diff --git a/README.md b/README.md index ef2cfa3..a94aa4d 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,14 @@ git submodule update --init --recursive 注册工厂),产品也可注册 `pi-agent` 等外部智能体工厂。`Model`/`ToolProfile`/`Skills` 角色级注入——即每个角色可以用不同的智能体、不同的模型。 +执行配置贯通(full-agent-r1 起):`ToolProfile`/`Tools`(工具白名单,支持 +`mcp__server__*` 命名空间通配)/`Skills`/`BashPermission`/`Model` 全部随步骤请求 +下发到 agent-native(`Tools`/`Skills` 原为死字段);`BashPermission=deny`(默认) +的角色不装配执行工具,`ask` 由 permission 挂起点先行把关,`allow` 直接放行 +(工厂层显式 allow/ask 才装配,派生构造同样兜底)。`RegisterAgentNativeFull` +接受 `AgentNativeConfig`(搜索/取回/执行/MCP/技能根/委派/压缩)由组合根注入; +步骤重试与首试共享同一截止时刻(agent-execution 标准的共享预算语义)。 + 角色来源三种(`RoleSource`): - **registered**:配置文件(`roles.d/*.json`)/ HTTP API / CLI 手动注册; - **derived**:规划器或用户按 `base_role` 基线现场派生(领域特化专员); @@ -76,7 +84,8 @@ git submodule update --init --recursive ## Leader-Delegation 编排模式(机制参考 Multica Squad Leader,原创实现) -除标准模式(一次性计划顺序执行)外,支持事件驱动的增量委派循环 +除标准模式(一次性计划顺序执行)外,支持事件驱动的增量委派循环(HTTP API +`POST /v1/runs` 亦支持 `mode`/`leader_role`/`max_leader_turns`,与 CLI 等价) (`--mode leader --leader-role `):leader 是注册表中的普通角色,每轮被 引擎激活——读目标/黑板/委派历史,产出结构化决策 `delegation.json`: diff --git a/deps/agent-native b/deps/agent-native index 292142a..f9ab2ef 160000 --- a/deps/agent-native +++ b/deps/agent-native @@ -1 +1 @@ -Subproject commit 292142ac1a8b125488794e9374faee2cb1c8c579 +Subproject commit f9ab2ef698ff253b8914a081e8651da65de3b38d diff --git a/server/server.go b/server/server.go index 31006f8..383358d 100644 --- a/server/server.go +++ b/server/server.go @@ -197,6 +197,11 @@ type createRunRequest struct { Goal squad.Goal `json:"goal"` TaskKey string `json:"task_key,omitempty"` Backend string `json:"backend,omitempty"` + // Mode 编排模式:standard(默认)| leader(leader-delegation)。 + Mode string `json:"mode,omitempty"` + // LeaderRole / MaxLeaderTurns leader 模式参数(CLI 等价项)。 + LeaderRole string `json:"leader_role,omitempty"` + MaxLeaderTurns int `json:"max_leader_turns,omitempty"` // MaterialsB64 便捷通道:filename → base64 内容。 MaterialsB64 map[string]string `json:"materials_b64,omitempty"` } @@ -222,7 +227,10 @@ func (s *Server) handleCreateRun(w http.ResponseWriter, r *http.Request) { // 同步发起:Run 在首次挂起或终态前返回;长时间执行由调用方带超时/异步包装。 // 这里为 HTTP 体验做异步:先返回 run_id 无法预知(ID 在 Run 内生成), // 因此采用同步调用 + 流式等待由调用方控制超时。 - res, err := s.engine.Run(r.Context(), req.Goal, squad.RunOptions{TaskKey: req.TaskKey, Backend: req.Backend}) + res, err := s.engine.Run(r.Context(), req.Goal, squad.RunOptions{ + TaskKey: req.TaskKey, Backend: req.Backend, + Mode: req.Mode, LeaderRole: req.LeaderRole, MaxLeaderTurns: req.MaxLeaderTurns, + }) if err != nil { s.writeErr(w, r, err) return diff --git a/server/server_test.go b/server/server_test.go index 8785192..6f2ea6d 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -302,3 +302,31 @@ func TestHealthAndBlueprints(t *testing.T) { t.Fatalf("blueprints: %v", bps) } } + +func TestCreateRunAcceptsLeaderModeOptions(t *testing.T) { + ts, eng := newTestServer(t) + defer ts.Close() + // leader 模式先过标准计划校验(规则骨架引用 researcher/writer/reviewer), + // 再进 leader 循环——四类角色都要注册。 + for _, role := range []string{"leader", "researcher", "writer", "reviewer"} { + _, _ = doJSON(t, "POST", ts.URL+"/v1/roles", map[string]any{"role": role, "backend": "script"}) + } + // 直接驱动引擎拿可读错误(HTTP 信封吞 reason)。 + if _, gerr := eng.Run(context.Background(), squad.Goal{Text: "写一份关于主题X的调研报告,输出markdown交付物"}, + squad.RunOptions{Mode: "leader", LeaderRole: "leader", MaxLeaderTurns: 3}); gerr != nil { + t.Fatalf("engine leader run must return a result not an error, got: %v", gerr) + } + code, out := doJSON(t, "POST", ts.URL+"/v1/runs", map[string]any{ + "goal": map[string]any{"text": "写一份关于主题X的调研报告,输出markdown交付物"}, + "mode": "leader", + "leader_role": "leader", + "max_leader_turns": 3, + }) + if code != http.StatusOK { + t.Fatalf("leader options must be accepted, got http %d: %v", code, out) + } + runID, _ := out["run_id"].(string) + if runID == "" { + t.Fatalf("run id missing: %v", out) + } +} diff --git a/squad/backend.go b/squad/backend.go index 0d2aa7b..f849b00 100644 --- a/squad/backend.go +++ b/squad/backend.go @@ -8,6 +8,9 @@ import ( "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" ) @@ -53,21 +56,72 @@ func (r *BackendResolver) RegisterInstance(name string, b exec.Backend) { r.underlying[name] = b } -// RegisterAgentNative 注册 "agent-native" 后端工厂:每个角色一个 -// agent-native 自研内核实例(runtime 循环 + 工作区工具),角色级 Model -// 覆盖 defaultModel。智能体执行全部委托 agent-native,本库不实现 Agent。 +// 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 +} + +// 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, + } + 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 +} + +// 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) { - model := role.Model - if model == "" { - model = defaultModel - } - if model == "" { - return nil, fmt.Errorf("agent-native backend requires a model (role=%s)", role.Role) - } - opts := native.Options{Model: model, MaxTurns: maxTurns, Timeout: timeout} - if client != nil { - opts.APIBase, opts.APIKey = client.BaseURL, client.APIKey + opts, err := buildAgentNativeOptions(cfg, role) + if err != nil { + return nil, err } return native.New(opts), nil }) diff --git a/squad/leader.go b/squad/leader.go index 364326b..2a80bff 100644 --- a/squad/leader.go +++ b/squad/leader.go @@ -12,15 +12,31 @@ import ( "agent-native/exec" ) -// execRequest 构造 leader 步骤的执行请求。 -func execRequest(workDir, prompt, instructions string, targets []string, timeout time.Duration) exec.Request { +// 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 { + stepID := fmt.Sprintf("leader_turn_%d", st.Leader.Turns) + var budget *exec.Budget + if timeout > 0 { + budget = &exec.Budget{Deadline: time.Now().Add(timeout)} + } return exec.Request{ - WorkDir: workDir, - Prompt: prompt, - Instructions: instructions, - Targets: targets, - Timeout: timeout, - OnEvent: func(ev exec.ProcessEvent) {}, + WorkDir: workDir, + Prompt: prompt, + Instructions: instructions, + Targets: targets, + Timeout: timeout, + ToolProfile: orDefault(leader.ToolProfile, exec.ToolProfileWorkspace), + ToolAllowlist: leader.Tools, + Skills: leader.Skills, + SessionScope: st.Meta.ID + "/" + stepID, + ExecutionID: st.Meta.ID + "-" + stepID, + Budget: budget, + OnEvent: func(ev exec.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})}) + }, } } @@ -233,7 +249,7 @@ func (e *SquadEngine) runLeaderLoop(ctx context.Context, st *runState) (*RunResu prompt := readFileOr(filepath.Join(subDir, "TASK.md"), leaderTaskBrief(st)) prompt = st.injectSteering(prompt) instructions := readFileOr(filepath.Join(subDir, "AGENTS.md"), leaderPrompt(members, e.opts.IntentCatalog.intentCatalogMd())) - result, execErr := backend.Execute(ctx, execRequest(subDir, prompt, instructions, []string{"delegation.json"}, e.deps.StepTimeout)) + result, execErr := backend.Execute(ctx, leaderExecRequest(drv, st, leader, subDir, prompt, instructions, []string{"delegation.json"}, e.deps.StepTimeout)) if execErr != nil || result == nil || !result.Success { reason := "leader execution failed" if execErr != nil { diff --git a/squad/leader_test.go b/squad/leader_test.go index 47a9fbc..dd0b827 100644 --- a/squad/leader_test.go +++ b/squad/leader_test.go @@ -380,3 +380,102 @@ func TestWakeupGuardMaxFires(t *testing.T) { } _ = time.Now } + +// eventingBackend 在执行时触发过程事件,验证 OnEvent 桥接到 detail 事件。 +type eventingBackend struct { + inner *leaderScriptBackend + mu sync.Mutex + reqs []exec.Request +} + +func (b *eventingBackend) Name() string { return "leader-script" } + +func (b *eventingBackend) Execute(ctx context.Context, req exec.Request) (*exec.Result, 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"}) + } + return b.inner.Execute(ctx, req) +} + +func TestLeaderTurnsEmitDetailEventsAndCarryProfile(t *testing.T) { + inner := &leaderScriptBackend{ + decisions: []string{delegateJSON("writer", "撰写"), closeJSON}, + script: map[string]map[string]string{"01_writer": {"output.md": goodReport}}, + } + eb := &eventingBackend{inner: inner} + dir := t.TempDir() + reg := NewRoleRegistry() + leader := RoleSpec{ + Role: "leader", Name: "主管", Backend: "leader-script", + ToolProfile: "workspace", Skills: []string{"协调"}, + Tools: []string{"read_file", "grep"}, + } + if err := reg.Register(leader); err != nil { + t.Fatal(err) + } + if err := reg.Register(RoleSpec{Role: "writer", Name: "撰写", Backend: "leader-script"}); err != nil { + t.Fatal(err) + } + backends := NewBackendResolver() + backends.RegisterInstance("leader-script", eb) + collector := &sliceSink{} + engine, err := NewEngine(Options{DataDir: dir, Registry: reg, Backends: backends, Sink: collector}) + if err != nil { + t.Fatal(err) + } + res, err := engine.Run(context.Background(), Goal{Text: "产出简报"}, RunOptions{Mode: "leader", LeaderRole: "leader"}) + if err != nil { + t.Fatal(err) + } + if res.Status != RunCompleted { + t.Fatalf("want completed, got %s err=%s", res.Status, res.Error) + } + // leader 轮的请求带上了 ToolProfile/白名单/技能与标识(修平 leader 请求缺口)。 + var leaderReq *exec.Request + for i := range eb.reqs { + if strings.HasSuffix(eb.reqs[i].WorkDir, "00_leader") { + leaderReq = &eb.reqs[i] + } + } + if leaderReq == nil { + t.Fatal("leader request missing") + } + if leaderReq.ToolProfile != exec.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_") { + t.Fatalf("leader execution id missing: %q", leaderReq.ExecutionID) + } + // 过程事件桥接到 detail 事件流。 + sawToolStart, sawToolEnd := false, false + for _, ev := range collector.events { + if ev.Type == EvDetail { + var data map[string]string + _ = json.Unmarshal(ev.Data, &data) + if data["process_type"] == "tool_execution_start" && data["tool"] == "grep" { + sawToolStart = true + } + if data["process_type"] == "tool_execution_end" { + sawToolEnd = true + } + } + } + if !sawToolStart || !sawToolEnd { + t.Fatalf("leader process events must reach the sink (start=%v end=%v)", sawToolStart, sawToolEnd) + } +} + +type sliceSink struct { + mu sync.Mutex + events []Event +} + +func (s *sliceSink) Emit(ev Event) { + s.mu.Lock() + defer s.mu.Unlock() + s.events = append(s.events, ev) +} diff --git a/squad/models.go b/squad/models.go index 3cad12c..9a56d11 100644 --- a/squad/models.go +++ b/squad/models.go @@ -15,6 +15,13 @@ const ( ) // RoleSpec 声明一个小队角色的完整设定。空表起步:库不预置任何角色。 +// BashPermission 三态(执行类工具的授权档位)。 +const ( + BashPermissionAllow = "allow" // 装配执行工具,无人工门 + BashPermissionAsk = "ask" // 装配执行工具,步骤前 HITL permission 门 + BashPermissionDeny = "deny" // 不装配执行工具(默认) +) + type RoleSpec struct { Role string `json:"role"` Name string `json:"name"` diff --git a/squad/planner_llm.go b/squad/planner_llm.go index 677ee3d..cb7d149 100644 --- a/squad/planner_llm.go +++ b/squad/planner_llm.go @@ -50,6 +50,10 @@ type llmAgent struct { BaseRole string `json:"base_role,omitempty"` Model string `json:"model,omitempty"` Backend string `json:"backend,omitempty"` + ToolProfile string `json:"tool_profile,omitempty"` // workspace | reasoning + Tools []string `json:"tools,omitempty"` // 工具白名单(可选) + Skills []string `json:"skills,omitempty"` // 技能标识(须已安装) + BashPermission string `json:"bash_permission,omitempty"` // allow | ask | deny DomainTopic string `json:"domain_topic,omitempty"` Responsibilities []string `json:"responsibilities,omitempty"` } @@ -70,7 +74,7 @@ const llmPlannerSystem = `你是智能体小队的规划器。给定用户目标 1. 只输出一个 JSON 对象,不要输出任何解释文字或代码围栏。 2. 目标信息不足时必须输出 {"clarify":[{"id":"q1","text":"问题","options":[{"value":"continue","label":"…"}]}]} 并停止,不要替用户假设。判定标准(任一命中即 clarify):目标少于 15 个字;没有明确的对象/主题(如"帮我写个东西");没有可辨识的交付物;关键约束互相矛盾。 3. 正常情况输出 {"agents":[...],"steps":[...],"confirm":bool,"confirm_reason":"...","template":"markdown 骨架"}。 -4. agents: 优先复用已注册角色({"action":"use","role":"已注册key"});确无合适角色才派生({"action":"derive","role":"新key","base_role":"基线key","name":"中文名","domain_topic":"领域","responsibilities":[...]})。派生数量尽量少。 +4. agents: 优先复用已注册角色({"action":"use","role":"已注册key"});确无合适角色才派生({"action":"derive","role":"新key","base_role":"基线key","name":"中文名","domain_topic":"领域","responsibilities":[...]})。派生数量尽量少。派生角色可附执行配置:tool_profile(workspace|reasoning,默认 workspace)、tools(工具白名单,如 ["read_file","grep"] 或命名空间通配 ["mcp__github__*"])、skills(技能标识,须已安装)、bash_permission(allow|ask|deny,默认 deny=禁用命令执行;ask=先问人)。仅在角色确需该能力时声明,默认不展开。 5. steps: 每步 {"id":"step_01_x","role":"角色key","title":"步骤职责"};可并行的相邻步骤 parallel=true;高风险/破坏性/对外发布类步骤 requires_approval=true;步骤通常 2~6 个,含梳理、专业执行、汇总成文、终审把关。 6. 涉及写入、删除、对外提交或超出常规预算的计划 confirm=true。 7. 通用守则:缺失的信息若会实质改变结果、执行路径、权限或安全,必须 clarify 问人;否则自行决定并在计划中说明你决定了什么。严禁假设交付完成:无法覆盖的目标范围要如实体现在步骤或假设中,不许静默缩水。 @@ -138,6 +142,10 @@ func (p *LLMPlanner) materialize(input PlanInput, parsed *llmPlanSchema) (*PlanO BaseRole: la.BaseRole, Backend: la.Backend, Model: la.Model, + ToolProfile: la.ToolProfile, + Tools: la.Tools, + Skills: la.Skills, + BashPermission: la.BashPermission, DomainTopic: la.DomainTopic, Responsibilities: la.Responsibilities, }) diff --git a/squad/registry.go b/squad/registry.go index 34b1a21..d1e5cbe 100644 --- a/squad/registry.go +++ b/squad/registry.go @@ -112,6 +112,10 @@ type DeriveRequest struct { BaseRole string `json:"base_role,omitempty"` Backend string `json:"backend,omitempty"` Model string `json:"model,omitempty"` + ToolProfile string `json:"tool_profile,omitempty"` + Tools []string `json:"tools,omitempty"` + Skills []string `json:"skills,omitempty"` + BashPermission string `json:"bash_permission,omitempty"` DomainTopic string `json:"domain_topic,omitempty"` Responsibilities []string `json:"responsibilities,omitempty"` ForbiddenActions []string `json:"forbidden_actions,omitempty"` @@ -138,10 +142,10 @@ func (r *RoleRegistry) Derive(req DeriveRequest) (RoleSpec, error) { BaseRole: base.Role, Backend: firstNonEmpty(req.Backend, base.Backend), Model: firstNonEmpty(req.Model, base.Model), - ToolProfile: base.ToolProfile, - Tools: append([]string{}, base.Tools...), - Skills: append([]string{}, base.Skills...), - BashPermission: base.BashPermission, + ToolProfile: firstNonEmpty(req.ToolProfile, base.ToolProfile), + Tools: firstNonEmptySlice(req.Tools, base.Tools), + Skills: firstNonEmptySlice(req.Skills, base.Skills), + BashPermission: firstNonEmpty(req.BashPermission, base.BashPermission), Responsibilities: append([]string{}, req.Responsibilities...), ForbiddenActions: append([]string{}, req.ForbiddenActions...), Instructions: firstNonEmpty(req.Instructions, base.Instructions), @@ -161,6 +165,14 @@ func (r *RoleRegistry) Derive(req DeriveRequest) (RoleSpec, error) { return spec, nil } +// firstNonEmptySlice 首个非空切片,否则回退后者。 +func firstNonEmptySlice(a, b []string) []string { + if len(a) > 0 { + return append([]string{}, a...) + } + return append([]string{}, b...) +} + func firstNonEmpty(vals ...string) string { for _, v := range vals { if v != "" { diff --git a/squad/request_v2_test.go b/squad/request_v2_test.go new file mode 100644 index 0000000..2da1d35 --- /dev/null +++ b/squad/request_v2_test.go @@ -0,0 +1,199 @@ +package squad + +import ( + "context" + "os" + "path/filepath" + "strings" + "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 + script map[string]string // 子目录名 → 单一目标内容 +} + +func (b *captureBackend) Name() string { return "capture" } + +func (b *captureBackend) Execute(ctx context.Context, req exec.Request) (*exec.Result, error) { + b.mu.Lock() + b.reqs = append(b.reqs, req) + b.mu.Unlock() + key := filepath.Base(filepath.Clean(req.WorkDir)) + content := b.script[key] + if content == "" { + content = "# 默认交付物\n\n脚本后端生成的合格内容,长度满足最小质检要求。\n" + } + patches := map[string][]byte{} + for _, t := range req.Targets { + out := filepath.Join(req.WorkDir, t) + _ = os.MkdirAll(filepath.Dir(out), 0o755) + _ = os.WriteFile(out, []byte(content), 0o644) + patches[t] = []byte(content) + } + return &exec.Result{Success: true, Patches: patches}, nil +} + +func TestStepRequestCarriesRoleSpecV2Fields(t *testing.T) { + dir := t.TempDir() + reg := NewRoleRegistry() + role := RoleSpec{ + Role: "researcher", Name: "调研", Backend: "capture", + Tools: []string{"read_file", "grep"}, Skills: []string{"docx-author"}, + } + if err := reg.Register(role); err != nil { + t.Fatal(err) + } + capture := &captureBackend{} + backends := NewBackendResolver() + backends.RegisterInstance("capture", capture) + engine, err := NewEngine(Options{ + DataDir: dir, Registry: reg, + Planner: mockScriptPlanner(StepSpec{ID: "s1", Role: "researcher", Title: "调研", Targets: []string{"output.md"}}), + Backends: backends, Sink: NopSink{}, + }) + 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("want completed, got %s (err=%s)", res.Status, res.Error) + } + if len(capture.reqs) == 0 { + t.Fatal("capture backend never invoked") + } + req := capture.reqs[0] + if len(req.ToolAllowlist) != 2 || req.ToolAllowlist[0] != "read_file" { + t.Fatalf("role.Tools must flow into ToolAllowlist, got %v", req.ToolAllowlist) + } + if len(req.Skills) != 1 || req.Skills[0] != "docx-author" { + t.Fatalf("role.Skills must flow into request, got %v", req.Skills) + } + if !strings.Contains(req.SessionScope, "s1") || !strings.Contains(req.ExecutionID, "s1") { + t.Fatalf("identifiers missing: scope=%q exec=%q", req.SessionScope, req.ExecutionID) + } + if req.Budget == nil || time.Until(req.Budget.Deadline) <= 0 { + t.Fatal("step budget deadline must be set and in the future") + } +} + +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{ + Role: "worker", Backend: "agent-native", Model: "m", ToolProfile: "workspace", + Tools: []string{"read_file"}, Skills: []string{"s1"}, BashPermission: "ask", + } + if err := reg.Register(base); err != nil { + t.Fatal(err) + } + derived, err := reg.Derive(DeriveRequest{ + RoleKey: "py-worker", BaseRole: "worker", + Tools: []string{"read_file", "run_python"}, + Skills: []string{"s1", "xlsx-author"}, + ToolProfile: "workspace", + BashPermission: "allow", + }) + if err != nil { + t.Fatal(err) + } + if derived.ToolProfile != "workspace" || derived.BashPermission != "allow" { + t.Fatalf("derive must honor explicit exec config: %+v", derived) + } + if len(derived.Tools) != 2 || len(derived.Skills) != 2 { + t.Fatalf("derive must take request slices over base: tools=%v skills=%v", derived.Tools, derived.Skills) + } + // 未显式给定时继承基线。 + inherits, err := reg.Derive(DeriveRequest{RoleKey: "plain", BaseRole: "worker"}) + if err != nil { + t.Fatal(err) + } + if inherits.BashPermission != "ask" || len(inherits.Tools) != 1 { + t.Fatalf("derive must inherit base exec config: %+v", inherits) + } +} + +func TestRoleHitlPolicyOverridesGlobal(t *testing.T) { + dir := t.TempDir() + reg := NewRoleRegistry() + // bash=ask + 角色级 auto:不再挂起等审批,而是直接失败(auto 无值守语义)。 + if err := reg.Register(RoleSpec{ + Role: "runner", Name: "执行", Backend: "capture", BashPermission: BashPermissionAsk, + HitlPolicy: &HitlPolicy{Mode: HitlAuto}, + }); err != nil { + t.Fatal(err) + } + capture := &captureBackend{} + backends := NewBackendResolver() + backends.RegisterInstance("capture", capture) + engine, err := NewEngine(Options{ + DataDir: dir, Registry: reg, + Planner: mockScriptPlanner(StepSpec{ID: "s1", Role: "runner", Title: "跑", Targets: []string{"output.md"}}), + Backends: backends, Sink: NopSink{}, + }) + 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 || !strings.Contains(res.Error, "permission denied") { + t.Fatalf("role auto policy must fail the bash=ask step instead of holding, got %s err=%s", res.Status, res.Error) + } + if len(capture.reqs) != 0 { + t.Fatal("unapproved bash step must never execute") + } +} diff --git a/squad/workflow.go b/squad/workflow.go index 7f37a8e..17a7bab 100644 --- a/squad/workflow.go +++ b/squad/workflow.go @@ -37,10 +37,10 @@ func (d *flowDriver) advance(ctx context.Context, st *runState) (res *RunResult, // HITL:角色权限升级(bash=ask 且本运行未授权过该角色)。 role, _ := findAgent(st.Plan.Squad.Agents, step.Role) - if role.BashPermission == "ask" && !st.Approved["role:"+role.Role] { - switch d.policy(st).mode() { + if role.BashPermission == BashPermissionAsk && !st.Approved["role:"+role.Role] { + switch d.policyRole(st, role).mode() { case HitlDisabled, HitlAuto: - step.Error = "permission denied: role " + role.Role + " requires bash approval (hitl mode=" + string(d.policy(st).mode()) + ")" + step.Error = "permission denied: role " + role.Role + " requires bash approval (hitl mode=" + string(d.policyRole(st, role).mode()) + ")" st.Plan.Workflow.Steps[i] = step return d.failStep(ctx, st, i) default: @@ -55,9 +55,9 @@ func (d *flowDriver) advance(ctx context.Context, st *runState) (res *RunResult, } } - // HITL:步骤显式要求审批。 + // HITL:步骤显式要求审批(角色级策略参与判定)。 if step.RequiresApproval && !st.Approved["step:"+step.ID] { - switch d.policy(st).mode() { + switch d.policyRole(st, role).mode() { case HitlDisabled, HitlAuto: step.Error = "approval required but hitl mode is " + string(d.policy(st).mode()) st.Plan.Workflow.Steps[i] = step @@ -205,6 +205,12 @@ func (d *flowDriver) runOneStep(ctx context.Context, st *runState, idx int) (*Ru if len(targets) == 0 { targets = []string{"output.md"} } + // 步骤级共享预算:重试与首次尝试共用同一截止时刻 + //(agent-execution 标准:重试层共享总预算,不随次数翻倍)。 + var stepBudget *exec.Budget + if d.opts.StepTimeout > 0 { + stepBudget = &exec.Budget{Deadline: time.Now().Add(d.opts.StepTimeout)} + } var result *exec.Result var err error for attempt := 1; attempt <= exec.MaxTransientStepInvocations; attempt++ { @@ -229,6 +235,12 @@ func (d *flowDriver) runOneStep(ctx context.Context, st *runState, idx int) (*Ru 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})}) }, + // RoleSpec v2 贯通:工具白名单 / 技能 / 标识(原为死字段或缺失)。 + ToolAllowlist: role.Tools, + Skills: role.Skills, + SessionScope: st.Meta.ID + "/" + step.ID, + ExecutionID: st.Meta.ID + "-" + step.ID, + Budget: stepBudget, } if step.IntentID != "" { // 系统功能步:不经智能体,由 IntentRunner 直接执行;缺席即 fail-closed。 @@ -663,6 +675,16 @@ func (d *flowDriver) hold(st *runState, kind HumanRequestKind, stepID, role, que } // policy 合并引擎全局策略与 Goal 覆盖(窄者胜)。 +// policyRole 在引擎全局/Goal 链之上叠加角色级 HitlPolicy +// (窄者胜;Goal 仍最高)。 +func (d *flowDriver) policyRole(st *runState, role RoleSpec) HitlPolicy { + p := d.policy(st) + if role.HitlPolicy != nil { + p = mergeHitlPolicy(p, role.HitlPolicy) + } + return p +} + func (d *flowDriver) policy(st *runState) HitlPolicy { return mergeHitlPolicy(d.opts.HitlPolicy, st.Meta.HitlPolicyOf()) }