Plumb RoleSpec execution config into agent-native requests and fix leader parity
ci / vet-and-test (pull_request) Failing after 2s
ci / docker-checkout-hostgateway (pull_request) Failing after 2m20s

Why: RoleSpec.Tools/Skills were dead fields, BashPermission only gated
an HITL prompt with nothing to enforce, leader turns dropped process
events and tool profile, and the HTTP API could not start leader mode.

Evidence: go vet ./... clean; go test ./... 6 packages ok. New cases:
step requests carry ToolAllowlist/Skills/SessionScope/ExecutionID and
a step-wide shared deadline; buildAgentNativeOptions bash gating
(deny/unset get no exec tools even when configured, ask/allow do,
missing model fails closed); Derive inheriting tool_profile/tools/
skills/bash_permission with request overrides; role-level HitlPolicy
making a bash=ask step fail under auto instead of holding; leader
requests fully assembled with detail events bridged to the sink; HTTP
createRun accepting mode/leader_role/max_leader_turns (engine-driven
repro of the plan-validation precondition included).

Impact: RegisterAgentNative keeps its signature and delegates to a new
RegisterAgentNativeFull(AgentNativeConfig); nested agent-native
gitlink pinned to f9ab2ef (full-agent batch); llm planner derive
schema gains tool_profile/tools/skills/bash_permission.

Refs: Dev task feature/full-agent-r1 batch B9; agent-native PR #3.
This commit is contained in:
2026-10-01 06:02:17 +00:00
parent 946e8d5538
commit d4d351913f
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` 注册工厂),产品也可注册 `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`): 角色来源三种(`RoleSource`):
- **registered**:配置文件(`roles.d/*.json`)/ HTTP API / CLI 手动注册; - **registered**:配置文件(`roles.d/*.json`)/ HTTP API / CLI 手动注册;
- **derived**:规划器或用户按 `base_role` 基线现场派生(领域特化专员); - **derived**:规划器或用户按 `base_role` 基线现场派生(领域特化专员);
@@ -76,7 +84,8 @@ git submodule update --init --recursive
## Leader-Delegation 编排模式(机制参考 Multica Squad Leader,原创实现) ## Leader-Delegation 编排模式(机制参考 Multica Squad Leader,原创实现)
除标准模式(一次性计划顺序执行)外,支持事件驱动的增量委派循环 除标准模式(一次性计划顺序执行)外,支持事件驱动的增量委派循环(HTTP API
`POST /v1/runs` 亦支持 `mode`/`leader_role`/`max_leader_turns`,与 CLI 等价)
(`--mode leader --leader-role <role>`):leader 是注册表中的普通角色,每轮被 (`--mode leader --leader-role <role>`):leader 是注册表中的普通角色,每轮被
引擎激活——读目标/黑板/委派历史,产出结构化决策 `delegation.json`: 引擎激活——读目标/黑板/委派历史,产出结构化决策 `delegation.json`:
+9 -1
View File
@@ -197,6 +197,11 @@ type createRunRequest struct {
Goal squad.Goal `json:"goal"` Goal squad.Goal `json:"goal"`
TaskKey string `json:"task_key,omitempty"` TaskKey string `json:"task_key,omitempty"`
Backend string `json:"backend,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 便捷通道:filename → base64 内容。
MaterialsB64 map[string]string `json:"materials_b64,omitempty"` MaterialsB64 map[string]string `json:"materials_b64,omitempty"`
} }
@@ -222,7 +227,10 @@ func (s *Server) handleCreateRun(w http.ResponseWriter, r *http.Request) {
// 同步发起:Run 在首次挂起或终态前返回;长时间执行由调用方带超时/异步包装。 // 同步发起:Run 在首次挂起或终态前返回;长时间执行由调用方带超时/异步包装。
// 这里为 HTTP 体验做异步:先返回 run_id 无法预知(ID 在 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 { if err != nil {
s.writeErr(w, r, err) s.writeErr(w, r, err)
return return
+28
View File
@@ -302,3 +302,31 @@ func TestHealthAndBlueprints(t *testing.T) {
t.Fatalf("blueprints: %v", bps) 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/adapters/native"
"agent-native/exec" "agent-native/exec"
"agent-native/runtime/llm" "agent-native/runtime/llm"
"agent-native/runtime/mcp"
"agent-native/runtime/subagent"
"agent-native/runtime/tools"
"error-code-system/errcode" "error-code-system/errcode"
) )
@@ -53,21 +56,72 @@ func (r *BackendResolver) RegisterInstance(name string, b exec.Backend) {
r.underlying[name] = b r.underlying[name] = b
} }
// RegisterAgentNative 注册 "agent-native" 后端工厂:每个角色一个 // AgentNativeConfig 声明 agent-native 后端的全量能力装配(组合根提供)。
// agent-native 自研内核实例(runtime 循环 + 工作区工具),角色级 Model // 零值 = 仅基础模型执行:搜索/取回/执行/MCP/技能/委派工具一律不注册(fail-closed)。
// 覆盖 defaultModel。智能体执行全部委托 agent-native,本库不实现 Agent。 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) { 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) { r.Register("agent-native", func(role RoleSpec) (exec.Backend, error) {
model := role.Model opts, err := buildAgentNativeOptions(cfg, role)
if model == "" { if err != nil {
model = defaultModel return nil, err
}
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
} }
return native.New(opts), nil return native.New(opts), nil
}) })
+25 -9
View File
@@ -12,15 +12,31 @@ import (
"agent-native/exec" "agent-native/exec"
) )
// execRequest 构造 leader 步骤的执行请求。 // leaderExecRequest 构造 leader 轮次的执行请求:与标准步骤同等装配
func execRequest(workDir, prompt, instructions string, targets []string, timeout time.Duration) exec.Request { // (过程事件桥、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{ return exec.Request{
WorkDir: workDir, WorkDir: workDir,
Prompt: prompt, Prompt: prompt,
Instructions: instructions, Instructions: instructions,
Targets: targets, Targets: targets,
Timeout: timeout, Timeout: timeout,
OnEvent: func(ev exec.ProcessEvent) {}, 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 := readFileOr(filepath.Join(subDir, "TASK.md"), leaderTaskBrief(st))
prompt = st.injectSteering(prompt) prompt = st.injectSteering(prompt)
instructions := readFileOr(filepath.Join(subDir, "AGENTS.md"), leaderPrompt(members, e.opts.IntentCatalog.intentCatalogMd())) 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 { if execErr != nil || result == nil || !result.Success {
reason := "leader execution failed" reason := "leader execution failed"
if execErr != nil { if execErr != nil {
+99
View File
@@ -380,3 +380,102 @@ func TestWakeupGuardMaxFires(t *testing.T) {
} }
_ = time.Now _ = 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 声明一个小队角色的完整设定。空表起步:库不预置任何角色。 // RoleSpec 声明一个小队角色的完整设定。空表起步:库不预置任何角色。
// BashPermission 三态(执行类工具的授权档位)。
const (
BashPermissionAllow = "allow" // 装配执行工具,无人工门
BashPermissionAsk = "ask" // 装配执行工具,步骤前 HITL permission 门
BashPermissionDeny = "deny" // 不装配执行工具(默认)
)
type RoleSpec struct { type RoleSpec struct {
Role string `json:"role"` Role string `json:"role"`
Name string `json:"name"` Name string `json:"name"`
+9 -1
View File
@@ -50,6 +50,10 @@ type llmAgent struct {
BaseRole string `json:"base_role,omitempty"` BaseRole string `json:"base_role,omitempty"`
Model string `json:"model,omitempty"` Model string `json:"model,omitempty"`
Backend string `json:"backend,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"` DomainTopic string `json:"domain_topic,omitempty"`
Responsibilities []string `json:"responsibilities,omitempty"` Responsibilities []string `json:"responsibilities,omitempty"`
} }
@@ -70,7 +74,7 @@ const llmPlannerSystem = `你是智能体小队的规划器。给定用户目标
1. 只输出一个 JSON 对象,不要输出任何解释文字或代码围栏。 1. 只输出一个 JSON 对象,不要输出任何解释文字或代码围栏。
2. 目标信息不足时必须输出 {"clarify":[{"id":"q1","text":"问题","options":[{"value":"continue","label":"…"}]}]} 并停止,不要替用户假设。判定标准(任一命中即 clarify):目标少于 15 个字;没有明确的对象/主题(如"帮我写个东西");没有可辨识的交付物;关键约束互相矛盾。 2. 目标信息不足时必须输出 {"clarify":[{"id":"q1","text":"问题","options":[{"value":"continue","label":"…"}]}]} 并停止,不要替用户假设。判定标准(任一命中即 clarify):目标少于 15 个字;没有明确的对象/主题(如"帮我写个东西");没有可辨识的交付物;关键约束互相矛盾。
3. 正常情况输出 {"agents":[...],"steps":[...],"confirm":bool,"confirm_reason":"...","template":"markdown 骨架"}。 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 个,含梳理、专业执行、汇总成文、终审把关。 5. steps: 每步 {"id":"step_01_x","role":"角色key","title":"步骤职责"};可并行的相邻步骤 parallel=true;高风险/破坏性/对外发布类步骤 requires_approval=true;步骤通常 2~6 个,含梳理、专业执行、汇总成文、终审把关。
6. 涉及写入、删除、对外提交或超出常规预算的计划 confirm=true。 6. 涉及写入、删除、对外提交或超出常规预算的计划 confirm=true。
7. 通用守则:缺失的信息若会实质改变结果、执行路径、权限或安全,必须 clarify 问人;否则自行决定并在计划中说明你决定了什么。严禁假设交付完成:无法覆盖的目标范围要如实体现在步骤或假设中,不许静默缩水。 7. 通用守则:缺失的信息若会实质改变结果、执行路径、权限或安全,必须 clarify 问人;否则自行决定并在计划中说明你决定了什么。严禁假设交付完成:无法覆盖的目标范围要如实体现在步骤或假设中,不许静默缩水。
@@ -138,6 +142,10 @@ func (p *LLMPlanner) materialize(input PlanInput, parsed *llmPlanSchema) (*PlanO
BaseRole: la.BaseRole, BaseRole: la.BaseRole,
Backend: la.Backend, Backend: la.Backend,
Model: la.Model, Model: la.Model,
ToolProfile: la.ToolProfile,
Tools: la.Tools,
Skills: la.Skills,
BashPermission: la.BashPermission,
DomainTopic: la.DomainTopic, DomainTopic: la.DomainTopic,
Responsibilities: la.Responsibilities, Responsibilities: la.Responsibilities,
}) })
+16 -4
View File
@@ -112,6 +112,10 @@ type DeriveRequest struct {
BaseRole string `json:"base_role,omitempty"` BaseRole string `json:"base_role,omitempty"`
Backend string `json:"backend,omitempty"` Backend string `json:"backend,omitempty"`
Model string `json:"model,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"` DomainTopic string `json:"domain_topic,omitempty"`
Responsibilities []string `json:"responsibilities,omitempty"` Responsibilities []string `json:"responsibilities,omitempty"`
ForbiddenActions []string `json:"forbidden_actions,omitempty"` ForbiddenActions []string `json:"forbidden_actions,omitempty"`
@@ -138,10 +142,10 @@ func (r *RoleRegistry) Derive(req DeriveRequest) (RoleSpec, error) {
BaseRole: base.Role, BaseRole: base.Role,
Backend: firstNonEmpty(req.Backend, base.Backend), Backend: firstNonEmpty(req.Backend, base.Backend),
Model: firstNonEmpty(req.Model, base.Model), Model: firstNonEmpty(req.Model, base.Model),
ToolProfile: base.ToolProfile, ToolProfile: firstNonEmpty(req.ToolProfile, base.ToolProfile),
Tools: append([]string{}, base.Tools...), Tools: firstNonEmptySlice(req.Tools, base.Tools),
Skills: append([]string{}, base.Skills...), Skills: firstNonEmptySlice(req.Skills, base.Skills),
BashPermission: base.BashPermission, BashPermission: firstNonEmpty(req.BashPermission, base.BashPermission),
Responsibilities: append([]string{}, req.Responsibilities...), Responsibilities: append([]string{}, req.Responsibilities...),
ForbiddenActions: append([]string{}, req.ForbiddenActions...), ForbiddenActions: append([]string{}, req.ForbiddenActions...),
Instructions: firstNonEmpty(req.Instructions, base.Instructions), Instructions: firstNonEmpty(req.Instructions, base.Instructions),
@@ -161,6 +165,14 @@ func (r *RoleRegistry) Derive(req DeriveRequest) (RoleSpec, error) {
return spec, nil 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 { func firstNonEmpty(vals ...string) string {
for _, v := range vals { for _, v := range vals {
if v != "" { 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 且本运行未授权过该角色)。 // HITL:角色权限升级(bash=ask 且本运行未授权过该角色)。
role, _ := findAgent(st.Plan.Squad.Agents, step.Role) role, _ := findAgent(st.Plan.Squad.Agents, step.Role)
if role.BashPermission == "ask" && !st.Approved["role:"+role.Role] { if role.BashPermission == BashPermissionAsk && !st.Approved["role:"+role.Role] {
switch d.policy(st).mode() { switch d.policyRole(st, role).mode() {
case HitlDisabled, HitlAuto: 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 st.Plan.Workflow.Steps[i] = step
return d.failStep(ctx, st, i) return d.failStep(ctx, st, i)
default: 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] { if step.RequiresApproval && !st.Approved["step:"+step.ID] {
switch d.policy(st).mode() { switch d.policyRole(st, role).mode() {
case HitlDisabled, HitlAuto: case HitlDisabled, HitlAuto:
step.Error = "approval required but hitl mode is " + string(d.policy(st).mode()) step.Error = "approval required but hitl mode is " + string(d.policy(st).mode())
st.Plan.Workflow.Steps[i] = step 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 { if len(targets) == 0 {
targets = []string{"output.md"} 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 result *exec.Result
var err error var err error
for attempt := 1; attempt <= exec.MaxTransientStepInvocations; attempt++ { 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, 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})}) 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 != "" { if step.IntentID != "" {
// 系统功能步:不经智能体,由 IntentRunner 直接执行;缺席即 fail-closed。 // 系统功能步:不经智能体,由 IntentRunner 直接执行;缺席即 fail-closed。
@@ -663,6 +675,16 @@ func (d *flowDriver) hold(st *runState, kind HumanRequestKind, stepID, role, que
} }
// policy 合并引擎全局策略与 Goal 覆盖(窄者胜)。 // 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 { func (d *flowDriver) policy(st *runState) HitlPolicy {
return mergeHitlPolicy(d.opts.HitlPolicy, st.Meta.HitlPolicyOf()) return mergeHitlPolicy(d.opts.HitlPolicy, st.Meta.HitlPolicyOf())
} }