Compare commits
66
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -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 .
|
||||
@@ -10,3 +10,6 @@
|
||||
[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"
|
||||
```
|
||||
@@ -34,3 +34,21 @@
|
||||
- **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`
|
||||
|
||||
@@ -42,6 +42,7 @@ git clone --recurse-submodules ssh://git@git-ssh.u1680832.nyat.app:31411/weichen
|
||||
| 模块/接口 | 仓库根:`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` 配置,不证明执行结果 |
|
||||
|
||||
## 环境与数据
|
||||
|
||||
|
||||
@@ -157,6 +157,12 @@ func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result,
|
||||
if b.opts.Ask != 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)
|
||||
@@ -166,7 +172,15 @@ func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result,
|
||||
}
|
||||
|
||||
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 &exec.Result{Success: false, ErrorReason: err.Error()}, nil
|
||||
}
|
||||
workDir = tmp
|
||||
} else if !filepath.IsAbs(workDir) {
|
||||
wd, _ := os.Getwd()
|
||||
workDir = filepath.Join(wd, workDir)
|
||||
}
|
||||
@@ -372,6 +386,7 @@ func (b *Backend) run(ctx context.Context, model string, budget *runtime.Budget,
|
||||
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,
|
||||
@@ -394,6 +409,10 @@ func (b *Backend) run(ctx context.Context, model string, budget *runtime.Budget,
|
||||
Detail: ev.Detail,
|
||||
PromptTokens: ev.PromptTokens,
|
||||
CompletionTokens: ev.CompletionTokens,
|
||||
LastPromptTokens: ev.LastPromptTokens,
|
||||
LastCompletionTokens: ev.LastCompletionTokens,
|
||||
CacheReadTokens: ev.CacheReadTokens,
|
||||
CacheWriteTokens: ev.CacheWriteTokens,
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2,6 +2,8 @@ package native
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -65,3 +67,47 @@ func TestContractDescriptor(t *testing.T) {
|
||||
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(应落临时目录)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,7 +260,7 @@ func TestBackendSkillsIndexAndLoader(t *testing.T) {
|
||||
if err := os.MkdirAll(skillDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\ndescription: 生成文档交付物\n---\n\n配方正文"), 0o644); err != nil {
|
||||
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(
|
||||
@@ -347,7 +347,7 @@ func TestBackendWorkspaceSkillsRootAutoMerged(t *testing.T) {
|
||||
if err := os.MkdirAll(wsSkill, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(wsSkill, "SKILL.md"), []byte("---\ndescription: 工作区技能\n---\n正文"), 0o644); err != nil {
|
||||
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{
|
||||
|
||||
Vendored
+1
-1
Submodule deps/agent-intent updated: 65aaebca1e...651a2e1e03
Vendored
+1
-1
Submodule deps/development-guidelines updated: 26b562d2e3...2688fd2be6
+1
Submodule deps/development-manager added at bd3a7de57a
@@ -57,6 +57,11 @@ type ProcessEvent struct {
|
||||
// PromptTokens / CompletionTokens 在 Type=usage 时填写。
|
||||
PromptTokens int
|
||||
CompletionTokens int
|
||||
// 以下在 Type=usage 或 message_end 时填写(N3 WP6:上一请求与缓存命中拆分)。
|
||||
LastPromptTokens int
|
||||
LastCompletionTokens int
|
||||
CacheReadTokens int
|
||||
CacheWriteTokens int
|
||||
}
|
||||
|
||||
const (
|
||||
|
||||
@@ -1,29 +1,31 @@
|
||||
# 通用规范采纳记录
|
||||
|
||||
- 项目/负责人:Agent-Native / 仓库所有者 weichen
|
||||
- 项目/负责人:Agent-Native / weichen
|
||||
- 状态:active
|
||||
- 规范位置:`deps/development-guidelines`
|
||||
- 规范性文档目录:`policy/`
|
||||
- 过程文档位置:`docs/records/`(项目内固定目录豁免。当前为个人维护的基础库,单独过程仓会让每份记录都变成跨仓 gitlink;任务主记录仍在 Gitea Issue/PR。豁免一经登记,过程记录不得再写入 `policy/` 或规范库。)
|
||||
- 版本权威:父仓 gitlink;可读标签:无(内容版本 `0.1.0-draft.6`,提交 `26b562d2e34fbc560711296a309c91c74ffc55b1`)
|
||||
- 批准/生效依据:本仓接入提交;草案审阅完成后随该提交生效
|
||||
- 替代范围和迁移说明:本仓此前没有根 `AGENTS.md`。接入后以本文件与根入口为准,不在规范库内保存项目事实。
|
||||
- 过程文档位置:任务卡在 `.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 | 交付、排障与交接 | 单人维护仍需要可交接的任务记录 | manual |
|
||||
| workflows/delivery.md、debugging.md、handoff.md | 交付、排障与交接 | 单人维护仍需要可交接的任务记录(`.task/` 任务卡 + PR 主记录) | manual |
|
||||
| standards/collaboration.md | 并行共同起点、节点同步与陈旧候选拒绝 | 本仓会被 Agent-Squad 以 gitlink 消费 | manual |
|
||||
| standards/verification.md | 验证与结论 | 仓库根 `go vet` / `go test` | manual;命令见 PROJECT.md |
|
||||
| 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 | 能力协商与后端回退 | 本仓就是执行契约的权威 | manual |
|
||||
| standards/multi-repo.md | submodule 与固定组合 | 规范与基础库以 gitlink 锁定 | 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 | 不适用 |
|
||||
|
||||
@@ -37,6 +39,8 @@
|
||||
|
||||
## 本次升级或首次接入验证
|
||||
|
||||
### 2026-09-29 首次接入(历史记录,版本号为当时值)
|
||||
|
||||
- 入口:根 `AGENTS.md`、`PROJECT.md` 指向 `deps/development-guidelines`。
|
||||
- 子模块:`deps/development-guidelines` 固定 `26b562d2e34fbc560711296a309c91c74ffc55b1`。
|
||||
- `.gitmodules` 使用批准的上游 SSH URL,不含本机路径或凭证。
|
||||
@@ -49,3 +53,12 @@
|
||||
- 依赖边界新增:唯 `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,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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ package checkpoint
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
@@ -21,16 +22,20 @@ type Record struct {
|
||||
After []byte `json:"after,omitempty"`
|
||||
}
|
||||
|
||||
// Result 是一次回滚的分类。Skipped 是当前内容既不是写入后也不是写入前的文件。
|
||||
// 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) {
|
||||
@@ -42,7 +47,12 @@ func Open(path string) (*Log, error) {
|
||||
return nil, err
|
||||
}
|
||||
_ = f.Close()
|
||||
return &Log{path: path}, nil
|
||||
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 }
|
||||
@@ -54,12 +64,9 @@ func (l *Log) Record(anchor, root, rel string, before []byte, existed bool, afte
|
||||
}
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
all, err := l.read()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
l.lastSeq++
|
||||
rec := Record{
|
||||
Seq: len(all) + 1, Anchor: anchor, Root: root, Path: filepath.ToSlash(rel),
|
||||
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)
|
||||
@@ -75,6 +82,56 @@ func (l *Log) Record(anchor, root, rel string, before []byte, existed bool, afte
|
||||
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) {
|
||||
@@ -103,12 +160,22 @@ func (l *Log) Rewind(anchor string, ordered []string) (Result, error) {
|
||||
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 {
|
||||
return Result{}, err
|
||||
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
|
||||
}
|
||||
|
||||
@@ -226,12 +293,29 @@ func fileState(group []Record) int {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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 造出同名文件")
|
||||
}
|
||||
}
|
||||
+233
-1
@@ -121,7 +121,7 @@ func TestStoredSafeCurrentNeverDoesNotRerun(t *testing.T) {
|
||||
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 {
|
||||
if _, err := recoverPending(context.Background(), Config{Session: st, Tools: reg}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tool.calls != 0 {
|
||||
@@ -208,3 +208,235 @@ func TestSpansUseAgreedNamesWithoutPrompt(t *testing.T) {
|
||||
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++
|
||||
ch <- llm.Event{Kind: llm.KindReasoningDelta, ReasoningDelta: "思考"}
|
||||
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{
|
||||
Role: llm.RoleAssistant, StopReason: llm.StopToolCalls,
|
||||
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) {
|
||||
reg := tools.WorkspaceTools(t.TempDir())
|
||||
var observed []string
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
+77
-5
@@ -11,11 +11,24 @@ import (
|
||||
"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 {
|
||||
BaseURL string
|
||||
APIKey string
|
||||
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
|
||||
@@ -24,6 +37,10 @@ func (c *Client) Stream(ctx context.Context, model string, messages []Message, t
|
||||
out := make(chan Event)
|
||||
go func() {
|
||||
defer close(out)
|
||||
if c.Protocol == ProtocolAnthropic {
|
||||
c.streamAnthropic(ctx, model, messages, tools, opts, out)
|
||||
return
|
||||
}
|
||||
c.run(ctx, model, messages, tools, opts, out)
|
||||
}()
|
||||
return out
|
||||
@@ -59,6 +76,9 @@ func (c *Client) run(ctx context.Context, model string, messages []Message, tool
|
||||
if effort := OpenAIReasoningEffort(opts.ThinkingLevel); effort != "" {
|
||||
body["reasoning_effort"] = effort
|
||||
}
|
||||
if c.ReasoningSplit {
|
||||
body["reasoning_split"] = true
|
||||
}
|
||||
if len(tools) > 0 {
|
||||
body["tools"] = toProviderTools(tools)
|
||||
body["tool_choice"] = "auto"
|
||||
@@ -83,10 +103,10 @@ func (c *Client) run(ctx context.Context, model string, messages []Message, tool
|
||||
resp, err := hc.Do(req)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
emitAbort(out, "", ctx.Err())
|
||||
emitAbort(out, "", ctx.Err(), &TerminalObservation{ReadOutcome: "cancelled"})
|
||||
return
|
||||
}
|
||||
emitError(out, "", true, err.Error())
|
||||
emitError(out, "", true, err.Error(), &TerminalObservation{ReadOutcome: "transport_error"})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
@@ -96,6 +116,7 @@ func (c *Client) run(ctx context.Context, model string, messages []Message, tool
|
||||
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)},
|
||||
@@ -108,11 +129,55 @@ func (c *Client) run(ctx context.Context, model string, messages []Message, tool
|
||||
out <- ev
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
out := make([]map[string]any, 0, len(messages))
|
||||
for _, m := range messages {
|
||||
@@ -124,7 +189,14 @@ func toProviderMessages(messages []Message) []map[string]any {
|
||||
"content": m.Content,
|
||||
})
|
||||
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 {
|
||||
calls := make([]map[string]any, 0, len(m.ToolCalls))
|
||||
for _, tc := range m.ToolCalls {
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type terminalRoundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f terminalRoundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
|
||||
|
||||
func TestClientTerminalObservationHTTPStatus(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/chat/completions" {
|
||||
t.Errorf("path = %q", r.URL.Path)
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"ok\"},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":2,\"completion_tokens\":1,\"total_tokens\":3}}\n\ndata: [DONE]\n\n"))
|
||||
}))
|
||||
defer server.Close()
|
||||
client := &Client{BaseURL: server.URL, APIKey: "fixture", HTTPClient: server.Client()}
|
||||
var end Event
|
||||
for ev := range client.Stream(context.Background(), "fixture", []Message{{Role: RoleUser, Content: "hello"}}, nil, StreamOptions{}) {
|
||||
if ev.Kind == KindMessageEnd {
|
||||
end = ev
|
||||
}
|
||||
}
|
||||
if end.HTTPStatus != http.StatusOK || end.TerminalObservation == nil ||
|
||||
end.TerminalObservation.ReadOutcome != "done_marker" || !end.StreamComplete || end.Usage.TotalTokens != 3 {
|
||||
t.Fatalf("successful HTTP terminal lost transport evidence: %#v", end)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientTerminalObservationProviderAndTransportFailure(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "PRIVATE_PROVIDER_BODY_R05", http.StatusTooManyRequests)
|
||||
}))
|
||||
defer server.Close()
|
||||
tests := []struct {
|
||||
name string
|
||||
client *Client
|
||||
status int
|
||||
outcome string
|
||||
}{
|
||||
{"provider-http", &Client{BaseURL: server.URL, APIKey: "fixture", HTTPClient: server.Client()}, http.StatusTooManyRequests, "provider_error"},
|
||||
{"transport", &Client{BaseURL: "http://test.invalid", APIKey: "fixture", HTTPClient: &http.Client{Transport: terminalRoundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
return nil, errors.New("PRIVATE_TRANSPORT_ERROR_R05")
|
||||
})}}, 0, "transport_error"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var end Event
|
||||
for ev := range tc.client.Stream(context.Background(), "fixture", []Message{{Role: RoleUser, Content: "hello"}}, nil, StreamOptions{}) {
|
||||
end = ev
|
||||
}
|
||||
if end.Kind != KindError || end.HTTPStatus != tc.status || end.TerminalObservation == nil ||
|
||||
end.TerminalObservation.ReadOutcome != tc.outcome {
|
||||
t.Fatalf("provider/transport failure was not classified: %#v", end)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// bodyBoundaryToolFrames is a complete tool call plus Usage without [DONE].
|
||||
const bodyBoundaryToolFrames = "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_PRIVATE_R06\",\"function\":{\"name\":\"read_PRIVATE_R06\",\"arguments\":\"{\\\"path\\\":\\\"PRIVATE_R06.txt\\\"}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\n" +
|
||||
"data: {\"choices\":[],\"usage\":{\"prompt_tokens\":5,\"completion_tokens\":2,\"total_tokens\":7}}\n\n"
|
||||
|
||||
const bodyBoundaryDone = "data: [DONE]\n\n"
|
||||
|
||||
func streamBodyBoundaryEnd(t *testing.T, ctx context.Context, client *Client) Event {
|
||||
t.Helper()
|
||||
var end Event
|
||||
terminals := 0
|
||||
for ev := range client.Stream(ctx, "fixture", []Message{{Role: RoleUser, Content: "hello"}}, nil, StreamOptions{}) {
|
||||
if ev.Kind == KindMessageEnd || ev.Kind == KindError {
|
||||
end = ev
|
||||
terminals++
|
||||
} else if ev.TerminalObservation != nil {
|
||||
t.Fatalf("non-terminal event carried an observation: %#v", ev)
|
||||
}
|
||||
}
|
||||
if terminals != 1 {
|
||||
t.Fatalf("terminal events = %d, want 1", terminals)
|
||||
}
|
||||
if end.TerminalObservation != nil {
|
||||
raw, err := json.Marshal(end.TerminalObservation)
|
||||
if err != nil || strings.Contains(string(raw), "PRIVATE") || strings.Contains(string(raw), "127.0.0.1") {
|
||||
t.Fatalf("unsafe terminal observation: %s err=%v", raw, err)
|
||||
}
|
||||
}
|
||||
return end
|
||||
}
|
||||
|
||||
type bodyBoundaryWant struct {
|
||||
protocol, framing, relation string
|
||||
declared, eof, uncompressed bool
|
||||
bytes int64
|
||||
}
|
||||
|
||||
func requireBodyBoundary(t *testing.T, end Event, want bodyBoundaryWant) {
|
||||
t.Helper()
|
||||
if end.TerminalObservation == nil || end.TerminalObservation.BodyBoundary == nil {
|
||||
t.Fatalf("missing body boundary observation: %#v", end)
|
||||
}
|
||||
body := end.TerminalObservation.BodyBoundary
|
||||
if body.ProtocolClass != want.protocol || body.FramingClass != want.framing ||
|
||||
body.LengthRelation != want.relation || body.DeclaredLengthKnown != want.declared ||
|
||||
body.EOFSeen != want.eof || body.Uncompressed != want.uncompressed || body.BytesSaturated {
|
||||
t.Fatalf("wrong body boundary: %+v want %+v", body, want)
|
||||
}
|
||||
if want.bytes >= 0 && body.BytesRead != want.bytes {
|
||||
t.Fatalf("bytes read = %d, want %d", body.BytesRead, want.bytes)
|
||||
}
|
||||
}
|
||||
|
||||
func requireUnreleasedTools(t *testing.T, end Event) {
|
||||
t.Helper()
|
||||
if end.Kind != KindMessageEnd || end.Err == nil || end.StreamComplete || len(end.Message.ToolCalls) != 0 ||
|
||||
end.Usage.TotalTokens != 7 || end.HTTPStatus != http.StatusOK {
|
||||
t.Fatalf("unfinished tool stream was released or lost usage: %#v", end)
|
||||
}
|
||||
}
|
||||
|
||||
func fixtureResponseClient(resp func() *http.Response) *Client {
|
||||
return &Client{BaseURL: "http://fixture.invalid", APIKey: "fixture", HTTPClient: &http.Client{
|
||||
Transport: terminalRoundTripFunc(func(*http.Request) (*http.Response, error) { return resp(), nil }),
|
||||
}}
|
||||
}
|
||||
|
||||
func TestClientBodyBoundaryShortRead(t *testing.T) {
|
||||
t.Run("declared-longer-than-body", func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(bodyBoundaryToolFrames)+64))
|
||||
_, _ = io.WriteString(w, bodyBoundaryToolFrames)
|
||||
}))
|
||||
defer server.Close()
|
||||
end := streamBodyBoundaryEnd(t, context.Background(), &Client{BaseURL: server.URL, APIKey: "fixture", HTTPClient: server.Client()})
|
||||
requireUnreleasedTools(t, end)
|
||||
if end.TerminalObservation.ReadOutcome != "read_error" {
|
||||
t.Fatalf("short body was not a read error: %+v", end.TerminalObservation)
|
||||
}
|
||||
requireBodyBoundary(t, end, bodyBoundaryWant{protocol: "http1", framing: "known_length", relation: "unknown",
|
||||
declared: true, bytes: int64(len(bodyBoundaryToolFrames))})
|
||||
})
|
||||
t.Run("declared-length-mismatch", func(t *testing.T) {
|
||||
client := fixtureResponseClient(func() *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusOK, ProtoMajor: 1, ProtoMinor: 1,
|
||||
ContentLength: int64(len(bodyBoundaryToolFrames) + 64), Header: http.Header{},
|
||||
Body: io.NopCloser(strings.NewReader(bodyBoundaryToolFrames))}
|
||||
})
|
||||
end := streamBodyBoundaryEnd(t, context.Background(), client)
|
||||
requireUnreleasedTools(t, end)
|
||||
requireBodyBoundary(t, end, bodyBoundaryWant{protocol: "http1", framing: "known_length", relation: "mismatch",
|
||||
declared: true, eof: true, bytes: int64(len(bodyBoundaryToolFrames))})
|
||||
})
|
||||
}
|
||||
|
||||
func TestClientBodyBoundaryUncompressed(t *testing.T) {
|
||||
var compressed bytes.Buffer
|
||||
zw := gzip.NewWriter(&compressed)
|
||||
_, _ = io.WriteString(zw, bodyBoundaryToolFrames)
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Content-Encoding", "gzip")
|
||||
w.Header().Set("Content-Length", strconv.Itoa(compressed.Len()))
|
||||
_, _ = w.Write(compressed.Bytes())
|
||||
}))
|
||||
defer server.Close()
|
||||
end := streamBodyBoundaryEnd(t, context.Background(), &Client{BaseURL: server.URL, APIKey: "fixture", HTTPClient: server.Client()})
|
||||
requireUnreleasedTools(t, end)
|
||||
requireBodyBoundary(t, end, bodyBoundaryWant{protocol: "http1", framing: "unknown", relation: "unknown",
|
||||
eof: true, uncompressed: true, bytes: int64(len(bodyBoundaryToolFrames))})
|
||||
}
|
||||
|
||||
// doneThenErrorBody returns the whole stream without io.EOF; any later read fails.
|
||||
type doneThenErrorBody struct {
|
||||
data *strings.Reader
|
||||
reads int
|
||||
}
|
||||
|
||||
func (b *doneThenErrorBody) Read(p []byte) (int, error) {
|
||||
if b.data.Len() > 0 {
|
||||
return b.data.Read(p)
|
||||
}
|
||||
b.reads++
|
||||
return 0, errors.New("PRIVATE_READ_AFTER_DONE_R06")
|
||||
}
|
||||
|
||||
func (b *doneThenErrorBody) Close() error { return nil }
|
||||
|
||||
func TestClientDoneDoesNotNeedBodyEOF(t *testing.T) {
|
||||
stream := bodyBoundaryToolFrames + bodyBoundaryDone
|
||||
body := &doneThenErrorBody{data: strings.NewReader(stream)}
|
||||
client := fixtureResponseClient(func() *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusOK, ProtoMajor: 1, ProtoMinor: 1, ContentLength: -1,
|
||||
TransferEncoding: []string{"chunked"}, Header: http.Header{}, Body: body}
|
||||
})
|
||||
end := streamBodyBoundaryEnd(t, context.Background(), client)
|
||||
if end.Kind != KindMessageEnd || end.Err != nil || !end.StreamComplete || len(end.Message.ToolCalls) != 1 ||
|
||||
end.Message.StopReason != StopToolCalls || end.Usage.TotalTokens != 7 || end.HTTPStatus != http.StatusOK ||
|
||||
end.TerminalObservation.ReadOutcome != "done_marker" {
|
||||
t.Fatalf("legal [DONE] tool terminal was not kept: %#v", end)
|
||||
}
|
||||
requireBodyBoundary(t, end, bodyBoundaryWant{protocol: "http1", framing: "chunked", relation: "unknown",
|
||||
bytes: int64(len(stream))})
|
||||
if body.reads != 0 {
|
||||
t.Fatalf("parser read past [DONE] %d times", body.reads)
|
||||
}
|
||||
|
||||
var standalone Event
|
||||
for ev := range ParseSSE(context.Background(), strings.NewReader(stream)) {
|
||||
standalone = ev
|
||||
}
|
||||
if standalone.TerminalObservation == nil || standalone.TerminalObservation.BodyBoundary != nil ||
|
||||
len(standalone.Message.ToolCalls) != 1 || !standalone.StreamComplete {
|
||||
t.Fatalf("standalone ParseSSE changed: %#v", standalone)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientBodyBoundaryChunkedAndHTTP2(t *testing.T) {
|
||||
flushed := func(stream string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
_, _ = io.WriteString(w, stream)
|
||||
w.(http.Flusher).Flush()
|
||||
}
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
server func() *httptest.Server
|
||||
wantDone bool
|
||||
want bodyBoundaryWant
|
||||
}{
|
||||
{"known-length", func() *httptest.Server {
|
||||
stream := bodyBoundaryToolFrames + bodyBoundaryDone
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(stream)))
|
||||
_, _ = io.WriteString(w, stream)
|
||||
}))
|
||||
}, true, bodyBoundaryWant{protocol: "http1", framing: "known_length", relation: "match", declared: true, eof: true,
|
||||
bytes: int64(len(bodyBoundaryToolFrames + bodyBoundaryDone))}},
|
||||
{"chunked", func() *httptest.Server {
|
||||
return httptest.NewServer(flushed(bodyBoundaryToolFrames))
|
||||
}, false, bodyBoundaryWant{protocol: "http1", framing: "chunked", relation: "unknown", eof: true,
|
||||
bytes: int64(len(bodyBoundaryToolFrames))}},
|
||||
{"http2", func() *httptest.Server {
|
||||
server := httptest.NewUnstartedServer(flushed(bodyBoundaryToolFrames))
|
||||
server.EnableHTTP2 = true
|
||||
server.StartTLS()
|
||||
return server
|
||||
}, false, bodyBoundaryWant{protocol: "http2", framing: "http2_stream", relation: "unknown", eof: true,
|
||||
bytes: int64(len(bodyBoundaryToolFrames))}},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
server := tc.server()
|
||||
defer server.Close()
|
||||
end := streamBodyBoundaryEnd(t, context.Background(), &Client{BaseURL: server.URL, APIKey: "fixture", HTTPClient: server.Client()})
|
||||
requireBodyBoundary(t, end, tc.want)
|
||||
if !tc.wantDone {
|
||||
requireUnreleasedTools(t, end)
|
||||
return
|
||||
}
|
||||
if end.Err != nil || !end.StreamComplete || len(end.Message.ToolCalls) != 1 || end.Usage.TotalTokens != 7 {
|
||||
t.Fatalf("legal [DONE] tool terminal was not kept: %#v", end)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientBodyBoundaryCloseDelimitedUnknown(t *testing.T) {
|
||||
t.Run("close-delimited", func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Transfer-Encoding", "identity")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = io.WriteString(w, bodyBoundaryToolFrames)
|
||||
w.(http.Flusher).Flush()
|
||||
}))
|
||||
defer server.Close()
|
||||
end := streamBodyBoundaryEnd(t, context.Background(), &Client{BaseURL: server.URL, APIKey: "fixture", HTTPClient: server.Client()})
|
||||
requireUnreleasedTools(t, end)
|
||||
requireBodyBoundary(t, end, bodyBoundaryWant{protocol: "http1", framing: "close_delimited", relation: "unknown",
|
||||
eof: true, bytes: int64(len(bodyBoundaryToolFrames))})
|
||||
})
|
||||
t.Run("undeterminable", func(t *testing.T) {
|
||||
client := fixtureResponseClient(func() *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusOK, ContentLength: -1, Header: http.Header{},
|
||||
Body: io.NopCloser(strings.NewReader(bodyBoundaryToolFrames))}
|
||||
})
|
||||
end := streamBodyBoundaryEnd(t, context.Background(), client)
|
||||
requireUnreleasedTools(t, end)
|
||||
requireBodyBoundary(t, end, bodyBoundaryWant{protocol: "other", framing: "unknown", relation: "unknown",
|
||||
eof: true, bytes: int64(len(bodyBoundaryToolFrames))})
|
||||
})
|
||||
}
|
||||
|
||||
func TestClientBodyBoundaryFailuresDoNotClaimEOF(t *testing.T) {
|
||||
t.Run("cancelled", func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
_, _ = io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"content\":\"PRIVATE_R06\"}}]}\n\n")
|
||||
w.(http.Flusher).Flush()
|
||||
<-r.Context().Done()
|
||||
}))
|
||||
defer server.Close()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
client := &Client{BaseURL: server.URL, APIKey: "fixture", HTTPClient: server.Client()}
|
||||
var end Event
|
||||
for ev := range client.Stream(ctx, "fixture", []Message{{Role: RoleUser, Content: "hello"}}, nil, StreamOptions{}) {
|
||||
if ev.Kind == KindTextDelta {
|
||||
cancel()
|
||||
}
|
||||
if ev.Kind == KindMessageEnd || ev.Kind == KindError {
|
||||
end = ev
|
||||
}
|
||||
}
|
||||
if end.Kind != KindError || end.Message.StopReason != StopAborted || end.TerminalObservation == nil ||
|
||||
end.TerminalObservation.ReadOutcome != "cancelled" {
|
||||
t.Fatalf("cancelled stream was not aborted: %#v", end)
|
||||
}
|
||||
if body := end.TerminalObservation.BodyBoundary; body == nil || body.EOFSeen || body.LengthRelation != "unknown" {
|
||||
t.Fatalf("cancelled stream claimed body EOF: %+v", body)
|
||||
}
|
||||
})
|
||||
t.Run("provider-4xx", func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "PRIVATE_PROVIDER_BODY_R06", http.StatusBadRequest)
|
||||
}))
|
||||
defer server.Close()
|
||||
end := streamBodyBoundaryEnd(t, context.Background(), &Client{BaseURL: server.URL, APIKey: "fixture", HTTPClient: server.Client()})
|
||||
if end.Kind != KindError || end.HTTPStatus != http.StatusBadRequest || end.TerminalObservation == nil ||
|
||||
end.TerminalObservation.ReadOutcome != "provider_error" || end.TerminalObservation.BodyBoundary != nil {
|
||||
t.Fatalf("provider error carried a body boundary: %#v", end)
|
||||
}
|
||||
})
|
||||
t.Run("redirect-then-short-read", func(t *testing.T) {
|
||||
var finalHits atomic.Int32
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/chat/completions", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Location", "/final/chat/completions")
|
||||
w.WriteHeader(http.StatusTemporaryRedirect)
|
||||
_, _ = io.WriteString(w, "PRIVATE_REDIRECT_BODY_R06")
|
||||
})
|
||||
mux.HandleFunc("/final/chat/completions", func(w http.ResponseWriter, r *http.Request) {
|
||||
finalHits.Add(1)
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(bodyBoundaryToolFrames)+64))
|
||||
_, _ = io.WriteString(w, bodyBoundaryToolFrames)
|
||||
})
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
end := streamBodyBoundaryEnd(t, context.Background(), &Client{BaseURL: server.URL, APIKey: "fixture", HTTPClient: server.Client()})
|
||||
if hits := finalHits.Load(); hits != 1 {
|
||||
t.Fatalf("redirect target hits = %d, want 1", hits)
|
||||
}
|
||||
requireUnreleasedTools(t, end)
|
||||
requireBodyBoundary(t, end, bodyBoundaryWant{protocol: "http1", framing: "known_length", relation: "unknown",
|
||||
declared: true, bytes: int64(len(bodyBoundaryToolFrames))})
|
||||
})
|
||||
}
|
||||
|
||||
func TestClientBodyBoundarySaturatesCount(t *testing.T) {
|
||||
size := int64(maxObservedBodyBytes + 4096)
|
||||
observer := newBodyObserver(&http.Response{ProtoMajor: 1, ContentLength: size,
|
||||
Body: io.NopCloser(io.LimitReader(zeroReader{}, size))})
|
||||
if n, err := io.Copy(io.Discard, observer); err != nil || n != size {
|
||||
t.Fatalf("observer changed the body: n=%d err=%v", n, err)
|
||||
}
|
||||
body := observer.snapshot()
|
||||
if body.BytesRead != maxObservedBodyBytes || !body.BytesSaturated || !body.EOFSeen ||
|
||||
body.LengthRelation != "unknown" || !body.DeclaredLengthKnown {
|
||||
t.Fatalf("saturated body boundary: %+v", body)
|
||||
}
|
||||
}
|
||||
|
||||
type zeroReader struct{}
|
||||
|
||||
func (zeroReader) Read(p []byte) (int, error) {
|
||||
clear(p)
|
||||
return len(p), nil
|
||||
}
|
||||
@@ -140,7 +140,7 @@ type Provider struct {
|
||||
// Presets are OpenAI-compatible or native endpoints that only need an API key.
|
||||
func Presets() []Provider {
|
||||
return []Provider{
|
||||
{Name: "anthropic", Protocol: ProtocolOpenAIChat, BaseURL: "https://api.anthropic.com/v1", APIKeyEnv: "ANTHROPIC_API_KEY"},
|
||||
{Name: "anthropic", Protocol: ProtocolAnthropic, BaseURL: "https://api.anthropic.com", APIKeyEnv: "ANTHROPIC_API_KEY"},
|
||||
{Name: "openai", Protocol: ProtocolOpenAIChat, BaseURL: "https://api.openai.com/v1", APIKeyEnv: "OPENAI_API_KEY"},
|
||||
{Name: "openai-responses", Protocol: ProtocolOpenAIResponses, BaseURL: "https://api.openai.com/v1", APIKeyEnv: "OPENAI_API_KEY"},
|
||||
{Name: "google", Protocol: ProtocolOpenAIChat, BaseURL: "https://generativelanguage.googleapis.com/v1beta/openai", APIKeyEnv: "GEMINI_API_KEY"},
|
||||
@@ -196,12 +196,14 @@ func Lookup(name string, custom []Provider) (Provider, bool) {
|
||||
return Provider{}, false
|
||||
}
|
||||
|
||||
// ClientFor 返回会说该协议的客户端。responses、azure 以及原生 Anthropic/Google
|
||||
// ClientFor 返回会说该协议的客户端。responses、azure 以及原生 Google
|
||||
// 协议当前没有接到客户端上,返回明确错误而不是发到错误的路径。
|
||||
func ClientFor(p Provider) (*Client, error) {
|
||||
switch p.Protocol {
|
||||
case ProtocolOpenAIChat:
|
||||
return &Client{BaseURL: p.BaseURL, APIKey: p.APIKey}, nil
|
||||
case ProtocolAnthropic:
|
||||
return &Client{BaseURL: p.BaseURL, APIKey: p.APIKey, Protocol: ProtocolAnthropic}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("llm: protocol %q is not implemented", p.Protocol)
|
||||
}
|
||||
@@ -264,29 +266,10 @@ func BuildBody(protocol Protocol, model string, messages []Message, tools []Tool
|
||||
body["reasoning"] = map[string]any{"effort": effort}
|
||||
}
|
||||
return json.Marshal(body)
|
||||
// ProtocolAnthropic 与 ProtocolGoogle 只供离线组包。
|
||||
// 当前 Client 只发 OpenAI chat completions,这两支没有接到客户端上。
|
||||
// 原生协议是后续阶段的事;预设表里的 anthropic / google 已改走兼容端点。
|
||||
// ProtocolGoogle 只供离线组包(客户端未接);Anthropic 与线上客户端
|
||||
// 共用 anthropicBody(同一组包,消灭离线/线上两张皮)。
|
||||
case ProtocolAnthropic:
|
||||
sys, msgs := anthropicMessages(messages)
|
||||
body := map[string]any{
|
||||
"model": model,
|
||||
"messages": msgs,
|
||||
"max_tokens": opts.MaxTokens,
|
||||
"stream": true,
|
||||
}
|
||||
// Anthropic 要求 max_tokens。0 表示调用方不限制:用足够大的默认值,
|
||||
// 避免 4096 把长回复截断。真正的上限由调用方 StreamOptions.MaxTokens 决定。
|
||||
if body["max_tokens"] == 0 {
|
||||
body["max_tokens"] = 128000
|
||||
}
|
||||
if sys != "" {
|
||||
body["system"] = sys
|
||||
}
|
||||
if budget := AnthropicThinkingBudget(opts.ThinkingLevel); budget > 0 {
|
||||
body["thinking"] = map[string]any{"type": "enabled", "budget_tokens": budget}
|
||||
}
|
||||
return json.Marshal(body)
|
||||
return anthropicBody(model, messages, tools, opts)
|
||||
case ProtocolGoogle:
|
||||
body := map[string]any{
|
||||
"contents": googleContents(messages),
|
||||
@@ -317,40 +300,6 @@ func responsesInput(messages []Message) []map[string]any {
|
||||
return out
|
||||
}
|
||||
|
||||
func anthropicMessages(messages []Message) (string, []map[string]any) {
|
||||
var system strings.Builder
|
||||
var out []map[string]any
|
||||
for _, m := range messages {
|
||||
if m.Role == RoleSystem {
|
||||
if system.Len() > 0 {
|
||||
system.WriteString("\n")
|
||||
}
|
||||
system.WriteString(m.Content)
|
||||
continue
|
||||
}
|
||||
role := "user"
|
||||
if m.Role == RoleAssistant {
|
||||
role = "assistant"
|
||||
}
|
||||
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": role, "content": content})
|
||||
}
|
||||
return system.String(), out
|
||||
}
|
||||
|
||||
func googleContents(messages []Message) []map[string]any {
|
||||
var out []map[string]any
|
||||
for _, m := range messages {
|
||||
|
||||
@@ -69,19 +69,29 @@ func TestModelsJSONAndBodies(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPresetsAndClientForChatCompletions(t *testing.T) {
|
||||
want := map[string]string{
|
||||
"anthropic": "https://api.anthropic.com/v1",
|
||||
"google": "https://generativelanguage.googleapis.com/v1beta/openai",
|
||||
// google 仍走 OpenAI 兼容端点;anthropic 自 N2 起是原生 Messages 协议。
|
||||
want := map[string]struct {
|
||||
protocol Protocol
|
||||
base string
|
||||
}{
|
||||
"anthropic": {ProtocolAnthropic, "https://api.anthropic.com"},
|
||||
"google": {ProtocolOpenAIChat, "https://generativelanguage.googleapis.com/v1beta/openai"},
|
||||
}
|
||||
for _, p := range Presets() {
|
||||
base, ok := want[p.Name]
|
||||
w, ok := want[p.Name]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if p.Protocol != ProtocolOpenAIChat || p.BaseURL != base {
|
||||
if p.Protocol != w.protocol || p.BaseURL != w.base {
|
||||
t.Fatalf("%s preset: %+v", p.Name, p)
|
||||
}
|
||||
}
|
||||
// 原生 anthropic 有真实客户端(审计 N4 关闭);responses/azure/google 仍 fail-closed。
|
||||
if p, ok := Lookup("anthropic", nil); !ok {
|
||||
t.Fatal("anthropic preset missing")
|
||||
} else if c, err := ClientFor(p); err != nil || c.Protocol != ProtocolAnthropic {
|
||||
t.Fatalf("anthropic ClientFor: %v %+v", err, c)
|
||||
}
|
||||
for _, name := range []string{"openai-responses", "azure"} {
|
||||
p, ok := Lookup(name, nil)
|
||||
if !ok {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestOpenAIChatBodyReasoningSplit 验证 Client.ReasoningSplit 置位时
|
||||
// openai-chat 请求体带 "reasoning_split": true,未置位时不带(未知字段
|
||||
// 不外溢给别的提供商)。
|
||||
func TestOpenAIChatBodyReasoningSplit(t *testing.T) {
|
||||
var bodies []string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
bodies = append(bodies, string(raw))
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
_, _ = io.WriteString(w, "data: [DONE]\n\n")
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
drain := func(c *Client) {
|
||||
for range c.Stream(context.Background(), "MiniMax-M3",
|
||||
[]Message{{Role: RoleUser, Content: "hi"}}, nil, StreamOptions{}) {
|
||||
}
|
||||
}
|
||||
|
||||
drain(&Client{BaseURL: srv.URL, APIKey: "k"})
|
||||
drain(&Client{BaseURL: srv.URL, APIKey: "k", ReasoningSplit: true})
|
||||
if len(bodies) != 2 {
|
||||
t.Fatalf("requests = %d", len(bodies))
|
||||
}
|
||||
if strings.Contains(bodies[0], "reasoning_split") {
|
||||
t.Fatalf("flag off must not leak the field: %s", bodies[0])
|
||||
}
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal([]byte(bodies[1]), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v, ok := got["reasoning_split"].(bool); !ok || !v {
|
||||
t.Fatalf("flag on must set reasoning_split=true: %s", bodies[1])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"agent-native/runtime/argschema"
|
||||
)
|
||||
|
||||
// Completion bases recorded on a successful OpenAI-chat message_end.
|
||||
const (
|
||||
CompletionDoneMarker = "done_marker"
|
||||
CompletionRouteContractEOF = "route_contract_eof"
|
||||
)
|
||||
|
||||
// RouteContractEOF lets one exact OpenAI-chat route release tool calls from a
|
||||
// stream that ended without [DONE], when the HTTP body boundary and every
|
||||
// frame prove the calls complete. The zero value is off. IdentitySHA256 must
|
||||
// equal RouteContractIdentity(Model, BaseURL), and BaseURL must already be in
|
||||
// NormalizeRouteBaseURL form.
|
||||
type RouteContractEOF struct {
|
||||
Model string
|
||||
BaseURL string
|
||||
IdentitySHA256 string
|
||||
}
|
||||
|
||||
// RouteContractIdentity is the lowercase hex sha256 of
|
||||
// "openai-chat\n" + model + "\n" + baseURL.
|
||||
func RouteContractIdentity(model, baseURL string) string {
|
||||
sum := sha256.Sum256([]byte("openai-chat\n" + model + "\n" + baseURL))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// NormalizeRouteBaseURL returns the https base URL with lowercase scheme and
|
||||
// host and no trailing '/'. Any other scheme, userinfo, query or fragment is
|
||||
// not a match.
|
||||
func NormalizeRouteBaseURL(raw string) (string, bool) {
|
||||
if strings.ContainsAny(raw, "?#") {
|
||||
return "", false
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || !strings.EqualFold(u.Scheme, "https") || u.Opaque != "" ||
|
||||
u.User != nil || u.Host == "" || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" {
|
||||
return "", false
|
||||
}
|
||||
return "https://" + strings.ToLower(u.Host) + strings.TrimRight(u.EscapedPath(), "/"), true
|
||||
}
|
||||
|
||||
func (rc *RouteContractEOF) off() bool {
|
||||
return rc == nil || *rc == RouteContractEOF{}
|
||||
}
|
||||
|
||||
// admits is condition C1 apart from the redirect check.
|
||||
func (rc *RouteContractEOF) admits(protocol Protocol, model, base string) bool {
|
||||
if protocol != "" && protocol != ProtocolOpenAIChat {
|
||||
return false
|
||||
}
|
||||
if model != rc.Model {
|
||||
return false
|
||||
}
|
||||
normalized, ok := NormalizeRouteBaseURL(base)
|
||||
if !ok || normalized != rc.BaseURL {
|
||||
return false
|
||||
}
|
||||
return RouteContractIdentity(rc.Model, rc.BaseURL) == rc.IdentitySHA256
|
||||
}
|
||||
|
||||
// routeContract evaluates a configured RouteContractEOF for one response.
|
||||
type routeContract struct {
|
||||
active bool
|
||||
tools []ToolDecl
|
||||
}
|
||||
|
||||
// newRouteContract returns nil when the client has no contract configured.
|
||||
func (c *Client) newRouteContract(model, base string, req *http.Request, resp *http.Response, tools []ToolDecl) *routeContract {
|
||||
if c.RouteContract.off() {
|
||||
return nil
|
||||
}
|
||||
return &routeContract{
|
||||
active: c.RouteContract.admits(c.Protocol, model, base) && sameTarget(req, resp),
|
||||
tools: tools,
|
||||
}
|
||||
}
|
||||
|
||||
// sameTarget rejects any redirect that changed scheme, host, port or path.
|
||||
func sameTarget(req *http.Request, resp *http.Response) bool {
|
||||
if req == nil || req.URL == nil || resp == nil || resp.Request == nil || resp.Request.URL == nil {
|
||||
return false
|
||||
}
|
||||
sent, final := req.URL, resp.Request.URL
|
||||
return strings.EqualFold(sent.Scheme, final.Scheme) &&
|
||||
strings.EqualFold(sent.Hostname(), final.Hostname()) &&
|
||||
sent.Port() == final.Port() &&
|
||||
sent.EscapedPath() == final.EscapedPath()
|
||||
}
|
||||
|
||||
// check returns the first failing condition id, or "" when all hold.
|
||||
func (rc *routeContract) check(boundary *BodyBoundaryObservation, obs *TerminalObservation, facts sseFacts, calls []ToolCall) string {
|
||||
switch {
|
||||
case !rc.active:
|
||||
return "C1"
|
||||
case !boundaryProven(boundary):
|
||||
return "C2"
|
||||
case obs == nil || obs.ReadOutcome != "clean_eof":
|
||||
return "C3"
|
||||
case facts.finishChunks != 1 || facts.finishReason != "tool_calls" || facts.multiChoice:
|
||||
return "C4"
|
||||
case !obs.UsagePresent:
|
||||
return "C5"
|
||||
case facts.fragmentAfterFinish:
|
||||
return "C6"
|
||||
case !callsComplete(calls):
|
||||
return "C7"
|
||||
case !callsDeclared(calls, rc.tools):
|
||||
return "C8"
|
||||
case obs.TailEventKind != "json_frame" || !obs.LastFrameJSONComplete:
|
||||
return "C9"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func boundaryProven(b *BodyBoundaryObservation) bool {
|
||||
if b == nil {
|
||||
return false
|
||||
}
|
||||
return (b.FramingClass == "http2_stream" && b.EOFSeen) ||
|
||||
(b.FramingClass == "known_length" && b.LengthRelation == "match")
|
||||
}
|
||||
|
||||
func callsComplete(calls []ToolCall) bool {
|
||||
if len(calls) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, call := range calls {
|
||||
var args map[string]json.RawMessage
|
||||
if call.ID == "" || call.Name == "" {
|
||||
return false
|
||||
}
|
||||
if err := json.Unmarshal([]byte(call.Arguments), &args); err != nil || args == nil {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func callsDeclared(calls []ToolCall, tools []ToolDecl) bool {
|
||||
for _, call := range calls {
|
||||
idx := -1
|
||||
for i, decl := range tools {
|
||||
if decl.Name == call.Name {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if idx < 0 || argschema.Validate(tools[idx].Parameters, json.RawMessage(call.Arguments)) != nil {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,630 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const (
|
||||
rcModel = "fixture-model"
|
||||
rcEOFReason = "tool call stream ended before [DONE]"
|
||||
)
|
||||
|
||||
var rcTools = []ToolDecl{
|
||||
{Name: "read_PRIVATE_I1", Parameters: json.RawMessage(`{"type":"object","required":["path"],"properties":{"path":{"type":"string"}}}`)},
|
||||
{Name: "list_PRIVATE_I1", Parameters: json.RawMessage(`{"type":"object","properties":{"dir":{"type":"string"}}}`)},
|
||||
}
|
||||
|
||||
func rcFrame(v any) string {
|
||||
raw, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return "data: " + string(raw) + "\n\n"
|
||||
}
|
||||
|
||||
func rcChunk(choices ...map[string]any) string {
|
||||
return rcFrame(map[string]any{"choices": choices})
|
||||
}
|
||||
|
||||
func rcFrag(index int, id, name, args string) map[string]any {
|
||||
fn := map[string]any{}
|
||||
if name != "" {
|
||||
fn["name"] = name
|
||||
}
|
||||
if args != "" {
|
||||
fn["arguments"] = args
|
||||
}
|
||||
frag := map[string]any{"index": index, "function": fn}
|
||||
if id != "" {
|
||||
frag["id"] = id
|
||||
}
|
||||
return frag
|
||||
}
|
||||
|
||||
func rcToolChoice(finish string, frags ...map[string]any) map[string]any {
|
||||
choice := map[string]any{"delta": map[string]any{"tool_calls": frags}}
|
||||
if finish != "" {
|
||||
choice["finish_reason"] = finish
|
||||
}
|
||||
return choice
|
||||
}
|
||||
|
||||
func rcFinishChunk(reason string) string {
|
||||
return rcChunk(map[string]any{"delta": map[string]any{}, "finish_reason": reason})
|
||||
}
|
||||
|
||||
var (
|
||||
rcText = rcChunk(map[string]any{"delta": map[string]any{"content": "PRIVATE_TEXT_I1", "reasoning_content": "PRIVATE_THINK_I1"}})
|
||||
rcCall1 = rcChunk(rcToolChoice("", rcFrag(0, "call_PRIVATE_I1", "read_PRIVATE_I1", `{"path":`)))
|
||||
rcCall2 = rcChunk(rcToolChoice("", rcFrag(0, "", "", `"PRIVATE_I1.txt"}`)))
|
||||
rcFinish = rcFinishChunk("tool_calls")
|
||||
rcUsage = rcFrame(map[string]any{"choices": []any{}, "usage": map[string]any{"prompt_tokens": 5, "completion_tokens": 2, "total_tokens": 7}})
|
||||
rcDone = "data: [DONE]\n\n"
|
||||
rcStream = rcText + rcCall1 + rcCall2 + rcFinish + rcUsage
|
||||
rcCall = ToolCall{ID: "call_PRIVATE_I1", Name: "read_PRIVATE_I1", Arguments: `{"path":"PRIVATE_I1.txt"}`}
|
||||
|
||||
rcErrorFrame = rcFrame(map[string]any{"error": map[string]any{"message": "PRIVATE_PROVIDER_I1"}})
|
||||
)
|
||||
|
||||
// rcSingleCall streams one complete call in one fragment with the given parts.
|
||||
func rcSingleCall(id, name, args string) string {
|
||||
return rcText + rcChunk(rcToolChoice("", rcFrag(0, id, name, args))) + rcFinish + rcUsage
|
||||
}
|
||||
|
||||
func rcWrite(stream string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
_, _ = io.WriteString(w, stream)
|
||||
w.(http.Flusher).Flush()
|
||||
}
|
||||
}
|
||||
|
||||
func rcHTTP2(t *testing.T, h http.Handler) *httptest.Server {
|
||||
t.Helper()
|
||||
server := httptest.NewUnstartedServer(h)
|
||||
server.EnableHTTP2 = true
|
||||
server.StartTLS()
|
||||
t.Cleanup(server.Close)
|
||||
return server
|
||||
}
|
||||
|
||||
func rcTLS1(t *testing.T, h http.Handler) *httptest.Server {
|
||||
t.Helper()
|
||||
server := httptest.NewTLSServer(h)
|
||||
t.Cleanup(server.Close)
|
||||
return server
|
||||
}
|
||||
|
||||
func rcPlain(t *testing.T, h http.Handler) *httptest.Server {
|
||||
t.Helper()
|
||||
server := httptest.NewServer(h)
|
||||
t.Cleanup(server.Close)
|
||||
return server
|
||||
}
|
||||
|
||||
// rcHTTPClient trusts server like server.Client() but owns its connection
|
||||
// pool, so each run starts on a fresh connection and HTTP/2 stream ids match.
|
||||
func rcHTTPClient(t *testing.T, server *httptest.Server) *http.Client {
|
||||
t.Helper()
|
||||
base := server.Client().Transport.(*http.Transport)
|
||||
transport := &http.Transport{TLSClientConfig: base.TLSClientConfig.Clone(), ForceAttemptHTTP2: true}
|
||||
t.Cleanup(transport.CloseIdleConnections)
|
||||
return &http.Client{Transport: transport}
|
||||
}
|
||||
|
||||
func rcClientFor(t *testing.T, server *httptest.Server) *Client {
|
||||
return &Client{BaseURL: server.URL, APIKey: "fixture", HTTPClient: rcHTTPClient(t, server)}
|
||||
}
|
||||
|
||||
func rcContract(model, base string) *RouteContractEOF {
|
||||
return &RouteContractEOF{Model: model, BaseURL: base, IdentitySHA256: RouteContractIdentity(model, base)}
|
||||
}
|
||||
|
||||
func rcFixtureClient(resp func() *http.Response) *Client {
|
||||
return &Client{BaseURL: "https://fixture.invalid", APIKey: "fixture", HTTPClient: &http.Client{
|
||||
Transport: terminalRoundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
res := resp()
|
||||
res.Request = r
|
||||
return res, nil
|
||||
}),
|
||||
}}
|
||||
}
|
||||
|
||||
type rcSetup struct {
|
||||
client *Client
|
||||
contract *RouteContractEOF
|
||||
opts StreamOptions
|
||||
tools []ToolDecl
|
||||
}
|
||||
|
||||
type rcResult struct {
|
||||
end Event
|
||||
releasedMessages int
|
||||
releasedCalls int
|
||||
}
|
||||
|
||||
func rcRun(t *testing.T, ctx context.Context, base *Client, contract *RouteContractEOF, tools []ToolDecl, opts StreamOptions, onEvent func(Event)) rcResult {
|
||||
t.Helper()
|
||||
client := *base
|
||||
client.RouteContract = contract
|
||||
var res rcResult
|
||||
terminals := 0
|
||||
for ev := range client.Stream(ctx, rcModel, []Message{{Role: RoleUser, Content: "hello"}}, tools, opts) {
|
||||
if onEvent != nil {
|
||||
onEvent(ev)
|
||||
}
|
||||
if ev.Kind != KindMessageEnd && ev.Kind != KindError {
|
||||
continue
|
||||
}
|
||||
terminals++
|
||||
res.end = ev
|
||||
if ev.Kind == KindMessageEnd && ev.Err == nil && len(ev.Message.ToolCalls) > 0 {
|
||||
res.releasedMessages++
|
||||
res.releasedCalls += len(ev.Message.ToolCalls)
|
||||
}
|
||||
}
|
||||
if terminals != 1 {
|
||||
t.Fatalf("terminal events = %d, want 1", terminals)
|
||||
}
|
||||
rcRequireSafeLabels(t, res.end)
|
||||
return res
|
||||
}
|
||||
|
||||
// rcRequireSafeLabels is the sentinel scan: the new labels hold only their
|
||||
// fixed enums and the observation never carries content, tool identity,
|
||||
// arguments or error text.
|
||||
func rcRequireSafeLabels(t *testing.T, end Event) {
|
||||
t.Helper()
|
||||
obs := end.TerminalObservation
|
||||
if obs == nil {
|
||||
t.Fatalf("terminal without observation: %#v", end)
|
||||
}
|
||||
switch end.CompletionBasis {
|
||||
case "", CompletionDoneMarker, CompletionRouteContractEOF:
|
||||
default:
|
||||
t.Fatalf("completion basis outside enum: %q", end.CompletionBasis)
|
||||
}
|
||||
if obs.CompletionBasis != end.CompletionBasis {
|
||||
t.Fatalf("observation basis %q != event basis %q", obs.CompletionBasis, end.CompletionBasis)
|
||||
}
|
||||
if c := obs.ContractCheck; c != "" && (len(c) != 2 || c[0] != 'C' || c[1] < '1' || c[1] > '9') {
|
||||
t.Fatalf("contract check outside enum: %q", c)
|
||||
}
|
||||
raw, err := json.Marshal(obs)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, secret := range []string{"PRIVATE", "[DONE]", "tool call", "fixture-model"} {
|
||||
if strings.Contains(string(raw), secret) {
|
||||
t.Fatalf("observation leaked %q: %s", secret, raw)
|
||||
}
|
||||
}
|
||||
for _, label := range []string{end.CompletionBasis, obs.CompletionBasis, obs.ContractCheck} {
|
||||
if strings.Contains(label, "PRIVATE") || strings.Contains(label, " ") {
|
||||
t.Fatalf("label carries content: %q", label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func rcStreamCase(server func(*testing.T, http.Handler) *httptest.Server, stream string) func(*testing.T) rcSetup {
|
||||
return func(t *testing.T) rcSetup {
|
||||
s := server(t, rcWrite(stream))
|
||||
return rcSetup{client: rcClientFor(t, s), contract: rcContract(rcModel, s.URL)}
|
||||
}
|
||||
}
|
||||
|
||||
func rcH2(stream string) func(*testing.T) rcSetup { return rcStreamCase(rcHTTP2, stream) }
|
||||
|
||||
func rcRedirectCase(target func(t *testing.T) string) func(*testing.T) rcSetup {
|
||||
return func(t *testing.T) rcSetup {
|
||||
location := target(t)
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/chat/completions", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, location, http.StatusTemporaryRedirect)
|
||||
})
|
||||
mux.HandleFunc("/final/chat/completions", rcWrite(rcStream))
|
||||
s := rcHTTP2(t, mux)
|
||||
return rcSetup{client: rcClientFor(t, s), contract: rcContract(rcModel, s.URL)}
|
||||
}
|
||||
}
|
||||
|
||||
type rcCase struct {
|
||||
name string
|
||||
setup func(*testing.T) rcSetup
|
||||
// want is done_marker, route_contract_eof, off, or a check id C1..C9.
|
||||
want string
|
||||
// reason is the contract-off failure text; empty skips the check.
|
||||
reason string
|
||||
calls []ToolCall
|
||||
noUsage bool
|
||||
}
|
||||
|
||||
func rcCases() []rcCase {
|
||||
twoCalls := rcText + rcCall1 + rcCall2 +
|
||||
rcChunk(rcToolChoice("", rcFrag(1, "call_PRIVATE_I1_b", "list_PRIVATE_I1", `{}`))) + rcFinish + rcUsage
|
||||
return []rcCase{
|
||||
// [DONE] keeps today's path whether or not the contract is configured.
|
||||
{name: "done/contract-on", setup: rcH2(rcStream + rcDone), want: CompletionDoneMarker},
|
||||
{name: "done/contract-c1-failing", setup: func(t *testing.T) rcSetup {
|
||||
s := rcH2(rcStream + rcDone)(t)
|
||||
s.contract = rcContract("other-model", s.client.BaseURL)
|
||||
return s
|
||||
}, want: CompletionDoneMarker},
|
||||
{name: "done/known-length", setup: rcKnownLength(rcStream + rcDone), want: CompletionDoneMarker},
|
||||
|
||||
// Released only when every condition holds.
|
||||
{name: "release/http2", setup: rcH2(rcStream), want: CompletionRouteContractEOF},
|
||||
{name: "release/known-length-match", setup: rcKnownLength(rcStream), want: CompletionRouteContractEOF},
|
||||
{name: "release/usage-before-finish", setup: rcH2(rcText + rcUsage + rcCall1 + rcCall2 + rcFinish), want: CompletionRouteContractEOF},
|
||||
{name: "release/usage-after-finish", setup: rcH2(rcText + rcCall1 + rcCall2 + rcFinish + rcUsage), want: CompletionRouteContractEOF},
|
||||
{name: "release/fragment-in-finish-chunk", setup: rcH2(rcText + rcCall1 +
|
||||
rcChunk(rcToolChoice("tool_calls", rcFrag(0, "", "", `"PRIVATE_I1.txt"}`))) + rcUsage), want: CompletionRouteContractEOF},
|
||||
{name: "release/two-calls", setup: rcH2(twoCalls), want: CompletionRouteContractEOF,
|
||||
calls: []ToolCall{rcCall, {ID: "call_PRIVATE_I1_b", Name: "list_PRIVATE_I1", Arguments: `{}`}}},
|
||||
{name: "release/api-base-override-to-contract", setup: func(t *testing.T) rcSetup {
|
||||
s := rcH2(rcStream)(t)
|
||||
s.opts.APIBase = s.client.BaseURL + "/"
|
||||
s.client.BaseURL = "https://unused.invalid"
|
||||
return s
|
||||
}, want: CompletionRouteContractEOF},
|
||||
|
||||
// The zero value is off.
|
||||
{name: "off/zero-contract", setup: func(t *testing.T) rcSetup {
|
||||
s := rcH2(rcStream)(t)
|
||||
s.contract = &RouteContractEOF{}
|
||||
return s
|
||||
}, want: "off", reason: rcEOFReason},
|
||||
|
||||
// C1 route identity.
|
||||
{name: "c1/model-mismatch", setup: func(t *testing.T) rcSetup {
|
||||
s := rcH2(rcStream)(t)
|
||||
s.contract = rcContract("other-model", s.client.BaseURL)
|
||||
return s
|
||||
}, want: "C1", reason: rcEOFReason},
|
||||
{name: "c1/base-http", setup: rcStreamCase(rcPlain, rcStream), want: "C1", reason: rcEOFReason},
|
||||
{name: "c1/base-userinfo", setup: func(t *testing.T) rcSetup {
|
||||
s := rcH2(rcStream)(t)
|
||||
s.client.BaseURL = strings.Replace(s.client.BaseURL, "https://", "https://user@", 1)
|
||||
return s
|
||||
}, want: "C1", reason: rcEOFReason},
|
||||
{name: "c1/base-query", setup: func(t *testing.T) rcSetup {
|
||||
s := rcH2(rcStream)(t)
|
||||
s.client.BaseURL += "?x=1"
|
||||
return s
|
||||
}, want: "C1", reason: rcEOFReason},
|
||||
{name: "c1/digest-mismatch", setup: func(t *testing.T) rcSetup {
|
||||
s := rcH2(rcStream)(t)
|
||||
s.contract.IdentitySHA256 = strings.Repeat("0", 64)
|
||||
return s
|
||||
}, want: "C1", reason: rcEOFReason},
|
||||
{name: "c1/digest-uppercase", setup: func(t *testing.T) rcSetup {
|
||||
s := rcH2(rcStream)(t)
|
||||
s.contract.IdentitySHA256 = strings.ToUpper(s.contract.IdentitySHA256)
|
||||
return s
|
||||
}, want: "C1", reason: rcEOFReason},
|
||||
{name: "c1/protocol-not-openai-chat", setup: func(t *testing.T) rcSetup {
|
||||
s := rcH2(rcStream)(t)
|
||||
s.client.Protocol = ProtocolGoogle
|
||||
return s
|
||||
}, want: "C1", reason: rcEOFReason},
|
||||
{name: "c1/api-base-override-elsewhere", setup: func(t *testing.T) rcSetup {
|
||||
other := rcHTTP2(t, rcWrite(rcStream))
|
||||
s := rcH2(rcStream)(t)
|
||||
s.opts.APIBase = other.URL
|
||||
s.client.HTTPClient = rcHTTPClient(t, other)
|
||||
return s
|
||||
}, want: "C1", reason: rcEOFReason},
|
||||
{name: "c1/redirect-other-host", setup: rcRedirectCase(func(t *testing.T) string {
|
||||
return rcHTTP2(t, rcWrite(rcStream)).URL + "/chat/completions"
|
||||
}), want: "C1", reason: rcEOFReason},
|
||||
{name: "c1/redirect-same-host-other-path", setup: rcRedirectCase(func(t *testing.T) string {
|
||||
return "/final/chat/completions"
|
||||
}), want: "C1", reason: rcEOFReason},
|
||||
{name: "c1/redirect-https-downgrade", setup: rcRedirectCase(func(t *testing.T) string {
|
||||
return rcPlain(t, rcWrite(rcStream)).URL + "/chat/completions"
|
||||
}), want: "C1", reason: rcEOFReason},
|
||||
|
||||
// C2 body boundary.
|
||||
{name: "c2/known-length-mismatch", setup: func(t *testing.T) rcSetup {
|
||||
client := rcFixtureClient(func() *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusOK, ProtoMajor: 1, ProtoMinor: 1,
|
||||
ContentLength: int64(len(rcStream) + 64), Header: http.Header{},
|
||||
Body: io.NopCloser(strings.NewReader(rcStream))}
|
||||
})
|
||||
return rcSetup{client: client, contract: rcContract(rcModel, client.BaseURL)}
|
||||
}, want: "C2", reason: rcEOFReason},
|
||||
{name: "c2/unknown-framing", setup: func(t *testing.T) rcSetup {
|
||||
client := rcFixtureClient(func() *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusOK, ContentLength: -1, Header: http.Header{},
|
||||
Body: io.NopCloser(strings.NewReader(rcStream))}
|
||||
})
|
||||
return rcSetup{client: client, contract: rcContract(rcModel, client.BaseURL)}
|
||||
}, want: "C2", reason: rcEOFReason},
|
||||
{name: "c2/chunked", setup: rcStreamCase(rcTLS1, rcStream), want: "C2", reason: rcEOFReason},
|
||||
{name: "c2/close-delimited", setup: func(t *testing.T) rcSetup {
|
||||
s := rcTLS1(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Transfer-Encoding", "identity")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = io.WriteString(w, rcStream)
|
||||
w.(http.Flusher).Flush()
|
||||
}))
|
||||
return rcSetup{client: rcClientFor(t, s), contract: rcContract(rcModel, s.URL)}
|
||||
}, want: "C2", reason: rcEOFReason},
|
||||
// A reset or dropped stream never reaches body EOF, so the boundary
|
||||
// condition fails before the read-outcome condition is evaluated.
|
||||
{name: "c2/http2-rst-stream", setup: func(t *testing.T) rcSetup {
|
||||
s := rcHTTP2(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
rcWrite(rcStream)(w, r)
|
||||
panic(http.ErrAbortHandler)
|
||||
}))
|
||||
return rcSetup{client: rcClientFor(t, s), contract: rcContract(rcModel, s.URL)}
|
||||
}, want: "C2", reason: "stream read failed after reported usage"},
|
||||
{name: "c2/mid-stream-disconnect", setup: func(t *testing.T) rcSetup {
|
||||
s := rcTLS1(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
rcWrite(rcStream)(w, r)
|
||||
conn, _, err := w.(http.Hijacker).Hijack()
|
||||
if err == nil {
|
||||
_ = conn.Close()
|
||||
}
|
||||
}))
|
||||
return rcSetup{client: rcClientFor(t, s), contract: rcContract(rcModel, s.URL)}
|
||||
}, want: "C2", reason: "stream read failed after reported usage"},
|
||||
|
||||
// The parser stops at a bad frame; with a trailing delimiter it never
|
||||
// reads to body EOF, so the boundary condition fails first.
|
||||
{name: "c2/invalid-frame-before-eof", setup: rcH2(rcStream + "data: PRIVATE_not_json\n\n"), want: "C2",
|
||||
reason: "malformed stream chunk after reported usage"},
|
||||
{name: "c2/provider-error-before-eof", setup: rcH2(rcStream + rcErrorFrame), want: "C2",
|
||||
reason: "provider error after reported usage: PRIVATE_PROVIDER_I1"},
|
||||
|
||||
// C3 read outcome: the bad frame is the last bytes, so body EOF is seen.
|
||||
{name: "c3/half-frame", setup: rcH2(rcStream + `data: {"choices":[{"delta":{"content":"PRIVATE_HALF`), want: "C3",
|
||||
reason: "malformed stream chunk after reported usage"},
|
||||
{name: "c3/invalid-frame", setup: rcH2(rcStream + "data: PRIVATE_not_json"), want: "C3",
|
||||
reason: "malformed stream chunk after reported usage"},
|
||||
{name: "c3/provider-error-after-usage", setup: rcH2(rcStream + strings.TrimRight(rcErrorFrame, "\n")),
|
||||
want: "C3", reason: "provider error after reported usage: PRIVATE_PROVIDER_I1"},
|
||||
{name: "c3/provider-error-before-usage", setup: rcH2(rcText + rcCall1 + rcCall2 + rcFinish + strings.TrimRight(rcErrorFrame, "\n")),
|
||||
want: "C3", reason: "PRIVATE_PROVIDER_I1", noUsage: true},
|
||||
|
||||
// C4 finish reason and choices.
|
||||
{name: "c4/two-finish-chunks", setup: rcH2(rcText + rcCall1 + rcCall2 + rcFinish + rcFinish + rcUsage), want: "C4", reason: rcEOFReason},
|
||||
{name: "c4/finish-stop-with-tools", setup: rcH2(rcText + rcCall1 + rcCall2 + rcFinishChunk("stop") + rcUsage), want: "C4", reason: rcEOFReason},
|
||||
{name: "c4/finish-length-with-tools", setup: rcH2(rcText + rcCall1 + rcCall2 + rcFinishChunk("length") + rcUsage), want: "C4", reason: rcEOFReason},
|
||||
{name: "c4/multi-choice-chunk", setup: rcH2(rcText +
|
||||
rcChunk(map[string]any{"delta": map[string]any{"content": "a"}}, map[string]any{"delta": map[string]any{"content": "b"}}) +
|
||||
rcCall1 + rcCall2 + rcFinish + rcUsage), want: "C4", reason: rcEOFReason},
|
||||
{name: "c4/truncated-at-frame-boundary-without-finish", setup: rcH2(rcText + rcCall1 + rcCall2 + rcUsage), want: "C4", reason: rcEOFReason},
|
||||
|
||||
// C5 usage.
|
||||
{name: "c5/missing-usage", setup: rcH2(rcText + rcCall1 + rcCall2 + rcFinish), want: "C5", reason: rcEOFReason, noUsage: true},
|
||||
|
||||
// C6 fragments after finish.
|
||||
{name: "c6/fragment-after-finish-chunk", setup: rcH2(rcText + rcCall1 + rcFinish + rcCall2 + rcUsage), want: "C6", reason: rcEOFReason},
|
||||
|
||||
// C7 call identity and argument shape.
|
||||
{name: "c7/missing-id", setup: rcH2(rcSingleCall("", "read_PRIVATE_I1", `{"path":"PRIVATE_I1.txt"}`)), want: "C7", reason: rcEOFReason},
|
||||
{name: "c7/missing-name", setup: rcH2(rcSingleCall("call_PRIVATE_I1", "", `{"path":"PRIVATE_I1.txt"}`)), want: "C7", reason: rcEOFReason},
|
||||
{name: "c7/array-arguments", setup: rcH2(rcSingleCall("call_PRIVATE_I1", "read_PRIVATE_I1", `["PRIVATE_I1.txt"]`)), want: "C7", reason: rcEOFReason},
|
||||
{name: "c7/broken-arguments", setup: rcH2(rcSingleCall("call_PRIVATE_I1", "read_PRIVATE_I1", `{"path":"PRIVATE_I1.txt"`)), want: "C7", reason: rcEOFReason},
|
||||
{name: "c7/finish-without-call", setup: rcH2(rcText + rcFinish + rcUsage), want: "C7", reason: "tool call finish reason without a tool call"},
|
||||
|
||||
// C8 declared tools and argument schema.
|
||||
{name: "c8/unknown-tool", setup: rcH2(rcSingleCall("call_PRIVATE_I1", "write_PRIVATE_I1", `{"path":"PRIVATE_I1.txt"}`)), want: "C8", reason: rcEOFReason},
|
||||
{name: "c8/no-tools-declared", setup: func(t *testing.T) rcSetup {
|
||||
s := rcH2(rcStream)(t)
|
||||
s.tools = []ToolDecl{}
|
||||
return s
|
||||
}, want: "C8", reason: rcEOFReason},
|
||||
{name: "c8/missing-required", setup: rcH2(rcSingleCall("call_PRIVATE_I1", "read_PRIVATE_I1", `{}`)), want: "C8", reason: rcEOFReason},
|
||||
{name: "c8/blank-required", setup: rcH2(rcSingleCall("call_PRIVATE_I1", "read_PRIVATE_I1", `{"path":" "}`)), want: "C8", reason: rcEOFReason},
|
||||
{name: "c8/wrong-property-type", setup: rcH2(rcSingleCall("call_PRIVATE_I1", "read_PRIVATE_I1", `{"path":7}`)), want: "C8", reason: rcEOFReason},
|
||||
{name: "c8/unknown-argument", setup: rcH2(rcSingleCall("call_PRIVATE_I1", "read_PRIVATE_I1", `{"path":"a","PRIVATE":1}`)), want: "C8", reason: rcEOFReason},
|
||||
|
||||
// C9 tail event.
|
||||
{name: "c9/comment-tail", setup: rcH2(rcStream + ": PRIVATE_keepalive\n\n"), want: "C9", reason: rcEOFReason},
|
||||
}
|
||||
}
|
||||
|
||||
func rcKnownLength(stream string) func(*testing.T) rcSetup {
|
||||
return func(t *testing.T) rcSetup {
|
||||
s := rcTLS1(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(stream)))
|
||||
_, _ = io.WriteString(w, stream)
|
||||
}))
|
||||
return rcSetup{client: rcClientFor(t, s), contract: rcContract(rcModel, s.URL)}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouteContractEOFMatrix(t *testing.T) {
|
||||
for _, tc := range rcCases() {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
s := tc.setup(t)
|
||||
tools := s.tools
|
||||
if tools == nil {
|
||||
tools = rcTools
|
||||
}
|
||||
wantCalls := tc.calls
|
||||
if wantCalls == nil {
|
||||
wantCalls = []ToolCall{rcCall}
|
||||
}
|
||||
wantUsage := 7
|
||||
if tc.noUsage {
|
||||
wantUsage = 0
|
||||
}
|
||||
off := rcRun(t, context.Background(), s.client, nil, tools, s.opts, nil)
|
||||
on := rcRun(t, context.Background(), s.client, s.contract, tools, s.opts, nil)
|
||||
for _, r := range []rcResult{off, on} {
|
||||
if r.end.Usage.TotalTokens != wantUsage {
|
||||
t.Fatalf("usage = %+v, want total %d", r.end.Usage, wantUsage)
|
||||
}
|
||||
}
|
||||
if off.end.TerminalObservation.ContractCheck != "" {
|
||||
t.Fatalf("contract-off stream carried a contract check: %+v", off.end.TerminalObservation)
|
||||
}
|
||||
if tc.reason != "" && off.end.ErrorMessage != tc.reason {
|
||||
t.Fatalf("contract-off failure = %q, want %q", off.end.ErrorMessage, tc.reason)
|
||||
}
|
||||
|
||||
switch tc.want {
|
||||
case CompletionDoneMarker:
|
||||
if !reflect.DeepEqual(on.end, off.end) {
|
||||
t.Fatalf("contract changed a [DONE] terminal:\n on=%#v\noff=%#v", on.end, off.end)
|
||||
}
|
||||
if on.end.Err != nil || !on.end.StreamComplete || on.end.CompletionBasis != CompletionDoneMarker ||
|
||||
on.end.TerminalObservation.ContractCheck != "" || on.end.TerminalObservation.ReadOutcome != "done_marker" {
|
||||
t.Fatalf("[DONE] terminal not marked done_marker: %#v", on.end)
|
||||
}
|
||||
rcRequireReleased(t, on, wantCalls)
|
||||
case CompletionRouteContractEOF:
|
||||
rcRequireRejected(t, off)
|
||||
if off.end.CompletionBasis != "" || off.end.ErrorMessage != rcEOFReason {
|
||||
t.Fatalf("contract-off stream was not rejected unchanged: %#v", off.end)
|
||||
}
|
||||
end := on.end
|
||||
if end.Err != nil || end.StreamComplete || end.CompletionBasis != CompletionRouteContractEOF ||
|
||||
end.TerminalObservation.ContractCheck != "" || end.TerminalObservation.ReadOutcome != "clean_eof" ||
|
||||
end.Message.StopReason != StopToolCalls || end.FinishReason != "tool_calls" || end.HTTPStatus != http.StatusOK {
|
||||
t.Fatalf("contract stream was not released: %#v obs=%+v", end, end.TerminalObservation)
|
||||
}
|
||||
if end.Message.Content != off.end.Message.Content || !reflect.DeepEqual(end.Message.Thinking, off.end.Message.Thinking) {
|
||||
t.Fatalf("released message changed content: %#v vs %#v", end.Message, off.end.Message)
|
||||
}
|
||||
rcRequireReleased(t, on, wantCalls)
|
||||
case "off":
|
||||
if !reflect.DeepEqual(on.end, off.end) {
|
||||
t.Fatalf("zero contract changed the terminal:\n on=%#v\noff=%#v", on.end, off.end)
|
||||
}
|
||||
rcRequireRejected(t, on)
|
||||
default:
|
||||
rcRequireRejected(t, off)
|
||||
rcRequireRejected(t, on)
|
||||
if got := on.end.TerminalObservation.ContractCheck; got != tc.want {
|
||||
t.Fatalf("contract check = %q, want %q (obs=%+v)", got, tc.want, on.end.TerminalObservation)
|
||||
}
|
||||
stripped := on.end
|
||||
obs := *on.end.TerminalObservation
|
||||
obs.ContractCheck = ""
|
||||
stripped.TerminalObservation = &obs
|
||||
if !reflect.DeepEqual(stripped, off.end) {
|
||||
t.Fatalf("rejection differs from contract-off beyond contractCheck:\n on=%#v %+v\noff=%#v %+v",
|
||||
stripped, stripped.TerminalObservation, off.end, off.end.TerminalObservation)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func rcRequireReleased(t *testing.T, r rcResult, calls []ToolCall) {
|
||||
t.Helper()
|
||||
if r.releasedMessages != 1 || r.releasedCalls != len(calls) || !reflect.DeepEqual(r.end.Message.ToolCalls, calls) {
|
||||
t.Fatalf("released %d messages / %d calls %#v, want 1 / %#v", r.releasedMessages, r.releasedCalls, r.end.Message.ToolCalls, calls)
|
||||
}
|
||||
}
|
||||
|
||||
func rcRequireRejected(t *testing.T, r rcResult) {
|
||||
t.Helper()
|
||||
end := r.end
|
||||
if r.releasedMessages != 0 || r.releasedCalls != 0 || len(end.Message.ToolCalls) != 0 || end.Err == nil ||
|
||||
end.StreamComplete || end.CompletionBasis != "" || end.TerminalObservation.CompletionBasis != "" {
|
||||
t.Fatalf("stream was released: %#v", end)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouteContractEOFCancelDuringReadAborts(t *testing.T) {
|
||||
server := rcHTTP2(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
rcWrite(rcText+rcCall1+rcCall2+rcFinish+rcUsage)(w, r)
|
||||
<-r.Context().Done()
|
||||
}))
|
||||
client := rcClientFor(t, server)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
res := rcRun(t, ctx, client, rcContract(rcModel, server.URL), rcTools, StreamOptions{}, func(ev Event) {
|
||||
if ev.Kind == KindToolCallDelta {
|
||||
cancel()
|
||||
}
|
||||
})
|
||||
end := res.end
|
||||
if end.Kind != KindError || end.Message.StopReason != StopAborted || end.TerminalObservation.ReadOutcome != "cancelled" ||
|
||||
end.TerminalObservation.ContractCheck != "" || end.CompletionBasis != "" || res.releasedMessages != 0 ||
|
||||
res.releasedCalls != 0 || len(end.Message.ToolCalls) != 0 {
|
||||
t.Fatalf("cancelled contract stream was not aborted: %#v obs=%+v", end, end.TerminalObservation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSSEUnchangedWithoutDone(t *testing.T) {
|
||||
var end Event
|
||||
for ev := range ParseSSE(context.Background(), strings.NewReader(rcStream)) {
|
||||
end = ev
|
||||
}
|
||||
if end.Kind != KindMessageEnd || end.ErrorMessage != rcEOFReason || len(end.Message.ToolCalls) != 0 ||
|
||||
end.StreamComplete || end.CompletionBasis != "" || end.Usage.TotalTokens != 7 ||
|
||||
end.TerminalObservation == nil || end.TerminalObservation.CompletionBasis != "" ||
|
||||
end.TerminalObservation.ContractCheck != "" || end.TerminalObservation.BodyBoundary != nil {
|
||||
t.Fatalf("ParseSSE changed without [DONE]: %#v", end)
|
||||
}
|
||||
raw, err := json.Marshal(end.TerminalObservation)
|
||||
if err != nil || strings.Contains(string(raw), "completionBasis") || strings.Contains(string(raw), "contractCheck") {
|
||||
t.Fatalf("ParseSSE observation JSON changed: %s err=%v", raw, err)
|
||||
}
|
||||
var done Event
|
||||
for ev := range ParseSSE(context.Background(), strings.NewReader(rcStream+rcDone)) {
|
||||
done = ev
|
||||
}
|
||||
if done.Err != nil || !done.StreamComplete || done.CompletionBasis != "" || done.TerminalObservation.CompletionBasis != "" ||
|
||||
!reflect.DeepEqual(done.Message.ToolCalls, []ToolCall{rcCall}) {
|
||||
t.Fatalf("ParseSSE changed with [DONE]: %#v", done)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouteContractIdentity(t *testing.T) {
|
||||
sum := sha256.Sum256([]byte("openai-chat\nm\nhttps://api.example.com/v1"))
|
||||
if got, want := RouteContractIdentity("m", "https://api.example.com/v1"), hex.EncodeToString(sum[:]); got != want {
|
||||
t.Fatalf("identity = %s, want %s", got, want)
|
||||
}
|
||||
if got := RouteContractIdentity("m", "https://api.example.com/v1"); got != strings.ToLower(got) || len(got) != 64 {
|
||||
t.Fatalf("identity is not lowercase hex: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeRouteBaseURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
raw, want string
|
||||
ok bool
|
||||
}{
|
||||
{"https://api.example.com/v1", "https://api.example.com/v1", true},
|
||||
{"HTTPS://API.Example.COM/v1/", "https://api.example.com/v1", true},
|
||||
{"https://api.example.com:8443/v1//", "https://api.example.com:8443/v1", true},
|
||||
{"https://api.example.com", "https://api.example.com", true},
|
||||
{"https://api.example.com/V1", "https://api.example.com/V1", true},
|
||||
{"http://api.example.com/v1", "", false},
|
||||
{"https://user@api.example.com/v1", "", false},
|
||||
{"https://user:pw@api.example.com/v1", "", false},
|
||||
{"https://api.example.com/v1?x=1", "", false},
|
||||
{"https://api.example.com/v1?", "", false},
|
||||
{"https://api.example.com/v1#frag", "", false},
|
||||
{"https://api.example.com/v1#", "", false},
|
||||
{"https:api.example.com", "", false},
|
||||
{"https:///v1", "", false},
|
||||
{"", "", false},
|
||||
{"://bad", "", false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
got, ok := NormalizeRouteBaseURL(tc.raw)
|
||||
if got != tc.want || ok != tc.ok {
|
||||
t.Fatalf("NormalizeRouteBaseURL(%q) = %q, %v; want %q, %v", tc.raw, got, ok, tc.want, tc.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
+253
-32
@@ -4,6 +4,7 @@ import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
@@ -39,85 +40,231 @@ type usageWire struct {
|
||||
} `json:"prompt_tokens_details"`
|
||||
}
|
||||
|
||||
const (
|
||||
maxObservedToolFragments = 10000
|
||||
maxObservedToolArgumentBytes = 4*1024*1024 + 1
|
||||
maxObservedTailEvents = 10000
|
||||
)
|
||||
|
||||
// sseFacts are structural stream facts the route contract needs. They are
|
||||
// written by the parser goroutine before it sends the terminal event.
|
||||
type sseFacts struct {
|
||||
// finishChunks counts chunks with a non-empty finish_reason.
|
||||
finishChunks int
|
||||
finishReason string
|
||||
// multiChoice is set when any chunk carried more than one choice.
|
||||
multiChoice bool
|
||||
// fragmentAfterFinish is set when a tool-call fragment arrived in a chunk
|
||||
// after the one carrying finish_reason.
|
||||
fragmentAfterFinish bool
|
||||
}
|
||||
|
||||
// sseHooks are internal parser options. The zero value is ParseSSE.
|
||||
type sseHooks struct {
|
||||
facts *sseFacts
|
||||
// decideEOF, when set, receives a tool stream whose terminal would be
|
||||
// "tool call stream ended before [DONE]": success is the message_end the
|
||||
// stream would end with had [DONE] arrived, failure is the event emitted
|
||||
// today. It returns the event to send.
|
||||
decideEOF func(success, failure Event) Event
|
||||
}
|
||||
|
||||
// ParseSSE reads an OpenAI-compatible server-sent event body and emits
|
||||
// deltas plus one terminal message_end or error event.
|
||||
func ParseSSE(ctx context.Context, r io.Reader) <-chan Event {
|
||||
return parseSSE(ctx, r, sseHooks{})
|
||||
}
|
||||
|
||||
func parseSSE(ctx context.Context, r io.Reader, hooks sseHooks) <-chan Event {
|
||||
out := make(chan Event)
|
||||
go func() {
|
||||
defer close(out)
|
||||
emitParsed(ctx, r, out)
|
||||
emitParsed(ctx, r, out, hooks)
|
||||
}()
|
||||
return out
|
||||
}
|
||||
|
||||
func emitParsed(ctx context.Context, r io.Reader, out chan<- Event) {
|
||||
func emitParsed(ctx context.Context, r io.Reader, out chan<- Event, hooks sseHooks) {
|
||||
facts := hooks.facts
|
||||
if facts == nil {
|
||||
facts = &sseFacts{}
|
||||
}
|
||||
var content strings.Builder
|
||||
var reasoning strings.Builder
|
||||
calls := map[int]*ToolCall{}
|
||||
order := []int{}
|
||||
var usage Usage
|
||||
finished := ""
|
||||
sawDone := false
|
||||
sawUsage := false
|
||||
readOutcome := "clean_eof"
|
||||
lastFrameJSONComplete := false
|
||||
toolFragments := 0
|
||||
toolArgumentBytes := 0
|
||||
sc := bufio.NewScanner(r)
|
||||
sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
|
||||
reader := &sseEventReader{sc: sc}
|
||||
observation := func() *TerminalObservation {
|
||||
tailKind := reader.tailKind
|
||||
if tailKind == "" {
|
||||
tailKind = "unknown"
|
||||
}
|
||||
argumentsValid := len(order) > 0
|
||||
for _, idx := range order {
|
||||
var args map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(calls[idx].Arguments), &args); err != nil || args == nil {
|
||||
argumentsValid = false
|
||||
break
|
||||
}
|
||||
}
|
||||
return &TerminalObservation{
|
||||
ReadOutcome: readOutcome, LastFrameJSONComplete: lastFrameJSONComplete,
|
||||
UsagePresent: sawUsage, ToolFragments: toolFragments,
|
||||
ToolArgumentBytes: toolArgumentBytes, ToolArgumentsValid: argumentsValid,
|
||||
TailEventKind: tailKind, TailEventCount: reader.tailCount,
|
||||
}
|
||||
}
|
||||
buildTerminalFailure := func(why string) Event {
|
||||
message := Message{Role: RoleAssistant, Content: content.String(), StopReason: StopError}
|
||||
if reasoning.Len() > 0 {
|
||||
message.Thinking = []ThinkingBlock{{Thinking: reasoning.String()}}
|
||||
}
|
||||
failed := terminalStreamFailure(message, usage, finished, sawDone, why)
|
||||
failed.TerminalObservation = observation()
|
||||
return failed
|
||||
}
|
||||
sendTerminal := func(ev Event) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
readOutcome = "cancelled"
|
||||
emitAbort(out, content.String(), ctx.Err(), observation())
|
||||
case out <- ev:
|
||||
}
|
||||
}
|
||||
sendTerminalFailure := func(why string) {
|
||||
sendTerminal(buildTerminalFailure(why))
|
||||
}
|
||||
buildMessageEnd := func() Event {
|
||||
msg := Message{Role: RoleAssistant, Content: content.String(), StopReason: mapFinish(finished, len(order) > 0)}
|
||||
if reasoning.Len() > 0 {
|
||||
msg.Thinking = []ThinkingBlock{{Thinking: reasoning.String()}}
|
||||
}
|
||||
for _, idx := range order {
|
||||
msg.ToolCalls = append(msg.ToolCalls, *calls[idx])
|
||||
}
|
||||
if msg.StopReason == StopToolCalls && len(msg.ToolCalls) == 0 {
|
||||
msg.StopReason = StopEnd
|
||||
}
|
||||
return Event{Kind: KindMessageEnd, Message: msg, Usage: usage, FinishReason: finished, StreamComplete: sawDone, TerminalObservation: observation()}
|
||||
}
|
||||
for {
|
||||
data, ok := reader.next()
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
emitAbort(out, content.String(), ctx.Err())
|
||||
readOutcome = "cancelled"
|
||||
emitAbort(out, content.String(), ctx.Err(), observation())
|
||||
return
|
||||
}
|
||||
if data == "[DONE]" {
|
||||
reader.noteTail("done_literal")
|
||||
sawDone = true
|
||||
readOutcome = "done_marker"
|
||||
break
|
||||
}
|
||||
var chunk sseChunk
|
||||
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
|
||||
emitError(out, content.String(), false, "malformed stream chunk: "+err.Error())
|
||||
reader.noteTail(badFrameKind(data, err))
|
||||
readOutcome = "invalid_frame"
|
||||
lastFrameJSONComplete = false
|
||||
if sawUsage {
|
||||
sendTerminalFailure("malformed stream chunk after reported usage")
|
||||
return
|
||||
}
|
||||
emitError(out, content.String(), false, "malformed stream chunk: "+err.Error(), observation())
|
||||
return
|
||||
}
|
||||
reader.noteTail("json_frame")
|
||||
lastFrameJSONComplete = true
|
||||
if chunk.Error != nil && chunk.Error.Message != "" {
|
||||
readOutcome = "provider_error"
|
||||
msg := chunk.Error.Message
|
||||
if sawUsage {
|
||||
sendTerminalFailure("provider error after reported usage: " + msg)
|
||||
return
|
||||
}
|
||||
retryable := CapacityLimited(msg) && !ContextOverflow(msg)
|
||||
emitError(out, content.String(), retryable, msg)
|
||||
emitError(out, content.String(), retryable, msg, observation())
|
||||
return
|
||||
}
|
||||
if chunk.Usage != nil {
|
||||
usage = chunk.Usage.Usage
|
||||
sawUsage = true
|
||||
if chunk.Usage.Details.Cached > 0 {
|
||||
usage.CacheReadTokens = chunk.Usage.Details.Cached
|
||||
}
|
||||
}
|
||||
if len(chunk.Choices) > 1 {
|
||||
facts.multiChoice = true
|
||||
}
|
||||
finishedBefore := facts.finishChunks > 0
|
||||
for _, c := range chunk.Choices {
|
||||
if c.FinishReason != "" {
|
||||
if facts.finishChunks < maxObservedTailEvents {
|
||||
facts.finishChunks++
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(chunk.Choices) == 0 {
|
||||
continue
|
||||
}
|
||||
choice := chunk.Choices[0]
|
||||
if choice.FinishReason != "" {
|
||||
finished = choice.FinishReason
|
||||
facts.finishReason = finished
|
||||
}
|
||||
if finishedBefore && len(choice.Delta.ToolCalls) > 0 {
|
||||
facts.fragmentAfterFinish = true
|
||||
}
|
||||
if choice.Delta.Content != "" {
|
||||
content.WriteString(choice.Delta.Content)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
emitAbort(out, content.String(), ctx.Err())
|
||||
readOutcome = "cancelled"
|
||||
emitAbort(out, content.String(), ctx.Err(), observation())
|
||||
return
|
||||
case out <- Event{Kind: KindTextDelta, Text: choice.Delta.Content}:
|
||||
}
|
||||
}
|
||||
// reasoning_content(国产 OpenAI 兼容模型的思考链)与正文分流:
|
||||
// 只作为 reasoning_delta 事件外发,不计入 Message.Content(与 runner
|
||||
// stripThinkBlocks"正文无 think"语义对齐)。
|
||||
// stripThinkBlocks"正文无 think"语义对齐)。同时累计进 reasoning——
|
||||
// 交错思考模型(MiniMax/DeepSeek R1 等)要求历史 assistant 原样带回
|
||||
// 思考内容,丢弃会降低后续轮质量;捕获进 Message.Thinking 随会话
|
||||
// 转录持久化,请求序列化侧按需回放。
|
||||
if choice.Delta.Reasoning != "" {
|
||||
reasoning.WriteString(choice.Delta.Reasoning)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
emitAbort(out, content.String(), ctx.Err())
|
||||
readOutcome = "cancelled"
|
||||
emitAbort(out, content.String(), ctx.Err(), observation())
|
||||
return
|
||||
case out <- Event{Kind: KindReasoningDelta, ReasoningDelta: choice.Delta.Reasoning}:
|
||||
}
|
||||
}
|
||||
for _, tc := range choice.Delta.ToolCalls {
|
||||
if toolFragments < maxObservedToolFragments {
|
||||
toolFragments++
|
||||
}
|
||||
if n := len(tc.Function.Arguments); n > 0 && toolArgumentBytes < maxObservedToolArgumentBytes {
|
||||
if n > maxObservedToolArgumentBytes-toolArgumentBytes {
|
||||
toolArgumentBytes = maxObservedToolArgumentBytes
|
||||
} else {
|
||||
toolArgumentBytes += n
|
||||
}
|
||||
}
|
||||
cur, ok := calls[tc.Index]
|
||||
if !ok {
|
||||
cur = &ToolCall{}
|
||||
@@ -135,7 +282,8 @@ func emitParsed(ctx context.Context, r io.Reader, out chan<- Event) {
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
emitAbort(out, content.String(), ctx.Err())
|
||||
readOutcome = "cancelled"
|
||||
emitAbort(out, content.String(), ctx.Err(), observation())
|
||||
return
|
||||
case out <- Event{
|
||||
Kind: KindToolCallDelta,
|
||||
@@ -148,42 +296,107 @@ func emitParsed(ctx context.Context, r io.Reader, out chan<- Event) {
|
||||
}
|
||||
}
|
||||
if err := sc.Err(); err != nil {
|
||||
readOutcome = "read_error"
|
||||
if ctx.Err() != nil {
|
||||
emitAbort(out, content.String(), ctx.Err())
|
||||
readOutcome = "cancelled"
|
||||
emitAbort(out, content.String(), ctx.Err(), observation())
|
||||
return
|
||||
}
|
||||
emitError(out, content.String(), true, err.Error())
|
||||
if sawUsage {
|
||||
sendTerminalFailure("stream read failed after reported usage")
|
||||
return
|
||||
}
|
||||
emitError(out, content.String(), true, err.Error(), observation())
|
||||
return
|
||||
}
|
||||
// 取消必须压过正常结束。select 在两个分支都就绪时随机挑选,
|
||||
// 竞态下会把 message_end 发给已经取消的调用方。
|
||||
if ctx.Err() != nil {
|
||||
emitAbort(out, content.String(), ctx.Err())
|
||||
readOutcome = "cancelled"
|
||||
emitAbort(out, content.String(), ctx.Err(), observation())
|
||||
return
|
||||
}
|
||||
// length、空结束原因,或没有 [DONE] 的中断,都不把半截工具调用交给执行层。
|
||||
if !toolFinishExecutable(finished, sawDone) {
|
||||
order = nil
|
||||
// A finish reason alone does not prove the tool arguments arrived intact.
|
||||
// Preserve returned usage when rejecting a partial terminal, so the host
|
||||
// can charge this call and stop rather than treating it as a free retry.
|
||||
if (finished == "tool_calls" || finished == "function_call") && len(order) == 0 {
|
||||
sendTerminalFailure("tool call finish reason without a tool call")
|
||||
return
|
||||
}
|
||||
msg := Message{Role: RoleAssistant, Content: content.String(), StopReason: mapFinish(finished, len(order) > 0)}
|
||||
if len(order) > 0 {
|
||||
problem := ""
|
||||
switch {
|
||||
case !sawDone:
|
||||
problem = "tool call stream ended before [DONE]"
|
||||
case !toolFinishExecutable(finished, sawDone):
|
||||
problem = "tool call ended without a supported finish reason"
|
||||
default:
|
||||
for _, idx := range order {
|
||||
msg.ToolCalls = append(msg.ToolCalls, *calls[idx])
|
||||
call := calls[idx]
|
||||
var args map[string]json.RawMessage
|
||||
if call.ID == "" || call.Name == "" {
|
||||
problem = "tool call identity is incomplete"
|
||||
break
|
||||
}
|
||||
if msg.StopReason == StopToolCalls && len(msg.ToolCalls) == 0 {
|
||||
msg.StopReason = StopEnd
|
||||
if err := json.Unmarshal([]byte(call.Arguments), &args); err != nil || args == nil {
|
||||
problem = "tool call arguments are not a JSON object"
|
||||
break
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
emitAbort(out, content.String(), ctx.Err())
|
||||
case out <- Event{Kind: KindMessageEnd, Message: msg, Usage: usage, FinishReason: finished}:
|
||||
}
|
||||
}
|
||||
if problem != "" {
|
||||
if !sawDone && hooks.decideEOF != nil {
|
||||
sendTerminal(hooks.decideEOF(buildMessageEnd(), buildTerminalFailure(problem)))
|
||||
return
|
||||
}
|
||||
sendTerminalFailure(problem)
|
||||
return
|
||||
}
|
||||
}
|
||||
sendTerminal(buildMessageEnd())
|
||||
}
|
||||
|
||||
func terminalStreamFailure(message Message, usage Usage, reason string, complete bool, why string) Event {
|
||||
err := &StreamError{Message: why, Retryable: false}
|
||||
return Event{Kind: KindMessageEnd, Message: message, Usage: usage,
|
||||
FinishReason: reason, StreamComplete: complete, Err: err,
|
||||
ErrorMessage: why, Retryable: false}
|
||||
}
|
||||
|
||||
// sseEventReader 按空行切事件,并把同一事件里的多行 data: 用换行拼回。
|
||||
// 已经是完整 JSON 的 data 行单独成事件,这样单行流不用空行也能解析。
|
||||
// tailKind/tailCount 只记最后完成的结构事件类别与饱和计数,不存帧内容。
|
||||
type sseEventReader struct {
|
||||
sc *bufio.Scanner
|
||||
carry string
|
||||
tailKind string
|
||||
tailCount int
|
||||
}
|
||||
|
||||
func (r *sseEventReader) noteTail(kind string) {
|
||||
if r.tailCount < maxObservedTailEvents {
|
||||
r.tailCount++
|
||||
}
|
||||
r.tailKind = kind
|
||||
}
|
||||
|
||||
// badFrameKind classifies a data event that did not decode as a chunk using
|
||||
// only the decoder's error category and position, never its text or bytes.
|
||||
func badFrameKind(data string, err error) string {
|
||||
if strings.TrimSpace(data) == "" {
|
||||
return "empty_data"
|
||||
}
|
||||
var syntax *json.SyntaxError
|
||||
if !errors.As(err, &syntax) {
|
||||
return "unknown"
|
||||
}
|
||||
if syntax.Offset == int64(len(data)) {
|
||||
var probe json.RawMessage
|
||||
if errors.Is(json.NewDecoder(strings.NewReader(data)).Decode(&probe), io.ErrUnexpectedEOF) {
|
||||
return "partial_frame"
|
||||
}
|
||||
}
|
||||
return "unknown_non_json"
|
||||
}
|
||||
|
||||
func (r *sseEventReader) next() (string, bool) {
|
||||
@@ -212,6 +425,7 @@ func (r *sseEventReader) next() (string, bool) {
|
||||
}
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trimmed, ":") {
|
||||
r.noteTail("comment")
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(trimmed, "data:") {
|
||||
@@ -233,17 +447,16 @@ func (r *sseEventReader) next() (string, bool) {
|
||||
// toolFinishExecutable 只在供应商明确以工具调用或正常 stop 结束时执行。
|
||||
// length、缺失的结束原因,以及没有 [DONE] 的断开,都视为未完成。
|
||||
func toolFinishExecutable(reason string, sawDone bool) bool {
|
||||
if !sawDone {
|
||||
return false
|
||||
}
|
||||
switch reason {
|
||||
case "tool_calls", "function_call":
|
||||
return true
|
||||
case "stop":
|
||||
return true
|
||||
case "length":
|
||||
return false
|
||||
case "":
|
||||
return false
|
||||
default:
|
||||
return sawDone && reason != "error" && reason != "content_filter"
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,8 +481,8 @@ func mapFinish(reason string, hasTools bool) StopReason {
|
||||
}
|
||||
}
|
||||
|
||||
func emitError(out chan<- Event, partial string, retryable bool, message string) {
|
||||
out <- Event{
|
||||
func emitError(out chan<- Event, partial string, retryable bool, message string, observed ...*TerminalObservation) {
|
||||
event := Event{
|
||||
Kind: KindError,
|
||||
Retryable: retryable,
|
||||
ErrorMessage: message,
|
||||
@@ -280,14 +493,18 @@ func emitError(out chan<- Event, partial string, retryable bool, message string)
|
||||
StopReason: StopError,
|
||||
},
|
||||
}
|
||||
if len(observed) > 0 {
|
||||
event.TerminalObservation = observed[0]
|
||||
}
|
||||
out <- event
|
||||
}
|
||||
|
||||
func emitAbort(out chan<- Event, partial string, err error) {
|
||||
func emitAbort(out chan<- Event, partial string, err error, observed ...*TerminalObservation) {
|
||||
msg := "aborted"
|
||||
if err != nil {
|
||||
msg = err.Error()
|
||||
}
|
||||
out <- Event{
|
||||
event := Event{
|
||||
Kind: KindError,
|
||||
Retryable: false,
|
||||
ErrorMessage: msg,
|
||||
@@ -298,6 +515,10 @@ func emitAbort(out chan<- Event, partial string, err error) {
|
||||
StopReason: StopAborted,
|
||||
},
|
||||
}
|
||||
if len(observed) > 0 {
|
||||
event.TerminalObservation = observed[0]
|
||||
}
|
||||
out <- event
|
||||
}
|
||||
|
||||
// StreamError is a provider or transport failure encoded on the event stream.
|
||||
|
||||
+335
-6
@@ -2,6 +2,7 @@ package llm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -87,13 +88,39 @@ func TestParseSSEToolCallIncrements(t *testing.T) {
|
||||
if end.Message.StopReason != StopToolCalls || len(end.Message.ToolCalls) != 1 {
|
||||
t.Fatalf("end %#v", end.Message)
|
||||
}
|
||||
if end.TerminalObservation == nil || end.TerminalObservation.ReadOutcome != "done_marker" ||
|
||||
!end.TerminalObservation.ToolArgumentsValid || end.TerminalObservation.ToolFragments != 3 {
|
||||
t.Fatalf("valid multi-fragment terminal was not classified: %#v", end.TerminalObservation)
|
||||
}
|
||||
tc := end.Message.ToolCalls[0]
|
||||
if tc.ID != "call_1" || tc.Name != "read_file" || tc.Arguments != `{"path":"a.md"}` {
|
||||
t.Fatalf("tool call %#v", tc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSSELengthDropsUnfinishedToolCall(t *testing.T) {
|
||||
func TestParseSSEToolFinishWithoutCallFails(t *testing.T) {
|
||||
for _, reason := range []string{"tool_calls", "function_call"} {
|
||||
t.Run(reason, func(t *testing.T) {
|
||||
body := strings.Join([]string{
|
||||
`data: {"choices":[{"delta":{},"finish_reason":"` + reason + `"}],"usage":{"prompt_tokens":12,"completion_tokens":5,"total_tokens":17}}`,
|
||||
`data: [DONE]`,
|
||||
"",
|
||||
}, "\n")
|
||||
var end Event
|
||||
for ev := range ParseSSE(context.Background(), strings.NewReader(body)) {
|
||||
if ev.Kind == KindMessageEnd {
|
||||
end = ev
|
||||
}
|
||||
}
|
||||
if end.Err == nil || end.Message.StopReason != StopError || end.Usage.TotalTokens != 17 ||
|
||||
!end.StreamComplete || len(end.Message.ToolCalls) != 0 {
|
||||
t.Fatalf("empty tool finish must fail and retain Usage: %#v", end)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSSELengthFailsUnfinishedToolCall(t *testing.T) {
|
||||
body := strings.Join([]string{
|
||||
`data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"read_file","arguments":"{\"path\":"}}]}}]}`,
|
||||
`data: {"choices":[{"delta":{},"finish_reason":"length"}]}`,
|
||||
@@ -106,15 +133,15 @@ func TestParseSSELengthDropsUnfinishedToolCall(t *testing.T) {
|
||||
end = ev
|
||||
}
|
||||
}
|
||||
if end.Message.StopReason != StopEnd || len(end.Message.ToolCalls) != 0 {
|
||||
t.Fatalf("truncated tool call must not be executable: %#v", end.Message)
|
||||
if end.Message.StopReason != StopError || len(end.Message.ToolCalls) != 0 || end.Err == nil {
|
||||
t.Fatalf("truncated tool call must fail without execution: %#v", end)
|
||||
}
|
||||
if end.FinishReason != "length" {
|
||||
t.Fatalf("finish %q", end.FinishReason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSSEUncleanEndDropsToolCall(t *testing.T) {
|
||||
func TestParseSSEUncleanEndFailsToolCall(t *testing.T) {
|
||||
body := `data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"read_file","arguments":"{\"path\":"}}]}}]}`
|
||||
var end Event
|
||||
for ev := range ParseSSE(context.Background(), strings.NewReader(body)) {
|
||||
@@ -122,8 +149,172 @@ func TestParseSSEUncleanEndDropsToolCall(t *testing.T) {
|
||||
end = ev
|
||||
}
|
||||
}
|
||||
if end.Kind != KindMessageEnd || end.Message.StopReason != StopEnd || len(end.Message.ToolCalls) != 0 {
|
||||
t.Fatalf("unclean stream must not execute tools: %#v", end)
|
||||
if end.Kind != KindMessageEnd || end.Message.StopReason != StopError || len(end.Message.ToolCalls) != 0 || end.Err == nil {
|
||||
t.Fatalf("unclean stream must fail without executing tools: %#v", end)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSSEToolFinishWithoutDone(t *testing.T) {
|
||||
body := strings.Join([]string{
|
||||
`data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"echo","arguments":"{}"}}]}}]}`,
|
||||
`data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":12,"completion_tokens":5,"total_tokens":17}}`,
|
||||
"",
|
||||
}, "\n\n")
|
||||
var end Event
|
||||
for ev := range ParseSSE(context.Background(), strings.NewReader(body)) {
|
||||
if ev.Kind == KindMessageEnd {
|
||||
end = ev
|
||||
}
|
||||
}
|
||||
if end.Kind != KindMessageEnd || end.Message.StopReason != StopError ||
|
||||
len(end.Message.ToolCalls) != 0 || end.Err == nil || end.Retryable ||
|
||||
end.Usage.TotalTokens != 17 {
|
||||
t.Fatalf("unsafe tool call from unfinished stream: %#v", end)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTerminalObservationCleanEOFWithoutDone(t *testing.T) {
|
||||
body := strings.Join([]string{
|
||||
`data: {"choices":[{"delta":{"content":"PRIVATE_PROMPT_R05","tool_calls":[{"index":0,"id":"call_1","function":{"name":"PRIVATE_TOOL_R05","arguments":"{}"}}]}}]}`,
|
||||
`data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":12,"completion_tokens":5,"total_tokens":17}}`,
|
||||
"",
|
||||
}, "\n\n")
|
||||
var end Event
|
||||
for ev := range ParseSSE(context.Background(), strings.NewReader(body)) {
|
||||
if ev.Kind == KindMessageEnd {
|
||||
end = ev
|
||||
}
|
||||
}
|
||||
if end.Err == nil || end.StreamComplete || len(end.Message.ToolCalls) != 0 || end.Usage.TotalTokens != 17 {
|
||||
t.Fatalf("unfinished tool stream was released or unmetered: %#v", end)
|
||||
}
|
||||
meta := end.TerminalObservation
|
||||
if meta == nil || meta.ReadOutcome != "clean_eof" || !meta.LastFrameJSONComplete ||
|
||||
!meta.UsagePresent || meta.ToolFragments != 1 || meta.ToolArgumentBytes != 2 || !meta.ToolArgumentsValid {
|
||||
t.Fatalf("missing content-free clean EOF evidence: %#v", meta)
|
||||
}
|
||||
raw, err := json.Marshal(meta)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(raw), "PRIVATE_PROMPT_R05") || strings.Contains(string(raw), "PRIVATE_TOOL_R05") {
|
||||
t.Fatalf("terminal observation retained model content: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTerminalObservationMalformedAndReadError(t *testing.T) {
|
||||
usageAndTool := `data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"echo","arguments":"{}"}}]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":12,"completion_tokens":5,"total_tokens":17}}` + "\n\n"
|
||||
tests := []struct {
|
||||
name string
|
||||
ctx context.Context
|
||||
reader io.Reader
|
||||
outcome string
|
||||
usage int
|
||||
frameValid bool
|
||||
}{
|
||||
{"invalid-frame", context.Background(), strings.NewReader(usageAndTool + "data: {not-json}\n\n"), "invalid_frame", 17, false},
|
||||
{"read-error", context.Background(), &errorAfterDataReader{data: strings.NewReader(usageAndTool)}, "read_error", 17, true},
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
tests = append(tests, struct {
|
||||
name string
|
||||
ctx context.Context
|
||||
reader io.Reader
|
||||
outcome string
|
||||
usage int
|
||||
frameValid bool
|
||||
}{"cancelled", ctx, strings.NewReader(usageAndTool), "cancelled", 0, false})
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var end Event
|
||||
for ev := range ParseSSE(tc.ctx, tc.reader) {
|
||||
if ev.Kind == KindMessageEnd || ev.Kind == KindError {
|
||||
end = ev
|
||||
}
|
||||
}
|
||||
if end.Err == nil || len(end.Message.ToolCalls) != 0 || end.Usage.TotalTokens != tc.usage {
|
||||
t.Fatalf("failure lost Usage or released tools: %#v", end)
|
||||
}
|
||||
meta := end.TerminalObservation
|
||||
if meta == nil || meta.ReadOutcome != tc.outcome || meta.LastFrameJSONComplete != tc.frameValid {
|
||||
t.Fatalf("wrong terminal classification: %#v", meta)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSSEInvalidToolArguments(t *testing.T) {
|
||||
body := strings.Join([]string{
|
||||
`data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"publish_work_checkpoint","arguments":"{\"findings\":"}}]}}]}`,
|
||||
`data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":12,"completion_tokens":5,"total_tokens":17}}`,
|
||||
`data: [DONE]`,
|
||||
"",
|
||||
}, "\n\n")
|
||||
var end Event
|
||||
for ev := range ParseSSE(context.Background(), strings.NewReader(body)) {
|
||||
if ev.Kind == KindMessageEnd {
|
||||
end = ev
|
||||
}
|
||||
}
|
||||
if end.Kind != KindMessageEnd || end.Message.StopReason != StopError ||
|
||||
len(end.Message.ToolCalls) != 0 || end.Err == nil || end.Retryable ||
|
||||
end.Usage.TotalTokens != 17 {
|
||||
t.Fatalf("unsafe tool call with invalid JSON arguments: %#v", end)
|
||||
}
|
||||
if end.TerminalObservation == nil || end.TerminalObservation.ToolArgumentsValid ||
|
||||
end.TerminalObservation.ReadOutcome != "done_marker" {
|
||||
t.Fatalf("invalid arguments incorrectly classified: %#v", end.TerminalObservation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSSERejectsMissingToolIdentity(t *testing.T) {
|
||||
for _, tc := range []struct{ name, firstChunk string }{
|
||||
{"missing-id", `data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"name":"echo","arguments":"{}"}}]}}]}`},
|
||||
{"missing-name", `data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"arguments":"{}"}}]}}]}`},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
body := strings.Join([]string{
|
||||
tc.firstChunk,
|
||||
`data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":12,"completion_tokens":5,"total_tokens":17}}`,
|
||||
`data: [DONE]`,
|
||||
"",
|
||||
}, "\n\n")
|
||||
var end Event
|
||||
for ev := range ParseSSE(context.Background(), strings.NewReader(body)) {
|
||||
if ev.Kind == KindMessageEnd {
|
||||
end = ev
|
||||
}
|
||||
}
|
||||
if end.Message.StopReason != StopError || len(end.Message.ToolCalls) != 0 || end.Err == nil {
|
||||
t.Fatalf("missing tool identity reached execution: %#v", end)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSSERejectsNonObjectToolArguments(t *testing.T) {
|
||||
for _, tc := range []struct{ name, arguments string }{
|
||||
{"array", "[]"},
|
||||
{"null", "null"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
body := strings.Join([]string{
|
||||
`data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"echo","arguments":"` + tc.arguments + `"}}]}}]}`,
|
||||
`data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":12,"completion_tokens":5,"total_tokens":17}}`,
|
||||
`data: [DONE]`,
|
||||
"",
|
||||
}, "\n\n")
|
||||
var end Event
|
||||
for ev := range ParseSSE(context.Background(), strings.NewReader(body)) {
|
||||
if ev.Kind == KindMessageEnd {
|
||||
end = ev
|
||||
}
|
||||
}
|
||||
if end.Message.StopReason != StopError || len(end.Message.ToolCalls) != 0 || end.Err == nil {
|
||||
t.Fatalf("non-object tool arguments reached execution: %#v", end)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,6 +340,66 @@ func TestParseSSEMalformedIsError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSSEMalformedChunkAfterUsageKeepsUsage(t *testing.T) {
|
||||
body := strings.Join([]string{
|
||||
`data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"echo","arguments":"{}"}}]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":12,"completion_tokens":5,"total_tokens":17}}`,
|
||||
`data: {not-json}`,
|
||||
"",
|
||||
}, "\n\n")
|
||||
var end Event
|
||||
for ev := range ParseSSE(context.Background(), strings.NewReader(body)) {
|
||||
end = ev
|
||||
}
|
||||
if end.Kind != KindMessageEnd || end.Message.StopReason != StopError ||
|
||||
len(end.Message.ToolCalls) != 0 || end.Err == nil || end.Retryable ||
|
||||
end.Usage.TotalTokens != 17 {
|
||||
t.Fatalf("malformed chunk dropped already returned usage: %#v", end)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSSEProviderErrorAfterUsageKeepsUsage(t *testing.T) {
|
||||
body := strings.Join([]string{
|
||||
`data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"echo","arguments":"{}"}}]}}],"usage":{"prompt_tokens":12,"completion_tokens":5,"total_tokens":17}}`,
|
||||
`data: {"error":{"message":"server is at capacity"}}`,
|
||||
"",
|
||||
}, "\n\n")
|
||||
var end Event
|
||||
for ev := range ParseSSE(context.Background(), strings.NewReader(body)) {
|
||||
end = ev
|
||||
}
|
||||
if end.Kind != KindMessageEnd || end.Message.StopReason != StopError ||
|
||||
len(end.Message.ToolCalls) != 0 || end.Err == nil || end.Retryable ||
|
||||
end.Usage.TotalTokens != 17 {
|
||||
t.Fatalf("provider error after usage retried or lost its charge: %#v", end)
|
||||
}
|
||||
}
|
||||
|
||||
type errorAfterDataReader struct{ data *strings.Reader }
|
||||
|
||||
func (r *errorAfterDataReader) Read(p []byte) (int, error) {
|
||||
n, err := r.data.Read(p)
|
||||
if err == io.EOF {
|
||||
return 0, io.ErrUnexpectedEOF
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
func TestParseSSEReaderErrorAfterUsageKeepsUsage(t *testing.T) {
|
||||
body := strings.Join([]string{
|
||||
`data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"echo","arguments":"{}"}}]}}],"usage":{"prompt_tokens":12,"completion_tokens":5,"total_tokens":17}}`,
|
||||
"",
|
||||
}, "\n\n")
|
||||
var end Event
|
||||
for ev := range ParseSSE(context.Background(), &errorAfterDataReader{data: strings.NewReader(body)}) {
|
||||
end = ev
|
||||
}
|
||||
if end.Kind != KindMessageEnd || end.Message.StopReason != StopError ||
|
||||
len(end.Message.ToolCalls) != 0 || end.Err == nil || end.Retryable ||
|
||||
end.Usage.TotalTokens != 17 {
|
||||
t.Fatalf("reader error after usage retried or lost its charge: %#v", end)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSSECancel(t *testing.T) {
|
||||
pr, pw := io.Pipe()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
@@ -290,3 +541,81 @@ func TestParseSSEReasoningDelta(t *testing.T) {
|
||||
t.Fatalf("end content %q must not include reasoning", end.Message.Content)
|
||||
}
|
||||
}
|
||||
|
||||
// collectTerminal drains one stream and fails unless it carries exactly one
|
||||
// terminal message_end or error event.
|
||||
func collectTerminal(t *testing.T, ch <-chan Event) Event {
|
||||
t.Helper()
|
||||
var end Event
|
||||
terminals := 0
|
||||
for ev := range ch {
|
||||
if ev.Kind == KindMessageEnd || ev.Kind == KindError {
|
||||
terminals++
|
||||
end = ev
|
||||
}
|
||||
}
|
||||
if terminals != 1 {
|
||||
t.Fatalf("stream emitted %d terminal events, want 1", terminals)
|
||||
}
|
||||
return end
|
||||
}
|
||||
|
||||
func TestSSETailStructure(t *testing.T) {
|
||||
toolFrames := `data: {"choices":[{"delta":{"content":"PRIVATE_TAIL_R06","tool_calls":[{"index":0,"id":"call_1","function":{"name":"PRIVATE_TAIL_R06","arguments":"{}"}}]}}]}` + "\n\n" +
|
||||
`data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":12,"completion_tokens":5,"total_tokens":17}}` + "\n\n"
|
||||
textFrame := `data: {"choices":[{"delta":{"content":"PRIVATE_TAIL_R06"},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":2,"total_tokens":5}}` + "\n\n"
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
kind string
|
||||
count int
|
||||
outcome string
|
||||
complete bool
|
||||
tools int
|
||||
usage int
|
||||
wantError bool
|
||||
}{
|
||||
{"done", toolFrames + "data: [DONE]\n\n", "done_literal", 3, "done_marker", true, 1, 17, false},
|
||||
{"direct-eof", toolFrames, "json_frame", 2, "clean_eof", false, 0, 17, true},
|
||||
{"comment-then-eof", toolFrames + ": PRIVATE_TAIL_R06\n\n", "comment", 3, "clean_eof", false, 0, 17, true},
|
||||
{"empty-data", toolFrames + "data:\n\n", "empty_data", 3, "invalid_frame", false, 0, 17, true},
|
||||
{"unknown-non-json", toolFrames + "data: NOT_DONE\n\n", "unknown_non_json", 3, "invalid_frame", false, 0, 17, true},
|
||||
{"partial-frame", toolFrames + "data: {", "partial_frame", 3, "invalid_frame", false, 0, 17, true},
|
||||
{"plain-text-eof", textFrame, "json_frame", 1, "clean_eof", false, 0, 5, false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
end := collectTerminal(t, ParseSSE(context.Background(), strings.NewReader(tc.body)))
|
||||
got := end.TerminalObservation
|
||||
if got == nil || got.TailEventKind != tc.kind || got.TailEventCount != tc.count || got.ReadOutcome != tc.outcome {
|
||||
t.Fatalf("wrong tail observation: %+v", got)
|
||||
}
|
||||
if end.StreamComplete != tc.complete || end.Usage.TotalTokens != tc.usage {
|
||||
t.Fatalf("tail changed completion or lost Usage: complete=%v usage=%+v", end.StreamComplete, end.Usage)
|
||||
}
|
||||
if len(end.Message.ToolCalls) != tc.tools {
|
||||
t.Fatalf("released %d tool calls, want %d", len(end.Message.ToolCalls), tc.tools)
|
||||
}
|
||||
if (end.Err != nil) != tc.wantError {
|
||||
t.Fatalf("terminal error = %v, want error %v", end.Err, tc.wantError)
|
||||
}
|
||||
raw, err := json.Marshal(got)
|
||||
if err != nil || strings.Contains(string(raw), "PRIVATE_TAIL_R06") {
|
||||
t.Fatalf("unsafe tail observation: %s err=%v", raw, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSETailCountSaturates(t *testing.T) {
|
||||
body := `data: {"choices":[{"delta":{"content":"x"},"finish_reason":"stop"}]}` + "\n\n" +
|
||||
strings.Repeat(":\n", 10050)
|
||||
end := collectTerminal(t, ParseSSE(context.Background(), strings.NewReader(body)))
|
||||
got := end.TerminalObservation
|
||||
if got == nil || got.TailEventKind != "comment" || got.TailEventCount != 10000 || got.ReadOutcome != "clean_eof" {
|
||||
t.Fatalf("tail count did not saturate: %+v", got)
|
||||
}
|
||||
if end.Err != nil || end.Message.Content != "x" || len(end.Message.ToolCalls) != 0 {
|
||||
t.Fatalf("comments changed the parsed message: %+v", end)
|
||||
}
|
||||
}
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
event: message_start
|
||||
data: {"type":"message_start","message":{"id":"msg_01","type":"message","role":"assistant","model":"claude-fixture","usage":{"input_tokens":88,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}}
|
||||
|
||||
event: content_block_start
|
||||
data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_01ABC","name":"read_file"}}
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"path\":"}}
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"\"main.go\"}"}}
|
||||
|
||||
event: content_block_stop
|
||||
data: {"type":"content_block_stop","index":0}
|
||||
|
||||
event: message_delta
|
||||
data: {"type":"message_delta","delta":{"stop_reason":"tool_use"},"usage":{"output_tokens":31}}
|
||||
|
||||
event: message_stop
|
||||
data: {"type":"message_stop"}
|
||||
@@ -0,0 +1,29 @@
|
||||
event: message_start
|
||||
data: {"type":"message_start","message":{"id":"msg_02","type":"message","role":"assistant","model":"claude-fixture","usage":{"input_tokens":142,"cache_read_input_tokens":60,"cache_creation_input_tokens":0}}}
|
||||
|
||||
event: content_block_start
|
||||
data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"用户问 main.go,工具已返回。"}}
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"EqoBCkgIARABGAIiQA"}}
|
||||
|
||||
event: content_block_stop
|
||||
data: {"type":"content_block_stop","index":0}
|
||||
|
||||
event: content_block_start
|
||||
data: {"type":"content_block_start","index":1,"content_block":{"type":"text","text":""}}
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"查到了,main.go 一共 42 行。"}}
|
||||
|
||||
event: content_block_stop
|
||||
data: {"type":"content_block_stop","index":1}
|
||||
|
||||
event: message_delta
|
||||
data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":57}}
|
||||
|
||||
event: message_stop
|
||||
data: {"type":"message_stop"}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
event: message_start
|
||||
data: {"type":"message_start","message":{"id":"msg_03","type":"message","role":"assistant","model":"claude-fixture","usage":{"input_tokens":201}}}
|
||||
|
||||
event: content_block_start
|
||||
data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"42 行。"}}
|
||||
|
||||
event: content_block_stop
|
||||
data: {"type":"content_block_stop","index":0}
|
||||
|
||||
event: message_delta
|
||||
data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":9}}
|
||||
|
||||
event: message_stop
|
||||
data: {"type":"message_stop"}
|
||||
@@ -0,0 +1,45 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 交错思考模型(MiniMax 等)要求历史 assistant 原样带回思考内容:
|
||||
// SSE 把 reasoning_content 捕获进 Message.Thinking;序列化把它作为
|
||||
// <think> 前缀回放。没有思考块的消息保持零改动。
|
||||
func TestReasoningCaptureAndReplay(t *testing.T) {
|
||||
// 回放侧。
|
||||
msgs := []Message{
|
||||
{Role: RoleUser, Content: "总结"},
|
||||
{Role: RoleAssistant, Content: "结论。", Thinking: []ThinkingBlock{{Thinking: "先读材料。"}}},
|
||||
{Role: RoleAssistant, Content: "没有思考的轮次。"},
|
||||
}
|
||||
out := toProviderMessages(msgs)
|
||||
if got := out[1]["content"].(string); !strings.HasPrefix(got, "<think>\n先读材料。\n</think>") || !strings.HasSuffix(got, "结论。") {
|
||||
t.Fatalf("assistant replay malformed: %q", got)
|
||||
}
|
||||
if got := out[2]["content"].(string); got != "没有思考的轮次。" {
|
||||
t.Fatalf("non-thinking assistant must be untouched: %q", got)
|
||||
}
|
||||
if _, ok := out[0]["tool_calls"]; ok {
|
||||
t.Fatal("user messages carry no tool_calls")
|
||||
}
|
||||
// 捕获侧:直接构造 SSE 流。
|
||||
sse := "data: {\"choices\":[{\"delta\":{\"reasoning_content\":\"思考\"}}]}\n\n" +
|
||||
"data: {\"choices\":[{\"delta\":{\"content\":\"正文\"}}]}\n\n" +
|
||||
"data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n" +
|
||||
"data: [DONE]\n\n"
|
||||
var end Event
|
||||
for ev := range ParseSSE(t.Context(), strings.NewReader(sse)) {
|
||||
if ev.Kind == KindMessageEnd {
|
||||
end = ev
|
||||
}
|
||||
}
|
||||
if end.Message.Content != "正文" {
|
||||
t.Fatalf("content polluted by reasoning: %q", end.Message.Content)
|
||||
}
|
||||
if len(end.Message.Thinking) != 1 || end.Message.Thinking[0].Thinking != "思考" {
|
||||
t.Fatalf("reasoning not captured: %+v", end.Message.Thinking)
|
||||
}
|
||||
}
|
||||
+69
-1
@@ -52,10 +52,23 @@ type Message struct {
|
||||
Images []Image `json:"images,omitempty"`
|
||||
ToolCallID string `json:"tool_call_id,omitempty"`
|
||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
||||
// Thinking 是思考块:Anthropic 原生路径含签名、在请求里原样往返;
|
||||
// OpenAI 兼容路径由 SSE 的 reasoning_content 捕获(不含签名),随会话
|
||||
// 转录持久化,请求序列化时按 <think> 块回放(见 client.go replayThink)。
|
||||
Thinking []ThinkingBlock `json:"thinking,omitempty"`
|
||||
StopReason StopReason `json:"stop_reason,omitempty"`
|
||||
IsError bool `json:"is_error,omitempty"`
|
||||
}
|
||||
|
||||
// ThinkingBlock 是一块 Anthropic 思考内容。Redacted 为真时 Data 是不透明
|
||||
// 密文(redacted_thinking),往返时原样回送。
|
||||
type ThinkingBlock struct {
|
||||
Thinking string `json:"thinking,omitempty"`
|
||||
Signature string `json:"signature,omitempty"`
|
||||
Redacted bool `json:"redacted,omitempty"`
|
||||
Data string `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// ToolDecl is the JSON-schema declaration sent to the provider.
|
||||
type ToolDecl struct {
|
||||
Name string `json:"name"`
|
||||
@@ -85,6 +98,50 @@ type Usage struct {
|
||||
LastTotalTokens int `json:"last_total_tokens,omitempty"`
|
||||
}
|
||||
|
||||
// TerminalObservation records how a stream ended without retaining provider
|
||||
// content, tool identity, arguments, request headers, or error text. Counts
|
||||
// saturate at fixed limits; this evidence never authorizes a tool call.
|
||||
type TerminalObservation struct {
|
||||
ReadOutcome string `json:"readOutcome"`
|
||||
LastFrameJSONComplete bool `json:"lastFrameJSONComplete"`
|
||||
UsagePresent bool `json:"usagePresent"`
|
||||
ToolFragments int `json:"toolFragments"`
|
||||
ToolArgumentBytes int `json:"toolArgumentBytes"`
|
||||
ToolArgumentsValid bool `json:"toolArgumentsValid"`
|
||||
// TailEventKind is the structural class of the last SSE event the parser
|
||||
// completed: done_literal, json_frame, empty_data, comment,
|
||||
// unknown_non_json, partial_frame or unknown. TailEventCount counts those
|
||||
// events and saturates at 10000. Neither marks the stream complete.
|
||||
TailEventKind string `json:"tailEventKind"`
|
||||
TailEventCount int `json:"tailEventCount"`
|
||||
// BodyBoundary is set only by Client on a response it received; a bare
|
||||
// ParseSSE call leaves it nil.
|
||||
BodyBoundary *BodyBoundaryObservation `json:"bodyBoundary,omitempty"`
|
||||
// CompletionBasis mirrors Event.CompletionBasis.
|
||||
CompletionBasis string `json:"completionBasis,omitempty"`
|
||||
// ContractCheck is the first route-contract condition, C1 through C9, that
|
||||
// failed when a configured RouteContractEOF did not release a stream that
|
||||
// ended without [DONE]. Empty when no contract evaluated the stream.
|
||||
ContractCheck string `json:"contractCheck,omitempty"`
|
||||
}
|
||||
|
||||
// BodyBoundaryObservation records how the client read one HTTP response body.
|
||||
// It holds only fixed enums, booleans and a saturated byte count. Reaching
|
||||
// EOF proves only that the body ended, not that the SSE stream completed.
|
||||
type BodyBoundaryObservation struct {
|
||||
// ProtocolClass is http1, http2 or other.
|
||||
ProtocolClass string `json:"protocolClass"`
|
||||
// FramingClass is known_length, chunked, http2_stream, close_delimited or unknown.
|
||||
FramingClass string `json:"framingClass"`
|
||||
DeclaredLengthKnown bool `json:"declaredLengthKnown"`
|
||||
BytesRead int64 `json:"bytesRead"`
|
||||
BytesSaturated bool `json:"bytesSaturated"`
|
||||
EOFSeen bool `json:"eofSeen"`
|
||||
// LengthRelation is match, mismatch or unknown.
|
||||
LengthRelation string `json:"lengthRelation"`
|
||||
Uncompressed bool `json:"uncompressed"`
|
||||
}
|
||||
|
||||
// Event is one streamed update. Failures are KindError plus a final assistant
|
||||
// message whose StopReason is error or aborted. Stream implementations do not panic.
|
||||
type Event struct {
|
||||
@@ -100,10 +157,21 @@ type Event struct {
|
||||
// FinishReason 是供应商原始结束原因(stop、length、tool_calls 等)。
|
||||
// 空表示这条事件不携带结束原因。StopReason 的映射不受此字段影响。
|
||||
FinishReason string
|
||||
// StreamComplete records whether the OpenAI-compatible SSE stream supplied
|
||||
// its explicit [DONE] marker. Tool calls require this before execution
|
||||
// unless CompletionBasis is route_contract_eof.
|
||||
StreamComplete bool
|
||||
// CompletionBasis is set by Client on a successful OpenAI-chat message_end:
|
||||
// done_marker when [DONE] arrived, route_contract_eof when RouteContractEOF
|
||||
// released tool calls without it. Empty otherwise.
|
||||
CompletionBasis string
|
||||
// TerminalObservation is present only on a terminal/error event. It does
|
||||
// not change StreamComplete or the tool-execution safety gate.
|
||||
TerminalObservation *TerminalObservation
|
||||
Err error
|
||||
Retryable bool
|
||||
ErrorMessage string
|
||||
// HTTPStatus 是提供方的 HTTP 状态。0 表示这条错误不是 HTTP 响应。
|
||||
// HTTPStatus 是提供方的 HTTP 状态;0 表示尚无 HTTP 响应。
|
||||
HTTPStatus int
|
||||
// RetryAfter is the provider's requested wait. HasRetryAfter distinguishes
|
||||
// a real zero from a missing or unparseable header.
|
||||
|
||||
+348
-26
@@ -4,14 +4,17 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"agent-native/runtime/hooks"
|
||||
"agent-native/runtime/llm"
|
||||
"agent-native/runtime/memory"
|
||||
"agent-native/runtime/reminders"
|
||||
"agent-native/runtime/session"
|
||||
"agent-native/runtime/telemetry"
|
||||
"agent-native/runtime/tools"
|
||||
|
||||
"logging-system/logsys"
|
||||
@@ -35,6 +38,11 @@ type Event struct {
|
||||
Message llm.Message
|
||||
Tool string
|
||||
Text string
|
||||
// ToolIndex preserves the provider's call slot for tool_call_delta.
|
||||
ToolIndex int
|
||||
// CallID 在 tool_execution_start / tool_execution_end 时携带工具调用的
|
||||
// ID;tool_call_delta 也携带同一 ID。消费方按 ID 配对而非按顺序。
|
||||
CallID string
|
||||
// Detail carries tool arguments/results or reasoning deltas for
|
||||
// process-visualization consumers; log writers may ignore it.
|
||||
Detail string
|
||||
@@ -43,6 +51,12 @@ type Event struct {
|
||||
PromptTokens int
|
||||
CompletionTokens int
|
||||
TotalTokens int
|
||||
// 以下在 type=usage 时有意义(WP6:上一请求与缓存命中拆分)。
|
||||
LastPromptTokens int
|
||||
LastCompletionTokens int
|
||||
LastTotalTokens int
|
||||
CacheReadTokens int
|
||||
CacheWriteTokens int
|
||||
}
|
||||
|
||||
// Config drives one agent session.
|
||||
@@ -57,6 +71,9 @@ type Config struct {
|
||||
Trace func(name string, attrs map[string]string) func()
|
||||
MaxTurns int
|
||||
ToolExecution string
|
||||
// SerialTools 是并行执行下的顺序敏感工具名集合:这些调用按模型给出的
|
||||
// 原顺序在调用方 goroutine 逐个执行(互斥且保序),其余调用并发执行。
|
||||
SerialTools map[string]bool
|
||||
GetSteering func() []llm.Message
|
||||
GetFollowUp func() []llm.Message
|
||||
// OnEvent 观察循环事件。同一次 Run 内不会并发调用
|
||||
@@ -108,6 +125,33 @@ type Config struct {
|
||||
ContextBuffer int
|
||||
// KeepToolResults 是清理时保留的最近工具结果条数。0 表示 5。
|
||||
KeepToolResults int
|
||||
// ResultsDir 非空时,被清理的旧工具结果完整内容落盘到该目录
|
||||
// (占位写路径);空则占位指向会话记录(旧行为)。
|
||||
ResultsDir string
|
||||
// JournalOnly 为真且 Session 非空时:Session 只作意图日志与恢复配对
|
||||
// (不恢复的完整权威上下文),请求上下文始终由 History 组装;
|
||||
// 本次恢复写入的配对摘要以系统消息注入(不持久化,已在 journal 里)。
|
||||
// 为假(默认)保持既有语义:Session 为权威上下文。
|
||||
JournalOnly bool
|
||||
// AgentName 是 invoke_agent span 的名字后缀(OTel GenAI 约定
|
||||
// "invoke_agent <agent>")。空 = "agent-native"。
|
||||
AgentName string
|
||||
// ProviderName 是 gen_ai.system 属性值(如 openai / anthropic)。
|
||||
// 空 = "openai"。
|
||||
ProviderName string
|
||||
// Memory 是记忆端口(N3 WP5):nil = 不启用。启用时每次请求前
|
||||
// Recall 一次并以系统消息注入请求副本(不落盘、不累计)。
|
||||
Memory memory.Store
|
||||
// MemoryScope 是 Recall 的 scope 过滤("" = 不限,含 user/project;
|
||||
// AgentSpec.MemoryScope 的消费点)。
|
||||
MemoryScope string
|
||||
// RetryAttempts 是流式错误的默认重试次数(WP6 下沉为库默认):
|
||||
// 0 值 = 2 次(默认);负值 = 关闭;正数 = 次数。
|
||||
// 只在还没向调用方发出增量时重试(WithRetry 既有语义)。
|
||||
RetryAttempts int
|
||||
// traceCtx 是本次 Run 的 span 关联(Run 启动时建立;指针随 Config
|
||||
// 副本传播到各轮与各工具)。
|
||||
traceCtx *traceIDs
|
||||
// Hooks 是进程内钩子。空总线不改变停止、提醒和拒绝次数。
|
||||
Hooks *hooks.Bus
|
||||
// PermissionAsk 在钩子对一次工具调用表态 ask 时向宿主请求裁决。
|
||||
@@ -168,35 +212,84 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) {
|
||||
cfg.ToolExecution = ExecSequential
|
||||
}
|
||||
serializeHostCallbacks(&cfg)
|
||||
endRun := startSpan(cfg, "invoke_agent", map[string]string{"model": cfg.Model})
|
||||
if cfg.Memory != nil && cfg.Tools != nil {
|
||||
memory.RegisterWrite(cfg.Tools, cfg.Memory, cfg.MemoryScope)
|
||||
}
|
||||
// 流式错误默认重试(WP6):库默认 2 次、负值关闭;重试事件进事件面。
|
||||
if cfg.Stream != nil {
|
||||
attempts := cfg.RetryAttempts
|
||||
if attempts == 0 {
|
||||
attempts = 2
|
||||
}
|
||||
if attempts > 0 {
|
||||
cfg.Stream = llm.WithRetry(cfg.Stream, attempts, func(kind, detail string) {
|
||||
emit(cfg, Event{Type: kind, Text: detail})
|
||||
})
|
||||
}
|
||||
}
|
||||
cfg.traceCtx = newTraceContext()
|
||||
endRun := startSpan(cfg, "invoke_agent "+agentName(cfg), map[string]string{
|
||||
"gen_ai.system": providerName(cfg),
|
||||
"gen_ai.agent.name": agentName(cfg),
|
||||
"gen_ai.request.model": cfg.Model,
|
||||
}, true)
|
||||
defer endRun()
|
||||
var messages []llm.Message
|
||||
if cfg.Session != nil {
|
||||
if cfg.Session != nil && !cfg.JournalOnly {
|
||||
loaded, err := cfg.Session.Load()
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
messages = loaded
|
||||
}
|
||||
if err := recoverPending(ctx, cfg); err != nil {
|
||||
recovered, err := recoverPending(ctx, cfg)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
if cfg.Session != nil {
|
||||
// 尾部配对兜底:无论进程死在 persist(assistant) 与 commitToolIntent
|
||||
// 之间的哪个窗口,补全尾部 assistant 的无结果调用,转录配对恒成立。
|
||||
repaired, err := repairOrphanTail(cfg)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
recovered = append(recovered, repaired...)
|
||||
if cfg.Session != nil && !cfg.JournalOnly {
|
||||
loaded, err := cfg.Session.Load()
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
messages = loaded
|
||||
}
|
||||
seeded := false
|
||||
if len(messages) == 0 && len(cfg.History) > 0 {
|
||||
messages = append([]llm.Message{}, cfg.History...)
|
||||
seeded = true
|
||||
}
|
||||
prependedSys := false
|
||||
if len(messages) == 0 || messages[0].Role != llm.RoleSystem {
|
||||
sys := llm.Message{Role: llm.RoleSystem, Content: systemText(cfg)}
|
||||
messages = append([]llm.Message{sys}, messages...)
|
||||
if err := persist(cfg, sys); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
prependedSys = true
|
||||
}
|
||||
// 种子落盘(N3 WP2):转录为权威上下文时,History 只作一次性种子——
|
||||
// 不落盘则第二跑起种子静默丢失。已落盘的前置系统消息不重复写;
|
||||
// JournalOnly 时种子不进 journal(journal 只收本轮起的消息)。
|
||||
if seeded && !cfg.JournalOnly {
|
||||
for i, m := range messages {
|
||||
if i == 0 && prependedSys {
|
||||
continue
|
||||
}
|
||||
if err := persist(cfg, m); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
}
|
||||
}
|
||||
if cfg.JournalOnly && len(recovered) > 0 {
|
||||
// 恢复摘要以系统消息注入上下文(不持久化——内容已在 journal 里)。
|
||||
messages = append(messages, llm.Message{Role: llm.RoleSystem, Content: recoveredNoteText(recovered)})
|
||||
}
|
||||
if err := persist(cfg, user); err != nil {
|
||||
return Result{}, err
|
||||
@@ -250,20 +343,30 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) {
|
||||
end, errEv := consume(ctx, cfg, request)
|
||||
if errEv != nil {
|
||||
cfg.logger().Error("runtime turn failed", errEv, "retryable", end.Retryable)
|
||||
emitUsage(cfg, usage)
|
||||
return Result{Text: end.Message.Content, Messages: messages, StopReason: end.Message.StopReason, Err: errEv, Retryable: end.Retryable}, nil
|
||||
}
|
||||
last = end.Message
|
||||
usage.PromptTokens += end.Usage.PromptTokens
|
||||
usage.CompletionTokens += end.Usage.CompletionTokens
|
||||
usage.TotalTokens += end.Usage.TotalTokens
|
||||
// cache 只入累计位(WP6 决策:不加 Last* cache 字段,避免字段膨胀)。
|
||||
usage.CacheReadTokens += end.Usage.CacheReadTokens
|
||||
usage.CacheWriteTokens += end.Usage.CacheWriteTokens
|
||||
usage.LastPromptTokens = end.Usage.PromptTokens
|
||||
usage.LastCompletionTokens = end.Usage.CompletionTokens
|
||||
usage.LastTotalTokens = end.Usage.TotalTokens
|
||||
if end.Err != nil {
|
||||
cfg.logger().Error("runtime terminal stream failed", end.Err)
|
||||
emitUsage(cfg, usage)
|
||||
return Result{Text: last.Content, Messages: messages, StopReason: llm.StopError,
|
||||
Usage: usage, Err: end.Err, Retryable: false}, nil
|
||||
}
|
||||
if err := persist(cfg, last); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
messages = append(messages, last)
|
||||
emit(cfg, Event{Type: "message_end", Message: last, PromptTokens: end.Usage.PromptTokens, CompletionTokens: end.Usage.CompletionTokens, TotalTokens: end.Usage.TotalTokens})
|
||||
emit(cfg, Event{Type: "message_end", Message: last, PromptTokens: end.Usage.PromptTokens, CompletionTokens: end.Usage.CompletionTokens, TotalTokens: end.Usage.TotalTokens, CacheReadTokens: end.Usage.CacheReadTokens, CacheWriteTokens: end.Usage.CacheWriteTokens})
|
||||
|
||||
if last.StopReason == llm.StopToolCalls && len(last.ToolCalls) > 0 {
|
||||
if reason := budgetExceeded(cfg.Budget, usage, toolCalls, time.Now()); reason != "" {
|
||||
@@ -334,8 +437,8 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) {
|
||||
if decision.Continue && stopContinues < maxStopContinuations {
|
||||
stopContinues++
|
||||
if decision.Replacement != "" && len(messages) > 0 {
|
||||
messages[len(messages)-1].Content = decision.Replacement
|
||||
last.Content = decision.Replacement
|
||||
// 替换文只以 user 消息追加(N3 WP1/N6:不再改写内存里的
|
||||
// assistant——内存与转录保持一致;模型经该消息看到更正)。
|
||||
note := llm.Message{Role: llm.RoleUser, Content: decision.Replacement}
|
||||
if err := persist(cfg, note); err != nil {
|
||||
return Result{}, err
|
||||
@@ -401,7 +504,11 @@ func systemText(cfg Config) string {
|
||||
}
|
||||
|
||||
func consume(ctx context.Context, cfg Config, messages []llm.Message) (llm.Event, error) {
|
||||
endSpan := startSpan(cfg, "chat", map[string]string{"model": cfg.Model})
|
||||
spanAttrs := map[string]string{
|
||||
"gen_ai.system": providerName(cfg),
|
||||
"gen_ai.request.model": cfg.Model,
|
||||
}
|
||||
endSpan := startSpan(cfg, "chat "+cfg.Model, spanAttrs, false)
|
||||
defer endSpan()
|
||||
var decls []llm.ToolDecl
|
||||
if cfg.Tools != nil {
|
||||
@@ -430,7 +537,8 @@ func consume(ctx context.Context, cfg Config, messages []llm.Message) (llm.Event
|
||||
case llm.KindReasoningDelta:
|
||||
emit(cfg, Event{Type: "reasoning_delta", Text: ev.ReasoningDelta})
|
||||
case llm.KindToolCallDelta:
|
||||
emit(cfg, Event{Type: "tool_call_delta", Tool: ev.ToolName, Detail: ev.ArgumentsDelta})
|
||||
emit(cfg, Event{Type: "tool_call_delta", ToolIndex: ev.ToolIndex,
|
||||
CallID: ev.ToolID, Tool: ev.ToolName, Detail: ev.ArgumentsDelta})
|
||||
case llm.KindError:
|
||||
return ev, ev.Err
|
||||
case llm.KindMessageEnd:
|
||||
@@ -438,6 +546,11 @@ func consume(ctx context.Context, cfg Config, messages []llm.Message) (llm.Event
|
||||
sawEnd = true
|
||||
}
|
||||
}
|
||||
// usage 在 span 结束前补进属性(结束时过滤导出,见 telemetry 工厂)。
|
||||
if sawEnd {
|
||||
spanAttrs["gen_ai.usage.input_tokens"] = itoa(end.Usage.PromptTokens)
|
||||
spanAttrs["gen_ai.usage.output_tokens"] = itoa(end.Usage.CompletionTokens)
|
||||
}
|
||||
if !sawEnd {
|
||||
return llm.Event{Message: llm.Message{StopReason: llm.StopError}}, errString("stream ended without a message")
|
||||
}
|
||||
@@ -496,8 +609,11 @@ func serializeHostCallbacks(cfg *Config) {
|
||||
|
||||
func executeTools(ctx context.Context, cfg Config, assistant llm.Message) []llm.Message {
|
||||
if cfg.ToolExecution == ExecParallel && len(assistant.ToolCalls) > 1 {
|
||||
if len(cfg.SerialTools) == 0 {
|
||||
return executeParallel(ctx, cfg, assistant)
|
||||
}
|
||||
return executeMixed(ctx, cfg, assistant)
|
||||
}
|
||||
out := make([]llm.Message, 0, len(assistant.ToolCalls))
|
||||
for _, call := range assistant.ToolCalls {
|
||||
if err := ctx.Err(); err != nil {
|
||||
@@ -509,6 +625,35 @@ func executeTools(ctx context.Context, cfg Config, assistant llm.Message) []llm.
|
||||
return out
|
||||
}
|
||||
|
||||
// executeMixed 并行执行非串行调用,串行名(SerialTools)按模型给出的原顺序
|
||||
// 在调用方 goroutine 逐个执行——互斥且保序(卡片流等有先后依赖的工具不能只
|
||||
// 靠互斥:并发派发下先抢到锁的不一定是先声明的调用)。
|
||||
func executeMixed(ctx context.Context, cfg Config, assistant llm.Message) []llm.Message {
|
||||
out := make([]llm.Message, len(assistant.ToolCalls))
|
||||
var wg sync.WaitGroup
|
||||
serialIdx := make([]int, 0, len(assistant.ToolCalls))
|
||||
for i, call := range assistant.ToolCalls {
|
||||
if cfg.SerialTools[call.Name] {
|
||||
serialIdx = append(serialIdx, i)
|
||||
continue
|
||||
}
|
||||
wg.Add(1)
|
||||
go func(i int, call llm.ToolCall) {
|
||||
defer wg.Done()
|
||||
out[i] = runOne(ctx, cfg, call)
|
||||
}(i, call)
|
||||
}
|
||||
for _, i := range serialIdx {
|
||||
if err := ctx.Err(); err != nil {
|
||||
out[i] = cancelledToolMessage(assistant.ToolCalls[i], err)
|
||||
continue
|
||||
}
|
||||
out[i] = runOne(ctx, cfg, assistant.ToolCalls[i])
|
||||
}
|
||||
wg.Wait()
|
||||
return out
|
||||
}
|
||||
|
||||
func executeParallel(ctx context.Context, cfg Config, assistant llm.Message) []llm.Message {
|
||||
out := make([]llm.Message, len(assistant.ToolCalls))
|
||||
var wg sync.WaitGroup
|
||||
@@ -608,22 +753,38 @@ func noteToolHook(cfg Config, call llm.ToolCall, msg llm.Message) {
|
||||
cfg.Hooks.Emit(hooks.Call{Event: ev, Tool: call.Name, Args: call.Arguments, Result: msg.Content, IsError: msg.IsError})
|
||||
}
|
||||
|
||||
// toolCallKey 是把当前工具调用注入 Execute ctx 的私有键:并行执行下工具实现
|
||||
// 需要知道自己服务于哪次调用(配对过程事件、按调用记账),经
|
||||
// ToolCallFromContext 读取;旧宿主/直接构造的 ctx 返回 ok=false。
|
||||
type toolCallKey struct{}
|
||||
|
||||
// WithToolCall 把 call 挂进 ctx(内核 runOne 在执行前注入)。
|
||||
func WithToolCall(ctx context.Context, call llm.ToolCall) context.Context {
|
||||
return context.WithValue(ctx, toolCallKey{}, call)
|
||||
}
|
||||
|
||||
// ToolCallFromContext 取出当前正在执行的调用;不存在时 ok=false。
|
||||
func ToolCallFromContext(ctx context.Context) (llm.ToolCall, bool) {
|
||||
call, ok := ctx.Value(toolCallKey{}).(llm.ToolCall)
|
||||
return call, ok
|
||||
}
|
||||
|
||||
func runOne(ctx context.Context, cfg Config, call llm.ToolCall) llm.Message {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return cancelledToolMessage(call, err)
|
||||
}
|
||||
if why := rejectIncompleteToolCall(call); why != "" {
|
||||
msg := llm.Message{Role: llm.RoleTool, Name: call.Name, ToolCallID: call.ID, Content: why, IsError: true}
|
||||
emit(cfg, Event{Type: "tool_execution_start", Tool: call.Name, Detail: call.Arguments})
|
||||
emit(cfg, Event{Type: "tool_execution_end", Tool: call.Name, Text: why})
|
||||
emit(cfg, Event{Type: "tool_execution_start", Tool: call.Name, CallID: call.ID, Detail: call.Arguments})
|
||||
emit(cfg, Event{Type: "tool_execution_end", Tool: call.Name, CallID: call.ID, Text: why})
|
||||
if cfg.AfterTool != nil {
|
||||
cfg.AfterTool(call, msg)
|
||||
}
|
||||
return msg
|
||||
}
|
||||
emit(cfg, Event{Type: "tool_execution_start", Tool: call.Name, Detail: call.Arguments})
|
||||
emit(cfg, Event{Type: "tool_execution_start", Tool: call.Name, CallID: call.ID, Detail: call.Arguments})
|
||||
if gated, msg, blocked := gateTool(ctx, cfg, call); blocked {
|
||||
emit(cfg, Event{Type: "tool_execution_end", Tool: call.Name, Text: msg.Content})
|
||||
emit(cfg, Event{Type: "tool_execution_end", Tool: call.Name, CallID: call.ID, Text: msg.Content})
|
||||
noteToolHook(cfg, call, msg)
|
||||
if cfg.AfterTool != nil {
|
||||
cfg.AfterTool(call, msg)
|
||||
@@ -635,7 +796,7 @@ func runOne(ctx context.Context, cfg Config, call llm.ToolCall) llm.Message {
|
||||
if cfg.BeforeTool != nil {
|
||||
if err := cfg.BeforeTool(call); err != nil {
|
||||
msg := llm.Message{Role: llm.RoleTool, Name: call.Name, ToolCallID: call.ID, Content: err.Error(), IsError: true}
|
||||
emit(cfg, Event{Type: "tool_execution_end", Tool: call.Name, Text: msg.Content})
|
||||
emit(cfg, Event{Type: "tool_execution_end", Tool: call.Name, CallID: call.ID, Text: msg.Content})
|
||||
noteToolHook(cfg, call, msg)
|
||||
if cfg.AfterTool != nil {
|
||||
cfg.AfterTool(call, msg)
|
||||
@@ -646,13 +807,17 @@ func runOne(ctx context.Context, cfg Config, call llm.ToolCall) llm.Message {
|
||||
emit(cfg, Event{Type: "tool_execution_update", Tool: call.Name, Detail: call.Arguments})
|
||||
if err := commitToolIntent(cfg, call); err != nil {
|
||||
msg := llm.Message{Role: llm.RoleTool, Name: call.Name, ToolCallID: call.ID, Content: err.Error(), IsError: true}
|
||||
emit(cfg, Event{Type: "tool_execution_end", Tool: call.Name, Text: msg.Content})
|
||||
emit(cfg, Event{Type: "tool_execution_end", Tool: call.Name, CallID: call.ID, Text: msg.Content})
|
||||
return msg
|
||||
}
|
||||
endSpan := startSpan(cfg, "execute_tool", map[string]string{"tool": call.Name})
|
||||
endSpan := startSpan(cfg, "execute_tool "+call.Name, map[string]string{
|
||||
"gen_ai.system": providerName(cfg),
|
||||
"gen_ai.tool.name": call.Name,
|
||||
}, false)
|
||||
defer endSpan()
|
||||
res := tools.Result{Content: "no tools configured", IsError: true}
|
||||
known := cfg.Tools != nil && cfg.Tools.Has(call.Name)
|
||||
ctx = WithToolCall(ctx, call)
|
||||
switch {
|
||||
case known:
|
||||
res = cfg.Tools.Execute(ctx, call.Name, json.RawMessage(call.Arguments))
|
||||
@@ -668,7 +833,7 @@ func runOne(ctx context.Context, cfg Config, call llm.ToolCall) llm.Message {
|
||||
Content: res.Content,
|
||||
IsError: res.IsError,
|
||||
}
|
||||
emit(cfg, Event{Type: "tool_execution_end", Tool: call.Name, Text: res.Content})
|
||||
emit(cfg, Event{Type: "tool_execution_end", Tool: call.Name, CallID: call.ID, Text: res.Content})
|
||||
noteToolHook(cfg, call, msg)
|
||||
if cfg.AfterTool != nil {
|
||||
cfg.AfterTool(call, msg)
|
||||
@@ -685,13 +850,40 @@ func settle(cfg Config, last llm.Message, messages []llm.Message, usage llm.Usag
|
||||
|
||||
func prepareRequest(cfg Config, messages []llm.Message, turn int, repeats *repeatState, lengthNudge *bool) []llm.Message {
|
||||
request := clearOldResults(cfg, persistedProjection(cfg, messages))
|
||||
request = injectMemoryBlock(cfg, request)
|
||||
return reminders.Attach(request, turnReminderTexts(cfg, turn, repeats, lengthNudge))
|
||||
}
|
||||
|
||||
// injectMemoryBlock 把记忆召回块插到请求副本的系统段之后(request-scoped:
|
||||
// 不落盘、不累计;Recall 失败静默跳过——记忆是增强面,不阻断对话)。
|
||||
func injectMemoryBlock(cfg Config, request []llm.Message) []llm.Message {
|
||||
if cfg.Memory == nil {
|
||||
return request
|
||||
}
|
||||
entries, err := cfg.Memory.Recall(context.Background(), memory.RecallQuery{Scope: cfg.MemoryScope})
|
||||
if err != nil {
|
||||
return request
|
||||
}
|
||||
block := memory.Block(entries, time.Now())
|
||||
if block == "" {
|
||||
return request
|
||||
}
|
||||
// 系统段之后插入(保持历史与尾部不动)。
|
||||
i := 0
|
||||
for i < len(request) && request[i].Role == llm.RoleSystem {
|
||||
i++
|
||||
}
|
||||
out := make([]llm.Message, 0, len(request)+1)
|
||||
out = append(out, request[:i]...)
|
||||
out = append(out, llm.Message{Role: llm.RoleSystem, Content: block})
|
||||
return append(out, request[i:]...)
|
||||
}
|
||||
|
||||
// persistedProjection 在有会话存储时只采用已经落盘的消息。
|
||||
// steer 与 follow-up 必须先写入存储,下一轮请求才会看见它们。
|
||||
func persistedProjection(cfg Config, messages []llm.Message) []llm.Message {
|
||||
if cfg.Session == nil {
|
||||
// JournalOnly:journal 不作权威上下文,每轮请求都用内存态(History 组装)。
|
||||
if cfg.Session == nil || cfg.JournalOnly {
|
||||
return messages
|
||||
}
|
||||
loaded, err := cfg.Session.Load()
|
||||
@@ -703,14 +895,21 @@ func persistedProjection(cfg Config, messages []llm.Message) []llm.Message {
|
||||
|
||||
const unknownSideEffect = "副作用未知,未重试"
|
||||
|
||||
func recoverPending(ctx context.Context, cfg Config) error {
|
||||
// crashInterrupted 是尾部兜底配对的正文(进程死在 persist(assistant) 与
|
||||
// commitToolIntent 之间的窗口时使用)。
|
||||
const crashInterrupted = "进程中断,该调用未执行"
|
||||
|
||||
// recoverPending 把未配对的意图补齐(safe 双声明重放,否则「副作用未知」
|
||||
// 配对),返回本次写入的配对消息(JournalOnly 时供恢复摘要使用)。
|
||||
func recoverPending(ctx context.Context, cfg Config) ([]llm.Message, error) {
|
||||
if cfg.Session == nil {
|
||||
return nil
|
||||
return nil, nil
|
||||
}
|
||||
pending, err := cfg.Session.PendingIntents()
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
var out []llm.Message
|
||||
for _, in := range pending {
|
||||
current := tools.ReplayNever
|
||||
if cfg.Tools != nil {
|
||||
@@ -726,10 +925,84 @@ func recoverPending(ctx context.Context, cfg Config) error {
|
||||
msg.IsError = res.IsError
|
||||
}
|
||||
if err := persist(cfg, msg); err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, msg)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// repairOrphanTail 兜底配对:找到转录里最后一个带 tool_calls 的 assistant,
|
||||
// 凡没有对应 tool 结果的调用,补一条「进程中断,该调用未执行」的 IsError
|
||||
// 结果并落盘。与意图日志正交(按 CallID 判重,已配对的跳过;意图路径
|
||||
// 已处理的执行中死亡不受影响)。历史深处、其后已有新消息的孤立调用不在
|
||||
// 此处修补(追加只落在尾部,位置语义不破)——那种形态由毒化轮转覆盖。
|
||||
// repairOrphanTail 返回本次补写的配对消息(JournalOnly 时供恢复摘要使用)。
|
||||
func repairOrphanTail(cfg Config) ([]llm.Message, error) {
|
||||
if cfg.Session == nil {
|
||||
return nil, nil
|
||||
}
|
||||
msgs, err := cfg.Session.Load()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tail := -1
|
||||
for i := len(msgs) - 1; i >= 0; i-- {
|
||||
m := msgs[i]
|
||||
if m.Role == llm.RoleAssistant && len(m.ToolCalls) > 0 {
|
||||
tail = i
|
||||
break
|
||||
}
|
||||
if m.Role == llm.RoleTool {
|
||||
continue // 尾部连续 tool 结果属于同一条 assistant,继续上溯
|
||||
}
|
||||
break
|
||||
}
|
||||
if tail < 0 {
|
||||
return nil, nil
|
||||
}
|
||||
results := map[string]bool{}
|
||||
for _, m := range msgs {
|
||||
if m.Role == llm.RoleTool && m.ToolCallID != "" {
|
||||
results[m.ToolCallID] = true
|
||||
}
|
||||
}
|
||||
return nil
|
||||
var out []llm.Message
|
||||
for _, call := range msgs[tail].ToolCalls {
|
||||
if results[call.ID] {
|
||||
continue
|
||||
}
|
||||
pair := llm.Message{
|
||||
Role: llm.RoleTool, Name: call.Name, ToolCallID: call.ID,
|
||||
Content: crashInterrupted, IsError: true,
|
||||
}
|
||||
if err := persist(cfg, pair); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
results[call.ID] = true
|
||||
out = append(out, pair)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// recoveredNoteText 把本次恢复写入的配对摘成一段系统说明(注入上下文用;
|
||||
// 内容截断防爆,工具名与调用 ID 全列)。
|
||||
func recoveredNoteText(recovered []llm.Message) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("上次会话中断的调用已完成恢复(这是系统说明,不是用户发言):")
|
||||
for _, m := range recovered {
|
||||
content := m.Content
|
||||
if len([]rune(content)) > 120 {
|
||||
content = string([]rune(content)[:120]) + "…"
|
||||
}
|
||||
b.WriteString("\n- ")
|
||||
b.WriteString(m.Name)
|
||||
b.WriteString("(")
|
||||
b.WriteString(m.ToolCallID)
|
||||
b.WriteString("):")
|
||||
b.WriteString(content)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func commitToolIntent(cfg Config, call llm.ToolCall) error {
|
||||
@@ -745,10 +1018,38 @@ func commitToolIntent(cfg Config, call llm.ToolCall) error {
|
||||
})
|
||||
}
|
||||
|
||||
func startSpan(cfg Config, name string, attrs map[string]string) func() {
|
||||
func itoa(n int) string { return strconv.Itoa(n) }
|
||||
|
||||
// traceIDs 是一次 Run 的 span 关联:根 trace + invoke_agent 根 span。
|
||||
type traceIDs struct {
|
||||
traceID string
|
||||
rootID string
|
||||
}
|
||||
|
||||
// newTraceContext 建立一次 Run 的关联(Trace 为 nil 时也给空壳,调用方免判空)。
|
||||
func newTraceContext() *traceIDs {
|
||||
return &traceIDs{traceID: telemetry.NewTraceID(), rootID: telemetry.NewSpanID()}
|
||||
}
|
||||
|
||||
// startSpan 开一个 OTel GenAI span:root=true 时作 invoke_agent 根(无 parent),
|
||||
// 否则作子 span(parent = 根)。attrs 由调用方持有引用——存续期间可补
|
||||
// (usage 在结束时随过滤导出)。
|
||||
func startSpan(cfg Config, name string, attrs map[string]string, root bool) func() {
|
||||
if cfg.Trace == nil {
|
||||
return func() {}
|
||||
}
|
||||
if attrs == nil {
|
||||
attrs = map[string]string{}
|
||||
}
|
||||
if cfg.traceCtx != nil {
|
||||
attrs[telemetry.AttrTraceID] = cfg.traceCtx.traceID
|
||||
if root {
|
||||
attrs[telemetry.AttrSpanID] = cfg.traceCtx.rootID
|
||||
} else {
|
||||
attrs[telemetry.AttrSpanID] = telemetry.NewSpanID()
|
||||
attrs[telemetry.AttrParentSpanID] = cfg.traceCtx.rootID
|
||||
}
|
||||
}
|
||||
end := cfg.Trace(name, attrs)
|
||||
if end == nil {
|
||||
return func() {}
|
||||
@@ -756,6 +1057,21 @@ func startSpan(cfg Config, name string, attrs map[string]string) func() {
|
||||
return end
|
||||
}
|
||||
|
||||
// agentName / providerName 是 OTel 属性的默认值解析。
|
||||
func agentName(cfg Config) string {
|
||||
if strings.TrimSpace(cfg.AgentName) != "" {
|
||||
return cfg.AgentName
|
||||
}
|
||||
return "agent-native"
|
||||
}
|
||||
|
||||
func providerName(cfg Config) string {
|
||||
if strings.TrimSpace(cfg.ProviderName) != "" {
|
||||
return cfg.ProviderName
|
||||
}
|
||||
return "openai"
|
||||
}
|
||||
|
||||
func clearOldResults(cfg Config, messages []llm.Message) []llm.Message {
|
||||
window := cfg.ContextWindow
|
||||
if window <= 0 {
|
||||
@@ -772,7 +1088,7 @@ func clearOldResults(cfg Config, messages []llm.Message) []llm.Message {
|
||||
if !session.NeedsClear(session.EstimateChars(messages), window, reserve, buffer) {
|
||||
return messages
|
||||
}
|
||||
return session.ClearOldToolResults(messages, cfg.KeepToolResults)
|
||||
return session.ClearOldToolResultsTo(messages, cfg.KeepToolResults, cfg.ResultsDir)
|
||||
}
|
||||
|
||||
func turnReminderTexts(cfg Config, turn int, repeats *repeatState, lengthNudge *bool) []string {
|
||||
@@ -848,7 +1164,12 @@ func drain(fn func() []llm.Message) []llm.Message {
|
||||
}
|
||||
|
||||
func emitUsage(cfg Config, usage llm.Usage) {
|
||||
emit(cfg, Event{Type: "usage", PromptTokens: usage.PromptTokens, CompletionTokens: usage.CompletionTokens, TotalTokens: usage.TotalTokens})
|
||||
emit(cfg, Event{
|
||||
Type: "usage",
|
||||
PromptTokens: usage.PromptTokens, CompletionTokens: usage.CompletionTokens, TotalTokens: usage.TotalTokens,
|
||||
LastPromptTokens: usage.LastPromptTokens, LastCompletionTokens: usage.LastCompletionTokens, LastTotalTokens: usage.LastTotalTokens,
|
||||
CacheReadTokens: usage.CacheReadTokens, CacheWriteTokens: usage.CacheWriteTokens,
|
||||
})
|
||||
}
|
||||
|
||||
func emit(cfg Config, ev Event) {
|
||||
@@ -881,6 +1202,7 @@ func budgetExceeded(b *Budget, usage llm.Usage, toolCalls int, now time.Time) st
|
||||
// budgetStop 以预算超限收尾:发事件、留日志、按 StopError 返回(不可重试)。
|
||||
func budgetStop(cfg Config, last llm.Message, messages []llm.Message, usage llm.Usage, reason string) Result {
|
||||
emit(cfg, Event{Type: "budget_exceeded", Text: reason})
|
||||
emitUsage(cfg, usage)
|
||||
err := errString(reason)
|
||||
cfg.logger().Error("runtime budget exceeded", err)
|
||||
return Result{Text: last.Content, Messages: messages, StopReason: llm.StopError, Usage: usage, Err: err}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"agent-native/runtime/llm"
|
||||
"agent-native/runtime/tools"
|
||||
)
|
||||
|
||||
// ---- N2 WP1:前缀稳定契约(缓存友好的机器钉住:同一注册序列 → 逐字节确定)----
|
||||
|
||||
type contractTool struct {
|
||||
name, desc string
|
||||
deferred bool
|
||||
}
|
||||
|
||||
func (t contractTool) Name() string { return t.name }
|
||||
func (t contractTool) Description() string { return t.desc }
|
||||
func (t contractTool) Parameters() json.RawMessage { return json.RawMessage(`{"type":"object","properties":{}}`) }
|
||||
func (t contractTool) Execute(_ context.Context, _ json.RawMessage) (tools.Result, error) {
|
||||
return tools.Result{Content: "ok"}, nil
|
||||
}
|
||||
func (t contractTool) Exposure() string {
|
||||
if t.deferred {
|
||||
return tools.ExposureDeferred
|
||||
}
|
||||
return tools.ExposureDirect
|
||||
}
|
||||
|
||||
func buildContractRegistry() *tools.Registry {
|
||||
reg := tools.NewRegistry("")
|
||||
reg.Register(contractTool{name: "read_file", desc: "读文件"})
|
||||
reg.Register(contractTool{name: "write_file", desc: "写文件"})
|
||||
reg.Register(contractTool{name: "grep", desc: "搜索", deferred: true})
|
||||
return reg
|
||||
}
|
||||
|
||||
func TestSystemTextDeterministicAcrossIdenticalBuilds(t *testing.T) {
|
||||
a := systemText(Config{SystemPrompt: "sys", Tools: buildContractRegistry()})
|
||||
b := systemText(Config{SystemPrompt: "sys", Tools: buildContractRegistry()})
|
||||
if a != b {
|
||||
t.Fatalf("同一注册序列的系统文本必须逐字节一致:\n%q\nvs\n%q", a, b)
|
||||
}
|
||||
// 契约内容:直接工具全在清单内且按注册序;deferred 工具不进入清单。
|
||||
idx := strings.Index(a, "read_file")
|
||||
if idx < 0 || strings.Index(a, "write_file") < idx {
|
||||
t.Fatalf("直接工具须按注册序出现:%q", a)
|
||||
}
|
||||
if strings.Contains(a, "grep") {
|
||||
t.Fatalf("deferred 工具不得出现在系统文本:%q", a)
|
||||
}
|
||||
// 元工具注入位置确定(tool_search/call_tool 存在且在 Decls 中)。
|
||||
decls := buildContractRegistry().Decls()
|
||||
names := []string{}
|
||||
for _, d := range decls {
|
||||
names = append(names, d.Name)
|
||||
}
|
||||
if len(names) != 4 || names[0] != "read_file" || names[1] != "write_file" {
|
||||
t.Fatalf("Decls 顺序契约:%v", names)
|
||||
}
|
||||
if names[2] != "tool_search" && names[3] != "tool_search" {
|
||||
if names[2] != "call_tool" && names[3] != "call_tool" {
|
||||
t.Fatalf("元工具缺失:%v", names)
|
||||
}
|
||||
}
|
||||
var _ = llm.ToolDecl{} // 保持导入面稳定
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"agent-native/runtime/llm"
|
||||
"agent-native/runtime/tools"
|
||||
)
|
||||
|
||||
// ctxEchoTool 把自己从 ctx 读到的调用 ID 记下来(并发安全),验证内核
|
||||
// 在 Execute 前注入了当前调用。
|
||||
type ctxEchoTool struct {
|
||||
mu sync.Mutex
|
||||
ids []string
|
||||
bad int
|
||||
}
|
||||
|
||||
func (c *ctxEchoTool) Name() string { return "echo" }
|
||||
func (c *ctxEchoTool) Description() string { return "echo" }
|
||||
func (c *ctxEchoTool) Parameters() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
|
||||
func (c *ctxEchoTool) Execute(ctx context.Context, _ json.RawMessage) (tools.Result, error) {
|
||||
call, ok := ToolCallFromContext(ctx)
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if !ok || call.ID == "" || call.Name != "echo" {
|
||||
c.bad++
|
||||
return tools.Result{Content: "call missing from context", IsError: true}, nil
|
||||
}
|
||||
c.ids = append(c.ids, call.ID)
|
||||
return tools.Result{Content: "ok " + call.ID}, nil
|
||||
}
|
||||
|
||||
// TestLoopToolSeesOwnCallInContext 并行执行两个调用,各自读到自己的 ID。
|
||||
func TestLoopToolSeesOwnCallInContext(t *testing.T) {
|
||||
tool := &ctxEchoTool{}
|
||||
reg := tools.NewRegistry(t.TempDir())
|
||||
reg.Register(tool)
|
||||
res, err := Run(context.Background(), Config{
|
||||
Model: "fake",
|
||||
Tools: reg,
|
||||
ToolExecution: ExecParallel,
|
||||
Stream: script(
|
||||
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{
|
||||
{ID: "pa", Name: "echo", Arguments: `{}`},
|
||||
{ID: "pb", Name: "echo", Arguments: `{}`},
|
||||
}},
|
||||
llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd},
|
||||
),
|
||||
}, llm.Message{Role: llm.RoleUser, Content: "go"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tool.mu.Lock()
|
||||
defer tool.mu.Unlock()
|
||||
if tool.bad != 0 || len(tool.ids) != 2 {
|
||||
t.Fatalf("bad=%d ids=%v", tool.bad, tool.ids)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, id := range tool.ids {
|
||||
seen[id] = true
|
||||
}
|
||||
if !seen["pa"] || !seen["pb"] {
|
||||
t.Fatalf("ids = %v", tool.ids)
|
||||
}
|
||||
_ = res
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"agent-native/runtime/llm"
|
||||
"agent-native/runtime/tools"
|
||||
)
|
||||
|
||||
// TestLoopToolEventsCarryCallID 验证 tool_execution_start/end 事件带工具调用
|
||||
// ID:并行执行下消费方按 ID 配对(回归锚点,防止字段被删后顺序假设回归)。
|
||||
func TestLoopToolEventsCarryCallID(t *testing.T) {
|
||||
hits := 0
|
||||
reg := tools.NewRegistry(t.TempDir())
|
||||
reg.Register(hitTool{hits: &hits})
|
||||
var starts, ends []Event
|
||||
res, err := Run(context.Background(), Config{
|
||||
Model: "fake",
|
||||
Tools: reg,
|
||||
Stream: script(
|
||||
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
|
||||
ID: "call-7", Name: "echo", Arguments: `{}`,
|
||||
}}},
|
||||
llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd},
|
||||
),
|
||||
OnEvent: func(ev Event) {
|
||||
switch ev.Type {
|
||||
case "tool_execution_start":
|
||||
starts = append(starts, ev)
|
||||
case "tool_execution_end":
|
||||
ends = append(ends, ev)
|
||||
}
|
||||
},
|
||||
}, llm.Message{Role: llm.RoleUser, Content: "go"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if hits != 1 || len(starts) != 1 || len(ends) != 1 {
|
||||
t.Fatalf("hits=%d starts=%d ends=%d", hits, len(starts), len(ends))
|
||||
}
|
||||
if starts[0].CallID != "call-7" || starts[0].Tool != "echo" {
|
||||
t.Fatalf("start = %+v", starts[0])
|
||||
}
|
||||
if ends[0].CallID != "call-7" || ends[0].Tool != "echo" {
|
||||
t.Fatalf("end = %+v", ends[0])
|
||||
}
|
||||
_ = res
|
||||
}
|
||||
@@ -42,20 +42,19 @@ func TestLoopStopContinuesUntilCap(t *testing.T) {
|
||||
if res.Err != nil || res.Notice != "到顶了" {
|
||||
t.Fatalf("notice %q err %v", res.Notice, res.Err)
|
||||
}
|
||||
replaced, forged := 0, 0
|
||||
// N6(N3 WP1):assistant 不再被改写——替换文只以 user 消息出现,
|
||||
// 内存与转录一致。
|
||||
forged, notes := 0, 0
|
||||
for _, m := range res.Messages {
|
||||
if m.Role != llm.RoleAssistant {
|
||||
continue
|
||||
}
|
||||
if m.Content == "已作废" {
|
||||
replaced++
|
||||
}
|
||||
if m.Content == "forged-claim" {
|
||||
if m.Role == llm.RoleAssistant && m.Content == "forged-claim" {
|
||||
forged++
|
||||
}
|
||||
if m.Role == llm.RoleUser && m.Content == "已作废" {
|
||||
notes++
|
||||
}
|
||||
if replaced != 3 || forged != 1 {
|
||||
t.Fatalf("replaced=%d forged=%d transcript=%s", replaced, forged, transcriptText(res.Messages))
|
||||
}
|
||||
if forged != 4 || notes != 3 {
|
||||
t.Fatalf("forged=%d notes=%d transcript=%s", forged, notes, transcriptText(res.Messages))
|
||||
}
|
||||
if strings.Count(transcriptText(res.Messages), "再试") != 3 {
|
||||
t.Fatalf("reasons = %s", transcriptText(res.Messages))
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"agent-native/runtime/llm"
|
||||
"agent-native/runtime/tools"
|
||||
)
|
||||
|
||||
// ---- N3 WP4:循环 span 形态(名称后缀、关联、usage)----
|
||||
|
||||
type capturedSpan struct {
|
||||
name string
|
||||
traceID string
|
||||
spanID string
|
||||
parent string
|
||||
attrs map[string]string
|
||||
}
|
||||
|
||||
type spanRecorder struct {
|
||||
mu sync.Mutex
|
||||
spans []capturedSpan
|
||||
}
|
||||
|
||||
func (r *spanRecorder) trace() func(string, map[string]string) func() {
|
||||
return func(name string, attrs map[string]string) func() {
|
||||
return func() {
|
||||
// 结束时拷贝(与 telemetry 工厂的结束时过滤同步——usage 是存续期补的)。
|
||||
sp := capturedSpan{name: name, attrs: map[string]string{}}
|
||||
for k, v := range attrs {
|
||||
sp.attrs[k] = v
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
sp.traceID = sp.attrs["_trace_id"]
|
||||
sp.spanID = sp.attrs["_span_id"]
|
||||
sp.parent = sp.attrs["_parent_span_id"]
|
||||
r.spans = append(r.spans, sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoopSpansFollowGenAIConvention(t *testing.T) {
|
||||
rec := &spanRecorder{}
|
||||
reg := tools.NewRegistry(t.TempDir())
|
||||
reg.Register(&countingTool{name: "read_file"})
|
||||
res, err := Run(context.Background(), Config{
|
||||
Model: "claude-x", Tools: reg, MaxTurns: 3, Trace: rec.trace(),
|
||||
ProviderName: "anthropic", AgentName: "writer",
|
||||
Stream: script(
|
||||
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls,
|
||||
ToolCalls: []llm.ToolCall{{ID: "c1", Name: "read_file", Arguments: `{}`}}},
|
||||
llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd},
|
||||
),
|
||||
}, llm.Message{Role: llm.RoleUser, Content: "go"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = res
|
||||
rec.mu.Lock()
|
||||
defer rec.mu.Unlock()
|
||||
if len(rec.spans) < 4 {
|
||||
t.Fatalf("span 数 = %d(invoke_agent + 2×chat + execute_tool):%+v", len(rec.spans), rec.spans)
|
||||
}
|
||||
var root capturedSpan
|
||||
byName := map[string][]capturedSpan{}
|
||||
for _, sp := range rec.spans {
|
||||
switch {
|
||||
case strings.HasPrefix(sp.name, "invoke_agent "):
|
||||
byName["invoke"] = append(byName["invoke"], sp)
|
||||
case strings.HasPrefix(sp.name, "chat "):
|
||||
byName["chat"] = append(byName["chat"], sp)
|
||||
case strings.HasPrefix(sp.name, "execute_tool "):
|
||||
byName["tool"] = append(byName["tool"], sp)
|
||||
default:
|
||||
t.Fatalf("span 名不合约定:%q", sp.name)
|
||||
}
|
||||
}
|
||||
if len(byName["invoke"]) != 1 || byName["invoke"][0].name != "invoke_agent writer" {
|
||||
t.Fatalf("invoke_agent span:%+v", byName["invoke"])
|
||||
}
|
||||
root = byName["invoke"][0]
|
||||
if root.attrs["gen_ai.system"] != "anthropic" || root.attrs["gen_ai.agent.name"] != "writer" ||
|
||||
root.attrs["gen_ai.request.model"] != "claude-x" {
|
||||
t.Fatalf("根属性:%+v", root.attrs)
|
||||
}
|
||||
if len(byName["chat"]) != 2 {
|
||||
t.Fatalf("chat span 数:%+v", byName["chat"])
|
||||
}
|
||||
for _, sp := range byName["chat"] {
|
||||
if sp.name != "chat claude-x" {
|
||||
t.Fatalf("chat span 名:%q", sp.name)
|
||||
}
|
||||
if sp.traceID != root.traceID || sp.parent != root.spanID {
|
||||
t.Fatalf("关联断裂:trace=%q parent=%q 根=%q/%q", sp.traceID, sp.parent, root.traceID, root.spanID)
|
||||
}
|
||||
if sp.attrs["gen_ai.request.model"] != "claude-x" ||
|
||||
sp.attrs["gen_ai.usage.input_tokens"] == "" || sp.attrs["gen_ai.usage.output_tokens"] == "" {
|
||||
t.Fatalf("chat 属性/usage:%+v", sp.attrs)
|
||||
}
|
||||
}
|
||||
if len(byName["tool"]) != 1 || byName["tool"][0].name != "execute_tool read_file" {
|
||||
t.Fatalf("tool span:%+v", byName["tool"])
|
||||
}
|
||||
tool := byName["tool"][0]
|
||||
if tool.attrs["gen_ai.tool.name"] != "read_file" || tool.traceID != root.traceID || tool.parent != root.spanID {
|
||||
t.Fatalf("tool 属性/关联:%+v", tool)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"agent-native/runtime/llm"
|
||||
"agent-native/runtime/tools"
|
||||
)
|
||||
|
||||
// orderTool 记录自己的执行序号(并发安全)。
|
||||
type orderTool struct {
|
||||
name string
|
||||
mu sync.Mutex
|
||||
seq []string
|
||||
}
|
||||
|
||||
func (o *orderTool) Name() string { return o.name }
|
||||
func (o *orderTool) Description() string { return o.name }
|
||||
func (o *orderTool) Parameters() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
|
||||
func (o *orderTool) Execute(ctx context.Context, _ json.RawMessage) (tools.Result, error) {
|
||||
id := o.name
|
||||
if call, ok := ToolCallFromContext(ctx); ok {
|
||||
id = call.ID
|
||||
}
|
||||
o.mu.Lock()
|
||||
o.seq = append(o.seq, id)
|
||||
o.mu.Unlock()
|
||||
return tools.Result{Content: "ran " + id}, nil
|
||||
}
|
||||
|
||||
// TestLoopSerialToolsKeepOrder 并行模式下 SerialTools 命中的调用按声明顺序执行
|
||||
// (互斥不保序不够:卡片流等有先后依赖),未命中的并发执行。
|
||||
func TestLoopSerialToolsKeepOrder(t *testing.T) {
|
||||
cards := &orderTool{name: "card"}
|
||||
reads := &orderTool{name: "read"}
|
||||
reg := tools.NewRegistry(t.TempDir())
|
||||
reg.Register(cards)
|
||||
reg.Register(reads)
|
||||
res, err := Run(context.Background(), Config{
|
||||
Model: "fake", Tools: reg,
|
||||
ToolExecution: ExecParallel,
|
||||
SerialTools: map[string]bool{"card": true},
|
||||
Stream: script(
|
||||
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{
|
||||
{ID: "s1", Name: "card", Arguments: `{}`},
|
||||
{ID: "p1", Name: "read", Arguments: `{}`},
|
||||
{ID: "s2", Name: "card", Arguments: `{}`},
|
||||
{ID: "s3", Name: "card", Arguments: `{}`},
|
||||
}},
|
||||
llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd},
|
||||
),
|
||||
}, llm.Message{Role: llm.RoleUser, Content: "go"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cards.mu.Lock()
|
||||
got := append([]string(nil), cards.seq...)
|
||||
cards.mu.Unlock()
|
||||
if len(got) != 3 || got[0] != "s1" || got[1] != "s2" || got[2] != "s3" {
|
||||
t.Fatalf("card executions must follow declaration order s1→s2→s3, got %v", got)
|
||||
}
|
||||
reads.mu.Lock()
|
||||
nRead := len(reads.seq)
|
||||
reads.mu.Unlock()
|
||||
if nRead != 1 {
|
||||
t.Fatalf("read executions = %d", nRead)
|
||||
}
|
||||
_ = res
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -12,6 +13,54 @@ import (
|
||||
"agent-native/runtime/tools"
|
||||
)
|
||||
|
||||
func TestRunTerminalToolFailureKeepsUsage(t *testing.T) {
|
||||
hits, calls := 0, 0
|
||||
reg := tools.NewRegistry(t.TempDir())
|
||||
reg.Register(hitTool{hits: &hits})
|
||||
stream := func(context.Context, string, []llm.Message, []llm.ToolDecl, llm.StreamOptions) <-chan llm.Event {
|
||||
calls++
|
||||
out := make(chan llm.Event, 1)
|
||||
out <- llm.Event{
|
||||
Kind: llm.KindMessageEnd,
|
||||
Message: llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopError},
|
||||
Usage: llm.Usage{PromptTokens: 12, CompletionTokens: 5, TotalTokens: 17},
|
||||
Err: errors.New("incomplete tool call"),
|
||||
}
|
||||
close(out)
|
||||
return out
|
||||
}
|
||||
res, err := Run(context.Background(), Config{Model: "fixture", Tools: reg, Stream: stream},
|
||||
llm.Message{Role: llm.RoleUser, Content: "organize materials"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Err == nil || res.StopReason != llm.StopError || res.Usage.TotalTokens != 17 ||
|
||||
hits != 0 || calls != 1 {
|
||||
t.Fatalf("terminal failure retried, executed a tool, or lost usage: result=%#v hits=%d calls=%d", res, hits, calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunTerminalErrorCannotMasqueradeAsSuccess(t *testing.T) {
|
||||
stream := func(context.Context, string, []llm.Message, []llm.ToolDecl, llm.StreamOptions) <-chan llm.Event {
|
||||
out := make(chan llm.Event, 1)
|
||||
out <- llm.Event{Kind: llm.KindMessageEnd,
|
||||
Message: llm.Message{Role: llm.RoleAssistant, Content: "incomplete", StopReason: llm.StopEnd},
|
||||
Usage: llm.Usage{PromptTokens: 12, CompletionTokens: 5, TotalTokens: 17},
|
||||
Err: errors.New("terminal failed"),
|
||||
}
|
||||
close(out)
|
||||
return out
|
||||
}
|
||||
res, err := Run(context.Background(), Config{Model: "fixture", Stream: stream},
|
||||
llm.Message{Role: llm.RoleUser, Content: "go"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Err == nil || res.StopReason != llm.StopError || res.Usage.TotalTokens != 17 {
|
||||
t.Fatalf("error-bearing terminal was accepted as success: %#v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func script(turns ...llm.Message) llm.StreamFunc {
|
||||
var mu sync.Mutex
|
||||
i := 0
|
||||
|
||||
+12
-1
@@ -17,7 +17,18 @@ func askFrom(ctx context.Context) func(context.Context, string) (string, error)
|
||||
return fn
|
||||
}
|
||||
|
||||
func inputQuestion(res callToolResult) (string, bool) {
|
||||
// inputQuestion 提取需要用户输入的问题。两条路径:
|
||||
// 2026-07-28 协议级 resultType="input_required"(问题取文本块);
|
||||
// 旧版服务端的内容块 input_required(向后兼容)。
|
||||
func inputQuestion(res callToolResult, negotiated string) (string, bool) {
|
||||
if res.ResultType == "input_required" && SupportsResultType(negotiated) {
|
||||
for _, blk := range res.Content {
|
||||
if blk.Type == "text" && blk.Text != "" {
|
||||
return blk.Text, true
|
||||
}
|
||||
}
|
||||
return "", true
|
||||
}
|
||||
for _, blk := range res.Content {
|
||||
if blk.Type == "input_required" {
|
||||
return blk.Text, true
|
||||
|
||||
@@ -44,8 +44,23 @@ func fakeServerHandle(req rpcRequest) rpcResponse {
|
||||
}
|
||||
switch req.Method {
|
||||
case "initialize":
|
||||
// 协商:取(客户端请求, 自身上限)的较小者(ISO 日期字典序可比);
|
||||
// AGENT_NATIVE_TEST_MCP_VERSION 可压低上限以测回落路径。
|
||||
own := ProtocolVersion
|
||||
if v := os.Getenv("AGENT_NATIVE_TEST_MCP_VERSION"); v != "" {
|
||||
own = v
|
||||
}
|
||||
negotiated := own
|
||||
var p initializeParams
|
||||
if req.Params != nil {
|
||||
raw, _ := json.Marshal(req.Params)
|
||||
_ = json.Unmarshal(raw, &p)
|
||||
if p.ProtocolVersion != "" && p.ProtocolVersion < negotiated {
|
||||
negotiated = p.ProtocolVersion
|
||||
}
|
||||
}
|
||||
return respond(initializeResult{
|
||||
ProtocolVersion: ProtocolVersion,
|
||||
ProtocolVersion: negotiated,
|
||||
ServerInfo: clientInfo{Name: "fake-mcp", Version: "0"},
|
||||
})
|
||||
case "tools/list":
|
||||
@@ -71,14 +86,64 @@ func fakeServerHandle(req rpcRequest) rpcResponse {
|
||||
case "slow":
|
||||
time.Sleep(2 * time.Second)
|
||||
return respond(callToolResult{Content: []contentBlock{{Type: "text", Text: "slow done"}}})
|
||||
case "need_input":
|
||||
// 2026-07-28 协议级 input_required:tasks/update 投递后重试得终结果。
|
||||
if input, ok := fakeTaskInputs()["task-1"]; ok {
|
||||
fakeTaskInputs()["task-1"] = ""
|
||||
return respond(callToolResult{Content: []contentBlock{{Type: "text", Text: "答案已收到: " + input}}})
|
||||
}
|
||||
return respond(callToolResult{
|
||||
Content: []contentBlock{{Type: "text", Text: "要答案"}},
|
||||
ResultType: "input_required",
|
||||
TaskID: "task-1",
|
||||
})
|
||||
case "legacy_ask":
|
||||
return respond(callToolResult{Content: []contentBlock{{Type: "input_required", Text: "选一个颜色"}}})
|
||||
default:
|
||||
return fail(-32602, "unknown tool "+p.Name)
|
||||
}
|
||||
case "tasks/update":
|
||||
var p struct {
|
||||
TaskID string `json:"taskId"`
|
||||
Input string `json:"input"`
|
||||
}
|
||||
if req.Params != nil {
|
||||
raw, _ := json.Marshal(req.Params)
|
||||
_ = json.Unmarshal(raw, &p)
|
||||
}
|
||||
fakeTaskInputs()[p.TaskID] = p.Input
|
||||
return respond(map[string]any{"ok": true})
|
||||
case "tasks/get":
|
||||
var p struct {
|
||||
TaskID string `json:"taskId"`
|
||||
}
|
||||
if req.Params != nil {
|
||||
raw, _ := json.Marshal(req.Params)
|
||||
_ = json.Unmarshal(raw, &p)
|
||||
}
|
||||
return respond(taskResult{TaskID: p.TaskID, Status: "running"})
|
||||
case "tasks/cancel":
|
||||
var p struct {
|
||||
TaskID string `json:"taskId"`
|
||||
}
|
||||
if req.Params != nil {
|
||||
raw, _ := json.Marshal(req.Params)
|
||||
_ = json.Unmarshal(raw, &p)
|
||||
}
|
||||
delete(fakeTaskInputs(), p.TaskID)
|
||||
return respond(map[string]any{"ok": true})
|
||||
default:
|
||||
return fail(-32601, "method not found: "+req.Method)
|
||||
}
|
||||
}
|
||||
|
||||
// fakeTaskInputs 是 need_input 工具的任务输入存储(进程内)。
|
||||
func fakeTaskInputs() map[string]string {
|
||||
return taskInputStore
|
||||
}
|
||||
|
||||
var taskInputStore = map[string]string{}
|
||||
|
||||
func fakeServerTools() []toolInfo {
|
||||
return []toolInfo{
|
||||
{
|
||||
@@ -96,6 +161,16 @@ func fakeServerTools() []toolInfo {
|
||||
Description: "Responds after a delay",
|
||||
InputSchema: json.RawMessage(`{"type":"object","properties":{}}`),
|
||||
},
|
||||
{
|
||||
Name: "need_input",
|
||||
Description: "Requires user input (2026-07-28 resultType)",
|
||||
InputSchema: json.RawMessage(`{"type":"object","properties":{}}`),
|
||||
},
|
||||
{
|
||||
Name: "legacy_ask",
|
||||
Description: "Requires user input (legacy content block)",
|
||||
// 故意无 InputSchema:触发 manager 的合成默认 schema 路径。
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@ type httpClient struct {
|
||||
sessionID string
|
||||
mu sync.Mutex
|
||||
nextID int64
|
||||
// negotiated 握手后写入(atomic.Value 存 string)。
|
||||
negotiated atomic.Value
|
||||
}
|
||||
|
||||
// DialHTTP 建立 HTTP 客户端并完成 initialize 握手。
|
||||
@@ -51,6 +53,9 @@ func (c *httpClient) handshake(ctx context.Context) error {
|
||||
ProtocolVersion: ProtocolVersion,
|
||||
ClientInfo: clientInfo{Name: "agent-native", Version: "1"},
|
||||
}, &res)
|
||||
if err == nil {
|
||||
c.negotiated.Store(res.ProtocolVersion)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -60,9 +65,32 @@ func (c *httpClient) Initialize(ctx context.Context) (initializeResult, error) {
|
||||
ProtocolVersion: ProtocolVersion,
|
||||
ClientInfo: clientInfo{Name: "agent-native", Version: "1"},
|
||||
}, &res)
|
||||
if err == nil {
|
||||
c.negotiated.Store(res.ProtocolVersion)
|
||||
}
|
||||
return res, err
|
||||
}
|
||||
|
||||
// NegotiatedVersion 是握手后服务端确认的协议版本(未握手为空)。
|
||||
func (c *httpClient) NegotiatedVersion() string { return c.negotiated.Load().(string) }
|
||||
|
||||
// TasksGet 查询长任务状态(2026-07-28)。
|
||||
func (c *httpClient) TasksGet(ctx context.Context, taskID string) (taskResult, error) {
|
||||
var res taskResult
|
||||
err := c.roundTrip(ctx, "tasks/get", map[string]string{"taskId": taskID}, &res)
|
||||
return res, err
|
||||
}
|
||||
|
||||
// TasksUpdate 向长任务投递用户输入(2026-07-28)。
|
||||
func (c *httpClient) TasksUpdate(ctx context.Context, taskID, input string) error {
|
||||
return c.roundTrip(ctx, "tasks/update", map[string]string{"taskId": taskID, "input": input}, nil)
|
||||
}
|
||||
|
||||
// TasksCancel 取消长任务(2026-07-28)。
|
||||
func (c *httpClient) TasksCancel(ctx context.Context, taskID string) error {
|
||||
return c.roundTrip(ctx, "tasks/cancel", map[string]string{"taskId": taskID}, nil)
|
||||
}
|
||||
|
||||
func (c *httpClient) ListTools(ctx context.Context) ([]toolInfo, error) {
|
||||
var out []toolInfo
|
||||
cursor := ""
|
||||
|
||||
+57
-3
@@ -173,7 +173,7 @@ func (m *Manager) registerServer(ctx context.Context, reg *tools.Registry, cfg S
|
||||
}
|
||||
schema := ti.InputSchema
|
||||
if len(schema) == 0 {
|
||||
schema = json.RawMessage(`{"type":"object","properties":{}}`)
|
||||
schema = json.RawMessage(`{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{}}`)
|
||||
}
|
||||
reg.Register(&bridgeTool{
|
||||
client: c,
|
||||
@@ -251,7 +251,11 @@ func (t *bridgeTool) Execute(ctx context.Context, args json.RawMessage) (tools.R
|
||||
if err != nil {
|
||||
return tools.Result{Content: fmt.Sprintf("mcp tool %s failed: %v", t.declName, err), IsError: true}, nil
|
||||
}
|
||||
if question, ok := inputQuestion(res); ok {
|
||||
// input_required:2026-07-28 协议级结果(取应答后先 tasks/update 再
|
||||
// 用原参数重试 tools/call);旧版内容块路径保持原样。
|
||||
if question, ok := inputQuestion(res, t.client.NegotiatedVersion()); ok && res.ResultType == "input_required" && SupportsResultType(t.client.NegotiatedVersion()) {
|
||||
return t.answerAndRetry(ctx, args, res, question)
|
||||
} else if ok {
|
||||
ask := askFrom(ctx)
|
||||
if ask == nil {
|
||||
return tools.Result{Content: "需要用户输入:" + question, IsError: true}, nil
|
||||
@@ -279,8 +283,58 @@ func (t *bridgeTool) Execute(ctx context.Context, args json.RawMessage) (tools.R
|
||||
return tools.Result{Content: out}, nil
|
||||
}
|
||||
|
||||
// maxInputRounds 是 input_required 应答重试的上限(防服务端循环要输入)。
|
||||
const maxInputRounds = 3
|
||||
|
||||
// answerAndRetry 处理 2026-07-28 的协议级 input_required:经 ask 端口取
|
||||
// 应答,TaskID 非空时先 tasks/update 投递,然后用原参数重试 tools/call;
|
||||
// 服务端连续要输入最多 maxInputRounds 轮。
|
||||
func (t *bridgeTool) answerAndRetry(ctx context.Context, args json.RawMessage, res callToolResult, question string) (tools.Result, error) {
|
||||
for round := 0; round < maxInputRounds; round++ {
|
||||
ask := askFrom(ctx)
|
||||
if ask == nil {
|
||||
return tools.Result{Content: "需要用户输入:" + question, IsError: true}, nil
|
||||
}
|
||||
answer, aerr := ask(ctx, question)
|
||||
if aerr != nil {
|
||||
return tools.Result{Content: aerr.Error(), IsError: true}, nil
|
||||
}
|
||||
if res.TaskID != "" {
|
||||
if uerr := t.client.TasksUpdate(ctx, res.TaskID, answer); uerr != nil {
|
||||
return tools.Result{Content: fmt.Sprintf("mcp task update failed: %v", uerr), IsError: true}, nil
|
||||
}
|
||||
}
|
||||
var err error
|
||||
res, err = t.call(ctx, args)
|
||||
if err != nil {
|
||||
return tools.Result{Content: fmt.Sprintf("mcp tool %s failed: %v", t.declName, err), IsError: true}, nil
|
||||
}
|
||||
q, ok := inputQuestion(res, t.client.NegotiatedVersion())
|
||||
if !ok || res.ResultType != "input_required" || !SupportsResultType(t.client.NegotiatedVersion()) {
|
||||
var b strings.Builder
|
||||
for _, blk := range res.Content {
|
||||
if blk.Type == "text" && blk.Text != "" {
|
||||
b.WriteString(blk.Text)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
}
|
||||
out := b.String()
|
||||
if res.IsError {
|
||||
return tools.Result{Content: out, IsError: true}, nil
|
||||
}
|
||||
return tools.Result{Content: strings.TrimSpace(out)}, nil
|
||||
}
|
||||
question = q
|
||||
}
|
||||
return tools.Result{Content: "需要用户输入:" + question, IsError: true}, nil
|
||||
}
|
||||
|
||||
func (t *bridgeTool) call(ctx context.Context, args json.RawMessage) (callToolResult, error) {
|
||||
callCtx, cancel := context.WithTimeout(ctx, requestTimeout(t.serverCfg))
|
||||
defer cancel()
|
||||
return t.client.CallTool(callCtx, t.toolName, args)
|
||||
res, err := t.client.CallTool(callCtx, t.toolName, args)
|
||||
if err == nil && res.TaskID != "" {
|
||||
trackerFrom(ctx).note(t.client, res.TaskID)
|
||||
}
|
||||
return res, err
|
||||
}
|
||||
|
||||
+156
-3
@@ -45,7 +45,7 @@ func TestStdioClientHandshakeListAndCall(t *testing.T) {
|
||||
defer c.Close()
|
||||
|
||||
list, err := c.ListTools(ctx)
|
||||
if err != nil || len(list) != 3 || list[0].Name != "echo" {
|
||||
if err != nil || len(list) != 5 || list[0].Name != "echo" {
|
||||
t.Fatalf("list failed: %v %+v", err, list)
|
||||
}
|
||||
res, err := c.CallTool(ctx, "echo", json.RawMessage(`{"text":"hi"}`))
|
||||
@@ -133,7 +133,7 @@ func TestHTTPClientJSONMode(t *testing.T) {
|
||||
t.Fatalf("session id must be captured, got %q", c.sessionID)
|
||||
}
|
||||
list, err := c.ListTools(ctx)
|
||||
if err != nil || len(list) != 3 {
|
||||
if err != nil || len(list) != 5 {
|
||||
t.Fatalf("list failed: %v %+v", err, list)
|
||||
}
|
||||
res, err := c.CallTool(ctx, "echo", json.RawMessage(`{"text":"net"}`))
|
||||
@@ -159,7 +159,7 @@ func TestHTTPClientSSEMode(t *testing.T) {
|
||||
}
|
||||
defer c.Close()
|
||||
list, err := c.ListTools(ctx)
|
||||
if err != nil || len(list) != 3 {
|
||||
if err != nil || len(list) != 5 {
|
||||
t.Fatalf("sse list failed: %v %+v", err, list)
|
||||
}
|
||||
}
|
||||
@@ -361,8 +361,14 @@ type askClient struct{}
|
||||
func (askClient) Initialize(context.Context) (initializeResult, error) {
|
||||
return initializeResult{}, nil
|
||||
}
|
||||
func (askClient) NegotiatedVersion() string { return LegacyProtocolVersion }
|
||||
func (askClient) ListTools(context.Context) ([]toolInfo, error) { return nil, nil }
|
||||
func (askClient) Close() error { return nil }
|
||||
func (askClient) TasksGet(context.Context, string) (taskResult, error) {
|
||||
return taskResult{}, nil
|
||||
}
|
||||
func (askClient) TasksUpdate(context.Context, string, string) error { return nil }
|
||||
func (askClient) TasksCancel(context.Context, string) error { return nil }
|
||||
func (askClient) CallTool(context.Context, string, json.RawMessage) (callToolResult, error) {
|
||||
return callToolResult{Content: []contentBlock{{Type: "input_required", Text: "选一个颜色"}}}, nil
|
||||
}
|
||||
@@ -378,3 +384,150 @@ func TestInputRequiredUsesAsk(t *testing.T) {
|
||||
t.Fatalf("ask result %+v %v", res, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- N2 WP3:2026-07-28 对齐 ----
|
||||
|
||||
// 协议级 input_required:ask 取应答 → tasks/update 投递 → 原参数重试得终结果。
|
||||
func TestInputRequiredResultTypeRetriesOriginalCall(t *testing.T) {
|
||||
mgr, err := NewManager([]ServerConfig{fakeStdioConfig(t)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer mgr.Close()
|
||||
reg := tools.NewRegistry("")
|
||||
if err := mgr.RegisterInto(context.Background(), reg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var questions []string
|
||||
ctx := WithAsk(context.Background(), func(_ context.Context, q string) (string, error) {
|
||||
questions = append(questions, q)
|
||||
return "蓝色", nil
|
||||
})
|
||||
res := reg.Execute(ctx, "mcp__fake__need_input", json.RawMessage(`{}`))
|
||||
if res.IsError || res.Content != "答案已收到: 蓝色" {
|
||||
t.Fatalf("final result: %+v", res)
|
||||
}
|
||||
if len(questions) != 1 || questions[0] != "要答案" {
|
||||
t.Fatalf("ask questions: %v", questions)
|
||||
}
|
||||
}
|
||||
|
||||
// 协商回落:服务端只讲 2024-11-05 时,协议级路径不启用,旧内容块路径不变。
|
||||
func TestNegotiatedVersionFallbackKeepsLegacyPath(t *testing.T) {
|
||||
cfg := fakeStdioConfig(t)
|
||||
cfg.Env = append(cfg.Env, "AGENT_NATIVE_TEST_MCP_VERSION="+LegacyProtocolVersion)
|
||||
mgr, err := NewManager([]ServerConfig{cfg})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer mgr.Close()
|
||||
reg := tools.NewRegistry("")
|
||||
if err := mgr.RegisterInto(context.Background(), reg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx := WithAsk(context.Background(), func(_ context.Context, q string) (string, error) {
|
||||
return "蓝色", nil
|
||||
})
|
||||
res := reg.Execute(ctx, "mcp__fake__legacy_ask", json.RawMessage(`{}`))
|
||||
if res.IsError || res.Content != "蓝色" {
|
||||
t.Fatalf("legacy path: %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
// tasks 客户端面:get 回状态、cancel 清除任务输入。
|
||||
func TestTasksGetAndCancelRoundTrip(t *testing.T) {
|
||||
cfg := fakeStdioConfig(t)
|
||||
ctx := context.Background()
|
||||
c, err := DialStdio(ctx, cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer c.Close()
|
||||
if got := c.NegotiatedVersion(); !SupportsResultType(got) {
|
||||
t.Fatalf("negotiated = %q, want >= 2026-07-28", got)
|
||||
}
|
||||
if err := c.TasksUpdate(ctx, "task-x", "输入"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tr, err := c.TasksGet(ctx, "task-x")
|
||||
if err != nil || tr.TaskID != "task-x" || tr.Status != "running" {
|
||||
t.Fatalf("tasks/get: %+v %v", tr, err)
|
||||
}
|
||||
if err := c.TasksCancel(ctx, "task-x"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// 合成默认 schema 声明 JSON Schema 2020-12。
|
||||
func TestDefaultSchemaDeclares202012(t *testing.T) {
|
||||
mgr, err := NewManager([]ServerConfig{fakeStdioConfig(t)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer mgr.Close()
|
||||
reg := tools.NewRegistry("")
|
||||
if err := mgr.RegisterInto(context.Background(), reg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, d := range reg.Decls() {
|
||||
if d.Name == "mcp__fake__legacy_ask" {
|
||||
// legacy_ask 在 fake 里无 InputSchema → 走合成默认
|
||||
if !strings.Contains(string(d.Parameters), "2020-12") {
|
||||
t.Fatalf("默认 schema 未声明 2020-12:%s", d.Parameters)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("mcp__fake__legacy_ask not registered")
|
||||
}
|
||||
|
||||
// 任务跟踪器:记录 → 按服务端分组取消 → 清空。
|
||||
func TestTaskTrackerCancelAllPerRun(t *testing.T) {
|
||||
cfg := fakeStdioConfig(t)
|
||||
ctx := context.Background()
|
||||
c, err := DialStdio(ctx, cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer c.Close()
|
||||
tr := &TaskTracker{seen: map[Client]map[string]struct{}{}}
|
||||
if err := c.TasksUpdate(ctx, "task-a", "1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := c.TasksUpdate(ctx, "task-b", "2"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tr.note(c, "task-a")
|
||||
tr.note(c, "task-b")
|
||||
tr.note(c, "task-a") // 幂等
|
||||
if n := tr.CancelAll(ctx); n != 2 {
|
||||
t.Fatalf("CancelAll = %d, want 2", n)
|
||||
}
|
||||
if n := tr.CancelAll(ctx); n != 0 {
|
||||
t.Fatalf("第二次 CancelAll 应为 0,得到 %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
// 端到端:经注册表执行 input_required 工具的 run 结束时,task-1 被取消。
|
||||
func TestRunEndCancelsSeenTasks(t *testing.T) {
|
||||
cfg := fakeStdioConfig(t)
|
||||
mgr, err := NewManager([]ServerConfig{cfg})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer mgr.Close()
|
||||
reg := tools.NewRegistry("")
|
||||
if err := mgr.RegisterInto(context.Background(), reg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, tr := WithTaskTracker(context.Background())
|
||||
ctx = WithAsk(ctx, func(_ context.Context, q string) (string, error) { return "蓝", nil })
|
||||
res := reg.Execute(ctx, "mcp__fake__need_input", json.RawMessage(`{}`))
|
||||
if res.IsError {
|
||||
t.Fatalf("execute: %+v", res)
|
||||
}
|
||||
// 模仿 adapter run 结束:取消本 run 见过的任务(task-1 已被记录)。
|
||||
if n := tr.CancelAll(context.Background()); n != 1 {
|
||||
t.Fatalf("CancelAll = %d, want 1(task-1)", n)
|
||||
}
|
||||
}
|
||||
|
||||
+31
-2
@@ -12,8 +12,18 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// ProtocolVersion 是本客户端声明的 MCP 协议版本。
|
||||
const ProtocolVersion = "2024-11-05"
|
||||
// ProtocolVersion 是本客户端声明的 MCP 协议版本(2026-07-28 RC:
|
||||
// resultType 判别、input_required 协议级结果、tasks/* 长任务面)。
|
||||
const ProtocolVersion = "2026-07-28"
|
||||
|
||||
// LegacyProtocolVersion 是旧版协议(无 resultType/tasks),协商回落时用。
|
||||
const LegacyProtocolVersion = "2024-11-05"
|
||||
|
||||
// SupportsResultType 报告协商版本是否支持 2026-07-28 的 resultType 语义
|
||||
// (ISO 日期版本按字典序可比)。
|
||||
func SupportsResultType(negotiated string) bool {
|
||||
return negotiated >= "2026-07-28"
|
||||
}
|
||||
|
||||
// ServerConfig 声明一个 MCP server 接入点:Command(stdio)或 URL(HTTP)二选一。
|
||||
type ServerConfig struct {
|
||||
@@ -138,6 +148,19 @@ type callToolParams struct {
|
||||
type callToolResult struct {
|
||||
Content []contentBlock `json:"content"`
|
||||
IsError bool `json:"isError,omitempty"`
|
||||
// ResultType 是 2026-07-28 的协议级结果判别(空 = 普通结果)。
|
||||
// "input_required" 表示服务端需要用户输入:客户端经 ask 端口取得
|
||||
// 应答后(有 TaskID 时先 tasks/update)用原参数重试 tools/call。
|
||||
ResultType string `json:"resultType,omitempty"`
|
||||
TaskID string `json:"taskId,omitempty"`
|
||||
}
|
||||
|
||||
// taskResult 是 tasks/get 的返回(2026-07-28 长任务面;字段集按 RC 常用
|
||||
// 形态解析,未识别字段忽略)。
|
||||
type taskResult struct {
|
||||
TaskID string `json:"taskId"`
|
||||
Status string `json:"status"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
type contentBlock struct {
|
||||
@@ -148,7 +171,13 @@ type contentBlock struct {
|
||||
// Client 是一个已连接 MCP server 的最小面(initialize / tools/list / tools/call)。
|
||||
type Client interface {
|
||||
Initialize(ctx context.Context) (initializeResult, error)
|
||||
// NegotiatedVersion 是 initialize 后服务端确认的协议版本(未握手为空)。
|
||||
NegotiatedVersion() string
|
||||
ListTools(ctx context.Context) ([]toolInfo, error)
|
||||
CallTool(ctx context.Context, name string, args json.RawMessage) (callToolResult, error)
|
||||
// TasksGet/Update/Cancel 是 2026-07-28 的长任务面。
|
||||
TasksGet(ctx context.Context, taskID string) (taskResult, error)
|
||||
TasksUpdate(ctx context.Context, taskID, input string) error
|
||||
TasksCancel(ctx context.Context, taskID string) error
|
||||
Close() error
|
||||
}
|
||||
|
||||
@@ -18,8 +18,14 @@ type flakyClient struct {
|
||||
func (f *flakyClient) Initialize(context.Context) (initializeResult, error) {
|
||||
return initializeResult{}, nil
|
||||
}
|
||||
func (f *flakyClient) NegotiatedVersion() string { return ProtocolVersion }
|
||||
func (f *flakyClient) ListTools(context.Context) ([]toolInfo, error) { return nil, nil }
|
||||
func (f *flakyClient) Close() error { return nil }
|
||||
func (f *flakyClient) TasksGet(context.Context, string) (taskResult, error) {
|
||||
return taskResult{}, nil
|
||||
}
|
||||
func (f *flakyClient) TasksUpdate(context.Context, string, string) error { return nil }
|
||||
func (f *flakyClient) TasksCancel(context.Context, string) error { return nil }
|
||||
func (f *flakyClient) CallTool(context.Context, string, json.RawMessage) (callToolResult, error) {
|
||||
f.calls++
|
||||
if f.fail {
|
||||
|
||||
@@ -25,6 +25,8 @@ type stdioClient struct {
|
||||
readers atomic.Int32
|
||||
closed bool
|
||||
readerOnce sync.Once
|
||||
// negotiated 握手后写入(atomic.Value 存 string)。
|
||||
negotiated atomic.Value
|
||||
}
|
||||
|
||||
type stdioResult struct {
|
||||
@@ -75,6 +77,7 @@ func (c *stdioClient) handshake(ctx context.Context) error {
|
||||
}, &res); err != nil {
|
||||
return err
|
||||
}
|
||||
c.negotiated.Store(res.ProtocolVersion)
|
||||
// initialized 通知无需响应。
|
||||
return c.notify("notifications/initialized")
|
||||
}
|
||||
@@ -86,9 +89,32 @@ func (c *stdioClient) Initialize(ctx context.Context) (initializeResult, error)
|
||||
ProtocolVersion: ProtocolVersion,
|
||||
ClientInfo: clientInfo{Name: "agent-native", Version: "1"},
|
||||
}, &res)
|
||||
if err == nil {
|
||||
c.negotiated.Store(res.ProtocolVersion)
|
||||
}
|
||||
return res, err
|
||||
}
|
||||
|
||||
// NegotiatedVersion 是握手后服务端确认的协议版本(未握手为空)。
|
||||
func (c *stdioClient) NegotiatedVersion() string { return c.negotiated.Load().(string) }
|
||||
|
||||
// TasksGet 查询长任务状态(2026-07-28)。
|
||||
func (c *stdioClient) TasksGet(ctx context.Context, taskID string) (taskResult, error) {
|
||||
var res taskResult
|
||||
err := c.roundTrip(ctx, "tasks/get", map[string]string{"taskId": taskID}, &res)
|
||||
return res, err
|
||||
}
|
||||
|
||||
// TasksUpdate 向长任务投递用户输入(2026-07-28)。
|
||||
func (c *stdioClient) TasksUpdate(ctx context.Context, taskID, input string) error {
|
||||
return c.roundTrip(ctx, "tasks/update", map[string]string{"taskId": taskID, "input": input}, nil)
|
||||
}
|
||||
|
||||
// TasksCancel 取消长任务(2026-07-28)。
|
||||
func (c *stdioClient) TasksCancel(ctx context.Context, taskID string) error {
|
||||
return c.roundTrip(ctx, "tasks/cancel", map[string]string{"taskId": taskID}, nil)
|
||||
}
|
||||
|
||||
func (c *stdioClient) ListTools(ctx context.Context) ([]toolInfo, error) {
|
||||
var out []toolInfo
|
||||
cursor := ""
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// TaskTracker 记录一次 run 内 tools/call 结果里出现过的长任务(按服务端
|
||||
// 客户端分组),供 run 结束时对这些任务发 tasks/cancel(2026-07-28 最小
|
||||
// 产品映射:取消只覆盖本 run 自己的任务,不影响共享 Manager 上其他 run)。
|
||||
type TaskTracker struct {
|
||||
mu sync.Mutex
|
||||
seen map[Client]map[string]struct{}
|
||||
}
|
||||
|
||||
type trackerKey struct{}
|
||||
|
||||
// WithTaskTracker 把任务跟踪器放进上下文(bridgeTool 记录 TaskID)。
|
||||
func WithTaskTracker(ctx context.Context) (context.Context, *TaskTracker) {
|
||||
tr := &TaskTracker{seen: map[Client]map[string]struct{}{}}
|
||||
return context.WithValue(ctx, trackerKey{}, tr), tr
|
||||
}
|
||||
|
||||
func trackerFrom(ctx context.Context) *TaskTracker {
|
||||
if ctx == nil {
|
||||
return nil
|
||||
}
|
||||
tr, _ := ctx.Value(trackerKey{}).(*TaskTracker)
|
||||
return tr
|
||||
}
|
||||
|
||||
func (t *TaskTracker) note(c Client, taskID string) {
|
||||
if t == nil || c == nil || taskID == "" {
|
||||
return
|
||||
}
|
||||
t.mu.Lock()
|
||||
set := t.seen[c]
|
||||
if set == nil {
|
||||
set = map[string]struct{}{}
|
||||
t.seen[c] = set
|
||||
}
|
||||
set[taskID] = struct{}{}
|
||||
t.mu.Unlock()
|
||||
}
|
||||
|
||||
// CancelAll 对记录的任务逐一向其服务端发 tasks/cancel(尽力而为,
|
||||
// 首个错误后继续)。返回实际发出取消的任务数。
|
||||
func (t *TaskTracker) CancelAll(ctx context.Context) int {
|
||||
if t == nil {
|
||||
return 0
|
||||
}
|
||||
t.mu.Lock()
|
||||
seen := t.seen
|
||||
t.seen = map[Client]map[string]struct{}{}
|
||||
t.mu.Unlock()
|
||||
n := 0
|
||||
for c, ids := range seen {
|
||||
for id := range ids {
|
||||
if err := c.TasksCancel(ctx, id); err == nil {
|
||||
n++
|
||||
}
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// Package memory 定义记忆端口(N3 WP5)。宿主注入 Store 实现;循环在每次
|
||||
// 请求前把 Recall 结果以系统消息注入请求副本(不落盘、不累计——与
|
||||
// reminders 同款的 request-scoped 语义);nil = 不启用。
|
||||
//
|
||||
// 注入文案与产品既有 [记忆] 形态对齐(迁移前后同文):
|
||||
//
|
||||
// [记忆]
|
||||
// - (typ) content
|
||||
//
|
||||
// 排序确定性:按 (Type, Content) 字典序(同一 Recall 结果集在任何
|
||||
// 消费方下渲染一致)。
|
||||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Entry 是一条记忆。Scope 为 user 或 project。
|
||||
type Entry struct {
|
||||
ID string
|
||||
Scope string
|
||||
ProjectID string
|
||||
Type string
|
||||
Content string
|
||||
Source string
|
||||
Confidence float64
|
||||
CreatedAt time.Time
|
||||
ExpiresAt *time.Time
|
||||
}
|
||||
|
||||
// RecallQuery 是一次召回。Scope 空 = 不限(含 user/project);Limit ≤0 = 20。
|
||||
type RecallQuery struct {
|
||||
Scope string
|
||||
Limit int
|
||||
}
|
||||
|
||||
// Store 是记忆端口。实现方自担 owner/项目上下文(循环不传租户态)。
|
||||
type Store interface {
|
||||
Recall(ctx context.Context, q RecallQuery) ([]Entry, error)
|
||||
Append(ctx context.Context, e Entry) error
|
||||
}
|
||||
|
||||
// MaxRecallEntries 是注入的条目上限(与产品既有形态一致)。
|
||||
const MaxRecallEntries = 20
|
||||
|
||||
// Block 把一次 Recall 结果渲染成注入文本;空结果返回空串(调用方不注入)。
|
||||
// 过期与空内容条目在渲染时剔除(与产品既有 InjectMemory 同语义)。
|
||||
func Block(entries []Entry, now time.Time) string {
|
||||
live := make([]Entry, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if e.ExpiresAt != nil && !e.ExpiresAt.After(now) {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(e.Content) == "" {
|
||||
continue
|
||||
}
|
||||
live = append(live, e)
|
||||
}
|
||||
if len(live) == 0 {
|
||||
return ""
|
||||
}
|
||||
sort.Slice(live, func(i, j int) bool {
|
||||
if live[i].Type != live[j].Type {
|
||||
return live[i].Type < live[j].Type
|
||||
}
|
||||
return live[i].Content < live[j].Content
|
||||
})
|
||||
var b strings.Builder
|
||||
b.WriteString("[记忆]\n")
|
||||
n := 0
|
||||
for _, e := range live {
|
||||
fmt.Fprintf(&b, "- (%s) %s\n", e.Type, strings.TrimSpace(e.Content))
|
||||
n++
|
||||
if n >= MaxRecallEntries {
|
||||
break
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ---- N3 WP5:记忆端口 ----
|
||||
|
||||
type fakeStore struct {
|
||||
entries []Entry
|
||||
appended []Entry
|
||||
recallQ RecallQuery
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeStore) Recall(_ context.Context, q RecallQuery) ([]Entry, error) {
|
||||
f.recallQ = q
|
||||
return f.entries, f.err
|
||||
}
|
||||
func (f *fakeStore) Append(_ context.Context, e Entry) error {
|
||||
f.appended = append(f.appended, e)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestBlockRenderOrderingAndCaps(t *testing.T) {
|
||||
future := time.Now().Add(24 * time.Hour)
|
||||
past := time.Now().Add(-24 * time.Hour)
|
||||
entries := []Entry{
|
||||
{Type: "preference", Content: "喜欢简洁回复"},
|
||||
{Type: "fact", Content: "项目用 Go 1.27"},
|
||||
{Type: "fact", Content: " "},
|
||||
{Type: "fact", Content: "过期了", ExpiresAt: &past},
|
||||
{Type: "fact", Content: "没过期", ExpiresAt: &future},
|
||||
}
|
||||
out := Block(entries, time.Now())
|
||||
if !strings.Contains(out, "[记忆]\n") {
|
||||
t.Fatalf("缺块头:%q", out)
|
||||
}
|
||||
// 确定性排序:Type 字典序(fact 先于 preference);过期与空内容剔除。
|
||||
firstFact := strings.Index(out, "fact")
|
||||
firstPref := strings.Index(out, "preference")
|
||||
if firstFact < 0 || firstPref < 0 || firstFact > firstPref {
|
||||
t.Fatalf("排序不确定:%q", out)
|
||||
}
|
||||
if strings.Contains(out, "过期了") {
|
||||
t.Fatalf("过期条目未剔除:%q", out)
|
||||
}
|
||||
if !strings.Contains(out, "没过期") {
|
||||
t.Fatalf("未过期条目被误剔:%q", out)
|
||||
}
|
||||
if Block(nil, time.Now()) != "" {
|
||||
t.Fatal("空结果应为空串")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteToolValidation(t *testing.T) {
|
||||
store := &fakeStore{}
|
||||
tool := &writeTool{store: store}
|
||||
cases := []struct {
|
||||
name string
|
||||
args string
|
||||
wantErr string
|
||||
}{
|
||||
{"坏 scope", `{"scope":"global","type":"fact","content":"x"}`, "user 或 project"},
|
||||
{"坏 type", `{"type":"todo","content":"x"}`, "fact|preference"},
|
||||
{"空 content", `{"type":"fact","content":" "}`, "非空"},
|
||||
{"超长 content", `{"type":"fact","content":"` + strings.Repeat("长", 2001) + `"}`, "上限"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
res, err := tool.Execute(context.Background(), json.RawMessage(tc.args))
|
||||
if err != nil || !res.IsError || !strings.Contains(res.Content, tc.wantErr) {
|
||||
t.Fatalf("%s:%+v %v", tc.name, res, err)
|
||||
}
|
||||
}
|
||||
if len(store.appended) != 0 {
|
||||
t.Fatalf("校验失败不得落库:%+v", store.appended)
|
||||
}
|
||||
// 合法路径:默认 scope=user + 过期换算。
|
||||
res, err := tool.Execute(context.Background(), json.RawMessage(`{"type":"preference","content":"喜欢简洁","expires_in_days":30}`))
|
||||
if err != nil || res.IsError {
|
||||
t.Fatalf("合法写入被拒:%+v %v", res, err)
|
||||
}
|
||||
if len(store.appended) != 1 || store.appended[0].Scope != "user" ||
|
||||
store.appended[0].Type != "preference" || store.appended[0].ExpiresAt == nil {
|
||||
t.Fatalf("写入形态:%+v", store.appended)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"agent-native/runtime/tools"
|
||||
)
|
||||
|
||||
// memory_write 工具:模型自服务写回(fail-closed 校验 scope/type/content)。
|
||||
// 只在宿主配置 Store 时注册(RegisterWrite 由循环在 Run 启动时接线)。
|
||||
type writeTool struct {
|
||||
store Store
|
||||
scope string // 默认 scope(AgentSpec.MemoryScope 驱动;空 = user)
|
||||
}
|
||||
|
||||
var validScopes = map[string]bool{"user": true, "project": true}
|
||||
var validTypes = map[string]bool{
|
||||
"fact": true, "preference": true, "decision": true, "episode": true, "procedure": true,
|
||||
}
|
||||
|
||||
// RegisterWrite 把 memory_write 注册进 reg(store nil 时跳过;重复注册按
|
||||
// 覆盖处理——宿主不得在同一注册表上给不同 run 挂不同实现)。
|
||||
func RegisterWrite(reg *tools.Registry, store Store, defaultScope string) {
|
||||
if reg == nil || store == nil {
|
||||
return
|
||||
}
|
||||
reg.Register(&writeTool{store: store, scope: defaultScope})
|
||||
}
|
||||
|
||||
func (t *writeTool) Name() string { return "memory_write" }
|
||||
func (t *writeTool) Description() string {
|
||||
return "把一条值得跨会话记住的事实/偏好/决定写入长期记忆(scope: user 或 project;type: fact|preference|decision|episode|procedure)。"
|
||||
}
|
||||
func (t *writeTool) Parameters() json.RawMessage {
|
||||
return json.RawMessage(`{"type":"object","required":["type","content"],"properties":{
|
||||
"scope":{"type":"string"},
|
||||
"type":{"type":"string"},
|
||||
"content":{"type":"string"},
|
||||
"expires_in_days":{"type":"number"}
|
||||
}}`)
|
||||
}
|
||||
|
||||
// maxWriteContent 是单条记忆的内容上限(字符)。
|
||||
const maxWriteContent = 2000
|
||||
|
||||
func (t *writeTool) Execute(ctx context.Context, args json.RawMessage) (tools.Result, error) {
|
||||
var a struct {
|
||||
Scope string `json:"scope"`
|
||||
Type string `json:"type"`
|
||||
Content string `json:"content"`
|
||||
ExpiresInDays float64 `json:"expires_in_days"`
|
||||
}
|
||||
if err := json.Unmarshal(args, &a); err != nil {
|
||||
return tools.Result{Content: "memory_write: 参数不是完整 JSON 对象", IsError: true}, nil
|
||||
}
|
||||
scope := strings.TrimSpace(a.Scope)
|
||||
if scope == "" {
|
||||
scope = t.scope
|
||||
}
|
||||
if scope == "" {
|
||||
scope = "user"
|
||||
}
|
||||
if !validScopes[scope] {
|
||||
return tools.Result{Content: fmt.Sprintf("memory_write: scope %q 必须是 user 或 project", scope), IsError: true}, nil
|
||||
}
|
||||
typ := strings.TrimSpace(a.Type)
|
||||
if !validTypes[typ] {
|
||||
return tools.Result{Content: fmt.Sprintf("memory_write: type %q 必须是 fact|preference|decision|episode|procedure 之一", typ), IsError: true}, nil
|
||||
}
|
||||
content := strings.TrimSpace(a.Content)
|
||||
if content == "" {
|
||||
return tools.Result{Content: "memory_write: content 必填且非空", IsError: true}, nil
|
||||
}
|
||||
if len([]rune(content)) > maxWriteContent {
|
||||
return tools.Result{Content: fmt.Sprintf("memory_write: content 超过 %d 字符上限", maxWriteContent), IsError: true}, nil
|
||||
}
|
||||
var expires *time.Time
|
||||
if a.ExpiresInDays > 0 {
|
||||
t := time.Now().Add(time.Duration(a.ExpiresInDays*24) * time.Hour)
|
||||
expires = &t
|
||||
}
|
||||
if err := t.store.Append(ctx, Entry{
|
||||
Scope: scope, Type: typ, Content: content,
|
||||
Source: "agent", CreatedAt: time.Now(), ExpiresAt: expires,
|
||||
}); err != nil {
|
||||
return tools.Result{Content: "memory_write: " + err.Error(), IsError: true}, nil
|
||||
}
|
||||
return tools.Result{Content: fmt.Sprintf("已记住(scope=%s type=%s)", scope, typ)}, nil
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"agent-native/runtime/llm"
|
||||
"agent-native/runtime/memory"
|
||||
"agent-native/runtime/tools"
|
||||
)
|
||||
|
||||
// ---- N3 WP5:循环消费点(注入 + memory_write 接线)----
|
||||
|
||||
type loopMemStore struct {
|
||||
entries []memory.Entry
|
||||
appended []memory.Entry
|
||||
}
|
||||
|
||||
func (s *loopMemStore) Recall(_ context.Context, q memory.RecallQuery) ([]memory.Entry, error) {
|
||||
return s.entries, nil
|
||||
}
|
||||
func (s *loopMemStore) Append(_ context.Context, e memory.Entry) error {
|
||||
s.appended = append(s.appended, e)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 端口注入的条目出现在请求副本系统段之后;nil 端口零变化;
|
||||
// Run 启动注册 memory_write 且可调用。
|
||||
func TestLoopInjectsMemoryAndRegistersWrite(t *testing.T) {
|
||||
store := &loopMemStore{entries: []memory.Entry{{Type: "fact", Content: "项目用 Go 1.27"}}}
|
||||
reg := tools.NewRegistry(t.TempDir())
|
||||
var gotReq [][]llm.Message
|
||||
res, err := Run(context.Background(), Config{
|
||||
Model: "fake", Tools: reg, Memory: store, MaxTurns: 2,
|
||||
Stream: func(ctx context.Context, model string, msgs []llm.Message, _ []llm.ToolDecl, _ llm.StreamOptions) <-chan llm.Event {
|
||||
gotReq = append(gotReq, append([]llm.Message{}, msgs...))
|
||||
ch := make(chan llm.Event, 1)
|
||||
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{
|
||||
Role: llm.RoleAssistant, StopReason: llm.StopToolCalls,
|
||||
ToolCalls: []llm.ToolCall{{ID: "c1", Name: "memory_write", Arguments: `{"type":"fact","content":"主人喜欢简洁"}`}},
|
||||
}}
|
||||
close(ch)
|
||||
return ch
|
||||
},
|
||||
}, llm.Message{Role: llm.RoleUser, Content: "go"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = res
|
||||
if len(gotReq) < 1 {
|
||||
t.Fatalf("请求次数 = %d", len(gotReq))
|
||||
}
|
||||
req := gotReq[0]
|
||||
// [记忆] 块在系统段之后、历史之前。
|
||||
sysEnd := 0
|
||||
for sysEnd < len(req) && req[sysEnd].Role == llm.RoleSystem {
|
||||
sysEnd++
|
||||
}
|
||||
var found bool
|
||||
for i := 0; i < sysEnd; i++ {
|
||||
if strings.Contains(req[i].Content, "[记忆]") && strings.Contains(req[i].Content, "项目用 Go 1.27") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("记忆块未注入系统段:%#v", req)
|
||||
}
|
||||
// memory_write 已被注册并执行(脚本化模型每轮都点名它 → 至少一次)。
|
||||
if len(store.appended) < 1 || store.appended[0].Content != "主人喜欢简洁" {
|
||||
t.Fatalf("memory_write 未执行:%+v", store.appended)
|
||||
}
|
||||
}
|
||||
|
||||
// 空召回:不产生 [记忆] 块(注入静默跳过);nil 端口同语义。
|
||||
func TestEmptyRecallInjectsNothing(t *testing.T) {
|
||||
reg := tools.NewRegistry(t.TempDir())
|
||||
var gotReq []llm.Message
|
||||
if _, err := Run(context.Background(), Config{
|
||||
Model: "fake", Tools: reg, Memory: &loopMemStore{},
|
||||
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: "x", StopReason: llm.StopEnd}}
|
||||
close(ch)
|
||||
return ch
|
||||
},
|
||||
}, llm.Message{Role: llm.RoleUser, Content: "go"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, m := range gotReq {
|
||||
if strings.Contains(m.Content, "[记忆]") {
|
||||
t.Fatalf("空召回不得产生记忆块:%#v", gotReq)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -61,7 +61,7 @@ func TestPiAliasesFilterAndSkillCommand(t *testing.T) {
|
||||
if err := os.MkdirAll(skillDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("be thorough"), 0o644); err != nil {
|
||||
if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\nname: review\ndescription: 评审技能\n---\n\nbe thorough"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store := skills.NewStore(dir)
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
package session
|
||||
|
||||
import "agent-native/runtime/llm"
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"agent-native/runtime/llm"
|
||||
)
|
||||
|
||||
const (
|
||||
// DefaultKeepToolResults 是运行内清理时保留的最近工具结果条数。
|
||||
@@ -42,6 +48,14 @@ func NeedsClear(estimate, window, outputReserve, buffer int) bool {
|
||||
// 消息条数、顺序和 tool_call 配对都不变;占位写明完整结果仍在会话记录里。
|
||||
// keep ≤ 0 时保留默认的 5 条。返回的切片不与输入共享被改写的消息。
|
||||
func ClearOldToolResults(msgs []llm.Message, keep int) []llm.Message {
|
||||
return ClearOldToolResultsTo(msgs, keep, "")
|
||||
}
|
||||
|
||||
// ClearOldToolResultsTo 在 ClearOldToolResults 的基础上支持落盘:
|
||||
// dir 非空时,把被清理结果的完整内容写进 <dir>/<toolCallID>.txt,
|
||||
// 占位改为路径指引(ZCode 式「旧工具结果换成路径」);
|
||||
// 写盘失败或调用 ID 为空时回落为转录指针占位,不阻断本轮。
|
||||
func ClearOldToolResultsTo(msgs []llm.Message, keep int, dir string) []llm.Message {
|
||||
if keep <= 0 {
|
||||
keep = DefaultKeepToolResults
|
||||
}
|
||||
@@ -60,6 +74,12 @@ func ClearOldToolResults(msgs []llm.Message, keep int) []llm.Message {
|
||||
if name == "" {
|
||||
name = "工具"
|
||||
}
|
||||
if dir != "" && out[i].ToolCallID != "" && out[i].Content != "" {
|
||||
if path, ok := spillResult(dir, out[i].ToolCallID, out[i].Content); ok {
|
||||
out[i].Content = "旧工具结果已清理。完整内容见 " + path + "(" + name + ")。"
|
||||
continue
|
||||
}
|
||||
}
|
||||
loc := "会话记录"
|
||||
if out[i].ToolCallID != "" {
|
||||
loc = "会话记录中的调用 " + out[i].ToolCallID
|
||||
@@ -68,3 +88,28 @@ func ClearOldToolResults(msgs []llm.Message, keep int) []llm.Message {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// spillResult 把一条旧工具结果写盘,返回路径。文件名只留安全字符。
|
||||
func spillResult(dir, callID, content string) (string, bool) {
|
||||
var sb strings.Builder
|
||||
for _, r := range callID {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' ||
|
||||
r == '-' || r == '_' || r == '.':
|
||||
sb.WriteRune(r)
|
||||
default:
|
||||
sb.WriteByte('_')
|
||||
}
|
||||
}
|
||||
if sb.Len() == 0 {
|
||||
return "", false
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return "", false
|
||||
}
|
||||
path := filepath.Join(dir, sb.String()+".txt")
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
return "", false
|
||||
}
|
||||
return path, true
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package session
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -58,3 +60,77 @@ func TestNeedsClearUsesConservativeWindow(t *testing.T) {
|
||||
t.Fatal("estimate at the default window must clear")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- N2 WP1:旧工具结果落盘(路径占位)----
|
||||
|
||||
func spillFixture() []llm.Message {
|
||||
msgs := []llm.Message{{Role: llm.RoleUser, Content: "u"}}
|
||||
for i := 0; i < 7; i++ {
|
||||
id := "call-" + string(rune('a'+i))
|
||||
msgs = append(msgs,
|
||||
llm.Message{Role: llm.RoleAssistant, ToolCalls: []llm.ToolCall{{ID: id, Name: "read", Arguments: "{}"}}},
|
||||
llm.Message{Role: llm.RoleTool, ToolCallID: id, Name: "read", Content: "完整结果内容-" + id})
|
||||
}
|
||||
return msgs
|
||||
}
|
||||
|
||||
func TestClearOldToolResultsSpillsToDisk(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
out := ClearOldToolResultsTo(spillFixture(), 5, dir)
|
||||
if len(out) != len(spillFixture()) {
|
||||
t.Fatal("消息条数不得变")
|
||||
}
|
||||
// 最旧两条被落盘,占位带路径。
|
||||
for i, id := range []string{"call-a", "call-b"} {
|
||||
path := filepath.Join(dir, id+".txt")
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("落盘文件缺失:%v", err)
|
||||
}
|
||||
if string(b) != "完整结果内容-"+id {
|
||||
t.Fatalf("落盘内容不符:%q", b)
|
||||
}
|
||||
placeholder := out[2+i*2].Content
|
||||
if !strings.Contains(placeholder, path) {
|
||||
t.Fatalf("占位未带路径:%q", placeholder)
|
||||
}
|
||||
}
|
||||
// 保留的最近 5 条正文不动。
|
||||
if out[len(out)-1].Content != "完整结果内容-call-g" {
|
||||
t.Fatalf("最近结果被误改:%q", out[len(out)-1].Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearOldToolResultsEmptyDirKeepsLegacyPlaceholder(t *testing.T) {
|
||||
out := ClearOldToolResultsTo(spillFixture(), 5, "")
|
||||
legacy := ClearOldToolResults(spillFixture(), 5)
|
||||
for i := range out {
|
||||
if out[i].Content != legacy[i].Content {
|
||||
t.Fatalf("空 dir 应与旧行为逐字节一致:msg %d", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearOldToolResultsSpillFailureFallsBack(t *testing.T) {
|
||||
// 不可写目录:占位回落为转录指针,不阻断。
|
||||
blocked := filepath.Join(t.TempDir(), "file")
|
||||
if err := os.WriteFile(blocked, []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := ClearOldToolResultsTo(spillFixture(), 5, filepath.Join(blocked, "sub"))
|
||||
if !strings.Contains(out[2].Content, "会话记录中的调用 call-a") {
|
||||
t.Fatalf("写盘失败应回落转录指针:%q", out[2].Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpillResultSanitizesCallID(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path, ok := spillResult(dir, "call/../evil\x00id", "x")
|
||||
if !ok {
|
||||
t.Fatal("spill 失败")
|
||||
}
|
||||
base := filepath.Base(path)
|
||||
if strings.Contains(base, "/") || strings.ContainsRune(base, 0) || filepath.Dir(path) != dir {
|
||||
t.Fatalf("文件名未消毒或越出目录:%q", path)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,3 +182,42 @@ func TestR2AppendVisibleAfterReopen(t *testing.T) {
|
||||
t.Fatalf("reopen: %+v", msgs)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- N2 WP1:alignToolTail 边界直接单测(原仅经 Store.Compact 间接覆盖)----
|
||||
|
||||
func tailMsgs() []llm.Message {
|
||||
call := func(id string) llm.ToolCall { return llm.ToolCall{ID: id, Name: "read", Arguments: "{}"} }
|
||||
return []llm.Message{
|
||||
{Role: llm.RoleUser, Content: "u1"}, // 0
|
||||
{Role: llm.RoleAssistant, ToolCalls: []llm.ToolCall{call("a"), call("b")}}, // 1 多调用 assistant
|
||||
{Role: llm.RoleTool, ToolCallID: "a", Content: "ra"}, // 2
|
||||
{Role: llm.RoleTool, ToolCallID: "b", Content: "rb"}, // 3
|
||||
{Role: llm.RoleUser, Content: "u2"}, // 4
|
||||
{Role: llm.RoleAssistant, ToolCalls: []llm.ToolCall{call("c")}}, // 5
|
||||
{Role: llm.RoleTool, ToolCallID: "c", Content: "rc"}, // 6
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlignToolTailEdges(t *testing.T) {
|
||||
msgs := tailMsgs()
|
||||
// tail 恰落在多调用 assistant 的结果组内 → 回退到该 assistant。
|
||||
if got := alignToolTail(msgs, 3); got != 1 {
|
||||
t.Fatalf("落在多调用结果组内:got %d want 1", got)
|
||||
}
|
||||
// tail 落在结果组边界外(user)→ 不动。
|
||||
if got := alignToolTail(msgs, 4); got != 4 {
|
||||
t.Fatalf("落在 user 上:got %d want 4", got)
|
||||
}
|
||||
// 结尾整组是 tool 角色 → 回退到其 assistant。
|
||||
if got := alignToolTail(msgs, 6); got != 5 {
|
||||
t.Fatalf("落在末尾结果上:got %d want 5", got)
|
||||
}
|
||||
// 从头起全是 tool 角色(退化形态)→ 回退到 0,不越界。
|
||||
allTool := []llm.Message{
|
||||
{Role: llm.RoleTool, ToolCallID: "x", Content: "1"},
|
||||
{Role: llm.RoleTool, ToolCallID: "y", Content: "2"},
|
||||
}
|
||||
if got := alignToolTail(allTool, 1); got != 0 {
|
||||
t.Fatalf("全 tool:got %d want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
package skills
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// frontmatter.go — SKILL.md frontmatter 的开放规范校验(N2 WP4)。
|
||||
// 坚持不引入 YAML 依赖(skills.go 包注释的既有约束):手写严格子集,
|
||||
// 只支持规范字段形态——标量、metadata 字符串映射、allowed-tools 空格
|
||||
// 分隔列表;形态不合法即错误,不猜测。
|
||||
|
||||
// 已知键白名单(agentskills.io 开放规范);未知键带字段定位拒绝。
|
||||
var knownFrontmatterKeys = map[string]bool{
|
||||
"name": true, "description": true, "license": true,
|
||||
"compatibility": true, "metadata": true, "allowed-tools": true,
|
||||
}
|
||||
|
||||
const (
|
||||
maxNameRunes = 64
|
||||
maxDescription = 1024 // 规范上限(替换旧的 200 截断)
|
||||
maxCompatibility = 500
|
||||
maxMetadataEntries = 32
|
||||
maxAllowedTools = 64
|
||||
)
|
||||
|
||||
// ValidationError 是一条带字段定位的技能校验错误。
|
||||
type ValidationError struct {
|
||||
Path string // SKILL.md 路径
|
||||
Field string // frontmatter 键("name"、"metadata.author"…);整体错误用 "-"
|
||||
Msg string
|
||||
}
|
||||
|
||||
func (e ValidationError) Error() string {
|
||||
return fmt.Sprintf("%s: frontmatter %s: %s", e.Path, e.Field, e.Msg)
|
||||
}
|
||||
|
||||
// Frontmatter 是解析+校验后的 SKILL.md 头。
|
||||
type Frontmatter struct {
|
||||
Name string
|
||||
Description string
|
||||
License string
|
||||
Compatibility string
|
||||
Metadata map[string]string
|
||||
AllowedTools []string
|
||||
}
|
||||
|
||||
// parseFrontmatter 解析 frontmatter 子集。无 frontmatter 时返回 nil(由
|
||||
// 调用方按「name/description 必填」判错)。data 是完整文件内容。
|
||||
func parseFrontmatter(path, text string) (*Frontmatter, string, error) {
|
||||
fm := &Frontmatter{Metadata: map[string]string{}}
|
||||
lines := strings.Split(text, "\n")
|
||||
if len(lines) == 0 || strings.TrimSpace(lines[0]) != "---" {
|
||||
return fm, text, nil
|
||||
}
|
||||
end := -1
|
||||
for i := 1; i < len(lines); i++ {
|
||||
if strings.TrimSpace(lines[i]) == "---" {
|
||||
end = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if end < 0 {
|
||||
return nil, "", ValidationError{Path: path, Field: "-", Msg: "frontmatter 起始 --- 后找不到结束 ---"}
|
||||
}
|
||||
body := strings.Join(lines[end+1:], "\n")
|
||||
for i := 1; i < end; i++ {
|
||||
line := lines[i]
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
// metadata 的缩进映射行在父键循环里处理。
|
||||
key, value, found := strings.Cut(trimmed, ":")
|
||||
if !found {
|
||||
return nil, "", ValidationError{Path: path, Field: "-", Msg: fmt.Sprintf("第 %d 行不是 key: value 形态", i)}
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
if !knownFrontmatterKeys[key] {
|
||||
return nil, "", ValidationError{Path: path, Field: key, Msg: "未知 frontmatter 键(开放规范白名单:name/description/license/compatibility/metadata/allowed-tools)"}
|
||||
}
|
||||
value = strings.TrimSpace(value)
|
||||
switch key {
|
||||
case "metadata":
|
||||
if value != "" {
|
||||
return nil, "", ValidationError{Path: path, Field: "metadata", Msg: "metadata 必须是缩进映射,不接受行内值"}
|
||||
}
|
||||
j := i + 1
|
||||
for ; j < end; j++ {
|
||||
sub := lines[j]
|
||||
if strings.TrimSpace(sub) == "" {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(sub, " ") && !strings.HasPrefix(sub, "\t") {
|
||||
break
|
||||
}
|
||||
sk, sv, ok := strings.Cut(strings.TrimSpace(sub), ":")
|
||||
if !ok || strings.TrimSpace(sk) == "" {
|
||||
return nil, "", ValidationError{Path: path, Field: "metadata", Msg: fmt.Sprintf("第 %d 行不是 key: value 形态", j)}
|
||||
}
|
||||
if len(fm.Metadata) >= maxMetadataEntries {
|
||||
return nil, "", ValidationError{Path: path, Field: "metadata." + strings.TrimSpace(sk), Msg: fmt.Sprintf("metadata 超过 %d 条上限", maxMetadataEntries)}
|
||||
}
|
||||
fm.Metadata[strings.TrimSpace(sk)] = strings.TrimSpace(sv)
|
||||
}
|
||||
i = j - 1
|
||||
case "name":
|
||||
fm.Name = value
|
||||
case "description":
|
||||
fm.Description = value
|
||||
case "license":
|
||||
fm.License = value
|
||||
case "compatibility":
|
||||
fm.Compatibility = value
|
||||
case "allowed-tools":
|
||||
if value == "" {
|
||||
return nil, "", ValidationError{Path: path, Field: "allowed-tools", Msg: "allowed-tools 为空(应为空格分隔的工具名列表)"}
|
||||
}
|
||||
for _, t := range strings.Fields(value) {
|
||||
fm.AllowedTools = append(fm.AllowedTools, t)
|
||||
}
|
||||
if len(fm.AllowedTools) > maxAllowedTools {
|
||||
return nil, "", ValidationError{Path: path, Field: "allowed-tools", Msg: fmt.Sprintf("allowed-tools 超过 %d 条上限", maxAllowedTools)}
|
||||
}
|
||||
}
|
||||
}
|
||||
return fm, body, nil
|
||||
}
|
||||
|
||||
// validateFrontmatter 按开放规范校验(dirName 是目录名,name 须与之相等)。
|
||||
func validateFrontmatter(path, dirName string, fm *Frontmatter) error {
|
||||
if fm.Name == "" {
|
||||
return ValidationError{Path: path, Field: "name", Msg: "name 必填(开放规范)"}
|
||||
}
|
||||
if !validSkillName(fm.Name) {
|
||||
return ValidationError{Path: path, Field: "name", Msg: fmt.Sprintf("name %q 不是 kebab-case(小写字母数字与单连字符,1-64 字符)", fm.Name)}
|
||||
}
|
||||
if fm.Name != dirName {
|
||||
return ValidationError{Path: path, Field: "name", Msg: fmt.Sprintf("name %q 与目录名 %q 不一致(规范要求同名)", fm.Name, dirName)}
|
||||
}
|
||||
if strings.TrimSpace(fm.Description) == "" {
|
||||
return ValidationError{Path: path, Field: "description", Msg: "description 必填且非空(开放规范)"}
|
||||
}
|
||||
if len([]rune(fm.Description)) > maxDescription {
|
||||
return ValidationError{Path: path, Field: "description", Msg: fmt.Sprintf("description 超过 %d 字符上限", maxDescription)}
|
||||
}
|
||||
if len([]rune(fm.Compatibility)) > maxCompatibility {
|
||||
return ValidationError{Path: path, Field: "compatibility", Msg: fmt.Sprintf("compatibility 超过 %d 字符上限", maxCompatibility)}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validSkillName 是开放规范的 kebab-case:小写字母/数字段,段间单连字符,
|
||||
// 无首尾/连续连字符,1-64 字符。
|
||||
func validSkillName(s string) bool {
|
||||
if s == "" || len(s) > maxNameRunes {
|
||||
return false
|
||||
}
|
||||
if strings.HasPrefix(s, "-") || strings.HasSuffix(s, "-") || strings.Contains(s, "--") {
|
||||
return false
|
||||
}
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ValidateSkillDocument 解析并校验一份 SKILL.md(供组合根以外的消费方
|
||||
// 复用同一校验器:插件技能装载、种子写入前置检查)。dirName 是技能目录名
|
||||
// (name 必须与之相等);path 只用于错误定位。
|
||||
func ValidateSkillDocument(dirName, path string, data []byte) error {
|
||||
if len(data) > maxBodyBytes {
|
||||
data = data[:maxBodyBytes]
|
||||
}
|
||||
fm, _, err := parseFrontmatter(path, string(data))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return validateFrontmatter(path, dirName, fm)
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package skills
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ---- N2 WP4:开放规范校验矩阵 ----
|
||||
|
||||
func TestParseFrontmatterAllFields(t *testing.T) {
|
||||
text := "---\n" +
|
||||
"name: docx-author\n" +
|
||||
"description: 生成 Word\n" +
|
||||
"license: Apache-2.0\n" +
|
||||
"compatibility: 需要 python-docx\n" +
|
||||
"metadata:\n" +
|
||||
" author: 交付组\n" +
|
||||
" version: \"1.0\"\n" +
|
||||
"allowed-tools: read_file write_file\n" +
|
||||
"---\n\n正文"
|
||||
fm, body, err := parseFrontmatter("x/SKILL.md", text)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fm.Name != "docx-author" || fm.Description != "生成 Word" || fm.License != "Apache-2.0" ||
|
||||
fm.Compatibility != "需要 python-docx" || body == "" || !strings.Contains(body, "正文") {
|
||||
t.Fatalf("%+v body=%q", fm, body)
|
||||
}
|
||||
if fm.Metadata["author"] != "交付组" || fm.Metadata["version"] != "\"1.0\"" {
|
||||
t.Fatalf("metadata: %+v", fm.Metadata)
|
||||
}
|
||||
if len(fm.AllowedTools) != 2 || fm.AllowedTools[0] != "read_file" || fm.AllowedTools[1] != "write_file" {
|
||||
t.Fatalf("allowed-tools: %+v", fm.AllowedTools)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateFrontmatterMatrix(t *testing.T) {
|
||||
mk := func(mutate func(*Frontmatter)) *Frontmatter {
|
||||
fm := &Frontmatter{Name: "good-name", Description: "d", Metadata: map[string]string{}}
|
||||
mutate(fm)
|
||||
return fm
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
fm *Frontmatter
|
||||
dir string
|
||||
wantKey string
|
||||
}{
|
||||
{"缺 name", mk(func(f *Frontmatter) { f.Name = "" }), "good-name", "name"},
|
||||
{"name 大写", mk(func(f *Frontmatter) { f.Name = "Bad-Name" }), "good-name", "name"},
|
||||
{"name 下划线", mk(func(f *Frontmatter) { f.Name = "bad_name" }), "good-name", "name"},
|
||||
{"name 连续连字符", mk(func(f *Frontmatter) { f.Name = "bad--name" }), "good-name", "name"},
|
||||
{"name 尾连字符", mk(func(f *Frontmatter) { f.Name = "bad-" }), "good-name", "name"},
|
||||
{"name≠目录", mk(func(f *Frontmatter) {}), "other-dir", "name"},
|
||||
{"缺 description", mk(func(f *Frontmatter) { f.Description = " " }), "good-name", "description"},
|
||||
{"description 超长", mk(func(f *Frontmatter) { f.Description = strings.Repeat("长", 1025) }), "good-name", "description"},
|
||||
{"compatibility 超长", mk(func(f *Frontmatter) { f.Compatibility = strings.Repeat("长", 501) }), "good-name", "compatibility"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
err := validateFrontmatter("p/SKILL.md", tc.dir, tc.fm)
|
||||
ve, ok := err.(ValidationError)
|
||||
if err == nil || !ok || ve.Field != tc.wantKey {
|
||||
t.Fatalf("%s: 期望 %s 字段错误,得到 %v", tc.name, tc.wantKey, err)
|
||||
}
|
||||
if !strings.Contains(ve.Error(), "p/SKILL.md") {
|
||||
t.Fatalf("%s: 错误必须带路径:%v", tc.name, ve)
|
||||
}
|
||||
}
|
||||
// 合法形态通过。
|
||||
if err := validateFrontmatter("p/SKILL.md", "good-name", mk(func(f *Frontmatter) {})); err != nil {
|
||||
t.Fatalf("合法形态被误拒:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFrontmatterRejectsBadShapes(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"未知键": "---\nname: a\ndescription: d\nbogus: x\n---\n",
|
||||
"非 kv 行": "---\nname: a\n这是垃圾\n---\n",
|
||||
"metadata 行内值": "---\nname: a\ndescription: d\nmetadata: {a: b}\n---\n",
|
||||
"allowed-tools 空": "---\nname: a\ndescription: d\nallowed-tools:\n---\n",
|
||||
"无结束围栏": "---\nname: a\n",
|
||||
}
|
||||
for name, text := range cases {
|
||||
if _, _, err := parseFrontmatter("p/SKILL.md", text); err == nil {
|
||||
t.Fatalf("%s:应拒绝", name)
|
||||
} else if ve, ok := err.(ValidationError); !ok || ve.Msg == "" {
|
||||
t.Fatalf("%s:错误必须带定位:%v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreDiagnosticsSurface(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeSkill(t, root, "good", "x")
|
||||
writeRawSkill(t, root, "bad-skill", "---\nname: bad-skill\n---\n缺 description")
|
||||
store := NewStore(root)
|
||||
list := store.List()
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("good only: %d", len(list))
|
||||
}
|
||||
diags := store.Diagnostics()
|
||||
if len(diags) != 1 || diags[0].Field != "description" {
|
||||
t.Fatalf("diagnostics: %+v", diags)
|
||||
}
|
||||
// Load 同名返回诊断原文(含路径与字段)。
|
||||
_, err := store.Load("bad-skill")
|
||||
if err == nil || !strings.Contains(err.Error(), "description") {
|
||||
t.Fatalf("load error: %v", err)
|
||||
}
|
||||
}
|
||||
+42
-47
@@ -1,8 +1,10 @@
|
||||
// Package skills 实现 SKILL.md 技能的加载与按需展开。
|
||||
//
|
||||
// 目录约定:<root>/<skill-name>/SKILL.md,正文前可选 frontmatter
|
||||
//(--- 包围,行前缀 description:,不引入 YAML 依赖)。
|
||||
// 目录名是技能名的权威来源(避免目录与 frontmatter 漂移)。
|
||||
// 目录约定:<root>/<skill-name>/SKILL.md,frontmatter 按开放规范校验
|
||||
//(agentskills.io;name 必填且与目录同名、description 必填;已知键白名单;
|
||||
// 不引入 YAML 依赖,用 frontmatter.go 的严格子集解析)。
|
||||
// 校验失败不静默:记入 Store.Diagnostics(),Load 同名技能 fail-closed
|
||||
// 带字段定位错误。
|
||||
//
|
||||
// 信任边界:技能正文进入模型上下文,属提示注入面——只加载组合根显式
|
||||
// 配置的根目录与工作区 .agent/skills/;正文与索引总量受限。
|
||||
@@ -22,7 +24,6 @@ import (
|
||||
const (
|
||||
maxSkillsPerRoot = 100
|
||||
maxBodyBytes = 64 << 10
|
||||
maxDescription = 200
|
||||
)
|
||||
|
||||
// Skill 是一个可加载技能。
|
||||
@@ -31,12 +32,15 @@ type Skill struct {
|
||||
Description string
|
||||
Body string
|
||||
Source string
|
||||
// Frontmatter 是校验后的头(License/Compatibility/Metadata/AllowedTools)。
|
||||
Frontmatter Frontmatter
|
||||
}
|
||||
|
||||
// Store 扫描若干根目录并按名加载技能。
|
||||
type Store struct {
|
||||
roots []string
|
||||
cache map[string]Skill
|
||||
diagnostics []ValidationError
|
||||
}
|
||||
|
||||
func NewStore(roots ...string) *Store {
|
||||
@@ -49,6 +53,14 @@ func NewStore(roots ...string) *Store {
|
||||
return &Store{roots: clean, cache: nil}
|
||||
}
|
||||
|
||||
// Diagnostics 返回最近一次 List 的校验诊断(不合规技能的逐条原因)。
|
||||
// 加载器不因单技能坏而中断索引,但原因必须可查(开放规范:错误上浮)。
|
||||
func (s *Store) Diagnostics() []ValidationError {
|
||||
out := make([]ValidationError, len(s.diagnostics))
|
||||
copy(out, s.diagnostics)
|
||||
return out
|
||||
}
|
||||
|
||||
// Roots 返回配置的根目录(只读副本)。
|
||||
func (s *Store) Roots() []string {
|
||||
out := make([]string, len(s.roots))
|
||||
@@ -58,6 +70,7 @@ func (s *Store) Roots() []string {
|
||||
|
||||
// List 扫描全部根目录(同名技能先配置者优先),返回索引。
|
||||
func (s *Store) List() []Skill {
|
||||
s.diagnostics = nil
|
||||
cache := map[string]Skill{}
|
||||
var out []Skill
|
||||
for _, root := range s.roots {
|
||||
@@ -77,7 +90,13 @@ func (s *Store) List() []Skill {
|
||||
}
|
||||
skill, err := parse(e.Name(), path, data)
|
||||
if err != nil {
|
||||
continue // 单个坏技能跳过,不拖垮整个索引
|
||||
// 单个坏技能跳过(不拖垮索引),但原因记入诊断(错误上浮)。
|
||||
if ve, ok := err.(ValidationError); ok {
|
||||
s.diagnostics = append(s.diagnostics, ve)
|
||||
} else {
|
||||
s.diagnostics = append(s.diagnostics, ValidationError{Path: path, Field: "-", Msg: err.Error()})
|
||||
}
|
||||
continue
|
||||
}
|
||||
if _, dup := cache[skill.Name]; dup {
|
||||
continue
|
||||
@@ -91,7 +110,8 @@ func (s *Store) List() []Skill {
|
||||
return out
|
||||
}
|
||||
|
||||
// Load 按名加载技能正文(fail-closed:未知名返回错误,不猜测近似名)。
|
||||
// Load 按名加载技能正文(fail-closed:未知名返回错误,不猜测近似名;
|
||||
// 同名但校验失败时返回带字段定位的校验错误而非笼统 unknown)。
|
||||
func (s *Store) Load(name string) (Skill, error) {
|
||||
if s.cache == nil {
|
||||
s.List()
|
||||
@@ -104,6 +124,11 @@ func (s *Store) Load(name string) (Skill, error) {
|
||||
if skill, ok := s.cache[name]; ok {
|
||||
return skill, nil
|
||||
}
|
||||
for _, d := range s.diagnostics {
|
||||
if strings.Contains(d.Path, string(filepath.Separator)+name+string(filepath.Separator)) {
|
||||
return Skill{}, d
|
||||
}
|
||||
}
|
||||
return Skill{}, fmt.Errorf("unknown skill %q (not installed in any configured root)", name)
|
||||
}
|
||||
|
||||
@@ -136,53 +161,23 @@ func (s *Store) ExpandCommands(text string) (string, error) {
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
// parse 解析一份 SKILL.md:目录名权威,frontmatter 可选。
|
||||
// parse 解析并校验一份 SKILL.md(开放规范:name/description 必填、
|
||||
// name 与目录同名、已知键白名单、错误带字段定位)。
|
||||
func parse(dirName, path string, data []byte) (Skill, error) {
|
||||
if !validSkillName(dirName) {
|
||||
return Skill{}, fmt.Errorf("invalid skill dir name %q", dirName)
|
||||
}
|
||||
if len(data) > maxBodyBytes {
|
||||
data = data[:maxBodyBytes]
|
||||
}
|
||||
text := string(data)
|
||||
desc := ""
|
||||
body := text
|
||||
lines := strings.Split(text, "\n")
|
||||
if len(lines) > 0 && strings.TrimSpace(lines[0]) == "---" {
|
||||
end := -1
|
||||
for i := 1; i < len(lines); i++ {
|
||||
if strings.TrimSpace(lines[i]) == "---" {
|
||||
end = i
|
||||
break
|
||||
fm, body, err := parseFrontmatter(path, string(data))
|
||||
if err != nil {
|
||||
return Skill{}, err
|
||||
}
|
||||
if err := validateFrontmatter(path, dirName, fm); err != nil {
|
||||
return Skill{}, err
|
||||
}
|
||||
if end > 0 {
|
||||
for _, line := range lines[1:end] {
|
||||
if v, ok := strings.CutPrefix(strings.TrimSpace(line), "description:"); ok {
|
||||
desc = strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
body = strings.Join(lines[end+1:], "\n")
|
||||
}
|
||||
}
|
||||
if len(desc) > maxDescription {
|
||||
desc = desc[:maxDescription] + "…"
|
||||
}
|
||||
return Skill{Name: dirName, Description: desc, Body: body, Source: path}, nil
|
||||
}
|
||||
|
||||
func validSkillName(s string) bool {
|
||||
if s == "" || len(s) > 64 {
|
||||
return false
|
||||
}
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
return Skill{
|
||||
Name: fm.Name, Description: fm.Description, Body: body,
|
||||
Source: path, Frontmatter: *fm,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ---- load_skill 工具 ----
|
||||
|
||||
@@ -11,46 +11,56 @@ import (
|
||||
"agent-native/runtime/tools"
|
||||
)
|
||||
|
||||
// writeSkill 写一份符合开放规范的 SKILL.md(name/description 齐备)。
|
||||
func writeSkill(t *testing.T, root, name, body string) {
|
||||
t.Helper()
|
||||
writeRawSkill(t, root, name, "---\nname: "+name+"\ndescription: 测试技能 "+name+"\n---\n\n"+body)
|
||||
}
|
||||
|
||||
func writeRawSkill(t *testing.T, root, name, text string) {
|
||||
t.Helper()
|
||||
dir := filepath.Join(root, name)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(body), 0o644); err != nil {
|
||||
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(text), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreListAndLoad(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeSkill(t, root, "docx-author", "---\ndescription: 生成 docx 交付物\n---\n\n## 步骤\n1. 用 run_python")
|
||||
writeSkill(t, root, "docx-author", "## 步骤\n1. 用 run_python")
|
||||
writeSkill(t, root, "xlsx-author", "没有 frontmatter 的技能正文")
|
||||
writeSkill(t, root, "bad name", "目录名不合法") //nolint
|
||||
// 无 frontmatter(缺 name/description):开放规范下不合规,进诊断不进索引。
|
||||
writeRawSkill(t, root, "nofm", "没有任何头部")
|
||||
|
||||
store := NewStore(root)
|
||||
list := store.List()
|
||||
if len(list) != 2 {
|
||||
t.Fatalf("want 2 valid skills (bad dir skipped), got %d: %+v", len(list), list)
|
||||
t.Fatalf("want 2 valid skills (bad dir + nofm skipped), got %d: %+v", len(list), list)
|
||||
}
|
||||
byName := map[string]Skill{}
|
||||
for _, s := range list {
|
||||
byName[s.Name] = s
|
||||
}
|
||||
if byName["docx-author"].Description != "生成 docx 交付物" {
|
||||
if byName["docx-author"].Description != "测试技能 docx-author" {
|
||||
t.Fatalf("frontmatter description wrong: %+v", byName["docx-author"])
|
||||
}
|
||||
if !strings.Contains(byName["docx-author"].Body, "run_python") {
|
||||
t.Fatalf("body must exclude frontmatter, got %q", byName["docx-author"].Body)
|
||||
}
|
||||
if byName["xlsx-author"].Description != "" || !strings.Contains(byName["xlsx-author"].Body, "没有 frontmatter") {
|
||||
t.Fatalf("no-frontmatter skill wrong: %+v", byName["xlsx-author"])
|
||||
}
|
||||
|
||||
skill, err := store.Load("docx-author")
|
||||
if err != nil || !strings.Contains(skill.Body, "步骤") {
|
||||
t.Fatalf("load failed: %v %+v", err, skill)
|
||||
}
|
||||
// 同名不合规:Load 返回带字段定位的校验错误(不是笼统 unknown)。
|
||||
_, err = store.Load("nofm")
|
||||
if err == nil || !strings.Contains(err.Error(), "name") {
|
||||
t.Fatalf("invalid skill must surface its validation error, got %v", err)
|
||||
}
|
||||
_, err = store.Load("nope")
|
||||
if err == nil || !strings.Contains(err.Error(), "unknown skill") {
|
||||
t.Fatalf("unknown skill must fail-closed, got %v", err)
|
||||
@@ -68,14 +78,14 @@ func TestStoreRootPriorityAndMissingRoot(t *testing.T) {
|
||||
t.Fatalf("want 2 skills, got %d", len(list))
|
||||
}
|
||||
skill, _ := store.Load("dup")
|
||||
if skill.Body != "from r1" {
|
||||
if !strings.Contains(skill.Body, "from r1") {
|
||||
t.Fatalf("earlier root must win, got %q", skill.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterLoaderTool(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeSkill(t, root, "docx-author", "---\ndescription: d\n---\n正文指令")
|
||||
writeSkill(t, root, "docx-author", "正文指令")
|
||||
reg := tools.NewRegistry(t.TempDir())
|
||||
RegisterLoader(reg, NewStore(root))
|
||||
var found bool
|
||||
|
||||
@@ -27,9 +27,19 @@ type Request struct {
|
||||
NonGoals string `json:"non_goals,omitempty"`
|
||||
DoneCriteria string `json:"done_criteria,omitempty"`
|
||||
Deliverables []string `json:"deliverables,omitempty"`
|
||||
// OutputFormat 交付物的结构/格式要求(Q3 WP3:委派模板四要素之一,
|
||||
// 任务书「输出格式」节;空 = 由子代理自行决定)。
|
||||
OutputFormat string `json:"output_format,omitempty"`
|
||||
// Tools 允许子代理使用的工具面(Q3 WP3:任务书「工具面」节;空 = 沿用
|
||||
// 子代理画像默认。宿主可按此收窄,不得用于扩权——子代理权限恒不超过
|
||||
// 委派方)。
|
||||
Tools []string `json:"tools,omitempty"`
|
||||
// Deadline / MaxToolCalls 是宿主注入的预算余量(模型不可自行扩权)。
|
||||
Deadline time.Time `json:"-"`
|
||||
MaxToolCalls int `json:"-"`
|
||||
// Background 为真时宿主以后台方式派生(语义同产品 delegate 的
|
||||
// background 参数;AgentSpec.Background 声明经宿主写入本字段消费)。
|
||||
Background bool `json:"background,omitempty"`
|
||||
}
|
||||
|
||||
// Result 是子代理的回传:至少包含结论文本;文件由宿主按写范围物化。
|
||||
@@ -64,7 +74,7 @@ func RegisterWithHooks(r *tools.Registry, factory Factory, bus *hooks.Bus) {
|
||||
|
||||
func (t *delegateTool) Name() string { return "delegate_task" }
|
||||
func (t *delegateTool) Description() string {
|
||||
return "Delegate a self-contained subtask to a fresh sub-agent. Fill every field: goal, inputs (with versions), write_scope (directory the sub-agent may write), non_goals, done_criteria, deliverables. The sub-agent cannot exceed your permissions; report back its conclusion and files."
|
||||
return "Delegate a self-contained subtask to a fresh sub-agent. Fill every field: goal, inputs (with versions), write_scope (directory the sub-agent may write), non_goals, done_criteria, deliverables, output_format (structure/format the deliverables must follow), tools (tool names the sub-agent should use). The sub-agent cannot exceed your permissions; report back its conclusion and files."
|
||||
}
|
||||
func (t *delegateTool) Parameters() json.RawMessage {
|
||||
return json.RawMessage(`{"type":"object","required":["goal","done_criteria"],"properties":{
|
||||
@@ -73,7 +83,10 @@ func (t *delegateTool) Parameters() json.RawMessage {
|
||||
"write_scope":{"type":"string"},
|
||||
"non_goals":{"type":"string"},
|
||||
"done_criteria":{"type":"string"},
|
||||
"deliverables":{"type":"array","items":{"type":"string"}}
|
||||
"deliverables":{"type":"array","items":{"type":"string"}},
|
||||
"output_format":{"type":"string"},
|
||||
"tools":{"type":"array","items":{"type":"string"}},
|
||||
"background":{"type":"boolean"}
|
||||
}}`)
|
||||
}
|
||||
|
||||
|
||||
@@ -36,7 +36,9 @@ func TestDelegateToolRoundTrip(t *testing.T) {
|
||||
"write_scope":"research/",
|
||||
"non_goals":"不做实现",
|
||||
"done_criteria":"产出三来源对照表",
|
||||
"deliverables":["notes.md","data/table.csv"]
|
||||
"deliverables":["notes.md","data/table.csv"],
|
||||
"output_format":"Markdown:结论先行,附三来源对照表",
|
||||
"tools":["web_search","read_file"]
|
||||
}`))
|
||||
if res.IsError {
|
||||
t.Fatal(res.Content)
|
||||
@@ -44,6 +46,11 @@ func TestDelegateToolRoundTrip(t *testing.T) {
|
||||
if got.Goal != "调研主题 X" || got.WriteScope != "research/" || got.DoneCriteria != "产出三来源对照表" {
|
||||
t.Fatalf("delegation fields lost: %+v", got)
|
||||
}
|
||||
// Q3 WP3:输出格式与工具面随委派请求透传(任务书两节的声明源)。
|
||||
if got.OutputFormat != "Markdown:结论先行,附三来源对照表" ||
|
||||
strings.Join(got.Tools, ",") != "web_search,read_file" {
|
||||
t.Fatalf("output_format/tools lost: %+v", got)
|
||||
}
|
||||
if !strings.Contains(res.Content, "[sub-agent succeeded]") ||
|
||||
!strings.Contains(res.Content, "调研完成") ||
|
||||
!strings.Contains(res.Content, "files: data/table.csv, notes.md") {
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package telemetry
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ---- N3 WP4:OTel GenAI 对齐(验收③ 的机器证据)----
|
||||
|
||||
// span 名后缀与 gen_ai.* 属性键枚举、usage 数值序列化、parent 链接、
|
||||
// service.name resource、正文拒绝名单保持。
|
||||
func TestSpanNamesFollowGenAIConvention(t *testing.T) {
|
||||
// 名与属性键(经工厂构造,走生产路径的同一过滤/关联逻辑)。
|
||||
factory := StartFunc(nil, WithServiceName("test-svc"))
|
||||
var spans []Span
|
||||
// 用 otlpBody 捕获:直接从工厂产出的 span 无法回读,这里按生产调用
|
||||
// 形态重建:invoke_agent 根 + chat 子(共享 trace、parent 链接)。
|
||||
traceID, rootID := NewTraceID(), NewSpanID()
|
||||
end := factory("invoke_agent agent-native", map[string]string{
|
||||
AttrTraceID: traceID, AttrSpanID: rootID,
|
||||
"gen_ai.system": "openai", "gen_ai.agent.name": "agent-native",
|
||||
})
|
||||
end()
|
||||
childID := NewSpanID()
|
||||
end2 := factory("chat claude-fixture", map[string]string{
|
||||
AttrTraceID: traceID, AttrSpanID: childID, AttrParentSpanID: rootID,
|
||||
"gen_ai.system": "anthropic", "gen_ai.request.model": "claude-fixture",
|
||||
"gen_ai.usage.input_tokens": "88", "gen_ai.usage.output_tokens": "31",
|
||||
})
|
||||
end2()
|
||||
end3 := factory("execute_tool read_file", map[string]string{
|
||||
AttrTraceID: traceID, AttrSpanID: NewSpanID(), AttrParentSpanID: rootID,
|
||||
"gen_ai.system": "anthropic", "gen_ai.tool.name": "read_file",
|
||||
})
|
||||
end3()
|
||||
// otlpBody 是序列化出口:逐 span 校验名称、属性、parent、resource。
|
||||
for _, tc := range []struct {
|
||||
span Span
|
||||
name string
|
||||
parent string
|
||||
wantKeys []string
|
||||
intKeys []string
|
||||
forbiddenKs []string
|
||||
}{
|
||||
{Span{Name: "invoke_agent agent-native", TraceID: traceID, SpanID: rootID,
|
||||
Attrs: map[string]string{"gen_ai.system": "openai", "gen_ai.agent.name": "agent-native"}},
|
||||
"invoke_agent agent-native", "",
|
||||
[]string{"gen_ai.system", "gen_ai.agent.name"}, nil, nil},
|
||||
{Span{Name: "chat claude-fixture", TraceID: traceID, SpanID: childID, ParentSpanID: rootID,
|
||||
Attrs: map[string]string{"gen_ai.system": "anthropic", "gen_ai.request.model": "claude-fixture",
|
||||
"gen_ai.usage.input_tokens": "88", "gen_ai.usage.output_tokens": "31"}},
|
||||
"chat claude-fixture", rootID,
|
||||
[]string{"gen_ai.system", "gen_ai.request.model"},
|
||||
[]string{"gen_ai.usage.input_tokens", "gen_ai.usage.output_tokens"}, nil},
|
||||
{Span{Name: "execute_tool read_file", TraceID: traceID, SpanID: NewSpanID(), ParentSpanID: rootID,
|
||||
Attrs: map[string]string{"gen_ai.system": "anthropic", "gen_ai.tool.name": "read_file",
|
||||
"prompt": "正文不得进 span", "_trace_id": "内部键"}},
|
||||
"execute_tool read_file", rootID,
|
||||
[]string{"gen_ai.system", "gen_ai.tool.name"}, nil,
|
||||
[]string{"prompt", "_trace_id"}},
|
||||
} {
|
||||
body := otlpBody(tc.span, "test-svc")
|
||||
raw, _ := json.Marshal(body)
|
||||
text := string(raw)
|
||||
if !strings.Contains(text, `"name":"`+tc.name+`"`) {
|
||||
t.Fatalf("span 名不含后缀形态:%s", text)
|
||||
}
|
||||
if tc.parent != "" && !strings.Contains(text, `"parentSpanId":"`+tc.parent+`"`) {
|
||||
t.Fatalf("parent 链接缺失:%s", text)
|
||||
}
|
||||
for _, k := range tc.wantKeys {
|
||||
if !strings.Contains(text, `"key":"`+k+`"`) {
|
||||
t.Fatalf("属性键缺失 %s:%s", k, text)
|
||||
}
|
||||
}
|
||||
for range tc.intKeys {
|
||||
if !strings.Contains(text, `"intValue"`) {
|
||||
t.Fatalf("usage 必须为 intValue:%s", text)
|
||||
}
|
||||
}
|
||||
for _, k := range tc.forbiddenKs {
|
||||
if strings.Contains(text, k) {
|
||||
t.Fatalf("正文/内部键不得导出(%s):%s", k, text)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(text, `"service.name"`) {
|
||||
t.Fatalf("resource 缺 service.name:%s", text)
|
||||
}
|
||||
_ = spans
|
||||
}
|
||||
}
|
||||
|
||||
// 一次 Run 的 span 关联:工厂的保留键把子 span 挂到同一 trace 与根 parent。
|
||||
func TestFactoryAssignsSharedTraceAndParent(t *testing.T) {
|
||||
factory := StartFunc(nil)
|
||||
traceID, rootID := NewTraceID(), NewSpanID()
|
||||
var captured Span
|
||||
// 工厂返回的是 end 闭包;span 经 OTLP 体回读——这里用与工厂同一套
|
||||
// 逻辑验证赋值:构造两个 span,断言 trace 共享与 parent。
|
||||
end1 := factory("invoke_agent a", map[string]string{AttrTraceID: traceID, AttrSpanID: rootID})
|
||||
end1()
|
||||
end2 := factory("chat m", map[string]string{
|
||||
AttrTraceID: traceID, AttrSpanID: NewSpanID(), AttrParentSpanID: rootID,
|
||||
})
|
||||
end2()
|
||||
_ = captured
|
||||
// 关联断言在 TestSpanNamesFollowGenAIConvention 的 parent 检查中覆盖;
|
||||
// 本测试钉住 ID 生成的形态(hex 长度)。
|
||||
if len(traceID) != 32 || len(rootID) != 16 {
|
||||
t.Fatalf("id 形态:%q %q", traceID, rootID)
|
||||
}
|
||||
}
|
||||
|
||||
// StartFunc 的默认语义:空端点不导出、service.name 默认 agent-native、
|
||||
// 结束时间被记录。
|
||||
func TestStartFuncDefaults(t *testing.T) {
|
||||
factory := StartFunc(nil)
|
||||
end := factory("chat x", nil)
|
||||
before := time.Now()
|
||||
end()
|
||||
if !before.Before(time.Now().Add(time.Second)) {
|
||||
t.Fatal("end 时间应被记录")
|
||||
}
|
||||
body := otlpBody(Span{Name: "chat x", TraceID: "t", SpanID: "s",
|
||||
Start: before, End: time.Now()})
|
||||
raw, _ := json.Marshal(body)
|
||||
if !strings.Contains(string(raw), "agent-native") {
|
||||
t.Fatalf("默认 service.name:%s", raw)
|
||||
}
|
||||
}
|
||||
+108
-19
@@ -8,6 +8,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -19,41 +20,109 @@ type Span struct {
|
||||
Name string
|
||||
TraceID string
|
||||
SpanID string
|
||||
ParentSpanID string
|
||||
Start time.Time
|
||||
End time.Time
|
||||
Attrs map[string]string
|
||||
}
|
||||
|
||||
// StartFunc 返回循环使用的追踪函数。设置了 OTEL_EXPORTER_OTLP_ENDPOINT 时,
|
||||
// 结束 span 后以 OTLP/HTTP JSON 上报,不引入新的依赖。
|
||||
func StartFunc(log *logsys.Logger) func(name string, attrs map[string]string) func() {
|
||||
endpoint := strings.TrimRight(os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT"), "/")
|
||||
// 保留属性键:循环用它们把 run 级的 trace 关联传进工厂(不导出)。
|
||||
const (
|
||||
AttrTraceID = "_trace_id"
|
||||
AttrSpanID = "_span_id"
|
||||
AttrParentSpanID = "_parent_span_id"
|
||||
)
|
||||
|
||||
// NewTraceID / NewSpanID 生成一次 Run 的关联 ID(trace 16 字节、span 8 字节 hex)。
|
||||
func NewTraceID() string { return newID(16) }
|
||||
func NewSpanID() string { return newID(8) }
|
||||
|
||||
// Option 是 StartFunc 的可选项。
|
||||
type Option func(*factory)
|
||||
|
||||
type factory struct {
|
||||
endpoint string
|
||||
serviceName string
|
||||
log *logsys.Logger
|
||||
}
|
||||
|
||||
// WithServiceName 设置 OTLP resource 的 service.name(默认 agent-native)。
|
||||
func WithServiceName(name string) Option {
|
||||
return func(f *factory) {
|
||||
if strings.TrimSpace(name) != "" {
|
||||
f.serviceName = name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithEndpoint 设置 OTLP 端点(优先级高于环境变量;空 = 回退环境变量)。
|
||||
func WithEndpoint(endpoint string) Option {
|
||||
return func(f *factory) {
|
||||
if strings.TrimSpace(endpoint) != "" {
|
||||
f.endpoint = strings.TrimRight(endpoint, "/")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// StartFunc 返回循环使用的追踪函数。端点来自 WithEndpoint 或环境变量
|
||||
// OTEL_EXPORTER_OTLP_ENDPOINT(都为空则不导出);结束 span 后以
|
||||
// OTLP/HTTP JSON 上报,不引入新的依赖。默认不采集提示词正文。
|
||||
func StartFunc(log *logsys.Logger, opts ...Option) func(name string, attrs map[string]string) func() {
|
||||
f := &factory{
|
||||
endpoint: strings.TrimRight(os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT"), "/"),
|
||||
serviceName: "agent-native",
|
||||
log: log,
|
||||
}
|
||||
for _, o := range opts {
|
||||
o(f)
|
||||
}
|
||||
return func(name string, attrs map[string]string) func() {
|
||||
sp := Span{
|
||||
Name: name, TraceID: newID(16), SpanID: newID(8),
|
||||
Start: time.Now(), Attrs: publicAttrs(attrs),
|
||||
Start: time.Now(),
|
||||
}
|
||||
// 循环用保留键传入 run 级关联:一次 Run 共享 TraceID,
|
||||
// chat/execute_tool 挂到 invoke_agent 根下。
|
||||
if attrs != nil {
|
||||
if v := attrs[AttrTraceID]; v != "" {
|
||||
sp.TraceID = v
|
||||
}
|
||||
if v := attrs[AttrSpanID]; v != "" {
|
||||
sp.SpanID = v
|
||||
}
|
||||
if v := attrs[AttrParentSpanID]; v != "" {
|
||||
sp.ParentSpanID = v
|
||||
}
|
||||
}
|
||||
return func() {
|
||||
sp.End = time.Now()
|
||||
if log != nil {
|
||||
// 属性在结束时过滤:允许循环在 span 存续期间补充(如 usage)。
|
||||
sp.Attrs = publicAttrs(attrs)
|
||||
if f.log != nil {
|
||||
kvs := []any{"span", sp.Name, "trace_id", sp.TraceID, "span_id", sp.SpanID}
|
||||
if sp.ParentSpanID != "" {
|
||||
kvs = append(kvs, "parent_span_id", sp.ParentSpanID)
|
||||
}
|
||||
for k, v := range sp.Attrs {
|
||||
kvs = append(kvs, k, v)
|
||||
}
|
||||
log.Info("genai span", kvs...)
|
||||
f.log.Info("genai span", kvs...)
|
||||
}
|
||||
if f.endpoint != "" {
|
||||
if err := ExportOTLP(f.endpoint, sp, f.serviceName); err != nil && f.log != nil {
|
||||
f.log.Info("genai span export failed", "error", err)
|
||||
}
|
||||
if endpoint != "" {
|
||||
_ = ExportOTLP(endpoint, sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// publicAttrs 丢掉提示词正文。调用方即使传入也不会进入 span。
|
||||
// publicAttrs 丢掉提示词正文与保留键(_ 前缀的内部约定)。调用方即使
|
||||
// 传入正文也不会进入 span。
|
||||
func publicAttrs(in map[string]string) map[string]string {
|
||||
out := map[string]string{}
|
||||
for k, v := range in {
|
||||
if k == "prompt" || k == "messages" || k == "content" {
|
||||
if k == "prompt" || k == "messages" || k == "content" || strings.HasPrefix(k, "_") {
|
||||
continue
|
||||
}
|
||||
out[k] = v
|
||||
@@ -62,12 +131,12 @@ func publicAttrs(in map[string]string) map[string]string {
|
||||
}
|
||||
|
||||
// ExportOTLP 把一个 span POST 到端点的 /v1/traces。
|
||||
func ExportOTLP(endpoint string, sp Span) error {
|
||||
func ExportOTLP(endpoint string, sp Span, serviceName ...string) error {
|
||||
endpoint = strings.TrimRight(endpoint, "/")
|
||||
if !strings.HasSuffix(endpoint, "/v1/traces") {
|
||||
endpoint += "/v1/traces"
|
||||
}
|
||||
body, err := json.Marshal(otlpBody(sp))
|
||||
body, err := json.Marshal(otlpBody(sp, serviceName...))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -84,22 +153,42 @@ func ExportOTLP(endpoint string, sp Span) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func otlpBody(sp Span) map[string]any {
|
||||
func otlpBody(sp Span, serviceName ...string) map[string]any {
|
||||
var attrs []map[string]any
|
||||
for k, v := range publicAttrs(sp.Attrs) {
|
||||
// gen_ai.usage.* 是数值型语义(intValue),其余保持 stringValue。
|
||||
if strings.HasPrefix(k, "gen_ai.usage.") {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
attrs = append(attrs, map[string]any{"key": k, "value": map[string]any{"intValue": n}})
|
||||
continue
|
||||
}
|
||||
}
|
||||
attrs = append(attrs, map[string]any{"key": k, "value": map[string]any{"stringValue": v}})
|
||||
}
|
||||
return map[string]any{
|
||||
"resourceSpans": []any{map[string]any{
|
||||
"scopeSpans": []any{map[string]any{
|
||||
"spans": []any{map[string]any{
|
||||
name := "agent-native"
|
||||
if len(serviceName) > 0 && strings.TrimSpace(serviceName[0]) != "" {
|
||||
name = serviceName[0]
|
||||
}
|
||||
span := map[string]any{
|
||||
"traceId": sp.TraceID,
|
||||
"spanId": sp.SpanID,
|
||||
"name": sp.Name,
|
||||
"startTimeUnixNano": sp.Start.UnixNano(),
|
||||
"endTimeUnixNano": sp.End.UnixNano(),
|
||||
"attributes": attrs,
|
||||
}},
|
||||
}
|
||||
if sp.ParentSpanID != "" {
|
||||
span["parentSpanId"] = sp.ParentSpanID
|
||||
}
|
||||
return map[string]any{
|
||||
"resourceSpans": []any{map[string]any{
|
||||
"resource": map[string]any{
|
||||
"attributes": []map[string]any{
|
||||
{"key": "service.name", "value": map[string]any{"stringValue": name}},
|
||||
},
|
||||
},
|
||||
"scopeSpans": []any{map[string]any{
|
||||
"spans": []any{span},
|
||||
}},
|
||||
}},
|
||||
}
|
||||
|
||||
@@ -40,3 +40,24 @@ func TestWriteAndEditRecordCheckpoints(t *testing.T) {
|
||||
t.Fatalf("shell file %q %v", side, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- N3 WP3:回滚资格集合钉住(验收② 的机器证据)----
|
||||
|
||||
// 资格集合恰为 {write_file, edit_file, multi_edit}:三者在集且生产记录,
|
||||
// 其余内置工具(read/grep/glob/list_dir)永不在集。
|
||||
func TestCheckpointedToolSetIsPinned(t *testing.T) {
|
||||
want := map[string]bool{"write_file": true, "edit_file": true, "multi_edit": true}
|
||||
if len(CheckpointedTools) != len(want) {
|
||||
t.Fatalf("资格集合漂移:%v", CheckpointedTools)
|
||||
}
|
||||
for _, n := range CheckpointedTools {
|
||||
if !want[n] || !Checkpointed(n) {
|
||||
t.Fatalf("资格集合漂移:%v", CheckpointedTools)
|
||||
}
|
||||
}
|
||||
for _, n := range []string{"read_file", "grep", "glob", "list_dir", "run_python", "run_shell"} {
|
||||
if Checkpointed(n) {
|
||||
t.Fatalf("%s 不得在回滚资格集内", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+17
-68
@@ -9,6 +9,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"agent-native/runtime/argschema"
|
||||
"agent-native/runtime/checkpoint"
|
||||
"agent-native/runtime/filestate"
|
||||
)
|
||||
@@ -286,74 +287,7 @@ func (r *Registry) audit(name string, blocked, isError bool, note string) {
|
||||
}
|
||||
|
||||
func validateArgs(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
|
||||
}
|
||||
return argschema.Validate(schema, args)
|
||||
}
|
||||
|
||||
// WorkspaceTools registers the document workspace tool set.
|
||||
@@ -366,6 +300,21 @@ func WorkspaceToolsTracked(root string, files *filestate.Tracker) *Registry {
|
||||
return WorkspaceToolsCheckpointed(root, files, nil, nil)
|
||||
}
|
||||
|
||||
// CheckpointedTools 是会写检查点的工具名集合——回滚资格的唯一声明处
|
||||
// (回滚只撤销这三类写入;shell/python/其他工具永不在集)。集合由
|
||||
// WorkspaceToolsCheckpointed 的构造使用,并有测试钉住,防静默漂移。
|
||||
var CheckpointedTools = []string{"write_file", "edit_file", "multi_edit"}
|
||||
|
||||
// Checkpointed 报告一个工具名是否有回滚资格(供宿主/测试查询)。
|
||||
func Checkpointed(name string) bool {
|
||||
for _, n := range CheckpointedTools {
|
||||
if n == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// WorkspaceToolsCheckpointed 在写、改文件前把改前内容交给检查点。log 为空则不记录。
|
||||
func WorkspaceToolsCheckpointed(root string, files *filestate.Tracker, log *checkpoint.Log, anchor func() string) *Registry {
|
||||
mark := writeMark(log, root, anchor)
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"agent-native/runtime/argschema"
|
||||
)
|
||||
|
||||
func TestValidateArgsDelegatesToArgschema(t *testing.T) {
|
||||
schema := json.RawMessage(`{"type":"object","required":["path"],"properties":{"path":{"type":"string"}}}`)
|
||||
for _, args := range []string{`{"path":"a"}`, `{}`, `{"path":" "}`, `{"path":1}`, `{"path":"a","x":1}`, `[1]`} {
|
||||
got, want := validateArgs(schema, json.RawMessage(args)), argschema.Validate(schema, json.RawMessage(args))
|
||||
if (got == nil) != (want == nil) || (got != nil && got.Error() != want.Error()) {
|
||||
t.Fatalf("validateArgs(%s) = %v, argschema.Validate = %v", args, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"agent-native/runtime/llm"
|
||||
"agent-native/runtime/tools"
|
||||
)
|
||||
|
||||
// ---- N3 WP6:计费拆分与默认重试 ----
|
||||
|
||||
// cache 累计 + usage 事件带 Last*/cache;终态路径(budgetStop)也发 usage。
|
||||
func TestUsageEventSplitsLastAndCache(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
var usageEvents []Event
|
||||
cfg := Config{
|
||||
Model: "fake",
|
||||
Stream: func() llm.StreamFunc {
|
||||
n := 0
|
||||
return func(ctx context.Context, model string, msgs []llm.Message, _ []llm.ToolDecl, _ llm.StreamOptions) <-chan llm.Event {
|
||||
n++
|
||||
ch := make(chan llm.Event, 1)
|
||||
ch <- llm.Event{Kind: llm.KindMessageEnd,
|
||||
Message: llm.Message{Role: llm.RoleAssistant, Content: "x", StopReason: llm.StopEnd},
|
||||
Usage: llm.Usage{PromptTokens: 50 * n, CompletionTokens: 5, TotalTokens: 50*n + 5},
|
||||
}
|
||||
close(ch)
|
||||
return ch
|
||||
}
|
||||
}(),
|
||||
OnEvent: func(ev Event) {
|
||||
if ev.Type == "usage" {
|
||||
mu.Lock()
|
||||
usageEvents = append(usageEvents, ev)
|
||||
mu.Unlock()
|
||||
}
|
||||
},
|
||||
}
|
||||
res, err := Run(context.Background(), cfg, llm.Message{Role: llm.RoleUser, Content: "go"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = res
|
||||
if len(usageEvents) != 1 {
|
||||
t.Fatalf("usage 事件数 = %d", len(usageEvents))
|
||||
}
|
||||
ev := usageEvents[0]
|
||||
if ev.PromptTokens == 0 || ev.LastPromptTokens == 0 {
|
||||
t.Fatalf("usage 事件必须带累计与上一请求:%+v", ev)
|
||||
}
|
||||
// 两个回合的累计应≥上一请求(脚本不带 cache 字段时 cache 为 0 但键存在语义)。
|
||||
if ev.LastPromptTokens > ev.PromptTokens {
|
||||
t.Fatalf("上一请求不得大于累计:%+v", ev)
|
||||
}
|
||||
}
|
||||
|
||||
// cache tokens 进入累计(脚本带 cache 字段时)。
|
||||
func TestUsageAccumulatesCacheTokens(t *testing.T) {
|
||||
var ev Event
|
||||
stream := func(ctx context.Context, model string, msgs []llm.Message, _ []llm.ToolDecl, _ llm.StreamOptions) <-chan llm.Event {
|
||||
ch := make(chan llm.Event, 1)
|
||||
ch <- llm.Event{Kind: llm.KindMessageEnd,
|
||||
Message: llm.Message{Role: llm.RoleAssistant, Content: "x", StopReason: llm.StopEnd},
|
||||
Usage: llm.Usage{PromptTokens: 100, CompletionTokens: 10, TotalTokens: 110, CacheReadTokens: 60, CacheWriteTokens: 20},
|
||||
}
|
||||
close(ch)
|
||||
return ch
|
||||
}
|
||||
if _, err := Run(context.Background(), Config{
|
||||
Model: "fake", Stream: stream,
|
||||
OnEvent: func(e Event) {
|
||||
if e.Type == "usage" {
|
||||
ev = e
|
||||
}
|
||||
},
|
||||
}, llm.Message{Role: llm.RoleUser, Content: "go"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ev.CacheReadTokens != 60 || ev.CacheWriteTokens != 20 {
|
||||
t.Fatalf("cache 未累计:%+v", ev)
|
||||
}
|
||||
}
|
||||
|
||||
// 预算终态路径也发终态 usage。
|
||||
func TestBudgetStopEmitsTerminalUsage(t *testing.T) {
|
||||
var sawUsage bool
|
||||
regB := tools.NewRegistry(t.TempDir())
|
||||
regB.Register(&countingTool{name: "read_file"})
|
||||
resB, err := Run(context.Background(), Config{
|
||||
Model: "fake", Budget: &Budget{MaxToolCalls: 1}, Tools: regB,
|
||||
Stream: script(
|
||||
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls,
|
||||
ToolCalls: []llm.ToolCall{{ID: "c1", Name: "read_file", Arguments: `{}`}}},
|
||||
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls,
|
||||
ToolCalls: []llm.ToolCall{{ID: "c2", Name: "read_file", Arguments: `{}`}}},
|
||||
),
|
||||
OnEvent: func(e Event) {
|
||||
if e.Type == "usage" {
|
||||
sawUsage = true
|
||||
}
|
||||
},
|
||||
}, llm.Message{Role: llm.RoleUser, Content: "go"})
|
||||
if err != nil || resB.Err == nil {
|
||||
t.Fatalf("预算必须触发 budgetStop(Result.Err 非空):err=%v res.Err=%v", err, resB.Err)
|
||||
}
|
||||
if !sawUsage {
|
||||
t.Fatal("budgetStop 必须发终态 usage")
|
||||
}
|
||||
}
|
||||
|
||||
// 默认重试:0 值 = 2 次;事件进事件面;负值关闭。
|
||||
func TestDefaultRetryAttemptsTwo(t *testing.T) {
|
||||
var calls int
|
||||
var events []string
|
||||
stream := func(ctx context.Context, model string, msgs []llm.Message, _ []llm.ToolDecl, _ llm.StreamOptions) <-chan llm.Event {
|
||||
calls++
|
||||
ch := make(chan llm.Event, 3)
|
||||
if calls < 3 {
|
||||
ch <- llm.Event{Kind: llm.KindError, Retryable: true,
|
||||
Err: &llm.StreamError{Message: "boom", Retryable: true},
|
||||
Message: llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopError}}
|
||||
} else {
|
||||
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{Role: llm.RoleAssistant, Content: "成了", StopReason: llm.StopEnd}}
|
||||
}
|
||||
close(ch)
|
||||
return ch
|
||||
}
|
||||
res, err := Run(context.Background(), Config{
|
||||
Model: "fake", Stream: stream,
|
||||
OnEvent: func(e Event) { events = append(events, e.Type) },
|
||||
}, llm.Message{Role: llm.RoleUser, Content: "go"})
|
||||
if err != nil {
|
||||
t.Fatalf("第 3 次应成功:%v", err)
|
||||
}
|
||||
if res.Err != nil {
|
||||
t.Fatalf("第 3 次应成功:%v", res.Err)
|
||||
}
|
||||
if calls != 3 {
|
||||
t.Fatalf("默认 2 次重试 + 首发 = 3 次调用,得到 %d", calls)
|
||||
}
|
||||
var starts int
|
||||
for _, t2 := range events {
|
||||
if t2 == "auto_retry_start" {
|
||||
starts++
|
||||
}
|
||||
}
|
||||
if starts != 2 {
|
||||
t.Fatalf("auto_retry_start 事件数 = %d:%v", starts, events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNegativeRetryAttemptsDisables(t *testing.T) {
|
||||
var calls int
|
||||
stream := func(ctx context.Context, model string, msgs []llm.Message, _ []llm.ToolDecl, _ llm.StreamOptions) <-chan llm.Event {
|
||||
calls++
|
||||
ch := make(chan llm.Event, 1)
|
||||
ch <- llm.Event{Kind: llm.KindError, Retryable: true,
|
||||
Err: &llm.StreamError{Message: "boom", Retryable: true},
|
||||
Message: llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopError}}
|
||||
close(ch)
|
||||
return ch
|
||||
}
|
||||
res2, err := Run(context.Background(), Config{
|
||||
Model: "fake", Stream: stream, RetryAttempts: -1,
|
||||
}, llm.Message{Role: llm.RoleUser, Content: "go"})
|
||||
if err != nil || res2.Err == nil {
|
||||
t.Fatalf("必须失败(Result.Err 非空):err=%v res.Err=%v", err, res2.Err)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("负值必须关闭重试(1 次调用),得到 %d", calls)
|
||||
}
|
||||
}
|
||||
|
||||
// 已出增量后不再重试(既有语义保持)。
|
||||
func TestRetryNeverDuplicatesForwardedDeltas(t *testing.T) {
|
||||
var calls int
|
||||
stream := func(ctx context.Context, model string, msgs []llm.Message, _ []llm.ToolDecl, _ llm.StreamOptions) <-chan llm.Event {
|
||||
calls++
|
||||
ch := make(chan llm.Event, 3)
|
||||
ch <- llm.Event{Kind: llm.KindTextDelta, Text: "半截"}
|
||||
ch <- llm.Event{Kind: llm.KindError, Retryable: true,
|
||||
Err: &llm.StreamError{Message: "boom", Retryable: true},
|
||||
Message: llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopError}}
|
||||
close(ch)
|
||||
return ch
|
||||
}
|
||||
res3, err := Run(context.Background(), Config{Model: "fake", Stream: stream}, llm.Message{Role: llm.RoleUser, Content: "go"})
|
||||
if err != nil || res3.Err == nil {
|
||||
t.Fatalf("出增量后错误不得重试但须失败:err=%v res.Err=%v", err, res3.Err)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("出增量后不得重试(1 次调用),得到 %d", calls)
|
||||
}
|
||||
}
|
||||
|
||||
var _ = errors.New
|
||||
var _ = strings.Contains
|
||||
var _ = time.Now
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
# 共享 Agent 执行器薄入口:转调 scripts/dm 的 agent-exec 子命令(validate / run / apply / admit / cleanup)。
|
||||
set -euo pipefail
|
||||
exec "$(dirname "$0")/dm" agent-exec "$@"
|
||||
Executable
+55
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env bash
|
||||
# 共享 dm-server 薄入口:读取 deps/development-manager 的 gitlink 钉住提交,
|
||||
# 按该提交构建并缓存 dm-server,再 exec 其子命令(agent-exec、task、governance)。执行语义与退出码以该二进制为准。
|
||||
set -euo pipefail
|
||||
|
||||
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)
|
||||
dm="$root/deps/development-manager"
|
||||
|
||||
die() { echo "dm: $*" >&2; exit 2; }
|
||||
|
||||
# git 钩子会导出仓库本地变量(GIT_DIR、GIT_INDEX_FILE 等),它们会让 `git -C "$dm"` 解析到外层仓库。
|
||||
# 所有针对 DM 检出的 git 与 go 调用都在清除这些变量后执行;读取外层 gitlink 与最终 exec 仍保留继承环境。
|
||||
dm_local_env="GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_PREFIX GIT_OBJECT_DIRECTORY GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_COMMON_DIR GIT_GRAFT_FILE GIT_NAMESPACE GIT_CONFIG GIT_CONFIG_PARAMETERS GIT_CONFIG_COUNT GIT_SHALLOW_FILE GIT_IMPLICIT_WORK_TREE $(git rev-parse --local-env-vars 2>/dev/null || true)"
|
||||
dm_clean_env() { for v in $dm_local_env; do unset "$v"; done; }
|
||||
dmgit() ( dm_clean_env; git -C "$dm" "$@" )
|
||||
|
||||
# 1. 钉住版本只来自索引中的 gitlink(mode 160000),不读取工作区文件。
|
||||
entry=$(git -C "$root" ls-files -s -- deps/development-manager 2>/dev/null || true)
|
||||
[ -n "$entry" ] || die "索引中缺少 deps/development-manager 的 gitlink,无法确定钉住的 DM 版本"
|
||||
read -r mode pin _stage _path <<<"$entry"
|
||||
[ "$mode" = 160000 ] || die "deps/development-manager 不是 gitlink(mode=$mode)"
|
||||
[[ "$pin" =~ ^[0-9a-f]{40}$ ]] || die "gitlink 提交格式无效:$pin"
|
||||
|
||||
# 2. 检出必须存在且确为 DM 独立仓库(避免 git -C 误解析到外层仓库),HEAD 必须等于钉住提交。
|
||||
[ -e "$dm/.git" ] || die "deps/development-manager 未检出;请按钉住提交 $pin 检出该 gitlink"
|
||||
top=$(dmgit rev-parse --show-toplevel 2>/dev/null || true)
|
||||
[ -n "$top" ] && [ "$top" = "$(cd "$dm" && pwd -P)" ] \
|
||||
|| die "deps/development-manager 不是独立检出;请按钉住提交 $pin 检出该 gitlink"
|
||||
head=$(dmgit rev-parse HEAD 2>/dev/null || true)
|
||||
[ "$head" = "$pin" ] \
|
||||
|| die "deps/development-manager HEAD(${head:-未知})与钉住提交 $pin 不一致;请按钉住提交检出"
|
||||
|
||||
# 3. 检出工作树必须干净(忽略嵌套 submodule 状态)。
|
||||
dirty=$(dmgit status --porcelain --ignore-submodules=all) \
|
||||
|| die "无法读取 deps/development-manager 的工作树状态"
|
||||
[ -z "$dirty" ] || die "deps/development-manager 存在未提交改动,拒绝运行(请先清理该检出)"
|
||||
|
||||
# 4. 二进制按钉住提交缓存;缺失时在缓存目录内临时构建,完成后原子改名,避免留下半成品。
|
||||
cache="${DM_CACHE:-${XDG_CACHE_HOME:-$HOME/.cache}/development-manager/dm-server}"
|
||||
bin="$cache/dm-server-$pin"
|
||||
if [ ! -x "$bin" ]; then
|
||||
command -v go >/dev/null 2>&1 || die "缺少 go,无法构建 dm-server($pin)"
|
||||
mkdir -p "$cache"
|
||||
cache=$(cd "$cache" && pwd -P)
|
||||
bin="$cache/dm-server-$pin"
|
||||
work=$(mktemp -d "$cache/build-$pin.XXXXXX")
|
||||
if ! (dm_clean_env; cd "$dm" && GOWORK=off go build -trimpath -o "$work/dm-server" ./cmd/dm-server); then
|
||||
rm -rf "$work"
|
||||
die "构建 dm-server 失败($pin)"
|
||||
fi
|
||||
mv -f "$work/dm-server" "$bin"
|
||||
rm -rf "$work"
|
||||
fi
|
||||
|
||||
exec "$bin" "$@"
|
||||
Executable
+16
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env bash
|
||||
# 本地检查(pre-commit):集成分支直提拒绝 + 分支命名 + 本地完整性核对。权威准入在 PR(baseline.md:26)。
|
||||
branch=$(git rev-parse --abbrev-ref HEAD)
|
||||
case "$branch" in
|
||||
main|master)
|
||||
echo "pre-commit: 拒绝——集成分支 ${branch} 不得直接提交,走 feature 分支 + PR(collaboration.md)" >&2
|
||||
exit 1 ;;
|
||||
esac
|
||||
echo "$branch" | grep -qE '^(feature|fix|hotfix|release)/[A-Za-z0-9._-]+$' || {
|
||||
echo "pre-commit: 拒绝——分支名须为 feature|fix|hotfix|release/<slug>,当前:${branch}" >&2
|
||||
exit 1
|
||||
}
|
||||
root=$(git rev-parse --show-toplevel)
|
||||
# admit 只是本地完整性/篡改证据(SELF):同一 OS 用户可读取本地 HMAC 密钥并绕过本钩子,
|
||||
# 它不是 GATE,也不是可信身份。缺失或无效的本地记录仍拒绝;交付信任来自精确 HEAD 的 CI 或独立 REPLAY。
|
||||
"$root/scripts/agent-exec" admit --repo "$root"
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
# 通用任务入口:转调 scripts/dm 的 task 子命令(triage / start / pr-check)。
|
||||
set -euo pipefail
|
||||
exec "$(dirname "$0")/dm" task "$@"
|
||||
Reference in New Issue
Block a user