202 lines
7.5 KiB
Go
202 lines
7.5 KiB
Go
// Package tools 实现统一工具注册表(4.8):内置、声明式 HTTP、插件工具同一形态;
|
||
// 来源标记(builtin/plugin/user)、启用开关、确认策略由画像三清单决定(runmgr 执行)。
|
||
package tools
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"sort"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
core "agent-system-core"
|
||
)
|
||
|
||
// KnowledgeSearchNoHits 是 knowledge_search 项目资料无命中时的输出。
|
||
const KnowledgeSearchNoHits = "(无命中)"
|
||
|
||
// Executor 是无状态执行器:入参已校验,返回输出文本或错误。
|
||
type Executor func(rc ToolExecCtx, args json.RawMessage) (string, error)
|
||
|
||
// ToolExecCtx 供工具访问受控环境(当前项目/检索/工件写入)。
|
||
// PeerConversations/PeerConversationMessages 的门禁(项目开关 crossSession.read、
|
||
// 出站 allowCrossRead、入站 crossPrivate、租户/项目归属)在实现方 runmgr 内执行,
|
||
// fail-closed;本包只做参数校验与渲染(提案 2026-09-30-cross-session-reading)。
|
||
// 运行时还可实现 Context() 与 CheckExternalEffect():外部请求/进程/文件写入
|
||
// 使用前者取消在途操作,并在每个副作用紧前调用后者复核有效账号与资源权限。
|
||
type ToolExecCtx interface {
|
||
ProjectID() string
|
||
ConversationID() string
|
||
Retrieve(q core.RetrievalQuery) ([]core.RetrievalHit, error)
|
||
WriteArtifact(name, kind, mime string, content []byte) (core.Artifact, error)
|
||
PeerConversations() ([]core.Conversation, error)
|
||
PeerConversationMessages(conversationID string, limit int) ([]core.Message, error)
|
||
// ---- 预留能力(提案 2026-09-30-file-preview-and-reserved-capabilities)----
|
||
OwnerID() string // 租户(fs 沙箱隔离用)
|
||
WorkspaceRoot() string // 工作区根目录(空=未配置)
|
||
SettingBool(key string, def bool) bool // 生效设置读取(Hidden 工具门禁用)
|
||
}
|
||
|
||
type tool struct {
|
||
desc core.ToolDesc
|
||
exec Executor
|
||
}
|
||
|
||
type Registry struct {
|
||
mu sync.RWMutex
|
||
tools map[string]tool
|
||
}
|
||
|
||
func NewRegistry() *Registry { return &Registry{tools: map[string]tool{}} }
|
||
|
||
// Register 幂等注册(后注册覆盖同名;来源标记随之更新)。
|
||
// 声明式 HTTP 工具经 HTTPExecutor(spec, lookup) 绑定执行器。
|
||
func (r *Registry) Register(desc core.ToolDesc, exec Executor) error {
|
||
if desc.Name == "" {
|
||
return fmt.Errorf("tools: empty name")
|
||
}
|
||
if exec == nil {
|
||
return fmt.Errorf("tools: %s has no executor", desc.Name)
|
||
}
|
||
r.mu.Lock()
|
||
r.tools[desc.Name] = tool{desc: desc, exec: exec}
|
||
r.mu.Unlock()
|
||
return nil
|
||
}
|
||
|
||
func (r *Registry) UnregisterBySource(source core.SourceKind, pluginID string) {
|
||
r.mu.Lock()
|
||
for name, t := range r.tools {
|
||
if t.desc.Source == source && (pluginID == "" || t.desc.PluginID == pluginID) {
|
||
delete(r.tools, name)
|
||
}
|
||
}
|
||
r.mu.Unlock()
|
||
}
|
||
|
||
func (r *Registry) List() []core.ToolDesc {
|
||
r.mu.RLock()
|
||
defer r.mu.RUnlock()
|
||
out := make([]core.ToolDesc, 0, len(r.tools))
|
||
for _, t := range r.tools {
|
||
out = append(out, t.desc)
|
||
}
|
||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||
return out
|
||
}
|
||
|
||
func (r *Registry) Get(name string) (core.ToolDesc, Executor, bool) {
|
||
r.mu.RLock()
|
||
t, ok := r.tools[name]
|
||
r.mu.RUnlock()
|
||
return t.desc, t.exec, ok
|
||
}
|
||
|
||
// Execute 校验存在性并调用执行器;策略(allow/deny/confirm)与截断由 runmgr 负责。
|
||
// Hidden 工具(预留能力)在此再执行一道门禁:runtime.fsTools 未开即拒绝(fail-closed,
|
||
// 与可见性过滤双保险——列表过滤挡"看见",这里挡"直达调用")。
|
||
func (r *Registry) Execute(rc ToolExecCtx, call core.ToolCallRequest) core.ToolCallResult {
|
||
desc, exec, ok := r.Get(call.Name)
|
||
if !ok {
|
||
return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: fmt.Sprintf("tool %q not found (registry miss)", call.Name)}
|
||
}
|
||
if desc.Hidden && !rc.SettingBool("runtime.fsTools", false) {
|
||
return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: fmt.Sprintf("tool %q is a reserved capability (runtime.fsTools off)", call.Name)}
|
||
}
|
||
out, err := exec(rc, call.Arguments)
|
||
if err != nil {
|
||
return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: err.Error()}
|
||
}
|
||
return core.ToolCallResult{CallID: call.CallID, Status: "ok", Output: out, Truncated: false, Artifact: nil}
|
||
}
|
||
|
||
// ---- 内置最小工具集(一切可禁用:画像三清单控制)----
|
||
|
||
// RegisterBuiltin 注册骨架期内置工具:时间、会话历史检索、知识检索、读文件、写工件。
|
||
func (r *Registry) RegisterBuiltin() error {
|
||
if err := r.Register(core.ToolDesc{
|
||
Name: "time_now", Description: "获取当前时间(RFC3339)与时区", Source: core.SourceBuiltin, ReadOnly: true,
|
||
Parameters: json.RawMessage(`{"type":"object","properties":{}}`),
|
||
}, func(rc ToolExecCtx, args json.RawMessage) (string, error) {
|
||
return time.Now().Format(time.RFC3339), nil
|
||
}); err != nil {
|
||
return err
|
||
}
|
||
if err := r.Register(core.ToolDesc{
|
||
Name: "knowledge_search", Description: "在项目资料(项目知识与插件知识库)中做全文检索,返回命中片段;法规、标准、标准号(GB/YY/YY/T/IEC 等)或指导原则类问题优先用 regulations_search", Source: core.SourceBuiltin, ReadOnly: true,
|
||
Parameters: json.RawMessage(`{"type":"object","properties":{"query":{"type":"string","description":"检索词"}},"required":["query"]}`),
|
||
}, func(rc ToolExecCtx, args json.RawMessage) (string, error) {
|
||
var a struct {
|
||
Query string `json:"query"`
|
||
TopK int `json:"topK"`
|
||
}
|
||
if len(args) > 0 {
|
||
if err := json.Unmarshal(args, &a); err != nil {
|
||
return "", fmt.Errorf("knowledge_search: bad args: %w", err)
|
||
}
|
||
}
|
||
if a.Query == "" {
|
||
return "", fmt.Errorf("knowledge_search: query is required")
|
||
}
|
||
hits, err := rc.Retrieve(core.RetrievalQuery{ProjectID: rc.ProjectID(), Text: a.Query, TopK: a.TopK})
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
if len(hits) == 0 {
|
||
return KnowledgeSearchNoHits, nil
|
||
}
|
||
var sb strings.Builder
|
||
for i, h := range hits {
|
||
fmt.Fprintf(&sb, "[%d] %s(来源 %s,得分 %.2f)\n%s\n\n", i+1, h.Doc.Title, h.Doc.Source, h.Score, h.Doc.Chunk)
|
||
}
|
||
return sb.String(), nil
|
||
}); err != nil {
|
||
return err
|
||
}
|
||
if err := r.Register(core.ToolDesc{
|
||
Name: "write_artifact", Description: "把生成内容写入工件(文档/数据),返回工件 ID 与名称", Source: core.SourceBuiltin, Edits: true, Risk: "medium",
|
||
Parameters: json.RawMessage(`{"type":"object","properties":{"name":{"type":"string"},"kind":{"type":"string","enum":["document","data"]},"content":{"type":"string"}},"required":["name","content"]}`),
|
||
}, func(rc ToolExecCtx, args json.RawMessage) (string, error) {
|
||
var a struct {
|
||
Name string `json:"name"`
|
||
Kind string `json:"kind"`
|
||
Content string `json:"content"`
|
||
}
|
||
if err := json.Unmarshal(args, &a); err != nil {
|
||
return "", err
|
||
}
|
||
if a.Name == "" {
|
||
return "", fmt.Errorf("write_artifact: name required")
|
||
}
|
||
if err := rejectBinaryOfficeWrite(a.Name); err != nil {
|
||
return "", err
|
||
}
|
||
kind := a.Kind
|
||
if kind == "" {
|
||
kind = "document"
|
||
}
|
||
mime := "text/markdown"
|
||
if kind == "data" {
|
||
mime = "application/json"
|
||
}
|
||
art, err := rc.WriteArtifact(a.Name, kind, mime, []byte(a.Content))
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
return fmt.Sprintf("已写入工件 %s(%s,%d 字节)", art.Name, art.ID, art.Size), nil
|
||
}); err != nil {
|
||
return err
|
||
}
|
||
if err := registerDocumentRenderer(r); err != nil {
|
||
return err
|
||
}
|
||
if err := registerQualityReview(r); err != nil {
|
||
return err
|
||
}
|
||
if err := registerPeerTools(r); err != nil {
|
||
return err
|
||
}
|
||
return registerAskTools(r)
|
||
}
|