mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-13 19:15:32 -10:00
feat/structural-code-query
78
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
cd0eb194e4 |
feat(tools): give tool results an image channel and add view_image (#843)
* feat(tools): give tool results an image channel and add view_image Closes #824. tools.Result carried only text, so no tool could hand the model a picture. The sharpest edge was our own capture tooling: browser_screenshot, desktop_screenshot and browser_pdf wrote a PNG, set Meta["artifact_path"] and returned "Artifact captured: <path>". Nothing read that path afterwards, so the model asked for a screenshot, was told one existed, and could not look at it. Zero was never image-blind on the way in — zeroruntime.ImageBlock, Message.Images and internal/imageinput are mature. What was missing is the model-initiated leg. Result.Images is delivered as a following USER message, not on the tool result itself. Every provider drops images on a tool-role message: Anthropic maps a tool result to a tool_result block whose content is a string, Gemini to a functionResponse, and OpenAI guards its image content-parts to the user role with an explicit comment saying so. Attaching them to the tool result would have plumbed the whole path and delivered nothing. A separate message also keeps one tool result per tool call, which the providers validate. Blocks with no data, or a media type outside the provider allow-list, are dropped rather than sent — a rejected image would fail the whole turn, and the tool's text still stands on its own. The message names the tool, since the images arrive detached from their result and nothing else says which call produced them when several ran in one turn. view_image resolves through resolveScopedReadPath before imageinput.LoadFile. That scoping is load-bearing: LoadFile joins a relative path to the workspace root and otherwise takes the path as given, so without it the tool would read anywhere on disk. It is registered with the other scoped readers and declared read-only and thread-safe, matching read_file. The capture tools now attach what they wrote. A PDF or an unreadable file yields no image and the text stands alone, so a capture is never failed over its attachment. Co-Authored-By: Claude Opus 5 <[email protected]> * fix(agent): keep tool results contiguous when a tool returns an image The image messages are user-role, and the first version appended each one immediately after its own tool result. With two tool calls in a turn that produces tool_result, user, tool_result — and a user message between two tool results breaks strict provider replay. Anthropic makes it concrete. appendUserBlocks coalesces consecutive user content into one block list, and tool_result blocks must come first in that message, so the interleaved shape maps to: [tool_result, text, image, tool_result] which the API rejects. loop.go already documents this exact rule three lines below the change, for the self-correction feedback it defers for the same reason. Images are now collected during the tool loop and appended once every tool_result is recorded — including the aborted placeholders on the abort, stop-reason and repeated-failure paths, so an early exit cannot strand them mid-sequence either. The original test used a single tool call, which cannot exhibit the bug. The new one runs two calls where the first returns an image and asserts the image message does not fall between the first and last tool result. Also from review: view_image joins the "execute" tool category, which grants read_file but not view_image, so a specialist declared with tools: [execute] could not look at a screenshot it had just captured. Covered by a test that holds for every category granting read_file rather than naming one. And the core read-only set is now asserted by tool name, not only by count — a count still passes when one reader is swapped for another. Co-Authored-By: Claude Opus 5 <[email protected]> * test(agent): pin the tool-result/image ordering contract The assertions only tracked the first and last tool-result index, so four distinct broken orderings passed: a message wedged between the two tool results, a duplicated image flush, a duplicated tool result, and both results answering the same tool_call_id. Mutation-testing each of those against the real loop confirmed the old assertions were green on all four. Now it collects every tool-result index and id and requires exactly two, in c1-then-c2 order, at adjacent indexes, with the image following the last one and delivered exactly once. Adjacency is the assertion that carries the contract: Anthropic needs every tool_result block ahead of any other block in the user message they coalesce into, so anything between the two results is the 400 this change exists to avoid. The image is deliberately NOT pinned to exactly last+1. appendUserBlocks merges consecutive user-role content, so [tool_result, tool_result, text, image] maps to tool_result blocks first and replays fine; pinning would fail that valid shape while killing nothing the adjacency and count assertions do not already catch. messageShape reports the actual role ordering on failure, and the loop uses two ifs rather than a switch so an image left on a tool-role message is reported as the misplacement it is instead of "the image never reached the model". --------- Co-authored-by: Claude Opus 5 <[email protected]> |
||
|
|
4ca4fa75d3 |
fix(agent): end the additional_permissions retry loop (#864)
* fix(agent): end the additional_permissions retry loop
Asking the agent to run an ordinary read-only command on Windows could leave it
oscillating between two rejections instead of executing:
additional_permissions requires sandbox_permissions set to "with_additional_permissions"
additional_permissions must include at least one of network or file_system, ...
Both come from inlineAdditionalPermissionsProfile, and each names only the fix
that ADDS something. The first pushes the model toward setting the flag; the
second pushes it back toward requesting a permission the command does not need.
Neither ever says to omit the field, which is the correct action for a read-only
command, so the two messages form a two-state oscillator. Every rejection now
carries that remedy.
The schema half is why the object is attached at all. Neither field description
said to omit them for ordinary commands, so a model that legitimately used
with_additional_permissions once in a session had nothing telling it to stop
attaching a permission object to the next command. Both descriptions now lead
with "omit", and they are shared constants rather than duplicated verbatim in
bash.go and exec_command.go, where they were free to drift.
Rejection is still rejection. Nothing that was refused is now accepted on the
strength of an empty or mismatched payload, and sandbox_permissions is never
upgraded on the model's behalf; only the guidance changed.
The one behavioural change is that an explicit additional_permissions: null is
now treated as the omitted field, since a null grants nothing and carries no
flag. That flips three shapes from reject to execute: null with use_default,
with require_escalated, and with no mode at all. The escalated one still routes
through the normal escalation prompt and is not a new capability — the identical
request without the null key already behaved that way. A FLAGGED null still
errors, so with_additional_permissions plus null is not silently downgraded.
Fixes #845. Fixes #847.
* fix(tools): condition the omit guidance on needed access, not read-only
The description said to omit sandbox_permissions "for ordinary commands,
including every read-only one". That overstates it: curl and gh are read-only
and still need sandboxed network, so a model following it literally would omit
the override on a command that genuinely needs one and hit the opposite failure.
It now says to omit unless the command needs access the sandbox does not already
give it. That is the claim that is actually true, and it is one a model can act
on without first deciding what counts as read-only.
The schema test asserted the wrong half too — a bare substring check for
"read-only" pinned the overstatement rather than the contract. It now pins the
conditional, with the reasoning recorded so the weaker assertion does not come
back.
Reported by CodeRabbit on #864.
|
||
|
|
8e266797a8 |
perf: improve harness context efficiency (#838)
* Improve harness context efficiency
* Address harness efficiency review findings
* Make recaps idle and cancellable
Delay recap generation until the session has been inactive, orient it around the active goal and next task, cancel stale work on activity, and render the result ephemerally above the composer.
Also strengthen the formatter safety regression to exercise formatter-modified edit output.
* Track every replace-all edit span
Translate each replacement into its post-edit line range, including cumulative shifts from earlier replacements, so follow-up edits remain authorized for every model-supplied change.
* Make review regressions cross-platform
Verify replace-all tracking through the canonical edit tool path instead of a platform-sensitive temp path, and cover cancellation when recaps are disabled during generation.
* fix(tools): derive SeenWhole from the recorded ranges
SeenWhole was a flag set only by a SINGLE read covering the file, while
SeenRange judged coverage from the accumulated ranges. The two then
disagreed about identical reads: after reading lines 1-50 and 51-100 of a
100-line file, SeenRange(1,100) was true and SeenWhole was false.
That matters because write_file gates overwrites on SeenWhole. A file past
the read byte budget cannot be read whole in one call — the read is
truncated and RecordSeenRange is correctly skipped, since the model did not
see those lines — so no sequence of reads could ever set the flag, and the
overwrite was refused permanently with:
the intended change depends on content that has not been read exactly
in this session. Read the affected lines with read_file, then retry.
Advice that could not change the answer. edit_file was unaffected because it
gates on SeenRange, so the escape was to edit rather than rewrite, but a
model following the error text would loop.
SeenWhole is now computed from the ranges against the file's recorded line
count, so scoped reads that together cover a file make it writable and the
error text becomes actionable. The observation carries that line count, and
a read reporting a different one clears the accumulated ranges: they
describe a shape the file no longer has.
Coverage is judged by merging sorted ranges rather than walking every line
against every range, which the previous SeenRange did — same answer, and it
no longer scans line-by-line over a large file.
Co-Authored-By: Claude Opus 5 <[email protected]>
* test(tools): key the large-file test on the resolved path
read_file resolves the workspace root through EvalSymlinks before recording
anything, so the tracker is keyed on the resolved path. The test asserted
against the raw t.TempDir() path, which is the same string on Windows and a
different one on macOS, where the temp dir sits under /var — a symlink to
/private/var. The SeenWhole assertions would have looked up a key that never
existed: passing here, failing on macOS CI.
Also switches the line generator to fmt.Fprintf (staticcheck QF1012).
Both from CodeRabbit on #842.
Co-Authored-By: Claude Opus 5 <[email protected]>
* test(tools): actually switch the line generator to fmt.Fprintf
The previous commit's message claimed this was done. It was not: the edit
silently did not apply and I reported it fixed without re-reading the file,
so builder.WriteString(fmt.Sprintf(...)) survived at the head — caught by
@anandh8x, not by me.
Verified this time by running the linter rather than by reading the diff:
golangci-lint --enable-only unused,ineffassign,staticcheck ./internal/tools/
reports 0 issues.
* fix: keep exact-read workflows recoverable
* test: cover permission normalization wiring
* fix: commit file observations after output budgeting
* fix: defer file observations across retries
* test: normalize patch line endings
---------
Co-authored-by: Vasanthdev2004 <[email protected]>
Co-authored-by: Claude Opus 5 <[email protected]>
|
||
|
|
81a5e6cb71 |
fix(sandbox): preserve user config and retry denied writes (#801)
* fix(sandbox): preserve user config and retry denied writes Keep user identity and configuration paths discoverable while retaining managed cache and temporary storage. Classify native sandbox failures as typed recoverable denials so approved commands can retry outside the sandbox. Add regression coverage for global Git ignores, typed denial handling, and permission retry behavior. * fix(sandbox): tighten denial retry safeguards * test(sandbox): assert denial contract * fix(sandbox): trust typed denials and restore Cargo state Stop promoting command output keywords into recoverable unrestricted denials, so only structured adapter facts can enter the approved unsandboxed retry path. Restore the managed CARGO_HOME directory and environment override so Cargo remains writable while the caller's real home stays read-only. * fix(sandbox): mask preserved credential configs Preserving caller configuration made credential-bearing tool files discoverable under Linux's read-all sandbox profile. Add default and environment-overridden npm, GitHub CLI, netrc, Docker, Kubernetes, and Zero config paths to the credential deny set, with regression coverage. Clarify the typed-denial retry and platform read-model comments without changing the approval behavior. * fix(sandbox): protect custom gcloud config CLOUDSDK_CONFIG can relocate gcloud credentials outside the XDG-derived default. Include the configured directory in the credential deny profile and cover the override with a regression test. * fix(sandbox): deny zero credential directory * test(sandbox): keep zero credential directory deny path expectations |
||
|
|
97ef90d27a |
chore: remove code unreachable on every supported platform (#706)
* chore: remove code unreachable on every supported platform The Security and code health job's advisory deadcode step reports 152 unreachable functions on main. Running the same check under GOOS=linux, windows, and darwin shows 13 of those are false positives whose callers live in windows build-tagged files, so only functions unreachable on all three platforms are touched. Functions with no references anywhere are deleted, including the internal/reasoning package, which nothing imports. Functions that only tests reference move into per-package export_test.go files so tests keep their seams while the production binary sheds the code. Test call sites of deleted convenience wrappers are ported to the live APIs (scoped tool constructors, tools.DeferredSource, FinishReason checks). Kept despite the report: cron.Store.Runs (only reader of runs.jsonl, which production writes), and agenteval.TraceEventKeys and MissingTraceEvents (used across packages by the trace contract test). * docs(config): correct ValidateFile's errors.As claim Message is a flattened string (.Error() already called on the parse error), not a wrapped error, so callers can't recover the concrete json error type via errors.As. Fix the comment to match. Co-Authored-By: Claude Sonnet 5 <[email protected]> * chore(trace,skills): drop obsolete Sink adapters and fix stale doc references The dead-code pass removed the Emit methods but left Sink, NDJSONSink, and TextSink exported with a contract nothing implements; remove the types with their contract. Also remove the stray blank line at EOF of trace.go flagged by gofmt, and update skills package docs and HOW_ZERO_WORKS.md to stop directing callers to DiscoveryRoots/Duplicates, which are now test seams. Co-Authored-By: Claude Fable 5 <[email protected]> * chore: clean up remaining review nits on dead code removal Replace a pointless fmt.Errorf(...%w...).Error() with fmt.Sprintf using %v, since the wrapped error was discarded immediately anyway. Remove a comment block that was duplicated verbatim. * docs(skills): drop remaining references to test-only List/Get Load and MergedSkills comments still named List/Get as production entry points after they moved to export_test.go. Point callers at Load and ListFromRoots instead. * chore: remove now-unreferenced export_test.go seams execSessionManager.len() and likelySandboxDenied/sandboxDenialKind had no remaining callers after the sandbox unify-command-execution rebase; the production code they wrapped for tests no longer exists. --------- Co-authored-by: Claude Sonnet 5 <[email protected]> |
||
|
|
96859c9bd1 |
feat(sandbox): unify command execution and enforcement (#781)
* feat(sandbox): unify command execution and enforcement * test(sandbox): make platform contracts portable * fix(sandbox): close execution lifecycle races Bound retained interactive output, preserve interruption state, and start workspace observation before process launch. Protect active runtime roots with cross-platform leases, capture transient protected-path creation events, and restore process attributes after PTY fallback. * fix(sandbox): restore strict macOS isolation Remove Seatbelt host-local network allowances after native validation showed localhost filters can reach host interfaces. Track process reap completion so retained sessions cannot signal a stale numeric PID. |
||
|
|
9242b9e037 |
Add transcript-grounded structured task state (#761)
* Add transcript-grounded structured task state Project existing plan, tool-result, changed-file, verification, and completion events into a deterministic per-run snapshot. Emit content-free aggregate snapshots to traces. Carry bounded objective context through compaction and ground completion checks only when the projection matches the transcript; mismatches retain transcript-derived behavior and drop stale carried state. Tested: make fmt-check Tested: go vet ./... Tested: go test ./... Tested: go test -race ./internal/agent ./internal/trace Tested: go run ./cmd/zero-release build Tested: go run ./cmd/zero-release smoke Tested: govulncheck ./... (no vulnerabilities found) Advisory: pinned golangci-lint reports only unrelated pre-existing findings Tested: git diff HEAD --check * Preserve task objective across repeated compaction Keep the immutable run objective when plan calls have been elided, while admitting mutable task fields only when plan parity still matches the transcript. Make plan parsing match update_plan rejection semantics, coalesce and cap task-state trace events, rename parity to its plan-only scope, use explicit nilable dependencies, and remove confirmable objective fingerprints from traces. Tested: make fmt-check Tested: go vet ./... Tested: go test ./... Tested: go test -race ./internal/agent ./internal/trace Tested: go run ./cmd/zero-release build Tested: go run ./cmd/zero-release smoke Tested: govulncheck ./... (no vulnerabilities found) Advisory: pinned golangci-lint reports only unrelated pre-existing findings Tested: git diff HEAD --check |
||
|
|
739a47e3ea |
perf(agent): preserve prompt cache prefixes (#760)
* perf(agent): preserve prompt cache prefixes Build the system prompt once per run, fingerprint the exact emitted prompt and ordered tool definitions, and expose the full system-prompt hash in traces. Add normalized and serialized two-turn regressions covering append-only messages, stable tools, and a stable session cache key. * fix(tools): stabilize registry iteration order Return registered tools in name order so provider-visible tool lists and prompt-prefix fingerprints cannot drift with Go map iteration. Add a regression covering the registry ordering contract. |
||
|
|
378d538e24 |
perf(agent): execution profiles with one-shot posture escalation (PR10b+PR10c) (#740)
* perf(agent): execution profiles, --exec-profile, bench passthrough, TUI /profile (PR10b+10c) * fix(execprofile): harden profiles against review findings - fast escalates on critical-risk mutations, not high: the sandbox marks every shell command high before command analysis, so a high trigger spent the one-shot on the first go test of any task - escalation can now restore the provider-default effort a profile displaced (PostureEscalation.RestoreDefaultEffort; a plain empty target means leave untouched) - TUI /turns while a profile is active pins the budget: disarms the escalation turn target (mirroring exec's explicit --max-turns rule) and profile reverts use touched bits instead of value equality, so explicit choices survive even when they coincide with profile values - spec-draft runs get the same escalation policy as the main run instead of the displaced budget with no safety net - bench validates and canonicalizes --exec-profile before spawning: an unknown name previously produced an exit-0 report full of millisecond usage-error walls - turn summary prints exec-profile alongside mode; zero exec rejects an inline-empty --exec-profile=; balanced trace label documented and pinned as deliberate attribution * fix(execprofile): explicit effort choices disarm the escalation effort restore An explicit effort set AFTER profile selection could be cleared by a mid-run escalation (the effort analog of the /turns pin, introduced by RestoreDefaultEffort): TUI /effort and Ctrl+T now disarm the restore on a policy clone, and the spec-draft path only arms it when no explicit --spec-reasoning-effort governs the draft. * fix(execprofile): address review findings on the profile surfaces - --use-spec projects away a profile's self-correct knob instead of silently arming an option the draft runner ignores (the explicit --self-correct --use-spec combination is already a parse-time error) - RunTurnBench canonicalizes and validates ExecProfile at the library boundary, so a direct caller's casing/whitespace cannot fork report labels and an unknown name fails before anything runs - reverting a profile whose displaced budget was zero clears ZERO_MAX_TURNS (SetMaxTurnsEnv ignores zero, so sub-agents would have kept the removed profile's budget) - the TUI effort-disarm test fails instead of skipping when the catalog stops filling low effort * fix(tui): reconcile an active profile's effort fill on model switch The fill is per-model, not a session preference: fast selected on a model without low fills nothing, and switching to a model that supports it should behave like selecting the profile there; the reverse switch must not keep sending a level the destination does not support. Both switch paths (the /model command and the cross-provider picker) now re-derive the fill, leaving explicitly touched efforts alone and keeping the escalation's effort restore in step. Also: the revert-env test asserts key removal via LookupEnv, and applyProfileTurnBudget's pinned-budget parameter is named and documented as covering mode presets, not just the flag. * fix(tui): void profile effort bookkeeping when the model-switch drop erases it The /model path's pre-existing unsupported-effort drop ran before the profile reconciliation and left contradictory state behind: a dropped touched override kept blocking the profile from governing, and a dropped profile fill left a stale applied value with the effort restore still armed. The drop itself is load-bearing (the TUI run path forwards the effort to the provider unfiltered), so instead of preserving a known-unsupported level, the voided choice now resets the profile's effort bookkeeping and the reconcile re-derives from scratch. The whole sequence lives in one ring-parameterized helper driven by the switch target's authoritative ring, unit-tested against synthetic rings for both the void-then-refill arc and the supported-override survival. * fix(tui): only drop an explicit effort when the target ring is authoritative A nil ring meant both a catalog model with no effort controls and a live-discovered custom model the catalog cannot vouch for. The drop now requires an authoritative ring (a resolved catalog entry, whose ring may legitimately be empty); an unknown ring preserves an explicit preference, matching the cross-provider picker's behavior, while the profile's own fill still applies only where support is known. |
||
|
|
af875df587 |
perf(agent): posture-escalation signals and controller (PR10a) (#736)
* trace(perf): add posture escalation counter * feat(agent): typed posture-escalation policy and tool-result risk field * feat(agent): posture controller wiring and executed-risk stamping * test(agent): cover posture controller, escalation act path, and risk stamping * test(agent): pin zero risk on not-executed results * fix(agent): wire uncertain-path escalation and validate risk thresholds |
||
|
|
30e2c3f7ff |
perf(providers): provider capabilities and default turn-session adapter (PR7) (#720)
* perf(providers): add provider capabilities and default turn-session adapter (PR7) * fix(providers): project effective reasoning efforts into capabilities * feat(agent): carry optimized turn sessions across mid-run model switches * test(agent): cover swap-time session open failure |
||
|
|
18cce358a2 |
Add deterministic completion policy (#719)
Extract completion decisions from the agent loop into a feature-gated typed policy with complete, incomplete, and uncertain outcomes. Preserve bounded plan-stall nudges and allow at most one semantic acceptance check for self-correcting runs. Tested: make build Tested: make test Tested: go fmt ./... Tested: go vet ./... Tested: make lint Tested: govulncheck ./... Note: repository-wide pinned golangci-lint reports 36 pre-existing unrelated findings; internal/agent/... reports 0 issues. |
||
|
|
e5670c427f |
perf(output): add token-aware semantic output budgeting (PR11) (#717)
* perf(output): add token-aware budget contract * perf(output): add semantic retention policies * perf(output): budget results after redaction * perf(output): propagate tool truncation metadata * perf(output): trace semantic budget decisions * perf(output): classify core tool output * docs(output): describe semantic output budgeting * test(output): make spill fallback portable on windows * fix(output): address semantic budgeting review findings * test(output): make hook budgeting tests portable * fix(output): retain oversized single-line file content * fix(output): apply semantic budgets to captured output * fix(output): preserve hook budgets and search paths * fix(output): preserve alias and truncation metadata * test(trace): validate output budget metadata --------- Co-authored-by: KRATOS <[email protected]> |
||
|
|
31d45d5f14 |
perf(agent): concurrent read-only tool batches via capability gate (#715)
Upgrade the parallel tool planner from SideEffectRead probes to the PR5 CapabilitiesOf contract: EffectReadOnly + ThreadSafe + auto-allowed, with resource-key conflict boundaries so same-path reads stay sequential. Mark audited pure reads ThreadSafe (read_file, read_minified_file, list_directory, glob, grep, skill, web_fetch). Glob/grep use scopedScan directory keys (nil for workspace-wide "." so scans do not false-conflict). Tests cover the capability gate, key conflicts, extendParallelRun windows, and catalog ThreadSafe audit for concurrent-safe reads. Co-authored-by: KRATOS <[email protected]> |
||
|
|
824ecdbcf9 |
fix(hooks): run sessionEnd hooks after Esc/Ctrl+C interrupts (#606)
* fix(hooks): run sessionEnd hooks after Esc/Ctrl+C interrupts On interrupt, Run returns with ctx already canceled. The deferred dispatchSessionEnd call passed that same ctx into Hooks.Dispatch, whose context.WithTimeout derives an already-canceled child context, so the hook process never actually launches (recorded as timed-out/error in the audit log). Since most sessions end via interrupt, sessionEnd hooks were effectively unreliable. Detach the sessionEnd dispatch from the run context's cancellation with context.WithoutCancel so the dispatcher's own timeout still applies without being short-circuited by the run's cancellation. Co-Authored-By: Claude Sonnet 5 <[email protected]> * test(agent): fall back to GOROOT for hook regression * test(agent): justify GOROOT fallback --------- Co-authored-by: Claude Sonnet 5 <[email protected]> |
||
|
|
1c5c6e78a8 |
feat(perf): emit prompt-prefix hash fingerprint per turn (#704)
Compute a seven-field SHA-256 fingerprint of the cacheable prompt prefix (base instructions, confirmation policy, project context, skills, tools, tool schemas, complete) and emit one prefix_hash event per turn through the trace recorder. The complete hash is the SHA-256 of the canonical join of the other six, so any sub-component drift is observable both individually and in aggregate. The decomposition exists so a downstream observer can detect which sub-component of the prefix broke cache stability across turns: a stable complete_prefix hash means the cacheable prefix is byte- identical, and the sub-hash that changed names the culprit. No behavior change for untraced runs. For OpenAI, the existing prompt_cache_key (session ID) is unchanged; the fingerprint is purely additive observability. The Anthropic cache_control breakpoint work already in main is not touched. Co-authored-by: anandh8x <[email protected]> |
||
|
|
abe24bcb10 |
Add per-turn tracing and a turn-benchmark harness (Phase 0 baseline) (#700)
* Add per-turn tracing and a turn-benchmark harness (Phase 0 baseline)
Introduce an opt-in `internal/trace` package that attributes a run's wall
time to named spans — prompt build, provider connect/queue, generation,
tool queue/execution, permission wait, verification, compaction,
persistence — plus counters for model requests, tool calls, retries,
reconnects, compactions, completion nudges, acceptance checks, model
switches, and input/cached/output tokens. The recorder is nil-safe (a nil
*Recorder is a no-op), so every stamp site can call it unguarded; an
untraced run is byte-identical to today.
The recorder threads into agent.Run via a new `Options.Trace` field and a
single `trace.WithContext` assignment, so the shared providerio seam
(connect around client.Do, queue around OAuth resolve, first token at the
first non-keepalive SSE payload) reaches it through `trace.FromContext`
without any provider-interface change. All instrumentation is opt-in: when
`Options.Trace` is nil the hot path is untouched.
Headless `zero exec` gains `--trace <path>` (and `ZERO_TRACE=<path>`),
writing an agenteval-contract-compatible NDJSON trace (`-` for stderr).
The trace is pure observation; enabling it does not change behavior.
A new per-turn benchmark harness extends `internal/perfbench` (reusing the
existing TaskSet/NumericStats/subprocess runner shapes): `RunTurnBench`
aggregates per-span median/P95 across iterations, ranks the top three
controllable latency sources by share of attributed time, rolls up per
task class, and totals tokens/counts. A checked-in 42-task manifest across
seven classes (nav, edit, fix, refactor, longproc, longctx, parallel) and
local offline fixtures back it, and `make baseline` runs it against the
built `zero`, writing `internal/perfbench/reports/baseline.json`. The
report is machine-specific and regenerated, not hand-edited.
The manifest is JSON rather than YAML to avoid adding a dependency;
per-task verification targets the specific test (`go test -run <name>`)
so fix tasks are independent. Each task is a fresh `zero exec` process, so
iterations are cold-start samples; a warm in-process path is left for
follow-up.
Verified: `go build ./...`, `go vet ./...`, `gofmt -l` clean on touched
files; `go test -race` green for internal/trace, internal/agent,
internal/perfbench, and cmd/zero-perf-bench.
* test(trace): make TestAttributionRatio clock-independent
The test recorded two synthetic 10ms spans with no real delay and asserted
the attribution ratio stayed <= 1.0. On a precise clock (Linux/macOS CI) the
wall is microseconds while attributed is 20ms, so attributed/wall blows past
1.0 and the test failed — even though the ratio legitimately exceeds 1.0
when spans overlap (parallel tool execution). On Windows the coarse timer
masked it, so the failure only surfaced in CI.
Assert the contract instead: attributed is the deterministic sum of span
durations, and the ratio equals attributed/wall (0 when wall is zero). Add a
zero-wall case for the divide-by-zero guard.
* Address review feedback on the per-turn tracer + benchmark harness
Benchmark runner (internal/perfbench):
- A nonzero agent exit code no longer counts as Passed. The runner set
VerifyErr but then fell through to outcome.Passed = true, so a crashed
run could be recorded as a pass. Return before the verification block
when VerifyErr is already set.
- A task now passes only when every iteration passes. The previous
"any iteration passes" rule let a flaky fail hide behind one good run.
- Each invocation copies its workspace fixture to a fresh temp dir and
runs the agent + verifier there, so mutating tasks (edit/fix/refactor)
can't dirty the checked-in fixtures or bleed into the next iteration.
- Trace load failures (missing/malformed trace file) are surfaced as
result warnings via a new TraceIssue field instead of being swallowed.
Tracer (internal/trace):
- Finish freezes the recorder: Counter, addSpan, and the first-event
stamps are dropped after Finish returns, so a snapshot captures exactly
one turn and late stamps can't mutate it.
- WriteText propagates the first write error instead of silently
swallowing sink failures.
Provider seam (internal/providers/providerio):
- FirstTokenAt is stamped only after a payload is accepted as real model
output, not before handle() decides to drop it as an error payload.
Agent loop (internal/agent):
- The compaction counter increments only when the history actually
shrinks, in both the proactive and reactive paths — paid no-ops no
longer inflate the compaction total.
- Document on Options.Trace that a fresh Recorder is required per Run.
CLI (internal/cli/exec):
- Finish the trace at the agent.Run boundary and defer only the
serialization, so the snapshot covers exactly the turn.
- Reject --trace / ZERO_TRACE for spec-draft runs with a clear error
instead of silently accepting and writing nothing.
Manifest + fixtures:
- Strengthen the edit verifiers: rename and value-bump tasks now assert
the old symbol is gone, and the getter task greps the method signature.
- Add the missing testdata/edit fixture (10 edit tasks referenced a
directory that was never committed, so the whole edit class errored
out at run time).
- The manifest test now stats each fixture so a missing dir fails at
load time instead of at every run.
- longproc benchmark keeps Process live across the loop; README code
fence gets a language tag and documents the per-invocation fixture copy.
- Add tests for the tracing wrapper (nil vs wired recorder, OnUsage
forwarding, token aggregation) and the frozen-recorder contract.
* Address review feedback: exclusive-time attribution + wired spans + fix-suite compile
Rewrite the trace attribution model so concurrent/nested spans no longer
double-count. Spans record wall intervals; the recorder derives a parent for
each by interval containment and computes exclusive time (duration minus the
union of its children). AttributedDuration sums only top-level spans; Coverage
is the union of intervals over wall, capped at 1, the honest ">=95%" metric.
Top-latency ranking uses exclusive time so shares sum to ~1 instead of being
inflated by overlapping provider_connect/generation and nested
permission_wait/tool_execution.
Wire the spans that were named but never stamped: verification around the
self-correct verify pass, and StampFirstVisibleEvent on the first forwarded
text event. Drop the tool_queue/process_wait/persistence constants that had no
stamps, and rename the misnamed near-noise prompt_build span to tool_partition.
Trim RequiredEventKeys to events a healthy short run actually emits; move
conditional phases (tools, permission, compaction, verification) to
OptionalEventKeys so a short trace does not false-fail.
Make ReadNDJSON fail loudly on a corrupt file: require a type:trace header,
error on span/counter lines that appear before it, and error when a header
yields no spans or counters.
Fix the blocking fix-suite compile error: Label's %d format verb on a string
tripped go test's vet before any fix task could verify. Make it a runtime
wrong-prefix bug ("admin-" vs "user-") that still type-checks, so each fix
task is independent again.
Manifest: correct refactor-04's stats.go to main.go, reword fix-07 to the
wrong-prefix bug, add a latency-only description (read-only tasks carry no
verification oracle by design), and frame the report README on exclusive time
and coverage.
Tests updated for the occurrence/exclusive/coverage model; trace, perfbench,
and agent green under -race.
* Fix exclusive-time round-trip, identical-interval nesting, and empty-trace parse
Three correctness bugs in the attribution model found by an adversarial review
of the prior commit, all undermining the no-double-count guarantee:
1. exclusive_ms:0 was overwritten with the inclusive Duration on re-parse. A
parent whose children tile its interval legitimately has exclusive time 0;
WriteNDJSON emits exclusive_ms:0, but ReadNDJSON's "<= 0 means missing"
fallback turned that back into the full duration, so every re-parsed trace
re-introduced the double-counting the model exists to prevent (the benchmark
harness, which ranks re-parsed traces, inherits it). Gate the fallback on
key ABSENCE, so a written 0 is preserved.
2. deriveNesting formed a 2-cycle for two spans with identical [start, end]
intervals: the symmetric containment check made each parent the other,
dropping both from top-level and zeroing AttributedDuration. Break the tie
deterministically — strict containment is an unambiguous parent; for
identical intervals, only the lower-indexed span may parent the other, so
one stays top-level.
3. ReadNDJSON returned a valid empty TurnTrace for empty/blank-only input, so a
run that crashed before emitting a trace (or where --trace was not honored)
masqueraded as a clean zero-attribution sample. Empty input now errors, so
the harness records a TraceIssue.
Also: decode counters via json.Number so int64 values above 2^53 round-trip
exactly instead of losing precision through float64; and drop the harness's
own "exclusive <= 0 -> Duration" ranking fallback, which was the same
double-count on the re-parsed path. Added regression tests covering the
zero-exclusive round-trip, the identical-interval nesting, empty-input
rejection, and counter precision.
* perf(turn-bench): split pass/fail into oracle tiers so the pass rate can't be misread as correctness
The turn benchmark counted any exit-0 task as passed, including the 24
read-only tasks (nav/longproc/longctx/parallel) that carry no verification
oracle — so the JSON tasksPassed / "N/M passed" headline could read as a
correctness verdict it never was. Split pass/fail into three oracle tiers:
- correctness (edit grep + fix go test): the only pass rate that can move
with model quality (tasksVerified / tasksPassed / correctnessPassRate)
- build-only (refactor go build): a non-positive oracle, reported as
buildPassRate and excluded from correctnessPassRate; the manifest declares
buildOnlyClasses: ["refactor"]
- latency-only (no oracle): counted in latencyOnlyTasks, never in any pass
rate
NewTurnExecRunner no longer sets Passed=true for tasks with no
verificationCommand, and the summary headline names each tier explicitly so
an exit-0 read-only run cannot inflate a correctness number. Bumps
TurnSchemaVersion to 2. Class breakdown and tier contract documented in
internal/perfbench/MANIFEST.md and reports/README.md.
Also install the trace first-token/visible-event forwarding handlers in
agent.Run whenever options.Trace is set, not only when a UI OnText /
OnReasoning callback is set, so a headless traced run (zero exec --trace)
still captures FirstTokenAt. forwardedVisibleText stays tied to the user
callback so stall-retry semantics are unchanged. Adds a regression
assertion to the existing traced-run test.
* Address CodeRabbit review on
|
||
|
|
6ec77d62f5 | Dispatch session lifecycle hooks (#581) | ||
|
|
3f0503bc23 |
fix(agent): keep tools exposed for max-turn finalization (#533)
* fix(agent): keep tools exposed for max-turn finalization * chore(agent): rename max-turn tool definitions parameter |
||
|
|
95ccd5bc32 |
perf: universal tool-output ceiling with spill + async post-edit diagnostics (#518)
* feat(tools): universal token-based output ceiling with spill-to-disk
Cap every tool result at the registry boundary (16k estimated tokens,
ZERO_TOOL_OUTPUT_CEILING_TOKENS-tunable, 0 disables) unless the tool
manages its own deliberate budget. Head+tail truncation, full output
spilled to the existing redacted spill dir and re-readable via
grep/read_file, so nothing is lost — only deferred.
This closes the three unbounded paths a single call could flood the
context window through (and then re-bill every following turn):
- web_fetch: up to 256KiB default / 2MiB max of non-HTML body
- skill: full SKILL.md returned uncapped
- MCP-served tools: no budget at all
Self-budgeting tools opt out via an unexported marker interface (an MCP
server can never exempt itself): bash, exec_command (model-raisable),
read_file, read_minified_file, grep, glob, list_directory. Browser
snapshots deliberately stay under the net.
bash itself is tightened to match the unified token model: emit budget
96KiB -> 32KiB per stream (~24k -> ~8k tokens worst case per stream),
while capture retention stays at 96KiB head+tail so the new spill file
covers 6x more of the output than the transcript shows.
* perf(agent): collect post-edit LSP diagnostics off the tool-call path
Inline post-edit diagnostics blocked every edit_file/write_file result on
the language server: a >=300ms quiet-period debounce that reset on each
publish, seconds on the first edit per language, capped at 10s — the
largest recurring latency tax in an edit-heavy session.
Diagnostics are now collected in the background: a file is enqueued the
moment a mutating tool finishes, a single worker checks it while the rest
of the batch executes, and the loop drains completed results just before
building the NEXT provider request, appending errors as a user nudge.
The model always issues another request after an edit turn (to read its
tool results), so it still sees introduced errors at the same decision
point — no tool call ever stalls on the language server.
A file re-edited before its check runs is checked once in its final
state (the checker re-reads the file); re-edited after, it is re-queued
and the newer result wins. A drain that finds the worker still busy
gives up after 3s and delivers on a later turn — results stay accurate
because any further edit re-enqueues the file.
The tools-side inline mechanism (tools.RunOptions.Diagnostics) is kept
for direct API callers; the loop just no longer wires it.
* fix: address review — max-turns diagnostics drain, capture-gap marker, test hardening
- Drain background diagnostics before the max-turns final-answer request
so an error introduced by the LAST turn's edit is reported in the
summary instead of silently dropped (+ regression test).
- spillBashStreams marks the head/tail junction with a capture-gap
marker when boundedBuffer dropped the middle of a stream, so a spilled
log never reads as contiguous when it is not; junction position is
asserted in the test.
- Tests override TMP/TEMP alongside TMPDIR (os.TempDir reads TMP/TEMP on
Windows) via a shared setTestTempDir helper.
- TestBudgetBashCaptureReportsTrueTotal now asserts spill_path meta and
spill file content; exemption-list test gains read_minified_file.
* fix(test): use a real workspace root in async diagnostics tests
filepath.IsAbs("/ws") is false on Windows (no drive letter), so the
collector test's workspace-absolute assertion failed on the windows
smoke job. Root the fake workspace at t.TempDir() — absolute on every
platform — and assert against it.
* fix: pattern-scrub spill files and gate finalization on pending diagnostics
Review follow-ups (Vasanthdev2004):
- Spill files now pass the pattern-based secret scanner (AWS keys,
GitHub/Slack/OpenAI tokens, PEM blocks, JWTs) in addition to the
configured-key scrub. A bash spill runs before formatBashOutput's
scan, so without this a spilled file held in cleartext exactly the
credentials the transcript redacts. Applied centrally in
spillTruncatedOutput, covering bash, exec_command, and ceiling spills.
- Finalization diagnostics gate: a no-tool-call final answer now drains
pending post-edit diagnostics with the full inline-era budget (10s,
vs the 3s per-turn wait) before the run is accepted; introduced
errors append the nudge and give the model one more turn instead of
being silently dropped when no later turn exists. The max-turns
summary path uses the same finalization budget. Free for runs that
never edited — an idle collector returns immediately.
|
||
|
|
87e7e69afd |
feat(openai): forward prompt_cache_key for server-side prefix cache routing (#515)
* feat(openai): forward prompt_cache_key for server-side prefix cache routing Send the session ID as OpenAI's prompt_cache_key on every completion request so consecutive turns route to a backend replica that already holds the conversation prefix in its prompt cache, instead of re-billing the full ~11k-token system+tools prefix each turn on OpenAI-compatible endpoints (which get no cache_control breakpoints, unlike Anthropic). Keyless requests serialize byte-identically to before, and ZERO_DISABLE_PROMPT_CACHE_KEY=1 suppresses the field for strict endpoints that reject unknown parameters. The Codex (Responses API) flavor is deliberately untouched: the ChatGPT backend is a separate, stricter surface. * fix(openai): parse prompt-cache-key kill switch as a boolean flag ZERO_DISABLE_PROMPT_CACHE_KEY=0 or =false previously disabled forwarding because any non-empty value tripped the check. Parse it the same way ZERO_FORMAT_ON_WRITE parses boolean flags, so explicitly-falsy values are a no-op and only truthy values flip the toggle. |
||
|
|
3c81fea228 |
feat: agent quality, caching, retry, and tooling upgrades (#506)
* 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 * 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 * 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. * 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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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]> |
||
|
|
952788f72d |
fix(tui): resolve every permission request so the agent can't deadlock (#397)
A goroutine dump of a run "stuck for 33 minutes" showed the agent parked forever in executeToolCall -> requestPermission, blocked on the TUI's decision channel. Root cause: a shell command whose sandbox decision was Allow but which requested additional sandbox permissions (sandbox_permissions: with_additional_permissions) made shouldRequestPermission block on OnPermissionRequest, yet buildPermissionEvent stamped the event Action=allow. The TUI's permissionRequestMsg handler only rendered/resolved Action=prompt requests and silently dropped everything else — so the decide callback was never called, the agent waited on a channel nothing would signal (no ctx cancellation on the active run), and the UI showed "Working" with no prompt and zero network activity. Intermittent and model-dependent: gpt-5.5's folder-creation command set the additional-permissions flag, glm's did not, which is exactly why one hung and the other ran clean. Two-part fix: - agent (buildPermissionEvent): an additional-permissions request is an ELEVATION the user must consent to, even over a sandbox Allow, so mark it Action=prompt (matching shouldRequestPermission's own condition). It now surfaces as a real prompt instead of a dropped auto-allow. - tui (permissionRequestMsg handler): enforce the contract that a blocked agent ALWAYS gets a decision. A non-prompt action resolves immediately and FAILS CLOSED (deny; cancel stays cancel) so the UI can never silently grant an unsurfaced permission; a superseded-run (runID mismatch) resolves as cancel so its parked goroutine unblocks without depending on ctx-cancel ordering. Matches the ACP handler's always-resolve, fail-closed contract. Red-green: reverting the tui branch reproduces the deadlock (decide never called); reverting the agent override reproduces Action=allow. End-to-end against a mock backend emitting the exact triggering tool call, the real TUI now renders the permission prompt and the run completes on approval, where before it parked forever. |
||
|
|
02f0c0929b |
fix: cut the content-stall dead-wait and auto-retry a mid-tool-call stall (#362)
* fix: cut the content-stall dead-wait and auto-retry a mid-tool-call stall Root-caused (via a verified multi-agent audit) the chatgpt/gpt-5.5 "stuck" reports: the ChatGPT Codex backend pauses real generation mid-stream on large single tool-call outputs (a ~1000-line write_file) while continuing SSE keep-alive heartbeats. That is an upstream limitation, not a Zero bug or a connection-pooling issue (the darwin DisableKeepAlives fix is applied and confirmed not the cause). Zero already detected it, but two UX gaps made it feel like a hang: 1. Faster abort. The content-stall watchdog fired at idle*2 = 10 minutes. A heartbeat-pause stall on gpt-5.x rarely recovers, so 10 minutes of a frozen token counter was a terrible wait for a doomed turn. Replaced the StreamContentStallFactor=2 const with ContentStallTimeout(idle)=idle*1.2 (6 minutes at the default). It still only ever fires on a TRUE zero-output freeze — any real data line resets it — so healthy slow-reasoning runs are unaffected. Scales with ZERO_STREAM_IDLE_TIMEOUT; returns 0 (disabled) when idle is disabled. 2. Auto-retry the actual stall. The existing stall-retry only fired when NOTHING had been forwarded — so the common case (model streamed transient reasoning + began the big write_file, then froze mid-arguments) was NOT retried, because the incomplete tool call is collected and transient output was forwarded. Relaxed the gate to retry when no visible PROSE was shown (forwardedVisibleText, marked only by OnText) and no final text was collected — dropping the len(ToolCalls)==0 exclusion, since an incomplete tool call from a timed-out stream is never executed (the turn returns on the error before dispatch) and never appended to messages, so the retry re-sends clean context with no duplication. A visible "[no output — model stalled; retrying N/max]" notice fires per attempt. Forwarded prose still blocks the retry (it would duplicate answer text). Verified: full -race suite green; a 4-dimension root-cause audit and a 3-dimension adversarial review of this diff both passed with no confirmed defects. New tests: TestRunRetriesStallAfterIncompleteToolCall (the exact gpt-5.x case), TestRunRetriesStallAfterOnlyReasoning; preserved TestRunDoesNotRetryStallAfterPartialOutput (prose still blocks retry). * fix(providerio): make ContentStallTimeout overflow-safe Address PR #362 review: idleTimeout*6/5 could overflow int64 for a pathologically large ZERO_STREAM_IDLE_TIMEOUT and wrap to a NEGATIVE duration, which would arm the content-stall timer to fire immediately and abort every stream. Compute 1.2× as idle + idle/5 with a clamp to the max duration on overflow (which just means "effectively no content watchdog", the sane result for an absurd idle timeout). The normal 1.2× values are unchanged (5m->6m, 30s->36s, 100ms->120ms). New test: TestContentStallTimeout covers the scaled values, the zero/disabled case, and the MaxInt64 overflow guard. |
||
|
|
7fb0092de7 |
fix: agent runtime resilience — stream stalls, weak-model tool args, and sub-agent reliability (#349)
* fix(local): defeat the macOS stale-pooled-connection hang (transport + stall-retry)
Root cause (you confirmed it: both chatgpt AND ollama hung, only on macOS): providers
fell back to http.DefaultClient, whose pooled keep-alive connections get reused on a
later turn after the server/NAT silently dropped them. The model call is a POST
(non-idempotent), so Go won't auto-retry it on a fresh conn — it blocks forever.
macOS keeps dead pooled conns around longer than Linux, so it reproduced on macOS only.
1. providerio.HTTPClient now returns a shared, tuned transport (ResponseHeaderTimeout
60s bounds a hung POST that gets no response; IdleConnTimeout 30s stops dead conns
from lingering to be reused). Routed openai/codex through it too (anthropic/gemini
already used HTTPClient). Slow first tokens / long reasoning are unaffected — the
header timeout only bounds time-to-first-response-header, not the stream body.
2. Agent loop now retries a stream idle/stall timeout that produced NO output yet, on
a fresh connection (capped + backoff). A partial-output turn is NOT retried (would
duplicate). Recovers any stall that slips past the transport timeout.
Local only — not pushed.
* fix(local): tolerate concatenated tool-arg JSON + widen response-header timeout
1) Weak models (minimax-m3, glm) pack MULTIPLE concatenated top-level JSON objects
into one tool-call arguments slot ({A}{B}), which json.Unmarshal rejects with
'invalid character {' after top-level value' (~9 failed tool calls/session).
New recoverableToolArguments returns the first JSON value ONLY when the payload is
one value optionally followed by more WHOLE JSON values; it rejects a bad/truncated
first value OR trailing NON-JSON garbage ({"x":1}xyz) so genuine corruption still
errors (adversarial-review fix). decodeToolArguments uses it at the central dispatch
(executeToolCall) so {A}{B} runs the first object; historySafeToolCalls uses the
SAME helper so the replayed transcript records the recovered first object (not {}),
keeping dispatch and history consistent. Truly-malformed JSON still hits the
existing sanitize-to-{} + retry path. Trailing objects are dropped (the model
re-issues on a later turn).
2) Raise the shared transport ResponseHeaderTimeout 60s -> 120s so a slow cloud proxy
(ollama *:cloud) that withholds its 200 header until first token isn't wrongly
aborted; still bounds a truly dead reused connection.
Local only — not pushed.
* feat(local): add /turns command + raise default maxTurns 30->50
Agentic multi-step runs (build + delegate + swarm + MCP + self-verify) routinely hit
the 30-turn ceiling mid-task and never reached the later steps; removing /mode earlier
also left NO interactive way to raise the budget.
- /turns [n]: per-session tool-turn budget setter (commandGroupSession), mirroring
/selfcorrect. Empty/status shows the current budget; a positive int sets
agentOptions.MaxTurns (read by the run at model.go); clamped to maxTurnsCeiling=500
to guard against typos; invalid/non-positive shows usage without changing it.
- defaultMaxTurns 30 -> 50 (matches the old 'deep' preset). Raise per-run with /turns
for long tasks (e.g. /turns 150 for a full acceptance run).
Local only — not pushed.
* fix(local): swarm reports failed members as failed + sub-agents inherit /turns budget
Issue B — a swarm member whose child exited non-zero (e.g. exit 4 / max-turns) was
recorded [done] (swarm_status '0 failed'), so the orchestrator — which can't see the
AGENTS panel — trusted incomplete work. The specialist launcher ignored
res.Result.Status; it now surfaces a StatusError result as a member FAILURE
(errors.New(report)) so the swarm marks it [failed]. Added Coordinator.FailWithSession
and switched the member watch path to it so a failed member keeps its session id
(still drillable) and its report rides along as the failure message.
Issue A — a member hit its OWN turn limit because /turns raised only the parent's
budget; children re-resolved config.json's default. New config.SetMaxTurnsEnv exports
ZERO_MAX_TURNS, read in applyEnv -> cfg.MaxTurns; /turns now exports it so spawned
sub-agents / swarm members (which inherit the env) run with the same budget. A
non-numeric/zero env value never clobbers the configured budget.
Tests: launcher non-zero-exit -> failed (+ session kept); SetMaxTurnsEnv round-trip
+ garbage-env guard; /turns exports + clamps the env. Full gate green (73 pkg).
Local only — not pushed.
* fix(local): corrective error for unknown specialist name + clamp ZERO_MAX_TURNS
1) A model that invents a specialist name (validator-runner, file-creator,
file-writer — none registered) got an opaque 'specialist not found' and kept
spawning doomed sub-agents. loadManifest now lists the available specialists and
notes that 'worker' has shell/edit while explorer/code-review are read-only, so the
model self-corrects to a capable specialist. New availableSpecialistList helper.
2) Defense-in-depth (review nit): applyEnv now clamps an inherited ZERO_MAX_TURNS to
MaxTurnsCeiling, so a raw shell 'export ZERO_MAX_TURNS=999999' can't bypass the
/turns ceiling. Ceiling promoted to config.MaxTurnsCeiling (single source of truth,
referenced by the TUI /turns clamp).
Tests: unknown-name error lists worker/explorer/code-review; applyEnv clamps an
over-ceiling env value. Full gate green (73 pkg).
Local only — not pushed.
* fix(local): surface sub-agent kill signal instead of opaque exit -1
When a sub-agent child is terminated by a signal, command.Wait returns an ExitError
whose ExitCode() is -1, losing the cause. Capture ProcessState.String() (portable,
e.g. "signal: killed") into ChildRunResult.Signal and have BuildFinalResult render
it — "Subagent terminated by a signal (signal: killed) — killed before it finished.
Likely causes: the OS out-of-memory killer ... a timeout, or cancellation." — instead
of an opaque "Subagent failed (exit -1)". The message lists causes rather than
asserting OOM, since timeout/cancellation also SIGKILL the child. A captured signal
always surfaces even if a late run_end reported a clean exit. The background task
path (which can't carry the signal name) emits the same hint for a negative exit code.
* address CodeRabbit review on #349
- loop.go: gate stall-retry on a turn-level forwardedAnything flag (wrapped
callbacks), so a retry never re-streams on top of output already forwarded earlier
in the turn (e.g. across the reactive-compaction retry). New test covers a
reasoning-then-stall (collected.Text empty but output forwarded).
- loop.go: route a reissued stream's non-stall error through the SAME recovery as
the initial stream (image-rejection wrapping / context-limit compaction) via a
shared recoverStreamError closure, instead of returning it raw.
- exec.go: drop the hardcoded worker/explorer/code-review guidance from the
unknown-specialist error; rely on the dynamically-rendered available list (a
custom registry may not have those names).
- streamer.go: kill-signal message lists OOM/timeout/cancellation evenhandedly
instead of asserting OOM (the branch also covers timeouts/cancellations).
- launcher_specialist.go + exec.go: preserve the child SessionID on a post-start
failure too, so FailWithSession keeps those failed members drillable.
- model.go: block /turns while a run is active (mid-run change would make the
inherited ZERO_MAX_TURNS budget inconsistent for later-spawned sub-agents).
|
||
|
|
fb3277f46e |
feat(tui): tabbed ask_user questionnaire in the composer with suggested answers (#327)
* feat(tui): tabbed ask_user questionnaire rendered in the composer
Redesign the interactive ask_user prompt to match the requested mockup: when a
question is raised the composer text box becomes the questionnaire.
- Multi-question prompts render as a row of TABS (one per question + a trailing
Confirm tab). Tab / Shift+Tab switch questions, answers are given in any order,
and Enter on Confirm submits them all. A single-question prompt submits on
answer (no Confirm step) and shows no tab row.
- Each option can carry a one-line DESCRIPTION (contract: per-option
optionDescriptions, or option objects {label, description}); options render
numbered with the description dimmer underneath and the recommended one tagged.
- Per-question optional `header` gives each tab a short title (falls back to a
trimmed question).
- Rendered in the composer/footer region (footerView), replacing the text box
while active, instead of a scrolling transcript card.
- Keys: Tab move · ↑↓ select · Enter confirm · Esc dismiss; a printable key in
the picker drops into free-text; Esc from "type your own" returns to the picker;
multi-select questions are free-text with the options shown as suggestions.
- Headless unaffected (zero exec never wires OnAskUser).
Removed the old single-question sequential state (cursor/typing/index,
renderFocusedAskUserPrompt, resolveAskUser). Tests rewritten for the tabbed flow
(picker default, type-your-own, multi-question tabs + Confirm, Esc dismiss,
descriptions render). make build / go vet / go test ./... -race / make lint green.
* fix(tui): ask_user questionnaire renders on the black terminal canvas
It was painting a gray panel background (styledBlockFill with zeroTheme.panel +
onPanel-wrapped foregrounds). Since the questionnaire replaces the composer, it
should match it: bare foregrounds on the terminal canvas (black) with the
composer's lineStrong border and no fill. The lime cursor/badge highlights and
the recommended tag stay.
* fix(tui): address PR review — schema/parser alignment, single-question Tab
- ask_user schema: options.items is declared as string, so advertise only the
string form (descriptions go via the parallel optionDescriptions array). The
parser stays object-tolerant defensively, but the schema no longer tells
strict-schema providers to send objects it would reject.
- moveAskUserTab no-ops for single-question prompts (no tab strip / Confirm tab),
so Tab/Shift+Tab can't move the prompt into the hidden Confirm state. Test added.
|
||
|
|
1d1fa7c514 |
feat(tui): ask_user suggested answers with a recommended default (#326)
* feat(tui): suggested answers with a recommended default for ask_user When the agent asks a clarifying question mid-task, it can now offer 2-4 suggested answers and mark one recommended; the interactive TUI renders a selector (arrow keys, lime cursor, "(recommended)" tag) with the cursor resting on the recommended option, plus a trailing "✎ Type my own answer…" entry that drops into the existing free-text composer. Whichever the user picks — a suggested option's text or their typed answer — flows through the same answer channel the agent loop already waits on. Optional and backward-compatible: a question with no options renders the plain free-text prompt exactly as before. Headless `zero exec` is unaffected (it never wires OnAskUser; the non-interactive best-assumption fallback stands). - contract: agent.AskUserQuestion / tools.AskUserQuestion gain Recommended; the tool schema advertises optional options + recommended; parser keeps recommended only when it resolves to one of the options (canonical text). - system prompt: tells the model it MAY supply options + a recommended one. - TUI: pendingAskUserPrompt gains cursor/typing; ask_user_prompt.go holds the selector logic mirroring permission_prompt.go; renderFocusedAskUserPrompt renders the picker or the free-text prompt. Tests: selector defaults to recommended and returns its text; "type my own" falls back to free-text and returns the typed text; no-options stays plain free-text. make build / go vet / go test ./... -race / make lint green. * fix(tui): ask_user — single input, Esc steps back from "type my own" Two UX fixes from interactive testing: - No double display: the focused ask card no longer echoes the in-progress free-text answer (it was shown both inside the card AND in the composer text box). The composer is now the single input; the card just shows the question and a hint. renderFocusedAskUserPrompt no longer takes the input string. - Esc from "type my own" returns to the selector for that question instead of cancelling the whole questionnaire (escapeAskUser); a question with no options, or the selector itself, still cancels on Esc as before. Tests: type-my-own asserts the card does not echo the typed text and the hint flips to "type your own answer"; new test covers Esc returning to the selector (no cancel, no answer delivered) then selecting a suggested option. * fix(tui): ask_user review fixes — multi-select, selector typing, dead code Address the three confirmed findings from the code review: - multi-select no longer silently narrowed to one option: a single-pick selector cannot represent MultiSelect, so a multi-select question now renders as free-text with its options surfaced as suggestions; the user types one or more and the answer is returned verbatim (syncQuestionState; escapeAskUser and the renderer updated to match). - typing in selector mode no longer discarded: a printable keystroke while the selector is showing flips into free-text and captures the key (model.go key capture), instead of accumulating in a hidden input that Enter then overrode with the highlighted option. - removed dead submitAskUserAnswer (the Enter handler now routes through confirmAskUser; the orphan had no callers). Tests: multi-select falls back to free-text and returns the typed answer; a printable key in the selector switches to free-text and is captured. Full gate green (build / vet / go test ./... -race / make lint). * fix(tui): address PR review — element-wise option compare, clearer Esc copy - ask_user_test: compare option slices with slices.Equal instead of strings.Join, so the parser test can't be fooled by ["A,B"] vs ["A","B"] or nil vs [""]. - ask_user prompt: the Esc hint said "skip", which reads like "skip this question" on a multi-question prompt even though Esc ends the rest of the questionnaire (remaining answered by best-assumption). Reworded to "skip the rest" in the selector and free-text hints; "type my own" keeps "go back". |
||
|
|
b8b0bb7f65 |
Move deferred tool discovery into tool search (#324)
* Move deferred tool discovery into tool search Keep deferred tool discovery in the tool_search definition instead of appending it as a per-turn user message, so models do not answer or acknowledge internal discovery metadata. Preserve deferred loading behavior: hidden tools remain hidden until loaded, tool_search stays reachable for allowlisted deferred tools, and retry paths keep loaded/deferred state. Tested: env GOCACHE=/tmp/zero-go-cache go test ./internal/agent ./internal/tools -run 'Test.*Deferred|Test.*ToolSearch|TestRunLoadsDeferredToolThenAdvertisesNextTurn|TestRunReactiveRetryKeepsLoadedDeferredToolAndDiscovery|TestRunConnectTimeReactiveRetryKeepsLoadedDeferredToolAndDiscovery|TestAllowlistedDeferredToolsKeepToolSearchReachable|TestDisabledToolSearchFallsBackToEager' Tested: env GOCACHE=/tmp/zero-go-cache go test ./internal/tools Tested: env GOCACHE=/tmp/zero-go-cache go test ./internal/agent ./internal/tools ./internal/cli Tested: git diff --check * Clarify deferred tool search selection |
||
|
|
f8ac5eceb0 |
fix(agent): gate headless run completion on honesty (no false-success on no-tool-call / self-reported-incomplete turns) (#325)
* fix(agent): don't end a run as success on a no-tool-call turn mid-task
A turn that produced text but no tool call was always accepted as the final answer, so the loop reported success even when the model stopped mid-task (e.g. ended on "...Let me check the SSH configuration:" with plan steps still pending).
Add an opt-in completion gate (Options.RequireCompletionSignal): when a turn has no tool call and work clearly remains -- pending update_plan items, or the message ends on a continuation cue -- re-prompt the model to continue instead of finalizing. Bounded by maxContinueNudges and still by MaxTurns/the deadline; once the budget is spent the run finalizes as INCOMPLETE (Result.Incomplete) rather than success. Default off, so the interactive path is byte-identical.
Genuine single-turn completions (no pending plan, no cue) still finalize as success. Covered by internal/agent/completion_gate_test.go.
* feat(exec): report stalled headless runs as INCOMPLETE (exit 4)
Enable the agent completion gate for headless exec (RequireCompletionSignal) and map Result.Incomplete to run_end status "incomplete" with a new exit code 4, so a run that stalled mid-task (model stopped without a tool call while work remained, continue budget exhausted) is no longer reported as success. Interactive callers are unaffected.
* fix(agent): downgrade self-reported non-completion; advisory task-grounded acceptance
Reduce -- not eliminate -- false-success on headless runs. Two DETERMINISTIC, unit-tested gates (with the plan gate from the prior commit):
(a) self-report downgrade: if the final message admits the model guessed or could not meet the objective, finalize INCOMPLETE (exit 4), never success. Inability is matched by first-person STEMS generalized over verb/tense ("I cannot/can't/could not/am unable to/do not have/unable to ...") plus guess/fallback/uncertainty phrases, with a guard so success-y negations ("could not find any issues", "cannot reproduce") are not misread. (b7bc0b8's plan gate already forces INCOMPLETE on pending/in_progress update_plan items at termination.)
(b) task-grounded acceptance is ADVISORY, not a guarantee. When --self-correct is on it demands one bounded acceptance pass that re-derives the task's stated criterion and runs a concrete check, discouraging three false-success patterns (well-formed==correct, existing-tests-pass==objective-met, result==baseline-it-was-told-to-beat). But it is a prompt: a model that ignores it and confidently claims "PASS, all requirements met" still slips, because ZERO has no general oracle to verify correctness against a task's hidden criterion. Empirically (TB-2, qwen3-coder:480b) this reliably catches admissions and incomplete plans and REDUCES false-success, but a confident false PASS on a model-ceiling task is a residual, fundamental gap -- not a tuning miss.
Default off (RequireCompletionSignal); interactive callers are byte-identical. Covered by internal/agent/{acceptance_gate_test.go,completion_gate_test.go}.
* feat(exec): surface the INCOMPLETE reason in run_end and logs
When a headless run finalizes as INCOMPLETE, include Result.IncompleteReason in the session error event and a stderr warning so an honestly-incomplete run (e.g. "the final message admits the objective was not met") is debuggable rather than an opaque exit 4.
* fix(agent): harden completion gate per PR review
Addresses the correctness findings from the PR review.
self-report (#1): drop behavior-describing phrases ("fall back to",
"placeholder value", bare "best guess", "as a fallback", "without proper")
that also match legitimate final answers; keep first-person/uncertainty
admissions only, since these are matched without a context guard.
self-report (#5): scan every occurrence of each inability stem so an early
success-negation ("could not find any examples") no longer masks a later
genuine admission with the same stem ("could not implement it").
continuation cue (#6): require a trailing colon AND an action lead-in on the
final clause; stop flagging recommendations, plain summary colons, and
sign-offs. Still catches the mid-line "...Let me check the config:".
gate order (#8): check the self-report admission BEFORE pending-plan, so an
admitted-impossible task downgrades immediately with the accurate reason
instead of burning continue-nudges.
pending plan (#3): treat a pending/in_progress update_plan item as a
NUDGE-only weak signal -- it no longer forces INCOMPLETE on its own (a
completed run that left stale plan bookkeeping is trusted). Only a
continuation cue or a self-report admission finalizes INCOMPLETE.
max-turns (#4): a run cut off at the MaxTurns ceiling now finalizes
INCOMPLETE under the gate instead of being reported as success.
exec json/cron (#2, #7): for -o json, emit the terminal done with exit 4 on
an incomplete run (final() pre-emits a success done:0 for json that would
otherwise mask it); emit an error event -- not just a warning -- so the cron
failure extractor can recover the reason.
Deferred (noted on the PR): acceptance-only-when-mutated (#9, cost) and
reusing tools.normalizePlanStatus (#10, would widen scope to internal/tools).
Tests: add TestContinuationCueMatching and TestMaxTurnsCutoffIsIncompleteUnderGate,
extend TestSelfReportedIncompletionMatching with the #1/#5 cases, and replace
the in_progress=>incomplete test with TestPendingPlanAloneDoesNotForceIncomplete.
make build / go vet / make lint / go test ./... -race all green.
|
||
|
|
d29ab98a79 |
Improve sandbox permission handling (#323)
Retry shell commands outside the sandbox when sandboxed output only reflects the sandbox namespace, and keep network retries sandboxed when a network grant is enough. Show user-facing permission reasons for escalated shell commands instead of exposing internal sandbox policy text. Tested: env GOCACHE=/tmp/zero-go-cache go test ./internal/agent ./internal/tui -run 'TestRunUsesEscalatedJustificationForPermissionPrompt|TestRunUsesUserFacingEscalatedFallbackForPermissionPrompt|TestPermissionPromptMapsEscalatedSandboxReason|TestRunRetriesShellUnsandboxedAfterSandboxNamespaceLimitedOutput' Tested: env GOCACHE=/tmp/zero-go-cache go test ./internal/tools Tested: env HOME=/tmp/zero-test-home-clean XDG_CONFIG_HOME=/tmp/zero-test-config-clean GOCACHE=/tmp/zero-go-cache go test ./... |
||
|
|
80ab238361 |
Align sandbox command approval behavior (#322)
* Align shell approval sandbox execution Make approved shell command-prefix executions use the escalated sandbox path when denied-read policy allows it, so remembered approvals do not still hit the restrictive sandbox. Allow auto sandbox mode to degrade to a direct command plan when native command wrapping is unavailable, while strict sandbox require continues to fail closed. Update regression tests for degraded execution, prefix-approved shell calls, and unavailable native sandbox metadata. Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/agent ./internal/sandbox Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/tools -run 'TestRegistryRunsWithDegradedUnavailableNativeSandbox|TestBashToolRunsWithDegradedUnavailableNativeSandbox|TestBashRequireEscalatedBypassesNativeSandboxAfterApproval|TestExecCommandRequireEscalatedBypassesNativeSandboxAfterApproval' Tested: GOCACHE=/tmp/zero-go-cache go test ./... -run '^$' Tested: git diff --check * Align sandbox command approval behavior Evaluate reusable shell approvals across plain command segments so prefix grants can cover commands with safe read-only tails without relying on command-specific sandbox exceptions. Keep process inspection commands sandboxed by default; unsandboxed host access continues to require explicit escalation or a narrow command-prefix approval. Improve shell tool guidance around sandboxed additional permissions, full escalation, justifications, and narrow reusable prefixes. Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox ./internal/tools ./internal/agent Tested: env HOME=/tmp/zero-test-home-clean XDG_CONFIG_HOME=/tmp/zero-test-config-clean GOCACHE=/tmp/zero-go-cache go test ./... * Normalize explicit sandbox backend platforms Infer the platform from a caller-provided sandbox backend name before choosing the manager runtime platform. This keeps Linux, macOS, and Windows backend plans deterministic across host OSes when tests or callers provide a backend without an explicit Platform field. Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox ./internal/tools Tested: env HOME=/tmp/zero-test-home-clean XDG_CONFIG_HOME=/tmp/zero-test-config-clean GOCACHE=/tmp/zero-go-cache go test ./... |
||
|
|
f1502f0bfe |
Add local control browser and terminal tools (#319)
* Add local control browser and terminal tools Add configurable local browser, desktop, and terminal control helpers with packaged helper discovery for install and release flows. Improve sandbox scope classification for local control commands and shared temporary paths. Add structured browser actions, terminal session line input/key normalization/read support, and artifact capture for local-control outputs. Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/localcontrol ./internal/tools ./internal/cli ./internal/config -run 'Test.*LocalControl|TestTerminal|TestBrowser|TestDesktop|TestCapture' Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/localcontrol ./internal/tools ./internal/cli ./internal/config ./internal/agent Tested: tuistory smoke launched bash, sent interactive input, waited for expected output, and captured a no-cursor snapshot Tested: git diff --cached --check * Fix npm wrapper helper manifest test on macOS Canonicalize expected helper paths in the npm wrapper manifest test so macOS /var and /private/var temp path aliases compare correctly. Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/npmwrapper Tested: git diff --check * Harden local control review fixes Respect resolved local-control config before tool listing and filter validation while preserving stream-json usage error ordering. Harden browser, desktop, and artifact tool validation; guard Linux-only app launch behavior; clear stale helper manifests from the npm wrapper. Make helper packaging lockfile-based with npm ci and add installer/package tests for helper staging. Tested: focused local-control/tool/CLI/release/install/sandbox suite Tested: isolated full go test ./... Tested: macOS and Windows compile-only checks for touched packages |
||
|
|
46c44a8753 |
feat(swarm): let swarm members build (write + sandboxed shell) (#313)
* feat(swarm): let swarm members build (write + sandboxed shell) Swarm subagents were effectively read-only: a member runs headless at "--auto low" (PermissionModeAuto), and ToolAdvertised strips every prompt-requiring tool from the model's list — so write_file/edit_file/ apply_patch/bash were never handed to the member. It only saw read/grep/ glob/plan tools, so it could not build and the orchestrator did all the writes itself. Add PermissionModeMemberAuto: a headless mode that ADVERTISES the in-workspace mutators (SideEffectWrite + SideEffectShell) on top of the Auto set, while behaving exactly like Auto everywhere else. The sandbox engine still decides at call time — in-workspace writes and sandbox-backed shell auto-allow; out-of-workspace writes, network, and destructive commands prompt and so are denied headless. member-auto normalizes to Auto in the sandbox layer, so no sandbox authority is widened beyond what an interactive auto agent already has. Scope: swarm members only. The launcher sets MemberAutonomy; the executor emits "--auto member" for a non-unsafe member (unsafe still -> high, plain specialists still -> low). Task-tool specialists are unchanged. Windows note: shell only auto-runs when the OS sandbox is active; without `zero sandbox setup` a member writes files but cannot run builds. * style: gofmt member_auto_test.go |
||
|
|
6be50cd3a7 |
fix(sandbox): retry approved network shell commands (#301)
* fix(sandbox): retry approved network shell commands * test(sandbox): fix portable smoke expectations * fix(sandbox): restore Windows offline egress block |
||
|
|
63c0e278ff |
feat(tui): chat + context sidebar redesign, AGENTS panel, animations (#300)
* feat(tui): chat + context sidebar redesign, AGENTS panel, animations, plan-timer fix
Two-column managed-mode layout: the existing chat (scroll engine, reduced width)
on the left + a new right context sidebar — AGENTS (spawned subagents: status
glyph, name, and a live working detail for running ones, with a running/total
count), the live PLAN, and a token readout. The sidebar is suppressed on the
home screen and under overlays/subchat; alt-screen managed mode, wide terminals.
Also: freeze the plan clock while the agent is idle (an in_progress step left
mid-plan when the agent yields stops ticking instead of counting forever), and a
clean-room animation port (phase scroll indicator + a cosine ripple working
line; the spinner was already animated, frozen under ZERO_REDUCED_MOTION).
Reuses the scroll/viewport engine; inline mode unchanged. New unit tests for the
sidebar, plan-clock freeze, and the pure animation helpers; go build/vet/test green.
* feat(swarm): live AGENTS sidebar + un-defer coordination tools
Sidebar AGENTS section now reflects the swarm/team accurately:
- name each member by a short 1-2 word task (not subagent-N)
- a mild, slow cool pulse on live member names (no per-letter flicker)
- members disappear when swarm_status reports them [done]/[failed]
- hide bogus "specialist not found" tool-misroute attempts
- keep the animation tick alive while sidebar agents exist
Plus the deeper fix so a coordinating model stops misrouting swarm calls
to the specialist tool ("specialist swarm_send not found"): the swarm
COORDINATION tools (swarm_send/status/inbox/collect) un-defer once a
swarm is active, while spawn/schedule/handoff stay discoverable via
tool_search. A new DeferralEligible() interface keeps all swarm tools
counting toward DeferThreshold even when exposed, so un-deferring can
never deactivate deferral / force-expose MCP tools in any config.
* feat(tui): polish pass — hierarchy, status line, motion, sidebar toggle
A grounded polish pass across the TUI (17 items):
- color: spread the gray tiers (AA-safe), lime working-ripple (no semantic
green/amber), calm steady ask-label, dim completed plan-step bodies,
bold-muted sidebar headers + stateful PLAN count
- status line: run-state chip (permission mode + effort) replaces the
duplicated provider; composer rule is model-only; context-fill gauge down to
the narrow tier + a sidebar context-% chip
- footer: persistent idle key-hint (managed mode) + a jump-to-bottom cue
- motion: pulsing streaming caret; running specialist spins; swarm/specialist
agents linger with a fading ✓ before removal (no abrupt pop)
- layout: Ctrl+B toggles the sidebar; padded ' │ ' divider; sidebar gated to
the medium tier so it never starves the chat
- home screen: cwd · branch · model orientation + example prompts
- cleanup: drop dead autoTag, fix 'six swarm tools' miscount
go build/vet/test green; -race clean; no hex outside theme.go.
* feat(tui): reading column for the chat transcript
Cap transcript body rows at a readable ~90-col measure and indent them by a
small left gutter, so on a wide terminal the chat text no longer runs
edge-to-edge. Only true transcript rows use the reading column; the frame,
composer, status line, sidebar, title bar, empty state, and prompts keep the
full chatColumnWidth. Streaming text shares the column so it doesn't snap on
finalize. Plus extra vertical spacing before headings and between paragraphs.
Implementation keeps the height cache valid (rows render at contentWidth, which
the width-keyed cache already captures) and the gutter is a horizontal post-pad;
each selectable line's textStart is shifted by the gutter so click-to-select and
the selection highlight stay aligned. Mouse card/toggle hit-testing is Y-based,
so it's unaffected. go build/vet/test green; -race clean.
* fix(tui): plain cancellation marker + copyable card/toggle rows
- 'Run cancelled.' renders as a plain amber '⊘ Run cancelled.' line instead of a
heavy box (other system notices keep their box).
- Copy root cause: the app's text-selection skipped 'button' rows (collapsible
toggles, specialist cards) — they carried no selectable text, so a drag-select
omitted exactly that (dull) content. Fix: those rows now carry their plain text
+ start, so a selection dragged THROUGH them copies their content; a direct
click still toggles/drills (resolved on press, before selection). The selection
highlight no longer excludes them. Permission buttons stay excluded.
- Surface Ctrl+E in the idle hint — it releases the mouse for native terminal
selection, which copies everything (the zero-risk full-copy path).
go build/vet/test green; -race clean.
* fix(tui): render all system notices as plain lines, not boxes
Every system notice (mouse-mode changes, mode set, run cancelled, etc.) now
renders as a plain marked line instead of a bordered box: a faint '·' marker +
muted text for info, amber '⊘' for a run cancellation. Multi-line notices keep
the marker on the first line and indent the rest. Command cards and error rows
keep their boxes. go build/vet/test green.
* fix(tui): separate think→act tool groups with a blank line
Reasoning headers (Thought for X) interleave the tool cards (tool → thought →
tool …), which broke the existing tool-card→tool-card gap rule, so consecutive
list_directory/read_file/update_plan groups packed into a dense wall. Now a
reasoning header that follows a tool card gets a blank line above it, so each
think→act group reads as a distinct block. go build/vet/test green.
* feat(tui): colour markdown emphasis + headings for readability
Assistant prose now reads as three tiers instead of one flat ink colour:
- body text stays ink
- bold / important words render in the brand accent (lime) so they pop
- markdown headings render accent + bold + underline as a clear top tier
go build/vet/test green.
* fix(tui): calmer prose colours + a working-phase label
- Revert the accent colour on bold/important words: emphasis is weight-only now
(no colour), dark-mode-friendly.
- Headings drop the bright lime — distinguished by ink + bold + underline instead
of a saturated accent colour.
- Working status line gains a phase label ('writing' while the answer streams,
'thinking' otherwise) so a long, output-less step reads as live progress
('Working · thinking · 55s') rather than a frozen screen.
go build/vet/test green.
* fix(tui): paint selection highlight in the gutter-shifted coordinate
Mouse text-selection looked like it started in the wrong place: the highlight
landed a few cells right of where you clicked, so selecting/copying felt
impossible to aim.
Cause: the row renderers painted the selection highlight on the UNshifted,
unpadded line, then the body item applied the reading-column gutter (pad lines +
shift selectable textStart) afterward. But the stored selection points are in the
shifted/displayed coordinate (that's how the mouse maps them), so the highlight
was computed against unshifted textStart and landed gutter-cells off. (The copied
text was actually correct — extraction uses the shifted selectable — but the
visual selection was wrong, which is what made it un-aimable.)
Fix: stop highlighting inside the row renderers; add finalizeTranscriptBodyRow,
which pads + shifts THEN paints the highlight, so it's computed in the same
coordinate the mouse uses and lands exactly on the click. Also applies the
highlight uniformly to all rows + the interim block. Regression test added.
|
||
|
|
f7ee189ba5 |
fix: resolve 15 findings from the 2026-06-20 deep audit (#281)
* docs(audit): 2026-06-20 deep adversarial audit report
Audit-only deliverable (no source changed). Fresh origin/main (
|
||
|
|
b2bb6c4b41 |
feat: five capability features from the reference-agent comparison (#276)
* feat(commands): user-defined slash commands from markdown files
Drop a markdown file at `.zero/commands/<name>.md` (project, shared with the
team) or `<userConfigDir>/zero/commands/<name>.md` (personal); typing
`/<name> args` expands its body template and submits it as a normal prompt.
Optional YAML frontmatter (`description:`, `model:`, `agent:`) feeds help text
and routing. This turns Zero's model-pulled skills into user-invokable,
repo-checked-in team workflows (`/release v1.2`, `/fix-flaky <test>`).
New internal/usercommands package: Load() scans the project + user dirs
(project shadows user on name collision, mirroring specialist scope
precedence), validates names to a safe slash-command shape, and parses
frontmatter with a self-contained parser. Expand() substitutes $ARGUMENTS,
$1..$9, and $$ (literal $); a placeholder-free template gets the raw args
appended so a trivial command still receives input.
Wiring: model loads commands at construction; the commandUnknown dispatch
checks the user set before reporting "unknown"; autocomplete lists user
commands alongside builtins. Builtins win on collision (listed first, shadow at
dispatch). Tests cover load/precedence/name-validation, the placeholder
substitution table, and the TUI dispatch + autocomplete + still-unknown paths.
* feat(init): guided /init that generates AGENTS.md seeded with repo facts
`/init` (TUI) and `zero init` (headless) investigate the repo and write a
concise AGENTS.md so future runs start with project context. Zero already has
the AGENTS.md loader and the repoinfo scanner, but the file only existed if a
user hand-authored it — this activates the machinery in one command.
New internal/agentinit.BuildPrompt seeds the bootstrap prompt with
repoinfo.Collect() output (primary language, build/test tools, CI, workspace
layout, git) formatted as a fact block, then instructs the agent to investigate
the gaps and write a tight AGENTS.md (build/test commands, conventions, things
to avoid). Collection failure is non-fatal — the agent investigates from
scratch. This is one better than a pure-prompt /init: the agent starts from
known facts instead of re-deriving them.
TUI: /init command + handleInitCommand reuse launchPrompt (standard run, tools,
AGENTS.md write path). Headless: `zero init` builds the prompt and forwards it
through the existing exec machinery, so all exec flags (--model, -w, --add-dir)
work unchanged. Tests cover FormatFacts (rendered/omitted fields), BuildPrompt,
and the TUI dispatch.
* feat(tools): lsp_navigate — semantic code navigation via the language server
A read-only, model-callable tool exposing LSP navigation: jump-to-definition,
find-all-references, find-implementations, and workspace_symbol search. The
agent can answer "where is X defined / who calls X / what implements this
interface" precisely, where grep collides on common names or misses indirect
references — cutting wasted read/grep turns and wrong-edit risk on large repos.
Zero already runs language servers for diagnostics, so this adds the
request/response navigation methods on top: Manager.Navigate syncs the file
(didOpen) then issues textDocument/definition|references|implementation or
workspace/symbol via the existing client.Call, decoding all three result shapes
(Location, []Location, []LocationLink). 1-based file:line:col in and out
(converting LSP's 0-based positions); the tool has its own lazily-started
manager (servers start on first use, reused across calls).
Opportunistic: a file type with no installed server, or a server lacking the
capability, degrades to a clear "unavailable — fall back to grep" message, not
an error. Registered in CoreReadOnlyToolsScoped. Tests cover the response-shape
decoding (incl. LocationLink + null), symbol decoding/kinds, unknown-op and
unsupported-extension degradation, and the tool's arg validation + read-only
safety.
* feat(agent): prune stale tool output before paid summarization
Long, dump-heavy sessions accumulate large read_file/grep/glob/bash tool
results the model has already acted on; their bodies are dead weight the loop
otherwise keeps paying an LLM summarizer to compress. Add a zero-cost prune
stage inside maybeCompact, before the summarizer: when over threshold, replace
the bodies of OLD, large tool results with a compact placeholder, recompute
size, and only fall through to the paid Compact() if still over.
pruneStaleToolOutput walks newest-first, protects the last preserveLast
messages plus a trailing 40k-token window of tool output (most likely still in
use), and prunes only tool results older than that and larger than 200 tokens —
gated so the whole pass is a no-op unless reclaimable output exceeds 20k tokens.
It keeps the tool message and its ToolCallID (provider replay stays valid; the
model knows what was there and can re-run the tool), never touches non-tool
messages, never re-prunes a placeholder (idempotent), and copies-on-write so it
never mutates the caller's slice. Recent turns are preserved verbatim — no
paraphrase loss, no token or latency cost.
Tests cover the reclaim gate, recent-window protection, idempotence,
non-tool-message safety, ToolCallID/role preservation, and no-input-mutation.
* feat(agent): reconnect on a transient model disconnect instead of ending the run
A long autonomous task (a big refactor, a swarm member, a headless/cron run)
should survive a single transient upstream hiccup rather than dying and
re-burning every token on a restart. When the initial StreamCompletion connect
fails with a disconnect-shaped error, streamWithReconnect re-issues the SAME
request with exponential backoff up to 2 times.
It retries only the connect — before any content is forwarded — so no
already-streamed OnText is ever duplicated. A context-limit error (the
compactor's job), an image rejection, a non-disconnect error, or a cancelled
context is returned immediately without retry, so the reconnect never fights
the existing handlers. Disconnect classification matches EOF / connection
reset|refused|closed / broken pipe / timeout / 502 / 503 / server-closed.
Reconnect attempts surface through OnReasoning ("connection lost —
reconnecting N/max"), a non-content channel that never corrupts the answer
text; silent when there's no reasoning sink.
Tests: recover-from-transient, give-up-after-max, no-retry-on-context-limit,
stop-on-context-cancel, the disconnect-vs-not classifier, backoff growth, and
the notice routing.
* fix(specialist): add lsp_navigate to knownToolNames
The new lsp_navigate tool was added to the core read-only registry but not to
specialist.knownToolNames, which mirrors the core tool set — so
TestKnownToolNamesMatchCoreRegistry failed (the list drifted from the registry).
Add it to the read-only group to match.
* fix(tools): confine lsp_navigate to the workspace (close arbitrary-file-read)
Review caught that lsp_navigate dropped the PathScope every sibling read-only
tool (read_file/glob/grep) carries, and its readFile joined or used the
model-supplied path verbatim — so `path:"/etc/passwd"` (absolute) or
`path:"../../etc/..."` (escaping) read arbitrary files off disk AND opened them
into the language-server subprocess, reachable via indirect prompt injection.
Add NewScopedLSPNavigateTool(workspaceRoot, scope) (NewLSPNavigateTool now
delegates with nil scope), resolve the path through resolveScopedReadPath BEFORE
reading or building the LSP request — the same confinement the neighbouring
tools enforce — and hand the LSP manager the resolved absolute path so it never
opens an out-of-workspace file. Errors echo only the relative path, never an
absolute one. Registered via the scoped constructor in CoreReadOnlyToolsScoped.
Regression test asserts `/etc/passwd` and `../../` are rejected for both the
position and workspace_symbol ops; verified by mutation (bypassing the scope
resolution makes the test fail).
|
||
|
|
c572791edf |
feat: clipboard image paste + provider 400 handler for image rejection (#268)
* fix(agent): abort on image-rejection 400s instead of retrying in a loop
* feat(imageinput): add ReadClipboardImage for OS clipboard image extraction
* feat(tui): clipboard image paste — detect and attach screenshots from OS clipboard
* fix(imageinput): use temp file for Windows clipboard image (PowerShell stdout can't emit raw binary)
* test: handle both image-present and image-absent clipboard states
* fix(vision): add kimi-k2 and qwen-vl to vision-capable name heuristic
* fix(tui): render image errors as red error rows instead of grey system notes
* feat(vision): check models.dev InputModalities for vision support instead of only name heuristics
The vision gate now checks three sources:
1. Curated model registry (catalog authority + name heuristic)
2. Discovered model list from models.dev (InputModalities contains 'image')
3. Name heuristic fallback for unknown models
This means custom/ollama/cloud models (kimi-k2, qwen-vl, etc.) are correctly
detected as vision-capable when models.dev reports them with image input
modality — no more manual name matching needed.
* chore: gofmt clipboard_test.go
* fix(imageinput): remove shell injection in the Linux clipboard reader
The Linux clipboard reader built shell commands by concatenating a
clipboard-derived MIME type into `sh -c`:
exec.Command("sh", "-c", "wl-paste --type "+t+" 2>/dev/null")
exec.Command("sh", "-c", "xclip ... -t "+t+" -o 2>/dev/null")
`t` comes from `wl-paste --list-types` / `xclip TARGETS` and is only filtered by
`HasPrefix(t, "image/")`, so a hostile clipboard offerer could register a target
like `image/png; rm -rf ~` — it passes the prefix check and then executes
through the shell. A real (if low-likelihood) local code-exec vector.
Drop the shell entirely: every helper now runs via exec.Command(prog, args...)
with the MIME type passed as a discrete argument (matching the existing no-shell
pattern in pdf.go). Stderr is discarded by not assigning it. Factored into
runClipboardStdout + imageMIMETypes helpers.
Test: imageMIMETypes filters to image/* lines and returns even an
injection-shaped type verbatim (safe now that it never reaches a shell).
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* test(tools): de-flake TestWriteStdinInterruptTerminatesSession
The test had two Windows-CI flakes that reddened unrelated PRs:
1. It relied on write_stdin's 1000ms yield window being long enough for the
async SIGKILL + reap to land before asserting the session was no longer
running. On a loaded runner the reap exceeded that window, so write_stdin
reported "still running" and the assertion failed. Fix: after terminating,
wait on session.done deterministically (30s safety timeout that fails loudly
if the kill genuinely hangs); the common case returns the instant the
process exits.
2. The SIGKILL'd child had the test's t.TempDir() as its cwd, and Windows may
not release the directory handle the instant the process is reaped — so
t.TempDir()'s immediate RemoveAll on cleanup failed with "being used by
another process". Fix: resilientTempDir retries RemoveAll for up to 5s
(best-effort; a leaked temp dir never fails the test).
Verified 20x locally; full internal/tools suite green.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
|
||
|
|
cf54e2cb0e |
Add persistent exec command sessions (#267)
* Add persistent exec command sessions Introduce exec_command and write_stdin tools backed by a shared session manager so long-running shell commands can remain alive and be polled or interrupted across tool calls. Wire exec_command through the existing shell permission, network prompt, command-prefix approval, sandbox grant scope, specialist manifest, and prompt/documentation paths. Keep web_fetch limited to public remote HTTP(S) URLs so localhost/private URLs go through shell commands where sandbox network permission applies. Tested: git diff --cached --check * Fix exec session lifecycle review issues |
||
|
|
d3e7b89eb0 |
TUI: live code-streaming preview, theme system, plan/Task transcript dedup (#262)
* tui: live code-streaming preview, theme system, plan/Task dedup - Live "writing <file>" preview: a long write_file/edit_file/apply_patch now streams the code as the model generates it (path + line count + a color-coded tail) instead of a frozen spinner. Tool-call argument deltas are forwarded zeroruntime -> agent -> TUI (OnToolCallStart/Delta). - Theme system: auto dark/light detection via a terminal-background probe, a /theme [auto|dark|light] command, a refreshed palette, and streaming fade auto-disabled on low-color / SSH / tmux terminals. - Plan/Task transcript dedup: update_plan collapses to a one-line summary (the live plan panel owns the detail), and Task delegations render only as the specialist card, not also as raw tool-call/result rows -- in both the live and resume paths. Robustness for the streaming preview: tolerate whitespace in tool-call JSON, extract edit_file's new_string, clear the preview on tool finalize / cancel / run-end (no stale or duplicate previews; a stale run can't wipe the active one), color new content green (never red for a leading '-'), and truncate by display width. * address M10: keep failed Task delegations on resume The unconditional Task tool-row skip on resume also hid a Task that failed before a specialist started (there is no card to replace it). A pre-pass now collects the tool-call ids that actually started a specialist; the Task tool-call/result rows are skipped only for those, so a failed delegation and its error stay visible on resume. * streaming preview: decode tool-call args incrementally (O(n) not O(n^2)) The live "writing" preview re-scanned the whole accumulated args buffer on every delta and grew it via string concat, so a large write_file/edit was quadratic and janked exactly during the long write it targets (M14/L6/L7). Replace the per-render decode with a streamingDecoder that consumes each fragment once: it buffers the head until the content value begins (extracting the path), then decodes the content's JSON-string escapes as they arrive, keeping only a bounded tail of lines plus a running line count. The view now reads that pre-computed state in O(tail) per render. Handles whitespace, escapes split across fragments, \uXXXX, and the closing quote; unit-tested including byte-by-byte vs whole-buffer equivalence. |
||
|
|
b237e9849a |
feat(tui): sticky plan panel + specialist task cards + task table overlay (#255)
* feat(tui): sticky plan panel + specialist task cards + task table overlay Three new TUI surfaces that give users live visibility into agent progress and specialist delegation, so long turns and subagent work never look frozen. Sticky plan panel (plan_panel.go): - Pinned between the title bar and transcript body, shows the agent's step-by-step plan from the update_plan tool as a live checklist. - Header with spinner + progress count + elapsed; text progress bar; per-step list with status icons (✓ completed, ⠹ in_progress, ○ pending, ✗ failed) and individual step timings. - Auto-collapses to a one-line green summary 30s after completion. Ctrl+P toggles expand/collapse. - Plan state synced from update_plan tool results via CurrentPlan(); timestamps preserved across the tool's full-replacement updates by matching on content. Specialist cards (specialist_card.go): - New rowSpecialist transcript row kind; EventSpecialistStart/Stop now handled in session.go hydration (previously fell through to default and were silently dropped on resume). - Animated card with spinner + name + task + elapsed + token count + tool call count. Blue while running, green when done, red on error. - specialistTracker tracks live state; Task tool calls/returns are intercepted in the OnToolCall/OnToolResult callbacks to start/complete tracking and emit card rows. - Height cache marks running specialist rows unstable so the spinner animates. Task table overlay (task_table.go): - Ctrl+G toggles a full-width overlay showing all specialists in the session as a sortable table: #, Specialist, Task, Status, Time, Tokens, Tools. - Summary bar with running/completed/error counts, total tokens, aggregate elapsed. - Arrow keys navigate rows; Esc closes the overlay. Tests: plan_panel_test.go (7 tests), specialist_card_test.go (11 tests), task_table_test.go (8 tests). All existing TUI tests pass. * fix(update_plan): expose structured nested item schema so models stop sending bad arguments The plan property's Items schema was omitted because of an outdated comment claiming the serializer drops nested Properties/Required. The serializer (propertyToRuntimeMap) already handles them recursively. Without the nested schema, weaker models guessed the item structure from a text description and sent malformed arguments, hitting the 4-strike guardrail halt. Now exposes Items as an object with content (string, required), status (enum), and notes (string). * fix(tui): route plan + specialist updates through message sink, not stale closures The OnToolCall/OnToolResult callbacks captured model by value, so m.plan.updateFromItems() and m.specialists.start/complete() mutated stale copies that never reached the live model. Introduced planUpdateMsg, specialistStartMsg, and specialistCompleteMsg tea.Msg types sent through runtimeMessageSink, handled in updateModel() where they mutate the live model. * feat(tui): subchat drill-in for specialist sessions Enter on a task table row loads the child session's events and swaps the transcript view to show the subagent's full conversation (user messages, agent text, tool calls, results). A nav bar at the top shows the specialist name/task and a back hint. ArrowUp or Esc pops back to the main chat at the saved scroll position. - subchat.go: subchatState with enter/exit, renderSubchatNavBar - transcript_selection.go: transcriptBodyItemsFromRows renders arbitrary rows (reuses the same rendering pipeline as the main transcript) - model.go: Enter on task table row triggers drill-in; ArrowUp/Esc exits; transcriptView() short-circuits to child rows when subchat is active * fix(ci): strip CRLF line endings and BOM from plan_tool_test.go and update_plan.go gofmt and git diff --check failed on ubuntu CI because PowerShell's Set-Content wrote UTF-8 with BOM and CRLF. Stripped both to plain LF without BOM. * feat(tui): add renderLeftRuleCard helper for borderless specialist cards * feat(tui): switch specialist cards to left-rule rendering, drop hint line * feat(tui): add renderSpecialistSummary one-line rollup for specialist cards * fix(tui): correct slice offset in renderSpecialistSummary and add pluralization test The muted tail was sliced at summary[len(spinnerView)] but the spinner sits at byte offset 2 (after the 2-space indent), so the slice cut into the spinner's UTF-8 continuation bytes and dropped the indent. Slice at 2+len(spinnerView) instead. Also add a test asserting '2 errors' is present and '2 error' (singular) is absent when two specialists errored. * refactor(tui): remove task table overlay — cards carry everything now * feat(tui): render specialist summary line above cards in transcript * feat(tui): mark specialist card lines as clickable selectable rows * feat(tui): click specialist card to drill into subchat, Enter as keyboard fallback * tui: clear selectedSpecialistID after Enter drill-in Fixes Task 7 review issue: selectedSpecialistID persisted after Enter drill-in, causing a subsequent Enter to re-drill into the specialist's subchat instead of submitting the composer. Clear it immediately after subchat.enter succeeds so the keyboard fallback is one-shot per click; click re-sets it, so click->Enter still works. * chore: gofmt model.go + test files * chore: gitignore .superpowers/ and docs/superpowers/ — local skill artifacts * fix(tui): remove Enter keyboard fallback for subchat drill-in — mouse click only * fix(tui): reconcile specialist childSessionID from tool call ID to real session ID on completion * feat(specialist): add Progress callback field to RunOptions and TaskRunOptions * feat(specialist): stream child stdout line-by-line via Progress callback * feat(agent): wire OnToolProgress callback for specialist child events * feat(tui): show live tool-call progress in specialist cards * fix(swarm): update RunChild mock signature for new progress callback param * fix(tui): render plan panel inline in transcript, not pinned at top * chore: gofmt model.go, specialist_card.go, specialist_click_test.go * fix(tui): address PR review - plan panel without specialists, stream reader safety, resume dedup + payload keys |
||
|
|
ac6addaa83 |
Align sandbox permission blocks and backend states (#253)
* Prompt for shell network permissions Route network-risk shell calls through the existing permission prompt instead of hard-denying them at sandbox preflight. Approved network prompts apply the current turn/session request-permissions overlay so the effective sandbox policy is widened only after user approval. Move network command detection into the shell AST analyzer for package installers, gh/git network subcommands, and python http.server so quoted process patterns such as pkill -f "python3 -m http.server 8000" are not misclassified as network activity. Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox -run 'TestAnalyzeCommand|TestClassifyDoesNotFlagQuotedHttpServerPattern|TestEvaluateExemptsNetworkToolsFromDenyByDefault|TestEngineClassifiesNetworkAndDestructiveShellCommands|TestGrantRequestPermissionsNetworkOverlaysPolicyForTurn' Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/agent -run 'TestRunPersistentCommandPrefixStillPromptsForNetwork|TestRunApprovedNetworkBashPromptAppliesTurnNetworkGrant' Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/tools -run 'TestBash|TestRegistryRunsBashThroughSandboxEngine' Tested: GOCACHE=/tmp/zero-go-cache go test ./... * Auto-allow native-sandboxed shell commands Remove the old hidden bash auto-allow environment/config path. Ordinary shell commands now use the active native sandbox as the permission boundary by default, while network, destructive, and workspace policy checks still run first. Policy-only and disabled sandbox backends continue to require approval for shell commands, so unsandboxed execution is not silently allowed. Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox -run 'TestSandboxedBash|TestShellSandboxActive|TestEvaluateExemptsNetworkToolsFromDenyByDefault|TestGrantRequestPermissionsNetworkOverlaysPolicyForTurn|TestAnalyzeCommand|TestClassifyDoesNotFlagQuotedHttpServerPattern' Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/tools -run 'TestBashAuto|TestBashStillPromptsWithoutActiveSandbox|TestRegistryRunsBashThroughSandboxEngine' Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/cli -run 'TestApplyConfiguredSandboxPolicy|TestApplyConfiguredAutonomyCeiling' Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/agent -run 'TestRunPersistentCommandPrefixStillPromptsForNetwork|TestRunApprovedNetworkBashPromptAppliesTurnNetworkGrant' Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/daemon -run 'TestScrubWorkerEnvRemovesReentrancyMarkers' Tested: GOCACHE=/tmp/zero-go-cache go test ./... * Prompt for destructive shell approval Remove the separate DenyDestructiveShell policy layer from active sandbox decisions. Destructive shell classification now produces an approval prompt in ask/auto flows instead of a sandbox hard-deny, while no-approval paths remain blocked. Drop the removed destructive-shell policy field from sandbox policy output, backend capabilities, snapshots, and Windows golden data. Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/agent -run 'TestRunPromptsForDestructiveShellInsteadOfSandboxDeny|TestRunPersistentCommandPrefixStillPromptsForNetwork|TestRunApprovedNetworkBashPromptAppliesTurnNetworkGrant' Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox -run 'TestEngineClassifiesNetworkAndDestructiveShellCommands|TestBackendCapabilitiesReflectDisabledPolicy' Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/cli -run 'TestRunSandboxPolicy|TestEffectiveSandboxPolicy' Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/zerocommands -run 'TestSandboxPolicySnapshotFromPolicyFillsEffectiveMode' Tested: GOCACHE=/tmp/zero-go-cache go test ./... * Remove web-tool network policy switch Remove the old EnforceToolNetwork policy option and the NetworkHostAllowed per-tool gate so sandbox network policy only controls sandboxed shell egress. Keep web_search and web_fetch on their own tool permission and URL safety paths, with tests proving deny/scoped shell network policy no longer blocks in-process web tools. Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox -run 'TestEvaluateExemptsNetworkToolsFromShellNetworkPolicy|TestEngineAllowsNetworkSideEffectWhenShellPolicyBlocksNetwork|TestGrantRequestPermissionsNetworkOverlaysPolicyForTurn|TestScopedNetworkGateInEvaluate' Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/tools -run 'TestWebSearchRunWithSandbox|TestWebFetchRunWithSandbox|TestRegistryRoutesWebFetchThroughSandboxPath' Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/agent -run 'TestRunPersistentCommandPrefixStillPromptsForNetwork|TestRunApprovedNetworkBashPromptAppliesTurnNetworkGrant' Tested: GOCACHE=/tmp/zero-go-cache go test ./... Tested: git diff --check * Prompt for outside-workspace file access Convert recoverable outside-workspace file path violations into permission prompts instead of hard-denying in ask/auto modes. Approved prompts now grant the requested filesystem scope through the existing request_permissions overlay for the current turn or session, so the follow-up sandbox evaluation and scoped file tools use the same policy path. Keep unsafe mode, apply_patch escapes, symlink traversal, and non-recoverable path cases hard-denied. Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox -run 'TestEngineAutoAllowsWorkspaceFileMutationTools|TestEngineDeniesOutOfWorkspacePaths|TestGrantRequestPermissionsReadDoesNotGrantWrite|TestGrantRequestPermissionsSessionPersists' Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/agent -run 'TestRunPromptsAndAllowsOutsideWorkspaceWrite|TestRunSessionAllowsLaterOutsideWorkspaceWrite|TestRunAppliesSandboxEvenInUnsafeMode' Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/tools -run 'TestRegistryAppliesSandboxBeforeToolExecution|TestRegistrySandboxGatesPathAliasKeys|TestRegistryAllowsPromptToolWithPersistentSandboxGrant' Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/cli -run 'TestRunSandboxCheckJSONDeniesOutOfWorkspaceWrite' Tested: GOCACHE=/tmp/zero-go-cache go test ./... Tested: git diff --check * Fix Windows network prompt test fixture Use a Windows curl.cmd shim and cmd.exe PATH syntax for TestRunApprovedNetworkBashPromptAppliesTurnNetworkGrant while keeping the POSIX fake curl fixture for Linux and macOS. This keeps the test focused on network prompt approval and turn network grants instead of relying on a POSIX shell script being executable on Windows. Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/agent -run 'TestRunApprovedNetworkBashPromptAppliesTurnNetworkGrant|TestRunPersistentCommandPrefixStillPromptsForNetwork' Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox -run 'TestClassifyASTCatchesNetworkProgramsRegexMisses|TestClassifyFlagsPipedInstallerVariants' Tested: GOCACHE=/tmp/zero-go-cache go test ./... Tested: git diff --check * Remove sandbox autonomy ceilings Sandbox permission decisions now match grants by tool and scope only, without MaxAutonomy or request autonomy checks. Persistent/session grants, permission approvals, workspace-write auto-allow, and native-sandbox shell auto-allow no longer route through an autonomy ceiling. Remove the sandbox.maxAutonomy config and CLI surfaces, including grant --auto, policy output, sandbox check autonomy input, and sandbox grant snapshots. MCP permission autonomy is intentionally unchanged because it is a separate MCP permission model. Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox ./internal/agent ./internal/tools ./internal/zerocommands ./internal/config ./internal/cli ./internal/tui Tested: GOCACHE=/tmp/zero-go-cache go test ./... * Fail closed without native sandbox Remove AllowPolicyOnlyRunner from sandbox policy and snapshots. A restricted/managed command plan now errors when native sandbox support is unavailable instead of running through a policy-only direct runner. Policy-only backend data remains as diagnostics for capability/reporting paths, but it no longer authorizes command execution when a platform sandbox is required. Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox ./internal/zerocommands ./internal/cli ./internal/tui Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/agent ./internal/tools Tested: GOCACHE=/tmp/zero-go-cache go test ./... * Remove scoped shell network policy Drop the domain-scoped shell network mode and the local scoped-egress proxy path from the active sandbox model. Shell network policy now serializes and evaluates only deny or allow. Request-permissions network grants widen to allow without domain lists, and platform profiles consume the binary mode directly. The Linux helper no longer accepts proxy-route flags, Windows network hashes only the binary mode, and web tool tests assert they remain separate from shell network policy. Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox ./internal/tools ./internal/notify Tested: GOCACHE=/tmp/zero-go-cache go test ./... * Align permission grant ordering and prompt text Move persistent and session grant matching ahead of recoverable sandbox prompts while keeping explicit denies and non-recoverable path violations fail-closed. Clean TUI permission transcript/card text so normal prompts show action, reason, scope, and readable block details instead of raw permission/risk/violation key-value metadata. Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox ./internal/tui ./internal/agent ./internal/tools Tested: GOCACHE=/tmp/zero-go-cache go test ./... * Remove policy-only sandbox terminology Replace the unavailable native-backend diagnostic path with explicit unavailable support/backend states, and stop presenting missing native isolation as a policy-only fallback. Rename structured sandbox denial metadata from violation to block across permission events, stream-json, snapshots, TUI rendering, and sandbox-check output so user-facing flow matches blocked action language. Update README, work split docs, smoke test selectors, and Windows sandbox policy golden output for the new backend/support names and block payloads. Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox ./internal/agent ./internal/tools ./internal/tui ./internal/cli ./internal/doctor ./internal/zerocommands ./internal/streamjson Tested: GOCACHE=/tmp/zero-go-cache go test ./... Tested: scripts/sandbox-smoke.sh Tested: git diff --check * Fix sandbox review follow-ups Fail closed for unknown network modes, keep network prompts ahead of bash allow grants, preserve v1 grant-file reads, and classify unparseable commands with obvious network programs as network-risk. Bump stream-json schema to v2 and document the block metadata field after the violation-to-block rename. Tests: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox ./internal/streamjson ./internal/specialist ./internal/cli -run 'TestExec.*Stream|TestStreamJSON|TestExecProtocol|TestExecReadsStreamJSONImages|TestExecAcceptsImageOnlyStreamJSONMessage|TestExecInputFormatStreamJSON' Tests: GOCACHE=/tmp/zero-go-cache go test ./... Tests: scripts/sandbox-smoke.sh Tests: git diff --check |
||
|
|
673fce11bd |
Persist command prefix approvals (#252)
Add file-backed command prefix grants to the sandbox store and load them through the engine so approved prefixes skip future prompts while still running through sandbox policy checks. Tighten reusable prefix validation for runner-style launchers and script paths, and classify find -delete as destructive. Surface persistent prefix choices in the TUI and include approved prefixes in the run prompt context and sandbox grants list. Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/agent ./internal/sandbox ./internal/tui ./internal/cli Tested: GOCACHE=/tmp/zero-go-cache go test ./... Tested: git diff --check |
||
|
|
f00e83a5ed |
Add session command prefix approvals (#250)
Bash permission prompts can now expose a validated session-scoped prefix approval. Prefixes are parsed from simple shell commands or an explicit prefix_rule argument, with redirects, control operators, assignments, expansions, globs, and broad launcher prefixes rejected before the option is shown. Approved prefixes are stored in the session sandbox engine and only skip future matching bash prompts; commands still execute through the existing sandbox backend. Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/agent ./internal/tui Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/agent ./internal/sandbox ./internal/tui ./internal/tools Tested: GOCACHE=/tmp/zero-go-cache go test ./... |
||
|
|
2cf6fd2334 |
Add request permissions flow (#248)
* Add request permissions flow Adds a runtime-handled request_permissions tool that can ask for turn or session filesystem/network grants and return the granted profile as structured JSON. Temporary turn grants update the shared sandbox scope and effective policy, then clean up at run end; session grants persist in memory for the session. Read grants now stay read-only through tool path resolution, policy evaluation, and native sandbox profile construction. The TUI renders request_permissions with grant-specific choices, including strict review, and Esc continues without granting permissions. Tested: GOCACHE=/tmp/zero-go-cache go test ./... * Align request permission grants with prompts Display and return the normalized grant roots that the sandbox actually applies, so file-like permission requests no longer show a narrower path than the directory root being granted. Also makes the network prompt explicit about all outbound access and relabels the strict option as a model review request instead of implying enforced command review. Tested: GOCACHE=/tmp/zero-go-cache go test ./... * Retry transient OAuth secret read locks Windows can briefly deny reads of the freshly published token secret while concurrent OAuth secret creation converges. Treat Windows sharing and lock violations as transient and retry those reads before returning an error. Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/oauth Tested: GOCACHE=/tmp/zero-go-cache GOOS=windows GOARCH=amd64 go test -c ./internal/oauth -o /tmp/zero-oauth-windows.test.exe Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/sandbox ./internal/agent ./internal/tui ./internal/tools ./internal/specialist ./internal/oauth Tested: GOCACHE=/tmp/zero-go-cache go test ./... |
||
|
|
995b9086c7 |
feat(subagent): proactive specialist delegation + swarm execution fixes (#243)
* feat(subagent): proactive specialist delegation + swarm execution fixes Fixes: - Task tool dropped the parent's permission mode, so every delegated sub-agent ran at --auto high regardless of the parent. Forward PermissionMode so a child never exceeds its parent's authority. - Swarm members never executed: swarm_spawn accepts agent types teammate/subagent, but the launcher looked those up as specialist names (only worker/explorer/code-review exist), so every member failed with "specialist not found". Run each member from its swarm definition via an inline manifest on the executor. Enhancements: - Auto-delegation: the orchestrator system prompt now lists the available specialists and when to use each, nudging it to offload read-heavy work to a sub-agent so large tool output stays out of the main context. - @-mention: a leading "@<specialist> <task>" in the TUI composer delegates to that specialist (transcript keeps the verbatim mention); the @ palette suggests specialists on the first token, file references mid-message. - swarm.maxTeamSize config caps concurrent members per team (0 => default 8; negative rejected), honored in user and project config. - Proactive spawning: read-only specialists (explorer/code-review) auto- approve via a new args-aware tool permission (tools.ArgsPermissioner), so the agent delegates exploration off a normal prompt without an approval prompt; write-capable specialists, resume, and unknown names still prompt. The exec gate registers specialist tools at medium/high autonomy so a non-trivial headless run can auto-delegate read-only too. * fix(specialist): fail-safe child autonomy when PermissionMode is unset specialistAutonomy mapped an empty PermissionMode to "--auto high" (unsafe), silently re-introducing the privilege escalation the rest of this PR fixes: an orchestrator that forgets to wire PermissionMode would get an unsafe child. The Task tool and swarm both now propagate a resolved mode, so "" only occurs for a caller that omitted it -- default that case to "low". Only an explicit unsafe parent now yields --auto high; everything else (incl. unset/unknown) is fail-safe low. Updates the stale "back-compat" comments and the tests that documented the old behavior. Addresses the non-blocking follow-up from PR review. |
||
|
|
eb608741b0 |
Align permission prompt rendering (#246)
* Align permission prompt rendering Remove normal permission prompt risk labels from focused cards, transcript rows, and permission detail text so risk metadata is not presented as the permission UI contract. Rename the recoverable deny option from a redirect-style prompt to a continue-without-running label that matches its current behavior. Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/tui Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/tui ./internal/agent ./internal/cli Tested: GOCACHE=/tmp/zero-go-cache go test ./... Tested: git diff --check * Tighten permission prompt fallbacks Use conservative allow/deny choices when a permission request lacks backend-supplied decisions, avoiding impossible session or persistent grant options. Document that a failed session-grant recording attempt must not deny the current user-approved tool call; it only means a later matching call prompts again. Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/tui ./internal/agent Tested: GOCACHE=/tmp/zero-go-cache go test ./... Tested: git diff --check * Split permission deny and cancel decisions Add an explicit cancel permission decision that aborts the active run while keeping deny as a recoverable tool refusal. Expose cancel choices for command and apply-patch approval prompts, keep apply-patch off the recoverable deny path, and stop offering persistent always approvals for command/apply-patch prompts until scoped prefix approval exists. Classify cancel decisions as permission-decision events for TUI session logs and exec JSON output. Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/agent ./internal/tui ./internal/cli Tested: GOCACHE=/tmp/zero-go-cache go test ./... Tested: git diff --check * Stabilize OAuth secret creation Publish encrypted OAuth token secrets atomically under a short-lived creation lock so concurrent first-run callers never observe a half-written secret file. This fixes the Zero Review failure in TestLoadOrCreateSecretConcurrentConverges. Tested: go test ./internal/oauth -run TestLoadOrCreateSecretConcurrentConverges -count=100 Tested: go test ./internal/oauth Tested: GOCACHE=/tmp/zero-go-cache go test ./internal/agent ./internal/tui ./internal/cli Tested: GOCACHE=/tmp/zero-go-cache go test ./... Tested: git diff --check |