fix(agent,loop): 静音路径工具广告面与流式路径统一(P0-12,审计 §4.3)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 原缺陷:流式路径经 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:
AskaEth committed 2026-10-04 14:14:05 +08:00
1 parent 20d452a51a
commit 5c28ace70d
5 files changed
+303 -10

No files matched your search

+71 -9
View File
@@ -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
}