Compare commits

...
Author SHA1 Message Date
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
weichen 16a164a594 Merge remote-tracking branch 'origin/main' into feature/sillytavern-review
ci / vet-and-test (pull_request) Successful in 49s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
2026-10-04 19:42:54 +00:00
weichen 3a110a728c Shape provider messages before they are sent
ci / vet-and-test (pull_request) Successful in 51s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: Some endpoints reject consecutive system messages or a run of the same role. The product already emits several system messages in one request.
Evidence: go test is covered by PrepareMessages tests for merge, strict folding, and tool pairs. Empty MessageShape leaves the transcript unchanged.
Impact: Callers opt in with StreamOptions.MessageShape. Tool calls and tool results are not merged.
Refs: docs/records/2026-10-04-sillytavern-review.md
2026-10-04 18:18:57 +00:00
weichen ecca2d74b9 Merge pull request 'Add a script host, deferred tools, and background MCP connect' (#15) from feature/code-orchestration into main
ci / vet-and-test (push) Successful in 48s
ci / docker-checkout-hostgateway (push) Successful in 1s
2026-10-03 18:07:11 +00:00
weichenandCursor e4020d297c Add a script host, deferred tools, and background MCP connect
ci / vet-and-test (pull_request) Successful in 49s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: models need a capped way to call several tools from one script, and a slow MCP server must not block the first prompt.
Evidence: GOWORK=off go test ./runtime/codeorch/ ./runtime/tools/ ./runtime/mcp/ ./runtime/policy/ ./adapters/native/
Impact: run_tool_script stays unregistered until the caller turns it on. Deferred tools stay out of the model list. An unready MCP server connects in the background.
Refs: feature/code-orchestration
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 18:02:53 +00:00
weichen b6d6708416 Merge pull request 'Record tool intent before execution and add file checkpoints' (#14) from feature/durable-rewind into main
ci / vet-and-test (push) Successful in 45s
ci / docker-checkout-hostgateway (push) Successful in 1s
2026-10-03 17:40:14 +00:00
weichenandCursor fb2b3b796c Record tool intent before execution and add file checkpoints
ci / vet-and-test (pull_request) Successful in 46s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: a crash after a tool was about to run left the call unpaired, and workspace writes could not be rewound.
Evidence: GOWORK=off go test ./runtime/ ./runtime/llm/ ./runtime/checkpoint/ ./runtime/telemetry/ ./runtime/tools/ ./runtime/session/ ./adapters/native/
Impact: pending calls rerun only when both the stored and current replay declarations are safe; otherwise the result is an error. Empty fallback lists and a nil checkpoint log keep the previous behavior.
Refs: feature/durable-rewind
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 17:27:04 +00:00
weichen ba90a7d9ae Merge pull request 'Add a hook bus and permission decisions for tool calls' (#13) from feature/permission-hooks into main
ci / vet-and-test (push) Successful in 46s
ci / docker-checkout-hostgateway (push) Successful in 1s
2026-10-03 17:06:47 +00:00
weichenandCursor 0a52d9f976 Add a hook bus and permission decisions for tool calls
ci / vet-and-test (pull_request) Successful in 46s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: Squad members never saw the direct-run stop, reminder, and before-tool gates, and there was no shared place to decide deny, ask, or allow.
Evidence: GOWORK=off go test ./runtime/hooks/ ./runtime/policy/ ./runtime/ ./adapters/native/ passed, including an A/B run where hook adapters match the old callbacks.
Impact: An empty bus leaves stop reason, reminder count, and deny count unchanged. Callers opt in by setting GateOptions.
Refs: feature/permission-hooks
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 17:02:28 +00:00
weichen 3851bfb8a7 Merge pull request 'Keep tool pairs and fold reminders into the last result' (#12) from feature/context-engineering into main
ci / vet-and-test (push) Successful in 46s
ci / docker-checkout-hostgateway (push) Successful in 1s
2026-10-03 16:25:36 +00:00
weichenandCursor 6fd1d441a9 Keep tool pairs and fold reminders into the last result
ci / vet-and-test (pull_request) Successful in 45s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: Old tool results were filling the window, compaction could split a tool group or commit a stale summary, and reminders arrived as extra user messages.
Evidence: Compaction retries a conflict and opens the circuit after three failures; clearing keeps the last five tool results; a stale file write is rejected; RPC reports started, queued, or handled.
Impact: Requests over the context window replace older tool results with a placeholder. Rewriting an existing file now requires a read in the same run. Subagent text over 32000 characters is capped.
Refs: feature/context-engineering
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 16:19:36 +00:00
weichen 9335fd40e6 Merge pull request 'Retry model streams only before the first delta' (#11) from feature/llm-robustness into main
ci / vet-and-test (push) Successful in 47s
ci / docker-checkout-hostgateway (push) Successful in 1s
2026-10-03 15:56:14 +00:00
weichenandCursor 9f5751c205 Retry model streams only before the first delta
ci / vet-and-test (pull_request) Successful in 47s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: Buffering a whole attempt hid live output, overflow detection missed common provider errors, and a cut-off tool call could still run.
Evidence: GOWORK=off go test -race -count=1 ./runtime/llm/ ./runtime/ passed.
Impact: WithRetry forwards events immediately and retries only when nothing has been forwarded. Length or an unclean end drops unfinished tool calls. Loop usage keeps a billing sum and the last request separately.
Refs: feature/llm-robustness
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 15:52:08 +00:00
weichen e51f2e6d00 Merge pull request 'Drop the intent adapter and fold contract into exec' (#10) from feature/agent-stack-d1 into main
ci / vet-and-test (push) Successful in 51s
ci / docker-checkout-hostgateway (push) Successful in 1s
2026-10-03 14:02:32 +00:00
weichenandCursor d84a67cd24 Treat a cancelled SSE stream as aborted
ci / vet-and-test (pull_request) Successful in 48s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: the race detector can deliver message_end when the context is already cancelled, because select picks either ready case.
Evidence: GOWORK=off go test -count=50 -race ./runtime/llm/ passed, including TestParseSSECancel.
Impact: a cancelled parse ends with the aborted event.
Refs: D1
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 13:51:41 +00:00
weichenandCursor 33573d0d75 Pin agent-intent to the IntentSpec schema sample
ci / vet-and-test (pull_request) Failing after 48s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: the nested checkout has to match the intent head that publishes the sample, even though native no longer imports it.
Evidence: gitlink only. The schema test already passed in the intent repository.
Impact: no native source change.
Refs: D1
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 13:43:32 +00:00
weichenandCursor 4a10818535 Drop the intent adapter and fold contract into exec
Why: native must compile without agent-intent, and the two execution packages were the same contract under two names.
Evidence: GOWORK=off go test -count=1 ./... passed, including schema.TestRequestAndResultRoundTrip and the native adapter tests after Options was split into embedded sub-structs.
Impact: adapters/intentgate is gone, go.mod no longer requires agent-intent, and the nested gitlink stays for the release task. Callers of Options composite literals name the sub-struct.
Refs: D1
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 13:31:48 +00:00
weichen d9b0f8f178 Close the native P1 gaps the audit listed for this batch
ci / vet-and-test (push) Successful in 48s
ci / docker-checkout-hostgateway (push) Successful in 0s
Merge via API
2026-10-03 13:22:54 +00:00
weichenandCursor 855fe41152 Record the golang.org/x/text sum native inherits from intent
ci / vet-and-test (pull_request) Successful in 53s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: vet of this module failed once intent started importing NFKC, because the sum was only in the intent module.
Evidence: GOWORK=off go vet ./... and go test -count=1 ./runtime/... ./adapters/... ./exec/... passed after go get golang.org/x/text@v0.29.0.
Impact: go.mod gains an indirect require and go.sum is created. Runtime behavior is unchanged.
Refs: I4
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 13:13:34 +00:00
weichenandCursor 11d1a76bdd Pin agent-intent to the R2 catalog and fold fixes
ci / vet-and-test (pull_request) Failing after 9s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: native must build against the intent head that deep-copies specs and hides private catalog metadata.
Evidence: deps/agent-intent is 05e74d8, the feature/agent-stack-r2 head whose race tests passed.
Impact: the nested intent gitlink moves; native behavior otherwise stays on 17f2a4e.
Refs: I4, I7, I8, I9
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 13:06:04 +00:00
weichenandCursor 17f2a4ecfa Close the native P1 gaps the audit listed for this batch
Why: token budget let the current tool batch run, compaction could split a tool pair or race an append, writes were not crash-atomic, and several MCP and session edges only existed as comments.
Evidence: GOWORK=off go test -count=1 -race ./runtime/ ./runtime/llm/ ./runtime/session/ ./runtime/tools/ ./runtime/mcp/ ./adapters/native/ ./adapters/intentgate/ passed, including the TestR2* cases for budget, replacement, cancel, compaction, SSE, UTF-8, schema, MCP retry, session terminate, poison reload, and registry reload.
Impact: over-budget tool calls are skipped and paired; Stop.Replacement is appended to the session; the intent gate can follow Registry.Reload.
Refs: N5, N6, N7, N8, N9, N11, N12, N15, N16, N17, N18, N19, N21, I9
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 13:05:58 +00:00
weichen b1f89bf969 Merge pull request #8 from feature/agent-stack-r0-r1 into main
ci / vet-and-test (push) Successful in 48s
ci / docker-checkout-hostgateway (push) Successful in 1s
CI on the head commit is success.
2026-10-03 12:47:35 +00:00
weichenandCursor 56848f3cbd Clone the repo in CI without GitHub-hosted actions
ci / vet-and-test (pull_request) Successful in 48s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Why: The runner cannot reach github.com, so cloning actions/checkout timed out before any test ran.
Evidence: The Go 1.27.1 tarball is available from mirrors.aliyun.com. Checkout uses the job token and GITHUB_SHA.
Impact: Submodule checkout still requires SUBMODULE_TOKEN. A missing secret fails the job with that message.
Refs: feature/agent-stack-r0-r1
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 10:31:41 +00:00
weichenandCursor 91c1556184 Pin agent-intent to the CI checkout fix
ci / vet-and-test (pull_request) Failing after 31s
ci / docker-checkout-hostgateway (pull_request) Successful in 22s
Why: Native must build the intent commit that fetches submodules over HTTPS.
Evidence: deps/agent-intent is 7f7879b06b053f0a62746309b79e0e1b9a00f42d.
Impact: The gitlink matches the open intent pull request head. Nested guideline pins are unchanged.
Refs: feature/agent-stack-r0-r1
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 10:20:13 +00:00
weichenandCursor 0773a25ab9 Fetch CI submodules over HTTPS and run the race detector
ci / vet-and-test (pull_request) Failing after 31s
ci / docker-checkout-hostgateway (pull_request) Failing after 31s
Why: Checkout did not recurse, so go vet failed without deps/agent-intent, and the runner has no SSH key.
Evidence: Workflow uses recursive HTTPS checkout, setup-go 1.27, GOPROXY=https://goproxy.cn,direct, and PROJECT.md vet plus test plus race.
Impact: vet-and-test runs on ubuntu-latest. SUBMODULE_TOKEN must be set by the owner before checkout can succeed.
Refs: feature/agent-stack-r0-r1
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 10:19:53 +00:00
weichenandCursor 694b08cdbe Pair skipped tool calls and close the stdio and fetch gaps
ci / vet-and-test (pull_request) Failing after 2s
ci / docker-checkout-hostgateway (pull_request) Successful in 2s
Why: a tool-budget stop left tool calls without results, host callbacks raced, a canceled MCP read stole the next response, and fetch re-resolved after the SSRF check.
Evidence: GOWORK=off go vet ./... && go test -count=1 ./... && go test -race ./runtime/... ./adapters/native/... passed, including TestLoopBudgetToolCallCap, TestParallelHostCallbacksAreSerial, TestStdioSlowCancelThenEcho, TestPresetsAndClientForChatCompletions, TestFetchResolveChangesToLoopbackRejected, and TestFetchHostCheckRedirectStillRejectsPrivate.
Impact: over-budget calls get an error result and no tool event; one Run serializes host callbacks; MCP dispatches by id; anthropic and google presets use OpenAI-compatible chat endpoints; fetch dials the address it just checked. deps/agent-intent is 3130190; error-code-system and logging-system are a3ee67b and 49b5d8b.
Refs: N1, N2, N3, N4, N13, N14
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-03 01:10:29 +00:00
weichen 7e0374ff40 Merge pull request 'Let length continuations and Anthropic output run unbounded' (#7) from feature/stream-display-overhaul into main
ci / vet-and-test (push) Failing after 2s
ci / docker-checkout-hostgateway (push) Successful in 2s
2026-10-02 05:04:53 +00:00
weichen e047d90985 Merge remote-tracking branch 'origin/main' into feature/stream-display-overhaul
ci / vet-and-test (pull_request) Failing after 3s
ci / docker-checkout-hostgateway (pull_request) Successful in 2s
2026-10-02 05:04:44 +00:00
weichenandCursor c6d867cde7 Let length continuations and Anthropic output run unbounded
ci / vet-and-test (pull_request) Failing after 1m30s
ci / docker-checkout-hostgateway (pull_request) Failing after 7m5s
Why: A fixed continuation cap and a 4096-token Anthropic fallback stopped long replies before the agent finished.
Evidence: go test ./runtime/ ./runtime/llm/
Impact: MaxLengthContinuations of 0 keeps the old cap of 3; a negative value removes it. Unset Anthropic max_tokens defaults to 128000.
Refs: feature/stream-display-overhaul
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-02 02:11:43 +00:00
weichen 1a999c344d Merge pull request 'Add optional harness hooks to the native turn loop' (#6) from feature/agent-harness-p1 into main
ci / vet-and-test (push) Failing after 2s
ci / docker-checkout-hostgateway (push) Successful in 2s
2026-10-01 13:41:33 +00:00
weichenandCursor a34689c78f Delegate unregistered tool calls when a fallback is set
ci / vet-and-test (pull_request) Failing after 2m48s
ci / docker-checkout-hostgateway (pull_request) Failing after 31s
Why: the direct chat path still executes model-emitted calls that are not in the profile allow-list, while squad keeps the unknown-tool error when the fallback is unset.
Evidence: go test ./runtime/ passed, including TestLoopUnknownToolFallsThrough and the existing unknown-tool cases.
Impact: Config.UnknownTool is optional. Nil preserves today's registry error.
Refs: feature/agent-harness-p1
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-01 13:27:36 +00:00
weichenandCursor e2815bbf94 Report token usage on each message end
Why: the direct-path adapter accounts usage per turn, including runs that stop on a budget error before the final usage event.
Evidence: go test ./runtime/ ./runtime/llm/ after the field was added.
Impact: message_end and usage events carry TotalTokens. Callers that ignore the new field are unchanged.
Refs: feature/agent-harness-p1
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-01 13:25:58 +00:00
weichenandCursor 35f65a3c49 Add optional stop, reminder, and continuation hooks to the turn loop
Why: the direct chat path needs one shared loop for deliverable checks, tool pairing, reminders, and truncated replies, while squad callers keep today's behavior when the new fields stay unset.
Evidence: go test ./... in agent-native passed, including Stop cap, BeforeTool pairing, reminders absent from the session, repeat-guard cap, length continuation, and zero-value loop tests.
Impact: runtime.Config and llm.Event grow optional fields; AgentSession.Prompt copies them through. Squad sessions that leave them zero are unchanged.
Refs: feature/agent-harness-p1
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-01 13:23:07 +00:00
weichen 886e86c6a8 Add the headless agent session, providers, and RPC
ci / vet-and-test (push) Failing after 1s
ci / docker-checkout-hostgateway (push) Successful in 2s
2026-10-01 10:30:41 +00:00
weichenandCursor 6b7d2432b3 Serve print, JSON, and RPC modes on AgentSession
ci / vet-and-test (pull_request) Failing after 2s
ci / docker-checkout-hostgateway (pull_request) Successful in 2s
Why: The library had no process entry, and the product backend still called the loop directly.
Evidence: go test -count=1 ./...
Impact: exec.Backend keeps the same request and result. Execute now goes through AgentSession. Extensions and packages are not a sandbox.
Refs: feature/pi-headless N6 N7
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-01 10:16:00 +00:00
weichenandCursor 23b5e3f336 Add an extension process protocol and package manager
Why: Tools, hooks, and providers need a way in without a Go rebuild, and packages need install, update, and removal.
Evidence: go test -count=1 ./runtime/ext/
Impact: Extensions run as subprocesses with the caller's privileges. They are not a sandbox.
Refs: feature/pi-headless N5
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-01 10:13:48 +00:00
weichenandCursor dfd0d23bf3 Add pi tool aliases, context files, and project trust
Why: Headless runs need the pi tool names, walked context files, prompt includes, and a trust gate before project settings are loaded.
Evidence: go test -count=1 ./runtime/tools/ ./runtime/resources/ ./runtime/skills/
Impact: Existing tool names stay registered. Aliases are added only when the underlying tool exists.
Refs: feature/pi-headless N4
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-01 10:12:26 +00:00
weichenandCursor 2dedc0d20c Add an AgentSession with steer and follow-up queues
Why: Steering hooks existed on the loop but nothing owned the queues, abort, or a model change during a run.
Evidence: go test -count=1 ./runtime/
Impact: Run still emits the existing event names. message_start, tool_execution_update, and agent_settled are added beside them.
Refs: feature/pi-headless N3
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-01 10:11:19 +00:00
weichenandCursor e7ab48e8ea Add native provider protocols and retry
Why: The runtime could only speak OpenAI chat completions, so thinking levels, images, and the other vendor wires had nowhere to go.
Evidence: go test -count=1 ./runtime/llm/
Impact: Existing chat-completions calls stay the same unless a thinking level or image is set. Overflow errors are not retried.
Refs: feature/pi-headless N2
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-01 10:10:02 +00:00
weichenandCursor 6bc1b08540 Record sessions as an append-only entry tree
Why: Linear transcripts cannot fork, and compaction rewrote history, so resume and branch navigation had nothing to point at.
Evidence: go test -count=1 ./runtime/session/
Impact: Load still returns the model-visible path, including old one-message-per-line files. Compaction appends a summary entry instead of truncating the file.
Refs: feature/pi-headless N1
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-01 10:08:04 +00:00
weichen ec731addef Let hosts register tools and observe token usage
ci / vet-and-test (push) Failing after 1s
ci / docker-checkout-hostgateway (push) Successful in 2s
2026-10-01 09:35:54 +00:00
weichenandCursor cfc840c11d Expose the step directory and a tool-call cap
ci / vet-and-test (pull_request) Failing after 2s
ci / docker-checkout-hostgateway (pull_request) Successful in 2s
Why: Host tools need the kernel workspace to register files the step just wrote, and product budgets must stop a step that keeps calling tools.
Evidence: go test ./adapters/native/ ./runtime/ -count=1 passed, including the workdir visibility test.
Impact: Host tools can read WorkDir from context. MaxToolCalls limits one kernel step when the request budget leaves it unset.
Refs: deliverable-quality-system B1
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-01 09:06:22 +00:00
weichenandCursor 951c141f95 Let hosts register tools and observe token usage
Why: product runners need register_artifact and card tools inside the kernel loop, and the UI needs token usage on the same event stream as text and tool calls.
Evidence: go test ./... in agent-native; TestHostToolAndUsageEvent and TestHostToolNameConflictFailsClosed.
Impact: Options.HostTools fail closed on name clashes; ProcessEvent and runtime events gain a usage type with token counts. Existing callers that ignore unknown event types stay compatible.
Refs: deliverable-quality-system B1
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-01 08:57:21 +00:00
weichen f9ab2ef698 Merge pull request 'Land the full-agent capability batch (B1-B8): port v0.2, tool surface, exec tools, network, MCP, skills, compaction, delegation' (#3) from feature/full-agent-r1 into main
ci / vet-and-test (push) Failing after 2s
ci / docker-checkout-hostgateway (push) Successful in 2s
2026-10-01 05:53:57 +00:00
weichen 001648cd0e Re-baseline the parity matrix for the full-agent expansion and update the README
ci / vet-and-test (pull_request) Failing after 1s
ci / docker-checkout-hostgateway (pull_request) Successful in 2s
Why: the matrix scoped native work to the pi wrapper's exposed surface;
the full-agent batch deliberately expands beyond it, so the new domains
need their own acceptance basis recorded (standard conformance +
negative tests + product acceptance, not pi comparison) plus honest
security boundary notes.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-29 14:20:37 +00:00
171 changed files with 19806 additions and 329 deletions
+38 -5
View File
@@ -6,15 +6,48 @@ on:
branches: [main]
jobs:
vet-and-test:
runs-on: nightly
runs-on: ubuntu-latest
env:
GOPROXY: https://goproxy.cn,direct
steps:
- uses: actions/checkout@v4
- run: go vet ./... && go test -count=1 ./...
- name: Checkout
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
SUBMODULE_TOKEN: ${{ secrets.SUBMODULE_TOKEN }}
run: |
set -euo pipefail
git init -q
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
git -c http.extraheader="AUTHORIZATION: token ${TOKEN}" fetch --depth=1 origin "${GITHUB_SHA}"
git checkout -q --detach FETCH_HEAD
if [ -z "${SUBMODULE_TOKEN}" ]; then
echo "SUBMODULE_TOKEN is not configured" >&2
exit 1
fi
git config --global url."https://weichen:${SUBMODULE_TOKEN}@git-home.u1680832.nyat.app:30319/".insteadOf "ssh://git@git-ssh.u1680832.nyat.app:31411/"
git -c fetch.recurseSubmodules=false submodule update --init --recursive
- name: Install Go 1.27.1
run: |
set -euo pipefail
curl -fsSL -o "$RUNNER_TEMP/go.tgz" https://mirrors.aliyun.com/golang/go1.27.1.linux-amd64.tar.gz
tar -C /usr/local -xzf "$RUNNER_TEMP/go.tgz"
ln -sfn /usr/local/go/bin/go /usr/local/bin/go
ln -sfn /usr/local/go/bin/gofmt /usr/local/bin/gofmt
- name: Vet, test, and race
run: go vet ./... && go test -count=1 ./... && go test -count=1 -race ./...
docker-checkout-hostgateway:
runs-on: ubuntu-latest
options: --add-host gitea:host-gateway
env:
GITHUB_SERVER_URL: http://gitea:3000
steps:
- uses: actions/checkout@v4
- run: test -f go.mod && echo "docker-mode checkout via host-gateway OK"
- name: Checkout without GitHub actions
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
git init -q
git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git"
git -c http.extraheader="AUTHORIZATION: token ${TOKEN}" fetch --depth=1 origin "${GITHUB_SHA}"
git checkout -q --detach FETCH_HEAD
test -f go.mod && echo "docker-mode checkout via host-gateway OK"
+12
View File
@@ -0,0 +1,12 @@
[submodule "deps/development-guidelines"]
path = deps/development-guidelines
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Development-Guidelines.git
[submodule "deps/error-code-system"]
path = deps/error-code-system
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Error-Code-System.git
[submodule "deps/logging-system"]
path = deps/logging-system
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Logging-System.git
[submodule "deps/agent-intent"]
path = deps/agent-intent
url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Intent.git
+36
View File
@@ -0,0 +1,36 @@
# Agent 工作说明(Agent-Native)
本仓开发遵循 Development-Guidelines(Dev 仓 `deps/development-guidelines`,gitlink 固定版本):
核心规则见其 `core/`,按任务触发阅读 `standards/` 专题;日常交付走 `workflows/delivery.md`。
## 任务启动门禁
- 任务启动或恢复后,第一次写操作前在 Dev 仓运行 `make precheck` 并附完整输出(本仓作为子仓随父仓编排);
本仓独立开发时核对工作树 clean、与权威 origin/main 同步,并按任务触发读对应专题。
- 从权威 origin/main 固定共同起点 R0,建 feature|fix|hotfix/<slug> 分支(Dev 仓 `make task-start` 自动化);
任务中途不重复 precheck。
- 验收、请求审核、合入前各同步一次最新目标分支并附证据;合入后回收任务工作区与已合入分支。
- 提交主题行:英文祈使句一行(≤100 列,不以句点结尾);body 写 `Why: / Evidence: / Impact: / Refs:`。
## 仓内规则
- 模块边界、公开契约与隔离档位见 `policy/modules/`(如有)与 PROJECT.md。
- 跨仓 gitlink 变更须经组合验证后在 Dev 仓推进;受保护分支经 PR 合入。
- 验证命令见 PROJECT.md「验证」节;完成声明须附实际命令输出证据。
## Gitea 与 Git 访问事实(2026-09-30 实测)
历史文档与旧链接里的 `git-panel.u1680832.nyat.app:30319` 已失效(TLS 握手失败),
见到一律按过期处理,不要再试。当前权威通道:
- **Web / API(HTTPS)**:`https://git-home.u1680832.nyat.app:30319`(Gitea 1.27.3,
API 前缀 `/api/v1`;偶发 broken pipe / 超时,重试即可)。
- **Git over SSH**:`ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/<Repo>.git`
(各仓 origin 与 gitlink 均为此格式)。
- **API token**:`~/.secrets/gitea`,两行键值 `URL_PREFIX=…` 与
`ACCESS_TOKEN=<40 位 hex>`;取值 `grep ACCESS_TOKEN ~/.secrets/gitea | cut -d= -f2-`,
用作 `Authorization: token <值>` 请求头。**token 值不入仓、不回显、不写进产出物**
(baseline 硬边界 2:只记录来源、字段与可用状态)。
- **SSH key**:`~/.ssh/home_gitea`(ED25519,公钥指纹
`SHA256:GYXaOXIMeloQ1fKSfdJ/ndgPQzs/Ix7Hk62ekfkfQ20`,Gitea 侧密钥名 GMKtec);
`~/.ssh/config` 已有 `git-ssh.u1680832.nyat.app` 主机条目,克隆/推送直接可用。
+53
View File
@@ -0,0 +1,53 @@
# Agent-Native 项目事实
工作规则见 [AGENTS.md](AGENTS.md)。此文件维护本项目事实,不复制通用规范。
## 仓库与操作
- 目标/产品边界:独立的 Agent 执行基础库。提供执行契约(contract)、执行端口(exec)、自研运行时(runtime)、外部适配器(adapters)与行为验收(conformance)。不实现小队编排,不依赖 Core 或 Squad。
- 源码、资产、文档、机器契约位置:Go 源码在仓库根包目录;模块路径 `agent-native`;人类文档在根 `README.md`、`docs/`、`policy/`;过程记录在 `docs/records/`;机器契约以 `contract` 包类型为准。
- 权威远端、集成基线、受保护分支:`ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Native.git`,集成基线 `main`。
- 并行共同基线记录位置、合入通知/主动检查方式:任务主记录在 Gitea Issue/PR;合入前在本仓 `main` 上 `git fetch` 并确认 HEAD。
- 发布线与批准版本/实际制品查询入口:当前无独立制品仓库。可构建性以本仓 `main` 的 `go test` 为准;依赖子模块版本以 gitlink 为准,不把工作区未提交文件当发布组合。
- 提交/审核/合并/发布身份与权限:仓库所有者 weichen。本机提交不代替远端合并。
- 任务唯一来源:Gitea `weichen/Agent-Native` 的 Issue 与 Pull Request。
- 规范性文档目录:`policy/`。
- 过程文档仓/目录:`docs/records/`(项目内豁免,理由见 [采纳记录](policy/guidelines-adoption.md))。
- 交接记录位置:随任务主记录;需要落盘的交接放入 `docs/records/`。
- 凭证查找入口及所需字段:模型与 Runner 凭证由调用方注入,不入库。本仓不保存密钥字段的值。
## 依赖组合
| 逻辑 ID | 路径 | 角色 |
|---|---|---|
| development-guidelines | `deps/development-guidelines` | development-only,规范文本 |
| error-code-system | `deps/error-code-system` | build-required,规范码目录 |
| logging-system | `deps/logging-system` | build-required,可选日志器 |
| agent-intent | `deps/agent-intent` | build-required,可选意图闸门装饰器(`adapters/intentgate`) |
克隆:
```bash
git clone --recurse-submodules ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Native.git
# 已克隆时:git submodule update --init --recursive
```
构建、审核和发布固定父仓 gitlink,不执行 `submodule update --remote`。
## 验证命令
| 影响 | 工作目录与真实命令/CI | 前提与不覆盖范围 |
|---|---|---|
| 文档 | 人工核对 `AGENTS.md`、`PROJECT.md`、`policy/` 相对链接 | 不证明运行时行为 |
| 模块/接口 | 仓库根:`go vet ./... && go test -count=1 ./...` | 需要 Go 1.27;不覆盖真实模型端点 |
| 数据/权限/恢复 | 不适用独立数据库。契约测试覆盖副作用分类与取消传播 | 不证明调用方产品库的持久化 |
| UI/执行后端 | `go test ./adapters/... ./runtime/... ./conformance/...` | 默认 fake/stub;真实模型需调用方提供端点,本命令不覆盖 |
## 环境与数据
| 环境/实例 ID | 只读查询入口 | 授权写入入口 | DB/存储归属 | 恢复入口 |
|---|---|---|---|---|
| 本地开发 | 本机工作区与 `go test` | 本机 git 提交 | 无共享数据库;会话文件由调用方 Workspace 决定 | 重新 clone 父仓并 `submodule update --init --recursive` |
共享项及 ACL:无。部署、迁移、发布的审批依据与负责角色:当前库不独立部署;合并进 `main` 由仓库所有者批准。
实际制品/配置/schema 的查询方法:阅读当前 `main` 的 `contract` 包与 `go.mod`,不以历史 README 快照代替。
+29 -7
View File
@@ -9,9 +9,9 @@
| 层 | 职责 | 依赖边界 |
|---|---|---|
| contract | 执行契约:Request/Result/Outcome/Error/Capability/Backend/Resolver | 不依赖内核、产品类型、数据库、配置 |
| exec | 执行端口:Backend/Request/Result/ProcessEvent、步骤重试与失败分类、交付物物化、Stub、并发限流 | 面向调用方的执行端口 |
| runtime | 多轮循环、工具调度、模型调用、执行会话 | 实现 contract;不导入 Core/Squad |
| contract | 执行契约:Request/Result/Outcome/Error/Capability/Backend/Resolver | 不依赖内核、产品类型、数据库、配置,也不导入错误码库 |
| exec | 执行端口:Backend/Request/Result/ProcessEvent、预算、步骤重试与失败分类、交付物物化、Stub、并发限流 | 面向调用方的执行端口 |
| runtime | 多轮循环、工具调度、模型调用、执行会话 | 不导入 Core/Squad;可选使用日志库,nil 日志器不改变结果 |
| adapters | PI Runner transport / typed-result-v1;native:自研内核 runtime → exec.Backend(规范后端名 agent-native,含会话毒化轮转——resume-unsafe 失败后自动换新会话) | 显式映射外部差异,不伪造缺失能力 |
| conformance | 所有 Backend 共用的行为验收 | fake 验证契约,真实 adapter 验证能力 |
@@ -23,9 +23,13 @@
|---|---|
| contract | 四态执行契约 |
| conformance | 行为验收套件与 fake |
| exec | Backend/Request/Result/ProcessEvent、步骤重试与失败分类、交付物物化、Stub、并发限流 |
| runtime | 多轮循环、llm、session、tools(含单测) |
| adapters/native | runtime 作为 exec.Backend,含会话毒化轮转 |
| exec | Backend/Request/Result/ProcessEvent(v0.2:Model/ToolAllowlist/Skills/Budget/SessionScope/ExecutionID/Env)、Budget、步骤重试与失败分类、交付物物化、Stub、并发限流 |
| runtime | 多轮循环(预算/取消贯穿、budget_exceeded 事件)、llm、session(compaction/元数据/崩溃安全)、tools(9 内置 + 策略层 Restrict) |
| runtime/mcp | MCP 客户端(stdio + Streamable HTTP)与 mcp__<server>__<tool> 桥接(server 元数据不可信、上限收敛) |
| runtime/skills | SKILL.md 技能加载与 load_skill 渐进披露 |
| runtime/promptx | 系统提示结构化装配(环境事实 + 技能索引 + 交付物约束) |
| runtime/subagent | 委派端口(Request/Result/Factory)与 delegate_task 工具;嵌套执行由宿主注入 |
| adapters/native | runtime 作为 exec.Backend,含会话毒化轮转、compaction、技能/搜索/执行/MCP/委派装配(Options 显式注入,未配置不注册) |
| adapters/pitransport | typed-result-v1 编解码、远程结果解析、Runner 搜索、鉴权头(含 Node 摘要交叉验证测试) |
本模块不依赖 Core 或 Squad。上层产品依赖本模块的 contract 与 exec。
@@ -37,6 +41,22 @@
- 失败副作用显式分类(none/possible);possible 禁止无条件重放。
- 重试单一责任层:步骤级归调用方,传输级有界归 adapter,turn 上限归 runtime。
- Workspace 路径授权 ≠ OS 沙箱。
- `contract.ErrorCode` 取值是 `agent.*` 规范码。`ErrorCode()` / `ErrorFields()` 供日志库识别,本包不导入错误码库。
## 依赖子模块
| 路径 | 作用 |
|---|---|
| `deps/development-guidelines` | 开发规范,不参与编译 |
| `deps/error-code-system` | 规范码目录。`go.mod` 的 `replace` 指向这里 |
| `deps/logging-system` | 结构化日志。runtime 与 native 适配器可选使用 |
版本以本仓 gitlink 为准。`scripts/submodule-consistency.sh` 核对 gitlink 与检出的 HEAD。
```bash
git clone --recurse-submodules ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Native.git
# 已克隆时:git submodule update --init --recursive
```
## CodeGraph
@@ -58,9 +78,11 @@ go test ./...
```
require agent-native v0.0.0
replace agent-native => ../Agent-Native
replace agent-native => ./deps/agent-native
```
本仓自己的 `go.mod` 则把错误码库和日志库替换到 `./deps/`。消费方若同时依赖这三仓,必须在消费方 `go.mod` 里写自己的 `replace`:依赖模块内部的 `replace` 不会在上层构建中生效。
远程:`ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Native.git`
## CI
+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
+421 -46
View File
@@ -14,20 +14,92 @@ import (
"agent-native/exec"
"agent-native/runtime"
"agent-native/runtime/checkpoint"
"agent-native/runtime/filestate"
"agent-native/runtime/hooks"
"agent-native/runtime/llm"
"agent-native/runtime/mcp"
"agent-native/runtime/promptx"
"agent-native/runtime/session"
"agent-native/runtime/skills"
"agent-native/runtime/subagent"
"agent-native/runtime/tools"
"logging-system/logsys"
)
// Options 构造后端所需的模型接入与执行参数(由组合根注入,无产品依赖)。
type Options struct {
// ModelOptions 是模型接入。Stream 非空时不再按 APIBase/APIKey 构造客户端。
type ModelOptions struct {
APIBase string
APIKey string
Model string
MaxTurns int
Timeout time.Duration
// Stream 测试注入的流函数;缺省按 APIBase/APIKey 构造 llm.Client。
Stream llm.StreamFunc
Stream llm.StreamFunc
}
// ToolOptions 是可选工具装配。nil 字段不注册对应工具。
type ToolOptions struct {
Search tools.SearchProvider
Exec *tools.ExecConfig
Fetch *tools.FetchConfig
MCP *mcp.Manager
SkillsRoots []string
Subagent subagent.Factory
HostTools []tools.Tool
Checkpoints *checkpoint.Log
CheckpointAnchor func() string
// CodeOrchestration 为真时注册 run_tool_script。默认关。
CodeOrchestration bool
}
// SessionOptions 是单次执行的会话预算与压缩。
type SessionOptions struct {
Compact *CompactConfig
MaxToolCalls int
}
// ObserveOptions 是日志。Log 为 nil 时丢弃。
type ObserveOptions struct {
Log *logsys.Logger
ExecutionID string
// Trace 交给运行循环。nil 不采集。
Trace func(name string, attrs map[string]string) func()
}
// GateOptions 把直连已经使用的停止、提醒和工具钩子交给小队成员。零值不改变行为。
type GateOptions struct {
BeforeTool func(call llm.ToolCall) error
AfterTool func(call llm.ToolCall, res llm.Message)
Stop func(last llm.Message, info runtime.StopInfo) runtime.StopDecision
Reminders func(turn int) []string
RepeatGuard bool
Hooks *hooks.Bus
// Ask 在 MCP 工具要求用户输入时调用。nil 则把问题作为错误返回。
Ask func(ctx context.Context, question string) (string, error)
// PermissionAsk 在内核钩子对工具表态 ask 时向宿主请求裁决。
// nil 时内核保持旧行为:ask 与 deny 一样按拒绝处理。
PermissionAsk func(ctx context.Context, call llm.ToolCall) runtime.AskVerdict
}
// Options 构造后端所需的模型接入与执行参数(由组合根注入,无产品依赖)。
// 字段经嵌入提升,读取仍写 opts.Model;复合字面量要写子结构名。
type Options struct {
ModelOptions
ToolOptions
SessionOptions
ObserveOptions
GateOptions
}
// CompactConfig 声明长会话压缩策略(优化性质:压缩失败不阻断执行,但留日志)。
type CompactConfig struct {
// ThresholdChars 触发阈值(按字节数近似),默认 300_000。
ThresholdChars int
// KeepLastN 压缩后保留的最近消息条数,默认 8。
KeepLastN int
// Summarizer 可选注入摘要函数;缺省用后端 stream 做一次无工具调用。
Summarizer func(ctx context.Context, messages []llm.Message) (string, error)
}
// Backend 以自研内核实现 exec.Backend。
@@ -35,10 +107,23 @@ type Backend struct {
opts Options
// lastFailure 记录各工作目录上次失败原因,用于会话毒化检测
// (context_overflow 等错误续跑同一会话只会复现失败,须换新会话)。
mu sync.Mutex
mu sync.Mutex
lastFailure map[string]string
}
type workDirKey struct{}
// WithWorkDir 把本步工作目录放进上下文,供宿主工具读取内核刚写出的文件。
func WithWorkDir(ctx context.Context, dir string) context.Context {
return context.WithValue(ctx, workDirKey{}, dir)
}
// WorkDir 返回 WithWorkDir 写入的目录。没有时返回空串。
func WorkDir(ctx context.Context) string {
dir, _ := ctx.Value(workDirKey{}).(string)
return dir
}
// New 创建 native 后端。
func New(opts Options) *Backend {
return &Backend{opts: opts, lastFailure: map[string]string{}}
@@ -47,6 +132,13 @@ func New(opts Options) *Backend {
// Name 返回规范后端名。
func (b *Backend) Name() string { return "agent-native" }
// Close 释放后端持有的长连接资源(MCP server 子进程等)。
func (b *Backend) Close() {
if b.opts.MCP != nil {
b.opts.MCP.Close()
}
}
// Execute 运行一轮带工具的自研智能体循环,产出目标交付物。
func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result, error) {
if len(req.Targets) == 0 {
@@ -62,12 +154,33 @@ func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result,
if b.opts.Stream == nil && (strings.TrimSpace(b.opts.APIBase) == "" || strings.TrimSpace(b.opts.APIKey) == "") {
return &exec.Result{Success: false, ErrorReason: "agent-native backend requires api base/key (or injected stream)"}, nil
}
if strings.TrimSpace(b.opts.Model) == "" {
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
}
workDir := req.WorkDir
if !filepath.IsAbs(workDir) {
if strings.TrimSpace(workDir) == "" {
// 空工作区 = 请求未授权持久工作区:会话状态(.agentx/transcript 等)
// 落一次性临时目录,不污染进程 CWD(契约套件即发此类请求)。
tmp, err := os.MkdirTemp("", "agent-native-run-*")
if err != nil {
return &exec.Result{Success: false, ErrorReason: err.Error()}, nil
}
workDir = tmp
} else if !filepath.IsAbs(workDir) {
wd, _ := os.Getwd()
workDir = filepath.Join(wd, workDir)
}
@@ -82,9 +195,64 @@ func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result,
_ = os.WriteFile(filepath.Join(workDir, "TASK.md"), []byte(req.Prompt), 0o644)
}
// 技能:全局根 + 工作区 .agent/skills;声明的技能必须存在(fail-closed)。
roots := make([]string, 0, len(b.opts.SkillsRoots)+1)
roots = append(roots, b.opts.SkillsRoots...)
roots = append(roots, filepath.Join(workDir, ".agent", "skills"))
skillStore := skills.NewStore(roots...)
skillIndex := skillStore.List()
indexSet := make(map[string]bool, len(skillIndex))
for _, s := range skillIndex {
indexSet[s.Name] = true
}
for _, name := range req.Skills {
if !indexSet[name] {
return &exec.Result{Success: false, ErrorReason: "declared skill not installed: " + name}, nil
}
}
var reg *tools.Registry
if profile == exec.ToolProfileWorkspace {
reg = tools.WorkspaceTools(workDir)
reg = tools.WorkspaceToolsCheckpointed(workDir, filestate.New(), b.opts.Checkpoints, b.opts.CheckpointAnchor)
skills.RegisterLoader(reg, skillStore)
if b.opts.Search != nil {
tools.RegisterSearch(reg, b.opts.Search)
}
if b.opts.Fetch != nil {
tools.RegisterFetch(reg, *b.opts.Fetch)
}
if b.opts.Exec != nil {
execCfg := *b.opts.Exec
for k, v := range req.Env {
execCfg.Env = append(execCfg.Env, k+"="+v)
}
tools.RegisterExecTools(reg, workDir, execCfg)
}
if b.opts.CodeOrchestration {
tools.RegisterCodeTool(reg)
}
reg.SetActivationFile(filepath.Join(workDir, ".agentx", "activated-tools.json"))
_ = reg.RestoreActivated()
if b.opts.MCP != nil {
switch {
case len(req.ToolAllowlist) > 0 || b.opts.MCP.Ready():
if err := b.opts.MCP.RegisterInto(ctx, reg); err != nil {
return &exec.Result{Success: false, ErrorReason: err.Error()}, nil
}
default:
b.opts.MCP.StartBackground(ctx, reg)
}
}
subagent.Register(reg, b.opts.Subagent)
for _, tool := range b.opts.HostTools {
if reg.Has(tool.Name()) {
return &exec.Result{Success: false, ErrorReason: "host tool name conflicts with a builtin: " + tool.Name()}, nil
}
reg.Register(tool)
}
if err := reg.Restrict(req.ToolAllowlist); err != nil {
return &exec.Result{Success: false, ErrorReason: err.Error()}, nil
}
}
// 会话毒化轮转:上次失败属 resume-unsafe 时,把旧 transcript 归档,
// 本次以全新会话开始(续跑毒化会话只会复现同样的失败)。
@@ -98,21 +266,56 @@ func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result,
client := &llm.Client{BaseURL: b.opts.APIBase, APIKey: b.opts.APIKey}
stream = client.Stream
}
_ = store.SaveMeta(map[string]string{
"model": model,
"scope": req.SessionScope,
"exec_id": req.ExecutionID,
"updated": time.Now().UTC().Format(time.RFC3339),
})
b.maybeCompact(ctx, store, stream, model)
timeout := req.Timeout
if timeout <= 0 {
timeout = b.opts.Timeout
}
runCtx := ctx
var runBudget *runtime.Budget
if b.opts.MaxToolCalls > 0 {
runBudget = &runtime.Budget{MaxToolCalls: b.opts.MaxToolCalls}
}
if req.Budget != nil {
runBudget = &runtime.Budget{
MaxPromptTokens: req.Budget.MaxPromptTokens,
MaxCompletionTokens: req.Budget.MaxCompletionTokens,
MaxToolCalls: req.Budget.MaxToolCalls,
Deadline: req.Budget.Deadline,
}
if runBudget.MaxToolCalls == 0 {
runBudget.MaxToolCalls = b.opts.MaxToolCalls
}
if !req.Budget.Deadline.IsZero() {
// Deadline 与 Timeout 先到者生效:把更短者作为 ctx 超时。
if until := time.Until(req.Budget.Deadline); timeout <= 0 || until < timeout {
timeout = until
}
}
}
runCtx := WithWorkDir(ctx, workDir)
cancel := func() {}
if timeout > 0 {
runCtx, cancel = context.WithTimeout(ctx, timeout)
runCtx, cancel = context.WithTimeout(runCtx, timeout)
}
defer cancel()
execID := req.ExecutionID
if strings.TrimSpace(execID) == "" {
execID = b.opts.ExecutionID
}
var logs strings.Builder
if strings.TrimSpace(execID) != "" || strings.TrimSpace(req.SessionScope) != "" {
logs.WriteString("execution_id=" + execID + " session=" + req.SessionScope + "\n")
}
onProcess := req.OnEvent
runRes, err := b.run(runCtx, reg, store, stream, profile, req, &logs, onProcess)
runRes, err := b.run(runCtx, model, runBudget, reg, store, stream, profile, req, workDir, skillIndex, &logs, onProcess)
if err != nil {
return &exec.Result{Success: false, ErrorReason: err.Error(), Logs: logs.String()}, nil
}
@@ -132,18 +335,22 @@ func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result,
if runRes.Err != nil {
reason := runRes.Err.Error()
b.mu.Lock()
b.lastFailure[workDir] = reason
b.mu.Unlock()
b.noteFailure(workDir, reason)
assessment := exec.AssessStepFailure(exec.StepFailureSignals{
Err: runRes.Err,
WorkDir: workDir,
Result: &exec.Result{ErrorReason: reason, Retryable: runRes.Retryable, Logs: logs.String()},
})
b.logger().Error("native step failure classified", runRes.Err,
"failure_class", assessment.Class.String(),
"work_dir", workDir)
res := &exec.Result{Success: false, ErrorReason: reason, Logs: logs.String(), Retryable: runRes.Retryable}
if runRes.StopReason == llm.StopAborted {
res.Retryable = true
}
return res, nil
}
b.mu.Lock()
delete(b.lastFailure, workDir)
b.mu.Unlock()
b.clearFailure(workDir)
patches, matErr := exec.MaterializeTargets(workDir, req.Targets, nil)
if matErr != nil {
@@ -159,17 +366,30 @@ func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result,
}
// run 驱动 runtime 循环并桥接过程事件(语义对齐主仓 pinative)。
func (b *Backend) run(ctx context.Context, reg *tools.Registry, store *session.Store,
stream llm.StreamFunc, profile string, req exec.Request, logs *strings.Builder,
onProcess func(exec.ProcessEvent)) (runtime.Result, error) {
func (b *Backend) run(ctx context.Context, model string, budget *runtime.Budget, reg *tools.Registry, store *session.Store,
stream llm.StreamFunc, profile string, req exec.Request, workDir string, skillIndex []skills.Skill,
logs *strings.Builder, onProcess func(exec.ProcessEvent)) (runtime.Result, error) {
runRes, err := runtime.Run(ctx, runtime.Config{
Model: b.opts.Model,
SystemPrompt: systemPrompt(req, profile),
execID := req.ExecutionID
if strings.TrimSpace(execID) == "" {
execID = b.opts.ExecutionID
}
agent := runtime.NewAgentSession(runtime.Config{
Model: model,
SystemPrompt: b.systemPrompt(req, model, profile, workDir, skillIndex),
Tools: reg,
Stream: stream,
Session: store,
MaxTurns: b.opts.MaxTurns,
Log: b.logger(),
ExecutionID: execID,
Budget: budget,
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)
if ev.Tool != "" {
@@ -183,17 +403,125 @@ func (b *Backend) run(ctx context.Context, reg *tools.Registry, store *session.S
logs.WriteByte('\n')
if onProcess != nil {
onProcess(exec.ProcessEvent{
Type: ev.Type,
Text: ev.Text,
Tool: ev.Tool,
Detail: ev.Detail,
Type: ev.Type,
Text: ev.Text,
Tool: ev.Tool,
Detail: ev.Detail,
PromptTokens: ev.PromptTokens,
CompletionTokens: ev.CompletionTokens,
LastPromptTokens: ev.LastPromptTokens,
LastCompletionTokens: ev.LastCompletionTokens,
CacheReadTokens: ev.CacheReadTokens,
CacheWriteTokens: ev.CacheWriteTokens,
})
}
},
}, llm.Message{Role: llm.RoleUser, Content: req.Prompt})
return runRes, err
})
return agent.Prompt(ctx, llm.Message{Role: llm.RoleUser, Content: req.Prompt})
}
// maybeCompact 在转录超过阈值时压缩头部历史。压缩是优化性质:
// 失败不阻断执行(按原文继续),但必须留日志,不静默。
func (b *Backend) maybeCompact(ctx context.Context, store *session.Store, stream llm.StreamFunc, model string) {
cfg := b.opts.Compact
if cfg == nil {
return
}
threshold := int64(cfg.ThresholdChars)
if threshold <= 0 {
threshold = 300_000
}
keep := cfg.KeepLastN
if keep <= 0 {
keep = 8
}
if store.Stats().Bytes < threshold {
return
}
summarize := cfg.Summarizer
if summarize == nil {
summarize = defaultSummarizer(stream, model)
}
done, err := store.Compact(keep, func(messages []llm.Message) (string, error) {
return summarize(ctx, messages)
})
if err != nil {
b.logger().Warn("session compaction failed; continuing with full transcript", "error", err.Error())
return
}
if done {
b.logger().Info("session compacted", "keep_last", keep)
}
}
// defaultSummarizer 用后端 stream 做一次无工具调用,把历史压成摘要。
func defaultSummarizer(stream llm.StreamFunc, model string) func(ctx context.Context, messages []llm.Message) (string, error) {
return func(ctx context.Context, messages []llm.Message) (string, error) {
var b strings.Builder
for _, m := range messages {
b.WriteString(string(m.Role))
b.WriteString(": ")
b.WriteString(m.Content)
b.WriteString("\n")
if b.Len() > 120_000 {
break
}
}
prompt := "把以下智能体对话历史压缩成摘要:保留全部关键事实、已做决定、文件变更与未决问题;中文;不超过 800 字;只输出摘要正文。\n\n" + b.String()
var out strings.Builder
for ev := range stream(ctx, model, []llm.Message{{Role: llm.RoleUser, Content: prompt}}, nil, llm.StreamOptions{}) {
switch ev.Kind {
case llm.KindTextDelta:
out.WriteString(ev.Text)
case llm.KindError:
return "", ev.Err
}
}
if strings.TrimSpace(out.String()) == "" {
return "", errString("summarizer produced empty output")
}
return out.String(), nil
}
}
func gateBus(g GateOptions) *hooks.Bus {
bus := g.Hooks
if g.BeforeTool == nil && g.AfterTool == nil && g.Stop == nil {
return bus
}
if bus == nil {
bus = hooks.New()
}
if g.BeforeTool != nil {
fn := g.BeforeTool
bus.On(hooks.AdaptBefore(func(name, args string) error { return fn(llm.ToolCall{Name: name, Arguments: args}) }))
}
if g.AfterTool != nil {
fn := g.AfterTool
bus.On(hooks.AdaptAfter(func(name, args, result string, isError bool) {
fn(llm.ToolCall{Name: name, Arguments: args}, llm.Message{Content: result, IsError: isError})
}))
}
if g.Stop != nil {
fn := g.Stop
bus.On(func(c hooks.Call) hooks.Outcome {
if c.Event != hooks.Stop {
return hooks.Outcome{}
}
d := fn(llm.Message{Role: llm.RoleAssistant, Content: c.LastText}, runtime.StopInfo{Continuations: c.Continuations, ToolCalls: c.ToolCalls})
if d.Continue {
return hooks.Outcome{Decision: hooks.Block, Reason: d.Reason, Replacement: d.Replacement, Notice: d.Notice}
}
return hooks.Outcome{Decision: hooks.Allow, Notice: d.Notice}
})
}
return bus
}
type errString string
func (e errString) Error() string { return string(e) }
// resumeUnsafeMarkers 续跑不安全的关键词(会话已被污染:上下文溢出/
// 迭代上限/非法请求——重试必须换新会话而非续跑)。
var resumeUnsafeMarkers = []string{
@@ -204,9 +532,7 @@ var resumeUnsafeMarkers = []string{
// rotateIfPoisoned 上次失败属 resume-unsafe 且存在旧会话时归档旧会话。
func (b *Backend) rotateIfPoisoned(workDir string) {
b.mu.Lock()
reason := strings.ToLower(b.lastFailure[workDir])
b.mu.Unlock()
reason := strings.ToLower(b.failureReason(workDir))
if reason == "" {
return
}
@@ -224,24 +550,73 @@ func (b *Backend) rotateIfPoisoned(workDir string) {
if _, err := os.Stat(path); err != nil {
return
}
b.logger().Warn("rotating poisoned session", "work_dir", workDir)
_ = os.Rename(path, filepath.Join(workDir, ".agentx",
"transcript.poisoned."+time.Now().UTC().Format("20060102T150405.000000000")+".jsonl"))
}
// systemPrompt 组装系统提示:角色指令 + 交付物与工具档位约束。
func systemPrompt(req exec.Request, profile string) string {
var b strings.Builder
if strings.TrimSpace(req.Instructions) != "" {
b.WriteString(req.Instructions)
b.WriteString("\n\n")
func (b *Backend) logger() *logsys.Logger {
if b == nil || b.opts.Log == nil {
return logsys.Nop()
}
b.WriteString("Deliverable files: ")
b.WriteString(strings.Join(req.Targets, ", "))
b.WriteString(".")
if profile == exec.ToolProfileWorkspace {
b.WriteString(" Use workspace tools to write those files. Do not invent paths outside the workspace.")
} else {
b.WriteString(" Reply with the deliverable text only. Tools are disabled.")
log := b.opts.Log.Module("native")
if b.opts.ExecutionID == "" {
return log
}
return b.String()
return log.With("execution_id", b.opts.ExecutionID)
}
const lastFailureFile = ".native-last-failure"
func (b *Backend) noteFailure(workDir, reason string) {
b.mu.Lock()
b.lastFailure[workDir] = reason
b.mu.Unlock()
_ = os.WriteFile(filepath.Join(workDir, lastFailureFile), []byte(reason), 0o644)
}
func (b *Backend) clearFailure(workDir string) {
b.mu.Lock()
delete(b.lastFailure, workDir)
b.mu.Unlock()
_ = os.Remove(filepath.Join(workDir, lastFailureFile))
}
func (b *Backend) failureReason(workDir string) string {
b.mu.Lock()
reason := b.lastFailure[workDir]
b.mu.Unlock()
if reason != "" {
return reason
}
raw, err := os.ReadFile(filepath.Join(workDir, lastFailureFile))
if err != nil {
return ""
}
return string(raw)
}
// systemPrompt 经 promptx 组装:角色指令 + 环境事实 + 技能索引 + 交付物约束。
func (b *Backend) systemPrompt(req exec.Request, model, profile, workDir string, skillIndex []skills.Skill) string {
var index []promptx.IndexEntry
for _, s := range skillIndex {
index = append(index, promptx.IndexEntry{Name: s.Name, Description: s.Description})
}
now := time.Now()
weekday := map[string]string{
"Sunday": "周日", "Monday": "周一", "Tuesday": "周二", "Wednesday": "周三",
"Thursday": "周四", "Friday": "周五", "Saturday": "周六",
}[now.Weekday().String()]
return promptx.Compose(promptx.Parts{
Instructions: req.Instructions,
SkillsDeclared: req.Skills,
SkillsIndex: index,
Deliverables: req.Targets,
Env: promptx.EnvFacts{
Date: now.Format("2006-01-02") + "(" + weekday + ")",
Model: model,
WorkspaceRoot: workDir,
Profile: profile,
},
})
}
+64 -11
View File
@@ -2,6 +2,7 @@ package native
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
@@ -39,12 +40,17 @@ func script(turns ...llm.Message) llm.StreamFunc {
func TestBackendWorkspaceProfileWritesDeliverable(t *testing.T) {
dir := t.TempDir()
b := New(Options{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":"# 交付\n\n工具写入的交付物内容。","reason":"产出交付物"}`,
}}},
llm.Message{Role: llm.RoleAssistant, Content: "已写入 output.md", StopReason: llm.StopEnd},
)})
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":"# 交付\n\n工具写入的交付物内容。"}`,
}}},
llm.Message{Role: llm.RoleAssistant, Content: "已写入 output.md", StopReason: llm.StopEnd},
),
},
})
var events []exec.ProcessEvent
res, err := b.Execute(context.Background(), exec.Request{
WorkDir: dir, Prompt: "写交付物", Instructions: "你是测试角色",
@@ -81,9 +87,14 @@ func TestBackendWorkspaceProfileWritesDeliverable(t *testing.T) {
func TestBackendReasoningProfileWritesText(t *testing.T) {
dir := t.TempDir()
b := New(Options{Model: "fake", Stream: script(
llm.Message{Role: llm.RoleAssistant, Content: "纯文本交付内容(无工具)。", StopReason: llm.StopEnd},
)})
b := New(Options{
ModelOptions: ModelOptions{
Model: "fake",
Stream: script(
llm.Message{Role: llm.RoleAssistant, Content: "纯文本交付内容(无工具)。", StopReason: llm.StopEnd},
),
},
})
res, err := b.Execute(context.Background(), exec.Request{
WorkDir: dir, Prompt: "直接回答", ToolProfile: exec.ToolProfileReasoning,
Targets: []string{"notes.md"},
@@ -109,14 +120,56 @@ func TestBackendFailClosedWithoutConfig(t *testing.T) {
if res.Success || !strings.Contains(res.ErrorReason, "requires") {
t.Fatalf("want fail-closed config error, got %+v", res)
}
res2, _ := New(Options{APIBase: "http://x", APIKey: "k"}).Execute(context.Background(),
res2, _ := New(Options{
ModelOptions: ModelOptions{
APIBase: "http://x",
APIKey: "k",
},
}).Execute(context.Background(),
exec.Request{WorkDir: t.TempDir(), Targets: []string{"x.md"}})
if res2.Success || !strings.Contains(res2.ErrorReason, "model") {
t.Fatalf("want model required error, got %+v", res2)
}
res3, _ := New(Options{Stream: script()}).Execute(context.Background(),
res3, _ := New(Options{
ModelOptions: ModelOptions{
Stream: script(),
},
}).Execute(context.Background(),
exec.Request{WorkDir: t.TempDir(), Targets: []string{"x.md"}, ToolProfile: "bogus"})
if res3.Success || !strings.Contains(res3.ErrorReason, "tool profile") {
t.Fatalf("want profile error, got %+v", res3)
}
}
func TestBeforeToolReachesSquadMembers(t *testing.T) {
dir := t.TempDir()
var seen string
b := New(Options{
ModelOptions: ModelOptions{
Model: "fake",
Stream: script(
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
ID: "c1", Name: "write_file", Arguments: `{"path":"output.md","content":"nope"}`,
}}},
llm.Message{Role: llm.RoleAssistant, Content: "blocked", StopReason: llm.StopEnd},
),
},
GateOptions: GateOptions{BeforeTool: func(call llm.ToolCall) error {
seen = call.Name
return errors.New("计划模式拒绝了写工具")
}},
})
res, err := b.Execute(context.Background(), exec.Request{WorkDir: dir, Prompt: "写", Targets: []string{"output.md"}})
if err != nil {
t.Fatal(err)
}
if seen != "write_file" {
t.Fatalf("before tool saw %q", seen)
}
if res.Success {
t.Fatal("denied write still succeeded")
}
if _, err := os.Stat(filepath.Join(dir, "output.md")); err == nil {
t.Fatal("write_file ran after deny")
}
}
+76
View File
@@ -0,0 +1,76 @@
package native
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"agent-native/exec"
"agent-native/runtime/llm"
)
func TestBackendCompactBeforeRun(t *testing.T) {
dir := t.TempDir()
// 预置一份超阈值的长转录(系统消息 + 12 条历史)。
transcript := filepath.Join(dir, ".agentx", "transcript.jsonl")
if err := os.MkdirAll(filepath.Dir(transcript), 0o755); err != nil {
t.Fatal(err)
}
var b strings.Builder
b.WriteString(`{"role":"system","content":"sys"}` + "\n")
for i := 0; i < 12; i++ {
b.WriteString(`{"role":"user","content":"` + strings.Repeat("x", 400) + `"}` + "\n")
}
if err := os.WriteFile(transcript, []byte(b.String()), 0o644); err != nil {
t.Fatal(err)
}
var sawSummary bool
// 注入式捕获:验证 Run 收到的消息里有摘要标记。
bk2 := New(Options{
ModelOptions: ModelOptions{
Model: "fake",
Stream: captureFirstStream(&sawSummary),
},
SessionOptions: SessionOptions{
Compact: &CompactConfig{ThresholdChars: 1000, KeepLastN: 3, Summarizer: func(ctx context.Context, messages []llm.Message) (string, error) {
return "预置历史摘要", nil
}},
},
})
res, err := bk2.Execute(context.Background(), exec.Request{
WorkDir: dir, Prompt: "继续", Targets: []string{"output.md"},
ToolProfile: exec.ToolProfileReasoning,
})
if err != nil {
t.Fatal(err)
}
if res.Success && !sawSummary {
t.Fatal("compaction should have run before the loop")
}
data, _ := os.ReadFile(transcript)
if !strings.Contains(string(data), "预置历史摘要") || !strings.Contains(string(data), `"type":"compaction"`) {
t.Fatalf("transcript should keep history and append a compaction entry, got:\n%.400s", data)
}
if !strings.Contains(string(data), `"role":"system"`) {
t.Fatal("compaction must not rewrite the original transcript")
}
}
func captureFirstStream(saw *bool) llm.StreamFunc {
return func(ctx context.Context, model string, messages []llm.Message, decls []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
for _, m := range messages {
if strings.Contains(m.Content, "[conversation summary]") {
*saw = true
}
}
ch := make(chan llm.Event, 4)
go func() {
defer close(ch)
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{Role: llm.RoleAssistant, Content: "压缩后续写完成", StopReason: llm.StopEnd}}
}()
return ch
}
}
+89
View File
@@ -0,0 +1,89 @@
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
}
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,
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,
}
}
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
out.Files = res.Patches
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
}
+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(应落临时目录)")
}
}
+410
View File
@@ -0,0 +1,410 @@
package native
import (
"context"
"os"
osexec "os/exec"
"path/filepath"
"strings"
"testing"
"agent-native/exec"
"agent-native/runtime/llm"
"agent-native/runtime/mcp"
"agent-native/runtime/subagent"
"agent-native/runtime/tools"
)
func TestBackendExecToolsWiredWithOptionsAndRequestEnv(t *testing.T) {
bash, err := osexec.LookPath("bash")
if err != nil {
t.Fatalf("bash not available: %v", err)
}
dir := t.TempDir()
b := New(Options{
ModelOptions: ModelOptions{
Model: "fake",
Stream: script(
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
ID: "c1", Name: "run_command",
Arguments: `{"command":"echo role=$ROLE_FLAG step=$STEP_FLAG > exec_out.txt"}`,
}}},
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
ID: "c2", Name: "write_file",
Arguments: `{"path":"output.md","content":"done"}`,
}}},
llm.Message{Role: llm.RoleAssistant, Content: "ok", StopReason: llm.StopEnd},
),
},
ToolOptions: ToolOptions{
Exec: &tools.ExecConfig{Bash: bash, Env: []string{"PATH=/usr/bin:/bin", "ROLE_FLAG=backend"}},
},
})
res, err := b.Execute(context.Background(), exec.Request{
WorkDir: dir, Prompt: "run", Targets: []string{"output.md"},
Env: map[string]string{"STEP_FLAG": "request"},
})
if err != nil || !res.Success {
t.Fatalf("execute: err=%v res=%+v logs=%s", err, res, res.Logs)
}
data, err := os.ReadFile(filepath.Join(dir, "exec_out.txt"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), "role=backend") || !strings.Contains(string(data), "step=request") {
t.Fatalf("env merge wrong: %q", data)
}
}
func TestBackendExecToolsAbsentWithoutOptions(t *testing.T) {
stream, c := captureStream()
b := New(Options{
ModelOptions: ModelOptions{
Model: "fake",
Stream: stream,
},
})
res, err := b.Execute(context.Background(), exec.Request{
WorkDir: t.TempDir(), Prompt: "x", Targets: []string{"output.md"},
ToolProfile: exec.ToolProfileReasoning,
})
if err != nil || !res.Success {
t.Fatalf("execute: err=%v res=%+v", err, res)
}
// reasoning 档本无工具;改用 workspace 档断言 decls 不含执行工具。
stream2, c2 := captureStream()
b2 := New(Options{
ModelOptions: ModelOptions{
Model: "fake",
Stream: stream2,
},
})
_, _ = b2.Execute(context.Background(), exec.Request{
WorkDir: t.TempDir(), Prompt: "x", Targets: []string{"output.md"},
ToolProfile: exec.ToolProfileWorkspace,
})
for _, names := range c2.toolDecls {
for _, n := range names {
if n == "run_command" || n == "run_python" {
t.Fatalf("exec tools must not register without Options.Exec, got %v", names)
}
}
}
_ = c
}
func TestBackendAllowlistCanDisableExecTools(t *testing.T) {
bash, _ := osexec.LookPath("bash")
stream, c := captureStream()
b := New(Options{
ModelOptions: ModelOptions{
Model: "fake",
Stream: stream,
},
ToolOptions: ToolOptions{
Exec: &tools.ExecConfig{Bash: bash, Env: []string{"PATH=/usr/bin:/bin"}},
},
})
res, err := b.Execute(context.Background(), exec.Request{
WorkDir: t.TempDir(), Prompt: "x", Targets: []string{"output.md"},
ToolAllowlist: []string{"read_file", "write_file", "edit_file", "list_dir", "grep", "glob", "multi_edit"},
})
if err != nil {
t.Fatal(err)
}
if res.Success {
t.Fatalf("write-only allowlist without write? (unexpected) res=%+v", res)
}
for _, names := range c.toolDecls {
for _, n := range names {
if n == "run_command" {
t.Fatalf("allowlist must be able to strip run_command, got %v", names)
}
}
}
}
// fakeSearchProvider 与 tools 包测试同构的最小搜索替身。
type fakeSearchProvider struct{ query string }
func (f *fakeSearchProvider) Search(_ context.Context, req tools.SearchRequest) (tools.SearchResponse, error) {
f.query = req.Query
return tools.SearchResponse{Answer: "ans", Results: []tools.SearchResult{{Title: "T", Content: "C", Score: 0.9}}}, nil
}
func TestBackendSearchToolsWiredViaOptions(t *testing.T) {
provider := &fakeSearchProvider{}
stream, c := captureStream(
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
ID: "c1", Name: "web_search", Arguments: `{"query":"golang generics"}`,
}}},
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
ID: "c2", Name: "write_file",
Arguments: `{"path":"output.md","content":"done"}`,
}}},
llm.Message{Role: llm.RoleAssistant, Content: "ok", StopReason: llm.StopEnd},
)
b := New(Options{
ModelOptions: ModelOptions{
Model: "fake",
Stream: stream,
},
ToolOptions: ToolOptions{
Search: provider,
},
})
dir := t.TempDir()
res, err := b.Execute(context.Background(), exec.Request{
WorkDir: dir, Prompt: "search", Targets: []string{"output.md"},
})
if err != nil || !res.Success {
t.Fatalf("execute: err=%v res=%+v logs=%s", err, res, res.Logs)
}
if provider.query != "golang generics" {
t.Fatalf("provider should receive the query, got %q", provider.query)
}
// 搜索工具确实出现在 decls 里。
found := false
for _, names := range c.toolDecls {
for _, n := range names {
if n == "web_search" || n == "tavily_search" {
found = true
}
}
}
if !found {
t.Fatal("search tools must be declared when Options.Search is set")
}
}
func TestBackendSearchToolsAbsentWithoutOptions(t *testing.T) {
stream, c := captureStream()
b := New(Options{
ModelOptions: ModelOptions{
Model: "fake",
Stream: stream,
},
})
_, _ = b.Execute(context.Background(), exec.Request{
WorkDir: t.TempDir(), Prompt: "x", Targets: []string{"output.md"},
ToolProfile: exec.ToolProfileWorkspace,
})
for _, names := range c.toolDecls {
for _, n := range names {
if n == "web_search" || n == "tavily_search" {
t.Fatalf("search tools must not register without Options.Search, got %v", names)
}
}
}
}
func TestBackendMCPToolsWiredViaOptions(t *testing.T) {
exe, err := osexec.LookPath(os.Args[0])
if err != nil {
t.Fatal(err)
}
// os.Args[0] 在 go test 下指向测试二进制——用其自身作为 MCP server。
mgr, err := mcp.NewManager([]mcp.ServerConfig{{
Name: "fake", Command: exe,
Env: append(os.Environ(), "AGENT_NATIVE_TEST_MCP_STDIO=1"),
}})
if err != nil {
t.Fatal(err)
}
defer mgr.Close()
if err := mgr.RegisterInto(context.Background(), tools.NewRegistry(t.TempDir())); err != nil {
t.Fatal(err)
}
stream, c := captureStream(
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
ID: "c1", Name: "mcp__fake__echo", Arguments: `{"text":"via-backend"}`,
}}},
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
ID: "c2", Name: "write_file",
Arguments: `{"path":"output.md","content":"done"}`,
}}},
llm.Message{Role: llm.RoleAssistant, Content: "ok", StopReason: llm.StopEnd},
)
b := New(Options{
ModelOptions: ModelOptions{
Model: "fake",
Stream: stream,
},
ToolOptions: ToolOptions{
MCP: mgr,
},
})
dir := t.TempDir()
res, err2 := b.Execute(context.Background(), exec.Request{
WorkDir: dir, Prompt: "call mcp", Targets: []string{"output.md"},
})
if err2 != nil || !res.Success {
t.Fatalf("execute: err=%v res=%+v logs=%s", err2, res, res.Logs)
}
found := false
for _, names := range c.toolDecls {
for _, n := range names {
if n == "mcp__fake__echo" {
found = true
}
}
}
if !found {
t.Fatal("mcp bridge tools must be declared to the model")
}
}
func TestBackendSkillsIndexAndLoader(t *testing.T) {
skillsRoot := t.TempDir()
skillDir := filepath.Join(skillsRoot, "docx-author")
if err := os.MkdirAll(skillDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\nname: docx-author\ndescription: 生成文档交付物\n---\n\n配方正文"), 0o644); err != nil {
t.Fatal(err)
}
stream, c := captureStream(
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
ID: "c1", Name: "load_skill", Arguments: `{"name":"docx-author"}`,
}}},
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
ID: "c2", Name: "write_file", Arguments: `{"path":"output.md","content":"done"}`,
}}},
llm.Message{Role: llm.RoleAssistant, Content: "ok", StopReason: llm.StopEnd},
)
b := New(Options{
ModelOptions: ModelOptions{
Model: "fake",
Stream: stream,
},
ToolOptions: ToolOptions{
SkillsRoots: []string{skillsRoot},
},
})
dir := t.TempDir()
res, err := b.Execute(context.Background(), exec.Request{
WorkDir: dir, Prompt: "写", Targets: []string{"output.md"}, Skills: []string{"docx-author"},
})
if err != nil || !res.Success {
t.Fatalf("execute: err=%v res=%+v logs=%s", err, res, res.Logs)
}
if !strings.Contains(c.system, "- docx-author — 生成文档交付物") {
t.Fatalf("skill index missing in system prompt:\n%s", c.system)
}
if !strings.Contains(c.system, "Declared for this task") || !strings.Contains(c.system, "docx-author") {
t.Fatalf("declared skills missing:\n%s", c.system)
}
sawLoader := false
for _, names := range c.toolDecls {
for _, n := range names {
if n == "load_skill" {
sawLoader = true
}
}
}
if !sawLoader {
t.Fatal("load_skill must be declared when skills exist")
}
}
func TestBackendDeclaredSkillMissingFailsClosed(t *testing.T) {
b := New(Options{
ModelOptions: ModelOptions{
Model: "fake",
Stream: script(),
},
ToolOptions: ToolOptions{
SkillsRoots: []string{t.TempDir()},
},
})
res, err := b.Execute(context.Background(), exec.Request{
WorkDir: t.TempDir(), Prompt: "x", Targets: []string{"output.md"},
Skills: []string{"ghost-skill"},
})
if err != nil {
t.Fatal(err)
}
if res.Success || !strings.Contains(res.ErrorReason, "declared skill not installed: ghost-skill") {
t.Fatalf("missing declared skill must fail-closed, got %+v", res)
}
}
func TestBackendWorkspaceSkillsRootAutoMerged(t *testing.T) {
stream, c := captureStream(
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
ID: "c1", Name: "write_file", Arguments: `{"path":"output.md","content":"done"}`,
}}},
llm.Message{Role: llm.RoleAssistant, Content: "ok", StopReason: llm.StopEnd},
)
b := New(Options{
ModelOptions: ModelOptions{
Model: "fake",
Stream: stream,
},
})
dir := t.TempDir()
wsSkill := filepath.Join(dir, ".agent", "skills", "ws-skill")
if err := os.MkdirAll(wsSkill, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(wsSkill, "SKILL.md"), []byte("---\nname: ws-skill\ndescription: 工作区技能\n---\n正文"), 0o644); err != nil {
t.Fatal(err)
}
res, err := b.Execute(context.Background(), exec.Request{
WorkDir: dir, Prompt: "x", Targets: []string{"output.md"},
})
if err != nil || !res.Success {
t.Fatalf("execute: err=%v res=%+v", err, res)
}
if !strings.Contains(c.system, "ws-skill") {
t.Fatalf("workspace skills root must be merged into index:\n%s", c.system)
}
}
func TestBackendSubagentWiredViaOptions(t *testing.T) {
var delegated *subagent.Request
stream, c := captureStream(
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
ID: "c1", Name: "delegate_task",
Arguments: `{"goal":"查证数据","write_scope":"research/","done_criteria":"三来源"}`,
}}},
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
ID: "c2", Name: "write_file",
Arguments: `{"path":"output.md","content":"done"}`,
}}},
llm.Message{Role: llm.RoleAssistant, Content: "ok", StopReason: llm.StopEnd},
)
b := New(Options{
ModelOptions: ModelOptions{
Model: "fake",
Stream: stream,
},
ToolOptions: ToolOptions{
Subagent: func(ctx context.Context, req subagent.Request) (subagent.Result, error) {
delegated = &req
return subagent.Result{Success: true, Text: "结论已查明"}, nil
},
},
})
dir := t.TempDir()
res, err := b.Execute(context.Background(), exec.Request{
WorkDir: dir, Prompt: "分工", Targets: []string{"output.md"},
})
if err != nil || !res.Success {
t.Fatalf("execute: err=%v res=%+v logs=%s", err, res, res.Logs)
}
if delegated == nil || delegated.Goal != "查证数据" {
t.Fatalf("factory not invoked with fields: %+v", delegated)
}
sawDelegate := false
for _, names := range c.toolDecls {
for _, n := range names {
if n == "delegate_task" {
sawDelegate = true
}
}
}
if !sawDelegate {
t.Fatal("delegate_task must be declared when Subagent factory is set")
}
}
+4
View File
@@ -0,0 +1,4 @@
execution_id=cx session=
agent_start
turn_start
message_start
+158
View File
@@ -0,0 +1,158 @@
package native
import (
"context"
"encoding/json"
"sync"
"testing"
"agent-native/exec"
"agent-native/runtime/llm"
"agent-native/runtime/tools"
)
type hostTool struct{ name string }
func (h hostTool) Name() string { return h.name }
func (h hostTool) Description() string { return "host tool" }
func (h hostTool) Parameters() json.RawMessage {
return json.RawMessage(`{"type":"object","properties":{"path":{"type":"string"}}}`)
}
func (h hostTool) Execute(ctx context.Context, args json.RawMessage) (tools.Result, error) {
return tools.Result{Content: "registered " + h.name}, nil
}
func TestHostToolAndUsageEvent(t *testing.T) {
var events []exec.ProcessEvent
b := New(Options{
ModelOptions: ModelOptions{
Model: "fake",
Stream: usageScript(),
},
ToolOptions: ToolOptions{
HostTools: []tools.Tool{hostTool{name: "register_artifact"}},
},
})
res, err := b.Execute(context.Background(), exec.Request{
WorkDir: t.TempDir(), Prompt: "登记",
OnEvent: func(ev exec.ProcessEvent) { events = append(events, ev) },
})
if err != nil || !res.Success {
t.Fatalf("execute: err=%v res=%+v logs=%s", err, res, logsOf(res))
}
sawText, sawStart, sawEnd, sawUsage := false, false, false, false
for _, ev := range events {
switch ev.Type {
case "text_delta":
if ev.Text != "" {
sawText = true
}
case "tool_execution_start":
if ev.Tool == "register_artifact" {
sawStart = true
}
case "tool_execution_end":
if ev.Tool == "register_artifact" {
sawEnd = true
}
case "usage":
if ev.PromptTokens == 11 && ev.CompletionTokens == 7 {
sawUsage = true
}
}
}
if !sawText || !sawStart || !sawEnd || !sawUsage {
t.Fatalf("events missing text/tool/usage: %+v", events)
}
}
func TestHostToolSeesStepWorkDir(t *testing.T) {
var seen string
b := New(Options{
ModelOptions: ModelOptions{
Model: "fake",
Stream: script(llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{ID: "c1", Name: "peek", Arguments: `{}`}}}),
},
ToolOptions: ToolOptions{
HostTools: []tools.Tool{peekTool{got: &seen}},
},
})
dir := t.TempDir()
_, err := b.Execute(context.Background(), exec.Request{WorkDir: dir, Prompt: "x", Targets: []string{"output.md"}})
if err != nil {
t.Fatal(err)
}
if seen != dir {
t.Fatalf("workdir = %q, want %s", seen, dir)
}
}
type peekTool struct{ got *string }
func (p peekTool) Name() string { return "peek" }
func (p peekTool) Description() string { return "peek" }
func (p peekTool) Parameters() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
func (p peekTool) Execute(ctx context.Context, args json.RawMessage) (tools.Result, error) {
*p.got = WorkDir(ctx)
return tools.Result{Content: "ok"}, nil
}
func TestHostToolNameConflictFailsClosed(t *testing.T) {
b := New(Options{
ModelOptions: ModelOptions{
Model: "fake",
Stream: script(llm.Message{Role: llm.RoleAssistant, Content: "x", StopReason: llm.StopEnd}),
},
ToolOptions: ToolOptions{
HostTools: []tools.Tool{hostTool{name: "read_file"}},
},
})
res, err := b.Execute(context.Background(), exec.Request{
WorkDir: t.TempDir(), Prompt: "x", Targets: []string{"output.md"},
})
if err != nil {
t.Fatal(err)
}
if res.Success || res.ErrorReason == "" {
t.Fatalf("conflict must fail closed, got %+v", res)
}
}
func usageScript() llm.StreamFunc {
var mu sync.Mutex
step := 0
return func(ctx context.Context, model string, messages []llm.Message, td []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
ch := make(chan llm.Event, 4)
go func() {
defer close(ch)
mu.Lock()
defer mu.Unlock()
step++
switch step {
case 1:
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{
Role: llm.RoleAssistant, StopReason: llm.StopToolCalls,
ToolCalls: []llm.ToolCall{{ID: "c1", Name: "register_artifact", Arguments: `{"path":"a.docx"}`}},
}, Usage: llm.Usage{PromptTokens: 4, CompletionTokens: 2}}
case 2:
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{
Role: llm.RoleAssistant, StopReason: llm.StopToolCalls,
ToolCalls: []llm.ToolCall{{ID: "c2", Name: "write_file", Arguments: `{"path":"output.md","content":"done"}`}},
}, Usage: llm.Usage{PromptTokens: 4, CompletionTokens: 2}}
default:
ch <- llm.Event{Kind: llm.KindTextDelta, Text: "已登记"}
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{
Role: llm.RoleAssistant, Content: "已登记", StopReason: llm.StopEnd,
}, Usage: llm.Usage{PromptTokens: 3, CompletionTokens: 3}}
}
}()
return ch
}
}
func logsOf(res *exec.Result) string {
if res == nil {
return ""
}
return res.Logs
}
+17
View File
@@ -0,0 +1,17 @@
package native
import (
"os"
"testing"
"agent-native/runtime/mcp"
)
// TestMain:MCP fake server 的 re-exec 分支(详见 mcp.RunFakeServer)。
func TestMain(m *testing.M) {
if os.Getenv(mcp.FakeServerEnv) == "1" {
mcp.RunFakeServer()
os.Exit(0)
}
os.Exit(m.Run())
}
+1
View File
@@ -0,0 +1 @@
hi
+65
View File
@@ -0,0 +1,65 @@
package native
import (
"context"
"encoding/json"
"os"
"os/exec"
"testing"
"agent-native/rpc"
"agent-native/runtime"
"agent-native/runtime/llm"
"agent-native/runtime/session"
)
func TestEventAndRPCFixtures(t *testing.T) {
var types []string
agent := runtime.NewAgentSession(runtime.Config{
Model: "fake",
Stream: func(ctx context.Context, model string, messages []llm.Message, td []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
ch := make(chan llm.Event, 1)
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{Role: llm.RoleAssistant, Content: "ok", StopReason: llm.StopEnd}}
close(ch)
return ch
},
OnEvent: func(ev runtime.Event) { types = append(types, ev.Type) },
})
if _, err := agent.Prompt(context.Background(), llm.Message{Role: llm.RoleUser, Content: "hi"}); err != nil {
t.Fatal(err)
}
raw, _ := json.Marshal(types)
for _, want := range []string{"message_start", "message_end", "agent_settled"} {
if !contains(types, want) {
t.Fatalf("fixture missing %s in %s", want, raw)
}
}
dir := t.TempDir()
store, err := session.Open(dir + "/s.jsonl")
if err != nil {
t.Fatal(err)
}
srv := &rpc.Server{Agent: agent, Store: store}
resp, err := srv.Handle(context.Background(), []byte(`{"type":"get_session_stats"}`))
if err != nil || !json.Valid(resp) {
t.Fatalf("rpc fixture: %s %v", resp, err)
}
}
func TestPiCrossCompareOptional(t *testing.T) {
if _, err := exec.LookPath("pi"); err != nil {
t.Skip("pi is not installed")
}
if os.Getenv("PI_CROSS") != "1" {
t.Skip("set PI_CROSS=1 to compare a live pi process")
}
}
func contains(list []string, want string) bool {
for _, item := range list {
if item == want {
return true
}
}
return false
}
+40
View File
@@ -0,0 +1,40 @@
package native
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestR2PoisonMarkerSurvivesNewProcess(t *testing.T) {
dir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, ".agentx"), 0o755); err != nil {
t.Fatal(err)
}
transcript := filepath.Join(dir, ".agentx", "transcript.jsonl")
if err := os.WriteFile(transcript, []byte("{}\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, lastFailureFile), []byte("context length exceeded"), 0o644); err != nil {
t.Fatal(err)
}
b := New(Options{})
b.rotateIfPoisoned(dir)
if _, err := os.Stat(transcript); !os.IsNotExist(err) {
t.Fatal("poisoned transcript still at the live path")
}
entries, err := os.ReadDir(filepath.Join(dir, ".agentx"))
if err != nil {
t.Fatal(err)
}
var renamed bool
for _, e := range entries {
if strings.Contains(e.Name(), "transcript.poisoned.") {
renamed = true
}
}
if !renamed {
t.Fatal("expected archived transcript")
}
}
+208
View File
@@ -0,0 +1,208 @@
package native
import (
"context"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"agent-native/exec"
"agent-native/runtime/llm"
)
// captureStream 捕获每次调用的 model / 首条 system 消息,并按序回放回合。
func captureStream(turns ...llm.Message) (llm.StreamFunc, *capture) {
c := &capture{}
var mu sync.Mutex
i := 0
return func(ctx context.Context, model string, messages []llm.Message, tools []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
ch := make(chan llm.Event, 4)
go func() {
defer close(ch)
mu.Lock()
defer mu.Unlock()
c.models = append(c.models, model)
c.toolDecls = append(c.toolDecls, toolNames(tools))
if len(messages) > 0 && messages[0].Role == llm.RoleSystem {
c.system = messages[0].Content
}
if i >= len(turns) {
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd}}
return
}
msg := turns[i]
i++
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: msg}
}()
return ch
}, c
}
type capture struct {
mu sync.Mutex
models []string
toolDecls [][]string
system string
}
func toolNames(decls []llm.ToolDecl) []string {
var out []string
for _, d := range decls {
out = append(out, d.Name)
}
return out
}
func TestBackendRequestModelOverride(t *testing.T) {
stream, c := captureStream()
b := New(Options{
ModelOptions: ModelOptions{
Model: "default-model",
Stream: stream,
},
})
dir := t.TempDir()
res, err := b.Execute(context.Background(), exec.Request{
WorkDir: dir, Prompt: "hi", Targets: []string{"output.md"}, Model: "override-model",
ToolProfile: exec.ToolProfileReasoning,
})
if err != nil || !res.Success {
t.Fatalf("execute: err=%v res=%+v", err, res)
}
if len(c.models) == 0 || c.models[0] != "override-model" {
t.Fatalf("want override-model, got %v", c.models)
}
}
func TestBackendRequestToolAllowlist(t *testing.T) {
stream, c := captureStream(
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
ID: "c1", Name: "write_file", Arguments: `{"path":"output.md","content":"blocked"}`,
}}},
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
ID: "c2", Name: "write_file", Arguments: `{"path":"output2.md","content":"still blocked"}`,
}}},
llm.Message{Role: llm.RoleAssistant, Content: "gave up", StopReason: llm.StopEnd},
)
b := New(Options{
ModelOptions: ModelOptions{
Model: "fake",
Stream: stream,
},
})
dir := t.TempDir()
// 目标 output2.md 不在 allowlist 内能写吗?——write_file 被整体收窄,
// 模型两次尝试都会得到 unknown tool,最终无交付物 → 失败。
res, err := b.Execute(context.Background(), exec.Request{
WorkDir: dir, Prompt: "写文件", Targets: []string{"output2.md"},
ToolAllowlist: []string{"read_file", "list_dir", "grep"},
})
if err != nil {
t.Fatal(err)
}
if res.Success {
t.Fatalf("write_file restricted out; deliverable must fail, got %+v", res)
}
if len(c.toolDecls) == 0 {
t.Fatal("expected tool decls")
}
for _, names := range c.toolDecls {
for _, n := range names {
if n == "write_file" || n == "edit_file" {
t.Fatalf("write/edit must be restricted out of decls, got %v", names)
}
}
}
}
func TestBackendRequestUnknownAllowlistFailsClosed(t *testing.T) {
b := New(Options{
ModelOptions: ModelOptions{
Model: "fake",
Stream: script(),
},
})
res, err := b.Execute(context.Background(), exec.Request{
WorkDir: t.TempDir(), Prompt: "x", Targets: []string{"output.md"},
ToolAllowlist: []string{"read_file", "no_such_tool"},
})
if err != nil {
t.Fatal(err)
}
if res.Success || !strings.Contains(res.ErrorReason, "no_such_tool") {
t.Fatalf("want fail-closed unknown allowlist name, got %+v", res)
}
}
func TestBackendRequestBudgetDeadlineBindsContext(t *testing.T) {
deadlineHit := make(chan struct{})
var once sync.Once
stream := func(ctx context.Context, model string, messages []llm.Message, tools []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
ch := make(chan llm.Event, 4)
go func() {
defer close(ch)
select {
case <-ctx.Done():
once.Do(func() { close(deadlineHit) })
ch <- llm.Event{Kind: llm.KindError, Err: ctx.Err(), Message: llm.Message{StopReason: llm.StopAborted}}
case <-time.After(3 * time.Second):
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd}}
}
}()
return ch
}
b := New(Options{
ModelOptions: ModelOptions{
Model: "fake",
Stream: stream,
},
})
start := time.Now()
res, err := b.Execute(context.Background(), exec.Request{
WorkDir: t.TempDir(), Prompt: "x", Targets: []string{"output.md"},
Timeout: 30 * time.Second, // 长超时,预算截止应更先生效
Budget: &exec.Budget{Deadline: time.Now().Add(150 * time.Millisecond)},
})
if err != nil {
t.Fatal(err)
}
select {
case <-deadlineHit:
case <-time.After(2 * time.Second):
t.Fatal("budget deadline should bind the run context")
}
if res.Success {
t.Fatalf("deadline run must not succeed, got %+v", res)
}
if elapsed := time.Since(start); elapsed > 5*time.Second {
t.Fatalf("deadline should fire early, took %s", elapsed)
}
}
func TestBackendRequestExecutionIDInLogs(t *testing.T) {
stream, _ := captureStream()
b := New(Options{
ModelOptions: ModelOptions{
Model: "fake",
Stream: stream,
},
})
dir := t.TempDir()
res, err := b.Execute(context.Background(), exec.Request{
WorkDir: dir, Prompt: "hi", Targets: []string{"output.md"},
Instructions: "你是角色",
ExecutionID: "exec-42", SessionScope: "run-9/step-3",
ToolProfile: exec.ToolProfileReasoning,
})
if err != nil || !res.Success {
t.Fatalf("execute: err=%v res=%+v", err, res)
}
logBytes, _ := os.ReadFile(filepath.Join(dir, "execution.log"))
logs := string(logBytes)
if !strings.Contains(logs, "execution_id=exec-42") || !strings.Contains(logs, "run-9/step-3") {
t.Fatalf("execution log should carry ids, got:\n%s", logs)
}
}
+12 -2
View File
@@ -38,7 +38,12 @@ func failThenSucceed(failMsg string) llm.StreamFunc {
func TestSessionRotationAfterPoisonedFailure(t *testing.T) {
dir := t.TempDir()
b := New(Options{Model: "fake", Stream: failThenSucceed("maximum context length exceeded (context_overflow)")})
b := New(Options{
ModelOptions: ModelOptions{
Model: "fake",
Stream: failThenSucceed("maximum context length exceeded (context_overflow)"),
},
})
// 第一次执行:失败(上下文溢出),留下毒化会话。
res1, err := b.Execute(context.Background(), execReq(dir, []string{"output.md"}))
@@ -87,7 +92,12 @@ func TestSessionRotationAfterPoisonedFailure(t *testing.T) {
func TestNoRotationOnRetryableNetworkFailure(t *testing.T) {
dir := t.TempDir()
b := New(Options{Model: "fake", Stream: failThenSucceed("connection reset by peer (provider_network)")})
b := New(Options{
ModelOptions: ModelOptions{
Model: "fake",
Stream: failThenSucceed("connection reset by peer (provider_network)"),
},
})
_, _ = b.Execute(context.Background(), execReq(dir, []string{"output.md"}))
transcript := filepath.Join(dir, ".agentx", "transcript.jsonl")
_ = os.MkdirAll(filepath.Dir(transcript), 0o755)
+11 -11
View File
@@ -7,11 +7,11 @@ import (
)
type remoteRunWire struct {
OK bool `json:"ok"`
RunID string `json:"run_id"`
FallbackReason string `json:"fallback_reason"`
Retryable bool `json:"retryable"`
RunnerProtocol string `json:"runner_protocol"`
OK bool `json:"ok"`
RunID string `json:"run_id"`
FallbackReason string `json:"fallback_reason"`
Retryable bool `json:"retryable"`
RunnerProtocol string `json:"runner_protocol"`
Capabilities *struct {
TypedResult bool `json:"typed_result"`
} `json:"capabilities"`
@@ -23,7 +23,7 @@ type remoteRunWire struct {
}
type decodedRemote struct {
OK bool
OK bool
Retryable bool
Reason string
Incoming map[string][]byte
@@ -91,17 +91,17 @@ func safeLegacyReason(reason string) string {
case r == "":
return "remote pi-agent reported task failure"
case strings.EqualFold(r, "prompt_empty"):
return "PROMPT_EMPTY"
return "agent.prompt_empty"
case strings.Contains(strings.ToLower(r), "unauthorized"):
return "UNAUTHORIZED"
return "agent.unauthorized"
case strings.Contains(strings.ToLower(r), "timeout"):
return "TIMEOUT"
return "agent.timeout"
default:
if strings.ContainsAny(r, "/\\") || strings.Contains(strings.ToLower(r), "token") {
return "RUN_FAILED"
return "agent.run_failed"
}
if len(r) > 64 {
return "RUN_FAILED"
return "agent.run_failed"
}
return r
}
+12 -10
View File
@@ -20,20 +20,22 @@ const (
var ErrTypedResult = fmt.Errorf("pitransport: typed result_v1 rejected")
// 对外错误码与 Error-Code-System 的 agent 域一致。
// CANCELLED 并入 agent.canceled,MISSING_TARGET 并入 agent.missing_deliverable。
var allowedErrorCodes = map[string]struct{}{
"PROMPT_EMPTY": {},
"UNAUTHORIZED": {},
"TIMEOUT": {},
"CANCELLED": {},
"TRANSIENT": {},
"MISSING_TARGET": {},
"RUN_FAILED": {},
"agent.prompt_empty": {},
"agent.unauthorized": {},
"agent.timeout": {},
"agent.canceled": {},
"agent.transient": {},
"agent.missing_deliverable": {},
"agent.run_failed": {},
}
var retryableErrorCodes = map[string]struct{}{
"TIMEOUT": {},
"CANCELLED": {},
"TRANSIENT": {},
"agent.timeout": {},
"agent.canceled": {},
"agent.transient": {},
}
type ResultV1Member struct {
+4 -4
View File
@@ -63,13 +63,13 @@ func TestResultV1RejectsMissingMemberBody(t *testing.T) {
}
func TestTypedRetryableAllowlist(t *testing.T) {
ok := BuildResultV1("r", "error", true, "TRANSIENT", nil)
ok := BuildResultV1("r", "error", true, "agent.transient", nil)
if !TypedRetryable(ok) {
t.Fatal("TRANSIENT retryable")
t.Fatal("agent.transient retryable")
}
denied := BuildResultV1("r", "error", true, "PROMPT_EMPTY", nil)
denied := BuildResultV1("r", "error", true, "agent.prompt_empty", nil)
if TypedRetryable(denied) {
t.Fatal("PROMPT_EMPTY must not retry")
t.Fatal("agent.prompt_empty must not retry")
}
}
+17 -17
View File
@@ -8,35 +8,35 @@ export const MAX_RESULT_WIRE_BYTES = 6 * 1024 * 1024;
/** Tail window for execution.log patches; full log remains on shared volume. */
export const EXECUTION_LOG_PATCH_BYTES = 128 * 1024;
const RETRYABLE_CODES = new Set(["TIMEOUT", "CANCELLED", "TRANSIENT"]);
const RETRYABLE_CODES = new Set(["agent.timeout", "agent.canceled", "agent.transient"]);
const ALLOWED_CODES = new Set([
"PROMPT_EMPTY",
"UNAUTHORIZED",
"TIMEOUT",
"CANCELLED",
"TRANSIENT",
"MISSING_TARGET",
"RUN_FAILED",
"agent.prompt_empty",
"agent.unauthorized",
"agent.timeout",
"agent.canceled",
"agent.transient",
"agent.missing_deliverable",
"agent.run_failed",
]);
export function typedErrorCode(payload) {
const raw = String(payload?.fallback_reason || payload?.error || "");
const t = raw.toLowerCase();
if (t.includes("prompt_empty")) return "PROMPT_EMPTY";
if (t.includes("unauthorized")) return "UNAUTHORIZED";
if (t.includes("timeout") || t.includes("timed out")) return "TIMEOUT";
if (t.includes("cancel")) return "CANCELLED";
if (t.includes("prompt_empty")) return "agent.prompt_empty";
if (t.includes("unauthorized")) return "agent.unauthorized";
if (t.includes("timeout") || t.includes("timed out")) return "agent.timeout";
if (t.includes("cancel")) return "agent.canceled";
if (
t.includes("connection") ||
t.includes("terminated") ||
t.includes("econnreset") ||
t.includes("socket hang")
) {
return "TRANSIENT";
return "agent.transient";
}
if (t.includes("missing target")) return "MISSING_TARGET";
if (payload?.retryable) return "TRANSIENT";
if (!payload?.ok) return "RUN_FAILED";
if (t.includes("missing target")) return "agent.missing_deliverable";
if (payload?.retryable) return "agent.transient";
if (!payload?.ok) return "agent.run_failed";
return "";
}
@@ -117,7 +117,7 @@ export function withResultV1(payload) {
run_id: String(out.run_id || "unknown").slice(0, 128),
outcome: ok ? "success" : "error",
retryable,
error_code: ALLOWED_CODES.has(errorCode) ? errorCode : ok ? "" : "RUN_FAILED",
error_code: ALLOWED_CODES.has(errorCode) ? errorCode : ok ? "" : "agent.run_failed",
members,
content_digest: membersDigest(members),
};
+34
View File
@@ -0,0 +1,34 @@
package catalogcheck
import (
"testing"
"agent-native/exec"
"error-code-system/errcode"
)
// 常量取值必须与错误码库种子码相同。旧短字符串只作为错误码库里的别名存在。
func TestContractCodesMatchCatalog(t *testing.T) {
pairs := []struct {
got exec.ErrorCode
want errcode.Code
}{
{exec.ErrCanceled, errcode.AgentCanceled},
{exec.ErrDeadlineExceeded, errcode.AgentDeadlineExceeded},
{exec.ErrBudgetExceeded, errcode.AgentBudgetExceeded},
{exec.ErrProviderUnavailable, errcode.AgentProviderUnavailable},
{exec.ErrProviderRejected, errcode.AgentProviderRejected},
{exec.ErrToolRejected, errcode.AgentToolRejected},
{exec.ErrPathOutsideWorkspace, errcode.AgentPathOutsideWorkspace},
{exec.ErrMissingCapability, errcode.AgentMissingCapability},
{exec.ErrMissingDeliverable, errcode.AgentMissingDeliverable},
{exec.ErrEmptyOutput, errcode.AgentEmptyOutput},
{exec.ErrUnknownOutcome, errcode.AgentUnknownOutcome},
{exec.ErrInternal, errcode.AgentInternal},
}
for _, pair := range pairs {
if string(pair.got) != string(pair.want) {
t.Errorf("contract %q != catalog %q", pair.got, pair.want)
}
}
}
+127
View File
@@ -0,0 +1,127 @@
package main
import (
"bufio"
"context"
"encoding/json"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"agent-native/rpc"
"agent-native/runtime"
"agent-native/runtime/llm"
"agent-native/runtime/session"
)
func main() {
mode := flag.String("mode", "print", "print, json, or rpc")
model := flag.String("model", "", "provider/model[:thinking]")
prompt := flag.String("prompt", "", "user message for print and json modes")
sessionPath := flag.String("session", "", "transcript path")
noSession := flag.Bool("no-session", false, "do not read or write a transcript")
export := flag.String("export", "", "write the active session as HTML and exit")
flag.Parse()
if *sessionPath == "" && !*noSession {
home, _ := os.UserHomeDir()
*sessionPath = filepath.Join(home, ".agent-native", "session.jsonl")
}
var store *session.Store
if !*noSession {
st, err := session.Open(*sessionPath)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
store = st
}
if *export != "" {
if store == nil {
fmt.Fprintln(os.Stderr, "export requires a session")
os.Exit(1)
}
if err := store.ExportHTML(*export); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
return
}
ref, err := llm.ParseModelRef(*model)
if *model != "" && err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
agent := runtime.NewAgentSession(runtime.Config{
Model: ref.Model,
ThinkingLevel: ref.Thinking,
Session: store,
Stream: liveStream(ref),
OnEvent: func(ev runtime.Event) {
if *mode != "json" {
return
}
raw, _ := json.Marshal(map[string]any{"type": ev.Type, "text": ev.Text, "tool": ev.Tool})
fmt.Println(string(raw))
},
})
srv := &rpc.Server{Agent: agent, Store: store, NoStore: *noSession}
ctx := context.Background()
switch *mode {
case "rpc":
in := bufio.NewScanner(os.Stdin)
in.Buffer(make([]byte, 0, 64*1024), 8*1024*1024)
for in.Scan() {
resp, err := srv.Handle(ctx, in.Bytes())
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
os.Stdout.Write(resp)
}
case "print", "json":
if *prompt == "" {
*prompt = readAll(os.Stdin)
}
res, err := agent.Prompt(ctx, llm.Message{Role: llm.RoleUser, Content: *prompt})
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
if res.Err != nil {
fmt.Fprintln(os.Stderr, res.Err)
os.Exit(1)
}
if *mode == "print" {
fmt.Println(res.Text)
}
default:
fmt.Fprintln(os.Stderr, "unknown mode")
os.Exit(1)
}
}
func liveStream(ref llm.ModelRef) llm.StreamFunc {
provider, ok := llm.Lookup(ref.Provider, nil)
if !ok {
provider = llm.Provider{Protocol: llm.ProtocolOpenAIChat, BaseURL: os.Getenv("OPENAI_BASE_URL"), APIKey: os.Getenv("OPENAI_API_KEY")}
}
client, err := llm.ClientFor(provider)
if err != nil {
return func(ctx context.Context, model string, messages []llm.Message, tools []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
ch := make(chan llm.Event, 1)
ch <- llm.Event{Kind: llm.KindError, Err: err, Message: llm.Message{StopReason: llm.StopError}}
close(ch)
return ch
}
}
return llm.WithRetry(client.Stream, 2, nil)
}
func readAll(r io.Reader) string {
raw, _ := io.ReadAll(r)
return string(raw)
}
+23 -23
View File
@@ -11,27 +11,27 @@ import (
"testing"
"time"
"agent-native/contract"
"agent-native/exec"
)
// FakeBackend 是确定性假实现,仅用于契约套件自测与消费方单测。
// 不模拟任何模型行为;按 Script 逐项返回。
type FakeBackend struct {
Desc contract.BackendDescriptor
Desc exec.BackendDescriptor
// Result 为每次 Execute 返回的终态(浅拷贝后返回)。
Result contract.Result
Result exec.ContractResult
// BlockUntil 若非 nil,Execute 阻塞直到该 channel 关闭或 ctx 取消,
// 用于验证取消语义。
BlockUntil <-chan struct{}
// CanceledWithCtx 记录 Execute 返回时 ctx 是否已取消。
CanceledWithCtx bool
// LastRequest 记录最近一次请求。
LastRequest contract.Request
LastRequest exec.ContractRequest
}
func (f *FakeBackend) Descriptor() contract.BackendDescriptor { return f.Desc }
func (f *FakeBackend) Descriptor() exec.BackendDescriptor { return f.Desc }
func (f *FakeBackend) Execute(ctx context.Context, req contract.Request) (*contract.Result, error) {
func (f *FakeBackend) Execute(ctx context.Context, req exec.ContractRequest) (*exec.ContractResult, error) {
f.LastRequest = req
if f.BlockUntil != nil {
select {
@@ -39,10 +39,10 @@ func (f *FakeBackend) Execute(ctx context.Context, req contract.Request) (*contr
case <-ctx.Done():
f.CanceledWithCtx = true
res := *(&f.Result)
res.Outcome = contract.OutcomeCanceled
res.Err = &contract.Error{
Code: contract.ErrCanceled,
SideEffects: contract.SideEffectNone,
res.Outcome = exec.OutcomeCanceled
res.Err = &exec.Error{
Code: exec.ErrCanceled,
SideEffects: exec.SideEffectNone,
}
return &res, nil
}
@@ -55,15 +55,15 @@ func (f *FakeBackend) Execute(ctx context.Context, req contract.Request) (*contr
type Case struct {
Name string
// Backend 为被测实现。
Backend contract.Backend
Backend exec.ContractBackend
// Request 为请求模板。
Request contract.Request
Request exec.ContractRequest
// CancelAfter 若非零,在等待该时长后取消 ctx(验证取消路径)。
CancelAfter time.Duration
// WantOutcome 为期望终态。
WantOutcome contract.Outcome
WantOutcome exec.Outcome
// WantErrCode 为期望错误码(Failed/Canceled/Unknown 时)。
WantErrCode contract.ErrorCode
WantErrCode exec.ErrorCode
}
// Run 执行一条契约用例。
@@ -96,23 +96,23 @@ func Run(t *testing.T, c Case) {
t.Fatalf("[%s] Err.SideEffects must be explicit (none|possible), got empty", c.Name)
}
}
if res.Outcome == contract.OutcomeSucceeded && res.Err != nil {
if res.Outcome == exec.OutcomeSucceeded && res.Err != nil {
t.Fatalf("[%s] Succeeded result must not carry Err", c.Name)
}
}
// Suite 是最小契约套件:成功、失败带副作用标记、取消三态。
// 每个真实 Backend 实现都必须通过;套件外按实现能力补充专项用例。
func Suite(t *testing.T, mk func() contract.Backend) {
func Suite(t *testing.T, mk func() exec.ContractBackend) {
t.Helper()
t.Run("succeeded_minimal", func(t *testing.T) {
b := mk()
Run(t, Case{
Name: "succeeded",
Backend: b,
Request: contract.Request{ExecutionID: "e1", Prompt: "hi"},
WantOutcome: contract.OutcomeSucceeded,
Name: "succeeded",
Backend: b,
Request: exec.ContractRequest{ExecutionID: "e1", Prompt: "hi"},
WantOutcome: exec.OutcomeSucceeded,
})
})
@@ -130,13 +130,13 @@ func Suite(t *testing.T, mk func() contract.Backend) {
t.Run("error_sideeffects_explicit", func(t *testing.T) {
b := mk()
// 通过描述能力判定是否有失败路径可测;无失败路径的实现跳过。
if !b.Descriptor().Has(contract.CapText) && !b.Descriptor().Has(contract.CapFiles) {
if !b.Descriptor().Has(exec.CapText) && !b.Descriptor().Has(exec.CapFiles) {
t.Skip("no text/files capability to force failure path")
}
// 空提示在多数实现下是确定性失败或成功空输出;仅验证当 Outcome=Failed
// 时错误字段完整。具体实现应补充自身失败注入用例。
res, _ := b.Execute(context.Background(), contract.Request{ExecutionID: "e2"})
if res != nil && res.Outcome == contract.OutcomeFailed {
res, _ := b.Execute(context.Background(), exec.ContractRequest{ExecutionID: "e2"})
if res != nil && res.Outcome == exec.OutcomeFailed {
if res.Err == nil || res.Err.Code == "" || res.Err.SideEffects == "" {
t.Fatalf("failed result must carry Code and SideEffects, got %+v", res.Err)
}
+41
View File
@@ -0,0 +1,41 @@
package conformance
import (
"testing"
"agent-native/exec"
)
func TestFakeBackendSuite(t *testing.T) {
Suite(t, func() exec.ContractBackend {
return &FakeBackend{
Desc: exec.BackendDescriptor{
Name: "fake",
Capabilities: []exec.Capability{exec.CapText},
},
Result: exec.ContractResult{
Outcome: exec.OutcomeSucceeded,
Text: "ok",
},
}
})
}
func TestFakeCancelSemantics(t *testing.T) {
block := make(chan struct{})
b := &FakeBackend{
Desc: exec.BackendDescriptor{Name: "fake", Capabilities: []exec.Capability{exec.CapCancel}},
BlockUntil: block,
}
Run(t, Case{
Name: "cancel",
Backend: b,
Request: exec.ContractRequest{ExecutionID: "e-cancel"},
CancelAfter: 50_000_000,
WantOutcome: exec.OutcomeCanceled,
WantErrCode: exec.ErrCanceled,
})
if !b.CanceledWithCtx {
t.Fatal("backend must observe ctx cancellation before returning")
}
}
-43
View File
@@ -1,43 +0,0 @@
package contract_test
import (
"testing"
"agent-native/conformance"
"agent-native/contract"
)
// 契约自测:fake 后端通过最小套件,验证套件本身与类型语义一致。
func TestFakeBackendSuite(t *testing.T) {
conformance.Suite(t, func() contract.Backend {
return &conformance.FakeBackend{
Desc: contract.BackendDescriptor{
Name: "fake",
Capabilities: []contract.Capability{contract.CapText},
},
Result: contract.Result{
Outcome: contract.OutcomeSucceeded,
Text: "ok",
},
}
})
}
func TestFakeCancelSemantics(t *testing.T) {
block := make(chan struct{})
b := &conformance.FakeBackend{
Desc: contract.BackendDescriptor{Name: "fake", Capabilities: []contract.Capability{contract.CapCancel}},
BlockUntil: block,
}
conformance.Run(t, conformance.Case{
Name: "cancel",
Backend: b,
Request: contract.Request{ExecutionID: "e-cancel"},
CancelAfter: 50_000_000, // 50ms in ns (avoid time import here)
WantOutcome: contract.OutcomeCanceled,
WantErrCode: contract.ErrCanceled,
})
if !b.CanceledWithCtx {
t.Fatal("backend must observe ctx cancellation before returning")
}
}
Vendored Submodule
+1
Submodule deps/agent-intent added at 343b1e6d9a
Vendored Submodule
+1
Submodule deps/error-code-system added at a3ee67bba2
Vendored Submodule
+1
Submodule deps/logging-system added at 49b5d8be60
+39
View File
@@ -66,3 +66,42 @@ baseline_fixed: true
- 本矩阵证据均指向仓库内文件(上文路径),不含运行时截图;
- Batch1 完成后在本文件追加"实测证据"列;
- 上游仅引固定 0.85.1 的 README/docs 结论,不引 main。
---
## R15 扩面批次(full-agent-r1,2026-10-01):从「wrapper 对标」到「完全智能体」
用户定调(2026-10-01):Agent-Native 补全 pi-Agent 全量工具调用能力(网络搜索、代码编写、
系统工具、外部工具调用、技能、提示词),并与 Agent-Squad 融合。扩面域**不做 pi 对标**
(上游 wrapper 未暴露这些能力),改以「标准符合 + conformance 负例 + 产品验收」为准——
对标口径条款对本批新增域不适用,原有 #1–#16 行继续有效。
| 域 | 内容 | 出口证据 |
|---|---|---|
| 执行基座 | exec.Request v0.2(Model/ToolAllowlist/Skills/Budget/SessionScope/ExecutionID/Env)+ Registry.Restrict 策略层(精确名 + 命名空间通配)+ 预算贯穿(token/工具数/截止,budget_exceeded 永久失败)+ 取消传播到工具 | loop_budget / registry_policy / request_v2 / step_retry_budget 测试;关闭 #4/#10/#16 |
| 代码工具面 | glob(** 跨目录)、grep 正则/include/ignore_case、read 行范围分页、multi_edit 原子多处替换 | code_tools_test.go |
| 系统工具 | run_command / run_python:解释器白名单、env 白名单(宿主密钥不透传)、进程组 SIGKILL(超时/取消)、64KB 头尾截断、命令黑名单、脚本落盘 .agent/scripts/;Options.Exec 未配置不注册 | exec_tools_test.go(含杀组时限断言) |
| 网络面 | tavily_search/web_search 经 Options.Search 接线(关闭 #5 尾环);fetch_url:SSRF 硬边界(私网/回环/链路本地/元数据端点逐跳校验)、域名策略、2MB/20s、HTML 剥离 | fetch_test.go + 适配器接线测试 |
| MCP | stdio + Streamable HTTP 客户端、initialize/tools/list(paged)/tools/call、Mcp-Session-Id、mcp__<server>__<tool> 桥接、64 工具/1KB 描述/64KB 结果上限、不可达 fail-closed、真实子进程测试(re-exec fake server) | runtime/mcp/mcp_test.go |
| 技能/提示词 | SKILL.md 加载(目录名权威、frontmatter 描述)、load_skill 渐进披露、声明技能 fail-closed 校验、promptx 装配器(环境/技能索引/交付物) | skills_test / promptx_test / 适配器测试 |
| 会话 | compaction(阈值触发、原子改写、失败留痕不阻断)、元数据 sidecar、崩溃半行容错 | compact_test.go;对齐 #11/#12 |
| 委派 | runtime/subagent 端口 + delegate_task(信息集=委派条款);嵌套执行由宿主注入 | subagent_test.go |
安全声明(不冒充已解决):run_command **不是 OS 沙箱**(宿主负责隔离);技能正文与 MCP
server 描述是提示注入面(只信受信根 + 长度上限);fetch 的 DNS 解析存在 rebinding 窗口。
扩展进程、安装的包和外部 CLI 都以调用者的完整权限运行,同样不是沙箱。
## R16 无界面全量(pi-headless,2026-10-01)
对标口径改为 pi 的无界面能力。不做 TUI、主题、快捷键,也不做订阅制 OAuth。
| 域 | 现状 |
|---|---|
| 会话树 | available:id/parentId、fork/clone、叶子切换、continue/resume、追加式压缩、旧线性文件仍可 Load、export_html |
| 供应商 | available:Anthropic、OpenAI Chat 与 Responses、Google、Azure、兼容预设、models.json、`provider/id:thinking`、图片、缓存 token、自动重试;上下文溢出不重试 |
| AgentSession | available:steer/follow-up、one 与 all、abort、clear_queue、中途换模型;事件含 message_start、tool_execution_update、queue_update、agent_settled |
| 资源 | available:pi 工具名与原名并存、上下文文件、提示词模板、`/skill:name`、项目信任 |
| 扩展与包 | available:stdio JSONL 子进程;install/remove/update/list/config |
| 模式 | available:`cmd/agent-native` 的 print/json/rpc,以及 Go `rpc.Client` |
| exec.Backend | available:`adapters/native` 的 Execute 经 AgentSession,请求/结果字段不变 |
@@ -0,0 +1,54 @@
# 接入开发规范并为执行契约对齐错误码与日志
状态:已批准范围(实现进行中);owner:weichen;主任务:本仓接入提交。
授权与交付终点:在 Agent-Native 内挂载规范子模块、补齐项目入口,并把执行错误与日志接到 Error-Code-System / Logging-System。不改 Gitea 权限,不发布制品。
并行基线:Agent-Native `main` @ `49e6dd1e0fc65de7f259f21d2f63c56e5b165a48`;Development-Guidelines `26b562d2e34fbc560711296a309c91c74ffc55b1`;Error-Code-System `b67696d393e7abfbdd16fdde1064c07f5ecd2f72`;Logging-System `7f5972007bdcb6047ce7f95ee891f53e0af9d136`。日期 2026-09-29。
## 问题与证据
四个基础库此前没有统一的项目入口,也没有把 Development-Guidelines 固定进父仓。Agent-Native 的 `contract.ErrorCode` 仍是短字符串(如 `canceled`),与错误码库的 `agent.*` 规范码只在映射文档中对应,调用方无法从错误值直接得到规范码。runtime 没有可替换的结构化日志,失败只能靠返回值。
经验检索:规范库与两个基础库均为新仓,无已验证经验条目。
## 目标与边界
- 规范以 `deps/development-guidelines` 的 gitlink 为版本权威。
- `contract.ErrorCode` 的取值改为 `agent.*`;`contract.Error` 提供 `ErrorCode()` / `ErrorFields()`,使日志库能识别错误且 `contract` 不导入错误码模块。
- pitransport 对外错误码同步为规范码;`CANCELLED` 并入 `agent.canceled`,`MISSING_TARGET` 并入 `agent.missing_deliverable`。
- runtime 与 native adapter 接受可选 `*logsys.Logger`,nil 时使用空日志器。
- 非目标:不改 Squad 编排;不把本仓变成错误码目录的第二权威;不自动跟踪子模块上游分支。
## 选择与实施
子模块统一放 `deps/`,与后续各仓布局一致。错误码常量改值而不是在调用点拼接前缀,避免两套字符串长期并存。`contract` 只增加鸭子方法,不 `import error-code-system`,保持该包零外部依赖。
1. 挂载 `deps/development-guidelines` 并提交入口文档。
2. 挂载 `deps/error-code-system`、`deps/logging-system`,`go.mod` 用相对 `replace`。
3. 修改 `contract` 与 `adapters/pitransport` 的错误码取值,补日志钩子与 `scripts/submodule-consistency.sh`。
4. 仓库根 `go test` 作为本模块出口。
## 兼容、迁移与恢复
错误码取值变更对按旧字符串比较的调用方是破坏性变更。错误码库已把旧值登记为别名,`Registry.Resolve` 仍可解析旧字符串。`agent-system-multi` 若硬编码旧码,需在其自己的消费提交里跟进;本仓 gitlink 未进主仓组合之前不影响主仓构建。
回退:父仓 PR 恢复旧 gitlink 与旧常量值。无数据库迁移。暂停条件:`go test` 失败或子模块 SHA 无法从批准远端获取。
## 验收
| 需求/验收条件 | 负责模块/契约或不变量 | 验证方法/用例 | 实际结果/证据 |
|---|---|---|---|
| 规范入口可发现且版本固定 | 根 AGENTS.md / gitlink | `git submodule status`;打开相对链接 | 计划阶段待执行 |
| contract 错误码为 agent.* 且鸭子方法可被日志识别 | contract.Error | `go test ./contract/...` | 计划阶段待执行 |
| pitransport 历史码映射到规范码 | adapters/pitransport | `go test ./adapters/pitransport/...` | 计划阶段待执行 |
| 可选日志器为 nil 时行为不变 | runtime / adapters/native | `go test ./runtime/... ./adapters/native/...` | 计划阶段待执行 |
| 用户/系统条件 | 检查层级与方法 | 预期结果 | 必需环境/授权 |
|---|---|---|---|
| 全新 clone 后可测试 | `git clone --recurse-submodules` 后 `go test` | 通过 | 能访问 Gitea SSH |
| 旧错误字符串仍可被错误码库解析 | 错误码库别名测试(该库既有用例) | 别名回到规范码 | Error-Code-System 源码 |
| 未知结果与缺能力 | contract/conformance 既有用例 | 不降级、不回落 Stub | 无 |
| 无日志器的既有调用 | runtime/native 既有测试 | 保持通过 | Go 1.27 |
## 决策与剩余项
多级副本由各父仓 gitlink 锁定;Agent-Squad 构建时只采用 Agent-Squad 自己的 `replace`。一致性脚本在本仓接入两个基础库后启用。验证证据在实现完成后写入同目录的验证记录,不把本方案改称已完成。
@@ -0,0 +1,23 @@
# Agent-Native 接入 agent-intent 意图闸门(可选装饰器)
对应设计:Agent-Intent 仓 `docs/records/2026-09-29-decoupling-and-llm-layering.md`(第三节 b,决策 D7 选仓内装饰器)。
形态:`adapters/intentgate.Wrap(next, recognizer, snapshot, handler) exec.Backend`——Execute 前对 `req.Prompt` 做一次确定性识别,守卫/命中/预期命中分支交宿主 Handler 解释(合成结果语义由宿主定义),Ambiguous/Fallback 或 Handler 放弃时原样透传;识别系统错误 fail-closed。
## 依赖与解耦边界
- 本包是本仓唯一 import `agent-intent` 的位置;contract/exec/runtime/adapters/native 零感知(`GOWORK=off go list -deps ./contract ./exec ./runtime ./adapters/native` 不含 agent-intent)。
- T0 行为解耦:未 Wrap(或任一组件为 nil,直接返回原后端)与 Wrap 了零意图快照,行为与原后端一致(`TestZeroSpecSnapshotPassthroughIdentical` 逐字节对比)。
- 回退程序:删 `adapters/intentgate/` + go.mod 两行 + submodule + `.gitmodules` 条目 + 一致性脚本一行。
## 验证证据(2026-09-29,go test ./... 全绿,intentgate 含 -race)
| 用例 | 外挂意图 | 输入(Prompt) | 识别结果 | 期望 | 结果 |
|---|---|---|---|---|---|
| TestZeroSpecSnapshotPassthroughIdentical | 无(零 spec 快照) | `帮我改一下系统网关地址` | Fallback(快照为空) | 与裸 stub 后端结果逐字节一致,Handler 不触发 | 通过 |
| TestGuardHitInterceptsWithoutModel | guard.config_change(includes: 系统配置/网关;risk=high) | `帮我改一下系统网关地址` | GuardHit,top=guard.config_change | Handler 收到 GuardHit;返回拒绝合成结果;**模型后端零调用** | 通过 |
| TestFallbackPassesThroughToBackend | 同上 | `写一份周报` | Fallback | 透传原后端,Handler 不触发,后端恰好调用 1 次 | 通过 |
| TestHandlerDeclineFallsBackToBackend | 同上 | `帮我改一下系统网关地址` | GuardHit(Handler 放弃) | 回退透传原后端 | 通过 |
| TestNilComponentsReturnNext | n/a | n/a | n/a | 任一组件为 nil → Wrap 返回原后端 | 通过 |
| TestRecognizeErrorFailsClosed | MaxInputLen=8 快照 | 超长输入 | 识别错误 sys.invalid_argument | fail-closed 返回错误,后端零调用 | 通过 |
命令:`GOWORK=off go vet ./... && GOWORK=off go test -count=1 ./...`;`GOWORK=off go test -count=1 -race ./adapters/intentgate/`;`scripts/submodule-consistency.sh`。
+20
View File
@@ -0,0 +1,20 @@
# 验证记录
- 范围/主任务:接入 Development-Guidelines,并把执行错误码与可选日志接到错误码库和日志库。
- 被测版本:工作区 `e65c6ba604c9079e1176d9449e295e0d09bedfd3`。该提交尚未推到批准远端。
- 目标仓/分支与共同起点:Agent-Native `main`,起点 `49e6dd1e0fc65de7f259f21d2f63c56e5b165a48`。
- 最新同步与测试对象:2026-09-29。子模块 `deps/development-guidelines` `26b562d2e34fbc560711296a309c91c74ffc55b1`,`deps/error-code-system` `b67696d393e7abfbdd16fdde1064c07f5ecd2f72`,`deps/logging-system` `7f5972007bdcb6047ce7f95ee891f53e0af9d136`。
- 测试结束/准入前目标复核:未合入远端。干净克隆测的是本机仓库,子模块从批准远端检出。
- 环境:本机 Go 1.27,无模型端点。
- 实际命令:仓库根 `go test -count=1 ./...`;`scripts/submodule-consistency.sh`;`git clone --recurse-submodules` 到 `/tmp/dg-verify/Agent-Native` 后再测一次。
| 预期用例/场景 | 实际是否执行 | 结果 | 证据/缺项 |
|---|---|---|---|
| 工作区全模块测试 | 是 | PASS | `go test -count=1 ./...` |
| 子模块 gitlink 与 HEAD 一致 | 是 | PASS | `scripts/submodule-consistency.sh` |
| 从本机父仓递归克隆后测试 | 是 | PASS | `/tmp/dg-verify/Agent-Native` 再次 `go test -count=1 ./...` |
| 真实模型端点 | 否 | N/A | 本仓测试使用注入流,不覆盖外部模型 |
首轮失败与重试:无。
结论与限制:只证实本机工作区与一次本机路径克隆。`e65c6ba` 还不能从批准远端按 SHA 获取,因此其他仓库在推送前不能把这个 gitlink 当作已发布组合。
剩余项/责任人:推送由仓库所有者决定。推送前 Agent-Squad 的全新远端克隆会因缺少该提交而失败。
+37 -1
View File
@@ -16,7 +16,35 @@ type Request struct {
// OnEvent 可选过程事件回调(nil = 调用方不需要过程,后端行为与现状完全一致)。
// 实现方必须保证:回调在 Execute 返回前完成(同步调用或等待收尾),无泄漏 goroutine;
// 回调内不得阻塞(消费方自行节流/异步入队)。
OnEvent func(ev ProcessEvent)
OnEvent func(ev ProcessEvent) `json:"-"`
// ---- v0.2 能力字段(全部可选;零值 = 既有行为,向后兼容) ----
// Model 覆盖本请求使用的模型(后端构造时的默认模型仍兜底)。
Model string `json:"model,omitempty"`
// ToolAllowlist 收窄可见工具集;出现后端未注册的工具名时 fail-closed。
ToolAllowlist []string `json:"tool_allowlist,omitempty"`
// Skills 声明本次执行需要加载的技能(内核在系统提示中列出并由 load_skill 按需展开)。
Skills []string `json:"skills,omitempty"`
// Budget 单次步骤执行的共享预算;步骤重试应传入剩余额度,不得各自另起一份。
Budget *Budget `json:"budget,omitempty"`
// SessionScope / ExecutionID 只用于日志与过程事件贯穿,不参与控制流。
SessionScope string `json:"session_scope,omitempty"`
ExecutionID string `json:"execution_id,omitempty"`
// Env 是组合根批准后注入执行类工具(run_command 等)的白名单环境变量。
// 后端不得把自身持有的密钥并入其中。
Env map[string]string `json:"env,omitempty"`
}
// Budget 是一次步骤执行的共享预算(agent-execution 标准:重试层各有职责,
// 共享总预算与截止时间,避免传输、步骤与主循环次数相乘)。
// 全部字段可选;零值字段不设限。
type Budget struct {
MaxPromptTokens int `json:"max_prompt_tokens,omitempty"`
MaxCompletionTokens int `json:"max_completion_tokens,omitempty"`
MaxToolCalls int `json:"max_tool_calls,omitempty"`
// Deadline 绝对截止时刻;与 Request.Timeout 先到者生效。
Deadline time.Time `json:"deadline,omitempty"`
}
// ProcessEvent 对 agentx 事件与远程 runner NDJSON 过程帧的统一投影,
@@ -26,6 +54,14 @@ type ProcessEvent struct {
Text string // delta 文本(Type 为 *_delta 时)
Tool string // 工具名(tool_* 时)
Detail string // 工具参数/结果摘要(tool_execution_* 时)
// PromptTokens / CompletionTokens 在 Type=usage 时填写。
PromptTokens int
CompletionTokens int
// 以下在 Type=usage 或 message_end 时填写(N3 WP6:上一请求与缓存命中拆分)。
LastPromptTokens int
LastCompletionTokens int
CacheReadTokens int
CacheWriteTokens int
}
const (
+72 -47
View File
@@ -1,7 +1,8 @@
// Package contract 定义 Agent-Native 的公共执行契约。
// 原 contract 包并入 exec。与本文件同名的执行门面仍是 Request、Result、Backend;
// 设计契约里冲突的四个名字加 Contract 前缀。
//
// 本包是助手(Core)与小队(Squad)共用的唯一执行接口层:Core 注入具体
// Backend 实现,Squad 只表达需求并使用已注入的后端。契约不依赖任何具体
// ContractBackend 实现,Squad 只表达需求并使用已注入的后端。契约不依赖任何具体
// 内核、产品类型、数据库或配置包。
//
// 语义不变量(实现方与消费方都必须遵守):
@@ -20,7 +21,7 @@
// 上下文,不授予跨会话可见性。
// 7. Workspace 的路径约束不是 OS 沙箱;执行不可信第三方工具需要调用方
// 提供的进程/容器隔离,不能由本契约推导。
package contract
package exec
import (
"context"
@@ -50,12 +51,6 @@ const (
CapStructuredOutput Capability = "structured-output"
)
// ToolProfile 是预定义工具策略名;与现有产品配置兼容。
const (
ToolProfileWorkspace = "workspace" // 默认:文档类工具受限集合
ToolProfileReasoning = "reasoning" // 无工具,仅推理
)
// ToolPolicy 描述执行允许的工具范围。实现不得自行扩展;未知工具一律拒绝。
type ToolPolicy struct {
// Profile 为策略名(workspace/reasoning/或产品注册的自定义名)。
@@ -82,8 +77,8 @@ type SessionScope struct {
Session string `json:"session,omitempty"`
}
// Budget 约束一次执行(含共享 ExecutionID 的重试)。
type Budget struct {
// ContractBudget 约束一次执行(含共享 ExecutionID 的重试)。
type ContractBudget struct {
// MaxPromptTokens/MaxCompletionTokens 为 0 时不限制。
MaxPromptTokens int `json:"max_prompt_tokens,omitempty"`
MaxCompletionTokens int `json:"max_completion_tokens,omitempty"`
@@ -98,16 +93,16 @@ type Message struct {
Content string `json:"content"`
}
// Request 是一次授权执行的统一输入。字段为设计契约;现有 file-only 流程
// ContractRequest 是一次授权执行的统一输入。字段为设计契约;现有 file-only 流程
// (Prompt+Instructions+Targets→reply.md)是其子集 facade。
type Request struct {
type ContractRequest struct {
// ExecutionID 由调用方生成,重试与受控 fallback 复用同值。
ExecutionID string `json:"execution_id"`
// SessionScope 为隔离身份;可为零值(一次性任务)。
SessionScope SessionScope `json:"session_scope"`
Prompt string `json:"prompt"`
Instructions string `json:"instructions,omitempty"`
Prompt string `json:"prompt"`
Instructions string `json:"instructions,omitempty"`
// Context 为可选历史消息;实现不得将其与 Prompt 之外的数据混合。
Context []Message `json:"context,omitempty"`
@@ -118,8 +113,8 @@ type Request struct {
Workspace WorkspaceHandle `json:"workspace"`
// ToolPolicy 为工具范围;空 Profile 等同 reasoning。
ToolPolicy ToolPolicy `json:"tool_policy"`
// Budget 为本次执行预算。
Budget Budget `json:"budget"`
// ContractBudget 为本次执行预算。
ContractBudget ContractBudget `json:"budget"`
// Required 为必须满足的能力;解析器据此选择实现,缺失即拒绝。
Required []Capability `json:"required,omitempty"`
@@ -143,12 +138,12 @@ const (
type EventKind string
const (
EventTextDelta EventKind = "text_delta"
EventReasoningDelta EventKind = "reasoning_delta"
EventToolCallDelta EventKind = "tool_call_delta"
EventToolExecStart EventKind = "tool_execution_start"
EventToolExecEnd EventKind = "tool_execution_end"
EventMessageEnd EventKind = "message_end"
EventTextDelta EventKind = "text_delta"
EventReasoningDelta EventKind = "reasoning_delta"
EventToolCallDelta EventKind = "tool_call_delta"
EventToolExecStart EventKind = "tool_execution_start"
EventToolExecEnd EventKind = "tool_execution_end"
EventMessageEnd EventKind = "message_end"
)
// Event 是过程事件帧;消费方(Core)负责映射为 SSE/持久化事件。
@@ -174,30 +169,60 @@ const (
type ErrorCode string
const (
ErrCanceled ErrorCode = "canceled"
ErrDeadlineExceeded ErrorCode = "deadline_exceeded"
ErrBudgetExceeded ErrorCode = "budget_exceeded"
ErrProviderUnavailable ErrorCode = "provider_unavailable"
ErrProviderRejected ErrorCode = "provider_rejected"
ErrToolRejected ErrorCode = "tool_rejected"
ErrPathOutsideWorkspace ErrorCode = "path_outside_workspace"
ErrMissingCapability ErrorCode = "missing_capability"
ErrMissingDeliverable ErrorCode = "missing_deliverable"
ErrEmptyOutput ErrorCode = "empty_output"
ErrUnknownOutcome ErrorCode = "unknown_outcome"
ErrInternal ErrorCode = "internal"
ErrCanceled ErrorCode = "agent.canceled"
ErrDeadlineExceeded ErrorCode = "agent.deadline_exceeded"
ErrBudgetExceeded ErrorCode = "agent.budget_exceeded"
ErrProviderUnavailable ErrorCode = "agent.provider_unavailable"
ErrProviderRejected ErrorCode = "agent.provider_rejected"
ErrToolRejected ErrorCode = "agent.tool_rejected"
ErrPathOutsideWorkspace ErrorCode = "agent.path_outside_workspace"
ErrMissingCapability ErrorCode = "agent.missing_capability"
ErrMissingDeliverable ErrorCode = "agent.missing_deliverable"
ErrEmptyOutput ErrorCode = "agent.empty_output"
ErrUnknownOutcome ErrorCode = "agent.unknown_outcome"
ErrInternal ErrorCode = "agent.internal"
)
// Error 是标准化执行错误。Retryable 仅表示传输级瞬时故障可由 adapter 有界
// 重试或调用方作为新步骤重试;与 SideEffects 独立判定。
type Error struct {
Code ErrorCode `json:"code"`
Reason string `json:"reason,omitempty"`
Retryable bool `json:"retryable,omitempty"`
Code ErrorCode `json:"code"`
Reason string `json:"reason,omitempty"`
Retryable bool `json:"retryable,omitempty"`
SideEffects SideEffects `json:"side_effects"`
}
func (e *Error) Error() string { return string(e.Code) + ": " + e.Reason }
func (e *Error) Error() string {
if e == nil {
return ""
}
return string(e.Code) + ": " + e.Reason
}
// ErrorCode 返回规范码字符串。日志库用这个方法识别错误,因此 contract 不导入错误码模块。
func (e *Error) ErrorCode() string {
if e == nil {
return ""
}
return string(e.Code)
}
// ErrorFields 返回可写入日志的字段。执行标识由调用方随日志字段另传,不放进本结构。
func (e *Error) ErrorFields() map[string]any {
if e == nil {
return nil
}
fields := map[string]any{
"retryable": e.Retryable,
}
if e.Reason != "" {
fields["reason"] = e.Reason
}
if e.SideEffects != "" {
fields["side_effects"] = string(e.SideEffects)
}
return fields
}
// Usage 报告 token 用量;实现报告不了时保持零值,不猜测。
type Usage struct {
@@ -205,14 +230,14 @@ type Usage struct {
CompletionTokens int `json:"completion_tokens,omitempty"`
}
// Result 是执行终态。Text 与 Files 依据任务可为空;Outcome 决定解释。
type Result struct {
// ContractResult 是执行终态。Text 与 Files 依据任务可为空;Outcome 决定解释。
type ContractResult struct {
Outcome Outcome `json:"outcome"`
// Text 为文本结果(CapText 任务必填)。
Text string `json:"text,omitempty"`
// Files 为文件候选产物(内容字节),由调用方决定正式落盘与提交。
Files map[string][]byte `json:"files,omitempty"`
// Logs 为执行日志/过程摘要(兼容现有 Result.Logs)。
// Logs 为执行日志/过程摘要(兼容现有 ContractResult.Logs)。
Logs string `json:"logs,omitempty"`
// Err 为 Failed/Canceled/Unknown 时的标准化错误;Succeeded 时为 nil。
Err *Error `json:"err,omitempty"`
@@ -241,19 +266,19 @@ func (d BackendDescriptor) Has(c Capability) bool {
return false
}
// Backend 是统一执行接口。实现必须:
// ContractBackend 是统一执行接口。实现必须:
// - 在 ctx 取消时尽快返回(Canceled 或 Unknown);
// - 不持有跨执行的可变会话状态(连接池等无状态资源可复用);
// - 不自行扩大工具范围或读取 Workspace 之外的路径。
type Backend interface {
type ContractBackend interface {
Descriptor() BackendDescriptor
// Execute 执行一次授权调用。返回的 Result 永不为 nil;ctx 错误映射为
// Execute 执行一次授权调用。返回的 ContractResult 永不为 nil;ctx 错误映射为
// Outcome=Canceled/Unknown,不返回 Go error 表达业务终态。
Execute(ctx context.Context, req Request) (*Result, error)
Execute(ctx context.Context, req ContractRequest) (*ContractResult, error)
}
// Resolver 按名称与必需能力解析后端。未知名必须返回错误,生产路径不得
// 回落到 Stub。
type Resolver interface {
Resolve(name string, required []Capability) (Backend, error)
Resolve(name string, required []Capability) (ContractBackend, error)
}
+23
View File
@@ -0,0 +1,23 @@
package exec
import "testing"
func TestErrorDuckTypeForLogs(t *testing.T) {
e := &Error{
Code: ErrProviderUnavailable,
Reason: "down",
Retryable: true,
SideEffects: SideEffectNone,
}
if e.ErrorCode() != "agent.provider_unavailable" {
t.Fatal(e.ErrorCode())
}
fields := e.ErrorFields()
if fields["reason"] != "down" || fields["side_effects"] != "none" || fields["retryable"] != true {
t.Fatalf("%v", fields)
}
var nilErr *Error
if nilErr.ErrorCode() != "" || nilErr.ErrorFields() != nil {
t.Fatal("nil error")
}
}
+1
View File
@@ -160,6 +160,7 @@ func combinedFailureText(sig StepFailureSignals) string {
func isPermanentReason(lower string) bool {
return strings.Contains(lower, "prompt_empty") ||
strings.Contains(lower, "unauthorized") ||
strings.Contains(lower, "budget_exceeded") ||
strings.Contains(lower, "治理强制终止") ||
strings.Contains(lower, "治理拦截")
}
+37
View File
@@ -0,0 +1,37 @@
package exec
import (
"errors"
"strings"
"testing"
"time"
)
func TestAssessStepFailureBudgetExceededIsPermanent(t *testing.T) {
ass := AssessStepFailure(StepFailureSignals{
Err: errors.New("budget_exceeded: tool calls 12 > max 10"),
})
if ass.Class != FailurePermanent {
t.Fatalf("budget exhaustion must classify permanent (no auto retry), got %s", ass.Class)
}
if !strings.Contains(ass.Reason, "permanent") {
t.Fatalf("want permanent reason, got %s", ass.Reason)
}
}
func TestAssessStepFailureDeadlinePastIsPermanent(t *testing.T) {
ass := AssessStepFailure(StepFailureSignals{
Result: &Result{ErrorReason: "budget_exceeded: deadline 2026-10-01T00:00:00Z passed"},
})
if ass.Class != FailurePermanent {
t.Fatalf("deadline exhaustion must classify permanent, got %s", ass.Class)
}
}
func TestBudgetZeroValueUnlimited(t *testing.T) {
var b Budget
if b.MaxPromptTokens != 0 || b.MaxCompletionTokens != 0 || b.MaxToolCalls != 0 || !b.Deadline.IsZero() {
t.Fatal("zero budget must express unlimited")
}
_ = time.Now
}
+9
View File
@@ -1,3 +1,12 @@
module agent-native
go 1.27
require (
error-code-system v0.0.0
logging-system v0.0.0
)
replace error-code-system => ./deps/error-code-system
replace logging-system => ./deps/logging-system
View File
+51
View File
@@ -0,0 +1,51 @@
# 通用规范采纳记录
- 项目/负责人:Agent-Native / 仓库所有者 weichen
- 状态:active
- 规范位置:`deps/development-guidelines`
- 规范性文档目录:`policy/`
- 过程文档位置:`docs/records/`(项目内固定目录豁免。当前为个人维护的基础库,单独过程仓会让每份记录都变成跨仓 gitlink;任务主记录仍在 Gitea Issue/PR。豁免一经登记,过程记录不得再写入 `policy/` 或规范库。)
- 版本权威:父仓 gitlink;可读标签:无(内容版本 `0.1.0-draft.6`,提交 `26b562d2e34fbc560711296a309c91c74ffc55b1`)
- 批准/生效依据:本仓接入提交;草案审阅完成后随该提交生效
- 替代范围和迁移说明:本仓此前没有根 `AGENTS.md`。接入后以本文件与根入口为准,不在规范库内保存项目事实。
| 规范/工作流 | 适用条件与范围 | 采纳或不适用理由 | 执行方式/证据 |
|---|---|---|---|
| core/baseline.md | 日常工作 | 所有改动都要守硬边界 | manual |
| workflows/delivery.md、debugging.md、handoff.md | 交付、排障与交接 | 单人维护仍需要可交接的任务记录 | manual |
| standards/collaboration.md | 并行共同起点、节点同步与陈旧候选拒绝 | 本仓会被 Agent-Squad 以 gitlink 消费 | manual |
| standards/verification.md | 验证与结论 | 仓库根 `go vet` / `go test` | manual;命令见 PROJECT.md |
| workflows/design-verification.md、standards/testability.md、standards/failure-containment.md | 契约、适配器与故障分类 | 执行器失败必须可定位且不扩大工具范围 | manual |
| standards/module-contracts.md | contract/exec/runtime/adapters 边界 | 公开端口已按层拆分 | manual |
| standards/observability.md | 错误码、过程事件 | 与 Error-Code-System / Logging-System 对齐 | manual |
| standards/agent-execution.md | 能力协商与后端回退 | 本仓就是执行契约的权威 | manual |
| standards/multi-repo.md | submodule 与固定组合 | 规范与基础库以 gitlink 锁定 | manual |
| standards/security.md | 路径授权、工具范围、凭证 | 凭证不入库;路径授权不等于 OS 沙箱 | manual |
| standards/documentation.md | 三层文档 | `policy/` 与 `docs/records/` 已分开 | manual |
| standards/data-reliability.md | 执行副作用与重放 | 无本仓数据库;适用副作用分类,不扩展成存储设计 | manual |
| standards/configuration.md | 调用方注入的模型与工具配置 | 本仓不自带多实例部署配置 | manual |
| standards/release-operations.md | 独立发布流水线 | 当前无制品仓库与部署入口 | 不适用 |
| workflows/automation.md | 定时与后台任务 | 本仓无 daemon | 不适用 |
## 项目差异
| 原规则/章节 | 项目采用方式 | 原因与风险 | 负责人/复查条件 |
|---|---|---|---|
| documentation.md 过程仓默认独立仓库 | 过程记录放 `docs/records/` | 个人基础库;风险是记录与代码同 PR 时容易把证据写成第二权威。任务状态仍只认 Gitea Issue/PR | 出现第二名维护者或对外发布流程时复查 |
默认做法差异说明理由;硬边界冲突须有明确有效决策,不能用此表自授权。建议不采用无需登记。
## 本次升级或首次接入验证
- 入口:根 `AGENTS.md`、`PROJECT.md` 指向 `deps/development-guidelines`。
- 子模块:`deps/development-guidelines` 固定 `26b562d2e34fbc560711296a309c91c74ffc55b1`。
- `.gitmodules` 使用批准的上游 SSH URL,不含本机路径或凭证。
- 执行方式保持 manual:挂载 Markdown 不自动改变 CI。
- 回退:恢复本提交之前的 gitlink,并删除根入口与 `policy/guidelines-adoption.md` 中对应条款;无已生效的 CI 或权限变更需要另恢复。
### 2026-09-29 追加:agent-intent 可选意图闸门
- 新增 `deps/agent-intent`(固定 `55780ae2cce4ae98d8b0557b8234af8833a7c522`),`deps/error-code-system` 前进到 `dbeed2f1b67eb417ce089e7033a228578fbbeb03`(追加 `squad.intent_refused`,兼容性变更无)。
- 依赖边界新增:唯 `adapters/intentgate` 可 import agent-intent;不使用该包时核心与 agent-intent 无编译关系。
- 接入与验证记录见 [docs/records/2026-09-29-intentgate-adoption.md](../docs/records/2026-09-29-intentgate-adoption.md)。
- 回退:删除 `adapters/intentgate/`、go.mod 的 agent-intent require/replace、`.gitmodules` 对应条目与 submodule,`scripts/submodule-consistency.sh` 移除该路径。
+54
View File
@@ -0,0 +1,54 @@
package rpc
import (
"bufio"
"context"
"encoding/json"
"io"
)
// Client is the Go SDK for the JSONL RPC mode.
type Client struct {
in io.Writer
out *bufio.Reader
}
// NewClient wraps a bidirectional pipe. Commands are one JSON object per line.
func NewClient(in io.Writer, out io.Reader) *Client {
return &Client{in: in, out: bufio.NewReader(out)}
}
// Call sends one command and reads the matching response.
func (c *Client) Call(ctx context.Context, req Request) (Response, error) {
raw, err := json.Marshal(req)
if err != nil {
return Response{}, err
}
if _, err := c.in.Write(append(raw, '\n')); err != nil {
return Response{}, err
}
type result struct {
resp Response
err error
}
ch := make(chan result, 1)
go func() {
line, err := c.out.ReadBytes('\n')
if err != nil {
ch <- result{err: err}
return
}
var resp Response
if err := json.Unmarshal(line, &resp); err != nil {
ch <- result{err: err}
return
}
ch <- result{resp: resp}
}()
select {
case <-ctx.Done():
return Response{}, ctx.Err()
case got := <-ch:
return got.resp, got.err
}
}
+213
View File
@@ -0,0 +1,213 @@
package rpc
import (
"context"
"encoding/json"
"fmt"
"agent-native/runtime"
"agent-native/runtime/llm"
"agent-native/runtime/session"
)
// Request is one RPC command. Type matches the pi headless command set.
type Request struct {
ID string `json:"id,omitempty"`
Type string `json:"type"`
Message string `json:"message,omitempty"`
Model string `json:"model,omitempty"`
ThinkingLevel string `json:"thinkingLevel,omitempty"`
Path string `json:"path,omitempty"`
Name string `json:"name,omitempty"`
Leaf string `json:"leaf,omitempty"`
Keep int `json:"keep,omitempty"`
}
// Response is the synchronous result of one command.
type Response struct {
ID string `json:"id,omitempty"`
Type string `json:"type"`
OK bool `json:"ok"`
Text string `json:"text,omitempty"`
Error string `json:"error,omitempty"`
Result any `json:"result,omitempty"`
// Status 是命令回执:started 已接手,queued 先排队,handled 已处理完。
Status string `json:"status,omitempty"`
}
// Server serves RPC commands against one agent session and its transcript.
type Server struct {
Agent *runtime.AgentSession
Store *session.Store
NoStore bool
}
// Handle runs one command and returns the JSON response line.
func (s *Server) Handle(ctx context.Context, line []byte) ([]byte, error) {
var req Request
if err := json.Unmarshal(line, &req); err != nil {
return encode(Response{Type: "response", OK: false, Error: err.Error()})
}
resp := s.dispatch(ctx, req)
resp.ID = req.ID
if resp.Type == "" {
resp.Type = "response"
}
return encode(resp)
}
func (s *Server) dispatch(ctx context.Context, req Request) Response {
switch req.Type {
case "prompt":
if s.Agent == nil {
return fail("no agent")
}
msg := llm.Message{Role: llm.RoleUser, Content: req.Message}
if s.Agent.Busy() {
s.Agent.FollowUp(msg)
return Response{OK: true, Status: "queued"}
}
res, err := s.Agent.Prompt(ctx, msg)
if err != nil {
return fail(err.Error())
}
if res.Err != nil {
return fail(res.Err.Error())
}
return Response{OK: true, Text: res.Text, Status: "handled"}
case "steer":
if s.Agent == nil {
return fail("no agent")
}
s.Agent.Steer(llm.Message{Role: llm.RoleUser, Content: req.Message})
return Response{OK: true, Status: queueStatus(s.Agent.Busy())}
case "follow_up":
if s.Agent == nil {
return fail("no agent")
}
s.Agent.FollowUp(llm.Message{Role: llm.RoleUser, Content: req.Message})
return Response{OK: true, Status: queueStatus(s.Agent.Busy())}
case "abort":
s.Agent.Abort()
return Response{OK: true}
case "clear_queue":
s.Agent.ClearQueue()
return Response{OK: true}
case "set_model":
if err := s.Agent.SetModel(req.Model, req.ThinkingLevel); err != nil {
return fail(err.Error())
}
return Response{OK: true}
case "compact":
keep := req.Keep
if keep <= 0 {
keep = 4
}
done, err := s.Agent.Compact(keep, func(messages []llm.Message) (string, error) {
return fmt.Sprintf("%d messages", len(messages)), nil
})
if err != nil {
return fail(err.Error())
}
return Response{OK: true, Result: done}
case "get_state":
leaf, _ := s.Store.Leaf()
return Response{OK: true, Result: map[string]any{"leaf": leaf, "name": s.Store.Name(), "path": s.Store.Path()}}
case "get_tree":
entries, err := s.Store.Entries()
if err != nil {
return fail(err.Error())
}
return Response{OK: true, Result: entries}
case "get_entries":
entries, err := s.Store.Entries()
if err != nil {
return fail(err.Error())
}
return Response{OK: true, Result: entries}
case "get_last_assistant_text":
msgs, err := s.Store.Load()
if err != nil {
return fail(err.Error())
}
text := ""
for _, m := range msgs {
if m.Role == llm.RoleAssistant {
text = m.Content
}
}
return Response{OK: true, Text: text}
case "set_session_name":
if err := s.Store.SetName(req.Name); err != nil {
return fail(err.Error())
}
return Response{OK: true}
case "fork":
dest := req.Path
if dest == "" {
return fail("path is required")
}
if _, err := s.Store.Fork(dest, req.Leaf); err != nil {
return fail(err.Error())
}
return Response{OK: true, Text: dest}
case "clone":
if req.Path == "" {
return fail("path is required")
}
if _, err := s.Store.Clone(req.Path); err != nil {
return fail(err.Error())
}
return Response{OK: true, Text: req.Path}
case "switch_session":
if req.Path == "" {
return fail("path is required")
}
st, err := session.Open(req.Path)
if err != nil {
return fail(err.Error())
}
s.Store = st
return Response{OK: true}
case "new_session":
if req.Path == "" {
return fail("path is required")
}
st, err := session.Open(req.Path)
if err != nil {
return fail(err.Error())
}
s.Store = st
return Response{OK: true, Text: req.Path}
case "export_html":
if req.Path == "" {
return fail("path is required")
}
if err := s.Store.ExportHTML(req.Path); err != nil {
return fail(err.Error())
}
return Response{OK: true, Text: req.Path}
case "get_session_stats":
st := s.Store.Stats()
return Response{OK: true, Result: st}
default:
return fail("unknown command " + req.Type)
}
}
func fail(msg string) Response { return Response{OK: false, Error: msg} }
func queueStatus(busy bool) string {
if busy {
return "queued"
}
return "started"
}
func encode(resp Response) ([]byte, error) {
raw, err := json.Marshal(resp)
if err != nil {
return nil, err
}
return append(raw, '\n'), nil
}
+75
View File
@@ -0,0 +1,75 @@
package rpc
import (
"context"
"io"
"path/filepath"
"testing"
"agent-native/runtime"
"agent-native/runtime/llm"
"agent-native/runtime/session"
)
func TestRPCSessionCommandsAndClient(t *testing.T) {
dir := t.TempDir()
store, err := session.Open(filepath.Join(dir, "s.jsonl"))
if err != nil {
t.Fatal(err)
}
if err := store.Append(llm.Message{Role: llm.RoleAssistant, Content: "ready"}); err != nil {
t.Fatal(err)
}
agent := runtime.NewAgentSession(runtime.Config{
Model: "fake",
Stream: func(ctx context.Context, model string, messages []llm.Message, td []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
ch := make(chan llm.Event, 1)
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{Role: llm.RoleAssistant, Content: "echo", StopReason: llm.StopEnd}}
close(ch)
return ch
},
})
srv := &Server{Agent: agent, Store: store}
clientIn, serverOut := io.Pipe()
serverIn, clientOut := io.Pipe()
go func() {
defer serverOut.Close()
buf := make([]byte, 4096)
for {
n, err := serverIn.Read(buf)
if n > 0 {
resp, herr := srv.Handle(context.Background(), buf[:n])
if herr == nil {
_, _ = serverOut.Write(resp)
}
}
if err != nil {
return
}
}
}()
client := NewClient(clientOut, clientIn)
resp, err := client.Call(context.Background(), Request{ID: "1", Type: "get_last_assistant_text"})
if err != nil || !resp.OK || resp.Text != "ready" {
t.Fatalf("%+v %v", resp, err)
}
fork := filepath.Join(dir, "fork.jsonl")
resp, err = client.Call(context.Background(), Request{Type: "fork", Path: fork})
if err != nil || !resp.OK {
t.Fatalf("fork %+v %v", resp, err)
}
resp, err = client.Call(context.Background(), Request{Type: "prompt", Message: "hi"})
if err != nil || !resp.OK || resp.Text != "echo" || resp.Status != "handled" {
t.Fatalf("prompt %+v %v", resp, err)
}
resp, err = client.Call(context.Background(), Request{Type: "steer", Message: "later"})
if err != nil || !resp.OK || resp.Status != "started" {
t.Fatalf("steer %+v %v", resp, err)
}
htmlPath := filepath.Join(dir, "out.html")
resp, err = client.Call(context.Background(), Request{Type: "export_html", Path: htmlPath})
if err != nil || !resp.OK {
t.Fatalf("export %+v %v", resp, err)
}
_ = clientOut.Close()
}
+95
View File
@@ -0,0 +1,95 @@
package runtime
import (
"context"
"errors"
"testing"
"agent-native/runtime/hooks"
"agent-native/runtime/llm"
"agent-native/runtime/tools"
)
func TestHookAdaptersMatchDirectCallbacks(t *testing.T) {
direct := runGate(t, false)
adapted := runGate(t, true)
if direct.stop != adapted.stop || direct.text != adapted.text {
t.Fatalf("stop/text direct=%s %q adapted=%s %q", direct.stop, direct.text, adapted.stop, adapted.text)
}
if direct.denies != adapted.denies || direct.reminders != adapted.reminders || direct.errors != adapted.errors {
t.Fatalf("counts direct=%+v adapted=%+v", direct, adapted)
}
if direct.denies != 1 || direct.reminders == 0 {
t.Fatalf("unexpected counts %+v", direct)
}
}
type gateStats struct {
stop, text string
denies, reminders, errors int
}
func runGate(t *testing.T, viaHooks bool) gateStats {
t.Helper()
var stats gateStats
before := func(call llm.ToolCall) error {
if call.Name == "read_file" {
stats.denies++
return errors.New("denied")
}
return nil
}
reminders := func(int) []string {
stats.reminders++
return []string{"记一笔"}
}
stop := func(last llm.Message, info StopInfo) StopDecision {
if info.Continuations < 1 {
return StopDecision{Continue: true, Reason: "再看一眼"}
}
return StopDecision{}
}
cfg := Config{
Model: "fake",
Tools: tools.WorkspaceTools(t.TempDir()),
Stream: script(
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
ID: "c1", Name: "read_file", Arguments: `{"path":"a.md"}`,
}}},
llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd},
),
Reminders: reminders,
}
if viaHooks {
bus := hooks.New()
bus.On(hooks.AdaptBefore(func(name, args string) error {
return before(llm.ToolCall{Name: name, Arguments: args})
}))
bus.On(func(c hooks.Call) hooks.Outcome {
if c.Event != hooks.Stop {
return hooks.Outcome{}
}
d := stop(llm.Message{Content: c.LastText}, StopInfo{Continuations: c.Continuations, ToolCalls: c.ToolCalls})
if d.Continue {
return hooks.Outcome{Decision: hooks.Block, Reason: d.Reason, Replacement: d.Replacement, Notice: d.Notice}
}
return hooks.Outcome{Decision: hooks.Allow, Notice: d.Notice}
})
cfg.Hooks = bus
} else {
cfg.BeforeTool = before
cfg.Stop = stop
}
res, err := Run(context.Background(), cfg, llm.Message{Role: llm.RoleUser, Content: "读文件"})
if err != nil {
t.Fatal(err)
}
stats.stop = string(res.StopReason)
stats.text = res.Text
for _, m := range res.Messages {
if m.Role == llm.RoleTool && m.IsError {
stats.errors++
}
}
return stats
}
+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")
}
}
+326
View File
@@ -0,0 +1,326 @@
// Package checkpoint 记录写文件之前的内容,并按用户消息把文件退回去。
// 只追踪显式交给它的写入。shell 与 python 的改动不经过这里。
package checkpoint
import (
"bytes"
"encoding/json"
"errors"
"os"
"path/filepath"
"sync"
)
// Record 是一次被追踪的写入。Before 是写入前的字节,After 是写入后的字节。
type Record struct {
Seq int `json:"seq"`
Anchor string `json:"anchor,omitempty"`
Root string `json:"root"`
Path string `json:"path"`
Existed bool `json:"existed"`
Before []byte `json:"before,omitempty"`
After []byte `json:"after,omitempty"`
}
// Result 是一次回滚的分类。Skipped 是当前内容既不是写入后也不是写入前的文件
// (含目标是符号链接的情况——恢复会跟出根外,按外部改动处理)。
// Failed 是恢复动作本身失败的文件(Rewind 不因单文件失败中止,继续其余)。
type Result struct {
Restored []string
Skipped []string
Failed []string
}
// Log 是追加写的检查点文件。
type Log struct {
path string
mu sync.Mutex
lastSeq int // Open 时一次读出的最大 Seq;Record 递增(不再全读)
}
func Open(path string) (*Log, error) {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return nil, err
}
f, err := os.OpenFile(path, os.O_CREATE, 0o644)
if err != nil {
return nil, err
}
_ = f.Close()
l := &Log{path: path}
// 初始化序号(一次读,之后递增)。
if all, err := l.read(); err == nil && len(all) > 0 {
l.lastSeq = all[len(all)-1].Seq
}
return l, nil
}
func (l *Log) Path() string { return l.path }
// Record 追加一条检查点。rel 是相对 root 的路径。
func (l *Log) Record(anchor, root, rel string, before []byte, existed bool, after []byte) error {
if l == nil {
return nil
}
l.mu.Lock()
defer l.mu.Unlock()
l.lastSeq++
rec := Record{
Seq: l.lastSeq, Anchor: anchor, Root: root, Path: filepath.ToSlash(rel),
Existed: existed, Before: append([]byte(nil), before...), After: append([]byte(nil), after...),
}
raw, err := json.Marshal(rec)
if err != nil {
return err
}
f, err := os.OpenFile(l.path, os.O_APPEND|os.O_WRONLY, 0o644)
if err != nil {
return err
}
defer f.Close()
_, err = f.Write(append(raw, '\n'))
return err
}
// Compact 只保留最近 keepAnchors 个 anchor 的记录(按 Seq 序判定新旧,
// 保留原 Seq 不重排,序号单调性不破)。keepAnchors ≤ 0 不动。
// 重写是原子的(临时文件 + rename)。
func (l *Log) Compact(keepAnchors int) error {
if l == nil || keepAnchors <= 0 {
return nil
}
l.mu.Lock()
defer l.mu.Unlock()
all, err := l.read()
if err != nil {
return err
}
var anchors []string
seen := map[string]bool{}
for _, rec := range all {
if !seen[rec.Anchor] {
seen[rec.Anchor] = true
anchors = append(anchors, rec.Anchor)
}
}
if len(anchors) <= keepAnchors {
return nil
}
keep := map[string]bool{}
for _, a := range anchors[len(anchors)-keepAnchors:] {
keep[a] = true
}
tmp := l.path + ".compact.tmp"
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
if err != nil {
return err
}
enc := json.NewEncoder(f)
for _, rec := range all {
if keep[rec.Anchor] {
if err := enc.Encode(rec); err != nil {
_ = f.Close()
_ = os.Remove(tmp)
return err
}
}
}
if err := f.Close(); err != nil {
_ = os.Remove(tmp)
return err
}
return os.Rename(tmp, l.path)
}
// Plan 列出从 anchor 起(含)会被回滚的文件,不改磁盘。
// ordered 是用户消息 id 的时间顺序。
func (l *Log) Plan(anchor string, ordered []string) (Result, error) {
if l == nil {
return Result{}, nil
}
l.mu.Lock()
defer l.mu.Unlock()
all, err := l.read()
if err != nil {
return Result{}, err
}
return classify(all, anchor, ordered), nil
}
// Rewind 把 anchor 及其后的被追踪写入退回写入前。
// 当前内容与最后一次记录的 After 不一致时跳过并列入 Skipped。
func (l *Log) Rewind(anchor string, ordered []string) (Result, error) {
if l == nil {
return Result{}, nil
}
l.mu.Lock()
defer l.mu.Unlock()
all, err := l.read()
if err != nil {
return Result{}, err
}
plan := classify(all, anchor, ordered)
// 单文件失败不中止:符号链接目标归入 Skipped(外部改动语义),
// 其余恢复错误记入 Failed 并继续(部分恢复对调用方可读)。
restored := plan.Restored[:0]
for _, rel := range plan.Restored {
group := groupFor(selected(all, anchor, ordered), rel)
if err := restore(group); err != nil {
if errors.Is(err, errSymlinkTarget) {
plan.Skipped = append(plan.Skipped, rel)
continue
}
plan.Failed = append(plan.Failed, rel)
continue
}
restored = append(restored, rel)
}
plan.Restored = restored
return plan, nil
}
func (l *Log) read() ([]Record, error) {
b, err := os.ReadFile(l.path)
if err != nil {
return nil, err
}
var out []Record
for _, line := range bytes.Split(b, []byte("\n")) {
line = bytes.TrimSpace(line)
if len(line) == 0 {
continue
}
var rec Record
if err := json.Unmarshal(line, &rec); err != nil {
return nil, err
}
out = append(out, rec)
}
return out, nil
}
func selected(all []Record, anchor string, ordered []string) []Record {
keep := map[string]bool{}
on := false
for _, id := range ordered {
if id == anchor {
on = true
}
if on {
keep[id] = true
}
}
if anchor != "" && len(ordered) == 0 {
keep[anchor] = true
}
var out []Record
for _, rec := range all {
if keep[rec.Anchor] {
out = append(out, rec)
}
}
return out
}
func classify(all []Record, anchor string, ordered []string) Result {
sel := selected(all, anchor, ordered)
seen := map[string]bool{}
var restored, skipped []string
for _, rec := range sel {
if seen[rec.Path] {
continue
}
seen[rec.Path] = true
group := groupFor(sel, rec.Path)
switch fileState(group) {
case stateRestore:
restored = append(restored, rec.Path)
case stateSkip:
skipped = append(skipped, rec.Path)
}
}
return Result{Restored: restored, Skipped: skipped}
}
func groupFor(sel []Record, rel string) []Record {
var group []Record
for _, rec := range sel {
if rec.Path == rel {
group = append(group, rec)
}
}
return group
}
const (
stateAlready = iota
stateRestore
stateSkip
)
func fileState(group []Record) int {
if len(group) == 0 {
return stateAlready
}
first, last := group[0], group[len(group)-1]
abs := filepath.Join(last.Root, filepath.FromSlash(last.Path))
current, err := os.ReadFile(abs)
missing := os.IsNotExist(err)
if err != nil && !missing {
return stateSkip
}
if missing {
if !last.Existed && len(last.After) == 0 {
return stateAlready
}
if !first.Existed && bytes.Equal(nil, first.Before) && len(last.After) > 0 {
return stateSkip
}
return stateSkip
}
if bytes.Equal(current, last.After) {
if !first.Existed {
return stateRestore
}
if bytes.Equal(current, first.Before) {
return stateAlready
}
return stateRestore
}
if first.Existed && bytes.Equal(current, first.Before) {
return stateAlready
}
return stateSkip
}
var errSymlinkTarget = errors.New("checkpoint: restore target is a symlink")
// restoreForTesting 是测试缝:非空时在每次恢复前调用(返回错误即恢复失败)。
// 生产代码永不应设置它;恢复期的故障注入无法绕开分类层的预检
// (权限类失败对 root 不可重现,故用显式缝)。
var restoreForTesting func(abs string) error
func restore(group []Record) error {
if len(group) == 0 {
return nil
}
first := group[0]
abs := filepath.Join(first.Root, filepath.FromSlash(first.Path))
if restoreForTesting != nil {
if err := restoreForTesting(abs); err != nil {
return err
}
}
// 符号链接防护:恢复会跟出根外(与 fs_write_file 的防护同语义)。
// 按外部改动处理——由调用方归入 Skipped,不写不删。
if info, err := os.Lstat(abs); err == nil && info.Mode()&os.ModeSymlink != 0 {
return errSymlinkTarget
}
if !first.Existed {
return os.Remove(abs)
}
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
return err
}
return os.WriteFile(abs, first.Before, 0o644)
}
+80
View File
@@ -0,0 +1,80 @@
package checkpoint
import (
"os"
"path/filepath"
"testing"
)
func TestRewindRestoresTrackedWriteAndSkipsExternalChange(t *testing.T) {
root := t.TempDir()
log, err := Open(filepath.Join(t.TempDir(), "cp.jsonl"))
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "keep.txt"), []byte("old"), 0o644); err != nil {
t.Fatal(err)
}
if err := log.Record("m1", root, "keep.txt", []byte("old"), true, []byte("new")); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "keep.txt"), []byte("new"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "side.txt"), []byte("from-shell"), 0o644); err != nil {
t.Fatal(err)
}
if err := log.Record("m1", root, "moved.txt", []byte("a"), true, []byte("b")); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "moved.txt"), []byte("someone-else"), 0o644); err != nil {
t.Fatal(err)
}
got, err := log.Rewind("m1", []string{"m1"})
if err != nil {
t.Fatal(err)
}
if len(got.Restored) != 1 || got.Restored[0] != "keep.txt" {
t.Fatalf("restored %#v", got.Restored)
}
if len(got.Skipped) != 1 || got.Skipped[0] != "moved.txt" {
t.Fatalf("skipped %#v", got.Skipped)
}
body, err := os.ReadFile(filepath.Join(root, "keep.txt"))
if err != nil || string(body) != "old" {
t.Fatalf("keep.txt %q %v", body, err)
}
side, err := os.ReadFile(filepath.Join(root, "side.txt"))
if err != nil || string(side) != "from-shell" {
t.Fatalf("untracked file changed: %q %v", side, err)
}
moved, _ := os.ReadFile(filepath.Join(root, "moved.txt"))
if string(moved) != "someone-else" {
t.Fatalf("external edit was reverted: %q", moved)
}
}
func TestRewindRemovesCreatedFile(t *testing.T) {
root := t.TempDir()
log, err := Open(filepath.Join(t.TempDir(), "cp.jsonl"))
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "new.txt"), []byte("hi"), 0o644); err != nil {
t.Fatal(err)
}
if err := log.Record("m2", root, "new.txt", nil, false, []byte("hi")); err != nil {
t.Fatal(err)
}
got, err := log.Rewind("m2", []string{"m1", "m2"})
if err != nil {
t.Fatal(err)
}
if len(got.Restored) != 1 {
t.Fatalf("restored %#v", got.Restored)
}
if _, err := os.Stat(filepath.Join(root, "new.txt")); !os.IsNotExist(err) {
t.Fatalf("created file still present: %v", err)
}
}
+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 造出同名文件")
}
}
+51
View File
@@ -0,0 +1,51 @@
"""工作流脚本回调。用法:import agentwf as wf。
请求写到 fd 3,响应从 fd 4 按行读回。宿主一次只处理一条。
"""
import json
import os
_seq = 0
_out = os.fdopen(3, "w", encoding="utf-8", buffering=1)
_inp = os.fdopen(4, "r", encoding="utf-8", buffering=1)
def _rpc(op, **fields):
global _seq
_seq += 1
msg = {"id": str(_seq), "op": op}
msg.update(fields)
_out.write(json.dumps(msg, ensure_ascii=False) + "\n")
_out.flush()
line = _inp.readline()
if not line:
raise RuntimeError("workflow host closed")
resp = json.loads(line)
if not resp.get("ok"):
raise RuntimeError(resp.get("error") or "workflow call failed")
return resp.get("output", "")
def tool(name, args=None):
return _rpc("tool.call", name=name, args=args or {})
def ask(question, choices=None):
return _rpc("hitl.ask", question=question, choices=choices or [])
def agent(name, task):
return _rpc("agent.spawn", agent=name, task=task)
def artifact(name, content):
return _rpc("artifact.register", name=name, content=content)
def card(title, kind="result"):
return _rpc("card.create", title=title, kind=kind)
def step(step_id, title, status):
return _rpc("step", stepId=step_id, title=title, status=status)
+12
View File
@@ -0,0 +1,12 @@
package codeorch
// Forbidden 是脚本不能再调用的工具:解释器、自身、派发和工作流。
func Forbidden(name string) bool {
switch name {
case "run_python", "run_shell", "run_command", "run_tool_script",
"delegate_task", "squad_orchestrate", "run_workflow", "review_findings":
return true
default:
return false
}
}
+380
View File
@@ -0,0 +1,380 @@
// Package codeorch 在会话目录里跑一段 Python,并按行把工具调用交回宿主。
// 工作流脚本和 run_tool_script 共用这一套进程与限额。
package codeorch
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
"unicode/utf8"
_ "embed"
)
//go:embed agentwf.py
var agentwfSource string
const (
defaultTimeout = 120 * time.Second
defaultMaxWait = 600 * time.Second
defaultCalls = 50
defaultResult = 1 << 20
defaultModel = 16000
)
var (
helperOnce sync.Once
helperDir string
helperErr error
)
// Call 是脚本发来的一行请求。Args 与 Choices 保持原始 JSON。
type Call struct {
ID string `json:"id"`
Op string `json:"op"`
Name string `json:"name"`
Args json.RawMessage `json:"args"`
Question string `json:"question"`
Choices json.RawMessage `json:"choices"`
Agent string `json:"agent"`
Task string `json:"task"`
Content string `json:"content"`
Title string `json:"title"`
Kind string `json:"kind"`
Status string `json:"status"`
StepID string `json:"stepId"`
}
// Limits 控制一次脚本运行。零值使用工具路径的缺省:120 秒、最长 600 秒、50 次、1 MiB、16k 字符。
type Limits struct {
Timeout time.Duration
MaxTimeout time.Duration
MaxCalls int
MaxResultBytes int
MaxModelChars int
FullDir string
}
// Outcome 是给模型的文本、已成功的调用,以及超限时的全文路径。
type Outcome struct {
Output string
FullPath string
Calls []string
Err error
}
// Starter 按工作目录和脚本路径准备命令。空则用 python3。
type Starter func(ctx context.Context, workDir, path string) (*exec.Cmd, error)
// Dispatch 处理一条回调。返回的错误会写回脚本并记入最终结果。
type Dispatch func(ctx context.Context, call Call) (string, error)
// ModelText 把截断后的输出、已执行调用和错误拼成给模型的文本。
func (o Outcome) ModelText() string {
text := o.Output
if o.Err != nil {
msg := o.Err.Error()
if len(o.Calls) > 0 {
msg = "已执行:" + strings.Join(o.Calls, "、") + "\n" + msg
}
if text != "" {
text += "\n"
}
text += msg
}
if o.FullPath != "" {
if text != "" {
text += "\n"
}
text += "全文在 " + o.FullPath
}
return text
}
// RunFile 执行已经落盘的脚本。
func RunFile(ctx context.Context, workDir, path string, lim Limits, start Starter, dispatch Dispatch) Outcome {
if ctx == nil {
ctx = context.Background()
}
timeout := lim.bounded()
runCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
helper, err := helperPath()
if err != nil {
return Outcome{Err: err}
}
if start == nil {
start = defaultStart
}
cmd, err := start(runCtx, workDir, path)
if err != nil {
return Outcome{Err: err}
}
cmd.Env = prependPythonPath(cmd.Env, helper)
reqR, reqW, err := os.Pipe()
if err != nil {
return Outcome{Err: err}
}
respR, respW, err := os.Pipe()
if err != nil {
reqR.Close()
reqW.Close()
return Outcome{Err: err}
}
cmd.ExtraFiles = []*os.File{reqW, respR}
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Start(); err != nil {
reqR.Close()
reqW.Close()
respR.Close()
respW.Close()
return Outcome{Err: err}
}
reqW.Close()
respR.Close()
defer reqR.Close()
defer respW.Close()
calls, callErr := serve(runCtx, reqR, respW, lim, dispatch)
if callErr != nil {
cancel()
}
waitErr := cmd.Wait()
out := Outcome{Output: clipJoined(&stdout, &stderr), Calls: calls}
out = clipModel(out, lim)
switch {
case callErr != nil:
out.Err = callErr
case runCtx.Err() == context.DeadlineExceeded:
out.Err = fmt.Errorf("脚本超时")
case runCtx.Err() == context.Canceled:
out.Err = fmt.Errorf("脚本已取消")
case waitErr != nil:
out.Err = fmt.Errorf("脚本退出:%v", waitErr)
}
return out
}
// Run 把脚本体写入工作目录后再执行。
func Run(ctx context.Context, workDir, body string, lim Limits, start Starter, dispatch Dispatch) Outcome {
dir := filepath.Join(workDir, ".agent")
if err := os.MkdirAll(dir, 0o755); err != nil {
return Outcome{Err: err}
}
path := filepath.Join(dir, fmt.Sprintf("orch-%d.py", time.Now().UnixNano()))
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
return Outcome{Err: err}
}
return RunFile(ctx, workDir, path, lim, start, dispatch)
}
func (l Limits) bounded() time.Duration {
d := l.Timeout
if d <= 0 {
d = defaultTimeout
}
max := l.MaxTimeout
if max <= 0 {
max = defaultMaxWait
}
if d > max {
return max
}
return d
}
func (l Limits) calls() int {
if l.MaxCalls <= 0 {
return defaultCalls
}
return l.MaxCalls
}
func (l Limits) resultBytes() int {
if l.MaxResultBytes <= 0 {
return defaultResult
}
return l.MaxResultBytes
}
func (l Limits) modelChars() int {
if l.MaxModelChars <= 0 {
return defaultModel
}
return l.MaxModelChars
}
func serve(ctx context.Context, reqR, respW *os.File, lim Limits, dispatch Dispatch) ([]string, error) {
sc := bufio.NewScanner(reqR)
sc.Buffer(make([]byte, 64*1024), lim.resultBytes())
var calls []string
n := 0
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" {
continue
}
n++
var call Call
if err := json.Unmarshal([]byte(line), &call); err != nil {
_ = writeResp(respW, call.ID, false, "", "请求不是合法 JSON")
continue
}
if n > lim.calls() {
_ = writeResp(respW, call.ID, false, "", "脚本回调超过 50 次")
return calls, fmt.Errorf("脚本回调超过 50 次")
}
if ctx.Err() != nil {
return calls, ctx.Err()
}
if dispatch == nil {
_ = writeResp(respW, call.ID, false, "", "没有回调处理")
continue
}
output, err := dispatch(ctx, call)
if err != nil {
_ = writeResp(respW, call.ID, false, "", err.Error())
if ctx.Err() != nil {
return calls, ctx.Err()
}
continue
}
if call.Op == "tool.call" && call.Name != "" {
calls = append(calls, call.Name)
}
if err := writeResp(respW, call.ID, true, clipBytes(output, lim.resultBytes()), ""); err != nil {
return calls, err
}
}
if err := sc.Err(); err != nil {
if err == bufio.ErrTooLong {
return calls, fmt.Errorf("单行请求超过 1MB")
}
if ctx.Err() != nil {
return calls, ctx.Err()
}
return calls, err
}
return calls, nil
}
func writeResp(w io.Writer, id string, ok bool, output, errText string) error {
raw, err := json.Marshal(struct {
ID string `json:"id"`
OK bool `json:"ok"`
Output string `json:"output,omitempty"`
Error string `json:"error,omitempty"`
}{ID: id, OK: ok, Output: output, Error: errText})
if err != nil {
return err
}
_, err = w.Write(append(raw, '\n'))
return err
}
func clipBytes(s string, n int) string {
if n <= 0 || len(s) <= n {
return s
}
cut := s[:n]
for !utf8.ValidString(cut) && len(cut) > 0 {
cut = cut[:len(cut)-1]
}
return cut
}
func clipJoined(stdout, stderr *bytes.Buffer) string {
text := strings.TrimSpace(stdout.String())
errText := strings.TrimSpace(stderr.String())
if errText != "" {
if text != "" {
text += "\n"
}
text += errText
}
return text
}
func clipModel(out Outcome, lim Limits) Outcome {
limit := lim.modelChars()
if utf8.RuneCountInString(out.Output) <= limit {
return out
}
dir := lim.FullDir
if dir == "" {
dir = os.TempDir()
}
if err := os.MkdirAll(dir, 0o755); err != nil {
out.Output = clipRunes(out.Output, limit)
return out
}
path := filepath.Join(dir, fmt.Sprintf("script-output-%d.txt", time.Now().UnixNano()))
if err := os.WriteFile(path, []byte(out.Output), 0o644); err != nil {
out.Output = clipRunes(out.Output, limit)
return out
}
out.FullPath = path
out.Output = clipRunes(out.Output, limit)
return out
}
func clipRunes(s string, n int) string {
if utf8.RuneCountInString(s) <= n {
return s
}
i := 0
for count := 0; count < n && i < len(s); count++ {
_, size := utf8.DecodeRuneInString(s[i:])
i += size
}
return s[:i] + "…(超长截断)"
}
func defaultStart(ctx context.Context, workDir, path string) (*exec.Cmd, error) {
bin, err := exec.LookPath("python3")
if err != nil {
return nil, fmt.Errorf("找不到 python3: %w", err)
}
cmd := exec.CommandContext(ctx, bin, path)
cmd.Dir = workDir
return cmd, nil
}
func helperPath() (string, error) {
helperOnce.Do(func() {
dir, err := os.MkdirTemp("", "agentwf-*")
if err != nil {
helperErr = err
return
}
if err := os.WriteFile(filepath.Join(dir, "agentwf.py"), []byte(agentwfSource), 0o644); err != nil {
helperErr = err
return
}
helperDir = dir
})
return helperDir, helperErr
}
func prependPythonPath(env []string, dir string) []string {
if env == nil {
env = os.Environ()
}
for i, kv := range env {
if strings.HasPrefix(kv, "PYTHONPATH=") {
env[i] = "PYTHONPATH=" + dir + string(os.PathListSeparator) + strings.TrimPrefix(kv, "PYTHONPATH=")
return env
}
}
return append(env, "PYTHONPATH="+dir)
}
+89
View File
@@ -0,0 +1,89 @@
package codeorch
import (
"context"
"errors"
"os"
"strings"
"testing"
"time"
"unicode/utf8"
)
func TestScriptCallsTwoToolsAndReportsTheOneThatRan(t *testing.T) {
body := "import agentwf as wf\nprint(wf.tool('alpha', {}))\nprint(wf.tool('beta', {}))\n"
out := Run(context.Background(), t.TempDir(), body, Limits{}, nil, func(_ context.Context, call Call) (string, error) {
if call.Name == "beta" {
return "", errors.New("beta 失败")
}
return "A", nil
})
text := out.ModelText()
if len(out.Calls) != 1 || out.Calls[0] != "alpha" {
t.Fatalf("calls %+v", out.Calls)
}
if !strings.Contains(text, "已执行:alpha") || !strings.Contains(text, "beta 失败") {
t.Fatalf("text %q err %v", text, out.Err)
}
}
func TestForbiddenToolIsRejectedBeforeDispatchRunsIt(t *testing.T) {
body := "import agentwf as wf\nprint(wf.tool('read_file', {}))\nprint(wf.tool('run_shell', {}))\n"
var ran []string
out := Run(context.Background(), t.TempDir(), body, Limits{}, nil, func(_ context.Context, call Call) (string, error) {
if Forbidden(call.Name) {
return "", errors.New("不能调用 " + call.Name)
}
ran = append(ran, call.Name)
return "ok", nil
})
if len(ran) != 1 || ran[0] != "read_file" {
t.Fatalf("ran %+v", ran)
}
if !strings.Contains(out.ModelText(), "不能调用 run_shell") {
t.Fatalf("text %q", out.ModelText())
}
}
func TestLongOutputIsClippedAndStored(t *testing.T) {
body := "import agentwf as wf\nprint(wf.tool('big', {}))\n"
dir := t.TempDir()
out := Run(context.Background(), dir, body, Limits{MaxModelChars: 8, FullDir: dir}, nil, func(_ context.Context, call Call) (string, error) {
return strings.Repeat("测", 20), nil
})
if out.FullPath == "" {
t.Fatal("expected a full-text file")
}
full, err := os.ReadFile(out.FullPath)
if err != nil {
t.Fatal(err)
}
if utf8.RuneCount(full) < 20 {
t.Fatalf("full text too short: %q", full)
}
if !strings.HasSuffix(out.Output, "…(超长截断)") || utf8.RuneCountInString(strings.TrimSuffix(out.Output, "…(超长截断)")) != 8 {
t.Fatalf("model output not clipped: %q", out.Output)
}
if !strings.Contains(out.ModelText(), out.FullPath) {
t.Fatalf("model text missing path: %q", out.ModelText())
}
}
func TestResultGivenToScriptIsCapped(t *testing.T) {
got := clipBytes(strings.Repeat("x", 20), 8)
if got != "xxxxxxxx" {
t.Fatalf("clip %q", got)
}
if !utf8.ValidString(clipBytes("测测测", 4)) {
t.Fatal("clip broke utf-8")
}
}
func TestTimeoutIsCapped(t *testing.T) {
if (Limits{Timeout: 900 * time.Second, MaxTimeout: 600 * time.Second}).bounded() != 600*time.Second {
t.Fatal("timeout must clamp to the max")
}
if (Limits{}).bounded() != defaultTimeout {
t.Fatal("empty limits must use the 120s default")
}
}
+442
View File
@@ -0,0 +1,442 @@
package runtime
import (
"context"
"path/filepath"
"strings"
"testing"
"agent-native/runtime/llm"
"agent-native/runtime/session"
"agent-native/runtime/tools"
)
type replayTool struct {
countingTool
replay string
}
func (r *replayTool) Replay() string {
if r.replay == "" {
return tools.ReplayNever
}
return r.replay
}
func TestPendingUnsafeToolIsNotRerun(t *testing.T) {
dir := t.TempDir()
st, err := session.Open(filepath.Join(dir, "s.jsonl"))
if err != nil {
t.Fatal(err)
}
if err := st.Append(llm.Message{
Role: llm.RoleAssistant, StopReason: llm.StopToolCalls,
ToolCalls: []llm.ToolCall{{ID: "c1", Name: "write_file", Arguments: `{}`}},
}); err != nil {
t.Fatal(err)
}
if err := st.AppendToolIntent(session.ToolIntent{CallID: "c1", Name: "write_file", Arguments: `{}`, Replay: tools.ReplayNever}); err != nil {
t.Fatal(err)
}
tool := &countingTool{name: "write_file"}
reg := tools.NewRegistry(dir)
reg.Register(tool)
_, err = Run(context.Background(), Config{
Model: "fake", Session: st, Tools: reg, MaxTurns: 2,
Stream: script(llm.Message{Role: llm.RoleAssistant, Content: "later", StopReason: llm.StopEnd}),
}, llm.Message{Role: llm.RoleUser, Content: "next"})
if err != nil {
t.Fatal(err)
}
if tool.calls != 0 {
t.Fatalf("unsafe tool reran: %d", tool.calls)
}
msgs, err := st.Load()
if err != nil {
t.Fatal(err)
}
var paired bool
for _, m := range msgs {
if m.Role == llm.RoleTool && m.ToolCallID == "c1" && m.Content == unknownSideEffect {
paired = true
}
}
if !paired {
t.Fatalf("pair missing: %#v", msgs)
}
}
func TestPendingSafeToolRerunsOnce(t *testing.T) {
dir := t.TempDir()
st, err := session.Open(filepath.Join(dir, "s.jsonl"))
if err != nil {
t.Fatal(err)
}
if err := st.Append(llm.Message{
Role: llm.RoleAssistant, StopReason: llm.StopToolCalls,
ToolCalls: []llm.ToolCall{{ID: "c1", Name: "read_file", Arguments: `{}`}},
}); err != nil {
t.Fatal(err)
}
if err := st.AppendToolIntent(session.ToolIntent{CallID: "c1", Name: "read_file", Arguments: `{}`, Replay: tools.ReplaySafe}); err != nil {
t.Fatal(err)
}
tool := &replayTool{countingTool: countingTool{name: "read_file"}, replay: tools.ReplaySafe}
reg := tools.NewRegistry(dir)
reg.Register(tool)
_, err = Run(context.Background(), Config{
Model: "fake", Session: st, Tools: reg, MaxTurns: 2,
Stream: script(llm.Message{Role: llm.RoleAssistant, Content: "later", StopReason: llm.StopEnd}),
}, llm.Message{Role: llm.RoleUser, Content: "next"})
if err != nil {
t.Fatal(err)
}
if tool.calls != 1 {
t.Fatalf("calls %d", tool.calls)
}
msgs, err := st.Load()
if err != nil {
t.Fatal(err)
}
for _, m := range msgs {
if m.Role == llm.RoleTool && m.ToolCallID == "c1" {
if m.Content != "ok" || strings.Contains(m.Content, unknownSideEffect) {
t.Fatalf("result %#v", m)
}
return
}
}
t.Fatal("missing tool result")
}
func TestStoredSafeCurrentNeverDoesNotRerun(t *testing.T) {
dir := t.TempDir()
st, err := session.Open(filepath.Join(dir, "s.jsonl"))
if err != nil {
t.Fatal(err)
}
if err := st.AppendToolIntent(session.ToolIntent{CallID: "c1", Name: "read_file", Arguments: `{}`, Replay: tools.ReplaySafe}); err != nil {
t.Fatal(err)
}
tool := &replayTool{countingTool: countingTool{name: "read_file"}, replay: tools.ReplayNever}
reg := tools.NewRegistry(dir)
reg.Register(tool)
if _, err := recoverPending(context.Background(), Config{Session: st, Tools: reg}); err != nil {
t.Fatal(err)
}
if tool.calls != 0 {
t.Fatalf("calls %d", tool.calls)
}
}
func TestFollowUpIsPersistedBeforeNextRequest(t *testing.T) {
dir := t.TempDir()
st, err := session.Open(filepath.Join(dir, "s.jsonl"))
if err != nil {
t.Fatal(err)
}
sent := false
var saw bool
stream := func(ctx context.Context, model string, messages []llm.Message, tools []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
ch := make(chan llm.Event, 1)
go func() {
defer close(ch)
for _, m := range messages {
if m.Content == "follow-please" {
loaded, err := st.Load()
if err != nil {
t.Errorf("load %v", err)
}
for _, got := range loaded {
if got.Content == "follow-please" {
saw = true
}
}
}
}
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{Role: llm.RoleAssistant, Content: "ok", StopReason: llm.StopEnd}}
}()
return ch
}
_, err = Run(context.Background(), Config{
Model: "fake", Session: st, MaxTurns: 3, Stream: stream,
GetFollowUp: func() []llm.Message {
if sent {
return nil
}
sent = true
return []llm.Message{{Role: llm.RoleUser, Content: "follow-please"}}
},
}, llm.Message{Role: llm.RoleUser, Content: "go"})
if err != nil {
t.Fatal(err)
}
if !saw {
t.Fatal("follow-up entered the request before it was stored")
}
}
func TestSpansUseAgreedNamesWithoutPrompt(t *testing.T) {
var names []string
var leaked bool
reg := tools.NewRegistry(t.TempDir())
reg.Register(&countingTool{name: "read_file"})
_, err := Run(context.Background(), Config{
Model: "fake", Tools: reg, MaxTurns: 2,
Trace: func(name string, attrs map[string]string) func() {
names = append(names, name)
if _, ok := attrs["prompt"]; ok || attrs["content"] != "" {
leaked = true
}
return func() {}
},
Stream: script(
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{ID: "1", Name: "read_file", Arguments: `{}`}}},
llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd},
),
}, llm.Message{Role: llm.RoleUser, Content: "用户的秘密提示词"})
if err != nil {
t.Fatal(err)
}
joined := strings.Join(names, ",")
for _, want := range []string{"invoke_agent", "chat", "execute_tool"} {
if !strings.Contains(joined, want) {
t.Fatalf("names %s", joined)
}
}
if leaked {
t.Fatal("prompt leaked into span attrs")
}
}
// ---- N3 WP1:崩溃窗口尾部配对兜底(验收① 库层机器核心)----
// 窗口内死亡(persist(assistant) 之后、任何 intent 之前):转录只有带
// tool_calls 的 assistant。恢复后每个调用都有「进程中断」配对,且 run 继续。
func TestResumePairsOrphanTailAfterCrashWindow(t *testing.T) {
dir := t.TempDir()
st, err := session.Open(filepath.Join(dir, "s.jsonl"))
if err != nil {
t.Fatal(err)
}
if err := st.Append(llm.Message{Role: llm.RoleSystem, Content: "sys"}); err != nil {
t.Fatal(err)
}
if err := st.Append(llm.Message{Role: llm.RoleUser, Content: "干个活"}); err != nil {
t.Fatal(err)
}
// 崩溃窗口形态:assistant 落盘、零意图、零结果。
if err := st.Append(llm.Message{
Role: llm.RoleAssistant, StopReason: llm.StopToolCalls,
ToolCalls: []llm.ToolCall{
{ID: "c1", Name: "write_file", Arguments: `{}`},
{ID: "c2", Name: "read_file", Arguments: `{}`},
},
}); err != nil {
t.Fatal(err)
}
reg := tools.NewRegistry(dir)
tool := &countingTool{name: "write_file"}
reg.Register(tool)
reg.Register(&countingTool{name: "read_file"})
_, err = Run(context.Background(), Config{
Model: "fake", Session: st, Tools: reg, MaxTurns: 2,
Stream: script(llm.Message{Role: llm.RoleAssistant, Content: "继续", StopReason: llm.StopEnd}),
}, llm.Message{Role: llm.RoleUser, Content: "还在吗"})
if err != nil {
t.Fatalf("恢复后的 Run 不得失败(配对修复应让提供方接受历史):%v", err)
}
msgs, err := st.Load()
if err != nil {
t.Fatal(err)
}
// 双向配对断言:每个调用有结果、每个结果有调用。
calls, results := map[string]string{}, map[string]bool{}
for _, m := range msgs {
for _, c := range m.ToolCalls {
calls[c.ID] = c.Name
}
if m.Role == llm.RoleTool && m.ToolCallID != "" {
results[m.ToolCallID] = true
if m.Content != crashInterrupted && m.Content != unknownSideEffect {
t.Fatalf("兜底文案不对:%q", m.Content)
}
if !m.IsError {
t.Fatal("兜底结果必须 IsError")
}
}
}
for id := range calls {
if !results[id] {
t.Fatalf("孤立 tool_call:%s", id)
}
}
if tool.calls != 0 {
t.Fatalf("兜底不得重放写工具:%d", tool.calls)
}
}
// 执行中死亡(c1 意图+结果、c2 意图无结果)+ 窗口死亡(c3 无意图)混合:
// 意图路径配 c2、尾部兜底配 c3,互不重复。
func TestResumeMixedIntentAndOrphanTail(t *testing.T) {
dir := t.TempDir()
st, err := session.Open(filepath.Join(dir, "s.jsonl"))
if err != nil {
t.Fatal(err)
}
if err := st.Append(llm.Message{
Role: llm.RoleAssistant, StopReason: llm.StopToolCalls,
ToolCalls: []llm.ToolCall{
{ID: "c1", Name: "read_file", Arguments: `{}`},
{ID: "c2", Name: "read_file", Arguments: `{}`},
{ID: "c3", Name: "write_file", Arguments: `{}`},
},
}); err != nil {
t.Fatal(err)
}
if err := st.AppendToolIntent(session.ToolIntent{CallID: "c1", Name: "read_file", Arguments: `{}`, Replay: tools.ReplaySafe}); err != nil {
t.Fatal(err)
}
if err := st.Append(llm.Message{Role: llm.RoleTool, ToolCallID: "c1", Name: "read_file", Content: "真实结果"}); err != nil {
t.Fatal(err)
}
if err := st.AppendToolIntent(session.ToolIntent{CallID: "c2", Name: "read_file", Arguments: `{}`, Replay: tools.ReplayNever}); err != nil {
t.Fatal(err)
}
reg := tools.NewRegistry(dir)
reg.Register(&countingTool{name: "read_file"})
reg.Register(&countingTool{name: "write_file"})
_, err = Run(context.Background(), Config{
Model: "fake", Session: st, Tools: reg, MaxTurns: 1,
Stream: script(llm.Message{Role: llm.RoleAssistant, Content: "x", StopReason: llm.StopEnd}),
}, llm.Message{Role: llm.RoleUser, Content: "next"})
if err != nil {
t.Fatal(err)
}
msgs, err := st.Load()
if err != nil {
t.Fatal(err)
}
byID := map[string][]string{}
for _, m := range msgs {
if m.Role == llm.RoleTool && m.ToolCallID != "" {
byID[m.ToolCallID] = append(byID[m.ToolCallID], m.Content)
}
}
if got := byID["c1"]; len(got) != 1 || got[0] != "真实结果" {
t.Fatalf("c1 必须保持原结果且不重复:%v", got)
}
if got := byID["c2"]; len(got) != 1 || got[0] != unknownSideEffect {
t.Fatalf("c2 走意图路径:%v", got)
}
if got := byID["c3"]; len(got) != 1 || got[0] != crashInterrupted {
t.Fatalf("c3 走尾部兜底:%v", got)
}
}
// 健康转录(尾部是完整配对或 user 消息)不做任何改写。
func TestRepairOrphanTailNoopOnHealthyTranscript(t *testing.T) {
dir := t.TempDir()
st, err := session.Open(filepath.Join(dir, "s.jsonl"))
if err != nil {
t.Fatal(err)
}
for _, m := range []llm.Message{
{Role: llm.RoleSystem, Content: "sys"},
{Role: llm.RoleUser, Content: "u"},
{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{ID: "c1", Name: "read_file", Arguments: `{}`}}},
{Role: llm.RoleTool, ToolCallID: "c1", Name: "read_file", Content: "r"},
{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd},
{Role: llm.RoleUser, Content: "u2"},
} {
if err := st.Append(m); err != nil {
t.Fatal(err)
}
}
before, _ := st.Load()
if _, err := repairOrphanTail(Config{Session: st}); err != nil {
t.Fatal(err)
}
after, _ := st.Load()
if len(after) != len(before) {
t.Fatalf("健康转录被改写:%d → %d", len(before), len(after))
}
}
// ---- N3 WP2:JournalOnly(journal 只作日志,上下文由 History 组装)----
// JournalOnly:journal 有杀窗口尾巴 → 恢复配对写入 journal;上下文来自
// History(不含 journal 内容),恢复摘要以系统消息注入;种子不进 journal。
func TestJournalOnlyRecoversWithoutTranscriptAuthority(t *testing.T) {
dir := t.TempDir()
st, err := session.Open(filepath.Join(dir, "s.jsonl"))
if err != nil {
t.Fatal(err)
}
if err := st.Append(llm.Message{
Role: llm.RoleAssistant, StopReason: llm.StopToolCalls,
ToolCalls: []llm.ToolCall{{ID: "c1", Name: "read_file", Arguments: `{}`}},
}); err != nil {
t.Fatal(err)
}
if err := st.AppendToolIntent(session.ToolIntent{CallID: "c1", Name: "read_file", Arguments: `{}`, Replay: tools.ReplaySafe}); err != nil {
t.Fatal(err)
}
reg := tools.NewRegistry(dir)
reg.Register(&replayTool{countingTool: countingTool{name: "read_file"}, replay: tools.ReplaySafe})
var gotReq []llm.Message
res, err := Run(context.Background(), Config{
Model: "fake", Session: st, JournalOnly: true, Tools: reg, MaxTurns: 2,
History: []llm.Message{
{Role: llm.RoleSystem, Content: "sys"},
{Role: llm.RoleUser, Content: "历史一"},
{Role: llm.RoleAssistant, Content: "回复一"},
},
Stream: func(ctx context.Context, model string, msgs []llm.Message, _ []llm.ToolDecl, _ llm.StreamOptions) <-chan llm.Event {
gotReq = append([]llm.Message{}, msgs...)
ch := make(chan llm.Event, 1)
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{Role: llm.RoleAssistant, Content: "继续", StopReason: llm.StopEnd}}
close(ch)
return ch
},
}, llm.Message{Role: llm.RoleUser, Content: "最新"})
if err != nil {
t.Fatal(err)
}
// journal 里 c1 被 safe 重放配对(一次真实结果)。
journal, _ := st.Load()
var journaled bool
for _, m := range journal {
if m.Role == llm.RoleTool && m.ToolCallID == "c1" && m.Content == "ok" {
journaled = true
}
if m.Content == "历史一" {
t.Fatal("种子历史不得进 journal")
}
}
if !journaled {
t.Fatalf("journal 缺恢复配对:%#v", journal)
}
// 上下文:History 内容在;恢复摘要在;且在新 user 消息之前。
var sawHist, sawNote int
noteIdx, userIdx := -1, -1
for i, m := range gotReq {
if m.Content == "历史一" {
sawHist++
}
if m.Role == llm.RoleSystem && strings.Contains(m.Content, "已完成恢复") && strings.Contains(m.Content, "c1") {
sawNote++
noteIdx = i
}
if m.Content == "最新" {
userIdx = i
}
}
if sawHist != 1 || sawNote != 1 {
t.Fatalf("历史=%d 摘要=%d:%#v", sawHist, sawNote, gotReq)
}
if noteIdx < 0 || userIdx < 0 || noteIdx > userIdx {
t.Fatalf("摘要必须在新 user 消息之前:note=%d user=%d", noteIdx, userIdx)
}
_ = res
}
+181
View File
@@ -0,0 +1,181 @@
package pkg
import (
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
)
// Manifest is the agent-native.json discovered in a package.
type Manifest struct {
Name string `json:"name"`
Extensions []string `json:"extensions,omitempty"`
Config map[string]string `json:"config,omitempty"`
}
// Manager installs packages under Dir.
type Manager struct {
Dir string
}
func (m Manager) root() string {
if m.Dir != "" {
return m.Dir
}
home, _ := os.UserHomeDir()
return filepath.Join(home, ".agent-native", "packages")
}
// Install copies a local directory, clones a git URL, or runs npm pack
// when src starts with "npm:". The package name comes from its manifest.
func (m Manager) Install(src string) (Manifest, error) {
dir, err := os.MkdirTemp("", "agent-native-pkg-*")
if err != nil {
return Manifest{}, err
}
defer os.RemoveAll(dir)
switch {
case strings.HasPrefix(src, "npm:"):
if _, err := exec.LookPath("npm"); err != nil {
return Manifest{}, fmt.Errorf("pkg: npm is not installed")
}
cmd := exec.Command("npm", "pack", strings.TrimPrefix(src, "npm:"))
cmd.Dir = dir
if out, err := cmd.CombinedOutput(); err != nil {
return Manifest{}, fmt.Errorf("pkg: npm pack: %s", out)
}
case strings.HasPrefix(src, "git+") || strings.HasSuffix(src, ".git") || strings.Contains(src, "://"):
cmd := exec.Command("git", "clone", "--depth", "1", src, filepath.Join(dir, "src"))
if out, err := cmd.CombinedOutput(); err != nil {
return Manifest{}, fmt.Errorf("pkg: git clone: %s", out)
}
dir = filepath.Join(dir, "src")
default:
if err := copyDir(src, filepath.Join(dir, "src")); err != nil {
return Manifest{}, err
}
dir = filepath.Join(dir, "src")
}
manifest, err := ReadManifest(dir)
if err != nil {
return Manifest{}, err
}
dest := filepath.Join(m.root(), manifest.Name)
if err := os.RemoveAll(dest); err != nil {
return Manifest{}, err
}
if err := copyDir(dir, dest); err != nil {
return Manifest{}, err
}
return manifest, nil
}
// Remove deletes an installed package.
func (m Manager) Remove(name string) error {
return os.RemoveAll(filepath.Join(m.root(), name))
}
// Update reinstalls from the recorded source when present, otherwise no-ops.
func (m Manager) Update(name string) (Manifest, error) {
manifest, err := ReadManifest(filepath.Join(m.root(), name))
if err != nil {
return Manifest{}, err
}
src := manifest.Config["source"]
if src == "" {
return manifest, nil
}
return m.Install(src)
}
// List returns installed manifests.
func (m Manager) List() ([]Manifest, error) {
entries, err := os.ReadDir(m.root())
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
var out []Manifest
for _, ent := range entries {
if !ent.IsDir() {
continue
}
manifest, err := ReadManifest(filepath.Join(m.root(), ent.Name()))
if err != nil {
continue
}
out = append(out, manifest)
}
return out, nil
}
// Config sets one package config key.
func (m Manager) Config(name, key, value string) error {
path := filepath.Join(m.root(), name)
manifest, err := ReadManifest(path)
if err != nil {
return err
}
if manifest.Config == nil {
manifest.Config = map[string]string{}
}
manifest.Config[key] = value
raw, err := json.MarshalIndent(manifest, "", " ")
if err != nil {
return err
}
return os.WriteFile(filepath.Join(path, "agent-native.json"), raw, 0o644)
}
// ReadManifest loads agent-native.json from a directory.
func ReadManifest(dir string) (Manifest, error) {
raw, err := os.ReadFile(filepath.Join(dir, "agent-native.json"))
if err != nil {
return Manifest{}, err
}
var manifest Manifest
if err := json.Unmarshal(raw, &manifest); err != nil {
return Manifest{}, err
}
if manifest.Name == "" {
return Manifest{}, fmt.Errorf("pkg: manifest name is empty")
}
return manifest, nil
}
func copyDir(src, dst string) error {
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(src, path)
if err != nil {
return err
}
target := filepath.Join(dst, rel)
if info.IsDir() {
return os.MkdirAll(target, 0o755)
}
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return err
}
in, err := os.Open(path)
if err != nil {
return err
}
defer in.Close()
out, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, info.Mode())
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, in)
return err
})
}
+235
View File
@@ -0,0 +1,235 @@
package ext
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"os/exec"
"sync"
"agent-native/runtime/llm"
"agent-native/runtime/tools"
)
// Message is one JSONL frame on the extension pipe.
type Message struct {
Type string `json:"type"`
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Description string `json:"description,omitempty"`
Parameters json.RawMessage `json:"parameters,omitempty"`
Arguments json.RawMessage `json:"arguments,omitempty"`
Content string `json:"content,omitempty"`
IsError bool `json:"isError,omitempty"`
Event string `json:"event,omitempty"`
Block bool `json:"block,omitempty"`
Reason string `json:"reason,omitempty"`
Message string `json:"message,omitempty"`
Protocol string `json:"protocol,omitempty"`
BaseURL string `json:"baseURL,omitempty"`
CustomType string `json:"customType,omitempty"`
Data json.RawMessage `json:"data,omitempty"`
Command string `json:"command,omitempty"`
}
// UIRequest is a question an extension wants a human to answer.
type UIRequest struct {
ID string
Message string
}
// Process is one extension subprocess.
type Process struct {
cmd *exec.Cmd
stdin io.WriteCloser
mu sync.Mutex
tools []registeredTool
commands []string
providers []llm.Provider
pending map[string]chan Message
UI chan UIRequest
OnCustom func(customType string, data json.RawMessage)
closed bool
}
type registeredTool struct {
name string
description string
parameters json.RawMessage
proc *Process
}
func (t registeredTool) Name() string { return t.name }
func (t registeredTool) Description() string { return t.description }
func (t registeredTool) Parameters() json.RawMessage { return t.parameters }
func (t registeredTool) Execute(ctx context.Context, args json.RawMessage) (tools.Result, error) {
return t.proc.callTool(ctx, t.name, args)
}
// Start launches command and reads its registration frames until it goes idle
// or the context ends. command is argv, not a shell line.
func Start(ctx context.Context, command []string) (*Process, error) {
if len(command) == 0 {
return nil, fmt.Errorf("ext: empty command")
}
cmd := exec.CommandContext(ctx, command[0], command[1:]...)
stdin, err := cmd.StdinPipe()
if err != nil {
return nil, err
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, err
}
if err := cmd.Start(); err != nil {
return nil, err
}
p := &Process{
cmd: cmd, stdin: stdin, pending: map[string]chan Message{},
UI: make(chan UIRequest, 4),
}
go p.read(stdout)
if err := p.send(Message{Type: "init"}); err != nil {
_ = p.Close()
return nil, err
}
return p, nil
}
// Tools returns the tools the extension registered.
func (p *Process) Tools() []tools.Tool {
p.mu.Lock()
defer p.mu.Unlock()
out := make([]tools.Tool, len(p.tools))
for i := range p.tools {
out[i] = p.tools[i]
}
return out
}
// Commands returns slash-command names.
func (p *Process) Commands() []string {
p.mu.Lock()
defer p.mu.Unlock()
return append([]string(nil), p.commands...)
}
// Providers returns endpoints the extension registered.
func (p *Process) Providers() []llm.Provider {
p.mu.Lock()
defer p.mu.Unlock()
return append([]llm.Provider(nil), p.providers...)
}
// Hook tells the extension about an event. A block result stops the host action.
func (p *Process) Hook(ctx context.Context, event, name string, args json.RawMessage) (bool, string) {
id := name + ":" + event
ch := make(chan Message, 1)
p.mu.Lock()
p.pending[id] = ch
p.mu.Unlock()
if err := p.send(Message{Type: "event", ID: id, Event: event, Name: name, Arguments: args}); err != nil {
return false, ""
}
select {
case <-ctx.Done():
return false, ctx.Err().Error()
case msg := <-ch:
return msg.Block, msg.Reason
}
}
func (p *Process) callTool(ctx context.Context, name string, args json.RawMessage) (tools.Result, error) {
id := name
ch := make(chan Message, 1)
p.mu.Lock()
p.pending[id] = ch
p.mu.Unlock()
if err := p.send(Message{Type: "tool_call", ID: id, Name: name, Arguments: args}); err != nil {
return tools.Result{IsError: true, Content: err.Error()}, err
}
select {
case <-ctx.Done():
return tools.Result{IsError: true, Content: ctx.Err().Error()}, ctx.Err()
case msg := <-ch:
return tools.Result{Content: msg.Content, IsError: msg.IsError}, nil
}
}
func (p *Process) send(msg Message) error {
raw, err := json.Marshal(msg)
if err != nil {
return err
}
p.mu.Lock()
defer p.mu.Unlock()
if p.closed {
return fmt.Errorf("ext: closed")
}
_, err = p.stdin.Write(append(raw, '\n'))
return err
}
func (p *Process) read(r io.Reader) {
sc := bufio.NewScanner(r)
sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
for sc.Scan() {
var msg Message
if err := json.Unmarshal(sc.Bytes(), &msg); err != nil {
continue
}
p.dispatch(msg)
}
}
func (p *Process) dispatch(msg Message) {
switch msg.Type {
case "register_tool":
p.mu.Lock()
p.tools = append(p.tools, registeredTool{
name: msg.Name, description: msg.Description, parameters: msg.Parameters, proc: p,
})
p.mu.Unlock()
case "register_command":
p.mu.Lock()
p.commands = append(p.commands, msg.Name)
p.mu.Unlock()
case "register_provider":
p.mu.Lock()
p.providers = append(p.providers, llm.Provider{
Name: msg.Name, Protocol: llm.Protocol(msg.Protocol), BaseURL: msg.BaseURL,
})
p.mu.Unlock()
case "tool_result", "hook_result":
p.mu.Lock()
ch := p.pending[msg.ID]
delete(p.pending, msg.ID)
p.mu.Unlock()
if ch != nil {
ch <- msg
}
case "ui_request":
select {
case p.UI <- UIRequest{ID: msg.ID, Message: msg.Message}:
default:
}
case "custom_entry":
if p.OnCustom != nil {
p.OnCustom(msg.CustomType, msg.Data)
}
}
}
// Close stops the subprocess.
func (p *Process) Close() error {
p.mu.Lock()
p.closed = true
_ = p.stdin.Close()
p.mu.Unlock()
if p.cmd.Process != nil {
_ = p.cmd.Process.Kill()
}
return p.cmd.Wait()
}
+100
View File
@@ -0,0 +1,100 @@
package ext
import (
"context"
"os"
"path/filepath"
"testing"
"time"
"agent-native/runtime/ext/pkg"
)
func TestExtensionRegistersAndHooks(t *testing.T) {
dir := t.TempDir()
script := filepath.Join(dir, "ext.sh")
body := `#!/bin/sh
while IFS= read -r line; do
case "$line" in
*'"type":"init"'*)
echo '{"type":"register_tool","name":"ping","description":"p","parameters":{"type":"object"}}'
echo '{"type":"register_command","name":"hi"}'
echo '{"type":"register_provider","name":"local","protocol":"openai-chat","baseURL":"http://127.0.0.1:9"}'
echo '{"type":"ui_request","id":"u1","message":"choose"}'
;;
*'"type":"tool_call"'*)
echo '{"type":"tool_result","id":"ping","content":"pong"}'
;;
*'"type":"event"'*)
echo '{"type":"hook_result","id":"ping:tool_call","block":true,"reason":"no"}'
;;
esac
done
`
if err := os.WriteFile(script, []byte(body), 0o755); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
proc, err := Start(ctx, []string{"/bin/sh", script})
if err != nil {
t.Fatal(err)
}
defer proc.Close()
deadline := time.Now().Add(2 * time.Second)
for len(proc.Tools()) == 0 && time.Now().Before(deadline) {
time.Sleep(20 * time.Millisecond)
}
tools := proc.Tools()
if len(tools) != 1 || tools[0].Name() != "ping" {
t.Fatalf("tools %+v", tools)
}
res, err := tools[0].Execute(ctx, []byte(`{}`))
if err != nil || res.Content != "pong" {
t.Fatalf("tool: %+v %v", res, err)
}
block, reason := proc.Hook(ctx, "tool_call", "ping", []byte(`{}`))
if !block || reason != "no" {
t.Fatalf("hook %v %s", block, reason)
}
if len(proc.Commands()) != 1 || len(proc.Providers()) != 1 {
t.Fatalf("commands %v providers %v", proc.Commands(), proc.Providers())
}
select {
case ui := <-proc.UI:
if ui.Message != "choose" {
t.Fatal(ui)
}
case <-time.After(time.Second):
t.Fatal("missing ui request")
}
}
func TestPackageInstallListConfigRemove(t *testing.T) {
src := t.TempDir()
if err := os.WriteFile(filepath.Join(src, "agent-native.json"), []byte(`{"name":"demo","extensions":["./ext.sh"]}`), 0o644); err != nil {
t.Fatal(err)
}
mgr := pkg.Manager{Dir: t.TempDir()}
manifest, err := mgr.Install(src)
if err != nil || manifest.Name != "demo" {
t.Fatalf("%+v %v", manifest, err)
}
if err := mgr.Config("demo", "source", src); err != nil {
t.Fatal(err)
}
list, err := mgr.List()
if err != nil || len(list) != 1 || list[0].Config["source"] != src {
t.Fatalf("list %+v %v", list, err)
}
if _, err := mgr.Update("demo"); err != nil {
t.Fatal(err)
}
if err := mgr.Remove("demo"); err != nil {
t.Fatal(err)
}
list, _ = mgr.List()
if len(list) != 0 {
t.Fatalf("still installed: %+v", list)
}
}
+63
View File
@@ -0,0 +1,63 @@
// Package filestate 记录本轮读过的文件,并在改写前核对修改时间与大小。
package filestate
import (
"fmt"
"os"
"sync"
"time"
)
type stamp struct {
mod time.Time
size int64
}
// Tracker 是一次 run 内的读取记录。nil 接收者表示不检查。
type Tracker struct {
mu sync.Mutex
seen map[string]stamp
}
func New() *Tracker {
return &Tracker{seen: map[string]stamp{}}
}
// NoteRead 记下当前的修改时间与大小。
func (t *Tracker) NoteRead(path string) error {
if t == nil {
return nil
}
info, err := os.Stat(path)
if err != nil {
return err
}
t.mu.Lock()
t.seen[path] = stamp{mod: info.ModTime(), size: info.Size()}
t.mu.Unlock()
return nil
}
// CheckWrite 允许新建。已存在的文件必须在本轮读过,且修改时间与大小未变。
func (t *Tracker) CheckWrite(path string) error {
if t == nil {
return nil
}
info, err := os.Stat(path)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
t.mu.Lock()
got, ok := t.seen[path]
t.mu.Unlock()
if !ok {
return fmt.Errorf("请先读取该文件再修改:%s", path)
}
if !got.mod.Equal(info.ModTime()) || got.size != info.Size() {
return fmt.Errorf("文件在读取后已变化,请重新读取:%s", path)
}
return nil
}
+40
View File
@@ -0,0 +1,40 @@
package filestate
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestCheckWriteRejectsStaleFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "note.txt")
if err := os.WriteFile(path, []byte("v1"), 0o644); err != nil {
t.Fatal(err)
}
tr := New()
if err := tr.CheckWrite(path); err == nil || !strings.Contains(err.Error(), "请先读取") {
t.Fatalf("unread write: %v", err)
}
if err := tr.NoteRead(path); err != nil {
t.Fatal(err)
}
if err := tr.CheckWrite(path); err != nil {
t.Fatal(err)
}
// 改大小,并尽量推开修改时间。
time.Sleep(20 * time.Millisecond)
if err := os.WriteFile(path, []byte("v1-changed"), 0o644); err != nil {
t.Fatal(err)
}
err := tr.CheckWrite(path)
if err == nil || !strings.Contains(err.Error(), "请重新读取") {
t.Fatalf("stale write: %v", err)
}
missing := filepath.Join(dir, "new.txt")
if err := tr.CheckWrite(missing); err != nil {
t.Fatalf("create: %v", err)
}
}
+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})
}
+136
View File
@@ -0,0 +1,136 @@
// Package hooks 是进程内钩子总线。决策按 block/deny、ask、modify、allow 取更强的一条。
// 没有表态的处理函数不改变结果。
package hooks
import "sync"
type Event string
const (
SessionStart Event = "SessionStart"
UserPromptSubmit Event = "UserPromptSubmit"
PreToolUse Event = "PreToolUse"
PermissionRequest Event = "PermissionRequest"
PostToolUse Event = "PostToolUse"
PostToolUseFailure Event = "PostToolUseFailure"
PostToolBatch Event = "PostToolBatch"
Stop Event = "Stop"
SubagentStart Event = "SubagentStart"
SubagentStop Event = "SubagentStop"
PreCompact Event = "PreCompact"
SessionEnd Event = "SessionEnd"
)
type Decision string
const (
Allow Decision = "allow"
Deny Decision = "deny"
Ask Decision = "ask"
Modify Decision = "modify"
Block Decision = "block"
)
// Call 是一次事件。工具字段只在工具事件上有意义。
type Call struct {
Event Event
Tool string
Args string
Result string
IsError bool
Turn int
LastText string
Continuations int
ToolCalls int
}
// Outcome 是一个处理函数的表态。空 Decision 表示不插手。
type Outcome struct {
Decision Decision
Reason string
Args string
Notice string
Replacement string
}
type Handler func(Call) Outcome
type Bus struct {
mu sync.Mutex
hs []Handler
}
func New() *Bus { return &Bus{} }
func (b *Bus) On(h Handler) {
if b == nil || h == nil {
return
}
b.mu.Lock()
b.hs = append(b.hs, h)
b.mu.Unlock()
}
// Emit 按优先级合并表态。deny 与 block 同级,先登记的保留。
func (b *Bus) Emit(c Call) Outcome {
if b == nil {
return Outcome{}
}
b.mu.Lock()
hs := append([]Handler(nil), b.hs...)
b.mu.Unlock()
var merged Outcome
for _, h := range hs {
merged = prefer(merged, h(c))
}
return merged
}
func prefer(cur, next Outcome) Outcome {
if rank(next.Decision) > rank(cur.Decision) {
return next
}
return cur
}
func rank(d Decision) int {
switch d {
case Block, Deny:
return 4
case Ask:
return 3
case Modify:
return 2
case Allow:
return 1
default:
return 0
}
}
// AdaptBefore 把「返回错误即拒绝」收成 PreToolUse 处理函数。
func AdaptBefore(fn func(name, args string) error) Handler {
return func(c Call) Outcome {
if c.Event != PreToolUse || fn == nil {
return Outcome{}
}
if err := fn(c.Tool, c.Args); err != nil {
return Outcome{Decision: Deny, Reason: err.Error()}
}
return Outcome{Decision: Allow}
}
}
// AdaptAfter 把工具结果观察收成 PostToolUse 或失败事件。它不改写结果。
func AdaptAfter(fn func(name, args, result string, isError bool)) Handler {
return func(c Call) Outcome {
if fn == nil {
return Outcome{}
}
switch c.Event {
case PostToolUse, PostToolUseFailure:
fn(c.Tool, c.Args, c.Result, c.IsError)
}
return Outcome{}
}
}
+40
View File
@@ -0,0 +1,40 @@
package hooks
import "testing"
func TestDenyBeatsAllow(t *testing.T) {
b := New()
b.On(func(Call) Outcome { return Outcome{Decision: Allow} })
b.On(func(c Call) Outcome {
if c.Event == PreToolUse {
return Outcome{Decision: Deny, Reason: "no"}
}
return Outcome{}
})
out := b.Emit(Call{Event: PreToolUse, Tool: "write_file"})
if out.Decision != Deny || out.Reason != "no" {
t.Fatalf("%+v", out)
}
}
func TestAdaptBefore(t *testing.T) {
h := AdaptBefore(func(name, args string) error {
if name == "write_file" {
return errString("blocked")
}
return nil
})
if h(Call{Event: PreToolUse, Tool: "read_file"}).Decision != Allow {
t.Fatal("read")
}
if h(Call{Event: PreToolUse, Tool: "write_file"}).Decision != Deny {
t.Fatal("write")
}
if h(Call{Event: Stop}).Decision != "" {
t.Fatal("stop should be ignored")
}
}
type errString string
func (e errString) Error() string { return string(e) }
+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)
}
}
+50 -5
View File
@@ -11,11 +11,15 @@ 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
}
// Stream implements StreamFunc. Transport and provider failures are encoded as
@@ -24,6 +28,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
@@ -42,6 +50,9 @@ func (c *Client) run(ctx context.Context, model string, messages []Message, tool
emitError(out, "", false, "agentx llm client is missing base url, api key, or model")
return
}
if opts.MessageShape != "" {
messages = PrepareMessages(messages, opts.MessageShape)
}
body := map[string]any{
"model": model,
"messages": toProviderMessages(messages),
@@ -53,6 +64,9 @@ func (c *Client) run(ctx context.Context, model string, messages []Message, tool
if opts.MaxTokens > 0 {
body["max_tokens"] = opts.MaxTokens
}
if effort := OpenAIReasoningEffort(opts.ThinkingLevel); effort != "" {
body["reasoning_effort"] = effort
}
if len(tools) > 0 {
body["tools"] = toProviderTools(tools)
body["tool_choice"] = "auto"
@@ -86,8 +100,20 @@ func (c *Client) run(ctx context.Context, model string, messages []Message, tool
defer resp.Body.Close()
if resp.StatusCode >= 400 {
slurp, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
retryable := resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500
emitError(out, "", retryable, fmt.Sprintf("provider status %d: %s", resp.StatusCode, strings.TrimSpace(string(slurp))))
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
}
for ev := range ParseSSE(ctx, resp.Body) {
@@ -95,6 +121,18 @@ func (c *Client) run(ctx context.Context, model string, messages []Message, tool
}
}
// 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 {
@@ -106,7 +144,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 {
@@ -125,7 +170,7 @@ func toProviderMessages(messages []Message) []map[string]any {
default:
out = append(out, map[string]any{
"role": string(m.Role),
"content": m.Content,
"content": messageContent(m),
})
}
}
+79
View File
@@ -0,0 +1,79 @@
package llm
import "context"
// WithFallback 在尚未转发任何增量时,遇到 5xx 就改用链上下一个模型。
// fallbacks 为空时返回原来的 StreamFunc,不改变行为。
func WithFallback(base StreamFunc, fallbacks []string, onSwitch func(from, to string)) StreamFunc {
chain := nonEmpty(fallbacks)
if base == nil || len(chain) == 0 {
return base
}
return func(ctx context.Context, model string, messages []Message, tools []ToolDecl, opts StreamOptions) <-chan Event {
out := make(chan Event)
go func() {
defer close(out)
models := append([]string{model}, chain...)
for i, current := range models {
forwarded := false
switchNext := false
for ev := range base(ctx, current, messages, tools, opts) {
if isDelta(ev) {
forwarded = true
}
if ev.Kind == KindError && !forwarded && serverError(ev) && i+1 < len(models) {
switchNext = true
if onSwitch != nil {
onSwitch(current, models[i+1])
}
break
}
select {
case out <- ev:
case <-ctx.Done():
return
}
if ev.Kind == KindMessageEnd || ev.Kind == KindError {
return
}
}
if !switchNext {
return
}
}
}()
return out
}
}
func nonEmpty(in []string) []string {
var out []string
seen := map[string]bool{}
for _, s := range in {
s = trimModel(s)
if s == "" || seen[s] {
continue
}
seen[s] = true
out = append(out, s)
}
return out
}
func trimModel(s string) string {
for len(s) > 0 && (s[0] == ' ' || s[0] == '\t') {
s = s[1:]
}
for len(s) > 0 && (s[len(s)-1] == ' ' || s[len(s)-1] == '\t') {
s = s[:len(s)-1]
}
return s
}
func isDelta(ev Event) bool {
return ev.Kind == KindTextDelta || ev.Kind == KindReasoningDelta || ev.Kind == KindToolCallDelta
}
func serverError(ev Event) bool {
return ev.HTTPStatus >= 500 && ev.HTTPStatus <= 599
}
+74
View File
@@ -0,0 +1,74 @@
package llm
import (
"context"
"testing"
)
func TestFallbackSwitchesOn5xxBeforeOutput(t *testing.T) {
var switched string
base := func(ctx context.Context, model string, messages []Message, tools []ToolDecl, opts StreamOptions) <-chan Event {
ch := make(chan Event, 2)
go func() {
defer close(ch)
if model == "primary" {
ch <- Event{Kind: KindError, HTTPStatus: 503, ErrorMessage: "provider status 503", Message: Message{Role: RoleAssistant, StopReason: StopError}}
return
}
ch <- Event{Kind: KindTextDelta, Text: "ok"}
ch <- Event{Kind: KindMessageEnd, Message: Message{Role: RoleAssistant, Content: "ok", StopReason: StopEnd}}
}()
return ch
}
stream := WithFallback(base, []string{"backup"}, func(from, to string) {
switched = from + "->" + to
})
var text string
var sawError bool
for ev := range stream(context.Background(), "primary", nil, nil, StreamOptions{}) {
if ev.Kind == KindTextDelta {
text += ev.Text
}
if ev.Kind == KindError {
sawError = true
}
}
if switched != "primary->backup" || text != "ok" || sawError {
t.Fatalf("switched %q text %q error %v", switched, text, sawError)
}
}
func TestFallbackStaysAfterFirstDelta(t *testing.T) {
calls := 0
base := func(ctx context.Context, model string, messages []Message, tools []ToolDecl, opts StreamOptions) <-chan Event {
calls++
ch := make(chan Event, 2)
go func() {
defer close(ch)
ch <- Event{Kind: KindTextDelta, Text: "partial"}
ch <- Event{Kind: KindError, HTTPStatus: 500, Message: Message{Role: RoleAssistant, StopReason: StopError}}
}()
return ch
}
stream := WithFallback(base, []string{"backup"}, func(from, to string) {
t.Fatal("switched after output")
})
n := 0
for range stream(context.Background(), "primary", nil, nil, StreamOptions{}) {
n++
}
if calls != 1 || n != 2 {
t.Fatalf("calls %d events %d", calls, n)
}
}
func TestEmptyFallbackIsIdentity(t *testing.T) {
base := func(ctx context.Context, model string, messages []Message, tools []ToolDecl, opts StreamOptions) <-chan Event {
ch := make(chan Event)
close(ch)
return ch
}
if WithFallback(base, nil, nil) == nil {
t.Fatal("nil stream")
}
}
+60
View File
@@ -0,0 +1,60 @@
package llm
// PrepareMessages 按 endpoint 整形消息。
// merge 合并相邻、且不携带工具调用的同角色消息。
// strict 在此之后把第一条之后的 system 收成 user,再合并,满足严格交替。
// 工具消息和带 tool_calls 的助手消息保持原样,避免拆开调用与结果。
func PrepareMessages(msgs []Message, shape string) []Message {
switch shape {
case "", "merge", "strict":
default:
return msgs
}
out := mergeSameRole(msgs)
if shape == "strict" {
out = foldExtraSystems(out)
out = mergeSameRole(out)
}
return out
}
func mergeable(m Message) bool {
return m.Role != RoleTool && len(m.ToolCalls) == 0 && m.ToolCallID == ""
}
func mergeSameRole(msgs []Message) []Message {
if len(msgs) == 0 {
return msgs
}
out := make([]Message, 0, len(msgs))
for _, m := range msgs {
if n := len(out); n > 0 && out[n-1].Role == m.Role && mergeable(out[n-1]) && mergeable(m) {
if out[n-1].Content != "" && m.Content != "" {
out[n-1].Content += "\n\n" + m.Content
} else {
out[n-1].Content += m.Content
}
continue
}
out = append(out, m)
}
return out
}
func foldExtraSystems(msgs []Message) []Message {
seenSystem := false
out := make([]Message, 0, len(msgs))
for _, m := range msgs {
if m.Role == RoleSystem {
if seenSystem {
m.Role = RoleUser
if m.Content != "" {
m.Content = "[system]\n" + m.Content
}
}
seenSystem = true
}
out = append(out, m)
}
return out
}
+38
View File
@@ -0,0 +1,38 @@
package llm
import "testing"
func TestPrepareMergesAdjacentSystems(t *testing.T) {
out := PrepareMessages([]Message{
{Role: RoleSystem, Content: "a"},
{Role: RoleSystem, Content: "b"},
{Role: RoleUser, Content: "hi"},
}, "merge")
if len(out) != 2 || out[0].Content != "a\n\nb" {
t.Fatalf("merged = %#v", out)
}
}
func TestPrepareStrictFoldsLaterSystem(t *testing.T) {
out := PrepareMessages([]Message{
{Role: RoleSystem, Content: "base"},
{Role: RoleUser, Content: "hi"},
{Role: RoleSystem, Content: "note"},
}, "strict")
if len(out) != 2 {
t.Fatalf("len = %d %#v", len(out), out)
}
if out[1].Role != RoleUser || out[1].Content != "hi\n\n[system]\nnote" {
t.Fatalf("folded = %#v", out[1])
}
}
func TestPrepareKeepsToolPairs(t *testing.T) {
out := PrepareMessages([]Message{
{Role: RoleAssistant, Content: "call", ToolCalls: []ToolCall{{ID: "1", Name: "t"}}},
{Role: RoleTool, ToolCallID: "1", Content: "ok"},
}, "merge")
if len(out) != 2 || out[1].Role != RoleTool {
t.Fatalf("tools = %#v", out)
}
}
+524
View File
@@ -0,0 +1,524 @@
package llm
import (
"context"
"encoding/json"
"fmt"
"math/rand"
"net/http"
"os"
"strconv"
"strings"
"time"
)
// Thinking levels, from off to max. Unknown values are rejected by NormalizeThinking.
var ThinkingLevels = []string{"off", "minimal", "low", "medium", "high", "xhigh", "max"}
// NormalizeThinking returns the canonical level. Empty stays empty.
func NormalizeThinking(level string) (string, error) {
if level == "" {
return "", nil
}
level = strings.ToLower(strings.TrimSpace(level))
for _, known := range ThinkingLevels {
if level == known {
return level, nil
}
}
return "", fmt.Errorf("llm: unknown thinking level %q", level)
}
// OpenAIReasoningEffort maps a thinking level onto OpenAI reasoning_effort.
// off and empty produce no parameter.
func OpenAIReasoningEffort(level string) string {
switch level {
case "minimal":
return "minimal"
case "low":
return "low"
case "medium":
return "medium"
case "high", "xhigh", "max":
return "high"
default:
return ""
}
}
// AnthropicThinkingBudget maps a thinking level onto a token budget.
// off and empty disable thinking.
func AnthropicThinkingBudget(level string) int {
switch level {
case "minimal":
return 1024
case "low":
return 2048
case "medium":
return 4096
case "high":
return 8192
case "xhigh":
return 16384
case "max":
return 32768
default:
return 0
}
}
// GoogleThinkingBudget maps a thinking level onto Gemini thinkingBudget.
func GoogleThinkingBudget(level string) int {
switch level {
case "minimal", "low":
return 1024
case "medium":
return 4096
case "high":
return 8192
case "xhigh", "max":
return 24576
default:
return 0
}
}
// ModelRef is provider/model with an optional :thinking suffix.
type ModelRef struct {
Provider string
Model string
Thinking string
}
// ParseModelRef accepts "model", "provider/model", and "provider/model:level".
func ParseModelRef(spec string) (ModelRef, error) {
spec = strings.TrimSpace(spec)
if spec == "" {
return ModelRef{}, fmt.Errorf("llm: empty model ref")
}
thinking := ""
if i := strings.LastIndex(spec, ":"); i >= 0 {
thinking = spec[i+1:]
spec = spec[:i]
norm, err := NormalizeThinking(thinking)
if err != nil {
return ModelRef{}, err
}
thinking = norm
}
provider, model := "", spec
if i := strings.Index(spec, "/"); i >= 0 {
provider = spec[:i]
model = spec[i+1:]
}
if model == "" {
return ModelRef{}, fmt.Errorf("llm: model id missing in %q", spec)
}
return ModelRef{Provider: provider, Model: model, Thinking: thinking}, nil
}
// Protocol names the wire format a provider speaks.
type Protocol string
const (
ProtocolOpenAIChat Protocol = "openai-chat"
ProtocolOpenAIResponses Protocol = "openai-responses"
ProtocolAnthropic Protocol = "anthropic"
ProtocolGoogle Protocol = "google"
ProtocolAzure Protocol = "azure"
)
// Provider is one configured endpoint.
type Provider struct {
Name string `json:"name"`
Protocol Protocol `json:"protocol"`
BaseURL string `json:"baseURL"`
APIKeyEnv string `json:"apiKeyEnv,omitempty"`
APIKey string `json:"-"`
}
// Presets are OpenAI-compatible or native endpoints that only need an API key.
func Presets() []Provider {
return []Provider{
{Name: "anthropic", Protocol: ProtocolAnthropic, BaseURL: "https://api.anthropic.com", APIKeyEnv: "ANTHROPIC_API_KEY"},
{Name: "openai", Protocol: ProtocolOpenAIChat, BaseURL: "https://api.openai.com/v1", APIKeyEnv: "OPENAI_API_KEY"},
{Name: "openai-responses", Protocol: ProtocolOpenAIResponses, BaseURL: "https://api.openai.com/v1", APIKeyEnv: "OPENAI_API_KEY"},
{Name: "google", Protocol: ProtocolOpenAIChat, BaseURL: "https://generativelanguage.googleapis.com/v1beta/openai", APIKeyEnv: "GEMINI_API_KEY"},
{Name: "azure", Protocol: ProtocolAzure, BaseURL: "", APIKeyEnv: "AZURE_OPENAI_API_KEY"},
{Name: "deepseek", Protocol: ProtocolOpenAIChat, BaseURL: "https://api.deepseek.com/v1", APIKeyEnv: "DEEPSEEK_API_KEY"},
{Name: "minimax", Protocol: ProtocolOpenAIChat, BaseURL: "https://api.minimaxi.com/v1", APIKeyEnv: "MINIMAX_API_KEY"},
{Name: "openrouter", Protocol: ProtocolOpenAIChat, BaseURL: "https://openrouter.ai/api/v1", APIKeyEnv: "OPENROUTER_API_KEY"},
{Name: "groq", Protocol: ProtocolOpenAIChat, BaseURL: "https://api.groq.com/openai/v1", APIKeyEnv: "GROQ_API_KEY"},
}
}
// modelsFile is the on-disk shape of models.json.
type modelsFile struct {
Providers []Provider `json:"providers"`
}
// LoadModelsJSON reads custom providers. Missing file yields an empty list.
func LoadModelsJSON(path string) ([]Provider, error) {
raw, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
var file modelsFile
if err := json.Unmarshal(raw, &file); err != nil {
return nil, err
}
for i := range file.Providers {
if file.Providers[i].APIKey == "" && file.Providers[i].APIKeyEnv != "" {
file.Providers[i].APIKey = os.Getenv(file.Providers[i].APIKeyEnv)
}
}
return file.Providers, nil
}
// Lookup finds a preset or a custom provider by name.
func Lookup(name string, custom []Provider) (Provider, bool) {
for _, p := range custom {
if p.Name == name {
return p, true
}
}
for _, p := range Presets() {
if p.Name == name {
if p.APIKey == "" {
p.APIKey = os.Getenv(p.APIKeyEnv)
}
return p, true
}
}
return Provider{}, false
}
// 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)
}
}
func messageContent(m Message) any {
if len(m.Images) == 0 {
return m.Content
}
parts := []map[string]any{}
if m.Content != "" {
parts = append(parts, map[string]any{"type": "text", "text": m.Content})
}
for _, img := range m.Images {
url := img.URL
if url == "" && img.Data != "" {
mime := img.MIME
if mime == "" {
mime = "image/png"
}
url = "data:" + mime + ";base64," + img.Data
}
parts = append(parts, map[string]any{
"type": "image_url",
"image_url": map[string]any{"url": url},
})
}
return parts
}
// BuildBody returns the JSON body for one native protocol.
func BuildBody(protocol Protocol, model string, messages []Message, tools []ToolDecl, opts StreamOptions) ([]byte, error) {
switch protocol {
case ProtocolOpenAIChat, ProtocolAzure:
body := map[string]any{
"model": model,
"messages": toProviderMessages(messages),
"stream": true,
}
if opts.MaxTokens > 0 {
body["max_tokens"] = opts.MaxTokens
}
if effort := OpenAIReasoningEffort(opts.ThinkingLevel); effort != "" {
body["reasoning_effort"] = effort
}
if len(tools) > 0 {
body["tools"] = toProviderTools(tools)
}
return json.Marshal(body)
case ProtocolOpenAIResponses:
body := map[string]any{
"model": model,
"input": responsesInput(messages),
"stream": true,
}
if opts.MaxTokens > 0 {
body["max_output_tokens"] = opts.MaxTokens
}
if effort := OpenAIReasoningEffort(opts.ThinkingLevel); effort != "" {
body["reasoning"] = map[string]any{"effort": effort}
}
return json.Marshal(body)
// ProtocolGoogle 只供离线组包(客户端未接);Anthropic 与线上客户端
// 共用 anthropicBody(同一组包,消灭离线/线上两张皮)。
case ProtocolAnthropic:
return anthropicBody(model, messages, tools, opts)
case ProtocolGoogle:
body := map[string]any{
"contents": googleContents(messages),
}
if budget := GoogleThinkingBudget(opts.ThinkingLevel); budget > 0 {
body["generationConfig"] = map[string]any{
"thinkingConfig": map[string]any{"thinkingBudget": budget},
}
}
return json.Marshal(body)
default:
return nil, fmt.Errorf("llm: unknown protocol %q", protocol)
}
}
func responsesInput(messages []Message) []map[string]any {
out := make([]map[string]any, 0, len(messages))
for _, m := range messages {
role := string(m.Role)
if role == "" {
role = "user"
}
out = append(out, map[string]any{
"role": role,
"content": messageContent(m),
})
}
return out
}
func googleContents(messages []Message) []map[string]any {
var out []map[string]any
for _, m := range messages {
if m.Role == RoleSystem {
continue
}
role := "user"
if m.Role == RoleAssistant {
role = "model"
}
parts := []map[string]any{}
if m.Content != "" {
parts = append(parts, map[string]any{"text": m.Content})
}
for _, img := range m.Images {
parts = append(parts, map[string]any{
"inlineData": map[string]any{
"mimeType": firstNonEmpty(img.MIME, "image/png"),
"data": img.Data,
},
})
}
out = append(out, map[string]any{"role": role, "parts": parts})
}
return out
}
func firstNonEmpty(a, b string) string {
if a != "" {
return a
}
return b
}
const (
retryBaseDelay = 200 * time.Millisecond
maxRetryAfter = 30 * time.Second
)
// overflowNeedles are substrings that mean the prompt does not fit.
// Matching is case-insensitive. A bare "context" is not enough.
var overflowNeedles = []string{
"status 413",
"payload too large",
"context_length_exceeded",
"context length",
"maximum context",
"context window",
"prompt is too long",
"exceeds the maximum",
"exceeds max",
"too many tokens",
"input is too long",
"input length",
"tokens exceed",
"上下文",
"超出最大",
"输入过长",
"提示过长",
"请求过长",
"请求体过大",
}
// ContextOverflow reports provider errors that ask the caller to compact.
func ContextOverflow(message string) bool {
m := strings.ToLower(message)
for _, needle := range overflowNeedles {
if strings.Contains(m, strings.ToLower(needle)) {
return true
}
}
return false
}
// CapacityLimited reports an in-stream overload that is worth retrying
// before any delta has been forwarded.
func CapacityLimited(message string) bool {
m := strings.ToLower(message)
return strings.Contains(m, "overloaded") || strings.Contains(m, "at capacity") || strings.Contains(m, "too many requests") || strings.Contains(m, "rate limit")
}
// StatusRetryable is true for timeouts, rate limits, server errors, and 529.
func StatusRetryable(code int) bool {
return code == http.StatusRequestTimeout || code == http.StatusTooManyRequests || code == 529 || code >= 500
}
// ParseRetryAfter reads a delay in seconds or an HTTP date.
// The returned duration is capped. ok is false when the header is missing or garbage.
func ParseRetryAfter(raw string, now time.Time) (time.Duration, bool) {
raw = strings.TrimSpace(raw)
if raw == "" {
return 0, false
}
if secs, err := strconv.Atoi(raw); err == nil {
if secs < 0 {
return 0, false
}
return capRetryAfter(time.Duration(secs) * time.Second), true
}
when, err := http.ParseTime(raw)
if err != nil {
return 0, false
}
d := when.Sub(now)
if d < 0 {
d = 0
}
return capRetryAfter(d), true
}
func capRetryAfter(d time.Duration) time.Duration {
if d > maxRetryAfter {
return maxRetryAfter
}
return d
}
func retryBackoff(extraTry int) time.Duration {
if extraTry < 1 {
extraTry = 1
}
shift := extraTry - 1
if shift > 4 {
shift = 4
}
delay := retryBaseDelay * time.Duration(1<<uint(shift))
jitter := time.Duration(rand.Int63n(int64(delay/2) + 1))
return delay + jitter
}
func forwardedDelta(ev Event) bool {
switch ev.Kind {
case KindTextDelta, KindReasoningDelta, KindToolCallDelta:
return true
default:
return false
}
}
// WithRetry re-invokes base on a retryable terminal error, up to attempts
// extra tries. Events are forwarded as they arrive. A retry happens only when
// no delta has been forwarded yet. onEvent receives auto_retry_start and
// auto_retry_end. Context-overflow errors are returned immediately.
// attempts <= 0 disables extra tries.
func WithRetry(base StreamFunc, attempts int, onEvent func(kind, detail string)) StreamFunc {
if attempts < 0 {
attempts = 0
}
return func(ctx context.Context, model string, messages []Message, tools []ToolDecl, opts StreamOptions) <-chan Event {
out := make(chan Event)
go func() {
defer close(out)
headerWait := time.Duration(0)
useHeader := false
for try := 0; try <= attempts; try++ {
if try > 0 {
delay := retryBackoff(try)
if useHeader {
delay = headerWait
}
detail := fmt.Sprintf("attempt %d", try+1)
if onEvent != nil {
onEvent("auto_retry_start", detail)
}
select {
case <-ctx.Done():
emitAbort(out, "", ctx.Err())
return
case <-time.After(delay):
}
}
useHeader = false
forwarded := false
var terminal *Event
for ev := range base(ctx, model, messages, tools, opts) {
if terminal != nil {
continue
}
if ev.Kind == KindError {
cp := ev
terminal = &cp
continue
}
if forwardedDelta(ev) {
forwarded = true
}
select {
case <-ctx.Done():
return
case out <- ev:
}
if ev.Kind == KindMessageEnd {
if try > 0 && onEvent != nil {
onEvent("auto_retry_end", fmt.Sprintf("attempt %d", try+1))
}
return
}
}
if terminal == nil {
return
}
overflow := ContextOverflow(terminal.ErrorMessage)
if forwarded || overflow || !terminal.Retryable || try == attempts {
if try > 0 && onEvent != nil {
onEvent("auto_retry_end", fmt.Sprintf("attempt %d", try+1))
}
select {
case <-ctx.Done():
return
case out <- *terminal:
}
return
}
if terminal.HasRetryAfter {
useHeader = true
headerWait = terminal.RetryAfter
}
}
}()
return out
}
}
+241
View File
@@ -0,0 +1,241 @@
package llm
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestParseModelRefAndThinking(t *testing.T) {
ref, err := ParseModelRef("anthropic/claude-sonnet:high")
if err != nil {
t.Fatal(err)
}
if ref.Provider != "anthropic" || ref.Model != "claude-sonnet" || ref.Thinking != "high" {
t.Fatalf("%+v", ref)
}
if _, err := ParseModelRef("openai/gpt:nope"); err == nil {
t.Fatal("unknown thinking level must fail")
}
if AnthropicThinkingBudget("high") != 8192 || OpenAIReasoningEffort("off") != "" {
t.Fatal("thinking map")
}
p, ok := Lookup("groq", nil)
if !ok || !strings.Contains(p.BaseURL, "groq") || p.Protocol != ProtocolOpenAIChat {
t.Fatalf("preset: %+v", p)
}
}
func TestModelsJSONAndBodies(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "models.json")
raw := []byte(`{"providers":[{"name":"local","protocol":"openai-chat","baseURL":"http://127.0.0.1:9","apiKeyEnv":"UNUSED_KEY"}]}`)
if err := os.WriteFile(path, raw, 0o644); err != nil {
t.Fatal(err)
}
got, err := LoadModelsJSON(path)
if err != nil || len(got) != 1 || got[0].Name != "local" {
t.Fatalf("%+v %v", got, err)
}
msgs := []Message{{
Role: RoleUser, Content: "see",
Images: []Image{{MIME: "image/png", Data: "aaaa"}},
}}
body, err := BuildBody(ProtocolAnthropic, "claude", append([]Message{{Role: RoleSystem, Content: "sys"}}, msgs...), nil, StreamOptions{ThinkingLevel: "low"})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(body), "budget_tokens") || !strings.Contains(string(body), "base64") {
t.Fatalf("anthropic body: %s", body)
}
gbody, err := BuildBody(ProtocolGoogle, "gemini", msgs, nil, StreamOptions{ThinkingLevel: "max"})
if err != nil || !strings.Contains(string(gbody), "inlineData") || !strings.Contains(string(gbody), "thinkingBudget") {
t.Fatalf("google: %s %v", gbody, err)
}
rbody, err := BuildBody(ProtocolOpenAIResponses, "gpt", msgs, nil, StreamOptions{ThinkingLevel: "medium", MaxTokens: 20})
if err != nil || !strings.Contains(string(rbody), "max_output_tokens") || !strings.Contains(string(rbody), "image_url") {
t.Fatalf("responses: %s %v", rbody, err)
}
var decoded map[string]any
if err := json.Unmarshal(rbody, &decoded); err != nil {
t.Fatal(err)
}
}
func TestPresetsAndClientForChatCompletions(t *testing.T) {
// 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() {
w, ok := want[p.Name]
if !ok {
continue
}
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 {
t.Fatal(name)
}
if _, err := ClientFor(p); err == nil {
t.Fatalf("%s should fail closed", name)
}
}
var path string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path = r.URL.Path
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte("data: [DONE]\n\n"))
}))
defer srv.Close()
client, err := ClientFor(Provider{Protocol: ProtocolOpenAIChat, BaseURL: srv.URL, APIKey: "k"})
if err != nil {
t.Fatal(err)
}
for ev := range client.Stream(context.Background(), "m", []Message{{Role: RoleUser, Content: "hi"}}, nil, StreamOptions{}) {
if ev.Kind == KindError {
t.Fatal(ev.Err)
}
}
if path != "/chat/completions" {
t.Fatalf("path %q", path)
}
}
func TestRetryThenSuccess(t *testing.T) {
n := 0
base := StreamFunc(func(ctx context.Context, model string, messages []Message, tools []ToolDecl, opts StreamOptions) <-chan Event {
out := make(chan Event, 2)
n++
if n == 1 {
out <- Event{Kind: KindError, Retryable: true, ErrorMessage: "429"}
} else {
out <- Event{Kind: KindTextDelta, Text: "ok"}
out <- Event{Kind: KindMessageEnd, Message: Message{Role: RoleAssistant, Content: "ok"}}
}
close(out)
return out
})
var events []string
wrapped := WithRetry(base, 2, func(kind, detail string) { events = append(events, kind) })
var texts []string
for ev := range wrapped(context.Background(), "m", nil, nil, StreamOptions{}) {
if ev.Kind == KindTextDelta {
texts = append(texts, ev.Text)
}
}
if len(texts) != 1 || texts[0] != "ok" {
t.Fatalf("texts %v calls %d", texts, n)
}
if len(events) == 0 || events[0] != "auto_retry_start" {
t.Fatalf("events %v", events)
}
}
func TestContextOverflowPatterns(t *testing.T) {
hits := []string{
"provider status 413: payload too large",
"context_length_exceeded",
"prompt is too long",
"input length exceeds the maximum",
"maximum context length exceeded",
"context length overflow",
"too many tokens",
"输入过长,请缩短上下文",
"请求过长",
"超出最大长度",
}
for _, msg := range hits {
if !ContextOverflow(msg) {
t.Fatalf("want overflow: %s", msg)
}
}
if ContextOverflow("context cancelled") || ContextOverflow("temporary network error") {
t.Fatal("unrelated context text must not count as overflow")
}
}
func TestParseRetryAfter(t *testing.T) {
now := time.Date(2026, 10, 3, 12, 0, 0, 0, time.UTC)
if d, ok := ParseRetryAfter("2", now); !ok || d != 2*time.Second {
t.Fatalf("seconds: %v %v", d, ok)
}
if d, ok := ParseRetryAfter("not-a-date", now); ok || d != 0 {
t.Fatalf("garbage should fail: %v %v", d, ok)
}
if _, ok := ParseRetryAfter("", now); ok {
t.Fatal("empty header")
}
far := now.Add(2 * time.Hour).Format(http.TimeFormat)
if d, ok := ParseRetryAfter(far, now); !ok || d != maxRetryAfter {
t.Fatalf("date cap: %v %v", d, ok)
}
past := now.Add(-time.Minute).Format(http.TimeFormat)
if d, ok := ParseRetryAfter(past, now); !ok || d != 0 {
t.Fatalf("past date: %v %v", d, ok)
}
}
func TestRetryDoesNotRepeatDeltas(t *testing.T) {
n := 0
base := StreamFunc(func(ctx context.Context, model string, messages []Message, tools []ToolDecl, opts StreamOptions) <-chan Event {
out := make(chan Event, 2)
n++
out <- Event{Kind: KindTextDelta, Text: "a"}
out <- Event{Kind: KindError, Retryable: true, ErrorMessage: "overloaded"}
close(out)
return out
})
var texts []string
var errors int
for ev := range WithRetry(base, 2, nil)(context.Background(), "m", nil, nil, StreamOptions{}) {
if ev.Kind == KindTextDelta {
texts = append(texts, ev.Text)
}
if ev.Kind == KindError {
errors++
}
}
if n != 1 || len(texts) != 1 || texts[0] != "a" || errors != 1 {
t.Fatalf("calls=%d texts=%v errors=%d", n, texts, errors)
}
}
func TestContextOverflowIsNotRetried(t *testing.T) {
if !ContextOverflow("maximum context length exceeded") {
t.Fatal("expected overflow")
}
n := 0
base := StreamFunc(func(ctx context.Context, model string, messages []Message, tools []ToolDecl, opts StreamOptions) <-chan Event {
out := make(chan Event, 1)
n++
out <- Event{Kind: KindError, Retryable: true, ErrorMessage: "context length overflow"}
close(out)
return out
})
for range WithRetry(base, 3, nil)(context.Background(), "m", nil, nil, StreamOptions{}) {
}
if n != 1 {
t.Fatalf("overflow must not retry, calls=%d", n)
}
}
+29
View File
@@ -0,0 +1,29 @@
package llm
import (
"context"
"strings"
"testing"
)
func TestR2SSEJoinsMultilineData(t *testing.T) {
body := strings.Join([]string{
`data: {"choices":[{"delta":{"content":"hi"}`,
`data: }]}`,
``,
`data: [DONE]`,
``,
}, "\n")
var got string
for ev := range ParseSSE(context.Background(), strings.NewReader(body)) {
if ev.Kind == KindTextDelta {
got += ev.Text
}
if ev.Kind == KindError {
t.Fatal(ev.Err)
}
}
if got != "hi" {
t.Fatalf("joined content %q", got)
}
}
+114 -15
View File
@@ -26,12 +26,19 @@ type sseChoice struct {
type sseChunk struct {
Choices []sseChoice `json:"choices"`
Usage *Usage `json:"usage"`
Usage *usageWire `json:"usage"`
Error *struct {
Message string `json:"message"`
} `json:"error"`
}
type usageWire struct {
Usage
Details struct {
Cached int `json:"cached_tokens"`
} `json:"prompt_tokens_details"`
}
// ParseSSE reads an OpenAI-compatible server-sent event body and emits
// deltas plus one terminal message_end or error event.
func ParseSSE(ctx context.Context, r io.Reader) <-chan Event {
@@ -45,26 +52,26 @@ func ParseSSE(ctx context.Context, r io.Reader) <-chan Event {
func emitParsed(ctx context.Context, r io.Reader, out chan<- Event) {
var content strings.Builder
var reasoning strings.Builder
calls := map[int]*ToolCall{}
order := []int{}
var usage Usage
finished := ""
sawDone := false
sc := bufio.NewScanner(r)
sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
for sc.Scan() {
reader := &sseEventReader{sc: sc}
for {
data, ok := reader.next()
if !ok {
break
}
if ctx.Err() != nil {
emitAbort(out, content.String(), ctx.Err())
return
}
line := strings.TrimSpace(sc.Text())
if line == "" || strings.HasPrefix(line, ":") {
continue
}
if !strings.HasPrefix(line, "data:") {
continue
}
data := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
if data == "[DONE]" {
sawDone = true
break
}
var chunk sseChunk
@@ -73,11 +80,16 @@ func emitParsed(ctx context.Context, r io.Reader, out chan<- Event) {
return
}
if chunk.Error != nil && chunk.Error.Message != "" {
emitError(out, content.String(), false, chunk.Error.Message)
msg := chunk.Error.Message
retryable := CapacityLimited(msg) && !ContextOverflow(msg)
emitError(out, content.String(), retryable, msg)
return
}
if chunk.Usage != nil {
usage = *chunk.Usage
usage = chunk.Usage.Usage
if chunk.Usage.Details.Cached > 0 {
usage.CacheReadTokens = chunk.Usage.Details.Cached
}
}
if len(chunk.Choices) == 0 {
continue
@@ -97,8 +109,12 @@ func emitParsed(ctx context.Context, r io.Reader, out chan<- Event) {
}
// 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())
@@ -144,7 +160,20 @@ func emitParsed(ctx context.Context, r io.Reader, out chan<- Event) {
emitError(out, content.String(), true, err.Error())
return
}
// 取消必须压过正常结束。select 在两个分支都就绪时随机挑选,
// 竞态下会把 message_end 发给已经取消的调用方。
if ctx.Err() != nil {
emitAbort(out, content.String(), ctx.Err())
return
}
// length、空结束原因,或没有 [DONE] 的中断,都不把半截工具调用交给执行层。
if !toolFinishExecutable(finished, sawDone) {
order = nil
}
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])
}
@@ -154,7 +183,75 @@ func emitParsed(ctx context.Context, r io.Reader, out chan<- Event) {
select {
case <-ctx.Done():
emitAbort(out, content.String(), ctx.Err())
case out <- Event{Kind: KindMessageEnd, Message: msg, Usage: usage}:
case out <- Event{Kind: KindMessageEnd, Message: msg, Usage: usage, FinishReason: finished}:
}
}
// sseEventReader 按空行切事件,并把同一事件里的多行 data: 用换行拼回。
// 已经是完整 JSON 的 data 行单独成事件,这样单行流不用空行也能解析。
type sseEventReader struct {
sc *bufio.Scanner
carry string
}
func (r *sseEventReader) next() (string, bool) {
var parts []string
nextPiece := func() (string, bool) {
if r.carry != "" {
piece := r.carry
r.carry = ""
return piece, true
}
if !r.sc.Scan() {
return "", false
}
return r.sc.Text(), true
}
for {
line, ok := nextPiece()
if !ok {
break
}
if strings.TrimSpace(line) == "" {
if len(parts) == 0 {
continue
}
return strings.Join(parts, "\n"), true
}
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, ":") {
continue
}
if !strings.HasPrefix(trimmed, "data:") {
continue
}
piece := strings.TrimSpace(strings.TrimPrefix(trimmed, "data:"))
if len(parts) > 0 && json.Valid([]byte(strings.Join(parts, "\n"))) {
r.carry = line
return strings.Join(parts, "\n"), true
}
parts = append(parts, piece)
}
if len(parts) > 0 {
return strings.Join(parts, "\n"), true
}
return "", false
}
// toolFinishExecutable 只在供应商明确以工具调用或正常 stop 结束时执行。
// length、缺失的结束原因,以及没有 [DONE] 的断开,都视为未完成。
func toolFinishExecutable(reason string, sawDone bool) bool {
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"
}
}
@@ -164,7 +261,9 @@ func mapFinish(reason string, hasTools bool) StopReason {
return StopToolCalls
case "content_filter", "error":
return StopError
case "stop", "length", "":
case "length":
return StopEnd
case "stop", "":
if hasTools {
return StopToolCalls
}
+68
View File
@@ -38,6 +38,29 @@ func TestParseSSETextAndUsage(t *testing.T) {
if end.Usage.TotalTokens != 5 {
t.Fatalf("usage %#v", end.Usage)
}
if end.FinishReason != "stop" {
t.Fatalf("finish reason %q, want stop", end.FinishReason)
}
}
func TestParseSSEFinishReasonLengthKeepsStopEnd(t *testing.T) {
body := strings.Join([]string{
`data: {"choices":[{"delta":{"content":"半"},"finish_reason":"length"}]}`,
`data: [DONE]`,
"",
}, "\n")
var end Event
for ev := range ParseSSE(context.Background(), strings.NewReader(body)) {
if ev.Kind == KindMessageEnd {
end = ev
}
}
if end.FinishReason != "length" {
t.Fatalf("finish reason %q, want length", end.FinishReason)
}
if end.Message.StopReason != StopEnd {
t.Fatalf("stop reason %s, want end so existing callers stay unchanged", end.Message.StopReason)
}
}
func TestParseSSEToolCallIncrements(t *testing.T) {
@@ -70,6 +93,51 @@ func TestParseSSEToolCallIncrements(t *testing.T) {
}
}
func TestParseSSELengthDropsUnfinishedToolCall(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"}]}`,
`data: [DONE]`,
"",
}, "\n")
var end Event
for ev := range ParseSSE(context.Background(), strings.NewReader(body)) {
if ev.Kind == KindMessageEnd {
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.FinishReason != "length" {
t.Fatalf("finish %q", end.FinishReason)
}
}
func TestParseSSEUncleanEndDropsToolCall(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)) {
if ev.Kind == KindMessageEnd {
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)
}
}
func TestParseSSECapacityErrorIsRetryable(t *testing.T) {
body := "data: {\"error\":{\"message\":\"server is at capacity\"}}\n\n"
var got Event
for ev := range ParseSSE(context.Background(), strings.NewReader(body)) {
got = ev
}
if got.Kind != KindError || !got.Retryable || !CapacityLimited(got.ErrorMessage) {
t.Fatalf("event %#v", got)
}
}
func TestParseSSEMalformedIsError(t *testing.T) {
body := "data: {not-json}\n\n"
var got Event
+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)
}
}
+53 -9
View File
@@ -3,6 +3,7 @@ package llm
import (
"context"
"encoding/json"
"time"
)
// Role is a transcript role carried into the provider request.
@@ -48,10 +49,24 @@ type Message struct {
Role Role `json:"role"`
Content string `json:"content,omitempty"`
Name string `json:"name,omitempty"`
Images []Image `json:"images,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
StopReason StopReason `json:"stop_reason,omitempty"`
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.
@@ -61,11 +76,26 @@ type ToolDecl struct {
Parameters json.RawMessage `json:"parameters"`
}
// Usage counts tokens reported by the provider. Zero means the provider omitted usage.
// Image is one picture attached to a user message.
type Image struct {
MIME string `json:"mime,omitempty"`
URL string `json:"url,omitempty"`
Data string `json:"data,omitempty"`
}
// Usage counts tokens. On one provider response, PromptTokens, CompletionTokens
// and TotalTokens are that request. On a loop Result they are the billing sum
// across requests; Last* is only the latest request, for context observation.
// Zero means the provider omitted usage.
type Usage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
CacheReadTokens int `json:"cache_read_tokens,omitempty"`
CacheWriteTokens int `json:"cache_write_tokens,omitempty"`
LastPromptTokens int `json:"last_prompt_tokens,omitempty"`
LastCompletionTokens int `json:"last_completion_tokens,omitempty"`
LastTotalTokens int `json:"last_total_tokens,omitempty"`
}
// Event is one streamed update. Failures are KindError plus a final assistant
@@ -80,9 +110,18 @@ type Event struct {
ArgumentsDelta string
Message Message
Usage Usage
Err error
Retryable bool
ErrorMessage string
// FinishReason 是供应商原始结束原因(stop、length、tool_calls 等)。
// 空表示这条事件不携带结束原因。StopReason 的映射不受此字段影响。
FinishReason string
Err error
Retryable bool
ErrorMessage string
// HTTPStatus 是提供方的 HTTP 状态。0 表示这条错误不是 HTTP 响应。
HTTPStatus int
// RetryAfter is the provider's requested wait. HasRetryAfter distinguishes
// a real zero from a missing or unparseable header.
RetryAfter time.Duration
HasRetryAfter bool
}
// StreamOptions tunes one provider request.
@@ -90,6 +129,11 @@ type StreamOptions struct {
MaxTokens int
APIBase string
APIKey string
// ThinkingLevel is off, minimal, low, medium, high, xhigh, or max.
// Empty leaves the provider default unchanged.
ThinkingLevel string
// MessageShape is merge, strict, or empty. Empty leaves the transcript unchanged.
MessageShape string
}
// StreamFunc is the provider boundary. It must return a channel that is closed
+902 -17
View File
File diff suppressed because it is too large Load Diff
+339
View File
@@ -0,0 +1,339 @@
package runtime
import (
"context"
"encoding/json"
"strings"
"sync"
"testing"
"time"
"agent-native/runtime/llm"
"agent-native/runtime/tools"
)
// usageScript 产出带 usage 的固定回合,用于预算测试。
func usageScript(usage llm.Usage, turns ...llm.Message) llm.StreamFunc {
var i int
return func(ctx context.Context, model string, messages []llm.Message, decls []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
ch := make(chan llm.Event, 4)
go func() {
defer close(ch)
if i >= len(turns) {
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd}, Usage: usage}
return
}
msg := turns[i]
i++
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: msg, Usage: usage}
}()
return ch
}
}
func TestLoopBudgetToolCallCap(t *testing.T) {
dir := t.TempDir()
reg := tools.WorkspaceTools(dir)
sawBudgetEvent := false
res, err := Run(context.Background(), Config{
Model: "fake",
Tools: reg,
Budget: &Budget{MaxToolCalls: 1},
OnEvent: func(ev Event) {
if ev.Type == "budget_exceeded" {
sawBudgetEvent = true
}
},
Stream: usageScript(llm.Usage{},
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
ID: "c1", Name: "list_dir", Arguments: `{}`,
}}},
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{
{ID: "c2", Name: "list_dir", Arguments: `{}`},
{ID: "c3", Name: "list_dir", Arguments: `{}`},
}},
llm.Message{Role: llm.RoleAssistant, Content: "never", StopReason: llm.StopEnd},
),
}, llm.Message{Role: llm.RoleUser, Content: "loop tools"})
if err != nil {
t.Fatal(err)
}
if res.StopReason != llm.StopError || res.Err == nil || !strings.Contains(res.Err.Error(), "budget_exceeded") {
t.Fatalf("want budget_exceeded stop, got stop=%s err=%v", res.StopReason, res.Err)
}
if !strings.Contains(res.Err.Error(), "tool calls") {
t.Fatalf("want tool-call budget reason, got %v", res.Err)
}
if !sawBudgetEvent {
t.Fatal("expected budget_exceeded event")
}
// 第一批执行 1 次;第二批超限,两条合成错误结果,不执行。
toolResults := 0
skipped := 0
for _, m := range res.Messages {
if m.Role != llm.RoleTool {
continue
}
toolResults++
if m.IsError && m.Content == toolBudgetSkipContent {
skipped++
}
}
if toolResults != 3 || skipped != 2 {
t.Fatalf("expected 3 tool results including 2 skipped, got results=%d skipped=%d", toolResults, skipped)
}
assertToolCallsPaired(t, res.Messages)
}
func assertToolCallsPaired(t *testing.T, messages []llm.Message) {
t.Helper()
pending := map[string]string{}
for _, m := range messages {
if m.Role == llm.RoleAssistant {
for _, call := range m.ToolCalls {
if _, ok := pending[call.ID]; ok {
t.Fatalf("duplicate tool call id %s", call.ID)
}
pending[call.ID] = call.Name
}
}
if m.Role == llm.RoleTool {
name, ok := pending[m.ToolCallID]
if !ok {
t.Fatalf("tool result %s has no matching call", m.ToolCallID)
}
if name != m.Name {
t.Fatalf("tool result %s name %q != call %q", m.ToolCallID, m.Name, name)
}
delete(pending, m.ToolCallID)
}
}
if len(pending) != 0 {
t.Fatalf("unpaired tool calls: %v", pending)
}
}
type pauseTool struct{}
func (pauseTool) Name() string { return "pause" }
func (pauseTool) Description() string { return "slow tool" }
func (pauseTool) Parameters() json.RawMessage {
return json.RawMessage(`{"type":"object","properties":{}}`)
}
func (pauseTool) Execute(context.Context, json.RawMessage) (tools.Result, error) {
time.Sleep(40 * time.Millisecond)
return tools.Result{Content: "ok"}, nil
}
func TestParallelHostCallbacksAreSerial(t *testing.T) {
reg := tools.NewRegistry(t.TempDir())
reg.Register(pauseTool{})
var mu sync.Mutex
inFlight, maxInFlight := 0, 0
enter := func() {
mu.Lock()
inFlight++
if inFlight > maxInFlight {
maxInFlight = inFlight
}
mu.Unlock()
time.Sleep(20 * time.Millisecond)
mu.Lock()
inFlight--
mu.Unlock()
}
_, err := Run(context.Background(), Config{
Model: "fake",
Tools: reg,
ToolExecution: ExecParallel,
OnEvent: func(Event) {
enter()
},
BeforeTool: func(llm.ToolCall) error {
enter()
return nil
},
AfterTool: func(llm.ToolCall, llm.Message) {
enter()
},
Stream: usageScript(llm.Usage{},
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{
{ID: "a", Name: "pause", Arguments: `{}`},
{ID: "b", Name: "pause", 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 maxInFlight != 1 {
t.Fatalf("host callbacks overlapped: max in flight %d", maxInFlight)
}
}
func TestLoopBudgetPromptTokens(t *testing.T) {
res, err := Run(context.Background(), Config{
Model: "fake",
Budget: &Budget{MaxPromptTokens: 10},
Stream: usageScript(llm.Usage{PromptTokens: 500, TotalTokens: 600},
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
ID: "c1", Name: "list_dir", Arguments: `{}`,
}}},
llm.Message{Role: llm.RoleAssistant, Content: "never", StopReason: llm.StopEnd},
),
}, llm.Message{Role: llm.RoleUser, Content: "go"})
if err != nil {
t.Fatal(err)
}
if res.Err == nil || !strings.Contains(res.Err.Error(), "prompt tokens") {
t.Fatalf("want prompt-token budget stop, got err=%v", res.Err)
}
if res.Usage.PromptTokens != 500 {
t.Fatalf("usage should be reported on budget stop, got %d", res.Usage.PromptTokens)
}
}
func TestLoopUsageSplitsLastRequestFromCumulative(t *testing.T) {
res, err := Run(context.Background(), Config{
Model: "fake",
Tools: tools.WorkspaceTools(t.TempDir()),
Budget: &Budget{MaxPromptTokens: 10},
Stream: usageScript(llm.Usage{PromptTokens: 6, CompletionTokens: 1, TotalTokens: 7},
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
ID: "c1", Name: "list_dir", Arguments: `{}`,
}}},
llm.Message{Role: llm.RoleAssistant, Content: "ok", StopReason: llm.StopEnd},
),
}, llm.Message{Role: llm.RoleUser, Content: "go"})
if err != nil {
t.Fatal(err)
}
if res.Err != nil || res.Text != "ok" {
t.Fatalf("last request stays under the window: err=%v text=%q", res.Err, res.Text)
}
if res.Usage.PromptTokens != 12 || res.Usage.LastPromptTokens != 6 {
t.Fatalf("billing sum %d last %d", res.Usage.PromptTokens, res.Usage.LastPromptTokens)
}
}
func TestLoopRejectsIncompleteToolCall(t *testing.T) {
tool := &countingTool{name: "list_dir"}
reg := tools.NewRegistry(t.TempDir())
reg.Register(tool)
res, err := Run(context.Background(), Config{
Model: "fake",
Tools: reg,
Stream: script(llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
ID: "c1", Name: "list_dir", Arguments: `{"path":`,
}}}, llm.Message{Role: llm.RoleAssistant, Content: "stopped", StopReason: llm.StopEnd}),
}, llm.Message{Role: llm.RoleUser, Content: "go"})
if err != nil {
t.Fatal(err)
}
if tool.calls != 0 {
t.Fatalf("incomplete call executed %d times", tool.calls)
}
var paired bool
for _, m := range res.Messages {
if m.Role == llm.RoleTool && m.ToolCallID == "c1" && m.IsError {
paired = true
}
}
if !paired {
t.Fatal("expected a paired error result")
}
}
func TestLoopBudgetDeadline(t *testing.T) {
res, err := Run(context.Background(), Config{
Model: "fake",
Budget: &Budget{Deadline: time.Now().Add(-time.Second)},
Stream: usageScript(llm.Usage{}, llm.Message{Role: llm.RoleAssistant, Content: "never", StopReason: llm.StopEnd}),
}, llm.Message{Role: llm.RoleUser, Content: "go"})
if err != nil {
t.Fatal(err)
}
if res.Err == nil || !strings.Contains(res.Err.Error(), "deadline") {
t.Fatalf("want deadline budget stop, got err=%v", res.Err)
}
}
func TestLoopBudgetNotHitCompletesNormally(t *testing.T) {
res, err := Run(context.Background(), Config{
Model: "fake",
Budget: &Budget{MaxPromptTokens: 10_000, MaxToolCalls: 10},
Stream: usageScript(llm.Usage{PromptTokens: 50},
llm.Message{Role: llm.RoleAssistant, Content: "ok", StopReason: llm.StopEnd}),
}, llm.Message{Role: llm.RoleUser, Content: "go"})
if err != nil {
t.Fatal(err)
}
if res.Err != nil || res.Text != "ok" {
t.Fatalf("clean run should finish, got err=%v text=%q", res.Err, res.Text)
}
}
// blockingTool 阻塞到 ctx 取消,验证取消传播到工具执行层。
type blockingTool struct{ canceled chan struct{} }
func (b *blockingTool) Name() string { return "block_forever" }
func (b *blockingTool) Description() string { return "blocks until context cancel" }
func (b *blockingTool) Parameters() json.RawMessage {
return json.RawMessage(`{"type":"object","properties":{}}`)
}
func (b *blockingTool) Execute(ctx context.Context, args json.RawMessage) (tools.Result, error) {
<-ctx.Done()
close(b.canceled)
return tools.Result{Content: "canceled", IsError: true}, ctx.Err()
}
func TestLoopCancelPropagatesToTool(t *testing.T) {
bt := &blockingTool{canceled: make(chan struct{})}
reg := tools.NewRegistry(t.TempDir())
reg.Register(bt)
ctx, cancel := context.WithCancel(context.Background())
firstCall := true
stream := func(ctx context.Context, model string, messages []llm.Message, decls []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
ch := make(chan llm.Event, 4)
go func() {
defer close(ch)
if firstCall {
firstCall = false
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{
Role: llm.RoleAssistant, StopReason: llm.StopToolCalls,
ToolCalls: []llm.ToolCall{{ID: "c1", Name: "block_forever", Arguments: `{}`}},
}}
return
}
// 工具返回后的下一轮:等取消或超时兜底。
select {
case <-ctx.Done():
ch <- llm.Event{Kind: llm.KindError, Err: ctx.Err(), Message: llm.Message{StopReason: llm.StopAborted}}
case <-time.After(3 * time.Second):
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd}}
}
}()
return ch
}
go func() {
time.Sleep(100 * time.Millisecond)
cancel()
}()
res, err := Run(ctx, Config{Model: "fake", Tools: reg, Stream: stream},
llm.Message{Role: llm.RoleUser, Content: "block"})
if err != nil {
t.Fatal(err)
}
select {
case <-bt.canceled:
case <-time.After(2 * time.Second):
t.Fatal("tool context was not canceled")
}
if res.StopReason != llm.StopAborted && res.Err == nil {
t.Fatalf("expected aborted stop, got %+v", res)
}
}
+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{} // 保持导入面稳定
}
+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)
}
}
+371
View File
@@ -0,0 +1,371 @@
package runtime
import (
"context"
"encoding/json"
"errors"
"path/filepath"
"strings"
"sync"
"testing"
"agent-native/runtime/llm"
"agent-native/runtime/session"
"agent-native/runtime/tools"
)
func TestLoopStopContinuesUntilCap(t *testing.T) {
var calls int
res, err := Run(context.Background(), Config{
Model: "fake",
MaxTurns: 8,
Stream: script(
llm.Message{Role: llm.RoleAssistant, Content: "forged-claim", StopReason: llm.StopEnd},
llm.Message{Role: llm.RoleAssistant, Content: "forged-claim", StopReason: llm.StopEnd},
llm.Message{Role: llm.RoleAssistant, Content: "forged-claim", StopReason: llm.StopEnd},
llm.Message{Role: llm.RoleAssistant, Content: "forged-claim", StopReason: llm.StopEnd},
),
Stop: func(last llm.Message, info StopInfo) StopDecision {
calls++
if info.Continuations != calls-1 {
t.Errorf("continuations = %d, want %d", info.Continuations, calls-1)
}
return StopDecision{Continue: true, Replacement: "已作废", Reason: "再试", Notice: "到顶了"}
},
}, llm.Message{Role: llm.RoleUser, Content: "go"})
if err != nil {
t.Fatal(err)
}
if calls != 4 {
t.Fatalf("stop calls = %d, want 4", calls)
}
if res.Err != nil || res.Notice != "到顶了" {
t.Fatalf("notice %q err %v", res.Notice, res.Err)
}
// N6(N3 WP1):assistant 不再被改写——替换文只以 user 消息出现,
// 内存与转录一致。
forged, notes := 0, 0
for _, m := range res.Messages {
if m.Role == llm.RoleAssistant && m.Content == "forged-claim" {
forged++
}
if m.Role == llm.RoleUser && m.Content == "已作废" {
notes++
}
}
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))
}
}
func TestLoopBeforeToolRejectPairsResult(t *testing.T) {
hits := 0
reg := tools.NewRegistry(t.TempDir())
reg.Register(hitTool{hits: &hits})
var seen llm.Message
res, err := Run(context.Background(), Config{
Model: "fake",
Tools: reg,
Stream: script(
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
ID: "c1", Name: "echo", Arguments: `{}`,
}}},
llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd},
),
BeforeTool: func(call llm.ToolCall) error {
return errors.New("不允许")
},
AfterTool: func(call llm.ToolCall, msg llm.Message) {
seen = msg
},
}, llm.Message{Role: llm.RoleUser, Content: "go"})
if err != nil {
t.Fatal(err)
}
if hits != 0 {
t.Fatalf("tool executed %d times", hits)
}
if !seen.IsError || seen.Content != "不允许" || seen.ToolCallID != "c1" {
t.Fatalf("after = %#v", seen)
}
paired := false
for _, m := range res.Messages {
if m.Role == llm.RoleTool && m.ToolCallID == "c1" && m.IsError && m.Content == "不允许" {
paired = true
}
}
if !paired {
t.Fatalf("missing paired tool result: %s", transcriptText(res.Messages))
}
}
func TestLoopRemindersStayOutOfSession(t *testing.T) {
path := filepath.Join(t.TempDir(), "transcript.jsonl")
st, err := session.Open(path)
if err != nil {
t.Fatal(err)
}
var saw bool
_, err = Run(context.Background(), Config{
Model: "fake",
Session: st,
Stream: func(ctx context.Context, model string, messages []llm.Message, decls []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
for _, m := range messages {
if strings.Contains(m.Content, "<system-reminder>") && strings.Contains(m.Content, "先核对交付物") {
saw = true
}
}
ch := make(chan llm.Event, 1)
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{Role: llm.RoleAssistant, Content: "ok", StopReason: llm.StopEnd}}
close(ch)
return ch
},
Reminders: func(turn int) []string {
if turn != 0 {
t.Errorf("turn %d", turn)
}
return []string{"先核对交付物"}
},
}, llm.Message{Role: llm.RoleUser, Content: "ping"})
if err != nil {
t.Fatal(err)
}
if !saw {
t.Fatal("reminder missing from the provider request")
}
loaded, err := st.Load()
if err != nil {
t.Fatal(err)
}
if strings.Contains(transcriptText(loaded), "先核对交付物") {
t.Fatalf("reminder persisted: %#v", loaded)
}
}
func TestLoopRepeatGuardFiresAndCaps(t *testing.T) {
var mu sync.Mutex
var requests []string
turns := make([]llm.Message, 0, 13)
for i := 0; i < 12; i++ {
args := `{"b":1,"a":2}`
if i%2 == 1 {
args = `{"a":2,"b":1}`
}
turns = append(turns, llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
ID: "c", Name: "echo", Arguments: args,
}}})
}
turns = append(turns, llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd})
var i int
stream := func(ctx context.Context, model string, messages []llm.Message, decls []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
mu.Lock()
requests = append(requests, transcriptText(messages))
n := i
i++
mu.Unlock()
ch := make(chan llm.Event, 1)
msg := turns[len(turns)-1]
if n < len(turns) {
msg = turns[n]
}
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: msg}
close(ch)
return ch
}
res, err := Run(context.Background(), Config{
Model: "fake",
MaxTurns: 20,
RepeatGuard: true,
Stream: stream,
}, llm.Message{Role: llm.RoleUser, Content: "go"})
if err != nil {
t.Fatal(err)
}
if res.Text != "done" {
t.Fatalf("text %q", res.Text)
}
reminders := 0
for _, req := range requests {
reminders += strings.Count(req, repeatReminderText)
}
if reminders != 3 {
t.Fatalf("reminders = %d, want 3; requests=%d", reminders, len(requests))
}
if strings.Contains(transcriptText(res.Messages), repeatReminderText) {
t.Fatal("repeat reminder was written into the transcript")
}
if !strings.Contains(requests[3], repeatReminderText) {
t.Fatal("first reminder should arrive on the request after the third identical call")
}
}
func TestLoopContinueOnLengthCaps(t *testing.T) {
var requests []string
var n int
stream := func(ctx context.Context, model string, messages []llm.Message, decls []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
requests = append(requests, transcriptText(messages))
n++
ch := make(chan llm.Event, 1)
ch <- llm.Event{
Kind: llm.KindMessageEnd,
FinishReason: "length",
Message: llm.Message{Role: llm.RoleAssistant, Content: "part", StopReason: llm.StopEnd},
}
close(ch)
return ch
}
res, err := Run(context.Background(), Config{
Model: "fake",
MaxTurns: 8,
ContinueOnLength: true,
Stream: stream,
}, llm.Message{Role: llm.RoleUser, Content: "go"})
if err != nil {
t.Fatal(err)
}
if res.Err == nil || !strings.Contains(res.Err.Error(), "length continuation exceeded") {
t.Fatalf("err = %v", res.Err)
}
if n != 4 {
t.Fatalf("model calls = %d, want 4", n)
}
if len(res.Messages) == 0 || !strings.Contains(transcriptText(res.Messages), "part") {
t.Fatal("truncated transcript was dropped")
}
if strings.Contains(transcriptText(res.Messages), lengthContinuePrompt) {
t.Fatal("continuation prompt was persisted")
}
prompts := 0
for _, req := range requests {
prompts += strings.Count(req, lengthContinuePrompt)
}
if prompts != 3 {
t.Fatalf("continuation prompts = %d, want 3", prompts)
}
}
func TestLoopContinueOnLengthUnlimited(t *testing.T) {
var n int
stream := func(ctx context.Context, model string, messages []llm.Message, decls []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
n++
ch := make(chan llm.Event, 1)
finish := "length"
text := "part"
if n >= 5 {
finish = "stop"
text = "done"
}
ch <- llm.Event{
Kind: llm.KindMessageEnd,
FinishReason: finish,
Message: llm.Message{Role: llm.RoleAssistant, Content: text, StopReason: llm.StopEnd},
}
close(ch)
return ch
}
res, err := Run(context.Background(), Config{
Model: "fake",
MaxTurns: 8,
ContinueOnLength: true,
MaxLengthContinuations: -1,
Stream: stream,
}, llm.Message{Role: llm.RoleUser, Content: "go"})
if err != nil {
t.Fatal(err)
}
if res.Err != nil {
t.Fatalf("err = %v", res.Err)
}
if n != 5 {
t.Fatalf("model calls = %d, want 5", n)
}
if res.Text != "done" {
t.Fatalf("text = %q", res.Text)
}
}
func TestLoopHistorySkipsGeneratedSystem(t *testing.T) {
var first string
_, err := Run(context.Background(), Config{
Model: "fake",
SystemPrompt: "LEAK",
History: []llm.Message{
{Role: llm.RoleSystem, Content: "FROM_HISTORY"},
{Role: llm.RoleUser, Content: "earlier"},
},
Stream: func(ctx context.Context, model string, messages []llm.Message, decls []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
if len(messages) > 0 {
first = messages[0].Content
}
ch := make(chan llm.Event, 1)
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{Role: llm.RoleAssistant, Content: "ok", StopReason: llm.StopEnd}}
close(ch)
return ch
},
}, llm.Message{Role: llm.RoleUser, Content: "now"})
if err != nil {
t.Fatal(err)
}
if first != "FROM_HISTORY" || strings.Contains(first, "LEAK") {
t.Fatalf("system = %q", first)
}
}
func TestLoopUnknownToolFallsThrough(t *testing.T) {
res, err := Run(context.Background(), Config{
Model: "fake",
UnknownTool: func(call llm.ToolCall) tools.Result {
return tools.Result{Content: "via " + call.Name}
},
Stream: script(
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
ID: "c1", Name: "make_report", Arguments: `{}`,
}}},
llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd},
),
}, llm.Message{Role: llm.RoleUser, Content: "go"})
if err != nil {
t.Fatal(err)
}
found := false
for _, m := range res.Messages {
if m.Role == llm.RoleTool && m.Content == "via make_report" && !m.IsError {
found = true
}
}
if !found {
t.Fatalf("unknown tool was not delegated: %s", transcriptText(res.Messages))
}
}
func TestLoopTurnCapKeepsTranscript(t *testing.T) {
res, err := Run(context.Background(), Config{
Model: "fake",
MaxTurns: 1,
Stream: script(llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
ID: "c1", Name: "missing", Arguments: `{}`,
}}}),
}, llm.Message{Role: llm.RoleUser, Content: "ping"})
if err != nil {
t.Fatal(err)
}
if res.Err == nil || !strings.Contains(res.Err.Error(), "max turns exceeded") {
t.Fatalf("err = %v", res.Err)
}
if !strings.Contains(transcriptText(res.Messages), "ping") {
t.Fatalf("transcript dropped: %s", transcriptText(res.Messages))
}
}
type hitTool struct{ hits *int }
func (h hitTool) Name() string { return "echo" }
func (h hitTool) Description() string { return "echo" }
func (h hitTool) Parameters() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
func (h hitTool) Execute(context.Context, json.RawMessage) (tools.Result, error) {
*h.hits++
return tools.Result{Content: "ran"}, nil
}
+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)
}
}
+38
View File
@@ -0,0 +1,38 @@
package mcp
import "context"
type askKey struct{}
// WithAsk 把当前回合的 AskUser 放进上下文。MCP 返回 input_required 时使用它。
func WithAsk(ctx context.Context, fn func(context.Context, string) (string, error)) context.Context {
return context.WithValue(ctx, askKey{}, fn)
}
func askFrom(ctx context.Context) func(context.Context, string) (string, error) {
if ctx == nil {
return nil
}
fn, _ := ctx.Value(askKey{}).(func(context.Context, string) (string, error))
return fn
}
// 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
}
}
return "", false
}
+178
View File
@@ -0,0 +1,178 @@
package mcp
// 本文件是跨测试二进制复用的最小 MCP fake server(stdio JSON-RPC)。
// 测试以「自身可执行文件 + AGENT_NATIVE_TEST_MCP_STDIO=1」re-exec 方式拉起,
// 供真实传输链路测试使用;非测试代码不得调用。
import (
"bufio"
"encoding/json"
"os"
"time"
)
// RunFakeServer 在 stdin/stdout 上运行最小 MCP server(echo / boom / 未知方法),
// 直到 stdin 关闭。测试二进制在 TestMain 的 re-exec 分支调用。
func RunFakeServer() {
scanner := bufio.NewScanner(os.Stdin)
scanner.Buffer(make([]byte, 1<<20), 1<<20)
w := bufio.NewWriter(os.Stdout)
defer w.Flush()
for scanner.Scan() {
var req rpcRequest
if err := json.Unmarshal(scanner.Bytes(), &req); err != nil {
continue
}
if req.ID == 0 {
continue // 通知帧
}
resp := fakeServerHandle(req)
body, _ := json.Marshal(resp)
w.Write(body)
w.WriteByte('\n')
w.Flush()
}
}
func fakeServerHandle(req rpcRequest) rpcResponse {
respond := func(result any) rpcResponse {
raw, _ := json.Marshal(result)
return rpcResponse{JSONRPC: "2.0", ID: &req.ID, Result: raw}
}
fail := func(code int, msg string) rpcResponse {
return rpcResponse{JSONRPC: "2.0", ID: &req.ID, Error: &rpcError{Code: code, Message: msg}}
}
switch req.Method {
case "initialize":
// 协商:取(客户端请求, 自身上限)的较小者(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: negotiated,
ServerInfo: clientInfo{Name: "fake-mcp", Version: "0"},
})
case "tools/list":
return respond(toolsListResult{Tools: fakeServerTools()})
case "tools/call":
var p callToolParams
if req.Params != nil {
raw, _ := json.Marshal(req.Params)
_ = json.Unmarshal(raw, &p)
}
switch p.Name {
case "echo":
var args struct {
Text string `json:"text"`
}
_ = json.Unmarshal(p.Arguments, &args)
return respond(callToolResult{Content: []contentBlock{{Type: "text", Text: "echo: " + args.Text}}})
case "boom":
return respond(callToolResult{
Content: []contentBlock{{Type: "text", Text: "boom failed"}},
IsError: true,
})
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{
{
Name: "echo",
Description: "Echo the text back",
InputSchema: json.RawMessage(`{"type":"object","required":["text"],"properties":{"text":{"type":"string"}}}`),
},
{
Name: "boom",
Description: "Always fails",
InputSchema: json.RawMessage(`{"type":"object","properties":{}}`),
},
{
Name: "slow",
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 路径。
},
}
}
// FakeServerEnv 是拉起 fake server 的环境变量名。
const FakeServerEnv = "AGENT_NATIVE_TEST_MCP_STDIO"

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