Plumb RoleSpec execution config into agent-native requests and fix leader parity #3

Merged
weichen merged 1 commits from feature/full-agent-r1 into main 2026-10-01 06:02:37 +00:00
12 changed files with 497 additions and 35 deletions
+10 -1
View File
@@ -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 <role>`):leader 是注册表中的普通角色,每轮被
引擎激活——读目标/黑板/委派历史,产出结构化决策 `delegation.json`:
+9 -1
View File
@@ -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
+28
View File
@@ -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)
}
}
+67 -13
View File
@@ -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
})
+25 -9
View File
@@ -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 {
+99
View File
@@ -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)
}
+7
View File
@@ -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"`
+9 -1
View File
@@ -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,
})
+16 -4
View File
@@ -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 != "" {
+199
View File
@@ -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")
}
}
+27 -5
View File
@@ -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())
}