Merge pull request 'Implement EXT-14: commands-as-tools dispatched through the command bus' (#114) from feature/ext-13-15-shared-runs into main
ci / vet-and-test (push) Successful in 43s

This commit was merged in pull request #114.
This commit is contained in:
2026-10-09 16:19:11 +00:00
6 changed files with 261 additions and 2 deletions
+11
View File
@@ -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)
}
+31
View File
@@ -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
+74
View File
@@ -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 }
+87
View File
@@ -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)
}
}
+57 -1
View File
@@ -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 }
// 实际会话沙箱为 <root>/<owner>/<conversationID>/;空值=未配置(工具执行报错)。
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, &params)
}
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