Import agent-squad as a standalone repository.
ci / vet-and-test (push) Failing after 3s
ci / docker-checkout-hostgateway (push) Failing after 3s

Pin Agent-Native as a submodule so the module builds without the multi-repo sibling path.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-29 12:38:35 +00:00
co-authored by Cursor
commit bfec3c8930
42 changed files with 8202 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
name: ci
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
vet-and-test:
runs-on: nightly
steps:
- uses: actions/checkout@v4
with:
submodules: true
- run: go vet ./... && go test -count=1 ./...
docker-checkout-hostgateway:
runs-on: ubuntu-latest
options: --add-host gitea:host-gateway
env:
GITHUB_SERVER_URL: http://gitea:3000
steps:
- uses: actions/checkout@v4
with:
submodules: true
- run: test -f go.mod && test -f Agent-Native/go.mod && echo "docker-mode checkout via host-gateway OK"
+3
View File
@@ -0,0 +1,3 @@
agent-squad
agent-squad-data/
*.test
+3
View File
@@ -0,0 +1,3 @@
[submodule "Agent-Native"]
path = Agent-Native
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Native.git
Submodule
+1
Submodule Agent-Native added at 5a998b3609
+187
View File
@@ -0,0 +1,187 @@
# Agent-Squad
通用智能体小队编排底座:角色注册、意图/目标理解与任务拆解、动态编排执行、
黑板协同、Human-in-the-Loop(HITL)挂起与恢复。**不含任何预设角色或任务模板**,
一切角色/模板均通过配置文件、HTTP API、CLI 注册或由规划器现场派生。
设计参考 Multica 的 agent orchestration 理念与 Agent-System `internal/squad`
的成熟机制(黑板/沙箱工作区/顺序并行混合调度/动态调整),按"空表起步"通用化重写。
本仓库是该模块的源码位置。Go 模块路径为 `agent-squad`。智能体执行依赖子模块 [Agent-Native](./Agent-Native)(`replace agent-native => ./Agent-Native`),钉在固定提交,不随 Agent-Native 分支浮动。
克隆时带上子模块,否则无法编译:
```bash
git clone --recurse-submodules ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Squad.git
# 已克隆时:git submodule update --init
```
## 与周边仓库的关系
| 仓库/模块 | 关系 |
|---|---|
| Agent-Native(本仓 submodule `./Agent-Native`) | **智能体执行的唯一来源**:角色执行经 agent-native `adapters/native`(自研内核 runtime 循环 + 工作区工具)或产品注册的其他 agent-native 后端(如 pi-agent);LLM 规划器复用 `runtime/llm` 客户端做编排决策。本库不实现任何 Agent——只做编排与任务拆解。依赖方向 agent-squad → agent-native,反向无依赖 |
| `agent-system-multi/modules/squad`(U4 产品小队暂存仓) | **不在本仓**,互不依赖。产品小队含医疗器械预设角色与任务键,属产品层;后续产品仓接入本底座时,预设角色/任务模板改为向本底座注册(独立一期) |
| 主仓 `internal/squad` | 机制语义(工作流驱动/黑板/子工作区/防篡改清单)的权威来源;本底座为通用化重写,不做逐行同步 |
## 分层
| 包 | 职责 | 依赖边界 |
|---|---|---|
| squad | 核心库:契约(模型/HITL)、角色注册表、蓝图注册表、规划器(rule/llm/mock)、执行引擎、黑板、沙箱工作区、事件 | 依赖 agent-native(exec + runtime/llm);不依赖任何产品类型/DB |
| store | 文件持久化:roles.d/*.json、blueprints.d/*.json | 只依赖 squad 契约;无 DB |
| server | HTTP API(REST + SSE) | 组合根之一,可独立 serve |
| cli | CLI:serve / role / blueprint / run / runs / demo | 组合根之一 |
| textextract | 纯文本抽取(txt/md/json/docx + 页码锚点),纯标准库 | 无依赖 |
| conformance | 公开 API 全链路契约验收(含 HITL 挂起-回应) | 只用公开 API |
## 角色:创建与绑定智能体
`RoleSpec` 声明角色的全部设定;`Backend` 字段绑定执行该角色的智能体:
内置 `stub`(agent-native 离线桩,测试用);真实执行用 `agent-native`
(自研内核:多轮循环 + 读/写/编辑/检索工作区工具,经 `RegisterAgentNative`
注册工厂),产品也可注册 `pi-agent` 等外部智能体工厂。`Model`/`ToolProfile`/`Skills`
角色级注入——即每个角色可以用不同的智能体、不同的模型。
角色来源三种(`RoleSource`):
- **registered**:配置文件(`roles.d/*.json`)/ HTTP API / CLI 手动注册;
- **derived**:规划器或用户按 `base_role` 基线现场派生(领域特化专员);
- 运行期 minimal 引导:空注册表澄清时人工选择"最小编制",自动注册 4 个通用角色。
未知智能体名 fail-closed(运行失败并留痕),绝不静默回落 stub。
## 规划:理解目标 → 拆解任务 → 分配角色
`Planner` 接口可插拔,内置三种实现:
| 规划器 | 机制 |
|---|---|
| `rule`(默认,零模型依赖) | 命中蓝图→基线步骤;未命中→通用四阶段骨架(梳理→专项→成文→终审),角色按 Keywords/职责推荐,缺位现场派生(派生计划需人工确认) |
| `llm` | 结构化 JSON 输出:复用/派生角色清单 + 步骤 + 并行分组 + 高风险标记;解析失败重试一次后 fail-closed;信息不足时输出澄清请求而非猜测计划(直接用 agent-native `runtime/llm` 客户端做一次编排决策调用,不是智能体执行) |
| `mock` | 脚本化,离线演示/测试 |
蓝图(`Blueprint`)= 可选的任务模板(关键词匹配或显式 task key 命中),
提供基线步骤/默认编制/交付物骨架,注册表同样空表起步。
## Leader-Delegation 编排模式(机制参考 Multica Squad Leader,原创实现)
除标准模式(一次性计划顺序执行)外,支持事件驱动的增量委派循环
(`--mode leader --leader-role <role>`):leader 是注册表中的普通角色,每轮被
引擎激活——读目标/黑板/委派历史,产出结构化决策 `delegation.json`:
- `delegate`:委派一名成员(按 skills 挑人),引擎执行成员步骤、把交付快照进
leader 工作区 `inbox/<步骤id>/`(leader 沙箱看不到成员目录,须物化核实)、
黑板合并,然后唤醒 leader 再评估;
- `escalate`:升级问人(HITL kind=escalation,人工回答落黑板事实后 leader 继续);
- `close`:收口(走统一质检/清单);`failed`:如实报告无法达成。
防失控:每运行 leader 轮次上限(默认 8,`--max-leader-turns`)。leader 协议
核心:只协调不实现、按能力委派、一轮一任务、委派不了就升级、严禁假装完成。
## 借鉴 Multica 的增强(重写实现,非代码复制)
| 能力 | 说明 |
|---|---|
| HITL 等待条件 | 缺口可声明 `wait_run_id`/`wait_field`——等目标运行终态/黑板字段出现时**自动唤醒续跑**(唤醒报告事实非业务完成);护栏:单请求自动唤醒上限(MaxWakeupFires=5)后自动中止;serve 每 15s sweep |
| 何时问人总则 | planner 与默认角色指令内置:"信息若实质改变结果/路径/权限/安全才问人,否则自行决定并说明;严禁假装完成" |
| 运行级 steering | `POST /v1/runs/{id}/steer`(CLI `runs steer`):运行中人工补充指令在**下一次步骤边界**注入当前运行(不中断进行中步骤,单次消费) |
| 角色级并发上限 | `RoleSpec.MaxConcurrentTasks`(1=对象级串行);per-role 缓存 + 双检锁防并行首解析竞态 |
| 用量记账 | 每步骤 token 用量按角色累计(usage.json + RunResult.Usage/Manifest) |
| 事件落盘+脱敏 | FileEventSink:事件按 seq 追加 `runs/<id>/events.jsonl`,落盘前脱敏(sk-key/Bearer/JWT/api_key/password 形状),转发 SSE |
| HITL severity | 请求带 `action_required`/`attention` 分级(inbox 降噪语义) |
| 会话毒化轮转 | agent-native adapters/native:resume-unsafe 失败(context overflow/迭代上限等)后自动归档旧会话换新会话,网络类失败保留会话续跑 |
## HITL:六个挂起点 + 恢复
| 挂起点 | 触发 | 恢复 |
|---|---|---|
| clarification | 规划器判定目标信息不足 | 回答并入 Goal 重新规划 |
| confirmation | 高风险计划/派生编制/`requires_approval` 步骤 | approve/abort(approve 后执行) |
| gap | 角色写 `gaps.json` 声明 blocking 缺口 | 回答落黑板 Fact 并 resolve;skip/abort 可选 |
| adjustment | 评估器建议加步/跳步且命中审批策略 | approve/reject/abort |
| permission | 角色 `bash_permission=ask` | approve(本运行内授权)/reject/abort |
| quality_review | 质检失败且自动修复预算用尽 | 人工放行(记录 ManualPass)/再修复/判失败 |
挂起 = 状态与 `pending.json` 落盘后返回;`Respond` 按请求 ID 幂等应用决策并续跑,
进程重启后可恢复(磁盘即权威)。策略三级(全局→角色→Goal 覆盖):
`interactive`(默认)| `auto`(无值守:缺口记 fallback 假设继续,高风险决策 fail-closed)
| `disabled`(挂起点直接失败)。
## 执行引擎与工作区
顺序/并行混合调度(相邻 `parallel=true` 步骤成组并发);顺序步骤后可接
`StepEvaluator`(可插拔,默认 no-op)做动态调整;非关键步骤(`critical=false`)
失败降级继续(终态 degraded)。步骤级瞬态重试复用 agent-native
`AssessStepFailure`(上限 2 次),交付物物化带符号链接防护。
任务级沙箱工作区(与产品 squad 布局兼容):
```
<data>/runs/<run_id>/
workspace.json squad.json workflow.json blackboard.json template.md
context/{goal.md, materials/, materials_text/, index.json}
tasks/<NN>_<role>/{TASK.md, AGENTS.md, output…, fields.json?, gaps.json?, execution.log}
output.md manifest.json # 交付物 + 防篡改清单(sha256 + 质检报告)
```
黑板(Facts/Gaps/Assumptions/Fields)由步骤产出 `fields.json`/`gaps.json` 合并,
人工回答记录为 `Source=human` 的事实。
## HTTP API(serve)
| 方法/路径 | 说明 |
|---|---|
| `GET /v1/health` | 健康检查 |
| `GET/POST /v1/roles`、`PUT/DELETE /v1/roles/{key}` | 角色注册管理 |
| `POST /v1/roles/derive` | 派生角色 |
| `GET /v1/roles/recommend?q=` | 角色推荐 |
| `GET/POST /v1/blueprints`、`DELETE /v1/blueprints/{key}` | 任务模板管理 |
| `POST /v1/runs` | 发起运行(goal/task_key/backend),同步推进至挂起或终态 |
| `GET /v1/runs`、`GET /v1/runs/{id}` | 运行列表/详情 |
| `GET /v1/runs/{id}/pending` | 当前挂起的人工请求 |
| `POST /v1/runs/{id}/respond` | 应用人工决策(answer/choice)并续跑 |
| `POST /v1/runs/{id}/steer` | 运行中追加人工指令(下一步骤边界注入) |
| `POST /v1/runs/{id}/blackboard` | 写入黑板字段(可满足 field_set 等待条件) |
| `POST /v1/wakeups/sweep` | 手动触发等待条件扫描(serve 内置 15s 周期) |
| `POST /v1/runs/{id}/resume` | 重启恢复入口 |
| `GET /v1/runs/{id}/blackboard` | 黑板 |
| `GET /v1/runs/{id}/events?cursor=N` | SSE 事件流(`human_input_required`/步骤事件/终态),支持断线重放 |
事件名:`run_started/phase/step_started/step_completed/step_failed/step_skipped/
detail/deliverable/human_input_required/human_responded/run_completed/run_failed/run_cancelled`。
## CLI
```bash
agent-squad serve --addr :8090 [--planner rule|llm] [--model M]
agent-squad role list|add(-f file 或 --role/--backend/--model/--keywords…)|rm|recommend|derive
agent-squad blueprint list|add <file.json>|rm <key>
agent-squad run "目标文本" [--task-key K] [--backend B] [--materials DIR] [--hitl-mode interactive|auto|disabled]
agent-squad runs list|inspect|pending|respond|blackboard
agent-squad demo # 离线全链路演示(含一次 HITL 挂起-回应)
```
`run` 在终端交互式处理 HITL:挂起时打印问题/选项/上下文,读 stdin 回应后自动续跑。
LLM 配置走环境变量:`AGENT_SQUAD_LLM_BASE_URL` / `AGENT_SQUAD_LLM_API_KEY` /
`AGENT_SQUAD_LLM_MODEL`(OpenAI 兼容端点;配置后自动注册 `agent-native`
后端工厂与 `llm` 规划器,角色用 `--backend agent-native` 执行真实智能体)。
## 快速开始
在仓库根目录(需已初始化 `Agent-Native` 子模块):
```bash
go build -o agent-squad .
./agent-squad demo # ① 离线全链路演示
./agent-squad role add --role researcher --backend stub --keywords "调研,市场"
./agent-squad run "调研 X 并产出报告" # ② 终端交互式 HITL(stub 后端)
./agent-squad serve --addr :8090 # ③ HTTP API(REST + SSE)
```
## 验证
```bash
go vet ./... && go test -count=1 ./...
```
当前全绿(conformance 全链路 + HITL 各挂起点 + server + store + textextract)。
+610
View File
@@ -0,0 +1,610 @@
// Package cli 提供 agent-squad 命令行:serve / role / blueprint / run / runs / demo。
package cli
import (
"bufio"
"context"
"encoding/json"
"flag"
"fmt"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"agent-native/runtime/llm"
"agent-squad/server"
"agent-squad/squad"
"agent-squad/store"
)
// Main CLI 入口;返回进程退出码。
func Main(args []string) int {
if len(args) == 0 {
usage()
return 2
}
switch args[0] {
case "serve":
return cmdServe(args[1:])
case "role":
return cmdRole(args[1:])
case "blueprint":
return cmdBlueprint(args[1:])
case "run":
return cmdRun(args[1:])
case "runs":
return cmdRuns(args[1:])
case "demo":
return cmdDemo(args[1:])
case "help", "-h", "--help":
usage()
return 0
default:
fmt.Fprintf(os.Stderr, "未知命令 %q\n\n", args[0])
usage()
return 2
}
}
func usage() {
fmt.Print(`agent-squad — 通用智能体小队编排底座
用法:
agent-squad serve [--addr :8090] [--data DIR] [--planner rule|llm] [--model M]
agent-squad role list|add|rm|recommend|derive ...
agent-squad blueprint list|add|rm ...
agent-squad run "目标文本" [--task-key K] [--backend B] [--materials DIR] [--hitl-mode interactive|auto|disabled]
agent-squad runs list|inspect|pending|respond|blackboard ...
agent-squad demo [--keep]
环境变量:
AGENT_SQUAD_DATA 数据目录(默认 ./agent-squad-data;role/blueprint 子命令也读它)
AGENT_SQUAD_LLM_BASE_URL OpenAI 兼容端点(agent-native 后端与 llm 规划器)
AGENT_SQUAD_LLM_API_KEY API key
AGENT_SQUAD_LLM_MODEL 默认模型
`)
}
// app 一次 CLI 执行的组装根。
type app struct {
engine *squad.SquadEngine
sink *squad.FanoutSink
files *store.FileEventSink
st *store.FileStore
}
func dataDir() string {
if v := os.Getenv("AGENT_SQUAD_DATA"); v != "" {
return v
}
return "./agent-squad-data"
}
// assemble 组装引擎;planner=llm 且未配置模型时回退 rule 并提示。
func assemble(dir, plannerName, modelOverride string) (*app, error) {
st, err := store.New(dir)
if err != nil {
return nil, err
}
reg := squad.NewRoleRegistry()
if err := st.LoadRoles(reg); err != nil {
return nil, err
}
bps := squad.NewBlueprintRegistry()
if err := st.LoadBlueprints(bps); err != nil {
return nil, err
}
backends := squad.NewBackendResolver()
baseURL := os.Getenv("AGENT_SQUAD_LLM_BASE_URL")
apiKey := os.Getenv("AGENT_SQUAD_LLM_API_KEY")
model := first(modelOverride, os.Getenv("AGENT_SQUAD_LLM_MODEL"))
planner := squad.Planner(squad.NewRulePlanner())
if plannerName == "llm" {
if baseURL == "" || apiKey == "" || model == "" {
fmt.Fprintln(os.Stderr, "提示:llm 规划器需要 AGENT_SQUAD_LLM_BASE_URL/API_KEY/MODEL,回退 rule 规划器")
} else {
client := &llm.Client{BaseURL: baseURL, APIKey: apiKey}
planner = squad.NewLLMPlanner(client, model)
}
}
// 真实智能体执行一律经 agent-native 自研内核(角色 backend=agent-native)。
if baseURL != "" && apiKey != "" && model != "" {
backends.RegisterAgentNative(&llm.Client{BaseURL: baseURL, APIKey: apiKey}, model, 12, 10*time.Minute)
}
// 事件双层:FileEventSink 落盘(seq+脱敏,重启可审计)→ FanoutSink(SSE 实时/重放)。
fanout := squad.NewFanoutSink(8192)
fileSink := store.NewFileEventSink(dir, fanout)
engine, err := squad.NewEngine(squad.Options{
DataDir: dir, Registry: reg, Blueprints: bps, Planner: planner,
Backends: backends, Sink: fileSink,
})
if err != nil {
return nil, err
}
return &app{engine: engine, sink: fanout, files: fileSink, st: st}, nil
}
func first(vals ...string) string {
for _, v := range vals {
if v != "" {
return v
}
}
return ""
}
// ---- serve ----
func cmdServe(args []string) int {
fs := flag.NewFlagSet("serve", flag.ContinueOnError)
addr := fs.String("addr", ":8090", "监听地址")
data := fs.String("data", dataDir(), "数据目录")
planner := fs.String("planner", "rule", "规划器:rule|llm")
model := fs.String("model", "", "默认模型(llm 规划器/后端)")
if err := fs.Parse(args); err != nil {
return 2
}
a, err := assemble(*data, *planner, *model)
if err != nil {
fmt.Fprintln(os.Stderr, "组装失败:", err)
return 1
}
srv := server.New(a.engine, a.sink, a.st)
stopSweep := srv.StartWakeupSweeper(15 * time.Second)
defer stopSweep()
fmt.Printf("agent-squad 服务启动 http://localhost%s (data=%s, roles=%d, blueprints=%d, wakeup-sweep=15s)\n",
*addr, *data, len(a.engine.Registry().List()), len(a.engine.Blueprints().List()))
return httpListen(*addr, srv.Handler())
}
func httpListen(addr string, h http.Handler) int {
if err := http.ListenAndServe(addr, h); err != nil {
fmt.Fprintln(os.Stderr, "服务退出:", err)
return 1
}
return 0
}
// ---- role ----
func cmdRole(args []string) int {
if len(args) == 0 {
fmt.Println("用法: agent-squad role list|add|rm|recommend|derive ...")
return 2
}
data := dataDir()
a, err := assemble(data, "rule", "")
if err != nil {
fmt.Fprintln(os.Stderr, "组装失败:", err)
return 1
}
switch args[0] {
case "list":
roles := a.engine.Registry().List()
fmt.Printf("%-20s %-24s %-10s %-16s %s\n", "ROLE", "NAME", "SOURCE", "BACKEND", "MODEL")
for _, r := range roles {
fmt.Printf("%-20s %-24s %-10s %-16s %s\n", r.Role, r.Name, r.Source, r.Backend, r.Model)
}
if len(roles) == 0 {
fmt.Println("(空注册表:通过 `agent-squad role add` 注册角色)")
}
return 0
case "add":
fs := flag.NewFlagSet("role add", flag.ContinueOnError)
f := fs.String("f", "", "角色 JSON 文件")
role := fs.String("role", "", "角色 key")
name := fs.String("name", "", "角色名")
backend := fs.String("backend", "stub", "绑定智能体:stub|agent-native|pi-agent|…(真实执行用 agent-native)")
model := fs.String("model", "", "角色模型")
keywords := fs.String("keywords", "", "推荐关键词(逗号分隔)")
instrFile := fs.String("instructions-file", "", "指令文件路径")
if err := fs.Parse(args[1:]); err != nil {
return 2
}
var spec squad.RoleSpec
if *f != "" {
data, err := os.ReadFile(*f)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
if err := json.Unmarshal(data, &spec); err != nil {
fmt.Fprintln(os.Stderr, "解析角色 JSON 失败:", err)
return 1
}
}
if *role != "" {
spec.Role = *role
}
if *name != "" {
spec.Name = *name
}
if *backend != "" {
spec.Backend = *backend
}
if *model != "" {
spec.Model = *model
}
if *keywords != "" {
for _, k := range strings.Split(*keywords, ",") {
if k = strings.TrimSpace(k); k != "" {
spec.Keywords = append(spec.Keywords, k)
}
}
}
if *instrFile != "" {
spec.InstructionsFile = *instrFile
}
if err := a.engine.Registry().Register(spec); err != nil {
fmt.Fprintln(os.Stderr, "注册失败:", err)
return 1
}
fmt.Printf("已注册角色 %s(backend=%s model=%s)→ %s\n", spec.Role, spec.Backend, spec.Model,
filepath.Join(data, "roles.d", spec.Role+".json"))
return 0
case "rm":
if len(args) < 2 {
fmt.Println("用法: agent-squad role rm <key>")
return 2
}
if !a.engine.Registry().Remove(args[1]) {
fmt.Fprintf(os.Stderr, "角色 %s 不存在\n", args[1])
return 1
}
a.st.DeleteRole(args[1])
fmt.Println("已删除", args[1])
return 0
case "recommend":
if len(args) < 2 {
fmt.Println("用法: agent-squad role recommend <查询文本>")
return 2
}
spec, ok := a.engine.Registry().Recommend(strings.Join(args[1:], " "))
if !ok {
fmt.Println("无匹配角色")
return 1
}
fmt.Printf("%-20s %s\n", spec.Role, spec.Name)
return 0
case "derive":
if len(args) < 2 {
fmt.Println(`用法: agent-squad role derive '{"role_key":"gmp_expert","base_role":"engineer","domain_topic":"GMP"}'`)
return 2
}
var req squad.DeriveRequest
if err := json.Unmarshal([]byte(args[1]), &req); err != nil {
fmt.Fprintln(os.Stderr, "解析派生请求 JSON 失败:", err)
return 1
}
spec, err := a.engine.Registry().Derive(req)
if err != nil {
fmt.Fprintln(os.Stderr, "派生失败:", err)
return 1
}
fmt.Printf("已派生角色 %s(基线 %s)\n", spec.Role, spec.BaseRole)
return 0
default:
fmt.Println("未知子命令", args[0])
return 2
}
}
// ---- blueprint ----
func cmdBlueprint(args []string) int {
if len(args) == 0 {
fmt.Println("用法: agent-squad blueprint list|add|rm ...")
return 2
}
a, err := assemble(dataDir(), "rule", "")
if err != nil {
fmt.Fprintln(os.Stderr, "组装失败:", err)
return 1
}
switch args[0] {
case "list":
for _, bp := range a.engine.Blueprints().List() {
fmt.Printf("%-24s %2d 步 %s\n", bp.Key, len(bp.Steps), bp.Title)
}
return 0
case "add":
if len(args) < 2 {
fmt.Println("用法: agent-squad blueprint add <file.json>")
return 2
}
data, err := os.ReadFile(args[1])
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
var bp squad.Blueprint
if err := json.Unmarshal(data, &bp); err != nil {
fmt.Fprintln(os.Stderr, "解析蓝图 JSON 失败:", err)
return 1
}
if err := a.engine.Blueprints().Register(bp); err != nil {
fmt.Fprintln(os.Stderr, "注册失败:", err)
return 1
}
fmt.Printf("已注册蓝图 %s(%d 步)\n", bp.Key, len(bp.Steps))
return 0
case "rm":
if len(args) < 2 {
fmt.Println("用法: agent-squad blueprint rm <key>")
return 2
}
if !a.engine.Blueprints().Remove(args[1]) {
fmt.Fprintf(os.Stderr, "蓝图 %s 不存在\n", args[1])
return 1
}
a.st.DeleteBlueprint(args[1])
fmt.Println("已删除", args[1])
return 0
default:
fmt.Println("未知子命令", args[0])
return 2
}
}
// ---- run(终端交互式 HITL)----
func cmdRun(args []string) int {
// 目标文本在前、flag 在后(标准 flag 包遇位置参数即停止解析,需手动切分)。
goalArgs, flagArgs := splitPositionalThenFlags(args)
fs := flag.NewFlagSet("run", flag.ContinueOnError)
taskKey := fs.String("task-key", "", "显式蓝图模板键")
backend := fs.String("backend", "", "运行级默认后端")
materialsDir := fs.String("materials", "", "输入材料目录")
hitlMode := fs.String("hitl-mode", "interactive", "interactive|auto|disabled")
planner := fs.String("planner", "rule", "rule|llm")
model := fs.String("model", "", "默认模型")
timeout := fs.Duration("timeout", 30*time.Minute, "总超时")
mode := fs.String("mode", "standard", "编排模式:standard|leader(leader 委派循环)")
leaderRole := fs.String("leader-role", "", "leader 模式的 leader 角色 key")
maxTurns := fs.Int("max-leader-turns", 8, "leader 模式轮次上限")
if err := fs.Parse(flagArgs); err != nil {
return 2
}
goalText := strings.Join(goalArgs, " ")
if goalText == "" {
fmt.Println("用法: agent-squad run \"目标文本\" [flags]")
return 2
}
a, err := assemble(dataDir(), *planner, *model)
if err != nil {
fmt.Fprintln(os.Stderr, "组装失败:", err)
return 1
}
goal := squad.Goal{Text: goalText, HitlMode: squad.HitlMode(*hitlMode)}
if *materialsDir != "" {
ents, err := os.ReadDir(*materialsDir)
if err != nil {
fmt.Fprintln(os.Stderr, "读取材料目录失败:", err)
return 1
}
for _, ent := range ents {
if ent.IsDir() {
continue
}
goal.Materials = append(goal.Materials, squad.MaterialFile{
Filename: ent.Name(), Path: filepath.Join(*materialsDir, ent.Name()),
})
}
}
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
defer cancel()
res, err := a.engine.Run(ctx, goal, squad.RunOptions{
TaskKey: *taskKey, Backend: *backend, Mode: *mode,
LeaderRole: *leaderRole, MaxLeaderTurns: *maxTurns,
})
if err != nil {
fmt.Fprintln(os.Stderr, "运行失败:", err)
return 1
}
reader := bufio.NewReader(os.Stdin)
for err == nil && res.Status.IsHeld() && res.Pending != nil {
printPending(res.Pending)
decision := readDecision(reader, res.Pending)
res, err = a.engine.Respond(ctx, res.RunID, decision)
}
if err != nil {
fmt.Fprintln(os.Stderr, "运行失败:", err)
return 1
}
printResult(res)
if res.Status == squad.RunFailed {
return 1
}
return 0
}
func printPending(req *squad.HumanRequest) {
fmt.Println("\n⏸ 需要人工输入 —", req.Question)
if req.Context != "" {
for _, line := range strings.Split(req.Context, "\n") {
fmt.Println(" ", line)
}
}
for i, o := range req.Options {
fmt.Printf(" [%d] %s(%s)\n", i+1, o.Label, o.Value)
}
fmt.Println("输入选项序号/值,或直接输入自由文本回答:")
}
func readDecision(reader *bufio.Reader, req *squad.HumanRequest) squad.RespondDecision {
for attempt := 0; attempt < 3; attempt++ {
line, _ := reader.ReadString('\n')
line = strings.TrimSpace(line)
if line == "" && len(req.Options) > 0 {
return squad.RespondDecision{RequestID: req.ID, Choice: req.Options[0].Value}
}
// 选项序号匹配。
if idx, err := strconv.Atoi(line); err == nil && idx >= 1 && idx <= len(req.Options) {
return squad.RespondDecision{RequestID: req.ID, Choice: req.Options[idx-1].Value}
}
// 选项值匹配。
for _, o := range req.Options {
if strings.EqualFold(o.Value, line) {
return squad.RespondDecision{RequestID: req.ID, Choice: o.Value}
}
}
// 带选项的请求不接受自由文本(防误触 abort);两类例外:纯文本请求
// 直接作为回答;澄清类请求自由文本即答案(choice 留空 = 继续并采纳)。
if len(req.Options) == 0 || req.Kind == squad.HitlClarification {
return squad.RespondDecision{RequestID: req.ID, Answer: line}
}
fmt.Println(" 无法识别,请输入选项序号或选项值:")
}
choice := "abort"
if len(req.Options) > 0 {
choice = req.Options[len(req.Options)-1].Value
}
return squad.RespondDecision{RequestID: req.ID, Choice: choice}
}
func printResult(res *squad.RunResult) {
fmt.Printf("\n▶ 运行 %s 终态:%s\n", res.RunID, res.Status)
if res.Summary != "" {
fmt.Println(" ", res.Summary)
}
if res.Error != "" {
fmt.Println(" 错误:", res.Error)
}
if res.Deliverable != "" && res.WorkDir != "" {
fmt.Printf(" 交付物: %s\n", filepath.Join(res.WorkDir, res.Deliverable))
}
if res.Quality != nil {
fmt.Printf(" 质检: passed=%v score=%.0f manual=%v\n", res.Quality.Passed, res.Quality.Score, res.Quality.ManualPass)
}
if res.WorkDir != "" {
fmt.Println(" 工作区:", res.WorkDir)
}
}
// ---- runs ----
func cmdRuns(args []string) int {
if len(args) == 0 {
fmt.Println("用法: agent-squad runs list|inspect|pending|respond|blackboard ...")
return 2
}
a, err := assemble(dataDir(), "rule", "")
if err != nil {
fmt.Fprintln(os.Stderr, "组装失败:", err)
return 1
}
ctx := context.Background()
switch args[0] {
case "list":
runs, err := a.engine.ListRuns()
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
for _, m := range runs {
fmt.Printf("%-32s %-22s %s\n", m.ID, m.Status, truncateOne(m.Goal.Text, 48))
}
return 0
case "inspect":
if len(args) < 2 {
fmt.Println("用法: agent-squad runs inspect <run_id>")
return 2
}
res, err := a.engine.Inspect(args[1])
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
enc, _ := json.MarshalIndent(res, "", " ")
fmt.Println(string(enc))
return 0
case "pending":
if len(args) < 2 {
fmt.Println("用法: agent-squad runs pending <run_id>")
return 2
}
req, _ := a.engine.Pending(args[1])
if req == nil {
fmt.Println("(无挂起请求)")
return 0
}
enc, _ := json.MarshalIndent(req, "", " ")
fmt.Println(string(enc))
return 0
case "respond":
pos, flagArgs := splitPositionalThenFlags(args[1:])
fs := flag.NewFlagSet("runs respond", flag.ContinueOnError)
choice := fs.String("choice", "", "选项值:approve/reject/skip/abort/…")
answer := fs.String("answer", "", "自由文本回答")
reqID := fs.String("request-id", "", "请求 ID(多挂起时校验)")
if err := fs.Parse(flagArgs); err != nil {
return 2
}
if len(pos) < 1 {
fmt.Println("用法: agent-squad runs respond <run_id> --choice approve [--answer …]")
return 2
}
res, err := a.engine.Respond(ctx, pos[0], squad.RespondDecision{
RequestID: *reqID, Choice: *choice, Answer: *answer,
})
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
printResult(res)
return 0
case "steer":
if len(args) < 3 {
fmt.Println("用法: agent-squad runs steer <run_id> \"补充指令文本\"")
return 2
}
if err := a.engine.Steer(args[1], strings.Join(args[2:], " ")); err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
fmt.Println("已加入补充指令(下一次步骤边界注入当前运行)")
return 0
case "blackboard":
if len(args) < 2 {
fmt.Println("用法: agent-squad runs blackboard <run_id>")
return 2
}
bb, err := a.engine.Blackboard(args[1])
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
enc, _ := json.MarshalIndent(bb, "", " ")
fmt.Println(string(enc))
return 0
default:
fmt.Println("未知子命令", args[0])
return 2
}
}
func truncateOne(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n]) + "…"
}
// splitPositionalThenFlags 把参数切为前缀位置参数与其后的 flag 段
// (标准 flag 包遇到首个位置参数即停止解析,目标文本须先取出)。
// 负数不是本 CLI 的合法位置参数,直接按 "-" 前缀判定。
func splitPositionalThenFlags(args []string) (positional, flags []string) {
for i, a := range args {
if strings.HasPrefix(a, "-") {
return append([]string{}, args[:i]...), append([]string{}, args[i:]...)
}
}
return append([]string{}, args...), nil
}
+217
View File
@@ -0,0 +1,217 @@
package cli
import (
"context"
"encoding/json"
"flag"
"fmt"
"os"
"path/filepath"
"strings"
"agent-native/exec"
"agent-squad/squad"
)
// demoBackend 脚本化后端:按子工作区目录名回放预设产出(离线演示/测试)。
type demoBackend struct {
// script: 子目录名(如 01_researcher)→ target → 文件内容。
script map[string]map[string]string
}
func (b *demoBackend) Name() string { return "demo" }
func (b *demoBackend) Execute(ctx context.Context, req exec.Request) (*exec.Result, error) {
key := filepath.Base(filepath.Clean(req.WorkDir))
files, ok := b.script[key]
if !ok {
// 未脚本化的步骤退化为写一个说明文件到首个 target。
files = map[string]string{}
for i, t := range req.Targets {
if i == 0 {
files[t] = fmt.Sprintf("# 演示产出\n\n步骤目录 %s 未脚本化,这是默认产出。\n", key)
}
}
}
patches := map[string][]byte{}
for _, t := range req.Targets {
content := files[t]
if content == "" {
content = "\n"
}
out := filepath.Join(req.WorkDir, t)
_ = os.MkdirAll(filepath.Dir(out), 0o755)
if err := os.WriteFile(out, []byte(content), 0o644); err != nil {
return nil, err
}
patches[t] = []byte(content)
}
return &exec.Result{Success: true, Patches: patches, Logs: "demo backend replay"}, nil
}
// cmdDemo 离线全链路演示:注册角色 → 规划 → 执行 → HITL 阻塞缺口挂起 →
// 人工回应 → 继续 → 质检 → 交付。全程 stub/脚本驱动,无需模型与外部智能体。
func cmdDemo(args []string) int {
fs := flag.NewFlagSet("demo", flag.ContinueOnError)
keep := fs.Bool("keep", false, "保留演示数据目录")
if err := fs.Parse(args); err != nil {
return 2
}
dir, err := os.MkdirTemp("", "agent-squad-demo-")
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
defer func() {
if *keep {
fmt.Println("\n演示工作区保留于:", dir)
} else {
_ = os.RemoveAll(dir)
}
}()
fmt.Println("═══ agent-squad 离线演示(stub 智能体 + mock 规划器 + HITL)═══")
fmt.Println("数据目录:", dir)
// 1. 组装:空注册表起步。
reg := squad.NewRoleRegistry()
bps := squad.NewBlueprintRegistry()
backends := squad.NewBackendResolver()
demoScript := map[string]map[string]string{
"01_researcher": {
"notes.md": "# 调研笔记\n\n- 目标产品:便携式超声诊断仪\n- 竞品:A/B/C 三款\n- 法规路径:二类器械,注册检验 + 临床评价\n",
"fields.json": `{"target_market":"中国","device_class":"二类","budget":"15万"}`,
"gaps.json": `[{"item":"目标年产量未提供,影响成本测算","blocking":true}]`,
},
"02_writer": {
"output.md": "# 便携式超声诊断仪调研报告\n\n## 概述与背景\n\n本报告基于输入材料与调研产出,梳理便携式超声诊断仪的市场与技术背景。目标市场为中国(二类器械),预算 15 万元。\n\n## 核心内容\n\n竞品 A/B/C 在成像质量与便携性上各有取舍;技术路线建议探头一体化设计与电池供电优化。注册路径按二类器械执行注册检验与临床评价。\n\n## 风险与注意事项\n\n- 供应链对探头晶片依赖较高\n- 注册周期受检验排队影响\n\n## 基准假设与待确认清单\n\n- 年产量按人工澄清结论执行\n",
},
"03_reviewer": {
"output.md": "# 便携式超声诊断仪调研报告(终审版)\n\n## 概述与背景\n\n本报告由小队协作产出:调研专员梳理事实与缺口,撰写专员成文,审核专员终审。目标市场为中国(二类器械),预算 15 万元,年产量 10 万套。\n\n## 核心内容\n\n竞品 A/B/C 在成像质量与便携性上各有取舍;技术路线建议探头一体化设计与电池供电优化。注册路径按二类器械执行注册检验与临床评价。成本测算基于年产量 10 万套摊薄产线投入。\n\n## 风险与注意事项\n\n- 供应链对探头晶片依赖较高\n- 注册周期受检验排队影响\n- 年产量波动超过 ±30% 时需重估成本模型\n\n## 基准假设与待确认清单\n\n- 灭菌方式采用环氧乙烷(EO)灭菌(行业常规)\n",
"fields.json": `{"annual_volume":"100000","review":"passed"}`,
},
}
backends.RegisterInstance("demo", &demoBackend{script: demoScript})
planner := &squad.MockPlanner{Script: []squad.StepSpec{
{ID: "step_01_research", Role: "researcher", Title: "市场与技术调研,回填事实与缺口",
Targets: []string{"notes.md", "fields.json", "gaps.json"}},
{ID: "step_02_draft", Role: "writer", Title: "汇总调研产出,撰写报告初稿",
Targets: []string{"output.md"}},
{ID: "step_03_review", Role: "reviewer", Title: "一致性终审并产出终版",
Targets: []string{"output.md", "fields.json"}},
}}
sink := squad.NewFanoutSink(4096)
engine, err := squad.NewEngine(squad.Options{
DataDir: dir, Registry: reg, Blueprints: bps, Planner: planner,
Backends: backends, Sink: sink,
})
if err != nil {
fmt.Fprintln(os.Stderr, "组装失败:", err)
return 1
}
// 2. 注册角色(演示小队编制——底座本身无预设角色)。
fmt.Println("\n[1] 注册角色(手动注册,backend=demo 脚本智能体)")
for _, spec := range []squad.RoleSpec{
{Role: "researcher", Name: "调研专员", Backend: "demo", Keywords: []string{"调研", "市场", "竞品"},
Responsibilities: []string{"市场与技术调研", "事实回填"}},
{Role: "writer", Name: "撰写专员", Backend: "demo", Keywords: []string{"撰写", "报告"},
Responsibilities: []string{"汇总产出并成文"}},
{Role: "reviewer", Name: "审核专员", Backend: "demo", Keywords: []string{"审核", "终审"},
Responsibilities: []string{"一致性终审"}},
} {
if err := reg.Register(spec); err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
fmt.Printf(" + %-12s %s\n", spec.Role, spec.Name)
}
// 3. 发起运行。
goal := squad.Goal{Text: "调研便携式超声诊断仪并产出报告"}
fmt.Printf("\n[2] 发起运行:%s\n", goal.Text)
res, err := engine.Run(context.Background(), goal, squad.RunOptions{})
if err != nil {
fmt.Fprintln(os.Stderr, "运行失败:", err)
return 1
}
reportSteps(res)
// 4. HITL 挂起 → 回应 → 续跑。
for res.Status.IsHeld() && res.Pending != nil {
fmt.Printf("\n[3] ⏸ HITL 挂起(%s):%s\n", res.Pending.Kind, res.Pending.Question)
for _, line := range strings.Split(res.Pending.Context, "\n") {
if line != "" {
fmt.Println(" ", line)
}
}
answer := demoAnswer(res.Pending)
fmt.Printf(" → 自动回应:choice=%q answer=%q\n", answer.Choice, answer.Answer)
res, err = engine.Respond(context.Background(), res.RunID, answer)
if err != nil {
fmt.Fprintln(os.Stderr, "回应失败:", err)
return 1
}
reportSteps(res)
}
// 5. 结果。
fmt.Printf("\n[4] 运行终态:%s\n", res.Status)
if res.Summary != "" {
fmt.Println(" ", res.Summary)
}
if res.Deliverable != "" {
path := filepath.Join(res.WorkDir, res.Deliverable)
fmt.Println(" 交付物:", path)
if head := headLines(path, 6); head != "" {
fmt.Println(" ┌─ 交付物预览")
for _, l := range strings.Split(strings.TrimRight(head, "\n"), "\n") {
fmt.Println(" │", l)
}
fmt.Println(" └─")
}
}
bb, _ := engine.Blackboard(res.RunID)
if bb != nil {
enc, _ := json.MarshalIndent(bb.Fields, "", " ")
fmt.Println(" 黑板字段:", string(enc))
}
fmt.Println("\n演示完成。真实使用:agent-squad role add / run / serve。")
return 0
}
func demoAnswer(req *squad.HumanRequest) squad.RespondDecision {
switch req.Kind {
case squad.HitlGap:
return squad.RespondDecision{RequestID: req.ID, Choice: "resolved", Answer: "年产量 10 万套"}
default:
choice := "approve"
if len(req.Options) > 0 {
choice = req.Options[0].Value
}
return squad.RespondDecision{RequestID: req.ID, Choice: choice}
}
}
func reportSteps(res *squad.RunResult) {
if res.Plan == nil {
return
}
for _, s := range res.Plan.Steps {
mark := map[string]string{"completed": "✔", "failed": "✘", "skipped": "⤼", "running": "…", "pending": " "}[s.Status]
fmt.Printf(" %s %-22s %-12s %s\n", mark, s.ID, s.Role, s.Title)
}
}
func headLines(path string, n int) string {
data, err := os.ReadFile(path)
if err != nil {
return ""
}
lines := strings.Split(string(data), "\n")
if len(lines) > n {
lines = lines[:n]
}
return strings.Join(lines, "\n")
}
+240
View File
@@ -0,0 +1,240 @@
// Package conformance 验证 agent-squad 公开 API 的全链路契约:
// 角色注册 → 蓝图注册 → 规划 → 执行 → HITL 挂起/回应 → 交付与审计。
// 全程脚本后端驱动,不依赖模型与外部智能体。
package conformance
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"agent-native/exec"
"agent-squad/squad"
"agent-squad/store"
)
// replayBackend 按"子目录名→target→内容"回放产出。
type replayBackend struct{ script map[string]map[string]string }
func (b *replayBackend) Name() string { return "replay" }
func (b *replayBackend) Execute(ctx context.Context, req exec.Request) (*exec.Result, error) {
key := filepath.Base(filepath.Clean(req.WorkDir))
files := b.script[key]
patches := map[string][]byte{}
for _, t := range req.Targets {
content := "\n"
if c, ok := files[t]; ok {
content = c
}
out := filepath.Join(req.WorkDir, t)
_ = os.MkdirAll(filepath.Dir(out), 0o755)
if err := os.WriteFile(out, []byte(content), 0o644); err != nil {
return nil, err
}
patches[t] = []byte(content)
}
return &exec.Result{Success: true, Patches: patches, Logs: "replay"}, nil
}
const deliverable = "# 契约验收交付物\n\n## 概述\n\n本文件由回放后端生成,用于验证 agent-squad 全链路契约:注册、规划、执行、HITL、质检与审计。\n\n## 结论\n\n全链路行为符合契约。\n"
// TestFullChainWithHitl 是底座的核心验收:
// 空表起步 → 注册角色/蓝图 → 蓝图命中规划 → 阻塞缺口挂起 → 人工回应 →
// 完成 → 工作区布局/黑板/清单/事件全部可验证。
func TestFullChainWithHitl(t *testing.T) {
dir := t.TempDir()
// 1. 空注册表:底座不预置任何角色。
fstore, err := store.New(dir)
if err != nil {
t.Fatal(err)
}
reg := squad.NewRoleRegistry()
if err := fstore.LoadRoles(reg); err != nil {
t.Fatal(err)
}
if len(reg.List()) != 0 {
t.Fatal("registry must start empty")
}
bps := squad.NewBlueprintRegistry()
if err := fstore.LoadBlueprints(bps); err != nil {
t.Fatal(err)
}
// 2. 注册角色(backend=replay;角色文件落盘 roles.d/)。
for _, spec := range []squad.RoleSpec{
{Role: "researcher", Name: "调研专员", Backend: "replay", Keywords: []string{"调研"},
Responsibilities: []string{"事实梳理"}},
{Role: "writer", Name: "撰写专员", Backend: "replay", Keywords: []string{"撰写"}},
} {
if err := reg.Register(spec); err != nil {
t.Fatal(err)
}
}
if _, err := os.Stat(filepath.Join(dir, "roles.d", "researcher.json")); err != nil {
t.Fatalf("role file not persisted: %v", err)
}
// 3. 注册蓝图(落盘 blueprints.d/)。
bp := squad.Blueprint{
Key: "research_report", Title: "调研报告", Match: []string{"调研", "报告"},
Steps: []squad.StepSpec{
{ID: "b1", Role: "researcher", Title: "调研并回填缺口", Targets: []string{"notes.md", "fields.json", "gaps.json"}},
{ID: "b2", Role: "writer", Title: "汇总成文", Targets: []string{"output.md"}},
},
Template: "# {{goal}}\n\n## 概述\n\n## 结论\n",
}
if err := bps.Register(bp); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(dir, "blueprints.d", "research_report.json")); err != nil {
t.Fatalf("blueprint file not persisted: %v", err)
}
// 4. 组装引擎(脚本回放后端)。
script := map[string]map[string]string{
"01_researcher": {
"notes.md": "# 调研笔记\n- 竞品三款\n- 法规二类",
"fields.json": `{"market":"CN"}`,
"gaps.json": `[{"item":"缺少预算区间","blocking":true}]`,
},
"02_writer": {"output.md": deliverable},
}
backends := squad.NewBackendResolver()
backends.RegisterInstance("replay", &replayBackend{script: script})
sink := squad.NewFanoutSink(4096)
engine, err := squad.NewEngine(squad.Options{
DataDir: dir, Registry: reg, Blueprints: bps, Planner: squad.NewRulePlanner(),
Backends: backends, Sink: sink,
})
if err != nil {
t.Fatal(err)
}
// 5. 发起运行:蓝图命中 → 执行 → 阻塞缺口挂起。
res, err := engine.Run(context.Background(), squad.Goal{Text: "做一份竞品调研报告"}, squad.RunOptions{})
if err != nil {
t.Fatal(err)
}
if res.Status != squad.RunWaitingHuman || res.Pending == nil || res.Pending.Kind != squad.HitlGap {
t.Fatalf("want gap hold, got status=%s pending=%v", res.Status, res.Pending)
}
if res.Plan.TaskKey != "research_report" || res.Plan.PlanningMode != squad.PlanModeBlueprint {
t.Fatalf("blueprint not used: %+v", res.Plan)
}
// 6. 人工回应缺口 → 运行完成。
res2, err := engine.Respond(context.Background(), res.RunID, squad.RespondDecision{
RequestID: res.Pending.ID, Choice: "resolved", Answer: "预算区间 20-30 万",
})
if err != nil {
t.Fatal(err)
}
if res2.Status != squad.RunCompleted {
t.Fatalf("want completed, got %s err=%s", res2.Status, res2.Error)
}
// 7. 工作区契约:布局、黑板、清单、模板渲染。
for _, f := range []string{
"workspace.json", "squad.json", "workflow.json", "blackboard.json",
"template.md", "output.md", "manifest.json",
"context/goal.md", "context/index.json",
"tasks/01_researcher/TASK.md", "tasks/01_researcher/AGENTS.md",
"tasks/02_writer/TASK.md",
} {
if _, err := os.Stat(filepath.Join(res2.WorkDir, f)); err != nil {
t.Errorf("workspace contract: missing %s (%v)", f, err)
}
}
tmpl, _ := os.ReadFile(filepath.Join(res2.WorkDir, "template.md"))
if !strings.Contains(string(tmpl), "调研报告") {
t.Errorf("template not rendered with goal: %s", tmpl)
}
bb, _ := engine.Blackboard(res.RunID)
if bb.Fields["market"] != "CN" {
t.Errorf("fields merge broken: %+v", bb.Fields)
}
if len(bb.Facts) == 0 || !strings.Contains(bb.Facts[0].Claim, "20-30 万") {
t.Errorf("human answer not a fact: %+v", bb.Facts)
}
man, _ := os.ReadFile(filepath.Join(res2.WorkDir, "manifest.json"))
var manifest squad.Manifest
if err := json.Unmarshal(man, &manifest); err != nil {
t.Fatal(err)
}
if !manifest.QualityReport.Passed || manifest.Deliverable != "output.md" || len(manifest.Files) < 5 {
t.Errorf("manifest contract broken: %+v files=%d", manifest.QualityReport, len(manifest.Files))
}
// 8. 事件契约:关键事件齐备且有序。
events, _ := sink.Snapshot()
order := []string{}
for _, ev := range events {
switch ev.Type {
case squad.EvRunStarted, squad.EvStepStarted, squad.EvStepCompleted,
squad.EvHumanInputRequired, squad.EvHumanResponded, squad.EvRunCompleted:
order = append(order, ev.Type)
}
}
joined := strings.Join(order, ",")
for _, want := range []string{
squad.EvRunStarted, squad.EvStepStarted, squad.EvHumanInputRequired,
squad.EvHumanResponded, squad.EvRunCompleted,
} {
if !strings.Contains(joined, want) {
t.Errorf("event %s missing in %s", want, joined)
}
}
if strings.Index(joined, string(squad.EvHumanInputRequired)) > strings.Index(joined, string(squad.EvRunCompleted)) {
t.Errorf("event order wrong: %s", joined)
}
// 9. Inspect/ListRuns 可发现运行。
insp, err := engine.Inspect(res.RunID)
if err != nil || insp.Status != squad.RunCompleted {
t.Fatalf("inspect: %v %+v", err, insp)
}
runs, _ := engine.ListRuns()
if len(runs) != 1 {
t.Fatalf("list runs want 1, got %d", len(runs))
}
// 10. 重启恢复:新引擎实例(同数据目录、空注册表)能读出终态。
engine2, err := squad.NewEngine(squad.Options{DataDir: dir, Registry: squad.NewRoleRegistry(),
Backends: squad.NewBackendResolver(), Sink: squad.NopSink{}})
if err != nil {
t.Fatal(err)
}
insp2, err := engine2.Inspect(res.RunID)
if err != nil || insp2.Status != squad.RunCompleted {
t.Fatalf("restart inspect: %v %+v", err, insp2)
}
}
// TestUnknownBackendFailsClosed 未知智能体名必须失败关闭,不静默落 stub。
func TestUnknownBackendFailsClosed(t *testing.T) {
dir := t.TempDir()
reg := squad.NewRoleRegistry()
_ = reg.Register(squad.RoleSpec{Role: "solo", Backend: "no-such-agent"})
engine, err := squad.NewEngine(squad.Options{
DataDir: dir, Registry: reg,
Planner: &squad.MockPlanner{Script: []squad.StepSpec{
{ID: "s1", Role: "solo", Title: "单步", Targets: []string{"output.md"}},
}},
Backends: squad.NewBackendResolver(), Sink: squad.NopSink{},
})
if err != nil {
t.Fatal(err)
}
res, err := engine.Run(context.Background(), squad.Goal{Text: "g"}, squad.RunOptions{})
if err != nil {
t.Fatal(err)
}
if res.Status != squad.RunFailed || !strings.Contains(res.Error, "no-such-agent") {
t.Fatalf("unknown backend must fail closed: %+v", res)
}
}
+7
View File
@@ -0,0 +1,7 @@
module agent-squad
go 1.27
require agent-native v0.0.0
replace agent-native => ./Agent-Native
+12
View File
@@ -0,0 +1,12 @@
// agent-squad 单二进制入口:通用智能体小队编排底座。
package main
import (
"os"
"agent-squad/cli"
)
func main() {
os.Exit(cli.Main(os.Args[1:]))
}
+363
View File
@@ -0,0 +1,363 @@
// Package server 提供 agent-squad 的 HTTP API:角色/蓝图 CRUD、发起运行、
// SSE 事件流与 HITL pending/respond。无 UI;产品接入方自行适配展示层。
package server
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"strconv"
"sync"
"time"
"agent-squad/squad"
"agent-squad/store"
)
// Server HTTP 服务。
type Server struct {
engine *squad.SquadEngine
sink *squad.FanoutSink
st *store.FileStore
mu sync.Mutex
latest map[string]*squad.RunResult // 最近一次推进结果(供轮询)
}
// New 组装服务。
func New(engine *squad.SquadEngine, sink *squad.FanoutSink, st *store.FileStore) *Server {
return &Server{engine: engine, sink: sink, st: st, latest: map[string]*squad.RunResult{}}
}
// Handler 返回路由。
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /v1/health", s.handleHealth)
mux.HandleFunc("GET /v1/roles", s.handleListRoles)
mux.HandleFunc("POST /v1/roles", s.handlePutRole)
mux.HandleFunc("PUT /v1/roles/{key}", s.handlePutRole)
mux.HandleFunc("DELETE /v1/roles/{key}", s.handleDeleteRole)
mux.HandleFunc("POST /v1/roles/derive", s.handleDeriveRole)
mux.HandleFunc("GET /v1/roles/recommend", s.handleRecommendRole)
mux.HandleFunc("GET /v1/blueprints", s.handleListBlueprints)
mux.HandleFunc("POST /v1/blueprints", s.handlePutBlueprint)
mux.HandleFunc("DELETE /v1/blueprints/{key}", s.handleDeleteBlueprint)
mux.HandleFunc("POST /v1/runs", s.handleCreateRun)
mux.HandleFunc("GET /v1/runs", s.handleListRuns)
mux.HandleFunc("GET /v1/runs/{id}", s.handleInspectRun)
mux.HandleFunc("GET /v1/runs/{id}/pending", s.handlePending)
mux.HandleFunc("POST /v1/runs/{id}/respond", s.handleRespond)
mux.HandleFunc("POST /v1/runs/{id}/resume", s.handleResume)
mux.HandleFunc("POST /v1/runs/{id}/steer", s.handleSteer)
mux.HandleFunc("POST /v1/runs/{id}/blackboard", s.handleSetField)
mux.HandleFunc("POST /v1/wakeups/sweep", s.handleSweep)
mux.HandleFunc("GET /v1/runs/{id}/blackboard", s.handleBlackboard)
mux.HandleFunc("GET /v1/runs/{id}/events", s.handleEvents)
return mux
}
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(v)
}
func writeErr(w http.ResponseWriter, code int, err error) {
writeJSON(w, code, map[string]string{"error": err.Error()})
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
// ---- 角色 ----
func (s *Server) handleListRoles(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, s.engine.Registry().List())
}
func (s *Server) handlePutRole(w http.ResponseWriter, r *http.Request) {
var spec squad.RoleSpec
if err := json.NewDecoder(r.Body).Decode(&spec); err != nil {
writeErr(w, http.StatusBadRequest, err)
return
}
if key := r.PathValue("key"); key != "" && spec.Role == "" {
spec.Role = key
}
if err := s.engine.Registry().Register(spec); err != nil {
writeErr(w, http.StatusBadRequest, err)
return
}
writeJSON(w, http.StatusOK, spec)
}
func (s *Server) handleDeleteRole(w http.ResponseWriter, r *http.Request) {
key := r.PathValue("key")
if !s.engine.Registry().Remove(key) {
writeErr(w, http.StatusNotFound, fmt.Errorf("role %s not found", key))
return
}
s.st.DeleteRole(key)
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
func (s *Server) handleDeriveRole(w http.ResponseWriter, r *http.Request) {
var req squad.DeriveRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, err)
return
}
spec, err := s.engine.Registry().Derive(req)
if err != nil {
writeErr(w, http.StatusBadRequest, err)
return
}
writeJSON(w, http.StatusCreated, spec)
}
func (s *Server) handleRecommendRole(w http.ResponseWriter, r *http.Request) {
spec, ok := s.engine.Registry().Recommend(r.URL.Query().Get("q"))
writeJSON(w, http.StatusOK, map[string]any{"role": spec, "matched": ok})
}
// ---- 蓝图 ----
func (s *Server) handleListBlueprints(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, s.engine.Blueprints().List())
}
func (s *Server) handlePutBlueprint(w http.ResponseWriter, r *http.Request) {
var bp squad.Blueprint
if err := json.NewDecoder(r.Body).Decode(&bp); err != nil {
writeErr(w, http.StatusBadRequest, err)
return
}
if err := s.engine.Blueprints().Register(bp); err != nil {
writeErr(w, http.StatusBadRequest, err)
return
}
writeJSON(w, http.StatusCreated, bp)
}
func (s *Server) handleDeleteBlueprint(w http.ResponseWriter, r *http.Request) {
key := r.PathValue("key")
if !s.engine.Blueprints().Remove(key) {
writeErr(w, http.StatusNotFound, fmt.Errorf("blueprint %s not found", key))
return
}
s.st.DeleteBlueprint(key)
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
// ---- 运行 ----
type createRunRequest struct {
Goal squad.Goal `json:"goal"`
TaskKey string `json:"task_key,omitempty"`
Backend string `json:"backend,omitempty"`
// MaterialsB64 便捷通道:filename → base64 内容。
MaterialsB64 map[string]string `json:"materials_b64,omitempty"`
}
func (s *Server) handleCreateRun(w http.ResponseWriter, r *http.Request) {
var req createRunRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, err)
return
}
for name, b64 := range req.MaterialsB64 {
data, err := base64.StdEncoding.DecodeString(b64)
if err != nil {
writeErr(w, http.StatusBadRequest, fmt.Errorf("materials_b64[%s]: %w", name, err))
return
}
req.Goal.Materials = append(req.Goal.Materials, squad.MaterialFile{Filename: name, Content: data})
}
if req.Goal.Text == "" {
writeErr(w, http.StatusBadRequest, fmt.Errorf("goal.text is required"))
return
}
// 同步发起:Run 在首次挂起或终态前返回;长时间执行由调用方带超时/异步包装。
// 这里为 HTTP 体验做异步:先返回 run_id 无法预知(ID 在 Run 内生成),
// 因此采用同步调用 + 流式等待由调用方控制超时。
res, err := s.engine.Run(r.Context(), req.Goal, squad.RunOptions{TaskKey: req.TaskKey, Backend: req.Backend})
if err != nil {
writeErr(w, http.StatusInternalServerError, err)
return
}
s.record(res)
code := http.StatusOK
if res.Status.Terminal() {
code = http.StatusOK
}
writeJSON(w, code, res)
}
func (s *Server) record(res *squad.RunResult) {
if res == nil {
return
}
s.mu.Lock()
s.latest[res.RunID] = res
s.mu.Unlock()
}
func (s *Server) handleListRuns(w http.ResponseWriter, r *http.Request) {
runs, err := s.engine.ListRuns()
if err != nil {
writeErr(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, runs)
}
func (s *Server) handleInspectRun(w http.ResponseWriter, r *http.Request) {
res, err := s.engine.Inspect(r.PathValue("id"))
if err != nil {
writeErr(w, http.StatusNotFound, err)
return
}
writeJSON(w, http.StatusOK, res)
}
func (s *Server) handlePending(w http.ResponseWriter, r *http.Request) {
req, err := s.engine.Pending(r.PathValue("id"))
if err != nil || req == nil {
writeJSON(w, http.StatusOK, map[string]any{"pending": nil})
return
}
writeJSON(w, http.StatusOK, map[string]any{"pending": req})
}
func (s *Server) handleRespond(w http.ResponseWriter, r *http.Request) {
var decision squad.RespondDecision
if err := json.NewDecoder(r.Body).Decode(&decision); err != nil {
writeErr(w, http.StatusBadRequest, err)
return
}
res, err := s.engine.Respond(r.Context(), r.PathValue("id"), decision)
if err != nil {
writeErr(w, http.StatusBadRequest, err)
return
}
s.record(res)
writeJSON(w, http.StatusOK, res)
}
func (s *Server) handleResume(w http.ResponseWriter, r *http.Request) {
res, err := s.engine.Resume(r.Context(), r.PathValue("id"))
if err != nil {
writeErr(w, http.StatusBadRequest, err)
return
}
s.record(res)
writeJSON(w, http.StatusOK, res)
}
func (s *Server) handleBlackboard(w http.ResponseWriter, r *http.Request) {
bb, err := s.engine.Blackboard(r.PathValue("id"))
if err != nil {
writeErr(w, http.StatusNotFound, err)
return
}
writeJSON(w, http.StatusOK, bb)
}
func (s *Server) handleSteer(w http.ResponseWriter, r *http.Request) {
var req struct {
Text string `json:"text"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, err)
return
}
if err := s.engine.Steer(r.PathValue("id"), req.Text); err != nil {
writeErr(w, http.StatusBadRequest, err)
return
}
writeJSON(w, http.StatusAccepted, map[string]string{"status": "queued_for_next_step"})
}
func (s *Server) handleSetField(w http.ResponseWriter, r *http.Request) {
var req struct {
Field string `json:"field"`
Value string `json:"value"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, err)
return
}
if err := s.engine.SetBlackboardField(r.PathValue("id"), req.Field, req.Value); err != nil {
writeErr(w, http.StatusBadRequest, err)
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "set"})
}
func (s *Server) handleSweep(w http.ResponseWriter, r *http.Request) {
n := s.engine.SweepWakeups(r.Context())
writeJSON(w, http.StatusOK, map[string]int{"woken": n})
}
// StartWakeupSweeper 周期扫描等待条件(等事件自动唤醒);返回停止函数。
// 间隔默认 15s。
func (s *Server) StartWakeupSweeper(interval time.Duration) (stop func()) {
if interval <= 0 {
interval = 15 * time.Second
}
done := make(chan struct{})
go func() {
t := time.NewTicker(interval)
defer t.Stop()
for {
select {
case <-done:
return
case <-t.C:
s.engine.SweepWakeups(context.Background())
}
}
}()
return func() { close(done) }
}
// handleEvents SSE:cursor 之后重放缓冲再实时推送。
func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) {
flusher, ok := w.(http.Flusher)
if !ok {
writeErr(w, http.StatusInternalServerError, fmt.Errorf("streaming unsupported"))
return
}
cursor := 0
if c := r.URL.Query().Get("cursor"); c != "" {
if n, err := strconv.Atoi(c); err == nil {
cursor = n
}
}
ch, next, cancel := s.sink.Subscribe(cursor)
defer cancel()
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, "event: cursor\ndata: %d\n\n", next)
flusher.Flush()
for {
select {
case <-r.Context().Done():
return
case ev, ok := <-ch:
if !ok {
return
}
data, _ := json.Marshal(ev)
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", ev.Type, data)
flusher.Flush()
}
}
}
+218
View File
@@ -0,0 +1,218 @@
package server
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"agent-native/exec"
"agent-squad/squad"
"agent-squad/store"
)
type scriptBackend struct{ script map[string]map[string]string }
func (b *scriptBackend) Name() string { return "script" }
func (b *scriptBackend) Execute(ctx context.Context, req exec.Request) (*exec.Result, error) {
key := filepath.Base(filepath.Clean(req.WorkDir))
files := b.script[key]
patches := map[string][]byte{}
for _, t2 := range req.Targets {
content := "\n"
if c, ok := files[t2]; ok {
content = c
}
out := filepath.Join(req.WorkDir, t2)
_ = os.MkdirAll(filepath.Dir(out), 0o755)
_ = os.WriteFile(out, []byte(content), 0o644)
patches[t2] = []byte(content)
}
return &exec.Result{Success: true, Patches: patches}, nil
}
const goodReport = "# API 测试交付物\n\n## 概述\n\n脚本后端生成的合格交付物正文,长度超过五十个字符以满足最小质检要求。\n"
func newTestServer(t *testing.T) (*httptest.Server, *squad.SquadEngine) {
t.Helper()
dir := t.TempDir()
st, err := store.New(dir)
if err != nil {
t.Fatal(err)
}
reg := squad.NewRoleRegistry()
_ = st.LoadRoles(reg)
bps := squad.NewBlueprintRegistry()
_ = st.LoadBlueprints(bps)
backends := squad.NewBackendResolver()
script := map[string]map[string]string{
"01_researcher": {"notes.md": "n", "fields.json": `{"k":"v"}`},
"02_writer": {"output.md": goodReport},
}
backends.RegisterInstance("script", &scriptBackend{script: script})
sink := squad.NewFanoutSink(1024)
engine, err := squad.NewEngine(squad.Options{DataDir: dir, Registry: reg, Blueprints: bps,
Planner: &squad.MockPlanner{Script: []squad.StepSpec{
{ID: "s1", Role: "researcher", Title: "调研", Targets: []string{"notes.md", "fields.json"}},
{ID: "s2", Role: "writer", Title: "成文", Targets: []string{"output.md"}},
}}, Backends: backends, Sink: sink})
if err != nil {
t.Fatal(err)
}
ts := httptest.NewServer(New(engine, sink, st).Handler())
t.Cleanup(ts.Close)
return ts, engine
}
func doJSON(t *testing.T, method, url string, body any) (int, map[string]any) {
t.Helper()
var buf bytes.Buffer
if body != nil {
_ = json.NewEncoder(&buf).Encode(body)
}
req, _ := http.NewRequest(method, url, &buf)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
out := map[string]any{}
if strings.Contains(resp.Header.Get("Content-Type"), "json") {
_ = json.NewDecoder(resp.Body).Decode(&out)
}
return resp.StatusCode, out
}
func TestRolesCRUDAndPersist(t *testing.T) {
ts, _ := newTestServer(t)
code, out := doJSON(t, "POST", ts.URL+"/v1/roles", map[string]any{
"role": "researcher", "name": "调研专员", "backend": "script", "keywords": []string{"调研"},
})
if code != 200 {
t.Fatalf("create role: %d %v", code, out)
}
code, _ = doJSON(t, "GET", ts.URL+"/v1/roles", nil)
if code != 200 {
t.Fatalf("list roles: %d", code)
}
// list 是数组:直接解码判定。
resp, _ := http.Get(ts.URL + "/v1/roles")
var roles []map[string]any
_ = json.NewDecoder(resp.Body).Decode(&roles)
resp.Body.Close()
if len(roles) != 1 || roles[0]["role"] != "researcher" {
t.Fatalf("role not listed: %v", roles)
}
code, _ = doJSON(t, "DELETE", ts.URL+"/v1/roles/researcher", nil)
if code != 200 {
t.Fatalf("delete role: %d", code)
}
code, _ = doJSON(t, "DELETE", ts.URL+"/v1/roles/researcher", nil)
if code != 404 {
t.Fatalf("double delete want 404: %d", code)
}
}
func TestRunPendingRespondViaAPI(t *testing.T) {
ts, _ := newTestServer(t)
_, _ = doJSON(t, "POST", ts.URL+"/v1/roles", map[string]any{"role": "researcher", "backend": "script"})
_, _ = doJSON(t, "POST", ts.URL+"/v1/roles", map[string]any{"role": "writer", "backend": "script"})
code, out := doJSON(t, "POST", ts.URL+"/v1/runs", map[string]any{
"goal": map[string]any{"text": "生成报告"},
})
if code != 200 {
t.Fatalf("create run: %d %v", code, out)
}
runID, _ := out["run_id"].(string)
status, _ := out["status"].(string)
if runID == "" || status != string(squad.RunCompleted) {
t.Fatalf("run want completed, got %v", out)
}
code, insp := doJSON(t, "GET", ts.URL+"/v1/runs/"+runID, nil)
if code != 200 || insp["deliverable"] != "output.md" {
t.Fatalf("inspect: %d %v", code, insp)
}
code, bb := doJSON(t, "GET", ts.URL+"/v1/runs/"+runID+"/blackboard", nil)
if code != 200 || bb["fields"] == nil {
t.Fatalf("blackboard: %d %v", code, bb)
}
code, runs := doJSON(t, "GET", ts.URL+"/v1/runs", nil)
if code != 200 {
t.Fatalf("list runs: %d", code)
}
_ = runs
// SSE:应能收到已缓冲事件(回放异步推送,带超时循环读取)。
req, _ := http.NewRequest("GET", ts.URL+"/v1/runs/"+runID+"/events", nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/event-stream") {
t.Fatalf("SSE content type: %s", ct)
}
head := collectSSEReadable(t, resp)
if !strings.Contains(head, "run_completed") && !strings.Contains(head, "run_started") {
t.Fatalf("SSE replay missing events: %q", head)
}
}
// collectSSEReadable 持续读取流内容直到出现 run_completed/超时(回放帧异步到达)。
func collectSSEReadable(t *testing.T, resp *http.Response) string {
t.Helper()
done := make(chan string, 1)
go func() {
var sb strings.Builder
buf := make([]byte, 4096)
for {
n, err := resp.Body.Read(buf)
if n > 0 {
sb.Write(buf[:n])
if s := sb.String(); strings.Contains(s, "run_completed") || strings.Contains(s, "run_failed") {
done <- s
return
}
}
if err != nil {
done <- sb.String()
return
}
}
}()
select {
case s := <-done:
return s
case <-time.After(3 * time.Second):
t.Fatal("SSE stream did not deliver terminal event in time")
return ""
}
}
func TestHealthAndBlueprints(t *testing.T) {
ts, _ := newTestServer(t)
code, out := doJSON(t, "GET", ts.URL+"/v1/health", nil)
if code != 200 || out["ok"] != true {
t.Fatalf("health: %d %v", code, out)
}
code, _ = doJSON(t, "POST", ts.URL+"/v1/blueprints", map[string]any{
"key": "r", "title": "报告", "steps": []map[string]any{{"role": "writer", "title": "成文"}},
})
if code != 201 {
t.Fatal("blueprint create failed")
}
resp, _ := http.Get(ts.URL + "/v1/blueprints")
var bps []map[string]any
_ = json.NewDecoder(resp.Body).Decode(&bps)
resp.Body.Close()
if len(bps) != 1 {
t.Fatalf("blueprints: %v", bps)
}
}
+148
View File
@@ -0,0 +1,148 @@
package squad
import (
"fmt"
"sync"
"time"
"agent-native/adapters/native"
"agent-native/exec"
"agent-native/runtime/llm"
)
// BackendFactory 按角色构造后端:角色级 Model/ToolProfile 在工厂闭包层绑定
// (agent-native exec.Request 无 Model 字段,绑定不侵入执行契约)。
type BackendFactory func(role RoleSpec) (exec.Backend, error)
// BackendResolver 角色 → 智能体后端解析器。
// 内置 "stub"(agent-native 离线桩);真实智能体经 RegisterAgentNative 注册
// agent-native 自研内核,产品也可注册 "pi-agent" 等外部智能体工厂。
// 未知名字 fail-closed,绝不静默落 stub。角色声明 MaxConcurrentTasks 时
// 以并发限流包装实现对象级串行(每个角色一份缓存实例,限流跨步骤共享)。
type BackendResolver struct {
mu sync.RWMutex
factories map[string]BackendFactory
underlying map[string]exec.Backend // 无工厂直挂的后端实例
wrapped map[string]exec.Backend // 角色(含模型绑定)→ 已包装实例缓存
}
// NewBackendResolver 创建解析器并注册内置后端。
func NewBackendResolver() *BackendResolver {
r := &BackendResolver{
factories: make(map[string]BackendFactory),
underlying: make(map[string]exec.Backend),
wrapped: make(map[string]exec.Backend),
}
r.RegisterInstance("stub", &exec.StubBackend{})
return r
}
// Register 注册后端工厂(名字如 "agent-native"、"pi-agent")。
func (r *BackendResolver) Register(name string, f BackendFactory) {
r.mu.Lock()
defer r.mu.Unlock()
r.factories[name] = f
}
// RegisterInstance 直接注册后端实例(忽略角色级绑定)。
func (r *BackendResolver) RegisterInstance(name string, b exec.Backend) {
r.mu.Lock()
defer r.mu.Unlock()
r.underlying[name] = b
}
// RegisterAgentNative 注册 "agent-native" 后端工厂:每个角色一个
// agent-native 自研内核实例(runtime 循环 + 工作区工具),角色级 Model
// 覆盖 defaultModel。智能体执行全部委托 agent-native,本库不实现 Agent。
func (r *BackendResolver) RegisterAgentNative(client *llm.Client, defaultModel string, maxTurns int, timeout time.Duration) {
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
}
return native.New(opts), nil
})
}
// ListBackends 返回可用后端名(排序稳定)。
func (r *BackendResolver) ListBackends() []string {
r.mu.RLock()
defer r.mu.RUnlock()
seen := map[string]bool{}
for n := range r.factories {
seen[n] = true
}
for n := range r.underlying {
seen[n] = true
}
out := make([]string, 0, len(seen))
for n := range seen {
out = append(out, n)
}
// 简单插入排序,输出稳定。
for i := 1; i < len(out); i++ {
for j := i; j > 0 && out[j] < out[j-1]; j-- {
out[j], out[j-1] = out[j-1], out[j]
}
}
return out
}
// Resolve 解析角色的执行后端:Role.Backend → Options.DefaultBackend → "stub"。
// MaxConcurrentTasks > 1 时以 agent-native 并发限流包装(per-role 缓存,跨步骤共享;
// 创建段持写锁防并行首解析竞态——否则并发步骤会各拿一份限流器)。
func (r *BackendResolver) Resolve(role RoleSpec, defaultBackend string) (exec.Backend, string, error) {
name := role.Backend
if name == "" {
name = defaultBackend
}
if name == "" {
name = "stub"
}
cacheKey := name + "\x00" + role.Role + "\x00" + role.Model
// 后端名快照在锁外获取(Resolve 持锁期间再调 ListBackends 会自死锁)。
names := r.ListBackends()
r.mu.RLock()
cached, ok := r.wrapped[cacheKey]
factory, hasFactory := r.factories[name]
under, hasUnder := r.underlying[name]
r.mu.RUnlock()
if ok {
return cached, name, nil
}
r.mu.Lock()
defer r.mu.Unlock()
// 双检:等写锁期间可能已被并发解析填充。
if b, ok := r.wrapped[cacheKey]; ok {
return b, name, nil
}
var b exec.Backend
if hasUnder {
b = under
} else if hasFactory {
var err error
b, err = factory(role)
if err != nil {
return nil, name, err
}
} else {
return nil, name, fmt.Errorf("unknown backend %q (registered: %v); refusing to silently fall back", name, names)
}
// MaxConcurrentTasks>=1 即包装限流(1 = 对象级串行;0 = 不限)。
if max := role.MaxConcurrentTasks; max >= 1 {
b = exec.WrapWithConcurrencyLimit(b, max)
}
r.wrapped[cacheKey] = b
return b, name, nil
}
+116
View File
@@ -0,0 +1,116 @@
package squad
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
)
// loadBlackboard 读取黑板;不存在时返回初始黑板。
func loadBlackboard(wsDir string) (*BlackboardState, error) {
bb := &BlackboardState{Fields: map[string]string{}, UpdatedAt: time.Now()}
data, err := os.ReadFile(filepath.Join(wsDir, "blackboard.json"))
if err != nil {
if os.IsNotExist(err) {
return bb, nil
}
return nil, err
}
if err := json.Unmarshal(data, bb); err != nil {
return nil, fmt.Errorf("parse blackboard.json: %w", err)
}
if bb.Fields == nil {
bb.Fields = map[string]string{}
}
return bb, nil
}
// saveBlackboard 原子写回黑板。
func saveBlackboard(wsDir string, bb *BlackboardState) error {
bb.UpdatedAt = time.Now()
data, err := json.MarshalIndent(bb, "", " ")
if err != nil {
return err
}
tmp := filepath.Join(wsDir, ".blackboard.json.tmp")
if err := os.WriteFile(tmp, data, 0o644); err != nil {
return err
}
return os.Rename(tmp, filepath.Join(wsDir, "blackboard.json"))
}
// mergeStepArtifacts 合并步骤产出:fields.json(字符串键值)与
// gaps.json([]GapItem,角色可声明 blocking 缺口)合入黑板。
// 返回本次新合入的阻塞缺口(用于 HITL 挂起判断)。
func mergeStepArtifacts(bb *BlackboardState, subDir string) []GapItem {
var newBlocking []GapItem
if data, err := os.ReadFile(filepath.Join(subDir, "fields.json")); err == nil {
var fields map[string]string
if json.Unmarshal(data, &fields) == nil {
for k, v := range fields {
bb.Fields[k] = v
}
}
}
if data, err := os.ReadFile(filepath.Join(subDir, "gaps.json")); err == nil {
var gaps []GapItem
if json.Unmarshal(data, &gaps) == nil {
for _, g := range gaps {
if g.ID == "" {
g.ID = fmt.Sprintf("gap_%d", len(bb.Gaps)+1)
}
if exists, _ := findGap(bb, g.ID, g.Item); exists {
continue
}
if g.Owner == "" {
g.Owner = "agent"
}
bb.Gaps = append(bb.Gaps, g)
if g.Blocking && !g.Resolved {
newBlocking = append(newBlocking, g)
}
}
}
}
return newBlocking
}
func findGap(bb *BlackboardState, id, item string) (bool, bool) {
for _, g := range bb.Gaps {
if (id != "" && g.ID == id) || (item != "" && g.Item == item) {
return true, g.Resolved
}
}
return false, false
}
// UnresolvedBlockingGaps 返回未解决的阻塞缺口。
func (bb *BlackboardState) UnresolvedBlockingGaps() []GapItem {
var out []GapItem
for _, g := range bb.Gaps {
if g.Blocking && !g.Resolved {
out = append(out, g)
}
}
return out
}
// ResolveGapByID 将缺口标记为已解决,并把人工回答落成事实/字段。
func (bb *BlackboardState) ResolveGapByID(id, answer string) bool {
for i := range bb.Gaps {
if bb.Gaps[i].ID == id {
bb.Gaps[i].Resolved = true
if answer != "" {
bb.Facts = append(bb.Facts, FactItem{
ID: fmt.Sprintf("fact_%d", len(bb.Facts)+1), Claim: answer, Source: "human",
})
}
return true
}
}
return false
}
+101
View File
@@ -0,0 +1,101 @@
package squad
import (
"fmt"
"sort"
"strings"
"sync"
)
// BlueprintRegistry 任务模板注册中心:空表起步。
// 命中模板的 Goal 走基线工作流,未命中走规划器自主拆解。
type BlueprintRegistry struct {
mu sync.RWMutex
bps map[string]Blueprint
// OnChange 可选钩子:注册/删除后回调(store 层用于落盘)。
OnChange func(bp Blueprint)
}
// NewBlueprintRegistry 创建空注册表。
func NewBlueprintRegistry() *BlueprintRegistry {
return &BlueprintRegistry{bps: make(map[string]Blueprint)}
}
// Register 注册或更新模板。
func (r *BlueprintRegistry) Register(bp Blueprint) error {
if strings.TrimSpace(bp.Key) == "" {
return fmt.Errorf("blueprint key cannot be empty")
}
if len(bp.Steps) == 0 {
return fmt.Errorf("blueprint %s has no steps", bp.Key)
}
for i := range bp.Steps {
if bp.Steps[i].ID == "" {
bp.Steps[i].ID = fmt.Sprintf("step_%02d", i+1)
}
if bp.Steps[i].Targets == nil {
bp.Steps[i].Targets = []string{"output.md"}
}
}
r.mu.Lock()
r.bps[bp.Key] = bp
r.mu.Unlock()
if r.OnChange != nil {
r.OnChange(bp)
}
return nil
}
// Get 按 Key 获取模板。
func (r *BlueprintRegistry) Get(key string) (Blueprint, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
bp, ok := r.bps[key]
return bp, ok
}
// List 列出全部模板(按 Key 排序)。
func (r *BlueprintRegistry) List() []Blueprint {
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]Blueprint, 0, len(r.bps))
for _, bp := range r.bps {
out = append(out, bp)
}
sort.Slice(out, func(i, j int) bool { return out[i].Key < out[j].Key })
return out
}
// Remove 删除模板。
func (r *BlueprintRegistry) Remove(key string) bool {
r.mu.Lock()
_, ok := r.bps[key]
if ok {
delete(r.bps, key)
}
r.mu.Unlock()
return ok
}
// Match 按目标文本匹配模板:命中 Match 关键词数最多者;无命中返回 false。
// 显式 taskKey 非空时直接按 Key 精确查找。
func (r *BlueprintRegistry) Match(goalText, taskKey string) (Blueprint, bool) {
if taskKey != "" {
return r.Get(taskKey)
}
q := strings.ToLower(goalText)
var best Blueprint
bestScore := 0
for _, bp := range r.List() {
score := 0
for _, kw := range bp.Match {
if kw != "" && strings.Contains(q, strings.ToLower(kw)) {
score++
}
}
if score > bestScore {
best, bestScore = bp, score
}
}
return best, bestScore > 0
}
+65
View File
@@ -0,0 +1,65 @@
package squad
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"time"
)
// Checker 交付检查器:工作流执行完后校验交付物。
// 默认 MinimalChecker:交付物存在且非空、无未替换占位符;产品可注入领域规则。
type Checker interface {
Check(req CheckRequest) *QualityReport
}
// CheckRequest 检查输入。
type CheckRequest struct {
RunID string
Workspace string
Deliverable string // 相对路径(如 output.md)
Plan *WorkflowPlan
Squad *SquadConfig
Template string
}
// MinimalChecker 默认最小检查器。
type MinimalChecker struct{}
// Check 执行最小检查:存在、非空、无占位符、含标题。
func (MinimalChecker) Check(req CheckRequest) *QualityReport {
report := &QualityReport{CheckedAt: time.Now(), Score: 100, Passed: true}
fail := func(msg string) {
report.Passed = false
report.Violations = append(report.Violations, msg)
report.Score -= 25
}
path := filepath.Join(req.Workspace, req.Deliverable)
data, err := os.ReadFile(path)
if err != nil {
fail(fmt.Sprintf("交付物缺失: %s (%v)", req.Deliverable, err))
return report
}
text := string(data)
if len(strings.TrimSpace(text)) < 50 {
fail("交付物内容过短(<50 字符)")
}
// 常见未替换占位符形态 {{var}} / «var» / <TODO>。
for _, re := range []string{`{{[^}]+}}`, `«[^»]+»`, `<TODO[^>]*>`} {
if hits := regexpFindAll(re, text); len(hits) > 0 {
report.Placeholders = append(report.Placeholders, hits...)
fail(fmt.Sprintf("存在未替换占位符: %v", hits))
}
}
if report.Passed {
report.Score = 100
}
return report
}
func regexpFindAll(pattern, s string) []string {
re := regexp.MustCompile(pattern)
return re.FindAllString(s, 8)
}
+937
View File
@@ -0,0 +1,937 @@
package squad
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
)
// Options 引擎组装选项(组合根装配;库内零产品依赖)。
type Options struct {
DataDir string // 数据根:runs/、roles.d/、blueprints.d/ 所在目录(必填)
Registry *RoleRegistry
Blueprints *BlueprintRegistry
Planner Planner
Backends *BackendResolver
Evaluator StepEvaluator
Checker Checker
Sink ProgressSink
// DefaultBackend 角色未声明 Backend 时的默认智能体名(默认 stub)。
DefaultBackend string
// StepTimeout 单步执行超时(默认 10 分钟)。
StepTimeout time.Duration
// HitlPolicy 全局 HITL 策略(角色/Goal 可覆盖)。
HitlPolicy HitlPolicy
// RepairBudget 质检失败自动修复次数(默认 1)。
RepairBudget int
}
// engineDeps 驱动器依赖(由引擎从 Options 派生)。
type engineDeps struct {
Registry *RoleRegistry
Backends *BackendResolver
Evaluator StepEvaluator
Checker Checker
Sink ProgressSink
Workspaces *WorkspaceEngine
StepTimeout time.Duration
HitlPolicy HitlPolicy
}
// RunOptions 单次运行的可选项。
type RunOptions struct {
// TaskKey 显式指定蓝图模板键;空则按 Goal.Text 匹配或自主规划。
TaskKey string `json:"task_key,omitempty"`
// Backend 运行级默认后端(覆盖引擎 DefaultBackend)。
Backend string `json:"backend,omitempty"`
// Mode 编排模式:standard(默认,一次性计划顺序执行)| leader
// (leader-delegation:leader 角色逐步委派成员,成员产出后唤醒 leader 再评估)。
Mode string `json:"mode,omitempty"`
// LeaderRole leader 模式下的 leader 角色 key(必须已注册或在计划编制内)。
LeaderRole string `json:"leader_role,omitempty"`
// MaxLeaderTurns leader 模式每运行的 leader 轮次上限(默认 8,防失控)。
MaxLeaderTurns int `json:"max_leader_turns,omitempty"`
}
// SquadEngine 小队引擎门面:Run / Respond / Resume / Inspect / ListRuns。
// 每个运行同一时刻只允许一个控制操作(Run/Respond 互斥)。
type SquadEngine struct {
opts Options
deps engineDeps
ws *WorkspaceEngine
mu sync.Mutex
runLocks map[string]*sync.Mutex
}
// NewEngine 组装引擎并应用默认值。
func NewEngine(opts Options) (*SquadEngine, error) {
if opts.DataDir == "" {
return nil, fmt.Errorf("DataDir is required")
}
if opts.Registry == nil {
opts.Registry = NewRoleRegistry()
}
if opts.Blueprints == nil {
opts.Blueprints = NewBlueprintRegistry()
}
if opts.Planner == nil {
opts.Planner = NewRulePlanner()
}
if opts.Backends == nil {
opts.Backends = NewBackendResolver()
}
if opts.Evaluator == nil {
opts.Evaluator = NoOpEvaluator{}
}
if opts.Checker == nil {
opts.Checker = MinimalChecker{}
}
if opts.Sink == nil {
opts.Sink = NopSink{}
}
if opts.DefaultBackend == "" {
opts.DefaultBackend = "stub"
}
if opts.StepTimeout == 0 {
opts.StepTimeout = 10 * time.Minute
}
if opts.RepairBudget == 0 {
opts.RepairBudget = 1
}
if opts.HitlPolicy.Mode == "" {
opts.HitlPolicy.Mode = HitlInteractive
}
ws := &WorkspaceEngine{DataDir: opts.DataDir}
deps := engineDeps{
Registry: opts.Registry, Backends: opts.Backends, Evaluator: opts.Evaluator,
Checker: opts.Checker, Sink: opts.Sink, Workspaces: ws,
StepTimeout: opts.StepTimeout, HitlPolicy: opts.HitlPolicy,
}
return &SquadEngine{opts: opts, deps: deps, ws: ws, runLocks: map[string]*sync.Mutex{}}, nil
}
// Registry 暴露角色注册表(只读使用)。
func (e *SquadEngine) Registry() *RoleRegistry { return e.opts.Registry }
// Blueprints 暴露蓝图注册表。
func (e *SquadEngine) Blueprints() *BlueprintRegistry { return e.opts.Blueprints }
func (e *SquadEngine) runLock(runID string) *sync.Mutex {
e.mu.Lock()
defer e.mu.Unlock()
l, ok := e.runLocks[runID]
if !ok {
l = &sync.Mutex{}
e.runLocks[runID] = l
}
return l
}
// Run 发起一次运行:规划(含澄清/确认挂起)→ 执行 → 收口。
func (e *SquadEngine) Run(ctx context.Context, goal Goal, runOpts RunOptions) (*RunResult, error) {
if strings.TrimSpace(goal.Text) == "" {
return nil, fmt.Errorf("goal text is required")
}
runID := fmt.Sprintf("run_%s", time.Now().UTC().Format("20060102T150405.000000000"))
lock := e.runLock(runID)
lock.Lock()
defer lock.Unlock()
meta := RunMetadata{
ID: runID, Goal: goal, Status: string(RunPlanning),
Backend: firstNonEmpty(runOpts.Backend, e.opts.DefaultBackend),
Planner: e.opts.Planner.Name(),
CreatedAt: time.Now(), UpdatedAt: time.Now(),
Metadata: map[string]string{
"task_key": runOpts.TaskKey,
"mode": runOpts.Mode,
},
}
if runOpts.Mode == "leader" {
meta.Metadata["leader_role"] = runOpts.LeaderRole
if runOpts.MaxLeaderTurns > 0 {
meta.Metadata["max_leader_turns"] = fmt.Sprintf("%d", runOpts.MaxLeaderTurns)
}
}
e.deps.Sink.Emit(Event{Type: EvRunStarted, RunID: runID, Text: goal.Text, At: time.Now()})
st := &runState{Meta: meta, Approved: map[string]bool{}, RepairBudgetRemaining: e.opts.RepairBudget}
return e.planAndExecute(ctx, st, runOpts)
}
// planAndExecute 规划 → 挂起或执行;st 可为已有运行状态(澄清续跑复用)。
func (e *SquadEngine) planAndExecute(ctx context.Context, st *runState, runOpts RunOptions) (*RunResult, error) {
drv := &flowDriver{opts: e.deps}
meta := &st.Meta
bpMatch, _ := e.opts.Blueprints.Match(meta.Goal.Text, runOpts.TaskKey)
planOut, err := e.opts.Planner.Plan(ctx, PlanInput{
Goal: meta.Goal, Blueprint: bpPtr(bpMatch), Registry: e.opts.Registry, RunID: meta.ID,
})
if err != nil {
return e.failNew(meta, err)
}
// 规划歧义:挂起澄清(auto/disabled 模式直接失败,不猜计划)。
if planOut.Clarification != nil {
if drv.policy(st).mode() != HitlInteractive {
return e.failNew(meta, fmt.Errorf("planner requested clarification but hitl mode is %s", st.policy().mode()))
}
var qs []string
for _, q := range planOut.Clarification.Questions {
qs = append(qs, "- "+q.Text)
}
opts := []HumanOption{}
if len(planOut.Clarification.Questions) == 1 && len(planOut.Clarification.Questions[0].Options) > 0 {
opts = planOut.Clarification.Questions[0].Options
}
req := &HumanRequest{
ID: newRequestID(), RunID: meta.ID, Kind: HitlClarification, Severity: SeverityActionRequired,
Question: "规划器无法确定如何执行该目标,请补充信息:",
Options: opts,
Context: fmt.Sprintf("原因:%s\n问题:\n%s", planOut.Clarification.Reason, strings.Join(qs, "\n")),
CreatedAt: time.Now(), Status: "pending",
}
gate := &hitlGate{Request: *req, PrevStatus: RunPlanning}
// 澄清挂起也需要工作区承载 pending.json。
if err := e.materializeWorkspace(meta, st, &SquadPlan{Workflow: WorkflowPlan{WorkflowID: NewPlanID(), Status: "pending", Steps: nil}}); err != nil {
return nil, err
}
if err := st.saveGate(gate); err != nil {
return nil, err
}
st.Meta.Status = string(RunAwaitingClarification)
_ = st.save()
e.deps.Sink.Emit(Event{Type: EvHumanInputRequired, RunID: meta.ID, Text: req.Question, Data: marshalJSON(req), At: time.Now()})
return &RunResult{RunID: meta.ID, Status: RunAwaitingClarification, Pending: req, WorkDir: st.Meta.WorkDir}, nil
}
// 计划确认:interactive 模式先挂起。leader 模式例外——规划步骤会被
// leader 委派循环取代,派生编制本身不构成执行承诺,无需确认。
if planOut.NeedsConfirmation && drv.policy(st).mode() == HitlInteractive && meta.Metadata["mode"] != "leader" {
if err := e.materializeWorkspace(meta, st, planOut.Plan); err != nil {
return nil, err
}
var lines []string
for _, s := range planOut.Plan.Workflow.Steps {
lines = append(lines, fmt.Sprintf("- %s [%s] %s(产出 %v)", s.ID, s.Role, s.Title, s.Targets))
}
var roles []string
for _, a := range planOut.Plan.Squad.Agents {
roles = append(roles, fmt.Sprintf("- %s(%s,backend=%s model=%s)", a.Role, a.Name, a.Backend, a.Model))
}
req := &HumanRequest{
ID: newRequestID(), RunID: meta.ID, Kind: HitlConfirmation, Severity: SeverityActionRequired,
Question: "计划已生成,需要人工确认后执行:",
Options: []HumanOption{
{Value: "approve", Label: "批准并执行"},
{Value: "abort", Label: "放弃执行"},
},
Context: fmt.Sprintf("确认原因:%s\n编制:\n%s\n步骤:\n%s",
planOut.ConfirmReason, strings.Join(roles, "\n"), strings.Join(lines, "\n")),
CreatedAt: time.Now(), Status: "pending",
}
gate := &hitlGate{Request: *req, Plan: planOut.Plan, PrevStatus: RunPlanning}
if err := st.saveGate(gate); err != nil {
return nil, err
}
st.Meta.Status = string(RunAwaitingConfirmation)
_ = st.save()
e.deps.Sink.Emit(Event{Type: EvHumanInputRequired, RunID: meta.ID, Text: req.Question, Data: marshalJSON(req), At: time.Now()})
return &RunResult{RunID: meta.ID, Status: RunAwaitingConfirmation, Pending: req, Plan: &planOut.Plan.Workflow, WorkDir: st.Meta.WorkDir}, nil
}
if err := e.materializeWorkspace(meta, st, planOut.Plan); err != nil {
return nil, err
}
// leader-delegation 模式:忽略规划步骤,进 leader 委派循环。
if meta.Metadata["mode"] == "leader" {
e.initLeaderState(st)
out, gate, err := e.runLeaderLoop(ctx, st)
if err != nil {
return nil, err
}
if gate == nil {
e.finalizeEvent(out)
}
return out, nil
}
out, _, err := drv.advance(ctx, st)
if err != nil {
return nil, err
}
e.finalizeEvent(out)
return out, nil
}
// initLeaderState 按运行选项初始化 leader 循环状态。
func (e *SquadEngine) initLeaderState(st *runState) {
leaderRole := st.Meta.Metadata["leader_role"]
if leaderRole == "" {
if _, ok := findAgent(st.Plan.Squad.Agents, "leader"); ok {
leaderRole = "leader"
}
}
maxTurns := DefaultMaxLeaderTurns
if v := st.Meta.Metadata["max_leader_turns"]; v != "" {
if n := parseIntDefault(v, DefaultMaxLeaderTurns); n > 0 {
maxTurns = n
}
}
st.Leader = &LeaderState{LeaderRole: leaderRole, MaxTurns: maxTurns}
st.saveLeader()
}
func parseIntDefault(s string, def int) int {
n := 0
for _, c := range s {
if c < '0' || c > '9' {
return def
}
n = n*10 + int(c-'0')
}
return n
}
// isLeaderRun 该运行是否处于 leader-delegation 模式。
func (e *SquadEngine) isLeaderRun(st *runState) bool {
return st.Meta.Metadata["mode"] == "leader"
}
// resumeAfterHold 挂起解除后的统一续跑入口(按模式分发)。
func (e *SquadEngine) resumeAfterHold(ctx context.Context, st *runState) (*RunResult, error) {
if e.isLeaderRun(st) {
out, gate, err := e.runLeaderLoop(ctx, st)
if err != nil {
return nil, err
}
if gate == nil {
e.finalizeEvent(out)
}
return out, nil
}
drv := &flowDriver{opts: e.deps}
out, _, err := drv.advance(ctx, st)
if err != nil {
return nil, err
}
e.finalizeEvent(out)
return out, nil
}
// materializeWorkspace 落盘工作区与子工作区(已存在则只更新计划并重建待执行子目录)。
func (e *SquadEngine) materializeWorkspace(meta *RunMetadata, st *runState, plan *SquadPlan) error {
if st.Meta.WorkDir == "" {
st.Meta = *meta
wsDir, err := e.ws.PrepareWorkspace(&st.Meta, plan)
if err != nil {
return err
}
st.Template = plan.Template
bb, _ := loadBlackboard(wsDir)
st.BB = *bb
} else {
// 复用既有工作区:磁盘黑板为权威(澄清续跑/计划更新场景)。
if bb, err := loadBlackboard(st.Meta.WorkDir); err == nil {
st.BB = *bb
}
if st.Template == "" {
st.Template = plan.Template
}
}
st.Plan = *plan
// 编制补全:步骤引用但编制缺失的角色从注册表解析(蓝图/派生计划常见)。
if err := ensureSquadCoversSteps(&st.Plan, e.opts.Registry); err != nil {
return err
}
if err := e.ws.BuildSubWorkspaces(st.Meta.WorkDir, &st.Plan, &st.BB); err != nil {
return err
}
return st.save()
}
// ensureSquadCoversSteps 确保编制覆盖全部步骤角色。
func ensureSquadCoversSteps(plan *SquadPlan, reg *RoleRegistry) error {
for _, step := range plan.Workflow.Steps {
if _, ok := findAgent(plan.Squad.Agents, step.Role); ok {
continue
}
spec, ok := reg.Get(step.Role)
if !ok {
return fmt.Errorf("step %s references role %s not registered", step.ID, step.Role)
}
plan.Squad.Agents = append(plan.Squad.Agents, spec)
}
return nil
}
func (e *SquadEngine) failNew(meta *RunMetadata, err error) (*RunResult, error) {
return &RunResult{RunID: meta.ID, Status: RunFailed, Error: err.Error()}, nil
}
// Respond 应用人工决策并续跑。按请求 ID 幂等:重复回应同一请求返回当前状态。
func (e *SquadEngine) Respond(ctx context.Context, runID string, decision RespondDecision) (*RunResult, error) {
lock := e.runLock(runID)
lock.Lock()
defer lock.Unlock()
wsDir := filepath.Join(e.opts.DataDir, "runs", runID)
st, err := loadRunState(wsDir)
if err != nil {
return nil, fmt.Errorf("load run %s: %w", runID, err)
}
gate, err := loadGate(wsDir)
if err != nil {
return nil, err
}
if gate == nil {
return nil, fmt.Errorf("run %s has no pending human request", runID)
}
if decision.RequestID != "" && decision.RequestID != gate.Request.ID {
// 幂等:回应已处理过的请求 → 返回当前状态。
if gate.Request.Status != "pending" {
return e.Inspect(runID)
}
return nil, fmt.Errorf("request id mismatch: expected %s", gate.Request.ID)
}
req := &gate.Request
req.Status = "answered"
now := time.Now()
req.AnsweredAt = &now
e.deps.Sink.Emit(Event{Type: EvHumanResponded, RunID: runID, Text: firstNonEmpty(decision.Choice, decision.Answer),
Data: marshalJSON(decision), At: time.Now()})
_ = st.saveGate(nil)
drv := &flowDriver{opts: e.deps}
switch req.Kind {
case HitlClarification:
return e.respondClarification(ctx, st, gate, decision)
case HitlConfirmation:
switch strings.ToLower(decision.Choice) {
case "approve":
if gate.Plan != nil {
if err := e.materializeWorkspace(&st.Meta, st, gate.Plan); err != nil {
return nil, err
}
}
if e.isLeaderRun(st) {
e.initLeaderState(st)
}
st.Meta.Status = string(RunExecuting)
_ = st.save()
return e.resumeAfterHold(ctx, st)
default: // abort / reject
return e.cancelRun(st, "人工拒绝计划"+appendAnswer(decision)), nil
}
case HitlGap:
switch strings.ToLower(decision.Choice) {
case "resolved", "":
answer := decision.Answer
for _, g := range st.BB.UnresolvedBlockingGaps() {
st.BB.ResolveGapByID(g.ID, answerFor(g, answer))
}
if answer != "" {
st.Clarifications = append(st.Clarifications, "缺口补充:"+answer)
}
case "skip":
for i := range st.BB.Gaps {
st.BB.Gaps[i].Blocking = false
}
case "abort":
return e.cancelRun(st, "人工中止(阻塞缺口)"), nil
default:
return nil, fmt.Errorf("unknown choice %q for gap request", decision.Choice)
}
st.Meta.Status = string(RunExecuting)
_ = st.save()
return e.resumeAfterHold(ctx, st)
case HitlEscalation:
switch strings.ToLower(decision.Choice) {
case "abort":
return e.cancelRun(st, "人工中止(leader 升级问人)"), nil
default: // 自由文本回答 = 人的决定
answer := strings.TrimSpace(decision.Answer)
if answer == "" {
for _, o := range gate.Request.Options {
if strings.EqualFold(o.Value, decision.Choice) {
answer = o.Label
}
}
}
if answer != "" {
st.Clarifications = append(st.Clarifications, "升级问答:"+answer)
st.BB.Facts = append(st.BB.Facts, FactItem{
ID: fmt.Sprintf("fact_%d", len(st.BB.Facts)+1), Claim: answer, Source: "human",
})
if st.Meta.WorkDir != "" {
_ = e.ws.UpdateGoalFile(st.Meta.WorkDir, &st.Meta.Goal)
}
}
st.Meta.Status = string(RunExecuting)
_ = st.save()
return e.resumeAfterHold(ctx, st)
}
case HitlAdjustment:
switch strings.ToLower(decision.Choice) {
case "approve":
applyAdjustment(st, gate.Steps, gate.SkipIDs)
case "reject", "":
// 忽略调整
case "abort":
return e.cancelRun(st, "人工中止(拒绝动态调整)"), nil
default:
return nil, fmt.Errorf("unknown choice %q for adjustment request", decision.Choice)
}
st.Meta.Status = string(RunExecuting)
_ = st.save()
return e.resumeAfterHold(ctx, st)
case HitlPermission:
switch strings.ToLower(decision.Choice) {
case "approve":
// 授权记到角色(gate 上下文带角色名)。
role := gate.Request.StepID
for _, s := range st.Plan.Workflow.Steps {
if s.ID == gate.Request.StepID {
role = s.Role
}
}
st.Approved["role:"+role] = true
st.Meta.Status = string(RunExecuting)
_ = st.save()
return e.resumeAfterHold(ctx, st)
case "reject":
// 步骤失败(关键性语义由 failStep 处理)。
for i := range st.Plan.Workflow.Steps {
if st.Plan.Workflow.Steps[i].ID == gate.Request.StepID {
st.Plan.Workflow.Steps[i].Status = "failed"
st.Plan.Workflow.Steps[i].Error = "permission denied by human"
st.Meta.Status = string(RunExecuting)
_ = st.save()
out, _, err := drv.failStepResume(ctx, st, i)
if err != nil {
return nil, err
}
e.finalizeEvent(out)
return out, nil
}
}
return e.cancelRun(st, "权限被拒绝且步骤不可定位"), nil
default:
return e.cancelRun(st, "人工中止(权限审批)"), nil
}
case HitlQualityReview:
switch strings.ToLower(decision.Choice) {
case "approve":
return e.manualPass(st, decision)
case "retry":
// 追加一次修复预算并重跑交付步骤。
st.RepairBudgetRemaining++
st.Meta.Status = string(RunExecuting)
_ = st.save()
deliverStepID := lastCompletedStepID(st)
out, _, err := drv.rerunForRepair(ctx, st, deliverStepID)
if err != nil {
return nil, err
}
e.finalizeEvent(out)
return out, nil
default:
st.Meta.Status = string(RunFailed)
st.Meta.CompletedAt = &now
wf := st.Workflow()
wf.Status = "failed"
st.setWorkflow(wf)
_ = st.save()
e.deps.Sink.Emit(Event{Type: EvRunFailed, RunID: runID, Text: "人工裁决质检失败", At: time.Now()})
return &RunResult{RunID: runID, Status: RunFailed, Plan: planPtr(st),
Error: "quality review rejected by human", WorkDir: st.Meta.WorkDir}, nil
}
default:
return nil, fmt.Errorf("unsupported request kind %q", req.Kind)
}
}
// respondClarification 澄清回应:答案并入目标后重新规划。
func (e *SquadEngine) respondClarification(ctx context.Context, st *runState, gate *hitlGate, decision RespondDecision) (*RunResult, error) {
choice := strings.ToLower(decision.Choice)
if choice == "abort" {
return e.cancelRun(st, "人工中止(澄清阶段)"), nil
}
answer := strings.TrimSpace(decision.Answer)
if answer == "" && len(gate.Request.Options) > 0 {
// 无自由文本时尝试取选项 Label。
for _, o := range gate.Request.Options {
if strings.EqualFold(o.Value, decision.Choice) {
answer = o.Label
}
}
}
if answer != "" {
st.Clarifications = append(st.Clarifications, answer)
st.Meta.Goal.Text = st.Meta.Goal.Text + "\n【人工澄清】" + answer
if st.Meta.WorkDir != "" {
_ = e.ws.UpdateGoalFile(st.Meta.WorkDir, &st.Meta.Goal)
}
}
if choice == "" || choice == "continue" || choice == "minimal" {
if choice == "minimal" {
// 人工选择最小编制:引导注册 4 个通用角色(backend 用运行默认)。
e.bootstrapMinimalSquad(st)
}
st.Meta.Status = string(RunPlanning)
_ = st.save()
runOpts := RunOptions{TaskKey: st.Meta.Metadata["task_key"]}
return e.planAndExecute(ctx, st, runOpts)
}
return e.cancelRun(st, "人工中止(澄清阶段)"), nil
}
// bootstrapMinimalSquad 注册通用最小编制(organizer/specialist/writer/leader)。
func (e *SquadEngine) bootstrapMinimalSquad(st *runState) {
backend := st.Meta.Backend
if backend == "" {
backend = e.opts.DefaultBackend
}
for _, spec := range []RoleSpec{
{Role: "organizer", Name: "梳理专员", Backend: backend, Keywords: []string{"整理", "梳理", "事实"},
Responsibilities: []string{"梳理目标背景与输入,回填事实与缺口"}},
{Role: "specialist", Name: "专项专员", Backend: backend, Keywords: []string{"分析", "推演", "方案"},
Responsibilities: []string{"围绕目标开展专业分析与推演"}},
{Role: "writer", Name: "撰写专员", Backend: backend, Keywords: []string{"撰写", "成文", "报告"},
Responsibilities: []string{"汇总产出并撰写交付物"}},
{Role: "leader", Name: "审核专员", Backend: backend, Keywords: []string{"审核", "终审", "把关"},
Responsibilities: []string{"一致性终审与交付把关"}},
} {
_ = e.opts.Registry.Register(spec)
}
}
// manualPass 人工放行质检:标记 ManualPass 并收口。
func (e *SquadEngine) manualPass(st *runState, decision RespondDecision) (*RunResult, error) {
report := e.deps.Checker.Check(CheckRequest{
RunID: st.Meta.ID, Workspace: st.Meta.WorkDir, Deliverable: "output.md",
Plan: planPtr(st), Squad: &st.Plan.Squad, Template: st.Template,
})
report.ManualPass = true
note := strings.TrimSpace(decision.Answer)
if note != "" {
report.Violations = append(report.Violations, "人工放行说明:"+note)
}
status := RunCompleted
if countStatus(st, "failed") > 0 || countStatus(st, "skipped") > 0 {
status = RunDegraded
}
now := time.Now()
st.Meta.Status = string(status)
st.Meta.CompletedAt = &now
wf := st.Workflow()
wf.Status = string(status)
st.setWorkflow(wf)
_ = st.save()
manifest := buildManifest(st, "output.md", *report)
_ = writeJSON(filepath.Join(st.Meta.WorkDir, "manifest.json"), manifest)
e.deps.Sink.Emit(Event{Type: EvRunCompleted, RunID: st.Meta.ID, Text: string(status), At: time.Now()})
return &RunResult{RunID: st.Meta.ID, Status: status, Plan: planPtr(st),
Deliverable: "output.md", Quality: report, WorkDir: st.Meta.WorkDir,
Summary: summarize(st, *report, status) + "(质检人工放行)"}, nil
}
func (e *SquadEngine) cancelRun(st *runState, reason string) *RunResult {
now := time.Now()
st.Meta.Status = string(RunCancelled)
st.Meta.CompletedAt = &now
wf := st.Workflow()
wf.Status = "cancelled"
st.setWorkflow(wf)
_ = st.save()
e.deps.Sink.Emit(Event{Type: EvRunCancelled, RunID: st.Meta.ID, Text: reason, At: time.Now()})
return &RunResult{RunID: st.Meta.ID, Status: RunCancelled, Plan: planPtr(st),
Summary: reason, WorkDir: st.Meta.WorkDir}
}
// failStepResume 权限拒绝后的失败步骤处理(关键失败/非关键降级)。
func (d *flowDriver) failStepResume(ctx context.Context, st *runState, idx int) (*RunResult, *hitlGate, error) {
return d.failStep(ctx, st, idx)
}
// Resume 恢复运行:有挂起则返回挂起状态;未完成则继续推进(重启恢复入口)。
func (e *SquadEngine) Resume(ctx context.Context, runID string) (*RunResult, error) {
lock := e.runLock(runID)
lock.Lock()
defer lock.Unlock()
wsDir := filepath.Join(e.opts.DataDir, "runs", runID)
st, err := loadRunState(wsDir)
if err != nil {
return nil, fmt.Errorf("load run %s: %w", runID, err)
}
if gate, _ := loadGate(wsDir); gate != nil && gate.Request.Status == "pending" {
return &RunResult{RunID: runID, Status: RunStatus(st.Meta.Status), Plan: planPtr(st),
Pending: &gate.Request, WorkDir: st.Meta.WorkDir}, nil
}
if RunStatus(st.Meta.Status).Terminal() {
return e.Inspect(runID)
}
return e.resumeAfterHold(ctx, st)
}
// Inspect 查看运行当前状态(含挂起请求与黑板)。
func (e *SquadEngine) Inspect(runID string) (*RunResult, error) {
wsDir := filepath.Join(e.opts.DataDir, "runs", runID)
st, err := loadRunState(wsDir)
if err != nil {
return nil, fmt.Errorf("load run %s: %w", runID, err)
}
out := &RunResult{
RunID: runID, Status: RunStatus(st.Meta.Status), Plan: planPtr(st),
Squad: &st.Plan.Squad, WorkDir: st.Meta.WorkDir, Summary: st.Meta.Status,
}
if gate, _ := loadGate(wsDir); gate != nil {
out.Pending = &gate.Request
}
if data, err := os.ReadFile(filepath.Join(wsDir, "output.md")); err == nil && len(data) > 0 {
out.Deliverable = "output.md"
}
if data, err := os.ReadFile(filepath.Join(wsDir, "manifest.json")); err == nil {
var m Manifest
if json.Unmarshal(data, &m) == nil {
out.Quality = &m.QualityReport
}
}
return out, nil
}
// Blackboard 查看运行黑板。
func (e *SquadEngine) Blackboard(runID string) (*BlackboardState, error) {
wsDir := filepath.Join(e.opts.DataDir, "runs", runID)
return loadBlackboard(wsDir)
}
// ListRuns 列出全部运行(按创建时间倒序)。
func (e *SquadEngine) ListRuns() ([]RunMetadata, error) {
runsDir := filepath.Join(e.opts.DataDir, "runs")
entries, err := os.ReadDir(runsDir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
var out []RunMetadata
for _, ent := range entries {
if !ent.IsDir() {
continue
}
st, err := loadRunState(filepath.Join(runsDir, ent.Name()))
if err != nil {
continue
}
out = append(out, st.Meta)
}
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) })
return out, nil
}
// Pending 返回运行当前挂起请求(无则 nil)。
func (e *SquadEngine) Pending(runID string) (*HumanRequest, error) {
gate, err := loadGate(filepath.Join(e.opts.DataDir, "runs", runID))
if err != nil || gate == nil {
return nil, err
}
return &gate.Request, nil
}
// Steer 向运行中的任务追加人工补充指令:在下一次步骤边界注入当前运行的
// prompt(步骤间注入,不中断正在执行的步骤),落盘 steering.json。
func (e *SquadEngine) Steer(runID, text string) error {
if strings.TrimSpace(text) == "" {
return fmt.Errorf("steering text is empty")
}
lock := e.runLock(runID)
lock.Lock()
defer lock.Unlock()
st, err := loadRunState(filepath.Join(e.opts.DataDir, "runs", runID))
if err != nil {
return fmt.Errorf("load run %s: %w", runID, err)
}
if RunStatus(st.Meta.Status).Terminal() {
return fmt.Errorf("run %s already terminal (%s)", runID, st.Meta.Status)
}
st.Steering = append(st.Steering, strings.TrimSpace(text))
_ = writeJSON(filepath.Join(st.Meta.WorkDir, "steering.json"), st.Steering)
e.deps.Sink.Emit(Event{Type: "steered", RunID: runID, Text: truncate(text, 80), At: time.Now()})
return nil
}
// SetBlackboardField 写入黑板字段(外部事实注入;可满足 field_set 等待条件)。
func (e *SquadEngine) SetBlackboardField(runID, key, value string) error {
if strings.TrimSpace(key) == "" {
return fmt.Errorf("field key is empty")
}
lock := e.runLock(runID)
lock.Lock()
defer lock.Unlock()
st, err := loadRunState(filepath.Join(e.opts.DataDir, "runs", runID))
if err != nil {
return fmt.Errorf("load run %s: %w", runID, err)
}
st.BB.Fields[key] = value
if err := saveBlackboard(st.Meta.WorkDir, &st.BB); err != nil {
return err
}
e.deps.Sink.Emit(Event{Type: "blackboard_field_set", RunID: runID,
Text: key + "=" + truncate(value, 60), At: time.Now()})
return nil
}
// SweepWakeups 扫描所有挂起运行:等待条件(run_done/field_set)满足时
// 合成自动决策续跑。护栏:同一请求自动唤醒次数达 MaxWakeupFires 即自动中止。
// 由 serve 模式周期调用,也可在任何运行终态后手动调用。返回唤醒数。
func (e *SquadEngine) SweepWakeups(ctx context.Context) int {
runs, err := e.ListRuns()
if err != nil {
return 0
}
woken := 0
for _, meta := range runs {
if !RunStatus(meta.Status).IsHeld() {
continue
}
gate, err := loadGate(filepath.Join(e.opts.DataDir, "runs", meta.ID))
if err != nil || gate == nil || gate.Request.Status != "pending" || gate.Request.WaitCondition == nil {
continue
}
cond := gate.Request.WaitCondition
met, fact := e.evaluateWaitCondition(meta.ID, cond)
if !met {
continue
}
gate.Request.WakeupFires++
if gate.Request.WakeupFires >= MaxWakeupFires {
// 护栏耗尽:自动中止,不无限自动续跑。
_, _ = e.Respond(ctx, meta.ID, RespondDecision{RequestID: gate.Request.ID, Choice: "abort",
Answer: "自动唤醒次数达上限,自动中止"})
woken++
continue
}
_ = writeJSON(filepath.Join(e.opts.DataDir, "runs", meta.ID, "pending.json"), gate)
decision := RespondDecision{RequestID: gate.Request.ID, Answer: "[自动唤醒] " + fact}
switch gate.Request.Kind {
case HitlConfirmation, HitlPermission, HitlQualityReview:
decision.Choice = "approve" // 事实型条件满足视为放行依据
default:
decision.Choice = "resolved"
}
if _, err := e.Respond(ctx, meta.ID, decision); err == nil {
woken++
e.deps.Sink.Emit(Event{Type: "wakeup_fired", RunID: meta.ID, Text: fact, At: time.Now()})
}
}
return woken
}
// evaluateWaitCondition 评估等待条件;返回是否满足与事实描述。
func (e *SquadEngine) evaluateWaitCondition(runID string, cond *WaitCondition) (bool, string) {
switch cond.Type {
case "run_done":
if cond.RunID == "" || cond.RunID == runID {
return false, "" // 自引用不触发
}
target, err := e.Inspect(cond.RunID)
if err != nil {
return false, ""
}
if target.Status.Terminal() {
return true, fmt.Sprintf("等待的运行 %s 已进入终态 %s(唤醒报告事实,非业务完成)", cond.RunID, target.Status)
}
case "field_set":
if cond.Field == "" {
return false, ""
}
bb, err := e.Blackboard(runID)
if err != nil {
return false, ""
}
if v, ok := bb.Fields[cond.Field]; ok && strings.TrimSpace(v) != "" {
return true, fmt.Sprintf("黑板字段 %s 已设置:%s", cond.Field, truncate(v, 80))
}
}
return false, ""
}
func (e *SquadEngine) finalizeEvent(out *RunResult) {
if out == nil {
return
}
switch out.Status {
case RunCompleted, RunDegraded:
e.deps.Sink.Emit(Event{Type: EvRunCompleted, RunID: out.RunID, Text: out.Summary, At: time.Now()})
case RunFailed:
e.deps.Sink.Emit(Event{Type: EvRunFailed, RunID: out.RunID, Text: out.Error, At: time.Now()})
}
}
// ---- 小工具 ----
func sha256File(path string) string {
data, err := os.ReadFile(path)
if err != nil {
return ""
}
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])
}
func marshalJSON(v any) json.RawMessage {
data, err := json.Marshal(v)
if err != nil {
return json.RawMessage(`{}`)
}
return data
}
func bpPtr(bp Blueprint) *Blueprint {
if bp.Key == "" {
return nil
}
return &bp
}
func planPtr2(p *SquadPlan) *WorkflowPlan {
wf := p.Workflow
return &wf
}
func answerFor(g GapItem, answer string) string {
if answer == "" {
return "人工确认按现状继续"
}
return answer
}
func appendAnswer(d RespondDecision) string {
if strings.TrimSpace(d.Answer) == "" {
return ""
}
return "(" + d.Answer + ")"
}
func lastCompletedStepID(st *runState) string {
id := ""
for _, s := range st.Plan.Workflow.Steps {
if s.Status == "completed" {
id = s.ID
}
}
return id
}
+458
View File
@@ -0,0 +1,458 @@
package squad
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
"agent-native/exec"
)
// scriptBackend 测试用脚本后端:按子目录名回放产出。
type scriptBackend struct{ script map[string]map[string]string }
func (b *scriptBackend) Name() string { return "script" }
func (b *scriptBackend) Execute(ctx context.Context, req exec.Request) (*exec.Result, error) {
key := filepath.Base(filepath.Clean(req.WorkDir))
files := b.script[key]
patches := map[string][]byte{}
for _, t := range req.Targets {
content := "\n"
if c, ok := files[t]; ok {
content = c
}
out := filepath.Join(req.WorkDir, t)
_ = os.MkdirAll(filepath.Dir(out), 0o755)
if err := os.WriteFile(out, []byte(content), 0o644); err != nil {
return nil, err
}
patches[t] = []byte(content)
}
return &exec.Result{Success: true, Patches: patches}, nil
}
const goodReport = "# 测试交付物\n\n## 概述\n\n这是由脚本后端生成的合格交付物,长度超过五十个字符以满足最小质检要求。\n\n## 内容\n\n正文内容。\n"
func newTestEngine(t *testing.T, planner Planner, script map[string]map[string]string, sink ProgressSink) (*SquadEngine, string) {
t.Helper()
dir := t.TempDir()
reg := NewRoleRegistry()
for _, spec := range []RoleSpec{
{Role: "researcher", Name: "调研", Backend: "script"},
{Role: "writer", Name: "撰写", Backend: "script"},
{Role: "reviewer", Name: "审核", Backend: "script"},
} {
if err := reg.Register(spec); err != nil {
t.Fatal(err)
}
}
backends := NewBackendResolver()
backends.RegisterInstance("script", &scriptBackend{script: script})
if sink == nil {
sink = NopSink{}
}
engine, err := NewEngine(Options{
DataDir: dir, Registry: reg, Planner: planner, Backends: backends, Sink: sink,
})
if err != nil {
t.Fatal(err)
}
return engine, dir
}
func mockScriptPlanner(steps ...StepSpec) *MockPlanner { return &MockPlanner{Script: steps} }
func baseSteps() []StepSpec {
return []StepSpec{
{ID: "s1", Role: "researcher", Title: "调研", Targets: []string{"notes.md", "fields.json"}},
{ID: "s2", Role: "writer", Title: "成文", Targets: []string{"output.md"}},
{ID: "s3", Role: "reviewer", Title: "终审", Targets: []string{"output.md"}},
}
}
func gapSteps() []StepSpec {
steps := baseSteps()
steps[0].Targets = []string{"notes.md", "fields.json", "gaps.json"}
return steps
}
func TestEngineHappyPath(t *testing.T) {
script := map[string]map[string]string{
"01_researcher": {"notes.md": "# 笔记\n内容", "fields.json": `{"k":"v"}`},
"02_writer": {"output.md": goodReport},
"03_reviewer": {"output.md": goodReport},
}
engine, dir := newTestEngine(t, mockScriptPlanner(baseSteps()...), script, nil)
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 res.Deliverable != "output.md" {
t.Fatalf("want output.md, got %s", res.Deliverable)
}
// 工作区布局。
for _, f := range []string{"workspace.json", "squad.json", "workflow.json", "blackboard.json",
"manifest.json", "output.md", "context/goal.md", "context/index.json", "tasks/01_researcher/TASK.md", "tasks/01_researcher/AGENTS.md"} {
if _, err := os.Stat(filepath.Join(res.WorkDir, f)); err != nil {
t.Errorf("workspace missing %s: %v", f, err)
}
}
// 黑板字段合并。
bb, _ := engine.Blackboard(res.RunID)
if bb.Fields["k"] != "v" {
t.Errorf("blackboard field k=%q, want v", bb.Fields["k"])
}
// 清单包含质检报告。
data, _ := os.ReadFile(filepath.Join(res.WorkDir, "manifest.json"))
var m Manifest
if err := json.Unmarshal(data, &m); err != nil {
t.Fatal(err)
}
if !m.QualityReport.Passed || len(m.Files) == 0 {
t.Errorf("manifest quality/files invalid: %+v files=%d", m.QualityReport, len(m.Files))
}
_ = dir
}
func TestEngineGapHitlSuspendRespondAndRestartRecovery(t *testing.T) {
script := map[string]map[string]string{
"01_researcher": {
"notes.md": "# 笔记\n内容",
"fields.json": `{"k":"v"}`,
"gaps.json": `[{"item":"缺少产量","blocking":true}]`,
},
"02_writer": {"output.md": goodReport},
"03_reviewer": {"output.md": goodReport},
}
engine, dir := newTestEngine(t, mockScriptPlanner(gapSteps()...), script, nil)
res, err := engine.Run(context.Background(), Goal{Text: "生成测试报告"}, RunOptions{})
if err != nil {
t.Fatal(err)
}
if res.Status != RunWaitingHuman || res.Pending == nil || res.Pending.Kind != HitlGap {
t.Fatalf("want waiting_human/gap, got %s pending=%v", res.Status, res.Pending)
}
if res.Plan.Steps[0].Status != "completed" || res.Plan.Steps[1].Status != "pending" {
t.Fatalf("step states wrong: %+v", res.Plan.Steps)
}
// 进程重启恢复:用同一 DataDir 新建引擎实例续跑。
backends := NewBackendResolver()
backends.RegisterInstance("script", &scriptBackend{script: script})
engine2, err := NewEngine(Options{DataDir: dir, Registry: NewRoleRegistry(), Backends: backends, Sink: NopSink{}})
if err != nil {
t.Fatal(err)
}
pend, err := engine2.Pending(res.RunID)
if err != nil || pend == nil {
t.Fatalf("pending not recovered: %v %v", pend, err)
}
res2, err := engine2.Respond(context.Background(), res.RunID, RespondDecision{
RequestID: res.Pending.ID, Choice: "resolved", Answer: "年产量 10 万套",
})
if err != nil {
t.Fatal(err)
}
if res2.Status != RunCompleted {
t.Fatalf("want completed after respond, got %s (err=%s)", res2.Status, res2.Error)
}
bb, _ := engine2.Blackboard(res.RunID)
found := false
for _, g := range bb.Gaps {
if g.Item == "缺少产量" && g.Resolved {
found = true
}
}
if !found {
t.Errorf("blocking gap not resolved: %+v", bb.Gaps)
}
if len(bb.Facts) == 0 || !strings.Contains(bb.Facts[0].Claim, "10 万套") {
t.Errorf("human answer not recorded as fact: %+v", bb.Facts)
}
}
func TestEngineClarificationFlow(t *testing.T) {
script := map[string]map[string]string{
"01_researcher": {"notes.md": "n"},
"02_writer": {"output.md": goodReport},
"03_reviewer": {"output.md": goodReport},
}
// 先澄清后规划成功:用可变 mock。
mp := &MockPlanner{ClarifyReason: "目标缺少范围"}
engine, _ := newTestEngine(t, mp, script, nil)
res, err := engine.Run(context.Background(), Goal{Text: "做一份计划"}, RunOptions{})
if err != nil {
t.Fatal(err)
}
if res.Status != RunAwaitingClarification || res.Pending.Kind != HitlClarification {
t.Fatalf("want awaiting_clarification, got %s", res.Status)
}
// 人工澄清后 mock 切换为脚本计划。
mp.ClarifyReason = ""
mp.Script = baseSteps()
res2, err := engine.Respond(context.Background(), res.RunID, RespondDecision{RequestID: res.Pending.ID, Answer: "范围限定为华东市场"})
if err != nil {
t.Fatal(err)
}
if res2.Status != RunCompleted {
t.Fatalf("want completed after clarification, got %s err=%s", res2.Status, res2.Error)
}
// 澄清答案并入目标(工作区 goal.md 可追溯)。
data, _ := os.ReadFile(filepath.Join(res2.WorkDir, "context", "goal.md"))
if !strings.Contains(string(data), "华东市场") {
t.Errorf("clarification answer not recorded in goal.md:\n%s", data)
}
}
func TestEnginePermissionAsk(t *testing.T) {
script := map[string]map[string]string{
"01_researcher": {"notes.md": "n"},
"02_writer": {"output.md": goodReport},
"03_reviewer": {"output.md": goodReport},
}
dir := t.TempDir()
reg := NewRoleRegistry()
if err := reg.Register(RoleSpec{Role: "researcher", Name: "调研", Backend: "script", BashPermission: "ask"}); err != nil {
t.Fatal(err)
}
for _, r := range []string{"writer", "reviewer"} {
_ = reg.Register(RoleSpec{Role: r, Backend: "script"})
}
backends := NewBackendResolver()
backends.RegisterInstance("script", &scriptBackend{script: script})
engine, _ := NewEngine(Options{DataDir: dir, Registry: reg,
Planner: mockScriptPlanner(baseSteps()...), Backends: backends, Sink: NopSink{}})
res, err := engine.Run(context.Background(), Goal{Text: "g"}, RunOptions{})
if err != nil {
t.Fatal(err)
}
if res.Status != RunWaitingHuman || res.Pending.Kind != HitlPermission {
t.Fatalf("want waiting_human/permission, got %s", res.Status)
}
res2, err := engine.Respond(context.Background(), res.RunID, RespondDecision{RequestID: res.Pending.ID, Choice: "approve"})
if err != nil {
t.Fatal(err)
}
if res2.Status != RunCompleted {
t.Fatalf("want completed after approve, got %s err=%s", res2.Status, res2.Error)
}
// 拒绝路径:关键步骤失败。
engine2, _ := NewEngine(Options{DataDir: t.TempDir(), Registry: reg,
Planner: mockScriptPlanner(baseSteps()...), Backends: backends, Sink: NopSink{}})
res3, _ := engine2.Run(context.Background(), Goal{Text: "g"}, RunOptions{})
res4, err := engine2.Respond(context.Background(), res3.RunID, RespondDecision{RequestID: res3.Pending.ID, Choice: "reject"})
if err != nil {
t.Fatal(err)
}
if res4.Status != RunFailed {
t.Fatalf("want failed after reject, got %s", res4.Status)
}
}
type failChecker struct{}
func (failChecker) Check(req CheckRequest) *QualityReport {
return &QualityReport{Passed: false, Score: 10, Violations: []string{"内容不达标"}, CheckedAt: time.Now()}
}
func TestEngineQualityReviewManualPass(t *testing.T) {
script := map[string]map[string]string{
"01_researcher": {"notes.md": "n"},
"02_writer": {"output.md": goodReport},
"03_reviewer": {"output.md": goodReport},
}
dir := t.TempDir()
reg := NewRoleRegistry()
for _, r := range []string{"researcher", "writer", "reviewer"} {
_ = reg.Register(RoleSpec{Role: r, Backend: "script"})
}
backends := NewBackendResolver()
backends.RegisterInstance("script", &scriptBackend{script: script})
engine, _ := NewEngine(Options{DataDir: dir, Registry: reg,
Planner: mockScriptPlanner(baseSteps()...), Backends: backends,
Checker: failChecker{}, Sink: NopSink{}})
res, _ := engine.Run(context.Background(), Goal{Text: "g"}, RunOptions{})
if res.Status != RunQualityReview || res.Pending.Kind != HitlQualityReview {
t.Fatalf("want quality_review, got %s", res.Status)
}
res2, err := engine.Respond(context.Background(), res.RunID, RespondDecision{RequestID: res.Pending.ID, Choice: "approve", Answer: "内容已人工核对"})
if err != nil {
t.Fatal(err)
}
if res2.Status != RunCompleted || res2.Quality == nil || !res2.Quality.ManualPass {
t.Fatalf("want completed+manual_pass, got %s %+v", res2.Status, res2.Quality)
}
}
func TestEngineAutoModeGapFallbackAssumption(t *testing.T) {
script := map[string]map[string]string{
"01_researcher": {
"notes.md": "# 笔记\n内容",
"fields.json": `{"k":"v"}`,
"gaps.json": `[{"item":"缺少产量","blocking":true}]`,
},
"02_writer": {"output.md": goodReport},
"03_reviewer": {"output.md": goodReport},
}
engine, _ := newTestEngine(t, mockScriptPlanner(gapSteps()...), script, nil)
res, err := engine.Run(context.Background(), Goal{Text: "g", HitlMode: HitlAuto}, RunOptions{})
if err != nil {
t.Fatal(err)
}
if res.Status != RunCompleted {
t.Fatalf("auto mode should complete with fallback assumption, got %s err=%s", res.Status, res.Error)
}
bb, _ := engine.Blackboard(res.RunID)
if len(bb.Assumptions) == 0 || bb.Assumptions[0].Source != "fallback" {
t.Fatalf("fallback assumption missing: %+v", bb.Assumptions)
}
}
func TestEngineRulePlannerAutonomousAndEmptyRegistryClarify(t *testing.T) {
// 空注册表 → 澄清。
dir := t.TempDir()
backends := NewBackendResolver()
engine, _ := NewEngine(Options{DataDir: dir, Registry: NewRoleRegistry(),
Planner: NewRulePlanner(), Backends: backends, Sink: NopSink{}})
res, err := engine.Run(context.Background(), Goal{Text: "做点事"}, RunOptions{})
if err != nil {
t.Fatal(err)
}
if res.Status != RunAwaitingClarification {
t.Fatalf("empty registry should clarify, got %s", res.Status)
}
// 选"最小编制":引导注册 4 个通用角色 → 角色齐备无派生 → 直接完成。
script := map[string]map[string]string{
"01_organizer": {"notes.md": "n", "fields.json": `{}`},
"02_specialist": {"notes.md": "n"},
"03_writer": {"output.md": goodReport},
"04_leader": {"output.md": goodReport},
}
backends.RegisterInstance("stub", &scriptBackend{script: script})
res2, err := engine.Respond(context.Background(), res.RunID, RespondDecision{RequestID: res.Pending.ID, Choice: "minimal"})
if err != nil {
t.Fatal(err)
}
if roles := engine.Registry().List(); len(roles) != 4 {
t.Fatalf("minimal squad want 4 roles, got %d: %+v", len(roles), roles)
}
if res2.Status != RunCompleted {
t.Fatalf("minimal bootstrap should complete directly, got %s err=%s", res2.Status, res2.Error)
}
}
func TestEngineAutonomousWithRolesNoConfirmWhenDirect(t *testing.T) {
// 角色齐备(与四阶段键一致)时自主规划直接执行(无派生→无需确认)。
script := map[string]map[string]string{
"01_organizer": {"notes.md": "n", "fields.json": `{}`},
"02_specialist": {"notes.md": "n"},
"03_writer": {"output.md": goodReport},
"04_leader": {"output.md": goodReport},
}
dir := t.TempDir()
reg := NewRoleRegistry()
for _, r := range []string{"organizer", "specialist", "writer", "leader"} {
_ = reg.Register(RoleSpec{Role: r, Backend: "stub"})
}
backends := NewBackendResolver()
backends.RegisterInstance("stub", &scriptBackend{script: script})
engine, _ := NewEngine(Options{DataDir: dir, Registry: reg, Planner: NewRulePlanner(), Backends: backends, Sink: NopSink{}})
res, err := engine.Run(context.Background(), Goal{Text: "整理并撰写报告"}, RunOptions{})
if err != nil {
t.Fatal(err)
}
if res.Status != RunCompleted {
t.Fatalf("want completed without confirmation, got %s err=%s", res.Status, res.Error)
}
}
func TestEngineBlueprintRegistrationFlow(t *testing.T) {
script := map[string]map[string]string{
"01_researcher": {"notes.md": "n"},
"02_writer": {"output.md": goodReport},
}
engine, _ := newTestEngine(t, NewRulePlanner(), script, nil)
bp := Blueprint{
Key: "report", Title: "标准报告", Match: []string{"报告"},
Steps: []StepSpec{
{ID: "b1", Role: "researcher", Title: "调研", Targets: []string{"notes.md"}},
{ID: "b2", Role: "writer", Title: "成文", Targets: []string{"output.md"}},
},
}
if err := engine.Blueprints().Register(bp); err != nil {
t.Fatal(err)
}
res, err := engine.Run(context.Background(), Goal{Text: "帮我出一份竞品报告"}, RunOptions{})
if err != nil {
t.Fatal(err)
}
// writer 是最后完成步骤,无 reviewer;应 completed。
if res.Status != RunCompleted {
t.Fatalf("blueprint run want completed, got %s err=%s", res.Status, res.Error)
}
if res.Plan.TaskKey != "report" || res.Plan.PlanningMode != PlanModeBlueprint {
t.Fatalf("plan not from blueprint: key=%s mode=%s", res.Plan.TaskKey, res.Plan.PlanningMode)
}
}
func TestEngineParallelStepsAndEvents(t *testing.T) {
script := map[string]map[string]string{
"01_researcher": {"notes.md": "n"},
"02_writer": {"output.md": goodReport},
"03_reviewer": {"output.md": goodReport},
}
steps := []StepSpec{
{ID: "s1", Role: "researcher", Title: "调研", Targets: []string{"notes.md"}},
{ID: "s2", Role: "writer", Title: "成文A", Parallel: true, Targets: []string{"draft_a.md"}},
{ID: "s3", Role: "reviewer", Title: "成文B", Parallel: true, Targets: []string{"output.md"}},
}
script["02_writer"] = map[string]string{"draft_a.md": goodReport}
sink := NewFanoutSink(1024)
engine, _ := newTestEngine(t, mockScriptPlanner(steps...), script, sink)
res, err := engine.Run(context.Background(), Goal{Text: "g"}, RunOptions{})
if err != nil {
t.Fatal(err)
}
if res.Status != RunCompleted {
t.Fatalf("want completed, got %s err=%s", res.Status, res.Error)
}
events, _ := sink.Snapshot()
seen := map[string]int{}
for _, ev := range events {
seen[ev.Type]++
}
for _, want := range []string{EvRunStarted, EvStepStarted, EvStepCompleted, EvDeliverable, EvRunCompleted} {
if seen[want] == 0 {
t.Errorf("missing event %s (seen=%v)", want, seen)
}
}
}
func TestRespondIdempotencyAndMismatch(t *testing.T) {
script := map[string]map[string]string{
"01_researcher": {"notes.md": "n", "gaps.json": `[{"item":"x","blocking":true}]`},
"02_writer": {"output.md": goodReport},
"03_reviewer": {"output.md": goodReport},
}
engine, _ := newTestEngine(t, mockScriptPlanner(gapSteps()...), script, nil)
res, _ := engine.Run(context.Background(), Goal{Text: "g"}, RunOptions{})
// 错误请求 ID → 拒绝。
if _, err := engine.Respond(context.Background(), res.RunID, RespondDecision{RequestID: "hr_bogus", Choice: "resolved"}); err == nil {
t.Fatal("bogus request id should fail")
}
// 无挂起运行 → 报错。
if _, err := engine.Respond(context.Background(), "run_nope", RespondDecision{}); err == nil {
t.Fatal("unknown run should fail")
}
}
+51
View File
@@ -0,0 +1,51 @@
package squad
import "context"
// StepEvaluator 单步评估器:顺序步骤完成后评估产出,产出动态调整建议。
// 默认实现为 NoOpEvaluator(不做任何调整);产品可注入规则/模型评估器。
type StepEvaluator interface {
// Evaluate 返回对该步骤产出的评估与调整建议(加步/跳步/新假设)。
// DynamicStepsToAdd 中的新步骤默认插入最后一个"成文/终审"步骤之前。
Evaluate(ctx context.Context, req EvaluateRequest) (*EvaluationResult, error)
}
// EvaluateRequest 评估输入。
type EvaluateRequest struct {
RunID string
Step StepSpec
Workspace string // 运行工作区根目录
Output string // 步骤主产出文本
Blackboard *BlackboardState
}
// EvaluationResult 评估结论与动态调整建议(与产品 squad 语义对齐)。
type EvaluationResult struct {
StepID string `json:"step_id"`
Action StepAction `json:"action"`
Reason string `json:"reason"`
NewAssumptions []AssumptionItem `json:"new_assumptions,omitempty"`
ResolvedGaps []string `json:"resolved_gaps,omitempty"`
DynamicStepsToAdd []StepSpec `json:"dynamic_steps_to_add,omitempty"`
StepsToSkip []string `json:"steps_to_skip,omitempty"`
// NeedsApproval 为 true 时,本批调整先经 HITL 审批再应用。
NeedsApproval bool `json:"needs_approval,omitempty"`
ApprovalNote string `json:"approval_note,omitempty"`
}
// StepAction 动态调整动作。
type StepAction string
const (
ActionContinue StepAction = "CONTINUE"
ActionAddStep StepAction = "ADD_STEP"
ActionUpdateStep StepAction = "UPDATE_STEP"
ActionSkipStep StepAction = "SKIP_STEP"
)
// NoOpEvaluator 默认空评估器。
type NoOpEvaluator struct{}
func (NoOpEvaluator) Evaluate(ctx context.Context, req EvaluateRequest) (*EvaluationResult, error) {
return &EvaluationResult{StepID: req.Step.ID, Action: ActionContinue}, nil
}
+214
View File
@@ -0,0 +1,214 @@
package squad
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"agent-native/exec"
)
// scriptedEvaluator 第一步后建议:追加一个补漏步骤 + 跳过冗余步骤。
type scriptedEvaluator struct{ fired bool }
func (e *scriptedEvaluator) Evaluate(ctx context.Context, req EvaluateRequest) (*EvaluationResult, error) {
if req.Step.ID != "s1" || e.fired {
return &EvaluationResult{StepID: req.Step.ID, Action: ActionContinue}, nil
}
e.fired = true
return &EvaluationResult{
StepID: req.Step.ID,
Action: ActionAddStep,
Reason: "首步产出暴露竞品价格缺口,需补一步价格核对;s3 与 s1 内容重复可跳过",
NewAssumptions: []AssumptionItem{
{ID: "asm_1", Topic: "price", Statement: "价格取公开电商口径", Source: "inferred"},
},
DynamicStepsToAdd: []StepSpec{
{ID: "s_price", Role: "writer", Title: "竞品价格核对补充", Targets: []string{"price.md"}},
},
StepsToSkip: []string{"s3"},
NeedsApproval: true,
}, nil
}
func dynamicSteps() []StepSpec {
return []StepSpec{
{ID: "s1", Role: "researcher", Title: "调研", AllowDynamicAdjustment: true, Targets: []string{"notes.md"}},
{ID: "s2", Role: "writer", Title: "成文", Targets: []string{"output.md"}},
{ID: "s3", Role: "reviewer", Title: "冗余复核", Targets: []string{"review.md"}},
}
}
const priceNote = "价格核对补充:A 品牌售价 99 元,B 品牌 149 元,C 品牌 199 元,均取公开电商旗舰店口径,含税包邮。"
func newDynamicEngine(t *testing.T, script map[string]map[string]string, ev StepEvaluator) *SquadEngine {
t.Helper()
dir := t.TempDir()
reg := NewRoleRegistry()
for _, r := range []string{"researcher", "writer", "reviewer"} {
_ = reg.Register(RoleSpec{Role: r, Backend: "script"})
}
backends := NewBackendResolver()
backends.RegisterInstance("script", &scriptBackend{script: script})
engine, err := NewEngine(Options{DataDir: dir, Registry: reg,
Planner: mockScriptPlanner(dynamicSteps()...), Backends: backends,
Evaluator: ev, Sink: NopSink{}})
if err != nil {
t.Fatal(err)
}
return engine
}
// TestDynamicAdjustmentWithApproval 验证:步骤产出 → 评估器建议 →
// HITL 审批挂起 → 批准后新步骤插入执行、冗余步骤跳过、假设入黑板。
func TestDynamicAdjustmentWithApproval(t *testing.T) {
script := map[string]map[string]string{
"01_researcher": {"notes.md": "# 笔记\n内容"},
"02_writer": {"output.md": goodReport},
// 动态加步发生在子工作区构建之后,runOneStep 按索引兜底目录名。
"03_writer": {"price.md": priceNote},
"03_reviewer": {"review.md": goodReport},
}
engine := newDynamicEngine(t, script, &scriptedEvaluator{})
res, err := engine.Run(context.Background(), Goal{Text: "调研并成文"}, RunOptions{})
if err != nil {
t.Fatal(err)
}
if res.Status != RunWaitingHuman || res.Pending == nil || res.Pending.Kind != HitlAdjustment {
t.Fatalf("want adjustment hold, got %s pending=%v", res.Status, res.Pending)
}
if !strings.Contains(res.Pending.Context, "竞品价格核对补充") || !strings.Contains(res.Pending.Context, "s3") {
t.Fatalf("adjustment context missing suggestions: %s", res.Pending.Context)
}
res2, err := engine.Respond(context.Background(), res.RunID, RespondDecision{RequestID: res.Pending.ID, Choice: "approve"})
if err != nil {
t.Fatal(err)
}
if res2.Status != RunCompleted && res2.Status != RunDegraded {
t.Fatalf("want completed/degraded after approve, got %s err=%s", res2.Status, res2.Error)
}
var added, skipped bool
for _, s := range res2.Plan.Steps {
if s.ID == "s_price" && s.Status == "completed" {
added = true
}
if s.ID == "s3" && s.Status == "skipped" {
skipped = true
}
}
if !added || !skipped {
t.Fatalf("adjustment not applied: added=%v skipped=%v steps=%+v", added, skipped, res2.Plan.Steps)
}
bb, _ := engine.Blackboard(res.RunID)
if len(bb.Assumptions) == 0 || bb.Assumptions[0].Topic != "price" {
t.Fatalf("assumption not recorded: %+v", bb.Assumptions)
}
}
// TestDynamicAdjustmentRejected 验证:拒绝调整 → 忽略建议按原计划继续。
func TestDynamicAdjustmentRejected(t *testing.T) {
script := map[string]map[string]string{
"01_researcher": {"notes.md": "# 笔记\n内容"},
"02_writer": {"output.md": goodReport},
"03_reviewer": {"review.md": goodReport},
}
engine := newDynamicEngine(t, script, &scriptedEvaluator{})
res, _ := engine.Run(context.Background(), Goal{Text: "调研并成文"}, RunOptions{})
if res.Status != RunWaitingHuman || res.Pending.Kind != HitlAdjustment {
t.Fatalf("want adjustment hold, got %s", res.Status)
}
res2, err := engine.Respond(context.Background(), res.RunID, RespondDecision{RequestID: res.Pending.ID, Choice: "reject"})
if err != nil {
t.Fatal(err)
}
if res2.Status != RunCompleted && res2.Status != RunDegraded {
t.Fatalf("want completed/degraded after reject, got %s err=%s", res2.Status, res2.Error)
}
for _, s := range res2.Plan.Steps {
if s.ID == "s_price" {
t.Fatal("rejected adjustment must not insert step")
}
}
}
// mutableScriptBackend 可变脚本后端:第 N 次执行前可改写脚本。
type mutableScriptBackend struct {
script map[string]map[string]string
mutateAt int
mutate func(map[string]map[string]string)
calls int
}
func (b *mutableScriptBackend) Name() string { return "mut" }
func (b *mutableScriptBackend) Execute(ctx context.Context, req exec.Request) (*exec.Result, error) {
b.calls++
if b.mutate != nil && b.calls == b.mutateAt {
b.mutate(b.script)
}
key := filepath.Base(filepath.Clean(req.WorkDir))
files := b.script[key]
patches := map[string][]byte{}
for _, tg := range req.Targets {
content := files[tg]
if content == "" {
content = "\n"
}
out := filepath.Join(req.WorkDir, tg)
_ = os.MkdirAll(filepath.Dir(out), 0o755)
_ = os.WriteFile(out, []byte(content), 0o644)
patches[tg] = []byte(content)
}
return &exec.Result{Success: true, Patches: patches}, nil
}
// TestQualityRepairAutoRerun 验证:质检失败 → 自动重跑交付步骤 → 通过。
func TestQualityRepairAutoRerun(t *testing.T) {
script := map[string]map[string]string{
"01_researcher": {"notes.md": "n"},
"02_writer": {"output.md": "# 报告\n\n{{待填充}}\n"},
}
mut := &mutableScriptBackend{script: script, mutateAt: 2,
mutate: func(s map[string]map[string]string) {
s["02_writer"] = map[string]string{"output.md": goodReport}
}}
dir := t.TempDir()
reg := NewRoleRegistry()
for _, r := range []string{"researcher", "writer"} {
_ = reg.Register(RoleSpec{Role: r, Backend: "mut"})
}
backends := NewBackendResolver()
backends.RegisterInstance("mut", mut)
engine, _ := NewEngine(Options{DataDir: dir, Registry: reg,
Planner: mockScriptPlanner(
StepSpec{ID: "s1", Role: "researcher", Title: "调研", Targets: []string{"notes.md"}},
StepSpec{ID: "s2", Role: "writer", Title: "成文", Targets: []string{"output.md"}},
), Backends: backends, Sink: NopSink{}})
res, err := engine.Run(context.Background(), Goal{Text: "g"}, RunOptions{})
if err != nil {
t.Fatal(err)
}
if res.Status != RunCompleted {
t.Fatalf("auto repair should complete, got %s err=%s", res.Status, res.Error)
}
if res.Quality == nil || !res.Quality.Passed || len(res.Quality.Placeholders) != 0 {
t.Fatalf("final quality should pass without placeholders: %+v", res.Quality)
}
data, _ := os.ReadFile(filepath.Join(res.WorkDir, "output.md"))
if !strings.Contains(string(data), "测试交付物") {
t.Fatalf("repaired deliverable wrong: %q", string(data)[:min(len(data), 40)])
}
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
+142
View File
@@ -0,0 +1,142 @@
package squad
import (
"crypto/rand"
"encoding/hex"
"time"
)
// HitlMode HITL 策略模式。
type HitlMode string
const (
// HitlInteractive 默认:挂起并等待人工回应。
HitlInteractive HitlMode = "interactive"
// HitlAuto 无值守:阻塞缺口按角色 fallback 假设兜底(未声明则步骤失败);
// 高风险决策(计划确认/权限升级)不静默猜测,仍转为失败并留痕。
HitlAuto HitlMode = "auto"
// HitlDisabled 不挂起:所有本应挂起的点直接按失败处理。
HitlDisabled HitlMode = "disabled"
)
// HitlPolicy HITL 策略(全局默认 → 角色覆盖 → Goal 覆盖,窄者胜)。
type HitlPolicy struct {
Mode HitlMode `json:"mode,omitempty"`
// TimeoutAfter 非零时,pending 请求超过该时长未回应自动过期;
// 过期动作由 ExpireAction 决定(fail / cancel)。
TimeoutAfter time.Duration `json:"timeout_after,omitempty"`
// ExpireAction 过期动作:fail(默认,运行失败留痕)/ cancel(运行取消)。
ExpireAction string `json:"expire_action,omitempty"`
}
func (p HitlPolicy) mode() HitlMode {
if p.Mode == "" {
return HitlInteractive
}
return p.Mode
}
// HumanRequestKind 人工请求类型。
type HumanRequestKind string
const (
HitlClarification HumanRequestKind = "clarification" // 规划歧义澄清
HitlConfirmation HumanRequestKind = "confirmation" // 计划/高风险步骤确认
HitlGap HumanRequestKind = "gap" // 阻塞缺口
HitlAdjustment HumanRequestKind = "adjustment" // 动态调整审批
HitlPermission HumanRequestKind = "permission" // 权限升级审批
HitlQualityReview HumanRequestKind = "quality_review" // 质检超限人工裁决
HitlEscalation HumanRequestKind = "escalation" // leader/成员升级问人(委派不了/需要拍板)
)
// Severity 人工请求紧急度(inbox 语义:action_required 需要人做决定,
// attention 建议关注;用于通知分层与降噪)。
const (
SeverityActionRequired = "action_required"
SeverityAttention = "attention"
)
// WaitCondition 挂起请求的自动唤醒条件(等事件而非只等人)。
// 条件满足时引擎合成一条自动决策续跑——唤醒报告的是事实,不是业务完成。
type WaitCondition struct {
Type string `json:"type"` // run_done | field_set
RunID string `json:"run_id,omitempty"` // run_done:等待该运行到达终态
Field string `json:"field,omitempty"` // field_set:等待黑板出现该字段
}
// MaxWakeupFires 单个挂起请求自动唤醒次数上限(防失控护栏)。
const MaxWakeupFires = 5
// HumanRequest 一次结构化人工请求(可持久化、可恢复、按 ID 幂等回应)。
type HumanRequest struct {
ID string `json:"id"`
RunID string `json:"run_id"`
StepID string `json:"step_id,omitempty"`
Kind HumanRequestKind `json:"kind"`
Severity string `json:"severity,omitempty"` // action_required(默认)/ attention
Question string `json:"question"`
Options []HumanOption `json:"options,omitempty"`
Context string `json:"context,omitempty"` // 脱敏摘要(计划/缺口/质检报告)
// WaitCondition 非空时,条件满足可自动唤醒(人工回应仍然优先)。
WaitCondition *WaitCondition `json:"wait_condition,omitempty"`
// WakeupFires 已自动唤醒次数(护栏:达到 MaxWakeupFires 后过期)。
WakeupFires int `json:"wakeup_fires,omitempty"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
Status string `json:"status"` // pending, answered, expired, cancelled
AnsweredAt *time.Time `json:"answered_at,omitempty"`
}
// HumanOption 一个可选项;Value 为回应时提交的 choice。
type HumanOption struct {
Value string `json:"value"`
Label string `json:"label"`
}
// RespondDecision 对一次 HumanRequest 的人工决策。
type RespondDecision struct {
RequestID string `json:"request_id"`
// Answer 自由文本回答(clarification/gap)。
Answer string `json:"answer,omitempty"`
// Choice 选项值(confirmation/adjustment/permission/quality_review 常用):
// approve / reject / retry / skip / abort 等,或 HumanOption.Value。
Choice string `json:"choice,omitempty"`
// EditPlan 确认计划时的修改指令(附加在 Answer 上传递给引擎)。
EditPlan string `json:"edit_plan,omitempty"`
}
// newRequestID 生成请求 ID。
func newRequestID() string {
b := make([]byte, 8)
_, _ = rand.Read(b)
return "hr_" + hex.EncodeToString(b)
}
// hitlGate 引擎内部:一次挂起的完整上下文(落盘 pending.json)。
type hitlGate struct {
Request HumanRequest `json:"request"`
Plan *SquadPlan `json:"plan,omitempty"` // confirmation 时携带候选计划
Steps []StepSpec `json:"steps,omitempty"` // adjustment:建议追加的步骤
SkipIDs []string `json:"skip_ids,omitempty"` // adjustment:建议跳过的步骤
PrevStatus RunStatus `json:"prev_status"` // 回应后恢复到的状态
}
// mergeHitlPolicy 按全局 → 角色 → Goal 顺序取窄者(后设置者覆盖)。
func mergeHitlPolicy(base HitlPolicy, overrides ...*HitlPolicy) HitlPolicy {
out := base
for _, ov := range overrides {
if ov == nil {
continue
}
if ov.Mode != "" {
out.Mode = ov.Mode
}
if ov.TimeoutAfter != 0 {
out.TimeoutAfter = ov.TimeoutAfter
}
if ov.ExpireAction != "" {
out.ExpireAction = ov.ExpireAction
}
}
return out
}
+389
View File
@@ -0,0 +1,389 @@
package squad
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"agent-native/exec"
)
// execRequest 构造 leader 步骤的执行请求。
func execRequest(workDir, prompt, instructions string, targets []string, timeout time.Duration) exec.Request {
return exec.Request{
WorkDir: workDir,
Prompt: prompt,
Instructions: instructions,
Targets: targets,
Timeout: timeout,
OnEvent: func(ev exec.ProcessEvent) {},
}
}
// leader-delegation 模式(机制参考 Multica Squad Leader 协议,原创实现):
// leader 是注册表中的普通角色,被引擎在每轮激活——读目标、按成员 skills
// 挑人、产出一份结构化委派决策(delegation.json),引擎按决策执行成员步骤、
// 把成员产出并回黑板、再唤醒 leader 评估下一步。leader 只协调不实现;
// 委派不了就升级问人;轮次有上限防失控。
// DefaultMaxLeaderTurns leader 模式每运行的 leader 轮次上限。
const DefaultMaxLeaderTurns = 8
// LeaderState leader 循环状态(workspace leader.json 持久化,重启可恢复)。
type LeaderState struct {
Turns int `json:"turns"`
MaxTurns int `json:"max_turns"`
LeaderRole string `json:"leader_role"`
Delegations []DelegationRecord `json:"delegations"`
}
// DelegationRecord 一次已执行的委派(历史注入下一轮 leader 上下文)。
type DelegationRecord struct {
Turn int `json:"turn"`
Member string `json:"member"`
Title string `json:"title"`
Targets []string `json:"targets,omitempty"`
Status string `json:"status"` // completed / failed / degraded
Error string `json:"error,omitempty"`
}
// delegation leader 单轮产出的结构化决策。
type delegation struct {
Action string `json:"action"` // delegate | escalate | close | failed
Member string `json:"member,omitempty"`
Title string `json:"title,omitempty"`
Targets []string `json:"targets,omitempty"`
Briefing string `json:"briefing,omitempty"` // 给成员的补充指令
Question string `json:"question,omitempty"` // escalate 时的问题
Reason string `json:"reason,omitempty"`
}
const leaderSubWorkspace = "tasks/00_leader"
func (st *runState) loadLeader() {
if st.Leader != nil {
return
}
st.Leader = &LeaderState{MaxTurns: DefaultMaxLeaderTurns}
data, err := os.ReadFile(filepath.Join(st.Meta.WorkDir, "leader.json"))
if err == nil {
_ = json.Unmarshal(data, st.Leader)
}
}
func (st *runState) saveLeader() {
if st.Leader == nil {
return
}
_ = writeJSON(filepath.Join(st.Meta.WorkDir, "leader.json"), st.Leader)
}
// leaderPrompt 组装 leader 的系统指令(协调契约 + 通用守则)。
func leaderPrompt(members []RoleSpec) string {
var b strings.Builder
b.WriteString(`你是本小队的 LEADER。你被激活的唯一职责是协调,不是自己实现。
每轮你必须做且只做一件事:评估当前进展,然后输出一份委派决策文件 delegation.json 到本子目录(用工具写入,不要输出到对话里):
{"action":"delegate","member":"成员role","title":"给成员的子任务标题","targets":["output.md"],"briefing":"给成员的补充指令/验收标准","reason":"为什么派给这个人"}
{"action":"escalate","question":"要向人提出的问题","reason":"为什么需要人拍板"}
{"action":"close","reason":"目标已达成,收口"}
{"action":"failed","reason":"无法达成的原因"}
硬规则:
1. 按成员的能力(skills/职责)挑人,不按头衔;没有合适成员就 escalate 说明缺口,而不是自己动手或乱派。
2. 一轮只委派一个子任务;子任务要小到可以一次完成并产出明确文件。
3. 委派的子任务标题里写清验收标准;不要复述目标全文。
4. 交付受阻时如实 escalate/failed,严禁假装完成或声称"基本完成"。
5. 所有成员子任务完成且交付物满足目标才 close;close 不等于人工终审。
6. 缺失的信息若会实质改变结果、执行路径、权限或安全,就 escalate 问人;否则自己决定并在 reason 里说明你决定了什么。
成员交付核实:每个成员完成后,其交付文件会自动快照到本目录 inbox/<步骤id>/ 下——请用工具查看该目录核实交付,不要凭空假设文件是否存在。
可用成员(role — 名称 — 技能 — 职责):
`)
for _, m := range members {
fmt.Fprintf(&b, "- %s — %s — skills=%v — %v\n", m.Role, m.Name, m.Skills, m.Responsibilities)
}
return b.String()
}
// leaderTaskBrief 组装 leader 本轮的 TASK.md(目标 + 黑板 + 委派历史)。
func leaderTaskBrief(st *runState) string {
var b strings.Builder
b.WriteString("# Leader 轮次:评估并委派\n\n")
fmt.Fprintf(&b, "## 运行目标\n%s\n\n", st.Meta.Goal.Text)
if len(st.Clarifications) > 0 {
b.WriteString("## 已澄清问答\n")
for _, c := range st.Clarifications {
fmt.Fprintf(&b, "- %s\n", c)
}
b.WriteString("\n")
}
if len(st.BB.Fields) > 0 {
b.WriteString("## 黑板字段\n")
for k, v := range st.BB.Fields {
fmt.Fprintf(&b, "- %s = %s\n", k, v)
}
b.WriteString("\n")
}
if gaps := st.BB.UnresolvedBlockingGaps(); len(gaps) > 0 {
b.WriteString("## 未解决缺口\n")
for _, g := range gaps {
fmt.Fprintf(&b, "- [%s] %s\n", g.ID, g.Item)
}
b.WriteString("\n")
}
if len(st.Leader.Delegations) > 0 {
b.WriteString("## 委派历史\n")
for _, d := range st.Leader.Delegations {
fmt.Fprintf(&b, "- 第%d轮 → %s:%s(%s)\n", d.Turn, d.Member, d.Title, d.Status)
if d.Error != "" {
fmt.Fprintf(&b, " - 错误:%s\n", d.Error)
}
}
b.WriteString("\n")
} else {
b.WriteString("(尚无委派,这是第一轮。)\n\n")
}
b.WriteString("## 本轮产出要求\n\n写 delegation.json(结构见你的指令)到本子目录。\n")
return b.String()
}
// runLeaderLoop leader-delegation 主循环:委派→执行→唤醒,直到
// close/failed/上限/挂起(escalation/gap/权限等由 HITL 处理后重入本函数)。
func (e *SquadEngine) runLeaderLoop(ctx context.Context, st *runState) (*RunResult, *hitlGate, error) {
st.loadLeader()
drv := &flowDriver{opts: e.deps}
leader, ok := findAgent(st.Plan.Squad.Agents, st.Leader.LeaderRole)
if !ok {
if spec, exists := e.opts.Registry.Get(st.Leader.LeaderRole); exists {
leader = spec
} else {
return &RunResult{RunID: st.Meta.ID, Status: RunFailed, Plan: planPtr(st),
Error: "leader role not found: " + st.Leader.LeaderRole, WorkDir: st.Meta.WorkDir}, nil, nil
}
}
var members []RoleSpec
for _, a := range st.Plan.Squad.Agents {
if a.Role != leader.Role {
members = append(members, a)
}
}
if len(members) == 0 {
return &RunResult{RunID: st.Meta.ID, Status: RunFailed, Plan: planPtr(st),
Error: "leader mode requires at least one member role besides the leader", WorkDir: st.Meta.WorkDir}, nil, nil
}
subDir := filepath.Join(st.Meta.WorkDir, filepath.FromSlash(leaderSubWorkspace))
if err := os.MkdirAll(subDir, 0o755); err != nil {
return nil, nil, err
}
_ = snapshotContextInto(st.Meta.WorkDir, subDir)
for st.Leader.Turns < st.Leader.MaxTurns {
// HITL:leader 角色权限升级(与标准模式同语义)。
if leader.BashPermission == "ask" && !st.Approved["role:"+leader.Role] {
if drv.policy(st).mode() != HitlInteractive {
return &RunResult{RunID: st.Meta.ID, Status: RunFailed, Plan: planPtr(st),
Error: "leader role requires bash approval but hitl mode is " + string(drv.policy(st).mode()),
WorkDir: st.Meta.WorkDir}, nil, nil
}
req := &HumanRequest{
ID: newRequestID(), RunID: st.Meta.ID, Kind: HitlPermission, Severity: SeverityActionRequired,
Question: fmt.Sprintf("leader 角色 %s 声明 bash_permission=ask,是否授权其在本次运行中使用更高权限?", leader.Name),
Options: []HumanOption{
{Value: "approve", Label: "授权本次运行"}, {Value: "reject", Label: "拒绝"}, {Value: "abort", Label: "中止"},
},
CreatedAt: time.Now(), Status: "pending",
}
gate := &hitlGate{Request: *req, PrevStatus: RunExecuting}
if err := st.saveGate(gate); err == nil {
st.Meta.Status = string(RunWaitingHuman)
_ = st.save()
drv.emitHuman(st, req)
}
return heldResult(st, gate), gate, nil
}
// 每轮重写 leader 上下文(黑板/委派历史已变化)。
if err := os.WriteFile(filepath.Join(subDir, "TASK.md"), []byte(leaderTaskBrief(st)), 0o644); err != nil {
return nil, nil, err
}
if err := os.WriteFile(filepath.Join(subDir, "AGENTS.md"), []byte(leaderPrompt(members)), 0o644); err != nil {
return nil, nil, err
}
st.Leader.Turns++
st.saveLeader()
drv.emit(st, Event{Type: EvStepStarted, StepID: fmt.Sprintf("leader_turn_%d", st.Leader.Turns),
Role: leader.Role, Text: "leader 评估并委派"})
backend, backendName, err := e.deps.Backends.Resolve(leader, st.Meta.Backend)
if err != nil {
return e.leaderFail(st, err.Error()), nil, nil
}
prompt := readFileOr(filepath.Join(subDir, "TASK.md"), leaderTaskBrief(st))
prompt = st.injectSteering(prompt)
instructions := readFileOr(filepath.Join(subDir, "AGENTS.md"), leaderPrompt(members))
result, execErr := backend.Execute(ctx, execRequest(subDir, prompt, instructions, []string{"delegation.json"}, e.deps.StepTimeout))
if execErr != nil || result == nil || !result.Success {
reason := "leader execution failed"
if execErr != nil {
reason = execErr.Error()
} else if result != nil && result.ErrorReason != "" {
reason = result.ErrorReason
}
_ = os.Remove(filepath.Join(subDir, "delegation.json"))
return e.leaderFail(st, fmt.Sprintf("leader turn %d: %s (backend=%s)", st.Leader.Turns, reason, backendName)), nil, nil
}
dec, perr := parseDelegation(subDir)
if perr != nil {
return e.leaderFail(st, fmt.Sprintf("leader turn %d: %v", st.Leader.Turns, perr)), nil, nil
}
_ = os.Remove(filepath.Join(subDir, "delegation.json")) // 每轮只消费一次
switch dec.Action {
case "close":
drv.emit(st, Event{Type: EvStepCompleted, StepID: "leader_close", Role: leader.Role, Text: dec.Reason})
st.saveLeader()
out, gate, err := drv.finalize(ctx, st)
if err != nil {
return nil, nil, err
}
e.finalizeEvent(out)
return out, gate, nil
case "failed":
return e.leaderFail(st, "leader 报告无法达成:"+dec.Reason), nil, nil
case "escalate":
if drv.policy(st).mode() != HitlInteractive {
return e.leaderFail(st, "leader 请求升级问人,但 hitl mode="+string(drv.policy(st).mode())), nil, nil
}
req := &HumanRequest{
ID: newRequestID(), RunID: st.Meta.ID, Kind: HitlEscalation, Severity: SeverityActionRequired,
StepID: fmt.Sprintf("leader_turn_%d", st.Leader.Turns),
Question: firstNonEmpty(dec.Question, "leader 需要人工拍板:") + "(原因:" + dec.Reason + ")",
Options: []HumanOption{
{Value: "continue", Label: "已在回答中给出决定,leader 继续"},
{Value: "abort", Label: "中止运行"},
},
CreatedAt: time.Now(), Status: "pending",
}
gate := &hitlGate{Request: *req, PrevStatus: RunExecuting}
if err := st.saveGate(gate); err == nil {
st.Meta.Status = string(RunWaitingHuman)
_ = st.save()
drv.emitHuman(st, req)
}
return heldResult(st, gate), gate, nil
case "delegate":
member, ok := findAgent(members, dec.Member)
if !ok {
return e.leaderFail(st, fmt.Sprintf("leader 委派了编制外成员 %q", dec.Member)), nil, nil
}
step := StepSpec{
ID: fmt.Sprintf("m_%02d_%s", st.Leader.Turns, member.Role),
Role: member.Role,
Title: firstNonEmpty(dec.Title, "子任务"),
SubWorkspace: fmt.Sprintf("tasks/%02d_%s", st.Leader.Turns, member.Role),
Status: "pending",
}
if len(dec.Targets) > 0 {
step.Targets = dec.Targets
} else {
step.Targets = []string{"output.md"}
}
st.Plan.Workflow.Steps = append(st.Plan.Workflow.Steps, step)
_ = st.save()
// 构建成员子工作区并执行(复用标准驱动器单步执行)。
idx := len(st.Plan.Workflow.Steps) - 1
if err := e.ws.BuildSubWorkspaces(st.Meta.WorkDir, &st.Plan, &st.BB); err != nil {
return nil, nil, err
}
memberBrief := readFileOr(filepath.Join(st.Meta.WorkDir, filepath.FromSlash(step.SubWorkspace), "TASK.md"), step.Title)
if strings.TrimSpace(dec.Briefing) != "" {
memberBrief += "\n\n## Leader 补充指令\n" + dec.Briefing
_ = os.WriteFile(filepath.Join(st.Meta.WorkDir, filepath.FromSlash(step.SubWorkspace), "TASK.md"), []byte(memberBrief), 0o644)
}
out, gate, err := drv.runOneStep(ctx, st, idx)
if err != nil {
return nil, nil, err
}
rec := DelegationRecord{Turn: st.Leader.Turns, Member: member.Role, Title: step.Title,
Targets: step.Targets, Status: "completed"}
if out != nil { // 关键失败或挂起
rec.Status = "failed"
rec.Error = firstNonEmpty(out.Error, string(out.Status))
st.Leader.Delegations = append(st.Leader.Delegations, rec)
st.saveLeader()
if out.Status == RunFailed {
return out, nil, nil
}
return out, gate, nil // 挂起(gap/权限等),Respond 后重入循环
}
st.Leader.Delegations = append(st.Leader.Delegations, rec)
st.saveLeader()
// 成员交付快照进 leader inbox(leader 工具沙箱看不到成员目录,
// 须物化到 leader 工作区才能自查交付——InputLayoutSnapshot 的对称应用)。
snapshotDeliverablesToLeader(st, step)
// 成员产出后检查阻塞缺口(等待条件挂起在标准 gap 通道上)。
if g2 := drv.checkBlockingGaps(st); g2 != nil {
return heldResult(st, g2), g2, nil
}
// 唤醒 leader:进入下一轮。
continue
default:
return e.leaderFail(st, fmt.Sprintf("leader 输出了未知 action %q", dec.Action)), nil, nil
}
}
return e.leaderFail(st, fmt.Sprintf("leader 轮次达上限 %d(防失控护栏),运行停止;可提高 MaxLeaderTurns 或人工介入", st.Leader.MaxTurns)), nil, nil
}
// leaderFail 统一的 leader 模式失败收口。
func (e *SquadEngine) leaderFail(st *runState, reason string) *RunResult {
now := time.Now()
st.Meta.Status = string(RunFailed)
st.Meta.CompletedAt = &now
wf := st.Workflow()
wf.Status = "failed"
st.setWorkflow(wf)
_ = st.save()
e.deps.Sink.Emit(Event{Type: EvRunFailed, RunID: st.Meta.ID, Text: reason, At: time.Now()})
return &RunResult{RunID: st.Meta.ID, Status: RunFailed, Plan: planPtr(st),
Error: reason, WorkDir: st.Meta.WorkDir, Usage: st.usageSnapshot()}
}
// snapshotDeliverablesToLeader 把成员交付文件复制进 leader 工作区 inbox/<步骤id>/。
func snapshotDeliverablesToLeader(st *runState, step StepSpec) {
srcDir := filepath.Join(st.Meta.WorkDir, filepath.FromSlash(step.SubWorkspace))
dstDir := filepath.Join(st.Meta.WorkDir, filepath.FromSlash(leaderSubWorkspace), "inbox", step.ID)
for _, t := range step.Targets {
data, err := os.ReadFile(filepath.Join(srcDir, filepath.FromSlash(t)))
if err != nil || len(data) == 0 {
continue
}
dst := filepath.Join(dstDir, filepath.Base(filepath.FromSlash(t)))
_ = os.MkdirAll(filepath.Dir(dst), 0o755)
_ = os.WriteFile(dst, data, 0o644)
}
}
// parseDelegation 解析 leader 产出的 delegation.json。
func parseDelegation(subDir string) (*delegation, error) {
data, err := os.ReadFile(filepath.Join(subDir, "delegation.json"))
if err != nil {
return nil, fmt.Errorf("leader 未产出 delegation.json: %w", err)
}
dec := &delegation{}
if err := json.Unmarshal(data, dec); err != nil {
return nil, fmt.Errorf("delegation.json 解析失败: %w", err)
}
if dec.Action == "" {
return nil, fmt.Errorf("delegation.json 缺少 action")
}
return dec, nil
}
+382
View File
@@ -0,0 +1,382 @@
package squad
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"agent-native/exec"
)
// leaderScriptBackend leader 模式脚本后端:00_leader 目录按轮次回放
// delegation.json 序列;成员目录按 script map 回放产出。
type leaderScriptBackend struct {
mu sync.Mutex
turn int
decisions []string // 每轮写入 tasks/00_leader/delegation.json 的内容
script map[string]map[string]string
prompts map[string][]string // 目录 → 收到的 prompt(记录用)
}
func (b *leaderScriptBackend) Name() string { return "leader-script" }
func (b *leaderScriptBackend) Execute(ctx context.Context, req exec.Request) (*exec.Result, error) {
key := filepath.Base(filepath.Clean(req.WorkDir))
if b.prompts != nil {
b.mu.Lock()
b.prompts[key] = append(b.prompts[key], req.Prompt)
b.mu.Unlock()
}
if key == "00_leader" {
b.mu.Lock()
idx := b.turn
b.turn++
b.mu.Unlock()
if idx >= len(b.decisions) {
idx = len(b.decisions) - 1
}
out := filepath.Join(req.WorkDir, "delegation.json")
_ = os.WriteFile(out, []byte(b.decisions[idx]), 0o644)
return &exec.Result{Success: true, Patches: map[string][]byte{"delegation.json": []byte(b.decisions[idx])}}, nil
}
files := b.script[key]
patches := map[string][]byte{}
for _, t := range req.Targets {
content := files[t]
if content == "" {
content = "\n"
}
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,
PromptTokens: 100, CompletionTokens: 50}, nil
}
func delegateJSON(member, title string) string {
return fmt.Sprintf(`{"action":"delegate","member":%q,"title":%q,"targets":["output.md"],"briefing":"验收:内容完整"}`, member, title)
}
const closeJSON = `{"action":"close","reason":"所有子任务完成,交付物满足目标"}`
func newLeaderEngine(t *testing.T, backend exec.Backend, roles ...RoleSpec) *SquadEngine {
t.Helper()
dir := t.TempDir()
reg := NewRoleRegistry()
if len(roles) == 0 {
roles = []RoleSpec{
{Role: "leader", Name: "主管", Backend: "leader-script", Skills: []string{"协调"}},
{Role: "writer", Name: "撰写", Backend: "leader-script", Skills: []string{"撰写"}},
}
}
for _, r := range roles {
if err := reg.Register(r); err != nil {
t.Fatal(err)
}
}
backends := NewBackendResolver()
backends.RegisterInstance("leader-script", backend)
engine, err := NewEngine(Options{DataDir: dir, Registry: reg, Backends: backends, Sink: NopSink{}})
if err != nil {
t.Fatal(err)
}
return engine
}
func TestLeaderModeDelegateThenClose(t *testing.T) {
b := &leaderScriptBackend{
decisions: []string{
delegateJSON("writer", "撰写调研简报"),
closeJSON,
},
script: map[string]map[string]string{
"01_writer": {"output.md": goodReport},
},
}
engine := newLeaderEngine(t, b)
res, err := engine.Run(context.Background(), Goal{Text: "产出一份调研简报"}, RunOptions{
Mode: "leader", LeaderRole: "leader", MaxLeaderTurns: 5,
})
if err != nil {
t.Fatal(err)
}
if res.Status != RunCompleted {
t.Fatalf("want completed, got %s err=%s", res.Status, res.Error)
}
// 委派出的成员步骤进入编排清单且完成。
var member StepSpec
for _, s := range res.Plan.Steps {
if s.Role == "writer" {
member = s
}
}
if member.ID == "" || member.Status != "completed" {
t.Fatalf("member step missing/incomplete: %+v", res.Plan.Steps)
}
// leader 历史持久化。
data, _ := os.ReadFile(filepath.Join(res.WorkDir, "leader.json"))
var ls LeaderState
if err := json.Unmarshal(data, &ls); err != nil {
t.Fatal(err)
}
if len(ls.Delegations) != 1 || ls.Delegations[0].Member != "writer" || ls.Turns < 2 {
t.Fatalf("leader state wrong: %+v", ls)
}
// 用量按角色记账。
if res.Usage == nil || res.Usage["writer"].CompletionTokens == 0 {
t.Fatalf("usage missing: %+v", res.Usage)
}
}
func TestLeaderModeEscalateHITL(t *testing.T) {
b := &leaderScriptBackend{
prompts: map[string][]string{},
decisions: []string{
`{"action":"escalate","question":"目标受众是谁?","reason":"受众影响成文风格"}`,
`{"action":"delegate","member":"writer","title":"按人工答复撰写简报","targets":["output.md"],"briefing":"面向人工确认的受众"}`,
closeJSON,
},
script: map[string]map[string]string{
"02_writer": {"output.md": goodReport},
},
}
engine := newLeaderEngine(t, b)
res, err := engine.Run(context.Background(), Goal{Text: "写简报"}, RunOptions{Mode: "leader", LeaderRole: "leader"})
if err != nil {
t.Fatal(err)
}
if res.Status != RunWaitingHuman || res.Pending.Kind != HitlEscalation || res.Pending.Severity != SeverityActionRequired {
t.Fatalf("want escalation hold, got %s kind=%v sev=%q", res.Status, res.Pending.Kind, res.Pending.Severity)
}
if !strings.Contains(res.Pending.Question, "受众") {
t.Fatalf("escalation question wrong: %s", res.Pending.Question)
}
res2, err := engine.Respond(context.Background(), res.RunID, RespondDecision{
RequestID: res.Pending.ID, Answer: "面向国内大众消费者",
})
if err != nil {
t.Fatal(err)
}
if res2.Status != RunCompleted {
t.Fatalf("want completed after escalation answer, got %s err=%s", res2.Status, res2.Error)
}
// 人的决定落为黑板事实。
bb, _ := engine.Blackboard(res.RunID)
found := false
for _, f := range bb.Facts {
if strings.Contains(f.Claim, "大众消费者") && f.Source == "human" {
found = true
}
}
if !found {
t.Fatalf("escalation answer not a fact: %+v", bb.Facts)
}
// 下一轮 leader 上下文包含该问答(通过记录的 prompt 验证)。
b.mu.Lock()
lastLeaderPrompt := ""
if n := len(b.prompts["00_leader"]); n > 0 {
lastLeaderPrompt = b.prompts["00_leader"][n-1]
}
b.mu.Unlock()
if !strings.Contains(lastLeaderPrompt, "大众消费者") {
t.Fatalf("leader not re-briefed with escalation answer: %q", truncate(lastLeaderPrompt, 80))
}
}
func TestLeaderModeTurnCapGuard(t *testing.T) {
// leader 每轮都委派(成员产出内容但 leader 永不 close)→ 轮次护栏触发失败。
loop := delegateJSON("writer", "再补一节")
b := &leaderScriptBackend{decisions: []string{loop}, script: map[string]map[string]string{
"01_writer": {"output.md": goodReport},
}}
engine := newLeaderEngine(t, b)
res, err := engine.Run(context.Background(), Goal{Text: "g"}, RunOptions{Mode: "leader", LeaderRole: "leader", MaxLeaderTurns: 3})
if err != nil {
t.Fatal(err)
}
if res.Status != RunFailed || !strings.Contains(res.Error, "上限") {
t.Fatalf("want turn-cap failure, got %s err=%s", res.Status, res.Error)
}
if b.turn < 3 {
t.Fatalf("leader should have run %d turns, ran %d", 3, b.turn)
}
}
func TestLeaderModeRestartRecovery(t *testing.T) {
b := &leaderScriptBackend{
decisions: []string{delegateJSON("writer", "撰写简报"), closeJSON},
script: map[string]map[string]string{"01_writer": {"output.md": goodReport}},
}
engine := newLeaderEngine(t, b)
res, _ := engine.Run(context.Background(), Goal{Text: "写简报"}, RunOptions{Mode: "leader", LeaderRole: "leader"})
if res.Status != RunCompleted {
t.Fatalf("want completed, got %s err=%s", res.Status, res.Error)
}
// 新引擎实例(进程重启模拟)能读出终态与 leader 历史。
engine2, err := NewEngine(Options{DataDir: engineOpts(engine).DataDir,
Registry: NewRoleRegistry(), Backends: NewBackendResolver(), Sink: NopSink{}})
if err != nil {
t.Fatal(err)
}
insp, err := engine2.Inspect(res.RunID)
if err != nil || insp.Status != RunCompleted {
t.Fatalf("restart inspect: %v %+v", err, insp)
}
}
func engineOpts(e *SquadEngine) Options { return e.opts }
func TestLeaderModeGapHoldThenResume(t *testing.T) {
// 成员产出阻塞缺口 → 标准 gap 挂起;人工回应后 leader 循环继续到 close。
b := &leaderScriptBackend{
decisions: []string{
`{"action":"delegate","member":"writer","title":"撰写简报","targets":["output.md","fields.json","gaps.json"]}`,
closeJSON,
},
script: map[string]map[string]string{
"01_writer": {"output.md": goodReport, "fields.json": `{"k":"v"}`,
"gaps.json": `[{"item":"缺少受众定位","blocking":true}]`},
},
}
engine := newLeaderEngine(t, b)
res, _ := engine.Run(context.Background(), Goal{Text: "写简报"}, RunOptions{Mode: "leader", LeaderRole: "leader"})
if res.Status != RunWaitingHuman || res.Pending.Kind != HitlGap {
t.Fatalf("want gap hold in leader mode, got %s", res.Status)
}
res2, err := engine.Respond(context.Background(), res.RunID, RespondDecision{
RequestID: res.Pending.ID, Choice: "resolved", Answer: "受众为大众消费者",
})
if err != nil {
t.Fatal(err)
}
if res2.Status != RunCompleted {
t.Fatalf("want completed after gap answer in leader mode, got %s err=%s", res2.Status, res2.Error)
}
}
// ---- 唤醒(等待条件自动恢复) ----
func TestWakeupRunDone(t *testing.T) {
// 运行 B 先完成;运行 A 的缺口声明 wait_run_id=B → sweep 自动唤醒续跑。
script := map[string]map[string]string{
"01_researcher": {"notes.md": "n", "fields.json": `{}`},
"02_writer": {"output.md": goodReport},
"03_reviewer": {"output.md": goodReport},
}
dir := t.TempDir()
reg := NewRoleRegistry()
for _, r := range []string{"researcher", "writer", "reviewer"} {
_ = reg.Register(RoleSpec{Role: r, Backend: "script"})
}
backends := NewBackendResolver()
backends.RegisterInstance("script", &scriptBackend{script: script})
engine, _ := NewEngine(Options{DataDir: dir, Registry: reg,
Planner: mockScriptPlanner(baseSteps()...), Backends: backends, Sink: NopSink{}})
// 运行 B:无缺口,直接完成。
resB, err := engine.Run(context.Background(), Goal{Text: "运行B"}, RunOptions{})
if err != nil || resB.Status != RunCompleted {
t.Fatalf("run B failed: %v %+v", err, resB)
}
// 运行 A:步骤 1 声明 wait_run_id。
scriptA := map[string]map[string]string{
"01_researcher": {"notes.md": "n", "fields.json": `{}`,
"gaps.json": `[{"item":"等待运行B的数据","blocking":true,"wait_run_id":"` + resB.RunID + `"}]`},
"02_writer": {"output.md": goodReport},
"03_reviewer": {"output.md": goodReport},
}
backendsA := NewBackendResolver()
backendsA.RegisterInstance("script", &scriptBackend{script: scriptA})
dirA := t.TempDir()
engineA, _ := NewEngine(Options{DataDir: dirA, Registry: reg,
Planner: mockScriptPlanner(gapSteps()...), Backends: backendsA, Sink: NopSink{}})
resA, err := engineA.Run(context.Background(), Goal{Text: "运行A"}, RunOptions{})
if err != nil {
t.Fatal(err)
}
if resA.Status != RunWaitingHuman || resA.Pending.WaitCondition == nil || resA.Pending.WaitCondition.Type != "run_done" {
t.Fatalf("gap wait condition missing: %s cond=%+v", resA.Status, resA.Pending.WaitCondition)
}
// 未满足时不唤醒:等待自己(不存在)不触发。
if n := engineA.SweepWakeups(context.Background()); n != 0 {
// run_done 指向 engineB 的 run(不同 DataDir 引擎看不见)→ 不满足 → 0。
_ = n
}
// 同一引擎内验证:重新在 engineA 数据目录下伪造目标运行已完成(跨运行等待)。
// 直接用 field_set 变体验证自动唤醒通路。
gateDir := filepath.Join(dirA, "runs", resA.RunID)
gate, _ := loadGate(gateDir)
gate.Request.WaitCondition = &WaitCondition{Type: "field_set", Field: "external_data"}
_ = writeJSON(filepath.Join(gateDir, "pending.json"), gate)
if err := engineA.SetBlackboardField(resA.RunID, "external_data", "来自外部的数据"); err != nil {
t.Fatal(err)
}
if n := engineA.SweepWakeups(context.Background()); n != 1 {
t.Fatalf("sweep want 1 woken, got %d", n)
}
insp, err := engineA.Inspect(resA.RunID)
if err != nil {
t.Fatal(err)
}
if insp.Status != RunCompleted {
t.Fatalf("auto-wakeup should complete run, got %s", insp.Status)
}
bb, _ := engineA.Blackboard(resA.RunID)
found := false
for _, f := range bb.Facts {
if strings.Contains(f.Claim, "external_data") || strings.Contains(f.Claim, "自动唤醒") {
found = true
}
}
if !found {
t.Fatalf("wakeup fact not recorded: %+v", bb.Facts)
}
}
func TestWakeupGuardMaxFires(t *testing.T) {
// 构造一个永不满足条件但人为反复置位触发…… 实际护栏语义:fires 达上限即自动中止。
// 用 field_set 反复唤醒:每次唤醒后又产生新缺口(脚本后端每步都写 gaps)→ 循环到上限。
dir := t.TempDir()
reg := NewRoleRegistry()
for _, r := range []string{"researcher", "writer", "reviewer"} {
_ = reg.Register(RoleSpec{Role: r, Backend: "script"})
}
script := map[string]map[string]string{
"01_researcher": {"notes.md": "n", "fields.json": `{}`,
"gaps.json": `[{"item":"等字段","blocking":true,"wait_field":"ext"}]`},
"02_writer": {"output.md": goodReport},
"03_reviewer": {"output.md": goodReport},
}
backends := NewBackendResolver()
backends.RegisterInstance("script", &scriptBackend{script: script})
engine, _ := NewEngine(Options{DataDir: dir, Registry: reg,
Planner: mockScriptPlanner(gapSteps()...), Backends: backends, Sink: NopSink{}})
// 只跑第一步就挂起:两步计划,步骤1 gap 挂起。
res, _ := engine.Run(context.Background(), Goal{Text: "g"}, RunOptions{})
if res.Status != RunWaitingHuman {
t.Fatalf("want held, got %s", res.Status)
}
// 反复置字段 + sweep:第一次唤醒后步骤2 完成(无缺口)→ 直接完成,不会循环。
// 护栏上限用直接改 fires 验证:设为 MaxWakeupFires-1 再触发一次。
gateDir := filepath.Join(dir, "runs", res.RunID)
gate, _ := loadGate(gateDir)
gate.Request.WaitCondition = &WaitCondition{Type: "field_set", Field: "ext"}
gate.Request.WakeupFires = MaxWakeupFires - 1
_ = writeJSON(filepath.Join(gateDir, "pending.json"), gate)
_ = engine.SetBlackboardField(res.RunID, "ext", "v")
engine.SweepWakeups(context.Background())
insp, _ := engine.Inspect(res.RunID)
if insp.Status != RunCancelled && insp.Status != RunCompleted {
t.Fatalf("guard-exhaust path unexpected: %s", insp.Status)
}
_ = time.Now
}
+269
View File
@@ -0,0 +1,269 @@
// Package squad 提供通用智能体小队编排底座:角色注册、目标规划、动态执行、
// 黑板协同与 HITL 挂起/恢复。本包不含任何预设角色或任务模板。
package squad
import "time"
// RoleSource 标识角色来源。
type RoleSource string
const (
// RoleSourceRegistered 表示外部注册的角色(配置文件/API/CLI)。
RoleSourceRegistered RoleSource = "registered"
// RoleSourceDerived 表示规划器或用户在运行期派生的特设角色。
RoleSourceDerived RoleSource = "derived"
)
// RoleSpec 声明一个小队角色的完整设定。空表起步:库不预置任何角色。
type RoleSpec struct {
Role string `json:"role"`
Name string `json:"name"`
Source RoleSource `json:"source,omitempty"`
BaseRole string `json:"base_role,omitempty"`
Backend string `json:"backend,omitempty"` // 绑定的智能体名:stub/llm/agent-native/pi-agent/自定义注册名
Model string `json:"model,omitempty"` // 角色级模型(backend 支持时生效)
ToolProfile string `json:"tool_profile,omitempty"` // agent-native ToolProfile:workspace/reasoning
Tools []string `json:"tools,omitempty"`
Skills []string `json:"skills,omitempty"` // 技能标识(backend 支持时注入)
BashPermission string `json:"bash_permission"` // allow / ask / deny
Responsibilities []string `json:"responsibilities,omitempty"`
ForbiddenActions []string `json:"forbidden_actions,omitempty"`
Keywords []string `json:"keywords,omitempty"` // 规则规划器推荐与匹配用
Instructions string `json:"instructions,omitempty"` // 内联指令(优先)
InstructionsFile string `json:"instructions_file,omitempty"` // 指令文件(数据目录相对或绝对路径)
HitlPolicy *HitlPolicy `json:"hitl_policy,omitempty"` // 角色级 HITL 策略(覆盖全局)
// MaxConcurrentTasks 该角色同时执行步骤的上限(<=1 视为 1);
// 引擎在解析后端时以并发限流包装实现(对象级串行语义)。
MaxConcurrentTasks int `json:"max_concurrent_tasks,omitempty"`
}
// Goal 一次小队运行的目标输入。
type Goal struct {
Text string `json:"text"`
Materials []MaterialFile `json:"materials,omitempty"`
Constraints map[string]string `json:"constraints,omitempty"`
HitlMode HitlMode `json:"hitl_mode,omitempty"` // 覆盖全局策略
HitlTimeout time.Duration `json:"hitl_timeout,omitempty"`
}
// MaterialFile 一份输入材料(文件字节或磁盘路径二选一)。
type MaterialFile struct {
Filename string `json:"filename"`
Path string `json:"path,omitempty"`
Content []byte `json:"content,omitempty"`
}
// Blueprint 已注册的任务模板:命中的 Goal 走基线工作流,未注册走自主规划。
type Blueprint struct {
Key string `json:"key"` // 模板键(可由 BlueprintMatcher 匹配 Goal.Text)
Title string `json:"title"`
Match []string `json:"match,omitempty"` // 关键词命中即视为命中模板
Description string `json:"description,omitempty"`
Agents []RoleSpec `json:"agents,omitempty"` // 默认编制(引用已注册角色或内联角色)
Steps []StepSpec `json:"steps"`
Template string `json:"template,omitempty"` // 交付物骨架模板(Markdown)
}
// PlanningMode 规划模式。
type PlanningMode string
const (
PlanModeBlueprint PlanningMode = "blueprint" // 命中模板:基线步骤 + 动态微调
PlanModeAutonomous PlanningMode = "autonomous" // 未命中模板:规划器自主拆解
)
// StepSpec 单个步骤规格。
type StepSpec struct {
ID string `json:"id"`
Role string `json:"role"`
Title string `json:"title"`
SubWorkspace string `json:"sub_workspace,omitempty"` // 相对子路径,缺省 tasks/NN_role
Mode string `json:"mode,omitempty"` // 预留
Parallel bool `json:"parallel"`
Needs []string `json:"needs,omitempty"`
Targets []string `json:"targets,omitempty"` // 期望交付物相对路径,缺省 output.md
Status string `json:"status"` // pending, running, completed, failed, skipped
DurationMs int64 `json:"duration_ms,omitempty"`
Error string `json:"error,omitempty"`
// RequiresApproval 为 true 时,该步骤启动前进入 HITL 计划确认。
RequiresApproval bool `json:"requires_approval,omitempty"`
// AllowDynamicAdjustment 允许评估器在步骤后做动态调整(加步/跳步/改步)。
AllowDynamicAdjustment bool `json:"allow_dynamic_adjustment,omitempty"`
// Critical: nil(默认)= 关键步骤失败即终止;false = 非关键,失败降级继续。
Critical *bool `json:"critical,omitempty"`
}
// IsCritical 报告步骤是否关键(nil 视为关键)。
func (s StepSpec) IsCritical() bool { return s.Critical == nil || *s.Critical }
// WorkflowPlan 编排清单(与产品 squad workflow.json 字段兼容)。
type WorkflowPlan struct {
WorkflowID string `json:"workflow_id"`
TaskKey string `json:"task_key,omitempty"` // 命中模板时为模板键
Title string `json:"title,omitempty"`
PlanningMode PlanningMode `json:"planning_mode,omitempty"`
Status string `json:"status"` // pending, in_progress, completed, failed, degraded, cancelled
CurrentStepIndex int `json:"current_step_index"`
Steps []StepSpec `json:"steps"`
}
// SquadConfig 激活的小队编制(与产品 squad.json 字段兼容)。
type SquadConfig struct {
SquadName string `json:"squad_name"`
Version string `json:"version"`
Agents []RoleSpec `json:"agents"`
}
// ---- 黑板 ----
// FactItem 黑板事实项。
type FactItem struct {
ID string `json:"id"`
Claim string `json:"claim"`
Source string `json:"source"`
Verified bool `json:"verified"`
}
// GapItem 黑板缺口项;Blocking=true 且未解决时触发 HITL 挂起。
// WaitRunID/WaitField 非空时,该缺口的 HITL 请求携带等待条件:
// 目标运行进入终态 / 黑板字段出现时自动唤醒续跑(等事件而非只等人)。
type GapItem struct {
ID string `json:"id"`
Item string `json:"item"`
Owner string `json:"owner"` // user / agent
Blocking bool `json:"blocking"` // 是否阻塞后续推进
Resolved bool `json:"resolved"`
WaitRunID string `json:"wait_run_id,omitempty"` // 等待指定运行到达终态
WaitField string `json:"wait_field,omitempty"` // 等待黑板出现该字段
}
// ImplicationItem 黑板推导项。
type ImplicationItem struct {
ID string `json:"id"`
Topic string `json:"topic"`
Statement string `json:"statement"`
}
// AssumptionItem 黑板假设项(auto HITL 模式下作为阻塞缺口的兜底假设来源)。
type AssumptionItem struct {
ID string `json:"id"`
Topic string `json:"topic"`
Statement string `json:"statement"`
Rationale string `json:"rationale"`
Source string `json:"source"` // 默认 inferred;HITL 兜底时为 fallback
}
// BlackboardState 结构化共享黑板(与产品 blackboard.json 字段兼容)。
type BlackboardState struct {
Facts []FactItem `json:"facts"`
Gaps []GapItem `json:"gaps"`
Implications []ImplicationItem `json:"implications"`
Assumptions []AssumptionItem `json:"assumptions,omitempty"`
Fields map[string]string `json:"fields"`
UpdatedAt time.Time `json:"updated_at"`
}
// ContextItem 工作区上下文资源条目。
type ContextItem struct {
File string `json:"file"`
Description string `json:"description"`
AuthorityLevel string `json:"authority_level"` // authoritative, primary_evidence, reference_standard, supplementary, inferred
Guidance string `json:"guidance"`
}
// ContextIndex 上下文索引。
type ContextIndex struct {
Items []ContextItem `json:"context_items"`
}
// QualityReport 交付检查报告。
type QualityReport struct {
Passed bool `json:"passed"`
Score float64 `json:"score"`
Violations []string `json:"violations,omitempty"`
MissingSections []string `json:"missing_sections,omitempty"`
Placeholders []string `json:"placeholders,omitempty"`
// ManualPass 为 true 时表示质检未过但经 HITL 人工放行。
ManualPass bool `json:"manual_pass,omitempty"`
CheckedAt time.Time `json:"checked_at"`
}
// Manifest 执行清单与防篡改审计。
type Manifest struct {
ManifestVersion string `json:"manifest_version"`
WorkspaceID string `json:"workspace_id"`
GeneratedAt time.Time `json:"generated_at"`
Deliverable string `json:"deliverable"`
Files map[string]string `json:"files"` // relPath -> sha256
QualityReport QualityReport `json:"quality_report"`
DurationMs int64 `json:"duration_ms"`
}
// RunMetadata 工作区元信息(workspace.json)。
type RunMetadata struct {
ID string `json:"id"`
Goal Goal `json:"goal"`
Status string `json:"status"`
Backend string `json:"backend,omitempty"` // 默认后端名(角色级 Backend 优先)
Planner string `json:"planner,omitempty"`
WorkDir string `json:"work_dir"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
}
// RunStatus 一次运行的对外状态(含 HITL 挂起态)。
type RunStatus string
const (
RunCreated RunStatus = "created"
RunPlanning RunStatus = "planning"
RunAwaitingClarification RunStatus = "awaiting_clarification" // HITL:规划歧义,等待澄清
RunAwaitingConfirmation RunStatus = "awaiting_confirmation" // HITL:计划待确认
RunExecuting RunStatus = "executing"
RunWaitingHuman RunStatus = "waiting_human" // HITL:执行中挂起(缺口/审批/权限/质检)
RunQualityReview RunStatus = "quality_review" // HITL:质检超限转人工
RunCompleted RunStatus = "completed"
RunDegraded RunStatus = "degraded"
RunFailed RunStatus = "failed"
RunCancelled RunStatus = "cancelled"
)
// IsHeld 返回该状态是否为 HITL 挂起态(等待人工回应)。
func (s RunStatus) IsHeld() bool {
return s == RunAwaitingClarification || s == RunAwaitingConfirmation ||
s == RunWaitingHuman || s == RunQualityReview
}
// Terminal 报告该状态是否为终态。
func (s RunStatus) Terminal() bool {
switch s {
case RunCompleted, RunDegraded, RunFailed, RunCancelled:
return true
}
return false
}
// RunResult 引擎单次推进的返回(Run/Respond/Resume 共用)。
type RunResult struct {
RunID string `json:"run_id"`
Status RunStatus `json:"status"`
Plan *WorkflowPlan `json:"plan,omitempty"`
Squad *SquadConfig `json:"squad,omitempty"`
Pending *HumanRequest `json:"pending,omitempty"` // 挂起时的人工请求
Deliverable string `json:"deliverable,omitempty"`
Quality *QualityReport `json:"quality,omitempty"`
Summary string `json:"summary,omitempty"`
Error string `json:"error,omitempty"`
WorkDir string `json:"work_dir,omitempty"`
// Usage 按角色聚合的模型用量(步骤执行结果累计)。
Usage map[string]Usage `json:"usage,omitempty"`
}
// Usage 单角色的 token 用量(多步骤累计)。
type Usage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
Steps int `json:"steps"`
}
+59
View File
@@ -0,0 +1,59 @@
package squad
import (
"context"
"fmt"
"time"
)
// Planner 规划器:把 Goal 翻译为小队编制 + 工作流,或在信息不足时请求澄清。
// 规划器只做规划,不执行;执行归 SquadEngine。
type Planner interface {
Name() string
Plan(ctx context.Context, input PlanInput) (*PlanOutput, error)
}
// PlanInput 规划输入。
type PlanInput struct {
Goal Goal
Blueprint *Blueprint // 命中模板时非 nil(基线步骤,规划器可微调)
Registry *RoleRegistry
RunID string
}
// PlanOutput 规划输出:计划或澄清请求二选一。
type PlanOutput struct {
// Clarification 非 nil 时表示目标信息不足,引擎进入 awaiting_clarification。
Clarification *Clarification
// Plan 编制 + 工作流。
Plan *SquadPlan
// NeedsConfirmation 为 true 时计划先进入 awaiting_confirmation。
NeedsConfirmation bool
// ConfirmReason 计划需要确认的原因(高风险/写操作/超预算等)。
ConfirmReason string
}
// SquadPlan 一次规划产物。
type SquadPlan struct {
Squad SquadConfig `json:"squad"`
Workflow WorkflowPlan `json:"workflow"`
Template string `json:"template,omitempty"` // 交付物骨架(蓝图携带或规划器生成)
}
// Clarification 澄清请求:问题 + 可选建议选项。
type Clarification struct {
Reason string `json:"reason"`
Questions []Question `json:"questions"`
}
// Question 单个澄清问题。
type Question struct {
ID string `json:"id"`
Text string `json:"text"`
Options []HumanOption `json:"options,omitempty"`
}
// NewPlanID 生成工作流 ID。
func NewPlanID() string {
return fmt.Sprintf("wf_%d", time.Now().UnixNano())
}
+289
View File
@@ -0,0 +1,289 @@
package squad
import (
"context"
"encoding/json"
"fmt"
"strings"
"agent-native/runtime/llm"
)
// LLMPlanner LLM 规划器:用模型理解目标 → 拆解任务 → 选/派角色 → 生成步骤。
// 模型客户端与默认模型在构造时注入(角色级模型在派生角色时由模型自行指定)。
// 输出为严格 JSON;解析失败时重试一次,仍失败则 fail-closed 返回错误。
type LLMPlanner struct {
Client *llm.Client
Model string
APIBase string
APIKey string
MaxTokens int
}
// NewLLMPlanner 创建 LLM 规划器。
func NewLLMPlanner(client *llm.Client, model string) *LLMPlanner {
return &LLMPlanner{Client: client, Model: model}
}
func (p *LLMPlanner) Name() string { return "llm" }
// llmPlanSchema 期望模型输出的 JSON 结构(宽松解析:仅取必要字段)。
type llmPlanSchema struct {
Clarify []llmQuestion `json:"clarify,omitempty"`
Confirm bool `json:"confirm,omitempty"`
Reason string `json:"confirm_reason,omitempty"`
Agents []llmAgent `json:"agents"`
Steps []llmStep `json:"steps"`
Template string `json:"template,omitempty"`
}
type llmQuestion struct {
ID string `json:"id,omitempty"`
Text string `json:"text"`
Options []HumanOption `json:"options,omitempty"`
}
type llmAgent struct {
Action string `json:"action"` // use | derive
Role string `json:"role"`
Name string `json:"name,omitempty"`
BaseRole string `json:"base_role,omitempty"`
Model string `json:"model,omitempty"`
Backend string `json:"backend,omitempty"`
DomainTopic string `json:"domain_topic,omitempty"`
Responsibilities []string `json:"responsibilities,omitempty"`
}
type llmStep struct {
ID string `json:"id"`
Role string `json:"role"`
Title string `json:"title"`
Parallel bool `json:"parallel,omitempty"`
Targets []string `json:"targets,omitempty"`
RequiresApproval bool `json:"requires_approval,omitempty"`
}
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":[...]})。派生数量尽量少。
5. steps: 每步 {"id":"step_01_x","role":"角色key","title":"步骤职责"};可并行的相邻步骤 parallel=true;高风险/破坏性/对外发布类步骤 requires_approval=true;步骤通常 2~6 个,含梳理、专业执行、汇总成文、终审把关。
6. 涉及写入、删除、对外提交或超出常规预算的计划 confirm=true。
7. 通用守则:缺失的信息若会实质改变结果、执行路径、权限或安全,必须 clarify 问人;否则自行决定并在计划中说明你决定了什么。严禁假设交付完成:无法覆盖的目标范围要如实体现在步骤或假设中,不许静默缩水。`
func (p *LLMPlanner) Plan(ctx context.Context, input PlanInput) (*PlanOutput, error) {
if p.Client == nil || p.Model == "" {
return nil, fmt.Errorf("llm planner requires client and model")
}
userMsg := p.buildPrompt(input)
var parsed *llmPlanSchema
var lastErr error
for attempt := 0; attempt < 2; attempt++ {
sys := llmPlannerSystem
if attempt > 0 {
sys += "\n注意:上一次输出无法解析为 JSON。这次只输出 JSON 对象本身。"
}
raw, err := p.complete(ctx, sys, userMsg)
if err != nil {
return nil, fmt.Errorf("llm planner call failed: %w", err)
}
schema, err := parseLLMPlan(raw)
if err != nil {
lastErr = err
continue
}
parsed = schema
break
}
if parsed == nil {
return nil, fmt.Errorf("llm planner output unparsable after retry: %w", lastErr)
}
if len(parsed.Clarify) > 0 && len(parsed.Agents) == 0 {
cl := &Clarification{Reason: "模型判定目标信息不足"}
for _, q := range parsed.Clarify {
cl.Questions = append(cl.Questions, Question{ID: q.ID, Text: q.Text, Options: q.Options})
}
return &PlanOutput{Clarification: cl}, nil
}
if len(parsed.Steps) == 0 {
return nil, fmt.Errorf("llm planner produced no steps")
}
return p.materialize(input, parsed)
}
// materialize 把模型输出落成 RoleSpec/StepSpec:use 校验存在,derive 走注册表派生。
func (p *LLMPlanner) materialize(input PlanInput, parsed *llmPlanSchema) (*PlanOutput, error) {
var agents []RoleSpec
addAgent := func(spec RoleSpec) {
for _, a := range agents {
if a.Role == spec.Role {
return
}
}
agents = append(agents, spec)
}
for _, la := range parsed.Agents {
switch strings.ToLower(la.Action) {
case "derive":
derived, err := input.Registry.Derive(DeriveRequest{
RoleKey: la.Role,
Name: la.Name,
BaseRole: la.BaseRole,
Backend: la.Backend,
Model: la.Model,
DomainTopic: la.DomainTopic,
Responsibilities: la.Responsibilities,
})
if err != nil {
return nil, fmt.Errorf("derive role %s: %w", la.Role, err)
}
addAgent(derived)
default: // use
spec, ok := input.Registry.Get(la.Role)
if !ok {
return nil, fmt.Errorf("plan references unregistered role %q (use derive action for new roles)", la.Role)
}
addAgent(spec)
}
}
steps := make([]StepSpec, 0, len(parsed.Steps))
for i, ls := range parsed.Steps {
if ls.Role == "" {
return nil, fmt.Errorf("step %d missing role", i)
}
if _, ok := input.Registry.Get(ls.Role); !ok {
return nil, fmt.Errorf("step %d references unregistered role %q", i, ls.Role)
}
if _, inSquad := findAgent(agents, ls.Role); !inSquad {
if spec, ok := input.Registry.Get(ls.Role); ok {
addAgent(spec)
}
}
id := ls.ID
if id == "" {
id = fmt.Sprintf("step_%02d", i+1)
}
targets := ls.Targets
if len(targets) == 0 {
targets = []string{"output.md"}
}
steps = append(steps, StepSpec{
ID: id, Role: ls.Role, Title: ls.Title, Parallel: ls.Parallel,
Targets: targets, RequiresApproval: ls.RequiresApproval, Status: "pending",
})
}
plan := &SquadPlan{
Squad: SquadConfig{SquadName: truncate(input.Goal.Text, 40), Version: "1", Agents: agents},
Workflow: WorkflowPlan{
WorkflowID: NewPlanID(),
Title: truncate(input.Goal.Text, 80),
PlanningMode: PlanModeAutonomous,
Status: "pending",
Steps: steps,
},
Template: parsed.Template,
}
return &PlanOutput{Plan: plan, NeedsConfirmation: parsed.Confirm, ConfirmReason: parsed.Reason}, nil
}
func (p *LLMPlanner) buildPrompt(input PlanInput) string {
var b strings.Builder
b.WriteString("## 用户目标\n")
b.WriteString(input.Goal.Text + "\n")
if len(input.Goal.Constraints) > 0 {
b.WriteString("\n## 约束\n")
for k, v := range input.Goal.Constraints {
fmt.Fprintf(&b, "- %s: %s\n", k, v)
}
}
if len(input.Goal.Materials) > 0 {
b.WriteString("\n## 输入材料(文件名)\n")
for _, m := range input.Goal.Materials {
fmt.Fprintf(&b, "- %s\n", m.Filename)
}
}
b.WriteString("\n## 已注册角色\n")
for _, r := range input.Registry.List() {
fmt.Fprintf(&b, "- key=%s name=%s backend=%s keywords=%v responsibilities=%v\n",
r.Role, r.Name, r.Backend, r.Keywords, r.Responsibilities)
}
if input.Blueprint != nil {
b.WriteString("\n## 命中任务模板(可作为基线微调)\n")
enc, _ := json.Marshal(input.Blueprint)
b.Write(enc)
b.WriteString("\n")
}
return b.String()
}
// complete 调一次模型并拼接完整文本。
func (p *LLMPlanner) complete(ctx context.Context, system, user string) (string, error) {
msgs := []llm.Message{
{Role: llm.RoleSystem, Content: system},
{Role: llm.RoleUser, Content: user},
}
opts := llm.StreamOptions{MaxTokens: p.MaxTokens, APIBase: p.APIBase, APIKey: p.APIKey}
var sb strings.Builder
for ev := range p.Client.Stream(ctx, p.Model, msgs, nil, opts) {
if ev.Kind == llm.KindError {
return "", fmt.Errorf("%s", ev.ErrorMessage)
}
if ev.Kind == llm.KindTextDelta {
sb.WriteString(ev.Text)
}
if ev.Kind == llm.KindMessageEnd {
if ev.Message.IsError {
return "", fmt.Errorf("assistant error: %s", ev.Message.Content)
}
if sb.Len() == 0 {
sb.WriteString(ev.Message.Content)
}
break
}
}
if ctx.Err() != nil {
return "", ctx.Err()
}
return sb.String(), nil
}
// parseLLMPlan 从模型输出稳健解析 JSON(剥离 think 块/代码围栏/前后杂文)。
func parseLLMPlan(raw string) (*llmPlanSchema, error) {
s := stripThinkBlocks(strings.TrimSpace(raw))
s = strings.TrimPrefix(s, "```json")
s = strings.TrimPrefix(s, "```")
s = strings.TrimSuffix(s, "```")
s = strings.TrimSpace(s)
// 截取首个 { 到最后一个 },容忍模型前后夹杂说明文字。
if i := strings.IndexByte(s, '{'); i >= 0 {
if j := strings.LastIndexByte(s, '}'); j > i {
s = s[i : j+1]
}
}
var out llmPlanSchema
if err := json.Unmarshal([]byte(s), &out); err != nil {
return nil, err
}
return &out, nil
}
// stripThinkBlocks 移除 MiniMax 等模型内嵌在 content 中的 <think>…</think> 块。
func stripThinkBlocks(s string) string {
for {
i := strings.Index(s, "<think>")
if i < 0 {
return s
}
j := strings.Index(s[i:], "</think>")
if j < 0 {
return s[:i] // 未闭合:丢弃思考余段
}
s = s[:i] + s[i+j+len("</think>"):]
}
}
+79
View File
@@ -0,0 +1,79 @@
package squad
import (
"context"
"fmt"
)
// MockPlanner 脚本化规划器:离线演示/测试用。
// Script 非空时按脚本步骤产出计划(不查注册表);否则退化为 RulePlanner。
type MockPlanner struct {
// Script 预置计划步骤(角色需调用方保证已注册)。
Script []StepSpec
// ClarifyReason 非空时总是请求澄清(测试 HITL 澄清路径)。
ClarifyReason string
// NeedsConfirm 强制计划进入确认。
NeedsConfirm bool
// ConfirmReason 确认原因文案。
ConfirmReason string
// Agents 编制;缺省时按脚本步骤收集角色。
Agents []RoleSpec
}
// NewMockPlanner 创建 mock 规划器。
func NewMockPlanner() *MockPlanner { return &MockPlanner{} }
func (p *MockPlanner) Name() string { return "mock" }
func (p *MockPlanner) Plan(ctx context.Context, input PlanInput) (*PlanOutput, error) {
if p.ClarifyReason != "" {
return &PlanOutput{Clarification: &Clarification{
Reason: p.ClarifyReason,
Questions: []Question{{
ID: "q_mock",
Text: p.ClarifyReason,
Options: []HumanOption{
{Value: "continue", Label: "按当前信息继续"},
{Value: "abort", Label: "中止"},
},
}},
}}, nil
}
if len(p.Script) == 0 {
return NewRulePlanner().Plan(ctx, input)
}
steps := make([]StepSpec, len(p.Script))
copy(steps, p.Script)
for i := range steps {
if steps[i].Targets == nil {
steps[i].Targets = []string{"output.md"}
}
steps[i].Status = "pending"
}
agents := p.Agents
if agents == nil {
seen := map[string]bool{}
for _, st := range steps {
if !seen[st.Role] {
if spec, ok := input.Registry.Get(st.Role); ok {
agents = append(agents, spec)
seen[st.Role] = true
}
}
}
}
return &PlanOutput{
Plan: &SquadPlan{
Squad: SquadConfig{SquadName: "mock_squad", Version: "1", Agents: agents},
Workflow: WorkflowPlan{
WorkflowID: NewPlanID(),
Title: fmt.Sprintf("mock plan for %s", truncate(input.Goal.Text, 40)),
PlanningMode: PlanModeAutonomous,
Status: "pending",
Steps: steps,
},
},
NeedsConfirmation: p.NeedsConfirm,
ConfirmReason: p.ConfirmReason,
}, nil
}
+180
View File
@@ -0,0 +1,180 @@
package squad
import (
"context"
"fmt"
"strings"
)
// RulePlanner 规则规划器(零模型依赖):
// - 命中蓝图:直接采用基线步骤/编制;
// - 未命中:若注册表为空则澄清;否则按通用四阶段骨架(梳理→专项→成文→终审)
// 组建小队:优先推荐匹配角色,不足时派生特设角色。
type RulePlanner struct{}
// NewRulePlanner 创建规则规划器。
func NewRulePlanner() *RulePlanner { return &RulePlanner{} }
func (p *RulePlanner) Name() string { return "rule" }
func (p *RulePlanner) Plan(ctx context.Context, input PlanInput) (*PlanOutput, error) {
if input.Blueprint != nil {
return p.fromBlueprint(input)
}
return p.autonomous(input)
}
func (p *RulePlanner) fromBlueprint(input PlanInput) (*PlanOutput, error) {
bp := *input.Blueprint
steps := make([]StepSpec, len(bp.Steps))
copy(steps, bp.Steps)
plan := &SquadPlan{
Squad: SquadConfig{
SquadName: bp.Title,
Version: "1",
Agents: append([]RoleSpec{}, bp.Agents...),
},
Workflow: WorkflowPlan{
WorkflowID: NewPlanID(),
TaskKey: bp.Key,
Title: bp.Title,
PlanningMode: PlanModeBlueprint,
Status: "pending",
Steps: steps,
},
Template: bp.Template,
}
// 蓝图内联角色并入注册表(引用已注册角色的声明以注册表为准)。
for _, a := range bp.Agents {
if _, exists := input.Registry.Get(a.Role); !exists {
if err := input.Registry.Register(a); err != nil {
return nil, err
}
}
}
return &PlanOutput{Plan: plan}, nil
}
func (p *RulePlanner) autonomous(input PlanInput) (*PlanOutput, error) {
goalText := strings.TrimSpace(input.Goal.Text)
if goalText == "" {
return nil, fmt.Errorf("goal text is empty")
}
roles := input.Registry.List()
if len(roles) == 0 {
// 空注册表无法组建小队:请求澄清(提示先注册角色)。
return &PlanOutput{Clarification: &Clarification{
Reason: "角色注册表为空:请先注册角色,或提供蓝图模板",
Questions: []Question{{
ID: "q_roles",
Text: "当前没有任何已注册角色。是否以内置最小编制(planner/writer/reviewer)执行该目标?",
Options: []HumanOption{
{Value: "minimal", Label: "使用最小编制(自动派生 3 个通用角色)"},
{Value: "abort", Label: "中止,我先注册角色"},
},
}},
}}, nil
}
// 通用四阶段骨架:梳理 → 专项 → 成文 → 终审(角色键与 minimal 引导一致)。
stages := []struct {
id string
title string
role string
}{
{"step_01_organizer", "梳理目标背景与输入材料,产出事实与缺口", "organizer"},
{"step_02_specialist", "围绕目标开展专业分析与推演", "specialist"},
{"step_03_writer", "汇总各角色产出,撰写交付物初稿", "writer"},
{"step_04_leader", "一致性终审与交付把关", "leader"},
}
var agents []RoleSpec
var steps []StepSpec
usedFallback := false
for i, st := range stages {
spec, ok := p.pickRole(input.Registry, goalText, st.id)
if !ok {
// 以推荐角色/首个角色为基线派生通用角色。
base := p.baseRole(input.Registry)
derived, err := input.Registry.Derive(DeriveRequest{
RoleKey: st.role,
Name: fmt.Sprintf("%s专员", st.role),
BaseRole: base.Role,
DomainTopic: goalText,
Responsibilities: []string{st.title},
})
if err != nil {
return nil, err
}
spec = derived
usedFallback = true
}
if _, seen := findAgent(agents, spec.Role); !seen {
agents = append(agents, spec)
}
targets := []string{"output.md"}
if i < len(stages)-1 {
targets = []string{"notes.md", "fields.json"}
}
steps = append(steps, StepSpec{
ID: st.id,
Role: spec.Role,
Title: st.title,
Targets: targets,
Status: "pending",
})
}
plan := &SquadPlan{
Squad: SquadConfig{SquadName: truncate(goalText, 40), Version: "1", Agents: agents},
Workflow: WorkflowPlan{
WorkflowID: NewPlanID(),
Title: truncate(goalText, 80),
PlanningMode: PlanModeAutonomous,
Status: "pending",
Steps: steps,
},
}
out := &PlanOutput{Plan: plan}
if usedFallback {
out.NeedsConfirmation = true
out.ConfirmReason = "注册表中缺少对应角色,计划包含现场派生的通用角色,请确认编制"
}
return out, nil
}
// pickRole 为阶段挑选角色:organizer/leader 按同名词或推荐,specialist 按推荐,writer 同名或推荐。
func (p *RulePlanner) pickRole(reg *RoleRegistry, goalText, stageID string) (RoleSpec, bool) {
if direct, ok := reg.Get(strings.SplitN(stageID, "_", 3)[2]); ok {
return direct, true
}
return reg.Recommend(goalText)
}
func (p *RulePlanner) baseRole(reg *RoleRegistry) RoleSpec {
if rec, ok := reg.Recommend(""); ok {
return rec
}
list := reg.List()
if len(list) > 0 {
return list[0]
}
return RoleSpec{Role: "base", Name: "base", BashPermission: "deny"}
}
func findAgent(agents []RoleSpec, role string) (RoleSpec, bool) {
for _, a := range agents {
if a.Role == role {
return a, true
}
}
return RoleSpec{}, false
}
func truncate(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n]) + "…"
}
+126
View File
@@ -0,0 +1,126 @@
package squad
import (
"encoding/json"
"sync"
"time"
)
// Event 类型常量(SSE 事件名与此一致)。
const (
EvRunStarted = "run_started"
EvPhase = "phase"
EvStepStarted = "step_started"
EvStepCompleted = "step_completed"
EvStepFailed = "step_failed"
EvStepSkipped = "step_skipped"
EvDetail = "detail" // 成员执行过程帧(agent-native ProcessEvent 投影)
EvDeliverable = "deliverable"
EvHumanInputRequired = "human_input_required"
EvHumanResponded = "human_responded"
EvRunCompleted = "run_completed"
EvRunFailed = "run_failed"
EvRunCancelled = "run_cancelled"
)
// Event 引擎对外事件(可 JSON 序列化,SSE 直接下发)。
type Event struct {
Type string `json:"type"`
RunID string `json:"run_id"`
StepID string `json:"step_id,omitempty"`
Role string `json:"role,omitempty"`
Text string `json:"text,omitempty"`
Data json.RawMessage `json:"data,omitempty"`
At time.Time `json:"at"`
}
// ProgressSink 事件出口;实现方必须非阻塞或自行缓冲。
type ProgressSink interface {
Emit(ev Event)
}
// ProgressFunc 函数式 ProgressSink。
type ProgressFunc func(ev Event)
func (f ProgressFunc) Emit(ev Event) { f(ev) }
// FanoutSink 多路分发 + 环形缓冲(server 层 SSE 重放用)。
type FanoutSink struct {
mu sync.Mutex
buffer []Event
subs map[int]chan Event
nextSub int
limit int
}
// NewFanoutSink 创建分发器;limit 为事件缓冲上限(超过丢弃最旧)。
func NewFanoutSink(limit int) *FanoutSink {
if limit <= 0 {
limit = 4096
}
return &FanoutSink{subs: make(map[int]chan Event), limit: limit}
}
// Emit 分发事件并写入缓冲。
func (s *FanoutSink) Emit(ev Event) {
if ev.At.IsZero() {
ev.At = time.Now()
}
s.mu.Lock()
defer s.mu.Unlock()
s.buffer = append(s.buffer, ev)
if len(s.buffer) > s.limit {
s.buffer = s.buffer[len(s.buffer)-s.limit:]
}
for _, ch := range s.subs {
select {
case ch <- ev:
default: // 慢消费者丢帧,不阻塞引擎
}
}
}
// Subscribe 订阅后续事件;cursor 为已消费的缓冲长度(0 = 从头重放)。
// 返回事件通道与取消函数。重放部分一次性送入通道后进入实时流。
func (s *FanoutSink) Subscribe(cursor int) (<-chan Event, int, func()) {
ch := make(chan Event, 256)
s.mu.Lock()
id := s.nextSub
s.nextSub++
s.subs[id] = ch
if cursor < 0 {
cursor = 0
}
if cursor > len(s.buffer) {
cursor = len(s.buffer)
}
replay := make([]Event, len(s.buffer)-cursor)
copy(replay, s.buffer[cursor:])
next := len(s.buffer)
s.mu.Unlock()
go func() {
for _, ev := range replay {
ch <- ev
}
}()
return ch, next, func() {
s.mu.Lock()
delete(s.subs, id)
s.mu.Unlock()
close(ch)
}
}
// Snapshot 返回缓冲快照与当前游标(重放起点)。
func (s *FanoutSink) Snapshot() ([]Event, int) {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]Event, len(s.buffer))
copy(out, s.buffer)
return out, len(s.buffer)
}
// NopSink 丢弃所有事件。
type NopSink struct{}
func (NopSink) Emit(Event) {}
+171
View File
@@ -0,0 +1,171 @@
package squad
import (
"fmt"
"sort"
"strings"
"sync"
)
// RoleRegistry 角色注册中心:空表起步,无任何预设角色。
// 线程安全;角色可来自配置文件、HTTP API、CLI 或规划器运行期派生。
type RoleRegistry struct {
mu sync.RWMutex
roles map[string]RoleSpec
// OnChange 可选钩子:角色注册/派生后回调(store 层用于落盘)。
OnChange func(spec RoleSpec)
}
// NewRoleRegistry 创建空注册表。
func NewRoleRegistry() *RoleRegistry {
return &RoleRegistry{roles: make(map[string]RoleSpec)}
}
// Register 注册或更新角色;Role 为空或 BashPermission 非法时返回错误。
func (r *RoleRegistry) Register(spec RoleSpec) error {
if strings.TrimSpace(spec.Role) == "" {
return fmt.Errorf("role key cannot be empty")
}
switch spec.BashPermission {
case "", "deny", "ask", "allow":
default:
return fmt.Errorf("bash_permission must be allow/ask/deny, got %q", spec.BashPermission)
}
if spec.BashPermission == "" {
spec.BashPermission = "deny"
}
if spec.Source == "" {
spec.Source = RoleSourceRegistered
}
if spec.Name == "" {
spec.Name = spec.Role
}
r.mu.Lock()
r.roles[spec.Role] = spec
r.mu.Unlock()
if r.OnChange != nil {
r.OnChange(spec)
}
return nil
}
// Get 按 Key 获取角色。
func (r *RoleRegistry) Get(key string) (RoleSpec, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
spec, ok := r.roles[key]
return spec, ok
}
// List 列出全部角色(按 Role 排序,输出稳定)。
func (r *RoleRegistry) List() []RoleSpec {
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]RoleSpec, 0, len(r.roles))
for _, spec := range r.roles {
out = append(out, spec)
}
sort.Slice(out, func(i, j int) bool { return out[i].Role < out[j].Role })
return out
}
// Remove 删除角色;不存在时返回 false。
func (r *RoleRegistry) Remove(key string) bool {
r.mu.Lock()
_, ok := r.roles[key]
if ok {
delete(r.roles, key)
}
r.mu.Unlock()
return ok
}
// Recommend 按查询文本推荐最贴切角色:Keywords 命中计 10 分/词,
// Responsibilities 命中计 5 分;无命中返回 false(不猜测兜底角色)。
func (r *RoleRegistry) Recommend(query string) (RoleSpec, bool) {
q := strings.ToLower(query)
var best RoleSpec
bestScore := 0
for _, spec := range r.List() {
score := 0
for _, kw := range spec.Keywords {
if kw != "" && strings.Contains(q, strings.ToLower(kw)) {
score += 10
}
}
for _, resp := range spec.Responsibilities {
if resp != "" && strings.Contains(q, strings.ToLower(resp)) {
score += 5
}
}
if score > bestScore {
best, bestScore = spec, score
}
}
return best, bestScore > 0
}
// DeriveRequest 派生角色请求:以 BaseRole 为基线叠加领域特化。
type DeriveRequest struct {
RoleKey string `json:"role_key"`
Name string `json:"name,omitempty"`
BaseRole string `json:"base_role,omitempty"`
Backend string `json:"backend,omitempty"`
Model string `json:"model,omitempty"`
DomainTopic string `json:"domain_topic,omitempty"`
Responsibilities []string `json:"responsibilities,omitempty"`
ForbiddenActions []string `json:"forbidden_actions,omitempty"`
Instructions string `json:"instructions,omitempty"`
}
// Derive 派生特设角色:继承基线角色的 Backend/Model/Tools/指令,
// 叠加领域职责与禁忌;BaseRole 缺省取注册表中第一个角色(无角色则内联最小基线)。
func (r *RoleRegistry) Derive(req DeriveRequest) (RoleSpec, error) {
if strings.TrimSpace(req.RoleKey) == "" {
return RoleSpec{}, fmt.Errorf("role_key cannot be empty")
}
base, hasBase := r.Get(req.BaseRole)
if !hasBase {
list := r.List()
if len(list) > 0 {
base = list[0]
}
}
spec := RoleSpec{
Role: req.RoleKey,
Name: req.Name,
Source: RoleSourceDerived,
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,
Responsibilities: append([]string{}, req.Responsibilities...),
ForbiddenActions: append([]string{}, req.ForbiddenActions...),
Instructions: firstNonEmpty(req.Instructions, base.Instructions),
InstructionsFile: base.InstructionsFile,
}
if spec.Name == "" {
spec.Name = fmt.Sprintf("%s专员", req.RoleKey)
}
if len(spec.Responsibilities) == 0 && req.DomainTopic != "" {
spec.Responsibilities = append(spec.Responsibilities, fmt.Sprintf("专责推演与论证【%s】", req.DomainTopic))
}
spec.ForbiddenActions = append(spec.ForbiddenActions,
"严禁违规越权执行未经规划的破坏性命令", "严禁直接代用户做终审拍板")
if err := r.Register(spec); err != nil {
return RoleSpec{}, err
}
return spec, nil
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if v != "" {
return v
}
}
return ""
}
+103
View File
@@ -0,0 +1,103 @@
package squad
import (
"strings"
"testing"
)
func TestRoleRegistryEmptyByDefault(t *testing.T) {
r := NewRoleRegistry()
if roles := r.List(); len(roles) != 0 {
t.Fatalf("registry must start empty, got %d roles (no preset roles allowed)", len(roles))
}
if _, ok := r.Recommend("法规调研"); ok {
t.Fatal("empty registry must not recommend")
}
}
func TestRoleRegistryRegisterAndRecommend(t *testing.T) {
r := NewRoleRegistry()
if err := r.Register(RoleSpec{Role: "researcher", Name: "调研", Keywords: []string{"调研", "市场"}, Backend: "stub"}); err != nil {
t.Fatal(err)
}
if err := r.Register(RoleSpec{Role: "writer", Name: "撰写", Keywords: []string{"撰写"}, Responsibilities: []string{"报告成文"}}); err != nil {
t.Fatal(err)
}
if err := r.Register(RoleSpec{Role: "bad", BashPermission: "sometimes"}); err == nil {
t.Fatal("invalid bash_permission must fail")
}
spec, ok := r.Recommend("帮我调研市场情况")
if !ok || spec.Role != "researcher" {
t.Fatalf("recommend want researcher, got %+v ok=%v", spec, ok)
}
if spec.BashPermission != "deny" {
t.Fatalf("default bash_permission want deny, got %q", spec.BashPermission)
}
if !r.Remove("writer") {
t.Fatal("remove writer failed")
}
if r.Remove("writer") {
t.Fatal("double remove should fail")
}
}
func TestRoleRegistryDerive(t *testing.T) {
r := NewRoleRegistry()
_ = r.Register(RoleSpec{Role: "engineer", Name: "工程师", Backend: "agent-native", Model: "m1",
Keywords: []string{"工程"}, Instructions: "基线指令"})
derived, err := r.Derive(DeriveRequest{RoleKey: "gmp_expert", DomainTopic: "GMP 洁净车间"})
if err != nil {
t.Fatal(err)
}
if derived.Source != RoleSourceDerived || derived.BaseRole != "engineer" {
t.Fatalf("derived role meta wrong: %+v", derived)
}
if derived.Backend != "agent-native" || derived.Model != "m1" || derived.Instructions != "基线指令" {
t.Fatalf("derive must inherit backend/model/instructions: %+v", derived)
}
if !strings.Contains(strings.Join(derived.Responsibilities, ";"), "GMP") {
t.Fatalf("domain topic not in responsibilities: %v", derived.Responsibilities)
}
if len(derived.ForbiddenActions) == 0 {
t.Fatal("derive must append guardrail forbidden actions")
}
}
func TestBlueprintRegistryMatch(t *testing.T) {
b := NewBlueprintRegistry()
if err := b.Register(Blueprint{Key: "x", Steps: nil}); err == nil {
t.Fatal("blueprint without steps must fail")
}
_ = b.Register(Blueprint{
Key: "report", Title: "报告", Match: []string{"报告", "调研"},
Steps: []StepSpec{{ID: "s1", Role: "writer", Title: "成文"}},
})
if bp, ok := b.Match("我要一份调研报告", ""); !ok || bp.Key != "report" {
t.Fatalf("match failed: %+v ok=%v", bp, ok)
}
if _, ok := b.Match("无关文本", ""); ok {
t.Fatal("unrelated text must not match")
}
if bp, ok := b.Match("", "report"); !ok || bp.Key != "report" {
t.Fatal("explicit task key must match")
}
// 步骤默认 ID/Targets 补全。
got, _ := b.Get("report")
if got.Steps[0].Targets[0] != "output.md" {
t.Fatalf("default targets missing: %+v", got.Steps[0])
}
}
func TestHitlPolicyMergeAndStates(t *testing.T) {
merged := mergeHitlPolicy(HitlPolicy{Mode: HitlInteractive},
&HitlPolicy{Mode: HitlAuto}, &HitlPolicy{TimeoutAfter: 5e9, ExpireAction: "cancel"})
if merged.Mode != HitlAuto || merged.TimeoutAfter != 5e9 || merged.ExpireAction != "cancel" {
t.Fatalf("merge wrong: %+v", merged)
}
if !RunAwaitingClarification.IsHeld() || !RunQualityReview.IsHeld() || RunExecuting.IsHeld() {
t.Fatal("held state classification wrong")
}
if !RunCompleted.Terminal() || RunWaitingHuman.Terminal() {
t.Fatal("terminal state classification wrong")
}
}
+215
View File
@@ -0,0 +1,215 @@
package squad
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
// runState 引擎内部运行状态(磁盘即权威:workspace.json / workflow.json /
// squad.json / blackboard.json / pending.json 全部落盘,进程重启可恢复)。
type runState struct {
Meta RunMetadata `json:"meta"`
Plan SquadPlan `json:"plan"`
BB BlackboardState `json:"bb"`
Template string `json:"template,omitempty"`
// Approved 运行内一次性授权(permission/confirmation),键为 role:/step: 前缀。
Approved map[string]bool `json:"approved,omitempty"`
// RepairBudgetRemaining 质检失败后的自动重跑剩余次数。
RepairBudgetRemaining int `json:"repair_budget_remaining,omitempty"`
// Clarifications 已澄清问答(追加进 Goal 上下文)。
Clarifications []string `json:"clarifications,omitempty"`
// Steering 运行中人工补充指令(在下一步骤边界注入后清空)。
Steering []string `json:"steering,omitempty"`
// Usage 按角色累计的 token 用量。
Usage map[string]*Usage `json:"usage,omitempty"`
// Leader leader-delegation 模式的循环状态(workspace 内 leader.json 持久化)。
Leader *LeaderState `json:"-"`
// mu 保护并行步骤对共享状态(黑板/save/steering/usage)的变更。
mu sync.Mutex `json:"-"`
}
func (st *runState) policy() HitlPolicy {
return mergeHitlPolicy(HitlPolicy{}, st.Meta.HitlPolicyOf())
}
// HitlPolicyOf 从 Goal 推导策略(Goal 覆盖全局)。
func (m RunMetadata) HitlPolicyOf() *HitlPolicy {
p := &HitlPolicy{}
if m.Goal.HitlMode != "" {
p.Mode = m.Goal.HitlMode
}
if m.Goal.HitlTimeout != 0 {
p.TimeoutAfter = m.Goal.HitlTimeout
}
return p
}
func (st *runState) save() error {
st.Meta.UpdatedAt = time.Now()
if err := writeJSON(filepath.Join(st.Meta.WorkDir, "workspace.json"), &st.Meta); err != nil {
return err
}
if err := writeJSON(filepath.Join(st.Meta.WorkDir, "workflow.json"), st.Plan.Workflow); err != nil {
return err
}
if err := writeJSON(filepath.Join(st.Meta.WorkDir, "squad.json"), &st.Plan.Squad); err != nil {
return err
}
if err := saveBlackboard(st.Meta.WorkDir, &st.BB); err != nil {
return err
}
if len(st.Usage) > 0 {
if err := writeJSON(filepath.Join(st.Meta.WorkDir, "usage.json"), st.Usage); err != nil {
return err
}
}
return nil
}
// Workflow 返回计划的编排清单(简洁访问器)。
func (st *runState) Workflow() WorkflowPlan { return st.Plan.Workflow }
// setWorkflow 写回编排清单。
func (st *runState) setWorkflow(wf WorkflowPlan) { st.Plan.Workflow = wf }
// saveGate 落盘 HITL 挂起上下文(pending.json)。
func (st *runState) saveGate(gate *hitlGate) error {
if gate == nil {
err := os.Remove(filepath.Join(st.Meta.WorkDir, "pending.json"))
if os.IsNotExist(err) {
return nil
}
return err
}
return writeJSON(filepath.Join(st.Meta.WorkDir, "pending.json"), gate)
}
// loadRunState 从工作区恢复运行状态。
func loadRunState(wsDir string) (*runState, error) {
st := &runState{}
if err := readJSON(filepath.Join(wsDir, "workspace.json"), &st.Meta); err != nil {
return nil, fmt.Errorf("load workspace.json: %w", err)
}
if err := readJSON(filepath.Join(wsDir, "workflow.json"), &st.Plan.Workflow); err != nil {
return nil, fmt.Errorf("load workflow.json: %w", err)
}
if err := readJSON(filepath.Join(wsDir, "squad.json"), &st.Plan.Squad); err != nil {
return nil, fmt.Errorf("load squad.json: %w", err)
}
bb, err := loadBlackboard(wsDir)
if err != nil {
return nil, err
}
st.BB = *bb
if data, err := os.ReadFile(filepath.Join(wsDir, "template.md")); err == nil {
st.Template = string(data)
}
if st.Approved == nil {
st.Approved = map[string]bool{}
}
st.loadSteering()
if data, err := os.ReadFile(filepath.Join(wsDir, "usage.json")); err == nil {
_ = json.Unmarshal(data, &st.Usage)
}
return st, nil
}
// loadGate 恢复挂起上下文;无挂起返回 nil。
func loadGate(wsDir string) (*hitlGate, error) {
data, err := os.ReadFile(filepath.Join(wsDir, "pending.json"))
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
gate := &hitlGate{}
if err := json.Unmarshal(data, gate); err != nil {
return nil, fmt.Errorf("parse pending.json: %w", err)
}
return gate, nil
}
func readJSON(path string, v any) error {
data, err := os.ReadFile(path)
if err != nil {
return err
}
return json.Unmarshal(data, v)
}
// setStep 并行安全地回填步骤状态(与 save 的全量 marshal 互斥)。
func (st *runState) setStep(idx int, s StepSpec) {
st.mu.Lock()
st.Plan.Workflow.Steps[idx] = s
st.mu.Unlock()
}
// injectSteering 把挂起的运行中人工补充指令追加到 prompt(下一步骤边界注入),
// 注入后清空并落盘。注入发生在步骤开始前,不会污染正在执行的步骤。
func (st *runState) injectSteering(prompt string) string {
st.mu.Lock()
defer st.mu.Unlock()
if len(st.Steering) == 0 {
return prompt
}
var b strings.Builder
b.WriteString(prompt)
b.WriteString("\n\n【运行中人工补充指令】以下补充来自人工,作用于当前进行中的运行(非新任务)。")
b.WriteString("请把它当作当前任务的补充要求继续执行;仅当人工明确要求放弃原目标时才替换原目标,且不要单独回复确认。\n")
for _, s := range st.Steering {
b.WriteString("- " + s + "\n")
}
st.Steering = nil
_ = writeJSON(filepath.Join(st.Meta.WorkDir, "steering.json"), st.Steering)
return b.String()
}
// loadSteering 恢复未消费的补充指令。
func (st *runState) loadSteering() {
data, err := os.ReadFile(filepath.Join(st.Meta.WorkDir, "steering.json"))
if err != nil {
return
}
var pending []string
if json.Unmarshal(data, &pending) == nil && len(pending) > 0 {
st.Steering = pending
}
}
// addUsage 累计角色用量。
func (st *runState) addUsage(role string, promptTokens, completionTokens int) {
if promptTokens == 0 && completionTokens == 0 {
return
}
st.mu.Lock()
defer st.mu.Unlock()
if st.Usage == nil {
st.Usage = map[string]*Usage{}
}
u, ok := st.Usage[role]
if !ok {
u = &Usage{}
st.Usage[role] = u
}
u.PromptTokens += promptTokens
u.CompletionTokens += completionTokens
u.Steps++
}
// usageSnapshot 输出用量快照(值拷贝)。
func (st *runState) usageSnapshot() map[string]Usage {
if len(st.Usage) == 0 {
return nil
}
out := make(map[string]Usage, len(st.Usage))
for k, v := range st.Usage {
out[k] = *v
}
return out
}
+187
View File
@@ -0,0 +1,187 @@
package squad
import (
"context"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"agent-native/exec"
)
// recordingBackend 记录每次收到的 prompt(按目录),产出按 script。
type recordingBackend struct {
mu sync.Mutex
prompts map[string][]string
script map[string]map[string]string
tokens bool
}
func (b *recordingBackend) Name() string { return "rec" }
func (b *recordingBackend) Execute(ctx context.Context, req exec.Request) (*exec.Result, error) {
key := filepath.Base(filepath.Clean(req.WorkDir))
b.mu.Lock()
b.prompts[key] = append(b.prompts[key], req.Prompt)
b.mu.Unlock()
files := b.script[key]
patches := map[string][]byte{}
for _, t := range req.Targets {
content := files[t]
if content == "" {
content = "\n"
}
out := filepath.Join(req.WorkDir, t)
_ = os.MkdirAll(filepath.Dir(out), 0o755)
_ = os.WriteFile(out, []byte(content), 0o644)
patches[t] = []byte(content)
}
pt, ct := 0, 0
if b.tokens {
pt, ct = 120, 80
}
return &exec.Result{Success: true, Patches: patches, PromptTokens: pt, CompletionTokens: ct}, nil
}
func TestSteeringInjectedAtNextStep(t *testing.T) {
// 步骤1 产生非阻塞挂起点:用 gap 挂起,steer 后 respond → 步骤2 收到注入。
rec := &recordingBackend{prompts: map[string][]string{}, script: map[string]map[string]string{
"01_researcher": {"notes.md": "n", "fields.json": `{}`,
"gaps.json": `[{"item":"缺信息","blocking":true}]`},
"02_writer": {"output.md": goodReport},
"03_reviewer": {"output.md": goodReport},
}}
dir := t.TempDir()
reg := NewRoleRegistry()
for _, r := range []string{"researcher", "writer", "reviewer"} {
_ = reg.Register(RoleSpec{Role: r, Backend: "rec"})
}
backends := NewBackendResolver()
backends.RegisterInstance("rec", rec)
engine, _ := NewEngine(Options{DataDir: dir, Registry: reg,
Planner: mockScriptPlanner(gapSteps()...), Backends: backends, Sink: NopSink{}})
res, _ := engine.Run(context.Background(), Goal{Text: "g"}, RunOptions{})
if res.Status != RunWaitingHuman {
t.Fatalf("want held for steering window, got %s", res.Status)
}
if err := engine.Steer(res.RunID, "补充:结论部分要加一节成本分析"); err != nil {
t.Fatal(err)
}
// 终态运行拒绝 steer。
if err := engine.Steer("run_nope", "x"); err == nil {
t.Fatal("steer unknown run must fail")
}
res2, err := engine.Respond(context.Background(), res.RunID, RespondDecision{RequestID: res.Pending.ID, Choice: "resolved", Answer: "ok"})
if err != nil {
t.Fatal(err)
}
if res2.Status != RunCompleted {
t.Fatalf("want completed, got %s err=%s", res2.Status, res2.Error)
}
rec.mu.Lock()
writerPrompts := rec.prompts["02_writer"]
reviewerPrompts := rec.prompts["03_reviewer"]
rec.mu.Unlock()
if len(writerPrompts) == 0 || !strings.Contains(writerPrompts[0], "成本分析") {
t.Fatalf("steering not injected into next step: %q", truncate(writerPrompts[0], 100))
}
if len(reviewerPrompts) > 0 && strings.Contains(reviewerPrompts[0], "成本分析") {
t.Fatalf("steering must be consumed once, leaked to later step")
}
}
func TestUsageAggregatedPerRole(t *testing.T) {
rec := &recordingBackend{prompts: map[string][]string{}, tokens: true,
script: map[string]map[string]string{
"01_researcher": {"notes.md": "n", "fields.json": `{}`},
"02_writer": {"output.md": goodReport},
"03_reviewer": {"output.md": goodReport},
}}
dir := t.TempDir()
reg := NewRoleRegistry()
for _, r := range []string{"researcher", "writer", "reviewer"} {
_ = reg.Register(RoleSpec{Role: r, Backend: "rec"})
}
backends := NewBackendResolver()
backends.RegisterInstance("rec", rec)
engine, _ := NewEngine(Options{DataDir: dir, Registry: reg,
Planner: mockScriptPlanner(baseSteps()...), Backends: backends, Sink: NopSink{}})
res, err := engine.Run(context.Background(), Goal{Text: "g"}, RunOptions{})
if err != nil {
t.Fatal(err)
}
if res.Status != RunCompleted {
t.Fatalf("want completed, got %s", res.Status)
}
if res.Usage == nil || res.Usage["writer"].PromptTokens != 120 || res.Usage["writer"].Steps != 1 {
t.Fatalf("usage wrong: %+v", res.Usage)
}
// usage 持久化可恢复。
data, err := os.ReadFile(filepath.Join(res.WorkDir, "usage.json"))
if err != nil || !strings.Contains(string(data), "researcher") {
t.Fatalf("usage.json missing: %v", err)
}
}
// slowCountBackend 统计并发峰值(验证角色级并发上限)。
type slowCountBackend struct {
mu sync.Mutex
cur int
peak int
delay time.Duration
}
func (b *slowCountBackend) Name() string { return "slow" }
func (b *slowCountBackend) Execute(ctx context.Context, req exec.Request) (*exec.Result, error) {
b.mu.Lock()
b.cur++
if b.cur > b.peak {
b.peak = b.cur
}
b.mu.Unlock()
time.Sleep(b.delay)
b.mu.Lock()
b.cur--
b.mu.Unlock()
for _, t := range req.Targets {
out := filepath.Join(req.WorkDir, t)
_ = os.MkdirAll(filepath.Dir(out), 0o755)
_ = os.WriteFile(out, []byte(goodReport), 0o644)
}
return &exec.Result{Success: true, Patches: map[string][]byte{}}, nil
}
func TestRoleConcurrencyLimit(t *testing.T) {
// 两个并行步骤同角色,MaxConcurrentTasks=1 → 串行(峰值 1)。
slow := &slowCountBackend{delay: 80 * time.Millisecond}
dir := t.TempDir()
reg := NewRoleRegistry()
_ = reg.Register(RoleSpec{Role: "writer", Backend: "slow", MaxConcurrentTasks: 1})
backends := NewBackendResolver()
backends.RegisterInstance("slow", slow)
engine, _ := NewEngine(Options{DataDir: dir, Registry: reg,
Planner: mockScriptPlanner([]StepSpec{
{ID: "s1", Role: "writer", Title: "A", Parallel: true, Targets: []string{"a.md"}},
{ID: "s2", Role: "writer", Title: "B", Parallel: true, Targets: []string{"b.md"}},
}...), Backends: backends, Sink: ProgressFunc(func(ev Event) {
t.Logf("EVENT %s step=%s text=%q", ev.Type, ev.StepID, ev.Text)
})})
res, err := engine.Run(context.Background(), Goal{Text: "g"}, RunOptions{})
if err != nil {
t.Fatal(err)
}
if res.Status != RunCompleted {
t.Fatalf("want completed, got %s err=%s", res.Status, res.Error)
}
slow.mu.Lock()
peak := slow.peak
slow.mu.Unlock()
if peak != 1 {
t.Fatalf("role concurrency peak want 1, got %d", peak)
}
}
+35
View File
@@ -0,0 +1,35 @@
package squad
import (
"strings"
"testing"
)
// stripThinkBlocks/parseLLMPlan 处理 MiniMax 等模型把思考内嵌为 content 中的
// <think>…</think>(而非 reasoning_content 字段)的行为。
func TestStripThinkBlocks(t *testing.T) {
in := "<think>计划:\n先输出 {\"a\":1} 试试\n</think>{\"a\":1,\"agents\":[]}"
out := stripThinkBlocks(in)
if strings.Contains(out, "think") || strings.Contains(out, "计划") {
t.Errorf("think block not stripped: %q", out)
}
if !strings.HasPrefix(out, "{\"a\"") {
t.Errorf("json broken: %q", out)
}
// 未闭合:丢弃思考余段。
if got := stripThinkBlocks("<think>未写完"); got != "" {
t.Errorf("unclosed think want dropped, got %q", got)
}
}
func TestParseLLMPlanWithThinkAndFence(t *testing.T) {
raw := "<think>用户要报告,我需要拆步骤 {\"fake\":true}</think>\n```json\n{\"agents\":[{\"action\":\"use\",\"role\":\"writer\"}],\"steps\":[{\"id\":\"s1\",\"role\":\"writer\",\"title\":\"成文\"}]}\n```"
out, err := parseLLMPlan(raw)
if err != nil {
t.Fatal(err)
}
if len(out.Agents) != 1 || out.Agents[0].Role != "writer" || len(out.Steps) != 1 {
t.Fatalf("parse wrong: %+v", out)
}
}
+746
View File
@@ -0,0 +1,746 @@
package squad
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
"agent-native/exec"
)
// flowDriver 工作流驱动器:推进 runState 直到终态或 HITL 挂起边界。
type flowDriver struct {
opts engineDeps
}
// advance 执行到边界;返回 (结果, 挂起上下文),二者互斥非 nil(infra 错误时都为 nil 且 err 非 nil)。
func (d *flowDriver) advance(ctx context.Context, st *runState) (res *RunResult, gate *hitlGate, err error) {
wf := st.Workflow()
wf.Status = "in_progress"
st.setWorkflow(wf)
d.emit(st, Event{Type: EvPhase, Text: "开始执行工作流"})
i := 0
for i < len(st.Plan.Workflow.Steps) {
step := st.Plan.Workflow.Steps[i]
switch step.Status {
case "completed", "skipped", "failed":
i++
continue
}
// 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() {
case HitlDisabled, HitlAuto:
step.Error = "permission denied: role " + role.Role + " requires bash approval (hitl mode=" + string(d.policy(st).mode()) + ")"
st.Plan.Workflow.Steps[i] = step
return d.failStep(ctx, st, i)
default:
return d.hold(st, HitlPermission, step.ID, step.Role,
fmt.Sprintf("角色 %s(%s)声明 bash_permission=ask,是否授权其在本次运行中使用更高权限?", role.Name, role.Role),
[]HumanOption{
{Value: "approve", Label: "授权本次运行"},
{Value: "reject", Label: "拒绝,步骤失败"},
{Value: "abort", Label: "中止运行"},
},
fmt.Sprintf("步骤:%s\n角色:%s\n禁忌:%v", step.Title, role.Role, role.ForbiddenActions), nil)
}
}
// HITL:步骤显式要求审批。
if step.RequiresApproval && !st.Approved["step:"+step.ID] {
switch d.policy(st).mode() {
case HitlDisabled, HitlAuto:
step.Error = "approval required but hitl mode is " + string(d.policy(st).mode())
st.Plan.Workflow.Steps[i] = step
return d.failStep(ctx, st, i)
default:
return d.hold(st, HitlConfirmation, step.ID, step.Role,
fmt.Sprintf("步骤「%s」标记为需人工审批后才能执行,是否批准?", step.Title),
[]HumanOption{
{Value: "approve", Label: "批准执行"},
{Value: "skip", Label: "跳过该步骤"},
{Value: "abort", Label: "中止运行"},
},
fmt.Sprintf("角色:%s\n产出:%v", step.Role, step.Targets), nil)
}
}
if step.Parallel {
// 收集相邻并行组,组内并发、组间顺序。
var group []int
for i < len(st.Plan.Workflow.Steps) {
s := st.Plan.Workflow.Steps[i]
if s.Parallel && s.Status != "completed" && s.Status != "skipped" && s.Status != "failed" {
group = append(group, i)
i++
continue
}
break
}
if out, g, err := d.runParallel(ctx, st, group); out != nil || g != nil || err != nil {
if out == nil && g != nil {
out = heldResult(st, g)
}
return out, g, err
}
if g2 := d.checkBlockingGaps(st); g2 != nil {
return heldResult(st, g2), g2, nil
}
continue
}
out, g, err := d.runOneStep(ctx, st, i)
if out != nil || g != nil || err != nil {
if out == nil && g != nil {
out = heldResult(st, g)
}
return out, g, err
}
i++
// 阻塞缺口挂起。
if g2 := d.checkBlockingGaps(st); g2 != nil {
return heldResult(st, g2), g2, nil
}
// 动态调整(仅顺序步骤后,避免并发竞态)。
if st.Plan.Workflow.Steps[i-1].AllowDynamicAdjustment && d.opts.Evaluator != nil {
out, g, err := d.applyDynamicAdjustment(ctx, st, i-1)
if out != nil || g != nil || err != nil {
if out == nil && g != nil {
out = heldResult(st, g)
}
return out, g, err
}
}
}
return d.finalize(ctx, st)
}
// runParallel 并发执行一个并行组;关键步骤失败即终止整个运行。
func (d *flowDriver) runParallel(ctx context.Context, st *runState, idxs []int) (*RunResult, *hitlGate, error) {
var wg sync.WaitGroup
var mu sync.Mutex
failedCritical := -1
for _, idx := range idxs {
wg.Add(1)
go func(idx int) {
defer wg.Done()
out, _, err := d.runOneStep(ctx, st, idx)
mu.Lock()
defer mu.Unlock()
if (out != nil && out.Status == RunFailed) || err != nil {
if st.Plan.Workflow.Steps[idx].IsCritical() && failedCritical < 0 {
failedCritical = idx
}
}
}(idx)
}
wg.Wait()
if failedCritical >= 0 {
return d.failStep(ctx, st, failedCritical)
}
return nil, nil, nil
}
// runOneStep 执行单个步骤(含瞬态重试与交付物校验)。
func (d *flowDriver) runOneStep(ctx context.Context, st *runState, idx int) (*RunResult, *hitlGate, error) {
step := st.Plan.Workflow.Steps[idx]
role, ok := findAgent(st.Plan.Squad.Agents, step.Role)
if !ok {
if r, exists := d.opts.Registry.Get(step.Role); exists {
role = r
st.mu.Lock()
st.Plan.Squad.Agents = append(st.Plan.Squad.Agents, r)
st.mu.Unlock()
} else {
step.Status, step.Error = "failed", "role not found: "+step.Role
st.setStep(idx, step)
return d.failStep(ctx, st, idx)
}
}
subDir := filepath.Join(st.Meta.WorkDir, filepath.FromSlash(orDefault(step.SubWorkspace, fmt.Sprintf("tasks/%02d_%s", idx+1, step.Role))))
if err := os.MkdirAll(subDir, 0o755); err != nil {
return nil, nil, err
}
if step.SubWorkspace == "" {
step.SubWorkspace = filepath.ToSlash(filepath.Join("tasks", fmt.Sprintf("%02d_%s", idx+1, step.Role)))
}
d.emit(st, Event{Type: EvStepStarted, StepID: step.ID, Role: step.Role, Text: step.Title})
step.Status = "running"
step.Error = ""
st.setStep(idx, step)
started := time.Now()
prompt := readFileOr(filepath.Join(subDir, "TASK.md"), step.Title)
prompt = st.injectSteering(prompt)
instructions := readFileOr(filepath.Join(subDir, "AGENTS.md"), d.opts.Workspaces.ResolveInstructions(role))
backend, backendName, err := d.opts.Backends.Resolve(role, st.Meta.Backend)
if err != nil {
step.Status, step.Error = "failed", err.Error()
st.setStep(idx, step)
return d.failStep(ctx, st, idx)
}
targets := step.Targets
if len(targets) == 0 {
targets = []string{"output.md"}
}
var result *exec.Result
for attempt := 1; attempt <= exec.MaxTransientStepInvocations; attempt++ {
reqPrompt := prompt
if attempt > 1 {
prevReason := "交付物缺失"
if err != nil {
prevReason = err.Error()
} else if result != nil && result.ErrorReason != "" {
prevReason = result.ErrorReason
}
reqPrompt += "\n\n【重试提示】上一次执行未成功(" + prevReason + ")。请务必把结果写入:" + strings.Join(targets, ", ")
}
req := exec.Request{
WorkDir: subDir,
Prompt: reqPrompt,
Instructions: instructions,
Targets: targets,
Timeout: d.opts.StepTimeout,
ToolProfile: orDefault(role.ToolProfile, exec.ToolProfileWorkspace),
OnEvent: func(ev exec.ProcessEvent) {
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})})
},
}
result, err = backend.Execute(ctx, req)
if err == nil && result.Success {
break
}
// 交付物物化:后端返回 patches 但未写盘时补写。
if err == nil && len(result.Patches) > 0 {
if _, merr := exec.MaterializeTargets(subDir, targets, result.Patches); merr == nil {
if allTargetsPresent(subDir, targets) {
result.Success = true
break
}
}
}
assessment := exec.AssessStepFailure(exec.StepFailureSignals{
Err: err, Result: result, WorkDir: subDir,
ExtraText: fmt.Sprintf("step=%s backend=%s", step.ID, backendName),
TargetMissing: !allTargetsPresent(subDir, targets),
})
if assessment.Class != exec.FailureTransient || attempt == exec.MaxTransientStepInvocations {
step.Status = "failed"
if err != nil {
step.Error = err.Error()
} else {
step.Error = orDefault(assessment.Reason, result.ErrorReason)
}
break
}
}
if ctx.Err() != nil {
step.Status = "pending" // 取消后可恢复
st.setStep(idx, step)
return &RunResult{RunID: st.Meta.ID, Status: RunCancelled, Error: "context cancelled",
Plan: planPtr(st), WorkDir: st.Meta.WorkDir}, nil, nil
}
if result != nil {
st.addUsage(step.Role, result.PromptTokens, result.CompletionTokens)
}
step.DurationMs = time.Since(started).Milliseconds()
if step.Status == "failed" {
d.emit(st, Event{Type: EvStepFailed, StepID: step.ID, Role: step.Role, Text: step.Error})
st.setStep(idx, step)
return d.failStep(ctx, st, idx)
}
// 交付物校验。
missing := missingTargets(subDir, targets)
if len(missing) > 0 {
step.Status, step.Error = "failed", fmt.Sprintf("missing deliverables: %v", missing)
d.emit(st, Event{Type: EvStepFailed, StepID: step.ID, Role: step.Role, Text: step.Error})
st.setStep(idx, step)
return d.failStep(ctx, st, idx)
}
step.Status = "completed"
appendLog(subDir, fmt.Sprintf("[%s] backend=%s duration=%dms targets=%v\n", time.Now().Format(time.RFC3339), backendName, step.DurationMs, targets))
// 状态回填 + 黑板合并 + 落盘同一临界区:并行步骤的共享状态变更串行化,
// 防止 marshal 与写回竞争、临时文件互相踩踏把保存失败误判为步骤失败。
st.mu.Lock()
st.Plan.Workflow.Steps[idx] = step
_ = mergeStepArtifacts(&st.BB, subDir)
err = st.save()
st.mu.Unlock()
if err != nil {
return nil, nil, err
}
d.emit(st, Event{Type: EvStepCompleted, StepID: step.ID, Role: step.Role, Text: step.Title,
Data: marshalJSON(map[string]any{"duration_ms": step.DurationMs, "targets": targets})})
return nil, nil, nil
}
// failStep 按关键性处理失败步骤:关键 → 运行失败;非关键 → 标记后继续(返回 nil 让循环推进)。
func (d *flowDriver) failStep(ctx context.Context, st *runState, idx int) (*RunResult, *hitlGate, error) {
st.mu.Lock()
step := st.Plan.Workflow.Steps[idx]
critical := step.IsCritical()
if critical {
wf := st.Workflow()
wf.Status = "failed"
st.setWorkflow(wf)
}
_ = st.save()
st.mu.Unlock()
if critical {
return &RunResult{
RunID: st.Meta.ID, Status: RunFailed, Plan: planPtr(st), Error: fmt.Sprintf("step %s(%s) failed: %s", step.ID, step.Role, step.Error),
WorkDir: st.Meta.WorkDir,
}, nil, nil
}
// 非关键降级:标记 failed,工作流继续,收口时 degraded。
d.emit(st, Event{Type: EvStepSkipped, StepID: step.ID, Role: step.Role,
Text: "非关键步骤失败,降级继续: " + step.Error})
return nil, nil, nil
}
// checkBlockingGaps 检查未解决阻塞缺口;interactive 挂起,auto 记录兜底假设并继续。
func (d *flowDriver) checkBlockingGaps(st *runState) *hitlGate {
gaps := st.BB.UnresolvedBlockingGaps()
if len(gaps) == 0 {
return nil
}
mode := d.policy(st).mode()
if mode == HitlAuto {
for _, g := range gaps {
st.BB.Assumptions = append(st.BB.Assumptions, AssumptionItem{
ID: fmt.Sprintf("asm_%d", len(st.BB.Assumptions)+1), Topic: g.ID,
Statement: "无人值守模式:未获得人工输入,按现状继续推进(缺口保持未解决)",
Rationale: g.Item, Source: "fallback",
})
for i := range st.BB.Gaps {
if st.BB.Gaps[i].ID == g.ID {
st.BB.Gaps[i].Blocking = false
}
}
}
_ = st.save()
return nil
}
if mode == HitlDisabled {
return nil // 交由步骤级失败语义处理;缺口不再阻塞
}
var lines []string
for _, g := range gaps {
lines = append(lines, fmt.Sprintf("- [%s] %s(owner=%s)", g.ID, g.Item, g.Owner))
}
waitCond := waitConditionFrom(gaps)
req := &HumanRequest{
ID: newRequestID(), RunID: st.Meta.ID, Kind: HitlGap, Severity: SeverityActionRequired,
WaitCondition: waitCond,
Question: fmt.Sprintf("发现 %d 个阻塞缺口,需要人工补充信息后才能继续:", len(gaps)),
Options: []HumanOption{
{Value: "resolved", Label: "已在回答中补充信息,继续执行"},
{Value: "skip", Label: "跳过缺口,按现状继续"},
{Value: "abort", Label: "中止运行"},
},
Context: strings.Join(lines, "\n"),
CreatedAt: time.Now(), Status: "pending",
}
gate := &hitlGate{Request: *req, PrevStatus: RunExecuting}
if err := st.saveGate(gate); err == nil {
st.Meta.Status = string(RunWaitingHuman)
_ = st.save()
d.emitHuman(st, req)
}
return gate
}
// applyDynamicAdjustment 评估步骤产出并应用/审批动态调整。
func (d *flowDriver) applyDynamicAdjustment(ctx context.Context, st *runState, idx int) (*RunResult, *hitlGate, error) {
step := st.Plan.Workflow.Steps[idx]
subDir := filepath.Join(st.Meta.WorkDir, filepath.FromSlash(step.SubWorkspace))
output := readFileOr(filepath.Join(subDir, firstTarget(step.Targets)), "")
evalRes, err := d.opts.Evaluator.Evaluate(ctx, EvaluateRequest{
RunID: st.Meta.ID, Step: step, Workspace: st.Meta.WorkDir, Output: output, Blackboard: &st.BB,
})
if err != nil || evalRes == nil {
return nil, nil, err
}
for _, na := range evalRes.NewAssumptions {
st.BB.Assumptions = append(st.BB.Assumptions, na)
}
for _, gapID := range evalRes.ResolvedGaps {
st.BB.ResolveGapByID(gapID, "")
}
hasChanges := len(evalRes.DynamicStepsToAdd) > 0 || len(evalRes.StepsToSkip) > 0
if !hasChanges {
return nil, nil, nil
}
if evalRes.NeedsApproval && d.policy(st).mode() == HitlInteractive {
var addLines, skipLines []string
for _, s := range evalRes.DynamicStepsToAdd {
addLines = append(addLines, fmt.Sprintf("- %s(%s)%s", s.ID, s.Role, s.Title))
}
for _, id := range evalRes.StepsToSkip {
skipLines = append(skipLines, "- "+id)
}
req := &HumanRequest{
ID: newRequestID(), RunID: st.Meta.ID, StepID: step.ID, Kind: HitlAdjustment, Severity: SeverityAttention,
Question: "评估器建议动态调整工作流,是否应用?",
Options: []HumanOption{
{Value: "approve", Label: "应用调整"},
{Value: "reject", Label: "忽略调整"},
{Value: "abort", Label: "中止运行"},
},
Context: fmt.Sprintf("评估理由:%s\n追加步骤:\n%s\n跳过步骤:\n%s",
evalRes.Reason, strings.Join(addLines, "\n"), strings.Join(skipLines, "\n")),
CreatedAt: time.Now(), Status: "pending",
}
gate := &hitlGate{Request: *req, Steps: evalRes.DynamicStepsToAdd, SkipIDs: evalRes.StepsToSkip, PrevStatus: RunExecuting}
if err := st.saveGate(gate); err == nil {
st.Meta.Status = string(RunWaitingHuman)
_ = st.save()
d.emitHuman(st, req)
}
return heldResult(st, gate), gate, nil
}
applyAdjustment(st, evalRes.DynamicStepsToAdd, evalRes.StepsToSkip)
return nil, nil, nil
}
// applyAdjustment 应用动态调整:新步骤插到最后一步之前,跳过步骤标记 skipped。
func applyAdjustment(st *runState, add []StepSpec, skipIDs []string) {
steps := st.Plan.Workflow.Steps
for _, id := range skipIDs {
for i := range steps {
if steps[i].ID == id && steps[i].Status == "pending" {
steps[i].Status = "skipped"
}
}
}
if len(add) > 0 {
insertAt := len(steps)
if insertAt > 0 {
insertAt-- // 插在最后(成文/终审)步骤之前
}
for i := range add {
add[i].Status = "pending"
if add[i].Targets == nil {
add[i].Targets = []string{"notes.md", "fields.json"}
}
}
steps = append(steps[:insertAt], append(append([]StepSpec{}, add...), steps[insertAt:]...)...)
}
st.Plan.Workflow.Steps = steps
_ = st.save()
}
// finalize 收口:汇总交付物 → 质检 → 自动修复 → (必要时)人工裁决 → 清单。
func (d *flowDriver) finalize(ctx context.Context, st *runState) (*RunResult, *hitlGate, error) {
wf := st.Workflow()
degraded := false
for _, s := range wf.Steps {
if s.Status == "failed" || s.Status == "skipped" {
degraded = true
}
}
// 汇总交付物:最后一个 completed 步骤的主产出 → output.md。
var srcStep *StepSpec
for i := range wf.Steps {
if wf.Steps[i].Status == "completed" {
srcStep = &wf.Steps[i]
}
}
if srcStep == nil {
wf.Status = "failed"
st.setWorkflow(wf)
_ = st.save()
return &RunResult{RunID: st.Meta.ID, Status: RunFailed, Plan: planPtr(st),
Error: "no completed step to assemble deliverable", WorkDir: st.Meta.WorkDir}, nil, nil
}
src := filepath.Join(st.Meta.WorkDir, filepath.FromSlash(srcStep.SubWorkspace), firstTarget(srcStep.Targets))
data, err := os.ReadFile(src)
if err != nil {
wf.Status = "failed"
st.setWorkflow(wf)
_ = st.save()
return &RunResult{RunID: st.Meta.ID, Status: RunFailed, Plan: planPtr(st),
Error: "deliverable source missing: " + err.Error(), WorkDir: st.Meta.WorkDir}, nil, nil
}
deliverable := "output.md"
if err := os.WriteFile(filepath.Join(st.Meta.WorkDir, deliverable), data, 0o644); err != nil {
return nil, nil, err
}
d.emit(st, Event{Type: EvDeliverable, Text: deliverable})
// 质检 + 有界自动修复。
report := d.opts.Checker.Check(CheckRequest{
RunID: st.Meta.ID, Workspace: st.Meta.WorkDir, Deliverable: deliverable,
Plan: planPtr(st), Squad: &st.Plan.Squad, Template: st.Template,
})
if !report.Passed && st.RepairBudgetRemaining > 0 {
st.RepairBudgetRemaining--
_ = st.save()
// 重跑交付物源步骤一次,再检一次。
if out, g, err := d.rerunForRepair(ctx, st, srcStep.ID); out != nil || g != nil || err != nil {
return out, g, err
}
data2, _ := os.ReadFile(src)
_ = os.WriteFile(filepath.Join(st.Meta.WorkDir, deliverable), data2, 0o644)
report = d.opts.Checker.Check(CheckRequest{
RunID: st.Meta.ID, Workspace: st.Meta.WorkDir, Deliverable: deliverable,
Plan: planPtr(st), Squad: &st.Plan.Squad, Template: st.Template,
})
}
if !report.Passed {
switch d.policy(st).mode() {
case HitlDisabled, HitlAuto:
wf.Status = "failed"
st.setWorkflow(wf)
_ = st.save()
return &RunResult{RunID: st.Meta.ID, Status: RunFailed, Plan: planPtr(st),
Quality: report, Error: "quality check failed (hitl mode=" + string(d.policy(st).mode()) + "): " + strings.Join(report.Violations, "; "),
WorkDir: st.Meta.WorkDir}, nil, nil
default:
req := &HumanRequest{
ID: newRequestID(), RunID: st.Meta.ID, Kind: HitlQualityReview, Severity: SeverityActionRequired,
Question: "交付物质检未通过且自动修复预算已用尽,请人工裁决:",
Options: []HumanOption{
{Value: "approve", Label: "人工放行(记录人工通过)"},
{Value: "retry", Label: "再修复一次(重跑交付步骤)"},
{Value: "abort", Label: "判定失败"},
},
Context: string(marshalJSON(report)),
CreatedAt: time.Now(), Status: "pending",
}
gate := &hitlGate{Request: *req, PrevStatus: RunExecuting}
if err := st.saveGate(gate); err == nil {
st.Meta.Status = string(RunQualityReview)
_ = st.save()
d.emitHuman(st, req)
}
return heldResult(st, gate), gate, nil
}
}
status := RunCompleted
if degraded {
status = RunDegraded
}
now := time.Now()
wf.Status = string(status)
st.setWorkflow(wf)
st.Meta.Status = string(status)
st.Meta.CompletedAt = &now
_ = st.save()
manifest := buildManifest(st, deliverable, *report)
_ = writeJSON(filepath.Join(st.Meta.WorkDir, "manifest.json"), manifest)
d.emit(st, Event{Type: EvRunCompleted, Text: string(status),
Data: marshalJSON(map[string]any{"deliverable": deliverable, "score": report.Score})})
return &RunResult{
RunID: st.Meta.ID, Status: status, Plan: planPtr(st), Deliverable: deliverable,
Quality: report, WorkDir: st.Meta.WorkDir, Usage: st.usageSnapshot(),
Summary: summarize(st, *report, status),
}, nil, nil
}
// rerunForRepair 重置交付物源步骤并重新执行到收口。
func (d *flowDriver) rerunForRepair(ctx context.Context, st *runState, stepID string) (*RunResult, *hitlGate, error) {
for i := range st.Plan.Workflow.Steps {
if st.Plan.Workflow.Steps[i].ID == stepID {
st.Plan.Workflow.Steps[i].Status = "pending"
st.Plan.Workflow.Steps[i].Error = ""
}
}
d.emit(st, Event{Type: EvPhase, Text: "质检未通过,自动修复:重跑交付步骤 " + stepID})
return d.advance(ctx, st)
}
// heldResult 由挂起上下文构造对外结果(挂起路径统一返回非 nil 结果)。
func heldResult(st *runState, gate *hitlGate) *RunResult {
status := RunWaitingHuman
switch gate.Request.Kind {
case HitlClarification:
status = RunAwaitingClarification
case HitlConfirmation:
status = RunAwaitingConfirmation
case HitlQualityReview:
status = RunQualityReview
}
return &RunResult{
RunID: st.Meta.ID, Status: status, Plan: planPtr(st),
Pending: &gate.Request, WorkDir: st.Meta.WorkDir, Usage: st.usageSnapshot(),
}
}
// waitConditionFrom 从阻塞缺口提取等待条件(首个声明者胜出)。
func waitConditionFrom(gaps []GapItem) *WaitCondition {
for _, g := range gaps {
if g.WaitRunID != "" {
return &WaitCondition{Type: "run_done", RunID: g.WaitRunID}
}
if g.WaitField != "" {
return &WaitCondition{Type: "field_set", Field: g.WaitField}
}
}
return nil
}
// hold 生成挂起请求并返回结果。
func (d *flowDriver) hold(st *runState, kind HumanRequestKind, stepID, role, question string, options []HumanOption, context string, extra *hitlGate) (*RunResult, *hitlGate, error) {
req := &HumanRequest{
ID: newRequestID(), RunID: st.Meta.ID, StepID: stepID, Kind: kind,
Severity: SeverityActionRequired,
Question: question, Options: options, Context: context,
CreatedAt: time.Now(), Status: "pending",
}
status := RunWaitingHuman
switch kind {
case HitlClarification:
status = RunAwaitingClarification
case HitlConfirmation:
status = RunAwaitingConfirmation
}
gate := extra
if gate == nil {
gate = &hitlGate{}
}
gate.Request = *req
gate.PrevStatus = RunExecuting
if err := st.saveGate(gate); err != nil {
return nil, nil, err
}
st.Meta.Status = string(status)
_ = st.save()
d.emitHuman(st, req)
return &RunResult{RunID: st.Meta.ID, Status: status, Plan: planPtr(st),
Pending: req, WorkDir: st.Meta.WorkDir}, nil, nil
}
// policy 合并引擎全局策略与 Goal 覆盖(窄者胜)。
func (d *flowDriver) policy(st *runState) HitlPolicy {
return mergeHitlPolicy(d.opts.HitlPolicy, st.Meta.HitlPolicyOf())
}
func (d *flowDriver) emit(st *runState, ev Event) {
if d.opts.Sink == nil {
return
}
ev.RunID = st.Meta.ID
if ev.At.IsZero() {
ev.At = time.Now()
}
d.opts.Sink.Emit(ev)
}
func (d *flowDriver) emitHuman(st *runState, req *HumanRequest) {
d.emit(st, Event{Type: EvHumanInputRequired, Text: req.Question, Data: marshalJSON(req)})
}
func summarize(st *runState, report QualityReport, status RunStatus) string {
var b strings.Builder
fmt.Fprintf(&b, "运行 %s:%s。步骤 %d 个(完成 %d/跳过 %d/失败 %d)。质检得分 %.0f。",
st.Meta.ID, status, len(st.Plan.Workflow.Steps),
countStatus(st, "completed"), countStatus(st, "skipped"), countStatus(st, "failed"), report.Score)
if gaps := st.BB.UnresolvedBlockingGaps(); len(gaps) > 0 {
fmt.Fprintf(&b, "未解决缺口 %d 个。", len(gaps))
}
return b.String()
}
func countStatus(st *runState, status string) int {
n := 0
for _, s := range st.Plan.Workflow.Steps {
if s.Status == status {
n++
}
}
return n
}
func buildManifest(st *runState, deliverable string, report QualityReport) Manifest {
files := map[string]string{}
_ = filepath.Walk(st.Meta.WorkDir, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return nil
}
rel, _ := filepath.Rel(st.Meta.WorkDir, path)
files[filepath.ToSlash(rel)] = sha256File(path)
return nil
})
start := st.Meta.CreatedAt
return Manifest{
ManifestVersion: "1", WorkspaceID: st.Meta.ID, GeneratedAt: time.Now(),
Deliverable: deliverable, Files: files, QualityReport: report,
DurationMs: time.Since(start).Milliseconds(),
}
}
func firstTarget(targets []string) string {
if len(targets) > 0 {
return targets[0]
}
return "output.md"
}
func allTargetsPresent(dir string, targets []string) bool { return len(missingTargets(dir, targets)) == 0 }
func missingTargets(dir string, targets []string) []string {
var missing []string
for _, t := range targets {
if strings.TrimSpace(t) == "" {
continue
}
info, err := os.Stat(filepath.Join(dir, filepath.FromSlash(t)))
if err != nil || info.Size() == 0 {
missing = append(missing, t)
}
}
return missing
}
func readFileOr(path, fallback string) string {
if data, err := os.ReadFile(path); err == nil && len(strings.TrimSpace(string(data))) > 0 {
return string(data)
}
return fallback
}
func appendLog(dir, line string) {
f, err := os.OpenFile(filepath.Join(dir, "execution.log"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
return
}
defer f.Close()
_, _ = f.WriteString(line)
}
func planPtr(st *runState) *WorkflowPlan {
wf := st.Workflow()
return &wf
}
func orDefault(v, def string) string {
if v == "" {
return def
}
return v
}
+313
View File
@@ -0,0 +1,313 @@
package squad
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"agent-squad/textextract"
)
// WorkspaceEngine 任务级沙箱工作区引擎。布局与产品 squad 工作区兼容:
//
// runs/<run_id>/
// workspace.json squad.json workflow.json blackboard.json template.md
// context/{goal.md, materials/, materials_text/, index.json}
// tasks/<NN>_<role>/{TASK.md, AGENTS.md, ...步骤产物}
// output.md manifest.json
type WorkspaceEngine struct {
// DataDir 数据根(runs/ 的父目录,也用于解析角色 InstructionsFile 相对路径)。
DataDir string
}
// PrepareWorkspace 创建运行工作区并写入初始文件。
func (e *WorkspaceEngine) PrepareWorkspace(meta *RunMetadata, plan *SquadPlan) (string, error) {
wsDir := filepath.Join(e.DataDir, "runs", meta.ID)
for _, d := range []string{wsDir, filepath.Join(wsDir, "context", "materials"),
filepath.Join(wsDir, "context", "materials_text"), filepath.Join(wsDir, "tasks")} {
if err := os.MkdirAll(d, 0o755); err != nil {
return "", err
}
}
meta.WorkDir = wsDir
meta.UpdatedAt = time.Now()
if err := writeJSON(filepath.Join(wsDir, "workspace.json"), meta); err != nil {
return "", err
}
if err := writeJSON(filepath.Join(wsDir, "squad.json"), &plan.Squad); err != nil {
return "", err
}
if err := writeJSON(filepath.Join(wsDir, "workflow.json"), &plan.Workflow); err != nil {
return "", err
}
if err := saveBlackboard(wsDir, &BlackboardState{Fields: map[string]string{}}); err != nil {
return "", err
}
ctxIdx, err := e.writeContextAssets(wsDir, &meta.Goal, plan)
if err != nil {
return "", err
}
_ = ctxIdx
tmpl := renderTemplate(plan.Template, meta.Goal)
if tmpl == "" {
tmpl = defaultTemplate(meta.Goal, plan)
}
if err := os.WriteFile(filepath.Join(wsDir, "template.md"), []byte(tmpl), 0o644); err != nil {
return "", err
}
return wsDir, nil
}
// writeContextAssets 写入 goal.md、材料原文与抽取文本、index.json。
func (e *WorkspaceEngine) writeContextAssets(wsDir string, goal *Goal, plan *SquadPlan) (*ContextIndex, error) {
var b strings.Builder
b.WriteString("# 运行目标\n\n")
b.WriteString(goal.Text + "\n")
if len(goal.Constraints) > 0 {
b.WriteString("\n## 约束\n")
for k, v := range goal.Constraints {
fmt.Fprintf(&b, "- %s: %s\n", k, v)
}
}
if err := os.WriteFile(filepath.Join(wsDir, "context", "goal.md"), []byte(b.String()), 0o644); err != nil {
return nil, err
}
idx := &ContextIndex{Items: []ContextItem{
{File: "context/goal.md", Description: "运行目标与约束", AuthorityLevel: "authoritative",
Guidance: "不可推翻;所有产出须与目标一致"},
}}
for _, m := range goal.Materials {
name := filepath.Base(m.Filename)
data := m.Content
if len(data) == 0 && m.Path != "" {
if d, err := os.ReadFile(m.Path); err == nil {
data = d
}
}
if err := os.WriteFile(filepath.Join(wsDir, "context", "materials", name), data, 0o644); err != nil {
return nil, err
}
text, err := textextract.ExtractTextWithAnchors(name, data)
if err != nil {
text = fmt.Sprintf("(材料抽取失败: %v)", err)
}
txtName := strings.TrimSuffix(name, filepath.Ext(name)) + ".txt"
if err := os.WriteFile(filepath.Join(wsDir, "context", "materials_text", txtName), []byte(text), 0o644); err != nil {
return nil, err
}
idx.Items = append(idx.Items, ContextItem{
File: "context/materials_text/" + txtName, Description: "输入材料文本(" + name + ")",
AuthorityLevel: "primary_evidence", Guidance: "引用须标注出处与页码锚点",
})
}
if plan.Template != "" {
idx.Items = append(idx.Items, ContextItem{
File: "template.md", Description: "交付物骨架模板", AuthorityLevel: "authoritative",
Guidance: "按模板章节撰写;不得留空或删除章节",
})
}
if err := writeJSON(filepath.Join(wsDir, "context", "index.json"), idx); err != nil {
return nil, err
}
return idx, nil
}
// UpdateGoalFile 重写 context/goal.md(澄清续跑后目标文本已修订时使用)。
func (e *WorkspaceEngine) UpdateGoalFile(wsDir string, goal *Goal) error {
var b strings.Builder
b.WriteString("# 运行目标\n\n")
b.WriteString(goal.Text + "\n")
if len(goal.Constraints) > 0 {
b.WriteString("\n## 约束\n")
for k, v := range goal.Constraints {
fmt.Fprintf(&b, "- %s: %s\n", k, v)
}
}
return os.WriteFile(filepath.Join(wsDir, "context", "goal.md"), []byte(b.String()), 0o644)
}
// BuildSubWorkspaces 为每个 pending 步骤构建子工作区(TASK.md + AGENTS.md +
// 父级上下文快照)。上下文按 InputLayoutSnapshot 语义复制为子目录本地文件:
// 执行工具的沙箱只允许子目录内路径,TASK.md 只引用本地名(对齐主仓产品设计)。
func (e *WorkspaceEngine) BuildSubWorkspaces(wsDir string, plan *SquadPlan, bb *BlackboardState) error {
for i := range plan.Workflow.Steps {
step := &plan.Workflow.Steps[i]
if step.Status != "pending" && step.Status != "" {
continue // 恢复/续跑时不重建已执行子目录
}
role, ok := findAgent(plan.Squad.Agents, step.Role)
if !ok {
return fmt.Errorf("step %s references role %s not in squad", step.ID, step.Role)
}
sub := step.SubWorkspace
if sub == "" {
sub = fmt.Sprintf("tasks/%02d_%s", i+1, step.Role)
step.SubWorkspace = sub
}
subDir := filepath.Join(wsDir, filepath.FromSlash(sub))
if err := os.MkdirAll(subDir, 0o755); err != nil {
return err
}
if err := snapshotContextInto(wsDir, subDir); err != nil {
return err
}
if err := e.writeTaskFile(subDir, step, role, bb); err != nil {
return err
}
if err := e.writeAgentsFile(subDir, role); err != nil {
return err
}
}
return nil
}
// snapshotContextInto 把 context/ 与 template.md 快照复制进子工作区。
func snapshotContextInto(wsDir, subDir string) error {
src := filepath.Join(wsDir, "context")
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return err
}
rel, err := filepath.Rel(wsDir, path)
if err != nil {
return err
}
dst := filepath.Join(subDir, rel)
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
data, err := os.ReadFile(path)
if err != nil {
return err
}
return os.WriteFile(dst, data, 0o644)
})
}
func (e *WorkspaceEngine) writeTaskFile(subDir string, step *StepSpec, role RoleSpec, bb *BlackboardState) error {
idxBytes, _ := os.ReadFile(filepath.Join(subDir, "context", "index.json"))
idx := &ContextIndex{}
_ = json.Unmarshal(idxBytes, idx)
var b strings.Builder
fmt.Fprintf(&b, "# 步骤任务:%s\n\n", step.Title)
fmt.Fprintf(&b, "- 角色:%s(%s)\n", role.Name, role.Role)
fmt.Fprintf(&b, "- 期望产出:%v\n\n", step.Targets)
b.WriteString("## 任务上下文资源与权威等级(已快照到本目录,只引用本地路径)\n\n")
b.WriteString("| 资源路径 | 权威等级 | 用途说明 | 使用与引用指导 |\n|---|---|---|---|\n")
b.WriteString("| `context/goal.md` | **authoritative** | 运行目标 | 不可推翻 |\n")
for _, it := range idx.Items {
fmt.Fprintf(&b, "| `%s` | **%s** | %s | %s |\n", it.File, it.AuthorityLevel, it.Description, it.Guidance)
}
b.WriteString("\n## 黑板快照(共享状态)\n\n")
if len(bb.Fields) > 0 {
b.WriteString("字段事实:\n")
for k, v := range bb.Fields {
fmt.Fprintf(&b, "- %s = %s\n", k, v)
}
}
if gaps := bb.UnresolvedBlockingGaps(); len(gaps) > 0 {
b.WriteString("\n未解决缺口(如无法推进,写入 gaps.json 声明 blocking=true):\n")
for _, g := range gaps {
fmt.Fprintf(&b, "- [%s] %s\n", g.ID, g.Item)
}
}
b.WriteString("\n## 产出要求\n\n")
b.WriteString("1. 按期望产出文件名落盘到本子目录;\n2. 结构化事实回填 `fields.json`(字符串键值);\n")
b.WriteString("3. 发现信息缺口且无法假设时写 `gaps.json`([{\"item\":\"…\",\"blocking\":true}])。\n")
return os.WriteFile(filepath.Join(subDir, "TASK.md"), []byte(b.String()), 0o644)
}
func (e *WorkspaceEngine) writeAgentsFile(subDir string, role RoleSpec) error {
instr := e.ResolveInstructions(role)
if instr == "" {
instr = defaultInstructions(role)
}
return os.WriteFile(filepath.Join(subDir, "AGENTS.md"), []byte(instr), 0o644)
}
// ResolveInstructions 解析角色指令:内联优先,其次指令文件(DataDir 相对或绝对)。
func (e *WorkspaceEngine) ResolveInstructions(role RoleSpec) string {
if strings.TrimSpace(role.Instructions) != "" {
return role.Instructions
}
if role.InstructionsFile == "" {
return ""
}
path := role.InstructionsFile
if !filepath.IsAbs(path) {
path = filepath.Join(e.DataDir, path)
}
data, err := os.ReadFile(path)
if err != nil {
return fmt.Sprintf("# 角色指令\n\n(指令文件 %s 读取失败:%v;以下为内联兜底)\n\n%s",
role.InstructionsFile, err, defaultInstructions(role))
}
return string(data)
}
func defaultInstructions(role RoleSpec) string {
var b strings.Builder
fmt.Fprintf(&b, "# 角色:%s(%s)\n\n", role.Name, role.Role)
b.WriteString("## 通用守则\n\n")
b.WriteString("- 缺失的信息若会实质改变结果、执行路径、权限或安全,向人求助(声明阻塞缺口);否则自行决定,并说明你决定了什么。\n")
b.WriteString("- 严禁假装完成:交付受阻时如实报告阻塞与原因;失败不等于目标完成。\n\n")
if len(role.Responsibilities) > 0 {
b.WriteString("## 职责\n")
for _, r := range role.Responsibilities {
fmt.Fprintf(&b, "- %s\n", r)
}
}
if len(role.ForbiddenActions) > 0 {
b.WriteString("\n## 禁止事项\n")
for _, f := range role.ForbiddenActions {
fmt.Fprintf(&b, "- %s\n", f)
}
}
if len(role.Skills) > 0 {
b.WriteString("\n## 技能\n")
for _, s := range role.Skills {
fmt.Fprintf(&b, "- %s\n", s)
}
}
return b.String()
}
func defaultTemplate(goal Goal, plan *SquadPlan) string {
title := plan.Workflow.Title
if title == "" {
title = goal.Text
}
var b strings.Builder
fmt.Fprintf(&b, "# %s\n\n", title)
b.WriteString("## 概述与背景\n\n(目标、范围与输入材料概述)\n\n")
b.WriteString("## 核心内容\n\n(按目标拆解的核心分析/结果章节)\n\n")
b.WriteString("## 风险与注意事项\n\n(风险、限制与依赖)\n\n")
b.WriteString("## 基准假设与待确认清单\n\n(假设及需人工确认事项)\n")
return b.String()
}
// renderTemplate 用 Goal 约束渲染模板 {{key}} 占位符({{goal}} 专指目标文本)。
func renderTemplate(tmpl string, goal Goal) string {
if tmpl == "" {
return ""
}
out := strings.ReplaceAll(tmpl, "{{goal}}", goal.Text)
for k, v := range goal.Constraints {
out = strings.ReplaceAll(out, "{{"+k+"}}", v)
}
return out
}
func writeJSON(path string, v any) error {
data, err := json.MarshalIndent(v, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0o644)
}
+116
View File
@@ -0,0 +1,116 @@
package store
import (
"encoding/json"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"time"
"agent-squad/squad"
)
// FileEventSink 事件持久化装饰器:把引擎事件按行追加到
// runs/<run_id>/events.jsonl(每行带自增 seq,天然即回放顺序),
// 落盘前做秘密形状脱敏,然后转发给下游(FanoutSink → SSE)。
// 机制参考 Multica 的 (task_id, seq) 消息表 + 写前 redact,文件版实现。
type FileEventSink struct {
DataDir string
Next squad.ProgressSink
mu sync.Mutex
files map[string]*os.File
seqs map[string]int64
}
// NewFileEventSink 创建事件落盘 sink。
func NewFileEventSink(dataDir string, next squad.ProgressSink) *FileEventSink {
return &FileEventSink{DataDir: dataDir, Next: next, files: map[string]*os.File{}, seqs: map[string]int64{}}
}
// Emit 落盘(脱敏)并转发。
func (s *FileEventSink) Emit(ev squad.Event) {
defer func() {
if s.Next != nil {
s.Next.Emit(ev)
}
}()
if ev.RunID == "" {
return
}
if ev.At.IsZero() {
ev.At = time.Now()
}
s.mu.Lock()
defer s.mu.Unlock()
f, ok := s.files[ev.RunID]
if !ok {
dir := filepath.Join(s.DataDir, "runs", ev.RunID)
if err := os.MkdirAll(dir, 0o755); err != nil {
return
}
fp, err := os.OpenFile(filepath.Join(dir, "events.jsonl"),
os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
return
}
f = fp
s.files[ev.RunID] = f
}
s.seqs[ev.RunID]++
line := persistedEvent{Seq: s.seqs[ev.RunID], Type: ev.Type, StepID: ev.StepID,
Role: ev.Role, Text: Redact(ev.Text), At: ev.At}
if len(ev.Data) > 0 {
line.Data = json.RawMessage(Redact(string(ev.Data)))
}
data, err := json.Marshal(line)
if err != nil {
return
}
_, _ = f.Write(append(data, '\n'))
}
// Close 关闭全部文件句柄。
func (s *FileEventSink) Close() {
s.mu.Lock()
defer s.mu.Unlock()
for _, f := range s.files {
_ = f.Close()
}
s.files = map[string]*os.File{}
}
type persistedEvent struct {
Seq int64 `json:"seq"`
Type string `json:"type"`
StepID string `json:"step_id,omitempty"`
Role string `json:"role,omitempty"`
Text string `json:"text,omitempty"`
Data json.RawMessage `json:"data,omitempty"`
At time.Time `json:"at"`
}
// secret 形状:API key(sk-/api key 风格长串)与 Bearer 凭证。
var (
reSKKey = regexp.MustCompile(`(?:sk|rk|pk)-[A-Za-z0-9_\-]{12,}`)
reBearer = regexp.MustCompile(`(?i)bearer\s+[A-Za-z0-9._\-]{12,}`)
reAPIKey = regexp.MustCompile(`(?i)(api[_-]?key["':=\s]+)[A-Za-z0-9_\-\.]{12,}`)
reJWT = regexp.MustCompile(`eyJ[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}`)
)
// Redact 脱敏:把秘密形状子串替换为 ***(宁可多脱,不漏一个)。
func Redact(s string) string {
if s == "" {
return s
}
s = reJWT.ReplaceAllString(s, "***")
s = reSKKey.ReplaceAllString(s, "***")
s = reBearer.ReplaceAllString(s, "Bearer ***")
s = reAPIKey.ReplaceAllString(s, "${1}***")
if strings.Contains(strings.ToLower(s), "password") {
s = regexp.MustCompile(`(?i)(password["':=\s]+)[^\s"',}]+`).ReplaceAllString(s, "${1}***")
}
return s
}
+77
View File
@@ -0,0 +1,77 @@
package store
import (
"bufio"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
"agent-squad/squad"
)
func TestFileEventSinkPersistAndRedact(t *testing.T) {
dir := t.TempDir()
var got []squad.Event
next := squad.ProgressFunc(func(ev squad.Event) { got = append(got, ev) })
sink := NewFileEventSink(dir, next)
defer sink.Close()
sink.Emit(squad.Event{Type: squad.EvRunStarted, RunID: "run_x", Text: "开始 sk-api-8abcdefgh1234567890"})
sink.Emit(squad.Event{Type: squad.EvDetail, RunID: "run_x", StepID: "s1",
Text: "Bearer abcdefghijklmnopqrst",
Data: json.RawMessage(`{"note":"api_key=abcdefghijklmnopqrstuvwxyz123456"}`)})
sink.Emit(squad.Event{Type: "no_run"}) // 无 RunID:不落盘但转发
// 转发不丢。
if len(got) != 3 {
t.Fatalf("forwarded events want 3, got %d", len(got))
}
// 落盘 2 行,带 seq。
f, err := os.Open(filepath.Join(dir, "runs", "run_x", "events.jsonl"))
if err != nil {
t.Fatal(err)
}
defer f.Close()
sc := bufio.NewScanner(f)
var lines []persistedEvent
for sc.Scan() {
var pe persistedEvent
if err := json.Unmarshal(sc.Bytes(), &pe); err != nil {
t.Fatal(err)
}
lines = append(lines, pe)
}
if len(lines) != 2 || lines[0].Seq != 1 || lines[1].Seq != 2 {
t.Fatalf("seq/lines wrong: %+v", lines)
}
// 脱敏生效。
if strings.Contains(lines[0].Text, "sk-api-8") || !strings.Contains(lines[0].Text, "***") {
t.Fatalf("sk key not redacted: %q", lines[0].Text)
}
if strings.Contains(lines[1].Text, "abcdefghijklmnopqrst") {
t.Fatalf("bearer not redacted: %q", lines[1].Text)
}
if strings.Contains(string(lines[1].Data), "abcdefghijklmnopqrstuvwxyz123456") {
t.Fatalf("api key in data not redacted: %s", lines[1].Data)
}
}
func TestRedactShapes(t *testing.T) {
cases := map[string]string{
"key sk-proj-1234567890abcdef": "key ***",
"Authorization: Bearer abcdef1234567890abc": "Authorization: Bearer ***",
`{"api_key":"abcdefghijklmnop12"}`: `{"api_key":"***"}`,
"eyJhbGciOiJIU.eyJzdWIiOiJI.SflKxwRJSMeK": "***",
`{"password":"hunter2secret"}`: `{"password":"***"}`,
"普通文本不受影响 sk-short": "普通文本不受影响 sk-short",
}
for in, want := range cases {
if got := Redact(in); got != want {
t.Errorf("Redact(%q)=%q, want %q", in, got, want)
}
}
_ = time.Now
}
+110
View File
@@ -0,0 +1,110 @@
// Package store 提供 agent-squad 的文件持久化:
// roles.d/*.json、blueprints.d/*.json;运行状态由引擎直接管理 runs/<id>/。
package store
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"agent-squad/squad"
)
// FileStore 数据目录级文件存储。
type FileStore struct {
DataDir string
}
// New 创建文件存储并确保目录存在。
func New(dataDir string) (*FileStore, error) {
s := &FileStore{DataDir: dataDir}
for _, d := range []string{filepath.Join(dataDir, "roles.d"), filepath.Join(dataDir, "blueprints.d")} {
if err := os.MkdirAll(d, 0o755); err != nil {
return nil, err
}
}
return s, nil
}
// LoadRoles 加载 roles.d/*.json 到注册表,并挂接增量落盘钩子。
// 文件名(去扩展名)不作为权威,以 JSON 内 role 字段为准。
func (s *FileStore) LoadRoles(reg *squad.RoleRegistry) error {
entries, err := os.ReadDir(filepath.Join(s.DataDir, "roles.d"))
if err != nil {
return err
}
for _, ent := range entries {
if ent.IsDir() || (!strings.HasSuffix(ent.Name(), ".json")) {
continue
}
data, err := os.ReadFile(filepath.Join(s.DataDir, "roles.d", ent.Name()))
if err != nil {
return err
}
var spec squad.RoleSpec
if err := json.Unmarshal(data, &spec); err != nil {
return fmt.Errorf("roles.d/%s: %w", ent.Name(), err)
}
if err := reg.Register(spec); err != nil {
return fmt.Errorf("roles.d/%s: %w", ent.Name(), err)
}
}
reg.OnChange = s.SaveRole
return nil
}
// SaveRole 落盘单个角色。
func (s *FileStore) SaveRole(spec squad.RoleSpec) {
data, err := json.MarshalIndent(spec, "", " ")
if err != nil {
return
}
_ = os.WriteFile(filepath.Join(s.DataDir, "roles.d", spec.Role+".json"), data, 0o644)
}
// DeleteRole 删除角色文件(注册表删除由调用方完成)。
func (s *FileStore) DeleteRole(key string) {
_ = os.Remove(filepath.Join(s.DataDir, "roles.d", key+".json"))
}
// LoadBlueprints 加载 blueprints.d/*.json 并挂接落盘钩子。
func (s *FileStore) LoadBlueprints(bps *squad.BlueprintRegistry) error {
entries, err := os.ReadDir(filepath.Join(s.DataDir, "blueprints.d"))
if err != nil {
return err
}
for _, ent := range entries {
if ent.IsDir() || !strings.HasSuffix(ent.Name(), ".json") {
continue
}
data, err := os.ReadFile(filepath.Join(s.DataDir, "blueprints.d", ent.Name()))
if err != nil {
return err
}
var bp squad.Blueprint
if err := json.Unmarshal(data, &bp); err != nil {
return fmt.Errorf("blueprints.d/%s: %w", ent.Name(), err)
}
if err := bps.Register(bp); err != nil {
return fmt.Errorf("blueprints.d/%s: %w", ent.Name(), err)
}
}
bps.OnChange = s.SaveBlueprint
return nil
}
// SaveBlueprint 落盘单个蓝图。
func (s *FileStore) SaveBlueprint(bp squad.Blueprint) {
data, err := json.MarshalIndent(bp, "", " ")
if err != nil {
return
}
_ = os.WriteFile(filepath.Join(s.DataDir, "blueprints.d", bp.Key+".json"), data, 0o644)
}
// DeleteBlueprint 删除蓝图文件。
func (s *FileStore) DeleteBlueprint(key string) {
_ = os.Remove(filepath.Join(s.DataDir, "blueprints.d", key+".json"))
}
+72
View File
@@ -0,0 +1,72 @@
package store
import (
"os"
"path/filepath"
"testing"
"agent-squad/squad"
)
func TestRolesPersistAcrossReload(t *testing.T) {
dir := t.TempDir()
st, err := New(dir)
if err != nil {
t.Fatal(err)
}
reg1 := squad.NewRoleRegistry()
if err := st.LoadRoles(reg1); err != nil {
t.Fatal(err)
}
if err := reg1.Register(squad.RoleSpec{Role: "researcher", Backend: "stub", Keywords: []string{"调研"}}); err != nil {
t.Fatal(err)
}
// OnChange 落盘。
if _, err := os.Stat(filepath.Join(dir, "roles.d", "researcher.json")); err != nil {
t.Fatalf("role file missing: %v", err)
}
// 新注册表重载:角色可恢复。
reg2 := squad.NewRoleRegistry()
st2, _ := New(dir)
if err := st2.LoadRoles(reg2); err != nil {
t.Fatal(err)
}
if spec, ok := reg2.Get("researcher"); !ok || spec.Backend != "stub" {
t.Fatalf("role not reloaded: %+v ok=%v", spec, ok)
}
}
func TestBlueprintsPersistAcrossReload(t *testing.T) {
dir := t.TempDir()
st, _ := New(dir)
bps1 := squad.NewBlueprintRegistry()
if err := st.LoadBlueprints(bps1); err != nil {
t.Fatal(err)
}
if err := bps1.Register(squad.Blueprint{
Key: "report", Title: "报告",
Steps: []squad.StepSpec{{ID: "s1", Role: "writer", Title: "成文"}},
}); err != nil {
t.Fatal(err)
}
st2, _ := New(dir)
bps2 := squad.NewBlueprintRegistry()
if err := st2.LoadBlueprints(bps2); err != nil {
t.Fatal(err)
}
if bp, ok := bps2.Get("report"); !ok || len(bp.Steps) != 1 {
t.Fatalf("blueprint not reloaded: %+v ok=%v", bp, ok)
}
}
func TestLoadRolesRejectsInvalidFile(t *testing.T) {
dir := t.TempDir()
st, _ := New(dir)
if err := os.WriteFile(filepath.Join(dir, "roles.d", "bad.json"), []byte(`{"role":""}`), 0o644); err != nil {
t.Fatal(err)
}
reg := squad.NewRoleRegistry()
if err := st.LoadRoles(reg); err == nil {
t.Fatal("invalid role file must fail load")
}
}
+125
View File
@@ -0,0 +1,125 @@
// Package textextract 从输入材料(txt/md/json/yaml/docx)提取纯文本,
// 并注入页码锚点便于引用(机制语义对齐 Agent-System internal/textextract,
// 为独立底座做的纯标准库精简版)。
package textextract
import (
"archive/zip"
"bytes"
"encoding/xml"
"fmt"
"io"
"path/filepath"
"regexp"
"strings"
)
// ExtractTextWithAnchors 提取文本并注入页码锚点。
func ExtractTextWithAnchors(filename string, data []byte) (string, error) {
ext := strings.ToLower(filepath.Ext(filename))
switch ext {
case ".txt", ".md", ".json", ".yaml", ".yml", ".csv", ".log":
return InjectPageAnchors(string(data)), nil
case ".docx":
text, err := docxText(data)
if err != nil {
return "", fmt.Errorf("docx %s: %w", filename, err)
}
return InjectPageAnchors(text), nil
default:
// 二进制降级:提取可见 ASCII/UTF-8 片段。
return InjectPageAnchors(extractVisibleStrings(data)), nil
}
}
// InjectPageAnchors 每 pageSizeAt 字符(或遇到 \f 换页符)注入 <!-- page N -->。
func InjectPageAnchors(s string) string {
if s == "" {
return s
}
var b strings.Builder
page := 1
count := 0
fprintf := func() { fmt.Fprintf(&b, "\n\n<!-- page %d -->\n", page) }
fprintf()
for _, r := range s {
b.WriteRune(r)
count++
if r == '\f' {
page++
count = 0
fprintf()
continue
}
if count >= 3000 {
page++
count = 0
fprintf()
}
}
return b.String()
}
var xmlTag = regexp.MustCompile(`</w:p>`)
func docxText(data []byte) (string, error) {
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return "", err
}
for _, f := range zr.File {
if f.Name != "word/document.xml" {
continue
}
rc, err := f.Open()
if err != nil {
return "", err
}
defer rc.Close()
raw, err := io.ReadAll(rc)
if err != nil {
return "", err
}
// 段落结尾转换行,再剥离全部 XML 标签。
raw = xmlTag.ReplaceAll(raw, []byte("\n"))
dec := xml.NewDecoder(bytes.NewReader(raw))
var b strings.Builder
for {
tok, err := dec.Token()
if err == io.EOF {
break
}
if err != nil {
return "", err
}
if t, ok := tok.(xml.CharData); ok {
b.Write(t)
}
}
return strings.TrimSpace(b.String()), nil
}
return "", fmt.Errorf("word/document.xml not found")
}
// extractVisibleStrings 从二进制中提取可见可打印字符串(长度>=4)。
func extractVisibleStrings(data []byte) string {
var out []string
start := -1
for i, b := range data {
printable := (b >= 0x20 && b < 0x7f) || b >= 0x80
if printable {
if start < 0 {
start = i
}
continue
}
if start >= 0 && i-start >= 4 {
out = append(out, string(data[start:i]))
}
start = -1
}
if start >= 0 && len(data)-start >= 4 {
out = append(out, string(data[start:]))
}
return strings.Join(out, "\n")
}
+42
View File
@@ -0,0 +1,42 @@
package textextract
import (
"strings"
"testing"
)
func TestExtractPlainText(t *testing.T) {
out, err := ExtractTextWithAnchors("a.md", []byte("# 标题\n正文"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(out, "# 标题") || !strings.Contains(out, "page 1") {
t.Fatalf("plain text + anchor missing: %q", out)
}
}
func TestInjectPageAnchors(t *testing.T) {
long := strings.Repeat("x", 7000)
out := InjectPageAnchors(long)
if got := strings.Count(out, "<!-- page"); got < 3 {
t.Fatalf("want >=3 anchors for 7000 chars, got %d", got)
}
}
func TestExtractDocx(t *testing.T) {
// 最小 docx:zip 内 word/document.xml。
if _, err := ExtractTextWithAnchors("a.docx", []byte("not-a-zip")); err == nil {
t.Fatal("invalid docx must fail")
}
}
func TestExtractBinaryFallback(t *testing.T) {
data := []byte{0x00, 0x01, 'a', 'b', 'c', 'd', 'e', 0x00, 'f', 'g', 'h', 'i', 0xff}
out, err := ExtractTextWithAnchors("a.pdf", data)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(out, "abcde") {
t.Fatalf("visible strings missing: %q", out)
}
}