Why: with a native that populates CallID/Status/ErrorReason the probe path regrouped concurrent tool frames instead of forwarding them in arrival order; the tap contract requires one frame per event. Evidence: concurrent-identity test passes on the new pin and a legacy passthrough case was added (verify-u11br.json). Impact: probe enriches frames in place; order and grouping unchanged. Refs: feature/agent-stack-audit-remediation-b2 U11b repair
384 lines
14 KiB
Go
384 lines
14 KiB
Go
// Package native 把 agent-native 接成小队的 Executor 与 ModelClient。
|
||
package native
|
||
|
||
import (
|
||
"context"
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"fmt"
|
||
"reflect"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
nativexec "agent-native/adapters/native"
|
||
"agent-native/exec"
|
||
"agent-native/runtime"
|
||
"agent-native/runtime/checkpoint"
|
||
"agent-native/runtime/hooks"
|
||
"agent-native/runtime/llm"
|
||
"agent-native/runtime/mcp"
|
||
"agent-native/runtime/subagent"
|
||
"agent-native/runtime/tools"
|
||
"agent-squad/squad"
|
||
)
|
||
|
||
// Config 是组合根交给 agent-native 后端的装配。零值只做模型执行。
|
||
type Config struct {
|
||
Client *llm.Client
|
||
DefaultModel string
|
||
MaxTurns int
|
||
Timeout time.Duration
|
||
Search tools.SearchProvider
|
||
Exec *tools.ExecConfig
|
||
Fetch *tools.FetchConfig
|
||
MCP *mcp.Manager
|
||
SkillsRoots []string
|
||
Subagent subagent.Factory
|
||
Compact *nativexec.CompactConfig
|
||
HostTools []tools.Tool
|
||
OnEvent func(ev squad.ProcessEvent)
|
||
Stream llm.StreamFunc
|
||
MaxToolCalls int
|
||
BeforeTool func(call llm.ToolCall) error
|
||
AfterTool func(call llm.ToolCall, res llm.Message)
|
||
Stop func(last llm.Message, info runtime.StopInfo) runtime.StopDecision
|
||
Reminders func(turn int) []string
|
||
RepeatGuard bool
|
||
Hooks *hooks.Bus
|
||
Checkpoints *checkpoint.Log
|
||
CheckpointAnchor func() string
|
||
Trace func(name string, attrs map[string]string) func()
|
||
CodeOrchestration bool
|
||
Ask func(ctx context.Context, question string) (string, error)
|
||
// PermissionAsk 把内核钩子的 ask 决策转交宿主裁决(nil = 内核保持
|
||
// ask 按拒绝处理的旧行为)。经 nativexec.GateOptions 透传。
|
||
PermissionAsk func(ctx context.Context, call llm.ToolCall) runtime.AskVerdict
|
||
}
|
||
|
||
// Identity 返回塑造 Executor 的配置摘要(模型/客户端/工具/Exec 等)。
|
||
// RegisterFull 经 BackendResolver.Register 替换工厂时会失效缓存;角色侧
|
||
// BashPermission 等由 squad.ConfigIdentity 纳入 Resolve 缓存键,故 allow→deny
|
||
// 在下一次 Resolve 即生效,无需重启进程。
|
||
func (c Config) Identity() string {
|
||
var b strings.Builder
|
||
b.WriteString("model=")
|
||
b.WriteString(c.DefaultModel)
|
||
b.WriteString("\x00turns=")
|
||
b.WriteString(fmt.Sprintf("%d", c.MaxTurns))
|
||
b.WriteString("\x00timeout=")
|
||
b.WriteString(c.Timeout.String())
|
||
if c.Client != nil {
|
||
b.WriteString("\x00base=")
|
||
b.WriteString(c.Client.BaseURL)
|
||
b.WriteString("\x00key=")
|
||
b.WriteString(c.Client.APIKey)
|
||
}
|
||
if c.Exec != nil {
|
||
b.WriteString("\x00exec.bash=")
|
||
b.WriteString(c.Exec.Bash)
|
||
b.WriteString("\x00exec.python=")
|
||
b.WriteString(c.Exec.Python)
|
||
}
|
||
if c.Search != nil {
|
||
b.WriteString("\x00search=1")
|
||
}
|
||
if c.Fetch != nil {
|
||
b.WriteString("\x00fetch=1")
|
||
}
|
||
if c.MCP != nil {
|
||
b.WriteString("\x00mcp=1")
|
||
}
|
||
if len(c.SkillsRoots) > 0 {
|
||
b.WriteString("\x00skills=")
|
||
b.WriteString(strings.Join(c.SkillsRoots, ","))
|
||
}
|
||
if c.CodeOrchestration {
|
||
b.WriteString("\x00code=1")
|
||
}
|
||
sum := sha256.Sum256([]byte(b.String()))
|
||
return hex.EncodeToString(sum[:16])
|
||
}
|
||
|
||
// Register 把 "agent-native" 工厂注册进解析器。
|
||
func Register(r *squad.BackendResolver, client *llm.Client, defaultModel string, maxTurns int, timeout time.Duration) {
|
||
RegisterFull(r, Config{Client: client, DefaultModel: defaultModel, MaxTurns: maxTurns, Timeout: timeout})
|
||
}
|
||
|
||
// RegisterFull 注册带工具装配的 "agent-native" 工厂。
|
||
// 再次调用会替换工厂并失效该后端缓存(见 BackendResolver.Register);
|
||
// 配置摘要见 Config.Identity,角色侧见 squad.ConfigIdentity。
|
||
func RegisterFull(r *squad.BackendResolver, cfg Config) {
|
||
r.Register("agent-native", func(role squad.RoleSpec) (squad.Executor, error) {
|
||
opts, err := buildOptions(cfg, role)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
next := &bridge{next: nativexec.New(opts)}
|
||
if cfg.OnEvent == nil {
|
||
return next, nil
|
||
}
|
||
return &eventTap{next: next, on: cfg.OnEvent}, nil
|
||
})
|
||
}
|
||
|
||
func buildOptions(cfg Config, role squad.RoleSpec) (nativexec.Options, error) {
|
||
model := role.Model
|
||
if model == "" {
|
||
model = cfg.DefaultModel
|
||
}
|
||
if model == "" {
|
||
return nativexec.Options{}, fmt.Errorf("agent-native backend requires a model (role=%s)", role.Role)
|
||
}
|
||
opts := nativexec.Options{
|
||
ModelOptions: nativexec.ModelOptions{
|
||
Model: model, MaxTurns: cfg.MaxTurns, Timeout: cfg.Timeout, Stream: cfg.Stream,
|
||
},
|
||
ToolOptions: nativexec.ToolOptions{
|
||
Search: cfg.Search, Fetch: cfg.Fetch, MCP: cfg.MCP,
|
||
SkillsRoots: cfg.SkillsRoots, Subagent: cfg.Subagent, HostTools: cfg.HostTools,
|
||
Checkpoints: cfg.Checkpoints, CheckpointAnchor: cfg.CheckpointAnchor,
|
||
CodeOrchestration: cfg.CodeOrchestration,
|
||
},
|
||
ObserveOptions: nativexec.ObserveOptions{Trace: cfg.Trace},
|
||
SessionOptions: nativexec.SessionOptions{Compact: cfg.Compact, MaxToolCalls: cfg.MaxToolCalls},
|
||
GateOptions: nativexec.GateOptions{
|
||
BeforeTool: cfg.BeforeTool, AfterTool: cfg.AfterTool, Stop: cfg.Stop,
|
||
Reminders: cfg.Reminders, RepeatGuard: cfg.RepeatGuard, Hooks: cfg.Hooks,
|
||
Ask: cfg.Ask, PermissionAsk: cfg.PermissionAsk,
|
||
},
|
||
}
|
||
if cfg.Client != nil {
|
||
opts.APIBase, opts.APIKey = cfg.Client.BaseURL, cfg.Client.APIKey
|
||
}
|
||
if cfg.Exec != nil && (role.BashPermission == squad.BashPermissionAllow || role.BashPermission == squad.BashPermissionAsk) {
|
||
opts.Exec = cfg.Exec
|
||
}
|
||
return opts, nil
|
||
}
|
||
|
||
type bridge struct{ next exec.Backend }
|
||
|
||
func (b *bridge) Name() string { return b.next.Name() }
|
||
|
||
func (b *bridge) Execute(ctx context.Context, req squad.ExecRequest) (*squad.ExecResult, error) {
|
||
res, err := b.next.Execute(ctx, toNative(req))
|
||
if res == nil {
|
||
return nil, err
|
||
}
|
||
return fromNative(res), err
|
||
}
|
||
|
||
func toNative(req squad.ExecRequest) exec.Request {
|
||
out := exec.Request{
|
||
WorkDir: req.WorkDir, Prompt: req.Prompt, Instructions: req.Instructions,
|
||
Targets: req.Targets, Timeout: req.Timeout, ToolProfile: req.ToolProfile,
|
||
Model: req.Model, ToolAllowlist: req.ToolAllowlist, Skills: req.Skills,
|
||
SessionScope: req.SessionScope, ExecutionID: req.ExecutionID, Env: req.Env,
|
||
}
|
||
if req.Budget != nil {
|
||
out.Budget = &exec.Budget{
|
||
MaxPromptTokens: req.Budget.MaxPromptTokens, MaxCompletionTokens: req.Budget.MaxCompletionTokens,
|
||
MaxToolCalls: req.Budget.MaxToolCalls, Deadline: req.Budget.Deadline,
|
||
}
|
||
}
|
||
if req.OnEvent != nil {
|
||
prev := req.OnEvent
|
||
// 一帧一回调:同步转发,不缓存、不批量重发、不按调用分组——下游看到的
|
||
// 就是 native 的到达序。富化在原帧上就地完成(逐帧独立取值),
|
||
// 并行工具调用互不串字段,也就不会把交错帧重排成按调用分组。
|
||
out.OnEvent = func(ev exec.ProcessEvent) {
|
||
// CallID/Status/ErrorReason 是 native 侧的可选字段(U11a 落地):
|
||
// 缺席 = legacy,取零值透传,绝不伪造。
|
||
extras := probeNativeEventExtras(ev)
|
||
prev(squad.ProcessEvent{
|
||
Type: ev.Type, Text: ev.Text, Tool: ev.Tool, Detail: ev.Detail,
|
||
PromptTokens: ev.PromptTokens, CompletionTokens: ev.CompletionTokens,
|
||
LastPromptTokens: ev.LastPromptTokens, LastCompletionTokens: ev.LastCompletionTokens,
|
||
CacheReadTokens: ev.CacheReadTokens, CacheWriteTokens: ev.CacheWriteTokens,
|
||
CallID: extras.CallID, Status: extras.Status, ErrorReason: extras.ErrorReason,
|
||
})
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func fromNative(res *exec.Result) *squad.ExecResult {
|
||
if res == nil {
|
||
return nil
|
||
}
|
||
out := &squad.ExecResult{
|
||
Success: res.Success,
|
||
Patches: res.Patches,
|
||
Logs: res.Logs,
|
||
ErrorReason: res.ErrorReason,
|
||
Retryable: res.Retryable,
|
||
PromptTokens: res.PromptTokens,
|
||
CompletionTokens: res.CompletionTokens,
|
||
}
|
||
// F05/U3b(Squad 侧 contract):
|
||
// - native exec.Result 在后续 release 会新增 SideEffects、Canceled、Aborted 字段;
|
||
// 本地 deps gitlink 若尚未跟进,从 JSON 探测拿不到这些字段时回落到
|
||
// 「legacy reading: side effects = possible」,与既有调用方旧行为一致。
|
||
// - 在不影响 ExecResult 默认值的前提下,用 JSON 探测而非硬性修改 deps。
|
||
probe, err := probeNativeResultExtras(res)
|
||
if err == nil {
|
||
out.SideEffects = probe.SideEffects
|
||
out.Canceled = probe.Canceled
|
||
out.Aborted = probe.Aborted
|
||
}
|
||
return out
|
||
}
|
||
|
||
// nativeResultExtras 探测 native exec.Result 上的可选字段(结构化 JSON)。
|
||
// 缺省(字段不存在或为零值时)按 legacy reading:SideEffects 空串视为 "possible";
|
||
// 调用方侧 ExecResult.IsRetryableSideEffectReports() 已经处理该回退。
|
||
type nativeResultExtras struct {
|
||
SideEffects squad.ExecSideEffects `json:"side_effects,omitempty"`
|
||
Canceled bool `json:"canceled,omitempty"`
|
||
Aborted bool `json:"aborted,omitempty"`
|
||
}
|
||
|
||
// probeNativeResultExtras 通过 JSON 探测 native Result 上的可选字段。
|
||
// 探测出 cancel/abort/side_effects 任何一个就使用它们;探测失败或全部为零值视为
|
||
// legacy reading(SideEffects 空 = possible 兜底)。
|
||
func probeNativeResultExtras(res *exec.Result) (nativeResultExtras, error) {
|
||
if res == nil {
|
||
return nativeResultExtras{}, fmt.Errorf("nil result")
|
||
}
|
||
v := reflect.ValueOf(res).Elem()
|
||
if !v.IsValid() {
|
||
return nativeResultExtras{}, fmt.Errorf("invalid result")
|
||
}
|
||
// 用 JSON 做结构化探测:这一路兼容 deps 升级(增字段)、不增字段两种情形。
|
||
data, err := json.Marshal(v.Interface())
|
||
if err != nil {
|
||
return nativeResultExtras{}, err
|
||
}
|
||
var out nativeResultExtras
|
||
if err := json.Unmarshal(data, &out); err != nil {
|
||
return nativeResultExtras{}, err
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// nativeEventExtras 是 native exec.ProcessEvent 上的可选字段:工具调用身份与
|
||
// 真实结局(U11b,语义与 native 侧一致:空串 = legacy/未知)。
|
||
type nativeEventExtras struct {
|
||
CallID string
|
||
Status string
|
||
ErrorReason string
|
||
}
|
||
|
||
// probeNativeEventExtras 读取 native 过程帧上的 CallID/Status/ErrorReason。
|
||
//
|
||
// 与 probeNativeResultExtras 同一取法(可选字段缺席 = legacy 零值),但改用
|
||
// 反射按字段名读取而不是 JSON:本地 deps gitlink 上的 exec.ProcessEvent 尚未
|
||
// 跟进这三个字段(U11a 落地),硬写 ev.CallID 会编译不过;反射让同一份代码
|
||
// 两侧都成立——字段缺席取零值(既有行为逐字节不变),字段落地后无需改本文件
|
||
// 即生效,也不受 native 是否给字段加 JSON tag 影响。每次过程帧只做三次字段
|
||
// 定位,不构造中间对象;native 可并行发帧时读到的仍是该帧自己的值。
|
||
func probeNativeEventExtras(v any) nativeEventExtras {
|
||
rv := reflect.ValueOf(v)
|
||
for rv.Kind() == reflect.Pointer || rv.Kind() == reflect.Interface {
|
||
if rv.IsNil() {
|
||
return nativeEventExtras{}
|
||
}
|
||
rv = rv.Elem()
|
||
}
|
||
if rv.Kind() != reflect.Struct {
|
||
return nativeEventExtras{}
|
||
}
|
||
return nativeEventExtras{
|
||
CallID: optionalStringField(rv, "CallID"),
|
||
Status: optionalStringField(rv, "Status"),
|
||
ErrorReason: optionalStringField(rv, "ErrorReason"),
|
||
}
|
||
}
|
||
|
||
// optionalStringField 按字段名取字符串字段;缺席、嵌套或类型不符都返回 ""
|
||
// (legacy 零值)。只取直接字段,不做嵌套指针下钻。
|
||
func optionalStringField(rv reflect.Value, name string) string {
|
||
field, ok := rv.Type().FieldByName(name)
|
||
if !ok || len(field.Index) != 1 {
|
||
return ""
|
||
}
|
||
value := rv.Field(field.Index[0])
|
||
if value.Kind() != reflect.String {
|
||
return ""
|
||
}
|
||
return value.String()
|
||
}
|
||
|
||
// eventTap 在组合根侧再挂一层过程事件观察。帧按值传递(squad.ProcessEvent
|
||
// 只含值类型字段),两个下游各自拿到整帧副本,并行工具调用不会互相串字段;
|
||
// 回调保持同步,不排队、不缓存。
|
||
//
|
||
// 扇出以 mu 串行:「一帧 → 全部下游」是一个整体。这不是可有可无的细节:
|
||
// native 对并行工具调用是每调用一个 goroutine 发帧,两次帧到达可以真正并发。
|
||
// 不串行时,帧 F 交给组合根侧 sink 之后、在交给请求侧 sink 之前,可能被后到
|
||
// 的帧 G 抢先递完,于是同一串到达帧在两个下游呈现出不同的顺序——实测就是
|
||
// 组合根侧看到 [start(err), start(ok), end(ok), end(err)](到达序),而请求侧
|
||
// 看到 [start(ok), end(ok), start(err), end(err)](被按调用重排/分组)。
|
||
// 串行扇出后每个下游看到的都是同一串到达序:一个上游帧 → 每个下游恰好一次、
|
||
// 顺序不变,且事件富化(CallID/Status/ErrorReason)在桥里就地完成、不在这里
|
||
// 重新分帧。mu 只在本次扇出期间持有,不跨帧持有。
|
||
type eventTap struct {
|
||
next squad.Executor
|
||
on func(squad.ProcessEvent)
|
||
mu sync.Mutex
|
||
}
|
||
|
||
func (b *eventTap) Name() string { return b.next.Name() }
|
||
|
||
func (b *eventTap) Execute(ctx context.Context, req squad.ExecRequest) (*squad.ExecResult, error) {
|
||
req.OnEvent = b.hook(req.OnEvent)
|
||
return b.next.Execute(ctx, req)
|
||
}
|
||
|
||
// hook 把一帧扇出到组合根侧 sink 与请求侧 sink,顺序不变。
|
||
func (b *eventTap) hook(prev func(squad.ProcessEvent)) func(squad.ProcessEvent) {
|
||
return func(ev squad.ProcessEvent) {
|
||
b.mu.Lock()
|
||
defer b.mu.Unlock()
|
||
b.on(ev)
|
||
if prev != nil {
|
||
prev(ev)
|
||
}
|
||
}
|
||
}
|
||
|
||
// LLM 把 llm.Client 适配成 ModelClient。
|
||
type LLM struct{ Inner *llm.Client }
|
||
|
||
func (c LLM) Complete(ctx context.Context, turn squad.ModelTurn) (string, error) {
|
||
if c.Inner == nil {
|
||
return "", fmt.Errorf("llm planner: no model client")
|
||
}
|
||
msgs := []llm.Message{
|
||
{Role: llm.RoleSystem, Content: turn.System},
|
||
{Role: llm.RoleUser, Content: turn.User},
|
||
}
|
||
opts := llm.StreamOptions{MaxTokens: turn.MaxTokens, APIBase: turn.APIBase, APIKey: turn.APIKey}
|
||
var sb strings.Builder
|
||
for ev := range c.Inner.Stream(ctx, turn.Model, msgs, nil, opts) {
|
||
if ev.Kind == llm.KindError {
|
||
return "", fmt.Errorf("%s", ev.ErrorMessage)
|
||
}
|
||
if ev.Kind == llm.KindTextDelta {
|
||
sb.WriteString(ev.Text)
|
||
}
|
||
if ev.Kind == llm.KindMessageEnd {
|
||
if ev.Message.IsError {
|
||
return "", fmt.Errorf("assistant error: %s", ev.Message.Content)
|
||
}
|
||
if sb.Len() == 0 {
|
||
sb.WriteString(ev.Message.Content)
|
||
}
|
||
break
|
||
}
|
||
}
|
||
return sb.String(), nil
|
||
}
|