Compare commits

..
Author SHA1 Message Date
Kevin Codex 2e2c181d28 fix: surface competing block-anchor spans; make write-barrier test observe cross-probe order
- edit_file: blockAnchorReplacer now returns every span clearing the
  similarity threshold (best first) instead of silently picking the
  single most-similar block. Hiding competitors from the cascade's
  distinct-candidate check could edit the wrong block when two blocks
  share anchors and both interiors are plausible; one clear winner still
  resolves, two plausible blocks now report ambiguity (covered by a
  two-block test that asserts the file is untouched).
- agent tests: the parallel write-barrier test now records both probes
  into one mutex-guarded shared log and asserts the cross-probe ordering
  it always claimed to (write starts after the first read batch ends;
  second read batch starts after the write ends). The previous version
  only compared indices within the read probe's own log, which a
  regression letting the write overlap a batch could still pass; the
  stray no-op mutex assignment is gone.
2026-07-05 17:16:24 +08:00
Kevin Codex 4643dc9348 fix: address review round 2 — permission-event race, fuzzy ambiguity, spill reachability
- agent: serialize OnPermission behind the same mutex as
  OnPermissionRequest in the parallel read batch. buildPermissionEvent
  fires for batched reads with a non-trivial sandbox decision (e.g. a
  grant-matched extra root), and front-end handlers append to shared
  session-recording state without their own locking — two batched reads
  emitting concurrently was a data race the pre-batch serial loop never
  allowed.
- edit_file: reject two or more DISTINCT candidate spans from one fuzzy
  strategy as ambiguous instead of silently editing the first. Duplicate
  blocks at different indentation each resolve to a unique literal, so
  the previous occurrence-uniqueness check passed and wrote the wrong
  span. A single candidate occurring at multiple positions still
  cascades to stricter strategies as before.
- tools: make spill files actually reachable by the recovery path the
  truncation notice advertises — resolveScopedReadPath now recognizes
  files inside the per-uid spill dir (containment verified after symlink
  resolution, so a planted link cannot smuggle an out-of-scope file
  through the gate); read_file/grep can follow the notice instead of
  re-running the command.
2026-07-05 16:57:39 +08:00
Kevin Codex 57e34c790d fix: address PR review feedback on fuzzy edits, diagnostics, spill
- edit_file: adapt the replacement to the fuzzy-resolved span — when the
  span is a uniformly deeper-indented copy of old_string (line-trimmed /
  indentation-flexible matches), the same indentation delta is applied to
  new_string, and a span ending in a bare CR keeps its CR so CRLF files
  never gain a mixed line ending; block-anchor matches with drifted
  interiors are deliberately left untouched
- diagnostics: render workspace-relative paths in inline post-edit
  diagnostics (base name outside the workspace) so absolute paths never
  leak the local username or directory layout into the model prompt or
  session transcript
- spill: harden the spill directory for shared temp dirs — per-uid name,
  reject symlinked or non-directory paths (MkdirAll follows symlinks),
  and on Unix require current-user ownership
- bash: the branch's output cap and truncation-flag threading are
  superseded by main's capture-time bounding (boundedBuffer +
  budgetBashCapture), which already sets Result.Truncated; this rebase
  adopts that design and keeps spill-to-disk on exec_command, where the
  full output is still buffered and a spill is genuinely complete
2026-07-05 16:47:18 +08:00
Kevin Codex d0ae9c98e3 feat: agent quality, caching, retry, and tooling upgrades
Ten self-contained improvements to the core agent engine and tools:

- edit_file: fuzzy fallback matching (7-strategy cascade with uniqueness
  protection and a disproportion guard) when old_string does not match
  byte-for-byte; exact match remains the fast path
- edit_file/write_file: inline error-severity LSP diagnostics in tool
  output so the model sees a break it introduced in the same turn
- lsp: expand server registry from 4 to 28 languages; doctor grades only
  tier-1 servers and lists the long tail informationally
- agent: consecutive read-only tool calls execute concurrently (bounded
  at 8), consumed in original order, never spanning a mutating call
- anthropic: cache_control breakpoints on the last two conversation
  messages so the transcript prefix stays a prompt-cache hit
- providerio: retry schedule raised to 6 attempts with exponential
  2s-30s backoff when the server sends no Retry-After
- tools: truncated exec/bash output spills to a redacted temp file
  referenced in the truncation notice; bash output (previously
  unbounded) now shares exec_command's token budget
- web_fetch: dependency-free HTML-to-markdown conversion (format:
  auto|raw|markdown) and larger limits (256KiB default, 2MiB max, 30s)
- modelregistry: live models.dev overlay refreshes context limits and
  base pricing from a TTL-cached fetch; opt-in at the CLI entrypoint
- tools: opt-in format-on-write (ZERO_FORMAT_ON_WRITE=1) runs standard
  formatters across 27 extensions, ordered before the file-tracker
  re-baseline so conflict detection is preserved
2026-07-05 16:43:50 +08:00
Anandan afb5010a46 polish v2: smoothness & delight (#484)
* feat(tui): actionable hints on provider/model errors

Classify a failed turn's provider error into auth / rate-limit /
connectivity / model-not-found / context-overflow and render a one-line
next step below the red error row, instead of dumping an identical raw
blob for every failure mode.

The classifier lives in a new internal/errhint package so both the TUI
error row and the CLI exec path can share it (TUIHint references slash
commands, CLIHint references zero subcommands). Classification is a
conservative string heuristic keyed off providerio.ClassifiedError's
prefixes plus lower-level DNS/TLS/timeout/context-length signatures,
since the numeric HTTP status is gone by the time the error reaches a UI
surface.

* feat(tools): budget bash output with head+tail truncation

bash was the one tool that returned command output with no byte cap —
'cat large.log' or a verbose test run could dump megabytes into context
and force compaction, while every other read/search tool already applies
a budget.

Cap stdout and stderr at 96KB each, keeping the head and tail of an
oversized stream (build/test failures usually land at the tail) and
dropping the middle behind a marker that points at redirect-to-file +
read_file. Record raw_bytes/emitted_bytes/estimated_tokens/truncated in
Meta like the other tools. Sandbox-denial and shell-issue detection still
run on the full raw output before budgeting.

* fix(gemini): retry 401 with refreshed OAuth token

The Gemini provider used providerio.SendWithRetry (429/503/529 only),
so an expired OAuth token surfaced as a terminal 'API key not valid'
auth error while OpenAI and Anthropic silently recovered via
SendWithAuthRetry's force-refresh-and-replay on 401.

Plumb OAuthResolver through gemini.Options/Provider and the factory
(exactly as the other two providers already do) and switch the stream
send to SendWithAuthRetry. With a nil resolver (API-key users) behavior
is unchanged.

* fix(errhint): gate classification on provider-origin marker

Address CodeRabbit review on #1: agentResponseMsg.err can also carry
local failures (a tool's 'permission denied', a 'file does not exist',
a config error), and broad substrings would have attached a bogus
/provider or /model hint to those.

Classify now returns Unknown unless the message carries a provider marker
the provider layer always attaches (auth error: / rate limit error: /
provider error: / provider request error: / provider stream error:).
Local errors never draw a provider hint; provider errors sub-classify as
before. Added local-failure test cases proving they stay Unknown.

* feat(cli): actionable hint on text-mode exec provider errors

zero exec printed provider failures as a bare '[zero] <raw error>' with
no next step. Append a one-line hint (reusing the shared errhint
classifier) for recognized provider failures — 'run `zero auth`',
'run `zero doctor`', etc. The provider-origin gate means non-provider
codes (sandbox_error, mcp_error) and JSON/stream-json output are
untouched.

* fix(tools): set Result.Truncated for budgeted bash output

Address CodeRabbit review on #3: budgetBashOutput recorded truncation in
meta["truncated"] but never set the Result.Truncated struct field, so
consumers reading the struct (like glob/web_fetch/read_minified do) would
miss truncated bash results. Return the bool from budgetBashOutput and
set Result.Truncated on every return path.

* perf(tui): reuse one LSP manager across prompts

runAgentWithOptions built a fresh lsp.NewManager per run and shut it down
when the run returned, so gopls (and every other language server)
cold-started on the first edit of every turn — 200ms-2s of latency the
manager's own design (long-lived, reused servers) exists to avoid.

Build one manager per session in newModel (cheap: servers start lazily on
first Check) and reuse it across runs; the SelfCorrector still gets a
fresh checker wrapping the shared manager each run. Torn down with a
short deadline in quit(). Runs fall back to a per-run manager when the
session manager is absent (cwd unknown, or a directly-built test model).

* perf(tui): accumulate streamingText as []byte (O(1) append)

m.streamingText += msg.delta was O(len) per delta -> O(n²) across a long
generation (a 10k-token code gen allocates hundreds of MB of intermediate
strings and the UI gets progressively laggier). Accumulate into a []byte
with append (O(1) amortized) and read via streamingTextString().

A []byte rather than strings.Builder because the TUI model is copied by
value on every Update, which would trip strings.Builder's copy check;
[]byte is nil-safe and copies cleanly like the other slice fields.

* perf(tui): coalesce streamed text deltas to one frame

Every OnText delta was its own tea.Msg, so a fast provider (100+ tok/s)
drove 100+ full Update->View cycles per second, each re-parsing the
growing markdown — visible stutter over SSH and wasted CPU.

Batch agentTextMsg deltas at the runtime sink over a ~16ms frame and
forward them as a single message, decoupling render rate from token rate.
Any non-text message flushes pending text first so ordering with
tool-call / reasoning / row messages is preserved; the final
agentResponseMsg (a tea.Cmd return, not a sink message) is safe because
the model already drops deltas for an inactive runID.

* fix(agent): raise tool-failure stop threshold 4->6

guardrails.go halted a run after 4 consecutive same-error tool failures.
A corrective hint fires at 2, and a model iterating on a genuinely tricky
edit can legitimately fail a couple more times after the hint while
converging — stopping at 4 cut those runs short. Raise to 6; the streak
still resets the instant the tool succeeds or hits a different error, so
only true same-error loops are affected.

* fix(agent): more reconnect retries with jitter; narrow to transport errors

#19: bump maxStreamReconnects 2->4 and add up-to-50% jitter on the
exponential backoff (capped at 8s), so a multi-second network blip is
ridden out instead of killing the run on a 2s hiccup, and concurrent runs
(swarms, cron fleets) don't reconnect in lockstep.

#34: drop the '502'/'503' substring matches from shouldReconnect. 503
already exhausted providerio.SendWithRetry (retrying here is a redundant
double-retry) and 502 is non-idempotent by providerio's rule (the POST
may have been processed). Only genuine transport failures — where no
response was received — reconnect now.

* fix(agent): reduce stall retries 2->1 to bound stuck-session time

A no-output stream stall is detected only after the full stream idle
timeout (~5min) elapses, and each retry can idle again — so 2 retries
left an interactive session frozen for ~15min. Drop to 1 retry: keeps the
common single-hiccup recovery while bounding the worst case to ~2x the
idle timeout.

* fix(tui): serialize coalescer forwarding to preserve message order

Address CodeRabbit review on #7: flush() and the run-switch path drained
the text buffer under c.mu but called forward() after releasing it, so a
timer-fired text flush could race a concurrent non-text send() and land
after the tool-call/reasoning message it should precede.

Hold c.mu across drain AND forward (drainAndForwardLocked), so whoever
holds the lock delivers atomically and the other caller blocks until it
is done — text can never overtake a following non-text message. Added a
concurrency stress test (timer racing an inline send) that passes under
-race.

* feat(tui): accept /compact now

Bare /compact already triggers compaction, but /compact now — what users
reach for when the context gauge climbs — hit the usage-error branch.
Accept the 'now' keyword and advertise it in the command usage.

* feat(tui): add /retry, /edit, /copy, /export commands

/retry resends the last prompt; /edit recalls it into the composer to
tweak and resend — both read a new lastPrompt field captured verbatim
(pre-expansion) in launchPrompt. /copy puts the last answer on the
clipboard via the existing copy machinery; /export writes a plain
role-prefixed transcript to a file (timestamped default, or a given
path). Real gaps for SSH users with no mouse select.

* feat(tools): separator-insensitive tool_search matching

tool_search ranked deferred tools by exact substring, so a model that
typed 'webfetch' (dropping the underscore) matched nothing and looped.
Add a separator-squashing fallback ('web_fetch' -> 'webfetch') scored
below an exact substring match, so precise queries still rank first.

* feat(tools): signal ask_user dismissal distinctly from a blank field

Empty answers all rendered as '(no answer provided)', so the model
couldn't tell a wholesale dismissal (user closed the prompt without
answering anything) from one field left blank amid real answers — and
might invent a default. FormatAskUserAnswers now flags a full dismissal
up front as a skip and marks individual empties '(left blank)' vs
'(skipped)'.

* feat(tui): fuzzy ranking in model/session pickers

The pickers filtered by flat strings.Contains with no ranking, so
finding 'sonnet 4.5' among 50+ models meant an exact prefix or scrolling.
Rank matches (exact < prefix < contains < subsequence, reusing
fuzzySubsequenceGap) so the closest match lands on top, and 'snt45' now
matches 'Sonnet 4.5'. Groups stay contiguous — ordered by their best
match, never split — so the grouped model picker still renders one header
per provider. Covers both /model and /resume (shared commandPicker).

* perf(agent): cache per-tool schema render across turns

partitionTools re-ran the recursive schema->map conversion
(schemaToRuntimeMap) for every tool on every turn — redundant, since a
tool's advertised name/description/schema is stable for the run. Add a
per-run definition cache keyed by tool name (nil-safe; the plain
partitionTools entrypoint and tests pass nil for fresh renders).

The partitioning itself (visibility, deferral, ordering) still recomputes
every turn — it must, because a tool's deferred state can flip mid-run
(swarm tools un-defer once a swarm is active). Only the expensive schema
render is memoized. tool_search is excluded by its callers (dynamic
description) so it never poisons the cache.

* perf(agent): calibrate compaction estimate against real prompt tokens

The byte/4 heuristic (ApproxTextTokens) over-counts code-heavy content
~15-20%, so with triggerRatio=0.7 compaction fired at ~60% of true
capacity — premature summarizer calls that degrade quality. Each turn now
folds (rawEstimate, provider InputTokens) into an EMA calibration ratio,
clamped per-sample to a sane band; maybeCompact and the reactive path
scale their estimates by it. Turn 1 uses the raw estimate (no data yet);
later turns compact near real capacity.

* style: gofmt the new agent test files

The CI gofmt check flagged the #22/#25 test files (struct-field and
switch alignment). No behavior change.

* feat(agent): turn-based plan staleness reminder

The stale-plan nudge only fired after 10 tool calls since the last
update_plan, so a plan that drifts stale across many low-tool-call turns
(one call per turn) took many turns to catch — the 'plan set turn 1 stays
pending forever' case. Add a turn-count complement: after
stalePlanTurnThreshold (8) turns without an update AND with items still
pending, fire the same one-shot reminder. Gated on pending items so a
fully-completed plan is never nagged; the existing tool-call trigger is
unchanged. (The roadmap's fuzzy 'tool calls unrelated to plan items'
relevance match is deliberately omitted — too false-positive-prone.)

* feat(agent): preserve recent-edits summary across compaction

Compaction preserved the plan, loaded skills, tool schemas, and project
instructions, but not file edits — so after the editing turns were elided
the model knew it had changed a file only vaguely, and would re-read to
rediscover its own footprint. Extract write_file/edit_file targets from
the elided middle with a one-line note from each tool result, merge them
across repeated compactions (newer note per path wins), and carry them in
the preserved-state JSON (capped at 20 paths / 160 bytes per note).

* feat(tui): workspace-scoped /resume + fewer event reads

/resume listed sessions from every project globally, and read the full
event file for each of them to test for resumable content. Filter the
picker (and /resume latest) by the current workspace Cwd, checked BEFORE
the per-session read so a large global history no longer pays N full file
reads to build one workspace's list; also skip zero-event sessions via
metadata without any read. Sessions with no recorded Cwd (older runs) and
an unknown current workspace stay visible so nothing is hidden that can't
be confidently placed. Explicit /resume <id> still resolves any session.

* feat(tui): warn on model switch that drops staged images

Staged images were silently dropped at submit when the active model had
no vision support — the user only learned after sending. Chips already
render above the composer (renderAttachmentChips); add an immediate amber
warning line to the /model switch status (both same-provider and
cross-provider paths) when images are staged and the newly selected model
can't accept them, so the drop is surfaced at switch time.

* feat(tui): inline recovery affordance on setup errors

A first-run setup error rendered as a bare red 'error: <text>' with no
pointer to the fix. Append a faint, stage-tailored recovery line (edit
the endpoint/name/key above then Enter, or pick another model) that names
only keys the stage's footer confirms, so the guidance is always accurate.
Empty for stages whose footer already makes recovery obvious.

* fix: address CodeRabbit review (5 findings)

- loop.go: recompute the compaction calibration estimate at calibration
  time, not request-build time, so a reactive-compaction reissue that
  shrinks the request feeds the EMA the sample that actually completed.
- errhint: match bare HTTP status codes (401/403/429/529) only at digit
  boundaries, so an incidental number ('4290ms', 'id 14015') isn't
  mis-bucketed as auth/rate-limit.
- bash: drop the stray unmatched ']' in the output-truncation marker.
- tui /retry: apply the same exiting/compactInFlight guards commandPrompt
  has, so a retry can't race compactResultMsg's rewrite of session state.
- tui /export: write the transcript 0o600 (it may contain echoed
  secrets), not world/group-readable 0o644.

* test(tui): skip export file-mode assertion on Windows

os.WriteFile ignores Unix permission bits on Windows and Stat reports
0666, so the 0o600 assertion failed there. The production 0o600 is
honored on Linux/macOS (where it matters); guard the check with
runtime.GOOS so Windows smoke passes.

* fix(tui): align /resume latest filters + case-insensitive workspace match on Windows

Address CodeRabbit review on #21:
- latestResumableInWorkspace now applies the SAME filters as the picker
  (skip zero-event metadata and empty/failed runs), so /resume latest
  can't land on a session the picker intentionally hides.
- sessionMatchesWorkspace compares paths case-insensitively on Windows
  (its filesystem is), so the same workspace spelled with different casing
  no longer hides resumable sessions; other platforms stay case-sensitive.

* fix(tui): /retry resends the last prompt's image/PDF attachments

/retry relaunched only lastPrompt's text, but launchPrompt clears the
pending image/document queues once a turn is sent. A vision- or PDF-backed
prompt that failed would silently retry as text-only and answer a different
task. Snapshot the consumed attachments at launch and re-stage them on
/retry so launchPrompt rebuilds an identical request (document preamble,
images, and the submit-time vision re-check). startNewSession drops the
snapshot so a post-/new /retry cannot leak the previous session's
attachments. Also correct the OAuthResolver factory comment, which omitted
the Gemini provider it is now passed to.

* fix: address jatmn review (reconnect 5xx, /edit attachments, edit-cap, coalescer test)

- reconnect: exclude HTTP 5xx (500/502/503/504) before the transport
  substring match so "504 Gateway Timeout" no longer slips through on the
  generic "timeout" needle. A gateway timeout is non-idempotent — the
  completion POST may already have reached the model — so replaying the
  connect risked duplicate billable work. Reuses a digit-boundary status
  matcher exported from errhint (HasStatusCode). Test asserts 504/500 stay
  non-reconnectable.

- tui /edit: re-stage the remembered image/PDF snapshot alongside the
  recalled prompt text, mirroring the /retry fix — editing a vision- or
  document-backed prompt no longer silently resends a text-only version.

- compaction recent-edits: order edits by LAST edit, not first, and move a
  re-touched path to the newest position on merge, so the maxRecentEdits
  tail cap keeps the file the model most recently edited instead of dropping
  it. Adds mergeRecentEdits (edit-specific) and a capped-list regression.

- coalescer test: inject the frame timer via afterFunc so
  TestCoalescerBatchesDeltas proves buffering deterministically instead of
  racing the real 16ms wall-clock timer.

* fix(tools): bound bash output capture in memory, not just model-visible text

Previously stdout/stderr streamed into unbounded bytes.Buffers and were only
truncated to the 96 KiB budget AFTER the command finished, so a runaway command
(cat huge.log, yes) could grow Zero's memory until it stalled or OOMed before
budgetBashOutput ever ran. Replace the capture with boundedBuffer, an io.Writer
that keeps only the head+tail each stream will surface and discards the middle as
it arrives, while counting the true total for the truncation marker. Memory is now
bounded to ~head+2×tail per stream regardless of output size.

truncateHeadTail is refactored onto a total-aware core so the string path (and its
tests) are unchanged, and budgetBashCapture reports the true raw_bytes even though
only a bounded slice was held. Adds boundedBuffer/capture unit tests and an
end-to-end test that streams ~5× the budget and asserts bounded emission with an
accurate raw_bytes.
2026-07-05 13:15:14 +05:30
Vasanth T 30dd7c3112 fix(config): let a gateway ANTHROPIC_BASE_URL resolve as anthropic-compatible (#497)
Setting ANTHROPIC_BASE_URL to a proxy/gateway failed setup with "anthropic
provider anthropic requires official baseURL https://api.anthropic.com".
OPENAI_BASE_URL already auto-promotes a non-official env URL to the
openai-compatible kind, but Anthropic had no equivalent, so the universal
ANTHROPIC_BASE_URL proxy convention was rejected outright.

Mirror the OpenAI behaviour: a non-official ANTHROPIC_BASE_URL from the
environment promotes the profile to anthropic-compatible. This stays env-only
on purpose -- a custom baseURL in a project config.json is still rejected by
Resolve, so an untrusted project can't redirect a first-party key to another
host (TestResolveRejectsAnthropicOfficialProviderCustomBaseURL still passes).

Fixes #479
2026-07-05 11:39:22 +08:00
Gautam ManchandaniandGautam Manchandani e7d88b4b51 perf: cache TUI model registry (#496)
Co-authored-by: Gautam Manchandani <[email protected]>
2026-07-05 11:38:45 +08:00
euxaristiaandClaude Sonnet 5 177442cfe4 fix(specialist): cap max specialist nesting depth (#491)
* fix(specialist): cap max specialist nesting depth

A Task specialist can recursively spawn further specialists via --depth.
Without a ceiling, a runaway or malicious chain of Task calls can recurse
indefinitely, exhausting process and memory resources. Reject any run whose
depth exceeds a hard cap of 8.

Related to #470, not closing it: today's normal specialist/swarm path
cannot reach this recursively, since child sessions are launched with
--tag specialist and shouldRegisterExecSpecialistTools suppresses the Task
tool for that tag (see PR description for details). This is a
defense-in-depth guard, not a fix for an active exploit. Split out of #468
at reviewer request.

Co-Authored-By: Claude Sonnet 5 <[email protected]>

* fix(specialist): reject depth cap at the boundary, not past it

Addresses jatmn's review on PR #491:
- Executor.Run checked `CurrentDepth > maxSpecialistDepth`, but this call is
  what launches the child at CurrentDepth+1. A parent already AT the cap
  passed the check and started a child one level past it, only getting
  rejected on that child's own next call. Change to `>=` so the guard fires
  before the over-cap child is launched.
- Add TestRunRejectsDepthAtMax to cover the boundary explicitly.

* test(specialist): cover resume boundary in depth-cap guard

The pre-branch depth guard in Run sits before the fresh/resume split, but
the boundary test only exercised the fresh path, leaving a future refactor
free to move the guard without any test catching a resume regression.

---------

Co-authored-by: Claude Sonnet 5 <[email protected]>
2026-07-05 11:38:15 +08:00
euxaristiaandClaude Fable 5 190241bd59 fix(tools): fix cmd.exe /S/C corrupting commands with embedded quotes (#465)
* fix(tools): fix cmd.exe /S/C corrupting commands with embedded quotes

Any shell command whose own text contains embedded double quotes -
python -c "print(15 / 3)", git commit -m "message", node -e "..." - was
silently corrupted on Windows before reaching the target program. Reported
live: a model ran python -c "print(15 / 3)" and got back a SyntaxError
showing the code Python received as literally "print(15, truncated right
before the /.

Root cause: bash.go passes the full command text as ONE argv element to
cmd.exe /d /s /c, and Go's os/exec escapes it correctly for a standard
CommandLineToArgvW-style parser (wrapping it in quotes, backslash-escaping
the embedded ones). But cmd.exe's own /C remainder parsing does not follow
those rules once /S is present: per its documented behavior, it strips the
FIRST and LAST literal quote character in the whole remainder - not a
matched pair - before handing the (now corrupted) text along to be
re-tokenized. The escaping was correct for one layer of parsing; the
command line actually goes through two.

Fix: on Windows, write commandText to a temp .cmd script file and point
/c at the file's path instead of the raw command text. This sidesteps
cmd.exe's /C-remainder heuristic entirely, since the remainder is then
just a plain path with no quotes to mis-strip. @echo off is prepended
since cmd.exe treats a /c target ending in .cmd as a batch file and
echoes each line it runs by default, which would otherwise pollute
captured stdout (caught by the existing bash tool test suite before this
commit, not left in).

buildBashCommand now returns a cleanup func (in addition to plan.Cleanup)
for the temp file. Both callers updated: bash.go's one-shot run defers it
immediately; exec_command.go's persistent session composes it with
startExecProcess's own cleanup and runs it after the session's process
actually exits, not before.

This fix lives upstream of the sandbox wrapping, in how the Name/Args spec
is constructed before either the direct or sandboxed code path consumes
it, so both benefit without separate sandbox-specific changes - cmd.exe's
own command-line parsing does not depend on what token launched it.

New TestBashToolPreservesEmbeddedQuotesOnWindows pins the exact failure
shape via a self-dispatch test helper (a quoted argument containing a
space and a slash, matching the original bug report's truncation point),
with no dependency on python being installed. Full internal/tools suite
green, excluding 3 failures pre-existing and identical on unmodified main
in my environment (unrelated path-assumption issues under a deeply nested
working directory).

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(tools): pass the shell command line to cmd.exe raw instead of via a temp script

Review on the original fix found real problems with running commandText
from a temporary .cmd file: it silently changed cmd.exe command-line
syntax into batch-file syntax (for example, for %i loops need %%i only
inside an actual .bat/.cmd file), /s was still corrupting the script
path itself if it contained spaces, and the script leaked on sandbox
planning errors.

The actual root cause was narrower than the temp-file approach assumed.
cmd.exe's own /C remainder parsing is not CommandLineToArgvW-compatible:
it strips the first and last literal quote from the remainder whenever
it starts with a quote, without undoing backslash-escaping. Passing
commandText as a normal exec.Cmd argument makes Go wrap it in exactly
that shape (quoted because it has spaces, inner quotes escaped as \")
since it contains embedded quotes, which is what the stripping then
corrupts.

Fixes it at the source instead: commandText now reaches cmd.exe
completely unescaped (via SysProcAttr.CmdLine for the unwrapped path,
and the same treatment inside the sandboxed runner's own process
creation for the wrapped path), exactly as if it had been typed at an
interactive prompt. No outer quotes means the stripping heuristic never
triggers for a command like `python -c "print(15 / 3)"`, and a command
that legitimately starts with a quoted executable path is handled by
cmd.exe's own careful quote-preserving special case for that shape.
Also drops /s, which forced the more aggressive of cmd.exe's two
stripping behaviors. No temp file, so nothing to leak on error, and no
batch-file reinterpretation of the command text.

internal/sandbox/windows_runner.go now owns the shared shell-argv shape
(WindowsShellArgs/WindowsShellCommandLine) so internal/tools/bash.go
and the sandboxed runner's windowsCommandLine build the exact same raw
command line instead of duplicating the flag literals in two places.

* chore: retrigger CI

The Windows smoke job failed on TestLoadOrCreateSecretConcurrentConverges in internal/oauth (a lock-file access-denied flake), unrelated to this change. internal/tools and internal/sandbox, the packages this PR touches, both passed. Retriggering to confirm.

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-05 11:36:00 +08:00
euxaristiaandClaude Fable 5 ba6c026469 fix(tools): make grep and glob respect run cancellation (#464)
* fix(tools): make grep and glob respect run cancellation

grep and glob's Run/RunWithSandbox discarded their context parameter
entirely (bound to _) and ran a synchronous filepath.WalkDir with no way
to know it should stop. An unscoped search over a large tree - a broad
parent directory, not just the workspace, which the model can and does
reach for - could run long enough to be effectively unkillable: the TUI's
Ctrl+C/exit path calls the stored CancelFunc and immediately marks the run
"cancelled" in the UI, but the underlying walk kept running regardless,
because nothing in it ever checked ctx.Err(). Worse, the TUI's quit path
explicitly waits for the cancelled run's own response (to drain
flushRunIDs, so /rewind checkpoints stay consistent) before it will
actually exit - so with the walk unable to hear the cancellation, neither
a second Ctrl+C nor /exit could complete. The only way out was killing the
process.

Thread ctx through Run/RunWithSandbox into grepFiles/scanGlob and check
ctx.Err() at the top of each WalkDir callback, ahead of walkErr, so a
cancelled run aborts the walk on its very next entry instead of visiting
every remaining file. Checked the rest of the tools package for the same
_ context.Context pattern: list_directory (single-level os.ReadDir, no
walk), skill, ask_user, and update_plan don't touch the filesystem, so
grep and glob were the only two tools with this gap.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(tools): thread cancellation through grep's match-collection phase

The walk-level fix in grepFiles stopped a cancelled search from visiting
new filesystem entries, but collectGrepMatches still read and
regex-scanned every file the walk had already found, so a search that
had discovered its full file list before cancellation kept running
regardless. Adds the same per-item ctx.Err() check to that phase, plus
a deterministic test that cancels after a fixed number of files instead
of relying on timing.

* test(tools): add mid-walk cancellation coverage and shared helper

Add deterministic mid-walk tests for grepFiles and scanGlob using countingCancelContext, and extract searchCancelledResult so grep/glob share the same cancellation Result mapping.

* style(tools): gofmt search_cancel.go (add missing trailing newline)

Co-Authored-By: Claude Fable 5 <[email protected]>

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-05 11:35:01 +08:00
Pengkuh Dwi Septiandi daf65e0af9 feat: support shift enter for composer newlines (#462) 2026-07-05 11:34:18 +08:00
Anandan f401c66e81 polish: cut per-turn overhead, stabilize cache, add /new (#478)
* polish: cut per-turn overhead, stabilize cache, add /new

Context economy
- Compaction: preserveLast 8->6, triggerRatio 0.8->0.7 (both copies in sync).
- System prompt: kill the "ELABORATE summary" directive; scale summary to
  the work. Comment discipline moved into the core prompt for all model
  families (was Anthropic-only); Minimax/DeepSeek/Qwen/etc. now get it.
- Token-budget regression test ratchets the fixed per-turn overhead (base
  prompt + eager tool schemas) using the runtime's own estimator, so silent
  prompt/schema growth fails CI.

Response quality
- defaultResponseStyle balanced->concise. Reversible via /style.

Tool discovery
- <available_skills> block in the system prompt lists installed skills
  (name + one-line description) so the model loads the right one on the
  first call instead of guessing and reading the failure. Budget-capped at
  640 bytes with an "...and N more" fallback; renders nothing when no
  skills are installed (byte-identical to prior prompt).

Session lifecycle
- /new starts a fresh session in place; previous session stays on disk and
  is named in the confirmation for /resume. Guards against mid-run reset.
- /clear now notes that context persists and points to /new.

Performance & caching
- partitionTools no longer alpha-sorts the whole tools array every turn,
  which inserted a loaded deferred tool mid-array and shifted the cached
  prefix on every load. It now builds append-only regions
  [eager sorted] + [tool_search] + [loaded deferred sorted] so a
  mid-session load grows the tail instead of busting the eager prefix.
  Regression test asserts the eager block precedes any loaded tool.
- /context now shows cache hit rate (flags low rate over >5 turns as a
  possible unstable prefix) and rolling average turn latency with
  time-to-first-token. Both reset on /new.

Measured baselines (auto-mode, no workspace): base prompt 3162 tokens,
eager tool schemas 2433 across 12 tools. Budget ceilings set ~10% above.

Deliberately deferred (need a decision): inverted safety-policy rewrite
(sandbox doesn't catch ~half the B-tier rules), core-tool/web_fetch
deferral (activation-semantics blocker), aggressive prompt slim.

* fix(tui): clear staged attachments + queued message on /new

CodeRabbit: startNewSession reset conversation/usage/latency state but not
the staged input (pendingImages, pendingImageLabels, pendingDocuments,
queuedMessage), which is only consumed at prompt-submit. Without clearing it,
the fresh session's first prompt silently inherited the previous session's
images/documents/queued text. Test now stages all four and asserts they clear.
2026-07-04 20:56:52 +08:00
Pengkuh Dwi Septiandi 949ee43f71 fix: preserve conversation context in exec prompts (#460)
* fix: preserve conversation context in exec prompts

* fix: remove redundant

* test: cover exec prompt
2026-07-04 08:53:47 +08:00
Ashwinhegde19 2fcea71778 feat(tui): add search/filter to provider picker in setup wizard (#400)
* feat(tui): add search/filter to provider picker in setup wizard

Add a search input to the provider step of the setup wizard, matching
the existing model picker search UX. Typing filters the provider list
in real-time via case-insensitive substring matching on name, ID, and
aliases.

Fixes #392

* fix(tui): resolve provider index before clearing search on advance

Clearing providerSearch before using currentProvider() caused the wrong
provider to be selected — selectedProvider is an index into the filtered
slice, and clearing the search swaps to the full unfiltered list.

Also update placeholder to reflect that search matches ID and aliases.

* fix(tui): guard empty-filter Enter and add provider search tests

- Enter on an empty provider filter is now a no-op instead of advancing
  with the wrong provider (first full-list entry)
- Add table tests for filteredProviders/name/ID/alias matching
- Add tests for selectedProvider reset on edit, empty-match noop,
  Ctrl+U clear restores full list, and filtered advance selection

Addresses review feedback from @Vasanthdev2004 and @gnanam1990 on PR #400
2026-07-04 08:53:16 +08:00
PierrunoYTandVasanthdev2004 5f36349c18 feat(update): add zero upgrade command to apply self-updates (#461)
* feat(update): add zero upgrade command to apply self-updates

Extends the existing `zero update --check` (which only reported an
available release) with an actual install step:

- `zero update --apply` / `zero upgrade` download, sha256-verify, and
  install the latest release.
- npm installs delegate to `npm install -g @gitlawb/zero@latest`.
- Standalone installs download the release archive, verify its
  checksum, extract it (zip-slip guarded), and atomically replace the
  running binary plus any pre-existing optional sandbox helpers. On
  Windows, the running exe is renamed aside (NTFS allows this even
  while it's executing) since it can't be overwritten directly; the
  leftover ".old" file is cleaned up on a later invocation.

Closes #459.

* fix(update): correct macOS expectations in apply test

The optional-helper assertion in TestApplyStandaloneUpdateReplacesBinary
assumed every non-Windows platform has a linux-style "zero-seccomp"
helper to refresh, but applyStandaloneUpdate correctly ships no optional
helpers on macOS (matching scripts/postinstall.mjs). This made the test
fail on macOS CI. Only assert the helper refresh on linux/windows.

* fix(update): address CodeRabbit review findings on zero upgrade

- Reject --target when combined with --apply/upgrade: applying installs
  onto the current machine, so a mismatched --target could replace the
  running binary with an incompatible-arch build.
- Bound archive/checksum downloads with a dedicated timeout separate
  from the release-metadata check timeout, so a stalled connection
  can't hang zero upgrade forever.
- Surface a failed rollback in the Windows replaceBinary path instead
  of swallowing it, so a botched install is reported rather than
  silently leaving the binary missing.
- Run CleanupStaleBinary regardless of whether an update is available,
  so a leftover Windows ".old" file isn't stuck waiting for a future
  upgrade that may never come.
- Report optional sandbox-helper refresh failures as warnings on
  ApplyResult (surfaced in text and JSON output) instead of discarding
  them silently.

* fix(oauth): retry secret-lock on Windows delete-pending race to unblock CI

TestLoadOrCreateSecretConcurrentConverges was failing this PR's Windows
smoke job with "create token secret lock: ...Access is denied." — a
pre-existing flake in internal/oauth, not in the upgrade code. On Windows
a concurrent holder's os.Remove leaves the .lock in a delete-pending
state, so an O_EXCL create races it with ERROR_ACCESS_DENIED
(os.ErrPermission) rather than ErrExist; createSecretFile only treated
ErrExist as contention and hard-failed otherwise.

Treat os.ErrPermission as retryable too, mirroring acquireFileLock in
lock.go. This is the same root-cause fix as #445 (diagnosis by
@gnanam1990); folded in here to make this PR's CI green. Verified on
Windows: the concurrent-converge test passes 30/30.

---------

Co-authored-by: Vasanthdev2004 <[email protected]>
2026-07-04 08:52:07 +08:00
euxaristiaandClaude Fable 5 e4f760ee8b fix(agent): reject a malformed additional_permissions payload before prompting (#453)
* fix(agent): reject a malformed additional_permissions payload before prompting

A shell tool call that sets sandbox_permissions: with_additional_permissions
but omits (or malforms) the additional_permissions payload was accepted into
the normal permission-prompt flow. buildPermissionEvent silently swallowed
the validation error while rendering the prompt's scope text, so the user
saw an ordinary-looking request, approved it, and only then hit a hard
denial from the same validation running again at grant time (loop.go's
grantInlineAdditionalPermissions), with the confusing message "failed to
grant session additional permissions: missing additional_permissions".

A malformed payload can never succeed regardless of the user's decision, so
run the same check (inlineAdditionalPermissionsProfile, already the single
source of truth for this shape) up front, before any prompt, and return a
plain tool error the driving model can see and retry from. This also covers
the mismatched case (additional_permissions present without the flag).

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(agent): make the additional_permissions rejection message actionable

The rejection added in the previous commit correctly stops a malformed
sandbox_permissions: with_additional_permissions call before any prompt,
but its message ("missing additional_permissions; provide at least one of
network or file_system") gave a model nothing concrete to correct itself
with. Observed in practice: a weaker tool-calling model (xai/grok-build-0.1)
kept re-attaching sandbox_permissions out of habit from an earlier, correct
gh/git call onto later commands that didn't need it (network was already
open via a session-scoped grant), hit this same rejection 4 times in a row
with no change in its retry, and gave up.

Both messages (missing payload, and empty-but-present payload) now include
a concrete, schema-accurate example - {"network": {"enabled": true}} or
{"file_system": {"write": ["/path"]}} - and the missing-payload message
adds the other half of the fix: if the command doesn't actually need
elevated permissions, omit sandbox_permissions entirely and retry.

Co-Authored-By: Claude Fable 5 <[email protected]>

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-04 08:51:14 +08:00
euxaristiaandClaude Fable 5 068c5ad3ad docs(install): recommend bun pm trust for Bun installs (#452)
The Bun instructions told global installers to run the postinstall script
manually against the global install path, because trustedDependencies only
works in a project package.json. Current Bun has a simpler sanctioned path
that works for both project and global installs: `bun pm trust
@gitlawb/zero` (with -g for globals) runs the blocked postinstall after the
fact. Verified on Bun 1.3.14 on Windows 11: `bun pm -g trust @gitlawb/zero`
fetched the binary and `zero --version` runs.

Lead with that in README.md, README_ZH.md, and docs/INSTALL.md, keep
trustedDependencies and the manual installer as alternatives for project
installs and older Bun versions, and teach the wrapper's missing-binary
message the same commands for the Bun case.

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-04 08:45:49 +08:00
Gautam ManchandaniandGautam Manchandani 407a92739f fix(action): keep provider key scoped to zero step (#448)
Co-authored-by: Gautam Manchandani <[email protected]>
2026-07-04 08:41:38 +08:00
KRATOS d05e9148a7 fix(oauth): treat Windows ERROR_ACCESS_DENIED as lock contention in createSecretFile (#445)
createSecretFile's secret-lock loop only retried on os.ErrExist. On Windows a
concurrent holder's `defer os.Remove(lockPath)` leaves the lock file in a
"delete pending" state, so a racer's O_EXCL create returns ERROR_ACCESS_DENIED
(os.ErrPermission) rather than ErrExist — which fell through to a hard
"create token secret lock: Access is denied" error instead of retrying. Mirrors
the identical handling already present in acquireFileLock (lock.go, from #261).

Fixes the flaky Windows-CI failure TestLoadOrCreateSecretConcurrentConverges
that was intermittently red-flagging every PR's Windows smoke job.

Verified on macOS: build/vet/gofmt clean, oauth package green, concurrent-
converge test passes under -race (5x). The Windows delete-pending path can't be
reproduced off Windows, so the definitive proof is the Windows CI on this PR.
2026-07-04 08:40:20 +08:00
Anandan 1d8638466c fix: allow non-TLS connections to private-network provider endpoints (#444)
* fix: allow non-TLS connections to private-network provider endpoints

The provider wizard and health probe rejected `http://` endpoints with a
non-loopback host, so users could not configure a local-network provider
like llama.cpp on another LAN machine (e.g. `http://192.168.1.100:8080/v1`).
Only loopback was relaxed.

Expand the "loopback-only" checks to also cover private-network addresses
(192.168.0.0/16, 10.0.0.0/8, 172.16.0.0/12) via `netip.Addr.IsPrivate()`:

- `providerhealth.endpointIsLoopback` → `endpointIsLoopbackOrPrivate`
  (also returns true for private IP literals).
- `providerhealth.allowLoopback` → `allowLoopbackOrPrivate` — the flag
  now relaxes both loopback and private addresses in `validateEndpoint`
  and `safeDialContext`.
- `providerWizardIsLoopbackHost` in the TUI wizard now accepts private
  IPs, so `http://192.168.x.y` passes the endpoint URL validation.
- `providerProfileIsLocal` in headless setup recognizes private-network
  IPs as local (skip API key for keyless LAN providers).
- `baseURLIsLoopback` in app.go recognizes private-network IPs for
  credential-requirement logic.

Link-local (169.254.x.x), cloud metadata, and other special ranges
remain blocked. Redirects to any local/private address remain blocked
regardless of the flag (SSRF protection for 3xx targets is unchanged).

Fixes #431

* chore: fix gofmt indentation in connectivity client test
2026-07-04 08:35:14 +08:00
Anandan 305a62c954 fix(auth): route zero auth login chatgpt to the dedicated ChatGPT flow (#443)
* fix(auth): route `zero auth login chatgpt` to the dedicated ChatGPT flow

`zero auth login chatgpt` went through the generic OAuth manager loopback
path, which builds a random-port http://127.0.0.1:<port>/callback
redirect_uri with no extra authorize params. OpenAI's ChatGPT client
registration requires exactly http://localhost:1455/auth/callback plus the
id_token_add_organizations, codex_cli_simplified_flow, and originator
params, so the authorize endpoint rejected the request with an
"Authentication Error" before any browser interaction.

Route the chatgpt provider to the existing dedicated ChatGPT login
(provideroauth.ChatGPTLogin), which already binds the fixed port, uses the
localhost host, the /auth/callback path, and sends the required params.
`zero auth login chatgpt` now behaves identically to `zero auth chatgpt`.
--device is rejected with a clear message since ChatGPT has no device-code
path.

Fixes #430

* fix(auth): reject --scope on ChatGPT login instead of silently dropping it

`zero auth login chatgpt --scope <x>` reached the dedicated ChatGPT flow,
which ignores the generic manager's ExtraScopes — the scopes were silently
dropped while --device was explicitly rejected. Reject --scope up front
with a clear message, since the Codex client registration pins a fixed
scope set (incl. api.connectors.*) that custom scopes would risk dropping.

Addresses CodeRabbit review on #443.
2026-07-04 08:34:30 +08:00
Vasanth T 9134148f4d fix(tui): keep the profile name on /model switch so the stored key resolves (#441)
normalizeProfileForProvider rewrote profile.Name to the catalog provider.ID
whenever normalizeIdentity was true — which includes any first-party provider
whose saved baseURL matches the catalog default. The credential store is keyed
by profile.Name, and this rewrite runs before profileWithCredential, so the
stored-key lookup missed and the rebuilt provider went out with no key: a 401 on
every /model switch for a profile named differently from its catalog id (e.g.
"my-openai").

Only canonicalize an empty or generic placeholder name now; a real user-provided
name is preserved. CatalogID canonicalization is unchanged (it is not the store
key). Adds regression tests covering a custom-named first-party profile surviving
normalization and placeholder names still canonicalizing.

Fixes #440
2026-07-04 08:27:41 +08:00
euxaristiaandClaude Fable 5 f653dcac36 fix(sandbox): gate /tmp test assertions on GOOS, not path existence (#426)
* fix(sandbox): gate /tmp test assertions on GOOS, not path existence

TestPermissionProfileFromPolicyIncludesDefaultTempWriteRoots and
TestNewScopeNormalizesAndValidatesExtraRoots guard their /tmp assertion with
pathExists("/tmp"), intending to skip it on Windows. But Go resolves the bare
path /tmp against the current drive on Windows, so on any host where C:\tmp
exists the guard passes while the product code (correctly, by design - see
defaultTempWriteRootCandidatesForGOOS) uses %TEMP%/%TMP% instead, and both
tests fail. Gate the assertion on runtime.GOOS != "windows" as well; POSIX
behavior is unchanged.

Co-Authored-By: Claude Fable 5 <[email protected]>

* chore: retrigger CI

The Windows smoke job failed on TestLoadOrCreateSecretConcurrentConverges in internal/oauth, unrelated to this change. Retriggering to confirm it was a flake.

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-04 08:26:22 +08:00
Eli Lap 071ea3a495 Make shortcuts reconfigurable via the config file. (#417)
* Make shortcuts reconfigurable via the config file.

* Use sidebarToggleAllowed. Fix fmt errors.

* Fix: combination matching superset.

* Remove non-default shortcut combination from the comment.
2026-07-04 08:25:33 +08:00
Kunj SHAH b0abde7d06 feat: add --auto flag for LLM-generated commit messages (#423)
* feat: add --auto flag to changes commit for LLM-generated commit messages

* fix: address CodeRabbit review suggestions on auto-commit message generation

* fix: resolve auto-commit message and flag validation tests

* fix: add 60s timeout for LLM call in --auto and surface --dry-run in help

Addresses gnanam1990 non-blocking feedback:
- Wrap provider call with context.WithTimeout (60s) to prevent hung provider
- Note --dry-run in --auto help text as preview escape hatch
2026-07-04 08:10:18 +08:00
PierrunoYTandClaude Sonnet 5 5658a36627 fix(tools): flag piped POSIX utilities before running on Windows (#412)
* fix(tools): flag piped POSIX utilities (head/tail/grep/...) before running on Windows

Zero always executes the bash tool through native cmd.exe on Windows
(shellExecutable() in bash.go), but the pre-flight Windows-syntax
detector only caught bash-style `cd /...` and bare `ls`. Anything
piped into head/tail/grep/wc/awk/sed/cut/xargs/tr — very common in
model-generated commands like `git log ... | head` — ran straight
through to cmd.exe, which has no such builtins, wasting a turn on a
guaranteed failure. Reported in the wild via `git log ... | head`
and a `git blame ... | grep` pipeline.

detectShellCommandIssue now also flags these before the command
runs, with the same "translate to cmd.exe / use Zero's native tools"
guidance already used for the cd/ls cases.

Note: the existing detectShellOutputIssue path (matching cmd.exe's
error text post-execution) only recognizes English strings and will
miss this on non-English Windows locales — the pre-flight check
above is locale-independent since it inspects the model-generated
command text rather than cmd.exe's translated error output.

Co-Authored-By: Claude Sonnet 5 <[email protected]>

* fix(tools): require whitespace/end after POSIX utility name, not just \b

CodeRabbit review on PR #412: windowsPosixUtilityPattern used \b after
the utility alternation, but \b only requires a non-word character
next — so tail-log.sh, grep-cli, and sed.exe would all false-positive
(the hyphen/dot satisfies \b without being a real command boundary).
Match windowsLSCommandPattern's existing convention: require
whitespace or end-of-string after the utility name instead.

Co-Authored-By: Claude Sonnet 5 <[email protected]>

---------

Co-authored-by: Claude Sonnet 5 <[email protected]>
2026-07-04 08:09:22 +08:00
c60ad8729f fix(config): fall back to a usable saved provider instead of forcing full re-onboarding (#410)
When config.json has providers configured but none marked active
(e.g. a blank/stale activeProvider field, or a config with 2+
providers where none was ever selected), normalizeProviders threw
away the already-normalized provider list on ErrNoActiveProvider.
That meant the interactive TUI's existing "fall back to an
already-configured usable provider" logic (firstUsableProvider)
never got a chance to run for this case — the app instead wiped
resolved to a zero value and forced the user into a brand-new
onboarding wizard, even though a usable, already-saved provider
existed.

- normalizeProviders/Resolve now return the normalized providers
  list alongside ErrNoActiveProvider instead of discarding it.
- runInteractiveTUIWithSetup tries firstUsableProvider on that list
  before forcing setup; onboarding only runs when no configured
  provider is actually usable, matching the existing fallback
  already used for a different flavor of this problem
  (active-provider-present-but-no-credential).

Co-authored-by: Claude Sonnet 5 <[email protected]>
Co-authored-by: Vasanth T <[email protected]>
2026-07-04 08:08:55 +08:00
euxaristiaandClaude Fable 5 0562e3bef7 fix(tui): show an M suffix for million-scale token counts (#457)
humanCount (the status-line/sidebar token formatter) only ever produced a K
suffix, so a 1,000,000-token context window rendered as "1000K" instead of
"1M" - visible with any 1M-context model (e.g. xai/grok-build-0.1). Its
sibling formatContextWindow already switches to "M" above a million; extend
the same behavior to humanCount, generalized for arbitrary (non-round)
values rather than formatContextWindow's round-number-only heuristic, since
humanCount also renders live, arbitrary "used" token counts.

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-03 22:50:44 +08:00
Anandan 9bd93c62f8 fix: add android platform support for Termux npm install (#455)
`npm install -g @gitlawb/zero` failed on Android/Termux with EBADPLATFORM
because `package.json` `os` array lacked `"android"`. Termux provides a
Linux userspace — the existing `linux-arm64` binary works natively.

- Add `"android"` to the `os` array in package.json.
- Map `android` platform to `linux` in postinstall.mjs resolvePlatform().

Fixes #449
2026-07-03 22:44:47 +08:00
PierrunoYTandClaude Sonnet 5 bdb1b0ecd1 fix(install): persist install dir to user PATH on Windows (#407)
* fix(install): persist install dir to user PATH on Windows

The PowerShell installer only printed a suggestion to add the
install directory to PATH but never actually did it, so `zero`
was unusable from a fresh terminal after install.

Co-Authored-By: Claude Sonnet 5 <[email protected]>

* fix(install): don't abort install if PATH persistence fails

Address CodeRabbit review on PR #407:
- Wrap the [Environment]::SetEnvironmentVariable call in try/catch
  so a PATH-persistence failure (e.g. restricted user profile) warns
  instead of aborting the whole install under $ErrorActionPreference
  = Stop.
- Factor the repeated PATH-contains check for user/session scopes
  into a Test-ZeroPathContainsDir helper.

Co-Authored-By: Claude Sonnet 5 <[email protected]>

---------

Co-authored-by: Claude Sonnet 5 <[email protected]>
2026-07-03 20:10:01 +08:00
Leonardo Faoro 4266baf222 fix(docs): rename AGENTS.MD > AGENTS.md (#438)
* fix(docs): rename AGENTS.MD > AGENTS.md

* fix(docs): fix references to AGENTS.MD > AGENTS.md
2026-07-03 19:40:50 +08:00
euxaristiaandClaude Fable 5 8d0c5feccb fix(sandbox): self-heal a corrupt unelevated setup marker (#437)
A malformed/truncated windows-unelevated-setup.json returned a hard error
from loadWindowsUnelevatedSetupMarker, bricking every unelevated command
until the file was hand-deleted, while the unknown-schema path already
reset to an empty marker. Treat corrupt JSON the same way: the marker only
memoizes idempotent ACL applies, so a reset can only cost redundant work,
never skip enforcement.

Addresses the review feedback from coderabbitai and @anandh8x on #427.

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-03 19:34:56 +08:00
178 changed files with 10648 additions and 678 deletions

No files matched your search

+17 -1
View File
@@ -4,12 +4,14 @@ on:
pull_request:
paths:
- action.yml
- docs/GITHUB_ACTION.md
- .github/workflows/zero-action-smoke.yml
push:
branches:
- main
paths:
- action.yml
- docs/GITHUB_ACTION.md
- .github/workflows/zero-action-smoke.yml
jobs:
@@ -29,7 +31,8 @@ jobs:
import sys, yaml
with open("action.yml", "r", encoding="utf-8") as handle:
doc = yaml.safe_load(handle)
action_raw = handle.read()
doc = yaml.safe_load(action_raw)
assert isinstance(doc, dict), "action.yml must be a mapping"
assert doc.get("name"), "action must declare a name"
@@ -65,6 +68,19 @@ jobs:
assert "--skip-permissions-unsafe" not in serialized, \
"action must never pass --skip-permissions-unsafe"
run_zero = next((step for step in steps if step.get("name") == "Run ZERO"), None)
assert run_zero is not None, "action.yml must contain a Run ZERO step"
run_zero_script = run_zero.get("run") or ""
assert 'export "${ZERO_INPUT_API_KEY_ENV}=${ZERO_INPUT_API_KEY}"' in run_zero_script, \
"Run ZERO must export the provider key for the current step"
assert "later steps inherit" not in action_raw.lower(), \
"provider keys must not be documented as inherited by later steps"
for line in run_zero_script.splitlines():
if "GITHUB_ENV" in line and (
"ZERO_INPUT_API_KEY" in line or "ZERO_INPUT_API_KEY_ENV" in line
):
raise AssertionError("provider key must not be persisted to GITHUB_ENV")
print(f"action.yml OK: {len(inputs)} inputs, {len(outputs)} outputs, {len(steps)} steps")
PY
+1 -1
View File
@@ -26,7 +26,7 @@ Accepted file names, in priority order at each level:
| `./ZERO.md` | Brand-specific alias. Same format, lower priority. |
| `./.zero/AGENTS.md` | Project-local, hidden, gitignored. Personal notes that stay out of git. |
Matching is **case-insensitive** on the basename, so `AGENTS.md`, `Agents.md`, and `agents.md` resolve to the same file on Windows and macOS. The git-tracked filename in this repo is `AGENTS.MD` (uppercase `MD`) — keep that on case-sensitive filesystems (Linux, the WSL filesystem, or a CI runner) to match what the loader looks for.
Matching is **case-insensitive** on the basename, so `AGENTS.md`, `Agents.md`, and `agents.md` resolve to the same file on Windows and macOS. The git-tracked filename in this repo is `AGENTS.md` — keep that on case-sensitive filesystems (Linux, the WSL filesystem, or a CI runner) to match what the loader looks for.
Both files use the same format. YAML frontmatter is optional; the markdown body is loaded as instructions for the agent. Zero reads the file once at session start, so changes take effect on the next `zero` launch — not mid-session.
+1 -1
View File
@@ -1,4 +1,4 @@
# Zero build/test/lint targets. AGENTS.MD says "Build with `make`" and "Run `make
# Zero build/test/lint targets. AGENTS.md says "Build with `make`" and "Run `make
# lint` before opening a PR" — these targets back those instructions.
.DEFAULT_GOAL := build
.PHONY: build build-all test test-race vet fmt fmt-check lint tidy clean help
+12 -15
View File
@@ -59,20 +59,24 @@ Bun does not run dependency lifecycle scripts by default, so the `postinstall`
that fetches the Zero binary is skipped and the first run fails with
`No native binary found next to the npm wrapper`.
Install with Bun, then fetch the binary in one of two ways:
The simplest fix is to trust the package after installing, which runs the
blocked postinstall. This works for project and global installs:
```bash
# Option A: run the installer manually
# project install
bun add @gitlawb/zero
node node_modules/@gitlawb/zero/scripts/postinstall.mjs
bun pm trust @gitlawb/zero
# Option B: allow the postinstall to run on install
# add to your package.json: "trustedDependencies": ["@gitlawb/zero"]
bun add @gitlawb/zero
# global install
bun add -g @gitlawb/zero
bun pm -g trust @gitlawb/zero
```
For global installs (`bun add -g @gitlawb/zero`), use Option A against the
global install path, or prefer the install scripts below.
Alternatives: allow the postinstall up front by adding
`"trustedDependencies": ["@gitlawb/zero"]` to your project's package.json
before `bun add`, or run the installer manually
(`node node_modules/@gitlawb/zero/scripts/postinstall.mjs`) on Bun versions
that do not have `bun pm trust`.
### Install scripts
@@ -144,16 +148,9 @@ the key in the wizard:
export OPENAI_API_KEY=sk-...
export ANTHROPIC_API_KEY=...
export GEMINI_API_KEY=...
export AIMLAPI_API_KEY=...
export LONGCAT_API_KEY=...
```
To configure AI/ML API directly, run:
```bash
zero providers setup aimlapi --set-active
```
To configure Meituan LongCat (LongCat-2.0) directly, run:
```bash
+7 -7
View File
@@ -44,19 +44,19 @@ npm 包安装一个小型包装器以及与你平台匹配的 Zero 二进制文
Bun 默认不运行依赖的生命周期脚本,因此获取 Zero 二进制文件的 `postinstall` 会被跳过,首次运行会失败并显示 `No native binary found next to the npm wrapper`。
使用 Bun 安装后,通过以下两种方式之一获取二进制文件:
最简单的解决方法是安装后信任该包,这会运行被阻止的 postinstall。项目安装和全局安装均适用:
```bash
# 选项 A:手动运行安装程序
# 项目安装
bun add @gitlawb/zero
node node_modules/@gitlawb/zero/scripts/postinstall.mjs
bun pm trust @gitlawb/zero
# 选项 B:允许 postinstall 在安装时运行
# 添加到你的 package.json: "trustedDependencies": ["@gitlawb/zero"]
bun add @gitlawb/zero
# 全局安装
bun add -g @gitlawb/zero
bun pm -g trust @gitlawb/zero
```
对于全局安装(`bun add -g @gitlawb/zero`),请对全局安装路径使用选项 A,或使用下面的安装脚本。
其他方式:在 `bun add` 之前将 `"trustedDependencies": ["@gitlawb/zero"]` 添加到项目的 package.json 以提前允许 postinstall;或在不支持 `bun pm trust` 的旧版 Bun 上手动运行安装程序(`node node_modules/@gitlawb/zero/scripts/postinstall.mjs`)。
### 安装脚本
+4 -5
View File
@@ -27,14 +27,14 @@ inputs:
api-key:
description: >-
The provider API key. Pass it from a repository or organization secret;
it is exported into the step environment and never written to the log.
it is exported only for the ZERO step and never written to the log.
required: false
default: ""
api-key-env:
description: >-
Name of the environment variable the chosen provider reads its key from
(for example OPENAI_API_KEY or ANTHROPIC_API_KEY). When set together with
api-key, that variable is exported for the run. Leave the action
api-key, that variable is exported only for the ZERO step. Leave the action
provider-agnostic by passing the env name your provider expects rather
than hardcoding one here.
required: false
@@ -192,10 +192,9 @@ runs:
echo "::error::api-key-env must be a valid environment variable name." >&2
exit 2
fi
# Export in-process so the zero invocation in this same run block sees
# it, and append to GITHUB_ENV so any later steps inherit it too.
# Export in-process for this Run ZERO step so zero can read it; do
# not persist provider credentials to later job steps.
export "${ZERO_INPUT_API_KEY_ENV}=${ZERO_INPUT_API_KEY}"
printf '%s=%s\n' "${ZERO_INPUT_API_KEY_ENV}" "${ZERO_INPUT_API_KEY}" >> "${GITHUB_ENV}"
fi
if [ -n "${ZERO_INPUT_PROVIDER}" ]; then
export ZERO_PROVIDER="${ZERO_INPUT_PROVIDER}"
+5 -3
View File
@@ -66,10 +66,12 @@ if (!existsSync(nativePath)) {
'\n' +
(ranByBun
? 'You installed with Bun, which does not run dependency lifecycle scripts\n' +
'by default. To let it run on future installs, add zero to your\n' +
'project\'s trustedDependencies:\n' +
'by default. Trust the package to run the blocked postinstall:\n' +
' bun pm trust @gitlawb/zero (project install)\n' +
' bun pm -g trust @gitlawb/zero (global install)\n' +
'On Bun versions without `bun pm trust`, add\n' +
' "trustedDependencies": ["@gitlawb/zero"]\n' +
'then reinstall.\n' +
'to your project package.json and reinstall.\n' +
'\n'
: '') +
'If that fails, build from source: https://github.com/Gitlawb/zero\n' +
+9 -7
View File
@@ -41,8 +41,9 @@ jobs:
> provider-neutral: `api-key-env` is the environment variable name the chosen
> provider reads its key from (for example `ANTHROPIC_API_KEY`, `GEMINI_API_KEY`,
> `OPENROUTER_API_KEY`), and `api-key` is the secret value. The action exports
> that variable for the run and never prints it. If your repository already
> commits a `.zero/config.json` with an active provider, you can omit `provider`.
> that variable only for the ZERO step and never prints it. If your repository
> already commits a `.zero/config.json` with an active provider, you can omit
> `provider`.
## Inputs
@@ -51,8 +52,8 @@ jobs:
| `prompt` | one of `prompt`/`prompt-file` | `""` | The instruction for ZERO to execute. |
| `prompt-file` | one of `prompt`/`prompt-file` | `""` | Path (relative to `working-directory`) to a file whose contents are the prompt. |
| `provider` | no | `""` | Provider id to activate (e.g. `openai`, `anthropic`, `gemini`, `ollama`, or any compatible endpoint). |
| `api-key` | no | `""` | The provider API key. Pass from a secret; never logged. |
| `api-key-env` | no | `""` | Env var name the provider reads its key from (e.g. `OPENAI_API_KEY`). Exported with `api-key`. |
| `api-key` | no | `""` | The provider API key. Pass from a secret; exported only for the ZERO step and never logged. |
| `api-key-env` | no | `""` | Env var name the provider reads its key from (e.g. `OPENAI_API_KEY`). Exported only for the ZERO step when used with `api-key`. |
| `model` | no | `""` | Model id. Defaults to the resolved provider's default. |
| `mode` | no | `""` | Run mode (`zero exec --mode`), e.g. `smart`, `deep`, `fast`. |
| `auto` | no | `low` | Autonomy ceiling (`zero exec --auto`): `low`, `medium`, or `high`. Conservative by default. |
@@ -162,9 +163,10 @@ jobs:
## Security notes
- **Secrets are passed as Action secrets and never logged.** The action exports
`api-key` (under `api-key-env`) and `slack-webhook-url` into the step
environment only. They are never echoed, and ZERO redacts secret-shaped
strings from its own output.
`api-key` (under `api-key-env`) only for the ZERO step. `slack-webhook-url`
is passed only to the Slack post step. They are never echoed or persisted to
later workflow steps, and ZERO redacts secret-shaped strings from its own
output.
- **The sandbox is always active.** This action never passes
`--skip-permissions-unsafe`, so writes stay inside the checked-out repository
(plus any roots you grant with `add-dir`). Unsafe mode is never enabled
+19 -6
View File
@@ -31,15 +31,24 @@ dependencies (only the installing project's own scripts), so the `postinstall`
that fetches the Zero binary is silently skipped. The first run then fails with
`No native binary found next to the npm wrapper`.
To install with Bun, either run the installer manually after installing:
The simplest fix is to trust the package after installing, which runs the
blocked postinstall. This works for project and global installs:
```bash
# project install
bun add @gitlawb/zero
node node_modules/@gitlawb/zero/scripts/postinstall.mjs
bun pm trust @gitlawb/zero
# global install
bun add -g @gitlawb/zero
bun pm -g trust @gitlawb/zero
```
Or allow the postinstall to run by adding the package to your project's
`trustedDependencies` before installing:
`bun pm untrusted` (or `bun pm -g untrusted`) lists the blocked postinstalls if
you want to inspect before trusting.
Alternatively, allow the postinstall to run at install time by adding the
package to your project's `trustedDependencies` before installing:
```json
{
@@ -51,8 +60,12 @@ Or allow the postinstall to run by adding the package to your project's
bun add @gitlawb/zero
```
For global installs (`bun add -g @gitlawb/zero`), run the installer manually
against the global install path, or use the install scripts below.
On Bun versions that do not have `bun pm trust`, run the installer manually
after installing:
```bash
node node_modules/@gitlawb/zero/scripts/postinstall.mjs
```
Reference: <https://bun.sh/docs/pm/lifecycle>
@@ -0,0 +1,146 @@
package agent
import (
"context"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/tools"
)
// A shell call that sets sandbox_permissions: with_additional_permissions but
// omits (or malforms) additional_permissions can never succeed: the same
// validation runs again at grant time regardless of the user's decision. It
// must be rejected as a plain tool error BEFORE any permission prompt, not
// surfaced as a confusing "approved but denied" prompt outcome.
func TestExecuteToolCallRejectsMissingAdditionalPermissionsBeforePrompt(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewBashTool(root))
promptCalls := 0
result, err := executeToolCall(
context.Background(),
registry,
ToolCall{
ID: "c1",
Name: "bash",
Arguments: `{"command":"echo hi","sandbox_permissions":"with_additional_permissions"}`,
},
PermissionModeAuto,
Options{
OnPermissionRequest: func(ctx context.Context, request PermissionRequest) (PermissionDecision, error) {
promptCalls++
return PermissionDecision{Action: PermissionDecisionAllow}, nil
},
},
)
if err != nil {
t.Fatalf("executeToolCall: %v", err)
}
if result.Status != tools.StatusError {
t.Fatalf("status = %q, want error", result.Status)
}
if promptCalls != 0 {
t.Fatalf("OnPermissionRequest called %d times, want 0 (must reject before prompting)", promptCalls)
}
}
// The mismatched-flag case (additional_permissions present without the
// sandbox_permissions flag) must be rejected the same way.
func TestExecuteToolCallRejectsAdditionalPermissionsWithoutFlagBeforePrompt(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewBashTool(root))
promptCalls := 0
result, err := executeToolCall(
context.Background(),
registry,
ToolCall{
ID: "c1",
Name: "bash",
Arguments: `{"command":"echo hi","additional_permissions":{"network":{"enabled":true}}}`,
},
PermissionModeAuto,
Options{
OnPermissionRequest: func(ctx context.Context, request PermissionRequest) (PermissionDecision, error) {
promptCalls++
return PermissionDecision{Action: PermissionDecisionAllow}, nil
},
},
)
if err != nil {
t.Fatalf("executeToolCall: %v", err)
}
if result.Status != tools.StatusError {
t.Fatalf("status = %q, want error", result.Status)
}
if promptCalls != 0 {
t.Fatalf("OnPermissionRequest called %d times, want 0 (must reject before prompting)", promptCalls)
}
}
// The rejection message must show a concrete, correctly-shaped example: a
// model that gets this wrong once (attaching sandbox_permissions out of habit
// from an earlier call, without a valid payload) needs enough in the error to
// self-correct on retry instead of repeating the identical mistake.
func TestExecuteToolCallMissingAdditionalPermissionsErrorIsActionable(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewBashTool(root))
result, err := executeToolCall(
context.Background(),
registry,
ToolCall{
ID: "c1",
Name: "bash",
Arguments: `{"command":"ping example.com","sandbox_permissions":"with_additional_permissions"}`,
},
PermissionModeAuto,
Options{},
)
if err != nil {
t.Fatalf("executeToolCall: %v", err)
}
if !strings.Contains(result.Output, `{"network": {"enabled": true}}`) {
t.Fatalf("output = %q, want a concrete additional_permissions example", result.Output)
}
if !strings.Contains(result.Output, "omit sandbox_permissions") {
t.Fatalf("output = %q, want guidance that the flag can simply be omitted", result.Output)
}
}
// A well-formed additional_permissions payload must still reach the normal
// permission prompt: this fix only rejects malformed payloads.
func TestExecuteToolCallStillPromptsForValidAdditionalPermissions(t *testing.T) {
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewBashTool(root))
promptCalls := 0
_, err := executeToolCall(
context.Background(),
registry,
ToolCall{
ID: "c1",
Name: "bash",
Arguments: `{"command":"echo hi","sandbox_permissions":"with_additional_permissions",` +
`"additional_permissions":{"network":{"enabled":true}}}`,
},
PermissionModeAuto,
Options{
OnPermissionRequest: func(ctx context.Context, request PermissionRequest) (PermissionDecision, error) {
promptCalls++
return PermissionDecision{Action: PermissionDecisionDeny}, nil
},
},
)
if err != nil {
t.Fatalf("executeToolCall: %v", err)
}
if promptCalls != 1 {
t.Fatalf("OnPermissionRequest called %d times, want exactly 1 for a valid elevation request", promptCalls)
}
}
+50 -8
View File
@@ -21,12 +21,17 @@ import (
// the agent loop owns the provider wiring.
// defaultCompactionPreserveLast is how many trailing messages are kept verbatim
// when the caller does not specify a preserve count.
const defaultCompactionPreserveLast = 8
// when the caller does not specify a preserve count. Kept in sync with the replay
// reconstruction default in internal/sessions/replay.go.
const defaultCompactionPreserveLast = 6
// compactionTriggerRatio is the fraction of the context window at which
// proactive compaction kicks in (top of each turn).
const compactionTriggerRatio = 0.8
// proactive compaction kicks in (top of each turn). NOTE: this is speculative
// tuning — validate against the per-turn latency/compaction metrics (roadmap 5.2)
// before trusting it. Measured fixed overhead is ~7.4k tokens (not the ~16k the
// roadmap assumed), so context fills slower; if data shows 0.7 triggers redundant
// summarization, raise it back toward 0.8.
const compactionTriggerRatio = 0.7
// summaryLabel prefixes the injected summary so it is unmistakable in the
// transcript (and so tests can assert on it).
@@ -320,6 +325,43 @@ type compactionState struct {
// OnText is deliberately NOT forwarded (compaction stays invisible to the user),
// but its token COST must still be counted so usage reports and budgets include it.
onUsage func(Usage)
// calibrationRatio scales the raw byte/4 token estimate toward the provider's
// real prompt-token count. ApproxTextTokens over-counts code-heavy content by
// ~15-20%, which would trip compaction early (at ~60% of true capacity). It
// starts at 1.0 and converges via an EMA as each turn reports actual usage, so
// later turns compact nearer to real capacity. Zero is treated as 1.0.
calibrationRatio float64
}
// calibrate folds one turn's (rawEstimate, actualPromptTokens) sample into the
// running calibration ratio. A single sample is clamped to a sane band so an
// outlier (a huge cache-read turn, a provider-overhead spike) can't skew the
// estimate enough to disable or thrash compaction.
func (state *compactionState) calibrate(rawEstimate int, actualPromptTokens int) {
if !state.enabled || rawEstimate <= 0 || actualPromptTokens <= 0 {
return
}
sample := float64(actualPromptTokens) / float64(rawEstimate)
if sample < 0.5 {
sample = 0.5
} else if sample > 2.0 {
sample = 2.0
}
if state.calibrationRatio <= 0 {
state.calibrationRatio = 1.0
}
const alpha = 0.3 // weight on the newest sample; smooths jitter across turns
state.calibrationRatio = state.calibrationRatio*(1-alpha) + sample*alpha
}
// calibratedTokens applies the learned ratio to a raw estimate. Before any sample
// arrives (ratio unset) it returns the raw estimate unchanged.
func (state *compactionState) calibratedTokens(raw int) int {
if state.calibrationRatio <= 0 {
return raw
}
return int(float64(raw) * state.calibrationRatio)
}
func newCompactionState(options Options) *compactionState {
@@ -348,7 +390,7 @@ func (state *compactionState) maybeCompact(
// the messages; both the threshold check and the shrink check below use the
// same term so they stay consistent.
toolTokens := estimateToolDefTokens(tools)
size := estimateTokens(messages) + toolTokens
size := state.calibratedTokens(estimateTokens(messages) + toolTokens)
if size <= state.threshold {
return messages
}
@@ -365,7 +407,7 @@ func (state *compactionState) maybeCompact(
// entirely and preserve recent turns verbatim.
if pruned, reclaimed := pruneStaleToolOutput(messages, state.preserveLast); reclaimed > 0 {
messages = pruned
size = estimateTokens(messages) + toolTokens
size = state.calibratedTokens(estimateTokens(messages) + toolTokens)
if size <= state.threshold {
state.lowWaterMark = size
return messages
@@ -381,7 +423,7 @@ func (state *compactionState) maybeCompact(
// later turn) can try again; we never drop messages on failure here.
return messages
}
newSize := estimateTokens(compacted) + toolTokens
newSize := state.calibratedTokens(estimateTokens(compacted) + toolTokens)
if newSize >= size {
// Compaction did not actually shrink anything (e.g. nothing to
// summarize). Leave the history untouched and don't churn next turn.
@@ -439,7 +481,7 @@ func (state *compactionState) recover(
// the SAME combined (messages + tool-defs) domain maybeCompact uses, so the
// proactive shrink-guard compares like with like.
state.reactiveAttempted = true
state.lowWaterMark = estimateTokens(result) + estimateToolDefTokens(tools)
state.lowWaterMark = state.calibratedTokens(estimateTokens(result) + estimateToolDefTokens(tools))
return result, true, nil
}
@@ -0,0 +1,50 @@
package agent
import "testing"
func TestCalibrationConvergesTowardActual(t *testing.T) {
state := &compactionState{enabled: true}
// Before any sample, the estimate is unchanged.
if got := state.calibratedTokens(1000); got != 1000 {
t.Fatalf("uncalibrated tokens = %d, want 1000", got)
}
// The estimator over-counts: our raw estimate is 1000 but the provider reports
// 850 (a 0.85 ratio). Feeding that sample repeatedly should pull the ratio
// toward 0.85, so a 1000-token raw estimate calibrates downward.
for range 20 {
state.calibrate(1000, 850)
}
got := state.calibratedTokens(1000)
if got >= 1000 || got < 820 || got > 880 {
t.Fatalf("calibrated tokens = %d, want ~850 after convergence", got)
}
}
func TestCalibrateIgnoresDegenerateSamples(t *testing.T) {
state := &compactionState{enabled: true}
state.calibrate(0, 500) // zero estimate
state.calibrate(500, 0) // zero actual
if state.calibrationRatio != 0 {
t.Fatalf("degenerate samples must not move the ratio, got %v", state.calibrationRatio)
}
// Disabled state never calibrates.
disabled := &compactionState{enabled: false}
disabled.calibrate(1000, 850)
if disabled.calibrationRatio != 0 {
t.Fatalf("disabled compaction must not calibrate, got %v", disabled.calibrationRatio)
}
}
func TestCalibrateClampsOutliers(t *testing.T) {
state := &compactionState{enabled: true}
// A wild outlier (10x) is clamped to 2.0, so one bad sample can't blow up the
// ratio and disable compaction.
for range 50 {
state.calibrate(1000, 10000)
}
if state.calibrationRatio > 2.0 {
t.Fatalf("ratio should be clamped at 2.0, got %v", state.calibrationRatio)
}
}
+194 -4
View File
@@ -20,6 +20,16 @@ const (
toolNameUpdatePlan = "update_plan"
toolNameToolSearch = "tool_search"
toolNameSkill = "skill"
toolNameWriteFile = "write_file"
toolNameEditFile = "edit_file"
)
// maxRecentEdits caps how many edited files are carried across a compaction, and
// maxEditNoteBytes caps each edit's one-line note, so a long editing run can't
// bloat the preserved-state block.
const (
maxRecentEdits = 20
maxEditNoteBytes = 160
)
const (
@@ -100,12 +110,123 @@ func formatPlanArguments(arguments string) string {
}
// skillEntry is a named preserved body. It began as loaded-skill state and is
// reused for loaded tools and project instruction blocks.
// reused for loaded tools, project instruction blocks, and recent file edits.
type skillEntry struct {
name string
body string
}
// recentEdits returns the files mutated by write_file/edit_file calls in messages
// — latest note per path, in last-seen order — as skillEntry{name: path, body:
// note}. After compaction elides the editing turns, this tells the model WHAT it
// changed in each file (from the tool's result) so it need not re-read to
// rediscover its own footprint. Capped at maxRecentEdits paths.
//
// Ordering is by LAST edit, not first: re-editing a file moves it to the newest
// position so the tail cap below keeps the file the model most recently touched
// rather than an earlier, now-stale entry.
func recentEdits(messages []zeroruntime.Message) []skillEntry {
pathByID := map[string]string{}
sequence := make([]string, 0)
for _, message := range messages {
for _, call := range message.ToolCalls {
if call.Name != toolNameWriteFile && call.Name != toolNameEditFile {
continue
}
path := editPathFromArguments(call.Arguments)
if path == "" {
continue
}
if call.ID != "" {
pathByID[call.ID] = path
}
sequence = append(sequence, path)
}
}
order := lastSeenOrder(sequence)
if len(order) == 0 {
return nil
}
noteByPath := map[string]string{}
for _, message := range messages {
if message.Role != zeroruntime.MessageRoleTool || message.ToolCallID == "" {
continue
}
if path, ok := pathByID[message.ToolCallID]; ok {
noteByPath[path] = editNote(message.Content)
}
}
// Keep the most recent maxRecentEdits paths (the tail of last-seen order).
if len(order) > maxRecentEdits {
order = order[len(order)-maxRecentEdits:]
}
entries := make([]skillEntry, 0, len(order))
for _, path := range order {
entries = append(entries, skillEntry{name: path, body: noteByPath[path]})
}
return entries
}
// lastSeenOrder dedupes paths keeping each at its LAST occurrence, preserving
// chronological order otherwise. A re-edited path therefore lands at the newest
// position rather than staying pinned to where it first appeared.
func lastSeenOrder(paths []string) []string {
lastIdx := make(map[string]int, len(paths))
for i, p := range paths {
lastIdx[p] = i
}
order := make([]string, 0, len(lastIdx))
for i, p := range paths {
if lastIdx[p] == i {
order = append(order, p)
}
}
return order
}
// editPathFromArguments pulls the target file path from a write_file/edit_file
// call's JSON arguments (path/file/file_path/filename aliases). Returns "" on
// malformed arguments or no path.
func editPathFromArguments(arguments string) string {
var parsed struct {
Path string `json:"path"`
File string `json:"file"`
FilePath string `json:"file_path"`
Filename string `json:"filename"`
}
if err := json.Unmarshal([]byte(strings.TrimSpace(arguments)), &parsed); err != nil {
return ""
}
for _, candidate := range []string{parsed.Path, parsed.File, parsed.FilePath, parsed.Filename} {
if s := strings.TrimSpace(candidate); s != "" {
return s
}
}
return ""
}
// editNote reduces an edit tool's result to a single short line (its first
// non-empty line, byte-capped on a rune boundary) for the preserved summary.
func editNote(content string) string {
for _, line := range strings.Split(content, "\n") {
s := strings.TrimSpace(line)
if s == "" {
continue
}
if len(s) > maxEditNoteBytes {
limit := maxEditNoteBytes
for limit > 0 && !utf8.RuneStart(s[limit]) {
limit--
}
s = s[:limit] + "…"
}
return s
}
return ""
}
// loadedSkills returns the skills loaded via the skill tool in messages — the
// latest body per name, in first-seen order — matching each skill tool call to
// its tool result by id.
@@ -310,11 +431,17 @@ func capBody(body string) string {
// preservedState is the JSON shape of the carried-across-compaction block.
type preservedState struct {
Plan string `json:"plan,omitempty"`
RecentEdits []preservedEdit `json:"recent_edits,omitempty"`
Tools []preservedTool `json:"tools,omitempty"`
Skills []preservedSkill `json:"skills,omitempty"`
ProjectInstructions []preservedInstruction `json:"project_instructions,omitempty"`
}
type preservedEdit struct {
Path string `json:"path"`
Note string `json:"note,omitempty"`
}
type preservedTool struct {
Name string `json:"name"`
Body string `json:"body"`
@@ -347,6 +474,12 @@ func appendPreservedState(summary string, middle []zeroruntime.Message) string {
plan = priorState.Plan
}
// Recent edits: merge edits preserved earlier with fresh write_file/edit_file
// results in middle (newer note per path wins AND moves to the newest
// position), so the tail cap keeps the files the model most recently touched
// across repeated compactions rather than dropping a just-re-edited file.
edits := capRecentEdits(mergeRecentEdits(preservedEditsToEntries(priorState.RecentEdits), recentEdits(middle)))
// Tools: preserve deferred tool_search schemas from the transcript. Fresh
// loads override older carried copies by name.
tools := mergeSkillEntries(preservedToolsToEntries(priorState.Tools), loadedToolSchemas(middle))
@@ -360,19 +493,76 @@ func appendPreservedState(summary string, middle []zeroruntime.Message) string {
projectInstructionEntries(middle),
)
if block := formatPreservedState(plan, tools, skills, instructions); block != "" {
if block := formatPreservedState(plan, edits, tools, skills, instructions); block != "" {
summary += "\n\n" + block
}
return summary
}
// mergeRecentEdits overlays fresh edits onto edits preserved by an earlier
// compaction. Unlike mergeSkillEntries (which keeps refreshed entries in their
// original slot), a path touched again by a fresh edit MOVES to the newest
// position and takes the fresh note, so capRecentEdits — which keeps only the
// most-recent tail — never drops a file the model just re-edited. Paths present
// only in the older set keep their relative order, ahead of the fresh ones.
func mergeRecentEdits(older, newer []skillEntry) []skillEntry {
if len(newer) == 0 {
return older
}
freshBody := make(map[string]string, len(newer))
freshOrder := make([]string, 0, len(newer))
for _, e := range newer {
if _, ok := freshBody[e.name]; !ok {
freshOrder = append(freshOrder, e.name)
}
freshBody[e.name] = e.body
}
merged := make([]skillEntry, 0, len(older)+len(freshOrder))
// Older entries that were NOT re-edited keep their position, oldest first.
for _, e := range older {
if _, refreshed := freshBody[e.name]; refreshed {
continue // re-appended at its newest position below
}
merged = append(merged, e)
}
// Fresh edits follow, in last-seen order, each carrying the fresh note — so
// the most recently touched files sit at the tail the cap preserves.
for _, name := range freshOrder {
merged = append(merged, skillEntry{name: name, body: freshBody[name]})
}
return merged
}
// capRecentEdits bounds the preserved edit list to the most recent maxRecentEdits
// entries after a merge, so repeated compactions can't grow it without limit.
func capRecentEdits(entries []skillEntry) []skillEntry {
if len(entries) > maxRecentEdits {
return entries[len(entries)-maxRecentEdits:]
}
return entries
}
func preservedEditsToEntries(edits []preservedEdit) []skillEntry {
entries := make([]skillEntry, 0, len(edits))
for _, e := range edits {
if e.Path == "" {
continue
}
entries = append(entries, skillEntry{name: e.Path, body: e.Note})
}
return entries
}
// formatPreservedState renders state as the labelled, single-line
// JSON block. Returns "" when there is nothing to preserve.
func formatPreservedState(plan string, tools, skills, instructions []skillEntry) string {
if plan == "" && len(tools) == 0 && len(skills) == 0 && len(instructions) == 0 {
func formatPreservedState(plan string, edits, tools, skills, instructions []skillEntry) string {
if plan == "" && len(edits) == 0 && len(tools) == 0 && len(skills) == 0 && len(instructions) == 0 {
return ""
}
state := preservedState{Plan: plan}
for _, e := range edits {
state.RecentEdits = append(state.RecentEdits, preservedEdit{Path: e.name, Note: e.body})
}
for _, t := range tools {
state.Tools = append(state.Tools, preservedTool{Name: t.name, Body: t.body})
}
@@ -0,0 +1,121 @@
package agent
import (
"fmt"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// recentEdits extracts each mutated file's path and a one-line note from the
// matching tool result, latest note per path in last-seen order.
func TestRecentEditsExtractsPathsAndNotes(t *testing.T) {
messages := []zeroruntime.Message{
{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{
{ID: "e1", Name: "write_file", Arguments: `{"path":"internal/foo.go","content":"package foo"}`},
}},
{Role: zeroruntime.MessageRoleTool, ToolCallID: "e1", Content: "Wrote internal/foo.go (12 lines)"},
{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{
{ID: "e2", Name: "edit_file", Arguments: `{"path":"internal/bar.go","old_string":"a","new_string":"b"}`},
}},
{Role: zeroruntime.MessageRoleTool, ToolCallID: "e2", Content: "Applied edit to internal/bar.go"},
}
edits := recentEdits(messages)
if len(edits) != 2 {
t.Fatalf("expected 2 edited files, got %d: %#v", len(edits), edits)
}
if edits[0].name != "internal/foo.go" || !strings.Contains(edits[0].body, "12 lines") {
t.Fatalf("first edit = %#v, want foo.go with its note", edits[0])
}
if edits[1].name != "internal/bar.go" || !strings.Contains(edits[1].body, "Applied edit") {
t.Fatalf("second edit = %#v, want bar.go with its note", edits[1])
}
}
// After compaction elides the editing turns, the preserved-state block still
// names the edited files and what changed, so the model needn't re-read them.
func TestCompactionPreservesRecentEdits(t *testing.T) {
messages := []zeroruntime.Message{
{Role: zeroruntime.MessageRoleSystem, Content: "system"},
{Role: zeroruntime.MessageRoleUser, Content: "add a flag"},
{Role: zeroruntime.MessageRoleAssistant, Content: "editing", ToolCalls: []zeroruntime.ToolCall{
{ID: "e1", Name: "write_file", Arguments: `{"path":"cmd/main.go","content":"..."}`},
}},
{Role: zeroruntime.MessageRoleTool, ToolCallID: "e1", Content: "Wrote cmd/main.go (adds --version flag)"},
{Role: zeroruntime.MessageRoleAssistant, Content: "done"},
{Role: zeroruntime.MessageRoleUser, Content: "continue"},
{Role: zeroruntime.MessageRoleAssistant, Content: "continuing"},
}
summary := compactStateConversation(t, messages)
state := parsePreservedStateBlock(summary)
if len(state.RecentEdits) != 1 {
t.Fatalf("expected 1 preserved edit, got %#v", state.RecentEdits)
}
if state.RecentEdits[0].Path != "cmd/main.go" || !strings.Contains(state.RecentEdits[0].Note, "--version") {
t.Fatalf("preserved edit = %#v, want cmd/main.go + its note", state.RecentEdits[0])
}
}
// A fresh edit note for a path overrides the one carried from an earlier
// compaction (newer wins) and moves the path to the newest position rather than
// duplicating it or leaving it pinned to its old slot.
func TestRecentEditsMergeNewerWins(t *testing.T) {
prior := preservedState{RecentEdits: []preservedEdit{{Path: "a.go", Note: "old note"}, {Path: "z.go", Note: "z"}}}
older := preservedEditsToEntries(prior.RecentEdits)
newer := []skillEntry{{name: "a.go", body: "new note"}, {name: "b.go", body: "added"}}
merged := mergeRecentEdits(older, newer)
// z.go (untouched) keeps its lead; a.go is re-edited so it takes the new note
// and moves behind z.go, ahead of the brand-new b.go: [z.go, a.go, b.go].
if len(merged) != 3 {
t.Fatalf("expected z.go + refreshed a.go + b.go, got %#v", merged)
}
if merged[0].name != "z.go" {
t.Fatalf("untouched z.go should stay first, got %#v", merged)
}
if merged[1].name != "a.go" || merged[1].body != "new note" {
t.Fatalf("re-edited a.go should move to the newest edits with the new note, got %#v", merged[1])
}
if merged[2].name != "b.go" {
t.Fatalf("brand-new b.go should be last, got %#v", merged[2])
}
}
// Regression: once more than maxRecentEdits distinct files are tracked, re-editing
// an early file must move it into the capped tail rather than leaving it to be
// dropped — otherwise the next compaction omits exactly the file the model most
// recently touched.
func TestRecentEditsCapKeepsReeditedFile(t *testing.T) {
// Earlier compaction preserved exactly maxRecentEdits files, f0 the oldest.
older := make([]skillEntry, 0, maxRecentEdits)
for i := 0; i < maxRecentEdits; i++ {
older = append(older, skillEntry{name: fmt.Sprintf("f%d.go", i), body: "old"})
}
// Fresh window re-edits the OLDEST file (f0) and adds a brand-new file, so the
// merged list exceeds the cap and something must be dropped.
newer := []skillEntry{{name: "f0.go", body: "re-edited"}, {name: "fresh.go", body: "new"}}
merged := capRecentEdits(mergeRecentEdits(older, newer))
if len(merged) != maxRecentEdits {
t.Fatalf("cap should keep %d entries, got %d", maxRecentEdits, len(merged))
}
var f0 *skillEntry
for i := range merged {
if merged[i].name == "f0.go" {
f0 = &merged[i]
}
if merged[i].name == "f1.go" {
t.Fatalf("f1.go (now the least-recently touched) should have been dropped, got %#v", merged)
}
}
if f0 == nil {
t.Fatalf("re-edited f0.go must survive the cap, got %#v", merged)
}
if f0.body != "re-edited" {
t.Fatalf("surviving f0.go should carry the fresh note, got %q", f0.body)
}
}
+62
View File
@@ -0,0 +1,62 @@
package agent
import (
"context"
"os"
"path/filepath"
"strings"
"time"
"github.com/Gitlawb/zero/internal/lsp"
)
// fileDiagnosticsTimeout bounds one inline post-edit diagnostics check so a
// slow or wedged language server can never hang a tool call; on timeout the
// edit simply reports without a diagnostics block.
const fileDiagnosticsTimeout = 10 * time.Second
// NewFileDiagnostics adapts an *lsp.Manager to the per-edit inline diagnostics
// callback (tools.RunOptions.Diagnostics): it reads the just-written file,
// checks it against the file's language server, and formats error-severity
// diagnostics for the model. Warnings and hints are excluded — nagging about
// style on every edit is noise, while a type error the edit just introduced is
// exactly what the model should see before its next step. Diagnostics are
// rendered with workspace-relative paths: the absolute path would put the
// local username/home directory into the model prompt and session transcript
// on every edit. Returns nil when manager is nil, disabling inline diagnostics
// entirely.
func NewFileDiagnostics(manager *lsp.Manager, workspaceRoot string) func(context.Context, string) string {
if manager == nil {
return nil
}
return func(ctx context.Context, absPath string) string {
text, err := os.ReadFile(absPath)
if err != nil {
return ""
}
checkCtx, cancel := context.WithTimeout(ctx, fileDiagnosticsTimeout)
defer cancel()
diagnostics, err := manager.Check(checkCtx, absPath, string(text))
if err != nil {
return ""
}
errors := lsp.FilterBySeverity(diagnostics, lsp.SeverityError)
if len(errors) == 0 {
return ""
}
return lsp.FormatDiagnostics(diagnosticsDisplayPath(workspaceRoot, absPath), errors)
}
}
// diagnosticsDisplayPath renders absPath relative to the workspace root for
// model-facing output, falling back to the file's base name when the path is
// outside the workspace (a bare name still identifies the file without
// exposing the directory layout).
func diagnosticsDisplayPath(workspaceRoot, absPath string) string {
if workspaceRoot != "" {
if rel, err := filepath.Rel(workspaceRoot, absPath); err == nil && !strings.HasPrefix(rel, "..") {
return rel
}
}
return filepath.Base(absPath)
}
+24
View File
@@ -0,0 +1,24 @@
package agent
import (
"path/filepath"
"testing"
)
// Diagnostics are model-facing: absolute paths would leak the local username
// and directory layout into the prompt and session transcript on every edit.
func TestDiagnosticsDisplayPath(t *testing.T) {
root := filepath.Join("/Users", "someone", "project")
cases := []struct {
root, abs, want string
}{
{root, filepath.Join(root, "internal", "a.go"), filepath.Join("internal", "a.go")},
{root, filepath.Join("/etc", "other.go"), "other.go"}, // outside root -> base name only
{"", filepath.Join("/home", "user", "x.go"), "x.go"}, // no root -> base name only
}
for _, c := range cases {
if got := diagnosticsDisplayPath(c.root, c.abs); got != c.want {
t.Errorf("diagnosticsDisplayPath(%q, %q) = %q, want %q", c.root, c.abs, got, c.want)
}
}
}
+30 -8
View File
@@ -22,6 +22,12 @@ const (
// calls have executed since the last update_plan call.
staleToolCallThreshold = 10
// stalePlanTurnThreshold injects the same one-shot reminder once this many
// turns have passed since the last update_plan while plan items are still
// pending — the turn-based complement to staleToolCallThreshold, catching a
// plan that drifts stale across many turns that each make few tool calls.
stalePlanTurnThreshold = 8
// toolOnlyProgressReminderAt injects a one-shot progress nudge after this
// many consecutive turns contain tool calls but no visible assistant text.
// It does not stop the run; it tells the model to synthesize what it already
@@ -43,8 +49,12 @@ const (
toolFailureHintAt = 2
// toolFailureStopAt halts the run after a tool fails this many times in a row
// with the same error, so NO model (weak or strong) burns turns looping on a
// bad call.
toolFailureStopAt = 4
// bad call. Set to 6 (not 4): a corrective hint fires at toolFailureHintAt (2),
// and a model iterating on a genuinely tricky edit can legitimately fail a few
// times after the hint while converging — stopping at 4 cut those runs short.
// The streak still resets the moment the tool succeeds or hits a different
// error, so this only affects true same-error loops.
toolFailureStopAt = 6
// maxContinueNudges bounds how many times the headless completion gate
// (Options.RequireCompletionSignal) re-prompts a model that stopped without a
@@ -354,8 +364,13 @@ type guardState struct {
emptyTurns int
totalToolCalls int
toolCallsSincePlanUpdate int
planEverCalled bool
notCalledReminderSent bool
// turnsSincePlanUpdate counts turns (not individual tool calls) since the last
// update_plan, so a plan that goes stale across many low-tool-call turns is
// still caught — the tool-call counter alone can take many turns to trip when
// the model makes only one call per turn.
turnsSincePlanUpdate int
planEverCalled bool
notCalledReminderSent bool
// staleReminderSent records whether the stale reminder has already fired for
// the current stale interval. It is cleared when a plan update opens a new
// interval, making the reminder one-shot per interval rather than per turn.
@@ -427,11 +442,16 @@ func (state *guardState) observeTurn(collected zeroruntime.CollectedStream) (sto
state.toolOnlyReminderSent = false
}
// One turn has passed; the plan-update below resets this to 0 when the model
// refreshes the plan this turn.
state.turnsSincePlanUpdate++
for _, call := range collected.ToolCalls {
state.totalToolCalls++
if call.Name == planToolName {
state.planEverCalled = true
state.toolCallsSincePlanUpdate = 0
state.turnsSincePlanUpdate = 0
// A fresh plan update opens a new stale interval.
state.staleReminderSent = false
// Record how many items remain so the completion gate knows whether
@@ -485,10 +505,12 @@ func (state *guardState) progressReminder() string {
// number of turns completed so far).
func (state *guardState) planReminder(turn int) string {
// STALE reminder takes priority: a long run without a plan update is the
// stronger signal. One-shot per stale interval.
if state.planEverCalled &&
!state.staleReminderSent &&
state.toolCallsSincePlanUpdate >= staleToolCallThreshold {
// stronger signal. Fires on either the tool-call streak OR a turn streak with
// pending items (so a plan drifting stale across many low-call turns is caught,
// while a fully-completed plan is left alone). One-shot per stale interval.
if state.planEverCalled && !state.staleReminderSent &&
(state.toolCallsSincePlanUpdate >= staleToolCallThreshold ||
(state.turnsSincePlanUpdate >= stalePlanTurnThreshold && state.planItemsPending > 0)) {
state.staleReminderSent = true
return planStaleReminder(state.toolCallsSincePlanUpdate)
}
+185 -63
View File
@@ -25,7 +25,12 @@ const maxTurnsFinalAnswerPrompt = "You have reached the tool-turn limit. Do not
// WITH NO OUTPUT yet is re-issued on a fresh connection before giving up. Only
// the no-output case is retried (a partial turn would duplicate), so this is a
// safe recovery for a stalled/dead pooled connection.
const maxStreamStallRetries = 2
//
// Set to 1 (not 2): each attempt can itself idle for the full stream timeout
// (~5min) before the stall is even detected, so 2 retries left an interactive
// session frozen for ~15min. One retry keeps the common single-hiccup recovery
// while bounding the worst case to ~2× the idle timeout.
const maxStreamStallRetries = 1
const (
toolResultMetaControl = "control"
@@ -145,6 +150,11 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
// has already been demanded this run, so it fires at most once.
acceptanceRequested := false
// toolDefCache memoizes each tool's rendered JSON-schema definition across
// turns (a tool's advertised schema is stable for the run), so partitionTools
// doesn't re-run the recursive schema→map conversion for every tool every turn.
toolDefCache := map[string]zeroruntime.ToolDefinition{}
result := Result{Messages: copyMessages(messages)}
for turn := 0; turn < maxTurns; turn++ {
result.Turns = turn + 1
@@ -153,7 +163,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
// the tool-definition tokens (they ride on every request) in its estimate.
// partitionTools depends only on registry/permissions/options/loaded, not on
// the messages, so computing it before compaction is safe.
exposed, _ := partitionTools(registry, permissionMode, options, loaded)
exposed, _ := partitionToolsCached(registry, permissionMode, options, loaded, toolDefCache)
// PROACTIVE compaction: if the history is approaching the model's
// context window, summarize the oldest middle before building the
@@ -340,6 +350,16 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
}
}
// Calibrate the compaction token estimator against the provider's real
// prompt-token count for the request we just sent, so later turns trigger
// compaction near true capacity instead of ~15% early on code-heavy history.
// Recompute the estimate HERE (not at request-build time): a reactive
// compaction may have replaced `messages` with a smaller set and re-sent, so
// this reflects the request that actually produced collected.Usage. The
// assistant reply is appended below, after this, so `messages` is still the
// sent request.
compactor.calibrate(estimateTokens(messages)+estimateToolDefTokens(exposed), collected.Usage.InputTokens)
// Carry the turn's terminal stop reason so a final answer cut off at the
// output token cap (or by a content filter) is reported as truncated. A
// tool-call turn normalizes to "" and clears any prior reason.
@@ -475,11 +495,35 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
// between tool_results breaks strict provider replay) — same after-batch
// rationale as turnRequestedModel above.
var changedFilesThisBatch []string
// Parallel read-ahead state: results for calls[precomputedStart:precomputedEnd]
// executed concurrently, consumed strictly in order below.
var precomputed []precomputedToolResult
precomputedStart, precomputedEnd := 0, 0
for index, call := range collected.ToolCalls {
// When this call starts a consecutive run of >= 2 auto-allowed read-only
// calls, execute the whole run concurrently now (see parallel_tools.go).
// The scan happens lazily at the run's first index — never ahead of a
// pending mutating call — so read-after-write ordering is preserved.
if index >= precomputedEnd {
runEnd := index
for runEnd < len(collected.ToolCalls) && parallelSafeToolCall(registry, collected.ToolCalls[runEnd], options) {
runEnd++
}
if runEnd-index >= 2 {
precomputed = executeParallelReadBatch(ctx, registry, collected.ToolCalls, index, runEnd, permissionMode, options)
precomputedStart, precomputedEnd = index, runEnd
}
}
if options.OnToolCall != nil {
options.OnToolCall(call)
}
toolResult, abortErr := executeToolCall(ctx, registry, call, permissionMode, options)
var toolResult ToolResult
var abortErr error
if index >= precomputedStart && index < precomputedEnd {
toolResult, abortErr = precomputed[index-precomputedStart].result, precomputed[index-precomputedStart].abortErr
} else {
toolResult, abortErr = executeToolCall(ctx, registry, call, permissionMode, options)
}
if options.OnToolResult != nil {
options.OnToolResult(toolResult)
}
@@ -816,6 +860,25 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
}
}
// A shell command that sets sandbox_permissions: with_additional_permissions
// must carry a valid additional_permissions payload; inlineAdditionalPermissionsProfile
// is the single source of truth for that shape, and buildPermissionEvent (below)
// calls it again to render the prompt's scope text. Check it here, before any
// permission prompt is shown: a malformed payload can never be satisfied no
// matter what the user decides, so surfacing it as a plain tool error (which the
// model can see and retry) is both clearer and avoids presenting a normal-looking
// prompt whose "allow" path is guaranteed to fail with a confusing denial.
if toolFound && isShellCommandTool(call.Name) {
if _, _, err := inlineAdditionalPermissionsProfile(args, options.Cwd); err != nil {
return ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Status: tools.StatusError,
Output: "Error: " + err.Error(),
}, nil
}
}
// ask_user is intercepted in the loop (like permissions) so the question can
// be routed to an interactive front-end instead of blocking inside the tool.
// When no front-end is wired up it degrades to the tool's own graceful Run().
@@ -1032,6 +1095,8 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
// Per-session file version tracker so write_file/edit_file refuse to clobber
// a file that changed on disk outside Zero since it was last read.
FileTracker: options.FileTracker,
// Inline post-edit diagnostics for mutating tools (nil = disabled).
Diagnostics: options.FileDiagnostics,
// Forward the run's operator tool filters so a filter-aware tool
// (tool_search) never discloses or loads an operator-hidden deferred tool.
EnabledTools: options.EnabledTools,
@@ -2322,7 +2387,12 @@ func inlineAdditionalPermissionsProfile(args map[string]any, basePath string) (s
}
raw, ok := args["additional_permissions"]
if !ok || raw == nil {
return sandbox.RequestPermissionProfile{}, true, fmt.Errorf("missing additional_permissions; provide at least one of network or file_system")
return sandbox.RequestPermissionProfile{}, true, fmt.Errorf(
"sandbox_permissions was set to %q but no additional_permissions object was provided. "+
`Include one, for example additional_permissions: {"network": {"enabled": true}} or `+
`{"file_system": {"write": ["/path"]}}. If this command does not need elevated permissions, `+
"omit sandbox_permissions entirely and retry",
tools.SandboxPermissionsWithAdditionalPermissions)
}
data, err := json.Marshal(raw)
if err != nil {
@@ -2337,7 +2407,9 @@ func inlineAdditionalPermissionsProfile(args map[string]any, basePath string) (s
return sandbox.RequestPermissionProfile{}, true, err
}
if normalized.Empty() {
return sandbox.RequestPermissionProfile{}, true, fmt.Errorf("additional_permissions must include at least one requested permission in network or file_system")
return sandbox.RequestPermissionProfile{}, true, fmt.Errorf(
"additional_permissions must include at least one of network or file_system, for example " +
`{"network": {"enabled": true}} or {"file_system": {"write": ["/path"]}}`)
}
grantProfile, err := sandbox.RequestPermissionGrantProfile(normalized)
if err != nil {
@@ -2436,6 +2508,17 @@ func permissionActionFromSandbox(action sandbox.Action) PermissionAction {
// exposed. The exposed slice is alpha-sorted by name, matching the legacy order
// so the inactive path is stable.
func partitionTools(registry *tools.Registry, permissionMode PermissionMode, options Options, loaded map[string]bool) ([]zeroruntime.ToolDefinition, string) {
return partitionToolsCached(registry, permissionMode, options, loaded, nil)
}
// partitionToolsCached is partitionTools with an optional per-tool definition
// cache. The partitioning itself (visibility, deferral, ordering) is recomputed
// every call — it must be, because a tool's deferred state can flip mid-run (e.g.
// swarm tools un-defer once a swarm is active). Only the expensive part —
// rendering each tool's JSON-schema parameters — is memoized by tool name, since a
// tool's advertised name/description/schema is stable for the run. defCache nil
// disables caching (used by tests and the plain partitionTools entrypoint).
func partitionToolsCached(registry *tools.Registry, permissionMode PermissionMode, options Options, loaded map[string]bool, defCache map[string]zeroruntime.ToolDefinition) ([]zeroruntime.ToolDefinition, string) {
registeredTools := registry.All()
visible := make([]tools.Tool, 0, len(registeredTools))
@@ -2470,76 +2553,115 @@ func partitionTools(registry *tools.Registry, permissionMode PermissionMode, opt
active := options.DeferThreshold > 0 && eligible >= options.DeferThreshold && loaderUsable
definitions := make([]zeroruntime.ToolDefinition, 0, len(visible))
exposedNames := make(map[string]bool, len(visible))
// INACTIVE: every visible tool eager (except tool_search), alpha-sorted. With no
// deferral there is no mid-session loading, so this is byte-stable across turns
// and byte-identical to the pre-deferral output.
if !active {
definitions := make([]zeroruntime.ToolDefinition, 0, len(visible))
for _, tool := range visible {
if tool.Name() == tools.ToolSearchToolName {
continue
}
definitions = append(definitions, cachedRuntimeToolDefinition(defCache, tool))
}
sort.Slice(definitions, func(left int, right int) bool {
return definitions[left].Name < definitions[right].Name
})
return definitions, ""
}
// ACTIVE: lay the tools array out so its cacheable prefix does NOT shift when a
// deferred tool loads mid-session. The tools block is part of the provider's
// cached prefix; if a load reorders it, the cache invalidates from that point
// through the system prompt and messages — on EVERY load. Previously the whole
// array was alpha-sorted each turn, so a newly loaded tool was INSERTED into the
// middle and shifted every later definition. Instead we build three append-only
// regions:
// 1. non-deferred tools, alpha-sorted — always present, identical every turn;
// 2. tool_search — always present, right after the stable block;
// 3. loaded deferred tools, alpha-sorted — APPENDED, so an unloaded->loaded
// transition grows the tail instead of inserting into the eager block.
// (tool_search's discovery text still shrinks as tools load, so keeping it and
// the loaded tail AFTER the eager block preserves the eager tools' cache across a
// load; fully stabilizing the loader's own description is a scoped follow-up.)
eager := make([]zeroruntime.ToolDefinition, 0, len(visible))
loadedTail := make([]zeroruntime.ToolDefinition, 0)
var hiddenTools []tools.Tool
for _, tool := range visible {
name := tool.Name()
deferred := tools.IsDeferred(tool)
if !active {
// Inactive: byte-identical to legacy, but tool_search is never advertised.
if name == tools.ToolSearchToolName {
continue
if name == tools.ToolSearchToolName {
continue // added explicitly between the eager block and the loaded tail
}
if tools.IsDeferred(tool) {
if loaded[name] {
loadedTail = append(loadedTail, cachedRuntimeToolDefinition(defCache, tool))
} else {
hiddenTools = append(hiddenTools, tool)
}
definitions = append(definitions, zeroruntime.ToolDefinition{
Name: name,
Description: tool.Description(),
Parameters: schemaToRuntimeMap(tool.Parameters()),
})
exposedNames[name] = true
continue
}
// Active path.
if deferred && !loaded[name] {
hiddenTools = append(hiddenTools, tool)
continue
}
definitions = append(definitions, zeroruntime.ToolDefinition{
Name: name,
Description: tool.Description(),
Parameters: schemaToRuntimeMap(tool.Parameters()),
})
exposedNames[name] = true
eager = append(eager, cachedRuntimeToolDefinition(defCache, tool))
}
// On the ACTIVE path tool_search is guaranteed runnable (active implies
// loaderUsable), so it must ALWAYS be reachable — even when a non-empty
// EnabledTools allowlist omits it (the operator allowlisted the deferred
// tools, not the loader). Expose its full definition whenever it is not
// already in the exposed set. This never runs on the inactive path, so the
// byte-identical below-threshold output is preserved.
discovery := ""
if active && len(hiddenTools) > 0 {
discovery = tools.BuildToolSearchDescription(hiddenTools)
}
if active && !exposedNames[tools.ToolSearchToolName] {
description := loader.Description()
if discovery != "" {
description = discovery
}
definitions = append(definitions, zeroruntime.ToolDefinition{
Name: loader.Name(),
Description: description,
Parameters: schemaToRuntimeMap(loader.Parameters()),
})
} else if active && discovery != "" {
for i := range definitions {
if definitions[i].Name == tools.ToolSearchToolName {
definitions[i].Description = discovery
break
}
}
}
sort.Slice(definitions, func(left int, right int) bool {
return definitions[left].Name < definitions[right].Name
sort.Slice(eager, func(left int, right int) bool {
return eager[left].Name < eager[right].Name
})
sort.Slice(loadedTail, func(left int, right int) bool {
return loadedTail[left].Name < loadedTail[right].Name
})
discovery := ""
if len(hiddenTools) > 0 {
discovery = tools.BuildToolSearchDescription(hiddenTools)
}
// tool_search is guaranteed runnable on the active path, so it is ALWAYS exposed
// — even when a non-empty EnabledTools allowlist omits it (the operator
// allowlisted the deferred tools, not the loader). It sits right after the stable
// eager block and carries the discovery list for still-hidden tools.
description := loader.Description()
if discovery != "" {
description = discovery
}
definitions := make([]zeroruntime.ToolDefinition, 0, len(eager)+1+len(loadedTail))
definitions = append(definitions, eager...)
definitions = append(definitions, zeroruntime.ToolDefinition{
Name: loader.Name(),
Description: description,
Parameters: schemaToRuntimeMap(loader.Parameters()),
})
definitions = append(definitions, loadedTail...)
return definitions, discovery
}
// cachedRuntimeToolDefinition returns the tool's rendered definition, reusing a
// cached render when defCache holds one for this tool name. A tool's advertised
// definition is stable across a run, so caching skips the recursive schema→map
// conversion (schemaToRuntimeMap) that would otherwise run for every tool on every
// turn. tool_search is excluded by its callers (its description is dynamic), so it
// never poisons the cache. A nil cache computes fresh.
func cachedRuntimeToolDefinition(defCache map[string]zeroruntime.ToolDefinition, tool tools.Tool) zeroruntime.ToolDefinition {
if defCache == nil {
return runtimeToolDefinition(tool)
}
if def, ok := defCache[tool.Name()]; ok {
return def
}
def := runtimeToolDefinition(tool)
defCache[tool.Name()] = def
return def
}
// runtimeToolDefinition renders a tool's advertised definition (name, description,
// JSON-schema parameters) as sent to the provider.
func runtimeToolDefinition(tool tools.Tool) zeroruntime.ToolDefinition {
return zeroruntime.ToolDefinition{
Name: tool.Name(),
Description: tool.Description(),
Parameters: schemaToRuntimeMap(tool.Parameters()),
}
}
func ToolVisible(tool tools.Tool, permissionMode PermissionMode, enabledTools []string, disabledTools []string) bool {
return ToolAllowedByFilters(tool.Name(), enabledTools, disabledTools) && ToolAdvertised(tool, permissionMode)
}
+97
View File
@@ -0,0 +1,97 @@
package agent
import (
"context"
"sync"
"github.com/Gitlawb/zero/internal/tools"
)
// Parallel read-ahead for tool batches. When a turn requests several
// independent lookups (read_file + grep + glob is the common shape), executing
// them one after another serializes pure I/O waits. A consecutive run of
// auto-allowed read-only calls is executed concurrently instead; results are
// then consumed in the original call order, so guard counters, message
// ordering, abort semantics, and the surface's call/result event pairing are
// byte-identical to sequential execution. Runs never span a mutating call: a
// read that follows a write must observe the write, so eligibility is decided
// per consecutive run, not per batch.
// maxParallelReadTools bounds concurrent read-only tool executions in a turn.
const maxParallelReadTools = 8
// precomputedToolResult is one parallel read-ahead execution, keyed back to
// its batch index by the caller.
type precomputedToolResult struct {
result ToolResult
abortErr error
}
// parallelSafeToolCall reports whether call may run concurrently with its
// neighbors: the tool must exist, be side-effect-free (SideEffectRead), and be
// auto-allowed for these args, so no interactive prompt or workspace mutation
// is on the hot path. Loop-intercepted tools (ask_user, request_permissions)
// and tool_search (mutates the deferred-tool set) stay sequential.
func parallelSafeToolCall(registry *tools.Registry, call ToolCall, options Options) bool {
switch call.Name {
case "ask_user", tools.RequestPermissionsToolName, tools.ToolSearchToolName:
return false
}
tool, found := registry.Get(call.Name)
if !found || tool.Safety().SideEffect != tools.SideEffectRead {
return false
}
args := map[string]any{}
if call.Arguments != "" {
if err := decodeToolArguments(call.Arguments, &args); err != nil {
return false
}
}
return effectivePermission(tool, args) == tools.PermissionAllow
}
// executeParallelReadBatch runs calls[start:end] concurrently (bounded by
// maxParallelReadTools) and returns results indexed relative to start. All
// execution-side callbacks that can fire inside executeToolCall are serialized
// behind one mutex: a permission prompt (a sandbox preflight can demand one
// even for an auto-allowed read) must never appear twice at once on an
// interactive front-end, and OnPermission event handlers append to shared
// session-recording state without their own locking — two batched reads under
// a granted extra root would otherwise race (the pre-batch serial loop never
// had two callbacks in flight at once).
func executeParallelReadBatch(ctx context.Context, registry *tools.Registry, calls []ToolCall, start, end int, permissionMode PermissionMode, options Options) []precomputedToolResult {
batchOptions := options
var callbackMutex sync.Mutex
if options.OnPermissionRequest != nil {
inner := options.OnPermissionRequest
batchOptions.OnPermissionRequest = func(ctx context.Context, request PermissionRequest) (PermissionDecision, error) {
callbackMutex.Lock()
defer callbackMutex.Unlock()
return inner(ctx, request)
}
}
if options.OnPermission != nil {
inner := options.OnPermission
batchOptions.OnPermission = func(event PermissionEvent) {
callbackMutex.Lock()
defer callbackMutex.Unlock()
inner(event)
}
}
results := make([]precomputedToolResult, end-start)
semaphore := make(chan struct{}, maxParallelReadTools)
var waitGroup sync.WaitGroup
for index := start; index < end; index++ {
waitGroup.Add(1)
go func(index int) {
defer waitGroup.Done()
semaphore <- struct{}{}
defer func() { <-semaphore }()
result, abortErr := executeToolCall(ctx, registry, calls[index], permissionMode, batchOptions)
results[index-start] = precomputedToolResult{result: result, abortErr: abortErr}
}(index)
}
waitGroup.Wait()
return results
}
+204
View File
@@ -0,0 +1,204 @@
package agent
import (
"context"
"fmt"
"sync"
"testing"
"time"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// probeTool records execution overlap and ordering so tests can assert what
// actually ran concurrently.
type probeTool struct {
name string
sideEffect tools.SideEffect
delay time.Duration
// shared, when set, receives this probe's start/end entries too, so a test
// can observe ordering ACROSS probes (e.g. reads vs a write barrier).
shared *probeLog
mu sync.Mutex
active int
maxActive int
log []string
}
// probeLog is a mutex-guarded event log shared between probes.
type probeLog struct {
mu sync.Mutex
entries []string
}
func (log *probeLog) append(entry string) {
log.mu.Lock()
defer log.mu.Unlock()
log.entries = append(log.entries, entry)
}
func (log *probeLog) snapshot() []string {
log.mu.Lock()
defer log.mu.Unlock()
return append([]string(nil), log.entries...)
}
func (tool *probeTool) Name() string { return tool.name }
func (tool *probeTool) Description() string { return "test probe tool" }
func (tool *probeTool) Parameters() tools.Schema {
return tools.Schema{
Type: "object",
Properties: map[string]tools.PropertySchema{"id": {Type: "string"}},
AdditionalProperties: false,
}
}
func (tool *probeTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tool.sideEffect, Permission: tools.PermissionAllow, Reason: "test"}
}
func (tool *probeTool) Run(_ context.Context, args map[string]any) tools.Result {
id, _ := args["id"].(string)
tool.mu.Lock()
tool.active++
if tool.active > tool.maxActive {
tool.maxActive = tool.active
}
tool.log = append(tool.log, "start:"+id)
tool.mu.Unlock()
if tool.shared != nil {
tool.shared.append("start:" + id)
}
time.Sleep(tool.delay)
if tool.shared != nil {
tool.shared.append("end:" + id)
}
tool.mu.Lock()
tool.active--
tool.log = append(tool.log, "end:"+id)
tool.mu.Unlock()
return tools.Result{Status: tools.StatusOK, Output: "probe " + id}
}
func probeCallEvents(callID, toolName, id string) []zeroruntime.StreamEvent {
return []zeroruntime.StreamEvent{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: callID, ToolName: toolName},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: callID, ArgumentsFragment: fmt.Sprintf(`{"id":%q}`, id)},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: callID},
}
}
func TestParallelSafeToolCall(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(&probeTool{name: "probe_read", sideEffect: tools.SideEffectRead})
registry.Register(&probeTool{name: "probe_write", sideEffect: tools.SideEffectWrite})
call := func(name, args string) ToolCall { return ToolCall{ID: "c", Name: name, Arguments: args} }
if !parallelSafeToolCall(registry, call("probe_read", `{"id":"a"}`), Options{}) {
t.Fatal("auto-allowed read tool must be parallel-safe")
}
if parallelSafeToolCall(registry, call("probe_write", `{"id":"a"}`), Options{}) {
t.Fatal("mutating tool must not be parallel-safe")
}
if parallelSafeToolCall(registry, call("unknown_tool", `{}`), Options{}) {
t.Fatal("unknown tool must not be parallel-safe")
}
if parallelSafeToolCall(registry, call("probe_read", `{"id":`), Options{}) {
t.Fatal("undecodable arguments must not be parallel-safe")
}
if parallelSafeToolCall(registry, call("ask_user", `{}`), Options{}) {
t.Fatal("loop-intercepted tools must stay sequential")
}
}
func TestRunExecutesConsecutiveReadsConcurrently(t *testing.T) {
probe := &probeTool{name: "probe_read", sideEffect: tools.SideEffectRead, delay: 60 * time.Millisecond}
registry := tools.NewRegistry()
registry.Register(probe)
turnOne := append(probeCallEvents("call-1", "probe_read", "a"), probeCallEvents("call-2", "probe_read", "b")...)
turnOne = append(turnOne, probeCallEvents("call-3", "probe_read", "c")...)
turnOne = append(turnOne, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone})
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
turnOne,
{{Type: zeroruntime.StreamEventText, Content: "done"}, {Type: zeroruntime.StreamEventDone}},
},
}
var results []ToolResult
_, err := Run(context.Background(), "probe", provider, Options{
Registry: registry,
OnToolResult: func(result ToolResult) { results = append(results, result) },
})
if err != nil {
t.Fatal(err)
}
if probe.maxActive < 2 {
t.Fatalf("consecutive read-only calls must overlap, max concurrency was %d", probe.maxActive)
}
// Results must still be recorded in original call order.
if len(results) != 3 || results[0].ToolCallID != "call-1" || results[1].ToolCallID != "call-2" || results[2].ToolCallID != "call-3" {
t.Fatalf("tool results out of order: %#v", results)
}
messages := provider.requests[1].Messages
var toolOrder []string
for _, message := range messages {
if message.Role == zeroruntime.MessageRoleTool {
toolOrder = append(toolOrder, message.ToolCallID)
}
}
if len(toolOrder) != 3 || toolOrder[0] != "call-1" || toolOrder[1] != "call-2" || toolOrder[2] != "call-3" {
t.Fatalf("recorded tool messages out of order: %v", toolOrder)
}
}
func TestRunParallelReadsNeverSpanMutatingCall(t *testing.T) {
shared := &probeLog{}
read := &probeTool{name: "probe_read", sideEffect: tools.SideEffectRead, delay: 30 * time.Millisecond, shared: shared}
write := &probeTool{name: "probe_write", sideEffect: tools.SideEffectWrite, shared: shared}
registry := tools.NewRegistry()
registry.Register(read)
registry.Register(write)
turnOne := append(probeCallEvents("call-1", "probe_read", "r1"), probeCallEvents("call-2", "probe_read", "r2")...)
turnOne = append(turnOne, probeCallEvents("call-3", "probe_write", "w")...)
turnOne = append(turnOne, probeCallEvents("call-4", "probe_read", "r3")...)
turnOne = append(turnOne, probeCallEvents("call-5", "probe_read", "r4")...)
turnOne = append(turnOne, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone})
provider := &mockProvider{
turns: [][]zeroruntime.StreamEvent{
turnOne,
{{Type: zeroruntime.StreamEventText, Content: "done"}, {Type: zeroruntime.StreamEventDone}},
},
}
_, err := Run(context.Background(), "probe", provider, Options{Registry: registry})
if err != nil {
t.Fatal(err)
}
// Cross-probe ordering on the SHARED log: the write must start only after
// both first-batch reads finished, and both second-batch reads must start
// only after the write finished — batches never cross a mutating call.
log := shared.snapshot()
index := func(entry string) int {
for i, e := range log {
if e == entry {
return i
}
}
t.Fatalf("entry %q missing from shared log: %v", entry, log)
return -1
}
writeStart, writeEnd := index("start:w"), index("end:w")
if firstBatchMaxEnd := max(index("end:r1"), index("end:r2")); writeStart < firstBatchMaxEnd {
t.Fatalf("write started before the first read batch finished: %v", log)
}
if secondBatchMinStart := min(index("start:r3"), index("start:r4")); secondBatchMinStart < writeEnd {
t.Fatalf("second read batch started before the write finished: %v", log)
}
if read.maxActive < 2 {
t.Fatalf("reads within a batch must overlap, max concurrency was %d", read.maxActive)
}
}
@@ -0,0 +1,44 @@
package agent
import (
"testing"
"github.com/Gitlawb/zero/internal/tools"
)
func TestPartitionToolsActiveAppendsLoadedToolAfterEagerBlock(t *testing.T) {
// Cache-stability property: loading a deferred tool must APPEND it after the
// always-eager tools, not sort it into the middle of them — otherwise the tool
// definitions (the provider's cacheable prefix) shift on every load. The old
// behaviour alpha-sorted the whole array, so "mcp__srv__alpha" jumped ahead of
// "read_file".
root := t.TempDir()
registry := tools.NewRegistry()
registry.Register(tools.NewReadFileTool(root)) // non-deferred, "read_file"
registry.Register(fakeToolSearchTool{})
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha"})
registry.Register(fakeDeferredTool{name: "mcp__srv__beta", desc: "beta"})
exposed, _ := partitionTools(registry, PermissionModeAuto, Options{DeferThreshold: 2}, map[string]bool{"mcp__srv__alpha": true})
pos := func(name string) int {
for i := range exposed {
if exposed[i].Name == name {
return i
}
}
return -1
}
readPos, searchPos, alphaPos := pos("read_file"), pos("tool_search"), pos("mcp__srv__alpha")
if readPos < 0 || searchPos < 0 || alphaPos < 0 {
t.Fatalf("expected read_file, tool_search and loaded alpha all exposed, got %#v", exposed)
}
// The eager tools must precede the loaded deferred tool (appended, not inserted).
if !(readPos < alphaPos && searchPos < alphaPos) {
t.Fatalf("loaded deferred tool must be appended after the eager block; got read=%d search=%d alpha=%d", readPos, searchPos, alphaPos)
}
// beta was never loaded — it stays hidden.
if pos("mcp__srv__beta") != -1 {
t.Fatalf("unloaded deferred tool must stay hidden, got %#v", exposed)
}
}
+63
View File
@@ -0,0 +1,63 @@
package agent
import (
"context"
"reflect"
"testing"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// countingSchemaTool records how many times its Parameters() schema is read, so a
// test can prove the definition cache avoids re-rendering across turns.
type countingSchemaTool struct {
name string
calls *int
}
func (t countingSchemaTool) Name() string { return t.name }
func (t countingSchemaTool) Description() string { return "counts schema reads" }
func (t countingSchemaTool) Parameters() tools.Schema {
*t.calls++
return tools.Schema{Type: "object", AdditionalProperties: false, Properties: map[string]tools.PropertySchema{
"x": {Type: "string"},
}}
}
func (t countingSchemaTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tools.SideEffectRead, Permission: tools.PermissionAllow}
}
func (t countingSchemaTool) Run(_ context.Context, _ map[string]any) tools.Result {
return tools.Result{Status: tools.StatusOK}
}
// The cache renders a tool's schema once and reuses it across calls, and its
// output is identical to the uncached path.
func TestPartitionToolsCacheRendersOnceAndMatchesUncached(t *testing.T) {
calls := 0
registry := tools.NewRegistry()
registry.Register(countingSchemaTool{name: "alpha", calls: &calls})
registry.Register(countingSchemaTool{name: "beta", calls: &calls})
uncached, _ := partitionTools(registry, PermissionModeAuto, Options{}, map[string]bool{})
base := calls
cache := map[string]zeroruntime.ToolDefinition{}
first, _ := partitionToolsCached(registry, PermissionModeAuto, Options{}, map[string]bool{}, cache)
rendersAfterFirst := calls - base
second, _ := partitionToolsCached(registry, PermissionModeAuto, Options{}, map[string]bool{}, cache)
rendersAfterSecond := calls - base
if rendersAfterFirst != 2 {
t.Fatalf("first cached call should render 2 tools once each, rendered %d times", rendersAfterFirst)
}
if rendersAfterSecond != 2 {
t.Fatalf("second cached call must reuse the cache (no new schema reads), total renders = %d", rendersAfterSecond)
}
if !reflect.DeepEqual(first, second) {
t.Fatal("cached calls must return identical definitions")
}
if !reflect.DeepEqual(first, uncached) {
t.Fatal("cached output must match the uncached partitionTools output")
}
}
+64
View File
@@ -0,0 +1,64 @@
package agent
import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func planUpdateTurn(status string) zeroruntime.CollectedStream {
return zeroruntime.CollectedStream{ToolCalls: []zeroruntime.ToolCall{
{Name: "update_plan", Arguments: `{"plan":[{"content":"step one","status":"` + status + `"}]}`},
}}
}
// Turn-based staleness: a plan that goes many turns without an update — while
// items are still pending — draws the stale reminder even though few tool calls
// have run (the tool-call streak alone would not have tripped).
func TestPlanReminderFiresOnTurnStaleness(t *testing.T) {
state := newGuardState()
state.observeTurn(planUpdateTurn("in_progress"))
// Text-only turns advance the turn counter without adding tool calls.
for range stalePlanTurnThreshold {
state.observeTurn(zeroruntime.CollectedStream{Text: "still working…"})
}
if state.toolCallsSincePlanUpdate >= staleToolCallThreshold {
t.Fatalf("precondition: the tool-call trigger must not fire (%d calls)", state.toolCallsSincePlanUpdate)
}
if got := state.planReminder(stalePlanTurnThreshold + 2); !strings.Contains(got, planStaleReminderMarker) {
t.Fatalf("expected a turn-based stale reminder, got %q", got)
}
}
// A fully-completed plan is NOT stale: no pending items means the turn-based
// trigger stays quiet even after many turns, so a finished plan isn't nagged.
func TestPlanReminderSkipsTurnStalenessWhenComplete(t *testing.T) {
state := newGuardState()
state.observeTurn(planUpdateTurn("completed"))
for range stalePlanTurnThreshold + 3 {
state.observeTurn(zeroruntime.CollectedStream{Text: "wrapping up…"})
}
if got := state.planReminder(stalePlanTurnThreshold + 5); got != "" {
t.Fatalf("a completed plan must not draw a stale reminder, got %q", got)
}
}
// A plan update resets the turn counter, so the stale reminder does not fire
// right after a fresh update.
func TestPlanUpdateResetsTurnStaleness(t *testing.T) {
state := newGuardState()
state.observeTurn(planUpdateTurn("in_progress"))
for range stalePlanTurnThreshold - 1 {
state.observeTurn(zeroruntime.CollectedStream{Text: "working…"})
}
// Refresh the plan — resets the turn streak.
state.observeTurn(planUpdateTurn("in_progress"))
if got := state.planReminder(stalePlanTurnThreshold + 1); got != "" {
t.Fatalf("a just-refreshed plan must not be stale, got %q", got)
}
}
+51
View File
@@ -0,0 +1,51 @@
package agent
import (
"testing"
"github.com/Gitlawb/zero/internal/tools"
)
// These ceilings are a deliberate ratchet on Zero's fixed per-turn overhead — the
// system prompt and the eager tool schemas that ride on EVERY request. They are
// set ~10% above the current measured cost, using ApproxTextTokens (the same
// estimate the compaction loop and /context use, ~non-whitespace-bytes/4). A change
// that pushes either past its ceiling must be justified (and the ceiling raised
// deliberately) or trimmed — the per-turn floor should not creep up silently.
//
// Measured baselines (2026-07): base system prompt ~3160 tokens; the tools a normal
// (auto permission-mode) interactive turn sends ~2430 tokens. The tool figure is
// the auto-mode advertised set, not the full core registry — higher-risk tools that
// only advertise in other modes are excluded, matching what a real turn actually
// pays.
const (
maxBaseSystemPromptTokens = 3500
maxEagerToolSchemaTokens = 2700
)
func TestSystemPromptTokenBudget(t *testing.T) {
// Minimal render: a model is set (so the session block renders) but no Cwd, so
// the workspace map, project guidelines, and repo map are excluded — this is the
// fixed base every session pays regardless of the workspace it runs in.
prompt := buildSystemPrompt(Options{Model: "claude-opus-4-8"})
got := ApproxTextTokens(prompt)
t.Logf("base system prompt: %d tokens (%d bytes)", got, len(prompt))
if got > maxBaseSystemPromptTokens {
t.Fatalf("base system prompt is %d tokens, over the %d ceiling — trim it or raise the ceiling deliberately", got, maxBaseSystemPromptTokens)
}
}
func TestEagerToolSchemaTokenBudget(t *testing.T) {
registry := tools.NewRegistry()
for _, tool := range tools.CoreToolsScoped(t.TempDir(), nil) {
registry.Register(tool)
}
// Options{} keeps DeferThreshold at 0, so deferral is inactive and every core
// tool is exposed eagerly — exactly what a plugin-free session sends each turn.
exposed, _ := partitionTools(registry, PermissionModeAuto, Options{}, map[string]bool{})
got := estimateToolDefTokens(exposed)
t.Logf("eager core tool schemas: %d tokens across %d tools", got, len(exposed))
if got > maxEagerToolSchemaTokens {
t.Fatalf("eager tool schemas are %d tokens, over the %d ceiling — defer a tool or raise the ceiling deliberately", got, maxEagerToolSchemaTokens)
}
}
+45 -5
View File
@@ -3,9 +3,11 @@ package agent
import (
"context"
"fmt"
"math/rand"
"strings"
"time"
"github.com/Gitlawb/zero/internal/errhint"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
@@ -17,10 +19,20 @@ import (
// times. We retry ONLY the connect (not a partially-consumed stream), so no
// already-forwarded OnText is ever duplicated.
const (
maxStreamReconnects = 2
streamReconnectBase = 500 * time.Millisecond
// maxStreamReconnects is how many times the connect is re-issued after a
// transient disconnect. 4 (not 2): with jittered exponential backoff this rides
// out a multi-second network blip (~0.5s + 1s + 2s + 4s worst case) instead of
// dying on a 2s hiccup and re-burning every token on a restart.
maxStreamReconnects = 4
// streamReconnectMax caps a single backoff so the tail attempts don't wait
// minutes on a long outage.
streamReconnectMax = 8 * time.Second
)
// streamReconnectBase is the first-retry delay (doubled each subsequent attempt).
// A var, not a const, so tests can shrink it to keep the exhaustion path fast.
var streamReconnectBase = 500 * time.Millisecond
// reconnectNotifier is called before each retry with the 1-based attempt number
// and the max, so the caller can surface a "Reconnecting N/max" notice. Nil is
// fine.
@@ -73,7 +85,7 @@ func streamWithReconnect(ctx context.Context, provider Provider, request zerorun
if notify != nil {
notify(attempt, maxStreamReconnects)
}
if waitErr := sleepWithContext(ctx, backoffFor(attempt)); waitErr != nil {
if waitErr := sleepWithContext(ctx, jitteredBackoff(attempt)); waitErr != nil {
return nil, err // ctx cancelled while waiting; surface the original error
}
stream, err = provider.StreamCompletion(ctx, request)
@@ -96,6 +108,19 @@ func shouldReconnect(ctx context.Context, err error) bool {
if isContextLimitError(msg) || isImageRejectionError(err) {
return false
}
// HTTP 5xx statuses are non-reconnectable and must be excluded BEFORE the
// transport substring match below — otherwise "504 Gateway Timeout" would slip
// through on the generic "timeout" needle. 503 already exhausted
// providerio.SendWithRetry (retrying is a redundant double-retry); 500/502/504
// are non-idempotent by providerio's rule — the completion POST may already
// have reached the model before the upstream/gateway gave up, so replaying the
// connect risks duplicate billable work. Digit-boundary matched so an
// incidental "504" inside a latency/id number is not mistaken for a status.
if errhint.HasStatusCode(msg, "500", "502", "503", "504") {
return false
}
// Transport-level disconnects only. A genuine transport failure (EOF, reset,
// refused, timeout) means no response was received, which is safe to reconnect.
for _, needle := range []string{
"eof",
"connection reset",
@@ -106,8 +131,6 @@ func shouldReconnect(ctx context.Context, err error) bool {
"timed out",
"temporarily unavailable",
"i/o timeout",
"503",
"502",
"server closed",
"unexpected end",
} {
@@ -118,14 +141,31 @@ func shouldReconnect(ctx context.Context, err error) bool {
return false
}
// backoffFor is the deterministic exponential base delay for a 1-based attempt,
// capped at streamReconnectMax. Jitter is layered on separately (jitteredBackoff).
func backoffFor(attempt int) time.Duration {
d := streamReconnectBase
for i := 1; i < attempt; i++ {
if d >= streamReconnectMax {
return streamReconnectMax
}
d *= 2
}
if d > streamReconnectMax {
d = streamReconnectMax
}
return d
}
// jitteredBackoff adds up to 50% random jitter on top of backoffFor so concurrent
// runs (swarm members, a cron fleet) that all trip on the same outage don't
// reconnect in lockstep and hammer a recovering endpoint. Never shorter than the
// deterministic base, so backoff still grows attempt over attempt.
func jitteredBackoff(attempt int) time.Duration {
base := backoffFor(attempt)
return base + time.Duration(rand.Int63n(int64(base/2)+1))
}
func sleepWithContext(ctx context.Context, d time.Duration) error {
timer := time.NewTimer(d)
defer timer.Stop()
+30 -1
View File
@@ -44,6 +44,10 @@ func TestStreamWithReconnectRecoversFromTransientDisconnect(t *testing.T) {
}
func TestStreamWithReconnectGivesUpAfterMax(t *testing.T) {
// Shrink the backoff so exhausting all retries stays fast (real base would
// sleep ~7.5s across 4 attempts).
defer func(orig time.Duration) { streamReconnectBase = orig }(streamReconnectBase)
streamReconnectBase = time.Millisecond
// Always fails with a disconnect error → exhausts retries and returns it.
p := &flakyProvider{failBefore: 99, failErr: errors.New("connection reset by peer")}
_, err := streamWithReconnect(context.Background(), p, zeroruntime.CompletionRequest{}, nil)
@@ -87,7 +91,7 @@ func TestShouldReconnectClassification(t *testing.T) {
ctx := context.Background()
disconnects := []string{
"unexpected EOF", "connection reset by peer", "broken pipe",
"i/o timeout", "503 Service Unavailable", "server closed the connection",
"i/o timeout", "server closed the connection", "connection refused",
}
for _, m := range disconnects {
if !shouldReconnect(ctx, errors.New(m)) {
@@ -97,6 +101,13 @@ func TestShouldReconnectClassification(t *testing.T) {
notDisconnects := []string{
"context length exceeded", "invalid api key", "model not found",
"400 bad request: unsupported parameter",
// HTTP 5xx statuses are handled by providerio.SendWithRetry (503) or are
// non-idempotent (500/502/504); the reconnect path must not double-retry
// them. "504 Gateway Timeout" must NOT slip through on the generic "timeout"
// transport signal — a gateway timeout is non-idempotent, since the upstream
// may have already processed the completion POST before giving up.
"503 Service Unavailable", "502 Bad Gateway",
"504 Gateway Timeout", "500 Internal Server Error",
}
for _, m := range notDisconnects {
if shouldReconnect(ctx, errors.New(m)) {
@@ -112,6 +123,24 @@ func TestBackoffGrows(t *testing.T) {
if backoffFor(2) != 2*streamReconnectBase {
t.Fatalf("attempt 2 backoff = %v, want %v", backoffFor(2), 2*streamReconnectBase)
}
// The exponential base is capped so late attempts don't wait minutes.
if got := backoffFor(20); got != streamReconnectMax {
t.Fatalf("attempt 20 backoff = %v, want cap %v", got, streamReconnectMax)
}
}
func TestJitteredBackoffStaysInBounds(t *testing.T) {
// Jitter never drops below the deterministic base and never exceeds base*1.5,
// so backoff still grows attempt over attempt while decorrelating retries.
for attempt := 1; attempt <= 5; attempt++ {
base := backoffFor(attempt)
for i := 0; i < 200; i++ {
got := jitteredBackoff(attempt)
if got < base || got > base+base/2 {
t.Fatalf("attempt %d jittered backoff %v out of [%v, %v]", attempt, got, base, base+base/2)
}
}
}
}
func TestReconnectNoticeRoutesThroughReasoning(t *testing.T) {
+63
View File
@@ -0,0 +1,63 @@
package agent
import (
"strconv"
"strings"
"testing"
)
func TestSkillsContextCapsLongList(t *testing.T) {
skills := make([]SkillInfo, 0, 40)
for i := 0; i < 40; i++ {
n := strconv.Itoa(i)
skills = append(skills, SkillInfo{Name: "skill-" + n, Description: "does something useful, number " + n})
}
got := skillsContext(Options{Skills: skills})
if len(got) > 1200 {
t.Fatalf("skills block should stay bounded, got %d bytes:\n%s", len(got), got)
}
if !strings.Contains(got, "more (call skill") {
t.Fatalf("expected an overflow summary line, got:\n%s", got)
}
// The first skill is always listed regardless of budget.
if !strings.Contains(got, "- skill-0:") {
t.Fatalf("expected the first skill to always be listed, got:\n%s", got)
}
}
func TestSkillsContext(t *testing.T) {
if got := skillsContext(Options{}); got != "" {
t.Fatalf("no skills should yield an empty section, got %q", got)
}
got := skillsContext(Options{Skills: []SkillInfo{
{Name: "commit-writer", Description: "Write a conventional-commit message."},
{Name: " ", Description: "nameless, should be skipped"},
{Name: "reviewer"},
}})
if !strings.Contains(got, "<available_skills>") || !strings.Contains(got, "</available_skills>") {
t.Fatalf("missing available_skills block: %q", got)
}
if !strings.Contains(got, "- commit-writer: Write a conventional-commit message.") {
t.Fatalf("missing commit-writer line: %q", got)
}
if !strings.Contains(got, "- reviewer\n") {
t.Fatalf("reviewer (no description) line missing: %q", got)
}
if strings.Contains(got, "nameless") {
t.Fatalf("nameless entry should be skipped: %q", got)
}
}
func TestSystemPromptIncludesSkillsOnlyWhenInstalled(t *testing.T) {
with := buildSystemPrompt(Options{Skills: []SkillInfo{
{Name: "commit-writer", Description: "Write a commit message."},
}})
if !strings.Contains(with, "<available_skills>") || !strings.Contains(with, "skill tool") {
t.Fatalf("expected available_skills guidance in system prompt: %q", with)
}
// Default (no skills) must reproduce the prior prompt: no skills block.
without := buildSystemPrompt(Options{})
if strings.Contains(without, "<available_skills>") {
t.Fatalf("available_skills block must not appear without skills")
}
}
+65 -3
View File
@@ -5,6 +5,7 @@ import (
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"unicode/utf8"
@@ -86,6 +87,9 @@ func buildSystemPrompt(options Options) string {
if delegation := specialistDelegationContext(options); delegation != "" {
sections = append(sections, delegation)
}
if skillsBlock := skillsContext(options); skillsBlock != "" {
sections = append(sections, skillsBlock)
}
if style := responseStyleContext(options); style != "" {
sections = append(sections, style)
}
@@ -164,6 +168,64 @@ func specialistDelegationContext(options Options) string {
return b.String()
}
// skillsContext lists the reusable skills the model can pull in on demand via the
// skill tool, so it invokes the right one on the first try instead of guessing a
// name, failing, and reading the error. It mirrors specialistDelegationContext:
// names + one-line descriptions only (never skill bodies), and it renders nothing
// when no skills are installed, so a skill-less run reproduces the previous prompt
// byte-for-byte.
// skillsContextListBudget bounds the bytes spent listing individual skills so a
// workspace with dozens of them can't bloat every turn's prompt. Skills past the
// budget are summarized as a count rather than dropped — the model can still load
// any of them by name (an unknown name returns the full list from the skill tool).
const skillsContextListBudget = 640
func skillsContext(options Options) string {
if len(options.Skills) == 0 {
return ""
}
var b strings.Builder
b.WriteString("<available_skills>\n")
b.WriteString("Reusable, on-demand instruction sets you can load with the skill tool. When a request matches one, call skill with its exact name to pull in its guidance before acting — do not guess names or skip a relevant skill.\n")
listed, spent, omitted := 0, 0, 0
for _, info := range options.Skills {
name := strings.TrimSpace(info.Name)
if name == "" {
continue
}
line := "- " + name
if desc := strings.TrimSpace(info.Description); desc != "" {
line += ": " + truncateForSkillLine(desc)
}
line += "\n"
// Always list at least one skill; past the budget, summarize the remainder
// as a count instead of silently dropping it.
if listed > 0 && spent+len(line) > skillsContextListBudget {
omitted++
continue
}
b.WriteString(line)
spent += len(line)
listed++
}
if omitted > 0 {
b.WriteString("- …and " + strconv.Itoa(omitted) + " more (call skill with a name; an unknown name lists them all)\n")
}
b.WriteString("</available_skills>")
return b.String()
}
// truncateForSkillLine keeps a skill's one-line description short so a single
// verbose description can't dominate the skills-list budget.
func truncateForSkillLine(desc string) string {
const maxDescRunes = 100
runes := []rune(desc)
if len(runes) <= maxDescRunes {
return desc
}
return strings.TrimSpace(string(runes[:maxDescRunes])) + "…"
}
func sessionRuntimeContext(options Options) string {
provider := strings.TrimSpace(options.ProviderName)
model := strings.TrimSpace(options.Model)
@@ -310,9 +372,9 @@ func projectGuidelineLabel(match, gitRoot string) string {
// findProjectContextFile returns the first matching project guideline file
// in dir. Lookup is case-insensitive on the basename and on the actual
// filename returned, so a git-tracked file like AGENTS.MD (uppercase MD)
// still resolves to its true filename on case-sensitive filesystems — the
// returned path is what should appear in the project guidelines label.
// filename returned, so a git-tracked file like AGENTS.md still resolves to
// its true filename on case-sensitive filesystems — the returned path is what
// should appear in the project guidelines label.
// Returns "" when nothing matches.
func findProjectContextFile(dir string) string {
for _, name := range projectContextFiles {
+7 -9
View File
@@ -46,14 +46,10 @@ work.
formatting-only edits unless the user asked for them.
4. **Verify.** Verify after edits; see the testing gate below. This is
mandatory.
5. **Summarize.** For a substantial task, close with an ELABORATE, well-structured
summary — never a single throwaway line. Use short headings and bullets: a
one-line "what I built/changed"; **Where it lives** — the key files and how they
fit together; **How each requirement is met** — walk through each requirement
from the request and name the specifics that satisfy it; what validation ran and
what was NOT verified; and a brief "Next steps" or "Want me to…?" offer. Scale
the depth to the work — a big build earns several sections, a trivial fix earns
a line or two.
5. **Summarize.** Close with a summary scaled to the work: a trivial fix earns one
line; a substantial change earns a few short bullets covering what changed, where
it lives, and what you did and did not verify. Lead with the outcome, and never
pad — do not restate the task or narrate steps the user already watched you take.
## Editing discipline
@@ -64,7 +60,9 @@ work.
- Make one tool call per file. Do not batch multi-file writes into a single
shell or script invocation.
- For edits to existing files, prefer edit_file or apply_patch with minimal,
targeted diffs. Match the existing indentation, imports, and idioms.
targeted diffs. Match the existing indentation, imports, and idioms. Match the
file's comment density: do not add explanatory comments unless the user asks or
the code is already comment-dense.
- Preserve behavior you were not asked to change. Do not delete or rewrite code
you did not author unless the task requires it; if you must, say so.
+10 -12
View File
@@ -6,11 +6,13 @@ import "strings"
//
// Different model families respond to different framing: the GPT family benefits
// from an explicit markdown/output spec and a strong "prefer native tools" nudge;
// Gemini benefits from explicit tool-preference and conciseness guidance; the
// Claude family is already well-aligned with the core prompt and only needs a
// light comment-discipline nudge. modelPromptAddendum returns the family-specific
// block appended after the core prompt. Classification is by model id (the agent
// only has the model string — no registry dependency).
// Gemini benefits from explicit tool-preference and conciseness guidance. The
// Claude family is already aligned with the core prompt and needs no addendum —
// comment discipline is universal in the core prompt now, so every family gets it.
// modelPromptAddendum returns the family-specific block appended after the core
// prompt, or "" when the family is unknown or needs no specialization.
// Classification is by model id (the agent only has the model string — no registry
// dependency).
const (
familyOpenAI = "openai"
@@ -45,9 +47,10 @@ func modelPromptAddendum(model string) string {
return openAIPromptAddendum
case familyGemini:
return geminiPromptAddendum
case familyAnthropic:
return anthropicPromptAddendum
default:
// Anthropic (aligned with the core prompt) and unknown families get no
// family-specific block; comment discipline now lives in the core prompt
// for every model.
return ""
}
}
@@ -70,8 +73,3 @@ const geminiPromptAddendum = `<model_guidance>
in one short clause why it is needed.
- Use update_plan for any multi-step task and keep it current.
</model_guidance>`
const anthropicPromptAddendum = `<model_guidance>
- Do not add code comments unless the user asks or the surrounding code is
comment-dense; match the file's existing comment density.
</model_guidance>`
+4 -2
View File
@@ -29,9 +29,11 @@ func TestBuildSystemPromptAppendsModelAddendum(t *testing.T) {
if got := buildSystemPrompt(Options{Model: "gpt-5"}); !strings.Contains(got, openAIPromptAddendum) {
t.Fatalf("expected the OpenAI addendum in the gpt-5 prompt")
}
// Claude is aligned with the core prompt and gets no family addendum now that
// comment discipline is universal; it must not pick up another family's block.
claude := buildSystemPrompt(Options{Model: "claude-opus-4-6"})
if !strings.Contains(claude, anthropicPromptAddendum) {
t.Fatalf("expected the Anthropic addendum in the claude prompt")
if strings.Contains(claude, "<model_guidance>") {
t.Fatalf("expected no model_guidance block for Claude (aligned with core prompt)")
}
if strings.Contains(claude, openAIPromptAddendum) {
t.Fatalf("the claude prompt must not contain the OpenAI addendum")
+3 -2
View File
@@ -23,7 +23,8 @@ func TestCoreSystemPromptIncludesCodingQualityRules(t *testing.T) {
"avoid broad refactors",
"search the web before answering",
"do not recognize",
"how each requirement is met",
"scaled to the work",
"comment density",
} {
if !strings.Contains(prompt, want) {
t.Fatalf("expected core system prompt to include %q, got:\n%s", want, buildSystemPrompt(Options{}))
@@ -128,7 +129,7 @@ func systemPromptTestBlock(t *testing.T, prompt, start, end string) string {
}
func TestBuildSystemPromptInjectsProjectGuidelinesCaseInsensitive(t *testing.T) {
// Git tracks AGENTS.MD (uppercase MD) on a case-sensitive filesystem; the
// Git tracks AGENTS.md on a case-sensitive filesystem; the
// loader must still resolve it when the cwd lookup uses lowercase.
cwd := t.TempDir()
if err := os.WriteFile(filepath.Join(cwd, "AGENTS.MD"), []byte("Always run `make lint`."), 0o644); err != nil {
+22
View File
@@ -174,6 +174,16 @@ type SpecialistInfo struct {
WhenToUse string
}
// SkillInfo is a one-line summary of a reusable, on-demand skill (its name and
// frontmatter description) surfaced to the system prompt so the model can invoke
// the right skill with the skill tool on the first try instead of guessing a name
// and reading the failure. Like SpecialistInfo it is plain data, so the agent
// package needs no dependency on internal/skills.
type SkillInfo struct {
Name string
Description string
}
type Options struct {
MaxTurns int
// DeferThreshold activates deferred MCP-tool loading: when the number of
@@ -189,6 +199,12 @@ type Options struct {
// populated only where the Task tool is actually registered, so an empty slice
// (the default) reproduces the previous prompt byte-for-byte.
Specialists []SpecialistInfo
// Skills lists the reusable skills installed for this run (the default skills
// dir merged with any plugin skill roots). When non-empty the system prompt
// gains an <available_skills> block naming them so the model loads the right one
// via the skill tool on the first try. Empty (the default) reproduces the
// previous prompt byte-for-byte.
Skills []SkillInfo
// Specialist/sub-agent metadata is carried through exec now and consumed by
// the specialist runtime in later slices.
SessionID string
@@ -269,6 +285,12 @@ type Options struct {
// ceiling and the autonomy gate. nil disables it entirely (the loop is
// byte-identical to before). One instance per run — it holds attempt state.
SelfCorrect *SelfCorrector
// FileDiagnostics, when set, is passed to mutating tools so edit_file /
// write_file append error-severity language diagnostics for the file they
// just wrote directly to their tool output — the model sees an error it
// introduced in the same turn instead of a later verification pass. Build
// one with NewFileDiagnostics. nil disables inline diagnostics.
FileDiagnostics func(ctx context.Context, absPath string) string
// RequireCompletionSignal gates run completion for HEADLESS exec. Without it,
// any assistant turn that produces text but no tool call is accepted as the
+37 -4
View File
@@ -19,6 +19,7 @@ import (
"github.com/Gitlawb/zero/internal/hooks"
"github.com/Gitlawb/zero/internal/localcontrol"
"github.com/Gitlawb/zero/internal/mcp"
"github.com/Gitlawb/zero/internal/modelregistry"
"github.com/Gitlawb/zero/internal/observability"
"github.com/Gitlawb/zero/internal/plugins"
"github.com/Gitlawb/zero/internal/providerhealth"
@@ -80,6 +81,7 @@ type appDeps struct {
runTUI func(context.Context, tui.Options) int
runEditor func(string) error
checkUpdate func(context.Context, update.Options) (update.Result, error)
applyUpdate func(context.Context, update.Options) (update.ApplyResult, error)
now func() time.Time
}
@@ -180,6 +182,7 @@ func defaultAppDeps() appDeps {
runTUI: tui.Run,
runEditor: openEditor,
checkUpdate: update.Check,
applyUpdate: update.Apply,
now: time.Now,
}
}
@@ -227,6 +230,12 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
defer observability.Recover(observability.DefaultCrashDir(), "cli", stderr, &exitCode)
deps = fillAppDeps(deps)
// CLI runs opt into the models.dev overlay (cached live context limits and
// pricing on top of the curated catalog). Explicitly enabled here — and only
// here — so library consumers and hermetic tests are never perturbed by a
// cache file on the machine. The refresh itself is fired in exec/TUI startup.
modelregistry.EnableModelsDevOverlay()
addDirs, args, err := splitLeadingAddDirFlags(args)
if err != nil {
return writeAppError(stderr, err.Error(), 1)
@@ -383,6 +392,8 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
return runSandbox(args[1:], stdout, stderr, deps)
case "update":
return runUpdate(args[1:], stdout, stderr, deps)
case "upgrade":
return runUpgrade(args[1:], stdout, stderr, deps)
case "worktrees", "worktree":
return runWorktrees(args[1:], stdout, stderr, deps)
case "verify":
@@ -515,6 +526,9 @@ func fillAppDeps(deps appDeps) appDeps {
if deps.checkUpdate == nil {
deps.checkUpdate = defaults.checkUpdate
}
if deps.applyUpdate == nil {
deps.applyUpdate = defaults.applyUpdate
}
if deps.now == nil {
deps.now = defaults.now
}
@@ -537,6 +551,11 @@ func runInteractiveTUI(stderr io.Writer, deps appDeps, permissionMode agent.Perm
}
func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode agent.PermissionMode, addDirs []string, theme string, forceSetup bool) int {
// Refresh the models.dev pricing/limits cache in the background when stale;
// the overlay is read at registry construction from the cache file, so this
// benefits the next run and never blocks or fails this one.
go func() { _ = modelregistry.RefreshModelsDevCache(context.Background()) }()
workspaceRoot, err := deps.getwd()
if err != nil {
return writeAppError(stderr, "failed to resolve workspace: "+err.Error(), 1)
@@ -557,8 +576,19 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
if !errors.Is(err, config.ErrNoActiveProvider) && !errors.Is(err, config.ErrProviderRequiresModel) {
return writeAppError(stderr, err.Error(), 1)
}
resolved = config.ResolvedConfig{}
forceSetup = true
// ErrNoActiveProvider can mean "nothing configured yet" (needs onboarding)
// or "providers ARE configured, just none marked active" (e.g. config.json's
// activeProvider is blank/stale). In the second case resolved.Providers still
// carries the already-normalized list — prefer falling back to one of those
// over wiping everything and forcing the user to re-enter credentials they
// already saved.
if usable, ok := firstUsableProvider(resolved.Providers); errors.Is(err, config.ErrNoActiveProvider) && ok {
resolved.Provider = usable
resolved.ActiveProvider = usable.Name
} else {
resolved = config.ResolvedConfig{}
forceSetup = true
}
}
userConfigPath, err := deps.userConfigPath()
if err != nil {
@@ -740,9 +770,11 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
Hooks: newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks),
DeferThreshold: resolved.Tools.DeferThreshold,
Specialists: specialistRuntime.specialists,
Skills: pluginActivation.skillInfos(deps.skillsDir()),
},
PermissionMode: permissionMode,
Notify: resolved.Notify,
KeyBindings: resolved.KeyBindings,
Setup: tui.SetupOptions{
Visible: setupVisible,
Required: needsSetup,
@@ -1158,7 +1190,8 @@ func profileHasCredential(profile config.ProviderProfile) bool {
}
// baseURLIsLoopback reports whether a provider base_url points at a loopback host
// (a keyless local provider like Ollama/LM Studio), which needs no API key.
// or a private-network host (192.168.x.y / 10.x.y.z / 172.16.x.y) — a local
// provider like Ollama/LM Studio that needs no API key.
func baseURLIsLoopback(baseURL string) bool {
u := strings.TrimSpace(baseURL)
if u == "" {
@@ -1173,7 +1206,7 @@ func baseURLIsLoopback(baseURL string) bool {
return true
}
if addr, err := netip.ParseAddr(host); err == nil {
return addr.IsLoopback()
return addr.IsLoopback() || addr.IsPrivate()
}
return false
}
+208
View File
@@ -205,6 +205,67 @@ func TestRunNoArgsEntersSetupWhenResolveReportsNoActiveProvider(t *testing.T) {
}
}
func TestRunNoArgsFallsBackToUsableProviderWhenNoneMarkedActive(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
cwd := t.TempDir()
setCLIUserConfigRoot(t)
userConfigPath := filepath.Join(t.TempDir(), "zero", "config.json")
var launchedOptions tui.Options
launched := false
var providerProfile config.ProviderProfile
fake := &cliFakeProvider{}
usable := config.ProviderProfile{
Name: "work",
ProviderKind: config.ProviderKindOpenAI,
BaseURL: config.OpenAIBaseURL,
APIKey: "sk-test",
Model: "gpt-test",
}
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
getwd: func() (string, error) {
return cwd, nil
},
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
// config.json has providers configured but none marked active (e.g. a
// blank/stale activeProvider field) — Resolve returns the successfully
// normalized providers list alongside ErrNoActiveProvider.
return config.ResolvedConfig{Providers: []config.ProviderProfile{usable}},
fmt.Errorf("%w: active provider %q not found", config.ErrNoActiveProvider, "")
},
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
providerProfile = profile
return fake, nil
},
userConfigPath: func() (string, error) {
return userConfigPath, nil
},
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
return noopMCPRuntime{}, nil
},
runTUI: func(ctx context.Context, options tui.Options) int {
launched = true
launchedOptions = options
return 0
},
})
if exitCode != 0 {
t.Fatalf("exit code = %d, want 0, stderr=%q", exitCode, stderr.String())
}
if !launched {
t.Fatal("TUI was not launched")
}
if launchedOptions.Setup.Visible || launchedOptions.Setup.Required {
t.Fatalf("Setup = %#v, want no setup wizard — a usable saved provider exists", launchedOptions.Setup)
}
if providerProfile.Name != "work" {
t.Fatalf("provider used = %q, want fallback to the usable saved provider %q", providerProfile.Name, "work")
}
}
func TestRunNoArgsFailsWhenResolveErrorIsNotProviderRelated(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
@@ -1267,6 +1328,153 @@ func TestRunUpdateReportsUpToDate(t *testing.T) {
}
}
func TestRunUpdateApplyTextAndJSON(t *testing.T) {
applyResult := update.ApplyResult{
Result: update.Result{
CurrentVersion: "dev",
LatestVersion: "0.2.0",
UpdateAvailable: true,
},
Applied: true,
InstallMethod: update.InstallMethodStandalone,
BinaryPath: "/usr/local/bin/zero",
Message: "updated to 0.2.0",
}
var gotOptions update.Options
deps := appDeps{
applyUpdate: func(_ context.Context, options update.Options) (update.ApplyResult, error) {
gotOptions = options
return applyResult, nil
},
checkUpdate: func(context.Context, update.Options) (update.Result, error) {
t.Fatal("checkUpdate should not run for --apply")
return update.Result{}, nil
},
}
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"update", "--apply"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
if !strings.Contains(stdout.String(), "updated to 0.2.0") {
t.Fatalf("unexpected apply text: %q", stdout.String())
}
if gotOptions.CurrentVersion != "dev" {
t.Fatalf("unexpected options passed to applyUpdate: %#v", gotOptions)
}
stdout.Reset()
stderr.Reset()
exitCode = runWithDeps([]string{"update", "--apply", "--json"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
var payload struct {
Applied bool `json:"applied"`
InstallMethod string `json:"installMethod"`
BinaryPath string `json:"binaryPath"`
}
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("apply JSON did not decode: %v\n%s", err, stdout.String())
}
if !payload.Applied || payload.InstallMethod != string(update.InstallMethodStandalone) || payload.BinaryPath != applyResult.BinaryPath {
t.Fatalf("unexpected apply JSON: %#v", payload)
}
}
func TestRunUpgradeDefaultsToApply(t *testing.T) {
var applyCalled bool
deps := appDeps{
applyUpdate: func(context.Context, update.Options) (update.ApplyResult, error) {
applyCalled = true
return update.ApplyResult{Message: "already up to date"}, nil
},
checkUpdate: func(context.Context, update.Options) (update.Result, error) {
t.Fatal("checkUpdate should not run for `zero upgrade`")
return update.Result{}, nil
},
}
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"upgrade"}, &stdout, &stderr, deps)
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
if !applyCalled {
t.Fatal("expected `zero upgrade` to call applyUpdate")
}
}
func TestRunUpdateRejectsCheckAndApplyTogether(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"update", "--check", "--apply"}, &stdout, &stderr, appDeps{
checkUpdate: func(context.Context, update.Options) (update.Result, error) {
t.Fatal("checkUpdate should not run")
return update.Result{}, nil
},
applyUpdate: func(context.Context, update.Options) (update.ApplyResult, error) {
t.Fatal("applyUpdate should not run")
return update.ApplyResult{}, nil
},
})
if exitCode != exitUsage {
t.Fatalf("expected usage exit code, got %d", exitCode)
}
if got := stderr.String(); !strings.Contains(got, "only one of --check or --apply") {
t.Fatalf("expected --check/--apply usage error, got %q", got)
}
}
func TestRunUpdateRejectsTargetWithApply(t *testing.T) {
for _, args := range [][]string{
{"update", "--apply", "--target", "linux-arm64"},
{"upgrade", "--target", "linux-arm64"},
} {
t.Run(strings.Join(args, " "), func(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps(args, &stdout, &stderr, appDeps{
applyUpdate: func(context.Context, update.Options) (update.ApplyResult, error) {
t.Fatal("applyUpdate should not run when --target is combined with --apply")
return update.ApplyResult{}, nil
},
})
if exitCode != exitUsage {
t.Fatalf("expected usage exit code, got %d", exitCode)
}
if got := stderr.String(); !strings.Contains(got, "--target cannot be combined with --apply") {
t.Fatalf("expected --target/--apply usage error, got %q", got)
}
})
}
}
func TestRunUpdateReportsApplyError(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode := runWithDeps([]string{"update", "--apply"}, &stdout, &stderr, appDeps{
applyUpdate: func(context.Context, update.Options) (update.ApplyResult, error) {
return update.ApplyResult{}, errors.New("download failed")
},
})
if exitCode == exitSuccess {
t.Fatalf("expected non-success exit code, got %d", exitCode)
}
if got := stderr.String(); !strings.Contains(got, "download failed") {
t.Fatalf("expected apply error, got %q", got)
}
}
func assertHelpOutput(t *testing.T, args []string) {
t.Helper()
+19 -3
View File
@@ -301,12 +301,27 @@ func runAuthLogin(args []string, stdout io.Writer, stderr io.Writer, deps appDep
if len(parsed.positional) != 1 {
return writeExecUsageError(stderr, "usage: zero auth login <provider> [--device] [--scope <scope>]")
}
provider := parsed.positional[0]
// ChatGPT (Codex) requires a fixed redirect_uri (http://localhost:1455/
// auth/callback) and mandatory authorize params (id_token_add_organizations,
// codex_cli_simplified_flow, originator) that the generic loopback flow
// cannot supply. Route it to the dedicated ChatGPT login so
// `zero auth login chatgpt` behaves identically to `zero auth chatgpt`.
if strings.EqualFold(provider, "chatgpt") {
if parsed.device {
return writeExecUsageError(stderr, "ChatGPT login does not support --device (it is loopback-only)")
}
if len(parsed.scopes) > 0 {
return writeExecUsageError(stderr, "ChatGPT login does not support --scope (the required scopes are fixed by the Codex client registration)")
}
return runAuthChatGPT(nil, stdout, stderr, deps)
}
manager, err := newAuthManager(deps, stdout)
if err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
status, err := manager.Login(context.Background(), oauth.LoginOptions{
Provider: parsed.positional[0],
Provider: provider,
Device: parsed.device,
ExtraScopes: parsed.scopes,
})
@@ -489,8 +504,9 @@ Commands:
A provider is any OAuth 2.0 / OIDC server. "openrouter" ('zero auth openrouter')
works out of the box. "xai" ('zero auth login xai') uses a built-in preset that is
off by default — enable it with ZERO_OAUTH_ALLOW_PRESETS=1, or set the
ZERO_OAUTH_XAI_* vars yourself. Any preset field is overridable via the env vars
below. For a custom provider named <name>, set:
ZERO_OAUTH_XAI_* vars yourself. "chatgpt" ('zero auth login chatgpt' or
'zero auth chatgpt') uses a fixed-port loopback flow against the Codex backend.
Any preset field is overridable via the env vars below. For a custom provider named <name>, set:
ZERO_OAUTH_<NAME>_CLIENT_ID (required)
ZERO_OAUTH_<NAME>_CLIENT_SECRET (optional)
ZERO_OAUTH_<NAME>_AUTHORIZE_URL ZERO_OAUTH_<NAME>_TOKEN_URL
+41
View File
@@ -156,3 +156,44 @@ func TestRunAuthHelp(t *testing.T) {
}
}
}
// TestRunAuthLoginChatGPTRoutesToDedicatedFlow verifies `zero auth login
// chatgpt` reaches the dedicated ChatGPT login (fixed-port loopback + mandatory
// authorize params), not the generic manager path. The generic login accepts
// --device, so a ChatGPT-specific rejection proves the routing took effect.
// See issue #430: the generic path built a random-port 127.0.0.1 redirect_uri
// without the required extra params, so OpenAI's authorize endpoint rejected it.
func TestRunAuthLoginChatGPTRoutesToDedicatedFlow(t *testing.T) {
withAuthStore(t)
var stdout, stderr bytes.Buffer
if code := runWithDeps([]string{"auth", "login", "chatgpt", "--device"}, &stdout, &stderr, appDeps{}); code == exitSuccess {
t.Fatal("auth login chatgpt --device should be rejected (ChatGPT is loopback-only)")
}
if !strings.Contains(stderr.String(), "ChatGPT login does not support --device") {
t.Fatalf("stderr = %q, want the ChatGPT-specific --device rejection", stderr.String())
}
// Case-insensitive provider name should still route.
stdout.Reset()
stderr.Reset()
if code := runWithDeps([]string{"auth", "login", "ChatGPT", "--device"}, &stdout, &stderr, appDeps{}); code == exitSuccess {
t.Fatal("auth login ChatGPT --device should be rejected")
}
if !strings.Contains(stderr.String(), "ChatGPT login does not support --device") {
t.Fatalf("stderr = %q, want the ChatGPT-specific rejection (case-insensitive)", stderr.String())
}
}
// TestRunAuthLoginChatGPTRejectsScope mirrors the --device rejection: --scope
// must not be silently dropped on the ChatGPT path. The Codex client
// registration pins a fixed scope set (incl. api.connectors.*), so custom
// scopes are rejected up front rather than plumbed through.
func TestRunAuthLoginChatGPTRejectsScope(t *testing.T) {
withAuthStore(t)
var stdout, stderr bytes.Buffer
if code := runWithDeps([]string{"auth", "login", "chatgpt", "--scope", "custom-scope"}, &stdout, &stderr, appDeps{}); code == exitSuccess {
t.Fatal("auth login chatgpt --scope should be rejected")
}
if !strings.Contains(stderr.String(), "ChatGPT login does not support --scope") {
t.Fatalf("stderr = %q, want the ChatGPT-specific --scope rejection", stderr.String())
}
}
+35 -13
View File
@@ -12,6 +12,7 @@ import (
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/errhint"
"github.com/Gitlawb/zero/internal/imageinput"
"github.com/Gitlawb/zero/internal/lsp"
"github.com/Gitlawb/zero/internal/modelregistry"
@@ -135,6 +136,11 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
return exitSuccess
}
// Refresh the models.dev pricing/limits cache in the background when stale;
// the overlay is read at registry construction from the cache file, so this
// benefits the next run and never blocks or fails this one.
go func() { _ = modelregistry.RefreshModelsDevCache(context.Background()) }()
// A mode seeds model/effort/max-turns/tool filters as a preset. Expand it up
// front — before tool-filter validation and the --list-tools branch — so a
// mode-injected tool filter is validated and reflected in --list-tools, and a
@@ -499,15 +505,17 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
// by default the corrector is nil, leaving the agent loop byte-identical. When
// on we verify with both the workspace test plan and LSP diagnostics over the
// changed files; the autonomy gate inside the corrector still decides whether
// failures auto-fix or just report. lspShutdown tears down any language-server
// sessions the LSP half spawned (no-op when self-correct is off).
selfCorrector, lspShutdown := newExecSelfCorrector(options.selfCorrect, workspaceRoot, options.autonomy)
// failures auto-fix or just report. fileDiagnostics (always on, lazy) gives
// edit_file/write_file inline error diagnostics for the file they just wrote.
// lspShutdown tears down any language-server sessions either half spawned.
selfCorrector, fileDiagnostics, lspShutdown := newExecSelfCorrector(options.selfCorrect, workspaceRoot, options.autonomy)
defer lspShutdown()
result, err := agent.Run(runCtx, agentPrompt, provider, agent.Options{
MaxTurns: resolved.MaxTurns,
ContextWindow: resolveAgentContextWindow(runCtx, modelRegistry, resolved.Provider),
DeferThreshold: effectiveDeferThreshold,
Specialists: specialistRuntime.specialistInfos(),
Skills: pluginActivation.skillInfos(deps.skillsDir()),
SessionID: preparedSession.Session.SessionID,
CallingSessionID: options.callingSessionID,
CallingToolUseID: options.callingToolUseID,
@@ -524,6 +532,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
PermissionMode: permissionMode,
Autonomy: options.autonomy,
SelfCorrect: selfCorrector,
FileDiagnostics: fileDiagnostics,
// Headless exec: don't accept a no-tool-call turn as "done" while work
// clearly remains (pending plan items / a mid-step continuation cue) —
// nudge to continue, and finalize as INCOMPLETE rather than false success
@@ -699,23 +708,28 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
// unless a changed file's language actually has one installed on PATH. The
// returned cleanup shuts that manager down (terminating any spawned server
// sessions); it is a no-op when self-correct is off.
func newExecSelfCorrector(enabled bool, workspaceRoot string, autonomy string) (*agent.SelfCorrector, func()) {
if !enabled {
return nil, func() {}
}
func newExecSelfCorrector(enabled bool, workspaceRoot string, autonomy string) (*agent.SelfCorrector, func(context.Context, string) string, func()) {
// The manager is created regardless of --self-correct: it also backs the
// always-on inline post-edit diagnostics (agent.NewFileDiagnostics), and it
// stays lazy — no language server is spawned unless an edited file's
// language actually has one installed on PATH.
manager := lsp.NewManager(workspaceRoot)
cleanup := func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = manager.Shutdown(ctx)
}
fileDiagnostics := agent.NewFileDiagnostics(manager, workspaceRoot)
if !enabled {
return nil, fileDiagnostics, cleanup
}
corrector := agent.NewSelfCorrector(workspaceRoot, agent.NewLSPDiagnosticsChecker(manager), agent.NewProjectVerifier(workspaceRoot), agent.SelfCorrectConfig{
Enabled: true,
IncludeTests: true,
IncludeLSP: true,
Autonomy: autonomy,
})
cleanup := func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = manager.Shutdown(ctx)
}
return corrector, cleanup
return corrector, fileDiagnostics, cleanup
}
func deferredEligibleCount(registry *tools.Registry, permissionMode agent.PermissionMode, enabledTools []string, disabledTools []string) int {
@@ -948,6 +962,14 @@ func writeExecProviderError(stdout io.Writer, stderr io.Writer, format execOutpu
if _, err := fmt.Fprintf(stderr, "[zero] %s\n", message); err != nil {
return exitCrash
}
// Append a one-line next step for recognized provider failures (auth /
// rate-limit / connectivity / …). The classifier gates on a provider-origin
// marker, so non-provider codes (sandbox_error, mcp_error) never draw a hint.
if hint := errhint.CLIHint(errors.New(message)); hint != "" {
if _, err := fmt.Fprintf(stderr, "[zero] %s\n", hint); err != nil {
return exitCrash
}
}
return exitProvider
}
+50
View File
@@ -0,0 +1,50 @@
package cli
import (
"bytes"
"strings"
"testing"
)
// A recognized provider error in text mode prints the raw message plus a one-line
// actionable hint; a non-provider error (or JSON mode) prints no hint.
func TestWriteExecProviderErrorAppendsHint(t *testing.T) {
t.Run("provider auth error gets a hint", func(t *testing.T) {
var stdout, stderr bytes.Buffer
code := writeExecProviderError(&stdout, &stderr, execOutputText, "provider_error",
"auth error: your API key is missing or invalid")
if code != exitProvider {
t.Fatalf("exit code = %d, want exitProvider", code)
}
out := stderr.String()
if !strings.Contains(out, "auth error:") {
t.Fatalf("expected raw message, got %q", out)
}
if !strings.Contains(out, "zero auth") {
t.Fatalf("expected an actionable hint referencing `zero auth`, got %q", out)
}
})
t.Run("non-provider error gets no hint", func(t *testing.T) {
var stdout, stderr bytes.Buffer
writeExecProviderError(&stdout, &stderr, execOutputText, "sandbox_error",
"sandbox setup failed: permission denied")
out := stderr.String()
// Exactly one "[zero]" line — no spurious hint attached to a local error.
if n := strings.Count(out, "[zero]"); n != 1 {
t.Fatalf("expected exactly one [zero] line for a non-provider error, got %d:\n%s", n, out)
}
})
t.Run("json mode never appends a hint line", func(t *testing.T) {
var stdout, stderr bytes.Buffer
writeExecProviderError(&stdout, &stderr, execOutputJSON, "provider_error",
"auth error: bad key")
if stderr.Len() != 0 {
t.Fatalf("json mode must not write to stderr, got %q", stderr.String())
}
if strings.Contains(stdout.String(), "zero auth") {
t.Fatalf("json mode must not inject a hint into the structured payload, got %q", stdout.String())
}
})
}
+23
View File
@@ -3,7 +3,9 @@ package cli
import (
"fmt"
"io"
"strings"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/hooks"
"github.com/Gitlawb/zero/internal/plugins"
"github.com/Gitlawb/zero/internal/tools"
@@ -57,6 +59,27 @@ func activatePlugins(workspaceRoot string, registry *tools.Registry, deps appDep
return pluginActivation{hooks: result.Hooks, skillRoots: result.SkillRoots}
}
// skillInfos resolves the reusable skills the model can load via the skill tool —
// the default skills dir merged with any plugin skill roots, the same set the
// plugin-aware skill tool resolves against — as plain data for the agent's system
// prompt. It returns nil when no skills are installed, so a skill-less run leaves
// the prompt byte-identical.
func (a pluginActivation) skillInfos(defaultDir string) []agent.SkillInfo {
merged, _ := plugins.MergedSkills(defaultDir, a.skillRoots)
if len(merged) == 0 {
return nil
}
infos := make([]agent.SkillInfo, 0, len(merged))
for _, skill := range merged {
name := strings.TrimSpace(skill.Name)
if name == "" {
continue
}
infos = append(infos, agent.SkillInfo{Name: name, Description: strings.TrimSpace(skill.Description)})
}
return infos
}
// formatLoadDiagnostic renders a plugin load diagnostic into a single warning
// line, prefixing the diagnostic kind and appending the most specific locator
// available (manifest path, then plugin path, then plugin ID) so an operator can
@@ -17,7 +17,6 @@ func TestProviderCatalogRuntimeProvidersShowOnboardingSetupHints(t *testing.T) {
defaultModel string
}{
{id: "openai", name: "OpenAI", transport: "openai", defaultModel: "gpt-4.1"},
{id: "aimlapi", name: "AI/ML API", transport: "openai-compatible", defaultModel: "openai/gpt-5-chat"},
{id: "groq", name: "Groq", transport: "openai-compatible", defaultModel: "llama-3.3-70b-versatile"},
{id: "longcat", name: "LongCat", transport: "openai-compatible", defaultModel: "LongCat-2.0"},
{id: "ollama-cloud", name: "Ollama Cloud", transport: "openai-compatible", defaultModel: "qwen3-coder:480b"},
@@ -48,21 +47,6 @@ func TestProviderCatalogRuntimeProvidersShowOnboardingSetupHints(t *testing.T) {
}
}
func TestProviderCatalogAIMLAPIRequiresAPIKey(t *testing.T) {
output := runProviderCatalogOnboarding(t)
block := providerCatalogOnboardingBlock(t, output, "aimlapi")
for _, want := range []string{
"requiresAuth=true",
"authEnvVars=AIMLAPI_API_KEY",
"setup: zero providers setup aimlapi --set-active",
} {
if !strings.Contains(block, want) {
t.Fatalf("expected AIMLAPI catalog block to contain %q, got:\n%s", want, block)
}
}
}
func TestProviderCatalogLongCatRequiresAPIKey(t *testing.T) {
output := runProviderCatalogOnboarding(t)
block := providerCatalogOnboardingBlock(t, output, "longcat")
+6
View File
@@ -3,6 +3,7 @@ package cli
import (
"fmt"
"io"
"net"
"net/url"
"strconv"
"strings"
@@ -453,6 +454,11 @@ func providerProfileIsLocal(profile config.ProviderProfile) bool {
case "localhost", "127.0.0.1", "::1":
return true
}
// Also treat explicit private-network IPs (192.168.x.y, 10.x.y.z,
// 172.16-31.x.y) as local — the user is pointing at their own LAN box.
if ip := net.ParseIP(parsed.Hostname()); ip != nil && ip.IsPrivate() {
return true
}
return false
}
+51 -8
View File
@@ -12,6 +12,7 @@ import (
type updateOptions struct {
check bool
apply bool
json bool
repository string
endpoint string
@@ -20,6 +21,18 @@ type updateOptions struct {
}
func runUpdate(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
return runUpdateCommand(args, stdout, stderr, deps, false)
}
func runUpgrade(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
return runUpdateCommand(args, stdout, stderr, deps, true)
}
// runUpdateCommand backs both `zero update` and `zero upgrade`. defaultApply
// makes `--apply` the implicit behavior for `zero upgrade` when neither
// `--check` nor `--apply` is passed explicitly; `zero update` keeps requiring
// one of the two so existing scripts around `zero update --check` don't change.
func runUpdateCommand(args []string, stdout io.Writer, stderr io.Writer, deps appDeps, defaultApply bool) int {
options, help, err := parseUpdateArgs(args)
if err != nil {
return writeUsageError(stderr, err.Error())
@@ -30,10 +43,19 @@ func runUpdate(args []string, stdout io.Writer, stderr io.Writer, deps appDeps)
}
return exitSuccess
}
if !options.check {
return writeUsageError(stderr, "Only `zero update --check` is available right now.")
if options.check && options.apply {
return writeUsageError(stderr, "Pass only one of --check or --apply.")
}
checkOptions := update.Options{
if !options.check && !options.apply {
if !defaultApply {
return writeUsageError(stderr, "Pass --check to check for updates or --apply to install one (or use `zero upgrade`).")
}
options.apply = true
}
if options.apply && options.target != "" {
return writeUsageError(stderr, "--target cannot be combined with --apply; it only verifies release assets for --check.")
}
updateOptions := update.Options{
CurrentVersion: version,
Repository: options.repository,
Endpoint: options.endpoint,
@@ -44,10 +66,26 @@ func runUpdate(args []string, stdout io.Writer, stderr io.Writer, deps appDeps)
if err != nil {
return writeUsageError(stderr, err.Error())
}
checkOptions.GOOS = target.GOOS
checkOptions.GOARCH = target.GOARCH
updateOptions.GOOS = target.GOOS
updateOptions.GOARCH = target.GOARCH
}
result, err := deps.checkUpdate(context.Background(), checkOptions)
if options.apply {
result, err := deps.applyUpdate(context.Background(), updateOptions)
if err != nil {
return writeAppError(stderr, "Could not install update: "+err.Error(), exitCrash)
}
if options.json {
if err := writePrettyJSON(stdout, result); err != nil {
return exitCrash
}
return exitSuccess
}
if _, err := fmt.Fprintln(stdout, update.FormatApply(result)); err != nil {
return exitCrash
}
return exitSuccess
}
result, err := deps.checkUpdate(context.Background(), updateOptions)
if err != nil {
return writeAppError(stderr, "Could not check for updates: "+err.Error(), exitCrash)
}
@@ -72,6 +110,8 @@ func parseUpdateArgs(args []string) (updateOptions, bool, error) {
return options, true, nil
case arg == "--check":
options.check = true
case arg == "--apply":
options.apply = true
case arg == "--json":
options.json = true
case arg == "--repo":
@@ -155,14 +195,17 @@ func parseUpdateTarget(value string) (string, error) {
func writeUpdateHelp(w io.Writer) error {
_, err := fmt.Fprint(w, `Usage:
zero update --check [flags]
zero update --apply [flags]
zero upgrade [flags]
Flags:
--check Check the latest GitHub release without installing
--json Print the update check result as JSON
--apply Download, verify, and install the latest release
--json Print the update result as JSON
--repo <owner/repo> Repository to check when no endpoint is provided
--endpoint <url|repo> Release API URL or owner/repo slug to check
--timeout <duration> Release check timeout (default 5s)
--target <platform> Release target to verify (for example windows-x64)
--target <platform> Release target to verify with --check (for example windows-x64); not valid with --apply
-h, --help Show this help
`)
return err
+141
View File
@@ -662,3 +662,144 @@ func TestRunExecRejectsWorktreeDirWithoutWorktree(t *testing.T) {
t.Fatalf("expected empty stdout, got %q", stdout.String())
}
}
func TestParseChangesArgsAuto(t *testing.T) {
// 1. Correct parsing of auto
for _, args := range [][]string{
{"--auto"},
{"-a"},
} {
options, help, err := parseChangesArgs(args, "commit")
if err != nil {
t.Fatalf("parseChangesArgs(%v) error: %v", args, err)
}
if help {
t.Fatalf("parseChangesArgs(%v) returned help", args)
}
if !options.auto {
t.Fatalf("auto = false, want true (args %v)", args)
}
}
// 2. Reject --auto on inspect
_, _, err := parseChangesArgs([]string{"--auto"}, "inspect")
if err == nil || !strings.Contains(err.Error(), "--auto") {
t.Fatalf("expected --auto rejection on inspect, got %v", err)
}
// 3. Reject both --message and --auto on commit
_, _, err = parseChangesArgs([]string{"--message", "foo", "--auto"}, "commit")
if err == nil || !strings.Contains(err.Error(), "cannot specify both --message and --auto") {
t.Fatalf("expected --message and --auto conflict error, got %v", err)
}
// 4. Reject both empty message and --auto on commit
_, _, err = parseChangesArgs([]string{"--message=", "--auto"}, "commit")
if err == nil || !strings.Contains(err.Error(), "cannot specify both --message and --auto") {
t.Fatalf("expected empty --message and --auto conflict error, got %v", err)
}
}
type mockCommitMsgProvider struct {
response string
req zeroruntime.CompletionRequest
}
func (p *mockCommitMsgProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
p.req = request
events := make(chan zeroruntime.StreamEvent, 2)
events <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: p.response}
events <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
close(events)
return events, nil
}
func TestRunChangesCommitAuto(t *testing.T) {
cwd := t.TempDir()
summary := zerogit.ChangeSummary{
Root: cwd,
Branch: "main",
Files: []zerogit.FileChange{{Path: "README.md", Status: "modified"}},
Diff: "some diff content with ghp_SECRETKEYHERE",
}
mockProv := &mockCommitMsgProvider{
response: "```\nfeat: auto commit message\n```",
}
var stdout bytes.Buffer
var stderr bytes.Buffer
commitCalled := false
exitCode := runWithDeps([]string{"changes", "commit", "--auto"}, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return cwd, nil },
inspectChanges: func(ctx context.Context, options zerogit.InspectOptions) (zerogit.ChangeSummary, error) {
return summary, nil
},
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
cfg := execResolvedConfig()
cfg.Provider.Name = "openai"
cfg.Provider.Model = "gpt-4o"
return cfg, nil
},
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
return mockProv, nil
},
commitChanges: func(ctx context.Context, options zerogit.CommitOptions) (zerogit.CommitResult, error) {
commitCalled = true
if options.Message != "feat: auto commit message" {
t.Fatalf("expected commit message 'feat: auto commit message', got %q", options.Message)
}
return zerogit.CommitResult{
Root: cwd,
Message: options.Message,
Committed: true,
CommitHash: "abc1234",
Before: summary,
}, nil
},
})
if exitCode != exitSuccess {
t.Fatalf("expected exit code %d, got %d: %s", exitSuccess, exitCode, stderr.String())
}
if !commitCalled {
t.Fatal("expected commitChanges to be called")
}
// Verify that secret in the diff was redacted
promptContent := mockProv.req.Messages[0].Content
if strings.Contains(promptContent, "ghp_SECRETKEYHERE") {
t.Fatal("expected secret in diff to be redacted, but it was found in the prompt")
}
if !strings.Contains(promptContent, "[REDACTED]") && !strings.Contains(promptContent, "REDACTED") {
t.Fatalf("expected redacted diff content in prompt, got: %q", promptContent)
}
if !strings.Contains(stdout.String(), "Generating commit message using LLM...") {
t.Fatalf("expected status message in stdout, got %q", stdout.String())
}
t.Run("EmptyLLMResponse", func(t *testing.T) {
mockProvEmpty := &mockCommitMsgProvider{
response: " \n\n ",
}
var stdout, stderr bytes.Buffer
code := runWithDeps([]string{"changes", "commit", "--auto"}, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return cwd, nil },
inspectChanges: func(ctx context.Context, options zerogit.InspectOptions) (zerogit.ChangeSummary, error) {
return summary, nil
},
resolveConfig: func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error) {
return execResolvedConfig(), nil
},
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
return mockProvEmpty, nil
},
})
if code == exitSuccess {
t.Fatal("expected command to fail when LLM returns empty response, got success")
}
if !strings.Contains(stderr.String(), "empty commit message") {
t.Fatalf("expected empty commit message error in stderr, got %q", stderr.String())
}
})
}
+96 -3
View File
@@ -6,13 +6,16 @@ import (
"io"
"strconv"
"strings"
"time"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/redaction"
"github.com/Gitlawb/zero/internal/selfverify"
"github.com/Gitlawb/zero/internal/testrunner"
"github.com/Gitlawb/zero/internal/verify"
"github.com/Gitlawb/zero/internal/worktrees"
"github.com/Gitlawb/zero/internal/zerogit"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
type worktreeCommandOptions struct {
@@ -35,8 +38,10 @@ type changesCommandOptions struct {
cwd string
baseRef string
message string
hasMessage bool
dryRun bool
maxDiffBytes int
auto bool
}
func runWorktrees(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
@@ -198,9 +203,52 @@ func runChanges(args []string, stdout io.Writer, stderr io.Writer, deps appDeps)
}
return exitSuccess
case "commit":
var message string
if options.auto {
summary, err := deps.inspectChanges(context.Background(), zerogit.InspectOptions{
Cwd: workspaceRoot,
MaxDiffBytes: options.maxDiffBytes,
})
if err != nil {
return writeExecUsageError(stderr, fmt.Sprintf("failed to inspect changes: %v", err))
}
if summary.Clean {
return writeExecUsageError(stderr, "no changes to commit")
}
resolved, err := deps.resolveConfig(workspaceRoot, config.Overrides{})
if err != nil {
return writeExecUsageError(stderr, fmt.Sprintf("failed to resolve config: %v", err))
}
if !config.HasProviderProfile(resolved.Provider) {
return writeExecUsageError(stderr, "no provider configured for auto-commit message")
}
provider, err := deps.newProvider(resolved.Provider)
if err != nil {
return writeExecUsageError(stderr, fmt.Sprintf("failed to create provider: %v", err))
}
if !options.json {
if _, err := fmt.Fprintln(stdout, "Generating commit message using LLM..."); err != nil {
return exitCrash
}
}
safeSummary := redactChangeSummary(summary)
genCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
msg, err := generateAutoCommitMessage(genCtx, provider, resolved.Provider.Model, safeSummary)
if err != nil {
return writeExecUsageError(stderr, fmt.Sprintf("failed to generate commit message: %v", err))
}
message = msg
} else {
message = options.message
}
result, err := deps.commitChanges(context.Background(), zerogit.CommitOptions{
Cwd: workspaceRoot,
Message: options.message,
Message: message,
DryRun: options.dryRun,
MaxDiffBytes: options.maxDiffBytes,
})
@@ -395,9 +443,11 @@ func parseChangesArgs(args []string, command string) (changesCommandOptions, boo
return options, false, err
}
options.message = value
options.hasMessage = true
index = next
case strings.HasPrefix(arg, "--message="):
options.message = strings.TrimSpace(strings.TrimPrefix(arg, "--message="))
options.hasMessage = true
case arg == "--diff-bytes":
value, next, err := nextFlagValue(args, index, arg)
if err != nil {
@@ -415,14 +465,19 @@ func parseChangesArgs(args []string, command string) (changesCommandOptions, boo
return options, false, err
}
options.maxDiffBytes = maxDiffBytes
case arg == "-a" || arg == "--auto":
options.auto = true
case strings.HasPrefix(arg, "-"):
return options, false, execUsageError{fmt.Sprintf("unknown changes flag %q", arg)}
default:
return options, false, execUsageError{fmt.Sprintf("unexpected changes argument %q", arg)}
}
}
if command != "commit" && (options.message != "" || options.dryRun) {
return options, false, execUsageError{"--message and --dry-run are only valid with `zero changes commit`"}
if command != "commit" && (options.hasMessage || options.dryRun || options.auto) {
return options, false, execUsageError{"--message, --dry-run, and --auto are only valid with `zero changes commit`"}
}
if command == "commit" && options.hasMessage && options.auto {
return options, false, execUsageError{"cannot specify both --message and --auto"}
}
if command == "commit" && options.baseRef != "" {
return options, false, execUsageError{"--base is only valid with `zero changes inspect`"}
@@ -712,9 +767,47 @@ Flags:
--base <ref> Diff against <ref>...HEAD instead of the working tree
--diff-bytes <n> Maximum diff bytes to include
-m, --message <text> Commit message for `+"`zero changes commit`"+`
-a, --auto Auto-generate commit message using LLM (use --dry-run to preview)
--dry-run Preview commit metadata without mutating git state
--json Print JSON output
-h, --help Show this help
`)
return err
}
func generateAutoCommitMessage(ctx context.Context, provider zeroruntime.Provider, model string, summary zerogit.ChangeSummary) (string, error) {
var promptBuilder strings.Builder
promptBuilder.WriteString("Analyze the following git diff and generate a concise, conventional commit message.\n")
promptBuilder.WriteString("The commit message subject line must be 72 characters or fewer, starting with a conventional commit type (e.g., feat, fix, docs, style, refactor, test, chore) followed by a colon and space, and a lowercase description.\n")
promptBuilder.WriteString("You may optionally include a blank line and a bulleted list of changes for the body if there are multiple files or complex changes.\n")
promptBuilder.WriteString("Output ONLY the raw commit message text. Do not wrap the message in markdown code block fence, do not include quotes or any introduction/explanation.\n\n")
promptBuilder.WriteString("Git Diff:\n")
promptBuilder.WriteString(summary.Diff)
request := zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{
{Role: zeroruntime.MessageRoleUser, Content: promptBuilder.String()},
},
}
stream, err := provider.StreamCompletion(ctx, request)
if err != nil {
return "", err
}
collected := zeroruntime.CollectStream(ctx, stream)
if collected.Error != "" {
return "", fmt.Errorf("%s", collected.Error)
}
msg := strings.TrimSpace(collected.Text)
if strings.HasPrefix(msg, "```") {
if idx := strings.Index(msg, "\n"); idx != -1 {
msg = msg[idx+1:]
}
}
msg = strings.TrimSuffix(msg, "```")
msg = strings.TrimSpace(msg)
if msg == "" {
return "", fmt.Errorf("provider returned empty commit message")
}
return msg, nil
}
+40 -2
View File
@@ -130,7 +130,11 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
providers, active, err := normalizeProviders(cfg.Providers, cfg.ActiveProvider, options.Env)
if err != nil {
return ResolvedConfig{}, err
// On ErrNoActiveProvider, providers may still hold the successfully
// normalized (but active-less) profile list — keep it so a caller can fall
// back to an already-configured usable provider instead of treating this
// like a config with nothing set up at all.
return ResolvedConfig{Providers: providers}, err
}
return ResolvedConfig{
@@ -144,6 +148,7 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
Tools: cfg.Tools,
Swarm: cfg.Swarm,
Preferences: cfg.Preferences,
KeyBindings: cfg.KeyBindings,
LocalControl: cfg.LocalControl,
}, nil
}
@@ -225,6 +230,7 @@ func mergeConfig(dst *FileConfig, src FileConfig) {
dst.Preferences.Theme = strings.TrimSpace(src.Preferences.Theme)
}
mergeLocalControlConfig(&dst.LocalControl, src.LocalControl)
mergeKeyBindings(&dst.KeyBindings, src.KeyBindings)
}
func mergeProjectConfig(dst *FileConfig, src FileConfig) error {
@@ -277,6 +283,7 @@ func mergeProjectConfig(dst *FileConfig, src FileConfig) error {
if src.Swarm.MaxTeamSize != 0 {
dst.Swarm.MaxTeamSize = src.Swarm.MaxTeamSize
}
mergeKeyBindings(&dst.KeyBindings, src.KeyBindings)
// Local control is intentionally user-config/override only. A cloned project
// must not be able to make browser, desktop, or terminal automation tools
// appear in the model's tool surface.
@@ -583,9 +590,17 @@ func applyProviderEnv(cfg *FileConfig, providerKind ProviderKind, env envProfile
if profile.Name == "" {
profile.Name = string(providerKind)
}
// A non-official baseURL from the environment signals a proxy/gateway, so promote
// the first-party kind to its -compatible transport (which accepts a custom URL).
// Env-only by design: a custom baseURL in a project config.json is left as-is and
// rejected by Resolve, so an untrusted project can't redirect a first-party key to
// another host.
if providerKind == ProviderKindOpenAI && baseURL != "" && !isOfficialOpenAIBaseURL(baseURL) {
profile.ProviderKind = ProviderKindOpenAICompatible
}
if providerKind == ProviderKindAnthropic && baseURL != "" && !isOfficialAnthropicBaseURL(baseURL) {
profile.ProviderKind = ProviderKindAnthropicCompat
}
// When the env supplies only credentials (no baseURL) for a provider name that
// already exists, don't force the standard transport kind — a same-named
// openai-compatible/anthropic-compatible proxy or gateway must keep its kind.
@@ -665,6 +680,7 @@ func applyOverrides(cfg *FileConfig, overrides Overrides) {
cfg.Tools.deferThresholdSet = true
}
mergeLocalControlConfig(&cfg.LocalControl, overrides.LocalControl)
mergeKeyBindings(&cfg.KeyBindings, overrides.KeyBindings)
for _, provider := range overrides.Providers {
mergeProvider(cfg, provider)
}
@@ -700,6 +716,24 @@ func mergeLocalControlDriverConfig(dst *LocalControlDriverConfig, src LocalContr
}
}
func mergeKeyBindings(dst *KeyBindingsConfig, src KeyBindingsConfig) {
if src.ToggleDetailed != "" {
dst.ToggleDetailed = src.ToggleDetailed
}
if src.ToggleMouse != "" {
dst.ToggleMouse = src.ToggleMouse
}
if src.CycleReasoning != "" {
dst.CycleReasoning = src.CycleReasoning
}
if src.TogglePlan != "" {
dst.TogglePlan = src.TogglePlan
}
if src.ToggleSidebar != "" {
dst.ToggleSidebar = src.ToggleSidebar
}
}
func mergeMCPConfig(dst *MCPConfig, src MCPConfig) {
if len(src.Servers) == 0 {
return
@@ -834,7 +868,11 @@ func normalizeProvidersWithOptions(providers []ProviderProfile, activeName strin
}
if !activeFound {
return nil, ProviderProfile{}, fmt.Errorf("%w: active provider %q not found", ErrNoActiveProvider, activeName)
// Return the successfully normalized list alongside the error (rather than
// nil) so a caller like the interactive TUI can still fall back to an
// already-configured, usable provider instead of forcing a full
// re-onboarding wizard just because none was marked active.
return normalized, ProviderProfile{}, fmt.Errorf("%w: active provider %q not found", ErrNoActiveProvider, activeName)
}
if active.Model == "" {
return nil, ProviderProfile{}, &setupFixableError{
+48
View File
@@ -617,6 +617,35 @@ func TestResolveUsesOpenAIEnvFallback(t *testing.T) {
}
}
func TestResolveUsesAnthropicEnvBaseURLAsCompatible(t *testing.T) {
// ANTHROPIC_BASE_URL pointing at a proxy/gateway must resolve to an
// anthropic-compatible provider (mirroring OPENAI_BASE_URL), not the "requires
// official baseURL" rejection — issue #479. The env is user-controlled, so a
// custom URL there is a deliberate gateway choice, unlike a project config.json.
resolved, err := Resolve(ResolveOptions{
Env: map[string]string{
"ANTHROPIC_API_KEY": "sk-ant-env",
"ANTHROPIC_BASE_URL": "https://gateway.example/anthropic",
"ANTHROPIC_MODEL": "claude-custom",
},
})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if resolved.ActiveProvider != "anthropic" {
t.Fatalf("ActiveProvider = %q, want anthropic", resolved.ActiveProvider)
}
if resolved.Provider.ProviderKind != ProviderKindAnthropicCompat {
t.Fatalf("ProviderKind = %q, want anthropic-compatible", resolved.Provider.ProviderKind)
}
if resolved.Provider.BaseURL != "https://gateway.example/anthropic" {
t.Fatalf("BaseURL = %q, want gateway URL", resolved.Provider.BaseURL)
}
if resolved.Provider.APIKey != "sk-ant-env" || resolved.Provider.Model != "claude-custom" {
t.Fatalf("Provider = %#v, want env credentials/model", resolved.Provider)
}
}
func TestResolveUsesOpenAIAPIKeyOnlyWithDefaultModel(t *testing.T) {
resolved, err := Resolve(ResolveOptions{
Env: map[string]string{
@@ -916,6 +945,25 @@ func TestResolveRejectsActiveProviderWithoutConfiguredProfiles(t *testing.T) {
}
}
func TestResolveKeepsNormalizedProvidersWhenNoneMarkedActive(t *testing.T) {
// Multiple providers configured (e.g. via `zero provider add`) but
// activeProvider is blank/stale — a caller like the interactive TUI still
// needs the normalized list to fall back to an already-usable provider
// instead of forcing a full re-onboarding wizard.
path := writeConfig(t, `{"providers":[
{"name":"work","provider_kind":"openai","apiKey":"sk-test","model":"gpt-test"},
{"name":"other","provider_kind":"openai","apiKey":"sk-other","model":"gpt-test"}
]}`)
resolved, err := Resolve(ResolveOptions{ProjectConfigPath: path, Env: map[string]string{}})
if !errors.Is(err, ErrNoActiveProvider) {
t.Fatalf("error = %v, want errors.Is(err, ErrNoActiveProvider)", err)
}
if len(resolved.Providers) != 2 {
t.Fatalf("Providers = %#v, want the 2 normalized profiles preserved despite the error", resolved.Providers)
}
}
func TestResolveTrimsProviderProfileAliasesBeforeFallback(t *testing.T) {
path := writeConfig(t, `{
"activeProvider": "custom",
+26
View File
@@ -112,6 +112,25 @@ func (p PreferencesConfig) RecapsEnabled() bool {
return p.Recaps == nil || *p.Recaps
}
// KeyBindingDef defines one key binding string (e.g. "ctrl+o") that the TUI
// can remap. An empty string means "use the built-in default" for that action.
type KeyBindingDef string
// KeyBindingsConfig holds the subset of TUI keybindings that users may remap
// via config.json. Each field defaults to a sensible built-in when empty.
type KeyBindingsConfig struct {
// ToggleDetailed toggles the detailed transcript view (default: ctrl+o).
ToggleDetailed KeyBindingDef `json:"toggleDetailed,omitempty"`
// ToggleMouse toggles mouse capture release (default: ctrl+e).
ToggleMouse KeyBindingDef `json:"toggleMouse,omitempty"`
// CycleReasoning cycles through reasoning effort levels (default: ctrl+t).
CycleReasoning KeyBindingDef `json:"cycleReasoning,omitempty"`
// TogglePlan toggles the plan panel expansion (default: ctrl+p).
TogglePlan KeyBindingDef `json:"togglePlan,omitempty"`
// ToggleSidebar toggles the right context sidebar (default: ctrl+b).
ToggleSidebar KeyBindingDef `json:"toggleSidebar,omitempty"`
}
// LocalControlConfig controls local browser/desktop/terminal automation helpers.
// Helpers are discovered lazily by the tool that needs them; no setup command or
// background probe is required during startup.
@@ -206,6 +225,7 @@ type FileConfig struct {
Tools ToolsConfig `json:"tools,omitempty"`
Swarm SwarmConfig `json:"swarm,omitempty"`
Preferences PreferencesConfig `json:"preferences,omitempty"`
KeyBindings KeyBindingsConfig `json:"keybindings,omitempty"`
LocalControl LocalControlConfig `json:"localControl,omitempty"`
}
@@ -220,6 +240,7 @@ func (cfg FileConfig) MarshalJSON() ([]byte, error) {
Tools ToolsConfig `json:"tools,omitempty"`
Swarm SwarmConfig `json:"swarm,omitempty"`
Preferences PreferencesConfig `json:"preferences,omitempty"`
KeyBindings KeyBindingsConfig `json:"keybindings,omitempty"`
LocalControl *LocalControlConfig `json:"localControl,omitempty"`
}
raw := rawConfig{
@@ -232,6 +253,7 @@ func (cfg FileConfig) MarshalJSON() ([]byte, error) {
Tools: cfg.Tools,
Swarm: cfg.Swarm,
Preferences: cfg.Preferences,
KeyBindings: cfg.KeyBindings,
}
if !cfg.LocalControl.Empty() {
raw.LocalControl = &cfg.LocalControl
@@ -256,6 +278,7 @@ type Overrides struct {
Sandbox SandboxConfig
Notify NotifyConfig
Tools ToolsConfig
KeyBindings KeyBindingsConfig
LocalControl LocalControlConfig
}
@@ -270,6 +293,7 @@ type ResolvedConfig struct {
Tools ToolsConfig
Swarm SwarmConfig
Preferences PreferencesConfig
KeyBindings KeyBindingsConfig
LocalControl LocalControlConfig
}
@@ -316,6 +340,7 @@ func (cfg *FileConfig) UnmarshalJSON(data []byte) error {
Tools ToolsConfig `json:"tools"`
Swarm SwarmConfig `json:"swarm"`
Preferences PreferencesConfig `json:"preferences"`
KeyBindings KeyBindingsConfig `json:"keybindings"`
LocalControl LocalControlConfig `json:"localControl"`
MCPServers map[string]MCPServerConfig `json:"mcpServers"`
MCPServersSnake map[string]MCPServerConfig `json:"mcp_servers"`
@@ -342,6 +367,7 @@ func (cfg *FileConfig) UnmarshalJSON(data []byte) error {
cfg.Tools = raw.Tools
cfg.Swarm = raw.Swarm
cfg.Preferences = raw.Preferences
cfg.KeyBindings = raw.KeyBindings
cfg.LocalControl = raw.LocalControl
if cfg.MCP.Servers == nil && (len(raw.MCPServers) > 0 || len(raw.MCPServersSnake) > 0) {
cfg.MCP.Servers = map[string]MCPServerConfig{}
+25 -9
View File
@@ -131,29 +131,45 @@ func doctorSandboxPolicy(cfg config.SandboxConfig) sandbox.Policy {
// lspServersCheck reports which language servers ZERO would use are present on
// PATH. Missing servers are not a failure — ZERO degrades to text-only edits for
// those languages — so the worst status is WARN, and each missing server gets an
// actionable install command keyed by its binary name.
// actionable install command keyed by its binary name. Only the tier-1 servers
// (lsp.CoreServerBinaries) drive the status: the long-tail servers configured
// for breadth are listed informationally under missingOptional, because warning
// about a missing zls on a machine with no Zig code would be permanent noise.
func lspServersCheck(lookup func(string) (string, error)) Check {
if lookup == nil {
lookup = exec.LookPath
}
core := map[string]bool{}
for _, binary := range lsp.CoreServerBinaries() {
core[binary] = true
}
present := map[string]any{}
missing := map[string]any{}
missingOptional := map[string]any{}
for _, binary := range lsp.ServerBinaries() {
if _, err := lookup(binary); err == nil {
present[binary] = "on PATH"
continue
}
missing[binary] = lspRemedy(binary)
if core[binary] {
missing[binary] = lspRemedy(binary)
} else {
missingOptional[binary] = lspRemedy(binary)
}
}
details := map[string]any{"present": present}
if len(missingOptional) > 0 {
details["missingOptional"] = missingOptional
}
if len(missing) == 0 {
return check("lsp.servers", "LSP servers", StatusPass, "All known language servers are available on PATH.", map[string]any{
"present": present,
})
message := "All core language servers are available on PATH."
if len(missingOptional) > 0 {
message = fmt.Sprintf("All core language servers are available on PATH (%d optional server(s) not installed).", len(missingOptional))
}
return check("lsp.servers", "LSP servers", StatusPass, message, details)
}
return check("lsp.servers", "LSP servers", StatusWarn, fmt.Sprintf("%d language server(s) missing from PATH; affected files degrade to text-only edits.", len(missing)), map[string]any{
"present": present,
"missing": missing,
})
details["missing"] = missing
return check("lsp.servers", "LSP servers", StatusWarn, fmt.Sprintf("%d language server(s) missing from PATH; affected files degrade to text-only edits.", len(missing)), details)
}
// lspRemedy returns an actionable install command for a missing language-server
+167
View File
@@ -0,0 +1,167 @@
// Package errhint classifies provider/model failures into a few user-actionable
// categories and turns them into a one-line "next step" hint.
//
// Provider errors already arrive with a classified string prefix from
// providerio.ClassifiedError ("auth error:", "rate limit error:", …); lower-level
// failures (DNS, TLS, timeouts, context-length) arrive as raw driver or library
// messages. Classify matches both so the interactive error row (TUI) and the
// `zero exec` provider-error path (CLI) can append one concrete next step instead
// of dumping an identical red blob for every failure mode.
package errhint
import "strings"
// Category buckets a provider/model failure into a small set of classes that each
// map to a distinct recovery action.
type Category int
const (
// Unknown means the error didn't match any known signature; callers should
// emit no hint rather than guess.
Unknown Category = iota
Auth
RateLimit
Connectivity
ModelNotFound
ContextOverflow
)
// providerMarkers are the prefixes the provider layer attaches to every
// provider-originated failure: providerio.ClassifiedError emits "auth error:",
// "rate limit error:", "provider error:", and "provider request error:", and the
// streaming paths wrap transport/decoding failures as "provider stream error:".
// A UI surface's error can also be a *local* failure (a tool's "permission
// denied", a "file does not exist", a config error), so Classify only proceeds
// past this gate for messages that are recognizably from the provider — otherwise
// a broad substring like "does not exist" would attach a bogus /model hint to an
// unrelated local error.
var providerMarkers = []string{
"auth error:",
"rate limit error:",
"provider error:",
"provider request error:",
"provider stream error:",
}
// Classify buckets err by scanning its message for known signatures. It is a
// deliberately conservative string heuristic — the provider layer has already
// discarded the numeric HTTP status by the time the error reaches a UI surface,
// so the message is all we have. It first gates on a provider-origin marker (see
// providerMarkers) so local failures never draw a provider hint, then sub-classifies.
// Order matters: more specific signatures are tested before broader ones (e.g.
// "context length" as overflow before the generic "timeout" as connectivity).
func Classify(err error) Category {
if err == nil {
return Unknown
}
m := strings.ToLower(err.Error())
if !containsAny(m, providerMarkers...) {
return Unknown
}
switch {
case containsAny(m, "auth error:", "unauthorized", "api key", "api_key", "invalid_api_key",
"authentication", "permission denied", "forbidden") || containsStatusCode(m, "401", "403"):
return Auth
case containsAny(m, "rate limit", "rate_limit", "too many requests", "quota",
"resource_exhausted", "overloaded") || containsStatusCode(m, "429", "529"):
return RateLimit
case containsAny(m, "context length", "context window", "maximum context", "context_length_exceeded",
"too many tokens", "prompt is too long", "reduce the length", "maximum context length"):
return ContextOverflow
case containsAny(m, "model not found", "model_not_found", "does not exist", "unknown model",
"no such model", "unsupported model", "invalid model", "model is not"):
return ModelNotFound
case containsAny(m, "dial tcp", "no such host", "connection refused", "network is unreachable",
"i/o timeout", "context deadline exceeded", "tls handshake", "connection reset",
"unexpected eof", "lookup ", "timeout"):
return Connectivity
default:
return Unknown
}
}
// TUIHint returns a one-line hint referencing interactive slash commands, or ""
// when the category is Unknown. Meant to sit under the raw error in the live
// error row.
func TUIHint(err error) string {
switch Classify(err) {
case Auth:
return "API key rejected — run /provider to re-check your credentials"
case RateLimit:
return "Rate limited — wait a moment, or switch model with /model"
case Connectivity:
return "Can't reach the provider — run /doctor --connectivity"
case ModelNotFound:
return "Model unavailable — pick another with /model"
case ContextOverflow:
return "Context window full — run /compact to free space"
default:
return ""
}
}
// CLIHint returns a one-line hint referencing `zero …` subcommands, or "" when the
// category is Unknown. Meant for the non-interactive `zero exec` error path, where
// slash commands don't apply.
func CLIHint(err error) string {
switch Classify(err) {
case Auth:
return "API key rejected — run `zero auth` or set the provider's API key, then retry"
case RateLimit:
return "Rate limited — wait a moment, or switch model with --model"
case Connectivity:
return "Can't reach the provider — run `zero doctor`"
case ModelNotFound:
return "Model unavailable — run `zero doctor` or pick another with --model"
case ContextOverflow:
return "Context window full — shorten the prompt or start a fresh session"
default:
return ""
}
}
func containsAny(haystack string, needles ...string) bool {
for _, n := range needles {
if strings.Contains(haystack, n) {
return true
}
}
return false
}
// containsStatusCode reports whether haystack contains any of the given HTTP
// status codes as a standalone number — not embedded in a longer digit run like
// "completed in 4290ms" or "request id 14015" — so an incidental number can't be
// mis-bucketed as an auth/rate-limit failure.
func containsStatusCode(haystack string, codes ...string) bool {
return HasStatusCode(haystack, codes...)
}
// HasStatusCode reports whether haystack contains any of the given HTTP status
// codes as a standalone number — not embedded in a longer digit run like
// "completed in 4290ms" or "request id 14015". Exported so other packages (e.g.
// the reconnect classifier) can gate on a status code without re-implementing
// the digit-boundary check.
func HasStatusCode(haystack string, codes ...string) bool {
for _, code := range codes {
for from := 0; ; {
rel := strings.Index(haystack[from:], code)
if rel < 0 {
break
}
pos := from + rel
beforeOK := pos == 0 || !isASCIIDigit(haystack[pos-1])
end := pos + len(code)
afterOK := end >= len(haystack) || !isASCIIDigit(haystack[end])
if beforeOK && afterOK {
return true
}
from = pos + 1
}
}
return false
}
func isASCIIDigit(b byte) bool {
return b >= '0' && b <= '9'
}
+94
View File
@@ -0,0 +1,94 @@
package errhint
import (
"errors"
"strings"
"testing"
)
func TestClassify(t *testing.T) {
cases := []struct {
name string
msg string
want Category
}{
{"nil-ish empty", "", Unknown},
{"providerio auth prefix", "auth error: your API key is missing or invalid — run `zero auth`", Auth},
{"raw 401", "provider request error: 401 Unauthorized", Auth},
{"invalid api key", "provider request error: invalid_api_key: incorrect key provided", Auth},
{"rate limit prefix", "rate limit error: 429 too many requests", RateLimit},
{"overloaded", "provider error: model is overloaded, please retry", RateLimit},
{"resource exhausted gemini", "provider stream error: rpc error: code = ResourceExhausted desc = quota exceeded", RateLimit},
{"context length openai", "provider request error: this model's maximum context length is 128000 tokens", ContextOverflow},
{"context_length_exceeded", "provider request error: context_length_exceeded", ContextOverflow},
{"model not found", "provider request error: 404 the model `gpt-9` does not exist", ModelNotFound},
{"unknown model", "provider request error: unknown model: sonnet-99", ModelNotFound},
{"dns failure", "provider stream error: Post \"https://api...\": dial tcp: lookup api.foo.com: no such host", Connectivity},
{"connection refused", "provider stream error: dial tcp 127.0.0.1:443: connection refused", Connectivity},
{"deadline", "provider stream error: context deadline exceeded (Client.Timeout exceeded)", Connectivity},
{"provider-marked but no sub-signature", "provider error: something totally unexpected happened", Unknown},
// An incidental number that merely contains a status-code digit run must NOT
// be mis-bucketed (digit-boundary check), even behind a provider marker.
{"incidental latency number", "provider error: request completed in 4290ms then failed", Unknown},
{"incidental request id", "provider error: request id 14015 failed unexpectedly", Unknown},
{"standalone 401 still auth", "provider request error: got 401 from upstream", Auth},
{"standalone 429 still rate limit", "provider error: 429 returned", RateLimit},
// Local (non-provider) failures must NOT be classified — no provider marker,
// so no bogus recovery hint attaches to them.
{"local fs permission denied", "open /etc/shadow: permission denied", Unknown},
{"local file missing", "stat foo.go: no such file or directory", Unknown},
{"local model config typo", "unknown model: sonnet-99", Unknown},
{"tool timeout local", "tool bash timed out after 600s", Unknown},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := Classify(errors.New(tc.msg)); got != tc.want {
t.Fatalf("Classify(%q) = %v, want %v", tc.msg, got, tc.want)
}
})
}
}
func TestClassifyNil(t *testing.T) {
if got := Classify(nil); got != Unknown {
t.Fatalf("Classify(nil) = %v, want Unknown", got)
}
}
// Context-overflow must win over the connectivity "timeout" catch-all: a message
// mentioning both "context length" and a timeout is an overflow, not a network
// problem.
func TestContextOverflowBeatsConnectivity(t *testing.T) {
err := errors.New("provider request error: maximum context length exceeded; request timeout")
if got := Classify(err); got != ContextOverflow {
t.Fatalf("Classify = %v, want ContextOverflow", got)
}
}
func TestHintsPresentForKnownCategories(t *testing.T) {
known := []error{
errors.New("auth error: bad key"),
errors.New("rate limit error: 429"),
errors.New("provider stream error: dial tcp: no such host"),
errors.New("provider request error: model does not exist"),
errors.New("provider request error: maximum context length is 128000 tokens"),
}
for _, err := range known {
if h := TUIHint(err); strings.TrimSpace(h) == "" {
t.Fatalf("TUIHint(%q) empty, want a hint", err)
}
if h := CLIHint(err); strings.TrimSpace(h) == "" {
t.Fatalf("CLIHint(%q) empty, want a hint", err)
}
}
// Unknown yields no hint on either surface.
unknown := errors.New("provider error: mystery")
if h := TUIHint(unknown); h != "" {
t.Fatalf("TUIHint(unknown) = %q, want empty", h)
}
if h := CLIHint(unknown); h != "" {
t.Fatalf("CLIHint(unknown) = %q, want empty", h)
}
}
+123 -15
View File
@@ -9,26 +9,134 @@ import (
// serverCommands maps a file extension to the language-server command (argv) ZERO
// will spawn. The first element is the binary looked up on PATH; missing binaries
// are not an error, the agent just degrades to text-only for that file.
// are not an error, the agent just degrades to text-only for that file. Every
// command is the language's community-standard server invoked in stdio mode, so
// a wrong guess can never spawn something surprising — only fail the PATH check.
var serverCommands = map[string][]string{
".go": {"gopls", "serve"},
".ts": {"typescript-language-server", "--stdio"},
".tsx": {"typescript-language-server", "--stdio"},
".js": {"typescript-language-server", "--stdio"},
".jsx": {"typescript-language-server", "--stdio"},
".py": {"pyright-langserver", "--stdio"},
".rs": {"rust-analyzer"},
".go": {"gopls", "serve"},
".ts": {"typescript-language-server", "--stdio"},
".tsx": {"typescript-language-server", "--stdio"},
".mts": {"typescript-language-server", "--stdio"},
".cts": {"typescript-language-server", "--stdio"},
".js": {"typescript-language-server", "--stdio"},
".jsx": {"typescript-language-server", "--stdio"},
".mjs": {"typescript-language-server", "--stdio"},
".cjs": {"typescript-language-server", "--stdio"},
".py": {"pyright-langserver", "--stdio"},
".pyi": {"pyright-langserver", "--stdio"},
".rs": {"rust-analyzer"},
".c": {"clangd"},
".h": {"clangd"},
".cpp": {"clangd"},
".cc": {"clangd"},
".cxx": {"clangd"},
".hpp": {"clangd"},
".java": {"jdtls"},
".kt": {"kotlin-language-server"},
".kts": {"kotlin-language-server"},
".rb": {"ruby-lsp"},
".php": {"intelephense", "--stdio"},
".zig": {"zls"},
".lua": {"lua-language-server"},
".ex": {"elixir-ls"},
".exs": {"elixir-ls"},
".hs": {"haskell-language-server-wrapper", "--lsp"},
".swift": {"sourcekit-lsp"},
".ml": {"ocamllsp"},
".mli": {"ocamllsp"},
".scala": {"metals"},
".clj": {"clojure-lsp"},
".cljs": {"clojure-lsp"},
".cljc": {"clojure-lsp"},
".dart": {"dart", "language-server"},
".gleam": {"gleam", "lsp"},
".nix": {"nixd"},
".tf": {"terraform-ls", "serve"},
".sh": {"bash-language-server", "start"},
".bash": {"bash-language-server", "start"},
".yaml": {"yaml-language-server", "--stdio"},
".yml": {"yaml-language-server", "--stdio"},
".json": {"vscode-json-language-server", "--stdio"},
".css": {"vscode-css-language-server", "--stdio"},
".scss": {"vscode-css-language-server", "--stdio"},
".less": {"vscode-css-language-server", "--stdio"},
".html": {"vscode-html-language-server", "--stdio"},
".svelte": {"svelteserver", "--stdio"},
".vue": {"vue-language-server", "--stdio"},
".astro": {"astro-ls", "--stdio"},
}
// languageIDs maps a file extension to the LSP languageId used in didOpen.
var languageIDs = map[string]string{
".go": "go",
".ts": "typescript",
".tsx": "typescriptreact",
".js": "javascript",
".jsx": "javascriptreact",
".py": "python",
".rs": "rust",
".go": "go",
".ts": "typescript",
".tsx": "typescriptreact",
".mts": "typescript",
".cts": "typescript",
".js": "javascript",
".jsx": "javascriptreact",
".mjs": "javascript",
".cjs": "javascript",
".py": "python",
".pyi": "python",
".rs": "rust",
".c": "c",
".h": "c",
".cpp": "cpp",
".cc": "cpp",
".cxx": "cpp",
".hpp": "cpp",
".java": "java",
".kt": "kotlin",
".kts": "kotlin",
".rb": "ruby",
".php": "php",
".zig": "zig",
".lua": "lua",
".ex": "elixir",
".exs": "elixir",
".hs": "haskell",
".swift": "swift",
".ml": "ocaml",
".mli": "ocaml",
".scala": "scala",
".clj": "clojure",
".cljs": "clojure",
".cljc": "clojure",
".dart": "dart",
".gleam": "gleam",
".nix": "nix",
".tf": "terraform",
".sh": "shellscript",
".bash": "shellscript",
".yaml": "yaml",
".yml": "yaml",
".json": "json",
".css": "css",
".scss": "scss",
".less": "less",
".html": "html",
".svelte": "svelte",
".vue": "vue",
".astro": "astro",
}
// coreServerBinaries are the tier-1 servers for the languages agents hit most;
// `zero doctor` treats their absence as warn-worthy. The long tail of servers
// configured above for breadth is reported informationally only — warning about
// a missing zls on a machine with no Zig code would be permanent noise.
var coreServerBinaries = []string{
"gopls",
"typescript-language-server",
"pyright-langserver",
"rust-analyzer",
}
// CoreServerBinaries returns the tier-1 server binaries (sorted copy).
func CoreServerBinaries() []string {
binaries := append([]string(nil), coreServerBinaries...)
sort.Strings(binaries)
return binaries
}
// ServerBinaries returns the unique set of language-server binaries ZERO may
+4
View File
@@ -63,6 +63,10 @@ func DefaultModelEntries() []ModelEntry {
googleModel("gemini-2.5-flash-lite", "Gemini 2.5 Flash-Lite", "gemini-2.5-flash-lite", ModelStatusActive, []string{"google:gemini-2.5-flash-lite", "gemini-flash-lite"}, ContextLimits{ContextWindow: 1_048_576, MaxOutputTokens: 65_536}, ModelCost{InputPerMillion: 0.1, CachedInputPerMillion: 0.01, OutputPerMillion: 0.4}, []ModelCapability{ModelCapabilityVision, ModelCapabilityJSONMode, ModelCapabilityReasoning, ModelCapabilityLongContext}, standardReasoningEfforts(), "Google low-cost Flash model for background routing and summaries."),
}
decorateModelDepth(entries)
// Overlay volatile facts (context limits, base pricing) from a cached
// models.dev snapshot when one is present and fresh — see modelsdev.go.
// Identity fields (ids, aliases, patterns, deprecations) stay curated.
entries = applyModelsDevOverrides(entries, cachedModelsDevProviders())
return cloneModelEntries(entries)
}
+269
View File
@@ -0,0 +1,269 @@
package modelregistry
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
)
// Live models.dev overlay for the curated catalog. The hand-maintained
// DefaultModelEntries list is the source of truth for identity — ids, aliases,
// match patterns, deprecations, escalation targets — but its VOLATILE facts
// (context window, max output tokens, per-million pricing) go stale between
// releases. When a cached snapshot of https://models.dev/api.json is present,
// those fields are refreshed from it at registry construction; everything else
// stays curated. The overlay never adds models (an auto-added entry would lack
// aliases, match patterns, and provider wiring) and never touches the network
// on the registry hot path — fetching happens only in the explicit background
// refresh, cached to disk with a TTL.
const (
modelsDevDefaultURL = "https://models.dev/api.json"
// modelsDevRefreshAfter is how old the cache may get before a background
// refresh re-fetches it.
modelsDevRefreshAfter = 24 * time.Hour
// modelsDevMaxAge is the oldest cache still applied as an overlay. Beyond
// this the curated catalog (updated with the binary) is likely fresher than
// the snapshot, so a stale file is ignored rather than trusted.
modelsDevMaxAge = 7 * 24 * time.Hour
modelsDevFetchLimit = 32 << 20 // 32MiB guard on the response body
modelsDevFetchWindow = 15 * time.Second
)
// modelsDevModel is the subset of a models.dev model record the overlay uses.
type modelsDevModel struct {
Limit struct {
Context int `json:"context"`
Output int `json:"output"`
} `json:"limit"`
Cost struct {
Input float64 `json:"input"`
Output float64 `json:"output"`
CacheRead float64 `json:"cache_read"`
CacheWrite float64 `json:"cache_write"`
} `json:"cost"`
}
// modelsDevProvider matches one provider object in api.json.
type modelsDevProvider struct {
Models map[string]modelsDevModel `json:"models"`
}
// parseModelsDev decodes an api.json document into provider-slug -> api-model
// -> record. api.json is a top-level object keyed by provider id.
func parseModelsDev(data []byte) (map[string]map[string]modelsDevModel, error) {
var doc map[string]modelsDevProvider
if err := json.Unmarshal(data, &doc); err != nil {
return nil, fmt.Errorf("modelregistry: parse models.dev: %w", err)
}
providers := make(map[string]map[string]modelsDevModel, len(doc))
for slug, provider := range doc {
if len(provider.Models) == 0 {
continue
}
providers[slug] = provider.Models
}
if len(providers) == 0 {
return nil, fmt.Errorf("modelregistry: models.dev document has no providers")
}
return providers, nil
}
// modelsDevSlugs maps an entry's provider kind to the models.dev provider ids
// worth checking. Only first-party slugs are used: router entries on models.dev
// carry generic or stale numbers.
func modelsDevSlugs(kind ProviderKind) []string {
switch kind {
case ProviderAnthropic:
return []string{"anthropic"}
case ProviderOpenAI:
return []string{"openai"}
case ProviderGoogle:
return []string{"google", "google-vertex"}
default:
return nil
}
}
// applyModelsDevOverrides refreshes each curated entry's context limits and
// base pricing from the snapshot, when the snapshot knows the model. Tiered
// pricing is never touched: models.dev has no tier data, and mixing a live
// base rate with curated tiers would misprice the tier boundaries.
func applyModelsDevOverrides(entries []ModelEntry, providers map[string]map[string]modelsDevModel) []ModelEntry {
if len(providers) == 0 {
return entries
}
for i := range entries {
entry := &entries[i]
var record modelsDevModel
found := false
for _, slug := range modelsDevSlugs(entry.Provider) {
if models, ok := providers[slug]; ok {
if candidate, ok := models[strings.TrimSpace(entry.APIModel)]; ok {
record = candidate
found = true
break
}
}
}
if !found {
continue
}
if record.Limit.Context > 0 {
entry.ContextLimits.ContextWindow = record.Limit.Context
}
if record.Limit.Output > 0 {
entry.ContextLimits.MaxOutputTokens = record.Limit.Output
}
if len(entry.Cost.Tiers) == 0 && record.Cost.Input > 0 && record.Cost.Output > 0 {
entry.Cost.InputPerMillion = record.Cost.Input
entry.Cost.OutputPerMillion = record.Cost.Output
if record.Cost.CacheRead > 0 {
entry.Cost.CachedInputPerMillion = record.Cost.CacheRead
}
if record.Cost.CacheWrite > 0 {
entry.Cost.CacheWritePerMillion = record.Cost.CacheWrite
}
entry.Cost.Source = "models.dev/api.json (cached)"
}
}
return entries
}
// modelsDevCachePath returns the on-disk cache location. ZERO_MODELS_CACHE_PATH
// overrides it (used by tests and unusual setups).
func modelsDevCachePath() (string, error) {
if override := strings.TrimSpace(os.Getenv("ZERO_MODELS_CACHE_PATH")); override != "" {
return override, nil
}
base, err := os.UserCacheDir()
if err != nil {
return "", err
}
return filepath.Join(base, "zero", "modelsdev.json"), nil
}
var (
modelsDevOnce sync.Once
modelsDevCached map[string]map[string]modelsDevModel
modelsDevEnabled atomic.Bool
)
// EnableModelsDevOverlay opts the process into applying the cached models.dev
// snapshot on top of the curated catalog. The CLI entrypoint calls it; library
// consumers and tests that never do get the curated catalog byte-identical to
// before, so hermetic tests can't be perturbed by a cache file on the machine.
// ZERO_DISABLE_MODELS_FETCH disables both the overlay and the fetch.
func EnableModelsDevOverlay() {
modelsDevEnabled.Store(true)
}
// cachedModelsDevProviders loads the cached snapshot once per process. Not
// enabled, missing, stale (> modelsDevMaxAge), or malformed all yield nil and
// the curated catalog is used untouched. Read once deliberately:
// DefaultRegistry is called on hot paths (pickers, cost views) and must not
// re-stat the file every time; a background refresh benefits the NEXT process.
func cachedModelsDevProviders() map[string]map[string]modelsDevModel {
if !modelsDevEnabled.Load() || strings.TrimSpace(os.Getenv("ZERO_DISABLE_MODELS_FETCH")) != "" {
return nil
}
modelsDevOnce.Do(func() {
path, err := modelsDevCachePath()
if err != nil {
return
}
info, err := os.Stat(path)
if err != nil || time.Since(info.ModTime()) > modelsDevMaxAge {
return
}
data, err := os.ReadFile(path)
if err != nil {
return
}
providers, err := parseModelsDev(data)
if err != nil {
return
}
modelsDevCached = providers
})
return modelsDevCached
}
// resetModelsDevCacheForTest clears the process-level cache memoization and
// disables the overlay.
func resetModelsDevCacheForTest() {
modelsDevOnce = sync.Once{}
modelsDevCached = nil
modelsDevEnabled.Store(false)
}
// RefreshModelsDevCache fetches models.dev/api.json into the on-disk cache
// when the cache is missing or older than modelsDevRefreshAfter. It is safe to
// call fire-and-forget from startup (use a goroutine); it never affects the
// current process's registry (see cachedModelsDevProviders). Disabled entirely
// by ZERO_DISABLE_MODELS_FETCH. The URL can be overridden with ZERO_MODELS_URL.
func RefreshModelsDevCache(ctx context.Context) error {
if strings.TrimSpace(os.Getenv("ZERO_DISABLE_MODELS_FETCH")) != "" {
return nil
}
path, err := modelsDevCachePath()
if err != nil {
return err
}
if info, err := os.Stat(path); err == nil && time.Since(info.ModTime()) < modelsDevRefreshAfter {
return nil
}
url := strings.TrimSpace(os.Getenv("ZERO_MODELS_URL"))
if url == "" {
url = modelsDevDefaultURL
}
fetchCtx, cancel := context.WithTimeout(ctx, modelsDevFetchWindow)
defer cancel()
request, err := http.NewRequestWithContext(fetchCtx, http.MethodGet, url, nil)
if err != nil {
return err
}
request.Header.Set("User-Agent", "zero-models-refresh/0.1")
response, err := http.DefaultClient.Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return fmt.Errorf("modelregistry: models.dev fetch: HTTP %d", response.StatusCode)
}
data, err := io.ReadAll(io.LimitReader(response.Body, modelsDevFetchLimit))
if err != nil {
return err
}
// Validate before persisting: a bad body must never clobber a good cache.
if _, err := parseModelsDev(data); err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
temp, err := os.CreateTemp(filepath.Dir(path), "modelsdev-*.json")
if err != nil {
return err
}
if _, err := temp.Write(data); err != nil {
_ = temp.Close()
_ = os.Remove(temp.Name())
return err
}
if err := temp.Close(); err != nil {
_ = os.Remove(temp.Name())
return err
}
return os.Rename(temp.Name(), path)
}
+228
View File
@@ -0,0 +1,228 @@
package modelregistry
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
)
const sampleModelsDev = `{
"anthropic": {
"id": "anthropic",
"models": {
"claude-sonnet-4-5-20250929": {
"limit": {"context": 1000000, "output": 64000},
"cost": {"input": 3.5, "output": 17.5, "cache_read": 0.35, "cache_write": 4.4}
}
}
},
"google": {
"id": "google",
"models": {
"gemini-2.5-pro": {
"limit": {"context": 2097152, "output": 65536},
"cost": {"input": 9.99, "output": 9.99}
}
}
}
}`
func TestParseModelsDev(t *testing.T) {
providers, err := parseModelsDev([]byte(sampleModelsDev))
if err != nil {
t.Fatal(err)
}
record, ok := providers["anthropic"]["claude-sonnet-4-5-20250929"]
if !ok {
t.Fatal("expected anthropic sonnet record")
}
if record.Limit.Context != 1_000_000 || record.Cost.Input != 3.5 {
t.Fatalf("unexpected record: %+v", record)
}
if _, err := parseModelsDev([]byte(`{}`)); err == nil {
t.Fatal("empty document must be rejected")
}
if _, err := parseModelsDev([]byte(`not json`)); err == nil {
t.Fatal("malformed document must be rejected")
}
}
func TestApplyModelsDevOverrides(t *testing.T) {
// Point the cache at a non-existent file so DefaultModelEntries returns the
// pure curated catalog, then apply the sample snapshot explicitly.
t.Setenv("ZERO_MODELS_CACHE_PATH", filepath.Join(t.TempDir(), "absent.json"))
resetModelsDevCacheForTest()
t.Cleanup(resetModelsDevCacheForTest)
providers, err := parseModelsDev([]byte(sampleModelsDev))
if err != nil {
t.Fatal(err)
}
entries := applyModelsDevOverrides(DefaultModelEntries(), providers)
var sonnet, geminiPro, opus ModelEntry
for _, entry := range entries {
switch entry.ID {
case "claude-sonnet-4.5":
sonnet = entry
case "gemini-2.5-pro":
geminiPro = entry
case "claude-opus-4.1":
opus = entry
}
}
// Known model: limits and base pricing refreshed from the snapshot.
if sonnet.ContextLimits.ContextWindow != 1_000_000 || sonnet.ContextLimits.MaxOutputTokens != 64_000 {
t.Fatalf("sonnet limits not overridden: %+v", sonnet.ContextLimits)
}
if sonnet.Cost.InputPerMillion != 3.5 || sonnet.Cost.OutputPerMillion != 17.5 || sonnet.Cost.CacheWritePerMillion != 4.4 {
t.Fatalf("sonnet cost not overridden: %+v", sonnet.Cost)
}
if sonnet.Cost.Source != "models.dev/api.json (cached)" {
t.Fatalf("sonnet cost source not marked: %q", sonnet.Cost.Source)
}
// Tiered pricing is curated: limits refresh, cost must NOT (gemini-2.5-pro
// has curated tiers and the snapshot's flat 9.99 would misprice them).
if geminiPro.ContextLimits.ContextWindow != 2_097_152 {
t.Fatalf("gemini limits not overridden: %+v", geminiPro.ContextLimits)
}
if geminiPro.Cost.InputPerMillion == 9.99 || len(geminiPro.Cost.Tiers) == 0 {
t.Fatalf("tiered cost must stay curated: %+v", geminiPro.Cost)
}
// Model absent from the snapshot: untouched.
if opus.ContextLimits.ContextWindow != 200_000 || opus.Cost.InputPerMillion != 15 {
t.Fatalf("opus must be untouched: %+v %+v", opus.ContextLimits, opus.Cost)
}
}
func TestRefreshModelsDevCacheFetchesAndCaches(t *testing.T) {
hits := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits++
_, _ = w.Write([]byte(sampleModelsDev))
}))
defer server.Close()
cachePath := filepath.Join(t.TempDir(), "modelsdev.json")
t.Setenv("ZERO_MODELS_CACHE_PATH", cachePath)
t.Setenv("ZERO_MODELS_URL", server.URL)
t.Setenv("ZERO_DISABLE_MODELS_FETCH", "")
if err := RefreshModelsDevCache(t.Context()); err != nil {
t.Fatal(err)
}
if hits != 1 {
t.Fatalf("expected 1 fetch, got %d", hits)
}
if _, err := os.Stat(cachePath); err != nil {
t.Fatalf("cache file missing: %v", err)
}
// Fresh cache: second call must not re-fetch.
if err := RefreshModelsDevCache(t.Context()); err != nil {
t.Fatal(err)
}
if hits != 1 {
t.Fatalf("fresh cache must skip the fetch, got %d hits", hits)
}
}
func TestRefreshModelsDevCacheRejectsBadBodyWithoutClobbering(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("not json"))
}))
defer server.Close()
cachePath := filepath.Join(t.TempDir(), "modelsdev.json")
if err := os.WriteFile(cachePath, []byte(sampleModelsDev), 0o644); err != nil {
t.Fatal(err)
}
stale := time.Now().Add(-48 * time.Hour)
if err := os.Chtimes(cachePath, stale, stale); err != nil {
t.Fatal(err)
}
t.Setenv("ZERO_MODELS_CACHE_PATH", cachePath)
t.Setenv("ZERO_MODELS_URL", server.URL)
if err := RefreshModelsDevCache(t.Context()); err == nil {
t.Fatal("bad body must return an error")
}
content, err := os.ReadFile(cachePath)
if err != nil || string(content) != sampleModelsDev {
t.Fatal("bad fetch must not clobber the existing cache")
}
}
func TestRefreshModelsDevCacheDisabledByEnv(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("fetch must not happen when disabled")
}))
defer server.Close()
t.Setenv("ZERO_MODELS_CACHE_PATH", filepath.Join(t.TempDir(), "modelsdev.json"))
t.Setenv("ZERO_MODELS_URL", server.URL)
t.Setenv("ZERO_DISABLE_MODELS_FETCH", "1")
if err := RefreshModelsDevCache(t.Context()); err != nil {
t.Fatal(err)
}
}
func TestCachedModelsDevProvidersIgnoresStaleCache(t *testing.T) {
cachePath := filepath.Join(t.TempDir(), "modelsdev.json")
if err := os.WriteFile(cachePath, []byte(sampleModelsDev), 0o644); err != nil {
t.Fatal(err)
}
stale := time.Now().Add(-modelsDevMaxAge - time.Hour)
if err := os.Chtimes(cachePath, stale, stale); err != nil {
t.Fatal(err)
}
t.Setenv("ZERO_MODELS_CACHE_PATH", cachePath)
resetModelsDevCacheForTest()
t.Cleanup(resetModelsDevCacheForTest)
EnableModelsDevOverlay()
if providers := cachedModelsDevProviders(); providers != nil {
t.Fatal("stale cache must be ignored")
}
}
func TestCachedModelsDevProvidersRequiresOptIn(t *testing.T) {
cachePath := filepath.Join(t.TempDir(), "modelsdev.json")
if err := os.WriteFile(cachePath, []byte(sampleModelsDev), 0o644); err != nil {
t.Fatal(err)
}
t.Setenv("ZERO_MODELS_CACHE_PATH", cachePath)
resetModelsDevCacheForTest()
t.Cleanup(resetModelsDevCacheForTest)
// Without EnableModelsDevOverlay a fresh, valid cache must still be ignored:
// library consumers and hermetic tests get the pure curated catalog.
if providers := cachedModelsDevProviders(); providers != nil {
t.Fatal("overlay must be opt-in")
}
}
func TestDefaultModelEntriesAppliesFreshCache(t *testing.T) {
cachePath := filepath.Join(t.TempDir(), "modelsdev.json")
if err := os.WriteFile(cachePath, []byte(sampleModelsDev), 0o644); err != nil {
t.Fatal(err)
}
t.Setenv("ZERO_MODELS_CACHE_PATH", cachePath)
resetModelsDevCacheForTest()
t.Cleanup(resetModelsDevCacheForTest)
EnableModelsDevOverlay()
for _, entry := range DefaultModelEntries() {
if entry.ID == "claude-sonnet-4.5" {
if entry.ContextLimits.ContextWindow != 1_000_000 {
t.Fatalf("fresh cache must overlay the registry: %+v", entry.ContextLimits)
}
return
}
}
t.Fatal("claude-sonnet-4.5 not found")
}
+6 -1
View File
@@ -114,7 +114,12 @@ func createSecretFile(path string) ([]byte, error) {
}
return writeNewSecretFile(path)
}
if !errors.Is(err, os.ErrExist) {
// On Windows a concurrent holder's os.Remove leaves the lock file in a
// "delete pending" state, so an O_EXCL create races it with
// ERROR_ACCESS_DENIED (os.ErrPermission) rather than ErrExist. Treat that
// as contention and retry too — mirroring acquireFileLock in lock.go —
// otherwise concurrent secret creation spuriously fails on Windows.
if !errors.Is(err, os.ErrExist) && !errors.Is(err, os.ErrPermission) {
return nil, fmt.Errorf("oauth: create token secret lock: %w", err)
}
if data, rerr := readSecretFileRetry(path); rerr == nil {
-3
View File
@@ -34,8 +34,6 @@ const (
var ErrUnknownProvider = errors.New("unknown provider")
const AIMLAPIID = "aimlapi"
type Descriptor struct {
ID string
Name string
@@ -101,7 +99,6 @@ var descriptors = []Descriptor{
localOpenAI("ollama", "Ollama Local", "http://localhost:11434/v1", "llama3.1", "ollama local"),
localOpenAI("lmstudio", "LM Studio", "http://localhost:1234/v1", "local-model", "lm-studio", "lm studio"),
oauthProvider(openAICompat("openrouter", "OpenRouter", "https://openrouter.ai/api/v1", "openai/gpt-4.1", []string{"OPENROUTER_API_KEY"}), true, false),
openAICompat(AIMLAPIID, "AI/ML API", "https://api.aimlapi.com/v1", "openai/gpt-5-chat", []string{"AIMLAPI_API_KEY"}, "aiml api", "ai/ml api"),
// Hugging Face Inference Providers — OpenAI-compatible router at
// https://router.huggingface.co/v1 exposes hundreds of OSS models. OAuth
// requires a one-time app registration at huggingface.co/settings/applications/new
+1 -24
View File
@@ -16,7 +16,6 @@ var expectedCatalogIDs = []string{
"ollama",
"lmstudio",
"openrouter",
"aimlapi",
"huggingface",
"chatgpt",
"groq",
@@ -103,28 +102,6 @@ func TestRecommendedProviderEndpoint(t *testing.T) {
}
}
func TestAIMLAPIDescriptor(t *testing.T) {
descriptor, err := Require("aimlapi")
if err != nil {
t.Fatalf("Require(aimlapi) error = %v", err)
}
if descriptor.Name != "AI/ML API" {
t.Fatalf("Name = %q, want AI/ML API", descriptor.Name)
}
if descriptor.DefaultBaseURL != "https://api.aimlapi.com/v1" {
t.Fatalf("DefaultBaseURL = %q, want AI/ML API endpoint", descriptor.DefaultBaseURL)
}
if descriptor.DefaultModel != "openai/gpt-5-chat" {
t.Fatalf("DefaultModel = %q, want openai/gpt-5-chat", descriptor.DefaultModel)
}
if descriptor.Transport != TransportOpenAICompatible {
t.Fatalf("Transport = %q, want %q", descriptor.Transport, TransportOpenAICompatible)
}
if !reflect.DeepEqual(descriptor.AuthEnvVars, []string{"AIMLAPI_API_KEY"}) {
t.Fatalf("AuthEnvVars = %#v, want AIMLAPI_API_KEY", descriptor.AuthEnvVars)
}
}
func TestLongCatDescriptor(t *testing.T) {
descriptor, err := Require("longcat")
if err != nil {
@@ -315,7 +292,7 @@ func TestListByTransportPreservesCatalogOrder(t *testing.T) {
TransportBedrock: {"bedrock"},
TransportVertex: {"vertex"},
TransportAnthropicCompat: {"minimax", "minimaxi-cn", "custom-anthropic-compatible"},
TransportOpenAICompat: {"gitlawb-opengateway", "ollama-cloud", "ollama", "lmstudio", "openrouter", "aimlapi", "huggingface", "chatgpt", "groq", "deepseek", "together", "dashscope", "moonshot", "longcat", "nvidia-nim", "mistral", "github", "xai", "venice", "xiaomi-mimo", "bankr", "zai", "zai-cn", "kilocode", "opencode", "opencode-go", "atomic-chat", "chatgpt-proxy", "custom-openai-compatible"},
TransportOpenAICompat: {"gitlawb-opengateway", "ollama-cloud", "ollama", "lmstudio", "openrouter", "huggingface", "chatgpt", "groq", "deepseek", "together", "dashscope", "moonshot", "longcat", "nvidia-nim", "mistral", "github", "xai", "venice", "xiaomi-mimo", "bankr", "zai", "zai-cn", "kilocode", "opencode", "opencode-go", "atomic-chat", "chatgpt-proxy", "custom-openai-compatible"},
}
for transport, wantIDs := range cases {
@@ -169,8 +169,8 @@ func TestValidateEndpointAllowsLoopbackOnlyForLocalProvider(t *testing.T) {
if err := validateEndpoint(ctx, ep, r, true); err != nil {
t.Errorf("local provider endpoint %q should be allowed with allowLoopback=true, got %v", ep, err)
}
if !endpointIsLoopback(ep) {
t.Errorf("endpointIsLoopback(%q) = false, want true", ep)
if !endpointIsLoopbackOrPrivate(ep) {
t.Errorf("endpointIsLoopbackOrPrivate(%q) = false, want true", ep)
}
}
@@ -179,14 +179,27 @@ func TestValidateEndpointAllowsLoopbackOnlyForLocalProvider(t *testing.T) {
t.Error("loopback must remain blocked when allowLoopback=false (redirects / non-local)")
}
// allowLoopback relaxes loopback ONLY — other special ranges (cloud metadata,
// link-local, private) stay blocked even for a local provider config.
for _, ep := range []string{"http://169.254.169.254/latest/meta-data", "http://10.0.0.5:8080/v1"} {
// allowLoopbackOrPrivate relaxes loopback AND private-network ranges — but
// link-local and other special ranges (cloud metadata, documentation) stay
// blocked even for a local provider config.
for _, ep := range []string{"http://169.254.169.254/latest/meta-data", "http://192.0.2.1:8080/v1"} {
if err := validateEndpoint(ctx, ep, staticResolver{addr: netip.MustParseAddr("169.254.169.254")}, true); err == nil {
t.Errorf("non-loopback special address %q must stay blocked even with allowLoopback=true", ep)
t.Errorf("special-use address %q must stay blocked even with flag=true", ep)
}
if endpointIsLoopback(ep) {
t.Errorf("endpointIsLoopback(%q) = true, want false", ep)
if endpointIsLoopbackOrPrivate(ep) {
t.Errorf("endpointIsLoopbackOrPrivate(%q) = true, want false", ep)
}
}
// Private-network addresses (192.168.x.y / 10.x.y.z / 172.16.x.y) are
// now allowed when the flag is true — the user is pointing at their own
// LAN box (e.g. llama.cpp on another local machine).
for _, ep := range []string{"http://192.168.1.100:8080/v1", "http://10.0.0.5:8080/v1", "http://172.16.0.10:8080/v1"} {
if err := validateEndpoint(ctx, ep, staticResolver{addr: netip.MustParseAddr("192.168.1.100")}, true); err != nil {
t.Errorf("private-network address %q should be allowed with flag=true, got %v", ep, err)
}
if !endpointIsLoopbackOrPrivate(ep) {
t.Errorf("endpointIsLoopbackOrPrivate(%q) = false, want true", ep)
}
}
}
+27 -25
View File
@@ -269,7 +269,7 @@ func connectivityCheck(ctx context.Context, profile config.ProviderProfile, kind
requestCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
request, allowLoopback, err := healthRequest(requestCtx, profile, kind, options)
request, allowLoopbackOrPrivate, err := healthRequest(requestCtx, profile, kind, options)
if err != nil {
category := CategoryConfig
var safety endpointSafetyError
@@ -280,7 +280,7 @@ func connectivityCheck(ctx context.Context, profile config.ProviderProfile, kind
}
client := options.HTTPClient
if client == nil {
client = newConnectivityClient(timeout, options.Resolver, sensitiveAuthHeaderNames(profile, kind), allowLoopback)
client = newConnectivityClient(timeout, options.Resolver, sensitiveAuthHeaderNames(profile, kind), allowLoopbackOrPrivate)
}
response, err := client.Do(request)
if err != nil {
@@ -332,8 +332,8 @@ func healthRequest(ctx context.Context, profile config.ProviderProfile, kind con
// Loopback is permitted only when the user's OWN configured base_url is loopback
// (a local provider like Ollama/LM Studio) — never for a redirect target. This is
// the flag returned to the caller so the dial path applies the same policy. (AUDIT-H1)
allowLoopback := endpointIsLoopback(endpoint)
if err := validateEndpoint(ctx, endpoint, options.Resolver, allowLoopback); err != nil {
allowLoopbackOrPrivate := endpointIsLoopbackOrPrivate(endpoint)
if err := validateEndpoint(ctx, endpoint, options.Resolver, allowLoopbackOrPrivate); err != nil {
return nil, false, err
}
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
@@ -344,7 +344,7 @@ func healthRequest(ctx context.Context, profile config.ProviderProfile, kind con
request.Header.Set("User-Agent", options.UserAgent)
}
applyAuth(request, profile, kind)
return request, allowLoopback, nil
return request, allowLoopbackOrPrivate, nil
}
func resolvedBaseURL(profile config.ProviderProfile, kind config.ProviderKind) (string, error) {
@@ -367,11 +367,11 @@ func resolvedBaseURL(profile config.ProviderProfile, kind config.ProviderKind) (
}
// validateEndpoint enforces the SSRF policy for the provider connectivity probe.
// allowLoopback permits loopback hosts (localhost / 127.0.0.0/8 / ::1) ONLY — it is
// set true exclusively for the user's own explicitly-configured local provider
// base_url (e.g. Ollama/LM Studio), never for redirect targets, and it relaxes
// loopback alone (other private/special ranges, incl. cloud metadata, stay blocked).
func validateEndpoint(ctx context.Context, endpoint string, resolver Resolver, allowLoopback bool) error {
// allowLoopbackOrPrivate permits loopback hosts (localhost / 127.0.0.0/8 / ::1)
// and private-network hosts (192.168.0.0/16 / 10.0.0.0/8 / 172.16.0.0/12) — it
// is set true exclusively for the user's own explicitly-configured base_url
// (e.g. Ollama/LM Studio on the LAN), never for redirect targets.
func validateEndpoint(ctx context.Context, endpoint string, resolver Resolver, allowLoopbackOrPrivate bool) error {
parsed, err := url.Parse(endpoint)
if err != nil {
return err
@@ -385,13 +385,13 @@ func validateEndpoint(ctx context.Context, endpoint string, resolver Resolver, a
}
normalized := strings.TrimSuffix(strings.ToLower(host), ".")
if normalized == "localhost" || strings.HasSuffix(normalized, ".localhost") {
if allowLoopback {
if allowLoopbackOrPrivate {
return nil // user-configured local provider; loopback is intentional
}
return endpointSafetyError{message: "provider connectivity URL is unsafe: localhost hosts are blocked"}
}
if addr, err := netip.ParseAddr(normalized); err == nil {
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopback && addr.IsLoopback()) {
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopbackOrPrivate && (addr.IsLoopback() || addr.IsPrivate())) {
return endpointSafetyError{message: "provider connectivity URL is unsafe: " + reason}
}
return nil
@@ -407,17 +407,19 @@ func validateEndpoint(ctx context.Context, endpoint string, resolver Resolver, a
return endpointSafetyError{message: "provider connectivity host resolved to no addresses"}
}
for _, addr := range addrs {
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopback && addr.IsLoopback()) {
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopbackOrPrivate && (addr.IsLoopback() || addr.IsPrivate())) {
return endpointSafetyError{message: "provider connectivity URL is unsafe: " + reason}
}
}
return nil
}
// endpointIsLoopback reports whether the configured endpoint's host is a loopback
// host the user typed themselves (localhost or a 127.0.0.0/8 / ::1 literal). Used to
// decide whether the connectivity probe may reach the user's own local provider.
func endpointIsLoopback(endpoint string) bool {
// endpointIsLoopbackOrPrivate reports whether the configured endpoint's host is a loopback
// or private-network host the user typed themselves — a local provider on the LAN
// (e.g. 192.168.x.y / 10.x.y.z) or localhost. Used to decide whether the
// connectivity probe may relax address-blocking and reach the user's own
// explicitly-configured provider.
func endpointIsLoopbackOrPrivate(endpoint string) bool {
parsed, err := url.Parse(endpoint)
if err != nil {
return false
@@ -427,7 +429,7 @@ func endpointIsLoopback(endpoint string) bool {
return true
}
if addr, err := netip.ParseAddr(host); err == nil {
return addr.IsLoopback()
return addr.IsLoopback() || addr.IsPrivate()
}
return false
}
@@ -446,7 +448,7 @@ const maxConnectivityRedirects = 5
// time and then dials the validated IP literal, closing the TOCTOU window
// between the pre-flight validateEndpoint check and the actual connection
// (DNS rebinding).
func newConnectivityClient(timeout time.Duration, resolver Resolver, sensitiveHeaders []string, allowLoopback bool) *http.Client {
func newConnectivityClient(timeout time.Duration, resolver Resolver, sensitiveHeaders []string, allowLoopbackOrPrivate bool) *http.Client {
if resolver == nil {
resolver = defaultResolver{}
}
@@ -456,7 +458,7 @@ func newConnectivityClient(timeout time.Duration, resolver Resolver, sensitiveHe
} else {
transport = &http.Transport{}
}
transport.DialContext = safeDialContext(resolver, allowLoopback)
transport.DialContext = safeDialContext(resolver, allowLoopbackOrPrivate)
return &http.Client{
Timeout: timeout,
Transport: transport,
@@ -476,8 +478,8 @@ func newConnectivityClient(timeout time.Duration, resolver Resolver, sensitiveHe
}
}
// Redirects are NEVER allowed to reach loopback, even for a local provider:
// allowLoopback covers only the user's own configured base_url, not a 3xx
// target (which could be attacker-controlled SSRF). (AUDIT-H1)
// allowLoopbackOrPrivate covers only the user's own configured base_url,
// not a 3xx target (which could be attacker-controlled SSRF). (AUDIT-H1)
return validateEndpoint(req.Context(), req.URL.String(), resolver, false)
},
}
@@ -519,7 +521,7 @@ func sensitiveAuthHeaderNames(profile config.ProviderProfile, kind config.Provid
// safeDialContext returns a dial function that resolves the target host, refuses
// the connection if any resolved address is blocked, then dials the validated IP
// literal so the kernel cannot re-resolve to a different address after the check.
func safeDialContext(resolver Resolver, allowLoopback bool) func(context.Context, string, string) (net.Conn, error) {
func safeDialContext(resolver Resolver, allowLoopbackOrPrivate bool) func(context.Context, string, string) (net.Conn, error) {
dialer := &net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}
return func(ctx context.Context, network, address string) (net.Conn, error) {
host, port, err := net.SplitHostPort(address)
@@ -527,7 +529,7 @@ func safeDialContext(resolver Resolver, allowLoopback bool) func(context.Context
return nil, err
}
if addr, parseErr := netip.ParseAddr(host); parseErr == nil {
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopback && addr.IsLoopback()) {
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopbackOrPrivate && (addr.IsLoopback() || addr.IsPrivate())) {
return nil, endpointSafetyError{message: "provider connectivity URL is unsafe: " + reason}
}
return dialer.DialContext(ctx, network, address)
@@ -540,7 +542,7 @@ func safeDialContext(resolver Resolver, allowLoopback bool) func(context.Context
return nil, endpointSafetyError{message: "provider connectivity host resolved to no addresses"}
}
for _, addr := range addrs {
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopback && addr.IsLoopback()) {
if reason := blockedAddrReason(addr); reason != "" && !(allowLoopbackOrPrivate && (addr.IsLoopback() || addr.IsPrivate())) {
return nil, endpointSafetyError{message: "provider connectivity URL is unsafe: " + reason}
}
}
+1 -1
View File
@@ -195,7 +195,7 @@ func discoverOpenAIModels(ctx context.Context, profile config.ProviderProfile, o
AuthHeader: profile.AuthHeader,
AuthScheme: profile.AuthScheme,
AuthHeaderValue: profile.AuthHeaderValue,
CustomHeaders: providerio.CopyHeaders(profile.CustomHeaders),
CustomHeaders: profile.CustomHeaders,
}, nil)
}
@@ -52,44 +52,6 @@ func TestDiscoverOpenAICompatibleModelsFetchesModelsEndpoint(t *testing.T) {
}
}
func TestDiscoverAIMLAPIModelsSendsAuthAndCustomHeadersWithoutAttribution(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
for header, want := range map[string]string{
"Authorization": "Bearer test-key",
"X-Trace": "test",
} {
if got := r.Header.Get(header); got != want {
t.Errorf("%s = %q, want %q", header, got, want)
}
}
// No first-party referral/attribution headers are injected for catalog
// presets; aimlapi rides through CopyHeaders like every other provider.
for _, header := range []string{
"X-AIMLAPI-Partner-ID",
"X-AIMLAPI-Integration-Repo",
"X-AIMLAPI-Integration-Version",
} {
if got := r.Header.Get(header); got != "" {
t.Errorf("%s = %q, want no attribution header", header, got)
}
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":[{"id":"openai/gpt-5-chat"}]}`))
}))
defer server.Close()
_, err := Discover(context.Background(), config.ProviderProfile{
CatalogID: "aimlapi",
ProviderKind: config.ProviderKindOpenAICompatible,
BaseURL: server.URL + "/v1",
APIKey: "test-key",
CustomHeaders: map[string]string{"X-Trace": "test"},
}, Options{HTTPClient: server.Client()})
if err != nil {
t.Fatalf("Discover returned error: %v", err)
}
}
func TestDiscoverOpenAICompatibleModelsHonorsAuthHeaderValue(t *testing.T) {
// A profile can authenticate via a raw auth-header value instead of APIKey;
// discovery must send it rather than probe unauthenticated.
@@ -0,0 +1,96 @@
package anthropic
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// The final two messages must carry a cache_control breakpoint on their last
// cacheable block so the conversation transcript — not just system prompt and
// tools — is a prompt-cache hit on the next turn. Earlier messages must stay
// unmarked (Anthropic caps breakpoints at 4 per request: system, tools, and
// these two).
func TestAnthropicRequestMarksLastTwoMessagesForCaching(t *testing.T) {
var gotBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
t.Fatalf("decode request body: %v", err)
}
writeSSEEvent(w, "message_stop", `{"type":"message_stop"}`)
}))
defer server.Close()
provider, err := New(Options{BaseURL: server.URL + "/", Model: "claude-test"})
if err != nil {
t.Fatalf("New returned error: %v", err)
}
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{
{Role: zeroruntime.MessageRoleUser, Content: "first"},
{
Role: zeroruntime.MessageRoleAssistant,
Content: "calling a tool",
ToolCalls: []zeroruntime.ToolCall{{ID: "toolu_1", Name: "grep", Arguments: `{}`}},
},
{Role: zeroruntime.MessageRoleTool, Content: "result", ToolCallID: "toolu_1"},
},
})
if err != nil {
t.Fatalf("StreamCompletion returned error: %v", err)
}
drain(stream)
messages := gotBody["messages"].([]any)
if len(messages) != 3 {
t.Fatalf("expected 3 wire messages, got %d: %#v", len(messages), messages)
}
lastBlockHasCache := func(message any) bool {
blocks, ok := message.(map[string]any)["content"].([]any)
if !ok || len(blocks) == 0 {
return false
}
last := blocks[len(blocks)-1].(map[string]any)
control, ok := last["cache_control"].(map[string]any)
return ok && control["type"] == "ephemeral"
}
if lastBlockHasCache(messages[0]) {
t.Fatalf("first message must not carry a breakpoint: %#v", messages[0])
}
if !lastBlockHasCache(messages[1]) {
t.Fatalf("second-to-last message must carry a breakpoint: %#v", messages[1])
}
if !lastBlockHasCache(messages[2]) {
t.Fatalf("last message must carry a breakpoint: %#v", messages[2])
}
}
// A string-content message must be converted to a block array so it can carry
// the breakpoint, and thinking blocks must never carry cache_control (the API
// rejects them) — the marker goes on the last cacheable block instead.
func TestApplyMessageCacheBreakpointsSkipsThinkingBlocks(t *testing.T) {
messages := []anthropicMessage{
{Role: "user", Content: "plain string"},
{Role: "assistant", Content: []map[string]any{
{"type": "text", "text": "answer"},
{"type": "thinking", "thinking": "hmm", "signature": "sig"},
}},
}
applyMessageCacheBreakpoints(messages)
userBlocks := contentBlocks(messages[0].Content)
if len(userBlocks) != 1 || userBlocks[0]["cache_control"] == nil {
t.Fatalf("string content must become a marked block: %#v", messages[0].Content)
}
assistantBlocks := contentBlocks(messages[1].Content)
if assistantBlocks[1]["cache_control"] != nil {
t.Fatalf("thinking block must not be marked: %#v", assistantBlocks[1])
}
if assistantBlocks[0]["cache_control"] == nil {
t.Fatalf("text block must carry the breakpoint instead: %#v", assistantBlocks[0])
}
}
+26
View File
@@ -405,9 +405,35 @@ func (provider *Provider) anthropicRequest(request zeroruntime.CompletionRequest
}
mapped.Tools[len(mapped.Tools)-1].CacheControl = &cacheControl{Type: cacheEphemeral}
}
applyMessageCacheBreakpoints(mapped.Messages)
return mapped, nil
}
// applyMessageCacheBreakpoints marks the last content block of the final two
// messages with cache_control so the conversation transcript is cached
// turn-over-turn, not just the system prompt and tool definitions. Two message
// breakpoints (not one) keep the previous turn's prefix a cache hit while the
// newest suffix is being written. Anthropic allows at most 4 breakpoints per
// request: system + tools use two above, these use the remaining two. Thinking
// blocks cannot carry cache_control, so the marker goes on the last block that
// can.
func applyMessageCacheBreakpoints(messages []anthropicMessage) {
marked := 0
for i := len(messages) - 1; i >= 0 && marked < 2; i-- {
blocks := contentBlocks(messages[i].Content)
for j := len(blocks) - 1; j >= 0; j-- {
blockType, _ := blocks[j]["type"].(string)
if blockType == "thinking" || blockType == "redacted_thinking" {
continue
}
blocks[j]["cache_control"] = map[string]any{"type": cacheEphemeral}
messages[i].Content = blocks
marked++
break
}
}
}
func mapMessages(messages []zeroruntime.Message) (string, []anthropicMessage, error) {
systemParts := []string{}
mapped := []anthropicMessage{}
+3 -2
View File
@@ -24,7 +24,7 @@ type Options struct {
ModelRegistry *modelregistry.Registry
// OAuthResolver, when set, lets the provider authenticate model calls with an
// OAuth bearer token (preferred over the API key). nil => API-key auth only.
// Applied to the OpenAI and Anthropic providers.
// Applied to the OpenAI, Anthropic, and Google (Gemini) providers.
OAuthResolver providerio.TokenResolver
// OAuthLoginKey is the credential-store key OAuthResolver bound to (empty when
// there is no OAuth login). It is the SAME selection the bearer resolver made,
@@ -61,7 +61,7 @@ func New(profile config.ProviderProfile, options Options) (zeroruntime.Provider,
AuthHeader: profile.AuthHeader,
AuthScheme: profile.AuthScheme,
AuthHeaderValue: profile.AuthHeaderValue,
CustomHeaders: providerio.CopyHeaders(profile.CustomHeaders),
CustomHeaders: profile.CustomHeaders,
OAuthResolver: options.OAuthResolver,
MaxTokens: resolved.maxOutputTokens,
HTTPClient: options.HTTPClient,
@@ -91,6 +91,7 @@ func New(profile config.ProviderProfile, options Options) (zeroruntime.Provider,
AuthScheme: profile.AuthScheme,
AuthHeaderValue: profile.AuthHeaderValue,
CustomHeaders: profile.CustomHeaders,
OAuthResolver: options.OAuthResolver,
MaxTokens: resolved.maxOutputTokens,
HTTPClient: options.HTTPClient,
UserAgent: options.UserAgent,
-49
View File
@@ -95,55 +95,6 @@ func TestNewThreadsCustomProviderHeaders(t *testing.T) {
}
}
func TestNewAIMLAPIProviderSendsEndpointAndAuthWithoutAttribution(t *testing.T) {
transport := &captureTransport{responseBody: "data: [DONE]\n\n"}
provider, err := New(config.ProviderProfile{
Name: "aimlapi",
CatalogID: "aimlapi",
ProviderKind: config.ProviderKindOpenAICompatible,
APIKey: "aimlapi-test-key",
Model: "openai/gpt-5-chat",
CustomHeaders: map[string]string{"X-Trace": "test"},
}, Options{HTTPClient: &http.Client{Transport: transport}})
if err != nil {
t.Fatalf("New() error = %v", err)
}
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hello"}},
})
if err != nil {
t.Fatalf("StreamCompletion() error = %v", err)
}
for range stream {
}
if transport.request == nil {
t.Fatal("HTTP client was not used")
}
if got := transport.request.URL.String(); got != "https://api.aimlapi.com/v1/chat/completions" {
t.Fatalf("request URL = %q, want AI/ML API endpoint", got)
}
for header, want := range map[string]string{
"Authorization": "Bearer aimlapi-test-key",
"X-Trace": "test",
} {
if got := transport.request.Header.Get(header); got != want {
t.Fatalf("%s = %q, want %q", header, got, want)
}
}
// No first-party referral/attribution headers are injected for catalog
// presets; aimlapi rides through CopyHeaders like every other provider.
for _, header := range []string{
"X-AIMLAPI-Partner-ID",
"X-AIMLAPI-Integration-Repo",
"X-AIMLAPI-Integration-Version",
} {
if got := transport.request.Header.Get(header); got != "" {
t.Fatalf("%s = %q, want no attribution header", header, got)
}
}
}
func TestNewSupportsOpenAIProviderKind(t *testing.T) {
provider, err := New(config.ProviderProfile{
Name: "openai",
+16 -8
View File
@@ -49,6 +49,10 @@ type Options struct {
CustomHeaders map[string]string
HTTPClient *http.Client
UserAgent string
// OAuthResolver, when set, supplies an OAuth bearer credential per request and
// is retried once with a forced token refresh after an upstream 401 (matching
// the OpenAI and Anthropic providers). Nil falls back to plain API-key auth.
OAuthResolver providerio.TokenResolver
// StreamIdleTimeout aborts the stream if no data arrives for this long.
// When unset, Zero uses providerio.ResolveStreamIdleTimeout — the
// ZERO_STREAM_IDLE_TIMEOUT override or providerio.DefaultStreamIdleTimeout.
@@ -67,6 +71,7 @@ type Provider struct {
customHeaders map[string]string
httpClient *http.Client
userAgent string
oauthResolver providerio.TokenResolver
streamIdleTimeout time.Duration
}
@@ -95,6 +100,7 @@ func New(options Options) (*Provider, error) {
customHeaders: providerio.CopyHeaders(options.CustomHeaders),
httpClient: providerio.HTTPClient(options.HTTPClient),
userAgent: options.UserAgent,
oauthResolver: options.OAuthResolver,
streamIdleTimeout: providerio.ResolveStreamIdleTimeout(options.StreamIdleTimeout),
}, nil
}
@@ -127,20 +133,22 @@ func (provider *Provider) stream(ctx context.Context, body []byte, events chan<-
streamCtx, cancelStream := context.WithCancel(ctx)
defer cancelStream()
response, err := providerio.SendWithRetry(streamCtx, provider.httpClient, http.MethodPost, provider.streamURL(), body, func(request *http.Request) {
request.Header.Set("Content-Type", "application/json")
if provider.userAgent != "" {
request.Header.Set("User-Agent", provider.userAgent)
}
providerio.ApplyAuthHeaders(request, providerio.AuthHeaders{
response, err := providerio.SendWithAuthRetry(streamCtx, provider.httpClient, http.MethodPost, provider.streamURL(), body,
providerio.AuthHeaders{
APIKey: provider.apiKey,
DefaultAuthHeader: "x-goog-api-key",
AuthHeader: provider.authHeader,
AuthScheme: provider.authScheme,
AuthHeaderValue: provider.authHeaderValue,
CustomHeaders: provider.customHeaders,
})
}, 0)
},
provider.oauthResolver,
func(request *http.Request) {
request.Header.Set("Content-Type", "application/json")
if provider.userAgent != "" {
request.Header.Set("User-Agent", provider.userAgent)
}
}, 0)
if err != nil {
providerio.SendEvent(ctx, events, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventError, Error: provider.redact("provider stream error: " + err.Error())})
return
@@ -370,6 +370,57 @@ func TestStreamCompletionClassifiesHTTPAndPromptBlockErrors(t *testing.T) {
}
}
// A 401 with an OAuth resolver is retried once with a force-refreshed token; the
// replayed request carries the refreshed bearer and succeeds.
func TestStreamCompletionRetries401WithRefreshedToken(t *testing.T) {
var attempts int
var secondAuth string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
attempts++
if attempts == 1 {
http.Error(w, `{"error":{"message":"token expired"}}`, http.StatusUnauthorized)
return
}
secondAuth = r.Header.Get("Authorization")
writeSSE(w, `{}`)
}))
defer server.Close()
var forceRefreshOnRetry bool
resolver := func(ctx context.Context, forceRefresh bool) (string, string, bool, error) {
if forceRefresh {
forceRefreshOnRetry = true
return "Authorization", "Bearer refreshed", true, nil
}
return "Authorization", "Bearer stale", true, nil
}
provider, err := New(Options{
APIKey: "sk-google",
BaseURL: server.URL,
Model: "gemini-test",
OAuthResolver: resolver,
})
if err != nil {
t.Fatalf("New returned error: %v", err)
}
stream, err := provider.StreamCompletion(context.Background(), validRequest())
if err != nil {
t.Fatalf("StreamCompletion returned error: %v", err)
}
drain(stream)
if attempts != 2 {
t.Fatalf("attempts = %d, want 2 (initial 401 + one refreshed retry)", attempts)
}
if !forceRefreshOnRetry {
t.Fatalf("resolver was not called with forceRefresh on the retry")
}
if secondAuth != "Bearer refreshed" {
t.Fatalf("retry Authorization = %q, want refreshed bearer", secondAuth)
}
}
func TestStreamCompletionEmitsStreamErrorObject(t *testing.T) {
provider := newTestProviderWithKey(t, "sk-google", func(w http.ResponseWriter, r *http.Request) {
writeSSE(w, `{"error":{"code":429,"message":"stream failed sk-google","status":"RESOURCE_EXHAUSTED"}}`)
+32 -11
View File
@@ -23,12 +23,18 @@ import (
// (billable) work. Only the INITIAL request is ever in scope; once the response
// body starts streaming it is never re-issued.
const defaultMaxRetryAttempts = 3
const defaultMaxRetryAttempts = 6
// maxBackoff caps a single backoff wait so a hostile or buggy Retry-After can't
// stall the agent for minutes.
const maxBackoff = 30 * time.Second
// retryBackoffBase is the first wait when the server supplied no Retry-After.
// Rate-limit windows are measured in seconds, not milliseconds: retrying a 429
// after 400ms almost always burns the attempt while still limited, so the
// schedule is 2s, 4s, 8s, 16s, then maxBackoff. A var so tests can shrink it.
var retryBackoffBase = 2 * time.Second
// SendWithRetry issues an HTTP request, retrying ONLY the safe-to-replay server
// responses (429 and 503, see ShouldRetryStatus) up to maxAttempts — backing off
// between tries and honoring a server Retry-After header and context
@@ -110,17 +116,11 @@ func ShouldRetryStatus(code int) bool {
}
// Backoff waits before retry attempt N (1-based), returning false if the context
// is cancelled during the wait. The wait is attempt*400ms unless the server
// supplied a (positive) Retry-After, and is capped at maxBackoff.
// is cancelled during the wait. A server-supplied (positive) Retry-After wins;
// otherwise the wait doubles from retryBackoffBase per attempt. Either way the
// wait is capped at maxBackoff.
func Backoff(ctx context.Context, attempt int, retryAfter time.Duration) bool {
wait := time.Duration(attempt) * 400 * time.Millisecond
if retryAfter > 0 {
wait = retryAfter
}
if wait > maxBackoff {
wait = maxBackoff
}
timer := time.NewTimer(wait)
timer := time.NewTimer(backoffWait(attempt, retryAfter))
defer timer.Stop()
select {
case <-ctx.Done():
@@ -130,6 +130,27 @@ func Backoff(ctx context.Context, attempt int, retryAfter time.Duration) bool {
}
}
// backoffWait computes the wait before retry attempt N (1-based): Retry-After
// when supplied, else exponential from retryBackoffBase, both capped at
// maxBackoff. The exponent is clamped so a large attempt count cannot overflow.
func backoffWait(attempt int, retryAfter time.Duration) time.Duration {
wait := retryAfter
if wait <= 0 {
exponent := attempt - 1
if exponent > 5 {
exponent = 5
}
if exponent < 0 {
exponent = 0
}
wait = retryBackoffBase * time.Duration(1<<exponent)
}
if wait > maxBackoff {
wait = maxBackoff
}
return wait
}
// RetryAfter parses a response's Retry-After header (delay-seconds or an HTTP
// date) into a positive duration, or 0 when absent/unparseable. The result is
// capped at maxBackoff by Backoff.
@@ -94,7 +94,40 @@ func TestBackoffWaitsThenReturnsTrue(t *testing.T) {
}
}
// shrinkBackoff makes retry waits negligible for the duration of a test.
func shrinkBackoff(t *testing.T) {
t.Helper()
saved := retryBackoffBase
retryBackoffBase = time.Millisecond
t.Cleanup(func() { retryBackoffBase = saved })
}
func TestBackoffWaitSchedule(t *testing.T) {
// Without Retry-After the wait doubles per attempt from 2s and caps at 30s;
// a supplied Retry-After wins but is capped too.
cases := []struct {
attempt int
retryAfter time.Duration
want time.Duration
}{
{1, 0, 2 * time.Second},
{2, 0, 4 * time.Second},
{3, 0, 8 * time.Second},
{4, 0, 16 * time.Second},
{5, 0, 30 * time.Second}, // 32s capped
{50, 0, 30 * time.Second}, // clamped exponent, no overflow
{1, 7 * time.Second, 7 * time.Second},
{1, 5 * time.Minute, 30 * time.Second}, // hostile Retry-After capped
}
for _, c := range cases {
if got := backoffWait(c.attempt, c.retryAfter); got != c.want {
t.Errorf("backoffWait(%d, %v) = %v, want %v", c.attempt, c.retryAfter, got, c.want)
}
}
}
func TestSendWithRetryRetriesThenSucceeds(t *testing.T) {
shrinkBackoff(t)
var hits int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if atomic.AddInt32(&hits, 1) == 1 {
@@ -140,6 +173,7 @@ func TestSendWithRetryReturnsNonRetryableImmediately(t *testing.T) {
}
func TestSendWithRetryReturnsLastResponseAfterMaxAttempts(t *testing.T) {
shrinkBackoff(t)
var hits int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&hits, 1)
+3
View File
@@ -72,6 +72,9 @@ func TestPermissionProfileFromPolicyIncludesDefaultTempWriteRoots(t *testing.T)
if !writeRootsContain(profile.FileSystem.WriteRoots, tmpdir) {
t.Fatalf("write roots = %#v, want temp root %q", profile.FileSystem.WriteRoots, tmpdir)
}
// /tmp is a default temp write root on POSIX only (see
// defaultTempWriteRootCandidatesForGOOS); on Windows the bare path resolves
// against the current drive, so a stray C:\tmp must not turn this on.
if runtime.GOOS != "windows" && pathExists("/tmp") && !writeRootsContain(profile.FileSystem.WriteRoots, "/tmp") {
t.Fatalf("write roots = %#v, want /tmp", profile.FileSystem.WriteRoots)
}
+3
View File
@@ -129,6 +129,9 @@ func TestNewScopeNormalizesAndValidatesExtraRoots(t *testing.T) {
if !stringSliceContains(roots, normalizeWorkspaceRootBestEffort(extra)) {
t.Fatalf("Roots()=%v want extra root %q", roots, normalizeWorkspaceRootBestEffort(extra))
}
// /tmp is a default temp write root on POSIX only (see
// defaultTempWriteRootCandidatesForGOOS); on Windows the bare path resolves
// against the current drive, so a stray C:\tmp must not turn this on.
if runtime.GOOS != "windows" && pathExists("/tmp") && !stringSliceContains(roots, normalizeWorkspaceRootBestEffort("/tmp")) {
t.Fatalf("Roots()=%v want default /tmp write root", roots)
}
+10 -1
View File
@@ -87,11 +87,20 @@ func windowsCommandLine(args []string) (string, error) {
if len(args) == 0 {
return "", errorsNewWindowsProcess("missing command")
}
out := make([]string, 0, len(args))
for _, arg := range args {
if strings.ContainsRune(arg, 0) {
return "", errorsNewWindowsProcess("command argument contains NUL")
}
}
// Zero's cmd.exe shell invocation needs its final element (the shell
// command text) reaching cmd.exe raw, not escaped like a normal argv
// element: see WindowsShellCommandLine for why. Every other command this
// runner might launch is escaped normally below.
if commandLine, ok := windowsShellCommandLineFromArgs(args); ok {
return commandLine, nil
}
out := make([]string, 0, len(args))
for _, arg := range args {
out = append(out, syscall.EscapeArg(arg))
}
return strings.Join(out, " "), nil
+50
View File
@@ -105,6 +105,56 @@ const (
WindowsSandboxLevelDisabled WindowsSandboxLevel = "disabled"
)
// WindowsShellArgs returns cmd.exe's argv (after the executable name) for
// running commandText as a shell command on Windows: no wrapping quotes and
// no /S, so commandText reaches cmd.exe's own /C remainder parsing exactly as
// written. See WindowsShellCommandLine for why that matters. Used both as
// CommandSpec.Args for the plain (unwrapped) path and, via
// windowsShellCommandLineFromArgs, to recognize this same shape once it has
// round-tripped through the sandboxed runner's CLI argv.
func WindowsShellArgs(commandText string) []string {
return []string{"/d", "/c", commandText}
}
// WindowsShellCommandLine builds the raw command line a Windows CreateProcess
// call should receive to run commandText as a shell command: "cmd.exe /d /c "
// followed by commandText completely unescaped.
//
// cmd.exe's own /C remainder parsing is not CommandLineToArgvW-compatible: it
// applies its own quote-stripping heuristic (documented in `cmd /?`) whenever
// the remainder starts with a literal quote, and that heuristic does not
// understand backslash-escaped inner quotes the way a normal argv consumer
// would. A command like `python -c "print(15 / 3)"` passed through the
// standard exec.Cmd Args mechanism gets wrapped in an outer pair of quotes
// (because it contains spaces) with its own quotes escaped as \" — exactly
// the shape that heuristic corrupts, stripping the outer quotes but leaving
// the literal backslashes behind. Passing commandText through raw, exactly as
// the model wrote it, means cmd.exe parses it the same way it would if typed
// directly at an interactive prompt: a leading quoted executable path like
// `"C:\Program Files\foo.exe" arg` is preserved correctly by cmd.exe's own
// quote-preserving special case, and a command with no leading quote at all,
// like the python example above, is never touched by the stripping heuristic
// in the first place.
func WindowsShellCommandLine(commandText string) string {
return "cmd.exe /d /c " + commandText
}
// windowsShellCommandLineFromArgs reports whether args is exactly
// ["cmd.exe"]+WindowsShellArgs(text) for some text and, if so, returns
// WindowsShellCommandLine's result for that text. The sandboxed runner path
// only has the deserialized argv (WindowsSandboxCommandConfig.Command) to
// work from, not the original commandText, so it recovers the raw command
// line by recognizing the shape instead.
func windowsShellCommandLineFromArgs(args []string) (string, bool) {
if len(args) != 4 {
return "", false
}
if !strings.EqualFold(args[0], "cmd.exe") || args[1] != "/d" || args[2] != "/c" {
return "", false
}
return WindowsShellCommandLine(args[3]), true
}
type WindowsSandboxCommandArgsOptions struct {
SandboxHome string
CommandCWD string
+53
View File
@@ -147,3 +147,56 @@ func TestRunWindowsSandboxCommandRunnerRejectsInvalidArgs(t *testing.T) {
t.Fatalf("stderr = %q, want runner name", stderr.String())
}
}
func TestWindowsShellArgsHasNoSAndKeepsCommandTextWhole(t *testing.T) {
args := WindowsShellArgs(`python -c "print(15 / 3)"`)
want := []string{"/d", "/c", `python -c "print(15 / 3)"`}
if len(args) != len(want) {
t.Fatalf("args = %#v, want %#v", args, want)
}
for index := range want {
if args[index] != want[index] {
t.Fatalf("args = %#v, want %#v", args, want)
}
}
}
func TestWindowsShellCommandLineLeavesCommandTextUnescaped(t *testing.T) {
// The whole point of this function: commandText's own quotes must reach
// cmd.exe exactly as written, not re-quoted or backslash-escaped the way
// a normal argv-escaping function (like syscall.EscapeArg) would treat an
// argument containing spaces and embedded quotes.
commandText := `python -c "print(15 / 3)"`
got := WindowsShellCommandLine(commandText)
want := `cmd.exe /d /c python -c "print(15 / 3)"`
if got != want {
t.Fatalf("WindowsShellCommandLine = %q, want %q", got, want)
}
}
func TestWindowsShellCommandLineFromArgsRecognizesTheShellShape(t *testing.T) {
commandLine, ok := windowsShellCommandLineFromArgs([]string{"cmd.exe", "/d", "/c", `echo "hi"`})
if !ok {
t.Fatal("windowsShellCommandLineFromArgs did not recognize the shell shape")
}
if want := `cmd.exe /d /c echo "hi"`; commandLine != want {
t.Fatalf("commandLine = %q, want %q", commandLine, want)
}
}
func TestWindowsShellCommandLineFromArgsRejectsOtherShapes(t *testing.T) {
cases := [][]string{
nil,
{"cmd.exe"},
{"cmd.exe", "/d", "/c"},
{"cmd.exe", "/d", "/c", "echo hi", "extra"},
{"git.exe", "/d", "/c", "echo hi"},
{"cmd.exe", "/s", "/c", "echo hi"},
{"cmd.exe", "/d", "/k", "echo hi"},
}
for _, args := range cases {
if _, ok := windowsShellCommandLineFromArgs(args); ok {
t.Fatalf("windowsShellCommandLineFromArgs(%#v) matched, want no match", args)
}
}
}
+5 -3
View File
@@ -61,8 +61,10 @@ func buildWindowsUnelevatedAppliedPlan(config WindowsSandboxCommandConfig) (Wind
// loadWindowsUnelevatedSetupMarker reads the marker; a missing file is the
// common first-run state and returns an empty marker, not an error. A marker
// with an unexpected schema version is treated as empty so the runner re-applies
// (and rewrites) rather than failing.
// with an unexpected schema version or a corrupt/truncated body is likewise
// treated as empty so the runner re-applies (and rewrites) rather than failing:
// the marker only memoizes idempotent ACL applies, so resetting it can only
// cost redundant work, never skip enforcement.
func loadWindowsUnelevatedSetupMarker(sandboxHome string) (WindowsUnelevatedSetupMarker, error) {
sandboxHome = strings.TrimSpace(sandboxHome)
if sandboxHome == "" {
@@ -78,7 +80,7 @@ func loadWindowsUnelevatedSetupMarker(sandboxHome string) (WindowsUnelevatedSetu
}
var marker WindowsUnelevatedSetupMarker
if err := json.Unmarshal(bytes, &marker); err != nil {
return WindowsUnelevatedSetupMarker{}, fmt.Errorf("parse windows unelevated setup marker %s: %w", path, err)
return WindowsUnelevatedSetupMarker{SchemaVersion: windowsUnelevatedSetupMarkerSchemaVersion}, nil
}
if marker.SchemaVersion != windowsUnelevatedSetupMarkerSchemaVersion {
return WindowsUnelevatedSetupMarker{SchemaVersion: windowsUnelevatedSetupMarkerSchemaVersion}, nil
@@ -138,6 +138,38 @@ func TestWindowsUnelevatedSetupMarkerEvictsOldestPastCap(t *testing.T) {
}
}
func TestLoadWindowsUnelevatedSetupMarkerSelfHealsCorruptFile(t *testing.T) {
home := filepath.Join(t.TempDir(), ".zero-sandbox")
if err := os.MkdirAll(home, 0o700); err != nil {
t.Fatalf("mkdir sandbox home: %v", err)
}
path := WindowsUnelevatedSetupMarkerPath(home)
// A truncated body must reset like an unknown schema, not brick every
// unelevated command until the file is hand-deleted.
if err := os.WriteFile(path, []byte(`{"schemaVersion":1,"appliedPlans":[{"aclPlanHash":"h`), 0o600); err != nil {
t.Fatalf("write corrupt marker: %v", err)
}
marker, err := loadWindowsUnelevatedSetupMarker(home)
if err != nil {
t.Fatalf("corrupt marker must self-heal, got error: %v", err)
}
if len(marker.AppliedPlans) != 0 || marker.SchemaVersion != windowsUnelevatedSetupMarkerSchemaVersion {
t.Fatalf("corrupt marker must reset to empty current-schema marker, got %+v", marker)
}
// The healed marker must accept new recordings over the corrupt file.
applied := WindowsUnelevatedAppliedPlan{ACLPlanHash: "hash-heal", ACLPlanEntries: 2}
if err := recordWindowsUnelevatedAppliedPlan(home, applied); err != nil {
t.Fatalf("record over corrupt marker: %v", err)
}
marker, err = loadWindowsUnelevatedSetupMarker(home)
if err != nil {
t.Fatalf("reload healed marker: %v", err)
}
if !marker.contains(applied) {
t.Fatalf("healed marker = %+v, want the recorded plan", marker)
}
}
func TestLoadWindowsUnelevatedSetupMarkerResetsUnknownSchema(t *testing.T) {
home := filepath.Join(t.TempDir(), ".zero-sandbox")
if err := os.MkdirAll(home, 0o700); err != nil {
+20 -4
View File
@@ -160,10 +160,7 @@ func FormatExecPrompt(prompt string, prepared PreparedExec) string {
if prepared.Mode == ModeNew || len(prepared.ContextEvents) == 0 {
return prompt
}
events := prepared.ContextEvents
if len(events) > 20 {
events = events[len(events)-20:]
}
events := promptContextEvents(prepared.ContextEvents)
lines := []string{}
for _, event := range events {
@@ -187,6 +184,25 @@ func FormatExecPrompt(prompt string, prepared PreparedExec) string {
}, "\n")
}
func promptContextEvents(events []Event) []Event {
const maxPromptContextEvents = 80
filtered := make([]Event, 0, len(events))
for _, event := range events {
switch event.Type {
case EventMessage, EventCompaction, EventSessionFork, EventSessionChild, EventSpecialistStart, EventSpecialistStop, EventError:
filtered = append(filtered, event)
}
}
if len(filtered) == 0 {
filtered = append(filtered, events...)
}
if len(filtered) > maxPromptContextEvents {
filtered = filtered[len(filtered)-maxPromptContextEvents:]
}
return filtered
}
func forkTitle(title string) string {
if title == "" {
return ""
+1 -1
View File
@@ -71,7 +71,7 @@ type CompactionPayload struct {
Truncated bool `json:"truncated,omitempty"`
}
const defaultCompactionPreserveLast = 8
const defaultCompactionPreserveLast = 6
const defaultCompactionMaxPromptChars = 8000
func (store *Store) PlanRewind(sessionID string, options RewindOptions) (RewindPlan, error) {
+85
View File
@@ -555,6 +555,91 @@ func TestPrepareExecSessionResolvesResumeAndFork(t *testing.T) {
}
}
func TestFormatExecPromptKeepsConversationMessagesWhenNoisyEventsFollow(t *testing.T) {
events := []Event{
{Sequence: 1, Type: EventMessage, Payload: json.RawMessage(`{"role":"user","content":"first user request"}`)},
{Sequence: 2, Type: EventMessage, Payload: json.RawMessage(`{"role":"assistant","content":"first assistant answer"}`)},
}
for sequence := 3; sequence <= 45; sequence++ {
events = append(events, Event{Sequence: sequence, Type: EventToolResult, Payload: json.RawMessage(`{"name":"read_file","output":"noisy tool result"}`)})
}
events = append(events, Event{Sequence: 46, Type: EventMessage, Payload: json.RawMessage(`{"role":"user","content":"latest user request"}`)})
prompt := FormatExecPrompt("continue", PreparedExec{
Mode: ModeResume,
Session: Metadata{SessionID: "session-with-noise"},
ContextEvents: events,
})
for _, want := range []string{"first user request", "first assistant answer", "latest user request", "Current user request:", "continue"} {
if !strings.Contains(prompt, want) {
t.Fatalf("expected prompt to contain %q, got %q", want, prompt)
}
}
if strings.Contains(prompt, "noisy tool result") {
t.Fatalf("expected prompt to omit noisy non-conversation events, got %q", prompt)
}
}
func TestFormatExecPromptTruncatesConversationMessagesAfterFilteringNoise(t *testing.T) {
events := make([]Event, 0, 100)
for sequence := 1; sequence <= 85; sequence++ {
events = append(events, Event{
Sequence: sequence,
Type: EventMessage,
Payload: json.RawMessage(fmt.Sprintf(`{"role":"user","content":"conversation message %03d"}`, sequence)),
})
if sequence%10 == 0 {
events = append(events, Event{
Sequence: 1000 + sequence,
Type: EventToolResult,
Payload: json.RawMessage(fmt.Sprintf(`{"name":"read_file","output":"noisy tool result %03d"}`, sequence)),
})
}
}
events = append(events, Event{
Sequence: 2000,
Type: EventToolResult,
Payload: json.RawMessage(`{"name":"bash","output":"trailing noisy tool result"}`),
})
contextEvents := promptContextEvents(events)
if len(contextEvents) != 80 {
t.Fatalf("expected prompt context to keep 80 events, got %d", len(contextEvents))
}
if contextEvents[0].Sequence != 6 || contextEvents[len(contextEvents)-1].Sequence != 85 {
t.Fatalf("expected prompt context to keep message sequences 6-85, got first=%d last=%d", contextEvents[0].Sequence, contextEvents[len(contextEvents)-1].Sequence)
}
prompt := FormatExecPrompt("continue", PreparedExec{
Mode: ModeResume,
Session: Metadata{SessionID: "long-session-with-noise"},
ContextEvents: events,
})
if got, want := strings.Count(prompt, "- #"), len(contextEvents); got != want {
t.Fatalf("expected prompt to contain %d context event lines, got %d in %q", want, got, prompt)
}
for _, want := range []string{"conversation message 006", "conversation message 085", "Current user request:", "continue"} {
if !strings.Contains(prompt, want) {
t.Fatalf("expected prompt to contain %q, got %q", want, prompt)
}
}
for _, unwanted := range []string{
"conversation message 001",
"conversation message 002",
"conversation message 003",
"conversation message 004",
"conversation message 005",
"noisy tool result",
"trailing noisy tool result",
} {
if strings.Contains(prompt, unwanted) {
t.Fatalf("expected prompt to omit %q, got %q", unwanted, prompt)
}
}
}
func TestPrepareExecPersistsSpecialistMetadataForNewSession(t *testing.T) {
store := NewStore(StoreOptions{RootDir: t.TempDir(), Now: fixedClock("2026-06-04T14:30:00Z")})
+8
View File
@@ -25,6 +25,7 @@ import (
const (
sessionTagSpecialist = "specialist"
promptFileThresholdBytes = 4 * 1024
maxSpecialistDepth = 8 // hard cap to prevent infinite recursion/resource exhaustion via Task
)
const SessionTagSpecialist = sessionTagSpecialist
@@ -218,6 +219,13 @@ func (executor Executor) Run(ctx context.Context, params TaskParameters, options
if options.CurrentDepth < 0 {
return ExecResult{}, fmt.Errorf("current depth cannot be negative")
}
// >= (not >): this Run call spawns a CHILD at options.CurrentDepth+1, so a
// parent already AT the cap must still be rejected here rather than being
// allowed to launch one more level before the child's own next call trips
// the guard.
if options.CurrentDepth >= maxSpecialistDepth {
return ExecResult{}, fmt.Errorf("spawning a specialist at depth %d would exceed maximum nesting depth %d", options.CurrentDepth+1, maxSpecialistDepth)
}
if strings.TrimSpace(params.Prompt) == "" {
return ExecResult{}, fmt.Errorf("specialist prompt is required")
}
+34
View File
@@ -1,6 +1,7 @@
package specialist
import (
"context"
"os"
"path/filepath"
"reflect"
@@ -336,6 +337,39 @@ func TestBuildArgsRejectsInvalidInputs(t *testing.T) {
}
}
func TestRunRejectsDepthExceedingMax(t *testing.T) {
_, err := (Executor{}).Run(context.Background(), TaskParameters{
Prompt: "hi",
}, TaskRunOptions{CurrentDepth: maxSpecialistDepth + 1})
if err == nil || !strings.Contains(err.Error(), "depth") {
t.Fatalf("Run error = %v, want depth error", err)
}
}
// TestRunRejectsDepthAtMax covers the boundary: a parent already AT the cap
// must be rejected too, since this Run call would launch a child one level
// past it (--depth CurrentDepth+1). Only checking ">" here would let that
// child start before the guard ever caught it. The guard sits before the
// fresh/resume branch in Run, so both call shapes must be proven to reject
// here rather than falling through to runFresh/runResume.
func TestRunRejectsDepthAtMax(t *testing.T) {
tests := []struct {
name string
params TaskParameters
}{
{name: "fresh", params: TaskParameters{Prompt: "hi"}},
{name: "resume", params: TaskParameters{Prompt: "hi", Resume: "child_session"}},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
_, err := (Executor{}).Run(context.Background(), tc.params, TaskRunOptions{CurrentDepth: maxSpecialistDepth})
if err == nil || !strings.Contains(err.Error(), "depth") {
t.Fatalf("Run error = %v, want depth error", err)
}
})
}
}
func TestBuildArgsRejectsInvalidSessionIDs(t *testing.T) {
_, err := (Executor{NewSessionID: func() (string, error) { return "../escape", nil }}).BuildArgs(BuildArgsInput{
Prompt: "hi",
+23 -2
View File
@@ -272,15 +272,36 @@ func coerceAskUserOptions(optionsValue, descsValue any) (labels []string, descri
// FormatAskUserAnswers renders question/answer pairs into a clear, model-readable
// block. Missing answers are surfaced explicitly so the model never silently
// treats an unanswered question as answered.
//
// It distinguishes two shapes of "empty" the model would otherwise conflate: a
// wholesale dismissal (the user closed the prompt without answering ANYTHING) is
// flagged up front as a skip, so the model doesn't invent a default; a single
// blank field amid other answers is marked "(left blank)".
func FormatAskUserAnswers(questions []AskUserQuestion, answers []string) string {
lines := make([]string, 0, len(questions)*3)
anyAnswered := false
for index := range questions {
if index < len(answers) && strings.TrimSpace(answers[index]) != "" {
anyAnswered = true
break
}
}
lines := make([]string, 0, len(questions)*3+2)
if len(questions) > 0 && !anyAnswered {
lines = append(lines, "[note] The user dismissed this prompt without answering. Treat this as a skip: do not assume a default — ask again more specifically, or proceed only if the intent is already unambiguous.")
lines = append(lines, "")
}
for index, question := range questions {
answer := ""
if index < len(answers) {
answer = strings.TrimSpace(answers[index])
}
if answer == "" {
answer = "(no answer provided)"
if anyAnswered {
answer = "(left blank)"
} else {
answer = "(skipped)"
}
}
lines = append(lines, fmt.Sprintf("%d. [question] %s", index+1, question.Question))
lines = append(lines, "[answer] "+answer)
+34
View File
@@ -0,0 +1,34 @@
package tools
import (
"strings"
"testing"
)
func TestFormatAskUserAnswersDistinguishesSkipFromBlank(t *testing.T) {
questions := []AskUserQuestion{
{Question: "Which database?"},
{Question: "Migrate existing data?"},
}
// Wholesale dismissal (nothing answered): flagged as a skip up front.
dismissed := FormatAskUserAnswers(questions, []string{"", ""})
if !strings.Contains(dismissed, "dismissed this prompt") || !strings.Contains(dismissed, "Treat this as a skip") {
t.Fatalf("all-empty answers must be flagged as a skip, got:\n%s", dismissed)
}
if strings.Contains(dismissed, "left blank") {
t.Fatalf("a full dismissal should read as skipped, not left-blank:\n%s", dismissed)
}
if !strings.Contains(dismissed, "(skipped)") {
t.Fatalf("dismissed questions should render (skipped):\n%s", dismissed)
}
// Partial answer: the blank one is "left blank", no wholesale-skip note.
partial := FormatAskUserAnswers(questions, []string{"postgres", ""})
if strings.Contains(partial, "dismissed this prompt") {
t.Fatalf("a partial answer is not a dismissal:\n%s", partial)
}
if !strings.Contains(partial, "postgres") || !strings.Contains(partial, "(left blank)") {
t.Fatalf("partial answers should show the answer and mark the blank one left-blank:\n%s", partial)
}
}
+163 -20
View File
@@ -1,7 +1,6 @@
package tools
import (
"bytes"
"context"
"errors"
"fmt"
@@ -120,14 +119,17 @@ func (tool bashTool) run(ctx context.Context, args map[string]any, engine *zeroS
Meta: meta,
}
}
// Release any plan-scoped resources once the command has finished running.
defer plan.Cleanup()
addSandboxMeta(meta, plan)
var stdout bytes.Buffer
var stderr bytes.Buffer
command.Stdout = &stdout
command.Stderr = &stderr
// Bound the capture so a command with runaway output (`cat huge.log`, `yes`)
// can't grow Zero's memory before truncation: only the head+tail each stream
// will ever surface to the model are retained, the middle is discarded as it
// streams, and the true size is counted for the truncation marker.
stdout := newBoundedBuffer(bashOutputBudgetBytes, bashOutputBudgetBytes)
stderr := newBoundedBuffer(bashOutputBudgetBytes, bashOutputBudgetBytes)
command.Stdout = stdout
command.Stderr = stderr
// Kill the shell as a process group on timeout and bound the post-kill I/O
// wait, so a backgrounded child cannot outlive the command or hang Run().
@@ -139,7 +141,12 @@ func (tool bashTool) run(ctx context.Context, args map[string]any, engine *zeroS
err = command.Run()
exitCode := commandExitCode(err)
meta["exit_code"] = strconv.Itoa(exitCode)
stderrText := appendSandboxBlocks(stderr.String(), monitor.Stop())
stdoutText := stdout.retained()
stderrRetained := stderr.retained()
stderrText := appendSandboxBlocks(stderrRetained, monitor.Stop())
// Sandbox blocks are extra model-visible stderr bytes appended after capture;
// count them toward the true total so the budget/marker stay accurate.
stderrTotal := stderr.total + (len(stderrText) - len(stderrRetained))
if errors.Is(commandCtx.Err(), context.DeadlineExceeded) {
return Result{
@@ -156,26 +163,31 @@ func (tool bashTool) run(ctx context.Context, args map[string]any, engine *zeroS
Meta: meta,
}
}
markLikelySandboxDenial(meta, plan, exitCode, stdout.String(), stderrText)
markLikelySandboxDenial(meta, plan, exitCode, stdoutText, stderrText)
outText, errText, truncated := budgetBashCapture(stdoutText, stdout.total, stderrText, stderrTotal, meta)
return Result{
Status: StatusError,
Output: formatBashOutputWithShellHint(commandText, stdout.String(), stderrText, exitCode, meta),
Meta: meta,
Status: StatusError,
Output: formatBashOutputWithShellHint(commandText, outText, errText, exitCode, meta),
Truncated: truncated,
Meta: meta,
}
}
markLikelySandboxDenial(meta, plan, exitCode, stdout.String(), stderrText)
markLikelySandboxDenial(meta, plan, exitCode, stdoutText, stderrText)
outText, errText, truncated := budgetBashCapture(stdoutText, stdout.total, stderrText, stderrTotal, meta)
if meta[SandboxLikelyDeniedMeta] == "true" {
return Result{
Status: StatusError,
Output: formatBashOutputWithShellHint(commandText, stdout.String(), stderrText, exitCode, meta),
Meta: meta,
Status: StatusError,
Output: formatBashOutputWithShellHint(commandText, outText, errText, exitCode, meta),
Truncated: truncated,
Meta: meta,
}
}
return Result{
Status: StatusOK,
Output: formatBashOutput(stdout.String(), stderrText, exitCode),
Meta: meta,
Status: StatusOK,
Output: formatBashOutput(outText, errText, exitCode),
Truncated: truncated,
Meta: meta,
}
}
@@ -221,6 +233,14 @@ func shellIssueBlockResult(issue shellIssue) Result {
}
}
// buildBashCommand returns the exec.Cmd and the sandbox plan for running
// commandText. On Windows, when the command is not wrapped by the sandbox
// engine (plan.Wrapped == false), it also overrides the child's raw command
// line so commandText reaches cmd.exe unescaped; see
// zeroSandbox.WindowsShellCommandLine for why that matters. The wrapped case
// gets the same treatment inside the sandboxed runner process itself
// (internal/sandbox/windows_process_windows.go), since that command line is
// built there, not here.
func buildBashCommand(ctx context.Context, commandText string, absoluteCwd string, engine *zeroSandbox.Engine) (*exec.Cmd, zeroSandbox.CommandPlan, error) {
spec := zeroSandbox.CommandSpec{
Name: shellExecutable(),
@@ -228,7 +248,11 @@ func buildBashCommand(ctx context.Context, commandText string, absoluteCwd strin
Dir: absoluteCwd,
}
if engine != nil {
return engine.CommandContext(ctx, spec)
command, plan, err := engine.CommandContext(ctx, spec)
if err == nil {
applyWindowsShellCommandLine(command, commandText, plan.Wrapped)
}
return command, plan, err
}
plan := zeroSandbox.CommandPlan{
Backend: zeroSandbox.Backend{
@@ -242,6 +266,7 @@ func buildBashCommand(ctx context.Context, commandText string, absoluteCwd strin
}
command := exec.CommandContext(ctx, spec.Name, spec.Args...)
command.Dir = spec.Dir
applyWindowsShellCommandLine(command, commandText, plan.Wrapped)
return command, plan, nil
}
@@ -302,7 +327,7 @@ func shellExecutable() string {
func shellArguments(command string) []string {
if runtime.GOOS == "windows" {
return []string{"/d", "/s", "/c", command}
return zeroSandbox.WindowsShellArgs(command)
}
return []string{"-c", command}
}
@@ -345,6 +370,124 @@ func formatBashOutput(stdout string, stderr string, exitCode int) string {
return strings.Join(parts, "\n")
}
// bashOutputBudgetBytes caps each of stdout/stderr shown to the model. bash is the
// one tool that can emit unbounded output (`cat large.log`, `find /`, verbose test
// runs); every other read/search tool already budgets its output. Head+tail
// truncation keeps both the start and the end of an oversized stream, since
// build/test failures usually surface at the tail.
const bashOutputBudgetBytes = 96 * 1024
// budgetBashOutput truncates stdout and stderr to bashOutputBudgetBytes each,
// keeping the head and tail of anything larger, and records raw/emitted byte
// counts plus a truncated flag in meta (mirroring outputBudgetMeta's shape for
// the read/search tools). Detection that needs the full output (sandbox-denial
// scanning) must run on the raw strings before this is applied.
func budgetBashOutput(stdout string, stderr string, meta map[string]string) (string, string, bool) {
return budgetBashCapture(stdout, len(stdout), stderr, len(stderr), meta)
}
// budgetBashCapture is budgetBashOutput for the streaming-capture path: outTotal
// and errTotal are the true byte counts (from boundedBuffer.total), which may
// exceed the retained strings when the middle was dropped during capture. Meta's
// raw_bytes therefore reflects everything the command produced, not just what was
// kept in memory.
func budgetBashCapture(out string, outTotal int, errStr string, errTotal int, meta map[string]string) (string, string, bool) {
outText, outRaw, outTrunc := truncateHeadTailWithTotal(out, outTotal, bashOutputBudgetBytes)
errText, errRaw, errTrunc := truncateHeadTailWithTotal(errStr, errTotal, bashOutputBudgetBytes)
truncated := outTrunc || errTrunc
if meta != nil {
emitted := len(outText) + len(errText)
meta["raw_bytes"] = strconv.Itoa(outRaw + errRaw)
meta["emitted_bytes"] = strconv.Itoa(emitted)
meta["estimated_tokens"] = strconv.Itoa(estimatedTokensFromBytes(emitted))
if truncated {
meta["truncated"] = "true"
}
}
return outText, errText, truncated
}
// boundedBuffer is an io.Writer that retains at most headCap bytes from the start
// and tailCap bytes from the end of a stream while counting the total written, so
// a command emitting unbounded output (`cat huge.log`, `yes`) cannot grow Zero's
// memory: the middle is discarded as it arrives instead of buffered whole and then
// truncated. total records the full size for the truncation marker even though the
// middle is never held. Not safe for concurrent writes; exec drives Stdout and
// Stderr from separate goroutines, so each stream gets its own buffer.
type boundedBuffer struct {
head []byte
headCap int
tail []byte
tailCap int
total int
}
func newBoundedBuffer(headCap, tailCap int) *boundedBuffer {
return &boundedBuffer{headCap: headCap, tailCap: tailCap}
}
func (b *boundedBuffer) Write(p []byte) (int, error) {
n := len(p)
b.total += n
// Fill the head until it reaches headCap; the head is written once and frozen.
if len(b.head) < b.headCap {
take := b.headCap - len(b.head)
if take > len(p) {
take = len(p)
}
b.head = append(b.head, p[:take]...)
p = p[take:]
}
// Anything past the head feeds a rolling tail that keeps only the last tailCap
// bytes. Compact once it grows past 2×tailCap so the append stays amortized O(1)
// while memory never exceeds ~2×tailCap.
if len(p) > 0 && b.tailCap > 0 {
b.tail = append(b.tail, p...)
if len(b.tail) > 2*b.tailCap {
b.tail = append(b.tail[:0], b.tail[len(b.tail)-b.tailCap:]...)
}
}
return n, nil
}
// retained returns the kept head+tail bytes (marker-less) as a string. The junction
// between head and tail lands in the middle, which the display budget trims away;
// callers that need the true size read total separately.
func (b *boundedBuffer) retained() string {
if len(b.tail) > b.tailCap {
// Not yet compacted since the last overflow; expose only the last tailCap.
return string(b.head) + string(b.tail[len(b.tail)-b.tailCap:])
}
return string(b.head) + string(b.tail)
}
// truncateHeadTail keeps the first and last halves of value when it exceeds
// maxBytes, dropping the middle behind a marker. Returns the possibly-truncated
// text, the raw byte length, and whether truncation happened.
func truncateHeadTail(value string, maxBytes int) (string, int, bool) {
// value holds the whole stream, so its length is the true raw size.
return truncateHeadTailWithTotal(value, len(value), maxBytes)
}
// truncateHeadTailWithTotal head+tail-truncates value to maxBytes, using total —
// the full original byte count — for the "N bytes omitted" marker and the raw
// count. total may exceed len(value) when the middle was already discarded during
// bounded capture (boundedBuffer): value then holds only the retained head+tail,
// and this trims it to the display budget while still reporting the true total.
func truncateHeadTailWithTotal(value string, total, maxBytes int) (string, int, bool) {
if maxBytes <= 0 || total <= maxBytes {
return value, total, false
}
marker := fmt.Sprintf("\n[zero] output truncated: %d bytes omitted from the middle — redirect to a file and read_file a range for the full text\n", total-maxBytes)
budget := maxBytes - len(marker)
if budget < 0 {
budget = 0
}
head := budget / 2
tail := budget - head
return utf8Prefix(value, head) + marker + utf8Suffix(value, tail), total, true
}
func formatBashOutputWithShellHint(command string, stdout string, stderr string, exitCode int, meta map[string]string) string {
output := formatBashOutput(stdout, stderr, exitCode)
if issue := detectShellOutputIssue(command, stdout+"\n"+stderr, runtime.GOOS); issue != nil {
+127
View File
@@ -0,0 +1,127 @@
package tools
import (
"fmt"
"strconv"
"strings"
"testing"
)
// Small output passes through untouched and records raw==emitted, no truncated flag.
func TestBudgetBashOutputSmallPassesThrough(t *testing.T) {
meta := map[string]string{}
out, errStr, truncated := budgetBashOutput("hello\n", "warn\n", meta)
if out != "hello\n" || errStr != "warn\n" {
t.Fatalf("small output altered: out=%q err=%q", out, errStr)
}
if truncated {
t.Fatalf("small output must report truncated=false")
}
if meta["truncated"] == "true" {
t.Fatalf("small output must not be flagged truncated: %v", meta)
}
if meta["raw_bytes"] != strconv.Itoa(len("hello\n")+len("warn\n")) {
t.Fatalf("raw_bytes wrong: %v", meta)
}
}
// Oversized stdout is truncated head+tail: both the first and last lines survive,
// the middle is dropped behind a marker, and meta is flagged.
func TestBudgetBashOutputTruncatesHeadAndTail(t *testing.T) {
head := "FIRST_LINE_MARKER\n"
tail := "\nLAST_LINE_MARKER"
big := head + strings.Repeat("x", bashOutputBudgetBytes) + tail
meta := map[string]string{}
out, _, truncated := budgetBashOutput(big, "", meta)
if !truncated {
t.Fatalf("oversized output must report truncated=true")
}
if !strings.Contains(out, "FIRST_LINE_MARKER") {
t.Fatalf("head lost after truncation")
}
if !strings.Contains(out, "LAST_LINE_MARKER") {
t.Fatalf("tail lost after truncation (failures live at the tail)")
}
if !strings.Contains(out, "output truncated") {
t.Fatalf("expected a truncation marker, got:\n%s", out[:200])
}
if len(out) > bashOutputBudgetBytes {
t.Fatalf("emitted %d bytes exceeds budget %d", len(out), bashOutputBudgetBytes)
}
if meta["truncated"] != "true" {
t.Fatalf("expected truncated=true, got %v", meta)
}
if meta["raw_bytes"] != strconv.Itoa(len(big)) {
t.Fatalf("raw_bytes = %s, want %d", meta["raw_bytes"], len(big))
}
if got, _ := strconv.Atoi(meta["emitted_bytes"]); got != len(out) {
t.Fatalf("emitted_bytes = %s, want %d", meta["emitted_bytes"], len(out))
}
}
// boundedBuffer must keep memory bounded to head+tail while counting every byte,
// so a runaway command can't OOM Zero before truncation runs. It keeps the very
// first bytes (head) and the very last bytes (tail), discarding the middle.
func TestBoundedBufferKeepsHeadAndTailBounded(t *testing.T) {
b := newBoundedBuffer(8, 8)
total := 0
for i := 0; i < 1000; i++ {
chunk := []byte(fmt.Sprintf("%05d.", i)) // 6 bytes each
n, err := b.Write(chunk)
if err != nil || n != len(chunk) {
t.Fatalf("Write = (%d, %v), want (%d, nil)", n, err, len(chunk))
}
total += len(chunk)
}
if b.total != total {
t.Fatalf("total = %d, want %d (every byte must be counted)", b.total, total)
}
// Retained memory is bounded regardless of how much streamed through.
if len(b.head) > 8 {
t.Fatalf("head grew past its cap: %d", len(b.head))
}
if len(b.tail) > 2*8 {
t.Fatalf("tail grew past 2×cap (compaction failed): %d", len(b.tail))
}
r := b.retained()
if len(r) > 8+8 {
t.Fatalf("retained %d bytes, want <= headCap+tailCap", len(r))
}
if !strings.HasPrefix(r, "00000.") {
t.Fatalf("retained head lost, got prefix %q", r[:min(6, len(r))])
}
if !strings.HasSuffix(r, "00999.") {
t.Fatalf("retained tail lost, got %q", r)
}
}
// budgetBashCapture reports the TRUE total (not the retained size) in the marker
// and raw_bytes, even though only a bounded head+tail was ever held in memory.
func TestBudgetBashCaptureReportsTrueTotal(t *testing.T) {
// Retained head+tail as boundedBuffer would hand over; the real command produced
// far more than was kept.
retained := "HEAD_START" + strings.Repeat("y", bashOutputBudgetBytes) + "TAIL_END"
total := 10 * bashOutputBudgetBytes
meta := map[string]string{}
out, _, truncated := budgetBashCapture(retained, total, "", 0, meta)
if !truncated {
t.Fatal("oversized capture must report truncated=true")
}
if !strings.Contains(out, "HEAD_START") || !strings.Contains(out, "TAIL_END") {
t.Fatalf("head/tail lost after budgeting:\n%s", out[:min(120, len(out))])
}
if len(out) > bashOutputBudgetBytes {
t.Fatalf("emitted %d bytes exceeds budget %d", len(out), bashOutputBudgetBytes)
}
if meta["raw_bytes"] != strconv.Itoa(total) {
t.Fatalf("raw_bytes = %s, want the true total %d", meta["raw_bytes"], total)
}
// The marker must cite the true omitted count (total-budget), not retained-budget.
if !strings.Contains(out, strconv.Itoa(total-bashOutputBudgetBytes)) {
t.Fatalf("marker should cite the true omitted byte count:\n%s", out[:min(200, len(out))])
}
}
+4
View File
@@ -42,3 +42,7 @@ func hardenProcessLifetime(command *exec.Cmd) {
return nil
}
}
// applyWindowsShellCommandLine is a no-op outside Windows; there is no
// cmd.exe command-line-parsing quirk to work around here.
func applyWindowsShellCommandLine(command *exec.Cmd, commandText string, wrapped bool) {}
+17
View File
@@ -5,7 +5,10 @@ package tools
import (
"os/exec"
"strconv"
"syscall"
"time"
zeroSandbox "github.com/Gitlawb/zero/internal/sandbox"
)
// bashWaitDelay bounds how long Wait blocks for the I/O pipes to drain after the
@@ -28,3 +31,17 @@ func hardenProcessLifetime(command *exec.Cmd) {
return nil
}
}
// applyWindowsShellCommandLine overrides command's raw child command line so
// commandText reaches cmd.exe unescaped instead of auto-quoted the way
// exec.Cmd would normally encode a single Args element. Skipped when wrapped
// is true: the sandbox engine then routes execution through a separate
// zero-windows-command-runner process, which builds its own child command
// line from scratch (internal/sandbox/windows_process_windows.go) rather than
// inheriting whatever this outer exec.Cmd is configured with.
func applyWindowsShellCommandLine(command *exec.Cmd, commandText string, wrapped bool) {
if wrapped {
return
}
command.SysProcAttr = &syscall.SysProcAttr{CmdLine: zeroSandbox.WindowsShellCommandLine(commandText)}
}
+141
View File
@@ -8,6 +8,7 @@ import (
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"testing"
"time"
@@ -17,6 +18,18 @@ import (
func TestMain(m *testing.M) {
if len(os.Args) >= 3 && os.Args[1] == "--zero-bash-helper" {
if os.Args[2] == "echo-arg" {
// Echoes back exactly the one argument it received, to verify a
// quoted, space-and-slash-containing argument (the same shape as
// `python -c "print(15 / 3)"`) survives the shell invocation
// intact instead of being truncated/corrupted.
if len(os.Args) < 4 {
fmt.Fprintln(os.Stderr, "echo-arg requires exactly one argument")
os.Exit(1)
}
fmt.Println(os.Args[3])
return
}
runBashToolHelper(os.Args[2])
return
}
@@ -161,6 +174,35 @@ func TestDetectShellCommandIssueRequiresActualLSCommand(t *testing.T) {
}
}
func TestDetectShellCommandIssueFlagsPipedPosixUtilities(t *testing.T) {
for _, command := range []string{
`git log --oneline -- pinokio_compat.py | head`,
`git blame -L 40,46 pinokio_compat.py --format=%author | grep summary`,
`cat file.txt | wc -l`,
`some_command | tail -n 20`,
} {
if issue := detectShellCommandIssue(command, "windows"); issue == nil {
t.Fatalf("expected POSIX utility pipeline to be flagged for %q", command)
}
}
}
func TestDetectShellCommandIssueAllowsUnrelatedCommands(t *testing.T) {
for _, command := range []string{
`git log --oneline`,
`dir /b`,
`findstr /r "summary" file.txt`,
`echo header text`,
`tail-log.sh`,
`grep-cli --version`,
`sed.exe --help`,
} {
if issue := detectShellCommandIssue(command, "windows"); issue != nil {
t.Fatalf("expected unrelated command to pass for %q, got %#v", command, issue)
}
}
}
func TestDetectShellOutputIssueAddsWindowsSyntaxHint(t *testing.T) {
issue := detectShellOutputIssue(`cd /d/tmp/zero-pr-158 && ls -la`, "The syntax of the command is incorrect.", "windows")
if issue == nil {
@@ -211,6 +253,43 @@ func TestBashToolRunsCommandInWorkspace(t *testing.T) {
}
}
// A command with runaway output must not be buffered whole in memory: the capture
// is bounded to head+tail, yet raw_bytes still reports the true (much larger) size.
// If capture were unbounded this would balloon Zero's memory before truncation.
func TestBashToolBoundsRunawayOutputCapture(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("uses a POSIX shell pipeline (yes | head)")
}
const produced = 500000 // ~5× the 96 KiB budget
result := NewBashTool(t.TempDir()).Run(context.Background(), map[string]any{
"command": fmt.Sprintf("yes ABCDEFGH | head -c %d", produced),
})
if result.Status != StatusOK {
t.Fatalf("expected ok status, got %s: %s", result.Status, result.Output)
}
if !result.Truncated || result.Meta["truncated"] != "true" {
t.Fatalf("runaway output should be flagged truncated, meta=%v", result.Meta)
}
if !strings.Contains(result.Output, "output truncated") {
t.Fatalf("expected a truncation marker in output")
}
// The emitted (model-visible) text is capped near the budget...
if len(result.Output) > bashOutputBudgetBytes+1024 {
t.Fatalf("emitted %d bytes far exceeds budget %d", len(result.Output), bashOutputBudgetBytes)
}
// ...but raw_bytes reflects the full stream, proving the total was counted while
// only a bounded slice was ever held (raw >> the ~2×budget retention cap).
raw, err := strconv.Atoi(result.Meta["raw_bytes"])
if err != nil {
t.Fatalf("raw_bytes not an int: %q", result.Meta["raw_bytes"])
}
if raw < produced {
t.Fatalf("raw_bytes = %d, want >= %d (full stream counted)", raw, produced)
}
}
func TestBashToolUsesRequestedCwd(t *testing.T) {
root := t.TempDir()
nested := filepath.Join(root, "nested")
@@ -506,6 +585,68 @@ func TestBashToolAllowsNonInteractiveCommand(t *testing.T) {
}
}
// TestBashToolPreservesEmbeddedQuotesOnWindows pins a real, previously-broken
// case: passing commandText as a normal exec.Cmd argument makes Go wrap it in
// an outer pair of quotes (it contains spaces) with its own quotes escaped as
// \", and cmd.exe's /C remainder parsing strips the first and last literal
// quote character in that remainder without undoing the backslash-escaping -
// corrupting a command whose own text contains embedded double quotes,
// exactly the shape of `python -c "print(15 / 3)"`, `git commit -m
// "message"`, `node -e "..."`. Before the fix, the helper below received a
// truncated, mis-quoted argument (starting with a stray literal `"`) instead
// of the text between the quotes.
func TestBashToolPreservesEmbeddedQuotesOnWindows(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("cmd.exe /S/C quote-remainder parsing is Windows-specific")
}
root := t.TempDir()
executable := shellQuote(os.Args[0])
// Space and a slash: the two characters whose position in the original
// bug report's SyntaxError ("print(15, truncated right before the /")
// showed exactly where the corruption happened.
commandText := executable + ` --zero-bash-helper echo-arg "hello / world"`
result := NewBashTool(root).Run(context.Background(), map[string]any{
"command": commandText,
})
if result.Status != StatusOK {
t.Fatalf("expected ok status, got %s: %s", result.Status, result.Output)
}
if !strings.Contains(result.Output, "hello / world") {
t.Fatalf("expected the embedded-quote argument to survive intact, got %q", result.Output)
}
}
// TestBashToolRunsCommandLineForLoopSyntax pins cmd.exe command-line syntax,
// not batch-file syntax: a `for %i in (...) do ...` loop with a single
// percent sign is valid typed directly at a cmd.exe prompt (or via
// `cmd /C "..."`), but requires %%i inside an actual .bat/.cmd FILE, because
// batch files perform an extra pass of percent-substitution before FOR ever
// runs, consuming the single percent so the loop variable never binds. An
// earlier version of this fix ran commandText from a temporary .cmd file
// instead of passing it straight through to cmd.exe's /C remainder, which
// silently broke single-percent syntax like this.
func TestBashToolRunsCommandLineForLoopSyntax(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("cmd.exe command-line vs batch-file parsing is Windows-specific")
}
root := t.TempDir()
result := NewBashTool(root).Run(context.Background(), map[string]any{
"command": "for %i in (1 2 3) do echo %i",
})
if result.Status != StatusOK {
t.Fatalf("expected ok status, got %s: %s", result.Status, result.Output)
}
for _, want := range []string{"1", "2", "3"} {
if !strings.Contains(result.Output, want) {
t.Fatalf("expected the for-loop to expand %%i to %q, got %q", want, result.Output)
}
}
}
func helperCommand(name string) string {
executable := shellQuote(os.Args[0])
return executable + " --zero-bash-helper " + name
+36 -1
View File
@@ -2,6 +2,7 @@ package tools
import (
"context"
"errors"
"fmt"
"os"
"strings"
@@ -44,7 +45,7 @@ func (tool editFileTool) Run(ctx context.Context, args map[string]any) Result {
return tool.RunWithOptions(ctx, args, RunOptions{})
}
func (tool editFileTool) RunWithOptions(_ context.Context, args map[string]any, options RunOptions) Result {
func (tool editFileTool) RunWithOptions(ctx context.Context, args map[string]any, options RunOptions) Result {
requestedPath, err := aliasedStringArg(args, []string{"path", "file", "file_path", "filename"}, "", true, false)
if err != nil {
return errorResult("Error: Invalid arguments for edit_file: " + err.Error())
@@ -98,6 +99,35 @@ func (tool editFileTool) RunWithOptions(_ context.Context, args map[string]any,
}
}
// Fuzzy fallback: when the exact string (and its CRLF translation) is absent,
// run a cascade of tolerant matchers (trimmed lines, block anchors, collapsed
// whitespace, indentation drift, escape normalization) to locate the span the
// model intended. Only a span that occurs literally in the file is accepted,
// so the replacement applied below is still exact.
if occurrences == 0 {
findOld, findNew := oldString, newString
if strings.Contains(content, "\r\n") && !strings.Contains(findOld, "\r\n") {
findOld = strings.ReplaceAll(findOld, "\n", "\r\n")
findNew = strings.ReplaceAll(findNew, "\n", "\r\n")
}
search, ferr := fuzzyEditMatch(content, findOld, replaceAll)
switch {
case ferr == nil:
oldString = search
// The model's new_string was written at old_string's (mismatched)
// indentation; re-shape it to the span actually being replaced so a
// tolerant match never strips indentation or a trailing CR.
newString = adaptReplacementToSpan(search, findOld, findNew)
occurrences = strings.Count(content, search)
case errors.Is(ferr, errEditFuzzyAmbiguous):
return errorResult("Error: old_string matches multiple locations in " + relativePath + " even after fuzzy matching. Provide more surrounding context to make the match unique, or pass replace_all: true.")
case errors.Is(ferr, errEditFuzzyNotFound):
// Fall through to the exact-match error below.
default:
return errorResult("Error editing " + relativePath + ": " + ferr.Error())
}
}
if occurrences == 0 {
return errorResult("Error: Could not find the exact string to replace in " + relativePath + ". The old_string must match the file byte-for-byte.")
}
@@ -120,6 +150,10 @@ func (tool editFileTool) RunWithOptions(_ context.Context, args map[string]any,
if err := os.WriteFile(absolutePath, []byte(updated), 0o644); err != nil {
return errorResult("Error writing " + relativePath + ": " + err.Error())
}
// Optional format-on-write (ZERO_FORMAT_ON_WRITE). Must run BEFORE the
// FileTracker re-baseline: recording pre-format content would make the very
// next edit look like an external modification and trip the conflict guard.
updated = maybeFormatWrittenFile(ctx, absolutePath, updated)
// Re-baseline to the content we just wrote so subsequent edits in this session
// compare against the current on-disk state, not the pre-edit version.
newInfo, _ := os.Stat(absolutePath)
@@ -130,6 +164,7 @@ func (tool editFileTool) RunWithOptions(_ context.Context, args map[string]any,
suffix = "s"
}
summary := fmt.Sprintf("Successfully edited %s (replaced %d occurrence%s).", relativePath, replacedCount, suffix)
summary += inlineDiagnostics(ctx, options, absolutePath, relativePath)
result := okResult(summary)
result.ChangedFiles = []string{relativePath}
// Card-only preview (Display.Preview): the model's Output stays the one-line
+554
View File
@@ -0,0 +1,554 @@
package tools
// Fuzzy fallback matching for edit_file. When the model's old_string fails to
// match byte-for-byte (drifted indentation, collapsed whitespace, escaped
// characters, a slightly-misremembered middle line), these replacers propose
// candidate spans that plausibly correspond to what the model intended. Only a
// candidate that occurs literally in the file is accepted, so the replacement
// itself is always exact even when the match was tolerant.
//
// Strategy cascade ported from opencode's edit tool, whose replacers were in
// turn distilled from Cline's diff-apply evals and gemini-cli's edit corrector.
import (
"errors"
"regexp"
"sort"
"strings"
)
// editReplacer proposes candidate spans for find inside content. Candidates
// are re-validated by fuzzyEditMatch before use.
type editReplacer func(content, find string) []string
var (
errEditFuzzyNotFound = errors.New("no fuzzy match for old_string")
errEditFuzzyAmbiguous = errors.New("fuzzy match for old_string is ambiguous")
)
// Minimum average middle-line similarity for block-anchor matches.
const editAnchorSimilarityThreshold = 0.65
// fuzzyEditMatch runs the replacer cascade and returns the exact span of
// content to replace. When replaceAll is false the span must be unique in
// content; an ambiguous candidate is skipped in favor of later candidates and
// only reported if nothing unique is found. A span wildly larger than
// old_string is refused outright rather than risking a destructive edit.
func fuzzyEditMatch(content, find string, replaceAll bool) (string, error) {
replacers := []editReplacer{
lineTrimmedReplacer,
blockAnchorReplacer,
whitespaceNormalizedReplacer,
indentationFlexibleReplacer,
escapeNormalizedReplacer,
trimmedBoundaryReplacer,
contextAwareReplacer,
}
found := false
for _, replacer := range replacers {
// Collect the replacer's DISTINCT candidate spans that literally occur in
// content. Two or more distinct spans from one strategy (e.g. duplicate
// blocks at different indentation, each occurring once) mean the model's
// intent is genuinely ambiguous — silently editing the first would be a
// wrong-span write, so it is rejected instead.
var candidates []string
seen := map[string]bool{}
for _, search := range replacer(content, find) {
if search == "" || seen[search] {
continue
}
seen[search] = true
if strings.Index(content, search) < 0 {
continue
}
candidates = append(candidates, search)
}
if len(candidates) == 0 {
continue
}
found = true
if !replaceAll && len(candidates) > 1 {
return "", errEditFuzzyAmbiguous
}
search := candidates[0]
if isDisproportionateEditMatch(search, find) {
return "", errors.New("refusing replacement because the matched span is much larger than old_string; re-read the file and provide the full exact old_string for the intended replacement")
}
if replaceAll {
return search, nil
}
if strings.Index(content, search) == strings.LastIndex(content, search) {
return search, nil
}
// The single candidate occurs at multiple positions; a later, stricter
// strategy may still resolve a unique span, so keep cascading.
}
if !found {
return "", errEditFuzzyNotFound
}
return "", errEditFuzzyAmbiguous
}
// isDisproportionateEditMatch guards against anchor-style replacers matching a
// span far larger than the text the model asked to replace (e.g. first/last
// line anchors bridging hundreds of unrelated lines).
func isDisproportionateEditMatch(search, find string) bool {
findLines := strings.Count(find, "\n") + 1
searchLines := strings.Count(search, "\n") + 1
limit := findLines + 3
if findLines*2 > limit {
limit = findLines * 2
}
if searchLines >= limit {
return true
}
if findLines == 1 {
return false
}
searchTrimmed := len(strings.TrimSpace(search))
findTrimmed := len(strings.TrimSpace(find))
byteLimit := findTrimmed + 500
if findTrimmed*4 > byteLimit {
byteLimit = findTrimmed * 4
}
return searchTrimmed > byteLimit
}
// splitFindLines splits find into lines, dropping the trailing empty line a
// trailing newline produces so windows align with real content lines.
func splitFindLines(find string) []string {
lines := strings.Split(find, "\n")
if len(lines) > 0 && lines[len(lines)-1] == "" {
lines = lines[:len(lines)-1]
}
return lines
}
// lineTrimmedReplacer matches when every line equals the corresponding
// old_string line after trimming surrounding whitespace.
func lineTrimmedReplacer(content, find string) []string {
contentLines := strings.Split(content, "\n")
findLines := splitFindLines(find)
if len(findLines) == 0 {
return nil
}
var candidates []string
for i := 0; i+len(findLines) <= len(contentLines); i++ {
matches := true
for j := range findLines {
if strings.TrimSpace(contentLines[i+j]) != strings.TrimSpace(findLines[j]) {
matches = false
break
}
}
if matches {
candidates = append(candidates, strings.Join(contentLines[i:i+len(findLines)], "\n"))
}
}
return candidates
}
// blockAnchorReplacer anchors on the first and last lines (trimmed) and
// accepts the block when the middle lines average >= the similarity threshold
// (Levenshtein-based), tolerating a slightly misremembered interior.
func blockAnchorReplacer(content, find string) []string {
findLines := splitFindLines(find)
if len(findLines) < 3 {
return nil
}
contentLines := strings.Split(content, "\n")
firstAnchor := strings.TrimSpace(findLines[0])
lastAnchor := strings.TrimSpace(findLines[len(findLines)-1])
searchBlockSize := len(findLines)
maxLineDelta := searchBlockSize / 4
if maxLineDelta < 1 {
maxLineDelta = 1
}
type span struct{ start, end int }
var candidates []span
for i := 0; i < len(contentLines); i++ {
if strings.TrimSpace(contentLines[i]) != firstAnchor {
continue
}
// Only the first occurrence of the last anchor after this start counts,
// mirroring the reference implementation: a farther-away closing line is
// assumed to close a different block.
for j := i + 2; j < len(contentLines); j++ {
if strings.TrimSpace(contentLines[j]) != lastAnchor {
continue
}
actualBlockSize := j - i + 1
delta := actualBlockSize - searchBlockSize
if delta < 0 {
delta = -delta
}
if delta <= maxLineDelta {
candidates = append(candidates, span{start: i, end: j})
}
break
}
}
if len(candidates) == 0 {
return nil
}
middleSimilarity := func(candidate span) float64 {
actualBlockSize := candidate.end - candidate.start + 1
linesToCheck := searchBlockSize - 2
if actualBlockSize-2 < linesToCheck {
linesToCheck = actualBlockSize - 2
}
if linesToCheck <= 0 {
return 1.0
}
similarity := 0.0
for j := 1; j < searchBlockSize-1 && j < actualBlockSize-1; j++ {
contentLine := strings.TrimSpace(contentLines[candidate.start+j])
findLine := strings.TrimSpace(findLines[j])
maxLen := len(contentLine)
if len(findLine) > maxLen {
maxLen = len(findLine)
}
if maxLen == 0 {
continue
}
distance := levenshtein(contentLine, findLine)
similarity += 1 - float64(distance)/float64(maxLen)
}
return similarity / float64(linesToCheck)
}
// Return EVERY candidate that clears the similarity threshold, best first.
// Picking only the best would hide competing blocks from fuzzyEditMatch's
// distinct-candidate ambiguity check and could silently edit the wrong
// block; with all qualifying spans surfaced, one clear winner still
// resolves (single candidate) while two plausible blocks are rejected as
// ambiguous. replaceAll consumers take the first (most similar) span.
type scoredSpan struct {
span span
similarity float64
}
var qualifying []scoredSpan
for _, candidate := range candidates {
if s := middleSimilarity(candidate); s >= editAnchorSimilarityThreshold {
qualifying = append(qualifying, scoredSpan{span: candidate, similarity: s})
}
}
if len(qualifying) == 0 {
return nil
}
sort.SliceStable(qualifying, func(i, j int) bool {
return qualifying[i].similarity > qualifying[j].similarity
})
spans := make([]string, 0, len(qualifying))
for _, scored := range qualifying {
spans = append(spans, strings.Join(contentLines[scored.span.start:scored.span.end+1], "\n"))
}
return spans
}
var editWhitespaceRun = regexp.MustCompile(`\s+`)
func normalizeEditWhitespace(text string) string {
return strings.TrimSpace(editWhitespaceRun.ReplaceAllString(text, " "))
}
// whitespaceNormalizedReplacer matches after collapsing all whitespace runs to
// a single space: full lines, sub-line spans (via a word-boundary regex), and
// multi-line windows.
func whitespaceNormalizedReplacer(content, find string) []string {
normalizedFind := normalizeEditWhitespace(find)
if normalizedFind == "" {
return nil
}
contentLines := strings.Split(content, "\n")
var candidates []string
var subLinePattern *regexp.Regexp
for _, line := range contentLines {
normalizedLine := normalizeEditWhitespace(line)
if normalizedLine == normalizedFind {
candidates = append(candidates, line)
continue
}
if !strings.Contains(normalizedLine, normalizedFind) {
continue
}
if subLinePattern == nil {
words := strings.Fields(find)
quoted := make([]string, len(words))
for i, word := range words {
quoted[i] = regexp.QuoteMeta(word)
}
pattern, err := regexp.Compile(strings.Join(quoted, `\s+`))
if err != nil {
continue
}
subLinePattern = pattern
}
if match := subLinePattern.FindString(line); match != "" {
candidates = append(candidates, match)
}
}
findLines := strings.Split(find, "\n")
if len(findLines) > 1 {
for i := 0; i+len(findLines) <= len(contentLines); i++ {
block := strings.Join(contentLines[i:i+len(findLines)], "\n")
if normalizeEditWhitespace(block) == normalizedFind {
candidates = append(candidates, block)
}
}
}
return candidates
}
// stripCommonIndentation removes the minimum leading-whitespace width shared
// by all non-empty lines, so blocks match regardless of their nesting depth.
func stripCommonIndentation(text string) string {
lines := strings.Split(text, "\n")
minIndent := -1
for _, line := range lines {
if strings.TrimSpace(line) == "" {
continue
}
indent := len(line) - len(strings.TrimLeft(line, " \t"))
if minIndent < 0 || indent < minIndent {
minIndent = indent
}
}
if minIndent <= 0 {
return text
}
stripped := make([]string, len(lines))
for i, line := range lines {
if strings.TrimSpace(line) == "" {
stripped[i] = line
continue
}
stripped[i] = line[minIndent:]
}
return strings.Join(stripped, "\n")
}
func indentationFlexibleReplacer(content, find string) []string {
normalizedFind := stripCommonIndentation(find)
contentLines := strings.Split(content, "\n")
findLines := strings.Split(find, "\n")
var candidates []string
for i := 0; i+len(findLines) <= len(contentLines); i++ {
block := strings.Join(contentLines[i:i+len(findLines)], "\n")
if stripCommonIndentation(block) == normalizedFind {
candidates = append(candidates, block)
}
}
return candidates
}
var editEscapeSequence = regexp.MustCompile("\\\\(n|t|r|'|\"|`|\\\\|\\n|\\$)")
// unescapeEditString undoes one level of string escaping (\n, \t, \", \\, a
// backslash-newline continuation, \$) — the model sometimes reproduces file
// content as it appeared inside a quoted string literal.
func unescapeEditString(text string) string {
return editEscapeSequence.ReplaceAllStringFunc(text, func(match string) string {
switch match[1:] {
case "n":
return "\n"
case "t":
return "\t"
case "r":
return "\r"
case "\n":
return "\n"
default:
// ', ", `, \, $ all unescape to themselves.
return match[1:]
}
})
}
func escapeNormalizedReplacer(content, find string) []string {
unescapedFind := unescapeEditString(find)
var candidates []string
if strings.Contains(content, unescapedFind) {
candidates = append(candidates, unescapedFind)
}
contentLines := strings.Split(content, "\n")
findLines := strings.Split(unescapedFind, "\n")
for i := 0; i+len(findLines) <= len(contentLines); i++ {
block := strings.Join(contentLines[i:i+len(findLines)], "\n")
if unescapeEditString(block) == unescapedFind {
candidates = append(candidates, block)
}
}
return candidates
}
// trimmedBoundaryReplacer tolerates stray leading/trailing whitespace (often
// blank lines) around an otherwise-exact old_string.
func trimmedBoundaryReplacer(content, find string) []string {
trimmedFind := strings.TrimSpace(find)
if trimmedFind == find || trimmedFind == "" {
return nil
}
var candidates []string
if strings.Contains(content, trimmedFind) {
candidates = append(candidates, trimmedFind)
}
contentLines := strings.Split(content, "\n")
findLines := strings.Split(find, "\n")
for i := 0; i+len(findLines) <= len(contentLines); i++ {
block := strings.Join(contentLines[i:i+len(findLines)], "\n")
if strings.TrimSpace(block) == trimmedFind {
candidates = append(candidates, block)
}
}
return candidates
}
// contextAwareReplacer anchors on the first and last lines and accepts an
// equal-length block when at least half of its non-empty middle lines match
// after trimming — a cheaper, stricter cousin of blockAnchorReplacer.
func contextAwareReplacer(content, find string) []string {
findLines := splitFindLines(find)
if len(findLines) < 3 {
return nil
}
contentLines := strings.Split(content, "\n")
firstAnchor := strings.TrimSpace(findLines[0])
lastAnchor := strings.TrimSpace(findLines[len(findLines)-1])
var candidates []string
for i := 0; i < len(contentLines); i++ {
if strings.TrimSpace(contentLines[i]) != firstAnchor {
continue
}
for j := i + 2; j < len(contentLines); j++ {
if strings.TrimSpace(contentLines[j]) != lastAnchor {
continue
}
if j-i+1 == len(findLines) {
matching, nonEmpty := 0, 0
for k := 1; k < len(findLines)-1; k++ {
blockLine := strings.TrimSpace(contentLines[i+k])
findLine := strings.TrimSpace(findLines[k])
if blockLine == "" && findLine == "" {
continue
}
nonEmpty++
if blockLine == findLine {
matching++
}
}
if nonEmpty == 0 || float64(matching)/float64(nonEmpty) >= 0.5 {
candidates = append(candidates, strings.Join(contentLines[i:j+1], "\n"))
}
}
break
}
}
return candidates
}
// adaptReplacementToSpan re-shapes the model's replacement to the span a
// tolerant matcher resolved. When old_string only matched after normalization,
// new_string was written at old_string's (wrong) shape, so applying it raw
// would strip the file's indentation or drop a trailing CR:
//
// 1. Uniform re-indent: when every span line equals delta + the corresponding
// find line's indentation (the line-trimmed / indentation-flexible shapes),
// the same delta is prepended to every non-blank replacement line. Any
// line that breaks the uniform-delta relationship disables the shift —
// block-anchor matches with a drifted interior are left untouched.
// 2. Trailing CR: a span from a CRLF file ends mid-line at "\r" (candidates
// are built by joining lines split on "\n"); the replacement gets the same
// trailing "\r" so the file's CRLF pairs stay intact.
func adaptReplacementToSpan(span, find, replacement string) string {
if delta, ok := uniformIndentDelta(span, find); ok && delta != "" {
lines := strings.Split(replacement, "\n")
for i, line := range lines {
if strings.TrimSpace(line) == "" {
continue
}
lines[i] = delta + line
}
replacement = strings.Join(lines, "\n")
}
if strings.HasSuffix(span, "\r") && !strings.HasSuffix(replacement, "\r") {
replacement += "\r"
}
return replacement
}
// uniformIndentDelta returns the indentation prefix that, prepended to every
// non-blank find line, yields the corresponding span line's indentation. ok is
// false when line counts differ, any line pair disagrees on the delta, or the
// span is not simply a uniformly deeper-indented copy of find.
func uniformIndentDelta(span, find string) (string, bool) {
spanLines := strings.Split(span, "\n")
findLines := splitFindLines(find)
if len(spanLines) != len(findLines) {
return "", false
}
leadingWhitespace := func(line string) string {
return line[:len(line)-len(strings.TrimLeft(line, " \t"))]
}
delta := ""
haveDelta := false
for i := range findLines {
spanLine := strings.TrimSuffix(spanLines[i], "\r")
findLine := strings.TrimSuffix(findLines[i], "\r")
if strings.TrimSpace(spanLine) == "" && strings.TrimSpace(findLine) == "" {
continue
}
spanIndent := leadingWhitespace(spanLine)
findIndent := leadingWhitespace(findLine)
if !strings.HasSuffix(spanIndent, findIndent) {
return "", false
}
lineDelta := spanIndent[:len(spanIndent)-len(findIndent)]
if !haveDelta {
delta = lineDelta
haveDelta = true
continue
}
if lineDelta != delta {
return "", false
}
}
return delta, haveDelta
}
// levenshtein computes edit distance with a two-row rolling matrix.
func levenshtein(a, b string) int {
if a == "" {
return len(b)
}
if b == "" {
return len(a)
}
previous := make([]int, len(b)+1)
current := make([]int, len(b)+1)
for j := range previous {
previous[j] = j
}
for i := 1; i <= len(a); i++ {
current[0] = i
for j := 1; j <= len(b); j++ {
cost := 1
if a[i-1] == b[j-1] {
cost = 0
}
minimum := previous[j] + 1
if current[j-1]+1 < minimum {
minimum = current[j-1] + 1
}
if previous[j-1]+cost < minimum {
minimum = previous[j-1] + cost
}
current[j] = minimum
}
previous, current = current, previous
}
return previous[len(b)]
}
+369
View File
@@ -0,0 +1,369 @@
package tools
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
)
func runEdit(t *testing.T, dir, initial string, args map[string]any) (Result, string) {
t.Helper()
path := filepath.Join(dir, "target.txt")
if err := os.WriteFile(path, []byte(initial), 0o644); err != nil {
t.Fatal(err)
}
if args["path"] == nil {
args["path"] = "target.txt"
}
result := NewEditFileTool(dir).Run(context.Background(), args)
after, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
return result, string(after)
}
func TestEditExactMatchStillPreferred(t *testing.T) {
// The fast path must keep byte-exact semantics: an exact match wins even
// when fuzzy strategies would also find candidates elsewhere.
result, after := runEdit(t, t.TempDir(), "alpha\nbeta\ngamma\n", map[string]any{
"old_string": "beta",
"new_string": "delta",
})
if result.Status != StatusOK {
t.Fatalf("expected ok, got %q", result.Output)
}
if after != "alpha\ndelta\ngamma\n" {
t.Fatalf("unexpected content: %q", after)
}
}
func TestEditFuzzyLineTrimmed(t *testing.T) {
// Model reproduced the lines without the file's leading indentation; the
// line-trimmed strategy must find the real indented span and preserve the
// file's own indentation on the replaced region boundary.
initial := "func a() {\n\tx := 1\n\treturn x\n}\n"
result, after := runEdit(t, t.TempDir(), initial, map[string]any{
"old_string": "x := 1\nreturn x",
"new_string": "y := 2\nreturn y",
})
if result.Status != StatusOK {
t.Fatalf("expected ok, got %q", result.Output)
}
// new_string was written at old_string's outdented shape; the span's
// indentation must be re-applied so the file keeps its tabs.
if !strings.Contains(after, "\ty := 2\n\treturn y\n}") {
t.Fatalf("unexpected content: %q", after)
}
if strings.Contains(after, "x := 1") {
t.Fatalf("old span survived: %q", after)
}
}
func TestEditFuzzyWhitespaceNormalizedSingleLine(t *testing.T) {
// Whitespace runs collapsed: "x := 1" in the file, single spaces from
// the model.
initial := "start\nx := 1\nend\n"
result, after := runEdit(t, t.TempDir(), initial, map[string]any{
"old_string": "x := 1",
"new_string": "x := 2",
})
if result.Status != StatusOK {
t.Fatalf("expected ok, got %q", result.Output)
}
if !strings.Contains(after, "x := 2") || strings.Contains(after, ":= 1") {
t.Fatalf("unexpected content: %q", after)
}
}
func TestEditFuzzyBlockAnchorToleratesMiddleDrift(t *testing.T) {
// First and last lines anchor the block; one interior line differs slightly
// (comment text drifted). Levenshtein similarity keeps it above 0.65.
initial := strings.Join([]string{
"func handler(w http.ResponseWriter, r *http.Request) {",
"\t// write the response body to the client",
"\tw.WriteHeader(http.StatusOK)",
"\tfmt.Fprint(w, \"done\")",
"}",
"",
}, "\n")
find := strings.Join([]string{
"func handler(w http.ResponseWriter, r *http.Request) {",
"\t// write the response body to client",
"\tw.WriteHeader(http.StatusOK)",
"\tfmt.Fprint(w, \"done\")",
"}",
}, "\n")
result, after := runEdit(t, t.TempDir(), initial, map[string]any{
"old_string": find,
"new_string": "func handler(w http.ResponseWriter, r *http.Request) {\n\tw.WriteHeader(http.StatusNoContent)\n}",
})
if result.Status != StatusOK {
t.Fatalf("expected ok, got %q", result.Output)
}
if !strings.Contains(after, "StatusNoContent") || strings.Contains(after, "StatusOK") {
t.Fatalf("unexpected content: %q", after)
}
}
func TestEditFuzzyIndentationFlexible(t *testing.T) {
// The whole block sits one nesting level deeper in the file than in the
// model's old_string, with interior relative indentation preserved.
initial := "if ok {\n\t\tfor i := range xs {\n\t\t\tsum += xs[i]\n\t\t}\n}\n"
result, after := runEdit(t, t.TempDir(), initial, map[string]any{
"old_string": "for i := range xs {\n\tsum += xs[i]\n}",
"new_string": "sum += total(xs)",
})
if result.Status != StatusOK {
t.Fatalf("expected ok, got %q", result.Output)
}
// The replacement lands at the span's real depth (two tabs), not at
// old_string's outdented depth.
if !strings.Contains(after, "\t\tsum += total(xs)") || strings.Contains(after, "range xs") {
t.Fatalf("unexpected content: %q", after)
}
}
func TestEditFuzzyCRLFLineTrimmedPreservesCarriageReturns(t *testing.T) {
// CRLF file + a multi-line outdented old_string: the resolved span carries
// tabs and trailing CRs; the replacement must inherit both so the file's
// CRLF pairs and indentation survive the edit.
initial := "func a() {\r\n\tx := 1\r\n\treturn x\r\n}\r\n"
result, after := runEdit(t, t.TempDir(), initial, map[string]any{
"old_string": "x := 1\nreturn x",
"new_string": "y := 2\nreturn y",
})
if result.Status != StatusOK {
t.Fatalf("expected ok, got %q", result.Output)
}
if !strings.Contains(after, "\ty := 2\r\n\treturn y\r\n}") {
t.Fatalf("unexpected content: %q", after)
}
if strings.Contains(after, "\n\treturn y\n") {
t.Fatalf("bare LF introduced into CRLF file: %q", after)
}
}
func TestUniformIndentDelta(t *testing.T) {
cases := []struct {
span, find string
want string
ok bool
}{
{"\tx := 1\n\treturn x", "x := 1\nreturn x", "\t", true},
{"\t\tfor {\n\t\t\tgo()\n\t\t}", "for {\n\tgo()\n}", "\t\t", true},
{"x := 1", "x := 1", "", true}, // no shift needed
{"\tx := 1\n return x", "x := 1\nreturn x", "", false}, // mixed delta
{"\ta\n\tb\n\tc", "a\nb", "", false}, // line-count mismatch
{" a", "\ta", "", false}, // find indent not a suffix
}
for _, c := range cases {
got, ok := uniformIndentDelta(c.span, c.find)
if ok != c.ok || (ok && got != c.want) {
t.Errorf("uniformIndentDelta(%q, %q) = %q,%v want %q,%v", c.span, c.find, got, ok, c.want, c.ok)
}
}
}
func TestAdaptReplacementLeavesNonUniformSpansAlone(t *testing.T) {
// Block-anchor style match with a drifted interior: no uniform delta, so
// the replacement must pass through untouched.
span := "\tfunc h() {\n\t\t// drifted comment\n\t}"
find := "func h() {\n// different comment\n}"
if got := adaptReplacementToSpan(span, find, "replacement()"); got != "replacement()" {
t.Fatalf("non-uniform span must not re-indent: %q", got)
}
}
func TestEditFuzzyEscapeNormalized(t *testing.T) {
// Model reproduced the line as it would appear inside a quoted string
// literal, with escaped quotes.
initial := "console.log(\"hello world\")\n"
result, after := runEdit(t, t.TempDir(), initial, map[string]any{
"old_string": `console.log(\"hello world\")`,
"new_string": `console.log("goodbye")`,
})
if result.Status != StatusOK {
t.Fatalf("expected ok, got %q", result.Output)
}
if !strings.Contains(after, `console.log("goodbye")`) {
t.Fatalf("unexpected content: %q", after)
}
}
func TestEditFuzzyTrimmedBoundary(t *testing.T) {
// Stray whitespace around an otherwise-exact old_string; " two " is not a
// substring of the file, so only the fuzzy cascade can resolve it.
initial := "one\ntwo\nthree\n"
result, after := runEdit(t, t.TempDir(), initial, map[string]any{
"old_string": " two ",
"new_string": "2",
})
if result.Status != StatusOK {
t.Fatalf("expected ok, got %q", result.Output)
}
if after != "one\n2\nthree\n" {
t.Fatalf("unexpected content: %q", after)
}
}
func TestTrimmedBoundaryReplacerYieldsTrimmedSpan(t *testing.T) {
candidates := trimmedBoundaryReplacer("one\ntwo\nthree\n", "\ntwo\n")
if len(candidates) == 0 || candidates[0] != "two" {
t.Fatalf("expected trimmed candidate \"two\", got %v", candidates)
}
if trimmedBoundaryReplacer("anything", "already-trimmed") != nil {
t.Fatal("already-trimmed find must yield no candidates")
}
}
// Two identical blocks that only match old_string after indentation-tolerant
// matching: exact search finds nothing, fuzzy finds both.
const ambiguousBlocks = "\tif x {\n\t\tgo()\n\t}\nmid\n\tif x {\n\t\tgo()\n\t}\n"
func TestEditFuzzyAmbiguousReportsError(t *testing.T) {
// No strategy can disambiguate identical blocks, so the tool must refuse
// rather than guess, and the file must be untouched.
result, after := runEdit(t, t.TempDir(), ambiguousBlocks, map[string]any{
"old_string": "if x {\n\tgo()\n}",
"new_string": "stop()",
})
if result.Status != StatusError || !strings.Contains(result.Output, "multiple locations") {
t.Fatalf("expected ambiguity error, got %q", result.Output)
}
if after != ambiguousBlocks {
t.Fatalf("file must be unchanged, got %q", after)
}
}
func TestEditFuzzyReplaceAll(t *testing.T) {
// replace_all applies the fuzzy-resolved span at every occurrence.
result, after := runEdit(t, t.TempDir(), ambiguousBlocks, map[string]any{
"old_string": "if x {\n\tgo()\n}",
"new_string": "stop()",
"replace_all": true,
})
if result.Status != StatusOK {
t.Fatalf("expected ok, got %q", result.Output)
}
if strings.Count(after, "stop()") != 2 || strings.Contains(after, "go()") {
t.Fatalf("unexpected content: %q", after)
}
}
func TestEditFuzzyNotFoundKeepsExactError(t *testing.T) {
result, after := runEdit(t, t.TempDir(), "alpha\n", map[string]any{
"old_string": "omega",
"new_string": "x",
})
if result.Status != StatusError || !strings.Contains(result.Output, "Could not find the exact string") {
t.Fatalf("expected not-found error, got %q", result.Output)
}
if after != "alpha\n" {
t.Fatalf("file must be unchanged, got %q", after)
}
}
func TestEditFuzzyCRLFFile(t *testing.T) {
// CRLF file + LF old_string with indentation drift: the CRLF translation
// feeds the cascade and the replacement preserves CRLF endings.
initial := "func a() {\r\n\tx := 1\r\n}\r\n"
result, after := runEdit(t, t.TempDir(), initial, map[string]any{
"old_string": "x := 1",
"new_string": "x := 2",
})
if result.Status != StatusOK {
t.Fatalf("expected ok, got %q", result.Output)
}
if !strings.Contains(after, "\tx := 2\r\n") {
t.Fatalf("unexpected content: %q", after)
}
}
func TestIsDisproportionateEditMatch(t *testing.T) {
// A candidate span that dwarfs old_string must be refused: anchors bridging
// unrelated code would otherwise delete it all.
big := strings.Repeat("line\n", 10)
if !isDisproportionateEditMatch(big, "a\nb\nc") {
t.Fatal("10-line span for 3-line find must be disproportionate")
}
if isDisproportionateEditMatch("a\nb\nc\nd", "a\nb\nc") {
t.Fatal("one extra line within tolerance must be allowed")
}
if isDisproportionateEditMatch("single line", "single") {
t.Fatal("single-line finds are exempt from the byte-length guard")
}
if !isDisproportionateEditMatch("xx\n"+strings.Repeat("y", 900)+"\nzz", "xx\nab\nzz") {
t.Fatal("byte-length blowup must be disproportionate")
}
}
func TestLevenshtein(t *testing.T) {
cases := []struct {
a, b string
want int
}{
{"", "", 0},
{"abc", "", 3},
{"", "abc", 3},
{"kitten", "sitting", 3},
{"same", "same", 0},
}
for _, c := range cases {
if got := levenshtein(c.a, c.b); got != c.want {
t.Fatalf("levenshtein(%q,%q) = %d, want %d", c.a, c.b, got, c.want)
}
}
}
func TestEditFuzzyDistinctCandidatesAreAmbiguous(t *testing.T) {
// Two same-content blocks at DIFFERENT indentation: each resolved span is
// distinct and occurs exactly once, so the old literal-uniqueness check
// passed and silently edited the first block. Distinct-shaped candidates
// from one strategy must instead report ambiguity and leave the file alone.
initial := "\tif x {\n\t\tgo()\n\t}\nmid\n\t\tif x {\n\t\t\tgo()\n\t\t}\n"
result, after := runEdit(t, t.TempDir(), initial, map[string]any{
"old_string": "if x {\n\tgo()\n}",
"new_string": "stop()",
})
if result.Status != StatusError || !strings.Contains(result.Output, "multiple locations") {
t.Fatalf("distinct fuzzy candidates must be ambiguous, got %q", result.Output)
}
if after != initial {
t.Fatalf("file must be unchanged, got %q", after)
}
}
func TestEditFuzzyBlockAnchorTwoPlausibleBlocksAmbiguous(t *testing.T) {
// Two blocks share the same first/last anchor lines and BOTH interiors sit
// above the similarity threshold. Picking the "best" one would silently
// edit a block the model may not have meant; both must surface so the
// cascade reports ambiguity and the file stays untouched.
initial := strings.Join([]string{
"func setup(cfg Config) {",
"\tvalue := compute(alpha, beta, gamma)",
"}",
"",
"func setup(cfg Config) {",
"\tvalue := compute(alpha, delta, gamma)",
"}",
"",
}, "\n")
find := strings.Join([]string{
"func setup(cfg Config) {",
"\tvalue := compute(alpha, omega, gamma)",
"}",
}, "\n")
result, after := runEdit(t, t.TempDir(), initial, map[string]any{
"old_string": find,
"new_string": "func setup(cfg Config) {}",
})
if result.Status != StatusError || !strings.Contains(result.Output, "multiple locations") {
t.Fatalf("two plausible anchor blocks must be ambiguous, got %q", result.Output)
}
if after != initial {
t.Fatalf("file must be unchanged, got %q", after)
}
}
+14 -1
View File
@@ -908,6 +908,15 @@ func formatExecCommandOutput(output string, sessionID int, exited bool, exitCode
}
func truncateExecOutput(output string, maxOutputTokens int) (string, bool) {
return truncateExecOutputSpill(output, maxOutputTokens, "exec_command")
}
// truncateExecOutputSpill keeps a head/tail window of the output within the
// token budget and, on truncation, spills the full output to disk so the model
// can grep/read the elided middle instead of re-running the command with a
// bigger budget. The spill is best-effort: when it fails the notice simply
// omits the file hint.
func truncateExecOutputSpill(output string, maxOutputTokens int, toolName string) (string, bool) {
if maxOutputTokens <= 0 {
maxOutputTokens = defaultMaxOutputTokens
}
@@ -915,9 +924,13 @@ func truncateExecOutput(output string, maxOutputTokens int) (string, bool) {
if len(output) <= maxBytes {
return output, false
}
notice := "\n[zero] output truncated\n"
if spillPath := spillTruncatedOutput(toolName, output); spillPath != "" {
notice = "\n[zero] output truncated — full output saved to " + spillPath + " (grep or read_file it instead of re-running)\n"
}
head := maxBytes / 2
tail := maxBytes - head
return utf8Prefix(output, head) + "\n[zero] output truncated\n" + utf8Suffix(output, tail), true
return utf8Prefix(output, head) + notice + utf8Suffix(output, tail), true
}
func utf8Prefix(value string, maxBytes int) string {
+97
View File
@@ -0,0 +1,97 @@
package tools
import (
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
// Format-on-write for the mutating file tools. When enabled, a successful
// edit_file/write_file runs the language's standard formatter on the file it
// just wrote, so the model's output always lands in project-canonical style
// and never fails a CI format check it cannot see. Off by default (set
// ZERO_FORMAT_ON_WRITE=1): auto-reformatting changes bytes the model did not
// write, which strict workflows may not want.
//
// Ordering matters: formatting runs BEFORE the FileTracker re-baseline, and
// the caller records the POST-format content. Formatting after the baseline
// would make the very next edit look like an external modification and trip
// the conflict guard.
// formatOnWriteTimeout bounds one formatter run; a wedged formatter must never
// hang a tool call. On timeout the unformatted write stands.
const formatOnWriteTimeout = 10 * time.Second
// formatterCommands maps a file extension to the formatter argv; the file path
// is appended as the final argument. Only in-place, config-respecting,
// community-standard formatters — a missing binary silently skips formatting.
var formatterCommands = map[string][]string{
".go": {"gofmt", "-w"},
".rs": {"rustfmt"},
".py": {"ruff", "format", "--quiet"},
".ts": {"prettier", "--log-level", "silent", "--write"},
".tsx": {"prettier", "--log-level", "silent", "--write"},
".js": {"prettier", "--log-level", "silent", "--write"},
".jsx": {"prettier", "--log-level", "silent", "--write"},
".json": {"prettier", "--log-level", "silent", "--write"},
".css": {"prettier", "--log-level", "silent", "--write"},
".scss": {"prettier", "--log-level", "silent", "--write"},
".html": {"prettier", "--log-level", "silent", "--write"},
".md": {"prettier", "--log-level", "silent", "--write"},
".yaml": {"prettier", "--log-level", "silent", "--write"},
".yml": {"prettier", "--log-level", "silent", "--write"},
".zig": {"zig", "fmt"},
".dart": {"dart", "format"},
".tf": {"terraform", "fmt"},
".gleam": {"gleam", "format"},
".sh": {"shfmt", "-w"},
".bash": {"shfmt", "-w"},
".c": {"clang-format", "-i"},
".h": {"clang-format", "-i"},
".cpp": {"clang-format", "-i"},
".hpp": {"clang-format", "-i"},
".cc": {"clang-format", "-i"},
".kt": {"ktlint", "-F"},
".swift": {"swiftformat"},
".lua": {"stylua"},
}
// formatOnWriteEnabled reports whether the opt-in env toggle is set.
func formatOnWriteEnabled() bool {
value := strings.TrimSpace(os.Getenv("ZERO_FORMAT_ON_WRITE"))
return value != "" && value != "0" && !strings.EqualFold(value, "false")
}
// maybeFormatWrittenFile runs the configured formatter for absolutePath (when
// enabled and on PATH) and returns the file's content afterwards. Best-effort
// throughout: any failure — no formatter, formatter error, timeout, unreadable
// result — returns writtenContent so the caller's state matches the last write
// it performed itself.
func maybeFormatWrittenFile(ctx context.Context, absolutePath string, writtenContent string) string {
if !formatOnWriteEnabled() {
return writtenContent
}
command, ok := formatterCommands[strings.ToLower(filepath.Ext(absolutePath))]
if !ok {
return writtenContent
}
if _, err := exec.LookPath(command[0]); err != nil {
return writtenContent
}
formatCtx, cancel := context.WithTimeout(ctx, formatOnWriteTimeout)
defer cancel()
arguments := append(append([]string(nil), command[1:]...), absolutePath)
formatter := exec.CommandContext(formatCtx, command[0], arguments...)
formatter.Dir = filepath.Dir(absolutePath)
if err := formatter.Run(); err != nil {
return writtenContent
}
formatted, err := os.ReadFile(absolutePath)
if err != nil {
return writtenContent
}
return string(formatted)
}
+82
View File
@@ -0,0 +1,82 @@
package tools
import (
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// gofmt ships with the Go toolchain, so it is the one formatter guaranteed to
// exist wherever these tests run.
func requireGofmt(t *testing.T) {
t.Helper()
if _, err := exec.LookPath("gofmt"); err != nil {
t.Skip("gofmt not on PATH")
}
}
func TestFormatOnWriteDisabledByDefault(t *testing.T) {
requireGofmt(t)
t.Setenv("ZERO_FORMAT_ON_WRITE", "")
dir := t.TempDir()
ugly := "package a\n\nfunc A( ) { }\n"
result := NewWriteFileTool(dir).(optionsAwareTool).RunWithOptions(context.Background(), map[string]any{
"path": "a.go",
"content": ugly,
}, RunOptions{})
if result.Status != StatusOK {
t.Fatalf("write failed: %q", result.Output)
}
onDisk, err := os.ReadFile(filepath.Join(dir, "a.go"))
if err != nil {
t.Fatal(err)
}
if string(onDisk) != ugly {
t.Fatalf("formatting must be off by default, got %q", onDisk)
}
}
func TestFormatOnWriteFormatsAndKeepsTrackerConsistent(t *testing.T) {
requireGofmt(t)
t.Setenv("ZERO_FORMAT_ON_WRITE", "1")
dir := t.TempDir()
tracker := NewFileTracker()
write := NewWriteFileTool(dir).(optionsAwareTool).RunWithOptions(context.Background(), map[string]any{
"path": "a.go",
"content": "package a\n\nfunc A( ) { }\n",
}, RunOptions{FileTracker: tracker})
if write.Status != StatusOK {
t.Fatalf("write failed: %q", write.Output)
}
onDisk, err := os.ReadFile(filepath.Join(dir, "a.go"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(onDisk), "func A() {") {
t.Fatalf("expected gofmt-formatted content, got %q", onDisk)
}
// The tracker must have been re-baselined to the POST-format content: a
// follow-up edit must not trip the external-modification conflict guard.
edit := NewEditFileTool(dir).(optionsAwareTool).RunWithOptions(context.Background(), map[string]any{
"path": "a.go",
"old_string": "func A() {",
"new_string": "func B() {",
}, RunOptions{FileTracker: tracker})
if edit.Status != StatusOK {
t.Fatalf("follow-up edit must not conflict after formatting: %q", edit.Output)
}
}
func TestFormatOnWriteSkipsUnknownExtensions(t *testing.T) {
t.Setenv("ZERO_FORMAT_ON_WRITE", "1")
content := maybeFormatWrittenFile(context.Background(), filepath.Join(t.TempDir(), "notes.xyz"), "raw text")
if content != "raw text" {
t.Fatalf("unknown extension must pass through: %q", content)
}
}
+16 -7
View File
@@ -45,18 +45,18 @@ func NewScopedGlobTool(workspaceRoot string, scope PathScope) Tool {
}
}
func (tool globTool) Run(_ context.Context, args map[string]any) Result {
return tool.runWith(args, readExcluder{})
func (tool globTool) Run(ctx context.Context, args map[string]any) Result {
return tool.runWith(ctx, args, readExcluder{})
}
// RunWithSandbox runs glob while skipping subtrees the sandbox policy denies
// reads to (DenyRead). With no DenyRead configured the excluder is a no-op and
// behavior is unchanged.
func (tool globTool) RunWithSandbox(_ context.Context, args map[string]any, engine *sandbox.Engine) Result {
return tool.runWith(args, sandboxReadExcluder(engine))
func (tool globTool) RunWithSandbox(ctx context.Context, args map[string]any, engine *sandbox.Engine) Result {
return tool.runWith(ctx, args, sandboxReadExcluder(engine))
}
func (tool globTool) runWith(args map[string]any, exclude readExcluder) Result {
func (tool globTool) runWith(ctx context.Context, args map[string]any, exclude readExcluder) Result {
pattern, err := aliasedStringArg(args, []string{"pattern", "glob", "match", "query", "expression"}, "", true, false)
if err != nil {
return errorResult("Error: Invalid arguments for glob: " + err.Error())
@@ -88,8 +88,11 @@ func (tool globTool) runWith(args map[string]any, exclude readExcluder) Result {
return errorResult("Error running glob " + fmt.Sprintf("%q", pattern) + ": " + err.Error())
}
matches, err := scanGlob(root, displayRoot, matcher, includeDirs, exclude)
matches, err := scanGlob(ctx, root, displayRoot, matcher, includeDirs, exclude)
if err != nil {
if res, ok := searchCancelledResult("glob", err); ok {
return res
}
return errorResult("Error running glob " + fmt.Sprintf("%q", pattern) + ": " + err.Error())
}
if len(matches) == 0 {
@@ -126,9 +129,15 @@ func (tool globTool) runWith(args map[string]any, exclude readExcluder) Result {
}
}
func scanGlob(root string, displayRoot string, matcher *regexp.Regexp, includeDirs bool, exclude readExcluder) ([]string, error) {
func scanGlob(ctx context.Context, root string, displayRoot string, matcher *regexp.Regexp, includeDirs bool, exclude readExcluder) ([]string, error) {
matches := []string{}
err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error {
// Checked first, ahead of walkErr: an unscoped scan over a large tree can
// run long enough that cancelling the run must stop the walk promptly
// rather than visiting every remaining entry to completion.
if err := ctx.Err(); err != nil {
return err
}
if walkErr != nil {
if path == root {
return walkErr
+33 -10
View File
@@ -55,19 +55,19 @@ func NewScopedGrepTool(workspaceRoot string, scope PathScope) Tool {
}
}
func (tool grepTool) Run(_ context.Context, args map[string]any) Result {
return tool.runWith(args, readExcluder{})
func (tool grepTool) Run(ctx context.Context, args map[string]any) Result {
return tool.runWith(ctx, args, readExcluder{})
}
// RunWithSandbox runs the search while skipping subtrees the sandbox policy
// denies reads to (DenyRead), so grep never surfaces content from a read-denied
// path. With no DenyRead configured the excluder is a no-op and behavior is
// unchanged.
func (tool grepTool) RunWithSandbox(_ context.Context, args map[string]any, engine *sandbox.Engine) Result {
return tool.runWith(args, sandboxReadExcluder(engine))
func (tool grepTool) RunWithSandbox(ctx context.Context, args map[string]any, engine *sandbox.Engine) Result {
return tool.runWith(ctx, args, sandboxReadExcluder(engine))
}
func (tool grepTool) runWith(args map[string]any, exclude readExcluder) Result {
func (tool grepTool) runWith(ctx context.Context, args map[string]any, exclude readExcluder) Result {
pattern, err := aliasedStringArg(args, []string{"pattern", "query", "regex", "search", "expression"}, "", true, false)
if err != nil {
return errorResult("Error: Invalid arguments for grep: " + err.Error())
@@ -138,15 +138,24 @@ func (tool grepTool) runWith(args map[string]any, exclude readExcluder) Result {
}
}
files, err := grepFiles(resolvedRoot, target, globMatcher, exclude)
files, err := grepFiles(ctx, resolvedRoot, target, globMatcher, exclude)
if err != nil {
if res, ok := searchCancelledResult("grep", err); ok {
return res
}
return errorResult("Error running grep: " + err.Error())
}
// resolveScopedPath returns an absolute displayRoot when the target resolved
// to an extra (non-workspace) granted root; emit absolute match paths there so
// they survive a round-trip through read_file/edit_file (mirrors glob).
matches := collectGrepMatches(resolvedRoot, filepath.IsAbs(displayRoot), files, compiled)
matches, err := collectGrepMatches(ctx, resolvedRoot, filepath.IsAbs(displayRoot), files, compiled)
if err != nil {
if res, ok := searchCancelledResult("grep", err); ok {
return res
}
return errorResult("Error running grep: " + err.Error())
}
if len(matches) == 0 {
if outputMode == "count" {
return okResult("0 matches found")
@@ -261,7 +270,7 @@ func confineGrepFile(resolvedRoot string, path string) (string, string, bool) {
return filepath.ToSlash(relative), resolved, true
}
func grepFiles(resolvedRoot string, target string, globMatcher *regexp.Regexp, exclude readExcluder) ([]string, error) {
func grepFiles(ctx context.Context, resolvedRoot string, target string, globMatcher *regexp.Regexp, exclude readExcluder) ([]string, error) {
info, err := os.Stat(target)
if err != nil {
return nil, err
@@ -288,6 +297,13 @@ func grepFiles(resolvedRoot string, target string, globMatcher *regexp.Regexp, e
files := []string{}
err = filepath.WalkDir(target, func(path string, entry os.DirEntry, walkErr error) error {
// Checked first, ahead of walkErr: an unscoped search over a large tree
// (e.g. a broad parent directory, not just the workspace) can run long
// enough that cancelling the run must stop the walk promptly rather than
// visiting every remaining entry to completion.
if err := ctx.Err(); err != nil {
return err
}
if walkErr != nil {
if path == target {
return walkErr
@@ -342,9 +358,16 @@ func grepFiles(resolvedRoot string, target string, globMatcher *regexp.Regexp, e
return files, nil
}
func collectGrepMatches(resolvedRoot string, absolutePaths bool, files []string, compiled *regexp.Regexp) []grepMatch {
func collectGrepMatches(ctx context.Context, resolvedRoot string, absolutePaths bool, files []string, compiled *regexp.Regexp) ([]grepMatch, error) {
matches := []grepMatch{}
for _, file := range files {
// Checked per file, matching the walk's per-entry check in grepFiles: a
// broad search can already have collected many files before the run is
// cancelled, and reading/regex-scanning each one is real work that must
// stop promptly rather than running to completion.
if err := ctx.Err(); err != nil {
return matches, err
}
// Re-confine at read time (defense-in-depth) AND to compute the clean
// workspace-relative path used in output.
relative, resolvedPath, ok := confineGrepFile(resolvedRoot, file)
@@ -381,5 +404,5 @@ func collectGrepMatches(resolvedRoot string, absolutePaths bool, files []string,
})
}
}
return matches
return matches, nil
}
+18
View File
@@ -0,0 +1,18 @@
package tools
import "context"
// inlineDiagnostics renders the post-write diagnostics block a mutating tool
// appends to its output. Empty when no Diagnostics callback is wired, the file
// is clean, or no language server is available — the tool output is then
// byte-identical to the pre-diagnostics behavior.
func inlineDiagnostics(ctx context.Context, options RunOptions, absolutePath, relativePath string) string {
if options.Diagnostics == nil {
return ""
}
block := options.Diagnostics(ctx, absolutePath)
if block == "" {
return ""
}
return "\n\nDiagnostics in " + relativePath + " after this change (fix any errors you introduced):\n" + block
}
+53
View File
@@ -0,0 +1,53 @@
package tools
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
)
// A wired Diagnostics callback must surface its block in the tool output for
// both mutating tools, and a clean file (empty block) must leave the output
// byte-identical to the pre-diagnostics behavior.
func TestMutatingToolsAppendInlineDiagnostics(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "a.go"), []byte("package a\n"), 0o644); err != nil {
t.Fatal(err)
}
diagnostics := func(_ context.Context, absPath string) string {
return absPath + ":1:1: error: undefined: x"
}
editResult := NewEditFileTool(dir).(optionsAwareTool).RunWithOptions(context.Background(), map[string]any{
"path": "a.go",
"old_string": "package a",
"new_string": "package b",
}, RunOptions{Diagnostics: diagnostics})
if editResult.Status != StatusOK {
t.Fatalf("edit failed: %q", editResult.Output)
}
if !strings.Contains(editResult.Output, "Diagnostics in a.go after this change") || !strings.Contains(editResult.Output, "undefined: x") {
t.Fatalf("edit output missing diagnostics block: %q", editResult.Output)
}
writeResult := NewWriteFileTool(dir).(optionsAwareTool).RunWithOptions(context.Background(), map[string]any{
"path": "b.go",
"content": "package b\n",
}, RunOptions{Diagnostics: diagnostics})
if writeResult.Status != StatusOK {
t.Fatalf("write failed: %q", writeResult.Output)
}
if !strings.Contains(writeResult.Output, "Diagnostics in b.go after this change") {
t.Fatalf("write output missing diagnostics block: %q", writeResult.Output)
}
clean := NewWriteFileTool(dir).(optionsAwareTool).RunWithOptions(context.Background(), map[string]any{
"path": "c.go",
"content": "package c\n",
}, RunOptions{Diagnostics: func(context.Context, string) string { return "" }})
if strings.Contains(clean.Output, "Diagnostics") {
t.Fatalf("clean file must not gain a diagnostics block: %q", clean.Output)
}
}
+6
View File
@@ -40,6 +40,12 @@ type RunOptions struct {
// tool-call progress in the specialist card. nil is a no-op (the default
// for every non-Task tool).
Progress func(streamjson.Event)
// Diagnostics, when set, returns a formatted language-diagnostics block for
// a file a mutating tool just wrote ("" when clean or no server available).
// edit_file/write_file append it to their output so the model sees an error
// it introduced in the same turn instead of waiting for a later verification
// pass. nil disables inline diagnostics.
Diagnostics func(ctx context.Context, absPath string) string
}
type sandboxAwareTool interface {
Loaded 100 of 178 files, more files were not shown because too many files have changed in this diff. Show more