From a182d7662b34ffaef8bb6020864f866eda3cea17 Mon Sep 17 00:00:00 2001 From: weichen Date: Sat, 3 Oct 2026 13:40:58 +0000 Subject: [PATCH] Move cross-library squad code into adapter modules Why: the squad core has to compile without agent-native or agent-intent. Ports stay in the core; adapters and the CLI own the concrete clients. Evidence: GOWORK=off go test -count=1 ./squad/ ./store/ ./textextract/ ./schema/ passed, and the intent, sqlite, native, and cmd modules passed their own go test. Impact: core go.mod keeps error-code-system only. Nested gitlinks stay. Callers register the native backend through agent-squad/adapters/native. Refs: D1 Co-authored-by: Cursor --- .gitignore | 2 +- {squadintent => adapters/intent}/catalog.go | 7 +- .../intent}/catalog_test.go | 0 {squadintent => adapters/intent}/gate.go | 0 {squadintent => adapters/intent}/gate_test.go | 0 adapters/intent/go.mod | 22 ++ adapters/intent/go.sum | 2 + .../intent}/integration_test.go | 5 +- adapters/native/go.mod | 21 ++ adapters/native/register.go | 178 ++++++++++++++++ adapters/native/register_test.go | 49 +++++ {platform => adapters/sqlite}/autopilot.go | 0 {platform => adapters/sqlite}/db.go | 0 adapters/sqlite/go.mod | 30 +++ adapters/sqlite/go.sum | 47 ++++ {platform => adapters/sqlite}/http.go | 0 {platform => adapters/sqlite}/notes.go | 0 .../sqlite}/platform_test.go | 0 {platform => adapters/sqlite}/queue.go | 0 {platform => adapters/sqlite}/runtime.go | 0 {platform => adapters/sqlite}/skills.go | 0 {platform => adapters/sqlite}/squads.go | 0 {cli => cmd/agent-squad/cli}/cli.go | 11 +- {cli => cmd/agent-squad/cli}/demo.go | 5 +- {cli => cmd/agent-squad/cli}/listen_test.go | 0 {cli => cmd/agent-squad/cli}/platform_cmd.go | 2 +- .../conformance}/conformance_test.go | 5 +- cmd/agent-squad/go.mod | 45 ++++ cmd/agent-squad/go.sum | 49 +++++ main.go => cmd/agent-squad/main.go | 2 +- .../agent-squad/server}/guard_test.go | 0 {server => cmd/agent-squad/server}/server.go | 2 +- .../agent-squad/server}/server_test.go | 5 +- deps/agent-native | 2 +- go.mod | 28 +-- go.sum | 25 --- schema/blueprint.sample.json | 1 + schema/role.sample.json | 1 + schema/roundtrip_test.go | 59 ++++++ schema/workflow-plan.sample.json | 1 + squad/backend.go | 138 +----------- squad/deliverable.go | 141 ++++++++++++ squad/engine_test.go | 6 +- squad/evaluator_test.go | 6 +- squad/intent.go | 4 +- squad/intent_catalog_test.go | 6 +- squad/leader.go | 14 +- squad/leader_test.go | 22 +- squad/planner_llm.go | 37 +--- squad/port.go | 151 +++++++++++++ squad/recovery_test.go | 6 +- squad/request_v2_test.go | 52 +---- squad/router_test.go | 6 +- squad/steering_test.go | 10 +- squad/step_retry.go | 200 ++++++++++++++++++ squad/workflow.go | 26 ++- 56 files changed, 1088 insertions(+), 343 deletions(-) rename {squadintent => adapters/intent}/catalog.go (94%) rename {squadintent => adapters/intent}/catalog_test.go (100%) rename {squadintent => adapters/intent}/gate.go (100%) rename {squadintent => adapters/intent}/gate_test.go (100%) create mode 100644 adapters/intent/go.mod create mode 100644 adapters/intent/go.sum rename {squadintent => adapters/intent}/integration_test.go (97%) create mode 100644 adapters/native/go.mod create mode 100644 adapters/native/register.go create mode 100644 adapters/native/register_test.go rename {platform => adapters/sqlite}/autopilot.go (100%) rename {platform => adapters/sqlite}/db.go (100%) create mode 100644 adapters/sqlite/go.mod create mode 100644 adapters/sqlite/go.sum rename {platform => adapters/sqlite}/http.go (100%) rename {platform => adapters/sqlite}/notes.go (100%) rename {platform => adapters/sqlite}/platform_test.go (100%) rename {platform => adapters/sqlite}/queue.go (100%) rename {platform => adapters/sqlite}/runtime.go (100%) rename {platform => adapters/sqlite}/skills.go (100%) rename {platform => adapters/sqlite}/squads.go (100%) rename {cli => cmd/agent-squad/cli}/cli.go (98%) rename {cli => cmd/agent-squad/cli}/demo.go (97%) rename {cli => cmd/agent-squad/cli}/listen_test.go (100%) rename {cli => cmd/agent-squad/cli}/platform_cmd.go (98%) rename {conformance => cmd/agent-squad/conformance}/conformance_test.go (97%) create mode 100644 cmd/agent-squad/go.mod create mode 100644 cmd/agent-squad/go.sum rename main.go => cmd/agent-squad/main.go (81%) rename {server => cmd/agent-squad/server}/guard_test.go (100%) rename {server => cmd/agent-squad/server}/server.go (99%) rename {server => cmd/agent-squad/server}/server_test.go (98%) create mode 100644 schema/blueprint.sample.json create mode 100644 schema/role.sample.json create mode 100644 schema/roundtrip_test.go create mode 100644 schema/workflow-plan.sample.json create mode 100644 squad/deliverable.go create mode 100644 squad/port.go create mode 100644 squad/step_retry.go diff --git a/.gitignore b/.gitignore index 63ee3c0..91e9e0e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -agent-squad +/agent-squad agent-squad-data/ *.test diff --git a/squadintent/catalog.go b/adapters/intent/catalog.go similarity index 94% rename from squadintent/catalog.go rename to adapters/intent/catalog.go index 77a97cb..1665931 100644 --- a/squadintent/catalog.go +++ b/adapters/intent/catalog.go @@ -6,7 +6,6 @@ import ( "sort" "strings" - "agent-native/exec" "agent-squad/squad" ) @@ -67,7 +66,7 @@ func (g *Gate) EchoRunner() *Runner { } // Run 实现 squad.IntentRunner。未知意图/未知 handler fail-closed(不静默降级)。 -func (r *Runner) Run(ctx context.Context, step squad.IntentStep) (*exec.Result, error) { +func (r *Runner) Run(ctx context.Context, step squad.IntentStep) (*squad.ExecResult, error) { if r == nil || r.gate == nil || r.gate.Snapshot() == nil { return nil, fmt.Errorf("intent runner: gate not configured") } @@ -91,12 +90,12 @@ func (r *Runner) Run(ctx context.Context, step squad.IntentStep) (*exec.Result, return textResult(step, text), nil } -func textResult(step squad.IntentStep, text string) *exec.Result { +func textResult(step squad.IntentStep, text string) *squad.ExecResult { patches := make(map[string][]byte, len(step.Targets)) for _, t := range step.Targets { patches[t] = []byte(text) } - return &exec.Result{Success: true, Patches: patches, Logs: "intent:" + step.IntentID} + return &squad.ExecResult{Success: true, Patches: patches, Logs: "intent:" + step.IntentID} } func echoText(step squad.IntentStep, handlerKey string, _ bool) string { diff --git a/squadintent/catalog_test.go b/adapters/intent/catalog_test.go similarity index 100% rename from squadintent/catalog_test.go rename to adapters/intent/catalog_test.go diff --git a/squadintent/gate.go b/adapters/intent/gate.go similarity index 100% rename from squadintent/gate.go rename to adapters/intent/gate.go diff --git a/squadintent/gate_test.go b/adapters/intent/gate_test.go similarity index 100% rename from squadintent/gate_test.go rename to adapters/intent/gate_test.go diff --git a/adapters/intent/go.mod b/adapters/intent/go.mod new file mode 100644 index 0000000..1b3f0e3 --- /dev/null +++ b/adapters/intent/go.mod @@ -0,0 +1,22 @@ +module agent-squad/adapters/intent + +go 1.27 + +require ( + agent-intent v0.0.0 + agent-squad v0.0.0 +) + +require ( + error-code-system v0.0.0 // indirect + golang.org/x/text v0.29.0 // indirect + logging-system v0.0.0 // indirect +) + +replace agent-intent => ../../deps/agent-intent + +replace agent-squad => ../.. + +replace error-code-system => ../../deps/error-code-system + +replace logging-system => ../../deps/logging-system diff --git a/adapters/intent/go.sum b/adapters/intent/go.sum new file mode 100644 index 0000000..3adec61 --- /dev/null +++ b/adapters/intent/go.sum @@ -0,0 +1,2 @@ +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= diff --git a/squadintent/integration_test.go b/adapters/intent/integration_test.go similarity index 97% rename from squadintent/integration_test.go rename to adapters/intent/integration_test.go index 58bc453..a850a3f 100644 --- a/squadintent/integration_test.go +++ b/adapters/intent/integration_test.go @@ -7,7 +7,6 @@ import ( "strings" "testing" - "agent-native/exec" "agent-squad/squad" ) @@ -16,7 +15,7 @@ type integrationScriptBackend struct{ content string } func (b *integrationScriptBackend) Name() string { return "script" } -func (b *integrationScriptBackend) Execute(ctx context.Context, req exec.Request) (*exec.Result, error) { +func (b *integrationScriptBackend) Execute(ctx context.Context, req squad.ExecRequest) (*squad.ExecResult, error) { patches := map[string][]byte{} for _, t := range req.Targets { out := filepath.Join(req.WorkDir, t) @@ -26,7 +25,7 @@ func (b *integrationScriptBackend) Execute(ctx context.Context, req exec.Request } patches[t] = []byte(b.content) } - return &exec.Result{Success: true, Patches: patches, Logs: "script"}, nil + return &squad.ExecResult{Success: true, Patches: patches, Logs: "script"}, nil } const intentReport = "# 竞品分析报告\n\n## 概述\n\n由意图定向的蓝图模板生成的交付物,长度满足最小质检要求。\n\n## 结论\n\n符合预期。\n" diff --git a/adapters/native/go.mod b/adapters/native/go.mod new file mode 100644 index 0000000..e75f32f --- /dev/null +++ b/adapters/native/go.mod @@ -0,0 +1,21 @@ +module agent-squad/adapters/native + +go 1.27 + +require ( + agent-native v0.0.0 + agent-squad v0.0.0 +) + +require ( + error-code-system v0.0.0 // indirect + logging-system v0.0.0 // indirect +) + +replace agent-native => ../../deps/agent-native + +replace agent-squad => ../.. + +replace error-code-system => ../../deps/error-code-system + +replace logging-system => ../../deps/logging-system diff --git a/adapters/native/register.go b/adapters/native/register.go new file mode 100644 index 0000000..c9b6d64 --- /dev/null +++ b/adapters/native/register.go @@ -0,0 +1,178 @@ +// Package native 把 agent-native 接成小队的 Executor 与 ModelClient。 +package native + +import ( + "context" + "fmt" + "strings" + "time" + + nativexec "agent-native/adapters/native" + "agent-native/exec" + "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 +} + +// 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" 工厂。 +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, + }, + SessionOptions: nativexec.SessionOptions{Compact: cfg.Compact, MaxToolCalls: cfg.MaxToolCalls}, + } + 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 + out.OnEvent = func(ev exec.ProcessEvent) { + prev(squad.ProcessEvent{ + Type: ev.Type, Text: ev.Text, Tool: ev.Tool, Detail: ev.Detail, + PromptTokens: ev.PromptTokens, CompletionTokens: ev.CompletionTokens, + }) + } + } + return out +} + +func fromNative(res *exec.Result) *squad.ExecResult { + return &squad.ExecResult{ + Success: res.Success, Patches: res.Patches, Logs: res.Logs, ErrorReason: res.ErrorReason, + Retryable: res.Retryable, PromptTokens: res.PromptTokens, CompletionTokens: res.CompletionTokens, + } +} + +type eventTap struct { + next squad.Executor + on func(squad.ProcessEvent) +} + +func (b *eventTap) Name() string { return b.next.Name() } + +func (b *eventTap) Execute(ctx context.Context, req squad.ExecRequest) (*squad.ExecResult, error) { + prev := req.OnEvent + req.OnEvent = func(ev squad.ProcessEvent) { + b.on(ev) + if prev != nil { + prev(ev) + } + } + return b.next.Execute(ctx, req) +} + +// 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 +} diff --git a/adapters/native/register_test.go b/adapters/native/register_test.go new file mode 100644 index 0000000..d48337e --- /dev/null +++ b/adapters/native/register_test.go @@ -0,0 +1,49 @@ +package native + +import ( + "testing" + "time" + + "agent-native/runtime/llm" + "agent-native/runtime/tools" + "agent-squad/squad" +) + +func TestBuildOptionsBashPermissionGating(t *testing.T) { + base := Config{ + Client: &llm.Client{BaseURL: "http://x", APIKey: "k"}, + DefaultModel: "m1", MaxTurns: 5, Timeout: time.Minute, + Exec: &tools.ExecConfig{Bash: "/bin/bash"}, + SkillsRoots: []string{"/skills"}, + } + deny, err := buildOptions(base, squad.RoleSpec{Role: "r", BashPermission: squad.BashPermissionDeny}) + if err != nil { + t.Fatal(err) + } + if deny.Exec != nil { + t.Fatal("deny role must not get exec tools even when config provides them") + } + ask, _ := buildOptions(base, squad.RoleSpec{Role: "r", BashPermission: squad.BashPermissionAsk}) + if ask.Exec == nil { + t.Fatal("ask role gets exec tools") + } + allow, _ := buildOptions(base, squad.RoleSpec{Role: "r", BashPermission: squad.BashPermissionAllow}) + if allow.Exec == nil { + t.Fatal("allow role gets exec tools") + } + empty, _ := buildOptions(base, squad.RoleSpec{Role: "r"}) + if empty.Exec != nil { + t.Fatal("unset bash_permission defaults to deny") + } + none, _ := buildOptions(Config{Client: base.Client, DefaultModel: "m"}, squad.RoleSpec{Role: "r", BashPermission: squad.BashPermissionAllow}) + if none.Exec != nil { + t.Fatal("no Exec config = no exec tools") + } + roleModel, _ := buildOptions(base, squad.RoleSpec{Role: "r", Model: "big"}) + if roleModel.Model != "big" { + t.Fatalf("role model must win, got %s", roleModel.Model) + } + if _, err := buildOptions(Config{Client: base.Client}, squad.RoleSpec{Role: "r"}); err == nil { + t.Fatal("missing model must fail-closed") + } +} diff --git a/platform/autopilot.go b/adapters/sqlite/autopilot.go similarity index 100% rename from platform/autopilot.go rename to adapters/sqlite/autopilot.go diff --git a/platform/db.go b/adapters/sqlite/db.go similarity index 100% rename from platform/db.go rename to adapters/sqlite/db.go diff --git a/adapters/sqlite/go.mod b/adapters/sqlite/go.mod new file mode 100644 index 0000000..bc4affd --- /dev/null +++ b/adapters/sqlite/go.mod @@ -0,0 +1,30 @@ +module agent-squad/adapters/sqlite + +go 1.27 + +require ( + agent-native v0.0.0 + modernc.org/sqlite v1.37.0 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect + golang.org/x/sys v0.31.0 // indirect + logging-system v0.0.0 // indirect + modernc.org/libc v1.62.1 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.9.1 // indirect +) + +replace agent-native => ../../deps/agent-native + +replace agent-squad => ../.. + +replace error-code-system => ../../deps/error-code-system + +replace logging-system => ../../deps/logging-system diff --git a/adapters/sqlite/go.sum b/adapters/sqlite/go.sum new file mode 100644 index 0000000..e944b01 --- /dev/null +++ b/adapters/sqlite/go.sum @@ -0,0 +1,47 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw= +golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM= +golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= +golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= +golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU= +golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ= +modernc.org/cc/v4 v4.25.2 h1:T2oH7sZdGvTaie0BRNFbIYsabzCxUQg8nLqCdQ2i0ic= +modernc.org/cc/v4 v4.25.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.25.1 h1:TFSzPrAGmDsdnhT9X2UrcPMI3N/mJ9/X9ykKXwLhDsU= +modernc.org/ccgo/v4 v4.25.1/go.mod h1:njjuAYiPflywOOrm3B7kCB444ONP5pAVr8PIEoE0uDw= +modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= +modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/libc v1.62.1 h1:s0+fv5E3FymN8eJVmnk0llBe6rOxCu/DEU+XygRbS8s= +modernc.org/libc v1.62.1/go.mod h1:iXhATfJQLjG3NWy56a6WVU73lWOcdYVxsvwCgoPljuo= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.9.1 h1:V/Z1solwAVmMW1yttq3nDdZPJqV1rM05Ccq6KMSZ34g= +modernc.org/memory v1.9.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI= +modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/platform/http.go b/adapters/sqlite/http.go similarity index 100% rename from platform/http.go rename to adapters/sqlite/http.go diff --git a/platform/notes.go b/adapters/sqlite/notes.go similarity index 100% rename from platform/notes.go rename to adapters/sqlite/notes.go diff --git a/platform/platform_test.go b/adapters/sqlite/platform_test.go similarity index 100% rename from platform/platform_test.go rename to adapters/sqlite/platform_test.go diff --git a/platform/queue.go b/adapters/sqlite/queue.go similarity index 100% rename from platform/queue.go rename to adapters/sqlite/queue.go diff --git a/platform/runtime.go b/adapters/sqlite/runtime.go similarity index 100% rename from platform/runtime.go rename to adapters/sqlite/runtime.go diff --git a/platform/skills.go b/adapters/sqlite/skills.go similarity index 100% rename from platform/skills.go rename to adapters/sqlite/skills.go diff --git a/platform/squads.go b/adapters/sqlite/squads.go similarity index 100% rename from platform/squads.go rename to adapters/sqlite/squads.go diff --git a/cli/cli.go b/cmd/agent-squad/cli/cli.go similarity index 98% rename from cli/cli.go rename to cmd/agent-squad/cli/cli.go index 6c86be0..3baf03e 100644 --- a/cli/cli.go +++ b/cmd/agent-squad/cli/cli.go @@ -17,10 +17,11 @@ import ( "agent-intent/intent" "agent-native/runtime/llm" - "agent-squad/platform" - "agent-squad/server" + squadintent "agent-squad/adapters/intent" + nativeadp "agent-squad/adapters/native" + platform "agent-squad/adapters/sqlite" + "agent-squad/cmd/agent-squad/server" "agent-squad/squad" - "agent-squad/squadintent" "agent-squad/store" "logging-system/config" @@ -122,12 +123,12 @@ func assemble(dir, plannerName, modelOverride, intentRoot string) (*app, error) fmt.Fprintln(os.Stderr, "提示:llm 规划器需要 AGENT_SQUAD_LLM_BASE_URL/API_KEY/MODEL,回退 rule 规划器") } else { client := &llm.Client{BaseURL: baseURL, APIKey: apiKey} - planner = squad.NewLLMPlanner(client, model) + planner = squad.NewLLMPlanner(nativeadp.LLM{Inner: client}, model) } } // 真实智能体执行一律经 agent-native 自研内核(角色 backend=agent-native)。 if baseURL != "" && apiKey != "" && model != "" { - backends.RegisterAgentNative(&llm.Client{BaseURL: baseURL, APIKey: apiKey}, model, 12, 10*time.Minute) + nativeadp.Register(backends, &llm.Client{BaseURL: baseURL, APIKey: apiKey}, model, 12, 10*time.Minute) } // 事件双层:FileEventSink 落盘(seq+脱敏,重启可审计)→ FanoutSink(SSE 实时/重放)。 diff --git a/cli/demo.go b/cmd/agent-squad/cli/demo.go similarity index 97% rename from cli/demo.go rename to cmd/agent-squad/cli/demo.go index bce410c..c8cb767 100644 --- a/cli/demo.go +++ b/cmd/agent-squad/cli/demo.go @@ -9,7 +9,6 @@ import ( "path/filepath" "strings" - "agent-native/exec" "agent-squad/squad" ) @@ -21,7 +20,7 @@ type demoBackend struct { func (b *demoBackend) Name() string { return "demo" } -func (b *demoBackend) Execute(ctx context.Context, req exec.Request) (*exec.Result, error) { +func (b *demoBackend) Execute(ctx context.Context, req squad.ExecRequest) (*squad.ExecResult, error) { key := filepath.Base(filepath.Clean(req.WorkDir)) files, ok := b.script[key] if !ok { @@ -46,7 +45,7 @@ func (b *demoBackend) Execute(ctx context.Context, req exec.Request) (*exec.Resu } patches[t] = []byte(content) } - return &exec.Result{Success: true, Patches: patches, Logs: "demo backend replay"}, nil + return &squad.ExecResult{Success: true, Patches: patches, Logs: "demo backend replay"}, nil } // cmdDemo 离线全链路演示:注册角色 → 规划 → 执行 → HITL 阻塞缺口挂起 → diff --git a/cli/listen_test.go b/cmd/agent-squad/cli/listen_test.go similarity index 100% rename from cli/listen_test.go rename to cmd/agent-squad/cli/listen_test.go diff --git a/cli/platform_cmd.go b/cmd/agent-squad/cli/platform_cmd.go similarity index 98% rename from cli/platform_cmd.go rename to cmd/agent-squad/cli/platform_cmd.go index 4c54d88..2d83a45 100644 --- a/cli/platform_cmd.go +++ b/cmd/agent-squad/cli/platform_cmd.go @@ -10,7 +10,7 @@ import ( "time" "agent-native/runtime/llm" - "agent-squad/platform" + platform "agent-squad/adapters/sqlite" ) func cmdPlatform(args []string) int { diff --git a/conformance/conformance_test.go b/cmd/agent-squad/conformance/conformance_test.go similarity index 97% rename from conformance/conformance_test.go rename to cmd/agent-squad/conformance/conformance_test.go index 9dfc2c6..988dda9 100644 --- a/conformance/conformance_test.go +++ b/cmd/agent-squad/conformance/conformance_test.go @@ -11,7 +11,6 @@ import ( "strings" "testing" - "agent-native/exec" "agent-squad/squad" "agent-squad/store" ) @@ -21,7 +20,7 @@ type replayBackend struct{ script map[string]map[string]string } func (b *replayBackend) Name() string { return "replay" } -func (b *replayBackend) Execute(ctx context.Context, req exec.Request) (*exec.Result, error) { +func (b *replayBackend) Execute(ctx context.Context, req squad.ExecRequest) (*squad.ExecResult, error) { key := filepath.Base(filepath.Clean(req.WorkDir)) files := b.script[key] patches := map[string][]byte{} @@ -37,7 +36,7 @@ func (b *replayBackend) Execute(ctx context.Context, req exec.Request) (*exec.Re } patches[t] = []byte(content) } - return &exec.Result{Success: true, Patches: patches, Logs: "replay"}, nil + return &squad.ExecResult{Success: true, Patches: patches, Logs: "replay"}, nil } const deliverable = "# 契约验收交付物\n\n## 概述\n\n本文件由回放后端生成,用于验证 agent-squad 全链路契约:注册、规划、执行、HITL、质检与审计。\n\n## 结论\n\n全链路行为符合契约。\n" diff --git a/cmd/agent-squad/go.mod b/cmd/agent-squad/go.mod new file mode 100644 index 0000000..a368da2 --- /dev/null +++ b/cmd/agent-squad/go.mod @@ -0,0 +1,45 @@ +module agent-squad/cmd/agent-squad + +go 1.27 + +require ( + agent-intent v0.0.0 + agent-native v0.0.0 + agent-squad v0.0.0 + agent-squad/adapters/intent v0.0.0 + agent-squad/adapters/native v0.0.0 + agent-squad/adapters/sqlite v0.0.0 + error-code-system v0.0.0 + logging-system v0.0.0 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect + golang.org/x/sys v0.31.0 // indirect + golang.org/x/text v0.29.0 // indirect + modernc.org/libc v1.62.1 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.9.1 // indirect + modernc.org/sqlite v1.37.0 // indirect +) + +replace agent-intent => ../../deps/agent-intent + +replace agent-native => ../../deps/agent-native + +replace agent-squad => ../.. + +replace agent-squad/adapters/intent => ../../adapters/intent + +replace agent-squad/adapters/native => ../../adapters/native + +replace agent-squad/adapters/sqlite => ../../adapters/sqlite + +replace error-code-system => ../../deps/error-code-system + +replace logging-system => ../../deps/logging-system diff --git a/cmd/agent-squad/go.sum b/cmd/agent-squad/go.sum new file mode 100644 index 0000000..7ea6745 --- /dev/null +++ b/cmd/agent-squad/go.sum @@ -0,0 +1,49 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw= +golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM= +golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= +golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= +golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +modernc.org/cc/v4 v4.25.2 h1:T2oH7sZdGvTaie0BRNFbIYsabzCxUQg8nLqCdQ2i0ic= +modernc.org/cc/v4 v4.25.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.25.1 h1:TFSzPrAGmDsdnhT9X2UrcPMI3N/mJ9/X9ykKXwLhDsU= +modernc.org/ccgo/v4 v4.25.1/go.mod h1:njjuAYiPflywOOrm3B7kCB444ONP5pAVr8PIEoE0uDw= +modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= +modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/libc v1.62.1 h1:s0+fv5E3FymN8eJVmnk0llBe6rOxCu/DEU+XygRbS8s= +modernc.org/libc v1.62.1/go.mod h1:iXhATfJQLjG3NWy56a6WVU73lWOcdYVxsvwCgoPljuo= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.9.1 h1:V/Z1solwAVmMW1yttq3nDdZPJqV1rM05Ccq6KMSZ34g= +modernc.org/memory v1.9.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI= +modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/main.go b/cmd/agent-squad/main.go similarity index 81% rename from main.go rename to cmd/agent-squad/main.go index ace4c60..e73de2b 100644 --- a/main.go +++ b/cmd/agent-squad/main.go @@ -4,7 +4,7 @@ package main import ( "os" - "agent-squad/cli" + "agent-squad/cmd/agent-squad/cli" ) func main() { diff --git a/server/guard_test.go b/cmd/agent-squad/server/guard_test.go similarity index 100% rename from server/guard_test.go rename to cmd/agent-squad/server/guard_test.go diff --git a/server/server.go b/cmd/agent-squad/server/server.go similarity index 99% rename from server/server.go rename to cmd/agent-squad/server/server.go index 6a74675..5ac91d4 100644 --- a/server/server.go +++ b/cmd/agent-squad/server/server.go @@ -15,7 +15,7 @@ import ( "sync" "time" - "agent-squad/platform" + platform "agent-squad/adapters/sqlite" "agent-squad/squad" "agent-squad/store" diff --git a/server/server_test.go b/cmd/agent-squad/server/server_test.go similarity index 98% rename from server/server_test.go rename to cmd/agent-squad/server/server_test.go index 6f2ea6d..78b62ba 100644 --- a/server/server_test.go +++ b/cmd/agent-squad/server/server_test.go @@ -13,7 +13,6 @@ import ( "testing" "time" - "agent-native/exec" "agent-squad/squad" "agent-squad/store" @@ -25,7 +24,7 @@ type scriptBackend struct{ script map[string]map[string]string } func (b *scriptBackend) Name() string { return "script" } -func (b *scriptBackend) Execute(ctx context.Context, req exec.Request) (*exec.Result, error) { +func (b *scriptBackend) Execute(ctx context.Context, req squad.ExecRequest) (*squad.ExecResult, error) { key := filepath.Base(filepath.Clean(req.WorkDir)) files := b.script[key] patches := map[string][]byte{} @@ -39,7 +38,7 @@ func (b *scriptBackend) Execute(ctx context.Context, req exec.Request) (*exec.Re _ = os.WriteFile(out, []byte(content), 0o644) patches[t2] = []byte(content) } - return &exec.Result{Success: true, Patches: patches}, nil + return &squad.ExecResult{Success: true, Patches: patches}, nil } const goodReport = "# API 测试交付物\n\n## 概述\n\n脚本后端生成的合格交付物正文,长度超过五十个字符以满足最小质检要求。\n" diff --git a/deps/agent-native b/deps/agent-native index 855fe41..4a10818 160000 --- a/deps/agent-native +++ b/deps/agent-native @@ -1 +1 @@ -Subproject commit 855fe411520d2d8c3445ff5e7db7b2c065245f43 +Subproject commit 4a10818535d55b76a8e089da5984a000d7828a9c diff --git a/go.mod b/go.mod index f19eddc..11c76e1 100644 --- a/go.mod +++ b/go.mod @@ -2,32 +2,6 @@ module agent-squad go 1.27 -require ( - agent-intent v0.0.0 - agent-native v0.0.0 - error-code-system v0.0.0 - logging-system v0.0.0 -) - -require ( - github.com/dustin/go-humanize v1.0.1 // indirect - github.com/google/uuid v1.6.0 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/ncruces/go-strftime v0.1.9 // indirect - github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect - golang.org/x/sys v0.31.0 // indirect - golang.org/x/text v0.29.0 // indirect - modernc.org/libc v1.62.1 // indirect - modernc.org/mathutil v1.7.1 // indirect - modernc.org/memory v1.9.1 // indirect - modernc.org/sqlite v1.37.0 // indirect -) - -replace agent-intent => ./deps/agent-intent - -replace agent-native => ./deps/agent-native +require error-code-system v0.0.0 replace error-code-system => ./deps/error-code-system - -replace logging-system => ./deps/logging-system diff --git a/go.sum b/go.sum index d497460..e69de29 100644 --- a/go.sum +++ b/go.sum @@ -1,25 +0,0 @@ -github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= -github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= -github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw= -golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= -golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= -golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= -modernc.org/libc v1.62.1 h1:s0+fv5E3FymN8eJVmnk0llBe6rOxCu/DEU+XygRbS8s= -modernc.org/libc v1.62.1/go.mod h1:iXhATfJQLjG3NWy56a6WVU73lWOcdYVxsvwCgoPljuo= -modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= -modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= -modernc.org/memory v1.9.1 h1:V/Z1solwAVmMW1yttq3nDdZPJqV1rM05Ccq6KMSZ34g= -modernc.org/memory v1.9.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= -modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI= -modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM= diff --git a/schema/blueprint.sample.json b/schema/blueprint.sample.json new file mode 100644 index 0000000..880912a --- /dev/null +++ b/schema/blueprint.sample.json @@ -0,0 +1 @@ +{"key":"demo","title":"示例","steps":[{"id":"s1","role":"writer","title":"起草","parallel":false}]} diff --git a/schema/role.sample.json b/schema/role.sample.json new file mode 100644 index 0000000..353f35d --- /dev/null +++ b/schema/role.sample.json @@ -0,0 +1 @@ +{"role":"writer","name":"写作者","backend":"stub","tool_profile":"workspace"} diff --git a/schema/roundtrip_test.go b/schema/roundtrip_test.go new file mode 100644 index 0000000..785374e --- /dev/null +++ b/schema/roundtrip_test.go @@ -0,0 +1,59 @@ +package schema + +import ( + "encoding/json" + "os" + "testing" + + "agent-squad/squad" +) + +func TestSamplesRoundTrip(t *testing.T) { + var role squad.RoleSpec + mustRoundTrip(t, "role.sample.json", &role) + if role.Role != "writer" || role.Name != "写作者" { + t.Fatalf("%+v", role) + } + var bp squad.Blueprint + mustRoundTrip(t, "blueprint.sample.json", &bp) + if bp.Key != "demo" || len(bp.Steps) != 1 { + t.Fatalf("%+v", bp) + } + var plan squad.WorkflowPlan + mustRoundTrip(t, "workflow-plan.sample.json", &plan) + if plan.WorkflowID != "wf-1" || plan.Status != "pending" { + t.Fatalf("%+v", plan) + } +} + +func mustRoundTrip(t *testing.T, name string, dest any) { + t.Helper() + raw, err := os.ReadFile(name) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(raw, dest); err != nil { + t.Fatal(err) + } + back, err := json.Marshal(dest) + if err != nil { + t.Fatal(err) + } + again := reflectNew(dest) + if err := json.Unmarshal(back, again); err != nil { + t.Fatal(err) + } +} + +func reflectNew(v any) any { + switch v.(type) { + case *squad.RoleSpec: + return &squad.RoleSpec{} + case *squad.Blueprint: + return &squad.Blueprint{} + case *squad.WorkflowPlan: + return &squad.WorkflowPlan{} + default: + panic("unknown sample") + } +} diff --git a/schema/workflow-plan.sample.json b/schema/workflow-plan.sample.json new file mode 100644 index 0000000..b913a94 --- /dev/null +++ b/schema/workflow-plan.sample.json @@ -0,0 +1 @@ +{"workflow_id":"wf-1","title":"示例","status":"pending","current_step_index":0,"steps":[{"id":"s1","role":"writer","title":"起草","parallel":false}]} diff --git a/squad/backend.go b/squad/backend.go index 900449e..1b18527 100644 --- a/squad/backend.go +++ b/squad/backend.go @@ -1,24 +1,15 @@ package squad import ( - "context" "fmt" "sync" - "time" - - "agent-native/adapters/native" - "agent-native/exec" - "agent-native/runtime/llm" - "agent-native/runtime/mcp" - "agent-native/runtime/subagent" - "agent-native/runtime/tools" "error-code-system/errcode" ) // BackendFactory 按角色构造后端:角色级 Model/ToolProfile 在工厂闭包层绑定 -// (agent-native exec.Request 无 Model 字段,绑定不侵入执行契约)。 -type BackendFactory func(role RoleSpec) (exec.Backend, error) +// (agent-native ExecRequest 无 Model 字段,绑定不侵入执行契约)。 +type BackendFactory func(role RoleSpec) (Executor, error) // BackendResolver 角色 → 智能体后端解析器。 // 内置 "stub"(agent-native 离线桩);真实智能体经 RegisterAgentNative 注册 @@ -28,18 +19,18 @@ type BackendFactory func(role RoleSpec) (exec.Backend, error) type BackendResolver struct { mu sync.RWMutex factories map[string]BackendFactory - underlying map[string]exec.Backend // 无工厂直挂的后端实例 - wrapped map[string]exec.Backend // 角色(含模型绑定)→ 已包装实例缓存 + underlying map[string]Executor // 无工厂直挂的后端实例 + wrapped map[string]Executor // 角色(含模型绑定)→ 已包装实例缓存 } // NewBackendResolver 创建解析器并注册内置后端。 func NewBackendResolver() *BackendResolver { r := &BackendResolver{ factories: make(map[string]BackendFactory), - underlying: make(map[string]exec.Backend), - wrapped: make(map[string]exec.Backend), + underlying: make(map[string]Executor), + wrapped: make(map[string]Executor), } - r.RegisterInstance("stub", &exec.StubBackend{}) + r.RegisterInstance("stub", &StubExecutor{}) return r } @@ -51,119 +42,12 @@ func (r *BackendResolver) Register(name string, f BackendFactory) { } // RegisterInstance 直接注册后端实例(忽略角色级绑定)。 -func (r *BackendResolver) RegisterInstance(name string, b exec.Backend) { +func (r *BackendResolver) RegisterInstance(name string, b Executor) { r.mu.Lock() defer r.mu.Unlock() r.underlying[name] = b } -// AgentNativeConfig 声明 agent-native 后端的全量能力装配(组合根提供)。 -// 零值 = 仅基础模型执行:搜索/取回/执行/MCP/技能/委派工具一律不注册(fail-closed)。 -type AgentNativeConfig struct { - Client *llm.Client - DefaultModel string - MaxTurns int - Timeout time.Duration - // 以下均为 agent-native Options 的直通注入,详见其注释。 - Search tools.SearchProvider - Exec *tools.ExecConfig - Fetch *tools.FetchConfig - MCP *mcp.Manager - SkillsRoots []string - Subagent subagent.Factory - Compact *native.CompactConfig - // HostTools 注册进内核工作区。与内置工具同名时该步骤失败,不覆盖。 - HostTools []tools.Tool - // OnEvent 收到内核的逐步过程事件(文本、工具、用量)。与 Request.OnEvent 同时生效。 - OnEvent func(ev exec.ProcessEvent) - // Stream 测试注入。为空时按 Client 访问模型。 - Stream llm.StreamFunc - // MaxToolCalls 单步工具调用上限。0 表示不限制。 - MaxToolCalls int -} - -// buildAgentNativeOptions 按角色把配置换算为 native.Options。 -// BashPermission 三态在此执行:deny = 不装配执行工具(无论组合根是否提供 Exec); -// ask/allow = 装配(ask 的人工门由引擎 permission 挂起点先行把关)。 -func buildAgentNativeOptions(cfg AgentNativeConfig, role RoleSpec) (native.Options, error) { - model := role.Model - if model == "" { - model = cfg.DefaultModel - } - if model == "" { - return native.Options{}, fmt.Errorf("agent-native backend requires a model (role=%s)", role.Role) - } - opts := native.Options{ - Model: model, - MaxTurns: cfg.MaxTurns, - Timeout: cfg.Timeout, - Search: cfg.Search, - Fetch: cfg.Fetch, - MCP: cfg.MCP, - SkillsRoots: cfg.SkillsRoots, - Subagent: cfg.Subagent, - Compact: cfg.Compact, - HostTools: cfg.HostTools, - Stream: cfg.Stream, - MaxToolCalls: cfg.MaxToolCalls, - } - if cfg.Client != nil { - opts.APIBase, opts.APIKey = cfg.Client.BaseURL, cfg.Client.APIKey - } - // 显式 allow/ask 才装配(注册层默认 deny,但派生/程序构造的 RoleSpec - // 未必经注册——工厂层兜底,空白串不放行)。 - if cfg.Exec != nil && (role.BashPermission == BashPermissionAllow || role.BashPermission == BashPermissionAsk) { - opts.Exec = cfg.Exec - } - return opts, nil -} - -// processEventBackend 把配置级过程回调接到每次 Execute,同时保留请求自带的 OnEvent。 -type processEventBackend struct { - next exec.Backend - on func(ev exec.ProcessEvent) -} - -func (b *processEventBackend) Name() string { return b.next.Name() } - -func (b *processEventBackend) Execute(ctx context.Context, req exec.Request) (*exec.Result, error) { - if b.on != nil { - prev := req.OnEvent - req.OnEvent = func(ev exec.ProcessEvent) { - b.on(ev) - if prev != nil { - prev(ev) - } - } - } - return b.next.Execute(ctx, req) -} - -// RegisterAgentNative 注册 "agent-native" 后端工厂(基础签名,向后兼容): -// 角色级 Model 覆盖 defaultModel,无附加能力装配。 -func (r *BackendResolver) RegisterAgentNative(client *llm.Client, defaultModel string, maxTurns int, timeout time.Duration) { - r.RegisterAgentNativeFull(AgentNativeConfig{ - Client: client, DefaultModel: defaultModel, MaxTurns: maxTurns, Timeout: timeout, - }) -} - -// RegisterAgentNativeFull 注册带全量能力装配的 "agent-native" 工厂: -// 搜索/取回/执行/MCP/技能/委派经配置注入;BashPermission=deny 的角色 -// 不装配执行工具。智能体执行全部委托 agent-native,本库不实现 Agent。 -func (r *BackendResolver) RegisterAgentNativeFull(cfg AgentNativeConfig) { - r.Register("agent-native", func(role RoleSpec) (exec.Backend, error) { - opts, err := buildAgentNativeOptions(cfg, role) - if err != nil { - return nil, err - } - next := native.New(opts) - if cfg.OnEvent == nil { - return next, nil - } - return &processEventBackend{next: next, on: cfg.OnEvent}, nil - }) -} - // ListBackends 返回可用后端名(排序稳定)。 func (r *BackendResolver) ListBackends() []string { r.mu.RLock() @@ -191,7 +75,7 @@ func (r *BackendResolver) ListBackends() []string { // Resolve 解析角色的执行后端:Role.Backend → Options.DefaultBackend → "stub"。 // MaxConcurrentTasks > 1 时以 agent-native 并发限流包装(per-role 缓存,跨步骤共享; // 创建段持写锁防并行首解析竞态——否则并发步骤会各拿一份限流器)。 -func (r *BackendResolver) Resolve(role RoleSpec, defaultBackend string) (exec.Backend, string, error) { +func (r *BackendResolver) Resolve(role RoleSpec, defaultBackend string) (Executor, string, error) { name := role.Backend if name == "" { name = defaultBackend @@ -219,7 +103,7 @@ func (r *BackendResolver) Resolve(role RoleSpec, defaultBackend string) (exec.Ba return b, name, nil } - var b exec.Backend + var b Executor if hasUnder { b = under } else if hasFactory { @@ -234,7 +118,7 @@ func (r *BackendResolver) Resolve(role RoleSpec, defaultBackend string) (exec.Ba // MaxConcurrentTasks>=1 即包装限流(1 = 对象级串行;0 = 不限)。 if max := role.MaxConcurrentTasks; max >= 1 { - b = exec.WrapWithConcurrencyLimit(b, max) + b = WrapWithConcurrencyLimit(b, max) } r.wrapped[cacheKey] = b return b, name, nil diff --git a/squad/deliverable.go b/squad/deliverable.go new file mode 100644 index 0000000..8e98f57 --- /dev/null +++ b/squad/deliverable.go @@ -0,0 +1,141 @@ +package squad + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "strings" +) + +func slashClean(p string) string { + return filepath.ToSlash(filepath.Clean(p)) +} + +// MatchDeliverableRel reports whether a runner rel_path refers to target +// inside workDir. Accepts output.md, ./output.md, absolute paths under workDir, +// and a duplicated step prefix (tasks/01_draft/output.md when workDir already +// ends with tasks/01_draft). Paths that escape workDir never match. +func MatchDeliverableRel(workDir, relPath, target string) bool { + target = slashClean(strings.TrimSpace(target)) + if target == "" || target == "." || strings.HasPrefix(target, "../") || strings.Contains(target, "..") { + return false + } + raw := strings.TrimSpace(relPath) + if raw == "" { + return false + } + abs := raw + if !filepath.IsAbs(raw) { + abs = filepath.Join(workDir, raw) + } + abs = filepath.Clean(abs) + rel, err := filepath.Rel(filepath.Clean(workDir), abs) + if err != nil { + return false + } + rel = slashClean(rel) + if rel == ".." || strings.HasPrefix(rel, "../") { + return false + } + if rel == target { + return true + } + if filepath.Base(rel) != filepath.Base(target) { + return false + } + prefix := slashClean(filepath.Dir(rel)) + wd := slashClean(workDir) + return prefix != "." && (strings.HasSuffix(wd, "/"+prefix) || strings.HasSuffix(wd, prefix)) +} + +// CollectTargetPatches maps incoming runner keys onto canonical target names +// and also accepts a non-empty file already at workDir/target. Empty bodies +// do not count — callers must not invent a deliverable. +func CollectTargetPatches(workDir string, targets []string, incoming map[string][]byte) map[string][]byte { + out := make(map[string][]byte, len(targets)) + for _, t := range targets { + t = strings.TrimSpace(t) + if t == "" { + continue + } + for k, v := range incoming { + if len(bytes.TrimSpace(v)) == 0 { + continue + } + if MatchDeliverableRel(workDir, k, t) { + out[t] = v + break + } + } + if _, ok := out[t]; ok { + continue + } + p := filepath.Join(workDir, t) + if b, err := os.ReadFile(p); err == nil && len(bytes.TrimSpace(b)) > 0 { + out[t] = b + } + } + return out +} + +// MaterializeTargets writes collected non-empty target bodies to workDir/. +// Returns a missing-target error without writing a stub if any required file is absent. +func MaterializeTargets(workDir string, targets []string, incoming map[string][]byte) (map[string][]byte, error) { + collected := CollectTargetPatches(workDir, targets, incoming) + for _, t := range targets { + t = strings.TrimSpace(t) + if t == "" { + continue + } + body, ok := collected[t] + if !ok || len(bytes.TrimSpace(body)) == 0 { + return collected, fmt.Errorf("missing target deliverable %s", t) + } + dest := filepath.Join(workDir, filepath.FromSlash(t)) + if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { + return collected, err + } + if err := rejectSymlinkEscape(workDir, dest); err != nil { + return collected, err + } + // Container-written files may be root-owned; make writable before overwrite. + _ = os.Chmod(dest, 0o666) + if err := os.WriteFile(dest, body, 0o644); err != nil { + return collected, err + } + } + return collected, nil +} + +func rejectSymlinkEscape(workDir, dest string) error { + root, err := filepath.Abs(workDir) + if err != nil { + return err + } + absDest, err := filepath.Abs(dest) + if err != nil { + return err + } + rel, err := filepath.Rel(root, absDest) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { + return fmt.Errorf("typed result path rejected") + } + resolvedRoot, err := filepath.EvalSymlinks(root) + if err != nil { + resolvedRoot = root + } + parent := filepath.Dir(absDest) + resolvedParent, err := filepath.EvalSymlinks(parent) + if err != nil { + return fmt.Errorf("typed result path rejected") + } + relParent, err := filepath.Rel(resolvedRoot, resolvedParent) + if err != nil || relParent == ".." || strings.HasPrefix(relParent, ".."+string(os.PathSeparator)) { + return fmt.Errorf("typed result path rejected") + } + if info, err := os.Lstat(absDest); err == nil && info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("typed result path rejected") + } + return nil +} diff --git a/squad/engine_test.go b/squad/engine_test.go index 0952f21..b140416 100644 --- a/squad/engine_test.go +++ b/squad/engine_test.go @@ -8,8 +8,6 @@ import ( "strings" "testing" "time" - - "agent-native/exec" ) // scriptBackend 测试用脚本后端:按子目录名回放产出。 @@ -17,7 +15,7 @@ type scriptBackend struct{ script map[string]map[string]string } func (b *scriptBackend) Name() string { return "script" } -func (b *scriptBackend) Execute(ctx context.Context, req exec.Request) (*exec.Result, error) { +func (b *scriptBackend) Execute(ctx context.Context, req ExecRequest) (*ExecResult, error) { key := filepath.Base(filepath.Clean(req.WorkDir)) files := b.script[key] patches := map[string][]byte{} @@ -33,7 +31,7 @@ func (b *scriptBackend) Execute(ctx context.Context, req exec.Request) (*exec.Re } patches[t] = []byte(content) } - return &exec.Result{Success: true, Patches: patches}, nil + return &ExecResult{Success: true, Patches: patches}, nil } const goodReport = "# 测试交付物\n\n## 概述\n\n这是由脚本后端生成的合格交付物,长度超过五十个字符以满足最小质检要求。\n\n## 内容\n\n正文内容。\n" diff --git a/squad/evaluator_test.go b/squad/evaluator_test.go index 9d43f17..b857dfa 100644 --- a/squad/evaluator_test.go +++ b/squad/evaluator_test.go @@ -6,8 +6,6 @@ import ( "path/filepath" "strings" "testing" - - "agent-native/exec" ) // scriptedEvaluator 第一步后建议:追加一个补漏步骤 + 跳过冗余步骤。 @@ -146,7 +144,7 @@ type mutableScriptBackend struct { func (b *mutableScriptBackend) Name() string { return "mut" } -func (b *mutableScriptBackend) Execute(ctx context.Context, req exec.Request) (*exec.Result, error) { +func (b *mutableScriptBackend) Execute(ctx context.Context, req ExecRequest) (*ExecResult, error) { b.calls++ if b.mutate != nil && b.calls == b.mutateAt { b.mutate(b.script) @@ -164,7 +162,7 @@ func (b *mutableScriptBackend) Execute(ctx context.Context, req exec.Request) (* _ = os.WriteFile(out, []byte(content), 0o644) patches[tg] = []byte(content) } - return &exec.Result{Success: true, Patches: patches}, nil + return &ExecResult{Success: true, Patches: patches}, nil } // TestQualityRepairAutoRerun 验证:质检失败 → 自动重跑交付步骤 → 通过。 diff --git a/squad/intent.go b/squad/intent.go index 8381c0a..4348df2 100644 --- a/squad/intent.go +++ b/squad/intent.go @@ -3,8 +3,6 @@ package squad import ( "context" "fmt" - - "agent-native/exec" ) // IntentRejection 守卫拦截:拒绝该次运行并给出引导("识别即拒绝"语义)。 @@ -96,7 +94,7 @@ type IntentStep struct { // IntentRunner 系统功能执行器(squadintent 提供实现:查 spec.Handler 并调宿主处理函数)。 type IntentRunner interface { - Run(ctx context.Context, step IntentStep) (*exec.Result, error) + Run(ctx context.Context, step IntentStep) (*ExecResult, error) } // DefaultIntentRole 系统功能步的默认角色 key(无后端语义,仅满足编制覆盖)。 diff --git a/squad/intent_catalog_test.go b/squad/intent_catalog_test.go index d2e52e8..0743b44 100644 --- a/squad/intent_catalog_test.go +++ b/squad/intent_catalog_test.go @@ -6,8 +6,6 @@ import ( "path/filepath" "strings" "testing" - - "agent-native/exec" ) type stubRunner struct { @@ -16,7 +14,7 @@ type stubRunner struct { err error } -func (r *stubRunner) Run(ctx context.Context, step IntentStep) (*exec.Result, error) { +func (r *stubRunner) Run(ctx context.Context, step IntentStep) (*ExecResult, error) { r.calls = append(r.calls, step) if r.err != nil { return nil, r.err @@ -25,7 +23,7 @@ func (r *stubRunner) Run(ctx context.Context, step IntentStep) (*exec.Result, er for _, t := range step.Targets { patches[t] = []byte(r.text) } - return &exec.Result{Success: true, Patches: patches, Logs: "stub-intent"}, nil + return &ExecResult{Success: true, Patches: patches, Logs: "stub-intent"}, nil } func testCatalog() *IntentCatalog { diff --git a/squad/leader.go b/squad/leader.go index 9230f55..4ce0e61 100644 --- a/squad/leader.go +++ b/squad/leader.go @@ -8,32 +8,30 @@ import ( "path/filepath" "strings" "time" - - "agent-native/exec" ) // leaderExecRequest 构造 leader 轮次的执行请求:与标准步骤同等装配 // (过程事件桥、ToolProfile/Skills/工具白名单、执行标识与预算)—— // 修平此前 leader 轮丢弃 OnEvent 与 ToolProfile 的缺口。 -func leaderExecRequest(drv *flowDriver, st *runState, leader RoleSpec, workDir, prompt, instructions string, targets []string, timeout time.Duration) exec.Request { +func leaderExecRequest(drv *flowDriver, st *runState, leader RoleSpec, workDir, prompt, instructions string, targets []string, timeout time.Duration) ExecRequest { stepID := fmt.Sprintf("leader_turn_%d", st.Leader.Turns) - var budget *exec.Budget + var budget *ExecBudget if timeout > 0 { - budget = &exec.Budget{Deadline: time.Now().Add(timeout)} + budget = &ExecBudget{Deadline: time.Now().Add(timeout)} } - return exec.Request{ + return ExecRequest{ WorkDir: workDir, Prompt: prompt, Instructions: instructions, Targets: targets, Timeout: timeout, - ToolProfile: orDefault(leader.ToolProfile, exec.ToolProfileWorkspace), + ToolProfile: orDefault(leader.ToolProfile, ToolProfileWorkspace), ToolAllowlist: leader.Tools, Skills: leader.Skills, SessionScope: st.Meta.ID + "/" + stepID, ExecutionID: st.Meta.ID + "-" + stepID, Budget: budget, - OnEvent: func(ev exec.ProcessEvent) { + OnEvent: func(ev ProcessEvent) { drv.emit(st, Event{Type: EvDetail, StepID: stepID, Role: leader.Role, Text: ev.Text, Data: marshalJSON(map[string]string{"process_type": ev.Type, "tool": ev.Tool, "detail": ev.Detail})}) }, diff --git a/squad/leader_test.go b/squad/leader_test.go index dd0b827..cad7080 100644 --- a/squad/leader_test.go +++ b/squad/leader_test.go @@ -10,8 +10,6 @@ import ( "sync" "testing" "time" - - "agent-native/exec" ) // leaderScriptBackend leader 模式脚本后端:00_leader 目录按轮次回放 @@ -26,7 +24,7 @@ type leaderScriptBackend struct { func (b *leaderScriptBackend) Name() string { return "leader-script" } -func (b *leaderScriptBackend) Execute(ctx context.Context, req exec.Request) (*exec.Result, error) { +func (b *leaderScriptBackend) Execute(ctx context.Context, req ExecRequest) (*ExecResult, error) { key := filepath.Base(filepath.Clean(req.WorkDir)) if b.prompts != nil { b.mu.Lock() @@ -43,7 +41,7 @@ func (b *leaderScriptBackend) Execute(ctx context.Context, req exec.Request) (*e } out := filepath.Join(req.WorkDir, "delegation.json") _ = os.WriteFile(out, []byte(b.decisions[idx]), 0o644) - return &exec.Result{Success: true, Patches: map[string][]byte{"delegation.json": []byte(b.decisions[idx])}}, nil + return &ExecResult{Success: true, Patches: map[string][]byte{"delegation.json": []byte(b.decisions[idx])}}, nil } files := b.script[key] patches := map[string][]byte{} @@ -57,7 +55,7 @@ func (b *leaderScriptBackend) Execute(ctx context.Context, req exec.Request) (*e _ = os.WriteFile(out, []byte(content), 0o644) patches[t] = []byte(content) } - return &exec.Result{Success: true, Patches: patches, + return &ExecResult{Success: true, Patches: patches, PromptTokens: 100, CompletionTokens: 50}, nil } @@ -67,7 +65,7 @@ func delegateJSON(member, title string) string { const closeJSON = `{"action":"close","reason":"所有子任务完成,交付物满足目标"}` -func newLeaderEngine(t *testing.T, backend exec.Backend, roles ...RoleSpec) *SquadEngine { +func newLeaderEngine(t *testing.T, backend Executor, roles ...RoleSpec) *SquadEngine { t.Helper() dir := t.TempDir() reg := NewRoleRegistry() @@ -385,18 +383,18 @@ func TestWakeupGuardMaxFires(t *testing.T) { type eventingBackend struct { inner *leaderScriptBackend mu sync.Mutex - reqs []exec.Request + reqs []ExecRequest } func (b *eventingBackend) Name() string { return "leader-script" } -func (b *eventingBackend) Execute(ctx context.Context, req exec.Request) (*exec.Result, error) { +func (b *eventingBackend) Execute(ctx context.Context, req ExecRequest) (*ExecResult, error) { b.mu.Lock() b.reqs = append(b.reqs, req) b.mu.Unlock() if req.OnEvent != nil { - req.OnEvent(exec.ProcessEvent{Type: "tool_execution_start", Tool: "grep", Detail: `{"q":"x"}`}) - req.OnEvent(exec.ProcessEvent{Type: "tool_execution_end", Tool: "grep", Text: "found"}) + req.OnEvent(ProcessEvent{Type: "tool_execution_start", Tool: "grep", Detail: `{"q":"x"}`}) + req.OnEvent(ProcessEvent{Type: "tool_execution_end", Tool: "grep", Text: "found"}) } return b.inner.Execute(ctx, req) } @@ -435,7 +433,7 @@ func TestLeaderTurnsEmitDetailEventsAndCarryProfile(t *testing.T) { t.Fatalf("want completed, got %s err=%s", res.Status, res.Error) } // leader 轮的请求带上了 ToolProfile/白名单/技能与标识(修平 leader 请求缺口)。 - var leaderReq *exec.Request + var leaderReq *ExecRequest for i := range eb.reqs { if strings.HasSuffix(eb.reqs[i].WorkDir, "00_leader") { leaderReq = &eb.reqs[i] @@ -444,7 +442,7 @@ func TestLeaderTurnsEmitDetailEventsAndCarryProfile(t *testing.T) { if leaderReq == nil { t.Fatal("leader request missing") } - if leaderReq.ToolProfile != exec.ToolProfileWorkspace || len(leaderReq.ToolAllowlist) != 2 || len(leaderReq.Skills) != 1 { + if leaderReq.ToolProfile != ToolProfileWorkspace || len(leaderReq.ToolAllowlist) != 2 || len(leaderReq.Skills) != 1 { t.Fatalf("leader request not fully assembled: %+v", leaderReq) } if !strings.Contains(leaderReq.ExecutionID, "leader_turn_") { diff --git a/squad/planner_llm.go b/squad/planner_llm.go index 4a89744..7a3f315 100644 --- a/squad/planner_llm.go +++ b/squad/planner_llm.go @@ -5,15 +5,13 @@ import ( "encoding/json" "fmt" "strings" - - "agent-native/runtime/llm" ) // LLMPlanner LLM 规划器:用模型理解目标 → 拆解任务 → 选/派角色 → 生成步骤。 // 模型客户端与默认模型在构造时注入(角色级模型在派生角色时由模型自行指定)。 // 输出为严格 JSON;解析失败时重试一次,仍失败则 fail-closed 返回错误。 type LLMPlanner struct { - Client *llm.Client + Client ModelClient Model string APIBase string APIKey string @@ -25,7 +23,7 @@ type LLMPlanner struct { } // NewLLMPlanner 创建 LLM 规划器。 -func NewLLMPlanner(client *llm.Client, model string) *LLMPlanner { +func NewLLMPlanner(client ModelClient, model string) *LLMPlanner { return &LLMPlanner{Client: client, Model: model} } @@ -283,33 +281,20 @@ func segmentGoal(text string) string { // complete 调一次模型并拼接完整文本。 func (p *LLMPlanner) complete(ctx context.Context, system, user string) (string, error) { - msgs := []llm.Message{ - {Role: llm.RoleSystem, Content: system}, - {Role: llm.RoleUser, Content: user}, + if p.Client == nil { + return "", fmt.Errorf("llm planner: no model client") } - opts := llm.StreamOptions{MaxTokens: p.MaxTokens, APIBase: p.APIBase, APIKey: p.APIKey} - var sb strings.Builder - for ev := range p.Client.Stream(ctx, p.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 - } + text, err := p.Client.Complete(ctx, ModelTurn{ + Model: p.Model, System: system, User: user, + MaxTokens: p.MaxTokens, APIBase: p.APIBase, APIKey: p.APIKey, + }) + if err != nil { + return "", err } if ctx.Err() != nil { return "", ctx.Err() } - return sb.String(), nil + return text, nil } // parseLLMPlan 从模型输出稳健解析 JSON(剥离 think 块/代码围栏/前后杂文)。 diff --git a/squad/port.go b/squad/port.go new file mode 100644 index 0000000..8662ba0 --- /dev/null +++ b/squad/port.go @@ -0,0 +1,151 @@ +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 +} + +// ExecResult 是一次步骤执行的终态。 +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"` +} + +// 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) +} diff --git a/squad/recovery_test.go b/squad/recovery_test.go index fcab0a1..cd32f2f 100644 --- a/squad/recovery_test.go +++ b/squad/recovery_test.go @@ -9,8 +9,6 @@ import ( "sync" "testing" "time" - - "agent-native/exec" ) func approvalScript() map[string]map[string]string { @@ -114,7 +112,7 @@ type countBackend struct { func (b *countBackend) Name() string { return "script" } -func (b *countBackend) Execute(ctx context.Context, req exec.Request) (*exec.Result, error) { +func (b *countBackend) Execute(ctx context.Context, req ExecRequest) (*ExecResult, error) { b.mu.Lock() b.n++ b.mu.Unlock() @@ -188,7 +186,7 @@ type blockBackend struct { func (b *blockBackend) Name() string { return "script" } -func (b *blockBackend) Execute(ctx context.Context, req exec.Request) (*exec.Result, error) { +func (b *blockBackend) Execute(ctx context.Context, req ExecRequest) (*ExecResult, error) { b.once.Do(func() { close(b.started) }) select { case <-b.release: diff --git a/squad/request_v2_test.go b/squad/request_v2_test.go index 2da1d35..5cedc6c 100644 --- a/squad/request_v2_test.go +++ b/squad/request_v2_test.go @@ -8,22 +8,18 @@ import ( "sync" "testing" "time" - - "agent-native/exec" - "agent-native/runtime/llm" - "agent-native/runtime/tools" ) // captureBackend 记录收到的请求(v2 字段贯通断言用)。 type captureBackend struct { mu sync.Mutex - reqs []exec.Request + reqs []ExecRequest script map[string]string // 子目录名 → 单一目标内容 } func (b *captureBackend) Name() string { return "capture" } -func (b *captureBackend) Execute(ctx context.Context, req exec.Request) (*exec.Result, error) { +func (b *captureBackend) Execute(ctx context.Context, req ExecRequest) (*ExecResult, error) { b.mu.Lock() b.reqs = append(b.reqs, req) b.mu.Unlock() @@ -39,7 +35,7 @@ func (b *captureBackend) Execute(ctx context.Context, req exec.Request) (*exec.R _ = os.WriteFile(out, []byte(content), 0o644) patches[t] = []byte(content) } - return &exec.Result{Success: true, Patches: patches}, nil + return &ExecResult{Success: true, Patches: patches}, nil } func TestStepRequestCarriesRoleSpecV2Fields(t *testing.T) { @@ -88,48 +84,6 @@ func TestStepRequestCarriesRoleSpecV2Fields(t *testing.T) { } } -func TestBuildAgentNativeOptionsBashPermissionGating(t *testing.T) { - base := AgentNativeConfig{ - Client: &llm.Client{BaseURL: "http://x", APIKey: "k"}, - DefaultModel: "m1", MaxTurns: 5, Timeout: time.Minute, - Exec: &tools.ExecConfig{Bash: "/bin/bash"}, - SkillsRoots: []string{"/skills"}, - } - deny, err := buildAgentNativeOptions(base, RoleSpec{Role: "r", BashPermission: BashPermissionDeny}) - if err != nil { - t.Fatal(err) - } - if deny.Exec != nil { - t.Fatal("deny role must not get exec tools even when config provides them") - } - ask, _ := buildAgentNativeOptions(base, RoleSpec{Role: "r", BashPermission: BashPermissionAsk}) - if ask.Exec == nil { - t.Fatal("ask role gets exec tools (the HITL gate runs before the step)") - } - allow, _ := buildAgentNativeOptions(base, RoleSpec{Role: "r", BashPermission: BashPermissionAllow}) - if allow.Exec == nil { - t.Fatal("allow role gets exec tools") - } - // 空白名 = 注册时默认 deny,同样不装配。 - empty, _ := buildAgentNativeOptions(base, RoleSpec{Role: "r"}) - if empty.Exec != nil { - t.Fatal("unset bash_permission defaults to deny at registration; no exec tools") - } - // 无 Exec 配置时任何档位都不装配。 - none, _ := buildAgentNativeOptions(AgentNativeConfig{Client: base.Client, DefaultModel: "m"}, RoleSpec{Role: "r", BashPermission: BashPermissionAllow}) - if none.Exec != nil { - t.Fatal("no Exec config = no exec tools regardless of permission") - } - // 模型解析:角色级覆盖默认。 - roleModel, _ := buildAgentNativeOptions(base, RoleSpec{Role: "r", Model: "big"}) - if roleModel.Model != "big" { - t.Fatalf("role model must win, got %s", roleModel.Model) - } - if _, err := buildAgentNativeOptions(AgentNativeConfig{Client: base.Client}, RoleSpec{Role: "r"}); err == nil { - t.Fatal("missing model must fail-closed") - } -} - func TestDeriveInheritsExecutionConfig(t *testing.T) { reg := NewRoleRegistry() base := RoleSpec{ diff --git a/squad/router_test.go b/squad/router_test.go index 71da1a8..93ebeda 100644 --- a/squad/router_test.go +++ b/squad/router_test.go @@ -5,8 +5,6 @@ import ( "os" "path/filepath" "testing" - - "agent-native/exec" ) type fixedRouter struct{ dec *RouteDecision } @@ -89,14 +87,14 @@ type captureSkillsBackend struct{ got []string } func (b *captureSkillsBackend) Name() string { return "capture-skills" } -func (b *captureSkillsBackend) Execute(ctx context.Context, req exec.Request) (*exec.Result, error) { +func (b *captureSkillsBackend) Execute(ctx context.Context, req ExecRequest) (*ExecResult, error) { b.got = append([]string{}, req.Skills...) for _, target := range req.Targets { path := filepath.Join(req.WorkDir, target) _ = os.MkdirAll(filepath.Dir(path), 0o755) _ = os.WriteFile(path, []byte(goodReport), 0o644) } - return &exec.Result{Success: true, Patches: map[string][]byte{"output.md": []byte(goodReport)}}, nil + return &ExecResult{Success: true, Patches: map[string][]byte{"output.md": []byte(goodReport)}}, nil } func TestStepSkillsOverrideRoleSkills(t *testing.T) { diff --git a/squad/steering_test.go b/squad/steering_test.go index a6d73e5..030179c 100644 --- a/squad/steering_test.go +++ b/squad/steering_test.go @@ -8,8 +8,6 @@ import ( "sync" "testing" "time" - - "agent-native/exec" ) // recordingBackend 记录每次收到的 prompt(按目录),产出按 script。 @@ -22,7 +20,7 @@ type recordingBackend struct { func (b *recordingBackend) Name() string { return "rec" } -func (b *recordingBackend) Execute(ctx context.Context, req exec.Request) (*exec.Result, error) { +func (b *recordingBackend) Execute(ctx context.Context, req ExecRequest) (*ExecResult, error) { key := filepath.Base(filepath.Clean(req.WorkDir)) b.mu.Lock() b.prompts[key] = append(b.prompts[key], req.Prompt) @@ -43,7 +41,7 @@ func (b *recordingBackend) Execute(ctx context.Context, req exec.Request) (*exec if b.tokens { pt, ct = 120, 80 } - return &exec.Result{Success: true, Patches: patches, PromptTokens: pt, CompletionTokens: ct}, nil + return &ExecResult{Success: true, Patches: patches, PromptTokens: pt, CompletionTokens: ct}, nil } func TestSteeringInjectedAtNextStep(t *testing.T) { @@ -137,7 +135,7 @@ type slowCountBackend struct { func (b *slowCountBackend) Name() string { return "slow" } -func (b *slowCountBackend) Execute(ctx context.Context, req exec.Request) (*exec.Result, error) { +func (b *slowCountBackend) Execute(ctx context.Context, req ExecRequest) (*ExecResult, error) { b.mu.Lock() b.cur++ if b.cur > b.peak { @@ -153,7 +151,7 @@ func (b *slowCountBackend) Execute(ctx context.Context, req exec.Request) (*exec _ = os.MkdirAll(filepath.Dir(out), 0o755) _ = os.WriteFile(out, []byte(goodReport), 0o644) } - return &exec.Result{Success: true, Patches: map[string][]byte{}}, nil + return &ExecResult{Success: true, Patches: map[string][]byte{}}, nil } func TestRoleConcurrencyLimit(t *testing.T) { diff --git a/squad/step_retry.go b/squad/step_retry.go new file mode 100644 index 0000000..408b9fa --- /dev/null +++ b/squad/step_retry.go @@ -0,0 +1,200 @@ +package squad + +import ( + "os" + "path/filepath" + "strings" + "unicode" +) + +// MaxTransientStepInvocations is the product-layer bound: the first +// invocation plus one fresh re-invocation. SDK in-process auto_retry is +// separate and must not be confused with this. +const MaxTransientStepInvocations = 2 + +type FailureClass int + +const ( + FailureNone FailureClass = iota + FailureTransient + FailurePermanent +) + +func (c FailureClass) String() string { + switch c { + case FailureTransient: + return "transient" + case FailurePermanent: + return "permanent" + default: + return "none" + } +} + +type TraceSignals struct { + ProviderTransient bool + Settled bool + RetryExhausted bool + HasUsableDeliverable bool // always false: write() args in traces are not deliverables +} + +type StepFailureSignals struct { + Err error + Result *ExecResult + WorkDir string + ExtraText string + TargetMissing bool + // TargetExists means this invocation wrote a non-empty file that differs + // from the pre-invoke baseline. Do not retry or overwrite it. The step + // still fails unless the backend already reported a trusted success. + TargetExists bool +} + +type FailureAssessment struct { + Class FailureClass + Reason string +} + +func ScanTraceText(text string) TraceSignals { + var out TraceSignals + if text == "" { + return out + } + // Line scan only. Never parse write.arguments.content into a body. + for _, line := range strings.Split(text, "\n") { + if line == "" { + continue + } + lower := strings.ToLower(line) + if strings.Contains(lower, `"type":"agent_settled"`) || strings.Contains(lower, `"type": "agent_settled"`) { + out.Settled = true + } + if strings.Contains(lower, `"type":"auto_retry_end"`) && strings.Contains(lower, `"success":false`) { + out.ProviderTransient = true + out.RetryExhausted = true + } + if strings.Contains(lower, `"type":"auto_retry_start"`) { + out.ProviderTransient = true + } + if strings.Contains(lower, `"stopreason":"error"`) { + out.ProviderTransient = true + } + if strings.Contains(lower, "connection error") { + out.ProviderTransient = true + } + if strings.Contains(lower, `"errormessage":"terminated"`) || strings.Contains(lower, `"errormessage": "terminated"`) { + out.ProviderTransient = true + } + } + return out +} + +func ReadWorkDirTraces(workDir string) string { + if strings.TrimSpace(workDir) == "" { + return "" + } + p := filepath.Join(workDir, "execution.log") + b, err := os.ReadFile(p) + if err != nil { + return "" + } + const capBytes = 512 << 10 + if len(b) > capBytes { + b = b[len(b)-capBytes:] + } + return string(b) +} + +func AssessStepFailure(sig StepFailureSignals) FailureAssessment { + reason := combinedFailureText(sig) + lower := strings.ToLower(reason) + trace := ScanTraceText(sig.ExtraText + "\n" + ReadWorkDirTraces(sig.WorkDir)) + missing := sig.TargetMissing || strings.Contains(lower, "missing target deliverable") + if sig.TargetExists { + return FailureAssessment{Class: FailurePermanent, Reason: "target-already-present: do not overwrite"} + } + if isPermanentReason(lower) { + return FailureAssessment{Class: FailurePermanent, Reason: "permanent: " + compactReason(reason)} + } + + runnerFailed := (sig.Result != nil && sig.Result.Retryable) || isRunnerExplicitFailure(lower) + provider := trace.ProviderTransient || hasProviderTermOrConn(lower) + if runnerFailed { + return FailureAssessment{Class: FailureTransient, Reason: "runner-explicit-failure: " + compactReason(reason)} + } + if provider && missing { + kind := "provider-transient-missing-target" + if trace.Settled { + kind = "settled-failure-missing-target" + } + return FailureAssessment{Class: FailureTransient, Reason: kind + ": " + compactReason(reason)} + } + // Model settled cleanly but never wrote the target (e.g. text-only reply, + // no write tool). One product-layer re-invocation with a corrective prompt + // is worth trying; still bounded by MaxTransientStepInvocations. + if missing && trace.Settled && !provider { + return FailureAssessment{Class: FailureTransient, Reason: "settled-no-deliverable: " + compactReason(reason)} + } + if missing { + return FailureAssessment{Class: FailurePermanent, Reason: "permanent-missing: " + compactReason(reason)} + } + return FailureAssessment{Class: FailurePermanent, Reason: "fail-closed: " + compactReason(reason)} +} + +func combinedFailureText(sig StepFailureSignals) string { + var parts []string + if sig.Err != nil { + parts = append(parts, sig.Err.Error()) + } + if sig.Result != nil { + if sig.Result.ErrorReason != "" { + parts = append(parts, sig.Result.ErrorReason) + } + if sig.Result.Logs != "" { + parts = append(parts, sig.Result.Logs) + } + } + return strings.Join(parts, " ") +} + +func isPermanentReason(lower string) bool { + return strings.Contains(lower, "prompt_empty") || + strings.Contains(lower, "unauthorized") || + strings.Contains(lower, "budget_exceeded") || + strings.Contains(lower, "治理强制终止") || + strings.Contains(lower, "治理拦截") +} + +func hasProviderTermOrConn(lower string) bool { + return strings.Contains(lower, "connection error") || + strings.Contains(lower, "terminated") || + strings.Contains(lower, "econnreset") || + strings.Contains(lower, "socket hang up") +} + +func isRunnerExplicitFailure(lower string) bool { + return strings.Contains(lower, "pi remote runner call failed") || + strings.Contains(lower, "auto_retry_failed") || + strings.Contains(lower, "auto_retry_end") || + (hasProviderTermOrConn(lower) && !strings.Contains(lower, "missing target deliverable")) +} + +func compactReason(s string) string { + s = strings.Join(strings.FieldsFunc(s, func(r rune) bool { + return r == '\n' || r == '\r' + }), " ") + s = strings.TrimSpace(s) + if s == "" { + return "unknown" + } + // Drop accidental write-argument bodies if a caller stuffed logs here. + if len(s) > 240 { + s = s[:240] + } + return strings.Map(func(r rune) rune { + if unicode.IsControl(r) && r != '\t' { + return -1 + } + return r + }, s) +} diff --git a/squad/workflow.go b/squad/workflow.go index 0d8f015..d718174 100644 --- a/squad/workflow.go +++ b/squad/workflow.go @@ -10,8 +10,6 @@ import ( "sync" "time" - "agent-native/exec" - "error-code-system/errcode" ) @@ -233,7 +231,7 @@ func (d *flowDriver) runOneStep(ctx context.Context, st *runState, idx int) (*Ru prompt = st.injectSteering(prompt) instructions := readFileOr(filepath.Join(subDir, "AGENTS.md"), d.opts.Workspaces.ResolveInstructions(role)) - var backend exec.Backend + var backend Executor backendName := "" if step.IntentID != "" { backendName = "intent:" + step.IntentID @@ -253,13 +251,13 @@ func (d *flowDriver) runOneStep(ctx context.Context, st *runState, idx int) (*Ru } // 步骤级共享预算:重试与首次尝试共用同一截止时刻 //(agent-execution 标准:重试层共享总预算,不随次数翻倍)。 - var stepBudget *exec.Budget + var stepBudget *ExecBudget if d.opts.StepTimeout > 0 { - stepBudget = &exec.Budget{Deadline: time.Now().Add(d.opts.StepTimeout)} + stepBudget = &ExecBudget{Deadline: time.Now().Add(d.opts.StepTimeout)} } - var result *exec.Result + var result *ExecResult var err error - for attempt := 1; attempt <= exec.MaxTransientStepInvocations; attempt++ { + for attempt := 1; attempt <= MaxTransientStepInvocations; attempt++ { reqPrompt := prompt if attempt > 1 { prevReason := "交付物缺失" @@ -270,14 +268,14 @@ func (d *flowDriver) runOneStep(ctx context.Context, st *runState, idx int) (*Ru } reqPrompt += "\n\n【重试提示】上一次执行未成功(" + prevReason + ")。请务必把结果写入:" + strings.Join(targets, ", ") } - req := exec.Request{ + req := ExecRequest{ WorkDir: subDir, Prompt: reqPrompt, Instructions: instructions, Targets: targets, Timeout: d.opts.StepTimeout, - ToolProfile: orDefault(role.ToolProfile, exec.ToolProfileWorkspace), - OnEvent: func(ev exec.ProcessEvent) { + ToolProfile: orDefault(role.ToolProfile, ToolProfileWorkspace), + OnEvent: func(ev ProcessEvent) { d.emit(st, Event{Type: EvDetail, StepID: step.ID, Role: step.Role, Text: ev.Text, Data: marshalJSON(map[string]string{"process_type": ev.Type, "tool": ev.Tool, "detail": ev.Detail})}) }, @@ -301,7 +299,7 @@ func (d *flowDriver) runOneStep(ctx context.Context, st *runState, idx int) (*Ru Title: step.Title, WorkDir: subDir, Targets: targets, }) if err == nil && result != nil && len(result.Patches) > 0 { - _, _ = exec.MaterializeTargets(subDir, targets, result.Patches) + _, _ = MaterializeTargets(subDir, targets, result.Patches) } } else { result, err = backend.Execute(ctx, req) @@ -311,19 +309,19 @@ func (d *flowDriver) runOneStep(ctx context.Context, st *runState, idx int) (*Ru } // 交付物物化:后端返回 patches 但未写盘时补写。 if err == nil && len(result.Patches) > 0 { - if _, merr := exec.MaterializeTargets(subDir, targets, result.Patches); merr == nil { + if _, merr := MaterializeTargets(subDir, targets, result.Patches); merr == nil { if allTargetsPresent(subDir, targets) { result.Success = true break } } } - assessment := exec.AssessStepFailure(exec.StepFailureSignals{ + assessment := AssessStepFailure(StepFailureSignals{ Err: err, Result: result, WorkDir: subDir, ExtraText: fmt.Sprintf("step=%s backend=%s", step.ID, backendName), TargetMissing: !allTargetsPresent(subDir, targets), }) - if assessment.Class != exec.FailureTransient || attempt == exec.MaxTransientStepInvocations { + if assessment.Class != FailureTransient || attempt == MaxTransientStepInvocations { step.Status = "failed" if err != nil { step.Error = err.Error()