Files
AskaEth 5c28ace70d
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
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 过滤(未改)
2026-10-04 14:14:05 +08:00

151 lines
6.3 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package agent
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 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),
// 写成请求上的 PersonaID,模型路由也按它走。为什么角色不在本函数参数里:RunStep
// 由 loop 与 automations 调用,角色只存在于 loop 的会话环境 ctx 里,而 agent
// 不能反向 import loop;按角色分开注入的适配器视图(一角色一 agent,
// design/22 §4.2 方案 A)是唯一不依赖 ctx 的载体。
//
// 角色**已知**时才切到 RouteRequestForPersona:未知角色(普通 *model.Router 或
// 未绑角色的视图)保持原来的 RouteRequest —— 后者带 vision 模型的改写规则,
// RouteRequestForPersona 刻意不含(图像路由已归 vision.Router,裁决 14.4.1/14.4.5)。
//
// 工具面从 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(ctx),
}
if ps, ok := a.adapter.(model.PersonaScoped); ok {
req.PersonaID = ps.ScopedPersona()
}
if a.router != nil {
if pr, ok := a.router.(model.PersonaRouter); ok && req.PersonaID != "" {
if routeModel, _ := pr.RouteRequestForPersona(req.PersonaID, req); routeModel != "" {
req.Model = routeModel
}
return req
}
if routeModel, _ := a.router.RouteRequest(req); routeModel != "" {
req.Model = routeModel
}
}
return req
}
// assembleMessages returns messages with the agent's instructions prepended as a
// leading system message when instructions are non-empty, otherwise the input
// slice is returned unchanged.
func (a *Agent) assembleMessages(messages []model.Message) []model.Message {
if a.instructions == "" {
return messages
}
out := make([]model.Message, 0, len(messages)+1)
out = append(out, model.TextMessage(model.RoleSystem, a.instructions))
out = append(out, messages...)
return out
}
// temperature returns the temperature pointer to place in a Request. Zero maps
// to nil so the provider default applies.
func (a *Agent) temperature() *float64 {
if a.opts.Temperature == 0 {
return nil
}
t := a.opts.Temperature
return &t
}
// purpose returns the CallRecord purpose label, defaulting to defaultPurpose.
func (a *Agent) purpose() string {
if a.opts.Purpose == "" {
return defaultPurpose
}
return a.opts.Purpose
}
// 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
}
return a.tools.ToolSchemas()
}