Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2fb9ffa6d | ||
|
|
cff69355f7 | ||
|
|
ac91b8436e | ||
|
|
5c0d2d87dd | ||
|
|
afbb567301 | ||
|
|
2fc66be652 | ||
|
|
899fd70d27 | ||
|
|
9c24e220dd | ||
|
|
28f3ea7674 | ||
|
|
b6a843337c | ||
|
|
5b21325da7 | ||
|
|
42b22b436b | ||
|
|
14d48697a6 | ||
|
|
73ae2bb616 | ||
|
|
3cf8fa39b6 | ||
|
|
8d09aa0265 | ||
|
|
a625686ea9 | ||
|
|
c570f80e29 | ||
|
|
9c7dc6bf4a | ||
|
|
b1034e13ea | ||
|
|
6a1dcddb48 | ||
|
|
a5e3c7c817 | ||
|
|
6e972335c9 | ||
|
|
e86e74bc36 | ||
|
|
94afd71f00 | ||
|
|
c348441691 | ||
|
|
7f60baeaf8 | ||
|
|
ef2fa75006 |
@@ -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` 配置,不证明执行结果 |
|
||||
|
||||
## 环境与数据
|
||||
|
||||
|
||||
+153
-32
@@ -141,7 +141,7 @@ func (b *Backend) Close() {
|
||||
|
||||
// Execute 运行一轮带工具的自研智能体循环,产出目标交付物。
|
||||
func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result, error) {
|
||||
if len(req.Targets) == 0 {
|
||||
if len(req.Targets) == 0 && !allowEmptyTargets(ctx) {
|
||||
req.Targets = []string{"output.md"}
|
||||
}
|
||||
profile := strings.TrimSpace(req.ToolProfile)
|
||||
@@ -149,10 +149,10 @@ func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result,
|
||||
profile = exec.ToolProfileWorkspace
|
||||
}
|
||||
if profile != exec.ToolProfileWorkspace && profile != exec.ToolProfileReasoning {
|
||||
return &exec.Result{Success: false, ErrorReason: "unknown tool profile: " + profile}, nil
|
||||
return preflightFail("unknown tool profile: " + profile), nil
|
||||
}
|
||||
if b.opts.Stream == nil && (strings.TrimSpace(b.opts.APIBase) == "" || strings.TrimSpace(b.opts.APIKey) == "") {
|
||||
return &exec.Result{Success: false, ErrorReason: "agent-native backend requires api base/key (or injected stream)"}, nil
|
||||
return preflightFail("agent-native backend requires api base/key (or injected stream)"), nil
|
||||
}
|
||||
if b.opts.Ask != nil {
|
||||
ctx = mcp.WithAsk(ctx, b.opts.Ask)
|
||||
@@ -168,7 +168,7 @@ func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result,
|
||||
model = strings.TrimSpace(req.Model)
|
||||
}
|
||||
if model == "" {
|
||||
return &exec.Result{Success: false, ErrorReason: "agent-native backend requires model"}, nil
|
||||
return preflightFail("agent-native backend requires model"), nil
|
||||
}
|
||||
|
||||
workDir := req.WorkDir
|
||||
@@ -177,7 +177,7 @@ func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result,
|
||||
// 落一次性临时目录,不污染进程 CWD(契约套件即发此类请求)。
|
||||
tmp, err := os.MkdirTemp("", "agent-native-run-*")
|
||||
if err != nil {
|
||||
return &exec.Result{Success: false, ErrorReason: err.Error()}, nil
|
||||
return preflightFail(err.Error()), nil
|
||||
}
|
||||
workDir = tmp
|
||||
} else if !filepath.IsAbs(workDir) {
|
||||
@@ -185,7 +185,7 @@ func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result,
|
||||
workDir = filepath.Join(wd, workDir)
|
||||
}
|
||||
if err := os.MkdirAll(workDir, 0o755); err != nil {
|
||||
return &exec.Result{Success: false, ErrorReason: err.Error()}, nil
|
||||
return preflightFail(err.Error()), nil
|
||||
}
|
||||
if strings.TrimSpace(req.Instructions) != "" {
|
||||
_ = os.WriteFile(filepath.Join(workDir, "INSTRUCTIONS.md"), []byte(req.Instructions), 0o644)
|
||||
@@ -207,13 +207,32 @@ func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result,
|
||||
}
|
||||
for _, name := range req.Skills {
|
||||
if !indexSet[name] {
|
||||
return &exec.Result{Success: false, ErrorReason: "declared skill not installed: " + name}, nil
|
||||
return preflightFail("declared skill not installed: " + name), nil
|
||||
}
|
||||
}
|
||||
|
||||
writable := writableFrom(ctx)
|
||||
// F02: a non-empty Writable cannot be enforced for exec/MCP/host tools or
|
||||
// subagent delegation — reject in preflight rather than pretend the
|
||||
// write-set holds.
|
||||
if len(writable) > 0 && profile == exec.ToolProfileWorkspace {
|
||||
if b.opts.Exec != nil {
|
||||
return preflightFail("writable write-set cannot be enforced when exec tools are enabled"), nil
|
||||
}
|
||||
if b.opts.MCP != nil {
|
||||
return preflightFail("writable write-set cannot be enforced when MCP tools are enabled"), nil
|
||||
}
|
||||
if len(b.opts.HostTools) > 0 {
|
||||
return preflightFail("writable write-set cannot be enforced when host tools are enabled"), nil
|
||||
}
|
||||
if b.opts.Subagent != nil {
|
||||
return preflightFail("writable write-set cannot be enforced when subagent delegation is enabled"), nil
|
||||
}
|
||||
}
|
||||
var reg *tools.Registry
|
||||
if profile == exec.ToolProfileWorkspace {
|
||||
reg = tools.WorkspaceToolsCheckpointed(workDir, filestate.New(), b.opts.Checkpoints, b.opts.CheckpointAnchor)
|
||||
tools.ApplyWriteAllowlist(reg, writable)
|
||||
skills.RegisterLoader(reg, skillStore)
|
||||
if b.opts.Search != nil {
|
||||
tools.RegisterSearch(reg, b.opts.Search)
|
||||
@@ -237,7 +256,7 @@ func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result,
|
||||
switch {
|
||||
case len(req.ToolAllowlist) > 0 || b.opts.MCP.Ready():
|
||||
if err := b.opts.MCP.RegisterInto(ctx, reg); err != nil {
|
||||
return &exec.Result{Success: false, ErrorReason: err.Error()}, nil
|
||||
return preflightFail(err.Error()), nil
|
||||
}
|
||||
default:
|
||||
b.opts.MCP.StartBackground(ctx, reg)
|
||||
@@ -246,32 +265,31 @@ func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result,
|
||||
subagent.Register(reg, b.opts.Subagent)
|
||||
for _, tool := range b.opts.HostTools {
|
||||
if reg.Has(tool.Name()) {
|
||||
return &exec.Result{Success: false, ErrorReason: "host tool name conflicts with a builtin: " + tool.Name()}, nil
|
||||
return preflightFail("host tool name conflicts with a builtin: " + tool.Name()), nil
|
||||
}
|
||||
reg.Register(tool)
|
||||
}
|
||||
if err := reg.Restrict(req.ToolAllowlist); err != nil {
|
||||
return &exec.Result{Success: false, ErrorReason: err.Error()}, nil
|
||||
return preflightFail(err.Error()), nil
|
||||
}
|
||||
}
|
||||
// Scope-keyed transcript: empty scope keeps the legacy fixed path.
|
||||
transcript := transcriptPathForScope(workDir, req.SessionScope)
|
||||
// 会话毒化轮转:上次失败属 resume-unsafe 时,把旧 transcript 归档,
|
||||
// 本次以全新会话开始(续跑毒化会话只会复现同样的失败)。
|
||||
b.rotateIfPoisoned(workDir)
|
||||
store, err := session.Open(filepath.Join(workDir, ".agentx", "transcript.jsonl"))
|
||||
b.rotateIfPoisoned(workDir, transcript)
|
||||
store, err := session.Open(transcript)
|
||||
if err != nil {
|
||||
return &exec.Result{Success: false, ErrorReason: err.Error()}, nil
|
||||
return preflightFail(err.Error()), nil
|
||||
}
|
||||
stream := b.opts.Stream
|
||||
if stream == nil {
|
||||
client := &llm.Client{BaseURL: b.opts.APIBase, APIKey: b.opts.APIKey}
|
||||
stream = client.Stream
|
||||
}
|
||||
_ = store.SaveMeta(map[string]string{
|
||||
"model": model,
|
||||
"scope": req.SessionScope,
|
||||
"exec_id": req.ExecutionID,
|
||||
"updated": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
if err := b.bindSessionScope(store, req.SessionScope, model, req.ExecutionID); err != nil {
|
||||
return preflightFail(err.Error()), nil
|
||||
}
|
||||
b.maybeCompact(ctx, store, stream, model)
|
||||
|
||||
timeout := req.Timeout
|
||||
@@ -317,36 +335,82 @@ func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result,
|
||||
onProcess := req.OnEvent
|
||||
runRes, err := b.run(runCtx, model, runBudget, reg, store, stream, profile, req, workDir, skillIndex, &logs, onProcess)
|
||||
if err != nil {
|
||||
return &exec.Result{Success: false, ErrorReason: err.Error(), Logs: logs.String()}, nil
|
||||
// Persist/session transport failure after the run started: outcome uncertain.
|
||||
return &exec.Result{
|
||||
Success: false, ErrorReason: err.Error(), Logs: logs.String(),
|
||||
Unknown: true, SideEffects: exec.SideEffectPossible,
|
||||
}, nil
|
||||
}
|
||||
_ = os.WriteFile(filepath.Join(workDir, "execution.log"), []byte(logs.String()), 0o644)
|
||||
|
||||
// CapText sink: always capture the final assistant text for contract mapping.
|
||||
captureText(ctx, runRes.Text)
|
||||
|
||||
// reasoning 档无工具:把最终文本写入仍为空的目标文件。
|
||||
// 先授权全部 target,任一拒绝则零写入(避免 ../、绝对路径、符号链接逃逸先落地)。
|
||||
if profile == exec.ToolProfileReasoning && strings.TrimSpace(runRes.Text) != "" {
|
||||
type reasoningTarget struct {
|
||||
path string
|
||||
skip bool
|
||||
}
|
||||
authorized := make([]reasoningTarget, 0, len(req.Targets))
|
||||
for _, target := range req.Targets {
|
||||
path := filepath.Join(workDir, filepath.FromSlash(target))
|
||||
if b2, err := os.ReadFile(path); err == nil && len(strings.TrimSpace(string(b2))) > 0 {
|
||||
if !pathInWriteSet(writable, target) {
|
||||
return &exec.Result{
|
||||
Success: false, ErrorReason: "target is outside the declared writable set: " + target,
|
||||
Logs: logs.String(), SideEffects: sideEffectsForTools(runRes.ToolCalls),
|
||||
}, nil
|
||||
}
|
||||
path, resolveErr := tools.Resolve(workDir, filepath.FromSlash(target))
|
||||
if resolveErr != nil {
|
||||
return &exec.Result{
|
||||
Success: false, ErrorReason: resolveErr.Error(), Logs: logs.String(),
|
||||
SideEffects: sideEffectsForTools(runRes.ToolCalls),
|
||||
}, nil
|
||||
}
|
||||
skip := false
|
||||
if b2, readErr := os.ReadFile(path); readErr == nil && len(strings.TrimSpace(string(b2))) > 0 {
|
||||
skip = true
|
||||
}
|
||||
authorized = append(authorized, reasoningTarget{path: path, skip: skip})
|
||||
}
|
||||
for _, at := range authorized {
|
||||
if at.skip {
|
||||
continue
|
||||
}
|
||||
_ = os.MkdirAll(filepath.Dir(path), 0o755)
|
||||
_ = os.WriteFile(path, []byte(runRes.Text), 0o644)
|
||||
_ = os.MkdirAll(filepath.Dir(at.path), 0o755)
|
||||
_ = os.WriteFile(at.path, []byte(runRes.Text), 0o644)
|
||||
}
|
||||
}
|
||||
|
||||
if runRes.Err != nil {
|
||||
reason := runRes.Err.Error()
|
||||
b.noteFailure(workDir, reason)
|
||||
canceled := runRes.Canceled || runRes.StopReason == llm.StopAborted
|
||||
retryable := runRes.Retryable && !canceled
|
||||
assessment := exec.AssessStepFailure(exec.StepFailureSignals{
|
||||
Err: runRes.Err,
|
||||
WorkDir: workDir,
|
||||
Result: &exec.Result{ErrorReason: reason, Retryable: runRes.Retryable, Logs: logs.String()},
|
||||
Result: &exec.Result{ErrorReason: reason, Retryable: retryable, Logs: logs.String()},
|
||||
})
|
||||
b.logger().Error("native step failure classified", runRes.Err,
|
||||
"failure_class", assessment.Class.String(),
|
||||
"work_dir", workDir)
|
||||
res := &exec.Result{Success: false, ErrorReason: reason, Logs: logs.String(), Retryable: runRes.Retryable}
|
||||
if runRes.StopReason == llm.StopAborted {
|
||||
res.Retryable = true
|
||||
"work_dir", workDir,
|
||||
"canceled", canceled)
|
||||
res := &exec.Result{
|
||||
Success: false,
|
||||
ErrorReason: reason,
|
||||
Logs: logs.String(),
|
||||
Retryable: retryable,
|
||||
Canceled: canceled,
|
||||
SideEffects: exec.SideEffectPossible,
|
||||
}
|
||||
if canceled {
|
||||
// Aborted/canceled: never retryable; side effects may already exist.
|
||||
res.Retryable = false
|
||||
res.SideEffects = exec.SideEffectPossible
|
||||
} else if runRes.ToolCalls == 0 {
|
||||
res.SideEffects = exec.SideEffectNone
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
@@ -354,7 +418,14 @@ func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result,
|
||||
|
||||
patches, matErr := exec.MaterializeTargets(workDir, req.Targets, nil)
|
||||
if matErr != nil {
|
||||
return &exec.Result{Success: false, ErrorReason: matErr.Error(), Patches: patches, Logs: logs.String()}, nil
|
||||
return &exec.Result{
|
||||
Success: false, ErrorReason: matErr.Error(), Patches: patches, Logs: logs.String(),
|
||||
SideEffects: exec.SideEffectPossible,
|
||||
}, nil
|
||||
}
|
||||
side := sideEffectsForTools(runRes.ToolCalls)
|
||||
if len(patches) > 0 {
|
||||
side = exec.SideEffectPossible
|
||||
}
|
||||
return &exec.Result{
|
||||
Success: true,
|
||||
@@ -362,6 +433,7 @@ func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result,
|
||||
Logs: logs.String(),
|
||||
PromptTokens: runRes.Usage.PromptTokens,
|
||||
CompletionTokens: runRes.Usage.CompletionTokens,
|
||||
SideEffects: side,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -407,6 +479,9 @@ func (b *Backend) run(ctx context.Context, model string, budget *runtime.Budget,
|
||||
Text: ev.Text,
|
||||
Tool: ev.Tool,
|
||||
Detail: ev.Detail,
|
||||
CallID: ev.CallID,
|
||||
Status: ev.Status,
|
||||
ErrorReason: ev.ErrorReason,
|
||||
PromptTokens: ev.PromptTokens,
|
||||
CompletionTokens: ev.CompletionTokens,
|
||||
LastPromptTokens: ev.LastPromptTokens,
|
||||
@@ -522,6 +597,19 @@ type errString string
|
||||
|
||||
func (e errString) Error() string { return string(e) }
|
||||
|
||||
// preflightFail is a definitive rejection before any tool dispatch.
|
||||
func preflightFail(reason string) *exec.Result {
|
||||
return &exec.Result{Success: false, ErrorReason: reason, SideEffects: exec.SideEffectNone}
|
||||
}
|
||||
|
||||
// sideEffectsForTools maps dispatched tool count to a SideEffects qualifier.
|
||||
func sideEffectsForTools(toolCalls int) exec.SideEffects {
|
||||
if toolCalls > 0 {
|
||||
return exec.SideEffectPossible
|
||||
}
|
||||
return exec.SideEffectNone
|
||||
}
|
||||
|
||||
// resumeUnsafeMarkers 续跑不安全的关键词(会话已被污染:上下文溢出/
|
||||
// 迭代上限/非法请求——重试必须换新会话而非续跑)。
|
||||
var resumeUnsafeMarkers = []string{
|
||||
@@ -531,7 +619,7 @@ var resumeUnsafeMarkers = []string{
|
||||
}
|
||||
|
||||
// rotateIfPoisoned 上次失败属 resume-unsafe 且存在旧会话时归档旧会话。
|
||||
func (b *Backend) rotateIfPoisoned(workDir string) {
|
||||
func (b *Backend) rotateIfPoisoned(workDir, transcript string) {
|
||||
reason := strings.ToLower(b.failureReason(workDir))
|
||||
if reason == "" {
|
||||
return
|
||||
@@ -546,7 +634,10 @@ func (b *Backend) rotateIfPoisoned(workDir string) {
|
||||
if !unsafe {
|
||||
return
|
||||
}
|
||||
path := filepath.Join(workDir, ".agentx", "transcript.jsonl")
|
||||
path := transcript
|
||||
if strings.TrimSpace(path) == "" {
|
||||
path = filepath.Join(workDir, ".agentx", "transcript.jsonl")
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
return
|
||||
}
|
||||
@@ -555,6 +646,36 @@ func (b *Backend) rotateIfPoisoned(workDir string) {
|
||||
"transcript.poisoned."+time.Now().UTC().Format("20060102T150405.000000000")+".jsonl"))
|
||||
}
|
||||
|
||||
// bindSessionScope records scope identity on the store and fail-closes when an
|
||||
// existing store's recorded identity differs or is absent for a non-empty scope
|
||||
// (do not auto-attribute old records to a new identity).
|
||||
func (b *Backend) bindSessionScope(store *session.Store, scopeID, model, execID string) error {
|
||||
meta := store.ReadMeta()
|
||||
if meta == nil {
|
||||
meta = map[string]string{}
|
||||
}
|
||||
if scopeID != "" {
|
||||
prev := strings.TrimSpace(meta["scope"])
|
||||
if prev != "" && prev != scopeID {
|
||||
return errString("session store scope identity mismatch: store has " + prev + ", request has " + scopeID)
|
||||
}
|
||||
if prev == "" {
|
||||
entries, err := store.Entries()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(entries) > 0 {
|
||||
return errString("session store lacks scope identity; refusing to adopt for scope " + scopeID)
|
||||
}
|
||||
}
|
||||
meta["scope"] = scopeID
|
||||
}
|
||||
meta["model"] = model
|
||||
meta["exec_id"] = execID
|
||||
meta["updated"] = time.Now().UTC().Format(time.RFC3339)
|
||||
return store.SaveMeta(meta)
|
||||
}
|
||||
|
||||
func (b *Backend) logger() *logsys.Logger {
|
||||
if b == nil || b.opts.Log == nil {
|
||||
return logsys.Nop()
|
||||
|
||||
@@ -111,6 +111,79 @@ func TestBackendReasoningProfileWritesText(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendReasoningProfileRejectsPathEscape(t *testing.T) {
|
||||
parent := t.TempDir()
|
||||
dir := filepath.Join(parent, "work")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outside := filepath.Join(parent, "pwned.md")
|
||||
leak := "SHOULD-NOT-ESCAPE"
|
||||
b := New(Options{
|
||||
ModelOptions: ModelOptions{
|
||||
Model: "fake",
|
||||
Stream: script(
|
||||
llm.Message{Role: llm.RoleAssistant, Content: leak, StopReason: llm.StopEnd},
|
||||
),
|
||||
},
|
||||
})
|
||||
|
||||
// Traversal: authorize-before-write must reject and leave zero target writes.
|
||||
res, err := b.Execute(context.Background(), exec.Request{
|
||||
WorkDir: dir, Prompt: "escape", ToolProfile: exec.ToolProfileReasoning,
|
||||
Targets: []string{"safe.md", "../pwned.md"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Success || !strings.Contains(res.ErrorReason, "escapes workspace") {
|
||||
t.Fatalf("want path escape failure, got %+v", res)
|
||||
}
|
||||
if _, statErr := os.Stat(outside); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("traversal must not create outside file: %v", statErr)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(dir, "safe.md")); !os.IsNotExist(statErr) {
|
||||
t.Fatal("rejected batch must perform zero target writes")
|
||||
}
|
||||
|
||||
// Absolute path outside workDir.
|
||||
res, err = b.Execute(context.Background(), exec.Request{
|
||||
WorkDir: dir, Prompt: "escape-abs", ToolProfile: exec.ToolProfileReasoning,
|
||||
Targets: []string{outside},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Success || !strings.Contains(res.ErrorReason, "escapes workspace") {
|
||||
t.Fatalf("want absolute escape failure, got %+v", res)
|
||||
}
|
||||
if _, statErr := os.Stat(outside); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("absolute escape must not create outside file: %v", statErr)
|
||||
}
|
||||
|
||||
// Symlink component leaving the workspace.
|
||||
outsideDir := filepath.Join(parent, "outside-dir")
|
||||
if err := os.MkdirAll(outsideDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outsideDir, filepath.Join(dir, "link")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res, err = b.Execute(context.Background(), exec.Request{
|
||||
WorkDir: dir, Prompt: "escape-symlink", ToolProfile: exec.ToolProfileReasoning,
|
||||
Targets: []string{"link/secret.md"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Success || !strings.Contains(res.ErrorReason, "escapes workspace") {
|
||||
t.Fatalf("want symlink escape failure, got %+v", res)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(outsideDir, "secret.md")); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("symlink escape must not write outside: %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendFailClosedWithoutConfig(t *testing.T) {
|
||||
b := New(Options{})
|
||||
res, err := b.Execute(context.Background(), exec.Request{WorkDir: t.TempDir(), Targets: []string{"x.md"}})
|
||||
|
||||
@@ -39,6 +39,7 @@ func (c contractBackend) Execute(ctx context.Context, req exec.ContractRequest)
|
||||
if profile == "" {
|
||||
profile = exec.ToolProfileReasoning
|
||||
}
|
||||
scopeID := scopeIdentity(req.SessionScope)
|
||||
old := exec.Request{
|
||||
Prompt: req.Prompt,
|
||||
Instructions: req.Instructions,
|
||||
@@ -47,6 +48,7 @@ func (c contractBackend) Execute(ctx context.Context, req exec.ContractRequest)
|
||||
ToolProfile: profile,
|
||||
ToolAllowlist: append([]string(nil), req.ToolPolicy.Allowlist...),
|
||||
WorkDir: req.Workspace.Root,
|
||||
SessionScope: scopeID,
|
||||
OnEvent: onEvent,
|
||||
}
|
||||
if b := req.ContractBudget; b.MaxPromptTokens > 0 || b.MaxCompletionTokens > 0 || !b.Deadline.IsZero() {
|
||||
@@ -56,6 +58,15 @@ func (c contractBackend) Execute(ctx context.Context, req exec.ContractRequest)
|
||||
Deadline: b.Deadline,
|
||||
}
|
||||
}
|
||||
// CapText / F15: pure-text contract tasks must not force implicit file delivery.
|
||||
explicitTargets := len(req.Targets) > 0
|
||||
if !explicitTargets && profile == exec.ToolProfileReasoning {
|
||||
ctx = withAllowEmptyTargets(ctx)
|
||||
}
|
||||
ctx = withWritable(ctx, req.Workspace.Writable)
|
||||
var finalText string
|
||||
ctx = withTextSink(ctx, &finalText)
|
||||
|
||||
res, err := c.b.Execute(ctx, old)
|
||||
if err != nil {
|
||||
// 传输/实现级错误:按契约映射为 Unknown,不经 Go error 返回业务终态。
|
||||
@@ -74,7 +85,16 @@ func (c contractBackend) Execute(ctx context.Context, req exec.ContractRequest)
|
||||
switch {
|
||||
case res.Success:
|
||||
out.Outcome = exec.OutcomeSucceeded
|
||||
out.Files = res.Patches
|
||||
if explicitTargets {
|
||||
out.Files = res.Patches
|
||||
}
|
||||
// CapText: surface the real assistant text for pure-text success.
|
||||
out.Text = finalText
|
||||
if out.Text == "" && len(res.Patches) == 1 {
|
||||
for _, body := range res.Patches {
|
||||
out.Text = string(body)
|
||||
}
|
||||
}
|
||||
case errors.Is(ctx.Err(), context.Canceled):
|
||||
out.Outcome = exec.OutcomeCanceled
|
||||
out.Err = &exec.Error{Code: exec.ErrCanceled, Reason: res.ErrorReason, SideEffects: exec.SideEffectPossible}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package native
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"agent-native/exec"
|
||||
)
|
||||
|
||||
// Contract-binding context keys: Writable / CapText / empty-target semantics
|
||||
// stay inside the adapter so exec.Request remains unchanged for the legacy facade.
|
||||
|
||||
type ctxKey int
|
||||
|
||||
const (
|
||||
keyWritable ctxKey = iota
|
||||
keyAllowEmptyTargets
|
||||
keyTextSink
|
||||
)
|
||||
|
||||
func withWritable(ctx context.Context, paths []string) context.Context {
|
||||
if len(paths) == 0 {
|
||||
return ctx
|
||||
}
|
||||
return context.WithValue(ctx, keyWritable, append([]string(nil), paths...))
|
||||
}
|
||||
|
||||
func writableFrom(ctx context.Context) []string {
|
||||
v, _ := ctx.Value(keyWritable).([]string)
|
||||
return v
|
||||
}
|
||||
|
||||
func withAllowEmptyTargets(ctx context.Context) context.Context {
|
||||
return context.WithValue(ctx, keyAllowEmptyTargets, true)
|
||||
}
|
||||
|
||||
func allowEmptyTargets(ctx context.Context) bool {
|
||||
v, _ := ctx.Value(keyAllowEmptyTargets).(bool)
|
||||
return v
|
||||
}
|
||||
|
||||
func withTextSink(ctx context.Context, sink *string) context.Context {
|
||||
if sink == nil {
|
||||
return ctx
|
||||
}
|
||||
return context.WithValue(ctx, keyTextSink, sink)
|
||||
}
|
||||
|
||||
func captureText(ctx context.Context, text string) {
|
||||
if sink, ok := ctx.Value(keyTextSink).(*string); ok && sink != nil {
|
||||
*sink = text
|
||||
}
|
||||
}
|
||||
|
||||
// scopeIdentity returns a stable non-empty identity string for a non-zero
|
||||
// SessionScope, or "" for the legacy one-shot / empty-scope path.
|
||||
func scopeIdentity(s exec.SessionScope) string {
|
||||
t := strings.TrimSpace(s.Tenant)
|
||||
p := strings.TrimSpace(s.Project)
|
||||
sess := strings.TrimSpace(s.Session)
|
||||
if t == "" && p == "" && sess == "" {
|
||||
return ""
|
||||
}
|
||||
return t + "/" + p + "/" + sess
|
||||
}
|
||||
|
||||
// transcriptPathForScope binds storage identity to SessionScope.
|
||||
// Empty scope keeps the historical fixed path under .agentx/transcript.jsonl.
|
||||
func transcriptPathForScope(workDir, scopeID string) string {
|
||||
if strings.TrimSpace(scopeID) == "" {
|
||||
return filepath.Join(workDir, ".agentx", "transcript.jsonl")
|
||||
}
|
||||
sum := sha256.Sum256([]byte(scopeID))
|
||||
return filepath.Join(workDir, ".agentx", "transcript."+hex.EncodeToString(sum[:16])+".jsonl")
|
||||
}
|
||||
|
||||
func pathInWriteSet(allow []string, rel string) bool {
|
||||
if len(allow) == 0 {
|
||||
return true
|
||||
}
|
||||
rel = filepath.ToSlash(filepath.Clean(strings.TrimSpace(rel)))
|
||||
for _, a := range allow {
|
||||
if filepath.ToSlash(filepath.Clean(strings.TrimSpace(a))) == rel {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
package native
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"agent-native/exec"
|
||||
"agent-native/runtime/llm"
|
||||
"agent-native/runtime/session"
|
||||
"agent-native/runtime/tools"
|
||||
)
|
||||
|
||||
func TestContractScopeIsolationNoSharedTranscript(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
b := New(Options{
|
||||
ModelOptions: ModelOptions{
|
||||
Model: "fake",
|
||||
Stream: script(
|
||||
llm.Message{Role: llm.RoleAssistant, Content: "from-a", StopReason: llm.StopEnd},
|
||||
),
|
||||
},
|
||||
}).Contract()
|
||||
|
||||
scopeA := exec.SessionScope{Tenant: "t1", Project: "p1", Session: "s-a"}
|
||||
scopeB := exec.SessionScope{Tenant: "t1", Project: "p1", Session: "s-b"}
|
||||
resA, err := b.Execute(context.Background(), exec.ContractRequest{
|
||||
ExecutionID: "e-a",
|
||||
Prompt: "hello-a",
|
||||
SessionScope: scopeA,
|
||||
Workspace: exec.WorkspaceHandle{Root: dir},
|
||||
})
|
||||
if err != nil || resA.Outcome != exec.OutcomeSucceeded {
|
||||
t.Fatalf("scope A: %+v %v", resA, err)
|
||||
}
|
||||
|
||||
// Fresh backend/stream for scope B so replies differ.
|
||||
b2 := New(Options{
|
||||
ModelOptions: ModelOptions{
|
||||
Model: "fake",
|
||||
Stream: script(
|
||||
llm.Message{Role: llm.RoleAssistant, Content: "from-b", StopReason: llm.StopEnd},
|
||||
),
|
||||
},
|
||||
}).Contract()
|
||||
resB, err := b2.Execute(context.Background(), exec.ContractRequest{
|
||||
ExecutionID: "e-b",
|
||||
Prompt: "hello-b",
|
||||
SessionScope: scopeB,
|
||||
Workspace: exec.WorkspaceHandle{Root: dir},
|
||||
})
|
||||
if err != nil || resB.Outcome != exec.OutcomeSucceeded {
|
||||
t.Fatalf("scope B: %+v %v", resB, err)
|
||||
}
|
||||
|
||||
pathA := transcriptPathForScope(dir, scopeIdentity(scopeA))
|
||||
pathB := transcriptPathForScope(dir, scopeIdentity(scopeB))
|
||||
if pathA == pathB {
|
||||
t.Fatal("different scopes must not share a transcript path")
|
||||
}
|
||||
if pathA == filepath.Join(dir, ".agentx", "transcript.jsonl") {
|
||||
t.Fatal("non-empty scope must not use the legacy fixed transcript path")
|
||||
}
|
||||
storeA, err := session.Open(pathA)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
storeB, err := session.Open(pathB)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
msgsA, err := storeA.Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
msgsB, err := storeB.Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
joinedA := messagesBlob(msgsA)
|
||||
joinedB := messagesBlob(msgsB)
|
||||
if strings.Contains(joinedA, "hello-b") || strings.Contains(joinedA, "from-b") {
|
||||
t.Fatalf("scope A transcript leaked B context: %s", joinedA)
|
||||
}
|
||||
if strings.Contains(joinedB, "hello-a") || strings.Contains(joinedB, "from-a") {
|
||||
t.Fatalf("scope B transcript leaked A context: %s", joinedB)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContractMatchingScopeCanResume(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
scope := exec.SessionScope{Tenant: "t", Project: "p", Session: "resume-me"}
|
||||
b1 := New(Options{
|
||||
ModelOptions: ModelOptions{
|
||||
Model: "fake",
|
||||
Stream: script(llm.Message{Role: llm.RoleAssistant, Content: "first", StopReason: llm.StopEnd}),
|
||||
},
|
||||
}).Contract()
|
||||
if res, err := b1.Execute(context.Background(), exec.ContractRequest{
|
||||
ExecutionID: "e1", Prompt: "turn-1", SessionScope: scope,
|
||||
Workspace: exec.WorkspaceHandle{Root: dir},
|
||||
}); err != nil || res.Outcome != exec.OutcomeSucceeded {
|
||||
t.Fatalf("first turn: %+v %v", res, err)
|
||||
}
|
||||
|
||||
var sawPrior bool
|
||||
b2 := New(Options{
|
||||
ModelOptions: ModelOptions{
|
||||
Model: "fake",
|
||||
Stream: func(ctx context.Context, model string, messages []llm.Message, decls []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
|
||||
for _, m := range messages {
|
||||
if m.Role == llm.RoleUser && m.Content == "turn-1" {
|
||||
sawPrior = true
|
||||
}
|
||||
if m.Role == llm.RoleAssistant && m.Content == "first" {
|
||||
sawPrior = true
|
||||
}
|
||||
}
|
||||
ch := make(chan llm.Event, 2)
|
||||
go func() {
|
||||
defer close(ch)
|
||||
ch <- llm.Event{Kind: llm.KindTextDelta, Text: "second"}
|
||||
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{Role: llm.RoleAssistant, Content: "second", StopReason: llm.StopEnd}}
|
||||
}()
|
||||
return ch
|
||||
},
|
||||
},
|
||||
}).Contract()
|
||||
if res, err := b2.Execute(context.Background(), exec.ContractRequest{
|
||||
ExecutionID: "e2", Prompt: "turn-2", SessionScope: scope,
|
||||
Workspace: exec.WorkspaceHandle{Root: dir},
|
||||
}); err != nil || res.Outcome != exec.OutcomeSucceeded {
|
||||
t.Fatalf("resume turn: %+v %v", res, err)
|
||||
}
|
||||
if !sawPrior {
|
||||
t.Fatal("matching scope must resume prior transcript context")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContractOldStoreWithoutIdentityNotAdopted(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
scope := exec.SessionScope{Tenant: "t", Project: "p", Session: "orphan"}
|
||||
path := transcriptPathForScope(dir, scopeIdentity(scope))
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Pre-seed records with no scope identity in meta.
|
||||
body := "{\"role\":\"user\",\"content\":\"old\"}\n{\"role\":\"assistant\",\"content\":\"ghost\"}\n"
|
||||
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := New(Options{
|
||||
ModelOptions: ModelOptions{
|
||||
Model: "fake",
|
||||
Stream: script(llm.Message{Role: llm.RoleAssistant, Content: "new", StopReason: llm.StopEnd}),
|
||||
},
|
||||
}).Contract()
|
||||
res, err := b.Execute(context.Background(), exec.ContractRequest{
|
||||
ExecutionID: "e-orphan", Prompt: "hi", SessionScope: scope,
|
||||
Workspace: exec.WorkspaceHandle{Root: dir},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("transport: %v", err)
|
||||
}
|
||||
if res.Outcome == exec.OutcomeSucceeded {
|
||||
t.Fatal("must not auto-adopt a store lacking scope identity")
|
||||
}
|
||||
if res.Err == nil || !strings.Contains(res.Err.Reason, "scope identity") {
|
||||
t.Fatalf("want scope-identity refusal, got %+v", res.Err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContractWritableFileToolHonorsAllowlist(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
b := New(Options{
|
||||
ModelOptions: ModelOptions{
|
||||
Model: "fake",
|
||||
Stream: script(
|
||||
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
|
||||
ID: "c1", Name: "write_file", Arguments: `{"path":"allowed.md","content":"ok"}`,
|
||||
}}},
|
||||
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
|
||||
ID: "c2", Name: "write_file", Arguments: `{"path":"denied.md","content":"no"}`,
|
||||
}}},
|
||||
llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd},
|
||||
),
|
||||
},
|
||||
}).Contract()
|
||||
res, err := b.Execute(context.Background(), exec.ContractRequest{
|
||||
ExecutionID: "e-w",
|
||||
Prompt: "write",
|
||||
Workspace: exec.WorkspaceHandle{
|
||||
Root: dir,
|
||||
Writable: []string{"allowed.md"},
|
||||
},
|
||||
ToolPolicy: exec.ToolPolicy{Profile: exec.ToolProfileWorkspace},
|
||||
Targets: []string{"allowed.md"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Outcome != exec.OutcomeSucceeded {
|
||||
// Tool denial is soft (IsError to model); run may still succeed if allowed.md exists.
|
||||
t.Logf("outcome=%s err=%+v", res.Outcome, res.Err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "allowed.md")); err != nil {
|
||||
t.Fatalf("allowed write missing: %v (outcome=%s)", err, res.Outcome)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "denied.md")); !os.IsNotExist(err) {
|
||||
t.Fatal("denied.md must not be written under Writable allowlist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContractWritableRejectsExecInPreflight(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
b := New(Options{
|
||||
ModelOptions: ModelOptions{
|
||||
Model: "fake",
|
||||
Stream: script(llm.Message{Role: llm.RoleAssistant, Content: "x", StopReason: llm.StopEnd}),
|
||||
},
|
||||
ToolOptions: ToolOptions{
|
||||
Exec: &tools.ExecConfig{Bash: "/bin/bash"},
|
||||
},
|
||||
}).Contract()
|
||||
res, err := b.Execute(context.Background(), exec.ContractRequest{
|
||||
ExecutionID: "e-exec",
|
||||
Prompt: "x",
|
||||
Workspace: exec.WorkspaceHandle{
|
||||
Root: dir,
|
||||
Writable: []string{"a.md"},
|
||||
},
|
||||
ToolPolicy: exec.ToolPolicy{Profile: exec.ToolProfileWorkspace},
|
||||
Targets: []string{"a.md"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Outcome == exec.OutcomeSucceeded {
|
||||
t.Fatal("Writable + exec tools enabled must be rejected in preflight")
|
||||
}
|
||||
if res.Err == nil || !strings.Contains(res.Err.Reason, "writable") {
|
||||
t.Fatalf("want writable preflight error, got %+v", res.Err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContractPureTextSuccessCarriesText(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
b := New(Options{
|
||||
ModelOptions: ModelOptions{
|
||||
Model: "fake",
|
||||
Stream: script(llm.Message{Role: llm.RoleAssistant, Content: "纯文本答复内容", StopReason: llm.StopEnd}),
|
||||
},
|
||||
}).Contract()
|
||||
res, err := b.Execute(context.Background(), exec.ContractRequest{
|
||||
ExecutionID: "e-text",
|
||||
Prompt: "说点什么",
|
||||
Workspace: exec.WorkspaceHandle{Root: dir},
|
||||
// empty ToolPolicy → reasoning; empty Targets → CapText, no file delivery
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Outcome != exec.OutcomeSucceeded {
|
||||
t.Fatalf("outcome=%s err=%+v", res.Outcome, res.Err)
|
||||
}
|
||||
if strings.TrimSpace(res.Text) == "" {
|
||||
t.Fatal("CapText pure-text success must carry non-empty Text")
|
||||
}
|
||||
if !strings.Contains(res.Text, "纯文本答复内容") {
|
||||
t.Fatalf("Text=%q", res.Text)
|
||||
}
|
||||
if len(res.Files) != 0 {
|
||||
t.Fatalf("pure-text must not force file delivery, Files=%v", res.Files)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "output.md")); !os.IsNotExist(err) {
|
||||
t.Fatal("pure-text must not implicitly write output.md")
|
||||
}
|
||||
}
|
||||
|
||||
func messagesBlob(msgs []llm.Message) string {
|
||||
var b strings.Builder
|
||||
for _, m := range msgs {
|
||||
b.WriteString(string(m.Role))
|
||||
b.WriteByte(':')
|
||||
b.WriteString(m.Content)
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package native
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"agent-native/exec"
|
||||
"agent-native/runtime/llm"
|
||||
"agent-native/runtime/tools"
|
||||
)
|
||||
|
||||
type fidelityEcho struct {
|
||||
fail bool
|
||||
}
|
||||
|
||||
func (f fidelityEcho) Name() string { return "echo" }
|
||||
func (f fidelityEcho) Description() string { return "echo" }
|
||||
func (f fidelityEcho) Parameters() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
|
||||
func (f fidelityEcho) Execute(context.Context, json.RawMessage) (tools.Result, error) {
|
||||
if f.fail {
|
||||
return tools.Result{Content: "native-boom", IsError: true}, nil
|
||||
}
|
||||
return tools.Result{Content: "ok"}, nil
|
||||
}
|
||||
|
||||
func TestNativeProcessEventPassesCallIDAndStatus(t *testing.T) {
|
||||
b := New(Options{
|
||||
ModelOptions: ModelOptions{
|
||||
Model: "fake",
|
||||
Stream: script(
|
||||
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{
|
||||
{ID: "n-a", Name: "echo", Arguments: `{}`},
|
||||
{ID: "n-b", Name: "echo", Arguments: `{}`},
|
||||
}},
|
||||
llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd},
|
||||
),
|
||||
},
|
||||
ToolOptions: ToolOptions{
|
||||
HostTools: []tools.Tool{fidelityEcho{}},
|
||||
},
|
||||
})
|
||||
var starts, ends []exec.ProcessEvent
|
||||
_, err := b.Execute(context.Background(), exec.Request{
|
||||
WorkDir: t.TempDir(), Prompt: "go", Targets: []string{"output.md"},
|
||||
OnEvent: func(ev exec.ProcessEvent) {
|
||||
switch ev.Type {
|
||||
case "tool_execution_start":
|
||||
starts = append(starts, ev)
|
||||
case "tool_execution_end":
|
||||
ends = append(ends, ev)
|
||||
}
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(starts) != 2 || len(ends) != 2 {
|
||||
t.Fatalf("starts=%d ends=%d", len(starts), len(ends))
|
||||
}
|
||||
ids := map[string]bool{}
|
||||
for _, ev := range starts {
|
||||
if ev.CallID == "" {
|
||||
t.Fatalf("start missing CallID: %+v", ev)
|
||||
}
|
||||
ids[ev.CallID] = true
|
||||
}
|
||||
if !ids["n-a"] || !ids["n-b"] {
|
||||
t.Fatalf("expected distinct CallIDs n-a/n-b, got %v", ids)
|
||||
}
|
||||
for _, ev := range ends {
|
||||
if ev.CallID == "" || !ids[ev.CallID] {
|
||||
t.Fatalf("end CallID unpaired: %+v", ev)
|
||||
}
|
||||
if ev.Status != "ok" {
|
||||
t.Fatalf("end Status=%q want ok: %+v", ev.Status, ev)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeProcessEventFailingToolStatusError(t *testing.T) {
|
||||
b := New(Options{
|
||||
ModelOptions: ModelOptions{
|
||||
Model: "fake",
|
||||
Stream: script(
|
||||
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
|
||||
ID: "n-fail", Name: "echo", Arguments: `{}`,
|
||||
}}},
|
||||
llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd},
|
||||
),
|
||||
},
|
||||
ToolOptions: ToolOptions{
|
||||
HostTools: []tools.Tool{fidelityEcho{fail: true}},
|
||||
},
|
||||
})
|
||||
var end exec.ProcessEvent
|
||||
_, err := b.Execute(context.Background(), exec.Request{
|
||||
WorkDir: t.TempDir(), Prompt: "go", Targets: []string{"output.md"},
|
||||
OnEvent: func(ev exec.ProcessEvent) {
|
||||
if ev.Type == "tool_execution_end" {
|
||||
end = ev
|
||||
}
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if end.CallID != "n-fail" || end.Status != "error" || end.ErrorReason != "native-boom" {
|
||||
t.Fatalf("end = %+v", end)
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ func TestR2PoisonMarkerSurvivesNewProcess(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := New(Options{})
|
||||
b.rotateIfPoisoned(dir)
|
||||
b.rotateIfPoisoned(dir, transcript)
|
||||
if _, err := os.Stat(transcript); !os.IsNotExist(err) {
|
||||
t.Fatal("poisoned transcript still at the live path")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
package native
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"agent-native/exec"
|
||||
"agent-native/runtime/llm"
|
||||
)
|
||||
|
||||
// TestExecuteCanceledIsNotRetryable: aborted/canceled runs carry Canceled
|
||||
// semantics with Retryable=false and SideEffects=possible.
|
||||
func TestExecuteCanceledIsNotRetryable(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
b := New(Options{
|
||||
ModelOptions: ModelOptions{
|
||||
Model: "fake",
|
||||
Stream: func(ctx context.Context, model string, messages []llm.Message, decls []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
|
||||
ch := make(chan llm.Event, 1)
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
ch <- llm.Event{
|
||||
Kind: llm.KindError,
|
||||
Err: ctx.Err(),
|
||||
Retryable: true, // provider may mark abort retryable; adapter must not.
|
||||
Message: llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopAborted},
|
||||
}
|
||||
close(ch)
|
||||
}()
|
||||
return ch
|
||||
},
|
||||
},
|
||||
})
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
|
||||
defer cancel()
|
||||
res, err := b.Execute(ctx, exec.Request{
|
||||
WorkDir: dir, Prompt: "cancel me", Targets: []string{"output.md"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("transport error: %v", err)
|
||||
}
|
||||
if res.Success {
|
||||
t.Fatalf("canceled run must not succeed: %+v", res)
|
||||
}
|
||||
if !res.Canceled {
|
||||
t.Fatalf("want Canceled=true, got %+v", res)
|
||||
}
|
||||
if res.Retryable {
|
||||
t.Fatalf("canceled must not be Retryable: %+v", res)
|
||||
}
|
||||
if res.EffectiveSideEffects() != exec.SideEffectPossible {
|
||||
t.Fatalf("canceled SideEffects = %q, want possible", res.EffectiveSideEffects())
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecuteToolFailureSideEffectsPossible: failure after tools may have run
|
||||
// reports SideEffects=possible.
|
||||
func TestExecuteToolFailureSideEffectsPossible(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
var call int
|
||||
b := New(Options{
|
||||
ModelOptions: ModelOptions{
|
||||
Model: "fake",
|
||||
Stream: func(ctx context.Context, model string, messages []llm.Message, decls []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
|
||||
ch := make(chan llm.Event, 2)
|
||||
go func() {
|
||||
defer close(ch)
|
||||
call++
|
||||
if call == 1 {
|
||||
msg := llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
|
||||
ID: "c1", Name: "write_file", Arguments: `{"path":"scratch.md","content":"side effect"}`,
|
||||
}}}
|
||||
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: msg}
|
||||
return
|
||||
}
|
||||
ch <- llm.Event{
|
||||
Kind: llm.KindError,
|
||||
Err: errString("provider failed after tools"),
|
||||
Message: llm.Message{Role: llm.RoleAssistant, Content: "boom", StopReason: llm.StopError},
|
||||
}
|
||||
}()
|
||||
return ch
|
||||
},
|
||||
},
|
||||
})
|
||||
res, err := b.Execute(context.Background(), exec.Request{
|
||||
WorkDir: dir, Prompt: "write then fail", Targets: []string{"output.md"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("transport error: %v", err)
|
||||
}
|
||||
if res.Success || res.Canceled {
|
||||
t.Fatalf("want definitive failure after tools, got %+v", res)
|
||||
}
|
||||
if res.SideEffects != exec.SideEffectPossible {
|
||||
t.Fatalf("SideEffects = %q, want possible", res.SideEffects)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(dir, "scratch.md")); statErr != nil {
|
||||
t.Fatalf("tool side effect missing: %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecutePreflightSideEffectsNone: validation rejection before tools
|
||||
// reports SideEffects=none.
|
||||
func TestExecutePreflightSideEffectsNone(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
opts Options
|
||||
req exec.Request
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "missing-config",
|
||||
opts: Options{},
|
||||
req: exec.Request{WorkDir: t.TempDir(), Targets: []string{"x.md"}},
|
||||
want: "requires",
|
||||
},
|
||||
{
|
||||
name: "bad-profile",
|
||||
opts: Options{ModelOptions: ModelOptions{Stream: script(), Model: "fake"}},
|
||||
req: exec.Request{WorkDir: t.TempDir(), Targets: []string{"x.md"}, ToolProfile: "bogus"},
|
||||
want: "tool profile",
|
||||
},
|
||||
{
|
||||
name: "missing-model",
|
||||
opts: Options{ModelOptions: ModelOptions{APIBase: "http://x", APIKey: "k"}},
|
||||
req: exec.Request{WorkDir: t.TempDir(), Targets: []string{"x.md"}},
|
||||
want: "model",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
res, err := New(tc.opts).Execute(context.Background(), tc.req)
|
||||
if err != nil {
|
||||
t.Fatalf("transport error: %v", err)
|
||||
}
|
||||
if res.Success || res.Canceled || res.Unknown || res.Retryable {
|
||||
t.Fatalf("preflight must be definitive non-retry failure: %+v", res)
|
||||
}
|
||||
if res.SideEffects != exec.SideEffectNone {
|
||||
t.Fatalf("SideEffects = %q, want none", res.SideEffects)
|
||||
}
|
||||
if !strings.Contains(res.ErrorReason, tc.want) {
|
||||
t.Fatalf("ErrorReason = %q, want substring %q", res.ErrorReason, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecuteSuccessSideEffectsReflectTools: success after tool dispatch
|
||||
// reports SideEffects=possible; patches also imply possible.
|
||||
func TestExecuteSuccessSideEffectsReflectTools(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
b := New(Options{
|
||||
ModelOptions: ModelOptions{
|
||||
Model: "fake",
|
||||
Stream: script(
|
||||
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
|
||||
ID: "c1", Name: "write_file", Arguments: `{"path":"output.md","content":"ok"}`,
|
||||
}}},
|
||||
llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd},
|
||||
),
|
||||
},
|
||||
})
|
||||
res, err := b.Execute(context.Background(), exec.Request{
|
||||
WorkDir: dir, Prompt: "write", Targets: []string{"output.md"},
|
||||
})
|
||||
if err != nil || !res.Success {
|
||||
t.Fatalf("want success, got err=%v res=%+v", err, res)
|
||||
}
|
||||
if res.SideEffects != exec.SideEffectPossible {
|
||||
t.Fatalf("SideEffects = %q, want possible", res.SideEffects)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package native
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"agent-native/exec"
|
||||
"agent-native/runtime/llm"
|
||||
"agent-native/runtime/subagent"
|
||||
)
|
||||
|
||||
func TestContractWritableRejectsSubagentInPreflight(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
streamCalls := 0
|
||||
inner := script(llm.Message{Role: llm.RoleAssistant, Content: "x", StopReason: llm.StopEnd})
|
||||
factoryCalls := 0
|
||||
b := New(Options{
|
||||
ModelOptions: ModelOptions{
|
||||
Model: "fake",
|
||||
Stream: func(ctx context.Context, model string, messages []llm.Message, decls []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
|
||||
streamCalls++
|
||||
return inner(ctx, model, messages, decls, opts)
|
||||
},
|
||||
},
|
||||
ToolOptions: ToolOptions{
|
||||
Subagent: func(ctx context.Context, req subagent.Request) (subagent.Result, error) {
|
||||
factoryCalls++
|
||||
return subagent.Result{Success: true}, nil
|
||||
},
|
||||
},
|
||||
}).Contract()
|
||||
res, err := b.Execute(context.Background(), exec.ContractRequest{
|
||||
ExecutionID: "e-sub",
|
||||
Prompt: "x",
|
||||
Workspace: exec.WorkspaceHandle{
|
||||
Root: dir,
|
||||
Writable: []string{"a.md"},
|
||||
},
|
||||
ToolPolicy: exec.ToolPolicy{Profile: exec.ToolProfileWorkspace},
|
||||
Targets: []string{"a.md"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Outcome == exec.OutcomeSucceeded {
|
||||
t.Fatal("Writable + subagent delegation enabled must be rejected in preflight")
|
||||
}
|
||||
const want = "writable write-set cannot be enforced when subagent delegation is enabled"
|
||||
if res.Err == nil || res.Err.Reason != want {
|
||||
t.Fatalf("want reason %q, got %+v", want, res.Err)
|
||||
}
|
||||
if streamCalls != 0 || factoryCalls != 0 {
|
||||
t.Fatalf("preflight must stop before model/factory: stream=%d factory=%d", streamCalls, factoryCalls)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "a.md")); !os.IsNotExist(err) {
|
||||
t.Fatal("deliverable a.md must not be created when preflight rejects")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, ".agentx")); !os.IsNotExist(err) {
|
||||
t.Fatal("no session state must be written when preflight rejects")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContractSubagentWithoutWritableStillDelegates(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
var delegated *subagent.Request
|
||||
stream, c := captureStream(
|
||||
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
|
||||
ID: "c1", Name: "delegate_task",
|
||||
Arguments: `{"goal":"查证数据","write_scope":"research/","done_criteria":"三来源"}`,
|
||||
}}},
|
||||
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
|
||||
ID: "c2", Name: "write_file",
|
||||
Arguments: `{"path":"output.md","content":"done"}`,
|
||||
}}},
|
||||
llm.Message{Role: llm.RoleAssistant, Content: "ok", StopReason: llm.StopEnd},
|
||||
)
|
||||
b := New(Options{
|
||||
ModelOptions: ModelOptions{Model: "fake", Stream: stream},
|
||||
ToolOptions: ToolOptions{
|
||||
Subagent: func(ctx context.Context, req subagent.Request) (subagent.Result, error) {
|
||||
delegated = &req
|
||||
return subagent.Result{Success: true, Text: "结论已查明"}, nil
|
||||
},
|
||||
},
|
||||
}).Contract()
|
||||
res, err := b.Execute(context.Background(), exec.ContractRequest{
|
||||
ExecutionID: "e-sub-ok",
|
||||
Prompt: "分工",
|
||||
Workspace: exec.WorkspaceHandle{Root: dir},
|
||||
ToolPolicy: exec.ToolPolicy{Profile: exec.ToolProfileWorkspace},
|
||||
Targets: []string{"output.md"},
|
||||
})
|
||||
if err != nil || res.Outcome != exec.OutcomeSucceeded {
|
||||
t.Fatalf("execute: err=%v res=%+v", err, res)
|
||||
}
|
||||
if delegated == nil || delegated.Goal != "查证数据" {
|
||||
t.Fatalf("factory not invoked with fields: %+v", delegated)
|
||||
}
|
||||
sawDelegate := false
|
||||
for _, names := range c.toolDecls {
|
||||
for _, n := range names {
|
||||
if n == "delegate_task" {
|
||||
sawDelegate = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !sawDelegate {
|
||||
t.Fatal("delegate_task must be declared when Subagent factory is set and Writable is empty")
|
||||
}
|
||||
}
|
||||
Vendored
+1
-1
Submodule deps/agent-intent updated: 343b1e6d9a...765a7e7177
Vendored
+1
-1
Submodule deps/development-guidelines updated: 26b562d2e3...2688fd2be6
+1
Submodule deps/development-manager added at bd3a7de57a
@@ -54,6 +54,14 @@ type ProcessEvent struct {
|
||||
Text string // delta 文本(Type 为 *_delta 时)
|
||||
Tool string // 工具名(tool_* 时)
|
||||
Detail string // 工具参数/结果摘要(tool_execution_* 时)
|
||||
// CallID 是提供方 tool-call id;在 tool_call_delta / tool_execution_start /
|
||||
// tool_execution_end 时填写。零值 = 旧行为(消费方仍可按 Tool 名启发式配对)。
|
||||
CallID string `json:"call_id,omitempty"`
|
||||
// Status 在 tool_execution_end 时填写:ok | error | denied | canceled。
|
||||
// 空串 = legacy/unknown,不得臆造。
|
||||
Status string `json:"status,omitempty"`
|
||||
// ErrorReason 在 Status 为 error/denied/canceled 时携带真实失败文本。
|
||||
ErrorReason string `json:"error_reason,omitempty"`
|
||||
// PromptTokens / CompletionTokens 在 Type=usage 时填写。
|
||||
PromptTokens int
|
||||
CompletionTokens int
|
||||
@@ -76,10 +84,34 @@ type Result struct {
|
||||
ErrorReason string `json:"error_reason"`
|
||||
// Retryable marks a provider-transient failure that may receive a
|
||||
// fresh step invocation. Permanent missing deliverables stay false.
|
||||
// Canceled and Unknown are never retryable; do not set Retryable with them.
|
||||
Retryable bool `json:"retryable,omitempty"`
|
||||
// PromptTokens and CompletionTokens are set by pi-native when the provider reports usage.
|
||||
PromptTokens int `json:"prompt_tokens,omitempty"`
|
||||
CompletionTokens int `json:"completion_tokens,omitempty"`
|
||||
|
||||
// ---- terminal fidelity (optional; zero-value = legacy behavior) ----
|
||||
|
||||
// Canceled marks an aborted/canceled run. Distinct from Retryable:
|
||||
// a canceled action must not be auto-retried (Retryable must stay false).
|
||||
Canceled bool `json:"canceled,omitempty"`
|
||||
// Unknown marks that execution reached the provider/tool stage and the
|
||||
// terminal outcome could not be determined (side effects may exist).
|
||||
// Unknown is not a retryable failure.
|
||||
Unknown bool `json:"unknown,omitempty"`
|
||||
// SideEffects qualifies external side-effect risk for retry/fallback.
|
||||
// Empty means SideEffectPossible — the safe legacy reading. Set
|
||||
// SideEffectNone only when no tool dispatch (or equivalent) is provable.
|
||||
SideEffects SideEffects `json:"side_effects,omitempty"`
|
||||
}
|
||||
|
||||
// EffectiveSideEffects returns the side-effect qualifier for retry decisions.
|
||||
// An empty SideEffects value means SideEffectPossible (backward compatible).
|
||||
func (r *Result) EffectiveSideEffects() SideEffects {
|
||||
if r == nil || r.SideEffects == "" {
|
||||
return SideEffectPossible
|
||||
}
|
||||
return r.SideEffects
|
||||
}
|
||||
|
||||
type Backend interface {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package exec
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestProcessEventLegacyOmitempty:零值新字段不出现在 JSON 中,旧消费方
|
||||
// 忽略未知字段时看到的遗留载荷与仅填 Type/Tool/Text/Detail 一致。
|
||||
func TestProcessEventLegacyOmitempty(t *testing.T) {
|
||||
legacy := ProcessEvent{Type: "tool_execution_end", Tool: "echo", Text: "ran", Detail: "{}"}
|
||||
withZeros := ProcessEvent{
|
||||
Type: "tool_execution_end", Tool: "echo", Text: "ran", Detail: "{}",
|
||||
CallID: "", Status: "", ErrorReason: "",
|
||||
}
|
||||
a, err := json.Marshal(legacy)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := json.Marshal(withZeros)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(a) != string(b) {
|
||||
t.Fatalf("zero new fields changed JSON:\n legacy=%s\n zeros=%s", a, b)
|
||||
}
|
||||
if strings.Contains(string(a), "call_id") || strings.Contains(string(a), "status") ||
|
||||
strings.Contains(string(a), "error_reason") {
|
||||
t.Fatalf("omitempty leaked zero fields: %s", a)
|
||||
}
|
||||
|
||||
populated := ProcessEvent{
|
||||
Type: "tool_execution_end", Tool: "echo", Text: "boom",
|
||||
CallID: "c1", Status: "error", ErrorReason: "boom",
|
||||
}
|
||||
raw, err := json.Marshal(populated)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(raw)
|
||||
for _, want := range []string{`"call_id":"c1"`, `"status":"error"`, `"error_reason":"boom"`} {
|
||||
if !strings.Contains(s, want) {
|
||||
t.Fatalf("missing %s in %s", want, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package exec_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"agent-native/exec"
|
||||
)
|
||||
|
||||
// TestResultZeroValueKeepsLegacyMeaning pins backward compatibility:
|
||||
// empty SideEffects reads as possible; Canceled/Unknown stay false.
|
||||
func TestResultZeroValueKeepsLegacyMeaning(t *testing.T) {
|
||||
var r exec.Result
|
||||
if r.Success || r.Retryable || r.Canceled || r.Unknown {
|
||||
t.Fatalf("zero Result must keep legacy false flags, got %+v", r)
|
||||
}
|
||||
if r.SideEffects != "" {
|
||||
t.Fatalf("zero SideEffects must be empty (legacy), got %q", r.SideEffects)
|
||||
}
|
||||
if got := r.EffectiveSideEffects(); got != exec.SideEffectPossible {
|
||||
t.Fatalf("EffectiveSideEffects() = %q, want %q", got, exec.SideEffectPossible)
|
||||
}
|
||||
r.SideEffects = exec.SideEffectNone
|
||||
if got := r.EffectiveSideEffects(); got != exec.SideEffectNone {
|
||||
t.Fatalf("explicit none: EffectiveSideEffects() = %q", got)
|
||||
}
|
||||
r.SideEffects = exec.SideEffectPossible
|
||||
if got := r.EffectiveSideEffects(); got != exec.SideEffectPossible {
|
||||
t.Fatalf("explicit possible: EffectiveSideEffects() = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,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,
|
||||
}
|
||||
}
|
||||
+45
-9
@@ -26,6 +26,9 @@ type Client struct {
|
||||
// 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
|
||||
@@ -100,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()
|
||||
@@ -111,12 +114,13 @@ func (c *Client) run(ctx context.Context, model string, messages []Message, tool
|
||||
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},
|
||||
Kind: KindError,
|
||||
HTTPStatus: resp.StatusCode,
|
||||
TerminalObservation: &TerminalObservation{ReadOutcome: "provider_error"},
|
||||
Retryable: StatusRetryable(resp.StatusCode) && !ContextOverflow(msg),
|
||||
ErrorMessage: msg,
|
||||
Err: &StreamError{Message: msg, Retryable: StatusRetryable(resp.StatusCode) && !ContextOverflow(msg)},
|
||||
Message: Message{Role: RoleAssistant, StopReason: StopError},
|
||||
}
|
||||
if d, ok := ParseRetryAfter(resp.Header.Get("Retry-After"), time.Now()); ok {
|
||||
ev.HasRetryAfter = true
|
||||
@@ -125,7 +129,39 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+252
-39
@@ -4,6 +4,7 @@ import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
@@ -39,18 +40,55 @@ 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{}
|
||||
@@ -58,51 +96,144 @@ func emitParsed(ctx context.Context, r io.Reader, out chan<- Event) {
|
||||
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}:
|
||||
}
|
||||
@@ -117,12 +248,23 @@ func emitParsed(ctx context.Context, r io.Reader, out chan<- Event) {
|
||||
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{}
|
||||
@@ -140,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,
|
||||
@@ -153,45 +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 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
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
emitAbort(out, content.String(), ctx.Err())
|
||||
case out <- Event{Kind: KindMessageEnd, Message: msg, Usage: usage, FinishReason: finished}:
|
||||
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 {
|
||||
call := calls[idx]
|
||||
var args map[string]json.RawMessage
|
||||
if call.ID == "" || call.Name == "" {
|
||||
problem = "tool call identity is incomplete"
|
||||
break
|
||||
}
|
||||
if err := json.Unmarshal([]byte(call.Arguments), &args); err != nil || args == nil {
|
||||
problem = "tool call arguments are not a JSON object"
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
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
|
||||
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) {
|
||||
@@ -220,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:") {
|
||||
@@ -241,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,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,
|
||||
@@ -288,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,
|
||||
@@ -306,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)
|
||||
}
|
||||
}
|
||||
|
||||
+59
-4
@@ -98,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 {
|
||||
@@ -113,10 +157,21 @@ type Event struct {
|
||||
// FinishReason 是供应商原始结束原因(stop、length、tool_calls 等)。
|
||||
// 空表示这条事件不携带结束原因。StopReason 的映射不受此字段影响。
|
||||
FinishReason string
|
||||
Err error
|
||||
Retryable bool
|
||||
ErrorMessage string
|
||||
// HTTPStatus 是提供方的 HTTP 状态。0 表示这条错误不是 HTTP 响应。
|
||||
// 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 int
|
||||
// RetryAfter is the provider's requested wait. HasRetryAfter distinguishes
|
||||
// a real zero from a missing or unparseable header.
|
||||
|
||||
+132
-29
@@ -3,6 +3,7 @@ package runtime
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -38,9 +39,17 @@ 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:并行执行下同回合多个调用并发进出,消费方按 ID 配对而非按顺序。
|
||||
// ID;tool_call_delta 也携带同一 ID。消费方按 ID 配对而非按顺序。
|
||||
// 不得从 ToolIndex 反推;ToolIndex 语义不变。
|
||||
CallID string
|
||||
// Status 在 tool_execution_end 时填写:ok | error | denied | canceled。
|
||||
// 空串 = legacy/unknown,不得臆造。
|
||||
Status string
|
||||
// ErrorReason 在 Status 为 error/denied/canceled 时携带真实失败文本。
|
||||
ErrorReason string
|
||||
// Detail carries tool arguments/results or reasoning deltas for
|
||||
// process-visualization consumers; log writers may ignore it.
|
||||
Detail string
|
||||
@@ -69,8 +78,11 @@ type Config struct {
|
||||
Trace func(name string, attrs map[string]string) func()
|
||||
MaxTurns int
|
||||
ToolExecution string
|
||||
GetSteering func() []llm.Message
|
||||
GetFollowUp func() []llm.Message
|
||||
// SerialTools 是并行执行下的顺序敏感工具名集合:这些调用按模型给出的
|
||||
// 原顺序在调用方 goroutine 逐个执行(互斥且保序),其余调用并发执行。
|
||||
SerialTools map[string]bool
|
||||
GetSteering func() []llm.Message
|
||||
GetFollowUp func() []llm.Message
|
||||
// OnEvent 观察循环事件。同一次 Run 内不会并发调用
|
||||
// (与 BeforeTool、AfterTool 共用一把锁串行)。
|
||||
OnEvent func(Event)
|
||||
@@ -193,6 +205,13 @@ type Result struct {
|
||||
Notice string
|
||||
Err error
|
||||
Retryable bool
|
||||
// Canceled is true when the run stopped due to abort/cancel (StopAborted
|
||||
// or context cancellation). Canceled runs are never retryable.
|
||||
Canceled bool
|
||||
// ToolCalls is the number of tool invocations dispatched during the run.
|
||||
// Adapters use it to set SideEffects (0 before any dispatch => none where
|
||||
// the failure is otherwise definitive; otherwise possible).
|
||||
ToolCalls int
|
||||
}
|
||||
|
||||
// Run executes the turn loop until the model stops, follow-ups drain, or the turn cap is hit.
|
||||
@@ -224,9 +243,9 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) {
|
||||
}
|
||||
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,
|
||||
"gen_ai.system": providerName(cfg),
|
||||
"gen_ai.agent.name": agentName(cfg),
|
||||
"gen_ai.request.model": cfg.Model,
|
||||
}, true)
|
||||
defer endRun()
|
||||
var messages []llm.Message
|
||||
@@ -327,19 +346,28 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) {
|
||||
for turn := 0; turn < cfg.MaxTurns; turn++ {
|
||||
if ctx.Err() != nil {
|
||||
cfg.logger().Error("runtime turn aborted", ctx.Err())
|
||||
return Result{Text: last.Content, Messages: messages, StopReason: llm.StopAborted, Err: ctx.Err()}, nil
|
||||
return Result{
|
||||
Text: last.Content, Messages: messages, StopReason: llm.StopAborted,
|
||||
Err: ctx.Err(), Canceled: true, ToolCalls: toolCalls,
|
||||
}, nil
|
||||
}
|
||||
if reason := budgetExceeded(cfg.Budget, usage, toolCalls, time.Now()); reason != "" {
|
||||
return budgetStop(cfg, last, messages, usage, reason), nil
|
||||
return budgetStop(cfg, last, messages, usage, toolCalls, reason), nil
|
||||
}
|
||||
emit(cfg, Event{Type: "turn_start"})
|
||||
emit(cfg, Event{Type: "message_start"})
|
||||
request := prepareRequest(cfg, messages, turn, &repeats, &lengthNudge)
|
||||
end, errEv := consume(ctx, cfg, request)
|
||||
if errEv != nil {
|
||||
cfg.logger().Error("runtime turn failed", errEv, "retryable", end.Retryable)
|
||||
canceled := end.Message.StopReason == llm.StopAborted ||
|
||||
errors.Is(errEv, context.Canceled) || errors.Is(ctx.Err(), context.Canceled)
|
||||
retryable := end.Retryable && !canceled
|
||||
cfg.logger().Error("runtime turn failed", errEv, "retryable", retryable, "canceled", canceled)
|
||||
emitUsage(cfg, usage)
|
||||
return Result{Text: end.Message.Content, Messages: messages, StopReason: end.Message.StopReason, Err: errEv, Retryable: end.Retryable}, nil
|
||||
return Result{
|
||||
Text: end.Message.Content, Messages: messages, StopReason: end.Message.StopReason,
|
||||
Err: errEv, Retryable: retryable, Canceled: canceled, ToolCalls: toolCalls,
|
||||
}, nil
|
||||
}
|
||||
last = end.Message
|
||||
usage.PromptTokens += end.Usage.PromptTokens
|
||||
@@ -351,6 +379,12 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) {
|
||||
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, ToolCalls: toolCalls}, nil
|
||||
}
|
||||
if err := persist(cfg, last); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
@@ -364,7 +398,7 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) {
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
return budgetStop(cfg, last, messages, usage, reason), nil
|
||||
return budgetStop(cfg, last, messages, usage, toolCalls, reason), nil
|
||||
}
|
||||
if cfg.Budget != nil && cfg.Budget.MaxToolCalls > 0 && toolCalls+len(last.ToolCalls) > cfg.Budget.MaxToolCalls {
|
||||
reason := fmt.Sprintf("budget_exceeded: tool calls %d+%d > max %d", toolCalls, len(last.ToolCalls), cfg.Budget.MaxToolCalls)
|
||||
@@ -373,7 +407,7 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) {
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
return budgetStop(cfg, last, messages, usage, reason), nil
|
||||
return budgetStop(cfg, last, messages, usage, toolCalls, reason), nil
|
||||
}
|
||||
toolCalls += len(last.ToolCalls)
|
||||
if cfg.RepeatGuard {
|
||||
@@ -407,7 +441,8 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) {
|
||||
if lengthCap > 0 && lengthContinues >= lengthCap {
|
||||
errCap := errString("length continuation exceeded")
|
||||
cfg.logger().Error("runtime length continuation exceeded", errCap)
|
||||
return Result{Text: last.Content, Messages: messages, StopReason: llm.StopError, Usage: usage, Err: errCap}, nil
|
||||
return Result{Text: last.Content, Messages: messages, StopReason: llm.StopError,
|
||||
Usage: usage, Err: errCap, ToolCalls: toolCalls}, nil
|
||||
}
|
||||
lengthContinues++
|
||||
lengthNudge = true
|
||||
@@ -443,9 +478,9 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) {
|
||||
}
|
||||
continue
|
||||
}
|
||||
return settle(cfg, last, messages, usage, decision.Notice), nil
|
||||
return settle(cfg, last, messages, usage, toolCalls, decision.Notice), nil
|
||||
}
|
||||
return settle(cfg, last, messages, usage, ""), nil
|
||||
return settle(cfg, last, messages, usage, toolCalls, ""), nil
|
||||
}
|
||||
errCap := errString("max turns exceeded")
|
||||
cfg.logger().Error("runtime turn cap hit", errCap)
|
||||
@@ -455,6 +490,7 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) {
|
||||
StopReason: llm.StopError,
|
||||
Usage: usage,
|
||||
Err: errCap,
|
||||
ToolCalls: toolCalls,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -526,7 +562,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:
|
||||
@@ -567,6 +604,9 @@ func appendSkippedToolResults(cfg Config, calls []llm.ToolCall, messages []llm.M
|
||||
}
|
||||
|
||||
// serializeHostCallbacks 让同一次 Run 的宿主回调不会并发进入。
|
||||
// 并行工具执行下各 goroutine 各自构造 Event 值(无跨调用共享缓冲);
|
||||
// 此处再串行化 OnEvent,避免消费方在回调内聚合时字段交错。回调须保持
|
||||
// 同步、非阻塞(与 Config.OnEvent 文档一致)。
|
||||
func serializeHostCallbacks(cfg *Config) {
|
||||
var mu sync.Mutex
|
||||
if cfg.OnEvent != nil {
|
||||
@@ -597,12 +637,17 @@ func serializeHostCallbacks(cfg *Config) {
|
||||
|
||||
func executeTools(ctx context.Context, cfg Config, assistant llm.Message) []llm.Message {
|
||||
if cfg.ToolExecution == ExecParallel && len(assistant.ToolCalls) > 1 {
|
||||
return executeParallel(ctx, cfg, assistant)
|
||||
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 {
|
||||
out = append(out, cancelledToolMessage(call, err))
|
||||
msg := cancelledToolMessage(call, err)
|
||||
emitCancelledTool(cfg, call, msg)
|
||||
out = append(out, msg)
|
||||
continue
|
||||
}
|
||||
out = append(out, runOne(ctx, cfg, call))
|
||||
@@ -610,6 +655,38 @@ 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 {
|
||||
call := assistant.ToolCalls[i]
|
||||
msg := cancelledToolMessage(call, err)
|
||||
emitCancelledTool(cfg, call, msg)
|
||||
out[i] = msg
|
||||
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
|
||||
@@ -634,6 +711,22 @@ func cancelledToolMessage(call llm.ToolCall, err error) llm.Message {
|
||||
}
|
||||
}
|
||||
|
||||
// emitCancelledTool 发出未执行/中止的 start+end;Status=canceled,不得报 ok。
|
||||
func emitCancelledTool(cfg Config, call llm.ToolCall, msg llm.Message) {
|
||||
emit(cfg, Event{Type: "tool_execution_start", Tool: call.Name, CallID: call.ID, Detail: call.Arguments})
|
||||
emitToolEnd(cfg, call, msg.Content, "canceled")
|
||||
}
|
||||
|
||||
// emitToolEnd 投影 tool_execution_end:CallID 取自 call,Status/ErrorReason
|
||||
// 仅在已知时填写(空 Status = legacy/unknown)。
|
||||
func emitToolEnd(cfg Config, call llm.ToolCall, text, status string) {
|
||||
ev := Event{Type: "tool_execution_end", Tool: call.Name, CallID: call.ID, Text: text, Status: status}
|
||||
if status == "error" || status == "denied" || status == "canceled" {
|
||||
ev.ErrorReason = text
|
||||
}
|
||||
emit(cfg, ev)
|
||||
}
|
||||
|
||||
func rejectIncompleteToolCall(call llm.ToolCall) string {
|
||||
if strings.TrimSpace(call.Name) == "" {
|
||||
return "工具调用未执行:名称不完整"
|
||||
@@ -727,12 +820,14 @@ func ToolCallFromContext(ctx context.Context) (llm.ToolCall, bool) {
|
||||
|
||||
func runOne(ctx context.Context, cfg Config, call llm.ToolCall) llm.Message {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return cancelledToolMessage(call, err)
|
||||
msg := cancelledToolMessage(call, err)
|
||||
emitCancelledTool(cfg, call, msg)
|
||||
return msg
|
||||
}
|
||||
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, CallID: call.ID, Detail: call.Arguments})
|
||||
emit(cfg, Event{Type: "tool_execution_end", Tool: call.Name, CallID: call.ID, Text: why})
|
||||
emitToolEnd(cfg, call, why, "error")
|
||||
if cfg.AfterTool != nil {
|
||||
cfg.AfterTool(call, msg)
|
||||
}
|
||||
@@ -740,7 +835,7 @@ func runOne(ctx context.Context, cfg Config, call llm.ToolCall) llm.Message {
|
||||
}
|
||||
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, CallID: call.ID, Text: msg.Content})
|
||||
emitToolEnd(cfg, call, msg.Content, "denied")
|
||||
noteToolHook(cfg, call, msg)
|
||||
if cfg.AfterTool != nil {
|
||||
cfg.AfterTool(call, msg)
|
||||
@@ -752,7 +847,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, CallID: call.ID, Text: msg.Content})
|
||||
emitToolEnd(cfg, call, msg.Content, "error")
|
||||
noteToolHook(cfg, call, msg)
|
||||
if cfg.AfterTool != nil {
|
||||
cfg.AfterTool(call, msg)
|
||||
@@ -763,7 +858,7 @@ 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, CallID: call.ID, Text: msg.Content})
|
||||
emitToolEnd(cfg, call, msg.Content, "error")
|
||||
return msg
|
||||
}
|
||||
endSpan := startSpan(cfg, "execute_tool "+call.Name, map[string]string{
|
||||
@@ -789,7 +884,15 @@ 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, CallID: call.ID, Text: res.Content})
|
||||
status := "ok"
|
||||
switch {
|
||||
case ctx.Err() != nil:
|
||||
// 取消优先于工具自报的 ok/error:过程事件不得把中止报成 ok。
|
||||
status = "canceled"
|
||||
case res.IsError:
|
||||
status = "error"
|
||||
}
|
||||
emitToolEnd(cfg, call, res.Content, status)
|
||||
noteToolHook(cfg, call, msg)
|
||||
if cfg.AfterTool != nil {
|
||||
cfg.AfterTool(call, msg)
|
||||
@@ -797,11 +900,11 @@ func runOne(ctx context.Context, cfg Config, call llm.ToolCall) llm.Message {
|
||||
return msg
|
||||
}
|
||||
|
||||
func settle(cfg Config, last llm.Message, messages []llm.Message, usage llm.Usage, notice string) Result {
|
||||
func settle(cfg Config, last llm.Message, messages []llm.Message, usage llm.Usage, toolCalls int, notice string) Result {
|
||||
emit(cfg, Event{Type: "agent_end", Message: last})
|
||||
emit(cfg, Event{Type: "agent_settled", Message: last})
|
||||
emitUsage(cfg, usage)
|
||||
return Result{Text: last.Content, Messages: messages, StopReason: last.StopReason, Usage: usage, Notice: notice}
|
||||
return Result{Text: last.Content, Messages: messages, StopReason: last.StopReason, Usage: usage, Notice: notice, ToolCalls: toolCalls}
|
||||
}
|
||||
|
||||
func prepareRequest(cfg Config, messages []llm.Message, turn int, repeats *repeatState, lengthNudge *bool) []llm.Message {
|
||||
@@ -1121,7 +1224,7 @@ func drain(fn func() []llm.Message) []llm.Message {
|
||||
|
||||
func emitUsage(cfg Config, usage llm.Usage) {
|
||||
emit(cfg, Event{
|
||||
Type: "usage",
|
||||
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,
|
||||
@@ -1156,12 +1259,12 @@ 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 {
|
||||
func budgetStop(cfg Config, last llm.Message, messages []llm.Message, usage llm.Usage, toolCalls int, 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}
|
||||
return Result{Text: last.Content, Messages: messages, StopReason: llm.StopError, Usage: usage, Err: err, ToolCalls: toolCalls}
|
||||
}
|
||||
|
||||
type errString string
|
||||
|
||||
@@ -45,5 +45,8 @@ func TestLoopToolEventsCarryCallID(t *testing.T) {
|
||||
if ends[0].CallID != "call-7" || ends[0].Tool != "echo" {
|
||||
t.Fatalf("end = %+v", ends[0])
|
||||
}
|
||||
if ends[0].Status != "ok" || ends[0].ErrorReason != "" {
|
||||
t.Fatalf("successful end must be ok with empty ErrorReason: %+v", ends[0])
|
||||
}
|
||||
_ = res
|
||||
}
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"agent-native/runtime/hooks"
|
||||
"agent-native/runtime/llm"
|
||||
"agent-native/runtime/tools"
|
||||
)
|
||||
|
||||
type failTool struct{ reason string }
|
||||
|
||||
func (f failTool) Name() string { return "echo" }
|
||||
func (f failTool) Description() string { return "echo that fails" }
|
||||
func (f failTool) Parameters() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
|
||||
func (f failTool) Execute(context.Context, json.RawMessage) (tools.Result, error) {
|
||||
return tools.Result{Content: f.reason, IsError: true}, nil
|
||||
}
|
||||
|
||||
// TestLoopSameNameToolsCarryDistinctCallIDs 同名工具连续/并行调用时,
|
||||
// start/end 的 CallID 必须互异且可按 ID 配对(不得按 Tool 名合并)。
|
||||
func TestLoopSameNameToolsCarryDistinctCallIDs(t *testing.T) {
|
||||
for _, mode := range []string{ExecSequential, ExecParallel} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
hits := 0
|
||||
reg := tools.NewRegistry(t.TempDir())
|
||||
reg.Register(hitTool{hits: &hits})
|
||||
var starts, ends []Event
|
||||
var mu sync.Mutex
|
||||
_, err := Run(context.Background(), Config{
|
||||
Model: "fake",
|
||||
Tools: reg,
|
||||
ToolExecution: mode,
|
||||
Stream: script(
|
||||
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{
|
||||
{ID: "call-a", Name: "echo", Arguments: `{}`},
|
||||
{ID: "call-b", Name: "echo", Arguments: `{}`},
|
||||
}},
|
||||
llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd},
|
||||
),
|
||||
OnEvent: func(ev Event) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
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 != 2 || len(starts) != 2 || len(ends) != 2 {
|
||||
t.Fatalf("hits=%d starts=%d ends=%d", hits, len(starts), len(ends))
|
||||
}
|
||||
byStart := map[string]Event{}
|
||||
for _, ev := range starts {
|
||||
if ev.CallID == "" || ev.Tool != "echo" {
|
||||
t.Fatalf("start missing identity: %+v", ev)
|
||||
}
|
||||
if _, dup := byStart[ev.CallID]; dup {
|
||||
t.Fatalf("duplicate start CallID %q", ev.CallID)
|
||||
}
|
||||
byStart[ev.CallID] = ev
|
||||
}
|
||||
for _, ev := range ends {
|
||||
if ev.Status != "ok" {
|
||||
t.Fatalf("end status = %q, want ok: %+v", ev.Status, ev)
|
||||
}
|
||||
if _, ok := byStart[ev.CallID]; !ok {
|
||||
t.Fatalf("end CallID %q has no matching start", ev.CallID)
|
||||
}
|
||||
delete(byStart, ev.CallID)
|
||||
}
|
||||
if len(byStart) != 0 {
|
||||
t.Fatalf("unpaired starts: %+v", byStart)
|
||||
}
|
||||
if _, ok := map[string]Event{ends[0].CallID: {}, ends[1].CallID: {}}["call-a"]; !ok {
|
||||
t.Fatalf("missing call-a in ends %+v", ends)
|
||||
}
|
||||
if ends[0].CallID == ends[1].CallID {
|
||||
t.Fatalf("CallIDs must differ, both %q", ends[0].CallID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoopFailingToolEndStatusError(t *testing.T) {
|
||||
reg := tools.NewRegistry(t.TempDir())
|
||||
reg.Register(failTool{reason: "boom-detail"})
|
||||
var end Event
|
||||
_, err := Run(context.Background(), Config{
|
||||
Model: "fake",
|
||||
Tools: reg,
|
||||
Stream: script(
|
||||
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
|
||||
ID: "c-fail", Name: "echo", Arguments: `{}`,
|
||||
}}},
|
||||
llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd},
|
||||
),
|
||||
OnEvent: func(ev Event) {
|
||||
if ev.Type == "tool_execution_end" {
|
||||
end = ev
|
||||
}
|
||||
},
|
||||
}, llm.Message{Role: llm.RoleUser, Content: "go"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if end.CallID != "c-fail" || end.Status != "error" || end.ErrorReason != "boom-detail" {
|
||||
t.Fatalf("end = %+v", end)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoopDeniedToolEndStatusDenied(t *testing.T) {
|
||||
hits := 0
|
||||
reg := tools.NewRegistry(t.TempDir())
|
||||
reg.Register(hitTool{hits: &hits})
|
||||
bus := hooks.New()
|
||||
bus.On(func(c hooks.Call) hooks.Outcome {
|
||||
if c.Event == hooks.PreToolUse {
|
||||
return hooks.Outcome{Decision: hooks.Deny, Reason: "策略拒绝"}
|
||||
}
|
||||
return hooks.Outcome{}
|
||||
})
|
||||
var end Event
|
||||
_, err := Run(context.Background(), Config{
|
||||
Model: "fake",
|
||||
Tools: reg,
|
||||
Hooks: bus,
|
||||
Stream: script(
|
||||
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
|
||||
ID: "c-deny", Name: "echo", Arguments: `{}`,
|
||||
}}},
|
||||
llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd},
|
||||
),
|
||||
OnEvent: func(ev Event) {
|
||||
if ev.Type == "tool_execution_end" {
|
||||
end = ev
|
||||
}
|
||||
},
|
||||
}, llm.Message{Role: llm.RoleUser, Content: "go"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if hits != 0 {
|
||||
t.Fatalf("tool ran %d times", hits)
|
||||
}
|
||||
if end.CallID != "c-deny" || end.Status != "denied" || end.ErrorReason != "策略拒绝" {
|
||||
t.Fatalf("end = %+v", end)
|
||||
}
|
||||
if end.Status == "ok" {
|
||||
t.Fatal("denied must not be reported ok")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoopCanceledToolEndStatusCanceled(t *testing.T) {
|
||||
bt := &blockingTool{canceled: make(chan struct{})}
|
||||
reg := tools.NewRegistry(t.TempDir())
|
||||
reg.Register(bt)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
firstCall := true
|
||||
stream := func(ctx context.Context, model string, messages []llm.Message, decls []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
|
||||
ch := make(chan llm.Event, 4)
|
||||
go func() {
|
||||
defer close(ch)
|
||||
if firstCall {
|
||||
firstCall = false
|
||||
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{
|
||||
Role: llm.RoleAssistant, StopReason: llm.StopToolCalls,
|
||||
ToolCalls: []llm.ToolCall{{ID: "c-cancel", Name: "block_forever", Arguments: `{}`}},
|
||||
}}
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
ch <- llm.Event{Kind: llm.KindError, Err: ctx.Err(), Message: llm.Message{StopReason: llm.StopAborted}}
|
||||
case <-time.After(3 * time.Second):
|
||||
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd}}
|
||||
}
|
||||
}()
|
||||
return ch
|
||||
}
|
||||
var ends []Event
|
||||
var mu sync.Mutex
|
||||
go func() {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
cancel()
|
||||
}()
|
||||
_, err := Run(ctx, Config{
|
||||
Model: "fake",
|
||||
Tools: reg,
|
||||
Stream: stream,
|
||||
OnEvent: func(ev Event) {
|
||||
if ev.Type == "tool_execution_end" {
|
||||
mu.Lock()
|
||||
ends = append(ends, ev)
|
||||
mu.Unlock()
|
||||
}
|
||||
},
|
||||
}, llm.Message{Role: llm.RoleUser, Content: "block"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(ends) == 0 {
|
||||
t.Fatal("expected tool_execution_end")
|
||||
}
|
||||
end := ends[0]
|
||||
if end.CallID != "c-cancel" {
|
||||
t.Fatalf("CallID = %q", end.CallID)
|
||||
}
|
||||
if end.Status != "canceled" {
|
||||
t.Fatalf("Status = %q, want canceled (not ok); end=%+v", end.Status, end)
|
||||
}
|
||||
if end.ErrorReason == "" {
|
||||
t.Fatalf("ErrorReason empty on canceled: %+v", end)
|
||||
}
|
||||
}
|
||||
|
||||
type cancelBatchTool struct {
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
func (c cancelBatchTool) Name() string { return "cancel_batch" }
|
||||
func (c cancelBatchTool) Description() string { return "cancels ctx then returns" }
|
||||
func (c cancelBatchTool) Parameters() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
|
||||
func (c cancelBatchTool) Execute(ctx context.Context, args json.RawMessage) (tools.Result, error) {
|
||||
c.cancel()
|
||||
return tools.Result{Content: "cancelled-siblings"}, nil
|
||||
}
|
||||
|
||||
// TestLoopSkippedCancelEmitsCanceledNotOK:串行批内前一个调用取消 ctx 后,
|
||||
// 后续未执行的调用必须发 Status=canceled,不得报 ok。
|
||||
func TestLoopSkippedCancelEmitsCanceledNotOK(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
reg := tools.NewRegistry(t.TempDir())
|
||||
reg.Register(cancelBatchTool{cancel: cancel})
|
||||
hits := 0
|
||||
reg.Register(hitTool{hits: &hits})
|
||||
var ends []Event
|
||||
_, err := Run(ctx, Config{
|
||||
Model: "fake",
|
||||
Tools: reg,
|
||||
ToolExecution: ExecSequential,
|
||||
Stream: script(
|
||||
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{
|
||||
{ID: "c-first", Name: "cancel_batch", Arguments: `{}`},
|
||||
{ID: "c-skip", Name: "echo", Arguments: `{}`},
|
||||
}},
|
||||
llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd},
|
||||
),
|
||||
OnEvent: func(ev Event) {
|
||||
if ev.Type == "tool_execution_end" {
|
||||
ends = append(ends, ev)
|
||||
}
|
||||
},
|
||||
}, llm.Message{Role: llm.RoleUser, Content: "go"})
|
||||
_ = err
|
||||
if hits != 0 {
|
||||
t.Fatalf("echo should not run after cancel, hits=%d", hits)
|
||||
}
|
||||
var skip *Event
|
||||
for i := range ends {
|
||||
if ends[i].CallID == "c-skip" {
|
||||
skip = &ends[i]
|
||||
}
|
||||
}
|
||||
if skip == nil {
|
||||
t.Fatalf("missing end for skipped call: %+v", ends)
|
||||
}
|
||||
if skip.Status != "canceled" {
|
||||
t.Fatalf("skipped end Status=%q want canceled: %+v", skip.Status, skip)
|
||||
}
|
||||
if skip.ErrorReason == "" || strings.Contains(skip.Status, "ok") {
|
||||
t.Fatalf("skipped end malformed: %+v", skip)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -153,6 +153,11 @@ func (s *Store) Name() string {
|
||||
}
|
||||
|
||||
func (s *Store) appendEntryLocked(e Entry) (Entry, error) {
|
||||
// Exclusive-writer: repair a torn/missing-newline tail before append so the
|
||||
// next load does not see mid-file JSON corruption from concatenated bytes.
|
||||
if err := s.repairTailLocked(); err != nil {
|
||||
return Entry{}, err
|
||||
}
|
||||
entries, err := s.readEntriesLocked()
|
||||
if err != nil {
|
||||
return Entry{}, err
|
||||
@@ -191,6 +196,45 @@ func (s *Store) appendEntryLocked(e Entry) (Entry, error) {
|
||||
return e, nil
|
||||
}
|
||||
|
||||
// repairTailLocked fixes only the final line under exclusive-writer semantics:
|
||||
// truncate an invalid torn fragment, or append a missing newline after a valid
|
||||
// final record. Mid-file corruption is left untouched so the next read still
|
||||
// fails closed.
|
||||
func (s *Store) repairTailLocked() error {
|
||||
raw, err := os.ReadFile(s.path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if len(raw) == 0 || raw[len(raw)-1] == '\n' {
|
||||
return nil
|
||||
}
|
||||
text := string(raw)
|
||||
idx := strings.LastIndexByte(text, '\n')
|
||||
var prefix, fragment string
|
||||
if idx >= 0 {
|
||||
prefix = text[:idx+1]
|
||||
fragment = text[idx+1:]
|
||||
} else {
|
||||
fragment = text
|
||||
}
|
||||
if fragment == "" {
|
||||
return nil
|
||||
}
|
||||
var fixed string
|
||||
if json.Valid([]byte(fragment)) {
|
||||
fixed = prefix + fragment + "\n"
|
||||
} else {
|
||||
fixed = prefix
|
||||
}
|
||||
if fixed == text {
|
||||
return nil
|
||||
}
|
||||
return os.WriteFile(s.path, []byte(fixed), 0o644)
|
||||
}
|
||||
|
||||
func (s *Store) readEntriesLocked() ([]Entry, error) {
|
||||
raw, err := os.ReadFile(s.path)
|
||||
if err != nil {
|
||||
|
||||
@@ -81,6 +81,111 @@ func TestLegacyTranscriptStillLoads(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepairTornTailBeforeAppend(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "torn.jsonl")
|
||||
// Valid record + torn final fragment (no closing brace / newline).
|
||||
body := "{\"role\":\"user\",\"content\":\"ok\"}\n{\"role\":\"assistant\",\"content\":\"tor"
|
||||
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Append(llm.Message{Role: llm.RoleAssistant, Content: "repaired"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
msgs, err := s.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("load after repair: %v", err)
|
||||
}
|
||||
if len(msgs) != 2 || msgs[0].Content != "ok" || msgs[1].Content != "repaired" {
|
||||
t.Fatalf("msgs after torn repair: %+v", msgs)
|
||||
}
|
||||
raw, _ := os.ReadFile(path)
|
||||
if !strings.HasSuffix(string(raw), "\n") {
|
||||
t.Fatal("file must end with newline after repair-append")
|
||||
}
|
||||
if strings.Contains(string(raw), "tor\n{") || strings.Contains(string(raw), "tor{") {
|
||||
t.Fatalf("torn fragment was concatenated: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepairMissingNewlineBeforeAppend(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "nonewline.jsonl")
|
||||
// Complete JSON record without trailing newline.
|
||||
body := "{\"role\":\"user\",\"content\":\"hi\"}"
|
||||
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Append(llm.Message{Role: llm.RoleAssistant, Content: "next"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
msgs, err := s.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("load after newline repair: %v", err)
|
||||
}
|
||||
if len(msgs) != 2 || msgs[0].Content != "hi" || msgs[1].Content != "next" {
|
||||
t.Fatalf("msgs: %+v", msgs)
|
||||
}
|
||||
raw, _ := os.ReadFile(path)
|
||||
// Two separate JSON lines — not one concatenated object.
|
||||
lines := strings.Split(strings.TrimSuffix(string(raw), "\n"), "\n")
|
||||
if len(lines) != 2 {
|
||||
t.Fatalf("want 2 lines, got %d in %q", len(lines), raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMidFileCorruptionStillErrors(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "mid.jsonl")
|
||||
body := "{\"role\":\"user\",\"content\":\"a\"}\n{not-json}\n{\"role\":\"assistant\",\"content\":\"b\"}\n"
|
||||
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.Load(); err == nil {
|
||||
t.Fatal("mid-file corruption must be a hard error on load")
|
||||
}
|
||||
if err := s.Append(llm.Message{Role: llm.RoleUser, Content: "x"}); err == nil {
|
||||
t.Fatal("mid-file corruption must be a hard error on append")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepairThenAppendThenLoad(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "roundtrip.jsonl")
|
||||
if err := os.WriteFile(path, []byte("{\"role\":\"user\",\"content\":\"one\"}\n{\"role\":\"as"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Append(llm.Message{Role: llm.RoleAssistant, Content: "two"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Append(llm.Message{Role: llm.RoleUser, Content: "three"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
msgs, err := s.Load()
|
||||
if err != nil || len(msgs) != 3 {
|
||||
t.Fatalf("roundtrip load: %+v %v", msgs, err)
|
||||
}
|
||||
if msgs[0].Content != "one" || msgs[1].Content != "two" || msgs[2].Content != "three" {
|
||||
t.Fatalf("contents: %+v", msgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogContinueResumeAndHTML(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s, err := Open(filepath.Join(dir, "sess.jsonl"))
|
||||
|
||||
@@ -78,9 +78,10 @@ func (t *readFile) Execute(_ context.Context, args json.RawMessage) (Result, err
|
||||
}
|
||||
|
||||
type writeFile struct {
|
||||
root string
|
||||
files *filestate.Tracker
|
||||
mark func(rel string, before []byte, existed bool, after []byte)
|
||||
root string
|
||||
files *filestate.Tracker
|
||||
mark func(rel string, before []byte, existed bool, after []byte)
|
||||
writable []string // empty = unrestricted (product default)
|
||||
}
|
||||
|
||||
func (t *writeFile) Name() string { return "write_file" }
|
||||
@@ -102,6 +103,9 @@ func (t *writeFile) Execute(_ context.Context, args json.RawMessage) (Result, er
|
||||
if err != nil {
|
||||
return Result{Content: err.Error(), IsError: true}, nil
|
||||
}
|
||||
if msg := rejectOutsideWriteSet(t.writable, obj.Path); msg != "" {
|
||||
return Result{Content: msg, IsError: true}, nil
|
||||
}
|
||||
if msg := rejectStaleWrite(t.files, path); msg != "" {
|
||||
return Result{Content: msg, IsError: true}, nil
|
||||
}
|
||||
@@ -119,9 +123,10 @@ func (t *writeFile) Execute(_ context.Context, args json.RawMessage) (Result, er
|
||||
}
|
||||
|
||||
type editFile struct {
|
||||
root string
|
||||
files *filestate.Tracker
|
||||
mark func(rel string, before []byte, existed bool, after []byte)
|
||||
root string
|
||||
files *filestate.Tracker
|
||||
mark func(rel string, before []byte, existed bool, after []byte)
|
||||
writable []string
|
||||
}
|
||||
|
||||
func (t *editFile) Name() string { return "edit_file" }
|
||||
@@ -147,6 +152,9 @@ func (t *editFile) Execute(_ context.Context, args json.RawMessage) (Result, err
|
||||
if err != nil {
|
||||
return Result{Content: err.Error(), IsError: true}, nil
|
||||
}
|
||||
if msg := rejectOutsideWriteSet(t.writable, obj.Path); msg != "" {
|
||||
return Result{Content: msg, IsError: true}, nil
|
||||
}
|
||||
if msg := rejectStaleWrite(t.files, path); msg != "" {
|
||||
return Result{Content: msg, IsError: true}, nil
|
||||
}
|
||||
@@ -294,9 +302,10 @@ func (t *grepTool) Execute(_ context.Context, args json.RawMessage) (Result, err
|
||||
|
||||
// multiEditTool 一次多处替换:全部命中且互不冲突才落盘,任一失败即整体不动。
|
||||
type multiEditTool struct {
|
||||
root string
|
||||
files *filestate.Tracker
|
||||
mark func(rel string, before []byte, existed bool, after []byte)
|
||||
root string
|
||||
files *filestate.Tracker
|
||||
mark func(rel string, before []byte, existed bool, after []byte)
|
||||
writable []string
|
||||
}
|
||||
|
||||
func (t *multiEditTool) Name() string { return "multi_edit" }
|
||||
@@ -327,6 +336,9 @@ func (t *multiEditTool) Execute(_ context.Context, args json.RawMessage) (Result
|
||||
if err != nil {
|
||||
return Result{Content: err.Error(), IsError: true}, nil
|
||||
}
|
||||
if msg := rejectOutsideWriteSet(t.writable, obj.Path); msg != "" {
|
||||
return Result{Content: msg, IsError: true}, nil
|
||||
}
|
||||
if msg := rejectStaleWrite(t.files, path); msg != "" {
|
||||
return Result{Content: msg, IsError: true}, nil
|
||||
}
|
||||
|
||||
+124
-12
@@ -9,6 +9,7 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
@@ -23,6 +24,7 @@ type ExecConfig struct {
|
||||
Bash string
|
||||
Python string
|
||||
// Env 传给子进程的完整白名单环境(KEY=VALUE);不含本进程任何隐式变量。
|
||||
// nil 在注册/启动时归一为空白名单(不继承宿主环境);显式空切片同样为空白名单。
|
||||
Env []string
|
||||
// ScriptDir 落盘脚本目录(相对 root,默认 .agent/scripts);空用默认。
|
||||
ScriptDir string
|
||||
@@ -34,6 +36,16 @@ const (
|
||||
execMaxOutput = 64 << 10
|
||||
)
|
||||
|
||||
// normalizeExecEnv maps a nil env to an empty whitelist baseline so os/exec
|
||||
// does not inherit the host environment. An explicit empty or non-nil slice
|
||||
// is returned unchanged.
|
||||
func normalizeExecEnv(env []string) []string {
|
||||
if env == nil {
|
||||
return []string{}
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
// blockedCommandMarkers 最小命令黑名单:明显提权/自毁模式直接拒绝。
|
||||
// 这不是沙箱,只是防呆;真实隔离靠宿主环境。
|
||||
var blockedCommandMarkers = []string{
|
||||
@@ -45,6 +57,7 @@ var blockedCommandMarkers = []string{
|
||||
|
||||
// RegisterExecTools 注册 run_command / run_python(解释器未配置的项跳过注册)。
|
||||
func RegisterExecTools(r *Registry, root string, cfg ExecConfig) {
|
||||
cfg.Env = normalizeExecEnv(cfg.Env)
|
||||
if strings.TrimSpace(cfg.Bash) != "" {
|
||||
r.Register(&runCommandTool{root: root, cfg: cfg})
|
||||
}
|
||||
@@ -81,8 +94,8 @@ func (t *runCommandTool) Execute(ctx context.Context, args json.RawMessage) (Res
|
||||
return Result{Content: "command rejected by blocklist (sudo / destructive patterns are not allowed)", IsError: true}, nil
|
||||
}
|
||||
timeout := clampTimeout(obj.TimeoutSeconds)
|
||||
out, timedOut, err := runProcess(ctx, t.cfg.Bash, []string{"-c", obj.Command}, t.root, t.cfg.Env, timeout)
|
||||
content := formatProcessOutput(out, timedOut, err)
|
||||
out, total, timedOut, err := runProcess(ctx, t.cfg.Bash, []string{"-c", obj.Command}, t.root, t.cfg.Env, timeout)
|
||||
content := formatProcessOutput(out, total, timedOut, err)
|
||||
return Result{Content: content, IsError: timedOut || err != nil}, nil
|
||||
}
|
||||
|
||||
@@ -124,19 +137,20 @@ func (t *runPythonTool) Execute(ctx context.Context, args json.RawMessage) (Resu
|
||||
return Result{Content: err.Error(), IsError: true}, nil
|
||||
}
|
||||
timeout := clampTimeout(obj.TimeoutSeconds)
|
||||
out, timedOut, err := runProcess(ctx, t.cfg.Python, []string{script}, t.root, t.cfg.Env, timeout)
|
||||
content := formatProcessOutput(out, timedOut, err)
|
||||
out, total, timedOut, err := runProcess(ctx, t.cfg.Python, []string{script}, t.root, t.cfg.Env, timeout)
|
||||
content := formatProcessOutput(out, total, timedOut, err)
|
||||
content = "script: " + filepath.ToSlash(filepath.Join(dir, name)) + "\n" + content
|
||||
return Result{Content: content, IsError: timedOut || err != nil}, nil
|
||||
}
|
||||
|
||||
// runProcess 启动独立进程组的子进程;ctx 取消或超时先到者杀整组(SIGKILL)。
|
||||
func runProcess(ctx context.Context, interpreter string, argv []string, dir string, env []string, timeout time.Duration) (out []byte, timedOut bool, err error) {
|
||||
// 合并输出持续排入有界头尾缓冲,避免 CombinedOutput 无界增长;Wait 回收子进程。
|
||||
func runProcess(ctx context.Context, interpreter string, argv []string, dir string, env []string, timeout time.Duration) (out []byte, total int, timedOut bool, err error) {
|
||||
runCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(runCtx, interpreter, argv...)
|
||||
cmd.Dir = dir
|
||||
cmd.Env = env
|
||||
cmd.Env = normalizeExecEnv(env)
|
||||
// 独立进程组:超时/取消时杀 -PGID,确保孙进程一并退出。
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
cmd.Cancel = func() error {
|
||||
@@ -146,9 +160,13 @@ func runProcess(ctx context.Context, interpreter string, argv []string, dir stri
|
||||
return nil
|
||||
}
|
||||
cmd.WaitDelay = 3 * time.Second
|
||||
out, err = cmd.CombinedOutput()
|
||||
buf := newHeadTailBuffer(execMaxOutput)
|
||||
cmd.Stdout = buf
|
||||
cmd.Stderr = buf
|
||||
err = cmd.Run()
|
||||
out, total = buf.Snapshot()
|
||||
timedOut = runCtx.Err() != nil && (ctx.Err() == nil || runCtx.Err() != ctx.Err())
|
||||
return out, timedOut, err
|
||||
return out, total, timedOut, err
|
||||
}
|
||||
|
||||
func clampTimeout(seconds int) time.Duration {
|
||||
@@ -171,14 +189,108 @@ func blocked(command string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// headTailBuffer keeps a fixed-size head and a ring-buffer tail while counting
|
||||
// all bytes written, so chatty children cannot grow memory without bound.
|
||||
type headTailBuffer struct {
|
||||
mu sync.Mutex
|
||||
max int
|
||||
half int
|
||||
head []byte
|
||||
tail []byte
|
||||
tailPos int
|
||||
tailFilled int
|
||||
total int
|
||||
}
|
||||
|
||||
func newHeadTailBuffer(max int) *headTailBuffer {
|
||||
if max < 2 {
|
||||
max = 2
|
||||
}
|
||||
half := max / 2
|
||||
return &headTailBuffer{
|
||||
max: max,
|
||||
half: half,
|
||||
head: make([]byte, 0, half),
|
||||
tail: make([]byte, half),
|
||||
}
|
||||
}
|
||||
|
||||
func (b *headTailBuffer) Write(p []byte) (int, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
n := len(p)
|
||||
for len(p) > 0 {
|
||||
if len(b.head) < b.half {
|
||||
take := b.half - len(b.head)
|
||||
if take > len(p) {
|
||||
take = len(p)
|
||||
}
|
||||
b.head = append(b.head, p[:take]...)
|
||||
b.total += take
|
||||
p = p[take:]
|
||||
continue
|
||||
}
|
||||
// Head is full: remaining bytes rotate through the fixed tail ring.
|
||||
for i := 0; i < len(p); i++ {
|
||||
b.tail[b.tailPos] = p[i]
|
||||
b.tailPos = (b.tailPos + 1) % b.half
|
||||
if b.tailFilled < b.half {
|
||||
b.tailFilled++
|
||||
}
|
||||
}
|
||||
b.total += len(p)
|
||||
break
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// Snapshot returns retained bytes and the total byte count observed.
|
||||
// When total <= max the slice is the full stream; when truncated it is
|
||||
// head||tail of length max (callers use total for the dropped-bytes marker).
|
||||
func (b *headTailBuffer) Snapshot() ([]byte, int) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
if b.total <= b.half {
|
||||
out := make([]byte, len(b.head))
|
||||
copy(out, b.head)
|
||||
return out, b.total
|
||||
}
|
||||
tail := b.tailBytesLocked()
|
||||
out := make([]byte, 0, len(b.head)+len(tail))
|
||||
out = append(out, b.head...)
|
||||
out = append(out, tail...)
|
||||
return out, b.total
|
||||
}
|
||||
|
||||
func (b *headTailBuffer) tailBytesLocked() []byte {
|
||||
if b.tailFilled == 0 {
|
||||
return nil
|
||||
}
|
||||
if b.tailFilled < b.half {
|
||||
out := make([]byte, b.tailFilled)
|
||||
copy(out, b.tail[:b.tailFilled])
|
||||
return out
|
||||
}
|
||||
out := make([]byte, b.half)
|
||||
n := copy(out, b.tail[b.tailPos:])
|
||||
copy(out[n:], b.tail[:b.tailPos])
|
||||
return out
|
||||
}
|
||||
|
||||
// formatProcessOutput 截断为头尾各半(总上限 execMaxOutput)并标注退出状态。
|
||||
func formatProcessOutput(out []byte, timedOut bool, err error) string {
|
||||
// out 为有界捕获结果;total 为子进程实际写出字节数(可大于 len(out))。
|
||||
func formatProcessOutput(out []byte, total int, timedOut bool, err error) string {
|
||||
text := string(out)
|
||||
if len(out) > execMaxOutput {
|
||||
if total > execMaxOutput {
|
||||
half := execMaxOutput / 2
|
||||
dropped := len(out) - execMaxOutput
|
||||
dropped := total - execMaxOutput
|
||||
head := CutUTF8(string(out), half)
|
||||
tail := string(out[len(out)-half:])
|
||||
tail := ""
|
||||
if len(out) > half {
|
||||
tail = string(out[len(out)-half:])
|
||||
} else if len(out) > len(head) {
|
||||
tail = string(out[len(head):])
|
||||
}
|
||||
for len(tail) > 0 && !utf8.RuneStart(tail[0]) {
|
||||
tail = tail[1:]
|
||||
}
|
||||
|
||||
@@ -54,6 +54,37 @@ func TestRunCommandEnvWhitelist(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCommandNilEnvIsEmptyWhitelist(t *testing.T) {
|
||||
bash := lookInterpreter(t, "bash")
|
||||
t.Setenv("KERNEL_SECRET_LEAK", "must-not-leak")
|
||||
root := t.TempDir()
|
||||
reg := NewRegistry(root)
|
||||
// Env left nil: must normalize to empty whitelist (not inherit host).
|
||||
RegisterExecTools(reg, root, ExecConfig{Bash: bash})
|
||||
res := runTool(t, reg, "run_command", `{"command":"echo secret=${KERNEL_SECRET_LEAK:-absent}"}`)
|
||||
if res.IsError {
|
||||
t.Fatal(res.Content)
|
||||
}
|
||||
if !strings.Contains(res.Content, "secret=absent") {
|
||||
t.Fatalf("nil Env must not inherit host secrets, got:\n%s", res.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCommandExplicitEmptyEnvStaysEmpty(t *testing.T) {
|
||||
bash := lookInterpreter(t, "bash")
|
||||
t.Setenv("KERNEL_SECRET_LEAK", "must-not-leak")
|
||||
root := t.TempDir()
|
||||
reg := NewRegistry(root)
|
||||
RegisterExecTools(reg, root, ExecConfig{Bash: bash, Env: []string{}})
|
||||
res := runTool(t, reg, "run_command", `{"command":"echo secret=${KERNEL_SECRET_LEAK:-absent}"}`)
|
||||
if res.IsError {
|
||||
t.Fatal(res.Content)
|
||||
}
|
||||
if !strings.Contains(res.Content, "secret=absent") {
|
||||
t.Fatalf("explicit empty Env must stay empty, got:\n%s", res.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCommandTimeoutKillsProcessGroup(t *testing.T) {
|
||||
bash := lookInterpreter(t, "bash")
|
||||
root := t.TempDir()
|
||||
@@ -129,6 +160,71 @@ func TestRunCommandOutputTruncation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeadTailBufferBoundsMemoryAndPreservesSmall(t *testing.T) {
|
||||
buf := newHeadTailBuffer(64)
|
||||
if _, err := buf.Write([]byte("hello")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, total := buf.Snapshot()
|
||||
if total != 5 || string(out) != "hello" {
|
||||
t.Fatalf("small output must be unchanged, got %q total=%d", out, total)
|
||||
}
|
||||
|
||||
buf = newHeadTailBuffer(64)
|
||||
var payload strings.Builder
|
||||
for i := 0; i < 200; i++ {
|
||||
payload.WriteByte(byte('A' + (i % 26)))
|
||||
}
|
||||
if _, err := buf.Write([]byte(payload.String())); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, total = buf.Snapshot()
|
||||
if total != 200 {
|
||||
t.Fatalf("total must count all bytes, got %d", total)
|
||||
}
|
||||
if len(out) != 64 {
|
||||
t.Fatalf("retained buffer must be capped at 64, got %d", len(out))
|
||||
}
|
||||
if string(out[:32]) != payload.String()[:32] {
|
||||
t.Fatalf("head mismatch: %q", out[:32])
|
||||
}
|
||||
if string(out[32:]) != payload.String()[200-32:] {
|
||||
t.Fatalf("tail mismatch: %q", out[32:])
|
||||
}
|
||||
// Internal retention stays O(max), not O(total).
|
||||
if cap(buf.head)+cap(buf.tail) > 64+8 {
|
||||
t.Fatalf("buffer capacity must stay near max, head=%d tail=%d", cap(buf.head), cap(buf.tail))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCommandChattyOutputCancelDrains(t *testing.T) {
|
||||
bash := lookInterpreter(t, "bash")
|
||||
root := t.TempDir()
|
||||
reg := NewRegistry(root)
|
||||
RegisterExecTools(reg, root, ExecConfig{Bash: bash, Env: []string{"PATH=/usr/bin:/bin"}})
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
go func() {
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
cancel()
|
||||
}()
|
||||
start := time.Now()
|
||||
// Chatty child: continuous stdout until killed; capture must stay bounded and Wait must reap.
|
||||
res := reg.Execute(ctx, "run_command", []byte(
|
||||
`{"command":"while true; do head -c 8192 /dev/zero | tr '\\0' 'y'; done","timeout_seconds":60}`,
|
||||
))
|
||||
elapsed := time.Since(start)
|
||||
if elapsed > 10*time.Second {
|
||||
t.Fatalf("cancel must drain/reap promptly, took %s", elapsed)
|
||||
}
|
||||
if !res.IsError {
|
||||
t.Fatalf("canceled chatty command must be an error result, got %+v", res)
|
||||
}
|
||||
if len(res.Content) > execMaxOutput+512 {
|
||||
t.Fatalf("chatty cancel output must stay capped, got %d", len(res.Content))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPythonPersistsScriptAndRuns(t *testing.T) {
|
||||
python := lookInterpreter(t, "python3")
|
||||
root := t.TempDir()
|
||||
|
||||
+42
-68
@@ -5,10 +5,12 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"agent-native/runtime/argschema"
|
||||
"agent-native/runtime/checkpoint"
|
||||
"agent-native/runtime/filestate"
|
||||
)
|
||||
@@ -286,74 +288,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.
|
||||
@@ -418,6 +353,45 @@ func rejectStaleWrite(files *filestate.Tracker, path string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// rejectOutsideWriteSet rejects rel when a non-empty write-set was declared and
|
||||
// rel is not one of the listed relative paths. Empty allow = unrestricted.
|
||||
func rejectOutsideWriteSet(allow []string, rel string) string {
|
||||
if len(allow) == 0 {
|
||||
return ""
|
||||
}
|
||||
rel = filepath.ToSlash(filepath.Clean(strings.TrimSpace(rel)))
|
||||
if rel == "." || rel == "" {
|
||||
return "path is outside the declared writable set"
|
||||
}
|
||||
for _, a := range allow {
|
||||
if filepath.ToSlash(filepath.Clean(strings.TrimSpace(a))) == rel {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return "path is outside the declared writable set"
|
||||
}
|
||||
|
||||
// ApplyWriteAllowlist threads a WorkspaceHandle.Writable set into document
|
||||
// write tools (write_file / edit_file / multi_edit). Empty allow is a no-op.
|
||||
func ApplyWriteAllowlist(r *Registry, allow []string) {
|
||||
if r == nil || len(allow) == 0 {
|
||||
return
|
||||
}
|
||||
cp := append([]string(nil), allow...)
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
for _, t := range r.tools {
|
||||
switch w := t.(type) {
|
||||
case *writeFile:
|
||||
w.writable = cp
|
||||
case *editFile:
|
||||
w.writable = cp
|
||||
case *multiEditTool:
|
||||
w.writable = cp
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mustPath(root, rel string) (string, error) {
|
||||
return Resolve(root, rel)
|
||||
}
|
||||
|
||||
@@ -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,26 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWriteAllowlistRestrictsDocumentTools(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
reg := WorkspaceTools(root)
|
||||
ApplyWriteAllowlist(reg, []string{"allowed.md"})
|
||||
|
||||
ok := reg.Execute(context.Background(), "write_file", []byte(`{"path":"allowed.md","content":"yes"}`))
|
||||
if ok.IsError {
|
||||
t.Fatalf("allowed write rejected: %s", ok.Content)
|
||||
}
|
||||
denied := reg.Execute(context.Background(), "write_file", []byte(`{"path":"other.md","content":"no"}`))
|
||||
if !denied.IsError {
|
||||
t.Fatal("write outside writable set must fail")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "other.md")); !os.IsNotExist(err) {
|
||||
t.Fatal("denied path must not be created")
|
||||
}
|
||||
}
|
||||
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