Compare commits

...
Author SHA1 Message Date
weichen cff69355f7 Carry tool-call identity and real outcome in process events
Why: F14 source half - exec.ProcessEvent lacked CallID and outcome
fields, forcing consumers to pair events by tool name and mark every
end ok; cancellations and failures displayed as success downstream.
Evidence: loop/adapter tests pass (verify-u11a.json); pairing, denial,
cancellation and concurrent-callback cases added.
Impact: new omitempty fields only; legacy events unchanged.
Refs: feature/agent-stack-audit-remediation-b2 U11a
2026-10-22 01:52:12 +00:00
weichen ac91b8436e Pin the nested agent-intent to the U5 snapshot fix
Why: composition consistency - Dev pins agent-intent at 765a7e7; the
native container's nested copy must match (check-pins).
Evidence: pin only; intent library verified by verify-u5.
Impact: nested gitlink pointer change.
Refs: feature/agent-stack-audit-remediation-b1
2026-10-21 17:44:55 +00:00
weichen 5c0d2d87dd Carry side-effect and cancellation semantics in exec results
Why: F05 prep - exec.Result could not distinguish canceled runs from
retryable failures or express side-effect uncertainty, so callers
inferred outcomes from error text.
Evidence: exec/adapters/runtime tests pass (verify-u3a.json);
canceled and side-effect population cases added.
Impact: zero-value results keep legacy meaning; no interface change.
Refs: feature/agent-stack-audit-remediation-b1 U3a
2026-10-21 16:15:03 +00:00
weichen afbb567301 Bind session scope identity, enforce write sets, repair torn JSONL
Why: F02/F04/F15 - contract requests shared one transcript across
session scopes, Writable was dropped, and a torn JSONL tail poisoned
the next append; pure-text contract results lost their text.
Evidence: adapters/native, runtime/session, runtime/tools and
conformance tests pass (verify-u2.json); scope-isolation, write-set and
torn-tail regression cases added.
Impact: empty scope keeps legacy one-shot semantics; identity-less old
transcripts are never auto-adopted by a new scope.
Refs: feature/agent-stack-audit-remediation-b1 U2
2026-10-21 16:03:04 +00:00
weichen 2fc66be652 Merge pull request 'Release tool calls on a verified route contract without [DONE]' (#48) from feature/fast-slow-o2-i1-route-contract into main
ci / docker-checkout-hostgateway (push) Successful in 11s
governance / governance (push) Successful in 53s
ci / vet-and-test (push) Successful in 1m23s
2026-10-20 05:54:30 +00:00
agent-exec-transport 899fd70d27 agent-exec transport e38bcf1cbe415c5d6a3e91773d811b0a/445c377b4484
ci / docker-checkout-hostgateway (pull_request) Successful in 12s
governance / governance (pull_request) Successful in 35s
ci / vet-and-test (pull_request) Successful in 1m24s
2026-10-20 04:03:18 +00:00
weichen 9c24e220dd Merge pull request 'Guard streamed tool terminals and retain Usage' (#46) from feature/fast-slow-u3-r04-stream-guard into main
ci / docker-checkout-hostgateway (push) Successful in 1s
governance / governance (push) Successful in 15s
ci / vet-and-test (push) Successful in 55s
2026-10-19 09:20:09 +00:00
agent-exec-transport 28f3ea7674 agent-exec transport d7776a79d43bd8fd44e6c0d78873761b/9585ecb5ab4b
governance / governance (pull_request) Successful in 17s
ci / docker-checkout-hostgateway (pull_request) Successful in 0s
ci / vet-and-test (pull_request) Successful in 54s
2026-10-19 03:50:46 +00:00
agent-exec-transport b6a843337c agent-exec transport 4838630332e6bb1220f0e63e947ddb81/599831d96200
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
governance / governance (pull_request) Successful in 18s
ci / vet-and-test (pull_request) Successful in 52s
2026-10-19 03:02:20 +00:00
agent-exec-transport 5b21325da7 agent-exec transport ed24e191762d6f6e3b41589f552e7744/cd1765c54231
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
governance / governance (pull_request) Successful in 14s
ci / vet-and-test (pull_request) Successful in 49s
2026-10-19 02:53:01 +00:00
weichen 42b22b436b Merge Agent-Native main into the U3 stream guard branch
ci / vet-and-test (pull_request) Successful in 52s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
governance / governance (pull_request) Successful in 16s
2026-10-19 02:46:12 +00:00
weichen 14d48697a6 Merge pull request 'Adopt the shared governance kit (development-governance-v2)' (#47) from feature/governance-adoption into main
ci / docker-checkout-hostgateway (push) Successful in 1s
governance / governance (push) Successful in 15s
ci / vet-and-test (push) Successful in 54s
2026-10-17 09:22:02 +00:00
weichen 73ae2bb616 Close and archive the governance adoption card
governance / governance (pull_request) Successful in 20s
ci / vet-and-test (pull_request) Successful in 56s
ci / docker-checkout-hostgateway (pull_request) Successful in 2s
Why: the adoption PR is reviewed and its candidate HEAD passed CI; the card closure is folded into the PR so no separate close PR is needed.
Evidence: CI GATE on 3cf8fa39b63197a98f54c18d24e8cb33b5d56908; independent Review REPLAY (cursor-agent-stable --mode ask 全新只读会话(fresh_session=true,session id not-observable)闭合重审 VERDICT: APPROVE(对象 3cf8fa3)).
Impact: task card only (moved to .task/archive/).
Refs: https://git-home.u1680832.nyat.app:30319/weichen/Agent-Native/pulls/47
2026-10-17 08:58:51 +00:00
weichen 3cf8fa39b6 docs(task): complete the card for review (sync, CI, results)
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
governance / governance (pull_request) Successful in 16s
ci / vet-and-test (pull_request) Successful in 52s
2026-10-17 08:53:09 +00:00
weichen 8d09aa0265 docs(task): record review repair run and evidence
ci / docker-checkout-hostgateway (pull_request) Successful in 2s
governance / governance (pull_request) Successful in 22s
ci / vet-and-test (pull_request) Successful in 58s
Why: independent Review blocked the adoption; the repair unit ran through the shared executor and was integrated with agent-exec apply.
Evidence: .task/evidence/governance-adoption/ (SELF): governance check and agent-exec validate on the repaired head.
Impact: task card and evidence logs only.
Refs: .task/feature-governance-adoption.md
2026-10-17 08:36:54 +00:00
agent-exec-transport a625686ea9 agent-exec transport 734e07415a1f8df7ee982853cb77d602/62c4e4ad578b 2026-10-17 08:34:39 +00:00
weichen c570f80e29 Record the adoption unit run and positive/negative evidence
ci / docker-checkout-hostgateway (pull_request) Successful in 2s
governance / governance (pull_request) Successful in 42s
ci / vet-and-test (pull_request) Successful in 1m17s
Why: Phase 6 requires one positive executor run and the negative cases (missing config, bad receipt, out-of-scope path, unknown side effect) before adoption becomes active.
Evidence: .task/evidence/governance-adoption/ (SELF): governance check, agent-exec validate and adoption cases; this commit passed the generated pre-commit hook (admit).
Impact: task card and evidence logs only.
Refs: .task/feature-governance-adoption.md
2026-10-17 07:38:23 +00:00
agent-exec-transport 9c7dc6bf4a agent-exec transport 18dcfcc1fbb42ee4e84c0a8ede513286/dfa357f74964 2026-10-17 07:21:05 +00:00
weichen b1034e13ea Bootstrap AGENT-EXECUTION.md and the governance adoption card
Why: development-governance-v2 Phase 6 adopts the shared governance kit in every formal repository; the executor needs a valid AGENT-EXECUTION.md before any implementation unit can run.
Evidence: generated by dm-server governance init at Development-Manager bd3a7de57a3a9e1a4495b94c5fa5c08d71e7be8c; dm-server agent-exec validate passes.
Impact: configuration and task card only; no code or CI change yet.
Refs: Agent-System-Dev docs/superpowers/plans/2026-10-16-development-governance-v2-master.md
2026-10-17 07:17:40 +00:00
weichen 6a1dcddb48 Record content-free stream terminal observations
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
ci / vet-and-test (pull_request) Successful in 52s
Why: The r0.4 trial could prove that the parser missed [DONE] but could not distinguish clean EOF, malformed frames, read failure, or provider error without retaining raw SSE.

Evidence: RED receipts fast-slow-u3-r05-task1-terminal-red.json and task1-http-error-red.json; GREEN targeted receipt and full vet/test/race receipt fast-slow-u3-r05-task1-agent-native-full.json.

Impact: Add bounded scalar diagnostics and successful HTTP status to terminal events while preserving the existing incomplete-tool rejection, returned Usage, and retry behavior.

Refs: U3 r0.5 approved design and plan; Agent-Native PR #46
2026-10-14 07:50:39 +00:00
weichen a5e3c7c817 Reject legacy empty function-call terminals
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
ci / vet-and-test (pull_request) Successful in 55s
Why: The parser also recognizes function_call as a tool finish, but cannot execute a legacy call when no deltas were collected. It must fail closed like tool_calls.

Evidence: The function_call variant failed before this guard and passed afterward; full Agent-Native vet/test passed.

Impact: Inconsistent legacy tool terminals fail with returned Usage preserved; valid streams are unchanged.

Refs: U3 r0.4 independent review Important 2 closure
2026-10-14 04:12:04 +00:00
weichen 6e972335c9 Reject empty tool-call stream terminals
ci / docker-checkout-hostgateway (pull_request) Successful in 0s
ci / vet-and-test (pull_request) Successful in 53s
Why: A provider can report finish_reason=tool_calls without emitting any tool call. Treating it as a successful empty answer hides an incomplete operation.

Evidence: New SSE regression failed before the guard and passed afterward; full go vet and go test -count=1 ./... passed.

Impact: Inconsistent tool terminals fail closed while preserving returned Usage; valid text and tool streams are unchanged.

Refs: U3 r0.4 independent review issue 2
2026-10-14 03:49:06 +00:00
weichen e86e74bc36 Preserve streamed tool delta identity
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
ci / vet-and-test (pull_request) Successful in 52s
Why: Backend emitted tool deltas from a separate goroutine, racing with serialized run events and occasionally losing a UI delta.

Evidence: Backend race detector reproduced concurrent event writes; Agent-Native identity test RED→GREEN; full vet and tests pass.

Impact: runtime tool delta events now carry provider slot and call ID for single-owner downstream projection.

Refs: runtime/loop.go; runtime/event_vocabulary_test.go
2026-10-14 02:22:34 +00:00
weichen 94afd71f00 Reject incomplete streamed tool calls
ci / docker-checkout-hostgateway (pull_request) Successful in 0s
ci / vet-and-test (pull_request) Successful in 51s
Why: a model tool finish could execute partial JSON or an unterminated SSE stream, while post-Usage errors could lose their charge.

Evidence: missing-DONE, malformed-JSON, identity, non-object and post-Usage RED→GREEN tests; GOWORK=off go vet ./... and go test -count=1 ./... passed.

Impact: invalid tool terminals fail without execution or automatic retry and retain returned Usage; valid text and multi-part tool streams keep working.

Refs: runtime/llm/sse.go; runtime/loop.go
2026-10-14 02:09:44 +00:00
weichen c348441691 Merge pull request 'Run serial-flagged tool calls in order while others fan out' (#44) from feature/serial-tools-partition into main
ci / docker-checkout-hostgateway (push) Successful in 1s
ci / vet-and-test (push) Successful in 49s
2026-10-10 09:01:03 +00:00
weichen 7f60baeaf8 Run serial-flagged tool calls in order while others fan out
ci / vet-and-test (pull_request) Successful in 54s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: Mutual exclusion alone does not preserve order. With parallel
execution the host cannot keep order-sensitive tools (card streaming
begin/append/commit, plan updates) correct by locking a mutex, because
whichever goroutine grabs the lock first is not necessarily the first
declared call - a real failure observed in the backend card-stream test.

Evidence: go test ./... green; TestLoopSerialToolsKeepOrder asserts the
serial calls execute in declaration order (s1->s2->s3) while parallel
calls still fan out.

Impact: New Config.SerialTools set. In parallel mode, matching calls run
sequentially on the caller goroutine in model-declared order; others
stay concurrent. Empty set keeps the previous all-parallel behavior;
sequential mode is untouched.

Refs: Dev task feature/agent-e1-parallel-fixes (F5).
2026-10-10 08:58:31 +00:00
weichen ef2fa75006 Merge pull request 'Inject the active tool call into the Execute context' (#43) from feature/tool-call-context into main
ci / vet-and-test (push) Successful in 50s
ci / docker-checkout-hostgateway (push) Successful in 1s
2026-10-10 08:50:13 +00:00
weichen a5da18b710 Inject the active tool call into the Execute context
ci / vet-and-test (pull_request) Successful in 50s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: With parallel tool execution the host-side tool wrapper can no
longer infer which invocation it is serving from a single in-flight
slot; tools need the call identity (ID/name/arguments) to pair process
state and per-call bookkeeping. Context injection keeps the tools
registry signature unchanged.

Evidence: go test ./... green; TestLoopToolSeesOwnCallInContext runs
two parallel calls and asserts each reads its own ID from ctx.

Impact: Additive WithToolCall/ToolCallFromContext helpers; runOne now
wraps the ctx before Execute. Callers that never inject keep ok=false
and are unaffected.

Refs: Dev task feature/agent-e1-parallel-fixes (F5 parallel wiring).
2026-10-10 08:48:26 +00:00
weichen d6267fba15 Merge pull request 'Carry tool CallID in loop events and add reasoning_split to the chat client' (#42) from feature/e1-parallel-reasoning into main
ci / vet-and-test (push) Successful in 49s
ci / docker-checkout-hostgateway (push) Successful in 0s
2026-10-10 08:36:09 +00:00
weichen 1f25049492 Carry tool CallID in loop events and add reasoning_split to the chat client
ci / vet-and-test (pull_request) Successful in 50s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: Parallel tool execution makes same-turn tool events interleave, so
consumers can no longer pair starts and ends by order; the kernel Event
needs the call ID. MiniMax interleaved-thinking models stream their
reasoning inside content <think> blocks unless the OpenAI-compatible
request opts in with reasoning_split=true, which forces reasoning into
the dedicated reasoning_content field the SSE layer already captures.

Evidence: go test ./... green; new tests TestLoopToolEventsCarryCallID
and TestOpenAIChatBodyReasoningSplit pin both behaviors (regression
anchors for the backend parallel-work change).

Impact: Additive Event.CallID (empty for non-tool events; existing
consumers unaffected) and an opt-in Client.ReasoningSplit flag that only
adds a request field when set. No default behavior change.

Refs: Agent-System-Dev proposal 2026-10-10-agent-delegation-routing-sandbox.md
(F2/F5), Dev task feature/agent-e1-parallel-fixes.
2026-10-10 08:33:53 +00:00
weichen 76b6171de5 Merge pull request 'Capture OpenAI-compatible reasoning and replay it in assistant history' (#41) from feature/quality-p2-i2 into main
ci / vet-and-test (push) Successful in 49s
ci / docker-checkout-hostgateway (push) Successful in 1s
2026-10-09 16:07:23 +00:00
weichen 3b92cb63b4 Capture OpenAI-compatible reasoning and replay it in assistant history
ci / vet-and-test (pull_request) Successful in 51s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: Interleaved-thinking models (MiniMax, DeepSeek R1) require the
assistant's thinking to be passed back verbatim in message history; dropping
it degrades later turns. The product's P2-I1 replayed thinking only into the
request-side rebuild; with the session transcript becoming the authoritative
context (quality P2-I2), capture must live in the kernel so thinking persists
in the transcript and replays on every request.

Evidence: SELF - TestReasoningCaptureAndReplay covers capture (SSE
reasoning_content into Message.Thinking, content unpolluted) and replay
(<think> prefix on thinking assistants, untouched non-thinking messages);
go vet + go test ./... pass.

Impact: sse.go accumulates reasoning deltas into the terminal message's
Thinking; client.go toProviderMessages renders captured thinking as a
<think> block prefix for assistant messages. Anthropic path unchanged;
messages without thinking serialize exactly as before.
2026-10-09 15:28:56 +00:00
weichen 37c5e6fbd6 Merge pull request 'Carry output format and tool surface on delegation requests (Q3 WP3)' (#40) from feature/q3-delegation-template into main
ci / vet-and-test (push) Successful in 50s
ci / docker-checkout-hostgateway (push) Successful in 0s
Merge pull request 'Carry output format and tool surface on delegation requests (Q3 WP3)' (#40) from feature/q3-delegation-template into main
2026-10-08 03:29:17 +00:00
weichen cdbdeb0b14 Carry output format and tool surface on delegation requests (Q3 WP3)
ci / vet-and-test (pull_request) Successful in 51s
ci / docker-checkout-hostgateway (pull_request) Successful in 2s
Why: the delegation task brief must state goal, output format, tools and
boundaries (Q3 acceptance); Request had no carriers for the two new
sections, so delegate_task callers could not declare them.

Evidence: TestDelegateToolRoundTrip extended to assert output_format and
tools survive the tool-call round trip; runtime suite green (SELF); CI
authoritative (GATE).

Impact: additive Request fields + delegate_task schema/description; empty
fields keep today's behavior (task brief sections omitted).

Refs: Q3 plan WP3; Dev card .task/feature-agent-stack-q3.md
2026-10-08 03:27:09 +00:00
weichen 1ac21c68d4 Merge pull request 'Thread last-request and cache usage fields through the exec event bridge (N3 WP6)' (#39) from feature/n3-exec-usage-fields-r1 into main
ci / vet-and-test (push) Successful in 50s
ci / docker-checkout-hostgateway (push) Successful in 1s
Thread last-request and cache usage fields through the exec event bridge (N3 WP6)
2026-10-07 17:12:08 +00:00
weichen ea2689641f Thread last-request and cache usage fields through the exec event bridge (N3 WP6)
ci / vet-and-test (pull_request) Successful in 51s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: squad consumes exec.ProcessEvent, which lacked the split usage
fields, so the WP6 accounting could not reach squad runs.
Evidence: GOWORK=off go vet green; additive fields on ProcessEvent.
Impact: additive only.
Refs: Agent-Native#35 #37, N3 plan WP6
2026-10-07 17:10:53 +00:00
weichen 2eccf3f6f5 Merge pull request 'Carry cache tokens on message_end events (N3 WP6 follow-up)' (#37) from feature/n3-usage-retry-wp6-followup into main
ci / vet-and-test (push) Successful in 51s
ci / docker-checkout-hostgateway (push) Successful in 0s
Carry cache tokens on message_end events (N3 WP6 follow-up)
2026-10-07 17:04:21 +00:00
weichen 28e237f646 Carry cache tokens on message_end events (N3 WP6 follow-up)
ci / vet-and-test (pull_request) Successful in 50s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: hosts accumulating per message_end could not see cache hits; the
fields now ride the same event as the per-request totals.
Evidence: GOWORK=off go test green.
Impact: additive event fields.
Refs: Agent-Native#35
2026-10-07 17:03:10 +00:00
weichen 16fdad92c4 Merge pull request 'Split last-request/cache usage, emit terminal usage, and retry stream errors by default (N3 WP6)' (#35) from feature/n3-usage-retry-wp6 into main
ci / vet-and-test (push) Successful in 48s
ci / docker-checkout-hostgateway (push) Successful in 1s
Split last-request/cache usage, emit terminal usage, and retry stream errors by default (N3 WP6)
2026-10-07 16:32:53 +00:00
weichen 87ff3946df Split last-request/cache usage, emit terminal usage, and retry stream errors by default (N3 WP6)
ci / vet-and-test (pull_request) Successful in 49s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: cache tokens were parsed but never accumulated, the usage event
carried only cumulative totals and was skipped on terminal paths
(audit N20 residual), and stream retry was host-opt-in so subagents,
the verifier, and squad runs were never retried.
Evidence: GOWORK=off go vet + go test -count=1 ./... green;
TestUsageEventSplitsLastAndCache (event carries Last*/cache),
TestUsageAccumulatesCacheTokens, TestBudgetStopEmitsTerminalUsage,
TestDefaultRetryAttemptsTwo (0 value = 2 retries with
auto_retry_start events), TestNegativeRetryAttemptsDisables (negative
= off), TestRetryNeverDuplicatesForwardedDeltas (the only-before-first-delta
invariant holds under the library default).
Impact: usage events now carry Last* and cache fields and are emitted
on budget/error terminal paths too; Config.RetryAttempts defaults to
2 (negative disables) so every host gets the existing retry semantics
without wrapping; default (unset) host wrappers should be retired to
avoid double retries.
Refs: Agent-System-Documents#43 (N3 plan WP6), audit N20
2026-10-07 16:31:21 +00:00
weichen 0e03cc2144 Merge pull request 'Add the memory port with loop recall injection and the memory_write tool (N3 WP5)' (#34) from feature/n3-memory-wp5 into main
ci / vet-and-test (push) Successful in 49s
ci / docker-checkout-hostgateway (push) Successful in 0s
Add the memory port with loop recall injection and the memory_write tool (N3 WP5)
2026-10-07 16:22:14 +00:00
weichen bdb45c3c2c Add the memory port with loop recall injection and the memory_write tool (N3 WP5)
ci / vet-and-test (pull_request) Successful in 49s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: the completion doc assigns the Memory port to the library; today
memory recall is a product-side interface assertion with no library
surface, AgentSpec.MemoryScope has no consumer, and the model has no
write-back path.
Evidence: GOWORK=off go vet + go test -count=1 ./... green;
TestLoopInjectsMemoryAndRegistersWrite (recall block lands after the
system section, memory_write executes through the loop),
TestEmptyRecallInjectsNothing (empty recall changes nothing),
TestBlockRenderOrderingAndCaps (deterministic order, expiry/empty
pruning), TestWriteToolValidation (fail-closed scope/type/content).
Impact: new opt-in Config.Memory/MemoryScope; nil port keeps behavior
byte-identical; injection is request-scoped (never persisted, never
accumulates) and deterministic ([记忆] text shape preserved for the
product migration).
Refs: Agent-System-Documents#43 (N3 plan WP5)
2026-10-07 16:05:36 +00:00
weichen f04c87ac31 Merge pull request 'Close the checkpoint gaps: pinned eligibility, symlink guard, partial restore, retention (N3 WP3)' (#33) from feature/n3-checkpoint-wp3 into main
ci / vet-and-test (push) Successful in 51s
ci / docker-checkout-hostgateway (push) Successful in 0s
Close the checkpoint gaps: pinned eligibility, symlink guard, partial restore, retention (N3 WP3)
2026-10-07 15:51:22 +00:00
weichen f2fbefa2b9 Make the partial-restore failure test root-proof via a test seam
ci / vet-and-test (pull_request) Successful in 53s
ci / docker-checkout-hostgateway (pull_request) Successful in 0s
Why: CI runs as root, where chmod-based failure injection does not
fail (root bypasses file permissions), and any filesystem state that
would fail restore is pre-detected by the classification pass. A
package-level test seam makes the injection deterministic on any uid.
Evidence: GOWORK=off go test (-race) green locally.
Impact: test-only seam, documented as never-for-production.
Refs: Agent-Native#33
2026-10-07 15:50:04 +00:00
weichen e8f4e097a7 Close the checkpoint gaps: pinned eligibility, symlink guard, partial restore, retention (N3 WP3)
ci / vet-and-test (pull_request) Failing after 26s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: rollback eligibility was implicit (any struct with a mark field),
restore followed symlinks out of the root, a single failing file
aborted the rewind leaving unreported partial state, and the log grew
unboundedly with an O(N) read per record.
Evidence: GOWORK=off go vet + go test -count=1 ./... green;
TestCheckpointedToolSetIsPinned (eligibility is exactly
{write_file, edit_file, multi_edit}), TestRewindSkipsSymlinkTarget
(link and outside target untouched, classified Skipped),
TestRewindPartialFailureContinues (Failed reported, rest restored),
TestCompactKeepsRecentAnchors (original seqs kept, monotonic after),
plus pinned edges for empty ordered, identical-rewrite=Already, and
per-record roots; all pre-existing rewind tests pass unchanged.
Impact: Result gains Failed (additive); Rewind no longer aborts on a
single file; symlinks classify as external changes; Compact bounds the
log; Seq is computed from a cached counter instead of a full read.
Refs: Agent-System-Documents#43 (N3 plan WP3)
2026-10-07 15:47:24 +00:00
weichen fe0c4a9f82 Merge pull request 'Align spans with the OTel GenAI convention (N3 WP4)' (#32) from feature/n3-otel-wp4 into main
ci / vet-and-test (push) Successful in 53s
ci / docker-checkout-hostgateway (push) Successful in 0s
Align spans with the OTel GenAI convention (N3 WP4)
2026-10-07 15:38:07 +00:00
weichen fc0558fc93 Align spans with the OTel GenAI convention (N3 WP4)
ci / vet-and-test (pull_request) Successful in 51s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: acceptance clause 3 requires span names and attributes to follow
the convention; today names were bare words with two ad-hoc attribute
keys, and every span got a fresh random trace with no parentage.
Evidence: GOWORK=off go vet + go test -count=1 ./... green;
TestSpanNamesFollowGenAIConvention (name suffixes, gen_ai.* key enum,
intValue usage, service.name resource, prompt/reserved-key exclusion),
TestLoopSpansFollowGenAIConvention (invoke_agent root with shared
trace, chat/execute_tool children with parent links, usage attrs on
chat spans); the pre-existing prompt-body denylist test passes
unchanged.
Impact: span names become invoke_agent <agent> / chat <model> /
execute_tool <tool>; attributes move to gen_ai.* with usage as
intValue; one Run shares one trace with invoke_agent as root. New
Config.AgentName/ProviderName (documented defaults keep old hosts
meaningful); telemetry StartFunc gains WithServiceName/WithEndpoint
options (defaults preserve current behavior).
Refs: Agent-System-Documents#43 (N3 plan WP4)
2026-10-07 15:36:35 +00:00
weichen 79e1a8fa85 Merge pull request 'Add JournalOnly sessions and persist one-time history seeds (N3 WP2)' (#31) from feature/n3-journal-session-wp2 into main
ci / vet-and-test (push) Successful in 53s
ci / docker-checkout-hostgateway (push) Successful in 0s
Add JournalOnly sessions and persist one-time history seeds (N3 WP2)
2026-10-07 15:23:25 +00:00
weichen e2be33f008 Add JournalOnly sessions and persist one-time history seeds (N3 WP2)
ci / vet-and-test (pull_request) Successful in 50s
ci / docker-checkout-hostgateway (pull_request) Successful in 0s
Why: the product conversation path needs the intent journal and
recovery pairing, but making the transcript the authoritative context
would freeze the assembled system prefix (memory/plans/injections go
stale). JournalOnly keeps the session as journal-only: recovery runs
against it, the context always comes from History, and the recovered
outcomes are injected as a system note. Separately, the one-time
History seed was memory-only and silently lost from the second run on
(authority mode); it is now persisted except in JournalOnly mode.
Evidence: GOWORK=off go vet + go test -count=1 ./... green;
TestJournalOnlyRecoversWithoutTranscriptAuthority (journal gets the
replayed pair exactly once, history never enters the journal, the
recovery note precedes the new user message, seeds stay out);
persistedProjection honors JournalOnly; all pre-existing durable and
harness tests pass unchanged (default semantics untouched).
Impact: new opt-in Config.JournalOnly; default (false) behavior is
byte-identical.
Refs: Agent-System-Documents#43 (N3 plan WP2)
2026-10-07 15:21:48 +00:00
weichen cb21ec4e2a Merge pull request 'Repair orphan tool-call tails on resume and stop mutating memory on Stop.Replacement (N3 WP1)' (#30) from feature/n3-runstate-wp1 into main
ci / vet-and-test (push) Successful in 51s
ci / docker-checkout-hostgateway (push) Successful in 0s
Repair orphan tool-call tails on resume and stop mutating memory on Stop.Replacement (N3 WP1)
2026-10-07 15:03:53 +00:00
weichen 5abb231efe Repair orphan tool-call tails on resume and stop mutating memory on Stop.Replacement (N3 WP1)
ci / vet-and-test (pull_request) Successful in 50s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: a kill between persist(assistant) and commitToolIntent left an
unpaired tail that no recovery path repaired (acceptance clause 1's
library gap), and Stop.Replacement mutated the in-memory assistant
while the transcript kept the original (audit N6 residual).
Evidence: GOWORK=off go vet + go test -count=1 ./... green;
TestResumePairsOrphanTailAfterCrashWindow (no-intent window pairs with
crashInterrupted, run continues), TestResumeMixedIntentAndOrphanTail
(intent path and tail repair never double-pair),
TestRepairOrphanTailNoopOnHealthyTranscript; the harness assertion now
pins replacement-as-user-note (forged=4, notes=3).
Impact: every next Run repairs the trailing assistant's resultless
calls before the first request; replacement text now appears only as a
user message in both memory and transcript (intentional, recorded in
the N3 card).
Refs: Agent-System-Documents#43 (N3 plan WP1)
2026-10-07 15:02:16 +00:00
weichen 74aa60bf2e Merge pull request 'Add the Background field to subagent requests (N2 WP5)' (#29) from feature/n2-subagent-background into main
ci / vet-and-test (push) Successful in 53s
ci / docker-checkout-hostgateway (push) Successful in 0s
Add the Background field to subagent requests (N2 WP5)
2026-10-07 14:11:08 +00:00
weichen b179452793 Add the Background field to subagent requests (N2 WP5)
ci / vet-and-test (pull_request) Successful in 57s
ci / docker-checkout-hostgateway (pull_request) Successful in 0s
Why: the host factory decides sync vs background spawning; the request
must carry the declaration (AgentSpec.Background consumption point,
mirroring the product delegate tool's background parameter).
Evidence: GOWORK=off go vet + go test green; the delegate_task schema
gains the background property.
Impact: none for hosts that do not set the field.
Refs: Agent-System-Documents#42 (N2 plan WP5)
2026-10-07 14:09:46 +00:00
weichen 7425b32b9e Merge pull request 'Validate skills against the open Agent Skills spec (N2 WP4)' (#28) from feature/n2-skills-wp4 into main
ci / vet-and-test (push) Successful in 52s
ci / docker-checkout-hostgateway (push) Successful in 0s
Validate skills against the open Agent Skills spec (N2 WP4)
2026-10-07 13:52:49 +00:00
weichen e1fac5864d Validate skills against the open Agent Skills spec (N2 WP4)
ci / vet-and-test (pull_request) Successful in 1m1s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: the completion doc's N2 requires skills validated per the open
spec; today frontmatter was a description-only line hack with silent
skips, no name field, and no error surface.
Evidence: GOWORK=off go vet + go test -count=1 ./... green; parser
units cover all field forms and reject malformed shapes with located
errors; the validation matrix covers kebab-case/name-equals-dir/
required description/limits; Store.Diagnostics surfaces per-skill
reasons and Load returns the located validation error for same-named
invalid skills; existing fixtures updated to spec form.
Impact: stricter by design — skills without name/description or with
unknown frontmatter keys now produce located errors instead of silently
loading or silently skipping; ValidateSkillDocument is exported so the
product plugin/seed paths share the same validator.
Refs: Agent-System-Documents#42 (N2 plan WP4)
2026-10-07 13:51:10 +00:00
weichen 77e7f97827 Merge pull request 'Align the MCP client with 2026-07-28: resultType, input_required retry, tasks surface (N2 WP3)' (#27) from feature/n2-mcp-wp3 into main
ci / vet-and-test (push) Successful in 52s
ci / docker-checkout-hostgateway (push) Successful in 1s
Align the MCP client with 2026-07-28 (N2 WP3)
2026-10-07 13:40:14 +00:00
weichen fcd2539b9e Align the MCP client with 2026-07-28: resultType, input_required retry, tasks surface (N2 WP3)
ci / vet-and-test (pull_request) Successful in 52s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: the completion doc requires the client to recognize resultType,
treat input_required as a protocol-level result whose answer retries the
original tools/call, and expose tasks/get|update|cancel; today only a
content-block special case existed and the advertised version was never
checked against the server's.
Evidence: GOWORK=off go vet + go test -count=1 ./... green;
TestInputRequiredResultTypeRetriesOriginalCall (ask → tasks/update →
retry, call order and final content), TestNegotiatedVersionFallbackKeepsLegacyPath
(byte-level legacy behavior on 2024-11-05), TestTasksGetAndCancelRoundTrip,
TestRunEndCancelsSeenTasks, TestDefaultSchemaDeclares202012; the
acceptance-③ regression TestStdioSlowCancelThenEcho passes unchanged.
Impact: advertise 2026-07-28 and branch on the negotiated version
(older servers keep byte-identical behavior); synthesized default tool
schemas now declare JSON Schema 2020-12; runs cancel the MCP tasks they
saw via a per-run TaskTracker (other runs on the shared manager are
unaffected).
Refs: Agent-System-Documents#42 (N2 plan WP3)
2026-10-07 13:38:51 +00:00
weichen d6053baf4c Merge pull request 'Add the native Anthropic Messages client with recorded fixtures (N2 WP2)' (#26) from feature/n2-anthropic-wp2 into main
ci / vet-and-test (push) Successful in 51s
ci / docker-checkout-hostgateway (push) Successful in 0s
Add the native Anthropic Messages client with recorded fixtures (N2 WP2)
2026-10-07 13:22:41 +00:00
weichen e63a9f38dd Add the native Anthropic Messages client with recorded fixtures (N2 WP2)
ci / vet-and-test (pull_request) Successful in 50s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: acceptance clause 1 requires the anthropic preset to complete a
tool-call round against recorded HTTP fixtures; audit N4's root cause
was an offline body builder the real client never used, with no
tool_use/thinking support on the wire.
Evidence: GOWORK=off go vet + go test -count=1 ./... green;
TestAnthropicPresetToolRound drives the preset through tools request →
tool_use → tool_result → final text with thinking-signature round-trip
against byte-recorded SSE fixtures; TestAnthropicBodyShape pins block
forms and cache_control; legacy OpenAI path untouched (Client.Protocol
zero value).
Impact: the anthropic preset now means the native Messages API (the
compat shim remains reachable via models.json custom providers); audit
N4 closes. BuildBody shares anthropicBody with the live client.
Refs: Agent-System-Documents#42 (N2 plan WP2), audit N4
2026-10-07 13:21:18 +00:00
weichen 64b8bd4c98 Merge pull request 'Spill cleared tool results to disk and pin the cache-stability contract (N2 WP1)' (#25) from feature/n2-context-wp1 into main
ci / vet-and-test (push) Successful in 52s
ci / docker-checkout-hostgateway (push) Successful in 1s
Spill cleared tool results to disk and pin the cache-stability contract (N2 WP1)
2026-10-07 13:05:01 +00:00
weichen ae42c9402d Spill cleared tool results to disk and pin the cache-stability contract (N2 WP1)
ci / vet-and-test (pull_request) Successful in 52s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: the completion doc's N2 requires old tool results to become file
paths (ZCode compact pattern) and cache-friendly prefix rules; today the
micro-compactor only left a transcript pointer and nothing pinned prefix
stability. alignToolTail had no direct edge tests.
Evidence: GOWORK=off go vet + go test -count=1 ./... green; new
TestClearOldToolResultsSpillsToDisk / TestSpillResultSanitizesCallID /
TestAlignToolTailEdges / TestSystemTextDeterministicAcrossIdenticalBuilds;
legacy placeholder bytes unchanged when ResultsDir is empty
(TestClearOldToolResultsEmptyDirKeepsLegacyPlaceholder).
Impact: opt-in via Config.ResultsDir; the native adapter wires it to
<workDir>/.agentx/results. No behavior change for other hosts.
Refs: 2026-10-07-agent-stack-n2-plan WP1
2026-10-07 12:59:05 +00:00
weichen ead690be30 Merge pull request 'Keep session state out of the process CWD for workspace-less requests' (#24) from feature/native-empty-workspace-tempdir into main
ci / vet-and-test (push) Successful in 1m3s
ci / docker-checkout-hostgateway (push) Successful in 1s
Keep session state out of the process CWD for workspace-less requests
2026-10-07 11:48:43 +00:00
weichen 4a7e19ad62 Keep session state out of the process CWD for workspace-less requests
ci / vet-and-test (pull_request) Successful in 1m1s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: a ContractRequest without a workspace (the conformance minimal
case) resolved WorkDir to the process CWD and wrote .agentx transcripts
into the repo, dirtying the tree in CI and failing the build step's
worktree check.
Evidence: GOWORK=off go vet + go test -count=1 ./... green;
TestContractEmptyWorkspaceKeepsCWDClean pins the fixture bytes;
the adapters/native tree no longer drifts after a suite run.
Impact: requests with an explicit workspace are unchanged; empty
workspace now lands in a disposable temp dir.
Refs: Agent-System-Dev#242 CI failure (run 1144)
2026-10-07 11:47:15 +00:00
weichen 3a1ad5afc7 Merge pull request 'Point the nested agent-intent pin at the I2 port work' (#23) from feature/agent-stack-i2 into main
ci / vet-and-test (push) Successful in 55s
ci / docker-checkout-hostgateway (push) Successful in 0s
Point the nested agent-intent pin at the I2 port work
2026-10-07 11:31:03 +00:00
weichen 6ab3b8677d Point the nested agent-intent pin at the I2 port work
ci / vet-and-test (pull_request) Successful in 51s
ci / docker-checkout-hostgateway (pull_request) Successful in 0s
Why: the Dev combination check requires every nested agent-intent pin
to equal the Dev top-level pin (343b1e6, I2 increments 1-2 on intent
main).
Evidence: tree-only pin move; agent-native code does not consume the
new ports.
Impact: none.
Refs: I2 increment 3 cascade
2026-10-07 11:29:40 +00:00
weichen 78b6c6477c Merge pull request 'Point the nested intent pin at the eval-v2 head' (#22) from feature/agent-stack-i1 into main
ci / vet-and-test (push) Successful in 49s
ci / docker-checkout-hostgateway (push) Successful in 1s
2026-10-07 08:24:37 +00:00
weichen 108c194c8d Point the nested intent archive at the eval-v2 head
ci / vet-and-test (pull_request) Successful in 50s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: Same cascade as the squad side (gitlink archive, no go.mod
reference).

Evidence: intent main head is the #8 merge.

Impact: Nested pin only.
2026-10-07 08:23:05 +00:00
weichen 6ea6929309 Merge pull request 'Point the nested agent-intent pin at the I1 command model' (#21) from feature/agent-stack-i1 into main
ci / vet-and-test (push) Successful in 50s
ci / docker-checkout-hostgateway (push) Successful in 1s
2026-10-07 07:47:57 +00:00
weichen d9a168635e Point the nested agent-intent pin at the I1 command model
ci / vet-and-test (pull_request) Successful in 49s
ci / docker-checkout-hostgateway (pull_request) Successful in 0s
Why: Dev moved its agent-intent pin to 2554b50 (PRs #6/#7);
check-pins requires the same combination in the nested copy
(gitlink only - no go.mod reference since D1).

Evidence: 2554b50 is the #7 merge on agent-intent main; native
suite runs next.

Impact: Nested pin only.
2026-10-07 07:46:45 +00:00
weichen c9c2f23990 Merge pull request 'Export the artifact ID pattern for host-side reference extraction' (#20) from feature/agent-stack-n1 into main
ci / vet-and-test (push) Successful in 49s
ci / docker-checkout-hostgateway (push) Successful in 1s
2026-10-07 06:25:43 +00:00
weichen 16e356a3a4 Merge origin/main into the N1 branch
ci / vet-and-test (pull_request) Successful in 48s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
2026-10-07 06:25:13 +00:00
weichen d333094f7e Export the artifact ID pattern for host-side reference extraction
ci / vet-and-test (pull_request) Successful in 48s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: Hosts also extract artifact IDs from text outside the gate;
sharing one compiled pattern keeps the forms aligned.

Evidence: additive export; the gates suite still passes.

Impact: ArtifactIDPattern exported; gate internals use it too.
2026-10-07 06:21:56 +00:00
weichen 0d8edbea52 Merge pull request 'Sink the final gate mechanics and add the contract backend adapter (N1 WP5/WP6)' (#19) from feature/agent-stack-n1 into main
ci / vet-and-test (push) Successful in 50s
ci / docker-checkout-hostgateway (push) Successful in 1s
2026-10-07 06:21:00 +00:00
weichen 7f2c8b9ee1 Sink the final gate mechanics and add the contract backend adapter
ci / vet-and-test (pull_request) Successful in 50s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: N1 WP5/WP6 library side - the gate decision logic, artifact
checks, verdict parsing and progress-reminder throttling move from
the product into a gates package behind ports, and the native backend
gains a contract form so conformance has a real implementation to
drive.

Evidence: go vet clean; go test ./... 27 packages ok, including the
conformance suite (succeeded/descriptor/error-sideeffects) against
the real backend.

Impact: new runtime/gates package (ClaimGate, CheckArtifacts,
ParseVerdict, ProgressReminder, shared caps); adapters/native.Backend
gains Contract() returning an exec.ContractBackend wrapper - the
legacy facade keeps its signature so squad consumption is untouched.
Product texts (fix prompts, verifier prompts) stay injected by hosts.

Refs: Documents proposal 2026-10-07-agent-stack-n1q1i1-plans.md WP5/WP6
2026-10-07 06:14:29 +00:00
weichen c5b9dca446 Merge pull request 'Add AgentSpec, three guardrail ports, subagent events, and ask passthrough (N1 WP1/WP4)' (#18) from feature/agent-stack-n1 into main
ci / vet-and-test (push) Successful in 47s
ci / docker-checkout-hostgateway (push) Successful in 1s
2026-10-07 05:07:25 +00:00
weichen 948ca36ca2 Add AgentSpec, three guardrail ports, subagent events, and ask passthrough
ci / vet-and-test (pull_request) Successful in 47s
ci / docker-checkout-hostgateway (pull_request) Successful in 0s
Why: N1 increment three (WP1/WP4) - agents need a canonical spec
format with plugin restrictions, hosts need typed input/output/tool
guardrails, delegation needs lifecycle events, and squad adapters
need to pass the permission ask port through.

Evidence: go vet clean; go test ./... 26 packages ok, including new
agentspec (12 tests), guardrails (5), loop_guardrails (5), and
subagent hook tests (3).

Impact: new runtime/agentspec and runtime/guardrails packages; loop
consumes UserPromptSubmit outcomes (deny skips the loop, modify
appends the rewrite); subagent gains RegisterWithHooks emitting
SubagentStart/Stop around delegation; GateOptions gains PermissionAsk
forwarded into the kernel config. All additions are opt-in: nil
ports preserve prior behavior exactly.

Refs: Documents proposal 2026-10-07-agent-stack-n1q1i1-plans.md WP1/WP4
2026-10-07 05:04:58 +00:00
weichen c2befaf17e Merge pull request 'Add permission ask loop, lifecycle hook emissions, and Policy type (N1 WP2/WP3)' (#17) from feature/agent-stack-n1 into main
ci / vet-and-test (push) Successful in 48s
ci / docker-checkout-hostgateway (push) Successful in 1s
2026-10-07 04:37:23 +00:00
weichen d5714e81ed Merge origin/main into the N1 ask-loop branch
ci / vet-and-test (pull_request) Successful in 48s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
2026-10-07 04:36:29 +00:00
weichen 9758a8d8d0 Add permission ask loop, lifecycle hook emissions, and Policy type
ci / vet-and-test (pull_request) Successful in 48s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: The N1 harness phase needs the kernel to resolve ask decisions
through the host (paired HITL), emit the full hook lifecycle the plan
promises, and carry mode/rules/session-allow in one named object.

Evidence: go vet clean; go test ./... 24 packages ok, including new
loop_hooks_lifecycle_test.go (lifecycle order, prompt text, ask
allow/deny/legacy paths, stop continuation counts) and three new
policy tests (struct-form parity, session-allow store, nil store).

Impact: hooks adds PostToolBatch and SessionEnd events; loop emits
SessionStart/UserPromptSubmit/PostToolBatch/SessionEnd and PreCompact
lands in AgentSession.Compact; gateTool consults PermissionAsk on ask
and keeps ask=reject when the port is unset; policy gains Policy,
SessionAllows, and MemoryAllows. SubagentStart/Stop emissions are
deferred to the adapters assembly increment (factory site has no bus
access yet).

Refs: Documents proposal 2026-10-07-agent-stack-n1q1i1-plans.md WP2/WP3
2026-10-07 03:01:17 +00:00
weichen fc5a1930b5 Shape provider messages before they are sent
ci / vet-and-test (push) Successful in 49s
ci / docker-checkout-hostgateway (push) Successful in 1s
Merge pull request 'Shape provider messages before they are sent' (#16) from feature/sillytavern-review into main
2026-10-04 19:44:56 +00:00
120 changed files with 10459 additions and 441 deletions
+150
View File
@@ -0,0 +1,150 @@
name: governance
on:
pull_request:
push:
branches: [main]
jobs:
governance:
runs-on: ubuntu-latest
env:
GOPROXY: https://goproxy.cn,direct
steps:
- name: Checkout
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
SUBMODULE_TOKEN: ${{ secrets.SUBMODULE_TOKEN }}
run: |
set -euo pipefail
# 只物化两个治理 gitlink(Guidelines 与 deps/development-manager),非递归;
# 其它 gitlink 不物化、不取回。凭据只以 URL 限定的 extraheader 存在于本进程树内。
python3 - <<'PY_CHECKOUT'
import os
from pathlib import Path
import re
import subprocess
import sys
from urllib.parse import urlsplit
# 服务端基址取运行器注入的 GITHUB_SERVER_URL(平台控制、候选 PR 不可影响)。
SERVER = os.environ.get("GITHUB_SERVER_URL", "").rstrip("/")
parsed_server = urlsplit(SERVER)
if parsed_server.scheme not in {"http", "https"} or not parsed_server.netloc:
sys.exit("Checkout server must be the platform-injected http(s) URL")
SSH = "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/"
# 审查过的白名单,独立于候选 .gitmodules:仅列出两个治理 gitlink。
REPOSITORIES = {
"deps/development-guidelines": "Development-Guidelines",
"deps/development-manager": "Development-Manager",
}
TARGET = "weichen/Agent-Native"
root_token = os.environ.pop("TOKEN", "")
component_token = os.environ.pop("SUBMODULE_TOKEN", "")
if not root_token or not component_token:
sys.exit("Checkout requires TOKEN and SUBMODULE_TOKEN")
if os.environ.get("GITHUB_REPOSITORY") != TARGET:
sys.exit("Checkout repository is outside the reviewed allowlist")
sha = os.environ.get("GITHUB_SHA", "")
if not re.fullmatch(r"[0-9a-fA-F]{40}", sha):
sys.exit("Checkout requires a fixed commit SHA")
# 丢弃继承的 Git 追踪/配置/SSH 钩子;凭据只存在于单个 Git 进程树的精确 URL 头中。
clean_env = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")}
clean_env.update(GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_SYSTEM="/dev/null",
GIT_CONFIG_GLOBAL="/dev/null", GIT_TERMINAL_PROMPT="0")
config = {
"core.hooksPath": "/dev/null", "core.fsmonitor": "false",
"init.templateDir": "", "credential.helper": "",
"fetch.recurseSubmodules": "false", "submodule.recurse": "false",
"http.followRedirects": "false", "protocol.allow": "never",
"protocol." + urlsplit(SERVER).scheme + ".allow": "always",
}
def git(repo, *args, extra=None):
values = config | (extra or {})
env = clean_env | {"GIT_CONFIG_COUNT": str(len(values))}
for index, (key, value) in enumerate(values.items()):
env[f"GIT_CONFIG_KEY_{index}"] = key
env[f"GIT_CONFIG_VALUE_{index}"] = value
return subprocess.run(["git", "-C", str(repo), *args], env=env,
check=True, stdout=subprocess.PIPE).stdout
def materialize_governance(root):
# -r 列出任意深度的 gitlink,但不进入 submodule 内部;其它 gitlink 只记录、从不取回。
links = {}
for entry in git(root, "ls-tree", "-rz", "HEAD").split(b"\0"):
if not entry:
continue
metadata, path = entry.split(b"\t", 1)
mode, kind, commit = metadata.decode().split()
if mode == "160000":
links[path.decode()] = commit
for name in REPOSITORIES:
if name not in links:
raise ValueError("Governance gitlink is missing from the fixed commit")
raw = git(root, "config", "--no-includes", "--blob", "HEAD:.gitmodules", "--null", "--list")
entries = {}
for item in raw.split(b"\0"):
if not item:
continue
key, value = item.decode().split("\n", 1)
match = re.fullmatch(r"submodule\.(.+)\.(path|url)", key)
if not match:
continue
name, field = match.groups()
if name not in REPOSITORIES:
continue
if field in entries.setdefault(name, {}):
raise ValueError("Duplicate candidate .gitmodules setting")
entries[name][field] = value
# 先整体校验两个治理条目,再进行任何带凭据的取回。
for name, repository in REPOSITORIES.items():
fields = entries.get(name, {})
if fields.get("path") != name:
raise ValueError("Governance submodule path is outside the reviewed allowlist")
trusted_url = f"{SERVER}/weichen/{repository}.git"
if fields.get("url") not in {SSH + repository + ".git", trusted_url}:
raise ValueError("Governance submodule URL is outside the reviewed allowlist")
for name, repository in REPOSITORIES.items():
url = f"{SERVER}/weichen/{repository}.git"
# 仅持久化审查过的公开 URL,使 Git 在进程级配置消失后仍识别该 submodule。
git(root, "config", f"submodule.{name}.url", url)
git(root, "submodule", "update", "--init", "--checkout", "--", name,
extra={f"submodule.{name}.url": url,
f"submodule.{name}.update": "checkout",
f"http.{url}.extraheader": "AUTHORIZATION: token " + component_token})
child = root / name
if git(child, "rev-parse", "HEAD").decode().strip() != links[name]:
raise ValueError("Materialized governance gitlink does not match the fixed SHA")
try:
root = Path.cwd()
# 必须是干净的临时工作区;不复用既有 Git 状态。
if (root / ".git").exists():
raise ValueError("Checkout requires a fresh workspace")
git(root, "init", "-q")
url = f"{SERVER}/{TARGET}.git"
git(root, "remote", "add", "origin", url)
git(root, "fetch", "--depth=1", "origin", sha,
extra={f"http.{url}.extraheader": "AUTHORIZATION: token " + root_token})
git(root, "checkout", "-q", "--detach", "FETCH_HEAD")
materialize_governance(root)
except (ValueError, OSError, subprocess.CalledProcessError) as error:
sys.exit(f"Checkout refused: {error}")
print("Checkout complete; credentials were not persisted in Git configuration")
PY_CHECKOUT
unset TOKEN SUBMODULE_TOKEN
- name: Install Go 1.27.1
run: |
set -euo pipefail
curl -fsSL -o "$RUNNER_TEMP/go.tgz" https://mirrors.aliyun.com/golang/go1.27.1.linux-amd64.tar.gz
tar -C /usr/local -xzf "$RUNNER_TEMP/go.tgz"
ln -sfn /usr/local/go/bin/go /usr/local/bin/go
ln -sfn /usr/local/go/bin/gofmt /usr/local/bin/gofmt
- name: Validate agent execution configuration
run: scripts/agent-exec validate --repo .
- name: Governance check
run: scripts/dm governance check --repo .
+3
View File
@@ -10,3 +10,6 @@
[submodule "deps/agent-intent"]
path = deps/agent-intent
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Intent.git
[submodule "deps/development-manager"]
path = deps/development-manager
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Development-Manager.git
@@ -0,0 +1,57 @@
# Agent-Native 采纳 development-governance-v2 治理套件 任务卡
任务主记录:[Agent-Native PR #47](https://git-home.u1680832.nyat.app:30319/weichen/Agent-Native/pulls/47)(本文件随 PR 描述维护)。
状态:已完成(闭合随本 PR 合入;合入以平台记录为准);owner:weichen。
## 基线与授权
- 权威仓:weichen/Agent-Native;root_target:main;immediate_target:main
- branch_kind:initiative;initiative_id:governance-adoption-Agent-Native
- R0(initial_main_sha):c348441691e8bdab784a03aa9be64d53dacff341;工作区:/home/weichen/projects/Agent-Native-wt-feature-governance-adoption(worktree);分支:feature/governance-adoption
- 父任务信息(仅子任务填写):无
- accepting_requirements:false(单仓采纳 initiative,范围固定为下列十步,不接收新需求)
- 需求分流:verdict=NEW;selected_initiative=无;候选=无;理由=development-governance-v2 Phase 6 每个正式仓库独立 initiative;triage 收据=无(本仓采纳前尚无 triage 入口)
- 授权与交付终点:owner 已授权 development-governance-v2 全部实施(Agent-System-Dev 主记录 docs/superpowers/plans/2026-10-16-development-governance-v2-master.md);终点为本 PR 合入 main 且采纳状态 active。分支保护与 tag 由用户执行。
- 实施执行:agent-per-unit(配置:AGENT-EXECUTION.md;外部 Agent 一单元一新进程/会话/工作区)
## 目标与非目标
目标(Phase 6 十步):固定 Guidelines SHA(deps/development-guidelines @ 2688fd2be6a52c0bf110e7e6366b6f291ab5e4eb);增加 AGENT-EXECUTION.md;增加共享执行器薄入口(deps/development-manager @ bd3a7de57a3a9e1a4495b94c5fa5c08d71e7be8c + scripts/dm、scripts/agent-exec、scripts/task);四条路由(implementation/repair/test/review);任务卡模板与分支元数据;CI 中启用 schema 校验(.gitea/workflows/governance.yml:agent-exec validate + governance check);一次正例运行;负例(缺配置、坏收据、越界路径、未知副作用);独立 Review + CI;采纳状态 active。
非目标:不改业务代码与既有测试;不改分支保护(由用户执行);不发布 tag。
引导说明:AGENT-EXECUTION.md 由 host 以固定版本治理套件(dm-server governance init @ bd3a7de57a3a9e1a4495b94c5fa5c08d71e7be8c)生成并作为引导配置提交(执行器运行前必须存在有效配置;沿用 DM 采纳先例);其余文件由实施单元生成。
## Agent 实施收据
| 单元 ID | 预期范围 | 角色 | 候选顺序 | attempt | CLI/模型 | 终态 | transport | fallback | 源码外收据路径 |
|---|---|---|---|---|---|---|---|---|---|
| governance-adoption-Agent-Native-r1 | 13 个允许路径(治理文件 + 两个 gitlink) | b1034e13 | implementation | cursor→antigravity→pi | run 18dcfcc1(dm-server pinned,attempts=1) | cursor / not-observable | success | 9c7dc6bf4a65(apply 快进) | 否 | /tmp/gov-receipt-Agent-Native-r1.json |
| governance-adoption-Agent-Native-repair-r1 | policy/guidelines-adoption.md | repair | cursor→antigravity→pi | run 734e0741(attempts=1) | cursor / not-observable | success | a625686ea9f4(apply 快进) | 否 | /tmp/gov-receipt-Agent-Native-repair-r1.json |
## 同步记录
| 节点 | sync_base_sha | tested_head_sha | checked_at | 结果 |
|---|---|---|---|---|
| 启动 | c348441691e8bdab784a03aa9be64d53dacff341 | c348441691e8bdab784a03aa9be64d53dacff341 | 2026-10-17T07:17:40Z | 一致 |
| 请求审核 | c348441691e8bdab784a03aa9be64d53dacff341 | 8d09aa0265d7 | 2026-10-17T08:53:09Z | 已含 origin/main |
## 结果与验证(完成后填写)
- 结果:治理套件已采纳——Guidelines 与 Development-Manager gitlink 固定、AGENT-EXECUTION.md 四条路由、共享执行器薄入口、pre-commit admit、governance CI;采纳状态 active 随本 PR 合入生效。
- 验证:见下表(命令输出由 /tmp/gov-neg/ev.sh 写入 .task/evidence/governance-adoption/,SELF 除非另注)。
- 复现:正负例脚本冻结副本 Agent-System-Dev `docs/superpowers/plans/evidence/governance-adopt-cases.py`(sha256 3aa1bf6ea1cd48f3f59c0a307620e4112389a93fe21a113006f54e62eb8f0d06),用法 `python3 governance-adopt-cases.py <仓库根>`;它经本仓 `scripts/agent-exec` 运行假 CLI,不访问网络。
- 影响与剩余项:不改业务代码;本地启用钩子需由人执行 `git config core.hooksPath scripts/hooks`;将 governance 检查设为分支保护必需项属用户双钥匙事项(命令见 Agent-System-Dev 治理主记录)。
| 时间(UTC) | 命令 | 被测 HEAD | exit | 来源 | 摘要/位置 |
|---|---|---|---|---|---|
| 20261017T073822Z | `scripts/dm governance check --repo .` | 9c7dc6bf4a65 | 0 | SELF | ok fixtures;[输出](../evidence/governance-adoption/00-20261017T073822Z.log) sha256:3694b7fcd797887a |
| 20261017T073822Z | `scripts/agent-exec validate --repo .` | 9c7dc6bf4a65 | 0 | SELF | validate: ok;[输出](../evidence/governance-adoption/01-20261017T073822Z.log) sha256:90017bfe574eef56 |
| 20261017T073822Z | `python3 /tmp/gov-neg/adopt_cases.py .` | 9c7dc6bf4a65 | 0 | SELF | [verdict] ALL PASS cases=5;[输出](../evidence/governance-adoption/02-20261017T073822Z.log) sha256:571116eee5eb098d |
| 20261017T083654Z | `scripts/dm governance check --repo .` | a625686ea9f4 | 0 | SELF | ok fixtures;[输出](../evidence/governance-adoption/03-20261017T083654Z.log) sha256:075f18ade3c51e71 |
| 20261017T083654Z | `scripts/agent-exec validate --repo .` | a625686ea9f4 | 0 | SELF | validate: ok;[输出](../evidence/governance-adoption/04-20261017T083654Z.log) sha256:29a607257c052021 |
| 2026-10-17T08:53:09Z | CI `ci / docker-checkout-hostgateway (pull_request)` | 8d09aa0265d7 | 0 | GATE | success;[运行](https://git-home.u1680832.nyat.app:30319/weichen/Agent-Native/actions/runs/3590/jobs/5679) |
| 2026-10-17T08:53:09Z | CI `governance / governance (pull_request)` | 8d09aa0265d7 | 0 | GATE | success;[运行](https://git-home.u1680832.nyat.app:30319/weichen/Agent-Native/actions/runs/3591/jobs/5680) |
| 2026-10-17T08:53:09Z | CI `ci / vet-and-test (pull_request)` | 8d09aa0265d7 | 0 | GATE | success;[运行](https://git-home.u1680832.nyat.app:30319/weichen/Agent-Native/actions/runs/3590/jobs/5678) |
| — | 候选 HEAD CI | 3cf8fa39b631 | 0 | GATE | success ci / docker-checkout-hostgateway (pull_request)=success(/weichen/Agent-Native/actions/runs/3614/jobs/5713);governance / governance (pull_request)=success(/weichen/Agent-Native/actions/runs/3615/jobs/5714);ci / vet-and-test (pull_request)=success(/weichen/Agent-Native/actions/runs/3614/jobs/5712) |
| — | 独立 Review | 3cf8fa39b631 | 0 | REPLAY | cursor-agent-stable --mode ask 全新只读会话(fresh_session=true,session id not-observable)闭合重审 VERDICT: APPROVE(对象 3cf8fa3) |
@@ -0,0 +1,19 @@
command: scripts/dm governance check --repo .
cwd: /home/weichen/projects/Agent-Native-wt-feature-governance-adoption
head: 9c7dc6bf4a658cb0a1ab17173964cbdc154daf9d
exit: 0
utc: 20261017T073822Z
---
ok gitlink deps/development-guidelines
ok gitlink deps/development-manager
ok agent-execution
ok file AGENTS.md
ok file PROJECT.md
ok file policy/guidelines-adoption.md
ok file .task/templates/task-card.md
ok executable scripts/agent-exec
ok executable scripts/hooks/pre-commit
ok executable scripts/dm
ok executable scripts/task
ok adoption status
ok fixtures
@@ -0,0 +1,7 @@
command: scripts/agent-exec validate --repo .
cwd: /home/weichen/projects/Agent-Native-wt-feature-governance-adoption
head: 9c7dc6bf4a658cb0a1ab17173964cbdc154daf9d
exit: 0
utc: 20261017T073822Z
---
validate: ok
@@ -0,0 +1,13 @@
command: python3 /tmp/gov-neg/adopt_cases.py .
cwd: /home/weichen/projects/Agent-Native-wt-feature-governance-adoption
head: 9c7dc6bf4a658cb0a1ab17173964cbdc154daf9d
exit: 0
utc: 20261017T073822Z
---
[object] Agent-Native-wt-feature-governance-adoption HEAD=9c7dc6bf4a658cb0a1ab17173964cbdc154daf9d
PASS positive-run-apply: run exit=0 terminal=success apply exit=0 head=232696a2f8ab
PASS missing-config: run exit=2 terminal=invalid-config validate exit=2
PASS bad-receipt: apply exit=4 head_unchanged=True msg=apply: no local integrity record: changed paths do not match the transport diff
PASS out-of-scope-path: run exit=5 terminal=scope-expansion transport=False
PASS unknown-side-effect: run exit=4 terminal=failed-unknown transport=False
[verdict] ALL PASS cases=5
@@ -0,0 +1,19 @@
command: scripts/dm governance check --repo .
cwd: /home/weichen/projects/Agent-Native-wt-feature-governance-adoption
head: a625686ea9f4448fd609e7dfff33f4680ce8f834
exit: 0
utc: 20261017T083654Z
---
ok gitlink deps/development-guidelines
ok gitlink deps/development-manager
ok agent-execution
ok file AGENTS.md
ok file PROJECT.md
ok file policy/guidelines-adoption.md
ok file .task/templates/task-card.md
ok executable scripts/agent-exec
ok executable scripts/hooks/pre-commit
ok executable scripts/dm
ok executable scripts/task
ok adoption status
ok fixtures
@@ -0,0 +1,7 @@
command: scripts/agent-exec validate --repo .
cwd: /home/weichen/projects/Agent-Native-wt-feature-governance-adoption
head: a625686ea9f4448fd609e7dfff33f4680ce8f834
exit: 0
utc: 20261017T083654Z
---
validate: ok
+66
View File
@@ -0,0 +1,66 @@
# <任务标题> 任务卡
任务主记录:<Issue/PR 链接,或注明"本文件随 PR 描述维护">。
状态:<进行中 / 待审核 / 已合入 / 已放弃>;owner:<填写>。
## 基线与授权
- 权威仓:<仓 URL 或逻辑 ID>;root_target:main;immediate_target:<main 或 feature/initiative>
- branch_kind:<initiative | child | standalone>;initiative_id:<稳定 ID,普通任务为空>
- R0(initial_main_sha):<完整 SHA>;工作区:<路径>(worktree / clone);分支:<feature|fix|hotfix/...>
- 父任务信息(仅子任务填写):parent_branch=<父分支>;parent_head_sha=<SHA>;parent_pr=<父 PR>
- accepting_requirements:<true | false>(仅 initiative 可为 true;冻结后为 false)
- 需求分流:verdict=<JOIN | NEW>;selected_initiative=<分支或无>;候选=<分支@HEAD 列表或无>;理由=<不适配/适配依据>;triage 收据=<源码外路径与摘要>
- 授权与交付终点:<本次允许的动作与终点;未授权事项写明>
- 实施执行:agent-per-unit(配置:AGENT-EXECUTION.md;外部 Agent 一单元一新进程/会话/工作区)
## 目标与非目标
<预期结果、受影响入口/模块/契约;明确不做什么。>
## Agent 实施收据
按根目录 AGENT-EXECUTION.md 路由;外部 Agent 一单元一新进程/会话/隔离工作区。实施、修复与测试必须通过登记的候选执行;未配置或候选安全耗尽停在 unavailable/blocked,禁止 host 隐式代写。
| 单元 ID | 预期范围 | 角色 | 候选顺序 | attempt | CLI/模型 | 终态 | transport | fallback | 源码外收据路径 |
|---|---|---|---|---|---|---|---|---|---|
| 待定义 | — | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | — |
## 复用材料(task-start 自动检索结果摘录)
- 相关近期提交:<git log -- <paths> 命中;无则写无>。
- 相关经验条目:<检索命中;无则写无>。
## 同步记录
| 节点 | sync_base_sha | tested_head_sha | checked_at | 结果 |
|---|---|---|---|---|
| 启动 | <R0> | <HEAD> | <日期时间> | <一致/已同步> |
| 验收前 | <填写> | <填写> | <填写> | <填写> |
字段没有发生的写"无",不省略。本卡与 PR 描述同源:开 PR 时将"目标与非目标、结果与验证、影响与剩余项"复制进 PR 描述,不另写第二份。
## 交付调度(有多个就绪任务时)
- 调度记录 / entry_id / 当前 attempt_id:<平台或调度服务链接及标识;未入队写无>。
- 入队源 HEAD / owner / 依赖:<完整 SHA、身份、依赖或无>。
- 固定候选引用:<调度尝试中的 base/head、组件与配置;未固定写无>。
- 窗口、最终 CI、Review 与真实合入结果:<引用调度记录及原始证据,不复制全队列>。
定义见 [合入调度与 CI 分配](../standards/merge-queue.md)。本段在候选固定前整理;固定后先使用源码之外的记录保存运行结果,再按项目闭合流程回填。
## 结果与验证(完成后填写)
- 结果:<交付了什么;未发生写无>。
- 验证:<结论性摘要:PASS/FAIL/SKIP/NOT_RUN 逐项;逐条证据见下表,来源标注与写入规则见 [按影响选择验证与证据](../standards/verification.md)>。
- 影响与剩余项:<影响面、遗留风险、后续任务>。
| 时间(UTC) | 命令 | 被测 HEAD | exit | 来源 | 摘要/位置 |
|---|---|---|---|---|---|
| <时间> | <命令;由项目 evidence 工具写入,无工具时附命令+对象 SHA+输出 digest 并标 SELF> | <HEAD> | <exit> | GATE/REPLAY/SELF | <摘要/位置> |
## 回收(task-finish)
- 合入核对:<分支已包含于目标分支的核对输出>。
- 未跟踪/ignored 文件处置:<归档/可再生/存疑 逐项;无则写无>。
- 回收动作:<worktree remove、分支删除、prune;放弃路径注明原因>。
+67
View File
@@ -0,0 +1,67 @@
# Project Agent Execution Configuration
# 项目工程 Agent 执行路由配置
适用范围:定义本项目中由 AI / Agent 主导的代码实施、修复、测试与审阅所调用的 Agent CLI 路由。
解析器识别正文中唯一一个 ```agent-execution 代码块。正文外的说明文字可根据项目需要调整。
通用硬规则见 Development-Guidelines 的 [Agent 执行与协作契约](deps/development-guidelines/standards/agent-execution.md)。
本配置为项目本地路由权威,声明具体的候选 profile、尝试次数上限、超时、集成入口与豁免路径;
代码块须通过 `deps/development-guidelines/templates/schemas/agent-execution.schema.json` 校验后才能登记为 active。
`fallback_to_host` 只能为 `never`;旧值 `all-unavailable` 仅供迁移工具读取,不能通过准入。
下例的 CLI 名称、命令与路径均为占位,按项目实际执行器替换。
```agent-execution
schema: agent-execution/v1
execution:
fresh_process_per_unit: true
permission: run-everything
fallback_to_host: never
max_parallel: 3
timeout_seconds: 7200
idle_timeout_seconds: 600
summary_bytes: 16384
raw_output_bytes: 10485760
max_attempts_per_profile: 3
emergency_profile: codex
roles:
implementation: [cursor, antigravity, pi]
repair: [cursor, antigravity, pi]
test: [pi, antigravity, cursor]
review: [pi, antigravity, cursor, codex]
profiles:
cursor:
adapter: cursor
executable: "cursor-agent-stable"
model: auto
enabled: true
antigravity:
adapter: antigravity
executable: agy
model: auto
enabled: true
pi:
adapter: pi
executable: pi
model: auto
enabled: true
codex:
adapter: codex
executable: codex
model: auto
enabled: true
integration:
exempt_paths: [".task/**", "docs/superpowers/specs/**", "docs/superpowers/plans/**"]
transport:
command: "scripts/agent-exec apply"
admission:
command: "scripts/agent-exec admit"
cleanup:
command: "scripts/agent-exec cleanup"
```
+18
View File
@@ -34,3 +34,21 @@
- **SSH key**:`~/.ssh/home_gitea`(ED25519,公钥指纹
`SHA256:GYXaOXIMeloQ1fKSfdJ/ndgPQzs/Ix7Hk62ekfkfQ20`,Gitea 侧密钥名 GMKtec);
`~/.ssh/config` 已有 `git-ssh.u1680832.nyat.app` 主机条目,克隆/推送直接可用。
## Agent 执行与新项目准入(强制)
AI 实施单元只经 `scripts/agent-exec run` 执行(路由与候选见 [AGENT-EXECUTION.md](AGENT-EXECUTION.md) 与 [Agent 执行与协作契约](deps/development-guidelines/standards/agent-execution.md)),并经 `scripts/agent-exec apply` 集成。
1. 缺 Guidelines 固定版本(`deps/development-guidelines` gitlink 未就绪或 HEAD 不符)时,Agent 只能只读分析,不得实施。
2. 缺有效 `AGENT-EXECUTION.md`(`scripts/dm governance check` 未通过)时禁止 AI 实施。
3. 缺共享执行器(`scripts/agent-exec` 或 `deps/development-manager` 不可用)时,状态为 `blocked/unavailable`,不得改由 host 替代。
4. 不允许 host 代写实施单元;实施、修复与测试必须经 `AGENT-EXECUTION.md` 登记的候选执行。
5. 人类直接开发按其自身授权与项目规则进行,不受本条款约束。
6. 分支保护与规范 tag 由用户负责设置与维护,Agent 不得代为设置。
入口:
- 需求分流:`scripts/task triage discover|decide|verify`
- 任务启动:`scripts/task start …`
- PR 检查:`scripts/task pr-check …`
- Agent 执行:`scripts/agent-exec run|apply|admit|validate`
+1
View File
@@ -42,6 +42,7 @@ git clone --recurse-submodules ssh://git@git-ssh.u1680832.nyat.app:31411/weichen
| 模块/接口 | 仓库根:`go vet ./... && go test -count=1 ./...` | 需要 Go 1.27;不覆盖真实模型端点 |
| 数据/权限/恢复 | 不适用独立数据库。契约测试覆盖副作用分类与取消传播 | 不证明调用方产品库的持久化 |
| UI/执行后端 | `go test ./adapters/... ./runtime/... ./conformance/...` | 默认 fake/stub;真实模型需调用方提供端点,本命令不覆盖 |
| 治理与 Agent 执行配置 | 仓库根(需在暂存副本中先 `git add -A`,gitlink 须在索引中):`scripts/dm governance check --repo .` 与 `scripts/agent-exec validate --repo .` | 只核验采纳文件、固定组合与 `AGENT-EXECUTION.md` 配置,不证明执行结果 |
## 环境与数据
+26
View File
@@ -0,0 +1,26 @@
{"id":"025357339a97c04d","timestamp":"2026-10-07T06:11:15.521547827Z","type":"message","message":{"role":"system","content":"## Environment\ndate: 2026-10-07(周三)\nmodel: fake\nworkspace: /home/weichen/projects/Agent-System-Dev-wt-feature-agent-stack-n1/deps/agent-native/adapters/native\ntool profile: workspace\n\nDeliverable files: output.md. Use workspace tools to write those files. Do not invent paths outside the workspace.\n\nTools available in this workspace:\n- read_file: Read a UTF-8 text file inside the workspace. Without offset/limit returns the whole file (truncated at 200 KiB); with offset/limit returns numbered lines from a 1-based offset.\n- write_file: Create or replace a UTF-8 text file inside the workspace.\n- edit_file: Replace one unique exact text span in a workspace file.\n- multi_edit: Apply several unique-span replacements to one workspace file atomically: either all edits land or the file is left untouched.\n- list_dir: List names in a workspace directory.\n- grep: Search workspace text files. Literal substring by default; regex=true switches to Go regexp, include filters file names (e.g. *.go), ignore_case folds case. Caps at 50 hits.\n- glob: Find workspace files matching a glob pattern (** crosses directories, * within one segment). Returns up to 500 relative paths."}}
{"id":"46a70655ee65ddc3","parentId":"025357339a97c04d","timestamp":"2026-10-07T06:11:15.524296265Z","type":"message","message":{"role":"user","content":"hi"}}
{"id":"ab242da6bd626cae","parentId":"46a70655ee65ddc3","timestamp":"2026-10-07T06:11:15.524748291Z","type":"message","message":{"role":"assistant","content":"hi","stop_reason":"end"}}
{"id":"f637d2872ab9f34b","parentId":"ab242da6bd626cae","timestamp":"2026-10-07T06:11:15.525362933Z","type":"message","message":{"role":"user"}}
{"id":"c1bada8c14ef7ec0","parentId":"f637d2872ab9f34b","timestamp":"2026-10-07T06:11:15.525828249Z","type":"message","message":{"role":"assistant","content":"hi","stop_reason":"end"}}
{"id":"a01a79f9d2d2c219","parentId":"c1bada8c14ef7ec0","timestamp":"2026-10-07T06:11:15.52644899Z","type":"message","message":{"role":"user","content":"hi"}}
{"id":"37ce369360ed5bc3","parentId":"a01a79f9d2d2c219","timestamp":"2026-10-07T06:11:22.666535507Z","type":"message","message":{"role":"user","content":"hi"}}
{"id":"64a2479c0d206bf1","parentId":"37ce369360ed5bc3","timestamp":"2026-10-07T06:11:22.668999313Z","type":"message","message":{"role":"assistant","content":"hi","stop_reason":"end"}}
{"id":"61593f441df28345","parentId":"64a2479c0d206bf1","timestamp":"2026-10-07T06:11:22.669756111Z","type":"message","message":{"role":"user"}}
{"id":"c5aa1aebdaa45000","parentId":"61593f441df28345","timestamp":"2026-10-07T06:11:22.670242386Z","type":"message","message":{"role":"assistant","content":"hi","stop_reason":"end"}}
{"id":"3a9f8790c99f9e94","parentId":"c5aa1aebdaa45000","timestamp":"2026-10-07T06:11:22.670926846Z","type":"message","message":{"role":"user","content":"hi"}}
{"id":"064604f3b2f1ad65","parentId":"3a9f8790c99f9e94","timestamp":"2026-10-07T06:13:48.448571493Z","type":"message","message":{"role":"user","content":"hi"}}
{"id":"9d3b5edce163fdbe","parentId":"064604f3b2f1ad65","timestamp":"2026-10-07T06:13:48.449399929Z","type":"message","message":{"role":"assistant","content":"hi","stop_reason":"end"}}
{"id":"f280fb470f6e90f5","parentId":"9d3b5edce163fdbe","timestamp":"2026-10-07T06:13:48.450112717Z","type":"message","message":{"role":"user"}}
{"id":"258b37ad6c79c52d","parentId":"f280fb470f6e90f5","timestamp":"2026-10-07T06:13:48.450611112Z","type":"message","message":{"role":"assistant","content":"hi","stop_reason":"end"}}
{"id":"b6462f3dbbd35ce9","parentId":"258b37ad6c79c52d","timestamp":"2026-10-07T06:13:48.451303652Z","type":"message","message":{"role":"user","content":"hi"}}
{"id":"1f742b0f9b7c92a1","parentId":"b6462f3dbbd35ce9","timestamp":"2026-10-07T06:13:48.971444939Z","type":"message","message":{"role":"user","content":"hi"}}
{"id":"f472774cc9cb019d","parentId":"1f742b0f9b7c92a1","timestamp":"2026-10-07T06:13:48.973639624Z","type":"message","message":{"role":"assistant","content":"hi","stop_reason":"end"}}
{"id":"27e484d323f079d3","parentId":"f472774cc9cb019d","timestamp":"2026-10-07T06:13:48.97478444Z","type":"message","message":{"role":"user"}}
{"id":"9a97876386e882d4","parentId":"27e484d323f079d3","timestamp":"2026-10-07T06:13:48.97545809Z","type":"message","message":{"role":"assistant","content":"hi","stop_reason":"end"}}
{"id":"a8a4c88afce69517","parentId":"9a97876386e882d4","timestamp":"2026-10-07T06:13:48.976690973Z","type":"message","message":{"role":"user","content":"hi"}}
{"id":"c2865055fe240f43","parentId":"a8a4c88afce69517","timestamp":"2026-10-07T06:14:02.592947456Z","type":"message","message":{"role":"user","content":"hi"}}
{"id":"a02f22b1760c8363","parentId":"c2865055fe240f43","timestamp":"2026-10-07T06:14:02.595218749Z","type":"message","message":{"role":"assistant","content":"hi","stop_reason":"end"}}
{"id":"c3321f8e2d467eda","parentId":"a02f22b1760c8363","timestamp":"2026-10-07T06:14:02.59619084Z","type":"message","message":{"role":"user"}}
{"id":"7d0eb0b5d39283b2","parentId":"c3321f8e2d467eda","timestamp":"2026-10-07T06:14:02.596897779Z","type":"message","message":{"role":"assistant","content":"hi","stop_reason":"end"}}
{"id":"f99d01430932d356","parentId":"7d0eb0b5d39283b2","timestamp":"2026-10-07T06:14:02.598248098Z","type":"message","message":{"role":"user","content":"hi"}}
@@ -0,0 +1,7 @@
{
"exec_id": "cx",
"leaf": "f99d01430932d356",
"model": "fake",
"scope": "",
"updated": "2026-10-07T06:14:02Z"
}
+1
View File
@@ -0,0 +1 @@
stream ended without a message
+1
View File
@@ -0,0 +1 @@
hi
+178 -38
View File
@@ -77,6 +77,9 @@ type GateOptions struct {
Hooks *hooks.Bus
// Ask 在 MCP 工具要求用户输入时调用。nil 则把问题作为错误返回。
Ask func(ctx context.Context, question string) (string, error)
// PermissionAsk 在内核钩子对工具表态 ask 时向宿主请求裁决。
// nil 时内核保持旧行为:ask 与 deny 一样按拒绝处理。
PermissionAsk func(ctx context.Context, call llm.ToolCall) runtime.AskVerdict
}
// Options 构造后端所需的模型接入与执行参数(由组合根注入,无产品依赖)。
@@ -138,7 +141,7 @@ func (b *Backend) Close() {
// Execute 运行一轮带工具的自研智能体循环,产出目标交付物。
func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result, error) {
if len(req.Targets) == 0 {
if len(req.Targets) == 0 && !allowEmptyTargets(ctx) {
req.Targets = []string{"output.md"}
}
profile := strings.TrimSpace(req.ToolProfile)
@@ -146,29 +149,43 @@ func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result,
profile = exec.ToolProfileWorkspace
}
if profile != exec.ToolProfileWorkspace && profile != exec.ToolProfileReasoning {
return &exec.Result{Success: false, ErrorReason: "unknown tool profile: " + profile}, nil
return preflightFail("unknown tool profile: " + profile), nil
}
if b.opts.Stream == nil && (strings.TrimSpace(b.opts.APIBase) == "" || strings.TrimSpace(b.opts.APIKey) == "") {
return &exec.Result{Success: false, ErrorReason: "agent-native backend requires api base/key (or injected stream)"}, nil
return preflightFail("agent-native backend requires api base/key (or injected stream)"), nil
}
if b.opts.Ask != nil {
ctx = mcp.WithAsk(ctx, b.opts.Ask)
}
// MCP 长任务跟踪(2026-07-28 最小映射):本 run 内 tools/call 见过的
// TaskID 在 run 结束时逐一 tasks/cancel,不影响共享 Manager 上其他 run。
// 取消用 Background:run 的 ctx 在结束时已作废,取消投递不能被它掐掉。
var taskTracker *mcp.TaskTracker
ctx, taskTracker = mcp.WithTaskTracker(ctx)
defer func() { taskTracker.CancelAll(context.Background()) }()
model := strings.TrimSpace(b.opts.Model)
if strings.TrimSpace(req.Model) != "" {
model = strings.TrimSpace(req.Model)
}
if model == "" {
return &exec.Result{Success: false, ErrorReason: "agent-native backend requires model"}, nil
return preflightFail("agent-native backend requires model"), nil
}
workDir := req.WorkDir
if !filepath.IsAbs(workDir) {
if strings.TrimSpace(workDir) == "" {
// 空工作区 = 请求未授权持久工作区:会话状态(.agentx/transcript 等)
// 落一次性临时目录,不污染进程 CWD(契约套件即发此类请求)。
tmp, err := os.MkdirTemp("", "agent-native-run-*")
if err != nil {
return preflightFail(err.Error()), nil
}
workDir = tmp
} else if !filepath.IsAbs(workDir) {
wd, _ := os.Getwd()
workDir = filepath.Join(wd, workDir)
}
if err := os.MkdirAll(workDir, 0o755); err != nil {
return &exec.Result{Success: false, ErrorReason: err.Error()}, nil
return preflightFail(err.Error()), nil
}
if strings.TrimSpace(req.Instructions) != "" {
_ = os.WriteFile(filepath.Join(workDir, "INSTRUCTIONS.md"), []byte(req.Instructions), 0o644)
@@ -190,13 +207,28 @@ func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result,
}
for _, name := range req.Skills {
if !indexSet[name] {
return &exec.Result{Success: false, ErrorReason: "declared skill not installed: " + name}, nil
return preflightFail("declared skill not installed: " + name), nil
}
}
writable := writableFrom(ctx)
// F02: a non-empty Writable cannot be enforced for exec/MCP/host tools —
// reject in preflight rather than pretend the write-set holds.
if len(writable) > 0 && profile == exec.ToolProfileWorkspace {
if b.opts.Exec != nil {
return preflightFail("writable write-set cannot be enforced when exec tools are enabled"), nil
}
if b.opts.MCP != nil {
return preflightFail("writable write-set cannot be enforced when MCP tools are enabled"), nil
}
if len(b.opts.HostTools) > 0 {
return preflightFail("writable write-set cannot be enforced when host tools are enabled"), nil
}
}
var reg *tools.Registry
if profile == exec.ToolProfileWorkspace {
reg = tools.WorkspaceToolsCheckpointed(workDir, filestate.New(), b.opts.Checkpoints, b.opts.CheckpointAnchor)
tools.ApplyWriteAllowlist(reg, writable)
skills.RegisterLoader(reg, skillStore)
if b.opts.Search != nil {
tools.RegisterSearch(reg, b.opts.Search)
@@ -220,7 +252,7 @@ func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result,
switch {
case len(req.ToolAllowlist) > 0 || b.opts.MCP.Ready():
if err := b.opts.MCP.RegisterInto(ctx, reg); err != nil {
return &exec.Result{Success: false, ErrorReason: err.Error()}, nil
return preflightFail(err.Error()), nil
}
default:
b.opts.MCP.StartBackground(ctx, reg)
@@ -229,32 +261,31 @@ func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result,
subagent.Register(reg, b.opts.Subagent)
for _, tool := range b.opts.HostTools {
if reg.Has(tool.Name()) {
return &exec.Result{Success: false, ErrorReason: "host tool name conflicts with a builtin: " + tool.Name()}, nil
return preflightFail("host tool name conflicts with a builtin: " + tool.Name()), nil
}
reg.Register(tool)
}
if err := reg.Restrict(req.ToolAllowlist); err != nil {
return &exec.Result{Success: false, ErrorReason: err.Error()}, nil
return preflightFail(err.Error()), nil
}
}
// Scope-keyed transcript: empty scope keeps the legacy fixed path.
transcript := transcriptPathForScope(workDir, req.SessionScope)
// 会话毒化轮转:上次失败属 resume-unsafe 时,把旧 transcript 归档,
// 本次以全新会话开始(续跑毒化会话只会复现同样的失败)。
b.rotateIfPoisoned(workDir)
store, err := session.Open(filepath.Join(workDir, ".agentx", "transcript.jsonl"))
b.rotateIfPoisoned(workDir, transcript)
store, err := session.Open(transcript)
if err != nil {
return &exec.Result{Success: false, ErrorReason: err.Error()}, nil
return preflightFail(err.Error()), nil
}
stream := b.opts.Stream
if stream == nil {
client := &llm.Client{BaseURL: b.opts.APIBase, APIKey: b.opts.APIKey}
stream = client.Stream
}
_ = store.SaveMeta(map[string]string{
"model": model,
"scope": req.SessionScope,
"exec_id": req.ExecutionID,
"updated": time.Now().UTC().Format(time.RFC3339),
})
if err := b.bindSessionScope(store, req.SessionScope, model, req.ExecutionID); err != nil {
return preflightFail(err.Error()), nil
}
b.maybeCompact(ctx, store, stream, model)
timeout := req.Timeout
@@ -300,36 +331,82 @@ func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result,
onProcess := req.OnEvent
runRes, err := b.run(runCtx, model, runBudget, reg, store, stream, profile, req, workDir, skillIndex, &logs, onProcess)
if err != nil {
return &exec.Result{Success: false, ErrorReason: err.Error(), Logs: logs.String()}, nil
// Persist/session transport failure after the run started: outcome uncertain.
return &exec.Result{
Success: false, ErrorReason: err.Error(), Logs: logs.String(),
Unknown: true, SideEffects: exec.SideEffectPossible,
}, nil
}
_ = os.WriteFile(filepath.Join(workDir, "execution.log"), []byte(logs.String()), 0o644)
// CapText sink: always capture the final assistant text for contract mapping.
captureText(ctx, runRes.Text)
// reasoning 档无工具:把最终文本写入仍为空的目标文件。
// 先授权全部 target,任一拒绝则零写入(避免 ../、绝对路径、符号链接逃逸先落地)。
if profile == exec.ToolProfileReasoning && strings.TrimSpace(runRes.Text) != "" {
type reasoningTarget struct {
path string
skip bool
}
authorized := make([]reasoningTarget, 0, len(req.Targets))
for _, target := range req.Targets {
path := filepath.Join(workDir, filepath.FromSlash(target))
if b2, err := os.ReadFile(path); err == nil && len(strings.TrimSpace(string(b2))) > 0 {
if !pathInWriteSet(writable, target) {
return &exec.Result{
Success: false, ErrorReason: "target is outside the declared writable set: " + target,
Logs: logs.String(), SideEffects: sideEffectsForTools(runRes.ToolCalls),
}, nil
}
path, resolveErr := tools.Resolve(workDir, filepath.FromSlash(target))
if resolveErr != nil {
return &exec.Result{
Success: false, ErrorReason: resolveErr.Error(), Logs: logs.String(),
SideEffects: sideEffectsForTools(runRes.ToolCalls),
}, nil
}
skip := false
if b2, readErr := os.ReadFile(path); readErr == nil && len(strings.TrimSpace(string(b2))) > 0 {
skip = true
}
authorized = append(authorized, reasoningTarget{path: path, skip: skip})
}
for _, at := range authorized {
if at.skip {
continue
}
_ = os.MkdirAll(filepath.Dir(path), 0o755)
_ = os.WriteFile(path, []byte(runRes.Text), 0o644)
_ = os.MkdirAll(filepath.Dir(at.path), 0o755)
_ = os.WriteFile(at.path, []byte(runRes.Text), 0o644)
}
}
if runRes.Err != nil {
reason := runRes.Err.Error()
b.noteFailure(workDir, reason)
canceled := runRes.Canceled || runRes.StopReason == llm.StopAborted
retryable := runRes.Retryable && !canceled
assessment := exec.AssessStepFailure(exec.StepFailureSignals{
Err: runRes.Err,
WorkDir: workDir,
Result: &exec.Result{ErrorReason: reason, Retryable: runRes.Retryable, Logs: logs.String()},
Result: &exec.Result{ErrorReason: reason, Retryable: retryable, Logs: logs.String()},
})
b.logger().Error("native step failure classified", runRes.Err,
"failure_class", assessment.Class.String(),
"work_dir", workDir)
res := &exec.Result{Success: false, ErrorReason: reason, Logs: logs.String(), Retryable: runRes.Retryable}
if runRes.StopReason == llm.StopAborted {
res.Retryable = true
"work_dir", workDir,
"canceled", canceled)
res := &exec.Result{
Success: false,
ErrorReason: reason,
Logs: logs.String(),
Retryable: retryable,
Canceled: canceled,
SideEffects: exec.SideEffectPossible,
}
if canceled {
// Aborted/canceled: never retryable; side effects may already exist.
res.Retryable = false
res.SideEffects = exec.SideEffectPossible
} else if runRes.ToolCalls == 0 {
res.SideEffects = exec.SideEffectNone
}
return res, nil
}
@@ -337,7 +414,14 @@ func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result,
patches, matErr := exec.MaterializeTargets(workDir, req.Targets, nil)
if matErr != nil {
return &exec.Result{Success: false, ErrorReason: matErr.Error(), Patches: patches, Logs: logs.String()}, nil
return &exec.Result{
Success: false, ErrorReason: matErr.Error(), Patches: patches, Logs: logs.String(),
SideEffects: exec.SideEffectPossible,
}, nil
}
side := sideEffectsForTools(runRes.ToolCalls)
if len(patches) > 0 {
side = exec.SideEffectPossible
}
return &exec.Result{
Success: true,
@@ -345,6 +429,7 @@ func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result,
Logs: logs.String(),
PromptTokens: runRes.Usage.PromptTokens,
CompletionTokens: runRes.Usage.CompletionTokens,
SideEffects: side,
}, nil
}
@@ -369,7 +454,9 @@ func (b *Backend) run(ctx context.Context, model string, budget *runtime.Budget,
Budget: budget,
Reminders: b.opts.Reminders,
RepeatGuard: b.opts.RepeatGuard,
ResultsDir: filepath.Join(workDir, ".agentx", "results"),
Hooks: gateBus(b.opts.GateOptions),
PermissionAsk: b.opts.PermissionAsk,
Trace: b.opts.Trace,
OnEvent: func(ev runtime.Event) {
logs.WriteString(ev.Type)
@@ -384,12 +471,19 @@ func (b *Backend) run(ctx context.Context, model string, budget *runtime.Budget,
logs.WriteByte('\n')
if onProcess != nil {
onProcess(exec.ProcessEvent{
Type: ev.Type,
Text: ev.Text,
Tool: ev.Tool,
Detail: ev.Detail,
PromptTokens: ev.PromptTokens,
CompletionTokens: ev.CompletionTokens,
Type: ev.Type,
Text: ev.Text,
Tool: ev.Tool,
Detail: ev.Detail,
CallID: ev.CallID,
Status: ev.Status,
ErrorReason: ev.ErrorReason,
PromptTokens: ev.PromptTokens,
CompletionTokens: ev.CompletionTokens,
LastPromptTokens: ev.LastPromptTokens,
LastCompletionTokens: ev.LastCompletionTokens,
CacheReadTokens: ev.CacheReadTokens,
CacheWriteTokens: ev.CacheWriteTokens,
})
}
},
@@ -499,6 +593,19 @@ type errString string
func (e errString) Error() string { return string(e) }
// preflightFail is a definitive rejection before any tool dispatch.
func preflightFail(reason string) *exec.Result {
return &exec.Result{Success: false, ErrorReason: reason, SideEffects: exec.SideEffectNone}
}
// sideEffectsForTools maps dispatched tool count to a SideEffects qualifier.
func sideEffectsForTools(toolCalls int) exec.SideEffects {
if toolCalls > 0 {
return exec.SideEffectPossible
}
return exec.SideEffectNone
}
// resumeUnsafeMarkers 续跑不安全的关键词(会话已被污染:上下文溢出/
// 迭代上限/非法请求——重试必须换新会话而非续跑)。
var resumeUnsafeMarkers = []string{
@@ -508,7 +615,7 @@ var resumeUnsafeMarkers = []string{
}
// rotateIfPoisoned 上次失败属 resume-unsafe 且存在旧会话时归档旧会话。
func (b *Backend) rotateIfPoisoned(workDir string) {
func (b *Backend) rotateIfPoisoned(workDir, transcript string) {
reason := strings.ToLower(b.failureReason(workDir))
if reason == "" {
return
@@ -523,7 +630,10 @@ func (b *Backend) rotateIfPoisoned(workDir string) {
if !unsafe {
return
}
path := filepath.Join(workDir, ".agentx", "transcript.jsonl")
path := transcript
if strings.TrimSpace(path) == "" {
path = filepath.Join(workDir, ".agentx", "transcript.jsonl")
}
if _, err := os.Stat(path); err != nil {
return
}
@@ -532,6 +642,36 @@ func (b *Backend) rotateIfPoisoned(workDir string) {
"transcript.poisoned."+time.Now().UTC().Format("20060102T150405.000000000")+".jsonl"))
}
// bindSessionScope records scope identity on the store and fail-closes when an
// existing store's recorded identity differs or is absent for a non-empty scope
// (do not auto-attribute old records to a new identity).
func (b *Backend) bindSessionScope(store *session.Store, scopeID, model, execID string) error {
meta := store.ReadMeta()
if meta == nil {
meta = map[string]string{}
}
if scopeID != "" {
prev := strings.TrimSpace(meta["scope"])
if prev != "" && prev != scopeID {
return errString("session store scope identity mismatch: store has " + prev + ", request has " + scopeID)
}
if prev == "" {
entries, err := store.Entries()
if err != nil {
return err
}
if len(entries) > 0 {
return errString("session store lacks scope identity; refusing to adopt for scope " + scopeID)
}
}
meta["scope"] = scopeID
}
meta["model"] = model
meta["exec_id"] = execID
meta["updated"] = time.Now().UTC().Format(time.RFC3339)
return store.SaveMeta(meta)
}
func (b *Backend) logger() *logsys.Logger {
if b == nil || b.opts.Log == nil {
return logsys.Nop()
+73
View File
@@ -111,6 +111,79 @@ func TestBackendReasoningProfileWritesText(t *testing.T) {
}
}
func TestBackendReasoningProfileRejectsPathEscape(t *testing.T) {
parent := t.TempDir()
dir := filepath.Join(parent, "work")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
outside := filepath.Join(parent, "pwned.md")
leak := "SHOULD-NOT-ESCAPE"
b := New(Options{
ModelOptions: ModelOptions{
Model: "fake",
Stream: script(
llm.Message{Role: llm.RoleAssistant, Content: leak, StopReason: llm.StopEnd},
),
},
})
// Traversal: authorize-before-write must reject and leave zero target writes.
res, err := b.Execute(context.Background(), exec.Request{
WorkDir: dir, Prompt: "escape", ToolProfile: exec.ToolProfileReasoning,
Targets: []string{"safe.md", "../pwned.md"},
})
if err != nil {
t.Fatal(err)
}
if res.Success || !strings.Contains(res.ErrorReason, "escapes workspace") {
t.Fatalf("want path escape failure, got %+v", res)
}
if _, statErr := os.Stat(outside); !os.IsNotExist(statErr) {
t.Fatalf("traversal must not create outside file: %v", statErr)
}
if _, statErr := os.Stat(filepath.Join(dir, "safe.md")); !os.IsNotExist(statErr) {
t.Fatal("rejected batch must perform zero target writes")
}
// Absolute path outside workDir.
res, err = b.Execute(context.Background(), exec.Request{
WorkDir: dir, Prompt: "escape-abs", ToolProfile: exec.ToolProfileReasoning,
Targets: []string{outside},
})
if err != nil {
t.Fatal(err)
}
if res.Success || !strings.Contains(res.ErrorReason, "escapes workspace") {
t.Fatalf("want absolute escape failure, got %+v", res)
}
if _, statErr := os.Stat(outside); !os.IsNotExist(statErr) {
t.Fatalf("absolute escape must not create outside file: %v", statErr)
}
// Symlink component leaving the workspace.
outsideDir := filepath.Join(parent, "outside-dir")
if err := os.MkdirAll(outsideDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outsideDir, filepath.Join(dir, "link")); err != nil {
t.Fatal(err)
}
res, err = b.Execute(context.Background(), exec.Request{
WorkDir: dir, Prompt: "escape-symlink", ToolProfile: exec.ToolProfileReasoning,
Targets: []string{"link/secret.md"},
})
if err != nil {
t.Fatal(err)
}
if res.Success || !strings.Contains(res.ErrorReason, "escapes workspace") {
t.Fatalf("want symlink escape failure, got %+v", res)
}
if _, statErr := os.Stat(filepath.Join(outsideDir, "secret.md")); !os.IsNotExist(statErr) {
t.Fatalf("symlink escape must not write outside: %v", statErr)
}
}
func TestBackendFailClosedWithoutConfig(t *testing.T) {
b := New(Options{})
res, err := b.Execute(context.Background(), exec.Request{WorkDir: t.TempDir(), Targets: []string{"x.md"}})
+109
View File
@@ -0,0 +1,109 @@
package native
import (
"context"
"errors"
"strings"
"agent-native/exec"
)
// contractBackend 把 Backend 适配为 exec.ContractBackend。
// 独立类型的原因:旧门面 Execute(ctx, exec.Request) 与契约的
// Execute(ctx, exec.ContractRequest) 签名冲突,同名方法不能并存;
// 包装让现有 squad 消费(旧门面)与契约消费(conformance/Core)互不影响。
type contractBackend struct{ b *Backend }
// Contract 返回该后端的契约形态(N1 WP6;旧门面保持不变)。
func (b *Backend) Contract() exec.ContractBackend {
return contractBackend{b: b}
}
func (c contractBackend) Descriptor() exec.BackendDescriptor {
return exec.BackendDescriptor{
Name: c.b.Name(),
Capabilities: []exec.Capability{exec.CapText, exec.CapStream, exec.CapTools},
}
}
func (c contractBackend) Execute(ctx context.Context, req exec.ContractRequest) (*exec.ContractResult, error) {
var onEvent func(ev exec.ProcessEvent)
if req.OnEvent != nil {
next := req.OnEvent
onEvent = func(ev exec.ProcessEvent) {
next(exec.Event{Kind: exec.EventKind(ev.Type), Text: ev.Text, Tool: ev.Tool, Detail: ev.Detail})
}
}
// 契约语义:空 ToolPolicy.Profile 等同 reasoning(无工具纯文本任务)。
profile := strings.TrimSpace(req.ToolPolicy.Profile)
if profile == "" {
profile = exec.ToolProfileReasoning
}
scopeID := scopeIdentity(req.SessionScope)
old := exec.Request{
Prompt: req.Prompt,
Instructions: req.Instructions,
ExecutionID: req.ExecutionID,
Targets: append([]string(nil), req.Targets...),
ToolProfile: profile,
ToolAllowlist: append([]string(nil), req.ToolPolicy.Allowlist...),
WorkDir: req.Workspace.Root,
SessionScope: scopeID,
OnEvent: onEvent,
}
if b := req.ContractBudget; b.MaxPromptTokens > 0 || b.MaxCompletionTokens > 0 || !b.Deadline.IsZero() {
old.Budget = &exec.Budget{
MaxPromptTokens: b.MaxPromptTokens,
MaxCompletionTokens: b.MaxCompletionTokens,
Deadline: b.Deadline,
}
}
// CapText / F15: pure-text contract tasks must not force implicit file delivery.
explicitTargets := len(req.Targets) > 0
if !explicitTargets && profile == exec.ToolProfileReasoning {
ctx = withAllowEmptyTargets(ctx)
}
ctx = withWritable(ctx, req.Workspace.Writable)
var finalText string
ctx = withTextSink(ctx, &finalText)
res, err := c.b.Execute(ctx, old)
if err != nil {
// 传输/实现级错误:按契约映射为 Unknown,不经 Go error 返回业务终态。
return &exec.ContractResult{
Outcome: exec.OutcomeUnknown,
Err: &exec.Error{Code: exec.ErrProviderUnavailable, Reason: err.Error(), SideEffects: exec.SideEffectPossible},
}, nil
}
out := &exec.ContractResult{
Logs: res.Logs,
Usage: exec.Usage{
PromptTokens: res.PromptTokens,
CompletionTokens: res.CompletionTokens,
},
}
switch {
case res.Success:
out.Outcome = exec.OutcomeSucceeded
if explicitTargets {
out.Files = res.Patches
}
// CapText: surface the real assistant text for pure-text success.
out.Text = finalText
if out.Text == "" && len(res.Patches) == 1 {
for _, body := range res.Patches {
out.Text = string(body)
}
}
case errors.Is(ctx.Err(), context.Canceled):
out.Outcome = exec.OutcomeCanceled
out.Err = &exec.Error{Code: exec.ErrCanceled, Reason: res.ErrorReason, SideEffects: exec.SideEffectPossible}
case errors.Is(ctx.Err(), context.DeadlineExceeded):
out.Outcome = exec.OutcomeFailed
out.Err = &exec.Error{Code: exec.ErrDeadlineExceeded, Reason: res.ErrorReason, SideEffects: exec.SideEffectPossible}
default:
out.Outcome = exec.OutcomeFailed
out.Err = &exec.Error{Code: exec.ErrProviderRejected, Reason: res.ErrorReason, Retryable: res.Retryable, SideEffects: exec.SideEffectPossible}
}
return out, nil
}
+91
View File
@@ -0,0 +1,91 @@
package native
import (
"context"
"crypto/sha256"
"encoding/hex"
"path/filepath"
"strings"
"agent-native/exec"
)
// Contract-binding context keys: Writable / CapText / empty-target semantics
// stay inside the adapter so exec.Request remains unchanged for the legacy facade.
type ctxKey int
const (
keyWritable ctxKey = iota
keyAllowEmptyTargets
keyTextSink
)
func withWritable(ctx context.Context, paths []string) context.Context {
if len(paths) == 0 {
return ctx
}
return context.WithValue(ctx, keyWritable, append([]string(nil), paths...))
}
func writableFrom(ctx context.Context) []string {
v, _ := ctx.Value(keyWritable).([]string)
return v
}
func withAllowEmptyTargets(ctx context.Context) context.Context {
return context.WithValue(ctx, keyAllowEmptyTargets, true)
}
func allowEmptyTargets(ctx context.Context) bool {
v, _ := ctx.Value(keyAllowEmptyTargets).(bool)
return v
}
func withTextSink(ctx context.Context, sink *string) context.Context {
if sink == nil {
return ctx
}
return context.WithValue(ctx, keyTextSink, sink)
}
func captureText(ctx context.Context, text string) {
if sink, ok := ctx.Value(keyTextSink).(*string); ok && sink != nil {
*sink = text
}
}
// scopeIdentity returns a stable non-empty identity string for a non-zero
// SessionScope, or "" for the legacy one-shot / empty-scope path.
func scopeIdentity(s exec.SessionScope) string {
t := strings.TrimSpace(s.Tenant)
p := strings.TrimSpace(s.Project)
sess := strings.TrimSpace(s.Session)
if t == "" && p == "" && sess == "" {
return ""
}
return t + "/" + p + "/" + sess
}
// transcriptPathForScope binds storage identity to SessionScope.
// Empty scope keeps the historical fixed path under .agentx/transcript.jsonl.
func transcriptPathForScope(workDir, scopeID string) string {
if strings.TrimSpace(scopeID) == "" {
return filepath.Join(workDir, ".agentx", "transcript.jsonl")
}
sum := sha256.Sum256([]byte(scopeID))
return filepath.Join(workDir, ".agentx", "transcript."+hex.EncodeToString(sum[:16])+".jsonl")
}
func pathInWriteSet(allow []string, rel string) bool {
if len(allow) == 0 {
return true
}
rel = filepath.ToSlash(filepath.Clean(strings.TrimSpace(rel)))
for _, a := range allow {
if filepath.ToSlash(filepath.Clean(strings.TrimSpace(a))) == rel {
return true
}
}
return false
}
+291
View File
@@ -0,0 +1,291 @@
package native
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"agent-native/exec"
"agent-native/runtime/llm"
"agent-native/runtime/session"
"agent-native/runtime/tools"
)
func TestContractScopeIsolationNoSharedTranscript(t *testing.T) {
dir := t.TempDir()
b := New(Options{
ModelOptions: ModelOptions{
Model: "fake",
Stream: script(
llm.Message{Role: llm.RoleAssistant, Content: "from-a", StopReason: llm.StopEnd},
),
},
}).Contract()
scopeA := exec.SessionScope{Tenant: "t1", Project: "p1", Session: "s-a"}
scopeB := exec.SessionScope{Tenant: "t1", Project: "p1", Session: "s-b"}
resA, err := b.Execute(context.Background(), exec.ContractRequest{
ExecutionID: "e-a",
Prompt: "hello-a",
SessionScope: scopeA,
Workspace: exec.WorkspaceHandle{Root: dir},
})
if err != nil || resA.Outcome != exec.OutcomeSucceeded {
t.Fatalf("scope A: %+v %v", resA, err)
}
// Fresh backend/stream for scope B so replies differ.
b2 := New(Options{
ModelOptions: ModelOptions{
Model: "fake",
Stream: script(
llm.Message{Role: llm.RoleAssistant, Content: "from-b", StopReason: llm.StopEnd},
),
},
}).Contract()
resB, err := b2.Execute(context.Background(), exec.ContractRequest{
ExecutionID: "e-b",
Prompt: "hello-b",
SessionScope: scopeB,
Workspace: exec.WorkspaceHandle{Root: dir},
})
if err != nil || resB.Outcome != exec.OutcomeSucceeded {
t.Fatalf("scope B: %+v %v", resB, err)
}
pathA := transcriptPathForScope(dir, scopeIdentity(scopeA))
pathB := transcriptPathForScope(dir, scopeIdentity(scopeB))
if pathA == pathB {
t.Fatal("different scopes must not share a transcript path")
}
if pathA == filepath.Join(dir, ".agentx", "transcript.jsonl") {
t.Fatal("non-empty scope must not use the legacy fixed transcript path")
}
storeA, err := session.Open(pathA)
if err != nil {
t.Fatal(err)
}
storeB, err := session.Open(pathB)
if err != nil {
t.Fatal(err)
}
msgsA, err := storeA.Load()
if err != nil {
t.Fatal(err)
}
msgsB, err := storeB.Load()
if err != nil {
t.Fatal(err)
}
joinedA := messagesBlob(msgsA)
joinedB := messagesBlob(msgsB)
if strings.Contains(joinedA, "hello-b") || strings.Contains(joinedA, "from-b") {
t.Fatalf("scope A transcript leaked B context: %s", joinedA)
}
if strings.Contains(joinedB, "hello-a") || strings.Contains(joinedB, "from-a") {
t.Fatalf("scope B transcript leaked A context: %s", joinedB)
}
}
func TestContractMatchingScopeCanResume(t *testing.T) {
dir := t.TempDir()
scope := exec.SessionScope{Tenant: "t", Project: "p", Session: "resume-me"}
b1 := New(Options{
ModelOptions: ModelOptions{
Model: "fake",
Stream: script(llm.Message{Role: llm.RoleAssistant, Content: "first", StopReason: llm.StopEnd}),
},
}).Contract()
if res, err := b1.Execute(context.Background(), exec.ContractRequest{
ExecutionID: "e1", Prompt: "turn-1", SessionScope: scope,
Workspace: exec.WorkspaceHandle{Root: dir},
}); err != nil || res.Outcome != exec.OutcomeSucceeded {
t.Fatalf("first turn: %+v %v", res, err)
}
var sawPrior bool
b2 := New(Options{
ModelOptions: ModelOptions{
Model: "fake",
Stream: func(ctx context.Context, model string, messages []llm.Message, decls []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
for _, m := range messages {
if m.Role == llm.RoleUser && m.Content == "turn-1" {
sawPrior = true
}
if m.Role == llm.RoleAssistant && m.Content == "first" {
sawPrior = true
}
}
ch := make(chan llm.Event, 2)
go func() {
defer close(ch)
ch <- llm.Event{Kind: llm.KindTextDelta, Text: "second"}
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{Role: llm.RoleAssistant, Content: "second", StopReason: llm.StopEnd}}
}()
return ch
},
},
}).Contract()
if res, err := b2.Execute(context.Background(), exec.ContractRequest{
ExecutionID: "e2", Prompt: "turn-2", SessionScope: scope,
Workspace: exec.WorkspaceHandle{Root: dir},
}); err != nil || res.Outcome != exec.OutcomeSucceeded {
t.Fatalf("resume turn: %+v %v", res, err)
}
if !sawPrior {
t.Fatal("matching scope must resume prior transcript context")
}
}
func TestContractOldStoreWithoutIdentityNotAdopted(t *testing.T) {
dir := t.TempDir()
scope := exec.SessionScope{Tenant: "t", Project: "p", Session: "orphan"}
path := transcriptPathForScope(dir, scopeIdentity(scope))
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
// Pre-seed records with no scope identity in meta.
body := "{\"role\":\"user\",\"content\":\"old\"}\n{\"role\":\"assistant\",\"content\":\"ghost\"}\n"
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
b := New(Options{
ModelOptions: ModelOptions{
Model: "fake",
Stream: script(llm.Message{Role: llm.RoleAssistant, Content: "new", StopReason: llm.StopEnd}),
},
}).Contract()
res, err := b.Execute(context.Background(), exec.ContractRequest{
ExecutionID: "e-orphan", Prompt: "hi", SessionScope: scope,
Workspace: exec.WorkspaceHandle{Root: dir},
})
if err != nil {
t.Fatalf("transport: %v", err)
}
if res.Outcome == exec.OutcomeSucceeded {
t.Fatal("must not auto-adopt a store lacking scope identity")
}
if res.Err == nil || !strings.Contains(res.Err.Reason, "scope identity") {
t.Fatalf("want scope-identity refusal, got %+v", res.Err)
}
}
func TestContractWritableFileToolHonorsAllowlist(t *testing.T) {
dir := t.TempDir()
b := New(Options{
ModelOptions: ModelOptions{
Model: "fake",
Stream: script(
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
ID: "c1", Name: "write_file", Arguments: `{"path":"allowed.md","content":"ok"}`,
}}},
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
ID: "c2", Name: "write_file", Arguments: `{"path":"denied.md","content":"no"}`,
}}},
llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd},
),
},
}).Contract()
res, err := b.Execute(context.Background(), exec.ContractRequest{
ExecutionID: "e-w",
Prompt: "write",
Workspace: exec.WorkspaceHandle{
Root: dir,
Writable: []string{"allowed.md"},
},
ToolPolicy: exec.ToolPolicy{Profile: exec.ToolProfileWorkspace},
Targets: []string{"allowed.md"},
})
if err != nil {
t.Fatal(err)
}
if res.Outcome != exec.OutcomeSucceeded {
// Tool denial is soft (IsError to model); run may still succeed if allowed.md exists.
t.Logf("outcome=%s err=%+v", res.Outcome, res.Err)
}
if _, err := os.Stat(filepath.Join(dir, "allowed.md")); err != nil {
t.Fatalf("allowed write missing: %v (outcome=%s)", err, res.Outcome)
}
if _, err := os.Stat(filepath.Join(dir, "denied.md")); !os.IsNotExist(err) {
t.Fatal("denied.md must not be written under Writable allowlist")
}
}
func TestContractWritableRejectsExecInPreflight(t *testing.T) {
dir := t.TempDir()
b := New(Options{
ModelOptions: ModelOptions{
Model: "fake",
Stream: script(llm.Message{Role: llm.RoleAssistant, Content: "x", StopReason: llm.StopEnd}),
},
ToolOptions: ToolOptions{
Exec: &tools.ExecConfig{Bash: "/bin/bash"},
},
}).Contract()
res, err := b.Execute(context.Background(), exec.ContractRequest{
ExecutionID: "e-exec",
Prompt: "x",
Workspace: exec.WorkspaceHandle{
Root: dir,
Writable: []string{"a.md"},
},
ToolPolicy: exec.ToolPolicy{Profile: exec.ToolProfileWorkspace},
Targets: []string{"a.md"},
})
if err != nil {
t.Fatal(err)
}
if res.Outcome == exec.OutcomeSucceeded {
t.Fatal("Writable + exec tools enabled must be rejected in preflight")
}
if res.Err == nil || !strings.Contains(res.Err.Reason, "writable") {
t.Fatalf("want writable preflight error, got %+v", res.Err)
}
}
func TestContractPureTextSuccessCarriesText(t *testing.T) {
dir := t.TempDir()
b := New(Options{
ModelOptions: ModelOptions{
Model: "fake",
Stream: script(llm.Message{Role: llm.RoleAssistant, Content: "纯文本答复内容", StopReason: llm.StopEnd}),
},
}).Contract()
res, err := b.Execute(context.Background(), exec.ContractRequest{
ExecutionID: "e-text",
Prompt: "说点什么",
Workspace: exec.WorkspaceHandle{Root: dir},
// empty ToolPolicy → reasoning; empty Targets → CapText, no file delivery
})
if err != nil {
t.Fatal(err)
}
if res.Outcome != exec.OutcomeSucceeded {
t.Fatalf("outcome=%s err=%+v", res.Outcome, res.Err)
}
if strings.TrimSpace(res.Text) == "" {
t.Fatal("CapText pure-text success must carry non-empty Text")
}
if !strings.Contains(res.Text, "纯文本答复内容") {
t.Fatalf("Text=%q", res.Text)
}
if len(res.Files) != 0 {
t.Fatalf("pure-text must not force file delivery, Files=%v", res.Files)
}
if _, err := os.Stat(filepath.Join(dir, "output.md")); !os.IsNotExist(err) {
t.Fatal("pure-text must not implicitly write output.md")
}
}
func messagesBlob(msgs []llm.Message) string {
var b strings.Builder
for _, m := range msgs {
b.WriteString(string(m.Role))
b.WriteByte(':')
b.WriteString(m.Content)
b.WriteByte('\n')
}
return b.String()
}
+113
View File
@@ -0,0 +1,113 @@
package native
import (
"context"
"os"
"path/filepath"
"testing"
"time"
"agent-native/conformance"
"agent-native/exec"
"agent-native/runtime/llm"
)
// TestContractBackendConformance 把 native 后端的契约形态挂上最小契约套件
// (N1 WP6:conformance 首次获得真实实现调用方)。
func TestContractBackendConformance(t *testing.T) {
conformance.Suite(t, func() exec.ContractBackend {
return New(Options{
ModelOptions: ModelOptions{
Model: "fake",
Stream: script(
llm.Message{Role: llm.RoleAssistant, Content: "hi", StopReason: llm.StopEnd},
),
},
}).Contract()
})
}
// TestContractExecuteCancel 取消路径:流阻塞到 ctx 结束,Outcome=Canceled 且
// SideEffects 显式。
func TestContractExecuteCancel(t *testing.T) {
b := New(Options{
ModelOptions: ModelOptions{
Model: "fake",
Stream: func(ctx context.Context, model string, messages []llm.Message, decls []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
ch := make(chan llm.Event)
go func() {
<-ctx.Done()
close(ch)
}()
return ch
},
},
}).Contract()
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
defer cancel()
res, err := b.Execute(ctx, exec.ContractRequest{ExecutionID: "cx", Prompt: "hi"})
if err != nil {
t.Fatalf("transport error: %v", err)
}
if res.Outcome != exec.OutcomeCanceled && res.Outcome != exec.OutcomeFailed {
t.Fatalf("outcome = %q", res.Outcome)
}
if res.Err == nil || res.Err.SideEffects == "" {
t.Fatalf("err = %+v", res.Err)
}
}
// TestContractDescriptor 描述的身份与能力。
func TestContractDescriptor(t *testing.T) {
d := New(Options{}).Contract().Descriptor()
if d.Name != "agent-native" {
t.Fatalf("name = %q", d.Name)
}
if !d.Has(exec.CapText) || !d.Has(exec.CapStream) || !d.Has(exec.CapTools) {
t.Fatalf("capabilities = %v", d.Capabilities)
}
}
// TestContractEmptyWorkspaceKeepsCWDClean 空 Workspace 的请求不得把会话状态
// 写进进程 CWD(CI 护栏:make test 后工作区必须保持干净;包目录的已提交
// .agentx 夹具内容必须逐字节不变)。
func TestContractEmptyWorkspaceKeepsCWDClean(t *testing.T) {
wd, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
snapshot := map[string][]byte{}
for _, rel := range []string{
filepath.Join(".agentx", "transcript.jsonl"),
filepath.Join(".agentx", "transcript.jsonl.meta.json"),
} {
if b, readErr := os.ReadFile(filepath.Join(wd, rel)); readErr == nil {
snapshot[rel] = b
}
}
b := New(Options{
ModelOptions: ModelOptions{
Model: "fake",
Stream: script(llm.Message{Role: llm.RoleAssistant, Content: "hi", StopReason: llm.StopEnd}),
},
}).Contract()
res, err := b.Execute(context.Background(), exec.ContractRequest{ExecutionID: "e-clean", Prompt: "hi"})
if err != nil {
t.Fatalf("transport error: %v", err)
}
if res.Outcome != exec.OutcomeSucceeded {
t.Fatalf("outcome = %q err = %+v", res.Outcome, res.Err)
}
for rel, want := range snapshot {
got, readErr := os.ReadFile(filepath.Join(wd, rel))
if readErr != nil {
t.Fatalf("夹具被删除:%s", rel)
}
if string(got) != string(want) {
t.Fatalf("空 Workspace 的执行改写了包内夹具 %s(应落临时目录)", rel)
}
}
if _, statErr := os.Stat(filepath.Join(wd, ".agentx", "activated-tools.json")); !os.IsNotExist(statErr) {
t.Fatal("空 Workspace 的执行在包目录写下了 activated-tools.json(应落临时目录)")
}
}
+111
View File
@@ -0,0 +1,111 @@
package native
import (
"context"
"encoding/json"
"testing"
"agent-native/exec"
"agent-native/runtime/llm"
"agent-native/runtime/tools"
)
type fidelityEcho struct {
fail bool
}
func (f fidelityEcho) Name() string { return "echo" }
func (f fidelityEcho) Description() string { return "echo" }
func (f fidelityEcho) Parameters() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
func (f fidelityEcho) Execute(context.Context, json.RawMessage) (tools.Result, error) {
if f.fail {
return tools.Result{Content: "native-boom", IsError: true}, nil
}
return tools.Result{Content: "ok"}, nil
}
func TestNativeProcessEventPassesCallIDAndStatus(t *testing.T) {
b := New(Options{
ModelOptions: ModelOptions{
Model: "fake",
Stream: script(
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{
{ID: "n-a", Name: "echo", Arguments: `{}`},
{ID: "n-b", Name: "echo", Arguments: `{}`},
}},
llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd},
),
},
ToolOptions: ToolOptions{
HostTools: []tools.Tool{fidelityEcho{}},
},
})
var starts, ends []exec.ProcessEvent
_, err := b.Execute(context.Background(), exec.Request{
WorkDir: t.TempDir(), Prompt: "go", Targets: []string{"output.md"},
OnEvent: func(ev exec.ProcessEvent) {
switch ev.Type {
case "tool_execution_start":
starts = append(starts, ev)
case "tool_execution_end":
ends = append(ends, ev)
}
},
})
if err != nil {
t.Fatal(err)
}
if len(starts) != 2 || len(ends) != 2 {
t.Fatalf("starts=%d ends=%d", len(starts), len(ends))
}
ids := map[string]bool{}
for _, ev := range starts {
if ev.CallID == "" {
t.Fatalf("start missing CallID: %+v", ev)
}
ids[ev.CallID] = true
}
if !ids["n-a"] || !ids["n-b"] {
t.Fatalf("expected distinct CallIDs n-a/n-b, got %v", ids)
}
for _, ev := range ends {
if ev.CallID == "" || !ids[ev.CallID] {
t.Fatalf("end CallID unpaired: %+v", ev)
}
if ev.Status != "ok" {
t.Fatalf("end Status=%q want ok: %+v", ev.Status, ev)
}
}
}
func TestNativeProcessEventFailingToolStatusError(t *testing.T) {
b := New(Options{
ModelOptions: ModelOptions{
Model: "fake",
Stream: script(
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
ID: "n-fail", Name: "echo", Arguments: `{}`,
}}},
llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd},
),
},
ToolOptions: ToolOptions{
HostTools: []tools.Tool{fidelityEcho{fail: true}},
},
})
var end exec.ProcessEvent
_, err := b.Execute(context.Background(), exec.Request{
WorkDir: t.TempDir(), Prompt: "go", Targets: []string{"output.md"},
OnEvent: func(ev exec.ProcessEvent) {
if ev.Type == "tool_execution_end" {
end = ev
}
},
})
if err != nil {
t.Fatal(err)
}
if end.CallID != "n-fail" || end.Status != "error" || end.ErrorReason != "native-boom" {
t.Fatalf("end = %+v", end)
}
}
+2 -2
View File
@@ -260,7 +260,7 @@ func TestBackendSkillsIndexAndLoader(t *testing.T) {
if err := os.MkdirAll(skillDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\ndescription: 生成文档交付物\n---\n\n配方正文"), 0o644); err != nil {
if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\nname: docx-author\ndescription: 生成文档交付物\n---\n\n配方正文"), 0o644); err != nil {
t.Fatal(err)
}
stream, c := captureStream(
@@ -347,7 +347,7 @@ func TestBackendWorkspaceSkillsRootAutoMerged(t *testing.T) {
if err := os.MkdirAll(wsSkill, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(wsSkill, "SKILL.md"), []byte("---\ndescription: 工作区技能\n---\n正文"), 0o644); err != nil {
if err := os.WriteFile(filepath.Join(wsSkill, "SKILL.md"), []byte("---\nname: ws-skill\ndescription: 工作区技能\n---\n正文"), 0o644); err != nil {
t.Fatal(err)
}
res, err := b.Execute(context.Background(), exec.Request{
+4
View File
@@ -0,0 +1,4 @@
execution_id=cx session=
agent_start
turn_start
message_start
+1
View File
@@ -0,0 +1 @@
hi
+1 -1
View File
@@ -20,7 +20,7 @@ func TestR2PoisonMarkerSurvivesNewProcess(t *testing.T) {
t.Fatal(err)
}
b := New(Options{})
b.rotateIfPoisoned(dir)
b.rotateIfPoisoned(dir, transcript)
if _, err := os.Stat(transcript); !os.IsNotExist(err) {
t.Fatal("poisoned transcript still at the live path")
}
+178
View File
@@ -0,0 +1,178 @@
package native
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
"agent-native/exec"
"agent-native/runtime/llm"
)
// TestExecuteCanceledIsNotRetryable: aborted/canceled runs carry Canceled
// semantics with Retryable=false and SideEffects=possible.
func TestExecuteCanceledIsNotRetryable(t *testing.T) {
dir := t.TempDir()
b := New(Options{
ModelOptions: ModelOptions{
Model: "fake",
Stream: func(ctx context.Context, model string, messages []llm.Message, decls []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
ch := make(chan llm.Event, 1)
go func() {
<-ctx.Done()
ch <- llm.Event{
Kind: llm.KindError,
Err: ctx.Err(),
Retryable: true, // provider may mark abort retryable; adapter must not.
Message: llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopAborted},
}
close(ch)
}()
return ch
},
},
})
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
defer cancel()
res, err := b.Execute(ctx, exec.Request{
WorkDir: dir, Prompt: "cancel me", Targets: []string{"output.md"},
})
if err != nil {
t.Fatalf("transport error: %v", err)
}
if res.Success {
t.Fatalf("canceled run must not succeed: %+v", res)
}
if !res.Canceled {
t.Fatalf("want Canceled=true, got %+v", res)
}
if res.Retryable {
t.Fatalf("canceled must not be Retryable: %+v", res)
}
if res.EffectiveSideEffects() != exec.SideEffectPossible {
t.Fatalf("canceled SideEffects = %q, want possible", res.EffectiveSideEffects())
}
}
// TestExecuteToolFailureSideEffectsPossible: failure after tools may have run
// reports SideEffects=possible.
func TestExecuteToolFailureSideEffectsPossible(t *testing.T) {
dir := t.TempDir()
var call int
b := New(Options{
ModelOptions: ModelOptions{
Model: "fake",
Stream: func(ctx context.Context, model string, messages []llm.Message, decls []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
ch := make(chan llm.Event, 2)
go func() {
defer close(ch)
call++
if call == 1 {
msg := llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
ID: "c1", Name: "write_file", Arguments: `{"path":"scratch.md","content":"side effect"}`,
}}}
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: msg}
return
}
ch <- llm.Event{
Kind: llm.KindError,
Err: errString("provider failed after tools"),
Message: llm.Message{Role: llm.RoleAssistant, Content: "boom", StopReason: llm.StopError},
}
}()
return ch
},
},
})
res, err := b.Execute(context.Background(), exec.Request{
WorkDir: dir, Prompt: "write then fail", Targets: []string{"output.md"},
})
if err != nil {
t.Fatalf("transport error: %v", err)
}
if res.Success || res.Canceled {
t.Fatalf("want definitive failure after tools, got %+v", res)
}
if res.SideEffects != exec.SideEffectPossible {
t.Fatalf("SideEffects = %q, want possible", res.SideEffects)
}
if _, statErr := os.Stat(filepath.Join(dir, "scratch.md")); statErr != nil {
t.Fatalf("tool side effect missing: %v", statErr)
}
}
// TestExecutePreflightSideEffectsNone: validation rejection before tools
// reports SideEffects=none.
func TestExecutePreflightSideEffectsNone(t *testing.T) {
cases := []struct {
name string
opts Options
req exec.Request
want string
}{
{
name: "missing-config",
opts: Options{},
req: exec.Request{WorkDir: t.TempDir(), Targets: []string{"x.md"}},
want: "requires",
},
{
name: "bad-profile",
opts: Options{ModelOptions: ModelOptions{Stream: script(), Model: "fake"}},
req: exec.Request{WorkDir: t.TempDir(), Targets: []string{"x.md"}, ToolProfile: "bogus"},
want: "tool profile",
},
{
name: "missing-model",
opts: Options{ModelOptions: ModelOptions{APIBase: "http://x", APIKey: "k"}},
req: exec.Request{WorkDir: t.TempDir(), Targets: []string{"x.md"}},
want: "model",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
res, err := New(tc.opts).Execute(context.Background(), tc.req)
if err != nil {
t.Fatalf("transport error: %v", err)
}
if res.Success || res.Canceled || res.Unknown || res.Retryable {
t.Fatalf("preflight must be definitive non-retry failure: %+v", res)
}
if res.SideEffects != exec.SideEffectNone {
t.Fatalf("SideEffects = %q, want none", res.SideEffects)
}
if !strings.Contains(res.ErrorReason, tc.want) {
t.Fatalf("ErrorReason = %q, want substring %q", res.ErrorReason, tc.want)
}
})
}
}
// TestExecuteSuccessSideEffectsReflectTools: success after tool dispatch
// reports SideEffects=possible; patches also imply possible.
func TestExecuteSuccessSideEffectsReflectTools(t *testing.T) {
dir := t.TempDir()
b := New(Options{
ModelOptions: ModelOptions{
Model: "fake",
Stream: script(
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
ID: "c1", Name: "write_file", Arguments: `{"path":"output.md","content":"ok"}`,
}}},
llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd},
),
},
})
res, err := b.Execute(context.Background(), exec.Request{
WorkDir: dir, Prompt: "write", Targets: []string{"output.md"},
})
if err != nil || !res.Success {
t.Fatalf("want success, got err=%v res=%+v", err, res)
}
if res.SideEffects != exec.SideEffectPossible {
t.Fatalf("SideEffects = %q, want possible", res.SideEffects)
}
}
Vendored Submodule
+1
+37
View File
@@ -54,9 +54,22 @@ type ProcessEvent struct {
Text string // delta 文本(Type 为 *_delta 时)
Tool string // 工具名(tool_* 时)
Detail string // 工具参数/结果摘要(tool_execution_* 时)
// CallID 是提供方 tool-call id;在 tool_call_delta / tool_execution_start /
// tool_execution_end 时填写。零值 = 旧行为(消费方仍可按 Tool 名启发式配对)。
CallID string `json:"call_id,omitempty"`
// Status 在 tool_execution_end 时填写:ok | error | denied | canceled。
// 空串 = legacy/unknown,不得臆造。
Status string `json:"status,omitempty"`
// ErrorReason 在 Status 为 error/denied/canceled 时携带真实失败文本。
ErrorReason string `json:"error_reason,omitempty"`
// PromptTokens / CompletionTokens 在 Type=usage 时填写。
PromptTokens int
CompletionTokens int
// 以下在 Type=usage 或 message_end 时填写(N3 WP6:上一请求与缓存命中拆分)。
LastPromptTokens int
LastCompletionTokens int
CacheReadTokens int
CacheWriteTokens int
}
const (
@@ -71,10 +84,34 @@ type Result struct {
ErrorReason string `json:"error_reason"`
// Retryable marks a provider-transient failure that may receive a
// fresh step invocation. Permanent missing deliverables stay false.
// Canceled and Unknown are never retryable; do not set Retryable with them.
Retryable bool `json:"retryable,omitempty"`
// PromptTokens and CompletionTokens are set by pi-native when the provider reports usage.
PromptTokens int `json:"prompt_tokens,omitempty"`
CompletionTokens int `json:"completion_tokens,omitempty"`
// ---- terminal fidelity (optional; zero-value = legacy behavior) ----
// Canceled marks an aborted/canceled run. Distinct from Retryable:
// a canceled action must not be auto-retried (Retryable must stay false).
Canceled bool `json:"canceled,omitempty"`
// Unknown marks that execution reached the provider/tool stage and the
// terminal outcome could not be determined (side effects may exist).
// Unknown is not a retryable failure.
Unknown bool `json:"unknown,omitempty"`
// SideEffects qualifies external side-effect risk for retry/fallback.
// Empty means SideEffectPossible — the safe legacy reading. Set
// SideEffectNone only when no tool dispatch (or equivalent) is provable.
SideEffects SideEffects `json:"side_effects,omitempty"`
}
// EffectiveSideEffects returns the side-effect qualifier for retry decisions.
// An empty SideEffects value means SideEffectPossible (backward compatible).
func (r *Result) EffectiveSideEffects() SideEffects {
if r == nil || r.SideEffects == "" {
return SideEffectPossible
}
return r.SideEffects
}
type Backend interface {
+47
View File
@@ -0,0 +1,47 @@
package exec
import (
"encoding/json"
"strings"
"testing"
)
// TestProcessEventLegacyOmitempty:零值新字段不出现在 JSON 中,旧消费方
// 忽略未知字段时看到的遗留载荷与仅填 Type/Tool/Text/Detail 一致。
func TestProcessEventLegacyOmitempty(t *testing.T) {
legacy := ProcessEvent{Type: "tool_execution_end", Tool: "echo", Text: "ran", Detail: "{}"}
withZeros := ProcessEvent{
Type: "tool_execution_end", Tool: "echo", Text: "ran", Detail: "{}",
CallID: "", Status: "", ErrorReason: "",
}
a, err := json.Marshal(legacy)
if err != nil {
t.Fatal(err)
}
b, err := json.Marshal(withZeros)
if err != nil {
t.Fatal(err)
}
if string(a) != string(b) {
t.Fatalf("zero new fields changed JSON:\n legacy=%s\n zeros=%s", a, b)
}
if strings.Contains(string(a), "call_id") || strings.Contains(string(a), "status") ||
strings.Contains(string(a), "error_reason") {
t.Fatalf("omitempty leaked zero fields: %s", a)
}
populated := ProcessEvent{
Type: "tool_execution_end", Tool: "echo", Text: "boom",
CallID: "c1", Status: "error", ErrorReason: "boom",
}
raw, err := json.Marshal(populated)
if err != nil {
t.Fatal(err)
}
s := string(raw)
for _, want := range []string{`"call_id":"c1"`, `"status":"error"`, `"error_reason":"boom"`} {
if !strings.Contains(s, want) {
t.Fatalf("missing %s in %s", want, s)
}
}
}
+30
View File
@@ -0,0 +1,30 @@
package exec_test
import (
"testing"
"agent-native/exec"
)
// TestResultZeroValueKeepsLegacyMeaning pins backward compatibility:
// empty SideEffects reads as possible; Canceled/Unknown stay false.
func TestResultZeroValueKeepsLegacyMeaning(t *testing.T) {
var r exec.Result
if r.Success || r.Retryable || r.Canceled || r.Unknown {
t.Fatalf("zero Result must keep legacy false flags, got %+v", r)
}
if r.SideEffects != "" {
t.Fatalf("zero SideEffects must be empty (legacy), got %q", r.SideEffects)
}
if got := r.EffectiveSideEffects(); got != exec.SideEffectPossible {
t.Fatalf("EffectiveSideEffects() = %q, want %q", got, exec.SideEffectPossible)
}
r.SideEffects = exec.SideEffectNone
if got := r.EffectiveSideEffects(); got != exec.SideEffectNone {
t.Fatalf("explicit none: EffectiveSideEffects() = %q", got)
}
r.SideEffects = exec.SideEffectPossible
if got := r.EffectiveSideEffects(); got != exec.SideEffectPossible {
t.Fatalf("explicit possible: EffectiveSideEffects() = %q", got)
}
}
+22 -9
View File
@@ -1,29 +1,31 @@
# 通用规范采纳记录
- 项目/负责人:Agent-Native / 仓库所有者 weichen
- 项目/负责人:Agent-Native / weichen
- 状态:active
- 规范位置:`deps/development-guidelines`
- 规范性文档目录:`policy/`
- 过程文档位置:`docs/records/`(项目内固定目录豁免。当前为个人维护的基础库,单独过程仓会让每份记录都变成跨仓 gitlink;任务主记录仍在 Gitea Issue/PR。豁免一经登记,过程记录不得再写入 `policy/` 或规范库。)
- 版本权威:父仓 gitlink;可读标签:无(内容版本 `0.1.0-draft.6`,提交 `26b562d2e34fbc560711296a309c91c74ffc55b1`)
- 批准/生效依据:本仓接入提交;草案审阅完成后随该提交生效
- 替代范围和迁移说明:本仓此前没有根 `AGENTS.md`。接入后以本文件与根入口为准,不在规范库内保存项目事实。
- 过程文档位置:任务卡在 `.task/`(模板 `.task/templates/task-card.md`);过程记录在 `docs/records/`(项目内固定目录豁免。当前为个人维护的基础库,单独过程仓会让每份记录都变成跨仓 gitlink;任务主记录在 PR 描述中。豁免一经登记,过程记录不得再写入 `policy/` 或规范库。)
- 版本权威:本仓 gitlink `deps/development-guidelines` @ `2688fd2be6a5`(完整 `2688fd2be6a52c0bf110e7e6366b6f291ab5e4eb`,内容版本 `0.1.0-draft.12`);可读标签:无
- 批准/生效依据:`feature/governance-adoption PR(Agent-Native)`;合入前须在 `.task/feature-governance-adoption.md` 记录正负例、Review 与 CI 证据
- 替代范围和迁移说明:无替代项。既有 `AGENTS.md`、`PROJECT.md` 内容保留,本次只向二者追加内容(「Agent 执行与新项目准入(强制)」段与验证命令);`AGENT-EXECUTION.md` 非既有文件,由本次采纳新增(治理引导提交 b1034e1)。新增共享执行器入口 `scripts/dm`、`scripts/agent-exec`、`scripts/task`,`scripts/hooks/pre-commit`(待 host 启用 `core.hooksPath`)与 `.gitea/workflows/governance.yml`。
| 规范/工作流 | 适用条件与范围 | 采纳或不适用理由 | 执行方式/证据 |
|---|---|---|---|
| core/baseline.md | 日常工作 | 所有改动都要守硬边界 | manual |
| workflows/delivery.md、debugging.md、handoff.md | 交付、排障与交接 | 单人维护仍需要可交接的任务记录 | manual |
| workflows/delivery.md、debugging.md、handoff.md | 交付、排障与交接 | 单人维护仍需要可交接的任务记录(`.task/` 任务卡 + PR 主记录) | manual |
| standards/collaboration.md | 并行共同起点、节点同步与陈旧候选拒绝 | 本仓会被 Agent-Squad 以 gitlink 消费 | manual |
| standards/verification.md | 验证与结论 | 仓库根 `go vet` / `go test` | manual;命令见 PROJECT.md |
| standards/merge-queue.md | 多个就绪任务的最终同步、CI、Review 与合入调度 | 不适用:个人维护的单仓,合入由仓库所有者串行完成,无多任务就绪队列 | 不适用 |
| standards/verification.md | 验证与结论 | 仓库根 `go vet` / `go test`;治理与执行配置命令见 PROJECT.md「验证」节 | manual;命令见 PROJECT.md |
| workflows/design-verification.md、standards/testability.md、standards/failure-containment.md | 契约、适配器与故障分类 | 执行器失败必须可定位且不扩大工具范围 | manual |
| standards/module-contracts.md | contract/exec/runtime/adapters 边界 | 公开端口已按层拆分 | manual |
| standards/observability.md | 错误码、过程事件 | 与 Error-Code-System / Logging-System 对齐 | manual |
| standards/agent-execution.md | 能力协商与后端回退 | 本仓就是执行契约的权威 | manual |
| standards/multi-repo.md | submodule 与固定组合 | 规范与基础库以 gitlink 锁定 | manual |
| standards/agent-execution.md | 工程 Agent CLI 执行路由、会话隔离与受控集成;模型能力协商与回退 | 本仓就是执行契约的权威。AI 实施单元只经 `AGENT-EXECUTION.md` 登记的候选由 `scripts/agent-exec run` 执行,并经 `scripts/agent-exec apply` 集成,禁止 host 代写 | `AGENT-EXECUTION.md` + `scripts/agent-exec`(validate/admit);证据:`governance check + 正负例(.task/feature-governance-adoption.md)` |
| standards/multi-repo.md | submodule 与固定版本组合 | 规范与基础库以 gitlink 锁定 | manual |
| standards/security.md | 路径授权、工具范围、凭证 | 凭证不入库;路径授权不等于 OS 沙箱 | manual |
| standards/documentation.md | 三层文档 | `policy/` 与 `docs/records/` 已分开 | manual |
| standards/data-reliability.md | 执行副作用与重放 | 无本仓数据库;适用副作用分类,不扩展成存储设计 | manual |
| standards/configuration.md | 调用方注入的模型与工具配置 | 本仓不自带多实例部署配置 | manual |
| standards/tool-adaptation.md | 为工具创建规则/钩子、接入新客户端 | 适用于 `scripts/hooks/pre-commit`(git 钩子,待 host 启用);本仓不新增工具规则或客户端接入 | manual |
| standards/release-operations.md | 独立发布流水线 | 当前无制品仓库与部署入口 | 不适用 |
| workflows/automation.md | 定时与后台任务 | 本仓无 daemon | 不适用 |
@@ -37,6 +39,8 @@
## 本次升级或首次接入验证
### 2026-09-29 首次接入(历史记录,版本号为当时值)
- 入口:根 `AGENTS.md`、`PROJECT.md` 指向 `deps/development-guidelines`。
- 子模块:`deps/development-guidelines` 固定 `26b562d2e34fbc560711296a309c91c74ffc55b1`。
- `.gitmodules` 使用批准的上游 SSH URL,不含本机路径或凭证。
@@ -49,3 +53,12 @@
- 依赖边界新增:唯 `adapters/intentgate` 可 import agent-intent;不使用该包时核心与 agent-intent 无编译关系。
- 接入与验证记录见 [docs/records/2026-09-29-intentgate-adoption.md](../docs/records/2026-09-29-intentgate-adoption.md)。
- 回退:删除 `adapters/intentgate/`、go.mod 的 agent-intent require/replace、`.gitmodules` 对应条目与 submodule,`scripts/submodule-consistency.sh` 移除该路径。
### 2026-10-17 治理套件采纳(feature/governance-adoption)
- 入口:根 `AGENTS.md`、`PROJECT.md`、`AGENT-EXECUTION.md` 指向 `deps/development-guidelines`;相对链接须可解析。
- 固定版本:`deps/development-guidelines` 由 `26b562d2` 前进到 `2688fd2be6a52c0bf110e7e6366b6f291ab5e4eb`;`deps/development-manager` 固定 `bd3a7de57a3a9e1a4495b94c5fa5c08d71e7be8c`(`.gitmodules` 条目与 gitlink 由执行器记录)。
- 共享执行器:`scripts/dm`(读取 `deps/development-manager` 钉住提交)、`scripts/agent-exec`、`scripts/task` 为薄入口;`.task/templates/task-card.md` 为任务卡模板。
- CI 门禁:`.gitea/workflows/governance.yml` 执行 `scripts/agent-exec validate --repo .` 与 `scripts/dm governance check --repo .`;除 agent-execution 外的专题保持 manual。
- 检查:本文件无尖括号占位符;暂存副本内 `scripts/dm governance check --repo .` 与 `scripts/agent-exec validate --repo .` 的输出,以及正负例、Review 与 CI 证据,合入前在 `.task/feature-governance-adoption.md` 补齐(当前为 NOT_RUN)。
- 回退:把 `deps/development-guidelines` 恢复到 `26b562d2e34fbc560711296a309c91c74ffc55b1`,删除 `deps/development-manager` 条目,删除 `scripts/dm`、`scripts/agent-exec`、`scripts/task`、`scripts/hooks/`、`.gitea/workflows/governance.yml` 与 `.task/templates/task-card.md`,并从 `AGENTS.md` 删除「Agent 执行与新项目准入(强制)」段。
+216
View File
@@ -0,0 +1,216 @@
// Package agentspec 定义子代理画像(AgentSpec)与 agent-*.md 文件形态。
// 宿主与内置画像可声明全部字段;插件来源的画像只允许身份与工具面,
// 不得声明钩子、MCP 与权限模式(fail-closed,错误定位到字段)。
package agentspec
import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
)
// AgentSpec 是一个子代理画像。字段对齐公开子代理 frontmatter 约定,
// 另加 outputSchema。空字段表示沿用宿主内置。
type AgentSpec struct {
Name string
Description string
Prompt string // 正文(frontmatter 之后的内容)
Tools []string
DisallowedTools []string
Model string
PermissionMode string
Skills []string
MaxTurns int
MemoryScope string
Effort string
Background bool
WorktreeIsolation bool
OutputSchema string
}
// pluginBannedKeys 是插件来源画像禁带的键:钩子、MCP 与权限模式归宿主与基座。
var pluginBannedKeys = map[string]bool{
"hooks": true,
"mcp": true,
"permissionMode": true,
}
// Parse 解析宿主/内置画像文件:frontmatter(--- 包裹的 key: value 行,
// 列表值用逗号分隔)+ 正文。未知键报错;name 必填且须小写连字符。
func Parse(content string) (AgentSpec, error) {
return parse(content, nil)
}
// ParsePlugin 解析插件来源画像文件:在 Parse 之上禁带 hooks/mcp/permissionMode 键。
func ParsePlugin(content string) (AgentSpec, error) {
return parse(content, pluginBannedKeys)
}
func parse(content string, banned map[string]bool) (AgentSpec, error) {
fm, body, err := splitFrontmatter(content)
if err != nil {
return AgentSpec{}, err
}
var spec AgentSpec
for key, value := range fm {
if banned[key] {
return AgentSpec{}, fmt.Errorf("agentspec: 插件画像不得声明 %s", key)
}
if err := assign(&spec, key, value); err != nil {
return AgentSpec{}, err
}
}
spec.Prompt = strings.TrimSpace(body)
if spec.Name == "" {
return AgentSpec{}, fmt.Errorf("agentspec: name required")
}
if !validName(spec.Name) {
return AgentSpec{}, fmt.Errorf("agentspec: name %q 须为小写连字符(如 doc-reviewer)", spec.Name)
}
return spec, nil
}
// LoadDir 整批加载目录下的 *.md 画像:任一文件解析失败则全批拒绝;
// 重名拒绝。parser 按来源选 Parse 或 ParsePlugin。返回按 Name 索引。
func LoadDir(dir string, parser func(string) (AgentSpec, error)) (map[string]AgentSpec, error) {
if parser == nil {
parser = Parse
}
names, err := filepath.Glob(filepath.Join(dir, "*.md"))
if err != nil {
return nil, fmt.Errorf("agentspec: glob %s: %w", dir, err)
}
out := map[string]AgentSpec{}
for _, name := range names {
raw, err := os.ReadFile(name)
if err != nil {
return nil, fmt.Errorf("agentspec: %s: %w", filepath.Base(name), err)
}
spec, err := parser(string(raw))
if err != nil {
return nil, fmt.Errorf("agentspec: %s: %w", filepath.Base(name), err)
}
if _, dup := out[spec.Name]; dup {
return nil, fmt.Errorf("agentspec: duplicate name %q in %s", spec.Name, dir)
}
out[spec.Name] = spec
}
return out, nil
}
func splitFrontmatter(content string) (map[string]string, string, error) {
text := strings.ReplaceAll(content, "\r\n", "\n")
if !strings.HasPrefix(text, "---\n") {
return nil, "", fmt.Errorf("agentspec: missing frontmatter(须以 --- 开头)")
}
rest := text[len("---\n"):]
end := strings.Index(rest, "\n---")
if end < 0 {
return nil, "", fmt.Errorf("agentspec: frontmatter 未闭合(缺 --- 结尾)")
}
fm := map[string]string{}
for _, line := range strings.Split(rest[:end], "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
i := strings.Index(line, ":")
if i <= 0 {
return nil, "", fmt.Errorf("agentspec: 无法解析的行 %q(须为 key: value)", line)
}
fm[strings.TrimSpace(line[:i])] = strings.TrimSpace(line[i+1:])
}
body := rest[end+len("\n---"):]
body = strings.TrimLeft(body, "\n")
return fm, body, nil
}
func assign(spec *AgentSpec, key, value string) error {
switch key {
case "name":
spec.Name = value
case "description":
spec.Description = value
case "tools":
spec.Tools = splitList(value)
case "disallowedTools":
spec.DisallowedTools = splitList(value)
case "model":
spec.Model = value
case "permissionMode":
spec.PermissionMode = value
case "skills":
spec.Skills = splitList(value)
case "maxTurns":
n, err := strconv.Atoi(value)
if err != nil || n < 0 {
return fmt.Errorf("agentspec: maxTurns %q 须为非负整数", value)
}
spec.MaxTurns = n
case "memoryScope":
spec.MemoryScope = value
case "effort":
spec.Effort = value
case "background":
b, err := parseBool(key, value)
if err != nil {
return err
}
spec.Background = b
case "worktreeIsolation":
b, err := parseBool(key, value)
if err != nil {
return err
}
spec.WorktreeIsolation = b
case "outputSchema":
spec.OutputSchema = value
default:
return fmt.Errorf("agentspec: 未知键 %q", key)
}
return nil
}
func parseBool(key, value string) (bool, error) {
switch value {
case "true":
return true, nil
case "false":
return false, nil
default:
return false, fmt.Errorf("agentspec: %s %q 须为 true/false", key, value)
}
}
func splitList(value string) []string {
if strings.TrimSpace(value) == "" {
return nil
}
parts := strings.Split(value, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if s := strings.TrimSpace(p); s != "" {
out = append(out, s)
}
}
return out
}
func validName(name string) bool {
if name == "" || strings.HasPrefix(name, "-") || strings.HasSuffix(name, "-") {
return false
}
for _, seg := range strings.Split(name, "-") {
if seg == "" {
return false
}
for _, r := range seg {
if (r < 'a' || r > 'z') && (r < '0' || r > '9') {
return false
}
}
}
return true
}
+131
View File
@@ -0,0 +1,131 @@
package agentspec
import (
"os"
"path/filepath"
"strings"
"testing"
)
const goodSpec = `---
name: doc-reviewer
description: 评审体系文档章节
tools: read_file, grep, write_file
disallowedTools: run_shell
model: lightweight
skills: house-style, rdm-review
maxTurns: 8
memoryScope: project
effort: high
background: true
worktreeIsolation: false
outputSchema: review-result
---
你是章节评审代理。逐条核对来源标注。
`
func TestParseFullFields(t *testing.T) {
spec, err := Parse(goodSpec)
if err != nil {
t.Fatal(err)
}
if spec.Name != "doc-reviewer" || spec.Description == "" {
t.Fatalf("identity = %+v", spec)
}
if len(spec.Tools) != 3 || spec.Tools[2] != "write_file" {
t.Fatalf("tools = %v", spec.Tools)
}
if len(spec.DisallowedTools) != 1 || spec.DisallowedTools[0] != "run_shell" {
t.Fatalf("disallowed = %v", spec.DisallowedTools)
}
if spec.Model != "lightweight" || spec.MaxTurns != 8 || spec.MemoryScope != "project" || spec.Effort != "high" {
t.Fatalf("scalars = %+v", spec)
}
if !spec.Background || spec.WorktreeIsolation {
t.Fatalf("bools = %+v", spec)
}
if len(spec.Skills) != 2 || spec.OutputSchema != "review-result" {
t.Fatalf("skills/schema = %+v", spec)
}
if spec.Prompt == "" {
t.Fatal("prompt body empty")
}
}
func TestParseDefaultsAndMinimal(t *testing.T) {
spec, err := Parse("---\nname: helper\n---\n做杂事。\n")
if err != nil {
t.Fatal(err)
}
if spec.MaxTurns != 0 || spec.Background || len(spec.Tools) != 0 || spec.PermissionMode != "" {
t.Fatalf("defaults = %+v", spec)
}
if spec.Prompt != "做杂事。" {
t.Fatalf("prompt = %q", spec.Prompt)
}
}
func TestParseRejectsBadInputs(t *testing.T) {
cases := map[string]string{
"missing frontmatter": "name: x\n---\n正文\n",
"unclosed frontmatter": "---\nname: x\n正文\n",
"unknown key": "---\nname: x\nbogus: 1\n---\n正文\n",
"missing name": "---\ndescription: x\n---\n正文\n",
"bad name uppercase": "---\nname: Bad\n---\n正文\n",
"bad name underscore": "---\nname: has_under\n---\n正文\n",
"bad maxTurns": "---\nname: x\nmaxTurns: many\n---\n正文\n",
"bad bool": "---\nname: x\nbackground: yes\n---\n正文\n",
"malformed line": "---\nname: x\nno-colon-here\n---\n正文\n",
}
for label, content := range cases {
if _, err := Parse(content); err == nil {
t.Fatalf("%s: expected error", label)
}
}
}
func TestParsePluginBansHostOnlyKeys(t *testing.T) {
for _, key := range []string{"hooks", "mcp", "permissionMode"} {
content := "---\nname: plug-agent\n" + key + ": anything\n---\n正文\n"
if _, err := ParsePlugin(content); err == nil {
t.Fatalf("plugin key %s: expected rejection", key)
} else if got := err.Error(); !strings.Contains(got, "不得声明 "+key) {
t.Fatalf("plugin key %s: error %q 未定位到字段", key, got)
}
}
// 宿主来源不受此限:permissionMode 在 Parse 下可用。
if _, err := Parse("---\nname: host-agent\npermissionMode: plan\n---\n正文\n"); err != nil {
t.Fatalf("host parse with permissionMode: %v", err)
}
// 插件的合法字段照常解析。
spec, err := ParsePlugin("---\nname: plug-agent\ntools: read_file\n---\n正文\n")
if err != nil || len(spec.Tools) != 1 {
t.Fatalf("plugin good spec: %+v err=%v", spec, err)
}
}
func TestLoadDirAtomicAndDuplicate(t *testing.T) {
dir := t.TempDir()
write := func(name, content string) {
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
write("a.md", "---\nname: alpha\n---\nA\n")
write("b.md", "---\nname: beta\n---\nB\n")
got, err := LoadDir(dir, nil)
if err != nil || len(got) != 2 {
t.Fatalf("load = %d err=%v", len(got), err)
}
write("c.md", "---\nname: alpha\n---\nC\n")
if _, err := LoadDir(dir, nil); err == nil {
t.Fatal("duplicate name accepted")
}
os.Remove(filepath.Join(dir, "c.md"))
write("bad.md", "---\nname: x\nbogus: 1\n---\n")
if _, err := LoadDir(dir, nil); err == nil {
t.Fatal("bad file did not fail the batch")
}
}
+85
View File
@@ -0,0 +1,85 @@
// Package argschema checks tool-call arguments against the small JSON-schema
// subset that tool declarations use. It has no dependencies beyond the
// standard library so both the tool registry and the LLM client can share it.
package argschema
import (
"encoding/json"
"fmt"
"strings"
)
// Validate reports whether args satisfy schema: the top-level type is object,
// required keys are present and not blank strings, declared properties have
// their primitive type, and unknown keys are rejected when properties are
// declared.
func Validate(schema, args json.RawMessage) error {
var spec struct {
Type string `json:"type"`
Required []string `json:"required"`
Properties map[string]json.RawMessage `json:"properties"`
}
if len(schema) > 0 {
if err := json.Unmarshal(schema, &spec); err != nil {
return fmt.Errorf("invalid tool schema")
}
}
var obj map[string]any
if err := json.Unmarshal(args, &obj); err != nil {
return fmt.Errorf("arguments must be a JSON object")
}
if spec.Type != "" && spec.Type != "object" {
return fmt.Errorf("unsupported schema type %s", spec.Type)
}
for _, req := range spec.Required {
v, ok := obj[req]
if !ok || v == nil {
return fmt.Errorf("missing required argument %s", req)
}
if s, ok := v.(string); ok && strings.TrimSpace(s) == "" {
return fmt.Errorf("missing required argument %s", req)
}
}
for key, raw := range spec.Properties {
v, ok := obj[key]
if !ok {
continue
}
var prop struct {
Type string `json:"type"`
}
if err := json.Unmarshal(raw, &prop); err != nil || prop.Type == "" {
continue
}
if !matchesType(prop.Type, v) {
return fmt.Errorf("argument %s must be %s", key, prop.Type)
}
}
if len(spec.Properties) > 0 {
for key := range obj {
if _, ok := spec.Properties[key]; !ok {
return fmt.Errorf("unknown argument %s", key)
}
}
}
return nil
}
func matchesType(kind string, v any) bool {
switch kind {
case "string":
_, ok := v.(string)
return ok
case "number":
_, ok := v.(float64)
return ok
case "integer":
f, ok := v.(float64)
return ok && f == float64(int64(f))
case "boolean":
_, ok := v.(bool)
return ok
default:
return true
}
}
+44
View File
@@ -0,0 +1,44 @@
package argschema
import (
"encoding/json"
"testing"
)
func TestValidate(t *testing.T) {
schema := json.RawMessage(`{"type":"object","required":["path"],"properties":{"path":{"type":"string"},"limit":{"type":"integer"},"ratio":{"type":"number"},"all":{"type":"boolean"},"any":{}}}`)
tests := []struct {
name string
schema json.RawMessage
args string
want string
}{
{"valid", schema, `{"path":"a.txt","limit":3,"ratio":0.5,"all":true,"any":[1]}`, ""},
{"no-schema-accepts-object", nil, `{"x":1}`, ""},
{"no-properties-accepts-unknown", json.RawMessage(`{"type":"object"}`), `{"x":1}`, ""},
{"invalid-schema", json.RawMessage(`[`), `{}`, "invalid tool schema"},
{"array-arguments", schema, `[1]`, "arguments must be a JSON object"},
{"broken-arguments", schema, `{"path":`, "arguments must be a JSON object"},
{"non-object-schema", json.RawMessage(`{"type":"array"}`), `{}`, "unsupported schema type array"},
{"missing-required", schema, `{}`, "missing required argument path"},
{"null-required", schema, `{"path":null}`, "missing required argument path"},
{"blank-required", schema, `{"path":" "}`, "missing required argument path"},
{"wrong-string", schema, `{"path":7}`, "argument path must be string"},
{"fractional-integer", schema, `{"path":"a","limit":1.5}`, "argument limit must be integer"},
{"wrong-number", schema, `{"path":"a","ratio":"x"}`, "argument ratio must be number"},
{"wrong-boolean", schema, `{"path":"a","all":1}`, "argument all must be boolean"},
{"unknown-key", schema, `{"path":"a","extra":1}`, "unknown argument extra"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := Validate(tc.schema, json.RawMessage(tc.args))
got := ""
if err != nil {
got = err.Error()
}
if got != tc.want {
t.Fatalf("Validate(%s, %s) = %q, want %q", tc.schema, tc.args, got, tc.want)
}
})
}
}
+94 -10
View File
@@ -5,6 +5,7 @@ package checkpoint
import (
"bytes"
"encoding/json"
"errors"
"os"
"path/filepath"
"sync"
@@ -21,16 +22,20 @@ type Record struct {
After []byte `json:"after,omitempty"`
}
// Result 是一次回滚的分类。Skipped 是当前内容既不是写入后也不是写入前的文件。
// Result 是一次回滚的分类。Skipped 是当前内容既不是写入后也不是写入前的文件
// (含目标是符号链接的情况——恢复会跟出根外,按外部改动处理)。
// Failed 是恢复动作本身失败的文件(Rewind 不因单文件失败中止,继续其余)。
type Result struct {
Restored []string
Skipped []string
Failed []string
}
// Log 是追加写的检查点文件。
type Log struct {
path string
mu sync.Mutex
path string
mu sync.Mutex
lastSeq int // Open 时一次读出的最大 Seq;Record 递增(不再全读)
}
func Open(path string) (*Log, error) {
@@ -42,7 +47,12 @@ func Open(path string) (*Log, error) {
return nil, err
}
_ = f.Close()
return &Log{path: path}, nil
l := &Log{path: path}
// 初始化序号(一次读,之后递增)。
if all, err := l.read(); err == nil && len(all) > 0 {
l.lastSeq = all[len(all)-1].Seq
}
return l, nil
}
func (l *Log) Path() string { return l.path }
@@ -54,12 +64,9 @@ func (l *Log) Record(anchor, root, rel string, before []byte, existed bool, afte
}
l.mu.Lock()
defer l.mu.Unlock()
all, err := l.read()
if err != nil {
return err
}
l.lastSeq++
rec := Record{
Seq: len(all) + 1, Anchor: anchor, Root: root, Path: filepath.ToSlash(rel),
Seq: l.lastSeq, Anchor: anchor, Root: root, Path: filepath.ToSlash(rel),
Existed: existed, Before: append([]byte(nil), before...), After: append([]byte(nil), after...),
}
raw, err := json.Marshal(rec)
@@ -75,6 +82,56 @@ func (l *Log) Record(anchor, root, rel string, before []byte, existed bool, afte
return err
}
// Compact 只保留最近 keepAnchors 个 anchor 的记录(按 Seq 序判定新旧,
// 保留原 Seq 不重排,序号单调性不破)。keepAnchors ≤ 0 不动。
// 重写是原子的(临时文件 + rename)。
func (l *Log) Compact(keepAnchors int) error {
if l == nil || keepAnchors <= 0 {
return nil
}
l.mu.Lock()
defer l.mu.Unlock()
all, err := l.read()
if err != nil {
return err
}
var anchors []string
seen := map[string]bool{}
for _, rec := range all {
if !seen[rec.Anchor] {
seen[rec.Anchor] = true
anchors = append(anchors, rec.Anchor)
}
}
if len(anchors) <= keepAnchors {
return nil
}
keep := map[string]bool{}
for _, a := range anchors[len(anchors)-keepAnchors:] {
keep[a] = true
}
tmp := l.path + ".compact.tmp"
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
if err != nil {
return err
}
enc := json.NewEncoder(f)
for _, rec := range all {
if keep[rec.Anchor] {
if err := enc.Encode(rec); err != nil {
_ = f.Close()
_ = os.Remove(tmp)
return err
}
}
}
if err := f.Close(); err != nil {
_ = os.Remove(tmp)
return err
}
return os.Rename(tmp, l.path)
}
// Plan 列出从 anchor 起(含)会被回滚的文件,不改磁盘。
// ordered 是用户消息 id 的时间顺序。
func (l *Log) Plan(anchor string, ordered []string) (Result, error) {
@@ -103,12 +160,22 @@ func (l *Log) Rewind(anchor string, ordered []string) (Result, error) {
return Result{}, err
}
plan := classify(all, anchor, ordered)
// 单文件失败不中止:符号链接目标归入 Skipped(外部改动语义),
// 其余恢复错误记入 Failed 并继续(部分恢复对调用方可读)。
restored := plan.Restored[:0]
for _, rel := range plan.Restored {
group := groupFor(selected(all, anchor, ordered), rel)
if err := restore(group); err != nil {
return Result{}, err
if errors.Is(err, errSymlinkTarget) {
plan.Skipped = append(plan.Skipped, rel)
continue
}
plan.Failed = append(plan.Failed, rel)
continue
}
restored = append(restored, rel)
}
plan.Restored = restored
return plan, nil
}
@@ -226,12 +293,29 @@ func fileState(group []Record) int {
return stateSkip
}
var errSymlinkTarget = errors.New("checkpoint: restore target is a symlink")
// restoreForTesting 是测试缝:非空时在每次恢复前调用(返回错误即恢复失败)。
// 生产代码永不应设置它;恢复期的故障注入无法绕开分类层的预检
// (权限类失败对 root 不可重现,故用显式缝)。
var restoreForTesting func(abs string) error
func restore(group []Record) error {
if len(group) == 0 {
return nil
}
first := group[0]
abs := filepath.Join(first.Root, filepath.FromSlash(first.Path))
if restoreForTesting != nil {
if err := restoreForTesting(abs); err != nil {
return err
}
}
// 符号链接防护:恢复会跟出根外(与 fs_write_file 的防护同语义)。
// 按外部改动处理——由调用方归入 Skipped,不写不删。
if info, err := os.Lstat(abs); err == nil && info.Mode()&os.ModeSymlink != 0 {
return errSymlinkTarget
}
if !first.Existed {
return os.Remove(abs)
}
+237
View File
@@ -0,0 +1,237 @@
package checkpoint
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
// ---- N3 WP3:检查点收口 ----
// 符号链接目标:恢复会跟出根外——归入 Skipped,链接与目标都不动。
func TestRewindSkipsSymlinkTarget(t *testing.T) {
root := t.TempDir()
log, err := Open(filepath.Join(root, "cp.jsonl"))
if err != nil {
t.Fatal(err)
}
real := filepath.Join(root, "real.txt")
if err := os.WriteFile(real, []byte("被跟踪写入后"), 0o644); err != nil {
t.Fatal(err)
}
if err := log.Record("m1", root, "real.txt", []byte("写入前"), true, []byte("被跟踪写入后")); err != nil {
t.Fatal(err)
}
outside := filepath.Join(t.TempDir(), "outside.txt")
if err := os.WriteFile(outside, []byte("根外内容"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Remove(real); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outside, real); err != nil {
t.Fatal(err)
}
res, err := log.Rewind("m1", []string{"m1"})
if err != nil {
t.Fatal(err)
}
if len(res.Skipped) != 1 || res.Skipped[0] != "real.txt" {
t.Fatalf("符号链接必须进 Skipped:%+v", res)
}
if len(res.Restored) != 0 || len(res.Failed) != 0 {
t.Fatalf("符号链接不得恢复也不得算失败:%+v", res)
}
body, _ := os.ReadFile(outside)
if string(body) != "根外内容" {
t.Fatalf("根外目标被写:%q", body)
}
if info, _ := os.Lstat(real); info == nil || info.Mode()&os.ModeSymlink == 0 {
t.Fatal("符号链接本身不得被改动")
}
}
// 部分恢复:一个文件恢复动作失败(所在目录只读,Remove 失败),
// 另一个照常恢复,失败进 Result.Failed,不中止。
func TestRewindPartialFailureContinues(t *testing.T) {
root := t.TempDir()
log, err := Open(filepath.Join(root, "cp.jsonl"))
if err != nil {
t.Fatal(err)
}
dirA, dirB := filepath.Join(root, "a"), filepath.Join(root, "b")
for _, d := range []string{dirA, dirB} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
// 两个都是「被跟踪创建」的文件(existed=false,当前内容==写入后 → stateRestore)。
if err := os.WriteFile(filepath.Join(dirA, "keep.txt"), []byte("A后"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dirB, "fail.txt"), []byte("B后"), 0o644); err != nil {
t.Fatal(err)
}
if err := log.Record("m1", root, "a/keep.txt", nil, false, []byte("A后")); err != nil {
t.Fatal(err)
}
if err := log.Record("m1", root, "b/fail.txt", nil, false, []byte("B后")); err != nil {
t.Fatal(err)
}
// 测试缝注入恢复失败(权限法对 root 不可重现——CI 以 root 跑)。
restoreForTesting = func(abs string) error {
if strings.HasSuffix(abs, "b/fail.txt") {
return errors.New("注入的恢复失败")
}
return nil
}
defer func() { restoreForTesting = nil }()
res, err := log.Rewind("m1", []string{"m1"})
if err != nil {
t.Fatalf("单文件失败不得中止:%v", err)
}
if len(res.Failed) != 1 || res.Failed[0] != "b/fail.txt" {
t.Fatalf("失败文件应进 Failed:%+v", res)
}
if len(res.Restored) != 1 || res.Restored[0] != "a/keep.txt" {
t.Fatalf("其余文件照常恢复:%+v", res)
}
if _, err := os.Stat(filepath.Join(dirA, "keep.txt")); !os.IsNotExist(err) {
t.Fatal("keep.txt 应被删除")
}
if _, err := os.Stat(filepath.Join(dirB, "fail.txt")); err != nil {
t.Fatal("fail.txt 应保留")
}
}
// Compact:只留最近 N 个 anchor 的记录,原 Seq 保留,后续 Record 序号单调。
func TestCompactKeepsRecentAnchors(t *testing.T) {
root := t.TempDir()
log, err := Open(filepath.Join(root, "cp.jsonl"))
if err != nil {
t.Fatal(err)
}
for _, tc := range []struct{ anchor, rel string }{
{"m1", "a"}, {"m2", "b"}, {"m2", "c"}, {"m3", "d"},
} {
if err := log.Record(tc.anchor, root, tc.rel, []byte("x"), true, []byte("y")); err != nil {
t.Fatal(err)
}
}
if err := log.Compact(2); err != nil {
t.Fatal(err)
}
all, err := log.read()
if err != nil {
t.Fatal(err)
}
if len(all) != 3 {
t.Fatalf("Compact(2) 应留 3 条(m2×2 + m3),得到 %d", len(all))
}
for _, rec := range all {
if rec.Anchor == "m1" {
t.Fatalf("m1 应被裁掉:%+v", all)
}
}
// Seq 保留原值且后续递增单调。
maxSeq := 0
for _, rec := range all {
if rec.Seq > maxSeq {
maxSeq = rec.Seq
}
}
if err := log.Record("m4", root, "e", []byte("x"), true, []byte("y")); err != nil {
t.Fatal(err)
}
all, _ = log.read()
if all[len(all)-1].Seq <= maxSeq {
t.Fatalf("Compact 后序号必须单调:%d → %d", maxSeq, all[len(all)-1].Seq)
}
}
// 空 ordered 边界:只回滚 anchor 自己的记录(既有语义钉住)。
func TestRewindEmptyOrderedKeepsAnchorOnly(t *testing.T) {
root := t.TempDir()
log, err := Open(filepath.Join(root, "cp.jsonl"))
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "a.txt"), []byte("A后"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "b.txt"), []byte("B后"), 0o644); err != nil {
t.Fatal(err)
}
if err := log.Record("m1", root, "a.txt", []byte("A前"), true, []byte("A后")); err != nil {
t.Fatal(err)
}
if err := log.Record("m2", root, "b.txt", []byte("B前"), true, []byte("B后")); err != nil {
t.Fatal(err)
}
res, err := log.Rewind("m2", nil)
if err != nil {
t.Fatal(err)
}
if len(res.Restored) != 1 || res.Restored[0] != "b.txt" {
t.Fatalf("空 ordered 只回滚 anchor 自己:%+v", res)
}
if body, _ := os.ReadFile(filepath.Join(root, "a.txt")); string(body) != "A后" {
t.Fatalf("m1 的文件不得被动:%q", body)
}
}
// 「外部同字节改写」分类为 Already(内容等价即视为同步,钉住语义)。
func TestIdenticalExternalRewriteIsAlready(t *testing.T) {
root := t.TempDir()
log, err := Open(filepath.Join(root, "cp.jsonl"))
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "a.txt"), []byte("相同内容"), 0o644); err != nil {
t.Fatal(err)
}
if err := log.Record("m1", root, "a.txt", []byte("相同内容"), true, []byte("相同内容")); err != nil {
t.Fatal(err)
}
// 外部用相同字节重写一遍(mtime 变、内容同)。
if err := os.WriteFile(filepath.Join(root, "a.txt"), []byte("相同内容"), 0o644); err != nil {
t.Fatal(err)
}
res, err := log.Plan("m1", []string{"m1"})
if err != nil {
t.Fatal(err)
}
if len(res.Restored) != 0 || len(res.Skipped) != 0 {
t.Fatalf("同字节改写应为 Already(不进任何分类):%+v", res)
}
}
// root 移动:记录按各自 Root 恢复,不窜根。
func TestRewindUsesPerRecordRoot(t *testing.T) {
root1, root2 := t.TempDir(), t.TempDir()
log, err := Open(filepath.Join(root1, "cp.jsonl"))
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root2, "a.txt"), []byte("root2 的内容"), 0o644); err != nil {
t.Fatal(err)
}
if err := log.Record("m1", root2, "a.txt", []byte("写入前"), true, []byte("root2 的内容")); err != nil {
t.Fatal(err)
}
res, err := log.Rewind("m1", []string{"m1"})
if err != nil {
t.Fatal(err)
}
if len(res.Restored) != 1 {
t.Fatalf("应按记录内 Root 恢复:%+v", res)
}
if body, _ := os.ReadFile(filepath.Join(root2, "a.txt")); string(body) != "写入前" {
t.Fatalf("root2 未恢复:%q", body)
}
if _, err := os.Stat(filepath.Join(root1, "a.txt")); !os.IsNotExist(err) {
t.Fatal("不得在 root1 造出同名文件")
}
}
+233 -1
View File
@@ -121,7 +121,7 @@ func TestStoredSafeCurrentNeverDoesNotRerun(t *testing.T) {
tool := &replayTool{countingTool: countingTool{name: "read_file"}, replay: tools.ReplayNever}
reg := tools.NewRegistry(dir)
reg.Register(tool)
if err := recoverPending(context.Background(), Config{Session: st, Tools: reg}); err != nil {
if _, err := recoverPending(context.Background(), Config{Session: st, Tools: reg}); err != nil {
t.Fatal(err)
}
if tool.calls != 0 {
@@ -208,3 +208,235 @@ func TestSpansUseAgreedNamesWithoutPrompt(t *testing.T) {
t.Fatal("prompt leaked into span attrs")
}
}
// ---- N3 WP1:崩溃窗口尾部配对兜底(验收① 库层机器核心)----
// 窗口内死亡(persist(assistant) 之后、任何 intent 之前):转录只有带
// tool_calls 的 assistant。恢复后每个调用都有「进程中断」配对,且 run 继续。
func TestResumePairsOrphanTailAfterCrashWindow(t *testing.T) {
dir := t.TempDir()
st, err := session.Open(filepath.Join(dir, "s.jsonl"))
if err != nil {
t.Fatal(err)
}
if err := st.Append(llm.Message{Role: llm.RoleSystem, Content: "sys"}); err != nil {
t.Fatal(err)
}
if err := st.Append(llm.Message{Role: llm.RoleUser, Content: "干个活"}); err != nil {
t.Fatal(err)
}
// 崩溃窗口形态:assistant 落盘、零意图、零结果。
if err := st.Append(llm.Message{
Role: llm.RoleAssistant, StopReason: llm.StopToolCalls,
ToolCalls: []llm.ToolCall{
{ID: "c1", Name: "write_file", Arguments: `{}`},
{ID: "c2", Name: "read_file", Arguments: `{}`},
},
}); err != nil {
t.Fatal(err)
}
reg := tools.NewRegistry(dir)
tool := &countingTool{name: "write_file"}
reg.Register(tool)
reg.Register(&countingTool{name: "read_file"})
_, err = Run(context.Background(), Config{
Model: "fake", Session: st, Tools: reg, MaxTurns: 2,
Stream: script(llm.Message{Role: llm.RoleAssistant, Content: "继续", StopReason: llm.StopEnd}),
}, llm.Message{Role: llm.RoleUser, Content: "还在吗"})
if err != nil {
t.Fatalf("恢复后的 Run 不得失败(配对修复应让提供方接受历史):%v", err)
}
msgs, err := st.Load()
if err != nil {
t.Fatal(err)
}
// 双向配对断言:每个调用有结果、每个结果有调用。
calls, results := map[string]string{}, map[string]bool{}
for _, m := range msgs {
for _, c := range m.ToolCalls {
calls[c.ID] = c.Name
}
if m.Role == llm.RoleTool && m.ToolCallID != "" {
results[m.ToolCallID] = true
if m.Content != crashInterrupted && m.Content != unknownSideEffect {
t.Fatalf("兜底文案不对:%q", m.Content)
}
if !m.IsError {
t.Fatal("兜底结果必须 IsError")
}
}
}
for id := range calls {
if !results[id] {
t.Fatalf("孤立 tool_call:%s", id)
}
}
if tool.calls != 0 {
t.Fatalf("兜底不得重放写工具:%d", tool.calls)
}
}
// 执行中死亡(c1 意图+结果、c2 意图无结果)+ 窗口死亡(c3 无意图)混合:
// 意图路径配 c2、尾部兜底配 c3,互不重复。
func TestResumeMixedIntentAndOrphanTail(t *testing.T) {
dir := t.TempDir()
st, err := session.Open(filepath.Join(dir, "s.jsonl"))
if err != nil {
t.Fatal(err)
}
if err := st.Append(llm.Message{
Role: llm.RoleAssistant, StopReason: llm.StopToolCalls,
ToolCalls: []llm.ToolCall{
{ID: "c1", Name: "read_file", Arguments: `{}`},
{ID: "c2", Name: "read_file", Arguments: `{}`},
{ID: "c3", Name: "write_file", Arguments: `{}`},
},
}); err != nil {
t.Fatal(err)
}
if err := st.AppendToolIntent(session.ToolIntent{CallID: "c1", Name: "read_file", Arguments: `{}`, Replay: tools.ReplaySafe}); err != nil {
t.Fatal(err)
}
if err := st.Append(llm.Message{Role: llm.RoleTool, ToolCallID: "c1", Name: "read_file", Content: "真实结果"}); err != nil {
t.Fatal(err)
}
if err := st.AppendToolIntent(session.ToolIntent{CallID: "c2", Name: "read_file", Arguments: `{}`, Replay: tools.ReplayNever}); err != nil {
t.Fatal(err)
}
reg := tools.NewRegistry(dir)
reg.Register(&countingTool{name: "read_file"})
reg.Register(&countingTool{name: "write_file"})
_, err = Run(context.Background(), Config{
Model: "fake", Session: st, Tools: reg, MaxTurns: 1,
Stream: script(llm.Message{Role: llm.RoleAssistant, Content: "x", StopReason: llm.StopEnd}),
}, llm.Message{Role: llm.RoleUser, Content: "next"})
if err != nil {
t.Fatal(err)
}
msgs, err := st.Load()
if err != nil {
t.Fatal(err)
}
byID := map[string][]string{}
for _, m := range msgs {
if m.Role == llm.RoleTool && m.ToolCallID != "" {
byID[m.ToolCallID] = append(byID[m.ToolCallID], m.Content)
}
}
if got := byID["c1"]; len(got) != 1 || got[0] != "真实结果" {
t.Fatalf("c1 必须保持原结果且不重复:%v", got)
}
if got := byID["c2"]; len(got) != 1 || got[0] != unknownSideEffect {
t.Fatalf("c2 走意图路径:%v", got)
}
if got := byID["c3"]; len(got) != 1 || got[0] != crashInterrupted {
t.Fatalf("c3 走尾部兜底:%v", got)
}
}
// 健康转录(尾部是完整配对或 user 消息)不做任何改写。
func TestRepairOrphanTailNoopOnHealthyTranscript(t *testing.T) {
dir := t.TempDir()
st, err := session.Open(filepath.Join(dir, "s.jsonl"))
if err != nil {
t.Fatal(err)
}
for _, m := range []llm.Message{
{Role: llm.RoleSystem, Content: "sys"},
{Role: llm.RoleUser, Content: "u"},
{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{ID: "c1", Name: "read_file", Arguments: `{}`}}},
{Role: llm.RoleTool, ToolCallID: "c1", Name: "read_file", Content: "r"},
{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd},
{Role: llm.RoleUser, Content: "u2"},
} {
if err := st.Append(m); err != nil {
t.Fatal(err)
}
}
before, _ := st.Load()
if _, err := repairOrphanTail(Config{Session: st}); err != nil {
t.Fatal(err)
}
after, _ := st.Load()
if len(after) != len(before) {
t.Fatalf("健康转录被改写:%d → %d", len(before), len(after))
}
}
// ---- N3 WP2:JournalOnly(journal 只作日志,上下文由 History 组装)----
// JournalOnly:journal 有杀窗口尾巴 → 恢复配对写入 journal;上下文来自
// History(不含 journal 内容),恢复摘要以系统消息注入;种子不进 journal。
func TestJournalOnlyRecoversWithoutTranscriptAuthority(t *testing.T) {
dir := t.TempDir()
st, err := session.Open(filepath.Join(dir, "s.jsonl"))
if err != nil {
t.Fatal(err)
}
if err := st.Append(llm.Message{
Role: llm.RoleAssistant, StopReason: llm.StopToolCalls,
ToolCalls: []llm.ToolCall{{ID: "c1", Name: "read_file", Arguments: `{}`}},
}); err != nil {
t.Fatal(err)
}
if err := st.AppendToolIntent(session.ToolIntent{CallID: "c1", Name: "read_file", Arguments: `{}`, Replay: tools.ReplaySafe}); err != nil {
t.Fatal(err)
}
reg := tools.NewRegistry(dir)
reg.Register(&replayTool{countingTool: countingTool{name: "read_file"}, replay: tools.ReplaySafe})
var gotReq []llm.Message
res, err := Run(context.Background(), Config{
Model: "fake", Session: st, JournalOnly: true, Tools: reg, MaxTurns: 2,
History: []llm.Message{
{Role: llm.RoleSystem, Content: "sys"},
{Role: llm.RoleUser, Content: "历史一"},
{Role: llm.RoleAssistant, Content: "回复一"},
},
Stream: func(ctx context.Context, model string, msgs []llm.Message, _ []llm.ToolDecl, _ llm.StreamOptions) <-chan llm.Event {
gotReq = append([]llm.Message{}, msgs...)
ch := make(chan llm.Event, 1)
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{Role: llm.RoleAssistant, Content: "继续", StopReason: llm.StopEnd}}
close(ch)
return ch
},
}, llm.Message{Role: llm.RoleUser, Content: "最新"})
if err != nil {
t.Fatal(err)
}
// journal 里 c1 被 safe 重放配对(一次真实结果)。
journal, _ := st.Load()
var journaled bool
for _, m := range journal {
if m.Role == llm.RoleTool && m.ToolCallID == "c1" && m.Content == "ok" {
journaled = true
}
if m.Content == "历史一" {
t.Fatal("种子历史不得进 journal")
}
}
if !journaled {
t.Fatalf("journal 缺恢复配对:%#v", journal)
}
// 上下文:History 内容在;恢复摘要在;且在新 user 消息之前。
var sawHist, sawNote int
noteIdx, userIdx := -1, -1
for i, m := range gotReq {
if m.Content == "历史一" {
sawHist++
}
if m.Role == llm.RoleSystem && strings.Contains(m.Content, "已完成恢复") && strings.Contains(m.Content, "c1") {
sawNote++
noteIdx = i
}
if m.Content == "最新" {
userIdx = i
}
}
if sawHist != 1 || sawNote != 1 {
t.Fatalf("历史=%d 摘要=%d:%#v", sawHist, sawNote, gotReq)
}
if noteIdx < 0 || userIdx < 0 || noteIdx > userIdx {
t.Fatalf("摘要必须在新 user 消息之前:note=%d user=%d", noteIdx, userIdx)
}
_ = res
}
+20 -1
View File
@@ -39,7 +39,7 @@ func vocabScript() llm.StreamFunc {
step++
ch <- llm.Event{Kind: llm.KindReasoningDelta, ReasoningDelta: "思考"}
ch <- llm.Event{Kind: llm.KindTextDelta, Text: "正文"}
ch <- llm.Event{Kind: llm.KindToolCallDelta, ToolName: "list_dir", ArgumentsDelta: `{"path":"."}`}
ch <- llm.Event{Kind: llm.KindToolCallDelta, ToolIndex: 2, ToolID: "c1", ToolName: "list_dir", ArgumentsDelta: `{"path":"."}`}
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{
Role: llm.RoleAssistant, StopReason: llm.StopToolCalls,
ToolCalls: []llm.ToolCall{{ID: "c1", Name: "list_dir", Arguments: `{"path":"."}`}},
@@ -54,6 +54,25 @@ func vocabScript() llm.StreamFunc {
}
}
func TestLoopPreservesToolCallDeltaIdentity(t *testing.T) {
reg := tools.WorkspaceTools(t.TempDir())
var got Event
_, err := Run(context.Background(), Config{
Model: "fake", Tools: reg, Stream: vocabScript(), MaxTurns: 4,
OnEvent: func(ev Event) {
if ev.Type == "tool_call_delta" {
got = ev
}
},
}, llm.Message{Role: llm.RoleUser, Content: "run"})
if err != nil {
t.Fatal(err)
}
if got.ToolIndex != 2 || got.CallID != "c1" || got.Tool != "list_dir" || got.Detail != `{"path":"."}` {
t.Fatalf("tool delta lost its provider identity: %#v", got)
}
}
func TestLoopEmitsProcessVocabulary(t *testing.T) {
reg := tools.WorkspaceTools(t.TempDir())
var observed []string
+177
View File
@@ -0,0 +1,177 @@
// Package gates 提供终轮门禁与进度提醒的组合机制(自产品下沉)。
// 产品依赖——工件存取、质量评分、验证器模型、提示词、进度快照——全部端口化;
// 判定逻辑(正则族)、续跑上限与节流策略由本包持有。
// 端口为 nil 时语义与「未启用该检查」一致,不改变既有行为。
package gates
import (
"encoding/json"
"fmt"
"path/filepath"
"regexp"
"strings"
)
// 续跑与节流上限。与产品现网取值一致;消费方引用本包常量,不再各自硬编码。
const (
// MaxDeliverableGuards 是「声称交付但无工件」的纠正轮次上限。
MaxDeliverableGuards = 2
// MaxVerifyContinuations 是终轮检查的续跑上限(与内核 Stop 的 3 次上限对齐)。
MaxVerifyContinuations = 3
// ReminderToolGap 是连续这么多个工具轮没有更新计划或待办就提醒一次。
ReminderToolGap = 3
// MaxChecklistReminders 是每个 run 的进度提醒次数上限。
MaxChecklistReminders = 3
)
// ArtifactLookup 按 ID 取工件:名称、内容与是否存在。
type ArtifactLookup func(id string) (name string, content []byte, ok bool)
// QualityScorer 对文本类工件评分;nil 表示跳过评分(视为通过)。
// why 在未通过时给出可读原因。
type QualityScorer func(name string, content []byte) (passed bool, why string)
// ArtifactRef 是一次工件引用(库内最小形态;宿主负责投影自有类型)。
type ArtifactRef struct {
ID string
Name string
Kind string
}
var (
// ArtifactIDPattern 是工件编号的文本形态(门禁与宿主的引用提取共用)。
ArtifactIDPattern = regexp.MustCompile(`art_[0-9a-f]{12}`)
fileClaimRe = regexp.MustCompile(`(?s)(?:已生成|已保存|已导出|已写入).{0,120}?\.(?:docx|xlsx|pptx|pdf|png|jpe?g|csv|md|txt|json)\b`)
softClaimRe = regexp.MustCompile(`(?s)(?:文件|文档|工件).{0,40}?已(?:正常)?(?:生成|登记|保存|导出|写入)`)
forgedCallRe = regexp.MustCompile(`\[(?:调用工具|工具结果|产出工件) `)
)
// ClaimGate 判断终轮正文是否在没有对应工件的情况下声称交付。
// 引用本轮已产出、或确实存在的工件不算谎称;伪造调用标记始终拦截。
// lookup 为 nil 时,任何引用未产出工件的 art_ 编号都视为不存在(拦截)。
func ClaimGate(body string, produced []ArtifactRef, lookup ArtifactLookup) (bool, string) {
if strings.TrimSpace(body) == "" {
return false, ""
}
if forgedCallRe.MatchString(body) {
return true, "回复正文里出现了工具调用标记,但本轮没有真实的工具调用"
}
producedIDs := map[string]bool{}
for _, ref := range produced {
if ref.ID != "" {
producedIDs[ref.ID] = true
}
}
knownExisting := false
for _, id := range ArtifactIDPattern.FindAllString(body, -1) {
if producedIDs[id] {
continue
}
if lookup != nil {
if _, _, ok := lookup(id); ok {
knownExisting = true
continue
}
}
return true, "回复引用了不存在的工件"
}
if len(producedIDs) > 0 || knownExisting {
return false, ""
}
if fileClaimRe.MatchString(body) || softClaimRe.MatchString(body) {
return true, "回复声称已经生成或登记了文件,但这一轮没有产生对应的工件"
}
return false, ""
}
// CheckArtifacts 确认登记的工件存在且非空。
// md、txt、docx 还要过 scorer;其他类型只检查存在与非空。
func CheckArtifacts(refs []ArtifactRef, lookup ArtifactLookup, scorer QualityScorer) []string {
var reasons []string
for _, ref := range refs {
if ref.ID == "" {
reasons = append(reasons, "工件引用缺少 ID")
continue
}
name := ref.Name
if name == "" {
name = ref.ID
}
var (
content []byte
ok bool
artName string
)
if lookup != nil {
artName, content, ok = lookup(ref.ID)
}
if !ok {
reasons = append(reasons, name+" 不存在")
continue
}
if artName != "" {
name = artName
}
if len(content) == 0 {
reasons = append(reasons, name+" 内容为空")
continue
}
switch strings.ToLower(filepath.Ext(name)) {
case ".md", ".txt", ".docx":
if scorer == nil {
continue
}
passed, why := scorer(name, content)
if !passed {
if why == "" {
why = "评分未通过"
}
reasons = append(reasons, name+":"+why)
}
}
}
return reasons
}
// Verdict 是独立验证器的裁决。
type Verdict struct {
Passed bool `json:"passed"`
Reason string `json:"reason"`
NextAction string `json:"nextAction"`
}
// ParseVerdict 解析验证器输出:剥围栏、取最外层 JSON、必须有 passed 键。
func ParseVerdict(raw string) (Verdict, error) {
s := stripFence(raw)
start := strings.Index(s, "{")
end := strings.LastIndex(s, "}")
if start < 0 || end < start {
return Verdict{}, fmt.Errorf("验证器返回无法解析")
}
s = s[start : end+1]
var probe map[string]json.RawMessage
if err := json.Unmarshal([]byte(s), &probe); err != nil {
return Verdict{}, err
}
if _, ok := probe["passed"]; !ok {
return Verdict{}, fmt.Errorf("验证器返回无法解析")
}
var verdict Verdict
if err := json.Unmarshal([]byte(s), &verdict); err != nil {
return Verdict{}, err
}
return verdict, nil
}
func stripFence(s string) string {
s = strings.TrimSpace(s)
if !strings.HasPrefix(s, "```") {
return s
}
s = strings.TrimPrefix(s, "```")
if i := strings.IndexByte(s, '\n'); i >= 0 {
s = s[i+1:]
}
s = strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(s), "```"))
return s
}
+133
View File
@@ -0,0 +1,133 @@
package gates
import (
"strings"
"testing"
"time"
)
func lookupWith(ids map[string][]byte) ArtifactLookup {
return func(id string) (string, []byte, bool) {
c, ok := ids[id]
return id + ".md", c, ok
}
}
func TestClaimGateForgedMarkerAlwaysBlocks(t *testing.T) {
block, why := ClaimGate("结果如下 [调用工具 write_file]", nil, nil)
if !block || why == "" {
t.Fatalf("block=%v why=%q", block, why)
}
}
func TestClaimGateProducedAndExistingDoNotBlock(t *testing.T) {
produced := []ArtifactRef{{ID: "art_aaaabbbbcccc"}}
if block, _ := ClaimGate("已生成 art_aaaabbbbcccc 请查收", produced, nil); block {
t.Fatal("produced reference blocked")
}
lookup := lookupWith(map[string][]byte{"art_dddd11112222": []byte("x")})
if block, _ := ClaimGate("之前的 art_dddd11112222 里有结论", nil, lookup); block {
t.Fatal("existing artifact reference blocked")
}
}
func TestClaimGateUnknownArtifactBlocks(t *testing.T) {
block, why := ClaimGate("见附件 art_000011112222", nil, lookupWith(nil))
if !block || !strings.Contains(why, "不存在") {
t.Fatalf("block=%v why=%q", block, why)
}
}
func TestClaimGateBareClaimWithoutArtifactsBlocks(t *testing.T) {
for _, body := range []string{
"已生成报告.md,请查收",
"文件已生成好了",
} {
if block, _ := ClaimGate(body, nil, nil); !block {
t.Fatalf("claim not blocked: %q", body)
}
}
// 如实说明没有生成则不拦。
if block, _ := ClaimGate("这一轮没有生成文件", nil, nil); block {
t.Fatal("honest note blocked")
}
}
func TestCheckArtifactsReasons(t *testing.T) {
lookup := func(id string) (string, []byte, bool) {
switch id {
case "ok":
return "ok.md", []byte("# 正常"), true
case "empty":
return "empty.md", nil, true
default:
return "", nil, false
}
}
scorer := func(name string, content []byte) (bool, string) { return false, "标题缺失" }
refs := []ArtifactRef{
{ID: ""},
{ID: "missing", Name: "缺失.md"},
{ID: "empty"},
{ID: "ok"},
}
reasons := CheckArtifacts(refs, lookup, scorer)
joined := strings.Join(reasons, ";")
for _, want := range []string{"缺少 ID", "缺失.md 不存在", "empty.md 内容为空", "ok.md:标题缺失"} {
if !strings.Contains(joined, want) {
t.Fatalf("reasons %q 缺 %q", joined, want)
}
}
}
func TestCheckArtifactsNilScorerSkipsQuality(t *testing.T) {
lookup := lookupWith(map[string][]byte{"a": []byte("x")})
if reasons := CheckArtifacts([]ArtifactRef{{ID: "a"}}, lookup, nil); len(reasons) != 0 {
t.Fatalf("reasons = %v", reasons)
}
}
func TestParseVerdictForms(t *testing.T) {
v, err := ParseVerdict("```json\n{\"passed\":true,\"reason\":\"ok\"}\n```")
if err != nil || !v.Passed {
t.Fatalf("v=%+v err=%v", v, err)
}
if _, err := ParseVerdict("没有 JSON"); err == nil {
t.Fatal("garbage accepted")
}
if _, err := ParseVerdict("{\"reason\":\"no passed key\"}"); err == nil {
t.Fatal("missing passed accepted")
}
}
func TestProgressReminderThrottleAndFormat(t *testing.T) {
r := &ProgressReminder{}
now := time.Now()
first := r.Remind([]string{"写第三章"}, []string{"评审"}, now)
if len(first) != 1 {
t.Fatalf("first = %v", first)
}
text := first[0]
for _, want := range []string{"已连续 3 个工具轮", "- 待办:写第三章", "- 计划:评审", "请更新计划或待办"} {
if !strings.Contains(text, want) {
t.Fatalf("text 缺 %q:\n%s", want, text)
}
}
// 内容相同且计数不同:key 不同,应继续发出直到封顶。
if got := r.Remind([]string{"写第三章"}, []string{"评审"}, now); len(got) != 1 {
t.Fatalf("second = %v", got)
}
if got := r.Remind([]string{"写第三章"}, []string{"评审"}, now); len(got) != 1 {
t.Fatalf("third = %v", got)
}
if got := r.Remind([]string{"写第三章"}, []string{"评审"}, now); got != nil {
t.Fatalf("fourth should be capped: %v", got)
}
if r.Sent() != 3 {
t.Fatalf("sent = %d", r.Sent())
}
// 无内容不发。
if got := (&ProgressReminder{}).Remind(nil, nil, now); got != nil {
t.Fatalf("empty = %v", got)
}
}
+78
View File
@@ -0,0 +1,78 @@
package gates
import (
"fmt"
"strings"
"time"
"agent-native/runtime/reminders"
)
// ProgressReminder 是进度提醒的节流状态机与格式化机制。
// 计数(第几个工具轮)由调用方按自己的事件流维护;本机制负责
// 「达到阈值后的去重、封顶与文案」。
type ProgressReminder struct {
Gap int // 连续多少个工具轮未更新即提醒;0 = ReminderToolGap
Cap int // 每 run 提醒上限;0 = MaxChecklistReminders
notes *reminders.Bus
sent int
}
func (r *ProgressReminder) gap() int {
if r.Gap <= 0 {
return ReminderToolGap
}
return r.Gap
}
func (r *ProgressReminder) cap() int {
if r.Cap <= 0 {
return MaxChecklistReminders
}
return r.Cap
}
// Sent 报告已发出的提醒条数(调用方判定封顶用)。
func (r *ProgressReminder) Sent() int { return r.sent }
// Remind 在给定未完成项时发一条提醒;节流或没有内容时返回 nil。
// 文案与产品现网一致:「计划和待办已连续 N 个工具轮没有更新」。
func (r *ProgressReminder) Remind(todos, plans []string, now time.Time) []string {
if r.sent >= r.cap() {
return nil
}
text := FormatUnfinished(r.gap(), todos, plans)
if text == "" {
return nil
}
if r.notes == nil {
r.notes = reminders.New(0, r.cap())
}
key := fmt.Sprintf("%d:%s", r.sent, text)
if !r.notes.Admit(reminders.Note{Kind: reminders.KindTodo, Key: key, Text: text}, now) {
return nil
}
r.sent++
return []string{text}
}
// FormatUnfinished 渲染未完成项提醒;无内容时返回空串。
func FormatUnfinished(gap int, todos, plans []string) string {
var lines []string
for _, item := range todos {
if text := strings.TrimSpace(item); text != "" {
lines = append(lines, "- 待办:"+text)
}
}
for _, step := range plans {
if text := strings.TrimSpace(step); text != "" {
lines = append(lines, "- 计划:"+text)
}
}
if len(lines) == 0 {
return ""
}
return fmt.Sprintf("计划和待办已连续 %d 个工具轮没有更新。未完成项:\n", gap) +
strings.Join(lines, "\n") +
"\n请更新计划或待办,或在回复里说明为什么先不做。"
}
+94
View File
@@ -0,0 +1,94 @@
// Package guardrails 提供三类语义化护栏端口:输入(只作用于入口智能体)、
// 输出(只作用于给出最终结果的智能体)、工具(每次工具调用)。
// 护栏以钩子总线注册:它是类型化语义层,钩子仍是通用事件层。
// 当前形态护栏拿不到运行 ctx(事件载荷不含它);需要取消语义时直接写钩子。
package guardrails
import (
"context"
"agent-native/runtime/hooks"
)
// Verdict 是一次护栏表态。空 Decision 表示不插手。
// Deny 拒绝(Output 层表现为要求模型改进后继续);Modify 改写载荷
// (Input 改用户输入、Output 改最终文本、Tool 改参数);Reason 附说明。
type Verdict struct {
Decision hooks.Decision
Reason string
Replacement string
}
type InputGuardrail func(ctx context.Context, text string) Verdict
type OutputGuardrail func(ctx context.Context, text string) Verdict
type ToolGuardrail func(ctx context.Context, name, args string) Verdict
// Set 是一组护栏;nil 字段表示该层不启用。
type Set struct {
Input InputGuardrail
Output OutputGuardrail
Tool ToolGuardrail
}
// Scope 标注本 run 的角色:Entry=本 run 处理入口用户输入;
// Final=本 run 给出最终答案。子代理 run 两者都为 false,
// Input/Output 护栏不会作用于它;Tool 护栏作用于一切工具调用。
type Scope struct {
Entry bool
Final bool
}
// Register 把护栏挂到总线。语义:
// - Input(仅 scope.Entry):挂 UserPromptSubmit。deny → 循环不启动,
// 以理由直接收尾;modify → 改写后的输入追加为新的用户消息(原文留痕)。
// - Output(仅 scope.Final):挂 Stop。deny → 视同续跑,理由交给模型改进
// (计入内核续跑上限,达上限后收尾并附 Notice);modify → 替换最终文本并续跑。
// - Tool:挂 PreToolUse,与其他钩子按既有优先级合并
// (deny/block > ask > modify > allow);modify 的 Replacement 改写工具参数。
func Register(bus *hooks.Bus, set Set, scope Scope) {
if bus == nil {
return
}
if set.Input != nil && scope.Entry {
g := set.Input
bus.On(func(c hooks.Call) hooks.Outcome {
if c.Event != hooks.UserPromptSubmit {
return hooks.Outcome{}
}
v := g(context.Background(), c.LastText)
return hooks.Outcome{Decision: v.Decision, Reason: v.Reason, Replacement: v.Replacement}
})
}
if set.Output != nil && scope.Final {
g := set.Output
bus.On(func(c hooks.Call) hooks.Outcome {
if c.Event != hooks.Stop {
return hooks.Outcome{}
}
v := g(context.Background(), c.LastText)
switch v.Decision {
case hooks.Deny, hooks.Block:
// 对 Stop 事件而言「拒绝最终答案」= 要求模型改进后再来一轮。
return hooks.Outcome{Decision: hooks.Block, Reason: v.Reason}
case hooks.Modify:
return hooks.Outcome{Decision: hooks.Block, Reason: v.Reason, Replacement: v.Replacement}
default:
return hooks.Outcome{Decision: v.Decision, Reason: v.Reason}
}
})
}
if set.Tool != nil {
g := set.Tool
bus.On(func(c hooks.Call) hooks.Outcome {
if c.Event != hooks.PreToolUse {
return hooks.Outcome{}
}
v := g(context.Background(), c.Tool, c.Args)
out := hooks.Outcome{Decision: v.Decision, Reason: v.Reason}
if v.Decision == hooks.Modify {
out.Args = v.Replacement
}
return out
})
}
}
+89
View File
@@ -0,0 +1,89 @@
package guardrails
import (
"context"
"testing"
"agent-native/runtime/hooks"
)
func TestToolGuardrailMergesOnPreToolUse(t *testing.T) {
bus := hooks.New()
Register(bus, Set{
Tool: func(ctx context.Context, name, args string) Verdict {
if name == "run_shell" {
return Verdict{Decision: hooks.Deny, Reason: "shell 被护栏禁用"}
}
return Verdict{}
},
}, Scope{})
out := bus.Emit(hooks.Call{Event: hooks.PreToolUse, Tool: "run_shell"})
if out.Decision != hooks.Deny || out.Reason == "" {
t.Fatalf("out = %+v", out)
}
out = bus.Emit(hooks.Call{Event: hooks.PreToolUse, Tool: "read_file"})
if out.Decision != "" {
t.Fatalf("read_file out = %+v", out)
}
}
func TestToolGuardrailModifyRewritesArgs(t *testing.T) {
bus := hooks.New()
Register(bus, Set{
Tool: func(ctx context.Context, name, args string) Verdict {
return Verdict{Decision: hooks.Modify, Replacement: `{"path":"safe.txt"}`}
},
}, Scope{})
out := bus.Emit(hooks.Call{Event: hooks.PreToolUse, Tool: "write_file"})
if out.Decision != hooks.Modify || out.Args != `{"path":"safe.txt"}` {
t.Fatalf("out = %+v", out)
}
}
func TestOutputGuardrailDenyBecomesContinuation(t *testing.T) {
bus := hooks.New()
Register(bus, Set{
Output: func(ctx context.Context, text string) Verdict {
return Verdict{Decision: hooks.Deny, Reason: "缺少来源标注"}
},
}, Scope{Final: true})
out := bus.Emit(hooks.Call{Event: hooks.Stop, LastText: "结论"})
// Output deny 必须表达为续跑(Block),而不是把 run 掐死。
if out.Decision != hooks.Block || out.Reason != "缺少来源标注" {
t.Fatalf("out = %+v", out)
}
}
func TestScopeGatesInputAndOutput(t *testing.T) {
fired := map[string]bool{}
mk := func() Set {
return Set{
Input: func(ctx context.Context, text string) Verdict { fired["input"] = true; return Verdict{} },
Output: func(ctx context.Context, text string) Verdict { fired["output"] = true; return Verdict{} },
}
}
// 子代理 run(Entry/Final 都为 false):Input/Output 不注册。
bus := hooks.New()
Register(bus, mk(), Scope{})
bus.Emit(hooks.Call{Event: hooks.UserPromptSubmit, LastText: "x"})
bus.Emit(hooks.Call{Event: hooks.Stop, LastText: "y"})
if fired["input"] || fired["output"] {
t.Fatalf("sub-run scope fired: %v", fired)
}
// 入口且最终:两层都触发。
bus2 := hooks.New()
Register(bus2, mk(), Scope{Entry: true, Final: true})
bus2.Emit(hooks.Call{Event: hooks.UserPromptSubmit, LastText: "x"})
bus2.Emit(hooks.Call{Event: hooks.Stop, LastText: "y"})
if !fired["input"] || !fired["output"] {
t.Fatalf("entry/final scope did not fire: %v", fired)
}
}
func TestNilBusIsNoop(t *testing.T) {
Register(nil, Set{
Input: func(ctx context.Context, text string) Verdict { return Verdict{Decision: hooks.Deny} },
Output: func(ctx context.Context, text string) Verdict { return Verdict{Decision: hooks.Deny} },
Tool: func(ctx context.Context, name, args string) Verdict { return Verdict{Decision: hooks.Deny} },
}, Scope{Entry: true, Final: true})
}
+2
View File
@@ -13,10 +13,12 @@ const (
PermissionRequest Event = "PermissionRequest"
PostToolUse Event = "PostToolUse"
PostToolUseFailure Event = "PostToolUseFailure"
PostToolBatch Event = "PostToolBatch"
Stop Event = "Stop"
SubagentStart Event = "SubagentStart"
SubagentStop Event = "SubagentStop"
PreCompact Event = "PreCompact"
SessionEnd Event = "SessionEnd"
)
type Decision string
+404
View File
@@ -0,0 +1,404 @@
package llm
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// anthropic.go — 原生 Anthropic Messages API 客户端(N2 WP2)。
// 与 OpenAI 路径共用 Message/Event 表面;组包与 BuildBody 的 Anthropic
// 分支是同一个函数(消灭「离线/线上两张皮」,审计 N4 的根因)。
// AnthropicVersion 是请求头 anthropic-version 的钉住值。
const AnthropicVersion = "2023-06-01"
// anthropicBody 组 Anthropic Messages 请求体(线上 Client 与离线 BuildBody
// 共用)。tools 挂 input_schema;system 与最后一个 tool 挂 cache_control
// (静态前缀的缓存断点);assistant 的思考块在内容块最前且带签名往返。
func anthropicBody(model string, messages []Message, tools []ToolDecl, opts StreamOptions) ([]byte, error) {
sys, msgs := anthropicMessages(messages)
body := map[string]any{
"model": model,
"messages": msgs,
"stream": true,
// Anthropic 要求 max_tokens。0 表示调用方不限制:用足够大的默认值,
// 避免 4096 把长回复截断。真正的上限由调用方 StreamOptions.MaxTokens 决定。
"max_tokens": opts.MaxTokens,
}
if body["max_tokens"] == 0 {
body["max_tokens"] = 128000
}
if sys != "" {
body["system"] = []map[string]any{{
"type": "text",
"text": sys,
"cache_control": map[string]any{"type": "ephemeral"},
}}
}
if budget := AnthropicThinkingBudget(opts.ThinkingLevel); budget > 0 {
body["thinking"] = map[string]any{"type": "enabled", "budget_tokens": budget}
}
if len(tools) > 0 {
body["tools"] = anthropicTools(tools)
}
return json.Marshal(body)
}
// anthropicTools 声明工具(name/description/input_schema);最后一个挂
// cache_control 作为静态工具前缀的断点。
func anthropicTools(decls []ToolDecl) []map[string]any {
out := make([]map[string]any, 0, len(decls))
for i, t := range decls {
params := t.Parameters
if len(params) == 0 {
params = json.RawMessage(`{"type":"object","properties":{}}`)
}
item := map[string]any{
"name": t.Name,
"description": t.Description,
"input_schema": json.RawMessage(params),
}
if i == len(decls)-1 {
item["cache_control"] = map[string]any{"type": "ephemeral"}
}
out = append(out, item)
}
return out
}
// anthropicMessages 把通用转录映射成 Anthropic 消息:
// system 收成字符串(由调用方决定挂法);assistant 的思考块在最前,
// 其后 text/image 与 tool_use;连续 RoleTool 折叠成一条 user 消息内的
// tool_result 块组(Anthropic 不允许 tool 角色)。
func anthropicMessages(messages []Message) (string, []map[string]any) {
var system strings.Builder
var out []map[string]any
for i := 0; i < len(messages); i++ {
m := messages[i]
switch m.Role {
case RoleSystem:
if system.Len() > 0 {
system.WriteString("\n")
}
system.WriteString(m.Content)
case RoleAssistant:
var content []map[string]any
for _, th := range m.Thinking {
if th.Redacted {
content = append(content, map[string]any{"type": "redacted_thinking", "data": th.Data})
continue
}
content = append(content, map[string]any{
"type": "thinking", "thinking": th.Thinking, "signature": th.Signature,
})
}
if m.Content != "" {
content = append(content, map[string]any{"type": "text", "text": m.Content})
}
for _, img := range m.Images {
content = append(content, map[string]any{
"type": "image",
"source": map[string]any{
"type": "base64",
"media_type": firstNonEmpty(img.MIME, "image/png"),
"data": img.Data,
},
})
}
for _, tc := range m.ToolCalls {
args := json.RawMessage(tc.Arguments)
if !json.Valid(args) {
args = json.RawMessage(`{}`)
}
content = append(content, map[string]any{
"type": "tool_use", "id": tc.ID, "name": tc.Name, "input": args,
})
}
if len(content) == 0 {
content = append(content, map[string]any{"type": "text", "text": ""})
}
out = append(out, map[string]any{"role": "assistant", "content": content})
case RoleTool:
// 连续 tool 结果折叠进同一条 user 消息。
var results []map[string]any
for ; i < len(messages) && messages[i].Role == RoleTool; i++ {
t := messages[i]
results = append(results, map[string]any{
"type": "tool_result",
"tool_use_id": t.ToolCallID,
"content": t.Content,
"is_error": t.IsError,
})
}
i--
out = append(out, map[string]any{"role": "user", "content": results})
default:
content := []map[string]any{}
if m.Content != "" {
content = append(content, map[string]any{"type": "text", "text": m.Content})
}
for _, img := range m.Images {
content = append(content, map[string]any{
"type": "image",
"source": map[string]any{
"type": "base64",
"media_type": firstNonEmpty(img.MIME, "image/png"),
"data": img.Data,
},
})
}
out = append(out, map[string]any{"role": "user", "content": content})
}
}
return system.String(), out
}
// streamAnthropic 发 POST {base}/messages 并把 Anthropic SSE 事件族映射到
// 通用 Event。与 OpenAI 客户端共享错误/中止语义。
func (c *Client) streamAnthropic(ctx context.Context, model string, messages []Message, tools []ToolDecl, opts StreamOptions, out chan<- Event) {
base := firstNonEmpty(opts.APIBase, c.BaseURL)
key := firstNonEmpty(opts.APIKey, c.APIKey)
if base == "" || key == "" || model == "" {
emitError(out, "", false, "agentx llm client is missing base url, api key, or model")
return
}
if opts.MessageShape != "" {
messages = PrepareMessages(messages, opts.MessageShape)
}
raw, err := anthropicBody(model, messages, tools, opts)
if err != nil {
emitError(out, "", false, err.Error())
return
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(base, "/")+"/messages", bytes.NewReader(raw))
if err != nil {
emitError(out, "", false, err.Error())
return
}
req.Header.Set("x-api-key", key)
req.Header.Set("anthropic-version", AnthropicVersion)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/event-stream")
hc := c.HTTPClient
if hc == nil {
hc = &http.Client{Timeout: 30 * time.Minute}
}
resp, err := hc.Do(req)
if err != nil {
if ctx.Err() != nil {
emitAbort(out, "", ctx.Err())
return
}
emitError(out, "", true, err.Error())
return
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
slurp, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
msg := fmt.Sprintf("provider status %d: %s", resp.StatusCode, strings.TrimSpace(string(slurp)))
ev := Event{
Kind: KindError,
HTTPStatus: resp.StatusCode,
Retryable: StatusRetryable(resp.StatusCode) && !ContextOverflow(msg),
ErrorMessage: msg,
Err: &StreamError{Message: msg, Retryable: StatusRetryable(resp.StatusCode) && !ContextOverflow(msg)},
Message: Message{Role: RoleAssistant, StopReason: StopError},
}
if d, ok := ParseRetryAfter(resp.Header.Get("Retry-After"), time.Now()); ok {
ev.HasRetryAfter = true
ev.RetryAfter = d
}
out <- ev
return
}
parseAnthropicSSE(ctx, resp.Body, out)
}
// ---- Anthropic SSE 解析 ----
type anthropicBlock struct {
typ string
toolID string
toolName string
args strings.Builder
thinking strings.Builder
signature strings.Builder
redacted string
}
// parseAnthropicSSE 读 event:/data: 帧对,产出文本/思考/工具增量与一次
// 终结 message_end(或 error)。
func parseAnthropicSSE(ctx context.Context, r io.Reader, out chan<- Event) {
blocks := map[int]*anthropicBlock{}
var content strings.Builder
var thinking []ThinkingBlock
var calls []ToolCall
var usage Usage
stopReason := StopEnd
finishReason := ""
sc := bufio.NewScanner(r)
sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
eventType := ""
for sc.Scan() {
line := sc.Text()
select {
case <-ctx.Done():
emitAbort(out, "", ctx.Err())
return
default:
}
switch {
case strings.HasPrefix(line, "event:"):
eventType = strings.TrimSpace(strings.TrimPrefix(line, "event:"))
case strings.HasPrefix(line, "data:"):
data := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
if data == "" {
continue
}
var frame map[string]json.RawMessage
if err := json.Unmarshal([]byte(data), &frame); err != nil {
continue
}
switch eventType {
case "message_start":
var v struct {
Message struct {
Usage struct {
InputTokens int `json:"input_tokens"`
CacheReadTokens int `json:"cache_read_input_tokens"`
CacheWriteTokens int `json:"cache_creation_input_tokens"`
} `json:"usage"`
} `json:"message"`
}
if json.Unmarshal(frame["message"], &v.Message) == nil {
usage.PromptTokens = v.Message.Usage.InputTokens
usage.CacheReadTokens = v.Message.Usage.CacheReadTokens
usage.CacheWriteTokens = v.Message.Usage.CacheWriteTokens
}
case "content_block_start":
var v struct {
Index int `json:"index"`
ContentBlock struct {
Type string `json:"type"`
ID string `json:"id"`
Name string `json:"name"`
Data string `json:"data"`
} `json:"content_block"`
}
if json.Unmarshal(frame["index"], &v.Index) == nil {
_ = json.Unmarshal(frame["content_block"], &v.ContentBlock)
b := &anthropicBlock{typ: v.ContentBlock.Type, toolID: v.ContentBlock.ID, toolName: v.ContentBlock.Name, redacted: v.ContentBlock.Data}
blocks[v.Index] = b
if v.ContentBlock.Type == "tool_use" {
out <- Event{Kind: KindToolCallDelta, ToolIndex: v.Index, ToolID: v.ContentBlock.ID, ToolName: v.ContentBlock.Name}
}
}
case "content_block_delta":
var v struct {
Index int `json:"index"`
Delta struct {
Type string `json:"type"`
Text string `json:"text"`
Thinking string `json:"thinking"`
Signature string `json:"signature"`
Partial string `json:"partial_json"`
} `json:"delta"`
}
if json.Unmarshal(frame["index"], &v.Index) != nil {
continue
}
_ = json.Unmarshal(frame["delta"], &v.Delta)
b := blocks[v.Index]
switch v.Delta.Type {
case "text_delta":
content.WriteString(v.Delta.Text)
out <- Event{Kind: KindTextDelta, Text: v.Delta.Text}
case "thinking_delta":
if b != nil {
b.thinking.WriteString(v.Delta.Thinking)
}
out <- Event{Kind: KindReasoningDelta, ReasoningDelta: v.Delta.Thinking}
case "signature_delta":
if b != nil {
b.signature.WriteString(v.Delta.Signature)
}
case "input_json_delta":
if b != nil {
b.args.WriteString(v.Delta.Partial)
}
out <- Event{Kind: KindToolCallDelta, ToolIndex: v.Index, ArgumentsDelta: v.Delta.Partial}
}
case "content_block_stop":
var v struct {
Index int `json:"index"`
}
if json.Unmarshal(frame["index"], &v.Index) != nil {
continue
}
b := blocks[v.Index]
if b == nil {
continue
}
switch b.typ {
case "tool_use":
calls = append(calls, ToolCall{ID: b.toolID, Name: b.toolName, Arguments: b.args.String()})
case "thinking":
thinking = append(thinking, ThinkingBlock{Thinking: b.thinking.String(), Signature: b.signature.String()})
case "redacted_thinking":
thinking = append(thinking, ThinkingBlock{Redacted: true, Data: b.redacted})
}
case "message_delta":
var v struct {
Delta struct {
StopReason string `json:"stop_reason"`
} `json:"delta"`
Usage struct {
OutputTokens int `json:"output_tokens"`
} `json:"usage"`
}
_ = json.Unmarshal(frame["delta"], &v.Delta)
_ = json.Unmarshal(frame["usage"], &v.Usage)
finishReason = v.Delta.StopReason
usage.CompletionTokens = v.Usage.OutputTokens
if v.Delta.StopReason == "tool_use" {
stopReason = StopToolCalls
}
case "error":
msg := data
var v struct {
Error struct {
Type string `json:"type"`
Message string `json:"message"`
} `json:"error"`
}
if json.Unmarshal(frame["error"], &v.Error) == nil && v.Error.Message != "" {
msg = v.Error.Type + ": " + v.Error.Message
}
out <- Event{Kind: KindError, ErrorMessage: msg, Err: &StreamError{Message: msg}, Message: Message{Role: RoleAssistant, StopReason: StopError}}
return
}
}
}
if err := sc.Err(); err != nil {
if ctx.Err() != nil {
emitAbort(out, "", ctx.Err())
return
}
emitError(out, "", true, "anthropic sse read: "+err.Error())
return
}
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
out <- Event{
Kind: KindMessageEnd,
Message: Message{Role: RoleAssistant, Content: content.String(), ToolCalls: calls, Thinking: thinking, StopReason: stopReason},
Usage: usage,
FinishReason: finishReason,
}
}
+250
View File
@@ -0,0 +1,250 @@
package llm
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
// ---- N2 WP2:Anthropic 原生客户端(验收① 的机器证据)----
// fixtureServer 按调用序回放 testdata/anthropic 下的录制 SSE,并记录请求。
type fixtureServer struct {
t *testing.T
requests []recordedRequest
count int
}
type recordedRequest struct {
path string
apiKey string
version string
body map[string]any
}
func (f *fixtureServer) handler(w http.ResponseWriter, r *http.Request) {
f.count++
body, _ := io.ReadAll(r.Body)
var parsed map[string]any
_ = json.Unmarshal(body, &parsed)
f.requests = append(f.requests, recordedRequest{
path: r.URL.Path,
apiKey: r.Header.Get("x-api-key"),
version: r.Header.Get("anthropic-version"),
body: parsed,
})
names := []string{"01-tool-use.sse", "02-thinking-text.sse", "03-text.sse"}
raw, err := os.ReadFile(filepath.Join("testdata", "anthropic", names[f.count-1]))
if err != nil {
f.t.Fatalf("fixture %d: %v", f.count, err)
}
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write(raw)
}
// anthropicPresetClient 经预设表构建客户端(验收的是「选 anthropic 预设」本身),
// BaseURL 指到夹具服务器(预设只是结构体,测试内覆盖端点,不打真实网络)。
func anthropicPresetClient(t *testing.T, base string) *Client {
t.Helper()
var preset Provider
for _, p := range Presets() {
if p.Name == "anthropic" {
preset = p
}
}
if preset.Name == "" {
t.Fatal("anthropic preset missing")
}
preset.BaseURL = base
preset.APIKey = "fixture-key"
c, err := ClientFor(preset)
if err != nil {
t.Fatalf("ClientFor: %v", err)
}
return c
}
func collect(t *testing.T, ch <-chan Event) (end Event, texts, reasoning []string) {
t.Helper()
for ev := range ch {
switch ev.Kind {
case KindTextDelta:
texts = append(texts, ev.Text)
case KindReasoningDelta:
reasoning = append(reasoning, ev.ReasoningDelta)
case KindMessageEnd:
end = ev
case KindError:
t.Fatalf("stream error: %s", ev.ErrorMessage)
}
}
return end, texts, reasoning
}
// TestAnthropicPresetToolRound 验收①:选 anthropic 预设,用录制 HTTP 夹具
// 完成一轮完整工具调用(tools 请求 → tool_use → tool_result → 终文本),
// 且 thinking 块带原签名在下一请求里往返。
func TestAnthropicPresetToolRound(t *testing.T) {
fs := &fixtureServer{t: t}
srv := httptest.NewServer(http.HandlerFunc(fs.handler))
defer srv.Close()
c := anthropicPresetClient(t, srv.URL)
ctx := context.Background()
tools := []ToolDecl{{Name: "read_file", Description: "读文件", Parameters: json.RawMessage(`{"type":"object","properties":{"path":{"type":"string"}}}`)}}
// 第一回合:带 tools 请求 → tool_use。
end1, _, _ := collect(t, c.Stream(ctx, "claude-fixture", []Message{{Role: RoleUser, Content: "查一下 main.go"}}, tools, StreamOptions{}))
if end1.Message.StopReason != StopToolCalls || len(end1.Message.ToolCalls) != 1 {
t.Fatalf("resp1: %+v", end1.Message)
}
call := end1.Message.ToolCalls[0]
if call.ID != "toolu_01ABC" || call.Name != "read_file" || call.Arguments != `{"path":"main.go"}` {
t.Fatalf("tool call: %+v", call)
}
req1 := fs.requests[0]
if req1.path != "/messages" || req1.apiKey != "fixture-key" || req1.version != AnthropicVersion {
t.Fatalf("req1 headers: %+v", req1)
}
toolsBody, _ := req1.body["tools"].([]any)
if len(toolsBody) != 1 {
t.Fatalf("req1 tools: %v", req1.body["tools"])
}
tool0 := toolsBody[0].(map[string]any)
if tool0["name"] != "read_file" || tool0["input_schema"] == nil || tool0["cache_control"] == nil {
t.Fatalf("req1 tool decl: %v", tool0)
}
// 第二回合:assistant(tool_use) + tool_result → thinking(带签名) + 终文本。
msgs2 := []Message{
{Role: RoleUser, Content: "查一下 main.go"},
end1.Message,
{Role: RoleTool, ToolCallID: call.ID, Name: call.Name, Content: "1 package main\n..."},
}
end2, texts2, reasoning2 := collect(t, c.Stream(ctx, "claude-fixture", msgs2, tools, StreamOptions{}))
if end2.Message.StopReason != StopEnd || end2.Message.Content != "查到了,main.go 一共 42 行。" {
t.Fatalf("resp2: %+v", end2.Message)
}
if len(end2.Message.Thinking) != 1 || end2.Message.Thinking[0].Signature != "EqoBCkgIARABGAIiQA" {
t.Fatalf("resp2 thinking: %+v", end2.Message.Thinking)
}
if strings.Join(texts2, "") != end2.Message.Content || len(reasoning2) == 0 {
t.Fatalf("deltas: %v %v", texts2, reasoning2)
}
req2 := fs.requests[1]
msgsBody, _ := req2.body["messages"].([]any)
var toolResult map[string]any
for _, m := range msgsBody {
mm := m.(map[string]any)
if mm["role"] != "user" {
continue
}
for _, blk := range mm["content"].([]any) {
b := blk.(map[string]any)
if b["type"] == "tool_result" {
toolResult = b
}
}
}
if toolResult == nil || toolResult["tool_use_id"] != "toolu_01ABC" {
t.Fatalf("req2 tool_result: %v", toolResult)
}
// 第三回合:assistant(thinking+text) 回送 → 签名必须原样往返。
msgs3 := append(append([]Message{}, msgs2...), end2.Message, Message{Role: RoleUser, Content: "再说一遍"})
end3, _, _ := collect(t, c.Stream(ctx, "claude-fixture", msgs3, tools, StreamOptions{}))
if end3.Message.Content != "42 行。" {
t.Fatalf("resp3: %+v", end3.Message)
}
req3 := fs.requests[2]
var thinkingBlock map[string]any
for _, m := range req3.body["messages"].([]any) {
mm := m.(map[string]any)
if mm["role"] != "assistant" {
continue
}
for _, blk := range mm["content"].([]any) {
b := blk.(map[string]any)
if b["type"] == "thinking" {
thinkingBlock = b
}
}
}
if thinkingBlock == nil || thinkingBlock["signature"] != "EqoBCkgIARABGAIiQA" {
t.Fatalf("req3 thinking round-trip: %v", thinkingBlock)
}
}
// TestAnthropicBodyShape 组包单测:tool_use/tool_result/thinking 块形态、
// system 与末位工具的 cache_control、max_tokens 默认。
func TestAnthropicBodyShape(t *testing.T) {
msgs := []Message{
{Role: RoleSystem, Content: "sys"},
{Role: RoleUser, Content: "u"},
{Role: RoleAssistant, Thinking: []ThinkingBlock{{Thinking: "想", Signature: "sig"}}, ToolCalls: []ToolCall{{ID: "t1", Name: "read", Arguments: `{"a":1}`}}},
{Role: RoleTool, ToolCallID: "t1", Name: "read", Content: "r1"},
{Role: RoleTool, ToolCallID: "t2", Name: "grep", Content: "r2", IsError: true},
}
raw, err := anthropicBody("claude", msgs, []ToolDecl{{Name: "read", Description: "读"}}, StreamOptions{})
if err != nil {
t.Fatal(err)
}
var body map[string]any
if err := json.Unmarshal(raw, &body); err != nil {
t.Fatal(err)
}
if body["max_tokens"] != float64(128000) {
t.Fatalf("max_tokens 默认:%v", body["max_tokens"])
}
sys := body["system"].([]any)[0].(map[string]any)
if sys["cache_control"] == nil {
t.Fatal("system 应挂 cache_control")
}
out := body["messages"].([]any)
// assistant:思考块在最前,其后 tool_use。
assistant := out[1].(map[string]any)
blocks := assistant["content"].([]any)
if blocks[0].(map[string]any)["type"] != "thinking" || blocks[1].(map[string]any)["type"] != "tool_use" {
t.Fatalf("assistant 块序:%v", blocks)
}
// 连续两条 tool 结果折叠进同一条 user 消息。
folded := out[2].(map[string]any)
if folded["role"] != "user" || len(folded["content"].([]any)) != 2 {
t.Fatalf("tool_result 折叠:%v", folded)
}
second := folded["content"].([]any)[1].(map[string]any)
if second["type"] != "tool_result" || second["is_error"] != true {
t.Fatalf("tool_result is_error:%v", second)
}
// 末位工具挂 cache_control。
tool := body["tools"].([]any)[0].(map[string]any)
if tool["cache_control"] == nil {
t.Fatal("末位工具应挂 cache_control")
}
}
// TestAnthropicSSEErrorFrame 协议内 error 帧映射为 KindError。
func TestAnthropicSSEErrorFrame(t *testing.T) {
raw := "event: error\ndata: {\"type\":\"error\",\"error\":{\"type\":\"overloaded_error\",\"message\":\"Overloaded\"}}\n\n"
ch := make(chan Event, 1)
go func() {
defer close(ch)
parseAnthropicSSE(context.Background(), strings.NewReader(raw), ch)
}()
var got []Event
for ev := range ch {
got = append(got, ev)
}
if len(got) != 1 || got[0].Kind != KindError || !strings.Contains(got[0].ErrorMessage, "Overloaded") {
t.Fatalf("%+v", got)
}
}
+80
View File
@@ -0,0 +1,80 @@
package llm
import (
"io"
"net/http"
"slices"
)
const maxObservedBodyBytes = 32*1024*1024 + 1
// bodyObserver counts the bytes a reader returns from an HTTP response body
// and whether it reached io.EOF. It never copies or keeps the bytes.
type bodyObserver struct {
src io.Reader
protocol string
framing string
declared int64
uncompressed bool
bytes int64
saturated bool
eof bool
}
// newBodyObserver classifies only what net/http exposes on the response;
// anything it cannot see reliably is recorded as unknown.
func newBodyObserver(resp *http.Response) *bodyObserver {
o := &bodyObserver{src: resp.Body, protocol: "other", framing: "unknown",
declared: resp.ContentLength, uncompressed: resp.Uncompressed}
switch resp.ProtoMajor {
case 1:
o.protocol = "http1"
switch {
case slices.Contains(resp.TransferEncoding, "chunked"):
o.framing = "chunked"
case resp.Uncompressed:
// Transparent decompression hides the wire framing and length.
case resp.ContentLength >= 0:
o.framing = "known_length"
case resp.Close:
o.framing = "close_delimited"
}
case 2:
o.protocol = "http2"
o.framing = "http2_stream"
}
return o
}
func (o *bodyObserver) Read(p []byte) (int, error) {
n, err := o.src.Read(p)
if n > 0 && !o.saturated {
if int64(n) >= maxObservedBodyBytes-o.bytes {
o.bytes = maxObservedBodyBytes
o.saturated = true
} else {
o.bytes += int64(n)
}
}
if err == io.EOF {
o.eof = true
}
return n, err
}
// snapshot must be taken after the last Read has returned.
func (o *bodyObserver) snapshot() *BodyBoundaryObservation {
relation := "unknown"
if o.declared >= 0 && !o.uncompressed && !o.saturated && o.eof {
relation = "mismatch"
if o.bytes == o.declared {
relation = "match"
}
}
return &BodyBoundaryObservation{
ProtocolClass: o.protocol, FramingClass: o.framing,
DeclaredLengthKnown: o.declared >= 0, BytesRead: o.bytes,
BytesSaturated: o.saturated, EOFSeen: o.eof,
LengthRelation: relation, Uncompressed: o.uncompressed,
}
}
+83 -11
View File
@@ -11,11 +11,24 @@ import (
"time"
)
// Client calls an OpenAI-compatible chat completions endpoint with streaming.
// Client calls a chat completions endpoint with streaming.
// 默认讲 OpenAI 兼容协议;Protocol 为 ProtocolAnthropic 时讲原生
// Anthropic Messages(组包与 BuildBody 共用,见 anthropic.go)。
type Client struct {
BaseURL string
APIKey string
HTTPClient *http.Client
// Protocol 空 = openai-chat(零值兼容所有直接构造点)。
Protocol Protocol
// ReasoningSplit 在 openai-chat 请求体加 "reasoning_split": true:
// MiniMax 等 interleaved-thinking 提供商据此把思考放进独立的
// reasoning_content 字段而不是混在 content 的 <think> 块里(分流后
// SSE 的 reasoning_content 捕获照常生效)。对不认识该字段的服务器
// 属未知字段,多数按忽略处理;宿主按模型名自行决定是否置位。
ReasoningSplit bool
// RouteContract optionally releases tool calls from an openai-chat stream
// that ended without [DONE]; nil or the zero value is off.
RouteContract *RouteContractEOF
}
// Stream implements StreamFunc. Transport and provider failures are encoded as
@@ -24,6 +37,10 @@ func (c *Client) Stream(ctx context.Context, model string, messages []Message, t
out := make(chan Event)
go func() {
defer close(out)
if c.Protocol == ProtocolAnthropic {
c.streamAnthropic(ctx, model, messages, tools, opts, out)
return
}
c.run(ctx, model, messages, tools, opts, out)
}()
return out
@@ -59,6 +76,9 @@ func (c *Client) run(ctx context.Context, model string, messages []Message, tool
if effort := OpenAIReasoningEffort(opts.ThinkingLevel); effort != "" {
body["reasoning_effort"] = effort
}
if c.ReasoningSplit {
body["reasoning_split"] = true
}
if len(tools) > 0 {
body["tools"] = toProviderTools(tools)
body["tool_choice"] = "auto"
@@ -83,10 +103,10 @@ func (c *Client) run(ctx context.Context, model string, messages []Message, tool
resp, err := hc.Do(req)
if err != nil {
if ctx.Err() != nil {
emitAbort(out, "", ctx.Err())
emitAbort(out, "", ctx.Err(), &TerminalObservation{ReadOutcome: "cancelled"})
return
}
emitError(out, "", true, err.Error())
emitError(out, "", true, err.Error(), &TerminalObservation{ReadOutcome: "transport_error"})
return
}
defer resp.Body.Close()
@@ -94,12 +114,13 @@ func (c *Client) run(ctx context.Context, model string, messages []Message, tool
slurp, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
msg := fmt.Sprintf("provider status %d: %s", resp.StatusCode, strings.TrimSpace(string(slurp)))
ev := Event{
Kind: KindError,
HTTPStatus: resp.StatusCode,
Retryable: StatusRetryable(resp.StatusCode) && !ContextOverflow(msg),
ErrorMessage: msg,
Err: &StreamError{Message: msg, Retryable: StatusRetryable(resp.StatusCode) && !ContextOverflow(msg)},
Message: Message{Role: RoleAssistant, StopReason: StopError},
Kind: KindError,
HTTPStatus: resp.StatusCode,
TerminalObservation: &TerminalObservation{ReadOutcome: "provider_error"},
Retryable: StatusRetryable(resp.StatusCode) && !ContextOverflow(msg),
ErrorMessage: msg,
Err: &StreamError{Message: msg, Retryable: StatusRetryable(resp.StatusCode) && !ContextOverflow(msg)},
Message: Message{Role: RoleAssistant, StopReason: StopError},
}
if d, ok := ParseRetryAfter(resp.Header.Get("Retry-After"), time.Now()); ok {
ev.HasRetryAfter = true
@@ -108,11 +129,55 @@ func (c *Client) run(ctx context.Context, model string, messages []Message, tool
out <- ev
return
}
for ev := range ParseSSE(ctx, resp.Body) {
observed := newBodyObserver(resp)
contract := c.newRouteContract(model, base, req, resp, tools)
var facts sseFacts
var hooks sseHooks
if contract != nil {
hooks.facts = &facts
hooks.decideEOF = func(success, failure Event) Event {
check := contract.check(observed.snapshot(), failure.TerminalObservation, facts, success.Message.ToolCalls)
if check != "" {
failure.TerminalObservation.ContractCheck = check
return failure
}
success.CompletionBasis = CompletionRouteContractEOF
success.TerminalObservation.CompletionBasis = CompletionRouteContractEOF
return success
}
}
for ev := range parseSSE(ctx, observed, hooks) {
if ev.Kind == KindMessageEnd || ev.Kind == KindError {
ev.HTTPStatus = resp.StatusCode
// ParseSSE sends its terminal event only after its last body read.
if obs := ev.TerminalObservation; obs != nil {
obs.BodyBoundary = observed.snapshot()
if ev.Kind == KindMessageEnd && ev.Err == nil && ev.StreamComplete {
ev.CompletionBasis = CompletionDoneMarker
obs.CompletionBasis = CompletionDoneMarker
}
if contract != nil && ev.Err != nil && !ev.StreamComplete &&
ev.Message.StopReason != StopAborted && obs.ContractCheck == "" {
obs.ContractCheck = contract.check(obs.BodyBoundary, obs, facts, ev.Message.ToolCalls)
}
}
}
out <- ev
}
}
// replayThink 拼接一条 assistant 消息捕获到的全部思考块。
func replayThink(m Message) string {
var b strings.Builder
for _, t := range m.Thinking {
if t.Thinking != "" {
b.WriteString(t.Thinking)
b.WriteString("\n")
}
}
return strings.TrimRight(b.String(), "\n")
}
func toProviderMessages(messages []Message) []map[string]any {
out := make([]map[string]any, 0, len(messages))
for _, m := range messages {
@@ -124,7 +189,14 @@ func toProviderMessages(messages []Message) []map[string]any {
"content": m.Content,
})
case RoleAssistant:
item := map[string]any{"role": "assistant", "content": m.Content}
// 回放捕获的思考(Message.Thinking 由 SSE 的 reasoning_content 捕获而来):
// 交错思考模型要求历史 assistant 原样带回 <think> 块。没有思考块的
// 消息零改动——非思考模型的上下文不受影响。
content := m.Content
if think := replayThink(m); think != "" {
content = "<think>\n" + think + "\n</think>\n\n" + content
}
item := map[string]any{"role": "assistant", "content": content}
if len(m.ToolCalls) > 0 {
calls := make([]map[string]any, 0, len(m.ToolCalls))
for _, tc := range m.ToolCalls {
+398
View File
@@ -0,0 +1,398 @@
package llm
import (
"bytes"
"compress/gzip"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"sync/atomic"
"testing"
)
type terminalRoundTripFunc func(*http.Request) (*http.Response, error)
func (f terminalRoundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
func TestClientTerminalObservationHTTPStatus(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/chat/completions" {
t.Errorf("path = %q", r.URL.Path)
}
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"ok\"},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":2,\"completion_tokens\":1,\"total_tokens\":3}}\n\ndata: [DONE]\n\n"))
}))
defer server.Close()
client := &Client{BaseURL: server.URL, APIKey: "fixture", HTTPClient: server.Client()}
var end Event
for ev := range client.Stream(context.Background(), "fixture", []Message{{Role: RoleUser, Content: "hello"}}, nil, StreamOptions{}) {
if ev.Kind == KindMessageEnd {
end = ev
}
}
if end.HTTPStatus != http.StatusOK || end.TerminalObservation == nil ||
end.TerminalObservation.ReadOutcome != "done_marker" || !end.StreamComplete || end.Usage.TotalTokens != 3 {
t.Fatalf("successful HTTP terminal lost transport evidence: %#v", end)
}
}
func TestClientTerminalObservationProviderAndTransportFailure(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "PRIVATE_PROVIDER_BODY_R05", http.StatusTooManyRequests)
}))
defer server.Close()
tests := []struct {
name string
client *Client
status int
outcome string
}{
{"provider-http", &Client{BaseURL: server.URL, APIKey: "fixture", HTTPClient: server.Client()}, http.StatusTooManyRequests, "provider_error"},
{"transport", &Client{BaseURL: "http://test.invalid", APIKey: "fixture", HTTPClient: &http.Client{Transport: terminalRoundTripFunc(func(*http.Request) (*http.Response, error) {
return nil, errors.New("PRIVATE_TRANSPORT_ERROR_R05")
})}}, 0, "transport_error"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var end Event
for ev := range tc.client.Stream(context.Background(), "fixture", []Message{{Role: RoleUser, Content: "hello"}}, nil, StreamOptions{}) {
end = ev
}
if end.Kind != KindError || end.HTTPStatus != tc.status || end.TerminalObservation == nil ||
end.TerminalObservation.ReadOutcome != tc.outcome {
t.Fatalf("provider/transport failure was not classified: %#v", end)
}
})
}
}
// bodyBoundaryToolFrames is a complete tool call plus Usage without [DONE].
const bodyBoundaryToolFrames = "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_PRIVATE_R06\",\"function\":{\"name\":\"read_PRIVATE_R06\",\"arguments\":\"{\\\"path\\\":\\\"PRIVATE_R06.txt\\\"}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\n" +
"data: {\"choices\":[],\"usage\":{\"prompt_tokens\":5,\"completion_tokens\":2,\"total_tokens\":7}}\n\n"
const bodyBoundaryDone = "data: [DONE]\n\n"
func streamBodyBoundaryEnd(t *testing.T, ctx context.Context, client *Client) Event {
t.Helper()
var end Event
terminals := 0
for ev := range client.Stream(ctx, "fixture", []Message{{Role: RoleUser, Content: "hello"}}, nil, StreamOptions{}) {
if ev.Kind == KindMessageEnd || ev.Kind == KindError {
end = ev
terminals++
} else if ev.TerminalObservation != nil {
t.Fatalf("non-terminal event carried an observation: %#v", ev)
}
}
if terminals != 1 {
t.Fatalf("terminal events = %d, want 1", terminals)
}
if end.TerminalObservation != nil {
raw, err := json.Marshal(end.TerminalObservation)
if err != nil || strings.Contains(string(raw), "PRIVATE") || strings.Contains(string(raw), "127.0.0.1") {
t.Fatalf("unsafe terminal observation: %s err=%v", raw, err)
}
}
return end
}
type bodyBoundaryWant struct {
protocol, framing, relation string
declared, eof, uncompressed bool
bytes int64
}
func requireBodyBoundary(t *testing.T, end Event, want bodyBoundaryWant) {
t.Helper()
if end.TerminalObservation == nil || end.TerminalObservation.BodyBoundary == nil {
t.Fatalf("missing body boundary observation: %#v", end)
}
body := end.TerminalObservation.BodyBoundary
if body.ProtocolClass != want.protocol || body.FramingClass != want.framing ||
body.LengthRelation != want.relation || body.DeclaredLengthKnown != want.declared ||
body.EOFSeen != want.eof || body.Uncompressed != want.uncompressed || body.BytesSaturated {
t.Fatalf("wrong body boundary: %+v want %+v", body, want)
}
if want.bytes >= 0 && body.BytesRead != want.bytes {
t.Fatalf("bytes read = %d, want %d", body.BytesRead, want.bytes)
}
}
func requireUnreleasedTools(t *testing.T, end Event) {
t.Helper()
if end.Kind != KindMessageEnd || end.Err == nil || end.StreamComplete || len(end.Message.ToolCalls) != 0 ||
end.Usage.TotalTokens != 7 || end.HTTPStatus != http.StatusOK {
t.Fatalf("unfinished tool stream was released or lost usage: %#v", end)
}
}
func fixtureResponseClient(resp func() *http.Response) *Client {
return &Client{BaseURL: "http://fixture.invalid", APIKey: "fixture", HTTPClient: &http.Client{
Transport: terminalRoundTripFunc(func(*http.Request) (*http.Response, error) { return resp(), nil }),
}}
}
func TestClientBodyBoundaryShortRead(t *testing.T) {
t.Run("declared-longer-than-body", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Content-Length", strconv.Itoa(len(bodyBoundaryToolFrames)+64))
_, _ = io.WriteString(w, bodyBoundaryToolFrames)
}))
defer server.Close()
end := streamBodyBoundaryEnd(t, context.Background(), &Client{BaseURL: server.URL, APIKey: "fixture", HTTPClient: server.Client()})
requireUnreleasedTools(t, end)
if end.TerminalObservation.ReadOutcome != "read_error" {
t.Fatalf("short body was not a read error: %+v", end.TerminalObservation)
}
requireBodyBoundary(t, end, bodyBoundaryWant{protocol: "http1", framing: "known_length", relation: "unknown",
declared: true, bytes: int64(len(bodyBoundaryToolFrames))})
})
t.Run("declared-length-mismatch", func(t *testing.T) {
client := fixtureResponseClient(func() *http.Response {
return &http.Response{StatusCode: http.StatusOK, ProtoMajor: 1, ProtoMinor: 1,
ContentLength: int64(len(bodyBoundaryToolFrames) + 64), Header: http.Header{},
Body: io.NopCloser(strings.NewReader(bodyBoundaryToolFrames))}
})
end := streamBodyBoundaryEnd(t, context.Background(), client)
requireUnreleasedTools(t, end)
requireBodyBoundary(t, end, bodyBoundaryWant{protocol: "http1", framing: "known_length", relation: "mismatch",
declared: true, eof: true, bytes: int64(len(bodyBoundaryToolFrames))})
})
}
func TestClientBodyBoundaryUncompressed(t *testing.T) {
var compressed bytes.Buffer
zw := gzip.NewWriter(&compressed)
_, _ = io.WriteString(zw, bodyBoundaryToolFrames)
if err := zw.Close(); err != nil {
t.Fatal(err)
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Content-Encoding", "gzip")
w.Header().Set("Content-Length", strconv.Itoa(compressed.Len()))
_, _ = w.Write(compressed.Bytes())
}))
defer server.Close()
end := streamBodyBoundaryEnd(t, context.Background(), &Client{BaseURL: server.URL, APIKey: "fixture", HTTPClient: server.Client()})
requireUnreleasedTools(t, end)
requireBodyBoundary(t, end, bodyBoundaryWant{protocol: "http1", framing: "unknown", relation: "unknown",
eof: true, uncompressed: true, bytes: int64(len(bodyBoundaryToolFrames))})
}
// doneThenErrorBody returns the whole stream without io.EOF; any later read fails.
type doneThenErrorBody struct {
data *strings.Reader
reads int
}
func (b *doneThenErrorBody) Read(p []byte) (int, error) {
if b.data.Len() > 0 {
return b.data.Read(p)
}
b.reads++
return 0, errors.New("PRIVATE_READ_AFTER_DONE_R06")
}
func (b *doneThenErrorBody) Close() error { return nil }
func TestClientDoneDoesNotNeedBodyEOF(t *testing.T) {
stream := bodyBoundaryToolFrames + bodyBoundaryDone
body := &doneThenErrorBody{data: strings.NewReader(stream)}
client := fixtureResponseClient(func() *http.Response {
return &http.Response{StatusCode: http.StatusOK, ProtoMajor: 1, ProtoMinor: 1, ContentLength: -1,
TransferEncoding: []string{"chunked"}, Header: http.Header{}, Body: body}
})
end := streamBodyBoundaryEnd(t, context.Background(), client)
if end.Kind != KindMessageEnd || end.Err != nil || !end.StreamComplete || len(end.Message.ToolCalls) != 1 ||
end.Message.StopReason != StopToolCalls || end.Usage.TotalTokens != 7 || end.HTTPStatus != http.StatusOK ||
end.TerminalObservation.ReadOutcome != "done_marker" {
t.Fatalf("legal [DONE] tool terminal was not kept: %#v", end)
}
requireBodyBoundary(t, end, bodyBoundaryWant{protocol: "http1", framing: "chunked", relation: "unknown",
bytes: int64(len(stream))})
if body.reads != 0 {
t.Fatalf("parser read past [DONE] %d times", body.reads)
}
var standalone Event
for ev := range ParseSSE(context.Background(), strings.NewReader(stream)) {
standalone = ev
}
if standalone.TerminalObservation == nil || standalone.TerminalObservation.BodyBoundary != nil ||
len(standalone.Message.ToolCalls) != 1 || !standalone.StreamComplete {
t.Fatalf("standalone ParseSSE changed: %#v", standalone)
}
}
func TestClientBodyBoundaryChunkedAndHTTP2(t *testing.T) {
flushed := func(stream string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
_, _ = io.WriteString(w, stream)
w.(http.Flusher).Flush()
}
}
tests := []struct {
name string
server func() *httptest.Server
wantDone bool
want bodyBoundaryWant
}{
{"known-length", func() *httptest.Server {
stream := bodyBoundaryToolFrames + bodyBoundaryDone
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Content-Length", strconv.Itoa(len(stream)))
_, _ = io.WriteString(w, stream)
}))
}, true, bodyBoundaryWant{protocol: "http1", framing: "known_length", relation: "match", declared: true, eof: true,
bytes: int64(len(bodyBoundaryToolFrames + bodyBoundaryDone))}},
{"chunked", func() *httptest.Server {
return httptest.NewServer(flushed(bodyBoundaryToolFrames))
}, false, bodyBoundaryWant{protocol: "http1", framing: "chunked", relation: "unknown", eof: true,
bytes: int64(len(bodyBoundaryToolFrames))}},
{"http2", func() *httptest.Server {
server := httptest.NewUnstartedServer(flushed(bodyBoundaryToolFrames))
server.EnableHTTP2 = true
server.StartTLS()
return server
}, false, bodyBoundaryWant{protocol: "http2", framing: "http2_stream", relation: "unknown", eof: true,
bytes: int64(len(bodyBoundaryToolFrames))}},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
server := tc.server()
defer server.Close()
end := streamBodyBoundaryEnd(t, context.Background(), &Client{BaseURL: server.URL, APIKey: "fixture", HTTPClient: server.Client()})
requireBodyBoundary(t, end, tc.want)
if !tc.wantDone {
requireUnreleasedTools(t, end)
return
}
if end.Err != nil || !end.StreamComplete || len(end.Message.ToolCalls) != 1 || end.Usage.TotalTokens != 7 {
t.Fatalf("legal [DONE] tool terminal was not kept: %#v", end)
}
})
}
}
func TestClientBodyBoundaryCloseDelimitedUnknown(t *testing.T) {
t.Run("close-delimited", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Transfer-Encoding", "identity")
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, bodyBoundaryToolFrames)
w.(http.Flusher).Flush()
}))
defer server.Close()
end := streamBodyBoundaryEnd(t, context.Background(), &Client{BaseURL: server.URL, APIKey: "fixture", HTTPClient: server.Client()})
requireUnreleasedTools(t, end)
requireBodyBoundary(t, end, bodyBoundaryWant{protocol: "http1", framing: "close_delimited", relation: "unknown",
eof: true, bytes: int64(len(bodyBoundaryToolFrames))})
})
t.Run("undeterminable", func(t *testing.T) {
client := fixtureResponseClient(func() *http.Response {
return &http.Response{StatusCode: http.StatusOK, ContentLength: -1, Header: http.Header{},
Body: io.NopCloser(strings.NewReader(bodyBoundaryToolFrames))}
})
end := streamBodyBoundaryEnd(t, context.Background(), client)
requireUnreleasedTools(t, end)
requireBodyBoundary(t, end, bodyBoundaryWant{protocol: "other", framing: "unknown", relation: "unknown",
eof: true, bytes: int64(len(bodyBoundaryToolFrames))})
})
}
func TestClientBodyBoundaryFailuresDoNotClaimEOF(t *testing.T) {
t.Run("cancelled", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
_, _ = io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"content\":\"PRIVATE_R06\"}}]}\n\n")
w.(http.Flusher).Flush()
<-r.Context().Done()
}))
defer server.Close()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
client := &Client{BaseURL: server.URL, APIKey: "fixture", HTTPClient: server.Client()}
var end Event
for ev := range client.Stream(ctx, "fixture", []Message{{Role: RoleUser, Content: "hello"}}, nil, StreamOptions{}) {
if ev.Kind == KindTextDelta {
cancel()
}
if ev.Kind == KindMessageEnd || ev.Kind == KindError {
end = ev
}
}
if end.Kind != KindError || end.Message.StopReason != StopAborted || end.TerminalObservation == nil ||
end.TerminalObservation.ReadOutcome != "cancelled" {
t.Fatalf("cancelled stream was not aborted: %#v", end)
}
if body := end.TerminalObservation.BodyBoundary; body == nil || body.EOFSeen || body.LengthRelation != "unknown" {
t.Fatalf("cancelled stream claimed body EOF: %+v", body)
}
})
t.Run("provider-4xx", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "PRIVATE_PROVIDER_BODY_R06", http.StatusBadRequest)
}))
defer server.Close()
end := streamBodyBoundaryEnd(t, context.Background(), &Client{BaseURL: server.URL, APIKey: "fixture", HTTPClient: server.Client()})
if end.Kind != KindError || end.HTTPStatus != http.StatusBadRequest || end.TerminalObservation == nil ||
end.TerminalObservation.ReadOutcome != "provider_error" || end.TerminalObservation.BodyBoundary != nil {
t.Fatalf("provider error carried a body boundary: %#v", end)
}
})
t.Run("redirect-then-short-read", func(t *testing.T) {
var finalHits atomic.Int32
mux := http.NewServeMux()
mux.HandleFunc("/chat/completions", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Location", "/final/chat/completions")
w.WriteHeader(http.StatusTemporaryRedirect)
_, _ = io.WriteString(w, "PRIVATE_REDIRECT_BODY_R06")
})
mux.HandleFunc("/final/chat/completions", func(w http.ResponseWriter, r *http.Request) {
finalHits.Add(1)
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Content-Length", strconv.Itoa(len(bodyBoundaryToolFrames)+64))
_, _ = io.WriteString(w, bodyBoundaryToolFrames)
})
server := httptest.NewServer(mux)
defer server.Close()
end := streamBodyBoundaryEnd(t, context.Background(), &Client{BaseURL: server.URL, APIKey: "fixture", HTTPClient: server.Client()})
if hits := finalHits.Load(); hits != 1 {
t.Fatalf("redirect target hits = %d, want 1", hits)
}
requireUnreleasedTools(t, end)
requireBodyBoundary(t, end, bodyBoundaryWant{protocol: "http1", framing: "known_length", relation: "unknown",
declared: true, bytes: int64(len(bodyBoundaryToolFrames))})
})
}
func TestClientBodyBoundarySaturatesCount(t *testing.T) {
size := int64(maxObservedBodyBytes + 4096)
observer := newBodyObserver(&http.Response{ProtoMajor: 1, ContentLength: size,
Body: io.NopCloser(io.LimitReader(zeroReader{}, size))})
if n, err := io.Copy(io.Discard, observer); err != nil || n != size {
t.Fatalf("observer changed the body: n=%d err=%v", n, err)
}
body := observer.snapshot()
if body.BytesRead != maxObservedBodyBytes || !body.BytesSaturated || !body.EOFSeen ||
body.LengthRelation != "unknown" || !body.DeclaredLengthKnown {
t.Fatalf("saturated body boundary: %+v", body)
}
}
type zeroReader struct{}
func (zeroReader) Read(p []byte) (int, error) {
clear(p)
return len(p), nil
}
+7 -58
View File
@@ -140,7 +140,7 @@ type Provider struct {
// Presets are OpenAI-compatible or native endpoints that only need an API key.
func Presets() []Provider {
return []Provider{
{Name: "anthropic", Protocol: ProtocolOpenAIChat, BaseURL: "https://api.anthropic.com/v1", APIKeyEnv: "ANTHROPIC_API_KEY"},
{Name: "anthropic", Protocol: ProtocolAnthropic, BaseURL: "https://api.anthropic.com", APIKeyEnv: "ANTHROPIC_API_KEY"},
{Name: "openai", Protocol: ProtocolOpenAIChat, BaseURL: "https://api.openai.com/v1", APIKeyEnv: "OPENAI_API_KEY"},
{Name: "openai-responses", Protocol: ProtocolOpenAIResponses, BaseURL: "https://api.openai.com/v1", APIKeyEnv: "OPENAI_API_KEY"},
{Name: "google", Protocol: ProtocolOpenAIChat, BaseURL: "https://generativelanguage.googleapis.com/v1beta/openai", APIKeyEnv: "GEMINI_API_KEY"},
@@ -196,12 +196,14 @@ func Lookup(name string, custom []Provider) (Provider, bool) {
return Provider{}, false
}
// ClientFor 返回会说该协议的客户端。responses、azure 以及原生 Anthropic/Google
// ClientFor 返回会说该协议的客户端。responses、azure 以及原生 Google
// 协议当前没有接到客户端上,返回明确错误而不是发到错误的路径。
func ClientFor(p Provider) (*Client, error) {
switch p.Protocol {
case ProtocolOpenAIChat:
return &Client{BaseURL: p.BaseURL, APIKey: p.APIKey}, nil
case ProtocolAnthropic:
return &Client{BaseURL: p.BaseURL, APIKey: p.APIKey, Protocol: ProtocolAnthropic}, nil
default:
return nil, fmt.Errorf("llm: protocol %q is not implemented", p.Protocol)
}
@@ -264,29 +266,10 @@ func BuildBody(protocol Protocol, model string, messages []Message, tools []Tool
body["reasoning"] = map[string]any{"effort": effort}
}
return json.Marshal(body)
// ProtocolAnthropic 与 ProtocolGoogle 只供离线组包。
// 当前 Client 只发 OpenAI chat completions,这两支没有接到客户端上。
// 原生协议是后续阶段的事;预设表里的 anthropic / google 已改走兼容端点。
// ProtocolGoogle 只供离线组包(客户端未接);Anthropic 与线上客户端
// 共用 anthropicBody(同一组包,消灭离线/线上两张皮)。
case ProtocolAnthropic:
sys, msgs := anthropicMessages(messages)
body := map[string]any{
"model": model,
"messages": msgs,
"max_tokens": opts.MaxTokens,
"stream": true,
}
// Anthropic 要求 max_tokens。0 表示调用方不限制:用足够大的默认值,
// 避免 4096 把长回复截断。真正的上限由调用方 StreamOptions.MaxTokens 决定。
if body["max_tokens"] == 0 {
body["max_tokens"] = 128000
}
if sys != "" {
body["system"] = sys
}
if budget := AnthropicThinkingBudget(opts.ThinkingLevel); budget > 0 {
body["thinking"] = map[string]any{"type": "enabled", "budget_tokens": budget}
}
return json.Marshal(body)
return anthropicBody(model, messages, tools, opts)
case ProtocolGoogle:
body := map[string]any{
"contents": googleContents(messages),
@@ -317,40 +300,6 @@ func responsesInput(messages []Message) []map[string]any {
return out
}
func anthropicMessages(messages []Message) (string, []map[string]any) {
var system strings.Builder
var out []map[string]any
for _, m := range messages {
if m.Role == RoleSystem {
if system.Len() > 0 {
system.WriteString("\n")
}
system.WriteString(m.Content)
continue
}
role := "user"
if m.Role == RoleAssistant {
role = "assistant"
}
content := []map[string]any{}
if m.Content != "" {
content = append(content, map[string]any{"type": "text", "text": m.Content})
}
for _, img := range m.Images {
content = append(content, map[string]any{
"type": "image",
"source": map[string]any{
"type": "base64",
"media_type": firstNonEmpty(img.MIME, "image/png"),
"data": img.Data,
},
})
}
out = append(out, map[string]any{"role": role, "content": content})
}
return system.String(), out
}
func googleContents(messages []Message) []map[string]any {
var out []map[string]any
for _, m := range messages {
+15 -5
View File
@@ -69,19 +69,29 @@ func TestModelsJSONAndBodies(t *testing.T) {
}
func TestPresetsAndClientForChatCompletions(t *testing.T) {
want := map[string]string{
"anthropic": "https://api.anthropic.com/v1",
"google": "https://generativelanguage.googleapis.com/v1beta/openai",
// google 仍走 OpenAI 兼容端点;anthropic 自 N2 起是原生 Messages 协议。
want := map[string]struct {
protocol Protocol
base string
}{
"anthropic": {ProtocolAnthropic, "https://api.anthropic.com"},
"google": {ProtocolOpenAIChat, "https://generativelanguage.googleapis.com/v1beta/openai"},
}
for _, p := range Presets() {
base, ok := want[p.Name]
w, ok := want[p.Name]
if !ok {
continue
}
if p.Protocol != ProtocolOpenAIChat || p.BaseURL != base {
if p.Protocol != w.protocol || p.BaseURL != w.base {
t.Fatalf("%s preset: %+v", p.Name, p)
}
}
// 原生 anthropic 有真实客户端(审计 N4 关闭);responses/azure/google 仍 fail-closed。
if p, ok := Lookup("anthropic", nil); !ok {
t.Fatal("anthropic preset missing")
} else if c, err := ClientFor(p); err != nil || c.Protocol != ProtocolAnthropic {
t.Fatalf("anthropic ClientFor: %v %+v", err, c)
}
for _, name := range []string{"openai-responses", "azure"} {
p, ok := Lookup(name, nil)
if !ok {
+47
View File
@@ -0,0 +1,47 @@
package llm
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// TestOpenAIChatBodyReasoningSplit 验证 Client.ReasoningSplit 置位时
// openai-chat 请求体带 "reasoning_split": true,未置位时不带(未知字段
// 不外溢给别的提供商)。
func TestOpenAIChatBodyReasoningSplit(t *testing.T) {
var bodies []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw, _ := io.ReadAll(r.Body)
bodies = append(bodies, string(raw))
w.Header().Set("Content-Type", "text/event-stream")
_, _ = io.WriteString(w, "data: [DONE]\n\n")
}))
defer srv.Close()
drain := func(c *Client) {
for range c.Stream(context.Background(), "MiniMax-M3",
[]Message{{Role: RoleUser, Content: "hi"}}, nil, StreamOptions{}) {
}
}
drain(&Client{BaseURL: srv.URL, APIKey: "k"})
drain(&Client{BaseURL: srv.URL, APIKey: "k", ReasoningSplit: true})
if len(bodies) != 2 {
t.Fatalf("requests = %d", len(bodies))
}
if strings.Contains(bodies[0], "reasoning_split") {
t.Fatalf("flag off must not leak the field: %s", bodies[0])
}
var got map[string]any
if err := json.Unmarshal([]byte(bodies[1]), &got); err != nil {
t.Fatal(err)
}
if v, ok := got["reasoning_split"].(bool); !ok || !v {
t.Fatalf("flag on must set reasoning_split=true: %s", bodies[1])
}
}
+164
View File
@@ -0,0 +1,164 @@
package llm
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"net/http"
"net/url"
"strings"
"agent-native/runtime/argschema"
)
// Completion bases recorded on a successful OpenAI-chat message_end.
const (
CompletionDoneMarker = "done_marker"
CompletionRouteContractEOF = "route_contract_eof"
)
// RouteContractEOF lets one exact OpenAI-chat route release tool calls from a
// stream that ended without [DONE], when the HTTP body boundary and every
// frame prove the calls complete. The zero value is off. IdentitySHA256 must
// equal RouteContractIdentity(Model, BaseURL), and BaseURL must already be in
// NormalizeRouteBaseURL form.
type RouteContractEOF struct {
Model string
BaseURL string
IdentitySHA256 string
}
// RouteContractIdentity is the lowercase hex sha256 of
// "openai-chat\n" + model + "\n" + baseURL.
func RouteContractIdentity(model, baseURL string) string {
sum := sha256.Sum256([]byte("openai-chat\n" + model + "\n" + baseURL))
return hex.EncodeToString(sum[:])
}
// NormalizeRouteBaseURL returns the https base URL with lowercase scheme and
// host and no trailing '/'. Any other scheme, userinfo, query or fragment is
// not a match.
func NormalizeRouteBaseURL(raw string) (string, bool) {
if strings.ContainsAny(raw, "?#") {
return "", false
}
u, err := url.Parse(raw)
if err != nil || !strings.EqualFold(u.Scheme, "https") || u.Opaque != "" ||
u.User != nil || u.Host == "" || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" {
return "", false
}
return "https://" + strings.ToLower(u.Host) + strings.TrimRight(u.EscapedPath(), "/"), true
}
func (rc *RouteContractEOF) off() bool {
return rc == nil || *rc == RouteContractEOF{}
}
// admits is condition C1 apart from the redirect check.
func (rc *RouteContractEOF) admits(protocol Protocol, model, base string) bool {
if protocol != "" && protocol != ProtocolOpenAIChat {
return false
}
if model != rc.Model {
return false
}
normalized, ok := NormalizeRouteBaseURL(base)
if !ok || normalized != rc.BaseURL {
return false
}
return RouteContractIdentity(rc.Model, rc.BaseURL) == rc.IdentitySHA256
}
// routeContract evaluates a configured RouteContractEOF for one response.
type routeContract struct {
active bool
tools []ToolDecl
}
// newRouteContract returns nil when the client has no contract configured.
func (c *Client) newRouteContract(model, base string, req *http.Request, resp *http.Response, tools []ToolDecl) *routeContract {
if c.RouteContract.off() {
return nil
}
return &routeContract{
active: c.RouteContract.admits(c.Protocol, model, base) && sameTarget(req, resp),
tools: tools,
}
}
// sameTarget rejects any redirect that changed scheme, host, port or path.
func sameTarget(req *http.Request, resp *http.Response) bool {
if req == nil || req.URL == nil || resp == nil || resp.Request == nil || resp.Request.URL == nil {
return false
}
sent, final := req.URL, resp.Request.URL
return strings.EqualFold(sent.Scheme, final.Scheme) &&
strings.EqualFold(sent.Hostname(), final.Hostname()) &&
sent.Port() == final.Port() &&
sent.EscapedPath() == final.EscapedPath()
}
// check returns the first failing condition id, or "" when all hold.
func (rc *routeContract) check(boundary *BodyBoundaryObservation, obs *TerminalObservation, facts sseFacts, calls []ToolCall) string {
switch {
case !rc.active:
return "C1"
case !boundaryProven(boundary):
return "C2"
case obs == nil || obs.ReadOutcome != "clean_eof":
return "C3"
case facts.finishChunks != 1 || facts.finishReason != "tool_calls" || facts.multiChoice:
return "C4"
case !obs.UsagePresent:
return "C5"
case facts.fragmentAfterFinish:
return "C6"
case !callsComplete(calls):
return "C7"
case !callsDeclared(calls, rc.tools):
return "C8"
case obs.TailEventKind != "json_frame" || !obs.LastFrameJSONComplete:
return "C9"
}
return ""
}
func boundaryProven(b *BodyBoundaryObservation) bool {
if b == nil {
return false
}
return (b.FramingClass == "http2_stream" && b.EOFSeen) ||
(b.FramingClass == "known_length" && b.LengthRelation == "match")
}
func callsComplete(calls []ToolCall) bool {
if len(calls) == 0 {
return false
}
for _, call := range calls {
var args map[string]json.RawMessage
if call.ID == "" || call.Name == "" {
return false
}
if err := json.Unmarshal([]byte(call.Arguments), &args); err != nil || args == nil {
return false
}
}
return true
}
func callsDeclared(calls []ToolCall, tools []ToolDecl) bool {
for _, call := range calls {
idx := -1
for i, decl := range tools {
if decl.Name == call.Name {
idx = i
break
}
}
if idx < 0 || argschema.Validate(tools[idx].Parameters, json.RawMessage(call.Arguments)) != nil {
return false
}
}
return true
}
+630
View File
@@ -0,0 +1,630 @@
package llm
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"reflect"
"strconv"
"strings"
"testing"
)
const (
rcModel = "fixture-model"
rcEOFReason = "tool call stream ended before [DONE]"
)
var rcTools = []ToolDecl{
{Name: "read_PRIVATE_I1", Parameters: json.RawMessage(`{"type":"object","required":["path"],"properties":{"path":{"type":"string"}}}`)},
{Name: "list_PRIVATE_I1", Parameters: json.RawMessage(`{"type":"object","properties":{"dir":{"type":"string"}}}`)},
}
func rcFrame(v any) string {
raw, err := json.Marshal(v)
if err != nil {
panic(err)
}
return "data: " + string(raw) + "\n\n"
}
func rcChunk(choices ...map[string]any) string {
return rcFrame(map[string]any{"choices": choices})
}
func rcFrag(index int, id, name, args string) map[string]any {
fn := map[string]any{}
if name != "" {
fn["name"] = name
}
if args != "" {
fn["arguments"] = args
}
frag := map[string]any{"index": index, "function": fn}
if id != "" {
frag["id"] = id
}
return frag
}
func rcToolChoice(finish string, frags ...map[string]any) map[string]any {
choice := map[string]any{"delta": map[string]any{"tool_calls": frags}}
if finish != "" {
choice["finish_reason"] = finish
}
return choice
}
func rcFinishChunk(reason string) string {
return rcChunk(map[string]any{"delta": map[string]any{}, "finish_reason": reason})
}
var (
rcText = rcChunk(map[string]any{"delta": map[string]any{"content": "PRIVATE_TEXT_I1", "reasoning_content": "PRIVATE_THINK_I1"}})
rcCall1 = rcChunk(rcToolChoice("", rcFrag(0, "call_PRIVATE_I1", "read_PRIVATE_I1", `{"path":`)))
rcCall2 = rcChunk(rcToolChoice("", rcFrag(0, "", "", `"PRIVATE_I1.txt"}`)))
rcFinish = rcFinishChunk("tool_calls")
rcUsage = rcFrame(map[string]any{"choices": []any{}, "usage": map[string]any{"prompt_tokens": 5, "completion_tokens": 2, "total_tokens": 7}})
rcDone = "data: [DONE]\n\n"
rcStream = rcText + rcCall1 + rcCall2 + rcFinish + rcUsage
rcCall = ToolCall{ID: "call_PRIVATE_I1", Name: "read_PRIVATE_I1", Arguments: `{"path":"PRIVATE_I1.txt"}`}
rcErrorFrame = rcFrame(map[string]any{"error": map[string]any{"message": "PRIVATE_PROVIDER_I1"}})
)
// rcSingleCall streams one complete call in one fragment with the given parts.
func rcSingleCall(id, name, args string) string {
return rcText + rcChunk(rcToolChoice("", rcFrag(0, id, name, args))) + rcFinish + rcUsage
}
func rcWrite(stream string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
_, _ = io.WriteString(w, stream)
w.(http.Flusher).Flush()
}
}
func rcHTTP2(t *testing.T, h http.Handler) *httptest.Server {
t.Helper()
server := httptest.NewUnstartedServer(h)
server.EnableHTTP2 = true
server.StartTLS()
t.Cleanup(server.Close)
return server
}
func rcTLS1(t *testing.T, h http.Handler) *httptest.Server {
t.Helper()
server := httptest.NewTLSServer(h)
t.Cleanup(server.Close)
return server
}
func rcPlain(t *testing.T, h http.Handler) *httptest.Server {
t.Helper()
server := httptest.NewServer(h)
t.Cleanup(server.Close)
return server
}
// rcHTTPClient trusts server like server.Client() but owns its connection
// pool, so each run starts on a fresh connection and HTTP/2 stream ids match.
func rcHTTPClient(t *testing.T, server *httptest.Server) *http.Client {
t.Helper()
base := server.Client().Transport.(*http.Transport)
transport := &http.Transport{TLSClientConfig: base.TLSClientConfig.Clone(), ForceAttemptHTTP2: true}
t.Cleanup(transport.CloseIdleConnections)
return &http.Client{Transport: transport}
}
func rcClientFor(t *testing.T, server *httptest.Server) *Client {
return &Client{BaseURL: server.URL, APIKey: "fixture", HTTPClient: rcHTTPClient(t, server)}
}
func rcContract(model, base string) *RouteContractEOF {
return &RouteContractEOF{Model: model, BaseURL: base, IdentitySHA256: RouteContractIdentity(model, base)}
}
func rcFixtureClient(resp func() *http.Response) *Client {
return &Client{BaseURL: "https://fixture.invalid", APIKey: "fixture", HTTPClient: &http.Client{
Transport: terminalRoundTripFunc(func(r *http.Request) (*http.Response, error) {
res := resp()
res.Request = r
return res, nil
}),
}}
}
type rcSetup struct {
client *Client
contract *RouteContractEOF
opts StreamOptions
tools []ToolDecl
}
type rcResult struct {
end Event
releasedMessages int
releasedCalls int
}
func rcRun(t *testing.T, ctx context.Context, base *Client, contract *RouteContractEOF, tools []ToolDecl, opts StreamOptions, onEvent func(Event)) rcResult {
t.Helper()
client := *base
client.RouteContract = contract
var res rcResult
terminals := 0
for ev := range client.Stream(ctx, rcModel, []Message{{Role: RoleUser, Content: "hello"}}, tools, opts) {
if onEvent != nil {
onEvent(ev)
}
if ev.Kind != KindMessageEnd && ev.Kind != KindError {
continue
}
terminals++
res.end = ev
if ev.Kind == KindMessageEnd && ev.Err == nil && len(ev.Message.ToolCalls) > 0 {
res.releasedMessages++
res.releasedCalls += len(ev.Message.ToolCalls)
}
}
if terminals != 1 {
t.Fatalf("terminal events = %d, want 1", terminals)
}
rcRequireSafeLabels(t, res.end)
return res
}
// rcRequireSafeLabels is the sentinel scan: the new labels hold only their
// fixed enums and the observation never carries content, tool identity,
// arguments or error text.
func rcRequireSafeLabels(t *testing.T, end Event) {
t.Helper()
obs := end.TerminalObservation
if obs == nil {
t.Fatalf("terminal without observation: %#v", end)
}
switch end.CompletionBasis {
case "", CompletionDoneMarker, CompletionRouteContractEOF:
default:
t.Fatalf("completion basis outside enum: %q", end.CompletionBasis)
}
if obs.CompletionBasis != end.CompletionBasis {
t.Fatalf("observation basis %q != event basis %q", obs.CompletionBasis, end.CompletionBasis)
}
if c := obs.ContractCheck; c != "" && (len(c) != 2 || c[0] != 'C' || c[1] < '1' || c[1] > '9') {
t.Fatalf("contract check outside enum: %q", c)
}
raw, err := json.Marshal(obs)
if err != nil {
t.Fatal(err)
}
for _, secret := range []string{"PRIVATE", "[DONE]", "tool call", "fixture-model"} {
if strings.Contains(string(raw), secret) {
t.Fatalf("observation leaked %q: %s", secret, raw)
}
}
for _, label := range []string{end.CompletionBasis, obs.CompletionBasis, obs.ContractCheck} {
if strings.Contains(label, "PRIVATE") || strings.Contains(label, " ") {
t.Fatalf("label carries content: %q", label)
}
}
}
func rcStreamCase(server func(*testing.T, http.Handler) *httptest.Server, stream string) func(*testing.T) rcSetup {
return func(t *testing.T) rcSetup {
s := server(t, rcWrite(stream))
return rcSetup{client: rcClientFor(t, s), contract: rcContract(rcModel, s.URL)}
}
}
func rcH2(stream string) func(*testing.T) rcSetup { return rcStreamCase(rcHTTP2, stream) }
func rcRedirectCase(target func(t *testing.T) string) func(*testing.T) rcSetup {
return func(t *testing.T) rcSetup {
location := target(t)
mux := http.NewServeMux()
mux.HandleFunc("/chat/completions", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, location, http.StatusTemporaryRedirect)
})
mux.HandleFunc("/final/chat/completions", rcWrite(rcStream))
s := rcHTTP2(t, mux)
return rcSetup{client: rcClientFor(t, s), contract: rcContract(rcModel, s.URL)}
}
}
type rcCase struct {
name string
setup func(*testing.T) rcSetup
// want is done_marker, route_contract_eof, off, or a check id C1..C9.
want string
// reason is the contract-off failure text; empty skips the check.
reason string
calls []ToolCall
noUsage bool
}
func rcCases() []rcCase {
twoCalls := rcText + rcCall1 + rcCall2 +
rcChunk(rcToolChoice("", rcFrag(1, "call_PRIVATE_I1_b", "list_PRIVATE_I1", `{}`))) + rcFinish + rcUsage
return []rcCase{
// [DONE] keeps today's path whether or not the contract is configured.
{name: "done/contract-on", setup: rcH2(rcStream + rcDone), want: CompletionDoneMarker},
{name: "done/contract-c1-failing", setup: func(t *testing.T) rcSetup {
s := rcH2(rcStream + rcDone)(t)
s.contract = rcContract("other-model", s.client.BaseURL)
return s
}, want: CompletionDoneMarker},
{name: "done/known-length", setup: rcKnownLength(rcStream + rcDone), want: CompletionDoneMarker},
// Released only when every condition holds.
{name: "release/http2", setup: rcH2(rcStream), want: CompletionRouteContractEOF},
{name: "release/known-length-match", setup: rcKnownLength(rcStream), want: CompletionRouteContractEOF},
{name: "release/usage-before-finish", setup: rcH2(rcText + rcUsage + rcCall1 + rcCall2 + rcFinish), want: CompletionRouteContractEOF},
{name: "release/usage-after-finish", setup: rcH2(rcText + rcCall1 + rcCall2 + rcFinish + rcUsage), want: CompletionRouteContractEOF},
{name: "release/fragment-in-finish-chunk", setup: rcH2(rcText + rcCall1 +
rcChunk(rcToolChoice("tool_calls", rcFrag(0, "", "", `"PRIVATE_I1.txt"}`))) + rcUsage), want: CompletionRouteContractEOF},
{name: "release/two-calls", setup: rcH2(twoCalls), want: CompletionRouteContractEOF,
calls: []ToolCall{rcCall, {ID: "call_PRIVATE_I1_b", Name: "list_PRIVATE_I1", Arguments: `{}`}}},
{name: "release/api-base-override-to-contract", setup: func(t *testing.T) rcSetup {
s := rcH2(rcStream)(t)
s.opts.APIBase = s.client.BaseURL + "/"
s.client.BaseURL = "https://unused.invalid"
return s
}, want: CompletionRouteContractEOF},
// The zero value is off.
{name: "off/zero-contract", setup: func(t *testing.T) rcSetup {
s := rcH2(rcStream)(t)
s.contract = &RouteContractEOF{}
return s
}, want: "off", reason: rcEOFReason},
// C1 route identity.
{name: "c1/model-mismatch", setup: func(t *testing.T) rcSetup {
s := rcH2(rcStream)(t)
s.contract = rcContract("other-model", s.client.BaseURL)
return s
}, want: "C1", reason: rcEOFReason},
{name: "c1/base-http", setup: rcStreamCase(rcPlain, rcStream), want: "C1", reason: rcEOFReason},
{name: "c1/base-userinfo", setup: func(t *testing.T) rcSetup {
s := rcH2(rcStream)(t)
s.client.BaseURL = strings.Replace(s.client.BaseURL, "https://", "https://user@", 1)
return s
}, want: "C1", reason: rcEOFReason},
{name: "c1/base-query", setup: func(t *testing.T) rcSetup {
s := rcH2(rcStream)(t)
s.client.BaseURL += "?x=1"
return s
}, want: "C1", reason: rcEOFReason},
{name: "c1/digest-mismatch", setup: func(t *testing.T) rcSetup {
s := rcH2(rcStream)(t)
s.contract.IdentitySHA256 = strings.Repeat("0", 64)
return s
}, want: "C1", reason: rcEOFReason},
{name: "c1/digest-uppercase", setup: func(t *testing.T) rcSetup {
s := rcH2(rcStream)(t)
s.contract.IdentitySHA256 = strings.ToUpper(s.contract.IdentitySHA256)
return s
}, want: "C1", reason: rcEOFReason},
{name: "c1/protocol-not-openai-chat", setup: func(t *testing.T) rcSetup {
s := rcH2(rcStream)(t)
s.client.Protocol = ProtocolGoogle
return s
}, want: "C1", reason: rcEOFReason},
{name: "c1/api-base-override-elsewhere", setup: func(t *testing.T) rcSetup {
other := rcHTTP2(t, rcWrite(rcStream))
s := rcH2(rcStream)(t)
s.opts.APIBase = other.URL
s.client.HTTPClient = rcHTTPClient(t, other)
return s
}, want: "C1", reason: rcEOFReason},
{name: "c1/redirect-other-host", setup: rcRedirectCase(func(t *testing.T) string {
return rcHTTP2(t, rcWrite(rcStream)).URL + "/chat/completions"
}), want: "C1", reason: rcEOFReason},
{name: "c1/redirect-same-host-other-path", setup: rcRedirectCase(func(t *testing.T) string {
return "/final/chat/completions"
}), want: "C1", reason: rcEOFReason},
{name: "c1/redirect-https-downgrade", setup: rcRedirectCase(func(t *testing.T) string {
return rcPlain(t, rcWrite(rcStream)).URL + "/chat/completions"
}), want: "C1", reason: rcEOFReason},
// C2 body boundary.
{name: "c2/known-length-mismatch", setup: func(t *testing.T) rcSetup {
client := rcFixtureClient(func() *http.Response {
return &http.Response{StatusCode: http.StatusOK, ProtoMajor: 1, ProtoMinor: 1,
ContentLength: int64(len(rcStream) + 64), Header: http.Header{},
Body: io.NopCloser(strings.NewReader(rcStream))}
})
return rcSetup{client: client, contract: rcContract(rcModel, client.BaseURL)}
}, want: "C2", reason: rcEOFReason},
{name: "c2/unknown-framing", setup: func(t *testing.T) rcSetup {
client := rcFixtureClient(func() *http.Response {
return &http.Response{StatusCode: http.StatusOK, ContentLength: -1, Header: http.Header{},
Body: io.NopCloser(strings.NewReader(rcStream))}
})
return rcSetup{client: client, contract: rcContract(rcModel, client.BaseURL)}
}, want: "C2", reason: rcEOFReason},
{name: "c2/chunked", setup: rcStreamCase(rcTLS1, rcStream), want: "C2", reason: rcEOFReason},
{name: "c2/close-delimited", setup: func(t *testing.T) rcSetup {
s := rcTLS1(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Transfer-Encoding", "identity")
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, rcStream)
w.(http.Flusher).Flush()
}))
return rcSetup{client: rcClientFor(t, s), contract: rcContract(rcModel, s.URL)}
}, want: "C2", reason: rcEOFReason},
// A reset or dropped stream never reaches body EOF, so the boundary
// condition fails before the read-outcome condition is evaluated.
{name: "c2/http2-rst-stream", setup: func(t *testing.T) rcSetup {
s := rcHTTP2(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rcWrite(rcStream)(w, r)
panic(http.ErrAbortHandler)
}))
return rcSetup{client: rcClientFor(t, s), contract: rcContract(rcModel, s.URL)}
}, want: "C2", reason: "stream read failed after reported usage"},
{name: "c2/mid-stream-disconnect", setup: func(t *testing.T) rcSetup {
s := rcTLS1(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rcWrite(rcStream)(w, r)
conn, _, err := w.(http.Hijacker).Hijack()
if err == nil {
_ = conn.Close()
}
}))
return rcSetup{client: rcClientFor(t, s), contract: rcContract(rcModel, s.URL)}
}, want: "C2", reason: "stream read failed after reported usage"},
// The parser stops at a bad frame; with a trailing delimiter it never
// reads to body EOF, so the boundary condition fails first.
{name: "c2/invalid-frame-before-eof", setup: rcH2(rcStream + "data: PRIVATE_not_json\n\n"), want: "C2",
reason: "malformed stream chunk after reported usage"},
{name: "c2/provider-error-before-eof", setup: rcH2(rcStream + rcErrorFrame), want: "C2",
reason: "provider error after reported usage: PRIVATE_PROVIDER_I1"},
// C3 read outcome: the bad frame is the last bytes, so body EOF is seen.
{name: "c3/half-frame", setup: rcH2(rcStream + `data: {"choices":[{"delta":{"content":"PRIVATE_HALF`), want: "C3",
reason: "malformed stream chunk after reported usage"},
{name: "c3/invalid-frame", setup: rcH2(rcStream + "data: PRIVATE_not_json"), want: "C3",
reason: "malformed stream chunk after reported usage"},
{name: "c3/provider-error-after-usage", setup: rcH2(rcStream + strings.TrimRight(rcErrorFrame, "\n")),
want: "C3", reason: "provider error after reported usage: PRIVATE_PROVIDER_I1"},
{name: "c3/provider-error-before-usage", setup: rcH2(rcText + rcCall1 + rcCall2 + rcFinish + strings.TrimRight(rcErrorFrame, "\n")),
want: "C3", reason: "PRIVATE_PROVIDER_I1", noUsage: true},
// C4 finish reason and choices.
{name: "c4/two-finish-chunks", setup: rcH2(rcText + rcCall1 + rcCall2 + rcFinish + rcFinish + rcUsage), want: "C4", reason: rcEOFReason},
{name: "c4/finish-stop-with-tools", setup: rcH2(rcText + rcCall1 + rcCall2 + rcFinishChunk("stop") + rcUsage), want: "C4", reason: rcEOFReason},
{name: "c4/finish-length-with-tools", setup: rcH2(rcText + rcCall1 + rcCall2 + rcFinishChunk("length") + rcUsage), want: "C4", reason: rcEOFReason},
{name: "c4/multi-choice-chunk", setup: rcH2(rcText +
rcChunk(map[string]any{"delta": map[string]any{"content": "a"}}, map[string]any{"delta": map[string]any{"content": "b"}}) +
rcCall1 + rcCall2 + rcFinish + rcUsage), want: "C4", reason: rcEOFReason},
{name: "c4/truncated-at-frame-boundary-without-finish", setup: rcH2(rcText + rcCall1 + rcCall2 + rcUsage), want: "C4", reason: rcEOFReason},
// C5 usage.
{name: "c5/missing-usage", setup: rcH2(rcText + rcCall1 + rcCall2 + rcFinish), want: "C5", reason: rcEOFReason, noUsage: true},
// C6 fragments after finish.
{name: "c6/fragment-after-finish-chunk", setup: rcH2(rcText + rcCall1 + rcFinish + rcCall2 + rcUsage), want: "C6", reason: rcEOFReason},
// C7 call identity and argument shape.
{name: "c7/missing-id", setup: rcH2(rcSingleCall("", "read_PRIVATE_I1", `{"path":"PRIVATE_I1.txt"}`)), want: "C7", reason: rcEOFReason},
{name: "c7/missing-name", setup: rcH2(rcSingleCall("call_PRIVATE_I1", "", `{"path":"PRIVATE_I1.txt"}`)), want: "C7", reason: rcEOFReason},
{name: "c7/array-arguments", setup: rcH2(rcSingleCall("call_PRIVATE_I1", "read_PRIVATE_I1", `["PRIVATE_I1.txt"]`)), want: "C7", reason: rcEOFReason},
{name: "c7/broken-arguments", setup: rcH2(rcSingleCall("call_PRIVATE_I1", "read_PRIVATE_I1", `{"path":"PRIVATE_I1.txt"`)), want: "C7", reason: rcEOFReason},
{name: "c7/finish-without-call", setup: rcH2(rcText + rcFinish + rcUsage), want: "C7", reason: "tool call finish reason without a tool call"},
// C8 declared tools and argument schema.
{name: "c8/unknown-tool", setup: rcH2(rcSingleCall("call_PRIVATE_I1", "write_PRIVATE_I1", `{"path":"PRIVATE_I1.txt"}`)), want: "C8", reason: rcEOFReason},
{name: "c8/no-tools-declared", setup: func(t *testing.T) rcSetup {
s := rcH2(rcStream)(t)
s.tools = []ToolDecl{}
return s
}, want: "C8", reason: rcEOFReason},
{name: "c8/missing-required", setup: rcH2(rcSingleCall("call_PRIVATE_I1", "read_PRIVATE_I1", `{}`)), want: "C8", reason: rcEOFReason},
{name: "c8/blank-required", setup: rcH2(rcSingleCall("call_PRIVATE_I1", "read_PRIVATE_I1", `{"path":" "}`)), want: "C8", reason: rcEOFReason},
{name: "c8/wrong-property-type", setup: rcH2(rcSingleCall("call_PRIVATE_I1", "read_PRIVATE_I1", `{"path":7}`)), want: "C8", reason: rcEOFReason},
{name: "c8/unknown-argument", setup: rcH2(rcSingleCall("call_PRIVATE_I1", "read_PRIVATE_I1", `{"path":"a","PRIVATE":1}`)), want: "C8", reason: rcEOFReason},
// C9 tail event.
{name: "c9/comment-tail", setup: rcH2(rcStream + ": PRIVATE_keepalive\n\n"), want: "C9", reason: rcEOFReason},
}
}
func rcKnownLength(stream string) func(*testing.T) rcSetup {
return func(t *testing.T) rcSetup {
s := rcTLS1(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Content-Length", strconv.Itoa(len(stream)))
_, _ = io.WriteString(w, stream)
}))
return rcSetup{client: rcClientFor(t, s), contract: rcContract(rcModel, s.URL)}
}
}
func TestRouteContractEOFMatrix(t *testing.T) {
for _, tc := range rcCases() {
t.Run(tc.name, func(t *testing.T) {
s := tc.setup(t)
tools := s.tools
if tools == nil {
tools = rcTools
}
wantCalls := tc.calls
if wantCalls == nil {
wantCalls = []ToolCall{rcCall}
}
wantUsage := 7
if tc.noUsage {
wantUsage = 0
}
off := rcRun(t, context.Background(), s.client, nil, tools, s.opts, nil)
on := rcRun(t, context.Background(), s.client, s.contract, tools, s.opts, nil)
for _, r := range []rcResult{off, on} {
if r.end.Usage.TotalTokens != wantUsage {
t.Fatalf("usage = %+v, want total %d", r.end.Usage, wantUsage)
}
}
if off.end.TerminalObservation.ContractCheck != "" {
t.Fatalf("contract-off stream carried a contract check: %+v", off.end.TerminalObservation)
}
if tc.reason != "" && off.end.ErrorMessage != tc.reason {
t.Fatalf("contract-off failure = %q, want %q", off.end.ErrorMessage, tc.reason)
}
switch tc.want {
case CompletionDoneMarker:
if !reflect.DeepEqual(on.end, off.end) {
t.Fatalf("contract changed a [DONE] terminal:\n on=%#v\noff=%#v", on.end, off.end)
}
if on.end.Err != nil || !on.end.StreamComplete || on.end.CompletionBasis != CompletionDoneMarker ||
on.end.TerminalObservation.ContractCheck != "" || on.end.TerminalObservation.ReadOutcome != "done_marker" {
t.Fatalf("[DONE] terminal not marked done_marker: %#v", on.end)
}
rcRequireReleased(t, on, wantCalls)
case CompletionRouteContractEOF:
rcRequireRejected(t, off)
if off.end.CompletionBasis != "" || off.end.ErrorMessage != rcEOFReason {
t.Fatalf("contract-off stream was not rejected unchanged: %#v", off.end)
}
end := on.end
if end.Err != nil || end.StreamComplete || end.CompletionBasis != CompletionRouteContractEOF ||
end.TerminalObservation.ContractCheck != "" || end.TerminalObservation.ReadOutcome != "clean_eof" ||
end.Message.StopReason != StopToolCalls || end.FinishReason != "tool_calls" || end.HTTPStatus != http.StatusOK {
t.Fatalf("contract stream was not released: %#v obs=%+v", end, end.TerminalObservation)
}
if end.Message.Content != off.end.Message.Content || !reflect.DeepEqual(end.Message.Thinking, off.end.Message.Thinking) {
t.Fatalf("released message changed content: %#v vs %#v", end.Message, off.end.Message)
}
rcRequireReleased(t, on, wantCalls)
case "off":
if !reflect.DeepEqual(on.end, off.end) {
t.Fatalf("zero contract changed the terminal:\n on=%#v\noff=%#v", on.end, off.end)
}
rcRequireRejected(t, on)
default:
rcRequireRejected(t, off)
rcRequireRejected(t, on)
if got := on.end.TerminalObservation.ContractCheck; got != tc.want {
t.Fatalf("contract check = %q, want %q (obs=%+v)", got, tc.want, on.end.TerminalObservation)
}
stripped := on.end
obs := *on.end.TerminalObservation
obs.ContractCheck = ""
stripped.TerminalObservation = &obs
if !reflect.DeepEqual(stripped, off.end) {
t.Fatalf("rejection differs from contract-off beyond contractCheck:\n on=%#v %+v\noff=%#v %+v",
stripped, stripped.TerminalObservation, off.end, off.end.TerminalObservation)
}
}
})
}
}
func rcRequireReleased(t *testing.T, r rcResult, calls []ToolCall) {
t.Helper()
if r.releasedMessages != 1 || r.releasedCalls != len(calls) || !reflect.DeepEqual(r.end.Message.ToolCalls, calls) {
t.Fatalf("released %d messages / %d calls %#v, want 1 / %#v", r.releasedMessages, r.releasedCalls, r.end.Message.ToolCalls, calls)
}
}
func rcRequireRejected(t *testing.T, r rcResult) {
t.Helper()
end := r.end
if r.releasedMessages != 0 || r.releasedCalls != 0 || len(end.Message.ToolCalls) != 0 || end.Err == nil ||
end.StreamComplete || end.CompletionBasis != "" || end.TerminalObservation.CompletionBasis != "" {
t.Fatalf("stream was released: %#v", end)
}
}
func TestRouteContractEOFCancelDuringReadAborts(t *testing.T) {
server := rcHTTP2(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rcWrite(rcText+rcCall1+rcCall2+rcFinish+rcUsage)(w, r)
<-r.Context().Done()
}))
client := rcClientFor(t, server)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
res := rcRun(t, ctx, client, rcContract(rcModel, server.URL), rcTools, StreamOptions{}, func(ev Event) {
if ev.Kind == KindToolCallDelta {
cancel()
}
})
end := res.end
if end.Kind != KindError || end.Message.StopReason != StopAborted || end.TerminalObservation.ReadOutcome != "cancelled" ||
end.TerminalObservation.ContractCheck != "" || end.CompletionBasis != "" || res.releasedMessages != 0 ||
res.releasedCalls != 0 || len(end.Message.ToolCalls) != 0 {
t.Fatalf("cancelled contract stream was not aborted: %#v obs=%+v", end, end.TerminalObservation)
}
}
func TestParseSSEUnchangedWithoutDone(t *testing.T) {
var end Event
for ev := range ParseSSE(context.Background(), strings.NewReader(rcStream)) {
end = ev
}
if end.Kind != KindMessageEnd || end.ErrorMessage != rcEOFReason || len(end.Message.ToolCalls) != 0 ||
end.StreamComplete || end.CompletionBasis != "" || end.Usage.TotalTokens != 7 ||
end.TerminalObservation == nil || end.TerminalObservation.CompletionBasis != "" ||
end.TerminalObservation.ContractCheck != "" || end.TerminalObservation.BodyBoundary != nil {
t.Fatalf("ParseSSE changed without [DONE]: %#v", end)
}
raw, err := json.Marshal(end.TerminalObservation)
if err != nil || strings.Contains(string(raw), "completionBasis") || strings.Contains(string(raw), "contractCheck") {
t.Fatalf("ParseSSE observation JSON changed: %s err=%v", raw, err)
}
var done Event
for ev := range ParseSSE(context.Background(), strings.NewReader(rcStream+rcDone)) {
done = ev
}
if done.Err != nil || !done.StreamComplete || done.CompletionBasis != "" || done.TerminalObservation.CompletionBasis != "" ||
!reflect.DeepEqual(done.Message.ToolCalls, []ToolCall{rcCall}) {
t.Fatalf("ParseSSE changed with [DONE]: %#v", done)
}
}
func TestRouteContractIdentity(t *testing.T) {
sum := sha256.Sum256([]byte("openai-chat\nm\nhttps://api.example.com/v1"))
if got, want := RouteContractIdentity("m", "https://api.example.com/v1"), hex.EncodeToString(sum[:]); got != want {
t.Fatalf("identity = %s, want %s", got, want)
}
if got := RouteContractIdentity("m", "https://api.example.com/v1"); got != strings.ToLower(got) || len(got) != 64 {
t.Fatalf("identity is not lowercase hex: %s", got)
}
}
func TestNormalizeRouteBaseURL(t *testing.T) {
tests := []struct {
raw, want string
ok bool
}{
{"https://api.example.com/v1", "https://api.example.com/v1", true},
{"HTTPS://API.Example.COM/v1/", "https://api.example.com/v1", true},
{"https://api.example.com:8443/v1//", "https://api.example.com:8443/v1", true},
{"https://api.example.com", "https://api.example.com", true},
{"https://api.example.com/V1", "https://api.example.com/V1", true},
{"http://api.example.com/v1", "", false},
{"https://user@api.example.com/v1", "", false},
{"https://user:pw@api.example.com/v1", "", false},
{"https://api.example.com/v1?x=1", "", false},
{"https://api.example.com/v1?", "", false},
{"https://api.example.com/v1#frag", "", false},
{"https://api.example.com/v1#", "", false},
{"https:api.example.com", "", false},
{"https:///v1", "", false},
{"", "", false},
{"://bad", "", false},
}
for _, tc := range tests {
got, ok := NormalizeRouteBaseURL(tc.raw)
if got != tc.want || ok != tc.ok {
t.Fatalf("NormalizeRouteBaseURL(%q) = %q, %v; want %q, %v", tc.raw, got, ok, tc.want, tc.ok)
}
}
}
+258 -37
View File
@@ -4,6 +4,7 @@ import (
"bufio"
"context"
"encoding/json"
"errors"
"io"
"strings"
)
@@ -39,85 +40,231 @@ type usageWire struct {
} `json:"prompt_tokens_details"`
}
const (
maxObservedToolFragments = 10000
maxObservedToolArgumentBytes = 4*1024*1024 + 1
maxObservedTailEvents = 10000
)
// sseFacts are structural stream facts the route contract needs. They are
// written by the parser goroutine before it sends the terminal event.
type sseFacts struct {
// finishChunks counts chunks with a non-empty finish_reason.
finishChunks int
finishReason string
// multiChoice is set when any chunk carried more than one choice.
multiChoice bool
// fragmentAfterFinish is set when a tool-call fragment arrived in a chunk
// after the one carrying finish_reason.
fragmentAfterFinish bool
}
// sseHooks are internal parser options. The zero value is ParseSSE.
type sseHooks struct {
facts *sseFacts
// decideEOF, when set, receives a tool stream whose terminal would be
// "tool call stream ended before [DONE]": success is the message_end the
// stream would end with had [DONE] arrived, failure is the event emitted
// today. It returns the event to send.
decideEOF func(success, failure Event) Event
}
// ParseSSE reads an OpenAI-compatible server-sent event body and emits
// deltas plus one terminal message_end or error event.
func ParseSSE(ctx context.Context, r io.Reader) <-chan Event {
return parseSSE(ctx, r, sseHooks{})
}
func parseSSE(ctx context.Context, r io.Reader, hooks sseHooks) <-chan Event {
out := make(chan Event)
go func() {
defer close(out)
emitParsed(ctx, r, out)
emitParsed(ctx, r, out, hooks)
}()
return out
}
func emitParsed(ctx context.Context, r io.Reader, out chan<- Event) {
func emitParsed(ctx context.Context, r io.Reader, out chan<- Event, hooks sseHooks) {
facts := hooks.facts
if facts == nil {
facts = &sseFacts{}
}
var content strings.Builder
var reasoning strings.Builder
calls := map[int]*ToolCall{}
order := []int{}
var usage Usage
finished := ""
sawDone := false
sawUsage := false
readOutcome := "clean_eof"
lastFrameJSONComplete := false
toolFragments := 0
toolArgumentBytes := 0
sc := bufio.NewScanner(r)
sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
reader := &sseEventReader{sc: sc}
observation := func() *TerminalObservation {
tailKind := reader.tailKind
if tailKind == "" {
tailKind = "unknown"
}
argumentsValid := len(order) > 0
for _, idx := range order {
var args map[string]json.RawMessage
if err := json.Unmarshal([]byte(calls[idx].Arguments), &args); err != nil || args == nil {
argumentsValid = false
break
}
}
return &TerminalObservation{
ReadOutcome: readOutcome, LastFrameJSONComplete: lastFrameJSONComplete,
UsagePresent: sawUsage, ToolFragments: toolFragments,
ToolArgumentBytes: toolArgumentBytes, ToolArgumentsValid: argumentsValid,
TailEventKind: tailKind, TailEventCount: reader.tailCount,
}
}
buildTerminalFailure := func(why string) Event {
message := Message{Role: RoleAssistant, Content: content.String(), StopReason: StopError}
if reasoning.Len() > 0 {
message.Thinking = []ThinkingBlock{{Thinking: reasoning.String()}}
}
failed := terminalStreamFailure(message, usage, finished, sawDone, why)
failed.TerminalObservation = observation()
return failed
}
sendTerminal := func(ev Event) {
select {
case <-ctx.Done():
readOutcome = "cancelled"
emitAbort(out, content.String(), ctx.Err(), observation())
case out <- ev:
}
}
sendTerminalFailure := func(why string) {
sendTerminal(buildTerminalFailure(why))
}
buildMessageEnd := func() Event {
msg := Message{Role: RoleAssistant, Content: content.String(), StopReason: mapFinish(finished, len(order) > 0)}
if reasoning.Len() > 0 {
msg.Thinking = []ThinkingBlock{{Thinking: reasoning.String()}}
}
for _, idx := range order {
msg.ToolCalls = append(msg.ToolCalls, *calls[idx])
}
if msg.StopReason == StopToolCalls && len(msg.ToolCalls) == 0 {
msg.StopReason = StopEnd
}
return Event{Kind: KindMessageEnd, Message: msg, Usage: usage, FinishReason: finished, StreamComplete: sawDone, TerminalObservation: observation()}
}
for {
data, ok := reader.next()
if !ok {
break
}
if ctx.Err() != nil {
emitAbort(out, content.String(), ctx.Err())
readOutcome = "cancelled"
emitAbort(out, content.String(), ctx.Err(), observation())
return
}
if data == "[DONE]" {
reader.noteTail("done_literal")
sawDone = true
readOutcome = "done_marker"
break
}
var chunk sseChunk
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
emitError(out, content.String(), false, "malformed stream chunk: "+err.Error())
reader.noteTail(badFrameKind(data, err))
readOutcome = "invalid_frame"
lastFrameJSONComplete = false
if sawUsage {
sendTerminalFailure("malformed stream chunk after reported usage")
return
}
emitError(out, content.String(), false, "malformed stream chunk: "+err.Error(), observation())
return
}
reader.noteTail("json_frame")
lastFrameJSONComplete = true
if chunk.Error != nil && chunk.Error.Message != "" {
readOutcome = "provider_error"
msg := chunk.Error.Message
if sawUsage {
sendTerminalFailure("provider error after reported usage: " + msg)
return
}
retryable := CapacityLimited(msg) && !ContextOverflow(msg)
emitError(out, content.String(), retryable, msg)
emitError(out, content.String(), retryable, msg, observation())
return
}
if chunk.Usage != nil {
usage = chunk.Usage.Usage
sawUsage = true
if chunk.Usage.Details.Cached > 0 {
usage.CacheReadTokens = chunk.Usage.Details.Cached
}
}
if len(chunk.Choices) > 1 {
facts.multiChoice = true
}
finishedBefore := facts.finishChunks > 0
for _, c := range chunk.Choices {
if c.FinishReason != "" {
if facts.finishChunks < maxObservedTailEvents {
facts.finishChunks++
}
break
}
}
if len(chunk.Choices) == 0 {
continue
}
choice := chunk.Choices[0]
if choice.FinishReason != "" {
finished = choice.FinishReason
facts.finishReason = finished
}
if finishedBefore && len(choice.Delta.ToolCalls) > 0 {
facts.fragmentAfterFinish = true
}
if choice.Delta.Content != "" {
content.WriteString(choice.Delta.Content)
select {
case <-ctx.Done():
emitAbort(out, content.String(), ctx.Err())
readOutcome = "cancelled"
emitAbort(out, content.String(), ctx.Err(), observation())
return
case out <- Event{Kind: KindTextDelta, Text: choice.Delta.Content}:
}
}
// reasoning_content(国产 OpenAI 兼容模型的思考链)与正文分流:
// 只作为 reasoning_delta 事件外发,不计入 Message.Content(与 runner
// stripThinkBlocks"正文无 think"语义对齐)。
// stripThinkBlocks"正文无 think"语义对齐)。同时累计进 reasoning——
// 交错思考模型(MiniMax/DeepSeek R1 等)要求历史 assistant 原样带回
// 思考内容,丢弃会降低后续轮质量;捕获进 Message.Thinking 随会话
// 转录持久化,请求序列化侧按需回放。
if choice.Delta.Reasoning != "" {
reasoning.WriteString(choice.Delta.Reasoning)
select {
case <-ctx.Done():
emitAbort(out, content.String(), ctx.Err())
readOutcome = "cancelled"
emitAbort(out, content.String(), ctx.Err(), observation())
return
case out <- Event{Kind: KindReasoningDelta, ReasoningDelta: choice.Delta.Reasoning}:
}
}
for _, tc := range choice.Delta.ToolCalls {
if toolFragments < maxObservedToolFragments {
toolFragments++
}
if n := len(tc.Function.Arguments); n > 0 && toolArgumentBytes < maxObservedToolArgumentBytes {
if n > maxObservedToolArgumentBytes-toolArgumentBytes {
toolArgumentBytes = maxObservedToolArgumentBytes
} else {
toolArgumentBytes += n
}
}
cur, ok := calls[tc.Index]
if !ok {
cur = &ToolCall{}
@@ -135,7 +282,8 @@ func emitParsed(ctx context.Context, r io.Reader, out chan<- Event) {
}
select {
case <-ctx.Done():
emitAbort(out, content.String(), ctx.Err())
readOutcome = "cancelled"
emitAbort(out, content.String(), ctx.Err(), observation())
return
case out <- Event{
Kind: KindToolCallDelta,
@@ -148,42 +296,107 @@ func emitParsed(ctx context.Context, r io.Reader, out chan<- Event) {
}
}
if err := sc.Err(); err != nil {
readOutcome = "read_error"
if ctx.Err() != nil {
emitAbort(out, content.String(), ctx.Err())
readOutcome = "cancelled"
emitAbort(out, content.String(), ctx.Err(), observation())
return
}
emitError(out, content.String(), true, err.Error())
if sawUsage {
sendTerminalFailure("stream read failed after reported usage")
return
}
emitError(out, content.String(), true, err.Error(), observation())
return
}
// 取消必须压过正常结束。select 在两个分支都就绪时随机挑选,
// 竞态下会把 message_end 发给已经取消的调用方。
if ctx.Err() != nil {
emitAbort(out, content.String(), ctx.Err())
readOutcome = "cancelled"
emitAbort(out, content.String(), ctx.Err(), observation())
return
}
// length、空结束原因,或没有 [DONE] 的中断,都不把半截工具调用交给执行层。
if !toolFinishExecutable(finished, sawDone) {
order = nil
// A finish reason alone does not prove the tool arguments arrived intact.
// Preserve returned usage when rejecting a partial terminal, so the host
// can charge this call and stop rather than treating it as a free retry.
if (finished == "tool_calls" || finished == "function_call") && len(order) == 0 {
sendTerminalFailure("tool call finish reason without a tool call")
return
}
msg := Message{Role: RoleAssistant, Content: content.String(), StopReason: mapFinish(finished, len(order) > 0)}
for _, idx := range order {
msg.ToolCalls = append(msg.ToolCalls, *calls[idx])
}
if msg.StopReason == StopToolCalls && len(msg.ToolCalls) == 0 {
msg.StopReason = StopEnd
}
select {
case <-ctx.Done():
emitAbort(out, content.String(), ctx.Err())
case out <- Event{Kind: KindMessageEnd, Message: msg, Usage: usage, FinishReason: finished}:
if len(order) > 0 {
problem := ""
switch {
case !sawDone:
problem = "tool call stream ended before [DONE]"
case !toolFinishExecutable(finished, sawDone):
problem = "tool call ended without a supported finish reason"
default:
for _, idx := range order {
call := calls[idx]
var args map[string]json.RawMessage
if call.ID == "" || call.Name == "" {
problem = "tool call identity is incomplete"
break
}
if err := json.Unmarshal([]byte(call.Arguments), &args); err != nil || args == nil {
problem = "tool call arguments are not a JSON object"
break
}
}
}
if problem != "" {
if !sawDone && hooks.decideEOF != nil {
sendTerminal(hooks.decideEOF(buildMessageEnd(), buildTerminalFailure(problem)))
return
}
sendTerminalFailure(problem)
return
}
}
sendTerminal(buildMessageEnd())
}
func terminalStreamFailure(message Message, usage Usage, reason string, complete bool, why string) Event {
err := &StreamError{Message: why, Retryable: false}
return Event{Kind: KindMessageEnd, Message: message, Usage: usage,
FinishReason: reason, StreamComplete: complete, Err: err,
ErrorMessage: why, Retryable: false}
}
// sseEventReader 按空行切事件,并把同一事件里的多行 data: 用换行拼回。
// 已经是完整 JSON 的 data 行单独成事件,这样单行流不用空行也能解析。
// tailKind/tailCount 只记最后完成的结构事件类别与饱和计数,不存帧内容。
type sseEventReader struct {
sc *bufio.Scanner
carry string
sc *bufio.Scanner
carry string
tailKind string
tailCount int
}
func (r *sseEventReader) noteTail(kind string) {
if r.tailCount < maxObservedTailEvents {
r.tailCount++
}
r.tailKind = kind
}
// badFrameKind classifies a data event that did not decode as a chunk using
// only the decoder's error category and position, never its text or bytes.
func badFrameKind(data string, err error) string {
if strings.TrimSpace(data) == "" {
return "empty_data"
}
var syntax *json.SyntaxError
if !errors.As(err, &syntax) {
return "unknown"
}
if syntax.Offset == int64(len(data)) {
var probe json.RawMessage
if errors.Is(json.NewDecoder(strings.NewReader(data)).Decode(&probe), io.ErrUnexpectedEOF) {
return "partial_frame"
}
}
return "unknown_non_json"
}
func (r *sseEventReader) next() (string, bool) {
@@ -212,6 +425,7 @@ func (r *sseEventReader) next() (string, bool) {
}
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, ":") {
r.noteTail("comment")
continue
}
if !strings.HasPrefix(trimmed, "data:") {
@@ -233,17 +447,16 @@ func (r *sseEventReader) next() (string, bool) {
// toolFinishExecutable 只在供应商明确以工具调用或正常 stop 结束时执行。
// length、缺失的结束原因,以及没有 [DONE] 的断开,都视为未完成。
func toolFinishExecutable(reason string, sawDone bool) bool {
if !sawDone {
return false
}
switch reason {
case "tool_calls", "function_call":
return true
case "stop":
return true
case "length":
return false
case "":
return false
default:
return sawDone && reason != "error" && reason != "content_filter"
return false
}
}
@@ -268,8 +481,8 @@ func mapFinish(reason string, hasTools bool) StopReason {
}
}
func emitError(out chan<- Event, partial string, retryable bool, message string) {
out <- Event{
func emitError(out chan<- Event, partial string, retryable bool, message string, observed ...*TerminalObservation) {
event := Event{
Kind: KindError,
Retryable: retryable,
ErrorMessage: message,
@@ -280,14 +493,18 @@ func emitError(out chan<- Event, partial string, retryable bool, message string)
StopReason: StopError,
},
}
if len(observed) > 0 {
event.TerminalObservation = observed[0]
}
out <- event
}
func emitAbort(out chan<- Event, partial string, err error) {
func emitAbort(out chan<- Event, partial string, err error, observed ...*TerminalObservation) {
msg := "aborted"
if err != nil {
msg = err.Error()
}
out <- Event{
event := Event{
Kind: KindError,
Retryable: false,
ErrorMessage: msg,
@@ -298,6 +515,10 @@ func emitAbort(out chan<- Event, partial string, err error) {
StopReason: StopAborted,
},
}
if len(observed) > 0 {
event.TerminalObservation = observed[0]
}
out <- event
}
// StreamError is a provider or transport failure encoded on the event stream.
+335 -6
View File
@@ -2,6 +2,7 @@ package llm
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
@@ -87,13 +88,39 @@ func TestParseSSEToolCallIncrements(t *testing.T) {
if end.Message.StopReason != StopToolCalls || len(end.Message.ToolCalls) != 1 {
t.Fatalf("end %#v", end.Message)
}
if end.TerminalObservation == nil || end.TerminalObservation.ReadOutcome != "done_marker" ||
!end.TerminalObservation.ToolArgumentsValid || end.TerminalObservation.ToolFragments != 3 {
t.Fatalf("valid multi-fragment terminal was not classified: %#v", end.TerminalObservation)
}
tc := end.Message.ToolCalls[0]
if tc.ID != "call_1" || tc.Name != "read_file" || tc.Arguments != `{"path":"a.md"}` {
t.Fatalf("tool call %#v", tc)
}
}
func TestParseSSELengthDropsUnfinishedToolCall(t *testing.T) {
func TestParseSSEToolFinishWithoutCallFails(t *testing.T) {
for _, reason := range []string{"tool_calls", "function_call"} {
t.Run(reason, func(t *testing.T) {
body := strings.Join([]string{
`data: {"choices":[{"delta":{},"finish_reason":"` + reason + `"}],"usage":{"prompt_tokens":12,"completion_tokens":5,"total_tokens":17}}`,
`data: [DONE]`,
"",
}, "\n")
var end Event
for ev := range ParseSSE(context.Background(), strings.NewReader(body)) {
if ev.Kind == KindMessageEnd {
end = ev
}
}
if end.Err == nil || end.Message.StopReason != StopError || end.Usage.TotalTokens != 17 ||
!end.StreamComplete || len(end.Message.ToolCalls) != 0 {
t.Fatalf("empty tool finish must fail and retain Usage: %#v", end)
}
})
}
}
func TestParseSSELengthFailsUnfinishedToolCall(t *testing.T) {
body := strings.Join([]string{
`data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"read_file","arguments":"{\"path\":"}}]}}]}`,
`data: {"choices":[{"delta":{},"finish_reason":"length"}]}`,
@@ -106,15 +133,15 @@ func TestParseSSELengthDropsUnfinishedToolCall(t *testing.T) {
end = ev
}
}
if end.Message.StopReason != StopEnd || len(end.Message.ToolCalls) != 0 {
t.Fatalf("truncated tool call must not be executable: %#v", end.Message)
if end.Message.StopReason != StopError || len(end.Message.ToolCalls) != 0 || end.Err == nil {
t.Fatalf("truncated tool call must fail without execution: %#v", end)
}
if end.FinishReason != "length" {
t.Fatalf("finish %q", end.FinishReason)
}
}
func TestParseSSEUncleanEndDropsToolCall(t *testing.T) {
func TestParseSSEUncleanEndFailsToolCall(t *testing.T) {
body := `data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"read_file","arguments":"{\"path\":"}}]}}]}`
var end Event
for ev := range ParseSSE(context.Background(), strings.NewReader(body)) {
@@ -122,8 +149,172 @@ func TestParseSSEUncleanEndDropsToolCall(t *testing.T) {
end = ev
}
}
if end.Kind != KindMessageEnd || end.Message.StopReason != StopEnd || len(end.Message.ToolCalls) != 0 {
t.Fatalf("unclean stream must not execute tools: %#v", end)
if end.Kind != KindMessageEnd || end.Message.StopReason != StopError || len(end.Message.ToolCalls) != 0 || end.Err == nil {
t.Fatalf("unclean stream must fail without executing tools: %#v", end)
}
}
func TestParseSSEToolFinishWithoutDone(t *testing.T) {
body := strings.Join([]string{
`data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"echo","arguments":"{}"}}]}}]}`,
`data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":12,"completion_tokens":5,"total_tokens":17}}`,
"",
}, "\n\n")
var end Event
for ev := range ParseSSE(context.Background(), strings.NewReader(body)) {
if ev.Kind == KindMessageEnd {
end = ev
}
}
if end.Kind != KindMessageEnd || end.Message.StopReason != StopError ||
len(end.Message.ToolCalls) != 0 || end.Err == nil || end.Retryable ||
end.Usage.TotalTokens != 17 {
t.Fatalf("unsafe tool call from unfinished stream: %#v", end)
}
}
func TestTerminalObservationCleanEOFWithoutDone(t *testing.T) {
body := strings.Join([]string{
`data: {"choices":[{"delta":{"content":"PRIVATE_PROMPT_R05","tool_calls":[{"index":0,"id":"call_1","function":{"name":"PRIVATE_TOOL_R05","arguments":"{}"}}]}}]}`,
`data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":12,"completion_tokens":5,"total_tokens":17}}`,
"",
}, "\n\n")
var end Event
for ev := range ParseSSE(context.Background(), strings.NewReader(body)) {
if ev.Kind == KindMessageEnd {
end = ev
}
}
if end.Err == nil || end.StreamComplete || len(end.Message.ToolCalls) != 0 || end.Usage.TotalTokens != 17 {
t.Fatalf("unfinished tool stream was released or unmetered: %#v", end)
}
meta := end.TerminalObservation
if meta == nil || meta.ReadOutcome != "clean_eof" || !meta.LastFrameJSONComplete ||
!meta.UsagePresent || meta.ToolFragments != 1 || meta.ToolArgumentBytes != 2 || !meta.ToolArgumentsValid {
t.Fatalf("missing content-free clean EOF evidence: %#v", meta)
}
raw, err := json.Marshal(meta)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(raw), "PRIVATE_PROMPT_R05") || strings.Contains(string(raw), "PRIVATE_TOOL_R05") {
t.Fatalf("terminal observation retained model content: %s", raw)
}
}
func TestTerminalObservationMalformedAndReadError(t *testing.T) {
usageAndTool := `data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"echo","arguments":"{}"}}]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":12,"completion_tokens":5,"total_tokens":17}}` + "\n\n"
tests := []struct {
name string
ctx context.Context
reader io.Reader
outcome string
usage int
frameValid bool
}{
{"invalid-frame", context.Background(), strings.NewReader(usageAndTool + "data: {not-json}\n\n"), "invalid_frame", 17, false},
{"read-error", context.Background(), &errorAfterDataReader{data: strings.NewReader(usageAndTool)}, "read_error", 17, true},
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
tests = append(tests, struct {
name string
ctx context.Context
reader io.Reader
outcome string
usage int
frameValid bool
}{"cancelled", ctx, strings.NewReader(usageAndTool), "cancelled", 0, false})
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var end Event
for ev := range ParseSSE(tc.ctx, tc.reader) {
if ev.Kind == KindMessageEnd || ev.Kind == KindError {
end = ev
}
}
if end.Err == nil || len(end.Message.ToolCalls) != 0 || end.Usage.TotalTokens != tc.usage {
t.Fatalf("failure lost Usage or released tools: %#v", end)
}
meta := end.TerminalObservation
if meta == nil || meta.ReadOutcome != tc.outcome || meta.LastFrameJSONComplete != tc.frameValid {
t.Fatalf("wrong terminal classification: %#v", meta)
}
})
}
}
func TestParseSSEInvalidToolArguments(t *testing.T) {
body := strings.Join([]string{
`data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"publish_work_checkpoint","arguments":"{\"findings\":"}}]}}]}`,
`data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":12,"completion_tokens":5,"total_tokens":17}}`,
`data: [DONE]`,
"",
}, "\n\n")
var end Event
for ev := range ParseSSE(context.Background(), strings.NewReader(body)) {
if ev.Kind == KindMessageEnd {
end = ev
}
}
if end.Kind != KindMessageEnd || end.Message.StopReason != StopError ||
len(end.Message.ToolCalls) != 0 || end.Err == nil || end.Retryable ||
end.Usage.TotalTokens != 17 {
t.Fatalf("unsafe tool call with invalid JSON arguments: %#v", end)
}
if end.TerminalObservation == nil || end.TerminalObservation.ToolArgumentsValid ||
end.TerminalObservation.ReadOutcome != "done_marker" {
t.Fatalf("invalid arguments incorrectly classified: %#v", end.TerminalObservation)
}
}
func TestParseSSERejectsMissingToolIdentity(t *testing.T) {
for _, tc := range []struct{ name, firstChunk string }{
{"missing-id", `data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"name":"echo","arguments":"{}"}}]}}]}`},
{"missing-name", `data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"arguments":"{}"}}]}}]}`},
} {
t.Run(tc.name, func(t *testing.T) {
body := strings.Join([]string{
tc.firstChunk,
`data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":12,"completion_tokens":5,"total_tokens":17}}`,
`data: [DONE]`,
"",
}, "\n\n")
var end Event
for ev := range ParseSSE(context.Background(), strings.NewReader(body)) {
if ev.Kind == KindMessageEnd {
end = ev
}
}
if end.Message.StopReason != StopError || len(end.Message.ToolCalls) != 0 || end.Err == nil {
t.Fatalf("missing tool identity reached execution: %#v", end)
}
})
}
}
func TestParseSSERejectsNonObjectToolArguments(t *testing.T) {
for _, tc := range []struct{ name, arguments string }{
{"array", "[]"},
{"null", "null"},
} {
t.Run(tc.name, func(t *testing.T) {
body := strings.Join([]string{
`data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"echo","arguments":"` + tc.arguments + `"}}]}}]}`,
`data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":12,"completion_tokens":5,"total_tokens":17}}`,
`data: [DONE]`,
"",
}, "\n\n")
var end Event
for ev := range ParseSSE(context.Background(), strings.NewReader(body)) {
if ev.Kind == KindMessageEnd {
end = ev
}
}
if end.Message.StopReason != StopError || len(end.Message.ToolCalls) != 0 || end.Err == nil {
t.Fatalf("non-object tool arguments reached execution: %#v", end)
}
})
}
}
@@ -149,6 +340,66 @@ func TestParseSSEMalformedIsError(t *testing.T) {
}
}
func TestParseSSEMalformedChunkAfterUsageKeepsUsage(t *testing.T) {
body := strings.Join([]string{
`data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"echo","arguments":"{}"}}]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":12,"completion_tokens":5,"total_tokens":17}}`,
`data: {not-json}`,
"",
}, "\n\n")
var end Event
for ev := range ParseSSE(context.Background(), strings.NewReader(body)) {
end = ev
}
if end.Kind != KindMessageEnd || end.Message.StopReason != StopError ||
len(end.Message.ToolCalls) != 0 || end.Err == nil || end.Retryable ||
end.Usage.TotalTokens != 17 {
t.Fatalf("malformed chunk dropped already returned usage: %#v", end)
}
}
func TestParseSSEProviderErrorAfterUsageKeepsUsage(t *testing.T) {
body := strings.Join([]string{
`data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"echo","arguments":"{}"}}]}}],"usage":{"prompt_tokens":12,"completion_tokens":5,"total_tokens":17}}`,
`data: {"error":{"message":"server is at capacity"}}`,
"",
}, "\n\n")
var end Event
for ev := range ParseSSE(context.Background(), strings.NewReader(body)) {
end = ev
}
if end.Kind != KindMessageEnd || end.Message.StopReason != StopError ||
len(end.Message.ToolCalls) != 0 || end.Err == nil || end.Retryable ||
end.Usage.TotalTokens != 17 {
t.Fatalf("provider error after usage retried or lost its charge: %#v", end)
}
}
type errorAfterDataReader struct{ data *strings.Reader }
func (r *errorAfterDataReader) Read(p []byte) (int, error) {
n, err := r.data.Read(p)
if err == io.EOF {
return 0, io.ErrUnexpectedEOF
}
return n, err
}
func TestParseSSEReaderErrorAfterUsageKeepsUsage(t *testing.T) {
body := strings.Join([]string{
`data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"echo","arguments":"{}"}}]}}],"usage":{"prompt_tokens":12,"completion_tokens":5,"total_tokens":17}}`,
"",
}, "\n\n")
var end Event
for ev := range ParseSSE(context.Background(), &errorAfterDataReader{data: strings.NewReader(body)}) {
end = ev
}
if end.Kind != KindMessageEnd || end.Message.StopReason != StopError ||
len(end.Message.ToolCalls) != 0 || end.Err == nil || end.Retryable ||
end.Usage.TotalTokens != 17 {
t.Fatalf("reader error after usage retried or lost its charge: %#v", end)
}
}
func TestParseSSECancel(t *testing.T) {
pr, pw := io.Pipe()
ctx, cancel := context.WithCancel(context.Background())
@@ -290,3 +541,81 @@ func TestParseSSEReasoningDelta(t *testing.T) {
t.Fatalf("end content %q must not include reasoning", end.Message.Content)
}
}
// collectTerminal drains one stream and fails unless it carries exactly one
// terminal message_end or error event.
func collectTerminal(t *testing.T, ch <-chan Event) Event {
t.Helper()
var end Event
terminals := 0
for ev := range ch {
if ev.Kind == KindMessageEnd || ev.Kind == KindError {
terminals++
end = ev
}
}
if terminals != 1 {
t.Fatalf("stream emitted %d terminal events, want 1", terminals)
}
return end
}
func TestSSETailStructure(t *testing.T) {
toolFrames := `data: {"choices":[{"delta":{"content":"PRIVATE_TAIL_R06","tool_calls":[{"index":0,"id":"call_1","function":{"name":"PRIVATE_TAIL_R06","arguments":"{}"}}]}}]}` + "\n\n" +
`data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":12,"completion_tokens":5,"total_tokens":17}}` + "\n\n"
textFrame := `data: {"choices":[{"delta":{"content":"PRIVATE_TAIL_R06"},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":2,"total_tokens":5}}` + "\n\n"
tests := []struct {
name string
body string
kind string
count int
outcome string
complete bool
tools int
usage int
wantError bool
}{
{"done", toolFrames + "data: [DONE]\n\n", "done_literal", 3, "done_marker", true, 1, 17, false},
{"direct-eof", toolFrames, "json_frame", 2, "clean_eof", false, 0, 17, true},
{"comment-then-eof", toolFrames + ": PRIVATE_TAIL_R06\n\n", "comment", 3, "clean_eof", false, 0, 17, true},
{"empty-data", toolFrames + "data:\n\n", "empty_data", 3, "invalid_frame", false, 0, 17, true},
{"unknown-non-json", toolFrames + "data: NOT_DONE\n\n", "unknown_non_json", 3, "invalid_frame", false, 0, 17, true},
{"partial-frame", toolFrames + "data: {", "partial_frame", 3, "invalid_frame", false, 0, 17, true},
{"plain-text-eof", textFrame, "json_frame", 1, "clean_eof", false, 0, 5, false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
end := collectTerminal(t, ParseSSE(context.Background(), strings.NewReader(tc.body)))
got := end.TerminalObservation
if got == nil || got.TailEventKind != tc.kind || got.TailEventCount != tc.count || got.ReadOutcome != tc.outcome {
t.Fatalf("wrong tail observation: %+v", got)
}
if end.StreamComplete != tc.complete || end.Usage.TotalTokens != tc.usage {
t.Fatalf("tail changed completion or lost Usage: complete=%v usage=%+v", end.StreamComplete, end.Usage)
}
if len(end.Message.ToolCalls) != tc.tools {
t.Fatalf("released %d tool calls, want %d", len(end.Message.ToolCalls), tc.tools)
}
if (end.Err != nil) != tc.wantError {
t.Fatalf("terminal error = %v, want error %v", end.Err, tc.wantError)
}
raw, err := json.Marshal(got)
if err != nil || strings.Contains(string(raw), "PRIVATE_TAIL_R06") {
t.Fatalf("unsafe tail observation: %s err=%v", raw, err)
}
})
}
}
func TestSSETailCountSaturates(t *testing.T) {
body := `data: {"choices":[{"delta":{"content":"x"},"finish_reason":"stop"}]}` + "\n\n" +
strings.Repeat(":\n", 10050)
end := collectTerminal(t, ParseSSE(context.Background(), strings.NewReader(body)))
got := end.TerminalObservation
if got == nil || got.TailEventKind != "comment" || got.TailEventCount != 10000 || got.ReadOutcome != "clean_eof" {
t.Fatalf("tail count did not saturate: %+v", got)
}
if end.Err != nil || end.Message.Content != "x" || len(end.Message.ToolCalls) != 0 {
t.Fatalf("comments changed the parsed message: %+v", end)
}
}
+20
View File
@@ -0,0 +1,20 @@
event: message_start
data: {"type":"message_start","message":{"id":"msg_01","type":"message","role":"assistant","model":"claude-fixture","usage":{"input_tokens":88,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}}
event: content_block_start
data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_01ABC","name":"read_file"}}
event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\"path\":"}}
event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"\"main.go\"}"}}
event: content_block_stop
data: {"type":"content_block_stop","index":0}
event: message_delta
data: {"type":"message_delta","delta":{"stop_reason":"tool_use"},"usage":{"output_tokens":31}}
event: message_stop
data: {"type":"message_stop"}
+29
View File
@@ -0,0 +1,29 @@
event: message_start
data: {"type":"message_start","message":{"id":"msg_02","type":"message","role":"assistant","model":"claude-fixture","usage":{"input_tokens":142,"cache_read_input_tokens":60,"cache_creation_input_tokens":0}}}
event: content_block_start
data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}
event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"用户问 main.go,工具已返回。"}}
event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"EqoBCkgIARABGAIiQA"}}
event: content_block_stop
data: {"type":"content_block_stop","index":0}
event: content_block_start
data: {"type":"content_block_start","index":1,"content_block":{"type":"text","text":""}}
event: content_block_delta
data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"查到了,main.go 一共 42 行。"}}
event: content_block_stop
data: {"type":"content_block_stop","index":1}
event: message_delta
data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":57}}
event: message_stop
data: {"type":"message_stop"}
+17
View File
@@ -0,0 +1,17 @@
event: message_start
data: {"type":"message_start","message":{"id":"msg_03","type":"message","role":"assistant","model":"claude-fixture","usage":{"input_tokens":201}}}
event: content_block_start
data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}
event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"42 行。"}}
event: content_block_stop
data: {"type":"content_block_stop","index":0}
event: message_delta
data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":9}}
event: message_stop
data: {"type":"message_stop"}
+45
View File
@@ -0,0 +1,45 @@
package llm
import (
"strings"
"testing"
)
// 交错思考模型(MiniMax 等)要求历史 assistant 原样带回思考内容:
// SSE 把 reasoning_content 捕获进 Message.Thinking;序列化把它作为
// <think> 前缀回放。没有思考块的消息保持零改动。
func TestReasoningCaptureAndReplay(t *testing.T) {
// 回放侧。
msgs := []Message{
{Role: RoleUser, Content: "总结"},
{Role: RoleAssistant, Content: "结论。", Thinking: []ThinkingBlock{{Thinking: "先读材料。"}}},
{Role: RoleAssistant, Content: "没有思考的轮次。"},
}
out := toProviderMessages(msgs)
if got := out[1]["content"].(string); !strings.HasPrefix(got, "<think>\n先读材料。\n</think>") || !strings.HasSuffix(got, "结论。") {
t.Fatalf("assistant replay malformed: %q", got)
}
if got := out[2]["content"].(string); got != "没有思考的轮次。" {
t.Fatalf("non-thinking assistant must be untouched: %q", got)
}
if _, ok := out[0]["tool_calls"]; ok {
t.Fatal("user messages carry no tool_calls")
}
// 捕获侧:直接构造 SSE 流。
sse := "data: {\"choices\":[{\"delta\":{\"reasoning_content\":\"思考\"}}]}\n\n" +
"data: {\"choices\":[{\"delta\":{\"content\":\"正文\"}}]}\n\n" +
"data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n" +
"data: [DONE]\n\n"
var end Event
for ev := range ParseSSE(t.Context(), strings.NewReader(sse)) {
if ev.Kind == KindMessageEnd {
end = ev
}
}
if end.Message.Content != "正文" {
t.Fatalf("content polluted by reasoning: %q", end.Message.Content)
}
if len(end.Message.Thinking) != 1 || end.Message.Thinking[0].Thinking != "思考" {
t.Fatalf("reasoning not captured: %+v", end.Message.Thinking)
}
}
+74 -6
View File
@@ -52,8 +52,21 @@ type Message struct {
Images []Image `json:"images,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
StopReason StopReason `json:"stop_reason,omitempty"`
IsError bool `json:"is_error,omitempty"`
// Thinking 是思考块:Anthropic 原生路径含签名、在请求里原样往返;
// OpenAI 兼容路径由 SSE 的 reasoning_content 捕获(不含签名),随会话
// 转录持久化,请求序列化时按 <think> 块回放(见 client.go replayThink)。
Thinking []ThinkingBlock `json:"thinking,omitempty"`
StopReason StopReason `json:"stop_reason,omitempty"`
IsError bool `json:"is_error,omitempty"`
}
// ThinkingBlock 是一块 Anthropic 思考内容。Redacted 为真时 Data 是不透明
// 密文(redacted_thinking),往返时原样回送。
type ThinkingBlock struct {
Thinking string `json:"thinking,omitempty"`
Signature string `json:"signature,omitempty"`
Redacted bool `json:"redacted,omitempty"`
Data string `json:"data,omitempty"`
}
// ToolDecl is the JSON-schema declaration sent to the provider.
@@ -85,6 +98,50 @@ type Usage struct {
LastTotalTokens int `json:"last_total_tokens,omitempty"`
}
// TerminalObservation records how a stream ended without retaining provider
// content, tool identity, arguments, request headers, or error text. Counts
// saturate at fixed limits; this evidence never authorizes a tool call.
type TerminalObservation struct {
ReadOutcome string `json:"readOutcome"`
LastFrameJSONComplete bool `json:"lastFrameJSONComplete"`
UsagePresent bool `json:"usagePresent"`
ToolFragments int `json:"toolFragments"`
ToolArgumentBytes int `json:"toolArgumentBytes"`
ToolArgumentsValid bool `json:"toolArgumentsValid"`
// TailEventKind is the structural class of the last SSE event the parser
// completed: done_literal, json_frame, empty_data, comment,
// unknown_non_json, partial_frame or unknown. TailEventCount counts those
// events and saturates at 10000. Neither marks the stream complete.
TailEventKind string `json:"tailEventKind"`
TailEventCount int `json:"tailEventCount"`
// BodyBoundary is set only by Client on a response it received; a bare
// ParseSSE call leaves it nil.
BodyBoundary *BodyBoundaryObservation `json:"bodyBoundary,omitempty"`
// CompletionBasis mirrors Event.CompletionBasis.
CompletionBasis string `json:"completionBasis,omitempty"`
// ContractCheck is the first route-contract condition, C1 through C9, that
// failed when a configured RouteContractEOF did not release a stream that
// ended without [DONE]. Empty when no contract evaluated the stream.
ContractCheck string `json:"contractCheck,omitempty"`
}
// BodyBoundaryObservation records how the client read one HTTP response body.
// It holds only fixed enums, booleans and a saturated byte count. Reaching
// EOF proves only that the body ended, not that the SSE stream completed.
type BodyBoundaryObservation struct {
// ProtocolClass is http1, http2 or other.
ProtocolClass string `json:"protocolClass"`
// FramingClass is known_length, chunked, http2_stream, close_delimited or unknown.
FramingClass string `json:"framingClass"`
DeclaredLengthKnown bool `json:"declaredLengthKnown"`
BytesRead int64 `json:"bytesRead"`
BytesSaturated bool `json:"bytesSaturated"`
EOFSeen bool `json:"eofSeen"`
// LengthRelation is match, mismatch or unknown.
LengthRelation string `json:"lengthRelation"`
Uncompressed bool `json:"uncompressed"`
}
// Event is one streamed update. Failures are KindError plus a final assistant
// message whose StopReason is error or aborted. Stream implementations do not panic.
type Event struct {
@@ -100,10 +157,21 @@ type Event struct {
// FinishReason 是供应商原始结束原因(stop、length、tool_calls 等)。
// 空表示这条事件不携带结束原因。StopReason 的映射不受此字段影响。
FinishReason string
Err error
Retryable bool
ErrorMessage string
// HTTPStatus 是提供方的 HTTP 状态。0 表示这条错误不是 HTTP 响应。
// StreamComplete records whether the OpenAI-compatible SSE stream supplied
// its explicit [DONE] marker. Tool calls require this before execution
// unless CompletionBasis is route_contract_eof.
StreamComplete bool
// CompletionBasis is set by Client on a successful OpenAI-chat message_end:
// done_marker when [DONE] arrived, route_contract_eof when RouteContractEOF
// released tool calls without it. Empty otherwise.
CompletionBasis string
// TerminalObservation is present only on a terminal/error event. It does
// not change StreamComplete or the tool-execution safety gate.
TerminalObservation *TerminalObservation
Err error
Retryable bool
ErrorMessage string
// HTTPStatus 是提供方的 HTTP 状态;0 表示尚无 HTTP 响应。
HTTPStatus int
// RetryAfter is the provider's requested wait. HasRetryAfter distinguishes
// a real zero from a missing or unparseable header.
+483 -51
View File
@@ -3,15 +3,19 @@ package runtime
import (
"context"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"sync"
"time"
"agent-native/runtime/hooks"
"agent-native/runtime/llm"
"agent-native/runtime/memory"
"agent-native/runtime/reminders"
"agent-native/runtime/session"
"agent-native/runtime/telemetry"
"agent-native/runtime/tools"
"logging-system/logsys"
@@ -35,6 +39,17 @@ type Event struct {
Message llm.Message
Tool string
Text string
// ToolIndex preserves the provider's call slot for tool_call_delta.
ToolIndex int
// CallID 在 tool_execution_start / tool_execution_end 时携带工具调用的
// ID;tool_call_delta 也携带同一 ID。消费方按 ID 配对而非按顺序。
// 不得从 ToolIndex 反推;ToolIndex 语义不变。
CallID string
// Status 在 tool_execution_end 时填写:ok | error | denied | canceled。
// 空串 = legacy/unknown,不得臆造。
Status string
// ErrorReason 在 Status 为 error/denied/canceled 时携带真实失败文本。
ErrorReason string
// Detail carries tool arguments/results or reasoning deltas for
// process-visualization consumers; log writers may ignore it.
Detail string
@@ -43,6 +58,12 @@ type Event struct {
PromptTokens int
CompletionTokens int
TotalTokens int
// 以下在 type=usage 时有意义(WP6:上一请求与缓存命中拆分)。
LastPromptTokens int
LastCompletionTokens int
LastTotalTokens int
CacheReadTokens int
CacheWriteTokens int
}
// Config drives one agent session.
@@ -57,8 +78,11 @@ type Config struct {
Trace func(name string, attrs map[string]string) func()
MaxTurns int
ToolExecution string
GetSteering func() []llm.Message
GetFollowUp func() []llm.Message
// SerialTools 是并行执行下的顺序敏感工具名集合:这些调用按模型给出的
// 原顺序在调用方 goroutine 逐个执行(互斥且保序),其余调用并发执行。
SerialTools map[string]bool
GetSteering func() []llm.Message
GetFollowUp func() []llm.Message
// OnEvent 观察循环事件。同一次 Run 内不会并发调用
// (与 BeforeTool、AfterTool 共用一把锁串行)。
OnEvent func(Event)
@@ -108,8 +132,46 @@ type Config struct {
ContextBuffer int
// KeepToolResults 是清理时保留的最近工具结果条数。0 表示 5。
KeepToolResults int
// ResultsDir 非空时,被清理的旧工具结果完整内容落盘到该目录
// (占位写路径);空则占位指向会话记录(旧行为)。
ResultsDir string
// JournalOnly 为真且 Session 非空时:Session 只作意图日志与恢复配对
// (不恢复的完整权威上下文),请求上下文始终由 History 组装;
// 本次恢复写入的配对摘要以系统消息注入(不持久化,已在 journal 里)。
// 为假(默认)保持既有语义:Session 为权威上下文。
JournalOnly bool
// AgentName 是 invoke_agent span 的名字后缀(OTel GenAI 约定
// "invoke_agent <agent>")。空 = "agent-native"。
AgentName string
// ProviderName 是 gen_ai.system 属性值(如 openai / anthropic)。
// 空 = "openai"。
ProviderName string
// Memory 是记忆端口(N3 WP5):nil = 不启用。启用时每次请求前
// Recall 一次并以系统消息注入请求副本(不落盘、不累计)。
Memory memory.Store
// MemoryScope 是 Recall 的 scope 过滤("" = 不限,含 user/project;
// AgentSpec.MemoryScope 的消费点)。
MemoryScope string
// RetryAttempts 是流式错误的默认重试次数(WP6 下沉为库默认):
// 0 值 = 2 次(默认);负值 = 关闭;正数 = 次数。
// 只在还没向调用方发出增量时重试(WithRetry 既有语义)。
RetryAttempts int
// traceCtx 是本次 Run 的 span 关联(Run 启动时建立;指针随 Config
// 副本传播到各轮与各工具)。
traceCtx *traceIDs
// Hooks 是进程内钩子。空总线不改变停止、提醒和拒绝次数。
Hooks *hooks.Bus
// PermissionAsk 在钩子对一次工具调用表态 ask 时向宿主请求裁决。
// nil 时保持旧行为:ask 与 deny 一样按拒绝处理。
// 返回 Allow 时工具照常执行;宿主需要「本会话始终允许」时自行记录,
// 内核不替宿主保存放行状态。
PermissionAsk func(ctx context.Context, call llm.ToolCall) AskVerdict
}
// AskVerdict 是宿主对一次 ask 决策的裁决。
type AskVerdict struct {
Allow bool
Reason string // 拒绝时写进配对 tool 结果的理由;放行时忽略
}
// StopInfo 是 Stop 钩子看到的本 run 进度。
@@ -143,6 +205,13 @@ type Result struct {
Notice string
Err error
Retryable bool
// Canceled is true when the run stopped due to abort/cancel (StopAborted
// or context cancellation). Canceled runs are never retryable.
Canceled bool
// ToolCalls is the number of tool invocations dispatched during the run.
// Adapters use it to set SideEffects (0 before any dispatch => none where
// the failure is otherwise definitive; otherwise possible).
ToolCalls int
}
// Run executes the turn loop until the model stops, follow-ups drain, or the turn cap is hit.
@@ -157,41 +226,112 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) {
cfg.ToolExecution = ExecSequential
}
serializeHostCallbacks(&cfg)
endRun := startSpan(cfg, "invoke_agent", map[string]string{"model": cfg.Model})
if cfg.Memory != nil && cfg.Tools != nil {
memory.RegisterWrite(cfg.Tools, cfg.Memory, cfg.MemoryScope)
}
// 流式错误默认重试(WP6):库默认 2 次、负值关闭;重试事件进事件面。
if cfg.Stream != nil {
attempts := cfg.RetryAttempts
if attempts == 0 {
attempts = 2
}
if attempts > 0 {
cfg.Stream = llm.WithRetry(cfg.Stream, attempts, func(kind, detail string) {
emit(cfg, Event{Type: kind, Text: detail})
})
}
}
cfg.traceCtx = newTraceContext()
endRun := startSpan(cfg, "invoke_agent "+agentName(cfg), map[string]string{
"gen_ai.system": providerName(cfg),
"gen_ai.agent.name": agentName(cfg),
"gen_ai.request.model": cfg.Model,
}, true)
defer endRun()
var messages []llm.Message
if cfg.Session != nil {
if cfg.Session != nil && !cfg.JournalOnly {
loaded, err := cfg.Session.Load()
if err != nil {
return Result{}, err
}
messages = loaded
}
if err := recoverPending(ctx, cfg); err != nil {
recovered, err := recoverPending(ctx, cfg)
if err != nil {
return Result{}, err
}
if cfg.Session != nil {
// 尾部配对兜底:无论进程死在 persist(assistant) 与 commitToolIntent
// 之间的哪个窗口,补全尾部 assistant 的无结果调用,转录配对恒成立。
repaired, err := repairOrphanTail(cfg)
if err != nil {
return Result{}, err
}
recovered = append(recovered, repaired...)
if cfg.Session != nil && !cfg.JournalOnly {
loaded, err := cfg.Session.Load()
if err != nil {
return Result{}, err
}
messages = loaded
}
seeded := false
if len(messages) == 0 && len(cfg.History) > 0 {
messages = append([]llm.Message{}, cfg.History...)
seeded = true
}
prependedSys := false
if len(messages) == 0 || messages[0].Role != llm.RoleSystem {
sys := llm.Message{Role: llm.RoleSystem, Content: systemText(cfg)}
messages = append([]llm.Message{sys}, messages...)
if err := persist(cfg, sys); err != nil {
return Result{}, err
}
prependedSys = true
}
// 种子落盘(N3 WP2):转录为权威上下文时,History 只作一次性种子——
// 不落盘则第二跑起种子静默丢失。已落盘的前置系统消息不重复写;
// JournalOnly 时种子不进 journal(journal 只收本轮起的消息)。
if seeded && !cfg.JournalOnly {
for i, m := range messages {
if i == 0 && prependedSys {
continue
}
if err := persist(cfg, m); err != nil {
return Result{}, err
}
}
}
if cfg.JournalOnly && len(recovered) > 0 {
// 恢复摘要以系统消息注入上下文(不持久化——内容已在 journal 里)。
messages = append(messages, llm.Message{Role: llm.RoleSystem, Content: recoveredNoteText(recovered)})
}
if err := persist(cfg, user); err != nil {
return Result{}, err
}
messages = append(messages, user)
emit(cfg, Event{Type: "agent_start"})
cfg.Hooks.Emit(hooks.Call{Event: hooks.SessionStart})
if out := cfg.Hooks.Emit(hooks.Call{Event: hooks.UserPromptSubmit, LastText: user.Content}); out.Decision != "" {
switch out.Decision {
case hooks.Deny, hooks.Block:
// 输入护栏拒绝:不进入循环,以理由直接收尾(原文已持久化留痕)。
why := out.Reason
if why == "" {
why = "输入被护栏拒绝"
}
return Result{Text: why, Messages: messages, StopReason: llm.StopEnd, Notice: why}, nil
case hooks.Modify:
// 改写后的输入追加为新的用户消息(原文保留,可审计)。
if out.Replacement != "" {
note := llm.Message{Role: llm.RoleUser, Content: out.Replacement}
if err := persist(cfg, note); err != nil {
return Result{}, err
}
messages = append(messages, note)
user = note
}
}
}
var last llm.Message
var usage llm.Usage
@@ -200,34 +340,56 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) {
lengthContinues := 0
lengthNudge := false
var repeats repeatState
defer func() {
cfg.Hooks.Emit(hooks.Call{Event: hooks.SessionEnd, LastText: last.Content, ToolCalls: toolCalls})
}()
for turn := 0; turn < cfg.MaxTurns; turn++ {
if ctx.Err() != nil {
cfg.logger().Error("runtime turn aborted", ctx.Err())
return Result{Text: last.Content, Messages: messages, StopReason: llm.StopAborted, Err: ctx.Err()}, nil
return Result{
Text: last.Content, Messages: messages, StopReason: llm.StopAborted,
Err: ctx.Err(), Canceled: true, ToolCalls: toolCalls,
}, nil
}
if reason := budgetExceeded(cfg.Budget, usage, toolCalls, time.Now()); reason != "" {
return budgetStop(cfg, last, messages, usage, reason), nil
return budgetStop(cfg, last, messages, usage, toolCalls, reason), nil
}
emit(cfg, Event{Type: "turn_start"})
emit(cfg, Event{Type: "message_start"})
request := prepareRequest(cfg, messages, turn, &repeats, &lengthNudge)
end, errEv := consume(ctx, cfg, request)
if errEv != nil {
cfg.logger().Error("runtime turn failed", errEv, "retryable", end.Retryable)
return Result{Text: end.Message.Content, Messages: messages, StopReason: end.Message.StopReason, Err: errEv, Retryable: end.Retryable}, nil
canceled := end.Message.StopReason == llm.StopAborted ||
errors.Is(errEv, context.Canceled) || errors.Is(ctx.Err(), context.Canceled)
retryable := end.Retryable && !canceled
cfg.logger().Error("runtime turn failed", errEv, "retryable", retryable, "canceled", canceled)
emitUsage(cfg, usage)
return Result{
Text: end.Message.Content, Messages: messages, StopReason: end.Message.StopReason,
Err: errEv, Retryable: retryable, Canceled: canceled, ToolCalls: toolCalls,
}, nil
}
last = end.Message
usage.PromptTokens += end.Usage.PromptTokens
usage.CompletionTokens += end.Usage.CompletionTokens
usage.TotalTokens += end.Usage.TotalTokens
// cache 只入累计位(WP6 决策:不加 Last* cache 字段,避免字段膨胀)。
usage.CacheReadTokens += end.Usage.CacheReadTokens
usage.CacheWriteTokens += end.Usage.CacheWriteTokens
usage.LastPromptTokens = end.Usage.PromptTokens
usage.LastCompletionTokens = end.Usage.CompletionTokens
usage.LastTotalTokens = end.Usage.TotalTokens
if end.Err != nil {
cfg.logger().Error("runtime terminal stream failed", end.Err)
emitUsage(cfg, usage)
return Result{Text: last.Content, Messages: messages, StopReason: llm.StopError,
Usage: usage, Err: end.Err, Retryable: false, ToolCalls: toolCalls}, nil
}
if err := persist(cfg, last); err != nil {
return Result{}, err
}
messages = append(messages, last)
emit(cfg, Event{Type: "message_end", Message: last, PromptTokens: end.Usage.PromptTokens, CompletionTokens: end.Usage.CompletionTokens, TotalTokens: end.Usage.TotalTokens})
emit(cfg, Event{Type: "message_end", Message: last, PromptTokens: end.Usage.PromptTokens, CompletionTokens: end.Usage.CompletionTokens, TotalTokens: end.Usage.TotalTokens, CacheReadTokens: end.Usage.CacheReadTokens, CacheWriteTokens: end.Usage.CacheWriteTokens})
if last.StopReason == llm.StopToolCalls && len(last.ToolCalls) > 0 {
if reason := budgetExceeded(cfg.Budget, usage, toolCalls, time.Now()); reason != "" {
@@ -236,7 +398,7 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) {
if err != nil {
return Result{}, err
}
return budgetStop(cfg, last, messages, usage, reason), nil
return budgetStop(cfg, last, messages, usage, toolCalls, reason), nil
}
if cfg.Budget != nil && cfg.Budget.MaxToolCalls > 0 && toolCalls+len(last.ToolCalls) > cfg.Budget.MaxToolCalls {
reason := fmt.Sprintf("budget_exceeded: tool calls %d+%d > max %d", toolCalls, len(last.ToolCalls), cfg.Budget.MaxToolCalls)
@@ -245,7 +407,7 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) {
if err != nil {
return Result{}, err
}
return budgetStop(cfg, last, messages, usage, reason), nil
return budgetStop(cfg, last, messages, usage, toolCalls, reason), nil
}
toolCalls += len(last.ToolCalls)
if cfg.RepeatGuard {
@@ -266,6 +428,7 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) {
messages = append(messages, m)
}
}
cfg.Hooks.Emit(hooks.Call{Event: hooks.PostToolBatch, Turn: turn, ToolCalls: len(last.ToolCalls)})
emit(cfg, Event{Type: "turn_end", Message: last})
continue
}
@@ -278,7 +441,8 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) {
if lengthCap > 0 && lengthContinues >= lengthCap {
errCap := errString("length continuation exceeded")
cfg.logger().Error("runtime length continuation exceeded", errCap)
return Result{Text: last.Content, Messages: messages, StopReason: llm.StopError, Usage: usage, Err: errCap}, nil
return Result{Text: last.Content, Messages: messages, StopReason: llm.StopError,
Usage: usage, Err: errCap, ToolCalls: toolCalls}, nil
}
lengthContinues++
lengthNudge = true
@@ -297,8 +461,8 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) {
if decision.Continue && stopContinues < maxStopContinuations {
stopContinues++
if decision.Replacement != "" && len(messages) > 0 {
messages[len(messages)-1].Content = decision.Replacement
last.Content = decision.Replacement
// 替换文只以 user 消息追加(N3 WP1/N6:不再改写内存里的
// assistant——内存与转录保持一致;模型经该消息看到更正)。
note := llm.Message{Role: llm.RoleUser, Content: decision.Replacement}
if err := persist(cfg, note); err != nil {
return Result{}, err
@@ -314,9 +478,9 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) {
}
continue
}
return settle(cfg, last, messages, usage, decision.Notice), nil
return settle(cfg, last, messages, usage, toolCalls, decision.Notice), nil
}
return settle(cfg, last, messages, usage, ""), nil
return settle(cfg, last, messages, usage, toolCalls, ""), nil
}
errCap := errString("max turns exceeded")
cfg.logger().Error("runtime turn cap hit", errCap)
@@ -326,6 +490,7 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) {
StopReason: llm.StopError,
Usage: usage,
Err: errCap,
ToolCalls: toolCalls,
}, nil
}
@@ -364,7 +529,11 @@ func systemText(cfg Config) string {
}
func consume(ctx context.Context, cfg Config, messages []llm.Message) (llm.Event, error) {
endSpan := startSpan(cfg, "chat", map[string]string{"model": cfg.Model})
spanAttrs := map[string]string{
"gen_ai.system": providerName(cfg),
"gen_ai.request.model": cfg.Model,
}
endSpan := startSpan(cfg, "chat "+cfg.Model, spanAttrs, false)
defer endSpan()
var decls []llm.ToolDecl
if cfg.Tools != nil {
@@ -393,7 +562,8 @@ func consume(ctx context.Context, cfg Config, messages []llm.Message) (llm.Event
case llm.KindReasoningDelta:
emit(cfg, Event{Type: "reasoning_delta", Text: ev.ReasoningDelta})
case llm.KindToolCallDelta:
emit(cfg, Event{Type: "tool_call_delta", Tool: ev.ToolName, Detail: ev.ArgumentsDelta})
emit(cfg, Event{Type: "tool_call_delta", ToolIndex: ev.ToolIndex,
CallID: ev.ToolID, Tool: ev.ToolName, Detail: ev.ArgumentsDelta})
case llm.KindError:
return ev, ev.Err
case llm.KindMessageEnd:
@@ -401,6 +571,11 @@ func consume(ctx context.Context, cfg Config, messages []llm.Message) (llm.Event
sawEnd = true
}
}
// usage 在 span 结束前补进属性(结束时过滤导出,见 telemetry 工厂)。
if sawEnd {
spanAttrs["gen_ai.usage.input_tokens"] = itoa(end.Usage.PromptTokens)
spanAttrs["gen_ai.usage.output_tokens"] = itoa(end.Usage.CompletionTokens)
}
if !sawEnd {
return llm.Event{Message: llm.Message{StopReason: llm.StopError}}, errString("stream ended without a message")
}
@@ -429,6 +604,9 @@ func appendSkippedToolResults(cfg Config, calls []llm.ToolCall, messages []llm.M
}
// serializeHostCallbacks 让同一次 Run 的宿主回调不会并发进入。
// 并行工具执行下各 goroutine 各自构造 Event 值(无跨调用共享缓冲);
// 此处再串行化 OnEvent,避免消费方在回调内聚合时字段交错。回调须保持
// 同步、非阻塞(与 Config.OnEvent 文档一致)。
func serializeHostCallbacks(cfg *Config) {
var mu sync.Mutex
if cfg.OnEvent != nil {
@@ -459,12 +637,17 @@ func serializeHostCallbacks(cfg *Config) {
func executeTools(ctx context.Context, cfg Config, assistant llm.Message) []llm.Message {
if cfg.ToolExecution == ExecParallel && len(assistant.ToolCalls) > 1 {
return executeParallel(ctx, cfg, assistant)
if len(cfg.SerialTools) == 0 {
return executeParallel(ctx, cfg, assistant)
}
return executeMixed(ctx, cfg, assistant)
}
out := make([]llm.Message, 0, len(assistant.ToolCalls))
for _, call := range assistant.ToolCalls {
if err := ctx.Err(); err != nil {
out = append(out, cancelledToolMessage(call, err))
msg := cancelledToolMessage(call, err)
emitCancelledTool(cfg, call, msg)
out = append(out, msg)
continue
}
out = append(out, runOne(ctx, cfg, call))
@@ -472,6 +655,38 @@ func executeTools(ctx context.Context, cfg Config, assistant llm.Message) []llm.
return out
}
// executeMixed 并行执行非串行调用,串行名(SerialTools)按模型给出的原顺序
// 在调用方 goroutine 逐个执行——互斥且保序(卡片流等有先后依赖的工具不能只
// 靠互斥:并发派发下先抢到锁的不一定是先声明的调用)。
func executeMixed(ctx context.Context, cfg Config, assistant llm.Message) []llm.Message {
out := make([]llm.Message, len(assistant.ToolCalls))
var wg sync.WaitGroup
serialIdx := make([]int, 0, len(assistant.ToolCalls))
for i, call := range assistant.ToolCalls {
if cfg.SerialTools[call.Name] {
serialIdx = append(serialIdx, i)
continue
}
wg.Add(1)
go func(i int, call llm.ToolCall) {
defer wg.Done()
out[i] = runOne(ctx, cfg, call)
}(i, call)
}
for _, i := range serialIdx {
if err := ctx.Err(); err != nil {
call := assistant.ToolCalls[i]
msg := cancelledToolMessage(call, err)
emitCancelledTool(cfg, call, msg)
out[i] = msg
continue
}
out[i] = runOne(ctx, cfg, assistant.ToolCalls[i])
}
wg.Wait()
return out
}
func executeParallel(ctx context.Context, cfg Config, assistant llm.Message) []llm.Message {
out := make([]llm.Message, len(assistant.ToolCalls))
var wg sync.WaitGroup
@@ -496,6 +711,22 @@ func cancelledToolMessage(call llm.ToolCall, err error) llm.Message {
}
}
// emitCancelledTool 发出未执行/中止的 start+end;Status=canceled,不得报 ok。
func emitCancelledTool(cfg Config, call llm.ToolCall, msg llm.Message) {
emit(cfg, Event{Type: "tool_execution_start", Tool: call.Name, CallID: call.ID, Detail: call.Arguments})
emitToolEnd(cfg, call, msg.Content, "canceled")
}
// emitToolEnd 投影 tool_execution_end:CallID 取自 call,Status/ErrorReason
// 仅在已知时填写(空 Status = legacy/unknown)。
func emitToolEnd(cfg Config, call llm.ToolCall, text, status string) {
ev := Event{Type: "tool_execution_end", Tool: call.Name, CallID: call.ID, Text: text, Status: status}
if status == "error" || status == "denied" || status == "canceled" {
ev.ErrorReason = text
}
emit(cfg, ev)
}
func rejectIncompleteToolCall(call llm.ToolCall) string {
if strings.TrimSpace(call.Name) == "" {
return "工具调用未执行:名称不完整"
@@ -525,19 +756,26 @@ func stopDecision(cfg Config, last llm.Message, continuations, toolCalls int) (S
return StopDecision{}, false
}
func gateTool(cfg Config, call llm.ToolCall) (llm.ToolCall, llm.Message, bool) {
func gateTool(ctx context.Context, cfg Config, call llm.ToolCall) (llm.ToolCall, llm.Message, bool) {
if cfg.Hooks == nil {
return call, llm.Message{}, false
}
out := cfg.Hooks.Emit(hooks.Call{Event: hooks.PreToolUse, Tool: call.Name, Args: call.Arguments})
switch out.Decision {
case hooks.Deny, hooks.Block, hooks.Ask:
case hooks.Ask:
// ask 先问宿主;没有裁决端口(或宿主拒绝)时保持旧行为:按拒绝处理。
cfg.Hooks.Emit(hooks.Call{Event: hooks.PermissionRequest, Tool: call.Name, Args: call.Arguments})
why := out.Reason
if why == "" {
why = "工具调用被拒绝"
if cfg.PermissionAsk != nil {
if verdict := cfg.PermissionAsk(ctx, call); verdict.Allow {
return call, llm.Message{}, false
} else if verdict.Reason != "" {
why = verdict.Reason
}
}
msg := llm.Message{Role: llm.RoleTool, Name: call.Name, ToolCallID: call.ID, Content: why, IsError: true}
return call, msg, true
return call, rejectToolMessage(call, why), true
case hooks.Deny, hooks.Block:
return call, rejectToolMessage(call, out.Reason), true
case hooks.Modify:
if out.Args != "" {
call.Arguments = out.Args
@@ -546,6 +784,13 @@ func gateTool(cfg Config, call llm.ToolCall) (llm.ToolCall, llm.Message, bool) {
return call, llm.Message{}, false
}
func rejectToolMessage(call llm.ToolCall, why string) llm.Message {
if why == "" {
why = "工具调用被拒绝"
}
return llm.Message{Role: llm.RoleTool, Name: call.Name, ToolCallID: call.ID, Content: why, IsError: true}
}
func noteToolHook(cfg Config, call llm.ToolCall, msg llm.Message) {
if cfg.Hooks == nil {
return
@@ -557,22 +802,40 @@ func noteToolHook(cfg Config, call llm.ToolCall, msg llm.Message) {
cfg.Hooks.Emit(hooks.Call{Event: ev, Tool: call.Name, Args: call.Arguments, Result: msg.Content, IsError: msg.IsError})
}
// toolCallKey 是把当前工具调用注入 Execute ctx 的私有键:并行执行下工具实现
// 需要知道自己服务于哪次调用(配对过程事件、按调用记账),经
// ToolCallFromContext 读取;旧宿主/直接构造的 ctx 返回 ok=false。
type toolCallKey struct{}
// WithToolCall 把 call 挂进 ctx(内核 runOne 在执行前注入)。
func WithToolCall(ctx context.Context, call llm.ToolCall) context.Context {
return context.WithValue(ctx, toolCallKey{}, call)
}
// ToolCallFromContext 取出当前正在执行的调用;不存在时 ok=false。
func ToolCallFromContext(ctx context.Context) (llm.ToolCall, bool) {
call, ok := ctx.Value(toolCallKey{}).(llm.ToolCall)
return call, ok
}
func runOne(ctx context.Context, cfg Config, call llm.ToolCall) llm.Message {
if err := ctx.Err(); err != nil {
return cancelledToolMessage(call, err)
msg := cancelledToolMessage(call, err)
emitCancelledTool(cfg, call, msg)
return msg
}
if why := rejectIncompleteToolCall(call); why != "" {
msg := llm.Message{Role: llm.RoleTool, Name: call.Name, ToolCallID: call.ID, Content: why, IsError: true}
emit(cfg, Event{Type: "tool_execution_start", Tool: call.Name, Detail: call.Arguments})
emit(cfg, Event{Type: "tool_execution_end", Tool: call.Name, Text: why})
emit(cfg, Event{Type: "tool_execution_start", Tool: call.Name, CallID: call.ID, Detail: call.Arguments})
emitToolEnd(cfg, call, why, "error")
if cfg.AfterTool != nil {
cfg.AfterTool(call, msg)
}
return msg
}
emit(cfg, Event{Type: "tool_execution_start", Tool: call.Name, Detail: call.Arguments})
if gated, msg, blocked := gateTool(cfg, call); blocked {
emit(cfg, Event{Type: "tool_execution_end", Tool: call.Name, Text: msg.Content})
emit(cfg, Event{Type: "tool_execution_start", Tool: call.Name, CallID: call.ID, Detail: call.Arguments})
if gated, msg, blocked := gateTool(ctx, cfg, call); blocked {
emitToolEnd(cfg, call, msg.Content, "denied")
noteToolHook(cfg, call, msg)
if cfg.AfterTool != nil {
cfg.AfterTool(call, msg)
@@ -584,7 +847,7 @@ func runOne(ctx context.Context, cfg Config, call llm.ToolCall) llm.Message {
if cfg.BeforeTool != nil {
if err := cfg.BeforeTool(call); err != nil {
msg := llm.Message{Role: llm.RoleTool, Name: call.Name, ToolCallID: call.ID, Content: err.Error(), IsError: true}
emit(cfg, Event{Type: "tool_execution_end", Tool: call.Name, Text: msg.Content})
emitToolEnd(cfg, call, msg.Content, "error")
noteToolHook(cfg, call, msg)
if cfg.AfterTool != nil {
cfg.AfterTool(call, msg)
@@ -595,13 +858,17 @@ func runOne(ctx context.Context, cfg Config, call llm.ToolCall) llm.Message {
emit(cfg, Event{Type: "tool_execution_update", Tool: call.Name, Detail: call.Arguments})
if err := commitToolIntent(cfg, call); err != nil {
msg := llm.Message{Role: llm.RoleTool, Name: call.Name, ToolCallID: call.ID, Content: err.Error(), IsError: true}
emit(cfg, Event{Type: "tool_execution_end", Tool: call.Name, Text: msg.Content})
emitToolEnd(cfg, call, msg.Content, "error")
return msg
}
endSpan := startSpan(cfg, "execute_tool", map[string]string{"tool": call.Name})
endSpan := startSpan(cfg, "execute_tool "+call.Name, map[string]string{
"gen_ai.system": providerName(cfg),
"gen_ai.tool.name": call.Name,
}, false)
defer endSpan()
res := tools.Result{Content: "no tools configured", IsError: true}
known := cfg.Tools != nil && cfg.Tools.Has(call.Name)
ctx = WithToolCall(ctx, call)
switch {
case known:
res = cfg.Tools.Execute(ctx, call.Name, json.RawMessage(call.Arguments))
@@ -617,7 +884,15 @@ func runOne(ctx context.Context, cfg Config, call llm.ToolCall) llm.Message {
Content: res.Content,
IsError: res.IsError,
}
emit(cfg, Event{Type: "tool_execution_end", Tool: call.Name, Text: res.Content})
status := "ok"
switch {
case ctx.Err() != nil:
// 取消优先于工具自报的 ok/error:过程事件不得把中止报成 ok。
status = "canceled"
case res.IsError:
status = "error"
}
emitToolEnd(cfg, call, res.Content, status)
noteToolHook(cfg, call, msg)
if cfg.AfterTool != nil {
cfg.AfterTool(call, msg)
@@ -625,22 +900,49 @@ func runOne(ctx context.Context, cfg Config, call llm.ToolCall) llm.Message {
return msg
}
func settle(cfg Config, last llm.Message, messages []llm.Message, usage llm.Usage, notice string) Result {
func settle(cfg Config, last llm.Message, messages []llm.Message, usage llm.Usage, toolCalls int, notice string) Result {
emit(cfg, Event{Type: "agent_end", Message: last})
emit(cfg, Event{Type: "agent_settled", Message: last})
emitUsage(cfg, usage)
return Result{Text: last.Content, Messages: messages, StopReason: last.StopReason, Usage: usage, Notice: notice}
return Result{Text: last.Content, Messages: messages, StopReason: last.StopReason, Usage: usage, Notice: notice, ToolCalls: toolCalls}
}
func prepareRequest(cfg Config, messages []llm.Message, turn int, repeats *repeatState, lengthNudge *bool) []llm.Message {
request := clearOldResults(cfg, persistedProjection(cfg, messages))
request = injectMemoryBlock(cfg, request)
return reminders.Attach(request, turnReminderTexts(cfg, turn, repeats, lengthNudge))
}
// injectMemoryBlock 把记忆召回块插到请求副本的系统段之后(request-scoped:
// 不落盘、不累计;Recall 失败静默跳过——记忆是增强面,不阻断对话)。
func injectMemoryBlock(cfg Config, request []llm.Message) []llm.Message {
if cfg.Memory == nil {
return request
}
entries, err := cfg.Memory.Recall(context.Background(), memory.RecallQuery{Scope: cfg.MemoryScope})
if err != nil {
return request
}
block := memory.Block(entries, time.Now())
if block == "" {
return request
}
// 系统段之后插入(保持历史与尾部不动)。
i := 0
for i < len(request) && request[i].Role == llm.RoleSystem {
i++
}
out := make([]llm.Message, 0, len(request)+1)
out = append(out, request[:i]...)
out = append(out, llm.Message{Role: llm.RoleSystem, Content: block})
return append(out, request[i:]...)
}
// persistedProjection 在有会话存储时只采用已经落盘的消息。
// steer 与 follow-up 必须先写入存储,下一轮请求才会看见它们。
func persistedProjection(cfg Config, messages []llm.Message) []llm.Message {
if cfg.Session == nil {
// JournalOnly:journal 不作权威上下文,每轮请求都用内存态(History 组装)。
if cfg.Session == nil || cfg.JournalOnly {
return messages
}
loaded, err := cfg.Session.Load()
@@ -652,14 +954,21 @@ func persistedProjection(cfg Config, messages []llm.Message) []llm.Message {
const unknownSideEffect = "副作用未知,未重试"
func recoverPending(ctx context.Context, cfg Config) error {
// crashInterrupted 是尾部兜底配对的正文(进程死在 persist(assistant) 与
// commitToolIntent 之间的窗口时使用)。
const crashInterrupted = "进程中断,该调用未执行"
// recoverPending 把未配对的意图补齐(safe 双声明重放,否则「副作用未知」
// 配对),返回本次写入的配对消息(JournalOnly 时供恢复摘要使用)。
func recoverPending(ctx context.Context, cfg Config) ([]llm.Message, error) {
if cfg.Session == nil {
return nil
return nil, nil
}
pending, err := cfg.Session.PendingIntents()
if err != nil {
return err
return nil, err
}
var out []llm.Message
for _, in := range pending {
current := tools.ReplayNever
if cfg.Tools != nil {
@@ -675,10 +984,84 @@ func recoverPending(ctx context.Context, cfg Config) error {
msg.IsError = res.IsError
}
if err := persist(cfg, msg); err != nil {
return err
return nil, err
}
out = append(out, msg)
}
return out, nil
}
// repairOrphanTail 兜底配对:找到转录里最后一个带 tool_calls 的 assistant,
// 凡没有对应 tool 结果的调用,补一条「进程中断,该调用未执行」的 IsError
// 结果并落盘。与意图日志正交(按 CallID 判重,已配对的跳过;意图路径
// 已处理的执行中死亡不受影响)。历史深处、其后已有新消息的孤立调用不在
// 此处修补(追加只落在尾部,位置语义不破)——那种形态由毒化轮转覆盖。
// repairOrphanTail 返回本次补写的配对消息(JournalOnly 时供恢复摘要使用)。
func repairOrphanTail(cfg Config) ([]llm.Message, error) {
if cfg.Session == nil {
return nil, nil
}
msgs, err := cfg.Session.Load()
if err != nil {
return nil, err
}
tail := -1
for i := len(msgs) - 1; i >= 0; i-- {
m := msgs[i]
if m.Role == llm.RoleAssistant && len(m.ToolCalls) > 0 {
tail = i
break
}
if m.Role == llm.RoleTool {
continue // 尾部连续 tool 结果属于同一条 assistant,继续上溯
}
break
}
if tail < 0 {
return nil, nil
}
results := map[string]bool{}
for _, m := range msgs {
if m.Role == llm.RoleTool && m.ToolCallID != "" {
results[m.ToolCallID] = true
}
}
return nil
var out []llm.Message
for _, call := range msgs[tail].ToolCalls {
if results[call.ID] {
continue
}
pair := llm.Message{
Role: llm.RoleTool, Name: call.Name, ToolCallID: call.ID,
Content: crashInterrupted, IsError: true,
}
if err := persist(cfg, pair); err != nil {
return nil, err
}
results[call.ID] = true
out = append(out, pair)
}
return out, nil
}
// recoveredNoteText 把本次恢复写入的配对摘成一段系统说明(注入上下文用;
// 内容截断防爆,工具名与调用 ID 全列)。
func recoveredNoteText(recovered []llm.Message) string {
var b strings.Builder
b.WriteString("上次会话中断的调用已完成恢复(这是系统说明,不是用户发言):")
for _, m := range recovered {
content := m.Content
if len([]rune(content)) > 120 {
content = string([]rune(content)[:120]) + "…"
}
b.WriteString("\n- ")
b.WriteString(m.Name)
b.WriteString("(")
b.WriteString(m.ToolCallID)
b.WriteString("):")
b.WriteString(content)
}
return b.String()
}
func commitToolIntent(cfg Config, call llm.ToolCall) error {
@@ -694,10 +1077,38 @@ func commitToolIntent(cfg Config, call llm.ToolCall) error {
})
}
func startSpan(cfg Config, name string, attrs map[string]string) func() {
func itoa(n int) string { return strconv.Itoa(n) }
// traceIDs 是一次 Run 的 span 关联:根 trace + invoke_agent 根 span。
type traceIDs struct {
traceID string
rootID string
}
// newTraceContext 建立一次 Run 的关联(Trace 为 nil 时也给空壳,调用方免判空)。
func newTraceContext() *traceIDs {
return &traceIDs{traceID: telemetry.NewTraceID(), rootID: telemetry.NewSpanID()}
}
// startSpan 开一个 OTel GenAI span:root=true 时作 invoke_agent 根(无 parent),
// 否则作子 span(parent = 根)。attrs 由调用方持有引用——存续期间可补
// (usage 在结束时随过滤导出)。
func startSpan(cfg Config, name string, attrs map[string]string, root bool) func() {
if cfg.Trace == nil {
return func() {}
}
if attrs == nil {
attrs = map[string]string{}
}
if cfg.traceCtx != nil {
attrs[telemetry.AttrTraceID] = cfg.traceCtx.traceID
if root {
attrs[telemetry.AttrSpanID] = cfg.traceCtx.rootID
} else {
attrs[telemetry.AttrSpanID] = telemetry.NewSpanID()
attrs[telemetry.AttrParentSpanID] = cfg.traceCtx.rootID
}
}
end := cfg.Trace(name, attrs)
if end == nil {
return func() {}
@@ -705,6 +1116,21 @@ func startSpan(cfg Config, name string, attrs map[string]string) func() {
return end
}
// agentName / providerName 是 OTel 属性的默认值解析。
func agentName(cfg Config) string {
if strings.TrimSpace(cfg.AgentName) != "" {
return cfg.AgentName
}
return "agent-native"
}
func providerName(cfg Config) string {
if strings.TrimSpace(cfg.ProviderName) != "" {
return cfg.ProviderName
}
return "openai"
}
func clearOldResults(cfg Config, messages []llm.Message) []llm.Message {
window := cfg.ContextWindow
if window <= 0 {
@@ -721,7 +1147,7 @@ func clearOldResults(cfg Config, messages []llm.Message) []llm.Message {
if !session.NeedsClear(session.EstimateChars(messages), window, reserve, buffer) {
return messages
}
return session.ClearOldToolResults(messages, cfg.KeepToolResults)
return session.ClearOldToolResultsTo(messages, cfg.KeepToolResults, cfg.ResultsDir)
}
func turnReminderTexts(cfg Config, turn int, repeats *repeatState, lengthNudge *bool) []string {
@@ -797,7 +1223,12 @@ func drain(fn func() []llm.Message) []llm.Message {
}
func emitUsage(cfg Config, usage llm.Usage) {
emit(cfg, Event{Type: "usage", PromptTokens: usage.PromptTokens, CompletionTokens: usage.CompletionTokens, TotalTokens: usage.TotalTokens})
emit(cfg, Event{
Type: "usage",
PromptTokens: usage.PromptTokens, CompletionTokens: usage.CompletionTokens, TotalTokens: usage.TotalTokens,
LastPromptTokens: usage.LastPromptTokens, LastCompletionTokens: usage.LastCompletionTokens, LastTotalTokens: usage.LastTotalTokens,
CacheReadTokens: usage.CacheReadTokens, CacheWriteTokens: usage.CacheWriteTokens,
})
}
func emit(cfg Config, ev Event) {
@@ -828,11 +1259,12 @@ func budgetExceeded(b *Budget, usage llm.Usage, toolCalls int, now time.Time) st
}
// budgetStop 以预算超限收尾:发事件、留日志、按 StopError 返回(不可重试)。
func budgetStop(cfg Config, last llm.Message, messages []llm.Message, usage llm.Usage, reason string) Result {
func budgetStop(cfg Config, last llm.Message, messages []llm.Message, usage llm.Usage, toolCalls int, reason string) Result {
emit(cfg, Event{Type: "budget_exceeded", Text: reason})
emitUsage(cfg, usage)
err := errString(reason)
cfg.logger().Error("runtime budget exceeded", err)
return Result{Text: last.Content, Messages: messages, StopReason: llm.StopError, Usage: usage, Err: err}
return Result{Text: last.Content, Messages: messages, StopReason: llm.StopError, Usage: usage, Err: err, ToolCalls: toolCalls}
}
type errString string
+70
View File
@@ -0,0 +1,70 @@
package runtime
import (
"context"
"encoding/json"
"strings"
"testing"
"agent-native/runtime/llm"
"agent-native/runtime/tools"
)
// ---- N2 WP1:前缀稳定契约(缓存友好的机器钉住:同一注册序列 → 逐字节确定)----
type contractTool struct {
name, desc string
deferred bool
}
func (t contractTool) Name() string { return t.name }
func (t contractTool) Description() string { return t.desc }
func (t contractTool) Parameters() json.RawMessage { return json.RawMessage(`{"type":"object","properties":{}}`) }
func (t contractTool) Execute(_ context.Context, _ json.RawMessage) (tools.Result, error) {
return tools.Result{Content: "ok"}, nil
}
func (t contractTool) Exposure() string {
if t.deferred {
return tools.ExposureDeferred
}
return tools.ExposureDirect
}
func buildContractRegistry() *tools.Registry {
reg := tools.NewRegistry("")
reg.Register(contractTool{name: "read_file", desc: "读文件"})
reg.Register(contractTool{name: "write_file", desc: "写文件"})
reg.Register(contractTool{name: "grep", desc: "搜索", deferred: true})
return reg
}
func TestSystemTextDeterministicAcrossIdenticalBuilds(t *testing.T) {
a := systemText(Config{SystemPrompt: "sys", Tools: buildContractRegistry()})
b := systemText(Config{SystemPrompt: "sys", Tools: buildContractRegistry()})
if a != b {
t.Fatalf("同一注册序列的系统文本必须逐字节一致:\n%q\nvs\n%q", a, b)
}
// 契约内容:直接工具全在清单内且按注册序;deferred 工具不进入清单。
idx := strings.Index(a, "read_file")
if idx < 0 || strings.Index(a, "write_file") < idx {
t.Fatalf("直接工具须按注册序出现:%q", a)
}
if strings.Contains(a, "grep") {
t.Fatalf("deferred 工具不得出现在系统文本:%q", a)
}
// 元工具注入位置确定(tool_search/call_tool 存在且在 Decls 中)。
decls := buildContractRegistry().Decls()
names := []string{}
for _, d := range decls {
names = append(names, d.Name)
}
if len(names) != 4 || names[0] != "read_file" || names[1] != "write_file" {
t.Fatalf("Decls 顺序契约:%v", names)
}
if names[2] != "tool_search" && names[3] != "tool_search" {
if names[2] != "call_tool" && names[3] != "call_tool" {
t.Fatalf("元工具缺失:%v", names)
}
}
var _ = llm.ToolDecl{} // 保持导入面稳定
}
+69
View File
@@ -0,0 +1,69 @@
package runtime
import (
"context"
"encoding/json"
"sync"
"testing"
"agent-native/runtime/llm"
"agent-native/runtime/tools"
)
// ctxEchoTool 把自己从 ctx 读到的调用 ID 记下来(并发安全),验证内核
// 在 Execute 前注入了当前调用。
type ctxEchoTool struct {
mu sync.Mutex
ids []string
bad int
}
func (c *ctxEchoTool) Name() string { return "echo" }
func (c *ctxEchoTool) Description() string { return "echo" }
func (c *ctxEchoTool) Parameters() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
func (c *ctxEchoTool) Execute(ctx context.Context, _ json.RawMessage) (tools.Result, error) {
call, ok := ToolCallFromContext(ctx)
c.mu.Lock()
defer c.mu.Unlock()
if !ok || call.ID == "" || call.Name != "echo" {
c.bad++
return tools.Result{Content: "call missing from context", IsError: true}, nil
}
c.ids = append(c.ids, call.ID)
return tools.Result{Content: "ok " + call.ID}, nil
}
// TestLoopToolSeesOwnCallInContext 并行执行两个调用,各自读到自己的 ID。
func TestLoopToolSeesOwnCallInContext(t *testing.T) {
tool := &ctxEchoTool{}
reg := tools.NewRegistry(t.TempDir())
reg.Register(tool)
res, err := Run(context.Background(), Config{
Model: "fake",
Tools: reg,
ToolExecution: ExecParallel,
Stream: script(
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{
{ID: "pa", Name: "echo", Arguments: `{}`},
{ID: "pb", Name: "echo", Arguments: `{}`},
}},
llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd},
),
}, llm.Message{Role: llm.RoleUser, Content: "go"})
if err != nil {
t.Fatal(err)
}
tool.mu.Lock()
defer tool.mu.Unlock()
if tool.bad != 0 || len(tool.ids) != 2 {
t.Fatalf("bad=%d ids=%v", tool.bad, tool.ids)
}
seen := map[string]bool{}
for _, id := range tool.ids {
seen[id] = true
}
if !seen["pa"] || !seen["pb"] {
t.Fatalf("ids = %v", tool.ids)
}
_ = res
}
+52
View File
@@ -0,0 +1,52 @@
package runtime
import (
"context"
"testing"
"agent-native/runtime/llm"
"agent-native/runtime/tools"
)
// TestLoopToolEventsCarryCallID 验证 tool_execution_start/end 事件带工具调用
// ID:并行执行下消费方按 ID 配对(回归锚点,防止字段被删后顺序假设回归)。
func TestLoopToolEventsCarryCallID(t *testing.T) {
hits := 0
reg := tools.NewRegistry(t.TempDir())
reg.Register(hitTool{hits: &hits})
var starts, ends []Event
res, err := Run(context.Background(), Config{
Model: "fake",
Tools: reg,
Stream: script(
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
ID: "call-7", Name: "echo", Arguments: `{}`,
}}},
llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd},
),
OnEvent: func(ev Event) {
switch ev.Type {
case "tool_execution_start":
starts = append(starts, ev)
case "tool_execution_end":
ends = append(ends, ev)
}
},
}, llm.Message{Role: llm.RoleUser, Content: "go"})
if err != nil {
t.Fatal(err)
}
if hits != 1 || len(starts) != 1 || len(ends) != 1 {
t.Fatalf("hits=%d starts=%d ends=%d", hits, len(starts), len(ends))
}
if starts[0].CallID != "call-7" || starts[0].Tool != "echo" {
t.Fatalf("start = %+v", starts[0])
}
if ends[0].CallID != "call-7" || ends[0].Tool != "echo" {
t.Fatalf("end = %+v", ends[0])
}
if ends[0].Status != "ok" || ends[0].ErrorReason != "" {
t.Fatalf("successful end must be ok with empty ErrorReason: %+v", ends[0])
}
_ = res
}
+286
View File
@@ -0,0 +1,286 @@
package runtime
import (
"context"
"encoding/json"
"strings"
"sync"
"testing"
"time"
"agent-native/runtime/hooks"
"agent-native/runtime/llm"
"agent-native/runtime/tools"
)
type failTool struct{ reason string }
func (f failTool) Name() string { return "echo" }
func (f failTool) Description() string { return "echo that fails" }
func (f failTool) Parameters() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
func (f failTool) Execute(context.Context, json.RawMessage) (tools.Result, error) {
return tools.Result{Content: f.reason, IsError: true}, nil
}
// TestLoopSameNameToolsCarryDistinctCallIDs 同名工具连续/并行调用时,
// start/end 的 CallID 必须互异且可按 ID 配对(不得按 Tool 名合并)。
func TestLoopSameNameToolsCarryDistinctCallIDs(t *testing.T) {
for _, mode := range []string{ExecSequential, ExecParallel} {
t.Run(mode, func(t *testing.T) {
hits := 0
reg := tools.NewRegistry(t.TempDir())
reg.Register(hitTool{hits: &hits})
var starts, ends []Event
var mu sync.Mutex
_, err := Run(context.Background(), Config{
Model: "fake",
Tools: reg,
ToolExecution: mode,
Stream: script(
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{
{ID: "call-a", Name: "echo", Arguments: `{}`},
{ID: "call-b", Name: "echo", Arguments: `{}`},
}},
llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd},
),
OnEvent: func(ev Event) {
mu.Lock()
defer mu.Unlock()
switch ev.Type {
case "tool_execution_start":
starts = append(starts, ev)
case "tool_execution_end":
ends = append(ends, ev)
}
},
}, llm.Message{Role: llm.RoleUser, Content: "go"})
if err != nil {
t.Fatal(err)
}
if hits != 2 || len(starts) != 2 || len(ends) != 2 {
t.Fatalf("hits=%d starts=%d ends=%d", hits, len(starts), len(ends))
}
byStart := map[string]Event{}
for _, ev := range starts {
if ev.CallID == "" || ev.Tool != "echo" {
t.Fatalf("start missing identity: %+v", ev)
}
if _, dup := byStart[ev.CallID]; dup {
t.Fatalf("duplicate start CallID %q", ev.CallID)
}
byStart[ev.CallID] = ev
}
for _, ev := range ends {
if ev.Status != "ok" {
t.Fatalf("end status = %q, want ok: %+v", ev.Status, ev)
}
if _, ok := byStart[ev.CallID]; !ok {
t.Fatalf("end CallID %q has no matching start", ev.CallID)
}
delete(byStart, ev.CallID)
}
if len(byStart) != 0 {
t.Fatalf("unpaired starts: %+v", byStart)
}
if _, ok := map[string]Event{ends[0].CallID: {}, ends[1].CallID: {}}["call-a"]; !ok {
t.Fatalf("missing call-a in ends %+v", ends)
}
if ends[0].CallID == ends[1].CallID {
t.Fatalf("CallIDs must differ, both %q", ends[0].CallID)
}
})
}
}
func TestLoopFailingToolEndStatusError(t *testing.T) {
reg := tools.NewRegistry(t.TempDir())
reg.Register(failTool{reason: "boom-detail"})
var end Event
_, err := Run(context.Background(), Config{
Model: "fake",
Tools: reg,
Stream: script(
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
ID: "c-fail", Name: "echo", Arguments: `{}`,
}}},
llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd},
),
OnEvent: func(ev Event) {
if ev.Type == "tool_execution_end" {
end = ev
}
},
}, llm.Message{Role: llm.RoleUser, Content: "go"})
if err != nil {
t.Fatal(err)
}
if end.CallID != "c-fail" || end.Status != "error" || end.ErrorReason != "boom-detail" {
t.Fatalf("end = %+v", end)
}
}
func TestLoopDeniedToolEndStatusDenied(t *testing.T) {
hits := 0
reg := tools.NewRegistry(t.TempDir())
reg.Register(hitTool{hits: &hits})
bus := hooks.New()
bus.On(func(c hooks.Call) hooks.Outcome {
if c.Event == hooks.PreToolUse {
return hooks.Outcome{Decision: hooks.Deny, Reason: "策略拒绝"}
}
return hooks.Outcome{}
})
var end Event
_, err := Run(context.Background(), Config{
Model: "fake",
Tools: reg,
Hooks: bus,
Stream: script(
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
ID: "c-deny", Name: "echo", Arguments: `{}`,
}}},
llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd},
),
OnEvent: func(ev Event) {
if ev.Type == "tool_execution_end" {
end = ev
}
},
}, llm.Message{Role: llm.RoleUser, Content: "go"})
if err != nil {
t.Fatal(err)
}
if hits != 0 {
t.Fatalf("tool ran %d times", hits)
}
if end.CallID != "c-deny" || end.Status != "denied" || end.ErrorReason != "策略拒绝" {
t.Fatalf("end = %+v", end)
}
if end.Status == "ok" {
t.Fatal("denied must not be reported ok")
}
}
func TestLoopCanceledToolEndStatusCanceled(t *testing.T) {
bt := &blockingTool{canceled: make(chan struct{})}
reg := tools.NewRegistry(t.TempDir())
reg.Register(bt)
ctx, cancel := context.WithCancel(context.Background())
firstCall := true
stream := func(ctx context.Context, model string, messages []llm.Message, decls []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
ch := make(chan llm.Event, 4)
go func() {
defer close(ch)
if firstCall {
firstCall = false
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{
Role: llm.RoleAssistant, StopReason: llm.StopToolCalls,
ToolCalls: []llm.ToolCall{{ID: "c-cancel", Name: "block_forever", Arguments: `{}`}},
}}
return
}
select {
case <-ctx.Done():
ch <- llm.Event{Kind: llm.KindError, Err: ctx.Err(), Message: llm.Message{StopReason: llm.StopAborted}}
case <-time.After(3 * time.Second):
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd}}
}
}()
return ch
}
var ends []Event
var mu sync.Mutex
go func() {
time.Sleep(100 * time.Millisecond)
cancel()
}()
_, err := Run(ctx, Config{
Model: "fake",
Tools: reg,
Stream: stream,
OnEvent: func(ev Event) {
if ev.Type == "tool_execution_end" {
mu.Lock()
ends = append(ends, ev)
mu.Unlock()
}
},
}, llm.Message{Role: llm.RoleUser, Content: "block"})
if err != nil {
t.Fatal(err)
}
mu.Lock()
defer mu.Unlock()
if len(ends) == 0 {
t.Fatal("expected tool_execution_end")
}
end := ends[0]
if end.CallID != "c-cancel" {
t.Fatalf("CallID = %q", end.CallID)
}
if end.Status != "canceled" {
t.Fatalf("Status = %q, want canceled (not ok); end=%+v", end.Status, end)
}
if end.ErrorReason == "" {
t.Fatalf("ErrorReason empty on canceled: %+v", end)
}
}
type cancelBatchTool struct {
cancel context.CancelFunc
}
func (c cancelBatchTool) Name() string { return "cancel_batch" }
func (c cancelBatchTool) Description() string { return "cancels ctx then returns" }
func (c cancelBatchTool) Parameters() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
func (c cancelBatchTool) Execute(ctx context.Context, args json.RawMessage) (tools.Result, error) {
c.cancel()
return tools.Result{Content: "cancelled-siblings"}, nil
}
// TestLoopSkippedCancelEmitsCanceledNotOK:串行批内前一个调用取消 ctx 后,
// 后续未执行的调用必须发 Status=canceled,不得报 ok。
func TestLoopSkippedCancelEmitsCanceledNotOK(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
reg := tools.NewRegistry(t.TempDir())
reg.Register(cancelBatchTool{cancel: cancel})
hits := 0
reg.Register(hitTool{hits: &hits})
var ends []Event
_, err := Run(ctx, Config{
Model: "fake",
Tools: reg,
ToolExecution: ExecSequential,
Stream: script(
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{
{ID: "c-first", Name: "cancel_batch", Arguments: `{}`},
{ID: "c-skip", Name: "echo", Arguments: `{}`},
}},
llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd},
),
OnEvent: func(ev Event) {
if ev.Type == "tool_execution_end" {
ends = append(ends, ev)
}
},
}, llm.Message{Role: llm.RoleUser, Content: "go"})
_ = err
if hits != 0 {
t.Fatalf("echo should not run after cancel, hits=%d", hits)
}
var skip *Event
for i := range ends {
if ends[i].CallID == "c-skip" {
skip = &ends[i]
}
}
if skip == nil {
t.Fatalf("missing end for skipped call: %+v", ends)
}
if skip.Status != "canceled" {
t.Fatalf("skipped end Status=%q want canceled: %+v", skip.Status, skip)
}
if skip.ErrorReason == "" || strings.Contains(skip.Status, "ok") {
t.Fatalf("skipped end malformed: %+v", skip)
}
}
+168
View File
@@ -0,0 +1,168 @@
package runtime
import (
"context"
"strings"
"testing"
"agent-native/runtime/guardrails"
"agent-native/runtime/hooks"
"agent-native/runtime/llm"
"agent-native/runtime/tools"
)
// TestInputGuardrailDenySkipsLoop:输入护栏 deny 时不进循环、不调模型,
// 以理由直接收尾;原文已进转录(留痕)。
func TestInputGuardrailDenySkipsLoop(t *testing.T) {
calls := 0
bus := hooks.New()
guardrails.Register(bus, guardrails.Set{
Input: func(ctx context.Context, text string) guardrails.Verdict {
return guardrails.Verdict{Decision: hooks.Deny, Reason: "含敏感指令"}
},
}, guardrails.Scope{Entry: true})
res, err := Run(context.Background(), Config{
Model: "fake",
Hooks: bus,
Stream: func(ctx context.Context, model string, messages []llm.Message, decls []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
calls++
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: "bad input"})
if err != nil {
t.Fatal(err)
}
if calls != 0 {
t.Fatalf("model called %d times after input deny", calls)
}
if res.Text != "含敏感指令" {
t.Fatalf("result text = %q", res.Text)
}
if !strings.Contains(transcriptText(res.Messages), "bad input") {
t.Fatal("original input missing from transcript")
}
}
// TestInputGuardrailModifyAppendsRewrite:modify 时改写内容追加为新的用户消息,
// 模型最终看到的是改写后的输入;原文保留在转录里。
func TestInputGuardrailModifyAppendsRewrite(t *testing.T) {
var saw string
bus := hooks.New()
guardrails.Register(bus, guardrails.Set{
Input: func(ctx context.Context, text string) guardrails.Verdict {
return guardrails.Verdict{Decision: hooks.Modify, Replacement: "改写后的输入"}
},
}, guardrails.Scope{Entry: true})
_, err := Run(context.Background(), Config{
Model: "fake",
Hooks: bus,
Stream: func(ctx context.Context, model string, messages []llm.Message, decls []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
saw = transcriptText(messages)
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: "原始输入"})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(saw, "原始输入") || !strings.Contains(saw, "改写后的输入") {
t.Fatalf("request messages = %q", saw)
}
}
// TestOutputGuardrailDenyContinuesThenCaps:输出护栏 deny 视同续跑,
// 理由进转录;达内核续跑上限后收尾。
func TestOutputGuardrailDenyContinuesThenCaps(t *testing.T) {
calls := 0
bus := hooks.New()
guardrails.Register(bus, guardrails.Set{
Output: func(ctx context.Context, text string) guardrails.Verdict {
return guardrails.Verdict{Decision: hooks.Deny, Reason: "缺少来源标注"}
},
}, guardrails.Scope{Final: true})
res, err := Run(context.Background(), Config{
Model: "fake",
MaxTurns: 8,
Hooks: bus,
Stream: script(
llm.Message{Role: llm.RoleAssistant, Content: "v1", StopReason: llm.StopEnd},
llm.Message{Role: llm.RoleAssistant, Content: "v2", StopReason: llm.StopEnd},
llm.Message{Role: llm.RoleAssistant, Content: "v3", StopReason: llm.StopEnd},
llm.Message{Role: llm.RoleAssistant, Content: "v4", StopReason: llm.StopEnd},
),
}, llm.Message{Role: llm.RoleUser, Content: "go"})
if err != nil {
t.Fatal(err)
}
_ = calls
if strings.Count(transcriptText(res.Messages), "缺少来源标注") != 3 {
t.Fatalf("reason count = %s", transcriptText(res.Messages))
}
if res.Text != "v4" {
t.Fatalf("final text = %q", res.Text)
}
}
// TestToolGuardrailDenyPairsResult:工具护栏 deny 写配对错误结果且不执行工具。
func TestToolGuardrailDenyPairsResult(t *testing.T) {
hits := 0
reg := tools.NewRegistry(t.TempDir())
reg.Register(hitTool{hits: &hits})
bus := hooks.New()
guardrails.Register(bus, guardrails.Set{
Tool: func(ctx context.Context, name, args string) guardrails.Verdict {
return guardrails.Verdict{Decision: hooks.Deny, Reason: "护栏禁用"}
},
}, guardrails.Scope{})
res, err := Run(context.Background(), Config{
Model: "fake",
Tools: reg,
Hooks: bus,
Stream: script(
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
ID: "c1", Name: "echo", Arguments: `{}`,
}}},
llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd},
),
}, llm.Message{Role: llm.RoleUser, Content: "go"})
if err != nil {
t.Fatal(err)
}
if hits != 0 {
t.Fatalf("tool executed %d times", hits)
}
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 result: %s", transcriptText(res.Messages))
}
}
// TestSubRunScopeSkipsInputOutput:子代理 run(Entry/Final=false)注册同名护栏也不触发。
func TestSubRunScopeSkipsInputOutput(t *testing.T) {
bus := hooks.New()
guardrails.Register(bus, guardrails.Set{
Input: func(ctx context.Context, text string) guardrails.Verdict { return guardrails.Verdict{Decision: hooks.Deny, Reason: "x"} },
Output: func(ctx context.Context, text string) guardrails.Verdict { return guardrails.Verdict{Decision: hooks.Deny, Reason: "y"} },
}, guardrails.Scope{})
res, err := Run(context.Background(), Config{
Model: "fake",
Hooks: bus,
Stream: script(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.Text != "ok" {
t.Fatalf("sub-scope run text = %q", res.Text)
}
}
+9 -10
View File
@@ -42,20 +42,19 @@ func TestLoopStopContinuesUntilCap(t *testing.T) {
if res.Err != nil || res.Notice != "到顶了" {
t.Fatalf("notice %q err %v", res.Notice, res.Err)
}
replaced, forged := 0, 0
// N6(N3 WP1):assistant 不再被改写——替换文只以 user 消息出现,
// 内存与转录一致。
forged, notes := 0, 0
for _, m := range res.Messages {
if m.Role != llm.RoleAssistant {
continue
}
if m.Content == "已作废" {
replaced++
}
if m.Content == "forged-claim" {
if m.Role == llm.RoleAssistant && m.Content == "forged-claim" {
forged++
}
if m.Role == llm.RoleUser && m.Content == "已作废" {
notes++
}
}
if replaced != 3 || forged != 1 {
t.Fatalf("replaced=%d forged=%d transcript=%s", replaced, forged, transcriptText(res.Messages))
if forged != 4 || notes != 3 {
t.Fatalf("forged=%d notes=%d transcript=%s", forged, notes, transcriptText(res.Messages))
}
if strings.Count(transcriptText(res.Messages), "再试") != 3 {
t.Fatalf("reasons = %s", transcriptText(res.Messages))
+253
View File
@@ -0,0 +1,253 @@
package runtime
import (
"context"
"strings"
"testing"
"agent-native/runtime/hooks"
"agent-native/runtime/llm"
"agent-native/runtime/tools"
)
type eventRecorder struct {
seen []hooks.Event
}
func (r *eventRecorder) handler(c hooks.Call) hooks.Outcome {
r.seen = append(r.seen, c.Event)
return hooks.Outcome{}
}
func (r *eventRecorder) has(ev hooks.Event) bool {
for _, e := range r.seen {
if e == ev {
return true
}
}
return false
}
// TestHookLifecycleOrder 断言一次带工具调用的 run 的事件顺序为
// SessionStart → UserPromptSubmit → PreToolUse → PostToolUse →
// PostToolBatch → Stop → SessionEnd。
func TestHookLifecycleOrder(t *testing.T) {
rec := &eventRecorder{}
bus := hooks.New()
bus.On(rec.handler)
hits := 0
reg := tools.NewRegistry(t.TempDir())
reg.Register(hitTool{hits: &hits})
_, err := Run(context.Background(), Config{
Model: "fake",
Tools: reg,
Hooks: bus,
Stream: script(
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
ID: "c1", Name: "echo", Arguments: `{}`,
}}},
llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd},
),
}, llm.Message{Role: llm.RoleUser, Content: "go"})
if err != nil {
t.Fatal(err)
}
want := []hooks.Event{hooks.SessionStart, hooks.UserPromptSubmit, hooks.PreToolUse,
hooks.PostToolUse, hooks.PostToolBatch, hooks.Stop, hooks.SessionEnd}
if len(rec.seen) != len(want) {
t.Fatalf("events = %v, want %v", rec.seen, want)
}
for i := range want {
if rec.seen[i] != want[i] {
t.Fatalf("events = %v, want %v", rec.seen, want)
}
}
}
// TestHookUserPromptSubmitCarriesText 断言 UserPromptSubmit 事件带用户输入正文。
func TestHookUserPromptSubmitCarriesText(t *testing.T) {
var got string
bus := hooks.New()
bus.On(func(c hooks.Call) hooks.Outcome {
if c.Event == hooks.UserPromptSubmit {
got = c.LastText
}
return hooks.Outcome{}
})
_, err := Run(context.Background(), Config{
Model: "fake",
Hooks: bus,
Stream: script(llm.Message{Role: llm.RoleAssistant, Content: "ok", StopReason: llm.StopEnd}),
}, llm.Message{Role: llm.RoleUser, Content: "ping"})
if err != nil {
t.Fatal(err)
}
if got != "ping" {
t.Fatalf("UserPromptSubmit text = %q", got)
}
}
// TestAskPortAllowProceeds:ask 决策经宿主裁决放行后工具照常执行。
func TestAskPortAllowProceeds(t *testing.T) {
hits := 0
reg := tools.NewRegistry(t.TempDir())
reg.Register(hitTool{hits: &hits})
bus := hooks.New()
bus.On(func(c hooks.Call) hooks.Outcome {
if c.Event == hooks.PreToolUse {
return hooks.Outcome{Decision: hooks.Ask, Reason: "需要确认"}
}
return hooks.Outcome{}
})
sawRequest := false
bus.On(func(c hooks.Call) hooks.Outcome {
if c.Event == hooks.PermissionRequest {
sawRequest = true
}
return hooks.Outcome{}
})
res, err := Run(context.Background(), Config{
Model: "fake",
Tools: reg,
Hooks: bus,
PermissionAsk: func(ctx context.Context, call llm.ToolCall) AskVerdict {
return AskVerdict{Allow: true}
},
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},
),
}, llm.Message{Role: llm.RoleUser, Content: "go"})
if err != nil {
t.Fatal(err)
}
if hits != 1 {
t.Fatalf("tool executed %d times, want 1", hits)
}
if !sawRequest {
t.Fatal("PermissionRequest event missing")
}
if !strings.Contains(transcriptText(res.Messages), "ran") {
t.Fatalf("tool result missing: %s", transcriptText(res.Messages))
}
}
// TestAskPortDenyPairsResult:宿主拒绝时写配对错误结果,理由取宿主理由。
func TestAskPortDenyPairsResult(t *testing.T) {
hits := 0
reg := tools.NewRegistry(t.TempDir())
reg.Register(hitTool{hits: &hits})
bus := hooks.New()
bus.On(func(c hooks.Call) hooks.Outcome {
if c.Event == hooks.PreToolUse {
return hooks.Outcome{Decision: hooks.Ask, Reason: "需要确认"}
}
return hooks.Outcome{}
})
res, err := Run(context.Background(), Config{
Model: "fake",
Tools: reg,
Hooks: bus,
PermissionAsk: func(ctx context.Context, call llm.ToolCall) AskVerdict {
return AskVerdict{Allow: false, Reason: "用户拒绝"}
},
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},
),
}, llm.Message{Role: llm.RoleUser, Content: "go"})
if err != nil {
t.Fatal(err)
}
if hits != 0 {
t.Fatalf("tool executed %d times, want 0", hits)
}
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 deny result: %s", transcriptText(res.Messages))
}
}
// TestAskWithoutPortKeepsLegacyReject:没有裁决端口时 ask 仍按拒绝处理(旧行为)。
func TestAskWithoutPortKeepsLegacyReject(t *testing.T) {
hits := 0
reg := tools.NewRegistry(t.TempDir())
reg.Register(hitTool{hits: &hits})
bus := hooks.New()
bus.On(func(c hooks.Call) hooks.Outcome {
if c.Event == hooks.PreToolUse {
return hooks.Outcome{Decision: hooks.Ask, Reason: "需要确认"}
}
return hooks.Outcome{}
})
res, err := Run(context.Background(), Config{
Model: "fake",
Tools: reg,
Hooks: bus,
Stream: script(
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
ID: "c1", Name: "echo", Arguments: `{}`,
}}},
llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd},
),
}, llm.Message{Role: llm.RoleUser, Content: "go"})
if err != nil {
t.Fatal(err)
}
if hits != 0 {
t.Fatalf("tool executed %d times, want 0", hits)
}
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 ask-reject result: %s", transcriptText(res.Messages))
}
}
// TestStopContinuationFlagVisible:续跑后 Stop 事件的 Continuations 计数对下游可见。
func TestStopContinuationFlagVisible(t *testing.T) {
var counts []int
bus := hooks.New()
bus.On(func(c hooks.Call) hooks.Outcome {
if c.Event == hooks.Stop {
counts = append(counts, c.Continuations)
return hooks.Outcome{Decision: hooks.Block, Reason: "再试"}
}
return hooks.Outcome{}
})
_, err := Run(context.Background(), Config{
Model: "fake",
MaxTurns: 8,
Hooks: bus,
Stream: script(
llm.Message{Role: llm.RoleAssistant, Content: "a", StopReason: llm.StopEnd},
llm.Message{Role: llm.RoleAssistant, Content: "a", StopReason: llm.StopEnd},
llm.Message{Role: llm.RoleAssistant, Content: "a", StopReason: llm.StopEnd},
llm.Message{Role: llm.RoleAssistant, Content: "a", StopReason: llm.StopEnd},
),
}, llm.Message{Role: llm.RoleUser, Content: "go"})
if err != nil {
t.Fatal(err)
}
if len(counts) != 4 {
t.Fatalf("stop events = %v", counts)
}
for i, c := range counts {
if c != i {
t.Fatalf("stop continuations = %v, want 0,1,2,3", counts)
}
}
}
+112
View File
@@ -0,0 +1,112 @@
package runtime
import (
"context"
"strings"
"sync"
"testing"
"agent-native/runtime/llm"
"agent-native/runtime/tools"
)
// ---- N3 WP4:循环 span 形态(名称后缀、关联、usage)----
type capturedSpan struct {
name string
traceID string
spanID string
parent string
attrs map[string]string
}
type spanRecorder struct {
mu sync.Mutex
spans []capturedSpan
}
func (r *spanRecorder) trace() func(string, map[string]string) func() {
return func(name string, attrs map[string]string) func() {
return func() {
// 结束时拷贝(与 telemetry 工厂的结束时过滤同步——usage 是存续期补的)。
sp := capturedSpan{name: name, attrs: map[string]string{}}
for k, v := range attrs {
sp.attrs[k] = v
}
r.mu.Lock()
defer r.mu.Unlock()
sp.traceID = sp.attrs["_trace_id"]
sp.spanID = sp.attrs["_span_id"]
sp.parent = sp.attrs["_parent_span_id"]
r.spans = append(r.spans, sp)
}
}
}
func TestLoopSpansFollowGenAIConvention(t *testing.T) {
rec := &spanRecorder{}
reg := tools.NewRegistry(t.TempDir())
reg.Register(&countingTool{name: "read_file"})
res, err := Run(context.Background(), Config{
Model: "claude-x", Tools: reg, MaxTurns: 3, Trace: rec.trace(),
ProviderName: "anthropic", AgentName: "writer",
Stream: script(
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls,
ToolCalls: []llm.ToolCall{{ID: "c1", Name: "read_file", Arguments: `{}`}}},
llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd},
),
}, llm.Message{Role: llm.RoleUser, Content: "go"})
if err != nil {
t.Fatal(err)
}
_ = res
rec.mu.Lock()
defer rec.mu.Unlock()
if len(rec.spans) < 4 {
t.Fatalf("span 数 = %d(invoke_agent + 2×chat + execute_tool):%+v", len(rec.spans), rec.spans)
}
var root capturedSpan
byName := map[string][]capturedSpan{}
for _, sp := range rec.spans {
switch {
case strings.HasPrefix(sp.name, "invoke_agent "):
byName["invoke"] = append(byName["invoke"], sp)
case strings.HasPrefix(sp.name, "chat "):
byName["chat"] = append(byName["chat"], sp)
case strings.HasPrefix(sp.name, "execute_tool "):
byName["tool"] = append(byName["tool"], sp)
default:
t.Fatalf("span 名不合约定:%q", sp.name)
}
}
if len(byName["invoke"]) != 1 || byName["invoke"][0].name != "invoke_agent writer" {
t.Fatalf("invoke_agent span:%+v", byName["invoke"])
}
root = byName["invoke"][0]
if root.attrs["gen_ai.system"] != "anthropic" || root.attrs["gen_ai.agent.name"] != "writer" ||
root.attrs["gen_ai.request.model"] != "claude-x" {
t.Fatalf("根属性:%+v", root.attrs)
}
if len(byName["chat"]) != 2 {
t.Fatalf("chat span 数:%+v", byName["chat"])
}
for _, sp := range byName["chat"] {
if sp.name != "chat claude-x" {
t.Fatalf("chat span 名:%q", sp.name)
}
if sp.traceID != root.traceID || sp.parent != root.spanID {
t.Fatalf("关联断裂:trace=%q parent=%q 根=%q/%q", sp.traceID, sp.parent, root.traceID, root.spanID)
}
if sp.attrs["gen_ai.request.model"] != "claude-x" ||
sp.attrs["gen_ai.usage.input_tokens"] == "" || sp.attrs["gen_ai.usage.output_tokens"] == "" {
t.Fatalf("chat 属性/usage:%+v", sp.attrs)
}
}
if len(byName["tool"]) != 1 || byName["tool"][0].name != "execute_tool read_file" {
t.Fatalf("tool span:%+v", byName["tool"])
}
tool := byName["tool"][0]
if tool.attrs["gen_ai.tool.name"] != "read_file" || tool.traceID != root.traceID || tool.parent != root.spanID {
t.Fatalf("tool 属性/关联:%+v", tool)
}
}
+72
View File
@@ -0,0 +1,72 @@
package runtime
import (
"context"
"encoding/json"
"sync"
"testing"
"agent-native/runtime/llm"
"agent-native/runtime/tools"
)
// orderTool 记录自己的执行序号(并发安全)。
type orderTool struct {
name string
mu sync.Mutex
seq []string
}
func (o *orderTool) Name() string { return o.name }
func (o *orderTool) Description() string { return o.name }
func (o *orderTool) Parameters() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
func (o *orderTool) Execute(ctx context.Context, _ json.RawMessage) (tools.Result, error) {
id := o.name
if call, ok := ToolCallFromContext(ctx); ok {
id = call.ID
}
o.mu.Lock()
o.seq = append(o.seq, id)
o.mu.Unlock()
return tools.Result{Content: "ran " + id}, nil
}
// TestLoopSerialToolsKeepOrder 并行模式下 SerialTools 命中的调用按声明顺序执行
// (互斥不保序不够:卡片流等有先后依赖),未命中的并发执行。
func TestLoopSerialToolsKeepOrder(t *testing.T) {
cards := &orderTool{name: "card"}
reads := &orderTool{name: "read"}
reg := tools.NewRegistry(t.TempDir())
reg.Register(cards)
reg.Register(reads)
res, err := Run(context.Background(), Config{
Model: "fake", Tools: reg,
ToolExecution: ExecParallel,
SerialTools: map[string]bool{"card": true},
Stream: script(
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{
{ID: "s1", Name: "card", Arguments: `{}`},
{ID: "p1", Name: "read", Arguments: `{}`},
{ID: "s2", Name: "card", Arguments: `{}`},
{ID: "s3", Name: "card", Arguments: `{}`},
}},
llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd},
),
}, llm.Message{Role: llm.RoleUser, Content: "go"})
if err != nil {
t.Fatal(err)
}
cards.mu.Lock()
got := append([]string(nil), cards.seq...)
cards.mu.Unlock()
if len(got) != 3 || got[0] != "s1" || got[1] != "s2" || got[2] != "s3" {
t.Fatalf("card executions must follow declaration order s1→s2→s3, got %v", got)
}
reads.mu.Lock()
nRead := len(reads.seq)
reads.mu.Unlock()
if nRead != 1 {
t.Fatalf("read executions = %d", nRead)
}
_ = res
}
+49
View File
@@ -2,6 +2,7 @@ package runtime
import (
"context"
"errors"
"path/filepath"
"strings"
"sync"
@@ -12,6 +13,54 @@ import (
"agent-native/runtime/tools"
)
func TestRunTerminalToolFailureKeepsUsage(t *testing.T) {
hits, calls := 0, 0
reg := tools.NewRegistry(t.TempDir())
reg.Register(hitTool{hits: &hits})
stream := func(context.Context, string, []llm.Message, []llm.ToolDecl, llm.StreamOptions) <-chan llm.Event {
calls++
out := make(chan llm.Event, 1)
out <- llm.Event{
Kind: llm.KindMessageEnd,
Message: llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopError},
Usage: llm.Usage{PromptTokens: 12, CompletionTokens: 5, TotalTokens: 17},
Err: errors.New("incomplete tool call"),
}
close(out)
return out
}
res, err := Run(context.Background(), Config{Model: "fixture", Tools: reg, Stream: stream},
llm.Message{Role: llm.RoleUser, Content: "organize materials"})
if err != nil {
t.Fatal(err)
}
if res.Err == nil || res.StopReason != llm.StopError || res.Usage.TotalTokens != 17 ||
hits != 0 || calls != 1 {
t.Fatalf("terminal failure retried, executed a tool, or lost usage: result=%#v hits=%d calls=%d", res, hits, calls)
}
}
func TestRunTerminalErrorCannotMasqueradeAsSuccess(t *testing.T) {
stream := func(context.Context, string, []llm.Message, []llm.ToolDecl, llm.StreamOptions) <-chan llm.Event {
out := make(chan llm.Event, 1)
out <- llm.Event{Kind: llm.KindMessageEnd,
Message: llm.Message{Role: llm.RoleAssistant, Content: "incomplete", StopReason: llm.StopEnd},
Usage: llm.Usage{PromptTokens: 12, CompletionTokens: 5, TotalTokens: 17},
Err: errors.New("terminal failed"),
}
close(out)
return out
}
res, err := Run(context.Background(), Config{Model: "fixture", Stream: stream},
llm.Message{Role: llm.RoleUser, Content: "go"})
if err != nil {
t.Fatal(err)
}
if res.Err == nil || res.StopReason != llm.StopError || res.Usage.TotalTokens != 17 {
t.Fatalf("error-bearing terminal was accepted as success: %#v", res)
}
}
func script(turns ...llm.Message) llm.StreamFunc {
var mu sync.Mutex
i := 0
+12 -1
View File
@@ -17,7 +17,18 @@ func askFrom(ctx context.Context) func(context.Context, string) (string, error)
return fn
}
func inputQuestion(res callToolResult) (string, bool) {
// inputQuestion 提取需要用户输入的问题。两条路径:
// 2026-07-28 协议级 resultType="input_required"(问题取文本块);
// 旧版服务端的内容块 input_required(向后兼容)。
func inputQuestion(res callToolResult, negotiated string) (string, bool) {
if res.ResultType == "input_required" && SupportsResultType(negotiated) {
for _, blk := range res.Content {
if blk.Type == "text" && blk.Text != "" {
return blk.Text, true
}
}
return "", true
}
for _, blk := range res.Content {
if blk.Type == "input_required" {
return blk.Text, true
+76 -1
View File
@@ -44,8 +44,23 @@ func fakeServerHandle(req rpcRequest) rpcResponse {
}
switch req.Method {
case "initialize":
// 协商:取(客户端请求, 自身上限)的较小者(ISO 日期字典序可比);
// AGENT_NATIVE_TEST_MCP_VERSION 可压低上限以测回落路径。
own := ProtocolVersion
if v := os.Getenv("AGENT_NATIVE_TEST_MCP_VERSION"); v != "" {
own = v
}
negotiated := own
var p initializeParams
if req.Params != nil {
raw, _ := json.Marshal(req.Params)
_ = json.Unmarshal(raw, &p)
if p.ProtocolVersion != "" && p.ProtocolVersion < negotiated {
negotiated = p.ProtocolVersion
}
}
return respond(initializeResult{
ProtocolVersion: ProtocolVersion,
ProtocolVersion: negotiated,
ServerInfo: clientInfo{Name: "fake-mcp", Version: "0"},
})
case "tools/list":
@@ -71,14 +86,64 @@ func fakeServerHandle(req rpcRequest) rpcResponse {
case "slow":
time.Sleep(2 * time.Second)
return respond(callToolResult{Content: []contentBlock{{Type: "text", Text: "slow done"}}})
case "need_input":
// 2026-07-28 协议级 input_required:tasks/update 投递后重试得终结果。
if input, ok := fakeTaskInputs()["task-1"]; ok {
fakeTaskInputs()["task-1"] = ""
return respond(callToolResult{Content: []contentBlock{{Type: "text", Text: "答案已收到: " + input}}})
}
return respond(callToolResult{
Content: []contentBlock{{Type: "text", Text: "要答案"}},
ResultType: "input_required",
TaskID: "task-1",
})
case "legacy_ask":
return respond(callToolResult{Content: []contentBlock{{Type: "input_required", Text: "选一个颜色"}}})
default:
return fail(-32602, "unknown tool "+p.Name)
}
case "tasks/update":
var p struct {
TaskID string `json:"taskId"`
Input string `json:"input"`
}
if req.Params != nil {
raw, _ := json.Marshal(req.Params)
_ = json.Unmarshal(raw, &p)
}
fakeTaskInputs()[p.TaskID] = p.Input
return respond(map[string]any{"ok": true})
case "tasks/get":
var p struct {
TaskID string `json:"taskId"`
}
if req.Params != nil {
raw, _ := json.Marshal(req.Params)
_ = json.Unmarshal(raw, &p)
}
return respond(taskResult{TaskID: p.TaskID, Status: "running"})
case "tasks/cancel":
var p struct {
TaskID string `json:"taskId"`
}
if req.Params != nil {
raw, _ := json.Marshal(req.Params)
_ = json.Unmarshal(raw, &p)
}
delete(fakeTaskInputs(), p.TaskID)
return respond(map[string]any{"ok": true})
default:
return fail(-32601, "method not found: "+req.Method)
}
}
// fakeTaskInputs 是 need_input 工具的任务输入存储(进程内)。
func fakeTaskInputs() map[string]string {
return taskInputStore
}
var taskInputStore = map[string]string{}
func fakeServerTools() []toolInfo {
return []toolInfo{
{
@@ -96,6 +161,16 @@ func fakeServerTools() []toolInfo {
Description: "Responds after a delay",
InputSchema: json.RawMessage(`{"type":"object","properties":{}}`),
},
{
Name: "need_input",
Description: "Requires user input (2026-07-28 resultType)",
InputSchema: json.RawMessage(`{"type":"object","properties":{}}`),
},
{
Name: "legacy_ask",
Description: "Requires user input (legacy content block)",
// 故意无 InputSchema:触发 manager 的合成默认 schema 路径。
},
}
}
+28
View File
@@ -21,6 +21,8 @@ type httpClient struct {
sessionID string
mu sync.Mutex
nextID int64
// negotiated 握手后写入(atomic.Value 存 string)。
negotiated atomic.Value
}
// DialHTTP 建立 HTTP 客户端并完成 initialize 握手。
@@ -51,6 +53,9 @@ func (c *httpClient) handshake(ctx context.Context) error {
ProtocolVersion: ProtocolVersion,
ClientInfo: clientInfo{Name: "agent-native", Version: "1"},
}, &res)
if err == nil {
c.negotiated.Store(res.ProtocolVersion)
}
return err
}
@@ -60,9 +65,32 @@ func (c *httpClient) Initialize(ctx context.Context) (initializeResult, error) {
ProtocolVersion: ProtocolVersion,
ClientInfo: clientInfo{Name: "agent-native", Version: "1"},
}, &res)
if err == nil {
c.negotiated.Store(res.ProtocolVersion)
}
return res, err
}
// NegotiatedVersion 是握手后服务端确认的协议版本(未握手为空)。
func (c *httpClient) NegotiatedVersion() string { return c.negotiated.Load().(string) }
// TasksGet 查询长任务状态(2026-07-28)。
func (c *httpClient) TasksGet(ctx context.Context, taskID string) (taskResult, error) {
var res taskResult
err := c.roundTrip(ctx, "tasks/get", map[string]string{"taskId": taskID}, &res)
return res, err
}
// TasksUpdate 向长任务投递用户输入(2026-07-28)。
func (c *httpClient) TasksUpdate(ctx context.Context, taskID, input string) error {
return c.roundTrip(ctx, "tasks/update", map[string]string{"taskId": taskID, "input": input}, nil)
}
// TasksCancel 取消长任务(2026-07-28)。
func (c *httpClient) TasksCancel(ctx context.Context, taskID string) error {
return c.roundTrip(ctx, "tasks/cancel", map[string]string{"taskId": taskID}, nil)
}
func (c *httpClient) ListTools(ctx context.Context) ([]toolInfo, error) {
var out []toolInfo
cursor := ""
+57 -3
View File
@@ -173,7 +173,7 @@ func (m *Manager) registerServer(ctx context.Context, reg *tools.Registry, cfg S
}
schema := ti.InputSchema
if len(schema) == 0 {
schema = json.RawMessage(`{"type":"object","properties":{}}`)
schema = json.RawMessage(`{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{}}`)
}
reg.Register(&bridgeTool{
client: c,
@@ -251,7 +251,11 @@ func (t *bridgeTool) Execute(ctx context.Context, args json.RawMessage) (tools.R
if err != nil {
return tools.Result{Content: fmt.Sprintf("mcp tool %s failed: %v", t.declName, err), IsError: true}, nil
}
if question, ok := inputQuestion(res); ok {
// input_required:2026-07-28 协议级结果(取应答后先 tasks/update 再
// 用原参数重试 tools/call);旧版内容块路径保持原样。
if question, ok := inputQuestion(res, t.client.NegotiatedVersion()); ok && res.ResultType == "input_required" && SupportsResultType(t.client.NegotiatedVersion()) {
return t.answerAndRetry(ctx, args, res, question)
} else if ok {
ask := askFrom(ctx)
if ask == nil {
return tools.Result{Content: "需要用户输入:" + question, IsError: true}, nil
@@ -279,8 +283,58 @@ func (t *bridgeTool) Execute(ctx context.Context, args json.RawMessage) (tools.R
return tools.Result{Content: out}, nil
}
// maxInputRounds 是 input_required 应答重试的上限(防服务端循环要输入)。
const maxInputRounds = 3
// answerAndRetry 处理 2026-07-28 的协议级 input_required:经 ask 端口取
// 应答,TaskID 非空时先 tasks/update 投递,然后用原参数重试 tools/call;
// 服务端连续要输入最多 maxInputRounds 轮。
func (t *bridgeTool) answerAndRetry(ctx context.Context, args json.RawMessage, res callToolResult, question string) (tools.Result, error) {
for round := 0; round < maxInputRounds; round++ {
ask := askFrom(ctx)
if ask == nil {
return tools.Result{Content: "需要用户输入:" + question, IsError: true}, nil
}
answer, aerr := ask(ctx, question)
if aerr != nil {
return tools.Result{Content: aerr.Error(), IsError: true}, nil
}
if res.TaskID != "" {
if uerr := t.client.TasksUpdate(ctx, res.TaskID, answer); uerr != nil {
return tools.Result{Content: fmt.Sprintf("mcp task update failed: %v", uerr), IsError: true}, nil
}
}
var err error
res, err = t.call(ctx, args)
if err != nil {
return tools.Result{Content: fmt.Sprintf("mcp tool %s failed: %v", t.declName, err), IsError: true}, nil
}
q, ok := inputQuestion(res, t.client.NegotiatedVersion())
if !ok || res.ResultType != "input_required" || !SupportsResultType(t.client.NegotiatedVersion()) {
var b strings.Builder
for _, blk := range res.Content {
if blk.Type == "text" && blk.Text != "" {
b.WriteString(blk.Text)
b.WriteString("\n")
}
}
out := b.String()
if res.IsError {
return tools.Result{Content: out, IsError: true}, nil
}
return tools.Result{Content: strings.TrimSpace(out)}, nil
}
question = q
}
return tools.Result{Content: "需要用户输入:" + question, IsError: true}, nil
}
func (t *bridgeTool) call(ctx context.Context, args json.RawMessage) (callToolResult, error) {
callCtx, cancel := context.WithTimeout(ctx, requestTimeout(t.serverCfg))
defer cancel()
return t.client.CallTool(callCtx, t.toolName, args)
res, err := t.client.CallTool(callCtx, t.toolName, args)
if err == nil && res.TaskID != "" {
trackerFrom(ctx).note(t.client, res.TaskID)
}
return res, err
}
+156 -3
View File
@@ -45,7 +45,7 @@ func TestStdioClientHandshakeListAndCall(t *testing.T) {
defer c.Close()
list, err := c.ListTools(ctx)
if err != nil || len(list) != 3 || list[0].Name != "echo" {
if err != nil || len(list) != 5 || list[0].Name != "echo" {
t.Fatalf("list failed: %v %+v", err, list)
}
res, err := c.CallTool(ctx, "echo", json.RawMessage(`{"text":"hi"}`))
@@ -133,7 +133,7 @@ func TestHTTPClientJSONMode(t *testing.T) {
t.Fatalf("session id must be captured, got %q", c.sessionID)
}
list, err := c.ListTools(ctx)
if err != nil || len(list) != 3 {
if err != nil || len(list) != 5 {
t.Fatalf("list failed: %v %+v", err, list)
}
res, err := c.CallTool(ctx, "echo", json.RawMessage(`{"text":"net"}`))
@@ -159,7 +159,7 @@ func TestHTTPClientSSEMode(t *testing.T) {
}
defer c.Close()
list, err := c.ListTools(ctx)
if err != nil || len(list) != 3 {
if err != nil || len(list) != 5 {
t.Fatalf("sse list failed: %v %+v", err, list)
}
}
@@ -361,8 +361,14 @@ type askClient struct{}
func (askClient) Initialize(context.Context) (initializeResult, error) {
return initializeResult{}, nil
}
func (askClient) NegotiatedVersion() string { return LegacyProtocolVersion }
func (askClient) ListTools(context.Context) ([]toolInfo, error) { return nil, nil }
func (askClient) Close() error { return nil }
func (askClient) TasksGet(context.Context, string) (taskResult, error) {
return taskResult{}, nil
}
func (askClient) TasksUpdate(context.Context, string, string) error { return nil }
func (askClient) TasksCancel(context.Context, string) error { return nil }
func (askClient) CallTool(context.Context, string, json.RawMessage) (callToolResult, error) {
return callToolResult{Content: []contentBlock{{Type: "input_required", Text: "选一个颜色"}}}, nil
}
@@ -378,3 +384,150 @@ func TestInputRequiredUsesAsk(t *testing.T) {
t.Fatalf("ask result %+v %v", res, err)
}
}
// ---- N2 WP3:2026-07-28 对齐 ----
// 协议级 input_required:ask 取应答 → tasks/update 投递 → 原参数重试得终结果。
func TestInputRequiredResultTypeRetriesOriginalCall(t *testing.T) {
mgr, err := NewManager([]ServerConfig{fakeStdioConfig(t)})
if err != nil {
t.Fatal(err)
}
defer mgr.Close()
reg := tools.NewRegistry("")
if err := mgr.RegisterInto(context.Background(), reg); err != nil {
t.Fatal(err)
}
var questions []string
ctx := WithAsk(context.Background(), func(_ context.Context, q string) (string, error) {
questions = append(questions, q)
return "蓝色", nil
})
res := reg.Execute(ctx, "mcp__fake__need_input", json.RawMessage(`{}`))
if res.IsError || res.Content != "答案已收到: 蓝色" {
t.Fatalf("final result: %+v", res)
}
if len(questions) != 1 || questions[0] != "要答案" {
t.Fatalf("ask questions: %v", questions)
}
}
// 协商回落:服务端只讲 2024-11-05 时,协议级路径不启用,旧内容块路径不变。
func TestNegotiatedVersionFallbackKeepsLegacyPath(t *testing.T) {
cfg := fakeStdioConfig(t)
cfg.Env = append(cfg.Env, "AGENT_NATIVE_TEST_MCP_VERSION="+LegacyProtocolVersion)
mgr, err := NewManager([]ServerConfig{cfg})
if err != nil {
t.Fatal(err)
}
defer mgr.Close()
reg := tools.NewRegistry("")
if err := mgr.RegisterInto(context.Background(), reg); err != nil {
t.Fatal(err)
}
ctx := WithAsk(context.Background(), func(_ context.Context, q string) (string, error) {
return "蓝色", nil
})
res := reg.Execute(ctx, "mcp__fake__legacy_ask", json.RawMessage(`{}`))
if res.IsError || res.Content != "蓝色" {
t.Fatalf("legacy path: %+v", res)
}
}
// tasks 客户端面:get 回状态、cancel 清除任务输入。
func TestTasksGetAndCancelRoundTrip(t *testing.T) {
cfg := fakeStdioConfig(t)
ctx := context.Background()
c, err := DialStdio(ctx, cfg)
if err != nil {
t.Fatal(err)
}
defer c.Close()
if got := c.NegotiatedVersion(); !SupportsResultType(got) {
t.Fatalf("negotiated = %q, want >= 2026-07-28", got)
}
if err := c.TasksUpdate(ctx, "task-x", "输入"); err != nil {
t.Fatal(err)
}
tr, err := c.TasksGet(ctx, "task-x")
if err != nil || tr.TaskID != "task-x" || tr.Status != "running" {
t.Fatalf("tasks/get: %+v %v", tr, err)
}
if err := c.TasksCancel(ctx, "task-x"); err != nil {
t.Fatal(err)
}
}
// 合成默认 schema 声明 JSON Schema 2020-12。
func TestDefaultSchemaDeclares202012(t *testing.T) {
mgr, err := NewManager([]ServerConfig{fakeStdioConfig(t)})
if err != nil {
t.Fatal(err)
}
defer mgr.Close()
reg := tools.NewRegistry("")
if err := mgr.RegisterInto(context.Background(), reg); err != nil {
t.Fatal(err)
}
for _, d := range reg.Decls() {
if d.Name == "mcp__fake__legacy_ask" {
// legacy_ask 在 fake 里无 InputSchema → 走合成默认
if !strings.Contains(string(d.Parameters), "2020-12") {
t.Fatalf("默认 schema 未声明 2020-12:%s", d.Parameters)
}
return
}
}
t.Fatal("mcp__fake__legacy_ask not registered")
}
// 任务跟踪器:记录 → 按服务端分组取消 → 清空。
func TestTaskTrackerCancelAllPerRun(t *testing.T) {
cfg := fakeStdioConfig(t)
ctx := context.Background()
c, err := DialStdio(ctx, cfg)
if err != nil {
t.Fatal(err)
}
defer c.Close()
tr := &TaskTracker{seen: map[Client]map[string]struct{}{}}
if err := c.TasksUpdate(ctx, "task-a", "1"); err != nil {
t.Fatal(err)
}
if err := c.TasksUpdate(ctx, "task-b", "2"); err != nil {
t.Fatal(err)
}
tr.note(c, "task-a")
tr.note(c, "task-b")
tr.note(c, "task-a") // 幂等
if n := tr.CancelAll(ctx); n != 2 {
t.Fatalf("CancelAll = %d, want 2", n)
}
if n := tr.CancelAll(ctx); n != 0 {
t.Fatalf("第二次 CancelAll 应为 0,得到 %d", n)
}
}
// 端到端:经注册表执行 input_required 工具的 run 结束时,task-1 被取消。
func TestRunEndCancelsSeenTasks(t *testing.T) {
cfg := fakeStdioConfig(t)
mgr, err := NewManager([]ServerConfig{cfg})
if err != nil {
t.Fatal(err)
}
defer mgr.Close()
reg := tools.NewRegistry("")
if err := mgr.RegisterInto(context.Background(), reg); err != nil {
t.Fatal(err)
}
ctx, tr := WithTaskTracker(context.Background())
ctx = WithAsk(ctx, func(_ context.Context, q string) (string, error) { return "蓝", nil })
res := reg.Execute(ctx, "mcp__fake__need_input", json.RawMessage(`{}`))
if res.IsError {
t.Fatalf("execute: %+v", res)
}
// 模仿 adapter run 结束:取消本 run 见过的任务(task-1 已被记录)。
if n := tr.CancelAll(context.Background()); n != 1 {
t.Fatalf("CancelAll = %d, want 1(task-1)", n)
}
}
+31 -2
View File
@@ -12,8 +12,18 @@ import (
"time"
)
// ProtocolVersion 是本客户端声明的 MCP 协议版本。
const ProtocolVersion = "2024-11-05"
// ProtocolVersion 是本客户端声明的 MCP 协议版本(2026-07-28 RC:
// resultType 判别、input_required 协议级结果、tasks/* 长任务面)。
const ProtocolVersion = "2026-07-28"
// LegacyProtocolVersion 是旧版协议(无 resultType/tasks),协商回落时用。
const LegacyProtocolVersion = "2024-11-05"
// SupportsResultType 报告协商版本是否支持 2026-07-28 的 resultType 语义
// (ISO 日期版本按字典序可比)。
func SupportsResultType(negotiated string) bool {
return negotiated >= "2026-07-28"
}
// ServerConfig 声明一个 MCP server 接入点:Command(stdio)或 URL(HTTP)二选一。
type ServerConfig struct {
@@ -138,6 +148,19 @@ type callToolParams struct {
type callToolResult struct {
Content []contentBlock `json:"content"`
IsError bool `json:"isError,omitempty"`
// ResultType 是 2026-07-28 的协议级结果判别(空 = 普通结果)。
// "input_required" 表示服务端需要用户输入:客户端经 ask 端口取得
// 应答后(有 TaskID 时先 tasks/update)用原参数重试 tools/call。
ResultType string `json:"resultType,omitempty"`
TaskID string `json:"taskId,omitempty"`
}
// taskResult 是 tasks/get 的返回(2026-07-28 长任务面;字段集按 RC 常用
// 形态解析,未识别字段忽略)。
type taskResult struct {
TaskID string `json:"taskId"`
Status string `json:"status"`
Detail string `json:"detail,omitempty"`
}
type contentBlock struct {
@@ -148,7 +171,13 @@ type contentBlock struct {
// Client 是一个已连接 MCP server 的最小面(initialize / tools/list / tools/call)。
type Client interface {
Initialize(ctx context.Context) (initializeResult, error)
// NegotiatedVersion 是 initialize 后服务端确认的协议版本(未握手为空)。
NegotiatedVersion() string
ListTools(ctx context.Context) ([]toolInfo, error)
CallTool(ctx context.Context, name string, args json.RawMessage) (callToolResult, error)
// TasksGet/Update/Cancel 是 2026-07-28 的长任务面。
TasksGet(ctx context.Context, taskID string) (taskResult, error)
TasksUpdate(ctx context.Context, taskID, input string) error
TasksCancel(ctx context.Context, taskID string) error
Close() error
}
+6
View File
@@ -18,8 +18,14 @@ type flakyClient struct {
func (f *flakyClient) Initialize(context.Context) (initializeResult, error) {
return initializeResult{}, nil
}
func (f *flakyClient) NegotiatedVersion() string { return ProtocolVersion }
func (f *flakyClient) ListTools(context.Context) ([]toolInfo, error) { return nil, nil }
func (f *flakyClient) Close() error { return nil }
func (f *flakyClient) TasksGet(context.Context, string) (taskResult, error) {
return taskResult{}, nil
}
func (f *flakyClient) TasksUpdate(context.Context, string, string) error { return nil }
func (f *flakyClient) TasksCancel(context.Context, string) error { return nil }
func (f *flakyClient) CallTool(context.Context, string, json.RawMessage) (callToolResult, error) {
f.calls++
if f.fail {
+26
View File
@@ -25,6 +25,8 @@ type stdioClient struct {
readers atomic.Int32
closed bool
readerOnce sync.Once
// negotiated 握手后写入(atomic.Value 存 string)。
negotiated atomic.Value
}
type stdioResult struct {
@@ -75,6 +77,7 @@ func (c *stdioClient) handshake(ctx context.Context) error {
}, &res); err != nil {
return err
}
c.negotiated.Store(res.ProtocolVersion)
// initialized 通知无需响应。
return c.notify("notifications/initialized")
}
@@ -86,9 +89,32 @@ func (c *stdioClient) Initialize(ctx context.Context) (initializeResult, error)
ProtocolVersion: ProtocolVersion,
ClientInfo: clientInfo{Name: "agent-native", Version: "1"},
}, &res)
if err == nil {
c.negotiated.Store(res.ProtocolVersion)
}
return res, err
}
// NegotiatedVersion 是握手后服务端确认的协议版本(未握手为空)。
func (c *stdioClient) NegotiatedVersion() string { return c.negotiated.Load().(string) }
// TasksGet 查询长任务状态(2026-07-28)。
func (c *stdioClient) TasksGet(ctx context.Context, taskID string) (taskResult, error) {
var res taskResult
err := c.roundTrip(ctx, "tasks/get", map[string]string{"taskId": taskID}, &res)
return res, err
}
// TasksUpdate 向长任务投递用户输入(2026-07-28)。
func (c *stdioClient) TasksUpdate(ctx context.Context, taskID, input string) error {
return c.roundTrip(ctx, "tasks/update", map[string]string{"taskId": taskID, "input": input}, nil)
}
// TasksCancel 取消长任务(2026-07-28)。
func (c *stdioClient) TasksCancel(ctx context.Context, taskID string) error {
return c.roundTrip(ctx, "tasks/cancel", map[string]string{"taskId": taskID}, nil)
}
func (c *stdioClient) ListTools(ctx context.Context) ([]toolInfo, error) {
var out []toolInfo
cursor := ""
+65
View File
@@ -0,0 +1,65 @@
package mcp
import (
"context"
"sync"
)
// TaskTracker 记录一次 run 内 tools/call 结果里出现过的长任务(按服务端
// 客户端分组),供 run 结束时对这些任务发 tasks/cancel(2026-07-28 最小
// 产品映射:取消只覆盖本 run 自己的任务,不影响共享 Manager 上其他 run)。
type TaskTracker struct {
mu sync.Mutex
seen map[Client]map[string]struct{}
}
type trackerKey struct{}
// WithTaskTracker 把任务跟踪器放进上下文(bridgeTool 记录 TaskID)。
func WithTaskTracker(ctx context.Context) (context.Context, *TaskTracker) {
tr := &TaskTracker{seen: map[Client]map[string]struct{}{}}
return context.WithValue(ctx, trackerKey{}, tr), tr
}
func trackerFrom(ctx context.Context) *TaskTracker {
if ctx == nil {
return nil
}
tr, _ := ctx.Value(trackerKey{}).(*TaskTracker)
return tr
}
func (t *TaskTracker) note(c Client, taskID string) {
if t == nil || c == nil || taskID == "" {
return
}
t.mu.Lock()
set := t.seen[c]
if set == nil {
set = map[string]struct{}{}
t.seen[c] = set
}
set[taskID] = struct{}{}
t.mu.Unlock()
}
// CancelAll 对记录的任务逐一向其服务端发 tasks/cancel(尽力而为,
// 首个错误后继续)。返回实际发出取消的任务数。
func (t *TaskTracker) CancelAll(ctx context.Context) int {
if t == nil {
return 0
}
t.mu.Lock()
seen := t.seen
t.seen = map[Client]map[string]struct{}{}
t.mu.Unlock()
n := 0
for c, ids := range seen {
for id := range ids {
if err := c.TasksCancel(ctx, id); err == nil {
n++
}
}
}
return n
}
+83
View File
@@ -0,0 +1,83 @@
// Package memory 定义记忆端口(N3 WP5)。宿主注入 Store 实现;循环在每次
// 请求前把 Recall 结果以系统消息注入请求副本(不落盘、不累计——与
// reminders 同款的 request-scoped 语义);nil = 不启用。
//
// 注入文案与产品既有 [记忆] 形态对齐(迁移前后同文):
//
// [记忆]
// - (typ) content
//
// 排序确定性:按 (Type, Content) 字典序(同一 Recall 结果集在任何
// 消费方下渲染一致)。
package memory
import (
"context"
"fmt"
"sort"
"strings"
"time"
)
// Entry 是一条记忆。Scope 为 user 或 project。
type Entry struct {
ID string
Scope string
ProjectID string
Type string
Content string
Source string
Confidence float64
CreatedAt time.Time
ExpiresAt *time.Time
}
// RecallQuery 是一次召回。Scope 空 = 不限(含 user/project);Limit ≤0 = 20。
type RecallQuery struct {
Scope string
Limit int
}
// Store 是记忆端口。实现方自担 owner/项目上下文(循环不传租户态)。
type Store interface {
Recall(ctx context.Context, q RecallQuery) ([]Entry, error)
Append(ctx context.Context, e Entry) error
}
// MaxRecallEntries 是注入的条目上限(与产品既有形态一致)。
const MaxRecallEntries = 20
// Block 把一次 Recall 结果渲染成注入文本;空结果返回空串(调用方不注入)。
// 过期与空内容条目在渲染时剔除(与产品既有 InjectMemory 同语义)。
func Block(entries []Entry, now time.Time) string {
live := make([]Entry, 0, len(entries))
for _, e := range entries {
if e.ExpiresAt != nil && !e.ExpiresAt.After(now) {
continue
}
if strings.TrimSpace(e.Content) == "" {
continue
}
live = append(live, e)
}
if len(live) == 0 {
return ""
}
sort.Slice(live, func(i, j int) bool {
if live[i].Type != live[j].Type {
return live[i].Type < live[j].Type
}
return live[i].Content < live[j].Content
})
var b strings.Builder
b.WriteString("[记忆]\n")
n := 0
for _, e := range live {
fmt.Fprintf(&b, "- (%s) %s\n", e.Type, strings.TrimSpace(e.Content))
n++
if n >= MaxRecallEntries {
break
}
}
return b.String()
}
+91
View File
@@ -0,0 +1,91 @@
package memory
import (
"context"
"encoding/json"
"strings"
"testing"
"time"
)
// ---- N3 WP5:记忆端口 ----
type fakeStore struct {
entries []Entry
appended []Entry
recallQ RecallQuery
err error
}
func (f *fakeStore) Recall(_ context.Context, q RecallQuery) ([]Entry, error) {
f.recallQ = q
return f.entries, f.err
}
func (f *fakeStore) Append(_ context.Context, e Entry) error {
f.appended = append(f.appended, e)
return nil
}
func TestBlockRenderOrderingAndCaps(t *testing.T) {
future := time.Now().Add(24 * time.Hour)
past := time.Now().Add(-24 * time.Hour)
entries := []Entry{
{Type: "preference", Content: "喜欢简洁回复"},
{Type: "fact", Content: "项目用 Go 1.27"},
{Type: "fact", Content: " "},
{Type: "fact", Content: "过期了", ExpiresAt: &past},
{Type: "fact", Content: "没过期", ExpiresAt: &future},
}
out := Block(entries, time.Now())
if !strings.Contains(out, "[记忆]\n") {
t.Fatalf("缺块头:%q", out)
}
// 确定性排序:Type 字典序(fact 先于 preference);过期与空内容剔除。
firstFact := strings.Index(out, "fact")
firstPref := strings.Index(out, "preference")
if firstFact < 0 || firstPref < 0 || firstFact > firstPref {
t.Fatalf("排序不确定:%q", out)
}
if strings.Contains(out, "过期了") {
t.Fatalf("过期条目未剔除:%q", out)
}
if !strings.Contains(out, "没过期") {
t.Fatalf("未过期条目被误剔:%q", out)
}
if Block(nil, time.Now()) != "" {
t.Fatal("空结果应为空串")
}
}
func TestWriteToolValidation(t *testing.T) {
store := &fakeStore{}
tool := &writeTool{store: store}
cases := []struct {
name string
args string
wantErr string
}{
{"坏 scope", `{"scope":"global","type":"fact","content":"x"}`, "user 或 project"},
{"坏 type", `{"type":"todo","content":"x"}`, "fact|preference"},
{"空 content", `{"type":"fact","content":" "}`, "非空"},
{"超长 content", `{"type":"fact","content":"` + strings.Repeat("长", 2001) + `"}`, "上限"},
}
for _, tc := range cases {
res, err := tool.Execute(context.Background(), json.RawMessage(tc.args))
if err != nil || !res.IsError || !strings.Contains(res.Content, tc.wantErr) {
t.Fatalf("%s:%+v %v", tc.name, res, err)
}
}
if len(store.appended) != 0 {
t.Fatalf("校验失败不得落库:%+v", store.appended)
}
// 合法路径:默认 scope=user + 过期换算。
res, err := tool.Execute(context.Background(), json.RawMessage(`{"type":"preference","content":"喜欢简洁","expires_in_days":30}`))
if err != nil || res.IsError {
t.Fatalf("合法写入被拒:%+v %v", res, err)
}
if len(store.appended) != 1 || store.appended[0].Scope != "user" ||
store.appended[0].Type != "preference" || store.appended[0].ExpiresAt == nil {
t.Fatalf("写入形态:%+v", store.appended)
}
}
+93
View File
@@ -0,0 +1,93 @@
package memory
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"agent-native/runtime/tools"
)
// memory_write 工具:模型自服务写回(fail-closed 校验 scope/type/content)。
// 只在宿主配置 Store 时注册(RegisterWrite 由循环在 Run 启动时接线)。
type writeTool struct {
store Store
scope string // 默认 scope(AgentSpec.MemoryScope 驱动;空 = user)
}
var validScopes = map[string]bool{"user": true, "project": true}
var validTypes = map[string]bool{
"fact": true, "preference": true, "decision": true, "episode": true, "procedure": true,
}
// RegisterWrite 把 memory_write 注册进 reg(store nil 时跳过;重复注册按
// 覆盖处理——宿主不得在同一注册表上给不同 run 挂不同实现)。
func RegisterWrite(reg *tools.Registry, store Store, defaultScope string) {
if reg == nil || store == nil {
return
}
reg.Register(&writeTool{store: store, scope: defaultScope})
}
func (t *writeTool) Name() string { return "memory_write" }
func (t *writeTool) Description() string {
return "把一条值得跨会话记住的事实/偏好/决定写入长期记忆(scope: user 或 project;type: fact|preference|decision|episode|procedure)。"
}
func (t *writeTool) Parameters() json.RawMessage {
return json.RawMessage(`{"type":"object","required":["type","content"],"properties":{
"scope":{"type":"string"},
"type":{"type":"string"},
"content":{"type":"string"},
"expires_in_days":{"type":"number"}
}}`)
}
// maxWriteContent 是单条记忆的内容上限(字符)。
const maxWriteContent = 2000
func (t *writeTool) Execute(ctx context.Context, args json.RawMessage) (tools.Result, error) {
var a struct {
Scope string `json:"scope"`
Type string `json:"type"`
Content string `json:"content"`
ExpiresInDays float64 `json:"expires_in_days"`
}
if err := json.Unmarshal(args, &a); err != nil {
return tools.Result{Content: "memory_write: 参数不是完整 JSON 对象", IsError: true}, nil
}
scope := strings.TrimSpace(a.Scope)
if scope == "" {
scope = t.scope
}
if scope == "" {
scope = "user"
}
if !validScopes[scope] {
return tools.Result{Content: fmt.Sprintf("memory_write: scope %q 必须是 user 或 project", scope), IsError: true}, nil
}
typ := strings.TrimSpace(a.Type)
if !validTypes[typ] {
return tools.Result{Content: fmt.Sprintf("memory_write: type %q 必须是 fact|preference|decision|episode|procedure 之一", typ), IsError: true}, nil
}
content := strings.TrimSpace(a.Content)
if content == "" {
return tools.Result{Content: "memory_write: content 必填且非空", IsError: true}, nil
}
if len([]rune(content)) > maxWriteContent {
return tools.Result{Content: fmt.Sprintf("memory_write: content 超过 %d 字符上限", maxWriteContent), IsError: true}, nil
}
var expires *time.Time
if a.ExpiresInDays > 0 {
t := time.Now().Add(time.Duration(a.ExpiresInDays*24) * time.Hour)
expires = &t
}
if err := t.store.Append(ctx, Entry{
Scope: scope, Type: typ, Content: content,
Source: "agent", CreatedAt: time.Now(), ExpiresAt: expires,
}); err != nil {
return tools.Result{Content: "memory_write: " + err.Error(), IsError: true}, nil
}
return tools.Result{Content: fmt.Sprintf("已记住(scope=%s type=%s)", scope, typ)}, nil
}
+96
View File
@@ -0,0 +1,96 @@
package runtime
import (
"context"
"strings"
"testing"
"agent-native/runtime/llm"
"agent-native/runtime/memory"
"agent-native/runtime/tools"
)
// ---- N3 WP5:循环消费点(注入 + memory_write 接线)----
type loopMemStore struct {
entries []memory.Entry
appended []memory.Entry
}
func (s *loopMemStore) Recall(_ context.Context, q memory.RecallQuery) ([]memory.Entry, error) {
return s.entries, nil
}
func (s *loopMemStore) Append(_ context.Context, e memory.Entry) error {
s.appended = append(s.appended, e)
return nil
}
// 端口注入的条目出现在请求副本系统段之后;nil 端口零变化;
// Run 启动注册 memory_write 且可调用。
func TestLoopInjectsMemoryAndRegistersWrite(t *testing.T) {
store := &loopMemStore{entries: []memory.Entry{{Type: "fact", Content: "项目用 Go 1.27"}}}
reg := tools.NewRegistry(t.TempDir())
var gotReq [][]llm.Message
res, err := Run(context.Background(), Config{
Model: "fake", Tools: reg, Memory: store, MaxTurns: 2,
Stream: func(ctx context.Context, model string, msgs []llm.Message, _ []llm.ToolDecl, _ llm.StreamOptions) <-chan llm.Event {
gotReq = append(gotReq, append([]llm.Message{}, msgs...))
ch := make(chan llm.Event, 1)
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{
Role: llm.RoleAssistant, StopReason: llm.StopToolCalls,
ToolCalls: []llm.ToolCall{{ID: "c1", Name: "memory_write", Arguments: `{"type":"fact","content":"主人喜欢简洁"}`}},
}}
close(ch)
return ch
},
}, llm.Message{Role: llm.RoleUser, Content: "go"})
if err != nil {
t.Fatal(err)
}
_ = res
if len(gotReq) < 1 {
t.Fatalf("请求次数 = %d", len(gotReq))
}
req := gotReq[0]
// [记忆] 块在系统段之后、历史之前。
sysEnd := 0
for sysEnd < len(req) && req[sysEnd].Role == llm.RoleSystem {
sysEnd++
}
var found bool
for i := 0; i < sysEnd; i++ {
if strings.Contains(req[i].Content, "[记忆]") && strings.Contains(req[i].Content, "项目用 Go 1.27") {
found = true
}
}
if !found {
t.Fatalf("记忆块未注入系统段:%#v", req)
}
// memory_write 已被注册并执行(脚本化模型每轮都点名它 → 至少一次)。
if len(store.appended) < 1 || store.appended[0].Content != "主人喜欢简洁" {
t.Fatalf("memory_write 未执行:%+v", store.appended)
}
}
// 空召回:不产生 [记忆] 块(注入静默跳过);nil 端口同语义。
func TestEmptyRecallInjectsNothing(t *testing.T) {
reg := tools.NewRegistry(t.TempDir())
var gotReq []llm.Message
if _, err := Run(context.Background(), Config{
Model: "fake", Tools: reg, Memory: &loopMemStore{},
Stream: func(ctx context.Context, model string, msgs []llm.Message, _ []llm.ToolDecl, _ llm.StreamOptions) <-chan llm.Event {
gotReq = append([]llm.Message{}, msgs...)
ch := make(chan llm.Event, 1)
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{Role: llm.RoleAssistant, Content: "x", StopReason: llm.StopEnd}}
close(ch)
return ch
},
}, llm.Message{Role: llm.RoleUser, Content: "go"}); err != nil {
t.Fatal(err)
}
for _, m := range gotReq {
if strings.Contains(m.Content, "[记忆]") {
t.Fatalf("空召回不得产生记忆块:%#v", gotReq)
}
}
}
+55 -1
View File
@@ -2,7 +2,10 @@
// 默认模式不额外表态,调用方继续用原有画像策略。
package policy
import "strings"
import (
"strings"
"sync"
)
type Mode string
@@ -92,6 +95,57 @@ func Decide(req Request, rules []Rule) Effect {
return EffectNone
}
// SessionAllows 记录并查询「本会话始终允许」的决定。键的口径由实现方定义;
// 宿主可以用自己的模式匹配(如参数通配)实现同一接口。
type SessionAllows interface {
Remember(name, args string)
Allowed(name, args string) bool
}
// MemoryAllows 是进程内的会话放行存储,按「工具名 + 原样参数串」精确匹配。
type MemoryAllows struct {
mu sync.Mutex
allowed map[string]bool
}
func NewMemoryAllows() *MemoryAllows {
return &MemoryAllows{allowed: make(map[string]bool)}
}
func (m *MemoryAllows) Remember(name, args string) {
m.mu.Lock()
m.allowed[name+"\n"+args] = true
m.mu.Unlock()
}
func (m *MemoryAllows) Allowed(name, args string) bool {
m.mu.Lock()
defer m.mu.Unlock()
return m.allowed[name+"\n"+args]
}
// Policy 是一次会话的权限配置:模式、规则与会话放行存在同一个对象上,
// Decide/Visible 语义与同名函数完全一致。
type Policy struct {
Mode Mode
Rules []Rule
SessionAllows SessionAllows // 可为 nil:等价于 SessionAllow 恒为 false
}
// Decide 按 Policy 的模式、规则与会话放行判定一次工具调用。
func (p Policy) Decide(name, args string, traits Traits) Effect {
var allowed bool
if p.SessionAllows != nil {
allowed = p.SessionAllows.Allowed(name, args)
}
return Decide(Request{Mode: p.Mode, Name: name, Args: args, Traits: traits, SessionAllow: allowed}, p.Rules)
}
// Visible 报告该模式下这个工具能否出现在清单里。
func (p Policy) Visible(name string) bool {
return Visible(string(p.Mode), name, mergeTraits(name, Traits{}).ReadOnly)
}
// Visible 报告计划模式下这个工具能否出现在清单里。其他模式一律可见。
func Visible(mode string, name string, readOnly bool) bool {
if Normalize(mode) != ModePlan {
+49
View File
@@ -39,3 +39,52 @@ func TestAutoEditAllowsWorkspaceWrites(t *testing.T) {
t.Fatalf("auto edit %q", got)
}
}
// TestPolicyDecideMatchesFunctionForm:Policy 结构体形态与函数形态判定一致。
func TestPolicyDecideMatchesFunctionForm(t *testing.T) {
rules := []Rule{{Effect: EffectAllow, Tool: "write_file"}, {Effect: EffectDeny, Tool: "write_*", ArgGlob: "*secret*"}}
cases := []struct {
p Policy
name string
args string
want Effect
}{
{Policy{Mode: ModePlan}, "write_file", `{"path":"a.md"}`, EffectDeny},
{Policy{Mode: ModeDefault, Rules: rules}, "write_file", `{"path":"a.md"}`, EffectAllow},
{Policy{Mode: ModeDefault, Rules: rules}, "write_file", `{"path":"secret.txt"}`, EffectDeny},
{Policy{Mode: ModeDefault}, "run_shell", `{"command":"rm -rf /"}`, EffectAsk},
}
for i, c := range cases {
if got := c.p.Decide(c.name, c.args, Traits{}); got != c.want {
t.Fatalf("case %d: effect %q, want %q", i, got, c.want)
}
}
}
// TestPolicySessionAllowsStore:会话放行经存储短路 ask,且跨调用存活。
func TestPolicySessionAllowsStore(t *testing.T) {
store := NewMemoryAllows()
p := Policy{Mode: ModeDefault, Rules: []Rule{{Effect: EffectAsk, Tool: "run_shell"}}, SessionAllows: store}
if got := p.Decide("run_shell", `{"command":"ls"}`, Traits{}); got != EffectAsk {
t.Fatalf("before remember: %q, want ask", got)
}
store.Remember("run_shell", `{"command":"ls"}`)
if got := p.Decide("run_shell", `{"command":"ls"}`, Traits{}); got != EffectAllow {
t.Fatalf("after remember: %q, want allow", got)
}
if got := p.Decide("run_shell", `{"command":"rm -rf /"}`, Traits{}); got != EffectAsk {
t.Fatalf("other args: %q, want ask(精确匹配不放行)", got)
}
}
// TestPolicyNilStoreAndVisible:nil 存储等价于 SessionAllow 恒 false;Visible 方法可用。
func TestPolicyNilStoreAndVisible(t *testing.T) {
p := Policy{Mode: ModePlan}
if got := p.Decide("write_file", `{}`, Traits{}); got != EffectDeny {
t.Fatalf("plan deny: %q", got)
}
if !p.Visible("update_plan") || p.Visible("write_file") {
t.Fatal("policy visible mismatch")
}
}
+1 -1
View File
@@ -61,7 +61,7 @@ func TestPiAliasesFilterAndSkillCommand(t *testing.T) {
if err := os.MkdirAll(skillDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("be thorough"), 0o644); err != nil {
if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\nname: review\ndescription: 评审技能\n---\n\nbe thorough"), 0o644); err != nil {
t.Fatal(err)
}
store := skills.NewStore(dir)
+46 -1
View File
@@ -1,6 +1,12 @@
package session
import "agent-native/runtime/llm"
import (
"os"
"path/filepath"
"strings"
"agent-native/runtime/llm"
)
const (
// DefaultKeepToolResults 是运行内清理时保留的最近工具结果条数。
@@ -42,6 +48,14 @@ func NeedsClear(estimate, window, outputReserve, buffer int) bool {
// 消息条数、顺序和 tool_call 配对都不变;占位写明完整结果仍在会话记录里。
// keep ≤ 0 时保留默认的 5 条。返回的切片不与输入共享被改写的消息。
func ClearOldToolResults(msgs []llm.Message, keep int) []llm.Message {
return ClearOldToolResultsTo(msgs, keep, "")
}
// ClearOldToolResultsTo 在 ClearOldToolResults 的基础上支持落盘:
// dir 非空时,把被清理结果的完整内容写进 <dir>/<toolCallID>.txt,
// 占位改为路径指引(ZCode 式「旧工具结果换成路径」);
// 写盘失败或调用 ID 为空时回落为转录指针占位,不阻断本轮。
func ClearOldToolResultsTo(msgs []llm.Message, keep int, dir string) []llm.Message {
if keep <= 0 {
keep = DefaultKeepToolResults
}
@@ -60,6 +74,12 @@ func ClearOldToolResults(msgs []llm.Message, keep int) []llm.Message {
if name == "" {
name = "工具"
}
if dir != "" && out[i].ToolCallID != "" && out[i].Content != "" {
if path, ok := spillResult(dir, out[i].ToolCallID, out[i].Content); ok {
out[i].Content = "旧工具结果已清理。完整内容见 " + path + "(" + name + ")。"
continue
}
}
loc := "会话记录"
if out[i].ToolCallID != "" {
loc = "会话记录中的调用 " + out[i].ToolCallID
@@ -68,3 +88,28 @@ func ClearOldToolResults(msgs []llm.Message, keep int) []llm.Message {
}
return out
}
// spillResult 把一条旧工具结果写盘,返回路径。文件名只留安全字符。
func spillResult(dir, callID, content string) (string, bool) {
var sb strings.Builder
for _, r := range callID {
switch {
case r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' ||
r == '-' || r == '_' || r == '.':
sb.WriteRune(r)
default:
sb.WriteByte('_')
}
}
if sb.Len() == 0 {
return "", false
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", false
}
path := filepath.Join(dir, sb.String()+".txt")
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
return "", false
}
return path, true
}
+76
View File
@@ -1,6 +1,8 @@
package session
import (
"os"
"path/filepath"
"strings"
"testing"
@@ -58,3 +60,77 @@ func TestNeedsClearUsesConservativeWindow(t *testing.T) {
t.Fatal("estimate at the default window must clear")
}
}
// ---- N2 WP1:旧工具结果落盘(路径占位)----
func spillFixture() []llm.Message {
msgs := []llm.Message{{Role: llm.RoleUser, Content: "u"}}
for i := 0; i < 7; i++ {
id := "call-" + string(rune('a'+i))
msgs = append(msgs,
llm.Message{Role: llm.RoleAssistant, ToolCalls: []llm.ToolCall{{ID: id, Name: "read", Arguments: "{}"}}},
llm.Message{Role: llm.RoleTool, ToolCallID: id, Name: "read", Content: "完整结果内容-" + id})
}
return msgs
}
func TestClearOldToolResultsSpillsToDisk(t *testing.T) {
dir := t.TempDir()
out := ClearOldToolResultsTo(spillFixture(), 5, dir)
if len(out) != len(spillFixture()) {
t.Fatal("消息条数不得变")
}
// 最旧两条被落盘,占位带路径。
for i, id := range []string{"call-a", "call-b"} {
path := filepath.Join(dir, id+".txt")
b, err := os.ReadFile(path)
if err != nil {
t.Fatalf("落盘文件缺失:%v", err)
}
if string(b) != "完整结果内容-"+id {
t.Fatalf("落盘内容不符:%q", b)
}
placeholder := out[2+i*2].Content
if !strings.Contains(placeholder, path) {
t.Fatalf("占位未带路径:%q", placeholder)
}
}
// 保留的最近 5 条正文不动。
if out[len(out)-1].Content != "完整结果内容-call-g" {
t.Fatalf("最近结果被误改:%q", out[len(out)-1].Content)
}
}
func TestClearOldToolResultsEmptyDirKeepsLegacyPlaceholder(t *testing.T) {
out := ClearOldToolResultsTo(spillFixture(), 5, "")
legacy := ClearOldToolResults(spillFixture(), 5)
for i := range out {
if out[i].Content != legacy[i].Content {
t.Fatalf("空 dir 应与旧行为逐字节一致:msg %d", i)
}
}
}
func TestClearOldToolResultsSpillFailureFallsBack(t *testing.T) {
// 不可写目录:占位回落为转录指针,不阻断。
blocked := filepath.Join(t.TempDir(), "file")
if err := os.WriteFile(blocked, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
out := ClearOldToolResultsTo(spillFixture(), 5, filepath.Join(blocked, "sub"))
if !strings.Contains(out[2].Content, "会话记录中的调用 call-a") {
t.Fatalf("写盘失败应回落转录指针:%q", out[2].Content)
}
}
func TestSpillResultSanitizesCallID(t *testing.T) {
dir := t.TempDir()
path, ok := spillResult(dir, "call/../evil\x00id", "x")
if !ok {
t.Fatal("spill 失败")
}
base := filepath.Base(path)
if strings.Contains(base, "/") || strings.ContainsRune(base, 0) || filepath.Dir(path) != dir {
t.Fatalf("文件名未消毒或越出目录:%q", path)
}
}
+39
View File
@@ -182,3 +182,42 @@ func TestR2AppendVisibleAfterReopen(t *testing.T) {
t.Fatalf("reopen: %+v", msgs)
}
}
// ---- N2 WP1:alignToolTail 边界直接单测(原仅经 Store.Compact 间接覆盖)----
func tailMsgs() []llm.Message {
call := func(id string) llm.ToolCall { return llm.ToolCall{ID: id, Name: "read", Arguments: "{}"} }
return []llm.Message{
{Role: llm.RoleUser, Content: "u1"}, // 0
{Role: llm.RoleAssistant, ToolCalls: []llm.ToolCall{call("a"), call("b")}}, // 1 多调用 assistant
{Role: llm.RoleTool, ToolCallID: "a", Content: "ra"}, // 2
{Role: llm.RoleTool, ToolCallID: "b", Content: "rb"}, // 3
{Role: llm.RoleUser, Content: "u2"}, // 4
{Role: llm.RoleAssistant, ToolCalls: []llm.ToolCall{call("c")}}, // 5
{Role: llm.RoleTool, ToolCallID: "c", Content: "rc"}, // 6
}
}
func TestAlignToolTailEdges(t *testing.T) {
msgs := tailMsgs()
// tail 恰落在多调用 assistant 的结果组内 → 回退到该 assistant。
if got := alignToolTail(msgs, 3); got != 1 {
t.Fatalf("落在多调用结果组内:got %d want 1", got)
}
// tail 落在结果组边界外(user)→ 不动。
if got := alignToolTail(msgs, 4); got != 4 {
t.Fatalf("落在 user 上:got %d want 4", got)
}
// 结尾整组是 tool 角色 → 回退到其 assistant。
if got := alignToolTail(msgs, 6); got != 5 {
t.Fatalf("落在末尾结果上:got %d want 5", got)
}
// 从头起全是 tool 角色(退化形态)→ 回退到 0,不越界。
allTool := []llm.Message{
{Role: llm.RoleTool, ToolCallID: "x", Content: "1"},
{Role: llm.RoleTool, ToolCallID: "y", Content: "2"},
}
if got := alignToolTail(allTool, 1); got != 0 {
t.Fatalf("全 tool:got %d want 0", got)
}
}
+44
View File
@@ -153,6 +153,11 @@ func (s *Store) Name() string {
}
func (s *Store) appendEntryLocked(e Entry) (Entry, error) {
// Exclusive-writer: repair a torn/missing-newline tail before append so the
// next load does not see mid-file JSON corruption from concatenated bytes.
if err := s.repairTailLocked(); err != nil {
return Entry{}, err
}
entries, err := s.readEntriesLocked()
if err != nil {
return Entry{}, err
@@ -191,6 +196,45 @@ func (s *Store) appendEntryLocked(e Entry) (Entry, error) {
return e, nil
}
// repairTailLocked fixes only the final line under exclusive-writer semantics:
// truncate an invalid torn fragment, or append a missing newline after a valid
// final record. Mid-file corruption is left untouched so the next read still
// fails closed.
func (s *Store) repairTailLocked() error {
raw, err := os.ReadFile(s.path)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
if len(raw) == 0 || raw[len(raw)-1] == '\n' {
return nil
}
text := string(raw)
idx := strings.LastIndexByte(text, '\n')
var prefix, fragment string
if idx >= 0 {
prefix = text[:idx+1]
fragment = text[idx+1:]
} else {
fragment = text
}
if fragment == "" {
return nil
}
var fixed string
if json.Valid([]byte(fragment)) {
fixed = prefix + fragment + "\n"
} else {
fixed = prefix
}
if fixed == text {
return nil
}
return os.WriteFile(s.path, []byte(fixed), 0o644)
}
func (s *Store) readEntriesLocked() ([]Entry, error) {
raw, err := os.ReadFile(s.path)
if err != nil {
+105
View File
@@ -81,6 +81,111 @@ func TestLegacyTranscriptStillLoads(t *testing.T) {
}
}
func TestRepairTornTailBeforeAppend(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "torn.jsonl")
// Valid record + torn final fragment (no closing brace / newline).
body := "{\"role\":\"user\",\"content\":\"ok\"}\n{\"role\":\"assistant\",\"content\":\"tor"
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
s, err := Open(path)
if err != nil {
t.Fatal(err)
}
if err := s.Append(llm.Message{Role: llm.RoleAssistant, Content: "repaired"}); err != nil {
t.Fatal(err)
}
msgs, err := s.Load()
if err != nil {
t.Fatalf("load after repair: %v", err)
}
if len(msgs) != 2 || msgs[0].Content != "ok" || msgs[1].Content != "repaired" {
t.Fatalf("msgs after torn repair: %+v", msgs)
}
raw, _ := os.ReadFile(path)
if !strings.HasSuffix(string(raw), "\n") {
t.Fatal("file must end with newline after repair-append")
}
if strings.Contains(string(raw), "tor\n{") || strings.Contains(string(raw), "tor{") {
t.Fatalf("torn fragment was concatenated: %s", raw)
}
}
func TestRepairMissingNewlineBeforeAppend(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "nonewline.jsonl")
// Complete JSON record without trailing newline.
body := "{\"role\":\"user\",\"content\":\"hi\"}"
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
s, err := Open(path)
if err != nil {
t.Fatal(err)
}
if err := s.Append(llm.Message{Role: llm.RoleAssistant, Content: "next"}); err != nil {
t.Fatal(err)
}
msgs, err := s.Load()
if err != nil {
t.Fatalf("load after newline repair: %v", err)
}
if len(msgs) != 2 || msgs[0].Content != "hi" || msgs[1].Content != "next" {
t.Fatalf("msgs: %+v", msgs)
}
raw, _ := os.ReadFile(path)
// Two separate JSON lines — not one concatenated object.
lines := strings.Split(strings.TrimSuffix(string(raw), "\n"), "\n")
if len(lines) != 2 {
t.Fatalf("want 2 lines, got %d in %q", len(lines), raw)
}
}
func TestMidFileCorruptionStillErrors(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "mid.jsonl")
body := "{\"role\":\"user\",\"content\":\"a\"}\n{not-json}\n{\"role\":\"assistant\",\"content\":\"b\"}\n"
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
s, err := Open(path)
if err != nil {
t.Fatal(err)
}
if _, err := s.Load(); err == nil {
t.Fatal("mid-file corruption must be a hard error on load")
}
if err := s.Append(llm.Message{Role: llm.RoleUser, Content: "x"}); err == nil {
t.Fatal("mid-file corruption must be a hard error on append")
}
}
func TestRepairThenAppendThenLoad(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "roundtrip.jsonl")
if err := os.WriteFile(path, []byte("{\"role\":\"user\",\"content\":\"one\"}\n{\"role\":\"as"), 0o644); err != nil {
t.Fatal(err)
}
s, err := Open(path)
if err != nil {
t.Fatal(err)
}
if err := s.Append(llm.Message{Role: llm.RoleAssistant, Content: "two"}); err != nil {
t.Fatal(err)
}
if err := s.Append(llm.Message{Role: llm.RoleUser, Content: "three"}); err != nil {
t.Fatal(err)
}
msgs, err := s.Load()
if err != nil || len(msgs) != 3 {
t.Fatalf("roundtrip load: %+v %v", msgs, err)
}
if msgs[0].Content != "one" || msgs[1].Content != "two" || msgs[2].Content != "three" {
t.Fatalf("contents: %+v", msgs)
}
}
func TestCatalogContinueResumeAndHTML(t *testing.T) {
dir := t.TempDir()
s, err := Open(filepath.Join(dir, "sess.jsonl"))
+2
View File
@@ -4,6 +4,7 @@ import (
"context"
"sync"
"agent-native/runtime/hooks"
"agent-native/runtime/llm"
"agent-native/runtime/session"
)
@@ -167,6 +168,7 @@ func (a *AgentSession) Compact(keep int, summarize func([]llm.Message) (string,
if a.cfg.Session == nil {
return false, nil
}
a.cfg.Hooks.Emit(hooks.Call{Event: hooks.PreCompact})
emit(a.cfg, Event{Type: "compaction_start"})
done, err := a.cfg.Session.Compact(keep, summarize)
text := "noop"
+184
View File
@@ -0,0 +1,184 @@
package skills
import (
"fmt"
"strings"
)
// frontmatter.go — SKILL.md frontmatter 的开放规范校验(N2 WP4)。
// 坚持不引入 YAML 依赖(skills.go 包注释的既有约束):手写严格子集,
// 只支持规范字段形态——标量、metadata 字符串映射、allowed-tools 空格
// 分隔列表;形态不合法即错误,不猜测。
// 已知键白名单(agentskills.io 开放规范);未知键带字段定位拒绝。
var knownFrontmatterKeys = map[string]bool{
"name": true, "description": true, "license": true,
"compatibility": true, "metadata": true, "allowed-tools": true,
}
const (
maxNameRunes = 64
maxDescription = 1024 // 规范上限(替换旧的 200 截断)
maxCompatibility = 500
maxMetadataEntries = 32
maxAllowedTools = 64
)
// ValidationError 是一条带字段定位的技能校验错误。
type ValidationError struct {
Path string // SKILL.md 路径
Field string // frontmatter 键("name"、"metadata.author"…);整体错误用 "-"
Msg string
}
func (e ValidationError) Error() string {
return fmt.Sprintf("%s: frontmatter %s: %s", e.Path, e.Field, e.Msg)
}
// Frontmatter 是解析+校验后的 SKILL.md 头。
type Frontmatter struct {
Name string
Description string
License string
Compatibility string
Metadata map[string]string
AllowedTools []string
}
// parseFrontmatter 解析 frontmatter 子集。无 frontmatter 时返回 nil(由
// 调用方按「name/description 必填」判错)。data 是完整文件内容。
func parseFrontmatter(path, text string) (*Frontmatter, string, error) {
fm := &Frontmatter{Metadata: map[string]string{}}
lines := strings.Split(text, "\n")
if len(lines) == 0 || strings.TrimSpace(lines[0]) != "---" {
return fm, text, nil
}
end := -1
for i := 1; i < len(lines); i++ {
if strings.TrimSpace(lines[i]) == "---" {
end = i
break
}
}
if end < 0 {
return nil, "", ValidationError{Path: path, Field: "-", Msg: "frontmatter 起始 --- 后找不到结束 ---"}
}
body := strings.Join(lines[end+1:], "\n")
for i := 1; i < end; i++ {
line := lines[i]
trimmed := strings.TrimSpace(line)
if trimmed == "" {
continue
}
// metadata 的缩进映射行在父键循环里处理。
key, value, found := strings.Cut(trimmed, ":")
if !found {
return nil, "", ValidationError{Path: path, Field: "-", Msg: fmt.Sprintf("第 %d 行不是 key: value 形态", i)}
}
key = strings.TrimSpace(key)
if !knownFrontmatterKeys[key] {
return nil, "", ValidationError{Path: path, Field: key, Msg: "未知 frontmatter 键(开放规范白名单:name/description/license/compatibility/metadata/allowed-tools)"}
}
value = strings.TrimSpace(value)
switch key {
case "metadata":
if value != "" {
return nil, "", ValidationError{Path: path, Field: "metadata", Msg: "metadata 必须是缩进映射,不接受行内值"}
}
j := i + 1
for ; j < end; j++ {
sub := lines[j]
if strings.TrimSpace(sub) == "" {
continue
}
if !strings.HasPrefix(sub, " ") && !strings.HasPrefix(sub, "\t") {
break
}
sk, sv, ok := strings.Cut(strings.TrimSpace(sub), ":")
if !ok || strings.TrimSpace(sk) == "" {
return nil, "", ValidationError{Path: path, Field: "metadata", Msg: fmt.Sprintf("第 %d 行不是 key: value 形态", j)}
}
if len(fm.Metadata) >= maxMetadataEntries {
return nil, "", ValidationError{Path: path, Field: "metadata." + strings.TrimSpace(sk), Msg: fmt.Sprintf("metadata 超过 %d 条上限", maxMetadataEntries)}
}
fm.Metadata[strings.TrimSpace(sk)] = strings.TrimSpace(sv)
}
i = j - 1
case "name":
fm.Name = value
case "description":
fm.Description = value
case "license":
fm.License = value
case "compatibility":
fm.Compatibility = value
case "allowed-tools":
if value == "" {
return nil, "", ValidationError{Path: path, Field: "allowed-tools", Msg: "allowed-tools 为空(应为空格分隔的工具名列表)"}
}
for _, t := range strings.Fields(value) {
fm.AllowedTools = append(fm.AllowedTools, t)
}
if len(fm.AllowedTools) > maxAllowedTools {
return nil, "", ValidationError{Path: path, Field: "allowed-tools", Msg: fmt.Sprintf("allowed-tools 超过 %d 条上限", maxAllowedTools)}
}
}
}
return fm, body, nil
}
// validateFrontmatter 按开放规范校验(dirName 是目录名,name 须与之相等)。
func validateFrontmatter(path, dirName string, fm *Frontmatter) error {
if fm.Name == "" {
return ValidationError{Path: path, Field: "name", Msg: "name 必填(开放规范)"}
}
if !validSkillName(fm.Name) {
return ValidationError{Path: path, Field: "name", Msg: fmt.Sprintf("name %q 不是 kebab-case(小写字母数字与单连字符,1-64 字符)", fm.Name)}
}
if fm.Name != dirName {
return ValidationError{Path: path, Field: "name", Msg: fmt.Sprintf("name %q 与目录名 %q 不一致(规范要求同名)", fm.Name, dirName)}
}
if strings.TrimSpace(fm.Description) == "" {
return ValidationError{Path: path, Field: "description", Msg: "description 必填且非空(开放规范)"}
}
if len([]rune(fm.Description)) > maxDescription {
return ValidationError{Path: path, Field: "description", Msg: fmt.Sprintf("description 超过 %d 字符上限", maxDescription)}
}
if len([]rune(fm.Compatibility)) > maxCompatibility {
return ValidationError{Path: path, Field: "compatibility", Msg: fmt.Sprintf("compatibility 超过 %d 字符上限", maxCompatibility)}
}
return nil
}
// validSkillName 是开放规范的 kebab-case:小写字母/数字段,段间单连字符,
// 无首尾/连续连字符,1-64 字符。
func validSkillName(s string) bool {
if s == "" || len(s) > maxNameRunes {
return false
}
if strings.HasPrefix(s, "-") || strings.HasSuffix(s, "-") || strings.Contains(s, "--") {
return false
}
for _, r := range s {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-':
default:
return false
}
}
return true
}
// ValidateSkillDocument 解析并校验一份 SKILL.md(供组合根以外的消费方
// 复用同一校验器:插件技能装载、种子写入前置检查)。dirName 是技能目录名
// (name 必须与之相等);path 只用于错误定位。
func ValidateSkillDocument(dirName, path string, data []byte) error {
if len(data) > maxBodyBytes {
data = data[:maxBodyBytes]
}
fm, _, err := parseFrontmatter(path, string(data))
if err != nil {
return err
}
return validateFrontmatter(path, dirName, fm)
}

Some files were not shown because too many files have changed in this diff Show More