Compare commits

..
Author SHA1 Message Date
KRATOS 7bce9ad9ff perf(agent): concurrent read-only tool batches via capability gate
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.
2026-07-17 17:34:16 +05:30
47 changed files with 253 additions and 4839 deletions

No files matched your search

+19 -63
View File
@@ -1,71 +1,27 @@
# Repository Guidelines for Zero
---
description: Project guidelines for the open-source CLI coding agent (zero) repository.
globs: "*.go, *.js, *.md, *.json, *.toml, *.yaml, *.yml"
alwaysApply: false
---
These instructions apply to all work in this repository. For the user-facing
guide to extending Zero with specialists, hooks, plugins, MCP, and skills, see
[docs/EXTENDING.md](docs/EXTENDING.md).
# Repository Conventions for Zero
## 1. Contribution and Pull Request Rules
This file outlines the project conventions and repository guidelines for coding agents when working on the `zero` repository. For the general guide on how to extend Zero (write specialist sub-agents, hooks, plugins, MCP, skills), see [docs/EXTENDING.md](docs/EXTENDING.md).
- Before opening any pull request, **all contributors**—including maintainers,
community contributors, and coding agents—must read and follow
[CONTRIBUTING.md](CONTRIBUTING.md).
- Community pull requests require an existing parent issue with the
`issue-approved` label. Team members may open pull requests through the
internal development process described in `CONTRIBUTING.md`.
- Keep each change focused on the approved or assigned scope. Do not include
unrelated fixes, refactors, formatting churn, generated output, or existing
local changes in the same commit or pull request.
- Discuss new implementation languages, runtimes, major dependency changes,
and broad architectural rewrites with maintainers before implementation.
- Pull request descriptions must explain what changed and why, link the parent
issue when required, and list the tests or verification performed. Include
screenshots or a short recording for user-visible UI changes when practical.
## 1. Project Conventions
## 2. Repository and Implementation Conventions
- Build with `make`, not `go build` directly.
- Tests live next to the source file (`foo_test.go` next to `foo.go`).
- Run `make lint` before opening a PR.
- Never edit files under `third_party/` — those are vendored.
- Unify functions/methods where possible to prevent codebase inflation. Prefer a single cross-platform function with conditional checks over duplicating helpers per platform.
- Use the Go version declared in `go.mod`. Do not hardcode a different local
toolchain version in scripts or documentation.
- Use the repository build and release commands (`make` and
`go run ./cmd/zero-release ...`) instead of inventing parallel build flows.
- Keep tests beside their source files (`foo_test.go` next to `foo.go`). Add a
regression test for behavior changes and run affected concurrent code under
the race detector.
- Never edit files under `third_party/`; they are vendored.
- Prefer one cross-platform function with small conditional checks over
duplicated platform-specific helpers when the behavior can remain unified.
- Do not commit generated benchmark reports from
`internal/perfbench/reports/*.json`; reports are configuration-specific
evidence, not repository state.
- Preserve the user's working tree. Do not overwrite, delete, stage, or commit
unrelated tracked or untracked files.
## 2. Guidelines for Coding Agents
## 3. Required Validation
If you are an AI coding agent executing tasks in this repository, you **MUST** run all Go code quality and security checks before committing code or completing your task:
Run validation from the repository root before committing, opening a pull
request, or completing an implementation task:
1. **Format & Vet**: Run `go fmt ./...` and `go vet ./...`.
2. **Lint**: Run `go run github.com/golangci/golangci-lint/v2/cmd/[email protected] run --enable-only unused,ineffassign,staticcheck ./...`.
3. **Security**: Run `go run golang.org/x/vuln/cmd/[email protected] ./...`.
1. **Formatting check**: `make fmt-check`. If it fails, format with
`go fmt ./...` (or `make fmt`) and run the check again.
2. **Vet**: `go vet ./...` (or `make vet`).
3. **Tests**: `go test ./...`. Use `make test` for the full race-enabled suite,
or run focused tests with `-race`, when concurrency is affected.
4. **Build**: `go run ./cmd/zero-release build`.
5. **Smoke test**: `go run ./cmd/zero-release smoke`.
6. **Advisory lint**:
`go run github.com/golangci/golangci-lint/v2/cmd/[email protected] run --enable-only unused,ineffassign,staticcheck ./...`.
7. **Security**:
`go run golang.org/x/vuln/cmd/[email protected] ./...`.
8. **Diff hygiene**: `git diff HEAD --check` (covers staged and unstaged
tracked changes).
`make lint` currently runs the formatting check and `go vet`; it does **not**
run golangci-lint. The pinned golangci-lint job is advisory in CI while the
existing repository-wide backlog is cleaned up. Fix findings introduced by or
related to the current change. Unrelated pre-existing advisory findings do not
justify expanding a focused pull request; report them separately.
Formatting, vet, tests, build, smoke, diff hygiene, and govulncheck are hard
requirements. If a related check fails, fix it. If a required check cannot run
because of the environment or fails for an unrelated external reason, report
the exact failure and obtain maintainer direction rather than silently ignoring
it.
If any check fails or cannot be run, do not ignore it. Prompt the user for instructions or setup assistance before proceeding.
-6
View File
@@ -358,12 +358,6 @@ go run ./cmd/zero-release smoke
go run ./cmd/zero-perf-bench
```
Experimental: `ZERO_OPENAI_TURN_SESSION=1` enables the optimized OpenAI turn
session (background connection prewarm + request-prefix telemetry) for headless
`zero exec` runs against official OpenAI profiles. Off by default; `0`/`false`
disable. A/B-benchmark it by running the same `zero-perf-bench` suite with the
variable unset and set.
### Code Quality and Security Checks
Before committing any changes, ensure all Go code quality and security checks pass. Pinned `go run` commands matching CI constraints can be used directly without prior installation:
+15 -159
View File
@@ -55,133 +55,6 @@ function localControlHelperManifest(packageRoot) {
return JSON.stringify({ version: 1, helpers });
}
// Mirrors internal/cli/observability.go parseDoctorArgs + writeDoctorHelp so the
// wrapper's missing-binary doctor fallback matches the Go doctor CLI surface.
const DOCTOR_HELP = `Usage:
zero doctor [flags]
Runs Go backend health checks for config and provider setup.
Flags:
--json Print JSON report
--connectivity Include provider endpoint connectivity probe when available
-h, --help Show this help
`;
const EXIT_USAGE = 2;
function installedByBun() {
if (process.env.ZERO_WRAPPER_SIMULATE_BUN === '1') {
return true;
}
return process.execPath.includes('bun') || !!process.versions?.bun;
}
function bunRecoveryParagraph() {
return (
'You installed with Bun, which does not run dependency lifecycle scripts\n' +
'by default. Trust the package to run the blocked postinstall:\n' +
' bun pm trust @gitlawb/zero (project install)\n' +
' bun pm -g trust @gitlawb/zero (global install)\n' +
'On Bun versions without `bun pm trust`, add\n' +
' "trustedDependencies": ["@gitlawb/zero"]\n' +
'to your project package.json and reinstall.\n' +
'\n'
);
}
function buildFromSourceParagraph() {
return (
'If this platform has no prebuilt binary, build from source:\n' +
'https://github.com/Gitlawb/zero (go run ./cmd/zero, requires Go 1.26+).'
);
}
function missingBinaryContextParagraph() {
return (
'Normally npm installs it as an optional dependency of @gitlawb/zero\n' +
`(@gitlawb/zero-${process.platform}-${process.arch}), and the wrapper can\n` +
'also download it from the GitHub Release when it is missing.'
);
}
function missingNativeRecoveryParagraphs(postinstallScript) {
const ranByBun = installedByBun();
return (
missingBinaryContextParagraph() +
'\n' +
'\n' +
'Things to try:\n' +
' - reinstall without omitting optional dependencies:\n' +
' npm install -g @gitlawb/zero\n' +
' - run the downloader manually (needs write access to the package dir):\n' +
` node "${postinstallScript}"\n` +
'\n' +
(ranByBun ? bunRecoveryParagraph() : '') +
buildFromSourceParagraph()
);
}
function formatGenericMissingNativeBinaryMessage(postinstallScript) {
return (
'[zero] No native binary is available for this install.\n' +
missingNativeRecoveryParagraphs(postinstallScript)
);
}
function parseDoctorArgs(args) {
let json = false;
for (const arg of args) {
switch (arg) {
case '-h':
case '--help':
case 'help':
return { kind: 'help' };
case '--json':
json = true;
break;
case '--connectivity':
break;
default:
return { kind: 'error', message: `unknown doctor flag ${JSON.stringify(arg)}` };
}
}
return { kind: 'run', json };
}
function missingNativeDoctorJSONReport(postinstallScript) {
return {
generatedAt: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'),
ok: false,
checks: [
{
id: 'runtime.go',
label: 'Go runtime',
status: 'fail',
message: 'Native zero binary is missing next to the npm wrapper.',
details: {
remedy: `node "${postinstallScript}"`,
},
},
],
};
}
function missingNativeDoctorTextReport(postinstallScript) {
return (
'Zero doctor report (' +
new Date().toISOString() +
')\n' +
'Overall: fail\n' +
'[fail] runtime.go - Native zero binary is missing next to the npm wrapper.\n' +
' remedy: node "' +
postinstallScript +
'"\n' +
'\n' +
missingNativeRecoveryParagraphs(postinstallScript)
);
}
// The platform payload is a version of @gitlawb/zero itself, installed under
// an npm: alias (see docs/NPM_PACKAGING.md). The alias name is derived from
// process.platform/process.arch, so an unsupported platform simply fails to
@@ -241,38 +114,21 @@ function resolveNativeBinary() {
const nativePath = resolveNativeBinary();
if (!nativePath) {
const postinstallScript = join(packageRoot, 'scripts', 'postinstall.mjs');
// `zero doctor` is the diagnostic command: when the native binary is missing
// it's the one invocation that must NOT bail with the generic wrapper error,
// because that's exactly the blind alley issue #405 reports. Instead, surface
// a doctor-shaped FAIL line for the runtime so the user's diagnostic finds the
// real cause. We branch on a literal 'doctor' subcommand only (matching `zero
// doctor` and `zero doctor --connectivity`), preserving the existing bail for
// every other invocation (exec, providers list, TUI, --version, etc.).
const argv = process.argv.slice(2);
const isDoctor = argv.length > 0 && argv[0] === 'doctor';
if (isDoctor) {
const parsed = parseDoctorArgs(argv.slice(1));
if (parsed.kind === 'help') {
process.stdout.write(DOCTOR_HELP);
process.exit(0);
}
if (parsed.kind === 'error') {
process.stderr.write(`[zero] ${parsed.message}\n`);
process.exit(EXIT_USAGE);
}
if (parsed.json) {
process.stdout.write(JSON.stringify(missingNativeDoctorJSONReport(postinstallScript), null, 2) + '\n');
process.exit(1);
}
process.stdout.write(missingNativeDoctorTextReport(postinstallScript) + '\n');
process.exit(1);
}
console.error(formatGenericMissingNativeBinaryMessage(postinstallScript));
console.error(
'[zero] No native binary is available for this install.\n' +
'Normally npm installs it as an optional dependency of @gitlawb/zero\n' +
`(@gitlawb/zero-${process.platform}-${process.arch}), and the wrapper can\n` +
'also download it from the GitHub Release when it is missing.\n' +
'\n' +
'Things to try:\n' +
' - reinstall without omitting optional dependencies:\n' +
' npm install -g @gitlawb/zero\n' +
' - run the downloader manually (needs write access to the package dir):\n' +
` node "${join(packageRoot, 'scripts', 'postinstall.mjs')}"\n` +
'\n' +
'If this platform has no prebuilt binary, build from source:\n' +
'https://github.com/Gitlawb/zero (go run ./cmd/zero, requires Go 1.26+).',
);
process.exit(1);
}
+3 -16
View File
@@ -272,8 +272,7 @@ sequenceDiagram
Agent->>Tool: execute(ctx, args)
Tool-->>Agent: output, error, metadata
Agent->>Hooks: afterTool hooks
Agent->>Agent: redact secrets, apply semantic output budget, enforce byte ceiling
Agent->>Agent: classify success/failure
Agent->>Agent: redact, truncate, classify success/failure
Agent-->>Surface: OnToolResult callback
Agent->>Transcript: append tool result message
end
@@ -605,22 +604,10 @@ flowchart TD
Prompt -- Yes --> Decision[Permission callback]
Prompt -- No --> Run[Registry.RunWithOptions]
Decision --> Run
Run --> Redact[Redact secrets]
Redact --> Budget[Token-aware semantic output budget]
Budget --> Ceiling[Existing hard byte ceiling]
Ceiling --> Message[Return one tool result to model]
Run --> Redact[Redact secrets + enforce output ceiling]
Redact --> Message[Return tool result to model]
```
Oversized results use deterministic, provider-neutral estimated-token budgets.
Policies retain useful structure for files, search matches, tests, process logs,
diffs, and worker conclusions; tools without a declared category use a UTF-8-safe
head/tail fallback. The estimate is intentionally conservative for non-ASCII
text and is not exact provider tokenization. Existing byte ceilings remain the
authoritative safety limit. When the existing spill mechanism can persist the
complete redacted text received by the budgeting layer, the result includes its
safe spill reference. This does not imply capture of subprocess bytes already
discarded by a tool's established internal buffer.
Core tool groups include:
- **Read-only tools**: file reads, directory listing, glob, grep, LSP navigation,
+1 -6
View File
@@ -141,11 +141,9 @@ func TestRunAskUserCancellationAbortsRun(t *testing.T) {
registry := registryWithAskUser()
args := `{"questions":[{"question":"Which framework?"}]}`
provider := providerCallingAskUserThenAnswer(args, "done")
var toolResults []ToolResult
result, err := Run(context.Background(), "clarify", provider, Options{
Registry: registry,
OnToolResult: func(result ToolResult) { toolResults = append(toolResults, result) },
Registry: registry,
OnAskUser: func(_ context.Context, _ AskUserRequest) (AskUserResponse, error) {
return AskUserResponse{}, context.Canceled
},
@@ -160,9 +158,6 @@ func TestRunAskUserCancellationAbortsRun(t *testing.T) {
if len(provider.requests) != 1 {
t.Fatalf("expected the run to stop after the canceled ask_user (1 turn), got %d", len(provider.requests))
}
if len(toolResults) != 1 || toolResults[0].ToolCallID == "" {
t.Fatalf("cancellation must emit exactly one result for the call, got %#v", toolResults)
}
// The recorded tool result must reflect cancellation, not a synthetic answer.
var toolMsg string
for _, m := range result.Messages {
-86
View File
@@ -1,86 +0,0 @@
package agent
// CompletionDecision is the deterministic outcome of evaluating a text-only
// assistant turn. Uncertain means the loop needs one bounded follow-up action
// before it can finalize the run.
type CompletionDecision string
const (
CompletionUncertain CompletionDecision = "uncertain"
CompletionComplete CompletionDecision = "complete"
CompletionIncomplete CompletionDecision = "incomplete"
)
type completionAction string
const (
completionActionNone completionAction = ""
completionActionContinue completionAction = "continue"
completionActionSemanticCheck completionAction = "semantic_check"
)
type completionEvaluation struct {
Decision CompletionDecision
Action completionAction
Reason string
}
// completionPolicy owns the small amount of state needed to keep uncertain
// decisions bounded. requireSemanticCheck is set only for run profiles that
// already opted into self-correction; ordinary completion remains entirely
// local and adds no model call.
type completionPolicy struct {
continueNudges int
requireSemanticCheck bool
semanticCheckRequested bool
}
func newCompletionPolicy(requireSemanticCheck bool) *completionPolicy {
return &completionPolicy{requireSemanticCheck: requireSemanticCheck}
}
func (policy *completionPolicy) evaluate(text string, planPending bool) completionEvaluation {
// A direct admission is strong local evidence and takes precedence over all
// weaker signals, avoiding both continuation nudges and semantic checks.
if reason := selfReportedIncompletion(text); reason != "" {
return completionEvaluation{Decision: CompletionIncomplete, Reason: reason}
}
// A continuation cue is strong unfinished evidence; a pending plan is only
// weak evidence because bookkeeping can be stale. Both get a bounded chance
// to continue, but only a persisted cue is ultimately incomplete.
cue := endsWithContinuationCue(text)
if cue || planPending {
if policy.continueNudges < maxContinueNudges {
policy.continueNudges++
reason := "your message ended mid-step"
if !cue {
reason = "pending plan items remain — finish them, or mark them complete with update_plan if you are done"
}
return completionEvaluation{
Decision: CompletionUncertain,
Action: completionActionContinue,
Reason: reason,
}
}
if cue {
return completionEvaluation{
Decision: CompletionIncomplete,
Reason: "your message ended mid-step",
}
}
}
// Profiles with self-correction enabled require one task-grounded semantic
// check. It is requested at most once per run; the next locally complete turn
// is accepted without another model call.
if policy.requireSemanticCheck && !policy.semanticCheckRequested {
policy.semanticCheckRequested = true
return completionEvaluation{
Decision: CompletionUncertain,
Action: completionActionSemanticCheck,
}
}
return completionEvaluation{Decision: CompletionComplete, Action: completionActionNone}
}
-64
View File
@@ -1,64 +0,0 @@
package agent
import "testing"
func TestCompletionPolicyLocalEvidenceDecidesWithoutSemanticCheck(t *testing.T) {
policy := newCompletionPolicy(false)
complete := policy.evaluate("Done. All required checks pass.", false)
if complete.Decision != CompletionComplete {
t.Fatalf("confident completion decision = %q, want %q", complete.Decision, CompletionComplete)
}
incomplete := policy.evaluate("I couldn't verify the result, so this is my best guess.", false)
if incomplete.Decision != CompletionIncomplete {
t.Fatalf("admitted failure decision = %q, want %q", incomplete.Decision, CompletionIncomplete)
}
}
func TestCompletionPolicyPreservesBoundedPlanStallProtection(t *testing.T) {
policy := newCompletionPolicy(false)
for attempt := 0; attempt < maxContinueNudges; attempt++ {
got := policy.evaluate("Let me inspect the remaining configuration:", true)
if got.Decision != CompletionUncertain || got.Action != completionActionContinue {
t.Fatalf("attempt %d = (%q, %q), want uncertain continue", attempt+1, got.Decision, got.Action)
}
}
got := policy.evaluate("Let me inspect the remaining configuration:", true)
if got.Decision != CompletionIncomplete {
t.Fatalf("decision after nudge budget = %q, want %q", got.Decision, CompletionIncomplete)
}
}
func TestCompletionPolicyTreatsPendingPlanAsWeakEvidence(t *testing.T) {
policy := newCompletionPolicy(false)
for attempt := 0; attempt < maxContinueNudges; attempt++ {
got := policy.evaluate("All set.", true)
if got.Decision != CompletionUncertain || got.Action != completionActionContinue {
t.Fatalf("attempt %d = (%q, %q), want uncertain continue", attempt+1, got.Decision, got.Action)
}
}
got := policy.evaluate("All set.", true)
if got.Decision != CompletionComplete {
t.Fatalf("stale plan decision after nudge budget = %q, want %q", got.Decision, CompletionComplete)
}
}
func TestCompletionPolicyAllowsExactlyOneRequiredSemanticCheck(t *testing.T) {
policy := newCompletionPolicy(true)
first := policy.evaluate("Implemented and tested.", false)
if first.Decision != CompletionUncertain || first.Action != completionActionSemanticCheck {
t.Fatalf("first decision = (%q, %q), want uncertain semantic check", first.Decision, first.Action)
}
second := policy.evaluate("PASS. The result meets the task criterion.", false)
if second.Decision != CompletionComplete {
t.Fatalf("post-check decision = %q, want %q", second.Decision, CompletionComplete)
}
if second.Action == completionActionSemanticCheck {
t.Fatal("semantic check was requested more than once")
}
}
+73 -117
View File
@@ -7,7 +7,6 @@ import (
"fmt"
"io"
"sort"
"strconv"
"strings"
"sync"
@@ -136,33 +135,6 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
}
}
// Establish the run's turn session — the seam an optimized provider session
// (connection reuse, prewarm, native compaction) plugs into without touching
// this loop. Default: wrap the passed provider so the session's Stream IS
// provider.StreamCompletion, byte-identical to calling it directly. Opened
// before dispatchSessionStart so a failed open is a clean run-start error
// with no session hooks fired and nothing to unwind.
turnSessions := options.TurnSessionProvider
if turnSessions == nil {
turnSessions = zeroruntime.NewProviderTurnSessionProvider(provider, zeroruntime.ProviderCapabilities{})
}
session, sessionErr := turnSessions.OpenTurnSession(ctx)
if sessionErr != nil {
return Result{}, fmt.Errorf("agent: open turn session: %w", sessionErr)
}
// Closed via closure (not `defer session.Close()`) so after a mid-run model
// swap the CURRENT session is the one closed at teardown — the swap closes
// the old one inline. Close errors are advisory and never fail the run.
defer func() { _ = session.Close() }()
// Best-effort prewarm: the default adapter no-ops; a real session may prime
// a connection. Failure is ignored by contract (never fatal).
_ = session.Prewarm(ctx)
// Route ALL provider I/O — per-turn streams, mid-stream retries, the
// compaction summarizer, and the final-answer request — through the session
// by reassigning the loop's provider local. Every existing call site reads
// this local, so no signatures change anywhere downstream.
provider = sessionProvider{session: session}
maxTurns := options.MaxTurns
if maxTurns <= 0 {
maxTurns = 12
@@ -197,10 +169,13 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
// loaded tool's full schema; it lives only for the run (v1 within-run scope).
loaded := map[string]bool{}
// The feature-gated completion policy keeps local completion decisions and its
// bounded follow-up state out of the central loop. Self-correcting profiles
// opt into the one permitted task-grounded semantic check.
completionPolicy := newCompletionPolicy(options.SelfCorrect != nil)
// continueNudges counts how many times the headless completion gate
// (Options.RequireCompletionSignal) has re-prompted a no-tool-call turn that
// stopped with work still unfinished. Bounded by maxContinueNudges.
continueNudges := 0
// acceptanceRequested records that the one-time task-grounded acceptance check
// has already been demanded this run, so it fires at most once.
acceptanceRequested := false
// toolDefCache memoizes each tool's rendered JSON-schema definition across
// turns (a tool's advertised schema is stable for the run), so partitionTools
@@ -545,32 +520,68 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
// model's final answer ONLY when the work is actually done. Default off
// (RequireCompletionSignal), so interactive runs stay byte-identical.
if options.RequireCompletionSignal {
evaluation := completionPolicy.evaluate(collected.Text, guards.pendingPlanItems())
switch evaluation.Decision {
case CompletionIncomplete:
// (1) Self-report downgrade (strongest, unambiguous): the model's own
// final message admits it guessed / could not meet the objective. Checked
// FIRST so an admitted-impossible task is downgraded immediately (no wasted
// continue-nudges) and reports the accurate reason.
if reason := selfReportedIncompletion(collected.Text); reason != "" {
result.Incomplete = true
result.IncompleteReason = evaluation.Reason
result.IncompleteReason = reason
result.FinalAnswer = collected.Text
result.Messages = copyMessages(messages)
return result, nil
case CompletionUncertain:
switch evaluation.Action {
case completionActionContinue:
}
// (2) The model stopped without a tool call while work may be unfinished:
// - a continuation cue ("…Let me check the config:") is an unambiguous
// mid-step stop;
// - pending update_plan items are a WEAK, ambiguous signal (the model may
// have finished without re-marking the last step).
// Nudge to continue (bounded). After the budget: a persisted continuation
// cue finalizes INCOMPLETE; pending-plan WITHOUT a cue does NOT (that would
// false-fail a completed run with stale bookkeeping) — fall through to the
// acceptance check / success.
cue := endsWithContinuationCue(collected.Text)
planPending := guards.pendingPlanItems()
if cue || planPending {
if continueNudges < maxContinueNudges {
continueNudges++
options.Trace.Counter(trace.CounterCompletionNudges, 1)
reason := "your message ended mid-step"
if !cue {
reason = "pending plan items remain — finish them, or mark them complete with update_plan if you are done"
}
messages = append(messages, zeroruntime.Message{
Role: zeroruntime.MessageRoleUser,
Content: continueNudge(evaluation.Reason),
})
case completionActionSemanticCheck:
options.Trace.Counter(trace.CounterAcceptanceChecks, 1)
messages = append(messages, zeroruntime.Message{
Role: zeroruntime.MessageRoleUser,
Content: acceptanceVerificationNudge(),
Content: continueNudge(reason),
})
continue
}
if cue {
result.Incomplete = true
result.IncompleteReason = "your message ended mid-step"
result.FinalAnswer = collected.Text
result.Messages = copyMessages(messages)
return result, nil
}
// pending-plan only, budget spent: trust the model's completion claim
// over stale plan bookkeeping; fall through.
}
// (3) Task-grounded acceptance: before accepting a "done" turn as success,
// require ONE acceptance check grounded in the task's stated criterion
// (only when self-correct is on). Rejects "well-formed == correct",
// "existing-tests-pass == objective met", and "result == baseline" false
// successes. Bounded to a single pass; a genuine post-check completion
// (no admission, no cue) then finalizes as success on the next turn.
if options.SelfCorrect != nil && !acceptanceRequested {
acceptanceRequested = true
options.Trace.Counter(trace.CounterAcceptanceChecks, 1)
messages = append(messages, zeroruntime.Message{
Role: zeroruntime.MessageRoleUser,
Content: acceptanceVerificationNudge(),
})
continue
case CompletionComplete:
// Local evidence is sufficient; proceed to final diagnostics.
}
}
// Finalization diagnostics gate: edits from this run may still have
@@ -645,7 +656,6 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
toolSpan.End()
}
options.Trace.Counter(trace.CounterToolCalls, 1)
recordOutputBudgetTrace(options.Trace, toolResult)
if options.OnToolResult != nil {
options.OnToolResult(toolResult)
}
@@ -737,53 +747,25 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
// of the run. At most one switch per turn (first request wins). A switcher
// error is NON-FATAL — record a brief note and continue on the current
// model. nil switcher ⇒ requests are ignored entirely (escalation off).
if turnRequestedModel != "" && (options.ModelSessionSwitcher != nil || options.ModelSwitcher != nil) {
// Resolve the escalated model's session source. The target-aware
// ModelSessionSwitcher is preferred: its TurnSessionProvider carries an
// optimized session (and capabilities) across the swap. The legacy
// ModelSwitcher fallback returns a bare Provider, wrapped in the
// default no-op session — identical to pre-seam behavior.
var newSessions zeroruntime.TurnSessionProvider
var switchErr error
if options.ModelSessionSwitcher != nil {
newSessions, switchErr = options.ModelSessionSwitcher(ctx, turnRequestedModel)
} else {
var newProvider Provider
newProvider, switchErr = options.ModelSwitcher(ctx, turnRequestedModel)
if switchErr == nil && newProvider != nil {
newSessions = zeroruntime.NewProviderTurnSessionProvider(newProvider, zeroruntime.ProviderCapabilities{})
}
}
if turnRequestedModel != "" && options.ModelSwitcher != nil {
newProvider, switchErr := options.ModelSwitcher(ctx, turnRequestedModel)
if switchErr != nil {
messages = append(messages, zeroruntime.Message{
Role: zeroruntime.MessageRoleUser,
Content: escalationFailedNoticePrefix + " (" + turnRequestedModel + "): " + switchErr.Error() + ". Continuing on " + options.Model + ".",
})
} else if newSessions != nil {
// Open the new session and close the old one, so the next turn's
// streams and compaction use the new model. For the default adapter
// the open never errors and Close is a no-op, so this stays
// byte-identical to a direct provider reassignment; the guarded open
// and prewarm matter once a session holds real state.
newSession, openErr := newSessions.OpenTurnSession(ctx)
if openErr != nil {
messages = append(messages, zeroruntime.Message{
Role: zeroruntime.MessageRoleUser,
Content: escalationFailedNoticePrefix + " (" + turnRequestedModel + "): " + openErr.Error() + ". Continuing on " + options.Model + ".",
})
} else {
_ = session.Close()
session = newSession
_ = session.Prewarm(ctx)
provider = sessionProvider{session: session}
options.Model = turnRequestedModel
options.Trace.Counter(trace.CounterModelSwitches, 1)
// KNOWN LIMITATION (deferred): the compactor's context-window budget
// is fixed at run start from options.ContextWindow and is NOT updated
// here, so a switch to a model with a different window keeps compacting
// against the original budget. Fixing it needs the switcher to also
// report the new window — out of scope for this change.
}
} else if newProvider != nil {
// Reassign the local provider so the next turn's StreamCompletion and
// compaction use it; update options.Model so subsequent RunOptions.Model,
// context-window sizing, and usage attribution follow the new model.
provider = newProvider
options.Model = turnRequestedModel
options.Trace.Counter(trace.CounterModelSwitches, 1)
// KNOWN LIMITATION (deferred): the compactor's context-window budget
// is fixed at run start from options.ContextWindow and is NOT updated
// here, so a switch to a model with a different window keeps compacting
// against the original budget. Fixing it needs a ModelSwitcher contract
// change (return the new window) — out of scope for this change.
}
}
@@ -859,28 +841,6 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
return result, nil
}
func recordOutputBudgetTrace(recorder *trace.Recorder, result ToolResult) {
if recorder == nil || result.Meta["output_budget_category"] == "" {
return
}
parseInt := func(key string) int {
value, _ := strconv.Atoi(result.Meta[key])
return value
}
spillCreated, _ := strconv.ParseBool(result.Meta["output_budget_spill_created"])
recorder.EmitOutputBudget(trace.OutputBudgetEvent{
Tool: result.Name,
Category: result.Meta["output_budget_category"],
OriginalBytes: parseInt("output_budget_original_bytes"),
RetainedBytes: parseInt("output_budget_retained_bytes"),
EstimatedOriginalTokens: parseInt("output_budget_estimated_original_tokens"),
EstimatedRetainedTokens: parseInt("output_budget_estimated_retained_tokens"),
Truncated: result.Truncated,
Reason: result.Meta["output_budget_reason"],
SpillCreated: spillCreated,
})
}
func finalAnswerAfterMaxTurns(ctx context.Context, provider Provider, messages []zeroruntime.Message, toolDefs []zeroruntime.ToolDefinition, options Options) (string, []zeroruntime.Message, string) {
finalMessages := copyMessages(messages)
finalMessages = append(finalMessages, zeroruntime.Message{
@@ -1335,7 +1295,6 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
if didRedact {
result.Redacted = true
}
result = registry.RebudgetAfterHook(call.Name, args, result)
}
}
// Secret scrubbing happens at the registry boundary (the single point both
@@ -1346,7 +1305,6 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
Name: call.Name,
Status: result.Status,
Output: result.Output,
Truncated: result.Truncated,
Meta: result.Meta,
Redacted: result.Redacted,
ChangedFiles: result.ChangedFiles,
@@ -1657,7 +1615,6 @@ func toolResultFromPrePermissionReject(call ToolCall, result tools.Result) ToolR
Name: call.Name,
Status: result.Status,
Output: output,
Truncated: result.Truncated,
Meta: meta,
Redacted: result.Redacted || outputRedacted || summaryRedacted || metaRedacted,
ChangedFiles: result.ChangedFiles,
@@ -1927,7 +1884,6 @@ func askUserFallbackResult(ctx context.Context, registry *tools.Registry, call T
Name: call.Name,
Status: result.Status,
Output: result.Output,
Truncated: result.Truncated,
Meta: result.Meta,
Redacted: result.Redacted,
ChangedFiles: result.ChangedFiles,
@@ -1,167 +0,0 @@
package agent
import (
"context"
"os"
"strconv"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/hooks"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/trace"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
type propagationOutputTool struct {
output string
}
func TestOutputBudgetHookHelperProcess(t *testing.T) {
for index, arg := range os.Args {
if arg != "--zero-output-budget-hook" || index+1 >= len(os.Args) {
continue
}
if _, err := os.Stdout.WriteString(os.Args[index+1]); err != nil {
os.Exit(2)
}
os.Exit(0)
}
}
func largeOutputBudgetHookDispatcher() *hooks.Dispatcher {
feedback := strings.Repeat("hook feedback ", 200)
return hooks.NewDispatcher(hooks.DispatcherOptions{Config: hooks.Config{
Enabled: true,
Hooks: []hooks.Definition{{
ID: "large-feedback",
Event: hooks.EventAfterTool,
Matcher: "propagation_output",
Command: os.Args[0],
Args: []string{
"-test.run=TestOutputBudgetHookHelperProcess",
"--",
"--zero-output-budget-hook",
feedback,
},
Enabled: true,
}},
}})
}
func (tool propagationOutputTool) Name() string { return "propagation_output" }
func (tool propagationOutputTool) Description() string { return "returns output for propagation tests" }
func (tool propagationOutputTool) Parameters() tools.Schema {
return tools.Schema{Type: "object", AdditionalProperties: false}
}
func (tool propagationOutputTool) Safety() tools.Safety {
return tools.Safety{SideEffect: tools.SideEffectRead, Permission: tools.PermissionAllow, Reason: "test read"}
}
func (tool propagationOutputTool) Run(context.Context, map[string]any) tools.Result {
return tools.Result{Status: tools.StatusOK, Output: tool.output}
}
func TestExecuteToolCallPropagatesOutputTruncation(t *testing.T) {
t.Setenv("TMPDIR", t.TempDir())
t.Setenv("ZERO_TOOL_OUTPUT_CEILING_TOKENS", "80")
registry := tools.NewRegistry()
registry.Register(propagationOutputTool{output: strings.Repeat("large output\n", 1000)})
result, abortErr := executeToolCall(context.Background(), registry, ToolCall{
ID: "call-budget",
Name: "propagation_output",
Arguments: `{}`,
}, PermissionModeAuto, Options{Cwd: t.TempDir()})
if abortErr != nil {
t.Fatalf("executeToolCall abort error: %v", abortErr)
}
if !result.Truncated {
t.Fatalf("agent ToolResult lost tools.Result.Truncated: %#v", result)
}
if result.Meta["spill_path"] == "" {
t.Fatalf("agent ToolResult lost spill metadata: %#v", result.Meta)
}
}
func TestRecordOutputBudgetTraceUsesOnlyCompactMetadata(t *testing.T) {
recorder := trace.NewRecorder("session", "run", "")
recorder.Start()
recordOutputBudgetTrace(recorder, ToolResult{
Name: "grep",
Truncated: true,
Output: "SECRET OUTPUT MUST NOT ENTER TRACE",
Meta: map[string]string{
"output_budget_category": "search",
"output_budget_original_bytes": "1000",
"output_budget_retained_bytes": "100",
"output_budget_estimated_original_tokens": "250",
"output_budget_estimated_retained_tokens": "25",
"output_budget_reason": "semantic_search_budget",
"output_budget_spill_created": "true",
"spill_path": "/secret/path/not-for-trace",
},
})
events := recorder.Finish().OutputBudgets
if len(events) != 1 {
t.Fatalf("events = %#v", events)
}
event := events[0]
if event.Tool != "grep" || event.Category != "search" || event.OriginalBytes != 1000 || event.RetainedBytes != 100 || !event.SpillCreated {
t.Fatalf("unexpected trace event: %#v", event)
}
}
func TestExecuteToolCallRebudgetsOversizedAfterToolFeedback(t *testing.T) {
t.Setenv("ZERO_TOOL_OUTPUT_CEILING_TOKENS", "80")
registry := tools.NewRegistry()
registry.Register(propagationOutputTool{output: "tool output"})
dispatcher := largeOutputBudgetHookDispatcher()
result, abortErr := executeToolCall(context.Background(), registry, ToolCall{
ID: "call-hook-budget",
Name: "propagation_output",
Arguments: `{}`,
}, PermissionModeAuto, Options{Hooks: dispatcher})
if abortErr != nil {
t.Fatalf("executeToolCall abort error: %v", abortErr)
}
if !result.Truncated || len(result.Output) > 80*4 {
t.Fatalf("afterTool feedback bypassed output budget: truncated=%t bytes=%d meta=%#v", result.Truncated, len(result.Output), result.Meta)
}
if result.Meta["output_budget_category"] == "" || result.Meta["output_budget_retained_bytes"] != strconv.Itoa(len(result.Output)) {
t.Fatalf("post-hook budget metadata does not describe final output: %#v", result.Meta)
}
}
func TestRunTraceReflectsPostHookBudget(t *testing.T) {
t.Setenv("ZERO_TOOL_OUTPUT_CEILING_TOKENS", "80")
registry := tools.NewRegistry()
registry.Register(propagationOutputTool{output: "tool output"})
dispatcher := largeOutputBudgetHookDispatcher()
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
{
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-hook-trace", ToolName: "propagation_output"},
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-hook-trace", ArgumentsFragment: `{}`},
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-hook-trace"},
{Type: zeroruntime.StreamEventDone},
},
{{Type: zeroruntime.StreamEventText, Content: "done"}, {Type: zeroruntime.StreamEventDone}},
}}
recorder := trace.NewRecorder("session", "run", "")
var toolResults []ToolResult
if _, err := Run(context.Background(), "budget hook", provider, Options{
Registry: registry,
Hooks: dispatcher,
Trace: recorder,
OnToolResult: func(result ToolResult) { toolResults = append(toolResults, result) },
}); err != nil {
t.Fatalf("Run: %v", err)
}
if len(toolResults) != 1 || !toolResults[0].Truncated {
t.Fatalf("tool result = %#v, want one truncated post-hook result", toolResults)
}
events := recorder.Finish().OutputBudgets
if len(events) != 1 || !events[0].Truncated || events[0].RetainedBytes != len(toolResults[0].Output) {
t.Fatalf("trace does not describe final post-hook output: events=%#v result=%#v", events, toolResults[0])
}
}
-21
View File
@@ -1,21 +0,0 @@
package agent
import (
"context"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// sessionProvider adapts a TurnSession back to the Provider interface so the
// loop's existing Provider-shaped call sites (streamWithReconnect, the
// compaction summarizer, finalAnswerAfterMaxTurns) route their stream I/O
// through the session without any signature change. For the default adapter
// this reduces to the wrapped provider's StreamCompletion, so behavior is
// byte-identical; an optimized session (PR8) takes effect here transparently.
type sessionProvider struct {
session zeroruntime.TurnSession
}
func (s sessionProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
return s.session.Stream(ctx, request)
}
-367
View File
@@ -1,367 +0,0 @@
package agent
import (
"context"
"errors"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// fakeTurnSession wraps an inner provider for streaming while counting the
// session lifecycle, so tests can observe exactly how the loop drives it.
type fakeTurnSession struct {
inner Provider
prewarms int
streams int
closes int
closeErr error
}
func (s *fakeTurnSession) Prewarm(context.Context) error { s.prewarms++; return nil }
func (s *fakeTurnSession) Stream(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
s.streams++
return s.inner.StreamCompletion(ctx, request)
}
func (s *fakeTurnSession) Compact(context.Context, zeroruntime.CompletionRequest) ([]zeroruntime.Message, error) {
return nil, zeroruntime.ErrCompactionUnsupported
}
func (s *fakeTurnSession) Close() error { s.closes++; return s.closeErr }
// fakeTurnSessionProvider opens a scripted session (or fails to).
type fakeTurnSessionProvider struct {
session *fakeTurnSession
openErr error
opens int
}
func (p *fakeTurnSessionProvider) OpenTurnSession(context.Context) (zeroruntime.TurnSession, error) {
p.opens++
if p.openErr != nil {
return nil, p.openErr
}
return p.session, nil
}
func (p *fakeTurnSessionProvider) Capabilities() zeroruntime.ProviderCapabilities {
return zeroruntime.ProviderCapabilities{}
}
func singleAnswerTurns(answer string) [][]zeroruntime.StreamEvent {
return [][]zeroruntime.StreamEvent{{
{Type: zeroruntime.StreamEventText, Content: answer},
{Type: zeroruntime.StreamEventDone},
}}
}
// TestRunOpenTurnSessionFailureSurfaces verifies a failed session open is a
// clean run-start error: no panic, no stream ever issued, nothing to close.
func TestRunOpenTurnSessionFailureSurfaces(t *testing.T) {
positional := &mockProvider{turns: singleAnswerTurns("never reached")}
tsp := &fakeTurnSessionProvider{openErr: errors.New("handshake refused")}
_, err := Run(context.Background(), "go", positional, Options{
MaxTurns: 2,
TurnSessionProvider: tsp,
})
if err == nil {
t.Fatal("expected an error from a failed session open")
}
if !strings.Contains(err.Error(), "open turn session") || !strings.Contains(err.Error(), "handshake refused") {
t.Fatalf("expected a wrapped open-turn-session error, got %v", err)
}
if len(positional.requests) != 0 {
t.Fatalf("expected no provider request after a failed open, got %d", len(positional.requests))
}
if tsp.opens != 1 {
t.Fatalf("expected exactly one open attempt, got %d", tsp.opens)
}
}
// TestRunCloseFailureIsSafe verifies a Close error at teardown is swallowed:
// the run still returns its normal result.
func TestRunCloseFailureIsSafe(t *testing.T) {
inner := &mockProvider{turns: singleAnswerTurns("done")}
session := &fakeTurnSession{inner: inner, closeErr: errors.New("close blew up")}
tsp := &fakeTurnSessionProvider{session: session}
result, err := Run(context.Background(), "go", inner, Options{
MaxTurns: 2,
TurnSessionProvider: tsp,
})
if err != nil {
t.Fatalf("expected Close failure to be swallowed, got %v", err)
}
if result.FinalAnswer != "done" {
t.Fatalf("expected normal result despite Close error, got %q", result.FinalAnswer)
}
if session.closes != 1 {
t.Fatalf("expected exactly one Close at teardown, got %d", session.closes)
}
}
// TestRunStreamsThroughTurnSession verifies that when a TurnSessionProvider is
// wired, every model request of the run flows through the session (and the
// session is prewarmed once and closed once).
func TestRunStreamsThroughTurnSession(t *testing.T) {
inner := &mockProvider{turns: singleAnswerTurns("via session")}
session := &fakeTurnSession{inner: inner}
tsp := &fakeTurnSessionProvider{session: session}
// The positional provider is a DIFFERENT mock: it must stay untouched,
// proving the session (not the positional provider) carried the run.
positional := &mockProvider{turns: singleAnswerTurns("wrong path")}
result, err := Run(context.Background(), "go", positional, Options{
MaxTurns: 2,
TurnSessionProvider: tsp,
})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "via session" {
t.Fatalf("expected the session-backed answer, got %q", result.FinalAnswer)
}
if len(positional.requests) != 0 {
t.Fatalf("expected the positional provider to be bypassed, got %d requests", len(positional.requests))
}
if session.streams == 0 || session.streams != len(inner.requests) {
t.Fatalf("expected every request to flow through the session: streams=%d inner=%d", session.streams, len(inner.requests))
}
if session.prewarms != 1 {
t.Fatalf("expected exactly one prewarm, got %d", session.prewarms)
}
if session.closes != 1 {
t.Fatalf("expected exactly one close, got %d", session.closes)
}
}
// TestRunDefaultTurnSessionMatchesRawProvider verifies the default (nil
// TurnSessionProvider) path is behavior-identical to passing an explicit
// default adapter over the same provider: same result, same request count.
func TestRunDefaultTurnSessionMatchesRawProvider(t *testing.T) {
run := func(explicit bool) (Result, *mockProvider) {
provider := &mockProvider{turns: escalateThenAnswerTurns("identical")}
registry := tools.NewRegistry()
registry.Register(escalatingTool{target: ""})
options := Options{Registry: registry, MaxTurns: 4}
if explicit {
options.TurnSessionProvider = zeroruntime.NewProviderTurnSessionProvider(provider, zeroruntime.ProviderCapabilities{})
}
result, err := Run(context.Background(), "go", provider, options)
if err != nil {
t.Fatal(err)
}
return result, provider
}
defaultResult, defaultProvider := run(false)
explicitResult, explicitProvider := run(true)
if defaultResult.FinalAnswer != explicitResult.FinalAnswer {
t.Fatalf("final answers diverged: default=%q explicit=%q", defaultResult.FinalAnswer, explicitResult.FinalAnswer)
}
if defaultResult.Turns != explicitResult.Turns {
t.Fatalf("turn counts diverged: default=%d explicit=%d", defaultResult.Turns, explicitResult.Turns)
}
if len(defaultProvider.requests) != len(explicitProvider.requests) {
t.Fatalf("request counts diverged: default=%d explicit=%d", len(defaultProvider.requests), len(explicitProvider.requests))
}
}
// TestRunModelSwitchClosesSessionAndContinues verifies a mid-run model switch
// closes the active session exactly once and streams the rest of the run on
// the new provider.
func TestRunModelSwitchClosesSessionAndContinues(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(escalatingTool{target: "claude-opus-4.1"})
firstInner := &mockProvider{turns: escalateThenAnswerTurns("unused")}
firstSession := &fakeTurnSession{inner: firstInner}
tsp := &fakeTurnSessionProvider{session: firstSession}
secondProvider := &mockProvider{turns: singleAnswerTurns("answered after switch")}
result, err := Run(context.Background(), "go", firstInner, Options{
Registry: registry,
Model: "claude-sonnet-4.5",
MaxTurns: 4,
TurnSessionProvider: tsp,
ModelSwitcher: func(_ context.Context, _ string) (Provider, error) {
return secondProvider, nil
},
})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "answered after switch" {
t.Fatalf("expected the post-switch answer, got %q", result.FinalAnswer)
}
// The original session handled exactly the escalation turn, then was closed
// once by the swap (the teardown defer closes the NEW session, not this one).
if firstSession.streams != 1 {
t.Fatalf("expected the original session to stream exactly the escalation turn, got %d", firstSession.streams)
}
if firstSession.closes != 1 {
t.Fatalf("expected the swap to close the original session exactly once, got %d", firstSession.closes)
}
if len(secondProvider.requests) != 1 {
t.Fatalf("expected the post-switch turn on the new provider, got %d requests", len(secondProvider.requests))
}
}
// TestRunModelSessionSwitcherCarriesOptimizedSession verifies the target-aware
// switcher: the switched-to TurnSessionProvider's DISTINCT session receives the
// post-switch streams, is prewarmed exactly once, and is closed exactly once at
// teardown — and the legacy ModelSwitcher is never consulted when both are set.
func TestRunModelSessionSwitcherCarriesOptimizedSession(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(escalatingTool{target: "claude-opus-4.1"})
firstInner := &mockProvider{turns: escalateThenAnswerTurns("unused")}
firstSession := &fakeTurnSession{inner: firstInner}
initial := &fakeTurnSessionProvider{session: firstSession}
switchedInner := &mockProvider{turns: singleAnswerTurns("answered on the switched session")}
switchedSession := &fakeTurnSession{inner: switchedInner}
switched := &fakeTurnSessionProvider{session: switchedSession}
var switchedTo string
legacyCalled := false
result, err := Run(context.Background(), "go", firstInner, Options{
Registry: registry,
Model: "claude-sonnet-4.5",
MaxTurns: 4,
TurnSessionProvider: initial,
ModelSessionSwitcher: func(_ context.Context, modelID string) (zeroruntime.TurnSessionProvider, error) {
switchedTo = modelID
return switched, nil
},
ModelSwitcher: func(_ context.Context, _ string) (Provider, error) {
legacyCalled = true
return nil, errors.New("legacy switcher must not be consulted")
},
})
if err != nil {
t.Fatal(err)
}
if result.FinalAnswer != "answered on the switched session" {
t.Fatalf("expected the switched session's answer, got %q", result.FinalAnswer)
}
if switchedTo != "claude-opus-4.1" {
t.Fatalf("expected switch to claude-opus-4.1, got %q", switchedTo)
}
if legacyCalled {
t.Fatal("legacy ModelSwitcher was consulted despite ModelSessionSwitcher being set")
}
if switched.opens != 1 {
t.Fatalf("expected exactly one open of the switched provider, got %d", switched.opens)
}
// The switched session carries the rest of the run: it streams the answer
// turn, is prewarmed exactly once at the swap, and is closed exactly once by
// the teardown defer.
if switchedSession.streams != 1 || len(switchedInner.requests) != 1 {
t.Fatalf("expected the switched session to stream the post-switch turn, streams=%d inner=%d", switchedSession.streams, len(switchedInner.requests))
}
if switchedSession.prewarms != 1 {
t.Fatalf("expected the switched session to be prewarmed exactly once, got %d", switchedSession.prewarms)
}
if switchedSession.closes != 1 {
t.Fatalf("expected the switched session to be closed exactly once at teardown, got %d", switchedSession.closes)
}
// The original session ends with the swap: one stream, one close.
if firstSession.streams != 1 || firstSession.closes != 1 {
t.Fatalf("expected the original session to end at the swap (streams=%d closes=%d)", firstSession.streams, firstSession.closes)
}
}
// TestRunModelSessionSwitcherOpenFailureIsNonFatal verifies the swap-time open
// failure: the switcher returns a TurnSessionProvider whose OpenTurnSession
// fails. The run stays non-fatal on the ORIGINAL session (closed only at
// teardown), and the switch-failure note reaches the next request.
func TestRunModelSessionSwitcherOpenFailureIsNonFatal(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(escalatingTool{target: "claude-opus-4.1"})
inner := &mockProvider{turns: escalateThenAnswerTurns("recovered after failed open")}
session := &fakeTurnSession{inner: inner}
failingOpen := &fakeTurnSessionProvider{openErr: errors.New("switched handshake refused")}
result, err := Run(context.Background(), "go", inner, Options{
Registry: registry,
Model: "claude-sonnet-4.5",
MaxTurns: 4,
TurnSessionProvider: &fakeTurnSessionProvider{session: session},
ModelSessionSwitcher: func(_ context.Context, _ string) (zeroruntime.TurnSessionProvider, error) {
return failingOpen, nil
},
})
if err != nil {
t.Fatalf("expected a failed swap open to be non-fatal, got %v", err)
}
if result.FinalAnswer != "recovered after failed open" {
t.Fatalf("expected the run to recover on the original session, got %q", result.FinalAnswer)
}
if failingOpen.opens != 1 {
t.Fatalf("expected exactly one open attempt on the switched provider, got %d", failingOpen.opens)
}
// The original session serves both turns and is closed only at teardown —
// never by the failed swap.
if session.streams != 2 || session.closes != 1 {
t.Fatalf("expected the original session to serve both turns and close once (streams=%d closes=%d)", session.streams, session.closes)
}
var sawNote bool
for _, message := range inner.requests[1].Messages {
if message.Role == zeroruntime.MessageRoleUser && strings.Contains(strings.ToLower(message.Content), "could not switch") {
sawNote = true
}
}
if !sawNote {
t.Fatalf("expected a switch-failure note on the next turn, messages: %+v", inner.requests[1].Messages)
}
}
// TestRunModelSessionSwitcherErrorIsNonFatal verifies a ModelSessionSwitcher
// error keeps the run on the current session with a transcript note — the same
// non-fatal contract as ModelSwitcher.
func TestRunModelSessionSwitcherErrorIsNonFatal(t *testing.T) {
registry := tools.NewRegistry()
registry.Register(escalatingTool{target: "claude-opus-4.1"})
inner := &mockProvider{turns: escalateThenAnswerTurns("recovered on original session")}
session := &fakeTurnSession{inner: inner}
result, err := Run(context.Background(), "go", inner, Options{
Registry: registry,
Model: "claude-sonnet-4.5",
MaxTurns: 4,
TurnSessionProvider: &fakeTurnSessionProvider{session: session},
ModelSessionSwitcher: func(_ context.Context, _ string) (zeroruntime.TurnSessionProvider, error) {
return nil, errors.New("session build blew up")
},
})
if err != nil {
t.Fatalf("expected session-switcher error to be non-fatal, got %v", err)
}
if result.FinalAnswer != "recovered on original session" {
t.Fatalf("expected the run to recover on the original session, got %q", result.FinalAnswer)
}
// Both turns stream through the original session; it closes once at teardown.
if session.streams != 2 || session.closes != 1 {
t.Fatalf("expected the original session to serve both turns and close once (streams=%d closes=%d)", session.streams, session.closes)
}
var sawNote bool
for _, request := range inner.requests[1].Messages {
if request.Role == zeroruntime.MessageRoleUser && strings.Contains(strings.ToLower(request.Content), "could not switch") {
sawNote = true
}
}
if !sawNote {
t.Fatalf("expected a non-fatal switch-failure note on the next turn, messages: %+v", inner.requests[1].Messages)
}
}
+6 -27
View File
@@ -61,13 +61,10 @@ const (
)
type ToolResult struct {
ToolCallID string
Name string
Status tools.Status
Output string
// Truncated reports that the tool's model-visible output omitted content.
// The full result may be recoverable through Meta["spill_path"].
Truncated bool
ToolCallID string
Name string
Status tools.Status
Output string
Meta map[string]string
Redacted bool
ChangedFiles []string
@@ -283,21 +280,6 @@ type Options struct {
// request), so every existing caller is unaffected. A returned error is
// non-fatal: the run continues on the current model.
ModelSwitcher func(ctx context.Context, modelID string) (Provider, error)
// TurnSessionProvider, when set, supplies the turn session the run streams
// through — the seam an optimized provider session (connection reuse,
// prewarm, native compaction) plugs into without touching the loop. nil
// keeps the default: the loop wraps the passed provider in a no-op session
// whose Stream IS provider.StreamCompletion, so behavior is byte-identical
// and every existing caller is unaffected.
TurnSessionProvider zeroruntime.TurnSessionProvider
// ModelSessionSwitcher, when set, is the target-aware escalation hook: the
// loop prefers it over ModelSwitcher, and its TurnSessionProvider keeps an
// optimized session (and its capabilities) across a mid-run model switch.
// nil falls back to ModelSwitcher, whose bare Provider is wrapped in the
// default no-op session — today's behavior, unchanged. Same non-fatal error
// contract as ModelSwitcher: a returned error records a note and the run
// continues on the current model and session.
ModelSessionSwitcher func(ctx context.Context, modelID string) (zeroruntime.TurnSessionProvider, error)
// Trace, when set, records per-turn timing for the run: the loop stamps
// spans (prompt build, generation, tool execution, permission wait,
// compaction, provider connect) and counters (model requests, tool calls,
@@ -330,11 +312,8 @@ type Options struct {
// continuation cue ("…Let me check the config:"). The loop then nudges the
// model to continue instead, bounded by maxContinueNudges (and still by
// MaxTurns and the run deadline); if the model keeps stalling, the run
// finalizes as INCOMPLETE (Result.Incomplete) rather than success. When the
// run's profile also enables SelfCorrect, an otherwise-complete turn gets one
// task-grounded semantic check before success; profiles without SelfCorrect
// add no model call. Default false leaves the loop byte-identical, so the
// interactive TUI is unaffected.
// finalizes as INCOMPLETE (Result.Incomplete) rather than success. Default
// false leaves the loop byte-identical, so the interactive TUI is unaffected.
RequireCompletionSignal bool
runPermissions *permissionRunState
+23 -58
View File
@@ -412,36 +412,6 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
}
}
// Optimized OpenAI turn sessions (ZERO_OPENAI_TURN_SESSION, default off).
// nil when gated off or the profile is ineligible: agent.Run then wraps the
// provider in its default adapter — the exact code path of today. The
// session switcher is installed only when the run START is optimized, so
// the legacy ModelSwitcher path above stays untouched otherwise.
turnSessions, _ := providers.OptimizedTurnSessions(resolved.Provider, provider, providers.Options{})
var modelSessionSwitcher func(context.Context, string) (zeroruntime.TurnSessionProvider, error)
if options.allowEscalation && turnSessions != nil {
modelSessionSwitcher = func(_ context.Context, modelID string) (zeroruntime.TurnSessionProvider, error) {
switchedProfile := resolved.Provider
switchedProfile.Model = modelID
switchedProvider, err := deps.newProvider(switchedProfile)
if err != nil {
return nil, err
}
if switchedProvider == nil {
// The loop treats a nil session source as "no swap" — mirror the
// legacy closure's (nil, nil) contract.
return nil, nil
}
currentModel = modelID
if optimized, ok := providers.OptimizedTurnSessions(switchedProfile, switchedProvider, providers.Options{}); ok {
return optimized, nil
}
// Ineligible switch target: default adapter, but with the switched
// model's resolved capability projection preserved.
return providers.DefaultTurnSessions(switchedProfile, switchedProvider, providers.Options{}), nil
}
}
runMetadata, err := resolveExecRunMetadata(resolved.Provider)
if err != nil {
return writeExecProviderError(stdout, stderr, options.outputFormat, "provider_error", err.Error())
@@ -602,31 +572,29 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
hookDispatcher, hookSkip := newHookDispatcherWithExtra(workspaceRoot, pluginActivation.hooks, trustRoot)
emitTrustNotice(stderr, hookSkip, pluginActivation.trustSkip, mcpSkip)
result, err := agent.Run(runCtx, agentPrompt, provider, agent.Options{
MaxTurns: resolved.MaxTurns,
ContextWindow: resolveAgentContextWindow(runCtx, modelRegistry, resolved.Provider),
DeferThreshold: effectiveDeferThreshold,
Specialists: specialistRuntime.specialistInfos(),
Skills: pluginActivation.skillInfos(deps.skillsDir()),
SessionID: preparedSession.Session.SessionID,
CallingSessionID: options.callingSessionID,
CallingToolUseID: options.callingToolUseID,
Tag: options.tag,
Depth: options.depth,
SessionTitle: sessionTitle,
ProviderName: resolved.Provider.Name,
Model: resolved.Provider.Model,
ModelSwitcher: modelSwitcher,
TurnSessionProvider: turnSessions,
ModelSessionSwitcher: modelSessionSwitcher,
ReasoningEffort: forwardEffort,
Trace: traceRecorder,
Cwd: workspaceRoot,
Images: images,
Registry: registry,
PermissionMode: permissionMode,
Autonomy: options.autonomy,
SelfCorrect: selfCorrector,
FileDiagnostics: fileDiagnostics,
MaxTurns: resolved.MaxTurns,
ContextWindow: resolveAgentContextWindow(runCtx, modelRegistry, resolved.Provider),
DeferThreshold: effectiveDeferThreshold,
Specialists: specialistRuntime.specialistInfos(),
Skills: pluginActivation.skillInfos(deps.skillsDir()),
SessionID: preparedSession.Session.SessionID,
CallingSessionID: options.callingSessionID,
CallingToolUseID: options.callingToolUseID,
Tag: options.tag,
Depth: options.depth,
SessionTitle: sessionTitle,
ProviderName: resolved.Provider.Name,
Model: resolved.Provider.Model,
ModelSwitcher: modelSwitcher,
ReasoningEffort: forwardEffort,
Trace: traceRecorder,
Cwd: workspaceRoot,
Images: images,
Registry: registry,
PermissionMode: permissionMode,
Autonomy: options.autonomy,
SelfCorrect: selfCorrector,
FileDiagnostics: fileDiagnostics,
// Headless exec: don't accept a no-tool-call turn as "done" while work
// clearly remains (pending plan items / a mid-step continuation cue) —
// nudge to continue, and finalize as INCOMPLETE rather than false success
@@ -668,9 +636,6 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
if len(result.Meta) > 0 {
payload["meta"] = result.Meta
}
if result.Truncated {
payload["truncated"] = true
}
if result.Redacted {
payload["redacted"] = true
}
-125
View File
@@ -1,125 +0,0 @@
package cli
import (
"bytes"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/providers/openai"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// TestRunExecOptimizedSessionUnderGate proves the end-to-end wiring: with
// ZERO_OPENAI_TURN_SESSION on and a real official-OpenAI provider, a headless
// run streams through the optimized session — the server sees the prewarm HEAD
// probe in addition to the turn's POST.
func TestRunExecOptimizedSessionUnderGate(t *testing.T) {
t.Setenv("XDG_DATA_HOME", t.TempDir())
t.Setenv("ZERO_OPENAI_TURN_SESSION", "1")
cwd := t.TempDir()
var heads, posts atomic.Int64
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodHead:
heads.Add(1)
w.WriteHeader(http.StatusMethodNotAllowed)
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/chat/completions"):
posts.Add(1)
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"done\"}}]}\n\n"))
_, _ = w.Write([]byte("data: [DONE]\n\n"))
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()
var stdout, stderr bytes.Buffer
exitCode := runWithDeps([]string{"exec", "say done"}, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return cwd, nil },
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return config.ResolvedConfig{
ActiveProvider: "official-openai",
Provider: config.ProviderProfile{
Name: "official-openai",
ProviderKind: config.ProviderKindOpenAI,
BaseURL: server.URL,
APIKey: "sk-exec-test",
Model: "pr8-exec-model",
},
}, nil
},
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
// Pin a keep-alive transport so the probe fires on every platform:
// the shared default transport disables keep-alives on macOS, where
// the session correctly skips the prewarm.
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.DisableKeepAlives = false
return openai.New(openai.Options{
APIKey: profile.APIKey,
BaseURL: profile.BaseURL,
Model: profile.Model,
HTTPClient: &http.Client{Transport: transport},
})
},
})
if exitCode != exitSuccess {
t.Fatalf("exitCode = %d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
}
if got := posts.Load(); got < 1 {
t.Fatalf("server saw %d chat-completions POSTs, want >= 1", got)
}
// The prewarm probe is asynchronous; it fires well before the model turn
// finishes, but poll rather than assuming scheduling order. The deadline
// exceeds the production prewarmTimeout (3s) so a slow probe cannot flake
// this assertion.
deadline := time.Now().Add(5 * time.Second)
for heads.Load() == 0 && time.Now().Before(deadline) {
time.Sleep(10 * time.Millisecond)
}
if got := heads.Load(); got != 1 {
t.Fatalf("server saw %d prewarm HEAD probes, want exactly 1", got)
}
}
// TestRunExecGateOnFallsBackForFakeProvider proves fallback safety: with the
// gate on but a provider that is not the concrete *openai.Provider, the run
// proceeds on the default path exactly as today.
func TestRunExecGateOnFallsBackForFakeProvider(t *testing.T) {
t.Setenv("XDG_DATA_HOME", t.TempDir())
t.Setenv("ZERO_OPENAI_TURN_SESSION", "1")
cwd := t.TempDir()
var builds int
var stdout, stderr bytes.Buffer
exitCode := runWithDeps([]string{"exec", "--model", "claude-haiku-4.5", "hello"}, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return cwd, nil },
resolveConfig: func(_ string, overrides config.Overrides) (config.ResolvedConfig, error) {
model := "claude-haiku-4.5"
if overrides.Provider.Model != "" {
model = overrides.Provider.Model
}
cfg := execResolvedConfig()
cfg.Provider.ProviderKind = config.ProviderKindAnthropic
cfg.Provider.Model = model
cfg.MaxTurns = 3
return cfg, nil
},
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
builds++
return &escalatingExecProvider{}, nil
},
})
if exitCode != exitSuccess {
t.Fatalf("exitCode = %d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
}
if builds != 1 {
t.Fatalf("newProvider called %d times, want 1 (default path, no optimized session)", builds)
}
}
+1 -5
View File
@@ -153,9 +153,6 @@ func (writer *execEventWriter) toolResult(result agent.ToolResult) {
if len(result.Meta) > 0 {
payload["meta"] = result.Meta
}
if result.Truncated {
payload["truncated"] = true
}
if result.Redacted {
payload["redacted"] = true
}
@@ -169,8 +166,7 @@ func (writer *execEventWriter) toolResult(result agent.ToolResult) {
return
}
if writer.format == execOutputStreamJSON {
output, surfaceTruncated := truncateForStreamJSONOutput(result.Output)
truncated := result.Truncated || surfaceTruncated
output, truncated := truncateForStreamJSONOutput(result.Output)
event := streamjson.Event{
Type: streamjson.EventToolResult,
RunID: writer.runID,
-36
View File
@@ -1,12 +1,8 @@
package cli
import (
"bytes"
"encoding/json"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/agent"
"github.com/Gitlawb/zero/internal/tools"
)
@@ -20,35 +16,3 @@ func TestStreamJSONSideEffectReportsNoneForControlTool(t *testing.T) {
t.Fatalf("streamJSONSideEffect(escalate_model) = %q, want none", got)
}
}
func TestExecWriterPropagatesToolResultTruncation(t *testing.T) {
for _, format := range []execOutputFormat{execOutputJSON, execOutputStreamJSON} {
t.Run(string(format), func(t *testing.T) {
var stdout, stderr bytes.Buffer
writer := execEventWriter{
stdout: &stdout,
stderr: &stderr,
format: format,
runID: "run_budget",
streamedText: &strings.Builder{},
}
writer.toolResult(agent.ToolResult{
ToolCallID: "call_budget",
Name: "read_file",
Status: tools.StatusOK,
Output: "bounded output",
Truncated: true,
})
if writer.err != nil {
t.Fatalf("toolResult: %v", writer.err)
}
var payload map[string]any
if err := json.Unmarshal(bytes.TrimSpace(stdout.Bytes()), &payload); err != nil {
t.Fatalf("decode output %q: %v", stdout.String(), err)
}
if payload["truncated"] != true {
t.Fatalf("truncated = %#v, want true; payload=%#v", payload["truncated"], payload)
}
})
}
}
-263
View File
@@ -12,54 +12,6 @@ import (
"time"
)
// Mirror internal/cli/exec.go exit codes so wrapper doctor fallback tests assert
// the same CLI contract as the Go doctor command.
const (
wrapperExitSuccess = 0
wrapperExitUsage = 2
wrapperExitDoctor = 1
)
// Canonical Bun recovery copy from bin/zero.js bunRecoveryParagraph() — shared
// by the generic missing-binary path and the doctor text fallback.
const (
bunRecoveryLead = "You installed with Bun, which does not run dependency lifecycle scripts"
bunPmTrustProject = "bun pm trust @gitlawb/zero"
bunPmTrustGlobal = "bun pm -g trust @gitlawb/zero"
bunRecoveryTrustedDeps = `"trustedDependencies": ["@gitlawb/zero"]`
buildFromSourceLead = "If this platform has no prebuilt binary, build from source:"
)
func runWrapperFixture(t *testing.T, wrapperPath string, args ...string) (stdout string, stderr string, exitCode int) {
return runWrapperFixtureWithEnv(t, wrapperPath, nil, args...)
}
func runWrapperFixtureWithEnv(t *testing.T, wrapperPath string, extraEnv []string, args ...string) (stdout string, stderr string, exitCode int) {
t.Helper()
node := requireNode(t)
ctx, cancel := context.WithTimeout(context.Background(), nodeWrapperTimeout())
defer cancel()
command := nodeWrapperCommand(ctx, node, wrapperPath, args...)
command.Env = append(withoutEnvKey(command.Env, "ZERO_LOCAL_CONTROL_HELPERS"), "ZERO_LOCAL_CONTROL_HELPERS=")
command.Env = append(withoutEnvKey(command.Env, "ZERO_WRAPPER_SIMULATE_BUN"), extraEnv...)
var stdoutBuf, stderrBuf strings.Builder
command.Stdout = &stdoutBuf
command.Stderr = &stderrBuf
err := command.Run()
if ctx.Err() != nil {
t.Fatalf("wrapper timed out: %v; stdout: %s stderr: %s", ctx.Err(), stdoutBuf.String(), stderrBuf.String())
}
exitCode = wrapperExitSuccess
if err != nil {
exitErr, ok := err.(*exec.ExitError)
if !ok {
t.Fatalf("wrapper err = %v, want *exec.ExitError; stdout=%s stderr=%s", err, stdoutBuf.String(), stderrBuf.String())
}
exitCode = exitErr.ExitCode()
}
return stdoutBuf.String(), stderrBuf.String(), exitCode
}
func TestPackageBinPointsToNodeWrapper(t *testing.T) {
root := repoRoot(t)
bytes, err := os.ReadFile(filepath.Join(root, "package.json"))
@@ -441,221 +393,6 @@ func nodeArchName() string {
}
}
// Issue #405: `zero doctor` is the diagnostic command, so when the native
// binary is the thing that's broken it must NOT bail with the generic wrapper
// error; that's exactly the blind alley the bug report calls out. Instead it
// emits a doctor-shaped FAIL line for the runtime so the user's own diagnostic
// surfaces the real cause. We assert on the doctor-report shape (so the doctor
// UX matches what the Go-side doctor.Format produces) and on exit 1 (a missing
// binary is still a hard failure, not a pass).
func TestNodeWrapperDoctorReportsMissingNativeBinaryAsDoctorFail(t *testing.T) {
wrapperPath := copyWrapperFixture(t)
stdout, stderr, exitCode := runWrapperFixture(t, wrapperPath, "doctor")
if exitCode != wrapperExitDoctor {
t.Fatalf("doctor exit = %d, want %d; stdout=%s stderr=%s", exitCode, wrapperExitDoctor, stdout, stderr)
}
if strings.TrimSpace(stderr) != "" {
t.Fatalf("doctor text report must go to stdout only, got stderr=%q", stderr)
}
// Doctor-shaped report, not the generic wrapper bail. Matches the shape the
// Go-side doctor.Format emits: a header, "Overall: <pass/fail>", then
// "[<status>] <id> - <message>" lines.
if !strings.Contains(stdout, "Zero doctor report (") {
t.Fatalf("doctor output should start with a doctor report header, got stdout=%q", stdout)
}
if !strings.Contains(stdout, "Overall: fail") {
t.Fatalf("doctor overall must be fail when the native binary is missing, got stdout=%q", stdout)
}
if !strings.Contains(stdout, "[fail] runtime.go") {
t.Fatalf("doctor must report a failing runtime.go check, got stdout=%q", stdout)
}
if !strings.Contains(stdout, "Native zero binary is missing next to the npm wrapper") {
t.Fatalf("doctor must name the actual cause (missing native binary), got stdout=%q", stdout)
}
// The actionable remedy must point at the postinstall script that would fix
// the install, not just "build from source".
if !strings.Contains(stdout, "postinstall.mjs") {
t.Fatalf("doctor remedy should name the postinstall script, got stdout=%q", stdout)
}
// Regression guard for the original blind-alley bug: the doctor path must
// NOT emit the generic wrapper bail (that's what sent users debugging the
// wrong thing).
if strings.Contains(stdout, "[zero] No native binary is available for this install") {
t.Fatalf("doctor must not emit the generic wrapper bail, got stdout=%q", stdout)
}
}
func TestNodeWrapperDoctorJSONReportsMissingNativeBinaryAsJSONFail(t *testing.T) {
wrapperPath := copyWrapperFixture(t)
stdout, stderr, exitCode := runWrapperFixture(t, wrapperPath, "doctor", "--json")
if exitCode != wrapperExitDoctor {
t.Fatalf("doctor --json exit = %d, want %d; stdout=%s stderr=%s", exitCode, wrapperExitDoctor, stdout, stderr)
}
if strings.TrimSpace(stderr) != "" {
t.Fatalf("doctor --json should write machine-readable output to stdout only, got stderr=%q", stderr)
}
var report struct {
GeneratedAt string `json:"generatedAt"`
OK bool `json:"ok"`
Checks []struct {
ID string `json:"id"`
Label string `json:"label"`
Status string `json:"status"`
Message string `json:"message"`
Details map[string]any `json:"details"`
} `json:"checks"`
}
if err := json.Unmarshal([]byte(stdout), &report); err != nil {
t.Fatalf("doctor --json stdout should be valid JSON, got %q: %v", stdout, err)
}
if report.GeneratedAt == "" {
t.Fatalf("doctor --json report should include generatedAt: %#v", report)
}
if report.OK {
t.Fatalf("doctor --json ok = true, want false: %#v", report)
}
if len(report.Checks) != 1 {
t.Fatalf("doctor --json checks length = %d, want 1: %#v", len(report.Checks), report.Checks)
}
check := report.Checks[0]
if check.ID != "runtime.go" || check.Label != "Go runtime" || check.Status != "fail" {
t.Fatalf("doctor --json runtime check = %#v, want failing runtime.go check", check)
}
if !strings.Contains(check.Message, "Native zero binary is missing next to the npm wrapper") {
t.Fatalf("doctor --json must name the actual cause, got %#v", check)
}
remedy, _ := check.Details["remedy"].(string)
if !strings.Contains(remedy, "postinstall.mjs") {
t.Fatalf("doctor --json remedy should name the postinstall script, got %#v", check.Details)
}
}
func TestNodeWrapperDoctorHelpShowsUsage(t *testing.T) {
wrapperPath := copyWrapperFixture(t)
for _, args := range [][]string{{"doctor", "--help"}, {"doctor", "help"}, {"doctor", "-h"}} {
stdout, stderr, exitCode := runWrapperFixture(t, wrapperPath, args...)
if exitCode != wrapperExitSuccess {
t.Fatalf("%v exit = %d, want %d; stdout=%s stderr=%s", args, exitCode, wrapperExitSuccess, stdout, stderr)
}
if strings.TrimSpace(stderr) != "" {
t.Fatalf("%v help must write to stdout only, got stderr=%q", args, stderr)
}
if !strings.Contains(stdout, "Usage:") || !strings.Contains(stdout, "zero doctor [flags]") {
t.Fatalf("%v help output = %q, want doctor usage text", args, stdout)
}
}
}
func TestNodeWrapperDoctorRejectsUnknownFlag(t *testing.T) {
wrapperPath := copyWrapperFixture(t)
stdout, stderr, exitCode := runWrapperFixture(t, wrapperPath, "doctor", "--bogus")
if exitCode != wrapperExitUsage {
t.Fatalf("doctor --bogus exit = %d, want %d; stdout=%s stderr=%s", exitCode, wrapperExitUsage, stdout, stderr)
}
if strings.TrimSpace(stdout) != "" {
t.Fatalf("invalid doctor flag should not write stdout, got %q", stdout)
}
if stderr != "[zero] unknown doctor flag \"--bogus\"\n" {
t.Fatalf("invalid doctor flag stderr = %q", stderr)
}
}
// `doctor --connectivity` (a valid doctor invocation with a trailing flag) must
// take the same doctor-shaped path: parseDoctorArgs accepts --connectivity and
// the missing-binary fallback still reports the runtime failure.
func TestNodeWrapperDoctorWithFlagsStillReportsDoctorFail(t *testing.T) {
wrapperPath := copyWrapperFixture(t)
stdout, stderr, exitCode := runWrapperFixture(t, wrapperPath, "doctor", "--connectivity")
if exitCode != wrapperExitDoctor {
t.Fatalf("doctor --connectivity exit = %d, want %d; stdout=%s stderr=%s", exitCode, wrapperExitDoctor, stdout, stderr)
}
if strings.TrimSpace(stderr) != "" {
t.Fatalf("doctor --connectivity must write to stdout only, got stderr=%q", stderr)
}
if !strings.Contains(stdout, "[fail] runtime.go") {
t.Fatalf("doctor --connectivity must still emit the failing runtime.go line, got stdout=%q", stdout)
}
if strings.Contains(stdout, "[zero] No native binary is available for this install") {
t.Fatalf("doctor --connectivity must not fall back to the generic bail, got stdout=%q", stdout)
}
}
func TestNodeWrapperGenericReportsBunRecoveryWhenInstalledByBun(t *testing.T) {
wrapperPath := copyWrapperFixture(t)
_, stderr, exitCode := runWrapperFixtureWithEnv(t, wrapperPath, []string{"ZERO_WRAPPER_SIMULATE_BUN=1"}, "--version")
if exitCode != wrapperExitDoctor {
t.Fatalf("generic missing-binary exit = %d, want %d; stderr=%s", exitCode, wrapperExitDoctor, stderr)
}
assertBunRecoveryMessage(t, stderr)
if !strings.Contains(stderr, buildFromSourceLead) {
t.Fatalf("generic bun stderr should include build-from-source guidance, got %q", stderr)
}
}
func TestNodeWrapperDoctorReportsBunRecoveryWhenInstalledByBun(t *testing.T) {
wrapperPath := copyWrapperFixture(t)
stdout, stderr, exitCode := runWrapperFixtureWithEnv(t, wrapperPath, []string{"ZERO_WRAPPER_SIMULATE_BUN=1"}, "doctor")
if exitCode != wrapperExitDoctor {
t.Fatalf("doctor bun exit = %d, want %d; stdout=%s stderr=%s", exitCode, wrapperExitDoctor, stdout, stderr)
}
if strings.TrimSpace(stderr) != "" {
t.Fatalf("doctor bun report must go to stdout only, got stderr=%q", stderr)
}
if !strings.Contains(stdout, "[fail] runtime.go") {
t.Fatalf("doctor bun report must still fail runtime.go, got stdout=%q", stdout)
}
assertBunRecoveryMessage(t, stdout)
if !strings.Contains(stdout, buildFromSourceLead) {
t.Fatalf("doctor bun report should reuse generic build-from-source guidance, got stdout=%q", stdout)
}
if strings.Contains(stdout, "If reinstall fails") {
t.Fatalf("doctor bun report must not use a separate build-from-source string, got stdout=%q", stdout)
}
}
func TestNodeWrapperDoctorAndGenericShareBunRecoveryCopy(t *testing.T) {
wrapperPath := copyWrapperFixture(t)
simulateBun := []string{"ZERO_WRAPPER_SIMULATE_BUN=1"}
_, genericStderr, _ := runWrapperFixtureWithEnv(t, wrapperPath, simulateBun, "--version")
doctorStdout, _, _ := runWrapperFixtureWithEnv(t, wrapperPath, simulateBun, "doctor")
genericBun := extractBunRecoveryBlock(genericStderr)
doctorBun := extractBunRecoveryBlock(doctorStdout)
if genericBun != doctorBun {
t.Fatalf("bun recovery copy drift:\ngeneric=%q\ndoctor=%q", genericBun, doctorBun)
}
}
func assertBunRecoveryMessage(t *testing.T, output string) {
t.Helper()
if !strings.Contains(output, bunRecoveryLead) {
t.Fatalf("output should include bun recovery lead, got %q", output)
}
if !strings.Contains(output, bunPmTrustProject) {
t.Fatalf("output should include project bun pm trust guidance, got %q", output)
}
if !strings.Contains(output, bunPmTrustGlobal) {
t.Fatalf("output should include global bun pm trust guidance, got %q", output)
}
if !strings.Contains(output, bunRecoveryTrustedDeps) {
t.Fatalf("output should include trustedDependencies fallback guidance, got %q", output)
}
if !strings.Contains(output, "to your project package.json and reinstall.") {
t.Fatalf("output should mention package.json reinstall fallback, got %q", output)
}
}
func extractBunRecoveryBlock(output string) string {
start := strings.Index(output, bunRecoveryLead)
if start < 0 {
return ""
}
end := strings.Index(output[start:], buildFromSourceLead)
if end < 0 {
return strings.TrimSpace(output[start:])
}
return strings.TrimSpace(output[start : start+end])
}
func TestNodeWrapperLaunchesNativeBinary(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("mock executable fixture uses a POSIX shell script")
-51
View File
@@ -108,57 +108,6 @@ func New(profile config.ProviderProfile, options Options) (zeroruntime.Provider,
}
}
// NewTurnSessionProvider builds the default TurnSessionProvider for a resolved
// profile: it constructs the provider exactly as New does, then wraps it with a
// ProviderCapabilities projection computed from the same model-registry entry
// New already resolves. The default session's Stream is the provider's
// StreamCompletion, so runtime behavior is identical to using New directly.
func NewTurnSessionProvider(profile config.ProviderProfile, options Options) (zeroruntime.TurnSessionProvider, error) {
provider, err := New(profile, options)
if err != nil {
return nil, err
}
caps, err := resolveCapabilities(profile, options)
if err != nil {
return nil, err
}
return zeroruntime.NewProviderTurnSessionProvider(provider, caps), nil
}
// resolveCapabilities projects the resolved profile's model-registry entry into
// the flat zeroruntime.ProviderCapabilities (zeroruntime cannot reference
// modelregistry types — modelregistry imports zeroruntime, so a typed field
// would form an import cycle). A model absent from the registry yields only the
// resolved API model id and max-output tokens; the rest stays zero (unknown).
func resolveCapabilities(profile config.ProviderProfile, options Options) (zeroruntime.ProviderCapabilities, error) {
resolved, err := resolveProfile(profile, options)
if err != nil {
return zeroruntime.ProviderCapabilities{}, err
}
caps := zeroruntime.ProviderCapabilities{
Model: resolved.apiModel,
MaxOutputTokens: resolved.maxOutputTokens,
}
registry, err := defaultRegistry(options.ModelRegistry)
if err != nil {
return zeroruntime.ProviderCapabilities{}, err
}
if entry, ok := registry.Get(strings.TrimSpace(profile.Model)); ok {
caps.ContextWindow = entry.ContextLimits.ContextWindow
caps.SupportsVision = entry.Supports(modelregistry.ModelCapabilityVision)
caps.SupportsReasoning = entry.Supports(modelregistry.ModelCapabilityReasoning)
caps.SupportsPromptCache = entry.Supports(modelregistry.ModelCapabilityPromptCache)
// Read efforts through Registry.ReasoningEfforts (not the raw entry) so
// the projection matches what the /effort picker and the run-time
// resolver advertise — including the name-based fallback for catalog
// entries that enumerate no efforts of their own.
for _, effort := range registry.ReasoningEfforts(entry.ID) {
caps.ReasoningEfforts = append(caps.ReasoningEfforts, string(effort))
}
}
return caps, nil
}
func parseThinkTagsForProfile(profile config.ProviderProfile, resolved resolvedProfile) bool {
if profile.ParseThinkTags != nil {
return *profile.ParseThinkTags
@@ -1,194 +0,0 @@
package providers
import (
"context"
"errors"
"testing"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/modelregistry"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// TestNewTurnSessionProviderForEveryKind asserts every current provider kind
// wraps into the default TurnSessionProvider: construction succeeds, a session
// opens, and Compact reports unsupported (the default adapter contract).
func TestNewTurnSessionProviderForEveryKind(t *testing.T) {
kinds := []config.ProviderKind{
config.ProviderKindOpenAI,
config.ProviderKindOpenAICompatible,
config.ProviderKindAnthropic,
config.ProviderKindAnthropicCompat,
config.ProviderKindGoogle,
}
for _, kind := range kinds {
t.Run(string(kind), func(t *testing.T) {
tsp, err := NewTurnSessionProvider(config.ProviderProfile{
Name: "pr7-" + string(kind),
ProviderKind: kind,
BaseURL: "https://provider.example/v1",
APIKey: "sk-turn-session-test",
// A model absent from the registry: resolution falls back to the
// raw id, so this test stays hermetic across catalog changes.
Model: "pr7-unregistered-model",
}, Options{UserAgent: "zero-turn-session-test"})
if err != nil {
t.Fatalf("NewTurnSessionProvider(%s): %v", kind, err)
}
session, err := tsp.OpenTurnSession(context.Background())
if err != nil {
t.Fatalf("OpenTurnSession(%s): %v", kind, err)
}
defer func() {
if closeErr := session.Close(); closeErr != nil {
t.Fatalf("Close(%s): %v", kind, closeErr)
}
}()
if err := session.Prewarm(context.Background()); err != nil {
t.Fatalf("Prewarm(%s): %v", kind, err)
}
if _, compactErr := session.Compact(context.Background(), zeroruntime.CompletionRequest{}); !errors.Is(compactErr, zeroruntime.ErrCompactionUnsupported) {
t.Fatalf("Compact(%s) = %v, want ErrCompactionUnsupported", kind, compactErr)
}
caps := tsp.Capabilities()
if caps.Model != "pr7-unregistered-model" {
t.Fatalf("Capabilities(%s).Model = %q, want the resolved api model", kind, caps.Model)
}
if caps.NativeCompaction {
t.Fatalf("Capabilities(%s).NativeCompaction = true, want false for the default adapter", kind)
}
})
}
}
// TestNewTurnSessionProviderProjectsRegistryCapabilities asserts the factory
// projects the model-registry entry (context limits, capability flags,
// reasoning efforts) into the flat ProviderCapabilities.
func TestNewTurnSessionProviderProjectsRegistryCapabilities(t *testing.T) {
registry, err := modelregistry.NewRegistry([]modelregistry.ModelEntry{{
ID: "pr7-caps-model",
DisplayName: "PR7 Capability Probe",
APIModel: "pr7-caps-api-model",
Provider: modelregistry.ProviderOpenAI,
ContextLimits: modelregistry.ContextLimits{
ContextWindow: 200_000,
MaxOutputTokens: 64_000,
},
ReasoningEfforts: []modelregistry.ReasoningEffort{
modelregistry.ReasoningEffortLow,
modelregistry.ReasoningEffortMedium,
modelregistry.ReasoningEffortHigh,
},
Capabilities: modelregistry.ModelCapabilities{
modelregistry.ModelCapabilityChat,
modelregistry.ModelCapabilityVision,
modelregistry.ModelCapabilityReasoning,
modelregistry.ModelCapabilityPromptCache,
},
Cost: modelregistry.ModelCost{
Currency: "USD",
Unit: "per_1m_tokens",
InputPerMillion: 1,
OutputPerMillion: 2,
Source: "https://provider.example/pricing (test fixture)",
SourceLastVerified: "2026-07-18",
},
Status: modelregistry.ModelStatusActive,
Aliases: []string{"pr7-caps"},
}})
if err != nil {
t.Fatalf("NewRegistry: %v", err)
}
tsp, err := NewTurnSessionProvider(config.ProviderProfile{
Name: "pr7-caps",
ProviderKind: config.ProviderKindOpenAI,
BaseURL: "https://provider.example/v1",
APIKey: "sk-turn-session-test",
Model: "pr7-caps-model",
}, Options{
UserAgent: "zero-turn-session-test",
ModelRegistry: &registry,
})
if err != nil {
t.Fatalf("NewTurnSessionProvider: %v", err)
}
caps := tsp.Capabilities()
if caps.Model != "pr7-caps-api-model" {
t.Fatalf("Model = %q, want the registry APIModel", caps.Model)
}
if caps.ContextWindow != 200_000 || caps.MaxOutputTokens != 64_000 {
t.Fatalf("limits = (%d, %d), want (200000, 64000)", caps.ContextWindow, caps.MaxOutputTokens)
}
if !caps.SupportsVision || !caps.SupportsReasoning || !caps.SupportsPromptCache {
t.Fatalf("capability flags = %+v, want vision/reasoning/prompt-cache all true", caps)
}
if len(caps.ReasoningEfforts) != 3 || caps.ReasoningEfforts[0] != "low" || caps.ReasoningEfforts[2] != "high" {
t.Fatalf("ReasoningEfforts = %v, want [low medium high]", caps.ReasoningEfforts)
}
if caps.NativeCompaction {
t.Fatal("NativeCompaction = true, want false for the default adapter")
}
}
// TestNewTurnSessionProviderUsesEffectiveReasoningEfforts asserts the
// projection reads efforts through Registry.ReasoningEfforts, so a catalog
// entry that enumerates no efforts of its own still reports the name-inferred
// effective tiers the /effort picker and run-time resolver advertise.
func TestNewTurnSessionProviderUsesEffectiveReasoningEfforts(t *testing.T) {
registry, err := modelregistry.NewRegistry([]modelregistry.ModelEntry{{
// A gpt-5-family id with NO ReasoningEfforts listed: the effective
// efforts come from name inference, differing from the raw entry.
ID: "gpt-5-pr7-probe",
DisplayName: "PR7 Effective Efforts Probe",
APIModel: "gpt-5-pr7-probe-api",
Provider: modelregistry.ProviderOpenAI,
ContextLimits: modelregistry.ContextLimits{
ContextWindow: 400_000,
MaxOutputTokens: 128_000,
},
Capabilities: modelregistry.ModelCapabilities{
modelregistry.ModelCapabilityChat,
modelregistry.ModelCapabilityReasoning,
},
Cost: modelregistry.ModelCost{
Currency: "USD",
Unit: "per_1m_tokens",
InputPerMillion: 1,
OutputPerMillion: 2,
Source: "https://provider.example/pricing (test fixture)",
SourceLastVerified: "2026-07-18",
},
Status: modelregistry.ModelStatusActive,
Aliases: []string{"pr7-effective-efforts"},
}})
if err != nil {
t.Fatalf("NewRegistry: %v", err)
}
tsp, err := NewTurnSessionProvider(config.ProviderProfile{
Name: "pr7-effective",
ProviderKind: config.ProviderKindOpenAI,
BaseURL: "https://provider.example/v1",
APIKey: "sk-turn-session-test",
Model: "gpt-5-pr7-probe",
}, Options{
UserAgent: "zero-turn-session-test",
ModelRegistry: &registry,
})
if err != nil {
t.Fatalf("NewTurnSessionProvider: %v", err)
}
got := tsp.Capabilities().ReasoningEfforts
want := []string{"minimal", "low", "medium", "high"}
if len(got) != len(want) {
t.Fatalf("ReasoningEfforts = %v, want %v (name-inferred fallback)", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("ReasoningEfforts[%d] = %q, want %q (full: %v)", i, got[i], want[i], got)
}
}
}
-205
View File
@@ -1,205 +0,0 @@
package openai
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"strings"
"sync"
"time"
"github.com/Gitlawb/zero/internal/trace"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// prewarmTimeout bounds the best-effort prewarm probe. The probe runs in the
// background, so this cap only limits how long the goroutine may linger — it
// can never delay the first turn.
const prewarmTimeout = 3 * time.Second
// NewTurnSessionProvider wraps an already-constructed *Provider in the
// optimized OpenAI turn session: a best-effort connection prewarm at run start
// plus request-prefix stability telemetry. Stream is Provider.StreamCompletion
// verbatim, so runtime request behavior is identical to the default adapter —
// the session adds observation and pool priming, never a different request.
func NewTurnSessionProvider(provider *Provider, caps zeroruntime.ProviderCapabilities) zeroruntime.TurnSessionProvider {
return &turnSessionProvider{provider: provider, caps: caps}
}
type turnSessionProvider struct {
provider *Provider
caps zeroruntime.ProviderCapabilities
}
func (p *turnSessionProvider) OpenTurnSession(context.Context) (zeroruntime.TurnSession, error) {
return &turnSession{provider: p.provider}, nil
}
func (p *turnSessionProvider) Capabilities() zeroruntime.ProviderCapabilities {
return p.caps
}
// turnSession is one run's optimized OpenAI session. The agent loop serializes
// all provider I/O through its session shim, so fields need no mutex. The
// fingerprint counters exist so a future stateful-reuse session (Responses API)
// inherits a proven prefix-stability detector; on chat completions there is no
// server-side response state to invalidate, so drift is telemetry only.
type turnSession struct {
provider *Provider
lastFingerprint string
prewarmOnce sync.Once
// prewarmDone closes when the background probe settles; tests wait on it.
prewarmDone chan struct{}
}
// Prewarm launches one bounded, unauthenticated HEAD probe to the provider's
// base URL in the background so the TCP+TLS handshake lands in the shared
// connection pool while the loop assembles the first prompt. One attempt, no
// retries, no bearer token — the goal is the handshake, not a 2xx (a 401/404/
// 405 response still primes the pool). Always returns nil: prewarm is advisory
// by contract and never required for correctness.
//
// The probe is issued directly through the client (NOT providerio.SendWithRetry)
// so it stamps ONLY the provider_prewarm span — a nested provider_connect stamp
// would contaminate the exact A/B metric this feature is measured by.
//
// When the client's transport cannot retain idle connections (macOS disables
// keep-alives on the shared transport because degraded pooled connections are
// indistinguishable from backend slowness there), the probe is skipped entirely
// rather than spending a request that can never be reused. The pool's 30s idle
// timeout bounds the warm window; a slower start simply falls back to a cold
// dial, never worse than today.
func (s *turnSession) Prewarm(ctx context.Context) error {
s.prewarmOnce.Do(func() {
done := make(chan struct{})
s.prewarmDone = done
provider := s.provider
if !transportRetainsIdleConns(provider.httpClient) {
close(done)
return
}
recorder := trace.FromContext(ctx)
go func() {
defer close(done)
probeCtx, cancel := context.WithTimeout(ctx, prewarmTimeout)
defer cancel()
span := recorder.Span(trace.SpanProviderPrewarm)
defer span.End()
recorder.Counter(trace.CounterPrewarmAttempts, 1)
request, err := http.NewRequestWithContext(probeCtx, http.MethodHead, provider.baseURL, nil)
if err != nil {
return
}
if provider.userAgent != "" {
request.Header.Set("User-Agent", provider.userAgent)
}
response, err := provider.httpClient.Do(request)
if err == nil {
_ = response.Body.Close()
}
}()
})
return nil
}
// transportRetainsIdleConns reports whether a warmed connection can outlive the
// probe. A *http.Transport with DisableKeepAlives (the shared transport on
// macOS) closes every connection after its request, so a prewarm there is pure
// overhead. A nil transport is net/http's default (keep-alives on); an unknown
// custom transport is assumed reusable — the probe stays a harmless best-effort.
func transportRetainsIdleConns(client *http.Client) bool {
if client == nil {
return false
}
if transport, ok := client.Transport.(*http.Transport); ok {
return !transport.DisableKeepAlives
}
return true
}
func (s *turnSession) Stream(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
s.observePrefix(ctx, request)
return s.provider.StreamCompletion(ctx, request)
}
func (s *turnSession) Compact(context.Context, zeroruntime.CompletionRequest) ([]zeroruntime.Message, error) {
return nil, zeroruntime.ErrCompactionUnsupported
}
// Close is an idempotent no-op: the shared transport pool owns the connections.
func (s *turnSession) Close() error { return nil }
// observePrefix tracks whether the request-prefix parameters stayed stable
// between this session's streams. The first stream seeds the fingerprint and
// counts as neither stable nor drift.
func (s *turnSession) observePrefix(ctx context.Context, request zeroruntime.CompletionRequest) {
fingerprint := s.computeFingerprint(request)
if s.lastFingerprint == "" {
s.lastFingerprint = fingerprint
return
}
recorder := trace.FromContext(ctx)
if fingerprint == s.lastFingerprint {
recorder.Counter(trace.CounterPrefixStable, 1)
return
}
recorder.Counter(trace.CounterPrefixDrift, 1)
s.lastFingerprint = fingerprint
}
// computeFingerprint digests the wire-affecting request parameters: the
// session's model and max-tokens (fixed at construction — CompletionRequest
// carries no model field), the normalized reasoning effort as it would appear
// on the wire, the prompt-cache key (constant within a session, and part of
// the request the provider serializes), and an order-preserving digest of the
// advertised tools (request serialization preserves tool order, so a reorder
// changes the wire bytes and counts as drift). Messages are excluded — they
// grow every turn; this fingerprints request parameters, not conversation
// content.
//
// SCOPE: this is per-provider request-parameter TELEMETRY, not a complete
// compatibility fingerprint. It deliberately does not incorporate the prompt
// prefix hashes (base instructions / project context / skills — the
// complete_prefix signal the agent loop already emits per turn). Any future
// stateful response reuse MUST combine both signals plus the remaining
// wire-affecting fields before reusing provider-side state; these counters
// alone are not sufficient compatibility protection.
func (s *turnSession) computeFingerprint(request zeroruntime.CompletionRequest) string {
var builder strings.Builder
builder.WriteString(s.provider.model)
builder.WriteByte('|')
fmt.Fprintf(&builder, "%d", s.provider.maxTokens)
builder.WriteByte('|')
builder.WriteString(openAIReasoningEffort(request.ReasoningEffort))
builder.WriteByte('|')
builder.WriteString(request.PromptCacheKey)
builder.WriteByte('|')
builder.WriteString(wireToolsDigest(request.Tools))
sum := sha256.Sum256([]byte(builder.String()))
return hex.EncodeToString(sum[:])
}
// wireToolsDigest renders the tool set in WIRE ORDER: each tool as name +
// description + its JSON-marshaled parameters (encoding/json sorts map keys,
// so each render is stable). Order is preserved deliberately — the OpenAI
// request serializes tools in the order given, so a reorder changes the
// request bytes and must count as drift. Descriptions are model-visible
// request bytes too. A schema that fails to marshal is recorded under a
// stable sentinel — this digest feeds telemetry counters, so cruder handling
// of that pathological case is acceptable.
func wireToolsDigest(tools []zeroruntime.ToolDefinition) string {
rendered := make([]string, 0, len(tools))
for _, tool := range tools {
schema, err := json.Marshal(tool.Parameters)
if err != nil {
rendered = append(rendered, tool.Name+"\n"+tool.Description+"\n__non_json:"+tool.Name)
continue
}
rendered = append(rendered, tool.Name+"\n"+tool.Description+"\n"+string(schema))
}
sum := sha256.Sum256([]byte(strings.Join(rendered, "\x00")))
return hex.EncodeToString(sum[:])
}
-387
View File
@@ -1,387 +0,0 @@
package openai
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
"github.com/Gitlawb/zero/internal/trace"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// keepAliveClient returns a client whose transport retains idle connections on
// EVERY platform. The shared default transport disables keep-alives on macOS,
// where the session correctly skips the probe — tests that assert the probe
// fires must therefore pin a reusable transport to stay platform-deterministic.
func keepAliveClient() *http.Client {
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.DisableKeepAlives = false
return &http.Client{Transport: transport}
}
// newPrewarmTestProvider builds a provider whose transport always allows the
// prewarm probe, regardless of host platform.
func newPrewarmTestProvider(t *testing.T, handler http.HandlerFunc) *Provider {
t.Helper()
return newTestProviderWithOptions(t, Options{APIKey: "sk-test", HTTPClient: keepAliveClient()}, handler)
}
func waitPrewarmDone(t *testing.T, session *turnSession) {
t.Helper()
if session.prewarmDone == nil {
t.Fatal("prewarm was never launched")
}
select {
case <-session.prewarmDone:
case <-time.After(10 * time.Second):
t.Fatal("prewarm probe did not settle in time")
}
}
func openOptimizedSession(t *testing.T, provider *Provider) *turnSession {
t.Helper()
session, err := NewTurnSessionProvider(provider, zeroruntime.ProviderCapabilities{}).OpenTurnSession(context.Background())
if err != nil {
t.Fatalf("OpenTurnSession: %v", err)
}
return session.(*turnSession)
}
func TestTurnSessionPrewarmSendsSingleHEAD(t *testing.T) {
var requests atomic.Int64
var lastMethod atomic.Value
provider := newPrewarmTestProvider(t, func(w http.ResponseWriter, r *http.Request) {
requests.Add(1)
lastMethod.Store(r.Method)
w.WriteHeader(http.StatusMethodNotAllowed)
})
session := openOptimizedSession(t, provider)
if err := session.Prewarm(context.Background()); err != nil {
t.Fatalf("Prewarm: %v", err)
}
waitPrewarmDone(t, session)
if got := requests.Load(); got != 1 {
t.Fatalf("prewarm sent %d requests, want exactly 1 (no retries even on 405)", got)
}
if method, _ := lastMethod.Load().(string); method != http.MethodHead {
t.Fatalf("prewarm method = %q, want HEAD", method)
}
// A second Prewarm (e.g. after a mid-run swap re-open) must not re-probe.
if err := session.Prewarm(context.Background()); err != nil {
t.Fatalf("second Prewarm: %v", err)
}
waitPrewarmDone(t, session)
if got := requests.Load(); got != 1 {
t.Fatalf("second Prewarm re-probed: %d requests, want 1", got)
}
}
func TestTurnSessionPrewarmNonFatalOnConnectError(t *testing.T) {
// A server that is immediately closed leaves a port that refuses connections.
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
deadURL := server.URL
server.Close()
provider, err := New(Options{APIKey: "sk-test", BaseURL: deadURL, Model: "gpt-test", HTTPClient: keepAliveClient()})
if err != nil {
t.Fatalf("New: %v", err)
}
session := openOptimizedSession(t, provider)
if err := session.Prewarm(context.Background()); err != nil {
t.Fatalf("Prewarm must be advisory even when the probe cannot connect, got %v", err)
}
waitPrewarmDone(t, session)
}
func TestTurnSessionPrewarmTraceStamps(t *testing.T) {
provider := newPrewarmTestProvider(t, func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusMethodNotAllowed)
})
recorder := trace.NewRecorder("session-test", "run-1", "test")
recorder.Start()
ctx := trace.WithContext(context.Background(), recorder)
session := openOptimizedSession(t, provider)
if err := session.Prewarm(ctx); err != nil {
t.Fatalf("Prewarm: %v", err)
}
waitPrewarmDone(t, session)
tr := recorder.Finish()
if got := tr.Counter(trace.CounterPrewarmAttempts); got != 1 {
t.Fatalf("prewarm_attempts = %d, want 1", got)
}
var sawSpan bool
for _, span := range tr.Spans {
if span.Name == trace.SpanProviderPrewarm {
sawSpan = true
}
}
if !sawSpan {
t.Fatalf("missing %s span in %+v", trace.SpanProviderPrewarm, tr.Spans)
}
}
func TestTurnSessionStreamDelegatesToProvider(t *testing.T) {
handler := func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"hello\"}}]}\n\n"))
_, _ = w.Write([]byte("data: [DONE]\n\n"))
}
direct := newTestProvider(t, handler)
viaSession := newTestProvider(t, handler)
session := openOptimizedSession(t, viaSession)
request := zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hi"}},
}
collect := func(stream <-chan zeroruntime.StreamEvent) []zeroruntime.StreamEvent {
var events []zeroruntime.StreamEvent
for event := range stream {
events = append(events, event)
}
return events
}
directStream, err := direct.StreamCompletion(context.Background(), request)
if err != nil {
t.Fatalf("direct StreamCompletion: %v", err)
}
sessionStream, err := session.Stream(context.Background(), request)
if err != nil {
t.Fatalf("session Stream: %v", err)
}
directEvents := collect(directStream)
sessionEvents := collect(sessionStream)
if len(directEvents) != len(sessionEvents) {
t.Fatalf("event counts diverged: direct=%d session=%d", len(directEvents), len(sessionEvents))
}
for i := range directEvents {
if directEvents[i].Type != sessionEvents[i].Type || directEvents[i].Content != sessionEvents[i].Content {
t.Fatalf("event %d diverged: direct=%+v session=%+v", i, directEvents[i], sessionEvents[i])
}
}
}
func TestTurnSessionFingerprintIgnoresMessages(t *testing.T) {
provider := newTestProvider(t, func(http.ResponseWriter, *http.Request) {})
session := openOptimizedSession(t, provider)
tools := []zeroruntime.ToolDefinition{{Name: "read_file", Parameters: map[string]any{"type": "object"}}}
base := session.computeFingerprint(zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "turn one"}},
Tools: tools,
PromptCacheKey: "session-a",
})
grown := session.computeFingerprint(zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{
{Role: zeroruntime.MessageRoleUser, Content: "turn one"},
{Role: zeroruntime.MessageRoleAssistant, Content: "reply"},
{Role: zeroruntime.MessageRoleUser, Content: "turn two"},
},
Tools: tools,
PromptCacheKey: "session-a",
})
if base != grown {
t.Fatal("fingerprint changed when only messages grew — conversation content must not be an input")
}
}
func TestTurnSessionFingerprintDriftOnCacheKey(t *testing.T) {
provider := newTestProvider(t, func(http.ResponseWriter, *http.Request) {})
session := openOptimizedSession(t, provider)
tools := []zeroruntime.ToolDefinition{{Name: "read_file", Parameters: map[string]any{"type": "object"}}}
base := session.computeFingerprint(zeroruntime.CompletionRequest{Tools: tools, PromptCacheKey: "session-a"})
changed := session.computeFingerprint(zeroruntime.CompletionRequest{Tools: tools, PromptCacheKey: "session-b"})
if base == changed {
t.Fatal("fingerprint did not drift on a changed prompt-cache key — it is a serialized request field")
}
}
func TestTurnSessionFingerprintDriftOnToolsAndEffort(t *testing.T) {
provider := newTestProvider(t, func(http.ResponseWriter, *http.Request) {})
session := openOptimizedSession(t, provider)
base := session.computeFingerprint(zeroruntime.CompletionRequest{
Tools: []zeroruntime.ToolDefinition{{Name: "read_file", Parameters: map[string]any{"type": "object"}}},
})
changedTools := session.computeFingerprint(zeroruntime.CompletionRequest{
Tools: []zeroruntime.ToolDefinition{{Name: "write_file", Parameters: map[string]any{"type": "object"}}},
})
if base == changedTools {
t.Fatal("fingerprint did not drift on a changed tool set")
}
changedEffort := session.computeFingerprint(zeroruntime.CompletionRequest{
Tools: []zeroruntime.ToolDefinition{{Name: "read_file", Parameters: map[string]any{"type": "object"}}},
ReasoningEffort: "high",
})
if base == changedEffort {
t.Fatal("fingerprint did not drift on a changed reasoning effort")
}
// Unrecognized efforts normalize to omitted — same as empty.
droppedEffort := session.computeFingerprint(zeroruntime.CompletionRequest{
Tools: []zeroruntime.ToolDefinition{{Name: "read_file", Parameters: map[string]any{"type": "object"}}},
ReasoningEffort: "xhigh",
})
if base != droppedEffort {
t.Fatal("an effort the wire would omit must fingerprint like no effort")
}
}
func TestTurnSessionFingerprintDriftOnToolDescription(t *testing.T) {
provider := newTestProvider(t, func(http.ResponseWriter, *http.Request) {})
session := openOptimizedSession(t, provider)
base := session.computeFingerprint(zeroruntime.CompletionRequest{
Tools: []zeroruntime.ToolDefinition{{Name: "read_file", Description: "Reads a file.", Parameters: map[string]any{"type": "object"}}},
})
changedDescription := session.computeFingerprint(zeroruntime.CompletionRequest{
Tools: []zeroruntime.ToolDefinition{{Name: "read_file", Description: "Reads a file, now with ranges.", Parameters: map[string]any{"type": "object"}}},
})
if base == changedDescription {
t.Fatal("fingerprint did not drift on a description-only tool change — descriptions are model-visible request bytes")
}
}
func TestTurnSessionFingerprintDriftOnToolReorder(t *testing.T) {
provider := newTestProvider(t, func(http.ResponseWriter, *http.Request) {})
session := openOptimizedSession(t, provider)
forward := session.computeFingerprint(zeroruntime.CompletionRequest{Tools: []zeroruntime.ToolDefinition{
{Name: "alpha", Parameters: map[string]any{"type": "object"}},
{Name: "beta", Parameters: map[string]any{"type": "object"}},
}})
reversed := session.computeFingerprint(zeroruntime.CompletionRequest{Tools: []zeroruntime.ToolDefinition{
{Name: "beta", Parameters: map[string]any{"type": "object"}},
{Name: "alpha", Parameters: map[string]any{"type": "object"}},
}})
if forward == reversed {
t.Fatal("fingerprint did not drift on a tool reorder — request serialization preserves tool order, so a reorder changes the wire bytes")
}
}
// TestTurnSessionPrewarmDoesNotStampProviderConnect guards the A/B metric: the
// prewarm probe must appear ONLY under provider_prewarm — a provider_connect
// stamp from the probe would contaminate the exact span the benchmark compares.
func TestTurnSessionPrewarmDoesNotStampProviderConnect(t *testing.T) {
provider := newPrewarmTestProvider(t, func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusMethodNotAllowed)
})
recorder := trace.NewRecorder("session-test", "run-1", "test")
recorder.Start()
ctx := trace.WithContext(context.Background(), recorder)
session := openOptimizedSession(t, provider)
if err := session.Prewarm(ctx); err != nil {
t.Fatalf("Prewarm: %v", err)
}
waitPrewarmDone(t, session)
tr := recorder.Finish()
for _, span := range tr.Spans {
if span.Name == trace.SpanProviderConnect {
t.Fatalf("prewarm stamped a %s span — it must stay solely under %s", trace.SpanProviderConnect, trace.SpanProviderPrewarm)
}
}
}
// TestTurnSessionPrewarmSkippedWhenKeepAlivesDisabled verifies the probe is not
// spent when the transport cannot retain idle connections (the macOS shared
// transport): no request, no trace stamps, and the done channel still closes.
func TestTurnSessionPrewarmSkippedWhenKeepAlivesDisabled(t *testing.T) {
var requests atomic.Int64
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
requests.Add(1)
w.WriteHeader(http.StatusMethodNotAllowed)
}))
defer server.Close()
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.DisableKeepAlives = true
provider, err := New(Options{
APIKey: "sk-test",
BaseURL: server.URL,
Model: "gpt-test",
HTTPClient: &http.Client{Transport: transport},
})
if err != nil {
t.Fatalf("New: %v", err)
}
recorder := trace.NewRecorder("session-test", "run-1", "test")
recorder.Start()
ctx := trace.WithContext(context.Background(), recorder)
session := openOptimizedSession(t, provider)
if err := session.Prewarm(ctx); err != nil {
t.Fatalf("Prewarm: %v", err)
}
waitPrewarmDone(t, session)
if got := requests.Load(); got != 0 {
t.Fatalf("keep-alive-disabled transport still received %d probe requests, want 0", got)
}
tr := recorder.Finish()
if got := tr.Counter(trace.CounterPrewarmAttempts); got != 0 {
t.Fatalf("prewarm_attempts = %d, want 0 for a skipped probe", got)
}
}
func TestTurnSessionPrefixCounters(t *testing.T) {
handler := func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte("data: [DONE]\n\n"))
}
provider := newTestProvider(t, handler)
session := openOptimizedSession(t, provider)
recorder := trace.NewRecorder("session-test", "run-1", "test")
recorder.Start()
ctx := trace.WithContext(context.Background(), recorder)
stable := zeroruntime.CompletionRequest{Tools: []zeroruntime.ToolDefinition{{Name: "read_file", Parameters: map[string]any{"type": "object"}}}}
drain := func(request zeroruntime.CompletionRequest) {
stream, err := session.Stream(ctx, request)
if err != nil {
t.Fatalf("Stream: %v", err)
}
for range stream {
}
}
drain(stable) // seeds — neither stable nor drift
drain(stable) // stable
drain(zeroruntime.CompletionRequest{Tools: []zeroruntime.ToolDefinition{{Name: "write_file", Parameters: map[string]any{"type": "object"}}}}) // drift
tr := recorder.Finish()
if got := tr.Counter(trace.CounterPrefixStable); got != 1 {
t.Fatalf("prefix_stable = %d, want 1", got)
}
if got := tr.Counter(trace.CounterPrefixDrift); got != 1 {
t.Fatalf("prefix_drift = %d, want 1", got)
}
}
func TestTurnSessionCloseIdempotentAndCompactUnsupported(t *testing.T) {
provider := newTestProvider(t, func(http.ResponseWriter, *http.Request) {})
session := openOptimizedSession(t, provider)
if err := session.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
if err := session.Close(); err != nil {
t.Fatalf("second Close: %v", err)
}
if _, err := session.Compact(context.Background(), zeroruntime.CompletionRequest{}); !errors.Is(err, zeroruntime.ErrCompactionUnsupported) {
t.Fatalf("Compact error = %v, want ErrCompactionUnsupported", err)
}
}
-72
View File
@@ -1,72 +0,0 @@
package providers
import (
"os"
"strings"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/providers/openai"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// openaiTurnSessionEnv gates the optimized OpenAI turn session (prewarm +
// prefix telemetry). Default OFF: unset, "0", or "false" (any case) leave the
// agent loop's default adapter path untouched. Set to any other non-empty
// value (e.g. "1") to enable — the same boolean idiom as ZERO_FORMAT_ON_WRITE.
const openaiTurnSessionEnv = "ZERO_OPENAI_TURN_SESSION"
func openaiTurnSessionEnabled() bool {
value := strings.TrimSpace(os.Getenv(openaiTurnSessionEnv))
return value != "" && value != "0" && !strings.EqualFold(value, "false")
}
// OptimizedTurnSessions returns the gated optimized TurnSessionProvider for an
// already-built provider, or (nil, false) when the gate is off or the profile
// is ineligible. Callers leave agent Options.TurnSessionProvider nil on false,
// so the loop's default wrap keeps today's behavior byte-identical.
//
// Eligibility is deliberately narrow: the resolved provider kind must be
// official OpenAI (NOT openai-compatible gateways — the constructor merges the
// two, so this branches on the resolved kind explicitly), not the ChatGPT
// Codex catalog, and the provider value must be the concrete *openai.Provider
// (a fake, a Codex provider, or nil falls back to the default path safely).
// No base-URL check on top of the kind: prewarm and fingerprint telemetry are
// harmless against any host, and kind==openai mirrors the existing
// official-OpenAI precedent used for prompt_cache_key.
func OptimizedTurnSessions(profile config.ProviderProfile, provider zeroruntime.Provider, options Options) (zeroruntime.TurnSessionProvider, bool) {
if !openaiTurnSessionEnabled() {
return nil, false
}
resolved, err := resolveProfile(profile, options)
if err != nil || resolved.providerKind != config.ProviderKindOpenAI || isCodexCatalog(profile, resolved) {
return nil, false
}
concrete, ok := provider.(*openai.Provider)
if !ok {
return nil, false
}
caps, err := resolveCapabilities(profile, options)
if err != nil {
// resolveProfile above already succeeded, so this is effectively
// unreachable — but never ship an optimized session with unknown
// capabilities; the default path is the safe answer.
return nil, false
}
return openai.NewTurnSessionProvider(concrete, caps), true
}
// DefaultTurnSessions wraps an already-built provider in the DEFAULT (no-op)
// turn-session adapter with the profile's resolved capability projection. It is
// the non-optimized sibling of OptimizedTurnSessions for callers that still
// want capabilities populated — e.g. the mid-run model-switch fallback when the
// switched model is not eligible for the optimized session. Capability
// resolution failure degrades to an unknown (zero) projection rather than
// failing the wrap: the default session has no behavior that depends on
// capabilities, so a swap must never be blocked by a projection error.
func DefaultTurnSessions(profile config.ProviderProfile, provider zeroruntime.Provider, options Options) zeroruntime.TurnSessionProvider {
caps, err := resolveCapabilities(profile, options)
if err != nil {
caps = zeroruntime.ProviderCapabilities{}
}
return zeroruntime.NewProviderTurnSessionProvider(provider, caps)
}
@@ -1,128 +0,0 @@
package providers
import (
"context"
"testing"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
func openaiEligibleProfile() config.ProviderProfile {
return config.ProviderProfile{
Name: "official-openai",
ProviderKind: config.ProviderKindOpenAI,
BaseURL: "https://provider.example/v1",
APIKey: "sk-gate-test",
Model: "pr8-unregistered-model",
}
}
func buildGateProvider(t *testing.T, profile config.ProviderProfile) zeroruntime.Provider {
t.Helper()
provider, err := New(profile, Options{UserAgent: "zero-gate-test"})
if err != nil {
t.Fatalf("New: %v", err)
}
return provider
}
type fakeGateProvider struct{}
func (fakeGateProvider) StreamCompletion(context.Context, zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
return nil, nil
}
func TestOptimizedTurnSessionsDefaultOff(t *testing.T) {
// No env set: the gate must be off even for a fully eligible profile.
profile := openaiEligibleProfile()
tsp, ok := OptimizedTurnSessions(profile, buildGateProvider(t, profile), Options{})
if ok || tsp != nil {
t.Fatal("optimized turn sessions must be disabled by default")
}
}
func TestOptimizedTurnSessionsFalseyValues(t *testing.T) {
profile := openaiEligibleProfile()
provider := buildGateProvider(t, profile)
for _, value := range []string{"0", "false", "FALSE", "False", " ", ""} {
t.Setenv(openaiTurnSessionEnv, value)
if _, ok := OptimizedTurnSessions(profile, provider, Options{}); ok {
t.Fatalf("gate enabled for falsey value %q", value)
}
}
for _, value := range []string{"1", "true", "on"} {
t.Setenv(openaiTurnSessionEnv, value)
if _, ok := OptimizedTurnSessions(profile, provider, Options{}); !ok {
t.Fatalf("gate disabled for truthy value %q", value)
}
}
}
func TestOptimizedTurnSessionsOpenAIEligible(t *testing.T) {
t.Setenv(openaiTurnSessionEnv, "1")
profile := openaiEligibleProfile()
tsp, ok := OptimizedTurnSessions(profile, buildGateProvider(t, profile), Options{})
if !ok || tsp == nil {
t.Fatal("expected the optimized session for an official-OpenAI profile with the gate on")
}
session, err := tsp.OpenTurnSession(context.Background())
if err != nil {
t.Fatalf("OpenTurnSession: %v", err)
}
if err := session.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
if caps := tsp.Capabilities(); caps.Model != "pr8-unregistered-model" {
t.Fatalf("Capabilities().Model = %q, want the resolved api model", caps.Model)
}
}
func TestOptimizedTurnSessionsRejectsCompatible(t *testing.T) {
t.Setenv(openaiTurnSessionEnv, "1")
profile := openaiEligibleProfile()
profile.ProviderKind = config.ProviderKindOpenAICompatible
if _, ok := OptimizedTurnSessions(profile, buildGateProvider(t, profile), Options{}); ok {
t.Fatal("openai-compatible gateways must not get the optimized session")
}
}
func TestOptimizedTurnSessionsRejectsCodexCatalog(t *testing.T) {
t.Setenv(openaiTurnSessionEnv, "1")
profile := openaiEligibleProfile()
profile.CatalogID = "chatgpt"
// Build the provider from the base profile: the point under test is the
// catalog check, not Codex construction (which needs OAuth wiring).
if _, ok := OptimizedTurnSessions(profile, buildGateProvider(t, openaiEligibleProfile()), Options{}); ok {
t.Fatal("the ChatGPT Codex catalog must not get the optimized session")
}
}
func TestDefaultTurnSessionsPreservesResolvedCapabilities(t *testing.T) {
profile := openaiEligibleProfile()
tsp := DefaultTurnSessions(profile, buildGateProvider(t, profile), Options{})
if tsp == nil {
t.Fatal("DefaultTurnSessions returned nil")
}
caps := tsp.Capabilities()
if caps.Model != "pr8-unregistered-model" {
t.Fatalf("Capabilities().Model = %q, want the resolved api model", caps.Model)
}
session, err := tsp.OpenTurnSession(context.Background())
if err != nil {
t.Fatalf("OpenTurnSession: %v", err)
}
if err := session.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
}
func TestOptimizedTurnSessionsRejectsForeignProviderValue(t *testing.T) {
t.Setenv(openaiTurnSessionEnv, "1")
if _, ok := OptimizedTurnSessions(openaiEligibleProfile(), fakeGateProvider{}, Options{}); ok {
t.Fatal("a non-*openai.Provider value must fall back to the default path")
}
if _, ok := OptimizedTurnSessions(openaiEligibleProfile(), nil, Options{}); ok {
t.Fatal("a nil provider must fall back to the default path")
}
}
+6 -41
View File
@@ -23,15 +23,6 @@ type bashTool struct {
scope PathScope
}
func (bashTool) outputCategory(args map[string]any) outputCategory {
command, _ := bashCommandArg(args)
return shellOutputCategory(command)
}
func bashCommandArg(args map[string]any) (string, error) {
return aliasedStringArg(args, []string{"command", "cmd", "script", "shell"}, "", true, false)
}
func NewBashTool(workspaceRoot string) Tool {
return NewScopedBashTool(workspaceRoot, nil)
}
@@ -72,19 +63,15 @@ func NewScopedBashTool(workspaceRoot string, scope PathScope) Tool {
}
func (tool bashTool) Run(ctx context.Context, args map[string]any) Result {
return tool.run(ctx, args, nil, true)
return tool.run(ctx, args, nil)
}
func (tool bashTool) RunWithSandbox(ctx context.Context, args map[string]any, engine *zeroSandbox.Engine) Result {
return tool.run(ctx, args, engine, true)
return tool.run(ctx, args, engine)
}
func (tool bashTool) RunWithOptions(ctx context.Context, args map[string]any, options RunOptions) Result {
return tool.run(ctx, args, options.Sandbox, false)
}
func (tool bashTool) run(ctx context.Context, args map[string]any, engine *zeroSandbox.Engine, directBudget bool) Result {
commandText, err := bashCommandArg(args)
func (tool bashTool) run(ctx context.Context, args map[string]any, engine *zeroSandbox.Engine) Result {
commandText, err := aliasedStringArg(args, []string{"command", "cmd", "script", "shell"}, "", true, false)
if err != nil {
return errorResult("Error: Invalid arguments for bash: " + err.Error())
}
@@ -185,7 +172,7 @@ func (tool bashTool) run(ctx context.Context, args map[string]any, engine *zeroS
}
}
markLikelySandboxDenial(meta, plan, exitCode, stdoutText, stderrText)
outText, errText, truncated := prepareBashOutput(stdoutText, stdout.total, stderrText, stderrTotal, meta, directBudget)
outText, errText, truncated := budgetBashCapture(stdoutText, stdout.total, stderrText, stderrTotal, meta)
return Result{
Status: StatusError,
Output: formatBashOutputWithShellHint(outText, errText, exitCode, meta),
@@ -195,7 +182,7 @@ func (tool bashTool) run(ctx context.Context, args map[string]any, engine *zeroS
}
markLikelySandboxDenial(meta, plan, exitCode, stdoutText, stderrText)
outText, errText, truncated := prepareBashOutput(stdoutText, stdout.total, stderrText, stderrTotal, meta, directBudget)
outText, errText, truncated := budgetBashCapture(stdoutText, stdout.total, stderrText, stderrTotal, meta)
if meta[SandboxLikelyDeniedMeta] == "true" {
return Result{
Status: StatusError,
@@ -428,28 +415,6 @@ func budgetBashOutput(stdout string, stderr string, meta map[string]string) (str
return budgetBashCapture(stdout, len(stdout), stderr, len(stderr), meta)
}
// prepareBashOutput keeps direct callers on the established positional budget.
// Registry calls retain the existing bounded capture but leave final semantic
// reduction and spill creation to the post-redaction registry boundary.
func prepareBashOutput(out string, outTotal int, errStr string, errTotal int, meta map[string]string, directBudget bool) (string, string, bool) {
if directBudget {
return budgetBashCapture(out, outTotal, errStr, errTotal, meta)
}
outText := sectionWithCaptureGap(out, outTotal)
errText := sectionWithCaptureGap(errStr, errTotal)
truncated := outTotal > len(out) || errTotal > len(errStr)
if meta != nil {
meta["raw_bytes"] = strconv.Itoa(outTotal + errTotal)
meta["emitted_bytes"] = strconv.Itoa(len(outText) + len(errText))
meta["estimated_tokens"] = strconv.Itoa(estimatedTokensFromBytes(len(outText) + len(errText)))
if truncated {
meta["truncated"] = "true"
meta["truncation_reason"] = "capture_budget"
}
}
return outText, errText, truncated
}
// budgetBashCapture is budgetBashOutput for the streaming-capture path: outTotal
// and errTotal are the true byte counts (from boundedBuffer.total), which may
// exceed the retained strings when the middle was dropped during capture. Meta's
-12
View File
@@ -37,18 +37,6 @@ func TestBudgetBashOutputSmallPassesThrough(t *testing.T) {
}
}
func TestPrepareBashOutputDefersRegistrySemanticBudget(t *testing.T) {
output := "START\n" + strings.Repeat("progress\n", 8_000) + "ERROR: retained\nEND"
registryOutput, _, registryTruncated := prepareBashOutput(output, len(output), "", 0, map[string]string{}, false)
if registryTruncated || registryOutput != output {
t.Fatalf("registry preparation must preserve capture-bounded output for semantic reduction: truncated=%t bytes=%d", registryTruncated, len(registryOutput))
}
directOutput, _, directTruncated := prepareBashOutput(output, len(output), "", 0, map[string]string{}, true)
if !directTruncated || len(directOutput) > bashOutputBudgetBytes+512 {
t.Fatalf("direct path must preserve legacy positional budget: truncated=%t bytes=%d", directTruncated, len(directOutput))
}
}
// Oversized stdout is truncated head+tail: both the first and last lines survive,
// the middle is dropped behind a marker, meta is flagged, and the captured text
// is spilled to a re-readable file.
+7 -32
View File
@@ -427,15 +427,6 @@ type execCommandTool struct {
manager *execSessionManager
}
func (execCommandTool) outputCategory(args map[string]any) outputCategory {
command, _ := execCommandArg(args)
return shellOutputCategory(command)
}
func execCommandArg(args map[string]any) (string, error) {
return aliasedStringArg(args, []string{"cmd", "command", "script", "shell"}, "", true, false)
}
func NewExecCommandTool(workspaceRoot string, manager *execSessionManager) Tool {
return NewScopedExecCommandTool(workspaceRoot, nil, manager)
}
@@ -484,15 +475,11 @@ func NewScopedExecCommandTool(workspaceRoot string, scope PathScope, manager *ex
}
func (tool execCommandTool) Run(ctx context.Context, args map[string]any) Result {
return tool.run(ctx, args, nil, true)
return tool.run(ctx, args, nil)
}
func (tool execCommandTool) RunWithSandbox(ctx context.Context, args map[string]any, engine *zeroSandbox.Engine) Result {
return tool.run(ctx, args, engine, true)
}
func (tool execCommandTool) RunWithOptions(ctx context.Context, args map[string]any, options RunOptions) Result {
return tool.run(ctx, args, options.Sandbox, false)
return tool.run(ctx, args, engine)
}
func (tool execCommandTool) ExecSessions() []ExecSessionSnapshot {
@@ -507,8 +494,8 @@ func (tool execCommandTool) StopAllExecSessions() []int {
return tool.manager.stopAll()
}
func (tool execCommandTool) run(ctx context.Context, args map[string]any, engine *zeroSandbox.Engine, directBudget bool) Result {
commandText, err := execCommandArg(args)
func (tool execCommandTool) run(ctx context.Context, args map[string]any, engine *zeroSandbox.Engine) Result {
commandText, err := aliasedStringArg(args, []string{"cmd", "command", "script", "shell"}, "", true, false)
if err != nil {
return errorResult("Error: Invalid arguments for exec_command: " + err.Error())
}
@@ -563,7 +550,7 @@ func (tool execCommandTool) run(ctx context.Context, args map[string]any, engine
if exited {
tool.manager.remove(session.id)
}
return execToolResultWithBudget(execToolResultInput{
return execToolResult(execToolResultInput{
commandText: commandText,
output: output,
outputBufferTruncated: outputTruncated,
@@ -574,7 +561,7 @@ func (tool execCommandTool) run(ctx context.Context, args map[string]any, engine
tty: session.tty,
plan: session.plan,
maxOutputTokens: maxOutputTokens,
}, directBudget)
})
}
func (tool execCommandTool) startSession(commandText string, absoluteCwd string, relativeCwd string, ttyRequested bool, engine *zeroSandbox.Engine, sandboxPermissions SandboxPermissionOverride) (*execSession, error) {
@@ -707,10 +694,6 @@ type writeStdinTool struct {
manager *execSessionManager
}
func (writeStdinTool) outputCategory(map[string]any) outputCategory {
return outputCategoryProcess
}
func NewWriteStdinTool(manager *execSessionManager) Tool {
if manager == nil {
manager = defaultExecSessionManager
@@ -860,15 +843,7 @@ type execToolResultInput struct {
}
func execToolResult(input execToolResultInput) Result {
return execToolResultWithBudget(input, true)
}
func execToolResultWithBudget(input execToolResultInput, directBudget bool) Result {
output := input.output
truncated := false
if directBudget {
output, truncated = truncateExecOutput(input.output, input.maxOutputTokens)
}
output, truncated := truncateExecOutput(input.output, input.maxOutputTokens)
meta := map[string]string{
"cwd": input.relativeCwd,
"tty": strconv.FormatBool(input.tty),
+14 -23
View File
@@ -18,8 +18,6 @@ type globTool struct {
scope PathScope
}
func (globTool) outputCategory(map[string]any) outputCategory { return outputCategorySearch }
func NewGlobTool(workspaceRoot string) Tool {
return NewScopedGlobTool(workspaceRoot, nil)
}
@@ -49,25 +47,17 @@ func NewScopedGlobTool(workspaceRoot string, scope PathScope) Tool {
}
func (tool globTool) Run(ctx context.Context, args map[string]any) Result {
return tool.runWith(ctx, args, readExcluder{}, true)
}
func (tool globTool) RunWithOptions(ctx context.Context, args map[string]any, options RunOptions) Result {
exclude := readExcluder{}
if options.Sandbox != nil {
exclude = sandboxReadExcluder(options.Sandbox)
}
return tool.runWith(ctx, args, exclude, false)
return tool.runWith(ctx, args, readExcluder{})
}
// RunWithSandbox runs glob while skipping subtrees the sandbox policy denies
// reads to (DenyRead). With no DenyRead configured the excluder is a no-op and
// behavior is unchanged.
func (tool globTool) RunWithSandbox(ctx context.Context, args map[string]any, engine *sandbox.Engine) Result {
return tool.runWith(ctx, args, sandboxReadExcluder(engine), true)
return tool.runWith(ctx, args, sandboxReadExcluder(engine))
}
func (tool globTool) runWith(ctx context.Context, args map[string]any, exclude readExcluder, directBudget bool) Result {
func (tool globTool) runWith(ctx context.Context, args map[string]any, exclude readExcluder) Result {
pattern, err := aliasedStringArg(args, []string{"pattern", "glob", "match", "query", "expression"}, "", true, false)
if err != nil {
return errorResult("Error: Invalid arguments for glob: " + err.Error())
@@ -120,23 +110,24 @@ func (tool globTool) runWith(ctx context.Context, args map[string]any, exclude r
if truncated {
output += fmt.Sprintf("\n\n[truncated: showing first %d of %d matches; increase limit or narrow cwd/pattern]", len(matches), totalMatches)
}
meta := map[string]string{}
budgeted := applyOutputBudget(output, searchOutputBudgetBytes, "increase limit or narrow cwd/pattern")
meta := outputBudgetMeta(budgeted)
meta["pattern"] = pattern
if truncated {
if truncated || budgeted.Truncated {
meta["truncated"] = "true"
meta["truncation_reason"] = "limit"
if budgeted.Truncated {
meta["truncation_reason"] = "byte_budget"
} else {
meta["truncation_reason"] = "limit"
}
}
result := Result{
return Result{
Status: StatusOK,
Output: output,
Truncated: truncated,
Output: budgeted.Output,
Truncated: truncated || budgeted.Truncated,
Meta: meta,
}
if directBudget {
return applyLegacyByteBudgetToResult(result, searchOutputBudgetBytes, "increase limit or narrow cwd/pattern")
}
return result
}
func scanGlob(ctx context.Context, root string, displayRoot string, matcher *regexp.Regexp, includeDirs bool, exclude readExcluder) ([]string, error) {
+23 -29
View File
@@ -21,8 +21,6 @@ type grepTool struct {
scope PathScope
}
func (grepTool) outputCategory(map[string]any) outputCategory { return outputCategorySearch }
type grepMatch struct {
file string
line int
@@ -64,15 +62,7 @@ func NewScopedGrepTool(workspaceRoot string, scope PathScope) Tool {
}
func (tool grepTool) Run(ctx context.Context, args map[string]any) Result {
return tool.runWith(ctx, args, readExcluder{}, true)
}
func (tool grepTool) RunWithOptions(ctx context.Context, args map[string]any, options RunOptions) Result {
exclude := readExcluder{}
if options.Sandbox != nil {
exclude = sandboxReadExcluder(options.Sandbox)
}
return tool.runWith(ctx, args, exclude, false)
return tool.runWith(ctx, args, readExcluder{})
}
// RunWithSandbox runs the search while skipping subtrees the sandbox policy
@@ -80,10 +70,10 @@ func (tool grepTool) RunWithOptions(ctx context.Context, args map[string]any, op
// path. With no DenyRead configured the excluder is a no-op and behavior is
// unchanged.
func (tool grepTool) RunWithSandbox(ctx context.Context, args map[string]any, engine *sandbox.Engine) Result {
return tool.runWith(ctx, args, sandboxReadExcluder(engine), true)
return tool.runWith(ctx, args, sandboxReadExcluder(engine))
}
func (tool grepTool) runWith(ctx context.Context, args map[string]any, exclude readExcluder, directBudget bool) Result {
func (tool grepTool) runWith(ctx context.Context, args map[string]any, exclude readExcluder) Result {
pattern, err := aliasedStringArg(args, []string{"pattern", "query", "regex", "search", "expression"}, "", true, false)
if err != nil {
return errorResult("Error: Invalid arguments for grep: " + err.Error())
@@ -164,7 +154,7 @@ func (tool grepTool) runWith(ctx context.Context, args map[string]any, exclude r
}
return errorResult("Error running grep: " + err.Error())
}
return applyDirectSearchBudget(collector.result(), directBudget, "narrow path/glob/pattern to continue")
return collector.result()
case "files_with_matches":
collector := &grepFileListCollector{}
if err := scanGrepMatches(ctx, resolvedRoot, target, globMatcher, exclude, absolutePaths, presenceGrepLineMatcher(compiled), collector.collect); err != nil {
@@ -173,7 +163,7 @@ func (tool grepTool) runWith(ctx context.Context, args map[string]any, exclude r
}
return errorResult("Error running grep: " + err.Error())
}
return applyDirectSearchBudget(collector.result(), directBudget, "narrow path/glob/pattern to continue")
return collector.result()
default:
collector := &grepContentCollector{headLimit: headLimit}
if err := scanGrepMatches(ctx, resolvedRoot, target, globMatcher, exclude, absolutePaths, presenceGrepLineMatcher(compiled), collector.collect); err != nil {
@@ -182,17 +172,10 @@ func (tool grepTool) runWith(ctx context.Context, args map[string]any, exclude r
}
return errorResult("Error running grep: " + err.Error())
}
return applyDirectSearchBudget(collector.result(), directBudget, "narrow path/glob/pattern or increase head_limit")
return collector.result()
}
}
func applyDirectSearchBudget(result Result, directBudget bool, hint string) Result {
if !directBudget {
return result
}
return applyLegacyByteBudgetToResult(result, searchOutputBudgetBytes, hint)
}
// resolveGrepRoot picks the scope root whose EvalSymlinks-resolved path contains
// the already-resolved target, so that confineGrepFile computes correct
// workspace-relative paths even when the target lives in an extra root.
@@ -472,7 +455,13 @@ func (collector *grepFileListCollector) result() Result {
return okResult("No matches found.")
}
sort.Strings(collector.files)
return Result{Status: StatusOK, Output: strings.Join(collector.files, "\n")}
budgeted := applyOutputBudget(strings.Join(collector.files, "\n"), searchOutputBudgetBytes, "narrow path/glob/pattern to continue")
meta := outputBudgetMeta(budgeted)
if budgeted.Truncated {
meta["truncated"] = "true"
meta["truncation_reason"] = "byte_budget"
}
return Result{Status: StatusOK, Output: budgeted.Output, Truncated: budgeted.Truncated, Meta: meta}
}
type grepContentCollector struct {
@@ -505,15 +494,20 @@ func (collector *grepContentCollector) result() Result {
if truncated {
output += fmt.Sprintf("\n\n[truncated: showing first %d matches; narrow path/glob/pattern or increase head_limit]", len(lines))
}
meta := map[string]string{}
if truncated {
budgeted := applyOutputBudget(output, searchOutputBudgetBytes, "narrow path/glob/pattern or increase head_limit")
meta := outputBudgetMeta(budgeted)
if truncated || budgeted.Truncated {
meta["truncated"] = "true"
meta["truncation_reason"] = "head_limit"
if budgeted.Truncated {
meta["truncation_reason"] = "byte_budget"
} else {
meta["truncation_reason"] = "head_limit"
}
}
return Result{
Status: StatusOK,
Output: output,
Truncated: truncated,
Output: budgeted.Output,
Truncated: truncated || budgeted.Truncated,
Meta: meta,
}
}
+6 -16
View File
@@ -15,10 +15,6 @@ type listDirectoryTool struct {
scope PathScope
}
func (listDirectoryTool) outputCategory(map[string]any) outputCategory {
return outputCategorySearch
}
func NewListDirectoryTool(workspaceRoot string) Tool {
return NewScopedListDirectoryTool(workspaceRoot, nil)
}
@@ -46,14 +42,6 @@ func NewScopedListDirectoryTool(workspaceRoot string, scope PathScope) Tool {
}
func (tool listDirectoryTool) Run(_ context.Context, args map[string]any) Result {
return tool.run(args, true)
}
func (tool listDirectoryTool) RunWithOptions(_ context.Context, args map[string]any, _ RunOptions) Result {
return tool.run(args, false)
}
func (tool listDirectoryTool) run(args map[string]any, directBudget bool) Result {
// Optional with a "." default: treat an explicit empty path (a common
// weak-model quirk) the same as the key being absent rather than erroring.
requestedPath, err := aliasedStringArg(args, []string{"path", "directory", "dir"}, ".", false, true)
@@ -88,11 +76,13 @@ func (tool listDirectoryTool) run(args map[string]any, directBudget bool) Result
return okResult("Directory is empty: " + relativePath)
}
output := "Contents of " + relativePath + ":\n\n" + strings.Join(entries, "\n")
result := Result{Status: StatusOK, Output: output}
if directBudget {
return applyLegacyByteBudgetToResult(result, searchOutputBudgetBytes, "use path, recursive=false, or a smaller max_depth to narrow the listing")
budgeted := applyOutputBudget(output, searchOutputBudgetBytes, "use path, recursive=false, or a smaller max_depth to narrow the listing")
meta := outputBudgetMeta(budgeted)
if budgeted.Truncated {
meta["truncated"] = "true"
meta["truncation_reason"] = "byte_budget"
}
return result
return Result{Status: StatusOK, Output: budgeted.Output, Truncated: budgeted.Truncated, Meta: meta}
}
func listDirectoryEntries(path string, depth int, maxDepth int) ([]string, error) {
-254
View File
@@ -1,254 +0,0 @@
package tools
import (
"strconv"
"strings"
)
const (
outputBudgetCategoryMeta = "output_budget_category"
outputBudgetOriginalBytesMeta = "output_budget_original_bytes"
outputBudgetRetainedBytesMeta = "output_budget_retained_bytes"
outputBudgetEstimatedOriginalTokensMeta = "output_budget_estimated_original_tokens"
outputBudgetEstimatedRetainedTokensMeta = "output_budget_estimated_retained_tokens"
outputBudgetReasonMeta = "output_budget_reason"
outputBudgetSpillCreatedMeta = "output_budget_spill_created"
)
// applyRegistryOutputBudget is the common post-redaction semantic budgeting
// boundary for tools that do not already own a deliberate output budget.
func applyRegistryOutputBudget(tool Tool, toolName string, args map[string]any, result Result) Result {
budget := registryOutputBudget(toolName)
if budget.maxEstimatedTokens <= 0 && budget.hardMaxBytes <= 0 {
return result // preserve ZERO_TOOL_OUTPUT_CEILING_TOKENS=0 semantics
}
category := resolveOutputCategory(tool, toolName, args)
budgeted := budgetSemanticOutput(result.Output, category, budget)
if budgeted.truncated {
budgeted = attachExistingSpill(toolName, result.Output, budget, budgeted)
}
if result.Truncated {
budgeted.truncated = true
if budgeted.reason == "" {
budgeted.reason = result.Meta["truncation_reason"]
if budgeted.reason == "" {
budgeted.reason = "upstream_tool_budget"
}
}
if budgeted.spillPath == "" {
budgeted.spillPath = result.Meta["spill_path"]
}
}
result.Output = budgeted.text
result.Truncated = result.Truncated || budgeted.truncated
result.Meta = addOutputBudgetMetadata(result.Meta, budgeted)
return result
}
// applySelfManagedOutputBudget applies semantic retention to tools that retain
// output with their own capture-aware limits. Their explicit budgets remain
// authoritative, rather than being replaced by the registry default ceiling.
func applySelfManagedOutputBudget(tool Tool, toolName string, args map[string]any, result Result) Result {
budget := selfManagedOutputBudget(toolName, args)
if budget.maxEstimatedTokens <= 0 && budget.hardMaxBytes <= 0 {
return annotateSelfBudgetedOutput(tool, toolName, args, result)
}
category := resolveOutputCategory(tool, toolName, args)
budgeted := budgetSemanticOutput(result.Output, category, budget)
if result.Truncated && !budgeted.truncated {
budgeted.truncated = true
budgeted.reason = result.Meta["truncation_reason"]
if budgeted.reason == "" {
budgeted.reason = "upstream_tool_budget"
}
}
if budgeted.truncated {
budgeted = attachExistingSpill(toolName, result.Output, budget, budgeted)
}
result.Output = budgeted.text
result.Truncated = result.Truncated || budgeted.truncated
result.Meta = addOutputBudgetMetadata(result.Meta, budgeted)
return result
}
func selfManagedOutputBudget(toolName string, args map[string]any) outputBudget {
switch toolName {
case "bash":
// bash previously exposed at most 32 KiB from each stream. Keep that
// combined 64 KiB ceiling while selecting its contents semantically.
return outputBudget{maxEstimatedTokens: (bashOutputBudgetBytes * 2) / 4, hardMaxBytes: bashOutputBudgetBytes * 2}
case ExecCommandToolName:
maxOutputTokens := defaultMaxOutputTokens
if parsed, err := intArg(args, "max_output_tokens", defaultMaxOutputTokens, 1, maxExecOutputTokenRequest); err == nil {
maxOutputTokens = parsed
}
return outputBudget{maxEstimatedTokens: maxOutputTokens, hardMaxBytes: maxOutputTokens * 4}
default:
return outputBudget{}
}
}
// RebudgetAfterHook reapplies the registry's redaction and output limits after
// an afterTool hook appends model-visible feedback to a completed result. The
// initial registry pass has already run; this second pass is limited to the
// newly combined result so hooks cannot bypass the established safety ceiling.
func (registry *Registry) RebudgetAfterHook(toolName string, args map[string]any, result Result) Result {
result = scrubResultSecrets(result)
tool, _ := registry.Get(toolName)
if _, ok := tool.(selfBudgeting); ok {
// Match the primary registry boundary: self-managed tools keep their
// call-specific capture/output budget instead of being tightened or
// loosened to the generic registry ceiling after hook feedback.
return applySelfManagedOutputBudget(tool, toolName, args, result)
}
result = applyRegistryOutputBudget(tool, toolName, args, result)
return enforceOutputCeiling(toolName, result)
}
func registryOutputBudget(toolName string) outputBudget {
switch toolName {
case "read_file", "read_minified_file":
return outputBudget{maxEstimatedTokens: readOutputBudgetBytes / 4, hardMaxBytes: readOutputBudgetBytes}
case "grep", "glob", "list_directory":
return outputBudget{maxEstimatedTokens: searchOutputBudgetBytes / 4, hardMaxBytes: searchOutputBudgetBytes}
default:
ceilingTokens := resolveOutputCeilingTokens()
if ceilingTokens <= 0 {
return outputBudget{}
}
return outputBudget{maxEstimatedTokens: ceilingTokens, hardMaxBytes: ceilingTokens * 4}
}
}
func resolveOutputCategory(tool Tool, toolName string, args map[string]any) outputCategory {
if provider, ok := tool.(outputPolicyProvider); ok {
if category := provider.outputCategory(args); category != "" {
return category
}
}
switch toolName {
case "Task", "swarm_collect":
return outputCategoryWorker
case "apply_patch":
return outputCategoryDiff
case "write_stdin":
return outputCategoryProcess
default:
return outputCategoryDefault
}
}
// annotateSelfBudgetedOutput records the same compact metadata for tools whose
// existing capture/budget implementation remains authoritative in PR11. It
// does not re-budget their text or claim that raw_bytes represents every byte a
// subprocess produced beyond its established capture limits.
func annotateSelfBudgetedOutput(tool Tool, toolName string, args map[string]any, result Result) Result {
retainedBytes := len(result.Output)
originalBytes := retainedBytes
if parsed, err := strconv.Atoi(result.Meta["raw_bytes"]); err == nil && parsed > originalBytes {
originalBytes = parsed
}
retainedTokens := estimateOutputTokens(result.Output)
originalTokens := retainedTokens
if originalBytes > retainedBytes {
originalTokens = max(originalTokens, estimatedTokensFromBytes(originalBytes))
}
reason := result.Meta["truncation_reason"]
if result.Truncated && reason == "" {
reason = "upstream_tool_budget"
}
observed := budgetedOutput{
text: result.Output,
originalBytes: originalBytes,
retainedBytes: retainedBytes,
estimatedOriginalTokens: originalTokens,
estimatedRetainedTokens: retainedTokens,
truncated: result.Truncated,
category: resolveOutputCategory(tool, toolName, args),
reason: reason,
spillPath: result.Meta["spill_path"],
}
result.Meta = addOutputBudgetMetadata(result.Meta, observed)
return result
}
func shellOutputCategory(command string) outputCategory {
normalized := strings.ToLower(strings.TrimSpace(command))
if containsAny(normalized,
"go test", "pytest", "python -m pytest", "cargo test", "npm test", "npm run test",
"pnpm test", "yarn test", "bun test", "dotnet test", "mvn test", "gradle test", "phpunit") {
return outputCategoryTest
}
if containsAny(normalized, "git diff", "git show", "git format-patch", " diff -u", "diff --git") || strings.HasPrefix(normalized, "diff ") {
return outputCategoryDiff
}
return outputCategoryProcess
}
// attachExistingSpill reuses Zero's hardened per-user spill directory. output
// is the already-redacted text received by this layer; it may itself be a
// capture-bounded view produced by a subprocess tool.
func attachExistingSpill(toolName, output string, budget outputBudget, current budgetedOutput) budgetedOutput {
path := spillTruncatedOutput(toolName, output)
if path == "" {
return current
}
notice := "[zero] full output received by budgeting layer saved to " + path + " (grep or read_file it instead of re-running)"
reduced := outputBudget{
maxEstimatedTokens: max(1, budget.maxEstimatedTokens-estimateOutputTokens("\n"+notice)),
hardMaxBytes: max(1, budget.hardMaxBytes-len("\n"+notice)),
}
base := budgetSemanticOutput(output, current.category, reduced)
text := strings.TrimRight(base.text, "\n") + "\n" + notice
if !fitsOutputBudget(text, budget) {
// An unusually long temp path or tiny configured ceiling can leave no
// room for the full notice. Keep the safe bounded result; the spill still
// exists but is intentionally not advertised with a chopped reference.
current.spillPath = path
return current
}
base.text = text
base.retainedBytes = len(text)
base.estimatedRetainedTokens = estimateOutputTokens(text)
base.truncated = true
if base.reason == "" {
base.reason = current.reason
}
base.category = current.category
base.spillPath = path
return base
}
func addOutputBudgetMetadata(meta map[string]string, output budgetedOutput) map[string]string {
if meta == nil {
meta = map[string]string{}
}
meta[outputBudgetCategoryMeta] = string(output.category)
meta[outputBudgetOriginalBytesMeta] = strconv.Itoa(output.originalBytes)
meta[outputBudgetRetainedBytesMeta] = strconv.Itoa(output.retainedBytes)
meta[outputBudgetEstimatedOriginalTokensMeta] = strconv.Itoa(output.estimatedOriginalTokens)
meta[outputBudgetEstimatedRetainedTokensMeta] = strconv.Itoa(output.estimatedRetainedTokens)
meta[outputBudgetSpillCreatedMeta] = strconv.FormatBool(output.spillPath != "")
// These established fields describe the final model-visible output. Refresh
// them on every pass because redaction and after-tool hooks may change the
// text even when this pass does not newly truncate it.
meta["emitted_bytes"] = strconv.Itoa(output.retainedBytes)
meta["estimated_tokens"] = strconv.Itoa(output.estimatedRetainedTokens)
if output.reason != "" {
meta[outputBudgetReasonMeta] = output.reason
}
if output.truncated {
// Preserve the existing metadata vocabulary used by callers and tests.
if _, exists := meta["raw_bytes"]; !exists {
meta["raw_bytes"] = strconv.Itoa(output.originalBytes)
}
meta["truncated"] = "true"
meta["truncation_reason"] = output.reason
}
if output.spillPath != "" {
meta["spill_path"] = output.spillPath
}
return meta
}
-381
View File
@@ -1,381 +0,0 @@
package tools
import (
"context"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"unicode/utf8"
)
func TestRegistryBudgetRunsAfterRedactionAndSpillsRedactedOutput(t *testing.T) {
setTestTempDir(t)
secret := "ghp_" + strings.Repeat("a", 36)
big := "HEAD\n" + secret + "\n" + strings.Repeat("🙂 noisy output\n", 20_000) + "TAIL"
registry := NewRegistry()
registry.Register(newCeilingFakeTool("redacted_big", big))
result := registry.Run(context.Background(), "redacted_big", map[string]any{})
if !result.Truncated || result.Meta[outputBudgetSpillCreatedMeta] != "true" {
t.Fatalf("unexpected budget result: truncated=%t meta=%#v", result.Truncated, result.Meta)
}
if strings.Contains(result.Output, secret) || !utf8.ValidString(result.Output) {
t.Fatalf("exposed output leaked secret or invalid UTF-8: %q", result.Output)
}
spillPath := result.Meta["spill_path"]
content, err := os.ReadFile(spillPath)
if err != nil {
t.Fatalf("read spill: %v", err)
}
if strings.Contains(string(content), secret) {
t.Fatal("spill contains unredacted secret")
}
if !strings.Contains(string(content), "HEAD") || !strings.Contains(string(content), "TAIL") {
t.Fatal("spill does not contain the complete redacted output received by the budget layer")
}
}
func TestRegistryBudgetSpillFailureFallsBackToBoundedOutput(t *testing.T) {
temp := t.TempDir()
blockedTemp := filepath.Join(temp, "not-a-directory")
if err := os.WriteFile(blockedTemp, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("TMPDIR", blockedTemp)
t.Setenv("TMP", blockedTemp)
t.Setenv("TEMP", blockedTemp)
t.Setenv(outputCeilingEnv, "100")
registry := NewRegistry()
registry.Register(newCeilingFakeTool("spill_failure", strings.Repeat("large output\n", 1000)))
result := registry.Run(context.Background(), "spill_failure", map[string]any{})
if !result.Truncated || len(result.Output) > 400 {
t.Fatalf("fallback is not bounded: truncated=%t bytes=%d", result.Truncated, len(result.Output))
}
if result.Meta[outputBudgetSpillCreatedMeta] != "false" || result.Meta["spill_path"] != "" {
t.Fatalf("spill failure incorrectly advertised: %#v", result.Meta)
}
}
func TestRegistryMissingPolicyUsesDefaultAndExactHardCeiling(t *testing.T) {
setTestTempDir(t)
t.Setenv(outputCeilingEnv, "80")
input := "HEAD\n" + strings.Repeat("x", 3000) + "\nTAIL"
registry := NewRegistry()
registry.Register(newCeilingFakeTool("no_policy", input))
result := registry.Run(context.Background(), "no_policy", map[string]any{})
if got := result.Meta[outputBudgetCategoryMeta]; got != string(outputCategoryDefault) {
t.Fatalf("category = %q, want default", got)
}
if len(result.Output) > 80*4 {
t.Fatalf("output = %d bytes, hard ceiling %d", len(result.Output), 80*4)
}
if result.Meta[outputBudgetOriginalBytesMeta] != strconv.Itoa(len(input)) {
t.Fatalf("original size metadata = %q", result.Meta[outputBudgetOriginalBytesMeta])
}
}
func TestRegistrySmallOutputRemainsByteIdentical(t *testing.T) {
registry := NewRegistry()
registry.Register(newCeilingFakeTool("small_identity", "hello\nworld\n"))
result := registry.Run(context.Background(), "small_identity", map[string]any{})
if result.Output != "hello\nworld\n" || result.Truncated {
t.Fatalf("small output changed: %#v", result)
}
}
func TestDirectReadAndSearchToolsKeepLegacyByteBudgets(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "large-read.txt"), []byte(strings.Repeat("source line with enough content\n", 7000)), 0o600); err != nil {
t.Fatal(err)
}
if result := NewReadFileTool(root).Run(context.Background(), map[string]any{"path": "large-read.txt"}); !result.Truncated || len(result.Output) > readOutputBudgetBytes {
t.Fatalf("direct read_file output is not bounded: %#v", result)
}
for index := 0; index < 800; index++ {
name := fmt.Sprintf("%04d-%s.txt", index, strings.Repeat("n", 80))
if err := os.WriteFile(filepath.Join(root, name), []byte("match\n"), 0o600); err != nil {
t.Fatal(err)
}
}
if err := os.WriteFile(filepath.Join(root, "large-content.txt"), []byte(strings.Repeat("match "+strings.Repeat("x", 2000)+"\n", 50)), 0o600); err != nil {
t.Fatal(err)
}
for name, result := range map[string]Result{
"list_directory": NewListDirectoryTool(root).Run(context.Background(), map[string]any{"recursive": false}),
"glob": NewGlobTool(root).Run(context.Background(), map[string]any{"pattern": "*.txt", "limit": 1000}),
"grep files": NewGrepTool(root).Run(context.Background(), map[string]any{"pattern": "match", "output_mode": "files_with_matches"}),
"grep content": NewGrepTool(root).Run(context.Background(), map[string]any{"pattern": "match", "path": "large-content.txt", "head_limit": 50}),
} {
if !result.Truncated || len(result.Output) > searchOutputBudgetBytes {
t.Fatalf("direct %s output is not bounded: truncated=%t bytes=%d meta=%#v", name, result.Truncated, len(result.Output), result.Meta)
}
}
}
func TestRegistryLargeFileUsesSemanticFilePolicy(t *testing.T) {
setTestTempDir(t)
root := t.TempDir()
var content strings.Builder
content.WriteString("HEAD_MARK\n")
for line := 0; line < 7000; line++ {
content.WriteString("ordinary source line with enough text to fill the output budget\n")
}
content.WriteString("TAIL_MARK\n")
if err := os.WriteFile(filepath.Join(root, "large.txt"), []byte(content.String()), 0o600); err != nil {
t.Fatal(err)
}
registry := NewRegistry()
registry.Register(NewReadFileTool(root))
result := registry.Run(context.Background(), "read_file", map[string]any{"path": "large.txt"})
if !result.Truncated || result.Meta[outputBudgetCategoryMeta] != string(outputCategoryFile) {
t.Fatalf("large file was not semantically budgeted: truncated=%t meta=%#v", result.Truncated, result.Meta)
}
if len(result.Output) > readOutputBudgetBytes {
t.Fatalf("large file output = %d bytes, ceiling %d", len(result.Output), readOutputBudgetBytes)
}
for _, want := range []string{"File: large.txt", "HEAD_MARK", "TAIL_MARK"} {
if !strings.Contains(result.Output, want) {
t.Fatalf("large file output missing %q", want)
}
}
}
func TestRegistryLargeSingleLineFileRetainsHeadAndTail(t *testing.T) {
setTestTempDir(t)
root := t.TempDir()
content := "HEAD_SINGLE_LINE_MARK" + strings.Repeat("x", readOutputBudgetBytes*2) + "TAIL_SINGLE_LINE_MARK"
if err := os.WriteFile(filepath.Join(root, "large.min.js"), []byte(content), 0o600); err != nil {
t.Fatal(err)
}
registry := NewRegistry()
registry.Register(NewReadFileTool(root))
result := registry.Run(context.Background(), "read_file", map[string]any{"path": "large.min.js"})
if !result.Truncated || result.Meta[outputBudgetCategoryMeta] != string(outputCategoryFile) {
t.Fatalf("single-line file was not semantically budgeted: truncated=%t meta=%#v", result.Truncated, result.Meta)
}
if len(result.Output) > readOutputBudgetBytes || !utf8.ValidString(result.Output) {
t.Fatalf("single-line file output is not safely bounded: bytes=%d valid=%t", len(result.Output), utf8.ValidString(result.Output))
}
if rawBytes, err := strconv.Atoi(result.Meta["raw_bytes"]); err != nil || rawBytes <= len(result.Output) {
t.Fatalf("single-line file capture was not bounded head/tail output: raw=%q emitted=%d", result.Meta["raw_bytes"], len(result.Output))
}
for _, want := range []string{"File: large.min.js", "HEAD_SINGLE_LINE_MARK", "TAIL_SINGLE_LINE_MARK"} {
if !strings.Contains(result.Output, want) {
t.Fatalf("single-line file output missing %q", want)
}
}
}
func TestRegistryMinifiedFileUpdatesEmittedBytesAfterBudgeting(t *testing.T) {
setTestTempDir(t)
root := t.TempDir()
content := "HEAD_MINIFIED_MARK" + strings.Repeat("x", readOutputBudgetBytes*2) + "TAIL_MINIFIED_MARK"
if err := os.WriteFile(filepath.Join(root, "large.txt"), []byte(content), 0o600); err != nil {
t.Fatal(err)
}
registry := NewRegistry()
registry.Register(NewReadMinifiedFileTool(root))
result := registry.Run(context.Background(), "read_minified_file", map[string]any{"path": "large.txt"})
if !result.Truncated || result.Meta["emitted_bytes"] != strconv.Itoa(len(result.Output)) {
t.Fatalf("minified metadata does not describe final output: truncated=%t emitted=%q bytes=%d", result.Truncated, result.Meta["emitted_bytes"], len(result.Output))
}
for _, want := range []string{"HEAD_MINIFIED_MARK", "TAIL_MINIFIED_MARK"} {
if !strings.Contains(result.Output, want) {
t.Fatalf("minified single-line file output missing %q", want)
}
}
}
func TestRegistryGrepUsesSemanticMultiFileCoverage(t *testing.T) {
setTestTempDir(t)
root := t.TempDir()
for _, name := range []string{"a.txt", "b.txt", "c.txt", "d.txt"} {
body := strings.Repeat("needle "+strings.Repeat(name, 15)+"\n", 350)
if err := os.WriteFile(filepath.Join(root, name), []byte(body), 0o600); err != nil {
t.Fatal(err)
}
}
registry := NewRegistry()
registry.Register(NewGrepTool(root))
result := registry.Run(context.Background(), "grep", map[string]any{
"pattern": "needle",
"path": ".",
"head_limit": 1400,
})
if !result.Truncated || result.Meta[outputBudgetCategoryMeta] != string(outputCategorySearch) {
t.Fatalf("grep was not semantically budgeted: truncated=%t meta=%#v", result.Truncated, result.Meta)
}
for _, name := range []string{"a.txt:", "b.txt:", "c.txt:", "d.txt:"} {
if !strings.Contains(result.Output, name) {
t.Fatalf("search output lost %s coverage", name)
}
}
}
func TestShellOutputCategoryClassification(t *testing.T) {
tests := []struct {
command string
want outputCategory
}{
{"go test ./...", outputCategoryTest},
{"python -m pytest -q", outputCategoryTest},
{"git diff --stat main...HEAD", outputCategoryDiff},
{"git show HEAD", outputCategoryDiff},
{"make build", outputCategoryProcess},
}
for _, test := range tests {
if got := shellOutputCategory(test.command); got != test.want {
t.Errorf("shellOutputCategory(%q) = %q, want %q", test.command, got, test.want)
}
}
}
func TestShellToolOutputCategoryUsesAllCommandAliases(t *testing.T) {
providers := []struct {
name string
provider outputPolicyProvider
}{
{name: "bash", provider: NewBashTool(t.TempDir()).(outputPolicyProvider)},
{name: "exec_command", provider: NewExecCommandTool(t.TempDir(), newExecSessionManager()).(outputPolicyProvider)},
}
for _, provider := range providers {
for _, alias := range []string{"command", "cmd", "script", "shell"} {
t.Run(provider.name+"/"+alias, func(t *testing.T) {
if got := provider.provider.outputCategory(map[string]any{alias: "go test ./..."}); got != outputCategoryTest {
t.Fatalf("category = %q, want test", got)
}
})
}
}
if got := providers[0].provider.outputCategory(map[string]any{"command": "make build", "cmd": "go test ./..."}); got != outputCategoryProcess {
t.Fatalf("bash alias precedence category = %q, want process", got)
}
if got := providers[1].provider.outputCategory(map[string]any{"cmd": "go test ./...", "command": "make build"}); got != outputCategoryTest {
t.Fatalf("exec_command alias precedence category = %q, want test", got)
}
}
func TestRegistryBudgetPreservesExistingTruncationAndRefreshesMetadata(t *testing.T) {
output := "post-hook output"
result := applyRegistryOutputBudget(newCeilingFakeTool("existing_truncation", output), "existing_truncation", map[string]any{}, Result{
Status: StatusOK,
Output: output,
Truncated: true,
Meta: map[string]string{
"emitted_bytes": "999",
"estimated_tokens": "999",
"truncation_reason": "capture_budget",
},
})
if !result.Truncated || result.Meta["truncated"] != "true" || result.Meta[outputBudgetReasonMeta] != "capture_budget" {
t.Fatalf("prior truncation state was lost: %#v", result)
}
if result.Meta["emitted_bytes"] != strconv.Itoa(len(output)) || result.Meta["estimated_tokens"] != strconv.Itoa(estimateOutputTokens(output)) {
t.Fatalf("final output metadata is stale: %#v", result.Meta)
}
if result.Meta[outputBudgetSpillCreatedMeta] != "false" {
t.Fatalf("existing truncation unexpectedly created a new spill: %#v", result.Meta)
}
}
func TestSelfManagedOutputBudgetUsesSemanticTestPolicy(t *testing.T) {
setTestTempDir(t)
input := "stdout:\n" + strings.Repeat("PASS progress\n", 8_000) +
"--- FAIL: TestImportant (0.01s)\nthing_test.go:42: expected 7, got 9\nFAIL\nexit_code: 1"
tests := []struct {
name string
tool Tool
args map[string]any
}{
{name: "bash", tool: NewBashTool(t.TempDir()), args: map[string]any{"command": "go test ./..."}},
{name: "exec", tool: NewExecCommandTool(t.TempDir(), newExecSessionManager()), args: map[string]any{"cmd": "go test ./...", "max_output_tokens": 16_000}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got := applySelfManagedOutputBudget(test.tool, test.tool.Name(), test.args, Result{Status: StatusError, Output: input, Meta: map[string]string{}})
if !got.Truncated || got.Meta[outputBudgetCategoryMeta] != string(outputCategoryTest) {
t.Fatalf("semantic budget was not applied: %#v", got)
}
for _, want := range []string{"TestImportant", "expected 7", "FAIL", "exit_code: 1"} {
if !strings.Contains(got.Output, want) {
t.Fatalf("semantic test output missing %q:\n%s", want, got.Output)
}
}
})
}
}
type semanticBashFakeTool struct{ ceilingFakeTool }
func (semanticBashFakeTool) managesOutputBudget() {}
func (semanticBashFakeTool) outputCategory(args map[string]any) outputCategory {
command, _ := args["command"].(string)
return shellOutputCategory(command)
}
func TestRegistryAppliesSemanticBudgetToSelfManagedShellOutput(t *testing.T) {
setTestTempDir(t)
input := "startup\n" + strings.Repeat("PASS progress\n", 8_000) +
"--- FAIL: TestRegistryImportant (0.01s)\nexpected 7, got 9\nFAIL\nexit_code: 1"
registry := NewRegistry()
registry.Register(semanticBashFakeTool{newCeilingFakeTool("bash", input)})
result := registry.Run(context.Background(), "bash", map[string]any{"command": "go test ./..."})
if !result.Truncated || result.Meta[outputBudgetReasonMeta] != "semantic_test_budget" {
t.Fatalf("registry did not apply semantic test budget: %#v", result.Meta)
}
for _, want := range []string{"TestRegistryImportant", "expected 7", "FAIL", "exit_code: 1"} {
if !strings.Contains(result.Output, want) {
t.Fatalf("registry semantic test output missing %q:\n%s", want, result.Output)
}
}
}
func TestRegistryAppliesSemanticBudgetToSelfManagedProcessOutput(t *testing.T) {
setTestTempDir(t)
input := "starting build\n" + strings.Repeat("progress\n", 8_000) +
"WARNING: cache is cold\nERROR: compilation failed\nexit_code: 1"
registry := NewRegistry()
registry.Register(semanticBashFakeTool{newCeilingFakeTool("bash", input)})
result := registry.Run(context.Background(), "bash", map[string]any{"command": "make build"})
if !result.Truncated || result.Meta[outputBudgetReasonMeta] != "semantic_process_budget" {
t.Fatalf("registry did not apply semantic process budget: %#v", result.Meta)
}
for _, want := range []string{"starting build", "WARNING", "ERROR", "exit_code: 1"} {
if !strings.Contains(result.Output, want) {
t.Fatalf("registry semantic process output missing %q:\n%s", want, result.Output)
}
}
}
func TestRebudgetAfterHookPreservesSelfManagedExecBudget(t *testing.T) {
setTestTempDir(t)
registry := NewRegistry()
registry.Register(NewExecCommandTool(t.TempDir(), newExecSessionManager()))
t.Run("tight call budget remains authoritative", func(t *testing.T) {
args := map[string]any{"cmd": "make build", "max_output_tokens": 10}
result := registry.RebudgetAfterHook(ExecCommandToolName, args, Result{Status: StatusOK, Output: strings.Repeat("x", 1_000)})
if !result.Truncated || len(result.Output) > 40 {
t.Fatalf("post-hook output escaped call budget: truncated=%t bytes=%d meta=%#v", result.Truncated, len(result.Output), result.Meta)
}
})
t.Run("raised call budget is not replaced by generic ceiling", func(t *testing.T) {
args := map[string]any{"cmd": "make build", "max_output_tokens": 20_000}
output := strings.Repeat("x", 70_000)
result := registry.RebudgetAfterHook(ExecCommandToolName, args, Result{Status: StatusOK, Output: output})
if result.Truncated || result.Output != output {
t.Fatalf("post-hook output was incorrectly limited by generic ceiling: truncated=%t bytes=%d", result.Truncated, len(result.Output))
}
})
}
+2 -213
View File
@@ -4,7 +4,6 @@ import (
"fmt"
"strconv"
"strings"
"unicode/utf8"
)
const (
@@ -12,167 +11,6 @@ const (
searchOutputBudgetBytes = 64 * 1024
)
// outputCategory selects the deterministic retention policy used for a tool
// result. It is deliberately internal: categories are an implementation detail
// of the tools boundary, not part of Zero's public tool protocol.
type outputCategory string
const (
outputCategoryDefault outputCategory = "default"
outputCategoryFile outputCategory = "file"
outputCategorySearch outputCategory = "search"
outputCategoryTest outputCategory = "test"
outputCategoryProcess outputCategory = "process"
outputCategoryDiff outputCategory = "diff"
outputCategoryWorker outputCategory = "worker"
)
// outputBudget combines a provider-neutral estimated-token target with the
// existing byte ceiling. The byte ceiling is always authoritative: semantic
// retention may use fewer bytes, but can never use more.
type outputBudget struct {
maxEstimatedTokens int
hardMaxBytes int
}
// budgetedOutput describes the text retained by a semantic output policy. The
// original size is the output received by this layer; it does not claim to be
// every byte an underlying subprocess may have produced before its own capture
// limits were applied.
type budgetedOutput struct {
text string
originalBytes int
retainedBytes int
estimatedOriginalTokens int
estimatedRetainedTokens int
truncated bool
category outputCategory
reason string
spillPath string
}
// outputPolicyProvider optionally assigns a semantic category to a tool call.
// Tools that do not implement it use outputCategoryDefault.
type outputPolicyProvider interface {
outputCategory(args map[string]any) outputCategory
}
// estimateOutputTokens is a deterministic, provider-neutral estimate used only
// for output budgeting. It is not exact provider tokenization. ASCII non-space
// text uses the repository's established four-bytes-per-token approximation;
// each byte of non-ASCII UTF-8 is counted as a token, intentionally
// overestimating multilingual text and emoji rather than letting them bypass a
// budget. Existing hard byte ceilings remain the final safety limit.
func estimateOutputTokens(value string) int {
asciiNonSpace, nonASCIIBytes := outputTokenComponents(value)
return (asciiNonSpace+3)/4 + nonASCIIBytes
}
func outputTokenComponents(value string) (asciiNonSpace int, nonASCIIBytes int) {
for index := 0; index < len(value); {
r, size := utf8.DecodeRuneInString(value[index:])
if r == utf8.RuneError && size == 1 {
// Invalid input is charged conservatively one token per byte. Budget
// slicing itself remains rune-safe for valid UTF-8 tool output.
nonASCIIBytes++
index++
continue
}
if r < utf8.RuneSelf {
switch r {
case ' ', '\t', '\n', '\r', '\f', '\v':
default:
asciiNonSpace++
}
} else {
nonASCIIBytes += size
}
index += size
}
return asciiNonSpace, nonASCIIBytes
}
const defaultSemanticTruncationNotice = "\n[zero] output truncated\n"
// budgetDefaultOutput applies the safe fallback policy: retain a rune-safe head
// and tail around a stable truncation notice. Small output is returned
// byte-identically. Spill creation is intentionally handled by the shared
// boundary integration so every semantic policy uses the existing spill path.
func budgetDefaultOutput(output string, budget outputBudget) budgetedOutput {
result := budgetedOutput{
text: output,
originalBytes: len(output),
retainedBytes: len(output),
estimatedOriginalTokens: estimateOutputTokens(output),
estimatedRetainedTokens: estimateOutputTokens(output),
category: outputCategoryDefault,
}
if fitsOutputBudget(output, budget) {
return result
}
reason := outputBudgetReason(result.originalBytes, result.estimatedOriginalTokens, budget)
maxContentBytes := len(output)
if budget.hardMaxBytes > 0 {
maxContentBytes = min(maxContentBytes, max(0, budget.hardMaxBytes-len(defaultSemanticTruncationNotice)))
}
// Find the largest deterministic head+tail window that satisfies both the
// estimate and the hard byte ceiling. fitsOutputBudget is monotonic as this
// window grows, so binary search avoids repeatedly trimming one rune at a time.
low, high := 0, maxContentBytes
best := ""
if fitsOutputBudget(defaultSemanticTruncationNotice, budget) {
best = defaultSemanticTruncationNotice
}
for low <= high {
window := low + (high-low)/2
candidate := defaultHeadTail(output, window)
if fitsOutputBudget(candidate, budget) {
best = candidate
low = window + 1
} else {
high = window - 1
}
}
result.text = best
result.retainedBytes = len(best)
result.estimatedRetainedTokens = estimateOutputTokens(best)
result.truncated = true
result.reason = reason
return result
}
func defaultHeadTail(output string, contentBytes int) string {
if contentBytes <= 0 {
return defaultSemanticTruncationNotice
}
headBytes := contentBytes / 2
tailBytes := contentBytes - headBytes
return utf8Prefix(output, headBytes) + defaultSemanticTruncationNotice + utf8Suffix(output, tailBytes)
}
func fitsOutputBudget(output string, budget outputBudget) bool {
if budget.hardMaxBytes > 0 && len(output) > budget.hardMaxBytes {
return false
}
return budget.maxEstimatedTokens <= 0 || estimateOutputTokens(output) <= budget.maxEstimatedTokens
}
func outputBudgetReason(originalBytes int, estimatedTokens int, budget outputBudget) string {
overTokens := budget.maxEstimatedTokens > 0 && estimatedTokens > budget.maxEstimatedTokens
overBytes := budget.hardMaxBytes > 0 && originalBytes > budget.hardMaxBytes
switch {
case overTokens && overBytes:
return "token_and_byte_budget"
case overTokens:
return "estimated_token_budget"
default:
return "hard_byte_ceiling"
}
}
type outputBudgetResult struct {
Output string
Truncated bool
@@ -218,7 +56,6 @@ func estimatedTokensFromBytes(bytes int) int {
type outputBudgetBuilder struct {
builder strings.Builder
capture *boundedBuffer
rawBytes int
maxBytes int
hint string
@@ -228,60 +65,17 @@ func newOutputBudgetBuilder(maxBytes int, hint string) *outputBudgetBuilder {
return &outputBudgetBuilder{maxBytes: maxBytes, hint: hint}
}
// newHeadTailOutputBudgetBuilder bounds capture while retaining both ends of
// an input for a later semantic policy. It is used for registry-driven file
// reads; direct Tool.Run calls keep the legacy prefix-only builder above.
func newHeadTailOutputBudgetBuilder(maxBytes int, hint string) *outputBudgetBuilder {
headBytes := maxBytes / 2
return &outputBudgetBuilder{
capture: newBoundedBuffer(headBytes, maxBytes-headBytes),
maxBytes: maxBytes,
hint: hint,
}
}
func (builder *outputBudgetBuilder) WriteString(value string) {
builder.rawBytes += len(value)
if builder.capture != nil {
_, _ = builder.capture.Write([]byte(value))
return
}
if builder.maxBytes <= 0 {
builder.builder.WriteString(value)
return
}
if builder.builder.Len() >= builder.maxBytes {
if builder.maxBytes <= 0 || builder.builder.Len() >= builder.maxBytes {
return
}
remaining := builder.maxBytes - builder.builder.Len()
builder.builder.WriteString(utf8Prefix(value, remaining))
}
// applyLegacyByteBudgetToResult preserves direct Tool.Run behavior for callers
// that intentionally bypass Registry.RunWithOptions. Agent/MCP execution uses
// the registry's post-redaction semantic boundary instead.
func applyLegacyByteBudgetToResult(result Result, maxBytes int, hint string) Result {
budgeted := applyOutputBudget(result.Output, maxBytes, hint)
result.Output = budgeted.Output
result.Truncated = result.Truncated || budgeted.Truncated
if result.Meta == nil {
result.Meta = map[string]string{}
}
for key, value := range outputBudgetMeta(budgeted) {
result.Meta[key] = value
}
if budgeted.Truncated {
result.Meta["truncated"] = "true"
result.Meta["truncation_reason"] = "byte_budget"
}
return result
}
func (builder *outputBudgetBuilder) Result() outputBudgetResult {
output := builder.builder.String()
if builder.capture != nil {
output = builder.capture.retained()
}
result := outputBudgetResult{
Output: output,
RawBytes: builder.rawBytes,
@@ -296,12 +90,7 @@ func (builder *outputBudgetBuilder) Result() outputBudgetResult {
if budget < 0 {
budget = 0
}
if builder.capture != nil {
headBytes := budget / 2
result.Output = utf8Prefix(output, headBytes) + marker + utf8Suffix(output, budget-headBytes)
} else {
result.Output = utf8Prefix(output, budget) + marker
}
result.Output = utf8Prefix(output, budget) + marker
result.Truncated = true
result.EmittedBytes = len(result.Output)
return result
-90
View File
@@ -1,90 +0,0 @@
package tools
import (
"strings"
"testing"
"unicode/utf8"
)
func TestEstimateOutputTokensASCIIAndUnicode(t *testing.T) {
if got := estimateOutputTokens("abcd efgh"); got != 2 {
t.Fatalf("ASCII estimate = %d, want 2", got)
}
unicodeText := "🙂界"
if got := estimateOutputTokens(unicodeText); got < len([]byte(unicodeText)) {
t.Fatalf("Unicode estimate = %d, want conservative estimate >= %d", got, len([]byte(unicodeText)))
}
if first, second := estimateOutputTokens(unicodeText), estimateOutputTokens(unicodeText); first != second {
t.Fatalf("estimator is not deterministic: %d != %d", first, second)
}
if input := strings.Repeat("\u0080", 4); estimateOutputTokens(input) < len([]byte(input)) {
t.Fatalf("U+0080 estimate = %d, want conservative estimate >= %d", estimateOutputTokens(input), len([]byte(input)))
}
}
func TestBudgetDefaultOutputLeavesSmallOutputByteIdentical(t *testing.T) {
input := "small output\nwith spacing\n"
got := budgetDefaultOutput(input, outputBudget{maxEstimatedTokens: 100, hardMaxBytes: 1024})
if got.text != input {
t.Fatalf("small output changed: got %q want %q", got.text, input)
}
if got.truncated || got.originalBytes != len(input) || got.retainedBytes != len(input) {
t.Fatalf("unexpected small-output metadata: %#v", got)
}
}
func TestBudgetDefaultOutputKeepsUTF8HeadTailWithinHardCeiling(t *testing.T) {
input := "HEAD🙂\n" + strings.Repeat("界", 200) + "\nTAIL🙂"
const hardMax = 120
got := budgetDefaultOutput(input, outputBudget{maxEstimatedTokens: 10_000, hardMaxBytes: hardMax})
if !got.truncated {
t.Fatal("large output was not truncated")
}
if len(got.text) > hardMax {
t.Fatalf("retained %d bytes, hard ceiling %d", len(got.text), hardMax)
}
if !utf8.ValidString(got.text) {
t.Fatalf("budgeted output is invalid UTF-8: %q", got.text)
}
for _, want := range []string{"HEAD", "TAIL", "output truncated"} {
if !strings.Contains(got.text, want) {
t.Fatalf("budgeted output missing %q: %q", want, got.text)
}
}
}
func TestBudgetDefaultOutputHonorsEstimatedTokenBudget(t *testing.T) {
input := strings.Repeat("🙂", 100)
got := budgetDefaultOutput(input, outputBudget{maxEstimatedTokens: 60, hardMaxBytes: 1024})
if !got.truncated || got.reason != "estimated_token_budget" {
t.Fatalf("unexpected token-budget result: %#v", got)
}
if got.estimatedRetainedTokens > 60 {
t.Fatalf("retained estimate = %d, budget 60", got.estimatedRetainedTokens)
}
}
func TestBudgetDefaultOutputDeterministic(t *testing.T) {
input := "head\n" + strings.Repeat("same line\n", 1000) + "tail\n"
budget := outputBudget{maxEstimatedTokens: 80, hardMaxBytes: 512}
want := budgetDefaultOutput(input, budget)
for iteration := 0; iteration < 20; iteration++ {
got := budgetDefaultOutput(input, budget)
if got != want {
t.Fatalf("iteration %d differs:\n got %#v\nwant %#v", iteration, got, want)
}
}
}
func TestBudgetDefaultOutputTinyBudgetsNeverExceedTheirCeilings(t *testing.T) {
input := strings.Repeat("oversized output ", 100)
for _, budget := range []outputBudget{
{hardMaxBytes: len(defaultSemanticTruncationNotice) - 1},
{maxEstimatedTokens: estimateOutputTokens(defaultSemanticTruncationNotice) - 1},
} {
got := budgetDefaultOutput(input, budget)
if !got.truncated || !fitsOutputBudget(got.text, budget) {
t.Fatalf("tiny budget result does not fit: budget=%#v result=%#v", budget, got)
}
}
}
+15 -17
View File
@@ -25,19 +25,23 @@ const defaultOutputCeilingTokens = 16_000
// disables the ceiling entirely; unset or unparsable keeps the default.
const outputCeilingEnv = "ZERO_TOOL_OUTPUT_CEILING_TOKENS"
// selfBudgeting marks a tool with a deliberate capture-aware output budget —
// possibly model-raisable (exec_command). The registry applies semantic
// retention within that explicit budget rather than replacing it with the
// default ceiling. The method is unexported so an MCP-served tool cannot opt
// into this path.
// selfBudgeting marks a tool that enforces its own deliberate output budget —
// possibly model-raisable (exec_command) — which the registry ceiling must not
// second-guess. The method is unexported on purpose: only tools in this
// package can opt out, so an MCP-served tool can never exempt itself.
type selfBudgeting interface{ managesOutputBudget() }
// The list is kept in one place. Shell/process tools retain their established
// capture-aware budgets: bash (per-stream bounded capture) and exec_command
// (model-raisable bounded session output). File/search tools use the standard
// shared post-redaction boundary.
func (bashTool) managesOutputBudget() {}
func (execCommandTool) managesOutputBudget() {}
// The exemption list, kept in one place. Each of these applies its own budget
// before returning: bash (bashOutputBudgetBytes per stream + spill),
// exec_command (model-raisable token budget + spill), read tools (128 KiB),
// search tools (64 KiB).
func (bashTool) managesOutputBudget() {}
func (execCommandTool) managesOutputBudget() {}
func (readFileTool) managesOutputBudget() {}
func (readMinifiedFileTool) managesOutputBudget() {}
func (grepTool) managesOutputBudget() {}
func (globTool) managesOutputBudget() {}
func (listDirectoryTool) managesOutputBudget() {}
func resolveOutputCeilingTokens() int {
raw := strings.TrimSpace(os.Getenv(outputCeilingEnv))
@@ -57,12 +61,6 @@ func resolveOutputCeilingTokens() int {
// and the spill file agree on what was hidden.
func enforceOutputCeiling(toolName string, result Result) Result {
ceiling := resolveOutputCeilingTokens()
switch toolName {
case "read_file", "read_minified_file":
ceiling = readOutputBudgetBytes / 4
case "grep", "glob", "list_directory":
ceiling = searchOutputBudgetBytes / 4
}
if ceiling <= 0 {
return result
}
+7 -7
View File
@@ -55,10 +55,10 @@ func TestOutputCeilingCapsUnbudgetedTool(t *testing.T) {
if result.Meta["raw_bytes"] != strconv.Itoa(len(big)) {
t.Fatalf("raw_bytes = %s, want %d", result.Meta["raw_bytes"], len(big))
}
if !strings.Contains(result.Output, "full output received by budgeting layer saved to ") {
if !strings.Contains(result.Output, "full output saved to ") {
t.Fatalf("ceiling truncation must include a spill hint: %q", result.Output[:200])
}
start := strings.Index(result.Output, "full output received by budgeting layer saved to ") + len("full output received by budgeting layer saved to ")
start := strings.Index(result.Output, "full output saved to ") + len("full output saved to ")
end := strings.Index(result.Output[start:], " (grep")
content, err := os.ReadFile(result.Output[start : start+end])
if err != nil {
@@ -123,6 +123,11 @@ func TestSelfBudgetingExemptionList(t *testing.T) {
exempt := []Tool{
NewBashTool(dir),
NewExecCommandTool(dir, newExecSessionManager()),
NewReadFileTool(dir),
NewReadMinifiedFileTool(dir),
NewGrepTool(dir),
NewGlobTool(dir),
NewListDirectoryTool(dir),
}
for _, tool := range exempt {
if _, ok := tool.(selfBudgeting); !ok {
@@ -132,9 +137,4 @@ func TestSelfBudgetingExemptionList(t *testing.T) {
if _, ok := NewWebFetchTool().(selfBudgeting); ok {
t.Error("web_fetch must NOT be exempt — the ceiling is its only budget")
}
for _, tool := range []Tool{NewReadFileTool(dir), NewReadMinifiedFileTool(dir), NewGrepTool(dir), NewGlobTool(dir), NewListDirectoryTool(dir)} {
if _, ok := tool.(selfBudgeting); ok {
t.Errorf("%s must use the shared post-redaction budget boundary", tool.Name())
}
}
}
-447
View File
@@ -1,447 +0,0 @@
package tools
import (
"fmt"
"sort"
"strconv"
"strings"
)
// budgetSemanticOutput applies the deterministic policy for category. The
// caller supplies output after secret redaction. Small results pass through
// byte-identically; only oversized results are semantically reduced.
func budgetSemanticOutput(output string, category outputCategory, budget outputBudget) budgetedOutput {
if category == "" {
category = outputCategoryDefault
}
if fitsOutputBudget(output, budget) {
return unchangedBudgetedOutput(output, category)
}
var retained string
switch category {
case outputCategoryFile:
retained = budgetFileLines(output, budget)
case outputCategorySearch:
retained = budgetSearchLines(output, budget)
case outputCategoryTest:
retained = budgetTestLines(output, budget)
case outputCategoryProcess:
retained = budgetProcessLines(output, budget)
case outputCategoryDiff:
retained = budgetDiffUnits(output, budget)
case outputCategoryWorker:
retained = budgetWorkerLines(output, budget)
default:
return budgetDefaultOutput(output, budget)
}
if retained == "" || !fitsOutputBudget(retained, budget) {
fallback := budgetDefaultOutput(output, budget)
fallback.category = category
return fallback
}
return truncatedBudgetedOutput(output, retained, category, "semantic_"+string(category)+"_budget")
}
func unchangedBudgetedOutput(output string, category outputCategory) budgetedOutput {
estimated := estimateOutputTokens(output)
return budgetedOutput{
text: output,
originalBytes: len(output),
retainedBytes: len(output),
estimatedOriginalTokens: estimated,
estimatedRetainedTokens: estimated,
category: category,
}
}
func truncatedBudgetedOutput(original, retained string, category outputCategory, reason string) budgetedOutput {
return budgetedOutput{
text: retained,
originalBytes: len(original),
retainedBytes: len(retained),
estimatedOriginalTokens: estimateOutputTokens(original),
estimatedRetainedTokens: estimateOutputTokens(retained),
truncated: true,
category: category,
reason: reason,
}
}
func budgetFileLines(output string, budget outputBudget) string {
lines := outputLines(output)
priorities := make([]int, 0, len(lines))
// Keep the existing file/range header first, then alternate from the start
// and end so both requested-location boundaries survive.
if len(lines) > 0 {
priorities = append(priorities, 0)
}
for left, right := 1, len(lines)-1; left <= right; left, right = left+1, right-1 {
priorities = append(priorities, left)
if right != left {
priorities = append(priorities, right)
}
}
units := lineDiffUnits(lines)
selected := selectPrioritizedUnits(units, priorities, budget)
for index := range selected {
// Index zero is the file/range header. If no non-blank requested
// content line fits as a complete line, let the common caller fall
// back to its UTF-8-safe head/tail policy rather than returning only
// a header and omission marker for a minified single-line file.
if index > 0 && strings.TrimSpace(lines[index]) != "" {
return renderSelectedUnits(units, selected)
}
}
return ""
}
func budgetSearchLines(output string, budget outputBudget) string {
lines := collapseConsecutiveDuplicateLines(outputLines(output))
priorities := make([]int, 0, len(lines))
for index, line := range lines {
lower := strings.ToLower(line)
// A match body naturally contains the search term (often literally
// "match"); treat only non-result lines as summaries so one busy file
// cannot consume the budget before cross-file representatives are chosen.
if searchResultFile(line) == "" && (strings.Contains(lower, "match") || strings.Contains(lower, "truncated") || strings.Contains(lower, "result")) {
priorities = append(priorities, index)
}
}
if len(lines) > 0 {
priorities = append(priorities, 0, len(lines)-1)
}
seenFiles := map[string]bool{}
for index, line := range lines {
if file := searchResultFile(line); file != "" && !seenFiles[file] {
seenFiles[file] = true
priorities = append(priorities, index)
}
}
priorities = append(priorities, sequence(len(lines))...)
return retainPrioritizedLines(lines, priorities, budget)
}
func budgetTestLines(output string, budget outputBudget) string {
lines := collapseConsecutiveDuplicateLines(outputLines(output))
priorities := make([]int, 0, len(lines))
for index, line := range lines {
if isTestFailureLine(line) {
for contextIndex := max(0, index-2); contextIndex <= min(len(lines)-1, index+3); contextIndex++ {
priorities = append(priorities, contextIndex)
}
}
}
// Final summaries and process status usually live at the tail.
for index := max(0, len(lines)-16); index < len(lines); index++ {
priorities = append(priorities, index)
}
for index := 0; index < min(8, len(lines)); index++ {
priorities = append(priorities, index)
}
priorities = append(priorities, sequence(len(lines))...)
return retainPrioritizedLines(lines, priorities, budget)
}
func budgetProcessLines(output string, budget outputBudget) string {
lines := collapseConsecutiveDuplicateLines(outputLines(output))
priorities := make([]int, 0, len(lines))
for index := 0; index < min(10, len(lines)); index++ {
priorities = append(priorities, index)
}
seenDiagnostic := map[string]bool{}
for index, line := range lines {
lower := strings.ToLower(line)
if containsAny(lower, "error", "warning", "warn:", "fatal", "panic", "failed", "denied") && !seenDiagnostic[line] {
seenDiagnostic[line] = true
priorities = append(priorities, index)
}
}
for index := max(0, len(lines)-16); index < len(lines); index++ {
priorities = append(priorities, index)
}
priorities = append(priorities, sequence(len(lines))...)
return retainPrioritizedLines(lines, priorities, budget)
}
func budgetWorkerLines(output string, budget outputBudget) string {
lines := collapseConsecutiveDuplicateLines(outputLines(output))
priorities := make([]int, 0, len(lines))
for index, line := range lines {
lower := strings.ToLower(line)
if containsAny(lower, "status", "error", "failed", "failure", "session_id", "changed", "files", "tools executed", "conclusion", "result") {
priorities = append(priorities, index)
}
}
// A specialist's final conclusion is conventionally at the end.
for index := max(0, len(lines)-20); index < len(lines); index++ {
priorities = append(priorities, index)
}
for index := 0; index < min(6, len(lines)); index++ {
priorities = append(priorities, index)
}
priorities = append(priorities, sequence(len(lines))...)
return retainPrioritizedLines(lines, priorities, budget)
}
type diffUnit struct {
order int
text string
key bool
}
func budgetDiffUnits(output string, budget outputBudget) string {
units := splitDiffUnits(output)
if len(units) == 0 {
return ""
}
priority := make([]int, 0, len(units))
for index, unit := range units {
if unit.key {
priority = append(priority, index)
}
}
priority = append(priority, sequence(len(units))...)
return retainPrioritizedUnits(units, priority, budget)
}
// splitDiffUnits keeps each hunk indivisible. File headers/stat text are key
// units so broad file coverage is selected before secondary hunks.
func splitDiffUnits(output string) []diffUnit {
lines := outputLines(output)
units := make([]diffUnit, 0)
var current []string
currentKey := false
flush := func() {
if len(current) == 0 {
return
}
units = append(units, diffUnit{order: len(units), text: strings.Join(current, "\n"), key: currentKey})
current = nil
currentKey = false
}
for _, line := range lines {
if strings.HasPrefix(line, "diff --git ") {
flush()
currentKey = true
current = append(current, line)
continue
}
if strings.HasPrefix(line, "@@ ") {
flush()
current = append(current, line)
continue
}
if len(current) == 0 {
currentKey = true // diff stat/summary or standalone file headers
}
current = append(current, line)
}
flush()
return units
}
func retainPrioritizedLines(lines []string, priorities []int, budget outputBudget) string {
return retainPrioritizedUnits(lineDiffUnits(lines), priorities, budget)
}
func lineDiffUnits(lines []string) []diffUnit {
units := make([]diffUnit, 0, len(lines))
for index, line := range lines {
units = append(units, diffUnit{order: index, text: line})
}
return units
}
func retainPrioritizedUnits(units []diffUnit, priorities []int, budget outputBudget) string {
return renderSelectedUnits(units, selectPrioritizedUnits(units, priorities, budget))
}
func selectPrioritizedUnits(units []diffUnit, priorities []int, budget outputBudget) map[int]bool {
selected := map[int]bool{}
indexes := make([]int, 0, len(priorities))
cost := retainedUnitCost{}
for _, index := range priorities {
if index < 0 || index >= len(units) || selected[index] {
continue
}
position := sort.SearchInts(indexes, index)
previous, next := -1, len(units)
if position > 0 {
previous = indexes[position-1]
}
if position < len(indexes) {
next = indexes[position]
}
candidate := cost
candidate.add(units[index].text)
if len(indexes) == 0 {
candidate.addOmission(index)
candidate.addOmission(len(units) - index - 1)
} else {
candidate.removeOmission(next - previous - 1)
candidate.addOmission(index - previous - 1)
candidate.addOmission(next - index - 1)
}
if !candidate.fits(budget) {
continue
}
selected[index] = true
indexes = append(indexes, 0)
copy(indexes[position+1:], indexes[position:])
indexes[position] = index
cost = candidate
}
return selected
}
// retainedUnitCost tracks the exact rendered size of selected units without
// repeatedly rebuilding the full candidate text. Newlines between rendered
// parts add bytes but no estimated tokens, so the token components remain
// additive even as priorities select units out of source order.
type retainedUnitCost struct {
textBytes int
asciiNonSpace int
nonASCIIBytes int
parts int
}
func (cost *retainedUnitCost) add(text string) {
ascii, nonASCII := outputTokenComponents(text)
cost.textBytes += len(text)
cost.asciiNonSpace += ascii
cost.nonASCIIBytes += nonASCII
cost.parts++
}
func (cost *retainedUnitCost) remove(text string) {
ascii, nonASCII := outputTokenComponents(text)
cost.textBytes -= len(text)
cost.asciiNonSpace -= ascii
cost.nonASCIIBytes -= nonASCII
cost.parts--
}
func (cost *retainedUnitCost) addOmission(count int) {
if count > 0 {
cost.add(omittedSectionsMarker(count))
}
}
func (cost *retainedUnitCost) removeOmission(count int) {
if count > 0 {
cost.remove(omittedSectionsMarker(count))
}
}
func (cost retainedUnitCost) fits(budget outputBudget) bool {
bytes := cost.textBytes + max(0, cost.parts-1)
tokens := (cost.asciiNonSpace+3)/4 + cost.nonASCIIBytes
return (budget.hardMaxBytes <= 0 || bytes <= budget.hardMaxBytes) &&
(budget.maxEstimatedTokens <= 0 || tokens <= budget.maxEstimatedTokens)
}
func omittedSectionsMarker(count int) string {
return fmt.Sprintf("[zero] ... %d section(s) omitted ...", count)
}
func renderSelectedUnits(units []diffUnit, selected map[int]bool) string {
indexes := make([]int, 0, len(selected))
for index := range selected {
indexes = append(indexes, index)
}
sort.Ints(indexes)
if len(indexes) == 0 {
return ""
}
parts := make([]string, 0, len(indexes)*2)
previous := -1
for _, index := range indexes {
if previous >= 0 && index != previous+1 {
omitted := index - previous - 1
parts = append(parts, omittedSectionsMarker(omitted))
} else if previous < 0 && index > 0 {
parts = append(parts, omittedSectionsMarker(index))
}
parts = append(parts, units[index].text)
previous = index
}
if previous < len(units)-1 {
parts = append(parts, omittedSectionsMarker(len(units)-previous-1))
}
return strings.Join(parts, "\n")
}
func outputLines(output string) []string {
return strings.Split(strings.TrimRight(output, "\r\n"), "\n")
}
func collapseConsecutiveDuplicateLines(lines []string) []string {
if len(lines) < 2 {
return lines
}
result := make([]string, 0, len(lines))
for index := 0; index < len(lines); {
end := index + 1
for end < len(lines) && lines[end] == lines[index] {
end++
}
result = append(result, lines[index])
if count := end - index; count > 1 {
result = append(result, fmt.Sprintf("[zero] previous line repeated %d more time(s)", count-1))
}
index = end
}
return result
}
func searchResultFile(line string) string {
// Search output is path:line: text. Locate the first numeric field from
// the left after an optional Windows drive prefix. Match text may itself
// contain numeric colon-delimited fields, which are not record structure.
searchFrom := 0
if len(line) >= 2 && line[1] == ':' && ((line[0] >= 'A' && line[0] <= 'Z') || (line[0] >= 'a' && line[0] <= 'z')) {
searchFrom = 2
}
for searchFrom < len(line) {
lineStartOffset := strings.IndexByte(line[searchFrom:], ':')
if lineStartOffset < 0 {
return ""
}
lineStart := searchFrom + lineStartOffset
lineEndOffset := strings.IndexByte(line[lineStart+1:], ':')
if lineEndOffset < 0 {
return ""
}
lineEnd := lineStart + 1 + lineEndOffset
if _, err := strconv.Atoi(line[lineStart+1 : lineEnd]); err == nil {
return strings.TrimSpace(line[:lineStart])
}
searchFrom = lineStart + 1
}
return ""
}
func isTestFailureLine(line string) bool {
lower := strings.ToLower(line)
return containsAny(lower, "--- fail:", "failed", "failure", "panic", "assert", "error", "fatal", "expected", "actual:")
}
func containsAny(value string, needles ...string) bool {
for _, needle := range needles {
if strings.Contains(value, needle) {
return true
}
}
return false
}
func sequence(length int) []int {
result := make([]int, length)
for index := range result {
result[index] = index
}
return result
}
-144
View File
@@ -1,144 +0,0 @@
package tools
import (
"fmt"
"strings"
"testing"
"unicode/utf8"
)
func semanticTestBudget() outputBudget {
return outputBudget{maxEstimatedTokens: 140, hardMaxBytes: 900}
}
func TestFileOutputPolicyKeepsCompleteHeadAndTailLines(t *testing.T) {
lines := []string{"File: huge.go (500 lines)", "1 | package main"}
for index := 2; index < 500; index++ {
lines = append(lines, fmt.Sprintf("%d | value := %d", index, index))
}
lines = append(lines, "500 | // END_MARK")
got := budgetSemanticOutput(strings.Join(lines, "\n"), outputCategoryFile, semanticTestBudget())
if !got.truncated || !strings.Contains(got.text, "File: huge.go") || !strings.Contains(got.text, "END_MARK") {
t.Fatalf("file policy lost boundaries: %#v", got)
}
if !utf8.ValidString(got.text) || strings.Contains(got.text, "value := 49\ufffd") {
t.Fatalf("file policy produced invalid or split text: %q", got.text)
}
}
func TestSearchOutputPolicyPreservesMultiFileCoverage(t *testing.T) {
var lines []string
for _, file := range []string{"a.go", "b.go", "c.go", "d.go"} {
for line := 1; line <= 30; line++ {
lines = append(lines, fmt.Sprintf("%s:%d: match value %d", file, line, line))
}
}
lines = append(lines, "120 matches found")
got := budgetSemanticOutput(strings.Join(lines, "\n"), outputCategorySearch, semanticTestBudget())
for _, want := range []string{"a.go:", "b.go:", "c.go:", "d.go:", "120 matches"} {
if !strings.Contains(got.text, want) {
t.Fatalf("search policy missing %q:\n%s", want, got.text)
}
}
}
func TestSearchOutputPolicyPreservesWindowsPathCoverage(t *testing.T) {
var lines []string
for _, file := range []string{"a.go", "b.go", "c.go", "d.go"} {
for line := 1; line <= 30; line++ {
lines = append(lines, fmt.Sprintf(`C:\workspace\%s:%d: match value %d`, file, line, line))
}
}
lines = append(lines, "120 matches found")
got := budgetSemanticOutput(strings.Join(lines, "\n"), outputCategorySearch, semanticTestBudget())
for _, want := range []string{`C:\workspace\a.go:`, `C:\workspace\b.go:`, `C:\workspace\c.go:`, `C:\workspace\d.go:`, "120 matches"} {
if !strings.Contains(got.text, want) {
t.Fatalf("search policy missing %q:\n%s", want, got.text)
}
}
}
func TestSearchResultFileIgnoresNumericMatchContent(t *testing.T) {
tests := []struct {
line string
want string
}{
{line: `C:\workspace\a.go:12: error code:123: failed`, want: `C:\workspace\a.go`},
{line: `/workspace/a.go:12: error code:123: failed`, want: `/workspace/a.go`},
{line: `/workspace/name:with-colon.go:12: match`, want: `/workspace/name:with-colon.go`},
}
for _, test := range tests {
if got := searchResultFile(test.line); got != test.want {
t.Errorf("searchResultFile(%q) = %q, want %q", test.line, got, test.want)
}
}
}
func TestProcessOutputPolicyCollapsesRepetitiveLogsAndKeepsDiagnostics(t *testing.T) {
input := "starting server\n" + strings.Repeat("polling...\n", 300) + "WARNING: queue slow\nERROR: request failed\nshutdown complete"
got := budgetSemanticOutput(input, outputCategoryProcess, semanticTestBudget())
for _, want := range []string{"starting server", "repeated", "WARNING", "ERROR", "shutdown complete"} {
if !strings.Contains(got.text, want) {
t.Fatalf("process policy missing %q:\n%s", want, got.text)
}
}
if strings.Count(got.text, "polling...") > 1 {
t.Fatalf("repetitive line was not collapsed:\n%s", got.text)
}
}
func TestTestOutputPolicyKeepsFailureAndFinalSummary(t *testing.T) {
input := "=== RUN TestSuite\n" + strings.Repeat("ok progress\n", 300) +
"--- FAIL: TestImportant (0.01s)\n thing_test.go:42: expected 7, got 9\nFAIL\nexit status 1"
got := budgetSemanticOutput(input, outputCategoryTest, semanticTestBudget())
for _, want := range []string{"TestImportant", "expected 7", "FAIL", "exit status 1"} {
if !strings.Contains(got.text, want) {
t.Fatalf("test policy missing %q:\n%s", want, got.text)
}
}
}
func TestDiffOutputPolicyKeepsFilesAndCompleteHunks(t *testing.T) {
var diff strings.Builder
for file := 1; file <= 4; file++ {
fmt.Fprintf(&diff, "diff --git a/f%d.go b/f%d.go\n--- a/f%d.go\n+++ b/f%d.go\n", file, file, file, file)
for hunk := 1; hunk <= 8; hunk++ {
fmt.Fprintf(&diff, "@@ -%d,2 +%d,2 @@\n-old%d_%d\n+new%d_%d\n", hunk, hunk, file, hunk, file, hunk)
}
}
got := budgetSemanticOutput(diff.String(), outputCategoryDiff, outputBudget{maxEstimatedTokens: 220, hardMaxBytes: 1400})
for file := 1; file <= 4; file++ {
if !strings.Contains(got.text, fmt.Sprintf("diff --git a/f%d.go", file)) {
t.Fatalf("diff policy lost file %d header:\n%s", file, got.text)
}
}
for _, line := range strings.Split(got.text, "\n") {
if strings.HasPrefix(line, "@@ ") {
// Each retained hunk is an indivisible three-line unit.
if !strings.Contains(got.text, line+"\n-old") {
t.Fatalf("hunk header was sliced from its body: %q", line)
}
}
}
}
func TestWorkerOutputPolicyKeepsStatusErrorsAndConclusion(t *testing.T) {
input := "session_id: child_1\nstatus: error\n" + strings.Repeat("worker progress\n", 200) +
"tools executed: grep, read_file\nchanged files: a.go, b.go\nerror: verification failed\nConclusion: fix the nil check before merging."
got := budgetSemanticOutput(input, outputCategoryWorker, semanticTestBudget())
for _, want := range []string{"session_id", "status: error", "tools executed", "changed files", "verification failed", "Conclusion"} {
if !strings.Contains(got.text, want) {
t.Fatalf("worker policy missing %q:\n%s", want, got.text)
}
}
}
func TestSemanticPoliciesAreDeterministic(t *testing.T) {
input := "start\n" + strings.Repeat("tick\n", 500) + "ERROR: boom\nend"
want := budgetSemanticOutput(input, outputCategoryProcess, semanticTestBudget())
for iteration := 0; iteration < 20; iteration++ {
if got := budgetSemanticOutput(input, outputCategoryProcess, semanticTestBudget()); got != want {
t.Fatalf("iteration %d differs:\n got %#v\nwant %#v", iteration, got, want)
}
}
}
+7 -35
View File
@@ -18,8 +18,6 @@ type readFileTool struct {
scope PathScope
}
func (readFileTool) outputCategory(map[string]any) outputCategory { return outputCategoryFile }
func NewReadFileTool(workspaceRoot string) Tool {
return NewScopedReadFileTool(workspaceRoot, nil)
}
@@ -52,14 +50,10 @@ func NewScopedReadFileTool(workspaceRoot string, scope PathScope) Tool {
}
func (tool readFileTool) Run(ctx context.Context, args map[string]any) Result {
return tool.run(args, RunOptions{}, true)
return tool.RunWithOptions(ctx, args, RunOptions{})
}
func (tool readFileTool) RunWithOptions(_ context.Context, args map[string]any, options RunOptions) Result {
return tool.run(args, options, false)
}
func (tool readFileTool) run(args map[string]any, options RunOptions, directBudget bool) Result {
requestedPath, err := aliasedStringArg(args, []string{"path", "file", "file_path", "filepath", "filename"}, "", true, false)
if err != nil {
return errorResult("Error: Invalid arguments for read_file: " + err.Error())
@@ -92,14 +86,10 @@ func (tool readFileTool) run(args map[string]any, options RunOptions, directBudg
// not the authoritative content hash.
options.FileTracker.RecordHash(absolutePath, stats.hash, stats.info)
maxBytes := 0
if directBudget {
maxBytes = readOutputBudgetBytes
}
return renderReadFileRange(absolutePath, relativePath, stats.lines, startLine, endLine, maxLines, maxBytes)
return renderReadFileRange(absolutePath, relativePath, stats.lines, startLine, endLine, maxLines)
}
func renderReadFileRange(absolutePath string, relativePath string, total int, startLine int, endLine int, maxLines int, maxBytes int) Result {
func renderReadFileRange(absolutePath string, relativePath string, total int, startLine int, endLine int, maxLines int) Result {
if startLine > total {
return okResult(fmt.Sprintf("File: %s\n(start_line %d is past the end of the file, which has %d lines)", relativePath, startLine, total))
}
@@ -124,16 +114,7 @@ func renderReadFileRange(absolutePath string, relativePath string, total int, st
header = fmt.Sprintf("File: %s (lines %d-%d of %d)", relativePath, startLine, lastLine, total)
}
// The shared registry boundary applies the file policy after redaction. Its
// input still needs a bounded capture: retain the requested range's head and
// tail instead of materializing an arbitrarily large file in memory. Direct
// Tool.Run calls preserve the legacy prefix-only byte budget.
var budgetedOutput *outputBudgetBuilder
if maxBytes > 0 {
budgetedOutput = newOutputBudgetBuilder(maxBytes, "use start_line/end_line or max_lines to continue with a smaller range")
} else {
budgetedOutput = newHeadTailOutputBudgetBuilder(readOutputBudgetBytes, "use start_line/end_line or max_lines to continue with a smaller range")
}
budgetedOutput := newOutputBudgetBuilder(readOutputBudgetBytes, "use start_line/end_line or max_lines to continue with a smaller range")
budgetedOutput.WriteString(header)
budgetedOutput.WriteString("\n\n")
if err := appendReadFileRange(budgetedOutput, absolutePath, startLine, selectedLines, width); err != nil {
@@ -147,19 +128,10 @@ func renderReadFileRange(absolutePath string, relativePath string, total int, st
}
budgeted := budgetedOutput.Result()
meta := map[string]string{}
if maxBytes > 0 || budgeted.Truncated {
for key, value := range outputBudgetMeta(budgeted) {
meta[key] = value
}
}
if truncated || budgeted.Truncated {
meta := outputBudgetMeta(budgeted)
if budgeted.Truncated {
meta["truncated"] = "true"
if budgeted.Truncated {
meta["truncation_reason"] = "byte_budget"
} else {
meta["truncation_reason"] = "max_lines"
}
meta["truncation_reason"] = "byte_budget"
}
return Result{
Status: StatusOK,
+9 -15
View File
@@ -16,10 +16,6 @@ type readMinifiedFileTool struct {
scope PathScope
}
func (readMinifiedFileTool) outputCategory(map[string]any) outputCategory {
return outputCategoryFile
}
func NewReadMinifiedFileTool(workspaceRoot string) Tool {
return NewScopedReadMinifiedFileTool(workspaceRoot, nil)
}
@@ -46,14 +42,10 @@ func NewScopedReadMinifiedFileTool(workspaceRoot string, scope PathScope) Tool {
}
func (tool readMinifiedFileTool) Run(ctx context.Context, args map[string]any) Result {
return tool.run(args, RunOptions{}, true)
return tool.RunWithOptions(ctx, args, RunOptions{})
}
func (tool readMinifiedFileTool) RunWithOptions(_ context.Context, args map[string]any, options RunOptions) Result {
return tool.run(args, options, false)
}
func (tool readMinifiedFileTool) run(args map[string]any, options RunOptions, directBudget bool) Result {
requestedPath, err := aliasedStringArg(args, []string{"path", "file", "file_path", "filepath", "filename"}, "", true, false)
if err != nil {
return errorResult("Error: Invalid arguments for read_minified_file: " + err.Error())
@@ -100,21 +92,23 @@ func (tool readMinifiedFileTool) run(args map[string]any, options RunOptions, di
savedTokens = estimatedTokensFromBytes(savedBytes)
}
output := header + "\n\n" + result.Content
meta := map[string]string{}
budgeted := applyOutputBudget(output, readOutputBudgetBytes, "use read_file with start_line/end_line or max_lines for a smaller exact range")
meta := outputBudgetMeta(budgeted)
meta["path"] = relativePath
meta["mode"] = result.Language
meta["compacted"] = strconv.FormatBool(result.Applied)
meta["raw_bytes"] = strconv.Itoa(rawBytes)
meta["compact_bytes"] = strconv.Itoa(compactBytes)
meta["emitted_bytes"] = strconv.Itoa(len(output))
meta["emitted_bytes"] = strconv.Itoa(budgeted.EmittedBytes)
meta["raw_lines"] = strconv.Itoa(rawLines)
meta["emitted_lines"] = strconv.Itoa(minLines)
meta["estimated_tokens_saved"] = strconv.Itoa(savedTokens)
toolResult := Result{Status: StatusOK, Output: output, Meta: meta}
if directBudget {
return applyLegacyByteBudgetToResult(toolResult, readOutputBudgetBytes, "use read_file with start_line/end_line or max_lines for a smaller exact range")
if budgeted.Truncated {
meta["truncated"] = "true"
meta["truncation_reason"] = "byte_budget"
}
return toolResult
return Result{Status: StatusOK, Output: budgeted.Output, Truncated: budgeted.Truncated, Meta: meta}
}
// lineCount reports the number of newline-separated lines in s (an empty string
+4 -9
View File
@@ -126,25 +126,20 @@ func (registry *Registry) RunWithOptions(ctx context.Context, name string, args
// args/paths) are redacted at the boundary just like tool output. The output
// ceiling runs after the scrub so the transcript and the spill file agree on
// what was hidden.
selfManagedOutput := false
var tool Tool
var ok bool
ceilingExempt := false
defer func() {
result = scrubResultSecrets(result)
if selfManagedOutput {
result = applySelfManagedOutputBudget(tool, name, args, result)
} else {
result = applyRegistryOutputBudget(tool, name, args, result)
if !ceilingExempt {
result = enforceOutputCeiling(name, result)
}
}()
tool, ok = registry.Get(name)
tool, ok := registry.Get(name)
if !ok {
return errorResult(`Error: Unknown tool "` + name + `".`)
}
if _, ok := tool.(selfBudgeting); ok {
selfManagedOutput = true
ceilingExempt = true
}
if rejecter, ok := tool.(PrePermissionRejecter); ok {
if res, rejected := rejecter.RejectBeforePermission(args); rejected {
-28
View File
@@ -117,25 +117,6 @@ func WriteNDJSON(w io.Writer, t *TurnTrace) error {
return err
}
}
// Output budget events stay in tool-result emission order. Sorting them by
// tool/category would destroy correlation with concurrent calls whose results
// are deliberately consumed in original call order.
for _, event := range t.OutputBudgets {
if err := enc.Encode(map[string]any{
"type": "output_budget",
"tool": event.Tool,
"category": event.Category,
"original_bytes": event.OriginalBytes,
"retained_bytes": event.RetainedBytes,
"estimated_original_tokens": event.EstimatedOriginalTokens,
"estimated_retained_tokens": event.EstimatedRetainedTokens,
"truncated": event.Truncated,
"reason": event.Reason,
"spill_created": event.SpillCreated,
}); err != nil {
return err
}
}
return nil
}
@@ -196,15 +177,6 @@ func WriteText(w io.Writer, t *TurnTrace) error {
for _, c := range counters {
write(" %-22s %d\n", c.Name, c.Value)
}
if len(t.OutputBudgets) > 0 {
write("output budgets:\n")
for _, event := range t.OutputBudgets {
write(" tool=%s category=%s bytes=%d/%d tokens=%d/%d truncated=%t reason=%s spill=%t\n",
event.Tool, event.Category, event.RetainedBytes, event.OriginalBytes,
event.EstimatedRetainedTokens, event.EstimatedOriginalTokens,
event.Truncated, event.Reason, event.SpillCreated)
}
}
return firstErr
}
-100
View File
@@ -1,100 +0,0 @@
package trace
import (
"bytes"
"encoding/json"
"io"
"strings"
"testing"
)
func TestOutputBudgetTraceRoundTripContainsNoOutput(t *testing.T) {
recorder := NewRecorder("session", "run", "")
recorder.Start()
event := OutputBudgetEvent{
Tool: "grep",
Category: "search",
OriginalBytes: 10000,
RetainedBytes: 1000,
EstimatedOriginalTokens: 2500,
EstimatedRetainedTokens: 250,
Truncated: true,
Reason: "semantic_search_budget",
SpillCreated: true,
}
recorder.EmitOutputBudget(event)
var encoded bytes.Buffer
if err := WriteNDJSON(&encoded, recorder.Finish()); err != nil {
t.Fatalf("WriteNDJSON: %v", err)
}
const secretOutput = "secret output body"
if strings.Contains(encoded.String(), secretOutput) {
t.Fatal("output_budget trace contains raw secret output")
}
allowedKeys := map[string]bool{
"type": true, "tool": true, "category": true, "original_bytes": true, "retained_bytes": true,
"estimated_original_tokens": true, "estimated_retained_tokens": true, "truncated": true,
"reason": true, "spill_created": true,
}
findUndocumentedKey := func(record map[string]any) string {
for key := range record {
if !allowedKeys[key] {
return key
}
}
return ""
}
decoder := json.NewDecoder(strings.NewReader(encoded.String()))
var outputBudgetRecord map[string]any
for {
var record map[string]any
if err := decoder.Decode(&record); err != nil {
if err == io.EOF {
break
}
t.Fatalf("decode trace record: %v", err)
}
if record["type"] == "output_budget" {
outputBudgetRecord = record
break
}
}
if outputBudgetRecord == nil {
t.Fatal("missing output_budget trace record")
}
if key := findUndocumentedKey(outputBudgetRecord); key != "" {
t.Fatalf("output_budget trace contains undocumented key %q", key)
}
for key := range allowedKeys {
if _, exists := outputBudgetRecord[key]; !exists {
t.Fatalf("output_budget trace missing documented key %q", key)
}
}
recordJSON, err := json.Marshal(outputBudgetRecord)
if err != nil {
t.Fatalf("encode output_budget trace record: %v", err)
}
var serializedEvent OutputBudgetEvent
if err := json.Unmarshal(recordJSON, &serializedEvent); err != nil {
t.Fatalf("decode output_budget trace event: %v", err)
}
if serializedEvent != event {
t.Fatalf("serialized output_budget event = %#v, want %#v", serializedEvent, event)
}
contaminated := make(map[string]any, len(outputBudgetRecord)+1)
for key, value := range outputBudgetRecord {
contaminated[key] = value
}
contaminated["output"] = secretOutput
if key := findUndocumentedKey(contaminated); key == "" {
t.Fatal("trace key validation would allow raw secret output")
}
parsed, err := ReadNDJSON(strings.NewReader(encoded.String()))
if err != nil {
t.Fatalf("ReadNDJSON: %v", err)
}
if len(parsed.OutputBudgets) != 1 || parsed.OutputBudgets[0] != event {
t.Fatalf("unexpected round trip: %#v", parsed.OutputBudgets)
}
}
+1 -21
View File
@@ -116,21 +116,6 @@ func ReadNDJSON(r io.Reader) (*TurnTrace, error) {
SchemaHash: stringField(obj, "schema"),
CompletePrefixHash: stringField(obj, "complete_prefix"),
})
case "output_budget":
if !sawTraceHeader {
return nil, errors.New("parse trace: not a valid NDJSON trace (no type:trace header)")
}
t.OutputBudgets = append(t.OutputBudgets, OutputBudgetEvent{
Tool: stringField(obj, "tool"),
Category: stringField(obj, "category"),
OriginalBytes: int(parseInt64(obj["original_bytes"])),
RetainedBytes: int(parseInt64(obj["retained_bytes"])),
EstimatedOriginalTokens: int(parseInt64(obj["estimated_original_tokens"])),
EstimatedRetainedTokens: int(parseInt64(obj["estimated_retained_tokens"])),
Truncated: boolField(obj, "truncated"),
Reason: stringField(obj, "reason"),
SpillCreated: boolField(obj, "spill_created"),
})
default:
// Unknown event type: tolerate (forward-compat) but only after a
// header has been seen.
@@ -151,17 +136,12 @@ func ReadNDJSON(r io.Reader) (*TurnTrace, error) {
if !sawTraceHeader {
return nil, errors.New("parse trace: non-empty input had no type:trace header")
}
if len(t.Spans) == 0 && len(t.Counters) == 0 && len(t.PrefixHashes) == 0 && len(t.OutputBudgets) == 0 {
if len(t.Spans) == 0 && len(t.Counters) == 0 && len(t.PrefixHashes) == 0 {
return nil, errors.New("parse trace: header present but no spans, counters, or prefix hashes recovered (corrupt or truncated)")
}
return t, nil
}
func boolField(obj map[string]any, key string) bool {
value, _ := obj[key].(bool)
return value
}
// stringField returns obj[key] as a string, or "" if the key is missing or
// the value is not a string. JSON-marshaled trace events always emit
// string fields as JSON strings, so the type assertion is the right
-16
View File
@@ -188,21 +188,6 @@ func (r *Recorder) EmitPrefixHash(p PrefixHash) {
r.tr.PrefixHashes = append(r.tr.PrefixHashes, p)
}
// EmitOutputBudget records one content-free output budgeting decision. Calls
// are retained in transcript emission order, including when the underlying
// tools executed concurrently.
func (r *Recorder) EmitOutputBudget(event OutputBudgetEvent) {
if r == nil {
return
}
r.mu.Lock()
defer r.mu.Unlock()
if r.finished {
return
}
r.tr.OutputBudgets = append(r.tr.OutputBudgets, event)
}
// Finish stamps CompletedAt, derives each span's parent (by interval
// containment) and exclusive time, and returns a snapshot of the trace. Calling
// Finish more than once returns the same snapshot.
@@ -222,7 +207,6 @@ func (r *Recorder) Finish() *TurnTrace {
snap.Spans = append([]Span(nil), r.tr.Spans...)
snap.Counters = append([]Counter(nil), r.tr.Counters...)
snap.PrefixHashes = append([]PrefixHash(nil), r.tr.PrefixHashes...)
snap.OutputBudgets = append([]OutputBudgetEvent(nil), r.tr.OutputBudgets...)
return &snap
}
+11 -35
View File
@@ -32,7 +32,6 @@ const (
SpanToolPartition = "tool_partition" // partitioning the tool set for the prompt
SpanProviderConnect = "provider_connect" // client.Do in the provider seam
SpanProviderQueue = "provider_queue" // pre-send OAuth/token resolve
SpanProviderPrewarm = "provider_prewarm" // best-effort turn-session prewarm probe
SpanGeneration = "generation" // streaming a model completion
SpanToolExecution = "tool_execution" // executing tool calls
SpanPermissionWait = "permission_wait" // waiting on a permission prompt
@@ -54,9 +53,6 @@ const (
CounterInputTokens = "input_tokens"
CounterCachedInputTokens = "cached_input_tokens"
CounterOutputTokens = "output_tokens"
CounterPrewarmAttempts = "prewarm_attempts"
CounterPrefixStable = "prefix_stable"
CounterPrefixDrift = "prefix_drift"
)
// Span is one named wall interval attributed to part of a run. Each stamp is
@@ -83,36 +79,20 @@ type Counter struct {
Value int64 `json:"value"`
}
// OutputBudgetEvent is compact, content-free metadata for one tool result's
// output budgeting decision. It deliberately carries no output text, paths, or
// arguments so tracing cannot become a secret-bearing side channel.
type OutputBudgetEvent struct {
Tool string `json:"tool"`
Category string `json:"category"`
OriginalBytes int `json:"original_bytes"`
RetainedBytes int `json:"retained_bytes"`
EstimatedOriginalTokens int `json:"estimated_original_tokens"`
EstimatedRetainedTokens int `json:"estimated_retained_tokens"`
Truncated bool `json:"truncated"`
Reason string `json:"reason,omitempty"`
SpillCreated bool `json:"spill_created"`
}
// TurnTrace is the finished record for one agent.Run. It is the value
// emitters serialize; it is not mutated after Finish returns a snapshot.
type TurnTrace struct {
SessionID string `json:"session_id"`
RunID string `json:"run_id"`
Profile string `json:"profile,omitempty"`
StartedAt time.Time `json:"started_at"`
FirstVisibleEventAt time.Time `json:"first_visible_event_at,omitempty"`
FirstUsefulActionAt time.Time `json:"first_useful_action_at,omitempty"`
FirstTokenAt time.Time `json:"first_token_at,omitempty"`
CompletedAt time.Time `json:"completed_at"`
Spans []Span `json:"spans"`
Counters []Counter `json:"counters"`
PrefixHashes []PrefixHash `json:"prefix_hashes,omitempty"`
OutputBudgets []OutputBudgetEvent `json:"output_budgets,omitempty"`
SessionID string `json:"session_id"`
RunID string `json:"run_id"`
Profile string `json:"profile,omitempty"`
StartedAt time.Time `json:"started_at"`
FirstVisibleEventAt time.Time `json:"first_visible_event_at,omitempty"`
FirstUsefulActionAt time.Time `json:"first_useful_action_at,omitempty"`
FirstTokenAt time.Time `json:"first_token_at,omitempty"`
CompletedAt time.Time `json:"completed_at"`
Spans []Span `json:"spans"`
Counters []Counter `json:"counters"`
PrefixHashes []PrefixHash `json:"prefix_hashes,omitempty"`
}
// PrefixHash is one fingerprint of the prompt prefix emitted by an agent run.
@@ -285,10 +265,6 @@ func OptionalEventKeys() []string {
"counter:" + CounterAcceptanceChecks,
"counter:" + CounterPollingTurn,
"counter:" + CounterModelSwitches,
"span:" + SpanProviderPrewarm,
"counter:" + CounterPrewarmAttempts,
"counter:" + CounterPrefixStable,
"counter:" + CounterPrefixDrift,
"event:prefix_hash",
}
}
-125
View File
@@ -1,125 +0,0 @@
package zeroruntime
import (
"context"
"errors"
)
// ProviderCapabilities is a flat, provider-agnostic projection of a resolved
// model's static capabilities. It is deliberately plain data — no modelregistry
// or reasoning types — because modelregistry imports zeroruntime, so a typed
// reference here would form an import cycle. The providers factory populates it
// from the model-registry entry it already resolves; a zero value means the
// capabilities are unknown, which every consumer must treat as "assume nothing".
type ProviderCapabilities struct {
// Model is the resolved API model id (informational; may be empty).
Model string
// ContextWindow is the model's max input context in tokens; 0 = unknown.
ContextWindow int
// MaxOutputTokens is the model's max output tokens per turn; 0 = unknown.
MaxOutputTokens int
// SupportsVision mirrors the registry's vision capability.
SupportsVision bool
// SupportsReasoning mirrors the registry's reasoning capability.
SupportsReasoning bool
// SupportsPromptCache mirrors the registry's prompt-cache capability.
SupportsPromptCache bool
// ReasoningEfforts lists the accepted effort tiers, weakest to strongest.
// nil/empty means the model exposes no effort control.
ReasoningEfforts []string
// NativeCompaction reports server-side compaction support. Always false for
// the default adapter; a future native-compaction session sets it, and the
// loop branches on it to call TurnSession.Compact instead of the local
// summarizer.
NativeCompaction bool
}
// ErrCompactionUnsupported is returned by TurnSession.Compact when the provider
// has no native server-side compaction (every default adapter). Callers treat
// it as "use the local summarizer path", not as a run failure.
var ErrCompactionUnsupported = errors.New("zeroruntime: native compaction unsupported")
// TurnSession is one provider conversation for the lifetime of a single agent
// run: many turns, compaction summaries, and any mid-run model swaps each open
// a fresh session. It owns whatever per-run provider state an optimized
// implementation keeps warm (a pooled connection, a cached-prefix handle). The
// default adapter holds nothing and every method degrades to today's one-shot
// request path.
type TurnSession interface {
// Prewarm optionally primes provider-side state before the first Stream.
// It must be safe to skip entirely: the default adapter no-ops and returns
// nil. A non-nil error is advisory — callers proceed without prewarming
// (best-effort, never fatal).
Prewarm(ctx context.Context) error
// Stream issues one completion request. The signature is identical to
// Provider.StreamCompletion so the default adapter forwards verbatim and
// callers' stream handling stays byte-for-byte unchanged.
Stream(ctx context.Context, request CompletionRequest) (<-chan StreamEvent, error)
// Compact asks the provider to compact the conversation server-side and
// returns the replacement messages. The default adapter returns
// ErrCompactionUnsupported; callers then keep their local summarizer path.
Compact(ctx context.Context, request CompletionRequest) ([]Message, error)
// Close releases per-run provider state. It must be idempotent and safe to
// call after a mid-run swap as well as at run teardown. Default: no-op.
Close() error
}
// TurnSessionProvider opens a TurnSession for one run and exposes the resolved
// model's static capabilities. The providers factory builds the default
// implementation by wrapping an existing Provider.
type TurnSessionProvider interface {
// OpenTurnSession starts a session for one run. Everything a session needs
// per request (messages, tools, reasoning effort, prompt-cache key) already
// arrives on Stream, so nothing beyond ctx is passed here — keeping the
// seam narrow. The default adapter never errors; a real implementation may
// fail a handshake, which callers surface as a clean run-start error.
OpenTurnSession(ctx context.Context) (TurnSession, error)
// Capabilities returns the resolved model's static capability projection.
Capabilities() ProviderCapabilities
}
// NewProviderTurnSessionProvider wraps an existing Provider as a default
// TurnSessionProvider. caps may be the zero value (unknown) for callers that
// only need streaming behavior; the providers factory supplies a populated
// projection.
func NewProviderTurnSessionProvider(provider Provider, caps ProviderCapabilities) TurnSessionProvider {
return providerTurnSessionProvider{provider: provider, caps: caps}
}
// providerTurnSessionProvider is the default TurnSessionProvider: it wraps a
// Provider so every current provider plugs into the session seam unchanged.
type providerTurnSessionProvider struct {
provider Provider
caps ProviderCapabilities
}
func (d providerTurnSessionProvider) OpenTurnSession(context.Context) (TurnSession, error) {
return providerTurnSession{provider: d.provider}, nil
}
func (d providerTurnSessionProvider) Capabilities() ProviderCapabilities {
return d.caps
}
// providerTurnSession forwards Stream to the wrapped Provider; Prewarm and
// Close are no-ops and Compact is unsupported. The value receiver keeps it
// stateless, so double-Close and post-Close Stream are inherently safe.
type providerTurnSession struct {
provider Provider
}
func (s providerTurnSession) Prewarm(context.Context) error { return nil }
func (s providerTurnSession) Stream(ctx context.Context, request CompletionRequest) (<-chan StreamEvent, error) {
return s.provider.StreamCompletion(ctx, request)
}
func (s providerTurnSession) Compact(context.Context, CompletionRequest) ([]Message, error) {
return nil, ErrCompactionUnsupported
}
func (s providerTurnSession) Close() error { return nil }
-126
View File
@@ -1,126 +0,0 @@
package zeroruntime
import (
"context"
"errors"
"testing"
)
// recordingProvider captures every request and replays a scripted event stream,
// so tests can assert the default session forwards verbatim.
type recordingProvider struct {
requests []CompletionRequest
events []StreamEvent
err error
}
func (p *recordingProvider) StreamCompletion(_ context.Context, request CompletionRequest) (<-chan StreamEvent, error) {
p.requests = append(p.requests, request)
if p.err != nil {
return nil, p.err
}
ch := make(chan StreamEvent, len(p.events))
for _, event := range p.events {
ch <- event
}
close(ch)
return ch, nil
}
func TestDefaultTurnSessionStreamForwardsVerbatim(t *testing.T) {
provider := &recordingProvider{events: []StreamEvent{
{Type: StreamEventText, Content: "hello"},
{Type: StreamEventDone, FinishReason: "stop"},
}}
session, err := NewProviderTurnSessionProvider(provider, ProviderCapabilities{}).OpenTurnSession(context.Background())
if err != nil {
t.Fatalf("OpenTurnSession: %v", err)
}
request := CompletionRequest{
Messages: []Message{{Role: MessageRoleUser, Content: "hi"}},
ReasoningEffort: "high",
PromptCacheKey: "session-123",
}
stream, err := session.Stream(context.Background(), request)
if err != nil {
t.Fatalf("Stream: %v", err)
}
var got []StreamEvent
for event := range stream {
got = append(got, event)
}
if len(got) != 2 || got[0].Content != "hello" || got[1].FinishReason != "stop" {
t.Fatalf("stream events not forwarded verbatim: %#v", got)
}
if len(provider.requests) != 1 {
t.Fatalf("provider saw %d requests, want 1", len(provider.requests))
}
recorded := provider.requests[0]
if recorded.PromptCacheKey != request.PromptCacheKey ||
recorded.ReasoningEffort != request.ReasoningEffort ||
len(recorded.Messages) != 1 || recorded.Messages[0].Content != "hi" {
t.Fatalf("request not forwarded verbatim: %#v", recorded)
}
}
func TestDefaultTurnSessionStreamPropagatesError(t *testing.T) {
wantErr := errors.New("provider unavailable")
provider := &recordingProvider{err: wantErr}
session, err := NewProviderTurnSessionProvider(provider, ProviderCapabilities{}).OpenTurnSession(context.Background())
if err != nil {
t.Fatalf("OpenTurnSession: %v", err)
}
if _, streamErr := session.Stream(context.Background(), CompletionRequest{}); !errors.Is(streamErr, wantErr) {
t.Fatalf("Stream error = %v, want %v", streamErr, wantErr)
}
}
func TestDefaultTurnSessionPrewarmCloseNoop(t *testing.T) {
session, err := NewProviderTurnSessionProvider(&recordingProvider{}, ProviderCapabilities{}).OpenTurnSession(context.Background())
if err != nil {
t.Fatalf("OpenTurnSession: %v", err)
}
if err := session.Prewarm(context.Background()); err != nil {
t.Fatalf("Prewarm: %v", err)
}
if err := session.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
// Idempotent: a second Close (e.g. after a mid-run swap already closed it)
// must also succeed.
if err := session.Close(); err != nil {
t.Fatalf("second Close: %v", err)
}
}
func TestDefaultTurnSessionCompactUnsupported(t *testing.T) {
session, err := NewProviderTurnSessionProvider(&recordingProvider{}, ProviderCapabilities{}).OpenTurnSession(context.Background())
if err != nil {
t.Fatalf("OpenTurnSession: %v", err)
}
if _, compactErr := session.Compact(context.Background(), CompletionRequest{}); !errors.Is(compactErr, ErrCompactionUnsupported) {
t.Fatalf("Compact error = %v, want ErrCompactionUnsupported", compactErr)
}
}
func TestDefaultTurnSessionProviderCapabilities(t *testing.T) {
caps := ProviderCapabilities{
Model: "test-model",
ContextWindow: 200_000,
MaxOutputTokens: 64_000,
SupportsVision: true,
SupportsReasoning: true,
SupportsPromptCache: true,
ReasoningEfforts: []string{"low", "medium", "high"},
}
tsp := NewProviderTurnSessionProvider(&recordingProvider{}, caps)
got := tsp.Capabilities()
if got.Model != caps.Model || got.ContextWindow != caps.ContextWindow ||
got.MaxOutputTokens != caps.MaxOutputTokens || !got.SupportsVision ||
!got.SupportsReasoning || !got.SupportsPromptCache ||
len(got.ReasoningEfforts) != 3 || got.NativeCompaction {
t.Fatalf("Capabilities() = %#v, want %#v", got, caps)
}
}