Import agent-squad as a standalone repository.
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:
@@ -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"
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
agent-squad
|
||||||
|
agent-squad-data/
|
||||||
|
*.test
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
module agent-squad
|
||||||
|
|
||||||
|
go 1.27
|
||||||
|
|
||||||
|
require agent-native v0.0.0
|
||||||
|
|
||||||
|
replace agent-native => ./Agent-Native
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
// agent-squad 单二进制入口:通用智能体小队编排底座。
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"agent-squad/cli"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
os.Exit(cli.Main(os.Args[1:]))
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -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>"):]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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]) + "…"
|
||||||
|
}
|
||||||
@@ -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) {}
|
||||||
@@ -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 ""
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -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"))
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user