From 5c28ace70d09aa9bda8a9ac03bd325896f279645 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 4 Oct 2026 14:14:05 +0800 Subject: [PATCH] =?UTF-8?q?fix(agent,loop):=20=E9=9D=99=E9=9F=B3=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=E5=B7=A5=E5=85=B7=E5=B9=BF=E5=91=8A=E9=9D=A2=E4=B8=8E?= =?UTF-8?q?=E6=B5=81=E5=BC=8F=E8=B7=AF=E5=BE=84=E7=BB=9F=E4=B8=80=EF=BC=88?= =?UTF-8?q?P0-12=EF=BC=8C=E5=AE=A1=E8=AE=A1=20=C2=A74.3=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 原缺陷:流式路径经 loop.toolSchemas(capacity 允许集 + restrict)过滤,而 agent 静音/内部路径 (RunStep,被 services/automations 等使用)广告 dispatcher **全量** ⇒ 两条路径不一致 - 修法(方案 1):**过滤实现只留一份**(loop.toolSchemas);l.agentStep 调用同一个函数,结果经 agent.WithToolSurface(ctx, …) 下传,agent 的 buildRequest 原样广告(取不到才回退全量,保持既有回退语义) · 按构造相同:同一次函数调用、同一份输入 ⇒ 不存在第二份可漂移的实现 · 未选「抽共享函数两侧各调一次」:loop 的过滤输入含 loop 私有会话环境 ctx key 与 capacity 判定器, agent 侧凑齐等于再造一套 ctx 管线,漂移面更大 - 复核:agent/loop 包测试绿;新增 parity 用例(owner 与 group 两组,静音 == 流式); 变异(撤掉下传行)→ parity 必红且精确复现「静音=全量 4 个」,已还原 - 附注:全仓仅剩 `services/automations` 的「无 Loop 回退」仍广告全量(没有循环就没有闸可言); 子代理 ToolWhitelist 是执行期否决、本不参与 schema 过滤(未改) --- internal/runtime/agent/request.go | 80 ++++++++++-- internal/runtime/agent/step.go | 7 +- internal/runtime/agent/tool_surface_test.go | 101 +++++++++++++++ internal/runtime/loop/step.go | 7 ++ .../runtime/loop/tool_surface_parity_test.go | 118 ++++++++++++++++++ 5 files changed, 303 insertions(+), 10 deletions(-) create mode 100644 internal/runtime/agent/tool_surface_test.go create mode 100644 internal/runtime/loop/tool_surface_parity_test.go diff --git a/internal/runtime/agent/request.go b/internal/runtime/agent/request.go index 0b6c31d..2fb2d9d 100644 --- a/internal/runtime/agent/request.go +++ b/internal/runtime/agent/request.go @@ -1,15 +1,64 @@ package agent -import "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" +import ( + "context" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" +) // defaultPurpose labels CallRecords when Options.Purpose is empty. const defaultPurpose = "agent" +// stepToolSurface 是本步「模型可见工具面」的 ctx 载体。用结构体(而不是 +// 裸切片)承载,使「已挂载但为空」与「未挂载」可区分:前者语义是「本步 +// 不广告任何工具」,后者是「无人过滤,按 dispatcher 全量广告」。 +type stepToolSurface struct { + tools []model.ToolSpec +} + +// stepToolSurfaceKey 是本步工具面的 context key。 +type stepToolSurfaceKey struct{} + +// WithToolSurface 把本步模型可见的工具面挂到 ctx 上。它由驱动 RunStep 的 +// 上层(运行时循环)在调用之前写入:工具面在**唯一一处**过滤逻辑里算出 +// (loop.toolSchemas:capacity 允许集 + design/11 §3 会话环境安全门), +// agent 只负责原样广告。 +// +// 为什么必须有这个载体(audit P0-12):agent 包不能反向 import loop,所以 +// 非流式/静音路径(RunStep,被 orchestration 子代理、automations、backthink +// 使用)过去只能自己拿 dispatcher 全量 schema,绕过循环的能力层与安全闸 —— +// 「模型只见允许集」在这条路径上不成立。下传同一份已过滤工具面(而不是让 +// agent 再实现一遍过滤)使两条路径的广告面**按构造相同**,不存在第二份 +// 实现可以漂移。 +// +// tools 允许为 nil/空:语义是「本步什么工具都不广告」。 +func WithToolSurface(ctx context.Context, tools []model.ToolSpec) context.Context { + if ctx == nil { + return nil + } + return context.WithValue(ctx, stepToolSurfaceKey{}, stepToolSurface{tools: tools}) +} + +// ToolSurfaceFrom 返回挂到 ctx 上的本步工具面。ok=false 表示未挂载 +// (agent 被独立使用、没有循环在过滤),此时按 dispatcher 全量广告 —— +// 与挂载前的行为逐字节一致。它是 WithToolSurface 的读侧。 +func ToolSurfaceFrom(ctx context.Context) ([]model.ToolSpec, bool) { + if ctx == nil { + return nil, false + } + s, ok := ctx.Value(stepToolSurfaceKey{}).(stepToolSurface) + if !ok { + return nil, false + } + return s.tools, true +} + // buildRequest assembles a model.Request for one step: the agent's instructions // prepended as a leading system message, plus the conversation messages, the -// configured model/temperature/max-tokens/purpose and the registered tool -// schemas. When a router is injected it overrides the model from the request -// contents, which is how vision requests are routed to the vision model. +// configured model/temperature/max-tokens/purpose and the tool schemas +// advertised for this step (see WithToolSurface). When a router is injected it +// overrides the model from the request contents, which is how vision requests +// are routed to the vision model. // // 本路径与流式主路径(loop.buildRequest)共用同一处 persona 钩子(裁决 03.4.1): // 角色由**适配器视图**携带(model.PersonaScoped,见 model/persona_stack.go), @@ -21,14 +70,18 @@ const defaultPurpose = "agent" // 角色**已知**时才切到 RouteRequestForPersona:未知角色(普通 *model.Router 或 // 未绑角色的视图)保持原来的 RouteRequest —— 后者带 vision 模型的改写规则, // RouteRequestForPersona 刻意不含(图像路由已归 vision.Router,裁决 14.4.1/14.4.5)。 -func (a *Agent) buildRequest(messages []model.Message) model.Request { +// +// 工具面从 ctx 取(见 WithToolSurface):驱动本步的循环已按 capacity 允许集与 +// design/11 §3 会话环境安全门过滤好并下传,本函数不再自行过滤、也不自行 +// 放宽 —— 流式路径与非流式路径因此广告同一份工具面(audit P0-12)。 +func (a *Agent) buildRequest(ctx context.Context, messages []model.Message) model.Request { req := model.Request{ Model: a.opts.Model, Messages: a.assembleMessages(messages), Temperature: a.temperature(), MaxTokens: a.opts.MaxTokens, Purpose: a.purpose(), - Tools: a.toolSchemas(), + Tools: a.toolSchemas(ctx), } if ps, ok := a.adapter.(model.PersonaScoped); ok { req.PersonaID = ps.ScopedPersona() @@ -78,9 +131,18 @@ func (a *Agent) purpose() string { return a.opts.Purpose } -// toolSchemas returns the registered tool schemas advertised to the model, or -// nil when no dispatcher is configured. -func (a *Agent) toolSchemas() []model.ToolSpec { +// toolSchemas 返回本步向模型广告的工具 schema。ctx 上带着本步工具面 +// (WithToolSurface,由循环按 capacity 允许集 + 会话环境安全门过滤好)时 +// 原样返回它 —— nil/空即「本步不广告任何工具」;未挂载时回退到 dispatcher +// 注册的全量 schema(agent 被独立使用、没有循环在过滤时的既有行为, +// dispatcher 为 nil 则为 nil)。 +// +// 注意回退分支**不是**过滤逻辑的第二份实现:它只是「没有循环驱动」这一 +// 情形下的既有行为,与流式路径的差别仅在于此路径本来就不经循环组装请求。 +func (a *Agent) toolSchemas(ctx context.Context) []model.ToolSpec { + if tools, ok := ToolSurfaceFrom(ctx); ok { + return tools + } if a.tools == nil { return nil } diff --git a/internal/runtime/agent/step.go b/internal/runtime/agent/step.go index 2cc1b27..f67270a 100644 --- a/internal/runtime/agent/step.go +++ b/internal/runtime/agent/step.go @@ -82,8 +82,13 @@ const ( // 输出侧紧接模型返回裁决(裁决 13.4.1 的「输出侧异步」由流式主路径 // loop.streamStep 承担——agent.RunStep 只服务静音/非流式内部流程,不落在 // 主会话关键路径上,且它的裁决是 StepResult 的一部分)。 +// +// 工具面(Tools)不由本方法选择:驱动本步的循环经 WithToolSurface 把过滤好的 +// 本步工具面挂在 ctx 上(capacity 允许集 + design/11 §3 环境安全门),本方法 +// 只负责原样广告,使静音/非流式路径与流式路径的广告面一致(audit P0-12)。 +// 独立使用(ctx 未挂载工具面)时按 dispatcher 全量广告,行为与从前一致。 func (a *Agent) RunStep(ctx context.Context, sessionID string, messages []model.Message) (*StepResult, error) { - req := a.buildRequest(messages) + req := a.buildRequest(ctx, messages) if a.guards != nil { gctx, gspan := trace.ChildSpan(ctx, guardrailSpanInput, trace.SpanKindGuardrail) diff --git a/internal/runtime/agent/tool_surface_test.go b/internal/runtime/agent/tool_surface_test.go new file mode 100644 index 0000000..1314020 --- /dev/null +++ b/internal/runtime/agent/tool_surface_test.go @@ -0,0 +1,101 @@ +package agent + +import ( + "context" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" +) + +// TestRunStep_InjectedToolSurfaceIsAdvertised 钉住审计 P0-12 的下传面: +// ctx 上带着(由驱动本步的循环按 capacity 允许集 + 会话环境安全门过滤好的) +// 本步工具面时,RunStep 必须**原样**广告它,而不是自己去取 dispatcher 全量 —— +// 否则非流式/静音路径的广告面又会绕过能力层与安全闸。 +func TestRunStep_InjectedToolSurfaceIsAdvertised(t *testing.T) { + t.Parallel() + td := dispatcherWith(t, + mockTool{name: "terminal", desc: "terminal"}, + mockTool{name: "file", desc: "file"}, + mockTool{name: "web-search", desc: "web"}, + ) + ad := &mockAdapter{resp: model.Response{Content: "hi"}} + a, err := New("instr", td, nil, WithModel(ad, nil)) + if err != nil { + t.Fatalf("New: %v", err) + } + + surface := []model.ToolSpec{{Name: "file", Description: "已过滤后的本步工具面"}} + ctx := WithToolSurface(context.Background(), surface) + if _, err := a.RunStep(ctx, "s", []model.Message{model.TextMessage(model.RoleUser, "hi")}); err != nil { + t.Fatalf("RunStep: %v", err) + } + + if len(ad.gotReq.Tools) != 1 { + t.Fatalf("req.Tools = %v, want exactly the injected surface", ad.gotReq.Tools) + } + got := ad.gotReq.Tools[0] + if got.Name != "file" || got.Description != surface[0].Description { + t.Fatalf("req.Tools[0] = %+v, want the injected spec verbatim", got) + } +} + +// TestRunStep_EmptyToolSurfaceAdvertisesNothing 钉住「已挂载但为空」的语义: +// 循环算出本步无任何可见工具时(例如全部被安全门收掉),agent 不得回退到 +// dispatcher 全量 —— 空面就是空面。 +func TestRunStep_EmptyToolSurfaceAdvertisesNothing(t *testing.T) { + t.Parallel() + td := dispatcherWith(t, mockTool{name: "terminal", desc: "terminal"}) + ad := &mockAdapter{resp: model.Response{Content: "hi"}} + a, err := New("instr", td, nil, WithModel(ad, nil)) + if err != nil { + t.Fatalf("New: %v", err) + } + + ctx := WithToolSurface(context.Background(), nil) + if _, err := a.RunStep(ctx, "s", []model.Message{model.TextMessage(model.RoleUser, "hi")}); err != nil { + t.Fatalf("RunStep: %v", err) + } + if len(ad.gotReq.Tools) != 0 { + t.Fatalf("req.Tools = %v, want none (empty surface must not fall back to the dispatcher)", ad.gotReq.Tools) + } +} + +// TestRunStep_NoToolSurfaceKeepsDispatcherFace 钉住回退分支未改变既有行为: +// ctx 未挂载工具面(agent 被独立使用、没有循环在过滤)时,仍按 dispatcher +// 注册的全量广告 —— 与接上下传面之前逐字节一致。 +func TestRunStep_NoToolSurfaceKeepsDispatcherFace(t *testing.T) { + t.Parallel() + td := dispatcherWith(t, + mockTool{name: "terminal", desc: "terminal"}, + mockTool{name: "file", desc: "file"}, + ) + ad := &mockAdapter{resp: model.Response{Content: "hi"}} + a, err := New("instr", td, nil, WithModel(ad, nil)) + if err != nil { + t.Fatalf("New: %v", err) + } + + if _, err := a.RunStep(context.Background(), "s", []model.Message{model.TextMessage(model.RoleUser, "hi")}); err != nil { + t.Fatalf("RunStep: %v", err) + } + if len(ad.gotReq.Tools) != 2 { + t.Fatalf("req.Tools = %v, want the two registered tools", ad.gotReq.Tools) + } +} + +// TestToolSurfaceFrom_AbsentVersusEmpty 钉住读侧对「未挂载」与「挂载了空面」 +// 的区分:前者 ok=false(回退全量),后者 ok=true 且面为空(不广告任何工具)。 +func TestToolSurfaceFrom_AbsentVersusEmpty(t *testing.T) { + t.Parallel() + if _, ok := ToolSurfaceFrom(context.Background()); ok { + t.Fatal("ToolSurfaceFrom(background) ok = true, want false") + } + if _, ok := ToolSurfaceFrom(WithToolSurface(context.Background(), nil)); !ok { + t.Fatal("ToolSurfaceFrom(injected empty) ok = false, want true") + } + ctx := WithToolSurface(context.Background(), []model.ToolSpec{{Name: "file"}}) + got, ok := ToolSurfaceFrom(ctx) + if !ok || len(got) != 1 || got[0].Name != "file" { + t.Fatalf("ToolSurfaceFrom = %v ok=%v, want the mounted surface", got, ok) + } +} diff --git a/internal/runtime/loop/step.go b/internal/runtime/loop/step.go index e09510f..9437044 100644 --- a/internal/runtime/loop/step.go +++ b/internal/runtime/loop/step.go @@ -64,7 +64,14 @@ func (l *Loop) step(ctx context.Context, sessionID string, messages []model.Mess // agentStep 把非流式步委托给该 turn 的 agent,由后者运行输入 // 与输出护栏、调用模型并解码所有工具调用。 +// +// 工具面与流式路径**同源**(audit P0-12):两条路径都取这一处过滤结果 +// (capacity 允许集 + design/11 §3 会话环境安全门),区别只在于流式路径在 +// 循环里组装请求时直接把它写进 model.Request,而本路径经 ctx 下传给 agent +// 广告。agent 侧因此不需要、也不允许自行过滤,两份实现无从漂移;静音内部 +// 流程(backthink、子代理)与前端聊天看到的是同一份工具面。 func (l *Loop) agentStep(ctx context.Context, sessionID string, messages []model.Message) (*stepOutcome, error) { + ctx = agent.WithToolSurface(ctx, l.toolSchemas(ctx, messages)) sr, err := l.agentFor(ctx).RunStep(ctx, sessionID, messages) if err != nil { return nil, fmt.Errorf("loop: agent step: %w", err) diff --git a/internal/runtime/loop/tool_surface_parity_test.go b/internal/runtime/loop/tool_surface_parity_test.go new file mode 100644 index 0000000..648c828 --- /dev/null +++ b/internal/runtime/loop/tool_surface_parity_test.go @@ -0,0 +1,118 @@ +package loop + +import ( + "context" + "sort" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry" + "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/capacity" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" +) + +// TestToolSurface_MutedPathMatchesStreamingPath 钉住审计 P0-12 的修复结论: +// 同一 persona/actor 条件下,静音/非流式路径(step → agentStep → RunStep) +// 向模型广告的工具面必须与流式主路径**逐条一致** —— capacity 允许集与会话 +// 环境安全门(design/11 §3)都在两条路径上同样生效,agent 不再自行取 +// dispatcher 全量(否则「模型只见允许集」在这条路径上不成立)。 +// +// 两条路径由同一个循环、同一份会话环境(含 persona)、同一条用户消息驱动, +// 唯一区别是 isMutedBroadcast —— 静音即内部自主流程(backthink / 子代理), +// 它们同样要按同一份允许集广告,只是不做 UI 广播。 +func TestToolSurface_MutedPathMatchesStreamingPath(t *testing.T) { + t.Parallel() + cases := []struct { + name string + env SessionEnv + // want 是两个过滤层(capacity 静息允许集 ∩ 会话环境安全门) + // 叠加后的期望广告面。 + want []string + }{ + { + // 主人私聊:安全门全放行,只剩 capacity 静息允许集 + // (terminal + file;web-search/json 被收掉)。 + name: "owner", + env: OwnerEnv(), + want: []string{"file", "terminal"}, + }, + { + // 群聊:capacity 静息允许集上再叠一层安全门 —— 只有带 + // AllowInGroup 标记的 file 留下,terminal 被身份闸收掉。 + name: "group", + env: SessionEnv{ActorEnv: restrict.ActorGroupMember, MemWritable: false, PersonaID: "cyrene"}, + want: []string{"file"}, + }, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + reg, d := newPolicyDispatcher(t, []string{"terminal", "file", "web-search", "json"}) + // 只有 file 对群聊开放;其余保持默认关闭(fail-closed)。 + mustSetPolicy(t, reg, "file", registry.ToolPolicy{AllowInGroup: true}) + + // 闲聊消息:capacity 判为静息,允许集 = terminal + file。 + // 这条输入对两个过滤层都有判别力。 + msgs := []model.Message{model.TextMessage(model.RoleUser, "你好呀,抱抱你")} + + ad := &mockAdapter{ + stream: []mgResp{{resp: model.Response{Content: "陪你"}}}, + chat: []mgResp{{resp: model.Response{Content: "陪你"}}}, + } + a := newTestAgent(t, ad, d) + l := New(Options{ + Agent: a, + MaxRounds: 8, + OnStream: func(model.StreamEvent) {}, + Capacity: capacity.New(capacity.Config{Enabled: true, MinConfidence: 0.6}), + }) + + // 流式主路径(未静音):请求在循环里组装,工具面直接写进 + // model.Request,记录在 streamReqs 上。 + if _, err := l.Run(WithSessionEnv(context.Background(), tc.env), "sess", msgs); err != nil { + t.Fatalf("streaming Run: %v", err) + } + // 静音路径:同一循环、同一会话环境,只是抑制广播; + // 请求由 agent.RunStep 组装,记录在 reqs 上。 + mutedCtx := WithMutedBroadcast(WithSessionEnv(context.Background(), tc.env)) + if _, err := l.Run(mutedCtx, "sess", msgs); err != nil { + t.Fatalf("muted Run: %v", err) + } + + if len(ad.streamReqs) == 0 || len(ad.reqs) == 0 { + t.Fatalf("captured requests: stream=%d chat=%d, want both paths exercised", + len(ad.streamReqs), len(ad.reqs)) + } + streamFace := sortedToolNames(ad.streamReqs[0].Tools) + mutedFace := sortedToolNames(ad.reqs[0].Tools) + if !sameToolFace(streamFace, mutedFace) { + t.Fatalf("静音路径广告面 %v != 流式路径广告面 %v(audit P0-12 回归)", mutedFace, streamFace) + } + if !sameToolFace(streamFace, tc.want) { + t.Fatalf("广告面 = %v, want %v(capacity 允许集 + 会话环境安全门)", streamFace, tc.want) + } + }) + } +} + +// sortedToolNames 把工具名按字典序排列,使两条路径的广告面可以做 +// 与注册顺序无关的逐条比较(registry.List 遍历 map,顺序不保留)。 +func sortedToolNames(specs []model.ToolSpec) []string { + names := toolNames(specs) + sort.Strings(names) + return names +} + +// sameToolFace 报告两份工具名列表逐条相同。 +func sameToolFace(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +}