fix(agent,loop): 静音路径工具广告面与流式路径统一(P0-12,审计 §4.3)
- 原缺陷:流式路径经 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 过滤(未改)
This commit is contained in:
1 parent
20d452a51a
commit
5c28ace70d
5 files changed
+303
-10
No files matched your search
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user