Plumb RoleSpec execution config into agent-native requests and fix leader parity #3
@@ -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`:
|
||||
|
||||
|
||||
Vendored
+1
-1
Submodule deps/agent-native updated: 292142ac1a...f9ab2ef698
+9
-1
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
@@ -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 != "" {
|
||||
|
||||
@@ -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
@@ -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())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user