Files
Agent-System-Backend/internal/tools/registry.go
T
weichen a21a3b57b4 Route workspace Word delivery through the shared renderer
Why: Task 10 replaces Backend's independent Markdown-to-Word production path with the reviewed canonical parser, embedded organization-default candidate, and Word adapter.
Evidence: Canonical parity, deterministic package, routing, safety, cancellation, comparison, persistence, affected-package race, full test, vet, build, and static build checks passed locally.
Impact: Ordinary workspace renders now persist Markdown, deterministic DOCX, manifest, and archive with hashes; controlled/project/review/approved routes fail closed to RDM, and legacy Go rendering is comparison-only.
Refs: Unified Document Renderer Task 10; Renderer d449b9f779f2f968f8ac556b89940a6c9aaf3fdc
2026-10-15 19:41:14 +00:00

199 lines
7.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package tools 实现统一工具注册表(4.8):内置、声明式 HTTP、插件工具同一形态;
// 来源标记(builtin/plugin/user)、启用开关、确认策略由画像三清单决定(runmgr 执行)。
package tools
import (
"encoding/json"
"fmt"
"sort"
"strings"
"sync"
"time"
core "agent-system-core"
)
// 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: "在项目知识与插件知识库中做全文检索,返回命中片段", 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 "(无命中)", 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)
}