From 020926234eca0f38f06ae3372ad57deb1667315f Mon Sep 17 00:00:00 2001 From: weichen Date: Fri, 9 Oct 2026 16:14:57 +0000 Subject: [PATCH] Expose commands as in-run tools dispatched through the command bus (EXT-14) Why: RDM's capabilities are rdm.* commands; the agent run has no bridge to them, so the V0.1 end-to-end goal (full-conversation operation) and the agent-driven two-trees-one-net access are unreachable (Q4 option A). Evidence: SELF - red tests first: TestCommandToolsProjected and TestCommandToolDispatchesThroughBus failed on undefined symbols, then green; TestCommandToolsFiltering and TestCommandToolDispatchEnvelope cover projection filtering (risk default low, description falls back to title, unexposed commands absent) and the full dispatch receipt with Entrance=agent preserved through CanonicalEntrance; go test ./... 32/32 packages ok. Impact: agent-command pin cc1ad77 -> 5c6dd0c (library PRs #4/#5: optional ExposeAsTool field + "agent" entrance). cmdbus gains CommandTool view and Bus.CommandTools; runmgr gains SetCommandSource with projection into runCtx.Tools (dedup: registry tools win) and routing in ExecuteTool - model calls dispatch through the bus with Entrance=agent, Actor/Owner = conversation owner, ConversationID set; high-danger commands keep the inline-confirm-card flow via the bus; unregistered source = current registry behavior. Composition root wires a busAdapter. No pkg/extension change. Refs: proposal 2026-10-09-ext-g1-b1-b3 section 2; Agent-Command #4/#5 --- deps/agent-command | 2 +- internal/app/app.go | 11 ++++ internal/cmdbus/cmdbus.go | 31 ++++++++++ internal/cmdbus/command_tools_test.go | 74 +++++++++++++++++++++++ internal/runmgr/command_tools_test.go | 87 +++++++++++++++++++++++++++ internal/runmgr/runmgr.go | 58 +++++++++++++++++- 6 files changed, 261 insertions(+), 2 deletions(-) create mode 100644 internal/cmdbus/command_tools_test.go create mode 100644 internal/runmgr/command_tools_test.go diff --git a/deps/agent-command b/deps/agent-command index cc1ad77..5c6dd0c 160000 --- a/deps/agent-command +++ b/deps/agent-command @@ -1 +1 @@ -Subproject commit cc1ad7749816afe129f81bcf0732d0ca7c789d10 +Subproject commit 5c6dd0c1bad8c6842600887758a33b7d226441ec diff --git a/internal/app/app.go b/internal/app/app.go index 13417ad..ccb6477 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -393,6 +393,9 @@ func Run(opt Options) error { ok, err := authzSlot.Authorize(owner, projectID) return err == nil && ok }) + // EXT-14:命令即工具——带 exposeAsTool 的命令投影进回合工具清单,模型调用 + // 经命令总分发(Entrance=agent,同审计信封与高危内联确认卡)。 + mgr.SetCommandSource(busAdapter{bus: bus}) // EXT-11:公开受限 Host 工厂(下游恢复路径经 extension.NewScopedHost 重建句柄)。 wireScopedHostFactory(caps) applyCompiled(skillsRoot, bus, caps) @@ -703,3 +706,11 @@ func splitKeyPool(s string) []string { } return out } + +// busAdapter EXT-14:cmdbus.Bus 适配 runmgr.CommandSource(工具投影 + 命令分发)。 +type busAdapter struct{ bus *cmdbus.Bus } + +func (a busAdapter) CommandTools() []cmdbus.CommandTool { return a.bus.CommandTools() } +func (a busAdapter) DispatchCommand(ctx context.Context, req cmdbus.Request) cmdbus.Response { + return a.bus.Dispatch(ctx, req) +} diff --git a/internal/cmdbus/cmdbus.go b/internal/cmdbus/cmdbus.go index 7f1bc2d..2062d66 100644 --- a/internal/cmdbus/cmdbus.go +++ b/internal/cmdbus/cmdbus.go @@ -34,6 +34,37 @@ type CardPort interface { // Handler 宿主执行适配器:同步内置用例返回 payload;异步执行自行返回 accepted 回执。 type Handler func(ctx context.Context, req Request) (any, error) +// CommandTool EXT-14:带 exposeAsTool 声明的命令的工具投影视图(runmgr 消费)。 +// Risk 取 low|medium|high(工具权限声明口径);ID 即工具名(命令 id)。 +type CommandTool struct { + ID string + Title string + Description string + Params json.RawMessage + Risk string +} + +// CommandTools 全部带 ExposeAsTool 的已启用命令(EXT-14 工具投影源)。 +func (b *Bus) CommandTools() []CommandTool { + out := []CommandTool{} + for _, r := range b.reg.List() { + if !r.Enabled || r.Spec.ExposeAsTool == nil { + continue + } + risk := r.Spec.ExposeAsTool.Risk + if risk == "" { + risk = "low" + } + desc := r.Spec.Description + if desc == "" { + desc = r.Spec.Title + } + out = append(out, CommandTool{ID: r.Spec.ID, Title: r.Spec.Title, + Description: desc, Params: r.Spec.ParamsSchema, Risk: risk}) + } + return out +} + // Request 一次分发请求(入口归一后的统一形态)。 type Request struct { ID string diff --git a/internal/cmdbus/command_tools_test.go b/internal/cmdbus/command_tools_test.go new file mode 100644 index 0000000..1ba6cba --- /dev/null +++ b/internal/cmdbus/command_tools_test.go @@ -0,0 +1,74 @@ +package cmdbus + +// EXT-14:Bus.CommandTools 只投影带 ExposeAsTool 的已启用命令;risk 缺省 low、 +// description 缺省取 title;未暴露/未启用的命令不出现。 + +import ( + "context" + "encoding/json" + "testing" + "time" + + agentcommand "agent-command" +) + +func newToolBus(t *testing.T) *Bus { + t.Helper() + return New(nopAudit{}, nil, func() time.Time { return time.Now().UTC() }) +} + +func TestCommandToolsFiltering(t *testing.T) { + b := newToolBus(t) + specs := []json.RawMessage{ + []byte(`{"id":"rdm.trace","title":"追溯查询","description":"查网","paramsSchema":{"type":"object"},"exposeAsTool":{"risk":"medium"}}`), + []byte(`{"id":"rdm.baseline","title":"基线视图","exposeAsTool":{}}`), + []byte(`{"id":"rdm.hidden","title":"未暴露","description":"不出现"}`), + } + if err := b.RegisterPluginCommands(specs); err != nil { + t.Fatal(err) + } + tools := b.CommandTools() + byID := map[string]CommandTool{} + for _, ct := range tools { + byID[ct.ID] = ct + } + if len(byID) != 2 { + t.Fatalf("projected = %+v, want 2(rdm.trace + rdm.baseline)", tools) + } + if ct := byID["rdm.trace"]; ct.Risk != "medium" || ct.Description != "查网" || string(ct.Params) != `{"type":"object"}` { + t.Fatalf("rdm.trace = %+v", ct) + } + if ct := byID["rdm.baseline"]; ct.Risk != "low" || ct.Description != "基线视图" { + t.Fatalf("rdm.baseline = %+v(risk 缺省 low、description 缺省 title)", ct) + } + if _, ok := byID["rdm.hidden"]; ok { + t.Fatal("未声明 exposeAsTool 的命令不得投影") + } +} + +func TestCommandToolDispatchEnvelope(t *testing.T) { + b := newToolBus(t) + // 注册一个带处理器的暴露命令,经 Dispatch 分发验证完整回执链路。 + var spec agentcommand.CommandSpec + if err := json.Unmarshal([]byte(`{"id":"rdm.echo","title":"回声","exposeAsTool":{"risk":"low"}}`), &spec); err != nil { + t.Fatal(err) + } + if err := b.RegisterPluginCommand(spec, func(ctx context.Context, req Request) (any, error) { + return map[string]any{"echo": req.Params["v"], "actor": req.Actor, "entrance": req.Entrance}, nil + }); err != nil { + t.Fatal(err) + } + resp := b.Dispatch(context.Background(), Request{ID: "rdm.echo", Params: map[string]any{"v": 1}, + Entrance: "agent", Actor: "bob", Owner: "bob", ConversationID: "cnv-1"}) + if resp.Receipt.State != "committed" { + t.Fatalf("receipt = %+v (%s)", resp.Receipt, resp.Receipt.Reason) + } + m, _ := resp.Payload.(map[string]any) + if m["entrance"] != "agent" || m["actor"] != "bob" { + t.Fatalf("payload = %+v(Entrance=agent 原样入审计)", m) + } +} + +type nopAudit struct{} + +func (nopAudit) AppendAudit(owner string, env agentcommand.AuditEnvelope) error { return nil } diff --git a/internal/runmgr/command_tools_test.go b/internal/runmgr/command_tools_test.go new file mode 100644 index 0000000..11ed42d --- /dev/null +++ b/internal/runmgr/command_tools_test.go @@ -0,0 +1,87 @@ +package runmgr + +// EXT-14 红测(提案 2026-10-09-ext-g1-b1-b3 §2):带 exposeAsTool 的命令投影为 +// 回合内工具;模型调用经命令总分发(Entrance="agent"、同审计信封);不带字段的 +// 命令不出现;高危命令在对话内弹内联确认卡(HITL 收口不变)。 + +import ( + "context" + "testing" + + agentcommand "agent-command" + "agent-system-backend/internal/cmdbus" + "agent-system-backend/internal/tools" + core "agent-system-core" +) + +func agentcommandCommitted() agentcommand.ExecReceipt { + return agentcommand.ExecReceipt{State: agentcommand.StateCommitted} +} + +// fakeBusSource EXT-14 测试桩:命令清单 + 分发记录。 +type fakeBusSource struct { + cmds []cmdbus.CommandTool + lastReq cmdbus.Request + resp cmdbus.Response +} + +func (f *fakeBusSource) CommandTools() []cmdbus.CommandTool { return f.cmds } + +func (f *fakeBusSource) DispatchCommand(ctx context.Context, req cmdbus.Request) cmdbus.Response { + f.lastReq = req + return f.resp +} + +func TestCommandToolsProjected(t *testing.T) { + rc0, _ := newExecEnv(t) + m := rc0.mgr + src := &fakeBusSource{cmds: []cmdbus.CommandTool{ + {ID: "rdm.trace", Title: "追溯查询", Description: "查追溯网", Params: []byte(`{"type":"object"}`), Risk: "low"}, + }} + m.SetCommandSource(src) + rc := rc0 + tools := rc.Tools() + found := map[string]bool{} + for _, d := range tools { + found[d.Name] = true + } + if !found["rdm.trace"] { + t.Fatalf("exposeAsTool 命令未投影为工具: %+v", tools) + } + if found["rdm.hidden"] { + t.Fatal("命令源之外的工具不得出现") + } +} + +func TestCommandToolDispatchesThroughBus(t *testing.T) { + rc0, _ := newExecEnv(t) + m := rc0.mgr + src := &fakeBusSource{ + cmds: []cmdbus.CommandTool{{ID: "rdm.trace", Title: "追溯查询", Params: []byte(`{"type":"object"}`), Risk: "low"}}, + resp: cmdbus.Response{Receipt: agentcommandCommitted()}, + } + m.SetCommandSource(src) + rc := rc0 + + res := rc.ExecuteTool(core.ToolCallRequest{CallID: "c1", Name: "rdm.trace", Arguments: []byte(`{"q":"x"}`)}) + if res.Status != "ok" { + t.Fatalf("dispatch result = %s %s", res.Status, res.Output) + } + req := src.lastReq + if req.ID != "rdm.trace" || req.Entrance != "agent" || req.Actor != rc.run.Owner || req.Owner != rc.run.Owner || req.ConversationID != rc.run.ConversationID { + t.Fatalf("bus request = %+v(须 Entrance=agent、Actor/Owner=会话 owner、带会话)", req) + } + if req.Params["q"] != "x" { + t.Fatalf("params = %+v", req.Params) + } + // 未注册命令源时:调用按注册表口径报错(现状),不 panic。 + reg2 := tools.NewRegistry() + if err := reg2.RegisterBuiltin(); err != nil { + t.Fatal(err) + } + m2 := New(nil, nil, reg2, nil, nil, nil, nil, nil, nil) + rc2 := &runCtx{mgr: m2, run: core.Run{Owner: "bob"}, prof: core.AgentProfile{Tools: core.ToolPolicy{Allow: []string{"*"}}}} + if res := rc2.ExecuteTool(core.ToolCallRequest{CallID: "c2", Name: "rdm.trace", Arguments: []byte(`{}`)}); res.Status != "error" { + t.Fatalf("no source: %s(须 registry miss 报错)", res.Status) + } +} diff --git a/internal/runmgr/runmgr.go b/internal/runmgr/runmgr.go index d6214cf..3492cab 100644 --- a/internal/runmgr/runmgr.go +++ b/internal/runmgr/runmgr.go @@ -5,6 +5,7 @@ package runmgr import ( "context" + "encoding/json" "errors" "fmt" "log" @@ -23,6 +24,7 @@ import ( "agent-system-backend/internal/bus" "agent-system-backend/internal/cardsvc" + "agent-system-backend/internal/cmdbus" "agent-system-backend/internal/ingest" "agent-system-backend/internal/intentgate" "agent-system-backend/internal/profiles" @@ -57,7 +59,9 @@ type Manager struct { // sharedKnowledgeAllow EXT-13:回合内检索的共享授权谓词(请求 owner × 知识所属 // projectID);nil = 未注册(现状:仅本人两路,不触碰共享通道)。 sharedKnowledgeAllow func(owner string, projectID core.ID) bool - indexer core.DocumentIndexer // 可选索引入口(nil = store 整文档单块) + // commands EXT-14:命令即工具的投影与分发源(nil = 未接线,命令不进工具清单)。 + commands CommandSource + indexer core.DocumentIndexer // 可选索引入口(nil = store 整文档单块) // workspaceRoot fs 预留能力(Hidden 工具族)的沙箱根;空=未配置,工具执行时报错。 workspaceRoot string @@ -162,6 +166,9 @@ func (m *Manager) SetSharedKnowledgeAllow(fn func(owner string, projectID core.I m.sharedKnowledgeAllow = fn } +// SetCommandSource 注入命令即工具源(EXT-14;组合根以命令总线适配)。nil = 现状。 +func (m *Manager) SetCommandSource(src CommandSource) { m.commands = src } + // SetIndexer 注入检索索引入口(分块+全文+可选向量;nil = 整文档单块)。 func (m *Manager) SetIndexer(i core.DocumentIndexer) { m.indexer = i } @@ -169,6 +176,13 @@ func (m *Manager) SetIndexer(i core.DocumentIndexer) { m.indexer = i } // 实际会话沙箱为 ///;空值=未配置(工具执行报错)。 func (m *Manager) SetWorkspaceRoot(dir string) { m.workspaceRoot = dir } +// CommandSource EXT-14:命令总线暴露给回合执行的两个面——工具投影清单与 +// 经命令总的分发(同审计信封与 HITL 收口)。组合根以 cmdbus.Bus 适配注入。 +type CommandSource interface { + CommandTools() []cmdbus.CommandTool + DispatchCommand(ctx context.Context, req cmdbus.Request) cmdbus.Response +} + // sharedRetriever EXT-13:检索器具备共享通道(rag.Pipeline.RetrieveShared 同形)。 type sharedRetriever interface { RetrieveShared(ctx context.Context, owner core.ID, q core.RetrievalQuery, allow func(docOwner, projectID core.ID) bool) ([]core.RetrievalHit, error) @@ -367,6 +381,20 @@ func (c *runCtx) PermissionAsk(ctx context.Context, call agentllm.ToolCall) nrun func (c *runCtx) Tools() []core.ToolDesc { list := tools.FilterExecDisabled(c.mgr.registry.List()) + if c.mgr.commands != nil { + // EXT-14:exposeAsTool 命令投影为工具(去重:注册表同名工具优先)。 + seen := make(map[string]bool, len(list)) + for _, t := range list { + seen[t.Name] = true + } + for _, ct := range c.mgr.commands.CommandTools() { + if seen[ct.ID] { + continue + } + list = append(list, core.ToolDesc{Name: ct.ID, Description: ct.Description, + Parameters: ct.Params, Source: core.SourcePlugin, Risk: ct.Risk}) + } + } out := make([]core.ToolDesc, 0, len(list)) fs := c.eff.GetBool("runtime.fsTools", false) code := c.eff.GetBool("runtime.codeOrchestration", false) @@ -785,6 +813,34 @@ func (c *runCtx) ExecuteTool(call core.ToolCallRequest) core.ToolCallResult { return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: "用户取消了本次工具调用"} } } + // EXT-14:命令即工具——命中命令投影时经命令总分发(Entrance=agent、 + // Actor/Owner=会话 owner、带会话上下文),同审计信封与高危确认卡收口。 + if c.mgr.commands != nil { + for _, ct := range c.mgr.commands.CommandTools() { + if ct.ID != call.Name { + continue + } + var params map[string]any + if len(call.Arguments) > 0 { + _ = json.Unmarshal(call.Arguments, ¶ms) + } + resp := c.mgr.commands.DispatchCommand(c.ctx, cmdbus.Request{ + ID: call.Name, Params: params, Entrance: "agent", + Actor: c.run.Owner, Owner: c.run.Owner, ConversationID: c.run.ConversationID, + }) + out, _ := json.Marshal(resp.Payload) + state := string(resp.Receipt.State) + if resp.Card != nil && state == "accepted" { + return core.ToolCallResult{CallID: call.CallID, Status: "ok", + Output: "已创建确认卡,等待用户确认后执行:" + resp.Receipt.Reason} + } + if state != "committed" && state != "accepted" { + return core.ToolCallResult{CallID: call.CallID, Status: "error", + Output: "命令 " + call.Name + " " + state + ": " + resp.Receipt.Reason} + } + return core.ToolCallResult{CallID: call.CallID, Status: "ok", Output: string(out)} + } + } tc := &toolCtx{rc: c, callID: call.CallID} res := c.mgr.registry.Execute(tc, call) // 工具产物回填(交付物卡片根因修复):注册表工具执行期经 rc.WriteArtifact