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
ci / vet-and-test (push) Successful in 43s
This commit was merged in pull request #114.
This commit is contained in:
Vendored
+1
-1
Submodule deps/agent-command updated: cc1ad77498...5c6dd0c1ba
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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, ¶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
|
||||
|
||||
Reference in New Issue
Block a user