Compare commits

...
Author SHA1 Message Date
weichenandCursor a34689c78f Delegate unregistered tool calls when a fallback is set
ci / vet-and-test (pull_request) Failing after 2m48s
ci / docker-checkout-hostgateway (pull_request) Failing after 31s
Why: the direct chat path still executes model-emitted calls that are not in the profile allow-list, while squad keeps the unknown-tool error when the fallback is unset.
Evidence: go test ./runtime/ passed, including TestLoopUnknownToolFallsThrough and the existing unknown-tool cases.
Impact: Config.UnknownTool is optional. Nil preserves today's registry error.
Refs: feature/agent-harness-p1
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-01 13:27:36 +00:00
weichenandCursor e2815bbf94 Report token usage on each message end
Why: the direct-path adapter accounts usage per turn, including runs that stop on a budget error before the final usage event.
Evidence: go test ./runtime/ ./runtime/llm/ after the field was added.
Impact: message_end and usage events carry TotalTokens. Callers that ignore the new field are unchanged.
Refs: feature/agent-harness-p1
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-01 13:25:58 +00:00
weichenandCursor 35f65a3c49 Add optional stop, reminder, and continuation hooks to the turn loop
Why: the direct chat path needs one shared loop for deliverable checks, tool pairing, reminders, and truncated replies, while squad callers keep today's behavior when the new fields stay unset.
Evidence: go test ./... in agent-native passed, including Stop cap, BeforeTool pairing, reminders absent from the session, repeat-guard cap, length continuation, and zero-value loop tests.
Impact: runtime.Config and llm.Event grow optional fields; AgentSession.Prompt copies them through. Squad sessions that leave them zero are unchanged.
Refs: feature/agent-harness-p1
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-01 13:23:07 +00:00
weichen 886e86c6a8 Add the headless agent session, providers, and RPC
ci / vet-and-test (push) Failing after 1s
ci / docker-checkout-hostgateway (push) Successful in 2s
2026-10-01 10:30:41 +00:00
weichenandCursor 6b7d2432b3 Serve print, JSON, and RPC modes on AgentSession
ci / vet-and-test (pull_request) Failing after 2s
ci / docker-checkout-hostgateway (pull_request) Successful in 2s
Why: The library had no process entry, and the product backend still called the loop directly.
Evidence: go test -count=1 ./...
Impact: exec.Backend keeps the same request and result. Execute now goes through AgentSession. Extensions and packages are not a sandbox.
Refs: feature/pi-headless N6 N7
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-01 10:16:00 +00:00
weichenandCursor 23b5e3f336 Add an extension process protocol and package manager
Why: Tools, hooks, and providers need a way in without a Go rebuild, and packages need install, update, and removal.
Evidence: go test -count=1 ./runtime/ext/
Impact: Extensions run as subprocesses with the caller's privileges. They are not a sandbox.
Refs: feature/pi-headless N5
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-01 10:13:48 +00:00
weichenandCursor dfd0d23bf3 Add pi tool aliases, context files, and project trust
Why: Headless runs need the pi tool names, walked context files, prompt includes, and a trust gate before project settings are loaded.
Evidence: go test -count=1 ./runtime/tools/ ./runtime/resources/ ./runtime/skills/
Impact: Existing tool names stay registered. Aliases are added only when the underlying tool exists.
Refs: feature/pi-headless N4
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-01 10:12:26 +00:00
weichenandCursor 2dedc0d20c Add an AgentSession with steer and follow-up queues
Why: Steering hooks existed on the loop but nothing owned the queues, abort, or a model change during a run.
Evidence: go test -count=1 ./runtime/
Impact: Run still emits the existing event names. message_start, tool_execution_update, and agent_settled are added beside them.
Refs: feature/pi-headless N3
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-01 10:11:19 +00:00
weichenandCursor e7ab48e8ea Add native provider protocols and retry
Why: The runtime could only speak OpenAI chat completions, so thinking levels, images, and the other vendor wires had nowhere to go.
Evidence: go test -count=1 ./runtime/llm/
Impact: Existing chat-completions calls stay the same unless a thinking level or image is set. Overflow errors are not retried.
Refs: feature/pi-headless N2
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-01 10:10:02 +00:00
weichenandCursor 6bc1b08540 Record sessions as an append-only entry tree
Why: Linear transcripts cannot fork, and compaction rewrote history, so resume and branch navigation had nothing to point at.
Evidence: go test -count=1 ./runtime/session/
Impact: Load still returns the model-visible path, including old one-message-per-line files. Compaction appends a summary entry instead of truncating the file.
Refs: feature/pi-headless N1
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-01 10:08:04 +00:00
weichen ec731addef Let hosts register tools and observe token usage
ci / vet-and-test (push) Failing after 1s
ci / docker-checkout-hostgateway (push) Successful in 2s
2026-10-01 09:35:54 +00:00
weichenandCursor cfc840c11d Expose the step directory and a tool-call cap
ci / vet-and-test (pull_request) Failing after 2s
ci / docker-checkout-hostgateway (pull_request) Successful in 2s
Why: Host tools need the kernel workspace to register files the step just wrote, and product budgets must stop a step that keeps calling tools.
Evidence: go test ./adapters/native/ ./runtime/ -count=1 passed, including the workdir visibility test.
Impact: Host tools can read WorkDir from context. MaxToolCalls limits one kernel step when the request budget leaves it unset.
Refs: deliverable-quality-system B1
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-01 09:06:22 +00:00
weichenandCursor 951c141f95 Let hosts register tools and observe token usage
Why: product runners need register_artifact and card tools inside the kernel loop, and the UI needs token usage on the same event stream as text and tool calls.
Evidence: go test ./... in agent-native; TestHostToolAndUsageEvent and TestHostToolNameConflictFailsClosed.
Impact: Options.HostTools fail closed on name clashes; ProcessEvent and runtime events gain a usage type with token counts. Existing callers that ignore unknown event types stay compatible.
Refs: deliverable-quality-system B1
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-01 08:57:21 +00:00
weichen f9ab2ef698 Merge pull request 'Land the full-agent capability batch (B1-B8): port v0.2, tool surface, exec tools, network, MCP, skills, compaction, delegation' (#3) from feature/full-agent-r1 into main
ci / vet-and-test (push) Failing after 2s
ci / docker-checkout-hostgateway (push) Successful in 2s
2026-10-01 05:53:57 +00:00
weichen 001648cd0e Re-baseline the parity matrix for the full-agent expansion and update the README
ci / vet-and-test (pull_request) Failing after 1s
ci / docker-checkout-hostgateway (pull_request) Successful in 2s
Why: the matrix scoped native work to the pi wrapper's exposed surface;
the full-agent batch deliberately expands beyond it, so the new domains
need their own acceptance basis recorded (standard conformance +
negative tests + product acceptance, not pi comparison) plus honest
security boundary notes.

Evidence: docs-only change; go vet/test unaffected (verified green in
the code commits this branch carries).

Impact: docs/parity-matrix.md gains the R15 expansion table mapping
every domain to its test evidence; README package table reflects the
new runtime/mcp, runtime/skills, runtime/promptx, runtime/subagent
packages and the v0.2 exec port.

Refs: docs/parity-matrix.md #1-#16 remain valid for wrapper-parity
rows.
2026-10-01 05:53:35 +00:00
weichen d5a98e3f18 Add the delegation port and delegate_task tool backed by host factories
Why: complete agents delegate; the kernel must not nest executions
itself (orchestration stays with the host per the agent-execution
standard), so the missing piece is a port plus a tool face.

Evidence: go vet ./... clean; go test ./... 14 packages ok. Cases:
nil factory registers nothing, full delegation field round-trip
(goal/inputs/write_scope/non_goals/done_criteria/deliverables) with
sorted file list in the result text, sub-agent failure and factory
error propagation, schema-required goal rejection, pre-canceled
delegations never starting, and adapter end-to-end wiring through
Options.Subagent.

Impact: new runtime/subagent package (Request/Result/Factory); the
delegation message set matches the agent-execution delegation clause
(sub-tasks derive no extra permissions; budget fields are host-set);
native.Options gains Subagent.

Refs: plan B8; standards/agent-execution.md delegation section.
2026-10-01 05:53:07 +00:00
weichen 7710be2db4 Add session compaction, metadata, and crash-safe transcript loading
Why: long multi-turn executions grow the JSONL transcript until they
hit provider context limits (parity-matrix #11), and a crashed append
left a partial line that made the whole session unloadable
(parity-matrix #12 resume semantics).

Evidence: go vet ./... clean; go test ./... 13 packages ok. Session
cases: crash-residue tail skipped while mid-file corruption still
errors, Stats line/byte counts, meta sidecar roundtrip (missing meta
nil), Compact rewriting system+summary+keepLastN atomically with no
tmp residue, no-op on short transcripts, summarizer error leaving the
file untouched. Adapter case: threshold-triggered compaction runs
before the loop and the summary marker reaches both the transcript
and the model's context.

Impact: session gains Stats/SaveMeta/ReadMeta/Compact; native.Options
gains Compact *CompactConfig (default summarizer is one tool-less
stream call; compaction failure logs and continues - optimization,
never a silent failure); session meta sidecar records model/scope/
exec id per execution.

Refs: docs/parity-matrix.md #11/#12 (Batch3 scope).
2026-10-01 05:51:46 +00:00
weichen b73611939e Add the SKILL.md skills system and the promptx system-prompt assembler
Why: roles had no way to carry operational know-how into executions
(RoleSpec.Skills never reached the backend), and the system prompt was
assembled ad hoc with no environment facts or skill index.

Evidence: go vet ./... clean; go test ./... 12 packages ok. Skills
cases cover directory-authoritative naming, optional frontmatter
description extraction, invalid dir skipping, root priority on name
clash, missing-root tolerance, fail-closed unknown-skill loads, 64 KiB
body / 200-byte description caps, load_skill registration only when
skills exist, and end-to-end adapter wiring (index in system prompt,
declared-skill validation failing closed on ghost skills, workspace
.agent/skills auto-merge). promptx cases pin section order, sorted
index, reasoning-profile sentence, and env defaults.

Impact: new runtime/skills + runtime/promptx packages; native.Options
gains SkillsRoots; adapters/native system prompt now composes
instructions + environment (date/model/workspace/profile) + declared
skills + index + deliverables; declared-but-missing skills fail the
step (configuration error, not silent).

Refs: plan B6; RoleSpec.Skills plumbing lands with the squad batch.
2026-10-01 05:46:41 +00:00
weichen b2638da64c Add an MCP client (stdio and streamable HTTP) with namespaced tool bridging
Why: external tool ecosystems speak MCP; the kernel had no way to
mount them, so every external capability needed a bespoke builtin.

Evidence: go vet ./... clean; go test ./... 11 packages ok. Tests run
against a REAL stdio server (test binary re-exec via
mcp.RunFakeServer) and httptest HTTP servers: initialize handshake,
tools/list with pagination cap, tools/call success / tool-level
isError / unknown-tool rpc error, JSON and SSE response modes,
Mcp-Session-Id capture, manager registration as mcp__<server>__<tool>,
namespace wildcard allowlist (mcp__fake__* keeps only that namespace,
unknown wildcard fails closed), unreachable server failing the step,
hung server bounded by per-request timeout, config validation
(duplicate names, dual/missing transport, bad name chars), and an
adapter end-to-end execution calling an MCP tool.

Impact: new runtime/mcp package; native.Options gains MCP *mcp.Manager
and Close() for process cleanup; tools.Registry.Restrict gains
prefix/* namespace entries; server-provided metadata is capped (64
tools/server, 1 KiB descriptions, 64 KiB text results) and treated as
untrusted input.

Refs: plan B5; agent-execution standard (tool providers get
authorized capability only).
2026-10-01 05:43:28 +00:00
weichen e4c48eb405 Wire search into the native backend and add fetch_url with SSRF guards
Why: tavily_search/web_search existed as tools but were never
registered on the default execution path (parity-matrix #5 leftover),
and the kernel had no URL fetch capability at all.

Evidence: go vet ./... clean; go test ./... all ok. Fetch cases cover
private/loopback/link-local rejection (127/8, 10/8, 192.168/16,
169.254 metadata endpoint, ::1), file:// scheme rejection, DNS
resolution landing on private space, per-hop redirect validation
rejecting a redirect to loopback, allowed-host policy, HTML stripping
(script/style removed, entities decoded), content-type gating, stub
end-to-end fetch, and absence without FetchConfig (IP normalization
fix included: IPv4-mapped ::ffff:0:0/96 must not swallow all IPv4).
Adapter tests assert Options.Search registers both search tools and
passes queries to the provider, and that neither registers when
unconfigured.

Impact: native.Options gains Search and Fetch; both tool families are
opt-in by the composition root (fail-closed), closing parity-matrix
#5 wiring.

Refs: docs/parity-matrix.md #5; product settings tools.tavily.*
injection point.
2026-10-01 05:35:38 +00:00
weichen fe5a973f15 Add run_command and run_python exec tools with process-group kill and env whitelist
Why: the kernel had no system-tool capability, so deliverable generation
via CLI/Python (the settled product direction) could not run inside
agent-native executions; squad's BashPermission gate had nothing to
guard.

Evidence: go vet ./... clean; go test ./... all ok. Cases cover echo
and non-zero exit reporting, env whitelist isolation (host secrets do
not leak into children), timeout and context-cancel killing the whole
process group promptly, blocklist rejection (sudo/destructive
patterns), 64 KiB head+tail output truncation, python script
persistence under .agent/scripts/, and fail-closed registration (no
interpreter configured = no tool). Adapter tests verify Options.Exec
wiring, per-request Env merge, allowlist stripping, and absence
without Options.Exec.

Impact: native.Options gains Exec *tools.ExecConfig; exec.Request.Env
now flows into child processes; workspace profile gains up to 2 tools
(only when configured). NOT an OS sandbox - isolation stays a host
responsibility, documented in tool descriptions.

Refs: parity-matrix #3/#4; settled product decision (deliverables via
CLI/Python + skills, see archived card feature-exec-interpreters).
2026-10-01 05:31:43 +00:00
weichen b105e71533 Add glob, multi_edit, regex grep, and line-range reads to the workspace toolset
Why: the five builtin tools could not express file discovery (no glob),
code search (literal-only grep), paged reads (200 KiB silent
truncation), or batch edits, capping the kernel below general coding-
agent parity.

Evidence: go vet ./... clean; go test ./... all ok. New cases cover
numbered line ranges and beyond-EOF rejection, regex/include/
ignore_case grep with invalid-regex errors, double-star glob
(**/*.go, docs/**, no-match), workspace confinement of glob, and
multi_edit atomicity (failed batch leaves the file untouched).

Impact: WorkspaceTools now registers 7 tools (adds glob, multi_edit);
read_file gains optional offset/limit and announces truncation; grep
gains regex/include/ignore_case while keeping literal default
behavior; existing callers see only additive changes.

Refs: docs/parity-matrix.md #3 (tool semantics negatives, Batch2).
2026-10-01 05:29:12 +00:00
weichen dd2ac11b31 Extend exec.Request with capability fields and enforce budgets end to end
Why: the frozen exec port could not express per-request model, tool
allowlist, skills, budget, or identifiers, so squad roles and product
profiles had no way to steer one execution; contract v0.1.0 semantics
(budget/cancel) stayed unconsumed (parity-matrix #4/#10/#16).

Evidence: go vet ./... clean; go test ./... all ok, including new
loop budget caps (tool calls, prompt tokens, deadline), cancel
propagation into a blocking tool, registry Restrict fail-closed
policy tests, native adapter v2 tests (model override, allowlist,
deadline-bound context, execution-id log header, skills prompt line),
and settle classification marking budget_exceeded permanent.

Impact: exec.Request gains optional Model/ToolAllowlist/Skills/Budget/
SessionScope/ExecutionID/Env (zero value = legacy behavior); runtime
loop enforces a shared budget and emits budget_exceeded events;
adapters/native consumes all new fields. Consumers on the old port
keep compiling and behaving identically.

Refs: docs/parity-matrix.md rows #4/#10/#16 (Batch2 scope); deps/
development-guidelines standards/agent-execution.md (shared budget
across retry layers).
2026-10-01 05:26:19 +00:00
weichen 39be648531 Merge pull request 'Record Gitea access facts in AGENTS.md' (#2) from feature/gitea-access-facts into main
ci / vet-and-test (push) Failing after 30s
ci / docker-checkout-hostgateway (push) Failing after 31s
2026-09-30 16:08:33 +00:00
weichen c90bc8ee17 Record Gitea access facts in AGENTS.md
ci / vet-and-test (pull_request) Failing after 29s
ci / docker-checkout-hostgateway (pull_request) Failing after 31s
Why: agents rediscovered Gitea hosts and credential locations from
scratch each session, and the retired git-panel host kept failing with
TLS broken-pipe errors before the right host was recalled.

Evidence: curl git-home...:30319/api/v1/version with token from
~/.secrets/gitea -> {"version":"1.27.3"}; ssh -p 31411 git@git-ssh...
(BatchMode) -> "Hi there, weichen!" with key ~/.ssh/home_gitea (ED25519,
SHA256:GYXaOXIMeloQ1fKSfdJ/ndgPQzs/Ix7Hk62ekfkfQ20). Same section already
merged in Agent-System-Dev (PR #73).

Impact: AGENTS.md only; records hosts and credential locations, no
secret values (baseline hard boundary 2).

Refs: Agent-System-Dev PR #73
2026-09-30 16:06:17 +00:00
weichen 228daa3ff5 Merge pull request 'Adopt the task-startup gate and commit format in AGENTS.md' (#1) from feature/entry-docs-gate into main
ci / vet-and-test (push) Failing after 2s
ci / docker-checkout-hostgateway (push) Successful in 2s
2026-09-30 03:15:24 +00:00
weichen 9b77bf4d6d Adopt the task-startup gate and commit format in AGENTS.md
ci / vet-and-test (pull_request) Failing after 2s
ci / docker-checkout-hostgateway (pull_request) Successful in 2s
Why: the composition adopted the delivery gate (Dev repo ef89fc8); resident
entry rules must carry the gate so agents load them without reading the
guidelines submodule first.
Evidence: text-only entry change; gate mechanics validated in the Dev repo
scratch-repo suite (19/19) and merged as PR weichen/Agent-System-Dev#1.
Impact: entry doc only; no code, contract, or build change in this repo.
Refs: Development-Guidelines 0.1.0-draft.7; Agent-System-Dev ef89fc8
2026-09-30 03:14:37 +00:00
weichen f735f18a73 Pin agent-intent to 3674dc4 for the slot constraints and capability catalog
ci / vet-and-test (push) Failing after 2s
ci / docker-checkout-hostgateway (push) Successful in 3s
Details (original message):
Pin agent-intent to 3674dc4 for the slot constraints and capability catalog.
2026-09-29 16:32:56 +00:00
weichen 5e489ab2cb Add the optional intentgate decorator over agent-intent with pass-through and fail-closed tests
Details (original message):
Add the optional intentgate decorator over agent-intent with pass-through and fail-closed tests.
2026-09-29 15:58:35 +00:00
weichenandCursor cc8a6a7702 Pin the error-code and logging repositories to their latest main commits.
ci / vet-and-test (push) Failing after 4s
ci / docker-checkout-hostgateway (push) Successful in 3s
The parent gitlink is the version that builds, so the newly published documentation commits need an explicit update here.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-29 14:35:25 +00:00
weichenandCursor 7af37906d9 Record the verification for the guidelines and logging adoption.
ci / vet-and-test (push) Failing after 4s
ci / docker-checkout-hostgateway (push) Successful in 5s
The note keeps the tested commit separate from the claim that the remote can already serve it.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-29 14:30:57 +00:00
weichenandCursor e65c6ba604 Align execution errors with the shared catalog and add optional logs.
Contract codes now use the agent.* values, and runtime can emit structured logs without changing results when no logger is configured.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-29 14:24:11 +00:00
weichenandCursor 57c049bd38 Pin Development Guidelines and add the project entry documents.
The guidelines submodule records the reviewed commit so project rules stay discoverable without copying them into this repository.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-29 14:20:37 +00:00
weichenandCursor 49e6dd1e0f Ignore CodeGraph indexes from the repository root as well.
ci / vet-and-test (push) Successful in 2s
ci / docker-checkout-hostgateway (push) Successful in 3s
A second ignore layer keeps the SQLite database local even if codegraph init overwrites the nested policy file.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-29 13:30:11 +00:00
weichenandCursor 3f0c6b2277 Keep the generated CodeGraph database out of the repository.
ci / vet-and-test (push) Successful in 2s
ci / docker-checkout-hostgateway (push) Successful in 3s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-29 13:25:44 +00:00
87 changed files with 8976 additions and 168 deletions
+13
View File
@@ -0,0 +1,13 @@
# CodeGraph data files — local to each machine, not for committing.
# Ignore everything in .codegraph/ except this file itself. Known transients:
# SQLite database and WAL/SHM, daemon.pid, sockets, and logs. The catch-all
# covers anything codegraph init adds later.
codegraph.db
codegraph.db-wal
codegraph.db-shm
codegraph.db-journal
daemon.pid
*.sock
*.log
*
!.gitignore
+4
View File
@@ -0,0 +1,4 @@
# CodeGraph local index. A second layer so the database stays untracked even
# if .codegraph/.gitignore is overwritten by codegraph init.
.codegraph/*
!.codegraph/.gitignore
+12
View File
@@ -0,0 +1,12 @@
[submodule "deps/development-guidelines"]
path = deps/development-guidelines
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Development-Guidelines.git
[submodule "deps/error-code-system"]
path = deps/error-code-system
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Error-Code-System.git
[submodule "deps/logging-system"]
path = deps/logging-system
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Logging-System.git
[submodule "deps/agent-intent"]
path = deps/agent-intent
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Intent.git
+36
View File
@@ -0,0 +1,36 @@
# Agent 工作说明(Agent-Native)
本仓开发遵循 Development-Guidelines(Dev 仓 `deps/development-guidelines`,gitlink 固定版本):
核心规则见其 `core/`,按任务触发阅读 `standards/` 专题;日常交付走 `workflows/delivery.md`。
## 任务启动门禁
- 任务启动或恢复后,第一次写操作前在 Dev 仓运行 `make precheck` 并附完整输出(本仓作为子仓随父仓编排);
本仓独立开发时核对工作树 clean、与权威 origin/main 同步,并按任务触发读对应专题。
- 从权威 origin/main 固定共同起点 R0,建 feature|fix|hotfix/<slug> 分支(Dev 仓 `make task-start` 自动化);
任务中途不重复 precheck。
- 验收、请求审核、合入前各同步一次最新目标分支并附证据;合入后回收任务工作区与已合入分支。
- 提交主题行:英文祈使句一行(≤100 列,不以句点结尾);body 写 `Why: / Evidence: / Impact: / Refs:`。
## 仓内规则
- 模块边界、公开契约与隔离档位见 `policy/modules/`(如有)与 PROJECT.md。
- 跨仓 gitlink 变更须经组合验证后在 Dev 仓推进;受保护分支经 PR 合入。
- 验证命令见 PROJECT.md「验证」节;完成声明须附实际命令输出证据。
## Gitea 与 Git 访问事实(2026-09-30 实测)
历史文档与旧链接里的 `git-panel.u1680832.nyat.app:30319` 已失效(TLS 握手失败),
见到一律按过期处理,不要再试。当前权威通道:
- **Web / API(HTTPS)**:`https://git-home.u1680832.nyat.app:30319`(Gitea 1.27.3,
API 前缀 `/api/v1`;偶发 broken pipe / 超时,重试即可)。
- **Git over SSH**:`ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/<Repo>.git`
(各仓 origin 与 gitlink 均为此格式)。
- **API token**:`~/.secrets/gitea`,两行键值 `URL_PREFIX=…` 与
`ACCESS_TOKEN=<40 位 hex>`;取值 `grep ACCESS_TOKEN ~/.secrets/gitea | cut -d= -f2-`,
用作 `Authorization: token <值>` 请求头。**token 值不入仓、不回显、不写进产出物**
(baseline 硬边界 2:只记录来源、字段与可用状态)。
- **SSH key**:`~/.ssh/home_gitea`(ED25519,公钥指纹
`SHA256:GYXaOXIMeloQ1fKSfdJ/ndgPQzs/Ix7Hk62ekfkfQ20`,Gitea 侧密钥名 GMKtec);
`~/.ssh/config` 已有 `git-ssh.u1680832.nyat.app` 主机条目,克隆/推送直接可用。
+53
View File
@@ -0,0 +1,53 @@
# Agent-Native 项目事实
工作规则见 [AGENTS.md](AGENTS.md)。此文件维护本项目事实,不复制通用规范。
## 仓库与操作
- 目标/产品边界:独立的 Agent 执行基础库。提供执行契约(contract)、执行端口(exec)、自研运行时(runtime)、外部适配器(adapters)与行为验收(conformance)。不实现小队编排,不依赖 Core 或 Squad。
- 源码、资产、文档、机器契约位置:Go 源码在仓库根包目录;模块路径 `agent-native`;人类文档在根 `README.md`、`docs/`、`policy/`;过程记录在 `docs/records/`;机器契约以 `contract` 包类型为准。
- 权威远端、集成基线、受保护分支:`ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Native.git`,集成基线 `main`。
- 并行共同基线记录位置、合入通知/主动检查方式:任务主记录在 Gitea Issue/PR;合入前在本仓 `main` 上 `git fetch` 并确认 HEAD。
- 发布线与批准版本/实际制品查询入口:当前无独立制品仓库。可构建性以本仓 `main` 的 `go test` 为准;依赖子模块版本以 gitlink 为准,不把工作区未提交文件当发布组合。
- 提交/审核/合并/发布身份与权限:仓库所有者 weichen。本机提交不代替远端合并。
- 任务唯一来源:Gitea `weichen/Agent-Native` 的 Issue 与 Pull Request。
- 规范性文档目录:`policy/`。
- 过程文档仓/目录:`docs/records/`(项目内豁免,理由见 [采纳记录](policy/guidelines-adoption.md))。
- 交接记录位置:随任务主记录;需要落盘的交接放入 `docs/records/`。
- 凭证查找入口及所需字段:模型与 Runner 凭证由调用方注入,不入库。本仓不保存密钥字段的值。
## 依赖组合
| 逻辑 ID | 路径 | 角色 |
|---|---|---|
| development-guidelines | `deps/development-guidelines` | development-only,规范文本 |
| error-code-system | `deps/error-code-system` | build-required,规范码目录 |
| logging-system | `deps/logging-system` | build-required,可选日志器 |
| agent-intent | `deps/agent-intent` | build-required,可选意图闸门装饰器(`adapters/intentgate`) |
克隆:
```bash
git clone --recurse-submodules ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Native.git
# 已克隆时:git submodule update --init --recursive
```
构建、审核和发布固定父仓 gitlink,不执行 `submodule update --remote`。
## 验证命令
| 影响 | 工作目录与真实命令/CI | 前提与不覆盖范围 |
|---|---|---|
| 文档 | 人工核对 `AGENTS.md`、`PROJECT.md`、`policy/` 相对链接 | 不证明运行时行为 |
| 模块/接口 | 仓库根:`go vet ./... && go test -count=1 ./...` | 需要 Go 1.27;不覆盖真实模型端点 |
| 数据/权限/恢复 | 不适用独立数据库。契约测试覆盖副作用分类与取消传播 | 不证明调用方产品库的持久化 |
| UI/执行后端 | `go test ./adapters/... ./runtime/... ./conformance/...` | 默认 fake/stub;真实模型需调用方提供端点,本命令不覆盖 |
## 环境与数据
| 环境/实例 ID | 只读查询入口 | 授权写入入口 | DB/存储归属 | 恢复入口 |
|---|---|---|---|---|
| 本地开发 | 本机工作区与 `go test` | 本机 git 提交 | 无共享数据库;会话文件由调用方 Workspace 决定 | 重新 clone 父仓并 `submodule update --init --recursive` |
共享项及 ACL:无。部署、迁移、发布的审批依据与负责角色:当前库不独立部署;合并进 `main` 由仓库所有者批准。
实际制品/配置/schema 的查询方法:阅读当前 `main` 的 `contract` 包与 `go.mod`,不以历史 README 快照代替。
+33 -7
View File
@@ -9,9 +9,9 @@
| 层 | 职责 | 依赖边界 |
|---|---|---|
| contract | 执行契约:Request/Result/Outcome/Error/Capability/Backend/Resolver | 不依赖内核、产品类型、数据库、配置 |
| exec | 执行端口:Backend/Request/Result/ProcessEvent、步骤重试与失败分类、交付物物化、Stub、并发限流 | 面向调用方的执行端口 |
| runtime | 多轮循环、工具调度、模型调用、执行会话 | 实现 contract;不导入 Core/Squad |
| contract | 执行契约:Request/Result/Outcome/Error/Capability/Backend/Resolver | 不依赖内核、产品类型、数据库、配置,也不导入错误码库 |
| exec | 执行端口:Backend/Request/Result/ProcessEvent、预算、步骤重试与失败分类、交付物物化、Stub、并发限流 | 面向调用方的执行端口 |
| runtime | 多轮循环、工具调度、模型调用、执行会话 | 不导入 Core/Squad;可选使用日志库,nil 日志器不改变结果 |
| adapters | PI Runner transport / typed-result-v1;native:自研内核 runtime → exec.Backend(规范后端名 agent-native,含会话毒化轮转——resume-unsafe 失败后自动换新会话) | 显式映射外部差异,不伪造缺失能力 |
| conformance | 所有 Backend 共用的行为验收 | fake 验证契约,真实 adapter 验证能力 |
@@ -23,9 +23,13 @@
|---|---|
| contract | 四态执行契约 |
| conformance | 行为验收套件与 fake |
| exec | Backend/Request/Result/ProcessEvent、步骤重试与失败分类、交付物物化、Stub、并发限流 |
| runtime | 多轮循环、llm、session、tools(含单测) |
| adapters/native | runtime 作为 exec.Backend,含会话毒化轮转 |
| exec | Backend/Request/Result/ProcessEvent(v0.2:Model/ToolAllowlist/Skills/Budget/SessionScope/ExecutionID/Env)、Budget、步骤重试与失败分类、交付物物化、Stub、并发限流 |
| runtime | 多轮循环(预算/取消贯穿、budget_exceeded 事件)、llm、session(compaction/元数据/崩溃安全)、tools(9 内置 + 策略层 Restrict) |
| runtime/mcp | MCP 客户端(stdio + Streamable HTTP)与 mcp__<server>__<tool> 桥接(server 元数据不可信、上限收敛) |
| runtime/skills | SKILL.md 技能加载与 load_skill 渐进披露 |
| runtime/promptx | 系统提示结构化装配(环境事实 + 技能索引 + 交付物约束) |
| runtime/subagent | 委派端口(Request/Result/Factory)与 delegate_task 工具;嵌套执行由宿主注入 |
| adapters/native | runtime 作为 exec.Backend,含会话毒化轮转、compaction、技能/搜索/执行/MCP/委派装配(Options 显式注入,未配置不注册) |
| adapters/pitransport | typed-result-v1 编解码、远程结果解析、Runner 搜索、鉴权头(含 Node 摘要交叉验证测试) |
本模块不依赖 Core 或 Squad。上层产品依赖本模块的 contract 与 exec。
@@ -37,6 +41,26 @@
- 失败副作用显式分类(none/possible);possible 禁止无条件重放。
- 重试单一责任层:步骤级归调用方,传输级有界归 adapter,turn 上限归 runtime。
- Workspace 路径授权 ≠ OS 沙箱。
- `contract.ErrorCode` 取值是 `agent.*` 规范码。`ErrorCode()` / `ErrorFields()` 供日志库识别,本包不导入错误码库。
## 依赖子模块
| 路径 | 作用 |
|---|---|
| `deps/development-guidelines` | 开发规范,不参与编译 |
| `deps/error-code-system` | 规范码目录。`go.mod` 的 `replace` 指向这里 |
| `deps/logging-system` | 结构化日志。runtime 与 native 适配器可选使用 |
版本以本仓 gitlink 为准。`scripts/submodule-consistency.sh` 核对 gitlink 与检出的 HEAD。
```bash
git clone --recurse-submodules ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Native.git
# 已克隆时:git submodule update --init --recursive
```
## CodeGraph
`codegraph init` 的索引写在 `.codegraph/`。仓库只跟踪 `.codegraph/.gitignore`。SQLite 数据库、WAL、pid、socket 和日志留在本机;根目录 `.gitignore` 与该文件双重忽略,避免索引被提交。
## 开发
@@ -54,9 +78,11 @@ go test ./...
```
require agent-native v0.0.0
replace agent-native => ../Agent-Native
replace agent-native => ./deps/agent-native
```
本仓自己的 `go.mod` 则把错误码库和日志库替换到 `./deps/`。消费方若同时依赖这三仓,必须在消费方 `go.mod` 里写自己的 `replace`:依赖模块内部的 `replace` 不会在上层构建中生效。
远程:`ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Native.git`
## CI
+56
View File
@@ -0,0 +1,56 @@
// Package intentgate 提供可选的意图闸门装饰器:在把 Prompt 交给被装饰的
// 执行后端之前,用 agent-intent 做一次确定性识别;分支为守卫/命中/预期命中时
// 交由宿主 Handler 解释,Ambiguous/Fallback 或 Handler 放弃时原样透传。
//
// 本包是 Agent-Native 仓内唯一 import agent-intent 的地方。不使用本包时,
// 本仓与 agent-intent 无任何编译关系;未 Wrap 或快照零意图时,被装饰后端的
// 行为与原来完全一致。
package intentgate
import (
"context"
"agent-intent/intent"
"agent-native/exec"
)
// Handler 解释"识别到意图"的执行请求。返回 handled=false 表示宿主放弃处理,
// 装饰器回退为透传原后端。合成结果的语义(拒绝、引导、定向执行)由宿主定义,
// 本包不发明结果语义。
type Handler func(ctx context.Context, req exec.Request, res intent.Result) (result *exec.Result, err error, handled bool)
type gated struct {
next exec.Backend
rec intent.Recognizer
snap *intent.Snapshot
handler Handler
}
// Wrap 返回带意图闸门的后端。next/rec/snap/handler 任一为 nil 时直接返回 next
// (等价于未启用,行为不变)。
func Wrap(next exec.Backend, rec intent.Recognizer, snap *intent.Snapshot, h Handler) exec.Backend {
if next == nil || rec == nil || snap == nil || h == nil {
return next
}
return &gated{next: next, rec: rec, snap: snap, handler: h}
}
func (g *gated) Name() string { return g.next.Name() }
func (g *gated) Execute(ctx context.Context, req exec.Request) (*exec.Result, error) {
res, err := g.rec.Recognize(ctx, req.Prompt, g.snap)
if err != nil {
// 识别系统错误 fail-closed:不静默放行可能应拦截的内容,也不吞错。
return nil, err
}
switch res.Branch {
case intent.BranchGuardHit, intent.BranchMatched, intent.BranchExpectationHit:
default:
return g.next.Execute(ctx, req) // 无裁决:透传原后端
}
out, herr, handled := g.handler(ctx, req, res)
if !handled {
return g.next.Execute(ctx, req)
}
return out, herr
}
+166
View File
@@ -0,0 +1,166 @@
package intentgate
import (
"context"
"errors"
"reflect"
"testing"
"agent-intent/intent"
"agent-native/exec"
)
type stubBackend struct {
name string
calls int
}
func (s *stubBackend) Name() string { return s.name }
func (s *stubBackend) Execute(ctx context.Context, req exec.Request) (*exec.Result, error) {
s.calls++
return &exec.Result{Success: true, Logs: "stub:" + req.Prompt}, nil
}
func emptySnapshot(t *testing.T) *intent.Snapshot {
t.Helper()
reg, err := intent.NewRegistryBuilder(intent.Options{}).Build()
if err != nil {
t.Fatal(err)
}
return reg.Snapshot()
}
// 带一个守卫 spec 与一个可路由 spec 的快照。
func loadedSnapshot(t *testing.T) *intent.Snapshot {
t.Helper()
b := intent.NewRegistryBuilder(intent.Options{
GuardDomains: []string{"guard"},
ProtectedDomains: []string{"guard"},
})
if err := b.Register(intent.IntentSpec{
ID: "guard.config_change", Domain: "guard", Version: 1,
Matcher: intent.MatchSpec{IncludesAny: []string{"系统配置", "网关"}},
Risk: intent.RiskHigh,
Handler: "refuse.and_guide",
Metadata: map[string]string{
"guidance": "系统配置请在管理界面修改",
},
}); err != nil {
t.Fatal(err)
}
reg, err := b.Build()
if err != nil {
t.Fatal(err)
}
return reg.Snapshot()
}
func newRec() intent.Recognizer { return intent.NewRecognizer(intent.Options{}, nil, nil) }
func TestZeroSpecSnapshotPassthroughIdentical(t *testing.T) {
// T0 行为解耦:零意图快照 + Wrap 后,与裸后端行为逐字节一致。
bare := &stubBackend{name: "native"}
gatedBe := Wrap(&stubBackend{name: "native"}, newRec(), emptySnapshot(t),
func(ctx context.Context, req exec.Request, res intent.Result) (*exec.Result, error, bool) {
t.Fatal("handler must not fire on empty snapshot")
return nil, nil, false
})
req := exec.Request{Prompt: "帮我改一下系统网关地址"}
want, werr := bare.Execute(context.Background(), req)
got, gerr := gatedBe.Execute(context.Background(), req)
if werr != nil || gerr != nil {
t.Fatalf("execute errors: %v %v", werr, gerr)
}
if !reflect.DeepEqual(want, got) {
t.Fatalf("results differ: %+v vs %+v", want, got)
}
if gatedBe.(*gated).next.(*stubBackend).calls != 1 {
t.Fatal("next backend not invoked exactly once")
}
}
func TestGuardHitInterceptsWithoutModel(t *testing.T) {
next := &stubBackend{name: "native"}
be := Wrap(next, newRec(), loadedSnapshot(t),
func(ctx context.Context, req exec.Request, res intent.Result) (*exec.Result, error, bool) {
if res.Branch != intent.BranchGuardHit || res.Top == nil || res.Top.ID != "guard.config_change" {
t.Fatalf("unexpected result: %+v", res)
}
// 宿主解释守卫意图:拒绝 + 引导(合成结果语义由宿主定义)。
return &exec.Result{Success: false, ErrorReason: "guard refused: " + res.Top.Metadata["guidance"]}, nil, true
})
out, err := be.Execute(context.Background(), exec.Request{Prompt: "帮我改一下系统网关地址"})
if err != nil {
t.Fatal(err)
}
if out.Success || out.ErrorReason == "" {
t.Fatalf("expected guard refusal, got %+v", out)
}
if next.calls != 0 {
t.Fatalf("model backend must not be called on guard hit, got %d calls", next.calls)
}
}
func TestFallbackPassesThroughToBackend(t *testing.T) {
next := &stubBackend{name: "native"}
be := Wrap(next, newRec(), loadedSnapshot(t),
func(ctx context.Context, req exec.Request, res intent.Result) (*exec.Result, error, bool) {
t.Fatal("handler must not fire on fallback")
return nil, nil, false
})
out, err := be.Execute(context.Background(), exec.Request{Prompt: "写一份周报"})
if err != nil || out == nil || !out.Success {
t.Fatalf("fallback must reach backend: %+v %v", out, err)
}
if next.calls != 1 {
t.Fatalf("backend calls = %d", next.calls)
}
}
func TestHandlerDeclineFallsBackToBackend(t *testing.T) {
next := &stubBackend{name: "native"}
be := Wrap(next, newRec(), loadedSnapshot(t),
func(ctx context.Context, req exec.Request, res intent.Result) (*exec.Result, error, bool) {
return nil, nil, false // 宿主放弃处理
})
out, err := be.Execute(context.Background(), exec.Request{Prompt: "帮我改一下系统网关地址"})
if err != nil || out == nil || !out.Success {
t.Fatalf("declined handling must pass through: %+v %v", out, err)
}
if next.calls != 1 {
t.Fatalf("backend calls = %d", next.calls)
}
}
func TestNilComponentsReturnNext(t *testing.T) {
next := &stubBackend{name: "native"}
if got := Wrap(next, nil, nil, nil); got != exec.Backend(next) {
t.Fatal("nil components must return next unchanged")
}
}
func TestRecognizeErrorFailsClosed(t *testing.T) {
next := &stubBackend{name: "native"}
b := intent.NewRegistryBuilder(intent.Options{MaxInputLen: 8})
reg, err := b.Build()
if err != nil {
t.Fatal(err)
}
be := Wrap(next, newRec(), reg.Snapshot(),
func(ctx context.Context, req exec.Request, res intent.Result) (*exec.Result, error, bool) {
t.Fatal("handler must not fire on recognize error")
return nil, nil, false
})
_, err = be.Execute(context.Background(), exec.Request{Prompt: "这一段输入明显超过八个字节"})
if err == nil {
t.Fatal("expected fail-closed error")
}
var ie *intent.Error
if !errors.As(err, &ie) {
t.Fatalf("expected *intent.Error, got %T", err)
}
if next.calls != 0 {
t.Fatal("backend must not run on recognize error")
}
}
+279 -31
View File
@@ -15,8 +15,14 @@ import (
"agent-native/exec"
"agent-native/runtime"
"agent-native/runtime/llm"
"agent-native/runtime/mcp"
"agent-native/runtime/promptx"
"agent-native/runtime/session"
"agent-native/runtime/skills"
"agent-native/runtime/subagent"
"agent-native/runtime/tools"
"logging-system/logsys"
)
// Options 构造后端所需的模型接入与执行参数(由组合根注入,无产品依赖)。
@@ -28,6 +34,40 @@ type Options struct {
Timeout time.Duration
// Stream 测试注入的流函数;缺省按 APIBase/APIKey 构造 llm.Client。
Stream llm.StreamFunc
// Search 可选:提供时注册 tavily_search / web_search(密钥由组合根注入)。
Search tools.SearchProvider
// Exec 可选:提供时注册 run_command / run_python(解释器留空项不注册)。
// nil = 不注册执行类工具(fail-closed,与 BashPermission 默认 deny 对齐)。
Exec *tools.ExecConfig
// Fetch 可选:提供时注册 fetch_url;nil = 不注册(fail-closed)。
Fetch *tools.FetchConfig
// MCP 可选:提供时把各 server 工具以 mcp__<server>__<tool> 注册进工作区。
// nil = 不注册(fail-closed:server 不可达时整个步骤失败,不静默跳过)。
MCP *mcp.Manager
// SkillsRoots 可选:全局技能根目录;执行时自动并入工作区 .agent/skills/。
SkillsRoots []string
// Compact 可选:转录超过阈值时把头部历史压成摘要(长会话上下文治理)。
Compact *CompactConfig
// Subagent 可选:提供时注册 delegate_task(委派由宿主实现,内核只开端口)。
Subagent subagent.Factory
// HostTools 是宿主产品工具(登记工件、卡片、知识库)。与内核工具同名时失败,不覆盖。
HostTools []tools.Tool
// MaxToolCalls 是单步工具调用上限。0 表示不限制。请求自带预算时以请求为准。
MaxToolCalls int
// Log 可选。nil 时丢弃日志。
Log *logsys.Logger
// ExecutionID 写入日志,不参与执行控制。
ExecutionID string
}
// CompactConfig 声明长会话压缩策略(优化性质:压缩失败不阻断执行,但留日志)。
type CompactConfig struct {
// ThresholdChars 触发阈值(按字节数近似),默认 300_000。
ThresholdChars int
// KeepLastN 压缩后保留的最近消息条数,默认 8。
KeepLastN int
// Summarizer 可选注入摘要函数;缺省用后端 stream 做一次无工具调用。
Summarizer func(ctx context.Context, messages []llm.Message) (string, error)
}
// Backend 以自研内核实现 exec.Backend。
@@ -35,10 +75,23 @@ type Backend struct {
opts Options
// lastFailure 记录各工作目录上次失败原因,用于会话毒化检测
// (context_overflow 等错误续跑同一会话只会复现失败,须换新会话)。
mu sync.Mutex
mu sync.Mutex
lastFailure map[string]string
}
type workDirKey struct{}
// WithWorkDir 把本步工作目录放进上下文,供宿主工具读取内核刚写出的文件。
func WithWorkDir(ctx context.Context, dir string) context.Context {
return context.WithValue(ctx, workDirKey{}, dir)
}
// WorkDir 返回 WithWorkDir 写入的目录。没有时返回空串。
func WorkDir(ctx context.Context) string {
dir, _ := ctx.Value(workDirKey{}).(string)
return dir
}
// New 创建 native 后端。
func New(opts Options) *Backend {
return &Backend{opts: opts, lastFailure: map[string]string{}}
@@ -47,6 +100,13 @@ func New(opts Options) *Backend {
// Name 返回规范后端名。
func (b *Backend) Name() string { return "agent-native" }
// Close 释放后端持有的长连接资源(MCP server 子进程等)。
func (b *Backend) Close() {
if b.opts.MCP != nil {
b.opts.MCP.Close()
}
}
// Execute 运行一轮带工具的自研智能体循环,产出目标交付物。
func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result, error) {
if len(req.Targets) == 0 {
@@ -62,7 +122,11 @@ func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result,
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
}
if strings.TrimSpace(b.opts.Model) == "" {
model := strings.TrimSpace(b.opts.Model)
if strings.TrimSpace(req.Model) != "" {
model = strings.TrimSpace(req.Model)
}
if model == "" {
return &exec.Result{Success: false, ErrorReason: "agent-native backend requires model"}, nil
}
@@ -82,9 +146,54 @@ func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result,
_ = os.WriteFile(filepath.Join(workDir, "TASK.md"), []byte(req.Prompt), 0o644)
}
// 技能:全局根 + 工作区 .agent/skills;声明的技能必须存在(fail-closed)。
roots := make([]string, 0, len(b.opts.SkillsRoots)+1)
roots = append(roots, b.opts.SkillsRoots...)
roots = append(roots, filepath.Join(workDir, ".agent", "skills"))
skillStore := skills.NewStore(roots...)
skillIndex := skillStore.List()
indexSet := make(map[string]bool, len(skillIndex))
for _, s := range skillIndex {
indexSet[s.Name] = true
}
for _, name := range req.Skills {
if !indexSet[name] {
return &exec.Result{Success: false, ErrorReason: "declared skill not installed: " + name}, nil
}
}
var reg *tools.Registry
if profile == exec.ToolProfileWorkspace {
reg = tools.WorkspaceTools(workDir)
skills.RegisterLoader(reg, skillStore)
if b.opts.Search != nil {
tools.RegisterSearch(reg, b.opts.Search)
}
if b.opts.Fetch != nil {
tools.RegisterFetch(reg, *b.opts.Fetch)
}
if b.opts.Exec != nil {
execCfg := *b.opts.Exec
for k, v := range req.Env {
execCfg.Env = append(execCfg.Env, k+"="+v)
}
tools.RegisterExecTools(reg, workDir, execCfg)
}
if b.opts.MCP != nil {
if err := b.opts.MCP.RegisterInto(ctx, reg); err != nil {
return &exec.Result{Success: false, ErrorReason: err.Error()}, nil
}
}
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
}
reg.Register(tool)
}
if err := reg.Restrict(req.ToolAllowlist); err != nil {
return &exec.Result{Success: false, ErrorReason: err.Error()}, nil
}
}
// 会话毒化轮转:上次失败属 resume-unsafe 时,把旧 transcript 归档,
// 本次以全新会话开始(续跑毒化会话只会复现同样的失败)。
@@ -98,21 +207,56 @@ func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result,
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),
})
b.maybeCompact(ctx, store, stream, model)
timeout := req.Timeout
if timeout <= 0 {
timeout = b.opts.Timeout
}
runCtx := ctx
var runBudget *runtime.Budget
if b.opts.MaxToolCalls > 0 {
runBudget = &runtime.Budget{MaxToolCalls: b.opts.MaxToolCalls}
}
if req.Budget != nil {
runBudget = &runtime.Budget{
MaxPromptTokens: req.Budget.MaxPromptTokens,
MaxCompletionTokens: req.Budget.MaxCompletionTokens,
MaxToolCalls: req.Budget.MaxToolCalls,
Deadline: req.Budget.Deadline,
}
if runBudget.MaxToolCalls == 0 {
runBudget.MaxToolCalls = b.opts.MaxToolCalls
}
if !req.Budget.Deadline.IsZero() {
// Deadline 与 Timeout 先到者生效:把更短者作为 ctx 超时。
if until := time.Until(req.Budget.Deadline); timeout <= 0 || until < timeout {
timeout = until
}
}
}
runCtx := WithWorkDir(ctx, workDir)
cancel := func() {}
if timeout > 0 {
runCtx, cancel = context.WithTimeout(ctx, timeout)
runCtx, cancel = context.WithTimeout(runCtx, timeout)
}
defer cancel()
execID := req.ExecutionID
if strings.TrimSpace(execID) == "" {
execID = b.opts.ExecutionID
}
var logs strings.Builder
if strings.TrimSpace(execID) != "" || strings.TrimSpace(req.SessionScope) != "" {
logs.WriteString("execution_id=" + execID + " session=" + req.SessionScope + "\n")
}
onProcess := req.OnEvent
runRes, err := b.run(runCtx, reg, store, stream, profile, req, &logs, onProcess)
runRes, err := b.run(runCtx, model, runBudget, reg, store, stream, profile, req, workDir, skillIndex, &logs, onProcess)
if err != nil {
return &exec.Result{Success: false, ErrorReason: err.Error(), Logs: logs.String()}, nil
}
@@ -135,6 +279,14 @@ func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result,
b.mu.Lock()
b.lastFailure[workDir] = reason
b.mu.Unlock()
assessment := exec.AssessStepFailure(exec.StepFailureSignals{
Err: runRes.Err,
WorkDir: workDir,
Result: &exec.Result{ErrorReason: reason, Retryable: runRes.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
@@ -159,17 +311,24 @@ func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result,
}
// run 驱动 runtime 循环并桥接过程事件(语义对齐主仓 pinative)。
func (b *Backend) run(ctx context.Context, reg *tools.Registry, store *session.Store,
stream llm.StreamFunc, profile string, req exec.Request, logs *strings.Builder,
onProcess func(exec.ProcessEvent)) (runtime.Result, error) {
func (b *Backend) run(ctx context.Context, model string, budget *runtime.Budget, reg *tools.Registry, store *session.Store,
stream llm.StreamFunc, profile string, req exec.Request, workDir string, skillIndex []skills.Skill,
logs *strings.Builder, onProcess func(exec.ProcessEvent)) (runtime.Result, error) {
runRes, err := runtime.Run(ctx, runtime.Config{
Model: b.opts.Model,
SystemPrompt: systemPrompt(req, profile),
execID := req.ExecutionID
if strings.TrimSpace(execID) == "" {
execID = b.opts.ExecutionID
}
agent := runtime.NewAgentSession(runtime.Config{
Model: model,
SystemPrompt: b.systemPrompt(req, model, profile, workDir, skillIndex),
Tools: reg,
Stream: stream,
Session: store,
MaxTurns: b.opts.MaxTurns,
Log: b.logger(),
ExecutionID: execID,
Budget: budget,
OnEvent: func(ev runtime.Event) {
logs.WriteString(ev.Type)
if ev.Tool != "" {
@@ -183,17 +342,87 @@ func (b *Backend) run(ctx context.Context, reg *tools.Registry, store *session.S
logs.WriteByte('\n')
if onProcess != nil {
onProcess(exec.ProcessEvent{
Type: ev.Type,
Text: ev.Text,
Tool: ev.Tool,
Detail: ev.Detail,
Type: ev.Type,
Text: ev.Text,
Tool: ev.Tool,
Detail: ev.Detail,
PromptTokens: ev.PromptTokens,
CompletionTokens: ev.CompletionTokens,
})
}
},
}, llm.Message{Role: llm.RoleUser, Content: req.Prompt})
return runRes, err
})
return agent.Prompt(ctx, llm.Message{Role: llm.RoleUser, Content: req.Prompt})
}
// maybeCompact 在转录超过阈值时压缩头部历史。压缩是优化性质:
// 失败不阻断执行(按原文继续),但必须留日志,不静默。
func (b *Backend) maybeCompact(ctx context.Context, store *session.Store, stream llm.StreamFunc, model string) {
cfg := b.opts.Compact
if cfg == nil {
return
}
threshold := int64(cfg.ThresholdChars)
if threshold <= 0 {
threshold = 300_000
}
keep := cfg.KeepLastN
if keep <= 0 {
keep = 8
}
if store.Stats().Bytes < threshold {
return
}
summarize := cfg.Summarizer
if summarize == nil {
summarize = defaultSummarizer(stream, model)
}
done, err := store.Compact(keep, func(messages []llm.Message) (string, error) {
return summarize(ctx, messages)
})
if err != nil {
b.logger().Warn("session compaction failed; continuing with full transcript", "error", err.Error())
return
}
if done {
b.logger().Info("session compacted", "keep_last", keep)
}
}
// defaultSummarizer 用后端 stream 做一次无工具调用,把历史压成摘要。
func defaultSummarizer(stream llm.StreamFunc, model string) func(ctx context.Context, messages []llm.Message) (string, error) {
return func(ctx context.Context, messages []llm.Message) (string, error) {
var b strings.Builder
for _, m := range messages {
b.WriteString(string(m.Role))
b.WriteString(": ")
b.WriteString(m.Content)
b.WriteString("\n")
if b.Len() > 120_000 {
break
}
}
prompt := "把以下智能体对话历史压缩成摘要:保留全部关键事实、已做决定、文件变更与未决问题;中文;不超过 800 字;只输出摘要正文。\n\n" + b.String()
var out strings.Builder
for ev := range stream(ctx, model, []llm.Message{{Role: llm.RoleUser, Content: prompt}}, nil, llm.StreamOptions{}) {
switch ev.Kind {
case llm.KindTextDelta:
out.WriteString(ev.Text)
case llm.KindError:
return "", ev.Err
}
}
if strings.TrimSpace(out.String()) == "" {
return "", errString("summarizer produced empty output")
}
return out.String(), nil
}
}
type errString string
func (e errString) Error() string { return string(e) }
// resumeUnsafeMarkers 续跑不安全的关键词(会话已被污染:上下文溢出/
// 迭代上限/非法请求——重试必须换新会话而非续跑)。
var resumeUnsafeMarkers = []string{
@@ -224,24 +453,43 @@ func (b *Backend) rotateIfPoisoned(workDir string) {
if _, err := os.Stat(path); err != nil {
return
}
b.logger().Warn("rotating poisoned session", "work_dir", workDir)
_ = os.Rename(path, filepath.Join(workDir, ".agentx",
"transcript.poisoned."+time.Now().UTC().Format("20060102T150405.000000000")+".jsonl"))
}
// systemPrompt 组装系统提示:角色指令 + 交付物与工具档位约束。
func systemPrompt(req exec.Request, profile string) string {
var b strings.Builder
if strings.TrimSpace(req.Instructions) != "" {
b.WriteString(req.Instructions)
b.WriteString("\n\n")
func (b *Backend) logger() *logsys.Logger {
if b == nil || b.opts.Log == nil {
return logsys.Nop()
}
b.WriteString("Deliverable files: ")
b.WriteString(strings.Join(req.Targets, ", "))
b.WriteString(".")
if profile == exec.ToolProfileWorkspace {
b.WriteString(" Use workspace tools to write those files. Do not invent paths outside the workspace.")
} else {
b.WriteString(" Reply with the deliverable text only. Tools are disabled.")
log := b.opts.Log.Module("native")
if b.opts.ExecutionID == "" {
return log
}
return b.String()
return log.With("execution_id", b.opts.ExecutionID)
}
// systemPrompt 经 promptx 组装:角色指令 + 环境事实 + 技能索引 + 交付物约束。
func (b *Backend) systemPrompt(req exec.Request, model, profile, workDir string, skillIndex []skills.Skill) string {
var index []promptx.IndexEntry
for _, s := range skillIndex {
index = append(index, promptx.IndexEntry{Name: s.Name, Description: s.Description})
}
now := time.Now()
weekday := map[string]string{
"Sunday": "周日", "Monday": "周一", "Tuesday": "周二", "Wednesday": "周三",
"Thursday": "周四", "Friday": "周五", "Saturday": "周六",
}[now.Weekday().String()]
return promptx.Compose(promptx.Parts{
Instructions: req.Instructions,
SkillsDeclared: req.Skills,
SkillsIndex: index,
Deliverables: req.Targets,
Env: promptx.EnvFacts{
Date: now.Format("2006-01-02") + "(" + weekday + ")",
Model: model,
WorkspaceRoot: workDir,
Profile: profile,
},
})
}
+72
View File
@@ -0,0 +1,72 @@
package native
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"agent-native/exec"
"agent-native/runtime/llm"
)
func TestBackendCompactBeforeRun(t *testing.T) {
dir := t.TempDir()
// 预置一份超阈值的长转录(系统消息 + 12 条历史)。
transcript := filepath.Join(dir, ".agentx", "transcript.jsonl")
if err := os.MkdirAll(filepath.Dir(transcript), 0o755); err != nil {
t.Fatal(err)
}
var b strings.Builder
b.WriteString(`{"role":"system","content":"sys"}` + "\n")
for i := 0; i < 12; i++ {
b.WriteString(`{"role":"user","content":"` + strings.Repeat("x", 400) + `"}` + "\n")
}
if err := os.WriteFile(transcript, []byte(b.String()), 0o644); err != nil {
t.Fatal(err)
}
var sawSummary bool
// 注入式捕获:验证 Run 收到的消息里有摘要标记。
bk2 := New(Options{
Model: "fake",
Stream: captureFirstStream(&sawSummary),
Compact: &CompactConfig{ThresholdChars: 1000, KeepLastN: 3, Summarizer: func(ctx context.Context, messages []llm.Message) (string, error) {
return "预置历史摘要", nil
}},
})
res, err := bk2.Execute(context.Background(), exec.Request{
WorkDir: dir, Prompt: "继续", Targets: []string{"output.md"},
ToolProfile: exec.ToolProfileReasoning,
})
if err != nil {
t.Fatal(err)
}
if res.Success && !sawSummary {
t.Fatal("compaction should have run before the loop")
}
data, _ := os.ReadFile(transcript)
if !strings.Contains(string(data), "预置历史摘要") || !strings.Contains(string(data), `"type":"compaction"`) {
t.Fatalf("transcript should keep history and append a compaction entry, got:\n%.400s", data)
}
if !strings.Contains(string(data), `"role":"system"`) {
t.Fatal("compaction must not rewrite the original transcript")
}
}
func captureFirstStream(saw *bool) llm.StreamFunc {
return func(ctx context.Context, model string, messages []llm.Message, decls []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
for _, m := range messages {
if strings.Contains(m.Content, "[conversation summary]") {
*saw = true
}
}
ch := make(chan llm.Event, 4)
go func() {
defer close(ch)
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{Role: llm.RoleAssistant, Content: "压缩后续写完成", StopReason: llm.StopEnd}}
}()
return ch
}
}
+342
View File
@@ -0,0 +1,342 @@
package native
import (
"context"
"os"
osexec "os/exec"
"path/filepath"
"strings"
"testing"
"agent-native/exec"
"agent-native/runtime/llm"
"agent-native/runtime/mcp"
"agent-native/runtime/subagent"
"agent-native/runtime/tools"
)
func TestBackendExecToolsWiredWithOptionsAndRequestEnv(t *testing.T) {
bash, err := osexec.LookPath("bash")
if err != nil {
t.Fatalf("bash not available: %v", err)
}
dir := t.TempDir()
b := New(Options{
Model: "fake",
Stream: script(
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
ID: "c1", Name: "run_command",
Arguments: `{"command":"echo role=$ROLE_FLAG step=$STEP_FLAG > exec_out.txt"}`,
}}},
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
ID: "c2", Name: "write_file",
Arguments: `{"path":"output.md","content":"done"}`,
}}},
llm.Message{Role: llm.RoleAssistant, Content: "ok", StopReason: llm.StopEnd},
),
Exec: &tools.ExecConfig{Bash: bash, Env: []string{"PATH=/usr/bin:/bin", "ROLE_FLAG=backend"}},
})
res, err := b.Execute(context.Background(), exec.Request{
WorkDir: dir, Prompt: "run", Targets: []string{"output.md"},
Env: map[string]string{"STEP_FLAG": "request"},
})
if err != nil || !res.Success {
t.Fatalf("execute: err=%v res=%+v logs=%s", err, res, res.Logs)
}
data, err := os.ReadFile(filepath.Join(dir, "exec_out.txt"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), "role=backend") || !strings.Contains(string(data), "step=request") {
t.Fatalf("env merge wrong: %q", data)
}
}
func TestBackendExecToolsAbsentWithoutOptions(t *testing.T) {
stream, c := captureStream()
b := New(Options{Model: "fake", Stream: stream})
res, err := b.Execute(context.Background(), exec.Request{
WorkDir: t.TempDir(), Prompt: "x", Targets: []string{"output.md"},
ToolProfile: exec.ToolProfileReasoning,
})
if err != nil || !res.Success {
t.Fatalf("execute: err=%v res=%+v", err, res)
}
// reasoning 档本无工具;改用 workspace 档断言 decls 不含执行工具。
stream2, c2 := captureStream()
b2 := New(Options{Model: "fake", Stream: stream2})
_, _ = b2.Execute(context.Background(), exec.Request{
WorkDir: t.TempDir(), Prompt: "x", Targets: []string{"output.md"},
ToolProfile: exec.ToolProfileWorkspace,
})
for _, names := range c2.toolDecls {
for _, n := range names {
if n == "run_command" || n == "run_python" {
t.Fatalf("exec tools must not register without Options.Exec, got %v", names)
}
}
}
_ = c
}
func TestBackendAllowlistCanDisableExecTools(t *testing.T) {
bash, _ := osexec.LookPath("bash")
stream, c := captureStream()
b := New(Options{
Model: "fake", Stream: stream,
Exec: &tools.ExecConfig{Bash: bash, Env: []string{"PATH=/usr/bin:/bin"}},
})
res, err := b.Execute(context.Background(), exec.Request{
WorkDir: t.TempDir(), Prompt: "x", Targets: []string{"output.md"},
ToolAllowlist: []string{"read_file", "write_file", "edit_file", "list_dir", "grep", "glob", "multi_edit"},
})
if err != nil {
t.Fatal(err)
}
if res.Success {
t.Fatalf("write-only allowlist without write? (unexpected) res=%+v", res)
}
for _, names := range c.toolDecls {
for _, n := range names {
if n == "run_command" {
t.Fatalf("allowlist must be able to strip run_command, got %v", names)
}
}
}
}
// fakeSearchProvider 与 tools 包测试同构的最小搜索替身。
type fakeSearchProvider struct{ query string }
func (f *fakeSearchProvider) Search(_ context.Context, req tools.SearchRequest) (tools.SearchResponse, error) {
f.query = req.Query
return tools.SearchResponse{Answer: "ans", Results: []tools.SearchResult{{Title: "T", Content: "C", Score: 0.9}}}, nil
}
func TestBackendSearchToolsWiredViaOptions(t *testing.T) {
provider := &fakeSearchProvider{}
stream, c := captureStream(
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
ID: "c1", Name: "web_search", Arguments: `{"query":"golang generics"}`,
}}},
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
ID: "c2", Name: "write_file",
Arguments: `{"path":"output.md","content":"done"}`,
}}},
llm.Message{Role: llm.RoleAssistant, Content: "ok", StopReason: llm.StopEnd},
)
b := New(Options{Model: "fake", Stream: stream, Search: provider})
dir := t.TempDir()
res, err := b.Execute(context.Background(), exec.Request{
WorkDir: dir, Prompt: "search", Targets: []string{"output.md"},
})
if err != nil || !res.Success {
t.Fatalf("execute: err=%v res=%+v logs=%s", err, res, res.Logs)
}
if provider.query != "golang generics" {
t.Fatalf("provider should receive the query, got %q", provider.query)
}
// 搜索工具确实出现在 decls 里。
found := false
for _, names := range c.toolDecls {
for _, n := range names {
if n == "web_search" || n == "tavily_search" {
found = true
}
}
}
if !found {
t.Fatal("search tools must be declared when Options.Search is set")
}
}
func TestBackendSearchToolsAbsentWithoutOptions(t *testing.T) {
stream, c := captureStream()
b := New(Options{Model: "fake", Stream: stream})
_, _ = b.Execute(context.Background(), exec.Request{
WorkDir: t.TempDir(), Prompt: "x", Targets: []string{"output.md"},
ToolProfile: exec.ToolProfileWorkspace,
})
for _, names := range c.toolDecls {
for _, n := range names {
if n == "web_search" || n == "tavily_search" {
t.Fatalf("search tools must not register without Options.Search, got %v", names)
}
}
}
}
func TestBackendMCPToolsWiredViaOptions(t *testing.T) {
exe, err := osexec.LookPath(os.Args[0])
if err != nil {
t.Fatal(err)
}
// os.Args[0] 在 go test 下指向测试二进制——用其自身作为 MCP server。
mgr, err := mcp.NewManager([]mcp.ServerConfig{{
Name: "fake", Command: exe,
Env: append(os.Environ(), "AGENT_NATIVE_TEST_MCP_STDIO=1"),
}})
if err != nil {
t.Fatal(err)
}
defer mgr.Close()
stream, c := captureStream(
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
ID: "c1", Name: "mcp__fake__echo", Arguments: `{"text":"via-backend"}`,
}}},
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
ID: "c2", Name: "write_file",
Arguments: `{"path":"output.md","content":"done"}`,
}}},
llm.Message{Role: llm.RoleAssistant, Content: "ok", StopReason: llm.StopEnd},
)
b := New(Options{Model: "fake", Stream: stream, MCP: mgr})
dir := t.TempDir()
res, err2 := b.Execute(context.Background(), exec.Request{
WorkDir: dir, Prompt: "call mcp", Targets: []string{"output.md"},
})
if err2 != nil || !res.Success {
t.Fatalf("execute: err=%v res=%+v logs=%s", err2, res, res.Logs)
}
found := false
for _, names := range c.toolDecls {
for _, n := range names {
if n == "mcp__fake__echo" {
found = true
}
}
}
if !found {
t.Fatal("mcp bridge tools must be declared to the model")
}
}
func TestBackendSkillsIndexAndLoader(t *testing.T) {
skillsRoot := t.TempDir()
skillDir := filepath.Join(skillsRoot, "docx-author")
if err := os.MkdirAll(skillDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\ndescription: 生成文档交付物\n---\n\n配方正文"), 0o644); err != nil {
t.Fatal(err)
}
stream, c := captureStream(
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
ID: "c1", Name: "load_skill", Arguments: `{"name":"docx-author"}`,
}}},
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
ID: "c2", Name: "write_file", Arguments: `{"path":"output.md","content":"done"}`,
}}},
llm.Message{Role: llm.RoleAssistant, Content: "ok", StopReason: llm.StopEnd},
)
b := New(Options{Model: "fake", Stream: stream, SkillsRoots: []string{skillsRoot}})
dir := t.TempDir()
res, err := b.Execute(context.Background(), exec.Request{
WorkDir: dir, Prompt: "写", Targets: []string{"output.md"}, Skills: []string{"docx-author"},
})
if err != nil || !res.Success {
t.Fatalf("execute: err=%v res=%+v logs=%s", err, res, res.Logs)
}
if !strings.Contains(c.system, "- docx-author — 生成文档交付物") {
t.Fatalf("skill index missing in system prompt:\n%s", c.system)
}
if !strings.Contains(c.system, "Declared for this task") || !strings.Contains(c.system, "docx-author") {
t.Fatalf("declared skills missing:\n%s", c.system)
}
sawLoader := false
for _, names := range c.toolDecls {
for _, n := range names {
if n == "load_skill" {
sawLoader = true
}
}
}
if !sawLoader {
t.Fatal("load_skill must be declared when skills exist")
}
}
func TestBackendDeclaredSkillMissingFailsClosed(t *testing.T) {
b := New(Options{Model: "fake", Stream: script(), SkillsRoots: []string{t.TempDir()}})
res, err := b.Execute(context.Background(), exec.Request{
WorkDir: t.TempDir(), Prompt: "x", Targets: []string{"output.md"},
Skills: []string{"ghost-skill"},
})
if err != nil {
t.Fatal(err)
}
if res.Success || !strings.Contains(res.ErrorReason, "declared skill not installed: ghost-skill") {
t.Fatalf("missing declared skill must fail-closed, got %+v", res)
}
}
func TestBackendWorkspaceSkillsRootAutoMerged(t *testing.T) {
stream, c := captureStream(
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
ID: "c1", Name: "write_file", Arguments: `{"path":"output.md","content":"done"}`,
}}},
llm.Message{Role: llm.RoleAssistant, Content: "ok", StopReason: llm.StopEnd},
)
b := New(Options{Model: "fake", Stream: stream})
dir := t.TempDir()
wsSkill := filepath.Join(dir, ".agent", "skills", "ws-skill")
if err := os.MkdirAll(wsSkill, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(wsSkill, "SKILL.md"), []byte("---\ndescription: 工作区技能\n---\n正文"), 0o644); err != nil {
t.Fatal(err)
}
res, err := b.Execute(context.Background(), exec.Request{
WorkDir: dir, Prompt: "x", Targets: []string{"output.md"},
})
if err != nil || !res.Success {
t.Fatalf("execute: err=%v res=%+v", err, res)
}
if !strings.Contains(c.system, "ws-skill") {
t.Fatalf("workspace skills root must be merged into index:\n%s", c.system)
}
}
func TestBackendSubagentWiredViaOptions(t *testing.T) {
var delegated *subagent.Request
stream, c := captureStream(
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
ID: "c1", Name: "delegate_task",
Arguments: `{"goal":"查证数据","write_scope":"research/","done_criteria":"三来源"}`,
}}},
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
ID: "c2", Name: "write_file",
Arguments: `{"path":"output.md","content":"done"}`,
}}},
llm.Message{Role: llm.RoleAssistant, Content: "ok", StopReason: llm.StopEnd},
)
b := New(Options{
Model: "fake",
Stream: stream,
Subagent: func(ctx context.Context, req subagent.Request) (subagent.Result, error) {
delegated = &req
return subagent.Result{Success: true, Text: "结论已查明"}, nil
},
})
dir := t.TempDir()
res, err := b.Execute(context.Background(), exec.Request{
WorkDir: dir, Prompt: "分工", Targets: []string{"output.md"},
})
if err != nil || !res.Success {
t.Fatalf("execute: err=%v res=%+v logs=%s", err, res, res.Logs)
}
if delegated == nil || delegated.Goal != "查证数据" {
t.Fatalf("factory not invoked with fields: %+v", delegated)
}
sawDelegate := false
for _, names := range c.toolDecls {
for _, n := range names {
if n == "delegate_task" {
sawDelegate = true
}
}
}
if !sawDelegate {
t.Fatal("delegate_task must be declared when Subagent factory is set")
}
}
+146
View File
@@ -0,0 +1,146 @@
package native
import (
"context"
"encoding/json"
"sync"
"testing"
"agent-native/exec"
"agent-native/runtime/llm"
"agent-native/runtime/tools"
)
type hostTool struct{ name string }
func (h hostTool) Name() string { return h.name }
func (h hostTool) Description() string { return "host tool" }
func (h hostTool) Parameters() json.RawMessage {
return json.RawMessage(`{"type":"object","properties":{"path":{"type":"string"}}}`)
}
func (h hostTool) Execute(ctx context.Context, args json.RawMessage) (tools.Result, error) {
return tools.Result{Content: "registered " + h.name}, nil
}
func TestHostToolAndUsageEvent(t *testing.T) {
var events []exec.ProcessEvent
b := New(Options{
Model: "fake",
HostTools: []tools.Tool{hostTool{name: "register_artifact"}},
Stream: usageScript(),
})
res, err := b.Execute(context.Background(), exec.Request{
WorkDir: t.TempDir(), Prompt: "登记",
OnEvent: func(ev exec.ProcessEvent) { events = append(events, ev) },
})
if err != nil || !res.Success {
t.Fatalf("execute: err=%v res=%+v logs=%s", err, res, logsOf(res))
}
sawText, sawStart, sawEnd, sawUsage := false, false, false, false
for _, ev := range events {
switch ev.Type {
case "text_delta":
if ev.Text != "" {
sawText = true
}
case "tool_execution_start":
if ev.Tool == "register_artifact" {
sawStart = true
}
case "tool_execution_end":
if ev.Tool == "register_artifact" {
sawEnd = true
}
case "usage":
if ev.PromptTokens == 11 && ev.CompletionTokens == 7 {
sawUsage = true
}
}
}
if !sawText || !sawStart || !sawEnd || !sawUsage {
t.Fatalf("events missing text/tool/usage: %+v", events)
}
}
func TestHostToolSeesStepWorkDir(t *testing.T) {
var seen string
b := New(Options{
Model: "fake",
Stream: script(llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{ID: "c1", Name: "peek", Arguments: `{}`}}}),
HostTools: []tools.Tool{peekTool{got: &seen}},
})
dir := t.TempDir()
_, err := b.Execute(context.Background(), exec.Request{WorkDir: dir, Prompt: "x", Targets: []string{"output.md"}})
if err != nil {
t.Fatal(err)
}
if seen != dir {
t.Fatalf("workdir = %q, want %s", seen, dir)
}
}
type peekTool struct{ got *string }
func (p peekTool) Name() string { return "peek" }
func (p peekTool) Description() string { return "peek" }
func (p peekTool) Parameters() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
func (p peekTool) Execute(ctx context.Context, args json.RawMessage) (tools.Result, error) {
*p.got = WorkDir(ctx)
return tools.Result{Content: "ok"}, nil
}
func TestHostToolNameConflictFailsClosed(t *testing.T) {
b := New(Options{
Model: "fake",
Stream: script(llm.Message{Role: llm.RoleAssistant, Content: "x", StopReason: llm.StopEnd}),
HostTools: []tools.Tool{hostTool{name: "read_file"}},
})
res, err := b.Execute(context.Background(), exec.Request{
WorkDir: t.TempDir(), Prompt: "x", Targets: []string{"output.md"},
})
if err != nil {
t.Fatal(err)
}
if res.Success || res.ErrorReason == "" {
t.Fatalf("conflict must fail closed, got %+v", res)
}
}
func usageScript() llm.StreamFunc {
var mu sync.Mutex
step := 0
return func(ctx context.Context, model string, messages []llm.Message, td []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
ch := make(chan llm.Event, 4)
go func() {
defer close(ch)
mu.Lock()
defer mu.Unlock()
step++
switch step {
case 1:
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{
Role: llm.RoleAssistant, StopReason: llm.StopToolCalls,
ToolCalls: []llm.ToolCall{{ID: "c1", Name: "register_artifact", Arguments: `{"path":"a.docx"}`}},
}, Usage: llm.Usage{PromptTokens: 4, CompletionTokens: 2}}
case 2:
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{
Role: llm.RoleAssistant, StopReason: llm.StopToolCalls,
ToolCalls: []llm.ToolCall{{ID: "c2", Name: "write_file", Arguments: `{"path":"output.md","content":"done"}`}},
}, Usage: llm.Usage{PromptTokens: 4, CompletionTokens: 2}}
default:
ch <- llm.Event{Kind: llm.KindTextDelta, Text: "已登记"}
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{
Role: llm.RoleAssistant, Content: "已登记", StopReason: llm.StopEnd,
}, Usage: llm.Usage{PromptTokens: 3, CompletionTokens: 3}}
}
}()
return ch
}
}
func logsOf(res *exec.Result) string {
if res == nil {
return ""
}
return res.Logs
}
+17
View File
@@ -0,0 +1,17 @@
package native
import (
"os"
"testing"
"agent-native/runtime/mcp"
)
// TestMain:MCP fake server 的 re-exec 分支(详见 mcp.RunFakeServer)。
func TestMain(m *testing.M) {
if os.Getenv(mcp.FakeServerEnv) == "1" {
mcp.RunFakeServer()
os.Exit(0)
}
os.Exit(m.Run())
}
+65
View File
@@ -0,0 +1,65 @@
package native
import (
"context"
"encoding/json"
"os"
"os/exec"
"testing"
"agent-native/rpc"
"agent-native/runtime"
"agent-native/runtime/llm"
"agent-native/runtime/session"
)
func TestEventAndRPCFixtures(t *testing.T) {
var types []string
agent := runtime.NewAgentSession(runtime.Config{
Model: "fake",
Stream: func(ctx context.Context, model string, messages []llm.Message, td []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
ch := make(chan llm.Event, 1)
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{Role: llm.RoleAssistant, Content: "ok", StopReason: llm.StopEnd}}
close(ch)
return ch
},
OnEvent: func(ev runtime.Event) { types = append(types, ev.Type) },
})
if _, err := agent.Prompt(context.Background(), llm.Message{Role: llm.RoleUser, Content: "hi"}); err != nil {
t.Fatal(err)
}
raw, _ := json.Marshal(types)
for _, want := range []string{"message_start", "message_end", "agent_settled"} {
if !contains(types, want) {
t.Fatalf("fixture missing %s in %s", want, raw)
}
}
dir := t.TempDir()
store, err := session.Open(dir + "/s.jsonl")
if err != nil {
t.Fatal(err)
}
srv := &rpc.Server{Agent: agent, Store: store}
resp, err := srv.Handle(context.Background(), []byte(`{"type":"get_session_stats"}`))
if err != nil || !json.Valid(resp) {
t.Fatalf("rpc fixture: %s %v", resp, err)
}
}
func TestPiCrossCompareOptional(t *testing.T) {
if _, err := exec.LookPath("pi"); err != nil {
t.Skip("pi is not installed")
}
if os.Getenv("PI_CROSS") != "1" {
t.Skip("set PI_CROSS=1 to compare a live pi process")
}
}
func contains(list []string, want string) bool {
for _, item := range list {
if item == want {
return true
}
}
return false
}
+183
View File
@@ -0,0 +1,183 @@
package native
import (
"context"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"agent-native/exec"
"agent-native/runtime/llm"
)
// captureStream 捕获每次调用的 model / 首条 system 消息,并按序回放回合。
func captureStream(turns ...llm.Message) (llm.StreamFunc, *capture) {
c := &capture{}
var mu sync.Mutex
i := 0
return func(ctx context.Context, model string, messages []llm.Message, tools []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
ch := make(chan llm.Event, 4)
go func() {
defer close(ch)
mu.Lock()
defer mu.Unlock()
c.models = append(c.models, model)
c.toolDecls = append(c.toolDecls, toolNames(tools))
if len(messages) > 0 && messages[0].Role == llm.RoleSystem {
c.system = messages[0].Content
}
if i >= len(turns) {
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd}}
return
}
msg := turns[i]
i++
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: msg}
}()
return ch
}, c
}
type capture struct {
mu sync.Mutex
models []string
toolDecls [][]string
system string
}
func toolNames(decls []llm.ToolDecl) []string {
var out []string
for _, d := range decls {
out = append(out, d.Name)
}
return out
}
func TestBackendRequestModelOverride(t *testing.T) {
stream, c := captureStream()
b := New(Options{Model: "default-model", Stream: stream})
dir := t.TempDir()
res, err := b.Execute(context.Background(), exec.Request{
WorkDir: dir, Prompt: "hi", Targets: []string{"output.md"}, Model: "override-model",
ToolProfile: exec.ToolProfileReasoning,
})
if err != nil || !res.Success {
t.Fatalf("execute: err=%v res=%+v", err, res)
}
if len(c.models) == 0 || c.models[0] != "override-model" {
t.Fatalf("want override-model, got %v", c.models)
}
}
func TestBackendRequestToolAllowlist(t *testing.T) {
stream, c := captureStream(
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
ID: "c1", Name: "write_file", Arguments: `{"path":"output.md","content":"blocked"}`,
}}},
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
ID: "c2", Name: "write_file", Arguments: `{"path":"output2.md","content":"still blocked"}`,
}}},
llm.Message{Role: llm.RoleAssistant, Content: "gave up", StopReason: llm.StopEnd},
)
b := New(Options{Model: "fake", Stream: stream})
dir := t.TempDir()
// 目标 output2.md 不在 allowlist 内能写吗?——write_file 被整体收窄,
// 模型两次尝试都会得到 unknown tool,最终无交付物 → 失败。
res, err := b.Execute(context.Background(), exec.Request{
WorkDir: dir, Prompt: "写文件", Targets: []string{"output2.md"},
ToolAllowlist: []string{"read_file", "list_dir", "grep"},
})
if err != nil {
t.Fatal(err)
}
if res.Success {
t.Fatalf("write_file restricted out; deliverable must fail, got %+v", res)
}
if len(c.toolDecls) == 0 {
t.Fatal("expected tool decls")
}
for _, names := range c.toolDecls {
for _, n := range names {
if n == "write_file" || n == "edit_file" {
t.Fatalf("write/edit must be restricted out of decls, got %v", names)
}
}
}
}
func TestBackendRequestUnknownAllowlistFailsClosed(t *testing.T) {
b := New(Options{Model: "fake", Stream: script()})
res, err := b.Execute(context.Background(), exec.Request{
WorkDir: t.TempDir(), Prompt: "x", Targets: []string{"output.md"},
ToolAllowlist: []string{"read_file", "no_such_tool"},
})
if err != nil {
t.Fatal(err)
}
if res.Success || !strings.Contains(res.ErrorReason, "no_such_tool") {
t.Fatalf("want fail-closed unknown allowlist name, got %+v", res)
}
}
func TestBackendRequestBudgetDeadlineBindsContext(t *testing.T) {
deadlineHit := make(chan struct{})
var once sync.Once
stream := func(ctx context.Context, model string, messages []llm.Message, tools []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
ch := make(chan llm.Event, 4)
go func() {
defer close(ch)
select {
case <-ctx.Done():
once.Do(func() { close(deadlineHit) })
ch <- llm.Event{Kind: llm.KindError, Err: ctx.Err(), Message: llm.Message{StopReason: llm.StopAborted}}
case <-time.After(3 * time.Second):
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd}}
}
}()
return ch
}
b := New(Options{Model: "fake", Stream: stream})
start := time.Now()
res, err := b.Execute(context.Background(), exec.Request{
WorkDir: t.TempDir(), Prompt: "x", Targets: []string{"output.md"},
Timeout: 30 * time.Second, // 长超时,预算截止应更先生效
Budget: &exec.Budget{Deadline: time.Now().Add(150 * time.Millisecond)},
})
if err != nil {
t.Fatal(err)
}
select {
case <-deadlineHit:
case <-time.After(2 * time.Second):
t.Fatal("budget deadline should bind the run context")
}
if res.Success {
t.Fatalf("deadline run must not succeed, got %+v", res)
}
if elapsed := time.Since(start); elapsed > 5*time.Second {
t.Fatalf("deadline should fire early, took %s", elapsed)
}
}
func TestBackendRequestExecutionIDInLogs(t *testing.T) {
stream, _ := captureStream()
b := New(Options{Model: "fake", Stream: stream})
dir := t.TempDir()
res, err := b.Execute(context.Background(), exec.Request{
WorkDir: dir, Prompt: "hi", Targets: []string{"output.md"},
Instructions: "你是角色",
ExecutionID: "exec-42", SessionScope: "run-9/step-3",
ToolProfile: exec.ToolProfileReasoning,
})
if err != nil || !res.Success {
t.Fatalf("execute: err=%v res=%+v", err, res)
}
logBytes, _ := os.ReadFile(filepath.Join(dir, "execution.log"))
logs := string(logBytes)
if !strings.Contains(logs, "execution_id=exec-42") || !strings.Contains(logs, "run-9/step-3") {
t.Fatalf("execution log should carry ids, got:\n%s", logs)
}
}
+11 -11
View File
@@ -7,11 +7,11 @@ import (
)
type remoteRunWire struct {
OK bool `json:"ok"`
RunID string `json:"run_id"`
FallbackReason string `json:"fallback_reason"`
Retryable bool `json:"retryable"`
RunnerProtocol string `json:"runner_protocol"`
OK bool `json:"ok"`
RunID string `json:"run_id"`
FallbackReason string `json:"fallback_reason"`
Retryable bool `json:"retryable"`
RunnerProtocol string `json:"runner_protocol"`
Capabilities *struct {
TypedResult bool `json:"typed_result"`
} `json:"capabilities"`
@@ -23,7 +23,7 @@ type remoteRunWire struct {
}
type decodedRemote struct {
OK bool
OK bool
Retryable bool
Reason string
Incoming map[string][]byte
@@ -91,17 +91,17 @@ func safeLegacyReason(reason string) string {
case r == "":
return "remote pi-agent reported task failure"
case strings.EqualFold(r, "prompt_empty"):
return "PROMPT_EMPTY"
return "agent.prompt_empty"
case strings.Contains(strings.ToLower(r), "unauthorized"):
return "UNAUTHORIZED"
return "agent.unauthorized"
case strings.Contains(strings.ToLower(r), "timeout"):
return "TIMEOUT"
return "agent.timeout"
default:
if strings.ContainsAny(r, "/\\") || strings.Contains(strings.ToLower(r), "token") {
return "RUN_FAILED"
return "agent.run_failed"
}
if len(r) > 64 {
return "RUN_FAILED"
return "agent.run_failed"
}
return r
}
+12 -10
View File
@@ -20,20 +20,22 @@ const (
var ErrTypedResult = fmt.Errorf("pitransport: typed result_v1 rejected")
// 对外错误码与 Error-Code-System 的 agent 域一致。
// CANCELLED 并入 agent.canceled,MISSING_TARGET 并入 agent.missing_deliverable。
var allowedErrorCodes = map[string]struct{}{
"PROMPT_EMPTY": {},
"UNAUTHORIZED": {},
"TIMEOUT": {},
"CANCELLED": {},
"TRANSIENT": {},
"MISSING_TARGET": {},
"RUN_FAILED": {},
"agent.prompt_empty": {},
"agent.unauthorized": {},
"agent.timeout": {},
"agent.canceled": {},
"agent.transient": {},
"agent.missing_deliverable": {},
"agent.run_failed": {},
}
var retryableErrorCodes = map[string]struct{}{
"TIMEOUT": {},
"CANCELLED": {},
"TRANSIENT": {},
"agent.timeout": {},
"agent.canceled": {},
"agent.transient": {},
}
type ResultV1Member struct {
+4 -4
View File
@@ -63,13 +63,13 @@ func TestResultV1RejectsMissingMemberBody(t *testing.T) {
}
func TestTypedRetryableAllowlist(t *testing.T) {
ok := BuildResultV1("r", "error", true, "TRANSIENT", nil)
ok := BuildResultV1("r", "error", true, "agent.transient", nil)
if !TypedRetryable(ok) {
t.Fatal("TRANSIENT retryable")
t.Fatal("agent.transient retryable")
}
denied := BuildResultV1("r", "error", true, "PROMPT_EMPTY", nil)
denied := BuildResultV1("r", "error", true, "agent.prompt_empty", nil)
if TypedRetryable(denied) {
t.Fatal("PROMPT_EMPTY must not retry")
t.Fatal("agent.prompt_empty must not retry")
}
}
+17 -17
View File
@@ -8,35 +8,35 @@ export const MAX_RESULT_WIRE_BYTES = 6 * 1024 * 1024;
/** Tail window for execution.log patches; full log remains on shared volume. */
export const EXECUTION_LOG_PATCH_BYTES = 128 * 1024;
const RETRYABLE_CODES = new Set(["TIMEOUT", "CANCELLED", "TRANSIENT"]);
const RETRYABLE_CODES = new Set(["agent.timeout", "agent.canceled", "agent.transient"]);
const ALLOWED_CODES = new Set([
"PROMPT_EMPTY",
"UNAUTHORIZED",
"TIMEOUT",
"CANCELLED",
"TRANSIENT",
"MISSING_TARGET",
"RUN_FAILED",
"agent.prompt_empty",
"agent.unauthorized",
"agent.timeout",
"agent.canceled",
"agent.transient",
"agent.missing_deliverable",
"agent.run_failed",
]);
export function typedErrorCode(payload) {
const raw = String(payload?.fallback_reason || payload?.error || "");
const t = raw.toLowerCase();
if (t.includes("prompt_empty")) return "PROMPT_EMPTY";
if (t.includes("unauthorized")) return "UNAUTHORIZED";
if (t.includes("timeout") || t.includes("timed out")) return "TIMEOUT";
if (t.includes("cancel")) return "CANCELLED";
if (t.includes("prompt_empty")) return "agent.prompt_empty";
if (t.includes("unauthorized")) return "agent.unauthorized";
if (t.includes("timeout") || t.includes("timed out")) return "agent.timeout";
if (t.includes("cancel")) return "agent.canceled";
if (
t.includes("connection") ||
t.includes("terminated") ||
t.includes("econnreset") ||
t.includes("socket hang")
) {
return "TRANSIENT";
return "agent.transient";
}
if (t.includes("missing target")) return "MISSING_TARGET";
if (payload?.retryable) return "TRANSIENT";
if (!payload?.ok) return "RUN_FAILED";
if (t.includes("missing target")) return "agent.missing_deliverable";
if (payload?.retryable) return "agent.transient";
if (!payload?.ok) return "agent.run_failed";
return "";
}
@@ -117,7 +117,7 @@ export function withResultV1(payload) {
run_id: String(out.run_id || "unknown").slice(0, 128),
outcome: ok ? "success" : "error",
retryable,
error_code: ALLOWED_CODES.has(errorCode) ? errorCode : ok ? "" : "RUN_FAILED",
error_code: ALLOWED_CODES.has(errorCode) ? errorCode : ok ? "" : "agent.run_failed",
members,
content_digest: membersDigest(members),
};
+34
View File
@@ -0,0 +1,34 @@
package catalogcheck
import (
"testing"
"agent-native/contract"
"error-code-system/errcode"
)
// 常量取值必须与错误码库种子码相同。旧短字符串只作为错误码库里的别名存在。
func TestContractCodesMatchCatalog(t *testing.T) {
pairs := []struct {
got contract.ErrorCode
want errcode.Code
}{
{contract.ErrCanceled, errcode.AgentCanceled},
{contract.ErrDeadlineExceeded, errcode.AgentDeadlineExceeded},
{contract.ErrBudgetExceeded, errcode.AgentBudgetExceeded},
{contract.ErrProviderUnavailable, errcode.AgentProviderUnavailable},
{contract.ErrProviderRejected, errcode.AgentProviderRejected},
{contract.ErrToolRejected, errcode.AgentToolRejected},
{contract.ErrPathOutsideWorkspace, errcode.AgentPathOutsideWorkspace},
{contract.ErrMissingCapability, errcode.AgentMissingCapability},
{contract.ErrMissingDeliverable, errcode.AgentMissingDeliverable},
{contract.ErrEmptyOutput, errcode.AgentEmptyOutput},
{contract.ErrUnknownOutcome, errcode.AgentUnknownOutcome},
{contract.ErrInternal, errcode.AgentInternal},
}
for _, pair := range pairs {
if string(pair.got) != string(pair.want) {
t.Errorf("contract %q != catalog %q", pair.got, pair.want)
}
}
}
+119
View File
@@ -0,0 +1,119 @@
package main
import (
"bufio"
"context"
"encoding/json"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"agent-native/rpc"
"agent-native/runtime"
"agent-native/runtime/llm"
"agent-native/runtime/session"
)
func main() {
mode := flag.String("mode", "print", "print, json, or rpc")
model := flag.String("model", "", "provider/model[:thinking]")
prompt := flag.String("prompt", "", "user message for print and json modes")
sessionPath := flag.String("session", "", "transcript path")
noSession := flag.Bool("no-session", false, "do not read or write a transcript")
export := flag.String("export", "", "write the active session as HTML and exit")
flag.Parse()
if *sessionPath == "" && !*noSession {
home, _ := os.UserHomeDir()
*sessionPath = filepath.Join(home, ".agent-native", "session.jsonl")
}
var store *session.Store
if !*noSession {
st, err := session.Open(*sessionPath)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
store = st
}
if *export != "" {
if store == nil {
fmt.Fprintln(os.Stderr, "export requires a session")
os.Exit(1)
}
if err := store.ExportHTML(*export); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
return
}
ref, err := llm.ParseModelRef(*model)
if *model != "" && err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
agent := runtime.NewAgentSession(runtime.Config{
Model: ref.Model,
ThinkingLevel: ref.Thinking,
Session: store,
Stream: liveStream(ref),
OnEvent: func(ev runtime.Event) {
if *mode != "json" {
return
}
raw, _ := json.Marshal(map[string]any{"type": ev.Type, "text": ev.Text, "tool": ev.Tool})
fmt.Println(string(raw))
},
})
srv := &rpc.Server{Agent: agent, Store: store, NoStore: *noSession}
ctx := context.Background()
switch *mode {
case "rpc":
in := bufio.NewScanner(os.Stdin)
in.Buffer(make([]byte, 0, 64*1024), 8*1024*1024)
for in.Scan() {
resp, err := srv.Handle(ctx, in.Bytes())
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
os.Stdout.Write(resp)
}
case "print", "json":
if *prompt == "" {
*prompt = readAll(os.Stdin)
}
res, err := agent.Prompt(ctx, llm.Message{Role: llm.RoleUser, Content: *prompt})
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
if res.Err != nil {
fmt.Fprintln(os.Stderr, res.Err)
os.Exit(1)
}
if *mode == "print" {
fmt.Println(res.Text)
}
default:
fmt.Fprintln(os.Stderr, "unknown mode")
os.Exit(1)
}
}
func liveStream(ref llm.ModelRef) llm.StreamFunc {
provider, ok := llm.Lookup(ref.Provider, nil)
if !ok {
provider = llm.Provider{Protocol: llm.ProtocolOpenAIChat, BaseURL: os.Getenv("OPENAI_BASE_URL"), APIKey: os.Getenv("OPENAI_API_KEY")}
}
client := &llm.Client{BaseURL: provider.BaseURL, APIKey: provider.APIKey}
return llm.WithRetry(client.Stream, 2, nil)
}
func readAll(r io.Reader) string {
raw, _ := io.ReadAll(r)
return string(raw)
}
+56 -26
View File
@@ -52,8 +52,8 @@ const (
// ToolProfile 是预定义工具策略名;与现有产品配置兼容。
const (
ToolProfileWorkspace = "workspace" // 默认:文档类工具受限集合
ToolProfileReasoning = "reasoning" // 无工具,仅推理
ToolProfileWorkspace = "workspace" // 默认:文档类工具受限集合
ToolProfileReasoning = "reasoning" // 无工具,仅推理
)
// ToolPolicy 描述执行允许的工具范围。实现不得自行扩展;未知工具一律拒绝。
@@ -106,8 +106,8 @@ type Request struct {
// SessionScope 为隔离身份;可为零值(一次性任务)。
SessionScope SessionScope `json:"session_scope"`
Prompt string `json:"prompt"`
Instructions string `json:"instructions,omitempty"`
Prompt string `json:"prompt"`
Instructions string `json:"instructions,omitempty"`
// Context 为可选历史消息;实现不得将其与 Prompt 之外的数据混合。
Context []Message `json:"context,omitempty"`
@@ -143,12 +143,12 @@ const (
type EventKind string
const (
EventTextDelta EventKind = "text_delta"
EventReasoningDelta EventKind = "reasoning_delta"
EventToolCallDelta EventKind = "tool_call_delta"
EventToolExecStart EventKind = "tool_execution_start"
EventToolExecEnd EventKind = "tool_execution_end"
EventMessageEnd EventKind = "message_end"
EventTextDelta EventKind = "text_delta"
EventReasoningDelta EventKind = "reasoning_delta"
EventToolCallDelta EventKind = "tool_call_delta"
EventToolExecStart EventKind = "tool_execution_start"
EventToolExecEnd EventKind = "tool_execution_end"
EventMessageEnd EventKind = "message_end"
)
// Event 是过程事件帧;消费方(Core)负责映射为 SSE/持久化事件。
@@ -174,30 +174,60 @@ const (
type ErrorCode string
const (
ErrCanceled ErrorCode = "canceled"
ErrDeadlineExceeded ErrorCode = "deadline_exceeded"
ErrBudgetExceeded ErrorCode = "budget_exceeded"
ErrProviderUnavailable ErrorCode = "provider_unavailable"
ErrProviderRejected ErrorCode = "provider_rejected"
ErrToolRejected ErrorCode = "tool_rejected"
ErrPathOutsideWorkspace ErrorCode = "path_outside_workspace"
ErrMissingCapability ErrorCode = "missing_capability"
ErrMissingDeliverable ErrorCode = "missing_deliverable"
ErrEmptyOutput ErrorCode = "empty_output"
ErrUnknownOutcome ErrorCode = "unknown_outcome"
ErrInternal ErrorCode = "internal"
ErrCanceled ErrorCode = "agent.canceled"
ErrDeadlineExceeded ErrorCode = "agent.deadline_exceeded"
ErrBudgetExceeded ErrorCode = "agent.budget_exceeded"
ErrProviderUnavailable ErrorCode = "agent.provider_unavailable"
ErrProviderRejected ErrorCode = "agent.provider_rejected"
ErrToolRejected ErrorCode = "agent.tool_rejected"
ErrPathOutsideWorkspace ErrorCode = "agent.path_outside_workspace"
ErrMissingCapability ErrorCode = "agent.missing_capability"
ErrMissingDeliverable ErrorCode = "agent.missing_deliverable"
ErrEmptyOutput ErrorCode = "agent.empty_output"
ErrUnknownOutcome ErrorCode = "agent.unknown_outcome"
ErrInternal ErrorCode = "agent.internal"
)
// Error 是标准化执行错误。Retryable 仅表示传输级瞬时故障可由 adapter 有界
// 重试或调用方作为新步骤重试;与 SideEffects 独立判定。
type Error struct {
Code ErrorCode `json:"code"`
Reason string `json:"reason,omitempty"`
Retryable bool `json:"retryable,omitempty"`
Code ErrorCode `json:"code"`
Reason string `json:"reason,omitempty"`
Retryable bool `json:"retryable,omitempty"`
SideEffects SideEffects `json:"side_effects"`
}
func (e *Error) Error() string { return string(e.Code) + ": " + e.Reason }
func (e *Error) Error() string {
if e == nil {
return ""
}
return string(e.Code) + ": " + e.Reason
}
// ErrorCode 返回规范码字符串。日志库用这个方法识别错误,因此 contract 不导入错误码模块。
func (e *Error) ErrorCode() string {
if e == nil {
return ""
}
return string(e.Code)
}
// ErrorFields 返回可写入日志的字段。执行标识由调用方随日志字段另传,不放进本结构。
func (e *Error) ErrorFields() map[string]any {
if e == nil {
return nil
}
fields := map[string]any{
"retryable": e.Retryable,
}
if e.Reason != "" {
fields["reason"] = e.Reason
}
if e.SideEffects != "" {
fields["side_effects"] = string(e.SideEffects)
}
return fields
}
// Usage 报告 token 用量;实现报告不了时保持零值,不猜测。
type Usage struct {
+21 -1
View File
@@ -26,7 +26,7 @@ func TestFakeBackendSuite(t *testing.T) {
func TestFakeCancelSemantics(t *testing.T) {
block := make(chan struct{})
b := &conformance.FakeBackend{
Desc: contract.BackendDescriptor{Name: "fake", Capabilities: []contract.Capability{contract.CapCancel}},
Desc: contract.BackendDescriptor{Name: "fake", Capabilities: []contract.Capability{contract.CapCancel}},
BlockUntil: block,
}
conformance.Run(t, conformance.Case{
@@ -41,3 +41,23 @@ func TestFakeCancelSemantics(t *testing.T) {
t.Fatal("backend must observe ctx cancellation before returning")
}
}
func TestErrorDuckTypeForLogs(t *testing.T) {
e := &contract.Error{
Code: contract.ErrProviderUnavailable,
Reason: "down",
Retryable: true,
SideEffects: contract.SideEffectNone,
}
if e.ErrorCode() != "agent.provider_unavailable" {
t.Fatal(e.ErrorCode())
}
fields := e.ErrorFields()
if fields["reason"] != "down" || fields["side_effects"] != "none" || fields["retryable"] != true {
t.Fatalf("%v", fields)
}
var nilErr *contract.Error
if nilErr.ErrorCode() != "" || nilErr.ErrorFields() != nil {
t.Fatal("nil error")
}
}
Vendored Submodule
+1
Submodule deps/agent-intent added at 3674dc4bfd
Vendored Submodule
+1
Submodule deps/error-code-system added at dbeed2f1b6
Vendored Submodule
+1
Submodule deps/logging-system added at 2c5716fdcf
+39
View File
@@ -66,3 +66,42 @@ baseline_fixed: true
- 本矩阵证据均指向仓库内文件(上文路径),不含运行时截图;
- Batch1 完成后在本文件追加"实测证据"列;
- 上游仅引固定 0.85.1 的 README/docs 结论,不引 main。
---
## R15 扩面批次(full-agent-r1,2026-10-01):从「wrapper 对标」到「完全智能体」
用户定调(2026-10-01):Agent-Native 补全 pi-Agent 全量工具调用能力(网络搜索、代码编写、
系统工具、外部工具调用、技能、提示词),并与 Agent-Squad 融合。扩面域**不做 pi 对标**
(上游 wrapper 未暴露这些能力),改以「标准符合 + conformance 负例 + 产品验收」为准——
对标口径条款对本批新增域不适用,原有 #1–#16 行继续有效。
| 域 | 内容 | 出口证据 |
|---|---|---|
| 执行基座 | exec.Request v0.2(Model/ToolAllowlist/Skills/Budget/SessionScope/ExecutionID/Env)+ Registry.Restrict 策略层(精确名 + 命名空间通配)+ 预算贯穿(token/工具数/截止,budget_exceeded 永久失败)+ 取消传播到工具 | loop_budget / registry_policy / request_v2 / step_retry_budget 测试;关闭 #4/#10/#16 |
| 代码工具面 | glob(** 跨目录)、grep 正则/include/ignore_case、read 行范围分页、multi_edit 原子多处替换 | code_tools_test.go |
| 系统工具 | run_command / run_python:解释器白名单、env 白名单(宿主密钥不透传)、进程组 SIGKILL(超时/取消)、64KB 头尾截断、命令黑名单、脚本落盘 .agent/scripts/;Options.Exec 未配置不注册 | exec_tools_test.go(含杀组时限断言) |
| 网络面 | tavily_search/web_search 经 Options.Search 接线(关闭 #5 尾环);fetch_url:SSRF 硬边界(私网/回环/链路本地/元数据端点逐跳校验)、域名策略、2MB/20s、HTML 剥离 | fetch_test.go + 适配器接线测试 |
| MCP | stdio + Streamable HTTP 客户端、initialize/tools/list(paged)/tools/call、Mcp-Session-Id、mcp__<server>__<tool> 桥接、64 工具/1KB 描述/64KB 结果上限、不可达 fail-closed、真实子进程测试(re-exec fake server) | runtime/mcp/mcp_test.go |
| 技能/提示词 | SKILL.md 加载(目录名权威、frontmatter 描述)、load_skill 渐进披露、声明技能 fail-closed 校验、promptx 装配器(环境/技能索引/交付物) | skills_test / promptx_test / 适配器测试 |
| 会话 | compaction(阈值触发、原子改写、失败留痕不阻断)、元数据 sidecar、崩溃半行容错 | compact_test.go;对齐 #11/#12 |
| 委派 | runtime/subagent 端口 + delegate_task(信息集=委派条款);嵌套执行由宿主注入 | subagent_test.go |
安全声明(不冒充已解决):run_command **不是 OS 沙箱**(宿主负责隔离);技能正文与 MCP
server 描述是提示注入面(只信受信根 + 长度上限);fetch 的 DNS 解析存在 rebinding 窗口。
扩展进程、安装的包和外部 CLI 都以调用者的完整权限运行,同样不是沙箱。
## R16 无界面全量(pi-headless,2026-10-01)
对标口径改为 pi 的无界面能力。不做 TUI、主题、快捷键,也不做订阅制 OAuth。
| 域 | 现状 |
|---|---|
| 会话树 | available:id/parentId、fork/clone、叶子切换、continue/resume、追加式压缩、旧线性文件仍可 Load、export_html |
| 供应商 | available:Anthropic、OpenAI Chat 与 Responses、Google、Azure、兼容预设、models.json、`provider/id:thinking`、图片、缓存 token、自动重试;上下文溢出不重试 |
| AgentSession | available:steer/follow-up、one 与 all、abort、clear_queue、中途换模型;事件含 message_start、tool_execution_update、queue_update、agent_settled |
| 资源 | available:pi 工具名与原名并存、上下文文件、提示词模板、`/skill:name`、项目信任 |
| 扩展与包 | available:stdio JSONL 子进程;install/remove/update/list/config |
| 模式 | available:`cmd/agent-native` 的 print/json/rpc,以及 Go `rpc.Client` |
| exec.Backend | available:`adapters/native` 的 Execute 经 AgentSession,请求/结果字段不变 |
@@ -0,0 +1,54 @@
# 接入开发规范并为执行契约对齐错误码与日志
状态:已批准范围(实现进行中);owner:weichen;主任务:本仓接入提交。
授权与交付终点:在 Agent-Native 内挂载规范子模块、补齐项目入口,并把执行错误与日志接到 Error-Code-System / Logging-System。不改 Gitea 权限,不发布制品。
并行基线:Agent-Native `main` @ `49e6dd1e0fc65de7f259f21d2f63c56e5b165a48`;Development-Guidelines `26b562d2e34fbc560711296a309c91c74ffc55b1`;Error-Code-System `b67696d393e7abfbdd16fdde1064c07f5ecd2f72`;Logging-System `7f5972007bdcb6047ce7f95ee891f53e0af9d136`。日期 2026-09-29。
## 问题与证据
四个基础库此前没有统一的项目入口,也没有把 Development-Guidelines 固定进父仓。Agent-Native 的 `contract.ErrorCode` 仍是短字符串(如 `canceled`),与错误码库的 `agent.*` 规范码只在映射文档中对应,调用方无法从错误值直接得到规范码。runtime 没有可替换的结构化日志,失败只能靠返回值。
经验检索:规范库与两个基础库均为新仓,无已验证经验条目。
## 目标与边界
- 规范以 `deps/development-guidelines` 的 gitlink 为版本权威。
- `contract.ErrorCode` 的取值改为 `agent.*`;`contract.Error` 提供 `ErrorCode()` / `ErrorFields()`,使日志库能识别错误且 `contract` 不导入错误码模块。
- pitransport 对外错误码同步为规范码;`CANCELLED` 并入 `agent.canceled`,`MISSING_TARGET` 并入 `agent.missing_deliverable`。
- runtime 与 native adapter 接受可选 `*logsys.Logger`,nil 时使用空日志器。
- 非目标:不改 Squad 编排;不把本仓变成错误码目录的第二权威;不自动跟踪子模块上游分支。
## 选择与实施
子模块统一放 `deps/`,与后续各仓布局一致。错误码常量改值而不是在调用点拼接前缀,避免两套字符串长期并存。`contract` 只增加鸭子方法,不 `import error-code-system`,保持该包零外部依赖。
1. 挂载 `deps/development-guidelines` 并提交入口文档。
2. 挂载 `deps/error-code-system`、`deps/logging-system`,`go.mod` 用相对 `replace`。
3. 修改 `contract` 与 `adapters/pitransport` 的错误码取值,补日志钩子与 `scripts/submodule-consistency.sh`。
4. 仓库根 `go test` 作为本模块出口。
## 兼容、迁移与恢复
错误码取值变更对按旧字符串比较的调用方是破坏性变更。错误码库已把旧值登记为别名,`Registry.Resolve` 仍可解析旧字符串。`agent-system-multi` 若硬编码旧码,需在其自己的消费提交里跟进;本仓 gitlink 未进主仓组合之前不影响主仓构建。
回退:父仓 PR 恢复旧 gitlink 与旧常量值。无数据库迁移。暂停条件:`go test` 失败或子模块 SHA 无法从批准远端获取。
## 验收
| 需求/验收条件 | 负责模块/契约或不变量 | 验证方法/用例 | 实际结果/证据 |
|---|---|---|---|
| 规范入口可发现且版本固定 | 根 AGENTS.md / gitlink | `git submodule status`;打开相对链接 | 计划阶段待执行 |
| contract 错误码为 agent.* 且鸭子方法可被日志识别 | contract.Error | `go test ./contract/...` | 计划阶段待执行 |
| pitransport 历史码映射到规范码 | adapters/pitransport | `go test ./adapters/pitransport/...` | 计划阶段待执行 |
| 可选日志器为 nil 时行为不变 | runtime / adapters/native | `go test ./runtime/... ./adapters/native/...` | 计划阶段待执行 |
| 用户/系统条件 | 检查层级与方法 | 预期结果 | 必需环境/授权 |
|---|---|---|---|
| 全新 clone 后可测试 | `git clone --recurse-submodules` 后 `go test` | 通过 | 能访问 Gitea SSH |
| 旧错误字符串仍可被错误码库解析 | 错误码库别名测试(该库既有用例) | 别名回到规范码 | Error-Code-System 源码 |
| 未知结果与缺能力 | contract/conformance 既有用例 | 不降级、不回落 Stub | 无 |
| 无日志器的既有调用 | runtime/native 既有测试 | 保持通过 | Go 1.27 |
## 决策与剩余项
多级副本由各父仓 gitlink 锁定;Agent-Squad 构建时只采用 Agent-Squad 自己的 `replace`。一致性脚本在本仓接入两个基础库后启用。验证证据在实现完成后写入同目录的验证记录,不把本方案改称已完成。
@@ -0,0 +1,23 @@
# Agent-Native 接入 agent-intent 意图闸门(可选装饰器)
对应设计:Agent-Intent 仓 `docs/records/2026-09-29-decoupling-and-llm-layering.md`(第三节 b,决策 D7 选仓内装饰器)。
形态:`adapters/intentgate.Wrap(next, recognizer, snapshot, handler) exec.Backend`——Execute 前对 `req.Prompt` 做一次确定性识别,守卫/命中/预期命中分支交宿主 Handler 解释(合成结果语义由宿主定义),Ambiguous/Fallback 或 Handler 放弃时原样透传;识别系统错误 fail-closed。
## 依赖与解耦边界
- 本包是本仓唯一 import `agent-intent` 的位置;contract/exec/runtime/adapters/native 零感知(`GOWORK=off go list -deps ./contract ./exec ./runtime ./adapters/native` 不含 agent-intent)。
- T0 行为解耦:未 Wrap(或任一组件为 nil,直接返回原后端)与 Wrap 了零意图快照,行为与原后端一致(`TestZeroSpecSnapshotPassthroughIdentical` 逐字节对比)。
- 回退程序:删 `adapters/intentgate/` + go.mod 两行 + submodule + `.gitmodules` 条目 + 一致性脚本一行。
## 验证证据(2026-09-29,go test ./... 全绿,intentgate 含 -race)
| 用例 | 外挂意图 | 输入(Prompt) | 识别结果 | 期望 | 结果 |
|---|---|---|---|---|---|
| TestZeroSpecSnapshotPassthroughIdentical | 无(零 spec 快照) | `帮我改一下系统网关地址` | Fallback(快照为空) | 与裸 stub 后端结果逐字节一致,Handler 不触发 | 通过 |
| TestGuardHitInterceptsWithoutModel | guard.config_change(includes: 系统配置/网关;risk=high) | `帮我改一下系统网关地址` | GuardHit,top=guard.config_change | Handler 收到 GuardHit;返回拒绝合成结果;**模型后端零调用** | 通过 |
| TestFallbackPassesThroughToBackend | 同上 | `写一份周报` | Fallback | 透传原后端,Handler 不触发,后端恰好调用 1 次 | 通过 |
| TestHandlerDeclineFallsBackToBackend | 同上 | `帮我改一下系统网关地址` | GuardHit(Handler 放弃) | 回退透传原后端 | 通过 |
| TestNilComponentsReturnNext | n/a | n/a | n/a | 任一组件为 nil → Wrap 返回原后端 | 通过 |
| TestRecognizeErrorFailsClosed | MaxInputLen=8 快照 | 超长输入 | 识别错误 sys.invalid_argument | fail-closed 返回错误,后端零调用 | 通过 |
命令:`GOWORK=off go vet ./... && GOWORK=off go test -count=1 ./...`;`GOWORK=off go test -count=1 -race ./adapters/intentgate/`;`scripts/submodule-consistency.sh`。
+20
View File
@@ -0,0 +1,20 @@
# 验证记录
- 范围/主任务:接入 Development-Guidelines,并把执行错误码与可选日志接到错误码库和日志库。
- 被测版本:工作区 `e65c6ba604c9079e1176d9449e295e0d09bedfd3`。该提交尚未推到批准远端。
- 目标仓/分支与共同起点:Agent-Native `main`,起点 `49e6dd1e0fc65de7f259f21d2f63c56e5b165a48`。
- 最新同步与测试对象:2026-09-29。子模块 `deps/development-guidelines` `26b562d2e34fbc560711296a309c91c74ffc55b1`,`deps/error-code-system` `b67696d393e7abfbdd16fdde1064c07f5ecd2f72`,`deps/logging-system` `7f5972007bdcb6047ce7f95ee891f53e0af9d136`。
- 测试结束/准入前目标复核:未合入远端。干净克隆测的是本机仓库,子模块从批准远端检出。
- 环境:本机 Go 1.27,无模型端点。
- 实际命令:仓库根 `go test -count=1 ./...`;`scripts/submodule-consistency.sh`;`git clone --recurse-submodules` 到 `/tmp/dg-verify/Agent-Native` 后再测一次。
| 预期用例/场景 | 实际是否执行 | 结果 | 证据/缺项 |
|---|---|---|---|
| 工作区全模块测试 | 是 | PASS | `go test -count=1 ./...` |
| 子模块 gitlink 与 HEAD 一致 | 是 | PASS | `scripts/submodule-consistency.sh` |
| 从本机父仓递归克隆后测试 | 是 | PASS | `/tmp/dg-verify/Agent-Native` 再次 `go test -count=1 ./...` |
| 真实模型端点 | 否 | N/A | 本仓测试使用注入流,不覆盖外部模型 |
首轮失败与重试:无。
结论与限制:只证实本机工作区与一次本机路径克隆。`e65c6ba` 还不能从批准远端按 SHA 获取,因此其他仓库在推送前不能把这个 gitlink 当作已发布组合。
剩余项/责任人:推送由仓库所有者决定。推送前 Agent-Squad 的全新远端克隆会因缺少该提交而失败。
+31
View File
@@ -17,6 +17,34 @@ type Request struct {
// 实现方必须保证:回调在 Execute 返回前完成(同步调用或等待收尾),无泄漏 goroutine;
// 回调内不得阻塞(消费方自行节流/异步入队)。
OnEvent func(ev ProcessEvent)
// ---- v0.2 能力字段(全部可选;零值 = 既有行为,向后兼容) ----
// Model 覆盖本请求使用的模型(后端构造时的默认模型仍兜底)。
Model string `json:"model,omitempty"`
// ToolAllowlist 收窄可见工具集;出现后端未注册的工具名时 fail-closed。
ToolAllowlist []string `json:"tool_allowlist,omitempty"`
// Skills 声明本次执行需要加载的技能(内核在系统提示中列出并由 load_skill 按需展开)。
Skills []string `json:"skills,omitempty"`
// Budget 单次步骤执行的共享预算;步骤重试应传入剩余额度,不得各自另起一份。
Budget *Budget `json:"budget,omitempty"`
// SessionScope / ExecutionID 只用于日志与过程事件贯穿,不参与控制流。
SessionScope string `json:"session_scope,omitempty"`
ExecutionID string `json:"execution_id,omitempty"`
// Env 是组合根批准后注入执行类工具(run_command 等)的白名单环境变量。
// 后端不得把自身持有的密钥并入其中。
Env map[string]string `json:"env,omitempty"`
}
// Budget 是一次步骤执行的共享预算(agent-execution 标准:重试层各有职责,
// 共享总预算与截止时间,避免传输、步骤与主循环次数相乘)。
// 全部字段可选;零值字段不设限。
type Budget struct {
MaxPromptTokens int `json:"max_prompt_tokens,omitempty"`
MaxCompletionTokens int `json:"max_completion_tokens,omitempty"`
MaxToolCalls int `json:"max_tool_calls,omitempty"`
// Deadline 绝对截止时刻;与 Request.Timeout 先到者生效。
Deadline time.Time `json:"deadline,omitempty"`
}
// ProcessEvent 对 agentx 事件与远程 runner NDJSON 过程帧的统一投影,
@@ -26,6 +54,9 @@ type ProcessEvent struct {
Text string // delta 文本(Type 为 *_delta 时)
Tool string // 工具名(tool_* 时)
Detail string // 工具参数/结果摘要(tool_execution_* 时)
// PromptTokens / CompletionTokens 在 Type=usage 时填写。
PromptTokens int
CompletionTokens int
}
const (
+1
View File
@@ -160,6 +160,7 @@ func combinedFailureText(sig StepFailureSignals) string {
func isPermanentReason(lower string) bool {
return strings.Contains(lower, "prompt_empty") ||
strings.Contains(lower, "unauthorized") ||
strings.Contains(lower, "budget_exceeded") ||
strings.Contains(lower, "治理强制终止") ||
strings.Contains(lower, "治理拦截")
}
+37
View File
@@ -0,0 +1,37 @@
package exec
import (
"errors"
"strings"
"testing"
"time"
)
func TestAssessStepFailureBudgetExceededIsPermanent(t *testing.T) {
ass := AssessStepFailure(StepFailureSignals{
Err: errors.New("budget_exceeded: tool calls 12 > max 10"),
})
if ass.Class != FailurePermanent {
t.Fatalf("budget exhaustion must classify permanent (no auto retry), got %s", ass.Class)
}
if !strings.Contains(ass.Reason, "permanent") {
t.Fatalf("want permanent reason, got %s", ass.Reason)
}
}
func TestAssessStepFailureDeadlinePastIsPermanent(t *testing.T) {
ass := AssessStepFailure(StepFailureSignals{
Result: &Result{ErrorReason: "budget_exceeded: deadline 2026-10-01T00:00:00Z passed"},
})
if ass.Class != FailurePermanent {
t.Fatalf("deadline exhaustion must classify permanent, got %s", ass.Class)
}
}
func TestBudgetZeroValueUnlimited(t *testing.T) {
var b Budget
if b.MaxPromptTokens != 0 || b.MaxCompletionTokens != 0 || b.MaxToolCalls != 0 || !b.Deadline.IsZero() {
t.Fatal("zero budget must express unlimited")
}
_ = time.Now
}
+12
View File
@@ -1,3 +1,15 @@
module agent-native
go 1.27
require (
agent-intent v0.0.0
error-code-system v0.0.0
logging-system v0.0.0
)
replace agent-intent => ./deps/agent-intent
replace error-code-system => ./deps/error-code-system
replace logging-system => ./deps/logging-system
+51
View File
@@ -0,0 +1,51 @@
# 通用规范采纳记录
- 项目/负责人:Agent-Native / 仓库所有者 weichen
- 状态:active
- 规范位置:`deps/development-guidelines`
- 规范性文档目录:`policy/`
- 过程文档位置:`docs/records/`(项目内固定目录豁免。当前为个人维护的基础库,单独过程仓会让每份记录都变成跨仓 gitlink;任务主记录仍在 Gitea Issue/PR。豁免一经登记,过程记录不得再写入 `policy/` 或规范库。)
- 版本权威:父仓 gitlink;可读标签:无(内容版本 `0.1.0-draft.6`,提交 `26b562d2e34fbc560711296a309c91c74ffc55b1`)
- 批准/生效依据:本仓接入提交;草案审阅完成后随该提交生效
- 替代范围和迁移说明:本仓此前没有根 `AGENTS.md`。接入后以本文件与根入口为准,不在规范库内保存项目事实。
| 规范/工作流 | 适用条件与范围 | 采纳或不适用理由 | 执行方式/证据 |
|---|---|---|---|
| core/baseline.md | 日常工作 | 所有改动都要守硬边界 | manual |
| workflows/delivery.md、debugging.md、handoff.md | 交付、排障与交接 | 单人维护仍需要可交接的任务记录 | manual |
| standards/collaboration.md | 并行共同起点、节点同步与陈旧候选拒绝 | 本仓会被 Agent-Squad 以 gitlink 消费 | manual |
| standards/verification.md | 验证与结论 | 仓库根 `go vet` / `go test` | 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/security.md | 路径授权、工具范围、凭证 | 凭证不入库;路径授权不等于 OS 沙箱 | manual |
| standards/documentation.md | 三层文档 | `policy/` 与 `docs/records/` 已分开 | manual |
| standards/data-reliability.md | 执行副作用与重放 | 无本仓数据库;适用副作用分类,不扩展成存储设计 | manual |
| standards/configuration.md | 调用方注入的模型与工具配置 | 本仓不自带多实例部署配置 | manual |
| standards/release-operations.md | 独立发布流水线 | 当前无制品仓库与部署入口 | 不适用 |
| workflows/automation.md | 定时与后台任务 | 本仓无 daemon | 不适用 |
## 项目差异
| 原规则/章节 | 项目采用方式 | 原因与风险 | 负责人/复查条件 |
|---|---|---|---|
| documentation.md 过程仓默认独立仓库 | 过程记录放 `docs/records/` | 个人基础库;风险是记录与代码同 PR 时容易把证据写成第二权威。任务状态仍只认 Gitea Issue/PR | 出现第二名维护者或对外发布流程时复查 |
默认做法差异说明理由;硬边界冲突须有明确有效决策,不能用此表自授权。建议不采用无需登记。
## 本次升级或首次接入验证
- 入口:根 `AGENTS.md`、`PROJECT.md` 指向 `deps/development-guidelines`。
- 子模块:`deps/development-guidelines` 固定 `26b562d2e34fbc560711296a309c91c74ffc55b1`。
- `.gitmodules` 使用批准的上游 SSH URL,不含本机路径或凭证。
- 执行方式保持 manual:挂载 Markdown 不自动改变 CI。
- 回退:恢复本提交之前的 gitlink,并删除根入口与 `policy/guidelines-adoption.md` 中对应条款;无已生效的 CI 或权限变更需要另恢复。
### 2026-09-29 追加:agent-intent 可选意图闸门
- 新增 `deps/agent-intent`(固定 `55780ae2cce4ae98d8b0557b8234af8833a7c522`),`deps/error-code-system` 前进到 `dbeed2f1b67eb417ce089e7033a228578fbbeb03`(追加 `squad.intent_refused`,兼容性变更无)。
- 依赖边界新增:唯 `adapters/intentgate` 可 import agent-intent;不使用该包时核心与 agent-intent 无编译关系。
- 接入与验证记录见 [docs/records/2026-09-29-intentgate-adoption.md](../docs/records/2026-09-29-intentgate-adoption.md)。
- 回退:删除 `adapters/intentgate/`、go.mod 的 agent-intent require/replace、`.gitmodules` 对应条目与 submodule,`scripts/submodule-consistency.sh` 移除该路径。
+54
View File
@@ -0,0 +1,54 @@
package rpc
import (
"bufio"
"context"
"encoding/json"
"io"
)
// Client is the Go SDK for the JSONL RPC mode.
type Client struct {
in io.Writer
out *bufio.Reader
}
// NewClient wraps a bidirectional pipe. Commands are one JSON object per line.
func NewClient(in io.Writer, out io.Reader) *Client {
return &Client{in: in, out: bufio.NewReader(out)}
}
// Call sends one command and reads the matching response.
func (c *Client) Call(ctx context.Context, req Request) (Response, error) {
raw, err := json.Marshal(req)
if err != nil {
return Response{}, err
}
if _, err := c.in.Write(append(raw, '\n')); err != nil {
return Response{}, err
}
type result struct {
resp Response
err error
}
ch := make(chan result, 1)
go func() {
line, err := c.out.ReadBytes('\n')
if err != nil {
ch <- result{err: err}
return
}
var resp Response
if err := json.Unmarshal(line, &resp); err != nil {
ch <- result{err: err}
return
}
ch <- result{resp: resp}
}()
select {
case <-ctx.Done():
return Response{}, ctx.Err()
case got := <-ch:
return got.resp, got.err
}
}
+193
View File
@@ -0,0 +1,193 @@
package rpc
import (
"context"
"encoding/json"
"fmt"
"agent-native/runtime"
"agent-native/runtime/llm"
"agent-native/runtime/session"
)
// Request is one RPC command. Type matches the pi headless command set.
type Request struct {
ID string `json:"id,omitempty"`
Type string `json:"type"`
Message string `json:"message,omitempty"`
Model string `json:"model,omitempty"`
ThinkingLevel string `json:"thinkingLevel,omitempty"`
Path string `json:"path,omitempty"`
Name string `json:"name,omitempty"`
Leaf string `json:"leaf,omitempty"`
Keep int `json:"keep,omitempty"`
}
// Response is the synchronous result of one command.
type Response struct {
ID string `json:"id,omitempty"`
Type string `json:"type"`
OK bool `json:"ok"`
Text string `json:"text,omitempty"`
Error string `json:"error,omitempty"`
Result any `json:"result,omitempty"`
}
// Server serves RPC commands against one agent session and its transcript.
type Server struct {
Agent *runtime.AgentSession
Store *session.Store
NoStore bool
}
// Handle runs one command and returns the JSON response line.
func (s *Server) Handle(ctx context.Context, line []byte) ([]byte, error) {
var req Request
if err := json.Unmarshal(line, &req); err != nil {
return encode(Response{Type: "response", OK: false, Error: err.Error()})
}
resp := s.dispatch(ctx, req)
resp.ID = req.ID
if resp.Type == "" {
resp.Type = "response"
}
return encode(resp)
}
func (s *Server) dispatch(ctx context.Context, req Request) Response {
switch req.Type {
case "prompt":
if s.Agent == nil {
return fail("no agent")
}
res, err := s.Agent.Prompt(ctx, llm.Message{Role: llm.RoleUser, Content: req.Message})
if err != nil {
return fail(err.Error())
}
if res.Err != nil {
return fail(res.Err.Error())
}
return Response{OK: true, Text: res.Text}
case "steer":
s.Agent.Steer(llm.Message{Role: llm.RoleUser, Content: req.Message})
return Response{OK: true}
case "follow_up":
s.Agent.FollowUp(llm.Message{Role: llm.RoleUser, Content: req.Message})
return Response{OK: true}
case "abort":
s.Agent.Abort()
return Response{OK: true}
case "clear_queue":
s.Agent.ClearQueue()
return Response{OK: true}
case "set_model":
if err := s.Agent.SetModel(req.Model, req.ThinkingLevel); err != nil {
return fail(err.Error())
}
return Response{OK: true}
case "compact":
keep := req.Keep
if keep <= 0 {
keep = 4
}
done, err := s.Agent.Compact(keep, func(messages []llm.Message) (string, error) {
return fmt.Sprintf("%d messages", len(messages)), nil
})
if err != nil {
return fail(err.Error())
}
return Response{OK: true, Result: done}
case "get_state":
leaf, _ := s.Store.Leaf()
return Response{OK: true, Result: map[string]any{"leaf": leaf, "name": s.Store.Name(), "path": s.Store.Path()}}
case "get_tree":
entries, err := s.Store.Entries()
if err != nil {
return fail(err.Error())
}
return Response{OK: true, Result: entries}
case "get_entries":
entries, err := s.Store.Entries()
if err != nil {
return fail(err.Error())
}
return Response{OK: true, Result: entries}
case "get_last_assistant_text":
msgs, err := s.Store.Load()
if err != nil {
return fail(err.Error())
}
text := ""
for _, m := range msgs {
if m.Role == llm.RoleAssistant {
text = m.Content
}
}
return Response{OK: true, Text: text}
case "set_session_name":
if err := s.Store.SetName(req.Name); err != nil {
return fail(err.Error())
}
return Response{OK: true}
case "fork":
dest := req.Path
if dest == "" {
return fail("path is required")
}
if _, err := s.Store.Fork(dest, req.Leaf); err != nil {
return fail(err.Error())
}
return Response{OK: true, Text: dest}
case "clone":
if req.Path == "" {
return fail("path is required")
}
if _, err := s.Store.Clone(req.Path); err != nil {
return fail(err.Error())
}
return Response{OK: true, Text: req.Path}
case "switch_session":
if req.Path == "" {
return fail("path is required")
}
st, err := session.Open(req.Path)
if err != nil {
return fail(err.Error())
}
s.Store = st
return Response{OK: true}
case "new_session":
if req.Path == "" {
return fail("path is required")
}
st, err := session.Open(req.Path)
if err != nil {
return fail(err.Error())
}
s.Store = st
return Response{OK: true, Text: req.Path}
case "export_html":
if req.Path == "" {
return fail("path is required")
}
if err := s.Store.ExportHTML(req.Path); err != nil {
return fail(err.Error())
}
return Response{OK: true, Text: req.Path}
case "get_session_stats":
st := s.Store.Stats()
return Response{OK: true, Result: st}
default:
return fail("unknown command " + req.Type)
}
}
func fail(msg string) Response { return Response{OK: false, Error: msg} }
func encode(resp Response) ([]byte, error) {
raw, err := json.Marshal(resp)
if err != nil {
return nil, err
}
return append(raw, '\n'), nil
}
+71
View File
@@ -0,0 +1,71 @@
package rpc
import (
"context"
"io"
"path/filepath"
"testing"
"agent-native/runtime"
"agent-native/runtime/llm"
"agent-native/runtime/session"
)
func TestRPCSessionCommandsAndClient(t *testing.T) {
dir := t.TempDir()
store, err := session.Open(filepath.Join(dir, "s.jsonl"))
if err != nil {
t.Fatal(err)
}
if err := store.Append(llm.Message{Role: llm.RoleAssistant, Content: "ready"}); err != nil {
t.Fatal(err)
}
agent := runtime.NewAgentSession(runtime.Config{
Model: "fake",
Stream: func(ctx context.Context, model string, messages []llm.Message, td []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
ch := make(chan llm.Event, 1)
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{Role: llm.RoleAssistant, Content: "echo", StopReason: llm.StopEnd}}
close(ch)
return ch
},
})
srv := &Server{Agent: agent, Store: store}
clientIn, serverOut := io.Pipe()
serverIn, clientOut := io.Pipe()
go func() {
defer serverOut.Close()
buf := make([]byte, 4096)
for {
n, err := serverIn.Read(buf)
if n > 0 {
resp, herr := srv.Handle(context.Background(), buf[:n])
if herr == nil {
_, _ = serverOut.Write(resp)
}
}
if err != nil {
return
}
}
}()
client := NewClient(clientOut, clientIn)
resp, err := client.Call(context.Background(), Request{ID: "1", Type: "get_last_assistant_text"})
if err != nil || !resp.OK || resp.Text != "ready" {
t.Fatalf("%+v %v", resp, err)
}
fork := filepath.Join(dir, "fork.jsonl")
resp, err = client.Call(context.Background(), Request{Type: "fork", Path: fork})
if err != nil || !resp.OK {
t.Fatalf("fork %+v %v", resp, err)
}
resp, err = client.Call(context.Background(), Request{Type: "prompt", Message: "hi"})
if err != nil || !resp.OK || resp.Text != "echo" {
t.Fatalf("prompt %+v %v", resp, err)
}
htmlPath := filepath.Join(dir, "out.html")
resp, err = client.Call(context.Background(), Request{Type: "export_html", Path: htmlPath})
if err != nil || !resp.OK {
t.Fatalf("export %+v %v", resp, err)
}
_ = clientOut.Close()
}
+181
View File
@@ -0,0 +1,181 @@
package pkg
import (
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
)
// Manifest is the agent-native.json discovered in a package.
type Manifest struct {
Name string `json:"name"`
Extensions []string `json:"extensions,omitempty"`
Config map[string]string `json:"config,omitempty"`
}
// Manager installs packages under Dir.
type Manager struct {
Dir string
}
func (m Manager) root() string {
if m.Dir != "" {
return m.Dir
}
home, _ := os.UserHomeDir()
return filepath.Join(home, ".agent-native", "packages")
}
// Install copies a local directory, clones a git URL, or runs npm pack
// when src starts with "npm:". The package name comes from its manifest.
func (m Manager) Install(src string) (Manifest, error) {
dir, err := os.MkdirTemp("", "agent-native-pkg-*")
if err != nil {
return Manifest{}, err
}
defer os.RemoveAll(dir)
switch {
case strings.HasPrefix(src, "npm:"):
if _, err := exec.LookPath("npm"); err != nil {
return Manifest{}, fmt.Errorf("pkg: npm is not installed")
}
cmd := exec.Command("npm", "pack", strings.TrimPrefix(src, "npm:"))
cmd.Dir = dir
if out, err := cmd.CombinedOutput(); err != nil {
return Manifest{}, fmt.Errorf("pkg: npm pack: %s", out)
}
case strings.HasPrefix(src, "git+") || strings.HasSuffix(src, ".git") || strings.Contains(src, "://"):
cmd := exec.Command("git", "clone", "--depth", "1", src, filepath.Join(dir, "src"))
if out, err := cmd.CombinedOutput(); err != nil {
return Manifest{}, fmt.Errorf("pkg: git clone: %s", out)
}
dir = filepath.Join(dir, "src")
default:
if err := copyDir(src, filepath.Join(dir, "src")); err != nil {
return Manifest{}, err
}
dir = filepath.Join(dir, "src")
}
manifest, err := ReadManifest(dir)
if err != nil {
return Manifest{}, err
}
dest := filepath.Join(m.root(), manifest.Name)
if err := os.RemoveAll(dest); err != nil {
return Manifest{}, err
}
if err := copyDir(dir, dest); err != nil {
return Manifest{}, err
}
return manifest, nil
}
// Remove deletes an installed package.
func (m Manager) Remove(name string) error {
return os.RemoveAll(filepath.Join(m.root(), name))
}
// Update reinstalls from the recorded source when present, otherwise no-ops.
func (m Manager) Update(name string) (Manifest, error) {
manifest, err := ReadManifest(filepath.Join(m.root(), name))
if err != nil {
return Manifest{}, err
}
src := manifest.Config["source"]
if src == "" {
return manifest, nil
}
return m.Install(src)
}
// List returns installed manifests.
func (m Manager) List() ([]Manifest, error) {
entries, err := os.ReadDir(m.root())
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
var out []Manifest
for _, ent := range entries {
if !ent.IsDir() {
continue
}
manifest, err := ReadManifest(filepath.Join(m.root(), ent.Name()))
if err != nil {
continue
}
out = append(out, manifest)
}
return out, nil
}
// Config sets one package config key.
func (m Manager) Config(name, key, value string) error {
path := filepath.Join(m.root(), name)
manifest, err := ReadManifest(path)
if err != nil {
return err
}
if manifest.Config == nil {
manifest.Config = map[string]string{}
}
manifest.Config[key] = value
raw, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
return err
}
return os.WriteFile(filepath.Join(path, "agent-native.json"), raw, 0o644)
}
// ReadManifest loads agent-native.json from a directory.
func ReadManifest(dir string) (Manifest, error) {
raw, err := os.ReadFile(filepath.Join(dir, "agent-native.json"))
if err != nil {
return Manifest{}, err
}
var manifest Manifest
if err := json.Unmarshal(raw, &manifest); err != nil {
return Manifest{}, err
}
if manifest.Name == "" {
return Manifest{}, fmt.Errorf("pkg: manifest name is empty")
}
return manifest, nil
}
func copyDir(src, dst string) error {
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(src, path)
if err != nil {
return err
}
target := filepath.Join(dst, rel)
if info.IsDir() {
return os.MkdirAll(target, 0o755)
}
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return err
}
in, err := os.Open(path)
if err != nil {
return err
}
defer in.Close()
out, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, info.Mode())
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, in)
return err
})
}
+235
View File
@@ -0,0 +1,235 @@
package ext
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"os/exec"
"sync"
"agent-native/runtime/llm"
"agent-native/runtime/tools"
)
// Message is one JSONL frame on the extension pipe.
type Message struct {
Type string `json:"type"`
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Description string `json:"description,omitempty"`
Parameters json.RawMessage `json:"parameters,omitempty"`
Arguments json.RawMessage `json:"arguments,omitempty"`
Content string `json:"content,omitempty"`
IsError bool `json:"isError,omitempty"`
Event string `json:"event,omitempty"`
Block bool `json:"block,omitempty"`
Reason string `json:"reason,omitempty"`
Message string `json:"message,omitempty"`
Protocol string `json:"protocol,omitempty"`
BaseURL string `json:"baseURL,omitempty"`
CustomType string `json:"customType,omitempty"`
Data json.RawMessage `json:"data,omitempty"`
Command string `json:"command,omitempty"`
}
// UIRequest is a question an extension wants a human to answer.
type UIRequest struct {
ID string
Message string
}
// Process is one extension subprocess.
type Process struct {
cmd *exec.Cmd
stdin io.WriteCloser
mu sync.Mutex
tools []registeredTool
commands []string
providers []llm.Provider
pending map[string]chan Message
UI chan UIRequest
OnCustom func(customType string, data json.RawMessage)
closed bool
}
type registeredTool struct {
name string
description string
parameters json.RawMessage
proc *Process
}
func (t registeredTool) Name() string { return t.name }
func (t registeredTool) Description() string { return t.description }
func (t registeredTool) Parameters() json.RawMessage { return t.parameters }
func (t registeredTool) Execute(ctx context.Context, args json.RawMessage) (tools.Result, error) {
return t.proc.callTool(ctx, t.name, args)
}
// Start launches command and reads its registration frames until it goes idle
// or the context ends. command is argv, not a shell line.
func Start(ctx context.Context, command []string) (*Process, error) {
if len(command) == 0 {
return nil, fmt.Errorf("ext: empty command")
}
cmd := exec.CommandContext(ctx, command[0], command[1:]...)
stdin, err := cmd.StdinPipe()
if err != nil {
return nil, err
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, err
}
if err := cmd.Start(); err != nil {
return nil, err
}
p := &Process{
cmd: cmd, stdin: stdin, pending: map[string]chan Message{},
UI: make(chan UIRequest, 4),
}
go p.read(stdout)
if err := p.send(Message{Type: "init"}); err != nil {
_ = p.Close()
return nil, err
}
return p, nil
}
// Tools returns the tools the extension registered.
func (p *Process) Tools() []tools.Tool {
p.mu.Lock()
defer p.mu.Unlock()
out := make([]tools.Tool, len(p.tools))
for i := range p.tools {
out[i] = p.tools[i]
}
return out
}
// Commands returns slash-command names.
func (p *Process) Commands() []string {
p.mu.Lock()
defer p.mu.Unlock()
return append([]string(nil), p.commands...)
}
// Providers returns endpoints the extension registered.
func (p *Process) Providers() []llm.Provider {
p.mu.Lock()
defer p.mu.Unlock()
return append([]llm.Provider(nil), p.providers...)
}
// Hook tells the extension about an event. A block result stops the host action.
func (p *Process) Hook(ctx context.Context, event, name string, args json.RawMessage) (bool, string) {
id := name + ":" + event
ch := make(chan Message, 1)
p.mu.Lock()
p.pending[id] = ch
p.mu.Unlock()
if err := p.send(Message{Type: "event", ID: id, Event: event, Name: name, Arguments: args}); err != nil {
return false, ""
}
select {
case <-ctx.Done():
return false, ctx.Err().Error()
case msg := <-ch:
return msg.Block, msg.Reason
}
}
func (p *Process) callTool(ctx context.Context, name string, args json.RawMessage) (tools.Result, error) {
id := name
ch := make(chan Message, 1)
p.mu.Lock()
p.pending[id] = ch
p.mu.Unlock()
if err := p.send(Message{Type: "tool_call", ID: id, Name: name, Arguments: args}); err != nil {
return tools.Result{IsError: true, Content: err.Error()}, err
}
select {
case <-ctx.Done():
return tools.Result{IsError: true, Content: ctx.Err().Error()}, ctx.Err()
case msg := <-ch:
return tools.Result{Content: msg.Content, IsError: msg.IsError}, nil
}
}
func (p *Process) send(msg Message) error {
raw, err := json.Marshal(msg)
if err != nil {
return err
}
p.mu.Lock()
defer p.mu.Unlock()
if p.closed {
return fmt.Errorf("ext: closed")
}
_, err = p.stdin.Write(append(raw, '\n'))
return err
}
func (p *Process) read(r io.Reader) {
sc := bufio.NewScanner(r)
sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
for sc.Scan() {
var msg Message
if err := json.Unmarshal(sc.Bytes(), &msg); err != nil {
continue
}
p.dispatch(msg)
}
}
func (p *Process) dispatch(msg Message) {
switch msg.Type {
case "register_tool":
p.mu.Lock()
p.tools = append(p.tools, registeredTool{
name: msg.Name, description: msg.Description, parameters: msg.Parameters, proc: p,
})
p.mu.Unlock()
case "register_command":
p.mu.Lock()
p.commands = append(p.commands, msg.Name)
p.mu.Unlock()
case "register_provider":
p.mu.Lock()
p.providers = append(p.providers, llm.Provider{
Name: msg.Name, Protocol: llm.Protocol(msg.Protocol), BaseURL: msg.BaseURL,
})
p.mu.Unlock()
case "tool_result", "hook_result":
p.mu.Lock()
ch := p.pending[msg.ID]
delete(p.pending, msg.ID)
p.mu.Unlock()
if ch != nil {
ch <- msg
}
case "ui_request":
select {
case p.UI <- UIRequest{ID: msg.ID, Message: msg.Message}:
default:
}
case "custom_entry":
if p.OnCustom != nil {
p.OnCustom(msg.CustomType, msg.Data)
}
}
}
// Close stops the subprocess.
func (p *Process) Close() error {
p.mu.Lock()
p.closed = true
_ = p.stdin.Close()
p.mu.Unlock()
if p.cmd.Process != nil {
_ = p.cmd.Process.Kill()
}
return p.cmd.Wait()
}
+100
View File
@@ -0,0 +1,100 @@
package ext
import (
"context"
"os"
"path/filepath"
"testing"
"time"
"agent-native/runtime/ext/pkg"
)
func TestExtensionRegistersAndHooks(t *testing.T) {
dir := t.TempDir()
script := filepath.Join(dir, "ext.sh")
body := `#!/bin/sh
while IFS= read -r line; do
case "$line" in
*'"type":"init"'*)
echo '{"type":"register_tool","name":"ping","description":"p","parameters":{"type":"object"}}'
echo '{"type":"register_command","name":"hi"}'
echo '{"type":"register_provider","name":"local","protocol":"openai-chat","baseURL":"http://127.0.0.1:9"}'
echo '{"type":"ui_request","id":"u1","message":"choose"}'
;;
*'"type":"tool_call"'*)
echo '{"type":"tool_result","id":"ping","content":"pong"}'
;;
*'"type":"event"'*)
echo '{"type":"hook_result","id":"ping:tool_call","block":true,"reason":"no"}'
;;
esac
done
`
if err := os.WriteFile(script, []byte(body), 0o755); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
proc, err := Start(ctx, []string{"/bin/sh", script})
if err != nil {
t.Fatal(err)
}
defer proc.Close()
deadline := time.Now().Add(2 * time.Second)
for len(proc.Tools()) == 0 && time.Now().Before(deadline) {
time.Sleep(20 * time.Millisecond)
}
tools := proc.Tools()
if len(tools) != 1 || tools[0].Name() != "ping" {
t.Fatalf("tools %+v", tools)
}
res, err := tools[0].Execute(ctx, []byte(`{}`))
if err != nil || res.Content != "pong" {
t.Fatalf("tool: %+v %v", res, err)
}
block, reason := proc.Hook(ctx, "tool_call", "ping", []byte(`{}`))
if !block || reason != "no" {
t.Fatalf("hook %v %s", block, reason)
}
if len(proc.Commands()) != 1 || len(proc.Providers()) != 1 {
t.Fatalf("commands %v providers %v", proc.Commands(), proc.Providers())
}
select {
case ui := <-proc.UI:
if ui.Message != "choose" {
t.Fatal(ui)
}
case <-time.After(time.Second):
t.Fatal("missing ui request")
}
}
func TestPackageInstallListConfigRemove(t *testing.T) {
src := t.TempDir()
if err := os.WriteFile(filepath.Join(src, "agent-native.json"), []byte(`{"name":"demo","extensions":["./ext.sh"]}`), 0o644); err != nil {
t.Fatal(err)
}
mgr := pkg.Manager{Dir: t.TempDir()}
manifest, err := mgr.Install(src)
if err != nil || manifest.Name != "demo" {
t.Fatalf("%+v %v", manifest, err)
}
if err := mgr.Config("demo", "source", src); err != nil {
t.Fatal(err)
}
list, err := mgr.List()
if err != nil || len(list) != 1 || list[0].Config["source"] != src {
t.Fatalf("list %+v %v", list, err)
}
if _, err := mgr.Update("demo"); err != nil {
t.Fatal(err)
}
if err := mgr.Remove("demo"); err != nil {
t.Fatal(err)
}
list, _ = mgr.List()
if len(list) != 0 {
t.Fatalf("still installed: %+v", list)
}
}
+4 -1
View File
@@ -53,6 +53,9 @@ func (c *Client) run(ctx context.Context, model string, messages []Message, tool
if opts.MaxTokens > 0 {
body["max_tokens"] = opts.MaxTokens
}
if effort := OpenAIReasoningEffort(opts.ThinkingLevel); effort != "" {
body["reasoning_effort"] = effort
}
if len(tools) > 0 {
body["tools"] = toProviderTools(tools)
body["tool_choice"] = "auto"
@@ -125,7 +128,7 @@ func toProviderMessages(messages []Message) []map[string]any {
default:
out = append(out, map[string]any{
"role": string(m.Role),
"content": m.Content,
"content": messageContent(m),
})
}
}
+431
View File
@@ -0,0 +1,431 @@
package llm
import (
"context"
"encoding/json"
"fmt"
"os"
"strings"
"time"
)
// Thinking levels, from off to max. Unknown values are rejected by NormalizeThinking.
var ThinkingLevels = []string{"off", "minimal", "low", "medium", "high", "xhigh", "max"}
// NormalizeThinking returns the canonical level. Empty stays empty.
func NormalizeThinking(level string) (string, error) {
if level == "" {
return "", nil
}
level = strings.ToLower(strings.TrimSpace(level))
for _, known := range ThinkingLevels {
if level == known {
return level, nil
}
}
return "", fmt.Errorf("llm: unknown thinking level %q", level)
}
// OpenAIReasoningEffort maps a thinking level onto OpenAI reasoning_effort.
// off and empty produce no parameter.
func OpenAIReasoningEffort(level string) string {
switch level {
case "minimal":
return "minimal"
case "low":
return "low"
case "medium":
return "medium"
case "high", "xhigh", "max":
return "high"
default:
return ""
}
}
// AnthropicThinkingBudget maps a thinking level onto a token budget.
// off and empty disable thinking.
func AnthropicThinkingBudget(level string) int {
switch level {
case "minimal":
return 1024
case "low":
return 2048
case "medium":
return 4096
case "high":
return 8192
case "xhigh":
return 16384
case "max":
return 32768
default:
return 0
}
}
// GoogleThinkingBudget maps a thinking level onto Gemini thinkingBudget.
func GoogleThinkingBudget(level string) int {
switch level {
case "minimal", "low":
return 1024
case "medium":
return 4096
case "high":
return 8192
case "xhigh", "max":
return 24576
default:
return 0
}
}
// ModelRef is provider/model with an optional :thinking suffix.
type ModelRef struct {
Provider string
Model string
Thinking string
}
// ParseModelRef accepts "model", "provider/model", and "provider/model:level".
func ParseModelRef(spec string) (ModelRef, error) {
spec = strings.TrimSpace(spec)
if spec == "" {
return ModelRef{}, fmt.Errorf("llm: empty model ref")
}
thinking := ""
if i := strings.LastIndex(spec, ":"); i >= 0 {
thinking = spec[i+1:]
spec = spec[:i]
norm, err := NormalizeThinking(thinking)
if err != nil {
return ModelRef{}, err
}
thinking = norm
}
provider, model := "", spec
if i := strings.Index(spec, "/"); i >= 0 {
provider = spec[:i]
model = spec[i+1:]
}
if model == "" {
return ModelRef{}, fmt.Errorf("llm: model id missing in %q", spec)
}
return ModelRef{Provider: provider, Model: model, Thinking: thinking}, nil
}
// Protocol names the wire format a provider speaks.
type Protocol string
const (
ProtocolOpenAIChat Protocol = "openai-chat"
ProtocolOpenAIResponses Protocol = "openai-responses"
ProtocolAnthropic Protocol = "anthropic"
ProtocolGoogle Protocol = "google"
ProtocolAzure Protocol = "azure"
)
// Provider is one configured endpoint.
type Provider struct {
Name string `json:"name"`
Protocol Protocol `json:"protocol"`
BaseURL string `json:"baseURL"`
APIKeyEnv string `json:"apiKeyEnv,omitempty"`
APIKey string `json:"-"`
}
// Presets are OpenAI-compatible or native endpoints that only need an API key.
func Presets() []Provider {
return []Provider{
{Name: "anthropic", Protocol: ProtocolAnthropic, BaseURL: "https://api.anthropic.com", APIKeyEnv: "ANTHROPIC_API_KEY"},
{Name: "openai", Protocol: ProtocolOpenAIChat, BaseURL: "https://api.openai.com/v1", APIKeyEnv: "OPENAI_API_KEY"},
{Name: "openai-responses", Protocol: ProtocolOpenAIResponses, BaseURL: "https://api.openai.com/v1", APIKeyEnv: "OPENAI_API_KEY"},
{Name: "google", Protocol: ProtocolGoogle, BaseURL: "https://generativelanguage.googleapis.com/v1beta", APIKeyEnv: "GEMINI_API_KEY"},
{Name: "azure", Protocol: ProtocolAzure, BaseURL: "", APIKeyEnv: "AZURE_OPENAI_API_KEY"},
{Name: "deepseek", Protocol: ProtocolOpenAIChat, BaseURL: "https://api.deepseek.com/v1", APIKeyEnv: "DEEPSEEK_API_KEY"},
{Name: "minimax", Protocol: ProtocolOpenAIChat, BaseURL: "https://api.minimaxi.com/v1", APIKeyEnv: "MINIMAX_API_KEY"},
{Name: "openrouter", Protocol: ProtocolOpenAIChat, BaseURL: "https://openrouter.ai/api/v1", APIKeyEnv: "OPENROUTER_API_KEY"},
{Name: "groq", Protocol: ProtocolOpenAIChat, BaseURL: "https://api.groq.com/openai/v1", APIKeyEnv: "GROQ_API_KEY"},
}
}
// modelsFile is the on-disk shape of models.json.
type modelsFile struct {
Providers []Provider `json:"providers"`
}
// LoadModelsJSON reads custom providers. Missing file yields an empty list.
func LoadModelsJSON(path string) ([]Provider, error) {
raw, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
var file modelsFile
if err := json.Unmarshal(raw, &file); err != nil {
return nil, err
}
for i := range file.Providers {
if file.Providers[i].APIKey == "" && file.Providers[i].APIKeyEnv != "" {
file.Providers[i].APIKey = os.Getenv(file.Providers[i].APIKeyEnv)
}
}
return file.Providers, nil
}
// Lookup finds a preset or a custom provider by name.
func Lookup(name string, custom []Provider) (Provider, bool) {
for _, p := range custom {
if p.Name == name {
return p, true
}
}
for _, p := range Presets() {
if p.Name == name {
if p.APIKey == "" {
p.APIKey = os.Getenv(p.APIKeyEnv)
}
return p, true
}
}
return Provider{}, false
}
func messageContent(m Message) any {
if len(m.Images) == 0 {
return m.Content
}
parts := []map[string]any{}
if m.Content != "" {
parts = append(parts, map[string]any{"type": "text", "text": m.Content})
}
for _, img := range m.Images {
url := img.URL
if url == "" && img.Data != "" {
mime := img.MIME
if mime == "" {
mime = "image/png"
}
url = "data:" + mime + ";base64," + img.Data
}
parts = append(parts, map[string]any{
"type": "image_url",
"image_url": map[string]any{"url": url},
})
}
return parts
}
// BuildBody returns the JSON body for one native protocol.
func BuildBody(protocol Protocol, model string, messages []Message, tools []ToolDecl, opts StreamOptions) ([]byte, error) {
switch protocol {
case ProtocolOpenAIChat, ProtocolAzure:
body := map[string]any{
"model": model,
"messages": toProviderMessages(messages),
"stream": true,
}
if opts.MaxTokens > 0 {
body["max_tokens"] = opts.MaxTokens
}
if effort := OpenAIReasoningEffort(opts.ThinkingLevel); effort != "" {
body["reasoning_effort"] = effort
}
if len(tools) > 0 {
body["tools"] = toProviderTools(tools)
}
return json.Marshal(body)
case ProtocolOpenAIResponses:
body := map[string]any{
"model": model,
"input": responsesInput(messages),
"stream": true,
}
if opts.MaxTokens > 0 {
body["max_output_tokens"] = opts.MaxTokens
}
if effort := OpenAIReasoningEffort(opts.ThinkingLevel); effort != "" {
body["reasoning"] = map[string]any{"effort": effort}
}
return json.Marshal(body)
case ProtocolAnthropic:
sys, msgs := anthropicMessages(messages)
body := map[string]any{
"model": model,
"messages": msgs,
"max_tokens": opts.MaxTokens,
"stream": true,
}
if body["max_tokens"] == 0 {
body["max_tokens"] = 4096
}
if sys != "" {
body["system"] = sys
}
if budget := AnthropicThinkingBudget(opts.ThinkingLevel); budget > 0 {
body["thinking"] = map[string]any{"type": "enabled", "budget_tokens": budget}
}
return json.Marshal(body)
case ProtocolGoogle:
body := map[string]any{
"contents": googleContents(messages),
}
if budget := GoogleThinkingBudget(opts.ThinkingLevel); budget > 0 {
body["generationConfig"] = map[string]any{
"thinkingConfig": map[string]any{"thinkingBudget": budget},
}
}
return json.Marshal(body)
default:
return nil, fmt.Errorf("llm: unknown protocol %q", protocol)
}
}
func responsesInput(messages []Message) []map[string]any {
out := make([]map[string]any, 0, len(messages))
for _, m := range messages {
role := string(m.Role)
if role == "" {
role = "user"
}
out = append(out, map[string]any{
"role": role,
"content": messageContent(m),
})
}
return out
}
func anthropicMessages(messages []Message) (string, []map[string]any) {
var system strings.Builder
var out []map[string]any
for _, m := range messages {
if m.Role == RoleSystem {
if system.Len() > 0 {
system.WriteString("\n")
}
system.WriteString(m.Content)
continue
}
role := "user"
if m.Role == RoleAssistant {
role = "assistant"
}
content := []map[string]any{}
if m.Content != "" {
content = append(content, map[string]any{"type": "text", "text": m.Content})
}
for _, img := range m.Images {
content = append(content, map[string]any{
"type": "image",
"source": map[string]any{
"type": "base64",
"media_type": firstNonEmpty(img.MIME, "image/png"),
"data": img.Data,
},
})
}
out = append(out, map[string]any{"role": role, "content": content})
}
return system.String(), out
}
func googleContents(messages []Message) []map[string]any {
var out []map[string]any
for _, m := range messages {
if m.Role == RoleSystem {
continue
}
role := "user"
if m.Role == RoleAssistant {
role = "model"
}
parts := []map[string]any{}
if m.Content != "" {
parts = append(parts, map[string]any{"text": m.Content})
}
for _, img := range m.Images {
parts = append(parts, map[string]any{
"inlineData": map[string]any{
"mimeType": firstNonEmpty(img.MIME, "image/png"),
"data": img.Data,
},
})
}
out = append(out, map[string]any{"role": role, "parts": parts})
}
return out
}
func firstNonEmpty(a, b string) string {
if a != "" {
return a
}
return b
}
// ContextOverflow reports provider errors that ask the caller to compact.
func ContextOverflow(message string) bool {
m := strings.ToLower(message)
return strings.Contains(m, "context") && (strings.Contains(m, "overflow") || strings.Contains(m, "too long") || strings.Contains(m, "length"))
}
// WithRetry re-invokes base on a retryable terminal error, up to attempts
// extra tries. onEvent receives auto_retry_start and auto_retry_end.
// Context-overflow errors are returned immediately so the caller can compact.
func WithRetry(base StreamFunc, attempts int, onEvent func(kind, detail string)) StreamFunc {
if attempts < 1 {
attempts = 1
}
return func(ctx context.Context, model string, messages []Message, tools []ToolDecl, opts StreamOptions) <-chan Event {
out := make(chan Event)
go func() {
defer close(out)
var last []Event
for try := 0; try <= attempts; try++ {
if try > 0 {
detail := fmt.Sprintf("attempt %d", try+1)
if onEvent != nil {
onEvent("auto_retry_start", detail)
}
delay := time.Duration(200*(1<<uint(try-1))) * time.Millisecond
select {
case <-ctx.Done():
emitAbort(out, "", ctx.Err())
return
case <-time.After(delay):
}
}
last = nil
for ev := range base(ctx, model, messages, tools, opts) {
last = append(last, ev)
}
overflow, retryable := false, false
for _, ev := range last {
if ev.Kind == KindError && ContextOverflow(ev.ErrorMessage) {
overflow = true
}
if ev.Kind == KindError && ev.Retryable {
retryable = true
}
}
if overflow || !retryable || try == attempts {
if try > 0 && onEvent != nil {
onEvent("auto_retry_end", fmt.Sprintf("attempt %d", try+1))
}
for _, ev := range last {
select {
case <-ctx.Done():
return
case out <- ev:
}
}
return
}
}
}()
return out
}
}
+115
View File
@@ -0,0 +1,115 @@
package llm
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
func TestParseModelRefAndThinking(t *testing.T) {
ref, err := ParseModelRef("anthropic/claude-sonnet:high")
if err != nil {
t.Fatal(err)
}
if ref.Provider != "anthropic" || ref.Model != "claude-sonnet" || ref.Thinking != "high" {
t.Fatalf("%+v", ref)
}
if _, err := ParseModelRef("openai/gpt:nope"); err == nil {
t.Fatal("unknown thinking level must fail")
}
if AnthropicThinkingBudget("high") != 8192 || OpenAIReasoningEffort("off") != "" {
t.Fatal("thinking map")
}
p, ok := Lookup("groq", nil)
if !ok || !strings.Contains(p.BaseURL, "groq") || p.Protocol != ProtocolOpenAIChat {
t.Fatalf("preset: %+v", p)
}
}
func TestModelsJSONAndBodies(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "models.json")
raw := []byte(`{"providers":[{"name":"local","protocol":"openai-chat","baseURL":"http://127.0.0.1:9","apiKeyEnv":"UNUSED_KEY"}]}`)
if err := os.WriteFile(path, raw, 0o644); err != nil {
t.Fatal(err)
}
got, err := LoadModelsJSON(path)
if err != nil || len(got) != 1 || got[0].Name != "local" {
t.Fatalf("%+v %v", got, err)
}
msgs := []Message{{
Role: RoleUser, Content: "see",
Images: []Image{{MIME: "image/png", Data: "aaaa"}},
}}
body, err := BuildBody(ProtocolAnthropic, "claude", append([]Message{{Role: RoleSystem, Content: "sys"}}, msgs...), nil, StreamOptions{ThinkingLevel: "low"})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(body), "budget_tokens") || !strings.Contains(string(body), "base64") {
t.Fatalf("anthropic body: %s", body)
}
gbody, err := BuildBody(ProtocolGoogle, "gemini", msgs, nil, StreamOptions{ThinkingLevel: "max"})
if err != nil || !strings.Contains(string(gbody), "inlineData") || !strings.Contains(string(gbody), "thinkingBudget") {
t.Fatalf("google: %s %v", gbody, err)
}
rbody, err := BuildBody(ProtocolOpenAIResponses, "gpt", msgs, nil, StreamOptions{ThinkingLevel: "medium", MaxTokens: 20})
if err != nil || !strings.Contains(string(rbody), "max_output_tokens") || !strings.Contains(string(rbody), "image_url") {
t.Fatalf("responses: %s %v", rbody, err)
}
var decoded map[string]any
if err := json.Unmarshal(rbody, &decoded); err != nil {
t.Fatal(err)
}
}
func TestRetryThenSuccess(t *testing.T) {
n := 0
base := StreamFunc(func(ctx context.Context, model string, messages []Message, tools []ToolDecl, opts StreamOptions) <-chan Event {
out := make(chan Event, 2)
n++
if n == 1 {
out <- Event{Kind: KindError, Retryable: true, ErrorMessage: "429"}
} else {
out <- Event{Kind: KindTextDelta, Text: "ok"}
out <- Event{Kind: KindMessageEnd, Message: Message{Role: RoleAssistant, Content: "ok"}}
}
close(out)
return out
})
var events []string
wrapped := WithRetry(base, 2, func(kind, detail string) { events = append(events, kind) })
var texts []string
for ev := range wrapped(context.Background(), "m", nil, nil, StreamOptions{}) {
if ev.Kind == KindTextDelta {
texts = append(texts, ev.Text)
}
}
if len(texts) != 1 || texts[0] != "ok" {
t.Fatalf("texts %v calls %d", texts, n)
}
if len(events) == 0 || events[0] != "auto_retry_start" {
t.Fatalf("events %v", events)
}
}
func TestContextOverflowIsNotRetried(t *testing.T) {
if !ContextOverflow("maximum context length exceeded") {
t.Fatal("expected overflow")
}
n := 0
base := StreamFunc(func(ctx context.Context, model string, messages []Message, tools []ToolDecl, opts StreamOptions) <-chan Event {
out := make(chan Event, 1)
n++
out <- Event{Kind: KindError, Retryable: true, ErrorMessage: "context length overflow"}
close(out)
return out
})
for range WithRetry(base, 3, nil)(context.Background(), "m", nil, nil, StreamOptions{}) {
}
if n != 1 {
t.Fatalf("overflow must not retry, calls=%d", n)
}
}
+13 -3
View File
@@ -26,12 +26,19 @@ type sseChoice struct {
type sseChunk struct {
Choices []sseChoice `json:"choices"`
Usage *Usage `json:"usage"`
Usage *usageWire `json:"usage"`
Error *struct {
Message string `json:"message"`
} `json:"error"`
}
type usageWire struct {
Usage
Details struct {
Cached int `json:"cached_tokens"`
} `json:"prompt_tokens_details"`
}
// 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 {
@@ -77,7 +84,10 @@ func emitParsed(ctx context.Context, r io.Reader, out chan<- Event) {
return
}
if chunk.Usage != nil {
usage = *chunk.Usage
usage = chunk.Usage.Usage
if chunk.Usage.Details.Cached > 0 {
usage.CacheReadTokens = chunk.Usage.Details.Cached
}
}
if len(chunk.Choices) == 0 {
continue
@@ -154,7 +164,7 @@ func emitParsed(ctx context.Context, r io.Reader, out chan<- Event) {
select {
case <-ctx.Done():
emitAbort(out, content.String(), ctx.Err())
case out <- Event{Kind: KindMessageEnd, Message: msg, Usage: usage}:
case out <- Event{Kind: KindMessageEnd, Message: msg, Usage: usage, FinishReason: finished}:
}
}
+23
View File
@@ -38,6 +38,29 @@ func TestParseSSETextAndUsage(t *testing.T) {
if end.Usage.TotalTokens != 5 {
t.Fatalf("usage %#v", end.Usage)
}
if end.FinishReason != "stop" {
t.Fatalf("finish reason %q, want stop", end.FinishReason)
}
}
func TestParseSSEFinishReasonLengthKeepsStopEnd(t *testing.T) {
body := strings.Join([]string{
`data: {"choices":[{"delta":{"content":"半"},"finish_reason":"length"}]}`,
`data: [DONE]`,
"",
}, "\n")
var end Event
for ev := range ParseSSE(context.Background(), strings.NewReader(body)) {
if ev.Kind == KindMessageEnd {
end = ev
}
}
if end.FinishReason != "length" {
t.Fatalf("finish reason %q, want length", end.FinishReason)
}
if end.Message.StopReason != StopEnd {
t.Fatalf("stop reason %s, want end so existing callers stay unchanged", end.Message.StopReason)
}
}
func TestParseSSEToolCallIncrements(t *testing.T) {
+19 -3
View File
@@ -48,6 +48,7 @@ type Message struct {
Role Role `json:"role"`
Content string `json:"content,omitempty"`
Name string `json:"name,omitempty"`
Images []Image `json:"images,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
StopReason StopReason `json:"stop_reason,omitempty"`
@@ -61,11 +62,20 @@ type ToolDecl struct {
Parameters json.RawMessage `json:"parameters"`
}
// Image is one picture attached to a user message.
type Image struct {
MIME string `json:"mime,omitempty"`
URL string `json:"url,omitempty"`
Data string `json:"data,omitempty"`
}
// Usage counts tokens reported by the provider. Zero means the provider omitted usage.
type Usage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
CacheReadTokens int `json:"cache_read_tokens,omitempty"`
CacheWriteTokens int `json:"cache_write_tokens,omitempty"`
}
// Event is one streamed update. Failures are KindError plus a final assistant
@@ -80,9 +90,12 @@ type Event struct {
ArgumentsDelta string
Message Message
Usage Usage
Err error
Retryable bool
ErrorMessage string
// FinishReason 是供应商原始结束原因(stop、length、tool_calls 等)。
// 空表示这条事件不携带结束原因。StopReason 的映射不受此字段影响。
FinishReason string
Err error
Retryable bool
ErrorMessage string
}
// StreamOptions tunes one provider request.
@@ -90,6 +103,9 @@ type StreamOptions struct {
MaxTokens int
APIBase string
APIKey string
// ThinkingLevel is off, minimal, low, medium, high, xhigh, or max.
// Empty leaves the provider default unchanged.
ThinkingLevel string
}
// StreamFunc is the provider boundary. It must return a channel that is closed
+290 -10
View File
@@ -3,19 +3,30 @@ package runtime
import (
"context"
"encoding/json"
"fmt"
"strings"
"sync"
"time"
"agent-native/runtime/llm"
"agent-native/runtime/session"
"agent-native/runtime/tools"
"logging-system/logsys"
)
const (
ExecSequential = "sequential"
ExecParallel = "parallel"
maxStopContinuations = 3
maxLengthContinuations = 3
maxRepeatReminders = 3
)
const repeatReminderText = "你已连续 3 次以相同参数调用同一工具。如果结果不符合预期,请更换参数或做法,不要再重复这一调用。"
const lengthContinuePrompt = "上一段回复因为达到长度上限被截断。请从断点接着写,不要重复已经写出的内容。"
// Event is a lifecycle notice for logs and UI.
type Event struct {
Type string
@@ -25,6 +36,11 @@ type Event struct {
// Detail carries tool arguments/results or reasoning deltas for
// process-visualization consumers; log writers may ignore it.
Detail string
// PromptTokens / CompletionTokens / TotalTokens 在 type=usage 或
// type=message_end 时有意义,供调用方累计用量。
PromptTokens int
CompletionTokens int
TotalTokens int
}
// Config drives one agent session.
@@ -39,6 +55,60 @@ type Config struct {
GetSteering func() []llm.Message
GetFollowUp func() []llm.Message
OnEvent func(Event)
// ThinkingLevel is forwarded on each provider request.
ThinkingLevel string
// Resolve, when set, overrides Model and ThinkingLevel at the start of each turn.
Resolve func() (model, thinking string)
// Log 可选。nil 时丢弃日志,不改变循环结果。
Log *logsys.Logger
// ExecutionID 只用于日志字段,不参与控制流。
ExecutionID string
// Budget 可选。任一上限命中即停止并返回 budget_exceeded 错误(StopError);
// 零值字段不设限。截止时刻与外层 ctx 先到者生效。
Budget *Budget
// History 是会话转录为空时的初始转录。以 system 开头时,不再前置
// SystemPrompt 和工具清单。不回写进 Session。
History []llm.Message
// Stop 在模型不再调用工具、且本轮没有 follow-up 时询问是否续跑。
// nil 表示不询问。内核最多接受 3 次 Continue。
Stop func(last llm.Message, info StopInfo) StopDecision
// BeforeTool 返回错误时不执行工具,但仍写一条 IsError 的 tool 结果。
BeforeTool func(call llm.ToolCall) error
// UnknownTool 处理注册表里没有的调用。nil 时仍返回 unknown tool。
UnknownTool func(call llm.ToolCall) tools.Result
// AfterTool 只观察工具结果,不能改写。
AfterTool func(call llm.ToolCall, res llm.Message)
// Reminders 在每次调用模型前提供提醒。正文包在 system-reminder 里,
// 只进入本次请求,不写进 Session。
Reminders func(turn int) []string
// RepeatGuard 为真时,同名且参数按键排序后相同的调用连续 3 次,注入一条提醒。
// 每个 run 最多 3 条,不中断循环。
RepeatGuard bool
// ContinueOnLength 为真时,FinishReason=length 且没有工具调用就续写,最多 3 次。
// 续写提示不写进 Session。第 4 次截断返回错误。
ContinueOnLength bool
}
// StopInfo 是 Stop 钩子看到的本 run 进度。
type StopInfo struct {
Continuations int
ToolCalls int
}
// StopDecision 决定模型停下之后是否再跑一轮。
type StopDecision struct {
Continue bool
Replacement string
Reason string
Notice string
}
// Budget 是循环层的执行预算;与 exec.Budget 字段同构,由适配层换算。
type Budget struct {
MaxPromptTokens int
MaxCompletionTokens int
MaxToolCalls int
Deadline time.Time
}
// Result is the final assistant text and the transcript produced by one run.
@@ -47,6 +117,7 @@ type Result struct {
Messages []llm.Message
StopReason llm.StopReason
Usage llm.Usage
Notice string
Err error
Retryable bool
}
@@ -70,6 +141,9 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) {
}
messages = loaded
}
if len(messages) == 0 && len(cfg.History) > 0 {
messages = append([]llm.Message{}, cfg.History...)
}
if len(messages) == 0 || messages[0].Role != llm.RoleSystem {
sys := llm.Message{Role: llm.RoleSystem, Content: systemText(cfg)}
messages = append([]llm.Message{sys}, messages...)
@@ -85,13 +159,25 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) {
var last llm.Message
var usage llm.Usage
toolCalls := 0
stopContinues := 0
lengthContinues := 0
lengthNudge := false
var repeats repeatState
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
}
if reason := budgetExceeded(cfg.Budget, usage, toolCalls, time.Now()); reason != "" {
return budgetStop(cfg, last, messages, usage, reason), nil
}
emit(cfg, Event{Type: "turn_start"})
end, errEv := consume(ctx, cfg, messages)
emit(cfg, Event{Type: "message_start"})
request := withEphemeral(messages, turnReminders(cfg, turn, &repeats, &lengthNudge))
end, errEv := consume(ctx, cfg, request)
if errEv != nil {
cfg.logger().Error("runtime turn failed", errEv, "retryable", end.Retryable)
return Result{Text: end.Message.Content, Messages: messages, StopReason: end.Message.StopReason, Err: errEv, Retryable: end.Retryable}, nil
}
last = end.Message
@@ -102,9 +188,17 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) {
return Result{}, err
}
messages = append(messages, last)
emit(cfg, Event{Type: "message_end", Message: last})
emit(cfg, Event{Type: "message_end", Message: last, PromptTokens: end.Usage.PromptTokens, CompletionTokens: end.Usage.CompletionTokens, TotalTokens: end.Usage.TotalTokens})
if last.StopReason == llm.StopToolCalls && len(last.ToolCalls) > 0 {
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)
return budgetStop(cfg, last, messages, usage, reason), nil
}
toolCalls += len(last.ToolCalls)
if cfg.RepeatGuard {
repeats.observe(last.ToolCalls)
}
results := executeTools(ctx, cfg, last)
for _, res := range results {
if err := persist(cfg, res); err != nil {
@@ -124,6 +218,16 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) {
continue
}
emit(cfg, Event{Type: "turn_end", Message: last})
if cfg.ContinueOnLength && end.FinishReason == "length" && len(last.ToolCalls) == 0 {
if lengthContinues >= maxLengthContinuations {
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
}
lengthContinues++
lengthNudge = true
continue
}
if follow := drain(cfg.GetFollowUp); len(follow) > 0 {
for _, m := range follow {
if err := persist(cfg, m); err != nil {
@@ -133,18 +237,49 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) {
}
continue
}
emit(cfg, Event{Type: "agent_end", Message: last})
return Result{Text: last.Content, Messages: messages, StopReason: last.StopReason, Usage: usage}, nil
if cfg.Stop != nil {
decision := cfg.Stop(last, StopInfo{Continuations: stopContinues, ToolCalls: toolCalls})
if decision.Continue && stopContinues < maxStopContinuations {
stopContinues++
if decision.Replacement != "" && len(messages) > 0 {
messages[len(messages)-1].Content = decision.Replacement
last.Content = decision.Replacement
}
if decision.Reason != "" {
reasonMsg := llm.Message{Role: llm.RoleUser, Content: decision.Reason}
if err := persist(cfg, reasonMsg); err != nil {
return Result{}, err
}
messages = append(messages, reasonMsg)
}
continue
}
return settle(cfg, last, messages, usage, decision.Notice), nil
}
return settle(cfg, last, messages, usage, ""), nil
}
errCap := errString("max turns exceeded")
cfg.logger().Error("runtime turn cap hit", errCap)
return Result{
Text: last.Content,
Messages: messages,
StopReason: llm.StopError,
Usage: usage,
Err: errString("max turns exceeded"),
Err: errCap,
}, nil
}
func (cfg Config) logger() *logsys.Logger {
if cfg.Log == nil {
return logsys.Nop()
}
log := cfg.Log.Module("runtime")
if cfg.ExecutionID == "" {
return log
}
return log.With("execution_id", cfg.ExecutionID)
}
func systemText(cfg Config) string {
var b strings.Builder
b.WriteString(cfg.SystemPrompt)
@@ -175,9 +310,21 @@ func consume(ctx context.Context, cfg Config, messages []llm.Message) (llm.Event
decls = append(decls, llm.ToolDecl{Name: d.Name, Description: d.Description, Parameters: d.Parameters})
}
}
model := cfg.Model
opts := llm.StreamOptions{ThinkingLevel: cfg.ThinkingLevel}
if cfg.Resolve != nil {
if m, th := cfg.Resolve(); m != "" || th != "" {
if m != "" {
model = m
}
if th != "" {
opts.ThinkingLevel = th
}
}
}
var end llm.Event
sawEnd := false
for ev := range cfg.Stream(ctx, cfg.Model, messages, decls, llm.StreamOptions{}) {
for ev := range cfg.Stream(ctx, model, messages, decls, opts) {
switch ev.Kind {
case llm.KindTextDelta:
emit(cfg, Event{Type: "text_delta", Text: ev.Text})
@@ -224,19 +371,119 @@ func executeParallel(ctx context.Context, cfg Config, assistant llm.Message) []l
}
func runOne(ctx context.Context, cfg Config, call llm.ToolCall) llm.Message {
emit(cfg, Event{Type: "tool_execution_start", Tool: call.Name})
emit(cfg, Event{Type: "tool_execution_start", Tool: call.Name, Detail: call.Arguments})
if cfg.BeforeTool != nil {
if err := cfg.BeforeTool(call); err != nil {
msg := llm.Message{Role: llm.RoleTool, Name: call.Name, ToolCallID: call.ID, Content: err.Error(), IsError: true}
emit(cfg, Event{Type: "tool_execution_end", Tool: call.Name, Text: msg.Content})
if cfg.AfterTool != nil {
cfg.AfterTool(call, msg)
}
return msg
}
}
emit(cfg, Event{Type: "tool_execution_update", Tool: call.Name, Detail: call.Arguments})
res := tools.Result{Content: "no tools configured", IsError: true}
if cfg.Tools != nil {
known := cfg.Tools != nil && cfg.Tools.Has(call.Name)
switch {
case known:
res = cfg.Tools.Execute(ctx, call.Name, json.RawMessage(call.Arguments))
case cfg.UnknownTool != nil:
res = cfg.UnknownTool(call)
case cfg.Tools != nil:
res = cfg.Tools.Execute(ctx, call.Name, json.RawMessage(call.Arguments))
}
emit(cfg, Event{Type: "tool_execution_end", Tool: call.Name, Text: res.Content})
return llm.Message{
msg := llm.Message{
Role: llm.RoleTool,
Name: call.Name,
ToolCallID: call.ID,
Content: res.Content,
IsError: res.IsError,
}
emit(cfg, Event{Type: "tool_execution_end", Tool: call.Name, Text: res.Content})
if cfg.AfterTool != nil {
cfg.AfterTool(call, msg)
}
return msg
}
func settle(cfg Config, last llm.Message, messages []llm.Message, usage llm.Usage, 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}
}
func reminderMessage(text string) llm.Message {
return llm.Message{Role: llm.RoleUser, Content: "<system-reminder>\n" + text + "\n</system-reminder>"}
}
func turnReminders(cfg Config, turn int, repeats *repeatState, lengthNudge *bool) []llm.Message {
var extra []llm.Message
if cfg.Reminders != nil {
for _, text := range cfg.Reminders(turn) {
if strings.TrimSpace(text) == "" {
continue
}
extra = append(extra, reminderMessage(text))
}
}
if lengthNudge != nil && *lengthNudge {
extra = append(extra, llm.Message{Role: llm.RoleUser, Content: lengthContinuePrompt})
*lengthNudge = false
}
if cfg.RepeatGuard && repeats != nil {
due := repeats.streak/3 - repeats.reminders
room := maxRepeatReminders - repeats.reminders
if due > room {
due = room
}
for i := 0; i < due; i++ {
extra = append(extra, reminderMessage(repeatReminderText))
repeats.reminders++
}
}
return extra
}
func withEphemeral(messages, extra []llm.Message) []llm.Message {
if len(extra) == 0 {
return messages
}
out := make([]llm.Message, 0, len(messages)+len(extra))
out = append(out, messages...)
out = append(out, extra...)
return out
}
type repeatState struct {
sig string
streak int
reminders int
}
func (s *repeatState) observe(calls []llm.ToolCall) {
for _, call := range calls {
sig := call.Name + "\n" + canonicalArgs(call.Arguments)
if sig == s.sig {
s.streak++
continue
}
s.sig = sig
s.streak = 1
}
}
func canonicalArgs(raw string) string {
var v any
if err := json.Unmarshal([]byte(raw), &v); err != nil {
return raw
}
b, err := json.Marshal(v)
if err != nil {
return raw
}
return string(b)
}
func persist(cfg Config, msg llm.Message) error {
@@ -253,12 +500,45 @@ func drain(fn func() []llm.Message) []llm.Message {
return fn()
}
func emitUsage(cfg Config, usage llm.Usage) {
emit(cfg, Event{Type: "usage", PromptTokens: usage.PromptTokens, CompletionTokens: usage.CompletionTokens, TotalTokens: usage.TotalTokens})
}
func emit(cfg Config, ev Event) {
if cfg.OnEvent != nil {
cfg.OnEvent(ev)
}
}
// budgetExceeded 返回命中的预算说明;空串表示预算仍充足(或未设预算)。
// 只在「即将消耗更多预算」的节点调用(轮次顶端 / 工具批执行前)。
func budgetExceeded(b *Budget, usage llm.Usage, toolCalls int, now time.Time) string {
if b == nil {
return ""
}
if !b.Deadline.IsZero() && now.After(b.Deadline) {
return fmt.Sprintf("budget_exceeded: deadline %s passed", b.Deadline.Format(time.RFC3339))
}
if b.MaxPromptTokens > 0 && usage.PromptTokens > b.MaxPromptTokens {
return fmt.Sprintf("budget_exceeded: prompt tokens %d > max %d", usage.PromptTokens, b.MaxPromptTokens)
}
if b.MaxCompletionTokens > 0 && usage.CompletionTokens > b.MaxCompletionTokens {
return fmt.Sprintf("budget_exceeded: completion tokens %d > max %d", usage.CompletionTokens, b.MaxCompletionTokens)
}
if b.MaxToolCalls > 0 && toolCalls > b.MaxToolCalls {
return fmt.Sprintf("budget_exceeded: tool calls %d > max %d", toolCalls, b.MaxToolCalls)
}
return ""
}
// budgetStop 以预算超限收尾:发事件、留日志、按 StopError 返回(不可重试)。
func budgetStop(cfg Config, last llm.Message, messages []llm.Message, usage llm.Usage, reason string) Result {
emit(cfg, Event{Type: "budget_exceeded", Text: reason})
err := errString(reason)
cfg.logger().Error("runtime budget exceeded", err)
return Result{Text: last.Content, Messages: messages, StopReason: llm.StopError, Usage: usage, Err: err}
}
type errString string
func (e errString) Error() string { return string(e) }
+194
View File
@@ -0,0 +1,194 @@
package runtime
import (
"context"
"encoding/json"
"strings"
"testing"
"time"
"agent-native/runtime/llm"
"agent-native/runtime/tools"
)
// usageScript 产出带 usage 的固定回合,用于预算测试。
func usageScript(usage llm.Usage, turns ...llm.Message) llm.StreamFunc {
var i int
return 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 i >= len(turns) {
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd}, Usage: usage}
return
}
msg := turns[i]
i++
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: msg, Usage: usage}
}()
return ch
}
}
func TestLoopBudgetToolCallCap(t *testing.T) {
dir := t.TempDir()
reg := tools.WorkspaceTools(dir)
sawBudgetEvent := false
res, err := Run(context.Background(), Config{
Model: "fake",
Tools: reg,
Budget: &Budget{MaxToolCalls: 1},
OnEvent: func(ev Event) {
if ev.Type == "budget_exceeded" {
sawBudgetEvent = true
}
},
Stream: usageScript(llm.Usage{},
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
ID: "c1", Name: "list_dir", Arguments: `{}`,
}}},
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{
{ID: "c2", Name: "list_dir", Arguments: `{}`},
{ID: "c3", Name: "list_dir", Arguments: `{}`},
}},
llm.Message{Role: llm.RoleAssistant, Content: "never", StopReason: llm.StopEnd},
),
}, llm.Message{Role: llm.RoleUser, Content: "loop tools"})
if err != nil {
t.Fatal(err)
}
if res.StopReason != llm.StopError || res.Err == nil || !strings.Contains(res.Err.Error(), "budget_exceeded") {
t.Fatalf("want budget_exceeded stop, got stop=%s err=%v", res.StopReason, res.Err)
}
if !strings.Contains(res.Err.Error(), "tool calls") {
t.Fatalf("want tool-call budget reason, got %v", res.Err)
}
if !sawBudgetEvent {
t.Fatal("expected budget_exceeded event")
}
// 第二批工具调用不该被执行(预算在执行前拦截):transcript 只含一次 tool 结果。
toolResults := 0
for _, m := range res.Messages {
if m.Role == llm.RoleTool {
toolResults++
}
}
if toolResults != 1 {
t.Fatalf("expected exactly 1 tool result, got %d", toolResults)
}
}
func TestLoopBudgetPromptTokens(t *testing.T) {
res, err := Run(context.Background(), Config{
Model: "fake",
Budget: &Budget{MaxPromptTokens: 10},
Stream: usageScript(llm.Usage{PromptTokens: 500, TotalTokens: 600},
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
ID: "c1", Name: "list_dir", Arguments: `{}`,
}}},
llm.Message{Role: llm.RoleAssistant, Content: "never", StopReason: llm.StopEnd},
),
}, llm.Message{Role: llm.RoleUser, Content: "go"})
if err != nil {
t.Fatal(err)
}
if res.Err == nil || !strings.Contains(res.Err.Error(), "prompt tokens") {
t.Fatalf("want prompt-token budget stop, got err=%v", res.Err)
}
if res.Usage.PromptTokens != 500 {
t.Fatalf("usage should be reported on budget stop, got %d", res.Usage.PromptTokens)
}
}
func TestLoopBudgetDeadline(t *testing.T) {
res, err := Run(context.Background(), Config{
Model: "fake",
Budget: &Budget{Deadline: time.Now().Add(-time.Second)},
Stream: usageScript(llm.Usage{}, llm.Message{Role: llm.RoleAssistant, Content: "never", StopReason: llm.StopEnd}),
}, llm.Message{Role: llm.RoleUser, Content: "go"})
if err != nil {
t.Fatal(err)
}
if res.Err == nil || !strings.Contains(res.Err.Error(), "deadline") {
t.Fatalf("want deadline budget stop, got err=%v", res.Err)
}
}
func TestLoopBudgetNotHitCompletesNormally(t *testing.T) {
res, err := Run(context.Background(), Config{
Model: "fake",
Budget: &Budget{MaxPromptTokens: 10_000, MaxToolCalls: 10},
Stream: usageScript(llm.Usage{PromptTokens: 50},
llm.Message{Role: llm.RoleAssistant, Content: "ok", StopReason: llm.StopEnd}),
}, llm.Message{Role: llm.RoleUser, Content: "go"})
if err != nil {
t.Fatal(err)
}
if res.Err != nil || res.Text != "ok" {
t.Fatalf("clean run should finish, got err=%v text=%q", res.Err, res.Text)
}
}
// blockingTool 阻塞到 ctx 取消,验证取消传播到工具执行层。
type blockingTool struct{ canceled chan struct{} }
func (b *blockingTool) Name() string { return "block_forever" }
func (b *blockingTool) Description() string { return "blocks until context cancel" }
func (b *blockingTool) Parameters() json.RawMessage {
return json.RawMessage(`{"type":"object","properties":{}}`)
}
func (b *blockingTool) Execute(ctx context.Context, args json.RawMessage) (tools.Result, error) {
<-ctx.Done()
close(b.canceled)
return tools.Result{Content: "canceled", IsError: true}, ctx.Err()
}
func TestLoopCancelPropagatesToTool(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: "c1", 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
}
go func() {
time.Sleep(100 * time.Millisecond)
cancel()
}()
res, err := Run(ctx, Config{Model: "fake", Tools: reg, Stream: stream},
llm.Message{Role: llm.RoleUser, Content: "block"})
if err != nil {
t.Fatal(err)
}
select {
case <-bt.canceled:
case <-time.After(2 * time.Second):
t.Fatal("tool context was not canceled")
}
if res.StopReason != llm.StopAborted && res.Err == nil {
t.Fatalf("expected aborted stop, got %+v", res)
}
}
+332
View File
@@ -0,0 +1,332 @@
package runtime
import (
"context"
"encoding/json"
"errors"
"path/filepath"
"strings"
"sync"
"testing"
"agent-native/runtime/llm"
"agent-native/runtime/session"
"agent-native/runtime/tools"
)
func TestLoopStopContinuesUntilCap(t *testing.T) {
var calls int
res, err := Run(context.Background(), Config{
Model: "fake",
MaxTurns: 8,
Stream: script(
llm.Message{Role: llm.RoleAssistant, Content: "forged-claim", StopReason: llm.StopEnd},
llm.Message{Role: llm.RoleAssistant, Content: "forged-claim", StopReason: llm.StopEnd},
llm.Message{Role: llm.RoleAssistant, Content: "forged-claim", StopReason: llm.StopEnd},
llm.Message{Role: llm.RoleAssistant, Content: "forged-claim", StopReason: llm.StopEnd},
),
Stop: func(last llm.Message, info StopInfo) StopDecision {
calls++
if info.Continuations != calls-1 {
t.Errorf("continuations = %d, want %d", info.Continuations, calls-1)
}
return StopDecision{Continue: true, Replacement: "已作废", Reason: "再试", Notice: "到顶了"}
},
}, llm.Message{Role: llm.RoleUser, Content: "go"})
if err != nil {
t.Fatal(err)
}
if calls != 4 {
t.Fatalf("stop calls = %d, want 4", calls)
}
if res.Err != nil || res.Notice != "到顶了" {
t.Fatalf("notice %q err %v", res.Notice, res.Err)
}
replaced, forged := 0, 0
for _, m := range res.Messages {
if m.Role != llm.RoleAssistant {
continue
}
if m.Content == "已作废" {
replaced++
}
if m.Content == "forged-claim" {
forged++
}
}
if replaced != 3 || forged != 1 {
t.Fatalf("replaced=%d forged=%d transcript=%s", replaced, forged, transcriptText(res.Messages))
}
if strings.Count(transcriptText(res.Messages), "再试") != 3 {
t.Fatalf("reasons = %s", transcriptText(res.Messages))
}
}
func TestLoopBeforeToolRejectPairsResult(t *testing.T) {
hits := 0
reg := tools.NewRegistry(t.TempDir())
reg.Register(hitTool{hits: &hits})
var seen llm.Message
res, err := Run(context.Background(), Config{
Model: "fake",
Tools: reg,
Stream: script(
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
ID: "c1", Name: "echo", Arguments: `{}`,
}}},
llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd},
),
BeforeTool: func(call llm.ToolCall) error {
return errors.New("不允许")
},
AfterTool: func(call llm.ToolCall, msg llm.Message) {
seen = msg
},
}, llm.Message{Role: llm.RoleUser, Content: "go"})
if err != nil {
t.Fatal(err)
}
if hits != 0 {
t.Fatalf("tool executed %d times", hits)
}
if !seen.IsError || seen.Content != "不允许" || seen.ToolCallID != "c1" {
t.Fatalf("after = %#v", seen)
}
paired := false
for _, m := range res.Messages {
if m.Role == llm.RoleTool && m.ToolCallID == "c1" && m.IsError && m.Content == "不允许" {
paired = true
}
}
if !paired {
t.Fatalf("missing paired tool result: %s", transcriptText(res.Messages))
}
}
func TestLoopRemindersStayOutOfSession(t *testing.T) {
path := filepath.Join(t.TempDir(), "transcript.jsonl")
st, err := session.Open(path)
if err != nil {
t.Fatal(err)
}
var saw bool
_, err = Run(context.Background(), Config{
Model: "fake",
Session: st,
Stream: func(ctx context.Context, model string, messages []llm.Message, decls []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
for _, m := range messages {
if strings.Contains(m.Content, "<system-reminder>") && strings.Contains(m.Content, "先核对交付物") {
saw = true
}
}
ch := make(chan llm.Event, 1)
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{Role: llm.RoleAssistant, Content: "ok", StopReason: llm.StopEnd}}
close(ch)
return ch
},
Reminders: func(turn int) []string {
if turn != 0 {
t.Errorf("turn %d", turn)
}
return []string{"先核对交付物"}
},
}, llm.Message{Role: llm.RoleUser, Content: "ping"})
if err != nil {
t.Fatal(err)
}
if !saw {
t.Fatal("reminder missing from the provider request")
}
loaded, err := st.Load()
if err != nil {
t.Fatal(err)
}
if strings.Contains(transcriptText(loaded), "先核对交付物") {
t.Fatalf("reminder persisted: %#v", loaded)
}
}
func TestLoopRepeatGuardFiresAndCaps(t *testing.T) {
var mu sync.Mutex
var requests []string
turns := make([]llm.Message, 0, 13)
for i := 0; i < 12; i++ {
args := `{"b":1,"a":2}`
if i%2 == 1 {
args = `{"a":2,"b":1}`
}
turns = append(turns, llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
ID: "c", Name: "echo", Arguments: args,
}}})
}
turns = append(turns, llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd})
var i int
stream := func(ctx context.Context, model string, messages []llm.Message, decls []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
mu.Lock()
requests = append(requests, transcriptText(messages))
n := i
i++
mu.Unlock()
ch := make(chan llm.Event, 1)
msg := turns[len(turns)-1]
if n < len(turns) {
msg = turns[n]
}
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: msg}
close(ch)
return ch
}
res, err := Run(context.Background(), Config{
Model: "fake",
MaxTurns: 20,
RepeatGuard: true,
Stream: stream,
}, llm.Message{Role: llm.RoleUser, Content: "go"})
if err != nil {
t.Fatal(err)
}
if res.Text != "done" {
t.Fatalf("text %q", res.Text)
}
reminders := 0
for _, req := range requests {
reminders += strings.Count(req, repeatReminderText)
}
if reminders != 3 {
t.Fatalf("reminders = %d, want 3; requests=%d", reminders, len(requests))
}
if strings.Contains(transcriptText(res.Messages), repeatReminderText) {
t.Fatal("repeat reminder was written into the transcript")
}
if !strings.Contains(requests[3], repeatReminderText) {
t.Fatal("first reminder should arrive on the request after the third identical call")
}
}
func TestLoopContinueOnLengthCaps(t *testing.T) {
var requests []string
var n int
stream := func(ctx context.Context, model string, messages []llm.Message, decls []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
requests = append(requests, transcriptText(messages))
n++
ch := make(chan llm.Event, 1)
ch <- llm.Event{
Kind: llm.KindMessageEnd,
FinishReason: "length",
Message: llm.Message{Role: llm.RoleAssistant, Content: "part", StopReason: llm.StopEnd},
}
close(ch)
return ch
}
res, err := Run(context.Background(), Config{
Model: "fake",
MaxTurns: 8,
ContinueOnLength: true,
Stream: stream,
}, llm.Message{Role: llm.RoleUser, Content: "go"})
if err != nil {
t.Fatal(err)
}
if res.Err == nil || !strings.Contains(res.Err.Error(), "length continuation exceeded") {
t.Fatalf("err = %v", res.Err)
}
if n != 4 {
t.Fatalf("model calls = %d, want 4", n)
}
if len(res.Messages) == 0 || !strings.Contains(transcriptText(res.Messages), "part") {
t.Fatal("truncated transcript was dropped")
}
if strings.Contains(transcriptText(res.Messages), lengthContinuePrompt) {
t.Fatal("continuation prompt was persisted")
}
prompts := 0
for _, req := range requests {
prompts += strings.Count(req, lengthContinuePrompt)
}
if prompts != 3 {
t.Fatalf("continuation prompts = %d, want 3", prompts)
}
}
func TestLoopHistorySkipsGeneratedSystem(t *testing.T) {
var first string
_, err := Run(context.Background(), Config{
Model: "fake",
SystemPrompt: "LEAK",
History: []llm.Message{
{Role: llm.RoleSystem, Content: "FROM_HISTORY"},
{Role: llm.RoleUser, Content: "earlier"},
},
Stream: func(ctx context.Context, model string, messages []llm.Message, decls []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
if len(messages) > 0 {
first = messages[0].Content
}
ch := make(chan llm.Event, 1)
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{Role: llm.RoleAssistant, Content: "ok", StopReason: llm.StopEnd}}
close(ch)
return ch
},
}, llm.Message{Role: llm.RoleUser, Content: "now"})
if err != nil {
t.Fatal(err)
}
if first != "FROM_HISTORY" || strings.Contains(first, "LEAK") {
t.Fatalf("system = %q", first)
}
}
func TestLoopUnknownToolFallsThrough(t *testing.T) {
res, err := Run(context.Background(), Config{
Model: "fake",
UnknownTool: func(call llm.ToolCall) tools.Result {
return tools.Result{Content: "via " + call.Name}
},
Stream: script(
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
ID: "c1", Name: "make_report", Arguments: `{}`,
}}},
llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd},
),
}, llm.Message{Role: llm.RoleUser, Content: "go"})
if err != nil {
t.Fatal(err)
}
found := false
for _, m := range res.Messages {
if m.Role == llm.RoleTool && m.Content == "via make_report" && !m.IsError {
found = true
}
}
if !found {
t.Fatalf("unknown tool was not delegated: %s", transcriptText(res.Messages))
}
}
func TestLoopTurnCapKeepsTranscript(t *testing.T) {
res, err := Run(context.Background(), Config{
Model: "fake",
MaxTurns: 1,
Stream: script(llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
ID: "c1", Name: "missing", Arguments: `{}`,
}}}),
}, llm.Message{Role: llm.RoleUser, Content: "ping"})
if err != nil {
t.Fatal(err)
}
if res.Err == nil || !strings.Contains(res.Err.Error(), "max turns exceeded") {
t.Fatalf("err = %v", res.Err)
}
if !strings.Contains(transcriptText(res.Messages), "ping") {
t.Fatalf("transcript dropped: %s", transcriptText(res.Messages))
}
}
type hitTool struct{ hits *int }
func (h hitTool) Name() string { return "echo" }
func (h hitTool) Description() string { return "echo" }
func (h hitTool) Parameters() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
func (h hitTool) Execute(context.Context, json.RawMessage) (tools.Result, error) {
*h.hits++
return tools.Result{Content: "ran"}, nil
}
+94
View File
@@ -0,0 +1,94 @@
package mcp
// 本文件是跨测试二进制复用的最小 MCP fake server(stdio JSON-RPC)。
// 测试以「自身可执行文件 + AGENT_NATIVE_TEST_MCP_STDIO=1」re-exec 方式拉起,
// 供真实传输链路测试使用;非测试代码不得调用。
import (
"bufio"
"encoding/json"
"os"
)
// RunFakeServer 在 stdin/stdout 上运行最小 MCP server(echo / boom / 未知方法),
// 直到 stdin 关闭。测试二进制在 TestMain 的 re-exec 分支调用。
func RunFakeServer() {
scanner := bufio.NewScanner(os.Stdin)
scanner.Buffer(make([]byte, 1<<20), 1<<20)
w := bufio.NewWriter(os.Stdout)
defer w.Flush()
for scanner.Scan() {
var req rpcRequest
if err := json.Unmarshal(scanner.Bytes(), &req); err != nil {
continue
}
if req.ID == 0 {
continue // 通知帧
}
resp := fakeServerHandle(req)
body, _ := json.Marshal(resp)
w.Write(body)
w.WriteByte('\n')
w.Flush()
}
}
func fakeServerHandle(req rpcRequest) rpcResponse {
respond := func(result any) rpcResponse {
raw, _ := json.Marshal(result)
return rpcResponse{JSONRPC: "2.0", ID: &req.ID, Result: raw}
}
fail := func(code int, msg string) rpcResponse {
return rpcResponse{JSONRPC: "2.0", ID: &req.ID, Error: &rpcError{Code: code, Message: msg}}
}
switch req.Method {
case "initialize":
return respond(initializeResult{
ProtocolVersion: ProtocolVersion,
ServerInfo: clientInfo{Name: "fake-mcp", Version: "0"},
})
case "tools/list":
return respond(toolsListResult{Tools: fakeServerTools()})
case "tools/call":
var p callToolParams
if req.Params != nil {
raw, _ := json.Marshal(req.Params)
_ = json.Unmarshal(raw, &p)
}
switch p.Name {
case "echo":
var args struct {
Text string `json:"text"`
}
_ = json.Unmarshal(p.Arguments, &args)
return respond(callToolResult{Content: []contentBlock{{Type: "text", Text: "echo: " + args.Text}}})
case "boom":
return respond(callToolResult{
Content: []contentBlock{{Type: "text", Text: "boom failed"}},
IsError: true,
})
default:
return fail(-32602, "unknown tool "+p.Name)
}
default:
return fail(-32601, "method not found: "+req.Method)
}
}
func fakeServerTools() []toolInfo {
return []toolInfo{
{
Name: "echo",
Description: "Echo the text back",
InputSchema: json.RawMessage(`{"type":"object","required":["text"],"properties":{"text":{"type":"string"}}}`),
},
{
Name: "boom",
Description: "Always fails",
InputSchema: json.RawMessage(`{"type":"object","properties":{}}`),
},
}
}
// FakeServerEnv 是拉起 fake server 的环境变量名。
const FakeServerEnv = "AGENT_NATIVE_TEST_MCP_STDIO"
+196
View File
@@ -0,0 +1,196 @@
package mcp
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"sync"
"sync/atomic"
"time"
)
// httpClient 走 MCP Streamable HTTP 传输:每个 JSON-RPC 请求一次 POST,
// 期待 application/json 的单帧响应(SSE 流式与 server 主动请求不在本客户端范围)。
// initialize 返回的 Mcp-Session-Id 会在后续请求回带。
type httpClient struct {
cfg ServerConfig
client *http.Client
sessionID string
mu sync.Mutex
nextID int64
}
// DialHTTP 建立 HTTP 客户端并完成 initialize 握手。
func DialHTTP(ctx context.Context, cfg ServerConfig) (*httpClient, error) {
if err := cfg.Validate(); err != nil {
return nil, err
}
if cfg.URL == "" {
return nil, fmt.Errorf("mcp server %q: http transport requires url", cfg.Name)
}
c := &httpClient{cfg: cfg, client: &http.Client{Timeout: requestTimeout(cfg)}}
if err := c.handshake(ctx); err != nil {
return nil, err
}
return c, nil
}
func requestTimeout(cfg ServerConfig) time.Duration {
if cfg.Timeout > 0 {
return cfg.Timeout
}
return 30 * time.Second
}
func (c *httpClient) handshake(ctx context.Context) error {
var res initializeResult
err := c.roundTrip(ctx, "initialize", initializeParams{
ProtocolVersion: ProtocolVersion,
ClientInfo: clientInfo{Name: "agent-native", Version: "1"},
}, &res)
return err
}
func (c *httpClient) Initialize(ctx context.Context) (initializeResult, error) {
var res initializeResult
err := c.roundTrip(ctx, "initialize", initializeParams{
ProtocolVersion: ProtocolVersion,
ClientInfo: clientInfo{Name: "agent-native", Version: "1"},
}, &res)
return res, err
}
func (c *httpClient) ListTools(ctx context.Context) ([]toolInfo, error) {
var out []toolInfo
cursor := ""
for {
params := map[string]any{}
if cursor != "" {
params["cursor"] = cursor
}
var res toolsListResult
if err := c.roundTrip(ctx, "tools/list", params, &res); err != nil {
return nil, err
}
out = append(out, res.Tools...)
if res.NextCursor == "" || len(out) > maxToolsPerServer*2 {
return out, nil
}
cursor = res.NextCursor
}
}
func (c *httpClient) CallTool(ctx context.Context, name string, args json.RawMessage) (callToolResult, error) {
var res callToolResult
if len(args) == 0 {
args = json.RawMessage(`{}`)
}
err := c.roundTrip(ctx, "tools/call", callToolParams{Name: name, Arguments: args}, &res)
return res, err
}
func (c *httpClient) Close() error { return nil }
func (c *httpClient) roundTrip(ctx context.Context, method string, params any, out any) error {
c.mu.Lock()
defer c.mu.Unlock()
id := atomic.AddInt64(&c.nextID, 1)
body, err := json.Marshal(rpcRequest{JSONRPC: "2.0", ID: id, Method: method, Params: params})
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.cfg.URL, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json, text/event-stream")
for k, v := range c.cfg.Headers {
req.Header.Set(k, v)
}
if c.sessionID != "" {
req.Header.Set("Mcp-Session-Id", c.sessionID)
}
resp, err := c.client.Do(req)
if err != nil {
return fmt.Errorf("mcp %s: %w", method, err)
}
defer resp.Body.Close()
if sid := resp.Header.Get("Mcp-Session-Id"); sid != "" {
c.sessionID = sid
}
if resp.StatusCode >= 400 {
snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return fmt.Errorf("mcp %s: http %d: %s", method, resp.StatusCode, string(snippet))
}
// Streamable HTTP 允许 SSE 回包:逐行扫 data: 帧,取第一个 JSON 对象。
var payload []byte
ct := resp.Header.Get("Content-Type")
if bytes.Contains([]byte(ct), []byte("text/event-stream")) {
payload, err = readSSEFrame(resp.Body)
} else {
payload, err = io.ReadAll(io.LimitReader(resp.Body, 8<<20))
}
if err != nil {
return fmt.Errorf("mcp %s: read body: %w", method, err)
}
var rpcResp rpcResponse
if err := json.Unmarshal(payload, &rpcResp); err != nil {
return fmt.Errorf("mcp %s: bad response frame: %w", method, err)
}
if rpcResp.ID == nil || *rpcResp.ID != id {
return fmt.Errorf("mcp %s: response id mismatch", method)
}
if rpcResp.Error != nil {
return rpcResp.Error
}
if out != nil && len(rpcResp.Result) > 0 {
return json.Unmarshal(rpcResp.Result, out)
}
return nil
}
// readSSEFrame 从 SSE 流读取首个 data: JSON 载荷(忽略事件注释行)。
func readSSEFrame(r io.Reader) ([]byte, error) {
buf := make([]byte, 0, 4096)
tmp := make([]byte, 2048)
for {
n, err := r.Read(tmp)
buf = append(buf, tmp[:n]...)
for _, line := range splitLines(buf) {
if bytes.HasPrefix(line, []byte("data:")) {
data := bytes.TrimSpace(line[len("data:"):])
if len(data) > 0 {
return data, nil
}
}
}
if err != nil {
if len(buf) == 0 || err == io.EOF {
return nil, fmt.Errorf("no data frame in sse response")
}
return nil, err
}
if len(buf) > 8<<20 {
return nil, fmt.Errorf("sse response too large")
}
}
}
func splitLines(b []byte) [][]byte {
var out [][]byte
start := 0
for i, c := range b {
if c == '\n' {
out = append(out, b[start:i])
start = i + 1
}
}
if start < len(b) {
out = append(out, b[start:])
}
return out
}
+171
View File
@@ -0,0 +1,171 @@
package mcp
import (
"context"
"encoding/json"
"fmt"
"strings"
"sync"
"time"
"agent-native/runtime/tools"
)
// 桥接上限:server 元数据是不可信输入,长度与数量都必须受限。
const (
maxToolsPerServer = 64
maxDescriptionBytes = 1024
maxTextResultBytes = 64 << 10
maxToolNameBytes = 128
callTimeoutDefault = 30 * time.Second
registerTimeoutFixed = 30 * time.Second
)
// Manager 维护一组 MCP server 连接,并把它们的工具注册进 tools.Registry。
// 零值不可用;由组合根显式构造并注入(未注入 = 无 MCP 工具,fail-closed)。
type Manager struct {
configs []ServerConfig
mu sync.Mutex
clients map[string]Client
}
func NewManager(configs []ServerConfig) (*Manager, error) {
seen := map[string]bool{}
for _, c := range configs {
if err := c.Validate(); err != nil {
return nil, err
}
if seen[c.Name] {
return nil, fmt.Errorf("mcp server %q configured twice", c.Name)
}
seen[c.Name] = true
}
return &Manager{configs: configs, clients: map[string]Client{}}, nil
}
// Configs 返回声明的 server 清单(只读副本)。
func (m *Manager) Configs() []ServerConfig {
out := make([]ServerConfig, len(m.configs))
copy(out, m.configs)
return out
}
func (m *Manager) client(ctx context.Context, cfg ServerConfig) (Client, error) {
m.mu.Lock()
defer m.mu.Unlock()
if c, ok := m.clients[cfg.Name]; ok {
return c, nil
}
dialCtx, cancel := context.WithTimeout(ctx, registerTimeoutFixed)
defer cancel()
var c Client
var err error
if cfg.Command != "" {
c, err = DialStdio(dialCtx, cfg)
} else {
c, err = DialHTTP(dialCtx, cfg)
}
if err != nil {
return nil, fmt.Errorf("mcp server %q unreachable (fail-closed): %w", cfg.Name, err)
}
m.clients[cfg.Name] = c
return c, nil
}
// RegisterInto 连接全部 server、枚举工具并以 mcp__<server>__<tool> 命名注册。
// 任一 server 不可达即返回错误(fail-closed,不静默跳过)。
func (m *Manager) RegisterInto(ctx context.Context, reg *tools.Registry) error {
for _, cfg := range m.configs {
c, err := m.client(ctx, cfg)
if err != nil {
return err
}
listCtx, cancel := context.WithTimeout(ctx, registerTimeoutFixed)
list, err := c.ListTools(listCtx)
cancel()
if err != nil {
return fmt.Errorf("mcp server %q tools/list failed: %w", cfg.Name, err)
}
if len(list) > maxToolsPerServer {
return fmt.Errorf("mcp server %q exposes %d tools (max %d)", cfg.Name, len(list), maxToolsPerServer)
}
for _, ti := range list {
name := ToolName(cfg.Name, ti.Name)
if len(name) > maxToolNameBytes {
return fmt.Errorf("mcp tool name too long: %s", name)
}
desc := ti.Description
if len(desc) > maxDescriptionBytes {
desc = desc[:maxDescriptionBytes] + "…[truncated]"
}
schema := ti.InputSchema
if len(schema) == 0 {
schema = json.RawMessage(`{"type":"object","properties":{}}`)
}
reg.Register(&bridgeTool{
client: c,
serverCfg: cfg,
toolName: ti.Name,
declName: name,
description: desc,
schema: schema,
})
}
}
return nil
}
// ToolName 生成桥接工具名(命名空间隔离)。
func ToolName(server, tool string) string {
return "mcp__" + server + "__" + tool
}
// Close 关闭全部连接(进程型 server 随之退出)。
func (m *Manager) Close() {
m.mu.Lock()
defer m.mu.Unlock()
for _, c := range m.clients {
_ = c.Close()
}
m.clients = map[string]Client{}
}
// bridgeTool 把一个 MCP 工具适配为 tools.Tool;调用失败重连一次后重试。
type bridgeTool struct {
client Client
serverCfg ServerConfig
toolName string
declName string
description string
schema json.RawMessage
}
func (t *bridgeTool) Name() string { return t.declName }
func (t *bridgeTool) Description() string { return t.description }
func (t *bridgeTool) Parameters() json.RawMessage {
return t.schema
}
func (t *bridgeTool) Execute(ctx context.Context, args json.RawMessage) (tools.Result, error) {
callCtx, cancel := context.WithTimeout(ctx, requestTimeout(t.serverCfg))
defer cancel()
res, err := t.client.CallTool(callCtx, t.toolName, args)
if err != nil {
return tools.Result{Content: fmt.Sprintf("mcp tool %s failed: %v", t.declName, err), IsError: true}, nil
}
var b strings.Builder
for _, blk := range res.Content {
if blk.Type == "text" && blk.Text != "" {
b.WriteString(blk.Text)
b.WriteString("\n")
}
}
out := b.String()
if len(out) > maxTextResultBytes {
out = out[:maxTextResultBytes] + "\n[truncated]"
}
if res.IsError {
return tools.Result{Content: out, IsError: true}, nil
}
return tools.Result{Content: out}, nil
}
+241
View File
@@ -0,0 +1,241 @@
package mcp
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"agent-native/runtime/tools"
)
// TestMain:环境变量置位时,本测试二进制化身为 stdio MCP server(真实子进程测试)。
func TestMain(m *testing.M) {
if os.Getenv(FakeServerEnv) == "1" {
RunFakeServer()
os.Exit(0)
}
os.Exit(m.Run())
}
func fakeStdioConfig(t *testing.T) ServerConfig {
t.Helper()
exe, err := os.Executable()
if err != nil {
t.Fatal(err)
}
return ServerConfig{
Name: "fake",
Command: exe,
Env: append(os.Environ(), FakeServerEnv+"=1"),
}
}
func TestStdioClientHandshakeListAndCall(t *testing.T) {
ctx := context.Background()
c, err := DialStdio(ctx, fakeStdioConfig(t))
if err != nil {
t.Fatal(err)
}
defer c.Close()
list, err := c.ListTools(ctx)
if err != nil || len(list) != 2 || list[0].Name != "echo" {
t.Fatalf("list failed: %v %+v", err, list)
}
res, err := c.CallTool(ctx, "echo", json.RawMessage(`{"text":"hi"}`))
if err != nil || len(res.Content) != 1 || res.Content[0].Text != "echo: hi" {
t.Fatalf("call failed: %v %+v", err, res)
}
_, err = c.CallTool(ctx, "no_such_tool", nil)
if err == nil || !strings.Contains(err.Error(), "unknown tool") {
t.Fatalf("unknown-tool call must fail via rpc error, got %v", err)
}
}
func TestStdioClientServerRejectsBadSchema(t *testing.T) {
ctx := context.Background()
c, err := DialStdio(ctx, fakeStdioConfig(t))
if err != nil {
t.Fatal(err)
}
defer c.Close()
// server 端 tool 报 isError:作为 callToolResult 返回而非 rpc 错误。
res, err := c.CallTool(ctx, "boom", nil)
if err != nil {
t.Fatal(err)
}
if !res.IsError || res.Content[0].Text != "boom failed" {
t.Fatalf("want isError result, got %+v", res)
}
}
func fakeHTTPHandler(w http.ResponseWriter, r *http.Request) {
var req rpcRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
w.WriteHeader(400)
return
}
resp := fakeServerHandle(req)
if sid := r.Header.Get("Mcp-Session-Id"); sid != "" {
w.Header().Set("Mcp-Session-Id", sid)
} else {
w.Header().Set("Mcp-Session-Id", "sess-1")
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}
func TestHTTPClientJSONMode(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(fakeHTTPHandler))
defer srv.Close()
ctx := context.Background()
c, err := DialHTTP(ctx, ServerConfig{Name: "fakehttp", URL: srv.URL})
if err != nil {
t.Fatal(err)
}
defer c.Close()
if c.sessionID != "sess-1" {
t.Fatalf("session id must be captured, got %q", c.sessionID)
}
list, err := c.ListTools(ctx)
if err != nil || len(list) != 2 {
t.Fatalf("list failed: %v %+v", err, list)
}
res, err := c.CallTool(ctx, "echo", json.RawMessage(`{"text":"net"}`))
if err != nil || res.Content[0].Text != "echo: net" {
t.Fatalf("call failed: %v %+v", err, res)
}
}
func TestHTTPClientSSEMode(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req rpcRequest
_ = json.NewDecoder(r.Body).Decode(&req)
resp := fakeServerHandle(req)
w.Header().Set("Content-Type", "text/event-stream")
body, _ := json.Marshal(resp)
fmt.Fprintf(w, "event: message\ndata: %s\n\n", body)
}))
defer srv.Close()
ctx := context.Background()
c, err := DialHTTP(ctx, ServerConfig{Name: "fakesse", URL: srv.URL})
if err != nil {
t.Fatal(err)
}
defer c.Close()
list, err := c.ListTools(ctx)
if err != nil || len(list) != 2 {
t.Fatalf("sse list failed: %v %+v", err, list)
}
}
func TestManagerRegisterIntoAndCallThroughRegistry(t *testing.T) {
mgr, err := NewManager([]ServerConfig{fakeStdioConfig(t)})
if err != nil {
t.Fatal(err)
}
defer mgr.Close()
reg := tools.NewRegistry(t.TempDir())
if err := mgr.RegisterInto(context.Background(), reg); err != nil {
t.Fatal(err)
}
var names []string
for _, d := range reg.Decls() {
names = append(names, d.Name)
}
joined := strings.Join(names, ",")
if !strings.Contains(joined, "mcp__fake__echo") || !strings.Contains(joined, "mcp__fake__boom") {
t.Fatalf("mcp tools not registered: %v", names)
}
res := reg.Execute(context.Background(), "mcp__fake__echo", json.RawMessage(`{"text":"bridge"}`))
if res.IsError || res.Content != "echo: bridge\n" {
t.Fatalf("bridge call failed: %+v", res)
}
res = reg.Execute(context.Background(), "mcp__fake__boom", nil)
if !res.IsError || !strings.Contains(res.Content, "boom failed") {
t.Fatalf("bridge error propagation failed: %+v", res)
}
}
func TestManagerNamespaceWildcardRestrict(t *testing.T) {
mgr, _ := NewManager([]ServerConfig{fakeStdioConfig(t)})
defer mgr.Close()
reg := tools.WorkspaceTools(t.TempDir())
if err := mgr.RegisterInto(context.Background(), reg); err != nil {
t.Fatal(err)
}
if err := reg.Restrict([]string{"mcp__fake__*"}); err != nil {
t.Fatal(err)
}
for _, d := range reg.Decls() {
if !strings.HasPrefix(d.Name, "mcp__fake__") {
t.Fatalf("wildcard restrict must keep only mcp namespace, got %s", d.Name)
}
}
// 通配未命中任何工具 → fail-closed。
err := reg.Restrict([]string{"mcp__nope__*"})
if err == nil || !strings.Contains(err.Error(), "mcp__nope__*") {
t.Fatalf("unknown namespace wildcard must fail-closed, got %v", err)
}
}
func TestManagerFailClosedOnUnreachableServer(t *testing.T) {
mgr, err := NewManager([]ServerConfig{{Name: "dead", Command: "/nonexistent/mcp-server-bin"}})
if err != nil {
t.Fatal(err)
}
defer mgr.Close()
reg := tools.NewRegistry(t.TempDir())
err = mgr.RegisterInto(context.Background(), reg)
if err == nil || !strings.Contains(err.Error(), "unreachable") {
t.Fatalf("unreachable server must fail-closed, got %v", err)
}
}
func TestManagerConfigValidation(t *testing.T) {
if _, err := NewManager([]ServerConfig{{Name: "bad name", Command: "x"}}); err == nil {
t.Fatal("invalid name must be rejected")
}
if _, err := NewManager([]ServerConfig{{Name: "both", Command: "x", URL: "http://y"}}); err == nil {
t.Fatal("dual transport must be rejected")
}
if _, err := NewManager([]ServerConfig{{Name: "none"}}); err == nil {
t.Fatal("missing transport must be rejected")
}
_, err := NewManager([]ServerConfig{{Name: "a", Command: "x"}, {Name: "a", URL: "http://y"}})
if err == nil || !strings.Contains(err.Error(), "twice") {
t.Fatal("duplicate names must be rejected")
}
}
func TestManagerRegisterTimeoutBounded(t *testing.T) {
// server 挂起不响应:initialize 必须在超时内 fail-closed,而不是永久阻塞。
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case <-r.Context().Done():
case <-time.After(10 * time.Second):
}
}))
defer srv.Close()
mgr, err := NewManager([]ServerConfig{{Name: "slow", URL: srv.URL, Timeout: 300 * time.Millisecond}})
if err != nil {
t.Fatal(err)
}
defer mgr.Close()
reg := tools.NewRegistry(t.TempDir())
start := time.Now()
err = mgr.RegisterInto(context.Background(), reg)
elapsed := time.Since(start)
if err == nil || !strings.Contains(err.Error(), "unreachable") {
t.Fatalf("hung server must fail-closed, got %v", err)
}
if elapsed > 5*time.Second {
t.Fatalf("timeout must bound the dial, took %s", elapsed)
}
}
+140
View File
@@ -0,0 +1,140 @@
// Package mcp 实现 Model Context Protocol 客户端(stdio 与 Streamable HTTP
// 两种传输),并把 server 工具桥接为 runtime/tools.Registry 的普通工具。
//
// 边界声明:server 提供的描述与 schema 视为不可信输入(长度受限、不解释);
// 工具调用的策略(allowlist/确认)仍由 tools.Registry 策略层统一执行。
package mcp
import (
"context"
"encoding/json"
"fmt"
"time"
)
// ProtocolVersion 是本客户端声明的 MCP 协议版本。
const ProtocolVersion = "2024-11-05"
// ServerConfig 声明一个 MCP server 接入点:Command(stdio)或 URL(HTTP)二选一。
type ServerConfig struct {
Name string `json:"name"`
Command string `json:"command,omitempty"`
Args []string `json:"args,omitempty"`
Env []string `json:"env,omitempty"`
URL string `json:"url,omitempty"`
Headers map[string]string `json:"headers,omitempty"`
// Timeout 单次请求上限(默认 30s)。
Timeout time.Duration `json:"timeout,omitempty"`
}
// Validate 校验配置完整性(fail-closed:命名不规范或双传输/无传输都拒绝)。
func (c ServerConfig) Validate() error {
if !validName(c.Name) {
return fmt.Errorf("mcp server name %q must be [a-zA-Z0-9_-]+", c.Name)
}
hasCmd := c.Command != ""
hasURL := c.URL != ""
if hasCmd == hasURL {
return fmt.Errorf("mcp server %q: exactly one of command or url is required", c.Name)
}
return nil
}
func validName(s string) bool {
if s == "" {
return false
}
for _, r := range s {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_':
default:
return false
}
}
return true
}
// ---- JSON-RPC 2.0 envelope ----
type rpcRequest struct {
JSONRPC string `json:"jsonrpc"`
ID int64 `json:"id,omitempty"`
Method string `json:"method"`
Params any `json:"params,omitempty"`
}
type rpcNotification struct {
JSONRPC string `json:"jsonrpc"`
Method string `json:"method"`
}
type rpcResponse struct {
JSONRPC string `json:"jsonrpc"`
ID *int64 `json:"id"`
Result json.RawMessage `json:"result,omitempty"`
Error *rpcError `json:"error,omitempty"`
}
type rpcError struct {
Code int `json:"code"`
Message string `json:"message"`
Data json.RawMessage `json:"data,omitempty"`
}
func (e *rpcError) Error() string {
return fmt.Sprintf("mcp rpc error %d: %s", e.Code, e.Message)
}
// ---- MCP 语义类型 ----
type initializeParams struct {
ProtocolVersion string `json:"protocolVersion"`
Capabilities json.RawMessage `json:"capabilities,omitempty"`
ClientInfo clientInfo `json:"clientInfo"`
}
type clientInfo struct {
Name string `json:"name"`
Version string `json:"version"`
}
type initializeResult struct {
ProtocolVersion string `json:"protocolVersion"`
Capabilities json.RawMessage `json:"capabilities"`
ServerInfo clientInfo `json:"serverInfo"`
Instructions string `json:"instructions,omitempty"`
}
type toolInfo struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
InputSchema json.RawMessage `json:"inputSchema,omitempty"`
}
type toolsListResult struct {
Tools []toolInfo `json:"tools"`
NextCursor string `json:"nextCursor,omitempty"`
}
type callToolParams struct {
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments,omitempty"`
}
type callToolResult struct {
Content []contentBlock `json:"content"`
IsError bool `json:"isError,omitempty"`
}
type contentBlock struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
}
// Client 是一个已连接 MCP server 的最小面(initialize / tools/list / tools/call)。
type Client interface {
Initialize(ctx context.Context) (initializeResult, error)
ListTools(ctx context.Context) ([]toolInfo, error)
CallTool(ctx context.Context, name string, args json.RawMessage) (callToolResult, error)
Close() error
}
+198
View File
@@ -0,0 +1,198 @@
package mcp
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"os/exec"
"sync"
"sync/atomic"
)
// stdioClient 通过「子进程 stdin/stdout 按行分隔的 JSON-RPC」与 server 通信。
// 同一时刻只允许一个在途请求(串行锁),匹配简单且足以支撑智能体工具调用。
type stdioClient struct {
cmd *exec.Cmd
stdin io.WriteCloser
stdout *bufio.Reader
mu sync.Mutex
nextID int64
closed bool
}
// DialStdio 启动 server 子进程并完成 initialize 握手。
func DialStdio(ctx context.Context, cfg ServerConfig) (*stdioClient, error) {
if err := cfg.Validate(); err != nil {
return nil, err
}
if cfg.Command == "" {
return nil, fmt.Errorf("mcp server %q: stdio transport requires command", cfg.Name)
}
cmd := exec.Command(cfg.Command, cfg.Args...)
cmd.Env = cfg.Env
stdin, err := cmd.StdinPipe()
if err != nil {
return nil, err
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, err
}
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("mcp server %q: start %s: %w", cfg.Name, cfg.Command, err)
}
c := &stdioClient{cmd: cmd, stdin: stdin, stdout: bufio.NewReaderSize(stdout, 1<<20)}
if err := c.handshake(ctx); err != nil {
_ = c.Close()
return nil, err
}
return c, nil
}
func (c *stdioClient) handshake(ctx context.Context) error {
var res initializeResult
if err := c.roundTrip(ctx, "initialize", initializeParams{
ProtocolVersion: ProtocolVersion,
ClientInfo: clientInfo{Name: "agent-native", Version: "1"},
}, &res); err != nil {
return err
}
// initialized 通知无需响应。
return c.notify("notifications/initialized")
}
func (c *stdioClient) Initialize(ctx context.Context) (initializeResult, error) {
// Initialize 已在 Dial 时完成;这里补一次协议层调用以支持探测场景。
var res initializeResult
err := c.roundTrip(ctx, "initialize", initializeParams{
ProtocolVersion: ProtocolVersion,
ClientInfo: clientInfo{Name: "agent-native", Version: "1"},
}, &res)
return res, err
}
func (c *stdioClient) ListTools(ctx context.Context) ([]toolInfo, error) {
var out []toolInfo
cursor := ""
for {
params := map[string]any{}
if cursor != "" {
params["cursor"] = cursor
}
var res toolsListResult
if err := c.roundTrip(ctx, "tools/list", params, &res); err != nil {
return nil, err
}
out = append(out, res.Tools...)
if res.NextCursor == "" || len(out) > maxToolsPerServer*2 {
return out, nil
}
cursor = res.NextCursor
}
}
func (c *stdioClient) CallTool(ctx context.Context, name string, args json.RawMessage) (callToolResult, error) {
var res callToolResult
if len(args) == 0 {
args = json.RawMessage(`{}`)
}
err := c.roundTrip(ctx, "tools/call", callToolParams{Name: name, Arguments: args}, &res)
return res, err
}
func (c *stdioClient) Close() error {
c.mu.Lock()
defer c.mu.Unlock()
if c.closed {
return nil
}
c.closed = true
_ = c.stdin.Close()
if c.cmd.Process != nil {
_ = c.cmd.Process.Kill()
}
return c.cmd.Wait()
}
func (c *stdioClient) roundTrip(ctx context.Context, method string, params any, out any) error {
c.mu.Lock()
defer c.mu.Unlock()
if c.closed {
return fmt.Errorf("mcp client closed")
}
id := atomic.AddInt64(&c.nextID, 1)
req := rpcRequest{JSONRPC: "2.0", ID: id, Method: method, Params: params}
body, err := json.Marshal(req)
if err != nil {
return err
}
body = append(body, '\n')
if _, err := c.stdin.Write(body); err != nil {
return fmt.Errorf("mcp write %s: %w", method, err)
}
line, err := c.readResponseLine(ctx)
if err != nil {
return fmt.Errorf("mcp read %s: %w", method, err)
}
var resp rpcResponse
if err := json.Unmarshal(line, &resp); err != nil {
return fmt.Errorf("mcp %s: bad response frame: %w", method, err)
}
if resp.ID == nil || *resp.ID != id {
return fmt.Errorf("mcp %s: response id mismatch", method)
}
if resp.Error != nil {
return resp.Error
}
if out != nil && len(resp.Result) > 0 {
return json.Unmarshal(resp.Result, out)
}
return nil
}
// readResponseLine 逐行读取,跳过通知帧(无 id),带 ctx 取消感知。
func (c *stdioClient) readResponseLine(ctx context.Context) ([]byte, error) {
type result struct {
line []byte
err error
}
done := make(chan result, 1)
go func() {
for {
line, err := c.stdout.ReadBytes('\n')
if err != nil {
done <- result{nil, err}
return
}
var probe rpcResponse
if json.Unmarshal(line, &probe) == nil && probe.ID != nil {
done <- result{line, nil}
return
}
// 通知或日志行:忽略。
}
}()
select {
case r := <-done:
return r.line, r.err
case <-ctx.Done():
return nil, ctx.Err()
}
}
func (c *stdioClient) notify(method string) error {
c.mu.Lock()
defer c.mu.Unlock()
if c.closed {
return fmt.Errorf("mcp client closed")
}
body, err := json.Marshal(rpcNotification{JSONRPC: "2.0", Method: method})
if err != nil {
return err
}
body = append(body, '\n')
_, err = c.stdin.Write(body)
return err
}
+106
View File
@@ -0,0 +1,106 @@
// Package promptx 组装执行内核的系统提示。
//
// 职责边界(要求与实现分离):人格/角色内容由上层(产品提示词库、角色
// Instructions)提供并经 Parts.Instructions 传入;本包只做结构化装配——
// 环境事实、技能索引、交付物与档位约束、护栏段。工具清单仍由 runtime
// 循环在系统提示之后追加,不在本包。
package promptx
import (
"sort"
"strings"
"time"
)
// EnvFacts 是注入系统提示的环境事实(由适配层填写,测试可固定)。
type EnvFacts struct {
Date string // 如 2026-10-01(周一)
Model string
WorkspaceRoot string
Profile string // workspace | reasoning
}
// IndexEntry 是技能索引行。
type IndexEntry struct {
Name string
Description string
}
// Parts 是一次执行的装配输入。
type Parts struct {
Instructions string
// SkillsDeclared 是本次执行显式要求的技能名(角色声明)。
SkillsDeclared []string
// SkillsIndex 是可用技能索引(渐进披露:只列名与描述)。
SkillsIndex []IndexEntry
Deliverables []string
Env EnvFacts
}
// Compose 产出系统提示正文。
func Compose(p Parts) string {
var b strings.Builder
if strings.TrimSpace(p.Instructions) != "" {
b.WriteString(strings.TrimSpace(p.Instructions))
b.WriteString("\n\n")
}
// 环境事实块。
env := p.Env
if env.Date == "" && env.Model == "" && env.WorkspaceRoot == "" {
env.Date = time.Now().Format("2006-01-02")
}
var envLines []string
if env.Date != "" {
envLines = append(envLines, "date: "+env.Date)
}
if env.Model != "" {
envLines = append(envLines, "model: "+env.Model)
}
if env.WorkspaceRoot != "" {
envLines = append(envLines, "workspace: "+env.WorkspaceRoot)
}
if env.Profile != "" {
envLines = append(envLines, "tool profile: "+env.Profile)
}
if len(envLines) > 0 {
b.WriteString("## Environment\n")
b.WriteString(strings.Join(envLines, "\n"))
b.WriteString("\n\n")
}
// 技能块:声明优先 + 全量索引 + 按需加载指引。
if len(p.SkillsDeclared) > 0 || len(p.SkillsIndex) > 0 {
b.WriteString("## Skills\n")
if len(p.SkillsDeclared) > 0 {
b.WriteString("Declared for this task (load with load_skill before relying on them): ")
b.WriteString(strings.Join(p.SkillsDeclared, ", "))
b.WriteString("\n")
}
if len(p.SkillsIndex) > 0 {
entries := make([]IndexEntry, len(p.SkillsIndex))
copy(entries, p.SkillsIndex)
sort.Slice(entries, func(i, j int) bool { return entries[i].Name < entries[j].Name })
b.WriteString("Available skills (name — description; use load_skill for full instructions):\n")
for _, e := range entries {
line := "- " + e.Name
if e.Description != "" {
line += " — " + e.Description
}
b.WriteString(line + "\n")
}
}
b.WriteString("\n")
}
// 交付物与档位约束(保持既有语义原文,行为兼容)。
b.WriteString("Deliverable files: ")
b.WriteString(strings.Join(p.Deliverables, ", "))
b.WriteString(".")
if p.Env.Profile == "reasoning" {
b.WriteString(" Reply with the deliverable text only. Tools are disabled.")
} else {
b.WriteString(" Use workspace tools to write those files. Do not invent paths outside the workspace.")
}
return strings.TrimSpace(b.String())
}
+62
View File
@@ -0,0 +1,62 @@
package promptx
import (
"strings"
"testing"
)
func TestComposeSections(t *testing.T) {
out := Compose(Parts{
Instructions: "你是审校员",
SkillsDeclared: []string{"docx-author"},
SkillsIndex: []IndexEntry{
{Name: "xlsx-author", Description: "表格交付"},
{Name: "docx-author", Description: "文档交付"},
},
Deliverables: []string{"output.md"},
Env: EnvFacts{Date: "2026-10-01(周四)", Model: "m1", WorkspaceRoot: "/w", Profile: "workspace"},
})
for _, want := range []string{
"你是审校员",
"## Environment",
"date: 2026-10-01(周四)",
"model: m1",
"workspace: /w",
"tool profile: workspace",
"## Skills",
"Declared for this task",
"docx-author",
"- docx-author — 文档交付", // 索引按名排序
"- xlsx-author — 表格交付",
"Deliverable files: output.md.",
"Use workspace tools to write those files.",
} {
if !strings.Contains(out, want) {
t.Errorf("missing %q in composed prompt:\n%s", want, out)
}
}
// 排序断言:docx 行在 xlsx 行之前。
if strings.Index(out, "- docx-author") > strings.Index(out, "- xlsx-author") {
t.Errorf("index must be sorted by name:\n%s", out)
}
}
func TestComposeReasoningProfileAndMinimal(t *testing.T) {
out := Compose(Parts{Deliverables: []string{"out.md"}, Env: EnvFacts{Profile: "reasoning"}})
if !strings.Contains(out, "Reply with the deliverable text only. Tools are disabled.") {
t.Fatalf("reasoning sentence missing:\n%s", out)
}
if strings.Contains(out, "## Environment") && !strings.Contains(out, "date:") {
t.Fatalf("unexpected env block:\n%s", out)
}
if strings.Contains(out, "## Skills") {
t.Fatalf("no skills should render no skill block:\n%s", out)
}
}
func TestComposeEnvDefaultsToDate(t *testing.T) {
out := Compose(Parts{Deliverables: []string{"o.md"}})
if !strings.Contains(out, "date: ") {
t.Fatalf("env should default to today's date:\n%s", out)
}
}
+37
View File
@@ -0,0 +1,37 @@
package resources
import (
"os"
"path/filepath"
"strings"
)
// ContextFiles walks from dir toward the filesystem root and concatenates
// AGENTS.md and CLAUDE.md (nearest first). SYSTEM.md and APPEND_SYSTEM.md
// are read from dir only.
func ContextFiles(dir string) (string, error) {
var parts []string
cur := dir
for {
for _, name := range []string{"AGENTS.md", "CLAUDE.md"} {
raw, err := os.ReadFile(filepath.Join(cur, name))
if err != nil {
continue
}
parts = append(parts, "# "+name+" ("+cur+")\n"+string(raw))
}
parent := filepath.Dir(cur)
if parent == cur {
break
}
cur = parent
}
for _, name := range []string{"SYSTEM.md", "APPEND_SYSTEM.md"} {
raw, err := os.ReadFile(filepath.Join(dir, name))
if err != nil {
continue
}
parts = append(parts, "# "+name+"\n"+string(raw))
}
return strings.Join(parts, "\n\n"), nil
}
+44
View File
@@ -0,0 +1,44 @@
package resources
import (
"os"
"path/filepath"
"strings"
)
// Expand replaces {{var}} placeholders and /name includes.
// An include reads <dir>/<name>.md. Unknown variables are left intact.
func Expand(text, dir string, vars map[string]string) (string, error) {
out := text
for key, value := range vars {
out = strings.ReplaceAll(out, "{{"+key+"}}", value)
}
for {
i := strings.Index(out, "/")
if i < 0 {
break
}
rest := out[i+1:]
j := 0
for j < len(rest) && isName(rest[j]) {
j++
}
if j == 0 || (i > 0 && out[i-1] != ' ' && out[i-1] != '\n') {
break
}
name := rest[:j]
raw, err := os.ReadFile(filepath.Join(dir, name+".md"))
if err != nil {
break
}
out = out[:i] + string(raw) + out[i+1+j:]
}
for key, value := range vars {
out = strings.ReplaceAll(out, "{{"+key+"}}", value)
}
return out, nil
}
func isName(b byte) bool {
return b == '-' || b == '_' || (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9')
}
+72
View File
@@ -0,0 +1,72 @@
package resources
import (
"os"
"path/filepath"
"strings"
"testing"
"agent-native/runtime/skills"
"agent-native/runtime/tools"
)
func TestContextAndPromptAndTrust(t *testing.T) {
root := t.TempDir()
proj := filepath.Join(root, "proj")
if err := os.MkdirAll(proj, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "AGENTS.md"), []byte("root-agents"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(proj, "SYSTEM.md"), []byte("sys"), 0o644); err != nil {
t.Fatal(err)
}
text, err := ContextFiles(proj)
if err != nil || !strings.Contains(text, "root-agents") || !strings.Contains(text, "sys") {
t.Fatalf("context: %s %v", text, err)
}
if err := os.WriteFile(filepath.Join(proj, "greet.md"), []byte("hello {{name}}"), 0o644); err != nil {
t.Fatal(err)
}
got, err := Expand("say /greet", proj, map[string]string{"name": "Ada"})
if err != nil || got != "say hello Ada" {
t.Fatalf("expand: %q %v", got, err)
}
if ProjectTrusted(TrustAsk, false, false) {
t.Fatal("ask is not trusted without approve")
}
if !ProjectTrusted(TrustNever, true, false) {
t.Fatal("approve overrides never")
}
if ProjectTrusted(TrustAlways, false, true) {
t.Fatal("no-approve wins")
}
}
func TestPiAliasesFilterAndSkillCommand(t *testing.T) {
reg := tools.WorkspaceTools(t.TempDir())
reg.RegisterPiAliases()
if !reg.Has("read") || !reg.Has("ls") || !reg.Has("read_file") {
t.Fatal("aliases missing")
}
if err := reg.Filter(tools.FilterOptions{Exclude: []string{"read"}, NoBuiltin: false}); err != nil {
t.Fatal(err)
}
if reg.Has("read") || !reg.Has("read_file") {
t.Fatal("exclude should drop only the alias")
}
dir := t.TempDir()
skillDir := filepath.Join(dir, "review")
if err := os.MkdirAll(skillDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("be thorough"), 0o644); err != nil {
t.Fatal(err)
}
store := skills.NewStore(dir)
out, err := store.ExpandCommands("use /skill:review now")
if err != nil || !strings.Contains(out, "be thorough") {
t.Fatalf("skill expand: %q %v", out, err)
}
}
+102
View File
@@ -0,0 +1,102 @@
package resources
import (
"encoding/json"
"os"
"path/filepath"
)
// Trust is how a project directory is treated in non-interactive runs.
type Trust string
const (
TrustAsk Trust = "ask"
TrustAlways Trust = "always"
TrustNever Trust = "never"
)
// Settings is the global plus project configuration.
type Settings struct {
Trust Trust `json:"trust,omitempty"`
Model string `json:"model,omitempty"`
Thinking string `json:"thinking,omitempty"`
Tools []string `json:"tools,omitempty"`
Exclude []string `json:"exclude,omitempty"`
Vars map[string]string `json:"vars,omitempty"`
}
// LoadSettings reads global (optional) then project settings.
// Project keys override global keys. A missing file is an empty settings.
func LoadSettings(globalPath, projectPath string) (Settings, error) {
global, err := readSettings(globalPath)
if err != nil {
return Settings{}, err
}
project, err := readSettings(projectPath)
if err != nil {
return Settings{}, err
}
return mergeSettings(global, project), nil
}
// ProjectTrusted reports whether project resources may be loaded.
// ask and never refuse in non-interactive mode. approve forces trust.
func ProjectTrusted(trust Trust, approve, noApprove bool) bool {
if noApprove {
return false
}
if approve {
return true
}
return trust == TrustAlways
}
func readSettings(path string) (Settings, error) {
if path == "" {
return Settings{}, nil
}
raw, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return Settings{}, nil
}
return Settings{}, err
}
var s Settings
if err := json.Unmarshal(raw, &s); err != nil {
return Settings{}, err
}
return s, nil
}
func mergeSettings(base, over Settings) Settings {
if over.Trust != "" {
base.Trust = over.Trust
}
if over.Model != "" {
base.Model = over.Model
}
if over.Thinking != "" {
base.Thinking = over.Thinking
}
if over.Tools != nil {
base.Tools = over.Tools
}
if over.Exclude != nil {
base.Exclude = over.Exclude
}
if len(over.Vars) > 0 {
if base.Vars == nil {
base.Vars = map[string]string{}
}
for k, v := range over.Vars {
base.Vars[k] = v
}
}
return base
}
// DefaultPaths returns the global and project settings paths for a cwd.
func DefaultPaths(home, cwd string) (string, string) {
return filepath.Join(home, ".agent-native", "settings.json"), filepath.Join(cwd, ".agent-native", "settings.json")
}
+105
View File
@@ -0,0 +1,105 @@
package session
import (
"os"
"path/filepath"
"sort"
"strings"
)
// Info describes one session file in a directory.
type Info struct {
ID string
Path string
Name string
CWD string
Leaf string
Updated string
}
// List returns session files in dir, newest first.
func List(dir string) ([]Info, error) {
ents, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
var out []Info
for _, ent := range ents {
name := ent.Name()
if ent.IsDir() || !strings.HasSuffix(name, ".jsonl") {
continue
}
path := filepath.Join(dir, name)
st, err := Open(path)
if err != nil {
continue
}
info, err := st.Info()
if err != nil {
continue
}
out = append(out, info)
}
sort.Slice(out, func(i, j int) bool { return out[i].Updated > out[j].Updated })
return out, nil
}
// Continue opens the newest session whose cwd matches. A second return
// value of false means there is nothing to continue.
func Continue(dir, cwd string) (*Store, bool, error) {
list, err := List(dir)
if err != nil {
return nil, false, err
}
for _, info := range list {
if cwd == "" || info.CWD == cwd {
st, err := Open(info.Path)
return st, err == nil, err
}
}
return nil, false, nil
}
// Resume opens a session by id (filename stem) or by name.
func Resume(dir, idOrName string) (*Store, error) {
list, err := List(dir)
if err != nil {
return nil, err
}
for _, info := range list {
if info.ID == idOrName || info.Name == idOrName {
return Open(info.Path)
}
}
return nil, os.ErrNotExist
}
// SetCWD records the working directory used by Continue.
func (s *Store) SetCWD(cwd string) error {
s.mu.Lock()
defer s.mu.Unlock()
return s.writeMetaKeyLocked("cwd", cwd)
}
// Info reads the catalog fields for this file.
func (s *Store) Info() (Info, error) {
s.mu.Lock()
defer s.mu.Unlock()
entries, err := s.readEntriesLocked()
if err != nil {
return Info{}, err
}
meta := s.readMetaLocked()
updated := ""
if len(entries) > 0 {
updated = entries[len(entries)-1].Timestamp
}
id := strings.TrimSuffix(filepath.Base(s.path), ".jsonl")
return Info{
ID: id, Path: s.path, Name: meta["name"], CWD: meta["cwd"],
Leaf: s.leafLocked(entries), Updated: updated,
}, nil
}
+118
View File
@@ -0,0 +1,118 @@
package session
import (
"bufio"
"encoding/json"
"os"
"agent-native/runtime/llm"
)
// Stats 是转录文件的轻量元数据(扫描即得,无副作用)。
type Stats struct {
Lines int
Bytes int64
}
// Stats 统计当前转录行数与字节数(空行不计)。
func (s *Store) Stats() Stats {
s.mu.Lock()
defer s.mu.Unlock()
st := Stats{}
f, err := os.Open(s.path)
if err != nil {
return st
}
defer f.Close()
sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 0, 64*1024), 8*1024*1024)
for sc.Scan() {
if len(sc.Bytes()) == 0 {
continue
}
st.Lines++
}
if info, err := f.Stat(); err == nil {
st.Bytes = info.Size()
}
return st
}
// SaveMeta 把会话元数据写进 <path>.meta.json(创建时间/模型等由调用方定义)。
func (s *Store) SaveMeta(meta map[string]string) error {
s.mu.Lock()
defer s.mu.Unlock()
raw, err := json.MarshalIndent(meta, "", " ")
if err != nil {
return err
}
tmp := s.path + ".meta.json.tmp"
if err := os.WriteFile(tmp, raw, 0o644); err != nil {
return err
}
return os.Rename(tmp, s.path+".meta.json")
}
// ReadMeta 读取会话元数据;不存在时返回 nil(新会话)。
func (s *Store) ReadMeta() map[string]string {
raw, err := os.ReadFile(s.path + ".meta.json")
if err != nil {
return nil
}
var meta map[string]string
if err := json.Unmarshal(raw, &meta); err != nil {
return nil
}
return meta
}
// Compact appends a compaction entry. The file keeps every earlier record.
// Load hides the summarized prefix behind the summary plus the last
// keepLastN non-system messages. Returns false when there is nothing to fold.
func (s *Store) Compact(keepLastN int, summarize func(messages []llm.Message) (string, error)) (bool, error) {
s.mu.Lock()
entries, err := s.readEntriesLocked()
s.mu.Unlock()
if err != nil {
return false, err
}
leaf := ""
s.mu.Lock()
leaf = s.leafLocked(entries)
s.mu.Unlock()
path := pathTo(entries, leaf)
var messages []llm.Message
var msgEntries []Entry
for _, e := range path {
if e.Type == TypeMessage && e.Message != nil {
messages = append(messages, *e.Message)
msgEntries = append(msgEntries, e)
}
}
if len(messages) <= keepLastN+1 {
return false, nil
}
rest := messages
restEntries := msgEntries
if len(rest) > 0 && rest[0].Role == llm.RoleSystem {
rest = rest[1:]
restEntries = restEntries[1:]
}
tailStart := len(rest) - keepLastN
if tailStart <= 0 {
return false, nil
}
head := rest[:tailStart]
summary, err := summarize(head)
if err != nil {
return false, err
}
var tailIDs []string
for _, e := range restEntries[tailStart:] {
tailIDs = append(tailIDs, e.ID)
}
s.mu.Lock()
defer s.mu.Unlock()
_, err = s.appendEntryLocked(Entry{Type: TypeCompaction, Summary: summary, TailIDs: tailIDs})
return err == nil, err
}
+162
View File
@@ -0,0 +1,162 @@
package session
import (
"os"
"path/filepath"
"strings"
"testing"
"agent-native/runtime/llm"
)
func appendMsgs(t *testing.T, s *Store, msgs ...llm.Message) {
t.Helper()
for _, m := range msgs {
if err := s.Append(m); err != nil {
t.Fatal(err)
}
}
}
func TestLoadSkipsCrashResidueTail(t *testing.T) {
dir := t.TempDir()
s, err := Open(filepath.Join(dir, "t.jsonl"))
if err != nil {
t.Fatal(err)
}
appendMsgs(t, s,
llm.Message{Role: llm.RoleSystem, Content: "sys"},
llm.Message{Role: llm.RoleUser, Content: "hi"},
)
// 模拟崩溃半行:无换行结尾的残缺 JSON。
f, _ := os.OpenFile(s.Path(), os.O_APPEND|os.O_WRONLY, 0o644)
_, _ = f.WriteString(`{"role":"assistant","content":"trunca`)
_ = f.Close()
msgs, err := s.Load()
if err != nil {
t.Fatalf("crash residue must not poison the session: %v", err)
}
if len(msgs) != 2 || msgs[0].Role != llm.RoleSystem || msgs[1].Content != "hi" {
t.Fatalf("want sys+hi, got %+v", msgs)
}
}
func TestLoadStillFailsOnMidFileCorruption(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "t.jsonl")
if err := os.WriteFile(path, []byte("{\"bad json}\n{\"role\":\"user\",\"content\":\"ok\"}\n"), 0o644); err != nil {
t.Fatal(err)
}
s := &Store{path: path}
if _, err := s.Load(); err == nil {
t.Fatal("mid-file corruption must still error")
}
}
func TestStatsAndMeta(t *testing.T) {
dir := t.TempDir()
s, _ := Open(filepath.Join(dir, "t.jsonl"))
appendMsgs(t, s,
llm.Message{Role: llm.RoleSystem, Content: "sys"},
llm.Message{Role: llm.RoleUser, Content: "q"},
)
st := s.Stats()
if st.Lines != 2 || st.Bytes <= 0 {
t.Fatalf("stats wrong: %+v", st)
}
if err := s.SaveMeta(map[string]string{"model": "m1"}); err != nil {
t.Fatal(err)
}
meta := s.ReadMeta()
if meta == nil || meta["model"] != "m1" {
t.Fatalf("meta roundtrip failed: %v", meta)
}
if other := (&Store{path: filepath.Join(dir, "missing.jsonl")}).ReadMeta(); other != nil {
t.Fatalf("missing meta must return nil, got %v", other)
}
}
func TestCompactRewritesTranscript(t *testing.T) {
dir := t.TempDir()
s, _ := Open(filepath.Join(dir, "t.jsonl"))
appendMsgs(t, s, llm.Message{Role: llm.RoleSystem, Content: "sys"})
for i := 0; i < 10; i++ {
appendMsgs(t, s,
llm.Message{Role: llm.RoleUser, Content: "early" + string(rune('a'+i))},
llm.Message{Role: llm.RoleAssistant, Content: "reply"},
)
}
var seenHead []llm.Message
done, err := s.Compact(3, func(messages []llm.Message) (string, error) {
seenHead = messages
return "这是摘要", nil
})
if err != nil || !done {
t.Fatalf("compact failed: %v done=%v", err, done)
}
if len(seenHead) != 17 { // 20 - system - 3 tail
t.Fatalf("summarizer should receive 17 head messages, got %d", len(seenHead))
}
msgs, err := s.Load()
if err != nil {
t.Fatal(err)
}
if len(msgs) != 5 { // system + summary + 3 tail
t.Fatalf("want 5 messages after compact, got %d", len(msgs))
}
if msgs[0].Role != llm.RoleSystem {
t.Fatal("system message must be preserved")
}
if !strings.Contains(msgs[1].Content, "[conversation summary]") || !strings.Contains(msgs[1].Content, "这是摘要") {
t.Fatalf("summary message wrong: %+v", msgs[1])
}
if msgs[2].Content != "reply" || msgs[3].Content != "earlyj" || msgs[4].Content != "reply" {
t.Fatalf("tail order wrong: %+v", msgs[2:])
}
entries, err := s.Entries()
if err != nil {
t.Fatal(err)
}
if len(entries) <= 5 {
t.Fatalf("compaction must append, not rewrite; got %d entries", len(entries))
}
}
func TestCompactNoopWhenShort(t *testing.T) {
dir := t.TempDir()
s, _ := Open(filepath.Join(dir, "t.jsonl"))
appendMsgs(t, s,
llm.Message{Role: llm.RoleSystem, Content: "sys"},
llm.Message{Role: llm.RoleUser, Content: "q"},
)
done, err := s.Compact(8, func([]llm.Message) (string, error) { return "x", nil })
if err != nil || done {
t.Fatalf("short transcript must be a no-op: %v %v", done, err)
}
msgs, _ := s.Load()
if len(msgs) != 2 {
t.Fatal("no-op compact must not touch the file")
}
}
func TestCompactSummarizerErrorAborts(t *testing.T) {
dir := t.TempDir()
s, _ := Open(filepath.Join(dir, "t.jsonl"))
appendMsgs(t, s, llm.Message{Role: llm.RoleSystem, Content: "sys"})
for i := 0; i < 6; i++ {
appendMsgs(t, s, llm.Message{Role: llm.RoleUser, Content: "m"})
}
done, err := s.Compact(2, func([]llm.Message) (string, error) { return "", errStringT("boom") })
if err == nil || done {
t.Fatal("summarizer error must abort compaction")
}
msgs, _ := s.Load()
if len(msgs) != 7 {
t.Fatal("failed compaction must leave the transcript untouched")
}
}
type errStringT string
func (e errStringT) Error() string { return string(e) }
+32
View File
@@ -0,0 +1,32 @@
package session
import (
"fmt"
"html"
"os"
"strings"
)
// ExportHTML writes the active branch as a standalone HTML document.
func (s *Store) ExportHTML(path string) error {
msgs, err := s.Load()
if err != nil {
return err
}
var b strings.Builder
b.WriteString("<!DOCTYPE html><html><head><meta charset=\"utf-8\"><title>")
title := s.Name()
if title == "" {
title = "session"
}
b.WriteString(html.EscapeString(title))
b.WriteString("</title></head><body>\n")
for _, m := range msgs {
fmt.Fprintf(&b, "<article data-role=\"%s\"><h2>%s</h2><pre>%s</pre></article>\n",
html.EscapeString(string(m.Role)),
html.EscapeString(string(m.Role)),
html.EscapeString(m.Content))
}
b.WriteString("</body></html>\n")
return os.WriteFile(path, []byte(b.String()), 0o644)
}
+12 -31
View File
@@ -1,8 +1,6 @@
package session
import (
"bufio"
"encoding/json"
"os"
"path/filepath"
"sync"
@@ -10,7 +8,9 @@ import (
"agent-native/runtime/llm"
)
// Store is an append-only JSONL transcript. Each line is one llm.Message.
// Store is an append-only JSONL transcript. New sessions are a tree of
// entries (id / parentId). Load still returns the model-visible message
// path, and files written as one llm.Message per line remain readable.
type Store struct {
path string
mu sync.Mutex
@@ -30,44 +30,25 @@ func Open(path string) (*Store, error) {
func (s *Store) Path() string { return s.path }
// Append records one message on the active branch.
func (s *Store) Append(msg llm.Message) error {
s.mu.Lock()
defer s.mu.Unlock()
raw, err := json.Marshal(msg)
if err != nil {
return err
}
f, err := os.OpenFile(s.path, os.O_APPEND|os.O_WRONLY, 0o644)
if err != nil {
return err
}
defer f.Close()
raw = append(raw, '\n')
_, err = f.Write(raw)
_, err := s.appendEntryLocked(Entry{Type: TypeMessage, Message: &msg})
return err
}
// Load returns the model-visible messages on the active branch.
// A compaction entry replaces earlier turns with its summary plus the
// retained tail; the file itself is never rewritten.
// Crash residue: a trailing line without a newline that is not valid JSON
// is dropped. A bad line in the middle is still an error.
func (s *Store) Load() ([]llm.Message, error) {
s.mu.Lock()
defer s.mu.Unlock()
f, err := os.Open(s.path)
entries, err := s.readEntriesLocked()
if err != nil {
return nil, err
}
defer f.Close()
var out []llm.Message
sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 0, 64*1024), 8*1024*1024)
for sc.Scan() {
line := sc.Bytes()
if len(line) == 0 {
continue
}
var msg llm.Message
if err := json.Unmarshal(line, &msg); err != nil {
return nil, err
}
out = append(out, msg)
}
return out, sc.Err()
return visibleMessages(entries, s.leafLocked(entries)), nil
}
+397
View File
@@ -0,0 +1,397 @@
package session
import (
"bufio"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"os"
"strings"
"time"
"agent-native/runtime/llm"
)
// EntryType is the kind of one JSONL record.
type EntryType string
const (
TypeHeader EntryType = "header"
TypeMessage EntryType = "message"
TypeModelChange EntryType = "model_change"
TypeThinkingLevelChange EntryType = "thinking_level_change"
TypeCompaction EntryType = "compaction"
TypeLabel EntryType = "label"
TypeCustom EntryType = "custom"
TypeSessionInfo EntryType = "session_info"
)
// Entry is one node in the session tree. Files written before the tree
// format (a bare llm.Message per line) are loaded as message entries.
type Entry struct {
ID string `json:"id"`
ParentID string `json:"parentId,omitempty"`
Timestamp string `json:"timestamp"`
Type EntryType `json:"type"`
Message *llm.Message `json:"message,omitempty"`
Model string `json:"model,omitempty"`
ThinkingLevel string `json:"thinkingLevel,omitempty"`
Label string `json:"label,omitempty"`
Name string `json:"name,omitempty"`
CustomType string `json:"customType,omitempty"`
Data json.RawMessage `json:"data,omitempty"`
Summary string `json:"summary,omitempty"`
TailIDs []string `json:"tailIds,omitempty"`
CWD string `json:"cwd,omitempty"`
}
// Entries returns every record in file order, including branches that are
// not on the active path.
func (s *Store) Entries() ([]Entry, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.readEntriesLocked()
}
// Leaf is the id of the current branch tip. Empty when the file has no entries.
func (s *Store) Leaf() (string, error) {
s.mu.Lock()
defer s.mu.Unlock()
entries, err := s.readEntriesLocked()
if err != nil {
return "", err
}
return s.leafLocked(entries), nil
}
// SetLeaf moves the active branch to an existing entry.
func (s *Store) SetLeaf(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
entries, err := s.readEntriesLocked()
if err != nil {
return err
}
if !hasID(entries, id) {
return fmt.Errorf("session: unknown entry %s", id)
}
return s.writeMetaKeyLocked("leaf", id)
}
// AppendEntry adds a non-message record (label, model change, custom, …)
// as a child of the current leaf and moves the leaf to it.
func (s *Store) AppendEntry(e Entry) (Entry, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.appendEntryLocked(e)
}
// Fork copies the tree into a new file and sets that file's leaf to atID.
// The source file is not modified. atID empty means the current leaf.
func (s *Store) Fork(dest, atID string) (*Store, error) {
return s.copyTo(dest, atID, true)
}
// Clone copies the tree and keeps the same leaf.
func (s *Store) Clone(dest string) (*Store, error) {
return s.copyTo(dest, "", false)
}
func (s *Store) copyTo(dest, atID string, moveLeaf bool) (*Store, error) {
s.mu.Lock()
defer s.mu.Unlock()
entries, err := s.readEntriesLocked()
if err != nil {
return nil, err
}
if err := copyFile(s.path, dest); err != nil {
return nil, err
}
out, err := Open(dest)
if err != nil {
return nil, err
}
meta := s.readMetaLocked()
if meta == nil {
meta = map[string]string{}
}
leaf := s.leafLocked(entries)
if moveLeaf && atID != "" {
if !hasID(entries, atID) {
return nil, fmt.Errorf("session: unknown entry %s", atID)
}
leaf = atID
}
if leaf != "" {
meta["leaf"] = leaf
}
if err := out.SaveMeta(meta); err != nil {
return nil, err
}
return out, nil
}
// SetName records a human name for continue/resume.
func (s *Store) SetName(name string) error {
s.mu.Lock()
defer s.mu.Unlock()
if err := s.writeMetaKeyLocked("name", name); err != nil {
return err
}
_, err := s.appendEntryLocked(Entry{Type: TypeSessionInfo, Name: name})
return err
}
// Name returns the session name, or empty.
func (s *Store) Name() string {
s.mu.Lock()
defer s.mu.Unlock()
return s.readMetaLocked()["name"]
}
func (s *Store) appendEntryLocked(e Entry) (Entry, error) {
entries, err := s.readEntriesLocked()
if err != nil {
return Entry{}, err
}
if e.ID == "" {
e.ID = newID()
}
if e.Timestamp == "" {
e.Timestamp = time.Now().UTC().Format(time.RFC3339Nano)
}
if e.Type == "" {
e.Type = TypeCustom
}
if e.ParentID == "" {
e.ParentID = s.leafLocked(entries)
}
raw, err := json.Marshal(e)
if err != nil {
return Entry{}, err
}
f, err := os.OpenFile(s.path, os.O_APPEND|os.O_WRONLY, 0o644)
if err != nil {
return Entry{}, err
}
defer f.Close()
if _, err := f.Write(append(raw, '\n')); err != nil {
return Entry{}, err
}
if err := s.writeMetaKeyLocked("leaf", e.ID); err != nil {
return Entry{}, err
}
return e, nil
}
func (s *Store) readEntriesLocked() ([]Entry, error) {
raw, err := os.ReadFile(s.path)
if err != nil {
return nil, err
}
text := string(raw)
if text != "" && !strings.HasSuffix(text, "\n") {
if idx := strings.LastIndexByte(text, '\n'); idx >= 0 {
if !json.Valid([]byte(text[idx+1:])) {
text = text[:idx+1]
}
} else if !json.Valid([]byte(text)) {
text = ""
}
}
var out []Entry
sc := bufio.NewScanner(strings.NewReader(text))
sc.Buffer(make([]byte, 0, 64*1024), 8*1024*1024)
legacyParent := ""
n := 0
for sc.Scan() {
line := sc.Bytes()
if len(line) == 0 {
continue
}
var probe map[string]json.RawMessage
if err := json.Unmarshal(line, &probe); err != nil {
return nil, err
}
if _, ok := probe["type"]; ok {
var e Entry
if err := json.Unmarshal(line, &e); err != nil {
return nil, err
}
out = append(out, e)
legacyParent = e.ID
n++
continue
}
var msg llm.Message
if err := json.Unmarshal(line, &msg); err != nil {
return nil, err
}
id := fmt.Sprintf("legacy-%d", n)
out = append(out, Entry{
ID: id, ParentID: legacyParent, Type: TypeMessage, Message: &msg,
})
legacyParent = id
n++
}
return out, sc.Err()
}
func (s *Store) leafLocked(entries []Entry) string {
if leaf := s.readMetaLocked()["leaf"]; leaf != "" && hasID(entries, leaf) {
return leaf
}
if len(entries) == 0 {
return ""
}
return entries[len(entries)-1].ID
}
func visibleMessages(entries []Entry, leaf string) []llm.Message {
path := pathTo(entries, leaf)
var last *Entry
for i := range path {
if path[i].Type == TypeCompaction {
last = &path[i]
}
}
if last == nil {
var msgs []llm.Message
for _, e := range path {
if e.Type == TypeMessage && e.Message != nil {
msgs = append(msgs, *e.Message)
}
}
return msgs
}
byID := map[string]Entry{}
for _, e := range entries {
byID[e.ID] = e
}
var msgs []llm.Message
for _, e := range path {
if e.ID == last.ID {
break
}
if e.Type == TypeMessage && e.Message != nil && e.Message.Role == llm.RoleSystem {
msgs = append(msgs, *e.Message)
}
}
msgs = append(msgs, llm.Message{
Role: llm.RoleUser,
Content: "[conversation summary]\n" + last.Summary + "\n[recent messages follow]",
})
for _, id := range last.TailIDs {
e, ok := byID[id]
if ok && e.Message != nil {
msgs = append(msgs, *e.Message)
}
}
after := false
for _, e := range path {
if e.ID == last.ID {
after = true
continue
}
if after && e.Type == TypeMessage && e.Message != nil {
msgs = append(msgs, *e.Message)
}
}
return msgs
}
func pathTo(entries []Entry, leaf string) []Entry {
byID := map[string]Entry{}
for _, e := range entries {
byID[e.ID] = e
}
var rev []Entry
seen := map[string]bool{}
for id := leaf; id != "" && !seen[id]; {
e, ok := byID[id]
if !ok {
break
}
seen[id] = true
rev = append(rev, e)
id = e.ParentID
}
for i, j := 0, len(rev)-1; i < j; i, j = i+1, j-1 {
rev[i], rev[j] = rev[j], rev[i]
}
return rev
}
func hasID(entries []Entry, id string) bool {
for _, e := range entries {
if e.ID == id {
return true
}
}
return false
}
func (s *Store) readMetaLocked() map[string]string {
raw, err := os.ReadFile(s.path + ".meta.json")
if err != nil {
return nil
}
var meta map[string]string
if err := json.Unmarshal(raw, &meta); err != nil {
return nil
}
return meta
}
func (s *Store) writeMetaKeyLocked(key, value string) error {
meta := s.readMetaLocked()
if meta == nil {
meta = map[string]string{}
}
meta[key] = value
raw, err := json.MarshalIndent(meta, "", " ")
if err != nil {
return err
}
tmp := s.path + ".meta.json.tmp"
if err := os.WriteFile(tmp, raw, 0o644); err != nil {
return err
}
return os.Rename(tmp, s.path+".meta.json")
}
func newID() string {
var b [8]byte
if _, err := rand.Read(b[:]); err != nil {
return fmt.Sprintf("%d", time.Now().UnixNano())
}
return hex.EncodeToString(b[:])
}
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
if err := os.MkdirAll(dirOf(dst), 0o755); err != nil {
return err
}
out, err := os.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, in)
return err
}
func dirOf(path string) string {
i := strings.LastIndex(path, "/")
if i < 0 {
return "."
}
return path[:i]
}
+128
View File
@@ -0,0 +1,128 @@
package session
import (
"os"
"path/filepath"
"strings"
"testing"
"agent-native/runtime/llm"
)
func TestTreeForkCloneAndLeaf(t *testing.T) {
dir := t.TempDir()
s, err := Open(filepath.Join(dir, "a.jsonl"))
if err != nil {
t.Fatal(err)
}
if err := s.Append(llm.Message{Role: llm.RoleUser, Content: "one"}); err != nil {
t.Fatal(err)
}
if err := s.Append(llm.Message{Role: llm.RoleAssistant, Content: "two"}); err != nil {
t.Fatal(err)
}
entries, err := s.Entries()
if err != nil || len(entries) != 2 || entries[1].ParentID != entries[0].ID {
t.Fatalf("tree links: %+v %v", entries, err)
}
fork, err := s.Fork(filepath.Join(dir, "fork.jsonl"), entries[0].ID)
if err != nil {
t.Fatal(err)
}
if err := fork.Append(llm.Message{Role: llm.RoleUser, Content: "branch"}); err != nil {
t.Fatal(err)
}
msgs, err := fork.Load()
if err != nil || len(msgs) != 2 || msgs[1].Content != "branch" {
t.Fatalf("fork path: %+v %v", msgs, err)
}
orig, _ := s.Load()
if len(orig) != 2 || orig[1].Content != "two" {
t.Fatalf("source branch changed: %+v", orig)
}
clone, err := s.Clone(filepath.Join(dir, "clone.jsonl"))
if err != nil {
t.Fatal(err)
}
cmsgs, _ := clone.Load()
if len(cmsgs) != 2 || cmsgs[1].Content != "two" {
t.Fatalf("clone: %+v", cmsgs)
}
if err := s.SetLeaf(entries[0].ID); err != nil {
t.Fatal(err)
}
switched, _ := s.Load()
if len(switched) != 1 || switched[0].Content != "one" {
t.Fatalf("set leaf: %+v", switched)
}
}
func TestLegacyTranscriptStillLoads(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "old.jsonl")
body := "{\"role\":\"system\",\"content\":\"sys\"}\n{\"role\":\"user\",\"content\":\"hi\"}\n"
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
s, err := Open(path)
if err != nil {
t.Fatal(err)
}
msgs, err := s.Load()
if err != nil || len(msgs) != 2 || msgs[1].Content != "hi" {
t.Fatalf("legacy: %+v %v", msgs, err)
}
if err := s.Append(llm.Message{Role: llm.RoleAssistant, Content: "next"}); err != nil {
t.Fatal(err)
}
msgs, err = s.Load()
if err != nil || len(msgs) != 3 || msgs[2].Content != "next" {
t.Fatalf("legacy append: %+v %v", msgs, err)
}
}
func TestCatalogContinueResumeAndHTML(t *testing.T) {
dir := t.TempDir()
s, err := Open(filepath.Join(dir, "sess.jsonl"))
if err != nil {
t.Fatal(err)
}
if err := s.SetCWD("/work"); err != nil {
t.Fatal(err)
}
if err := s.SetName("notes"); err != nil {
t.Fatal(err)
}
if err := s.Append(llm.Message{Role: llm.RoleUser, Content: "hello"}); err != nil {
t.Fatal(err)
}
got, ok, err := Continue(dir, "/work")
if err != nil || !ok || got.Path() != s.Path() {
t.Fatalf("continue: %v %v %v", got, ok, err)
}
resumed, err := Resume(dir, "notes")
if err != nil || resumed.Name() != "notes" {
t.Fatalf("resume: %v %v", resumed, err)
}
htmlPath := filepath.Join(dir, "out.html")
if err := s.ExportHTML(htmlPath); err != nil {
t.Fatal(err)
}
raw, _ := os.ReadFile(htmlPath)
if !strings.Contains(string(raw), "hello") || !strings.Contains(string(raw), "notes") {
t.Fatalf("html: %s", raw)
}
if _, err := s.AppendEntry(Entry{Type: TypeLabel, Label: "checkpoint"}); err != nil {
t.Fatal(err)
}
entries, _ := s.Entries()
var labels int
for _, e := range entries {
if e.Type == TypeLabel && e.Label == "checkpoint" {
labels++
}
}
if labels != 1 {
t.Fatalf("label missing: %+v", entries)
}
}
+200
View File
@@ -0,0 +1,200 @@
package runtime
import (
"context"
"sync"
"agent-native/runtime/llm"
"agent-native/runtime/session"
)
// Delivery selects how a queued steer or follow-up is handed to the loop.
type Delivery string
const (
DeliverOne Delivery = "one"
DeliverAll Delivery = "all"
)
// AgentSession is the headless session object: queues, abort, and
// mid-run model changes around Run.
type AgentSession struct {
cfg Config
mu sync.Mutex
steer []llm.Message
follow []llm.Message
steerMode Delivery
followMode Delivery
model string
thinking string
cancel context.CancelFunc
runID int
}
// NewAgentSession copies cfg. Queue modes default to one-at-a-time.
func NewAgentSession(cfg Config) *AgentSession {
if cfg.Model == "" {
cfg.Model = "default"
}
return &AgentSession{
cfg: cfg,
steerMode: DeliverOne,
followMode: DeliverOne,
model: cfg.Model,
thinking: cfg.ThinkingLevel,
}
}
// SetQueueMode sets the delivery mode for steer and follow-up.
func (a *AgentSession) SetQueueMode(steer, follow Delivery) {
a.mu.Lock()
defer a.mu.Unlock()
if steer != "" {
a.steerMode = steer
}
if follow != "" {
a.followMode = follow
}
}
// Steer queues a message to be delivered after the current tool batch.
func (a *AgentSession) Steer(msg llm.Message) {
a.mu.Lock()
a.steer = append(a.steer, msg)
a.mu.Unlock()
a.emitQueue("steer")
}
// FollowUp queues a message for after the assistant stops.
func (a *AgentSession) FollowUp(msg llm.Message) {
a.mu.Lock()
a.follow = append(a.follow, msg)
a.mu.Unlock()
a.emitQueue("follow_up")
}
// ClearQueue drops both queues.
func (a *AgentSession) ClearQueue() {
a.mu.Lock()
a.steer = nil
a.follow = nil
a.mu.Unlock()
a.emitQueue("clear")
}
// Abort cancels the in-flight Prompt.
func (a *AgentSession) Abort() {
a.mu.Lock()
cancel := a.cancel
a.mu.Unlock()
if cancel != nil {
cancel()
}
}
// SetModel changes the model and thinking level used on the next turn.
// A session store, when present, records the change.
func (a *AgentSession) SetModel(model, thinking string) error {
if thinking != "" {
norm, err := llm.NormalizeThinking(thinking)
if err != nil {
return err
}
thinking = norm
}
a.mu.Lock()
if model != "" {
a.model = model
}
if thinking != "" {
a.thinking = thinking
}
store := a.cfg.Session
a.mu.Unlock()
if store == nil {
return nil
}
if model != "" {
if _, err := store.AppendEntry(session.Entry{Type: session.TypeModelChange, Model: model}); err != nil {
return err
}
}
if thinking != "" {
if _, err := store.AppendEntry(session.Entry{Type: session.TypeThinkingLevelChange, ThinkingLevel: thinking}); err != nil {
return err
}
}
return nil
}
// Prompt runs one user message until the agent settles.
func (a *AgentSession) Prompt(ctx context.Context, user llm.Message) (Result, error) {
ctx, cancel := context.WithCancel(ctx)
a.mu.Lock()
a.runID++
id := a.runID
a.cancel = cancel
cfg := a.cfg
a.mu.Unlock()
defer func() {
cancel()
a.mu.Lock()
if a.runID == id {
a.cancel = nil
}
a.mu.Unlock()
}()
cfg.GetSteering = func() []llm.Message { return a.take("steer") }
cfg.GetFollowUp = func() []llm.Message { return a.take("follow") }
cfg.Resolve = func() (string, string) {
a.mu.Lock()
defer a.mu.Unlock()
return a.model, a.thinking
}
return Run(ctx, cfg, user)
}
// Compact appends a compaction entry and emits compaction events.
func (a *AgentSession) Compact(keep int, summarize func([]llm.Message) (string, error)) (bool, error) {
if a.cfg.Session == nil {
return false, nil
}
emit(a.cfg, Event{Type: "compaction_start"})
done, err := a.cfg.Session.Compact(keep, summarize)
text := "noop"
if done {
text = "compacted"
}
if err != nil {
text = err.Error()
}
emit(a.cfg, Event{Type: "compaction_end", Text: text})
return done, err
}
func (a *AgentSession) take(kind string) []llm.Message {
a.mu.Lock()
defer a.mu.Unlock()
q := &a.steer
mode := a.steerMode
if kind == "follow" {
q = &a.follow
mode = a.followMode
}
if len(*q) == 0 {
return nil
}
if mode == DeliverAll {
out := append([]llm.Message(nil), (*q)...)
*q = nil
return out
}
out := []llm.Message{(*q)[0]}
*q = (*q)[1:]
return out
}
func (a *AgentSession) emitQueue(text string) {
emit(a.cfg, Event{Type: "queue_update", Text: text})
}
+112
View File
@@ -0,0 +1,112 @@
package runtime
import (
"context"
"sync"
"testing"
"agent-native/runtime/llm"
)
func TestAgentSessionPromptForwardsStop(t *testing.T) {
called := false
sess := NewAgentSession(Config{
Model: "m",
Stream: func(ctx context.Context, model string, messages []llm.Message, td []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
ch := make(chan llm.Event, 1)
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{Role: llm.RoleAssistant, Content: "hi", StopReason: llm.StopEnd}}
close(ch)
return ch
},
Stop: func(last llm.Message, info StopInfo) StopDecision {
called = true
return StopDecision{}
},
})
res, err := sess.Prompt(context.Background(), llm.Message{Role: llm.RoleUser, Content: "go"})
if err != nil {
t.Fatal(err)
}
if !called || res.Text != "hi" {
t.Fatalf("called=%v text=%q", called, res.Text)
}
}
func TestAgentSessionSteerFollowAndAbort(t *testing.T) {
var mu sync.Mutex
step := 0
stream := llm.StreamFunc(func(ctx context.Context, model string, messages []llm.Message, td []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
ch := make(chan llm.Event, 2)
go func() {
defer close(ch)
mu.Lock()
n := step
step++
mu.Unlock()
if opts.ThinkingLevel != "low" && n == 0 {
t.Errorf("thinking level not forwarded: %s", opts.ThinkingLevel)
}
if n == 0 {
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{
Role: llm.RoleAssistant, StopReason: llm.StopToolCalls,
ToolCalls: []llm.ToolCall{{ID: "1", Name: "missing", Arguments: "{}"}},
}}
return
}
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{
Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd,
}}
}()
return ch
})
var types []string
sess := NewAgentSession(Config{
Model: "m",
ThinkingLevel: "low",
Stream: stream,
MaxTurns: 6,
OnEvent: func(ev Event) {
types = append(types, ev.Type)
},
})
sess.SetQueueMode(DeliverOne, DeliverAll)
sess.Steer(llm.Message{Role: llm.RoleUser, Content: "steer-me"})
sess.FollowUp(llm.Message{Role: llm.RoleUser, Content: "later"})
res, err := sess.Prompt(context.Background(), llm.Message{Role: llm.RoleUser, Content: "go"})
if err != nil {
t.Fatal(err)
}
if res.StopReason != llm.StopEnd {
t.Fatalf("stop %s err %v", res.StopReason, res.Err)
}
found := map[string]bool{}
for _, typ := range types {
found[typ] = true
}
for _, want := range []string{"message_start", "queue_update", "tool_execution_update", "agent_settled"} {
if !found[want] {
t.Fatalf("missing %s in %v", want, types)
}
}
}
func TestClearQueueAndSetModel(t *testing.T) {
sess := NewAgentSession(Config{Model: "old", Stream: func(ctx context.Context, model string, messages []llm.Message, td []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
ch := make(chan llm.Event, 1)
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{Role: llm.RoleAssistant, Content: model, StopReason: llm.StopEnd}}
close(ch)
return ch
}})
sess.Steer(llm.Message{Role: llm.RoleUser, Content: "drop"})
sess.ClearQueue()
if err := sess.SetModel("new-model", "high"); err != nil {
t.Fatal(err)
}
res, err := sess.Prompt(context.Background(), llm.Message{Role: llm.RoleUser, Content: "hi"})
if err != nil {
t.Fatal(err)
}
if res.Text != "new-model" {
t.Fatalf("model switch did not apply: %q", res.Text)
}
}
+221
View File
@@ -0,0 +1,221 @@
// Package skills 实现 SKILL.md 技能的加载与按需展开。
//
// 目录约定:<root>/<skill-name>/SKILL.md,正文前可选 frontmatter
//(--- 包围,行前缀 description:,不引入 YAML 依赖)。
// 目录名是技能名的权威来源(避免目录与 frontmatter 漂移)。
//
// 信任边界:技能正文进入模型上下文,属提示注入面——只加载组合根显式
// 配置的根目录与工作区 .agent/skills/;正文与索引总量受限。
package skills
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"agent-native/runtime/tools"
)
const (
maxSkillsPerRoot = 100
maxBodyBytes = 64 << 10
maxDescription = 200
)
// Skill 是一个可加载技能。
type Skill struct {
Name string
Description string
Body string
Source string
}
// Store 扫描若干根目录并按名加载技能。
type Store struct {
roots []string
cache map[string]Skill
}
func NewStore(roots ...string) *Store {
clean := make([]string, 0, len(roots))
for _, r := range roots {
if strings.TrimSpace(r) != "" {
clean = append(clean, r)
}
}
return &Store{roots: clean, cache: nil}
}
// Roots 返回配置的根目录(只读副本)。
func (s *Store) Roots() []string {
out := make([]string, len(s.roots))
copy(out, s.roots)
return out
}
// List 扫描全部根目录(同名技能先配置者优先),返回索引。
func (s *Store) List() []Skill {
cache := map[string]Skill{}
var out []Skill
for _, root := range s.roots {
entries, err := os.ReadDir(root)
if err != nil {
continue // 根不存在:跳过(技能是可选能力)
}
count := 0
for _, e := range entries {
if !e.IsDir() || count >= maxSkillsPerRoot {
continue
}
path := filepath.Join(root, e.Name(), "SKILL.md")
data, err := os.ReadFile(path)
if err != nil {
continue
}
skill, err := parse(e.Name(), path, data)
if err != nil {
continue // 单个坏技能跳过,不拖垮整个索引
}
if _, dup := cache[skill.Name]; dup {
continue
}
cache[skill.Name] = skill
out = append(out, skill)
count++
}
}
s.cache = cache
return out
}
// Load 按名加载技能正文(fail-closed:未知名返回错误,不猜测近似名)。
func (s *Store) Load(name string) (Skill, error) {
if s.cache == nil {
s.List()
}
if skill, ok := s.cache[name]; ok {
return skill, nil
}
// 缓存可能过期(List 之后目录变化):重扫一遍再判。
s.List()
if skill, ok := s.cache[name]; ok {
return skill, nil
}
return Skill{}, fmt.Errorf("unknown skill %q (not installed in any configured root)", name)
}
// ExpandCommands replaces /skill:name with that skill's body.
// An unknown name returns an error and leaves the text unchanged.
func (s *Store) ExpandCommands(text string) (string, error) {
const marker = "/skill:"
var b strings.Builder
rest := text
for {
i := strings.Index(rest, marker)
if i < 0 {
b.WriteString(rest)
break
}
b.WriteString(rest[:i])
name := rest[i+len(marker):]
end := 0
for end < len(name) && name[end] != ' ' && name[end] != '\n' && name[end] != '\t' {
end++
}
skillName := name[:end]
skill, err := s.Load(skillName)
if err != nil {
return text, err
}
b.WriteString(skill.Body)
rest = name[end:]
}
return b.String(), nil
}
// parse 解析一份 SKILL.md:目录名权威,frontmatter 可选。
func parse(dirName, path string, data []byte) (Skill, error) {
if !validSkillName(dirName) {
return Skill{}, fmt.Errorf("invalid skill dir name %q", dirName)
}
if len(data) > maxBodyBytes {
data = data[:maxBodyBytes]
}
text := string(data)
desc := ""
body := text
lines := strings.Split(text, "\n")
if len(lines) > 0 && strings.TrimSpace(lines[0]) == "---" {
end := -1
for i := 1; i < len(lines); i++ {
if strings.TrimSpace(lines[i]) == "---" {
end = i
break
}
}
if end > 0 {
for _, line := range lines[1:end] {
if v, ok := strings.CutPrefix(strings.TrimSpace(line), "description:"); ok {
desc = strings.TrimSpace(v)
}
}
body = strings.Join(lines[end+1:], "\n")
}
}
if len(desc) > maxDescription {
desc = desc[:maxDescription] + "…"
}
return Skill{Name: dirName, Description: desc, Body: body, Source: path}, nil
}
func validSkillName(s string) bool {
if s == "" || len(s) > 64 {
return false
}
for _, r := range s {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_':
default:
return false
}
}
return true
}
// ---- load_skill 工具 ----
type loadSkillTool struct{ store *Store }
// RegisterLoader 注册 load_skill 工具(store 无技能时不注册,避免死工具)。
func RegisterLoader(r *tools.Registry, store *Store) {
if store == nil || len(store.List()) == 0 {
return
}
r.Register(&loadSkillTool{store: store})
}
func (t *loadSkillTool) Name() string { return "load_skill" }
func (t *loadSkillTool) Description() string {
return "Load the full instructions of a skill by name (names are listed in the system prompt skill index)."
}
func (t *loadSkillTool) Parameters() json.RawMessage {
return json.RawMessage(`{"type":"object","required":["name"],"properties":{"name":{"type":"string"}}}`)
}
func (t *loadSkillTool) Execute(_ context.Context, args json.RawMessage) (tools.Result, error) {
var obj struct {
Name string `json:"name"`
}
if err := json.Unmarshal(args, &obj); err != nil {
return tools.Result{}, err
}
skill, err := t.store.Load(obj.Name)
if err != nil {
return tools.Result{Content: err.Error(), IsError: true}, nil
}
return tools.Result{Content: "# skill: " + skill.Name + "\n\n" + skill.Body}, nil
}
+112
View File
@@ -0,0 +1,112 @@
package skills
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"agent-native/runtime/tools"
)
func writeSkill(t *testing.T, root, name, body string) {
t.Helper()
dir := filepath.Join(root, name)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
func TestStoreListAndLoad(t *testing.T) {
root := t.TempDir()
writeSkill(t, root, "docx-author", "---\ndescription: 生成 docx 交付物\n---\n\n## 步骤\n1. 用 run_python")
writeSkill(t, root, "xlsx-author", "没有 frontmatter 的技能正文")
writeSkill(t, root, "bad name", "目录名不合法") //nolint
store := NewStore(root)
list := store.List()
if len(list) != 2 {
t.Fatalf("want 2 valid skills (bad dir skipped), got %d: %+v", len(list), list)
}
byName := map[string]Skill{}
for _, s := range list {
byName[s.Name] = s
}
if byName["docx-author"].Description != "生成 docx 交付物" {
t.Fatalf("frontmatter description wrong: %+v", byName["docx-author"])
}
if !strings.Contains(byName["docx-author"].Body, "run_python") {
t.Fatalf("body must exclude frontmatter, got %q", byName["docx-author"].Body)
}
if byName["xlsx-author"].Description != "" || !strings.Contains(byName["xlsx-author"].Body, "没有 frontmatter") {
t.Fatalf("no-frontmatter skill wrong: %+v", byName["xlsx-author"])
}
skill, err := store.Load("docx-author")
if err != nil || !strings.Contains(skill.Body, "步骤") {
t.Fatalf("load failed: %v %+v", err, skill)
}
_, err = store.Load("nope")
if err == nil || !strings.Contains(err.Error(), "unknown skill") {
t.Fatalf("unknown skill must fail-closed, got %v", err)
}
}
func TestStoreRootPriorityAndMissingRoot(t *testing.T) {
r1, r2 := t.TempDir(), t.TempDir()
writeSkill(t, r1, "dup", "from r1")
writeSkill(t, r2, "dup", "from r2")
writeSkill(t, r2, "only-r2", "x")
store := NewStore(r1, r2, "/nonexistent/root")
list := store.List()
if len(list) != 2 {
t.Fatalf("want 2 skills, got %d", len(list))
}
skill, _ := store.Load("dup")
if skill.Body != "from r1" {
t.Fatalf("earlier root must win, got %q", skill.Body)
}
}
func TestRegisterLoaderTool(t *testing.T) {
root := t.TempDir()
writeSkill(t, root, "docx-author", "---\ndescription: d\n---\n正文指令")
reg := tools.NewRegistry(t.TempDir())
RegisterLoader(reg, NewStore(root))
var found bool
for _, d := range reg.Decls() {
if d.Name == "load_skill" {
found = true
}
}
if !found {
t.Fatal("load_skill must be registered when skills exist")
}
res := reg.Execute(context.Background(), "load_skill", json.RawMessage(`{"name":"docx-author"}`))
if res.IsError || !strings.Contains(res.Content, "正文指令") {
t.Fatalf("load failed: %+v", res)
}
res = reg.Execute(context.Background(), "load_skill", json.RawMessage(`{"name":"ghost"}`))
if !res.IsError || !strings.Contains(res.Content, "unknown skill") {
t.Fatalf("ghost skill must error, got %+v", res)
}
}
func TestRegisterLoaderSkipsEmptyStore(t *testing.T) {
reg := tools.NewRegistry(t.TempDir())
RegisterLoader(reg, NewStore(t.TempDir()))
for _, d := range reg.Decls() {
if d.Name == "load_skill" {
t.Fatal("no skills = no loader tool")
}
}
RegisterLoader(reg, nil)
if len(reg.Decls()) != 0 {
t.Fatal("nil store must not register")
}
}
+103
View File
@@ -0,0 +1,103 @@
// Package subagent 定义委派端口:内核不内置嵌套执行(那是编排层职责),
// 只提供工具面与信息集契约;宿主(产品 SpawnSubagent、小队引擎等)
// 注入 Factory 实现真实派生执行。未注入则 delegate_task 不注册(fail-closed)。
//
// 信息集对齐 agent-execution 标准的委派条款:目标、输入、写范围、非目标、
// 完成判据、预算与回传格式——子任务不能从父任务目标推导额外权限。
package subagent
import (
"context"
"encoding/json"
"fmt"
"sort"
"strings"
"time"
"agent-native/runtime/tools"
)
// Request 是一次委派的完整信息集(模型经工具参数逐项填写)。
type Request struct {
Goal string `json:"goal"`
Inputs string `json:"inputs,omitempty"`
WriteScope string `json:"write_scope,omitempty"`
NonGoals string `json:"non_goals,omitempty"`
DoneCriteria string `json:"done_criteria,omitempty"`
Deliverables []string `json:"deliverables,omitempty"`
// Deadline / MaxToolCalls 是宿主注入的预算余量(模型不可自行扩权)。
Deadline time.Time `json:"-"`
MaxToolCalls int `json:"-"`
}
// Result 是子代理的回传:至少包含结论文本;文件由宿主按写范围物化。
type Result struct {
Success bool
Text string
Files map[string][]byte
Err string
}
// Factory 由宿主注入;ctx 取消必须传播到子执行。
type Factory func(ctx context.Context, req Request) (Result, error)
type delegateTool struct{ factory Factory }
// Register 把 delegate_task 工具注册进注册表(factory 为 nil 时不注册)。
func Register(r *tools.Registry, factory Factory) {
if factory == nil {
return
}
r.Register(&delegateTool{factory: factory})
}
func (t *delegateTool) Name() string { return "delegate_task" }
func (t *delegateTool) Description() string {
return "Delegate a self-contained subtask to a fresh sub-agent. Fill every field: goal, inputs (with versions), write_scope (directory the sub-agent may write), non_goals, done_criteria, deliverables. The sub-agent cannot exceed your permissions; report back its conclusion and files."
}
func (t *delegateTool) Parameters() json.RawMessage {
return json.RawMessage(`{"type":"object","required":["goal","done_criteria"],"properties":{
"goal":{"type":"string"},
"inputs":{"type":"string"},
"write_scope":{"type":"string"},
"non_goals":{"type":"string"},
"done_criteria":{"type":"string"},
"deliverables":{"type":"array","items":{"type":"string"}}
}}`)
}
func (t *delegateTool) Execute(ctx context.Context, args json.RawMessage) (tools.Result, error) {
var req Request
if err := json.Unmarshal(args, &req); err != nil {
return tools.Result{}, err
}
if strings.TrimSpace(req.Goal) == "" {
return tools.Result{Content: "goal is empty", IsError: true}, nil
}
if ctx.Err() != nil {
return tools.Result{Content: "delegation canceled before start: " + ctx.Err().Error(), IsError: true}, nil
}
res, err := t.factory(ctx, req)
if err != nil {
return tools.Result{Content: fmt.Sprintf("sub-agent failed: %v", err), IsError: true}, nil
}
var b strings.Builder
if res.Success {
b.WriteString("[sub-agent succeeded]\n")
} else {
b.WriteString("[sub-agent failed]\n")
}
if res.Err != "" {
b.WriteString("error: " + res.Err + "\n")
}
b.WriteString(res.Text)
if len(res.Files) > 0 {
names := make([]string, 0, len(res.Files))
for name := range res.Files {
names = append(names, name)
}
sort.Strings(names)
b.WriteString("\nfiles: " + strings.Join(names, ", "))
}
return tools.Result{Content: b.String(), IsError: !res.Success}, nil
}
+86
View File
@@ -0,0 +1,86 @@
package subagent
import (
"context"
"encoding/json"
"strings"
"testing"
"agent-native/runtime/tools"
)
func TestRegisterNilFactoryNoop(t *testing.T) {
reg := tools.NewRegistry(t.TempDir())
Register(reg, nil)
for _, d := range reg.Decls() {
if d.Name == "delegate_task" {
t.Fatal("nil factory must not register delegate_task")
}
}
}
func TestDelegateToolRoundTrip(t *testing.T) {
var got Request
reg := tools.NewRegistry(t.TempDir())
Register(reg, func(ctx context.Context, req Request) (Result, error) {
got = req
return Result{
Success: true,
Text: "调研完成:结论 A。",
Files: map[string][]byte{"notes.md": []byte("n"), "data/table.csv": []byte("c")},
}, nil
})
res := reg.Execute(context.Background(), "delegate_task", json.RawMessage(`{
"goal":"调研主题 X",
"inputs":"docs/v2 规范",
"write_scope":"research/",
"non_goals":"不做实现",
"done_criteria":"产出三来源对照表",
"deliverables":["notes.md","data/table.csv"]
}`))
if res.IsError {
t.Fatal(res.Content)
}
if got.Goal != "调研主题 X" || got.WriteScope != "research/" || got.DoneCriteria != "产出三来源对照表" {
t.Fatalf("delegation fields lost: %+v", got)
}
if !strings.Contains(res.Content, "[sub-agent succeeded]") ||
!strings.Contains(res.Content, "调研完成") ||
!strings.Contains(res.Content, "files: data/table.csv, notes.md") {
t.Fatalf("result text wrong:\n%s", res.Content)
}
}
func TestDelegateToolFailureAndValidation(t *testing.T) {
reg := tools.NewRegistry(t.TempDir())
Register(reg, func(ctx context.Context, req Request) (Result, error) {
return Result{Success: false, Err: "子任务超出预算"}, nil
})
res := reg.Execute(context.Background(), "delegate_task", json.RawMessage(`{"goal":"g","done_criteria":"d"}`))
if !res.IsError || !strings.Contains(res.Content, "[sub-agent failed]") || !strings.Contains(res.Content, "子任务超出预算") {
t.Fatalf("failure must propagate, got %+v", res)
}
// goal 必填(schema 校验拦截)。
res = reg.Execute(context.Background(), "delegate_task", json.RawMessage(`{"done_criteria":"d"}`))
if !res.IsError || !strings.Contains(res.Content, "required argument goal") {
t.Fatalf("missing goal must fail schema validation, got %+v", res)
}
}
func TestDelegateToolFactoryErrorAndCancel(t *testing.T) {
reg := tools.NewRegistry(t.TempDir())
Register(reg, func(ctx context.Context, req Request) (Result, error) {
return Result{}, context.Canceled
})
res := reg.Execute(context.Background(), "delegate_task", json.RawMessage(`{"goal":"g","done_criteria":"d"}`))
if !res.IsError || !strings.Contains(res.Content, "sub-agent failed") {
t.Fatalf("factory error must surface, got %+v", res)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
res = reg.Execute(ctx, "delegate_task", json.RawMessage(`{"goal":"g","done_criteria":"d"}`))
if !res.IsError || !strings.Contains(res.Content, "canceled before start") {
t.Fatalf("pre-canceled delegation must not start, got %+v", res)
}
}
+83
View File
@@ -0,0 +1,83 @@
package tools
import (
"context"
"encoding/json"
)
// aliasTool exposes an existing tool under a pi-style name.
type aliasTool struct {
name string
inner Tool
}
func (a aliasTool) Name() string { return a.name }
func (a aliasTool) Description() string { return a.inner.Description() }
func (a aliasTool) Parameters() json.RawMessage { return a.inner.Parameters() }
func (a aliasTool) Execute(ctx context.Context, args json.RawMessage) (Result, error) {
return a.inner.Execute(ctx, args)
}
// piAliases maps the headless pi tool name onto the name already registered.
var piAliases = map[string]string{
"read": "read_file",
"write": "write_file",
"edit": "edit_file",
"bash": "run_command",
"grep": "grep",
"find": "glob",
"ls": "list_dir",
}
// RegisterPiAliases adds pi tool names beside the existing ones.
// A name is skipped when the underlying tool is not registered.
func (r *Registry) RegisterPiAliases() {
for alias, inner := range piAliases {
if alias == inner || r.Has(alias) || !r.Has(inner) {
continue
}
r.Register(aliasTool{name: alias, inner: r.tools[inner]})
}
}
// FilterOptions selects which tools stay visible.
type FilterOptions struct {
Tools []string
Exclude []string
NoBuiltin bool
}
// Filter applies an allow list, then exclusions. NoBuiltin drops the
// workspace tool names and their pi aliases.
func (r *Registry) Filter(opts FilterOptions) error {
if len(opts.Tools) > 0 {
if err := r.Restrict(opts.Tools); err != nil {
return err
}
}
drop := map[string]bool{}
for _, name := range opts.Exclude {
drop[name] = true
}
if opts.NoBuiltin {
for _, name := range []string{"read_file", "write_file", "edit_file", "multi_edit", "list_dir", "grep", "glob", "run_command", "run_python"} {
drop[name] = true
}
for alias := range piAliases {
drop[alias] = true
}
}
if len(drop) == 0 {
return nil
}
var order []string
for _, name := range r.order {
if drop[name] {
delete(r.tools, name)
continue
}
order = append(order, name)
}
r.order = order
return nil
}
+129 -13
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
)
@@ -13,17 +14,21 @@ type readFile struct{ root string }
func (t *readFile) Name() string { return "read_file" }
func (t *readFile) Description() string {
return "Read a UTF-8 text file inside the workspace."
return "Read a UTF-8 text file inside the workspace. Without offset/limit returns the whole file (truncated at 200 KiB); with offset/limit returns numbered lines from a 1-based offset."
}
func (t *readFile) Parameters() json.RawMessage {
return json.RawMessage(`{"type":"object","required":["path"],"properties":{"path":{"type":"string"}}}`)
return json.RawMessage(`{"type":"object","required":["path"],"properties":{"path":{"type":"string"},"offset":{"type":"integer","description":"1-based first line to return"},"limit":{"type":"integer","description":"number of lines to return"}}}`)
}
func (t *readFile) Execute(_ context.Context, args json.RawMessage) (Result, error) {
rel, err := readArg(args, "path")
if err != nil {
var obj struct {
Path string `json:"path"`
Offset int `json:"offset"`
Limit int `json:"limit"`
}
if err := json.Unmarshal(args, &obj); err != nil {
return Result{}, err
}
path, err := mustPath(t.root, rel)
path, err := mustPath(t.root, obj.Path)
if err != nil {
return Result{Content: err.Error(), IsError: true}, nil
}
@@ -31,10 +36,37 @@ func (t *readFile) Execute(_ context.Context, args json.RawMessage) (Result, err
if err != nil {
return Result{Content: err.Error(), IsError: true}, nil
}
if len(b) > maxReadBytes {
b = b[:maxReadBytes]
if obj.Offset <= 0 && obj.Limit <= 0 {
truncated := false
if len(b) > maxReadBytes {
b = b[:maxReadBytes]
truncated = true
}
content := string(b)
if truncated {
content += "\n[truncated at 200 KiB; use offset/limit to page through the file]"
}
return Result{Content: content}, nil
}
return Result{Content: string(b)}, nil
if obj.Offset <= 0 {
obj.Offset = 1
}
if obj.Limit <= 0 {
obj.Limit = 1000
}
lines := strings.Split(string(b), "\n")
if obj.Offset > len(lines) {
return Result{Content: fmt.Sprintf("offset %d beyond end of file (%d lines)", obj.Offset, len(lines)), IsError: true}, nil
}
end := obj.Offset - 1 + obj.Limit
if end > len(lines) {
end = len(lines)
}
var out []string
for i := obj.Offset - 1; i < end; i++ {
out = append(out, fmt.Sprintf("%d\t%s", i+1, lines[i]))
}
return Result{Content: strings.Join(out, "\n")}, nil
}
type writeFile struct{ root string }
@@ -145,22 +177,51 @@ type grepTool struct{ root string }
func (t *grepTool) Name() string { return "grep" }
func (t *grepTool) Description() string {
return "Search workspace text files for a literal substring."
return "Search workspace text files. Literal substring by default; regex=true switches to Go regexp, include filters file names (e.g. *.go), ignore_case folds case. Caps at 50 hits."
}
func (t *grepTool) Parameters() json.RawMessage {
return json.RawMessage(`{"type":"object","required":["pattern"],"properties":{"pattern":{"type":"string"},"path":{"type":"string"}}}`)
return json.RawMessage(`{"type":"object","required":["pattern"],"properties":{"pattern":{"type":"string"},"path":{"type":"string"},"regex":{"type":"boolean"},"ignore_case":{"type":"boolean"},"include":{"type":"string","description":"file-name glob filter such as *.go"}}}`)
}
func (t *grepTool) Execute(_ context.Context, args json.RawMessage) (Result, error) {
var obj struct {
Pattern string `json:"pattern"`
Path string `json:"path"`
Pattern string `json:"pattern"`
Path string `json:"path"`
Regex bool `json:"regex"`
IgnoreCase bool `json:"ignore_case"`
Include string `json:"include"`
}
if err := json.Unmarshal(args, &obj); err != nil {
return Result{}, err
}
if strings.TrimSpace(obj.Pattern) == "" {
return Result{Content: "pattern is empty", IsError: true}, nil
}
if obj.Path == "" {
obj.Path = "."
}
var includeRe *globMatcher
if strings.TrimSpace(obj.Include) != "" {
m, err := newGlobMatcher(obj.Include)
if err != nil {
return Result{Content: err.Error(), IsError: true}, nil
}
includeRe = m
}
contains := func(line string) bool { return strings.Contains(line, obj.Pattern) }
if obj.Regex {
pat := obj.Pattern
if obj.IgnoreCase {
pat = "(?i)" + pat
}
re, cerr := regexp.Compile(pat)
if cerr != nil {
return Result{Content: cerr.Error(), IsError: true}, nil
}
contains = re.MatchString
} else if obj.IgnoreCase {
lower := strings.ToLower(obj.Pattern)
contains = func(line string) bool { return strings.Contains(strings.ToLower(line), lower) }
}
start, err := mustPath(t.root, obj.Path)
if err != nil {
return Result{Content: err.Error(), IsError: true}, nil
@@ -173,13 +234,16 @@ func (t *grepTool) Execute(_ context.Context, args json.RawMessage) (Result, err
if len(hits) >= 50 {
return fmt.Errorf("stop")
}
if includeRe != nil && !includeRe.Match(filepath.Base(path)) {
return nil
}
b, err := os.ReadFile(path)
if err != nil || len(b) > maxReadBytes {
return nil
}
rel, _ := filepath.Rel(t.root, path)
for i, line := range strings.Split(string(b), "\n") {
if strings.Contains(line, obj.Pattern) {
if contains(line) {
hits = append(hits, fmt.Sprintf("%s:%d:%s", filepath.ToSlash(rel), i+1, line))
if len(hits) >= 50 {
return fmt.Errorf("stop")
@@ -196,3 +260,55 @@ func (t *grepTool) Execute(_ context.Context, args json.RawMessage) (Result, err
}
return Result{Content: strings.Join(hits, "\n")}, nil
}
// multiEditTool 一次多处替换:全部命中且互不冲突才落盘,任一失败即整体不动。
type multiEditTool struct{ root string }
func (t *multiEditTool) Name() string { return "multi_edit" }
func (t *multiEditTool) Description() string {
return "Apply several unique-span replacements to one workspace file atomically: either all edits land or the file is left untouched."
}
func (t *multiEditTool) Parameters() json.RawMessage {
return json.RawMessage(`{"type":"object","required":["path","edits"],"properties":{"path":{"type":"string"},"edits":{"type":"array","items":{"type":"object","required":["old","new"],"properties":{"old":{"type":"string"},"new":{"type":"string"}}}}}}`)
}
type multiEditSpan struct {
Old string `json:"old"`
New string `json:"new"`
}
func (t *multiEditTool) Execute(_ context.Context, args json.RawMessage) (Result, error) {
var obj struct {
Path string `json:"path"`
Edits []multiEditSpan `json:"edits"`
}
if err := json.Unmarshal(args, &obj); err != nil {
return Result{}, err
}
if len(obj.Edits) == 0 {
return Result{Content: "edits is empty", IsError: true}, nil
}
path, err := mustPath(t.root, obj.Path)
if err != nil {
return Result{Content: err.Error(), IsError: true}, nil
}
b, err := os.ReadFile(path)
if err != nil {
return Result{Content: err.Error(), IsError: true}, nil
}
text := string(b)
for i, e := range obj.Edits {
if e.Old == "" {
return Result{Content: fmt.Sprintf("edit %d: old text is empty", i+1), IsError: true}, nil
}
n := strings.Count(text, e.Old)
if n != 1 {
return Result{Content: fmt.Sprintf("edit %d: expected one match, found %d; file unchanged", i+1, n), IsError: true}, nil
}
text = strings.Replace(text, e.Old, e.New, 1)
}
if err := os.WriteFile(path, []byte(text), 0o644); err != nil {
return Result{Content: err.Error(), IsError: true}, nil
}
return Result{Content: fmt.Sprintf("applied %d edits to %s", len(obj.Edits), obj.Path)}, nil
}
+158
View File
@@ -0,0 +1,158 @@
package tools
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
)
func writeFileT(t *testing.T, root, rel, content string) {
t.Helper()
path := filepath.Join(root, filepath.FromSlash(rel))
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
func runTool(t *testing.T, reg *Registry, name, args string) Result {
t.Helper()
res := reg.Execute(context.Background(), name, []byte(args))
return res
}
func TestReadFileLineRange(t *testing.T) {
root := t.TempDir()
writeFileT(t, root, "a.txt", "l1\nl2\nl3\nl4\nl5")
reg := WorkspaceTools(root)
res := runTool(t, reg, "read_file", `{"path":"a.txt","offset":2,"limit":2}`)
if res.IsError || res.Content != "2\tl2\n3\tl3" {
t.Fatalf("want numbered lines 2-3, got %+v", res)
}
res = runTool(t, reg, "read_file", `{"path":"a.txt","offset":4}`)
if res.IsError || res.Content != "4\tl4\n5\tl5" {
t.Fatalf("want tail from line 4, got %+v", res)
}
res = runTool(t, reg, "read_file", `{"path":"a.txt","offset":99}`)
if !res.IsError || !strings.Contains(res.Content, "beyond end") {
t.Fatalf("want beyond-end error, got %+v", res)
}
// 无 offset/limit 保持旧行为:整文件原文。
res = runTool(t, reg, "read_file", `{"path":"a.txt"}`)
if res.IsError || res.Content != "l1\nl2\nl3\nl4\nl5" {
t.Fatalf("legacy whole-file read changed, got %+v", res)
}
}
func TestGrepRegexIncludeAndCase(t *testing.T) {
root := t.TempDir()
writeFileT(t, root, "src/a.go", "func Alpha() {}\nfunc beta() {}\n")
writeFileT(t, root, "src/b.go", "func Gamma() {}\n")
writeFileT(t, root, "doc/a.md", "func Alpha docs\n")
reg := WorkspaceTools(root)
res := runTool(t, reg, "grep", `{"pattern":"func [A-Z][a-z]+","regex":true,"include":"*.go"}`)
if res.IsError {
t.Fatal(res.Content)
}
if !strings.Contains(res.Content, "src/a.go:1:func Alpha() {}") || !strings.Contains(res.Content, "src/b.go:1:func Gamma() {}") {
t.Fatalf("regex+include should hit go files only, got:\n%s", res.Content)
}
if strings.Contains(res.Content, "a.md") || strings.Contains(res.Content, "beta") {
t.Fatalf("md file / lowercase must not match, got:\n%s", res.Content)
}
res = runTool(t, reg, "grep", `{"pattern":"FUNC BETA","regex":true,"ignore_case":true}`)
if res.IsError || !strings.Contains(res.Content, "func beta() {}") {
t.Fatalf("ignore_case regex failed, got %+v", res)
}
res = runTool(t, reg, "grep", `{"pattern":"func [","regex":true}`)
if !res.IsError {
t.Fatal("invalid regex must be an error result")
}
// 字面默认路径不变。
res = runTool(t, reg, "grep", `{"pattern":"Alpha"}`)
if res.IsError || !strings.Contains(res.Content, "a.go:1") {
t.Fatalf("literal default failed, got %+v", res)
}
}
func TestGlobDoubleStar(t *testing.T) {
root := t.TempDir()
writeFileT(t, root, "docs/x/deep/a.md", "x")
writeFileT(t, root, "docs/b.md", "x")
writeFileT(t, root, "src/main.go", "x")
writeFileT(t, root, "main.go", "x")
reg := WorkspaceTools(root)
res := runTool(t, reg, "glob", `{"pattern":"**/*.go"}`)
if res.IsError {
t.Fatal(res.Content)
}
if !strings.Contains(res.Content, "src/main.go") || !strings.Contains(res.Content, "main.go") || strings.Contains(res.Content, ".md") {
t.Fatalf("**/*.go wrong, got:\n%s", res.Content)
}
res = runTool(t, reg, "glob", `{"pattern":"docs/**/*.md"}`)
if res.IsError || !strings.Contains(res.Content, "docs/x/deep/a.md") || !strings.Contains(res.Content, "docs/b.md") {
t.Fatalf("docs/**/*.md wrong, got:\n%s", res.Content)
}
res = runTool(t, reg, "glob", `{"pattern":"docs/**"}`)
if res.IsError || !strings.Contains(res.Content, "docs/b.md") || !strings.Contains(res.Content, "docs/x/deep/a.md") {
t.Fatalf("trailing ** must match everything below, got:\n%s", res.Content)
}
res = runTool(t, reg, "glob", `{"pattern":"**/nope.txt"}`)
if res.IsError || res.Content != "no matches" {
t.Fatalf("want no matches, got %+v", res)
}
}
func TestGlobEscapingOutsideWorkspace(t *testing.T) {
reg := WorkspaceTools(t.TempDir())
// glob 只在工作区内匹配(WalkDir 从 root 出发),模式里的 ../ 无从逃逸;
// 字面 .. 也只是找不到匹配。
res := runTool(t, reg, "glob", `{"pattern":"../**/*.go"}`)
if res.IsError && res.Content == "" {
t.Fatal("unexpected empty error")
}
}
func TestMultiEditAtomic(t *testing.T) {
root := t.TempDir()
writeFileT(t, root, "a.md", "alpha\nbeta\ngamma\n")
reg := WorkspaceTools(root)
res := runTool(t, reg, "multi_edit", `{"path":"a.md","edits":[{"old":"alpha","new":"ALPHA"},{"old":"gamma","new":"GAMMA"}]}`)
if res.IsError || res.Content != "applied 2 edits to a.md" {
t.Fatalf("want both applied, got %+v", res)
}
data, _ := os.ReadFile(filepath.Join(root, "a.md"))
if string(data) != "ALPHA\nbeta\nGAMMA\n" {
t.Fatalf("file content wrong: %q", data)
}
// 第二处替换失败 → 文件保持原样(全有或全无)。
writeFileT(t, root, "b.md", "one\ntwo\n")
res = runTool(t, reg, "multi_edit", `{"path":"b.md","edits":[{"old":"one","new":"ONE"},{"old":"three","new":"THREE"}]}`)
if !res.IsError || !strings.Contains(res.Content, "found 0") || !strings.Contains(res.Content, "unchanged") {
t.Fatalf("want atomic failure, got %+v", res)
}
data, _ = os.ReadFile(filepath.Join(root, "b.md"))
if string(data) != "one\ntwo\n" {
t.Fatalf("failed batch must not modify file, got %q", data)
}
res = runTool(t, reg, "multi_edit", `{"path":"b.md","edits":[]}`)
if !res.IsError {
t.Fatal("empty edits must be an error")
}
}
+194
View File
@@ -0,0 +1,194 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"math/rand"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"time"
)
// ExecConfig 声明执行类工具的运行条件,由组合根显式提供;
// 未提供(nil)时 run_command / run_python 一律不注册(fail-closed)。
// 注意:工作区路径约束不等于 OS 沙箱——进程仍以当前用户权限运行,
// 隔离需由宿主环境提供(容器/沙箱),本文件只做时限、输出、env 与审计收敛。
type ExecConfig struct {
// Bash / Python 是解释器绝对路径;留空则对应工具不注册。
Bash string
Python string
// Env 传给子进程的完整白名单环境(KEY=VALUE);不含本进程任何隐式变量。
Env []string
// ScriptDir 落盘脚本目录(相对 root,默认 .agent/scripts);空用默认。
ScriptDir string
}
const (
execDefaultTimeout = 60 * time.Second
execMaxTimeout = 300 * time.Second
execMaxOutput = 64 << 10
)
// blockedCommandMarkers 最小命令黑名单:明显提权/自毁模式直接拒绝。
// 这不是沙箱,只是防呆;真实隔离靠宿主环境。
var blockedCommandMarkers = []string{
"sudo ", "sudo\t", "sudo\n",
"rm -rf /", "rm -fr /",
":(){:|:&};:",
"mkfs", "shutdown", "reboot",
}
// RegisterExecTools 注册 run_command / run_python(解释器未配置的项跳过注册)。
func RegisterExecTools(r *Registry, root string, cfg ExecConfig) {
if strings.TrimSpace(cfg.Bash) != "" {
r.Register(&runCommandTool{root: root, cfg: cfg})
}
if strings.TrimSpace(cfg.Python) != "" {
r.Register(&runPythonTool{root: root, cfg: cfg})
}
}
type runCommandTool struct {
root string
cfg ExecConfig
}
func (t *runCommandTool) Name() string { return "run_command" }
func (t *runCommandTool) Description() string {
return "Run a bash command in the workspace root (process group, default 60s timeout, 64 KiB output cap). Non-zero exit and timeout are reported as text. NOT an OS sandbox."
}
func (t *runCommandTool) Parameters() json.RawMessage {
return json.RawMessage(`{"type":"object","required":["command"],"properties":{"command":{"type":"string"},"timeout_seconds":{"type":"integer","description":"1..300, default 60"}}}`)
}
func (t *runCommandTool) Execute(ctx context.Context, args json.RawMessage) (Result, error) {
var obj struct {
Command string `json:"command"`
TimeoutSeconds int `json:"timeout_seconds"`
}
if err := json.Unmarshal(args, &obj); err != nil {
return Result{}, err
}
if strings.TrimSpace(obj.Command) == "" {
return Result{Content: "command is empty", IsError: true}, nil
}
if blocked(obj.Command) {
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)
return Result{Content: content, IsError: timedOut || err != nil}, nil
}
type runPythonTool struct {
root string
cfg ExecConfig
}
func (t *runPythonTool) Name() string { return "run_python" }
func (t *runPythonTool) Description() string {
return "Run a Python script: the code is persisted under .agent/scripts/ in the workspace and executed with the configured python3 interpreter (default 60s timeout, 64 KiB output cap)."
}
func (t *runPythonTool) Parameters() json.RawMessage {
return json.RawMessage(`{"type":"object","required":["code"],"properties":{"code":{"type":"string"},"timeout_seconds":{"type":"integer","description":"1..300, default 60"}}}`)
}
func (t *runPythonTool) Execute(ctx context.Context, args json.RawMessage) (Result, error) {
var obj struct {
Code string `json:"code"`
TimeoutSeconds int `json:"timeout_seconds"`
}
if err := json.Unmarshal(args, &obj); err != nil {
return Result{}, err
}
if strings.TrimSpace(obj.Code) == "" {
return Result{Content: "code is empty", IsError: true}, nil
}
dir := t.cfg.ScriptDir
if dir == "" {
dir = ".agent/scripts"
}
scriptDir := filepath.Join(t.root, filepath.FromSlash(dir))
if err := os.MkdirAll(scriptDir, 0o755); err != nil {
return Result{Content: err.Error(), IsError: true}, nil
}
name := fmt.Sprintf("%s-%06d.py", time.Now().UTC().Format("20060102T150405"), rand.Intn(1000000))
script := filepath.Join(scriptDir, name)
if err := os.WriteFile(script, []byte(obj.Code), 0o644); err != nil {
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)
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) {
runCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
cmd := exec.CommandContext(runCtx, interpreter, argv...)
cmd.Dir = dir
cmd.Env = env
// 独立进程组:超时/取消时杀 -PGID,确保孙进程一并退出。
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
cmd.Cancel = func() error {
if cmd.Process != nil {
return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
}
return nil
}
cmd.WaitDelay = 3 * time.Second
out, err = cmd.CombinedOutput()
timedOut = runCtx.Err() != nil && (ctx.Err() == nil || runCtx.Err() != ctx.Err())
return out, timedOut, err
}
func clampTimeout(seconds int) time.Duration {
if seconds <= 0 {
return execDefaultTimeout
}
if seconds > int(execMaxTimeout.Seconds()) {
return execMaxTimeout
}
return time.Duration(seconds) * time.Second
}
func blocked(command string) bool {
lower := strings.ToLower(command)
for _, m := range blockedCommandMarkers {
if strings.Contains(lower, m) {
return true
}
}
return false
}
// formatProcessOutput 截断为头尾各半(总上限 execMaxOutput)并标注退出状态。
func formatProcessOutput(out []byte, timedOut bool, err error) string {
text := string(out)
if len(out) > execMaxOutput {
half := execMaxOutput / 2
dropped := len(out) - execMaxOutput
text = string(out[:half]) + fmt.Sprintf("\n[... truncated %d bytes ...]\n", dropped) + string(out[len(out)-half:])
}
switch {
case timedOut:
text += "\n[timeout: process group killed]"
case err != nil:
if ee, ok := err.(*exec.ExitError); ok {
text += fmt.Sprintf("\n[exit code %d]", ee.ExitCode())
} else {
text += "\n[error] " + err.Error()
}
default:
text += "\n[exit code 0]"
}
return text
}
+166
View File
@@ -0,0 +1,166 @@
package tools
import (
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
func lookInterpreter(t *testing.T, name string) string {
t.Helper()
p, err := exec.LookPath(name)
if err != nil {
t.Fatalf("%s interpreter not available on host: %v", name, err)
}
return p
}
func TestRunCommandEchoAndExitCode(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"}})
res := runTool(t, reg, "run_command", `{"command":"echo hello"}`)
if res.IsError || !strings.Contains(res.Content, "hello") || !strings.Contains(res.Content, "[exit code 0]") {
t.Fatalf("echo failed: %+v", res)
}
res = runTool(t, reg, "run_command", `{"command":"echo boom >&2; exit 3"}`)
if !res.IsError || !strings.Contains(res.Content, "boom") || !strings.Contains(res.Content, "[exit code 3]") {
t.Fatalf("non-zero exit must be reported as text: %+v", res)
}
}
func TestRunCommandEnvWhitelist(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{"PATH=/usr/bin:/bin", "ALLOWED_FLAG=yes"},
})
res := runTool(t, reg, "run_command", `{"command":"echo allowed=$ALLOWED_FLAG secret=${KERNEL_SECRET_LEAK:-absent}"}`)
if res.IsError {
t.Fatal(res.Content)
}
if !strings.Contains(res.Content, "allowed=yes") || !strings.Contains(res.Content, "secret=absent") {
t.Fatalf("env whitelist violated, got:\n%s", res.Content)
}
}
func TestRunCommandTimeoutKillsProcessGroup(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"}})
start := time.Now()
res := runTool(t, reg, "run_command", `{"command":"sleep 30 & sleep 30","timeout_seconds":1}`)
elapsed := time.Since(start)
if !res.IsError || !strings.Contains(res.Content, "[timeout: process group killed]") {
t.Fatalf("want timeout kill, got %+v", res)
}
if elapsed > 10*time.Second {
t.Fatalf("process group must die promptly, took %s", elapsed)
}
}
func TestRunCommandContextCancelKillsGroup(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(200 * time.Millisecond)
cancel()
}()
start := time.Now()
res := reg.Execute(ctx, "run_command", []byte(`{"command":"sleep 30","timeout_seconds":60}`))
elapsed := time.Since(start)
if elapsed > 10*time.Second {
t.Fatalf("cancel must kill promptly, took %s", elapsed)
}
if !res.IsError {
t.Fatalf("canceled command must be an error result, got %+v", res)
}
}
func TestRunCommandBlocklist(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"}})
for _, cmd := range []string{
`{"command":"sudo rm x"}`,
`{"command":"rm -rf /"}`,
`{"command":"shutdown now"}`,
} {
res := runTool(t, reg, "run_command", cmd)
if !res.IsError || !strings.Contains(res.Content, "blocklist") {
t.Fatalf("blocklist must reject %s, got %+v", cmd, res)
}
}
}
func TestRunCommandOutputTruncation(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"}})
res := runTool(t, reg, "run_command", `{"command":"head -c 200000 /dev/zero | tr '\\0' 'x'"}`)
if res.IsError {
t.Fatal(res.Content)
}
if len(res.Content) > execMaxOutput+512 {
t.Fatalf("output must be truncated to ~%d bytes, got %d", execMaxOutput, len(res.Content))
}
if !strings.Contains(res.Content, "truncated") {
t.Fatalf("truncation marker missing, len=%d", len(res.Content))
}
}
func TestRunPythonPersistsScriptAndRuns(t *testing.T) {
python := lookInterpreter(t, "python3")
root := t.TempDir()
reg := NewRegistry(root)
RegisterExecTools(reg, root, ExecConfig{Python: python, Env: []string{"PATH=/usr/bin:/bin"}})
res := runTool(t, reg, "run_python", `{"code":"print(2 + 3)\n"}`)
if res.IsError || !strings.Contains(res.Content, "5") {
t.Fatalf("python run failed: %+v", res)
}
if !strings.Contains(res.Content, "script: .agent/scripts/") {
t.Fatalf("script path must be reported, got:\n%s", res.Content)
}
matches, _ := filepath.Glob(filepath.Join(root, ".agent", "scripts", "*.py"))
if len(matches) != 1 {
t.Fatalf("script must be persisted exactly once, got %v", matches)
}
// 脚本内容落盘可审计。
data, _ := os.ReadFile(matches[0])
if !strings.Contains(string(data), "print(2 + 3)") {
t.Fatalf("persisted script content wrong: %q", data)
}
}
func TestRegisterExecToolsSkipsMissingInterpreters(t *testing.T) {
reg := NewRegistry(t.TempDir())
RegisterExecTools(reg, t.TempDir(), ExecConfig{}) // 无解释器:两个工具都不注册
names := make(map[string]bool)
for _, d := range reg.Decls() {
names[d.Name] = true
}
if names["run_command"] || names["run_python"] {
t.Fatalf("no interpreter configured, exec tools must not register, got %v", names)
}
}
+225
View File
@@ -0,0 +1,225 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"html"
"io"
"net"
"net/http"
"net/url"
"regexp"
"strings"
"time"
)
// FetchConfig 声明 fetch_url 的运行条件;未注册(组合根未提供)则工具不存在。
type FetchConfig struct {
// Client 可选注入(测试替身);缺省构造 20s 超时客户端。
Client *http.Client
// MaxBytes 响应体上限(默认 2 MiB)。
MaxBytes int
// AllowedHosts 非空时仅允许这些主机(域名或 host:port);空 = 公网放开,
// 但私网/回环/链路本地地址在任何模式下都被拒绝(SSRF 硬边界)。
AllowedHosts []string
// Resolve 可选注入主机解析(测试替身);缺省 net.LookupIP。
Resolve func(host string) ([]net.IP, error)
}
const (
fetchTimeout = 20 * time.Second
fetchMaxBytes = 2 << 20
)
func (c *FetchConfig) resolver() func(string) ([]net.IP, error) {
if c.Resolve != nil {
return c.Resolve
}
return net.LookupIP
}
// RegisterFetch 注册 fetch_url 工具(cfg 必须非 nil,由组合根显式提供)。
func RegisterFetch(r *Registry, cfg FetchConfig) {
r.Register(&fetchTool{cfg: cfg})
}
type fetchTool struct {
cfg FetchConfig
}
func (t *fetchTool) Name() string { return "fetch_url" }
func (t *fetchTool) Description() string {
return "Fetch an http(s) URL and return readable text (HTML stripped, up to 2 MiB, 20s timeout). Private/loopback network targets are always rejected."
}
func (t *fetchTool) Parameters() json.RawMessage {
return json.RawMessage(`{"type":"object","required":["url"],"properties":{"url":{"type":"string"}}}`)
}
func (t *fetchTool) Execute(ctx context.Context, args json.RawMessage) (Result, error) {
var obj struct {
URL string `json:"url"`
}
if err := json.Unmarshal(args, &obj); err != nil {
return Result{}, err
}
raw := strings.TrimSpace(obj.URL)
if raw == "" {
return Result{Content: "url is empty", IsError: true}, nil
}
client := t.cfg.Client
if client == nil {
client = &http.Client{Timeout: fetchTimeout}
}
allowed := make(map[string]bool, len(t.cfg.AllowedHosts))
for _, h := range t.cfg.AllowedHosts {
allowed[strings.ToLower(h)] = true
}
maxBytes := t.cfg.MaxBytes
if maxBytes <= 0 {
maxBytes = fetchMaxBytes
}
reqCtx, cancel := context.WithTimeout(ctx, fetchTimeout)
defer cancel()
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, raw, nil)
if err != nil {
return Result{Content: err.Error(), IsError: true}, nil
}
req.Header.Set("User-Agent", "agent-native-fetch/1.0")
var finalURL string
resolve := t.cfg.resolver()
client2 := client
if client2.CheckRedirect == nil {
client2 = &http.Client{
Transport: client.Transport,
Jar: client.Jar,
Timeout: client.Timeout,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 5 {
return fmt.Errorf("stopped after 5 redirects")
}
return validateFetchTarget(req.URL, allowed, resolve)
},
}
}
if err := validateFetchTarget(req.URL, allowed, resolve); err != nil {
return Result{Content: err.Error(), IsError: true}, nil
}
resp, err := client2.Do(req)
if err != nil {
return Result{Content: err.Error(), IsError: true}, nil
}
defer resp.Body.Close()
finalURL = resp.Request.URL.String()
body, err := io.ReadAll(io.LimitReader(resp.Body, int64(maxBytes)+1))
if err != nil {
return Result{Content: err.Error(), IsError: true}, nil
}
if len(body) > maxBytes {
body = body[:maxBytes]
}
ct := resp.Header.Get("Content-Type")
text, ok := fetchReadableText(ct, string(body))
if !ok {
return Result{Content: fmt.Sprintf("unsupported content type %q at %s (only text/html/json/xml/yaml/csv are extracted)", ct, finalURL), IsError: true}, nil
}
return Result{Content: "url: " + finalURL + "\ncontent-type: " + ct + "\n\n" + text}, nil
}
// validateFetchTarget 校验 scheme、主机策略与 SSRF 硬边界(私网/回环/链路本地)。
func validateFetchTarget(u *url.URL, allowed map[string]bool, resolve func(string) ([]net.IP, error)) error {
if u.Scheme != "http" && u.Scheme != "https" {
return fmt.Errorf("scheme %q not allowed (http/https only)", u.Scheme)
}
host := u.Hostname()
if host == "" {
return fmt.Errorf("empty host")
}
if len(allowed) > 0 && !allowed[strings.ToLower(host)] {
return fmt.Errorf("host %q not in allowed hosts", host)
}
// 解析全部地址:任一命中私网段即拒绝(域名可能同时解析到公私网地址)。
addrs, err := resolve(host)
if err != nil {
return fmt.Errorf("resolve %q: %w", host, err)
}
for _, ip := range addrs {
if ipIsPrivate(ip) {
return fmt.Errorf("target %q resolves to private/loopback address %s (SSRF guard)", host, ip)
}
}
return nil
}
var fetchPrivateNets = mustParseCIDRs(
"0.0.0.0/8", "10.0.0.0/8", "127.0.0.0/8", "169.254.0.0/16",
"172.16.0.0/12", "192.168.0.0/16", "100.64.0.0/10",
"::1/128", "fc00::/7", "fe80::/10",
)
func mustParseCIDRs(specs ...string) []*net.IPNet {
var out []*net.IPNet
for _, s := range specs {
_, n, err := net.ParseCIDR(s)
if err != nil {
panic("fetch: bad cidr " + s)
}
out = append(out, n)
}
return out
}
func ipIsPrivate(ip net.IP) bool {
// IPv4(含 Go 内部的 ::ffff:a.b.c.d 映射形式)先归一化成 4 字节,
// 否则 ::ffff:0:0/96 这类映射网段会误吞所有 IPv4 地址。
if v4 := ip.To4(); v4 != nil {
ip = v4
}
if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsUnspecified() {
return true
}
for _, n := range fetchPrivateNets {
if n.Contains(ip) {
return true
}
}
return false
}
// fetchReadableText 按内容类型抽取可读文本;HTML 剥脚本样式与标签。
func fetchReadableText(contentType, body string) (string, bool) {
ct := strings.ToLower(strings.TrimSpace(strings.Split(contentType, ";")[0]))
if ct == "" {
ct = "text/plain" // 无声明按纯文本处理
}
switch {
case ct == "text/html":
return htmlToText(body), true
case strings.HasPrefix(ct, "text/"),
ct == "application/json",
ct == "application/xml",
strings.HasSuffix(ct, "+json"),
strings.HasSuffix(ct, "+xml"),
ct == "application/yaml" || ct == "text/yaml",
ct == "application/csv" || ct == "text/csv":
return body, true
default:
return "", false
}
}
var (
fetchScriptRe = regexp.MustCompile(`(?is)<(script|style|noscript)[^>]*>.*?</(script|style|noscript)>`)
fetchTagRe = regexp.MustCompile(`(?s)<[^>]*>`)
fetchSpaceRe = regexp.MustCompile(`\n{3,}`)
)
func htmlToText(body string) string {
s := fetchScriptRe.ReplaceAllString(body, "")
s = fetchTagRe.ReplaceAllString(s, "\n")
s = html.UnescapeString(s)
s = strings.ReplaceAll(s, "\r", "")
s = fetchSpaceRe.ReplaceAllString(s, "\n\n")
return strings.TrimSpace(s)
}
+154
View File
@@ -0,0 +1,154 @@
package tools
import (
"net"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// fakeResolve 固定域名 → 地址(无 DNS 依赖的封闭测试)。
func fakeResolve(mapping map[string][]net.IP) func(string) ([]net.IP, error) {
return func(host string) ([]net.IP, error) {
if ips, ok := mapping[host]; ok {
return ips, nil
}
// IP 字面量直接解析。
if ip := net.ParseIP(host); ip != nil {
return []net.IP{ip}, nil
}
return nil, &net.DNSError{Err: "no such host", Name: host}
}
}
func fetchReg(cfg FetchConfig) *Registry {
reg := NewRegistry("/")
RegisterFetch(reg, cfg)
return reg
}
func TestFetchReadableTextUnit(t *testing.T) {
out, ok := fetchReadableText("text/html; charset=utf-8", "<html><script>bad()</script><h1>Hi</h1><p>A &amp; B</p></html>")
if !ok || !strings.Contains(out, "Hi") || !strings.Contains(out, "A & B") || strings.Contains(out, "bad()") {
t.Fatalf("html strip wrong: %q ok=%v", out, ok)
}
out, ok = fetchReadableText("application/json", `{"x":1}`)
if !ok || out != `{"x":1}` {
t.Fatalf("json passthrough wrong: %q", out)
}
_, ok = fetchReadableText("image/png", "binary")
if ok {
t.Fatal("binary content type must be rejected")
}
out, ok = fetchReadableText("", "no content type")
if !ok || out != "no content type" {
t.Fatalf("empty content type should default to text, got %q %v", out, ok)
}
}
func TestFetchURLSSRDGuards(t *testing.T) {
reg := fetchReg(FetchConfig{Resolve: fakeResolve(nil)})
for _, target := range []string{
`{"url":"http://127.0.0.1/x"}`,
`{"url":"http://10.0.0.5/x"}`,
`{"url":"http://192.168.1.1/x"}`,
`{"url":"http://169.254.169.254/latest/meta-data"}`,
`{"url":"http://[::1]/x"}`,
`{"url":"file:///etc/passwd"}`,
} {
res := runTool(t, reg, "fetch_url", target)
if !res.IsError {
t.Fatalf("SSRF/scheme guard must reject %s, got %+v", target, res)
}
}
// 域名解析到私网同样拒绝。
reg2 := fetchReg(FetchConfig{Resolve: fakeResolve(map[string][]net.IP{
"internal.example": {net.ParseIP("192.168.0.10")},
})})
res := runTool(t, reg2, "fetch_url", `{"url":"http://internal.example/x"}`)
if !res.IsError || !strings.Contains(res.Content, "SSRF guard") {
t.Fatalf("private resolution must be rejected, got %+v", res)
}
}
func TestFetchURLAllowedHostsPolicy(t *testing.T) {
reg := fetchReg(FetchConfig{
AllowedHosts: []string{"api.example.com"},
Resolve: fakeResolve(map[string][]net.IP{"api.example.com": {net.ParseIP("93.184.216.34")}}),
})
res := runTool(t, reg, "fetch_url", `{"url":"http://other.example.com/x"}`)
if !res.IsError || !strings.Contains(res.Content, "not in allowed hosts") {
t.Fatalf("host policy must reject, got %+v", res)
}
// 允许的主机:校验必须放行(真实网络下可能成功取回,也可能因出口
// 受限连接失败——两者都不算策略拦截)。
res = runTool(t, reg, "fetch_url", `{"url":"http://api.example.com/x"}`)
if strings.Contains(res.Content, "not in allowed") || strings.Contains(res.Content, "SSRF") {
t.Fatalf("allowed host should pass validation, got %+v", res)
}
}
func TestFetchURLEndToEndWithStubTransport(t *testing.T) {
var handler http.HandlerFunc = func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
_, _ = w.Write([]byte("fetched body"))
}
reg := fetchReg(FetchConfig{
Client: &http.Client{Transport: stubTransport{handler: handler}},
Resolve: fakeResolve(map[string][]net.IP{"ok.example": {net.ParseIP("93.184.216.34")}}),
})
res := runTool(t, reg, "fetch_url", `{"url":"http://ok.example/x"}`)
if res.IsError {
t.Fatal(res.Content)
}
if !strings.Contains(res.Content, "fetched body") || !strings.Contains(res.Content, "url: http://ok.example/x") {
t.Fatalf("stub fetch wrong: %+v", res)
}
}
type stubTransport struct{ handler http.HandlerFunc }
func (s stubTransport) RoundTrip(req *http.Request) (*http.Response, error) {
rec := httptest.NewRecorder()
s.handler(rec, req)
resp := rec.Result()
resp.Request = req
return resp, nil
}
func TestFetchURLRedirectToPrivateRejected(t *testing.T) {
reg := fetchReg(FetchConfig{
Client: &http.Client{Transport: redirectTransport{to: "http://127.0.0.1/evil"}},
Resolve: fakeResolve(map[string][]net.IP{
"public.example": {net.ParseIP("93.184.216.34")},
"127.0.0.1": {net.ParseIP("127.0.0.1")},
}),
})
res := runTool(t, reg, "fetch_url", `{"url":"http://public.example/redirect"}`)
if !res.IsError {
t.Fatalf("redirect to private target must be rejected, got %+v", res)
}
}
type redirectTransport struct{ to string }
func (s redirectTransport) RoundTrip(req *http.Request) (*http.Response, error) {
rec := httptest.NewRecorder()
rec.Header().Set("Location", s.to)
rec.Code = http.StatusFound
resp := rec.Result()
resp.Request = req
return resp, nil
}
func TestFetchNotRegisteredByDefault(t *testing.T) {
reg := WorkspaceTools(t.TempDir())
for _, d := range reg.Decls() {
if d.Name == "fetch_url" {
t.Fatal("fetch_url must not register without explicit FetchConfig")
}
}
}
+169
View File
@@ -0,0 +1,169 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
)
const maxGlobResults = 500
type globTool struct{ root string }
func (t *globTool) Name() string { return "glob" }
func (t *globTool) Description() string {
return "Find workspace files matching a glob pattern (** crosses directories, * within one segment). Returns up to 500 relative paths."
}
func (t *globTool) Parameters() json.RawMessage {
return json.RawMessage(`{"type":"object","required":["pattern"],"properties":{"pattern":{"type":"string","description":"glob such as **/*.go or docs/*.md"}}}`)
}
func (t *globTool) Execute(_ context.Context, args json.RawMessage) (Result, error) {
rel, err := readArg(args, "pattern")
if err != nil {
return Result{}, err
}
if strings.TrimSpace(rel) == "" {
return Result{Content: "pattern is empty", IsError: true}, nil
}
re, err := globRegexp(rel)
if err != nil {
return Result{Content: err.Error(), IsError: true}, nil
}
var matches []string
capped := false
err = filepath.WalkDir(t.root, func(path string, d os.DirEntry, walkErr error) error {
if walkErr != nil {
return nil
}
r, rerr := filepath.Rel(t.root, path)
if rerr != nil {
return nil
}
r = filepath.ToSlash(r)
if r == "." {
return nil
}
if d.IsDir() {
// 目录命中以 / 结尾,供模型分辨文件与目录。
if re.MatchString(r + "/") {
if len(matches) < maxGlobResults {
matches = append(matches, r+"/")
} else {
capped = true
}
}
return nil
}
if re.MatchString(r) {
if len(matches) < maxGlobResults {
matches = append(matches, r)
} else {
capped = true
}
}
return nil
})
if err != nil {
return Result{Content: err.Error(), IsError: true}, nil
}
if len(matches) == 0 {
return Result{Content: "no matches"}, nil
}
sort.Strings(matches)
out := strings.Join(matches, "\n")
if capped {
out += fmt.Sprintf("\n(showing first %d matches)", maxGlobResults)
}
return Result{Content: out}, nil
}
// globRegexp 把受限 glob(** 跨目录、* 单段、? 单字符,其余按字面)编译为正则。
// 尾段单独的 ** 匹配其下全部内容(文件与目录);中段的 ** 匹配零或多层目录。
// 不支持字符类与分组,避免与正则语义混淆。
func globRegexp(pattern string) (*regexp.Regexp, error) {
var b strings.Builder
b.WriteString("^")
segs := strings.Split(filepath.ToSlash(pattern), "/")
prevStarStar := false
for i, seg := range segs {
// 前一段是独立的 **(非尾段)时,其 (?:[^/]+/)* 已含目录斜杠,
// 零层目录的场景不需要再补一个分隔符。
if i > 0 && !prevStarStar {
b.WriteString("/")
}
prevStarStar = false
if seg == "**" {
if i == len(segs)-1 {
// 尾段 **:匹配其余全部路径(文件与目录)。
b.WriteString(".+")
} else {
b.WriteString("(?:[^/]+/)*")
prevStarStar = true
}
continue
}
for _, part := range splitDoubleStar(seg) {
if part == "**" {
b.WriteString("[^/]*")
continue
}
for _, ch := range part {
switch ch {
case '*':
b.WriteString("[^/]*")
case '?':
b.WriteString("[^/]")
default:
b.WriteString(regexp.QuoteMeta(string(ch)))
}
}
}
}
b.WriteString("$")
return regexp.Compile(b.String())
}
// splitDoubleStar 把段内混用的 ** 与字面部分切开(如 "a**b")。
func splitDoubleStar(seg string) []string {
var out []string
cur := strings.Builder{}
for i := 0; i < len(seg); i++ {
if i+1 < len(seg) && seg[i] == '*' && seg[i+1] == '*' {
if cur.Len() > 0 {
out = append(out, cur.String())
cur.Reset()
}
out = append(out, "**")
i++
continue
}
cur.WriteByte(seg[i])
}
if cur.Len() > 0 {
out = append(out, cur.String())
}
return out
}
// globMatcher 是文件名级 glob(filepath.Match 语义),供 grep include 过滤用。
type globMatcher struct{ pattern string }
func newGlobMatcher(pattern string) (*globMatcher, error) {
if _, err := filepath.Match(pattern, "probe"); err != nil {
return nil, fmt.Errorf("invalid include pattern %q: %w", pattern, err)
}
return &globMatcher{pattern: pattern}, nil
}
func (m *globMatcher) Match(name string) bool {
ok, _ := filepath.Match(m.pattern, name)
return ok
}
+76
View File
@@ -45,6 +45,11 @@ func NewRegistry(root string) *Registry {
func (r *Registry) Root() string { return r.root }
func (r *Registry) Has(name string) bool {
_, ok := r.tools[name]
return ok
}
func (r *Registry) Register(t Tool) {
if _, ok := r.tools[t.Name()]; !ok {
r.order = append(r.order, t.Name())
@@ -61,6 +66,75 @@ func (r *Registry) Decls() []Decl {
return out
}
// Restrict 把注册表收窄到 allow 列出的工具(工具策略的唯一执行点之一:
// 调用方声明 allowlist,注册表执行收窄)。条目支持两种形式:
//
// 精确名(read_file)或命名空间通配(mcp__github__*,匹配该前缀全部工具)。
//
// 任一条目未命中任何已注册工具时返回错误(fail-closed,不静默忽略配置错误)。
// 空列表不收窄;可重复调用(在已收窄集合上继续求交)。
func (r *Registry) Restrict(allow []string) error {
if len(allow) == 0 {
return nil
}
exact := make(map[string]bool, len(allow))
prefixes := make([]string, 0)
for _, entry := range allow {
if name, ok := strings.CutSuffix(entry, "*"); ok {
prefixes = append(prefixes, name)
continue
}
exact[entry] = true
}
var unknown []string
for _, name := range allow {
if _, ok := strings.CutSuffix(name, "*"); ok {
continue
}
if _, ok := r.tools[name]; !ok {
unknown = append(unknown, name)
}
}
for _, p := range prefixes {
if !r.hasPrefix(p) {
unknown = append(unknown, p+"*")
}
}
if len(unknown) > 0 {
return fmt.Errorf("tool allowlist names not registered: %s", strings.Join(unknown, ", "))
}
keep := func(name string) bool {
if exact[name] {
return true
}
for _, p := range prefixes {
if strings.HasPrefix(name, p) {
return true
}
}
return false
}
var order []string
for _, name := range r.order {
if keep(name) {
order = append(order, name)
} else {
delete(r.tools, name)
}
}
r.order = order
return nil
}
func (r *Registry) hasPrefix(prefix string) bool {
for _, name := range r.order {
if strings.HasPrefix(name, prefix) {
return true
}
}
return false
}
// Decl is the schema view of a tool.
type Decl struct {
Name string
@@ -182,8 +256,10 @@ func WorkspaceTools(root string) *Registry {
r.Register(&readFile{root: root})
r.Register(&writeFile{root: root})
r.Register(&editFile{root: root})
r.Register(&multiEditTool{root: root})
r.Register(&listDir{root: root})
r.Register(&grepTool{root: root})
r.Register(&globTool{root: root})
return r
}
+71
View File
@@ -0,0 +1,71 @@
package tools
import (
"context"
"strings"
"testing"
)
func TestRegistryRestrictNarrowsDeclsAndExecute(t *testing.T) {
dir := t.TempDir()
reg := WorkspaceTools(dir)
if err := reg.Restrict([]string{"read_file", "list_dir"}); err != nil {
t.Fatal(err)
}
names := make(map[string]bool)
for _, d := range reg.Decls() {
names[d.Name] = true
}
if len(names) != 2 || !names["read_file"] || !names["list_dir"] {
t.Fatalf("want read_file+list_dir only, got %v", names)
}
// 被收窄掉的工具体现为 unknown tool。
res := reg.Execute(context.Background(), "write_file", []byte(`{"path":"a.md","content":"x"}`))
if !res.IsError || !strings.Contains(res.Content, "unknown tool") {
t.Fatalf("want unknown tool error, got %+v", res)
}
// 保留的工具可用。
res = reg.Execute(context.Background(), "list_dir", []byte(`{}`))
if res.IsError {
t.Fatalf("list_dir should still work: %+v", res)
}
}
func TestRegistryRestrictFailsClosedOnUnknownNames(t *testing.T) {
reg := WorkspaceTools(t.TempDir())
err := reg.Restrict([]string{"read_file", "not_a_tool"})
if err == nil || !strings.Contains(err.Error(), "not_a_tool") {
t.Fatalf("want fail-closed error naming the unknown tool, got %v", err)
}
// 失败后注册表不被部分修改。
if len(reg.Decls()) != 7 {
t.Fatalf("registry should be untouched after failed restrict, got %d tools", len(reg.Decls()))
}
}
func TestRegistryRestrictEmptyKeepsAll(t *testing.T) {
reg := WorkspaceTools(t.TempDir())
if err := reg.Restrict(nil); err != nil {
t.Fatal(err)
}
if len(reg.Decls()) != 7 {
t.Fatalf("nil allowlist should keep all tools, got %d", len(reg.Decls()))
}
}
func TestRegistryRestrictIsIdempotentIntersection(t *testing.T) {
reg := WorkspaceTools(t.TempDir())
if err := reg.Restrict([]string{"read_file", "list_dir"}); err != nil {
t.Fatal(err)
}
// 二次收窄若点名已被移除的工具,属调用方配置错误:fail-closed。
if err := reg.Restrict([]string{"read_file", "write_file"}); err == nil || !strings.Contains(err.Error(), "write_file") {
t.Fatalf("second restrict naming a removed tool must fail-closed, got %v", err)
}
if err := reg.Restrict([]string{"read_file"}); err != nil {
t.Fatal(err)
}
if len(reg.Decls()) != 1 || reg.Decls()[0].Name != "read_file" {
t.Fatalf("want intersection to keep read_file only, got %+v", reg.Decls())
}
}
+22
View File
@@ -0,0 +1,22 @@
#!/bin/sh
# 核对本仓 gitlink 与已检出子模块 HEAD 是否一致。
# 嵌套副本是否与消费方自己的 deps 相同,由消费方仓库的同名脚本比较。
set -eu
cd "$(dirname "$0")/.."
fail=0
for path in deps/development-guidelines deps/error-code-system deps/logging-system deps/agent-intent; do
if [ ! -e "$path/.git" ]; then
echo "missing submodule checkout: $path" >&2
fail=1
continue
fi
recorded=$(git rev-parse ":$path")
actual=$(git -C "$path" rev-parse HEAD)
if [ "$recorded" != "$actual" ]; then
echo "gitlink mismatch: $path recorded=$recorded HEAD=$actual" >&2
fail=1
else
echo "ok $path $recorded"
fi
done
exit "$fail"