Compare commits
145
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2fb9ffa6d | ||
|
|
cff69355f7 | ||
|
|
ac91b8436e | ||
|
|
5c0d2d87dd | ||
|
|
afbb567301 | ||
|
|
2fc66be652 | ||
|
|
899fd70d27 | ||
|
|
9c24e220dd | ||
|
|
28f3ea7674 | ||
|
|
b6a843337c | ||
|
|
5b21325da7 | ||
|
|
42b22b436b | ||
|
|
14d48697a6 | ||
|
|
73ae2bb616 | ||
|
|
3cf8fa39b6 | ||
|
|
8d09aa0265 | ||
|
|
a625686ea9 | ||
|
|
c570f80e29 | ||
|
|
9c7dc6bf4a | ||
|
|
b1034e13ea | ||
|
|
6a1dcddb48 | ||
|
|
a5e3c7c817 | ||
|
|
6e972335c9 | ||
|
|
e86e74bc36 | ||
|
|
94afd71f00 | ||
|
|
c348441691 | ||
|
|
7f60baeaf8 | ||
|
|
ef2fa75006 | ||
|
|
a5da18b710 | ||
|
|
d6267fba15 | ||
|
|
1f25049492 | ||
|
|
76b6171de5 | ||
|
|
3b92cb63b4 | ||
|
|
37c5e6fbd6 | ||
|
|
cdbdeb0b14 | ||
|
|
1ac21c68d4 | ||
|
|
ea2689641f | ||
|
|
2eccf3f6f5 | ||
|
|
28e237f646 | ||
|
|
16fdad92c4 | ||
|
|
87ff3946df | ||
|
|
0e03cc2144 | ||
|
|
bdb45c3c2c | ||
|
|
f04c87ac31 | ||
|
|
f2fbefa2b9 | ||
|
|
e8f4e097a7 | ||
|
|
fe0c4a9f82 | ||
|
|
fc0558fc93 | ||
|
|
79e1a8fa85 | ||
|
|
e2be33f008 | ||
|
|
cb21ec4e2a | ||
|
|
5abb231efe | ||
|
|
74aa60bf2e | ||
|
|
b179452793 | ||
|
|
7425b32b9e | ||
|
|
e1fac5864d | ||
|
|
77e7f97827 | ||
|
|
fcd2539b9e | ||
|
|
d6053baf4c | ||
|
|
e63a9f38dd | ||
|
|
64b8bd4c98 | ||
|
|
ae42c9402d | ||
|
|
ead690be30 | ||
|
|
4a7e19ad62 | ||
|
|
3a1ad5afc7 | ||
|
|
6ab3b8677d | ||
|
|
78b6c6477c | ||
|
|
108c194c8d | ||
|
|
6ea6929309 | ||
|
|
d9a168635e | ||
|
|
c9c2f23990 | ||
|
|
16e356a3a4 | ||
|
|
d333094f7e | ||
|
|
0d8edbea52 | ||
|
|
7f2c8b9ee1 | ||
|
|
c5b9dca446 | ||
|
|
948ca36ca2 | ||
|
|
c2befaf17e | ||
|
|
d5714e81ed | ||
|
|
9758a8d8d0 | ||
|
|
fc5a1930b5 | ||
|
|
16a164a594 | ||
|
|
3a110a728c | ||
|
|
ecca2d74b9 | ||
|
|
e4020d297c | ||
|
|
b6d6708416 | ||
|
|
fb2b3b796c | ||
|
|
ba90a7d9ae | ||
|
|
0a52d9f976 | ||
|
|
3851bfb8a7 | ||
|
|
6fd1d441a9 | ||
|
|
9335fd40e6 | ||
|
|
9f5751c205 | ||
|
|
e51f2e6d00 | ||
|
|
d84a67cd24 | ||
|
|
33573d0d75 | ||
|
|
4a10818535 | ||
|
|
d9b0f8f178 | ||
|
|
855fe41152 | ||
|
|
11d1a76bdd | ||
|
|
17f2a4ecfa | ||
|
|
b1f89bf969 | ||
|
|
56848f3cbd | ||
|
|
91c1556184 | ||
|
|
0773a25ab9 | ||
|
|
694b08cdbe | ||
|
|
7e0374ff40 | ||
|
|
e047d90985 | ||
|
|
c6d867cde7 | ||
|
|
1a999c344d | ||
|
|
a34689c78f | ||
|
|
e2815bbf94 | ||
|
|
35f65a3c49 | ||
|
|
886e86c6a8 | ||
|
|
6b7d2432b3 | ||
|
|
23b5e3f336 | ||
|
|
dfd0d23bf3 | ||
|
|
2dedc0d20c | ||
|
|
e7ab48e8ea | ||
|
|
6bc1b08540 | ||
|
|
ec731addef | ||
|
|
cfc840c11d | ||
|
|
951c141f95 | ||
|
|
f9ab2ef698 | ||
|
|
001648cd0e | ||
|
|
d5a98e3f18 | ||
|
|
7710be2db4 | ||
|
|
b73611939e | ||
|
|
b2638da64c | ||
|
|
e4c48eb405 | ||
|
|
fe5a973f15 | ||
|
|
b105e71533 | ||
|
|
dd2ac11b31 | ||
|
|
39be648531 | ||
|
|
c90bc8ee17 | ||
|
|
228daa3ff5 | ||
|
|
9b77bf4d6d | ||
|
|
f735f18a73 | ||
|
|
5e489ab2cb | ||
|
|
cc8a6a7702 | ||
|
|
7af37906d9 | ||
|
|
e65c6ba604 | ||
|
|
57c049bd38 | ||
|
|
49e6dd1e0f | ||
|
|
3f0c6b2277 |
@@ -0,0 +1,13 @@
|
|||||||
|
# CodeGraph data files — local to each machine, not for committing.
|
||||||
|
# Ignore everything in .codegraph/ except this file itself. Known transients:
|
||||||
|
# SQLite database and WAL/SHM, daemon.pid, sockets, and logs. The catch-all
|
||||||
|
# covers anything codegraph init adds later.
|
||||||
|
codegraph.db
|
||||||
|
codegraph.db-wal
|
||||||
|
codegraph.db-shm
|
||||||
|
codegraph.db-journal
|
||||||
|
daemon.pid
|
||||||
|
*.sock
|
||||||
|
*.log
|
||||||
|
*
|
||||||
|
!.gitignore
|
||||||
+38
-5
@@ -6,15 +6,48 @@ on:
|
|||||||
branches: [main]
|
branches: [main]
|
||||||
jobs:
|
jobs:
|
||||||
vet-and-test:
|
vet-and-test:
|
||||||
runs-on: nightly
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
GOPROXY: https://goproxy.cn,direct
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- name: Checkout
|
||||||
- run: go vet ./... && go test -count=1 ./...
|
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}@git-home.u1680832.nyat.app:30319/".insteadOf "ssh://git@git-ssh.u1680832.nyat.app:31411/"
|
||||||
|
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: go vet ./... && go test -count=1 ./... && go test -count=1 -race ./...
|
||||||
docker-checkout-hostgateway:
|
docker-checkout-hostgateway:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
options: --add-host gitea:host-gateway
|
options: --add-host gitea:host-gateway
|
||||||
env:
|
env:
|
||||||
GITHUB_SERVER_URL: http://gitea:3000
|
GITHUB_SERVER_URL: http://gitea:3000
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- name: Checkout without GitHub actions
|
||||||
- run: test -f go.mod && echo "docker-mode checkout via host-gateway OK"
|
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@git-ssh.u1680832.nyat.app:31411/weichen/"
|
||||||
|
# 审查过的白名单,独立于候选 .gitmodules:仅列出两个治理 gitlink。
|
||||||
|
REPOSITORIES = {
|
||||||
|
"deps/development-guidelines": "Development-Guidelines",
|
||||||
|
"deps/development-manager": "Development-Manager",
|
||||||
|
}
|
||||||
|
TARGET = "weichen/Agent-Native"
|
||||||
|
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 .
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# CodeGraph local index. A second layer so the database stays untracked even
|
||||||
|
# if .codegraph/.gitignore is overwritten by codegraph init.
|
||||||
|
.codegraph/*
|
||||||
|
!.codegraph/.gitignore
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
[submodule "deps/development-guidelines"]
|
||||||
|
path = deps/development-guidelines
|
||||||
|
url = ssh://git@git-ssh.u1680832.nyat.app:31411/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
|
||||||
|
[submodule "deps/logging-system"]
|
||||||
|
path = deps/logging-system
|
||||||
|
url = ssh://git@git-ssh.u1680832.nyat.app:31411/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
|
||||||
|
[submodule "deps/development-manager"]
|
||||||
|
path = deps/development-manager
|
||||||
|
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Development-Manager.git
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# Agent-Native 采纳 development-governance-v2 治理套件 任务卡
|
||||||
|
|
||||||
|
任务主记录:[Agent-Native PR #47](https://git-home.u1680832.nyat.app:30319/weichen/Agent-Native/pulls/47)(本文件随 PR 描述维护)。
|
||||||
|
状态:已完成(闭合随本 PR 合入;合入以平台记录为准);owner:weichen。
|
||||||
|
|
||||||
|
## 基线与授权
|
||||||
|
|
||||||
|
- 权威仓:weichen/Agent-Native;root_target:main;immediate_target:main
|
||||||
|
- branch_kind:initiative;initiative_id:governance-adoption-Agent-Native
|
||||||
|
- R0(initial_main_sha):c348441691e8bdab784a03aa9be64d53dacff341;工作区:/home/weichen/projects/Agent-Native-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。
|
||||||
|
|
||||||
|
引导说明:AGENT-EXECUTION.md 由 host 以固定版本治理套件(dm-server governance init @ bd3a7de57a3a9e1a4495b94c5fa5c08d71e7be8c)生成并作为引导配置提交(执行器运行前必须存在有效配置;沿用 DM 采纳先例);其余文件由实施单元生成。
|
||||||
|
|
||||||
|
## Agent 实施收据
|
||||||
|
|
||||||
|
| 单元 ID | 预期范围 | 角色 | 候选顺序 | attempt | CLI/模型 | 终态 | transport | fallback | 源码外收据路径 |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| governance-adoption-Agent-Native-r1 | 13 个允许路径(治理文件 + 两个 gitlink) | b1034e13 | implementation | cursor→antigravity→pi | run 18dcfcc1(dm-server pinned,attempts=1) | cursor / not-observable | success | 9c7dc6bf4a65(apply 快进) | 否 | /tmp/gov-receipt-Agent-Native-r1.json |
|
||||||
|
| governance-adoption-Agent-Native-repair-r1 | policy/guidelines-adoption.md | repair | cursor→antigravity→pi | run 734e0741(attempts=1) | cursor / not-observable | success | a625686ea9f4(apply 快进) | 否 | /tmp/gov-receipt-Agent-Native-repair-r1.json |
|
||||||
|
|
||||||
|
## 同步记录
|
||||||
|
|
||||||
|
| 节点 | sync_base_sha | tested_head_sha | checked_at | 结果 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 启动 | c348441691e8bdab784a03aa9be64d53dacff341 | c348441691e8bdab784a03aa9be64d53dacff341 | 2026-10-17T07:17:40Z | 一致 |
|
||||||
|
| 请求审核 | c348441691e8bdab784a03aa9be64d53dacff341 | 8d09aa0265d7 | 2026-10-17T08:53:09Z | 已含 origin/main |
|
||||||
|
|
||||||
|
## 结果与验证(完成后填写)
|
||||||
|
|
||||||
|
- 结果:治理套件已采纳——Guidelines 与 Development-Manager gitlink 固定、AGENT-EXECUTION.md 四条路由、共享执行器薄入口、pre-commit admit、governance CI;采纳状态 active 随本 PR 合入生效。
|
||||||
|
- 验证:见下表(命令输出由 /tmp/gov-neg/ev.sh 写入 .task/evidence/governance-adoption/,SELF 除非另注)。
|
||||||
|
- 复现:正负例脚本冻结副本 Agent-System-Dev `docs/superpowers/plans/evidence/governance-adopt-cases.py`(sha256 3aa1bf6ea1cd48f3f59c0a307620e4112389a93fe21a113006f54e62eb8f0d06),用法 `python3 governance-adopt-cases.py <仓库根>`;它经本仓 `scripts/agent-exec` 运行假 CLI,不访问网络。
|
||||||
|
- 影响与剩余项:不改业务代码;本地启用钩子需由人执行 `git config core.hooksPath scripts/hooks`;将 governance 检查设为分支保护必需项属用户双钥匙事项(命令见 Agent-System-Dev 治理主记录)。
|
||||||
|
|
||||||
|
| 时间(UTC) | 命令 | 被测 HEAD | exit | 来源 | 摘要/位置 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 20261017T073822Z | `scripts/dm governance check --repo .` | 9c7dc6bf4a65 | 0 | SELF | ok fixtures;[输出](../evidence/governance-adoption/00-20261017T073822Z.log) sha256:3694b7fcd797887a |
|
||||||
|
| 20261017T073822Z | `scripts/agent-exec validate --repo .` | 9c7dc6bf4a65 | 0 | SELF | validate: ok;[输出](../evidence/governance-adoption/01-20261017T073822Z.log) sha256:90017bfe574eef56 |
|
||||||
|
| 20261017T073822Z | `python3 /tmp/gov-neg/adopt_cases.py .` | 9c7dc6bf4a65 | 0 | SELF | [verdict] ALL PASS cases=5;[输出](../evidence/governance-adoption/02-20261017T073822Z.log) sha256:571116eee5eb098d |
|
||||||
|
| 20261017T083654Z | `scripts/dm governance check --repo .` | a625686ea9f4 | 0 | SELF | ok fixtures;[输出](../evidence/governance-adoption/03-20261017T083654Z.log) sha256:075f18ade3c51e71 |
|
||||||
|
| 20261017T083654Z | `scripts/agent-exec validate --repo .` | a625686ea9f4 | 0 | SELF | validate: ok;[输出](../evidence/governance-adoption/04-20261017T083654Z.log) sha256:29a607257c052021 |
|
||||||
|
| 2026-10-17T08:53:09Z | CI `ci / docker-checkout-hostgateway (pull_request)` | 8d09aa0265d7 | 0 | GATE | success;[运行](https://git-home.u1680832.nyat.app:30319/weichen/Agent-Native/actions/runs/3590/jobs/5679) |
|
||||||
|
| 2026-10-17T08:53:09Z | CI `governance / governance (pull_request)` | 8d09aa0265d7 | 0 | GATE | success;[运行](https://git-home.u1680832.nyat.app:30319/weichen/Agent-Native/actions/runs/3591/jobs/5680) |
|
||||||
|
| 2026-10-17T08:53:09Z | CI `ci / vet-and-test (pull_request)` | 8d09aa0265d7 | 0 | GATE | success;[运行](https://git-home.u1680832.nyat.app:30319/weichen/Agent-Native/actions/runs/3590/jobs/5678) |
|
||||||
|
| — | 候选 HEAD CI | 3cf8fa39b631 | 0 | GATE | success ci / docker-checkout-hostgateway (pull_request)=success(/weichen/Agent-Native/actions/runs/3614/jobs/5713);governance / governance (pull_request)=success(/weichen/Agent-Native/actions/runs/3615/jobs/5714);ci / vet-and-test (pull_request)=success(/weichen/Agent-Native/actions/runs/3614/jobs/5712) |
|
||||||
|
| — | 独立 Review | 3cf8fa39b631 | 0 | REPLAY | cursor-agent-stable --mode ask 全新只读会话(fresh_session=true,session id not-observable)闭合重审 VERDICT: APPROVE(对象 3cf8fa3) |
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
command: scripts/dm governance check --repo .
|
||||||
|
cwd: /home/weichen/projects/Agent-Native-wt-feature-governance-adoption
|
||||||
|
head: 9c7dc6bf4a658cb0a1ab17173964cbdc154daf9d
|
||||||
|
exit: 0
|
||||||
|
utc: 20261017T073822Z
|
||||||
|
---
|
||||||
|
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-Native-wt-feature-governance-adoption
|
||||||
|
head: 9c7dc6bf4a658cb0a1ab17173964cbdc154daf9d
|
||||||
|
exit: 0
|
||||||
|
utc: 20261017T073822Z
|
||||||
|
---
|
||||||
|
validate: ok
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
command: python3 /tmp/gov-neg/adopt_cases.py .
|
||||||
|
cwd: /home/weichen/projects/Agent-Native-wt-feature-governance-adoption
|
||||||
|
head: 9c7dc6bf4a658cb0a1ab17173964cbdc154daf9d
|
||||||
|
exit: 0
|
||||||
|
utc: 20261017T073822Z
|
||||||
|
---
|
||||||
|
[object] Agent-Native-wt-feature-governance-adoption HEAD=9c7dc6bf4a658cb0a1ab17173964cbdc154daf9d
|
||||||
|
PASS positive-run-apply: run exit=0 terminal=success apply exit=0 head=232696a2f8ab
|
||||||
|
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-Native-wt-feature-governance-adoption
|
||||||
|
head: a625686ea9f4448fd609e7dfff33f4680ce8f834
|
||||||
|
exit: 0
|
||||||
|
utc: 20261017T083654Z
|
||||||
|
---
|
||||||
|
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-Native-wt-feature-governance-adoption
|
||||||
|
head: a625686ea9f4448fd609e7dfff33f4680ce8f834
|
||||||
|
exit: 0
|
||||||
|
utc: 20261017T083654Z
|
||||||
|
---
|
||||||
|
validate: ok
|
||||||
@@ -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"
|
||||||
|
```
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Agent 工作说明(Agent-Native)
|
||||||
|
|
||||||
|
本仓开发遵循 Development-Guidelines(Dev 仓 `deps/development-guidelines`,gitlink 固定版本):
|
||||||
|
核心规则见其 `core/`,按任务触发阅读 `standards/` 专题;日常交付走 `workflows/delivery.md`。
|
||||||
|
|
||||||
|
## 任务启动门禁
|
||||||
|
|
||||||
|
- 任务启动或恢复后,第一次写操作前在 Dev 仓运行 `make precheck` 并附完整输出(本仓作为子仓随父仓编排);
|
||||||
|
本仓独立开发时核对工作树 clean、与权威 origin/main 同步,并按任务触发读对应专题。
|
||||||
|
- 从权威 origin/main 固定共同起点 R0,建 feature|fix|hotfix/<slug> 分支(Dev 仓 `make task-start` 自动化);
|
||||||
|
任务中途不重复 precheck。
|
||||||
|
- 验收、请求审核、合入前各同步一次最新目标分支并附证据;合入后回收任务工作区与已合入分支。
|
||||||
|
- 提交主题行:英文祈使句一行(≤100 列,不以句点结尾);body 写 `Why: / Evidence: / Impact: / Refs:`。
|
||||||
|
|
||||||
|
## 仓内规则
|
||||||
|
|
||||||
|
- 模块边界、公开契约与隔离档位见 `policy/modules/`(如有)与 PROJECT.md。
|
||||||
|
- 跨仓 gitlink 变更须经组合验证后在 Dev 仓推进;受保护分支经 PR 合入。
|
||||||
|
- 验证命令见 PROJECT.md「验证」节;完成声明须附实际命令输出证据。
|
||||||
|
|
||||||
|
## Gitea 与 Git 访问事实(2026-09-30 实测)
|
||||||
|
|
||||||
|
历史文档与旧链接里的 `git-panel.u1680832.nyat.app:30319` 已失效(TLS 握手失败),
|
||||||
|
见到一律按过期处理,不要再试。当前权威通道:
|
||||||
|
|
||||||
|
- **Web / API(HTTPS)**:`https://git-home.u1680832.nyat.app:30319`(Gitea 1.27.3,
|
||||||
|
API 前缀 `/api/v1`;偶发 broken pipe / 超时,重试即可)。
|
||||||
|
- **Git over SSH**:`ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/<Repo>.git`
|
||||||
|
(各仓 origin 与 gitlink 均为此格式)。
|
||||||
|
- **API token**:`~/.secrets/gitea`,两行键值 `URL_PREFIX=…` 与
|
||||||
|
`ACCESS_TOKEN=<40 位 hex>`;取值 `grep ACCESS_TOKEN ~/.secrets/gitea | cut -d= -f2-`,
|
||||||
|
用作 `Authorization: token <值>` 请求头。**token 值不入仓、不回显、不写进产出物**
|
||||||
|
(baseline 硬边界 2:只记录来源、字段与可用状态)。
|
||||||
|
- **SSH key**:`~/.ssh/home_gitea`(ED25519,公钥指纹
|
||||||
|
`SHA256:GYXaOXIMeloQ1fKSfdJ/ndgPQzs/Ix7Hk62ekfkfQ20`,Gitea 侧密钥名 GMKtec);
|
||||||
|
`~/.ssh/config` 已有 `git-ssh.u1680832.nyat.app` 主机条目,克隆/推送直接可用。
|
||||||
|
|
||||||
|
## Agent 执行与新项目准入(强制)
|
||||||
|
|
||||||
|
AI 实施单元只经 `scripts/agent-exec run` 执行(路由与候选见 [AGENT-EXECUTION.md](AGENT-EXECUTION.md) 与 [Agent 执行与协作契约](deps/development-guidelines/standards/agent-execution.md)),并经 `scripts/agent-exec apply` 集成。
|
||||||
|
|
||||||
|
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`
|
||||||
+54
@@ -0,0 +1,54 @@
|
|||||||
|
# Agent-Native 项目事实
|
||||||
|
|
||||||
|
工作规则见 [AGENTS.md](AGENTS.md)。此文件维护本项目事实,不复制通用规范。
|
||||||
|
|
||||||
|
## 仓库与操作
|
||||||
|
|
||||||
|
- 目标/产品边界:独立的 Agent 执行基础库。提供执行契约(contract)、执行端口(exec)、自研运行时(runtime)、外部适配器(adapters)与行为验收(conformance)。不实现小队编排,不依赖 Core 或 Squad。
|
||||||
|
- 源码、资产、文档、机器契约位置:Go 源码在仓库根包目录;模块路径 `agent-native`;人类文档在根 `README.md`、`docs/`、`policy/`;过程记录在 `docs/records/`;机器契约以 `contract` 包类型为准。
|
||||||
|
- 权威远端、集成基线、受保护分支:`ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Native.git`,集成基线 `main`。
|
||||||
|
- 并行共同基线记录位置、合入通知/主动检查方式:任务主记录在 Gitea Issue/PR;合入前在本仓 `main` 上 `git fetch` 并确认 HEAD。
|
||||||
|
- 发布线与批准版本/实际制品查询入口:当前无独立制品仓库。可构建性以本仓 `main` 的 `go test` 为准;依赖子模块版本以 gitlink 为准,不把工作区未提交文件当发布组合。
|
||||||
|
- 提交/审核/合并/发布身份与权限:仓库所有者 weichen。本机提交不代替远端合并。
|
||||||
|
- 任务唯一来源:Gitea `weichen/Agent-Native` 的 Issue 与 Pull Request。
|
||||||
|
- 规范性文档目录:`policy/`。
|
||||||
|
- 过程文档仓/目录:`docs/records/`(项目内豁免,理由见 [采纳记录](policy/guidelines-adoption.md))。
|
||||||
|
- 交接记录位置:随任务主记录;需要落盘的交接放入 `docs/records/`。
|
||||||
|
- 凭证查找入口及所需字段:模型与 Runner 凭证由调用方注入,不入库。本仓不保存密钥字段的值。
|
||||||
|
|
||||||
|
## 依赖组合
|
||||||
|
|
||||||
|
| 逻辑 ID | 路径 | 角色 |
|
||||||
|
|---|---|---|
|
||||||
|
| development-guidelines | `deps/development-guidelines` | development-only,规范文本 |
|
||||||
|
| error-code-system | `deps/error-code-system` | build-required,规范码目录 |
|
||||||
|
| logging-system | `deps/logging-system` | build-required,可选日志器 |
|
||||||
|
| agent-intent | `deps/agent-intent` | build-required,可选意图闸门装饰器(`adapters/intentgate`) |
|
||||||
|
|
||||||
|
克隆:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone --recurse-submodules ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Native.git
|
||||||
|
# 已克隆时:git submodule update --init --recursive
|
||||||
|
```
|
||||||
|
|
||||||
|
构建、审核和发布固定父仓 gitlink,不执行 `submodule update --remote`。
|
||||||
|
|
||||||
|
## 验证命令
|
||||||
|
|
||||||
|
| 影响 | 工作目录与真实命令/CI | 前提与不覆盖范围 |
|
||||||
|
|---|---|---|
|
||||||
|
| 文档 | 人工核对 `AGENTS.md`、`PROJECT.md`、`policy/` 相对链接 | 不证明运行时行为 |
|
||||||
|
| 模块/接口 | 仓库根:`go vet ./... && go test -count=1 ./...` | 需要 Go 1.27;不覆盖真实模型端点 |
|
||||||
|
| 数据/权限/恢复 | 不适用独立数据库。契约测试覆盖副作用分类与取消传播 | 不证明调用方产品库的持久化 |
|
||||||
|
| UI/执行后端 | `go test ./adapters/... ./runtime/... ./conformance/...` | 默认 fake/stub;真实模型需调用方提供端点,本命令不覆盖 |
|
||||||
|
| 治理与 Agent 执行配置 | 仓库根(需在暂存副本中先 `git add -A`,gitlink 须在索引中):`scripts/dm governance check --repo .` 与 `scripts/agent-exec validate --repo .` | 只核验采纳文件、固定组合与 `AGENT-EXECUTION.md` 配置,不证明执行结果 |
|
||||||
|
|
||||||
|
## 环境与数据
|
||||||
|
|
||||||
|
| 环境/实例 ID | 只读查询入口 | 授权写入入口 | DB/存储归属 | 恢复入口 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 本地开发 | 本机工作区与 `go test` | 本机 git 提交 | 无共享数据库;会话文件由调用方 Workspace 决定 | 重新 clone 父仓并 `submodule update --init --recursive` |
|
||||||
|
|
||||||
|
共享项及 ACL:无。部署、迁移、发布的审批依据与负责角色:当前库不独立部署;合并进 `main` 由仓库所有者批准。
|
||||||
|
实际制品/配置/schema 的查询方法:阅读当前 `main` 的 `contract` 包与 `go.mod`,不以历史 README 快照代替。
|
||||||
@@ -9,9 +9,9 @@
|
|||||||
|
|
||||||
| 层 | 职责 | 依赖边界 |
|
| 层 | 职责 | 依赖边界 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| contract | 执行契约:Request/Result/Outcome/Error/Capability/Backend/Resolver | 不依赖内核、产品类型、数据库、配置 |
|
| contract | 执行契约:Request/Result/Outcome/Error/Capability/Backend/Resolver | 不依赖内核、产品类型、数据库、配置,也不导入错误码库 |
|
||||||
| exec | 执行端口:Backend/Request/Result/ProcessEvent、步骤重试与失败分类、交付物物化、Stub、并发限流 | 面向调用方的执行端口 |
|
| exec | 执行端口:Backend/Request/Result/ProcessEvent、预算、步骤重试与失败分类、交付物物化、Stub、并发限流 | 面向调用方的执行端口 |
|
||||||
| runtime | 多轮循环、工具调度、模型调用、执行会话 | 实现 contract;不导入 Core/Squad |
|
| runtime | 多轮循环、工具调度、模型调用、执行会话 | 不导入 Core/Squad;可选使用日志库,nil 日志器不改变结果 |
|
||||||
| adapters | PI Runner transport / typed-result-v1;native:自研内核 runtime → exec.Backend(规范后端名 agent-native,含会话毒化轮转——resume-unsafe 失败后自动换新会话) | 显式映射外部差异,不伪造缺失能力 |
|
| adapters | PI Runner transport / typed-result-v1;native:自研内核 runtime → exec.Backend(规范后端名 agent-native,含会话毒化轮转——resume-unsafe 失败后自动换新会话) | 显式映射外部差异,不伪造缺失能力 |
|
||||||
| conformance | 所有 Backend 共用的行为验收 | fake 验证契约,真实 adapter 验证能力 |
|
| conformance | 所有 Backend 共用的行为验收 | fake 验证契约,真实 adapter 验证能力 |
|
||||||
|
|
||||||
@@ -23,9 +23,13 @@
|
|||||||
|---|---|
|
|---|---|
|
||||||
| contract | 四态执行契约 |
|
| contract | 四态执行契约 |
|
||||||
| conformance | 行为验收套件与 fake |
|
| conformance | 行为验收套件与 fake |
|
||||||
| exec | Backend/Request/Result/ProcessEvent、步骤重试与失败分类、交付物物化、Stub、并发限流 |
|
| exec | Backend/Request/Result/ProcessEvent(v0.2:Model/ToolAllowlist/Skills/Budget/SessionScope/ExecutionID/Env)、Budget、步骤重试与失败分类、交付物物化、Stub、并发限流 |
|
||||||
| runtime | 多轮循环、llm、session、tools(含单测) |
|
| runtime | 多轮循环(预算/取消贯穿、budget_exceeded 事件)、llm、session(compaction/元数据/崩溃安全)、tools(9 内置 + 策略层 Restrict) |
|
||||||
| adapters/native | runtime 作为 exec.Backend,含会话毒化轮转 |
|
| runtime/mcp | MCP 客户端(stdio + Streamable HTTP)与 mcp__<server>__<tool> 桥接(server 元数据不可信、上限收敛) |
|
||||||
|
| runtime/skills | SKILL.md 技能加载与 load_skill 渐进披露 |
|
||||||
|
| runtime/promptx | 系统提示结构化装配(环境事实 + 技能索引 + 交付物约束) |
|
||||||
|
| runtime/subagent | 委派端口(Request/Result/Factory)与 delegate_task 工具;嵌套执行由宿主注入 |
|
||||||
|
| adapters/native | runtime 作为 exec.Backend,含会话毒化轮转、compaction、技能/搜索/执行/MCP/委派装配(Options 显式注入,未配置不注册) |
|
||||||
| adapters/pitransport | typed-result-v1 编解码、远程结果解析、Runner 搜索、鉴权头(含 Node 摘要交叉验证测试) |
|
| adapters/pitransport | typed-result-v1 编解码、远程结果解析、Runner 搜索、鉴权头(含 Node 摘要交叉验证测试) |
|
||||||
|
|
||||||
本模块不依赖 Core 或 Squad。上层产品依赖本模块的 contract 与 exec。
|
本模块不依赖 Core 或 Squad。上层产品依赖本模块的 contract 与 exec。
|
||||||
@@ -37,6 +41,26 @@
|
|||||||
- 失败副作用显式分类(none/possible);possible 禁止无条件重放。
|
- 失败副作用显式分类(none/possible);possible 禁止无条件重放。
|
||||||
- 重试单一责任层:步骤级归调用方,传输级有界归 adapter,turn 上限归 runtime。
|
- 重试单一责任层:步骤级归调用方,传输级有界归 adapter,turn 上限归 runtime。
|
||||||
- Workspace 路径授权 ≠ OS 沙箱。
|
- Workspace 路径授权 ≠ OS 沙箱。
|
||||||
|
- `contract.ErrorCode` 取值是 `agent.*` 规范码。`ErrorCode()` / `ErrorFields()` 供日志库识别,本包不导入错误码库。
|
||||||
|
|
||||||
|
## 依赖子模块
|
||||||
|
|
||||||
|
| 路径 | 作用 |
|
||||||
|
|---|---|
|
||||||
|
| `deps/development-guidelines` | 开发规范,不参与编译 |
|
||||||
|
| `deps/error-code-system` | 规范码目录。`go.mod` 的 `replace` 指向这里 |
|
||||||
|
| `deps/logging-system` | 结构化日志。runtime 与 native 适配器可选使用 |
|
||||||
|
|
||||||
|
版本以本仓 gitlink 为准。`scripts/submodule-consistency.sh` 核对 gitlink 与检出的 HEAD。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone --recurse-submodules ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Native.git
|
||||||
|
# 已克隆时:git submodule update --init --recursive
|
||||||
|
```
|
||||||
|
|
||||||
|
## CodeGraph
|
||||||
|
|
||||||
|
`codegraph init` 的索引写在 `.codegraph/`。仓库只跟踪 `.codegraph/.gitignore`。SQLite 数据库、WAL、pid、socket 和日志留在本机;根目录 `.gitignore` 与该文件双重忽略,避免索引被提交。
|
||||||
|
|
||||||
## 开发
|
## 开发
|
||||||
|
|
||||||
@@ -54,9 +78,11 @@ go test ./...
|
|||||||
```
|
```
|
||||||
require agent-native v0.0.0
|
require agent-native v0.0.0
|
||||||
|
|
||||||
replace agent-native => ../Agent-Native
|
replace agent-native => ./deps/agent-native
|
||||||
```
|
```
|
||||||
|
|
||||||
|
本仓自己的 `go.mod` 则把错误码库和日志库替换到 `./deps/`。消费方若同时依赖这三仓,必须在消费方 `go.mod` 里写自己的 `replace`:依赖模块内部的 `replace` 不会在上层构建中生效。
|
||||||
|
|
||||||
远程:`ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Native.git`
|
远程:`ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Native.git`
|
||||||
|
|
||||||
## CI
|
## CI
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{"id":"025357339a97c04d","timestamp":"2026-10-07T06:11:15.521547827Z","type":"message","message":{"role":"system","content":"## Environment\ndate: 2026-10-07(周三)\nmodel: fake\nworkspace: /home/weichen/projects/Agent-System-Dev-wt-feature-agent-stack-n1/deps/agent-native/adapters/native\ntool profile: workspace\n\nDeliverable files: output.md. Use workspace tools to write those files. Do not invent paths outside the workspace.\n\nTools available in this workspace:\n- read_file: Read a UTF-8 text file inside the workspace. Without offset/limit returns the whole file (truncated at 200 KiB); with offset/limit returns numbered lines from a 1-based offset.\n- write_file: Create or replace a UTF-8 text file inside the workspace.\n- edit_file: Replace one unique exact text span in a workspace file.\n- multi_edit: Apply several unique-span replacements to one workspace file atomically: either all edits land or the file is left untouched.\n- list_dir: List names in a workspace directory.\n- grep: Search workspace text files. Literal substring by default; regex=true switches to Go regexp, include filters file names (e.g. *.go), ignore_case folds case. Caps at 50 hits.\n- glob: Find workspace files matching a glob pattern (** crosses directories, * within one segment). Returns up to 500 relative paths."}}
|
||||||
|
{"id":"46a70655ee65ddc3","parentId":"025357339a97c04d","timestamp":"2026-10-07T06:11:15.524296265Z","type":"message","message":{"role":"user","content":"hi"}}
|
||||||
|
{"id":"ab242da6bd626cae","parentId":"46a70655ee65ddc3","timestamp":"2026-10-07T06:11:15.524748291Z","type":"message","message":{"role":"assistant","content":"hi","stop_reason":"end"}}
|
||||||
|
{"id":"f637d2872ab9f34b","parentId":"ab242da6bd626cae","timestamp":"2026-10-07T06:11:15.525362933Z","type":"message","message":{"role":"user"}}
|
||||||
|
{"id":"c1bada8c14ef7ec0","parentId":"f637d2872ab9f34b","timestamp":"2026-10-07T06:11:15.525828249Z","type":"message","message":{"role":"assistant","content":"hi","stop_reason":"end"}}
|
||||||
|
{"id":"a01a79f9d2d2c219","parentId":"c1bada8c14ef7ec0","timestamp":"2026-10-07T06:11:15.52644899Z","type":"message","message":{"role":"user","content":"hi"}}
|
||||||
|
{"id":"37ce369360ed5bc3","parentId":"a01a79f9d2d2c219","timestamp":"2026-10-07T06:11:22.666535507Z","type":"message","message":{"role":"user","content":"hi"}}
|
||||||
|
{"id":"64a2479c0d206bf1","parentId":"37ce369360ed5bc3","timestamp":"2026-10-07T06:11:22.668999313Z","type":"message","message":{"role":"assistant","content":"hi","stop_reason":"end"}}
|
||||||
|
{"id":"61593f441df28345","parentId":"64a2479c0d206bf1","timestamp":"2026-10-07T06:11:22.669756111Z","type":"message","message":{"role":"user"}}
|
||||||
|
{"id":"c5aa1aebdaa45000","parentId":"61593f441df28345","timestamp":"2026-10-07T06:11:22.670242386Z","type":"message","message":{"role":"assistant","content":"hi","stop_reason":"end"}}
|
||||||
|
{"id":"3a9f8790c99f9e94","parentId":"c5aa1aebdaa45000","timestamp":"2026-10-07T06:11:22.670926846Z","type":"message","message":{"role":"user","content":"hi"}}
|
||||||
|
{"id":"064604f3b2f1ad65","parentId":"3a9f8790c99f9e94","timestamp":"2026-10-07T06:13:48.448571493Z","type":"message","message":{"role":"user","content":"hi"}}
|
||||||
|
{"id":"9d3b5edce163fdbe","parentId":"064604f3b2f1ad65","timestamp":"2026-10-07T06:13:48.449399929Z","type":"message","message":{"role":"assistant","content":"hi","stop_reason":"end"}}
|
||||||
|
{"id":"f280fb470f6e90f5","parentId":"9d3b5edce163fdbe","timestamp":"2026-10-07T06:13:48.450112717Z","type":"message","message":{"role":"user"}}
|
||||||
|
{"id":"258b37ad6c79c52d","parentId":"f280fb470f6e90f5","timestamp":"2026-10-07T06:13:48.450611112Z","type":"message","message":{"role":"assistant","content":"hi","stop_reason":"end"}}
|
||||||
|
{"id":"b6462f3dbbd35ce9","parentId":"258b37ad6c79c52d","timestamp":"2026-10-07T06:13:48.451303652Z","type":"message","message":{"role":"user","content":"hi"}}
|
||||||
|
{"id":"1f742b0f9b7c92a1","parentId":"b6462f3dbbd35ce9","timestamp":"2026-10-07T06:13:48.971444939Z","type":"message","message":{"role":"user","content":"hi"}}
|
||||||
|
{"id":"f472774cc9cb019d","parentId":"1f742b0f9b7c92a1","timestamp":"2026-10-07T06:13:48.973639624Z","type":"message","message":{"role":"assistant","content":"hi","stop_reason":"end"}}
|
||||||
|
{"id":"27e484d323f079d3","parentId":"f472774cc9cb019d","timestamp":"2026-10-07T06:13:48.97478444Z","type":"message","message":{"role":"user"}}
|
||||||
|
{"id":"9a97876386e882d4","parentId":"27e484d323f079d3","timestamp":"2026-10-07T06:13:48.97545809Z","type":"message","message":{"role":"assistant","content":"hi","stop_reason":"end"}}
|
||||||
|
{"id":"a8a4c88afce69517","parentId":"9a97876386e882d4","timestamp":"2026-10-07T06:13:48.976690973Z","type":"message","message":{"role":"user","content":"hi"}}
|
||||||
|
{"id":"c2865055fe240f43","parentId":"a8a4c88afce69517","timestamp":"2026-10-07T06:14:02.592947456Z","type":"message","message":{"role":"user","content":"hi"}}
|
||||||
|
{"id":"a02f22b1760c8363","parentId":"c2865055fe240f43","timestamp":"2026-10-07T06:14:02.595218749Z","type":"message","message":{"role":"assistant","content":"hi","stop_reason":"end"}}
|
||||||
|
{"id":"c3321f8e2d467eda","parentId":"a02f22b1760c8363","timestamp":"2026-10-07T06:14:02.59619084Z","type":"message","message":{"role":"user"}}
|
||||||
|
{"id":"7d0eb0b5d39283b2","parentId":"c3321f8e2d467eda","timestamp":"2026-10-07T06:14:02.596897779Z","type":"message","message":{"role":"assistant","content":"hi","stop_reason":"end"}}
|
||||||
|
{"id":"f99d01430932d356","parentId":"7d0eb0b5d39283b2","timestamp":"2026-10-07T06:14:02.598248098Z","type":"message","message":{"role":"user","content":"hi"}}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"exec_id": "cx",
|
||||||
|
"leaf": "f99d01430932d356",
|
||||||
|
"model": "fake",
|
||||||
|
"scope": "",
|
||||||
|
"updated": "2026-10-07T06:14:02Z"
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
stream ended without a message
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
hi
|
||||||
+561
-65
@@ -14,20 +14,92 @@ import (
|
|||||||
|
|
||||||
"agent-native/exec"
|
"agent-native/exec"
|
||||||
"agent-native/runtime"
|
"agent-native/runtime"
|
||||||
|
"agent-native/runtime/checkpoint"
|
||||||
|
"agent-native/runtime/filestate"
|
||||||
|
"agent-native/runtime/hooks"
|
||||||
"agent-native/runtime/llm"
|
"agent-native/runtime/llm"
|
||||||
|
"agent-native/runtime/mcp"
|
||||||
|
"agent-native/runtime/promptx"
|
||||||
"agent-native/runtime/session"
|
"agent-native/runtime/session"
|
||||||
|
"agent-native/runtime/skills"
|
||||||
|
"agent-native/runtime/subagent"
|
||||||
"agent-native/runtime/tools"
|
"agent-native/runtime/tools"
|
||||||
|
|
||||||
|
"logging-system/logsys"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Options 构造后端所需的模型接入与执行参数(由组合根注入,无产品依赖)。
|
// ModelOptions 是模型接入。Stream 非空时不再按 APIBase/APIKey 构造客户端。
|
||||||
type Options struct {
|
type ModelOptions struct {
|
||||||
APIBase string
|
APIBase string
|
||||||
APIKey string
|
APIKey string
|
||||||
Model string
|
Model string
|
||||||
MaxTurns int
|
MaxTurns int
|
||||||
Timeout time.Duration
|
Timeout time.Duration
|
||||||
// Stream 测试注入的流函数;缺省按 APIBase/APIKey 构造 llm.Client。
|
Stream llm.StreamFunc
|
||||||
Stream llm.StreamFunc
|
}
|
||||||
|
|
||||||
|
// ToolOptions 是可选工具装配。nil 字段不注册对应工具。
|
||||||
|
type ToolOptions struct {
|
||||||
|
Search tools.SearchProvider
|
||||||
|
Exec *tools.ExecConfig
|
||||||
|
Fetch *tools.FetchConfig
|
||||||
|
MCP *mcp.Manager
|
||||||
|
SkillsRoots []string
|
||||||
|
Subagent subagent.Factory
|
||||||
|
HostTools []tools.Tool
|
||||||
|
Checkpoints *checkpoint.Log
|
||||||
|
CheckpointAnchor func() string
|
||||||
|
// CodeOrchestration 为真时注册 run_tool_script。默认关。
|
||||||
|
CodeOrchestration bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// SessionOptions 是单次执行的会话预算与压缩。
|
||||||
|
type SessionOptions struct {
|
||||||
|
Compact *CompactConfig
|
||||||
|
MaxToolCalls int
|
||||||
|
}
|
||||||
|
|
||||||
|
// ObserveOptions 是日志。Log 为 nil 时丢弃。
|
||||||
|
type ObserveOptions struct {
|
||||||
|
Log *logsys.Logger
|
||||||
|
ExecutionID string
|
||||||
|
// Trace 交给运行循环。nil 不采集。
|
||||||
|
Trace func(name string, attrs map[string]string) func()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GateOptions 把直连已经使用的停止、提醒和工具钩子交给小队成员。零值不改变行为。
|
||||||
|
type GateOptions struct {
|
||||||
|
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
|
||||||
|
// Ask 在 MCP 工具要求用户输入时调用。nil 则把问题作为错误返回。
|
||||||
|
Ask func(ctx context.Context, question string) (string, error)
|
||||||
|
// PermissionAsk 在内核钩子对工具表态 ask 时向宿主请求裁决。
|
||||||
|
// nil 时内核保持旧行为:ask 与 deny 一样按拒绝处理。
|
||||||
|
PermissionAsk func(ctx context.Context, call llm.ToolCall) runtime.AskVerdict
|
||||||
|
}
|
||||||
|
|
||||||
|
// Options 构造后端所需的模型接入与执行参数(由组合根注入,无产品依赖)。
|
||||||
|
// 字段经嵌入提升,读取仍写 opts.Model;复合字面量要写子结构名。
|
||||||
|
type Options struct {
|
||||||
|
ModelOptions
|
||||||
|
ToolOptions
|
||||||
|
SessionOptions
|
||||||
|
ObserveOptions
|
||||||
|
GateOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompactConfig 声明长会话压缩策略(优化性质:压缩失败不阻断执行,但留日志)。
|
||||||
|
type CompactConfig struct {
|
||||||
|
// ThresholdChars 触发阈值(按字节数近似),默认 300_000。
|
||||||
|
ThresholdChars int
|
||||||
|
// KeepLastN 压缩后保留的最近消息条数,默认 8。
|
||||||
|
KeepLastN int
|
||||||
|
// Summarizer 可选注入摘要函数;缺省用后端 stream 做一次无工具调用。
|
||||||
|
Summarizer func(ctx context.Context, messages []llm.Message) (string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Backend 以自研内核实现 exec.Backend。
|
// Backend 以自研内核实现 exec.Backend。
|
||||||
@@ -35,10 +107,23 @@ type Backend struct {
|
|||||||
opts Options
|
opts Options
|
||||||
// lastFailure 记录各工作目录上次失败原因,用于会话毒化检测
|
// lastFailure 记录各工作目录上次失败原因,用于会话毒化检测
|
||||||
// (context_overflow 等错误续跑同一会话只会复现失败,须换新会话)。
|
// (context_overflow 等错误续跑同一会话只会复现失败,须换新会话)。
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
lastFailure map[string]string
|
lastFailure map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type workDirKey struct{}
|
||||||
|
|
||||||
|
// WithWorkDir 把本步工作目录放进上下文,供宿主工具读取内核刚写出的文件。
|
||||||
|
func WithWorkDir(ctx context.Context, dir string) context.Context {
|
||||||
|
return context.WithValue(ctx, workDirKey{}, dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WorkDir 返回 WithWorkDir 写入的目录。没有时返回空串。
|
||||||
|
func WorkDir(ctx context.Context) string {
|
||||||
|
dir, _ := ctx.Value(workDirKey{}).(string)
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
// New 创建 native 后端。
|
// New 创建 native 后端。
|
||||||
func New(opts Options) *Backend {
|
func New(opts Options) *Backend {
|
||||||
return &Backend{opts: opts, lastFailure: map[string]string{}}
|
return &Backend{opts: opts, lastFailure: map[string]string{}}
|
||||||
@@ -47,9 +132,16 @@ func New(opts Options) *Backend {
|
|||||||
// Name 返回规范后端名。
|
// Name 返回规范后端名。
|
||||||
func (b *Backend) Name() string { return "agent-native" }
|
func (b *Backend) Name() string { return "agent-native" }
|
||||||
|
|
||||||
|
// Close 释放后端持有的长连接资源(MCP server 子进程等)。
|
||||||
|
func (b *Backend) Close() {
|
||||||
|
if b.opts.MCP != nil {
|
||||||
|
b.opts.MCP.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Execute 运行一轮带工具的自研智能体循环,产出目标交付物。
|
// Execute 运行一轮带工具的自研智能体循环,产出目标交付物。
|
||||||
func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result, error) {
|
func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result, error) {
|
||||||
if len(req.Targets) == 0 {
|
if len(req.Targets) == 0 && !allowEmptyTargets(ctx) {
|
||||||
req.Targets = []string{"output.md"}
|
req.Targets = []string{"output.md"}
|
||||||
}
|
}
|
||||||
profile := strings.TrimSpace(req.ToolProfile)
|
profile := strings.TrimSpace(req.ToolProfile)
|
||||||
@@ -57,22 +149,43 @@ func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result,
|
|||||||
profile = exec.ToolProfileWorkspace
|
profile = exec.ToolProfileWorkspace
|
||||||
}
|
}
|
||||||
if profile != exec.ToolProfileWorkspace && profile != exec.ToolProfileReasoning {
|
if profile != exec.ToolProfileWorkspace && profile != exec.ToolProfileReasoning {
|
||||||
return &exec.Result{Success: false, ErrorReason: "unknown tool profile: " + profile}, nil
|
return preflightFail("unknown tool profile: " + profile), nil
|
||||||
}
|
}
|
||||||
if b.opts.Stream == nil && (strings.TrimSpace(b.opts.APIBase) == "" || strings.TrimSpace(b.opts.APIKey) == "") {
|
if b.opts.Stream == nil && (strings.TrimSpace(b.opts.APIBase) == "" || strings.TrimSpace(b.opts.APIKey) == "") {
|
||||||
return &exec.Result{Success: false, ErrorReason: "agent-native backend requires api base/key (or injected stream)"}, nil
|
return preflightFail("agent-native backend requires api base/key (or injected stream)"), nil
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(b.opts.Model) == "" {
|
if b.opts.Ask != nil {
|
||||||
return &exec.Result{Success: false, ErrorReason: "agent-native backend requires model"}, nil
|
ctx = mcp.WithAsk(ctx, b.opts.Ask)
|
||||||
|
}
|
||||||
|
// MCP 长任务跟踪(2026-07-28 最小映射):本 run 内 tools/call 见过的
|
||||||
|
// TaskID 在 run 结束时逐一 tasks/cancel,不影响共享 Manager 上其他 run。
|
||||||
|
// 取消用 Background:run 的 ctx 在结束时已作废,取消投递不能被它掐掉。
|
||||||
|
var taskTracker *mcp.TaskTracker
|
||||||
|
ctx, taskTracker = mcp.WithTaskTracker(ctx)
|
||||||
|
defer func() { taskTracker.CancelAll(context.Background()) }()
|
||||||
|
model := strings.TrimSpace(b.opts.Model)
|
||||||
|
if strings.TrimSpace(req.Model) != "" {
|
||||||
|
model = strings.TrimSpace(req.Model)
|
||||||
|
}
|
||||||
|
if model == "" {
|
||||||
|
return preflightFail("agent-native backend requires model"), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
workDir := req.WorkDir
|
workDir := req.WorkDir
|
||||||
if !filepath.IsAbs(workDir) {
|
if strings.TrimSpace(workDir) == "" {
|
||||||
|
// 空工作区 = 请求未授权持久工作区:会话状态(.agentx/transcript 等)
|
||||||
|
// 落一次性临时目录,不污染进程 CWD(契约套件即发此类请求)。
|
||||||
|
tmp, err := os.MkdirTemp("", "agent-native-run-*")
|
||||||
|
if err != nil {
|
||||||
|
return preflightFail(err.Error()), nil
|
||||||
|
}
|
||||||
|
workDir = tmp
|
||||||
|
} else if !filepath.IsAbs(workDir) {
|
||||||
wd, _ := os.Getwd()
|
wd, _ := os.Getwd()
|
||||||
workDir = filepath.Join(wd, workDir)
|
workDir = filepath.Join(wd, workDir)
|
||||||
}
|
}
|
||||||
if err := os.MkdirAll(workDir, 0o755); err != nil {
|
if err := os.MkdirAll(workDir, 0o755); err != nil {
|
||||||
return &exec.Result{Success: false, ErrorReason: err.Error()}, nil
|
return preflightFail(err.Error()), nil
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(req.Instructions) != "" {
|
if strings.TrimSpace(req.Instructions) != "" {
|
||||||
_ = os.WriteFile(filepath.Join(workDir, "INSTRUCTIONS.md"), []byte(req.Instructions), 0o644)
|
_ = os.WriteFile(filepath.Join(workDir, "INSTRUCTIONS.md"), []byte(req.Instructions), 0o644)
|
||||||
@@ -82,72 +195,237 @@ func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result,
|
|||||||
_ = os.WriteFile(filepath.Join(workDir, "TASK.md"), []byte(req.Prompt), 0o644)
|
_ = os.WriteFile(filepath.Join(workDir, "TASK.md"), []byte(req.Prompt), 0o644)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 技能:全局根 + 工作区 .agent/skills;声明的技能必须存在(fail-closed)。
|
||||||
|
roots := make([]string, 0, len(b.opts.SkillsRoots)+1)
|
||||||
|
roots = append(roots, b.opts.SkillsRoots...)
|
||||||
|
roots = append(roots, filepath.Join(workDir, ".agent", "skills"))
|
||||||
|
skillStore := skills.NewStore(roots...)
|
||||||
|
skillIndex := skillStore.List()
|
||||||
|
indexSet := make(map[string]bool, len(skillIndex))
|
||||||
|
for _, s := range skillIndex {
|
||||||
|
indexSet[s.Name] = true
|
||||||
|
}
|
||||||
|
for _, name := range req.Skills {
|
||||||
|
if !indexSet[name] {
|
||||||
|
return preflightFail("declared skill not installed: " + name), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
writable := writableFrom(ctx)
|
||||||
|
// F02: a non-empty Writable cannot be enforced for exec/MCP/host tools or
|
||||||
|
// subagent delegation — reject in preflight rather than pretend the
|
||||||
|
// write-set holds.
|
||||||
|
if len(writable) > 0 && profile == exec.ToolProfileWorkspace {
|
||||||
|
if b.opts.Exec != nil {
|
||||||
|
return preflightFail("writable write-set cannot be enforced when exec tools are enabled"), nil
|
||||||
|
}
|
||||||
|
if b.opts.MCP != nil {
|
||||||
|
return preflightFail("writable write-set cannot be enforced when MCP tools are enabled"), nil
|
||||||
|
}
|
||||||
|
if len(b.opts.HostTools) > 0 {
|
||||||
|
return preflightFail("writable write-set cannot be enforced when host tools are enabled"), nil
|
||||||
|
}
|
||||||
|
if b.opts.Subagent != nil {
|
||||||
|
return preflightFail("writable write-set cannot be enforced when subagent delegation is enabled"), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
var reg *tools.Registry
|
var reg *tools.Registry
|
||||||
if profile == exec.ToolProfileWorkspace {
|
if profile == exec.ToolProfileWorkspace {
|
||||||
reg = tools.WorkspaceTools(workDir)
|
reg = tools.WorkspaceToolsCheckpointed(workDir, filestate.New(), b.opts.Checkpoints, b.opts.CheckpointAnchor)
|
||||||
|
tools.ApplyWriteAllowlist(reg, writable)
|
||||||
|
skills.RegisterLoader(reg, skillStore)
|
||||||
|
if b.opts.Search != nil {
|
||||||
|
tools.RegisterSearch(reg, b.opts.Search)
|
||||||
|
}
|
||||||
|
if b.opts.Fetch != nil {
|
||||||
|
tools.RegisterFetch(reg, *b.opts.Fetch)
|
||||||
|
}
|
||||||
|
if b.opts.Exec != nil {
|
||||||
|
execCfg := *b.opts.Exec
|
||||||
|
for k, v := range req.Env {
|
||||||
|
execCfg.Env = append(execCfg.Env, k+"="+v)
|
||||||
|
}
|
||||||
|
tools.RegisterExecTools(reg, workDir, execCfg)
|
||||||
|
}
|
||||||
|
if b.opts.CodeOrchestration {
|
||||||
|
tools.RegisterCodeTool(reg)
|
||||||
|
}
|
||||||
|
reg.SetActivationFile(filepath.Join(workDir, ".agentx", "activated-tools.json"))
|
||||||
|
_ = reg.RestoreActivated()
|
||||||
|
if b.opts.MCP != nil {
|
||||||
|
switch {
|
||||||
|
case len(req.ToolAllowlist) > 0 || b.opts.MCP.Ready():
|
||||||
|
if err := b.opts.MCP.RegisterInto(ctx, reg); err != nil {
|
||||||
|
return preflightFail(err.Error()), nil
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
b.opts.MCP.StartBackground(ctx, reg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
subagent.Register(reg, b.opts.Subagent)
|
||||||
|
for _, tool := range b.opts.HostTools {
|
||||||
|
if reg.Has(tool.Name()) {
|
||||||
|
return preflightFail("host tool name conflicts with a builtin: " + tool.Name()), nil
|
||||||
|
}
|
||||||
|
reg.Register(tool)
|
||||||
|
}
|
||||||
|
if err := reg.Restrict(req.ToolAllowlist); err != nil {
|
||||||
|
return preflightFail(err.Error()), nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
// Scope-keyed transcript: empty scope keeps the legacy fixed path.
|
||||||
|
transcript := transcriptPathForScope(workDir, req.SessionScope)
|
||||||
// 会话毒化轮转:上次失败属 resume-unsafe 时,把旧 transcript 归档,
|
// 会话毒化轮转:上次失败属 resume-unsafe 时,把旧 transcript 归档,
|
||||||
// 本次以全新会话开始(续跑毒化会话只会复现同样的失败)。
|
// 本次以全新会话开始(续跑毒化会话只会复现同样的失败)。
|
||||||
b.rotateIfPoisoned(workDir)
|
b.rotateIfPoisoned(workDir, transcript)
|
||||||
store, err := session.Open(filepath.Join(workDir, ".agentx", "transcript.jsonl"))
|
store, err := session.Open(transcript)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &exec.Result{Success: false, ErrorReason: err.Error()}, nil
|
return preflightFail(err.Error()), nil
|
||||||
}
|
}
|
||||||
stream := b.opts.Stream
|
stream := b.opts.Stream
|
||||||
if stream == nil {
|
if stream == nil {
|
||||||
client := &llm.Client{BaseURL: b.opts.APIBase, APIKey: b.opts.APIKey}
|
client := &llm.Client{BaseURL: b.opts.APIBase, APIKey: b.opts.APIKey}
|
||||||
stream = client.Stream
|
stream = client.Stream
|
||||||
}
|
}
|
||||||
|
if err := b.bindSessionScope(store, req.SessionScope, model, req.ExecutionID); err != nil {
|
||||||
|
return preflightFail(err.Error()), nil
|
||||||
|
}
|
||||||
|
b.maybeCompact(ctx, store, stream, model)
|
||||||
|
|
||||||
timeout := req.Timeout
|
timeout := req.Timeout
|
||||||
if timeout <= 0 {
|
if timeout <= 0 {
|
||||||
timeout = b.opts.Timeout
|
timeout = b.opts.Timeout
|
||||||
}
|
}
|
||||||
runCtx := ctx
|
var runBudget *runtime.Budget
|
||||||
|
if b.opts.MaxToolCalls > 0 {
|
||||||
|
runBudget = &runtime.Budget{MaxToolCalls: b.opts.MaxToolCalls}
|
||||||
|
}
|
||||||
|
if req.Budget != nil {
|
||||||
|
runBudget = &runtime.Budget{
|
||||||
|
MaxPromptTokens: req.Budget.MaxPromptTokens,
|
||||||
|
MaxCompletionTokens: req.Budget.MaxCompletionTokens,
|
||||||
|
MaxToolCalls: req.Budget.MaxToolCalls,
|
||||||
|
Deadline: req.Budget.Deadline,
|
||||||
|
}
|
||||||
|
if runBudget.MaxToolCalls == 0 {
|
||||||
|
runBudget.MaxToolCalls = b.opts.MaxToolCalls
|
||||||
|
}
|
||||||
|
if !req.Budget.Deadline.IsZero() {
|
||||||
|
// Deadline 与 Timeout 先到者生效:把更短者作为 ctx 超时。
|
||||||
|
if until := time.Until(req.Budget.Deadline); timeout <= 0 || until < timeout {
|
||||||
|
timeout = until
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
runCtx := WithWorkDir(ctx, workDir)
|
||||||
cancel := func() {}
|
cancel := func() {}
|
||||||
if timeout > 0 {
|
if timeout > 0 {
|
||||||
runCtx, cancel = context.WithTimeout(ctx, timeout)
|
runCtx, cancel = context.WithTimeout(runCtx, timeout)
|
||||||
}
|
}
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
|
execID := req.ExecutionID
|
||||||
|
if strings.TrimSpace(execID) == "" {
|
||||||
|
execID = b.opts.ExecutionID
|
||||||
|
}
|
||||||
var logs strings.Builder
|
var logs strings.Builder
|
||||||
|
if strings.TrimSpace(execID) != "" || strings.TrimSpace(req.SessionScope) != "" {
|
||||||
|
logs.WriteString("execution_id=" + execID + " session=" + req.SessionScope + "\n")
|
||||||
|
}
|
||||||
onProcess := req.OnEvent
|
onProcess := req.OnEvent
|
||||||
runRes, err := b.run(runCtx, reg, store, stream, profile, req, &logs, onProcess)
|
runRes, err := b.run(runCtx, model, runBudget, reg, store, stream, profile, req, workDir, skillIndex, &logs, onProcess)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &exec.Result{Success: false, ErrorReason: err.Error(), Logs: logs.String()}, nil
|
// Persist/session transport failure after the run started: outcome uncertain.
|
||||||
|
return &exec.Result{
|
||||||
|
Success: false, ErrorReason: err.Error(), Logs: logs.String(),
|
||||||
|
Unknown: true, SideEffects: exec.SideEffectPossible,
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
_ = os.WriteFile(filepath.Join(workDir, "execution.log"), []byte(logs.String()), 0o644)
|
_ = os.WriteFile(filepath.Join(workDir, "execution.log"), []byte(logs.String()), 0o644)
|
||||||
|
|
||||||
|
// CapText sink: always capture the final assistant text for contract mapping.
|
||||||
|
captureText(ctx, runRes.Text)
|
||||||
|
|
||||||
// reasoning 档无工具:把最终文本写入仍为空的目标文件。
|
// reasoning 档无工具:把最终文本写入仍为空的目标文件。
|
||||||
|
// 先授权全部 target,任一拒绝则零写入(避免 ../、绝对路径、符号链接逃逸先落地)。
|
||||||
if profile == exec.ToolProfileReasoning && strings.TrimSpace(runRes.Text) != "" {
|
if profile == exec.ToolProfileReasoning && strings.TrimSpace(runRes.Text) != "" {
|
||||||
|
type reasoningTarget struct {
|
||||||
|
path string
|
||||||
|
skip bool
|
||||||
|
}
|
||||||
|
authorized := make([]reasoningTarget, 0, len(req.Targets))
|
||||||
for _, target := range req.Targets {
|
for _, target := range req.Targets {
|
||||||
path := filepath.Join(workDir, filepath.FromSlash(target))
|
if !pathInWriteSet(writable, target) {
|
||||||
if b2, err := os.ReadFile(path); err == nil && len(strings.TrimSpace(string(b2))) > 0 {
|
return &exec.Result{
|
||||||
|
Success: false, ErrorReason: "target is outside the declared writable set: " + target,
|
||||||
|
Logs: logs.String(), SideEffects: sideEffectsForTools(runRes.ToolCalls),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
path, resolveErr := tools.Resolve(workDir, filepath.FromSlash(target))
|
||||||
|
if resolveErr != nil {
|
||||||
|
return &exec.Result{
|
||||||
|
Success: false, ErrorReason: resolveErr.Error(), Logs: logs.String(),
|
||||||
|
SideEffects: sideEffectsForTools(runRes.ToolCalls),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
skip := false
|
||||||
|
if b2, readErr := os.ReadFile(path); readErr == nil && len(strings.TrimSpace(string(b2))) > 0 {
|
||||||
|
skip = true
|
||||||
|
}
|
||||||
|
authorized = append(authorized, reasoningTarget{path: path, skip: skip})
|
||||||
|
}
|
||||||
|
for _, at := range authorized {
|
||||||
|
if at.skip {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
_ = os.MkdirAll(filepath.Dir(path), 0o755)
|
_ = os.MkdirAll(filepath.Dir(at.path), 0o755)
|
||||||
_ = os.WriteFile(path, []byte(runRes.Text), 0o644)
|
_ = os.WriteFile(at.path, []byte(runRes.Text), 0o644)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if runRes.Err != nil {
|
if runRes.Err != nil {
|
||||||
reason := runRes.Err.Error()
|
reason := runRes.Err.Error()
|
||||||
b.mu.Lock()
|
b.noteFailure(workDir, reason)
|
||||||
b.lastFailure[workDir] = reason
|
canceled := runRes.Canceled || runRes.StopReason == llm.StopAborted
|
||||||
b.mu.Unlock()
|
retryable := runRes.Retryable && !canceled
|
||||||
res := &exec.Result{Success: false, ErrorReason: reason, Logs: logs.String(), Retryable: runRes.Retryable}
|
assessment := exec.AssessStepFailure(exec.StepFailureSignals{
|
||||||
if runRes.StopReason == llm.StopAborted {
|
Err: runRes.Err,
|
||||||
res.Retryable = true
|
WorkDir: workDir,
|
||||||
|
Result: &exec.Result{ErrorReason: reason, Retryable: retryable, Logs: logs.String()},
|
||||||
|
})
|
||||||
|
b.logger().Error("native step failure classified", runRes.Err,
|
||||||
|
"failure_class", assessment.Class.String(),
|
||||||
|
"work_dir", workDir,
|
||||||
|
"canceled", canceled)
|
||||||
|
res := &exec.Result{
|
||||||
|
Success: false,
|
||||||
|
ErrorReason: reason,
|
||||||
|
Logs: logs.String(),
|
||||||
|
Retryable: retryable,
|
||||||
|
Canceled: canceled,
|
||||||
|
SideEffects: exec.SideEffectPossible,
|
||||||
|
}
|
||||||
|
if canceled {
|
||||||
|
// Aborted/canceled: never retryable; side effects may already exist.
|
||||||
|
res.Retryable = false
|
||||||
|
res.SideEffects = exec.SideEffectPossible
|
||||||
|
} else if runRes.ToolCalls == 0 {
|
||||||
|
res.SideEffects = exec.SideEffectNone
|
||||||
}
|
}
|
||||||
return res, nil
|
return res, nil
|
||||||
}
|
}
|
||||||
b.mu.Lock()
|
b.clearFailure(workDir)
|
||||||
delete(b.lastFailure, workDir)
|
|
||||||
b.mu.Unlock()
|
|
||||||
|
|
||||||
patches, matErr := exec.MaterializeTargets(workDir, req.Targets, nil)
|
patches, matErr := exec.MaterializeTargets(workDir, req.Targets, nil)
|
||||||
if matErr != nil {
|
if matErr != nil {
|
||||||
return &exec.Result{Success: false, ErrorReason: matErr.Error(), Patches: patches, Logs: logs.String()}, nil
|
return &exec.Result{
|
||||||
|
Success: false, ErrorReason: matErr.Error(), Patches: patches, Logs: logs.String(),
|
||||||
|
SideEffects: exec.SideEffectPossible,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
side := sideEffectsForTools(runRes.ToolCalls)
|
||||||
|
if len(patches) > 0 {
|
||||||
|
side = exec.SideEffectPossible
|
||||||
}
|
}
|
||||||
return &exec.Result{
|
return &exec.Result{
|
||||||
Success: true,
|
Success: true,
|
||||||
@@ -155,21 +433,35 @@ func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result,
|
|||||||
Logs: logs.String(),
|
Logs: logs.String(),
|
||||||
PromptTokens: runRes.Usage.PromptTokens,
|
PromptTokens: runRes.Usage.PromptTokens,
|
||||||
CompletionTokens: runRes.Usage.CompletionTokens,
|
CompletionTokens: runRes.Usage.CompletionTokens,
|
||||||
|
SideEffects: side,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// run 驱动 runtime 循环并桥接过程事件(语义对齐主仓 pinative)。
|
// run 驱动 runtime 循环并桥接过程事件(语义对齐主仓 pinative)。
|
||||||
func (b *Backend) run(ctx context.Context, reg *tools.Registry, store *session.Store,
|
func (b *Backend) run(ctx context.Context, model string, budget *runtime.Budget, reg *tools.Registry, store *session.Store,
|
||||||
stream llm.StreamFunc, profile string, req exec.Request, logs *strings.Builder,
|
stream llm.StreamFunc, profile string, req exec.Request, workDir string, skillIndex []skills.Skill,
|
||||||
onProcess func(exec.ProcessEvent)) (runtime.Result, error) {
|
logs *strings.Builder, onProcess func(exec.ProcessEvent)) (runtime.Result, error) {
|
||||||
|
|
||||||
runRes, err := runtime.Run(ctx, runtime.Config{
|
execID := req.ExecutionID
|
||||||
Model: b.opts.Model,
|
if strings.TrimSpace(execID) == "" {
|
||||||
SystemPrompt: systemPrompt(req, profile),
|
execID = b.opts.ExecutionID
|
||||||
|
}
|
||||||
|
agent := runtime.NewAgentSession(runtime.Config{
|
||||||
|
Model: model,
|
||||||
|
SystemPrompt: b.systemPrompt(req, model, profile, workDir, skillIndex),
|
||||||
Tools: reg,
|
Tools: reg,
|
||||||
Stream: stream,
|
Stream: stream,
|
||||||
Session: store,
|
Session: store,
|
||||||
MaxTurns: b.opts.MaxTurns,
|
MaxTurns: b.opts.MaxTurns,
|
||||||
|
Log: b.logger(),
|
||||||
|
ExecutionID: execID,
|
||||||
|
Budget: budget,
|
||||||
|
Reminders: b.opts.Reminders,
|
||||||
|
RepeatGuard: b.opts.RepeatGuard,
|
||||||
|
ResultsDir: filepath.Join(workDir, ".agentx", "results"),
|
||||||
|
Hooks: gateBus(b.opts.GateOptions),
|
||||||
|
PermissionAsk: b.opts.PermissionAsk,
|
||||||
|
Trace: b.opts.Trace,
|
||||||
OnEvent: func(ev runtime.Event) {
|
OnEvent: func(ev runtime.Event) {
|
||||||
logs.WriteString(ev.Type)
|
logs.WriteString(ev.Type)
|
||||||
if ev.Tool != "" {
|
if ev.Tool != "" {
|
||||||
@@ -183,15 +475,139 @@ func (b *Backend) run(ctx context.Context, reg *tools.Registry, store *session.S
|
|||||||
logs.WriteByte('\n')
|
logs.WriteByte('\n')
|
||||||
if onProcess != nil {
|
if onProcess != nil {
|
||||||
onProcess(exec.ProcessEvent{
|
onProcess(exec.ProcessEvent{
|
||||||
Type: ev.Type,
|
Type: ev.Type,
|
||||||
Text: ev.Text,
|
Text: ev.Text,
|
||||||
Tool: ev.Tool,
|
Tool: ev.Tool,
|
||||||
Detail: ev.Detail,
|
Detail: ev.Detail,
|
||||||
|
CallID: ev.CallID,
|
||||||
|
Status: ev.Status,
|
||||||
|
ErrorReason: ev.ErrorReason,
|
||||||
|
PromptTokens: ev.PromptTokens,
|
||||||
|
CompletionTokens: ev.CompletionTokens,
|
||||||
|
LastPromptTokens: ev.LastPromptTokens,
|
||||||
|
LastCompletionTokens: ev.LastCompletionTokens,
|
||||||
|
CacheReadTokens: ev.CacheReadTokens,
|
||||||
|
CacheWriteTokens: ev.CacheWriteTokens,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}, llm.Message{Role: llm.RoleUser, Content: req.Prompt})
|
})
|
||||||
return runRes, err
|
return agent.Prompt(ctx, llm.Message{Role: llm.RoleUser, Content: req.Prompt})
|
||||||
|
}
|
||||||
|
|
||||||
|
// maybeCompact 在转录超过阈值时压缩头部历史。压缩是优化性质:
|
||||||
|
// 失败不阻断执行(按原文继续),但必须留日志,不静默。
|
||||||
|
func (b *Backend) maybeCompact(ctx context.Context, store *session.Store, stream llm.StreamFunc, model string) {
|
||||||
|
cfg := b.opts.Compact
|
||||||
|
if cfg == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
threshold := int64(cfg.ThresholdChars)
|
||||||
|
if threshold <= 0 {
|
||||||
|
threshold = 300_000
|
||||||
|
}
|
||||||
|
keep := cfg.KeepLastN
|
||||||
|
if keep <= 0 {
|
||||||
|
keep = 8
|
||||||
|
}
|
||||||
|
if store.Stats().Bytes < threshold {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
summarize := cfg.Summarizer
|
||||||
|
if summarize == nil {
|
||||||
|
summarize = defaultSummarizer(stream, model)
|
||||||
|
}
|
||||||
|
done, err := store.Compact(keep, func(messages []llm.Message) (string, error) {
|
||||||
|
return summarize(ctx, messages)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
b.logger().Warn("session compaction failed; continuing with full transcript", "error", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if done {
|
||||||
|
b.logger().Info("session compacted", "keep_last", keep)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultSummarizer 用后端 stream 做一次无工具调用,把历史压成摘要。
|
||||||
|
func defaultSummarizer(stream llm.StreamFunc, model string) func(ctx context.Context, messages []llm.Message) (string, error) {
|
||||||
|
return func(ctx context.Context, messages []llm.Message) (string, error) {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, m := range messages {
|
||||||
|
b.WriteString(string(m.Role))
|
||||||
|
b.WriteString(": ")
|
||||||
|
b.WriteString(m.Content)
|
||||||
|
b.WriteString("\n")
|
||||||
|
if b.Len() > 120_000 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prompt := "把以下智能体对话历史压缩成摘要:保留全部关键事实、已做决定、文件变更与未决问题;中文;不超过 800 字;只输出摘要正文。\n\n" + b.String()
|
||||||
|
var out strings.Builder
|
||||||
|
for ev := range stream(ctx, model, []llm.Message{{Role: llm.RoleUser, Content: prompt}}, nil, llm.StreamOptions{}) {
|
||||||
|
switch ev.Kind {
|
||||||
|
case llm.KindTextDelta:
|
||||||
|
out.WriteString(ev.Text)
|
||||||
|
case llm.KindError:
|
||||||
|
return "", ev.Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(out.String()) == "" {
|
||||||
|
return "", errString("summarizer produced empty output")
|
||||||
|
}
|
||||||
|
return out.String(), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func gateBus(g GateOptions) *hooks.Bus {
|
||||||
|
bus := g.Hooks
|
||||||
|
if g.BeforeTool == nil && g.AfterTool == nil && g.Stop == nil {
|
||||||
|
return bus
|
||||||
|
}
|
||||||
|
if bus == nil {
|
||||||
|
bus = hooks.New()
|
||||||
|
}
|
||||||
|
if g.BeforeTool != nil {
|
||||||
|
fn := g.BeforeTool
|
||||||
|
bus.On(hooks.AdaptBefore(func(name, args string) error { return fn(llm.ToolCall{Name: name, Arguments: args}) }))
|
||||||
|
}
|
||||||
|
if g.AfterTool != nil {
|
||||||
|
fn := g.AfterTool
|
||||||
|
bus.On(hooks.AdaptAfter(func(name, args, result string, isError bool) {
|
||||||
|
fn(llm.ToolCall{Name: name, Arguments: args}, llm.Message{Content: result, IsError: isError})
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
if g.Stop != nil {
|
||||||
|
fn := g.Stop
|
||||||
|
bus.On(func(c hooks.Call) hooks.Outcome {
|
||||||
|
if c.Event != hooks.Stop {
|
||||||
|
return hooks.Outcome{}
|
||||||
|
}
|
||||||
|
d := fn(llm.Message{Role: llm.RoleAssistant, Content: c.LastText}, runtime.StopInfo{Continuations: c.Continuations, ToolCalls: c.ToolCalls})
|
||||||
|
if d.Continue {
|
||||||
|
return hooks.Outcome{Decision: hooks.Block, Reason: d.Reason, Replacement: d.Replacement, Notice: d.Notice}
|
||||||
|
}
|
||||||
|
return hooks.Outcome{Decision: hooks.Allow, Notice: d.Notice}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return bus
|
||||||
|
}
|
||||||
|
|
||||||
|
type errString string
|
||||||
|
|
||||||
|
func (e errString) Error() string { return string(e) }
|
||||||
|
|
||||||
|
// preflightFail is a definitive rejection before any tool dispatch.
|
||||||
|
func preflightFail(reason string) *exec.Result {
|
||||||
|
return &exec.Result{Success: false, ErrorReason: reason, SideEffects: exec.SideEffectNone}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sideEffectsForTools maps dispatched tool count to a SideEffects qualifier.
|
||||||
|
func sideEffectsForTools(toolCalls int) exec.SideEffects {
|
||||||
|
if toolCalls > 0 {
|
||||||
|
return exec.SideEffectPossible
|
||||||
|
}
|
||||||
|
return exec.SideEffectNone
|
||||||
}
|
}
|
||||||
|
|
||||||
// resumeUnsafeMarkers 续跑不安全的关键词(会话已被污染:上下文溢出/
|
// resumeUnsafeMarkers 续跑不安全的关键词(会话已被污染:上下文溢出/
|
||||||
@@ -203,10 +619,8 @@ var resumeUnsafeMarkers = []string{
|
|||||||
}
|
}
|
||||||
|
|
||||||
// rotateIfPoisoned 上次失败属 resume-unsafe 且存在旧会话时归档旧会话。
|
// rotateIfPoisoned 上次失败属 resume-unsafe 且存在旧会话时归档旧会话。
|
||||||
func (b *Backend) rotateIfPoisoned(workDir string) {
|
func (b *Backend) rotateIfPoisoned(workDir, transcript string) {
|
||||||
b.mu.Lock()
|
reason := strings.ToLower(b.failureReason(workDir))
|
||||||
reason := strings.ToLower(b.lastFailure[workDir])
|
|
||||||
b.mu.Unlock()
|
|
||||||
if reason == "" {
|
if reason == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -220,28 +634,110 @@ func (b *Backend) rotateIfPoisoned(workDir string) {
|
|||||||
if !unsafe {
|
if !unsafe {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
path := filepath.Join(workDir, ".agentx", "transcript.jsonl")
|
path := transcript
|
||||||
|
if strings.TrimSpace(path) == "" {
|
||||||
|
path = filepath.Join(workDir, ".agentx", "transcript.jsonl")
|
||||||
|
}
|
||||||
if _, err := os.Stat(path); err != nil {
|
if _, err := os.Stat(path); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
b.logger().Warn("rotating poisoned session", "work_dir", workDir)
|
||||||
_ = os.Rename(path, filepath.Join(workDir, ".agentx",
|
_ = os.Rename(path, filepath.Join(workDir, ".agentx",
|
||||||
"transcript.poisoned."+time.Now().UTC().Format("20060102T150405.000000000")+".jsonl"))
|
"transcript.poisoned."+time.Now().UTC().Format("20060102T150405.000000000")+".jsonl"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// systemPrompt 组装系统提示:角色指令 + 交付物与工具档位约束。
|
// bindSessionScope records scope identity on the store and fail-closes when an
|
||||||
func systemPrompt(req exec.Request, profile string) string {
|
// existing store's recorded identity differs or is absent for a non-empty scope
|
||||||
var b strings.Builder
|
// (do not auto-attribute old records to a new identity).
|
||||||
if strings.TrimSpace(req.Instructions) != "" {
|
func (b *Backend) bindSessionScope(store *session.Store, scopeID, model, execID string) error {
|
||||||
b.WriteString(req.Instructions)
|
meta := store.ReadMeta()
|
||||||
b.WriteString("\n\n")
|
if meta == nil {
|
||||||
|
meta = map[string]string{}
|
||||||
}
|
}
|
||||||
b.WriteString("Deliverable files: ")
|
if scopeID != "" {
|
||||||
b.WriteString(strings.Join(req.Targets, ", "))
|
prev := strings.TrimSpace(meta["scope"])
|
||||||
b.WriteString(".")
|
if prev != "" && prev != scopeID {
|
||||||
if profile == exec.ToolProfileWorkspace {
|
return errString("session store scope identity mismatch: store has " + prev + ", request has " + scopeID)
|
||||||
b.WriteString(" Use workspace tools to write those files. Do not invent paths outside the workspace.")
|
}
|
||||||
} else {
|
if prev == "" {
|
||||||
b.WriteString(" Reply with the deliverable text only. Tools are disabled.")
|
entries, err := store.Entries()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(entries) > 0 {
|
||||||
|
return errString("session store lacks scope identity; refusing to adopt for scope " + scopeID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
meta["scope"] = scopeID
|
||||||
}
|
}
|
||||||
return b.String()
|
meta["model"] = model
|
||||||
|
meta["exec_id"] = execID
|
||||||
|
meta["updated"] = time.Now().UTC().Format(time.RFC3339)
|
||||||
|
return store.SaveMeta(meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) logger() *logsys.Logger {
|
||||||
|
if b == nil || b.opts.Log == nil {
|
||||||
|
return logsys.Nop()
|
||||||
|
}
|
||||||
|
log := b.opts.Log.Module("native")
|
||||||
|
if b.opts.ExecutionID == "" {
|
||||||
|
return log
|
||||||
|
}
|
||||||
|
return log.With("execution_id", b.opts.ExecutionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastFailureFile = ".native-last-failure"
|
||||||
|
|
||||||
|
func (b *Backend) noteFailure(workDir, reason string) {
|
||||||
|
b.mu.Lock()
|
||||||
|
b.lastFailure[workDir] = reason
|
||||||
|
b.mu.Unlock()
|
||||||
|
_ = os.WriteFile(filepath.Join(workDir, lastFailureFile), []byte(reason), 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) clearFailure(workDir string) {
|
||||||
|
b.mu.Lock()
|
||||||
|
delete(b.lastFailure, workDir)
|
||||||
|
b.mu.Unlock()
|
||||||
|
_ = os.Remove(filepath.Join(workDir, lastFailureFile))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Backend) failureReason(workDir string) string {
|
||||||
|
b.mu.Lock()
|
||||||
|
reason := b.lastFailure[workDir]
|
||||||
|
b.mu.Unlock()
|
||||||
|
if reason != "" {
|
||||||
|
return reason
|
||||||
|
}
|
||||||
|
raw, err := os.ReadFile(filepath.Join(workDir, lastFailureFile))
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return string(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
// systemPrompt 经 promptx 组装:角色指令 + 环境事实 + 技能索引 + 交付物约束。
|
||||||
|
func (b *Backend) systemPrompt(req exec.Request, model, profile, workDir string, skillIndex []skills.Skill) string {
|
||||||
|
var index []promptx.IndexEntry
|
||||||
|
for _, s := range skillIndex {
|
||||||
|
index = append(index, promptx.IndexEntry{Name: s.Name, Description: s.Description})
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
weekday := map[string]string{
|
||||||
|
"Sunday": "周日", "Monday": "周一", "Tuesday": "周二", "Wednesday": "周三",
|
||||||
|
"Thursday": "周四", "Friday": "周五", "Saturday": "周六",
|
||||||
|
}[now.Weekday().String()]
|
||||||
|
return promptx.Compose(promptx.Parts{
|
||||||
|
Instructions: req.Instructions,
|
||||||
|
SkillsDeclared: req.Skills,
|
||||||
|
SkillsIndex: index,
|
||||||
|
Deliverables: req.Targets,
|
||||||
|
Env: promptx.EnvFacts{
|
||||||
|
Date: now.Format("2006-01-02") + "(" + weekday + ")",
|
||||||
|
Model: model,
|
||||||
|
WorkspaceRoot: workDir,
|
||||||
|
Profile: profile,
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+137
-11
@@ -2,6 +2,7 @@ package native
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -39,12 +40,17 @@ func script(turns ...llm.Message) llm.StreamFunc {
|
|||||||
|
|
||||||
func TestBackendWorkspaceProfileWritesDeliverable(t *testing.T) {
|
func TestBackendWorkspaceProfileWritesDeliverable(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
b := New(Options{Model: "fake", Stream: script(
|
b := New(Options{
|
||||||
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
|
ModelOptions: ModelOptions{
|
||||||
ID: "c1", Name: "write_file", Arguments: `{"path":"output.md","content":"# 交付\n\n工具写入的交付物内容。","reason":"产出交付物"}`,
|
Model: "fake",
|
||||||
}}},
|
Stream: script(
|
||||||
llm.Message{Role: llm.RoleAssistant, Content: "已写入 output.md", StopReason: llm.StopEnd},
|
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
|
||||||
)})
|
ID: "c1", Name: "write_file", Arguments: `{"path":"output.md","content":"# 交付\n\n工具写入的交付物内容。"}`,
|
||||||
|
}}},
|
||||||
|
llm.Message{Role: llm.RoleAssistant, Content: "已写入 output.md", StopReason: llm.StopEnd},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
})
|
||||||
var events []exec.ProcessEvent
|
var events []exec.ProcessEvent
|
||||||
res, err := b.Execute(context.Background(), exec.Request{
|
res, err := b.Execute(context.Background(), exec.Request{
|
||||||
WorkDir: dir, Prompt: "写交付物", Instructions: "你是测试角色",
|
WorkDir: dir, Prompt: "写交付物", Instructions: "你是测试角色",
|
||||||
@@ -81,9 +87,14 @@ func TestBackendWorkspaceProfileWritesDeliverable(t *testing.T) {
|
|||||||
|
|
||||||
func TestBackendReasoningProfileWritesText(t *testing.T) {
|
func TestBackendReasoningProfileWritesText(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
b := New(Options{Model: "fake", Stream: script(
|
b := New(Options{
|
||||||
llm.Message{Role: llm.RoleAssistant, Content: "纯文本交付内容(无工具)。", StopReason: llm.StopEnd},
|
ModelOptions: ModelOptions{
|
||||||
)})
|
Model: "fake",
|
||||||
|
Stream: script(
|
||||||
|
llm.Message{Role: llm.RoleAssistant, Content: "纯文本交付内容(无工具)。", StopReason: llm.StopEnd},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
})
|
||||||
res, err := b.Execute(context.Background(), exec.Request{
|
res, err := b.Execute(context.Background(), exec.Request{
|
||||||
WorkDir: dir, Prompt: "直接回答", ToolProfile: exec.ToolProfileReasoning,
|
WorkDir: dir, Prompt: "直接回答", ToolProfile: exec.ToolProfileReasoning,
|
||||||
Targets: []string{"notes.md"},
|
Targets: []string{"notes.md"},
|
||||||
@@ -100,6 +111,79 @@ func TestBackendReasoningProfileWritesText(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBackendReasoningProfileRejectsPathEscape(t *testing.T) {
|
||||||
|
parent := t.TempDir()
|
||||||
|
dir := filepath.Join(parent, "work")
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
outside := filepath.Join(parent, "pwned.md")
|
||||||
|
leak := "SHOULD-NOT-ESCAPE"
|
||||||
|
b := New(Options{
|
||||||
|
ModelOptions: ModelOptions{
|
||||||
|
Model: "fake",
|
||||||
|
Stream: script(
|
||||||
|
llm.Message{Role: llm.RoleAssistant, Content: leak, StopReason: llm.StopEnd},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Traversal: authorize-before-write must reject and leave zero target writes.
|
||||||
|
res, err := b.Execute(context.Background(), exec.Request{
|
||||||
|
WorkDir: dir, Prompt: "escape", ToolProfile: exec.ToolProfileReasoning,
|
||||||
|
Targets: []string{"safe.md", "../pwned.md"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if res.Success || !strings.Contains(res.ErrorReason, "escapes workspace") {
|
||||||
|
t.Fatalf("want path escape failure, got %+v", res)
|
||||||
|
}
|
||||||
|
if _, statErr := os.Stat(outside); !os.IsNotExist(statErr) {
|
||||||
|
t.Fatalf("traversal must not create outside file: %v", statErr)
|
||||||
|
}
|
||||||
|
if _, statErr := os.Stat(filepath.Join(dir, "safe.md")); !os.IsNotExist(statErr) {
|
||||||
|
t.Fatal("rejected batch must perform zero target writes")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Absolute path outside workDir.
|
||||||
|
res, err = b.Execute(context.Background(), exec.Request{
|
||||||
|
WorkDir: dir, Prompt: "escape-abs", ToolProfile: exec.ToolProfileReasoning,
|
||||||
|
Targets: []string{outside},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if res.Success || !strings.Contains(res.ErrorReason, "escapes workspace") {
|
||||||
|
t.Fatalf("want absolute escape failure, got %+v", res)
|
||||||
|
}
|
||||||
|
if _, statErr := os.Stat(outside); !os.IsNotExist(statErr) {
|
||||||
|
t.Fatalf("absolute escape must not create outside file: %v", statErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Symlink component leaving the workspace.
|
||||||
|
outsideDir := filepath.Join(parent, "outside-dir")
|
||||||
|
if err := os.MkdirAll(outsideDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Symlink(outsideDir, filepath.Join(dir, "link")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
res, err = b.Execute(context.Background(), exec.Request{
|
||||||
|
WorkDir: dir, Prompt: "escape-symlink", ToolProfile: exec.ToolProfileReasoning,
|
||||||
|
Targets: []string{"link/secret.md"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if res.Success || !strings.Contains(res.ErrorReason, "escapes workspace") {
|
||||||
|
t.Fatalf("want symlink escape failure, got %+v", res)
|
||||||
|
}
|
||||||
|
if _, statErr := os.Stat(filepath.Join(outsideDir, "secret.md")); !os.IsNotExist(statErr) {
|
||||||
|
t.Fatalf("symlink escape must not write outside: %v", statErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBackendFailClosedWithoutConfig(t *testing.T) {
|
func TestBackendFailClosedWithoutConfig(t *testing.T) {
|
||||||
b := New(Options{})
|
b := New(Options{})
|
||||||
res, err := b.Execute(context.Background(), exec.Request{WorkDir: t.TempDir(), Targets: []string{"x.md"}})
|
res, err := b.Execute(context.Background(), exec.Request{WorkDir: t.TempDir(), Targets: []string{"x.md"}})
|
||||||
@@ -109,14 +193,56 @@ func TestBackendFailClosedWithoutConfig(t *testing.T) {
|
|||||||
if res.Success || !strings.Contains(res.ErrorReason, "requires") {
|
if res.Success || !strings.Contains(res.ErrorReason, "requires") {
|
||||||
t.Fatalf("want fail-closed config error, got %+v", res)
|
t.Fatalf("want fail-closed config error, got %+v", res)
|
||||||
}
|
}
|
||||||
res2, _ := New(Options{APIBase: "http://x", APIKey: "k"}).Execute(context.Background(),
|
res2, _ := New(Options{
|
||||||
|
ModelOptions: ModelOptions{
|
||||||
|
APIBase: "http://x",
|
||||||
|
APIKey: "k",
|
||||||
|
},
|
||||||
|
}).Execute(context.Background(),
|
||||||
exec.Request{WorkDir: t.TempDir(), Targets: []string{"x.md"}})
|
exec.Request{WorkDir: t.TempDir(), Targets: []string{"x.md"}})
|
||||||
if res2.Success || !strings.Contains(res2.ErrorReason, "model") {
|
if res2.Success || !strings.Contains(res2.ErrorReason, "model") {
|
||||||
t.Fatalf("want model required error, got %+v", res2)
|
t.Fatalf("want model required error, got %+v", res2)
|
||||||
}
|
}
|
||||||
res3, _ := New(Options{Stream: script()}).Execute(context.Background(),
|
res3, _ := New(Options{
|
||||||
|
ModelOptions: ModelOptions{
|
||||||
|
Stream: script(),
|
||||||
|
},
|
||||||
|
}).Execute(context.Background(),
|
||||||
exec.Request{WorkDir: t.TempDir(), Targets: []string{"x.md"}, ToolProfile: "bogus"})
|
exec.Request{WorkDir: t.TempDir(), Targets: []string{"x.md"}, ToolProfile: "bogus"})
|
||||||
if res3.Success || !strings.Contains(res3.ErrorReason, "tool profile") {
|
if res3.Success || !strings.Contains(res3.ErrorReason, "tool profile") {
|
||||||
t.Fatalf("want profile error, got %+v", res3)
|
t.Fatalf("want profile error, got %+v", res3)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBeforeToolReachesSquadMembers(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
var seen string
|
||||||
|
b := New(Options{
|
||||||
|
ModelOptions: ModelOptions{
|
||||||
|
Model: "fake",
|
||||||
|
Stream: script(
|
||||||
|
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
|
||||||
|
ID: "c1", Name: "write_file", Arguments: `{"path":"output.md","content":"nope"}`,
|
||||||
|
}}},
|
||||||
|
llm.Message{Role: llm.RoleAssistant, Content: "blocked", StopReason: llm.StopEnd},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
GateOptions: GateOptions{BeforeTool: func(call llm.ToolCall) error {
|
||||||
|
seen = call.Name
|
||||||
|
return errors.New("计划模式拒绝了写工具")
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
res, err := b.Execute(context.Background(), exec.Request{WorkDir: dir, Prompt: "写", Targets: []string{"output.md"}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if seen != "write_file" {
|
||||||
|
t.Fatalf("before tool saw %q", seen)
|
||||||
|
}
|
||||||
|
if res.Success {
|
||||||
|
t.Fatal("denied write still succeeded")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, "output.md")); err == nil {
|
||||||
|
t.Fatal("write_file ran after deny")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package native
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"agent-native/exec"
|
||||||
|
"agent-native/runtime/llm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBackendCompactBeforeRun(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
// 预置一份超阈值的长转录(系统消息 + 12 条历史)。
|
||||||
|
transcript := filepath.Join(dir, ".agentx", "transcript.jsonl")
|
||||||
|
if err := os.MkdirAll(filepath.Dir(transcript), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(`{"role":"system","content":"sys"}` + "\n")
|
||||||
|
for i := 0; i < 12; i++ {
|
||||||
|
b.WriteString(`{"role":"user","content":"` + strings.Repeat("x", 400) + `"}` + "\n")
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(transcript, []byte(b.String()), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var sawSummary bool
|
||||||
|
// 注入式捕获:验证 Run 收到的消息里有摘要标记。
|
||||||
|
bk2 := New(Options{
|
||||||
|
ModelOptions: ModelOptions{
|
||||||
|
Model: "fake",
|
||||||
|
Stream: captureFirstStream(&sawSummary),
|
||||||
|
},
|
||||||
|
SessionOptions: SessionOptions{
|
||||||
|
Compact: &CompactConfig{ThresholdChars: 1000, KeepLastN: 3, Summarizer: func(ctx context.Context, messages []llm.Message) (string, error) {
|
||||||
|
return "预置历史摘要", nil
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
res, err := bk2.Execute(context.Background(), exec.Request{
|
||||||
|
WorkDir: dir, Prompt: "继续", Targets: []string{"output.md"},
|
||||||
|
ToolProfile: exec.ToolProfileReasoning,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if res.Success && !sawSummary {
|
||||||
|
t.Fatal("compaction should have run before the loop")
|
||||||
|
}
|
||||||
|
data, _ := os.ReadFile(transcript)
|
||||||
|
if !strings.Contains(string(data), "预置历史摘要") || !strings.Contains(string(data), `"type":"compaction"`) {
|
||||||
|
t.Fatalf("transcript should keep history and append a compaction entry, got:\n%.400s", data)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(data), `"role":"system"`) {
|
||||||
|
t.Fatal("compaction must not rewrite the original transcript")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func captureFirstStream(saw *bool) llm.StreamFunc {
|
||||||
|
return func(ctx context.Context, model string, messages []llm.Message, decls []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
|
||||||
|
for _, m := range messages {
|
||||||
|
if strings.Contains(m.Content, "[conversation summary]") {
|
||||||
|
*saw = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ch := make(chan llm.Event, 4)
|
||||||
|
go func() {
|
||||||
|
defer close(ch)
|
||||||
|
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{Role: llm.RoleAssistant, Content: "压缩后续写完成", StopReason: llm.StopEnd}}
|
||||||
|
}()
|
||||||
|
return ch
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package native
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"agent-native/exec"
|
||||||
|
)
|
||||||
|
|
||||||
|
// contractBackend 把 Backend 适配为 exec.ContractBackend。
|
||||||
|
// 独立类型的原因:旧门面 Execute(ctx, exec.Request) 与契约的
|
||||||
|
// Execute(ctx, exec.ContractRequest) 签名冲突,同名方法不能并存;
|
||||||
|
// 包装让现有 squad 消费(旧门面)与契约消费(conformance/Core)互不影响。
|
||||||
|
type contractBackend struct{ b *Backend }
|
||||||
|
|
||||||
|
// Contract 返回该后端的契约形态(N1 WP6;旧门面保持不变)。
|
||||||
|
func (b *Backend) Contract() exec.ContractBackend {
|
||||||
|
return contractBackend{b: b}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c contractBackend) Descriptor() exec.BackendDescriptor {
|
||||||
|
return exec.BackendDescriptor{
|
||||||
|
Name: c.b.Name(),
|
||||||
|
Capabilities: []exec.Capability{exec.CapText, exec.CapStream, exec.CapTools},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c contractBackend) Execute(ctx context.Context, req exec.ContractRequest) (*exec.ContractResult, error) {
|
||||||
|
var onEvent func(ev exec.ProcessEvent)
|
||||||
|
if req.OnEvent != nil {
|
||||||
|
next := req.OnEvent
|
||||||
|
onEvent = func(ev exec.ProcessEvent) {
|
||||||
|
next(exec.Event{Kind: exec.EventKind(ev.Type), Text: ev.Text, Tool: ev.Tool, Detail: ev.Detail})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 契约语义:空 ToolPolicy.Profile 等同 reasoning(无工具纯文本任务)。
|
||||||
|
profile := strings.TrimSpace(req.ToolPolicy.Profile)
|
||||||
|
if profile == "" {
|
||||||
|
profile = exec.ToolProfileReasoning
|
||||||
|
}
|
||||||
|
scopeID := scopeIdentity(req.SessionScope)
|
||||||
|
old := exec.Request{
|
||||||
|
Prompt: req.Prompt,
|
||||||
|
Instructions: req.Instructions,
|
||||||
|
ExecutionID: req.ExecutionID,
|
||||||
|
Targets: append([]string(nil), req.Targets...),
|
||||||
|
ToolProfile: profile,
|
||||||
|
ToolAllowlist: append([]string(nil), req.ToolPolicy.Allowlist...),
|
||||||
|
WorkDir: req.Workspace.Root,
|
||||||
|
SessionScope: scopeID,
|
||||||
|
OnEvent: onEvent,
|
||||||
|
}
|
||||||
|
if b := req.ContractBudget; b.MaxPromptTokens > 0 || b.MaxCompletionTokens > 0 || !b.Deadline.IsZero() {
|
||||||
|
old.Budget = &exec.Budget{
|
||||||
|
MaxPromptTokens: b.MaxPromptTokens,
|
||||||
|
MaxCompletionTokens: b.MaxCompletionTokens,
|
||||||
|
Deadline: b.Deadline,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// CapText / F15: pure-text contract tasks must not force implicit file delivery.
|
||||||
|
explicitTargets := len(req.Targets) > 0
|
||||||
|
if !explicitTargets && profile == exec.ToolProfileReasoning {
|
||||||
|
ctx = withAllowEmptyTargets(ctx)
|
||||||
|
}
|
||||||
|
ctx = withWritable(ctx, req.Workspace.Writable)
|
||||||
|
var finalText string
|
||||||
|
ctx = withTextSink(ctx, &finalText)
|
||||||
|
|
||||||
|
res, err := c.b.Execute(ctx, old)
|
||||||
|
if err != nil {
|
||||||
|
// 传输/实现级错误:按契约映射为 Unknown,不经 Go error 返回业务终态。
|
||||||
|
return &exec.ContractResult{
|
||||||
|
Outcome: exec.OutcomeUnknown,
|
||||||
|
Err: &exec.Error{Code: exec.ErrProviderUnavailable, Reason: err.Error(), SideEffects: exec.SideEffectPossible},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
out := &exec.ContractResult{
|
||||||
|
Logs: res.Logs,
|
||||||
|
Usage: exec.Usage{
|
||||||
|
PromptTokens: res.PromptTokens,
|
||||||
|
CompletionTokens: res.CompletionTokens,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case res.Success:
|
||||||
|
out.Outcome = exec.OutcomeSucceeded
|
||||||
|
if explicitTargets {
|
||||||
|
out.Files = res.Patches
|
||||||
|
}
|
||||||
|
// CapText: surface the real assistant text for pure-text success.
|
||||||
|
out.Text = finalText
|
||||||
|
if out.Text == "" && len(res.Patches) == 1 {
|
||||||
|
for _, body := range res.Patches {
|
||||||
|
out.Text = string(body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case errors.Is(ctx.Err(), context.Canceled):
|
||||||
|
out.Outcome = exec.OutcomeCanceled
|
||||||
|
out.Err = &exec.Error{Code: exec.ErrCanceled, Reason: res.ErrorReason, SideEffects: exec.SideEffectPossible}
|
||||||
|
case errors.Is(ctx.Err(), context.DeadlineExceeded):
|
||||||
|
out.Outcome = exec.OutcomeFailed
|
||||||
|
out.Err = &exec.Error{Code: exec.ErrDeadlineExceeded, Reason: res.ErrorReason, SideEffects: exec.SideEffectPossible}
|
||||||
|
default:
|
||||||
|
out.Outcome = exec.OutcomeFailed
|
||||||
|
out.Err = &exec.Error{Code: exec.ErrProviderRejected, Reason: res.ErrorReason, Retryable: res.Retryable, SideEffects: exec.SideEffectPossible}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package native
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"agent-native/exec"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Contract-binding context keys: Writable / CapText / empty-target semantics
|
||||||
|
// stay inside the adapter so exec.Request remains unchanged for the legacy facade.
|
||||||
|
|
||||||
|
type ctxKey int
|
||||||
|
|
||||||
|
const (
|
||||||
|
keyWritable ctxKey = iota
|
||||||
|
keyAllowEmptyTargets
|
||||||
|
keyTextSink
|
||||||
|
)
|
||||||
|
|
||||||
|
func withWritable(ctx context.Context, paths []string) context.Context {
|
||||||
|
if len(paths) == 0 {
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
return context.WithValue(ctx, keyWritable, append([]string(nil), paths...))
|
||||||
|
}
|
||||||
|
|
||||||
|
func writableFrom(ctx context.Context) []string {
|
||||||
|
v, _ := ctx.Value(keyWritable).([]string)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func withAllowEmptyTargets(ctx context.Context) context.Context {
|
||||||
|
return context.WithValue(ctx, keyAllowEmptyTargets, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func allowEmptyTargets(ctx context.Context) bool {
|
||||||
|
v, _ := ctx.Value(keyAllowEmptyTargets).(bool)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func withTextSink(ctx context.Context, sink *string) context.Context {
|
||||||
|
if sink == nil {
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
return context.WithValue(ctx, keyTextSink, sink)
|
||||||
|
}
|
||||||
|
|
||||||
|
func captureText(ctx context.Context, text string) {
|
||||||
|
if sink, ok := ctx.Value(keyTextSink).(*string); ok && sink != nil {
|
||||||
|
*sink = text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// scopeIdentity returns a stable non-empty identity string for a non-zero
|
||||||
|
// SessionScope, or "" for the legacy one-shot / empty-scope path.
|
||||||
|
func scopeIdentity(s exec.SessionScope) string {
|
||||||
|
t := strings.TrimSpace(s.Tenant)
|
||||||
|
p := strings.TrimSpace(s.Project)
|
||||||
|
sess := strings.TrimSpace(s.Session)
|
||||||
|
if t == "" && p == "" && sess == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return t + "/" + p + "/" + sess
|
||||||
|
}
|
||||||
|
|
||||||
|
// transcriptPathForScope binds storage identity to SessionScope.
|
||||||
|
// Empty scope keeps the historical fixed path under .agentx/transcript.jsonl.
|
||||||
|
func transcriptPathForScope(workDir, scopeID string) string {
|
||||||
|
if strings.TrimSpace(scopeID) == "" {
|
||||||
|
return filepath.Join(workDir, ".agentx", "transcript.jsonl")
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256([]byte(scopeID))
|
||||||
|
return filepath.Join(workDir, ".agentx", "transcript."+hex.EncodeToString(sum[:16])+".jsonl")
|
||||||
|
}
|
||||||
|
|
||||||
|
func pathInWriteSet(allow []string, rel string) bool {
|
||||||
|
if len(allow) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
rel = filepath.ToSlash(filepath.Clean(strings.TrimSpace(rel)))
|
||||||
|
for _, a := range allow {
|
||||||
|
if filepath.ToSlash(filepath.Clean(strings.TrimSpace(a))) == rel {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
package native
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"agent-native/exec"
|
||||||
|
"agent-native/runtime/llm"
|
||||||
|
"agent-native/runtime/session"
|
||||||
|
"agent-native/runtime/tools"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestContractScopeIsolationNoSharedTranscript(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
b := New(Options{
|
||||||
|
ModelOptions: ModelOptions{
|
||||||
|
Model: "fake",
|
||||||
|
Stream: script(
|
||||||
|
llm.Message{Role: llm.RoleAssistant, Content: "from-a", StopReason: llm.StopEnd},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}).Contract()
|
||||||
|
|
||||||
|
scopeA := exec.SessionScope{Tenant: "t1", Project: "p1", Session: "s-a"}
|
||||||
|
scopeB := exec.SessionScope{Tenant: "t1", Project: "p1", Session: "s-b"}
|
||||||
|
resA, err := b.Execute(context.Background(), exec.ContractRequest{
|
||||||
|
ExecutionID: "e-a",
|
||||||
|
Prompt: "hello-a",
|
||||||
|
SessionScope: scopeA,
|
||||||
|
Workspace: exec.WorkspaceHandle{Root: dir},
|
||||||
|
})
|
||||||
|
if err != nil || resA.Outcome != exec.OutcomeSucceeded {
|
||||||
|
t.Fatalf("scope A: %+v %v", resA, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fresh backend/stream for scope B so replies differ.
|
||||||
|
b2 := New(Options{
|
||||||
|
ModelOptions: ModelOptions{
|
||||||
|
Model: "fake",
|
||||||
|
Stream: script(
|
||||||
|
llm.Message{Role: llm.RoleAssistant, Content: "from-b", StopReason: llm.StopEnd},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}).Contract()
|
||||||
|
resB, err := b2.Execute(context.Background(), exec.ContractRequest{
|
||||||
|
ExecutionID: "e-b",
|
||||||
|
Prompt: "hello-b",
|
||||||
|
SessionScope: scopeB,
|
||||||
|
Workspace: exec.WorkspaceHandle{Root: dir},
|
||||||
|
})
|
||||||
|
if err != nil || resB.Outcome != exec.OutcomeSucceeded {
|
||||||
|
t.Fatalf("scope B: %+v %v", resB, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pathA := transcriptPathForScope(dir, scopeIdentity(scopeA))
|
||||||
|
pathB := transcriptPathForScope(dir, scopeIdentity(scopeB))
|
||||||
|
if pathA == pathB {
|
||||||
|
t.Fatal("different scopes must not share a transcript path")
|
||||||
|
}
|
||||||
|
if pathA == filepath.Join(dir, ".agentx", "transcript.jsonl") {
|
||||||
|
t.Fatal("non-empty scope must not use the legacy fixed transcript path")
|
||||||
|
}
|
||||||
|
storeA, err := session.Open(pathA)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
storeB, err := session.Open(pathB)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
msgsA, err := storeA.Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
msgsB, err := storeB.Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
joinedA := messagesBlob(msgsA)
|
||||||
|
joinedB := messagesBlob(msgsB)
|
||||||
|
if strings.Contains(joinedA, "hello-b") || strings.Contains(joinedA, "from-b") {
|
||||||
|
t.Fatalf("scope A transcript leaked B context: %s", joinedA)
|
||||||
|
}
|
||||||
|
if strings.Contains(joinedB, "hello-a") || strings.Contains(joinedB, "from-a") {
|
||||||
|
t.Fatalf("scope B transcript leaked A context: %s", joinedB)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContractMatchingScopeCanResume(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
scope := exec.SessionScope{Tenant: "t", Project: "p", Session: "resume-me"}
|
||||||
|
b1 := New(Options{
|
||||||
|
ModelOptions: ModelOptions{
|
||||||
|
Model: "fake",
|
||||||
|
Stream: script(llm.Message{Role: llm.RoleAssistant, Content: "first", StopReason: llm.StopEnd}),
|
||||||
|
},
|
||||||
|
}).Contract()
|
||||||
|
if res, err := b1.Execute(context.Background(), exec.ContractRequest{
|
||||||
|
ExecutionID: "e1", Prompt: "turn-1", SessionScope: scope,
|
||||||
|
Workspace: exec.WorkspaceHandle{Root: dir},
|
||||||
|
}); err != nil || res.Outcome != exec.OutcomeSucceeded {
|
||||||
|
t.Fatalf("first turn: %+v %v", res, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var sawPrior bool
|
||||||
|
b2 := New(Options{
|
||||||
|
ModelOptions: ModelOptions{
|
||||||
|
Model: "fake",
|
||||||
|
Stream: func(ctx context.Context, model string, messages []llm.Message, decls []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
|
||||||
|
for _, m := range messages {
|
||||||
|
if m.Role == llm.RoleUser && m.Content == "turn-1" {
|
||||||
|
sawPrior = true
|
||||||
|
}
|
||||||
|
if m.Role == llm.RoleAssistant && m.Content == "first" {
|
||||||
|
sawPrior = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ch := make(chan llm.Event, 2)
|
||||||
|
go func() {
|
||||||
|
defer close(ch)
|
||||||
|
ch <- llm.Event{Kind: llm.KindTextDelta, Text: "second"}
|
||||||
|
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{Role: llm.RoleAssistant, Content: "second", StopReason: llm.StopEnd}}
|
||||||
|
}()
|
||||||
|
return ch
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}).Contract()
|
||||||
|
if res, err := b2.Execute(context.Background(), exec.ContractRequest{
|
||||||
|
ExecutionID: "e2", Prompt: "turn-2", SessionScope: scope,
|
||||||
|
Workspace: exec.WorkspaceHandle{Root: dir},
|
||||||
|
}); err != nil || res.Outcome != exec.OutcomeSucceeded {
|
||||||
|
t.Fatalf("resume turn: %+v %v", res, err)
|
||||||
|
}
|
||||||
|
if !sawPrior {
|
||||||
|
t.Fatal("matching scope must resume prior transcript context")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContractOldStoreWithoutIdentityNotAdopted(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
scope := exec.SessionScope{Tenant: "t", Project: "p", Session: "orphan"}
|
||||||
|
path := transcriptPathForScope(dir, scopeIdentity(scope))
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Pre-seed records with no scope identity in meta.
|
||||||
|
body := "{\"role\":\"user\",\"content\":\"old\"}\n{\"role\":\"assistant\",\"content\":\"ghost\"}\n"
|
||||||
|
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
b := New(Options{
|
||||||
|
ModelOptions: ModelOptions{
|
||||||
|
Model: "fake",
|
||||||
|
Stream: script(llm.Message{Role: llm.RoleAssistant, Content: "new", StopReason: llm.StopEnd}),
|
||||||
|
},
|
||||||
|
}).Contract()
|
||||||
|
res, err := b.Execute(context.Background(), exec.ContractRequest{
|
||||||
|
ExecutionID: "e-orphan", Prompt: "hi", SessionScope: scope,
|
||||||
|
Workspace: exec.WorkspaceHandle{Root: dir},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("transport: %v", err)
|
||||||
|
}
|
||||||
|
if res.Outcome == exec.OutcomeSucceeded {
|
||||||
|
t.Fatal("must not auto-adopt a store lacking scope identity")
|
||||||
|
}
|
||||||
|
if res.Err == nil || !strings.Contains(res.Err.Reason, "scope identity") {
|
||||||
|
t.Fatalf("want scope-identity refusal, got %+v", res.Err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContractWritableFileToolHonorsAllowlist(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
b := New(Options{
|
||||||
|
ModelOptions: ModelOptions{
|
||||||
|
Model: "fake",
|
||||||
|
Stream: script(
|
||||||
|
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
|
||||||
|
ID: "c1", Name: "write_file", Arguments: `{"path":"allowed.md","content":"ok"}`,
|
||||||
|
}}},
|
||||||
|
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
|
||||||
|
ID: "c2", Name: "write_file", Arguments: `{"path":"denied.md","content":"no"}`,
|
||||||
|
}}},
|
||||||
|
llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}).Contract()
|
||||||
|
res, err := b.Execute(context.Background(), exec.ContractRequest{
|
||||||
|
ExecutionID: "e-w",
|
||||||
|
Prompt: "write",
|
||||||
|
Workspace: exec.WorkspaceHandle{
|
||||||
|
Root: dir,
|
||||||
|
Writable: []string{"allowed.md"},
|
||||||
|
},
|
||||||
|
ToolPolicy: exec.ToolPolicy{Profile: exec.ToolProfileWorkspace},
|
||||||
|
Targets: []string{"allowed.md"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if res.Outcome != exec.OutcomeSucceeded {
|
||||||
|
// Tool denial is soft (IsError to model); run may still succeed if allowed.md exists.
|
||||||
|
t.Logf("outcome=%s err=%+v", res.Outcome, res.Err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, "allowed.md")); err != nil {
|
||||||
|
t.Fatalf("allowed write missing: %v (outcome=%s)", err, res.Outcome)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, "denied.md")); !os.IsNotExist(err) {
|
||||||
|
t.Fatal("denied.md must not be written under Writable allowlist")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContractWritableRejectsExecInPreflight(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
b := New(Options{
|
||||||
|
ModelOptions: ModelOptions{
|
||||||
|
Model: "fake",
|
||||||
|
Stream: script(llm.Message{Role: llm.RoleAssistant, Content: "x", StopReason: llm.StopEnd}),
|
||||||
|
},
|
||||||
|
ToolOptions: ToolOptions{
|
||||||
|
Exec: &tools.ExecConfig{Bash: "/bin/bash"},
|
||||||
|
},
|
||||||
|
}).Contract()
|
||||||
|
res, err := b.Execute(context.Background(), exec.ContractRequest{
|
||||||
|
ExecutionID: "e-exec",
|
||||||
|
Prompt: "x",
|
||||||
|
Workspace: exec.WorkspaceHandle{
|
||||||
|
Root: dir,
|
||||||
|
Writable: []string{"a.md"},
|
||||||
|
},
|
||||||
|
ToolPolicy: exec.ToolPolicy{Profile: exec.ToolProfileWorkspace},
|
||||||
|
Targets: []string{"a.md"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if res.Outcome == exec.OutcomeSucceeded {
|
||||||
|
t.Fatal("Writable + exec tools enabled must be rejected in preflight")
|
||||||
|
}
|
||||||
|
if res.Err == nil || !strings.Contains(res.Err.Reason, "writable") {
|
||||||
|
t.Fatalf("want writable preflight error, got %+v", res.Err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContractPureTextSuccessCarriesText(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
b := New(Options{
|
||||||
|
ModelOptions: ModelOptions{
|
||||||
|
Model: "fake",
|
||||||
|
Stream: script(llm.Message{Role: llm.RoleAssistant, Content: "纯文本答复内容", StopReason: llm.StopEnd}),
|
||||||
|
},
|
||||||
|
}).Contract()
|
||||||
|
res, err := b.Execute(context.Background(), exec.ContractRequest{
|
||||||
|
ExecutionID: "e-text",
|
||||||
|
Prompt: "说点什么",
|
||||||
|
Workspace: exec.WorkspaceHandle{Root: dir},
|
||||||
|
// empty ToolPolicy → reasoning; empty Targets → CapText, no file delivery
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if res.Outcome != exec.OutcomeSucceeded {
|
||||||
|
t.Fatalf("outcome=%s err=%+v", res.Outcome, res.Err)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(res.Text) == "" {
|
||||||
|
t.Fatal("CapText pure-text success must carry non-empty Text")
|
||||||
|
}
|
||||||
|
if !strings.Contains(res.Text, "纯文本答复内容") {
|
||||||
|
t.Fatalf("Text=%q", res.Text)
|
||||||
|
}
|
||||||
|
if len(res.Files) != 0 {
|
||||||
|
t.Fatalf("pure-text must not force file delivery, Files=%v", res.Files)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, "output.md")); !os.IsNotExist(err) {
|
||||||
|
t.Fatal("pure-text must not implicitly write output.md")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func messagesBlob(msgs []llm.Message) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, m := range msgs {
|
||||||
|
b.WriteString(string(m.Role))
|
||||||
|
b.WriteByte(':')
|
||||||
|
b.WriteString(m.Content)
|
||||||
|
b.WriteByte('\n')
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package native
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"agent-native/conformance"
|
||||||
|
"agent-native/exec"
|
||||||
|
"agent-native/runtime/llm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestContractBackendConformance 把 native 后端的契约形态挂上最小契约套件
|
||||||
|
// (N1 WP6:conformance 首次获得真实实现调用方)。
|
||||||
|
func TestContractBackendConformance(t *testing.T) {
|
||||||
|
conformance.Suite(t, func() exec.ContractBackend {
|
||||||
|
return New(Options{
|
||||||
|
ModelOptions: ModelOptions{
|
||||||
|
Model: "fake",
|
||||||
|
Stream: script(
|
||||||
|
llm.Message{Role: llm.RoleAssistant, Content: "hi", StopReason: llm.StopEnd},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}).Contract()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestContractExecuteCancel 取消路径:流阻塞到 ctx 结束,Outcome=Canceled 且
|
||||||
|
// SideEffects 显式。
|
||||||
|
func TestContractExecuteCancel(t *testing.T) {
|
||||||
|
b := New(Options{
|
||||||
|
ModelOptions: ModelOptions{
|
||||||
|
Model: "fake",
|
||||||
|
Stream: func(ctx context.Context, model string, messages []llm.Message, decls []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
|
||||||
|
ch := make(chan llm.Event)
|
||||||
|
go func() {
|
||||||
|
<-ctx.Done()
|
||||||
|
close(ch)
|
||||||
|
}()
|
||||||
|
return ch
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}).Contract()
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
res, err := b.Execute(ctx, exec.ContractRequest{ExecutionID: "cx", Prompt: "hi"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("transport error: %v", err)
|
||||||
|
}
|
||||||
|
if res.Outcome != exec.OutcomeCanceled && res.Outcome != exec.OutcomeFailed {
|
||||||
|
t.Fatalf("outcome = %q", res.Outcome)
|
||||||
|
}
|
||||||
|
if res.Err == nil || res.Err.SideEffects == "" {
|
||||||
|
t.Fatalf("err = %+v", res.Err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestContractDescriptor 描述的身份与能力。
|
||||||
|
func TestContractDescriptor(t *testing.T) {
|
||||||
|
d := New(Options{}).Contract().Descriptor()
|
||||||
|
if d.Name != "agent-native" {
|
||||||
|
t.Fatalf("name = %q", d.Name)
|
||||||
|
}
|
||||||
|
if !d.Has(exec.CapText) || !d.Has(exec.CapStream) || !d.Has(exec.CapTools) {
|
||||||
|
t.Fatalf("capabilities = %v", d.Capabilities)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestContractEmptyWorkspaceKeepsCWDClean 空 Workspace 的请求不得把会话状态
|
||||||
|
// 写进进程 CWD(CI 护栏:make test 后工作区必须保持干净;包目录的已提交
|
||||||
|
// .agentx 夹具内容必须逐字节不变)。
|
||||||
|
func TestContractEmptyWorkspaceKeepsCWDClean(t *testing.T) {
|
||||||
|
wd, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
snapshot := map[string][]byte{}
|
||||||
|
for _, rel := range []string{
|
||||||
|
filepath.Join(".agentx", "transcript.jsonl"),
|
||||||
|
filepath.Join(".agentx", "transcript.jsonl.meta.json"),
|
||||||
|
} {
|
||||||
|
if b, readErr := os.ReadFile(filepath.Join(wd, rel)); readErr == nil {
|
||||||
|
snapshot[rel] = b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b := New(Options{
|
||||||
|
ModelOptions: ModelOptions{
|
||||||
|
Model: "fake",
|
||||||
|
Stream: script(llm.Message{Role: llm.RoleAssistant, Content: "hi", StopReason: llm.StopEnd}),
|
||||||
|
},
|
||||||
|
}).Contract()
|
||||||
|
res, err := b.Execute(context.Background(), exec.ContractRequest{ExecutionID: "e-clean", Prompt: "hi"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("transport error: %v", err)
|
||||||
|
}
|
||||||
|
if res.Outcome != exec.OutcomeSucceeded {
|
||||||
|
t.Fatalf("outcome = %q err = %+v", res.Outcome, res.Err)
|
||||||
|
}
|
||||||
|
for rel, want := range snapshot {
|
||||||
|
got, readErr := os.ReadFile(filepath.Join(wd, rel))
|
||||||
|
if readErr != nil {
|
||||||
|
t.Fatalf("夹具被删除:%s", rel)
|
||||||
|
}
|
||||||
|
if string(got) != string(want) {
|
||||||
|
t.Fatalf("空 Workspace 的执行改写了包内夹具 %s(应落临时目录)", rel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, statErr := os.Stat(filepath.Join(wd, ".agentx", "activated-tools.json")); !os.IsNotExist(statErr) {
|
||||||
|
t.Fatal("空 Workspace 的执行在包目录写下了 activated-tools.json(应落临时目录)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package native
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"agent-native/exec"
|
||||||
|
"agent-native/runtime/llm"
|
||||||
|
"agent-native/runtime/tools"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fidelityEcho struct {
|
||||||
|
fail bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f fidelityEcho) Name() string { return "echo" }
|
||||||
|
func (f fidelityEcho) Description() string { return "echo" }
|
||||||
|
func (f fidelityEcho) Parameters() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
|
||||||
|
func (f fidelityEcho) Execute(context.Context, json.RawMessage) (tools.Result, error) {
|
||||||
|
if f.fail {
|
||||||
|
return tools.Result{Content: "native-boom", IsError: true}, nil
|
||||||
|
}
|
||||||
|
return tools.Result{Content: "ok"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNativeProcessEventPassesCallIDAndStatus(t *testing.T) {
|
||||||
|
b := New(Options{
|
||||||
|
ModelOptions: ModelOptions{
|
||||||
|
Model: "fake",
|
||||||
|
Stream: script(
|
||||||
|
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{
|
||||||
|
{ID: "n-a", Name: "echo", Arguments: `{}`},
|
||||||
|
{ID: "n-b", Name: "echo", Arguments: `{}`},
|
||||||
|
}},
|
||||||
|
llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
ToolOptions: ToolOptions{
|
||||||
|
HostTools: []tools.Tool{fidelityEcho{}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
var starts, ends []exec.ProcessEvent
|
||||||
|
_, err := b.Execute(context.Background(), exec.Request{
|
||||||
|
WorkDir: t.TempDir(), Prompt: "go", Targets: []string{"output.md"},
|
||||||
|
OnEvent: func(ev exec.ProcessEvent) {
|
||||||
|
switch ev.Type {
|
||||||
|
case "tool_execution_start":
|
||||||
|
starts = append(starts, ev)
|
||||||
|
case "tool_execution_end":
|
||||||
|
ends = append(ends, ev)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(starts) != 2 || len(ends) != 2 {
|
||||||
|
t.Fatalf("starts=%d ends=%d", len(starts), len(ends))
|
||||||
|
}
|
||||||
|
ids := map[string]bool{}
|
||||||
|
for _, ev := range starts {
|
||||||
|
if ev.CallID == "" {
|
||||||
|
t.Fatalf("start missing CallID: %+v", ev)
|
||||||
|
}
|
||||||
|
ids[ev.CallID] = true
|
||||||
|
}
|
||||||
|
if !ids["n-a"] || !ids["n-b"] {
|
||||||
|
t.Fatalf("expected distinct CallIDs n-a/n-b, got %v", ids)
|
||||||
|
}
|
||||||
|
for _, ev := range ends {
|
||||||
|
if ev.CallID == "" || !ids[ev.CallID] {
|
||||||
|
t.Fatalf("end CallID unpaired: %+v", ev)
|
||||||
|
}
|
||||||
|
if ev.Status != "ok" {
|
||||||
|
t.Fatalf("end Status=%q want ok: %+v", ev.Status, ev)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNativeProcessEventFailingToolStatusError(t *testing.T) {
|
||||||
|
b := New(Options{
|
||||||
|
ModelOptions: ModelOptions{
|
||||||
|
Model: "fake",
|
||||||
|
Stream: script(
|
||||||
|
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
|
||||||
|
ID: "n-fail", Name: "echo", Arguments: `{}`,
|
||||||
|
}}},
|
||||||
|
llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
ToolOptions: ToolOptions{
|
||||||
|
HostTools: []tools.Tool{fidelityEcho{fail: true}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
var end exec.ProcessEvent
|
||||||
|
_, err := b.Execute(context.Background(), exec.Request{
|
||||||
|
WorkDir: t.TempDir(), Prompt: "go", Targets: []string{"output.md"},
|
||||||
|
OnEvent: func(ev exec.ProcessEvent) {
|
||||||
|
if ev.Type == "tool_execution_end" {
|
||||||
|
end = ev
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if end.CallID != "n-fail" || end.Status != "error" || end.ErrorReason != "native-boom" {
|
||||||
|
t.Fatalf("end = %+v", end)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,410 @@
|
|||||||
|
package native
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
osexec "os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"agent-native/exec"
|
||||||
|
"agent-native/runtime/llm"
|
||||||
|
"agent-native/runtime/mcp"
|
||||||
|
"agent-native/runtime/subagent"
|
||||||
|
"agent-native/runtime/tools"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBackendExecToolsWiredWithOptionsAndRequestEnv(t *testing.T) {
|
||||||
|
bash, err := osexec.LookPath("bash")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("bash not available: %v", err)
|
||||||
|
}
|
||||||
|
dir := t.TempDir()
|
||||||
|
b := New(Options{
|
||||||
|
ModelOptions: ModelOptions{
|
||||||
|
Model: "fake",
|
||||||
|
Stream: script(
|
||||||
|
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
|
||||||
|
ID: "c1", Name: "run_command",
|
||||||
|
Arguments: `{"command":"echo role=$ROLE_FLAG step=$STEP_FLAG > exec_out.txt"}`,
|
||||||
|
}}},
|
||||||
|
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
|
||||||
|
ID: "c2", Name: "write_file",
|
||||||
|
Arguments: `{"path":"output.md","content":"done"}`,
|
||||||
|
}}},
|
||||||
|
llm.Message{Role: llm.RoleAssistant, Content: "ok", StopReason: llm.StopEnd},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
ToolOptions: ToolOptions{
|
||||||
|
Exec: &tools.ExecConfig{Bash: bash, Env: []string{"PATH=/usr/bin:/bin", "ROLE_FLAG=backend"}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
res, err := b.Execute(context.Background(), exec.Request{
|
||||||
|
WorkDir: dir, Prompt: "run", Targets: []string{"output.md"},
|
||||||
|
Env: map[string]string{"STEP_FLAG": "request"},
|
||||||
|
})
|
||||||
|
if err != nil || !res.Success {
|
||||||
|
t.Fatalf("execute: err=%v res=%+v logs=%s", err, res, res.Logs)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(filepath.Join(dir, "exec_out.txt"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(data), "role=backend") || !strings.Contains(string(data), "step=request") {
|
||||||
|
t.Fatalf("env merge wrong: %q", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackendExecToolsAbsentWithoutOptions(t *testing.T) {
|
||||||
|
stream, c := captureStream()
|
||||||
|
b := New(Options{
|
||||||
|
ModelOptions: ModelOptions{
|
||||||
|
Model: "fake",
|
||||||
|
Stream: stream,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
res, err := b.Execute(context.Background(), exec.Request{
|
||||||
|
WorkDir: t.TempDir(), Prompt: "x", Targets: []string{"output.md"},
|
||||||
|
ToolProfile: exec.ToolProfileReasoning,
|
||||||
|
})
|
||||||
|
if err != nil || !res.Success {
|
||||||
|
t.Fatalf("execute: err=%v res=%+v", err, res)
|
||||||
|
}
|
||||||
|
// reasoning 档本无工具;改用 workspace 档断言 decls 不含执行工具。
|
||||||
|
stream2, c2 := captureStream()
|
||||||
|
b2 := New(Options{
|
||||||
|
ModelOptions: ModelOptions{
|
||||||
|
Model: "fake",
|
||||||
|
Stream: stream2,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
_, _ = b2.Execute(context.Background(), exec.Request{
|
||||||
|
WorkDir: t.TempDir(), Prompt: "x", Targets: []string{"output.md"},
|
||||||
|
ToolProfile: exec.ToolProfileWorkspace,
|
||||||
|
})
|
||||||
|
for _, names := range c2.toolDecls {
|
||||||
|
for _, n := range names {
|
||||||
|
if n == "run_command" || n == "run_python" {
|
||||||
|
t.Fatalf("exec tools must not register without Options.Exec, got %v", names)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = c
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackendAllowlistCanDisableExecTools(t *testing.T) {
|
||||||
|
bash, _ := osexec.LookPath("bash")
|
||||||
|
stream, c := captureStream()
|
||||||
|
b := New(Options{
|
||||||
|
ModelOptions: ModelOptions{
|
||||||
|
Model: "fake",
|
||||||
|
Stream: stream,
|
||||||
|
},
|
||||||
|
ToolOptions: ToolOptions{
|
||||||
|
Exec: &tools.ExecConfig{Bash: bash, Env: []string{"PATH=/usr/bin:/bin"}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
res, err := b.Execute(context.Background(), exec.Request{
|
||||||
|
WorkDir: t.TempDir(), Prompt: "x", Targets: []string{"output.md"},
|
||||||
|
ToolAllowlist: []string{"read_file", "write_file", "edit_file", "list_dir", "grep", "glob", "multi_edit"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if res.Success {
|
||||||
|
t.Fatalf("write-only allowlist without write? (unexpected) res=%+v", res)
|
||||||
|
}
|
||||||
|
for _, names := range c.toolDecls {
|
||||||
|
for _, n := range names {
|
||||||
|
if n == "run_command" {
|
||||||
|
t.Fatalf("allowlist must be able to strip run_command, got %v", names)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakeSearchProvider 与 tools 包测试同构的最小搜索替身。
|
||||||
|
type fakeSearchProvider struct{ query string }
|
||||||
|
|
||||||
|
func (f *fakeSearchProvider) Search(_ context.Context, req tools.SearchRequest) (tools.SearchResponse, error) {
|
||||||
|
f.query = req.Query
|
||||||
|
return tools.SearchResponse{Answer: "ans", Results: []tools.SearchResult{{Title: "T", Content: "C", Score: 0.9}}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackendSearchToolsWiredViaOptions(t *testing.T) {
|
||||||
|
provider := &fakeSearchProvider{}
|
||||||
|
stream, c := captureStream(
|
||||||
|
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
|
||||||
|
ID: "c1", Name: "web_search", Arguments: `{"query":"golang generics"}`,
|
||||||
|
}}},
|
||||||
|
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
|
||||||
|
ID: "c2", Name: "write_file",
|
||||||
|
Arguments: `{"path":"output.md","content":"done"}`,
|
||||||
|
}}},
|
||||||
|
llm.Message{Role: llm.RoleAssistant, Content: "ok", StopReason: llm.StopEnd},
|
||||||
|
)
|
||||||
|
b := New(Options{
|
||||||
|
ModelOptions: ModelOptions{
|
||||||
|
Model: "fake",
|
||||||
|
Stream: stream,
|
||||||
|
},
|
||||||
|
ToolOptions: ToolOptions{
|
||||||
|
Search: provider,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
dir := t.TempDir()
|
||||||
|
res, err := b.Execute(context.Background(), exec.Request{
|
||||||
|
WorkDir: dir, Prompt: "search", Targets: []string{"output.md"},
|
||||||
|
})
|
||||||
|
if err != nil || !res.Success {
|
||||||
|
t.Fatalf("execute: err=%v res=%+v logs=%s", err, res, res.Logs)
|
||||||
|
}
|
||||||
|
if provider.query != "golang generics" {
|
||||||
|
t.Fatalf("provider should receive the query, got %q", provider.query)
|
||||||
|
}
|
||||||
|
// 搜索工具确实出现在 decls 里。
|
||||||
|
found := false
|
||||||
|
for _, names := range c.toolDecls {
|
||||||
|
for _, n := range names {
|
||||||
|
if n == "web_search" || n == "tavily_search" {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatal("search tools must be declared when Options.Search is set")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackendSearchToolsAbsentWithoutOptions(t *testing.T) {
|
||||||
|
stream, c := captureStream()
|
||||||
|
b := New(Options{
|
||||||
|
ModelOptions: ModelOptions{
|
||||||
|
Model: "fake",
|
||||||
|
Stream: stream,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
_, _ = b.Execute(context.Background(), exec.Request{
|
||||||
|
WorkDir: t.TempDir(), Prompt: "x", Targets: []string{"output.md"},
|
||||||
|
ToolProfile: exec.ToolProfileWorkspace,
|
||||||
|
})
|
||||||
|
for _, names := range c.toolDecls {
|
||||||
|
for _, n := range names {
|
||||||
|
if n == "web_search" || n == "tavily_search" {
|
||||||
|
t.Fatalf("search tools must not register without Options.Search, got %v", names)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackendMCPToolsWiredViaOptions(t *testing.T) {
|
||||||
|
exe, err := osexec.LookPath(os.Args[0])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// os.Args[0] 在 go test 下指向测试二进制——用其自身作为 MCP server。
|
||||||
|
mgr, err := mcp.NewManager([]mcp.ServerConfig{{
|
||||||
|
Name: "fake", Command: exe,
|
||||||
|
Env: append(os.Environ(), "AGENT_NATIVE_TEST_MCP_STDIO=1"),
|
||||||
|
}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer mgr.Close()
|
||||||
|
if err := mgr.RegisterInto(context.Background(), tools.NewRegistry(t.TempDir())); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
stream, c := captureStream(
|
||||||
|
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
|
||||||
|
ID: "c1", Name: "mcp__fake__echo", Arguments: `{"text":"via-backend"}`,
|
||||||
|
}}},
|
||||||
|
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
|
||||||
|
ID: "c2", Name: "write_file",
|
||||||
|
Arguments: `{"path":"output.md","content":"done"}`,
|
||||||
|
}}},
|
||||||
|
llm.Message{Role: llm.RoleAssistant, Content: "ok", StopReason: llm.StopEnd},
|
||||||
|
)
|
||||||
|
b := New(Options{
|
||||||
|
ModelOptions: ModelOptions{
|
||||||
|
Model: "fake",
|
||||||
|
Stream: stream,
|
||||||
|
},
|
||||||
|
ToolOptions: ToolOptions{
|
||||||
|
MCP: mgr,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
dir := t.TempDir()
|
||||||
|
res, err2 := b.Execute(context.Background(), exec.Request{
|
||||||
|
WorkDir: dir, Prompt: "call mcp", Targets: []string{"output.md"},
|
||||||
|
})
|
||||||
|
if err2 != nil || !res.Success {
|
||||||
|
t.Fatalf("execute: err=%v res=%+v logs=%s", err2, res, res.Logs)
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, names := range c.toolDecls {
|
||||||
|
for _, n := range names {
|
||||||
|
if n == "mcp__fake__echo" {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatal("mcp bridge tools must be declared to the model")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackendSkillsIndexAndLoader(t *testing.T) {
|
||||||
|
skillsRoot := t.TempDir()
|
||||||
|
skillDir := filepath.Join(skillsRoot, "docx-author")
|
||||||
|
if err := os.MkdirAll(skillDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\nname: docx-author\ndescription: 生成文档交付物\n---\n\n配方正文"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
stream, c := captureStream(
|
||||||
|
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
|
||||||
|
ID: "c1", Name: "load_skill", Arguments: `{"name":"docx-author"}`,
|
||||||
|
}}},
|
||||||
|
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
|
||||||
|
ID: "c2", Name: "write_file", Arguments: `{"path":"output.md","content":"done"}`,
|
||||||
|
}}},
|
||||||
|
llm.Message{Role: llm.RoleAssistant, Content: "ok", StopReason: llm.StopEnd},
|
||||||
|
)
|
||||||
|
b := New(Options{
|
||||||
|
ModelOptions: ModelOptions{
|
||||||
|
Model: "fake",
|
||||||
|
Stream: stream,
|
||||||
|
},
|
||||||
|
ToolOptions: ToolOptions{
|
||||||
|
SkillsRoots: []string{skillsRoot},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
dir := t.TempDir()
|
||||||
|
res, err := b.Execute(context.Background(), exec.Request{
|
||||||
|
WorkDir: dir, Prompt: "写", Targets: []string{"output.md"}, Skills: []string{"docx-author"},
|
||||||
|
})
|
||||||
|
if err != nil || !res.Success {
|
||||||
|
t.Fatalf("execute: err=%v res=%+v logs=%s", err, res, res.Logs)
|
||||||
|
}
|
||||||
|
if !strings.Contains(c.system, "- docx-author — 生成文档交付物") {
|
||||||
|
t.Fatalf("skill index missing in system prompt:\n%s", c.system)
|
||||||
|
}
|
||||||
|
if !strings.Contains(c.system, "Declared for this task") || !strings.Contains(c.system, "docx-author") {
|
||||||
|
t.Fatalf("declared skills missing:\n%s", c.system)
|
||||||
|
}
|
||||||
|
sawLoader := false
|
||||||
|
for _, names := range c.toolDecls {
|
||||||
|
for _, n := range names {
|
||||||
|
if n == "load_skill" {
|
||||||
|
sawLoader = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !sawLoader {
|
||||||
|
t.Fatal("load_skill must be declared when skills exist")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackendDeclaredSkillMissingFailsClosed(t *testing.T) {
|
||||||
|
b := New(Options{
|
||||||
|
ModelOptions: ModelOptions{
|
||||||
|
Model: "fake",
|
||||||
|
Stream: script(),
|
||||||
|
},
|
||||||
|
ToolOptions: ToolOptions{
|
||||||
|
SkillsRoots: []string{t.TempDir()},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
res, err := b.Execute(context.Background(), exec.Request{
|
||||||
|
WorkDir: t.TempDir(), Prompt: "x", Targets: []string{"output.md"},
|
||||||
|
Skills: []string{"ghost-skill"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if res.Success || !strings.Contains(res.ErrorReason, "declared skill not installed: ghost-skill") {
|
||||||
|
t.Fatalf("missing declared skill must fail-closed, got %+v", res)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackendWorkspaceSkillsRootAutoMerged(t *testing.T) {
|
||||||
|
stream, c := captureStream(
|
||||||
|
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
|
||||||
|
ID: "c1", Name: "write_file", Arguments: `{"path":"output.md","content":"done"}`,
|
||||||
|
}}},
|
||||||
|
llm.Message{Role: llm.RoleAssistant, Content: "ok", StopReason: llm.StopEnd},
|
||||||
|
)
|
||||||
|
b := New(Options{
|
||||||
|
ModelOptions: ModelOptions{
|
||||||
|
Model: "fake",
|
||||||
|
Stream: stream,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
dir := t.TempDir()
|
||||||
|
wsSkill := filepath.Join(dir, ".agent", "skills", "ws-skill")
|
||||||
|
if err := os.MkdirAll(wsSkill, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(wsSkill, "SKILL.md"), []byte("---\nname: ws-skill\ndescription: 工作区技能\n---\n正文"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
res, err := b.Execute(context.Background(), exec.Request{
|
||||||
|
WorkDir: dir, Prompt: "x", Targets: []string{"output.md"},
|
||||||
|
})
|
||||||
|
if err != nil || !res.Success {
|
||||||
|
t.Fatalf("execute: err=%v res=%+v", err, res)
|
||||||
|
}
|
||||||
|
if !strings.Contains(c.system, "ws-skill") {
|
||||||
|
t.Fatalf("workspace skills root must be merged into index:\n%s", c.system)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackendSubagentWiredViaOptions(t *testing.T) {
|
||||||
|
var delegated *subagent.Request
|
||||||
|
stream, c := captureStream(
|
||||||
|
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
|
||||||
|
ID: "c1", Name: "delegate_task",
|
||||||
|
Arguments: `{"goal":"查证数据","write_scope":"research/","done_criteria":"三来源"}`,
|
||||||
|
}}},
|
||||||
|
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
|
||||||
|
ID: "c2", Name: "write_file",
|
||||||
|
Arguments: `{"path":"output.md","content":"done"}`,
|
||||||
|
}}},
|
||||||
|
llm.Message{Role: llm.RoleAssistant, Content: "ok", StopReason: llm.StopEnd},
|
||||||
|
)
|
||||||
|
b := New(Options{
|
||||||
|
ModelOptions: ModelOptions{
|
||||||
|
Model: "fake",
|
||||||
|
Stream: stream,
|
||||||
|
},
|
||||||
|
ToolOptions: ToolOptions{
|
||||||
|
Subagent: func(ctx context.Context, req subagent.Request) (subagent.Result, error) {
|
||||||
|
delegated = &req
|
||||||
|
return subagent.Result{Success: true, Text: "结论已查明"}, nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
dir := t.TempDir()
|
||||||
|
res, err := b.Execute(context.Background(), exec.Request{
|
||||||
|
WorkDir: dir, Prompt: "分工", Targets: []string{"output.md"},
|
||||||
|
})
|
||||||
|
if err != nil || !res.Success {
|
||||||
|
t.Fatalf("execute: err=%v res=%+v logs=%s", err, res, res.Logs)
|
||||||
|
}
|
||||||
|
if delegated == nil || delegated.Goal != "查证数据" {
|
||||||
|
t.Fatalf("factory not invoked with fields: %+v", delegated)
|
||||||
|
}
|
||||||
|
sawDelegate := false
|
||||||
|
for _, names := range c.toolDecls {
|
||||||
|
for _, n := range names {
|
||||||
|
if n == "delegate_task" {
|
||||||
|
sawDelegate = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !sawDelegate {
|
||||||
|
t.Fatal("delegate_task must be declared when Subagent factory is set")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
execution_id=cx session=
|
||||||
|
agent_start
|
||||||
|
turn_start
|
||||||
|
message_start
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
package native
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"agent-native/exec"
|
||||||
|
"agent-native/runtime/llm"
|
||||||
|
"agent-native/runtime/tools"
|
||||||
|
)
|
||||||
|
|
||||||
|
type hostTool struct{ name string }
|
||||||
|
|
||||||
|
func (h hostTool) Name() string { return h.name }
|
||||||
|
func (h hostTool) Description() string { return "host tool" }
|
||||||
|
func (h hostTool) Parameters() json.RawMessage {
|
||||||
|
return json.RawMessage(`{"type":"object","properties":{"path":{"type":"string"}}}`)
|
||||||
|
}
|
||||||
|
func (h hostTool) Execute(ctx context.Context, args json.RawMessage) (tools.Result, error) {
|
||||||
|
return tools.Result{Content: "registered " + h.name}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHostToolAndUsageEvent(t *testing.T) {
|
||||||
|
var events []exec.ProcessEvent
|
||||||
|
b := New(Options{
|
||||||
|
ModelOptions: ModelOptions{
|
||||||
|
Model: "fake",
|
||||||
|
Stream: usageScript(),
|
||||||
|
},
|
||||||
|
ToolOptions: ToolOptions{
|
||||||
|
HostTools: []tools.Tool{hostTool{name: "register_artifact"}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
res, err := b.Execute(context.Background(), exec.Request{
|
||||||
|
WorkDir: t.TempDir(), Prompt: "登记",
|
||||||
|
OnEvent: func(ev exec.ProcessEvent) { events = append(events, ev) },
|
||||||
|
})
|
||||||
|
if err != nil || !res.Success {
|
||||||
|
t.Fatalf("execute: err=%v res=%+v logs=%s", err, res, logsOf(res))
|
||||||
|
}
|
||||||
|
sawText, sawStart, sawEnd, sawUsage := false, false, false, false
|
||||||
|
for _, ev := range events {
|
||||||
|
switch ev.Type {
|
||||||
|
case "text_delta":
|
||||||
|
if ev.Text != "" {
|
||||||
|
sawText = true
|
||||||
|
}
|
||||||
|
case "tool_execution_start":
|
||||||
|
if ev.Tool == "register_artifact" {
|
||||||
|
sawStart = true
|
||||||
|
}
|
||||||
|
case "tool_execution_end":
|
||||||
|
if ev.Tool == "register_artifact" {
|
||||||
|
sawEnd = true
|
||||||
|
}
|
||||||
|
case "usage":
|
||||||
|
if ev.PromptTokens == 11 && ev.CompletionTokens == 7 {
|
||||||
|
sawUsage = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !sawText || !sawStart || !sawEnd || !sawUsage {
|
||||||
|
t.Fatalf("events missing text/tool/usage: %+v", events)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHostToolSeesStepWorkDir(t *testing.T) {
|
||||||
|
var seen string
|
||||||
|
b := New(Options{
|
||||||
|
ModelOptions: ModelOptions{
|
||||||
|
Model: "fake",
|
||||||
|
Stream: script(llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{ID: "c1", Name: "peek", Arguments: `{}`}}}),
|
||||||
|
},
|
||||||
|
ToolOptions: ToolOptions{
|
||||||
|
HostTools: []tools.Tool{peekTool{got: &seen}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
dir := t.TempDir()
|
||||||
|
_, err := b.Execute(context.Background(), exec.Request{WorkDir: dir, Prompt: "x", Targets: []string{"output.md"}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if seen != dir {
|
||||||
|
t.Fatalf("workdir = %q, want %s", seen, dir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type peekTool struct{ got *string }
|
||||||
|
|
||||||
|
func (p peekTool) Name() string { return "peek" }
|
||||||
|
func (p peekTool) Description() string { return "peek" }
|
||||||
|
func (p peekTool) Parameters() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
|
||||||
|
func (p peekTool) Execute(ctx context.Context, args json.RawMessage) (tools.Result, error) {
|
||||||
|
*p.got = WorkDir(ctx)
|
||||||
|
return tools.Result{Content: "ok"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHostToolNameConflictFailsClosed(t *testing.T) {
|
||||||
|
b := New(Options{
|
||||||
|
ModelOptions: ModelOptions{
|
||||||
|
Model: "fake",
|
||||||
|
Stream: script(llm.Message{Role: llm.RoleAssistant, Content: "x", StopReason: llm.StopEnd}),
|
||||||
|
},
|
||||||
|
ToolOptions: ToolOptions{
|
||||||
|
HostTools: []tools.Tool{hostTool{name: "read_file"}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
res, err := b.Execute(context.Background(), exec.Request{
|
||||||
|
WorkDir: t.TempDir(), Prompt: "x", Targets: []string{"output.md"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if res.Success || res.ErrorReason == "" {
|
||||||
|
t.Fatalf("conflict must fail closed, got %+v", res)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func usageScript() llm.StreamFunc {
|
||||||
|
var mu sync.Mutex
|
||||||
|
step := 0
|
||||||
|
return func(ctx context.Context, model string, messages []llm.Message, td []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
|
||||||
|
ch := make(chan llm.Event, 4)
|
||||||
|
go func() {
|
||||||
|
defer close(ch)
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
step++
|
||||||
|
switch step {
|
||||||
|
case 1:
|
||||||
|
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{
|
||||||
|
Role: llm.RoleAssistant, StopReason: llm.StopToolCalls,
|
||||||
|
ToolCalls: []llm.ToolCall{{ID: "c1", Name: "register_artifact", Arguments: `{"path":"a.docx"}`}},
|
||||||
|
}, Usage: llm.Usage{PromptTokens: 4, CompletionTokens: 2}}
|
||||||
|
case 2:
|
||||||
|
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{
|
||||||
|
Role: llm.RoleAssistant, StopReason: llm.StopToolCalls,
|
||||||
|
ToolCalls: []llm.ToolCall{{ID: "c2", Name: "write_file", Arguments: `{"path":"output.md","content":"done"}`}},
|
||||||
|
}, Usage: llm.Usage{PromptTokens: 4, CompletionTokens: 2}}
|
||||||
|
default:
|
||||||
|
ch <- llm.Event{Kind: llm.KindTextDelta, Text: "已登记"}
|
||||||
|
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{
|
||||||
|
Role: llm.RoleAssistant, Content: "已登记", StopReason: llm.StopEnd,
|
||||||
|
}, Usage: llm.Usage{PromptTokens: 3, CompletionTokens: 3}}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return ch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func logsOf(res *exec.Result) string {
|
||||||
|
if res == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return res.Logs
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package native
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"agent-native/runtime/mcp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestMain:MCP fake server 的 re-exec 分支(详见 mcp.RunFakeServer)。
|
||||||
|
func TestMain(m *testing.M) {
|
||||||
|
if os.Getenv(mcp.FakeServerEnv) == "1" {
|
||||||
|
mcp.RunFakeServer()
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
os.Exit(m.Run())
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
hi
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package native
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"agent-native/rpc"
|
||||||
|
"agent-native/runtime"
|
||||||
|
"agent-native/runtime/llm"
|
||||||
|
"agent-native/runtime/session"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEventAndRPCFixtures(t *testing.T) {
|
||||||
|
var types []string
|
||||||
|
agent := runtime.NewAgentSession(runtime.Config{
|
||||||
|
Model: "fake",
|
||||||
|
Stream: 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: "ok", StopReason: llm.StopEnd}}
|
||||||
|
close(ch)
|
||||||
|
return ch
|
||||||
|
},
|
||||||
|
OnEvent: func(ev runtime.Event) { types = append(types, ev.Type) },
|
||||||
|
})
|
||||||
|
if _, err := agent.Prompt(context.Background(), llm.Message{Role: llm.RoleUser, Content: "hi"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
raw, _ := json.Marshal(types)
|
||||||
|
for _, want := range []string{"message_start", "message_end", "agent_settled"} {
|
||||||
|
if !contains(types, want) {
|
||||||
|
t.Fatalf("fixture missing %s in %s", want, raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dir := t.TempDir()
|
||||||
|
store, err := session.Open(dir + "/s.jsonl")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
srv := &rpc.Server{Agent: agent, Store: store}
|
||||||
|
resp, err := srv.Handle(context.Background(), []byte(`{"type":"get_session_stats"}`))
|
||||||
|
if err != nil || !json.Valid(resp) {
|
||||||
|
t.Fatalf("rpc fixture: %s %v", resp, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPiCrossCompareOptional(t *testing.T) {
|
||||||
|
if _, err := exec.LookPath("pi"); err != nil {
|
||||||
|
t.Skip("pi is not installed")
|
||||||
|
}
|
||||||
|
if os.Getenv("PI_CROSS") != "1" {
|
||||||
|
t.Skip("set PI_CROSS=1 to compare a live pi process")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(list []string, want string) bool {
|
||||||
|
for _, item := range list {
|
||||||
|
if item == want {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package native
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestR2PoisonMarkerSurvivesNewProcess(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := os.MkdirAll(filepath.Join(dir, ".agentx"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
transcript := filepath.Join(dir, ".agentx", "transcript.jsonl")
|
||||||
|
if err := os.WriteFile(transcript, []byte("{}\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, lastFailureFile), []byte("context length exceeded"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
b := New(Options{})
|
||||||
|
b.rotateIfPoisoned(dir, transcript)
|
||||||
|
if _, err := os.Stat(transcript); !os.IsNotExist(err) {
|
||||||
|
t.Fatal("poisoned transcript still at the live path")
|
||||||
|
}
|
||||||
|
entries, err := os.ReadDir(filepath.Join(dir, ".agentx"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var renamed bool
|
||||||
|
for _, e := range entries {
|
||||||
|
if strings.Contains(e.Name(), "transcript.poisoned.") {
|
||||||
|
renamed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !renamed {
|
||||||
|
t.Fatal("expected archived transcript")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
package native
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"agent-native/exec"
|
||||||
|
"agent-native/runtime/llm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// captureStream 捕获每次调用的 model / 首条 system 消息,并按序回放回合。
|
||||||
|
func captureStream(turns ...llm.Message) (llm.StreamFunc, *capture) {
|
||||||
|
c := &capture{}
|
||||||
|
var mu sync.Mutex
|
||||||
|
i := 0
|
||||||
|
return func(ctx context.Context, model string, messages []llm.Message, tools []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
|
||||||
|
ch := make(chan llm.Event, 4)
|
||||||
|
go func() {
|
||||||
|
defer close(ch)
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
c.models = append(c.models, model)
|
||||||
|
c.toolDecls = append(c.toolDecls, toolNames(tools))
|
||||||
|
if len(messages) > 0 && messages[0].Role == llm.RoleSystem {
|
||||||
|
c.system = messages[0].Content
|
||||||
|
}
|
||||||
|
if i >= len(turns) {
|
||||||
|
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd}}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msg := turns[i]
|
||||||
|
i++
|
||||||
|
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: msg}
|
||||||
|
}()
|
||||||
|
return ch
|
||||||
|
}, c
|
||||||
|
}
|
||||||
|
|
||||||
|
type capture struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
models []string
|
||||||
|
toolDecls [][]string
|
||||||
|
system string
|
||||||
|
}
|
||||||
|
|
||||||
|
func toolNames(decls []llm.ToolDecl) []string {
|
||||||
|
var out []string
|
||||||
|
for _, d := range decls {
|
||||||
|
out = append(out, d.Name)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackendRequestModelOverride(t *testing.T) {
|
||||||
|
stream, c := captureStream()
|
||||||
|
b := New(Options{
|
||||||
|
ModelOptions: ModelOptions{
|
||||||
|
Model: "default-model",
|
||||||
|
Stream: stream,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
dir := t.TempDir()
|
||||||
|
res, err := b.Execute(context.Background(), exec.Request{
|
||||||
|
WorkDir: dir, Prompt: "hi", Targets: []string{"output.md"}, Model: "override-model",
|
||||||
|
ToolProfile: exec.ToolProfileReasoning,
|
||||||
|
})
|
||||||
|
if err != nil || !res.Success {
|
||||||
|
t.Fatalf("execute: err=%v res=%+v", err, res)
|
||||||
|
}
|
||||||
|
if len(c.models) == 0 || c.models[0] != "override-model" {
|
||||||
|
t.Fatalf("want override-model, got %v", c.models)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackendRequestToolAllowlist(t *testing.T) {
|
||||||
|
stream, c := captureStream(
|
||||||
|
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
|
||||||
|
ID: "c1", Name: "write_file", Arguments: `{"path":"output.md","content":"blocked"}`,
|
||||||
|
}}},
|
||||||
|
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
|
||||||
|
ID: "c2", Name: "write_file", Arguments: `{"path":"output2.md","content":"still blocked"}`,
|
||||||
|
}}},
|
||||||
|
llm.Message{Role: llm.RoleAssistant, Content: "gave up", StopReason: llm.StopEnd},
|
||||||
|
)
|
||||||
|
b := New(Options{
|
||||||
|
ModelOptions: ModelOptions{
|
||||||
|
Model: "fake",
|
||||||
|
Stream: stream,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
dir := t.TempDir()
|
||||||
|
// 目标 output2.md 不在 allowlist 内能写吗?——write_file 被整体收窄,
|
||||||
|
// 模型两次尝试都会得到 unknown tool,最终无交付物 → 失败。
|
||||||
|
res, err := b.Execute(context.Background(), exec.Request{
|
||||||
|
WorkDir: dir, Prompt: "写文件", Targets: []string{"output2.md"},
|
||||||
|
ToolAllowlist: []string{"read_file", "list_dir", "grep"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if res.Success {
|
||||||
|
t.Fatalf("write_file restricted out; deliverable must fail, got %+v", res)
|
||||||
|
}
|
||||||
|
if len(c.toolDecls) == 0 {
|
||||||
|
t.Fatal("expected tool decls")
|
||||||
|
}
|
||||||
|
for _, names := range c.toolDecls {
|
||||||
|
for _, n := range names {
|
||||||
|
if n == "write_file" || n == "edit_file" {
|
||||||
|
t.Fatalf("write/edit must be restricted out of decls, got %v", names)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackendRequestUnknownAllowlistFailsClosed(t *testing.T) {
|
||||||
|
b := New(Options{
|
||||||
|
ModelOptions: ModelOptions{
|
||||||
|
Model: "fake",
|
||||||
|
Stream: script(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
res, err := b.Execute(context.Background(), exec.Request{
|
||||||
|
WorkDir: t.TempDir(), Prompt: "x", Targets: []string{"output.md"},
|
||||||
|
ToolAllowlist: []string{"read_file", "no_such_tool"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if res.Success || !strings.Contains(res.ErrorReason, "no_such_tool") {
|
||||||
|
t.Fatalf("want fail-closed unknown allowlist name, got %+v", res)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackendRequestBudgetDeadlineBindsContext(t *testing.T) {
|
||||||
|
deadlineHit := make(chan struct{})
|
||||||
|
var once sync.Once
|
||||||
|
stream := func(ctx context.Context, model string, messages []llm.Message, tools []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
|
||||||
|
ch := make(chan llm.Event, 4)
|
||||||
|
go func() {
|
||||||
|
defer close(ch)
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
once.Do(func() { close(deadlineHit) })
|
||||||
|
ch <- llm.Event{Kind: llm.KindError, Err: ctx.Err(), Message: llm.Message{StopReason: llm.StopAborted}}
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd}}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return ch
|
||||||
|
}
|
||||||
|
b := New(Options{
|
||||||
|
ModelOptions: ModelOptions{
|
||||||
|
Model: "fake",
|
||||||
|
Stream: stream,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
start := time.Now()
|
||||||
|
res, err := b.Execute(context.Background(), exec.Request{
|
||||||
|
WorkDir: t.TempDir(), Prompt: "x", Targets: []string{"output.md"},
|
||||||
|
Timeout: 30 * time.Second, // 长超时,预算截止应更先生效
|
||||||
|
Budget: &exec.Budget{Deadline: time.Now().Add(150 * time.Millisecond)},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-deadlineHit:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("budget deadline should bind the run context")
|
||||||
|
}
|
||||||
|
if res.Success {
|
||||||
|
t.Fatalf("deadline run must not succeed, got %+v", res)
|
||||||
|
}
|
||||||
|
if elapsed := time.Since(start); elapsed > 5*time.Second {
|
||||||
|
t.Fatalf("deadline should fire early, took %s", elapsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackendRequestExecutionIDInLogs(t *testing.T) {
|
||||||
|
stream, _ := captureStream()
|
||||||
|
b := New(Options{
|
||||||
|
ModelOptions: ModelOptions{
|
||||||
|
Model: "fake",
|
||||||
|
Stream: stream,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
dir := t.TempDir()
|
||||||
|
res, err := b.Execute(context.Background(), exec.Request{
|
||||||
|
WorkDir: dir, Prompt: "hi", Targets: []string{"output.md"},
|
||||||
|
Instructions: "你是角色",
|
||||||
|
ExecutionID: "exec-42", SessionScope: "run-9/step-3",
|
||||||
|
ToolProfile: exec.ToolProfileReasoning,
|
||||||
|
})
|
||||||
|
if err != nil || !res.Success {
|
||||||
|
t.Fatalf("execute: err=%v res=%+v", err, res)
|
||||||
|
}
|
||||||
|
logBytes, _ := os.ReadFile(filepath.Join(dir, "execution.log"))
|
||||||
|
logs := string(logBytes)
|
||||||
|
if !strings.Contains(logs, "execution_id=exec-42") || !strings.Contains(logs, "run-9/step-3") {
|
||||||
|
t.Fatalf("execution log should carry ids, got:\n%s", logs)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
package native
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"agent-native/exec"
|
||||||
|
"agent-native/runtime/llm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestExecuteCanceledIsNotRetryable: aborted/canceled runs carry Canceled
|
||||||
|
// semantics with Retryable=false and SideEffects=possible.
|
||||||
|
func TestExecuteCanceledIsNotRetryable(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
b := New(Options{
|
||||||
|
ModelOptions: ModelOptions{
|
||||||
|
Model: "fake",
|
||||||
|
Stream: func(ctx context.Context, model string, messages []llm.Message, decls []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
|
||||||
|
ch := make(chan llm.Event, 1)
|
||||||
|
go func() {
|
||||||
|
<-ctx.Done()
|
||||||
|
ch <- llm.Event{
|
||||||
|
Kind: llm.KindError,
|
||||||
|
Err: ctx.Err(),
|
||||||
|
Retryable: true, // provider may mark abort retryable; adapter must not.
|
||||||
|
Message: llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopAborted},
|
||||||
|
}
|
||||||
|
close(ch)
|
||||||
|
}()
|
||||||
|
return ch
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
res, err := b.Execute(ctx, exec.Request{
|
||||||
|
WorkDir: dir, Prompt: "cancel me", Targets: []string{"output.md"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("transport error: %v", err)
|
||||||
|
}
|
||||||
|
if res.Success {
|
||||||
|
t.Fatalf("canceled run must not succeed: %+v", res)
|
||||||
|
}
|
||||||
|
if !res.Canceled {
|
||||||
|
t.Fatalf("want Canceled=true, got %+v", res)
|
||||||
|
}
|
||||||
|
if res.Retryable {
|
||||||
|
t.Fatalf("canceled must not be Retryable: %+v", res)
|
||||||
|
}
|
||||||
|
if res.EffectiveSideEffects() != exec.SideEffectPossible {
|
||||||
|
t.Fatalf("canceled SideEffects = %q, want possible", res.EffectiveSideEffects())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestExecuteToolFailureSideEffectsPossible: failure after tools may have run
|
||||||
|
// reports SideEffects=possible.
|
||||||
|
func TestExecuteToolFailureSideEffectsPossible(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
var call int
|
||||||
|
b := New(Options{
|
||||||
|
ModelOptions: ModelOptions{
|
||||||
|
Model: "fake",
|
||||||
|
Stream: func(ctx context.Context, model string, messages []llm.Message, decls []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
|
||||||
|
ch := make(chan llm.Event, 2)
|
||||||
|
go func() {
|
||||||
|
defer close(ch)
|
||||||
|
call++
|
||||||
|
if call == 1 {
|
||||||
|
msg := llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
|
||||||
|
ID: "c1", Name: "write_file", Arguments: `{"path":"scratch.md","content":"side effect"}`,
|
||||||
|
}}}
|
||||||
|
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: msg}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ch <- llm.Event{
|
||||||
|
Kind: llm.KindError,
|
||||||
|
Err: errString("provider failed after tools"),
|
||||||
|
Message: llm.Message{Role: llm.RoleAssistant, Content: "boom", StopReason: llm.StopError},
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return ch
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
res, err := b.Execute(context.Background(), exec.Request{
|
||||||
|
WorkDir: dir, Prompt: "write then fail", Targets: []string{"output.md"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("transport error: %v", err)
|
||||||
|
}
|
||||||
|
if res.Success || res.Canceled {
|
||||||
|
t.Fatalf("want definitive failure after tools, got %+v", res)
|
||||||
|
}
|
||||||
|
if res.SideEffects != exec.SideEffectPossible {
|
||||||
|
t.Fatalf("SideEffects = %q, want possible", res.SideEffects)
|
||||||
|
}
|
||||||
|
if _, statErr := os.Stat(filepath.Join(dir, "scratch.md")); statErr != nil {
|
||||||
|
t.Fatalf("tool side effect missing: %v", statErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestExecutePreflightSideEffectsNone: validation rejection before tools
|
||||||
|
// reports SideEffects=none.
|
||||||
|
func TestExecutePreflightSideEffectsNone(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
opts Options
|
||||||
|
req exec.Request
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "missing-config",
|
||||||
|
opts: Options{},
|
||||||
|
req: exec.Request{WorkDir: t.TempDir(), Targets: []string{"x.md"}},
|
||||||
|
want: "requires",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bad-profile",
|
||||||
|
opts: Options{ModelOptions: ModelOptions{Stream: script(), Model: "fake"}},
|
||||||
|
req: exec.Request{WorkDir: t.TempDir(), Targets: []string{"x.md"}, ToolProfile: "bogus"},
|
||||||
|
want: "tool profile",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing-model",
|
||||||
|
opts: Options{ModelOptions: ModelOptions{APIBase: "http://x", APIKey: "k"}},
|
||||||
|
req: exec.Request{WorkDir: t.TempDir(), Targets: []string{"x.md"}},
|
||||||
|
want: "model",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
res, err := New(tc.opts).Execute(context.Background(), tc.req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("transport error: %v", err)
|
||||||
|
}
|
||||||
|
if res.Success || res.Canceled || res.Unknown || res.Retryable {
|
||||||
|
t.Fatalf("preflight must be definitive non-retry failure: %+v", res)
|
||||||
|
}
|
||||||
|
if res.SideEffects != exec.SideEffectNone {
|
||||||
|
t.Fatalf("SideEffects = %q, want none", res.SideEffects)
|
||||||
|
}
|
||||||
|
if !strings.Contains(res.ErrorReason, tc.want) {
|
||||||
|
t.Fatalf("ErrorReason = %q, want substring %q", res.ErrorReason, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestExecuteSuccessSideEffectsReflectTools: success after tool dispatch
|
||||||
|
// reports SideEffects=possible; patches also imply possible.
|
||||||
|
func TestExecuteSuccessSideEffectsReflectTools(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
b := New(Options{
|
||||||
|
ModelOptions: ModelOptions{
|
||||||
|
Model: "fake",
|
||||||
|
Stream: script(
|
||||||
|
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
|
||||||
|
ID: "c1", Name: "write_file", Arguments: `{"path":"output.md","content":"ok"}`,
|
||||||
|
}}},
|
||||||
|
llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
res, err := b.Execute(context.Background(), exec.Request{
|
||||||
|
WorkDir: dir, Prompt: "write", Targets: []string{"output.md"},
|
||||||
|
})
|
||||||
|
if err != nil || !res.Success {
|
||||||
|
t.Fatalf("want success, got err=%v res=%+v", err, res)
|
||||||
|
}
|
||||||
|
if res.SideEffects != exec.SideEffectPossible {
|
||||||
|
t.Fatalf("SideEffects = %q, want possible", res.SideEffects)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,7 +38,12 @@ func failThenSucceed(failMsg string) llm.StreamFunc {
|
|||||||
|
|
||||||
func TestSessionRotationAfterPoisonedFailure(t *testing.T) {
|
func TestSessionRotationAfterPoisonedFailure(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
b := New(Options{Model: "fake", Stream: failThenSucceed("maximum context length exceeded (context_overflow)")})
|
b := New(Options{
|
||||||
|
ModelOptions: ModelOptions{
|
||||||
|
Model: "fake",
|
||||||
|
Stream: failThenSucceed("maximum context length exceeded (context_overflow)"),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
// 第一次执行:失败(上下文溢出),留下毒化会话。
|
// 第一次执行:失败(上下文溢出),留下毒化会话。
|
||||||
res1, err := b.Execute(context.Background(), execReq(dir, []string{"output.md"}))
|
res1, err := b.Execute(context.Background(), execReq(dir, []string{"output.md"}))
|
||||||
@@ -87,7 +92,12 @@ func TestSessionRotationAfterPoisonedFailure(t *testing.T) {
|
|||||||
|
|
||||||
func TestNoRotationOnRetryableNetworkFailure(t *testing.T) {
|
func TestNoRotationOnRetryableNetworkFailure(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
b := New(Options{Model: "fake", Stream: failThenSucceed("connection reset by peer (provider_network)")})
|
b := New(Options{
|
||||||
|
ModelOptions: ModelOptions{
|
||||||
|
Model: "fake",
|
||||||
|
Stream: failThenSucceed("connection reset by peer (provider_network)"),
|
||||||
|
},
|
||||||
|
})
|
||||||
_, _ = b.Execute(context.Background(), execReq(dir, []string{"output.md"}))
|
_, _ = b.Execute(context.Background(), execReq(dir, []string{"output.md"}))
|
||||||
transcript := filepath.Join(dir, ".agentx", "transcript.jsonl")
|
transcript := filepath.Join(dir, ".agentx", "transcript.jsonl")
|
||||||
_ = os.MkdirAll(filepath.Dir(transcript), 0o755)
|
_ = os.MkdirAll(filepath.Dir(transcript), 0o755)
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package native
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"agent-native/exec"
|
||||||
|
"agent-native/runtime/llm"
|
||||||
|
"agent-native/runtime/subagent"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestContractWritableRejectsSubagentInPreflight(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
streamCalls := 0
|
||||||
|
inner := script(llm.Message{Role: llm.RoleAssistant, Content: "x", StopReason: llm.StopEnd})
|
||||||
|
factoryCalls := 0
|
||||||
|
b := New(Options{
|
||||||
|
ModelOptions: ModelOptions{
|
||||||
|
Model: "fake",
|
||||||
|
Stream: func(ctx context.Context, model string, messages []llm.Message, decls []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
|
||||||
|
streamCalls++
|
||||||
|
return inner(ctx, model, messages, decls, opts)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ToolOptions: ToolOptions{
|
||||||
|
Subagent: func(ctx context.Context, req subagent.Request) (subagent.Result, error) {
|
||||||
|
factoryCalls++
|
||||||
|
return subagent.Result{Success: true}, nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}).Contract()
|
||||||
|
res, err := b.Execute(context.Background(), exec.ContractRequest{
|
||||||
|
ExecutionID: "e-sub",
|
||||||
|
Prompt: "x",
|
||||||
|
Workspace: exec.WorkspaceHandle{
|
||||||
|
Root: dir,
|
||||||
|
Writable: []string{"a.md"},
|
||||||
|
},
|
||||||
|
ToolPolicy: exec.ToolPolicy{Profile: exec.ToolProfileWorkspace},
|
||||||
|
Targets: []string{"a.md"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if res.Outcome == exec.OutcomeSucceeded {
|
||||||
|
t.Fatal("Writable + subagent delegation enabled must be rejected in preflight")
|
||||||
|
}
|
||||||
|
const want = "writable write-set cannot be enforced when subagent delegation is enabled"
|
||||||
|
if res.Err == nil || res.Err.Reason != want {
|
||||||
|
t.Fatalf("want reason %q, got %+v", want, res.Err)
|
||||||
|
}
|
||||||
|
if streamCalls != 0 || factoryCalls != 0 {
|
||||||
|
t.Fatalf("preflight must stop before model/factory: stream=%d factory=%d", streamCalls, factoryCalls)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, "a.md")); !os.IsNotExist(err) {
|
||||||
|
t.Fatal("deliverable a.md must not be created when preflight rejects")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, ".agentx")); !os.IsNotExist(err) {
|
||||||
|
t.Fatal("no session state must be written when preflight rejects")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestContractSubagentWithoutWritableStillDelegates(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
var delegated *subagent.Request
|
||||||
|
stream, c := captureStream(
|
||||||
|
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
|
||||||
|
ID: "c1", Name: "delegate_task",
|
||||||
|
Arguments: `{"goal":"查证数据","write_scope":"research/","done_criteria":"三来源"}`,
|
||||||
|
}}},
|
||||||
|
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
|
||||||
|
ID: "c2", Name: "write_file",
|
||||||
|
Arguments: `{"path":"output.md","content":"done"}`,
|
||||||
|
}}},
|
||||||
|
llm.Message{Role: llm.RoleAssistant, Content: "ok", StopReason: llm.StopEnd},
|
||||||
|
)
|
||||||
|
b := New(Options{
|
||||||
|
ModelOptions: ModelOptions{Model: "fake", Stream: stream},
|
||||||
|
ToolOptions: ToolOptions{
|
||||||
|
Subagent: func(ctx context.Context, req subagent.Request) (subagent.Result, error) {
|
||||||
|
delegated = &req
|
||||||
|
return subagent.Result{Success: true, Text: "结论已查明"}, nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}).Contract()
|
||||||
|
res, err := b.Execute(context.Background(), exec.ContractRequest{
|
||||||
|
ExecutionID: "e-sub-ok",
|
||||||
|
Prompt: "分工",
|
||||||
|
Workspace: exec.WorkspaceHandle{Root: dir},
|
||||||
|
ToolPolicy: exec.ToolPolicy{Profile: exec.ToolProfileWorkspace},
|
||||||
|
Targets: []string{"output.md"},
|
||||||
|
})
|
||||||
|
if err != nil || res.Outcome != exec.OutcomeSucceeded {
|
||||||
|
t.Fatalf("execute: err=%v res=%+v", err, res)
|
||||||
|
}
|
||||||
|
if delegated == nil || delegated.Goal != "查证数据" {
|
||||||
|
t.Fatalf("factory not invoked with fields: %+v", delegated)
|
||||||
|
}
|
||||||
|
sawDelegate := false
|
||||||
|
for _, names := range c.toolDecls {
|
||||||
|
for _, n := range names {
|
||||||
|
if n == "delegate_task" {
|
||||||
|
sawDelegate = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !sawDelegate {
|
||||||
|
t.Fatal("delegate_task must be declared when Subagent factory is set and Writable is empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,11 +7,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type remoteRunWire struct {
|
type remoteRunWire struct {
|
||||||
OK bool `json:"ok"`
|
OK bool `json:"ok"`
|
||||||
RunID string `json:"run_id"`
|
RunID string `json:"run_id"`
|
||||||
FallbackReason string `json:"fallback_reason"`
|
FallbackReason string `json:"fallback_reason"`
|
||||||
Retryable bool `json:"retryable"`
|
Retryable bool `json:"retryable"`
|
||||||
RunnerProtocol string `json:"runner_protocol"`
|
RunnerProtocol string `json:"runner_protocol"`
|
||||||
Capabilities *struct {
|
Capabilities *struct {
|
||||||
TypedResult bool `json:"typed_result"`
|
TypedResult bool `json:"typed_result"`
|
||||||
} `json:"capabilities"`
|
} `json:"capabilities"`
|
||||||
@@ -23,7 +23,7 @@ type remoteRunWire struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type decodedRemote struct {
|
type decodedRemote struct {
|
||||||
OK bool
|
OK bool
|
||||||
Retryable bool
|
Retryable bool
|
||||||
Reason string
|
Reason string
|
||||||
Incoming map[string][]byte
|
Incoming map[string][]byte
|
||||||
@@ -91,17 +91,17 @@ func safeLegacyReason(reason string) string {
|
|||||||
case r == "":
|
case r == "":
|
||||||
return "remote pi-agent reported task failure"
|
return "remote pi-agent reported task failure"
|
||||||
case strings.EqualFold(r, "prompt_empty"):
|
case strings.EqualFold(r, "prompt_empty"):
|
||||||
return "PROMPT_EMPTY"
|
return "agent.prompt_empty"
|
||||||
case strings.Contains(strings.ToLower(r), "unauthorized"):
|
case strings.Contains(strings.ToLower(r), "unauthorized"):
|
||||||
return "UNAUTHORIZED"
|
return "agent.unauthorized"
|
||||||
case strings.Contains(strings.ToLower(r), "timeout"):
|
case strings.Contains(strings.ToLower(r), "timeout"):
|
||||||
return "TIMEOUT"
|
return "agent.timeout"
|
||||||
default:
|
default:
|
||||||
if strings.ContainsAny(r, "/\\") || strings.Contains(strings.ToLower(r), "token") {
|
if strings.ContainsAny(r, "/\\") || strings.Contains(strings.ToLower(r), "token") {
|
||||||
return "RUN_FAILED"
|
return "agent.run_failed"
|
||||||
}
|
}
|
||||||
if len(r) > 64 {
|
if len(r) > 64 {
|
||||||
return "RUN_FAILED"
|
return "agent.run_failed"
|
||||||
}
|
}
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,20 +20,22 @@ const (
|
|||||||
|
|
||||||
var ErrTypedResult = fmt.Errorf("pitransport: typed result_v1 rejected")
|
var ErrTypedResult = fmt.Errorf("pitransport: typed result_v1 rejected")
|
||||||
|
|
||||||
|
// 对外错误码与 Error-Code-System 的 agent 域一致。
|
||||||
|
// CANCELLED 并入 agent.canceled,MISSING_TARGET 并入 agent.missing_deliverable。
|
||||||
var allowedErrorCodes = map[string]struct{}{
|
var allowedErrorCodes = map[string]struct{}{
|
||||||
"PROMPT_EMPTY": {},
|
"agent.prompt_empty": {},
|
||||||
"UNAUTHORIZED": {},
|
"agent.unauthorized": {},
|
||||||
"TIMEOUT": {},
|
"agent.timeout": {},
|
||||||
"CANCELLED": {},
|
"agent.canceled": {},
|
||||||
"TRANSIENT": {},
|
"agent.transient": {},
|
||||||
"MISSING_TARGET": {},
|
"agent.missing_deliverable": {},
|
||||||
"RUN_FAILED": {},
|
"agent.run_failed": {},
|
||||||
}
|
}
|
||||||
|
|
||||||
var retryableErrorCodes = map[string]struct{}{
|
var retryableErrorCodes = map[string]struct{}{
|
||||||
"TIMEOUT": {},
|
"agent.timeout": {},
|
||||||
"CANCELLED": {},
|
"agent.canceled": {},
|
||||||
"TRANSIENT": {},
|
"agent.transient": {},
|
||||||
}
|
}
|
||||||
|
|
||||||
type ResultV1Member struct {
|
type ResultV1Member struct {
|
||||||
|
|||||||
@@ -63,13 +63,13 @@ func TestResultV1RejectsMissingMemberBody(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestTypedRetryableAllowlist(t *testing.T) {
|
func TestTypedRetryableAllowlist(t *testing.T) {
|
||||||
ok := BuildResultV1("r", "error", true, "TRANSIENT", nil)
|
ok := BuildResultV1("r", "error", true, "agent.transient", nil)
|
||||||
if !TypedRetryable(ok) {
|
if !TypedRetryable(ok) {
|
||||||
t.Fatal("TRANSIENT retryable")
|
t.Fatal("agent.transient retryable")
|
||||||
}
|
}
|
||||||
denied := BuildResultV1("r", "error", true, "PROMPT_EMPTY", nil)
|
denied := BuildResultV1("r", "error", true, "agent.prompt_empty", nil)
|
||||||
if TypedRetryable(denied) {
|
if TypedRetryable(denied) {
|
||||||
t.Fatal("PROMPT_EMPTY must not retry")
|
t.Fatal("agent.prompt_empty must not retry")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+17
-17
@@ -8,35 +8,35 @@ export const MAX_RESULT_WIRE_BYTES = 6 * 1024 * 1024;
|
|||||||
/** Tail window for execution.log patches; full log remains on shared volume. */
|
/** Tail window for execution.log patches; full log remains on shared volume. */
|
||||||
export const EXECUTION_LOG_PATCH_BYTES = 128 * 1024;
|
export const EXECUTION_LOG_PATCH_BYTES = 128 * 1024;
|
||||||
|
|
||||||
const RETRYABLE_CODES = new Set(["TIMEOUT", "CANCELLED", "TRANSIENT"]);
|
const RETRYABLE_CODES = new Set(["agent.timeout", "agent.canceled", "agent.transient"]);
|
||||||
const ALLOWED_CODES = new Set([
|
const ALLOWED_CODES = new Set([
|
||||||
"PROMPT_EMPTY",
|
"agent.prompt_empty",
|
||||||
"UNAUTHORIZED",
|
"agent.unauthorized",
|
||||||
"TIMEOUT",
|
"agent.timeout",
|
||||||
"CANCELLED",
|
"agent.canceled",
|
||||||
"TRANSIENT",
|
"agent.transient",
|
||||||
"MISSING_TARGET",
|
"agent.missing_deliverable",
|
||||||
"RUN_FAILED",
|
"agent.run_failed",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export function typedErrorCode(payload) {
|
export function typedErrorCode(payload) {
|
||||||
const raw = String(payload?.fallback_reason || payload?.error || "");
|
const raw = String(payload?.fallback_reason || payload?.error || "");
|
||||||
const t = raw.toLowerCase();
|
const t = raw.toLowerCase();
|
||||||
if (t.includes("prompt_empty")) return "PROMPT_EMPTY";
|
if (t.includes("prompt_empty")) return "agent.prompt_empty";
|
||||||
if (t.includes("unauthorized")) return "UNAUTHORIZED";
|
if (t.includes("unauthorized")) return "agent.unauthorized";
|
||||||
if (t.includes("timeout") || t.includes("timed out")) return "TIMEOUT";
|
if (t.includes("timeout") || t.includes("timed out")) return "agent.timeout";
|
||||||
if (t.includes("cancel")) return "CANCELLED";
|
if (t.includes("cancel")) return "agent.canceled";
|
||||||
if (
|
if (
|
||||||
t.includes("connection") ||
|
t.includes("connection") ||
|
||||||
t.includes("terminated") ||
|
t.includes("terminated") ||
|
||||||
t.includes("econnreset") ||
|
t.includes("econnreset") ||
|
||||||
t.includes("socket hang")
|
t.includes("socket hang")
|
||||||
) {
|
) {
|
||||||
return "TRANSIENT";
|
return "agent.transient";
|
||||||
}
|
}
|
||||||
if (t.includes("missing target")) return "MISSING_TARGET";
|
if (t.includes("missing target")) return "agent.missing_deliverable";
|
||||||
if (payload?.retryable) return "TRANSIENT";
|
if (payload?.retryable) return "agent.transient";
|
||||||
if (!payload?.ok) return "RUN_FAILED";
|
if (!payload?.ok) return "agent.run_failed";
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,7 +117,7 @@ export function withResultV1(payload) {
|
|||||||
run_id: String(out.run_id || "unknown").slice(0, 128),
|
run_id: String(out.run_id || "unknown").slice(0, 128),
|
||||||
outcome: ok ? "success" : "error",
|
outcome: ok ? "success" : "error",
|
||||||
retryable,
|
retryable,
|
||||||
error_code: ALLOWED_CODES.has(errorCode) ? errorCode : ok ? "" : "RUN_FAILED",
|
error_code: ALLOWED_CODES.has(errorCode) ? errorCode : ok ? "" : "agent.run_failed",
|
||||||
members,
|
members,
|
||||||
content_digest: membersDigest(members),
|
content_digest: membersDigest(members),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package catalogcheck
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"agent-native/exec"
|
||||||
|
"error-code-system/errcode"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 常量取值必须与错误码库种子码相同。旧短字符串只作为错误码库里的别名存在。
|
||||||
|
func TestContractCodesMatchCatalog(t *testing.T) {
|
||||||
|
pairs := []struct {
|
||||||
|
got exec.ErrorCode
|
||||||
|
want errcode.Code
|
||||||
|
}{
|
||||||
|
{exec.ErrCanceled, errcode.AgentCanceled},
|
||||||
|
{exec.ErrDeadlineExceeded, errcode.AgentDeadlineExceeded},
|
||||||
|
{exec.ErrBudgetExceeded, errcode.AgentBudgetExceeded},
|
||||||
|
{exec.ErrProviderUnavailable, errcode.AgentProviderUnavailable},
|
||||||
|
{exec.ErrProviderRejected, errcode.AgentProviderRejected},
|
||||||
|
{exec.ErrToolRejected, errcode.AgentToolRejected},
|
||||||
|
{exec.ErrPathOutsideWorkspace, errcode.AgentPathOutsideWorkspace},
|
||||||
|
{exec.ErrMissingCapability, errcode.AgentMissingCapability},
|
||||||
|
{exec.ErrMissingDeliverable, errcode.AgentMissingDeliverable},
|
||||||
|
{exec.ErrEmptyOutput, errcode.AgentEmptyOutput},
|
||||||
|
{exec.ErrUnknownOutcome, errcode.AgentUnknownOutcome},
|
||||||
|
{exec.ErrInternal, errcode.AgentInternal},
|
||||||
|
}
|
||||||
|
for _, pair := range pairs {
|
||||||
|
if string(pair.got) != string(pair.want) {
|
||||||
|
t.Errorf("contract %q != catalog %q", pair.got, pair.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"agent-native/rpc"
|
||||||
|
"agent-native/runtime"
|
||||||
|
"agent-native/runtime/llm"
|
||||||
|
"agent-native/runtime/session"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
mode := flag.String("mode", "print", "print, json, or rpc")
|
||||||
|
model := flag.String("model", "", "provider/model[:thinking]")
|
||||||
|
prompt := flag.String("prompt", "", "user message for print and json modes")
|
||||||
|
sessionPath := flag.String("session", "", "transcript path")
|
||||||
|
noSession := flag.Bool("no-session", false, "do not read or write a transcript")
|
||||||
|
export := flag.String("export", "", "write the active session as HTML and exit")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if *sessionPath == "" && !*noSession {
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
*sessionPath = filepath.Join(home, ".agent-native", "session.jsonl")
|
||||||
|
}
|
||||||
|
var store *session.Store
|
||||||
|
if !*noSession {
|
||||||
|
st, err := session.Open(*sessionPath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
store = st
|
||||||
|
}
|
||||||
|
if *export != "" {
|
||||||
|
if store == nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "export requires a session")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if err := store.ExportHTML(*export); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ref, err := llm.ParseModelRef(*model)
|
||||||
|
if *model != "" && err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
agent := runtime.NewAgentSession(runtime.Config{
|
||||||
|
Model: ref.Model,
|
||||||
|
ThinkingLevel: ref.Thinking,
|
||||||
|
Session: store,
|
||||||
|
Stream: liveStream(ref),
|
||||||
|
OnEvent: func(ev runtime.Event) {
|
||||||
|
if *mode != "json" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
raw, _ := json.Marshal(map[string]any{"type": ev.Type, "text": ev.Text, "tool": ev.Tool})
|
||||||
|
fmt.Println(string(raw))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
srv := &rpc.Server{Agent: agent, Store: store, NoStore: *noSession}
|
||||||
|
ctx := context.Background()
|
||||||
|
switch *mode {
|
||||||
|
case "rpc":
|
||||||
|
in := bufio.NewScanner(os.Stdin)
|
||||||
|
in.Buffer(make([]byte, 0, 64*1024), 8*1024*1024)
|
||||||
|
for in.Scan() {
|
||||||
|
resp, err := srv.Handle(ctx, in.Bytes())
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
os.Stdout.Write(resp)
|
||||||
|
}
|
||||||
|
case "print", "json":
|
||||||
|
if *prompt == "" {
|
||||||
|
*prompt = readAll(os.Stdin)
|
||||||
|
}
|
||||||
|
res, err := agent.Prompt(ctx, llm.Message{Role: llm.RoleUser, Content: *prompt})
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if res.Err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, res.Err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if *mode == "print" {
|
||||||
|
fmt.Println(res.Text)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
fmt.Fprintln(os.Stderr, "unknown mode")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func liveStream(ref llm.ModelRef) llm.StreamFunc {
|
||||||
|
provider, ok := llm.Lookup(ref.Provider, nil)
|
||||||
|
if !ok {
|
||||||
|
provider = llm.Provider{Protocol: llm.ProtocolOpenAIChat, BaseURL: os.Getenv("OPENAI_BASE_URL"), APIKey: os.Getenv("OPENAI_API_KEY")}
|
||||||
|
}
|
||||||
|
client, err := llm.ClientFor(provider)
|
||||||
|
if err != nil {
|
||||||
|
return func(ctx context.Context, model string, messages []llm.Message, tools []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
|
||||||
|
ch := make(chan llm.Event, 1)
|
||||||
|
ch <- llm.Event{Kind: llm.KindError, Err: err, Message: llm.Message{StopReason: llm.StopError}}
|
||||||
|
close(ch)
|
||||||
|
return ch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return llm.WithRetry(client.Stream, 2, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func readAll(r io.Reader) string {
|
||||||
|
raw, _ := io.ReadAll(r)
|
||||||
|
return string(raw)
|
||||||
|
}
|
||||||
+23
-23
@@ -11,27 +11,27 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"agent-native/contract"
|
"agent-native/exec"
|
||||||
)
|
)
|
||||||
|
|
||||||
// FakeBackend 是确定性假实现,仅用于契约套件自测与消费方单测。
|
// FakeBackend 是确定性假实现,仅用于契约套件自测与消费方单测。
|
||||||
// 不模拟任何模型行为;按 Script 逐项返回。
|
// 不模拟任何模型行为;按 Script 逐项返回。
|
||||||
type FakeBackend struct {
|
type FakeBackend struct {
|
||||||
Desc contract.BackendDescriptor
|
Desc exec.BackendDescriptor
|
||||||
// Result 为每次 Execute 返回的终态(浅拷贝后返回)。
|
// Result 为每次 Execute 返回的终态(浅拷贝后返回)。
|
||||||
Result contract.Result
|
Result exec.ContractResult
|
||||||
// BlockUntil 若非 nil,Execute 阻塞直到该 channel 关闭或 ctx 取消,
|
// BlockUntil 若非 nil,Execute 阻塞直到该 channel 关闭或 ctx 取消,
|
||||||
// 用于验证取消语义。
|
// 用于验证取消语义。
|
||||||
BlockUntil <-chan struct{}
|
BlockUntil <-chan struct{}
|
||||||
// CanceledWithCtx 记录 Execute 返回时 ctx 是否已取消。
|
// CanceledWithCtx 记录 Execute 返回时 ctx 是否已取消。
|
||||||
CanceledWithCtx bool
|
CanceledWithCtx bool
|
||||||
// LastRequest 记录最近一次请求。
|
// LastRequest 记录最近一次请求。
|
||||||
LastRequest contract.Request
|
LastRequest exec.ContractRequest
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *FakeBackend) Descriptor() contract.BackendDescriptor { return f.Desc }
|
func (f *FakeBackend) Descriptor() exec.BackendDescriptor { return f.Desc }
|
||||||
|
|
||||||
func (f *FakeBackend) Execute(ctx context.Context, req contract.Request) (*contract.Result, error) {
|
func (f *FakeBackend) Execute(ctx context.Context, req exec.ContractRequest) (*exec.ContractResult, error) {
|
||||||
f.LastRequest = req
|
f.LastRequest = req
|
||||||
if f.BlockUntil != nil {
|
if f.BlockUntil != nil {
|
||||||
select {
|
select {
|
||||||
@@ -39,10 +39,10 @@ func (f *FakeBackend) Execute(ctx context.Context, req contract.Request) (*contr
|
|||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
f.CanceledWithCtx = true
|
f.CanceledWithCtx = true
|
||||||
res := *(&f.Result)
|
res := *(&f.Result)
|
||||||
res.Outcome = contract.OutcomeCanceled
|
res.Outcome = exec.OutcomeCanceled
|
||||||
res.Err = &contract.Error{
|
res.Err = &exec.Error{
|
||||||
Code: contract.ErrCanceled,
|
Code: exec.ErrCanceled,
|
||||||
SideEffects: contract.SideEffectNone,
|
SideEffects: exec.SideEffectNone,
|
||||||
}
|
}
|
||||||
return &res, nil
|
return &res, nil
|
||||||
}
|
}
|
||||||
@@ -55,15 +55,15 @@ func (f *FakeBackend) Execute(ctx context.Context, req contract.Request) (*contr
|
|||||||
type Case struct {
|
type Case struct {
|
||||||
Name string
|
Name string
|
||||||
// Backend 为被测实现。
|
// Backend 为被测实现。
|
||||||
Backend contract.Backend
|
Backend exec.ContractBackend
|
||||||
// Request 为请求模板。
|
// Request 为请求模板。
|
||||||
Request contract.Request
|
Request exec.ContractRequest
|
||||||
// CancelAfter 若非零,在等待该时长后取消 ctx(验证取消路径)。
|
// CancelAfter 若非零,在等待该时长后取消 ctx(验证取消路径)。
|
||||||
CancelAfter time.Duration
|
CancelAfter time.Duration
|
||||||
// WantOutcome 为期望终态。
|
// WantOutcome 为期望终态。
|
||||||
WantOutcome contract.Outcome
|
WantOutcome exec.Outcome
|
||||||
// WantErrCode 为期望错误码(Failed/Canceled/Unknown 时)。
|
// WantErrCode 为期望错误码(Failed/Canceled/Unknown 时)。
|
||||||
WantErrCode contract.ErrorCode
|
WantErrCode exec.ErrorCode
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run 执行一条契约用例。
|
// Run 执行一条契约用例。
|
||||||
@@ -96,23 +96,23 @@ func Run(t *testing.T, c Case) {
|
|||||||
t.Fatalf("[%s] Err.SideEffects must be explicit (none|possible), got empty", c.Name)
|
t.Fatalf("[%s] Err.SideEffects must be explicit (none|possible), got empty", c.Name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if res.Outcome == contract.OutcomeSucceeded && res.Err != nil {
|
if res.Outcome == exec.OutcomeSucceeded && res.Err != nil {
|
||||||
t.Fatalf("[%s] Succeeded result must not carry Err", c.Name)
|
t.Fatalf("[%s] Succeeded result must not carry Err", c.Name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Suite 是最小契约套件:成功、失败带副作用标记、取消三态。
|
// Suite 是最小契约套件:成功、失败带副作用标记、取消三态。
|
||||||
// 每个真实 Backend 实现都必须通过;套件外按实现能力补充专项用例。
|
// 每个真实 Backend 实现都必须通过;套件外按实现能力补充专项用例。
|
||||||
func Suite(t *testing.T, mk func() contract.Backend) {
|
func Suite(t *testing.T, mk func() exec.ContractBackend) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
t.Run("succeeded_minimal", func(t *testing.T) {
|
t.Run("succeeded_minimal", func(t *testing.T) {
|
||||||
b := mk()
|
b := mk()
|
||||||
Run(t, Case{
|
Run(t, Case{
|
||||||
Name: "succeeded",
|
Name: "succeeded",
|
||||||
Backend: b,
|
Backend: b,
|
||||||
Request: contract.Request{ExecutionID: "e1", Prompt: "hi"},
|
Request: exec.ContractRequest{ExecutionID: "e1", Prompt: "hi"},
|
||||||
WantOutcome: contract.OutcomeSucceeded,
|
WantOutcome: exec.OutcomeSucceeded,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -130,13 +130,13 @@ func Suite(t *testing.T, mk func() contract.Backend) {
|
|||||||
t.Run("error_sideeffects_explicit", func(t *testing.T) {
|
t.Run("error_sideeffects_explicit", func(t *testing.T) {
|
||||||
b := mk()
|
b := mk()
|
||||||
// 通过描述能力判定是否有失败路径可测;无失败路径的实现跳过。
|
// 通过描述能力判定是否有失败路径可测;无失败路径的实现跳过。
|
||||||
if !b.Descriptor().Has(contract.CapText) && !b.Descriptor().Has(contract.CapFiles) {
|
if !b.Descriptor().Has(exec.CapText) && !b.Descriptor().Has(exec.CapFiles) {
|
||||||
t.Skip("no text/files capability to force failure path")
|
t.Skip("no text/files capability to force failure path")
|
||||||
}
|
}
|
||||||
// 空提示在多数实现下是确定性失败或成功空输出;仅验证当 Outcome=Failed
|
// 空提示在多数实现下是确定性失败或成功空输出;仅验证当 Outcome=Failed
|
||||||
// 时错误字段完整。具体实现应补充自身失败注入用例。
|
// 时错误字段完整。具体实现应补充自身失败注入用例。
|
||||||
res, _ := b.Execute(context.Background(), contract.Request{ExecutionID: "e2"})
|
res, _ := b.Execute(context.Background(), exec.ContractRequest{ExecutionID: "e2"})
|
||||||
if res != nil && res.Outcome == contract.OutcomeFailed {
|
if res != nil && res.Outcome == exec.OutcomeFailed {
|
||||||
if res.Err == nil || res.Err.Code == "" || res.Err.SideEffects == "" {
|
if res.Err == nil || res.Err.Code == "" || res.Err.SideEffects == "" {
|
||||||
t.Fatalf("failed result must carry Code and SideEffects, got %+v", res.Err)
|
t.Fatalf("failed result must carry Code and SideEffects, got %+v", res.Err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package conformance
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"agent-native/exec"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFakeBackendSuite(t *testing.T) {
|
||||||
|
Suite(t, func() exec.ContractBackend {
|
||||||
|
return &FakeBackend{
|
||||||
|
Desc: exec.BackendDescriptor{
|
||||||
|
Name: "fake",
|
||||||
|
Capabilities: []exec.Capability{exec.CapText},
|
||||||
|
},
|
||||||
|
Result: exec.ContractResult{
|
||||||
|
Outcome: exec.OutcomeSucceeded,
|
||||||
|
Text: "ok",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFakeCancelSemantics(t *testing.T) {
|
||||||
|
block := make(chan struct{})
|
||||||
|
b := &FakeBackend{
|
||||||
|
Desc: exec.BackendDescriptor{Name: "fake", Capabilities: []exec.Capability{exec.CapCancel}},
|
||||||
|
BlockUntil: block,
|
||||||
|
}
|
||||||
|
Run(t, Case{
|
||||||
|
Name: "cancel",
|
||||||
|
Backend: b,
|
||||||
|
Request: exec.ContractRequest{ExecutionID: "e-cancel"},
|
||||||
|
CancelAfter: 50_000_000,
|
||||||
|
WantOutcome: exec.OutcomeCanceled,
|
||||||
|
WantErrCode: exec.ErrCanceled,
|
||||||
|
})
|
||||||
|
if !b.CanceledWithCtx {
|
||||||
|
t.Fatal("backend must observe ctx cancellation before returning")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
package contract_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"agent-native/conformance"
|
|
||||||
"agent-native/contract"
|
|
||||||
)
|
|
||||||
|
|
||||||
// 契约自测:fake 后端通过最小套件,验证套件本身与类型语义一致。
|
|
||||||
func TestFakeBackendSuite(t *testing.T) {
|
|
||||||
conformance.Suite(t, func() contract.Backend {
|
|
||||||
return &conformance.FakeBackend{
|
|
||||||
Desc: contract.BackendDescriptor{
|
|
||||||
Name: "fake",
|
|
||||||
Capabilities: []contract.Capability{contract.CapText},
|
|
||||||
},
|
|
||||||
Result: contract.Result{
|
|
||||||
Outcome: contract.OutcomeSucceeded,
|
|
||||||
Text: "ok",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFakeCancelSemantics(t *testing.T) {
|
|
||||||
block := make(chan struct{})
|
|
||||||
b := &conformance.FakeBackend{
|
|
||||||
Desc: contract.BackendDescriptor{Name: "fake", Capabilities: []contract.Capability{contract.CapCancel}},
|
|
||||||
BlockUntil: block,
|
|
||||||
}
|
|
||||||
conformance.Run(t, conformance.Case{
|
|
||||||
Name: "cancel",
|
|
||||||
Backend: b,
|
|
||||||
Request: contract.Request{ExecutionID: "e-cancel"},
|
|
||||||
CancelAfter: 50_000_000, // 50ms in ns (avoid time import here)
|
|
||||||
WantOutcome: contract.OutcomeCanceled,
|
|
||||||
WantErrCode: contract.ErrCanceled,
|
|
||||||
})
|
|
||||||
if !b.CanceledWithCtx {
|
|
||||||
t.Fatal("backend must observe ctx cancellation before returning")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+1
Submodule deps/agent-intent added at 765a7e7177
+1
Submodule deps/development-guidelines added at 2688fd2be6
+1
Submodule deps/development-manager added at bd3a7de57a
+1
Submodule deps/error-code-system added at a3ee67bba2
+1
Submodule deps/logging-system added at 49b5d8be60
@@ -66,3 +66,42 @@ baseline_fixed: true
|
|||||||
- 本矩阵证据均指向仓库内文件(上文路径),不含运行时截图;
|
- 本矩阵证据均指向仓库内文件(上文路径),不含运行时截图;
|
||||||
- Batch1 完成后在本文件追加"实测证据"列;
|
- Batch1 完成后在本文件追加"实测证据"列;
|
||||||
- 上游仅引固定 0.85.1 的 README/docs 结论,不引 main。
|
- 上游仅引固定 0.85.1 的 README/docs 结论,不引 main。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## R15 扩面批次(full-agent-r1,2026-10-01):从「wrapper 对标」到「完全智能体」
|
||||||
|
|
||||||
|
用户定调(2026-10-01):Agent-Native 补全 pi-Agent 全量工具调用能力(网络搜索、代码编写、
|
||||||
|
系统工具、外部工具调用、技能、提示词),并与 Agent-Squad 融合。扩面域**不做 pi 对标**
|
||||||
|
(上游 wrapper 未暴露这些能力),改以「标准符合 + conformance 负例 + 产品验收」为准——
|
||||||
|
对标口径条款对本批新增域不适用,原有 #1–#16 行继续有效。
|
||||||
|
|
||||||
|
| 域 | 内容 | 出口证据 |
|
||||||
|
|---|---|---|
|
||||||
|
| 执行基座 | exec.Request v0.2(Model/ToolAllowlist/Skills/Budget/SessionScope/ExecutionID/Env)+ Registry.Restrict 策略层(精确名 + 命名空间通配)+ 预算贯穿(token/工具数/截止,budget_exceeded 永久失败)+ 取消传播到工具 | loop_budget / registry_policy / request_v2 / step_retry_budget 测试;关闭 #4/#10/#16 |
|
||||||
|
| 代码工具面 | glob(** 跨目录)、grep 正则/include/ignore_case、read 行范围分页、multi_edit 原子多处替换 | code_tools_test.go |
|
||||||
|
| 系统工具 | run_command / run_python:解释器白名单、env 白名单(宿主密钥不透传)、进程组 SIGKILL(超时/取消)、64KB 头尾截断、命令黑名单、脚本落盘 .agent/scripts/;Options.Exec 未配置不注册 | exec_tools_test.go(含杀组时限断言) |
|
||||||
|
| 网络面 | tavily_search/web_search 经 Options.Search 接线(关闭 #5 尾环);fetch_url:SSRF 硬边界(私网/回环/链路本地/元数据端点逐跳校验)、域名策略、2MB/20s、HTML 剥离 | fetch_test.go + 适配器接线测试 |
|
||||||
|
| MCP | stdio + Streamable HTTP 客户端、initialize/tools/list(paged)/tools/call、Mcp-Session-Id、mcp__<server>__<tool> 桥接、64 工具/1KB 描述/64KB 结果上限、不可达 fail-closed、真实子进程测试(re-exec fake server) | runtime/mcp/mcp_test.go |
|
||||||
|
| 技能/提示词 | SKILL.md 加载(目录名权威、frontmatter 描述)、load_skill 渐进披露、声明技能 fail-closed 校验、promptx 装配器(环境/技能索引/交付物) | skills_test / promptx_test / 适配器测试 |
|
||||||
|
| 会话 | compaction(阈值触发、原子改写、失败留痕不阻断)、元数据 sidecar、崩溃半行容错 | compact_test.go;对齐 #11/#12 |
|
||||||
|
| 委派 | runtime/subagent 端口 + delegate_task(信息集=委派条款);嵌套执行由宿主注入 | subagent_test.go |
|
||||||
|
|
||||||
|
安全声明(不冒充已解决):run_command **不是 OS 沙箱**(宿主负责隔离);技能正文与 MCP
|
||||||
|
server 描述是提示注入面(只信受信根 + 长度上限);fetch 的 DNS 解析存在 rebinding 窗口。
|
||||||
|
扩展进程、安装的包和外部 CLI 都以调用者的完整权限运行,同样不是沙箱。
|
||||||
|
|
||||||
|
## R16 无界面全量(pi-headless,2026-10-01)
|
||||||
|
|
||||||
|
对标口径改为 pi 的无界面能力。不做 TUI、主题、快捷键,也不做订阅制 OAuth。
|
||||||
|
|
||||||
|
| 域 | 现状 |
|
||||||
|
|---|---|
|
||||||
|
| 会话树 | available:id/parentId、fork/clone、叶子切换、continue/resume、追加式压缩、旧线性文件仍可 Load、export_html |
|
||||||
|
| 供应商 | available:Anthropic、OpenAI Chat 与 Responses、Google、Azure、兼容预设、models.json、`provider/id:thinking`、图片、缓存 token、自动重试;上下文溢出不重试 |
|
||||||
|
| AgentSession | available:steer/follow-up、one 与 all、abort、clear_queue、中途换模型;事件含 message_start、tool_execution_update、queue_update、agent_settled |
|
||||||
|
| 资源 | available:pi 工具名与原名并存、上下文文件、提示词模板、`/skill:name`、项目信任 |
|
||||||
|
| 扩展与包 | available:stdio JSONL 子进程;install/remove/update/list/config |
|
||||||
|
| 模式 | available:`cmd/agent-native` 的 print/json/rpc,以及 Go `rpc.Client` |
|
||||||
|
| exec.Backend | available:`adapters/native` 的 Execute 经 AgentSession,请求/结果字段不变 |
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# 接入开发规范并为执行契约对齐错误码与日志
|
||||||
|
|
||||||
|
状态:已批准范围(实现进行中);owner:weichen;主任务:本仓接入提交。
|
||||||
|
授权与交付终点:在 Agent-Native 内挂载规范子模块、补齐项目入口,并把执行错误与日志接到 Error-Code-System / Logging-System。不改 Gitea 权限,不发布制品。
|
||||||
|
并行基线:Agent-Native `main` @ `49e6dd1e0fc65de7f259f21d2f63c56e5b165a48`;Development-Guidelines `26b562d2e34fbc560711296a309c91c74ffc55b1`;Error-Code-System `b67696d393e7abfbdd16fdde1064c07f5ecd2f72`;Logging-System `7f5972007bdcb6047ce7f95ee891f53e0af9d136`。日期 2026-09-29。
|
||||||
|
|
||||||
|
## 问题与证据
|
||||||
|
|
||||||
|
四个基础库此前没有统一的项目入口,也没有把 Development-Guidelines 固定进父仓。Agent-Native 的 `contract.ErrorCode` 仍是短字符串(如 `canceled`),与错误码库的 `agent.*` 规范码只在映射文档中对应,调用方无法从错误值直接得到规范码。runtime 没有可替换的结构化日志,失败只能靠返回值。
|
||||||
|
|
||||||
|
经验检索:规范库与两个基础库均为新仓,无已验证经验条目。
|
||||||
|
|
||||||
|
## 目标与边界
|
||||||
|
|
||||||
|
- 规范以 `deps/development-guidelines` 的 gitlink 为版本权威。
|
||||||
|
- `contract.ErrorCode` 的取值改为 `agent.*`;`contract.Error` 提供 `ErrorCode()` / `ErrorFields()`,使日志库能识别错误且 `contract` 不导入错误码模块。
|
||||||
|
- pitransport 对外错误码同步为规范码;`CANCELLED` 并入 `agent.canceled`,`MISSING_TARGET` 并入 `agent.missing_deliverable`。
|
||||||
|
- runtime 与 native adapter 接受可选 `*logsys.Logger`,nil 时使用空日志器。
|
||||||
|
- 非目标:不改 Squad 编排;不把本仓变成错误码目录的第二权威;不自动跟踪子模块上游分支。
|
||||||
|
|
||||||
|
## 选择与实施
|
||||||
|
|
||||||
|
子模块统一放 `deps/`,与后续各仓布局一致。错误码常量改值而不是在调用点拼接前缀,避免两套字符串长期并存。`contract` 只增加鸭子方法,不 `import error-code-system`,保持该包零外部依赖。
|
||||||
|
|
||||||
|
1. 挂载 `deps/development-guidelines` 并提交入口文档。
|
||||||
|
2. 挂载 `deps/error-code-system`、`deps/logging-system`,`go.mod` 用相对 `replace`。
|
||||||
|
3. 修改 `contract` 与 `adapters/pitransport` 的错误码取值,补日志钩子与 `scripts/submodule-consistency.sh`。
|
||||||
|
4. 仓库根 `go test` 作为本模块出口。
|
||||||
|
|
||||||
|
## 兼容、迁移与恢复
|
||||||
|
|
||||||
|
错误码取值变更对按旧字符串比较的调用方是破坏性变更。错误码库已把旧值登记为别名,`Registry.Resolve` 仍可解析旧字符串。`agent-system-multi` 若硬编码旧码,需在其自己的消费提交里跟进;本仓 gitlink 未进主仓组合之前不影响主仓构建。
|
||||||
|
|
||||||
|
回退:父仓 PR 恢复旧 gitlink 与旧常量值。无数据库迁移。暂停条件:`go test` 失败或子模块 SHA 无法从批准远端获取。
|
||||||
|
|
||||||
|
## 验收
|
||||||
|
|
||||||
|
| 需求/验收条件 | 负责模块/契约或不变量 | 验证方法/用例 | 实际结果/证据 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 规范入口可发现且版本固定 | 根 AGENTS.md / gitlink | `git submodule status`;打开相对链接 | 计划阶段待执行 |
|
||||||
|
| contract 错误码为 agent.* 且鸭子方法可被日志识别 | contract.Error | `go test ./contract/...` | 计划阶段待执行 |
|
||||||
|
| pitransport 历史码映射到规范码 | adapters/pitransport | `go test ./adapters/pitransport/...` | 计划阶段待执行 |
|
||||||
|
| 可选日志器为 nil 时行为不变 | runtime / adapters/native | `go test ./runtime/... ./adapters/native/...` | 计划阶段待执行 |
|
||||||
|
|
||||||
|
| 用户/系统条件 | 检查层级与方法 | 预期结果 | 必需环境/授权 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 全新 clone 后可测试 | `git clone --recurse-submodules` 后 `go test` | 通过 | 能访问 Gitea SSH |
|
||||||
|
| 旧错误字符串仍可被错误码库解析 | 错误码库别名测试(该库既有用例) | 别名回到规范码 | Error-Code-System 源码 |
|
||||||
|
| 未知结果与缺能力 | contract/conformance 既有用例 | 不降级、不回落 Stub | 无 |
|
||||||
|
| 无日志器的既有调用 | runtime/native 既有测试 | 保持通过 | Go 1.27 |
|
||||||
|
|
||||||
|
## 决策与剩余项
|
||||||
|
|
||||||
|
多级副本由各父仓 gitlink 锁定;Agent-Squad 构建时只采用 Agent-Squad 自己的 `replace`。一致性脚本在本仓接入两个基础库后启用。验证证据在实现完成后写入同目录的验证记录,不把本方案改称已完成。
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Agent-Native 接入 agent-intent 意图闸门(可选装饰器)
|
||||||
|
|
||||||
|
对应设计:Agent-Intent 仓 `docs/records/2026-09-29-decoupling-and-llm-layering.md`(第三节 b,决策 D7 选仓内装饰器)。
|
||||||
|
形态:`adapters/intentgate.Wrap(next, recognizer, snapshot, handler) exec.Backend`——Execute 前对 `req.Prompt` 做一次确定性识别,守卫/命中/预期命中分支交宿主 Handler 解释(合成结果语义由宿主定义),Ambiguous/Fallback 或 Handler 放弃时原样透传;识别系统错误 fail-closed。
|
||||||
|
|
||||||
|
## 依赖与解耦边界
|
||||||
|
|
||||||
|
- 本包是本仓唯一 import `agent-intent` 的位置;contract/exec/runtime/adapters/native 零感知(`GOWORK=off go list -deps ./contract ./exec ./runtime ./adapters/native` 不含 agent-intent)。
|
||||||
|
- T0 行为解耦:未 Wrap(或任一组件为 nil,直接返回原后端)与 Wrap 了零意图快照,行为与原后端一致(`TestZeroSpecSnapshotPassthroughIdentical` 逐字节对比)。
|
||||||
|
- 回退程序:删 `adapters/intentgate/` + go.mod 两行 + submodule + `.gitmodules` 条目 + 一致性脚本一行。
|
||||||
|
|
||||||
|
## 验证证据(2026-09-29,go test ./... 全绿,intentgate 含 -race)
|
||||||
|
|
||||||
|
| 用例 | 外挂意图 | 输入(Prompt) | 识别结果 | 期望 | 结果 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| TestZeroSpecSnapshotPassthroughIdentical | 无(零 spec 快照) | `帮我改一下系统网关地址` | Fallback(快照为空) | 与裸 stub 后端结果逐字节一致,Handler 不触发 | 通过 |
|
||||||
|
| TestGuardHitInterceptsWithoutModel | guard.config_change(includes: 系统配置/网关;risk=high) | `帮我改一下系统网关地址` | GuardHit,top=guard.config_change | Handler 收到 GuardHit;返回拒绝合成结果;**模型后端零调用** | 通过 |
|
||||||
|
| TestFallbackPassesThroughToBackend | 同上 | `写一份周报` | Fallback | 透传原后端,Handler 不触发,后端恰好调用 1 次 | 通过 |
|
||||||
|
| TestHandlerDeclineFallsBackToBackend | 同上 | `帮我改一下系统网关地址` | GuardHit(Handler 放弃) | 回退透传原后端 | 通过 |
|
||||||
|
| TestNilComponentsReturnNext | n/a | n/a | n/a | 任一组件为 nil → Wrap 返回原后端 | 通过 |
|
||||||
|
| TestRecognizeErrorFailsClosed | MaxInputLen=8 快照 | 超长输入 | 识别错误 sys.invalid_argument | fail-closed 返回错误,后端零调用 | 通过 |
|
||||||
|
|
||||||
|
命令:`GOWORK=off go vet ./... && GOWORK=off go test -count=1 ./...`;`GOWORK=off go test -count=1 -race ./adapters/intentgate/`;`scripts/submodule-consistency.sh`。
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# 验证记录
|
||||||
|
|
||||||
|
- 范围/主任务:接入 Development-Guidelines,并把执行错误码与可选日志接到错误码库和日志库。
|
||||||
|
- 被测版本:工作区 `e65c6ba604c9079e1176d9449e295e0d09bedfd3`。该提交尚未推到批准远端。
|
||||||
|
- 目标仓/分支与共同起点:Agent-Native `main`,起点 `49e6dd1e0fc65de7f259f21d2f63c56e5b165a48`。
|
||||||
|
- 最新同步与测试对象:2026-09-29。子模块 `deps/development-guidelines` `26b562d2e34fbc560711296a309c91c74ffc55b1`,`deps/error-code-system` `b67696d393e7abfbdd16fdde1064c07f5ecd2f72`,`deps/logging-system` `7f5972007bdcb6047ce7f95ee891f53e0af9d136`。
|
||||||
|
- 测试结束/准入前目标复核:未合入远端。干净克隆测的是本机仓库,子模块从批准远端检出。
|
||||||
|
- 环境:本机 Go 1.27,无模型端点。
|
||||||
|
- 实际命令:仓库根 `go test -count=1 ./...`;`scripts/submodule-consistency.sh`;`git clone --recurse-submodules` 到 `/tmp/dg-verify/Agent-Native` 后再测一次。
|
||||||
|
|
||||||
|
| 预期用例/场景 | 实际是否执行 | 结果 | 证据/缺项 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 工作区全模块测试 | 是 | PASS | `go test -count=1 ./...` |
|
||||||
|
| 子模块 gitlink 与 HEAD 一致 | 是 | PASS | `scripts/submodule-consistency.sh` |
|
||||||
|
| 从本机父仓递归克隆后测试 | 是 | PASS | `/tmp/dg-verify/Agent-Native` 再次 `go test -count=1 ./...` |
|
||||||
|
| 真实模型端点 | 否 | N/A | 本仓测试使用注入流,不覆盖外部模型 |
|
||||||
|
|
||||||
|
首轮失败与重试:无。
|
||||||
|
结论与限制:只证实本机工作区与一次本机路径克隆。`e65c6ba` 还不能从批准远端按 SHA 获取,因此其他仓库在推送前不能把这个 gitlink 当作已发布组合。
|
||||||
|
剩余项/责任人:推送由仓库所有者决定。推送前 Agent-Squad 的全新远端克隆会因缺少该提交而失败。
|
||||||
+69
-1
@@ -16,7 +16,35 @@ type Request struct {
|
|||||||
// OnEvent 可选过程事件回调(nil = 调用方不需要过程,后端行为与现状完全一致)。
|
// OnEvent 可选过程事件回调(nil = 调用方不需要过程,后端行为与现状完全一致)。
|
||||||
// 实现方必须保证:回调在 Execute 返回前完成(同步调用或等待收尾),无泄漏 goroutine;
|
// 实现方必须保证:回调在 Execute 返回前完成(同步调用或等待收尾),无泄漏 goroutine;
|
||||||
// 回调内不得阻塞(消费方自行节流/异步入队)。
|
// 回调内不得阻塞(消费方自行节流/异步入队)。
|
||||||
OnEvent func(ev ProcessEvent)
|
OnEvent func(ev ProcessEvent) `json:"-"`
|
||||||
|
|
||||||
|
// ---- v0.2 能力字段(全部可选;零值 = 既有行为,向后兼容) ----
|
||||||
|
|
||||||
|
// Model 覆盖本请求使用的模型(后端构造时的默认模型仍兜底)。
|
||||||
|
Model string `json:"model,omitempty"`
|
||||||
|
// ToolAllowlist 收窄可见工具集;出现后端未注册的工具名时 fail-closed。
|
||||||
|
ToolAllowlist []string `json:"tool_allowlist,omitempty"`
|
||||||
|
// Skills 声明本次执行需要加载的技能(内核在系统提示中列出并由 load_skill 按需展开)。
|
||||||
|
Skills []string `json:"skills,omitempty"`
|
||||||
|
// Budget 单次步骤执行的共享预算;步骤重试应传入剩余额度,不得各自另起一份。
|
||||||
|
Budget *Budget `json:"budget,omitempty"`
|
||||||
|
// SessionScope / ExecutionID 只用于日志与过程事件贯穿,不参与控制流。
|
||||||
|
SessionScope string `json:"session_scope,omitempty"`
|
||||||
|
ExecutionID string `json:"execution_id,omitempty"`
|
||||||
|
// Env 是组合根批准后注入执行类工具(run_command 等)的白名单环境变量。
|
||||||
|
// 后端不得把自身持有的密钥并入其中。
|
||||||
|
Env map[string]string `json:"env,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Budget 是一次步骤执行的共享预算(agent-execution 标准:重试层各有职责,
|
||||||
|
// 共享总预算与截止时间,避免传输、步骤与主循环次数相乘)。
|
||||||
|
// 全部字段可选;零值字段不设限。
|
||||||
|
type Budget struct {
|
||||||
|
MaxPromptTokens int `json:"max_prompt_tokens,omitempty"`
|
||||||
|
MaxCompletionTokens int `json:"max_completion_tokens,omitempty"`
|
||||||
|
MaxToolCalls int `json:"max_tool_calls,omitempty"`
|
||||||
|
// Deadline 绝对截止时刻;与 Request.Timeout 先到者生效。
|
||||||
|
Deadline time.Time `json:"deadline,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProcessEvent 对 agentx 事件与远程 runner NDJSON 过程帧的统一投影,
|
// ProcessEvent 对 agentx 事件与远程 runner NDJSON 过程帧的统一投影,
|
||||||
@@ -26,6 +54,22 @@ type ProcessEvent struct {
|
|||||||
Text string // delta 文本(Type 为 *_delta 时)
|
Text string // delta 文本(Type 为 *_delta 时)
|
||||||
Tool string // 工具名(tool_* 时)
|
Tool string // 工具名(tool_* 时)
|
||||||
Detail string // 工具参数/结果摘要(tool_execution_* 时)
|
Detail string // 工具参数/结果摘要(tool_execution_* 时)
|
||||||
|
// CallID 是提供方 tool-call id;在 tool_call_delta / tool_execution_start /
|
||||||
|
// tool_execution_end 时填写。零值 = 旧行为(消费方仍可按 Tool 名启发式配对)。
|
||||||
|
CallID string `json:"call_id,omitempty"`
|
||||||
|
// Status 在 tool_execution_end 时填写:ok | error | denied | canceled。
|
||||||
|
// 空串 = legacy/unknown,不得臆造。
|
||||||
|
Status string `json:"status,omitempty"`
|
||||||
|
// ErrorReason 在 Status 为 error/denied/canceled 时携带真实失败文本。
|
||||||
|
ErrorReason string `json:"error_reason,omitempty"`
|
||||||
|
// PromptTokens / CompletionTokens 在 Type=usage 时填写。
|
||||||
|
PromptTokens int
|
||||||
|
CompletionTokens int
|
||||||
|
// 以下在 Type=usage 或 message_end 时填写(N3 WP6:上一请求与缓存命中拆分)。
|
||||||
|
LastPromptTokens int
|
||||||
|
LastCompletionTokens int
|
||||||
|
CacheReadTokens int
|
||||||
|
CacheWriteTokens int
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -40,10 +84,34 @@ type Result struct {
|
|||||||
ErrorReason string `json:"error_reason"`
|
ErrorReason string `json:"error_reason"`
|
||||||
// Retryable marks a provider-transient failure that may receive a
|
// Retryable marks a provider-transient failure that may receive a
|
||||||
// fresh step invocation. Permanent missing deliverables stay false.
|
// fresh step invocation. Permanent missing deliverables stay false.
|
||||||
|
// Canceled and Unknown are never retryable; do not set Retryable with them.
|
||||||
Retryable bool `json:"retryable,omitempty"`
|
Retryable bool `json:"retryable,omitempty"`
|
||||||
// PromptTokens and CompletionTokens are set by pi-native when the provider reports usage.
|
// PromptTokens and CompletionTokens are set by pi-native when the provider reports usage.
|
||||||
PromptTokens int `json:"prompt_tokens,omitempty"`
|
PromptTokens int `json:"prompt_tokens,omitempty"`
|
||||||
CompletionTokens int `json:"completion_tokens,omitempty"`
|
CompletionTokens int `json:"completion_tokens,omitempty"`
|
||||||
|
|
||||||
|
// ---- terminal fidelity (optional; zero-value = legacy behavior) ----
|
||||||
|
|
||||||
|
// Canceled marks an aborted/canceled run. Distinct from Retryable:
|
||||||
|
// a canceled action must not be auto-retried (Retryable must stay false).
|
||||||
|
Canceled bool `json:"canceled,omitempty"`
|
||||||
|
// Unknown marks that execution reached the provider/tool stage and the
|
||||||
|
// terminal outcome could not be determined (side effects may exist).
|
||||||
|
// Unknown is not a retryable failure.
|
||||||
|
Unknown bool `json:"unknown,omitempty"`
|
||||||
|
// SideEffects qualifies external side-effect risk for retry/fallback.
|
||||||
|
// Empty means SideEffectPossible — the safe legacy reading. Set
|
||||||
|
// SideEffectNone only when no tool dispatch (or equivalent) is provable.
|
||||||
|
SideEffects SideEffects `json:"side_effects,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// EffectiveSideEffects returns the side-effect qualifier for retry decisions.
|
||||||
|
// An empty SideEffects value means SideEffectPossible (backward compatible).
|
||||||
|
func (r *Result) EffectiveSideEffects() SideEffects {
|
||||||
|
if r == nil || r.SideEffects == "" {
|
||||||
|
return SideEffectPossible
|
||||||
|
}
|
||||||
|
return r.SideEffects
|
||||||
}
|
}
|
||||||
|
|
||||||
type Backend interface {
|
type Backend interface {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
// Package contract 定义 Agent-Native 的公共执行契约。
|
// 原 contract 包并入 exec。与本文件同名的执行门面仍是 Request、Result、Backend;
|
||||||
|
// 设计契约里冲突的四个名字加 Contract 前缀。
|
||||||
//
|
//
|
||||||
// 本包是助手(Core)与小队(Squad)共用的唯一执行接口层:Core 注入具体
|
// 本包是助手(Core)与小队(Squad)共用的唯一执行接口层:Core 注入具体
|
||||||
// Backend 实现,Squad 只表达需求并使用已注入的后端。契约不依赖任何具体
|
// ContractBackend 实现,Squad 只表达需求并使用已注入的后端。契约不依赖任何具体
|
||||||
// 内核、产品类型、数据库或配置包。
|
// 内核、产品类型、数据库或配置包。
|
||||||
//
|
//
|
||||||
// 语义不变量(实现方与消费方都必须遵守):
|
// 语义不变量(实现方与消费方都必须遵守):
|
||||||
@@ -20,7 +21,7 @@
|
|||||||
// 上下文,不授予跨会话可见性。
|
// 上下文,不授予跨会话可见性。
|
||||||
// 7. Workspace 的路径约束不是 OS 沙箱;执行不可信第三方工具需要调用方
|
// 7. Workspace 的路径约束不是 OS 沙箱;执行不可信第三方工具需要调用方
|
||||||
// 提供的进程/容器隔离,不能由本契约推导。
|
// 提供的进程/容器隔离,不能由本契约推导。
|
||||||
package contract
|
package exec
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -50,12 +51,6 @@ const (
|
|||||||
CapStructuredOutput Capability = "structured-output"
|
CapStructuredOutput Capability = "structured-output"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ToolProfile 是预定义工具策略名;与现有产品配置兼容。
|
|
||||||
const (
|
|
||||||
ToolProfileWorkspace = "workspace" // 默认:文档类工具受限集合
|
|
||||||
ToolProfileReasoning = "reasoning" // 无工具,仅推理
|
|
||||||
)
|
|
||||||
|
|
||||||
// ToolPolicy 描述执行允许的工具范围。实现不得自行扩展;未知工具一律拒绝。
|
// ToolPolicy 描述执行允许的工具范围。实现不得自行扩展;未知工具一律拒绝。
|
||||||
type ToolPolicy struct {
|
type ToolPolicy struct {
|
||||||
// Profile 为策略名(workspace/reasoning/或产品注册的自定义名)。
|
// Profile 为策略名(workspace/reasoning/或产品注册的自定义名)。
|
||||||
@@ -82,8 +77,8 @@ type SessionScope struct {
|
|||||||
Session string `json:"session,omitempty"`
|
Session string `json:"session,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Budget 约束一次执行(含共享 ExecutionID 的重试)。
|
// ContractBudget 约束一次执行(含共享 ExecutionID 的重试)。
|
||||||
type Budget struct {
|
type ContractBudget struct {
|
||||||
// MaxPromptTokens/MaxCompletionTokens 为 0 时不限制。
|
// MaxPromptTokens/MaxCompletionTokens 为 0 时不限制。
|
||||||
MaxPromptTokens int `json:"max_prompt_tokens,omitempty"`
|
MaxPromptTokens int `json:"max_prompt_tokens,omitempty"`
|
||||||
MaxCompletionTokens int `json:"max_completion_tokens,omitempty"`
|
MaxCompletionTokens int `json:"max_completion_tokens,omitempty"`
|
||||||
@@ -98,16 +93,16 @@ type Message struct {
|
|||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Request 是一次授权执行的统一输入。字段为设计契约;现有 file-only 流程
|
// ContractRequest 是一次授权执行的统一输入。字段为设计契约;现有 file-only 流程
|
||||||
// (Prompt+Instructions+Targets→reply.md)是其子集 facade。
|
// (Prompt+Instructions+Targets→reply.md)是其子集 facade。
|
||||||
type Request struct {
|
type ContractRequest struct {
|
||||||
// ExecutionID 由调用方生成,重试与受控 fallback 复用同值。
|
// ExecutionID 由调用方生成,重试与受控 fallback 复用同值。
|
||||||
ExecutionID string `json:"execution_id"`
|
ExecutionID string `json:"execution_id"`
|
||||||
// SessionScope 为隔离身份;可为零值(一次性任务)。
|
// SessionScope 为隔离身份;可为零值(一次性任务)。
|
||||||
SessionScope SessionScope `json:"session_scope"`
|
SessionScope SessionScope `json:"session_scope"`
|
||||||
|
|
||||||
Prompt string `json:"prompt"`
|
Prompt string `json:"prompt"`
|
||||||
Instructions string `json:"instructions,omitempty"`
|
Instructions string `json:"instructions,omitempty"`
|
||||||
// Context 为可选历史消息;实现不得将其与 Prompt 之外的数据混合。
|
// Context 为可选历史消息;实现不得将其与 Prompt 之外的数据混合。
|
||||||
Context []Message `json:"context,omitempty"`
|
Context []Message `json:"context,omitempty"`
|
||||||
|
|
||||||
@@ -118,8 +113,8 @@ type Request struct {
|
|||||||
Workspace WorkspaceHandle `json:"workspace"`
|
Workspace WorkspaceHandle `json:"workspace"`
|
||||||
// ToolPolicy 为工具范围;空 Profile 等同 reasoning。
|
// ToolPolicy 为工具范围;空 Profile 等同 reasoning。
|
||||||
ToolPolicy ToolPolicy `json:"tool_policy"`
|
ToolPolicy ToolPolicy `json:"tool_policy"`
|
||||||
// Budget 为本次执行预算。
|
// ContractBudget 为本次执行预算。
|
||||||
Budget Budget `json:"budget"`
|
ContractBudget ContractBudget `json:"budget"`
|
||||||
// Required 为必须满足的能力;解析器据此选择实现,缺失即拒绝。
|
// Required 为必须满足的能力;解析器据此选择实现,缺失即拒绝。
|
||||||
Required []Capability `json:"required,omitempty"`
|
Required []Capability `json:"required,omitempty"`
|
||||||
|
|
||||||
@@ -143,12 +138,12 @@ const (
|
|||||||
type EventKind string
|
type EventKind string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
EventTextDelta EventKind = "text_delta"
|
EventTextDelta EventKind = "text_delta"
|
||||||
EventReasoningDelta EventKind = "reasoning_delta"
|
EventReasoningDelta EventKind = "reasoning_delta"
|
||||||
EventToolCallDelta EventKind = "tool_call_delta"
|
EventToolCallDelta EventKind = "tool_call_delta"
|
||||||
EventToolExecStart EventKind = "tool_execution_start"
|
EventToolExecStart EventKind = "tool_execution_start"
|
||||||
EventToolExecEnd EventKind = "tool_execution_end"
|
EventToolExecEnd EventKind = "tool_execution_end"
|
||||||
EventMessageEnd EventKind = "message_end"
|
EventMessageEnd EventKind = "message_end"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Event 是过程事件帧;消费方(Core)负责映射为 SSE/持久化事件。
|
// Event 是过程事件帧;消费方(Core)负责映射为 SSE/持久化事件。
|
||||||
@@ -174,30 +169,60 @@ const (
|
|||||||
type ErrorCode string
|
type ErrorCode string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
ErrCanceled ErrorCode = "canceled"
|
ErrCanceled ErrorCode = "agent.canceled"
|
||||||
ErrDeadlineExceeded ErrorCode = "deadline_exceeded"
|
ErrDeadlineExceeded ErrorCode = "agent.deadline_exceeded"
|
||||||
ErrBudgetExceeded ErrorCode = "budget_exceeded"
|
ErrBudgetExceeded ErrorCode = "agent.budget_exceeded"
|
||||||
ErrProviderUnavailable ErrorCode = "provider_unavailable"
|
ErrProviderUnavailable ErrorCode = "agent.provider_unavailable"
|
||||||
ErrProviderRejected ErrorCode = "provider_rejected"
|
ErrProviderRejected ErrorCode = "agent.provider_rejected"
|
||||||
ErrToolRejected ErrorCode = "tool_rejected"
|
ErrToolRejected ErrorCode = "agent.tool_rejected"
|
||||||
ErrPathOutsideWorkspace ErrorCode = "path_outside_workspace"
|
ErrPathOutsideWorkspace ErrorCode = "agent.path_outside_workspace"
|
||||||
ErrMissingCapability ErrorCode = "missing_capability"
|
ErrMissingCapability ErrorCode = "agent.missing_capability"
|
||||||
ErrMissingDeliverable ErrorCode = "missing_deliverable"
|
ErrMissingDeliverable ErrorCode = "agent.missing_deliverable"
|
||||||
ErrEmptyOutput ErrorCode = "empty_output"
|
ErrEmptyOutput ErrorCode = "agent.empty_output"
|
||||||
ErrUnknownOutcome ErrorCode = "unknown_outcome"
|
ErrUnknownOutcome ErrorCode = "agent.unknown_outcome"
|
||||||
ErrInternal ErrorCode = "internal"
|
ErrInternal ErrorCode = "agent.internal"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Error 是标准化执行错误。Retryable 仅表示传输级瞬时故障可由 adapter 有界
|
// Error 是标准化执行错误。Retryable 仅表示传输级瞬时故障可由 adapter 有界
|
||||||
// 重试或调用方作为新步骤重试;与 SideEffects 独立判定。
|
// 重试或调用方作为新步骤重试;与 SideEffects 独立判定。
|
||||||
type Error struct {
|
type Error struct {
|
||||||
Code ErrorCode `json:"code"`
|
Code ErrorCode `json:"code"`
|
||||||
Reason string `json:"reason,omitempty"`
|
Reason string `json:"reason,omitempty"`
|
||||||
Retryable bool `json:"retryable,omitempty"`
|
Retryable bool `json:"retryable,omitempty"`
|
||||||
SideEffects SideEffects `json:"side_effects"`
|
SideEffects SideEffects `json:"side_effects"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Error) Error() string { return string(e.Code) + ": " + e.Reason }
|
func (e *Error) Error() string {
|
||||||
|
if e == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return string(e.Code) + ": " + e.Reason
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrorCode 返回规范码字符串。日志库用这个方法识别错误,因此 contract 不导入错误码模块。
|
||||||
|
func (e *Error) ErrorCode() string {
|
||||||
|
if e == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return string(e.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrorFields 返回可写入日志的字段。执行标识由调用方随日志字段另传,不放进本结构。
|
||||||
|
func (e *Error) ErrorFields() map[string]any {
|
||||||
|
if e == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fields := map[string]any{
|
||||||
|
"retryable": e.Retryable,
|
||||||
|
}
|
||||||
|
if e.Reason != "" {
|
||||||
|
fields["reason"] = e.Reason
|
||||||
|
}
|
||||||
|
if e.SideEffects != "" {
|
||||||
|
fields["side_effects"] = string(e.SideEffects)
|
||||||
|
}
|
||||||
|
return fields
|
||||||
|
}
|
||||||
|
|
||||||
// Usage 报告 token 用量;实现报告不了时保持零值,不猜测。
|
// Usage 报告 token 用量;实现报告不了时保持零值,不猜测。
|
||||||
type Usage struct {
|
type Usage struct {
|
||||||
@@ -205,14 +230,14 @@ type Usage struct {
|
|||||||
CompletionTokens int `json:"completion_tokens,omitempty"`
|
CompletionTokens int `json:"completion_tokens,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Result 是执行终态。Text 与 Files 依据任务可为空;Outcome 决定解释。
|
// ContractResult 是执行终态。Text 与 Files 依据任务可为空;Outcome 决定解释。
|
||||||
type Result struct {
|
type ContractResult struct {
|
||||||
Outcome Outcome `json:"outcome"`
|
Outcome Outcome `json:"outcome"`
|
||||||
// Text 为文本结果(CapText 任务必填)。
|
// Text 为文本结果(CapText 任务必填)。
|
||||||
Text string `json:"text,omitempty"`
|
Text string `json:"text,omitempty"`
|
||||||
// Files 为文件候选产物(内容字节),由调用方决定正式落盘与提交。
|
// Files 为文件候选产物(内容字节),由调用方决定正式落盘与提交。
|
||||||
Files map[string][]byte `json:"files,omitempty"`
|
Files map[string][]byte `json:"files,omitempty"`
|
||||||
// Logs 为执行日志/过程摘要(兼容现有 Result.Logs)。
|
// Logs 为执行日志/过程摘要(兼容现有 ContractResult.Logs)。
|
||||||
Logs string `json:"logs,omitempty"`
|
Logs string `json:"logs,omitempty"`
|
||||||
// Err 为 Failed/Canceled/Unknown 时的标准化错误;Succeeded 时为 nil。
|
// Err 为 Failed/Canceled/Unknown 时的标准化错误;Succeeded 时为 nil。
|
||||||
Err *Error `json:"err,omitempty"`
|
Err *Error `json:"err,omitempty"`
|
||||||
@@ -241,19 +266,19 @@ func (d BackendDescriptor) Has(c Capability) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Backend 是统一执行接口。实现必须:
|
// ContractBackend 是统一执行接口。实现必须:
|
||||||
// - 在 ctx 取消时尽快返回(Canceled 或 Unknown);
|
// - 在 ctx 取消时尽快返回(Canceled 或 Unknown);
|
||||||
// - 不持有跨执行的可变会话状态(连接池等无状态资源可复用);
|
// - 不持有跨执行的可变会话状态(连接池等无状态资源可复用);
|
||||||
// - 不自行扩大工具范围或读取 Workspace 之外的路径。
|
// - 不自行扩大工具范围或读取 Workspace 之外的路径。
|
||||||
type Backend interface {
|
type ContractBackend interface {
|
||||||
Descriptor() BackendDescriptor
|
Descriptor() BackendDescriptor
|
||||||
// Execute 执行一次授权调用。返回的 Result 永不为 nil;ctx 错误映射为
|
// Execute 执行一次授权调用。返回的 ContractResult 永不为 nil;ctx 错误映射为
|
||||||
// Outcome=Canceled/Unknown,不返回 Go error 表达业务终态。
|
// Outcome=Canceled/Unknown,不返回 Go error 表达业务终态。
|
||||||
Execute(ctx context.Context, req Request) (*Result, error)
|
Execute(ctx context.Context, req ContractRequest) (*ContractResult, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolver 按名称与必需能力解析后端。未知名必须返回错误,生产路径不得
|
// Resolver 按名称与必需能力解析后端。未知名必须返回错误,生产路径不得
|
||||||
// 回落到 Stub。
|
// 回落到 Stub。
|
||||||
type Resolver interface {
|
type Resolver interface {
|
||||||
Resolve(name string, required []Capability) (Backend, error)
|
Resolve(name string, required []Capability) (ContractBackend, error)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package exec
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestErrorDuckTypeForLogs(t *testing.T) {
|
||||||
|
e := &Error{
|
||||||
|
Code: ErrProviderUnavailable,
|
||||||
|
Reason: "down",
|
||||||
|
Retryable: true,
|
||||||
|
SideEffects: SideEffectNone,
|
||||||
|
}
|
||||||
|
if e.ErrorCode() != "agent.provider_unavailable" {
|
||||||
|
t.Fatal(e.ErrorCode())
|
||||||
|
}
|
||||||
|
fields := e.ErrorFields()
|
||||||
|
if fields["reason"] != "down" || fields["side_effects"] != "none" || fields["retryable"] != true {
|
||||||
|
t.Fatalf("%v", fields)
|
||||||
|
}
|
||||||
|
var nilErr *Error
|
||||||
|
if nilErr.ErrorCode() != "" || nilErr.ErrorFields() != nil {
|
||||||
|
t.Fatal("nil error")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package exec
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestProcessEventLegacyOmitempty:零值新字段不出现在 JSON 中,旧消费方
|
||||||
|
// 忽略未知字段时看到的遗留载荷与仅填 Type/Tool/Text/Detail 一致。
|
||||||
|
func TestProcessEventLegacyOmitempty(t *testing.T) {
|
||||||
|
legacy := ProcessEvent{Type: "tool_execution_end", Tool: "echo", Text: "ran", Detail: "{}"}
|
||||||
|
withZeros := ProcessEvent{
|
||||||
|
Type: "tool_execution_end", Tool: "echo", Text: "ran", Detail: "{}",
|
||||||
|
CallID: "", Status: "", ErrorReason: "",
|
||||||
|
}
|
||||||
|
a, err := json.Marshal(legacy)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(withZeros)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if string(a) != string(b) {
|
||||||
|
t.Fatalf("zero new fields changed JSON:\n legacy=%s\n zeros=%s", a, b)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(a), "call_id") || strings.Contains(string(a), "status") ||
|
||||||
|
strings.Contains(string(a), "error_reason") {
|
||||||
|
t.Fatalf("omitempty leaked zero fields: %s", a)
|
||||||
|
}
|
||||||
|
|
||||||
|
populated := ProcessEvent{
|
||||||
|
Type: "tool_execution_end", Tool: "echo", Text: "boom",
|
||||||
|
CallID: "c1", Status: "error", ErrorReason: "boom",
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(populated)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s := string(raw)
|
||||||
|
for _, want := range []string{`"call_id":"c1"`, `"status":"error"`, `"error_reason":"boom"`} {
|
||||||
|
if !strings.Contains(s, want) {
|
||||||
|
t.Fatalf("missing %s in %s", want, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package exec_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"agent-native/exec"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestResultZeroValueKeepsLegacyMeaning pins backward compatibility:
|
||||||
|
// empty SideEffects reads as possible; Canceled/Unknown stay false.
|
||||||
|
func TestResultZeroValueKeepsLegacyMeaning(t *testing.T) {
|
||||||
|
var r exec.Result
|
||||||
|
if r.Success || r.Retryable || r.Canceled || r.Unknown {
|
||||||
|
t.Fatalf("zero Result must keep legacy false flags, got %+v", r)
|
||||||
|
}
|
||||||
|
if r.SideEffects != "" {
|
||||||
|
t.Fatalf("zero SideEffects must be empty (legacy), got %q", r.SideEffects)
|
||||||
|
}
|
||||||
|
if got := r.EffectiveSideEffects(); got != exec.SideEffectPossible {
|
||||||
|
t.Fatalf("EffectiveSideEffects() = %q, want %q", got, exec.SideEffectPossible)
|
||||||
|
}
|
||||||
|
r.SideEffects = exec.SideEffectNone
|
||||||
|
if got := r.EffectiveSideEffects(); got != exec.SideEffectNone {
|
||||||
|
t.Fatalf("explicit none: EffectiveSideEffects() = %q", got)
|
||||||
|
}
|
||||||
|
r.SideEffects = exec.SideEffectPossible
|
||||||
|
if got := r.EffectiveSideEffects(); got != exec.SideEffectPossible {
|
||||||
|
t.Fatalf("explicit possible: EffectiveSideEffects() = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -160,6 +160,7 @@ func combinedFailureText(sig StepFailureSignals) string {
|
|||||||
func isPermanentReason(lower string) bool {
|
func isPermanentReason(lower string) bool {
|
||||||
return strings.Contains(lower, "prompt_empty") ||
|
return strings.Contains(lower, "prompt_empty") ||
|
||||||
strings.Contains(lower, "unauthorized") ||
|
strings.Contains(lower, "unauthorized") ||
|
||||||
|
strings.Contains(lower, "budget_exceeded") ||
|
||||||
strings.Contains(lower, "治理强制终止") ||
|
strings.Contains(lower, "治理强制终止") ||
|
||||||
strings.Contains(lower, "治理拦截")
|
strings.Contains(lower, "治理拦截")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package exec
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAssessStepFailureBudgetExceededIsPermanent(t *testing.T) {
|
||||||
|
ass := AssessStepFailure(StepFailureSignals{
|
||||||
|
Err: errors.New("budget_exceeded: tool calls 12 > max 10"),
|
||||||
|
})
|
||||||
|
if ass.Class != FailurePermanent {
|
||||||
|
t.Fatalf("budget exhaustion must classify permanent (no auto retry), got %s", ass.Class)
|
||||||
|
}
|
||||||
|
if !strings.Contains(ass.Reason, "permanent") {
|
||||||
|
t.Fatalf("want permanent reason, got %s", ass.Reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAssessStepFailureDeadlinePastIsPermanent(t *testing.T) {
|
||||||
|
ass := AssessStepFailure(StepFailureSignals{
|
||||||
|
Result: &Result{ErrorReason: "budget_exceeded: deadline 2026-10-01T00:00:00Z passed"},
|
||||||
|
})
|
||||||
|
if ass.Class != FailurePermanent {
|
||||||
|
t.Fatalf("deadline exhaustion must classify permanent, got %s", ass.Class)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBudgetZeroValueUnlimited(t *testing.T) {
|
||||||
|
var b Budget
|
||||||
|
if b.MaxPromptTokens != 0 || b.MaxCompletionTokens != 0 || b.MaxToolCalls != 0 || !b.Deadline.IsZero() {
|
||||||
|
t.Fatal("zero budget must express unlimited")
|
||||||
|
}
|
||||||
|
_ = time.Now
|
||||||
|
}
|
||||||
@@ -1,3 +1,12 @@
|
|||||||
module agent-native
|
module agent-native
|
||||||
|
|
||||||
go 1.27
|
go 1.27
|
||||||
|
|
||||||
|
require (
|
||||||
|
error-code-system v0.0.0
|
||||||
|
logging-system v0.0.0
|
||||||
|
)
|
||||||
|
|
||||||
|
replace error-code-system => ./deps/error-code-system
|
||||||
|
|
||||||
|
replace logging-system => ./deps/logging-system
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# 通用规范采纳记录
|
||||||
|
|
||||||
|
- 项目/负责人:Agent-Native / weichen
|
||||||
|
- 状态:active
|
||||||
|
- 规范位置:`deps/development-guidelines`
|
||||||
|
- 规范性文档目录:`policy/`
|
||||||
|
- 过程文档位置:任务卡在 `.task/`(模板 `.task/templates/task-card.md`);过程记录在 `docs/records/`(项目内固定目录豁免。当前为个人维护的基础库,单独过程仓会让每份记录都变成跨仓 gitlink;任务主记录在 PR 描述中。豁免一经登记,过程记录不得再写入 `policy/` 或规范库。)
|
||||||
|
- 版本权威:本仓 gitlink `deps/development-guidelines` @ `2688fd2be6a5`(完整 `2688fd2be6a52c0bf110e7e6366b6f291ab5e4eb`,内容版本 `0.1.0-draft.12`);可读标签:无
|
||||||
|
- 批准/生效依据:`feature/governance-adoption PR(Agent-Native)`;合入前须在 `.task/feature-governance-adoption.md` 记录正负例、Review 与 CI 证据
|
||||||
|
- 替代范围和迁移说明:无替代项。既有 `AGENTS.md`、`PROJECT.md` 内容保留,本次只向二者追加内容(「Agent 执行与新项目准入(强制)」段与验证命令);`AGENT-EXECUTION.md` 非既有文件,由本次采纳新增(治理引导提交 b1034e1)。新增共享执行器入口 `scripts/dm`、`scripts/agent-exec`、`scripts/task`,`scripts/hooks/pre-commit`(待 host 启用 `core.hooksPath`)与 `.gitea/workflows/governance.yml`。
|
||||||
|
|
||||||
|
| 规范/工作流 | 适用条件与范围 | 采纳或不适用理由 | 执行方式/证据 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| core/baseline.md | 日常工作 | 所有改动都要守硬边界 | manual |
|
||||||
|
| workflows/delivery.md、debugging.md、handoff.md | 交付、排障与交接 | 单人维护仍需要可交接的任务记录(`.task/` 任务卡 + PR 主记录) | manual |
|
||||||
|
| standards/collaboration.md | 并行共同起点、节点同步与陈旧候选拒绝 | 本仓会被 Agent-Squad 以 gitlink 消费 | manual |
|
||||||
|
| standards/merge-queue.md | 多个就绪任务的最终同步、CI、Review 与合入调度 | 不适用:个人维护的单仓,合入由仓库所有者串行完成,无多任务就绪队列 | 不适用 |
|
||||||
|
| standards/verification.md | 验证与结论 | 仓库根 `go vet` / `go test`;治理与执行配置命令见 PROJECT.md「验证」节 | manual;命令见 PROJECT.md |
|
||||||
|
| workflows/design-verification.md、standards/testability.md、standards/failure-containment.md | 契约、适配器与故障分类 | 执行器失败必须可定位且不扩大工具范围 | manual |
|
||||||
|
| standards/module-contracts.md | contract/exec/runtime/adapters 边界 | 公开端口已按层拆分 | manual |
|
||||||
|
| standards/observability.md | 错误码、过程事件 | 与 Error-Code-System / Logging-System 对齐 | manual |
|
||||||
|
| standards/agent-execution.md | 工程 Agent CLI 执行路由、会话隔离与受控集成;模型能力协商与回退 | 本仓就是执行契约的权威。AI 实施单元只经 `AGENT-EXECUTION.md` 登记的候选由 `scripts/agent-exec run` 执行,并经 `scripts/agent-exec apply` 集成,禁止 host 代写 | `AGENT-EXECUTION.md` + `scripts/agent-exec`(validate/admit);证据:`governance check + 正负例(.task/feature-governance-adoption.md)` |
|
||||||
|
| standards/multi-repo.md | submodule 与固定版本组合 | 规范与基础库以 gitlink 锁定 | manual |
|
||||||
|
| standards/security.md | 路径授权、工具范围、凭证 | 凭证不入库;路径授权不等于 OS 沙箱 | manual |
|
||||||
|
| standards/documentation.md | 三层文档 | `policy/` 与 `docs/records/` 已分开 | manual |
|
||||||
|
| standards/data-reliability.md | 执行副作用与重放 | 无本仓数据库;适用副作用分类,不扩展成存储设计 | manual |
|
||||||
|
| standards/configuration.md | 调用方注入的模型与工具配置 | 本仓不自带多实例部署配置 | manual |
|
||||||
|
| standards/tool-adaptation.md | 为工具创建规则/钩子、接入新客户端 | 适用于 `scripts/hooks/pre-commit`(git 钩子,待 host 启用);本仓不新增工具规则或客户端接入 | manual |
|
||||||
|
| standards/release-operations.md | 独立发布流水线 | 当前无制品仓库与部署入口 | 不适用 |
|
||||||
|
| workflows/automation.md | 定时与后台任务 | 本仓无 daemon | 不适用 |
|
||||||
|
|
||||||
|
## 项目差异
|
||||||
|
|
||||||
|
| 原规则/章节 | 项目采用方式 | 原因与风险 | 负责人/复查条件 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| documentation.md 过程仓默认独立仓库 | 过程记录放 `docs/records/` | 个人基础库;风险是记录与代码同 PR 时容易把证据写成第二权威。任务状态仍只认 Gitea Issue/PR | 出现第二名维护者或对外发布流程时复查 |
|
||||||
|
|
||||||
|
默认做法差异说明理由;硬边界冲突须有明确有效决策,不能用此表自授权。建议不采用无需登记。
|
||||||
|
|
||||||
|
## 本次升级或首次接入验证
|
||||||
|
|
||||||
|
### 2026-09-29 首次接入(历史记录,版本号为当时值)
|
||||||
|
|
||||||
|
- 入口:根 `AGENTS.md`、`PROJECT.md` 指向 `deps/development-guidelines`。
|
||||||
|
- 子模块:`deps/development-guidelines` 固定 `26b562d2e34fbc560711296a309c91c74ffc55b1`。
|
||||||
|
- `.gitmodules` 使用批准的上游 SSH URL,不含本机路径或凭证。
|
||||||
|
- 执行方式保持 manual:挂载 Markdown 不自动改变 CI。
|
||||||
|
- 回退:恢复本提交之前的 gitlink,并删除根入口与 `policy/guidelines-adoption.md` 中对应条款;无已生效的 CI 或权限变更需要另恢复。
|
||||||
|
|
||||||
|
### 2026-09-29 追加:agent-intent 可选意图闸门
|
||||||
|
|
||||||
|
- 新增 `deps/agent-intent`(固定 `55780ae2cce4ae98d8b0557b8234af8833a7c522`),`deps/error-code-system` 前进到 `dbeed2f1b67eb417ce089e7033a228578fbbeb03`(追加 `squad.intent_refused`,兼容性变更无)。
|
||||||
|
- 依赖边界新增:唯 `adapters/intentgate` 可 import agent-intent;不使用该包时核心与 agent-intent 无编译关系。
|
||||||
|
- 接入与验证记录见 [docs/records/2026-09-29-intentgate-adoption.md](../docs/records/2026-09-29-intentgate-adoption.md)。
|
||||||
|
- 回退:删除 `adapters/intentgate/`、go.mod 的 agent-intent require/replace、`.gitmodules` 对应条目与 submodule,`scripts/submodule-consistency.sh` 移除该路径。
|
||||||
|
|
||||||
|
### 2026-10-17 治理套件采纳(feature/governance-adoption)
|
||||||
|
|
||||||
|
- 入口:根 `AGENTS.md`、`PROJECT.md`、`AGENT-EXECUTION.md` 指向 `deps/development-guidelines`;相对链接须可解析。
|
||||||
|
- 固定版本:`deps/development-guidelines` 由 `26b562d2` 前进到 `2688fd2be6a52c0bf110e7e6366b6f291ab5e4eb`;`deps/development-manager` 固定 `bd3a7de57a3a9e1a4495b94c5fa5c08d71e7be8c`(`.gitmodules` 条目与 gitlink 由执行器记录)。
|
||||||
|
- 共享执行器:`scripts/dm`(读取 `deps/development-manager` 钉住提交)、`scripts/agent-exec`、`scripts/task` 为薄入口;`.task/templates/task-card.md` 为任务卡模板。
|
||||||
|
- CI 门禁:`.gitea/workflows/governance.yml` 执行 `scripts/agent-exec validate --repo .` 与 `scripts/dm governance check --repo .`;除 agent-execution 外的专题保持 manual。
|
||||||
|
- 检查:本文件无尖括号占位符;暂存副本内 `scripts/dm governance check --repo .` 与 `scripts/agent-exec validate --repo .` 的输出,以及正负例、Review 与 CI 证据,合入前在 `.task/feature-governance-adoption.md` 补齐(当前为 NOT_RUN)。
|
||||||
|
- 回退:把 `deps/development-guidelines` 恢复到 `26b562d2e34fbc560711296a309c91c74ffc55b1`,删除 `deps/development-manager` 条目,删除 `scripts/dm`、`scripts/agent-exec`、`scripts/task`、`scripts/hooks/`、`.gitea/workflows/governance.yml` 与 `.task/templates/task-card.md`,并从 `AGENTS.md` 删除「Agent 执行与新项目准入(强制)」段。
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package rpc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client is the Go SDK for the JSONL RPC mode.
|
||||||
|
type Client struct {
|
||||||
|
in io.Writer
|
||||||
|
out *bufio.Reader
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClient wraps a bidirectional pipe. Commands are one JSON object per line.
|
||||||
|
func NewClient(in io.Writer, out io.Reader) *Client {
|
||||||
|
return &Client{in: in, out: bufio.NewReader(out)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call sends one command and reads the matching response.
|
||||||
|
func (c *Client) Call(ctx context.Context, req Request) (Response, error) {
|
||||||
|
raw, err := json.Marshal(req)
|
||||||
|
if err != nil {
|
||||||
|
return Response{}, err
|
||||||
|
}
|
||||||
|
if _, err := c.in.Write(append(raw, '\n')); err != nil {
|
||||||
|
return Response{}, err
|
||||||
|
}
|
||||||
|
type result struct {
|
||||||
|
resp Response
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
ch := make(chan result, 1)
|
||||||
|
go func() {
|
||||||
|
line, err := c.out.ReadBytes('\n')
|
||||||
|
if err != nil {
|
||||||
|
ch <- result{err: err}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var resp Response
|
||||||
|
if err := json.Unmarshal(line, &resp); err != nil {
|
||||||
|
ch <- result{err: err}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ch <- result{resp: resp}
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return Response{}, ctx.Err()
|
||||||
|
case got := <-ch:
|
||||||
|
return got.resp, got.err
|
||||||
|
}
|
||||||
|
}
|
||||||
+213
@@ -0,0 +1,213 @@
|
|||||||
|
package rpc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"agent-native/runtime"
|
||||||
|
"agent-native/runtime/llm"
|
||||||
|
"agent-native/runtime/session"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Request is one RPC command. Type matches the pi headless command set.
|
||||||
|
type Request struct {
|
||||||
|
ID string `json:"id,omitempty"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
Model string `json:"model,omitempty"`
|
||||||
|
ThinkingLevel string `json:"thinkingLevel,omitempty"`
|
||||||
|
Path string `json:"path,omitempty"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Leaf string `json:"leaf,omitempty"`
|
||||||
|
Keep int `json:"keep,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response is the synchronous result of one command.
|
||||||
|
type Response struct {
|
||||||
|
ID string `json:"id,omitempty"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
Result any `json:"result,omitempty"`
|
||||||
|
// Status 是命令回执:started 已接手,queued 先排队,handled 已处理完。
|
||||||
|
Status string `json:"status,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server serves RPC commands against one agent session and its transcript.
|
||||||
|
type Server struct {
|
||||||
|
Agent *runtime.AgentSession
|
||||||
|
Store *session.Store
|
||||||
|
NoStore bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle runs one command and returns the JSON response line.
|
||||||
|
func (s *Server) Handle(ctx context.Context, line []byte) ([]byte, error) {
|
||||||
|
var req Request
|
||||||
|
if err := json.Unmarshal(line, &req); err != nil {
|
||||||
|
return encode(Response{Type: "response", OK: false, Error: err.Error()})
|
||||||
|
}
|
||||||
|
resp := s.dispatch(ctx, req)
|
||||||
|
resp.ID = req.ID
|
||||||
|
if resp.Type == "" {
|
||||||
|
resp.Type = "response"
|
||||||
|
}
|
||||||
|
return encode(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) dispatch(ctx context.Context, req Request) Response {
|
||||||
|
switch req.Type {
|
||||||
|
case "prompt":
|
||||||
|
if s.Agent == nil {
|
||||||
|
return fail("no agent")
|
||||||
|
}
|
||||||
|
msg := llm.Message{Role: llm.RoleUser, Content: req.Message}
|
||||||
|
if s.Agent.Busy() {
|
||||||
|
s.Agent.FollowUp(msg)
|
||||||
|
return Response{OK: true, Status: "queued"}
|
||||||
|
}
|
||||||
|
res, err := s.Agent.Prompt(ctx, msg)
|
||||||
|
if err != nil {
|
||||||
|
return fail(err.Error())
|
||||||
|
}
|
||||||
|
if res.Err != nil {
|
||||||
|
return fail(res.Err.Error())
|
||||||
|
}
|
||||||
|
return Response{OK: true, Text: res.Text, Status: "handled"}
|
||||||
|
case "steer":
|
||||||
|
if s.Agent == nil {
|
||||||
|
return fail("no agent")
|
||||||
|
}
|
||||||
|
s.Agent.Steer(llm.Message{Role: llm.RoleUser, Content: req.Message})
|
||||||
|
return Response{OK: true, Status: queueStatus(s.Agent.Busy())}
|
||||||
|
case "follow_up":
|
||||||
|
if s.Agent == nil {
|
||||||
|
return fail("no agent")
|
||||||
|
}
|
||||||
|
s.Agent.FollowUp(llm.Message{Role: llm.RoleUser, Content: req.Message})
|
||||||
|
return Response{OK: true, Status: queueStatus(s.Agent.Busy())}
|
||||||
|
case "abort":
|
||||||
|
s.Agent.Abort()
|
||||||
|
return Response{OK: true}
|
||||||
|
case "clear_queue":
|
||||||
|
s.Agent.ClearQueue()
|
||||||
|
return Response{OK: true}
|
||||||
|
case "set_model":
|
||||||
|
if err := s.Agent.SetModel(req.Model, req.ThinkingLevel); err != nil {
|
||||||
|
return fail(err.Error())
|
||||||
|
}
|
||||||
|
return Response{OK: true}
|
||||||
|
case "compact":
|
||||||
|
keep := req.Keep
|
||||||
|
if keep <= 0 {
|
||||||
|
keep = 4
|
||||||
|
}
|
||||||
|
done, err := s.Agent.Compact(keep, func(messages []llm.Message) (string, error) {
|
||||||
|
return fmt.Sprintf("%d messages", len(messages)), nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fail(err.Error())
|
||||||
|
}
|
||||||
|
return Response{OK: true, Result: done}
|
||||||
|
case "get_state":
|
||||||
|
leaf, _ := s.Store.Leaf()
|
||||||
|
return Response{OK: true, Result: map[string]any{"leaf": leaf, "name": s.Store.Name(), "path": s.Store.Path()}}
|
||||||
|
case "get_tree":
|
||||||
|
entries, err := s.Store.Entries()
|
||||||
|
if err != nil {
|
||||||
|
return fail(err.Error())
|
||||||
|
}
|
||||||
|
return Response{OK: true, Result: entries}
|
||||||
|
case "get_entries":
|
||||||
|
entries, err := s.Store.Entries()
|
||||||
|
if err != nil {
|
||||||
|
return fail(err.Error())
|
||||||
|
}
|
||||||
|
return Response{OK: true, Result: entries}
|
||||||
|
case "get_last_assistant_text":
|
||||||
|
msgs, err := s.Store.Load()
|
||||||
|
if err != nil {
|
||||||
|
return fail(err.Error())
|
||||||
|
}
|
||||||
|
text := ""
|
||||||
|
for _, m := range msgs {
|
||||||
|
if m.Role == llm.RoleAssistant {
|
||||||
|
text = m.Content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Response{OK: true, Text: text}
|
||||||
|
case "set_session_name":
|
||||||
|
if err := s.Store.SetName(req.Name); err != nil {
|
||||||
|
return fail(err.Error())
|
||||||
|
}
|
||||||
|
return Response{OK: true}
|
||||||
|
case "fork":
|
||||||
|
dest := req.Path
|
||||||
|
if dest == "" {
|
||||||
|
return fail("path is required")
|
||||||
|
}
|
||||||
|
if _, err := s.Store.Fork(dest, req.Leaf); err != nil {
|
||||||
|
return fail(err.Error())
|
||||||
|
}
|
||||||
|
return Response{OK: true, Text: dest}
|
||||||
|
case "clone":
|
||||||
|
if req.Path == "" {
|
||||||
|
return fail("path is required")
|
||||||
|
}
|
||||||
|
if _, err := s.Store.Clone(req.Path); err != nil {
|
||||||
|
return fail(err.Error())
|
||||||
|
}
|
||||||
|
return Response{OK: true, Text: req.Path}
|
||||||
|
case "switch_session":
|
||||||
|
if req.Path == "" {
|
||||||
|
return fail("path is required")
|
||||||
|
}
|
||||||
|
st, err := session.Open(req.Path)
|
||||||
|
if err != nil {
|
||||||
|
return fail(err.Error())
|
||||||
|
}
|
||||||
|
s.Store = st
|
||||||
|
return Response{OK: true}
|
||||||
|
case "new_session":
|
||||||
|
if req.Path == "" {
|
||||||
|
return fail("path is required")
|
||||||
|
}
|
||||||
|
st, err := session.Open(req.Path)
|
||||||
|
if err != nil {
|
||||||
|
return fail(err.Error())
|
||||||
|
}
|
||||||
|
s.Store = st
|
||||||
|
return Response{OK: true, Text: req.Path}
|
||||||
|
case "export_html":
|
||||||
|
if req.Path == "" {
|
||||||
|
return fail("path is required")
|
||||||
|
}
|
||||||
|
if err := s.Store.ExportHTML(req.Path); err != nil {
|
||||||
|
return fail(err.Error())
|
||||||
|
}
|
||||||
|
return Response{OK: true, Text: req.Path}
|
||||||
|
case "get_session_stats":
|
||||||
|
st := s.Store.Stats()
|
||||||
|
return Response{OK: true, Result: st}
|
||||||
|
default:
|
||||||
|
return fail("unknown command " + req.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fail(msg string) Response { return Response{OK: false, Error: msg} }
|
||||||
|
|
||||||
|
func queueStatus(busy bool) string {
|
||||||
|
if busy {
|
||||||
|
return "queued"
|
||||||
|
}
|
||||||
|
return "started"
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(resp Response) ([]byte, error) {
|
||||||
|
raw, err := json.Marshal(resp)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return append(raw, '\n'), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package rpc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"agent-native/runtime"
|
||||||
|
"agent-native/runtime/llm"
|
||||||
|
"agent-native/runtime/session"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRPCSessionCommandsAndClient(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
store, err := session.Open(filepath.Join(dir, "s.jsonl"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := store.Append(llm.Message{Role: llm.RoleAssistant, Content: "ready"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
agent := runtime.NewAgentSession(runtime.Config{
|
||||||
|
Model: "fake",
|
||||||
|
Stream: 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: "echo", StopReason: llm.StopEnd}}
|
||||||
|
close(ch)
|
||||||
|
return ch
|
||||||
|
},
|
||||||
|
})
|
||||||
|
srv := &Server{Agent: agent, Store: store}
|
||||||
|
clientIn, serverOut := io.Pipe()
|
||||||
|
serverIn, clientOut := io.Pipe()
|
||||||
|
go func() {
|
||||||
|
defer serverOut.Close()
|
||||||
|
buf := make([]byte, 4096)
|
||||||
|
for {
|
||||||
|
n, err := serverIn.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
resp, herr := srv.Handle(context.Background(), buf[:n])
|
||||||
|
if herr == nil {
|
||||||
|
_, _ = serverOut.Write(resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
client := NewClient(clientOut, clientIn)
|
||||||
|
resp, err := client.Call(context.Background(), Request{ID: "1", Type: "get_last_assistant_text"})
|
||||||
|
if err != nil || !resp.OK || resp.Text != "ready" {
|
||||||
|
t.Fatalf("%+v %v", resp, err)
|
||||||
|
}
|
||||||
|
fork := filepath.Join(dir, "fork.jsonl")
|
||||||
|
resp, err = client.Call(context.Background(), Request{Type: "fork", Path: fork})
|
||||||
|
if err != nil || !resp.OK {
|
||||||
|
t.Fatalf("fork %+v %v", resp, err)
|
||||||
|
}
|
||||||
|
resp, err = client.Call(context.Background(), Request{Type: "prompt", Message: "hi"})
|
||||||
|
if err != nil || !resp.OK || resp.Text != "echo" || resp.Status != "handled" {
|
||||||
|
t.Fatalf("prompt %+v %v", resp, err)
|
||||||
|
}
|
||||||
|
resp, err = client.Call(context.Background(), Request{Type: "steer", Message: "later"})
|
||||||
|
if err != nil || !resp.OK || resp.Status != "started" {
|
||||||
|
t.Fatalf("steer %+v %v", resp, err)
|
||||||
|
}
|
||||||
|
htmlPath := filepath.Join(dir, "out.html")
|
||||||
|
resp, err = client.Call(context.Background(), Request{Type: "export_html", Path: htmlPath})
|
||||||
|
if err != nil || !resp.OK {
|
||||||
|
t.Fatalf("export %+v %v", resp, err)
|
||||||
|
}
|
||||||
|
_ = clientOut.Close()
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package runtime
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"agent-native/runtime/hooks"
|
||||||
|
"agent-native/runtime/llm"
|
||||||
|
"agent-native/runtime/tools"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHookAdaptersMatchDirectCallbacks(t *testing.T) {
|
||||||
|
direct := runGate(t, false)
|
||||||
|
adapted := runGate(t, true)
|
||||||
|
if direct.stop != adapted.stop || direct.text != adapted.text {
|
||||||
|
t.Fatalf("stop/text direct=%s %q adapted=%s %q", direct.stop, direct.text, adapted.stop, adapted.text)
|
||||||
|
}
|
||||||
|
if direct.denies != adapted.denies || direct.reminders != adapted.reminders || direct.errors != adapted.errors {
|
||||||
|
t.Fatalf("counts direct=%+v adapted=%+v", direct, adapted)
|
||||||
|
}
|
||||||
|
if direct.denies != 1 || direct.reminders == 0 {
|
||||||
|
t.Fatalf("unexpected counts %+v", direct)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type gateStats struct {
|
||||||
|
stop, text string
|
||||||
|
denies, reminders, errors int
|
||||||
|
}
|
||||||
|
|
||||||
|
func runGate(t *testing.T, viaHooks bool) gateStats {
|
||||||
|
t.Helper()
|
||||||
|
var stats gateStats
|
||||||
|
before := func(call llm.ToolCall) error {
|
||||||
|
if call.Name == "read_file" {
|
||||||
|
stats.denies++
|
||||||
|
return errors.New("denied")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
reminders := func(int) []string {
|
||||||
|
stats.reminders++
|
||||||
|
return []string{"记一笔"}
|
||||||
|
}
|
||||||
|
stop := func(last llm.Message, info StopInfo) StopDecision {
|
||||||
|
if info.Continuations < 1 {
|
||||||
|
return StopDecision{Continue: true, Reason: "再看一眼"}
|
||||||
|
}
|
||||||
|
return StopDecision{}
|
||||||
|
}
|
||||||
|
cfg := Config{
|
||||||
|
Model: "fake",
|
||||||
|
Tools: tools.WorkspaceTools(t.TempDir()),
|
||||||
|
Stream: script(
|
||||||
|
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
|
||||||
|
ID: "c1", Name: "read_file", Arguments: `{"path":"a.md"}`,
|
||||||
|
}}},
|
||||||
|
llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd},
|
||||||
|
),
|
||||||
|
Reminders: reminders,
|
||||||
|
}
|
||||||
|
if viaHooks {
|
||||||
|
bus := hooks.New()
|
||||||
|
bus.On(hooks.AdaptBefore(func(name, args string) error {
|
||||||
|
return before(llm.ToolCall{Name: name, Arguments: args})
|
||||||
|
}))
|
||||||
|
bus.On(func(c hooks.Call) hooks.Outcome {
|
||||||
|
if c.Event != hooks.Stop {
|
||||||
|
return hooks.Outcome{}
|
||||||
|
}
|
||||||
|
d := stop(llm.Message{Content: c.LastText}, StopInfo{Continuations: c.Continuations, ToolCalls: c.ToolCalls})
|
||||||
|
if d.Continue {
|
||||||
|
return hooks.Outcome{Decision: hooks.Block, Reason: d.Reason, Replacement: d.Replacement, Notice: d.Notice}
|
||||||
|
}
|
||||||
|
return hooks.Outcome{Decision: hooks.Allow, Notice: d.Notice}
|
||||||
|
})
|
||||||
|
cfg.Hooks = bus
|
||||||
|
} else {
|
||||||
|
cfg.BeforeTool = before
|
||||||
|
cfg.Stop = stop
|
||||||
|
}
|
||||||
|
res, err := Run(context.Background(), cfg, llm.Message{Role: llm.RoleUser, Content: "读文件"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
stats.stop = string(res.StopReason)
|
||||||
|
stats.text = res.Text
|
||||||
|
for _, m := range res.Messages {
|
||||||
|
if m.Role == llm.RoleTool && m.IsError {
|
||||||
|
stats.errors++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return stats
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
// Package agentspec 定义子代理画像(AgentSpec)与 agent-*.md 文件形态。
|
||||||
|
// 宿主与内置画像可声明全部字段;插件来源的画像只允许身份与工具面,
|
||||||
|
// 不得声明钩子、MCP 与权限模式(fail-closed,错误定位到字段)。
|
||||||
|
package agentspec
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AgentSpec 是一个子代理画像。字段对齐公开子代理 frontmatter 约定,
|
||||||
|
// 另加 outputSchema。空字段表示沿用宿主内置。
|
||||||
|
type AgentSpec struct {
|
||||||
|
Name string
|
||||||
|
Description string
|
||||||
|
Prompt string // 正文(frontmatter 之后的内容)
|
||||||
|
Tools []string
|
||||||
|
DisallowedTools []string
|
||||||
|
Model string
|
||||||
|
PermissionMode string
|
||||||
|
Skills []string
|
||||||
|
MaxTurns int
|
||||||
|
MemoryScope string
|
||||||
|
Effort string
|
||||||
|
Background bool
|
||||||
|
WorktreeIsolation bool
|
||||||
|
OutputSchema string
|
||||||
|
}
|
||||||
|
|
||||||
|
// pluginBannedKeys 是插件来源画像禁带的键:钩子、MCP 与权限模式归宿主与基座。
|
||||||
|
var pluginBannedKeys = map[string]bool{
|
||||||
|
"hooks": true,
|
||||||
|
"mcp": true,
|
||||||
|
"permissionMode": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse 解析宿主/内置画像文件:frontmatter(--- 包裹的 key: value 行,
|
||||||
|
// 列表值用逗号分隔)+ 正文。未知键报错;name 必填且须小写连字符。
|
||||||
|
func Parse(content string) (AgentSpec, error) {
|
||||||
|
return parse(content, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParsePlugin 解析插件来源画像文件:在 Parse 之上禁带 hooks/mcp/permissionMode 键。
|
||||||
|
func ParsePlugin(content string) (AgentSpec, error) {
|
||||||
|
return parse(content, pluginBannedKeys)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parse(content string, banned map[string]bool) (AgentSpec, error) {
|
||||||
|
fm, body, err := splitFrontmatter(content)
|
||||||
|
if err != nil {
|
||||||
|
return AgentSpec{}, err
|
||||||
|
}
|
||||||
|
var spec AgentSpec
|
||||||
|
for key, value := range fm {
|
||||||
|
if banned[key] {
|
||||||
|
return AgentSpec{}, fmt.Errorf("agentspec: 插件画像不得声明 %s", key)
|
||||||
|
}
|
||||||
|
if err := assign(&spec, key, value); err != nil {
|
||||||
|
return AgentSpec{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
spec.Prompt = strings.TrimSpace(body)
|
||||||
|
if spec.Name == "" {
|
||||||
|
return AgentSpec{}, fmt.Errorf("agentspec: name required")
|
||||||
|
}
|
||||||
|
if !validName(spec.Name) {
|
||||||
|
return AgentSpec{}, fmt.Errorf("agentspec: name %q 须为小写连字符(如 doc-reviewer)", spec.Name)
|
||||||
|
}
|
||||||
|
return spec, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadDir 整批加载目录下的 *.md 画像:任一文件解析失败则全批拒绝;
|
||||||
|
// 重名拒绝。parser 按来源选 Parse 或 ParsePlugin。返回按 Name 索引。
|
||||||
|
func LoadDir(dir string, parser func(string) (AgentSpec, error)) (map[string]AgentSpec, error) {
|
||||||
|
if parser == nil {
|
||||||
|
parser = Parse
|
||||||
|
}
|
||||||
|
names, err := filepath.Glob(filepath.Join(dir, "*.md"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("agentspec: glob %s: %w", dir, err)
|
||||||
|
}
|
||||||
|
out := map[string]AgentSpec{}
|
||||||
|
for _, name := range names {
|
||||||
|
raw, err := os.ReadFile(name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("agentspec: %s: %w", filepath.Base(name), err)
|
||||||
|
}
|
||||||
|
spec, err := parser(string(raw))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("agentspec: %s: %w", filepath.Base(name), err)
|
||||||
|
}
|
||||||
|
if _, dup := out[spec.Name]; dup {
|
||||||
|
return nil, fmt.Errorf("agentspec: duplicate name %q in %s", spec.Name, dir)
|
||||||
|
}
|
||||||
|
out[spec.Name] = spec
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitFrontmatter(content string) (map[string]string, string, error) {
|
||||||
|
text := strings.ReplaceAll(content, "\r\n", "\n")
|
||||||
|
if !strings.HasPrefix(text, "---\n") {
|
||||||
|
return nil, "", fmt.Errorf("agentspec: missing frontmatter(须以 --- 开头)")
|
||||||
|
}
|
||||||
|
rest := text[len("---\n"):]
|
||||||
|
end := strings.Index(rest, "\n---")
|
||||||
|
if end < 0 {
|
||||||
|
return nil, "", fmt.Errorf("agentspec: frontmatter 未闭合(缺 --- 结尾)")
|
||||||
|
}
|
||||||
|
fm := map[string]string{}
|
||||||
|
for _, line := range strings.Split(rest[:end], "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
i := strings.Index(line, ":")
|
||||||
|
if i <= 0 {
|
||||||
|
return nil, "", fmt.Errorf("agentspec: 无法解析的行 %q(须为 key: value)", line)
|
||||||
|
}
|
||||||
|
fm[strings.TrimSpace(line[:i])] = strings.TrimSpace(line[i+1:])
|
||||||
|
}
|
||||||
|
body := rest[end+len("\n---"):]
|
||||||
|
body = strings.TrimLeft(body, "\n")
|
||||||
|
return fm, body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func assign(spec *AgentSpec, key, value string) error {
|
||||||
|
switch key {
|
||||||
|
case "name":
|
||||||
|
spec.Name = value
|
||||||
|
case "description":
|
||||||
|
spec.Description = value
|
||||||
|
case "tools":
|
||||||
|
spec.Tools = splitList(value)
|
||||||
|
case "disallowedTools":
|
||||||
|
spec.DisallowedTools = splitList(value)
|
||||||
|
case "model":
|
||||||
|
spec.Model = value
|
||||||
|
case "permissionMode":
|
||||||
|
spec.PermissionMode = value
|
||||||
|
case "skills":
|
||||||
|
spec.Skills = splitList(value)
|
||||||
|
case "maxTurns":
|
||||||
|
n, err := strconv.Atoi(value)
|
||||||
|
if err != nil || n < 0 {
|
||||||
|
return fmt.Errorf("agentspec: maxTurns %q 须为非负整数", value)
|
||||||
|
}
|
||||||
|
spec.MaxTurns = n
|
||||||
|
case "memoryScope":
|
||||||
|
spec.MemoryScope = value
|
||||||
|
case "effort":
|
||||||
|
spec.Effort = value
|
||||||
|
case "background":
|
||||||
|
b, err := parseBool(key, value)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
spec.Background = b
|
||||||
|
case "worktreeIsolation":
|
||||||
|
b, err := parseBool(key, value)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
spec.WorktreeIsolation = b
|
||||||
|
case "outputSchema":
|
||||||
|
spec.OutputSchema = value
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("agentspec: 未知键 %q", key)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseBool(key, value string) (bool, error) {
|
||||||
|
switch value {
|
||||||
|
case "true":
|
||||||
|
return true, nil
|
||||||
|
case "false":
|
||||||
|
return false, nil
|
||||||
|
default:
|
||||||
|
return false, fmt.Errorf("agentspec: %s %q 须为 true/false", key, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitList(value string) []string {
|
||||||
|
if strings.TrimSpace(value) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
parts := strings.Split(value, ",")
|
||||||
|
out := make([]string, 0, len(parts))
|
||||||
|
for _, p := range parts {
|
||||||
|
if s := strings.TrimSpace(p); s != "" {
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func validName(name string) bool {
|
||||||
|
if name == "" || strings.HasPrefix(name, "-") || strings.HasSuffix(name, "-") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, seg := range strings.Split(name, "-") {
|
||||||
|
if seg == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, r := range seg {
|
||||||
|
if (r < 'a' || r > 'z') && (r < '0' || r > '9') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package agentspec
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
const goodSpec = `---
|
||||||
|
name: doc-reviewer
|
||||||
|
description: 评审体系文档章节
|
||||||
|
tools: read_file, grep, write_file
|
||||||
|
disallowedTools: run_shell
|
||||||
|
model: lightweight
|
||||||
|
skills: house-style, rdm-review
|
||||||
|
maxTurns: 8
|
||||||
|
memoryScope: project
|
||||||
|
effort: high
|
||||||
|
background: true
|
||||||
|
worktreeIsolation: false
|
||||||
|
outputSchema: review-result
|
||||||
|
---
|
||||||
|
你是章节评审代理。逐条核对来源标注。
|
||||||
|
`
|
||||||
|
|
||||||
|
func TestParseFullFields(t *testing.T) {
|
||||||
|
spec, err := Parse(goodSpec)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if spec.Name != "doc-reviewer" || spec.Description == "" {
|
||||||
|
t.Fatalf("identity = %+v", spec)
|
||||||
|
}
|
||||||
|
if len(spec.Tools) != 3 || spec.Tools[2] != "write_file" {
|
||||||
|
t.Fatalf("tools = %v", spec.Tools)
|
||||||
|
}
|
||||||
|
if len(spec.DisallowedTools) != 1 || spec.DisallowedTools[0] != "run_shell" {
|
||||||
|
t.Fatalf("disallowed = %v", spec.DisallowedTools)
|
||||||
|
}
|
||||||
|
if spec.Model != "lightweight" || spec.MaxTurns != 8 || spec.MemoryScope != "project" || spec.Effort != "high" {
|
||||||
|
t.Fatalf("scalars = %+v", spec)
|
||||||
|
}
|
||||||
|
if !spec.Background || spec.WorktreeIsolation {
|
||||||
|
t.Fatalf("bools = %+v", spec)
|
||||||
|
}
|
||||||
|
if len(spec.Skills) != 2 || spec.OutputSchema != "review-result" {
|
||||||
|
t.Fatalf("skills/schema = %+v", spec)
|
||||||
|
}
|
||||||
|
if spec.Prompt == "" {
|
||||||
|
t.Fatal("prompt body empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseDefaultsAndMinimal(t *testing.T) {
|
||||||
|
spec, err := Parse("---\nname: helper\n---\n做杂事。\n")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if spec.MaxTurns != 0 || spec.Background || len(spec.Tools) != 0 || spec.PermissionMode != "" {
|
||||||
|
t.Fatalf("defaults = %+v", spec)
|
||||||
|
}
|
||||||
|
if spec.Prompt != "做杂事。" {
|
||||||
|
t.Fatalf("prompt = %q", spec.Prompt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseRejectsBadInputs(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"missing frontmatter": "name: x\n---\n正文\n",
|
||||||
|
"unclosed frontmatter": "---\nname: x\n正文\n",
|
||||||
|
"unknown key": "---\nname: x\nbogus: 1\n---\n正文\n",
|
||||||
|
"missing name": "---\ndescription: x\n---\n正文\n",
|
||||||
|
"bad name uppercase": "---\nname: Bad\n---\n正文\n",
|
||||||
|
"bad name underscore": "---\nname: has_under\n---\n正文\n",
|
||||||
|
"bad maxTurns": "---\nname: x\nmaxTurns: many\n---\n正文\n",
|
||||||
|
"bad bool": "---\nname: x\nbackground: yes\n---\n正文\n",
|
||||||
|
"malformed line": "---\nname: x\nno-colon-here\n---\n正文\n",
|
||||||
|
}
|
||||||
|
for label, content := range cases {
|
||||||
|
if _, err := Parse(content); err == nil {
|
||||||
|
t.Fatalf("%s: expected error", label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParsePluginBansHostOnlyKeys(t *testing.T) {
|
||||||
|
for _, key := range []string{"hooks", "mcp", "permissionMode"} {
|
||||||
|
content := "---\nname: plug-agent\n" + key + ": anything\n---\n正文\n"
|
||||||
|
if _, err := ParsePlugin(content); err == nil {
|
||||||
|
t.Fatalf("plugin key %s: expected rejection", key)
|
||||||
|
} else if got := err.Error(); !strings.Contains(got, "不得声明 "+key) {
|
||||||
|
t.Fatalf("plugin key %s: error %q 未定位到字段", key, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 宿主来源不受此限:permissionMode 在 Parse 下可用。
|
||||||
|
if _, err := Parse("---\nname: host-agent\npermissionMode: plan\n---\n正文\n"); err != nil {
|
||||||
|
t.Fatalf("host parse with permissionMode: %v", err)
|
||||||
|
}
|
||||||
|
// 插件的合法字段照常解析。
|
||||||
|
spec, err := ParsePlugin("---\nname: plug-agent\ntools: read_file\n---\n正文\n")
|
||||||
|
if err != nil || len(spec.Tools) != 1 {
|
||||||
|
t.Fatalf("plugin good spec: %+v err=%v", spec, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadDirAtomicAndDuplicate(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
write := func(name, content string) {
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
write("a.md", "---\nname: alpha\n---\nA\n")
|
||||||
|
write("b.md", "---\nname: beta\n---\nB\n")
|
||||||
|
got, err := LoadDir(dir, nil)
|
||||||
|
if err != nil || len(got) != 2 {
|
||||||
|
t.Fatalf("load = %d err=%v", len(got), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
write("c.md", "---\nname: alpha\n---\nC\n")
|
||||||
|
if _, err := LoadDir(dir, nil); err == nil {
|
||||||
|
t.Fatal("duplicate name accepted")
|
||||||
|
}
|
||||||
|
os.Remove(filepath.Join(dir, "c.md"))
|
||||||
|
|
||||||
|
write("bad.md", "---\nname: x\nbogus: 1\n---\n")
|
||||||
|
if _, err := LoadDir(dir, nil); err == nil {
|
||||||
|
t.Fatal("bad file did not fail the batch")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
// Package argschema checks tool-call arguments against the small JSON-schema
|
||||||
|
// subset that tool declarations use. It has no dependencies beyond the
|
||||||
|
// standard library so both the tool registry and the LLM client can share it.
|
||||||
|
package argschema
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Validate reports whether args satisfy schema: the top-level type is object,
|
||||||
|
// required keys are present and not blank strings, declared properties have
|
||||||
|
// their primitive type, and unknown keys are rejected when properties are
|
||||||
|
// declared.
|
||||||
|
func Validate(schema, args json.RawMessage) error {
|
||||||
|
var spec struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Required []string `json:"required"`
|
||||||
|
Properties map[string]json.RawMessage `json:"properties"`
|
||||||
|
}
|
||||||
|
if len(schema) > 0 {
|
||||||
|
if err := json.Unmarshal(schema, &spec); err != nil {
|
||||||
|
return fmt.Errorf("invalid tool schema")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var obj map[string]any
|
||||||
|
if err := json.Unmarshal(args, &obj); err != nil {
|
||||||
|
return fmt.Errorf("arguments must be a JSON object")
|
||||||
|
}
|
||||||
|
if spec.Type != "" && spec.Type != "object" {
|
||||||
|
return fmt.Errorf("unsupported schema type %s", spec.Type)
|
||||||
|
}
|
||||||
|
for _, req := range spec.Required {
|
||||||
|
v, ok := obj[req]
|
||||||
|
if !ok || v == nil {
|
||||||
|
return fmt.Errorf("missing required argument %s", req)
|
||||||
|
}
|
||||||
|
if s, ok := v.(string); ok && strings.TrimSpace(s) == "" {
|
||||||
|
return fmt.Errorf("missing required argument %s", req)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for key, raw := range spec.Properties {
|
||||||
|
v, ok := obj[key]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var prop struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &prop); err != nil || prop.Type == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !matchesType(prop.Type, v) {
|
||||||
|
return fmt.Errorf("argument %s must be %s", key, prop.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(spec.Properties) > 0 {
|
||||||
|
for key := range obj {
|
||||||
|
if _, ok := spec.Properties[key]; !ok {
|
||||||
|
return fmt.Errorf("unknown argument %s", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchesType(kind string, v any) bool {
|
||||||
|
switch kind {
|
||||||
|
case "string":
|
||||||
|
_, ok := v.(string)
|
||||||
|
return ok
|
||||||
|
case "number":
|
||||||
|
_, ok := v.(float64)
|
||||||
|
return ok
|
||||||
|
case "integer":
|
||||||
|
f, ok := v.(float64)
|
||||||
|
return ok && f == float64(int64(f))
|
||||||
|
case "boolean":
|
||||||
|
_, ok := v.(bool)
|
||||||
|
return ok
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package argschema
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidate(t *testing.T) {
|
||||||
|
schema := json.RawMessage(`{"type":"object","required":["path"],"properties":{"path":{"type":"string"},"limit":{"type":"integer"},"ratio":{"type":"number"},"all":{"type":"boolean"},"any":{}}}`)
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
schema json.RawMessage
|
||||||
|
args string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"valid", schema, `{"path":"a.txt","limit":3,"ratio":0.5,"all":true,"any":[1]}`, ""},
|
||||||
|
{"no-schema-accepts-object", nil, `{"x":1}`, ""},
|
||||||
|
{"no-properties-accepts-unknown", json.RawMessage(`{"type":"object"}`), `{"x":1}`, ""},
|
||||||
|
{"invalid-schema", json.RawMessage(`[`), `{}`, "invalid tool schema"},
|
||||||
|
{"array-arguments", schema, `[1]`, "arguments must be a JSON object"},
|
||||||
|
{"broken-arguments", schema, `{"path":`, "arguments must be a JSON object"},
|
||||||
|
{"non-object-schema", json.RawMessage(`{"type":"array"}`), `{}`, "unsupported schema type array"},
|
||||||
|
{"missing-required", schema, `{}`, "missing required argument path"},
|
||||||
|
{"null-required", schema, `{"path":null}`, "missing required argument path"},
|
||||||
|
{"blank-required", schema, `{"path":" "}`, "missing required argument path"},
|
||||||
|
{"wrong-string", schema, `{"path":7}`, "argument path must be string"},
|
||||||
|
{"fractional-integer", schema, `{"path":"a","limit":1.5}`, "argument limit must be integer"},
|
||||||
|
{"wrong-number", schema, `{"path":"a","ratio":"x"}`, "argument ratio must be number"},
|
||||||
|
{"wrong-boolean", schema, `{"path":"a","all":1}`, "argument all must be boolean"},
|
||||||
|
{"unknown-key", schema, `{"path":"a","extra":1}`, "unknown argument extra"},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
err := Validate(tc.schema, json.RawMessage(tc.args))
|
||||||
|
got := ""
|
||||||
|
if err != nil {
|
||||||
|
got = err.Error()
|
||||||
|
}
|
||||||
|
if got != tc.want {
|
||||||
|
t.Fatalf("Validate(%s, %s) = %q, want %q", tc.schema, tc.args, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
// Package checkpoint 记录写文件之前的内容,并按用户消息把文件退回去。
|
||||||
|
// 只追踪显式交给它的写入。shell 与 python 的改动不经过这里。
|
||||||
|
package checkpoint
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Record 是一次被追踪的写入。Before 是写入前的字节,After 是写入后的字节。
|
||||||
|
type Record struct {
|
||||||
|
Seq int `json:"seq"`
|
||||||
|
Anchor string `json:"anchor,omitempty"`
|
||||||
|
Root string `json:"root"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Existed bool `json:"existed"`
|
||||||
|
Before []byte `json:"before,omitempty"`
|
||||||
|
After []byte `json:"after,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result 是一次回滚的分类。Skipped 是当前内容既不是写入后也不是写入前的文件
|
||||||
|
// (含目标是符号链接的情况——恢复会跟出根外,按外部改动处理)。
|
||||||
|
// Failed 是恢复动作本身失败的文件(Rewind 不因单文件失败中止,继续其余)。
|
||||||
|
type Result struct {
|
||||||
|
Restored []string
|
||||||
|
Skipped []string
|
||||||
|
Failed []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log 是追加写的检查点文件。
|
||||||
|
type Log struct {
|
||||||
|
path string
|
||||||
|
mu sync.Mutex
|
||||||
|
lastSeq int // Open 时一次读出的最大 Seq;Record 递增(不再全读)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Open(path string) (*Log, error) {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
f, err := os.OpenFile(path, os.O_CREATE, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
_ = f.Close()
|
||||||
|
l := &Log{path: path}
|
||||||
|
// 初始化序号(一次读,之后递增)。
|
||||||
|
if all, err := l.read(); err == nil && len(all) > 0 {
|
||||||
|
l.lastSeq = all[len(all)-1].Seq
|
||||||
|
}
|
||||||
|
return l, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Log) Path() string { return l.path }
|
||||||
|
|
||||||
|
// Record 追加一条检查点。rel 是相对 root 的路径。
|
||||||
|
func (l *Log) Record(anchor, root, rel string, before []byte, existed bool, after []byte) error {
|
||||||
|
if l == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
l.lastSeq++
|
||||||
|
rec := Record{
|
||||||
|
Seq: l.lastSeq, Anchor: anchor, Root: root, Path: filepath.ToSlash(rel),
|
||||||
|
Existed: existed, Before: append([]byte(nil), before...), After: append([]byte(nil), after...),
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(rec)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
f, err := os.OpenFile(l.path, os.O_APPEND|os.O_WRONLY, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
_, err = f.Write(append(raw, '\n'))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compact 只保留最近 keepAnchors 个 anchor 的记录(按 Seq 序判定新旧,
|
||||||
|
// 保留原 Seq 不重排,序号单调性不破)。keepAnchors ≤ 0 不动。
|
||||||
|
// 重写是原子的(临时文件 + rename)。
|
||||||
|
func (l *Log) Compact(keepAnchors int) error {
|
||||||
|
if l == nil || keepAnchors <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
all, err := l.read()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var anchors []string
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, rec := range all {
|
||||||
|
if !seen[rec.Anchor] {
|
||||||
|
seen[rec.Anchor] = true
|
||||||
|
anchors = append(anchors, rec.Anchor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(anchors) <= keepAnchors {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
keep := map[string]bool{}
|
||||||
|
for _, a := range anchors[len(anchors)-keepAnchors:] {
|
||||||
|
keep[a] = true
|
||||||
|
}
|
||||||
|
tmp := l.path + ".compact.tmp"
|
||||||
|
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
enc := json.NewEncoder(f)
|
||||||
|
for _, rec := range all {
|
||||||
|
if keep[rec.Anchor] {
|
||||||
|
if err := enc.Encode(rec); err != nil {
|
||||||
|
_ = f.Close()
|
||||||
|
_ = os.Remove(tmp)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := f.Close(); err != nil {
|
||||||
|
_ = os.Remove(tmp)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(tmp, l.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plan 列出从 anchor 起(含)会被回滚的文件,不改磁盘。
|
||||||
|
// ordered 是用户消息 id 的时间顺序。
|
||||||
|
func (l *Log) Plan(anchor string, ordered []string) (Result, error) {
|
||||||
|
if l == nil {
|
||||||
|
return Result{}, nil
|
||||||
|
}
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
all, err := l.read()
|
||||||
|
if err != nil {
|
||||||
|
return Result{}, err
|
||||||
|
}
|
||||||
|
return classify(all, anchor, ordered), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rewind 把 anchor 及其后的被追踪写入退回写入前。
|
||||||
|
// 当前内容与最后一次记录的 After 不一致时跳过并列入 Skipped。
|
||||||
|
func (l *Log) Rewind(anchor string, ordered []string) (Result, error) {
|
||||||
|
if l == nil {
|
||||||
|
return Result{}, nil
|
||||||
|
}
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
all, err := l.read()
|
||||||
|
if err != nil {
|
||||||
|
return Result{}, err
|
||||||
|
}
|
||||||
|
plan := classify(all, anchor, ordered)
|
||||||
|
// 单文件失败不中止:符号链接目标归入 Skipped(外部改动语义),
|
||||||
|
// 其余恢复错误记入 Failed 并继续(部分恢复对调用方可读)。
|
||||||
|
restored := plan.Restored[:0]
|
||||||
|
for _, rel := range plan.Restored {
|
||||||
|
group := groupFor(selected(all, anchor, ordered), rel)
|
||||||
|
if err := restore(group); err != nil {
|
||||||
|
if errors.Is(err, errSymlinkTarget) {
|
||||||
|
plan.Skipped = append(plan.Skipped, rel)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
plan.Failed = append(plan.Failed, rel)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
restored = append(restored, rel)
|
||||||
|
}
|
||||||
|
plan.Restored = restored
|
||||||
|
return plan, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Log) read() ([]Record, error) {
|
||||||
|
b, err := os.ReadFile(l.path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var out []Record
|
||||||
|
for _, line := range bytes.Split(b, []byte("\n")) {
|
||||||
|
line = bytes.TrimSpace(line)
|
||||||
|
if len(line) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var rec Record
|
||||||
|
if err := json.Unmarshal(line, &rec); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, rec)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func selected(all []Record, anchor string, ordered []string) []Record {
|
||||||
|
keep := map[string]bool{}
|
||||||
|
on := false
|
||||||
|
for _, id := range ordered {
|
||||||
|
if id == anchor {
|
||||||
|
on = true
|
||||||
|
}
|
||||||
|
if on {
|
||||||
|
keep[id] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if anchor != "" && len(ordered) == 0 {
|
||||||
|
keep[anchor] = true
|
||||||
|
}
|
||||||
|
var out []Record
|
||||||
|
for _, rec := range all {
|
||||||
|
if keep[rec.Anchor] {
|
||||||
|
out = append(out, rec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func classify(all []Record, anchor string, ordered []string) Result {
|
||||||
|
sel := selected(all, anchor, ordered)
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var restored, skipped []string
|
||||||
|
for _, rec := range sel {
|
||||||
|
if seen[rec.Path] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[rec.Path] = true
|
||||||
|
group := groupFor(sel, rec.Path)
|
||||||
|
switch fileState(group) {
|
||||||
|
case stateRestore:
|
||||||
|
restored = append(restored, rec.Path)
|
||||||
|
case stateSkip:
|
||||||
|
skipped = append(skipped, rec.Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Result{Restored: restored, Skipped: skipped}
|
||||||
|
}
|
||||||
|
|
||||||
|
func groupFor(sel []Record, rel string) []Record {
|
||||||
|
var group []Record
|
||||||
|
for _, rec := range sel {
|
||||||
|
if rec.Path == rel {
|
||||||
|
group = append(group, rec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return group
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
stateAlready = iota
|
||||||
|
stateRestore
|
||||||
|
stateSkip
|
||||||
|
)
|
||||||
|
|
||||||
|
func fileState(group []Record) int {
|
||||||
|
if len(group) == 0 {
|
||||||
|
return stateAlready
|
||||||
|
}
|
||||||
|
first, last := group[0], group[len(group)-1]
|
||||||
|
abs := filepath.Join(last.Root, filepath.FromSlash(last.Path))
|
||||||
|
current, err := os.ReadFile(abs)
|
||||||
|
missing := os.IsNotExist(err)
|
||||||
|
if err != nil && !missing {
|
||||||
|
return stateSkip
|
||||||
|
}
|
||||||
|
if missing {
|
||||||
|
if !last.Existed && len(last.After) == 0 {
|
||||||
|
return stateAlready
|
||||||
|
}
|
||||||
|
if !first.Existed && bytes.Equal(nil, first.Before) && len(last.After) > 0 {
|
||||||
|
return stateSkip
|
||||||
|
}
|
||||||
|
return stateSkip
|
||||||
|
}
|
||||||
|
if bytes.Equal(current, last.After) {
|
||||||
|
if !first.Existed {
|
||||||
|
return stateRestore
|
||||||
|
}
|
||||||
|
if bytes.Equal(current, first.Before) {
|
||||||
|
return stateAlready
|
||||||
|
}
|
||||||
|
return stateRestore
|
||||||
|
}
|
||||||
|
if first.Existed && bytes.Equal(current, first.Before) {
|
||||||
|
return stateAlready
|
||||||
|
}
|
||||||
|
return stateSkip
|
||||||
|
}
|
||||||
|
|
||||||
|
var errSymlinkTarget = errors.New("checkpoint: restore target is a symlink")
|
||||||
|
|
||||||
|
// restoreForTesting 是测试缝:非空时在每次恢复前调用(返回错误即恢复失败)。
|
||||||
|
// 生产代码永不应设置它;恢复期的故障注入无法绕开分类层的预检
|
||||||
|
// (权限类失败对 root 不可重现,故用显式缝)。
|
||||||
|
var restoreForTesting func(abs string) error
|
||||||
|
|
||||||
|
func restore(group []Record) error {
|
||||||
|
if len(group) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
first := group[0]
|
||||||
|
abs := filepath.Join(first.Root, filepath.FromSlash(first.Path))
|
||||||
|
if restoreForTesting != nil {
|
||||||
|
if err := restoreForTesting(abs); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 符号链接防护:恢复会跟出根外(与 fs_write_file 的防护同语义)。
|
||||||
|
// 按外部改动处理——由调用方归入 Skipped,不写不删。
|
||||||
|
if info, err := os.Lstat(abs); err == nil && info.Mode()&os.ModeSymlink != 0 {
|
||||||
|
return errSymlinkTarget
|
||||||
|
}
|
||||||
|
if !first.Existed {
|
||||||
|
return os.Remove(abs)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(abs, first.Before, 0o644)
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package checkpoint
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRewindRestoresTrackedWriteAndSkipsExternalChange(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
log, err := Open(filepath.Join(t.TempDir(), "cp.jsonl"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "keep.txt"), []byte("old"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := log.Record("m1", root, "keep.txt", []byte("old"), true, []byte("new")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "keep.txt"), []byte("new"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "side.txt"), []byte("from-shell"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := log.Record("m1", root, "moved.txt", []byte("a"), true, []byte("b")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "moved.txt"), []byte("someone-else"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := log.Rewind("m1", []string{"m1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(got.Restored) != 1 || got.Restored[0] != "keep.txt" {
|
||||||
|
t.Fatalf("restored %#v", got.Restored)
|
||||||
|
}
|
||||||
|
if len(got.Skipped) != 1 || got.Skipped[0] != "moved.txt" {
|
||||||
|
t.Fatalf("skipped %#v", got.Skipped)
|
||||||
|
}
|
||||||
|
body, err := os.ReadFile(filepath.Join(root, "keep.txt"))
|
||||||
|
if err != nil || string(body) != "old" {
|
||||||
|
t.Fatalf("keep.txt %q %v", body, err)
|
||||||
|
}
|
||||||
|
side, err := os.ReadFile(filepath.Join(root, "side.txt"))
|
||||||
|
if err != nil || string(side) != "from-shell" {
|
||||||
|
t.Fatalf("untracked file changed: %q %v", side, err)
|
||||||
|
}
|
||||||
|
moved, _ := os.ReadFile(filepath.Join(root, "moved.txt"))
|
||||||
|
if string(moved) != "someone-else" {
|
||||||
|
t.Fatalf("external edit was reverted: %q", moved)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRewindRemovesCreatedFile(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
log, err := Open(filepath.Join(t.TempDir(), "cp.jsonl"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "new.txt"), []byte("hi"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := log.Record("m2", root, "new.txt", nil, false, []byte("hi")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := log.Rewind("m2", []string{"m1", "m2"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(got.Restored) != 1 {
|
||||||
|
t.Fatalf("restored %#v", got.Restored)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(root, "new.txt")); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("created file still present: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
package checkpoint
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- N3 WP3:检查点收口 ----
|
||||||
|
|
||||||
|
// 符号链接目标:恢复会跟出根外——归入 Skipped,链接与目标都不动。
|
||||||
|
func TestRewindSkipsSymlinkTarget(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
log, err := Open(filepath.Join(root, "cp.jsonl"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
real := filepath.Join(root, "real.txt")
|
||||||
|
if err := os.WriteFile(real, []byte("被跟踪写入后"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := log.Record("m1", root, "real.txt", []byte("写入前"), true, []byte("被跟踪写入后")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
outside := filepath.Join(t.TempDir(), "outside.txt")
|
||||||
|
if err := os.WriteFile(outside, []byte("根外内容"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Remove(real); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Symlink(outside, real); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
res, err := log.Rewind("m1", []string{"m1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(res.Skipped) != 1 || res.Skipped[0] != "real.txt" {
|
||||||
|
t.Fatalf("符号链接必须进 Skipped:%+v", res)
|
||||||
|
}
|
||||||
|
if len(res.Restored) != 0 || len(res.Failed) != 0 {
|
||||||
|
t.Fatalf("符号链接不得恢复也不得算失败:%+v", res)
|
||||||
|
}
|
||||||
|
body, _ := os.ReadFile(outside)
|
||||||
|
if string(body) != "根外内容" {
|
||||||
|
t.Fatalf("根外目标被写:%q", body)
|
||||||
|
}
|
||||||
|
if info, _ := os.Lstat(real); info == nil || info.Mode()&os.ModeSymlink == 0 {
|
||||||
|
t.Fatal("符号链接本身不得被改动")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 部分恢复:一个文件恢复动作失败(所在目录只读,Remove 失败),
|
||||||
|
// 另一个照常恢复,失败进 Result.Failed,不中止。
|
||||||
|
func TestRewindPartialFailureContinues(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
log, err := Open(filepath.Join(root, "cp.jsonl"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
dirA, dirB := filepath.Join(root, "a"), filepath.Join(root, "b")
|
||||||
|
for _, d := range []string{dirA, dirB} {
|
||||||
|
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 两个都是「被跟踪创建」的文件(existed=false,当前内容==写入后 → stateRestore)。
|
||||||
|
if err := os.WriteFile(filepath.Join(dirA, "keep.txt"), []byte("A后"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dirB, "fail.txt"), []byte("B后"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := log.Record("m1", root, "a/keep.txt", nil, false, []byte("A后")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := log.Record("m1", root, "b/fail.txt", nil, false, []byte("B后")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// 测试缝注入恢复失败(权限法对 root 不可重现——CI 以 root 跑)。
|
||||||
|
restoreForTesting = func(abs string) error {
|
||||||
|
if strings.HasSuffix(abs, "b/fail.txt") {
|
||||||
|
return errors.New("注入的恢复失败")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defer func() { restoreForTesting = nil }()
|
||||||
|
res, err := log.Rewind("m1", []string{"m1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("单文件失败不得中止:%v", err)
|
||||||
|
}
|
||||||
|
if len(res.Failed) != 1 || res.Failed[0] != "b/fail.txt" {
|
||||||
|
t.Fatalf("失败文件应进 Failed:%+v", res)
|
||||||
|
}
|
||||||
|
if len(res.Restored) != 1 || res.Restored[0] != "a/keep.txt" {
|
||||||
|
t.Fatalf("其余文件照常恢复:%+v", res)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(dirA, "keep.txt")); !os.IsNotExist(err) {
|
||||||
|
t.Fatal("keep.txt 应被删除")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(dirB, "fail.txt")); err != nil {
|
||||||
|
t.Fatal("fail.txt 应保留")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compact:只留最近 N 个 anchor 的记录,原 Seq 保留,后续 Record 序号单调。
|
||||||
|
func TestCompactKeepsRecentAnchors(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
log, err := Open(filepath.Join(root, "cp.jsonl"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, tc := range []struct{ anchor, rel string }{
|
||||||
|
{"m1", "a"}, {"m2", "b"}, {"m2", "c"}, {"m3", "d"},
|
||||||
|
} {
|
||||||
|
if err := log.Record(tc.anchor, root, tc.rel, []byte("x"), true, []byte("y")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := log.Compact(2); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
all, err := log.read()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(all) != 3 {
|
||||||
|
t.Fatalf("Compact(2) 应留 3 条(m2×2 + m3),得到 %d", len(all))
|
||||||
|
}
|
||||||
|
for _, rec := range all {
|
||||||
|
if rec.Anchor == "m1" {
|
||||||
|
t.Fatalf("m1 应被裁掉:%+v", all)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Seq 保留原值且后续递增单调。
|
||||||
|
maxSeq := 0
|
||||||
|
for _, rec := range all {
|
||||||
|
if rec.Seq > maxSeq {
|
||||||
|
maxSeq = rec.Seq
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := log.Record("m4", root, "e", []byte("x"), true, []byte("y")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
all, _ = log.read()
|
||||||
|
if all[len(all)-1].Seq <= maxSeq {
|
||||||
|
t.Fatalf("Compact 后序号必须单调:%d → %d", maxSeq, all[len(all)-1].Seq)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 空 ordered 边界:只回滚 anchor 自己的记录(既有语义钉住)。
|
||||||
|
func TestRewindEmptyOrderedKeepsAnchorOnly(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
log, err := Open(filepath.Join(root, "cp.jsonl"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "a.txt"), []byte("A后"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "b.txt"), []byte("B后"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := log.Record("m1", root, "a.txt", []byte("A前"), true, []byte("A后")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := log.Record("m2", root, "b.txt", []byte("B前"), true, []byte("B后")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
res, err := log.Rewind("m2", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(res.Restored) != 1 || res.Restored[0] != "b.txt" {
|
||||||
|
t.Fatalf("空 ordered 只回滚 anchor 自己:%+v", res)
|
||||||
|
}
|
||||||
|
if body, _ := os.ReadFile(filepath.Join(root, "a.txt")); string(body) != "A后" {
|
||||||
|
t.Fatalf("m1 的文件不得被动:%q", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 「外部同字节改写」分类为 Already(内容等价即视为同步,钉住语义)。
|
||||||
|
func TestIdenticalExternalRewriteIsAlready(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
log, err := Open(filepath.Join(root, "cp.jsonl"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "a.txt"), []byte("相同内容"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := log.Record("m1", root, "a.txt", []byte("相同内容"), true, []byte("相同内容")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// 外部用相同字节重写一遍(mtime 变、内容同)。
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "a.txt"), []byte("相同内容"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
res, err := log.Plan("m1", []string{"m1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(res.Restored) != 0 || len(res.Skipped) != 0 {
|
||||||
|
t.Fatalf("同字节改写应为 Already(不进任何分类):%+v", res)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// root 移动:记录按各自 Root 恢复,不窜根。
|
||||||
|
func TestRewindUsesPerRecordRoot(t *testing.T) {
|
||||||
|
root1, root2 := t.TempDir(), t.TempDir()
|
||||||
|
log, err := Open(filepath.Join(root1, "cp.jsonl"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(root2, "a.txt"), []byte("root2 的内容"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := log.Record("m1", root2, "a.txt", []byte("写入前"), true, []byte("root2 的内容")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
res, err := log.Rewind("m1", []string{"m1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(res.Restored) != 1 {
|
||||||
|
t.Fatalf("应按记录内 Root 恢复:%+v", res)
|
||||||
|
}
|
||||||
|
if body, _ := os.ReadFile(filepath.Join(root2, "a.txt")); string(body) != "写入前" {
|
||||||
|
t.Fatalf("root2 未恢复:%q", body)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(root1, "a.txt")); !os.IsNotExist(err) {
|
||||||
|
t.Fatal("不得在 root1 造出同名文件")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""工作流脚本回调。用法:import agentwf as wf。
|
||||||
|
|
||||||
|
请求写到 fd 3,响应从 fd 4 按行读回。宿主一次只处理一条。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
_seq = 0
|
||||||
|
_out = os.fdopen(3, "w", encoding="utf-8", buffering=1)
|
||||||
|
_inp = os.fdopen(4, "r", encoding="utf-8", buffering=1)
|
||||||
|
|
||||||
|
|
||||||
|
def _rpc(op, **fields):
|
||||||
|
global _seq
|
||||||
|
_seq += 1
|
||||||
|
msg = {"id": str(_seq), "op": op}
|
||||||
|
msg.update(fields)
|
||||||
|
_out.write(json.dumps(msg, ensure_ascii=False) + "\n")
|
||||||
|
_out.flush()
|
||||||
|
line = _inp.readline()
|
||||||
|
if not line:
|
||||||
|
raise RuntimeError("workflow host closed")
|
||||||
|
resp = json.loads(line)
|
||||||
|
if not resp.get("ok"):
|
||||||
|
raise RuntimeError(resp.get("error") or "workflow call failed")
|
||||||
|
return resp.get("output", "")
|
||||||
|
|
||||||
|
|
||||||
|
def tool(name, args=None):
|
||||||
|
return _rpc("tool.call", name=name, args=args or {})
|
||||||
|
|
||||||
|
|
||||||
|
def ask(question, choices=None):
|
||||||
|
return _rpc("hitl.ask", question=question, choices=choices or [])
|
||||||
|
|
||||||
|
|
||||||
|
def agent(name, task):
|
||||||
|
return _rpc("agent.spawn", agent=name, task=task)
|
||||||
|
|
||||||
|
|
||||||
|
def artifact(name, content):
|
||||||
|
return _rpc("artifact.register", name=name, content=content)
|
||||||
|
|
||||||
|
|
||||||
|
def card(title, kind="result"):
|
||||||
|
return _rpc("card.create", title=title, kind=kind)
|
||||||
|
|
||||||
|
|
||||||
|
def step(step_id, title, status):
|
||||||
|
return _rpc("step", stepId=step_id, title=title, status=status)
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package codeorch
|
||||||
|
|
||||||
|
// Forbidden 是脚本不能再调用的工具:解释器、自身、派发和工作流。
|
||||||
|
func Forbidden(name string) bool {
|
||||||
|
switch name {
|
||||||
|
case "run_python", "run_shell", "run_command", "run_tool_script",
|
||||||
|
"delegate_task", "squad_orchestrate", "run_workflow", "review_findings":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,380 @@
|
|||||||
|
// Package codeorch 在会话目录里跑一段 Python,并按行把工具调用交回宿主。
|
||||||
|
// 工作流脚本和 run_tool_script 共用这一套进程与限额。
|
||||||
|
package codeorch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
_ "embed"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed agentwf.py
|
||||||
|
var agentwfSource string
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultTimeout = 120 * time.Second
|
||||||
|
defaultMaxWait = 600 * time.Second
|
||||||
|
defaultCalls = 50
|
||||||
|
defaultResult = 1 << 20
|
||||||
|
defaultModel = 16000
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
helperOnce sync.Once
|
||||||
|
helperDir string
|
||||||
|
helperErr error
|
||||||
|
)
|
||||||
|
|
||||||
|
// Call 是脚本发来的一行请求。Args 与 Choices 保持原始 JSON。
|
||||||
|
type Call struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Op string `json:"op"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Args json.RawMessage `json:"args"`
|
||||||
|
Question string `json:"question"`
|
||||||
|
Choices json.RawMessage `json:"choices"`
|
||||||
|
Agent string `json:"agent"`
|
||||||
|
Task string `json:"task"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
StepID string `json:"stepId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limits 控制一次脚本运行。零值使用工具路径的缺省:120 秒、最长 600 秒、50 次、1 MiB、16k 字符。
|
||||||
|
type Limits struct {
|
||||||
|
Timeout time.Duration
|
||||||
|
MaxTimeout time.Duration
|
||||||
|
MaxCalls int
|
||||||
|
MaxResultBytes int
|
||||||
|
MaxModelChars int
|
||||||
|
FullDir string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Outcome 是给模型的文本、已成功的调用,以及超限时的全文路径。
|
||||||
|
type Outcome struct {
|
||||||
|
Output string
|
||||||
|
FullPath string
|
||||||
|
Calls []string
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Starter 按工作目录和脚本路径准备命令。空则用 python3。
|
||||||
|
type Starter func(ctx context.Context, workDir, path string) (*exec.Cmd, error)
|
||||||
|
|
||||||
|
// Dispatch 处理一条回调。返回的错误会写回脚本并记入最终结果。
|
||||||
|
type Dispatch func(ctx context.Context, call Call) (string, error)
|
||||||
|
|
||||||
|
// ModelText 把截断后的输出、已执行调用和错误拼成给模型的文本。
|
||||||
|
func (o Outcome) ModelText() string {
|
||||||
|
text := o.Output
|
||||||
|
if o.Err != nil {
|
||||||
|
msg := o.Err.Error()
|
||||||
|
if len(o.Calls) > 0 {
|
||||||
|
msg = "已执行:" + strings.Join(o.Calls, "、") + "\n" + msg
|
||||||
|
}
|
||||||
|
if text != "" {
|
||||||
|
text += "\n"
|
||||||
|
}
|
||||||
|
text += msg
|
||||||
|
}
|
||||||
|
if o.FullPath != "" {
|
||||||
|
if text != "" {
|
||||||
|
text += "\n"
|
||||||
|
}
|
||||||
|
text += "全文在 " + o.FullPath
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunFile 执行已经落盘的脚本。
|
||||||
|
func RunFile(ctx context.Context, workDir, path string, lim Limits, start Starter, dispatch Dispatch) Outcome {
|
||||||
|
if ctx == nil {
|
||||||
|
ctx = context.Background()
|
||||||
|
}
|
||||||
|
timeout := lim.bounded()
|
||||||
|
runCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||||
|
defer cancel()
|
||||||
|
helper, err := helperPath()
|
||||||
|
if err != nil {
|
||||||
|
return Outcome{Err: err}
|
||||||
|
}
|
||||||
|
if start == nil {
|
||||||
|
start = defaultStart
|
||||||
|
}
|
||||||
|
cmd, err := start(runCtx, workDir, path)
|
||||||
|
if err != nil {
|
||||||
|
return Outcome{Err: err}
|
||||||
|
}
|
||||||
|
cmd.Env = prependPythonPath(cmd.Env, helper)
|
||||||
|
reqR, reqW, err := os.Pipe()
|
||||||
|
if err != nil {
|
||||||
|
return Outcome{Err: err}
|
||||||
|
}
|
||||||
|
respR, respW, err := os.Pipe()
|
||||||
|
if err != nil {
|
||||||
|
reqR.Close()
|
||||||
|
reqW.Close()
|
||||||
|
return Outcome{Err: err}
|
||||||
|
}
|
||||||
|
cmd.ExtraFiles = []*os.File{reqW, respR}
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
cmd.Stdout = &stdout
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
reqR.Close()
|
||||||
|
reqW.Close()
|
||||||
|
respR.Close()
|
||||||
|
respW.Close()
|
||||||
|
return Outcome{Err: err}
|
||||||
|
}
|
||||||
|
reqW.Close()
|
||||||
|
respR.Close()
|
||||||
|
defer reqR.Close()
|
||||||
|
defer respW.Close()
|
||||||
|
calls, callErr := serve(runCtx, reqR, respW, lim, dispatch)
|
||||||
|
if callErr != nil {
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
waitErr := cmd.Wait()
|
||||||
|
out := Outcome{Output: clipJoined(&stdout, &stderr), Calls: calls}
|
||||||
|
out = clipModel(out, lim)
|
||||||
|
switch {
|
||||||
|
case callErr != nil:
|
||||||
|
out.Err = callErr
|
||||||
|
case runCtx.Err() == context.DeadlineExceeded:
|
||||||
|
out.Err = fmt.Errorf("脚本超时")
|
||||||
|
case runCtx.Err() == context.Canceled:
|
||||||
|
out.Err = fmt.Errorf("脚本已取消")
|
||||||
|
case waitErr != nil:
|
||||||
|
out.Err = fmt.Errorf("脚本退出:%v", waitErr)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run 把脚本体写入工作目录后再执行。
|
||||||
|
func Run(ctx context.Context, workDir, body string, lim Limits, start Starter, dispatch Dispatch) Outcome {
|
||||||
|
dir := filepath.Join(workDir, ".agent")
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return Outcome{Err: err}
|
||||||
|
}
|
||||||
|
path := filepath.Join(dir, fmt.Sprintf("orch-%d.py", time.Now().UnixNano()))
|
||||||
|
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||||
|
return Outcome{Err: err}
|
||||||
|
}
|
||||||
|
return RunFile(ctx, workDir, path, lim, start, dispatch)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l Limits) bounded() time.Duration {
|
||||||
|
d := l.Timeout
|
||||||
|
if d <= 0 {
|
||||||
|
d = defaultTimeout
|
||||||
|
}
|
||||||
|
max := l.MaxTimeout
|
||||||
|
if max <= 0 {
|
||||||
|
max = defaultMaxWait
|
||||||
|
}
|
||||||
|
if d > max {
|
||||||
|
return max
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l Limits) calls() int {
|
||||||
|
if l.MaxCalls <= 0 {
|
||||||
|
return defaultCalls
|
||||||
|
}
|
||||||
|
return l.MaxCalls
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l Limits) resultBytes() int {
|
||||||
|
if l.MaxResultBytes <= 0 {
|
||||||
|
return defaultResult
|
||||||
|
}
|
||||||
|
return l.MaxResultBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l Limits) modelChars() int {
|
||||||
|
if l.MaxModelChars <= 0 {
|
||||||
|
return defaultModel
|
||||||
|
}
|
||||||
|
return l.MaxModelChars
|
||||||
|
}
|
||||||
|
|
||||||
|
func serve(ctx context.Context, reqR, respW *os.File, lim Limits, dispatch Dispatch) ([]string, error) {
|
||||||
|
sc := bufio.NewScanner(reqR)
|
||||||
|
sc.Buffer(make([]byte, 64*1024), lim.resultBytes())
|
||||||
|
var calls []string
|
||||||
|
n := 0
|
||||||
|
for sc.Scan() {
|
||||||
|
line := strings.TrimSpace(sc.Text())
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
n++
|
||||||
|
var call Call
|
||||||
|
if err := json.Unmarshal([]byte(line), &call); err != nil {
|
||||||
|
_ = writeResp(respW, call.ID, false, "", "请求不是合法 JSON")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if n > lim.calls() {
|
||||||
|
_ = writeResp(respW, call.ID, false, "", "脚本回调超过 50 次")
|
||||||
|
return calls, fmt.Errorf("脚本回调超过 50 次")
|
||||||
|
}
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return calls, ctx.Err()
|
||||||
|
}
|
||||||
|
if dispatch == nil {
|
||||||
|
_ = writeResp(respW, call.ID, false, "", "没有回调处理")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
output, err := dispatch(ctx, call)
|
||||||
|
if err != nil {
|
||||||
|
_ = writeResp(respW, call.ID, false, "", err.Error())
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return calls, ctx.Err()
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if call.Op == "tool.call" && call.Name != "" {
|
||||||
|
calls = append(calls, call.Name)
|
||||||
|
}
|
||||||
|
if err := writeResp(respW, call.ID, true, clipBytes(output, lim.resultBytes()), ""); err != nil {
|
||||||
|
return calls, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := sc.Err(); err != nil {
|
||||||
|
if err == bufio.ErrTooLong {
|
||||||
|
return calls, fmt.Errorf("单行请求超过 1MB")
|
||||||
|
}
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return calls, ctx.Err()
|
||||||
|
}
|
||||||
|
return calls, err
|
||||||
|
}
|
||||||
|
return calls, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeResp(w io.Writer, id string, ok bool, output, errText string) error {
|
||||||
|
raw, err := json.Marshal(struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Output string `json:"output,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}{ID: id, OK: ok, Output: output, Error: errText})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = w.Write(append(raw, '\n'))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func clipBytes(s string, n int) string {
|
||||||
|
if n <= 0 || len(s) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
cut := s[:n]
|
||||||
|
for !utf8.ValidString(cut) && len(cut) > 0 {
|
||||||
|
cut = cut[:len(cut)-1]
|
||||||
|
}
|
||||||
|
return cut
|
||||||
|
}
|
||||||
|
|
||||||
|
func clipJoined(stdout, stderr *bytes.Buffer) string {
|
||||||
|
text := strings.TrimSpace(stdout.String())
|
||||||
|
errText := strings.TrimSpace(stderr.String())
|
||||||
|
if errText != "" {
|
||||||
|
if text != "" {
|
||||||
|
text += "\n"
|
||||||
|
}
|
||||||
|
text += errText
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
func clipModel(out Outcome, lim Limits) Outcome {
|
||||||
|
limit := lim.modelChars()
|
||||||
|
if utf8.RuneCountInString(out.Output) <= limit {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
dir := lim.FullDir
|
||||||
|
if dir == "" {
|
||||||
|
dir = os.TempDir()
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
out.Output = clipRunes(out.Output, limit)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
path := filepath.Join(dir, fmt.Sprintf("script-output-%d.txt", time.Now().UnixNano()))
|
||||||
|
if err := os.WriteFile(path, []byte(out.Output), 0o644); err != nil {
|
||||||
|
out.Output = clipRunes(out.Output, limit)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
out.FullPath = path
|
||||||
|
out.Output = clipRunes(out.Output, limit)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func clipRunes(s string, n int) string {
|
||||||
|
if utf8.RuneCountInString(s) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
i := 0
|
||||||
|
for count := 0; count < n && i < len(s); count++ {
|
||||||
|
_, size := utf8.DecodeRuneInString(s[i:])
|
||||||
|
i += size
|
||||||
|
}
|
||||||
|
return s[:i] + "…(超长截断)"
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultStart(ctx context.Context, workDir, path string) (*exec.Cmd, error) {
|
||||||
|
bin, err := exec.LookPath("python3")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("找不到 python3: %w", err)
|
||||||
|
}
|
||||||
|
cmd := exec.CommandContext(ctx, bin, path)
|
||||||
|
cmd.Dir = workDir
|
||||||
|
return cmd, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func helperPath() (string, error) {
|
||||||
|
helperOnce.Do(func() {
|
||||||
|
dir, err := os.MkdirTemp("", "agentwf-*")
|
||||||
|
if err != nil {
|
||||||
|
helperErr = err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "agentwf.py"), []byte(agentwfSource), 0o644); err != nil {
|
||||||
|
helperErr = err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
helperDir = dir
|
||||||
|
})
|
||||||
|
return helperDir, helperErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func prependPythonPath(env []string, dir string) []string {
|
||||||
|
if env == nil {
|
||||||
|
env = os.Environ()
|
||||||
|
}
|
||||||
|
for i, kv := range env {
|
||||||
|
if strings.HasPrefix(kv, "PYTHONPATH=") {
|
||||||
|
env[i] = "PYTHONPATH=" + dir + string(os.PathListSeparator) + strings.TrimPrefix(kv, "PYTHONPATH=")
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return append(env, "PYTHONPATH="+dir)
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package codeorch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestScriptCallsTwoToolsAndReportsTheOneThatRan(t *testing.T) {
|
||||||
|
body := "import agentwf as wf\nprint(wf.tool('alpha', {}))\nprint(wf.tool('beta', {}))\n"
|
||||||
|
out := Run(context.Background(), t.TempDir(), body, Limits{}, nil, func(_ context.Context, call Call) (string, error) {
|
||||||
|
if call.Name == "beta" {
|
||||||
|
return "", errors.New("beta 失败")
|
||||||
|
}
|
||||||
|
return "A", nil
|
||||||
|
})
|
||||||
|
text := out.ModelText()
|
||||||
|
if len(out.Calls) != 1 || out.Calls[0] != "alpha" {
|
||||||
|
t.Fatalf("calls %+v", out.Calls)
|
||||||
|
}
|
||||||
|
if !strings.Contains(text, "已执行:alpha") || !strings.Contains(text, "beta 失败") {
|
||||||
|
t.Fatalf("text %q err %v", text, out.Err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestForbiddenToolIsRejectedBeforeDispatchRunsIt(t *testing.T) {
|
||||||
|
body := "import agentwf as wf\nprint(wf.tool('read_file', {}))\nprint(wf.tool('run_shell', {}))\n"
|
||||||
|
var ran []string
|
||||||
|
out := Run(context.Background(), t.TempDir(), body, Limits{}, nil, func(_ context.Context, call Call) (string, error) {
|
||||||
|
if Forbidden(call.Name) {
|
||||||
|
return "", errors.New("不能调用 " + call.Name)
|
||||||
|
}
|
||||||
|
ran = append(ran, call.Name)
|
||||||
|
return "ok", nil
|
||||||
|
})
|
||||||
|
if len(ran) != 1 || ran[0] != "read_file" {
|
||||||
|
t.Fatalf("ran %+v", ran)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out.ModelText(), "不能调用 run_shell") {
|
||||||
|
t.Fatalf("text %q", out.ModelText())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLongOutputIsClippedAndStored(t *testing.T) {
|
||||||
|
body := "import agentwf as wf\nprint(wf.tool('big', {}))\n"
|
||||||
|
dir := t.TempDir()
|
||||||
|
out := Run(context.Background(), dir, body, Limits{MaxModelChars: 8, FullDir: dir}, nil, func(_ context.Context, call Call) (string, error) {
|
||||||
|
return strings.Repeat("测", 20), nil
|
||||||
|
})
|
||||||
|
if out.FullPath == "" {
|
||||||
|
t.Fatal("expected a full-text file")
|
||||||
|
}
|
||||||
|
full, err := os.ReadFile(out.FullPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if utf8.RuneCount(full) < 20 {
|
||||||
|
t.Fatalf("full text too short: %q", full)
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(out.Output, "…(超长截断)") || utf8.RuneCountInString(strings.TrimSuffix(out.Output, "…(超长截断)")) != 8 {
|
||||||
|
t.Fatalf("model output not clipped: %q", out.Output)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out.ModelText(), out.FullPath) {
|
||||||
|
t.Fatalf("model text missing path: %q", out.ModelText())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResultGivenToScriptIsCapped(t *testing.T) {
|
||||||
|
got := clipBytes(strings.Repeat("x", 20), 8)
|
||||||
|
if got != "xxxxxxxx" {
|
||||||
|
t.Fatalf("clip %q", got)
|
||||||
|
}
|
||||||
|
if !utf8.ValidString(clipBytes("测测测", 4)) {
|
||||||
|
t.Fatal("clip broke utf-8")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTimeoutIsCapped(t *testing.T) {
|
||||||
|
if (Limits{Timeout: 900 * time.Second, MaxTimeout: 600 * time.Second}).bounded() != 600*time.Second {
|
||||||
|
t.Fatal("timeout must clamp to the max")
|
||||||
|
}
|
||||||
|
if (Limits{}).bounded() != defaultTimeout {
|
||||||
|
t.Fatal("empty limits must use the 120s default")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,442 @@
|
|||||||
|
package runtime
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"agent-native/runtime/llm"
|
||||||
|
"agent-native/runtime/session"
|
||||||
|
"agent-native/runtime/tools"
|
||||||
|
)
|
||||||
|
|
||||||
|
type replayTool struct {
|
||||||
|
countingTool
|
||||||
|
replay string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *replayTool) Replay() string {
|
||||||
|
if r.replay == "" {
|
||||||
|
return tools.ReplayNever
|
||||||
|
}
|
||||||
|
return r.replay
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPendingUnsafeToolIsNotRerun(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
st, err := session.Open(filepath.Join(dir, "s.jsonl"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := st.Append(llm.Message{
|
||||||
|
Role: llm.RoleAssistant, StopReason: llm.StopToolCalls,
|
||||||
|
ToolCalls: []llm.ToolCall{{ID: "c1", Name: "write_file", Arguments: `{}`}},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := st.AppendToolIntent(session.ToolIntent{CallID: "c1", Name: "write_file", Arguments: `{}`, Replay: tools.ReplayNever}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
tool := &countingTool{name: "write_file"}
|
||||||
|
reg := tools.NewRegistry(dir)
|
||||||
|
reg.Register(tool)
|
||||||
|
_, err = Run(context.Background(), Config{
|
||||||
|
Model: "fake", Session: st, Tools: reg, MaxTurns: 2,
|
||||||
|
Stream: script(llm.Message{Role: llm.RoleAssistant, Content: "later", StopReason: llm.StopEnd}),
|
||||||
|
}, llm.Message{Role: llm.RoleUser, Content: "next"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if tool.calls != 0 {
|
||||||
|
t.Fatalf("unsafe tool reran: %d", tool.calls)
|
||||||
|
}
|
||||||
|
msgs, err := st.Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var paired bool
|
||||||
|
for _, m := range msgs {
|
||||||
|
if m.Role == llm.RoleTool && m.ToolCallID == "c1" && m.Content == unknownSideEffect {
|
||||||
|
paired = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !paired {
|
||||||
|
t.Fatalf("pair missing: %#v", msgs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPendingSafeToolRerunsOnce(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
st, err := session.Open(filepath.Join(dir, "s.jsonl"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := st.Append(llm.Message{
|
||||||
|
Role: llm.RoleAssistant, StopReason: llm.StopToolCalls,
|
||||||
|
ToolCalls: []llm.ToolCall{{ID: "c1", Name: "read_file", Arguments: `{}`}},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := st.AppendToolIntent(session.ToolIntent{CallID: "c1", Name: "read_file", Arguments: `{}`, Replay: tools.ReplaySafe}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
tool := &replayTool{countingTool: countingTool{name: "read_file"}, replay: tools.ReplaySafe}
|
||||||
|
reg := tools.NewRegistry(dir)
|
||||||
|
reg.Register(tool)
|
||||||
|
_, err = Run(context.Background(), Config{
|
||||||
|
Model: "fake", Session: st, Tools: reg, MaxTurns: 2,
|
||||||
|
Stream: script(llm.Message{Role: llm.RoleAssistant, Content: "later", StopReason: llm.StopEnd}),
|
||||||
|
}, llm.Message{Role: llm.RoleUser, Content: "next"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if tool.calls != 1 {
|
||||||
|
t.Fatalf("calls %d", tool.calls)
|
||||||
|
}
|
||||||
|
msgs, err := st.Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, m := range msgs {
|
||||||
|
if m.Role == llm.RoleTool && m.ToolCallID == "c1" {
|
||||||
|
if m.Content != "ok" || strings.Contains(m.Content, unknownSideEffect) {
|
||||||
|
t.Fatalf("result %#v", m)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatal("missing tool result")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStoredSafeCurrentNeverDoesNotRerun(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
st, err := session.Open(filepath.Join(dir, "s.jsonl"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := st.AppendToolIntent(session.ToolIntent{CallID: "c1", Name: "read_file", Arguments: `{}`, Replay: tools.ReplaySafe}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
tool := &replayTool{countingTool: countingTool{name: "read_file"}, replay: tools.ReplayNever}
|
||||||
|
reg := tools.NewRegistry(dir)
|
||||||
|
reg.Register(tool)
|
||||||
|
if _, err := recoverPending(context.Background(), Config{Session: st, Tools: reg}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if tool.calls != 0 {
|
||||||
|
t.Fatalf("calls %d", tool.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFollowUpIsPersistedBeforeNextRequest(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
st, err := session.Open(filepath.Join(dir, "s.jsonl"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sent := false
|
||||||
|
var saw bool
|
||||||
|
stream := func(ctx context.Context, model string, messages []llm.Message, tools []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
|
||||||
|
ch := make(chan llm.Event, 1)
|
||||||
|
go func() {
|
||||||
|
defer close(ch)
|
||||||
|
for _, m := range messages {
|
||||||
|
if m.Content == "follow-please" {
|
||||||
|
loaded, err := st.Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("load %v", err)
|
||||||
|
}
|
||||||
|
for _, got := range loaded {
|
||||||
|
if got.Content == "follow-please" {
|
||||||
|
saw = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{Role: llm.RoleAssistant, Content: "ok", StopReason: llm.StopEnd}}
|
||||||
|
}()
|
||||||
|
return ch
|
||||||
|
}
|
||||||
|
_, err = Run(context.Background(), Config{
|
||||||
|
Model: "fake", Session: st, MaxTurns: 3, Stream: stream,
|
||||||
|
GetFollowUp: func() []llm.Message {
|
||||||
|
if sent {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
sent = true
|
||||||
|
return []llm.Message{{Role: llm.RoleUser, Content: "follow-please"}}
|
||||||
|
},
|
||||||
|
}, llm.Message{Role: llm.RoleUser, Content: "go"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !saw {
|
||||||
|
t.Fatal("follow-up entered the request before it was stored")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpansUseAgreedNamesWithoutPrompt(t *testing.T) {
|
||||||
|
var names []string
|
||||||
|
var leaked bool
|
||||||
|
reg := tools.NewRegistry(t.TempDir())
|
||||||
|
reg.Register(&countingTool{name: "read_file"})
|
||||||
|
_, err := Run(context.Background(), Config{
|
||||||
|
Model: "fake", Tools: reg, MaxTurns: 2,
|
||||||
|
Trace: func(name string, attrs map[string]string) func() {
|
||||||
|
names = append(names, name)
|
||||||
|
if _, ok := attrs["prompt"]; ok || attrs["content"] != "" {
|
||||||
|
leaked = true
|
||||||
|
}
|
||||||
|
return func() {}
|
||||||
|
},
|
||||||
|
Stream: script(
|
||||||
|
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{ID: "1", Name: "read_file", Arguments: `{}`}}},
|
||||||
|
llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd},
|
||||||
|
),
|
||||||
|
}, llm.Message{Role: llm.RoleUser, Content: "用户的秘密提示词"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
joined := strings.Join(names, ",")
|
||||||
|
for _, want := range []string{"invoke_agent", "chat", "execute_tool"} {
|
||||||
|
if !strings.Contains(joined, want) {
|
||||||
|
t.Fatalf("names %s", joined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if leaked {
|
||||||
|
t.Fatal("prompt leaked into span attrs")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- N3 WP1:崩溃窗口尾部配对兜底(验收① 库层机器核心)----
|
||||||
|
|
||||||
|
// 窗口内死亡(persist(assistant) 之后、任何 intent 之前):转录只有带
|
||||||
|
// tool_calls 的 assistant。恢复后每个调用都有「进程中断」配对,且 run 继续。
|
||||||
|
func TestResumePairsOrphanTailAfterCrashWindow(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
st, err := session.Open(filepath.Join(dir, "s.jsonl"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := st.Append(llm.Message{Role: llm.RoleSystem, Content: "sys"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := st.Append(llm.Message{Role: llm.RoleUser, Content: "干个活"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// 崩溃窗口形态:assistant 落盘、零意图、零结果。
|
||||||
|
if err := st.Append(llm.Message{
|
||||||
|
Role: llm.RoleAssistant, StopReason: llm.StopToolCalls,
|
||||||
|
ToolCalls: []llm.ToolCall{
|
||||||
|
{ID: "c1", Name: "write_file", Arguments: `{}`},
|
||||||
|
{ID: "c2", Name: "read_file", Arguments: `{}`},
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
reg := tools.NewRegistry(dir)
|
||||||
|
tool := &countingTool{name: "write_file"}
|
||||||
|
reg.Register(tool)
|
||||||
|
reg.Register(&countingTool{name: "read_file"})
|
||||||
|
_, err = Run(context.Background(), Config{
|
||||||
|
Model: "fake", Session: st, Tools: reg, MaxTurns: 2,
|
||||||
|
Stream: script(llm.Message{Role: llm.RoleAssistant, Content: "继续", StopReason: llm.StopEnd}),
|
||||||
|
}, llm.Message{Role: llm.RoleUser, Content: "还在吗"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("恢复后的 Run 不得失败(配对修复应让提供方接受历史):%v", err)
|
||||||
|
}
|
||||||
|
msgs, err := st.Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// 双向配对断言:每个调用有结果、每个结果有调用。
|
||||||
|
calls, results := map[string]string{}, map[string]bool{}
|
||||||
|
for _, m := range msgs {
|
||||||
|
for _, c := range m.ToolCalls {
|
||||||
|
calls[c.ID] = c.Name
|
||||||
|
}
|
||||||
|
if m.Role == llm.RoleTool && m.ToolCallID != "" {
|
||||||
|
results[m.ToolCallID] = true
|
||||||
|
if m.Content != crashInterrupted && m.Content != unknownSideEffect {
|
||||||
|
t.Fatalf("兜底文案不对:%q", m.Content)
|
||||||
|
}
|
||||||
|
if !m.IsError {
|
||||||
|
t.Fatal("兜底结果必须 IsError")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for id := range calls {
|
||||||
|
if !results[id] {
|
||||||
|
t.Fatalf("孤立 tool_call:%s", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if tool.calls != 0 {
|
||||||
|
t.Fatalf("兜底不得重放写工具:%d", tool.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行中死亡(c1 意图+结果、c2 意图无结果)+ 窗口死亡(c3 无意图)混合:
|
||||||
|
// 意图路径配 c2、尾部兜底配 c3,互不重复。
|
||||||
|
func TestResumeMixedIntentAndOrphanTail(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
st, err := session.Open(filepath.Join(dir, "s.jsonl"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := st.Append(llm.Message{
|
||||||
|
Role: llm.RoleAssistant, StopReason: llm.StopToolCalls,
|
||||||
|
ToolCalls: []llm.ToolCall{
|
||||||
|
{ID: "c1", Name: "read_file", Arguments: `{}`},
|
||||||
|
{ID: "c2", Name: "read_file", Arguments: `{}`},
|
||||||
|
{ID: "c3", Name: "write_file", Arguments: `{}`},
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := st.AppendToolIntent(session.ToolIntent{CallID: "c1", Name: "read_file", Arguments: `{}`, Replay: tools.ReplaySafe}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := st.Append(llm.Message{Role: llm.RoleTool, ToolCallID: "c1", Name: "read_file", Content: "真实结果"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := st.AppendToolIntent(session.ToolIntent{CallID: "c2", Name: "read_file", Arguments: `{}`, Replay: tools.ReplayNever}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
reg := tools.NewRegistry(dir)
|
||||||
|
reg.Register(&countingTool{name: "read_file"})
|
||||||
|
reg.Register(&countingTool{name: "write_file"})
|
||||||
|
_, err = Run(context.Background(), Config{
|
||||||
|
Model: "fake", Session: st, Tools: reg, MaxTurns: 1,
|
||||||
|
Stream: script(llm.Message{Role: llm.RoleAssistant, Content: "x", StopReason: llm.StopEnd}),
|
||||||
|
}, llm.Message{Role: llm.RoleUser, Content: "next"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
msgs, err := st.Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
byID := map[string][]string{}
|
||||||
|
for _, m := range msgs {
|
||||||
|
if m.Role == llm.RoleTool && m.ToolCallID != "" {
|
||||||
|
byID[m.ToolCallID] = append(byID[m.ToolCallID], m.Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := byID["c1"]; len(got) != 1 || got[0] != "真实结果" {
|
||||||
|
t.Fatalf("c1 必须保持原结果且不重复:%v", got)
|
||||||
|
}
|
||||||
|
if got := byID["c2"]; len(got) != 1 || got[0] != unknownSideEffect {
|
||||||
|
t.Fatalf("c2 走意图路径:%v", got)
|
||||||
|
}
|
||||||
|
if got := byID["c3"]; len(got) != 1 || got[0] != crashInterrupted {
|
||||||
|
t.Fatalf("c3 走尾部兜底:%v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 健康转录(尾部是完整配对或 user 消息)不做任何改写。
|
||||||
|
func TestRepairOrphanTailNoopOnHealthyTranscript(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
st, err := session.Open(filepath.Join(dir, "s.jsonl"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, m := range []llm.Message{
|
||||||
|
{Role: llm.RoleSystem, Content: "sys"},
|
||||||
|
{Role: llm.RoleUser, Content: "u"},
|
||||||
|
{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{ID: "c1", Name: "read_file", Arguments: `{}`}}},
|
||||||
|
{Role: llm.RoleTool, ToolCallID: "c1", Name: "read_file", Content: "r"},
|
||||||
|
{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd},
|
||||||
|
{Role: llm.RoleUser, Content: "u2"},
|
||||||
|
} {
|
||||||
|
if err := st.Append(m); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
before, _ := st.Load()
|
||||||
|
if _, err := repairOrphanTail(Config{Session: st}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
after, _ := st.Load()
|
||||||
|
if len(after) != len(before) {
|
||||||
|
t.Fatalf("健康转录被改写:%d → %d", len(before), len(after))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- N3 WP2:JournalOnly(journal 只作日志,上下文由 History 组装)----
|
||||||
|
|
||||||
|
// JournalOnly:journal 有杀窗口尾巴 → 恢复配对写入 journal;上下文来自
|
||||||
|
// History(不含 journal 内容),恢复摘要以系统消息注入;种子不进 journal。
|
||||||
|
func TestJournalOnlyRecoversWithoutTranscriptAuthority(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
st, err := session.Open(filepath.Join(dir, "s.jsonl"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := st.Append(llm.Message{
|
||||||
|
Role: llm.RoleAssistant, StopReason: llm.StopToolCalls,
|
||||||
|
ToolCalls: []llm.ToolCall{{ID: "c1", Name: "read_file", Arguments: `{}`}},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := st.AppendToolIntent(session.ToolIntent{CallID: "c1", Name: "read_file", Arguments: `{}`, Replay: tools.ReplaySafe}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
reg := tools.NewRegistry(dir)
|
||||||
|
reg.Register(&replayTool{countingTool: countingTool{name: "read_file"}, replay: tools.ReplaySafe})
|
||||||
|
var gotReq []llm.Message
|
||||||
|
res, err := Run(context.Background(), Config{
|
||||||
|
Model: "fake", Session: st, JournalOnly: true, Tools: reg, MaxTurns: 2,
|
||||||
|
History: []llm.Message{
|
||||||
|
{Role: llm.RoleSystem, Content: "sys"},
|
||||||
|
{Role: llm.RoleUser, Content: "历史一"},
|
||||||
|
{Role: llm.RoleAssistant, Content: "回复一"},
|
||||||
|
},
|
||||||
|
Stream: func(ctx context.Context, model string, msgs []llm.Message, _ []llm.ToolDecl, _ llm.StreamOptions) <-chan llm.Event {
|
||||||
|
gotReq = append([]llm.Message{}, msgs...)
|
||||||
|
ch := make(chan llm.Event, 1)
|
||||||
|
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{Role: llm.RoleAssistant, Content: "继续", StopReason: llm.StopEnd}}
|
||||||
|
close(ch)
|
||||||
|
return ch
|
||||||
|
},
|
||||||
|
}, llm.Message{Role: llm.RoleUser, Content: "最新"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// journal 里 c1 被 safe 重放配对(一次真实结果)。
|
||||||
|
journal, _ := st.Load()
|
||||||
|
var journaled bool
|
||||||
|
for _, m := range journal {
|
||||||
|
if m.Role == llm.RoleTool && m.ToolCallID == "c1" && m.Content == "ok" {
|
||||||
|
journaled = true
|
||||||
|
}
|
||||||
|
if m.Content == "历史一" {
|
||||||
|
t.Fatal("种子历史不得进 journal")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !journaled {
|
||||||
|
t.Fatalf("journal 缺恢复配对:%#v", journal)
|
||||||
|
}
|
||||||
|
// 上下文:History 内容在;恢复摘要在;且在新 user 消息之前。
|
||||||
|
var sawHist, sawNote int
|
||||||
|
noteIdx, userIdx := -1, -1
|
||||||
|
for i, m := range gotReq {
|
||||||
|
if m.Content == "历史一" {
|
||||||
|
sawHist++
|
||||||
|
}
|
||||||
|
if m.Role == llm.RoleSystem && strings.Contains(m.Content, "已完成恢复") && strings.Contains(m.Content, "c1") {
|
||||||
|
sawNote++
|
||||||
|
noteIdx = i
|
||||||
|
}
|
||||||
|
if m.Content == "最新" {
|
||||||
|
userIdx = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if sawHist != 1 || sawNote != 1 {
|
||||||
|
t.Fatalf("历史=%d 摘要=%d:%#v", sawHist, sawNote, gotReq)
|
||||||
|
}
|
||||||
|
if noteIdx < 0 || userIdx < 0 || noteIdx > userIdx {
|
||||||
|
t.Fatalf("摘要必须在新 user 消息之前:note=%d user=%d", noteIdx, userIdx)
|
||||||
|
}
|
||||||
|
_ = res
|
||||||
|
}
|
||||||
@@ -39,7 +39,7 @@ func vocabScript() llm.StreamFunc {
|
|||||||
step++
|
step++
|
||||||
ch <- llm.Event{Kind: llm.KindReasoningDelta, ReasoningDelta: "思考"}
|
ch <- llm.Event{Kind: llm.KindReasoningDelta, ReasoningDelta: "思考"}
|
||||||
ch <- llm.Event{Kind: llm.KindTextDelta, Text: "正文"}
|
ch <- llm.Event{Kind: llm.KindTextDelta, Text: "正文"}
|
||||||
ch <- llm.Event{Kind: llm.KindToolCallDelta, ToolName: "list_dir", ArgumentsDelta: `{"path":"."}`}
|
ch <- llm.Event{Kind: llm.KindToolCallDelta, ToolIndex: 2, ToolID: "c1", ToolName: "list_dir", ArgumentsDelta: `{"path":"."}`}
|
||||||
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{
|
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{
|
||||||
Role: llm.RoleAssistant, StopReason: llm.StopToolCalls,
|
Role: llm.RoleAssistant, StopReason: llm.StopToolCalls,
|
||||||
ToolCalls: []llm.ToolCall{{ID: "c1", Name: "list_dir", Arguments: `{"path":"."}`}},
|
ToolCalls: []llm.ToolCall{{ID: "c1", Name: "list_dir", Arguments: `{"path":"."}`}},
|
||||||
@@ -54,6 +54,25 @@ func vocabScript() llm.StreamFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoopPreservesToolCallDeltaIdentity(t *testing.T) {
|
||||||
|
reg := tools.WorkspaceTools(t.TempDir())
|
||||||
|
var got Event
|
||||||
|
_, err := Run(context.Background(), Config{
|
||||||
|
Model: "fake", Tools: reg, Stream: vocabScript(), MaxTurns: 4,
|
||||||
|
OnEvent: func(ev Event) {
|
||||||
|
if ev.Type == "tool_call_delta" {
|
||||||
|
got = ev
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}, llm.Message{Role: llm.RoleUser, Content: "run"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.ToolIndex != 2 || got.CallID != "c1" || got.Tool != "list_dir" || got.Detail != `{"path":"."}` {
|
||||||
|
t.Fatalf("tool delta lost its provider identity: %#v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoopEmitsProcessVocabulary(t *testing.T) {
|
func TestLoopEmitsProcessVocabulary(t *testing.T) {
|
||||||
reg := tools.WorkspaceTools(t.TempDir())
|
reg := tools.WorkspaceTools(t.TempDir())
|
||||||
var observed []string
|
var observed []string
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
package pkg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Manifest is the agent-native.json discovered in a package.
|
||||||
|
type Manifest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Extensions []string `json:"extensions,omitempty"`
|
||||||
|
Config map[string]string `json:"config,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manager installs packages under Dir.
|
||||||
|
type Manager struct {
|
||||||
|
Dir string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Manager) root() string {
|
||||||
|
if m.Dir != "" {
|
||||||
|
return m.Dir
|
||||||
|
}
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
return filepath.Join(home, ".agent-native", "packages")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Install copies a local directory, clones a git URL, or runs npm pack
|
||||||
|
// when src starts with "npm:". The package name comes from its manifest.
|
||||||
|
func (m Manager) Install(src string) (Manifest, error) {
|
||||||
|
dir, err := os.MkdirTemp("", "agent-native-pkg-*")
|
||||||
|
if err != nil {
|
||||||
|
return Manifest{}, err
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(dir)
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(src, "npm:"):
|
||||||
|
if _, err := exec.LookPath("npm"); err != nil {
|
||||||
|
return Manifest{}, fmt.Errorf("pkg: npm is not installed")
|
||||||
|
}
|
||||||
|
cmd := exec.Command("npm", "pack", strings.TrimPrefix(src, "npm:"))
|
||||||
|
cmd.Dir = dir
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
return Manifest{}, fmt.Errorf("pkg: npm pack: %s", out)
|
||||||
|
}
|
||||||
|
case strings.HasPrefix(src, "git+") || strings.HasSuffix(src, ".git") || strings.Contains(src, "://"):
|
||||||
|
cmd := exec.Command("git", "clone", "--depth", "1", src, filepath.Join(dir, "src"))
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
return Manifest{}, fmt.Errorf("pkg: git clone: %s", out)
|
||||||
|
}
|
||||||
|
dir = filepath.Join(dir, "src")
|
||||||
|
default:
|
||||||
|
if err := copyDir(src, filepath.Join(dir, "src")); err != nil {
|
||||||
|
return Manifest{}, err
|
||||||
|
}
|
||||||
|
dir = filepath.Join(dir, "src")
|
||||||
|
}
|
||||||
|
manifest, err := ReadManifest(dir)
|
||||||
|
if err != nil {
|
||||||
|
return Manifest{}, err
|
||||||
|
}
|
||||||
|
dest := filepath.Join(m.root(), manifest.Name)
|
||||||
|
if err := os.RemoveAll(dest); err != nil {
|
||||||
|
return Manifest{}, err
|
||||||
|
}
|
||||||
|
if err := copyDir(dir, dest); err != nil {
|
||||||
|
return Manifest{}, err
|
||||||
|
}
|
||||||
|
return manifest, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove deletes an installed package.
|
||||||
|
func (m Manager) Remove(name string) error {
|
||||||
|
return os.RemoveAll(filepath.Join(m.root(), name))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update reinstalls from the recorded source when present, otherwise no-ops.
|
||||||
|
func (m Manager) Update(name string) (Manifest, error) {
|
||||||
|
manifest, err := ReadManifest(filepath.Join(m.root(), name))
|
||||||
|
if err != nil {
|
||||||
|
return Manifest{}, err
|
||||||
|
}
|
||||||
|
src := manifest.Config["source"]
|
||||||
|
if src == "" {
|
||||||
|
return manifest, nil
|
||||||
|
}
|
||||||
|
return m.Install(src)
|
||||||
|
}
|
||||||
|
|
||||||
|
// List returns installed manifests.
|
||||||
|
func (m Manager) List() ([]Manifest, error) {
|
||||||
|
entries, err := os.ReadDir(m.root())
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var out []Manifest
|
||||||
|
for _, ent := range entries {
|
||||||
|
if !ent.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
manifest, err := ReadManifest(filepath.Join(m.root(), ent.Name()))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, manifest)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config sets one package config key.
|
||||||
|
func (m Manager) Config(name, key, value string) error {
|
||||||
|
path := filepath.Join(m.root(), name)
|
||||||
|
manifest, err := ReadManifest(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if manifest.Config == nil {
|
||||||
|
manifest.Config = map[string]string{}
|
||||||
|
}
|
||||||
|
manifest.Config[key] = value
|
||||||
|
raw, err := json.MarshalIndent(manifest, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(filepath.Join(path, "agent-native.json"), raw, 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadManifest loads agent-native.json from a directory.
|
||||||
|
func ReadManifest(dir string) (Manifest, error) {
|
||||||
|
raw, err := os.ReadFile(filepath.Join(dir, "agent-native.json"))
|
||||||
|
if err != nil {
|
||||||
|
return Manifest{}, err
|
||||||
|
}
|
||||||
|
var manifest Manifest
|
||||||
|
if err := json.Unmarshal(raw, &manifest); err != nil {
|
||||||
|
return Manifest{}, err
|
||||||
|
}
|
||||||
|
if manifest.Name == "" {
|
||||||
|
return Manifest{}, fmt.Errorf("pkg: manifest name is empty")
|
||||||
|
}
|
||||||
|
return manifest, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyDir(src, dst string) error {
|
||||||
|
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rel, err := filepath.Rel(src, path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
target := filepath.Join(dst, rel)
|
||||||
|
if info.IsDir() {
|
||||||
|
return os.MkdirAll(target, 0o755)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
in, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer in.Close()
|
||||||
|
out, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, info.Mode())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer out.Close()
|
||||||
|
_, err = io.Copy(out, in)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
package ext
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os/exec"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"agent-native/runtime/llm"
|
||||||
|
"agent-native/runtime/tools"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Message is one JSONL frame on the extension pipe.
|
||||||
|
type Message struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
ID string `json:"id,omitempty"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Parameters json.RawMessage `json:"parameters,omitempty"`
|
||||||
|
Arguments json.RawMessage `json:"arguments,omitempty"`
|
||||||
|
Content string `json:"content,omitempty"`
|
||||||
|
IsError bool `json:"isError,omitempty"`
|
||||||
|
Event string `json:"event,omitempty"`
|
||||||
|
Block bool `json:"block,omitempty"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
Protocol string `json:"protocol,omitempty"`
|
||||||
|
BaseURL string `json:"baseURL,omitempty"`
|
||||||
|
CustomType string `json:"customType,omitempty"`
|
||||||
|
Data json.RawMessage `json:"data,omitempty"`
|
||||||
|
Command string `json:"command,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UIRequest is a question an extension wants a human to answer.
|
||||||
|
type UIRequest struct {
|
||||||
|
ID string
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process is one extension subprocess.
|
||||||
|
type Process struct {
|
||||||
|
cmd *exec.Cmd
|
||||||
|
stdin io.WriteCloser
|
||||||
|
mu sync.Mutex
|
||||||
|
tools []registeredTool
|
||||||
|
commands []string
|
||||||
|
providers []llm.Provider
|
||||||
|
pending map[string]chan Message
|
||||||
|
UI chan UIRequest
|
||||||
|
OnCustom func(customType string, data json.RawMessage)
|
||||||
|
closed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type registeredTool struct {
|
||||||
|
name string
|
||||||
|
description string
|
||||||
|
parameters json.RawMessage
|
||||||
|
proc *Process
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t registeredTool) Name() string { return t.name }
|
||||||
|
func (t registeredTool) Description() string { return t.description }
|
||||||
|
func (t registeredTool) Parameters() json.RawMessage { return t.parameters }
|
||||||
|
func (t registeredTool) Execute(ctx context.Context, args json.RawMessage) (tools.Result, error) {
|
||||||
|
return t.proc.callTool(ctx, t.name, args)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start launches command and reads its registration frames until it goes idle
|
||||||
|
// or the context ends. command is argv, not a shell line.
|
||||||
|
func Start(ctx context.Context, command []string) (*Process, error) {
|
||||||
|
if len(command) == 0 {
|
||||||
|
return nil, fmt.Errorf("ext: empty command")
|
||||||
|
}
|
||||||
|
cmd := exec.CommandContext(ctx, command[0], command[1:]...)
|
||||||
|
stdin, err := cmd.StdinPipe()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
stdout, err := cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
p := &Process{
|
||||||
|
cmd: cmd, stdin: stdin, pending: map[string]chan Message{},
|
||||||
|
UI: make(chan UIRequest, 4),
|
||||||
|
}
|
||||||
|
go p.read(stdout)
|
||||||
|
if err := p.send(Message{Type: "init"}); err != nil {
|
||||||
|
_ = p.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tools returns the tools the extension registered.
|
||||||
|
func (p *Process) Tools() []tools.Tool {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
out := make([]tools.Tool, len(p.tools))
|
||||||
|
for i := range p.tools {
|
||||||
|
out[i] = p.tools[i]
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commands returns slash-command names.
|
||||||
|
func (p *Process) Commands() []string {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
return append([]string(nil), p.commands...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Providers returns endpoints the extension registered.
|
||||||
|
func (p *Process) Providers() []llm.Provider {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
return append([]llm.Provider(nil), p.providers...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hook tells the extension about an event. A block result stops the host action.
|
||||||
|
func (p *Process) Hook(ctx context.Context, event, name string, args json.RawMessage) (bool, string) {
|
||||||
|
id := name + ":" + event
|
||||||
|
ch := make(chan Message, 1)
|
||||||
|
p.mu.Lock()
|
||||||
|
p.pending[id] = ch
|
||||||
|
p.mu.Unlock()
|
||||||
|
if err := p.send(Message{Type: "event", ID: id, Event: event, Name: name, Arguments: args}); err != nil {
|
||||||
|
return false, ""
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return false, ctx.Err().Error()
|
||||||
|
case msg := <-ch:
|
||||||
|
return msg.Block, msg.Reason
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Process) callTool(ctx context.Context, name string, args json.RawMessage) (tools.Result, error) {
|
||||||
|
id := name
|
||||||
|
ch := make(chan Message, 1)
|
||||||
|
p.mu.Lock()
|
||||||
|
p.pending[id] = ch
|
||||||
|
p.mu.Unlock()
|
||||||
|
if err := p.send(Message{Type: "tool_call", ID: id, Name: name, Arguments: args}); err != nil {
|
||||||
|
return tools.Result{IsError: true, Content: err.Error()}, err
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return tools.Result{IsError: true, Content: ctx.Err().Error()}, ctx.Err()
|
||||||
|
case msg := <-ch:
|
||||||
|
return tools.Result{Content: msg.Content, IsError: msg.IsError}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Process) send(msg Message) error {
|
||||||
|
raw, err := json.Marshal(msg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
if p.closed {
|
||||||
|
return fmt.Errorf("ext: closed")
|
||||||
|
}
|
||||||
|
_, err = p.stdin.Write(append(raw, '\n'))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Process) read(r io.Reader) {
|
||||||
|
sc := bufio.NewScanner(r)
|
||||||
|
sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
|
||||||
|
for sc.Scan() {
|
||||||
|
var msg Message
|
||||||
|
if err := json.Unmarshal(sc.Bytes(), &msg); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
p.dispatch(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Process) dispatch(msg Message) {
|
||||||
|
switch msg.Type {
|
||||||
|
case "register_tool":
|
||||||
|
p.mu.Lock()
|
||||||
|
p.tools = append(p.tools, registeredTool{
|
||||||
|
name: msg.Name, description: msg.Description, parameters: msg.Parameters, proc: p,
|
||||||
|
})
|
||||||
|
p.mu.Unlock()
|
||||||
|
case "register_command":
|
||||||
|
p.mu.Lock()
|
||||||
|
p.commands = append(p.commands, msg.Name)
|
||||||
|
p.mu.Unlock()
|
||||||
|
case "register_provider":
|
||||||
|
p.mu.Lock()
|
||||||
|
p.providers = append(p.providers, llm.Provider{
|
||||||
|
Name: msg.Name, Protocol: llm.Protocol(msg.Protocol), BaseURL: msg.BaseURL,
|
||||||
|
})
|
||||||
|
p.mu.Unlock()
|
||||||
|
case "tool_result", "hook_result":
|
||||||
|
p.mu.Lock()
|
||||||
|
ch := p.pending[msg.ID]
|
||||||
|
delete(p.pending, msg.ID)
|
||||||
|
p.mu.Unlock()
|
||||||
|
if ch != nil {
|
||||||
|
ch <- msg
|
||||||
|
}
|
||||||
|
case "ui_request":
|
||||||
|
select {
|
||||||
|
case p.UI <- UIRequest{ID: msg.ID, Message: msg.Message}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
case "custom_entry":
|
||||||
|
if p.OnCustom != nil {
|
||||||
|
p.OnCustom(msg.CustomType, msg.Data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close stops the subprocess.
|
||||||
|
func (p *Process) Close() error {
|
||||||
|
p.mu.Lock()
|
||||||
|
p.closed = true
|
||||||
|
_ = p.stdin.Close()
|
||||||
|
p.mu.Unlock()
|
||||||
|
if p.cmd.Process != nil {
|
||||||
|
_ = p.cmd.Process.Kill()
|
||||||
|
}
|
||||||
|
return p.cmd.Wait()
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package ext
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"agent-native/runtime/ext/pkg"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExtensionRegistersAndHooks(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
script := filepath.Join(dir, "ext.sh")
|
||||||
|
body := `#!/bin/sh
|
||||||
|
while IFS= read -r line; do
|
||||||
|
case "$line" in
|
||||||
|
*'"type":"init"'*)
|
||||||
|
echo '{"type":"register_tool","name":"ping","description":"p","parameters":{"type":"object"}}'
|
||||||
|
echo '{"type":"register_command","name":"hi"}'
|
||||||
|
echo '{"type":"register_provider","name":"local","protocol":"openai-chat","baseURL":"http://127.0.0.1:9"}'
|
||||||
|
echo '{"type":"ui_request","id":"u1","message":"choose"}'
|
||||||
|
;;
|
||||||
|
*'"type":"tool_call"'*)
|
||||||
|
echo '{"type":"tool_result","id":"ping","content":"pong"}'
|
||||||
|
;;
|
||||||
|
*'"type":"event"'*)
|
||||||
|
echo '{"type":"hook_result","id":"ping:tool_call","block":true,"reason":"no"}'
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(script, []byte(body), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
proc, err := Start(ctx, []string{"/bin/sh", script})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer proc.Close()
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
for len(proc.Tools()) == 0 && time.Now().Before(deadline) {
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
}
|
||||||
|
tools := proc.Tools()
|
||||||
|
if len(tools) != 1 || tools[0].Name() != "ping" {
|
||||||
|
t.Fatalf("tools %+v", tools)
|
||||||
|
}
|
||||||
|
res, err := tools[0].Execute(ctx, []byte(`{}`))
|
||||||
|
if err != nil || res.Content != "pong" {
|
||||||
|
t.Fatalf("tool: %+v %v", res, err)
|
||||||
|
}
|
||||||
|
block, reason := proc.Hook(ctx, "tool_call", "ping", []byte(`{}`))
|
||||||
|
if !block || reason != "no" {
|
||||||
|
t.Fatalf("hook %v %s", block, reason)
|
||||||
|
}
|
||||||
|
if len(proc.Commands()) != 1 || len(proc.Providers()) != 1 {
|
||||||
|
t.Fatalf("commands %v providers %v", proc.Commands(), proc.Providers())
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case ui := <-proc.UI:
|
||||||
|
if ui.Message != "choose" {
|
||||||
|
t.Fatal(ui)
|
||||||
|
}
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("missing ui request")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPackageInstallListConfigRemove(t *testing.T) {
|
||||||
|
src := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(src, "agent-native.json"), []byte(`{"name":"demo","extensions":["./ext.sh"]}`), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
mgr := pkg.Manager{Dir: t.TempDir()}
|
||||||
|
manifest, err := mgr.Install(src)
|
||||||
|
if err != nil || manifest.Name != "demo" {
|
||||||
|
t.Fatalf("%+v %v", manifest, err)
|
||||||
|
}
|
||||||
|
if err := mgr.Config("demo", "source", src); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
list, err := mgr.List()
|
||||||
|
if err != nil || len(list) != 1 || list[0].Config["source"] != src {
|
||||||
|
t.Fatalf("list %+v %v", list, err)
|
||||||
|
}
|
||||||
|
if _, err := mgr.Update("demo"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := mgr.Remove("demo"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
list, _ = mgr.List()
|
||||||
|
if len(list) != 0 {
|
||||||
|
t.Fatalf("still installed: %+v", list)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
// Package filestate 记录本轮读过的文件,并在改写前核对修改时间与大小。
|
||||||
|
package filestate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type stamp struct {
|
||||||
|
mod time.Time
|
||||||
|
size int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tracker 是一次 run 内的读取记录。nil 接收者表示不检查。
|
||||||
|
type Tracker struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
seen map[string]stamp
|
||||||
|
}
|
||||||
|
|
||||||
|
func New() *Tracker {
|
||||||
|
return &Tracker{seen: map[string]stamp{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NoteRead 记下当前的修改时间与大小。
|
||||||
|
func (t *Tracker) NoteRead(path string) error {
|
||||||
|
if t == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
t.mu.Lock()
|
||||||
|
t.seen[path] = stamp{mod: info.ModTime(), size: info.Size()}
|
||||||
|
t.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckWrite 允许新建。已存在的文件必须在本轮读过,且修改时间与大小未变。
|
||||||
|
func (t *Tracker) CheckWrite(path string) error {
|
||||||
|
if t == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
t.mu.Lock()
|
||||||
|
got, ok := t.seen[path]
|
||||||
|
t.mu.Unlock()
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("请先读取该文件再修改:%s", path)
|
||||||
|
}
|
||||||
|
if !got.mod.Equal(info.ModTime()) || got.size != info.Size() {
|
||||||
|
return fmt.Errorf("文件在读取后已变化,请重新读取:%s", path)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package filestate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCheckWriteRejectsStaleFile(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "note.txt")
|
||||||
|
if err := os.WriteFile(path, []byte("v1"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
tr := New()
|
||||||
|
if err := tr.CheckWrite(path); err == nil || !strings.Contains(err.Error(), "请先读取") {
|
||||||
|
t.Fatalf("unread write: %v", err)
|
||||||
|
}
|
||||||
|
if err := tr.NoteRead(path); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := tr.CheckWrite(path); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// 改大小,并尽量推开修改时间。
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
if err := os.WriteFile(path, []byte("v1-changed"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
err := tr.CheckWrite(path)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "请重新读取") {
|
||||||
|
t.Fatalf("stale write: %v", err)
|
||||||
|
}
|
||||||
|
missing := filepath.Join(dir, "new.txt")
|
||||||
|
if err := tr.CheckWrite(missing); err != nil {
|
||||||
|
t.Fatalf("create: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
// Package gates 提供终轮门禁与进度提醒的组合机制(自产品下沉)。
|
||||||
|
// 产品依赖——工件存取、质量评分、验证器模型、提示词、进度快照——全部端口化;
|
||||||
|
// 判定逻辑(正则族)、续跑上限与节流策略由本包持有。
|
||||||
|
// 端口为 nil 时语义与「未启用该检查」一致,不改变既有行为。
|
||||||
|
package gates
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 续跑与节流上限。与产品现网取值一致;消费方引用本包常量,不再各自硬编码。
|
||||||
|
const (
|
||||||
|
// MaxDeliverableGuards 是「声称交付但无工件」的纠正轮次上限。
|
||||||
|
MaxDeliverableGuards = 2
|
||||||
|
// MaxVerifyContinuations 是终轮检查的续跑上限(与内核 Stop 的 3 次上限对齐)。
|
||||||
|
MaxVerifyContinuations = 3
|
||||||
|
// ReminderToolGap 是连续这么多个工具轮没有更新计划或待办就提醒一次。
|
||||||
|
ReminderToolGap = 3
|
||||||
|
// MaxChecklistReminders 是每个 run 的进度提醒次数上限。
|
||||||
|
MaxChecklistReminders = 3
|
||||||
|
)
|
||||||
|
|
||||||
|
// ArtifactLookup 按 ID 取工件:名称、内容与是否存在。
|
||||||
|
type ArtifactLookup func(id string) (name string, content []byte, ok bool)
|
||||||
|
|
||||||
|
// QualityScorer 对文本类工件评分;nil 表示跳过评分(视为通过)。
|
||||||
|
// why 在未通过时给出可读原因。
|
||||||
|
type QualityScorer func(name string, content []byte) (passed bool, why string)
|
||||||
|
|
||||||
|
// ArtifactRef 是一次工件引用(库内最小形态;宿主负责投影自有类型)。
|
||||||
|
type ArtifactRef struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
Kind string
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ArtifactIDPattern 是工件编号的文本形态(门禁与宿主的引用提取共用)。
|
||||||
|
ArtifactIDPattern = regexp.MustCompile(`art_[0-9a-f]{12}`)
|
||||||
|
fileClaimRe = regexp.MustCompile(`(?s)(?:已生成|已保存|已导出|已写入).{0,120}?\.(?:docx|xlsx|pptx|pdf|png|jpe?g|csv|md|txt|json)\b`)
|
||||||
|
softClaimRe = regexp.MustCompile(`(?s)(?:文件|文档|工件).{0,40}?已(?:正常)?(?:生成|登记|保存|导出|写入)`)
|
||||||
|
forgedCallRe = regexp.MustCompile(`\[(?:调用工具|工具结果|产出工件) `)
|
||||||
|
)
|
||||||
|
|
||||||
|
// ClaimGate 判断终轮正文是否在没有对应工件的情况下声称交付。
|
||||||
|
// 引用本轮已产出、或确实存在的工件不算谎称;伪造调用标记始终拦截。
|
||||||
|
// lookup 为 nil 时,任何引用未产出工件的 art_ 编号都视为不存在(拦截)。
|
||||||
|
func ClaimGate(body string, produced []ArtifactRef, lookup ArtifactLookup) (bool, string) {
|
||||||
|
if strings.TrimSpace(body) == "" {
|
||||||
|
return false, ""
|
||||||
|
}
|
||||||
|
if forgedCallRe.MatchString(body) {
|
||||||
|
return true, "回复正文里出现了工具调用标记,但本轮没有真实的工具调用"
|
||||||
|
}
|
||||||
|
producedIDs := map[string]bool{}
|
||||||
|
for _, ref := range produced {
|
||||||
|
if ref.ID != "" {
|
||||||
|
producedIDs[ref.ID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
knownExisting := false
|
||||||
|
for _, id := range ArtifactIDPattern.FindAllString(body, -1) {
|
||||||
|
if producedIDs[id] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if lookup != nil {
|
||||||
|
if _, _, ok := lookup(id); ok {
|
||||||
|
knownExisting = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true, "回复引用了不存在的工件"
|
||||||
|
}
|
||||||
|
if len(producedIDs) > 0 || knownExisting {
|
||||||
|
return false, ""
|
||||||
|
}
|
||||||
|
if fileClaimRe.MatchString(body) || softClaimRe.MatchString(body) {
|
||||||
|
return true, "回复声称已经生成或登记了文件,但这一轮没有产生对应的工件"
|
||||||
|
}
|
||||||
|
return false, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckArtifacts 确认登记的工件存在且非空。
|
||||||
|
// md、txt、docx 还要过 scorer;其他类型只检查存在与非空。
|
||||||
|
func CheckArtifacts(refs []ArtifactRef, lookup ArtifactLookup, scorer QualityScorer) []string {
|
||||||
|
var reasons []string
|
||||||
|
for _, ref := range refs {
|
||||||
|
if ref.ID == "" {
|
||||||
|
reasons = append(reasons, "工件引用缺少 ID")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := ref.Name
|
||||||
|
if name == "" {
|
||||||
|
name = ref.ID
|
||||||
|
}
|
||||||
|
var (
|
||||||
|
content []byte
|
||||||
|
ok bool
|
||||||
|
artName string
|
||||||
|
)
|
||||||
|
if lookup != nil {
|
||||||
|
artName, content, ok = lookup(ref.ID)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
reasons = append(reasons, name+" 不存在")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if artName != "" {
|
||||||
|
name = artName
|
||||||
|
}
|
||||||
|
if len(content) == 0 {
|
||||||
|
reasons = append(reasons, name+" 内容为空")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch strings.ToLower(filepath.Ext(name)) {
|
||||||
|
case ".md", ".txt", ".docx":
|
||||||
|
if scorer == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
passed, why := scorer(name, content)
|
||||||
|
if !passed {
|
||||||
|
if why == "" {
|
||||||
|
why = "评分未通过"
|
||||||
|
}
|
||||||
|
reasons = append(reasons, name+":"+why)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return reasons
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verdict 是独立验证器的裁决。
|
||||||
|
type Verdict struct {
|
||||||
|
Passed bool `json:"passed"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
NextAction string `json:"nextAction"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseVerdict 解析验证器输出:剥围栏、取最外层 JSON、必须有 passed 键。
|
||||||
|
func ParseVerdict(raw string) (Verdict, error) {
|
||||||
|
s := stripFence(raw)
|
||||||
|
start := strings.Index(s, "{")
|
||||||
|
end := strings.LastIndex(s, "}")
|
||||||
|
if start < 0 || end < start {
|
||||||
|
return Verdict{}, fmt.Errorf("验证器返回无法解析")
|
||||||
|
}
|
||||||
|
s = s[start : end+1]
|
||||||
|
var probe map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal([]byte(s), &probe); err != nil {
|
||||||
|
return Verdict{}, err
|
||||||
|
}
|
||||||
|
if _, ok := probe["passed"]; !ok {
|
||||||
|
return Verdict{}, fmt.Errorf("验证器返回无法解析")
|
||||||
|
}
|
||||||
|
var verdict Verdict
|
||||||
|
if err := json.Unmarshal([]byte(s), &verdict); err != nil {
|
||||||
|
return Verdict{}, err
|
||||||
|
}
|
||||||
|
return verdict, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func stripFence(s string) string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if !strings.HasPrefix(s, "```") {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
s = strings.TrimPrefix(s, "```")
|
||||||
|
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||||||
|
s = s[i+1:]
|
||||||
|
}
|
||||||
|
s = strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(s), "```"))
|
||||||
|
return s
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package gates
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func lookupWith(ids map[string][]byte) ArtifactLookup {
|
||||||
|
return func(id string) (string, []byte, bool) {
|
||||||
|
c, ok := ids[id]
|
||||||
|
return id + ".md", c, ok
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaimGateForgedMarkerAlwaysBlocks(t *testing.T) {
|
||||||
|
block, why := ClaimGate("结果如下 [调用工具 write_file]", nil, nil)
|
||||||
|
if !block || why == "" {
|
||||||
|
t.Fatalf("block=%v why=%q", block, why)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaimGateProducedAndExistingDoNotBlock(t *testing.T) {
|
||||||
|
produced := []ArtifactRef{{ID: "art_aaaabbbbcccc"}}
|
||||||
|
if block, _ := ClaimGate("已生成 art_aaaabbbbcccc 请查收", produced, nil); block {
|
||||||
|
t.Fatal("produced reference blocked")
|
||||||
|
}
|
||||||
|
lookup := lookupWith(map[string][]byte{"art_dddd11112222": []byte("x")})
|
||||||
|
if block, _ := ClaimGate("之前的 art_dddd11112222 里有结论", nil, lookup); block {
|
||||||
|
t.Fatal("existing artifact reference blocked")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaimGateUnknownArtifactBlocks(t *testing.T) {
|
||||||
|
block, why := ClaimGate("见附件 art_000011112222", nil, lookupWith(nil))
|
||||||
|
if !block || !strings.Contains(why, "不存在") {
|
||||||
|
t.Fatalf("block=%v why=%q", block, why)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaimGateBareClaimWithoutArtifactsBlocks(t *testing.T) {
|
||||||
|
for _, body := range []string{
|
||||||
|
"已生成报告.md,请查收",
|
||||||
|
"文件已生成好了",
|
||||||
|
} {
|
||||||
|
if block, _ := ClaimGate(body, nil, nil); !block {
|
||||||
|
t.Fatalf("claim not blocked: %q", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 如实说明没有生成则不拦。
|
||||||
|
if block, _ := ClaimGate("这一轮没有生成文件", nil, nil); block {
|
||||||
|
t.Fatal("honest note blocked")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckArtifactsReasons(t *testing.T) {
|
||||||
|
lookup := func(id string) (string, []byte, bool) {
|
||||||
|
switch id {
|
||||||
|
case "ok":
|
||||||
|
return "ok.md", []byte("# 正常"), true
|
||||||
|
case "empty":
|
||||||
|
return "empty.md", nil, true
|
||||||
|
default:
|
||||||
|
return "", nil, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scorer := func(name string, content []byte) (bool, string) { return false, "标题缺失" }
|
||||||
|
refs := []ArtifactRef{
|
||||||
|
{ID: ""},
|
||||||
|
{ID: "missing", Name: "缺失.md"},
|
||||||
|
{ID: "empty"},
|
||||||
|
{ID: "ok"},
|
||||||
|
}
|
||||||
|
reasons := CheckArtifacts(refs, lookup, scorer)
|
||||||
|
joined := strings.Join(reasons, ";")
|
||||||
|
for _, want := range []string{"缺少 ID", "缺失.md 不存在", "empty.md 内容为空", "ok.md:标题缺失"} {
|
||||||
|
if !strings.Contains(joined, want) {
|
||||||
|
t.Fatalf("reasons %q 缺 %q", joined, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckArtifactsNilScorerSkipsQuality(t *testing.T) {
|
||||||
|
lookup := lookupWith(map[string][]byte{"a": []byte("x")})
|
||||||
|
if reasons := CheckArtifacts([]ArtifactRef{{ID: "a"}}, lookup, nil); len(reasons) != 0 {
|
||||||
|
t.Fatalf("reasons = %v", reasons)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseVerdictForms(t *testing.T) {
|
||||||
|
v, err := ParseVerdict("```json\n{\"passed\":true,\"reason\":\"ok\"}\n```")
|
||||||
|
if err != nil || !v.Passed {
|
||||||
|
t.Fatalf("v=%+v err=%v", v, err)
|
||||||
|
}
|
||||||
|
if _, err := ParseVerdict("没有 JSON"); err == nil {
|
||||||
|
t.Fatal("garbage accepted")
|
||||||
|
}
|
||||||
|
if _, err := ParseVerdict("{\"reason\":\"no passed key\"}"); err == nil {
|
||||||
|
t.Fatal("missing passed accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProgressReminderThrottleAndFormat(t *testing.T) {
|
||||||
|
r := &ProgressReminder{}
|
||||||
|
now := time.Now()
|
||||||
|
first := r.Remind([]string{"写第三章"}, []string{"评审"}, now)
|
||||||
|
if len(first) != 1 {
|
||||||
|
t.Fatalf("first = %v", first)
|
||||||
|
}
|
||||||
|
text := first[0]
|
||||||
|
for _, want := range []string{"已连续 3 个工具轮", "- 待办:写第三章", "- 计划:评审", "请更新计划或待办"} {
|
||||||
|
if !strings.Contains(text, want) {
|
||||||
|
t.Fatalf("text 缺 %q:\n%s", want, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 内容相同且计数不同:key 不同,应继续发出直到封顶。
|
||||||
|
if got := r.Remind([]string{"写第三章"}, []string{"评审"}, now); len(got) != 1 {
|
||||||
|
t.Fatalf("second = %v", got)
|
||||||
|
}
|
||||||
|
if got := r.Remind([]string{"写第三章"}, []string{"评审"}, now); len(got) != 1 {
|
||||||
|
t.Fatalf("third = %v", got)
|
||||||
|
}
|
||||||
|
if got := r.Remind([]string{"写第三章"}, []string{"评审"}, now); got != nil {
|
||||||
|
t.Fatalf("fourth should be capped: %v", got)
|
||||||
|
}
|
||||||
|
if r.Sent() != 3 {
|
||||||
|
t.Fatalf("sent = %d", r.Sent())
|
||||||
|
}
|
||||||
|
// 无内容不发。
|
||||||
|
if got := (&ProgressReminder{}).Remind(nil, nil, now); got != nil {
|
||||||
|
t.Fatalf("empty = %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package gates
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"agent-native/runtime/reminders"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ProgressReminder 是进度提醒的节流状态机与格式化机制。
|
||||||
|
// 计数(第几个工具轮)由调用方按自己的事件流维护;本机制负责
|
||||||
|
// 「达到阈值后的去重、封顶与文案」。
|
||||||
|
type ProgressReminder struct {
|
||||||
|
Gap int // 连续多少个工具轮未更新即提醒;0 = ReminderToolGap
|
||||||
|
Cap int // 每 run 提醒上限;0 = MaxChecklistReminders
|
||||||
|
notes *reminders.Bus
|
||||||
|
sent int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ProgressReminder) gap() int {
|
||||||
|
if r.Gap <= 0 {
|
||||||
|
return ReminderToolGap
|
||||||
|
}
|
||||||
|
return r.Gap
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ProgressReminder) cap() int {
|
||||||
|
if r.Cap <= 0 {
|
||||||
|
return MaxChecklistReminders
|
||||||
|
}
|
||||||
|
return r.Cap
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sent 报告已发出的提醒条数(调用方判定封顶用)。
|
||||||
|
func (r *ProgressReminder) Sent() int { return r.sent }
|
||||||
|
|
||||||
|
// Remind 在给定未完成项时发一条提醒;节流或没有内容时返回 nil。
|
||||||
|
// 文案与产品现网一致:「计划和待办已连续 N 个工具轮没有更新」。
|
||||||
|
func (r *ProgressReminder) Remind(todos, plans []string, now time.Time) []string {
|
||||||
|
if r.sent >= r.cap() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
text := FormatUnfinished(r.gap(), todos, plans)
|
||||||
|
if text == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if r.notes == nil {
|
||||||
|
r.notes = reminders.New(0, r.cap())
|
||||||
|
}
|
||||||
|
key := fmt.Sprintf("%d:%s", r.sent, text)
|
||||||
|
if !r.notes.Admit(reminders.Note{Kind: reminders.KindTodo, Key: key, Text: text}, now) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
r.sent++
|
||||||
|
return []string{text}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatUnfinished 渲染未完成项提醒;无内容时返回空串。
|
||||||
|
func FormatUnfinished(gap int, todos, plans []string) string {
|
||||||
|
var lines []string
|
||||||
|
for _, item := range todos {
|
||||||
|
if text := strings.TrimSpace(item); text != "" {
|
||||||
|
lines = append(lines, "- 待办:"+text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, step := range plans {
|
||||||
|
if text := strings.TrimSpace(step); text != "" {
|
||||||
|
lines = append(lines, "- 计划:"+text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(lines) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("计划和待办已连续 %d 个工具轮没有更新。未完成项:\n", gap) +
|
||||||
|
strings.Join(lines, "\n") +
|
||||||
|
"\n请更新计划或待办,或在回复里说明为什么先不做。"
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
// Package guardrails 提供三类语义化护栏端口:输入(只作用于入口智能体)、
|
||||||
|
// 输出(只作用于给出最终结果的智能体)、工具(每次工具调用)。
|
||||||
|
// 护栏以钩子总线注册:它是类型化语义层,钩子仍是通用事件层。
|
||||||
|
// 当前形态护栏拿不到运行 ctx(事件载荷不含它);需要取消语义时直接写钩子。
|
||||||
|
package guardrails
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"agent-native/runtime/hooks"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Verdict 是一次护栏表态。空 Decision 表示不插手。
|
||||||
|
// Deny 拒绝(Output 层表现为要求模型改进后继续);Modify 改写载荷
|
||||||
|
// (Input 改用户输入、Output 改最终文本、Tool 改参数);Reason 附说明。
|
||||||
|
type Verdict struct {
|
||||||
|
Decision hooks.Decision
|
||||||
|
Reason string
|
||||||
|
Replacement string
|
||||||
|
}
|
||||||
|
|
||||||
|
type InputGuardrail func(ctx context.Context, text string) Verdict
|
||||||
|
type OutputGuardrail func(ctx context.Context, text string) Verdict
|
||||||
|
type ToolGuardrail func(ctx context.Context, name, args string) Verdict
|
||||||
|
|
||||||
|
// Set 是一组护栏;nil 字段表示该层不启用。
|
||||||
|
type Set struct {
|
||||||
|
Input InputGuardrail
|
||||||
|
Output OutputGuardrail
|
||||||
|
Tool ToolGuardrail
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scope 标注本 run 的角色:Entry=本 run 处理入口用户输入;
|
||||||
|
// Final=本 run 给出最终答案。子代理 run 两者都为 false,
|
||||||
|
// Input/Output 护栏不会作用于它;Tool 护栏作用于一切工具调用。
|
||||||
|
type Scope struct {
|
||||||
|
Entry bool
|
||||||
|
Final bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register 把护栏挂到总线。语义:
|
||||||
|
// - Input(仅 scope.Entry):挂 UserPromptSubmit。deny → 循环不启动,
|
||||||
|
// 以理由直接收尾;modify → 改写后的输入追加为新的用户消息(原文留痕)。
|
||||||
|
// - Output(仅 scope.Final):挂 Stop。deny → 视同续跑,理由交给模型改进
|
||||||
|
// (计入内核续跑上限,达上限后收尾并附 Notice);modify → 替换最终文本并续跑。
|
||||||
|
// - Tool:挂 PreToolUse,与其他钩子按既有优先级合并
|
||||||
|
// (deny/block > ask > modify > allow);modify 的 Replacement 改写工具参数。
|
||||||
|
func Register(bus *hooks.Bus, set Set, scope Scope) {
|
||||||
|
if bus == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if set.Input != nil && scope.Entry {
|
||||||
|
g := set.Input
|
||||||
|
bus.On(func(c hooks.Call) hooks.Outcome {
|
||||||
|
if c.Event != hooks.UserPromptSubmit {
|
||||||
|
return hooks.Outcome{}
|
||||||
|
}
|
||||||
|
v := g(context.Background(), c.LastText)
|
||||||
|
return hooks.Outcome{Decision: v.Decision, Reason: v.Reason, Replacement: v.Replacement}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if set.Output != nil && scope.Final {
|
||||||
|
g := set.Output
|
||||||
|
bus.On(func(c hooks.Call) hooks.Outcome {
|
||||||
|
if c.Event != hooks.Stop {
|
||||||
|
return hooks.Outcome{}
|
||||||
|
}
|
||||||
|
v := g(context.Background(), c.LastText)
|
||||||
|
switch v.Decision {
|
||||||
|
case hooks.Deny, hooks.Block:
|
||||||
|
// 对 Stop 事件而言「拒绝最终答案」= 要求模型改进后再来一轮。
|
||||||
|
return hooks.Outcome{Decision: hooks.Block, Reason: v.Reason}
|
||||||
|
case hooks.Modify:
|
||||||
|
return hooks.Outcome{Decision: hooks.Block, Reason: v.Reason, Replacement: v.Replacement}
|
||||||
|
default:
|
||||||
|
return hooks.Outcome{Decision: v.Decision, Reason: v.Reason}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if set.Tool != nil {
|
||||||
|
g := set.Tool
|
||||||
|
bus.On(func(c hooks.Call) hooks.Outcome {
|
||||||
|
if c.Event != hooks.PreToolUse {
|
||||||
|
return hooks.Outcome{}
|
||||||
|
}
|
||||||
|
v := g(context.Background(), c.Tool, c.Args)
|
||||||
|
out := hooks.Outcome{Decision: v.Decision, Reason: v.Reason}
|
||||||
|
if v.Decision == hooks.Modify {
|
||||||
|
out.Args = v.Replacement
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package guardrails
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"agent-native/runtime/hooks"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestToolGuardrailMergesOnPreToolUse(t *testing.T) {
|
||||||
|
bus := hooks.New()
|
||||||
|
Register(bus, Set{
|
||||||
|
Tool: func(ctx context.Context, name, args string) Verdict {
|
||||||
|
if name == "run_shell" {
|
||||||
|
return Verdict{Decision: hooks.Deny, Reason: "shell 被护栏禁用"}
|
||||||
|
}
|
||||||
|
return Verdict{}
|
||||||
|
},
|
||||||
|
}, Scope{})
|
||||||
|
out := bus.Emit(hooks.Call{Event: hooks.PreToolUse, Tool: "run_shell"})
|
||||||
|
if out.Decision != hooks.Deny || out.Reason == "" {
|
||||||
|
t.Fatalf("out = %+v", out)
|
||||||
|
}
|
||||||
|
out = bus.Emit(hooks.Call{Event: hooks.PreToolUse, Tool: "read_file"})
|
||||||
|
if out.Decision != "" {
|
||||||
|
t.Fatalf("read_file out = %+v", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToolGuardrailModifyRewritesArgs(t *testing.T) {
|
||||||
|
bus := hooks.New()
|
||||||
|
Register(bus, Set{
|
||||||
|
Tool: func(ctx context.Context, name, args string) Verdict {
|
||||||
|
return Verdict{Decision: hooks.Modify, Replacement: `{"path":"safe.txt"}`}
|
||||||
|
},
|
||||||
|
}, Scope{})
|
||||||
|
out := bus.Emit(hooks.Call{Event: hooks.PreToolUse, Tool: "write_file"})
|
||||||
|
if out.Decision != hooks.Modify || out.Args != `{"path":"safe.txt"}` {
|
||||||
|
t.Fatalf("out = %+v", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOutputGuardrailDenyBecomesContinuation(t *testing.T) {
|
||||||
|
bus := hooks.New()
|
||||||
|
Register(bus, Set{
|
||||||
|
Output: func(ctx context.Context, text string) Verdict {
|
||||||
|
return Verdict{Decision: hooks.Deny, Reason: "缺少来源标注"}
|
||||||
|
},
|
||||||
|
}, Scope{Final: true})
|
||||||
|
out := bus.Emit(hooks.Call{Event: hooks.Stop, LastText: "结论"})
|
||||||
|
// Output deny 必须表达为续跑(Block),而不是把 run 掐死。
|
||||||
|
if out.Decision != hooks.Block || out.Reason != "缺少来源标注" {
|
||||||
|
t.Fatalf("out = %+v", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScopeGatesInputAndOutput(t *testing.T) {
|
||||||
|
fired := map[string]bool{}
|
||||||
|
mk := func() Set {
|
||||||
|
return Set{
|
||||||
|
Input: func(ctx context.Context, text string) Verdict { fired["input"] = true; return Verdict{} },
|
||||||
|
Output: func(ctx context.Context, text string) Verdict { fired["output"] = true; return Verdict{} },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 子代理 run(Entry/Final 都为 false):Input/Output 不注册。
|
||||||
|
bus := hooks.New()
|
||||||
|
Register(bus, mk(), Scope{})
|
||||||
|
bus.Emit(hooks.Call{Event: hooks.UserPromptSubmit, LastText: "x"})
|
||||||
|
bus.Emit(hooks.Call{Event: hooks.Stop, LastText: "y"})
|
||||||
|
if fired["input"] || fired["output"] {
|
||||||
|
t.Fatalf("sub-run scope fired: %v", fired)
|
||||||
|
}
|
||||||
|
// 入口且最终:两层都触发。
|
||||||
|
bus2 := hooks.New()
|
||||||
|
Register(bus2, mk(), Scope{Entry: true, Final: true})
|
||||||
|
bus2.Emit(hooks.Call{Event: hooks.UserPromptSubmit, LastText: "x"})
|
||||||
|
bus2.Emit(hooks.Call{Event: hooks.Stop, LastText: "y"})
|
||||||
|
if !fired["input"] || !fired["output"] {
|
||||||
|
t.Fatalf("entry/final scope did not fire: %v", fired)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNilBusIsNoop(t *testing.T) {
|
||||||
|
Register(nil, Set{
|
||||||
|
Input: func(ctx context.Context, text string) Verdict { return Verdict{Decision: hooks.Deny} },
|
||||||
|
Output: func(ctx context.Context, text string) Verdict { return Verdict{Decision: hooks.Deny} },
|
||||||
|
Tool: func(ctx context.Context, name, args string) Verdict { return Verdict{Decision: hooks.Deny} },
|
||||||
|
}, Scope{Entry: true, Final: true})
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
// Package hooks 是进程内钩子总线。决策按 block/deny、ask、modify、allow 取更强的一条。
|
||||||
|
// 没有表态的处理函数不改变结果。
|
||||||
|
package hooks
|
||||||
|
|
||||||
|
import "sync"
|
||||||
|
|
||||||
|
type Event string
|
||||||
|
|
||||||
|
const (
|
||||||
|
SessionStart Event = "SessionStart"
|
||||||
|
UserPromptSubmit Event = "UserPromptSubmit"
|
||||||
|
PreToolUse Event = "PreToolUse"
|
||||||
|
PermissionRequest Event = "PermissionRequest"
|
||||||
|
PostToolUse Event = "PostToolUse"
|
||||||
|
PostToolUseFailure Event = "PostToolUseFailure"
|
||||||
|
PostToolBatch Event = "PostToolBatch"
|
||||||
|
Stop Event = "Stop"
|
||||||
|
SubagentStart Event = "SubagentStart"
|
||||||
|
SubagentStop Event = "SubagentStop"
|
||||||
|
PreCompact Event = "PreCompact"
|
||||||
|
SessionEnd Event = "SessionEnd"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Decision string
|
||||||
|
|
||||||
|
const (
|
||||||
|
Allow Decision = "allow"
|
||||||
|
Deny Decision = "deny"
|
||||||
|
Ask Decision = "ask"
|
||||||
|
Modify Decision = "modify"
|
||||||
|
Block Decision = "block"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Call 是一次事件。工具字段只在工具事件上有意义。
|
||||||
|
type Call struct {
|
||||||
|
Event Event
|
||||||
|
Tool string
|
||||||
|
Args string
|
||||||
|
Result string
|
||||||
|
IsError bool
|
||||||
|
Turn int
|
||||||
|
LastText string
|
||||||
|
Continuations int
|
||||||
|
ToolCalls int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Outcome 是一个处理函数的表态。空 Decision 表示不插手。
|
||||||
|
type Outcome struct {
|
||||||
|
Decision Decision
|
||||||
|
Reason string
|
||||||
|
Args string
|
||||||
|
Notice string
|
||||||
|
Replacement string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Handler func(Call) Outcome
|
||||||
|
|
||||||
|
type Bus struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
hs []Handler
|
||||||
|
}
|
||||||
|
|
||||||
|
func New() *Bus { return &Bus{} }
|
||||||
|
|
||||||
|
func (b *Bus) On(h Handler) {
|
||||||
|
if b == nil || h == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.mu.Lock()
|
||||||
|
b.hs = append(b.hs, h)
|
||||||
|
b.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit 按优先级合并表态。deny 与 block 同级,先登记的保留。
|
||||||
|
func (b *Bus) Emit(c Call) Outcome {
|
||||||
|
if b == nil {
|
||||||
|
return Outcome{}
|
||||||
|
}
|
||||||
|
b.mu.Lock()
|
||||||
|
hs := append([]Handler(nil), b.hs...)
|
||||||
|
b.mu.Unlock()
|
||||||
|
var merged Outcome
|
||||||
|
for _, h := range hs {
|
||||||
|
merged = prefer(merged, h(c))
|
||||||
|
}
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
func prefer(cur, next Outcome) Outcome {
|
||||||
|
if rank(next.Decision) > rank(cur.Decision) {
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
return cur
|
||||||
|
}
|
||||||
|
|
||||||
|
func rank(d Decision) int {
|
||||||
|
switch d {
|
||||||
|
case Block, Deny:
|
||||||
|
return 4
|
||||||
|
case Ask:
|
||||||
|
return 3
|
||||||
|
case Modify:
|
||||||
|
return 2
|
||||||
|
case Allow:
|
||||||
|
return 1
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdaptBefore 把「返回错误即拒绝」收成 PreToolUse 处理函数。
|
||||||
|
func AdaptBefore(fn func(name, args string) error) Handler {
|
||||||
|
return func(c Call) Outcome {
|
||||||
|
if c.Event != PreToolUse || fn == nil {
|
||||||
|
return Outcome{}
|
||||||
|
}
|
||||||
|
if err := fn(c.Tool, c.Args); err != nil {
|
||||||
|
return Outcome{Decision: Deny, Reason: err.Error()}
|
||||||
|
}
|
||||||
|
return Outcome{Decision: Allow}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdaptAfter 把工具结果观察收成 PostToolUse 或失败事件。它不改写结果。
|
||||||
|
func AdaptAfter(fn func(name, args, result string, isError bool)) Handler {
|
||||||
|
return func(c Call) Outcome {
|
||||||
|
if fn == nil {
|
||||||
|
return Outcome{}
|
||||||
|
}
|
||||||
|
switch c.Event {
|
||||||
|
case PostToolUse, PostToolUseFailure:
|
||||||
|
fn(c.Tool, c.Args, c.Result, c.IsError)
|
||||||
|
}
|
||||||
|
return Outcome{}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package hooks
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestDenyBeatsAllow(t *testing.T) {
|
||||||
|
b := New()
|
||||||
|
b.On(func(Call) Outcome { return Outcome{Decision: Allow} })
|
||||||
|
b.On(func(c Call) Outcome {
|
||||||
|
if c.Event == PreToolUse {
|
||||||
|
return Outcome{Decision: Deny, Reason: "no"}
|
||||||
|
}
|
||||||
|
return Outcome{}
|
||||||
|
})
|
||||||
|
out := b.Emit(Call{Event: PreToolUse, Tool: "write_file"})
|
||||||
|
if out.Decision != Deny || out.Reason != "no" {
|
||||||
|
t.Fatalf("%+v", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdaptBefore(t *testing.T) {
|
||||||
|
h := AdaptBefore(func(name, args string) error {
|
||||||
|
if name == "write_file" {
|
||||||
|
return errString("blocked")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if h(Call{Event: PreToolUse, Tool: "read_file"}).Decision != Allow {
|
||||||
|
t.Fatal("read")
|
||||||
|
}
|
||||||
|
if h(Call{Event: PreToolUse, Tool: "write_file"}).Decision != Deny {
|
||||||
|
t.Fatal("write")
|
||||||
|
}
|
||||||
|
if h(Call{Event: Stop}).Decision != "" {
|
||||||
|
t.Fatal("stop should be ignored")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type errString string
|
||||||
|
|
||||||
|
func (e errString) Error() string { return string(e) }
|
||||||
@@ -0,0 +1,404 @@
|
|||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// anthropic.go — 原生 Anthropic Messages API 客户端(N2 WP2)。
|
||||||
|
// 与 OpenAI 路径共用 Message/Event 表面;组包与 BuildBody 的 Anthropic
|
||||||
|
// 分支是同一个函数(消灭「离线/线上两张皮」,审计 N4 的根因)。
|
||||||
|
|
||||||
|
// AnthropicVersion 是请求头 anthropic-version 的钉住值。
|
||||||
|
const AnthropicVersion = "2023-06-01"
|
||||||
|
|
||||||
|
// anthropicBody 组 Anthropic Messages 请求体(线上 Client 与离线 BuildBody
|
||||||
|
// 共用)。tools 挂 input_schema;system 与最后一个 tool 挂 cache_control
|
||||||
|
// (静态前缀的缓存断点);assistant 的思考块在内容块最前且带签名往返。
|
||||||
|
func anthropicBody(model string, messages []Message, tools []ToolDecl, opts StreamOptions) ([]byte, error) {
|
||||||
|
sys, msgs := anthropicMessages(messages)
|
||||||
|
body := map[string]any{
|
||||||
|
"model": model,
|
||||||
|
"messages": msgs,
|
||||||
|
"stream": true,
|
||||||
|
// Anthropic 要求 max_tokens。0 表示调用方不限制:用足够大的默认值,
|
||||||
|
// 避免 4096 把长回复截断。真正的上限由调用方 StreamOptions.MaxTokens 决定。
|
||||||
|
"max_tokens": opts.MaxTokens,
|
||||||
|
}
|
||||||
|
if body["max_tokens"] == 0 {
|
||||||
|
body["max_tokens"] = 128000
|
||||||
|
}
|
||||||
|
if sys != "" {
|
||||||
|
body["system"] = []map[string]any{{
|
||||||
|
"type": "text",
|
||||||
|
"text": sys,
|
||||||
|
"cache_control": map[string]any{"type": "ephemeral"},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
if budget := AnthropicThinkingBudget(opts.ThinkingLevel); budget > 0 {
|
||||||
|
body["thinking"] = map[string]any{"type": "enabled", "budget_tokens": budget}
|
||||||
|
}
|
||||||
|
if len(tools) > 0 {
|
||||||
|
body["tools"] = anthropicTools(tools)
|
||||||
|
}
|
||||||
|
return json.Marshal(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// anthropicTools 声明工具(name/description/input_schema);最后一个挂
|
||||||
|
// cache_control 作为静态工具前缀的断点。
|
||||||
|
func anthropicTools(decls []ToolDecl) []map[string]any {
|
||||||
|
out := make([]map[string]any, 0, len(decls))
|
||||||
|
for i, t := range decls {
|
||||||
|
params := t.Parameters
|
||||||
|
if len(params) == 0 {
|
||||||
|
params = json.RawMessage(`{"type":"object","properties":{}}`)
|
||||||
|
}
|
||||||
|
item := map[string]any{
|
||||||
|
"name": t.Name,
|
||||||
|
"description": t.Description,
|
||||||
|
"input_schema": json.RawMessage(params),
|
||||||
|
}
|
||||||
|
if i == len(decls)-1 {
|
||||||
|
item["cache_control"] = map[string]any{"type": "ephemeral"}
|
||||||
|
}
|
||||||
|
out = append(out, item)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// anthropicMessages 把通用转录映射成 Anthropic 消息:
|
||||||
|
// system 收成字符串(由调用方决定挂法);assistant 的思考块在最前,
|
||||||
|
// 其后 text/image 与 tool_use;连续 RoleTool 折叠成一条 user 消息内的
|
||||||
|
// tool_result 块组(Anthropic 不允许 tool 角色)。
|
||||||
|
func anthropicMessages(messages []Message) (string, []map[string]any) {
|
||||||
|
var system strings.Builder
|
||||||
|
var out []map[string]any
|
||||||
|
for i := 0; i < len(messages); i++ {
|
||||||
|
m := messages[i]
|
||||||
|
switch m.Role {
|
||||||
|
case RoleSystem:
|
||||||
|
if system.Len() > 0 {
|
||||||
|
system.WriteString("\n")
|
||||||
|
}
|
||||||
|
system.WriteString(m.Content)
|
||||||
|
case RoleAssistant:
|
||||||
|
var content []map[string]any
|
||||||
|
for _, th := range m.Thinking {
|
||||||
|
if th.Redacted {
|
||||||
|
content = append(content, map[string]any{"type": "redacted_thinking", "data": th.Data})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
content = append(content, map[string]any{
|
||||||
|
"type": "thinking", "thinking": th.Thinking, "signature": th.Signature,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if m.Content != "" {
|
||||||
|
content = append(content, map[string]any{"type": "text", "text": m.Content})
|
||||||
|
}
|
||||||
|
for _, img := range m.Images {
|
||||||
|
content = append(content, map[string]any{
|
||||||
|
"type": "image",
|
||||||
|
"source": map[string]any{
|
||||||
|
"type": "base64",
|
||||||
|
"media_type": firstNonEmpty(img.MIME, "image/png"),
|
||||||
|
"data": img.Data,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for _, tc := range m.ToolCalls {
|
||||||
|
args := json.RawMessage(tc.Arguments)
|
||||||
|
if !json.Valid(args) {
|
||||||
|
args = json.RawMessage(`{}`)
|
||||||
|
}
|
||||||
|
content = append(content, map[string]any{
|
||||||
|
"type": "tool_use", "id": tc.ID, "name": tc.Name, "input": args,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if len(content) == 0 {
|
||||||
|
content = append(content, map[string]any{"type": "text", "text": ""})
|
||||||
|
}
|
||||||
|
out = append(out, map[string]any{"role": "assistant", "content": content})
|
||||||
|
case RoleTool:
|
||||||
|
// 连续 tool 结果折叠进同一条 user 消息。
|
||||||
|
var results []map[string]any
|
||||||
|
for ; i < len(messages) && messages[i].Role == RoleTool; i++ {
|
||||||
|
t := messages[i]
|
||||||
|
results = append(results, map[string]any{
|
||||||
|
"type": "tool_result",
|
||||||
|
"tool_use_id": t.ToolCallID,
|
||||||
|
"content": t.Content,
|
||||||
|
"is_error": t.IsError,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
i--
|
||||||
|
out = append(out, map[string]any{"role": "user", "content": results})
|
||||||
|
default:
|
||||||
|
content := []map[string]any{}
|
||||||
|
if m.Content != "" {
|
||||||
|
content = append(content, map[string]any{"type": "text", "text": m.Content})
|
||||||
|
}
|
||||||
|
for _, img := range m.Images {
|
||||||
|
content = append(content, map[string]any{
|
||||||
|
"type": "image",
|
||||||
|
"source": map[string]any{
|
||||||
|
"type": "base64",
|
||||||
|
"media_type": firstNonEmpty(img.MIME, "image/png"),
|
||||||
|
"data": img.Data,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
out = append(out, map[string]any{"role": "user", "content": content})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return system.String(), out
|
||||||
|
}
|
||||||
|
|
||||||
|
// streamAnthropic 发 POST {base}/messages 并把 Anthropic SSE 事件族映射到
|
||||||
|
// 通用 Event。与 OpenAI 客户端共享错误/中止语义。
|
||||||
|
func (c *Client) streamAnthropic(ctx context.Context, model string, messages []Message, tools []ToolDecl, opts StreamOptions, out chan<- Event) {
|
||||||
|
base := firstNonEmpty(opts.APIBase, c.BaseURL)
|
||||||
|
key := firstNonEmpty(opts.APIKey, c.APIKey)
|
||||||
|
if base == "" || key == "" || model == "" {
|
||||||
|
emitError(out, "", false, "agentx llm client is missing base url, api key, or model")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if opts.MessageShape != "" {
|
||||||
|
messages = PrepareMessages(messages, opts.MessageShape)
|
||||||
|
}
|
||||||
|
raw, err := anthropicBody(model, messages, tools, opts)
|
||||||
|
if err != nil {
|
||||||
|
emitError(out, "", false, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(base, "/")+"/messages", bytes.NewReader(raw))
|
||||||
|
if err != nil {
|
||||||
|
emitError(out, "", false, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Header.Set("x-api-key", key)
|
||||||
|
req.Header.Set("anthropic-version", AnthropicVersion)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Accept", "text/event-stream")
|
||||||
|
hc := c.HTTPClient
|
||||||
|
if hc == nil {
|
||||||
|
hc = &http.Client{Timeout: 30 * time.Minute}
|
||||||
|
}
|
||||||
|
resp, err := hc.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
emitAbort(out, "", ctx.Err())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
emitError(out, "", true, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
slurp, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||||
|
msg := fmt.Sprintf("provider status %d: %s", resp.StatusCode, strings.TrimSpace(string(slurp)))
|
||||||
|
ev := Event{
|
||||||
|
Kind: KindError,
|
||||||
|
HTTPStatus: resp.StatusCode,
|
||||||
|
Retryable: StatusRetryable(resp.StatusCode) && !ContextOverflow(msg),
|
||||||
|
ErrorMessage: msg,
|
||||||
|
Err: &StreamError{Message: msg, Retryable: StatusRetryable(resp.StatusCode) && !ContextOverflow(msg)},
|
||||||
|
Message: Message{Role: RoleAssistant, StopReason: StopError},
|
||||||
|
}
|
||||||
|
if d, ok := ParseRetryAfter(resp.Header.Get("Retry-After"), time.Now()); ok {
|
||||||
|
ev.HasRetryAfter = true
|
||||||
|
ev.RetryAfter = d
|
||||||
|
}
|
||||||
|
out <- ev
|
||||||
|
return
|
||||||
|
}
|
||||||
|
parseAnthropicSSE(ctx, resp.Body, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Anthropic SSE 解析 ----
|
||||||
|
|
||||||
|
type anthropicBlock struct {
|
||||||
|
typ string
|
||||||
|
toolID string
|
||||||
|
toolName string
|
||||||
|
args strings.Builder
|
||||||
|
thinking strings.Builder
|
||||||
|
signature strings.Builder
|
||||||
|
redacted string
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseAnthropicSSE 读 event:/data: 帧对,产出文本/思考/工具增量与一次
|
||||||
|
// 终结 message_end(或 error)。
|
||||||
|
func parseAnthropicSSE(ctx context.Context, r io.Reader, out chan<- Event) {
|
||||||
|
blocks := map[int]*anthropicBlock{}
|
||||||
|
var content strings.Builder
|
||||||
|
var thinking []ThinkingBlock
|
||||||
|
var calls []ToolCall
|
||||||
|
var usage Usage
|
||||||
|
stopReason := StopEnd
|
||||||
|
finishReason := ""
|
||||||
|
sc := bufio.NewScanner(r)
|
||||||
|
sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
|
||||||
|
eventType := ""
|
||||||
|
for sc.Scan() {
|
||||||
|
line := sc.Text()
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
emitAbort(out, "", ctx.Err())
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(line, "event:"):
|
||||||
|
eventType = strings.TrimSpace(strings.TrimPrefix(line, "event:"))
|
||||||
|
case strings.HasPrefix(line, "data:"):
|
||||||
|
data := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
|
||||||
|
if data == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var frame map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal([]byte(data), &frame); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch eventType {
|
||||||
|
case "message_start":
|
||||||
|
var v struct {
|
||||||
|
Message struct {
|
||||||
|
Usage struct {
|
||||||
|
InputTokens int `json:"input_tokens"`
|
||||||
|
CacheReadTokens int `json:"cache_read_input_tokens"`
|
||||||
|
CacheWriteTokens int `json:"cache_creation_input_tokens"`
|
||||||
|
} `json:"usage"`
|
||||||
|
} `json:"message"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(frame["message"], &v.Message) == nil {
|
||||||
|
usage.PromptTokens = v.Message.Usage.InputTokens
|
||||||
|
usage.CacheReadTokens = v.Message.Usage.CacheReadTokens
|
||||||
|
usage.CacheWriteTokens = v.Message.Usage.CacheWriteTokens
|
||||||
|
}
|
||||||
|
case "content_block_start":
|
||||||
|
var v struct {
|
||||||
|
Index int `json:"index"`
|
||||||
|
ContentBlock struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Data string `json:"data"`
|
||||||
|
} `json:"content_block"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(frame["index"], &v.Index) == nil {
|
||||||
|
_ = json.Unmarshal(frame["content_block"], &v.ContentBlock)
|
||||||
|
b := &anthropicBlock{typ: v.ContentBlock.Type, toolID: v.ContentBlock.ID, toolName: v.ContentBlock.Name, redacted: v.ContentBlock.Data}
|
||||||
|
blocks[v.Index] = b
|
||||||
|
if v.ContentBlock.Type == "tool_use" {
|
||||||
|
out <- Event{Kind: KindToolCallDelta, ToolIndex: v.Index, ToolID: v.ContentBlock.ID, ToolName: v.ContentBlock.Name}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "content_block_delta":
|
||||||
|
var v struct {
|
||||||
|
Index int `json:"index"`
|
||||||
|
Delta struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
Thinking string `json:"thinking"`
|
||||||
|
Signature string `json:"signature"`
|
||||||
|
Partial string `json:"partial_json"`
|
||||||
|
} `json:"delta"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(frame["index"], &v.Index) != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(frame["delta"], &v.Delta)
|
||||||
|
b := blocks[v.Index]
|
||||||
|
switch v.Delta.Type {
|
||||||
|
case "text_delta":
|
||||||
|
content.WriteString(v.Delta.Text)
|
||||||
|
out <- Event{Kind: KindTextDelta, Text: v.Delta.Text}
|
||||||
|
case "thinking_delta":
|
||||||
|
if b != nil {
|
||||||
|
b.thinking.WriteString(v.Delta.Thinking)
|
||||||
|
}
|
||||||
|
out <- Event{Kind: KindReasoningDelta, ReasoningDelta: v.Delta.Thinking}
|
||||||
|
case "signature_delta":
|
||||||
|
if b != nil {
|
||||||
|
b.signature.WriteString(v.Delta.Signature)
|
||||||
|
}
|
||||||
|
case "input_json_delta":
|
||||||
|
if b != nil {
|
||||||
|
b.args.WriteString(v.Delta.Partial)
|
||||||
|
}
|
||||||
|
out <- Event{Kind: KindToolCallDelta, ToolIndex: v.Index, ArgumentsDelta: v.Delta.Partial}
|
||||||
|
}
|
||||||
|
case "content_block_stop":
|
||||||
|
var v struct {
|
||||||
|
Index int `json:"index"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(frame["index"], &v.Index) != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b := blocks[v.Index]
|
||||||
|
if b == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch b.typ {
|
||||||
|
case "tool_use":
|
||||||
|
calls = append(calls, ToolCall{ID: b.toolID, Name: b.toolName, Arguments: b.args.String()})
|
||||||
|
case "thinking":
|
||||||
|
thinking = append(thinking, ThinkingBlock{Thinking: b.thinking.String(), Signature: b.signature.String()})
|
||||||
|
case "redacted_thinking":
|
||||||
|
thinking = append(thinking, ThinkingBlock{Redacted: true, Data: b.redacted})
|
||||||
|
}
|
||||||
|
case "message_delta":
|
||||||
|
var v struct {
|
||||||
|
Delta struct {
|
||||||
|
StopReason string `json:"stop_reason"`
|
||||||
|
} `json:"delta"`
|
||||||
|
Usage struct {
|
||||||
|
OutputTokens int `json:"output_tokens"`
|
||||||
|
} `json:"usage"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(frame["delta"], &v.Delta)
|
||||||
|
_ = json.Unmarshal(frame["usage"], &v.Usage)
|
||||||
|
finishReason = v.Delta.StopReason
|
||||||
|
usage.CompletionTokens = v.Usage.OutputTokens
|
||||||
|
if v.Delta.StopReason == "tool_use" {
|
||||||
|
stopReason = StopToolCalls
|
||||||
|
}
|
||||||
|
case "error":
|
||||||
|
msg := data
|
||||||
|
var v struct {
|
||||||
|
Error struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
} `json:"error"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(frame["error"], &v.Error) == nil && v.Error.Message != "" {
|
||||||
|
msg = v.Error.Type + ": " + v.Error.Message
|
||||||
|
}
|
||||||
|
out <- Event{Kind: KindError, ErrorMessage: msg, Err: &StreamError{Message: msg}, Message: Message{Role: RoleAssistant, StopReason: StopError}}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := sc.Err(); err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
emitAbort(out, "", ctx.Err())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
emitError(out, "", true, "anthropic sse read: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
|
||||||
|
out <- Event{
|
||||||
|
Kind: KindMessageEnd,
|
||||||
|
Message: Message{Role: RoleAssistant, Content: content.String(), ToolCalls: calls, Thinking: thinking, StopReason: stopReason},
|
||||||
|
Usage: usage,
|
||||||
|
FinishReason: finishReason,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- N2 WP2:Anthropic 原生客户端(验收① 的机器证据)----
|
||||||
|
|
||||||
|
// fixtureServer 按调用序回放 testdata/anthropic 下的录制 SSE,并记录请求。
|
||||||
|
type fixtureServer struct {
|
||||||
|
t *testing.T
|
||||||
|
requests []recordedRequest
|
||||||
|
count int
|
||||||
|
}
|
||||||
|
|
||||||
|
type recordedRequest struct {
|
||||||
|
path string
|
||||||
|
apiKey string
|
||||||
|
version string
|
||||||
|
body map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fixtureServer) handler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
f.count++
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
var parsed map[string]any
|
||||||
|
_ = json.Unmarshal(body, &parsed)
|
||||||
|
f.requests = append(f.requests, recordedRequest{
|
||||||
|
path: r.URL.Path,
|
||||||
|
apiKey: r.Header.Get("x-api-key"),
|
||||||
|
version: r.Header.Get("anthropic-version"),
|
||||||
|
body: parsed,
|
||||||
|
})
|
||||||
|
names := []string{"01-tool-use.sse", "02-thinking-text.sse", "03-text.sse"}
|
||||||
|
raw, err := os.ReadFile(filepath.Join("testdata", "anthropic", names[f.count-1]))
|
||||||
|
if err != nil {
|
||||||
|
f.t.Fatalf("fixture %d: %v", f.count, err)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
_, _ = w.Write(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
// anthropicPresetClient 经预设表构建客户端(验收的是「选 anthropic 预设」本身),
|
||||||
|
// BaseURL 指到夹具服务器(预设只是结构体,测试内覆盖端点,不打真实网络)。
|
||||||
|
func anthropicPresetClient(t *testing.T, base string) *Client {
|
||||||
|
t.Helper()
|
||||||
|
var preset Provider
|
||||||
|
for _, p := range Presets() {
|
||||||
|
if p.Name == "anthropic" {
|
||||||
|
preset = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if preset.Name == "" {
|
||||||
|
t.Fatal("anthropic preset missing")
|
||||||
|
}
|
||||||
|
preset.BaseURL = base
|
||||||
|
preset.APIKey = "fixture-key"
|
||||||
|
c, err := ClientFor(preset)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ClientFor: %v", err)
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func collect(t *testing.T, ch <-chan Event) (end Event, texts, reasoning []string) {
|
||||||
|
t.Helper()
|
||||||
|
for ev := range ch {
|
||||||
|
switch ev.Kind {
|
||||||
|
case KindTextDelta:
|
||||||
|
texts = append(texts, ev.Text)
|
||||||
|
case KindReasoningDelta:
|
||||||
|
reasoning = append(reasoning, ev.ReasoningDelta)
|
||||||
|
case KindMessageEnd:
|
||||||
|
end = ev
|
||||||
|
case KindError:
|
||||||
|
t.Fatalf("stream error: %s", ev.ErrorMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return end, texts, reasoning
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAnthropicPresetToolRound 验收①:选 anthropic 预设,用录制 HTTP 夹具
|
||||||
|
// 完成一轮完整工具调用(tools 请求 → tool_use → tool_result → 终文本),
|
||||||
|
// 且 thinking 块带原签名在下一请求里往返。
|
||||||
|
func TestAnthropicPresetToolRound(t *testing.T) {
|
||||||
|
fs := &fixtureServer{t: t}
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(fs.handler))
|
||||||
|
defer srv.Close()
|
||||||
|
c := anthropicPresetClient(t, srv.URL)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
tools := []ToolDecl{{Name: "read_file", Description: "读文件", Parameters: json.RawMessage(`{"type":"object","properties":{"path":{"type":"string"}}}`)}}
|
||||||
|
|
||||||
|
// 第一回合:带 tools 请求 → tool_use。
|
||||||
|
end1, _, _ := collect(t, c.Stream(ctx, "claude-fixture", []Message{{Role: RoleUser, Content: "查一下 main.go"}}, tools, StreamOptions{}))
|
||||||
|
if end1.Message.StopReason != StopToolCalls || len(end1.Message.ToolCalls) != 1 {
|
||||||
|
t.Fatalf("resp1: %+v", end1.Message)
|
||||||
|
}
|
||||||
|
call := end1.Message.ToolCalls[0]
|
||||||
|
if call.ID != "toolu_01ABC" || call.Name != "read_file" || call.Arguments != `{"path":"main.go"}` {
|
||||||
|
t.Fatalf("tool call: %+v", call)
|
||||||
|
}
|
||||||
|
|
||||||
|
req1 := fs.requests[0]
|
||||||
|
if req1.path != "/messages" || req1.apiKey != "fixture-key" || req1.version != AnthropicVersion {
|
||||||
|
t.Fatalf("req1 headers: %+v", req1)
|
||||||
|
}
|
||||||
|
toolsBody, _ := req1.body["tools"].([]any)
|
||||||
|
if len(toolsBody) != 1 {
|
||||||
|
t.Fatalf("req1 tools: %v", req1.body["tools"])
|
||||||
|
}
|
||||||
|
tool0 := toolsBody[0].(map[string]any)
|
||||||
|
if tool0["name"] != "read_file" || tool0["input_schema"] == nil || tool0["cache_control"] == nil {
|
||||||
|
t.Fatalf("req1 tool decl: %v", tool0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 第二回合:assistant(tool_use) + tool_result → thinking(带签名) + 终文本。
|
||||||
|
msgs2 := []Message{
|
||||||
|
{Role: RoleUser, Content: "查一下 main.go"},
|
||||||
|
end1.Message,
|
||||||
|
{Role: RoleTool, ToolCallID: call.ID, Name: call.Name, Content: "1 package main\n..."},
|
||||||
|
}
|
||||||
|
end2, texts2, reasoning2 := collect(t, c.Stream(ctx, "claude-fixture", msgs2, tools, StreamOptions{}))
|
||||||
|
if end2.Message.StopReason != StopEnd || end2.Message.Content != "查到了,main.go 一共 42 行。" {
|
||||||
|
t.Fatalf("resp2: %+v", end2.Message)
|
||||||
|
}
|
||||||
|
if len(end2.Message.Thinking) != 1 || end2.Message.Thinking[0].Signature != "EqoBCkgIARABGAIiQA" {
|
||||||
|
t.Fatalf("resp2 thinking: %+v", end2.Message.Thinking)
|
||||||
|
}
|
||||||
|
if strings.Join(texts2, "") != end2.Message.Content || len(reasoning2) == 0 {
|
||||||
|
t.Fatalf("deltas: %v %v", texts2, reasoning2)
|
||||||
|
}
|
||||||
|
|
||||||
|
req2 := fs.requests[1]
|
||||||
|
msgsBody, _ := req2.body["messages"].([]any)
|
||||||
|
var toolResult map[string]any
|
||||||
|
for _, m := range msgsBody {
|
||||||
|
mm := m.(map[string]any)
|
||||||
|
if mm["role"] != "user" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, blk := range mm["content"].([]any) {
|
||||||
|
b := blk.(map[string]any)
|
||||||
|
if b["type"] == "tool_result" {
|
||||||
|
toolResult = b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if toolResult == nil || toolResult["tool_use_id"] != "toolu_01ABC" {
|
||||||
|
t.Fatalf("req2 tool_result: %v", toolResult)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 第三回合:assistant(thinking+text) 回送 → 签名必须原样往返。
|
||||||
|
msgs3 := append(append([]Message{}, msgs2...), end2.Message, Message{Role: RoleUser, Content: "再说一遍"})
|
||||||
|
end3, _, _ := collect(t, c.Stream(ctx, "claude-fixture", msgs3, tools, StreamOptions{}))
|
||||||
|
if end3.Message.Content != "42 行。" {
|
||||||
|
t.Fatalf("resp3: %+v", end3.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
req3 := fs.requests[2]
|
||||||
|
var thinkingBlock map[string]any
|
||||||
|
for _, m := range req3.body["messages"].([]any) {
|
||||||
|
mm := m.(map[string]any)
|
||||||
|
if mm["role"] != "assistant" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, blk := range mm["content"].([]any) {
|
||||||
|
b := blk.(map[string]any)
|
||||||
|
if b["type"] == "thinking" {
|
||||||
|
thinkingBlock = b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if thinkingBlock == nil || thinkingBlock["signature"] != "EqoBCkgIARABGAIiQA" {
|
||||||
|
t.Fatalf("req3 thinking round-trip: %v", thinkingBlock)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAnthropicBodyShape 组包单测:tool_use/tool_result/thinking 块形态、
|
||||||
|
// system 与末位工具的 cache_control、max_tokens 默认。
|
||||||
|
func TestAnthropicBodyShape(t *testing.T) {
|
||||||
|
msgs := []Message{
|
||||||
|
{Role: RoleSystem, Content: "sys"},
|
||||||
|
{Role: RoleUser, Content: "u"},
|
||||||
|
{Role: RoleAssistant, Thinking: []ThinkingBlock{{Thinking: "想", Signature: "sig"}}, ToolCalls: []ToolCall{{ID: "t1", Name: "read", Arguments: `{"a":1}`}}},
|
||||||
|
{Role: RoleTool, ToolCallID: "t1", Name: "read", Content: "r1"},
|
||||||
|
{Role: RoleTool, ToolCallID: "t2", Name: "grep", Content: "r2", IsError: true},
|
||||||
|
}
|
||||||
|
raw, err := anthropicBody("claude", msgs, []ToolDecl{{Name: "read", Description: "读"}}, StreamOptions{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var body map[string]any
|
||||||
|
if err := json.Unmarshal(raw, &body); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if body["max_tokens"] != float64(128000) {
|
||||||
|
t.Fatalf("max_tokens 默认:%v", body["max_tokens"])
|
||||||
|
}
|
||||||
|
sys := body["system"].([]any)[0].(map[string]any)
|
||||||
|
if sys["cache_control"] == nil {
|
||||||
|
t.Fatal("system 应挂 cache_control")
|
||||||
|
}
|
||||||
|
out := body["messages"].([]any)
|
||||||
|
// assistant:思考块在最前,其后 tool_use。
|
||||||
|
assistant := out[1].(map[string]any)
|
||||||
|
blocks := assistant["content"].([]any)
|
||||||
|
if blocks[0].(map[string]any)["type"] != "thinking" || blocks[1].(map[string]any)["type"] != "tool_use" {
|
||||||
|
t.Fatalf("assistant 块序:%v", blocks)
|
||||||
|
}
|
||||||
|
// 连续两条 tool 结果折叠进同一条 user 消息。
|
||||||
|
folded := out[2].(map[string]any)
|
||||||
|
if folded["role"] != "user" || len(folded["content"].([]any)) != 2 {
|
||||||
|
t.Fatalf("tool_result 折叠:%v", folded)
|
||||||
|
}
|
||||||
|
second := folded["content"].([]any)[1].(map[string]any)
|
||||||
|
if second["type"] != "tool_result" || second["is_error"] != true {
|
||||||
|
t.Fatalf("tool_result is_error:%v", second)
|
||||||
|
}
|
||||||
|
// 末位工具挂 cache_control。
|
||||||
|
tool := body["tools"].([]any)[0].(map[string]any)
|
||||||
|
if tool["cache_control"] == nil {
|
||||||
|
t.Fatal("末位工具应挂 cache_control")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAnthropicSSEErrorFrame 协议内 error 帧映射为 KindError。
|
||||||
|
func TestAnthropicSSEErrorFrame(t *testing.T) {
|
||||||
|
raw := "event: error\ndata: {\"type\":\"error\",\"error\":{\"type\":\"overloaded_error\",\"message\":\"Overloaded\"}}\n\n"
|
||||||
|
ch := make(chan Event, 1)
|
||||||
|
go func() {
|
||||||
|
defer close(ch)
|
||||||
|
parseAnthropicSSE(context.Background(), strings.NewReader(raw), ch)
|
||||||
|
}()
|
||||||
|
var got []Event
|
||||||
|
for ev := range ch {
|
||||||
|
got = append(got, ev)
|
||||||
|
}
|
||||||
|
if len(got) != 1 || got[0].Kind != KindError || !strings.Contains(got[0].ErrorMessage, "Overloaded") {
|
||||||
|
t.Fatalf("%+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package llm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxObservedBodyBytes = 32*1024*1024 + 1
|
||||||
|
|
||||||
|
// bodyObserver counts the bytes a reader returns from an HTTP response body
|
||||||
|
// and whether it reached io.EOF. It never copies or keeps the bytes.
|
||||||
|
type bodyObserver struct {
|
||||||
|
src io.Reader
|
||||||
|
protocol string
|
||||||
|
framing string
|
||||||
|
declared int64
|
||||||
|
uncompressed bool
|
||||||
|
bytes int64
|
||||||
|
saturated bool
|
||||||
|
eof bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// newBodyObserver classifies only what net/http exposes on the response;
|
||||||
|
// anything it cannot see reliably is recorded as unknown.
|
||||||
|
func newBodyObserver(resp *http.Response) *bodyObserver {
|
||||||
|
o := &bodyObserver{src: resp.Body, protocol: "other", framing: "unknown",
|
||||||
|
declared: resp.ContentLength, uncompressed: resp.Uncompressed}
|
||||||
|
switch resp.ProtoMajor {
|
||||||
|
case 1:
|
||||||
|
o.protocol = "http1"
|
||||||
|
switch {
|
||||||
|
case slices.Contains(resp.TransferEncoding, "chunked"):
|
||||||
|
o.framing = "chunked"
|
||||||
|
case resp.Uncompressed:
|
||||||
|
// Transparent decompression hides the wire framing and length.
|
||||||
|
case resp.ContentLength >= 0:
|
||||||
|
o.framing = "known_length"
|
||||||
|
case resp.Close:
|
||||||
|
o.framing = "close_delimited"
|
||||||
|
}
|
||||||
|
case 2:
|
||||||
|
o.protocol = "http2"
|
||||||
|
o.framing = "http2_stream"
|
||||||
|
}
|
||||||
|
return o
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *bodyObserver) Read(p []byte) (int, error) {
|
||||||
|
n, err := o.src.Read(p)
|
||||||
|
if n > 0 && !o.saturated {
|
||||||
|
if int64(n) >= maxObservedBodyBytes-o.bytes {
|
||||||
|
o.bytes = maxObservedBodyBytes
|
||||||
|
o.saturated = true
|
||||||
|
} else {
|
||||||
|
o.bytes += int64(n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err == io.EOF {
|
||||||
|
o.eof = true
|
||||||
|
}
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// snapshot must be taken after the last Read has returned.
|
||||||
|
func (o *bodyObserver) snapshot() *BodyBoundaryObservation {
|
||||||
|
relation := "unknown"
|
||||||
|
if o.declared >= 0 && !o.uncompressed && !o.saturated && o.eof {
|
||||||
|
relation = "mismatch"
|
||||||
|
if o.bytes == o.declared {
|
||||||
|
relation = "match"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &BodyBoundaryObservation{
|
||||||
|
ProtocolClass: o.protocol, FramingClass: o.framing,
|
||||||
|
DeclaredLengthKnown: o.declared >= 0, BytesRead: o.bytes,
|
||||||
|
BytesSaturated: o.saturated, EOFSeen: o.eof,
|
||||||
|
LengthRelation: relation, Uncompressed: o.uncompressed,
|
||||||
|
}
|
||||||
|
}
|
||||||
+98
-8
@@ -11,11 +11,24 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Client calls an OpenAI-compatible chat completions endpoint with streaming.
|
// Client calls a chat completions endpoint with streaming.
|
||||||
|
// 默认讲 OpenAI 兼容协议;Protocol 为 ProtocolAnthropic 时讲原生
|
||||||
|
// Anthropic Messages(组包与 BuildBody 共用,见 anthropic.go)。
|
||||||
type Client struct {
|
type Client struct {
|
||||||
BaseURL string
|
BaseURL string
|
||||||
APIKey string
|
APIKey string
|
||||||
HTTPClient *http.Client
|
HTTPClient *http.Client
|
||||||
|
// Protocol 空 = openai-chat(零值兼容所有直接构造点)。
|
||||||
|
Protocol Protocol
|
||||||
|
// ReasoningSplit 在 openai-chat 请求体加 "reasoning_split": true:
|
||||||
|
// MiniMax 等 interleaved-thinking 提供商据此把思考放进独立的
|
||||||
|
// reasoning_content 字段而不是混在 content 的 <think> 块里(分流后
|
||||||
|
// SSE 的 reasoning_content 捕获照常生效)。对不认识该字段的服务器
|
||||||
|
// 属未知字段,多数按忽略处理;宿主按模型名自行决定是否置位。
|
||||||
|
ReasoningSplit bool
|
||||||
|
// RouteContract optionally releases tool calls from an openai-chat stream
|
||||||
|
// that ended without [DONE]; nil or the zero value is off.
|
||||||
|
RouteContract *RouteContractEOF
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stream implements StreamFunc. Transport and provider failures are encoded as
|
// Stream implements StreamFunc. Transport and provider failures are encoded as
|
||||||
@@ -24,6 +37,10 @@ func (c *Client) Stream(ctx context.Context, model string, messages []Message, t
|
|||||||
out := make(chan Event)
|
out := make(chan Event)
|
||||||
go func() {
|
go func() {
|
||||||
defer close(out)
|
defer close(out)
|
||||||
|
if c.Protocol == ProtocolAnthropic {
|
||||||
|
c.streamAnthropic(ctx, model, messages, tools, opts, out)
|
||||||
|
return
|
||||||
|
}
|
||||||
c.run(ctx, model, messages, tools, opts, out)
|
c.run(ctx, model, messages, tools, opts, out)
|
||||||
}()
|
}()
|
||||||
return out
|
return out
|
||||||
@@ -42,6 +59,9 @@ func (c *Client) run(ctx context.Context, model string, messages []Message, tool
|
|||||||
emitError(out, "", false, "agentx llm client is missing base url, api key, or model")
|
emitError(out, "", false, "agentx llm client is missing base url, api key, or model")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if opts.MessageShape != "" {
|
||||||
|
messages = PrepareMessages(messages, opts.MessageShape)
|
||||||
|
}
|
||||||
body := map[string]any{
|
body := map[string]any{
|
||||||
"model": model,
|
"model": model,
|
||||||
"messages": toProviderMessages(messages),
|
"messages": toProviderMessages(messages),
|
||||||
@@ -53,6 +73,12 @@ func (c *Client) run(ctx context.Context, model string, messages []Message, tool
|
|||||||
if opts.MaxTokens > 0 {
|
if opts.MaxTokens > 0 {
|
||||||
body["max_tokens"] = opts.MaxTokens
|
body["max_tokens"] = opts.MaxTokens
|
||||||
}
|
}
|
||||||
|
if effort := OpenAIReasoningEffort(opts.ThinkingLevel); effort != "" {
|
||||||
|
body["reasoning_effort"] = effort
|
||||||
|
}
|
||||||
|
if c.ReasoningSplit {
|
||||||
|
body["reasoning_split"] = true
|
||||||
|
}
|
||||||
if len(tools) > 0 {
|
if len(tools) > 0 {
|
||||||
body["tools"] = toProviderTools(tools)
|
body["tools"] = toProviderTools(tools)
|
||||||
body["tool_choice"] = "auto"
|
body["tool_choice"] = "auto"
|
||||||
@@ -77,24 +103,81 @@ func (c *Client) run(ctx context.Context, model string, messages []Message, tool
|
|||||||
resp, err := hc.Do(req)
|
resp, err := hc.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
emitAbort(out, "", ctx.Err())
|
emitAbort(out, "", ctx.Err(), &TerminalObservation{ReadOutcome: "cancelled"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
emitError(out, "", true, err.Error())
|
emitError(out, "", true, err.Error(), &TerminalObservation{ReadOutcome: "transport_error"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
if resp.StatusCode >= 400 {
|
if resp.StatusCode >= 400 {
|
||||||
slurp, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
slurp, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||||
retryable := resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500
|
msg := fmt.Sprintf("provider status %d: %s", resp.StatusCode, strings.TrimSpace(string(slurp)))
|
||||||
emitError(out, "", retryable, fmt.Sprintf("provider status %d: %s", resp.StatusCode, strings.TrimSpace(string(slurp))))
|
ev := Event{
|
||||||
|
Kind: KindError,
|
||||||
|
HTTPStatus: resp.StatusCode,
|
||||||
|
TerminalObservation: &TerminalObservation{ReadOutcome: "provider_error"},
|
||||||
|
Retryable: StatusRetryable(resp.StatusCode) && !ContextOverflow(msg),
|
||||||
|
ErrorMessage: msg,
|
||||||
|
Err: &StreamError{Message: msg, Retryable: StatusRetryable(resp.StatusCode) && !ContextOverflow(msg)},
|
||||||
|
Message: Message{Role: RoleAssistant, StopReason: StopError},
|
||||||
|
}
|
||||||
|
if d, ok := ParseRetryAfter(resp.Header.Get("Retry-After"), time.Now()); ok {
|
||||||
|
ev.HasRetryAfter = true
|
||||||
|
ev.RetryAfter = d
|
||||||
|
}
|
||||||
|
out <- ev
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for ev := range ParseSSE(ctx, resp.Body) {
|
observed := newBodyObserver(resp)
|
||||||
|
contract := c.newRouteContract(model, base, req, resp, tools)
|
||||||
|
var facts sseFacts
|
||||||
|
var hooks sseHooks
|
||||||
|
if contract != nil {
|
||||||
|
hooks.facts = &facts
|
||||||
|
hooks.decideEOF = func(success, failure Event) Event {
|
||||||
|
check := contract.check(observed.snapshot(), failure.TerminalObservation, facts, success.Message.ToolCalls)
|
||||||
|
if check != "" {
|
||||||
|
failure.TerminalObservation.ContractCheck = check
|
||||||
|
return failure
|
||||||
|
}
|
||||||
|
success.CompletionBasis = CompletionRouteContractEOF
|
||||||
|
success.TerminalObservation.CompletionBasis = CompletionRouteContractEOF
|
||||||
|
return success
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for ev := range parseSSE(ctx, observed, hooks) {
|
||||||
|
if ev.Kind == KindMessageEnd || ev.Kind == KindError {
|
||||||
|
ev.HTTPStatus = resp.StatusCode
|
||||||
|
// ParseSSE sends its terminal event only after its last body read.
|
||||||
|
if obs := ev.TerminalObservation; obs != nil {
|
||||||
|
obs.BodyBoundary = observed.snapshot()
|
||||||
|
if ev.Kind == KindMessageEnd && ev.Err == nil && ev.StreamComplete {
|
||||||
|
ev.CompletionBasis = CompletionDoneMarker
|
||||||
|
obs.CompletionBasis = CompletionDoneMarker
|
||||||
|
}
|
||||||
|
if contract != nil && ev.Err != nil && !ev.StreamComplete &&
|
||||||
|
ev.Message.StopReason != StopAborted && obs.ContractCheck == "" {
|
||||||
|
obs.ContractCheck = contract.check(obs.BodyBoundary, obs, facts, ev.Message.ToolCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
out <- ev
|
out <- ev
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// replayThink 拼接一条 assistant 消息捕获到的全部思考块。
|
||||||
|
func replayThink(m Message) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, t := range m.Thinking {
|
||||||
|
if t.Thinking != "" {
|
||||||
|
b.WriteString(t.Thinking)
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.TrimRight(b.String(), "\n")
|
||||||
|
}
|
||||||
|
|
||||||
func toProviderMessages(messages []Message) []map[string]any {
|
func toProviderMessages(messages []Message) []map[string]any {
|
||||||
out := make([]map[string]any, 0, len(messages))
|
out := make([]map[string]any, 0, len(messages))
|
||||||
for _, m := range messages {
|
for _, m := range messages {
|
||||||
@@ -106,7 +189,14 @@ func toProviderMessages(messages []Message) []map[string]any {
|
|||||||
"content": m.Content,
|
"content": m.Content,
|
||||||
})
|
})
|
||||||
case RoleAssistant:
|
case RoleAssistant:
|
||||||
item := map[string]any{"role": "assistant", "content": m.Content}
|
// 回放捕获的思考(Message.Thinking 由 SSE 的 reasoning_content 捕获而来):
|
||||||
|
// 交错思考模型要求历史 assistant 原样带回 <think> 块。没有思考块的
|
||||||
|
// 消息零改动——非思考模型的上下文不受影响。
|
||||||
|
content := m.Content
|
||||||
|
if think := replayThink(m); think != "" {
|
||||||
|
content = "<think>\n" + think + "\n</think>\n\n" + content
|
||||||
|
}
|
||||||
|
item := map[string]any{"role": "assistant", "content": content}
|
||||||
if len(m.ToolCalls) > 0 {
|
if len(m.ToolCalls) > 0 {
|
||||||
calls := make([]map[string]any, 0, len(m.ToolCalls))
|
calls := make([]map[string]any, 0, len(m.ToolCalls))
|
||||||
for _, tc := range m.ToolCalls {
|
for _, tc := range m.ToolCalls {
|
||||||
@@ -125,7 +215,7 @@ func toProviderMessages(messages []Message) []map[string]any {
|
|||||||
default:
|
default:
|
||||||
out = append(out, map[string]any{
|
out = append(out, map[string]any{
|
||||||
"role": string(m.Role),
|
"role": string(m.Role),
|
||||||
"content": m.Content,
|
"content": messageContent(m),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user