Why: F14 squad half - the event port dropped CallID/Status/ErrorReason, so downstream hosts paired by tool name and showed failures as success. Evidence: bridge and process-event tests pass (verify-u11b.json); taps/wrappers preserve the fields; concurrent callbacks copy per-event. Impact: new omitempty fields only; legacy events byte-identical. Refs: feature/agent-stack-audit-remediation-b2 U11b
210 lines
7.6 KiB
Go
210 lines
7.6 KiB
Go
package squad
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// Executor 是小队核心对一次智能体执行的端口。实现放在适配器模块。
|
||
type Executor interface {
|
||
Name() string
|
||
Execute(ctx context.Context, req ExecRequest) (*ExecResult, error)
|
||
}
|
||
|
||
// ModelClient 是规划器对一次模型补全的端口。实现放在适配器模块。
|
||
type ModelClient interface {
|
||
Complete(ctx context.Context, turn ModelTurn) (string, error)
|
||
}
|
||
|
||
// ModelTurn 是一次规划补全的输入。适配器负责把它变成供应商协议。
|
||
type ModelTurn struct {
|
||
Model string
|
||
System string
|
||
User string
|
||
MaxTokens int
|
||
APIBase string
|
||
APIKey string
|
||
}
|
||
|
||
const (
|
||
ToolProfileWorkspace = "workspace"
|
||
ToolProfileReasoning = "reasoning"
|
||
)
|
||
|
||
// ExecRequest 是一次步骤执行的输入。字段与 native exec.Request 的 JSON 对齐,类型不共享。
|
||
type ExecRequest struct {
|
||
WorkDir string `json:"work_dir"`
|
||
Prompt string `json:"prompt"`
|
||
Instructions string `json:"instructions"`
|
||
Targets []string `json:"targets"`
|
||
Timeout time.Duration `json:"timeout"`
|
||
ToolProfile string `json:"tool_profile,omitempty"`
|
||
OnEvent func(ProcessEvent) `json:"-"`
|
||
Model string `json:"model,omitempty"`
|
||
ToolAllowlist []string `json:"tool_allowlist,omitempty"`
|
||
Skills []string `json:"skills,omitempty"`
|
||
Budget *ExecBudget `json:"budget,omitempty"`
|
||
SessionScope string `json:"session_scope,omitempty"`
|
||
ExecutionID string `json:"execution_id,omitempty"`
|
||
Env map[string]string `json:"env,omitempty"`
|
||
}
|
||
|
||
// ExecBudget 是一次步骤执行的共享预算。
|
||
type ExecBudget struct {
|
||
MaxPromptTokens int `json:"max_prompt_tokens,omitempty"`
|
||
MaxCompletionTokens int `json:"max_completion_tokens,omitempty"`
|
||
MaxToolCalls int `json:"max_tool_calls,omitempty"`
|
||
Deadline time.Time `json:"deadline,omitempty"`
|
||
}
|
||
|
||
// ProcessEvent 是过程帧。
|
||
type ProcessEvent struct {
|
||
Type string
|
||
Text string
|
||
Tool string
|
||
Detail string
|
||
PromptTokens int
|
||
CompletionTokens int
|
||
// 以下在 Type=usage 时填写(N3 WP6:上一请求与缓存命中拆分)。
|
||
LastPromptTokens int
|
||
LastCompletionTokens int
|
||
CacheReadTokens int
|
||
CacheWriteTokens int
|
||
// CallID 是本次工具调用的身份(与 native exec.ProcessEvent.CallID 同语义):
|
||
// tool_call_delta / tool_execution_start / tool_execution_end 携带同一 ID,
|
||
// 消费方按 ID 配对而不是按到达顺序——并行工具调用会交错到达。
|
||
// 空串 = 上游未填充(legacy 版本),不是「一次无名调用」。
|
||
CallID string `json:"call_id,omitempty"`
|
||
// Status 是 tool_execution_end 的真实结局,取值见 ToolStatus*;
|
||
// 空串 = legacy/未知,不据此判定失败(与既有行为一致)。
|
||
Status string `json:"status,omitempty"`
|
||
// ErrorReason 是 Status=error 时的真实失败原因原文(与 native 同语义);
|
||
// 空串 = 无失败或上游未填充。
|
||
ErrorReason string `json:"error_reason,omitempty"`
|
||
}
|
||
|
||
// ProcessEvent.Status 的取值域(与 native 侧一致;空串 = legacy/未知)。
|
||
const (
|
||
ToolStatusOK = "ok"
|
||
ToolStatusError = "error"
|
||
ToolStatusDenied = "denied"
|
||
ToolStatusCanceled = "canceled"
|
||
)
|
||
|
||
// ExecResult 是一次步骤执行的终态。
|
||
//
|
||
// 语义契约(F05/U3b):Success=true 才是经过验证的成功;仅在交付物齐全且
|
||
// ExecutionID 复用前提下成立。仅凭已物化的文件存在不能把失败翻成成功。
|
||
// 后端可以选择性填写以下可选字段;缺省 = 旧的读取行为。
|
||
type ExecResult struct {
|
||
Success bool `json:"success"`
|
||
Patches map[string][]byte `json:"patches"`
|
||
Logs string `json:"logs"`
|
||
ErrorReason string `json:"error_reason"`
|
||
Retryable bool `json:"retryable,omitempty"`
|
||
PromptTokens int `json:"prompt_tokens,omitempty"`
|
||
CompletionTokens int `json:"completion_tokens,omitempty"`
|
||
// Canceled 为 true 时表示执行是被请求/中断终止的,不是业务失败。
|
||
// F05/KTD4:取消结果不应被 retry 改判为可重试;这是用户/系统显式意图。
|
||
Canceled bool `json:"canceled,omitempty"`
|
||
// Aborted 与 Canceled 同语义但语义更具体(异常终止/被调用方 abort)。
|
||
Aborted bool `json:"aborted,omitempty"`
|
||
// SideEffects 报告执行是否可能产生过外部动作。空值 = "possible"(旧行为)。
|
||
// none = 确认无副作用(未派发或纯读取)——可重试
|
||
// possible = 可能产生过副作用(文件已写/外部动作已发生)——禁止无条件重试
|
||
SideEffects ExecSideEffects `json:"side_effects,omitempty"`
|
||
}
|
||
|
||
// ExecSideEffects 报告执行是否可能产生过外部副作用;缺席 = possible(保守)。
|
||
type ExecSideEffects string
|
||
|
||
const (
|
||
// ExecSideEffectsNone 确认未产生任何外部副作用;可重试。
|
||
ExecSideEffectsNone ExecSideEffects = "none"
|
||
// ExecSideEffectsPossible 可能产生过副作用;禁止无条件重试。
|
||
ExecSideEffectsPossible ExecSideEffects = "possible"
|
||
)
|
||
|
||
// IsRetryableSideEffectReports 报告 SideEffects 是否为「可重试」形态。
|
||
// 默认 possible 视为不可重试(保守):调用方必须显式声明 none 才放开重试。
|
||
func (r *ExecResult) IsRetryableSideEffectReports() bool {
|
||
return r != nil && r.SideEffects == ExecSideEffectsNone
|
||
}
|
||
|
||
// IsCanceled 报告结果是否被取消/中止(不是业务失败)。
|
||
func (r *ExecResult) IsCanceled() bool {
|
||
return r != nil && (r.Canceled || r.Aborted)
|
||
}
|
||
|
||
// StubExecutor 是离线桩,测试与未接模型时使用。
|
||
type StubExecutor struct{}
|
||
|
||
func (s *StubExecutor) Name() string { return "stub" }
|
||
|
||
func (s *StubExecutor) Execute(ctx context.Context, req ExecRequest) (*ExecResult, error) {
|
||
patches := make(map[string][]byte)
|
||
for _, target := range req.Targets {
|
||
outPath := filepath.Join(req.WorkDir, target)
|
||
_ = os.MkdirAll(filepath.Dir(outPath), 0o755)
|
||
var content []byte
|
||
if strings.HasSuffix(target, ".json") {
|
||
if existing, err := os.ReadFile(outPath); err == nil {
|
||
var dummy any
|
||
if json.Unmarshal(existing, &dummy) == nil {
|
||
content = existing
|
||
}
|
||
}
|
||
if len(content) == 0 {
|
||
content = []byte(`{"status":"mock_ok"}`)
|
||
}
|
||
} else {
|
||
content = []byte(fmt.Sprintf("# 模拟生成结果\n\n- 目标文件: %s\n- 提示词摘要: %s\n", target, req.Prompt))
|
||
}
|
||
_ = os.WriteFile(outPath, content, 0o644)
|
||
patches[target] = content
|
||
}
|
||
return &ExecResult{Success: true, Patches: patches, Logs: "Stub agent execution completed."}, nil
|
||
}
|
||
|
||
// LimitingExecutor 用有界信号量限制底层执行并发。
|
||
type LimitingExecutor struct {
|
||
inner Executor
|
||
sem chan struct{}
|
||
}
|
||
|
||
// WrapWithConcurrencyLimit 包装执行器;max<=0 时默认 8。
|
||
func WrapWithConcurrencyLimit(inner Executor, max int) Executor {
|
||
if inner == nil {
|
||
return nil
|
||
}
|
||
if max <= 0 {
|
||
max = 8
|
||
}
|
||
return &LimitingExecutor{inner: inner, sem: make(chan struct{}, max)}
|
||
}
|
||
|
||
func (l *LimitingExecutor) Name() string {
|
||
if l.inner == nil {
|
||
return "limiting"
|
||
}
|
||
return l.inner.Name()
|
||
}
|
||
|
||
func (l *LimitingExecutor) Execute(ctx context.Context, req ExecRequest) (*ExecResult, error) {
|
||
select {
|
||
case l.sem <- struct{}{}:
|
||
defer func() { <-l.sem }()
|
||
case <-ctx.Done():
|
||
return &ExecResult{
|
||
Success: false,
|
||
ErrorReason: fmt.Sprintf("agent concurrency wait canceled: %v", ctx.Err()),
|
||
}, ctx.Err()
|
||
}
|
||
return l.inner.Execute(ctx, req)
|
||
}
|