Compare commits
127
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
edbf14abc6 | ||
|
|
4e6295d8e3 | ||
|
|
17e0ec697b | ||
|
|
a6d53dc61f | ||
|
|
4bbb91c735 | ||
|
|
c7121bda2a | ||
|
|
ad58d0ab1a | ||
|
|
c0ce10a82c | ||
|
|
11ff3a0a9c | ||
|
|
3b03a04b4b | ||
|
|
edb8b1a77e | ||
|
|
9ded893f8c | ||
|
|
4816ec3efb | ||
|
|
b3e7da4a11 | ||
|
|
05b5098272 | ||
|
|
d0f9081f47 | ||
|
|
e29284eb7b | ||
|
|
468878f62e | ||
|
|
b83905a87a | ||
|
|
2a2a1fd131 | ||
|
|
bfb0834dd6 | ||
|
|
b9a8576274 | ||
|
|
ae21e1d3a0 | ||
|
|
93cc70241c | ||
|
|
490b0b2dfa | ||
|
|
234aa28af9 | ||
|
|
8bbf850fad | ||
|
|
9ec3d86e86 | ||
|
|
819b0c5f82 | ||
|
|
df1f877a4f | ||
|
|
663e89ec58 | ||
|
|
424bfc0e29 | ||
|
|
633e853bfc | ||
|
|
d6669cd9c8 | ||
|
|
bcc8d080b0 | ||
|
|
acbe744142 | ||
|
|
aec2a4aa0f | ||
|
|
c88100967d | ||
|
|
01d5b96cda | ||
|
|
6d76111a16 | ||
|
|
8103b8da35 | ||
|
|
f3a67ced9c | ||
|
|
c462bc4ae5 | ||
|
|
cff355d7e6 | ||
|
|
8b1000eb09 | ||
|
|
ef17c6630a | ||
|
|
40ce140cad | ||
|
|
b72bbe48a1 | ||
|
|
bf7fe8429b | ||
|
|
6dc03c02a9 | ||
|
|
ff36b23191 | ||
|
|
d775b4b205 | ||
|
|
f38cf7c441 | ||
|
|
a9948da781 | ||
|
|
90077b6601 | ||
|
|
b3363e83b9 | ||
|
|
931e5ae410 | ||
|
|
ec2c6a3495 | ||
|
|
6306451552 | ||
|
|
088d7f8543 | ||
|
|
9d3faefb97 | ||
|
|
c70d7c96d9 | ||
|
|
92f4e925d2 | ||
|
|
1bd42ab980 | ||
|
|
41ea27668c | ||
|
|
b4f1d26f33 | ||
|
|
56941bf8ea | ||
|
|
fe8de0e057 | ||
|
|
029edc08e7 | ||
|
|
1d86344c85 | ||
|
|
551e11ec19 | ||
|
|
83e72b4eaa | ||
|
|
eba77e782f | ||
|
|
90ac98ea65 | ||
|
|
1c3995beaa | ||
|
|
626b2a3f34 | ||
|
|
b34d6ed187 | ||
|
|
96d1e0e49c | ||
|
|
3870489614 | ||
|
|
f90abafe79 | ||
|
|
bb02683d89 | ||
|
|
4e8d684125 | ||
|
|
5403b6e7a9 | ||
|
|
ebbebdf20b | ||
|
|
592a410288 | ||
|
|
7fe41e86a1 | ||
|
|
6b7a991750 | ||
|
|
cb398dadc6 | ||
|
|
7ecc04ee61 | ||
|
|
3bf100fd9b | ||
|
|
fc9620e05f | ||
|
|
ef14bda11f | ||
|
|
bf5b210bd1 | ||
|
|
a75d359ade | ||
|
|
1051dc5ae5 | ||
|
|
bf5b389d55 | ||
|
|
61985d457f | ||
|
|
5a57636cc0 | ||
|
|
59ca80cee2 | ||
|
|
dcd523ea05 | ||
|
|
d9a573e497 | ||
|
|
a3b49e7b1f | ||
|
|
55e9b37770 | ||
|
|
beea97e8cf | ||
|
|
41dfe7e3ef | ||
|
|
68b18ec2c5 | ||
|
|
4d534508a9 | ||
|
|
3abc5944bd | ||
|
|
9efce79b86 | ||
|
|
c5c5a24e2c | ||
|
|
02c0d6aae4 | ||
|
|
5e414ffecc | ||
|
|
f68e5bde7b | ||
|
|
a182d7662b | ||
|
|
20a9eb8078 | ||
|
|
31ee395895 | ||
|
|
1bb0c3a2ae | ||
|
|
9537321782 | ||
|
|
8dc2c54adf | ||
|
|
6c8810029d | ||
|
|
e928ea3c0f | ||
|
|
cda1fd7c70 | ||
|
|
73b8d09545 | ||
|
|
8e987bf535 | ||
|
|
6aa61dff9f | ||
|
|
590fd86f4c | ||
|
|
40f7db9c61 |
+38
-9
@@ -6,19 +6,48 @@ on:
|
||||
branches: [main]
|
||||
jobs:
|
||||
vet-and-test:
|
||||
runs-on: nightly
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GOPROXY: https://goproxy.cn,direct
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: true
|
||||
- run: go vet ./... && go test -count=1 ./...
|
||||
- name: Checkout
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SUBMODULE_TOKEN: ${{ secrets.SUBMODULE_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git init -q
|
||||
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
|
||||
git -c http.extraheader="AUTHORIZATION: token ${TOKEN}" fetch --depth=1 origin "${GITHUB_SHA}"
|
||||
git checkout -q --detach FETCH_HEAD
|
||||
if [ -z "${SUBMODULE_TOKEN}" ]; then
|
||||
echo "SUBMODULE_TOKEN is not configured" >&2
|
||||
exit 1
|
||||
fi
|
||||
git config --global url."https://weichen:${SUBMODULE_TOKEN}@gitea.naitangbaobao.cn/".insteadOf "ssh://git@gitea.naitangbaobao.cn:52222/"
|
||||
git -c fetch.recurseSubmodules=false submodule update --init --recursive
|
||||
- name: Install Go 1.27.1
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -fsSL -o "$RUNNER_TEMP/go.tgz" https://mirrors.aliyun.com/golang/go1.27.1.linux-amd64.tar.gz
|
||||
tar -C /usr/local -xzf "$RUNNER_TEMP/go.tgz"
|
||||
ln -sfn /usr/local/go/bin/go /usr/local/bin/go
|
||||
ln -sfn /usr/local/go/bin/gofmt /usr/local/bin/gofmt
|
||||
- name: Vet, test, and race
|
||||
run: GOWORK=off go vet ./... && GOWORK=off go test -count=1 ./... && GOWORK=off go test -count=1 -race ./...
|
||||
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"
|
||||
- name: Checkout without GitHub actions
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git init -q
|
||||
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
|
||||
git -c http.extraheader="AUTHORIZATION: token ${TOKEN}" fetch --depth=1 origin "${GITHUB_SHA}"
|
||||
git checkout -q --detach FETCH_HEAD
|
||||
test -f go.mod && echo "docker-mode checkout via host-gateway OK"
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
name: governance
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
governance:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GOPROXY: https://goproxy.cn,direct
|
||||
steps:
|
||||
- name: Checkout
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SUBMODULE_TOKEN: ${{ secrets.SUBMODULE_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# 只物化两个治理 gitlink(Guidelines 与 deps/development-manager),非递归;
|
||||
# 其它 gitlink 不物化、不取回。凭据只以 URL 限定的 extraheader 存在于本进程树内。
|
||||
python3 - <<'PY_CHECKOUT'
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
# 服务端基址取运行器注入的 GITHUB_SERVER_URL(平台控制、候选 PR 不可影响)。
|
||||
SERVER = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
|
||||
parsed_server = urlsplit(SERVER)
|
||||
if parsed_server.scheme not in {"http", "https"} or not parsed_server.netloc:
|
||||
sys.exit("Checkout server must be the platform-injected http(s) URL")
|
||||
SSH = "ssh://git@gitea.naitangbaobao.cn:52222/weichen/"
|
||||
# 审查过的白名单,独立于候选 .gitmodules:仅列出两个治理 gitlink。
|
||||
REPOSITORIES = {
|
||||
"deps/development-guidelines": "Development-Guidelines",
|
||||
"deps/development-manager": "Development-Manager",
|
||||
}
|
||||
TARGET = "weichen/Agent-Squad"
|
||||
root_token = os.environ.pop("TOKEN", "")
|
||||
component_token = os.environ.pop("SUBMODULE_TOKEN", "")
|
||||
if not root_token or not component_token:
|
||||
sys.exit("Checkout requires TOKEN and SUBMODULE_TOKEN")
|
||||
if os.environ.get("GITHUB_REPOSITORY") != TARGET:
|
||||
sys.exit("Checkout repository is outside the reviewed allowlist")
|
||||
sha = os.environ.get("GITHUB_SHA", "")
|
||||
if not re.fullmatch(r"[0-9a-fA-F]{40}", sha):
|
||||
sys.exit("Checkout requires a fixed commit SHA")
|
||||
|
||||
# 丢弃继承的 Git 追踪/配置/SSH 钩子;凭据只存在于单个 Git 进程树的精确 URL 头中。
|
||||
clean_env = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")}
|
||||
clean_env.update(GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_SYSTEM="/dev/null",
|
||||
GIT_CONFIG_GLOBAL="/dev/null", GIT_TERMINAL_PROMPT="0")
|
||||
config = {
|
||||
"core.hooksPath": "/dev/null", "core.fsmonitor": "false",
|
||||
"init.templateDir": "", "credential.helper": "",
|
||||
"fetch.recurseSubmodules": "false", "submodule.recurse": "false",
|
||||
"http.followRedirects": "false", "protocol.allow": "never",
|
||||
"protocol." + urlsplit(SERVER).scheme + ".allow": "always",
|
||||
}
|
||||
|
||||
|
||||
def git(repo, *args, extra=None):
|
||||
values = config | (extra or {})
|
||||
env = clean_env | {"GIT_CONFIG_COUNT": str(len(values))}
|
||||
for index, (key, value) in enumerate(values.items()):
|
||||
env[f"GIT_CONFIG_KEY_{index}"] = key
|
||||
env[f"GIT_CONFIG_VALUE_{index}"] = value
|
||||
return subprocess.run(["git", "-C", str(repo), *args], env=env,
|
||||
check=True, stdout=subprocess.PIPE).stdout
|
||||
|
||||
|
||||
def materialize_governance(root):
|
||||
# -r 列出任意深度的 gitlink,但不进入 submodule 内部;其它 gitlink 只记录、从不取回。
|
||||
links = {}
|
||||
for entry in git(root, "ls-tree", "-rz", "HEAD").split(b"\0"):
|
||||
if not entry:
|
||||
continue
|
||||
metadata, path = entry.split(b"\t", 1)
|
||||
mode, kind, commit = metadata.decode().split()
|
||||
if mode == "160000":
|
||||
links[path.decode()] = commit
|
||||
for name in REPOSITORIES:
|
||||
if name not in links:
|
||||
raise ValueError("Governance gitlink is missing from the fixed commit")
|
||||
raw = git(root, "config", "--no-includes", "--blob", "HEAD:.gitmodules", "--null", "--list")
|
||||
entries = {}
|
||||
for item in raw.split(b"\0"):
|
||||
if not item:
|
||||
continue
|
||||
key, value = item.decode().split("\n", 1)
|
||||
match = re.fullmatch(r"submodule\.(.+)\.(path|url)", key)
|
||||
if not match:
|
||||
continue
|
||||
name, field = match.groups()
|
||||
if name not in REPOSITORIES:
|
||||
continue
|
||||
if field in entries.setdefault(name, {}):
|
||||
raise ValueError("Duplicate candidate .gitmodules setting")
|
||||
entries[name][field] = value
|
||||
# 先整体校验两个治理条目,再进行任何带凭据的取回。
|
||||
for name, repository in REPOSITORIES.items():
|
||||
fields = entries.get(name, {})
|
||||
if fields.get("path") != name:
|
||||
raise ValueError("Governance submodule path is outside the reviewed allowlist")
|
||||
trusted_url = f"{SERVER}/weichen/{repository}.git"
|
||||
if fields.get("url") not in {SSH + repository + ".git", trusted_url}:
|
||||
raise ValueError("Governance submodule URL is outside the reviewed allowlist")
|
||||
for name, repository in REPOSITORIES.items():
|
||||
url = f"{SERVER}/weichen/{repository}.git"
|
||||
# 仅持久化审查过的公开 URL,使 Git 在进程级配置消失后仍识别该 submodule。
|
||||
git(root, "config", f"submodule.{name}.url", url)
|
||||
git(root, "submodule", "update", "--init", "--checkout", "--", name,
|
||||
extra={f"submodule.{name}.url": url,
|
||||
f"submodule.{name}.update": "checkout",
|
||||
f"http.{url}.extraheader": "AUTHORIZATION: token " + component_token})
|
||||
child = root / name
|
||||
if git(child, "rev-parse", "HEAD").decode().strip() != links[name]:
|
||||
raise ValueError("Materialized governance gitlink does not match the fixed SHA")
|
||||
|
||||
|
||||
try:
|
||||
root = Path.cwd()
|
||||
# 必须是干净的临时工作区;不复用既有 Git 状态。
|
||||
if (root / ".git").exists():
|
||||
raise ValueError("Checkout requires a fresh workspace")
|
||||
git(root, "init", "-q")
|
||||
url = f"{SERVER}/{TARGET}.git"
|
||||
git(root, "remote", "add", "origin", url)
|
||||
git(root, "fetch", "--depth=1", "origin", sha,
|
||||
extra={f"http.{url}.extraheader": "AUTHORIZATION: token " + root_token})
|
||||
git(root, "checkout", "-q", "--detach", "FETCH_HEAD")
|
||||
materialize_governance(root)
|
||||
except (ValueError, OSError, subprocess.CalledProcessError) as error:
|
||||
sys.exit(f"Checkout refused: {error}")
|
||||
print("Checkout complete; credentials were not persisted in Git configuration")
|
||||
PY_CHECKOUT
|
||||
unset TOKEN SUBMODULE_TOKEN
|
||||
- name: Install Go 1.27.1
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -fsSL -o "$RUNNER_TEMP/go.tgz" https://mirrors.aliyun.com/golang/go1.27.1.linux-amd64.tar.gz
|
||||
tar -C /usr/local -xzf "$RUNNER_TEMP/go.tgz"
|
||||
ln -sfn /usr/local/go/bin/go /usr/local/bin/go
|
||||
ln -sfn /usr/local/go/bin/gofmt /usr/local/bin/gofmt
|
||||
- name: Validate agent execution configuration
|
||||
run: scripts/agent-exec validate --repo .
|
||||
- name: Governance check
|
||||
run: scripts/dm governance check --repo .
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
agent-squad
|
||||
/agent-squad
|
||||
agent-squad-data/
|
||||
*.test
|
||||
|
||||
|
||||
+8
-5
@@ -1,15 +1,18 @@
|
||||
[submodule "deps/agent-native"]
|
||||
path = deps/agent-native
|
||||
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Native.git
|
||||
url = ssh://git@gitea.naitangbaobao.cn:52222/weichen/Agent-Native.git
|
||||
[submodule "deps/development-guidelines"]
|
||||
path = deps/development-guidelines
|
||||
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Development-Guidelines.git
|
||||
url = ssh://git@gitea.naitangbaobao.cn:52222/weichen/Development-Guidelines.git
|
||||
[submodule "deps/error-code-system"]
|
||||
path = deps/error-code-system
|
||||
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Error-Code-System.git
|
||||
url = ssh://git@gitea.naitangbaobao.cn:52222/weichen/Error-Code-System.git
|
||||
[submodule "deps/logging-system"]
|
||||
path = deps/logging-system
|
||||
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Logging-System.git
|
||||
url = ssh://git@gitea.naitangbaobao.cn:52222/weichen/Logging-System.git
|
||||
[submodule "deps/agent-intent"]
|
||||
path = deps/agent-intent
|
||||
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Intent.git
|
||||
url = ssh://git@gitea.naitangbaobao.cn:52222/weichen/Agent-Intent.git
|
||||
[submodule "deps/development-manager"]
|
||||
path = deps/development-manager
|
||||
url = ssh://git@gitea.naitangbaobao.cn:52222/weichen/Development-Manager.git
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# Agent-Squad 采纳 development-governance-v2 治理套件 任务卡
|
||||
|
||||
任务主记录:[Agent-Squad PR #54](https://git-home.u1680832.nyat.app:30319/weichen/Agent-Squad/pulls/54)(本文件随 PR 描述维护)。
|
||||
状态:已完成(闭合随本 PR 合入;合入以平台记录为准);owner:weichen。
|
||||
|
||||
## 基线与授权
|
||||
|
||||
- 权威仓:weichen/Agent-Squad;root_target:main;immediate_target:main
|
||||
- branch_kind:initiative;initiative_id:governance-adoption-Agent-Squad
|
||||
- R0(initial_main_sha):490b0b2dfa1567cb16ff7a7230fd78e52bc2938e;工作区:/home/weichen/projects/Agent-Squad-wt-feature-governance-adoption(worktree);分支:feature/governance-adoption
|
||||
- 父任务信息(仅子任务填写):无
|
||||
- accepting_requirements:false(单仓采纳 initiative,范围固定为下列十步,不接收新需求)
|
||||
- 需求分流:verdict=NEW;selected_initiative=无;候选=无;理由=development-governance-v2 Phase 6 每个正式仓库独立 initiative;triage 收据=无(本仓采纳前尚无 triage 入口)
|
||||
- 授权与交付终点:owner 已授权 development-governance-v2 全部实施(Agent-System-Dev 主记录 docs/superpowers/plans/2026-10-16-development-governance-v2-master.md);终点为本 PR 合入 main 且采纳状态 active。分支保护与 tag 由用户执行。
|
||||
- 实施执行:agent-per-unit(配置:AGENT-EXECUTION.md;外部 Agent 一单元一新进程/会话/工作区)
|
||||
|
||||
## 目标与非目标
|
||||
|
||||
目标(Phase 6 十步):固定 Guidelines SHA(deps/development-guidelines @ 2688fd2be6a52c0bf110e7e6366b6f291ab5e4eb);增加 AGENT-EXECUTION.md;增加共享执行器薄入口(deps/development-manager @ bd3a7de57a3a9e1a4495b94c5fa5c08d71e7be8c + scripts/dm、scripts/agent-exec、scripts/task);四条路由(implementation/repair/test/review);任务卡模板与分支元数据;CI 中启用 schema 校验(.gitea/workflows/governance.yml:agent-exec validate + governance check);一次正例运行;负例(缺配置、坏收据、越界路径、未知副作用);独立 Review + CI;采纳状态 active。
|
||||
|
||||
非目标:不改业务代码与既有测试;不改分支保护(由用户执行);不发布 tag。
|
||||
|
||||
范围说明(Review 阻塞项 2):`scripts/submodule-consistency.sh` 要求 deps/agent-native、deps/agent-intent 内嵌的 Guidelines/错误码/日志 gitlink 与本仓一致;本 PR 把 Guidelines 固定到 2688fd2 后,按最小修复把这两个 gitlink 升到各自已合入治理采纳的 main(Agent-Native 14d4869、Agent-Intent 651a2e1;两者相对原固定版本只增治理文件、不改 Go 代码),其余 gitlink 不动。核对见证据 `scripts/submodule-consistency.sh`。
|
||||
|
||||
引导说明:AGENT-EXECUTION.md 由 host 以固定版本治理套件(dm-server governance init @ bd3a7de57a3a9e1a4495b94c5fa5c08d71e7be8c)生成并作为引导配置提交(执行器运行前必须存在有效配置;沿用 DM 采纳先例);其余文件由实施单元生成。
|
||||
|
||||
## Agent 实施收据
|
||||
|
||||
| 单元 ID | 预期范围 | 角色 | 候选顺序 | attempt | CLI/模型 | 终态 | transport | fallback | 源码外收据路径 |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| governance-adoption-Agent-Squad-r1 | 13 个允许路径(治理文件 + 两个 gitlink) | | implementation | cursor→antigravity→pi | run 71696608(dm-server pinned,attempts=1) | cursor / not-observable | failed-unknown | 无(apply 快进) | 否 | /tmp/gov-receipt-Agent-Squad-r1.json |
|
||||
| 对账(r1) | 规范 worktree 仍为 2a2a1fd 且干净;无 transport ref | — | — | — | — | — | 已对账;全新运行 r2(单元包追加禁止固定共享 /tmp 路径约束) | — | — | — |
|
||||
| governance-adoption-Agent-Squad-r2 | 13 个允许路径(治理文件 + 两个 gitlink) | b83905a8 | implementation | cursor→antigravity→pi | run 145e30ce(dm-server 42e675f,attempts=1) | cursor / not-observable | success | 468878f62ede(apply 快进) | 否 | /tmp/gov-receipt-Agent-Squad-r2.json |
|
||||
| (无效启动 repair-r0) | — | repair | — | 无 attempt | — | invalid-config(单元包尚未写出即启动,执行器读取失败;无 attempt、无副作用,worktree 仍为 e29284e 且干净) | 无 | 否 | /tmp/gov-receipt-Agent-Squad-repair-r0-invalid-unit-path.json |
|
||||
| governance-adoption-Agent-Squad-repair-r1 | policy/guidelines-adoption.md、PROJECT.md、deps/agent-native、deps/agent-intent | repair | cursor→antigravity→pi | run fadb9c92(attempts=1) | cursor / not-observable | success | d0f9081f47eb(apply 快进) | 否 | /tmp/gov-receipt-Agent-Squad-repair-r1.json |
|
||||
|
||||
## 同步记录
|
||||
|
||||
| 节点 | sync_base_sha | tested_head_sha | checked_at | 结果 |
|
||||
|---|---|---|---|---|
|
||||
| 启动 | 490b0b2dfa1567cb16ff7a7230fd78e52bc2938e | 490b0b2dfa1567cb16ff7a7230fd78e52bc2938e | 2026-10-17T07:17:41Z | 一致 |
|
||||
|
||||
## 结果与验证(完成后填写)
|
||||
|
||||
- 结果:治理套件已采纳——Guidelines 与 Development-Manager gitlink 固定、AGENT-EXECUTION.md 四条路由、共享执行器薄入口、pre-commit admit、governance CI;采纳状态 active(随合入生效)。
|
||||
- 验证:见下表(命令输出由 /tmp/gov-neg/ev.sh 写入 .task/evidence/governance-adoption/,SELF 除非另注)。
|
||||
- 影响与剩余项:不改业务代码;本地启用钩子需由人执行 `git config core.hooksPath scripts/hooks`;将 governance 检查设为分支保护必需项属用户双钥匙事项(命令见 Agent-System-Dev 治理主记录)。
|
||||
|
||||
| 时间(UTC) | 命令 | 被测 HEAD | exit | 来源 | 摘要/位置 |
|
||||
|---|---|---|---|---|---|
|
||||
| 20261017T080150Z | `scripts/dm governance check --repo .` | 468878f62ede | 0 | SELF | ok fixtures;[输出](../evidence/governance-adoption/00-20261017T080150Z.log) sha256:b07d207012a2fd6d |
|
||||
| 20261017T080150Z | `scripts/agent-exec validate --repo .` | 468878f62ede | 0 | SELF | validate: ok;[输出](../evidence/governance-adoption/01-20261017T080150Z.log) sha256:b3a91246221bb5f8 |
|
||||
| 20261017T080150Z | `python3 /tmp/gov-neg/adopt_cases.py .` | 468878f62ede | 0 | SELF | [verdict] ALL PASS cases=5;[输出](../evidence/governance-adoption/02-20261017T080150Z.log) sha256:109906d5e57fe039 |
|
||||
| 20261017T094306Z | `scripts/dm governance check --repo .` | d0f9081f47eb | 0 | SELF | ok fixtures;[输出](../evidence/governance-adoption/03-20261017T094306Z.log) sha256:b904686133da5f80 |
|
||||
| 20261017T094306Z | `scripts/agent-exec validate --repo .` | d0f9081f47eb | 0 | SELF | validate: ok;[输出](../evidence/governance-adoption/04-20261017T094306Z.log) sha256:19dc223efda318d6 |
|
||||
| 20261017T094308Z | `scripts/submodule-consistency.sh` | 05b509827274 | 0 | SELF | ok deps/agent-intent 651a2e1e03c4326888ef9f742ce592140ae30ab8;[输出](../evidence/governance-adoption/05-20261017T094308Z.log) sha256:bc528fe029ed41d2 |
|
||||
| — | 候选 HEAD CI | b3e7da4a11b9 | 0 | GATE | success ci / docker-checkout-hostgateway (pull_request)=success(/weichen/Agent-Squad/actions/runs/3672/jobs/5787);governance / governance (pull_request)=success(/weichen/Agent-Squad/actions/runs/3673/jobs/5788);ci / vet-and-test (pull_request)=success(/weichen/Agent-Squad/actions/runs/3672/jobs/5786) |
|
||||
| — | 独立 Review | b3e7da4a11b9 | 0 | REPLAY | cursor-agent-stable --mode ask 全新只读会话(fresh_session=true,session id not-observable)闭合重审 VERDICT: APPROVE(对象 b3e7da4) |
|
||||
@@ -0,0 +1,19 @@
|
||||
command: scripts/dm governance check --repo .
|
||||
cwd: /home/weichen/projects/Agent-Squad-wt-feature-governance-adoption
|
||||
head: 468878f62eded8347d18a1d6c1b7c5f7e24a9c4c
|
||||
exit: 0
|
||||
utc: 20261017T080150Z
|
||||
---
|
||||
ok gitlink deps/development-guidelines
|
||||
ok gitlink deps/development-manager
|
||||
ok agent-execution
|
||||
ok file AGENTS.md
|
||||
ok file PROJECT.md
|
||||
ok file policy/guidelines-adoption.md
|
||||
ok file .task/templates/task-card.md
|
||||
ok executable scripts/agent-exec
|
||||
ok executable scripts/hooks/pre-commit
|
||||
ok executable scripts/dm
|
||||
ok executable scripts/task
|
||||
ok adoption status
|
||||
ok fixtures
|
||||
@@ -0,0 +1,7 @@
|
||||
command: scripts/agent-exec validate --repo .
|
||||
cwd: /home/weichen/projects/Agent-Squad-wt-feature-governance-adoption
|
||||
head: 468878f62eded8347d18a1d6c1b7c5f7e24a9c4c
|
||||
exit: 0
|
||||
utc: 20261017T080150Z
|
||||
---
|
||||
validate: ok
|
||||
@@ -0,0 +1,13 @@
|
||||
command: python3 /tmp/gov-neg/adopt_cases.py .
|
||||
cwd: /home/weichen/projects/Agent-Squad-wt-feature-governance-adoption
|
||||
head: 468878f62eded8347d18a1d6c1b7c5f7e24a9c4c
|
||||
exit: 0
|
||||
utc: 20261017T080150Z
|
||||
---
|
||||
[object] Agent-Squad-wt-feature-governance-adoption HEAD=468878f62eded8347d18a1d6c1b7c5f7e24a9c4c
|
||||
PASS positive-run-apply: run exit=0 terminal=success apply exit=0 head=312d80d409a1
|
||||
PASS missing-config: run exit=2 terminal=invalid-config validate exit=2
|
||||
PASS bad-receipt: apply exit=4 head_unchanged=True msg=apply: no local integrity record: changed paths do not match the transport diff
|
||||
PASS out-of-scope-path: run exit=5 terminal=scope-expansion transport=False
|
||||
PASS unknown-side-effect: run exit=4 terminal=failed-unknown transport=False
|
||||
[verdict] ALL PASS cases=5
|
||||
@@ -0,0 +1,19 @@
|
||||
command: scripts/dm governance check --repo .
|
||||
cwd: /home/weichen/projects/Agent-Squad-wt-feature-governance-adoption
|
||||
head: d0f9081f47eb59565323f731cb8085e5891dd7ba
|
||||
exit: 0
|
||||
utc: 20261017T094306Z
|
||||
---
|
||||
ok gitlink deps/development-guidelines
|
||||
ok gitlink deps/development-manager
|
||||
ok agent-execution
|
||||
ok file AGENTS.md
|
||||
ok file PROJECT.md
|
||||
ok file policy/guidelines-adoption.md
|
||||
ok file .task/templates/task-card.md
|
||||
ok executable scripts/agent-exec
|
||||
ok executable scripts/hooks/pre-commit
|
||||
ok executable scripts/dm
|
||||
ok executable scripts/task
|
||||
ok adoption status
|
||||
ok fixtures
|
||||
@@ -0,0 +1,7 @@
|
||||
command: scripts/agent-exec validate --repo .
|
||||
cwd: /home/weichen/projects/Agent-Squad-wt-feature-governance-adoption
|
||||
head: d0f9081f47eb59565323f731cb8085e5891dd7ba
|
||||
exit: 0
|
||||
utc: 20261017T094306Z
|
||||
---
|
||||
validate: ok
|
||||
@@ -0,0 +1,14 @@
|
||||
command: scripts/submodule-consistency.sh
|
||||
cwd: /home/weichen/projects/Agent-Squad-wt-feature-governance-adoption
|
||||
head: 05b5098272741987767efa2e78b8fe01aa84b6e5
|
||||
exit: 0
|
||||
utc: 20261017T094308Z
|
||||
---
|
||||
ok development-guidelines@deps/agent-native 2688fd2be6a52c0bf110e7e6366b6f291ab5e4eb
|
||||
ok error-code-system@deps/agent-native a3ee67bba269e1276c14e22c026171ecf2cf6190
|
||||
ok logging-system@deps/agent-native 49b5d8be604d05e4246dffa0d10fa1c2b7ad45d7
|
||||
ok development-guidelines@deps/agent-intent 2688fd2be6a52c0bf110e7e6366b6f291ab5e4eb
|
||||
ok error-code-system@deps/agent-intent a3ee67bba269e1276c14e22c026171ecf2cf6190
|
||||
ok logging-system@deps/agent-intent 49b5d8be604d05e4246dffa0d10fa1c2b7ad45d7
|
||||
ok deps/agent-native 14d48697a69ddba9ebc67ba7daa588f1b082d096
|
||||
ok deps/agent-intent 651a2e1e03c4326888ef9f742ce592140ae30ab8
|
||||
@@ -0,0 +1,66 @@
|
||||
# <任务标题> 任务卡
|
||||
|
||||
任务主记录:<Issue/PR 链接,或注明"本文件随 PR 描述维护">。
|
||||
状态:<进行中 / 待审核 / 已合入 / 已放弃>;owner:<填写>。
|
||||
|
||||
## 基线与授权
|
||||
|
||||
- 权威仓:<仓 URL 或逻辑 ID>;root_target:main;immediate_target:<main 或 feature/initiative>
|
||||
- branch_kind:<initiative | child | standalone>;initiative_id:<稳定 ID,普通任务为空>
|
||||
- R0(initial_main_sha):<完整 SHA>;工作区:<路径>(worktree / clone);分支:<feature|fix|hotfix/...>
|
||||
- 父任务信息(仅子任务填写):parent_branch=<父分支>;parent_head_sha=<SHA>;parent_pr=<父 PR>
|
||||
- accepting_requirements:<true | false>(仅 initiative 可为 true;冻结后为 false)
|
||||
- 需求分流:verdict=<JOIN | NEW>;selected_initiative=<分支或无>;候选=<分支@HEAD 列表或无>;理由=<不适配/适配依据>;triage 收据=<源码外路径与摘要>
|
||||
- 授权与交付终点:<本次允许的动作与终点;未授权事项写明>
|
||||
- 实施执行:agent-per-unit(配置:AGENT-EXECUTION.md;外部 Agent 一单元一新进程/会话/工作区)
|
||||
|
||||
## 目标与非目标
|
||||
|
||||
<预期结果、受影响入口/模块/契约;明确不做什么。>
|
||||
|
||||
## Agent 实施收据
|
||||
|
||||
按根目录 AGENT-EXECUTION.md 路由;外部 Agent 一单元一新进程/会话/隔离工作区。实施、修复与测试必须通过登记的候选执行;未配置或候选安全耗尽停在 unavailable/blocked,禁止 host 隐式代写。
|
||||
|
||||
| 单元 ID | 预期范围 | 角色 | 候选顺序 | attempt | CLI/模型 | 终态 | transport | fallback | 源码外收据路径 |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| 待定义 | — | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | — |
|
||||
|
||||
## 复用材料(task-start 自动检索结果摘录)
|
||||
|
||||
- 相关近期提交:<git log -- <paths> 命中;无则写无>。
|
||||
- 相关经验条目:<检索命中;无则写无>。
|
||||
|
||||
## 同步记录
|
||||
|
||||
| 节点 | sync_base_sha | tested_head_sha | checked_at | 结果 |
|
||||
|---|---|---|---|---|
|
||||
| 启动 | <R0> | <HEAD> | <日期时间> | <一致/已同步> |
|
||||
| 验收前 | <填写> | <填写> | <填写> | <填写> |
|
||||
|
||||
字段没有发生的写"无",不省略。本卡与 PR 描述同源:开 PR 时将"目标与非目标、结果与验证、影响与剩余项"复制进 PR 描述,不另写第二份。
|
||||
|
||||
## 交付调度(有多个就绪任务时)
|
||||
|
||||
- 调度记录 / entry_id / 当前 attempt_id:<平台或调度服务链接及标识;未入队写无>。
|
||||
- 入队源 HEAD / owner / 依赖:<完整 SHA、身份、依赖或无>。
|
||||
- 固定候选引用:<调度尝试中的 base/head、组件与配置;未固定写无>。
|
||||
- 窗口、最终 CI、Review 与真实合入结果:<引用调度记录及原始证据,不复制全队列>。
|
||||
|
||||
定义见 [合入调度与 CI 分配](../standards/merge-queue.md)。本段在候选固定前整理;固定后先使用源码之外的记录保存运行结果,再按项目闭合流程回填。
|
||||
|
||||
## 结果与验证(完成后填写)
|
||||
|
||||
- 结果:<交付了什么;未发生写无>。
|
||||
- 验证:<结论性摘要:PASS/FAIL/SKIP/NOT_RUN 逐项;逐条证据见下表,来源标注与写入规则见 [按影响选择验证与证据](../standards/verification.md)>。
|
||||
- 影响与剩余项:<影响面、遗留风险、后续任务>。
|
||||
|
||||
| 时间(UTC) | 命令 | 被测 HEAD | exit | 来源 | 摘要/位置 |
|
||||
|---|---|---|---|---|---|
|
||||
| <时间> | <命令;由项目 evidence 工具写入,无工具时附命令+对象 SHA+输出 digest 并标 SELF> | <HEAD> | <exit> | GATE/REPLAY/SELF | <摘要/位置> |
|
||||
|
||||
## 回收(task-finish)
|
||||
|
||||
- 合入核对:<分支已包含于目标分支的核对输出>。
|
||||
- 未跟踪/ignored 文件处置:<归档/可再生/存疑 逐项;无则写无>。
|
||||
- 回收动作:<worktree remove、分支删除、prune;放弃路径注明原因>。
|
||||
@@ -0,0 +1,67 @@
|
||||
# Project Agent Execution Configuration
|
||||
# 项目工程 Agent 执行路由配置
|
||||
|
||||
适用范围:定义本项目中由 AI / Agent 主导的代码实施、修复、测试与审阅所调用的 Agent CLI 路由。
|
||||
解析器识别正文中唯一一个 ```agent-execution 代码块。正文外的说明文字可根据项目需要调整。
|
||||
|
||||
通用硬规则见 Development-Guidelines 的 [Agent 执行与协作契约](deps/development-guidelines/standards/agent-execution.md)。
|
||||
本配置为项目本地路由权威,声明具体的候选 profile、尝试次数上限、超时、集成入口与豁免路径;
|
||||
代码块须通过 `deps/development-guidelines/templates/schemas/agent-execution.schema.json` 校验后才能登记为 active。
|
||||
`fallback_to_host` 只能为 `never`;旧值 `all-unavailable` 仅供迁移工具读取,不能通过准入。
|
||||
下例的 CLI 名称、命令与路径均为占位,按项目实际执行器替换。
|
||||
|
||||
```agent-execution
|
||||
schema: agent-execution/v1
|
||||
|
||||
execution:
|
||||
fresh_process_per_unit: true
|
||||
permission: run-everything
|
||||
fallback_to_host: never
|
||||
max_parallel: 3
|
||||
timeout_seconds: 7200
|
||||
idle_timeout_seconds: 600
|
||||
summary_bytes: 16384
|
||||
raw_output_bytes: 10485760
|
||||
max_attempts_per_profile: 3
|
||||
emergency_profile: codex
|
||||
|
||||
roles:
|
||||
implementation: [cursor, antigravity, pi]
|
||||
repair: [cursor, antigravity, pi]
|
||||
test: [pi, antigravity, cursor]
|
||||
review: [pi, antigravity, cursor, codex]
|
||||
|
||||
profiles:
|
||||
cursor:
|
||||
adapter: cursor
|
||||
executable: "cursor-agent-stable"
|
||||
model: auto
|
||||
enabled: true
|
||||
|
||||
antigravity:
|
||||
adapter: antigravity
|
||||
executable: agy
|
||||
model: auto
|
||||
enabled: true
|
||||
|
||||
pi:
|
||||
adapter: pi
|
||||
executable: pi
|
||||
model: auto
|
||||
enabled: true
|
||||
|
||||
codex:
|
||||
adapter: codex
|
||||
executable: codex
|
||||
model: auto
|
||||
enabled: true
|
||||
|
||||
integration:
|
||||
exempt_paths: [".task/**", "docs/superpowers/specs/**", "docs/superpowers/plans/**"]
|
||||
transport:
|
||||
command: "scripts/agent-exec apply"
|
||||
admission:
|
||||
command: "scripts/agent-exec admit"
|
||||
cleanup:
|
||||
command: "scripts/agent-exec cleanup"
|
||||
```
|
||||
@@ -23,9 +23,9 @@
|
||||
历史文档与旧链接里的 `git-panel.u1680832.nyat.app:30319` 已失效(TLS 握手失败),
|
||||
见到一律按过期处理,不要再试。当前权威通道:
|
||||
|
||||
- **Web / API(HTTPS)**:`https://git-home.u1680832.nyat.app:30319`(Gitea 1.27.3,
|
||||
- **Web / API(HTTPS)**:`https://gitea.naitangbaobao.cn`(Gitea 1.27.3,
|
||||
API 前缀 `/api/v1`;偶发 broken pipe / 超时,重试即可)。
|
||||
- **Git over SSH**:`ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/<Repo>.git`
|
||||
- **Git over SSH**:`ssh://git@gitea.naitangbaobao.cn:52222/weichen/<Repo>.git`
|
||||
(各仓 origin 与 gitlink 均为此格式)。
|
||||
- **API token**:`~/.secrets/gitea`,两行键值 `URL_PREFIX=…` 与
|
||||
`ACCESS_TOKEN=<40 位 hex>`;取值 `grep ACCESS_TOKEN ~/.secrets/gitea | cut -d= -f2-`,
|
||||
@@ -33,4 +33,22 @@
|
||||
(baseline 硬边界 2:只记录来源、字段与可用状态)。
|
||||
- **SSH key**:`~/.ssh/home_gitea`(ED25519,公钥指纹
|
||||
`SHA256:GYXaOXIMeloQ1fKSfdJ/ndgPQzs/Ix7Hk62ekfkfQ20`,Gitea 侧密钥名 GMKtec);
|
||||
`~/.ssh/config` 已有 `git-ssh.u1680832.nyat.app` 主机条目,克隆/推送直接可用。
|
||||
`~/.ssh/config` 已有 `gitea.naitangbaobao.cn(端口 52222)` 主机条目,克隆/推送直接可用。
|
||||
|
||||
## Agent 执行与新项目准入(强制)
|
||||
|
||||
1. 缺 Guidelines 固定版本(`deps/development-guidelines` gitlink 未就绪或 HEAD 不符)时,Agent 只能只读分析,不得实施。
|
||||
2. 缺有效 `AGENT-EXECUTION.md`(`scripts/dm governance check` 未通过)时禁止 AI 实施。
|
||||
3. 缺共享执行器(`scripts/agent-exec` 或 `deps/development-manager` 不可用)时,状态为 `blocked/unavailable`,不得改由 host 替代。
|
||||
4. 不允许 host 代写实施单元;实施、修复与测试必须经 `AGENT-EXECUTION.md` 登记的候选执行。
|
||||
5. 人类直接开发按其自身授权与项目规则进行,不受本条款约束。
|
||||
6. 分支保护与规范 tag 由用户负责设置与维护,Agent 不得代为设置。
|
||||
|
||||
入口:
|
||||
|
||||
- 需求分流:`scripts/task triage discover|decide|verify`
|
||||
- 任务启动:`scripts/task start …`
|
||||
- PR 检查:`scripts/task pr-check …`
|
||||
- Agent 执行:`scripts/agent-exec run|apply|admit|validate`
|
||||
|
||||
AI 实施单元只经 `scripts/agent-exec run` 按 AGENT-EXECUTION.md 运行,并经 `scripts/agent-exec apply` 集成。
|
||||
|
||||
+7
-4
@@ -6,14 +6,14 @@
|
||||
|
||||
- 目标/产品边界:通用智能体小队编排底座。角色、蓝图、规划、执行引擎、黑板、HITL 与 HTTP/CLI 组合根。不含预设医疗器械角色或任务模板。不实现 Agent。
|
||||
- 源码、资产、文档、机器契约位置:Go 模块路径 `agent-squad`。包为 `squad`、`store`、`server`、`cli`、`textextract`、`conformance`。人类文档在根 `README.md`、`policy/`;过程记录在 `docs/records/`。对外 HTTP 错误合同以错误码库信封为准。
|
||||
- 权威远端、集成基线、受保护分支:`ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Squad.git`,集成基线 `main`。
|
||||
- 权威远端、集成基线、受保护分支:`ssh://git@gitea.naitangbaobao.cn:52222/weichen/Agent-Squad.git`,集成基线 `main`。
|
||||
- 并行共同基线记录位置、合入通知/主动检查方式:Gitea Issue/PR。跨仓共同起点是本仓提交加上它锁定的 Agent-Native、错误码库、日志库与规范库 SHA。
|
||||
- 发布线与批准版本/实际制品查询入口:无独立制品库。可执行文件由 `go build` 从固定 gitlink 组合产出。
|
||||
- 提交/审核/合并/发布身份与权限:仓库所有者 weichen。
|
||||
- 任务唯一来源:Gitea `weichen/Agent-Squad` 的 Issue 与 Pull Request。
|
||||
- 规范性文档目录:`policy/`。
|
||||
- 过程文档仓/目录:`docs/records/`(项目内豁免,见采纳记录)。
|
||||
- 交接记录位置:随任务主记录;需要落盘的交接放入 `docs/records/`。
|
||||
- 过程文档仓/目录:任务卡与交付证据在 `.task/`(模板 `.task/templates/task-card.md`);其他过程记录在 `docs/records/`(项目内豁免,见采纳记录)。
|
||||
- 交接记录位置:随任务主记录;需要落盘的任务交接与交付证据放入 `.task/`,其他落盘交接放入 `docs/records/`。
|
||||
- 凭证查找入口及所需字段:LLM 使用环境变量 `AGENT_SQUAD_LLM_BASE_URL`、`AGENT_SQUAD_LLM_API_KEY`、`AGENT_SQUAD_LLM_MODEL`。日志采集 token 使用日志库自己的配置。文档与仓库不记录这些值。
|
||||
|
||||
## 依赖组合
|
||||
@@ -28,10 +28,12 @@
|
||||
|
||||
`go.mod` 的 `replace` 都指向 `./deps/`。嵌套在 `deps/agent-native/deps/`、`deps/agent-intent/deps/` 里的同名副本不参与本仓编译,提交必须与本仓 gitlink 一致(`scripts/submodule-consistency.sh`)。
|
||||
|
||||
同一 run 的临界区在 unix 上额外持有 `runs/<id>/.lock` 的 flock,拿不到锁时返回 `ErrRunBusy`。非 unix 平台没有这把文件锁,只保留进程内互斥。
|
||||
|
||||
克隆:
|
||||
|
||||
```bash
|
||||
git clone --recurse-submodules ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Squad.git
|
||||
git clone --recurse-submodules ssh://git@gitea.naitangbaobao.cn:52222/weichen/Agent-Squad.git
|
||||
# 已克隆时:git submodule update --init --recursive
|
||||
```
|
||||
|
||||
@@ -43,6 +45,7 @@ git clone --recurse-submodules ssh://git@git-ssh.u1680832.nyat.app:31411/weichen
|
||||
| 模块/接口 | 仓库根:`GOWORK=off go vet ./... && GOWORK=off go test -count=1 ./...` | 需要已初始化的 Agent-Native 子模块;默认 stub,不调用真实模型 |
|
||||
| 数据/权限/恢复 | conformance 与 store 测试覆盖运行落盘、HITL 恢复 | 无共享数据库 |
|
||||
| UI/执行后端 | `GOWORK=off go test -count=1 ./server/... ./conformance/...` | 不覆盖浏览器;SSE 由测试客户端验证 |
|
||||
| 治理与 Agent 执行配置 | 仓库根:`scripts/agent-exec validate --repo .` 与 `scripts/dm governance check --repo .` | 需 `deps/development-manager` 已按 gitlink 检出(首次 `dm` 调用会构建 dm-server);只核验 AGENT-EXECUTION.md 与治理文件,不证明实施单元正确 |
|
||||
|
||||
## 环境与数据
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ Agent-Native 自己的 `deps/` 里还有一份错误码库、日志库和规范
|
||||
克隆时带上子模块,否则无法编译。已有克隆若仍留着根目录 `Agent-Native`,先删掉该残留目录再初始化:
|
||||
|
||||
```bash
|
||||
git clone --recurse-submodules ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Squad.git
|
||||
git clone --recurse-submodules ssh://git@gitea.naitangbaobao.cn:52222/weichen/Agent-Squad.git
|
||||
# 已克隆时:
|
||||
git submodule sync --recursive
|
||||
git submodule update --init --recursive
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
// Package a2a 把小队接入 A2A(Agent2Agent)1.0 互通面(Q3 WP4):
|
||||
// - 发布侧:BuildCard 从角色注册表与蓝图注册表渲染 Agent Card(描述本
|
||||
// 团队能力),CardHandler 以 JSON 服务(挂载在
|
||||
// /.well-known/agent-card.json);
|
||||
// - 消费侧:FetchCard 发现远端 Agent Card(fail-closed 校验必备字段),
|
||||
// Executor 实现 squad.Executor——message/send 发起任务,tasks/get 轮询
|
||||
// A2A 任务状态机(submitted/working/input-required → completed/failed/
|
||||
// canceled),终态映射 ExecResult:completed 的 artifacts(TextPart/
|
||||
// FilePart)落成 patches/目标文件;failed/canceled/input-required 一律
|
||||
// Success=false(跨进程挂起不在本适配面内,fail-closed 交引擎重试/降级
|
||||
// 语义处理)。
|
||||
//
|
||||
// 传输为 JSON-RPC 2.0 over HTTP(A2A 1.0 形态);不引入协议之外的依赖。
|
||||
package a2a
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"agent-squad/squad"
|
||||
)
|
||||
|
||||
// ProtocolVersion 对齐的 A2A 协议版本。
|
||||
const ProtocolVersion = "1.0"
|
||||
|
||||
// WellKnownPath 是 Agent Card 的发现路径(A2A 1.0)。
|
||||
const WellKnownPath = "/.well-known/agent-card.json"
|
||||
|
||||
// AgentCard 是 A2A 1.0 Agent Card(发布与消费共用形态)。
|
||||
type AgentCard struct {
|
||||
ProtocolVersion string `json:"protocolVersion"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
// URL 是 JSON-RPC 服务端点(消费侧据此发 message/send)。
|
||||
URL string `json:"url"`
|
||||
Version string `json:"version"`
|
||||
Capabilities CardCapabilities `json:"capabilities"`
|
||||
DefaultInputModes []string `json:"defaultInputModes,omitempty"`
|
||||
DefaultOutputModes []string `json:"defaultOutputModes,omitempty"`
|
||||
Skills []AgentSkill `json:"skills,omitempty"`
|
||||
}
|
||||
|
||||
// CardCapabilities 能力声明(本适配面:非流式、无推送、无状态历史)。
|
||||
type CardCapabilities struct {
|
||||
Streaming bool `json:"streaming"`
|
||||
PushNotifications bool `json:"pushNotifications"`
|
||||
StateTransitionHistory bool `json:"stateTransitionHistory"`
|
||||
}
|
||||
|
||||
// AgentSkill 是一项可被远端调用的能力。
|
||||
type AgentSkill struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Examples []string `json:"examples,omitempty"`
|
||||
}
|
||||
|
||||
// CardOptions 覆盖 BuildCard 的身份字段(空则用缺省)。
|
||||
type CardOptions struct {
|
||||
Name string
|
||||
Description string
|
||||
// URL JSON-RPC 端点(必填:远端没有端点就无法调用本团队)。
|
||||
URL string
|
||||
// Version 卡片版本(空 = "1.0.0")。
|
||||
Version string
|
||||
// Skills 显式能力清单;空 = 从注册表派生。
|
||||
Skills []AgentSkill
|
||||
}
|
||||
|
||||
// BuildCard 从角色注册表与蓝图注册表渲染 Agent Card(描述本团队能力)。
|
||||
// 角色 → 技能(关键词作 tags);蓝图 → 技能(match 词作 examples)。
|
||||
func BuildCard(reg *squad.RoleRegistry, bps *squad.BlueprintRegistry, opts CardOptions) (AgentCard, error) {
|
||||
if strings.TrimSpace(opts.URL) == "" {
|
||||
return AgentCard{}, fmt.Errorf("a2a: Agent Card 需要 JSON-RPC 端点 URL")
|
||||
}
|
||||
card := AgentCard{
|
||||
ProtocolVersion: ProtocolVersion,
|
||||
Name: orDefault(opts.Name, "agent-squad"),
|
||||
Description: opts.Description,
|
||||
URL: opts.URL,
|
||||
Version: orDefault(opts.Version, "1.0.0"),
|
||||
DefaultInputModes: []string{"text/plain"},
|
||||
DefaultOutputModes: []string{"text/plain"},
|
||||
Skills: append([]AgentSkill(nil), opts.Skills...),
|
||||
}
|
||||
if len(card.Skills) == 0 {
|
||||
if reg != nil {
|
||||
for _, role := range reg.List() {
|
||||
desc := firstLine(role.Instructions)
|
||||
if desc == "" {
|
||||
desc = role.Name
|
||||
}
|
||||
card.Skills = append(card.Skills, AgentSkill{
|
||||
ID: role.Role, Name: orDefault(role.Name, role.Role),
|
||||
Description: desc, Tags: append([]string(nil), role.Keywords...),
|
||||
})
|
||||
}
|
||||
}
|
||||
if bps != nil {
|
||||
for _, bp := range bps.List() {
|
||||
card.Skills = append(card.Skills, AgentSkill{
|
||||
ID: bp.Key, Name: orDefault(bp.Title, bp.Key),
|
||||
Description: bp.Description, Examples: append([]string(nil), bp.Match...),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return card, nil
|
||||
}
|
||||
|
||||
// CardHandler 以 JSON 服务 Agent Card(调用方挂载到 WellKnownPath)。
|
||||
func CardHandler(card AgentCard) http.Handler {
|
||||
body, _ := json.Marshal(card)
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(body)
|
||||
})
|
||||
}
|
||||
|
||||
// FetchCard 发现并校验远端 Agent Card(fail-closed:必备字段缺失即拒)。
|
||||
func FetchCard(client *http.Client, wellKnownURL string) (AgentCard, error) {
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
resp, err := client.Get(wellKnownURL)
|
||||
if err != nil {
|
||||
return AgentCard{}, fmt.Errorf("a2a: 获取 Agent Card: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return AgentCard{}, fmt.Errorf("a2a: Agent Card 端点返回 %d", resp.StatusCode)
|
||||
}
|
||||
var card AgentCard
|
||||
if err := json.NewDecoder(resp.Body).Decode(&card); err != nil {
|
||||
return AgentCard{}, fmt.Errorf("a2a: Agent Card 解析失败: %w", err)
|
||||
}
|
||||
if err := validateCard(card); err != nil {
|
||||
return AgentCard{}, err
|
||||
}
|
||||
return card, nil
|
||||
}
|
||||
|
||||
// validateCard 消费侧最小校验(A2A 1.0 必备字段)。
|
||||
func validateCard(card AgentCard) error {
|
||||
if card.ProtocolVersion == "" {
|
||||
return fmt.Errorf("a2a: Agent Card 缺 protocolVersion")
|
||||
}
|
||||
if strings.TrimSpace(card.Name) == "" {
|
||||
return fmt.Errorf("a2a: Agent Card 缺 name")
|
||||
}
|
||||
if strings.TrimSpace(card.URL) == "" {
|
||||
return fmt.Errorf("a2a: Agent Card 缺 url")
|
||||
}
|
||||
if strings.TrimSpace(card.Version) == "" {
|
||||
return fmt.Errorf("a2a: Agent Card 缺 version")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func orDefault(v, def string) string {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func firstLine(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||||
s = s[:i]
|
||||
}
|
||||
if len(s) > 120 {
|
||||
s = s[:120] + "…"
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
package a2a
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"agent-squad/squad"
|
||||
)
|
||||
|
||||
// conformance_test.go — Q3 WP4 验收:双向 conformance(本地假 A2A 服务端)。
|
||||
// 发布侧:BuildCard + CardHandler 在 well-known 路径服务合法 Agent Card;
|
||||
// 消费侧:FetchCard 发现校验 + Executor 走完任务状态机
|
||||
// (submitted→working→completed / failed / input-required),终态对齐
|
||||
// squad.ExecResult。
|
||||
|
||||
// fakeA2AServer 最小 A2A 1.0 服务端:well-known 卡片 + JSON-RPC
|
||||
// (message/send 建任务;tasks/get 按脚本推进状态)。
|
||||
type fakeA2AServer struct {
|
||||
mu sync.Mutex
|
||||
card AgentCard
|
||||
tasks map[string]*Task
|
||||
plan []string // 每次 tasks/get 推进到的状态序列(走完停在末态)
|
||||
calls map[string]int
|
||||
artifacts []Artifact
|
||||
statusMsg string
|
||||
}
|
||||
|
||||
func newFakeServer(t *testing.T, card AgentCard, plan []string, artifacts []Artifact) *fakeA2AServer {
|
||||
t.Helper()
|
||||
s := &fakeA2AServer{card: card, tasks: map[string]*Task{}, plan: plan,
|
||||
calls: map[string]int{}, artifacts: artifacts}
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *fakeA2AServer) handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc(WellKnownPath, func(w http.ResponseWriter, r *http.Request) {
|
||||
s.mu.Lock()
|
||||
body, _ := json.Marshal(s.card)
|
||||
s.mu.Unlock()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(body)
|
||||
})
|
||||
mux.HandleFunc("/rpc", func(w http.ResponseWriter, r *http.Request) {
|
||||
var req rpcRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeRPCError(w, 0, -32700, "parse error")
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.calls[req.Method]++
|
||||
switch req.Method {
|
||||
case "message/send":
|
||||
id := fmt.Sprintf("task_%d", len(s.tasks)+1)
|
||||
state := StateSubmitted
|
||||
if len(s.plan) > 0 {
|
||||
state = s.plan[0]
|
||||
}
|
||||
task := &Task{Kind: "task", ID: id, ContextID: "ctx_1",
|
||||
Status: TaskStatus{State: state, Message: s.statusMessage()}}
|
||||
s.tasks[id] = task
|
||||
writeRPCResult(w, req.ID, task)
|
||||
case "tasks/get":
|
||||
var params struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
raw, _ := json.Marshal(req.Params)
|
||||
_ = json.Unmarshal(raw, ¶ms)
|
||||
task, ok := s.tasks[params.ID]
|
||||
if !ok {
|
||||
writeRPCError(w, req.ID, -32001, "task not found")
|
||||
return
|
||||
}
|
||||
// 按脚本推进状态机(停留在终态)。
|
||||
if !isTerminal(task.Status.State) && len(s.plan) > 0 {
|
||||
n := s.calls["tasks/get#"+params.ID]
|
||||
s.calls["tasks/get#"+params.ID] = n + 1
|
||||
idx := n + 1
|
||||
if idx >= len(s.plan) {
|
||||
idx = len(s.plan) - 1
|
||||
}
|
||||
task.Status.State = s.plan[idx]
|
||||
task.Status.Message = s.statusMessage()
|
||||
if task.Status.State == StateCompleted {
|
||||
task.Artifacts = s.artifacts
|
||||
}
|
||||
}
|
||||
writeRPCResult(w, req.ID, task)
|
||||
default:
|
||||
writeRPCError(w, req.ID, -32601, "method not found")
|
||||
}
|
||||
})
|
||||
return mux
|
||||
}
|
||||
|
||||
func (s *fakeA2AServer) statusMessage() *Message {
|
||||
if s.statusMsg == "" {
|
||||
return nil
|
||||
}
|
||||
return &Message{Kind: "message", Role: "agent",
|
||||
Parts: []Part{{Kind: "text", Text: s.statusMsg}}}
|
||||
}
|
||||
|
||||
func writeRPCResult(w http.ResponseWriter, id int64, result any) {
|
||||
body, _ := json.Marshal(result)
|
||||
env, _ := json.Marshal(rpcResponse{JSONRPC: "2.0", ID: id, Result: body})
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(env)
|
||||
}
|
||||
|
||||
func writeRPCError(w http.ResponseWriter, id int64, code int, msg string) {
|
||||
env, _ := json.Marshal(rpcResponse{JSONRPC: "2.0", ID: id, Error: &rpcError{Code: code, Message: msg}})
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(env)
|
||||
}
|
||||
|
||||
// ---- 发布侧 ----
|
||||
|
||||
func TestBuildCardFromRegistries(t *testing.T) {
|
||||
reg := squad.NewRoleRegistry()
|
||||
for _, r := range []squad.RoleSpec{
|
||||
{Role: "researcher", Name: "调研员", Keywords: []string{"调研", "检索"},
|
||||
Instructions: "围绕目标调研关键问题。\n第二行不进摘要。"},
|
||||
{Role: "writer", Name: "撰稿员", Keywords: []string{"撰写"}},
|
||||
} {
|
||||
if err := reg.Register(r); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
bps := squad.NewBlueprintRegistry()
|
||||
if err := bps.Register(squad.Blueprint{Key: "brief", Title: "简报",
|
||||
Description: "生成简报", Match: []string{"简报", "brief"},
|
||||
Steps: []squad.StepSpec{{ID: "s1", Role: "writer", Title: "成文"}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
card, err := BuildCard(reg, bps, CardOptions{
|
||||
Name: "示例小队", Description: "演示团队能力", URL: "https://team.example/rpc",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if card.ProtocolVersion != ProtocolVersion || card.Version != "1.0.0" {
|
||||
t.Fatalf("card = %+v", card)
|
||||
}
|
||||
byID := map[string]AgentSkill{}
|
||||
for _, s := range card.Skills {
|
||||
byID[s.ID] = s
|
||||
}
|
||||
if byID["researcher"].Name != "调研员" || byID["researcher"].Description != "围绕目标调研关键问题。" {
|
||||
t.Fatalf("role skill = %+v", byID["researcher"])
|
||||
}
|
||||
if len(byID["researcher"].Tags) != 2 {
|
||||
t.Fatalf("tags = %v", byID["researcher"].Tags)
|
||||
}
|
||||
if byID["brief"].Name != "简报" || strings.Join(byID["brief"].Examples, ",") != "简报,brief" {
|
||||
t.Fatalf("blueprint skill = %+v", byID["brief"])
|
||||
}
|
||||
if _, err := BuildCard(reg, bps, CardOptions{}); err == nil {
|
||||
t.Fatal("缺 URL 必须 fail-closed")
|
||||
}
|
||||
}
|
||||
|
||||
// CardHandler 以 JSON 服务卡片(发布端点形态)。
|
||||
func TestCardHandlerServesJSON(t *testing.T) {
|
||||
card := AgentCard{ProtocolVersion: ProtocolVersion, Name: "小队", URL: "https://x/rpc", Version: "1.0.0"}
|
||||
ts := httptest.NewServer(CardHandler(card))
|
||||
defer ts.Close()
|
||||
resp, err := http.Get(ts.URL + WellKnownPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if ct := resp.Header.Get("Content-Type"); ct != "application/json" {
|
||||
t.Fatalf("content-type = %s", ct)
|
||||
}
|
||||
var got AgentCard
|
||||
if err := json.NewDecoder(resp.Body).Decode(&got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Name != "小队" || got.ProtocolVersion != ProtocolVersion {
|
||||
t.Fatalf("card = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 消费侧:发现 + 状态机 ----
|
||||
|
||||
func TestFetchCardAndExecuteCompleted(t *testing.T) {
|
||||
fileBytes := base64.StdEncoding.EncodeToString([]byte("notes 内容"))
|
||||
artifacts := []Artifact{{
|
||||
ArtifactID: "art1", Name: "调研结果",
|
||||
Parts: []Part{
|
||||
{Kind: "text", Text: "# 调研结论\n\n结论正文,长度足够作为交付物内容。"},
|
||||
{Kind: "file", File: &FilePart{Name: "notes.md", Bytes: fileBytes, MimeType: "text/markdown"}},
|
||||
},
|
||||
}}
|
||||
card := AgentCard{ProtocolVersion: ProtocolVersion, Name: "远端小队",
|
||||
URL: "PLACEHOLDER", Version: "1.0.0"}
|
||||
srv := newFakeServer(t, card, []string{StateWorking, StateWorking, StateCompleted}, artifacts)
|
||||
ts := httptest.NewServer(srv.handler())
|
||||
defer ts.Close()
|
||||
srv.mu.Lock()
|
||||
srv.card.URL = ts.URL + "/rpc"
|
||||
srv.mu.Unlock()
|
||||
|
||||
// 发现:well-known 路径取卡并校验。
|
||||
got, err := FetchCard(ts.Client(), ts.URL+WellKnownPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Name != "远端小队" || got.URL != ts.URL+"/rpc" {
|
||||
t.Fatalf("card = %+v", got)
|
||||
}
|
||||
|
||||
// 执行:submitted/working → completed,工件映射 patches。
|
||||
var states []string
|
||||
ex := NewExecutor(got)
|
||||
ex.Client = ts.Client()
|
||||
ex.PollInterval = 5 * time.Millisecond
|
||||
ex.OnState = func(s string) { states = append(states, s) }
|
||||
res, err := ex.Execute(context.Background(), squad.ExecRequest{
|
||||
Prompt: "调研主题 X", Targets: []string{"output.md"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !res.Success {
|
||||
t.Fatalf("result = %+v", res)
|
||||
}
|
||||
if string(res.Patches["notes.md"]) != "notes 内容" {
|
||||
t.Fatalf("file part 未落成 patch:%q", res.Patches["notes.md"])
|
||||
}
|
||||
if !strings.Contains(string(res.Patches["output.md"]), "调研结论") {
|
||||
t.Fatalf("文本部件未写入目标:%q", res.Patches["output.md"])
|
||||
}
|
||||
joined := strings.Join(states, ",")
|
||||
if !strings.Contains(joined, StateWorking) || !strings.HasSuffix(joined, StateCompleted) {
|
||||
t.Fatalf("状态机迁移 = %v", states)
|
||||
}
|
||||
srv.mu.Lock()
|
||||
sends := srv.calls["message/send"]
|
||||
srv.mu.Unlock()
|
||||
if sends != 1 {
|
||||
t.Fatalf("message/send = %d", sends)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteMapsFailedAndInputRequired(t *testing.T) {
|
||||
card := AgentCard{ProtocolVersion: ProtocolVersion, Name: "远端", URL: "PLACEHOLDER", Version: "1"}
|
||||
srv := newFakeServer(t, card, []string{StateWorking, StateFailed}, nil)
|
||||
srv.statusMsg = "远端执行崩溃"
|
||||
ts := httptest.NewServer(srv.handler())
|
||||
defer ts.Close()
|
||||
srv.mu.Lock()
|
||||
srv.card.URL = ts.URL + "/rpc"
|
||||
srv.mu.Unlock()
|
||||
|
||||
ex := NewExecutor(card)
|
||||
ex.BaseURL = ts.URL + "/rpc"
|
||||
ex.Client = ts.Client()
|
||||
ex.PollInterval = 5 * time.Millisecond
|
||||
res, err := ex.Execute(context.Background(), squad.ExecRequest{Prompt: "x", Targets: []string{"output.md"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Success || !strings.Contains(res.ErrorReason, "failed") || !strings.Contains(res.ErrorReason, "远端执行崩溃") {
|
||||
t.Fatalf("failed 映射 = %+v", res)
|
||||
}
|
||||
|
||||
// input-required:fail-closed(跨进程人工输入不在适配面内)。
|
||||
srv2 := newFakeServer(t, card, []string{StateInputRequired}, nil)
|
||||
srv2.statusMsg = "请补充授权"
|
||||
ts2 := httptest.NewServer(srv2.handler())
|
||||
defer ts2.Close()
|
||||
ex2 := &Executor{BaseURL: ts2.URL + "/rpc", Client: ts2.Client(), PollInterval: 5 * time.Millisecond}
|
||||
res2, err := ex2.Execute(context.Background(), squad.ExecRequest{Prompt: "x", Targets: []string{"output.md"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res2.Success || !strings.Contains(res2.ErrorReason, "input-required") {
|
||||
t.Fatalf("input-required 映射 = %+v", res2)
|
||||
}
|
||||
}
|
||||
|
||||
// 直接回 Message(无任务态)→ 文本落目标。
|
||||
func TestExecuteDirectMessageResult(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req rpcRequest
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
writeRPCResult(w, req.ID, Message{Kind: "message", Role: "agent",
|
||||
Parts: []Part{{Kind: "text", Text: "直接回答正文"}}})
|
||||
}))
|
||||
defer ts.Close()
|
||||
ex := &Executor{BaseURL: ts.URL, Client: ts.Client()}
|
||||
res, err := ex.Execute(context.Background(), squad.ExecRequest{Prompt: "x", Targets: []string{"output.md"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !res.Success || !strings.Contains(string(res.Patches["output.md"]), "直接回答正文") {
|
||||
t.Fatalf("res = %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
// 传输/协议错误 → error(引擎按瞬态评估),不吞成成功。
|
||||
func TestExecuteProtocolErrors(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
writeRPCError(w, 1, -32601, "method not found")
|
||||
}))
|
||||
defer ts.Close()
|
||||
ex := &Executor{BaseURL: ts.URL, Client: ts.Client()}
|
||||
if _, err := ex.Execute(context.Background(), squad.ExecRequest{Prompt: "x"}); err == nil ||
|
||||
!strings.Contains(err.Error(), "协议错误") {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
// ctx 取消传播。
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
slow := newFakeServer(t, AgentCard{ProtocolVersion: "1.0", Name: "x", URL: ts.URL, Version: "1"},
|
||||
[]string{StateWorking}, nil)
|
||||
ts2 := httptest.NewServer(slow.handler())
|
||||
defer ts2.Close()
|
||||
ex2 := &Executor{BaseURL: ts2.URL + "/rpc", Client: ts2.Client(), PollInterval: time.Second}
|
||||
if _, err := ex2.Execute(ctx, squad.ExecRequest{Prompt: "x"}); err == nil {
|
||||
t.Fatal("取消必须传播")
|
||||
}
|
||||
}
|
||||
|
||||
// FetchCard fail-closed:缺必备字段拒绝。
|
||||
func TestFetchCardValidates(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"protocolVersion":"1.0","name":"","url":"","version":""}`))
|
||||
}))
|
||||
defer ts.Close()
|
||||
if _, err := FetchCard(ts.Client(), ts.URL); err == nil || !strings.Contains(err.Error(), "name") {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
package a2a
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"agent-squad/squad"
|
||||
)
|
||||
|
||||
// executor.go — 远端智能体 Executor 客户端(A2A 任务状态机对齐 ExecResult)。
|
||||
|
||||
// 任务状态(A2A 1.0 TaskState)。
|
||||
const (
|
||||
StateSubmitted = "submitted"
|
||||
StateWorking = "working"
|
||||
StateInputRequired = "input-required"
|
||||
StateCompleted = "completed"
|
||||
StateCanceled = "canceled"
|
||||
StateFailed = "failed"
|
||||
StateRejected = "rejected"
|
||||
)
|
||||
|
||||
// DefaultPollInterval 是 tasks/get 轮询缺省间隔。
|
||||
const DefaultPollInterval = 500 * time.Millisecond
|
||||
|
||||
// Executor 把远端 A2A 智能体接成 squad.Executor:一次步骤执行 = 一个
|
||||
// A2A 任务(message/send 发起,tasks/get 轮询到终态)。
|
||||
type Executor struct {
|
||||
// BaseURL 远端 JSON-RPC 端点(Agent Card 的 url)。
|
||||
BaseURL string
|
||||
// Client HTTP 客户端(nil = http.DefaultClient)。
|
||||
Client *http.Client
|
||||
// PollInterval 轮询间隔(0 = DefaultPollInterval)。
|
||||
PollInterval time.Duration
|
||||
// OnState 可选状态回调(观测任务状态机迁移)。
|
||||
OnState func(state string)
|
||||
|
||||
callID atomic.Int64
|
||||
}
|
||||
|
||||
// NewExecutor 按 Agent Card 构造远端执行器。
|
||||
func NewExecutor(card AgentCard) *Executor {
|
||||
return &Executor{BaseURL: card.URL}
|
||||
}
|
||||
|
||||
// Name 实现 squad.Executor。
|
||||
func (e *Executor) Name() string { return "a2a" }
|
||||
|
||||
// ---- A2A 1.0 报文形态(JSON-RPC 2.0) ----
|
||||
|
||||
type rpcRequest struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID int64 `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Params any `json:"params"`
|
||||
}
|
||||
|
||||
type rpcError struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type rpcResponse struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID int64 `json:"id"`
|
||||
Result json.RawMessage `json:"result,omitempty"`
|
||||
Error *rpcError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// Part 是消息/工件的部件(text | file | data)。
|
||||
type Part struct {
|
||||
Kind string `json:"kind"`
|
||||
Text string `json:"text,omitempty"`
|
||||
File *FilePart `json:"file,omitempty"`
|
||||
Data *json.RawMessage `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// FilePart 文件部件:bytes(base64)或 uri 二选一。
|
||||
type FilePart struct {
|
||||
Name string `json:"name"`
|
||||
Bytes string `json:"bytes,omitempty"`
|
||||
URI string `json:"uri,omitempty"`
|
||||
MimeType string `json:"mimeType,omitempty"`
|
||||
}
|
||||
|
||||
// Message 是一条 A2A 消息。
|
||||
type Message struct {
|
||||
Kind string `json:"kind"`
|
||||
Role string `json:"role"`
|
||||
Parts []Part `json:"parts"`
|
||||
MessageID string `json:"messageId"`
|
||||
}
|
||||
|
||||
// TaskStatus 任务状态。
|
||||
type TaskStatus struct {
|
||||
State string `json:"state"`
|
||||
Message *Message `json:"message,omitempty"`
|
||||
Timestamp string `json:"timestamp,omitempty"`
|
||||
}
|
||||
|
||||
// Artifact 任务工件。
|
||||
type Artifact struct {
|
||||
ArtifactID string `json:"artifactId,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Parts []Part `json:"parts"`
|
||||
}
|
||||
|
||||
// Task 是 A2A 任务(状态机载体)。
|
||||
type Task struct {
|
||||
Kind string `json:"kind"`
|
||||
ID string `json:"id"`
|
||||
ContextID string `json:"contextId,omitempty"`
|
||||
Status TaskStatus `json:"status"`
|
||||
Artifacts []Artifact `json:"artifacts,omitempty"`
|
||||
}
|
||||
|
||||
// Execute 实现 squad.Executor:发起任务并轮询到终态,映射 ExecResult。
|
||||
// 传输/协议错误返回 error(引擎按瞬态失败评估);任务级失败
|
||||
// (failed/canceled/rejected/input-required)返回 Success=false 的结果
|
||||
// (fail-closed:跨进程人工输入不在本适配面内)。
|
||||
func (e *Executor) Execute(ctx context.Context, req squad.ExecRequest) (*squad.ExecResult, error) {
|
||||
if strings.TrimSpace(e.BaseURL) == "" {
|
||||
return nil, fmt.Errorf("a2a: executor 缺 BaseURL")
|
||||
}
|
||||
prompt := strings.TrimSpace(req.Prompt)
|
||||
if prompt == "" {
|
||||
prompt = strings.TrimSpace(req.Instructions)
|
||||
}
|
||||
msg := Message{
|
||||
Kind: "message", Role: "user",
|
||||
Parts: []Part{{Kind: "text", Text: prompt}},
|
||||
MessageID: fmt.Sprintf("msg-%d-%d", time.Now().UnixNano(), e.callID.Add(1)),
|
||||
}
|
||||
raw, err := e.call(ctx, "message/send", map[string]any{"message": msg})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 服务端可直接回 Message(无任务态)或 Task。
|
||||
var probe struct {
|
||||
Kind string `json:"kind"`
|
||||
}
|
||||
_ = json.Unmarshal(raw, &probe)
|
||||
if probe.Kind == "message" {
|
||||
var m Message
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return nil, fmt.Errorf("a2a: message 结果解析失败: %w", err)
|
||||
}
|
||||
return e.resultFromParts(m.Parts, req.Targets), nil
|
||||
}
|
||||
var task Task
|
||||
if err := json.Unmarshal(raw, &task); err != nil {
|
||||
return nil, fmt.Errorf("a2a: task 结果解析失败: %w", err)
|
||||
}
|
||||
e.observe(task.Status.State)
|
||||
if shouldStop(task.Status.State) {
|
||||
return e.resultFromTask(task, req.Targets), nil
|
||||
}
|
||||
// 轮询任务状态机直至终态(ctx 取消传播)。
|
||||
interval := e.PollInterval
|
||||
if interval <= 0 {
|
||||
interval = DefaultPollInterval
|
||||
}
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-ticker.C:
|
||||
}
|
||||
raw, err := e.call(ctx, "tasks/get", map[string]any{"id": task.ID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var next Task
|
||||
if err := json.Unmarshal(raw, &next); err != nil {
|
||||
return nil, fmt.Errorf("a2a: tasks/get 解析失败: %w", err)
|
||||
}
|
||||
if next.Status.State != task.Status.State {
|
||||
task = next
|
||||
e.observe(task.Status.State)
|
||||
} else {
|
||||
task = next
|
||||
}
|
||||
if shouldStop(task.Status.State) {
|
||||
return e.resultFromTask(task, req.Targets), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// call 发一次 JSON-RPC 调用并取 result。
|
||||
func (e *Executor) call(ctx context.Context, method string, params any) (json.RawMessage, error) {
|
||||
client := e.Client
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
body, _ := json.Marshal(rpcRequest{
|
||||
JSONRPC: "2.0", ID: e.callID.Add(1), Method: method, Params: params,
|
||||
})
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, e.BaseURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("a2a: %s 传输失败: %w", method, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("a2a: %s 读取响应失败: %w", method, err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("a2a: %s 返回 HTTP %d", method, resp.StatusCode)
|
||||
}
|
||||
var env rpcResponse
|
||||
if err := json.Unmarshal(data, &env); err != nil {
|
||||
return nil, fmt.Errorf("a2a: %s 响应不是合法 JSON-RPC: %w", method, err)
|
||||
}
|
||||
if env.Error != nil {
|
||||
return nil, fmt.Errorf("a2a: %s 协议错误 %d: %s", method, env.Error.Code, env.Error.Message)
|
||||
}
|
||||
return env.Result, nil
|
||||
}
|
||||
|
||||
func (e *Executor) observe(state string) {
|
||||
if e.OnState != nil {
|
||||
e.OnState(state)
|
||||
}
|
||||
}
|
||||
|
||||
func isTerminal(state string) bool {
|
||||
switch state {
|
||||
case StateCompleted, StateFailed, StateCanceled, StateRejected:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// shouldStop 轮询停止条件:真终态,或 input-required(跨进程人工输入不在
|
||||
// 本适配面内,fail-closed 交引擎语义处理,不死等)。
|
||||
func shouldStop(state string) bool {
|
||||
return isTerminal(state) || state == StateInputRequired
|
||||
}
|
||||
|
||||
// resultFromTask 终态任务 → ExecResult。
|
||||
func (e *Executor) resultFromTask(task Task, targets []string) *squad.ExecResult {
|
||||
switch task.Status.State {
|
||||
case StateCompleted:
|
||||
var parts []Part
|
||||
for _, art := range task.Artifacts {
|
||||
parts = append(parts, art.Parts...)
|
||||
}
|
||||
return e.resultFromParts(parts, targets)
|
||||
case StateInputRequired:
|
||||
return &squad.ExecResult{Success: false,
|
||||
ErrorReason: "a2a: 远端要求人工输入(input-required):" + statusText(task.Status)}
|
||||
case StateCanceled:
|
||||
return &squad.ExecResult{Success: false,
|
||||
ErrorReason: "a2a: 远端任务被取消(canceled):" + statusText(task.Status)}
|
||||
case StateRejected:
|
||||
return &squad.ExecResult{Success: false,
|
||||
ErrorReason: "a2a: 远端拒绝任务(rejected):" + statusText(task.Status)}
|
||||
default: // failed 及未知终态一律 fail-closed
|
||||
return &squad.ExecResult{Success: false,
|
||||
ErrorReason: "a2a: 远端任务失败(" + task.Status.State + "):" + statusText(task.Status)}
|
||||
}
|
||||
}
|
||||
|
||||
// resultFromParts 部件流 → patches:FilePart(bytes) 按名落 patch;
|
||||
// 文本部件拼接后写入全部声明目标(无文件工件时的交付物兜底,
|
||||
// 与其他后端「正文写目标文件」契约一致)。
|
||||
func (e *Executor) resultFromParts(parts []Part, targets []string) *squad.ExecResult {
|
||||
patches := map[string][]byte{}
|
||||
var text strings.Builder
|
||||
for _, p := range parts {
|
||||
switch p.Kind {
|
||||
case "text":
|
||||
if text.Len() > 0 {
|
||||
text.WriteString("\n")
|
||||
}
|
||||
text.WriteString(p.Text)
|
||||
case "file":
|
||||
if p.File == nil || p.File.Name == "" {
|
||||
continue
|
||||
}
|
||||
data, err := base64.StdEncoding.DecodeString(p.File.Bytes)
|
||||
if err != nil {
|
||||
continue // uri 形态与坏 base64 不落 patch(消费侧最小面)
|
||||
}
|
||||
patches[p.File.Name] = data
|
||||
}
|
||||
}
|
||||
body := strings.TrimSpace(text.String())
|
||||
if len(targets) == 0 {
|
||||
targets = []string{"output.md"}
|
||||
}
|
||||
if body != "" {
|
||||
for _, t := range targets {
|
||||
if _, ok := patches[t]; !ok {
|
||||
patches[t] = []byte(body + "\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(patches) == 0 {
|
||||
return &squad.ExecResult{Success: false, ErrorReason: "a2a: 完成任务无任何工件"}
|
||||
}
|
||||
return &squad.ExecResult{Success: true, Patches: patches, Logs: body}
|
||||
}
|
||||
|
||||
func statusText(st TaskStatus) string {
|
||||
if st.Message == nil {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, p := range st.Message.Parts {
|
||||
if p.Kind == "text" {
|
||||
b.WriteString(p.Text)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
module agent-squad/adapters/a2a
|
||||
|
||||
go 1.27
|
||||
|
||||
require agent-squad v0.0.0
|
||||
|
||||
require error-code-system v0.0.0 // indirect
|
||||
|
||||
replace agent-squad => ../..
|
||||
|
||||
replace error-code-system => ../../deps/error-code-system
|
||||
@@ -6,22 +6,22 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"agent-native/exec"
|
||||
"agent-squad/squad"
|
||||
)
|
||||
|
||||
// Catalog 把识别快照渲染为 squad 的能力目录数据视图(规划者提示词 + 计划校验用)。
|
||||
// 闸门未启用(nil Gate)时返回 nil。
|
||||
func (g *Gate) Catalog() *squad.IntentCatalog {
|
||||
if g == nil || g.snap == nil {
|
||||
snap := g.Snapshot()
|
||||
if g == nil || snap == nil {
|
||||
return nil
|
||||
}
|
||||
catalog := &squad.IntentCatalog{
|
||||
Revision: g.snap.Revision(),
|
||||
Markdown: g.snap.CatalogMarkdown(),
|
||||
Specs: make(map[string]squad.IntentSpecView, len(g.snap.SpecIDs())),
|
||||
Revision: snap.Revision(),
|
||||
Markdown: snap.CatalogMarkdown(),
|
||||
Specs: make(map[string]squad.IntentSpecView, len(snap.SpecIDs())),
|
||||
}
|
||||
for _, cs := range g.snap.Catalog() {
|
||||
for _, cs := range snap.Catalog() {
|
||||
view := squad.IntentSpecView{
|
||||
ID: cs.ID,
|
||||
Description: cs.Description,
|
||||
@@ -66,11 +66,11 @@ func (g *Gate) EchoRunner() *Runner {
|
||||
}
|
||||
|
||||
// Run 实现 squad.IntentRunner。未知意图/未知 handler fail-closed(不静默降级)。
|
||||
func (r *Runner) Run(ctx context.Context, step squad.IntentStep) (*exec.Result, error) {
|
||||
if r == nil || r.gate == nil || r.gate.snap == nil {
|
||||
func (r *Runner) Run(ctx context.Context, step squad.IntentStep) (*squad.ExecResult, error) {
|
||||
if r == nil || r.gate == nil || r.gate.Snapshot() == nil {
|
||||
return nil, fmt.Errorf("intent runner: gate not configured")
|
||||
}
|
||||
spec, ok := r.gate.snap.SpecByID(step.IntentID)
|
||||
spec, ok := r.gate.Snapshot().SpecByID(step.IntentID)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("intent runner: snapshot 中不存在意图 %s", step.IntentID)
|
||||
}
|
||||
@@ -90,12 +90,12 @@ func (r *Runner) Run(ctx context.Context, step squad.IntentStep) (*exec.Result,
|
||||
return textResult(step, text), nil
|
||||
}
|
||||
|
||||
func textResult(step squad.IntentStep, text string) *exec.Result {
|
||||
func textResult(step squad.IntentStep, text string) *squad.ExecResult {
|
||||
patches := make(map[string][]byte, len(step.Targets))
|
||||
for _, t := range step.Targets {
|
||||
patches[t] = []byte(text)
|
||||
}
|
||||
return &exec.Result{Success: true, Patches: patches, Logs: "intent:" + step.IntentID}
|
||||
return &squad.ExecResult{Success: true, Patches: patches, Logs: "intent:" + step.IntentID}
|
||||
}
|
||||
|
||||
func echoText(step squad.IntentStep, handlerKey string, _ bool) string {
|
||||
@@ -120,3 +120,25 @@ func TestNilGateCatalogAndRunner(t *testing.T) {
|
||||
t.Fatal("nil gate runner must fail-closed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestR2GateReloadSeesNewIntent(t *testing.T) {
|
||||
fsys := fstest.MapFS{"intents.json": &fstest.MapFile{Data: []byte(`{"intents":[{"id":"demo.old","domain":"demo","version":1,"matcher":{"includes_any":["旧"]}}]}`)}}
|
||||
g, err := New(context.Background(), Options{
|
||||
IntentOptions: intent.Options{},
|
||||
FS: fsys,
|
||||
Roots: []string{"."},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := g.Snapshot().SpecByID("demo.new"); ok {
|
||||
t.Fatal("new spec exists before reload")
|
||||
}
|
||||
fsys["intents.json"].Data = []byte(`{"intents":[{"id":"demo.old","domain":"demo","version":1,"matcher":{"includes_any":["旧"]}},{"id":"demo.new","domain":"demo","version":1,"matcher":{"includes_any":["新"]}}]}`)
|
||||
if _, err := g.Reload(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := g.Snapshot().SpecByID("demo.new"); !ok {
|
||||
t.Fatal("reload did not publish demo.new")
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,21 @@ type Options struct {
|
||||
Roots []string
|
||||
}
|
||||
|
||||
// NewFromOverlay 从 JSON overlay 构造闸门,并把 guard 域标成受保护域。
|
||||
// 命令行用它,这样命令本身不必 import agent-intent。
|
||||
// NFKC 开启:全角/兼容等价输入与词条同形折叠(与 backend intentgate 一致)。
|
||||
func NewFromOverlay(ctx context.Context, fsys fs.FS, roots ...string) (*Gate, error) {
|
||||
return New(ctx, Options{
|
||||
IntentOptions: intent.Options{
|
||||
GuardDomains: []string{"guard"},
|
||||
ProtectedDomains: []string{"guard"},
|
||||
NFKC: true,
|
||||
},
|
||||
FS: fsys,
|
||||
Roots: roots,
|
||||
})
|
||||
}
|
||||
|
||||
// New 构造闸门。没有任何意图来源时返回 (nil, nil)——引擎拿到 nil IntentGate,
|
||||
// 行为与原实现一致(T0 行为解耦)。
|
||||
func New(ctx context.Context, o Options) (*Gate, error) {
|
||||
@@ -55,32 +70,40 @@ func New(ctx context.Context, o Options) (*Gate, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Gate{rec: intent.NewRecognizer(o.IntentOptions, nil, nil), snap: reg.Snapshot()}, nil
|
||||
return &Gate{rec: intent.NewRecognizer(o.IntentOptions, nil, nil), reg: reg}, nil
|
||||
}
|
||||
|
||||
// Gate 实现 squad.IntentGate。
|
||||
// Gate 实现 squad.IntentGate。快照从 Registry 现读,Reload 后下一次 Decide 即生效。
|
||||
type Gate struct {
|
||||
rec intent.Recognizer
|
||||
snap *intent.Snapshot
|
||||
rec intent.Recognizer
|
||||
reg *intent.Registry
|
||||
}
|
||||
|
||||
// Snapshot 暴露当前识别快照(诊断/审计用;Reload 由宿主经重建闸门实现)。
|
||||
// Snapshot 暴露当前识别快照(诊断/审计用)。
|
||||
func (g *Gate) Snapshot() *intent.Snapshot {
|
||||
if g == nil {
|
||||
if g == nil || g.reg == nil {
|
||||
return nil
|
||||
}
|
||||
return g.snap
|
||||
return g.reg.Snapshot()
|
||||
}
|
||||
|
||||
// Reload 重读配置来源。失败时保留旧快照。
|
||||
func (g *Gate) Reload(ctx context.Context) (*intent.Snapshot, error) {
|
||||
if g == nil || g.reg == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return g.reg.Reload(ctx)
|
||||
}
|
||||
|
||||
// Decide 实现 squad.IntentGate。同输入 + 同快照结果确定;无副作用。
|
||||
func (g *Gate) Decide(ctx context.Context, goal squad.Goal, opts squad.RunOptions) (*squad.IntentDecision, error) {
|
||||
if g == nil || g.snap == nil {
|
||||
if g == nil || g.Snapshot() == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if opts.TaskKey != "" {
|
||||
return nil, nil // 显式 TaskKey 优先(引擎侧同样保证,双保险)
|
||||
}
|
||||
res, err := g.rec.Recognize(ctx, goal.Text, g.snap)
|
||||
res, err := g.rec.Recognize(ctx, goal.Text, g.Snapshot())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
module agent-squad/adapters/intent
|
||||
|
||||
go 1.27
|
||||
|
||||
require (
|
||||
agent-intent v0.0.0
|
||||
agent-squad v0.0.0
|
||||
)
|
||||
|
||||
require (
|
||||
error-code-system v0.0.0 // indirect
|
||||
golang.org/x/text v0.29.0 // indirect
|
||||
logging-system v0.0.0 // indirect
|
||||
)
|
||||
|
||||
replace agent-intent => ../../deps/agent-intent
|
||||
|
||||
replace agent-squad => ../..
|
||||
|
||||
replace error-code-system => ../../deps/error-code-system
|
||||
|
||||
replace logging-system => ../../deps/logging-system
|
||||
@@ -0,0 +1,2 @@
|
||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"agent-native/exec"
|
||||
"agent-squad/squad"
|
||||
)
|
||||
|
||||
@@ -16,7 +15,7 @@ type integrationScriptBackend struct{ content string }
|
||||
|
||||
func (b *integrationScriptBackend) Name() string { return "script" }
|
||||
|
||||
func (b *integrationScriptBackend) Execute(ctx context.Context, req exec.Request) (*exec.Result, error) {
|
||||
func (b *integrationScriptBackend) Execute(ctx context.Context, req squad.ExecRequest) (*squad.ExecResult, error) {
|
||||
patches := map[string][]byte{}
|
||||
for _, t := range req.Targets {
|
||||
out := filepath.Join(req.WorkDir, t)
|
||||
@@ -26,7 +25,7 @@ func (b *integrationScriptBackend) Execute(ctx context.Context, req exec.Request
|
||||
}
|
||||
patches[t] = []byte(b.content)
|
||||
}
|
||||
return &exec.Result{Success: true, Patches: patches, Logs: "script"}, nil
|
||||
return &squad.ExecResult{Success: true, Patches: patches, Logs: "script"}, nil
|
||||
}
|
||||
|
||||
const intentReport = "# 竞品分析报告\n\n## 概述\n\n由意图定向的蓝图模板生成的交付物,长度满足最小质检要求。\n\n## 结论\n\n符合预期。\n"
|
||||
@@ -0,0 +1,21 @@
|
||||
module agent-squad/adapters/native
|
||||
|
||||
go 1.27
|
||||
|
||||
require (
|
||||
agent-native v0.0.0
|
||||
agent-squad v0.0.0
|
||||
)
|
||||
|
||||
require (
|
||||
error-code-system v0.0.0 // indirect
|
||||
logging-system v0.0.0 // indirect
|
||||
)
|
||||
|
||||
replace agent-native => ../../deps/agent-native
|
||||
|
||||
replace agent-squad => ../..
|
||||
|
||||
replace error-code-system => ../../deps/error-code-system
|
||||
|
||||
replace logging-system => ../../deps/logging-system
|
||||
@@ -0,0 +1,205 @@
|
||||
// Package native 把 agent-native 接成小队的 Executor 与 ModelClient。
|
||||
package native
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
nativexec "agent-native/adapters/native"
|
||||
"agent-native/exec"
|
||||
"agent-native/runtime"
|
||||
"agent-native/runtime/checkpoint"
|
||||
"agent-native/runtime/hooks"
|
||||
"agent-native/runtime/llm"
|
||||
"agent-native/runtime/mcp"
|
||||
"agent-native/runtime/subagent"
|
||||
"agent-native/runtime/tools"
|
||||
"agent-squad/squad"
|
||||
)
|
||||
|
||||
// Config 是组合根交给 agent-native 后端的装配。零值只做模型执行。
|
||||
type Config struct {
|
||||
Client *llm.Client
|
||||
DefaultModel string
|
||||
MaxTurns int
|
||||
Timeout time.Duration
|
||||
Search tools.SearchProvider
|
||||
Exec *tools.ExecConfig
|
||||
Fetch *tools.FetchConfig
|
||||
MCP *mcp.Manager
|
||||
SkillsRoots []string
|
||||
Subagent subagent.Factory
|
||||
Compact *nativexec.CompactConfig
|
||||
HostTools []tools.Tool
|
||||
OnEvent func(ev squad.ProcessEvent)
|
||||
Stream llm.StreamFunc
|
||||
MaxToolCalls int
|
||||
BeforeTool func(call llm.ToolCall) error
|
||||
AfterTool func(call llm.ToolCall, res llm.Message)
|
||||
Stop func(last llm.Message, info runtime.StopInfo) runtime.StopDecision
|
||||
Reminders func(turn int) []string
|
||||
RepeatGuard bool
|
||||
Hooks *hooks.Bus
|
||||
Checkpoints *checkpoint.Log
|
||||
CheckpointAnchor func() string
|
||||
Trace func(name string, attrs map[string]string) func()
|
||||
CodeOrchestration bool
|
||||
Ask func(ctx context.Context, question string) (string, error)
|
||||
// PermissionAsk 把内核钩子的 ask 决策转交宿主裁决(nil = 内核保持
|
||||
// ask 按拒绝处理的旧行为)。经 nativexec.GateOptions 透传。
|
||||
PermissionAsk func(ctx context.Context, call llm.ToolCall) runtime.AskVerdict
|
||||
}
|
||||
|
||||
// Register 把 "agent-native" 工厂注册进解析器。
|
||||
func Register(r *squad.BackendResolver, client *llm.Client, defaultModel string, maxTurns int, timeout time.Duration) {
|
||||
RegisterFull(r, Config{Client: client, DefaultModel: defaultModel, MaxTurns: maxTurns, Timeout: timeout})
|
||||
}
|
||||
|
||||
// RegisterFull 注册带工具装配的 "agent-native" 工厂。
|
||||
func RegisterFull(r *squad.BackendResolver, cfg Config) {
|
||||
r.Register("agent-native", func(role squad.RoleSpec) (squad.Executor, error) {
|
||||
opts, err := buildOptions(cfg, role)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
next := &bridge{next: nativexec.New(opts)}
|
||||
if cfg.OnEvent == nil {
|
||||
return next, nil
|
||||
}
|
||||
return &eventTap{next: next, on: cfg.OnEvent}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func buildOptions(cfg Config, role squad.RoleSpec) (nativexec.Options, error) {
|
||||
model := role.Model
|
||||
if model == "" {
|
||||
model = cfg.DefaultModel
|
||||
}
|
||||
if model == "" {
|
||||
return nativexec.Options{}, fmt.Errorf("agent-native backend requires a model (role=%s)", role.Role)
|
||||
}
|
||||
opts := nativexec.Options{
|
||||
ModelOptions: nativexec.ModelOptions{
|
||||
Model: model, MaxTurns: cfg.MaxTurns, Timeout: cfg.Timeout, Stream: cfg.Stream,
|
||||
},
|
||||
ToolOptions: nativexec.ToolOptions{
|
||||
Search: cfg.Search, Fetch: cfg.Fetch, MCP: cfg.MCP,
|
||||
SkillsRoots: cfg.SkillsRoots, Subagent: cfg.Subagent, HostTools: cfg.HostTools,
|
||||
Checkpoints: cfg.Checkpoints, CheckpointAnchor: cfg.CheckpointAnchor,
|
||||
CodeOrchestration: cfg.CodeOrchestration,
|
||||
},
|
||||
ObserveOptions: nativexec.ObserveOptions{Trace: cfg.Trace},
|
||||
SessionOptions: nativexec.SessionOptions{Compact: cfg.Compact, MaxToolCalls: cfg.MaxToolCalls},
|
||||
GateOptions: nativexec.GateOptions{
|
||||
BeforeTool: cfg.BeforeTool, AfterTool: cfg.AfterTool, Stop: cfg.Stop,
|
||||
Reminders: cfg.Reminders, RepeatGuard: cfg.RepeatGuard, Hooks: cfg.Hooks,
|
||||
Ask: cfg.Ask, PermissionAsk: cfg.PermissionAsk,
|
||||
},
|
||||
}
|
||||
if cfg.Client != nil {
|
||||
opts.APIBase, opts.APIKey = cfg.Client.BaseURL, cfg.Client.APIKey
|
||||
}
|
||||
if cfg.Exec != nil && (role.BashPermission == squad.BashPermissionAllow || role.BashPermission == squad.BashPermissionAsk) {
|
||||
opts.Exec = cfg.Exec
|
||||
}
|
||||
return opts, nil
|
||||
}
|
||||
|
||||
type bridge struct{ next exec.Backend }
|
||||
|
||||
func (b *bridge) Name() string { return b.next.Name() }
|
||||
|
||||
func (b *bridge) Execute(ctx context.Context, req squad.ExecRequest) (*squad.ExecResult, error) {
|
||||
res, err := b.next.Execute(ctx, toNative(req))
|
||||
if res == nil {
|
||||
return nil, err
|
||||
}
|
||||
return fromNative(res), err
|
||||
}
|
||||
|
||||
func toNative(req squad.ExecRequest) exec.Request {
|
||||
out := exec.Request{
|
||||
WorkDir: req.WorkDir, Prompt: req.Prompt, Instructions: req.Instructions,
|
||||
Targets: req.Targets, Timeout: req.Timeout, ToolProfile: req.ToolProfile,
|
||||
Model: req.Model, ToolAllowlist: req.ToolAllowlist, Skills: req.Skills,
|
||||
SessionScope: req.SessionScope, ExecutionID: req.ExecutionID, Env: req.Env,
|
||||
}
|
||||
if req.Budget != nil {
|
||||
out.Budget = &exec.Budget{
|
||||
MaxPromptTokens: req.Budget.MaxPromptTokens, MaxCompletionTokens: req.Budget.MaxCompletionTokens,
|
||||
MaxToolCalls: req.Budget.MaxToolCalls, Deadline: req.Budget.Deadline,
|
||||
}
|
||||
}
|
||||
if req.OnEvent != nil {
|
||||
prev := req.OnEvent
|
||||
out.OnEvent = func(ev exec.ProcessEvent) {
|
||||
prev(squad.ProcessEvent{
|
||||
Type: ev.Type, Text: ev.Text, Tool: ev.Tool, Detail: ev.Detail,
|
||||
PromptTokens: ev.PromptTokens, CompletionTokens: ev.CompletionTokens,
|
||||
LastPromptTokens: ev.LastPromptTokens, LastCompletionTokens: ev.LastCompletionTokens,
|
||||
CacheReadTokens: ev.CacheReadTokens, CacheWriteTokens: ev.CacheWriteTokens,
|
||||
})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func fromNative(res *exec.Result) *squad.ExecResult {
|
||||
return &squad.ExecResult{
|
||||
Success: res.Success, Patches: res.Patches, Logs: res.Logs, ErrorReason: res.ErrorReason,
|
||||
Retryable: res.Retryable, PromptTokens: res.PromptTokens, CompletionTokens: res.CompletionTokens,
|
||||
}
|
||||
}
|
||||
|
||||
type eventTap struct {
|
||||
next squad.Executor
|
||||
on func(squad.ProcessEvent)
|
||||
}
|
||||
|
||||
func (b *eventTap) Name() string { return b.next.Name() }
|
||||
|
||||
func (b *eventTap) Execute(ctx context.Context, req squad.ExecRequest) (*squad.ExecResult, error) {
|
||||
prev := req.OnEvent
|
||||
req.OnEvent = func(ev squad.ProcessEvent) {
|
||||
b.on(ev)
|
||||
if prev != nil {
|
||||
prev(ev)
|
||||
}
|
||||
}
|
||||
return b.next.Execute(ctx, req)
|
||||
}
|
||||
|
||||
// LLM 把 llm.Client 适配成 ModelClient。
|
||||
type LLM struct{ Inner *llm.Client }
|
||||
|
||||
func (c LLM) Complete(ctx context.Context, turn squad.ModelTurn) (string, error) {
|
||||
if c.Inner == nil {
|
||||
return "", fmt.Errorf("llm planner: no model client")
|
||||
}
|
||||
msgs := []llm.Message{
|
||||
{Role: llm.RoleSystem, Content: turn.System},
|
||||
{Role: llm.RoleUser, Content: turn.User},
|
||||
}
|
||||
opts := llm.StreamOptions{MaxTokens: turn.MaxTokens, APIBase: turn.APIBase, APIKey: turn.APIKey}
|
||||
var sb strings.Builder
|
||||
for ev := range c.Inner.Stream(ctx, turn.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
|
||||
}
|
||||
}
|
||||
return sb.String(), nil
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package native
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"agent-native/runtime/llm"
|
||||
"agent-native/runtime/tools"
|
||||
"agent-squad/squad"
|
||||
)
|
||||
|
||||
func TestBuildOptionsBashPermissionGating(t *testing.T) {
|
||||
base := Config{
|
||||
Client: &llm.Client{BaseURL: "http://x", APIKey: "k"},
|
||||
DefaultModel: "m1", MaxTurns: 5, Timeout: time.Minute,
|
||||
Exec: &tools.ExecConfig{Bash: "/bin/bash"},
|
||||
SkillsRoots: []string{"/skills"},
|
||||
}
|
||||
deny, err := buildOptions(base, squad.RoleSpec{Role: "r", BashPermission: squad.BashPermissionDeny})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if deny.Exec != nil {
|
||||
t.Fatal("deny role must not get exec tools even when config provides them")
|
||||
}
|
||||
ask, _ := buildOptions(base, squad.RoleSpec{Role: "r", BashPermission: squad.BashPermissionAsk})
|
||||
if ask.Exec == nil {
|
||||
t.Fatal("ask role gets exec tools")
|
||||
}
|
||||
allow, _ := buildOptions(base, squad.RoleSpec{Role: "r", BashPermission: squad.BashPermissionAllow})
|
||||
if allow.Exec == nil {
|
||||
t.Fatal("allow role gets exec tools")
|
||||
}
|
||||
empty, _ := buildOptions(base, squad.RoleSpec{Role: "r"})
|
||||
if empty.Exec != nil {
|
||||
t.Fatal("unset bash_permission defaults to deny")
|
||||
}
|
||||
none, _ := buildOptions(Config{Client: base.Client, DefaultModel: "m"}, squad.RoleSpec{Role: "r", BashPermission: squad.BashPermissionAllow})
|
||||
if none.Exec != nil {
|
||||
t.Fatal("no Exec config = no exec tools")
|
||||
}
|
||||
roleModel, _ := buildOptions(base, squad.RoleSpec{Role: "r", Model: "big"})
|
||||
if roleModel.Model != "big" {
|
||||
t.Fatalf("role model must win, got %s", roleModel.Model)
|
||||
}
|
||||
if _, err := buildOptions(Config{Client: base.Client}, squad.RoleSpec{Role: "r"}); err == nil {
|
||||
t.Fatal("missing model must fail-closed")
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ type Store struct {
|
||||
}
|
||||
|
||||
func Open(path string) (*Store, error) {
|
||||
db, err := sql.Open("sqlite", path+"?_pragma=busy_timeout(5000)")
|
||||
db, err := sql.Open("sqlite", path+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
module agent-squad/adapters/sqlite
|
||||
|
||||
go 1.27
|
||||
|
||||
require (
|
||||
agent-native v0.0.0
|
||||
agent-squad v0.0.0
|
||||
modernc.org/sqlite v1.37.0
|
||||
)
|
||||
|
||||
require (
|
||||
error-code-system v0.0.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
|
||||
golang.org/x/sys v0.31.0 // indirect
|
||||
logging-system v0.0.0 // indirect
|
||||
modernc.org/libc v1.62.1 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.9.1 // indirect
|
||||
)
|
||||
|
||||
replace agent-native => ../../deps/agent-native
|
||||
|
||||
replace agent-squad => ../..
|
||||
|
||||
replace error-code-system => ../../deps/error-code-system
|
||||
|
||||
replace logging-system => ../../deps/logging-system
|
||||
@@ -0,0 +1,47 @@
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
|
||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
|
||||
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
|
||||
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
|
||||
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU=
|
||||
golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ=
|
||||
modernc.org/cc/v4 v4.25.2 h1:T2oH7sZdGvTaie0BRNFbIYsabzCxUQg8nLqCdQ2i0ic=
|
||||
modernc.org/cc/v4 v4.25.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.25.1 h1:TFSzPrAGmDsdnhT9X2UrcPMI3N/mJ9/X9ykKXwLhDsU=
|
||||
modernc.org/ccgo/v4 v4.25.1/go.mod h1:njjuAYiPflywOOrm3B7kCB444ONP5pAVr8PIEoE0uDw=
|
||||
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
|
||||
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/libc v1.62.1 h1:s0+fv5E3FymN8eJVmnk0llBe6rOxCu/DEU+XygRbS8s=
|
||||
modernc.org/libc v1.62.1/go.mod h1:iXhATfJQLjG3NWy56a6WVU73lWOcdYVxsvwCgoPljuo=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.9.1 h1:V/Z1solwAVmMW1yttq3nDdZPJqV1rM05Ccq6KMSZ34g=
|
||||
modernc.org/memory v1.9.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI=
|
||||
modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"agent-native/runtime/llm"
|
||||
agentsquad "agent-squad/squad"
|
||||
)
|
||||
|
||||
func TestOrchestrationFlow(t *testing.T) {
|
||||
@@ -116,12 +116,10 @@ func TestOrchestrationFlow(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text, err := s.RunInProcess(context.Background(), woke, work, func(ctx context.Context, model string, messages []llm.Message, td []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
|
||||
ch := make(chan llm.Event, 1)
|
||||
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd}}
|
||||
close(ch)
|
||||
return ch
|
||||
})
|
||||
text, err := s.RunInProcess(context.Background(), woke, work, stubExecutor{result: &agentsquad.ExecResult{
|
||||
Success: true, Logs: "done",
|
||||
Patches: map[string][]byte{"output.md": []byte("done")},
|
||||
}})
|
||||
if err != nil || text != "done" {
|
||||
t.Fatalf("in-process %q %v", text, err)
|
||||
}
|
||||
@@ -233,6 +231,17 @@ func openTest(t *testing.T) *Store {
|
||||
return s
|
||||
}
|
||||
|
||||
func TestR2JournalModeWAL(t *testing.T) {
|
||||
s := openTest(t)
|
||||
var mode string
|
||||
if err := s.db.QueryRow(`PRAGMA journal_mode`).Scan(&mode); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if mode != "wal" {
|
||||
t.Fatalf("journal_mode %q", mode)
|
||||
}
|
||||
}
|
||||
|
||||
func claimAny(t *testing.T, s *Store, workspaceID string) (Task, bool, error) {
|
||||
t.Helper()
|
||||
rt, err := s.RegisterRuntime(Runtime{WorkspaceID: workspaceID, Name: "tmp", Command: "in-process", Protocol: "agent-native"})
|
||||
@@ -233,8 +233,10 @@ func (s *Store) Claim(runtimeID string, at time.Time) (Task, bool, error) {
|
||||
continue
|
||||
}
|
||||
lease := at.UTC().Add(5 * time.Minute).Format(time.RFC3339Nano)
|
||||
res, err := s.db.Exec(`UPDATE task SET status='dispatched', runtime_id=?, lease_until=?, updated_at=? WHERE id=? AND status='queued'`,
|
||||
runtimeID, lease, now(), cand.id)
|
||||
res, err := s.db.Exec(`UPDATE task SET status='dispatched', runtime_id=?, lease_until=?, updated_at=?
|
||||
WHERE id=? AND status='queued'
|
||||
AND (SELECT COUNT(*) FROM task WHERE agent_id=? AND status IN ('dispatched','running')) < ?`,
|
||||
runtimeID, lease, now(), cand.id, cand.agentID, cap)
|
||||
if err != nil {
|
||||
return Task{}, false, err
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"agent-squad/squad"
|
||||
)
|
||||
|
||||
// SQLiteStore 把 squad 的 Store 端口落到本库(Q1 WP5):run/gate/checkpoint/leader
|
||||
// 与平台其余表同一个 SQLite 文件——运行状态与平台状态不再各记一份。
|
||||
// 表名带 squad_run_ 前缀,与平台既有 23 张表并存不冲突。
|
||||
type SQLiteStore struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// OpenRunStore 在本 Store 的底层连接上提供 squad.Store 端口(schema 随 migrate 建)。
|
||||
func (s *Store) RunStore() *SQLiteStore {
|
||||
return &SQLiteStore{db: s.db}
|
||||
}
|
||||
|
||||
const runSchema = `
|
||||
CREATE TABLE IF NOT EXISTS squad_run_state (
|
||||
ws_dir TEXT PRIMARY KEY,
|
||||
meta TEXT NOT NULL, workflow TEXT NOT NULL, squad TEXT NOT NULL, bb TEXT NOT NULL,
|
||||
template TEXT, approved TEXT, repair_budget_remaining INTEGER NOT NULL DEFAULT 0,
|
||||
repair_budget_granted INTEGER NOT NULL DEFAULT 0,
|
||||
clarifications TEXT, steering TEXT, usage TEXT, updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS squad_run_gate (
|
||||
ws_dir TEXT PRIMARY KEY, gate TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS squad_run_checkpoint (
|
||||
ws_dir TEXT NOT NULL, superstep INTEGER NOT NULL, at TEXT NOT NULL,
|
||||
graph_hash TEXT NOT NULL, steps TEXT NOT NULL,
|
||||
PRIMARY KEY (ws_dir, superstep)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS squad_run_leader (
|
||||
ws_dir TEXT PRIMARY KEY, data TEXT NOT NULL
|
||||
);
|
||||
`
|
||||
|
||||
func (s *SQLiteStore) migrate() error {
|
||||
_, err := s.db.Exec(runSchema)
|
||||
return err
|
||||
}
|
||||
|
||||
// OpenSQLiteStore 独立打开一个 squad.Store(不经 platform.Store 的场景,如测试)。
|
||||
func OpenSQLiteStore(path string) (*SQLiteStore, error) {
|
||||
db, err := sql.Open("sqlite", path+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s := &SQLiteStore{db: db}
|
||||
if err := s.migrate(); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func j(v any) string {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) SaveRun(st *squad.RunState) error {
|
||||
if err := s.migrate(); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := s.db.Exec(`INSERT INTO squad_run_state
|
||||
(ws_dir, meta, workflow, squad, bb, template, approved, repair_budget_remaining, repair_budget_granted, clarifications, steering, usage, updated_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(ws_dir) DO UPDATE SET
|
||||
meta=excluded.meta, workflow=excluded.workflow, squad=excluded.squad, bb=excluded.bb,
|
||||
template=excluded.template, approved=excluded.approved,
|
||||
repair_budget_remaining=excluded.repair_budget_remaining,
|
||||
repair_budget_granted=excluded.repair_budget_granted,
|
||||
clarifications=excluded.clarifications, steering=excluded.steering,
|
||||
usage=excluded.usage, updated_at=excluded.updated_at`,
|
||||
st.Meta.WorkDir, j(st.Meta), j(st.Plan.Workflow), j(st.Plan.Squad), j(st.BB),
|
||||
st.Template, j(st.Approved), st.RepairBudgetRemaining, st.RepairBudgetGranted,
|
||||
j(st.Clarifications), j(st.Steering), j(st.Usage), now())
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) LoadRun(wsDir string) (*squad.RunState, error) {
|
||||
if err := s.migrate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
st := &squad.RunState{}
|
||||
var meta, wf, sq, bb, template, approved, clar, steering, usage string
|
||||
row := s.db.QueryRow(`SELECT meta, workflow, squad, bb, template, approved,
|
||||
repair_budget_remaining, repair_budget_granted, clarifications, steering, usage
|
||||
FROM squad_run_state WHERE ws_dir = ?`, wsDir)
|
||||
var granted int
|
||||
err := row.Scan(&meta, &wf, &sq, &bb, &template, &approved,
|
||||
&st.RepairBudgetRemaining, &granted, &clar, &steering, &usage)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("load run %s: %w", wsDir, os.ErrNotExist)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
st.RepairBudgetGranted = granted
|
||||
for pair, dst := range map[string]any{
|
||||
meta: &st.Meta, wf: &st.Plan.Workflow, sq: &st.Plan.Squad, bb: &st.BB,
|
||||
approved: &st.Approved, clar: &st.Clarifications, steering: &st.Steering, usage: &st.Usage,
|
||||
} {
|
||||
if err := json.Unmarshal([]byte(pair), dst); err != nil {
|
||||
return nil, fmt.Errorf("load run %s: parse: %w", wsDir, err)
|
||||
}
|
||||
}
|
||||
st.Template = template
|
||||
if st.Approved == nil {
|
||||
st.Approved = map[string]bool{}
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) SaveGate(wsDir string, gate *squad.HitlGate) error {
|
||||
if err := s.migrate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if gate == nil {
|
||||
_, err := s.db.Exec(`DELETE FROM squad_run_gate WHERE ws_dir = ?`, wsDir)
|
||||
return err
|
||||
}
|
||||
_, err := s.db.Exec(`INSERT INTO squad_run_gate (ws_dir, gate) VALUES (?,?)
|
||||
ON CONFLICT(ws_dir) DO UPDATE SET gate=excluded.gate`, wsDir, j(gate))
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) LoadGate(wsDir string) (*squad.HitlGate, error) {
|
||||
if err := s.migrate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var raw string
|
||||
err := s.db.QueryRow(`SELECT gate FROM squad_run_gate WHERE ws_dir = ?`, wsDir).Scan(&raw)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
gate := &squad.HitlGate{}
|
||||
if err := json.Unmarshal([]byte(raw), gate); err != nil {
|
||||
return nil, fmt.Errorf("parse gate: %w", err)
|
||||
}
|
||||
return gate, nil
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) SaveCheckpoint(wsDir string, cp squad.Checkpoint) error {
|
||||
if err := s.migrate(); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := s.db.Exec(`INSERT INTO squad_run_checkpoint (ws_dir, superstep, at, graph_hash, steps)
|
||||
VALUES (?,?,?,?,?)
|
||||
ON CONFLICT(ws_dir, superstep) DO UPDATE SET at=excluded.at, graph_hash=excluded.graph_hash, steps=excluded.steps`,
|
||||
wsDir, cp.Superstep, cp.At.UTC().Format("2006-01-02T15:04:05Z07:00"), cp.GraphHash, j(cp.StepStates))
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) LoadLatestCheckpoint(wsDir string) (*squad.Checkpoint, error) {
|
||||
if err := s.migrate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var cp squad.Checkpoint
|
||||
var at, steps string
|
||||
err := s.db.QueryRow(`SELECT superstep, at, graph_hash, steps FROM squad_run_checkpoint
|
||||
WHERE ws_dir = ? ORDER BY superstep DESC LIMIT 1`, wsDir).
|
||||
Scan(&cp.Superstep, &at, &cp.GraphHash, &steps)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
t, _ := parseTime(at)
|
||||
cp.At = t
|
||||
if err := json.Unmarshal([]byte(steps), &cp.StepStates); err != nil {
|
||||
return nil, fmt.Errorf("parse checkpoint steps: %w", err)
|
||||
}
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) SaveLeader(wsDir string, data []byte) error {
|
||||
if err := s.migrate(); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := s.db.Exec(`INSERT INTO squad_run_leader (ws_dir, data) VALUES (?,?)
|
||||
ON CONFLICT(ws_dir) DO UPDATE SET data=excluded.data`, wsDir, string(data))
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SQLiteStore) LoadLeader(wsDir string) ([]byte, error) {
|
||||
if err := s.migrate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var raw string
|
||||
err := s.db.QueryRow(`SELECT data FROM squad_run_leader WHERE ws_dir = ?`, wsDir).Scan(&raw)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return []byte(raw), nil
|
||||
}
|
||||
|
||||
// Close 只在独立打开(OpenSQLiteStore)时调用;经 platform.Store 打开时由平台统一关闭。
|
||||
func (s *SQLiteStore) Close() error { return s.db.Close() }
|
||||
|
||||
var _ squad.Store = (*SQLiteStore)(nil)
|
||||
|
||||
// parseTime 解析检查点时间(RFC3339Nano 与 RFC3339 两态)。
|
||||
func parseTime(s string) (time.Time, error) {
|
||||
for _, layout := range []string{time.RFC3339Nano, time.RFC3339} {
|
||||
if t, err := time.Parse(layout, s); err == nil {
|
||||
return t, nil
|
||||
}
|
||||
}
|
||||
return time.Time{}, fmt.Errorf("bad time %q", s)
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"agent-squad/squad"
|
||||
)
|
||||
|
||||
func newTestRunState(wsDir string) *squad.RunState {
|
||||
return &squad.RunState{
|
||||
Meta: squad.RunMetadata{
|
||||
ID: "run_test", Status: "executing", WorkDir: wsDir,
|
||||
CreatedAt: time.Now(), UpdatedAt: time.Now(),
|
||||
Metadata: map[string]string{"mode": ""},
|
||||
},
|
||||
Plan: squad.SquadPlan{
|
||||
Workflow: squad.WorkflowPlan{
|
||||
WorkflowID: "wf1", Status: "in_progress",
|
||||
Steps: []squad.StepSpec{
|
||||
{ID: "s1", Role: "researcher", Status: "completed", Attempts: 1, AttemptID: "run_test/s1/1"},
|
||||
{ID: "s2", Role: "writer", Status: "pending"},
|
||||
},
|
||||
},
|
||||
Squad: squad.SquadConfig{SquadName: "demo"},
|
||||
},
|
||||
BB: squad.BlackboardState{Facts: []squad.FactItem{{ID: "f1", Claim: "x"}}},
|
||||
Template: "tpl",
|
||||
Approved: map[string]bool{"step:s1": true},
|
||||
RepairBudgetRemaining: 1,
|
||||
RepairBudgetGranted: 0,
|
||||
Clarifications: []string{"c1"},
|
||||
Steering: []string{"st1"},
|
||||
Usage: map[string]*squad.Usage{"writer": {PromptTokens: 10, CompletionTokens: 5, Steps: 1}},
|
||||
}
|
||||
}
|
||||
|
||||
// TestSQLiteStoreRoundTrip:run/gate/checkpoint/leader 全链路(Q1 WP5 端口实现)。
|
||||
func TestSQLiteStoreRoundTrip(t *testing.T) {
|
||||
st, err := OpenSQLiteStore(filepath.Join(t.TempDir(), "runs.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
ws := filepath.Join(t.TempDir(), "runs", "run_test")
|
||||
orig := newTestRunState(ws)
|
||||
if err := st.SaveRun(orig); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := st.LoadRun(ws)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Meta.ID != "run_test" || got.Plan.Workflow.WorkflowID != "wf1" || got.Template != "tpl" {
|
||||
t.Fatalf("loaded = %+v", got.Meta)
|
||||
}
|
||||
if !got.Approved["step:s1"] || got.RepairBudgetRemaining != 1 || len(got.Clarifications) != 1 || len(got.Steering) != 1 {
|
||||
t.Fatalf("scalars = %+v", got)
|
||||
}
|
||||
if got.Usage["writer"].PromptTokens != 10 {
|
||||
t.Fatalf("usage = %+v", got.Usage)
|
||||
}
|
||||
if _, err := st.LoadRun(filepath.Join(t.TempDir(), "nope")); err == nil {
|
||||
t.Fatal("missing run should error")
|
||||
}
|
||||
|
||||
// gate:写→读→删。
|
||||
gate := &squad.HitlGate{PrevStatus: squad.RunExecuting}
|
||||
if err := st.SaveGate(ws, gate); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if g2, err := st.LoadGate(ws); err != nil || g2 == nil {
|
||||
t.Fatalf("load gate: %v %+v", err, g2)
|
||||
}
|
||||
if err := st.SaveGate(ws, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if g2, err := st.LoadGate(ws); err != nil || g2 != nil {
|
||||
t.Fatalf("gate after delete: %v %+v", err, g2)
|
||||
}
|
||||
|
||||
// checkpoint:两层,latest 取最大 superstep。
|
||||
if err := st.SaveCheckpoint(ws, squad.Checkpoint{Superstep: 1, At: time.Now(), GraphHash: "h1", StepStates: []squad.StepSnapshot{{ID: "s1", Status: "completed", AttemptID: "run_test/s1/1"}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.SaveCheckpoint(ws, squad.Checkpoint{Superstep: 2, At: time.Now(), GraphHash: "h2"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
latest, err := st.LoadLatestCheckpoint(ws)
|
||||
if err != nil || latest == nil {
|
||||
t.Fatalf("latest: %v", err)
|
||||
}
|
||||
if latest.Superstep != 2 || latest.GraphHash != "h2" {
|
||||
t.Fatalf("latest = %+v", latest)
|
||||
}
|
||||
if l2, err := st.LoadLatestCheckpoint(filepath.Join(t.TempDir(), "nope")); err != nil || l2 != nil {
|
||||
t.Fatalf("latest for unknown: %v %+v", err, l2)
|
||||
}
|
||||
|
||||
// leader bytes。
|
||||
if err := st.SaveLeader(ws, []byte(`{"turns":2}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := st.LoadLeader(ws)
|
||||
if err != nil || string(data) != `{"turns":2}` {
|
||||
t.Fatalf("leader = %q %v", data, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEngineRunsOnSQLiteStore:引擎注入 SQLiteStore 后运行状态进 SQLite、
|
||||
// 不再写 run 级 JSON 文件;第二引擎可从中恢复(验收第三条的实证形态)。
|
||||
func TestEngineRunsOnSQLiteStore(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "runs.db")
|
||||
runStore, err := OpenSQLiteStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer runStore.Close()
|
||||
dir := t.TempDir()
|
||||
reg := squad.NewRoleRegistry()
|
||||
for _, spec := range []squad.RoleSpec{{Role: "researcher", Backend: "script"}, {Role: "writer", Backend: "script"}, {Role: "reviewer", Backend: "script"}} {
|
||||
if err := reg.Register(spec); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
backends := squad.NewBackendResolver()
|
||||
backends.RegisterInstance("script", stubExecutor{result: &squad.ExecResult{Success: false, Logs: "ok", Patches: map[string][]byte{"output.md": []byte(goodReportLite), "notes.md": []byte(goodReportLite), "fields.json": []byte("{}")}}})
|
||||
blueprints := squad.NewBlueprintRegistry()
|
||||
if err := blueprints.Register(squad.Blueprint{
|
||||
Key: "demo", Title: "入库演示", Match: []string{"入库演示"},
|
||||
Steps: []squad.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"}},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
eng, err := squad.NewEngine(squad.Options{
|
||||
DataDir: dir, Registry: reg, Blueprints: blueprints,
|
||||
Planner: squad.NewRulePlanner(),
|
||||
Backends: backends, Sink: squad.NopSink{},
|
||||
Store: runStore,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res, err := eng.Run(context.Background(), squad.Goal{Text: "做一个三步入库演示", HitlMode: squad.HitlAuto}, squad.RunOptions{})
|
||||
if err != nil || res.Status != squad.RunCompleted {
|
||||
t.Fatalf("run: %v %+v", err, res)
|
||||
}
|
||||
// run 级 JSON 不在盘上(状态全进了 SQLite)。
|
||||
for _, f := range []string{"workspace.json", "workflow.json", "squad.json", "blackboard.json", "runstate.json"} {
|
||||
if _, err := os.Stat(filepath.Join(res.WorkDir, f)); !os.IsNotExist(err) {
|
||||
t.Fatalf("%s should not be on disk with SQLite store (err=%v)", f, err)
|
||||
}
|
||||
}
|
||||
// 状态可经端口读回。
|
||||
st, err := runStore.LoadRun(res.WorkDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st.Plan.Workflow.Status != "completed" {
|
||||
t.Fatalf("workflow status = %s", st.Plan.Workflow.Status)
|
||||
}
|
||||
latest, err := runStore.LoadLatestCheckpoint(res.WorkDir)
|
||||
if err != nil || latest == nil {
|
||||
t.Fatalf("latest checkpoint: %v", err)
|
||||
}
|
||||
// 第二引擎(同库)恢复可用。
|
||||
eng2, err := squad.NewEngine(squad.Options{
|
||||
DataDir: dir, Registry: reg, Blueprints: blueprints,
|
||||
Planner: squad.NewRulePlanner(),
|
||||
Backends: backends, Sink: squad.NopSink{},
|
||||
Store: runStore,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := eng2.Inspect(res.RunID); err != nil {
|
||||
t.Fatalf("inspect from second engine: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
const goodReportLite = "# 测试交付物\n\n## 概述\n\n这是由脚本后端生成的合格交付物,长度超过五十个字符以满足最小质检要求。\n"
|
||||
@@ -9,8 +9,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"agent-native/runtime"
|
||||
"agent-native/runtime/llm"
|
||||
"agent-squad/squad"
|
||||
)
|
||||
|
||||
// CLICommands are the external tools a daemon probes, in order.
|
||||
@@ -196,36 +195,54 @@ func (s *Store) RunCommand(ctx context.Context, task Task, command, workdir stri
|
||||
return s.FinishTask(task.ID, "completed", "")
|
||||
}
|
||||
|
||||
// RunInProcess executes the task with AgentSession. stream is required; the
|
||||
// product supplies a provider, tests supply a script.
|
||||
func (s *Store) RunInProcess(ctx context.Context, task Task, workdir string, stream llm.StreamFunc) (string, error) {
|
||||
// RunInProcess executes the task through the squad.Executor port(Q1 WP5 修 X6:
|
||||
// 不再绕过执行端口直驱 agent-native runtime)。调用方经 adapters/native 等
|
||||
// 适配器构造 Executor;nil 即 fail-closed。
|
||||
func (s *Store) RunInProcess(ctx context.Context, task Task, workdir string, exec squad.Executor) (string, error) {
|
||||
if exec == nil {
|
||||
return "", fmt.Errorf("RunInProcess requires an executor (no bypass)")
|
||||
}
|
||||
if err := s.MarkRunning(task.ID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
issue, _ := s.GetIssue(task.IssueID)
|
||||
agent := runtime.NewAgentSession(runtime.Config{
|
||||
Model: "agent-native",
|
||||
Stream: stream,
|
||||
MaxTurns: 4,
|
||||
OnEvent: func(ev runtime.Event) {
|
||||
res, err := exec.Execute(ctx, squad.ExecRequest{
|
||||
WorkDir: workdir,
|
||||
Prompt: issue.Title + "\n" + issue.Body,
|
||||
Targets: []string{"output.md"},
|
||||
OnEvent: func(ev squad.ProcessEvent) {
|
||||
_ = s.AddTaskMessage(task.ID, ev.Type, ev.Text)
|
||||
},
|
||||
})
|
||||
res, err := agent.Prompt(ctx, llm.Message{Role: llm.RoleUser, Content: issue.Title + "\n" + issue.Body})
|
||||
if err != nil {
|
||||
_ = s.FinishTask(task.ID, "failed", "config")
|
||||
return "", err
|
||||
}
|
||||
if res.Err != nil {
|
||||
_ = s.FinishTask(task.ID, "failed", "model")
|
||||
return res.Text, res.Err
|
||||
if res == nil || !res.Success {
|
||||
reason := "model"
|
||||
if res != nil && res.ErrorReason != "" {
|
||||
reason = res.ErrorReason
|
||||
}
|
||||
_ = s.FinishTask(task.ID, "failed", reason)
|
||||
if res != nil {
|
||||
return res.Logs, fmt.Errorf("executor failed: %s", res.ErrorReason)
|
||||
}
|
||||
return "", fmt.Errorf("executor failed")
|
||||
}
|
||||
_ = s.AddUsage(task.ID, "agent-native", res.Usage.PromptTokens, res.Usage.CompletionTokens, res.Usage.CacheReadTokens, res.Usage.CacheWriteTokens)
|
||||
if len(res.Patches) > 0 {
|
||||
_, _ = squad.MaterializeTargets(workdir, []string{"output.md"}, res.Patches)
|
||||
}
|
||||
_ = s.AddUsage(task.ID, exec.Name(), res.PromptTokens, res.CompletionTokens, 0, 0)
|
||||
if err := s.FinishTask(task.ID, "completed", ""); err != nil {
|
||||
return res.Text, err
|
||||
return res.Logs, err
|
||||
}
|
||||
_ = os.WriteFile(filepath.Join(workdir, "output.md"), []byte(res.Text), 0o644)
|
||||
return res.Text, nil
|
||||
if data, rerr := os.ReadFile(filepath.Join(workdir, "output.md")); rerr == nil {
|
||||
return string(data), nil
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(workdir, "output.md"), []byte(res.Logs), 0o644); err != nil {
|
||||
return res.Logs, err
|
||||
}
|
||||
return res.Logs, nil
|
||||
}
|
||||
|
||||
// AgentCLI applies a task-token command: comment, status, sub-issue, or activity.
|
||||
@@ -0,0 +1,30 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"agent-squad/squad"
|
||||
)
|
||||
|
||||
// stubExecutor 是测试用的 squad.Executor(X6 后调用方经端口注入)。
|
||||
type stubExecutor struct {
|
||||
result *squad.ExecResult
|
||||
err error
|
||||
}
|
||||
|
||||
func (s stubExecutor) Name() string { return "stub" }
|
||||
|
||||
func (s stubExecutor) Execute(ctx context.Context, req squad.ExecRequest) (*squad.ExecResult, error) {
|
||||
if s.result == nil {
|
||||
return nil, s.err
|
||||
}
|
||||
// 每次调用返回拷贝:调用方会改写 Success 字段,共享指针会跨步骤串味。
|
||||
cp := *s.result
|
||||
if s.result.Patches != nil {
|
||||
cp.Patches = map[string][]byte{}
|
||||
for k, v := range s.result.Patches {
|
||||
cp.Patches[k] = v
|
||||
}
|
||||
}
|
||||
return &cp, s.err
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -14,12 +15,12 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"agent-intent/intent"
|
||||
"agent-native/runtime/llm"
|
||||
"agent-squad/platform"
|
||||
"agent-squad/server"
|
||||
squadintent "agent-squad/adapters/intent"
|
||||
nativeadp "agent-squad/adapters/native"
|
||||
platform "agent-squad/adapters/sqlite"
|
||||
"agent-squad/cmd/agent-squad/server"
|
||||
"agent-squad/squad"
|
||||
"agent-squad/squadintent"
|
||||
"agent-squad/store"
|
||||
|
||||
"logging-system/config"
|
||||
@@ -61,7 +62,7 @@ func usage() {
|
||||
fmt.Print(`agent-squad — 通用智能体小队编排底座
|
||||
|
||||
用法:
|
||||
agent-squad serve [--addr :8090] [--data DIR] [--planner rule|llm] [--model M]
|
||||
agent-squad serve [--addr 127.0.0.1:8090] [--data DIR] [--token TOK] [--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]
|
||||
@@ -85,6 +86,7 @@ type app struct {
|
||||
files *store.FileEventSink
|
||||
st *store.FileStore
|
||||
log *logsys.Logger
|
||||
plat *platform.Store
|
||||
}
|
||||
|
||||
func dataDir() string {
|
||||
@@ -121,12 +123,12 @@ func assemble(dir, plannerName, modelOverride, intentRoot string) (*app, error)
|
||||
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)
|
||||
planner = squad.NewLLMPlanner(nativeadp.LLM{Inner: client}, model)
|
||||
}
|
||||
}
|
||||
// 真实智能体执行一律经 agent-native 自研内核(角色 backend=agent-native)。
|
||||
if baseURL != "" && apiKey != "" && model != "" {
|
||||
backends.RegisterAgentNative(&llm.Client{BaseURL: baseURL, APIKey: apiKey}, model, 12, 10*time.Minute)
|
||||
nativeadp.Register(backends, &llm.Client{BaseURL: baseURL, APIKey: apiKey}, model, 12, 10*time.Minute)
|
||||
}
|
||||
|
||||
// 事件双层:FileEventSink 落盘(seq+脱敏,重启可审计)→ FanoutSink(SSE 实时/重放)。
|
||||
@@ -136,14 +138,7 @@ func assemble(dir, plannerName, modelOverride, intentRoot string) (*app, error)
|
||||
var catalog *squad.IntentCatalog
|
||||
var runner squad.IntentRunner
|
||||
if intentRoot != "" {
|
||||
g, err := squadintent.New(context.Background(), squadintent.Options{
|
||||
IntentOptions: intent.Options{
|
||||
GuardDomains: []string{"guard"},
|
||||
ProtectedDomains: []string{"guard"},
|
||||
},
|
||||
FS: os.DirFS(intentRoot),
|
||||
Roots: []string{"."},
|
||||
})
|
||||
g, err := squadintent.NewFromOverlay(context.Background(), os.DirFS(intentRoot), ".")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("意图配置加载失败(fail-closed):%w", err)
|
||||
}
|
||||
@@ -165,12 +160,20 @@ func assemble(dir, plannerName, modelOverride, intentRoot string) (*app, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
// 单库收编(Q1 WP5):引擎运行状态与平台编排状态进同一个 SQLite。
|
||||
// 库打不开即失败(与 serve 现状一致),不允许静默退回文件态。
|
||||
plat, err := platform.Open(filepath.Join(dir, "orchestration.db"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("编排库打开失败: %w", err)
|
||||
}
|
||||
engine, err := squad.NewEngine(squad.Options{
|
||||
DataDir: dir, Registry: reg, Blueprints: bps, Planner: planner,
|
||||
Backends: backends, Sink: fileSink, IntentGate: gate,
|
||||
IntentCatalog: catalog, IntentRunner: runner,
|
||||
Store: plat.RunStore(),
|
||||
})
|
||||
if err != nil {
|
||||
_ = plat.Close()
|
||||
return nil, err
|
||||
}
|
||||
logCfg, err := config.Load("")
|
||||
@@ -184,7 +187,7 @@ func assemble(dir, plannerName, modelOverride, intentRoot string) (*app, error)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &app{engine: engine, sink: fanout, files: fileSink, st: st, log: logger}, nil
|
||||
return &app{engine: engine, sink: fanout, files: fileSink, st: st, log: logger, plat: plat}, nil
|
||||
}
|
||||
|
||||
func (a *app) close() {
|
||||
@@ -206,7 +209,9 @@ func first(vals ...string) string {
|
||||
|
||||
func cmdServe(args []string) int {
|
||||
fs := flag.NewFlagSet("serve", flag.ContinueOnError)
|
||||
addr := fs.String("addr", ":8090", "监听地址")
|
||||
addr := fs.String("addr", "127.0.0.1:8090", "监听地址")
|
||||
token := fs.String("token", os.Getenv("AGENT_SQUAD_TOKEN"), "Bearer token;也可用 AGENT_SQUAD_TOKEN")
|
||||
insecure := fs.Bool("insecure", false, "允许在没有 token 时监听非回环地址")
|
||||
data := fs.String("data", dataDir(), "数据目录")
|
||||
planner := fs.String("planner", "rule", "规划器:rule|llm")
|
||||
model := fs.String("model", "", "默认模型(llm 规划器/后端)")
|
||||
@@ -220,14 +225,16 @@ func cmdServe(args []string) int {
|
||||
return 1
|
||||
}
|
||||
defer a.close()
|
||||
if err := listenGuard(*addr, *token, *insecure); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return 2
|
||||
}
|
||||
srv := server.New(a.engine, a.sink, a.st)
|
||||
srv.SetToken(*token)
|
||||
srv.SetLogger(a.log)
|
||||
if plat, err := platform.Open(filepath.Join(*data, "orchestration.db")); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "编排库打开失败:", err)
|
||||
return 1
|
||||
} else {
|
||||
srv.SetPlatform(plat)
|
||||
defer plat.Close()
|
||||
if a.plat != nil {
|
||||
srv.SetPlatform(a.plat)
|
||||
defer a.plat.Close()
|
||||
}
|
||||
stopSweep := srv.StartWakeupSweeper(15 * time.Second)
|
||||
defer stopSweep()
|
||||
@@ -236,6 +243,22 @@ func cmdServe(args []string) int {
|
||||
return httpListen(*addr, srv.Handler())
|
||||
}
|
||||
|
||||
func listenGuard(addr, token string, insecure bool) error {
|
||||
if token == "" && !insecure && !loopbackAddr(addr) {
|
||||
return fmt.Errorf("拒绝在没有 token 时监听非回环地址;传入 --token 或 --insecure")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loopbackAddr(addr string) bool {
|
||||
host, _, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
host = addr
|
||||
}
|
||||
host = strings.Trim(host, "[]")
|
||||
return host == "127.0.0.1" || host == "::1" || strings.EqualFold(host, "localhost")
|
||||
}
|
||||
|
||||
func httpListen(addr string, h http.Handler) int {
|
||||
if err := http.ListenAndServe(addr, h); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "服务退出:", err)
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"agent-native/exec"
|
||||
"agent-squad/squad"
|
||||
)
|
||||
|
||||
@@ -21,7 +20,7 @@ type demoBackend struct {
|
||||
|
||||
func (b *demoBackend) Name() string { return "demo" }
|
||||
|
||||
func (b *demoBackend) Execute(ctx context.Context, req exec.Request) (*exec.Result, error) {
|
||||
func (b *demoBackend) Execute(ctx context.Context, req squad.ExecRequest) (*squad.ExecResult, error) {
|
||||
key := filepath.Base(filepath.Clean(req.WorkDir))
|
||||
files, ok := b.script[key]
|
||||
if !ok {
|
||||
@@ -46,7 +45,7 @@ func (b *demoBackend) Execute(ctx context.Context, req exec.Request) (*exec.Resu
|
||||
}
|
||||
patches[t] = []byte(content)
|
||||
}
|
||||
return &exec.Result{Success: true, Patches: patches, Logs: "demo backend replay"}, nil
|
||||
return &squad.ExecResult{Success: true, Patches: patches, Logs: "demo backend replay"}, nil
|
||||
}
|
||||
|
||||
// cmdDemo 离线全链路演示:注册角色 → 规划 → 执行 → HITL 阻塞缺口挂起 →
|
||||
@@ -0,0 +1,28 @@
|
||||
package cli
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestListenGuard(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
addr string
|
||||
token string
|
||||
insecure bool
|
||||
ok bool
|
||||
}{
|
||||
{name: "loopback without token", addr: "127.0.0.1:8090", ok: true},
|
||||
{name: "public without token", addr: "0.0.0.0:8090", ok: false},
|
||||
{name: "public with insecure", addr: "0.0.0.0:8090", insecure: true, ok: true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := listenGuard(tc.addr, tc.token, tc.insecure)
|
||||
if tc.ok && err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !tc.ok && err == nil {
|
||||
t.Fatal("expected refusal")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,8 @@ import (
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"agent-native/runtime/llm"
|
||||
"agent-squad/platform"
|
||||
platform "agent-squad/adapters/sqlite"
|
||||
"agent-squad/squad"
|
||||
)
|
||||
|
||||
func cmdPlatform(args []string) int {
|
||||
@@ -99,7 +99,7 @@ func cmdPlatform(args []string) int {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
text, err := st.RunInProcess(context.Background(), task, fs.Arg(1), scriptedOK)
|
||||
text, err := st.RunInProcess(context.Background(), task, fs.Arg(1), scriptedExecutor{})
|
||||
return printPlatform(map[string]string{"text": text}, err)
|
||||
default:
|
||||
fmt.Fprintln(os.Stderr, "未知编排命令", args[0], args[1])
|
||||
@@ -111,11 +111,16 @@ func taskByID(st *platform.Store, id string) (platform.Task, error) {
|
||||
return st.TaskByToken(id)
|
||||
}
|
||||
|
||||
func scriptedOK(ctx context.Context, model string, messages []llm.Message, td []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
|
||||
ch := make(chan llm.Event, 1)
|
||||
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{Role: llm.RoleAssistant, Content: "ok", StopReason: llm.StopEnd}}
|
||||
close(ch)
|
||||
return ch
|
||||
// scriptedExecutor 是 daemon run-native 冒烟路径的脚本执行器(X6 后经端口注入)。
|
||||
type scriptedExecutor struct{}
|
||||
|
||||
func (scriptedExecutor) Name() string { return "scripted" }
|
||||
|
||||
func (scriptedExecutor) Execute(ctx context.Context, req squad.ExecRequest) (*squad.ExecResult, error) {
|
||||
return &squad.ExecResult{
|
||||
Success: true, Logs: "ok",
|
||||
Patches: map[string][]byte{"output.md": []byte("ok")},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func printPlatform(v any, err error) int {
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"agent-native/exec"
|
||||
"agent-squad/squad"
|
||||
"agent-squad/store"
|
||||
)
|
||||
@@ -21,7 +20,7 @@ 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) {
|
||||
func (b *replayBackend) Execute(ctx context.Context, req squad.ExecRequest) (*squad.ExecResult, error) {
|
||||
key := filepath.Base(filepath.Clean(req.WorkDir))
|
||||
files := b.script[key]
|
||||
patches := map[string][]byte{}
|
||||
@@ -37,7 +36,7 @@ func (b *replayBackend) Execute(ctx context.Context, req exec.Request) (*exec.Re
|
||||
}
|
||||
patches[t] = []byte(content)
|
||||
}
|
||||
return &exec.Result{Success: true, Patches: patches, Logs: "replay"}, nil
|
||||
return &squad.ExecResult{Success: true, Patches: patches, Logs: "replay"}, nil
|
||||
}
|
||||
|
||||
const deliverable = "# 契约验收交付物\n\n## 概述\n\n本文件由回放后端生成,用于验证 agent-squad 全链路契约:注册、规划、执行、HITL、质检与审计。\n\n## 结论\n\n全链路行为符合契约。\n"
|
||||
@@ -0,0 +1,45 @@
|
||||
module agent-squad/cmd/agent-squad
|
||||
|
||||
go 1.27
|
||||
|
||||
require (
|
||||
agent-native v0.0.0
|
||||
agent-squad v0.0.0
|
||||
agent-squad/adapters/intent v0.0.0
|
||||
agent-squad/adapters/native v0.0.0
|
||||
agent-squad/adapters/sqlite v0.0.0
|
||||
error-code-system v0.0.0
|
||||
logging-system v0.0.0
|
||||
)
|
||||
|
||||
require (
|
||||
agent-intent v0.0.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
|
||||
golang.org/x/sys v0.31.0 // indirect
|
||||
golang.org/x/text v0.29.0 // indirect
|
||||
modernc.org/libc v1.62.1 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.9.1 // indirect
|
||||
modernc.org/sqlite v1.37.0 // indirect
|
||||
)
|
||||
|
||||
replace agent-intent => ../../deps/agent-intent
|
||||
|
||||
replace agent-native => ../../deps/agent-native
|
||||
|
||||
replace agent-squad => ../..
|
||||
|
||||
replace agent-squad/adapters/intent => ../../adapters/intent
|
||||
|
||||
replace agent-squad/adapters/native => ../../adapters/native
|
||||
|
||||
replace agent-squad/adapters/sqlite => ../../adapters/sqlite
|
||||
|
||||
replace error-code-system => ../../deps/error-code-system
|
||||
|
||||
replace logging-system => ../../deps/logging-system
|
||||
@@ -0,0 +1,49 @@
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
|
||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
|
||||
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
|
||||
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
|
||||
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
|
||||
modernc.org/cc/v4 v4.25.2 h1:T2oH7sZdGvTaie0BRNFbIYsabzCxUQg8nLqCdQ2i0ic=
|
||||
modernc.org/cc/v4 v4.25.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.25.1 h1:TFSzPrAGmDsdnhT9X2UrcPMI3N/mJ9/X9ykKXwLhDsU=
|
||||
modernc.org/ccgo/v4 v4.25.1/go.mod h1:njjuAYiPflywOOrm3B7kCB444ONP5pAVr8PIEoE0uDw=
|
||||
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
|
||||
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/libc v1.62.1 h1:s0+fv5E3FymN8eJVmnk0llBe6rOxCu/DEU+XygRbS8s=
|
||||
modernc.org/libc v1.62.1/go.mod h1:iXhATfJQLjG3NWy56a6WVU73lWOcdYVxsvwCgoPljuo=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.9.1 h1:V/Z1solwAVmMW1yttq3nDdZPJqV1rM05Ccq6KMSZ34g=
|
||||
modernc.org/memory v1.9.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI=
|
||||
modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
@@ -4,7 +4,7 @@ package main
|
||||
import (
|
||||
"os"
|
||||
|
||||
"agent-squad/cli"
|
||||
"agent-squad/cmd/agent-squad/cli"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -0,0 +1,74 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"agent-squad/squad"
|
||||
"agent-squad/store"
|
||||
)
|
||||
|
||||
func TestMissingTokenIsUnauthorized(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
st, err := store.New(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reg := squad.NewRoleRegistry()
|
||||
engine, err := squad.NewEngine(squad.Options{
|
||||
DataDir: dir, Registry: reg, Planner: &squad.MockPlanner{}, Sink: squad.NopSink{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv := New(engine, squad.NewFanoutSink(8), st)
|
||||
srv.SetToken("secret")
|
||||
ts := httptest.NewServer(srv.Handler())
|
||||
defer ts.Close()
|
||||
|
||||
resp, err := http.Get(ts.URL + "/v1/roles")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("status %d", resp.StatusCode)
|
||||
}
|
||||
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/roles", nil)
|
||||
req.Header.Set("Authorization", "Bearer secret")
|
||||
resp, err = http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("authed status %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathTraversalRejected(t *testing.T) {
|
||||
ts, _ := newTestServer(t)
|
||||
resp, err := http.Get(ts.URL + "/v1/runs/a..b")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("status %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOversizedBodyIs413(t *testing.T) {
|
||||
ts, _ := newTestServer(t)
|
||||
body := strings.NewReader(`{"role":"x","name":"` + strings.Repeat("a", 2<<20) + `"}`)
|
||||
resp, err := http.Post(ts.URL+"/v1/roles", "application/json", body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusRequestEntityTooLarge {
|
||||
t.Fatalf("status %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -4,15 +4,18 @@ package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"agent-squad/platform"
|
||||
platform "agent-squad/adapters/sqlite"
|
||||
"agent-squad/squad"
|
||||
"agent-squad/store"
|
||||
|
||||
@@ -34,8 +37,12 @@ type Server struct {
|
||||
latest map[string]*squad.RunResult // 最近一次推进结果(供轮询)
|
||||
log *logsys.Logger
|
||||
plat *platform.Store
|
||||
token string
|
||||
}
|
||||
|
||||
// SetToken 要求除健康检查外的请求带 Bearer token。空串表示不校验。
|
||||
func (s *Server) SetToken(token string) { s.token = token }
|
||||
|
||||
// SetLogger 设置组合根日志器。nil 表示不写日志。
|
||||
func (s *Server) SetLogger(log *logsys.Logger) {
|
||||
if s == nil {
|
||||
@@ -87,7 +94,54 @@ func (s *Server) Handler() http.Handler {
|
||||
if s.plat != nil {
|
||||
platform.Mount(mux, s.plat)
|
||||
}
|
||||
return httpx.Trace(mux)
|
||||
var h http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
limit := int64(1 << 20)
|
||||
if r.Method == http.MethodPost && r.URL.Path == "/v1/runs" {
|
||||
limit = 32 << 20
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, limit)
|
||||
mux.ServeHTTP(w, r)
|
||||
})
|
||||
if s.token != "" {
|
||||
inner := h
|
||||
h = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/v1/health" {
|
||||
inner.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
const prefix = "Bearer "
|
||||
header := r.Header.Get("Authorization")
|
||||
got := strings.TrimPrefix(header, prefix)
|
||||
if len(header) != len(prefix)+len(s.token) || subtle.ConstantTimeCompare([]byte(got), []byte(s.token)) != 1 {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
inner.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
return httpx.Trace(h)
|
||||
}
|
||||
|
||||
func (s *Server) decode(w http.ResponseWriter, r *http.Request, dst any) bool {
|
||||
err := json.NewDecoder(r.Body).Decode(dst)
|
||||
if err == nil {
|
||||
return true
|
||||
}
|
||||
var maxErr *http.MaxBytesError
|
||||
if errors.As(err, &maxErr) || strings.Contains(err.Error(), "request body too large") {
|
||||
w.WriteHeader(http.StatusRequestEntityTooLarge)
|
||||
return false
|
||||
}
|
||||
s.writeErr(w, r, errcode.Wrap(err, errcode.SysInvalidArgument, "请求 JSON 无法解析"))
|
||||
return false
|
||||
}
|
||||
|
||||
func rejectBadID(w http.ResponseWriter, id string) bool {
|
||||
if squad.ValidID(id) {
|
||||
return false
|
||||
}
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return true
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, code int, v any) {
|
||||
@@ -132,8 +186,7 @@ func (s *Server) handleListRoles(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handlePutRole(w http.ResponseWriter, r *http.Request) {
|
||||
var spec squad.RoleSpec
|
||||
if err := json.NewDecoder(r.Body).Decode(&spec); err != nil {
|
||||
s.writeErr(w, r, errcode.Wrap(err, errcode.SysInvalidArgument, "请求 JSON 无法解析"))
|
||||
if !s.decode(w, r, &spec) {
|
||||
return
|
||||
}
|
||||
if key := r.PathValue("key"); key != "" && spec.Role == "" {
|
||||
@@ -148,6 +201,9 @@ func (s *Server) handlePutRole(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleDeleteRole(w http.ResponseWriter, r *http.Request) {
|
||||
key := r.PathValue("key")
|
||||
if rejectBadID(w, key) {
|
||||
return
|
||||
}
|
||||
if !s.engine.Registry().Remove(key) {
|
||||
s.writeErr(w, r, errcode.New(errcode.SquadRoleNotFound, fmt.Sprintf("role %s not found", key)))
|
||||
return
|
||||
@@ -158,8 +214,7 @@ func (s *Server) handleDeleteRole(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleDeriveRole(w http.ResponseWriter, r *http.Request) {
|
||||
var req squad.DeriveRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
s.writeErr(w, r, errcode.Wrap(err, errcode.SysInvalidArgument, "请求 JSON 无法解析"))
|
||||
if !s.decode(w, r, &req) {
|
||||
return
|
||||
}
|
||||
spec, err := s.engine.Registry().Derive(req)
|
||||
@@ -183,8 +238,7 @@ func (s *Server) handleListBlueprints(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handlePutBlueprint(w http.ResponseWriter, r *http.Request) {
|
||||
var bp squad.Blueprint
|
||||
if err := json.NewDecoder(r.Body).Decode(&bp); err != nil {
|
||||
s.writeErr(w, r, errcode.Wrap(err, errcode.SysInvalidArgument, "请求 JSON 无法解析"))
|
||||
if !s.decode(w, r, &bp) {
|
||||
return
|
||||
}
|
||||
if err := s.engine.Blueprints().Register(bp); err != nil {
|
||||
@@ -196,6 +250,9 @@ func (s *Server) handlePutBlueprint(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleDeleteBlueprint(w http.ResponseWriter, r *http.Request) {
|
||||
key := r.PathValue("key")
|
||||
if rejectBadID(w, key) {
|
||||
return
|
||||
}
|
||||
if !s.engine.Blueprints().Remove(key) {
|
||||
s.writeErr(w, r, errcode.New(errcode.SquadBlueprintNotFound, fmt.Sprintf("blueprint %s not found", key)))
|
||||
return
|
||||
@@ -221,8 +278,7 @@ type createRunRequest struct {
|
||||
|
||||
func (s *Server) handleCreateRun(w http.ResponseWriter, r *http.Request) {
|
||||
var req createRunRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
s.writeErr(w, r, errcode.Wrap(err, errcode.SysInvalidArgument, "请求 JSON 无法解析"))
|
||||
if !s.decode(w, r, &req) {
|
||||
return
|
||||
}
|
||||
for name, b64 := range req.MaterialsB64 {
|
||||
@@ -276,7 +332,11 @@ func (s *Server) handleListRuns(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) handleInspectRun(w http.ResponseWriter, r *http.Request) {
|
||||
res, err := s.engine.Inspect(r.PathValue("id"))
|
||||
id := r.PathValue("id")
|
||||
if rejectBadID(w, id) {
|
||||
return
|
||||
}
|
||||
res, err := s.engine.Inspect(id)
|
||||
if err != nil {
|
||||
s.writeErr(w, r, errcode.Wrap(err, errcode.SquadRunNotFound, "运行不存在"))
|
||||
return
|
||||
@@ -285,7 +345,11 @@ func (s *Server) handleInspectRun(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) handlePending(w http.ResponseWriter, r *http.Request) {
|
||||
req, err := s.engine.Pending(r.PathValue("id"))
|
||||
id := r.PathValue("id")
|
||||
if rejectBadID(w, id) {
|
||||
return
|
||||
}
|
||||
req, err := s.engine.Pending(id)
|
||||
if err != nil || req == nil {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"pending": nil})
|
||||
return
|
||||
@@ -295,11 +359,14 @@ func (s *Server) handlePending(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleRespond(w http.ResponseWriter, r *http.Request) {
|
||||
var decision squad.RespondDecision
|
||||
if err := json.NewDecoder(r.Body).Decode(&decision); err != nil {
|
||||
s.writeErr(w, r, errcode.Wrap(err, errcode.SysInvalidArgument, "请求 JSON 无法解析"))
|
||||
if !s.decode(w, r, &decision) {
|
||||
return
|
||||
}
|
||||
res, err := s.engine.Respond(r.Context(), r.PathValue("id"), decision)
|
||||
id := r.PathValue("id")
|
||||
if rejectBadID(w, id) {
|
||||
return
|
||||
}
|
||||
res, err := s.engine.Respond(r.Context(), id, decision)
|
||||
if err != nil {
|
||||
s.writeErr(w, r, errcode.Wrap(err, errcode.SysInvalidArgument, err.Error()))
|
||||
return
|
||||
@@ -310,7 +377,11 @@ func (s *Server) handleRespond(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) handleResume(w http.ResponseWriter, r *http.Request) {
|
||||
res, err := s.engine.Resume(r.Context(), r.PathValue("id"))
|
||||
id := r.PathValue("id")
|
||||
if rejectBadID(w, id) {
|
||||
return
|
||||
}
|
||||
res, err := s.engine.Resume(r.Context(), id)
|
||||
if err != nil {
|
||||
s.writeErr(w, r, errcode.Wrap(err, errcode.SysInvalidArgument, err.Error()))
|
||||
return
|
||||
@@ -321,7 +392,11 @@ func (s *Server) handleResume(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) handleBlackboard(w http.ResponseWriter, r *http.Request) {
|
||||
bb, err := s.engine.Blackboard(r.PathValue("id"))
|
||||
id := r.PathValue("id")
|
||||
if rejectBadID(w, id) {
|
||||
return
|
||||
}
|
||||
bb, err := s.engine.Blackboard(id)
|
||||
if err != nil {
|
||||
s.writeErr(w, r, errcode.Wrap(err, errcode.SquadRunNotFound, "运行不存在"))
|
||||
return
|
||||
@@ -333,11 +408,14 @@ 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 {
|
||||
s.writeErr(w, r, errcode.Wrap(err, errcode.SysInvalidArgument, "请求 JSON 无法解析"))
|
||||
if !s.decode(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if err := s.engine.Steer(r.PathValue("id"), req.Text); err != nil {
|
||||
id := r.PathValue("id")
|
||||
if rejectBadID(w, id) {
|
||||
return
|
||||
}
|
||||
if err := s.engine.Steer(id, req.Text); err != nil {
|
||||
s.writeErr(w, r, errcode.Wrap(err, errcode.SysInvalidArgument, err.Error()))
|
||||
return
|
||||
}
|
||||
@@ -349,11 +427,14 @@ func (s *Server) handleSetField(w http.ResponseWriter, r *http.Request) {
|
||||
Field string `json:"field"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
s.writeErr(w, r, errcode.Wrap(err, errcode.SysInvalidArgument, "请求 JSON 无法解析"))
|
||||
if !s.decode(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if err := s.engine.SetBlackboardField(r.PathValue("id"), req.Field, req.Value); err != nil {
|
||||
id := r.PathValue("id")
|
||||
if rejectBadID(w, id) {
|
||||
return
|
||||
}
|
||||
if err := s.engine.SetBlackboardField(id, req.Field, req.Value); err != nil {
|
||||
s.writeErr(w, r, errcode.Wrap(err, errcode.SysInvalidArgument, err.Error()))
|
||||
return
|
||||
}
|
||||
@@ -381,6 +462,8 @@ func (s *Server) StartWakeupSweeper(interval time.Duration) (stop func()) {
|
||||
return
|
||||
case <-t.C:
|
||||
s.engine.SweepWakeups(context.Background())
|
||||
// Q6:与事件唤醒并列,过期挂起按 ExpireAction 合成决策(此前是死代码)。
|
||||
s.engine.SweepExpired(context.Background(), time.Now())
|
||||
}
|
||||
}
|
||||
}()
|
||||
@@ -389,6 +472,9 @@ func (s *Server) StartWakeupSweeper(interval time.Duration) (stop func()) {
|
||||
|
||||
// handleEvents SSE:cursor 之后重放缓冲再实时推送。
|
||||
func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) {
|
||||
if rejectBadID(w, r.PathValue("id")) {
|
||||
return
|
||||
}
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
s.writeErr(w, r, errcode.New(errcode.SysInternal, "streaming unsupported"))
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"agent-native/exec"
|
||||
"agent-squad/squad"
|
||||
"agent-squad/store"
|
||||
|
||||
@@ -25,7 +24,7 @@ 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) {
|
||||
func (b *scriptBackend) Execute(ctx context.Context, req squad.ExecRequest) (*squad.ExecResult, error) {
|
||||
key := filepath.Base(filepath.Clean(req.WorkDir))
|
||||
files := b.script[key]
|
||||
patches := map[string][]byte{}
|
||||
@@ -39,7 +38,7 @@ func (b *scriptBackend) Execute(ctx context.Context, req exec.Request) (*exec.Re
|
||||
_ = os.WriteFile(out, []byte(content), 0o644)
|
||||
patches[t2] = []byte(content)
|
||||
}
|
||||
return &exec.Result{Success: true, Patches: patches}, nil
|
||||
return &squad.ExecResult{Success: true, Patches: patches}, nil
|
||||
}
|
||||
|
||||
const goodReport = "# API 测试交付物\n\n## 概述\n\n脚本后端生成的合格交付物正文,长度超过五十个字符以满足最小质检要求。\n"
|
||||
Vendored
+1
-1
Submodule deps/agent-intent updated: 3674dc4bfd...8b54508964
Vendored
+1
-1
Submodule deps/agent-native updated: 6b7d2432b3...2df0d10d96
Vendored
+1
-1
Submodule deps/development-guidelines updated: 26b562d2e3...3ed982138c
+1
Submodule deps/development-manager added at 24e8a5c8ba
Vendored
+1
-1
Submodule deps/error-code-system updated: dbeed2f1b6...00c03035b5
Vendored
+1
-1
Submodule deps/logging-system updated: 2c5716fdcf...7005493077
@@ -2,31 +2,6 @@ module agent-squad
|
||||
|
||||
go 1.27
|
||||
|
||||
require (
|
||||
agent-intent v0.0.0
|
||||
agent-native v0.0.0
|
||||
error-code-system v0.0.0
|
||||
logging-system v0.0.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
|
||||
golang.org/x/sys v0.31.0 // indirect
|
||||
modernc.org/libc v1.62.1 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.9.1 // indirect
|
||||
modernc.org/sqlite v1.37.0 // indirect
|
||||
)
|
||||
|
||||
replace agent-intent => ./deps/agent-intent
|
||||
|
||||
replace agent-native => ./deps/agent-native
|
||||
require error-code-system v0.0.0
|
||||
|
||||
replace error-code-system => ./deps/error-code-system
|
||||
|
||||
replace logging-system => ./deps/logging-system
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
|
||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
modernc.org/libc v1.62.1 h1:s0+fv5E3FymN8eJVmnk0llBe6rOxCu/DEU+XygRbS8s=
|
||||
modernc.org/libc v1.62.1/go.mod h1:iXhATfJQLjG3NWy56a6WVU73lWOcdYVxsvwCgoPljuo=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.9.1 h1:V/Z1solwAVmMW1yttq3nDdZPJqV1rM05Ccq6KMSZ34g=
|
||||
modernc.org/memory v1.9.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI=
|
||||
modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM=
|
||||
|
||||
@@ -1,24 +1,26 @@
|
||||
# 通用规范采纳记录
|
||||
|
||||
- 项目/负责人:Agent-Squad / 仓库所有者 weichen
|
||||
- 项目/负责人:Agent-Squad / weichen
|
||||
- 状态:active
|
||||
- 规范位置:`deps/development-guidelines`
|
||||
- 规范性文档目录:`policy/`
|
||||
- 过程文档位置:`docs/records/`(项目内固定目录豁免。个人维护的编排库,任务主记录仍在 Gitea Issue/PR。过程记录不进规范库,也不写成与 Issue 平行的第二状态。)
|
||||
- 版本权威:父仓 gitlink;可读标签:无(内容版本 `0.1.0-draft.6`,提交 `26b562d2e34fbc560711296a309c91c74ffc55b1`)
|
||||
- 批准/生效依据:本仓接入提交
|
||||
- 替代范围和迁移说明:本仓此前没有根 `AGENTS.md`。Agent-Native 子模块已从仓库根迁到 `deps/agent-native`,迁移说明见 `docs/records/2026-09-29-guidelines-and-observability.md`。
|
||||
- 过程文档位置:任务卡在 `.task/`(`.task/feature-governance-adoption.md`);任务主记录在 Gitea PR 中维护。既有过程记录仍按项目内固定目录豁免留在 `docs/records/`。过程记录不进规范库,也不写成与 Issue/PR 平行的第二状态。
|
||||
- 版本权威:本仓 gitlink deps/development-guidelines @ 2688fd2be6a5(完整 `2688fd2be6a52c0bf110e7e6366b6f291ab5e4eb`,内容版本 `0.1.0-draft.12`,无已发布标签)
|
||||
- 批准/生效依据:`feature/governance-adoption PR(Agent-Squad)`
|
||||
- 替代范围和迁移说明:本次替代 2026-09-29 采纳的固定版本 `26b562d2e34f`(内容版本 `0.1.0-draft.6`),规则条目按 `0.1.0-draft.12` 重新核对。新增 `AGENT-EXECUTION.md` 执行路由、`deps/development-manager` gitlink(`bd3a7de57a3a`)、`scripts/dm`、`scripts/agent-exec`、`scripts/task`、`scripts/hooks/pre-commit`、`.gitea/workflows/governance.yml` 与任务卡模板。不改业务代码,不改既有 `.gitea/workflows/ci.yml`。此前迁移说明见 `docs/records/2026-09-29-guidelines-and-observability.md`。
|
||||
|
||||
| 规范/工作流 | 适用条件与范围 | 采纳或不适用理由 | 执行方式/证据 |
|
||||
|---|---|---|---|
|
||||
| core/baseline.md | 日常工作 | 编排库对外有失败关闭语义 | manual |
|
||||
| workflows/delivery.md、debugging.md、handoff.md | 交付、排障与交接 | 跨仓组合需要可交接记录 | manual |
|
||||
| standards/collaboration.md | 并行共同起点与组合升级 | 父仓 gitlink 锁定 Agent-Native 与基础库 | manual |
|
||||
| standards/verification.md | 验证与结论 | `GOWORK=off go test` | manual;命令见 PROJECT.md |
|
||||
| standards/merge-queue.md | 多个就绪任务的最终同步、CI、Review 与合入调度 | 多个 agent 单元并行开发,合入须串行同步;协调者为仓库所有者 | manual |
|
||||
| standards/verification.md | 验证与结论 | `GOWORK=off go test` | manual;命令见 PROJECT.md(含 `scripts/agent-exec validate --repo .`、`scripts/dm governance check --repo .`) |
|
||||
| workflows/design-verification.md、standards/testability.md、standards/failure-containment.md | 规划、执行、HITL | conformance 覆盖公开 API | manual |
|
||||
| standards/module-contracts.md | squad 与 agent-native 边界 | 依赖方向只允许 squad → agent-native | manual |
|
||||
| standards/observability.md | HTTP 信封、trace、事件 | 接入错误码库与日志库 | manual |
|
||||
| standards/agent-execution.md | 角色绑定的执行后端 | 本库不实现 Agent | manual |
|
||||
| standards/agent-execution.md | 工程 Agent CLI 执行路由、会话隔离与受控集成 | 凡允许 AI 协助工程变更必须采纳;路由配置见 AGENT-EXECUTION.md;本库不实现 Agent | 执行方式:AGENT-EXECUTION.md + scripts/agent-exec(validate / admit);证据:`governance check + 正负例(.task/feature-governance-adoption.md)` |
|
||||
| standards/tool-adaptation.md | 为具体工具创建规则与钩子 | 本次引入 `scripts/hooks/pre-commit`(启用由 host/人执行,`core.hooksPath`) | manual;启用后以本仓 pre-commit 检查为证据 |
|
||||
| standards/multi-repo.md | 多级 submodule | 本仓 replace 是编译时的唯一依赖解析 | manual |
|
||||
| standards/data-reliability.md | 运行目录、HITL 恢复、幂等回应 | 磁盘为权威 | manual |
|
||||
| standards/security.md | bash 权限挂起、脱敏 | 事件落盘沿用现有脱敏;凭证走环境变量 | manual |
|
||||
@@ -31,14 +33,17 @@
|
||||
|
||||
| 原规则/章节 | 项目采用方式 | 原因与风险 | 负责人/复查条件 |
|
||||
|---|---|---|---|
|
||||
| documentation.md 过程仓默认独立仓库 | 过程记录放 `docs/records/` | 同 Agent-Native:避免个人项目再维护一个过程仓。风险是证据与代码同库 | 多人协作或对外发布时复查 |
|
||||
| documentation.md 过程仓默认独立仓库 | 其他过程记录放 `docs/records/`(既有过程记录的项目内固定目录豁免) | 同 Agent-Native:避免个人项目再维护一个过程仓。风险是过程记录与代码同库 | 多人协作或对外发布时复查 |
|
||||
| documentation.md 过程仓默认独立仓库 | 任务卡与交付证据放 `.task/`(模板 `.task/templates/task-card.md`) | 任务状态仍只认 Gitea Issue/PR,`.task/` 不写成平行状态;风险是任务卡与代码同库 | 多人协作或对外发布时复查 |
|
||||
| multi-repo.md 组件说明不手写第二套 SHA | PROJECT.md 只写路径与角色,SHA 以 `git submodule status` 为准 | 手抄 SHA 会与 gitlink 漂移 | 每次依赖升级核对脚本 |
|
||||
|
||||
## 本次升级或首次接入验证
|
||||
|
||||
- `deps/development-guidelines` 固定 `26b562d2e34fbc560711296a309c91c74ffc55b1`。
|
||||
- 执行方式为 manual。
|
||||
- 回退该规范挂载时,同时确认 Agent-Native 子模块路径是否已迁移;只还原规范 gitlink 不会把 `deps/agent-native` 移回根目录。
|
||||
- `deps/development-guidelines` 固定 `2688fd2be6a52c0bf110e7e6366b6f291ab5e4eb`(内容版本 `0.1.0-draft.12`);`deps/development-manager` 固定 `bd3a7de57a3a9e1a4495b94c5fa5c08d71e7be8c`。
|
||||
- 工程 Agent 执行:`AGENT-EXECUTION.md` 为唯一路由权威;AI 实施单元只经 `scripts/agent-exec run` 执行,并经 `scripts/agent-exec apply` 集成。`scripts/agent-exec` 与 `scripts/task` 只是转调 `scripts/dm` 的薄入口。
|
||||
- 登记为 active 的前置证据:一次成功执行正例(有效 `completed` 收据经 transport/admission 集成);缺配置、无效终态收据、越权路径、未知副作用四类负例均被拒绝或 fail-stop。证据按 `.task/feature-governance-adoption.md` 记录。
|
||||
- 入口核验:`scripts/dm governance check --repo .` 与 `scripts/agent-exec validate --repo .`(PROJECT.md「验证」节)。CI 由 `.gitea/workflows/governance.yml` 执行;既有 `ci.yml` 未改。
|
||||
- 回退该规范挂载时,恢复上一已批准 gitlink `26b562d2e34f`,并同时还原本次新增的治理脚本与 `governance.yml`;如已启用 `core.hooksPath`,需一并关闭。只还原规范 gitlink 不会把 `deps/agent-native` 移回根目录。
|
||||
|
||||
### 2026-09-29 追加:agent-intent 可选意图闸门
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"key":"demo","title":"示例","steps":[{"id":"s1","role":"writer","title":"起草","parallel":false}]}
|
||||
@@ -0,0 +1 @@
|
||||
{"role":"writer","name":"写作者","backend":"stub","tool_profile":"workspace"}
|
||||
@@ -0,0 +1,59 @@
|
||||
package schema
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"agent-squad/squad"
|
||||
)
|
||||
|
||||
func TestSamplesRoundTrip(t *testing.T) {
|
||||
var role squad.RoleSpec
|
||||
mustRoundTrip(t, "role.sample.json", &role)
|
||||
if role.Role != "writer" || role.Name != "写作者" {
|
||||
t.Fatalf("%+v", role)
|
||||
}
|
||||
var bp squad.Blueprint
|
||||
mustRoundTrip(t, "blueprint.sample.json", &bp)
|
||||
if bp.Key != "demo" || len(bp.Steps) != 1 {
|
||||
t.Fatalf("%+v", bp)
|
||||
}
|
||||
var plan squad.WorkflowPlan
|
||||
mustRoundTrip(t, "workflow-plan.sample.json", &plan)
|
||||
if plan.WorkflowID != "wf-1" || plan.Status != "pending" {
|
||||
t.Fatalf("%+v", plan)
|
||||
}
|
||||
}
|
||||
|
||||
func mustRoundTrip(t *testing.T, name string, dest any) {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.Unmarshal(raw, dest); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
back, err := json.Marshal(dest)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
again := reflectNew(dest)
|
||||
if err := json.Unmarshal(back, again); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func reflectNew(v any) any {
|
||||
switch v.(type) {
|
||||
case *squad.RoleSpec:
|
||||
return &squad.RoleSpec{}
|
||||
case *squad.Blueprint:
|
||||
return &squad.Blueprint{}
|
||||
case *squad.WorkflowPlan:
|
||||
return &squad.WorkflowPlan{}
|
||||
default:
|
||||
panic("unknown sample")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"workflow_id":"wf-1","title":"示例","status":"pending","current_step_index":0,"steps":[{"id":"s1","role":"writer","title":"起草","parallel":false}]}
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
# 共享 Agent 执行器薄入口:转调 scripts/dm 的 agent-exec 子命令(validate / run / apply / admit / cleanup)。
|
||||
set -euo pipefail
|
||||
exec "$(dirname "$0")/dm" agent-exec "$@"
|
||||
Executable
+55
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env bash
|
||||
# 共享 dm-server 薄入口:读取 deps/development-manager 的 gitlink 钉住提交,
|
||||
# 按该提交构建并缓存 dm-server,再 exec 其子命令(agent-exec、task、governance)。执行语义与退出码以该二进制为准。
|
||||
set -euo pipefail
|
||||
|
||||
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)
|
||||
dm="$root/deps/development-manager"
|
||||
|
||||
die() { echo "dm: $*" >&2; exit 2; }
|
||||
|
||||
# git 钩子会导出仓库本地变量(GIT_DIR、GIT_INDEX_FILE 等),它们会让 `git -C "$dm"` 解析到外层仓库。
|
||||
# 所有针对 DM 检出的 git 与 go 调用都在清除这些变量后执行;读取外层 gitlink 与最终 exec 仍保留继承环境。
|
||||
dm_local_env="GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_PREFIX GIT_OBJECT_DIRECTORY GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_COMMON_DIR GIT_GRAFT_FILE GIT_NAMESPACE GIT_CONFIG GIT_CONFIG_PARAMETERS GIT_CONFIG_COUNT GIT_SHALLOW_FILE GIT_IMPLICIT_WORK_TREE $(git rev-parse --local-env-vars 2>/dev/null || true)"
|
||||
dm_clean_env() { for v in $dm_local_env; do unset "$v"; done; }
|
||||
dmgit() ( dm_clean_env; git -C "$dm" "$@" )
|
||||
|
||||
# 1. 钉住版本只来自索引中的 gitlink(mode 160000),不读取工作区文件。
|
||||
entry=$(git -C "$root" ls-files -s -- deps/development-manager 2>/dev/null || true)
|
||||
[ -n "$entry" ] || die "索引中缺少 deps/development-manager 的 gitlink,无法确定钉住的 DM 版本"
|
||||
read -r mode pin _stage _path <<<"$entry"
|
||||
[ "$mode" = 160000 ] || die "deps/development-manager 不是 gitlink(mode=$mode)"
|
||||
[[ "$pin" =~ ^[0-9a-f]{40}$ ]] || die "gitlink 提交格式无效:$pin"
|
||||
|
||||
# 2. 检出必须存在且确为 DM 独立仓库(避免 git -C 误解析到外层仓库),HEAD 必须等于钉住提交。
|
||||
[ -e "$dm/.git" ] || die "deps/development-manager 未检出;请按钉住提交 $pin 检出该 gitlink"
|
||||
top=$(dmgit rev-parse --show-toplevel 2>/dev/null || true)
|
||||
[ -n "$top" ] && [ "$top" = "$(cd "$dm" && pwd -P)" ] \
|
||||
|| die "deps/development-manager 不是独立检出;请按钉住提交 $pin 检出该 gitlink"
|
||||
head=$(dmgit rev-parse HEAD 2>/dev/null || true)
|
||||
[ "$head" = "$pin" ] \
|
||||
|| die "deps/development-manager HEAD(${head:-未知})与钉住提交 $pin 不一致;请按钉住提交检出"
|
||||
|
||||
# 3. 检出工作树必须干净(忽略嵌套 submodule 状态)。
|
||||
dirty=$(dmgit status --porcelain --ignore-submodules=all) \
|
||||
|| die "无法读取 deps/development-manager 的工作树状态"
|
||||
[ -z "$dirty" ] || die "deps/development-manager 存在未提交改动,拒绝运行(请先清理该检出)"
|
||||
|
||||
# 4. 二进制按钉住提交缓存;缺失时在缓存目录内临时构建,完成后原子改名,避免留下半成品。
|
||||
cache="${DM_CACHE:-${XDG_CACHE_HOME:-$HOME/.cache}/development-manager/dm-server}"
|
||||
bin="$cache/dm-server-$pin"
|
||||
if [ ! -x "$bin" ]; then
|
||||
command -v go >/dev/null 2>&1 || die "缺少 go,无法构建 dm-server($pin)"
|
||||
mkdir -p "$cache"
|
||||
cache=$(cd "$cache" && pwd -P)
|
||||
bin="$cache/dm-server-$pin"
|
||||
work=$(mktemp -d "$cache/build-$pin.XXXXXX")
|
||||
if ! (dm_clean_env; cd "$dm" && GOWORK=off go build -trimpath -o "$work/dm-server" ./cmd/dm-server); then
|
||||
rm -rf "$work"
|
||||
die "构建 dm-server 失败($pin)"
|
||||
fi
|
||||
mv -f "$work/dm-server" "$bin"
|
||||
rm -rf "$work"
|
||||
fi
|
||||
|
||||
exec "$bin" "$@"
|
||||
Executable
+16
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env bash
|
||||
# 本地检查(pre-commit):集成分支直提拒绝 + 分支命名 + 本地完整性核对。权威准入在 PR(baseline.md:26)。
|
||||
branch=$(git rev-parse --abbrev-ref HEAD)
|
||||
case "$branch" in
|
||||
main|master)
|
||||
echo "pre-commit: 拒绝——集成分支 ${branch} 不得直接提交,走 feature 分支 + PR(collaboration.md)" >&2
|
||||
exit 1 ;;
|
||||
esac
|
||||
echo "$branch" | grep -qE '^(feature|fix|hotfix|release)/[A-Za-z0-9._-]+$' || {
|
||||
echo "pre-commit: 拒绝——分支名须为 feature|fix|hotfix|release/<slug>,当前:${branch}" >&2
|
||||
exit 1
|
||||
}
|
||||
root=$(git rev-parse --show-toplevel)
|
||||
# admit 只是本地完整性/篡改证据(SELF):同一 OS 用户可读取本地 HMAC 密钥并绕过本钩子,
|
||||
# 它不是 GATE,也不是可信身份。缺失或无效的本地记录仍拒绝;交付信任来自精确 HEAD 的 CI 或独立 REPLAY。
|
||||
"$root/scripts/agent-exec" admit --repo "$root"
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
# 通用任务入口:转调 scripts/dm 的 task 子命令(triage / start / pr-check)。
|
||||
set -euo pipefail
|
||||
exec "$(dirname "$0")/dm" task "$@"
|
||||
@@ -0,0 +1,56 @@
|
||||
package squad
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAtomicWriteLeavesNoTemp(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "runstate.json")
|
||||
if err := AtomicWrite(path, []byte(`{"ok":true}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil || string(got) != `{"ok":true}` {
|
||||
t.Fatalf("content: %v %s", err, got)
|
||||
}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.Name() != "runstate.json" {
|
||||
t.Fatalf("temp file left behind: %s", e.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAtomicWriteRenameFailureKeepsOriginal(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "occupied")
|
||||
if err := os.Mkdir(path, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
kept := filepath.Join(path, "keep.txt")
|
||||
if err := os.WriteFile(kept, []byte("stay"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := AtomicWrite(path, []byte("new")); err == nil {
|
||||
t.Fatal("rename onto a non-empty directory should fail")
|
||||
}
|
||||
got, err := os.ReadFile(kept)
|
||||
if err != nil || string(got) != "stay" {
|
||||
t.Fatalf("original changed: %v %s", err, got)
|
||||
}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.Name() != "occupied" {
|
||||
t.Fatalf("temp file left behind: %s", e.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package squad
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// perStepRecordingBackend 按子工作区归因计数(steps 的 role→tasks/NN_role 布局)。
|
||||
type perStepRecordingBackend struct {
|
||||
mu sync.Mutex
|
||||
calls map[string]int
|
||||
}
|
||||
|
||||
func (b *perStepRecordingBackend) Name() string { return "recording" }
|
||||
|
||||
func (b *perStepRecordingBackend) Execute(ctx context.Context, req ExecRequest) (*ExecResult, error) {
|
||||
b.mu.Lock()
|
||||
if b.calls == nil {
|
||||
b.calls = map[string]int{}
|
||||
}
|
||||
b.calls[filepath.Base(req.WorkDir)]++
|
||||
b.mu.Unlock()
|
||||
out := map[string][]byte{}
|
||||
for _, t := range req.Targets {
|
||||
out[t] = []byte(goodReport)
|
||||
}
|
||||
if _, err := MaterializeTargets(req.WorkDir, req.Targets, out); err != nil {
|
||||
return &ExecResult{Success: false, ErrorReason: err.Error()}, nil
|
||||
}
|
||||
return &ExecResult{Success: true, Logs: "ok"}, nil
|
||||
}
|
||||
|
||||
func (b *perStepRecordingBackend) countOf(sub string) int {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.calls[sub]
|
||||
}
|
||||
|
||||
func newAttemptTestEngine(t *testing.T, backend *perStepRecordingBackend, dir string) *SquadEngine {
|
||||
t.Helper()
|
||||
reg := NewRoleRegistry()
|
||||
for _, role := range []string{"researcher", "writer", "reviewer"} {
|
||||
if err := reg.Register(RoleSpec{Role: role, Backend: "recording"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
backends := NewBackendResolver()
|
||||
backends.RegisterInstance("recording", backend)
|
||||
eng, err := NewEngine(Options{
|
||||
DataDir: dir, Registry: reg, Planner: mockScriptPlanner(baseSteps()...),
|
||||
Backends: backends, Sink: NopSink{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return eng
|
||||
}
|
||||
|
||||
func flipToInterrupted(t *testing.T, workDir string, stepIdx int) {
|
||||
t.Helper()
|
||||
st, err := loadRunState(workDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st.Meta.Status = string(RunExecuting)
|
||||
st.Meta.CompletedAt = nil
|
||||
wf := st.Workflow()
|
||||
wf.Status = "running"
|
||||
wf.Steps[stepIdx].Status = "running"
|
||||
wf.Steps[stepIdx].Attempts = 1
|
||||
st.setWorkflow(wf)
|
||||
if err := st.save(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAttemptIDFormat:attempt id 稳定落盘且形如 runID/stepID/N(Q1 WP3)。
|
||||
func TestAttemptIDFormat(t *testing.T) {
|
||||
backend := &perStepRecordingBackend{}
|
||||
dir := t.TempDir()
|
||||
eng := newAttemptTestEngine(t, backend, dir)
|
||||
res, err := eng.Run(context.Background(), Goal{Text: "格式"}, RunOptions{})
|
||||
if err != nil || res.Status != RunCompleted {
|
||||
t.Fatalf("run: %v %+v", err, res)
|
||||
}
|
||||
if res.Plan == nil {
|
||||
t.Fatal("no plan")
|
||||
}
|
||||
for _, s := range res.Plan.Steps {
|
||||
want := res.RunID + "/" + s.ID + "/1"
|
||||
if s.AttemptID != want {
|
||||
t.Fatalf("step %s attempt id = %q, want %q", s.ID, s.AttemptID, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestKilledAtSuperstepBoundarySideEffectsOnce:杀进程于 superstep 边界后恢复,
|
||||
// 已完成步骤不重新执行(执行次数=1);running 且产物齐全的步骤按 attempt 判定
|
||||
// 完成、不 rerun——Q1 验收第一条(WP3)。
|
||||
func TestKilledAtSuperstepBoundarySideEffectsOnce(t *testing.T) {
|
||||
backend := &perStepRecordingBackend{}
|
||||
dir := t.TempDir()
|
||||
eng := newAttemptTestEngine(t, backend, dir)
|
||||
res, err := eng.Run(context.Background(), Goal{Text: "先跑完再中断", HitlMode: HitlAuto}, RunOptions{})
|
||||
if err != nil || res.Status != RunCompleted {
|
||||
t.Fatalf("setup run: %v %+v", err, res)
|
||||
}
|
||||
before := map[string]int{}
|
||||
st, err := loadRunState(res.WorkDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, s := range st.Plan.Workflow.Steps {
|
||||
before[s.ID] = backend.countOf(filepath.Base(filepath.FromSlash(s.SubWorkspace)))
|
||||
}
|
||||
|
||||
// 第二步停在 running(产物其实在盘上——首次运行真实写出的)。
|
||||
flipToInterrupted(t, res.WorkDir, 1)
|
||||
|
||||
eng2 := newAttemptTestEngine(t, backend, dir)
|
||||
res, err = eng2.Resume(context.Background(), res.RunID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Status != RunCompleted {
|
||||
t.Fatalf("resume: %+v", res)
|
||||
}
|
||||
for _, s := range res.Plan.Steps {
|
||||
if s.Status != "completed" && s.Status != "skipped" {
|
||||
t.Fatalf("step %s not finished: %s", s.ID, s.Status)
|
||||
}
|
||||
got := backend.countOf(filepath.Base(filepath.FromSlash(s.SubWorkspace)))
|
||||
if got != before[s.ID] {
|
||||
t.Fatalf("step %s executed %d times after kill (before=%d)——已完成/running 步骤被重复执行", s.ID, got, before[s.ID])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestInterruptedMissingProductsRetries:running 崩溃且产物缺失时按 auto 一次重试(WP3)。
|
||||
func TestInterruptedMissingProductsRetries(t *testing.T) {
|
||||
backend := &perStepRecordingBackend{}
|
||||
dir := t.TempDir()
|
||||
eng := newAttemptTestEngine(t, backend, dir)
|
||||
res, err := eng.Run(context.Background(), Goal{Text: "先跑完再中断", HitlMode: HitlAuto}, RunOptions{})
|
||||
if err != nil || res.Status != RunCompleted {
|
||||
t.Fatalf("setup run: %v %+v", err, res)
|
||||
}
|
||||
// 第二步停 running 并删掉其交付物(副作用未发生的形态)。
|
||||
flipToInterrupted(t, res.WorkDir, 1)
|
||||
st, err := loadRunState(res.WorkDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sub := filepath.FromSlash(st.Plan.Workflow.Steps[1].SubWorkspace)
|
||||
if err := os.Remove(filepath.Join(res.WorkDir, sub, "output.md")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
eng2 := newAttemptTestEngine(t, backend, dir)
|
||||
res, err = eng2.Resume(context.Background(), res.RunID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Status != RunCompleted {
|
||||
t.Fatalf("resume: %+v", res)
|
||||
}
|
||||
got := backend.countOf(filepath.Base(sub))
|
||||
if got != 2 {
|
||||
t.Fatalf("missing-product step executed %d times, want 2(auto 一次重试)", got)
|
||||
}
|
||||
}
|
||||
+11
-127
@@ -1,24 +1,15 @@
|
||||
package squad
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"agent-native/adapters/native"
|
||||
"agent-native/exec"
|
||||
"agent-native/runtime/llm"
|
||||
"agent-native/runtime/mcp"
|
||||
"agent-native/runtime/subagent"
|
||||
"agent-native/runtime/tools"
|
||||
|
||||
"error-code-system/errcode"
|
||||
)
|
||||
|
||||
// BackendFactory 按角色构造后端:角色级 Model/ToolProfile 在工厂闭包层绑定
|
||||
// (agent-native exec.Request 无 Model 字段,绑定不侵入执行契约)。
|
||||
type BackendFactory func(role RoleSpec) (exec.Backend, error)
|
||||
// (agent-native ExecRequest 无 Model 字段,绑定不侵入执行契约)。
|
||||
type BackendFactory func(role RoleSpec) (Executor, error)
|
||||
|
||||
// BackendResolver 角色 → 智能体后端解析器。
|
||||
// 内置 "stub"(agent-native 离线桩);真实智能体经 RegisterAgentNative 注册
|
||||
@@ -28,18 +19,18 @@ type BackendFactory func(role RoleSpec) (exec.Backend, error)
|
||||
type BackendResolver struct {
|
||||
mu sync.RWMutex
|
||||
factories map[string]BackendFactory
|
||||
underlying map[string]exec.Backend // 无工厂直挂的后端实例
|
||||
wrapped map[string]exec.Backend // 角色(含模型绑定)→ 已包装实例缓存
|
||||
underlying map[string]Executor // 无工厂直挂的后端实例
|
||||
wrapped map[string]Executor // 角色(含模型绑定)→ 已包装实例缓存
|
||||
}
|
||||
|
||||
// NewBackendResolver 创建解析器并注册内置后端。
|
||||
func NewBackendResolver() *BackendResolver {
|
||||
r := &BackendResolver{
|
||||
factories: make(map[string]BackendFactory),
|
||||
underlying: make(map[string]exec.Backend),
|
||||
wrapped: make(map[string]exec.Backend),
|
||||
underlying: make(map[string]Executor),
|
||||
wrapped: make(map[string]Executor),
|
||||
}
|
||||
r.RegisterInstance("stub", &exec.StubBackend{})
|
||||
r.RegisterInstance("stub", &StubExecutor{})
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -51,119 +42,12 @@ func (r *BackendResolver) Register(name string, f BackendFactory) {
|
||||
}
|
||||
|
||||
// RegisterInstance 直接注册后端实例(忽略角色级绑定)。
|
||||
func (r *BackendResolver) RegisterInstance(name string, b exec.Backend) {
|
||||
func (r *BackendResolver) RegisterInstance(name string, b Executor) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.underlying[name] = b
|
||||
}
|
||||
|
||||
// AgentNativeConfig 声明 agent-native 后端的全量能力装配(组合根提供)。
|
||||
// 零值 = 仅基础模型执行:搜索/取回/执行/MCP/技能/委派工具一律不注册(fail-closed)。
|
||||
type AgentNativeConfig struct {
|
||||
Client *llm.Client
|
||||
DefaultModel string
|
||||
MaxTurns int
|
||||
Timeout time.Duration
|
||||
// 以下均为 agent-native Options 的直通注入,详见其注释。
|
||||
Search tools.SearchProvider
|
||||
Exec *tools.ExecConfig
|
||||
Fetch *tools.FetchConfig
|
||||
MCP *mcp.Manager
|
||||
SkillsRoots []string
|
||||
Subagent subagent.Factory
|
||||
Compact *native.CompactConfig
|
||||
// HostTools 注册进内核工作区。与内置工具同名时该步骤失败,不覆盖。
|
||||
HostTools []tools.Tool
|
||||
// OnEvent 收到内核的逐步过程事件(文本、工具、用量)。与 Request.OnEvent 同时生效。
|
||||
OnEvent func(ev exec.ProcessEvent)
|
||||
// Stream 测试注入。为空时按 Client 访问模型。
|
||||
Stream llm.StreamFunc
|
||||
// MaxToolCalls 单步工具调用上限。0 表示不限制。
|
||||
MaxToolCalls int
|
||||
}
|
||||
|
||||
// buildAgentNativeOptions 按角色把配置换算为 native.Options。
|
||||
// BashPermission 三态在此执行:deny = 不装配执行工具(无论组合根是否提供 Exec);
|
||||
// ask/allow = 装配(ask 的人工门由引擎 permission 挂起点先行把关)。
|
||||
func buildAgentNativeOptions(cfg AgentNativeConfig, role RoleSpec) (native.Options, error) {
|
||||
model := role.Model
|
||||
if model == "" {
|
||||
model = cfg.DefaultModel
|
||||
}
|
||||
if model == "" {
|
||||
return native.Options{}, fmt.Errorf("agent-native backend requires a model (role=%s)", role.Role)
|
||||
}
|
||||
opts := native.Options{
|
||||
Model: model,
|
||||
MaxTurns: cfg.MaxTurns,
|
||||
Timeout: cfg.Timeout,
|
||||
Search: cfg.Search,
|
||||
Fetch: cfg.Fetch,
|
||||
MCP: cfg.MCP,
|
||||
SkillsRoots: cfg.SkillsRoots,
|
||||
Subagent: cfg.Subagent,
|
||||
Compact: cfg.Compact,
|
||||
HostTools: cfg.HostTools,
|
||||
Stream: cfg.Stream,
|
||||
MaxToolCalls: cfg.MaxToolCalls,
|
||||
}
|
||||
if cfg.Client != nil {
|
||||
opts.APIBase, opts.APIKey = cfg.Client.BaseURL, cfg.Client.APIKey
|
||||
}
|
||||
// 显式 allow/ask 才装配(注册层默认 deny,但派生/程序构造的 RoleSpec
|
||||
// 未必经注册——工厂层兜底,空白串不放行)。
|
||||
if cfg.Exec != nil && (role.BashPermission == BashPermissionAllow || role.BashPermission == BashPermissionAsk) {
|
||||
opts.Exec = cfg.Exec
|
||||
}
|
||||
return opts, nil
|
||||
}
|
||||
|
||||
// processEventBackend 把配置级过程回调接到每次 Execute,同时保留请求自带的 OnEvent。
|
||||
type processEventBackend struct {
|
||||
next exec.Backend
|
||||
on func(ev exec.ProcessEvent)
|
||||
}
|
||||
|
||||
func (b *processEventBackend) Name() string { return b.next.Name() }
|
||||
|
||||
func (b *processEventBackend) Execute(ctx context.Context, req exec.Request) (*exec.Result, error) {
|
||||
if b.on != nil {
|
||||
prev := req.OnEvent
|
||||
req.OnEvent = func(ev exec.ProcessEvent) {
|
||||
b.on(ev)
|
||||
if prev != nil {
|
||||
prev(ev)
|
||||
}
|
||||
}
|
||||
}
|
||||
return b.next.Execute(ctx, req)
|
||||
}
|
||||
|
||||
// RegisterAgentNative 注册 "agent-native" 后端工厂(基础签名,向后兼容):
|
||||
// 角色级 Model 覆盖 defaultModel,无附加能力装配。
|
||||
func (r *BackendResolver) RegisterAgentNative(client *llm.Client, defaultModel string, maxTurns int, timeout time.Duration) {
|
||||
r.RegisterAgentNativeFull(AgentNativeConfig{
|
||||
Client: client, DefaultModel: defaultModel, MaxTurns: maxTurns, Timeout: timeout,
|
||||
})
|
||||
}
|
||||
|
||||
// RegisterAgentNativeFull 注册带全量能力装配的 "agent-native" 工厂:
|
||||
// 搜索/取回/执行/MCP/技能/委派经配置注入;BashPermission=deny 的角色
|
||||
// 不装配执行工具。智能体执行全部委托 agent-native,本库不实现 Agent。
|
||||
func (r *BackendResolver) RegisterAgentNativeFull(cfg AgentNativeConfig) {
|
||||
r.Register("agent-native", func(role RoleSpec) (exec.Backend, error) {
|
||||
opts, err := buildAgentNativeOptions(cfg, role)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
next := native.New(opts)
|
||||
if cfg.OnEvent == nil {
|
||||
return next, nil
|
||||
}
|
||||
return &processEventBackend{next: next, on: cfg.OnEvent}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// ListBackends 返回可用后端名(排序稳定)。
|
||||
func (r *BackendResolver) ListBackends() []string {
|
||||
r.mu.RLock()
|
||||
@@ -191,7 +75,7 @@ func (r *BackendResolver) ListBackends() []string {
|
||||
// Resolve 解析角色的执行后端:Role.Backend → Options.DefaultBackend → "stub"。
|
||||
// MaxConcurrentTasks > 1 时以 agent-native 并发限流包装(per-role 缓存,跨步骤共享;
|
||||
// 创建段持写锁防并行首解析竞态——否则并发步骤会各拿一份限流器)。
|
||||
func (r *BackendResolver) Resolve(role RoleSpec, defaultBackend string) (exec.Backend, string, error) {
|
||||
func (r *BackendResolver) Resolve(role RoleSpec, defaultBackend string) (Executor, string, error) {
|
||||
name := role.Backend
|
||||
if name == "" {
|
||||
name = defaultBackend
|
||||
@@ -219,7 +103,7 @@ func (r *BackendResolver) Resolve(role RoleSpec, defaultBackend string) (exec.Ba
|
||||
return b, name, nil
|
||||
}
|
||||
|
||||
var b exec.Backend
|
||||
var b Executor
|
||||
if hasUnder {
|
||||
b = under
|
||||
} else if hasFactory {
|
||||
@@ -234,7 +118,7 @@ func (r *BackendResolver) Resolve(role RoleSpec, defaultBackend string) (exec.Ba
|
||||
|
||||
// MaxConcurrentTasks>=1 即包装限流(1 = 对象级串行;0 = 不限)。
|
||||
if max := role.MaxConcurrentTasks; max >= 1 {
|
||||
b = exec.WrapWithConcurrencyLimit(b, max)
|
||||
b = WrapWithConcurrencyLimit(b, max)
|
||||
}
|
||||
r.wrapped[cacheKey] = b
|
||||
return b, name, nil
|
||||
|
||||
+1
-5
@@ -34,11 +34,7 @@ func saveBlackboard(wsDir string, bb *BlackboardState) error {
|
||||
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"))
|
||||
return AtomicWrite(filepath.Join(wsDir, "blackboard.json"), append(data, '\n'))
|
||||
}
|
||||
|
||||
// mergeStepArtifacts 合并步骤产出:fields.json(字符串键值)与
|
||||
|
||||
+6
-4
@@ -13,7 +13,7 @@ type BlueprintRegistry struct {
|
||||
mu sync.RWMutex
|
||||
bps map[string]Blueprint
|
||||
// OnChange 可选钩子:注册/删除后回调(store 层用于落盘)。
|
||||
OnChange func(bp Blueprint)
|
||||
OnChange func(bp Blueprint) error
|
||||
}
|
||||
|
||||
// NewBlueprintRegistry 创建空注册表。
|
||||
@@ -23,8 +23,8 @@ func NewBlueprintRegistry() *BlueprintRegistry {
|
||||
|
||||
// Register 注册或更新模板。
|
||||
func (r *BlueprintRegistry) Register(bp Blueprint) error {
|
||||
if strings.TrimSpace(bp.Key) == "" {
|
||||
return fmt.Errorf("blueprint key cannot be empty")
|
||||
if !ValidID(bp.Key) {
|
||||
return invalidID(bp.Key)
|
||||
}
|
||||
if len(bp.Steps) == 0 {
|
||||
return fmt.Errorf("blueprint %s has no steps", bp.Key)
|
||||
@@ -41,7 +41,9 @@ func (r *BlueprintRegistry) Register(bp Blueprint) error {
|
||||
r.bps[bp.Key] = bp
|
||||
r.mu.Unlock()
|
||||
if r.OnChange != nil {
|
||||
r.OnChange(bp)
|
||||
if err := r.OnChange(bp); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package squad
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// brief_test.go — Q3 WP1a:StepSpec.Brief 渲染进 TASK.md「任务指令」节。
|
||||
|
||||
func TestStepBriefRendersIntoTaskFile(t *testing.T) {
|
||||
rec := &promptRecordingBackend{prompts: map[string][]string{}}
|
||||
engine := newRecordingEngine(t, rec, []StepSpec{
|
||||
{ID: "s1", Role: "researcher", Title: "起草", Targets: []string{"output.md"},
|
||||
Brief: "写一份关于「太空电梯」的简报,篇幅:短。\n必须包含单独一行「核对标记:P4」。"},
|
||||
})
|
||||
res, err := engine.Run(context.Background(), Goal{Text: "带任务指令"}, RunOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Status != RunCompleted {
|
||||
t.Fatalf("status = %s err = %v", res.Status, res.Error)
|
||||
}
|
||||
var prompt string
|
||||
for _, p := range rec.prompts["01_researcher"] {
|
||||
prompt += p
|
||||
}
|
||||
if !strings.Contains(prompt, "## 任务指令") {
|
||||
t.Fatalf("TASK.md 缺任务指令节:%q", prompt)
|
||||
}
|
||||
if !strings.Contains(prompt, "核对标记:P4") {
|
||||
t.Fatalf("任务指令正文缺失:%q", prompt)
|
||||
}
|
||||
// 指令节在期望产出之后、上下文表之前。
|
||||
iOut := strings.Index(prompt, "期望产出")
|
||||
iBrief := strings.Index(prompt, "## 任务指令")
|
||||
iCtx := strings.Index(prompt, "## 任务上下文资源")
|
||||
if !(iOut < iBrief && iBrief < iCtx) {
|
||||
t.Fatalf("任务指令节位置错误:%d/%d/%d", iOut, iBrief, iCtx)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package squad
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestCheckpointFilesAtBoundaries:sync 默认档——每个 superstep 边界有快照,
|
||||
// latest 指向最后一层且图指纹与重编译一致(Q1 WP4)。
|
||||
func TestCheckpointFilesAtBoundaries(t *testing.T) {
|
||||
eng, _ := newTestEngine(t, mockScriptPlanner(baseSteps()...), map[string]map[string]string{
|
||||
"01_researcher": {"notes.md": goodReport, "fields.json": "{}"},
|
||||
"02_writer": {"output.md": goodReport},
|
||||
"03_reviewer": {"output.md": goodReport},
|
||||
}, NopSink{})
|
||||
res, err := eng.Run(context.Background(), Goal{Text: "快照"}, RunOptions{})
|
||||
if err != nil || res.Status != RunCompleted {
|
||||
t.Fatalf("run: %v %+v", err, res)
|
||||
}
|
||||
latest, err := FileStore{}.LoadLatestCheckpoint(res.WorkDir)
|
||||
if err != nil || latest == nil {
|
||||
t.Fatalf("latest checkpoint: %v", err)
|
||||
}
|
||||
if latest.Superstep < 3 {
|
||||
t.Fatalf("superstep = %d, want >= 3(三步各占一层)", latest.Superstep)
|
||||
}
|
||||
g, err := compileGraph(res.Plan.Steps)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if latest.GraphHash != g.hash() {
|
||||
t.Fatalf("hash = %s, want %s", latest.GraphHash, g.hash())
|
||||
}
|
||||
if len(latest.StepStates) != 3 {
|
||||
t.Fatalf("step states = %d", len(latest.StepStates))
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(res.WorkDir, "checkpoints", "001.json")); err != nil {
|
||||
t.Fatalf("first boundary snapshot missing: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckpointAsyncAndOnExit:async 在退出前落完;on_exit 只有最终快照。
|
||||
func TestCheckpointAsyncAndOnExit(t *testing.T) {
|
||||
for _, mode := range []string{"async", "on_exit"} {
|
||||
eng, _ := newTestEngine(t, mockScriptPlanner(baseSteps()...), map[string]map[string]string{
|
||||
"01_researcher": {"notes.md": goodReport, "fields.json": "{}"},
|
||||
"02_writer": {"output.md": goodReport},
|
||||
"03_reviewer": {"output.md": goodReport},
|
||||
}, NopSink{})
|
||||
res, err := eng.Run(context.Background(), Goal{Text: "快照"}, RunOptions{CheckpointMode: mode})
|
||||
if err != nil || res.Status != RunCompleted {
|
||||
t.Fatalf("%s run: %v %+v", mode, err, res)
|
||||
}
|
||||
latest, err := FileStore{}.LoadLatestCheckpoint(res.WorkDir)
|
||||
if err != nil || latest == nil {
|
||||
t.Fatalf("%s latest: %v", mode, err)
|
||||
}
|
||||
if latest.Superstep < 3 {
|
||||
t.Fatalf("%s superstep = %d", mode, latest.Superstep)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGraphContinuityRejectsStructuralChange:崩溃期间图形态被改则拒绝恢复(WP1 裁定)。
|
||||
func TestGraphContinuityRejectsStructuralChange(t *testing.T) {
|
||||
eng, _ := newTestEngine(t, mockScriptPlanner(baseSteps()...), map[string]map[string]string{
|
||||
"01_researcher": {"notes.md": goodReport, "fields.json": "{}"},
|
||||
"02_writer": {"output.md": goodReport},
|
||||
"03_reviewer": {"output.md": goodReport},
|
||||
}, NopSink{})
|
||||
dir := eng.opts.DataDir
|
||||
res, err := eng.Run(context.Background(), Goal{Text: "先跑完再中断", HitlMode: HitlAuto}, RunOptions{})
|
||||
if err != nil || res.Status != RunCompleted {
|
||||
t.Fatalf("setup run: %v %+v", err, res)
|
||||
}
|
||||
// 翻回 running(仅状态变化,图结构不变——应先过连续性校验)。
|
||||
st, err := loadRunState(res.WorkDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st.Meta.Status = string(RunExecuting)
|
||||
st.Meta.CompletedAt = nil
|
||||
wf := st.Workflow()
|
||||
wf.Status = "running"
|
||||
wf.Steps[1].Status = "running"
|
||||
wf.Steps[1].Attempts = 1
|
||||
// 再改结构:把 s3 的 needs 改成另一个合法 id——编译能过但指纹必变。
|
||||
wf.Steps[2].Needs = []string{"s1"}
|
||||
st.setWorkflow(wf)
|
||||
if err := st.save(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 用一个新的引擎在同一目录上恢复(图编译本身合法:needs 引用存在的 id)。
|
||||
reg := NewRoleRegistry()
|
||||
for _, role := range []string{"researcher", "writer", "reviewer"} {
|
||||
if err := reg.Register(RoleSpec{Role: role, Backend: "script"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
backends := NewBackendResolver()
|
||||
backends.RegisterInstance("script", &scriptBackend{script: map[string]map[string]string{}})
|
||||
eng2, err := NewEngine(Options{DataDir: dir, Registry: reg, Planner: eng.opts.Planner, Backends: backends, Sink: NopSink{}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = eng2.Resume(context.Background(), res.RunID)
|
||||
if err == nil || !strings.Contains(err.Error(), "图形态不一致") && !strings.Contains(err.Error(), "graph continuity") {
|
||||
t.Fatalf("expected continuity rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package squad
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func slashClean(p string) string {
|
||||
return filepath.ToSlash(filepath.Clean(p))
|
||||
}
|
||||
|
||||
// MatchDeliverableRel reports whether a runner rel_path refers to target
|
||||
// inside workDir. Accepts output.md, ./output.md, absolute paths under workDir,
|
||||
// and a duplicated step prefix (tasks/01_draft/output.md when workDir already
|
||||
// ends with tasks/01_draft). Paths that escape workDir never match.
|
||||
func MatchDeliverableRel(workDir, relPath, target string) bool {
|
||||
target = slashClean(strings.TrimSpace(target))
|
||||
if target == "" || target == "." || strings.HasPrefix(target, "../") || strings.Contains(target, "..") {
|
||||
return false
|
||||
}
|
||||
raw := strings.TrimSpace(relPath)
|
||||
if raw == "" {
|
||||
return false
|
||||
}
|
||||
abs := raw
|
||||
if !filepath.IsAbs(raw) {
|
||||
abs = filepath.Join(workDir, raw)
|
||||
}
|
||||
abs = filepath.Clean(abs)
|
||||
rel, err := filepath.Rel(filepath.Clean(workDir), abs)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
rel = slashClean(rel)
|
||||
if rel == ".." || strings.HasPrefix(rel, "../") {
|
||||
return false
|
||||
}
|
||||
if rel == target {
|
||||
return true
|
||||
}
|
||||
if filepath.Base(rel) != filepath.Base(target) {
|
||||
return false
|
||||
}
|
||||
prefix := slashClean(filepath.Dir(rel))
|
||||
wd := slashClean(workDir)
|
||||
return prefix != "." && (strings.HasSuffix(wd, "/"+prefix) || strings.HasSuffix(wd, prefix))
|
||||
}
|
||||
|
||||
// CollectTargetPatches maps incoming runner keys onto canonical target names
|
||||
// and also accepts a non-empty file already at workDir/target. Empty bodies
|
||||
// do not count — callers must not invent a deliverable.
|
||||
func CollectTargetPatches(workDir string, targets []string, incoming map[string][]byte) map[string][]byte {
|
||||
out := make(map[string][]byte, len(targets))
|
||||
for _, t := range targets {
|
||||
t = strings.TrimSpace(t)
|
||||
if t == "" {
|
||||
continue
|
||||
}
|
||||
for k, v := range incoming {
|
||||
if len(bytes.TrimSpace(v)) == 0 {
|
||||
continue
|
||||
}
|
||||
if MatchDeliverableRel(workDir, k, t) {
|
||||
out[t] = v
|
||||
break
|
||||
}
|
||||
}
|
||||
if _, ok := out[t]; ok {
|
||||
continue
|
||||
}
|
||||
p := filepath.Join(workDir, t)
|
||||
if b, err := os.ReadFile(p); err == nil && len(bytes.TrimSpace(b)) > 0 {
|
||||
out[t] = b
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// MaterializeTargets writes collected non-empty target bodies to workDir/<target>.
|
||||
// Returns a missing-target error without writing a stub if any required file is absent.
|
||||
func MaterializeTargets(workDir string, targets []string, incoming map[string][]byte) (map[string][]byte, error) {
|
||||
collected := CollectTargetPatches(workDir, targets, incoming)
|
||||
for _, t := range targets {
|
||||
t = strings.TrimSpace(t)
|
||||
if t == "" {
|
||||
continue
|
||||
}
|
||||
body, ok := collected[t]
|
||||
if !ok || len(bytes.TrimSpace(body)) == 0 {
|
||||
return collected, fmt.Errorf("missing target deliverable %s", t)
|
||||
}
|
||||
dest := filepath.Join(workDir, filepath.FromSlash(t))
|
||||
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||
return collected, err
|
||||
}
|
||||
if err := rejectSymlinkEscape(workDir, dest); err != nil {
|
||||
return collected, err
|
||||
}
|
||||
// Container-written files may be root-owned; make writable before overwrite.
|
||||
_ = os.Chmod(dest, 0o666)
|
||||
if err := os.WriteFile(dest, body, 0o644); err != nil {
|
||||
return collected, err
|
||||
}
|
||||
}
|
||||
return collected, nil
|
||||
}
|
||||
|
||||
func rejectSymlinkEscape(workDir, dest string) error {
|
||||
root, err := filepath.Abs(workDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
absDest, err := filepath.Abs(dest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, err := filepath.Rel(root, absDest)
|
||||
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
|
||||
return fmt.Errorf("typed result path rejected")
|
||||
}
|
||||
resolvedRoot, err := filepath.EvalSymlinks(root)
|
||||
if err != nil {
|
||||
resolvedRoot = root
|
||||
}
|
||||
parent := filepath.Dir(absDest)
|
||||
resolvedParent, err := filepath.EvalSymlinks(parent)
|
||||
if err != nil {
|
||||
return fmt.Errorf("typed result path rejected")
|
||||
}
|
||||
relParent, err := filepath.Rel(resolvedRoot, resolvedParent)
|
||||
if err != nil || relParent == ".." || strings.HasPrefix(relParent, ".."+string(os.PathSeparator)) {
|
||||
return fmt.Errorf("typed result path rejected")
|
||||
}
|
||||
if info, err := os.Lstat(absDest); err == nil && info.Mode()&os.ModeSymlink != 0 {
|
||||
return fmt.Errorf("typed result path rejected")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+519
-65
@@ -5,6 +5,7 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -34,6 +35,9 @@ type Options struct {
|
||||
HitlPolicy HitlPolicy
|
||||
// RepairBudget 质检失败自动修复次数(默认 1)。
|
||||
RepairBudget int
|
||||
// Store 运行状态持久化端口(Q1 WP4/WP5);nil 回落 FileStore。
|
||||
// 注入 SQLiteStore 后运行状态进 SQLite(装配期一次,跨 run 一致)。
|
||||
Store Store
|
||||
// IntentGate 可选意图闸门:Run 入口一次确定性裁决(nil = 不启用,
|
||||
// 行为与原实现一致)。见 intent.go;实现方为 squadintent 适配器。
|
||||
IntentGate IntentGate
|
||||
@@ -59,6 +63,31 @@ type engineDeps struct {
|
||||
StepTimeout time.Duration
|
||||
HitlPolicy HitlPolicy
|
||||
IntentRunner IntentRunner
|
||||
// Store 运行状态持久化端口(Q1 WP4);nil 回落 FileStore(装配期注入替换实现)。
|
||||
Store Store
|
||||
}
|
||||
|
||||
// storeOr 返回注入的 Store,缺省 FileStore。
|
||||
func (d engineDeps) storeOr() Store {
|
||||
if d.Store != nil {
|
||||
return d.Store
|
||||
}
|
||||
return FileStore{}
|
||||
}
|
||||
|
||||
// loadRun 经端口加载运行状态,并让它携带同一端口(后续 save 自然走同一实现)。
|
||||
// runDir 是运行的工作区目录(所有 run 级 JSON 的根)。
|
||||
func (e *SquadEngine) runDir(runID string) string {
|
||||
return filepath.Join(e.opts.DataDir, "runs", runID)
|
||||
}
|
||||
|
||||
func (e *SquadEngine) loadRun(wsDir string) (*RunState, error) {
|
||||
st, err := e.deps.storeOr().LoadRun(wsDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
st.store = e.deps.storeOr()
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// RunOptions 单次运行的可选项。
|
||||
@@ -74,6 +103,30 @@ type RunOptions struct {
|
||||
LeaderRole string `json:"leader_role,omitempty"`
|
||||
// MaxLeaderTurns leader 模式每运行的 leader 轮次上限(默认 8,防失控)。
|
||||
MaxLeaderTurns int `json:"max_leader_turns,omitempty"`
|
||||
// CheckpointMode superstep 边界快照档位(Q1 WP4):sync(默认)| async | on_exit。
|
||||
CheckpointMode string `json:"checkpoint_mode,omitempty"`
|
||||
// Topology 显式拓扑名(Q2;优先于 Blueprint.Topology;空 = standard 图)。
|
||||
// 未注册名 / primitive 单跑 / driver 未点亮均 fail-closed。
|
||||
Topology string `json:"topology,omitempty"`
|
||||
// Deadline 运行级截止时刻(Q2 long_running):superstep 边界检查,
|
||||
// 超期把未完步骤置 skipped 并降级收口。零值 = 不设限。
|
||||
Deadline time.Time `json:"deadline,omitempty"`
|
||||
// MaxReplans leader 模式的重规划上限(Q2;与 MaxLeaderTurns 独立)。
|
||||
// 0 = 默认 DefaultMaxReplans。
|
||||
MaxReplans int `json:"max_replans,omitempty"`
|
||||
// GroupChatPolicy group_chat 拓扑发言人策略(Q2):rotate(默认)| moderator。
|
||||
GroupChatPolicy string `json:"group_chat_policy,omitempty"`
|
||||
// GroupChatModerator 主持人角色 key(moderator 策略必填,须在名册内)。
|
||||
GroupChatModerator string `json:"group_chat_moderator,omitempty"`
|
||||
// GroupChatMaxTurns 群聊轮次预算(0 = DefaultMaxGroupChatTurns)。
|
||||
GroupChatMaxTurns int `json:"group_chat_max_turns,omitempty"`
|
||||
// ReviewVerifyRole review 拓扑核实角色 key(默认 "verifier")。
|
||||
ReviewVerifyRole string `json:"review_verify_role,omitempty"`
|
||||
// ReviewTarget 被评审材料(默认取 context/goal.md)。
|
||||
ReviewTarget string `json:"review_target,omitempty"`
|
||||
// TaskBoardLeaseSeconds task_board 任务租约时限(秒;0 = 默认 600)。
|
||||
// 孤儿租约(claimed 且过期)在驱动重入时再武装回 open。
|
||||
TaskBoardLeaseSeconds int `json:"task_board_lease_seconds,omitempty"`
|
||||
}
|
||||
|
||||
// SquadEngine 小队引擎门面:Run / Respond / Resume / Inspect / ListRuns。
|
||||
@@ -130,7 +183,7 @@ func NewEngine(opts Options) (*SquadEngine, error) {
|
||||
Registry: opts.Registry, Backends: opts.Backends, Evaluator: opts.Evaluator,
|
||||
Checker: opts.Checker, Sink: opts.Sink, Workspaces: ws,
|
||||
StepTimeout: opts.StepTimeout, HitlPolicy: opts.HitlPolicy,
|
||||
IntentRunner: opts.IntentRunner,
|
||||
IntentRunner: opts.IntentRunner, Store: opts.Store,
|
||||
}
|
||||
return &SquadEngine{opts: opts, deps: deps, ws: ws, runLocks: map[string]*sync.Mutex{}}, nil
|
||||
}
|
||||
@@ -152,15 +205,42 @@ func (e *SquadEngine) runLock(runID string) *sync.Mutex {
|
||||
return l
|
||||
}
|
||||
|
||||
// ErrRunBusy 表示该 run 的文件锁正被占用,且在 ctx 截止前没有拿到。
|
||||
// 非 unix 平台没有 flock,只保留进程内互斥;跨进程互斥需要 unix。
|
||||
var ErrRunBusy = errors.New("run is busy")
|
||||
|
||||
// acquire 进入 run 临界区:先校验 id,再拿进程内锁,然后按 ctx 轮询文件锁。
|
||||
func (e *SquadEngine) acquire(ctx context.Context, runID string) (func(), error) {
|
||||
if !ValidID(runID) {
|
||||
return nil, invalidID(runID)
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
mu := e.runLock(runID)
|
||||
mu.Lock()
|
||||
release, err := lockRunFile(ctx, filepath.Join(e.opts.DataDir, "runs", runID))
|
||||
if err != nil {
|
||||
mu.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
return func() {
|
||||
release()
|
||||
mu.Unlock()
|
||||
}, nil
|
||||
}
|
||||
|
||||
// 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()
|
||||
release, err := e.acquire(ctx, runID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer release()
|
||||
|
||||
meta := RunMetadata{
|
||||
ID: runID, Goal: goal, Status: string(RunPlanning),
|
||||
@@ -178,6 +258,42 @@ func (e *SquadEngine) Run(ctx context.Context, goal Goal, runOpts RunOptions) (*
|
||||
meta.Metadata["max_leader_turns"] = fmt.Sprintf("%d", runOpts.MaxLeaderTurns)
|
||||
}
|
||||
}
|
||||
if runOpts.CheckpointMode != "" {
|
||||
meta.Metadata["checkpoint_mode"] = runOpts.CheckpointMode
|
||||
}
|
||||
if runOpts.Topology != "" {
|
||||
meta.Metadata["topology"] = runOpts.Topology
|
||||
// leader 拓扑映射到委派循环须发生在计划确认之前(确认门按
|
||||
// mode=leader 豁免);后置映射会让 leader 运行误触计划确认。
|
||||
if runOpts.Topology == TopologyLeader && meta.Metadata["mode"] == "" {
|
||||
meta.Metadata["mode"] = "leader"
|
||||
}
|
||||
}
|
||||
if !runOpts.Deadline.IsZero() {
|
||||
// RFC3339Nano:秒级 RFC3339 会截断亚秒精度,短截止会被立刻判超期。
|
||||
meta.Metadata["deadline"] = runOpts.Deadline.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
if runOpts.MaxReplans > 0 {
|
||||
meta.Metadata["max_replans"] = fmt.Sprintf("%d", runOpts.MaxReplans)
|
||||
}
|
||||
if runOpts.GroupChatPolicy != "" {
|
||||
meta.Metadata["group_chat_policy"] = runOpts.GroupChatPolicy
|
||||
}
|
||||
if runOpts.GroupChatModerator != "" {
|
||||
meta.Metadata["group_chat_moderator"] = runOpts.GroupChatModerator
|
||||
}
|
||||
if runOpts.GroupChatMaxTurns > 0 {
|
||||
meta.Metadata["group_chat_max_turns"] = fmt.Sprintf("%d", runOpts.GroupChatMaxTurns)
|
||||
}
|
||||
if runOpts.ReviewVerifyRole != "" {
|
||||
meta.Metadata["review_verify_role"] = runOpts.ReviewVerifyRole
|
||||
}
|
||||
if runOpts.ReviewTarget != "" {
|
||||
meta.Metadata["review_target"] = runOpts.ReviewTarget
|
||||
}
|
||||
if runOpts.TaskBoardLeaseSeconds > 0 {
|
||||
meta.Metadata["task_board_lease_seconds"] = fmt.Sprintf("%d", runOpts.TaskBoardLeaseSeconds)
|
||||
}
|
||||
e.deps.Sink.Emit(Event{Type: EvRunStarted, RunID: runID, Text: goal.Text, At: time.Now()})
|
||||
|
||||
// 意图闸门(可选,入口一次裁决;显式 TaskKey 优先,不重复过闸)。
|
||||
@@ -211,12 +327,12 @@ func (e *SquadEngine) Run(ctx context.Context, goal Goal, runOpts RunOptions) (*
|
||||
}
|
||||
}
|
||||
|
||||
st := &runState{Meta: meta, Approved: map[string]bool{}, RepairBudgetRemaining: e.opts.RepairBudget}
|
||||
st := &RunState{Meta: meta, Approved: map[string]bool{}, RepairBudgetRemaining: e.opts.RepairBudget, store: e.deps.storeOr()}
|
||||
return e.planAndExecute(ctx, st, runOpts)
|
||||
}
|
||||
|
||||
// planAndExecute 规划 → 挂起或执行;st 可为已有运行状态(澄清续跑复用)。
|
||||
func (e *SquadEngine) planAndExecute(ctx context.Context, st *runState, runOpts RunOptions) (*RunResult, error) {
|
||||
func (e *SquadEngine) planAndExecute(ctx context.Context, st *RunState, runOpts RunOptions) (*RunResult, error) {
|
||||
drv := &flowDriver{opts: e.deps}
|
||||
meta := &st.Meta
|
||||
|
||||
@@ -238,6 +354,10 @@ func (e *SquadEngine) planAndExecute(ctx context.Context, st *runState, runOpts
|
||||
planOut = &PlanOutput{Plan: plan}
|
||||
} else {
|
||||
bpMatch, _ := e.opts.Blueprints.Match(meta.Goal.Text, runOpts.TaskKey)
|
||||
// 拓扑解析(Q2):RunOptions 优先,Blueprint 兜底。
|
||||
if meta.Metadata["topology"] == "" && bpMatch.Topology != "" {
|
||||
meta.Metadata["topology"] = bpMatch.Topology
|
||||
}
|
||||
planOut, err = e.opts.Planner.Plan(ctx, PlanInput{
|
||||
Goal: meta.Goal, Blueprint: bpPtr(bpMatch), Registry: e.opts.Registry, RunID: meta.ID,
|
||||
IntentCatalog: e.opts.IntentCatalog,
|
||||
@@ -271,7 +391,7 @@ func (e *SquadEngine) planAndExecute(ctx context.Context, st *runState, runOpts
|
||||
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}
|
||||
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
|
||||
@@ -309,8 +429,9 @@ func (e *SquadEngine) planAndExecute(ctx context.Context, st *runState, runOpts
|
||||
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",
|
||||
Reason: "plan",
|
||||
}
|
||||
gate := &hitlGate{Request: *req, Plan: planOut.Plan, PrevStatus: RunPlanning}
|
||||
gate := &HitlGate{Request: *req, Plan: planOut.Plan, PrevStatus: RunPlanning}
|
||||
if err := st.saveGate(gate); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -323,6 +444,20 @@ func (e *SquadEngine) planAndExecute(ctx context.Context, st *runState, runOpts
|
||||
if err := e.materializeWorkspace(meta, st, planOut.Plan); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 拓扑校验与分发(Q2):未注册名 / primitive 单跑 / driver 未点亮 fail-closed;
|
||||
// leader 拓扑映射到既有 Mode 委派循环(不新造执行面)。
|
||||
if topo := meta.Metadata["topology"]; topo != "" {
|
||||
kind, terr := checkTopology(topo)
|
||||
if terr != nil {
|
||||
return e.failNew(meta, terr)
|
||||
}
|
||||
if kind == TopologyMode && topo == TopologyLeader {
|
||||
if meta.Metadata["mode"] == "" {
|
||||
meta.Metadata["mode"] = "leader"
|
||||
}
|
||||
}
|
||||
e.deps.Sink.Emit(Event{Type: EvPhase, RunID: meta.ID, Text: "拓扑 " + topo, At: time.Now()})
|
||||
}
|
||||
// leader-delegation 模式:忽略规划步骤,进 leader 委派循环。
|
||||
if meta.Metadata["mode"] == "leader" {
|
||||
e.initLeaderState(st)
|
||||
@@ -335,6 +470,15 @@ func (e *SquadEngine) planAndExecute(ctx context.Context, st *runState, runOpts
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
// driver 类拓扑(Q2 WP3+):按名分发专用驱动(review/group_chat;
|
||||
// task_board 属 WP4,未点亮前 checkTopology 已 fail-closed)。
|
||||
if out, handled, derr := e.dispatchTopologyDriver(ctx, st); handled {
|
||||
if derr != nil {
|
||||
return nil, derr
|
||||
}
|
||||
e.finalizeEvent(out)
|
||||
return out, nil
|
||||
}
|
||||
out, _, err := drv.advance(ctx, st)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -344,7 +488,7 @@ func (e *SquadEngine) planAndExecute(ctx context.Context, st *runState, runOpts
|
||||
}
|
||||
|
||||
// initLeaderState 按运行选项初始化 leader 循环状态。
|
||||
func (e *SquadEngine) initLeaderState(st *runState) {
|
||||
func (e *SquadEngine) initLeaderState(st *RunState) {
|
||||
leaderRole := st.Meta.Metadata["leader_role"]
|
||||
if leaderRole == "" {
|
||||
if _, ok := findAgent(st.Plan.Squad.Agents, "leader"); ok {
|
||||
@@ -357,7 +501,13 @@ func (e *SquadEngine) initLeaderState(st *runState) {
|
||||
maxTurns = n
|
||||
}
|
||||
}
|
||||
st.Leader = &LeaderState{LeaderRole: leaderRole, MaxTurns: maxTurns}
|
||||
maxReplans := DefaultMaxReplans
|
||||
if v := st.Meta.Metadata["max_replans"]; v != "" {
|
||||
if n := parseIntDefault(v, DefaultMaxReplans); n > 0 {
|
||||
maxReplans = n
|
||||
}
|
||||
}
|
||||
st.Leader = &LeaderState{LeaderRole: leaderRole, MaxTurns: maxTurns, MaxReplans: maxReplans}
|
||||
st.saveLeader()
|
||||
}
|
||||
|
||||
@@ -373,12 +523,12 @@ func parseIntDefault(s string, def int) int {
|
||||
}
|
||||
|
||||
// isLeaderRun 该运行是否处于 leader-delegation 模式。
|
||||
func (e *SquadEngine) isLeaderRun(st *runState) bool {
|
||||
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) {
|
||||
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 {
|
||||
@@ -389,6 +539,15 @@ func (e *SquadEngine) resumeAfterHold(ctx context.Context, st *runState) (*RunRe
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
// driver 类拓扑续跑:驱动自身幂等(review 已完成 finder 不重跑;
|
||||
// group_chat 按已落盘轮次续转)。
|
||||
if out, handled, err := e.dispatchTopologyDriver(ctx, st); handled {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e.finalizeEvent(out)
|
||||
return out, nil
|
||||
}
|
||||
drv := &flowDriver{opts: e.deps}
|
||||
out, _, err := drv.advance(ctx, st)
|
||||
if err != nil {
|
||||
@@ -398,17 +557,43 @@ func (e *SquadEngine) resumeAfterHold(ctx context.Context, st *runState) (*RunRe
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// dispatchTopologyDriver 按运行拓扑分发 driver 类拓扑。
|
||||
// handled=false 表示非 driver 拓扑(走既有图/模式路径)。
|
||||
func (e *SquadEngine) dispatchTopologyDriver(ctx context.Context, st *RunState) (*RunResult, bool, error) {
|
||||
topo := st.Meta.Metadata["topology"]
|
||||
if topo == "" {
|
||||
return nil, false, nil
|
||||
}
|
||||
spec, ok := LookupTopology(topo)
|
||||
if !ok || spec.Kind != TopologyDriver {
|
||||
return nil, false, nil
|
||||
}
|
||||
switch topo {
|
||||
case TopologyReview:
|
||||
out, err := e.runReviewDriver(ctx, st)
|
||||
return out, true, err
|
||||
case TopologyGroupChat:
|
||||
out, err := e.runGroupChatDriver(ctx, st)
|
||||
return out, true, err
|
||||
case TopologyTaskBoard:
|
||||
out, err := e.runTaskBoardDriver(ctx, st)
|
||||
return out, true, err
|
||||
default:
|
||||
return e.driverFail(st, fmt.Sprintf("topology: %q 驱动未实现(fail-closed)", topo)), true, nil
|
||||
}
|
||||
}
|
||||
|
||||
// materializeWorkspace 落盘工作区与子工作区(已存在则只更新计划并重建待执行子目录)。
|
||||
func (e *SquadEngine) materializeWorkspace(meta *RunMetadata, st *runState, plan *SquadPlan) error {
|
||||
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)
|
||||
_, err := e.ws.PrepareWorkspace(&st.Meta, plan)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
st.Template = plan.Template
|
||||
bb, _ := loadBlackboard(wsDir)
|
||||
st.BB = *bb
|
||||
// 新运行的黑板在内存初始化;持久化由后续 st.save() 经 Store 端口完成。
|
||||
st.BB = BlackboardState{Fields: map[string]string{}}
|
||||
} else {
|
||||
// 复用既有工作区:磁盘黑板为权威(澄清续跑/计划更新场景)。
|
||||
if bb, err := loadBlackboard(st.Meta.WorkDir); err == nil {
|
||||
@@ -450,12 +635,17 @@ func (e *SquadEngine) failNew(meta *RunMetadata, err error) (*RunResult, error)
|
||||
|
||||
// Respond 应用人工决策并续跑。按请求 ID 幂等:重复回应同一请求返回当前状态。
|
||||
func (e *SquadEngine) Respond(ctx context.Context, runID string, decision RespondDecision) (*RunResult, error) {
|
||||
lock := e.runLock(runID)
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
release, err := e.acquire(ctx, runID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer release()
|
||||
return e.respondLocked(ctx, runID, decision)
|
||||
}
|
||||
|
||||
func (e *SquadEngine) respondLocked(ctx context.Context, runID string, decision RespondDecision) (*RunResult, error) {
|
||||
wsDir := filepath.Join(e.opts.DataDir, "runs", runID)
|
||||
st, err := loadRunState(wsDir)
|
||||
st, err := e.loadRun(wsDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load run %s: %w", runID, err)
|
||||
}
|
||||
@@ -475,18 +665,85 @@ func (e *SquadEngine) Respond(ctx context.Context, runID string, decision Respon
|
||||
}
|
||||
|
||||
req := &gate.Request
|
||||
// 非 human actor 不得决议 choice 门(Q2 验收三的第二道防线:邮箱/代理
|
||||
// 消息即便伪造 Respond 调用也清不掉 permission/confirmation/quality 门)。
|
||||
if decision.Actor != "" && decision.Actor != ActorHuman && choiceHoldKind(req.Kind) &&
|
||||
strings.TrimSpace(decision.Choice) != "" {
|
||||
e.deps.Sink.Emit(Event{Type: EvPhase, RunID: runID,
|
||||
Text: fmt.Sprintf("拒绝非人工 actor(%s) 决议 %s 门(请求 %s)", decision.Actor, req.Kind, req.ID), At: time.Now()})
|
||||
return e.noteAndKeepHold(st, gate, RespondDecision{
|
||||
RequestID: decision.RequestID,
|
||||
Answer: fmt.Sprintf("[blocked] 非人工 actor(%s) 尝试决议本门,已拒绝;等待人工裁决。", decision.Actor),
|
||||
})
|
||||
}
|
||||
if strings.TrimSpace(decision.Choice) == "" && strings.TrimSpace(decision.Answer) != "" && choiceHoldKind(req.Kind) {
|
||||
return e.noteAndKeepHold(st, gate, decision)
|
||||
}
|
||||
req.Status = "answered"
|
||||
now := time.Now()
|
||||
req.AnsweredAt = &now
|
||||
if err := st.saveGate(gate); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e.deps.Sink.Emit(Event{Type: EvHumanResponded, RunID: runID, Text: firstNonEmpty(decision.Choice, decision.Answer),
|
||||
Data: marshalJSON(decision), At: time.Now()})
|
||||
_ = st.saveGate(nil)
|
||||
|
||||
res, err := e.applyDecision(ctx, st, gate, decision)
|
||||
if err != nil {
|
||||
req.Status = "pending"
|
||||
req.AnsweredAt = nil
|
||||
_ = st.saveGate(gate)
|
||||
return nil, err
|
||||
}
|
||||
if err := clearSameGate(st, req.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func choiceHoldKind(kind HumanRequestKind) bool {
|
||||
switch kind {
|
||||
case HitlConfirmation, HitlPermission, HitlQualityReview:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (e *SquadEngine) noteAndKeepHold(st *RunState, gate *HitlGate, decision RespondDecision) (*RunResult, error) {
|
||||
if err := st.addSteering(decision.Answer); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gate.Request.Status = "pending"
|
||||
gate.Request.AnsweredAt = nil
|
||||
if err := st.saveGate(gate); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return heldResult(st, gate), nil
|
||||
}
|
||||
|
||||
func clearSameGate(st *RunState, requestID string) error {
|
||||
current, err := loadGate(st.Meta.WorkDir)
|
||||
if err != nil || current == nil || current.Request.ID != requestID {
|
||||
return err
|
||||
}
|
||||
return st.saveGate(nil)
|
||||
}
|
||||
|
||||
func (e *SquadEngine) applyDecision(ctx context.Context, st *RunState, gate *HitlGate, decision RespondDecision) (*RunResult, error) {
|
||||
req := &gate.Request
|
||||
now := time.Now()
|
||||
drv := &flowDriver{opts: e.deps}
|
||||
switch req.Kind {
|
||||
case HitlClarification:
|
||||
return e.respondClarification(ctx, st, gate, decision)
|
||||
case HitlConfirmation:
|
||||
if req.Reason == "interrupted" {
|
||||
return e.respondInterrupted(ctx, st, req, decision)
|
||||
}
|
||||
if req.StepID != "" && gate.Plan == nil {
|
||||
return e.respondStepApproval(ctx, st, req, decision)
|
||||
}
|
||||
switch strings.ToLower(decision.Choice) {
|
||||
case "approve":
|
||||
if gate.Plan != nil {
|
||||
@@ -604,8 +861,17 @@ func (e *SquadEngine) Respond(ctx context.Context, runID string, decision Respon
|
||||
case "approve":
|
||||
return e.manualPass(st, decision)
|
||||
case "retry":
|
||||
// 追加一次修复预算并重跑交付步骤。
|
||||
// 追加一次修复预算并重跑交付步骤。累计追加有上限(Q26),
|
||||
// 上限内才加——无上限的人工 retry 会把运行变成永动机。
|
||||
term := e.terminationOf(st)
|
||||
if st.RepairBudgetGranted >= term.MaxRepairGranted {
|
||||
drv.emit(st, Event{Type: EvPhase, Text: fmt.Sprintf("人工修复追加已达上限 %d,不再追加(Q26)", term.MaxRepairGranted)})
|
||||
return &RunResult{RunID: st.Meta.ID, Status: RunFailed, Plan: planPtr(st),
|
||||
Error: fmt.Sprintf("repair budget grants exhausted (%d/%d)", st.RepairBudgetGranted, term.MaxRepairGranted),
|
||||
WorkDir: st.Meta.WorkDir}, nil
|
||||
}
|
||||
st.RepairBudgetRemaining++
|
||||
st.RepairBudgetGranted++
|
||||
st.Meta.Status = string(RunExecuting)
|
||||
_ = st.save()
|
||||
deliverStepID := lastCompletedStepID(st)
|
||||
@@ -622,8 +888,8 @@ func (e *SquadEngine) Respond(ctx context.Context, runID string, decision Respon
|
||||
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),
|
||||
e.deps.Sink.Emit(Event{Type: EvRunFailed, RunID: st.Meta.ID, Text: "人工裁决质检失败", At: time.Now()})
|
||||
return &RunResult{RunID: st.Meta.ID, Status: RunFailed, Plan: planPtr(st),
|
||||
Error: "quality review rejected by human", WorkDir: st.Meta.WorkDir}, nil
|
||||
}
|
||||
default:
|
||||
@@ -632,7 +898,7 @@ func (e *SquadEngine) Respond(ctx context.Context, runID string, decision Respon
|
||||
}
|
||||
|
||||
// respondClarification 澄清回应:答案并入目标后重新规划。
|
||||
func (e *SquadEngine) respondClarification(ctx context.Context, st *runState, gate *hitlGate, decision RespondDecision) (*RunResult, error) {
|
||||
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
|
||||
@@ -667,7 +933,7 @@ func (e *SquadEngine) respondClarification(ctx context.Context, st *runState, ga
|
||||
}
|
||||
|
||||
// bootstrapMinimalSquad 注册通用最小编制(organizer/specialist/writer/leader)。
|
||||
func (e *SquadEngine) bootstrapMinimalSquad(st *runState) {
|
||||
func (e *SquadEngine) bootstrapMinimalSquad(st *RunState) {
|
||||
backend := st.Meta.Backend
|
||||
if backend == "" {
|
||||
backend = e.opts.DefaultBackend
|
||||
@@ -687,7 +953,7 @@ func (e *SquadEngine) bootstrapMinimalSquad(st *runState) {
|
||||
}
|
||||
|
||||
// manualPass 人工放行质检:标记 ManualPass 并收口。
|
||||
func (e *SquadEngine) manualPass(st *runState, decision RespondDecision) (*RunResult, error) {
|
||||
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,
|
||||
@@ -716,7 +982,7 @@ func (e *SquadEngine) manualPass(st *runState, decision RespondDecision) (*RunRe
|
||||
Summary: summarize(st, *report, status) + "(质检人工放行)"}, nil
|
||||
}
|
||||
|
||||
func (e *SquadEngine) cancelRun(st *runState, reason string) *RunResult {
|
||||
func (e *SquadEngine) cancelRun(st *RunState, reason string) *RunResult {
|
||||
now := time.Now()
|
||||
st.Meta.Status = string(RunCancelled)
|
||||
st.Meta.CompletedAt = &now
|
||||
@@ -730,18 +996,143 @@ func (e *SquadEngine) cancelRun(st *runState, reason string) *RunResult {
|
||||
}
|
||||
|
||||
// failStepResume 权限拒绝后的失败步骤处理(关键失败/非关键降级)。
|
||||
func (d *flowDriver) failStepResume(ctx context.Context, st *runState, idx int) (*RunResult, *hitlGate, error) {
|
||||
func (d *flowDriver) failStepResume(ctx context.Context, st *RunState, idx int) (*RunResult, *HitlGate, error) {
|
||||
return d.failStep(ctx, st, idx)
|
||||
}
|
||||
|
||||
func (e *SquadEngine) respondStepApproval(ctx context.Context, st *RunState, req *HumanRequest, decision RespondDecision) (*RunResult, error) {
|
||||
switch strings.ToLower(decision.Choice) {
|
||||
case "approve":
|
||||
st.Approved["step:"+req.StepID] = true
|
||||
case "skip":
|
||||
if !markStep(st, req.StepID, "skipped", "") {
|
||||
return nil, fmt.Errorf("step %s not found", req.StepID)
|
||||
}
|
||||
default:
|
||||
return e.cancelRun(st, "人工中止(步骤审批)"), nil
|
||||
}
|
||||
st.Meta.Status = string(RunExecuting)
|
||||
if err := st.save(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return e.resumeAfterHold(ctx, st)
|
||||
}
|
||||
|
||||
func (e *SquadEngine) respondInterrupted(ctx context.Context, st *RunState, req *HumanRequest, decision RespondDecision) (*RunResult, error) {
|
||||
switch strings.ToLower(decision.Choice) {
|
||||
case "retry":
|
||||
if !markStep(st, req.StepID, "pending", "") {
|
||||
return nil, fmt.Errorf("step %s not found", req.StepID)
|
||||
}
|
||||
case "skip":
|
||||
if !markStep(st, req.StepID, "skipped", "") {
|
||||
return nil, fmt.Errorf("step %s not found", req.StepID)
|
||||
}
|
||||
default:
|
||||
return e.cancelRun(st, "人工中止(中断恢复)"), nil
|
||||
}
|
||||
st.Meta.Status = string(RunExecuting)
|
||||
if err := st.save(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return e.resumeAfterHold(ctx, st)
|
||||
}
|
||||
|
||||
func markStep(st *RunState, id, status, errText string) bool {
|
||||
for i := range st.Plan.Workflow.Steps {
|
||||
if st.Plan.Workflow.Steps[i].ID != id {
|
||||
continue
|
||||
}
|
||||
st.Plan.Workflow.Steps[i].Status = status
|
||||
st.Plan.Workflow.Steps[i].Error = errText
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// recoverInterrupted 处理崩溃时停在 running 的步骤。
|
||||
// interactive 挂起确认,不自动再执行;auto 允许一次重试;disabled 直接失败。
|
||||
func (e *SquadEngine) recoverInterrupted(ctx context.Context, st *RunState) (*RunResult, error) {
|
||||
for i := range st.Plan.Workflow.Steps {
|
||||
step := st.Plan.Workflow.Steps[i]
|
||||
if step.Status != "running" {
|
||||
continue
|
||||
}
|
||||
switch st.policy().mode() {
|
||||
case HitlDisabled:
|
||||
st.Plan.Workflow.Steps[i].Status = "failed"
|
||||
st.Plan.Workflow.Steps[i].Error = "interrupted"
|
||||
if err := st.save(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
drv := &flowDriver{opts: e.deps}
|
||||
out, _, err := drv.failStep(ctx, st, i)
|
||||
return out, err
|
||||
case HitlAuto:
|
||||
if step.Attempts >= 2 {
|
||||
st.Plan.Workflow.Steps[i].Status = "failed"
|
||||
st.Plan.Workflow.Steps[i].Error = "interrupted twice"
|
||||
if err := st.save(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
drv := &flowDriver{opts: e.deps}
|
||||
out, _, err := drv.failStep(ctx, st, i)
|
||||
return out, err
|
||||
}
|
||||
// Q1 WP3 产物校验:步骤在 running 崩溃,但其交付物已全部落盘——
|
||||
// 副作用实际发生过,直接判定完成而不是重新执行(副作用次数=1)。
|
||||
subDir := filepath.Join(st.Meta.WorkDir, filepath.FromSlash(orDefault(step.SubWorkspace, fmt.Sprintf("tasks/%02d_%s", i+1, step.Role))))
|
||||
targets := step.Targets
|
||||
if len(targets) == 0 {
|
||||
targets = []string{"output.md"}
|
||||
}
|
||||
if missingTargets(subDir, targets) == nil {
|
||||
step.Status = "completed"
|
||||
step.Error = ""
|
||||
st.Plan.Workflow.Steps[i] = step
|
||||
st.mu.Lock()
|
||||
_ = mergeStepArtifacts(&st.BB, subDir)
|
||||
err := st.save()
|
||||
st.mu.Unlock()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
drv := &flowDriver{opts: e.deps}
|
||||
drv.emit(st, Event{Type: EvStepCompleted, StepID: step.ID, Role: step.Role,
|
||||
Text: fmt.Sprintf("中断恢复:产物齐全,按 attempt %s 判定完成", step.AttemptID)})
|
||||
continue
|
||||
}
|
||||
st.Plan.Workflow.Steps[i].Status = "pending"
|
||||
if err := st.save(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, nil
|
||||
default:
|
||||
drv := &flowDriver{opts: e.deps}
|
||||
out, _, err := drv.hold(st, HitlConfirmation, step.ID, step.Role,
|
||||
fmt.Sprintf("步骤「%s」在执行中中断。如何继续?", step.Title),
|
||||
[]HumanOption{
|
||||
{Value: "retry", Label: "重试"},
|
||||
{Value: "skip", Label: "跳过该步骤"},
|
||||
{Value: "abort", Label: "中止运行"},
|
||||
},
|
||||
"interrupted", "interrupted", nil)
|
||||
return out, err
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Resume 恢复运行:有挂起则返回挂起状态;未完成则继续推进(重启恢复入口)。
|
||||
func (e *SquadEngine) Resume(ctx context.Context, runID string) (*RunResult, error) {
|
||||
lock := e.runLock(runID)
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
release, err := e.acquire(ctx, runID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer release()
|
||||
|
||||
wsDir := filepath.Join(e.opts.DataDir, "runs", runID)
|
||||
st, err := loadRunState(wsDir)
|
||||
st, err := e.loadRun(wsDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load run %s: %w", runID, err)
|
||||
}
|
||||
@@ -752,13 +1143,24 @@ func (e *SquadEngine) Resume(ctx context.Context, runID string) (*RunResult, err
|
||||
if RunStatus(st.Meta.Status).Terminal() {
|
||||
return e.Inspect(runID)
|
||||
}
|
||||
// 图连续性校验(Q1 WP4/WP1 裁定):崩溃期间的检查点图指纹与当前 Steps
|
||||
// 重编译不一致 = 图形态被改过(动态调整或外力),拒绝恢复并提示重新规划。
|
||||
if err := validateGraphContinuity(st); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out, err := e.recoverInterrupted(ctx, st); out != nil || err != nil {
|
||||
return out, err
|
||||
}
|
||||
return e.resumeAfterHold(ctx, st)
|
||||
}
|
||||
|
||||
// Inspect 查看运行当前状态(含挂起请求与黑板)。
|
||||
func (e *SquadEngine) Inspect(runID string) (*RunResult, error) {
|
||||
if !ValidID(runID) {
|
||||
return nil, invalidID(runID)
|
||||
}
|
||||
wsDir := filepath.Join(e.opts.DataDir, "runs", runID)
|
||||
st, err := loadRunState(wsDir)
|
||||
st, err := e.loadRun(wsDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load run %s: %w", runID, err)
|
||||
}
|
||||
@@ -783,6 +1185,9 @@ func (e *SquadEngine) Inspect(runID string) (*RunResult, error) {
|
||||
|
||||
// Blackboard 查看运行黑板。
|
||||
func (e *SquadEngine) Blackboard(runID string) (*BlackboardState, error) {
|
||||
if !ValidID(runID) {
|
||||
return nil, invalidID(runID)
|
||||
}
|
||||
wsDir := filepath.Join(e.opts.DataDir, "runs", runID)
|
||||
return loadBlackboard(wsDir)
|
||||
}
|
||||
@@ -802,7 +1207,7 @@ func (e *SquadEngine) ListRuns() ([]RunMetadata, error) {
|
||||
if !ent.IsDir() {
|
||||
continue
|
||||
}
|
||||
st, err := loadRunState(filepath.Join(runsDir, ent.Name()))
|
||||
st, err := e.loadRun(filepath.Join(runsDir, ent.Name()))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
@@ -814,6 +1219,9 @@ func (e *SquadEngine) ListRuns() ([]RunMetadata, error) {
|
||||
|
||||
// Pending 返回运行当前挂起请求(无则 nil)。
|
||||
func (e *SquadEngine) Pending(runID string) (*HumanRequest, error) {
|
||||
if !ValidID(runID) {
|
||||
return nil, invalidID(runID)
|
||||
}
|
||||
gate, err := loadGate(filepath.Join(e.opts.DataDir, "runs", runID))
|
||||
if err != nil || gate == nil {
|
||||
return nil, err
|
||||
@@ -827,11 +1235,13 @@ 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()
|
||||
release, err := e.acquire(context.Background(), runID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer release()
|
||||
|
||||
st, err := loadRunState(filepath.Join(e.opts.DataDir, "runs", runID))
|
||||
st, err := e.loadRun(filepath.Join(e.opts.DataDir, "runs", runID))
|
||||
if err != nil {
|
||||
return fmt.Errorf("load run %s: %w", runID, err)
|
||||
}
|
||||
@@ -849,11 +1259,13 @@ 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()
|
||||
release, err := e.acquire(context.Background(), runID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer release()
|
||||
|
||||
st, err := loadRunState(filepath.Join(e.opts.DataDir, "runs", runID))
|
||||
st, err := e.loadRun(filepath.Join(e.opts.DataDir, "runs", runID))
|
||||
if err != nil {
|
||||
return fmt.Errorf("load run %s: %w", runID, err)
|
||||
}
|
||||
@@ -883,35 +1295,77 @@ func (e *SquadEngine) SweepWakeups(ctx context.Context) int {
|
||||
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 {
|
||||
release, err := e.acquire(ctx, meta.ID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
gate.Request.WakeupFires++
|
||||
if gate.Request.WakeupFires >= MaxWakeupFires {
|
||||
// 护栏耗尽:自动中止,不无限自动续跑。
|
||||
_, _ = e.Respond(ctx, meta.ID, RespondDecision{RequestID: gate.Request.ID, Choice: "abort",
|
||||
Answer: "自动唤醒次数达上限,自动中止"})
|
||||
if e.fireWakeup(ctx, meta.ID) {
|
||||
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()})
|
||||
}
|
||||
release()
|
||||
}
|
||||
return woken
|
||||
}
|
||||
|
||||
// SweepExpired 把超过 HITL 时限的挂起请求合成 fail 或 cancel 决策。
|
||||
func (e *SquadEngine) SweepExpired(ctx context.Context, now time.Time) int {
|
||||
runs, err := e.ListRuns()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
n := 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" {
|
||||
continue
|
||||
}
|
||||
decision, due := expiredDecision(gate.Request, e.deps.HitlPolicy, now)
|
||||
if !due {
|
||||
continue
|
||||
}
|
||||
if _, err := e.Respond(ctx, meta.ID, decision); err == nil {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// fireWakeup 在已持有 run 锁时处理一条挂起。只自动放行 gap 与 escalation。
|
||||
func (e *SquadEngine) fireWakeup(ctx context.Context, runID string) bool {
|
||||
gate, err := loadGate(filepath.Join(e.opts.DataDir, "runs", runID))
|
||||
if err != nil || gate == nil || gate.Request.Status != "pending" || gate.Request.WaitCondition == nil {
|
||||
return false
|
||||
}
|
||||
met, fact := e.evaluateWaitCondition(runID, gate.Request.WaitCondition)
|
||||
if !met {
|
||||
return false
|
||||
}
|
||||
gate.Request.WakeupFires++
|
||||
if err := writeJSON(filepath.Join(e.opts.DataDir, "runs", runID, "pending.json"), gate); err != nil {
|
||||
return false
|
||||
}
|
||||
if gate.Request.WakeupFires >= e.terminationOf(nil).MaxWakeupFires {
|
||||
_, err := e.respondLocked(ctx, runID, RespondDecision{RequestID: gate.Request.ID, Choice: "abort",
|
||||
Answer: "自动唤醒次数达上限,自动中止"})
|
||||
return err == nil
|
||||
}
|
||||
switch gate.Request.Kind {
|
||||
case HitlGap, HitlEscalation:
|
||||
decision := RespondDecision{RequestID: gate.Request.ID, Choice: "resolved", Answer: "[自动唤醒] " + fact}
|
||||
if _, err := e.respondLocked(ctx, runID, decision); err != nil {
|
||||
return false
|
||||
}
|
||||
e.deps.Sink.Emit(Event{Type: "wakeup_fired", RunID: runID, Text: fact, At: time.Now()})
|
||||
return true
|
||||
default:
|
||||
e.deps.Sink.Emit(Event{Type: "wakeup_held", RunID: runID, Text: fact, At: time.Now()})
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// evaluateWaitCondition 评估等待条件;返回是否满足与事实描述。
|
||||
func (e *SquadEngine) evaluateWaitCondition(runID string, cond *WaitCondition) (bool, string) {
|
||||
switch cond.Type {
|
||||
@@ -998,7 +1452,7 @@ func appendAnswer(d RespondDecision) string {
|
||||
return "(" + d.Answer + ")"
|
||||
}
|
||||
|
||||
func lastCompletedStepID(st *runState) string {
|
||||
func lastCompletedStepID(st *RunState) string {
|
||||
id := ""
|
||||
for _, s := range st.Plan.Workflow.Steps {
|
||||
if s.Status == "completed" {
|
||||
|
||||
@@ -8,8 +8,6 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"agent-native/exec"
|
||||
)
|
||||
|
||||
// scriptBackend 测试用脚本后端:按子目录名回放产出。
|
||||
@@ -17,7 +15,7 @@ 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) {
|
||||
func (b *scriptBackend) Execute(ctx context.Context, req ExecRequest) (*ExecResult, error) {
|
||||
key := filepath.Base(filepath.Clean(req.WorkDir))
|
||||
files := b.script[key]
|
||||
patches := map[string][]byte{}
|
||||
@@ -33,7 +31,7 @@ func (b *scriptBackend) Execute(ctx context.Context, req exec.Request) (*exec.Re
|
||||
}
|
||||
patches[t] = []byte(content)
|
||||
}
|
||||
return &exec.Result{Success: true, Patches: patches}, nil
|
||||
return &ExecResult{Success: true, Patches: patches}, nil
|
||||
}
|
||||
|
||||
const goodReport = "# 测试交付物\n\n## 概述\n\n这是由脚本后端生成的合格交付物,长度超过五十个字符以满足最小质检要求。\n\n## 内容\n\n正文内容。\n"
|
||||
|
||||
@@ -6,8 +6,6 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"agent-native/exec"
|
||||
)
|
||||
|
||||
// scriptedEvaluator 第一步后建议:追加一个补漏步骤 + 跳过冗余步骤。
|
||||
@@ -146,7 +144,7 @@ type mutableScriptBackend struct {
|
||||
|
||||
func (b *mutableScriptBackend) Name() string { return "mut" }
|
||||
|
||||
func (b *mutableScriptBackend) Execute(ctx context.Context, req exec.Request) (*exec.Result, error) {
|
||||
func (b *mutableScriptBackend) Execute(ctx context.Context, req ExecRequest) (*ExecResult, error) {
|
||||
b.calls++
|
||||
if b.mutate != nil && b.calls == b.mutateAt {
|
||||
b.mutate(b.script)
|
||||
@@ -164,7 +162,7 @@ func (b *mutableScriptBackend) Execute(ctx context.Context, req exec.Request) (*
|
||||
_ = os.WriteFile(out, []byte(content), 0o644)
|
||||
patches[tg] = []byte(content)
|
||||
}
|
||||
return &exec.Result{Success: true, Patches: patches}, nil
|
||||
return &ExecResult{Success: true, Patches: patches}, nil
|
||||
}
|
||||
|
||||
// TestQualityRepairAutoRerun 验证:质检失败 → 自动重跑交付步骤 → 通过。
|
||||
|
||||
+253
@@ -0,0 +1,253 @@
|
||||
package squad
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// graph 是一次执行图:由权威 Steps 现场编译(Steps 仍是唯一真相源,
|
||||
// 动态调整改 Steps 后下一轮自然生效)。只携带调度所需的派生信息:
|
||||
// 依赖、写集与可执行分层。
|
||||
type graph struct {
|
||||
needs map[string][]string // step id → 依赖 id(含相邻 Parallel 兼容映射产生的)
|
||||
order []string // 声明序
|
||||
writes map[string][]string // step id → 写集
|
||||
}
|
||||
|
||||
// compileGraph 编译步骤图为调度图。规则:
|
||||
// - 声明 Needs 的步骤:依赖即其 Needs(未知引用/环 fail-closed)。
|
||||
// - 未声明 Needs 的步骤:保持现状语义——相邻 Parallel=true 的步骤同层
|
||||
// (依赖 = 上一个「非本组」步骤),非 Parallel 步骤依赖前一个步骤。
|
||||
// - 混合图允许:Needs 提供剪枝,未声明处保持线性。
|
||||
func compileGraph(steps []StepSpec) (*graph, error) {
|
||||
g := &graph{
|
||||
needs: map[string][]string{},
|
||||
order: make([]string, 0, len(steps)),
|
||||
writes: map[string][]string{},
|
||||
}
|
||||
ids := map[string]bool{}
|
||||
for _, s := range steps {
|
||||
if s.ID == "" {
|
||||
return nil, fmt.Errorf("graph: 存在空 id 的步骤")
|
||||
}
|
||||
if ids[s.ID] {
|
||||
return nil, fmt.Errorf("graph: 步骤 id 重复:%s", s.ID)
|
||||
}
|
||||
ids[s.ID] = true
|
||||
}
|
||||
prevNonGroup := -1 // 上一个「非本 Parallel 组」的步骤索引
|
||||
for i, s := range steps {
|
||||
g.order = append(g.order, s.ID)
|
||||
g.writes[s.ID] = append([]string(nil), s.Writes...)
|
||||
needsDecl := len(s.Needs) > 0
|
||||
switch {
|
||||
case needsDecl:
|
||||
for _, n := range s.Needs {
|
||||
if !ids[n] {
|
||||
return nil, fmt.Errorf("graph: 步骤 %s 的 needs 引用未知步骤 %s", s.ID, n)
|
||||
}
|
||||
if n == s.ID {
|
||||
return nil, fmt.Errorf("graph: 步骤 %s 的 needs 引用自身", s.ID)
|
||||
}
|
||||
}
|
||||
g.needs[s.ID] = append([]string(nil), s.Needs...)
|
||||
prevNonGroup = i
|
||||
case s.Parallel:
|
||||
// 相邻 Parallel 组:依赖上一个组外步骤(没有则无依赖)。
|
||||
if prevNonGroup >= 0 {
|
||||
g.needs[s.ID] = []string{steps[prevNonGroup].ID}
|
||||
}
|
||||
// Parallel 步骤不推进 prevNonGroup:相邻同伴同层。
|
||||
default:
|
||||
// 非 Parallel:依赖紧邻的前一步(与旧线性扫描的执行序一致——
|
||||
// 整组 Parallel 完成后才轮到它,而不是只等组外前一步)。
|
||||
if i > 0 {
|
||||
g.needs[s.ID] = []string{steps[i-1].ID}
|
||||
}
|
||||
prevNonGroup = i
|
||||
}
|
||||
}
|
||||
if cycle := g.findCycle(); cycle != nil {
|
||||
return nil, fmt.Errorf("graph: needs 依赖成环:%s", strings.Join(cycle, " → "))
|
||||
}
|
||||
// 循环步与路由步校验(Q2):预算必须显式,引用必须存在,两者互斥
|
||||
//(同一步既循环又分支的语义未定义,fail-closed)。
|
||||
for _, s := range steps {
|
||||
if s.Loop != nil && len(s.Branches) > 0 {
|
||||
return nil, fmt.Errorf("graph: 步骤 %s 不得同时声明 loop 与 branches", s.ID)
|
||||
}
|
||||
if s.Loop != nil {
|
||||
if s.Loop.MaxIterations <= 0 {
|
||||
return nil, fmt.Errorf("graph: 步骤 %s 的 loop.max_iterations 必须 > 0", s.ID)
|
||||
}
|
||||
for _, b := range s.Loop.Body {
|
||||
if !ids[b] {
|
||||
return nil, fmt.Errorf("graph: 步骤 %s 的 loop.body 引用未知步骤 %s", s.ID, b)
|
||||
}
|
||||
if b == s.ID {
|
||||
return nil, fmt.Errorf("graph: 步骤 %s 的 loop.body 引用自身", s.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, br := range s.Branches {
|
||||
if br.When.Field == "" {
|
||||
return nil, fmt.Errorf("graph: 步骤 %s 的分支缺少 when.field", s.ID)
|
||||
}
|
||||
if br.When.Equals == "" && br.When.NotEquals == "" {
|
||||
return nil, fmt.Errorf("graph: 步骤 %s 的分支 when 需要 equals 或 not_equals", s.ID)
|
||||
}
|
||||
if len(br.Then) == 0 {
|
||||
return nil, fmt.Errorf("graph: 步骤 %s 的分支 then 为空", s.ID)
|
||||
}
|
||||
for _, t := range br.Then {
|
||||
if !ids[t] {
|
||||
return nil, fmt.Errorf("graph: 步骤 %s 的分支 then 引用未知步骤 %s", s.ID, t)
|
||||
}
|
||||
if t == s.ID {
|
||||
return nil, fmt.Errorf("graph: 步骤 %s 的分支 then 引用自身", s.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return g, nil
|
||||
}
|
||||
|
||||
// findCycle 报告一条依赖环(没有则 nil)。
|
||||
func (g *graph) findCycle() []string {
|
||||
const (
|
||||
white = iota
|
||||
gray
|
||||
black
|
||||
)
|
||||
color := map[string]int{}
|
||||
var stack []string
|
||||
var visit func(id string) []string
|
||||
visit = func(id string) []string {
|
||||
if color[id] == gray {
|
||||
return append(stack, id)
|
||||
}
|
||||
if color[id] == black {
|
||||
return nil
|
||||
}
|
||||
color[id] = gray
|
||||
stack = append(stack, id)
|
||||
for _, n := range g.needs[id] {
|
||||
if c := visit(n); c != nil {
|
||||
return c
|
||||
}
|
||||
}
|
||||
stack = stack[:len(stack)-1]
|
||||
color[id] = black
|
||||
return nil
|
||||
}
|
||||
for _, id := range g.order {
|
||||
if c := visit(id); c != nil {
|
||||
return c
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// layer 是一个 superstep 的可执行集合:seq 为有写集冲突需按声明序串行的
|
||||
// 步骤索引,par 为可并行的步骤索引。先执行 par 组(runParallel 语义),
|
||||
// 再逐个执行 seq(含动态调整),与现状「并行组先于后续顺序步骤」一致。
|
||||
type layer struct {
|
||||
par []int
|
||||
seq []int
|
||||
}
|
||||
|
||||
// stepPending 报告步骤是否仍待执行。蓝图登记的步骤不带 status(空串),
|
||||
// 旧线性扫描将其视为可执行——这里与之一致:空串等同 pending。
|
||||
func stepPending(status string) bool {
|
||||
return status == "" || status == "pending" || status == "running"
|
||||
}
|
||||
|
||||
// nextLayer 计算当前可执行层:状态为 pending/running 且全部依赖已「满足」。
|
||||
// 依赖满足 = completed/skipped/failed——failed 视为满足与现状「非关键失败
|
||||
// 降级继续」的线性扫描一致(关键失败时运行已终止,不会走到这里)。
|
||||
// 无可执行步骤时返回 (layer{}, false):可能全部完成,也可能调度卡死
|
||||
// (由调用方用 pendingCount 区分)。
|
||||
func (g *graph) nextLayer(steps []StepSpec) (layer, bool) {
|
||||
idxOf := map[string]int{}
|
||||
for i, s := range steps {
|
||||
idxOf[s.ID] = i
|
||||
}
|
||||
satisfied := func(id string) bool {
|
||||
st := steps[idxOf[id]].Status
|
||||
return st == "completed" || st == "skipped" || st == "failed"
|
||||
}
|
||||
var out layer
|
||||
for i, s := range steps {
|
||||
if !stepPending(s.Status) {
|
||||
continue
|
||||
}
|
||||
ready := true
|
||||
for _, n := range g.needs[s.ID] {
|
||||
if !satisfied(n) {
|
||||
ready = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if !ready {
|
||||
continue
|
||||
}
|
||||
if g.conflictsWithEarlier(out, i, steps) {
|
||||
out.seq = append(out.seq, i)
|
||||
} else {
|
||||
out.par = append(out.par, i)
|
||||
}
|
||||
}
|
||||
return out, len(out.par)+len(out.seq) > 0
|
||||
}
|
||||
|
||||
// conflictsWithEarlier 报告步骤 i 的写集与本层中更早的可执行步骤相交。
|
||||
func (g *graph) conflictsWithEarlier(l layer, i int, steps []StepSpec) bool {
|
||||
wi := g.writes[steps[i].ID]
|
||||
if len(wi) == 0 {
|
||||
return false
|
||||
}
|
||||
earlier := append(append([]int(nil), l.par...), l.seq...)
|
||||
for _, j := range earlier {
|
||||
for _, w := range g.writes[steps[j].ID] {
|
||||
for _, v := range wi {
|
||||
if w == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// pendingCount 统计仍未终结(pending/running/空串)的步骤数。
|
||||
func pendingCount(steps []StepSpec) int {
|
||||
n := 0
|
||||
for _, s := range steps {
|
||||
if stepPending(s.Status) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// hash 是图结构指纹:声明序、依赖与写集的稳定散列。
|
||||
// 恢复连续性校验用它比对崩溃前后的图形态(Q1 WP4;WP1 的图版本裁定落点)。
|
||||
func (g *graph) hash() string {
|
||||
h := fnv.New64a()
|
||||
for _, id := range g.order {
|
||||
h.Write([]byte(id))
|
||||
h.Write([]byte{0})
|
||||
for _, n := range g.needs[id] {
|
||||
h.Write([]byte(">"))
|
||||
h.Write([]byte(n))
|
||||
}
|
||||
h.Write([]byte{1})
|
||||
for _, w := range g.writes[id] {
|
||||
h.Write([]byte("w"))
|
||||
h.Write([]byte(w))
|
||||
}
|
||||
h.Write([]byte{2})
|
||||
}
|
||||
return fmt.Sprintf("%016x", h.Sum64())
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package squad
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func stepsOf(specs ...StepSpec) []StepSpec {
|
||||
out := make([]StepSpec, len(specs))
|
||||
copy(out, specs)
|
||||
return out
|
||||
}
|
||||
|
||||
func TestCompileGraphLinearAndAdjacentParallel(t *testing.T) {
|
||||
steps := stepsOf(
|
||||
StepSpec{ID: "a", Status: "pending"},
|
||||
StepSpec{ID: "b", Status: "pending", Parallel: true},
|
||||
StepSpec{ID: "c", Status: "pending", Parallel: true},
|
||||
StepSpec{ID: "d", Status: "pending"},
|
||||
)
|
||||
g, err := compileGraph(steps)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 线性链:b/c 同层依赖 a,d 依赖 c。
|
||||
if len(g.needs["a"]) != 0 {
|
||||
t.Fatalf("a needs = %v", g.needs["a"])
|
||||
}
|
||||
if len(g.needs["b"]) != 1 || g.needs["b"][0] != "a" || g.needs["c"][0] != "a" {
|
||||
t.Fatalf("parallel needs = %v %v", g.needs["b"], g.needs["c"])
|
||||
}
|
||||
if len(g.needs["d"]) != 1 || g.needs["d"][0] != "c" {
|
||||
t.Fatalf("d needs = %v", g.needs["d"])
|
||||
}
|
||||
// 首轮可执行:只有 a。
|
||||
layer, ok := g.nextLayer(steps)
|
||||
if !ok || len(layer.par) != 1 || layer.par[0] != 0 || len(layer.seq) != 0 {
|
||||
t.Fatalf("first layer = %+v", layer)
|
||||
}
|
||||
// a 完成后:b、c 同层并行。
|
||||
steps[0].Status = "completed"
|
||||
layer, ok = g.nextLayer(steps)
|
||||
if !ok || len(layer.par) != 2 || layer.par[0] != 1 || layer.par[1] != 2 {
|
||||
t.Fatalf("second layer = %+v", layer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompileGraphNeedsPruneAndMixed(t *testing.T) {
|
||||
steps := stepsOf(
|
||||
StepSpec{ID: "a", Status: "pending"},
|
||||
StepSpec{ID: "b", Status: "pending"},
|
||||
StepSpec{ID: "c", Status: "pending", Needs: []string{"a"}},
|
||||
)
|
||||
g, err := compileGraph(steps)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// a 完成后:b(线性依赖 a)与 c(needs a)同层。
|
||||
steps[0].Status = "completed"
|
||||
layer, ok := g.nextLayer(steps)
|
||||
if !ok || len(layer.par) != 2 {
|
||||
t.Fatalf("layer = %+v", layer)
|
||||
}
|
||||
// b 失败(非关键降级)后 c 仍按 needs 调度(与线性扫描的降级继续一致)。
|
||||
steps[1].Status = "failed"
|
||||
layer, ok = g.nextLayer(steps)
|
||||
if !ok || len(layer.par)+len(layer.seq) == 0 {
|
||||
t.Fatalf("after b failed: layer = %+v", layer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompileGraphRejectsUnknownAndCycleAndDup(t *testing.T) {
|
||||
if _, err := compileGraph(stepsOf(
|
||||
StepSpec{ID: "a", Status: "pending", Needs: []string{"ghost"}},
|
||||
)); err == nil || !strings.Contains(err.Error(), "未知步骤") {
|
||||
t.Fatalf("unknown ref: %v", err)
|
||||
}
|
||||
if _, err := compileGraph(stepsOf(
|
||||
StepSpec{ID: "a", Status: "pending", Needs: []string{"b"}},
|
||||
StepSpec{ID: "b", Status: "pending", Needs: []string{"a"}},
|
||||
)); err == nil || !strings.Contains(err.Error(), "成环") {
|
||||
t.Fatalf("cycle: %v", err)
|
||||
}
|
||||
if _, err := compileGraph(stepsOf(
|
||||
StepSpec{ID: "a", Status: "pending"},
|
||||
StepSpec{ID: "a", Status: "pending"},
|
||||
)); err == nil || !strings.Contains(err.Error(), "重复") {
|
||||
t.Fatalf("dup: %v", err)
|
||||
}
|
||||
if _, err := compileGraph(stepsOf(
|
||||
StepSpec{ID: "a", Status: "pending", Needs: []string{"a"}},
|
||||
)); err == nil || !strings.Contains(err.Error(), "自身") {
|
||||
t.Fatalf("self ref: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteSetConflictSerializes(t *testing.T) {
|
||||
steps := stepsOf(
|
||||
StepSpec{ID: "z", Status: "completed"},
|
||||
StepSpec{ID: "a", Status: "pending", Parallel: true, Writes: []string{"report.md"}},
|
||||
StepSpec{ID: "b", Status: "pending", Parallel: true, Writes: []string{"report.md"}},
|
||||
StepSpec{ID: "c", Status: "pending", Parallel: true, Writes: []string{"notes.md"}},
|
||||
StepSpec{ID: "d", Status: "pending", Parallel: true},
|
||||
)
|
||||
// 全部首轮可执行(无依赖):b 与 a 冲突 → b 串行;c、d 无冲突 → 并行。
|
||||
g, err := compileGraph(steps)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
layer, ok := g.nextLayer(steps)
|
||||
if !ok {
|
||||
t.Fatal("no layer")
|
||||
}
|
||||
if len(layer.seq) != 1 || layer.seq[0] != 2 {
|
||||
t.Fatalf("seq = %v", layer.seq)
|
||||
}
|
||||
if len(layer.par) != 3 || layer.par[0] != 1 || layer.par[1] != 3 || layer.par[2] != 4 {
|
||||
t.Fatalf("par = %v", layer.par)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailedDependencyStillSchedules(t *testing.T) {
|
||||
// 与线性扫描的「非关键失败降级继续」一致:依赖 failed 仍视为满足。
|
||||
steps := stepsOf(
|
||||
StepSpec{ID: "b", Status: "failed"},
|
||||
StepSpec{ID: "a", Status: "pending", Needs: []string{"b"}},
|
||||
)
|
||||
g, err := compileGraph(steps)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
layer, ok := g.nextLayer(steps)
|
||||
if !ok || len(layer.par) != 1 || layer.par[0] != 1 {
|
||||
t.Fatalf("failed dependency should still schedule: %+v", layer)
|
||||
}
|
||||
if pendingCount(steps) != 1 {
|
||||
t.Fatalf("pending = %d", pendingCount(steps))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
package squad
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// groupchat.go — group_chat 拓扑驱动(Q2 WP3)。
|
||||
// 形态(计划 §三 WP3 与边界 §五):共享转录(BB 字段 transcript +
|
||||
// group_chat/transcript.md 全量落盘);发言人策略只做**轮转**与**主持人**
|
||||
// 两种(脚本可判定,LLM 选人策略不进库);预算停机(max_turns,缺省 8)。
|
||||
// 蓝图步骤 = 参与者名册(按序,不直接执行,driver 标记 skipped 并派生轮次
|
||||
// 步骤);任一发言含 [close] 标记即收口;主持人模式下主持人输出 JSON 决策
|
||||
// ({"next":"role"} / {"action":"close"}),未知角色/非法 JSON fail-closed。
|
||||
// 驱动幂等可恢复:轮次由已落盘的 gc 步骤数重建,挂起(权限/HITL)后
|
||||
// Respond 重入续转。
|
||||
|
||||
func init() {
|
||||
_ = RegisterTopologyDriver(TopologyGroupChat)
|
||||
}
|
||||
|
||||
// DefaultMaxGroupChatTurns 群聊轮次预算缺省值(预算停机)。
|
||||
const DefaultMaxGroupChatTurns = 8
|
||||
|
||||
// gcRosterSkipReason 名册步骤的 skipped 原因(非执行步骤,不算降级)。
|
||||
const gcRosterSkipReason = "group_chat: 名册步骤(参与者由驱动接管)"
|
||||
|
||||
// gcCloseMarker 任一发言包含该标记(大小写不敏感)即收口。
|
||||
const gcCloseMarker = "[close]"
|
||||
|
||||
// gcTranscriptCapBB 是 BB 转录字段的容量上限(TASK.md 快照只带近端)。
|
||||
const gcTranscriptCapBB = 4000
|
||||
|
||||
// runGroupChatDriver group_chat 拓扑入口。
|
||||
func (e *SquadEngine) runGroupChatDriver(ctx context.Context, st *RunState) (*RunResult, error) {
|
||||
drv := &flowDriver{opts: e.deps}
|
||||
|
||||
// 名册:首次进入从蓝图步骤取参与者序列并持久化到元数据(恢复时直读)。
|
||||
roster, err := e.gcRoster(st)
|
||||
if err != nil {
|
||||
return e.driverFail(st, err.Error()), nil
|
||||
}
|
||||
policy := orDefault(st.Meta.Metadata["group_chat_policy"], "rotate")
|
||||
moderator := st.Meta.Metadata["group_chat_moderator"]
|
||||
if policy != "rotate" && policy != "moderator" {
|
||||
return e.driverFail(st, fmt.Sprintf("group_chat: 未知发言人策略 %q(只支持 rotate/moderator)", policy)), nil
|
||||
}
|
||||
if policy == "moderator" {
|
||||
if moderator == "" || !containsString(roster, moderator) {
|
||||
return e.driverFail(st, fmt.Sprintf("group_chat: 主持人 %q 不在名册内(fail-closed)", moderator)), nil
|
||||
}
|
||||
}
|
||||
maxTurns := DefaultMaxGroupChatTurns
|
||||
if v := st.Meta.Metadata["group_chat_max_turns"]; v != "" {
|
||||
if n := parseIntDefault(v, DefaultMaxGroupChatTurns); n > 0 {
|
||||
maxTurns = n
|
||||
}
|
||||
}
|
||||
|
||||
// 名册步骤标记 skipped(首次进入时;恢复重入无 pending 名册步骤)。
|
||||
st.mu.Lock()
|
||||
for i := range st.Plan.Workflow.Steps {
|
||||
s := &st.Plan.Workflow.Steps[i]
|
||||
if !strings.HasPrefix(s.ID, "gc") && (s.Status == "pending" || s.Status == "running") {
|
||||
s.Status = "skipped"
|
||||
s.Error = gcRosterSkipReason
|
||||
}
|
||||
}
|
||||
_ = st.save()
|
||||
st.mu.Unlock()
|
||||
|
||||
for {
|
||||
// 恢复重入:上次挂起留下的未完 gc 轮次步骤优先续跑(不重新选人)。
|
||||
if idx := e.gcHeldStepIdx(st); idx >= 0 {
|
||||
out, gate, err := e.gcRunStep(ctx, drv, st, idx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if gate != nil {
|
||||
if out == nil {
|
||||
out = heldResult(st, gate)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
if out != nil {
|
||||
return out, nil
|
||||
}
|
||||
if e.gcMessageClosed(st, idx) {
|
||||
drv.emit(st, Event{Type: EvPhase, Text: fmt.Sprintf("group_chat:%s 发出收口标记,停机", st.Plan.Workflow.Steps[idx].Role)})
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
turns := e.gcTurnCount(st)
|
||||
if turns >= maxTurns {
|
||||
drv.emit(st, Event{Type: EvPhase, Text: fmt.Sprintf("group_chat:轮次预算 %d 用尽,停机收口", maxTurns)})
|
||||
break
|
||||
}
|
||||
speaker, closed, derr := e.gcNextSpeaker(st, roster, policy, moderator, turns)
|
||||
if derr != nil {
|
||||
return e.driverFail(st, derr.Error()), nil
|
||||
}
|
||||
if closed {
|
||||
drv.emit(st, Event{Type: EvPhase, Text: "group_chat:主持人收口"})
|
||||
break
|
||||
}
|
||||
out, gate, err := e.gcRunTurn(ctx, drv, st, turns, speaker)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if gate != nil {
|
||||
if out == nil {
|
||||
out = heldResult(st, gate)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
if out != nil { // 轮次步骤失败(关键)→ fail-closed 传播
|
||||
return out, nil
|
||||
}
|
||||
if e.gcMessageClosed(st, len(st.Plan.Workflow.Steps)-1) {
|
||||
drv.emit(st, Event{Type: EvPhase, Text: fmt.Sprintf("group_chat:%s 发出收口标记,停机", speaker)})
|
||||
break
|
||||
}
|
||||
}
|
||||
return e.gcFinish(st, drv), nil
|
||||
}
|
||||
|
||||
// gcRoster 参与者名册(首次从蓝图步骤派生并写入元数据;恢复直读)。
|
||||
func (e *SquadEngine) gcRoster(st *RunState) ([]string, error) {
|
||||
if v := st.Meta.Metadata["group_chat_roster"]; v != "" {
|
||||
return strings.Split(v, ","), nil
|
||||
}
|
||||
var roster []string
|
||||
for _, s := range st.Plan.Workflow.Steps {
|
||||
if s.Role != "" && !containsString(roster, s.Role) {
|
||||
roster = append(roster, s.Role)
|
||||
}
|
||||
}
|
||||
if len(roster) == 0 {
|
||||
return nil, fmt.Errorf("group_chat: 名册为空(蓝图步骤即参与者)")
|
||||
}
|
||||
st.mu.Lock()
|
||||
if st.Meta.Metadata == nil {
|
||||
st.Meta.Metadata = map[string]string{}
|
||||
}
|
||||
st.Meta.Metadata["group_chat_roster"] = strings.Join(roster, ",")
|
||||
_ = st.save()
|
||||
st.mu.Unlock()
|
||||
return roster, nil
|
||||
}
|
||||
|
||||
// gcTurnCount 已完成的轮次数(预算与轮转序号的基准;恢复重入自然续算)。
|
||||
func (e *SquadEngine) gcTurnCount(st *RunState) int {
|
||||
n := 0
|
||||
for _, s := range st.Plan.Workflow.Steps {
|
||||
if strings.HasPrefix(s.ID, "gc") && s.Status == "completed" {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// gcHeldStepIdx 返回上次挂起留下的未完 gc 轮次步骤下标(无则 -1)。
|
||||
func (e *SquadEngine) gcHeldStepIdx(st *RunState) int {
|
||||
for i := range st.Plan.Workflow.Steps {
|
||||
s := &st.Plan.Workflow.Steps[i]
|
||||
if strings.HasPrefix(s.ID, "gc") && (s.Status == "pending" || s.Status == "running") {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// gcNextSpeaker 决定下一发言人。rotate = 轮转;moderator = 主持人裁决
|
||||
// (上一步是参与者发言 → 主持人接手;主持人决策 close → 收口,next → 指名,
|
||||
// 非法 JSON / 未知角色 fail-closed)。
|
||||
func (e *SquadEngine) gcNextSpeaker(st *RunState, roster []string, policy, moderator string, turns int) (string, bool, error) {
|
||||
if policy == "rotate" {
|
||||
return roster[turns%len(roster)], false, nil
|
||||
}
|
||||
last := e.gcLastStep(st)
|
||||
if last == nil || last.Role == moderator {
|
||||
// 首轮或上一步就是主持人(不该连续,防御性让主持人接手)。
|
||||
if last != nil && last.Role == moderator {
|
||||
// 解析主持人决策。
|
||||
dec, err := e.gcParseDecision(st, last)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
if dec.Action == "close" {
|
||||
return "", true, nil
|
||||
}
|
||||
if !containsString(roster, dec.Next) {
|
||||
return "", false, fmt.Errorf("group_chat: 主持人指名 %q 不在名册内(fail-closed)", dec.Next)
|
||||
}
|
||||
return dec.Next, false, nil
|
||||
}
|
||||
return moderator, false, nil
|
||||
}
|
||||
// 上一步是参与者发言 → 主持人接手裁决。
|
||||
return moderator, false, nil
|
||||
}
|
||||
|
||||
type gcDecision struct {
|
||||
Action string `json:"action"`
|
||||
Next string `json:"next"`
|
||||
}
|
||||
|
||||
// gcParseDecision 宽松解析主持人 message.md 里的 JSON 决策(首 `{` 末 `}`)。
|
||||
func (e *SquadEngine) gcParseDecision(st *RunState, step *StepSpec) (*gcDecision, error) {
|
||||
msg := e.gcMessageOf(st, step)
|
||||
start := strings.Index(msg, "{")
|
||||
end := strings.LastIndex(msg, "}")
|
||||
if start < 0 || end <= start {
|
||||
return nil, fmt.Errorf("group_chat: 主持人输出无 JSON 决策(fail-closed):%q", truncateStr(msg, 80))
|
||||
}
|
||||
var dec gcDecision
|
||||
if err := json.Unmarshal([]byte(msg[start:end+1]), &dec); err != nil {
|
||||
return nil, fmt.Errorf("group_chat: 主持人决策解析失败(fail-closed): %v", err)
|
||||
}
|
||||
if dec.Action == "close" {
|
||||
return &dec, nil
|
||||
}
|
||||
if strings.TrimSpace(dec.Next) == "" {
|
||||
return nil, fmt.Errorf("group_chat: 主持人决策缺 next/action(fail-closed)")
|
||||
}
|
||||
return &dec, nil
|
||||
}
|
||||
|
||||
// gcRunTurn 派生一轮发言步骤并执行。
|
||||
func (e *SquadEngine) gcRunTurn(ctx context.Context, drv *flowDriver, st *RunState, turns int, speaker string) (*RunResult, *HitlGate, error) {
|
||||
step := StepSpec{
|
||||
ID: fmt.Sprintf("gc%02d", turns+1),
|
||||
Role: speaker,
|
||||
Title: fmt.Sprintf("群聊第 %d 轮:%s 发言(把发言写入 message.md;同意收口就写 [close])", turns+1, speaker),
|
||||
Targets: []string{"message.md"},
|
||||
SubWorkspace: fmt.Sprintf("group_chat/turn_%02d_%s", turns+1, speaker),
|
||||
Status: "pending",
|
||||
}
|
||||
st.mu.Lock()
|
||||
st.Plan.Workflow.Steps = append(st.Plan.Workflow.Steps, step)
|
||||
idx := len(st.Plan.Workflow.Steps) - 1
|
||||
_ = st.save()
|
||||
st.mu.Unlock()
|
||||
return e.gcRunStep(ctx, drv, st, idx)
|
||||
}
|
||||
|
||||
// gcRunStep 执行一个轮次步骤:过门 → runOneStep → 转录并入共享状态。
|
||||
// 挂起(gate)时步骤保持未完,恢复重入经 gcHeldStepIdx 续跑同一步骤。
|
||||
func (e *SquadEngine) gcRunStep(ctx context.Context, drv *flowDriver, st *RunState, idx int) (*RunResult, *HitlGate, error) {
|
||||
out, gate, err := drv.gateBeforeRun(ctx, st, idx)
|
||||
if err != nil || gate != nil || out != nil {
|
||||
return out, gate, err
|
||||
}
|
||||
step := st.Plan.Workflow.Steps[idx]
|
||||
drv.emit(st, Event{Type: EvPhase, StepID: step.ID, Role: step.Role,
|
||||
Text: fmt.Sprintf("group_chat:%s 发言(第 %d 轮)", step.Role, e.gcTurnCount(st)+1)})
|
||||
out, gate, err = drv.runOneStep(ctx, st, idx)
|
||||
if err != nil || gate != nil || out != nil {
|
||||
return out, gate, err
|
||||
}
|
||||
// 转录并入共享状态:全量落盘 transcript.md,近端进 BB 字段(下一位
|
||||
// 发言人的 TASK.md 黑板快照可见)。
|
||||
msg := e.gcMessageOf(st, &st.Plan.Workflow.Steps[idx])
|
||||
e.gcAppendTranscript(st, e.gcTurnCount(st), step.Role, msg)
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
// gcAppendTranscript 追加转录(全量文件 + BB 近端快照)。
|
||||
func (e *SquadEngine) gcAppendTranscript(st *RunState, turn int, speaker, msg string) {
|
||||
dir := filepath.Join(st.Meta.WorkDir, "group_chat")
|
||||
_ = os.MkdirAll(dir, 0o755)
|
||||
line := fmt.Sprintf("%d. %s:%s\n", turn, speaker, strings.TrimSpace(msg))
|
||||
f, _ := os.OpenFile(filepath.Join(dir, "transcript.md"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
||||
if f != nil {
|
||||
_, _ = f.WriteString(line)
|
||||
_ = f.Close()
|
||||
}
|
||||
st.mu.Lock()
|
||||
if st.BB.Fields == nil {
|
||||
st.BB.Fields = map[string]string{}
|
||||
}
|
||||
cur := st.BB.Fields["transcript"] + line
|
||||
if len(cur) > gcTranscriptCapBB {
|
||||
cur = cur[len(cur)-gcTranscriptCapBB:]
|
||||
}
|
||||
st.BB.Fields["transcript"] = cur
|
||||
_ = st.save()
|
||||
st.mu.Unlock()
|
||||
}
|
||||
|
||||
// gcLastStep 最后一个已完成的派生轮次步骤(主持人决策解析的锚点)。
|
||||
func (e *SquadEngine) gcLastStep(st *RunState) *StepSpec {
|
||||
for i := len(st.Plan.Workflow.Steps) - 1; i >= 0; i-- {
|
||||
s := &st.Plan.Workflow.Steps[i]
|
||||
if strings.HasPrefix(s.ID, "gc") && s.Status == "completed" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// gcMessageOf 读取某轮的 message.md(缺文件 = 空串)。
|
||||
func (e *SquadEngine) gcMessageOf(st *RunState, step *StepSpec) string {
|
||||
data, err := os.ReadFile(filepath.Join(st.Meta.WorkDir, filepath.FromSlash(step.SubWorkspace), "message.md"))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
// gcMessageClosed 第 idx 步的发言是否带收口标记。
|
||||
func (e *SquadEngine) gcMessageClosed(st *RunState, idx int) bool {
|
||||
if idx < 0 || idx >= len(st.Plan.Workflow.Steps) {
|
||||
return false
|
||||
}
|
||||
msg := e.gcMessageOf(st, &st.Plan.Workflow.Steps[idx])
|
||||
return strings.Contains(strings.ToLower(msg), gcCloseMarker)
|
||||
}
|
||||
|
||||
// gcFinish 收口:交付物 = 全量转录;写 output.md;状态落盘;构造结果。
|
||||
func (e *SquadEngine) gcFinish(st *RunState, drv *flowDriver) *RunResult {
|
||||
transcript := ""
|
||||
if data, err := os.ReadFile(filepath.Join(st.Meta.WorkDir, "group_chat", "transcript.md")); err == nil {
|
||||
transcript = string(data)
|
||||
}
|
||||
report := "# 群聊转录\n\n" + transcript
|
||||
_ = os.WriteFile(filepath.Join(st.Meta.WorkDir, "output.md"), []byte(report), 0o644)
|
||||
turns := e.gcTurnCount(st)
|
||||
st.mu.Lock()
|
||||
wf := st.Workflow()
|
||||
wf.Status = string(RunCompleted)
|
||||
st.setWorkflow(wf)
|
||||
st.Meta.Status = string(RunCompleted)
|
||||
now := time.Now()
|
||||
st.Meta.CompletedAt = &now
|
||||
_ = st.save()
|
||||
st.mu.Unlock()
|
||||
summary := fmt.Sprintf("group_chat:%d 轮发言,转录已收口", turns)
|
||||
drv.emit(st, Event{Type: EvPhase, Text: summary})
|
||||
return &RunResult{RunID: st.Meta.ID, Status: RunCompleted, Plan: planPtr(st),
|
||||
Deliverable: "output.md", Summary: summary, WorkDir: st.Meta.WorkDir}
|
||||
}
|
||||
|
||||
func truncateStr(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "…"
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package squad
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// handoff.go — handoff 跨拓扑原语(Q2 WP3)。
|
||||
// 语义(计划 §三 WP3):StepSpec.Handoff 非空的步骤完成后,最终答案所有权
|
||||
// 移交给目标角色——① 记录所有权转移(Handoffs,随 RunState 持久化);
|
||||
// ② 目标角色的下一个步骤收到「过滤历史」:只带移交步骤的主产出,不带全量
|
||||
// 转录(一次性并入任务书,与 mailbox 同款机制);③ finalize 汇总交付物时
|
||||
// 取链尾(最后一次移交的目标角色)的最后一个完成步骤产出,而非全图最后
|
||||
// 完成步骤。目标角色不在小队内 = fail-closed(步骤启动前拒绝)。
|
||||
|
||||
// HandoffRecord 是一次所有权转移记录。
|
||||
type HandoffRecord struct {
|
||||
FromRole string `json:"from_role"`
|
||||
FromStep string `json:"from_step"`
|
||||
ToRole string `json:"to_role"`
|
||||
Output string `json:"output,omitempty"` // 移交步骤主产出(截断上限内)
|
||||
At time.Time `json:"at"`
|
||||
Delivered bool `json:"delivered,omitempty"`
|
||||
}
|
||||
|
||||
// handoffOutputCap 移交上下文的产出截断上限(过滤历史:只带主产出,且限量)。
|
||||
const handoffOutputCap = 64 * 1024
|
||||
|
||||
// takeHandoffFor 取出移交给某角色的未投递记录,标记已投递(一次性)。
|
||||
// 调用方负责随后 st.save()(或在临界区内)。
|
||||
func takeHandoffFor(st *RunState, role string) *HandoffRecord {
|
||||
for i := range st.Handoffs {
|
||||
h := &st.Handoffs[i]
|
||||
if h.Delivered || h.ToRole != role {
|
||||
continue
|
||||
}
|
||||
h.Delivered = true
|
||||
out := *h
|
||||
return &out
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// formatHandoffSection 渲染任务书里的移交上下文段(无则空串)。
|
||||
func formatHandoffSection(h *HandoffRecord) string {
|
||||
if h == nil {
|
||||
return ""
|
||||
}
|
||||
body := h.Output
|
||||
if len(body) > handoffOutputCap {
|
||||
body = body[:handoffOutputCap] + "\n…(已截断)"
|
||||
}
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "\n\n## 移交上下文(所有权来自 %s,步骤 %s)\n", h.FromRole, h.FromStep)
|
||||
b.WriteString("以下是移交方的主产出(过滤历史:只含该产出,不含其过程转录):\n\n")
|
||||
b.WriteString(body)
|
||||
b.WriteString("\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// handoffChainTail 返回移交链尾角色(无移交记录返回空串)。
|
||||
func handoffChainTail(st *RunState) string {
|
||||
if n := len(st.Handoffs); n > 0 {
|
||||
return st.Handoffs[n-1].ToRole
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// recordHandoff 步骤完成时的所有权转移记录:主产出快照(截断上限内)+
|
||||
// 持久化 + 事件。目标角色合法性已在步骤启动前 fail-closed 校验。
|
||||
func (d *flowDriver) recordHandoff(st *RunState, step StepSpec, subDir string, targets []string) {
|
||||
output := ""
|
||||
if data, err := os.ReadFile(filepath.Join(subDir, firstTarget(targets))); err == nil {
|
||||
output = string(data)
|
||||
}
|
||||
if len(output) > handoffOutputCap {
|
||||
output = output[:handoffOutputCap] + "\n…(已截断)"
|
||||
}
|
||||
st.mu.Lock()
|
||||
st.Handoffs = append(st.Handoffs, HandoffRecord{
|
||||
FromRole: step.Role, FromStep: step.ID, ToRole: step.Handoff,
|
||||
Output: output, At: time.Now(),
|
||||
})
|
||||
_ = st.save()
|
||||
st.mu.Unlock()
|
||||
d.emit(st, Event{Type: EvPhase, StepID: step.ID, Role: step.Role,
|
||||
Text: fmt.Sprintf("handoff:%s → %s(最终答案所有权移交)", step.Role, step.Handoff)})
|
||||
}
|
||||
+39
-4
@@ -75,8 +75,10 @@ type HumanRequest struct {
|
||||
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"` // 脱敏摘要(计划/缺口/质检报告)
|
||||
// Reason 区分同一 Kind 的来源:plan、step_approval、interrupted。空值保持旧行为。
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Options []HumanOption `json:"options,omitempty"`
|
||||
Context string `json:"context,omitempty"` // 脱敏摘要(计划/缺口/质检报告)
|
||||
// WaitCondition 非空时,条件满足可自动唤醒(人工回应仍然优先)。
|
||||
WaitCondition *WaitCondition `json:"wait_condition,omitempty"`
|
||||
// WakeupFires 已自动唤醒次数(护栏:达到 MaxWakeupFires 后过期)。
|
||||
@@ -103,8 +105,19 @@ type RespondDecision struct {
|
||||
Choice string `json:"choice,omitempty"`
|
||||
// EditPlan 确认计划时的修改指令(附加在 Answer 上传递给引擎)。
|
||||
EditPlan string `json:"edit_plan,omitempty"`
|
||||
// Actor 决议发起方(Q2):"" 或 human = 人工(默认,兼容既有调用方);
|
||||
// agent/system = 非人工。非人工 actor 的 choice 决议在
|
||||
// permission/confirmation/quality_review 门上一律被拒绝(验收三)。
|
||||
Actor string `json:"actor,omitempty"`
|
||||
}
|
||||
|
||||
// Actor 归属常量。
|
||||
const (
|
||||
ActorHuman = "human"
|
||||
ActorAgent = "agent"
|
||||
ActorSystem = "system"
|
||||
)
|
||||
|
||||
// newRequestID 生成请求 ID。
|
||||
func newRequestID() string {
|
||||
b := make([]byte, 8)
|
||||
@@ -112,8 +125,30 @@ func newRequestID() string {
|
||||
return "hr_" + hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// hitlGate 引擎内部:一次挂起的完整上下文(落盘 pending.json)。
|
||||
type hitlGate struct {
|
||||
// expiredDecision 在请求超过 TimeoutAfter 或 ExpiresAt 时合成过期决策。
|
||||
// ExpireAction 为 cancel 时选择 cancel,否则 fail。未到期返回 false。
|
||||
func expiredDecision(req HumanRequest, policy HitlPolicy, now time.Time) (RespondDecision, bool) {
|
||||
var deadline time.Time
|
||||
switch {
|
||||
case req.ExpiresAt != nil:
|
||||
deadline = *req.ExpiresAt
|
||||
case policy.TimeoutAfter > 0 && !req.CreatedAt.IsZero():
|
||||
deadline = req.CreatedAt.Add(policy.TimeoutAfter)
|
||||
default:
|
||||
return RespondDecision{}, false
|
||||
}
|
||||
if now.Before(deadline) {
|
||||
return RespondDecision{}, false
|
||||
}
|
||||
choice := "fail"
|
||||
if policy.ExpireAction == "cancel" {
|
||||
choice = "cancel"
|
||||
}
|
||||
return RespondDecision{RequestID: req.ID, Choice: choice, Answer: "expired"}, true
|
||||
}
|
||||
|
||||
// HitlGate 引擎内部:一次挂起的完整上下文(落盘 pending.json)。
|
||||
type HitlGate struct {
|
||||
Request HumanRequest `json:"request"`
|
||||
Plan *SquadPlan `json:"plan,omitempty"` // confirmation 时携带候选计划
|
||||
Steps []StepSpec `json:"steps,omitempty"` // adjustment:建议追加的步骤
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package squad
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// idPattern 限制 run、角色、蓝图 key:首字符为字母或数字,其余为字母、数字、点、下划线、连字符,最长 128,且不得含 ".."。
|
||||
var idPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`)
|
||||
|
||||
// ValidID 报告 id 是否可安全拼进数据目录路径。
|
||||
func ValidID(id string) bool {
|
||||
return idPattern.MatchString(id) && !strings.Contains(id, "..")
|
||||
}
|
||||
|
||||
func invalidID(id string) error {
|
||||
return fmt.Errorf("invalid id %q", id)
|
||||
}
|
||||
+1
-3
@@ -3,8 +3,6 @@ package squad
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"agent-native/exec"
|
||||
)
|
||||
|
||||
// IntentRejection 守卫拦截:拒绝该次运行并给出引导("识别即拒绝"语义)。
|
||||
@@ -96,7 +94,7 @@ type IntentStep struct {
|
||||
|
||||
// IntentRunner 系统功能执行器(squadintent 提供实现:查 spec.Handler 并调宿主处理函数)。
|
||||
type IntentRunner interface {
|
||||
Run(ctx context.Context, step IntentStep) (*exec.Result, error)
|
||||
Run(ctx context.Context, step IntentStep) (*ExecResult, error)
|
||||
}
|
||||
|
||||
// DefaultIntentRole 系统功能步的默认角色 key(无后端语义,仅满足编制覆盖)。
|
||||
|
||||
@@ -6,8 +6,6 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"agent-native/exec"
|
||||
)
|
||||
|
||||
type stubRunner struct {
|
||||
@@ -16,7 +14,7 @@ type stubRunner struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (r *stubRunner) Run(ctx context.Context, step IntentStep) (*exec.Result, error) {
|
||||
func (r *stubRunner) Run(ctx context.Context, step IntentStep) (*ExecResult, error) {
|
||||
r.calls = append(r.calls, step)
|
||||
if r.err != nil {
|
||||
return nil, r.err
|
||||
@@ -25,7 +23,7 @@ func (r *stubRunner) Run(ctx context.Context, step IntentStep) (*exec.Result, er
|
||||
for _, t := range step.Targets {
|
||||
patches[t] = []byte(r.text)
|
||||
}
|
||||
return &exec.Result{Success: true, Patches: patches, Logs: "stub-intent"}, nil
|
||||
return &ExecResult{Success: true, Patches: patches, Logs: "stub-intent"}, nil
|
||||
}
|
||||
|
||||
func testCatalog() *IntentCatalog {
|
||||
|
||||
+134
-26
@@ -8,32 +8,30 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"agent-native/exec"
|
||||
)
|
||||
|
||||
// leaderExecRequest 构造 leader 轮次的执行请求:与标准步骤同等装配
|
||||
// (过程事件桥、ToolProfile/Skills/工具白名单、执行标识与预算)——
|
||||
// 修平此前 leader 轮丢弃 OnEvent 与 ToolProfile 的缺口。
|
||||
func leaderExecRequest(drv *flowDriver, st *runState, leader RoleSpec, workDir, prompt, instructions string, targets []string, timeout time.Duration) exec.Request {
|
||||
func leaderExecRequest(drv *flowDriver, st *RunState, leader RoleSpec, workDir, prompt, instructions string, targets []string, timeout time.Duration) ExecRequest {
|
||||
stepID := fmt.Sprintf("leader_turn_%d", st.Leader.Turns)
|
||||
var budget *exec.Budget
|
||||
var budget *ExecBudget
|
||||
if timeout > 0 {
|
||||
budget = &exec.Budget{Deadline: time.Now().Add(timeout)}
|
||||
budget = &ExecBudget{Deadline: time.Now().Add(timeout)}
|
||||
}
|
||||
return exec.Request{
|
||||
return ExecRequest{
|
||||
WorkDir: workDir,
|
||||
Prompt: prompt,
|
||||
Instructions: instructions,
|
||||
Targets: targets,
|
||||
Timeout: timeout,
|
||||
ToolProfile: orDefault(leader.ToolProfile, exec.ToolProfileWorkspace),
|
||||
ToolProfile: orDefault(leader.ToolProfile, ToolProfileWorkspace),
|
||||
ToolAllowlist: leader.Tools,
|
||||
Skills: leader.Skills,
|
||||
SessionScope: st.Meta.ID + "/" + stepID,
|
||||
ExecutionID: st.Meta.ID + "-" + stepID,
|
||||
Budget: budget,
|
||||
OnEvent: func(ev exec.ProcessEvent) {
|
||||
OnEvent: func(ev ProcessEvent) {
|
||||
drv.emit(st, Event{Type: EvDetail, StepID: stepID, Role: leader.Role, Text: ev.Text,
|
||||
Data: marshalJSON(map[string]string{"process_type": ev.Type, "tool": ev.Tool, "detail": ev.Detail})})
|
||||
},
|
||||
@@ -49,12 +47,34 @@ func leaderExecRequest(drv *flowDriver, st *runState, leader RoleSpec, workDir,
|
||||
// DefaultMaxLeaderTurns leader 模式每运行的 leader 轮次上限。
|
||||
const DefaultMaxLeaderTurns = 8
|
||||
|
||||
// DefaultMaxReplans leader 卡顿触发的重规划上限(Q2;与轮次上限独立)。
|
||||
const DefaultMaxReplans = 3
|
||||
|
||||
// DefaultStuckThreshold 连续无进展轮数达到该值触发一次重规划(Q2)。
|
||||
const DefaultStuckThreshold = 2
|
||||
|
||||
// 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"`
|
||||
// 进度账本(Q2):每轮委派后记一条进展评估;卡顿检测与重规划的凭据。
|
||||
Progress []ProgressEntry `json:"progress,omitempty"`
|
||||
// StuckTurns 连续无进展轮数(有进展即清零)。
|
||||
StuckTurns int `json:"stuck_turns,omitempty"`
|
||||
// Replans 已触发的重规划次数;MaxReplans 触顶降级收口。
|
||||
Replans int `json:"replans,omitempty"`
|
||||
MaxReplans int `json:"max_replans,omitempty"`
|
||||
// ReplanNote 一次性重规划指令(并入下一轮 leader 任务书后清空)。
|
||||
ReplanNote string `json:"replan_note,omitempty"`
|
||||
}
|
||||
|
||||
// ProgressEntry 是进度账本的一条评估记录。
|
||||
type ProgressEntry struct {
|
||||
Turn int `json:"turn"`
|
||||
Signal string `json:"signal"` // progress | stuck
|
||||
Detail string `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
// DelegationRecord 一次已执行的委派(历史注入下一轮 leader 上下文)。
|
||||
@@ -82,22 +102,24 @@ type delegation struct {
|
||||
|
||||
const leaderSubWorkspace = "tasks/00_leader"
|
||||
|
||||
func (st *runState) loadLeader() {
|
||||
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 {
|
||||
data, err := st.storeOr().LoadLeader(st.Meta.WorkDir)
|
||||
if err == nil && data != nil {
|
||||
_ = json.Unmarshal(data, st.Leader)
|
||||
}
|
||||
}
|
||||
|
||||
func (st *runState) saveLeader() {
|
||||
func (st *RunState) saveLeader() {
|
||||
if st.Leader == nil {
|
||||
return
|
||||
}
|
||||
_ = writeJSON(filepath.Join(st.Meta.WorkDir, "leader.json"), st.Leader)
|
||||
if data, err := json.Marshal(st.Leader); err == nil {
|
||||
_ = st.storeOr().SaveLeader(st.Meta.WorkDir, data)
|
||||
}
|
||||
}
|
||||
|
||||
// leaderPrompt 组装 leader 的系统指令(协调契约 + 通用守则)。
|
||||
@@ -134,7 +156,7 @@ func leaderPrompt(members []RoleSpec, catalogMd string) string {
|
||||
}
|
||||
|
||||
// leaderTaskBrief 组装 leader 本轮的 TASK.md(目标 + 黑板 + 委派历史)。
|
||||
func leaderTaskBrief(st *runState) string {
|
||||
func leaderTaskBrief(st *RunState, mail []MailMessage) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("# Leader 轮次:评估并委派\n\n")
|
||||
fmt.Fprintf(&b, "## 运行目标\n%s\n\n", st.Meta.Goal.Text)
|
||||
@@ -171,13 +193,28 @@ func leaderTaskBrief(st *runState) string {
|
||||
} else {
|
||||
b.WriteString("(尚无委派,这是第一轮。)\n\n")
|
||||
}
|
||||
if len(st.Leader.Progress) > 0 {
|
||||
b.WriteString("## 进度账本\n")
|
||||
for _, p := range st.Leader.Progress {
|
||||
fmt.Fprintf(&b, "- 第%d轮 [%s] %s\n", p.Turn, p.Signal, p.Detail)
|
||||
}
|
||||
fmt.Fprintf(&b, "- 已重规划 %d/%d 次\n\n", st.Leader.Replans, st.Leader.MaxReplans)
|
||||
}
|
||||
if st.Leader.ReplanNote != "" {
|
||||
fmt.Fprintf(&b, "%s\n\n", st.Leader.ReplanNote)
|
||||
}
|
||||
|
||||
if section := formatMailSection(mail); section != "" {
|
||||
b.WriteString(strings.TrimPrefix(section, "\n\n"))
|
||||
b.WriteString("\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) {
|
||||
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)
|
||||
@@ -206,7 +243,8 @@ func (e *SquadEngine) runLeaderLoop(ctx context.Context, st *runState) (*RunResu
|
||||
}
|
||||
_ = snapshotContextInto(st.Meta.WorkDir, subDir)
|
||||
|
||||
for st.Leader.Turns < st.Leader.MaxTurns {
|
||||
term := e.terminationOf(st)
|
||||
for st.Leader.Turns < term.MaxLeaderTurns {
|
||||
// HITL:leader 角色权限升级(与标准模式同语义)。
|
||||
if leader.BashPermission == "ask" && !st.Approved["role:"+leader.Role] {
|
||||
if drv.policy(st).mode() != HitlInteractive {
|
||||
@@ -222,7 +260,7 @@ func (e *SquadEngine) runLeaderLoop(ctx context.Context, st *runState) (*RunResu
|
||||
},
|
||||
CreatedAt: time.Now(), Status: "pending",
|
||||
}
|
||||
gate := &hitlGate{Request: *req, PrevStatus: RunExecuting}
|
||||
gate := &HitlGate{Request: *req, PrevStatus: RunExecuting}
|
||||
if err := st.saveGate(gate); err == nil {
|
||||
st.Meta.Status = string(RunWaitingHuman)
|
||||
_ = st.save()
|
||||
@@ -232,9 +270,20 @@ func (e *SquadEngine) runLeaderLoop(ctx context.Context, st *runState) (*RunResu
|
||||
}
|
||||
|
||||
// 每轮重写 leader 上下文(黑板/委派历史已变化)。
|
||||
if err := os.WriteFile(filepath.Join(subDir, "TASK.md"), []byte(leaderTaskBrief(st)), 0o644); err != nil {
|
||||
// 邮箱消息与重规划指令是一次性的:并入本轮任务书后清空并落盘。
|
||||
st.mu.Lock()
|
||||
leaderMail := takeMailFor(st, st.Leader.LeaderRole)
|
||||
st.mu.Unlock()
|
||||
if err := os.WriteFile(filepath.Join(subDir, "TASK.md"), []byte(leaderTaskBrief(st, leaderMail)), 0o644); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if len(leaderMail) > 0 {
|
||||
_ = st.save()
|
||||
}
|
||||
if st.Leader.ReplanNote != "" {
|
||||
st.Leader.ReplanNote = ""
|
||||
st.saveLeader()
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(subDir, "AGENTS.md"), []byte(leaderPrompt(members, e.opts.IntentCatalog.intentCatalogMd())), 0o644); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
@@ -248,7 +297,7 @@ func (e *SquadEngine) runLeaderLoop(ctx context.Context, st *runState) (*RunResu
|
||||
if err != nil {
|
||||
return e.leaderFail(st, err.Error()), nil, nil
|
||||
}
|
||||
prompt := readFileOr(filepath.Join(subDir, "TASK.md"), leaderTaskBrief(st))
|
||||
prompt := readFileOr(filepath.Join(subDir, "TASK.md"), leaderTaskBrief(st, nil))
|
||||
prompt = st.injectSteering(prompt)
|
||||
instructions := readFileOr(filepath.Join(subDir, "AGENTS.md"), leaderPrompt(members, e.opts.IntentCatalog.intentCatalogMd()))
|
||||
result, execErr := backend.Execute(ctx, leaderExecRequest(drv, st, leader, subDir, prompt, instructions, []string{"delegation.json"}, e.deps.StepTimeout))
|
||||
@@ -294,7 +343,7 @@ func (e *SquadEngine) runLeaderLoop(ctx context.Context, st *runState) (*RunResu
|
||||
},
|
||||
CreatedAt: time.Now(), Status: "pending",
|
||||
}
|
||||
gate := &hitlGate{Request: *req, PrevStatus: RunExecuting}
|
||||
gate := &HitlGate{Request: *req, PrevStatus: RunExecuting}
|
||||
if err := st.saveGate(gate); err == nil {
|
||||
st.Meta.Status = string(RunWaitingHuman)
|
||||
_ = st.save()
|
||||
@@ -337,6 +386,7 @@ func (e *SquadEngine) runLeaderLoop(ctx context.Context, st *runState) (*RunResu
|
||||
memberBrief += "\n\n## Leader 补充指令\n" + dec.Briefing
|
||||
_ = os.WriteFile(filepath.Join(st.Meta.WorkDir, filepath.FromSlash(step.SubWorkspace), "TASK.md"), []byte(memberBrief), 0o644)
|
||||
}
|
||||
factsBefore := len(st.BB.Facts)
|
||||
out, gate, err := drv.runOneStep(ctx, st, idx)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
@@ -354,10 +404,56 @@ func (e *SquadEngine) runLeaderLoop(ctx context.Context, st *runState) (*RunResu
|
||||
return out, gate, nil // 挂起(gap/权限等),Respond 后重入循环
|
||||
}
|
||||
st.Leader.Delegations = append(st.Leader.Delegations, rec)
|
||||
// 进度账本(Q2):委派完成且有新事实、或不是与上轮同题重复 → 有进展。
|
||||
factsGrew := len(st.BB.Facts) > factsBefore
|
||||
sameAsPrev := false
|
||||
if n := len(st.Leader.Delegations); n >= 2 {
|
||||
prev := st.Leader.Delegations[n-2]
|
||||
sameAsPrev = prev.Member == rec.Member && prev.Title == rec.Title
|
||||
}
|
||||
if factsGrew || !sameAsPrev {
|
||||
st.Leader.StuckTurns = 0
|
||||
st.Leader.Progress = append(st.Leader.Progress, ProgressEntry{
|
||||
Turn: rec.Turn, Signal: "progress",
|
||||
Detail: fmt.Sprintf("%s:%s(新事实 %v)", rec.Member, rec.Title, factsGrew),
|
||||
})
|
||||
} else {
|
||||
st.Leader.StuckTurns++
|
||||
st.Leader.Progress = append(st.Leader.Progress, ProgressEntry{
|
||||
Turn: rec.Turn, Signal: "stuck",
|
||||
Detail: fmt.Sprintf("与上轮同题重复且无新事实(连续 %d 轮)", st.Leader.StuckTurns),
|
||||
})
|
||||
}
|
||||
// 卡顿 → 重规划;重规划触顶 → 降级收口(与轮次上限同款 Q23 语义)。
|
||||
if st.Leader.StuckTurns >= DefaultStuckThreshold {
|
||||
st.Leader.StuckTurns = 0
|
||||
st.Leader.Replans++
|
||||
if st.Leader.Replans >= st.Leader.MaxReplans {
|
||||
st.saveLeader()
|
||||
drv.emit(st, Event{Type: EvPhase, Text: fmt.Sprintf(
|
||||
"重规划达上限 %d,按现有产出降级收口(Q2)", st.Leader.MaxReplans)})
|
||||
out2, gate2, err2 := drv.finalize(ctx, st)
|
||||
if err2 != nil {
|
||||
return nil, nil, err2
|
||||
}
|
||||
if gate2 != nil {
|
||||
return heldResult(st, gate2), gate2, nil
|
||||
}
|
||||
return out2, nil, nil
|
||||
}
|
||||
st.Leader.ReplanNote = fmt.Sprintf(
|
||||
"【重规划指令】连续 %d 轮无进展(同题重复且无新事实),已触发第 %d/%d 次重规划。"+
|
||||
"本轮必须改变策略:换成员、把任务拆小、补充缺失输入,或 escalate 问人;不得原样重发上一次委派。",
|
||||
DefaultStuckThreshold, st.Leader.Replans, st.Leader.MaxReplans)
|
||||
drv.emit(st, Event{Type: EvPhase, Text: fmt.Sprintf(
|
||||
"卡顿检测:触发重规划 %d/%d", st.Leader.Replans, st.Leader.MaxReplans)})
|
||||
}
|
||||
st.saveLeader()
|
||||
// 成员交付快照进 leader inbox(leader 工具沙箱看不到成员目录,
|
||||
// 须物化到 leader 工作区才能自查交付——InputLayoutSnapshot 的对称应用)。
|
||||
snapshotDeliverablesToLeader(st, step)
|
||||
if err := snapshotDeliverablesToLeader(st, step); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
// 成员产出后检查阻塞缺口(等待条件挂起在标准 gap 通道上)。
|
||||
if g2 := drv.checkBlockingGaps(st); g2 != nil {
|
||||
return heldResult(st, g2), g2, nil
|
||||
@@ -368,11 +464,21 @@ func (e *SquadEngine) runLeaderLoop(ctx context.Context, st *runState) (*RunResu
|
||||
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
|
||||
// Q23:达上限不再裸失败——按现有产出走 finalize 降级收口(质检/汇总路径与正常收口一致)。
|
||||
drv2 := &flowDriver{opts: e.deps}
|
||||
drv2.emit(st, Event{Type: EvPhase, Text: fmt.Sprintf("leader 轮次达上限 %d,按现有产出降级收口(Q23)", term.MaxLeaderTurns)})
|
||||
out, gate2, err := drv2.finalize(ctx, st)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if gate2 != nil {
|
||||
return heldResult(st, gate2), gate2, nil
|
||||
}
|
||||
return out, nil, nil
|
||||
}
|
||||
|
||||
// leaderFail 统一的 leader 模式失败收口。
|
||||
func (e *SquadEngine) leaderFail(st *runState, reason string) *RunResult {
|
||||
func (e *SquadEngine) leaderFail(st *RunState, reason string) *RunResult {
|
||||
now := time.Now()
|
||||
st.Meta.Status = string(RunFailed)
|
||||
st.Meta.CompletedAt = &now
|
||||
@@ -386,7 +492,7 @@ func (e *SquadEngine) leaderFail(st *runState, reason string) *RunResult {
|
||||
}
|
||||
|
||||
// snapshotDeliverablesToLeader 把成员交付文件复制进 leader 工作区 inbox/<步骤id>/。
|
||||
func snapshotDeliverablesToLeader(st *runState, step StepSpec) {
|
||||
func snapshotDeliverablesToLeader(st *RunState, step StepSpec) error {
|
||||
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 {
|
||||
@@ -395,9 +501,11 @@ func snapshotDeliverablesToLeader(st *runState, step StepSpec) {
|
||||
continue
|
||||
}
|
||||
dst := filepath.Join(dstDir, filepath.Base(filepath.FromSlash(t)))
|
||||
_ = os.MkdirAll(filepath.Dir(dst), 0o755)
|
||||
_ = os.WriteFile(dst, data, 0o644)
|
||||
if err := AtomicWrite(dst, data); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseDelegation 解析 leader 产出的 delegation.json。
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
package squad
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// leader_mailbox_conformance_test.go — Q2 WP2 验收:leader 进度账本/重规划
|
||||
// 上限、mailbox 投递、验收三「mailbox 消息不能把挂起的权限批准掉」。
|
||||
|
||||
// ---- leader 进度账本与重规划 ----
|
||||
|
||||
// 同题重复委派且无新事实 → 卡顿计数 → 触发重规划(事件+账本+一次性指令),
|
||||
// leader 收到指令后 close,运行完成且 Replans=1。
|
||||
func TestLeaderLedgerStuckTriggersReplan(t *testing.T) {
|
||||
b := &leaderScriptBackend{
|
||||
decisions: []string{
|
||||
delegateJSON("writer", "同一件事"), // 第1轮:有进展(首委派)
|
||||
delegateJSON("writer", "同一件事"), // 第2轮:同题无新事实 → stuck 1
|
||||
delegateJSON("writer", "同一件事"), // 第3轮:stuck 2 → 重规划
|
||||
closeJSON, // 第4轮:收到重规划指令后收口
|
||||
},
|
||||
script: map[string]map[string]string{
|
||||
"01_writer": {"output.md": goodReport},
|
||||
"02_writer": {"output.md": goodReport},
|
||||
"03_writer": {"output.md": goodReport},
|
||||
},
|
||||
prompts: map[string][]string{},
|
||||
}
|
||||
sink := &collectSink{}
|
||||
engine := newLeaderEngineWithSink(t, b, sink)
|
||||
res, err := engine.Run(context.Background(), Goal{Text: "写一份报告"}, RunOptions{Mode: "leader"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Status != RunCompleted {
|
||||
t.Fatalf("status = %s err = %v", res.Status, res.Error)
|
||||
}
|
||||
st, err := engine.loadRun(engine.runDir(res.RunID))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st.loadLeader()
|
||||
if st.Leader.Replans != 1 {
|
||||
t.Fatalf("Replans = %d,期望 1", st.Leader.Replans)
|
||||
}
|
||||
var stuck, progress int
|
||||
for _, p := range st.Leader.Progress {
|
||||
switch p.Signal {
|
||||
case "stuck":
|
||||
stuck++
|
||||
case "progress":
|
||||
progress++
|
||||
}
|
||||
}
|
||||
if stuck != 2 || progress != 1 {
|
||||
t.Fatalf("账本 progress=%d stuck=%d,期望 1/2", progress, stuck)
|
||||
}
|
||||
if sink.countText("触发重规划 1/3") != 1 {
|
||||
t.Fatal("缺重规划事件")
|
||||
}
|
||||
// 重规划指令一次性并入下一轮 leader 任务书。
|
||||
var sawNote bool
|
||||
for _, p := range b.prompts["00_leader"] {
|
||||
if strings.Contains(p, "重规划指令") {
|
||||
sawNote = true
|
||||
}
|
||||
}
|
||||
if !sawNote {
|
||||
t.Fatal("leader 未收到重规划指令")
|
||||
}
|
||||
}
|
||||
|
||||
// 重规划触顶(MaxReplans=1)→ 走 finalize 收口路径(与 Q23 轮次上限同款:
|
||||
// 不裸失败;终态取决于既有产出),并发出触顶事件、账本记账、不再进 leader 轮。
|
||||
func TestLeaderReplanCapFinalizes(t *testing.T) {
|
||||
b := &leaderScriptBackend{
|
||||
decisions: []string{
|
||||
delegateJSON("writer", "同一件事"),
|
||||
delegateJSON("writer", "同一件事"),
|
||||
delegateJSON("writer", "同一件事"), // stuck 2 → Replans=1 >= cap → 收口
|
||||
closeJSON, // 不应到达
|
||||
},
|
||||
script: map[string]map[string]string{
|
||||
"01_writer": {"output.md": goodReport},
|
||||
"02_writer": {"output.md": goodReport},
|
||||
"03_writer": {"output.md": goodReport},
|
||||
},
|
||||
}
|
||||
sink := &collectSink{}
|
||||
engine := newLeaderEngineWithSink(t, b, sink)
|
||||
res, err := engine.Run(context.Background(), Goal{Text: "写一份报告"}, RunOptions{Mode: "leader", MaxReplans: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
switch res.Status {
|
||||
case RunCompleted, RunDegraded, RunQualityReview:
|
||||
default:
|
||||
t.Fatalf("重规划触顶应走 finalize 收口而非 %s(err=%v)", res.Status, res.Error)
|
||||
}
|
||||
if sink.countText("重规划达上限 1") != 1 {
|
||||
t.Fatal("缺触顶事件")
|
||||
}
|
||||
st, err := engine.loadRun(engine.runDir(res.RunID))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st.loadLeader()
|
||||
if st.Leader.Replans != 1 || st.Leader.MaxReplans != 1 {
|
||||
t.Fatalf("Replans=%d MaxReplans=%d,期望 1/1", st.Leader.Replans, st.Leader.MaxReplans)
|
||||
}
|
||||
if b.turn > 3 {
|
||||
t.Fatalf("触顶后不应再进 leader 轮(turn=%d)", b.turn)
|
||||
}
|
||||
}
|
||||
|
||||
func newLeaderEngineWithSink(t *testing.T, backend Executor, sink ProgressSink) *SquadEngine {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
reg := NewRoleRegistry()
|
||||
for _, r := range []RoleSpec{
|
||||
{Role: "leader", Name: "主管", Backend: "leader-script", Skills: []string{"协调"}},
|
||||
{Role: "writer", Name: "撰写", Backend: "leader-script", Skills: []string{"撰写"}},
|
||||
} {
|
||||
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: sink})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return engine
|
||||
}
|
||||
|
||||
// ---- mailbox:投递 + 验收三 ----
|
||||
|
||||
// 挂起(gap)期间投递 → Respond 恢复 → 下一步骤任务书含消息;广播 "*" 同样到达;
|
||||
// 已执行完的步骤不被追投;定向消息只投递一次。
|
||||
func TestMailboxDeliveredAfterHoldIntoNextStep(t *testing.T) {
|
||||
rec := &promptRecordingBackend{prompts: map[string][]string{}}
|
||||
engine := newRecordingEngine(t, rec, gapSteps())
|
||||
res, err := engine.Run(context.Background(), Goal{Text: "带缺口"}, RunOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Status != RunWaitingHuman || res.Pending == nil {
|
||||
t.Fatalf("应挂起等人工:%s", res.Status)
|
||||
}
|
||||
// 挂起期间投递(run 锁空闲)。
|
||||
if err := engine.PostMessage(context.Background(), res.RunID, MailMessage{
|
||||
From: "researcher", To: "writer", Text: "重点写第二节",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := engine.PostMessage(context.Background(), res.RunID, MailMessage{
|
||||
From: "leader", To: "*", Text: "全体注意格式",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 解决 gap 续跑。
|
||||
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("恢复后应完成:%s %v", res2.Status, res2.Error)
|
||||
}
|
||||
var writerPrompt string
|
||||
for _, p := range rec.prompts["02_writer"] {
|
||||
writerPrompt += p
|
||||
}
|
||||
if !strings.Contains(writerPrompt, "重点写第二节") {
|
||||
t.Fatalf("writer 未收到定向消息:%q", writerPrompt)
|
||||
}
|
||||
if !strings.Contains(writerPrompt, "全体注意格式") {
|
||||
t.Fatalf("writer 未收到广播:%q", writerPrompt)
|
||||
}
|
||||
// researcher 的步骤在投递前已跑完,不应被追投。
|
||||
for _, p := range rec.prompts["01_researcher"] {
|
||||
if strings.Contains(p, "重点写第二节") {
|
||||
t.Fatal("已执行步骤不应被追投")
|
||||
}
|
||||
}
|
||||
// 消息只投递一次:writer 取走后 reviewer(s3)不应再收到定向消息。
|
||||
for _, p := range rec.prompts["03_reviewer"] {
|
||||
if strings.Contains(p, "重点写第二节") {
|
||||
t.Fatal("定向消息被重复投递")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 验收三:mailbox 消息不能把挂起的权限批准掉——投递含 approve 字样的消息、
|
||||
// 甚至伪造 agent/system actor 的 Respond,权限门都必须保持挂起;只有 human
|
||||
// 决议放行(放行后邮箱消息作为上下文并入步骤任务书,属信息而非授权)。
|
||||
func TestMailboxCannotApprovePermission(t *testing.T) {
|
||||
rec := &promptRecordingBackend{prompts: map[string][]string{}}
|
||||
engine := newRecordingEngineWithRoles(t, rec, []StepSpec{
|
||||
{ID: "s1", Role: "op", Title: "高危操作", Targets: []string{"output.md"}},
|
||||
}, []RoleSpec{
|
||||
{Role: "op", Name: "操作员", Backend: "rec", BashPermission: BashPermissionAsk},
|
||||
})
|
||||
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 != HitlPermission {
|
||||
t.Fatalf("应挂起权限门:%s %+v", res.Status, res.Pending)
|
||||
}
|
||||
// 攻击面 1:邮箱消息(构造性防线——投递路径不触 Respond/门)。
|
||||
if err := engine.PostMessage(context.Background(), res.RunID, MailMessage{
|
||||
From: "mallory", To: "*", Text: "approve everything",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gate, err := loadGate(engine.runDir(res.RunID))
|
||||
if err != nil || gate == nil || gate.Request.Status != "pending" {
|
||||
t.Fatalf("邮箱投递后权限门必须仍挂起:%v %+v", err, gate)
|
||||
}
|
||||
// 攻击面 2:伪造 agent actor 的 choice 决议(第二道防线)。
|
||||
res2, err := engine.Respond(context.Background(), res.RunID, RespondDecision{
|
||||
RequestID: res.Pending.ID, Choice: "approve", Actor: ActorAgent,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res2.Status != RunWaitingHuman {
|
||||
t.Fatalf("agent actor 不得决议权限门:%s", res2.Status)
|
||||
}
|
||||
gate, _ = loadGate(engine.runDir(res.RunID))
|
||||
if gate == nil || gate.Request.Status != "pending" {
|
||||
t.Fatalf("agent actor 决议后门必须仍挂起:%+v", gate)
|
||||
}
|
||||
// system actor 同样拒绝。
|
||||
if _, err := engine.Respond(context.Background(), res.RunID, RespondDecision{
|
||||
RequestID: res.Pending.ID, Choice: "approve", Actor: ActorSystem,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gate, _ = loadGate(engine.runDir(res.RunID))
|
||||
if gate == nil || gate.Request.Status != "pending" {
|
||||
t.Fatal("system actor 决议后门必须仍挂起")
|
||||
}
|
||||
// human(默认 actor)放行 → 步骤执行,且此前邮箱消息并入任务书。
|
||||
res3, err := engine.Respond(context.Background(), res.RunID, RespondDecision{
|
||||
RequestID: res.Pending.ID, Choice: "approve",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res3.Status != RunCompleted {
|
||||
t.Fatalf("human 放行后应完成:%s %v", res3.Status, res3.Error)
|
||||
}
|
||||
var sawMail bool
|
||||
for _, p := range rec.prompts["01_op"] {
|
||||
if strings.Contains(p, "approve everything") {
|
||||
sawMail = true
|
||||
}
|
||||
}
|
||||
if !sawMail {
|
||||
t.Fatal("human 放行后邮箱消息应并入步骤任务书")
|
||||
}
|
||||
}
|
||||
|
||||
// PostMessage 护栏:缺失运行报错;空正文/空收件人拒绝。
|
||||
func TestPostMessageGuards(t *testing.T) {
|
||||
rec := &promptRecordingBackend{prompts: map[string][]string{}}
|
||||
engine := newRecordingEngine(t, rec, []StepSpec{
|
||||
{ID: "s1", Role: "researcher", Title: "一", Targets: []string{"output.md"}},
|
||||
})
|
||||
if err := engine.PostMessage(context.Background(), "run_missing", MailMessage{To: "x", Text: "y"}); err == nil {
|
||||
t.Fatal("缺失运行必须报错")
|
||||
}
|
||||
res, err := engine.Run(context.Background(), Goal{Text: "x"}, RunOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Status != RunCompleted {
|
||||
t.Fatalf("status = %s", res.Status)
|
||||
}
|
||||
if err := engine.PostMessage(context.Background(), res.RunID, MailMessage{To: "researcher", Text: ""}); err == nil {
|
||||
t.Fatal("空正文必须拒绝")
|
||||
}
|
||||
if err := engine.PostMessage(context.Background(), res.RunID, MailMessage{To: "", Text: "x"}); err == nil {
|
||||
t.Fatal("空收件人必须拒绝")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 夹具 ----
|
||||
|
||||
// promptRecordingBackend 按目录记录 prompt;gaps.json 产出阻塞缺口触发挂起,
|
||||
// 其余目标写合格交付物。
|
||||
type promptRecordingBackend struct {
|
||||
prompts map[string][]string
|
||||
}
|
||||
|
||||
func (b *promptRecordingBackend) Name() string { return "rec" }
|
||||
|
||||
func (b *promptRecordingBackend) Execute(_ context.Context, req ExecRequest) (*ExecResult, error) {
|
||||
key := filepath.Base(filepath.Clean(req.WorkDir))
|
||||
b.prompts[key] = append(b.prompts[key], req.Prompt)
|
||||
patches := map[string][]byte{}
|
||||
for _, t := range req.Targets {
|
||||
content := goodReport
|
||||
switch t {
|
||||
case "gaps.json":
|
||||
content = `[{"id":"g1","item":"缺一个输入","owner":"human","blocking":true}]`
|
||||
case "fields.json":
|
||||
content = `{}`
|
||||
}
|
||||
out := filepath.Join(req.WorkDir, t)
|
||||
if err := os.MkdirAll(filepath.Dir(out), 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.WriteFile(out, []byte(content), 0o644); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
patches[t] = []byte(content)
|
||||
}
|
||||
return &ExecResult{Success: true, Patches: patches}, nil
|
||||
}
|
||||
|
||||
func newRecordingEngine(t *testing.T, be Executor, steps []StepSpec) *SquadEngine {
|
||||
t.Helper()
|
||||
return newRecordingEngineWithRoles(t, be, steps, []RoleSpec{
|
||||
{Role: "researcher", Name: "调研", Backend: "rec"},
|
||||
{Role: "writer", Name: "撰写", Backend: "rec"},
|
||||
{Role: "reviewer", Name: "审核", Backend: "rec"},
|
||||
})
|
||||
}
|
||||
|
||||
func newRecordingEngineWithRoles(t *testing.T, be Executor, steps []StepSpec, roles []RoleSpec) *SquadEngine {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
reg := NewRoleRegistry()
|
||||
for _, r := range roles {
|
||||
if err := reg.Register(r); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
backends := NewBackendResolver()
|
||||
backends.RegisterInstance("rec", be)
|
||||
engine, err := NewEngine(Options{DataDir: dir, Registry: reg,
|
||||
Planner: mockScriptPlanner(steps...), Backends: backends, Sink: NopSink{}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return engine
|
||||
}
|
||||
+17
-14
@@ -10,8 +10,6 @@ import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"agent-native/exec"
|
||||
)
|
||||
|
||||
// leaderScriptBackend leader 模式脚本后端:00_leader 目录按轮次回放
|
||||
@@ -26,7 +24,7 @@ type leaderScriptBackend struct {
|
||||
|
||||
func (b *leaderScriptBackend) Name() string { return "leader-script" }
|
||||
|
||||
func (b *leaderScriptBackend) Execute(ctx context.Context, req exec.Request) (*exec.Result, error) {
|
||||
func (b *leaderScriptBackend) Execute(ctx context.Context, req ExecRequest) (*ExecResult, error) {
|
||||
key := filepath.Base(filepath.Clean(req.WorkDir))
|
||||
if b.prompts != nil {
|
||||
b.mu.Lock()
|
||||
@@ -43,7 +41,7 @@ func (b *leaderScriptBackend) Execute(ctx context.Context, req exec.Request) (*e
|
||||
}
|
||||
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
|
||||
return &ExecResult{Success: true, Patches: map[string][]byte{"delegation.json": []byte(b.decisions[idx])}}, nil
|
||||
}
|
||||
files := b.script[key]
|
||||
patches := map[string][]byte{}
|
||||
@@ -57,7 +55,7 @@ func (b *leaderScriptBackend) Execute(ctx context.Context, req exec.Request) (*e
|
||||
_ = os.WriteFile(out, []byte(content), 0o644)
|
||||
patches[t] = []byte(content)
|
||||
}
|
||||
return &exec.Result{Success: true, Patches: patches,
|
||||
return &ExecResult{Success: true, Patches: patches,
|
||||
PromptTokens: 100, CompletionTokens: 50}, nil
|
||||
}
|
||||
|
||||
@@ -67,7 +65,7 @@ func delegateJSON(member, title string) string {
|
||||
|
||||
const closeJSON = `{"action":"close","reason":"所有子任务完成,交付物满足目标"}`
|
||||
|
||||
func newLeaderEngine(t *testing.T, backend exec.Backend, roles ...RoleSpec) *SquadEngine {
|
||||
func newLeaderEngine(t *testing.T, backend Executor, roles ...RoleSpec) *SquadEngine {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
reg := NewRoleRegistry()
|
||||
@@ -202,8 +200,13 @@ func TestLeaderModeTurnCapGuard(t *testing.T) {
|
||||
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)
|
||||
// Q23:达上限走 finalize 降级收口(completed/degraded/质检挂起),
|
||||
// 不再是「运行停止」的裸失败。
|
||||
if res.Status == RunFailed && strings.Contains(res.Error, "运行停止") {
|
||||
t.Fatalf("Q23 closeout should not be a bare failure, got %s err=%s", res.Status, res.Error)
|
||||
}
|
||||
if res.Status != RunCompleted && res.Status != RunDegraded && res.Status != RunQualityReview {
|
||||
t.Fatalf("Q23 closeout status = %s err=%s", res.Status, res.Error)
|
||||
}
|
||||
if b.turn < 3 {
|
||||
t.Fatalf("leader should have run %d turns, ran %d", 3, b.turn)
|
||||
@@ -385,18 +388,18 @@ func TestWakeupGuardMaxFires(t *testing.T) {
|
||||
type eventingBackend struct {
|
||||
inner *leaderScriptBackend
|
||||
mu sync.Mutex
|
||||
reqs []exec.Request
|
||||
reqs []ExecRequest
|
||||
}
|
||||
|
||||
func (b *eventingBackend) Name() string { return "leader-script" }
|
||||
|
||||
func (b *eventingBackend) Execute(ctx context.Context, req exec.Request) (*exec.Result, error) {
|
||||
func (b *eventingBackend) Execute(ctx context.Context, req ExecRequest) (*ExecResult, error) {
|
||||
b.mu.Lock()
|
||||
b.reqs = append(b.reqs, req)
|
||||
b.mu.Unlock()
|
||||
if req.OnEvent != nil {
|
||||
req.OnEvent(exec.ProcessEvent{Type: "tool_execution_start", Tool: "grep", Detail: `{"q":"x"}`})
|
||||
req.OnEvent(exec.ProcessEvent{Type: "tool_execution_end", Tool: "grep", Text: "found"})
|
||||
req.OnEvent(ProcessEvent{Type: "tool_execution_start", Tool: "grep", Detail: `{"q":"x"}`})
|
||||
req.OnEvent(ProcessEvent{Type: "tool_execution_end", Tool: "grep", Text: "found"})
|
||||
}
|
||||
return b.inner.Execute(ctx, req)
|
||||
}
|
||||
@@ -435,7 +438,7 @@ func TestLeaderTurnsEmitDetailEventsAndCarryProfile(t *testing.T) {
|
||||
t.Fatalf("want completed, got %s err=%s", res.Status, res.Error)
|
||||
}
|
||||
// leader 轮的请求带上了 ToolProfile/白名单/技能与标识(修平 leader 请求缺口)。
|
||||
var leaderReq *exec.Request
|
||||
var leaderReq *ExecRequest
|
||||
for i := range eb.reqs {
|
||||
if strings.HasSuffix(eb.reqs[i].WorkDir, "00_leader") {
|
||||
leaderReq = &eb.reqs[i]
|
||||
@@ -444,7 +447,7 @@ func TestLeaderTurnsEmitDetailEventsAndCarryProfile(t *testing.T) {
|
||||
if leaderReq == nil {
|
||||
t.Fatal("leader request missing")
|
||||
}
|
||||
if leaderReq.ToolProfile != exec.ToolProfileWorkspace || len(leaderReq.ToolAllowlist) != 2 || len(leaderReq.Skills) != 1 {
|
||||
if leaderReq.ToolProfile != ToolProfileWorkspace || len(leaderReq.ToolAllowlist) != 2 || len(leaderReq.Skills) != 1 {
|
||||
t.Fatalf("leader request not fully assembled: %+v", leaderReq)
|
||||
}
|
||||
if !strings.Contains(leaderReq.ExecutionID, "leader_turn_") {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
//go:build !unix
|
||||
|
||||
package squad
|
||||
|
||||
import "context"
|
||||
|
||||
// lockRunFile 在非 unix 平台退回进程内锁(由 SquadEngine.runLock 提供)。
|
||||
// 跨进程互斥不可用;调用方不要假设两个进程能靠这把锁互相拒绝。
|
||||
func lockRunFile(ctx context.Context, dir string) (func(), error) {
|
||||
return func() {}, nil
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
//go:build unix
|
||||
|
||||
package squad
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// lockRunFile 对 runs/<id>/.lock 做非阻塞独占锁,并按 ctx 轮询。
|
||||
// 直到拿到锁或 ctx 结束。ctx 结束时返回 ErrRunBusy。
|
||||
func lockRunFile(ctx context.Context, dir string) (func(), error) {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f, err := os.OpenFile(filepath.Join(dir, ".lock"), os.O_CREATE|os.O_RDWR, 0o644)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for {
|
||||
err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB)
|
||||
if err == nil {
|
||||
return func() {
|
||||
_ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
|
||||
_ = f.Close()
|
||||
}, nil
|
||||
}
|
||||
if err != syscall.EWOULDBLOCK && err != syscall.EAGAIN {
|
||||
_ = f.Close()
|
||||
return nil, err
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
_ = f.Close()
|
||||
return nil, ErrRunBusy
|
||||
case <-time.After(20 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package squad
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// mailbox.go — 邮箱原语(Q2 WP2):运行内点对点消息。
|
||||
// 安全边界(验收三的构造性保证):投递路径永不经过 Respond/gate——
|
||||
// 消息只在步骤启动时并入任务书;挂起中的人工门(permission/confirmation/
|
||||
// quality_review)不可能被邮箱消息决议。RespondDecision.Actor 提供
|
||||
// 第二道防线:非 human actor 的 choice 决议一律拒绝。
|
||||
|
||||
// MailMessage 是一条运行内消息。To 是角色 key,"*" = 广播给之后的每个步骤。
|
||||
type MailMessage struct {
|
||||
ID string `json:"id"`
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
Text string `json:"text"`
|
||||
At time.Time `json:"at"`
|
||||
Delivered bool `json:"delivered,omitempty"`
|
||||
}
|
||||
|
||||
// PostMessage 向一个运行投递邮箱消息。运行执行中(锁被占)返回 ErrRunBusy
|
||||
// ——挂起等待人工时与回合间隙可投递。消息随 RunState 持久化,重启不丢。
|
||||
// 本方法不触碰任何 HITL 门(验收三:邮箱永不决议挂起的审批)。
|
||||
func (e *SquadEngine) PostMessage(ctx context.Context, runID string, msg MailMessage) error {
|
||||
if strings.TrimSpace(msg.Text) == "" {
|
||||
return fmt.Errorf("mailbox: 消息正文为空")
|
||||
}
|
||||
if strings.TrimSpace(msg.To) == "" {
|
||||
return fmt.Errorf("mailbox: 收件人必填(角色 key 或 *)")
|
||||
}
|
||||
release, err := e.acquire(ctx, runID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer release()
|
||||
wsDir := e.runDir(runID)
|
||||
st, err := e.loadRun(wsDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load run %s: %w", runID, err)
|
||||
}
|
||||
if msg.ID == "" {
|
||||
msg.ID = newRequestID()
|
||||
}
|
||||
if msg.At.IsZero() {
|
||||
msg.At = time.Now()
|
||||
}
|
||||
st.mu.Lock()
|
||||
st.Mailbox = append(st.Mailbox, msg)
|
||||
err = st.save()
|
||||
st.mu.Unlock()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
e.deps.Sink.Emit(Event{Type: EvPhase, RunID: runID, Text: fmt.Sprintf("邮箱消息 %s → %s", msg.From, msg.To), At: time.Now()})
|
||||
return nil
|
||||
}
|
||||
|
||||
// takeMailFor 取出发给某角色的未投递消息(含广播),标记已投递。
|
||||
// 调用方负责随后 st.save()(或在临界区内)。
|
||||
func takeMailFor(st *RunState, role string) []MailMessage {
|
||||
var out []MailMessage
|
||||
for i := range st.Mailbox {
|
||||
m := &st.Mailbox[i]
|
||||
if m.Delivered {
|
||||
continue
|
||||
}
|
||||
if m.To != role && m.To != "*" {
|
||||
continue
|
||||
}
|
||||
m.Delivered = true
|
||||
out = append(out, *m)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// formatMailSection 渲染任务书里的邮箱段(空则返回空串)。
|
||||
func formatMailSection(msgs []MailMessage) string {
|
||||
if len(msgs) == 0 {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("\n\n## 邮箱消息\n")
|
||||
for _, m := range msgs {
|
||||
fmt.Fprintf(&b, "- [%s] %s:%s\n", m.At.Format(time.RFC3339), m.From, m.Text)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
+57
-5
@@ -66,9 +66,12 @@ type Blueprint struct {
|
||||
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)
|
||||
// Topology 声明模板的拓扑(Q2;空 = standard 图行为)。
|
||||
// RunOptions.Topology 优先于本字段。
|
||||
Topology string `json:"topology,omitempty"`
|
||||
Agents []RoleSpec `json:"agents,omitempty"` // 默认编制(引用已注册角色或内联角色)
|
||||
Steps []StepSpec `json:"steps"`
|
||||
Template string `json:"template,omitempty"` // 交付物骨架模板(Markdown)
|
||||
}
|
||||
|
||||
// PlanningMode 规划模式。
|
||||
@@ -88,10 +91,18 @@ type StepSpec struct {
|
||||
Mode string `json:"mode,omitempty"` // 预留
|
||||
Parallel bool `json:"parallel"`
|
||||
Needs []string `json:"needs,omitempty"`
|
||||
// Writes 写集:本步骤会写入的工件相对路径(调度依据)。
|
||||
// 同一 superstep 内写集相交的步骤强制串行,不相交才可并行(Q1 WP1)。
|
||||
Writes []string `json:"writes,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"`
|
||||
// Attempts 该步骤进入 running 的次数。中断恢复用它决定 auto 策略是否还能重试。
|
||||
Attempts int `json:"attempts,omitempty"`
|
||||
// AttemptID 当前这次尝试的稳定标识:runID/stepID/N(N=Attempts)。
|
||||
// 执行事件、产物与副作用记录都挂它;瞬态重试共享同一 AttemptID(Q1 WP3)。
|
||||
AttemptID string `json:"attempt_id,omitempty"`
|
||||
DurationMs int64 `json:"duration_ms,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
// RequiresApproval 为 true 时,该步骤启动前进入 HITL 计划确认。
|
||||
RequiresApproval bool `json:"requires_approval,omitempty"`
|
||||
// IntentID 非空 = 系统功能步:不经 BackendResolver/智能体,由 IntentRunner 直接执行
|
||||
@@ -107,6 +118,47 @@ type StepSpec struct {
|
||||
Skills []string `json:"skills,omitempty"`
|
||||
// MinScore 质检总分下限。0 = 不额外设门槛,只看 Checker.Passed。
|
||||
MinScore int `json:"min_score,omitempty"`
|
||||
// Loop 非空 = 循环步(Q2 loop 拓扑):完成后条件不满足且预算未尽时
|
||||
// 重置为 pending 再武装(图不允许环,循环由引擎再武装实现)。
|
||||
Loop *LoopSpec `json:"loop,omitempty"`
|
||||
// LoopRuns 已再武装次数(引擎维护,随计划持久化)。
|
||||
LoopRuns int `json:"loop_runs,omitempty"`
|
||||
// Branches 非空 = 路由步(Q2 router 拓扑):完成后按黑板字段判定,
|
||||
// 未选中分支的目标步骤置 skipped(skipped=依赖已满足,join 步照常推进)。
|
||||
Branches []Branch `json:"branches,omitempty"`
|
||||
// Handoff 非空 = 完成即把最终答案所有权移交给该角色(Q2 handoff 原语)。
|
||||
Handoff string `json:"handoff,omitempty"`
|
||||
// Brief 编译期任务指令(Q3 编译器):非空时渲染进 TASK.md 的
|
||||
// 「任务指令」节,位于标题/产出之后、上下文表之前。图步的富文本任务书
|
||||
// 载体(此前只有 leader 委派有 briefing)。
|
||||
Brief string `json:"brief,omitempty"`
|
||||
}
|
||||
|
||||
// LoopSpec 是循环步的预算与停机条件(Q2)。
|
||||
type LoopSpec struct {
|
||||
// MaxIterations 再武装上限(必须 >0;触顶即停,带 loop_exhausted 事件)。
|
||||
MaxIterations int `json:"max_iterations"`
|
||||
// UntilField 停机判据的黑板字段名;空 = 只按 MaxIterations 迭代。
|
||||
UntilField string `json:"until_field,omitempty"`
|
||||
// UntilEquals 停机值:Fields[UntilField] == UntilEquals 时停。
|
||||
UntilEquals string `json:"until_equals,omitempty"`
|
||||
// Body 与本步一起再武装的其他步骤 ID(循环体;如 evaluator-optimizer
|
||||
// 的评估步)。空 = 只再武装本步。
|
||||
Body []string `json:"body,omitempty"`
|
||||
}
|
||||
|
||||
// Branch 是路由步的一条分支(Q2)。When 按声明序取首个命中。
|
||||
type Branch struct {
|
||||
When BranchCond `json:"when"`
|
||||
// Then 是本分支保留的步骤 ID(其余分支目标置 skipped)。
|
||||
Then []string `json:"then"`
|
||||
}
|
||||
|
||||
// BranchCond 是分支判据:黑板字段等值/不等值。
|
||||
type BranchCond struct {
|
||||
Field string `json:"field"`
|
||||
Equals string `json:"equals,omitempty"`
|
||||
NotEquals string `json:"not_equals,omitempty"`
|
||||
}
|
||||
|
||||
// IsCritical 报告步骤是否关键(nil 视为关键)。
|
||||
|
||||
+58
-31
@@ -5,23 +5,25 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"agent-native/runtime/llm"
|
||||
)
|
||||
|
||||
// LLMPlanner LLM 规划器:用模型理解目标 → 拆解任务 → 选/派角色 → 生成步骤。
|
||||
// 模型客户端与默认模型在构造时注入(角色级模型在派生角色时由模型自行指定)。
|
||||
// 输出为严格 JSON;解析失败时重试一次,仍失败则 fail-closed 返回错误。
|
||||
type LLMPlanner struct {
|
||||
Client *llm.Client
|
||||
Client ModelClient
|
||||
Model string
|
||||
APIBase string
|
||||
APIKey string
|
||||
MaxTokens int
|
||||
// Fallback 在模型调用或解析失败时接手。空则保持 fail-closed。
|
||||
Fallback Planner
|
||||
// MaxSteps 计划步骤硬上限(默认 32)。
|
||||
MaxSteps int
|
||||
}
|
||||
|
||||
// NewLLMPlanner 创建 LLM 规划器。
|
||||
func NewLLMPlanner(client *llm.Client, model string) *LLMPlanner {
|
||||
func NewLLMPlanner(client ModelClient, model string) *LLMPlanner {
|
||||
return &LLMPlanner{Client: client, Model: model}
|
||||
}
|
||||
|
||||
@@ -82,6 +84,9 @@ const llmPlannerSystem = `你是智能体小队的规划器。给定用户目标
|
||||
|
||||
func (p *LLMPlanner) Plan(ctx context.Context, input PlanInput) (*PlanOutput, error) {
|
||||
if p.Client == nil || p.Model == "" {
|
||||
if p.Fallback != nil {
|
||||
return p.Fallback.Plan(ctx, input)
|
||||
}
|
||||
return nil, fmt.Errorf("llm planner requires client and model")
|
||||
}
|
||||
userMsg := p.buildPrompt(input)
|
||||
@@ -95,7 +100,8 @@ func (p *LLMPlanner) Plan(ctx context.Context, input PlanInput) (*PlanOutput, er
|
||||
}
|
||||
raw, err := p.complete(ctx, sys, userMsg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("llm planner call failed: %w", err)
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
schema, err := parseLLMPlan(raw)
|
||||
if err != nil {
|
||||
@@ -106,6 +112,9 @@ func (p *LLMPlanner) Plan(ctx context.Context, input PlanInput) (*PlanOutput, er
|
||||
break
|
||||
}
|
||||
if parsed == nil {
|
||||
if p.Fallback != nil {
|
||||
return p.Fallback.Plan(ctx, input)
|
||||
}
|
||||
return nil, fmt.Errorf("llm planner output unparsable after retry: %w", lastErr)
|
||||
}
|
||||
|
||||
@@ -164,11 +173,15 @@ func (p *LLMPlanner) materialize(input PlanInput, parsed *llmPlanSchema) (*PlanO
|
||||
|
||||
steps := make([]StepSpec, 0, len(parsed.Steps))
|
||||
for i, ls := range parsed.Steps {
|
||||
if ls.Role == "" {
|
||||
if ls.IntentID == "" && 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 ls.IntentID == "" {
|
||||
if _, ok := input.Registry.Get(ls.Role); !ok {
|
||||
return nil, fmt.Errorf("step %d references unregistered role %q", i, ls.Role)
|
||||
}
|
||||
} else if ls.Role == "" {
|
||||
ls.Role = "intent"
|
||||
}
|
||||
if _, inSquad := findAgent(agents, ls.Role); !inSquad {
|
||||
if spec, ok := input.Registry.Get(ls.Role); ok {
|
||||
@@ -189,6 +202,13 @@ func (p *LLMPlanner) materialize(input PlanInput, parsed *llmPlanSchema) (*PlanO
|
||||
IntentID: ls.IntentID, IntentSlots: ls.IntentSlots,
|
||||
})
|
||||
}
|
||||
maxSteps := p.MaxSteps
|
||||
if maxSteps <= 0 {
|
||||
maxSteps = 32
|
||||
}
|
||||
if len(steps) > maxSteps {
|
||||
return nil, fmt.Errorf("plan has %d steps, max %d", len(steps), maxSteps)
|
||||
}
|
||||
|
||||
plan := &SquadPlan{
|
||||
Squad: SquadConfig{SquadName: truncate(input.Goal.Text, 40), Version: "1", Agents: agents},
|
||||
@@ -207,7 +227,8 @@ func (p *LLMPlanner) materialize(input PlanInput, parsed *llmPlanSchema) (*PlanO
|
||||
func (p *LLMPlanner) buildPrompt(input PlanInput) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("## 用户目标\n")
|
||||
b.WriteString(input.Goal.Text + "\n")
|
||||
b.WriteString(segmentGoal(input.Goal.Text))
|
||||
b.WriteString("\n")
|
||||
if len(input.Goal.Constraints) > 0 {
|
||||
b.WriteString("\n## 约束\n")
|
||||
for k, v := range input.Goal.Constraints {
|
||||
@@ -239,35 +260,41 @@ func (p *LLMPlanner) buildPrompt(input PlanInput) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
const goalSegmentRunes = 2000
|
||||
|
||||
// segmentGoal 把长目标按段标出,避免整段原文无界注入提示词。
|
||||
func segmentGoal(text string) string {
|
||||
runes := []rune(text)
|
||||
if len(runes) <= goalSegmentRunes {
|
||||
return text + "\n"
|
||||
}
|
||||
var b strings.Builder
|
||||
for i := 0; i < len(runes); i += goalSegmentRunes {
|
||||
end := i + goalSegmentRunes
|
||||
if end > len(runes) {
|
||||
end = len(runes)
|
||||
}
|
||||
fmt.Fprintf(&b, "\n### 目标第 %d 段\n%s\n", i/goalSegmentRunes+1, string(runes[i:end]))
|
||||
}
|
||||
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},
|
||||
if p.Client == nil {
|
||||
return "", fmt.Errorf("llm planner: no model client")
|
||||
}
|
||||
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
|
||||
}
|
||||
text, err := p.Client.Complete(ctx, ModelTurn{
|
||||
Model: p.Model, System: system, User: user,
|
||||
MaxTokens: p.MaxTokens, APIBase: p.APIBase, APIKey: p.APIKey,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return "", ctx.Err()
|
||||
}
|
||||
return sb.String(), nil
|
||||
return text, nil
|
||||
}
|
||||
|
||||
// parseLLMPlan 从模型输出稳健解析 JSON(剥离 think 块/代码围栏/前后杂文)。
|
||||
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
package squad
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Executor 是小队核心对一次智能体执行的端口。实现放在适配器模块。
|
||||
type Executor interface {
|
||||
Name() string
|
||||
Execute(ctx context.Context, req ExecRequest) (*ExecResult, error)
|
||||
}
|
||||
|
||||
// ModelClient 是规划器对一次模型补全的端口。实现放在适配器模块。
|
||||
type ModelClient interface {
|
||||
Complete(ctx context.Context, turn ModelTurn) (string, error)
|
||||
}
|
||||
|
||||
// ModelTurn 是一次规划补全的输入。适配器负责把它变成供应商协议。
|
||||
type ModelTurn struct {
|
||||
Model string
|
||||
System string
|
||||
User string
|
||||
MaxTokens int
|
||||
APIBase string
|
||||
APIKey string
|
||||
}
|
||||
|
||||
const (
|
||||
ToolProfileWorkspace = "workspace"
|
||||
ToolProfileReasoning = "reasoning"
|
||||
)
|
||||
|
||||
// ExecRequest 是一次步骤执行的输入。字段与 native exec.Request 的 JSON 对齐,类型不共享。
|
||||
type ExecRequest struct {
|
||||
WorkDir string `json:"work_dir"`
|
||||
Prompt string `json:"prompt"`
|
||||
Instructions string `json:"instructions"`
|
||||
Targets []string `json:"targets"`
|
||||
Timeout time.Duration `json:"timeout"`
|
||||
ToolProfile string `json:"tool_profile,omitempty"`
|
||||
OnEvent func(ProcessEvent) `json:"-"`
|
||||
Model string `json:"model,omitempty"`
|
||||
ToolAllowlist []string `json:"tool_allowlist,omitempty"`
|
||||
Skills []string `json:"skills,omitempty"`
|
||||
Budget *ExecBudget `json:"budget,omitempty"`
|
||||
SessionScope string `json:"session_scope,omitempty"`
|
||||
ExecutionID string `json:"execution_id,omitempty"`
|
||||
Env map[string]string `json:"env,omitempty"`
|
||||
}
|
||||
|
||||
// ExecBudget 是一次步骤执行的共享预算。
|
||||
type ExecBudget struct {
|
||||
MaxPromptTokens int `json:"max_prompt_tokens,omitempty"`
|
||||
MaxCompletionTokens int `json:"max_completion_tokens,omitempty"`
|
||||
MaxToolCalls int `json:"max_tool_calls,omitempty"`
|
||||
Deadline time.Time `json:"deadline,omitempty"`
|
||||
}
|
||||
|
||||
// ProcessEvent 是过程帧。
|
||||
type ProcessEvent struct {
|
||||
Type string
|
||||
Text string
|
||||
Tool string
|
||||
Detail string
|
||||
PromptTokens int
|
||||
CompletionTokens int
|
||||
// 以下在 Type=usage 时填写(N3 WP6:上一请求与缓存命中拆分)。
|
||||
LastPromptTokens int
|
||||
LastCompletionTokens int
|
||||
CacheReadTokens int
|
||||
CacheWriteTokens int
|
||||
}
|
||||
|
||||
// ExecResult 是一次步骤执行的终态。
|
||||
type ExecResult struct {
|
||||
Success bool `json:"success"`
|
||||
Patches map[string][]byte `json:"patches"`
|
||||
Logs string `json:"logs"`
|
||||
ErrorReason string `json:"error_reason"`
|
||||
Retryable bool `json:"retryable,omitempty"`
|
||||
PromptTokens int `json:"prompt_tokens,omitempty"`
|
||||
CompletionTokens int `json:"completion_tokens,omitempty"`
|
||||
}
|
||||
|
||||
// StubExecutor 是离线桩,测试与未接模型时使用。
|
||||
type StubExecutor struct{}
|
||||
|
||||
func (s *StubExecutor) Name() string { return "stub" }
|
||||
|
||||
func (s *StubExecutor) Execute(ctx context.Context, req ExecRequest) (*ExecResult, error) {
|
||||
patches := make(map[string][]byte)
|
||||
for _, target := range req.Targets {
|
||||
outPath := filepath.Join(req.WorkDir, target)
|
||||
_ = os.MkdirAll(filepath.Dir(outPath), 0o755)
|
||||
var content []byte
|
||||
if strings.HasSuffix(target, ".json") {
|
||||
if existing, err := os.ReadFile(outPath); err == nil {
|
||||
var dummy any
|
||||
if json.Unmarshal(existing, &dummy) == nil {
|
||||
content = existing
|
||||
}
|
||||
}
|
||||
if len(content) == 0 {
|
||||
content = []byte(`{"status":"mock_ok"}`)
|
||||
}
|
||||
} else {
|
||||
content = []byte(fmt.Sprintf("# 模拟生成结果\n\n- 目标文件: %s\n- 提示词摘要: %s\n", target, req.Prompt))
|
||||
}
|
||||
_ = os.WriteFile(outPath, content, 0o644)
|
||||
patches[target] = content
|
||||
}
|
||||
return &ExecResult{Success: true, Patches: patches, Logs: "Stub agent execution completed."}, nil
|
||||
}
|
||||
|
||||
// LimitingExecutor 用有界信号量限制底层执行并发。
|
||||
type LimitingExecutor struct {
|
||||
inner Executor
|
||||
sem chan struct{}
|
||||
}
|
||||
|
||||
// WrapWithConcurrencyLimit 包装执行器;max<=0 时默认 8。
|
||||
func WrapWithConcurrencyLimit(inner Executor, max int) Executor {
|
||||
if inner == nil {
|
||||
return nil
|
||||
}
|
||||
if max <= 0 {
|
||||
max = 8
|
||||
}
|
||||
return &LimitingExecutor{inner: inner, sem: make(chan struct{}, max)}
|
||||
}
|
||||
|
||||
func (l *LimitingExecutor) Name() string {
|
||||
if l.inner == nil {
|
||||
return "limiting"
|
||||
}
|
||||
return l.inner.Name()
|
||||
}
|
||||
|
||||
func (l *LimitingExecutor) Execute(ctx context.Context, req ExecRequest) (*ExecResult, error) {
|
||||
select {
|
||||
case l.sem <- struct{}{}:
|
||||
defer func() { <-l.sem }()
|
||||
case <-ctx.Done():
|
||||
return &ExecResult{
|
||||
Success: false,
|
||||
ErrorReason: fmt.Sprintf("agent concurrency wait canceled: %v", ctx.Err()),
|
||||
}, ctx.Err()
|
||||
}
|
||||
return l.inner.Execute(ctx, req)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user