Compare commits

..
Author SHA1 Message Date
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
132 changed files with 11315 additions and 749 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"
-56
View File
@@ -1,56 +0,0 @@
// Package intentgate 提供可选的意图闸门装饰器:在把 Prompt 交给被装饰的
// 执行后端之前,用 agent-intent 做一次确定性识别;分支为守卫/命中/预期命中时
// 交由宿主 Handler 解释,Ambiguous/Fallback 或 Handler 放弃时原样透传。
//
// 本包是 Agent-Native 仓内唯一 import agent-intent 的地方。不使用本包时,
// 本仓与 agent-intent 无任何编译关系;未 Wrap 或快照零意图时,被装饰后端的
// 行为与原来完全一致。
package intentgate
import (
"context"
"agent-intent/intent"
"agent-native/exec"
)
// Handler 解释"识别到意图"的执行请求。返回 handled=false 表示宿主放弃处理,
// 装饰器回退为透传原后端。合成结果的语义(拒绝、引导、定向执行)由宿主定义,
// 本包不发明结果语义。
type Handler func(ctx context.Context, req exec.Request, res intent.Result) (result *exec.Result, err error, handled bool)
type gated struct {
next exec.Backend
rec intent.Recognizer
snap *intent.Snapshot
handler Handler
}
// Wrap 返回带意图闸门的后端。next/rec/snap/handler 任一为 nil 时直接返回 next
// (等价于未启用,行为不变)。
func Wrap(next exec.Backend, rec intent.Recognizer, snap *intent.Snapshot, h Handler) exec.Backend {
if next == nil || rec == nil || snap == nil || h == nil {
return next
}
return &gated{next: next, rec: rec, snap: snap, handler: h}
}
func (g *gated) Name() string { return g.next.Name() }
func (g *gated) Execute(ctx context.Context, req exec.Request) (*exec.Result, error) {
res, err := g.rec.Recognize(ctx, req.Prompt, g.snap)
if err != nil {
// 识别系统错误 fail-closed:不静默放行可能应拦截的内容,也不吞错。
return nil, err
}
switch res.Branch {
case intent.BranchGuardHit, intent.BranchMatched, intent.BranchExpectationHit:
default:
return g.next.Execute(ctx, req) // 无裁决:透传原后端
}
out, herr, handled := g.handler(ctx, req, res)
if !handled {
return g.next.Execute(ctx, req)
}
return out, herr
}
-166
View File
@@ -1,166 +0,0 @@
package intentgate
import (
"context"
"errors"
"reflect"
"testing"
"agent-intent/intent"
"agent-native/exec"
)
type stubBackend struct {
name string
calls int
}
func (s *stubBackend) Name() string { return s.name }
func (s *stubBackend) Execute(ctx context.Context, req exec.Request) (*exec.Result, error) {
s.calls++
return &exec.Result{Success: true, Logs: "stub:" + req.Prompt}, nil
}
func emptySnapshot(t *testing.T) *intent.Snapshot {
t.Helper()
reg, err := intent.NewRegistryBuilder(intent.Options{}).Build()
if err != nil {
t.Fatal(err)
}
return reg.Snapshot()
}
// 带一个守卫 spec 与一个可路由 spec 的快照。
func loadedSnapshot(t *testing.T) *intent.Snapshot {
t.Helper()
b := intent.NewRegistryBuilder(intent.Options{
GuardDomains: []string{"guard"},
ProtectedDomains: []string{"guard"},
})
if err := b.Register(intent.IntentSpec{
ID: "guard.config_change", Domain: "guard", Version: 1,
Matcher: intent.MatchSpec{IncludesAny: []string{"系统配置", "网关"}},
Risk: intent.RiskHigh,
Handler: "refuse.and_guide",
Metadata: map[string]string{
"guidance": "系统配置请在管理界面修改",
},
}); err != nil {
t.Fatal(err)
}
reg, err := b.Build()
if err != nil {
t.Fatal(err)
}
return reg.Snapshot()
}
func newRec() intent.Recognizer { return intent.NewRecognizer(intent.Options{}, nil, nil) }
func TestZeroSpecSnapshotPassthroughIdentical(t *testing.T) {
// T0 行为解耦:零意图快照 + Wrap 后,与裸后端行为逐字节一致。
bare := &stubBackend{name: "native"}
gatedBe := Wrap(&stubBackend{name: "native"}, newRec(), emptySnapshot(t),
func(ctx context.Context, req exec.Request, res intent.Result) (*exec.Result, error, bool) {
t.Fatal("handler must not fire on empty snapshot")
return nil, nil, false
})
req := exec.Request{Prompt: "帮我改一下系统网关地址"}
want, werr := bare.Execute(context.Background(), req)
got, gerr := gatedBe.Execute(context.Background(), req)
if werr != nil || gerr != nil {
t.Fatalf("execute errors: %v %v", werr, gerr)
}
if !reflect.DeepEqual(want, got) {
t.Fatalf("results differ: %+v vs %+v", want, got)
}
if gatedBe.(*gated).next.(*stubBackend).calls != 1 {
t.Fatal("next backend not invoked exactly once")
}
}
func TestGuardHitInterceptsWithoutModel(t *testing.T) {
next := &stubBackend{name: "native"}
be := Wrap(next, newRec(), loadedSnapshot(t),
func(ctx context.Context, req exec.Request, res intent.Result) (*exec.Result, error, bool) {
if res.Branch != intent.BranchGuardHit || res.Top == nil || res.Top.ID != "guard.config_change" {
t.Fatalf("unexpected result: %+v", res)
}
// 宿主解释守卫意图:拒绝 + 引导(合成结果语义由宿主定义)。
return &exec.Result{Success: false, ErrorReason: "guard refused: " + res.Top.Metadata["guidance"]}, nil, true
})
out, err := be.Execute(context.Background(), exec.Request{Prompt: "帮我改一下系统网关地址"})
if err != nil {
t.Fatal(err)
}
if out.Success || out.ErrorReason == "" {
t.Fatalf("expected guard refusal, got %+v", out)
}
if next.calls != 0 {
t.Fatalf("model backend must not be called on guard hit, got %d calls", next.calls)
}
}
func TestFallbackPassesThroughToBackend(t *testing.T) {
next := &stubBackend{name: "native"}
be := Wrap(next, newRec(), loadedSnapshot(t),
func(ctx context.Context, req exec.Request, res intent.Result) (*exec.Result, error, bool) {
t.Fatal("handler must not fire on fallback")
return nil, nil, false
})
out, err := be.Execute(context.Background(), exec.Request{Prompt: "写一份周报"})
if err != nil || out == nil || !out.Success {
t.Fatalf("fallback must reach backend: %+v %v", out, err)
}
if next.calls != 1 {
t.Fatalf("backend calls = %d", next.calls)
}
}
func TestHandlerDeclineFallsBackToBackend(t *testing.T) {
next := &stubBackend{name: "native"}
be := Wrap(next, newRec(), loadedSnapshot(t),
func(ctx context.Context, req exec.Request, res intent.Result) (*exec.Result, error, bool) {
return nil, nil, false // 宿主放弃处理
})
out, err := be.Execute(context.Background(), exec.Request{Prompt: "帮我改一下系统网关地址"})
if err != nil || out == nil || !out.Success {
t.Fatalf("declined handling must pass through: %+v %v", out, err)
}
if next.calls != 1 {
t.Fatalf("backend calls = %d", next.calls)
}
}
func TestNilComponentsReturnNext(t *testing.T) {
next := &stubBackend{name: "native"}
if got := Wrap(next, nil, nil, nil); got != exec.Backend(next) {
t.Fatal("nil components must return next unchanged")
}
}
func TestRecognizeErrorFailsClosed(t *testing.T) {
next := &stubBackend{name: "native"}
b := intent.NewRegistryBuilder(intent.Options{MaxInputLen: 8})
reg, err := b.Build()
if err != nil {
t.Fatal(err)
}
be := Wrap(next, newRec(), reg.Snapshot(),
func(ctx context.Context, req exec.Request, res intent.Result) (*exec.Result, error, bool) {
t.Fatal("handler must not fire on recognize error")
return nil, nil, false
})
_, err = be.Execute(context.Background(), exec.Request{Prompt: "这一段输入明显超过八个字节"})
if err == nil {
t.Fatal("expected fail-closed error")
}
var ie *intent.Error
if !errors.As(err, &ie) {
t.Fatalf("expected *intent.Error, got %T", err)
}
if next.calls != 0 {
t.Fatal("backend must not run on recognize error")
}
}
+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
+149 -41
View File
@@ -14,6 +14,9 @@ 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"
@@ -25,39 +28,68 @@ import (
"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
// Search 可选:提供时注册 tavily_search / web_search(密钥由组合根注入)。
Search tools.SearchProvider
// Exec 可选:提供时注册 run_command / run_python(解释器留空项不注册)。
// nil = 不注册执行类工具(fail-closed,与 BashPermission 默认 deny 对齐)。
Exec *tools.ExecConfig
// Fetch 可选:提供时注册 fetch_url;nil = 不注册(fail-closed)。
Fetch *tools.FetchConfig
// MCP 可选:提供时把各 server 工具以 mcp__<server>__<tool> 注册进工作区。
// nil = 不注册(fail-closed:server 不可达时整个步骤失败,不静默跳过)。
MCP *mcp.Manager
// SkillsRoots 可选:全局技能根目录;执行时自动并入工作区 .agent/skills/。
SkillsRoots []string
// Compact 可选:转录超过阈值时把头部历史压成摘要(长会话上下文治理)。
Compact *CompactConfig
// Subagent 可选:提供时注册 delegate_task(委派由宿主实现,内核只开端口)。
Subagent subagent.Factory
// HostTools 是宿主产品工具(登记工件、卡片、知识库)。与内核工具同名时失败,不覆盖。
HostTools []tools.Tool
// MaxToolCalls 是单步工具调用上限。0 表示不限制。请求自带预算时以请求为准。
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
// Log 可选。nil 时丢弃日志。
Log *logsys.Logger
// ExecutionID 写入日志,不参与执行控制。
}
// 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 声明长会话压缩策略(优化性质:压缩失败不阻断执行,但留日志)。
@@ -122,6 +154,9 @@ 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 b.opts.Ask != nil {
ctx = mcp.WithAsk(ctx, b.opts.Ask)
}
model := strings.TrimSpace(b.opts.Model)
if strings.TrimSpace(req.Model) != "" {
model = strings.TrimSpace(req.Model)
@@ -164,7 +199,7 @@ func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result,
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)
@@ -179,9 +214,19 @@ func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result,
}
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 {
if err := b.opts.MCP.RegisterInto(ctx, reg); err != nil {
return &exec.Result{Success: false, ErrorReason: err.Error()}, 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)
@@ -276,9 +321,7 @@ 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,
@@ -293,9 +336,7 @@ func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result,
}
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 {
@@ -319,7 +360,7 @@ func (b *Backend) run(ctx context.Context, model string, budget *runtime.Budget,
if strings.TrimSpace(execID) == "" {
execID = b.opts.ExecutionID
}
runRes, err := runtime.Run(ctx, runtime.Config{
agent := runtime.NewAgentSession(runtime.Config{
Model: model,
SystemPrompt: b.systemPrompt(req, model, profile, workDir, skillIndex),
Tools: reg,
@@ -329,6 +370,11 @@ func (b *Backend) run(ctx context.Context, model string, budget *runtime.Budget,
Log: b.logger(),
ExecutionID: execID,
Budget: budget,
Reminders: b.opts.Reminders,
RepeatGuard: b.opts.RepeatGuard,
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 != "" {
@@ -351,8 +397,8 @@ func (b *Backend) run(ctx context.Context, model string, budget *runtime.Budget,
})
}
},
}, llm.Message{Role: llm.RoleUser, Content: req.Prompt})
return runRes, err
})
return agent.Prompt(ctx, llm.Message{Role: llm.RoleUser, Content: req.Prompt})
}
// maybeCompact 在转录超过阈值时压缩头部历史。压缩是优化性质:
@@ -419,6 +465,40 @@ func defaultSummarizer(stream llm.StreamFunc, model string) func(ctx context.Con
}
}
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) }
@@ -433,9 +513,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
}
@@ -469,6 +547,36 @@ func (b *Backend) logger() *logsys.Logger {
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
+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")
}
}
+14 -7
View File
@@ -30,11 +30,15 @@ func TestBackendCompactBeforeRun(t *testing.T) {
var sawSummary bool
// 注入式捕获:验证 Run 收到的消息里有摘要标记。
bk2 := New(Options{
Model: "fake",
Stream: captureFirstStream(&sawSummary),
Compact: &CompactConfig{ThresholdChars: 1000, KeepLastN: 3, Summarizer: func(ctx context.Context, messages []llm.Message) (string, error) {
return "预置历史摘要", nil
}},
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"},
@@ -47,8 +51,11 @@ func TestBackendCompactBeforeRun(t *testing.T) {
t.Fatal("compaction should have run before the loop")
}
data, _ := os.ReadFile(transcript)
if !strings.Contains(string(data), "[conversation summary]") {
t.Fatalf("transcript should contain the summary marker, got:\n%.200s", data)
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")
}
}
+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
}
+67
View File
@@ -0,0 +1,67 @@
package native
import (
"context"
"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)
}
}
+96 -28
View File
@@ -22,19 +22,23 @@ func TestBackendExecToolsWiredWithOptionsAndRequestEnv(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: "run_command",
Arguments: `{"command":"echo role=$ROLE_FLAG step=$STEP_FLAG > exec_out.txt"}`,
}}},
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
ID: "c2", Name: "write_file",
Arguments: `{"path":"output.md","content":"done"}`,
}}},
llm.Message{Role: llm.RoleAssistant, Content: "ok", StopReason: llm.StopEnd},
),
Exec: &tools.ExecConfig{Bash: bash, Env: []string{"PATH=/usr/bin:/bin", "ROLE_FLAG=backend"}},
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"},
@@ -54,7 +58,12 @@ func TestBackendExecToolsWiredWithOptionsAndRequestEnv(t *testing.T) {
func TestBackendExecToolsAbsentWithoutOptions(t *testing.T) {
stream, c := captureStream()
b := New(Options{Model: "fake", Stream: stream})
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,
@@ -64,7 +73,12 @@ func TestBackendExecToolsAbsentWithoutOptions(t *testing.T) {
}
// reasoning 档本无工具;改用 workspace 档断言 decls 不含执行工具。
stream2, c2 := captureStream()
b2 := New(Options{Model: "fake", Stream: stream2})
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,
@@ -83,8 +97,13 @@ func TestBackendAllowlistCanDisableExecTools(t *testing.T) {
bash, _ := osexec.LookPath("bash")
stream, c := captureStream()
b := New(Options{
Model: "fake", Stream: stream,
Exec: &tools.ExecConfig{Bash: bash, Env: []string{"PATH=/usr/bin:/bin"}},
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"},
@@ -125,7 +144,15 @@ func TestBackendSearchToolsWiredViaOptions(t *testing.T) {
}}},
llm.Message{Role: llm.RoleAssistant, Content: "ok", StopReason: llm.StopEnd},
)
b := New(Options{Model: "fake", Stream: stream, Search: provider})
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"},
@@ -152,7 +179,12 @@ func TestBackendSearchToolsWiredViaOptions(t *testing.T) {
func TestBackendSearchToolsAbsentWithoutOptions(t *testing.T) {
stream, c := captureStream()
b := New(Options{Model: "fake", Stream: stream})
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,
@@ -180,6 +212,9 @@ func TestBackendMCPToolsWiredViaOptions(t *testing.T) {
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"}`,
@@ -190,7 +225,15 @@ func TestBackendMCPToolsWiredViaOptions(t *testing.T) {
}}},
llm.Message{Role: llm.RoleAssistant, Content: "ok", StopReason: llm.StopEnd},
)
b := New(Options{Model: "fake", Stream: stream, MCP: mgr})
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"},
@@ -229,7 +272,15 @@ func TestBackendSkillsIndexAndLoader(t *testing.T) {
}}},
llm.Message{Role: llm.RoleAssistant, Content: "ok", StopReason: llm.StopEnd},
)
b := New(Options{Model: "fake", Stream: stream, SkillsRoots: []string{skillsRoot}})
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"},
@@ -257,7 +308,15 @@ func TestBackendSkillsIndexAndLoader(t *testing.T) {
}
func TestBackendDeclaredSkillMissingFailsClosed(t *testing.T) {
b := New(Options{Model: "fake", Stream: script(), SkillsRoots: []string{t.TempDir()}})
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"},
@@ -277,7 +336,12 @@ func TestBackendWorkspaceSkillsRootAutoMerged(t *testing.T) {
}}},
llm.Message{Role: llm.RoleAssistant, Content: "ok", StopReason: llm.StopEnd},
)
b := New(Options{Model: "fake", Stream: stream})
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 {
@@ -311,11 +375,15 @@ func TestBackendSubagentWiredViaOptions(t *testing.T) {
llm.Message{Role: llm.RoleAssistant, Content: "ok", StopReason: llm.StopEnd},
)
b := New(Options{
Model: "fake",
Stream: stream,
Subagent: func(ctx context.Context, req subagent.Request) (subagent.Result, error) {
delegated = &req
return subagent.Result{Success: true, Text: "结论已查明"}, nil
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()
+4
View File
@@ -0,0 +1,4 @@
execution_id=cx session=
agent_start
turn_start
message_start
+21 -9
View File
@@ -25,9 +25,13 @@ func (h hostTool) Execute(ctx context.Context, args json.RawMessage) (tools.Resu
func TestHostToolAndUsageEvent(t *testing.T) {
var events []exec.ProcessEvent
b := New(Options{
Model: "fake",
HostTools: []tools.Tool{hostTool{name: "register_artifact"}},
Stream: usageScript(),
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: "登记",
@@ -65,9 +69,13 @@ func TestHostToolAndUsageEvent(t *testing.T) {
func TestHostToolSeesStepWorkDir(t *testing.T) {
var seen string
b := New(Options{
Model: "fake",
Stream: script(llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{ID: "c1", Name: "peek", Arguments: `{}`}}}),
HostTools: []tools.Tool{peekTool{got: &seen}},
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"}})
@@ -91,9 +99,13 @@ func (p peekTool) Execute(ctx context.Context, args json.RawMessage) (tools.Resu
func TestHostToolNameConflictFailsClosed(t *testing.T) {
b := New(Options{
Model: "fake",
Stream: script(llm.Message{Role: llm.RoleAssistant, Content: "x", StopReason: llm.StopEnd}),
HostTools: []tools.Tool{hostTool{name: "read_file"}},
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"},
+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")
}
}
+31 -6
View File
@@ -58,7 +58,12 @@ func toolNames(decls []llm.ToolDecl) []string {
func TestBackendRequestModelOverride(t *testing.T) {
stream, c := captureStream()
b := New(Options{Model: "default-model", Stream: stream})
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",
@@ -82,7 +87,12 @@ func TestBackendRequestToolAllowlist(t *testing.T) {
}}},
llm.Message{Role: llm.RoleAssistant, Content: "gave up", StopReason: llm.StopEnd},
)
b := New(Options{Model: "fake", Stream: stream})
b := New(Options{
ModelOptions: ModelOptions{
Model: "fake",
Stream: stream,
},
})
dir := t.TempDir()
// 目标 output2.md 不在 allowlist 内能写吗?——write_file 被整体收窄,
// 模型两次尝试都会得到 unknown tool,最终无交付物 → 失败。
@@ -109,7 +119,12 @@ func TestBackendRequestToolAllowlist(t *testing.T) {
}
func TestBackendRequestUnknownAllowlistFailsClosed(t *testing.T) {
b := New(Options{Model: "fake", Stream: script()})
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"},
@@ -139,7 +154,12 @@ func TestBackendRequestBudgetDeadlineBindsContext(t *testing.T) {
}()
return ch
}
b := New(Options{Model: "fake", Stream: stream})
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"},
@@ -164,12 +184,17 @@ func TestBackendRequestBudgetDeadlineBindsContext(t *testing.T) {
func TestBackendRequestExecutionIDInLogs(t *testing.T) {
stream, _ := captureStream()
b := New(Options{Model: "fake", Stream: stream})
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",
ExecutionID: "exec-42", SessionScope: "run-9/step-3",
ToolProfile: exec.ToolProfileReasoning,
})
if err != nil || !res.Success {
+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)
+14 -14
View File
@@ -3,28 +3,28 @@ package catalogcheck
import (
"testing"
"agent-native/contract"
"agent-native/exec"
"error-code-system/errcode"
)
// 常量取值必须与错误码库种子码相同。旧短字符串只作为错误码库里的别名存在。
func TestContractCodesMatchCatalog(t *testing.T) {
pairs := []struct {
got contract.ErrorCode
got exec.ErrorCode
want errcode.Code
}{
{contract.ErrCanceled, errcode.AgentCanceled},
{contract.ErrDeadlineExceeded, errcode.AgentDeadlineExceeded},
{contract.ErrBudgetExceeded, errcode.AgentBudgetExceeded},
{contract.ErrProviderUnavailable, errcode.AgentProviderUnavailable},
{contract.ErrProviderRejected, errcode.AgentProviderRejected},
{contract.ErrToolRejected, errcode.AgentToolRejected},
{contract.ErrPathOutsideWorkspace, errcode.AgentPathOutsideWorkspace},
{contract.ErrMissingCapability, errcode.AgentMissingCapability},
{contract.ErrMissingDeliverable, errcode.AgentMissingDeliverable},
{contract.ErrEmptyOutput, errcode.AgentEmptyOutput},
{contract.ErrUnknownOutcome, errcode.AgentUnknownOutcome},
{contract.ErrInternal, errcode.AgentInternal},
{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) {
+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")
}
}
-63
View File
@@ -1,63 +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")
}
}
func TestErrorDuckTypeForLogs(t *testing.T) {
e := &contract.Error{
Code: contract.ErrProviderUnavailable,
Reason: "down",
Retryable: true,
SideEffects: contract.SideEffectNone,
}
if e.ErrorCode() != "agent.provider_unavailable" {
t.Fatal(e.ErrorCode())
}
fields := e.ErrorFields()
if fields["reason"] != "down" || fields["side_effects"] != "none" || fields["retryable"] != true {
t.Fatalf("%v", fields)
}
var nilErr *contract.Error
if nilErr.ErrorCode() != "" || nilErr.ErrorFields() != nil {
t.Fatal("nil error")
}
}
+16
View File
@@ -89,3 +89,19 @@ baseline_fixed: true
安全声明(不冒充已解决):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,请求/结果字段不变 |
+1 -1
View File
@@ -16,7 +16,7 @@ type Request struct {
// OnEvent 可选过程事件回调(nil = 调用方不需要过程,后端行为与现状完全一致)。
// 实现方必须保证:回调在 Execute 返回前完成(同步调用或等待收尾),无泄漏 goroutine;
// 回调内不得阻塞(消费方自行节流/异步入队)。
OnEvent func(ev ProcessEvent)
OnEvent func(ev ProcessEvent) `json:"-"`
// ---- v0.2 能力字段(全部可选;零值 = 既有行为,向后兼容) ----
+18 -23
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,9 +93,9 @@ 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 为隔离身份;可为零值(一次性任务)。
@@ -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"`
@@ -235,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"`
@@ -271,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")
}
}
-3
View File
@@ -3,13 +3,10 @@ module agent-native
go 1.27
require (
agent-intent v0.0.0
error-code-system v0.0.0
logging-system v0.0.0
)
replace agent-intent => ./deps/agent-intent
replace error-code-system => ./deps/error-code-system
replace logging-system => ./deps/logging-system
View File
+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")
}
}
+242
View File
@@ -0,0 +1,242 @@
// Package checkpoint 记录写文件之前的内容,并按用户消息把文件退回去。
// 只追踪显式交给它的写入。shell 与 python 的改动不经过这里。
package checkpoint
import (
"bytes"
"encoding/json"
"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 是当前内容既不是写入后也不是写入前的文件。
type Result struct {
Restored []string
Skipped []string
}
// Log 是追加写的检查点文件。
type Log struct {
path string
mu sync.Mutex
}
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()
return &Log{path: path}, 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()
all, err := l.read()
if err != nil {
return err
}
rec := Record{
Seq: len(all) + 1, 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
}
// 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)
for _, rel := range plan.Restored {
group := groupFor(selected(all, anchor, ordered), rel)
if err := restore(group); err != nil {
return Result{}, err
}
}
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
}
func restore(group []Record) error {
if len(group) == 0 {
return nil
}
first := group[0]
abs := filepath.Join(first.Root, filepath.FromSlash(first.Path))
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)
}
}
+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")
}
}
+210
View File
@@ -0,0 +1,210 @@
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")
}
}
+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) }
+21 -3
View File
@@ -42,6 +42,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 +56,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 +92,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) {
@@ -125,7 +143,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)
}
}
+575
View File
@@ -0,0 +1,575 @@
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: ProtocolOpenAIChat, BaseURL: "https://api.anthropic.com/v1", 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 以及原生 Anthropic/Google
// 协议当前没有接到客户端上,返回明确错误而不是发到错误的路径。
func ClientFor(p Provider) (*Client, error) {
switch p.Protocol {
case ProtocolOpenAIChat:
return &Client{BaseURL: p.BaseURL, APIKey: p.APIKey}, 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)
// ProtocolAnthropic 与 ProtocolGoogle 只供离线组包。
// 当前 Client 只发 OpenAI chat completions,这两支没有接到客户端上。
// 原生协议是后续阶段的事;预设表里的 anthropic / google 已改走兼容端点。
case ProtocolAnthropic:
sys, msgs := anthropicMessages(messages)
body := map[string]any{
"model": model,
"messages": msgs,
"max_tokens": opts.MaxTokens,
"stream": true,
}
// Anthropic 要求 max_tokens。0 表示调用方不限制:用足够大的默认值,
// 避免 4096 把长回复截断。真正的上限由调用方 StreamOptions.MaxTokens 决定。
if body["max_tokens"] == 0 {
body["max_tokens"] = 128000
}
if sys != "" {
body["system"] = sys
}
if budget := AnthropicThinkingBudget(opts.ThinkingLevel); budget > 0 {
body["thinking"] = map[string]any{"type": "enabled", "budget_tokens": budget}
}
return json.Marshal(body)
case ProtocolGoogle:
body := map[string]any{
"contents": googleContents(messages),
}
if budget := GoogleThinkingBudget(opts.ThinkingLevel); budget > 0 {
body["generationConfig"] = map[string]any{
"thinkingConfig": map[string]any{"thinkingBudget": budget},
}
}
return json.Marshal(body)
default:
return nil, fmt.Errorf("llm: unknown protocol %q", protocol)
}
}
func responsesInput(messages []Message) []map[string]any {
out := make([]map[string]any, 0, len(messages))
for _, m := range messages {
role := string(m.Role)
if role == "" {
role = "user"
}
out = append(out, map[string]any{
"role": role,
"content": messageContent(m),
})
}
return out
}
func anthropicMessages(messages []Message) (string, []map[string]any) {
var system strings.Builder
var out []map[string]any
for _, m := range messages {
if m.Role == RoleSystem {
if system.Len() > 0 {
system.WriteString("\n")
}
system.WriteString(m.Content)
continue
}
role := "user"
if m.Role == RoleAssistant {
role = "assistant"
}
content := []map[string]any{}
if m.Content != "" {
content = append(content, map[string]any{"type": "text", "text": m.Content})
}
for _, img := range m.Images {
content = append(content, map[string]any{
"type": "image",
"source": map[string]any{
"type": "base64",
"media_type": firstNonEmpty(img.MIME, "image/png"),
"data": img.Data,
},
})
}
out = append(out, map[string]any{"role": role, "content": content})
}
return system.String(), out
}
func googleContents(messages []Message) []map[string]any {
var out []map[string]any
for _, m := range messages {
if m.Role == RoleSystem {
continue
}
role := "user"
if m.Role == RoleAssistant {
role = "model"
}
parts := []map[string]any{}
if m.Content != "" {
parts = append(parts, map[string]any{"text": m.Content})
}
for _, img := range m.Images {
parts = append(parts, map[string]any{
"inlineData": map[string]any{
"mimeType": firstNonEmpty(img.MIME, "image/png"),
"data": img.Data,
},
})
}
out = append(out, map[string]any{"role": role, "parts": parts})
}
return out
}
func firstNonEmpty(a, b string) string {
if a != "" {
return a
}
return b
}
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
}
}
+231
View File
@@ -0,0 +1,231 @@
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) {
want := map[string]string{
"anthropic": "https://api.anthropic.com/v1",
"google": "https://generativelanguage.googleapis.com/v1beta/openai",
}
for _, p := range Presets() {
base, ok := want[p.Name]
if !ok {
continue
}
if p.Protocol != ProtocolOpenAIChat || p.BaseURL != base {
t.Fatalf("%s preset: %+v", p.Name, p)
}
}
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)
}
}
+105 -14
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 {
@@ -49,22 +56,21 @@ func emitParsed(ctx context.Context, r io.Reader, out chan<- Event) {
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 +79,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
@@ -144,6 +155,16 @@ 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)}
for _, idx := range order {
msg.ToolCalls = append(msg.ToolCalls, *calls[idx])
@@ -154,7 +175,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 +253,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
+38 -7
View File
@@ -3,6 +3,7 @@ package llm
import (
"context"
"encoding/json"
"time"
)
// Role is a transcript role carried into the provider request.
@@ -48,6 +49,7 @@ type Message struct {
Role Role `json:"role"`
Content string `json:"content,omitempty"`
Name string `json:"name,omitempty"`
Images []Image `json:"images,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
StopReason StopReason `json:"stop_reason,omitempty"`
@@ -61,11 +63,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 +97,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 +116,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
+567 -20
View File
@@ -8,7 +8,9 @@ import (
"sync"
"time"
"agent-native/runtime/hooks"
"agent-native/runtime/llm"
"agent-native/runtime/reminders"
"agent-native/runtime/session"
"agent-native/runtime/tools"
@@ -18,8 +20,15 @@ import (
const (
ExecSequential = "sequential"
ExecParallel = "parallel"
maxStopContinuations = 3
maxLengthContinuations = 3
maxRepeatReminders = 3
)
const repeatReminderText = "你已连续 3 次以相同参数调用同一工具。如果结果不符合预期,请更换参数或做法,不要再重复这一调用。"
const lengthContinuePrompt = "上一段回复因为达到长度上限被截断。请从断点接着写,不要重复已经写出的内容。"
// Event is a lifecycle notice for logs and UI.
type Event struct {
Type string
@@ -29,23 +38,34 @@ type Event struct {
// Detail carries tool arguments/results or reasoning deltas for
// process-visualization consumers; log writers may ignore it.
Detail string
// PromptTokens / CompletionTokens 只在 type=usage 时有意义。
// PromptTokens / CompletionTokens / TotalTokens 在 type=usage 或
// type=message_end 时有意义,供调用方累计用量。
PromptTokens int
CompletionTokens int
TotalTokens int
}
// Config drives one agent session.
type Config struct {
Model string
SystemPrompt string
Tools *tools.Registry
Stream llm.StreamFunc
Session *session.Store
Model string
SystemPrompt string
Tools *tools.Registry
Stream llm.StreamFunc
Session *session.Store
// Trace 开始一个 span,返回的函数结束它。nil 表示不采集。
// 属性里不要放提示词正文。
Trace func(name string, attrs map[string]string) func()
MaxTurns int
ToolExecution string
GetSteering func() []llm.Message
GetFollowUp func() []llm.Message
OnEvent func(Event)
// OnEvent 观察循环事件。同一次 Run 内不会并发调用
// (与 BeforeTool、AfterTool 共用一把锁串行)。
OnEvent func(Event)
// ThinkingLevel is forwarded on each provider request.
ThinkingLevel string
// Resolve, when set, overrides Model and ThinkingLevel at the start of each turn.
Resolve func() (model, thinking string)
// Log 可选。nil 时丢弃日志,不改变循环结果。
Log *logsys.Logger
// ExecutionID 只用于日志字段,不参与控制流。
@@ -53,6 +73,68 @@ type Config struct {
// Budget 可选。任一上限命中即停止并返回 budget_exceeded 错误(StopError);
// 零值字段不设限。截止时刻与外层 ctx 先到者生效。
Budget *Budget
// History 是会话转录为空时的初始转录。以 system 开头时,不再前置
// SystemPrompt 和工具清单。不回写进 Session。
History []llm.Message
// Stop 在模型不再调用工具、且本轮没有 follow-up 时询问是否续跑。
// nil 表示不询问。内核最多接受 3 次 Continue。
Stop func(last llm.Message, info StopInfo) StopDecision
// BeforeTool 返回错误时不执行工具,但仍写一条 IsError 的 tool 结果。
// 同一次 Run 内与 OnEvent、AfterTool 串行,不会并发调用。
BeforeTool func(call llm.ToolCall) error
// UnknownTool 处理注册表里没有的调用。nil 时仍返回 unknown tool。
UnknownTool func(call llm.ToolCall) tools.Result
// AfterTool 只观察工具结果,不能改写。
// 同一次 Run 内与 OnEvent、BeforeTool 串行,不会并发调用。
AfterTool func(call llm.ToolCall, res llm.Message)
// Reminders 在每次调用模型前提供提醒。正文包在 system-reminder 里,
// 并入最后一条工具结果或用户消息,只进入本次请求,不写进 Session。
Reminders func(turn int) []string
// RepeatGuard 为真时,同名且参数按键排序后相同的调用连续 3 次,注入一条提醒。
// 每个 run 最多 3 条,不中断循环。
RepeatGuard bool
// ContinueOnLength 为真时,FinishReason=length 且没有工具调用就续写。
// 续写提示不写进 Session。次数由 MaxLengthContinuations 决定。
ContinueOnLength bool
// MaxLengthContinuations 是 length 续写的次数上限。
// 0 表示默认 3 次;负数表示不限制。
MaxLengthContinuations int
// ContextWindow 是模型窗口的字符估算。0 表示用保守默认值。
// 超过窗口减去 OutputReserve 与 ContextBuffer 时,清理最近若干条之前的旧工具结果。
ContextWindow int
// OutputReserve 是留给模型输出的字符数。0 表示默认预留。
OutputReserve int
// ContextBuffer 是窗口估算的余量。0 表示默认余量。
ContextBuffer int
// KeepToolResults 是清理时保留的最近工具结果条数。0 表示 5。
KeepToolResults int
// Hooks 是进程内钩子。空总线不改变停止、提醒和拒绝次数。
Hooks *hooks.Bus
// PermissionAsk 在钩子对一次工具调用表态 ask 时向宿主请求裁决。
// nil 时保持旧行为:ask 与 deny 一样按拒绝处理。
// 返回 Allow 时工具照常执行;宿主需要「本会话始终允许」时自行记录,
// 内核不替宿主保存放行状态。
PermissionAsk func(ctx context.Context, call llm.ToolCall) AskVerdict
}
// AskVerdict 是宿主对一次 ask 决策的裁决。
type AskVerdict struct {
Allow bool
Reason string // 拒绝时写进配对 tool 结果的理由;放行时忽略
}
// StopInfo 是 Stop 钩子看到的本 run 进度。
type StopInfo struct {
Continuations int
ToolCalls int
}
// StopDecision 决定模型停下之后是否再跑一轮。
type StopDecision struct {
Continue bool
Replacement string
Reason string
Notice string
}
// Budget 是循环层的执行预算;与 exec.Budget 字段同构,由适配层换算。
@@ -69,6 +151,7 @@ type Result struct {
Messages []llm.Message
StopReason llm.StopReason
Usage llm.Usage
Notice string
Err error
Retryable bool
}
@@ -84,6 +167,9 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) {
if cfg.ToolExecution == "" {
cfg.ToolExecution = ExecSequential
}
serializeHostCallbacks(&cfg)
endRun := startSpan(cfg, "invoke_agent", map[string]string{"model": cfg.Model})
defer endRun()
var messages []llm.Message
if cfg.Session != nil {
loaded, err := cfg.Session.Load()
@@ -92,6 +178,19 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) {
}
messages = loaded
}
if err := recoverPending(ctx, cfg); err != nil {
return Result{}, err
}
if cfg.Session != nil {
loaded, err := cfg.Session.Load()
if err != nil {
return Result{}, err
}
messages = loaded
}
if len(messages) == 0 && len(cfg.History) > 0 {
messages = append([]llm.Message{}, cfg.History...)
}
if len(messages) == 0 || messages[0].Role != llm.RoleSystem {
sys := llm.Message{Role: llm.RoleSystem, Content: systemText(cfg)}
messages = append([]llm.Message{sys}, messages...)
@@ -104,10 +203,39 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) {
}
messages = append(messages, user)
emit(cfg, Event{Type: "agent_start"})
cfg.Hooks.Emit(hooks.Call{Event: hooks.SessionStart})
if out := cfg.Hooks.Emit(hooks.Call{Event: hooks.UserPromptSubmit, LastText: user.Content}); out.Decision != "" {
switch out.Decision {
case hooks.Deny, hooks.Block:
// 输入护栏拒绝:不进入循环,以理由直接收尾(原文已持久化留痕)。
why := out.Reason
if why == "" {
why = "输入被护栏拒绝"
}
return Result{Text: why, Messages: messages, StopReason: llm.StopEnd, Notice: why}, nil
case hooks.Modify:
// 改写后的输入追加为新的用户消息(原文保留,可审计)。
if out.Replacement != "" {
note := llm.Message{Role: llm.RoleUser, Content: out.Replacement}
if err := persist(cfg, note); err != nil {
return Result{}, err
}
messages = append(messages, note)
user = note
}
}
}
var last llm.Message
var usage llm.Usage
toolCalls := 0
stopContinues := 0
lengthContinues := 0
lengthNudge := false
var repeats repeatState
defer func() {
cfg.Hooks.Emit(hooks.Call{Event: hooks.SessionEnd, LastText: last.Content, ToolCalls: toolCalls})
}()
for turn := 0; turn < cfg.MaxTurns; turn++ {
if ctx.Err() != nil {
cfg.logger().Error("runtime turn aborted", ctx.Err())
@@ -117,7 +245,9 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) {
return budgetStop(cfg, last, messages, usage, reason), nil
}
emit(cfg, Event{Type: "turn_start"})
end, errEv := consume(ctx, cfg, messages)
emit(cfg, Event{Type: "message_start"})
request := prepareRequest(cfg, messages, turn, &repeats, &lengthNudge)
end, errEv := consume(ctx, cfg, request)
if errEv != nil {
cfg.logger().Error("runtime turn failed", errEv, "retryable", end.Retryable)
return Result{Text: end.Message.Content, Messages: messages, StopReason: end.Message.StopReason, Err: errEv, Retryable: end.Retryable}, nil
@@ -126,18 +256,37 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) {
usage.PromptTokens += end.Usage.PromptTokens
usage.CompletionTokens += end.Usage.CompletionTokens
usage.TotalTokens += end.Usage.TotalTokens
usage.LastPromptTokens = end.Usage.PromptTokens
usage.LastCompletionTokens = end.Usage.CompletionTokens
usage.LastTotalTokens = end.Usage.TotalTokens
if err := persist(cfg, last); err != nil {
return Result{}, err
}
messages = append(messages, last)
emit(cfg, Event{Type: "message_end", Message: last})
emit(cfg, Event{Type: "message_end", Message: last, PromptTokens: end.Usage.PromptTokens, CompletionTokens: end.Usage.CompletionTokens, TotalTokens: end.Usage.TotalTokens})
if last.StopReason == llm.StopToolCalls && len(last.ToolCalls) > 0 {
if reason := budgetExceeded(cfg.Budget, usage, toolCalls, time.Now()); reason != "" {
var err error
messages, err = appendSkippedToolResults(cfg, last.ToolCalls, messages)
if err != nil {
return Result{}, err
}
return budgetStop(cfg, last, messages, usage, reason), nil
}
if cfg.Budget != nil && cfg.Budget.MaxToolCalls > 0 && toolCalls+len(last.ToolCalls) > cfg.Budget.MaxToolCalls {
reason := fmt.Sprintf("budget_exceeded: tool calls %d+%d > max %d", toolCalls, len(last.ToolCalls), cfg.Budget.MaxToolCalls)
var err error
messages, err = appendSkippedToolResults(cfg, last.ToolCalls, messages)
if err != nil {
return Result{}, err
}
return budgetStop(cfg, last, messages, usage, reason), nil
}
toolCalls += len(last.ToolCalls)
if cfg.RepeatGuard {
repeats.observe(last.ToolCalls)
}
results := executeTools(ctx, cfg, last)
for _, res := range results {
if err := persist(cfg, res); err != nil {
@@ -153,10 +302,25 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) {
messages = append(messages, m)
}
}
cfg.Hooks.Emit(hooks.Call{Event: hooks.PostToolBatch, Turn: turn, ToolCalls: len(last.ToolCalls)})
emit(cfg, Event{Type: "turn_end", Message: last})
continue
}
emit(cfg, Event{Type: "turn_end", Message: last})
if cfg.ContinueOnLength && end.FinishReason == "length" && len(last.ToolCalls) == 0 {
lengthCap := cfg.MaxLengthContinuations
if lengthCap == 0 {
lengthCap = maxLengthContinuations
}
if lengthCap > 0 && lengthContinues >= lengthCap {
errCap := errString("length continuation exceeded")
cfg.logger().Error("runtime length continuation exceeded", errCap)
return Result{Text: last.Content, Messages: messages, StopReason: llm.StopError, Usage: usage, Err: errCap}, nil
}
lengthContinues++
lengthNudge = true
continue
}
if follow := drain(cfg.GetFollowUp); len(follow) > 0 {
for _, m := range follow {
if err := persist(cfg, m); err != nil {
@@ -166,9 +330,30 @@ func Run(ctx context.Context, cfg Config, user llm.Message) (Result, error) {
}
continue
}
emit(cfg, Event{Type: "agent_end", Message: last})
emitUsage(cfg, usage)
return Result{Text: last.Content, Messages: messages, StopReason: last.StopReason, Usage: usage}, nil
if decision, hooked := stopDecision(cfg, last, stopContinues, toolCalls); hooked {
if decision.Continue && stopContinues < maxStopContinuations {
stopContinues++
if decision.Replacement != "" && len(messages) > 0 {
messages[len(messages)-1].Content = decision.Replacement
last.Content = decision.Replacement
note := llm.Message{Role: llm.RoleUser, Content: decision.Replacement}
if err := persist(cfg, note); err != nil {
return Result{}, err
}
messages = append(messages, note)
}
if decision.Reason != "" {
reasonMsg := llm.Message{Role: llm.RoleUser, Content: decision.Reason}
if err := persist(cfg, reasonMsg); err != nil {
return Result{}, err
}
messages = append(messages, reasonMsg)
}
continue
}
return settle(cfg, last, messages, usage, decision.Notice), nil
}
return settle(cfg, last, messages, usage, ""), nil
}
errCap := errString("max turns exceeded")
cfg.logger().Error("runtime turn cap hit", errCap)
@@ -216,15 +401,29 @@ func systemText(cfg Config) string {
}
func consume(ctx context.Context, cfg Config, messages []llm.Message) (llm.Event, error) {
endSpan := startSpan(cfg, "chat", map[string]string{"model": cfg.Model})
defer endSpan()
var decls []llm.ToolDecl
if cfg.Tools != nil {
for _, d := range cfg.Tools.Decls() {
decls = append(decls, llm.ToolDecl{Name: d.Name, Description: d.Description, Parameters: d.Parameters})
}
}
model := cfg.Model
opts := llm.StreamOptions{ThinkingLevel: cfg.ThinkingLevel}
if cfg.Resolve != nil {
if m, th := cfg.Resolve(); m != "" || th != "" {
if m != "" {
model = m
}
if th != "" {
opts.ThinkingLevel = th
}
}
}
var end llm.Event
sawEnd := false
for ev := range cfg.Stream(ctx, cfg.Model, messages, decls, llm.StreamOptions{}) {
for ev := range cfg.Stream(ctx, model, messages, decls, opts) {
switch ev.Kind {
case llm.KindTextDelta:
emit(cfg, Event{Type: "text_delta", Text: ev.Text})
@@ -245,12 +444,66 @@ func consume(ctx context.Context, cfg Config, messages []llm.Message) (llm.Event
return end, nil
}
const toolBudgetSkipContent = "not executed: tool budget exceeded"
// appendSkippedToolResults 为未执行的 tool_call 各写一条错误结果,保持调用与结果成对。
// 不发工具事件:这些调用没有进入执行。
func appendSkippedToolResults(cfg Config, calls []llm.ToolCall, messages []llm.Message) ([]llm.Message, error) {
for _, call := range calls {
msg := llm.Message{
Role: llm.RoleTool,
Name: call.Name,
ToolCallID: call.ID,
Content: toolBudgetSkipContent,
IsError: true,
}
if err := persist(cfg, msg); err != nil {
return nil, err
}
messages = append(messages, msg)
}
return messages, nil
}
// serializeHostCallbacks 让同一次 Run 的宿主回调不会并发进入。
func serializeHostCallbacks(cfg *Config) {
var mu sync.Mutex
if cfg.OnEvent != nil {
inner := cfg.OnEvent
cfg.OnEvent = func(ev Event) {
mu.Lock()
defer mu.Unlock()
inner(ev)
}
}
if cfg.BeforeTool != nil {
inner := cfg.BeforeTool
cfg.BeforeTool = func(call llm.ToolCall) error {
mu.Lock()
defer mu.Unlock()
return inner(call)
}
}
if cfg.AfterTool != nil {
inner := cfg.AfterTool
cfg.AfterTool = func(call llm.ToolCall, res llm.Message) {
mu.Lock()
defer mu.Unlock()
inner(call, res)
}
}
}
func executeTools(ctx context.Context, cfg Config, assistant llm.Message) []llm.Message {
if cfg.ToolExecution == ExecParallel && len(assistant.ToolCalls) > 1 {
return executeParallel(ctx, cfg, assistant)
}
out := make([]llm.Message, 0, len(assistant.ToolCalls))
for _, call := range assistant.ToolCalls {
if err := ctx.Err(); err != nil {
out = append(out, cancelledToolMessage(call, err))
continue
}
out = append(out, runOne(ctx, cfg, call))
}
return out
@@ -270,20 +523,314 @@ func executeParallel(ctx context.Context, cfg Config, assistant llm.Message) []l
return out
}
func cancelledToolMessage(call llm.ToolCall, err error) llm.Message {
return llm.Message{
Role: llm.RoleTool,
Name: call.Name,
ToolCallID: call.ID,
Content: "not executed: " + err.Error(),
IsError: true,
}
}
func rejectIncompleteToolCall(call llm.ToolCall) string {
if strings.TrimSpace(call.Name) == "" {
return "工具调用未执行:名称不完整"
}
args := strings.TrimSpace(call.Arguments)
if !json.Valid([]byte(args)) || !strings.HasPrefix(args, "{") {
return "工具调用未执行:参数不是完整的 JSON 对象"
}
return ""
}
func stopDecision(cfg Config, last llm.Message, continuations, toolCalls int) (StopDecision, bool) {
if cfg.Hooks != nil {
out := cfg.Hooks.Emit(hooks.Call{
Event: hooks.Stop, LastText: last.Content, Continuations: continuations, ToolCalls: toolCalls,
})
if out.Decision != "" {
return StopDecision{
Continue: out.Decision == hooks.Block, Reason: out.Reason,
Replacement: out.Replacement, Notice: out.Notice,
}, true
}
}
if cfg.Stop != nil {
return cfg.Stop(last, StopInfo{Continuations: continuations, ToolCalls: toolCalls}), true
}
return StopDecision{}, false
}
func gateTool(ctx context.Context, cfg Config, call llm.ToolCall) (llm.ToolCall, llm.Message, bool) {
if cfg.Hooks == nil {
return call, llm.Message{}, false
}
out := cfg.Hooks.Emit(hooks.Call{Event: hooks.PreToolUse, Tool: call.Name, Args: call.Arguments})
switch out.Decision {
case hooks.Ask:
// ask 先问宿主;没有裁决端口(或宿主拒绝)时保持旧行为:按拒绝处理。
cfg.Hooks.Emit(hooks.Call{Event: hooks.PermissionRequest, Tool: call.Name, Args: call.Arguments})
why := out.Reason
if cfg.PermissionAsk != nil {
if verdict := cfg.PermissionAsk(ctx, call); verdict.Allow {
return call, llm.Message{}, false
} else if verdict.Reason != "" {
why = verdict.Reason
}
}
return call, rejectToolMessage(call, why), true
case hooks.Deny, hooks.Block:
return call, rejectToolMessage(call, out.Reason), true
case hooks.Modify:
if out.Args != "" {
call.Arguments = out.Args
}
}
return call, llm.Message{}, false
}
func rejectToolMessage(call llm.ToolCall, why string) llm.Message {
if why == "" {
why = "工具调用被拒绝"
}
return llm.Message{Role: llm.RoleTool, Name: call.Name, ToolCallID: call.ID, Content: why, IsError: true}
}
func noteToolHook(cfg Config, call llm.ToolCall, msg llm.Message) {
if cfg.Hooks == nil {
return
}
ev := hooks.PostToolUse
if msg.IsError {
ev = hooks.PostToolUseFailure
}
cfg.Hooks.Emit(hooks.Call{Event: ev, Tool: call.Name, Args: call.Arguments, Result: msg.Content, IsError: msg.IsError})
}
func runOne(ctx context.Context, cfg Config, call llm.ToolCall) llm.Message {
emit(cfg, Event{Type: "tool_execution_start", Tool: call.Name})
if err := ctx.Err(); err != nil {
return cancelledToolMessage(call, err)
}
if why := rejectIncompleteToolCall(call); why != "" {
msg := llm.Message{Role: llm.RoleTool, Name: call.Name, ToolCallID: call.ID, Content: why, IsError: true}
emit(cfg, Event{Type: "tool_execution_start", Tool: call.Name, Detail: call.Arguments})
emit(cfg, Event{Type: "tool_execution_end", Tool: call.Name, Text: why})
if cfg.AfterTool != nil {
cfg.AfterTool(call, msg)
}
return msg
}
emit(cfg, Event{Type: "tool_execution_start", Tool: call.Name, Detail: call.Arguments})
if gated, msg, blocked := gateTool(ctx, cfg, call); blocked {
emit(cfg, Event{Type: "tool_execution_end", Tool: call.Name, Text: msg.Content})
noteToolHook(cfg, call, msg)
if cfg.AfterTool != nil {
cfg.AfterTool(call, msg)
}
return msg
} else {
call = gated
}
if cfg.BeforeTool != nil {
if err := cfg.BeforeTool(call); err != nil {
msg := llm.Message{Role: llm.RoleTool, Name: call.Name, ToolCallID: call.ID, Content: err.Error(), IsError: true}
emit(cfg, Event{Type: "tool_execution_end", Tool: call.Name, Text: msg.Content})
noteToolHook(cfg, call, msg)
if cfg.AfterTool != nil {
cfg.AfterTool(call, msg)
}
return msg
}
}
emit(cfg, Event{Type: "tool_execution_update", Tool: call.Name, Detail: call.Arguments})
if err := commitToolIntent(cfg, call); err != nil {
msg := llm.Message{Role: llm.RoleTool, Name: call.Name, ToolCallID: call.ID, Content: err.Error(), IsError: true}
emit(cfg, Event{Type: "tool_execution_end", Tool: call.Name, Text: msg.Content})
return msg
}
endSpan := startSpan(cfg, "execute_tool", map[string]string{"tool": call.Name})
defer endSpan()
res := tools.Result{Content: "no tools configured", IsError: true}
if cfg.Tools != nil {
known := cfg.Tools != nil && cfg.Tools.Has(call.Name)
switch {
case known:
res = cfg.Tools.Execute(ctx, call.Name, json.RawMessage(call.Arguments))
case cfg.UnknownTool != nil:
res = cfg.UnknownTool(call)
case cfg.Tools != nil:
res = cfg.Tools.Execute(ctx, call.Name, json.RawMessage(call.Arguments))
}
emit(cfg, Event{Type: "tool_execution_end", Tool: call.Name, Text: res.Content})
return llm.Message{
msg := llm.Message{
Role: llm.RoleTool,
Name: call.Name,
ToolCallID: call.ID,
Content: res.Content,
IsError: res.IsError,
}
emit(cfg, Event{Type: "tool_execution_end", Tool: call.Name, Text: res.Content})
noteToolHook(cfg, call, msg)
if cfg.AfterTool != nil {
cfg.AfterTool(call, msg)
}
return msg
}
func settle(cfg Config, last llm.Message, messages []llm.Message, usage llm.Usage, notice string) Result {
emit(cfg, Event{Type: "agent_end", Message: last})
emit(cfg, Event{Type: "agent_settled", Message: last})
emitUsage(cfg, usage)
return Result{Text: last.Content, Messages: messages, StopReason: last.StopReason, Usage: usage, Notice: notice}
}
func prepareRequest(cfg Config, messages []llm.Message, turn int, repeats *repeatState, lengthNudge *bool) []llm.Message {
request := clearOldResults(cfg, persistedProjection(cfg, messages))
return reminders.Attach(request, turnReminderTexts(cfg, turn, repeats, lengthNudge))
}
// persistedProjection 在有会话存储时只采用已经落盘的消息。
// steer 与 follow-up 必须先写入存储,下一轮请求才会看见它们。
func persistedProjection(cfg Config, messages []llm.Message) []llm.Message {
if cfg.Session == nil {
return messages
}
loaded, err := cfg.Session.Load()
if err != nil {
return messages
}
return loaded
}
const unknownSideEffect = "副作用未知,未重试"
func recoverPending(ctx context.Context, cfg Config) error {
if cfg.Session == nil {
return nil
}
pending, err := cfg.Session.PendingIntents()
if err != nil {
return err
}
for _, in := range pending {
current := tools.ReplayNever
if cfg.Tools != nil {
current = cfg.Tools.Replay(in.Name)
}
msg := llm.Message{
Role: llm.RoleTool, Name: in.Name, ToolCallID: in.CallID,
Content: unknownSideEffect, IsError: true,
}
if in.Replay == tools.ReplaySafe && current == tools.ReplaySafe && cfg.Tools != nil {
res := cfg.Tools.Execute(ctx, in.Name, json.RawMessage(in.Arguments))
msg.Content = res.Content
msg.IsError = res.IsError
}
if err := persist(cfg, msg); err != nil {
return err
}
}
return nil
}
func commitToolIntent(cfg Config, call llm.ToolCall) error {
if cfg.Session == nil {
return nil
}
replay := tools.ReplayNever
if cfg.Tools != nil {
replay = cfg.Tools.Replay(call.Name)
}
return cfg.Session.AppendToolIntent(session.ToolIntent{
CallID: call.ID, Name: call.Name, Arguments: call.Arguments, Replay: replay,
})
}
func startSpan(cfg Config, name string, attrs map[string]string) func() {
if cfg.Trace == nil {
return func() {}
}
end := cfg.Trace(name, attrs)
if end == nil {
return func() {}
}
return end
}
func clearOldResults(cfg Config, messages []llm.Message) []llm.Message {
window := cfg.ContextWindow
if window <= 0 {
window = session.DefaultContextWindow
}
reserve := cfg.OutputReserve
if reserve <= 0 {
reserve = session.DefaultOutputReserve
}
buffer := cfg.ContextBuffer
if buffer <= 0 {
buffer = session.DefaultContextBuffer
}
if !session.NeedsClear(session.EstimateChars(messages), window, reserve, buffer) {
return messages
}
return session.ClearOldToolResults(messages, cfg.KeepToolResults)
}
func turnReminderTexts(cfg Config, turn int, repeats *repeatState, lengthNudge *bool) []string {
var extra []string
if cfg.Reminders != nil {
for _, text := range cfg.Reminders(turn) {
if strings.TrimSpace(text) == "" {
continue
}
extra = append(extra, text)
}
}
if lengthNudge != nil && *lengthNudge {
extra = append(extra, lengthContinuePrompt)
*lengthNudge = false
}
if cfg.RepeatGuard && repeats != nil {
due := repeats.streak/3 - repeats.reminders
room := maxRepeatReminders - repeats.reminders
if due > room {
due = room
}
for i := 0; i < due; i++ {
extra = append(extra, repeatReminderText)
repeats.reminders++
}
}
return extra
}
type repeatState struct {
sig string
streak int
reminders int
}
func (s *repeatState) observe(calls []llm.ToolCall) {
for _, call := range calls {
sig := call.Name + "\n" + canonicalArgs(call.Arguments)
if sig == s.sig {
s.streak++
continue
}
s.sig = sig
s.streak = 1
}
}
func canonicalArgs(raw string) string {
var v any
if err := json.Unmarshal([]byte(raw), &v); err != nil {
return raw
}
b, err := json.Marshal(v)
if err != nil {
return raw
}
return string(b)
}
func persist(cfg Config, msg llm.Message) error {
@@ -301,7 +848,7 @@ func drain(fn func() []llm.Message) []llm.Message {
}
func emitUsage(cfg Config, usage llm.Usage) {
emit(cfg, Event{Type: "usage", PromptTokens: usage.PromptTokens, CompletionTokens: usage.CompletionTokens})
emit(cfg, Event{Type: "usage", PromptTokens: usage.PromptTokens, CompletionTokens: usage.CompletionTokens, TotalTokens: usage.TotalTokens})
}
func emit(cfg Config, ev Event) {
@@ -319,8 +866,8 @@ func budgetExceeded(b *Budget, usage llm.Usage, toolCalls int, now time.Time) st
if !b.Deadline.IsZero() && now.After(b.Deadline) {
return fmt.Sprintf("budget_exceeded: deadline %s passed", b.Deadline.Format(time.RFC3339))
}
if b.MaxPromptTokens > 0 && usage.PromptTokens > b.MaxPromptTokens {
return fmt.Sprintf("budget_exceeded: prompt tokens %d > max %d", usage.PromptTokens, b.MaxPromptTokens)
if b.MaxPromptTokens > 0 && usage.LastPromptTokens > b.MaxPromptTokens {
return fmt.Sprintf("budget_exceeded: prompt tokens %d > max %d", usage.LastPromptTokens, b.MaxPromptTokens)
}
if b.MaxCompletionTokens > 0 && usage.CompletionTokens > b.MaxCompletionTokens {
return fmt.Sprintf("budget_exceeded: completion tokens %d > max %d", usage.CompletionTokens, b.MaxCompletionTokens)
+150 -5
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"strings"
"sync"
"testing"
"time"
@@ -66,15 +67,108 @@ func TestLoopBudgetToolCallCap(t *testing.T) {
if !sawBudgetEvent {
t.Fatal("expected budget_exceeded event")
}
// 第二批工具调用不该被执行(预算在执行前拦截):transcript 只含一次 tool 结果。
// 第一批执行 1 次;第二批超限,两条合成错误结果,不执行。
toolResults := 0
skipped := 0
for _, m := range res.Messages {
if m.Role == llm.RoleTool {
toolResults++
if m.Role != llm.RoleTool {
continue
}
toolResults++
if m.IsError && m.Content == toolBudgetSkipContent {
skipped++
}
}
if toolResults != 1 {
t.Fatalf("expected exactly 1 tool result, got %d", toolResults)
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)
}
}
@@ -100,6 +194,57 @@ func TestLoopBudgetPromptTokens(t *testing.T) {
}
}
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",
+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)
}
}
+372
View File
@@ -0,0 +1,372 @@
package runtime
import (
"context"
"encoding/json"
"errors"
"path/filepath"
"strings"
"sync"
"testing"
"agent-native/runtime/llm"
"agent-native/runtime/session"
"agent-native/runtime/tools"
)
func TestLoopStopContinuesUntilCap(t *testing.T) {
var calls int
res, err := Run(context.Background(), Config{
Model: "fake",
MaxTurns: 8,
Stream: script(
llm.Message{Role: llm.RoleAssistant, Content: "forged-claim", StopReason: llm.StopEnd},
llm.Message{Role: llm.RoleAssistant, Content: "forged-claim", StopReason: llm.StopEnd},
llm.Message{Role: llm.RoleAssistant, Content: "forged-claim", StopReason: llm.StopEnd},
llm.Message{Role: llm.RoleAssistant, Content: "forged-claim", StopReason: llm.StopEnd},
),
Stop: func(last llm.Message, info StopInfo) StopDecision {
calls++
if info.Continuations != calls-1 {
t.Errorf("continuations = %d, want %d", info.Continuations, calls-1)
}
return StopDecision{Continue: true, Replacement: "已作废", Reason: "再试", Notice: "到顶了"}
},
}, llm.Message{Role: llm.RoleUser, Content: "go"})
if err != nil {
t.Fatal(err)
}
if calls != 4 {
t.Fatalf("stop calls = %d, want 4", calls)
}
if res.Err != nil || res.Notice != "到顶了" {
t.Fatalf("notice %q err %v", res.Notice, res.Err)
}
replaced, forged := 0, 0
for _, m := range res.Messages {
if m.Role != llm.RoleAssistant {
continue
}
if m.Content == "已作废" {
replaced++
}
if m.Content == "forged-claim" {
forged++
}
}
if replaced != 3 || forged != 1 {
t.Fatalf("replaced=%d forged=%d transcript=%s", replaced, forged, transcriptText(res.Messages))
}
if strings.Count(transcriptText(res.Messages), "再试") != 3 {
t.Fatalf("reasons = %s", transcriptText(res.Messages))
}
}
func TestLoopBeforeToolRejectPairsResult(t *testing.T) {
hits := 0
reg := tools.NewRegistry(t.TempDir())
reg.Register(hitTool{hits: &hits})
var seen llm.Message
res, err := Run(context.Background(), Config{
Model: "fake",
Tools: reg,
Stream: script(
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
ID: "c1", Name: "echo", Arguments: `{}`,
}}},
llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd},
),
BeforeTool: func(call llm.ToolCall) error {
return errors.New("不允许")
},
AfterTool: func(call llm.ToolCall, msg llm.Message) {
seen = msg
},
}, llm.Message{Role: llm.RoleUser, Content: "go"})
if err != nil {
t.Fatal(err)
}
if hits != 0 {
t.Fatalf("tool executed %d times", hits)
}
if !seen.IsError || seen.Content != "不允许" || seen.ToolCallID != "c1" {
t.Fatalf("after = %#v", seen)
}
paired := false
for _, m := range res.Messages {
if m.Role == llm.RoleTool && m.ToolCallID == "c1" && m.IsError && m.Content == "不允许" {
paired = true
}
}
if !paired {
t.Fatalf("missing paired tool result: %s", transcriptText(res.Messages))
}
}
func TestLoopRemindersStayOutOfSession(t *testing.T) {
path := filepath.Join(t.TempDir(), "transcript.jsonl")
st, err := session.Open(path)
if err != nil {
t.Fatal(err)
}
var saw bool
_, err = Run(context.Background(), Config{
Model: "fake",
Session: st,
Stream: func(ctx context.Context, model string, messages []llm.Message, decls []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
for _, m := range messages {
if strings.Contains(m.Content, "<system-reminder>") && strings.Contains(m.Content, "先核对交付物") {
saw = true
}
}
ch := make(chan llm.Event, 1)
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{Role: llm.RoleAssistant, Content: "ok", StopReason: llm.StopEnd}}
close(ch)
return ch
},
Reminders: func(turn int) []string {
if turn != 0 {
t.Errorf("turn %d", turn)
}
return []string{"先核对交付物"}
},
}, llm.Message{Role: llm.RoleUser, Content: "ping"})
if err != nil {
t.Fatal(err)
}
if !saw {
t.Fatal("reminder missing from the provider request")
}
loaded, err := st.Load()
if err != nil {
t.Fatal(err)
}
if strings.Contains(transcriptText(loaded), "先核对交付物") {
t.Fatalf("reminder persisted: %#v", loaded)
}
}
func TestLoopRepeatGuardFiresAndCaps(t *testing.T) {
var mu sync.Mutex
var requests []string
turns := make([]llm.Message, 0, 13)
for i := 0; i < 12; i++ {
args := `{"b":1,"a":2}`
if i%2 == 1 {
args = `{"a":2,"b":1}`
}
turns = append(turns, llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
ID: "c", Name: "echo", Arguments: args,
}}})
}
turns = append(turns, llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd})
var i int
stream := func(ctx context.Context, model string, messages []llm.Message, decls []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
mu.Lock()
requests = append(requests, transcriptText(messages))
n := i
i++
mu.Unlock()
ch := make(chan llm.Event, 1)
msg := turns[len(turns)-1]
if n < len(turns) {
msg = turns[n]
}
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: msg}
close(ch)
return ch
}
res, err := Run(context.Background(), Config{
Model: "fake",
MaxTurns: 20,
RepeatGuard: true,
Stream: stream,
}, llm.Message{Role: llm.RoleUser, Content: "go"})
if err != nil {
t.Fatal(err)
}
if res.Text != "done" {
t.Fatalf("text %q", res.Text)
}
reminders := 0
for _, req := range requests {
reminders += strings.Count(req, repeatReminderText)
}
if reminders != 3 {
t.Fatalf("reminders = %d, want 3; requests=%d", reminders, len(requests))
}
if strings.Contains(transcriptText(res.Messages), repeatReminderText) {
t.Fatal("repeat reminder was written into the transcript")
}
if !strings.Contains(requests[3], repeatReminderText) {
t.Fatal("first reminder should arrive on the request after the third identical call")
}
}
func TestLoopContinueOnLengthCaps(t *testing.T) {
var requests []string
var n int
stream := func(ctx context.Context, model string, messages []llm.Message, decls []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
requests = append(requests, transcriptText(messages))
n++
ch := make(chan llm.Event, 1)
ch <- llm.Event{
Kind: llm.KindMessageEnd,
FinishReason: "length",
Message: llm.Message{Role: llm.RoleAssistant, Content: "part", StopReason: llm.StopEnd},
}
close(ch)
return ch
}
res, err := Run(context.Background(), Config{
Model: "fake",
MaxTurns: 8,
ContinueOnLength: true,
Stream: stream,
}, llm.Message{Role: llm.RoleUser, Content: "go"})
if err != nil {
t.Fatal(err)
}
if res.Err == nil || !strings.Contains(res.Err.Error(), "length continuation exceeded") {
t.Fatalf("err = %v", res.Err)
}
if n != 4 {
t.Fatalf("model calls = %d, want 4", n)
}
if len(res.Messages) == 0 || !strings.Contains(transcriptText(res.Messages), "part") {
t.Fatal("truncated transcript was dropped")
}
if strings.Contains(transcriptText(res.Messages), lengthContinuePrompt) {
t.Fatal("continuation prompt was persisted")
}
prompts := 0
for _, req := range requests {
prompts += strings.Count(req, lengthContinuePrompt)
}
if prompts != 3 {
t.Fatalf("continuation prompts = %d, want 3", prompts)
}
}
func 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)
}
}
}
+27
View File
@@ -0,0 +1,27 @@
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
}
func inputQuestion(res callToolResult) (string, bool) {
for _, blk := range res.Content {
if blk.Type == "input_required" {
return blk.Text, true
}
}
return "", false
}
+9
View File
@@ -8,6 +8,7 @@ import (
"bufio"
"encoding/json"
"os"
"time"
)
// RunFakeServer 在 stdin/stdout 上运行最小 MCP server(echo / boom / 未知方法),
@@ -67,6 +68,9 @@ func fakeServerHandle(req rpcRequest) rpcResponse {
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"}}})
default:
return fail(-32602, "unknown tool "+p.Name)
}
@@ -87,6 +91,11 @@ func fakeServerTools() []toolInfo {
Description: "Always fails",
InputSchema: json.RawMessage(`{"type":"object","properties":{}}`),
},
{
Name: "slow",
Description: "Responds after a delay",
InputSchema: json.RawMessage(`{"type":"object","properties":{}}`),
},
}
}
+25 -1
View File
@@ -92,7 +92,31 @@ func (c *httpClient) CallTool(ctx context.Context, name string, args json.RawMes
return res, err
}
func (c *httpClient) Close() error { return nil }
func (c *httpClient) Close() error {
c.mu.Lock()
sid := c.sessionID
url := c.cfg.URL
client := c.client
c.mu.Unlock()
if sid == "" || url == "" || client == nil {
return nil
}
req, err := http.NewRequest(http.MethodDelete, url, nil)
if err != nil {
return err
}
req.Header.Set("Mcp-Session-Id", sid)
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 2048))
if resp.StatusCode >= 400 {
return fmt.Errorf("mcp session terminate: http %d", resp.StatusCode)
}
return nil
}
func (c *httpClient) roundTrip(ctx context.Context, method string, params any, out any) error {
c.mu.Lock()
+162 -47
View File
@@ -19,6 +19,8 @@ const (
maxToolNameBytes = 128
callTimeoutDefault = 30 * time.Second
registerTimeoutFixed = 30 * time.Second
// 工具数超过这个值且没有显式暴露级别时,整台服务器改为延迟暴露。
deferredToolThreshold = 8
)
// Manager 维护一组 MCP server 连接,并把它们的工具注册进 tools.Registry。
@@ -72,49 +74,138 @@ func (m *Manager) client(ctx context.Context, cfg ServerConfig) (Client, error)
return c, nil
}
// RegisterInto 连接全部 server、枚举工具并以 mcp__<server>__<tool> 命名注册。
// 任一 server 不可达即返回错误(fail-closed,不静默跳过)。
func (m *Manager) RegisterInto(ctx context.Context, reg *tools.Registry) error {
for _, cfg := range m.configs {
c, err := m.client(ctx, cfg)
if err != nil {
// ApplyOverrides 用项目配置覆盖启用状态和暴露级别。空字符串不改。
func (m *Manager) ApplyOverrides(raw string) error {
if m == nil || strings.TrimSpace(raw) == "" {
return nil
}
var over map[string]struct {
Enabled *bool `json:"enabled"`
Exposure string `json:"exposure"`
}
if err := json.Unmarshal([]byte(raw), &over); err != nil {
return err
}
for i, cfg := range m.configs {
o, ok := over[cfg.Name]
if !ok {
continue
}
if o.Enabled != nil {
m.configs[i].Enabled = o.Enabled
}
if o.Exposure != "" {
m.configs[i].Exposure = o.Exposure
}
if err := m.configs[i].Validate(); err != nil {
return err
}
listCtx, cancel := context.WithTimeout(ctx, registerTimeoutFixed)
list, err := c.ListTools(listCtx)
cancel()
if err != nil {
return fmt.Errorf("mcp server %q tools/list failed: %w", cfg.Name, err)
}
if len(list) > maxToolsPerServer {
return fmt.Errorf("mcp server %q exposes %d tools (max %d)", cfg.Name, len(list), maxToolsPerServer)
}
for _, ti := range list {
name := ToolName(cfg.Name, ti.Name)
if len(name) > maxToolNameBytes {
return fmt.Errorf("mcp tool name too long: %s", name)
}
desc := ti.Description
if len(desc) > maxDescriptionBytes {
desc = desc[:maxDescriptionBytes] + "…[truncated]"
}
schema := ti.InputSchema
if len(schema) == 0 {
schema = json.RawMessage(`{"type":"object","properties":{}}`)
}
reg.Register(&bridgeTool{
client: c,
serverCfg: cfg,
toolName: ti.Name,
declName: name,
description: desc,
schema: schema,
})
}
}
return nil
}
// Ready 报告已启用的服务器是否都已经连上。已连上时注册只是枚举工具,不再握手。
func (m *Manager) Ready() bool {
if m == nil {
return true
}
m.mu.Lock()
defer m.mu.Unlock()
for _, cfg := range m.configs {
if !cfg.IsEnabled() {
continue
}
if _, ok := m.clients[cfg.Name]; !ok {
return false
}
}
return true
}
func (m *Manager) StartBackground(ctx context.Context, reg *tools.Registry) {
if m == nil || reg == nil {
return
}
for _, cfg := range m.configs {
cfg := cfg
if !cfg.IsEnabled() {
continue
}
go func() { _ = m.registerServer(ctx, reg, cfg) }()
}
}
// RegisterInto 连接全部已启用的 server 并注册工具。未启用的跳过。
func (m *Manager) RegisterInto(ctx context.Context, reg *tools.Registry) error {
for _, cfg := range m.configs {
if !cfg.IsEnabled() {
continue
}
if err := m.registerServer(ctx, reg, cfg); err != nil {
return err
}
}
return nil
}
func (m *Manager) registerServer(ctx context.Context, reg *tools.Registry, cfg ServerConfig) error {
c, err := m.client(ctx, cfg)
if err != nil {
return err
}
listCtx, cancel := context.WithTimeout(ctx, registerTimeoutFixed)
list, err := c.ListTools(listCtx)
cancel()
if err != nil {
return fmt.Errorf("mcp server %q tools/list failed: %w", cfg.Name, err)
}
if len(list) > maxToolsPerServer {
return fmt.Errorf("mcp server %q exposes %d tools (max %d)", cfg.Name, len(list), maxToolsPerServer)
}
exposure := chooseExposure(cfg.Exposure, len(list))
for _, ti := range list {
name := ToolName(cfg.Name, ti.Name)
if len(name) > maxToolNameBytes {
return fmt.Errorf("mcp tool name too long: %s", name)
}
desc := ti.Description
if len(desc) > maxDescriptionBytes {
desc = tools.CutUTF8(desc, maxDescriptionBytes) + "…[truncated]"
}
schema := ti.InputSchema
if len(schema) == 0 {
schema = json.RawMessage(`{"type":"object","properties":{}}`)
}
reg.Register(&bridgeTool{
client: c,
serverCfg: cfg,
toolName: ti.Name,
declName: name,
description: desc,
schema: schema,
exposure: exposure,
redial: func(ctx context.Context) (Client, error) {
m.mu.Lock()
delete(m.clients, cfg.Name)
m.mu.Unlock()
return m.client(ctx, cfg)
},
})
}
return nil
}
func chooseExposure(explicit string, n int) string {
switch explicit {
case tools.ExposureDirect, tools.ExposureDeferred:
return explicit
default:
if n > deferredToolThreshold {
return tools.ExposureDeferred
}
return tools.ExposureDirect
}
}
// ToolName 生成桥接工具名(命名空间隔离)。
func ToolName(server, tool string) string {
return "mcp__" + server + "__" + tool
@@ -130,7 +221,7 @@ func (m *Manager) Close() {
m.clients = map[string]Client{}
}
// bridgeTool 把一个 MCP 工具适配为 tools.Tool;调用失败重连一次后重试。
// bridgeTool 把一个 MCP 工具适配为 tools.Tool。CallTool 失败时重连一次再试。
type bridgeTool struct {
client Client
serverCfg ServerConfig
@@ -138,21 +229,39 @@ type bridgeTool struct {
declName string
description string
schema json.RawMessage
exposure string
redial func(ctx context.Context) (Client, error)
}
func (t *bridgeTool) Name() string { return t.declName }
func (t *bridgeTool) Description() string { return t.description }
func (t *bridgeTool) Parameters() json.RawMessage {
return t.schema
}
func (t *bridgeTool) Name() string { return t.declName }
func (t *bridgeTool) Description() string { return t.description }
func (t *bridgeTool) Parameters() json.RawMessage { return t.schema }
func (t *bridgeTool) Exposure() string { return t.exposure }
func (t *bridgeTool) Execute(ctx context.Context, args json.RawMessage) (tools.Result, error) {
callCtx, cancel := context.WithTimeout(ctx, requestTimeout(t.serverCfg))
defer cancel()
res, err := t.client.CallTool(callCtx, t.toolName, args)
res, err := t.call(ctx, args)
if err != nil && t.redial != nil {
redial := t.redial
t.redial = nil
if c, rerr := redial(ctx); rerr == nil {
t.client = c
res, err = t.call(ctx, args)
}
}
if err != nil {
return tools.Result{Content: fmt.Sprintf("mcp tool %s failed: %v", t.declName, err), IsError: true}, nil
}
if question, ok := inputQuestion(res); ok {
ask := askFrom(ctx)
if ask == nil {
return tools.Result{Content: "需要用户输入:" + question, IsError: true}, nil
}
answer, aerr := ask(ctx, question)
if aerr != nil {
return tools.Result{Content: aerr.Error(), IsError: true}, nil
}
return tools.Result{Content: answer}, nil
}
var b strings.Builder
for _, blk := range res.Content {
if blk.Type == "text" && blk.Text != "" {
@@ -162,10 +271,16 @@ func (t *bridgeTool) Execute(ctx context.Context, args json.RawMessage) (tools.R
}
out := b.String()
if len(out) > maxTextResultBytes {
out = out[:maxTextResultBytes] + "\n[truncated]"
out = tools.CutUTF8(out, maxTextResultBytes) + "\n[truncated]"
}
if res.IsError {
return tools.Result{Content: out, IsError: true}, nil
}
return tools.Result{Content: out}, nil
}
func (t *bridgeTool) call(ctx context.Context, args json.RawMessage) (callToolResult, error) {
callCtx, cancel := context.WithTimeout(ctx, requestTimeout(t.serverCfg))
defer cancel()
return t.client.CallTool(callCtx, t.toolName, args)
}
+142 -3
View File
@@ -45,7 +45,7 @@ func TestStdioClientHandshakeListAndCall(t *testing.T) {
defer c.Close()
list, err := c.ListTools(ctx)
if err != nil || len(list) != 2 || list[0].Name != "echo" {
if err != nil || len(list) != 3 || list[0].Name != "echo" {
t.Fatalf("list failed: %v %+v", err, list)
}
res, err := c.CallTool(ctx, "echo", json.RawMessage(`{"text":"hi"}`))
@@ -75,6 +75,35 @@ func TestStdioClientServerRejectsBadSchema(t *testing.T) {
}
}
func TestStdioSlowCancelThenEcho(t *testing.T) {
ctx := context.Background()
c, err := DialStdio(ctx, fakeStdioConfig(t))
if err != nil {
t.Fatal(err)
}
defer c.Close()
slowCtx, cancel := context.WithTimeout(ctx, 80*time.Millisecond)
defer cancel()
_, err = c.CallTool(slowCtx, "slow", nil)
if err == nil {
t.Fatal("slow call should time out")
}
echoCtx, echoCancel := context.WithTimeout(ctx, 3*time.Second)
defer echoCancel()
res, err := c.CallTool(echoCtx, "echo", json.RawMessage(`{"text":"after-slow"}`))
if err != nil {
t.Fatal(err)
}
if len(res.Content) != 1 || res.Content[0].Text != "echo: after-slow" {
t.Fatalf("echo must receive its own result, got %+v", res)
}
if c.Readers() != 1 {
t.Fatalf("cancel must leave a single reader, got %d", c.Readers())
}
}
func fakeHTTPHandler(w http.ResponseWriter, r *http.Request) {
var req rpcRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -104,7 +133,7 @@ func TestHTTPClientJSONMode(t *testing.T) {
t.Fatalf("session id must be captured, got %q", c.sessionID)
}
list, err := c.ListTools(ctx)
if err != nil || len(list) != 2 {
if err != nil || len(list) != 3 {
t.Fatalf("list failed: %v %+v", err, list)
}
res, err := c.CallTool(ctx, "echo", json.RawMessage(`{"text":"net"}`))
@@ -130,7 +159,7 @@ func TestHTTPClientSSEMode(t *testing.T) {
}
defer c.Close()
list, err := c.ListTools(ctx)
if err != nil || len(list) != 2 {
if err != nil || len(list) != 3 {
t.Fatalf("sse list failed: %v %+v", err, list)
}
}
@@ -239,3 +268,113 @@ func TestManagerRegisterTimeoutBounded(t *testing.T) {
t.Fatalf("timeout must bound the dial, took %s", elapsed)
}
}
func TestBackgroundConnectDoesNotBlockTheCaller(t *testing.T) {
started := make(chan struct{})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case <-started:
default:
close(started)
}
time.Sleep(400 * time.Millisecond)
fakeHTTPHandler(w, r)
}))
defer srv.Close()
mgr, err := NewManager([]ServerConfig{{Name: "late", URL: srv.URL}})
if err != nil {
t.Fatal(err)
}
defer mgr.Close()
reg := tools.NewRegistry(t.TempDir())
t0 := time.Now()
mgr.StartBackground(context.Background(), reg)
if time.Since(t0) > 150*time.Millisecond {
t.Fatalf("background connect blocked the caller for %s", time.Since(t0))
}
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if reg.Has("mcp__late__echo") {
return
}
time.Sleep(20 * time.Millisecond)
}
t.Fatal("tools did not appear after the server became ready")
}
func TestDeferredMCPToolsUseSearchAndCall(t *testing.T) {
mgr, err := NewManager([]ServerConfig{{Name: "fake", Command: fakeStdioConfig(t).Command, Args: fakeStdioConfig(t).Args, Env: fakeStdioConfig(t).Env, Exposure: "deferred"}})
if err != nil {
t.Fatal(err)
}
defer mgr.Close()
reg := tools.NewRegistry(t.TempDir())
if err := mgr.RegisterInto(context.Background(), reg); err != nil {
t.Fatal(err)
}
for _, d := range reg.Decls() {
if strings.HasPrefix(d.Name, "mcp__fake__") {
t.Fatalf("deferred mcp tool listed: %s", d.Name)
}
}
if !strings.Contains(reg.Search("echo"), "mcp__fake__echo") {
t.Fatalf("search %q", reg.Search("echo"))
}
res := reg.Execute(context.Background(), "call_tool", json.RawMessage(`{"name":"mcp__fake__echo","arguments":{"text":"via"}}`))
if res.IsError || !strings.Contains(res.Content, "echo: via") {
t.Fatalf("call %+v", res)
}
}
func TestDisabledServerIsSkipped(t *testing.T) {
off := false
mgr, err := NewManager([]ServerConfig{{Name: "fake", Command: "x", Enabled: &off}})
if err != nil {
t.Fatal(err)
}
reg := tools.NewRegistry(t.TempDir())
if err := mgr.RegisterInto(context.Background(), reg); err != nil {
t.Fatal(err)
}
if reg.Has("mcp__fake__echo") {
t.Fatal("disabled server registered a tool")
}
if err := mgr.ApplyOverrides(`{"fake":{"enabled":true,"exposure":"direct"}}`); err != nil {
t.Fatal(err)
}
if !mgr.Configs()[0].IsEnabled() || mgr.Configs()[0].Exposure != "direct" {
t.Fatalf("override %+v", mgr.Configs()[0])
}
}
func TestManyToolsDefaultToDeferred(t *testing.T) {
if chooseExposure("", 9) != tools.ExposureDeferred {
t.Fatal("many tools should default to deferred")
}
if chooseExposure("direct", 9) != tools.ExposureDirect {
t.Fatal("explicit direct must win")
}
}
type askClient struct{}
func (askClient) Initialize(context.Context) (initializeResult, error) {
return initializeResult{}, nil
}
func (askClient) ListTools(context.Context) ([]toolInfo, error) { return nil, nil }
func (askClient) Close() error { return nil }
func (askClient) CallTool(context.Context, string, json.RawMessage) (callToolResult, error) {
return callToolResult{Content: []contentBlock{{Type: "input_required", Text: "选一个颜色"}}}, nil
}
func TestInputRequiredUsesAsk(t *testing.T) {
tool := &bridgeTool{
client: askClient{}, declName: "mcp__x__ask", toolName: "ask",
schema: json.RawMessage(`{"type":"object","properties":{}}`),
}
ctx := WithAsk(context.Background(), func(context.Context, string) (string, error) { return "蓝色", nil })
res, err := tool.Execute(ctx, json.RawMessage(`{}`))
if err != nil || res.IsError || res.Content != "蓝色" {
t.Fatalf("ask result %+v %v", res, err)
}
}
+14
View File
@@ -25,6 +25,10 @@ type ServerConfig struct {
Headers map[string]string `json:"headers,omitempty"`
// Timeout 单次请求上限(默认 30s)。
Timeout time.Duration `json:"timeout,omitempty"`
// Enabled 为 false 时跳过这台服务器。nil 表示启用。
Enabled *bool `json:"enabled,omitempty"`
// Exposure 为 direct 或 deferred。空则工具多时延迟暴露。
Exposure string `json:"exposure,omitempty"`
}
// Validate 校验配置完整性(fail-closed:命名不规范或双传输/无传输都拒绝)。
@@ -37,9 +41,19 @@ func (c ServerConfig) Validate() error {
if hasCmd == hasURL {
return fmt.Errorf("mcp server %q: exactly one of command or url is required", c.Name)
}
switch c.Exposure {
case "", "direct", "deferred":
default:
return fmt.Errorf("mcp server %q: exposure must be direct or deferred", c.Name)
}
return nil
}
// IsEnabled 报告这台服务器是否要连接。
func (c ServerConfig) IsEnabled() bool {
return c.Enabled == nil || *c.Enabled
}
func validName(s string) bool {
if s == "" {
return false
+66
View File
@@ -0,0 +1,66 @@
package mcp
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
type flakyClient struct {
fail bool
calls int
}
func (f *flakyClient) Initialize(context.Context) (initializeResult, error) {
return initializeResult{}, nil
}
func (f *flakyClient) ListTools(context.Context) ([]toolInfo, error) { return nil, nil }
func (f *flakyClient) Close() error { return nil }
func (f *flakyClient) CallTool(context.Context, string, json.RawMessage) (callToolResult, error) {
f.calls++
if f.fail {
return callToolResult{}, fmt.Errorf("down")
}
return callToolResult{Content: []contentBlock{{Type: "text", Text: "back"}}}, nil
}
func TestR2MCPRetriesOnceAfterReconnect(t *testing.T) {
first := &flakyClient{fail: true}
second := &flakyClient{}
tool := &bridgeTool{
client: first,
toolName: "echo",
declName: "mcp__s__echo",
redial: func(context.Context) (Client, error) {
return second, nil
},
}
res, err := tool.Execute(context.Background(), []byte(`{}`))
if err != nil || res.IsError {
t.Fatalf("retry: %+v %v", res, err)
}
if first.calls != 1 || second.calls != 1 || !strings.Contains(res.Content, "back") {
t.Fatalf("calls first=%d second=%d content=%q", first.calls, second.calls, res.Content)
}
}
func TestR2HTTPCloseTerminatesSession(t *testing.T) {
var method, sid string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
method = r.Method
sid = r.Header.Get("Mcp-Session-Id")
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
c := &httpClient{cfg: ServerConfig{URL: srv.URL}, client: &http.Client{}, sessionID: "sess-1"}
if err := c.Close(); err != nil {
t.Fatal(err)
}
if method != http.MethodDelete || sid != "sess-1" {
t.Fatalf("terminate %s session %q", method, sid)
}
}
+99 -46
View File
@@ -12,14 +12,24 @@ import (
)
// stdioClient 通过「子进程 stdin/stdout 按行分隔的 JSON-RPC」与 server 通信。
// 同一时刻只允许一个在途请求(串行锁),匹配简单且足以支撑智能体工具调用。
// 一个长驻读循环按 id 把响应分发给各请求;写 stdin 仍串行。
// 取消会摘掉等待项,迟到的回复直接丢弃。读循环退出时,在等的请求全部失败,客户端标记为已死。
type stdioClient struct {
cmd *exec.Cmd
stdin io.WriteCloser
stdout *bufio.Reader
mu sync.Mutex
nextID int64
closed bool
cmd *exec.Cmd
stdin io.WriteCloser
stdout *bufio.Reader
writeMu sync.Mutex
mu sync.Mutex
pending map[int64]chan stdioResult
nextID int64
readers atomic.Int32
closed bool
readerOnce sync.Once
}
type stdioResult struct {
resp rpcResponse
err error
}
// DialStdio 启动 server 子进程并完成 initialize 握手。
@@ -43,7 +53,13 @@ func DialStdio(ctx context.Context, cfg ServerConfig) (*stdioClient, error) {
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("mcp server %q: start %s: %w", cfg.Name, cfg.Command, err)
}
c := &stdioClient{cmd: cmd, stdin: stdin, stdout: bufio.NewReaderSize(stdout, 1<<20)}
c := &stdioClient{
cmd: cmd,
stdin: stdin,
stdout: bufio.NewReaderSize(stdout, 1<<20),
pending: map[int64]chan stdioResult{},
}
c.startReader()
if err := c.handshake(ctx); err != nil {
_ = c.Close()
return nil, err
@@ -104,11 +120,12 @@ func (c *stdioClient) CallTool(ctx context.Context, name string, args json.RawMe
func (c *stdioClient) Close() error {
c.mu.Lock()
defer c.mu.Unlock()
if c.closed {
c.mu.Unlock()
return nil
}
c.closed = true
c.mu.Unlock()
_ = c.stdin.Close()
if c.cmd.Process != nil {
_ = c.cmd.Process.Kill()
@@ -117,29 +134,43 @@ func (c *stdioClient) Close() error {
}
func (c *stdioClient) roundTrip(ctx context.Context, method string, params any, out any) error {
c.startReader()
id := atomic.AddInt64(&c.nextID, 1)
ch := make(chan stdioResult, 1)
c.mu.Lock()
defer c.mu.Unlock()
if c.closed {
c.mu.Unlock()
return fmt.Errorf("mcp client closed")
}
id := atomic.AddInt64(&c.nextID, 1)
c.pending[id] = ch
c.mu.Unlock()
req := rpcRequest{JSONRPC: "2.0", ID: id, Method: method, Params: params}
body, err := json.Marshal(req)
if err != nil {
c.dropPending(id)
return err
}
body = append(body, '\n')
if _, err := c.stdin.Write(body); err != nil {
c.writeMu.Lock()
_, err = c.stdin.Write(body)
c.writeMu.Unlock()
if err != nil {
c.dropPending(id)
return fmt.Errorf("mcp write %s: %w", method, err)
}
line, err := c.readResponseLine(ctx)
if err != nil {
return fmt.Errorf("mcp read %s: %w", method, err)
var got stdioResult
select {
case got = <-ch:
case <-ctx.Done():
c.dropPending(id)
return fmt.Errorf("mcp read %s: %w", method, ctx.Err())
}
var resp rpcResponse
if err := json.Unmarshal(line, &resp); err != nil {
return fmt.Errorf("mcp %s: bad response frame: %w", method, err)
if got.err != nil {
return fmt.Errorf("mcp read %s: %w", method, got.err)
}
resp := got.resp
if resp.ID == nil || *resp.ID != id {
return fmt.Errorf("mcp %s: response id mismatch", method)
}
@@ -152,40 +183,60 @@ func (c *stdioClient) roundTrip(ctx context.Context, method string, params any,
return nil
}
// readResponseLine 逐行读取,跳过通知帧(无 id),带 ctx 取消感知。
func (c *stdioClient) readResponseLine(ctx context.Context) ([]byte, error) {
type result struct {
line []byte
err error
}
done := make(chan result, 1)
go func() {
for {
line, err := c.stdout.ReadBytes('\n')
if err != nil {
done <- result{nil, err}
return
}
var probe rpcResponse
if json.Unmarshal(line, &probe) == nil && probe.ID != nil {
done <- result{line, nil}
return
}
// 通知或日志行:忽略。
func (c *stdioClient) dropPending(id int64) {
c.mu.Lock()
delete(c.pending, id)
c.mu.Unlock()
}
func (c *stdioClient) startReader() {
c.readerOnce.Do(func() {
go c.readLoop()
})
}
func (c *stdioClient) readLoop() {
c.readers.Add(1)
defer c.readers.Add(-1)
var fatal error
for {
line, err := c.stdout.ReadBytes('\n')
if err != nil {
fatal = err
break
}
}()
select {
case r := <-done:
return r.line, r.err
case <-ctx.Done():
return nil, ctx.Err()
var resp rpcResponse
if json.Unmarshal(line, &resp) != nil || resp.ID == nil {
continue
}
c.mu.Lock()
ch := c.pending[*resp.ID]
if ch != nil {
delete(c.pending, *resp.ID)
}
c.mu.Unlock()
if ch == nil {
continue
}
ch <- stdioResult{resp: resp}
}
c.mu.Lock()
c.closed = true
pending := c.pending
c.pending = map[int64]chan stdioResult{}
c.mu.Unlock()
for _, ch := range pending {
ch <- stdioResult{err: fatal}
}
}
func (c *stdioClient) Readers() int { return int(c.readers.Load()) }
func (c *stdioClient) notify(method string) error {
c.mu.Lock()
defer c.mu.Unlock()
if c.closed {
closed := c.closed
c.mu.Unlock()
if closed {
return fmt.Errorf("mcp client closed")
}
body, err := json.Marshal(rpcNotification{JSONRPC: "2.0", Method: method})
@@ -193,6 +244,8 @@ func (c *stdioClient) notify(method string) error {
return err
}
body = append(body, '\n')
c.writeMu.Lock()
defer c.writeMu.Unlock()
_, err = c.stdin.Write(body)
return err
}
+275
View File
@@ -0,0 +1,275 @@
// Package policy 决定一次工具调用在当前权限模式下是拒绝、询问还是放行。
// 默认模式不额外表态,调用方继续用原有画像策略。
package policy
import (
"strings"
"sync"
)
type Mode string
const (
ModeDefault Mode = "default"
ModePlan Mode = "plan"
ModeAutoEdit Mode = "auto_edit"
)
type Effect string
const (
EffectNone Effect = ""
EffectDeny Effect = "deny"
EffectAsk Effect = "ask"
EffectAllow Effect = "allow"
)
type Traits struct {
ReadOnly bool
Edits bool
Risk string
}
type Rule struct {
Effect Effect
Tool string
ArgGlob string
}
type Request struct {
Mode Mode
Name string
Args string
Traits Traits
SessionAllow bool
}
func Normalize(mode string) Mode {
switch Mode(mode) {
case ModePlan, ModeAutoEdit, ModeDefault:
return Mode(mode)
default:
return ModeDefault
}
}
// Decide 的优先级是拒绝规则、计划模式禁写、本会话放行、询问规则、自动改文件、高风险命令。
// 都没有命中时返回空,表示沿用画像的拒绝、确认和放行。
func Decide(req Request, rules []Rule) Effect {
var sawAsk, sawAllow bool
for _, rule := range rules {
if !matchTool(rule.Tool, req.Name) || !glob(rule.ArgGlob, req.Args) {
continue
}
switch rule.Effect {
case EffectDeny:
return EffectDeny
case EffectAsk:
sawAsk = true
case EffectAllow:
sawAllow = true
}
}
traits := mergeTraits(req.Name, req.Traits)
if commandRisk(req.Name, req.Args) == "high" {
traits.Risk = "high"
}
if Normalize(string(req.Mode)) == ModePlan && !planVisible(req.Name, traits.ReadOnly) {
return EffectDeny
}
if req.SessionAllow {
return EffectAllow
}
if sawAsk {
return EffectAsk
}
if Normalize(string(req.Mode)) == ModeAutoEdit && traits.Edits && traits.Risk != "high" {
return EffectAllow
}
if sawAllow {
return EffectAllow
}
if traits.Risk == "high" && (req.Name == "run_shell" || req.Name == "run_command") {
return EffectAsk
}
return EffectNone
}
// SessionAllows 记录并查询「本会话始终允许」的决定。键的口径由实现方定义;
// 宿主可以用自己的模式匹配(如参数通配)实现同一接口。
type SessionAllows interface {
Remember(name, args string)
Allowed(name, args string) bool
}
// MemoryAllows 是进程内的会话放行存储,按「工具名 + 原样参数串」精确匹配。
type MemoryAllows struct {
mu sync.Mutex
allowed map[string]bool
}
func NewMemoryAllows() *MemoryAllows {
return &MemoryAllows{allowed: make(map[string]bool)}
}
func (m *MemoryAllows) Remember(name, args string) {
m.mu.Lock()
m.allowed[name+"\n"+args] = true
m.mu.Unlock()
}
func (m *MemoryAllows) Allowed(name, args string) bool {
m.mu.Lock()
defer m.mu.Unlock()
return m.allowed[name+"\n"+args]
}
// Policy 是一次会话的权限配置:模式、规则与会话放行存在同一个对象上,
// Decide/Visible 语义与同名函数完全一致。
type Policy struct {
Mode Mode
Rules []Rule
SessionAllows SessionAllows // 可为 nil:等价于 SessionAllow 恒为 false
}
// Decide 按 Policy 的模式、规则与会话放行判定一次工具调用。
func (p Policy) Decide(name, args string, traits Traits) Effect {
var allowed bool
if p.SessionAllows != nil {
allowed = p.SessionAllows.Allowed(name, args)
}
return Decide(Request{Mode: p.Mode, Name: name, Args: args, Traits: traits, SessionAllow: allowed}, p.Rules)
}
// Visible 报告该模式下这个工具能否出现在清单里。
func (p Policy) Visible(name string) bool {
return Visible(string(p.Mode), name, mergeTraits(name, Traits{}).ReadOnly)
}
// Visible 报告计划模式下这个工具能否出现在清单里。其他模式一律可见。
func Visible(mode string, name string, readOnly bool) bool {
if Normalize(mode) != ModePlan {
return true
}
return planVisible(name, readOnly)
}
func planVisible(name string, readOnly bool) bool {
switch name {
case "update_plan", "update_todos", "ask_user", "exit_plan_mode":
return true
default:
return readOnly
}
}
// TraitsFor 是内置工具的只读、改文件和风险声明。未知工具为零值。
func TraitsFor(name string) Traits {
switch name {
case "read_file", "list_dir", "grep", "glob", "load_skill",
"fs_read_file", "fs_list_dir", "time_now", "knowledge_search",
"list_conversations", "read_conversation", "quality_review",
"web_search", "tavily_search", "fetch_url", "ask_user", "exit_plan_mode",
"update_plan", "update_todos", "tool_search":
return Traits{ReadOnly: true, Risk: "low"}
case "write_file", "edit_file", "multi_edit", "fs_write_file", "write_artifact", "register_artifact":
return Traits{Edits: true, Risk: "medium"}
case "run_shell", "run_command", "run_python", "run_tool_script", "call_tool":
return Traits{Risk: "medium"}
default:
return Traits{}
}
}
func mergeTraits(name string, given Traits) Traits {
base := TraitsFor(name)
if given.ReadOnly {
base.ReadOnly = true
}
if given.Edits {
base.Edits = true
}
if given.Risk != "" {
base.Risk = given.Risk
}
return base
}
// ClassifyCommand 按命令文本分级。高风险会在默认模式下改为询问。
func ClassifyCommand(cmd string) string {
c := strings.ToLower(cmd)
for _, n := range []string{"rm -rf", "rm -fr", "sudo ", "mkfs", "shutdown", "reboot", "> /dev/", "| sh", "|sh", "chmod -r 777"} {
if strings.Contains(c, n) {
return "high"
}
}
for _, n := range []string{"rm ", "mv ", "chmod ", "chown ", "kill "} {
if strings.Contains(c, n) {
return "medium"
}
}
return "low"
}
func commandRisk(name, args string) string {
if name != "run_shell" && name != "run_command" {
return ""
}
return ClassifyCommand(extractCommand(args))
}
func extractCommand(args string) string {
for _, key := range []string{`"command"`, `"cmd"`} {
i := strings.Index(args, key)
if i < 0 {
continue
}
rest := args[i+len(key):]
rest = strings.TrimLeft(rest, " \t\n:")
if !strings.HasPrefix(rest, `"`) {
continue
}
rest = rest[1:]
if j := strings.Index(rest, `"`); j >= 0 {
return rest[:j]
}
}
return args
}
func matchTool(pattern, name string) bool {
if pattern == "" || pattern == "*" {
return true
}
return glob(pattern, name)
}
func glob(pattern, s string) bool {
if pattern == "" || pattern == "*" {
return true
}
parts := strings.Split(pattern, "*")
if !strings.HasPrefix(pattern, "*") {
if !strings.HasPrefix(s, parts[0]) {
return false
}
}
if !strings.HasSuffix(pattern, "*") {
last := parts[len(parts)-1]
if !strings.HasSuffix(s, last) {
return false
}
}
idx := 0
for _, part := range parts {
if part == "" {
continue
}
j := strings.Index(s[idx:], part)
if j < 0 {
return false
}
idx += j + len(part)
}
return true
}
+90
View File
@@ -0,0 +1,90 @@
package policy
import "testing"
func TestPlanModeDeniesWrites(t *testing.T) {
effect := Decide(Request{Mode: ModePlan, Name: "write_file", Args: `{"path":"a.md"}`}, nil)
if effect != EffectDeny {
t.Fatalf("effect %q", effect)
}
if !Visible(string(ModePlan), "update_plan", false) {
t.Fatal("plan tool hidden")
}
if Visible(string(ModePlan), "write_file", false) {
t.Fatal("write visible in plan mode")
}
}
func TestDefaultModeStaysQuiet(t *testing.T) {
if got := Decide(Request{Mode: ModeDefault, Name: "write_file"}, nil); got != EffectNone {
t.Fatalf("default write %q", got)
}
if got := Decide(Request{Mode: ModeDefault, Name: "run_shell", Args: `{"command":"rm -rf /"}`}, nil); got != EffectAsk {
t.Fatalf("high shell %q", got)
}
}
func TestRulePrecedenceAndSessionAllow(t *testing.T) {
rules := []Rule{{Effect: EffectAllow, Tool: "write_file"}, {Effect: EffectDeny, Tool: "write_*", ArgGlob: "*secret*"}}
if got := Decide(Request{Name: "write_file", Args: `{"path":"secret.txt"}`}, rules); got != EffectDeny {
t.Fatalf("deny lost: %q", got)
}
if got := Decide(Request{Name: "http.fetch", SessionAllow: true}, []Rule{{Effect: EffectAsk, Tool: "http.*"}}); got != EffectAllow {
t.Fatalf("session allow %q", got)
}
}
func TestAutoEditAllowsWorkspaceWrites(t *testing.T) {
if got := Decide(Request{Mode: ModeAutoEdit, Name: "fs_write_file"}, nil); got != EffectAllow {
t.Fatalf("auto edit %q", got)
}
}
// TestPolicyDecideMatchesFunctionForm:Policy 结构体形态与函数形态判定一致。
func TestPolicyDecideMatchesFunctionForm(t *testing.T) {
rules := []Rule{{Effect: EffectAllow, Tool: "write_file"}, {Effect: EffectDeny, Tool: "write_*", ArgGlob: "*secret*"}}
cases := []struct {
p Policy
name string
args string
want Effect
}{
{Policy{Mode: ModePlan}, "write_file", `{"path":"a.md"}`, EffectDeny},
{Policy{Mode: ModeDefault, Rules: rules}, "write_file", `{"path":"a.md"}`, EffectAllow},
{Policy{Mode: ModeDefault, Rules: rules}, "write_file", `{"path":"secret.txt"}`, EffectDeny},
{Policy{Mode: ModeDefault}, "run_shell", `{"command":"rm -rf /"}`, EffectAsk},
}
for i, c := range cases {
if got := c.p.Decide(c.name, c.args, Traits{}); got != c.want {
t.Fatalf("case %d: effect %q, want %q", i, got, c.want)
}
}
}
// TestPolicySessionAllowsStore:会话放行经存储短路 ask,且跨调用存活。
func TestPolicySessionAllowsStore(t *testing.T) {
store := NewMemoryAllows()
p := Policy{Mode: ModeDefault, Rules: []Rule{{Effect: EffectAsk, Tool: "run_shell"}}, SessionAllows: store}
if got := p.Decide("run_shell", `{"command":"ls"}`, Traits{}); got != EffectAsk {
t.Fatalf("before remember: %q, want ask", got)
}
store.Remember("run_shell", `{"command":"ls"}`)
if got := p.Decide("run_shell", `{"command":"ls"}`, Traits{}); got != EffectAllow {
t.Fatalf("after remember: %q, want allow", got)
}
if got := p.Decide("run_shell", `{"command":"rm -rf /"}`, Traits{}); got != EffectAsk {
t.Fatalf("other args: %q, want ask(精确匹配不放行)", got)
}
}
// TestPolicyNilStoreAndVisible:nil 存储等价于 SessionAllow 恒 false;Visible 方法可用。
func TestPolicyNilStoreAndVisible(t *testing.T) {
p := Policy{Mode: ModePlan}
if got := p.Decide("write_file", `{}`, Traits{}); got != EffectDeny {
t.Fatalf("plan deny: %q", got)
}
if !p.Visible("update_plan") || p.Visible("write_file") {
t.Fatal("policy visible mismatch")
}
}
+130
View File
@@ -0,0 +1,130 @@
package runtime
import (
"context"
"encoding/json"
"path/filepath"
"strings"
"testing"
"agent-native/runtime/llm"
"agent-native/runtime/session"
"agent-native/runtime/tools"
)
type countingTool struct {
name string
calls int
hook func()
}
func (c *countingTool) Name() string { return c.name }
func (c *countingTool) Description() string { return c.name }
func (c *countingTool) Parameters() json.RawMessage {
return json.RawMessage(`{"type":"object","properties":{}}`)
}
func (c *countingTool) Execute(ctx context.Context, args json.RawMessage) (tools.Result, error) {
c.calls++
if c.hook != nil {
c.hook()
}
return tools.Result{Content: "ok"}, nil
}
func TestR2TokenBudgetSkipsTools(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,
Budget: &Budget{MaxPromptTokens: 10},
Stream: usageScript(llm.Usage{PromptTokens: 500},
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
ID: "c1", Name: "list_dir", Arguments: `{}`,
}}},
),
}, llm.Message{Role: llm.RoleUser, Content: "go"})
if err != nil {
t.Fatal(err)
}
if tool.calls != 0 {
t.Fatalf("tool ran after token budget: %d", tool.calls)
}
if res.Err == nil || !strings.Contains(res.Err.Error(), "prompt tokens") {
t.Fatalf("budget error: %v", res.Err)
}
}
func TestR2StopReplacementIsPersisted(t *testing.T) {
store, err := session.Open(filepath.Join(t.TempDir(), "t.jsonl"))
if err != nil {
t.Fatal(err)
}
_, err = Run(context.Background(), Config{
Model: "fake",
MaxTurns: 3,
Session: store,
Stream: script(
llm.Message{Role: llm.RoleAssistant, Content: "draft", StopReason: llm.StopEnd},
llm.Message{Role: llm.RoleAssistant, Content: "final", StopReason: llm.StopEnd},
),
Stop: func(last llm.Message, info StopInfo) StopDecision {
if info.Continuations == 0 {
return StopDecision{Continue: true, Replacement: "corrected-claim"}
}
return StopDecision{}
},
}, llm.Message{Role: llm.RoleUser, Content: "go"})
if err != nil {
t.Fatal(err)
}
msgs, err := store.Load()
if err != nil {
t.Fatal(err)
}
var saw bool
for _, m := range msgs {
if m.Content == "corrected-claim" {
saw = true
}
}
if !saw {
t.Fatalf("replacement missing from session: %+v", msgs)
}
}
func TestR2SequentialToolsStopAfterCancel(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
first := &countingTool{name: "first", hook: cancel}
second := &countingTool{name: "second"}
reg := tools.NewRegistry(t.TempDir())
reg.Register(first)
reg.Register(second)
res, err := Run(ctx, Config{
Model: "fake",
Tools: reg,
Stream: script(
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{
{ID: "1", Name: "first", Arguments: `{}`},
{ID: "2", Name: "second", Arguments: `{}`},
}},
llm.Message{Role: llm.RoleAssistant, Content: "after", StopReason: llm.StopEnd},
),
}, llm.Message{Role: llm.RoleUser, Content: "go"})
if err != nil {
t.Fatal(err)
}
if first.calls != 1 || second.calls != 0 {
t.Fatalf("calls first=%d second=%d", first.calls, second.calls)
}
var paired int
for _, m := range res.Messages {
if m.Role == llm.RoleTool {
paired++
}
}
if paired != 2 {
t.Fatalf("tool results = %d, want a pair for each call", paired)
}
}
+97
View File
@@ -0,0 +1,97 @@
// Package reminders 收集一轮里要带给模型的提醒,并做节流。
// 提醒并入最后一条工具结果或用户消息,不另起一条 user 消息。
package reminders
import (
"strings"
"sync"
"time"
"agent-native/runtime/llm"
)
type Kind string
const (
KindTodo Kind = "todo"
KindPlan Kind = "plan"
KindBackground Kind = "background"
KindDeliverable Kind = "deliverable"
KindBudget Kind = "budget"
)
// Note 是一条待投递的提醒。Key 相同的提醒在本轮只投递一次。
type Note struct {
Kind Kind
Key string
Text string
}
// Bus 按最小间隔、每 run 上限和去重决定是否投递。
type Bus struct {
mu sync.Mutex
minGap time.Duration
cap int
sent int
last time.Time
seen map[string]struct{}
}
func New(minGap time.Duration, cap int) *Bus {
if cap <= 0 {
cap = 3
}
return &Bus{minGap: minGap, cap: cap, seen: map[string]struct{}{}}
}
// Admit 在通过节流时返回真。同一 Kind+Key 只通过一次。
func (b *Bus) Admit(n Note, now time.Time) bool {
if b == nil {
return false
}
b.mu.Lock()
defer b.mu.Unlock()
if b.sent >= b.cap {
return false
}
if b.minGap > 0 && !b.last.IsZero() && now.Sub(b.last) < b.minGap {
return false
}
key := string(n.Kind) + "\n" + n.Key
if _, ok := b.seen[key]; ok {
return false
}
b.seen[key] = struct{}{}
b.last = now
b.sent++
return true
}
// Attach 把提醒正文并入最后一条工具结果或用户消息。
// 找不到这两种消息时原样返回,不追加 user 消息。
func Attach(messages []llm.Message, texts []string) []llm.Message {
var lines []string
for _, text := range texts {
text = strings.TrimSpace(text)
if text != "" {
lines = append(lines, text)
}
}
if len(lines) == 0 || len(messages) == 0 {
return messages
}
block := "<system-reminder>\n" + strings.Join(lines, "\n") + "\n</system-reminder>"
out := append([]llm.Message{}, messages...)
for i := len(out) - 1; i >= 0; i-- {
if out[i].Role != llm.RoleTool && out[i].Role != llm.RoleUser {
continue
}
if out[i].Content != "" {
out[i].Content += "\n" + block
} else {
out[i].Content = block
}
return out
}
return messages
}
+53
View File
@@ -0,0 +1,53 @@
package reminders
import (
"strings"
"testing"
"time"
"agent-native/runtime/llm"
)
func TestAttachDoesNotAddUserAfterTool(t *testing.T) {
msgs := []llm.Message{
{Role: llm.RoleUser, Content: "请改文件"},
{Role: llm.RoleAssistant, ToolCalls: []llm.ToolCall{{ID: "c1", Name: "read_file"}}},
{Role: llm.RoleTool, Name: "read_file", ToolCallID: "c1", Content: "原文"},
}
out := Attach(msgs, []string{"计划和待办已过期"})
if len(out) != len(msgs) {
t.Fatalf("len %d, want %d", len(out), len(msgs))
}
for i := range msgs {
if out[i].Role != msgs[i].Role {
t.Fatalf("role changed at %d: %s", i, out[i].Role)
}
}
last := out[len(out)-1]
if last.Role != llm.RoleTool || !strings.Contains(last.Content, "原文") || !strings.Contains(last.Content, "system-reminder") {
t.Fatalf("reminder not folded into tool result: %+v", last)
}
if strings.Contains(out[0].Content, "system-reminder") {
t.Fatal("reminder leaked onto the earlier user message")
}
}
func TestAdmitThrottles(t *testing.T) {
bus := New(time.Minute, 2)
now := time.Now()
if !bus.Admit(Note{Kind: KindBudget, Key: "near", Text: "预算将尽"}, now) {
t.Fatal("first admit")
}
if bus.Admit(Note{Kind: KindBudget, Key: "near", Text: "预算将尽"}, now.Add(time.Second)) {
t.Fatal("duplicate key admitted")
}
if bus.Admit(Note{Kind: KindBackground, Key: "job", Text: "后台结束"}, now.Add(time.Second)) {
t.Fatal("inside min gap admitted")
}
if !bus.Admit(Note{Kind: KindBackground, Key: "job", Text: "后台结束"}, now.Add(time.Minute)) {
t.Fatal("after gap")
}
if bus.Admit(Note{Kind: KindDeliverable, Key: "doc", Text: "交付物待确认"}, now.Add(2*time.Minute)) {
t.Fatal("over cap")
}
}
+37
View File
@@ -0,0 +1,37 @@
package resources
import (
"os"
"path/filepath"
"strings"
)
// ContextFiles walks from dir toward the filesystem root and concatenates
// AGENTS.md and CLAUDE.md (nearest first). SYSTEM.md and APPEND_SYSTEM.md
// are read from dir only.
func ContextFiles(dir string) (string, error) {
var parts []string
cur := dir
for {
for _, name := range []string{"AGENTS.md", "CLAUDE.md"} {
raw, err := os.ReadFile(filepath.Join(cur, name))
if err != nil {
continue
}
parts = append(parts, "# "+name+" ("+cur+")\n"+string(raw))
}
parent := filepath.Dir(cur)
if parent == cur {
break
}
cur = parent
}
for _, name := range []string{"SYSTEM.md", "APPEND_SYSTEM.md"} {
raw, err := os.ReadFile(filepath.Join(dir, name))
if err != nil {
continue
}
parts = append(parts, "# "+name+"\n"+string(raw))
}
return strings.Join(parts, "\n\n"), nil
}
+44
View File
@@ -0,0 +1,44 @@
package resources
import (
"os"
"path/filepath"
"strings"
)
// Expand replaces {{var}} placeholders and /name includes.
// An include reads <dir>/<name>.md. Unknown variables are left intact.
func Expand(text, dir string, vars map[string]string) (string, error) {
out := text
for key, value := range vars {
out = strings.ReplaceAll(out, "{{"+key+"}}", value)
}
for {
i := strings.Index(out, "/")
if i < 0 {
break
}
rest := out[i+1:]
j := 0
for j < len(rest) && isName(rest[j]) {
j++
}
if j == 0 || (i > 0 && out[i-1] != ' ' && out[i-1] != '\n') {
break
}
name := rest[:j]
raw, err := os.ReadFile(filepath.Join(dir, name+".md"))
if err != nil {
break
}
out = out[:i] + string(raw) + out[i+1+j:]
}
for key, value := range vars {
out = strings.ReplaceAll(out, "{{"+key+"}}", value)
}
return out, nil
}
func isName(b byte) bool {
return b == '-' || b == '_' || (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9')
}
+72
View File
@@ -0,0 +1,72 @@
package resources
import (
"os"
"path/filepath"
"strings"
"testing"
"agent-native/runtime/skills"
"agent-native/runtime/tools"
)
func TestContextAndPromptAndTrust(t *testing.T) {
root := t.TempDir()
proj := filepath.Join(root, "proj")
if err := os.MkdirAll(proj, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "AGENTS.md"), []byte("root-agents"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(proj, "SYSTEM.md"), []byte("sys"), 0o644); err != nil {
t.Fatal(err)
}
text, err := ContextFiles(proj)
if err != nil || !strings.Contains(text, "root-agents") || !strings.Contains(text, "sys") {
t.Fatalf("context: %s %v", text, err)
}
if err := os.WriteFile(filepath.Join(proj, "greet.md"), []byte("hello {{name}}"), 0o644); err != nil {
t.Fatal(err)
}
got, err := Expand("say /greet", proj, map[string]string{"name": "Ada"})
if err != nil || got != "say hello Ada" {
t.Fatalf("expand: %q %v", got, err)
}
if ProjectTrusted(TrustAsk, false, false) {
t.Fatal("ask is not trusted without approve")
}
if !ProjectTrusted(TrustNever, true, false) {
t.Fatal("approve overrides never")
}
if ProjectTrusted(TrustAlways, false, true) {
t.Fatal("no-approve wins")
}
}
func TestPiAliasesFilterAndSkillCommand(t *testing.T) {
reg := tools.WorkspaceTools(t.TempDir())
reg.RegisterPiAliases()
if !reg.Has("read") || !reg.Has("ls") || !reg.Has("read_file") {
t.Fatal("aliases missing")
}
if err := reg.Filter(tools.FilterOptions{Exclude: []string{"read"}, NoBuiltin: false}); err != nil {
t.Fatal(err)
}
if reg.Has("read") || !reg.Has("read_file") {
t.Fatal("exclude should drop only the alias")
}
dir := t.TempDir()
skillDir := filepath.Join(dir, "review")
if err := os.MkdirAll(skillDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("be thorough"), 0o644); err != nil {
t.Fatal(err)
}
store := skills.NewStore(dir)
out, err := store.ExpandCommands("use /skill:review now")
if err != nil || !strings.Contains(out, "be thorough") {
t.Fatalf("skill expand: %q %v", out, err)
}
}
+102
View File
@@ -0,0 +1,102 @@
package resources
import (
"encoding/json"
"os"
"path/filepath"
)
// Trust is how a project directory is treated in non-interactive runs.
type Trust string
const (
TrustAsk Trust = "ask"
TrustAlways Trust = "always"
TrustNever Trust = "never"
)
// Settings is the global plus project configuration.
type Settings struct {
Trust Trust `json:"trust,omitempty"`
Model string `json:"model,omitempty"`
Thinking string `json:"thinking,omitempty"`
Tools []string `json:"tools,omitempty"`
Exclude []string `json:"exclude,omitempty"`
Vars map[string]string `json:"vars,omitempty"`
}
// LoadSettings reads global (optional) then project settings.
// Project keys override global keys. A missing file is an empty settings.
func LoadSettings(globalPath, projectPath string) (Settings, error) {
global, err := readSettings(globalPath)
if err != nil {
return Settings{}, err
}
project, err := readSettings(projectPath)
if err != nil {
return Settings{}, err
}
return mergeSettings(global, project), nil
}
// ProjectTrusted reports whether project resources may be loaded.
// ask and never refuse in non-interactive mode. approve forces trust.
func ProjectTrusted(trust Trust, approve, noApprove bool) bool {
if noApprove {
return false
}
if approve {
return true
}
return trust == TrustAlways
}
func readSettings(path string) (Settings, error) {
if path == "" {
return Settings{}, nil
}
raw, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return Settings{}, nil
}
return Settings{}, err
}
var s Settings
if err := json.Unmarshal(raw, &s); err != nil {
return Settings{}, err
}
return s, nil
}
func mergeSettings(base, over Settings) Settings {
if over.Trust != "" {
base.Trust = over.Trust
}
if over.Model != "" {
base.Model = over.Model
}
if over.Thinking != "" {
base.Thinking = over.Thinking
}
if over.Tools != nil {
base.Tools = over.Tools
}
if over.Exclude != nil {
base.Exclude = over.Exclude
}
if len(over.Vars) > 0 {
if base.Vars == nil {
base.Vars = map[string]string{}
}
for k, v := range over.Vars {
base.Vars[k] = v
}
}
return base
}
// DefaultPaths returns the global and project settings paths for a cwd.
func DefaultPaths(home, cwd string) (string, string) {
return filepath.Join(home, ".agent-native", "settings.json"), filepath.Join(cwd, ".agent-native", "settings.json")
}
+105
View File
@@ -0,0 +1,105 @@
package session
import (
"os"
"path/filepath"
"sort"
"strings"
)
// Info describes one session file in a directory.
type Info struct {
ID string
Path string
Name string
CWD string
Leaf string
Updated string
}
// List returns session files in dir, newest first.
func List(dir string) ([]Info, error) {
ents, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
var out []Info
for _, ent := range ents {
name := ent.Name()
if ent.IsDir() || !strings.HasSuffix(name, ".jsonl") {
continue
}
path := filepath.Join(dir, name)
st, err := Open(path)
if err != nil {
continue
}
info, err := st.Info()
if err != nil {
continue
}
out = append(out, info)
}
sort.Slice(out, func(i, j int) bool { return out[i].Updated > out[j].Updated })
return out, nil
}
// Continue opens the newest session whose cwd matches. A second return
// value of false means there is nothing to continue.
func Continue(dir, cwd string) (*Store, bool, error) {
list, err := List(dir)
if err != nil {
return nil, false, err
}
for _, info := range list {
if cwd == "" || info.CWD == cwd {
st, err := Open(info.Path)
return st, err == nil, err
}
}
return nil, false, nil
}
// Resume opens a session by id (filename stem) or by name.
func Resume(dir, idOrName string) (*Store, error) {
list, err := List(dir)
if err != nil {
return nil, err
}
for _, info := range list {
if info.ID == idOrName || info.Name == idOrName {
return Open(info.Path)
}
}
return nil, os.ErrNotExist
}
// SetCWD records the working directory used by Continue.
func (s *Store) SetCWD(cwd string) error {
s.mu.Lock()
defer s.mu.Unlock()
return s.writeMetaKeyLocked("cwd", cwd)
}
// Info reads the catalog fields for this file.
func (s *Store) Info() (Info, error) {
s.mu.Lock()
defer s.mu.Unlock()
entries, err := s.readEntriesLocked()
if err != nil {
return Info{}, err
}
meta := s.readMetaLocked()
updated := ""
if len(entries) > 0 {
updated = entries[len(entries)-1].Timestamp
}
id := strings.TrimSuffix(filepath.Base(s.path), ".jsonl")
return Info{
ID: id, Path: s.path, Name: meta["name"], CWD: meta["cwd"],
Leaf: s.leafLocked(entries), Updated: updated,
}, nil
}
+63 -47
View File
@@ -3,6 +3,7 @@ package session
import (
"bufio"
"encoding/json"
"fmt"
"os"
"agent-native/runtime/llm"
@@ -66,73 +67,88 @@ func (s *Store) ReadMeta() map[string]string {
return meta
}
// Compact 把转录改写为「系统消息 + 摘要消息 + 最近 keepLastN 条」。
// 摘要由 summarize 生成(由适配层注入,通常是一次无工具模型调用);
// 改写原子落盘(临时文件 + rename)。
// 返回是否发生了压缩(消息数不足/已是紧凑形态时返回 false 且不动文件)。
const maxCompactAttempts = 3
// Compact appends a compaction entry. The file keeps every earlier record.
// Load hides the summarized prefix behind the summary plus the last
// keepLastN non-system messages. Returns false when there is nothing to fold.
// A session change during summary is retried from the new transcript.
// Three consecutive summary failures or conflicts trip the breaker and
// leave the file unchanged.
func (s *Store) Compact(keepLastN int, summarize func(messages []llm.Message) (string, error)) (bool, error) {
messages, err := s.Load()
var last error
for attempt := 0; attempt < maxCompactAttempts; attempt++ {
done, err, retry := s.compactOnce(keepLastN, summarize)
if !retry {
return done, err
}
last = err
}
if last == nil {
last = fmt.Errorf("compaction conflict: session changed during summary")
}
return false, fmt.Errorf("compaction circuit open: %w", last)
}
func (s *Store) compactOnce(keepLastN int, summarize func(messages []llm.Message) (string, error)) (bool, error, bool) {
s.mu.Lock()
entries, err := s.readEntriesLocked()
if err != nil {
return false, err
s.mu.Unlock()
return false, err, false
}
if len(messages) <= keepLastN+1 { // 系统消息 + 尾部,无可压缩头部
return false, nil
leaf := s.leafLocked(entries)
generation := len(entries)
s.mu.Unlock()
path := pathTo(entries, leaf)
var messages []llm.Message
var msgEntries []Entry
for _, e := range path {
if e.Type == TypeMessage && e.Message != nil {
messages = append(messages, *e.Message)
msgEntries = append(msgEntries, e)
}
}
if len(messages) <= keepLastN+1 {
return false, nil, false
}
var system *llm.Message
rest := messages
restEntries := msgEntries
if len(rest) > 0 && rest[0].Role == llm.RoleSystem {
copyOf := rest[0]
system = &copyOf
rest = rest[1:]
restEntries = restEntries[1:]
}
tailStart := len(rest) - keepLastN
if tailStart <= 0 {
return false, nil
return false, nil, false
}
tailStart = alignToolTail(rest, tailStart)
head := rest[:tailStart]
tail := rest[tailStart:]
summary, err := summarize(head)
if err != nil {
return false, err
return false, err, true
}
summaryMsg := llm.Message{
Role: llm.RoleUser,
Content: "[conversation summary]\n" + summary + "\n[recent messages follow]",
var tailIDs []string
for _, e := range restEntries[tailStart:] {
tailIDs = append(tailIDs, e.ID)
}
var rebuilt []llm.Message
if system != nil {
rebuilt = append(rebuilt, *system)
}
rebuilt = append(rebuilt, summaryMsg)
rebuilt = append(rebuilt, tail...)
s.mu.Lock()
defer s.mu.Unlock()
tmp := s.path + ".compact.tmp"
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
now, err := s.readEntriesLocked()
if err != nil {
return false, err
return false, err, false
}
w := bufio.NewWriter(f)
for _, m := range rebuilt {
raw, err := json.Marshal(m)
if err != nil {
_ = f.Close()
_ = os.Remove(tmp)
return false, err
}
w.Write(raw)
w.WriteByte('\n')
if len(now) != generation {
return false, fmt.Errorf("compaction conflict: session changed during summary"), true
}
if err := w.Flush(); err != nil {
_ = f.Close()
_ = os.Remove(tmp)
return false, err
_, err = s.appendEntryLocked(Entry{Type: TypeCompaction, Summary: summary, TailIDs: tailIDs})
return err == nil, err, false
}
// alignToolTail 让保留尾巴从一条非 tool 消息开始,避免把 tool_calls 和结果拆开。
func alignToolTail(messages []llm.Message, tailStart int) int {
for tailStart > 0 && messages[tailStart].Role == llm.RoleTool {
tailStart--
}
_ = f.Close()
if err := os.Rename(tmp, s.path); err != nil {
_ = os.Remove(tmp)
return false, err
}
return true, nil
return tailStart
}
+6 -3
View File
@@ -114,9 +114,12 @@ func TestCompactRewritesTranscript(t *testing.T) {
if msgs[2].Content != "reply" || msgs[3].Content != "earlyj" || msgs[4].Content != "reply" {
t.Fatalf("tail order wrong: %+v", msgs[2:])
}
// 无 tmp 残留。
if _, err := os.Stat(s.Path() + ".compact.tmp"); !os.IsNotExist(err) {
t.Fatal("compact tmp file must not remain")
entries, err := s.Entries()
if err != nil {
t.Fatal(err)
}
if len(entries) <= 5 {
t.Fatalf("compaction must append, not rewrite; got %d entries", len(entries))
}
}
+32
View File
@@ -0,0 +1,32 @@
package session
import (
"fmt"
"html"
"os"
"strings"
)
// ExportHTML writes the active branch as a standalone HTML document.
func (s *Store) ExportHTML(path string) error {
msgs, err := s.Load()
if err != nil {
return err
}
var b strings.Builder
b.WriteString("<!DOCTYPE html><html><head><meta charset=\"utf-8\"><title>")
title := s.Name()
if title == "" {
title = "session"
}
b.WriteString(html.EscapeString(title))
b.WriteString("</title></head><body>\n")
for _, m := range msgs {
fmt.Fprintf(&b, "<article data-role=\"%s\"><h2>%s</h2><pre>%s</pre></article>\n",
html.EscapeString(string(m.Role)),
html.EscapeString(string(m.Role)),
html.EscapeString(m.Content))
}
b.WriteString("</body></html>\n")
return os.WriteFile(path, []byte(b.String()), 0o644)
}
+57
View File
@@ -0,0 +1,57 @@
package session
import (
"encoding/json"
"agent-native/runtime/llm"
)
// ToolIntent 是一次尚未配对结果的工具调用声明。
type ToolIntent struct {
CallID string `json:"callId"`
Name string `json:"name"`
Arguments string `json:"arguments,omitempty"`
Replay string `json:"replay"`
}
// AppendToolIntent 先落下「即将执行」。结果另写一条 tool 消息。
func (s *Store) AppendToolIntent(in ToolIntent) error {
raw, err := json.Marshal(in)
if err != nil {
return err
}
_, err = s.AppendEntry(Entry{Type: TypeToolIntent, Data: raw})
return err
}
// PendingIntents 返回活动分支上还没有对应 tool 结果的意图。
func (s *Store) PendingIntents() ([]ToolIntent, error) {
s.mu.Lock()
defer s.mu.Unlock()
entries, err := s.readEntriesLocked()
if err != nil {
return nil, err
}
answered := map[string]bool{}
var intents []ToolIntent
for _, e := range pathTo(entries, s.leafLocked(entries)) {
if e.Type == TypeMessage && e.Message != nil && e.Message.Role == llm.RoleTool && e.Message.ToolCallID != "" {
answered[e.Message.ToolCallID] = true
}
if e.Type != TypeToolIntent {
continue
}
var in ToolIntent
if err := json.Unmarshal(e.Data, &in); err != nil {
return nil, err
}
intents = append(intents, in)
}
var pending []ToolIntent
for _, in := range intents {
if !answered[in.CallID] {
pending = append(pending, in)
}
}
return pending, nil
}
+12 -44
View File
@@ -1,17 +1,16 @@
package session
import (
"bufio"
"encoding/json"
"os"
"path/filepath"
"strings"
"sync"
"agent-native/runtime/llm"
)
// Store is an append-only JSONL transcript. Each line is one llm.Message.
// Store is an append-only JSONL transcript. New sessions are a tree of
// entries (id / parentId). Load still returns the model-visible message
// path, and files written as one llm.Message per line remain readable.
type Store struct {
path string
mu sync.Mutex
@@ -31,56 +30,25 @@ func Open(path string) (*Store, error) {
func (s *Store) Path() string { return s.path }
// Append records one message on the active branch.
func (s *Store) Append(msg llm.Message) error {
s.mu.Lock()
defer s.mu.Unlock()
raw, err := json.Marshal(msg)
if err != nil {
return err
}
f, err := os.OpenFile(s.path, os.O_APPEND|os.O_WRONLY, 0o644)
if err != nil {
return err
}
defer f.Close()
raw = append(raw, '\n')
_, err = f.Write(raw)
_, err := s.appendEntryLocked(Entry{Type: TypeMessage, Message: &msg})
return err
}
// Load 全量读取转录。崩溃残留容错:Append 恒以换行收尾,因此无换行结尾的
// 尾部若不是完整 JSON 即为崩溃半行,丢弃而不是让整个会话不可用;
// 行中间的坏行仍报错(那是文件损坏,不是崩溃残留)。
// Load returns the model-visible messages on the active branch.
// A compaction entry replaces earlier turns with its summary plus the
// retained tail; the file itself is never rewritten.
// Crash residue: a trailing line without a newline that is not valid JSON
// is dropped. A bad line in the middle is still an error.
func (s *Store) Load() ([]llm.Message, error) {
s.mu.Lock()
defer s.mu.Unlock()
raw, err := os.ReadFile(s.path)
entries, err := s.readEntriesLocked()
if err != nil {
return nil, err
}
text := string(raw)
if text != "" && !strings.HasSuffix(text, "\n") {
if idx := strings.LastIndexByte(text, '\n'); idx >= 0 {
if !json.Valid([]byte(text[idx+1:])) {
text = text[:idx+1]
}
} else if !json.Valid([]byte(text)) {
text = ""
}
}
var out []llm.Message
sc := bufio.NewScanner(strings.NewReader(text))
sc.Buffer(make([]byte, 0, 64*1024), 8*1024*1024)
for sc.Scan() {
line := sc.Bytes()
if len(line) == 0 {
continue
}
var msg llm.Message
if err := json.Unmarshal(line, &msg); err != nil {
return nil, err
}
out = append(out, msg)
}
return out, sc.Err()
return visibleMessages(entries, s.leafLocked(entries)), nil
}
+70
View File
@@ -0,0 +1,70 @@
package session
import "agent-native/runtime/llm"
const (
// DefaultKeepToolResults 是运行内清理时保留的最近工具结果条数。
DefaultKeepToolResults = 5
// DefaultContextWindow 是未配置模型窗口时的保守字符数。
DefaultContextWindow = 32000
// DefaultOutputReserve 是留给模型输出的字符预留。
DefaultOutputReserve = 4096
// DefaultContextBuffer 是窗口估算的余量,避免贴着上限才清理。
DefaultContextBuffer = 1024
)
// EstimateChars 用字符数近似这次请求的体积。
func EstimateChars(msgs []llm.Message) int {
n := 0
for _, m := range msgs {
n += len([]rune(m.Content))
for _, c := range m.ToolCalls {
n += len([]rune(c.Name)) + len([]rune(c.Arguments))
}
}
return n
}
// NeedsClear 在估算超过「窗口 − 输出预留 − 缓冲」时返回真。
// window ≤ 0 时用保守默认值。
func NeedsClear(estimate, window, outputReserve, buffer int) bool {
if window <= 0 {
window = DefaultContextWindow
}
room := window - outputReserve - buffer
if room < 1 {
room = 1
}
return estimate > room
}
// ClearOldToolResults 把最近 keep 条之前的工具结果换成占位。
// 消息条数、顺序和 tool_call 配对都不变;占位写明完整结果仍在会话记录里。
// keep ≤ 0 时保留默认的 5 条。返回的切片不与输入共享被改写的消息。
func ClearOldToolResults(msgs []llm.Message, keep int) []llm.Message {
if keep <= 0 {
keep = DefaultKeepToolResults
}
out := append([]llm.Message{}, msgs...)
var toolIdx []int
for i, m := range out {
if m.Role == llm.RoleTool {
toolIdx = append(toolIdx, i)
}
}
if len(toolIdx) <= keep {
return out
}
for _, i := range toolIdx[:len(toolIdx)-keep] {
name := out[i].Name
if name == "" {
name = "工具"
}
loc := "会话记录"
if out[i].ToolCallID != "" {
loc = "会话记录中的调用 " + out[i].ToolCallID
}
out[i].Content = "旧工具结果已清理。完整内容见" + loc + "(" + name + ")。"
}
return out
}
+60
View File
@@ -0,0 +1,60 @@
package session
import (
"strings"
"testing"
"agent-native/runtime/llm"
)
func TestClearOldToolResultsKeepsLastFive(t *testing.T) {
var msgs []llm.Message
for i := 0; i < 8; i++ {
id := string(rune('a' + i))
msgs = append(msgs,
llm.Message{Role: llm.RoleAssistant, ToolCalls: []llm.ToolCall{{ID: id, Name: "read_file"}}},
llm.Message{Role: llm.RoleTool, Name: "read_file", ToolCallID: id, Content: "正文-" + id},
)
}
out := ClearOldToolResults(msgs, 5)
if len(out) != len(msgs) {
t.Fatalf("len %d, want %d", len(out), len(msgs))
}
cleared := 0
for i, m := range out {
if m.Role != llm.RoleTool {
if m.Role != msgs[i].Role || len(m.ToolCalls) != len(msgs[i].ToolCalls) {
t.Fatalf("pairing changed at %d", i)
}
continue
}
if m.ToolCallID != msgs[i].ToolCallID {
t.Fatalf("tool id changed at %d", i)
}
if strings.Contains(m.Content, "旧工具结果已清理") {
cleared++
if !strings.Contains(m.Content, m.ToolCallID) {
t.Fatalf("placeholder missing call id: %s", m.Content)
}
} else if m.Content != msgs[i].Content {
t.Fatalf("recent result changed: %s", m.Content)
}
}
if cleared != 3 {
t.Fatalf("cleared %d, want 3", cleared)
}
for _, m := range out[len(out)-10:] {
if m.Role == llm.RoleTool && strings.Contains(m.Content, "旧工具结果已清理") {
t.Fatalf("last five were cleared: %s", m.ToolCallID)
}
}
}
func TestNeedsClearUsesConservativeWindow(t *testing.T) {
if NeedsClear(10, 0, 0, 0) {
t.Fatal("small estimate must fit the default window")
}
if !NeedsClear(DefaultContextWindow, 0, DefaultOutputReserve, DefaultContextBuffer) {
t.Fatal("estimate at the default window must clear")
}
}

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