From 75d8c08103b170129cd80696df538f80d14547a1 Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 1 Oct 2026 09:00:13 +0000 Subject: [PATCH 1/2] Add a router fast path and pass host tools through Why: Simple tasks should finish as one assistant step, while complex tasks stay in leader mode, and every kernel step needs the host's tools and events. Evidence: go test ./squad/ -count=1 passed, including direct route, leader entry, skill override, and delegation score fields. Impact: Hosts can inject HostTools and OnEvent; a Router can choose direct or plan without changing product roles. Refs: deliverable-quality-system B2 Co-authored-by: Cursor --- deps/agent-native | 2 +- squad/backend.go | 33 ++++++++++- squad/engine.go | 35 +++++++++--- squad/leader.go | 10 +++- squad/models.go | 4 ++ squad/router.go | 76 +++++++++++++++++++++++++ squad/router_test.go | 130 +++++++++++++++++++++++++++++++++++++++++++ squad/workflow.go | 13 ++++- 8 files changed, 292 insertions(+), 11 deletions(-) create mode 100644 squad/router.go create mode 100644 squad/router_test.go diff --git a/deps/agent-native b/deps/agent-native index f9ab2ef..951c141 160000 --- a/deps/agent-native +++ b/deps/agent-native @@ -1 +1 @@ -Subproject commit f9ab2ef698ff253b8914a081e8651da65de3b38d +Subproject commit 951c141f95b4d6ac0f82804dd5ae9b7a6d7ad31e diff --git a/squad/backend.go b/squad/backend.go index f849b00..b1da8d8 100644 --- a/squad/backend.go +++ b/squad/backend.go @@ -1,6 +1,7 @@ package squad import ( + "context" "fmt" "sync" "time" @@ -71,6 +72,10 @@ type AgentNativeConfig struct { SkillsRoots []string Subagent subagent.Factory Compact *native.CompactConfig + // HostTools 注册进内核工作区。与内置工具同名时该步骤失败,不覆盖。 + HostTools []tools.Tool + // OnEvent 收到内核的逐步过程事件(文本、工具、用量)。与 Request.OnEvent 同时生效。 + OnEvent func(ev exec.ProcessEvent) } // buildAgentNativeOptions 按角色把配置换算为 native.Options。 @@ -94,6 +99,7 @@ func buildAgentNativeOptions(cfg AgentNativeConfig, role RoleSpec) (native.Optio SkillsRoots: cfg.SkillsRoots, Subagent: cfg.Subagent, Compact: cfg.Compact, + HostTools: cfg.HostTools, } if cfg.Client != nil { opts.APIBase, opts.APIKey = cfg.Client.BaseURL, cfg.Client.APIKey @@ -106,6 +112,27 @@ func buildAgentNativeOptions(cfg AgentNativeConfig, role RoleSpec) (native.Optio 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) { @@ -123,7 +150,11 @@ func (r *BackendResolver) RegisterAgentNativeFull(cfg AgentNativeConfig) { if err != nil { return nil, err } - return native.New(opts), nil + next := native.New(opts) + if cfg.OnEvent == nil { + return next, nil + } + return &processEventBackend{next: next, on: cfg.OnEvent}, nil }) } diff --git a/squad/engine.go b/squad/engine.go index 2269dbc..01d245f 100644 --- a/squad/engine.go +++ b/squad/engine.go @@ -43,6 +43,9 @@ type Options struct { // IntentRunner 系统功能步执行器(squadintent 提供)。目录启用而 Runner 缺席时, // intent 步骤 fail-closed。 IntentRunner IntentRunner + // Router 可选分诊。direct = 单步 assistant,不进多角色;plan = leader 委派。 + // nil = 保持调用方传入的 RunOptions.Mode。 + Router Router } // engineDeps 驱动器依赖(由引擎从 Options 派生)。 @@ -217,13 +220,31 @@ func (e *SquadEngine) planAndExecute(ctx context.Context, st *runState, runOpts drv := &flowDriver{opts: e.deps} meta := &st.Meta - bpMatch, _ := e.opts.Blueprints.Match(meta.Goal.Text, runOpts.TaskKey) - planOut, err := e.opts.Planner.Plan(ctx, PlanInput{ - Goal: meta.Goal, Blueprint: bpPtr(bpMatch), Registry: e.opts.Registry, RunID: meta.ID, - IntentCatalog: e.opts.IntentCatalog, - }) - if err != nil { - return e.failNew(meta, err) + if e.opts.Router != nil && runOpts.Mode == "" && meta.Metadata["route"] == "" { + dec, rerr := e.opts.Router.Route(ctx, meta.Goal) + if rerr != nil { + return e.failNew(meta, rerr) + } + applyRoute(meta, dec, runOpts.Mode) + } + + var planOut *PlanOutput + var err error + if meta.Metadata["route_direct"] == "1" { + plan, perr := directAssistantPlan(e.opts.Registry, meta.Goal) + if perr != nil { + return e.failNew(meta, perr) + } + planOut = &PlanOutput{Plan: plan} + } else { + bpMatch, _ := e.opts.Blueprints.Match(meta.Goal.Text, runOpts.TaskKey) + planOut, err = e.opts.Planner.Plan(ctx, PlanInput{ + Goal: meta.Goal, Blueprint: bpPtr(bpMatch), Registry: e.opts.Registry, RunID: meta.ID, + IntentCatalog: e.opts.IntentCatalog, + }) + if err != nil { + return e.failNew(meta, err) + } } // 目录驱动编排的确定性校验:intent 步骤命中目录、必填槽位齐、枚举合法(fail-closed)。 if err := validateIntentSteps(planOut.Plan, e.opts.IntentCatalog); err != nil { diff --git a/squad/leader.go b/squad/leader.go index 2a80bff..af95d37 100644 --- a/squad/leader.go +++ b/squad/leader.go @@ -73,6 +73,8 @@ type delegation struct { Member string `json:"member,omitempty"` Title string `json:"title,omitempty"` Targets []string `json:"targets,omitempty"` + Skills []string `json:"skills,omitempty"` + MinScore int `json:"min_score,omitempty"` Briefing string `json:"briefing,omitempty"` // 给成员的补充指令 Question string `json:"question,omitempty"` // escalate 时的问题 Reason string `json:"reason,omitempty"` @@ -104,7 +106,7 @@ func leaderPrompt(members []RoleSpec, catalogMd string) string { b.WriteString(`你是本小队的 LEADER。你被激活的唯一职责是协调,不是自己实现。 每轮你必须做且只做一件事:评估当前进展,然后输出一份委派决策文件 delegation.json 到本子目录(用工具写入,不要输出到对话里): -{"action":"delegate","member":"成员role","title":"给成员的子任务标题","targets":["output.md"],"briefing":"给成员的补充指令/验收标准","reason":"为什么派给这个人"} +{"action":"delegate","member":"成员role","title":"给成员的子任务标题","targets":["output.md"],"skills":["可选,覆盖该成员本步技能"],"min_score":0,"briefing":"给成员的补充指令/验收标准","reason":"为什么派给这个人"} {"action":"escalate","question":"要向人提出的问题","reason":"为什么需要人拍板"} {"action":"close","reason":"目标已达成,收口"} {"action":"failed","reason":"无法达成的原因"} @@ -316,6 +318,12 @@ func (e *SquadEngine) runLeaderLoop(ctx context.Context, st *runState) (*RunResu } else { step.Targets = []string{"output.md"} } + if len(dec.Skills) > 0 { + step.Skills = dec.Skills + } + if dec.MinScore > 0 { + step.MinScore = dec.MinScore + } st.Plan.Workflow.Steps = append(st.Plan.Workflow.Steps, step) _ = st.save() diff --git a/squad/models.go b/squad/models.go index 9a56d11..7e7d3fe 100644 --- a/squad/models.go +++ b/squad/models.go @@ -103,6 +103,10 @@ type StepSpec struct { AllowDynamicAdjustment bool `json:"allow_dynamic_adjustment,omitempty"` // Critical: nil(默认)= 关键步骤失败即终止;false = 非关键,失败降级继续。 Critical *bool `json:"critical,omitempty"` + // Skills 覆盖角色默认技能。空 = 用角色自己的技能列表。 + Skills []string `json:"skills,omitempty"` + // MinScore 质检总分下限。0 = 不额外设门槛,只看 Checker.Passed。 + MinScore int `json:"min_score,omitempty"` } // IsCritical 报告步骤是否关键(nil 视为关键)。 diff --git a/squad/router.go b/squad/router.go new file mode 100644 index 0000000..c691a2d --- /dev/null +++ b/squad/router.go @@ -0,0 +1,76 @@ +package squad + +import ( + "context" + "fmt" +) + +// RouteMode 是分诊结果。direct 不进入多角色流程;plan 进入 leader 委派。 +type RouteMode string + +const ( + RouteDirect RouteMode = "direct" + RoutePlan RouteMode = "plan" +) + +// RouteDecision 描述这一次目标怎么跑,以及它属于哪一族交付物。 +type RouteDecision struct { + Mode RouteMode + Family string + Reason string +} + +// Router 在规划之前做一次分诊。nil 决策表示不干预。 +type Router interface { + Route(ctx context.Context, goal Goal) (*RouteDecision, error) +} + +// applyRoute 把分诊写进运行元数据。显式 RunOptions.Mode 优先,分诊不覆盖。 +func applyRoute(meta *RunMetadata, dec *RouteDecision, explicitMode string) { + if dec == nil || explicitMode != "" { + return + } + if meta.Metadata == nil { + meta.Metadata = map[string]string{} + } + meta.Metadata["route"] = string(dec.Mode) + if dec.Family != "" { + meta.Metadata["deliverable_family"] = dec.Family + } + if dec.Reason != "" { + meta.Metadata["route_reason"] = dec.Reason + } + switch dec.Mode { + case RouteDirect: + meta.Metadata["route_direct"] = "1" + case RoutePlan: + meta.Metadata["mode"] = "leader" + if meta.Metadata["leader_role"] == "" { + meta.Metadata["leader_role"] = "leader" + } + } +} + +// directAssistantPlan 生成只有 assistant 一步的计划。 +func directAssistantPlan(reg *RoleRegistry, goal Goal) (*SquadPlan, error) { + spec, ok := reg.Get("assistant") + if !ok { + return nil, fmt.Errorf("direct route requires a registered assistant role") + } + return &SquadPlan{ + Squad: SquadConfig{Agents: []RoleSpec{spec}}, + Workflow: WorkflowPlan{ + WorkflowID: NewPlanID(), + Title: goal.Text, + Status: "pending", + Steps: []StepSpec{{ + ID: "assistant", + Role: "assistant", + Title: goal.Text, + Targets: []string{"output.md"}, + SubWorkspace: "tasks/01_assistant", + Status: "pending", + }}, + }, + }, nil +} diff --git a/squad/router_test.go b/squad/router_test.go new file mode 100644 index 0000000..71da1a8 --- /dev/null +++ b/squad/router_test.go @@ -0,0 +1,130 @@ +package squad + +import ( + "context" + "os" + "path/filepath" + "testing" + + "agent-native/exec" +) + +type fixedRouter struct{ dec *RouteDecision } + +func (r fixedRouter) Route(ctx context.Context, goal Goal) (*RouteDecision, error) { + return r.dec, nil +} + +func TestRouterDirectRunsAssistantOnly(t *testing.T) { + dir := t.TempDir() + reg := NewRoleRegistry() + if err := reg.Register(RoleSpec{Role: "assistant", Name: "助手", Backend: "script"}); err != nil { + t.Fatal(err) + } + backends := NewBackendResolver() + backends.RegisterInstance("script", &scriptBackend{script: map[string]map[string]string{ + "01_assistant": {"output.md": goodReport}, + }}) + engine, err := NewEngine(Options{ + DataDir: dir, Registry: reg, Planner: NewMockPlanner(), Backends: backends, + HitlPolicy: HitlPolicy{Mode: HitlDisabled}, + Router: fixedRouter{dec: &RouteDecision{Mode: RouteDirect, Family: "minutes", Reason: "短任务"}}, + }) + if err != nil { + t.Fatal(err) + } + res, err := engine.Run(context.Background(), Goal{Text: "写一句纪要"}, RunOptions{}) + if err != nil { + t.Fatal(err) + } + if res.Status != RunCompleted { + t.Fatalf("status %s err=%s", res.Status, res.Error) + } + if res.Plan == nil || len(res.Plan.Steps) != 1 || res.Plan.Steps[0].Role != "assistant" { + t.Fatalf("plan = %+v", res.Plan) + } +} + +func TestRouterPlanEntersLeaderMode(t *testing.T) { + dir := t.TempDir() + reg := NewRoleRegistry() + _ = reg.Register(RoleSpec{Role: "writer", Name: "撰写", Backend: "script"}) + backends := NewBackendResolver() + backends.RegisterInstance("script", &scriptBackend{script: map[string]map[string]string{}}) + engine, err := NewEngine(Options{ + DataDir: dir, Registry: reg, + Planner: mockScriptPlanner(StepSpec{ID: "w", Role: "writer", Title: "写", Targets: []string{"output.md"}}), + Backends: backends, + HitlPolicy: HitlPolicy{Mode: HitlDisabled}, + Router: fixedRouter{dec: &RouteDecision{Mode: RoutePlan, Family: "report"}}, + }) + if err != nil { + t.Fatal(err) + } + res, err := engine.Run(context.Background(), Goal{Text: "写完整报告"}, RunOptions{}) + if err != nil { + t.Fatal(err) + } + if res.Status != RunFailed || res.Error == "" { + t.Fatalf("missing leader should fail closed, got %s %s", res.Status, res.Error) + } +} + +func TestDelegationCarriesSkillsAndScore(t *testing.T) { + dir := t.TempDir() + body := []byte(`{"action":"delegate","member":"writer","title":"成文","skills":["docx-author"],"min_score":70}`) + if err := os.WriteFile(filepath.Join(dir, "delegation.json"), body, 0o644); err != nil { + t.Fatal(err) + } + dec, err := parseDelegation(dir) + if err != nil { + t.Fatal(err) + } + if len(dec.Skills) != 1 || dec.Skills[0] != "docx-author" || dec.MinScore != 70 { + t.Fatalf("delegation = %+v", dec) + } +} + +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) { + 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 +} + +func TestStepSkillsOverrideRoleSkills(t *testing.T) { + dir := t.TempDir() + reg := NewRoleRegistry() + _ = reg.Register(RoleSpec{Role: "writer", Name: "撰写", Backend: "capture-skills", Skills: []string{"role-skill"}}) + got := &captureSkillsBackend{} + backends := NewBackendResolver() + backends.RegisterInstance("capture-skills", got) + engine, err := NewEngine(Options{ + DataDir: dir, Registry: reg, Backends: backends, + HitlPolicy: HitlPolicy{Mode: HitlDisabled}, + Planner: &MockPlanner{Script: []StepSpec{{ + ID: "w", Role: "writer", Title: "写", Targets: []string{"output.md"}, + SubWorkspace: "tasks/01_writer", Skills: []string{"docx-author"}, MinScore: 10, + }}}, + }) + if err != nil { + t.Fatal(err) + } + res, err := engine.Run(context.Background(), Goal{Text: "写"}, RunOptions{}) + if err != nil { + t.Fatal(err) + } + if res.Status != RunCompleted { + t.Fatalf("status %s err=%s", res.Status, res.Error) + } + if len(got.got) != 1 || got.got[0] != "docx-author" { + t.Fatalf("request skills = %v", got.got) + } +} diff --git a/squad/workflow.go b/squad/workflow.go index 17a7bab..d8da6ca 100644 --- a/squad/workflow.go +++ b/squad/workflow.go @@ -1,6 +1,7 @@ package squad import ( + "bytes" "context" "fmt" "os" @@ -237,7 +238,7 @@ func (d *flowDriver) runOneStep(ctx context.Context, st *runState, idx int) (*Ru }, // RoleSpec v2 贯通:工具白名单 / 技能 / 标识(原为死字段或缺失)。 ToolAllowlist: role.Tools, - Skills: role.Skills, + Skills: firstNonEmptySlice(step.Skills, role.Skills), SessionScope: st.Meta.ID + "/" + step.ID, ExecutionID: st.Meta.ID + "-" + step.ID, Budget: stepBudget, @@ -532,6 +533,10 @@ func (d *flowDriver) finalize(ctx context.Context, st *runState) (*RunResult, *h RunID: st.Meta.ID, Workspace: st.Meta.WorkDir, Deliverable: deliverable, Plan: planPtr(st), Squad: &st.Plan.Squad, Template: st.Template, }) + if srcStep.MinScore > 0 && report.Score < float64(srcStep.MinScore) { + report.Passed = false + report.Violations = append(report.Violations, fmt.Sprintf("总分 %.0f 低于步骤门槛 %d", report.Score, srcStep.MinScore)) + } if !report.Passed && st.RepairBudgetRemaining > 0 { st.RepairBudgetRemaining-- _ = st.save() @@ -607,6 +612,12 @@ func (d *flowDriver) rerunForRepair(ctx context.Context, st *runState, stepID st if st.Plan.Workflow.Steps[i].ID == stepID { st.Plan.Workflow.Steps[i].Status = "pending" st.Plan.Workflow.Steps[i].Error = "" + note, err := os.ReadFile(filepath.Join(st.Meta.WorkDir, "quality-feedback.md")) + if err == nil && len(bytes.TrimSpace(note)) > 0 { + taskPath := filepath.Join(st.Meta.WorkDir, filepath.FromSlash(st.Plan.Workflow.Steps[i].SubWorkspace), "TASK.md") + existing, _ := os.ReadFile(taskPath) + _ = os.WriteFile(taskPath, append(existing, append([]byte("\n\n"), note...)...), 0o644) + } } } d.emit(st, Event{Type: EvPhase, Text: "质检未通过,自动修复:重跑交付步骤 " + stepID}) From d38f838dbca0587470050b42afb91ecc1df49321 Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 1 Oct 2026 09:06:28 +0000 Subject: [PATCH 2/2] Pass a scripted stream and tool-call cap into the kernel Why: Tests and product budgets need to drive each kernel step without a live model, and stop it when the tool-call cap is hit. Evidence: go test ./squad/ -count=1 passed after fetching agent-native cfc840c. Impact: AgentNativeConfig.Stream and MaxToolCalls reach native.Options. The nested kernel pin moves to cfc840c. Refs: deliverable-quality-system B2 Co-authored-by: Cursor --- deps/agent-native | 2 +- squad/backend.go | 26 ++++++++++++++++---------- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/deps/agent-native b/deps/agent-native index 951c141..cfc840c 160000 --- a/deps/agent-native +++ b/deps/agent-native @@ -1 +1 @@ -Subproject commit 951c141f95b4d6ac0f82804dd5ae9b7a6d7ad31e +Subproject commit cfc840c11d1e85b8f780dbb4030e9f62f670fa3b diff --git a/squad/backend.go b/squad/backend.go index b1da8d8..900449e 100644 --- a/squad/backend.go +++ b/squad/backend.go @@ -76,6 +76,10 @@ type AgentNativeConfig struct { HostTools []tools.Tool // OnEvent 收到内核的逐步过程事件(文本、工具、用量)。与 Request.OnEvent 同时生效。 OnEvent func(ev exec.ProcessEvent) + // Stream 测试注入。为空时按 Client 访问模型。 + Stream llm.StreamFunc + // MaxToolCalls 单步工具调用上限。0 表示不限制。 + MaxToolCalls int } // buildAgentNativeOptions 按角色把配置换算为 native.Options。 @@ -90,16 +94,18 @@ func buildAgentNativeOptions(cfg AgentNativeConfig, role RoleSpec) (native.Optio 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, + 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