mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 09:49:34 -10:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ecce5d9374 | ||
|
|
a053a6a8f0 | ||
|
|
ce4a996ffa | ||
|
|
378d538e24 | ||
|
|
015452c1c9 | ||
|
|
af875df587 | ||
|
|
dbd9443014 | ||
|
|
0fae754460 | ||
|
|
2cc6f43bf7 | ||
|
|
77960229b8 | ||
|
|
60dc84e7a3 | ||
|
|
30e2c3f7ff | ||
|
|
18cce358a2 | ||
|
|
e5670c427f | ||
|
|
31d45d5f14 | ||
|
|
727ad4d321 |
No files matched your search
@@ -1,5 +1,11 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Questions and setup help
|
||||
url: https://github.com/Gitlawb/zero/discussions/categories/q-a
|
||||
about: Ask in Discussions Q&A. Include zero --version, your OS, provider/model, and zero doctor output for setup problems.
|
||||
- name: Feature ideas
|
||||
url: https://github.com/Gitlawb/zero/discussions/categories/ideas
|
||||
about: Propose and discuss ideas in Discussions before opening a PR (Zero is in controlled development, see CONTRIBUTING.md).
|
||||
- name: Security vulnerability
|
||||
url: https://github.com/Gitlawb/zero/security/advisories/new
|
||||
about: Report security issues privately via GitHub Security Advisories (see SECURITY.md). Do not file a public issue.
|
||||
@@ -1,12 +1,22 @@
|
||||
name: Feature request
|
||||
description: Propose a new capability or change
|
||||
name: Feature request (previously discussed)
|
||||
description: File only after your idea gained traction in Discussions Ideas or a maintainer asked for a tracking issue
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Please do not begin implementation until the issue has been reviewed and marked with the
|
||||
New feature ideas start in [Discussions Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas),
|
||||
not here. This form is for the step after that: an idea that gained traction in Discussions,
|
||||
or one a maintainer asked to have tracked as an issue. Please link the discussion below.
|
||||
Do not begin implementation until the issue has been reviewed and marked with the
|
||||
`issue-approved` label (see CONTRIBUTING.md).
|
||||
- type: input
|
||||
id: discussion
|
||||
attributes:
|
||||
label: Ideas discussion
|
||||
description: Link to the Discussions Ideas thread (or note which maintainer asked for this issue).
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
|
||||
@@ -55,3 +55,6 @@ docs/superpowers/
|
||||
zero-termux-arm64
|
||||
zero-linux-sandbox
|
||||
zero-seccomp
|
||||
|
||||
# Generated benchmark reports are evidence for one configuration, never tree state
|
||||
internal/perfbench/reports/*.json
|
||||
@@ -1,27 +1,71 @@
|
||||
---
|
||||
description: Project guidelines for the open-source CLI coding agent (zero) repository.
|
||||
globs: "*.go, *.js, *.md, *.json, *.toml, *.yaml, *.yml"
|
||||
alwaysApply: false
|
||||
---
|
||||
# Repository Guidelines for Zero
|
||||
|
||||
# Repository Conventions for Zero
|
||||
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).
|
||||
|
||||
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).
|
||||
## 1. Contribution and Pull Request Rules
|
||||
|
||||
## 1. Project Conventions
|
||||
- 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.
|
||||
|
||||
- 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.
|
||||
## 2. Repository and Implementation Conventions
|
||||
|
||||
## 2. Guidelines for Coding Agents
|
||||
- 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.
|
||||
|
||||
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:
|
||||
## 3. Required Validation
|
||||
|
||||
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] ./...`.
|
||||
Run validation from the repository root before committing, opening a pull
|
||||
request, or completing an implementation task:
|
||||
|
||||
If any check fails or cannot be run, do not ignore it. Prompt the user for instructions or setup assistance before proceeding.
|
||||
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.
|
||||
+26
-5
@@ -8,6 +8,13 @@ stabilizes. Community participation is still welcome, especially through bug
|
||||
reports, feature requests, reproduction details, logs, and discussion on existing
|
||||
issues.
|
||||
|
||||
Questions and setup help belong in
|
||||
[GitHub Discussions](https://github.com/Gitlawb/zero/discussions) (see
|
||||
[SUPPORT.md](SUPPORT.md)), not in the issue tracker. Feature ideas start in the
|
||||
[Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas) category;
|
||||
an idea that gains traction there is the path to an approved issue and,
|
||||
eventually, an accepted PR.
|
||||
|
||||
Please read this policy before opening a pull request. Pull requests that do not
|
||||
follow it may be closed without review.
|
||||
|
||||
@@ -33,7 +40,11 @@ unless the issue has been explicitly approved for outside contribution.
|
||||
There are three primary ways to contribute:
|
||||
|
||||
1. Open a bug report with clear reproduction steps.
|
||||
2. Open a feature request describing the problem, use case, and expected behavior.
|
||||
2. Propose a feature in
|
||||
[Discussions Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas),
|
||||
describing the problem, use case, and expected behavior. A feature-request
|
||||
issue is filed only after the idea gains traction there or a maintainer asks
|
||||
for one.
|
||||
3. Add useful context to an existing issue, such as logs, screenshots, examples,
|
||||
affected versions, or reproduction cases.
|
||||
|
||||
@@ -46,7 +57,8 @@ All community pull requests require an approved parent issue.
|
||||
|
||||
Before starting implementation work:
|
||||
|
||||
1. Open an issue using the appropriate issue template.
|
||||
1. For a bug, open an issue using the bug-report template. For a feature, start
|
||||
in Discussions Ideas; the feature-request issue comes after the discussion.
|
||||
2. Describe the bug, feature request, or functional change clearly.
|
||||
3. Wait for the core team to review the issue.
|
||||
4. Do not open a pull request unless the issue has the `issue-approved` label.
|
||||
@@ -133,7 +145,12 @@ Clear bug reports are the most useful way to help the project during this phase.
|
||||
|
||||
## Submitting a Feature Request
|
||||
|
||||
When filing a feature request, please include:
|
||||
Feature requests start as a thread in
|
||||
[Discussions Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas),
|
||||
not as an issue. The feature-request issue form exists for the step AFTER that:
|
||||
when an idea has gained traction in Discussions or a maintainer has asked for an
|
||||
issue to track it. Whether in the discussion or the eventual issue, please
|
||||
include:
|
||||
|
||||
- The problem you are trying to solve.
|
||||
- The behavior or functionality you are requesting.
|
||||
@@ -156,5 +173,9 @@ contribution phase.
|
||||
|
||||
## Getting Help
|
||||
|
||||
If you are unsure whether a change is appropriate, open an issue first and ask.
|
||||
Please keep discussion focused, specific, and respectful of maintainer time.
|
||||
General questions, setup help, and "is this a bug or my config" uncertainty all
|
||||
go to
|
||||
[Discussions Q&A](https://github.com/Gitlawb/zero/discussions/categories/q-a).
|
||||
If you are unsure whether a proposed code change is appropriate, raise it in
|
||||
Discussions Ideas or on the relevant issue first and ask. Please keep discussion
|
||||
focused, specific, and respectful of maintainer time.
|
||||
@@ -358,6 +358,12 @@ 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:
|
||||
@@ -396,6 +402,24 @@ go run ./cmd/zero-release build --goos windows --goarch amd64 --output dist/zero
|
||||
- [Performance](docs/PERFORMANCE.md)
|
||||
- [Agent evals](docs/AGENT_EVALS.md)
|
||||
|
||||
## Community
|
||||
|
||||
Questions, setup help, ideas, and sharing all live in
|
||||
[GitHub Discussions](https://github.com/Gitlawb/zero/discussions):
|
||||
|
||||
| Category | Use it for |
|
||||
|---|---|
|
||||
| [Q&A](https://github.com/Gitlawb/zero/discussions/categories/q-a) | Setup help, provider/model configuration, "how do I" questions |
|
||||
| [Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas) | Feature proposals and design discussion before any PR |
|
||||
| [Show and tell](https://github.com/Gitlawb/zero/discussions/categories/show-and-tell) | Your skills, plugins, MCP setups, themes, and workflows |
|
||||
| [Announcements](https://github.com/Gitlawb/zero/discussions/categories/announcements) | Releases and project news from the maintainers |
|
||||
|
||||
For a good Q&A answer fast, include `zero --version`, your OS and install
|
||||
method, the provider/model in use, and `zero doctor` output. See
|
||||
[SUPPORT.md](SUPPORT.md). Bugs belong in
|
||||
[issues](https://github.com/Gitlawb/zero/issues/new/choose); security reports
|
||||
follow [SECURITY.md](SECURITY.md), never a public thread.
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome. Read [CONTRIBUTING.md](CONTRIBUTING.md), run the
|
||||
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
# Getting help with Zero
|
||||
|
||||
## Questions, setup help, configuration
|
||||
|
||||
Ask in [GitHub Discussions](https://github.com/Gitlawb/zero/discussions). Use
|
||||
[Q&A](https://github.com/Gitlawb/zero/discussions/categories/q-a) so answers can
|
||||
be marked and found by the next person.
|
||||
|
||||
The fastest way to a useful answer is to include:
|
||||
|
||||
- `zero --version` output
|
||||
- your OS (Windows, macOS, Linux) and how you installed Zero (npm, install
|
||||
script, source)
|
||||
- the provider and model you are using (`zero providers list`)
|
||||
- `zero doctor` output for setup problems
|
||||
- the exact command you ran and the exact error text
|
||||
|
||||
## Bugs
|
||||
|
||||
If Zero does something wrong (crashes, wrong output, broken behavior), file a
|
||||
[bug report](https://github.com/Gitlawb/zero/issues/new/choose). If you are not
|
||||
sure whether it is a bug or a setup problem, start in Discussions and we will
|
||||
promote it to an issue if it is one.
|
||||
|
||||
## Feature ideas
|
||||
|
||||
Start a thread in
|
||||
[Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas). Zero is
|
||||
in controlled development (see CONTRIBUTING.md), so discussing an idea first is
|
||||
also the path to getting a PR accepted.
|
||||
|
||||
## Security issues
|
||||
|
||||
Never file a public issue or discussion. Report privately via
|
||||
[GitHub Security Advisories](https://github.com/Gitlawb/zero/security/advisories/new)
|
||||
(see SECURITY.md).
|
||||
+159
-15
@@ -55,6 +55,133 @@ 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
|
||||
@@ -114,21 +241,38 @@ function resolveNativeBinary() {
|
||||
const nativePath = resolveNativeBinary();
|
||||
|
||||
if (!nativePath) {
|
||||
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+).',
|
||||
);
|
||||
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));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/perfbench"
|
||||
)
|
||||
|
||||
func TestParseArgsUsesCliAndEnvOverrides(t *testing.T) {
|
||||
@@ -92,3 +94,18 @@ func TestHelpText(t *testing.T) {
|
||||
func emptyEnv(string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestTurnExitCodeFailsWhenEveryTaskErrored(t *testing.T) {
|
||||
var stderr bytes.Buffer
|
||||
allErrored := perfbench.TurnBenchResult{TasksAttempted: 3, TasksErrored: 3}
|
||||
if code := turnExitCode(allErrored, &stderr); code != 1 {
|
||||
t.Fatalf("exit=%d, want 1 when every task errored", code)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "no accepted benchmark sample") {
|
||||
t.Fatalf("stderr missing explanation: %q", stderr.String())
|
||||
}
|
||||
partial := perfbench.TurnBenchResult{TasksAttempted: 3, TasksErrored: 2}
|
||||
if code := turnExitCode(partial, &stderr); code != 0 {
|
||||
t.Fatalf("exit=%d, want 0 for partial errors (summary surfaces them)", code)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/execprofile"
|
||||
"github.com/Gitlawb/zero/internal/perfbench"
|
||||
)
|
||||
|
||||
@@ -20,6 +21,7 @@ type turnOptions struct {
|
||||
SuitePath string
|
||||
Model string
|
||||
Mode string
|
||||
ExecProfile string
|
||||
SelfCorrect bool
|
||||
Binary string
|
||||
Iterations int
|
||||
@@ -64,6 +66,7 @@ func runTurnCommand(args []string, getenv func(string) string, stdout io.Writer,
|
||||
result, err := perfbench.RunTurnBench(context.Background(), set, perfbench.TurnBenchConfig{
|
||||
Model: options.Model,
|
||||
Mode: options.Mode,
|
||||
ExecProfile: options.ExecProfile,
|
||||
SelfCorrect: options.SelfCorrect,
|
||||
Version: options.Version,
|
||||
Commit: options.Commit,
|
||||
@@ -86,9 +89,23 @@ func runTurnCommand(args []string, getenv func(string) string, stdout io.Writer,
|
||||
_, _ = fmt.Fprintln(stderr, "[zero] Turn benchmark failed: "+err.Error())
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
return turnExitCode(result, stderr)
|
||||
}
|
||||
_, _ = fmt.Fprintln(stdout, perfbench.FormatTurnBenchSummary(result))
|
||||
return turnExitCode(result, stderr)
|
||||
}
|
||||
|
||||
// turnExitCode fails the command when every attempted task errored (no
|
||||
// iteration produced an accepted benchmark sample): such a report measures
|
||||
// nothing, and exiting 0 would let a broken configuration (missing binary, bad
|
||||
// path, failing harness step) pass as a clean baseline. Partial errors keep
|
||||
// exit 0 — the summary surfaces them loudly and the surviving samples are
|
||||
// still valid measurements.
|
||||
func turnExitCode(result perfbench.TurnBenchResult, stderr io.Writer) int {
|
||||
if result.TasksAttempted > 0 && result.TasksErrored == result.TasksAttempted {
|
||||
_, _ = fmt.Fprintln(stderr, "[zero] Turn benchmark failed: every task errored with no accepted benchmark sample (see warnings); the report contains no valid measurements")
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -123,6 +140,13 @@ func parseTurnArgs(args []string, getenv func(string) string) (turnOptions, erro
|
||||
}
|
||||
options.Mode = value
|
||||
index = next
|
||||
case "--exec-profile":
|
||||
value, next, err := readOptionValue(args, inlineValue, index, flag)
|
||||
if err != nil {
|
||||
return options, err
|
||||
}
|
||||
options.ExecProfile = value
|
||||
index = next
|
||||
case "--binary":
|
||||
value, next, err := readOptionValue(args, inlineValue, index, flag)
|
||||
if err != nil {
|
||||
@@ -195,6 +219,20 @@ func parseTurnArgs(args []string, getenv func(string) string) (turnOptions, erro
|
||||
if strings.TrimSpace(options.Model) == "" && !options.DryRun {
|
||||
return options, fmt.Errorf("--model is required (or pass --dry-run)")
|
||||
}
|
||||
// Validate the profile here, before anything spawns: an unknown name would
|
||||
// otherwise make every child exit with a usage error in milliseconds, and
|
||||
// those near-zero walls would be recorded as valid latency samples in an
|
||||
// exit-0 report — exactly the misread-as-improvement trap the harness
|
||||
// closed for spawn failures. Normalizing to the catalog name also keeps
|
||||
// the stamped execProfile canonical (Lookup is case-insensitive), so two
|
||||
// captures of the same posture always compare equal.
|
||||
if raw := strings.TrimSpace(options.ExecProfile); raw != "" {
|
||||
profile, ok := execprofile.Lookup(raw)
|
||||
if !ok {
|
||||
return options, fmt.Errorf("unknown execution profile %q for --exec-profile. Valid profiles: %s", raw, strings.Join(execprofile.Names(), ", "))
|
||||
}
|
||||
options.ExecProfile = profile.Name
|
||||
}
|
||||
return options, nil
|
||||
}
|
||||
|
||||
@@ -225,6 +263,9 @@ func turnHelpText() string {
|
||||
" --suite <path> Task set JSON file (required)",
|
||||
" --model <model> Model to run (required unless --dry-run)",
|
||||
" --mode <name> Exec mode preset to apply",
|
||||
" --exec-profile <name>",
|
||||
" Execution profile for every task (fast|balanced|thorough);",
|
||||
" forwarded to zero exec and stamped into the result",
|
||||
" --self-correct Enable the post-edit verify-and-correct loop",
|
||||
" --binary <path> Path to the `zero` binary (default: zero on PATH / repo root)",
|
||||
" --iterations <n> Times to run each task (default: 1)",
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseTurnArgsExecProfile(t *testing.T) {
|
||||
getenv := func(string) string { return "" }
|
||||
options, err := parseTurnArgs([]string{
|
||||
"--suite", "suite.json",
|
||||
"--model", "m",
|
||||
"--exec-profile", "fast",
|
||||
}, getenv)
|
||||
if err != nil {
|
||||
t.Fatalf("parseTurnArgs: %v", err)
|
||||
}
|
||||
if options.ExecProfile != "fast" {
|
||||
t.Fatalf("ExecProfile = %q, want fast", options.ExecProfile)
|
||||
}
|
||||
|
||||
// Inline form.
|
||||
options, err = parseTurnArgs([]string{
|
||||
"--suite=suite.json",
|
||||
"--model=m",
|
||||
"--exec-profile=thorough",
|
||||
}, getenv)
|
||||
if err != nil {
|
||||
t.Fatalf("parseTurnArgs inline: %v", err)
|
||||
}
|
||||
if options.ExecProfile != "thorough" {
|
||||
t.Fatalf("ExecProfile = %q, want thorough", options.ExecProfile)
|
||||
}
|
||||
}
|
||||
|
||||
// The bench validates the profile BEFORE spawning anything: an unknown name
|
||||
// would otherwise make every child exit with a usage error in milliseconds
|
||||
// and record those near-zero walls as valid latency samples in an exit-0
|
||||
// report. Lookup normalization also keeps the stamped name canonical so two
|
||||
// captures of the same posture compare equal.
|
||||
func TestParseTurnArgsExecProfileValidatesAndNormalizes(t *testing.T) {
|
||||
getenv := func(string) string { return "" }
|
||||
|
||||
options, err := parseTurnArgs([]string{
|
||||
"--suite", "suite.json", "--model", "m", "--exec-profile", "FAST",
|
||||
}, getenv)
|
||||
if err != nil {
|
||||
t.Fatalf("parseTurnArgs: %v", err)
|
||||
}
|
||||
if options.ExecProfile != "fast" {
|
||||
t.Fatalf("ExecProfile = %q, want the canonical fast", options.ExecProfile)
|
||||
}
|
||||
|
||||
_, err = parseTurnArgs([]string{
|
||||
"--suite", "suite.json", "--model", "m", "--exec-profile", "blanced",
|
||||
}, getenv)
|
||||
if err == nil {
|
||||
t.Fatal("an unknown profile must fail parse, before anything spawns")
|
||||
}
|
||||
for _, name := range []string{"balanced", "fast", "thorough"} {
|
||||
if !strings.Contains(err.Error(), name) {
|
||||
t.Fatalf("error must list %q, got %q", name, err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
-3
@@ -272,7 +272,8 @@ sequenceDiagram
|
||||
Agent->>Tool: execute(ctx, args)
|
||||
Tool-->>Agent: output, error, metadata
|
||||
Agent->>Hooks: afterTool hooks
|
||||
Agent->>Agent: redact, truncate, classify success/failure
|
||||
Agent->>Agent: redact secrets, apply semantic output budget, enforce byte ceiling
|
||||
Agent->>Agent: classify success/failure
|
||||
Agent-->>Surface: OnToolResult callback
|
||||
Agent->>Transcript: append tool result message
|
||||
end
|
||||
@@ -604,10 +605,22 @@ flowchart TD
|
||||
Prompt -- Yes --> Decision[Permission callback]
|
||||
Prompt -- No --> Run[Registry.RunWithOptions]
|
||||
Decision --> Run
|
||||
Run --> Redact[Redact secrets + enforce output ceiling]
|
||||
Redact --> Message[Return tool result to model]
|
||||
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]
|
||||
```
|
||||
|
||||
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,
|
||||
|
||||
@@ -141,9 +141,11 @@ 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,
|
||||
Registry: registry,
|
||||
OnToolResult: func(result ToolResult) { toolResults = append(toolResults, result) },
|
||||
OnAskUser: func(_ context.Context, _ AskUserRequest) (AskUserResponse, error) {
|
||||
return AskUserResponse{}, context.Canceled
|
||||
},
|
||||
@@ -158,6 +160,9 @@ 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 {
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
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}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
+177
-78
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
@@ -135,6 +136,33 @@ 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
|
||||
@@ -157,6 +185,32 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
|
||||
guards := newGuardState()
|
||||
compactor := newCompactionState(options)
|
||||
// The execution-profile posture controller. nil Options.Profile (every
|
||||
// existing caller) makes every observe/decide call a no-op, keeping the
|
||||
// loop byte-identical for unprofiled runs.
|
||||
posture := newProfileController(options.Profile)
|
||||
// applyPosture applies at most one posture escalation when an armed trigger
|
||||
// has fired: raise the hoisted turn ceiling, restore effort and the
|
||||
// completion gate, stamp the counter. Shared by the end-of-turn tail and
|
||||
// the completion-gate uncertain path (which continues the loop before the
|
||||
// tail runs). No-op forever after the first escalation and for unprofiled
|
||||
// runs.
|
||||
applyPosture := func() {
|
||||
if target, fired := posture.maybeEscalate(); fired {
|
||||
if target.MaxTurns > maxTurns {
|
||||
maxTurns = target.MaxTurns
|
||||
}
|
||||
if target.ReasoningEffort != "" {
|
||||
options.ReasoningEffort = target.ReasoningEffort
|
||||
} else if target.RestoreDefaultEffort {
|
||||
options.ReasoningEffort = ""
|
||||
}
|
||||
if target.RestoreCompletionGate {
|
||||
options.RequireCompletionSignal = true
|
||||
}
|
||||
options.Trace.Counter(trace.CounterPostureEscalations, 1)
|
||||
}
|
||||
}
|
||||
|
||||
// Background post-edit diagnostics: files changed by mutating tools are
|
||||
// checked off the tool-call critical path and any errors are appended as a
|
||||
@@ -169,13 +223,10 @@ 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{}
|
||||
|
||||
// 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
|
||||
// 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)
|
||||
|
||||
// toolDefCache memoizes each tool's rendered JSON-schema definition across
|
||||
// turns (a tool's advertised schema is stable for the run), so partitionTools
|
||||
@@ -520,68 +571,37 @@ 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 {
|
||||
// (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 != "" {
|
||||
evaluation := completionPolicy.evaluate(collected.Text, guards.pendingPlanItems())
|
||||
switch evaluation.Decision {
|
||||
case CompletionIncomplete:
|
||||
result.Incomplete = true
|
||||
result.IncompleteReason = reason
|
||||
result.IncompleteReason = evaluation.Reason
|
||||
result.FinalAnswer = collected.Text
|
||||
result.Messages = copyMessages(messages)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// (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++
|
||||
case CompletionUncertain:
|
||||
// Observe the uncertainty signal and apply any escalation NOW:
|
||||
// this branch continues the turn loop before the end-of-turn
|
||||
// act point, so deferring would skip escalation entirely.
|
||||
posture.observeUncertain()
|
||||
applyPosture()
|
||||
switch evaluation.Action {
|
||||
case completionActionContinue:
|
||||
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(reason),
|
||||
Content: continueNudge(evaluation.Reason),
|
||||
})
|
||||
case completionActionSemanticCheck:
|
||||
options.Trace.Counter(trace.CounterAcceptanceChecks, 1)
|
||||
messages = append(messages, zeroruntime.Message{
|
||||
Role: zeroruntime.MessageRoleUser,
|
||||
Content: acceptanceVerificationNudge(),
|
||||
})
|
||||
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
|
||||
@@ -632,10 +652,9 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
// The scan happens lazily at the run's first index — never ahead of a
|
||||
// pending mutating call — so read-after-write ordering is preserved.
|
||||
if index >= precomputedEnd {
|
||||
runEnd := index
|
||||
for runEnd < len(collected.ToolCalls) && parallelSafeToolCall(registry, collected.ToolCalls[runEnd], options) {
|
||||
runEnd++
|
||||
}
|
||||
// Capability-safe consecutive run (ReadOnly+ThreadSafe, no
|
||||
// resource-key conflicts). See extendParallelRun.
|
||||
runEnd := extendParallelRun(registry, collected.ToolCalls, index, options)
|
||||
if runEnd-index >= 2 {
|
||||
batchSpan := options.Trace.Span(trace.SpanToolExecution)
|
||||
precomputed = executeParallelReadBatch(ctx, registry, collected.ToolCalls, index, runEnd, permissionMode, options)
|
||||
@@ -657,6 +676,7 @@ 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)
|
||||
}
|
||||
@@ -703,6 +723,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
// would misdirect the model toward JSON shape or blocked behavior.
|
||||
retriableFailure := isRetriableToolError(toolResult)
|
||||
outcome := guards.observeToolResult(call.Name, retriableFailure, toolResult.Output)
|
||||
posture.observeToolOutcome(outcome, toolResult)
|
||||
if outcome.Stop {
|
||||
// The assistant message advertised EVERY collected tool call, but
|
||||
// the guard halts mid-turn so the calls after this one never run.
|
||||
@@ -735,7 +756,9 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
// the assistant's tool_results stay contiguous (a user message between
|
||||
// tool_results breaks strict provider replay). nil SelfCorrect is a no-op.
|
||||
if options.SelfCorrect != nil && len(changedFilesThisBatch) > 0 {
|
||||
if feedback, _ := options.SelfCorrect.AfterEdit(ctx, dedupeStrings(changedFilesThisBatch)); feedback != "" {
|
||||
feedback, selfCorrectOutcome := options.SelfCorrect.AfterEdit(ctx, dedupeStrings(changedFilesThisBatch))
|
||||
posture.observeSelfCorrect(selfCorrectOutcome)
|
||||
if feedback != "" {
|
||||
messages = append(messages, zeroruntime.Message{
|
||||
Role: zeroruntime.MessageRoleUser,
|
||||
Content: feedback,
|
||||
@@ -748,25 +771,53 @@ 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.ModelSwitcher != nil {
|
||||
newProvider, switchErr := options.ModelSwitcher(ctx, turnRequestedModel)
|
||||
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 switchErr != nil {
|
||||
messages = append(messages, zeroruntime.Message{
|
||||
Role: zeroruntime.MessageRoleUser,
|
||||
Content: escalationFailedNoticePrefix + " (" + turnRequestedModel + "): " + switchErr.Error() + ". Continuing on " + options.Model + ".",
|
||||
})
|
||||
} 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.
|
||||
} 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.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -800,6 +851,13 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
Content: reminder,
|
||||
})
|
||||
}
|
||||
|
||||
// One-shot posture escalation: when an armed profile trigger fired this
|
||||
// turn, restore the stricter knob values the profile displaced. Mutates
|
||||
// only per-turn-read policy (the hoisted turn ceiling, reasoning effort,
|
||||
// completion gate); never messages, model, or session. No-op forever
|
||||
// after the first escalation and for every unprofiled run.
|
||||
applyPosture()
|
||||
}
|
||||
|
||||
if ctx.Err() != nil {
|
||||
@@ -842,6 +900,28 @@ 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{
|
||||
@@ -1296,16 +1376,33 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
|
||||
if didRedact {
|
||||
result.Redacted = true
|
||||
}
|
||||
result = registry.RebudgetAfterHook(call.Name, args, result)
|
||||
}
|
||||
}
|
||||
// Stamp the sandbox risk classification for this EXECUTED call so run-policy
|
||||
// observers can see the risk of an allowed mutation. Prefer the preflight
|
||||
// decision's classification when the sandbox evaluated the call; otherwise
|
||||
// run the same pure classifier the permission path uses. Denied/canceled
|
||||
// results returned earlier keep the zero value.
|
||||
executedRisk := sandbox.Risk{}
|
||||
if preflightDecision != nil {
|
||||
executedRisk = preflightDecision.Risk
|
||||
} else if toolFound {
|
||||
// Unknown-tool results fall through here with a nil tool; they carry a
|
||||
// denial and keep the zero risk value.
|
||||
executedRisk = sandbox.Classify(sandboxRequest(call.Name, tool, args, permissionGranted, permissionMode, options))
|
||||
}
|
||||
|
||||
// Secret scrubbing happens at the registry boundary (the single point both
|
||||
// the agent loop and the MCP server pass through), so result.Output is
|
||||
// already redacted here and result.Redacted reflects whether it changed.
|
||||
return ToolResult{
|
||||
Risk: executedRisk,
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Name,
|
||||
Status: result.Status,
|
||||
Output: result.Output,
|
||||
Truncated: result.Truncated,
|
||||
Meta: result.Meta,
|
||||
Redacted: result.Redacted,
|
||||
ChangedFiles: result.ChangedFiles,
|
||||
@@ -1616,6 +1713,7 @@ 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,
|
||||
@@ -1885,6 +1983,7 @@ 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,
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
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])
|
||||
}
|
||||
}
|
||||
@@ -10,12 +10,23 @@ import (
|
||||
// Parallel read-ahead for tool batches. When a turn requests several
|
||||
// independent lookups (read_file + grep + glob is the common shape), executing
|
||||
// them one after another serializes pure I/O waits. A consecutive run of
|
||||
// auto-allowed read-only calls is executed concurrently instead; results are
|
||||
// capability-safe read-only calls is executed concurrently instead; results are
|
||||
// then consumed in the original call order, so guard counters, message
|
||||
// ordering, abort semantics, and the surface's call/result event pairing are
|
||||
// byte-identical to sequential execution. Runs never span a mutating call: a
|
||||
// read that follows a write must observe the write, so eligibility is decided
|
||||
// per consecutive run, not per batch.
|
||||
//
|
||||
// Eligibility uses the PR5 tool-effect contract (tools.CapabilitiesOf):
|
||||
//
|
||||
// Effect == ReadOnly
|
||||
// AND ThreadSafe == true
|
||||
// AND auto-allowed (no interactive permission prompt on the hot path)
|
||||
// AND no resource-key conflict with an earlier call in the same concurrent window
|
||||
//
|
||||
// Unknown, mutators, interactive tools, and non-thread-safe reads stay
|
||||
// sequential. Empty resource keys do not conflict (ThreadSafe is the safety
|
||||
// gate); shared non-empty keys force a batch boundary.
|
||||
|
||||
// maxParallelReadTools bounds concurrent read-only tool executions in a turn.
|
||||
const maxParallelReadTools = 8
|
||||
@@ -28,28 +39,117 @@ type precomputedToolResult struct {
|
||||
}
|
||||
|
||||
// parallelSafeToolCall reports whether call may run concurrently with its
|
||||
// neighbors: the tool must exist, be side-effect-free (SideEffectRead), and be
|
||||
// auto-allowed for these args, so no interactive prompt or workspace mutation
|
||||
// is on the hot path. Loop-intercepted tools (ask_user, request_permissions)
|
||||
// and tool_search (mutates the deferred-tool set) stay sequential.
|
||||
// neighbors under the PR5 capability contract. Loop-intercepted tools
|
||||
// (ask_user, request_permissions) and tool_search (mutates the deferred-tool
|
||||
// set) always stay sequential.
|
||||
func parallelSafeToolCall(registry *tools.Registry, call ToolCall, options Options) bool {
|
||||
switch call.Name {
|
||||
case "ask_user", tools.RequestPermissionsToolName, tools.ToolSearchToolName:
|
||||
return false
|
||||
}
|
||||
tool, found := registry.Get(call.Name)
|
||||
if !found || tool.Safety().SideEffect != tools.SideEffectRead {
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
args := map[string]any{}
|
||||
if call.Arguments != "" {
|
||||
if err := decodeToolArguments(call.Arguments, &args); err != nil {
|
||||
return false
|
||||
}
|
||||
caps := tools.CapabilitiesOf(tool)
|
||||
// Fail-closed: only audited concurrent-safe pure reads.
|
||||
if caps.Effect != tools.EffectReadOnly || !caps.ThreadSafe {
|
||||
return false
|
||||
}
|
||||
args, ok := decodeCallArgs(call)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return effectivePermission(tool, args) == tools.PermissionAllow
|
||||
}
|
||||
|
||||
// decodeCallArgs decodes tool call JSON arguments. Returns false on malformed
|
||||
// input so the call stays sequential (never panics into the parallel path).
|
||||
func decodeCallArgs(call ToolCall) (map[string]any, bool) {
|
||||
args := map[string]any{}
|
||||
if call.Arguments == "" {
|
||||
return args, true
|
||||
}
|
||||
if err := decodeToolArguments(call.Arguments, &args); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return args, true
|
||||
}
|
||||
|
||||
// resourceKeysForCall returns the conflict keys for a call, or nil when the
|
||||
// tool has no ResourceKeys function / no keys for these args.
|
||||
func resourceKeysForCall(registry *tools.Registry, call ToolCall) []string {
|
||||
tool, found := registry.Get(call.Name)
|
||||
if !found {
|
||||
return nil
|
||||
}
|
||||
caps := tools.CapabilitiesOf(tool)
|
||||
if caps.ResourceKeys == nil {
|
||||
return nil
|
||||
}
|
||||
args, ok := decodeCallArgs(call)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return caps.ResourceKeys(args)
|
||||
}
|
||||
|
||||
// resourceKeysConflict reports whether two key sets share any non-empty key.
|
||||
// Empty key sets never conflict: ThreadSafe is the eligibility gate; keys only
|
||||
// refine conflict detection among tools that declare specific resources.
|
||||
func resourceKeysConflict(a, b []string) bool {
|
||||
if len(a) == 0 || len(b) == 0 {
|
||||
return false
|
||||
}
|
||||
seen := make(map[string]struct{}, len(a))
|
||||
for _, key := range a {
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
}
|
||||
for _, key := range b {
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[key]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// extendParallelRun returns the exclusive end index of a consecutive parallel-
|
||||
// safe run starting at start. The run stops before the first call that is not
|
||||
// parallel-safe or that conflicts on resource keys with any earlier call in
|
||||
// the same window (so two read_file calls on the same path stay sequential).
|
||||
func extendParallelRun(registry *tools.Registry, calls []ToolCall, start int, options Options) int {
|
||||
if start >= len(calls) || !parallelSafeToolCall(registry, calls[start], options) {
|
||||
return start
|
||||
}
|
||||
end := start + 1
|
||||
keysWindow := [][]string{resourceKeysForCall(registry, calls[start])}
|
||||
for end < len(calls) {
|
||||
if !parallelSafeToolCall(registry, calls[end], options) {
|
||||
break
|
||||
}
|
||||
nextKeys := resourceKeysForCall(registry, calls[end])
|
||||
conflict := false
|
||||
for _, prev := range keysWindow {
|
||||
if resourceKeysConflict(prev, nextKeys) {
|
||||
conflict = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if conflict {
|
||||
break
|
||||
}
|
||||
keysWindow = append(keysWindow, nextKeys)
|
||||
end++
|
||||
}
|
||||
return end
|
||||
}
|
||||
|
||||
// executeParallelReadBatch runs calls[start:end] concurrently (bounded by
|
||||
// maxParallelReadTools) and returns results indexed relative to start. All
|
||||
// execution-side callbacks that can fire inside executeToolCall are serialized
|
||||
|
||||
@@ -57,6 +57,20 @@ func (tool *probeTool) Parameters() tools.Schema {
|
||||
func (tool *probeTool) Safety() tools.Safety {
|
||||
return tools.Safety{SideEffect: tool.sideEffect, Permission: tools.PermissionAllow, Reason: "test"}
|
||||
}
|
||||
|
||||
// Capabilities maps the probe's SideEffect into the PR5 capability contract so
|
||||
// parallelSafeToolCall (CapabilitiesOf) can classify reads vs mutators. Without
|
||||
// this, probes default to EffectUnknown and never enter the concurrent path.
|
||||
func (tool *probeTool) Capabilities() tools.ToolCapabilities {
|
||||
switch tool.sideEffect {
|
||||
case tools.SideEffectRead:
|
||||
return tools.ToolCapabilities{Effect: tools.EffectReadOnly, ThreadSafe: true}
|
||||
case tools.SideEffectWrite:
|
||||
return tools.ToolCapabilities{Effect: tools.EffectWorkspaceWrite, ThreadSafe: false}
|
||||
default:
|
||||
return tools.UnknownCapabilities()
|
||||
}
|
||||
}
|
||||
func (tool *probeTool) Run(_ context.Context, args map[string]any) tools.Result {
|
||||
id, _ := args["id"].(string)
|
||||
tool.mu.Lock()
|
||||
@@ -92,6 +106,11 @@ func TestParallelSafeToolCall(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(&probeTool{name: "probe_read", sideEffect: tools.SideEffectRead})
|
||||
registry.Register(&probeTool{name: "probe_write", sideEffect: tools.SideEffectWrite})
|
||||
// Read-only but not ThreadSafe: must stay sequential under the PR5 gate.
|
||||
registry.Register(&keyedProbeTool{
|
||||
probeTool: probeTool{name: "probe_read_serial", sideEffect: tools.SideEffectRead},
|
||||
threadSafe: false,
|
||||
})
|
||||
|
||||
call := func(name, args string) ToolCall { return ToolCall{ID: "c", Name: name, Arguments: args} }
|
||||
if !parallelSafeToolCall(registry, call("probe_read", `{"id":"a"}`), Options{}) {
|
||||
@@ -109,6 +128,91 @@ func TestParallelSafeToolCall(t *testing.T) {
|
||||
if parallelSafeToolCall(registry, call("ask_user", `{}`), Options{}) {
|
||||
t.Fatal("loop-intercepted tools must stay sequential")
|
||||
}
|
||||
if parallelSafeToolCall(registry, call("probe_read_serial", `{"id":"a"}`), Options{}) {
|
||||
t.Fatal("ReadOnly without ThreadSafe must not be parallel-safe")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourceKeysConflict(t *testing.T) {
|
||||
if resourceKeysConflict(nil, []string{"file:a"}) {
|
||||
t.Fatal("empty vs non-empty must not conflict")
|
||||
}
|
||||
if resourceKeysConflict([]string{"file:a"}, nil) {
|
||||
t.Fatal("non-empty vs empty must not conflict")
|
||||
}
|
||||
if resourceKeysConflict([]string{"file:a"}, []string{"file:b"}) {
|
||||
t.Fatal("distinct keys must not conflict")
|
||||
}
|
||||
if !resourceKeysConflict([]string{"file:a", "file:b"}, []string{"file:b"}) {
|
||||
t.Fatal("shared key must conflict")
|
||||
}
|
||||
if resourceKeysConflict([]string{"", "file:a"}, []string{""}) {
|
||||
t.Fatal("empty-string keys must be ignored")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtendParallelRunResourceKeyBoundary(t *testing.T) {
|
||||
// Two keyed reads on the same path must not share a concurrent window;
|
||||
// distinct paths may batch together.
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(&keyedProbeTool{
|
||||
probeTool: probeTool{name: "keyed_read", sideEffect: tools.SideEffectRead},
|
||||
threadSafe: true,
|
||||
keys: func(args map[string]any) []string {
|
||||
id, _ := args["id"].(string)
|
||||
if id == "" {
|
||||
return nil
|
||||
}
|
||||
return []string{"file:" + id}
|
||||
},
|
||||
})
|
||||
calls := []ToolCall{
|
||||
{ID: "1", Name: "keyed_read", Arguments: `{"id":"a"}`},
|
||||
{ID: "2", Name: "keyed_read", Arguments: `{"id":"b"}`},
|
||||
{ID: "3", Name: "keyed_read", Arguments: `{"id":"a"}`}, // conflicts with call 0
|
||||
}
|
||||
// start=0: a and b share no key → run covers [0,2)
|
||||
if end := extendParallelRun(registry, calls, 0, Options{}); end != 2 {
|
||||
t.Fatalf("extend from 0 = %d, want 2 (stop before second file:a)", end)
|
||||
}
|
||||
// start=2: single remaining call
|
||||
if end := extendParallelRun(registry, calls, 2, Options{}); end != 3 {
|
||||
t.Fatalf("extend from 2 = %d, want 3", end)
|
||||
}
|
||||
// Empty keys never conflict: three keyless safe reads form one window.
|
||||
registry.Register(&probeTool{name: "keyless_read", sideEffect: tools.SideEffectRead})
|
||||
keyless := []ToolCall{
|
||||
{ID: "1", Name: "keyless_read", Arguments: `{"id":"x"}`},
|
||||
{ID: "2", Name: "keyless_read", Arguments: `{"id":"y"}`},
|
||||
{ID: "3", Name: "keyless_read", Arguments: `{"id":"z"}`},
|
||||
}
|
||||
if end := extendParallelRun(registry, keyless, 0, Options{}); end != 3 {
|
||||
t.Fatalf("keyless extend = %d, want 3", end)
|
||||
}
|
||||
}
|
||||
|
||||
// keyedProbeTool is a probe with explicit ResourceKeys / ThreadSafe for
|
||||
// planner unit tests.
|
||||
type keyedProbeTool struct {
|
||||
probeTool
|
||||
threadSafe bool
|
||||
keys func(args map[string]any) []string
|
||||
}
|
||||
|
||||
func (tool *keyedProbeTool) Capabilities() tools.ToolCapabilities {
|
||||
caps := tools.ToolCapabilities{
|
||||
Effect: tools.EffectReadOnly,
|
||||
ThreadSafe: tool.threadSafe,
|
||||
}
|
||||
if tool.keys != nil {
|
||||
caps.ResourceKeys = tool.keys
|
||||
}
|
||||
// Honor mutator side effects from the embedded probe when used as write.
|
||||
if tool.sideEffect == tools.SideEffectWrite {
|
||||
caps.Effect = tools.EffectWorkspaceWrite
|
||||
caps.ThreadSafe = false
|
||||
}
|
||||
return caps
|
||||
}
|
||||
|
||||
func TestRunExecutesConsecutiveReadsConcurrently(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
)
|
||||
|
||||
// profileController observes per-turn run signals and decides at most one
|
||||
// posture escalation per run for the armed execution profile. A nil policy (or
|
||||
// a policy without Escalate) makes every method a no-op, so runs without a
|
||||
// profile are byte-identical to before — the same opt-in convention as Trace
|
||||
// and SelfCorrect.
|
||||
//
|
||||
// The controller only ever CHANGES loop policy knobs (turn ceiling, reasoning
|
||||
// effort, completion gate) through the values the loop applies at its act
|
||||
// point; it never injects messages, never touches the model or session, and
|
||||
// never interacts with the model-initiated escalate_model path.
|
||||
type profileController struct {
|
||||
policy *PostureEscalation
|
||||
|
||||
escalated bool
|
||||
uncertainSeen int
|
||||
|
||||
failureTripped bool
|
||||
riskTripped bool
|
||||
scTripped bool
|
||||
uncertainTrip bool
|
||||
}
|
||||
|
||||
// newProfileController is nil-safe: a nil ProfilePolicy (the default for every
|
||||
// existing caller) or a nil Escalate yields a controller that observes and
|
||||
// decides nothing.
|
||||
func newProfileController(policy *ProfilePolicy) *profileController {
|
||||
if policy == nil {
|
||||
return &profileController{}
|
||||
}
|
||||
return &profileController{policy: policy.Escalate}
|
||||
}
|
||||
|
||||
// observeToolOutcome watches two signals from an executed tool call: the
|
||||
// repeated-failure guard's streak (reusing the guard's own counting, not a
|
||||
// second failure model) and the sandbox risk classification of an executed
|
||||
// result.
|
||||
func (c *profileController) observeToolOutcome(outcome toolFailureOutcome, result ToolResult) {
|
||||
if c.policy == nil || c.escalated {
|
||||
return
|
||||
}
|
||||
if c.policy.OnToolFailureStreak > 0 && outcome.Count >= c.policy.OnToolFailureStreak {
|
||||
c.failureTripped = true
|
||||
}
|
||||
if threshold := riskRank(c.policy.OnRiskyMutation); threshold > 0 &&
|
||||
result.DenialReason == DenialNone &&
|
||||
riskRank(result.Risk.Level) >= threshold {
|
||||
// The call EXECUTED (was not denied): partial failures count too, since
|
||||
// the mutation ran. An unrecognized threshold ranks 0 and disables the
|
||||
// signal instead of matching everything.
|
||||
c.riskTripped = true
|
||||
}
|
||||
}
|
||||
|
||||
// observeUncertain counts uncertain completion evaluations (continue nudges and
|
||||
// semantic checks). Headless-only by construction: the completion gate never
|
||||
// runs interactively.
|
||||
func (c *profileController) observeUncertain() {
|
||||
if c.policy == nil || c.escalated || c.policy.OnCompletionUncertain <= 0 {
|
||||
return
|
||||
}
|
||||
c.uncertainSeen++
|
||||
if c.uncertainSeen >= c.policy.OnCompletionUncertain {
|
||||
c.uncertainTrip = true
|
||||
}
|
||||
}
|
||||
|
||||
// observeSelfCorrect watches the post-edit verification outcome. Any failing
|
||||
// outcome (correcting, reported, aborted) is a signal; passed and disabled are
|
||||
// not.
|
||||
func (c *profileController) observeSelfCorrect(outcome Outcome) {
|
||||
if c.policy == nil || c.escalated || !c.policy.OnSelfCorrectFailure {
|
||||
return
|
||||
}
|
||||
switch outcome {
|
||||
case OutcomeCorrecting, OutcomeReported, OutcomeAborted:
|
||||
c.scTripped = true
|
||||
}
|
||||
}
|
||||
|
||||
// maybeEscalate reports the escalation target exactly once, the first time any
|
||||
// armed trigger has fired. Subsequent calls return false for the rest of the
|
||||
// run: escalation is one-shot and never de-escalates, so no cooldown state is
|
||||
// needed.
|
||||
func (c *profileController) maybeEscalate() (PostureEscalation, bool) {
|
||||
if c.policy == nil || c.escalated {
|
||||
return PostureEscalation{}, false
|
||||
}
|
||||
if !(c.failureTripped || c.riskTripped || c.scTripped || c.uncertainTrip) {
|
||||
return PostureEscalation{}, false
|
||||
}
|
||||
c.escalated = true
|
||||
return *c.policy, true
|
||||
}
|
||||
|
||||
// riskRank orders sandbox risk levels for threshold comparison. Unknown levels
|
||||
// rank 0: an unrecognized RESULT level can never meet a valid threshold, and an
|
||||
// unrecognized THRESHOLD is rejected before comparison (rank 0 disables the
|
||||
// signal) so a typo in a profile can never make every result match.
|
||||
func riskRank(level sandbox.RiskLevel) int {
|
||||
switch level {
|
||||
case sandbox.RiskLow:
|
||||
return 1
|
||||
case sandbox.RiskMedium:
|
||||
return 2
|
||||
case sandbox.RiskHigh:
|
||||
return 3
|
||||
case sandbox.RiskCritical:
|
||||
return 4
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/trace"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
func TestProfileControllerNilPolicyIsNoOp(t *testing.T) {
|
||||
for _, controller := range []*profileController{
|
||||
newProfileController(nil),
|
||||
newProfileController(&ProfilePolicy{Name: "balanced"}), // Escalate nil
|
||||
} {
|
||||
controller.observeToolOutcome(toolFailureOutcome{Count: 99}, ToolResult{Status: tools.StatusOK, Risk: sandbox.Risk{Level: sandbox.RiskCritical}})
|
||||
controller.observeUncertain()
|
||||
controller.observeSelfCorrect(OutcomeAborted)
|
||||
if _, fired := controller.maybeEscalate(); fired {
|
||||
t.Fatal("nil policy must never escalate")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileControllerEscalatesOnFailureStreak(t *testing.T) {
|
||||
controller := newProfileController(&ProfilePolicy{Escalate: &PostureEscalation{OnToolFailureStreak: 2, MaxTurns: 80}})
|
||||
controller.observeToolOutcome(toolFailureOutcome{Count: 1}, ToolResult{Status: tools.StatusError})
|
||||
if _, fired := controller.maybeEscalate(); fired {
|
||||
t.Fatal("streak below threshold must not escalate")
|
||||
}
|
||||
controller.observeToolOutcome(toolFailureOutcome{Count: 2}, ToolResult{Status: tools.StatusError})
|
||||
target, fired := controller.maybeEscalate()
|
||||
if !fired || target.MaxTurns != 80 {
|
||||
t.Fatalf("expected escalation with the policy target, got fired=%t target=%+v", fired, target)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileControllerEscalatesOnRiskyMutation(t *testing.T) {
|
||||
controller := newProfileController(&ProfilePolicy{Escalate: &PostureEscalation{OnRiskyMutation: sandbox.RiskHigh}})
|
||||
// Medium risk on an executed result: below threshold.
|
||||
controller.observeToolOutcome(toolFailureOutcome{}, ToolResult{Status: tools.StatusOK, Risk: sandbox.Risk{Level: sandbox.RiskMedium}})
|
||||
if _, fired := controller.maybeEscalate(); fired {
|
||||
t.Fatal("medium risk must not trip a high threshold")
|
||||
}
|
||||
// Critical risk on a DENIED result: never counts (the mutation never ran).
|
||||
controller.observeToolOutcome(toolFailureOutcome{}, ToolResult{Status: tools.StatusError, DenialReason: DenialSandboxBlock, Risk: sandbox.Risk{Level: sandbox.RiskCritical}})
|
||||
if _, fired := controller.maybeEscalate(); fired {
|
||||
t.Fatal("a denied result must not trip the risky-mutation signal")
|
||||
}
|
||||
// Critical risk on an executed PARTIAL FAILURE (error status, no denial):
|
||||
// the mutation ran, so it counts.
|
||||
controller.observeToolOutcome(toolFailureOutcome{}, ToolResult{Status: tools.StatusError, Risk: sandbox.Risk{Level: sandbox.RiskCritical}})
|
||||
if _, fired := controller.maybeEscalate(); !fired {
|
||||
t.Fatal("an executed partial failure at critical risk must trip a high threshold")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileControllerIgnoresInvalidRiskThreshold(t *testing.T) {
|
||||
controller := newProfileController(&ProfilePolicy{Escalate: &PostureEscalation{OnRiskyMutation: sandbox.RiskLevel("hgih")}})
|
||||
controller.observeToolOutcome(toolFailureOutcome{}, ToolResult{Status: tools.StatusOK, Risk: sandbox.Risk{Level: sandbox.RiskCritical}})
|
||||
if _, fired := controller.maybeEscalate(); fired {
|
||||
t.Fatal("an unrecognized threshold must disable the signal, not match every result")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileControllerEscalatesOnSelfCorrectFailure(t *testing.T) {
|
||||
controller := newProfileController(&ProfilePolicy{Escalate: &PostureEscalation{OnSelfCorrectFailure: true}})
|
||||
controller.observeSelfCorrect(OutcomePassed)
|
||||
controller.observeSelfCorrect(OutcomeDisabled)
|
||||
if _, fired := controller.maybeEscalate(); fired {
|
||||
t.Fatal("passing/disabled outcomes must not escalate")
|
||||
}
|
||||
controller.observeSelfCorrect(OutcomeReported)
|
||||
if _, fired := controller.maybeEscalate(); !fired {
|
||||
t.Fatal("a failing verification outcome must escalate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileControllerEscalatesOnUncertainCompletion(t *testing.T) {
|
||||
controller := newProfileController(&ProfilePolicy{Escalate: &PostureEscalation{OnCompletionUncertain: 2}})
|
||||
controller.observeUncertain()
|
||||
if _, fired := controller.maybeEscalate(); fired {
|
||||
t.Fatal("first uncertain evaluation must not trip a threshold of 2")
|
||||
}
|
||||
controller.observeUncertain()
|
||||
if _, fired := controller.maybeEscalate(); !fired {
|
||||
t.Fatal("second uncertain evaluation must escalate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileControllerEscalatesAtMostOnce(t *testing.T) {
|
||||
controller := newProfileController(&ProfilePolicy{Escalate: &PostureEscalation{OnToolFailureStreak: 1}})
|
||||
controller.observeToolOutcome(toolFailureOutcome{Count: 1}, ToolResult{Status: tools.StatusError})
|
||||
if _, fired := controller.maybeEscalate(); !fired {
|
||||
t.Fatal("expected first escalation")
|
||||
}
|
||||
controller.observeToolOutcome(toolFailureOutcome{Count: 5}, ToolResult{Status: tools.StatusOK, Risk: sandbox.Risk{Level: sandbox.RiskCritical}})
|
||||
controller.observeUncertain()
|
||||
if _, fired := controller.maybeEscalate(); fired {
|
||||
t.Fatal("escalation must be one-shot per run")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPostureEscalationOnUncertainCompletion proves the uncertain-path act
|
||||
// point: the completion gate's continue branch escapes the end-of-turn tail,
|
||||
// so escalation must apply before the continue. A cue turn under the gate
|
||||
// counts as uncertain, escalates, and the very next request carries the target
|
||||
// effort.
|
||||
func TestPostureEscalationOnUncertainCompletion(t *testing.T) {
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "Let me read the file:"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "Done. All set."},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
}}
|
||||
|
||||
recorder := trace.NewRecorder("posture-session", "run-2", "fast")
|
||||
result, err := Run(context.Background(), "go", provider, Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
MaxTurns: 10,
|
||||
RequireCompletionSignal: true,
|
||||
Trace: recorder,
|
||||
Profile: &ProfilePolicy{
|
||||
Name: "fast",
|
||||
Escalate: &PostureEscalation{
|
||||
ReasoningEffort: "high",
|
||||
OnCompletionUncertain: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FinalAnswer != "Done. All set." {
|
||||
t.Fatalf("expected the completed answer, got %q", result.FinalAnswer)
|
||||
}
|
||||
if len(provider.requests) != 2 {
|
||||
t.Fatalf("expected 2 requests, got %d", len(provider.requests))
|
||||
}
|
||||
if provider.requests[1].ReasoningEffort != "high" {
|
||||
t.Fatalf("expected the post-escalation request to carry the target effort, got %q", provider.requests[1].ReasoningEffort)
|
||||
}
|
||||
tr := recorder.Finish()
|
||||
if got := tr.Counter(trace.CounterPostureEscalations); got != 1 {
|
||||
t.Fatalf("posture_escalations = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A profile that filled the run's effort displaced the provider default ("");
|
||||
// RestoreDefaultEffort lets escalation restore that default, which a plain ""
|
||||
// ReasoningEffort target cannot express (it means "leave untouched").
|
||||
func TestPostureEscalationRestoresDefaultEffort(t *testing.T) {
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "Let me read the file:"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "Done. All set."},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
}}
|
||||
|
||||
result, err := Run(context.Background(), "go", provider, Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
MaxTurns: 10,
|
||||
ReasoningEffort: "low",
|
||||
RequireCompletionSignal: true,
|
||||
Profile: &ProfilePolicy{
|
||||
Name: "fast",
|
||||
Escalate: &PostureEscalation{
|
||||
RestoreDefaultEffort: true,
|
||||
OnCompletionUncertain: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FinalAnswer != "Done. All set." {
|
||||
t.Fatalf("expected the completed answer, got %q", result.FinalAnswer)
|
||||
}
|
||||
if len(provider.requests) != 2 {
|
||||
t.Fatalf("expected 2 requests, got %d", len(provider.requests))
|
||||
}
|
||||
if provider.requests[0].ReasoningEffort != "low" {
|
||||
t.Fatalf("pre-escalation request effort = %q, want the profile's low", provider.requests[0].ReasoningEffort)
|
||||
}
|
||||
if provider.requests[1].ReasoningEffort != "" {
|
||||
t.Fatalf("post-escalation request effort = %q, want the restored provider default (empty)", provider.requests[1].ReasoningEffort)
|
||||
}
|
||||
}
|
||||
|
||||
// failingProfileTool always errors with the same signature so the repeated-
|
||||
// failure guard builds a streak.
|
||||
type failingProfileTool struct{}
|
||||
|
||||
func (failingProfileTool) Name() string { return "flaky_probe" }
|
||||
func (failingProfileTool) Description() string { return "always fails identically (test)" }
|
||||
func (failingProfileTool) Parameters() tools.Schema {
|
||||
return tools.Schema{Type: "object", AdditionalProperties: false}
|
||||
}
|
||||
func (failingProfileTool) Safety() tools.Safety {
|
||||
return tools.Safety{SideEffect: tools.SideEffectRead, Permission: tools.PermissionAllow}
|
||||
}
|
||||
func (failingProfileTool) Run(context.Context, map[string]any) tools.Result {
|
||||
return tools.Result{Status: tools.StatusError, Output: "probe exploded: same signature"}
|
||||
}
|
||||
|
||||
// TestPostureEscalationRaisesTurnCeilingMidRun proves the end-to-end act path:
|
||||
// a Fast-style run with a 2-turn ceiling hits a failure streak, escalates, and
|
||||
// finishes on a turn the original ceiling would have cut off, with the effort
|
||||
// target applied to the post-escalation request and the counter emitted.
|
||||
func TestPostureEscalationRaisesTurnCeilingMidRun(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(failingProfileTool{})
|
||||
|
||||
toolTurn := []zeroruntime.StreamEvent{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "c1", ToolName: "flaky_probe"},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "c1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
}
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
toolTurn,
|
||||
toolTurn,
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "recovered after escalation"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
}}
|
||||
|
||||
recorder := trace.NewRecorder("posture-session", "run-1", "fast")
|
||||
result, err := Run(context.Background(), "go", provider, Options{
|
||||
Registry: registry,
|
||||
MaxTurns: 2,
|
||||
Trace: recorder,
|
||||
Profile: &ProfilePolicy{
|
||||
Name: "fast",
|
||||
Escalate: &PostureEscalation{
|
||||
MaxTurns: 4,
|
||||
ReasoningEffort: "high",
|
||||
OnToolFailureStreak: 2,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FinalAnswer != "recovered after escalation" {
|
||||
t.Fatalf("expected the run to continue past the original 2-turn ceiling, got %q (turns=%d)", result.FinalAnswer, result.Turns)
|
||||
}
|
||||
if len(provider.requests) != 3 {
|
||||
t.Fatalf("expected 3 requests (ceiling raised from 2 to 4), got %d", len(provider.requests))
|
||||
}
|
||||
if provider.requests[2].ReasoningEffort != "high" {
|
||||
t.Fatalf("expected the post-escalation request to carry the target effort, got %q", provider.requests[2].ReasoningEffort)
|
||||
}
|
||||
tr := recorder.Finish()
|
||||
if got := tr.Counter(trace.CounterPostureEscalations); got != 1 {
|
||||
t.Fatalf("posture_escalations = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPostureEscalationAbsentWithoutProfile pins the no-regression invariant at
|
||||
// the loop level: the identical failing script without a profile ends at the
|
||||
// original ceiling with the max-turns answer.
|
||||
func TestPostureEscalationAbsentWithoutProfile(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(failingProfileTool{})
|
||||
|
||||
toolTurn := []zeroruntime.StreamEvent{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "c1", ToolName: "flaky_probe"},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "c1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
}
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{toolTurn, toolTurn}}
|
||||
|
||||
result, err := Run(context.Background(), "go", provider, Options{
|
||||
Registry: registry,
|
||||
MaxTurns: 2,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(provider.requests) > 3 {
|
||||
t.Fatalf("unprofiled run must not extend the ceiling, got %d requests", len(provider.requests))
|
||||
}
|
||||
if !strings.Contains(result.FinalAnswer, "maximum number of turns") {
|
||||
t.Fatalf("unprofiled run must end with the max-turns answer, got %q", result.FinalAnswer)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecuteToolCallClassifiesRiskWithoutSandbox verifies the executed-risk
|
||||
// stamp on the unsandboxed path: a read-only tool classifies low.
|
||||
func TestExecuteToolCallClassifiesRiskWithoutSandbox(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(failingProfileTool{})
|
||||
|
||||
result, abortErr := executeToolCall(
|
||||
context.Background(),
|
||||
registry,
|
||||
ToolCall{ID: "c1", Name: "flaky_probe"},
|
||||
PermissionModeAuto,
|
||||
Options{},
|
||||
)
|
||||
if abortErr != nil {
|
||||
t.Fatal(abortErr)
|
||||
}
|
||||
if result.Risk.Level == "" {
|
||||
t.Fatal("executed result must carry a risk classification")
|
||||
}
|
||||
if riskRank(result.Risk.Level) > riskRank(sandbox.RiskMedium) {
|
||||
t.Fatalf("read-only tool classified %q, want at most medium", result.Risk.Level)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnknownToolResultCarriesZeroRisk verifies a call that never executed a
|
||||
// tool (unknown name, nil tool) keeps the zero risk value instead of panicking
|
||||
// or inventing a classification.
|
||||
func TestUnknownToolResultCarriesZeroRisk(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
result, abortErr := executeToolCall(
|
||||
context.Background(),
|
||||
registry,
|
||||
ToolCall{ID: "c1", Name: "no_such_tool"},
|
||||
PermissionModeAuto,
|
||||
Options{},
|
||||
)
|
||||
if abortErr != nil {
|
||||
t.Fatal(abortErr)
|
||||
}
|
||||
if result.Status == tools.StatusOK {
|
||||
t.Fatal("expected an error result for an unknown tool")
|
||||
}
|
||||
if result.Risk.Level != "" {
|
||||
t.Fatalf("not-executed result must keep the zero risk value, got %q", result.Risk.Level)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
+87
-6
@@ -61,10 +61,13 @@ const (
|
||||
)
|
||||
|
||||
type ToolResult struct {
|
||||
ToolCallID string
|
||||
Name string
|
||||
Status tools.Status
|
||||
Output string
|
||||
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
|
||||
Meta map[string]string
|
||||
Redacted bool
|
||||
ChangedFiles []string
|
||||
@@ -72,6 +75,12 @@ type ToolResult struct {
|
||||
// DenialReason categorizes why a tool call was blocked (empty when it ran).
|
||||
// It lets a surface distinguish the cause precisely instead of parsing Output.
|
||||
DenialReason DenialCategory
|
||||
// Risk is the sandbox risk classification of this call, stamped for EXECUTED
|
||||
// results so run-policy observers (the execution-profile controller) can see
|
||||
// the risk level of an allowed mutation. It mirrors the classification the
|
||||
// permission path already computes; denied or canceled results keep the zero
|
||||
// value. Pure observation: nothing about permissions or sandboxing changes.
|
||||
Risk sandbox.Risk
|
||||
// LoadedTools carries the deferred-tool names a tool_search call asked the
|
||||
// loop to expose next turn (lifted from Meta["load_tools"]). nil for every
|
||||
// ordinary tool result; only tool_search populates it.
|
||||
@@ -95,6 +104,54 @@ const (
|
||||
DenialHookBlocked DenialCategory = "hook_blocked" // vetoed by a beforeTool hook
|
||||
)
|
||||
|
||||
// ProfilePolicy is the loop-facing slice of a selected execution profile.
|
||||
// The surface (exec flag, TUI command) resolves a named profile into this
|
||||
// policy; the loop only ever sees the policy, never the catalog.
|
||||
type ProfilePolicy struct {
|
||||
// Name labels trace counters and diagnostics (e.g. "fast"). The trace
|
||||
// recorder's profile label is set by the caller at recorder construction.
|
||||
Name string
|
||||
// Escalate, when non-nil, arms one-shot in-run posture escalation.
|
||||
Escalate *PostureEscalation
|
||||
}
|
||||
|
||||
// PostureEscalation describes a one-shot escalation to stricter knob values,
|
||||
// applied mid-run when an armed trigger fires. Targets are the values the
|
||||
// selected profile DISPLACED at run start (i.e. "restore the balanced
|
||||
// posture"), so escalation can never introduce a value that was not already
|
||||
// valid for this run and model. Zero-valued targets leave that knob untouched.
|
||||
type PostureEscalation struct {
|
||||
// MaxTurns raises the turn ceiling to this value when greater than the
|
||||
// ceiling in effect. 0 leaves the ceiling untouched.
|
||||
MaxTurns int
|
||||
// ReasoningEffort replaces the run's effort when non-empty.
|
||||
ReasoningEffort string
|
||||
// RestoreDefaultEffort clears the run's effort override (back to the
|
||||
// provider/model default) when true. It exists because the displaced value
|
||||
// of a profile-filled effort is "" — which as a ReasoningEffort target
|
||||
// means "leave untouched" — so restoring the default needs its own signal.
|
||||
// Ignored when ReasoningEffort is non-empty.
|
||||
RestoreDefaultEffort bool
|
||||
// RestoreCompletionGate re-enables RequireCompletionSignal (headless
|
||||
// completion semantics) when true.
|
||||
RestoreCompletionGate bool
|
||||
|
||||
// Triggers. A zero value disables that signal entirely.
|
||||
// OnToolFailureStreak fires when the repeated-failure guard observes a
|
||||
// same-tool retriable-failure streak of at least this length.
|
||||
OnToolFailureStreak int
|
||||
// OnCompletionUncertain fires on the Nth uncertain completion evaluation
|
||||
// (continue nudge or semantic check). Headless only: the completion gate
|
||||
// never runs interactively.
|
||||
OnCompletionUncertain int
|
||||
// OnSelfCorrectFailure fires when a post-edit verification cycle reports a
|
||||
// failing outcome (correcting, reported, or aborted).
|
||||
OnSelfCorrectFailure bool
|
||||
// OnRiskyMutation fires when an EXECUTED tool result carries a sandbox risk
|
||||
// level at or above this threshold. Empty disables the signal.
|
||||
OnRiskyMutation sandbox.RiskLevel
|
||||
}
|
||||
|
||||
type PermissionRequest struct {
|
||||
ToolCallID string `json:"toolCallId"`
|
||||
ToolName string `json:"name"`
|
||||
@@ -280,6 +337,27 @@ 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)
|
||||
// Profile, when set, arms the execution-profile posture controller for this
|
||||
// run (auto-escalation to stricter knob values on failure/uncertainty/risky
|
||||
// mutation signals). nil — the default everywhere today — leaves the loop
|
||||
// byte-identical: no observation, no escalation, no counters. Same opt-in
|
||||
// convention as Trace and SelfCorrect.
|
||||
Profile *ProfilePolicy
|
||||
// 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,
|
||||
@@ -312,8 +390,11 @@ 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. Default
|
||||
// false leaves the loop byte-identical, so the interactive TUI is unaffected.
|
||||
// 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.
|
||||
RequireCompletionSignal bool
|
||||
|
||||
runPermissions *permissionRunState
|
||||
|
||||
@@ -1341,6 +1341,9 @@ Flags:
|
||||
--spec-reasoning-effort <effort>
|
||||
Override draft reasoning effort when --use-spec is set
|
||||
--max-turns <number> Override the maximum agent loop turns
|
||||
--exec-profile <name> Apply an execution profile (balanced, fast, thorough): loop
|
||||
posture only (turn budget, effort, self-correction, escalation);
|
||||
composes with --mode (which picks the model) and explicit flags win
|
||||
--auto <low|medium|high> Set exec autonomy; high enables unsafe tools
|
||||
--enabled-tools <tools> Only expose these comma or space separated tools
|
||||
--disabled-tools <tools> Hide these comma or space separated tools
|
||||
|
||||
+147
-25
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/errhint"
|
||||
"github.com/Gitlawb/zero/internal/execprofile"
|
||||
"github.com/Gitlawb/zero/internal/imageinput"
|
||||
"github.com/Gitlawb/zero/internal/lsp"
|
||||
"github.com/Gitlawb/zero/internal/modelregistry"
|
||||
@@ -69,7 +70,14 @@ type execOptions struct {
|
||||
// intentionally inert: nothing consumes it. Model selection is driven by
|
||||
// --model / --mode instead. See writeExecHelp ("Accept legacy model profile
|
||||
// selection") and TestRunExecAcceptsLegacyModelProfileFlags.
|
||||
modelProfile string
|
||||
modelProfile string
|
||||
// execProfile selects a named execution profile (balanced, fast, thorough):
|
||||
// a loop-posture bundle (turn budget, reasoning effort, self-correction,
|
||||
// escalation triggers) applied AFTER --mode with the same
|
||||
// fill-only-if-unset rule, so precedence is explicit flag > mode > profile.
|
||||
// Distinct from the mode preset also named "fast" (which picks a model) and
|
||||
// from the legacy inert --profile above. See internal/execprofile.
|
||||
execProfile string
|
||||
reasoningEffort string
|
||||
useSpec bool
|
||||
specModel string
|
||||
@@ -167,6 +175,14 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
if err := applyExecMode(&options); err != nil {
|
||||
return writeExecFormatUsageError(stdout, stderr, options.outputFormat, err.Error())
|
||||
}
|
||||
// A profile tunes loop posture the way a mode tunes model selection, and
|
||||
// runs after it so the mode's fills count as "set" and win. MaxTurns is
|
||||
// deferred to config resolution below, where the displaced resolved budget
|
||||
// is known and becomes the escalation restore target.
|
||||
execProfile, execProfileFilledEffort, err := applyExecProfile(&options)
|
||||
if err != nil {
|
||||
return writeExecFormatUsageError(stdout, stderr, options.outputFormat, err.Error())
|
||||
}
|
||||
|
||||
workspaceRoot, err := resolveWorkspaceRoot(options.cwd, deps)
|
||||
if err != nil {
|
||||
@@ -272,6 +288,8 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
}
|
||||
return writeExecProviderError(stdout, stderr, options.outputFormat, "provider_error", err.Error())
|
||||
}
|
||||
var displacedMaxTurns int
|
||||
resolved.MaxTurns, displacedMaxTurns = applyProfileTurnBudget(execProfile, options.maxTurns, resolved.MaxTurns)
|
||||
registerLocalControlTools(registry, workspaceRoot, resolved.LocalControl)
|
||||
if err := validateExecToolFilters(options, registry); err != nil {
|
||||
return writeExecFormatUsageError(stdout, stderr, options.outputFormat, err.Error())
|
||||
@@ -412,6 +430,36 @@ 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())
|
||||
@@ -456,6 +504,13 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
reasoningEffort: forwardEffort,
|
||||
specPermissionMode: permissionMode,
|
||||
notifier: notifier,
|
||||
// The profile displaced resolved.MaxTurns above, so the spec-draft
|
||||
// run arms the same escalation policy as the main run (in practice
|
||||
// only the failure-streak and risky-mutation triggers can fire
|
||||
// here: the draft runs without the completion gate or a
|
||||
// self-corrector).
|
||||
profilePolicy: execProfile.Policy(displacedMaxTurns,
|
||||
specProfileEffortFilled(execProfileFilledEffort, options.specReasoningEffort)),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -498,7 +553,7 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
var traceRecorder *trace.Recorder
|
||||
var traceSnapshot *trace.TurnTrace
|
||||
if tracePath != "" {
|
||||
traceRecorder = trace.NewRecorder(preparedSession.Session.SessionID, runID, "")
|
||||
traceRecorder = trace.NewRecorder(preparedSession.Session.SessionID, runID, execProfile.Name)
|
||||
defer func() {
|
||||
if err := writeTraceSnapshot(traceSnapshot, tracePath, stderr); err != nil {
|
||||
fmt.Fprintf(stderr, "[zero] failed to write trace: %s\n", err)
|
||||
@@ -572,29 +627,32 @@ 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,
|
||||
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,
|
||||
TurnSessionProvider: turnSessions,
|
||||
ModelSessionSwitcher: modelSessionSwitcher,
|
||||
ReasoningEffort: forwardEffort,
|
||||
Trace: traceRecorder,
|
||||
Cwd: workspaceRoot,
|
||||
Images: images,
|
||||
Registry: registry,
|
||||
PermissionMode: permissionMode,
|
||||
Autonomy: options.autonomy,
|
||||
SelfCorrect: selfCorrector,
|
||||
FileDiagnostics: fileDiagnostics,
|
||||
Profile: execProfile.Policy(displacedMaxTurns, execProfileFilledEffort),
|
||||
// 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
|
||||
@@ -636,6 +694,9 @@ 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
|
||||
}
|
||||
@@ -1098,6 +1159,67 @@ func applyExecMode(options *execOptions) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyExecProfile expands an --exec-profile selection onto the exec options.
|
||||
// Like applyExecMode it only fills fields the caller left unset, and it runs
|
||||
// AFTER the mode so a mode-filled field counts as set: precedence is explicit
|
||||
// flag > --mode > --exec-profile. Only the options-level knobs are applied here
|
||||
// (reasoning effort still flows through the model-supported gating downstream;
|
||||
// self-correct is presence-only so a profile can only turn it on). MaxTurns is
|
||||
// intentionally NOT applied here — the profile displaces the resolved budget
|
||||
// after config resolution, where the displaced value is known and becomes the
|
||||
// escalation restore target. An unknown profile is a usage error listing the
|
||||
// valid names. Not to be confused with the legacy inert --profile flag.
|
||||
// The second return reports whether the profile actually filled the reasoning
|
||||
// effort (false when the user set one explicitly), so the escalation policy
|
||||
// knows the displaced effort was the provider default and can restore it.
|
||||
func applyExecProfile(options *execOptions) (execprofile.Profile, bool, error) {
|
||||
name := strings.TrimSpace(options.execProfile)
|
||||
if name == "" {
|
||||
return execprofile.Profile{}, false, nil
|
||||
}
|
||||
profile, ok := execprofile.Lookup(name)
|
||||
if !ok {
|
||||
return execprofile.Profile{}, false, execUsageError{fmt.Sprintf("unknown execution profile %q. Valid profiles: %s.", options.execProfile, strings.Join(execprofile.Names(), ", "))}
|
||||
}
|
||||
effortFilled := false
|
||||
if options.reasoningEffort == "" && profile.ReasoningEffort != "" {
|
||||
options.reasoningEffort = profile.ReasoningEffort
|
||||
effortFilled = true
|
||||
}
|
||||
// Spec-safe projection: the spec-draft path wires no self-corrector (an
|
||||
// explicit --self-correct --use-spec is rejected at parse time, before
|
||||
// profiles apply), so a profile's self-correct knob is meaningless there.
|
||||
// Project it away rather than erroring: the user asked for a thorough
|
||||
// DRAFT, and the profile's other knobs (budget, effort) apply to it fine.
|
||||
if profile.SelfCorrect && !options.useSpec {
|
||||
options.selfCorrect = true
|
||||
}
|
||||
return profile, effortFilled, nil
|
||||
}
|
||||
|
||||
// specProfileEffortFilled reports whether the profile's effort fill actually
|
||||
// governs the spec-draft run. An explicit --spec-reasoning-effort replaces the
|
||||
// filled effort for the draft, so the escalation's effort restore must not arm
|
||||
// there: escalation must never clear an effort the user pinned by hand.
|
||||
func specProfileEffortFilled(effortFilled bool, specReasoningEffort string) bool {
|
||||
return effortFilled && strings.TrimSpace(specReasoningEffort) == ""
|
||||
}
|
||||
|
||||
// applyProfileTurnBudget decides the run's turn budget once config is resolved.
|
||||
// The profile displaces the RESOLVED value, not the flag: a pinned budget
|
||||
// always wins and the profile backs off entirely, while an env/config budget
|
||||
// is the "balanced posture" a mid-run escalation can restore. pinnedMaxTurns
|
||||
// is any caller-pinned budget — an explicit --max-turns flag OR a mode
|
||||
// preset's fill (both land in options.maxTurns, and a mode outranks a profile
|
||||
// just like the flag does). displaced is 0 when nothing was displaced, so an
|
||||
// escalation leaves the ceiling untouched.
|
||||
func applyProfileTurnBudget(profile execprofile.Profile, pinnedMaxTurns int, resolvedMaxTurns int) (effective int, displaced int) {
|
||||
if profile.MaxTurns > 0 && pinnedMaxTurns == 0 {
|
||||
return profile.MaxTurns, resolvedMaxTurns
|
||||
}
|
||||
return resolvedMaxTurns, 0
|
||||
}
|
||||
|
||||
// resolveSelectedModel routes a user-supplied --model value through the model
|
||||
// registry so that fuzzy aliases (e.g. "sonnet 4.5") resolve to canonical ids
|
||||
// and deprecated models auto-redirect to their fallback. It returns the model id
|
||||
|
||||
@@ -147,6 +147,19 @@ func parseExecArgs(args []string) (execOptions, bool, error) {
|
||||
index = next
|
||||
case strings.HasPrefix(arg, "--profile="):
|
||||
options.modelProfile = strings.TrimSpace(strings.TrimPrefix(arg, "--profile="))
|
||||
case arg == "--exec-profile":
|
||||
value, next, err := nextFlagValue(args, index, arg)
|
||||
if err != nil {
|
||||
return options, false, err
|
||||
}
|
||||
options.execProfile = strings.TrimSpace(value)
|
||||
index = next
|
||||
case strings.HasPrefix(arg, "--exec-profile="):
|
||||
value, err := requiredInlineFlagValue(arg, "--exec-profile")
|
||||
if err != nil {
|
||||
return options, false, err
|
||||
}
|
||||
options.execProfile = value
|
||||
case arg == "-r" || arg == "--reasoning-effort":
|
||||
value, next, err := nextFlagValue(args, index, arg)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/execprofile"
|
||||
)
|
||||
|
||||
func TestApplyExecProfileFillsOnlyUnset(t *testing.T) {
|
||||
// Unset effort: the profile fills it and reports the fill, so the
|
||||
// escalation policy knows the displaced effort was the provider default.
|
||||
options := execOptions{execProfile: "fast"}
|
||||
profile, effortFilled, err := applyExecProfile(&options)
|
||||
if err != nil {
|
||||
t.Fatalf("applyExecProfile: %v", err)
|
||||
}
|
||||
if profile.Name != "fast" {
|
||||
t.Fatalf("profile.Name = %q, want fast", profile.Name)
|
||||
}
|
||||
if options.reasoningEffort != "low" {
|
||||
t.Fatalf("reasoningEffort = %q, want the profile's low", options.reasoningEffort)
|
||||
}
|
||||
if !effortFilled {
|
||||
t.Fatal("the profile filled the effort, so effortFilled must report it")
|
||||
}
|
||||
|
||||
// Set effort (explicit flag or a mode's fill): the profile backs off.
|
||||
options = execOptions{execProfile: "fast", reasoningEffort: "high"}
|
||||
_, effortFilled, err = applyExecProfile(&options)
|
||||
if err != nil {
|
||||
t.Fatalf("applyExecProfile: %v", err)
|
||||
}
|
||||
if options.reasoningEffort != "high" {
|
||||
t.Fatalf("reasoningEffort = %q, an explicit value must win over the profile", options.reasoningEffort)
|
||||
}
|
||||
if effortFilled {
|
||||
t.Fatal("the profile backed off, so effortFilled must be false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyExecProfileThoroughArmsSelfCorrect(t *testing.T) {
|
||||
options := execOptions{execProfile: "thorough"}
|
||||
if _, _, err := applyExecProfile(&options); err != nil {
|
||||
t.Fatalf("applyExecProfile: %v", err)
|
||||
}
|
||||
if !options.selfCorrect {
|
||||
t.Fatal("thorough must arm the post-edit self-correct loop")
|
||||
}
|
||||
if options.reasoningEffort != "high" {
|
||||
t.Fatalf("reasoningEffort = %q, want high", options.reasoningEffort)
|
||||
}
|
||||
}
|
||||
|
||||
// The spec-draft path wires no self-corrector (explicit --self-correct
|
||||
// --use-spec is rejected at parse time, before profiles apply), so a profile's
|
||||
// self-correct knob is projected away under --use-spec instead of silently
|
||||
// arming a knob the draft runner ignores. The other knobs still apply.
|
||||
func TestApplyExecProfileSpecProjectsAwaySelfCorrect(t *testing.T) {
|
||||
options := execOptions{execProfile: "thorough", useSpec: true}
|
||||
if _, _, err := applyExecProfile(&options); err != nil {
|
||||
t.Fatalf("applyExecProfile: %v", err)
|
||||
}
|
||||
if options.selfCorrect {
|
||||
t.Fatal("thorough under --use-spec must not arm self-correct (the draft runner has none)")
|
||||
}
|
||||
if options.reasoningEffort != "high" {
|
||||
t.Fatalf("reasoningEffort = %q, the profile's other knobs must still apply", options.reasoningEffort)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyExecProfileUnknownIsUsageError(t *testing.T) {
|
||||
options := execOptions{execProfile: "turbo"}
|
||||
_, _, err := applyExecProfile(&options)
|
||||
if err == nil {
|
||||
t.Fatal("expected a usage error for an unknown profile")
|
||||
}
|
||||
if _, ok := err.(execUsageError); !ok {
|
||||
t.Fatalf("expected execUsageError, got %T: %v", err, err)
|
||||
}
|
||||
for _, name := range execprofile.Names() {
|
||||
if !strings.Contains(err.Error(), name) {
|
||||
t.Fatalf("usage error must list %q, got %q", name, err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Precedence: explicit flag > --mode > --exec-profile. The deep mode fills
|
||||
// effort=high and max-turns=160; the fast profile must not override either,
|
||||
// because a mode-filled field counts as set by the time the profile runs.
|
||||
func TestExecProfilePrecedenceFlagOverModeOverProfile(t *testing.T) {
|
||||
options := execOptions{mode: "deep", execProfile: "fast"}
|
||||
if err := applyExecMode(&options); err != nil {
|
||||
t.Fatalf("applyExecMode: %v", err)
|
||||
}
|
||||
profile, _, err := applyExecProfile(&options)
|
||||
if err != nil {
|
||||
t.Fatalf("applyExecProfile: %v", err)
|
||||
}
|
||||
if options.reasoningEffort != "high" {
|
||||
t.Fatalf("reasoningEffort = %q, the mode's high must win over the profile's low", options.reasoningEffort)
|
||||
}
|
||||
// The mode filled options.maxTurns, so the turn budget must not be displaced.
|
||||
effective, displaced := applyProfileTurnBudget(profile, options.maxTurns, options.maxTurns)
|
||||
if effective != 160 || displaced != 0 {
|
||||
t.Fatalf("turn budget = (%d, displaced %d), the mode's 160 must win with nothing displaced", effective, displaced)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyProfileTurnBudget(t *testing.T) {
|
||||
fast, _ := execprofile.Lookup("fast")
|
||||
balanced, _ := execprofile.Lookup("balanced")
|
||||
|
||||
// No explicit flag: the profile displaces the resolved budget.
|
||||
if effective, displaced := applyProfileTurnBudget(fast, 0, 80); effective != 30 || displaced != 80 {
|
||||
t.Fatalf("fast over resolved 80 = (%d, %d), want (30, 80)", effective, displaced)
|
||||
}
|
||||
// Explicit --max-turns pins the budget; the profile backs off entirely.
|
||||
if effective, displaced := applyProfileTurnBudget(fast, 50, 50); effective != 50 || displaced != 0 {
|
||||
t.Fatalf("fast with explicit 50 = (%d, %d), want (50, 0)", effective, displaced)
|
||||
}
|
||||
// Balanced never displaces anything.
|
||||
if effective, displaced := applyProfileTurnBudget(balanced, 0, 80); effective != 80 || displaced != 0 {
|
||||
t.Fatalf("balanced over resolved 80 = (%d, %d), want (80, 0)", effective, displaced)
|
||||
}
|
||||
}
|
||||
|
||||
// The no-regression invariant at the options level: selecting balanced leaves
|
||||
// the options byte-identical to not selecting a profile at all, and produces
|
||||
// no loop policy.
|
||||
func TestExecProfileBalancedLeavesOptionsUntouched(t *testing.T) {
|
||||
options := execOptions{execProfile: "balanced"}
|
||||
reference := options
|
||||
profile, _, err := applyExecProfile(&options)
|
||||
if err != nil {
|
||||
t.Fatalf("applyExecProfile: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(options, reference) {
|
||||
t.Fatalf("balanced changed the options: %+v vs %+v", options, reference)
|
||||
}
|
||||
if policy := profile.Policy(80, false); policy != nil {
|
||||
t.Fatalf("balanced must produce a nil policy, got %+v", policy)
|
||||
}
|
||||
}
|
||||
|
||||
// --exec-profile= with an inline empty value must error like its two-token
|
||||
// form and the sibling --mode=, not silently run the default posture (the
|
||||
// classic shell hazard: an unset variable expanding into the inline form).
|
||||
func TestParseExecProfileInlineEmptyIsError(t *testing.T) {
|
||||
_, _, err := parseExecArgs([]string{"--exec-profile=", "hello"})
|
||||
if err == nil || !strings.Contains(err.Error(), "--exec-profile requires a value") {
|
||||
t.Fatalf("expected a required-value error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Full echo-provider run: the selected profile must be stamped into the
|
||||
// per-turn trace so benchmark attribution can group runs by posture.
|
||||
func TestRunExecTraceRecordsSelectedProfile(t *testing.T) {
|
||||
tracePath := filepath.Join(t.TempDir(), "trace.ndjson")
|
||||
exitCode, _, stderr := runExecWithEcho(t, []string{
|
||||
"exec", "--exec-profile", "fast", "--trace", tracePath, "hello",
|
||||
})
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("expected exit %d, got %d: %s", exitSuccess, exitCode, stderr)
|
||||
}
|
||||
raw, err := os.ReadFile(tracePath)
|
||||
if err != nil {
|
||||
t.Fatalf("read trace: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"profile":"fast"`) {
|
||||
t.Fatalf("trace must record the selected profile, got %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// An explicit --spec-reasoning-effort governs the spec draft's effort, so the
|
||||
// escalation effort-restore must not arm there even when the profile filled
|
||||
// the (unused) main effort.
|
||||
func TestSpecProfileEffortFilled(t *testing.T) {
|
||||
if !specProfileEffortFilled(true, "") {
|
||||
t.Fatal("no explicit spec effort: the profile's fill governs, restore must arm")
|
||||
}
|
||||
if specProfileEffortFilled(true, "high") {
|
||||
t.Fatal("an explicit spec effort must disarm the effort restore")
|
||||
}
|
||||
if specProfileEffortFilled(false, "") {
|
||||
t.Fatal("nothing filled, nothing to restore")
|
||||
}
|
||||
}
|
||||
|
||||
// The trace label deliberately records the SELECTED profile, balanced
|
||||
// included, so captures are self-describing. Run behavior stays identical
|
||||
// (balanced fills nothing and arms no policy); the label is the one intended
|
||||
// difference in the opt-in trace artifact.
|
||||
func TestRunExecTraceRecordsBalancedSelection(t *testing.T) {
|
||||
tracePath := filepath.Join(t.TempDir(), "trace.ndjson")
|
||||
exitCode, _, stderr := runExecWithEcho(t, []string{
|
||||
"exec", "--exec-profile", "balanced", "--trace", tracePath, "hello",
|
||||
})
|
||||
if exitCode != exitSuccess {
|
||||
t.Fatalf("expected exit %d, got %d: %s", exitSuccess, exitCode, stderr)
|
||||
}
|
||||
raw, err := os.ReadFile(tracePath)
|
||||
if err != nil {
|
||||
t.Fatalf("read trace: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"profile":"balanced"`) {
|
||||
t.Fatalf("trace must record the balanced selection, got %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunExecUnknownExecProfileIsUsageError(t *testing.T) {
|
||||
exitCode, _, stderr := runExecWithEcho(t, []string{
|
||||
"exec", "--exec-profile", "turbo", "hello",
|
||||
})
|
||||
if exitCode != exitUsage {
|
||||
t.Fatalf("expected usage exit %d, got %d", exitUsage, exitCode)
|
||||
}
|
||||
if !strings.Contains(stderr, "unknown execution profile") {
|
||||
t.Fatalf("expected an unknown-profile usage error, got %q", stderr)
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,11 @@ type execSpecDraftRun struct {
|
||||
reasoningEffort string
|
||||
specPermissionMode agent.PermissionMode
|
||||
notifier *notify.Notifier
|
||||
// profilePolicy carries the selected execution profile's escalation policy
|
||||
// (nil when none): the profile displaces resolved.MaxTurns before this
|
||||
// path branches off, so the spec-draft run needs the same safety net as
|
||||
// the main run.
|
||||
profilePolicy *agent.ProfilePolicy
|
||||
}
|
||||
|
||||
type execSpecDraftInfo struct {
|
||||
@@ -119,6 +124,7 @@ func runExecSpecDraft(run execSpecDraftRun) int {
|
||||
ProviderName: run.resolved.Provider.Name,
|
||||
Model: run.resolved.Provider.Model,
|
||||
ReasoningEffort: run.reasoningEffort,
|
||||
Profile: run.profilePolicy,
|
||||
Cwd: run.workspaceRoot,
|
||||
SystemPrompt: specmode.DraftSystemPrompt,
|
||||
Images: run.images,
|
||||
|
||||
@@ -549,10 +549,14 @@ func TestExecMemberAutoToolListIncludesMutators(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRunExecAcceptsLegacyModelProfileFlags(t *testing.T) {
|
||||
// --profile (legacy, inert model profile) and --exec-profile (execution
|
||||
// profile) are distinct flags and must coexist on one invocation.
|
||||
exitCode, stdout, stderr := runExecWithEcho(t, []string{
|
||||
"exec",
|
||||
"--profile",
|
||||
"fast",
|
||||
"--exec-profile",
|
||||
"balanced",
|
||||
"--reasoning-effort",
|
||||
"low",
|
||||
"hello",
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -153,6 +153,9 @@ 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
|
||||
}
|
||||
@@ -166,7 +169,8 @@ func (writer *execEventWriter) toolResult(result agent.ToolResult) {
|
||||
return
|
||||
}
|
||||
if writer.format == execOutputStreamJSON {
|
||||
output, truncated := truncateForStreamJSONOutput(result.Output)
|
||||
output, surfaceTruncated := truncateForStreamJSONOutput(result.Output)
|
||||
truncated := result.Truncated || surfaceTruncated
|
||||
event := streamjson.Event{
|
||||
Type: streamjson.EventToolResult,
|
||||
RunID: writer.runID,
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
@@ -16,3 +20,35 @@ 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
// Package execprofile defines named execution profiles: bundles of loop-posture
|
||||
// knobs (turn budget, reasoning effort, self-correction, escalation triggers)
|
||||
// that tune how hard the agent loop tries, independent of which model runs.
|
||||
// Profiles COMPOSE with --mode: a mode answers "what runs" (model, tools),
|
||||
// a profile answers "how hard the loop tries". Selection precedence is
|
||||
// explicit flag > --mode > profile, enforced by the callers applying profiles
|
||||
// last with the same fill-only-if-unset rule modes use.
|
||||
//
|
||||
// The catalog is deliberately tiny and value-stable: balanced is the empty
|
||||
// profile (selecting it leaves the run's options, budget, and loop behavior
|
||||
// identical to an unflagged run — asserted by test; only the opt-in trace and
|
||||
// bench artifacts record the selected profile name, by design, so captures are
|
||||
// self-describing), fast trades turn budget and effort for latency with a
|
||||
// one-shot escalation back to the displaced posture as the safety net, and
|
||||
// thorough raises the budget and arms full self-correction.
|
||||
package execprofile
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
)
|
||||
|
||||
// Profile is a named execution posture. Zero-valued fields inherit the run's
|
||||
// existing value (flag, mode, config, or built-in default) — a profile only
|
||||
// ever fills knobs the caller left unset, except MaxTurns which REPLACES the
|
||||
// resolved budget (that displacement is what escalation restores).
|
||||
type Profile struct {
|
||||
// Name identifies the profile in traces, help text, and diagnostics.
|
||||
Name string
|
||||
// MaxTurns replaces the resolved per-run tool-turn budget when > 0 and the
|
||||
// user did not pass an explicit --max-turns. The displaced resolved value
|
||||
// becomes the escalation target (see Policy).
|
||||
MaxTurns int
|
||||
// ReasoningEffort fills the run's effort when the user left it unset. It
|
||||
// still flows through the model-supported gating at the call site, so an
|
||||
// unsupported level degrades exactly like an explicit flag would.
|
||||
ReasoningEffort string
|
||||
// SelfCorrect arms the post-edit verify-and-correct loop. Profiles can only
|
||||
// turn it ON (the flag is presence-only, so false is indistinguishable from
|
||||
// unset and must never override an explicit opt-in).
|
||||
SelfCorrect bool
|
||||
|
||||
// Escalation triggers. All zero means the profile never escalates and
|
||||
// Policy returns nil (the loop stays byte-identical to a nil-profile run).
|
||||
// Targets are NOT part of the catalog: escalation restores the values the
|
||||
// profile displaced at selection time, never invented ones, so targets are
|
||||
// stamped by Policy from what the caller measured.
|
||||
EscalateOnToolFailureStreak int
|
||||
EscalateOnCompletionUncertain int
|
||||
EscalateOnSelfCorrectFailure bool
|
||||
EscalateOnRiskyMutation sandbox.RiskLevel
|
||||
}
|
||||
|
||||
// The catalog. Values marked provisional are floors/ceilings derived from the
|
||||
// Phase 0 baseline's read-class evidence (successful nav runs used single-digit
|
||||
// turns; the mutating classes produced no successful samples to tune from) and
|
||||
// are expected to be re-tuned from the post-oracle-fix re-capture. The
|
||||
// escalation safety net is what makes shipping provisional Fast values safe:
|
||||
// a run that hits trouble gets its displaced budget back mid-run.
|
||||
var (
|
||||
// Balanced is the default posture: empty on purpose. Selecting it changes
|
||||
// nothing — the no-regression invariant of the whole feature.
|
||||
Balanced = Profile{Name: "balanced"}
|
||||
|
||||
// Fast starts cheap (30 turns, low effort) and arms every escalation
|
||||
// trigger so a struggling run restores its displaced posture: two
|
||||
// same-tool retriable failures in a row, a second uncertain completion
|
||||
// evaluation, a failing self-correction cycle, or a critical-risk
|
||||
// mutation. The risk threshold is deliberately Critical, not High: the
|
||||
// sandbox classifies EVERY shell command as at least high risk before any
|
||||
// command analysis, so a High trigger would fire on the first `go test`
|
||||
// of virtually any coding task and spend the one-shot on turn one.
|
||||
// Critical marks the genuinely scary categories (destructive commands,
|
||||
// piped installers, out-of-workspace writes, network mutations).
|
||||
Fast = Profile{
|
||||
Name: "fast",
|
||||
MaxTurns: 30,
|
||||
ReasoningEffort: "low",
|
||||
EscalateOnToolFailureStreak: 2,
|
||||
EscalateOnCompletionUncertain: 2,
|
||||
EscalateOnSelfCorrectFailure: true,
|
||||
EscalateOnRiskyMutation: sandbox.RiskCritical,
|
||||
}
|
||||
|
||||
// Thorough doubles the default budget, asks for high effort, and arms the
|
||||
// full post-edit verify-and-correct loop. Already the maximum posture, so
|
||||
// it has nothing to escalate to.
|
||||
Thorough = Profile{
|
||||
Name: "thorough",
|
||||
MaxTurns: 160,
|
||||
ReasoningEffort: "high",
|
||||
SelfCorrect: true,
|
||||
}
|
||||
)
|
||||
|
||||
var catalog = map[string]Profile{
|
||||
Balanced.Name: Balanced,
|
||||
Fast.Name: Fast,
|
||||
Thorough.Name: Thorough,
|
||||
}
|
||||
|
||||
// Lookup resolves a profile by name, case-insensitively and ignoring
|
||||
// surrounding whitespace.
|
||||
func Lookup(name string) (Profile, bool) {
|
||||
profile, ok := catalog[strings.ToLower(strings.TrimSpace(name))]
|
||||
return profile, ok
|
||||
}
|
||||
|
||||
// Names returns the catalog's profile names, sorted, for usage errors and help.
|
||||
func Names() []string {
|
||||
names := make([]string, 0, len(catalog))
|
||||
for name := range catalog {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
// Policy projects the profile into the loop-facing ProfilePolicy.
|
||||
//
|
||||
// displacedMaxTurns is the turn budget this profile displaced at selection time
|
||||
// (what the run would have used without it) and becomes the escalation's
|
||||
// restore target; pass 0 when the profile did not displace the budget (e.g.
|
||||
// the user pinned --max-turns explicitly) so escalation leaves the ceiling
|
||||
// untouched. effortFilled reports whether the profile actually filled the
|
||||
// run's reasoning effort (it backs off when the user set one explicitly); the
|
||||
// displaced effort is then "" (the provider default) by construction, which
|
||||
// the escalation restores via RestoreDefaultEffort — a plain "" target would
|
||||
// mean "leave untouched".
|
||||
//
|
||||
// Profiles with no armed triggers return nil: the loop treats a nil policy as
|
||||
// "no profile" (no observation, no counters), which keeps balanced and
|
||||
// thorough runs byte-identical to the same knob values set by hand.
|
||||
func (p Profile) Policy(displacedMaxTurns int, effortFilled bool) *agent.ProfilePolicy {
|
||||
if p.EscalateOnToolFailureStreak == 0 && p.EscalateOnCompletionUncertain == 0 &&
|
||||
!p.EscalateOnSelfCorrectFailure && p.EscalateOnRiskyMutation == "" {
|
||||
return nil
|
||||
}
|
||||
return &agent.ProfilePolicy{
|
||||
Name: p.Name,
|
||||
Escalate: &agent.PostureEscalation{
|
||||
MaxTurns: displacedMaxTurns,
|
||||
RestoreDefaultEffort: effortFilled,
|
||||
OnToolFailureStreak: p.EscalateOnToolFailureStreak,
|
||||
OnCompletionUncertain: p.EscalateOnCompletionUncertain,
|
||||
OnSelfCorrectFailure: p.EscalateOnSelfCorrectFailure,
|
||||
OnRiskyMutation: p.EscalateOnRiskyMutation,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package execprofile
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
)
|
||||
|
||||
// The no-regression invariant of the whole feature: balanced must be the empty
|
||||
// profile (nothing but a name), so selecting it cannot change a run.
|
||||
func TestBalancedProfileIsEmpty(t *testing.T) {
|
||||
profile, ok := Lookup("balanced")
|
||||
if !ok {
|
||||
t.Fatal("balanced must exist in the catalog")
|
||||
}
|
||||
if profile.Name != "balanced" {
|
||||
t.Fatalf("Name = %q, want %q", profile.Name, "balanced")
|
||||
}
|
||||
profile.Name = ""
|
||||
if !reflect.DeepEqual(profile, Profile{}) {
|
||||
t.Fatalf("balanced must be zero-valued apart from its name, got %+v", profile)
|
||||
}
|
||||
if policy := Balanced.Policy(80, false); policy != nil {
|
||||
t.Fatalf("balanced Policy must be nil (byte-identical loop), got %+v", policy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupIsCaseAndSpaceInsensitive(t *testing.T) {
|
||||
for _, name := range []string{"fast", "Fast", " FAST "} {
|
||||
if _, ok := Lookup(name); !ok {
|
||||
t.Fatalf("Lookup(%q) should resolve", name)
|
||||
}
|
||||
}
|
||||
if _, ok := Lookup("turbo"); ok {
|
||||
t.Fatal("Lookup(turbo) should not resolve")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNamesAreSorted(t *testing.T) {
|
||||
want := []string{"balanced", "fast", "thorough"}
|
||||
if got := Names(); !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("Names() = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Escalation targets must be the displaced values only — the catalog carries
|
||||
// triggers, the call site supplies what was displaced, and nothing else may
|
||||
// appear in the escalation.
|
||||
func TestFastPolicyTargetsDisplacedValuesOnly(t *testing.T) {
|
||||
policy := Fast.Policy(80, true)
|
||||
if policy == nil {
|
||||
t.Fatal("fast must arm an escalation policy")
|
||||
}
|
||||
if policy.Name != "fast" {
|
||||
t.Fatalf("policy.Name = %q, want fast", policy.Name)
|
||||
}
|
||||
esc := policy.Escalate
|
||||
if esc == nil {
|
||||
t.Fatal("fast policy must carry an escalation")
|
||||
}
|
||||
if esc.MaxTurns != 80 {
|
||||
t.Fatalf("Escalate.MaxTurns = %d, want the displaced 80", esc.MaxTurns)
|
||||
}
|
||||
if esc.ReasoningEffort != "" {
|
||||
t.Fatalf("Escalate.ReasoningEffort = %q, want empty (displaced effort is the provider default by construction)", esc.ReasoningEffort)
|
||||
}
|
||||
if !esc.RestoreDefaultEffort {
|
||||
t.Fatal("effort was profile-filled, so escalation must restore the provider default")
|
||||
}
|
||||
if esc.RestoreCompletionGate {
|
||||
t.Fatal("fast does not touch the completion gate, so escalation must not either")
|
||||
}
|
||||
if esc.OnToolFailureStreak != Fast.EscalateOnToolFailureStreak ||
|
||||
esc.OnCompletionUncertain != Fast.EscalateOnCompletionUncertain ||
|
||||
esc.OnSelfCorrectFailure != Fast.EscalateOnSelfCorrectFailure ||
|
||||
esc.OnRiskyMutation != Fast.EscalateOnRiskyMutation {
|
||||
t.Fatalf("escalation triggers must mirror the catalog, got %+v", esc)
|
||||
}
|
||||
}
|
||||
|
||||
// The effort restore arms only when the profile actually filled the effort:
|
||||
// with an explicit user effort the profile backed off, so escalation must not
|
||||
// clear the user's choice.
|
||||
func TestFastPolicyExplicitEffortIsNeverRestored(t *testing.T) {
|
||||
policy := Fast.Policy(80, false)
|
||||
if policy == nil || policy.Escalate == nil {
|
||||
t.Fatal("fast must still arm its triggers")
|
||||
}
|
||||
if policy.Escalate.RestoreDefaultEffort {
|
||||
t.Fatal("the profile did not fill the effort, so escalation must leave it alone")
|
||||
}
|
||||
}
|
||||
|
||||
// A profile that did not displace the budget (explicit --max-turns pinned it)
|
||||
// must not let escalation move the ceiling at all.
|
||||
func TestFastPolicyZeroDisplacedLeavesCeilingUntouched(t *testing.T) {
|
||||
policy := Fast.Policy(0, false)
|
||||
if policy == nil || policy.Escalate == nil {
|
||||
t.Fatal("fast must still arm its triggers with a zero displaced budget")
|
||||
}
|
||||
if policy.Escalate.MaxTurns != 0 {
|
||||
t.Fatalf("Escalate.MaxTurns = %d, want 0 (no displaced value to restore)", policy.Escalate.MaxTurns)
|
||||
}
|
||||
}
|
||||
|
||||
// Thorough is already the maximum posture: no triggers, so no policy — the
|
||||
// loop must stay byte-identical to the same knobs set by hand.
|
||||
func TestThoroughPolicyIsNil(t *testing.T) {
|
||||
if policy := Thorough.Policy(80, true); policy != nil {
|
||||
t.Fatalf("thorough Policy must be nil, got %+v", policy)
|
||||
}
|
||||
}
|
||||
|
||||
// Pin the catalog's provisional values so a retune is a deliberate,
|
||||
// test-visible diff (these floors came from the Phase 0 baseline's read-class
|
||||
// evidence and are expected to move after the post-oracle-fix re-capture).
|
||||
func TestCatalogProvisionalValues(t *testing.T) {
|
||||
if Fast.MaxTurns != 30 || Fast.ReasoningEffort != "low" || Fast.SelfCorrect {
|
||||
t.Fatalf("fast knobs changed: %+v", Fast)
|
||||
}
|
||||
// Critical, not High: the sandbox classifies every shell command as at
|
||||
// least high risk, so a High trigger would spend the one-shot escalation
|
||||
// on the first benign `go test` of any coding task.
|
||||
if Fast.EscalateOnRiskyMutation != sandbox.RiskCritical {
|
||||
t.Fatalf("fast risky-mutation trigger = %q, want %q", Fast.EscalateOnRiskyMutation, sandbox.RiskCritical)
|
||||
}
|
||||
if Thorough.MaxTurns != 160 || Thorough.ReasoningEffort != "high" || !Thorough.SelfCorrect {
|
||||
t.Fatalf("thorough knobs changed: %+v", Thorough)
|
||||
}
|
||||
}
|
||||
@@ -133,6 +133,26 @@ func discoverAuthorizationServer(ctx context.Context, client *http.Client, baseU
|
||||
}, nil
|
||||
}
|
||||
|
||||
// validateResolvedEndpoints applies the shared https/loopback endpoint rule to
|
||||
// every non-empty endpoint in the resolved metadata — configured or discovered
|
||||
// alike — so discovery metadata can never downgrade an MCP login to an insecure
|
||||
// or attacker-controlled authorization, token, or registration endpoint.
|
||||
func validateResolvedEndpoints(metadata authServerMetadata) error {
|
||||
for _, ep := range []struct{ kind, url string }{
|
||||
{"authorization", metadata.AuthorizationEndpoint},
|
||||
{"token", metadata.TokenEndpoint},
|
||||
{"registration", metadata.RegistrationEndpoint},
|
||||
} {
|
||||
if strings.TrimSpace(ep.url) == "" {
|
||||
continue
|
||||
}
|
||||
if err := oauth.ValidateEndpointURL(ep.url); err != nil {
|
||||
return fmt.Errorf("mcp oauth: %s endpoint: %w", ep.kind, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveAuthorizationServer discovers metadata and applies explicit config
|
||||
// overrides. Configured endpoints take precedence over discovered ones, and act
|
||||
// as a fallback when discovery fails or omits a value.
|
||||
@@ -148,6 +168,9 @@ func resolveAuthorizationServer(ctx context.Context, client *http.Client, baseUR
|
||||
TokenEndpoint: strings.TrimSpace(cfg.TokenEndpoint),
|
||||
RegistrationEndpoint: strings.TrimSpace(cfg.RegistrationEndpoint),
|
||||
}
|
||||
if err := validateResolvedEndpoints(metadata); err != nil {
|
||||
return authServerMetadata{}, err
|
||||
}
|
||||
return metadata, nil
|
||||
}
|
||||
|
||||
@@ -179,6 +202,9 @@ func resolveAuthorizationServer(ctx context.Context, client *http.Client, baseUR
|
||||
if strings.TrimSpace(metadata.TokenEndpoint) == "" {
|
||||
return authServerMetadata{}, errors.New("no token endpoint discovered or configured")
|
||||
}
|
||||
if err := validateResolvedEndpoints(metadata); err != nil {
|
||||
return authServerMetadata{}, err
|
||||
}
|
||||
return metadata, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -73,6 +73,22 @@ func TestResolveEndpointsFallsBackToConfig(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Discovered MCP metadata that serves an insecure endpoint must be rejected
|
||||
// before use, so discovery cannot downgrade the MCP login to an
|
||||
// attacker-controlled origin (issue #511).
|
||||
func TestResolveAuthorizationServerRejectsInsecureDiscovered(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
mux.HandleFunc("/.well-known/oauth-authorization-server", func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"issuer":"` + server.URL + `","token_endpoint":"` + server.URL + `/token","authorization_endpoint":"http://evil.example/authorize"}`))
|
||||
})
|
||||
_, err := resolveAuthorizationServer(context.Background(), server.Client(), server.URL, OAuthConfig{})
|
||||
if err == nil || !strings.Contains(err.Error(), "authorization endpoint") {
|
||||
t.Fatalf("err = %v, want insecure authorization endpoint rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
// roundTripperFunc adapts a function to http.RoundTripper so a test can
|
||||
// observe every outbound request.
|
||||
type roundTripperFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
@@ -12,6 +12,54 @@ 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"))
|
||||
@@ -393,6 +441,221 @@ 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")
|
||||
|
||||
+21
-5
@@ -39,6 +39,13 @@ func BuildAuthorizationURL(cfg Config, pkce PKCE, state, redirectURI string, ext
|
||||
if endpoint == "" {
|
||||
return "", errors.New("oauth: no authorization endpoint configured")
|
||||
}
|
||||
// Backstop: validate the authorization endpoint at the shared choke point
|
||||
// both the provider and MCP flows build their browser URL through, so a
|
||||
// discovery-downgraded endpoint can never open in the browser even if a
|
||||
// merge site missed it.
|
||||
if err := ValidateEndpointURL(endpoint); err != nil {
|
||||
return "", err
|
||||
}
|
||||
parsed, err := url.Parse(endpoint)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("oauth: parse authorization endpoint: %w", err)
|
||||
@@ -83,13 +90,15 @@ func isReservedAuthParam(key string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// validateTokenEndpoint refuses to send a credential to a token endpoint that is
|
||||
// not https, unless it is a loopback host (mirrors oauth2-client.js
|
||||
// validateTokenEndpointURL). This prevents leaking codes/tokens over cleartext.
|
||||
func validateTokenEndpoint(endpoint string) error {
|
||||
// ValidateEndpointURL refuses an OAuth endpoint that is not https, unless it is
|
||||
// a loopback host (mirrors oauth2-client.js validateTokenEndpointURL). Every
|
||||
// credential-bearing endpoint — configured OR learned from discovery — must
|
||||
// pass this single rule, so discovery metadata can never downgrade a login to
|
||||
// cleartext or redirect it to an attacker-controlled http origin.
|
||||
func ValidateEndpointURL(endpoint string) error {
|
||||
parsed, err := url.Parse(trimmed(endpoint))
|
||||
if err != nil || parsed.Host == "" {
|
||||
return fmt.Errorf("oauth: invalid token endpoint %q", endpoint)
|
||||
return fmt.Errorf("oauth: invalid endpoint %q", endpoint)
|
||||
}
|
||||
if parsed.Scheme == "https" {
|
||||
return nil
|
||||
@@ -100,6 +109,13 @@ func validateTokenEndpoint(endpoint string) error {
|
||||
return fmt.Errorf("%w: %s", ErrInsecureTokenEndpoint, parsed.Scheme+"://"+parsed.Host)
|
||||
}
|
||||
|
||||
// validateTokenEndpoint checks a token endpoint against the shared endpoint rule
|
||||
// (kept as a named helper for the token-exchange and device call sites). This
|
||||
// prevents leaking codes/tokens over cleartext.
|
||||
func validateTokenEndpoint(endpoint string) error {
|
||||
return ValidateEndpointURL(endpoint)
|
||||
}
|
||||
|
||||
func isLoopbackHost(host string) bool {
|
||||
if host == "localhost" {
|
||||
return true
|
||||
|
||||
@@ -112,6 +112,20 @@ func TestBuildAuthorizationURLRejectsPlain(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The shared choke point both flows build their browser URL through must refuse
|
||||
// an insecure authorization endpoint, so a discovery-downgraded endpoint can
|
||||
// never open in the browser even if a merge site missed it (issue #511).
|
||||
func TestBuildAuthorizationURLRejectsInsecureEndpoint(t *testing.T) {
|
||||
_, err := BuildAuthorizationURL(
|
||||
Config{AuthorizationEndpoint: "http://evil.example/authorize", ClientID: "c"},
|
||||
PKCE{Method: MethodS256, Challenge: "chal"},
|
||||
"state", "http://127.0.0.1/cb", nil,
|
||||
)
|
||||
if !errors.Is(err, ErrInsecureTokenEndpoint) {
|
||||
t.Fatalf("err = %v, want ErrInsecureTokenEndpoint", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTokenEndpoint(t *testing.T) {
|
||||
cases := map[string]bool{
|
||||
"https://auth.example.com/token": true,
|
||||
|
||||
@@ -218,13 +218,26 @@ func (m *Manager) resolveEndpoints(ctx context.Context, cfg Config) (Config, err
|
||||
// may already be sufficient for the chosen flow. Only merge on success (and
|
||||
// never return from the error branch, which would trip the nilerr linter).
|
||||
if meta, err := m.discover(ctx, cfg.IssuerURL); err == nil {
|
||||
if cfg.AuthorizationEndpoint == "" {
|
||||
// A discovered endpoint must pass the same https/loopback rule as a
|
||||
// configured one before it is merged, so discovery metadata can never
|
||||
// silently downgrade the login to an insecure or attacker-controlled
|
||||
// endpoint. Fail closed on a bad endpoint rather than merging it.
|
||||
if cfg.AuthorizationEndpoint == "" && meta.AuthorizationEndpoint != "" {
|
||||
if err := ValidateEndpointURL(meta.AuthorizationEndpoint); err != nil {
|
||||
return cfg, fmt.Errorf("oauth: discovered authorization endpoint: %w", err)
|
||||
}
|
||||
cfg.AuthorizationEndpoint = meta.AuthorizationEndpoint
|
||||
}
|
||||
if cfg.TokenEndpoint == "" {
|
||||
if cfg.TokenEndpoint == "" && meta.TokenEndpoint != "" {
|
||||
if err := ValidateEndpointURL(meta.TokenEndpoint); err != nil {
|
||||
return cfg, fmt.Errorf("oauth: discovered token endpoint: %w", err)
|
||||
}
|
||||
cfg.TokenEndpoint = meta.TokenEndpoint
|
||||
}
|
||||
if cfg.DeviceAuthorizationEndpoint == "" {
|
||||
if cfg.DeviceAuthorizationEndpoint == "" && meta.DeviceAuthorizationEndpoint != "" {
|
||||
if err := ValidateEndpointURL(meta.DeviceAuthorizationEndpoint); err != nil {
|
||||
return cfg, fmt.Errorf("oauth: discovered device authorization endpoint: %w", err)
|
||||
}
|
||||
cfg.DeviceAuthorizationEndpoint = meta.DeviceAuthorizationEndpoint
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package oauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -200,6 +201,24 @@ func TestManagerGetFreshDiscoversIssuerEndpoints(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A discovery document that serves an insecure (non-https, non-loopback)
|
||||
// endpoint must be rejected before it is merged, so discovery metadata cannot
|
||||
// silently downgrade the login to an attacker-controlled origin (issue #511).
|
||||
func TestResolveEndpointsRejectsInsecureDiscoveredEndpoint(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
mux.HandleFunc("/.well-known/oauth-authorization-server", func(w http.ResponseWriter, _ *http.Request) {
|
||||
// Loopback issuer, but an attacker-controlled http authorization endpoint.
|
||||
_, _ = io.WriteString(w, `{"issuer":"`+server.URL+`","token_endpoint":"`+server.URL+`/token","authorization_endpoint":"http://evil.example/authorize"}`)
|
||||
})
|
||||
m := managerFor(t, map[string]string{}, nil)
|
||||
_, err := m.resolveEndpoints(context.Background(), Config{IssuerURL: server.URL})
|
||||
if !errors.Is(err, ErrInsecureTokenEndpoint) {
|
||||
t.Fatalf("resolveEndpoints err = %v, want ErrInsecureTokenEndpoint", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerGetFreshRefreshesExpired(t *testing.T) {
|
||||
fp := newFakeProvider(t, `{"access_token":"fresh-at","expires_in":3600}`)
|
||||
env := map[string]string{
|
||||
|
||||
+120
-35
@@ -10,16 +10,19 @@ matters for months, so the contract is written down here.
|
||||
|
||||
48 tasks across seven classes:
|
||||
|
||||
| class | count | oracle | tier |
|
||||
|-----------|-------|-----------------------------|-------------|
|
||||
| nav | 10 | none | latency |
|
||||
| edit | 10 | substring grep | correctness |
|
||||
| fix | 8 | scoped `go test -run <name>` | correctness |
|
||||
| refactor | 6 | `go build ./...` | build |
|
||||
| longproc | 4 | none | latency |
|
||||
| longctx | 4 | none | latency |
|
||||
| parallel | 6 | none | latency |
|
||||
| **total** | **48**| | |
|
||||
| class | count | oracle | tier |
|
||||
|-----------|-------|-----------------------------------------|-------------|
|
||||
| nav | 10 | grep on captured final answer | correctness |
|
||||
| edit | 10 | stamped `oracle_test.go` + `go test` | correctness |
|
||||
| fix | 8 | scoped `go test -run <name>` | correctness |
|
||||
| refactor | 6 | stamped `oracle_test.go` + `go test` | correctness |
|
||||
| longproc | 4 | none | latency |
|
||||
| longctx | 4 | none | latency |
|
||||
| parallel | 6 | none | latency |
|
||||
| **total** | **48**| | |
|
||||
|
||||
Tier counts: **correctness 34** (10 edit + 8 fix + 10 nav + 6 refactor),
|
||||
**build 0**, **latency 14** (4 longproc + 4 longctx + 6 parallel).
|
||||
|
||||
## Oracle tiers
|
||||
|
||||
@@ -27,16 +30,21 @@ Pass/fail is reported per tier so the report cannot be misread as a blanket
|
||||
correctness verdict. The tier is decided per task from oracle presence and the
|
||||
manifest's `buildOnlyClasses` list:
|
||||
|
||||
- **Correctness** (18 tasks: 10 edit + 8 fix) — a positive oracle (substring
|
||||
grep that the requested change landed, or a scoped `go test -run` that the
|
||||
bug fix passes). This is the only pass rate that can move with model quality:
|
||||
`tasksVerified` / `tasksPassed` / `correctnessPassRate`.
|
||||
- **Build-only** (6 tasks: refactor) — `go build ./...` proves the edit
|
||||
compiles, not that the refactor achieved its goal (a no-op refactor passes).
|
||||
The manifest declares `buildOnlyClasses: ["refactor"]`. Reported as
|
||||
`buildCheckedTasks` / `buildPassedTasks` / `buildPassRate`, never in
|
||||
`correctnessPassRate`.
|
||||
- **Latency-only** (24 tasks: nav, longproc, longctx, parallel) — no
|
||||
- **Correctness** (34 tasks: edit, fix, nav, refactor) — a positive oracle. For
|
||||
edit/refactor the harness stamps an `oracle_test.go` (from the task's
|
||||
`oracleTest` field) into the fixture copy **after** the agent run, then runs
|
||||
`go test ./...`: the Go compiler is the structural verifier, so a no-op
|
||||
refactor, a missing field, or a reworded-but-not-removed line fails to
|
||||
compile/run. fix uses a scoped `go test -run <name>`. nav greps the agent's
|
||||
captured final answer (`.zero-answer.txt`) for determinable facts. This is
|
||||
the only pass rate that can move with model quality: `tasksVerified` /
|
||||
`tasksPassed` / `correctnessPassRate`.
|
||||
- **Build-only** (0 tasks) — the tier is empty. refactor used to live here with
|
||||
a non-positive `go build ./...` (a no-op refactor passed); PR #712 gave it a
|
||||
structural oracle, graduating it to correctness. `buildOnlyClasses` is `[]`.
|
||||
`buildCheckedTasks` / `buildPassedTasks` / `buildPassRate` are reported as 0
|
||||
and never in `correctnessPassRate`.
|
||||
- **Latency-only** (14 tasks: longproc, longctx, parallel) — no
|
||||
`verificationCommand`. An exit 0 only proves the turn ran, not that the answer
|
||||
was right. They contribute to latency and span attribution only and are
|
||||
counted in `latencyOnlyTasks`, never in any pass rate.
|
||||
@@ -45,22 +53,89 @@ A task's tier is driven by oracle **presence** first: a task with no
|
||||
`verificationCommand` is latency-only even if its class is listed in
|
||||
`buildOnlyClasses`, so a missing oracle can never silently pass on exit 0.
|
||||
|
||||
## Known limitations (deferred)
|
||||
### How the stamped oracle and captured answer work
|
||||
|
||||
These are accepted for the Phase 0 baseline and tracked in a follow-up issue;
|
||||
they do not block the baseline because the tier split keeps the report honest:
|
||||
The production runner (`NewTurnExecRunner` in `turn_bench.go`) copies the fixture
|
||||
into an isolated temp dir, runs `zero exec --trace`, then — before running the
|
||||
`verificationCommand` — stamps two files into the copy:
|
||||
|
||||
- The edit grep oracles are substring checks. A rename oracle asserts the new
|
||||
name is present AND the old name is gone (compound `bash -c`), but an
|
||||
add-field oracle (`grep -R Label .`) only proves the string appears, not that
|
||||
it landed on the right struct. Strengthening these to `go vet`/`go build` +
|
||||
structural assertions is follow-up work.
|
||||
- The refactor build oracle is non-positive (a no-op refactor passes). A
|
||||
structural verifier that proves the refactor happened is follow-up work;
|
||||
until then refactor is `buildPassRate`, not `correctnessPassRate`.
|
||||
- The 24 read-only tasks have no oracle. Adding deterministic oracles for nav
|
||||
(diff against an expected answer) and documenting longproc/longctx/parallel
|
||||
as permanently latency-only is follow-up work.
|
||||
- `oracle_test.go` from `task.OracleTest` (edit/refactor). It is stamped *after*
|
||||
the agent run so it can't interfere with the agent's own `go build`/`go test`
|
||||
during the task (refactor-03's `package zeroapp` test would break a pre-rename
|
||||
build) and can't be pre-seen or tampered with. The `verificationCommand`
|
||||
(`go test ./...`) then compiles and runs it.
|
||||
- `.zero-answer.txt` from the `{"type":"final","text":...}` event in the
|
||||
stream-json output (nav). The `verificationCommand` is a compound `bash -c`
|
||||
grep requiring the determinable facts (e.g. nav-09 requires the answer to
|
||||
mention `port`, `name`, and `retries`). An empty file (no `final` event) makes
|
||||
the grep fail, which is the correct outcome for a run that produced no answer.
|
||||
|
||||
The `oracleTest` source uses compile-time references where the assertion is
|
||||
structural (`var _ = Config{}.Label` for edit-03, `var _ = formatGreeting` for
|
||||
refactor-01) and behavioral `Test…` functions where the assertion is a value
|
||||
(edit-04 `Version != "1.1.0"`, edit-10 `greet("x") != "hello, world"`, edit-09
|
||||
`load("nonexistent")` wrapping). Tasks whose oracle is a plain grep/build with
|
||||
no stamped test (edit-02, refactor-02) carry no `oracleTest`.
|
||||
|
||||
## Known limitations
|
||||
|
||||
These are accepted for the Phase 0 baseline; the tier split keeps the report
|
||||
honest where they apply:
|
||||
|
||||
- **longproc / longctx / parallel are permanently latency-only.** There is no
|
||||
deterministic oracle for "report whether the build succeeds", "summarize a
|
||||
large generated file", or "report the first line of each of six files" — the
|
||||
answer is free-form prose and a contains-check would rubber-stamp. They stay
|
||||
in `latencyOnlyTasks` and never enter a pass rate.
|
||||
- **nav count tasks (nav-01, nav-04, nav-05, nav-08) require a structured,
|
||||
line-anchored `count: N` token with the fixture's exact expected count AND a
|
||||
workspace-derived fact that proves the agent actually inspected the fixture —
|
||||
not the count alone.** Their prompts ask the agent to state the count as
|
||||
`count: N` on its own line and to name the thing(s) counted, and the oracle
|
||||
greps for an anchored `^count: N$` with the exact expected N plus a named fact:
|
||||
nav-01 requires `count: 5` and names `main.go`, `config.json`, and `README.md`
|
||||
(the three the oracle anchors on — a correct answer lists all five files, but
|
||||
the verification contract only requires those three be named); nav-04 names
|
||||
the one test function (`TestGreet` from `main_test.go`); nav-05 names the file
|
||||
holding the one TODO (`main.go`); nav-08 names both stdlib imports (`fmt` from
|
||||
`main.go`, `testing` from `main_test.go`). A substring like "the count: 1" or a
|
||||
wrong count like "count: 3" fails the anchor; a bare "count: 1" with no named
|
||||
fact fails the inspection check. Non-zero ground truth alone was not enough —
|
||||
it closes the always-guess-zero game but opens an always-guess-one game (an
|
||||
agent that never opens the workspace and emits "count: 1" would pass a
|
||||
count-only oracle). The named-fact requirement closes that surface: a blind
|
||||
count is no longer sufficient, the agent must commit to a fact only readable
|
||||
from the fixture. nav-04 and nav-05 were given a real `TestGreet` and a real
|
||||
TODO so the named fact exists to require; nav-08 stays at count 0 (a real
|
||||
third-party import would need a network fetch and break the offline suite), so
|
||||
its named fact is the stdlib imports the agent must enumerate across every file
|
||||
to conclude 0 third-party. The named facts are not all equally tight. nav-04's
|
||||
`TestGreet` is a specific, non-generic identifier a blind agent cannot produce
|
||||
without reading `main.go` to learn a greet function exists, so it fully closes
|
||||
the blind-count-one surface. nav-05's `main.go` is the conventional Go
|
||||
entrypoint and thus the most guessable filename, so it *reduces* rather than
|
||||
fully closes that surface — a blind "count: 1, in main.go" can still pass.
|
||||
Accepted for a Phase 0 baseline: the blind floor on nav-05 is below 100% (a
|
||||
bare "count: 1" fails), so a model still moves the pass rate by actually
|
||||
inspecting; strengthening it further (e.g. requiring the TODO's surrounding
|
||||
symbol) would trade output freedom for determinism and risks false-failing a
|
||||
terse-but-correct answer. nav-08's `testing` half is the tighter of its two:
|
||||
`fmt` alone is the most common Go stdlib import and could be named blind, but
|
||||
`testing` only appears in the test file, so naming it proves the agent read more
|
||||
than `main.go`. This trades a little output freedom for determinism (a loose
|
||||
prose-contains on "0"/"1" would match almost any answer, and an unanchored
|
||||
`count: N` would match inside a longer line).
|
||||
- **nav-03 / nav-06 / nav-07 are lenient contains-oracles on real facts.** A
|
||||
wrong-but-plausible answer that happens to mention the real symbols (e.g.
|
||||
"Config" and "MaxRetries" for nav-06) could in principle pass. Accepted for a
|
||||
latency-first baseline; the oracle still catches an agent that didn't look at
|
||||
the code at all.
|
||||
- **Rename oracles use a declaration-site negative grep** (`! grep -RIn
|
||||
'MaxRetries =' .`) rather than a broad `! grep -R MaxRetries .`, so a correct
|
||||
rename that leaves a doc-comment mention of the old name is not false-failed.
|
||||
The negative grep is still required: without it, a no-op that *adds* the new
|
||||
name and *keeps* the old would pass the compile-time `var _ = RetryLimit` and
|
||||
`go test`.
|
||||
|
||||
## Fixtures
|
||||
|
||||
@@ -68,4 +143,14 @@ Each task's `workspaceFixture` points at a small self-contained workspace under
|
||||
`testdata/` so the suite runs offline and repeatably. Mutating tasks (edit,
|
||||
fix, refactor) run against a per-invocation **copy** of their fixture, so the
|
||||
checked-in fixtures stay clean and one task's edits can't bleed into the next
|
||||
iteration or a later task.
|
||||
iteration or a later task.
|
||||
|
||||
Each mutating fixture (and nav) carries a `go.mod` (`module <pkg>; go 1.22`) so
|
||||
`go test ./...` / `go build ./...` oracles work in the copy: `copyFixture`
|
||||
creates a unique, 0700 parent under the system temp root
|
||||
(`os.MkdirTemp(os.TempDir(), "zero-turn-bench-*")`) and nests the copy beneath
|
||||
it, so the copy is a *grandchild* of the temp root. Go ignores `go.mod` in
|
||||
direct children of the system temp dir (a hijack guard), so the copy must sit
|
||||
one level below that root or every compiler-backed oracle would fail with
|
||||
"cannot find main module". The unique parent also avoids a predictable
|
||||
shared directory and is removed wholesale on cleanup.
|
||||
@@ -1,62 +1,545 @@
|
||||
{
|
||||
"id": "zero-baseline-turn",
|
||||
"name": "Zero per-turn baseline (Phase 0)",
|
||||
"description": "Latency-first baseline: measures where a turn spends wall time, with pass/fail reported per oracle tier so it cannot be misread as a blanket correctness verdict. Read-only classes (nav, longproc, longctx, parallel) carry no verification oracle — a zero-exit only proves the turn ran, so they count as latency-only and never in any pass rate. Correctness classes (edit, fix) carry a positive oracle (substring grep or a scoped `go test`) and are the only tasks in correctnessPassRate. Build-only classes (refactor) carry `go build ./...`, which proves the edit compiles but not that the refactor achieved its goal, so they are reported as buildPassRate and excluded from correctnessPassRate.",
|
||||
"buildOnlyClasses": ["refactor"],
|
||||
"description": "Latency-first baseline: measures where a turn spends wall time, with pass/fail reported per oracle tier so it cannot be misread as a blanket correctness verdict. Correctness classes (edit, fix, nav, refactor) carry a positive oracle: edit/refactor stamp an oracle_test.go compiled by `go test ./...` (the Go compiler is the structural verifier \u2014 a no-op refactor or a missing field fails to compile), fix runs a scoped `go test -run`, and nav greps the agent's captured final answer (.zero-answer.txt) for determinable facts. tasksVerified/tasksPassed/correctnessPassRate cover exactly these. Latency-only classes (longproc, longctx, parallel) carry no verification oracle \u2014 a zero-exit only proves the turn ran, so they count as latency-only and never in any pass rate. The build tier is empty: refactor graduated to correctness once its oracle became structural-positive. See MANIFEST.md for the per-task oracle specs and known limitations.",
|
||||
"buildOnlyClasses": [],
|
||||
"tasks": [
|
||||
{"id": "nav-01", "name": "list repo files", "class": "nav", "prompt": "List the files in this repository and report their count. Do not modify anything.", "workspaceFixture": "../testdata/nav"},
|
||||
{"id": "nav-02", "name": "find a function", "class": "nav", "prompt": "Find the function named 'greet' and report the file and line it is defined on. Do not modify anything.", "workspaceFixture": "../testdata/nav"},
|
||||
{"id": "nav-03", "name": "summarize module", "class": "nav", "prompt": "Read main.go and summarize what this program does in two sentences. Do not modify anything.", "workspaceFixture": "../testdata/nav"},
|
||||
{"id": "nav-04", "name": "count tests", "class": "nav", "prompt": "Count the test functions (functions whose names start with Test) in this repository and report the number. Do not modify anything.", "workspaceFixture": "../testdata/nav"},
|
||||
{"id": "nav-05", "name": "locate a string", "class": "nav", "prompt": "Find every occurrence of the string 'TODO' in the repository and report the files. Do not modify anything.", "workspaceFixture": "../testdata/nav"},
|
||||
{"id": "nav-06", "name": "describe package", "class": "nav", "prompt": "Describe the exported API of this package by reading the source. Do not modify anything.", "workspaceFixture": "../testdata/nav"},
|
||||
{"id": "nav-07", "name": "trace a call", "class": "nav", "prompt": "Trace which function calls which other function starting from main. Do not modify anything.", "workspaceFixture": "../testdata/nav"},
|
||||
{"id": "nav-08", "name": "find imports", "class": "nav", "prompt": "List every third-party import used by this repository. Do not modify anything.", "workspaceFixture": "../testdata/nav"},
|
||||
{"id": "nav-09", "name": "report config", "class": "nav", "prompt": "Read config.json and report the keys it contains. Do not modify anything.", "workspaceFixture": "../testdata/nav"},
|
||||
{"id": "nav-10", "name": "doc lookup", "class": "nav", "prompt": "Find the doc comment on the greet function and quote it. Do not modify anything.", "workspaceFixture": "../testdata/nav"},
|
||||
|
||||
{"id": "edit-01", "name": "rename a constant", "class": "edit", "prompt": "Rename the constant MaxRetries to RetryLimit everywhere it appears.", "workspaceFixture": "../testdata/edit", "verificationCommand": ["bash", "-c", "grep -R RetryLimit . && ! grep -R MaxRetries ."]},
|
||||
{"id": "edit-02", "name": "fix a typo", "class": "edit", "prompt": "Fix the typo in the comment above the greet function ('receieves' should be 'receives').", "workspaceFixture": "../testdata/edit", "verificationCommand": ["bash", "-c", "! grep -R receieves ."]},
|
||||
{"id": "edit-03", "name": "add a field", "class": "edit", "prompt": "Add a string field named Label to the Config struct in main.go.", "workspaceFixture": "../testdata/edit", "verificationCommand": ["grep", "-R", "Label", "."]},
|
||||
{"id": "edit-04", "name": "bump a version", "class": "edit", "prompt": "Change the version string in version.go from 1.0.0 to 1.1.0.", "workspaceFixture": "../testdata/edit", "verificationCommand": ["bash", "-c", "grep -R 1.1.0 . && ! grep -R 1.0.0 ."]},
|
||||
{"id": "edit-05", "name": "remove a log line", "class": "edit", "prompt": "Remove the line that prints 'debug: starting'.", "workspaceFixture": "../testdata/edit", "verificationCommand": ["bash", "-c", "! grep -R 'debug: starting' ."]},
|
||||
{"id": "edit-06", "name": "add a license header", "class": "edit", "prompt": "Add a comment line '// SPDX-License-Identifier: MIT' at the top of main.go.", "workspaceFixture": "../testdata/edit", "verificationCommand": ["grep", "-R", "SPDX-License-Identifier", "."]},
|
||||
{"id": "edit-07", "name": "change a default", "class": "edit", "prompt": "Change the default value of the Port constant from 8080 to 9090.", "workspaceFixture": "../testdata/edit", "verificationCommand": ["bash", "-c", "grep -R 9090 . && ! grep -R 8080 ."]},
|
||||
{"id": "edit-08", "name": "add a getter", "class": "edit", "prompt": "Add a method func (c *Config) GetLabel() string that returns c.Label.", "workspaceFixture": "../testdata/edit", "verificationCommand": ["bash", "-c", "grep -R 'func (c *Config) GetLabel' ."]},
|
||||
{"id": "edit-09", "name": "wrap an error", "class": "edit", "prompt": "In the load function, wrap the returned error with fmt.Errorf so it includes 'load failed'.", "workspaceFixture": "../testdata/edit", "verificationCommand": ["grep", "-R", "load failed", "."]},
|
||||
{"id": "edit-10", "name": "update a message", "class": "edit", "prompt": "Change the printed greeting from 'hello' to 'hello, world'.", "workspaceFixture": "../testdata/edit", "verificationCommand": ["grep", "-R", "hello, world", "."]},
|
||||
|
||||
{"id": "fix-01", "name": "off-by-one", "class": "fix", "prompt": "In bugs.go the Sum function has an off-by-one error that skips the last element; fix the loop so it sums all elements.", "workspaceFixture": "../testdata/fix", "verificationCommand": ["go", "test", "-run", "TestSumIncludesLastElement", "./..."]},
|
||||
{"id": "fix-02", "name": "nil deref", "class": "fix", "prompt": "In bugs.go DefaultConfig returns nil and Port() dereferences it; make DefaultConfig return a valid Config with Port 8080.", "workspaceFixture": "../testdata/fix", "verificationCommand": ["go", "test", "-run", "TestPortReturnsDefault", "./..."]},
|
||||
{"id": "fix-03", "name": "wrong operator", "class": "fix", "prompt": "In bugs.go Max uses the wrong inequality and returns the smaller value; fix it to return the larger.", "workspaceFixture": "../testdata/fix", "verificationCommand": ["go", "test", "-run", "TestMaxReturnsLarger", "./..."]},
|
||||
{"id": "fix-04", "name": "truncated read", "class": "fix", "prompt": "In bugs.go ReadFirst returns only a single character instead of the full content; fix it to return the whole string.", "workspaceFixture": "../testdata/fix", "verificationCommand": ["go", "test", "-run", "TestReadFirstReturnsFullContent", "./..."]},
|
||||
{"id": "fix-05", "name": "swapped args", "class": "fix", "prompt": "In bugs.go Divide swaps its operands; fix the order so Divide(10,2) returns 5.", "workspaceFixture": "../testdata/fix", "verificationCommand": ["go", "test", "-run", "TestDivideOrder", "./..."]},
|
||||
{"id": "fix-06", "name": "missing return", "class": "fix", "prompt": "In bugs.go Classify is missing a return for the high case; add it so Classify(500) returns \"high\".", "workspaceFixture": "../testdata/fix", "verificationCommand": ["go", "test", "-run", "TestClassifyHigh", "./..."]},
|
||||
{"id": "fix-07", "name": "wrong label prefix", "class": "fix", "prompt": "In bugs.go Label returns 'admin-<name>' but the test expects 'user-<name>'; change the prefix from 'admin-' to 'user-'.", "workspaceFixture": "../testdata/fix", "verificationCommand": ["go", "test", "-run", "TestLabelUsesString", "./..."]},
|
||||
{"id": "fix-08", "name": "race on counter", "class": "fix", "prompt": "In bugs.go the global counter is incremented without synchronization; guard it with a mutex.", "workspaceFixture": "../testdata/fix", "verificationCommand": ["go", "test", "-race", "-run", "TestCounterRace", "./..."]},
|
||||
|
||||
{"id": "refactor-01", "name": "extract helper", "class": "refactor", "prompt": "Extract the duplicated greeting logic into a single helper function and call it from both sites.", "workspaceFixture": "../testdata/refactor", "verificationCommand": ["go", "build", "./..."]},
|
||||
{"id": "refactor-02", "name": "split a file", "class": "refactor", "prompt": "Split main.go into two files: one for Config, one for the rest, keeping the package building.", "workspaceFixture": "../testdata/refactor", "verificationCommand": ["go", "build", "./..."]},
|
||||
{"id": "refactor-03", "name": "rename package", "class": "refactor", "prompt": "Rename the package from app to zeroapp everywhere and keep it building.", "workspaceFixture": "../testdata/refactor", "verificationCommand": ["go", "build", "./..."]},
|
||||
{"id": "refactor-04", "name": "introduce a type", "class": "refactor", "prompt": "Introduce a named type for the map[string]int used in main.go and update its users.", "workspaceFixture": "../testdata/refactor", "verificationCommand": ["go", "build", "./..."]},
|
||||
{"id": "refactor-05", "name": "inline a wrapper", "class": "refactor", "prompt": "Inline the thin wrapper function into its single caller and remove it.", "workspaceFixture": "../testdata/refactor", "verificationCommand": ["go", "build", "./..."]},
|
||||
{"id": "refactor-06", "name": "consolidate errors", "class": "refactor", "prompt": "Consolidate the repeated error-construction blocks into a single helper.", "workspaceFixture": "../testdata/refactor", "verificationCommand": ["go", "build", "./..."]},
|
||||
|
||||
{"id": "longproc-01", "name": "long build", "class": "longproc", "prompt": "Run the build command and report whether it succeeds. Do not modify anything.", "workspaceFixture": "../testdata/longproc"},
|
||||
{"id": "longproc-02", "name": "long test", "class": "longproc", "prompt": "Run go test ./... and report the result. Do not modify anything.", "workspaceFixture": "../testdata/longproc"},
|
||||
{"id": "longproc-03", "name": "bench run", "class": "longproc", "prompt": "Run go test -bench=. -benchtime=1x and report the benchmark numbers. Do not modify anything.", "workspaceFixture": "../testdata/longproc"},
|
||||
{"id": "longproc-04", "name": "vet run", "class": "longproc", "prompt": "Run go vet ./... and report the output. Do not modify anything.", "workspaceFixture": "../testdata/longproc"},
|
||||
|
||||
{"id": "longctx-01", "name": "summarize large file", "class": "longctx", "prompt": "Read the large generated file big.go and summarize its structure. Do not modify anything.", "workspaceFixture": "../testdata/longctx"},
|
||||
{"id": "longctx-02", "name": "find in large file", "class": "longctx", "prompt": "In big.go, find every function that returns an error and list them. Do not modify anything.", "workspaceFixture": "../testdata/longctx"},
|
||||
{"id": "longctx-03", "name": "cross-file question", "class": "longctx", "prompt": "Read all .go files and report which two functions share the same name across files. Do not modify anything.", "workspaceFixture": "../testdata/longctx"},
|
||||
{"id": "longctx-04", "name": "trace through large file", "class": "longctx", "prompt": "Starting from Handle, trace the call path through big.go and report the chain. Do not modify anything.", "workspaceFixture": "../testdata/longctx"},
|
||||
|
||||
{"id": "parallel-01", "name": "read six files", "class": "parallel", "prompt": "Read a.txt, b.txt, c.txt, d.txt, e.txt, and f.txt, then report the first line of each. Do not modify anything.", "workspaceFixture": "../testdata/parallel"},
|
||||
{"id": "parallel-02", "name": "grep six patterns", "class": "parallel", "prompt": "Search for 'alpha', 'beta', 'gamma', 'delta', 'epsilon', and 'zeta' across the repo and report which files contain each. Do not modify anything.", "workspaceFixture": "../testdata/parallel"},
|
||||
{"id": "parallel-03", "name": "list six dirs", "class": "parallel", "prompt": "List the contents of dir1, dir2, dir3, dir4, dir5, and dir6. Do not modify anything.", "workspaceFixture": "../testdata/parallel"},
|
||||
{"id": "parallel-04", "name": "stat six files", "class": "parallel", "prompt": "Report the line count of a.txt, b.txt, c.txt, d.txt, e.txt, and f.txt. Do not modify anything.", "workspaceFixture": "../testdata/parallel"},
|
||||
{"id": "parallel-05", "name": "read configs", "class": "parallel", "prompt": "Read config1.json, config2.json, config3.json, config4.json, config5.json, and config6.json and report each top-level key. Do not modify anything.", "workspaceFixture": "../testdata/parallel"},
|
||||
{"id": "parallel-06", "name": "summarize six", "class": "parallel", "prompt": "Read the six .txt files and give a one-line summary of each. Do not modify anything.", "workspaceFixture": "../testdata/parallel"}
|
||||
{
|
||||
"id": "nav-01",
|
||||
"name": "list repo files",
|
||||
"class": "nav",
|
||||
"prompt": "List the files in this repository and report their count. State the count as 'count: N' on its own line. Do not modify anything.",
|
||||
"workspaceFixture": "../testdata/nav",
|
||||
"verificationCommand": [
|
||||
"bash",
|
||||
"-c",
|
||||
"grep -iq 'main.go' .zero-answer.txt && grep -iq 'config.json' .zero-answer.txt && grep -iq 'readme' .zero-answer.txt && grep -iqE '^count:[[:space:]]*5$' .zero-answer.txt"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "nav-02",
|
||||
"name": "find a function",
|
||||
"class": "nav",
|
||||
"prompt": "Find the function named 'greet' and report the file and line it is defined on. Do not modify anything.",
|
||||
"workspaceFixture": "../testdata/nav",
|
||||
"verificationCommand": [
|
||||
"bash",
|
||||
"-c",
|
||||
"grep -iq 'main.go' .zero-answer.txt && grep -iqE '\\b1[5-7]\\b' .zero-answer.txt"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "nav-03",
|
||||
"name": "summarize module",
|
||||
"class": "nav",
|
||||
"prompt": "Read main.go and summarize what this program does in two sentences. Do not modify anything.",
|
||||
"workspaceFixture": "../testdata/nav",
|
||||
"verificationCommand": [
|
||||
"bash",
|
||||
"-c",
|
||||
"grep -iqE 'greet|hello|Config' .zero-answer.txt"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "nav-04",
|
||||
"name": "count tests",
|
||||
"class": "nav",
|
||||
"prompt": "Count the test functions (functions whose names start with Test) in this repository. Name each one you find and report the number. State the count as 'count: N' on its own line. Do not modify anything.",
|
||||
"workspaceFixture": "../testdata/nav",
|
||||
"verificationCommand": [
|
||||
"bash",
|
||||
"-c",
|
||||
"grep -iq 'TestGreet' .zero-answer.txt && grep -iqE '^count:[[:space:]]*1$' .zero-answer.txt"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "nav-05",
|
||||
"name": "locate a string",
|
||||
"class": "nav",
|
||||
"prompt": "Find every occurrence of the string 'TODO' in the repository and report the file(s) they appear in. State the count as 'count: N' on its own line. Do not modify anything.",
|
||||
"workspaceFixture": "../testdata/nav",
|
||||
"verificationCommand": [
|
||||
"bash",
|
||||
"-c",
|
||||
"grep -iq 'main.go' .zero-answer.txt && grep -iqE '^count:[[:space:]]*1$' .zero-answer.txt"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "nav-06",
|
||||
"name": "describe package",
|
||||
"class": "nav",
|
||||
"prompt": "Describe the exported API of this package by reading the source. Do not modify anything.",
|
||||
"workspaceFixture": "../testdata/nav",
|
||||
"verificationCommand": [
|
||||
"bash",
|
||||
"-c",
|
||||
"grep -iq config .zero-answer.txt && grep -iq maxretries .zero-answer.txt"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "nav-07",
|
||||
"name": "trace a call",
|
||||
"class": "nav",
|
||||
"prompt": "Trace which function calls which other function starting from main. Do not modify anything.",
|
||||
"workspaceFixture": "../testdata/nav",
|
||||
"verificationCommand": [
|
||||
"bash",
|
||||
"-c",
|
||||
"grep -iq main .zero-answer.txt && grep -iq greet .zero-answer.txt"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "nav-08",
|
||||
"name": "find imports",
|
||||
"class": "nav",
|
||||
"prompt": "List every import used by this repository and classify each as standard library or third-party. State the number of third-party (non-stdlib) imports as 'count: N' on its own line. Do not modify anything.",
|
||||
"workspaceFixture": "../testdata/nav",
|
||||
"verificationCommand": [
|
||||
"bash",
|
||||
"-c",
|
||||
"grep -iq 'fmt' .zero-answer.txt && grep -iq 'testing' .zero-answer.txt && grep -iqE '^count:[[:space:]]*0$' .zero-answer.txt"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "nav-09",
|
||||
"name": "report config",
|
||||
"class": "nav",
|
||||
"prompt": "Read config.json and report the keys it contains. Do not modify anything.",
|
||||
"workspaceFixture": "../testdata/nav",
|
||||
"verificationCommand": [
|
||||
"bash",
|
||||
"-c",
|
||||
"grep -iq port .zero-answer.txt && grep -iq name .zero-answer.txt && grep -iq retries .zero-answer.txt"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "nav-10",
|
||||
"name": "doc lookup",
|
||||
"class": "nav",
|
||||
"prompt": "Find the doc comment on the greet function and quote it. Do not modify anything.",
|
||||
"workspaceFixture": "../testdata/nav",
|
||||
"verificationCommand": [
|
||||
"bash",
|
||||
"-c",
|
||||
"grep -iq 'greet returns a greeting' .zero-answer.txt"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "edit-01",
|
||||
"name": "rename a constant",
|
||||
"class": "edit",
|
||||
"prompt": "Rename the constant MaxRetries to RetryLimit everywhere it appears.",
|
||||
"workspaceFixture": "../testdata/edit",
|
||||
"verificationCommand": [
|
||||
"bash",
|
||||
"-c",
|
||||
"go test ./... && ! grep --exclude=.zero-answer.txt -RIn 'MaxRetries =' ."
|
||||
],
|
||||
"oracleTest": "package edit\n\nvar _ = RetryLimit\n"
|
||||
},
|
||||
{
|
||||
"id": "edit-02",
|
||||
"name": "fix a typo",
|
||||
"class": "edit",
|
||||
"prompt": "Fix the typo in the comment above the greet function ('receieves' should be 'receives').",
|
||||
"workspaceFixture": "../testdata/edit",
|
||||
"verificationCommand": [
|
||||
"bash",
|
||||
"-c",
|
||||
"go test ./... && ! grep --exclude=.zero-answer.txt -R receieves . && grep --exclude=.zero-answer.txt -R receives ."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "edit-03",
|
||||
"name": "add a field",
|
||||
"class": "edit",
|
||||
"prompt": "Add a string field named Label to the Config struct in main.go.",
|
||||
"workspaceFixture": "../testdata/edit",
|
||||
"verificationCommand": [
|
||||
"go",
|
||||
"test",
|
||||
"./..."
|
||||
],
|
||||
"oracleTest": "package edit\n\nvar _ string = Config{}.Label\n"
|
||||
},
|
||||
{
|
||||
"id": "edit-04",
|
||||
"name": "bump a version",
|
||||
"class": "edit",
|
||||
"prompt": "Change the version string in version.go from 1.0.0 to 1.1.0.",
|
||||
"workspaceFixture": "../testdata/edit",
|
||||
"verificationCommand": [
|
||||
"go",
|
||||
"test",
|
||||
"./..."
|
||||
],
|
||||
"oracleTest": "package edit\n\nimport \"testing\"\n\nfunc TestVersion(t *testing.T) {\n\tif Version != \"1.1.0\" {\n\t\tt.Fatalf(\"Version=%q\", Version)\n\t}\n}\n"
|
||||
},
|
||||
{
|
||||
"id": "edit-05",
|
||||
"name": "remove a log line",
|
||||
"class": "edit",
|
||||
"prompt": "Remove the line that prints 'debug: starting'.",
|
||||
"workspaceFixture": "../testdata/edit",
|
||||
"verificationCommand": [
|
||||
"go",
|
||||
"test",
|
||||
"./..."
|
||||
],
|
||||
"oracleTest": "package edit\n\nimport (\n\t\"os\"\n\t\"strings\"\n\t\"testing\"\n)\n\nfunc TestNoDebugPrintln(t *testing.T) {\n\tb, err := os.ReadFile(\"main.go\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\t// The greet print is fmt.Println(greet(...)) with no string literal, so a\n\t// remaining fmt.Println(\"...\") call means the debug line was not removed\n\t// (or was only reworded, not deleted).\n\tif strings.Contains(string(b), `fmt.Println(\"`) {\n\t\tt.Fatal(\"main.go still has a fmt.Println string-literal call; the debug line was not removed\")\n\t}\n\t// The task is to remove the line that prints \"debug: starting\", so the\n\t// message text itself must be gone -- this catches a reword onto a different\n\t// call form (fmt.Printf, backticks, log.Println, println) that the\n\t// string-literal check above would miss.\n\tif strings.Contains(string(b), \"debug: starting\") {\n\t\tt.Fatal(\"main.go still prints 'debug: starting'; the line was not removed (only reworded or respelled)\")\n\t}\n}\n"
|
||||
},
|
||||
{
|
||||
"id": "edit-06",
|
||||
"name": "add a license header",
|
||||
"class": "edit",
|
||||
"prompt": "Add a comment line '// SPDX-License-Identifier: MIT' at the top of main.go.",
|
||||
"workspaceFixture": "../testdata/edit",
|
||||
"verificationCommand": [
|
||||
"go",
|
||||
"test",
|
||||
"./..."
|
||||
],
|
||||
"oracleTest": "package edit\n\nimport (\n\t\"os\"\n\t\"strings\"\n\t\"testing\"\n)\n\nfunc TestSPDXHeader(t *testing.T) {\n\tb, err := os.ReadFile(\"main.go\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif !strings.HasPrefix(string(b), \"// SPDX-License-Identifier: MIT\") {\n\t\tt.Fatal(\"main.go must start with the SPDX-License-Identifier: MIT header\")\n\t}\n}\n"
|
||||
},
|
||||
{
|
||||
"id": "edit-07",
|
||||
"name": "change a default",
|
||||
"class": "edit",
|
||||
"prompt": "Change the default value of the Port constant from 8080 to 9090.",
|
||||
"workspaceFixture": "../testdata/edit",
|
||||
"verificationCommand": [
|
||||
"go",
|
||||
"test",
|
||||
"./..."
|
||||
],
|
||||
"oracleTest": "package edit\n\nimport \"testing\"\n\nfunc TestPort(t *testing.T) {\n\tif Port != 9090 {\n\t\tt.Fatalf(\"Port=%d, want 9090\", Port)\n\t}\n}\n"
|
||||
},
|
||||
{
|
||||
"id": "edit-08",
|
||||
"name": "add a getter",
|
||||
"class": "edit",
|
||||
"prompt": "Add a method func (c *Config) GetName() string that returns c.Name.",
|
||||
"workspaceFixture": "../testdata/edit",
|
||||
"verificationCommand": [
|
||||
"go",
|
||||
"test",
|
||||
"./..."
|
||||
],
|
||||
"oracleTest": "package edit\n\nimport \"testing\"\n\nfunc TestGetName(t *testing.T) {\n\tc := Config{Name: \"x\"}\n\tif got := c.GetName(); got != \"x\" {\n\t\tt.Fatalf(\"GetName()=%q, want %q\", got, \"x\")\n\t}\n}\n"
|
||||
},
|
||||
{
|
||||
"id": "edit-09",
|
||||
"name": "wrap an error",
|
||||
"class": "edit",
|
||||
"prompt": "In the load function, wrap the returned error with fmt.Errorf so it includes 'load failed'.",
|
||||
"workspaceFixture": "../testdata/edit",
|
||||
"verificationCommand": [
|
||||
"go",
|
||||
"test",
|
||||
"./..."
|
||||
],
|
||||
"oracleTest": "package edit\n\nimport (\n\t\"strings\"\n\t\"testing\"\n)\n\nfunc TestLoadWrapsError(t *testing.T) {\n\t_, err := load(\"nonexistent-file-xyz\")\n\tif err == nil {\n\t\tt.Fatal(\"want a non-nil error for a missing file\")\n\t}\n\tif !strings.Contains(err.Error(), \"load failed\") {\n\t\tt.Fatalf(\"err=%v, want it to contain 'load failed'\", err)\n\t}\n}\n"
|
||||
},
|
||||
{
|
||||
"id": "edit-10",
|
||||
"name": "update a message",
|
||||
"class": "edit",
|
||||
"prompt": "Change the printed greeting from 'hello' to 'hello, world'.",
|
||||
"workspaceFixture": "../testdata/edit",
|
||||
"verificationCommand": [
|
||||
"go",
|
||||
"test",
|
||||
"./..."
|
||||
],
|
||||
"oracleTest": "package edit\n\nimport \"testing\"\n\nfunc TestGreet(t *testing.T) {\n\tif got := greet(\"x\"); got != \"hello, world\" {\n\t\tt.Fatalf(\"greet()=%q, want %q\", got, \"hello, world\")\n\t}\n}\n"
|
||||
},
|
||||
{
|
||||
"id": "fix-01",
|
||||
"name": "off-by-one",
|
||||
"class": "fix",
|
||||
"prompt": "In bugs.go the Sum function has an off-by-one error that skips the last element; fix the loop so it sums all elements.",
|
||||
"workspaceFixture": "../testdata/fix",
|
||||
"verificationCommand": [
|
||||
"go",
|
||||
"test",
|
||||
"-run",
|
||||
"TestSumIncludesLastElement",
|
||||
"./..."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "fix-02",
|
||||
"name": "nil deref",
|
||||
"class": "fix",
|
||||
"prompt": "In bugs.go DefaultConfig returns nil and Port() dereferences it; make DefaultConfig return a valid Config with Port 8080.",
|
||||
"workspaceFixture": "../testdata/fix",
|
||||
"verificationCommand": [
|
||||
"go",
|
||||
"test",
|
||||
"-run",
|
||||
"TestPortReturnsDefault",
|
||||
"./..."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "fix-03",
|
||||
"name": "wrong operator",
|
||||
"class": "fix",
|
||||
"prompt": "In bugs.go Max uses the wrong inequality and returns the smaller value; fix it to return the larger.",
|
||||
"workspaceFixture": "../testdata/fix",
|
||||
"verificationCommand": [
|
||||
"go",
|
||||
"test",
|
||||
"-run",
|
||||
"TestMaxReturnsLarger",
|
||||
"./..."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "fix-04",
|
||||
"name": "truncated read",
|
||||
"class": "fix",
|
||||
"prompt": "In bugs.go ReadFirst returns only a single character instead of the full content; fix it to return the whole string.",
|
||||
"workspaceFixture": "../testdata/fix",
|
||||
"verificationCommand": [
|
||||
"go",
|
||||
"test",
|
||||
"-run",
|
||||
"TestReadFirstReturnsFullContent",
|
||||
"./..."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "fix-05",
|
||||
"name": "swapped args",
|
||||
"class": "fix",
|
||||
"prompt": "In bugs.go Divide swaps its operands; fix the order so Divide(10,2) returns 5.",
|
||||
"workspaceFixture": "../testdata/fix",
|
||||
"verificationCommand": [
|
||||
"go",
|
||||
"test",
|
||||
"-run",
|
||||
"TestDivideOrder",
|
||||
"./..."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "fix-06",
|
||||
"name": "missing return",
|
||||
"class": "fix",
|
||||
"prompt": "In bugs.go Classify is missing a return for the high case; add it so Classify(500) returns \"high\".",
|
||||
"workspaceFixture": "../testdata/fix",
|
||||
"verificationCommand": [
|
||||
"go",
|
||||
"test",
|
||||
"-run",
|
||||
"TestClassifyHigh",
|
||||
"./..."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "fix-07",
|
||||
"name": "wrong label prefix",
|
||||
"class": "fix",
|
||||
"prompt": "In bugs.go Label returns 'admin-<name>' but the test expects 'user-<name>'; change the prefix from 'admin-' to 'user-'.",
|
||||
"workspaceFixture": "../testdata/fix",
|
||||
"verificationCommand": [
|
||||
"go",
|
||||
"test",
|
||||
"-run",
|
||||
"TestLabelUsesString",
|
||||
"./..."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "fix-08",
|
||||
"name": "race on counter",
|
||||
"class": "fix",
|
||||
"prompt": "In bugs.go the global counter is incremented without synchronization; guard it with a mutex.",
|
||||
"workspaceFixture": "../testdata/fix",
|
||||
"verificationCommand": [
|
||||
"go",
|
||||
"test",
|
||||
"-race",
|
||||
"-run",
|
||||
"TestCounterRace",
|
||||
"./..."
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "refactor-01",
|
||||
"name": "extract helper",
|
||||
"class": "refactor",
|
||||
"prompt": "Extract the duplicated fmt.Sprintf(\"hello, %s\", ...) greeting logic into a single helper function named formatGreeting and call it from both GreetFromConfig and GreetByName.",
|
||||
"workspaceFixture": "../testdata/refactor",
|
||||
"verificationCommand": [
|
||||
"bash",
|
||||
"-c",
|
||||
"go test ./... && [ $(grep -c 'hello, %s' main.go) -eq 1 ]"
|
||||
],
|
||||
"oracleTest": "package refactor\n\nvar _ = formatGreeting\n"
|
||||
},
|
||||
{
|
||||
"id": "refactor-02",
|
||||
"name": "split a file",
|
||||
"class": "refactor",
|
||||
"prompt": "Move the Config type into a new file named config.go, keeping the package building.",
|
||||
"workspaceFixture": "../testdata/refactor",
|
||||
"verificationCommand": [
|
||||
"bash",
|
||||
"-c",
|
||||
"go test ./... && grep -q 'type Config' config.go && ! grep -q 'type Config' main.go"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "refactor-03",
|
||||
"name": "rename package",
|
||||
"class": "refactor",
|
||||
"prompt": "Rename the package from refactor to zeroapp everywhere and keep it building.",
|
||||
"workspaceFixture": "../testdata/refactor",
|
||||
"verificationCommand": [
|
||||
"bash",
|
||||
"-c",
|
||||
"go test ./... && ! grep --exclude=.zero-answer.txt -R 'package refactor' . && grep --exclude=.zero-answer.txt -R 'package zeroapp' ."
|
||||
],
|
||||
"oracleTest": "package zeroapp\n\nvar _ = Config{}\n"
|
||||
},
|
||||
{
|
||||
"id": "refactor-04",
|
||||
"name": "introduce a type",
|
||||
"class": "refactor",
|
||||
"prompt": "Introduce a named type `type Stats map[string]int` for the bare map used by stats, and update its users (Record, Lookup).",
|
||||
"workspaceFixture": "../testdata/refactor",
|
||||
"verificationCommand": [
|
||||
"go",
|
||||
"test",
|
||||
"./..."
|
||||
],
|
||||
"oracleTest": "package refactor\n\nimport (\n\t\"os\"\n\t\"regexp\"\n\t\"strings\"\n\t\"testing\"\n)\n\n// TestStatsNamedType proves the bare map was replaced by the named Stats type:\n// the old `var stats = map[string]int` (or `stats := map[string]int{}`) form is\n// gone -- matched with a spacing-tolerant regex so a `var stats=map[string]int`\n// or `stats :=` variant can't dodge it -- and the Stats type is actually\n// referenced in main.go, so a no-op that only adds an unused `type Stats` while\n// keeping the bare map fails. `go test ./...` compiling the fixture proves the\n// type declaration is valid.\nfunc TestStatsNamedType(t *testing.T) {\n\tb, err := os.ReadFile(\"main.go\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tsrc := string(b)\n\tif regexp.MustCompile(`var\\s+stats\\s*=\\s*map\\[string\\]int`).MatchString(src) ||\n\t\tregexp.MustCompile(`stats\\s*:=\\s*map\\[string\\]int`).MatchString(src) {\n\t\tt.Fatal(\"main.go still declares stats as a bare map[string]int; the named Stats type was not adopted\")\n\t}\n\tif !strings.Contains(src, \"Stats\") {\n\t\tt.Fatal(\"main.go does not reference the Stats type; refactor-04 was a no-op\")\n\t}\n}\n"
|
||||
},
|
||||
{
|
||||
"id": "refactor-05",
|
||||
"name": "inline a wrapper",
|
||||
"class": "refactor",
|
||||
"prompt": "Inline the Wrapper function into greetWrapped (its single caller) and remove the Wrapper function.",
|
||||
"workspaceFixture": "../testdata/refactor",
|
||||
"verificationCommand": [
|
||||
"bash",
|
||||
"-c",
|
||||
"go test ./... && ! grep --exclude=.zero-answer.txt -R 'func Wrapper' ."
|
||||
],
|
||||
"oracleTest": "package refactor\n\nimport \"testing\"\n\n// TestGreetWrapped proves greetWrapped still exists and behaves as the inlined\n// Wrapper did: deleting both Wrapper and greetWrapped (or stubbing greetWrapped\n// to return \"\") fails to compile or run, so the oracle can't be passed by\n// removing the target along with its caller. Paired with the ! grep 'func\n// Wrapper' verificationCommand that proves Wrapper itself is gone.\nfunc TestGreetWrapped(t *testing.T) {\n\tif got := greetWrapped(\"x\"); got != \"hello, x\" {\n\t\tt.Fatalf(\"greetWrapped(x) = %q, want %q\", got, \"hello, x\")\n\t}\n}\n"
|
||||
},
|
||||
{
|
||||
"id": "refactor-06",
|
||||
"name": "consolidate errors",
|
||||
"class": "refactor",
|
||||
"prompt": "Consolidate the three error-construction functions (firstError, secondError, buildError) into a single helper func makeLoadError(ctx string) error that returns fmt.Errorf(\"load failed: %s\", ctx), and call it from all three.",
|
||||
"workspaceFixture": "../testdata/refactor",
|
||||
"verificationCommand": [
|
||||
"bash",
|
||||
"-c",
|
||||
"go test ./... && [ $(grep -c 'fmt.Errorf(\"load failed' main.go) -eq 1 ]"
|
||||
],
|
||||
"oracleTest": "package refactor\n\nvar _ = makeLoadError\n"
|
||||
},
|
||||
{
|
||||
"id": "longproc-01",
|
||||
"name": "long build",
|
||||
"class": "longproc",
|
||||
"prompt": "Run the build command and report whether it succeeds. Do not modify anything.",
|
||||
"workspaceFixture": "../testdata/longproc"
|
||||
},
|
||||
{
|
||||
"id": "longproc-02",
|
||||
"name": "long test",
|
||||
"class": "longproc",
|
||||
"prompt": "Run go test ./... and report the result. Do not modify anything.",
|
||||
"workspaceFixture": "../testdata/longproc"
|
||||
},
|
||||
{
|
||||
"id": "longproc-03",
|
||||
"name": "bench run",
|
||||
"class": "longproc",
|
||||
"prompt": "Run go test -bench=. -benchtime=1x and report the benchmark numbers. Do not modify anything.",
|
||||
"workspaceFixture": "../testdata/longproc"
|
||||
},
|
||||
{
|
||||
"id": "longproc-04",
|
||||
"name": "vet run",
|
||||
"class": "longproc",
|
||||
"prompt": "Run go vet ./... and report the output. Do not modify anything.",
|
||||
"workspaceFixture": "../testdata/longproc"
|
||||
},
|
||||
{
|
||||
"id": "longctx-01",
|
||||
"name": "summarize large file",
|
||||
"class": "longctx",
|
||||
"prompt": "Read the large generated file big.go and summarize its structure. Do not modify anything.",
|
||||
"workspaceFixture": "../testdata/longctx"
|
||||
},
|
||||
{
|
||||
"id": "longctx-02",
|
||||
"name": "find in large file",
|
||||
"class": "longctx",
|
||||
"prompt": "In big.go, find every function that returns an error and list them. Do not modify anything.",
|
||||
"workspaceFixture": "../testdata/longctx"
|
||||
},
|
||||
{
|
||||
"id": "longctx-03",
|
||||
"name": "cross-file question",
|
||||
"class": "longctx",
|
||||
"prompt": "Read all .go files and report which two functions share the same name across files. Do not modify anything.",
|
||||
"workspaceFixture": "../testdata/longctx"
|
||||
},
|
||||
{
|
||||
"id": "longctx-04",
|
||||
"name": "trace through large file",
|
||||
"class": "longctx",
|
||||
"prompt": "Starting from Handle, trace the call path through big.go and report the chain. Do not modify anything.",
|
||||
"workspaceFixture": "../testdata/longctx"
|
||||
},
|
||||
{
|
||||
"id": "parallel-01",
|
||||
"name": "read six files",
|
||||
"class": "parallel",
|
||||
"prompt": "Read a.txt, b.txt, c.txt, d.txt, e.txt, and f.txt, then report the first line of each. Do not modify anything.",
|
||||
"workspaceFixture": "../testdata/parallel"
|
||||
},
|
||||
{
|
||||
"id": "parallel-02",
|
||||
"name": "grep six patterns",
|
||||
"class": "parallel",
|
||||
"prompt": "Search for 'alpha', 'beta', 'gamma', 'delta', 'epsilon', and 'zeta' across the repo and report which files contain each. Do not modify anything.",
|
||||
"workspaceFixture": "../testdata/parallel"
|
||||
},
|
||||
{
|
||||
"id": "parallel-03",
|
||||
"name": "list six dirs",
|
||||
"class": "parallel",
|
||||
"prompt": "List the contents of dir1, dir2, dir3, dir4, dir5, and dir6. Do not modify anything.",
|
||||
"workspaceFixture": "../testdata/parallel"
|
||||
},
|
||||
{
|
||||
"id": "parallel-04",
|
||||
"name": "stat six files",
|
||||
"class": "parallel",
|
||||
"prompt": "Report the line count of a.txt, b.txt, c.txt, d.txt, e.txt, and f.txt. Do not modify anything.",
|
||||
"workspaceFixture": "../testdata/parallel"
|
||||
},
|
||||
{
|
||||
"id": "parallel-05",
|
||||
"name": "read configs",
|
||||
"class": "parallel",
|
||||
"prompt": "Read config1.json, config2.json, config3.json, config4.json, config5.json, and config6.json and report each top-level key. Do not modify anything.",
|
||||
"workspaceFixture": "../testdata/parallel"
|
||||
},
|
||||
{
|
||||
"id": "parallel-06",
|
||||
"name": "summarize six",
|
||||
"class": "parallel",
|
||||
"prompt": "Read the six .txt files and give a one-line summary of each. Do not modify anything.",
|
||||
"workspaceFixture": "../testdata/parallel"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,14 @@ type BenchTask struct {
|
||||
Prompt string `json:"prompt"`
|
||||
WorkspaceFixture string `json:"workspaceFixture,omitempty"`
|
||||
VerificationCommand []string `json:"verificationCommand,omitempty"`
|
||||
// OracleTest is Go source stamped into <fixtureCopy>/oracle_test.go AFTER
|
||||
// the agent run (so it can't interfere with the agent's own go build/test
|
||||
// during the task and can't be pre-seen). The verificationCommand then runs
|
||||
// `go test ./...`, making the Go compiler the structural verifier: a
|
||||
// compile-time reference (e.g. `var _ = Config{}.Label`) or a behavioral call
|
||||
// fails to compile/run when the task wasn't actually done. Empty for tasks
|
||||
// whose verificationCommand is a plain grep/build with no stamped test.
|
||||
OracleTest string `json:"oracleTest,omitempty"`
|
||||
}
|
||||
|
||||
// TaskConfig is the recorded configuration of a benchmark run. The honest
|
||||
@@ -77,6 +85,10 @@ type RunContext struct {
|
||||
Model string
|
||||
Mode string
|
||||
SelfCorrect bool
|
||||
// ExecProfile, when non-empty, is forwarded to the agent invocation as
|
||||
// --exec-profile (turn benchmark runner; the task benchmark leaves it
|
||||
// empty).
|
||||
ExecProfile string
|
||||
}
|
||||
|
||||
// TaskRunner runs a single benchmark task and returns its outcome.
|
||||
@@ -389,14 +401,26 @@ func runVerification(ctx context.Context, task BenchTask) TaskOutcome {
|
||||
return TaskOutcome{Passed: true}
|
||||
}
|
||||
|
||||
// streamJSONExitCode scans stream-json output for the terminal run_end event and
|
||||
// returns its exit code. ZERO emits one JSON object per line; the last run_end
|
||||
// (or final) event carries the exit code. Lines that don't parse are skipped.
|
||||
func streamJSONExitCode(output []byte) (int, bool) {
|
||||
// streamJSONResult holds the terminal events parsed from one pass over a
|
||||
// stream-json output: the last run_end exit code (if any) and the last final
|
||||
// event's text (the agent's answer, if any). streamJSONExitCode and
|
||||
// streamJSONFinalText both consume this so the scanner limits and
|
||||
// malformed-event handling live in exactly one place.
|
||||
type streamJSONResult struct {
|
||||
exitCode int
|
||||
haveExit bool
|
||||
finalText string
|
||||
}
|
||||
|
||||
// parseStreamJSON scans stream-json output once and returns the terminal
|
||||
// run_end exit code and the final event's text. ZERO emits one JSON object per
|
||||
// line; the last run_end carries the exit code and the last final carries the
|
||||
// answer. Lines that don't parse are skipped. The scanner buffer is raised so a
|
||||
// large single-line event (e.g. a big final answer) doesn't error out.
|
||||
func parseStreamJSON(output []byte) streamJSONResult {
|
||||
scanner := bufio.NewScanner(bytes.NewReader(output))
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
|
||||
exitCode := 0
|
||||
found := false
|
||||
var r streamJSONResult
|
||||
for scanner.Scan() {
|
||||
line := bytes.TrimSpace(scanner.Bytes())
|
||||
if len(line) == 0 || line[0] != '{' {
|
||||
@@ -405,16 +429,40 @@ func streamJSONExitCode(output []byte) (int, bool) {
|
||||
var event struct {
|
||||
Type string `json:"type"`
|
||||
ExitCode *int `json:"exitCode"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
if err := json.Unmarshal(line, &event); err != nil {
|
||||
continue
|
||||
}
|
||||
if event.Type == "run_end" && event.ExitCode != nil {
|
||||
exitCode = *event.ExitCode
|
||||
found = true
|
||||
r.exitCode = *event.ExitCode
|
||||
r.haveExit = true
|
||||
}
|
||||
if event.Type == "final" {
|
||||
r.finalText = event.Text
|
||||
}
|
||||
}
|
||||
return exitCode, found
|
||||
return r
|
||||
}
|
||||
|
||||
// streamJSONExitCode scans stream-json output for the terminal run_end event and
|
||||
// returns its exit code. The last run_end event carries the exit code. Lines
|
||||
// that don't parse are skipped.
|
||||
func streamJSONExitCode(output []byte) (int, bool) {
|
||||
r := parseStreamJSON(output)
|
||||
return r.exitCode, r.haveExit
|
||||
}
|
||||
|
||||
// streamJSONFinalText scans stream-json output for the terminal "final" event
|
||||
// and returns its text (the agent's final answer). ZERO emits one final event
|
||||
// per run on both the success and incomplete paths (exec.go writer.final), so
|
||||
// the last one wins. The turn benchmark writes this to .zero-answer.txt in the
|
||||
// fixture copy so nav answer-oracles can grep the captured answer rather than
|
||||
// the raw stream. Returns "" when no final event was emitted (e.g. a provider
|
||||
// error path) — the caller writes an empty file and a nav grep then fails,
|
||||
// which is the correct outcome for a run that produced no answer.
|
||||
func streamJSONFinalText(output []byte) string {
|
||||
return parseStreamJSON(output).finalText
|
||||
}
|
||||
|
||||
func firstLine(text string) string {
|
||||
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
module edit
|
||||
|
||||
go 1.22
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
module fix
|
||||
|
||||
go 1.22
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
module nav
|
||||
|
||||
go 1.22
|
||||
+4
@@ -22,3 +22,7 @@ func greet(name string) string {
|
||||
func main() {
|
||||
fmt.Println(greet("world"))
|
||||
}
|
||||
|
||||
// TODO: replace the demo greet with the real client call before shipping.
|
||||
// (Present so nav-05's find-the-markers task has a non-zero, inspection-
|
||||
// required answer; an agent that always emits "count: 0" fails.)
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package nav
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestGreet is a real test so nav-04's "count the test functions" has a
|
||||
// non-zero, inspection-required answer: an agent that never opens the workspace
|
||||
// and always emits "count: 0" fails, which is the whole point of graduating nav
|
||||
// into the correctness tier. It runs green offline (no third-party deps).
|
||||
func TestGreet(t *testing.T) {
|
||||
if got := greet("x"); got != "hello, x" {
|
||||
t.Fatalf("greet(x) = %q, want %q", got, "hello, x")
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
module refactor
|
||||
|
||||
go 1.22
|
||||
@@ -28,6 +28,12 @@ func Wrapper(name string) string {
|
||||
return GreetByName(name)
|
||||
}
|
||||
|
||||
// greetWrapped is Wrapper's single caller — refactor-05 inlines Wrapper here
|
||||
// and removes the Wrapper function.
|
||||
func greetWrapped(name string) string {
|
||||
return Wrapper(name)
|
||||
}
|
||||
|
||||
// stats is a bare map used across the file — refactor-04 introduces a named type.
|
||||
var stats = map[string]int{}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/execprofile"
|
||||
"github.com/Gitlawb/zero/internal/trace"
|
||||
)
|
||||
|
||||
@@ -27,7 +28,38 @@ import (
|
||||
// buildPassedTasks/buildPassRate cover the non-positive build-check classes
|
||||
// (refactor); latencyOnlyTasks covers the no-oracle classes (nav/longproc/
|
||||
// longctx/parallel). tasksAttempted is still every task run.
|
||||
const TurnSchemaVersion = 2
|
||||
//
|
||||
// v3 (Phase 0 oracle hardening) strengthens the oracles so the tiers are honest: edit/refactor
|
||||
// now carry a stamped oracle_test.go compiled by `go test ./...` (the Go
|
||||
// compiler is the structural verifier — a no-op refactor or a missing field
|
||||
// fails to compile), and nav carries an answer-oracle grepping the agent's
|
||||
// captured final answer. refactor graduates from build to correctness (its
|
||||
// oracle is now positive), nav graduates from latency-only to correctness, and
|
||||
// the build tier is empty (nothing is only-build-checked anymore). The result
|
||||
// SHAPE is unchanged — only class-list membership shifts — but
|
||||
// correctnessPassRate now means something strictly stronger, so the bump
|
||||
// prevents a v2→v2 cross-version comparison from misreading the jump as a model
|
||||
// improvement. New counts: correctness 34 (edit 10 + fix 8 + nav 10 + refactor
|
||||
// 6), build 0, latency 14 (longproc 4 + longctx 4 + parallel 6).
|
||||
//
|
||||
// v4 adds tasksErrored: the number of tasks whose every iteration produced no
|
||||
// accepted benchmark sample. That covers pre-run failures (missing binary,
|
||||
// spawn error, process crash) and harness errors after a successful agent run
|
||||
// (e.g. oracle stamping failed) alike — in every case the iteration yields no
|
||||
// usable measurement. Errored tasks were previously visible only in warnings,
|
||||
// so a run where nothing was actually measured still printed a clean-looking
|
||||
// 0% pass summary and exited 0. tasksErrored makes that state first-class:
|
||||
// the summary surfaces it, and the turn command exits nonzero when every
|
||||
// attempted task errored. Accounting: an errored oracle task stays in its
|
||||
// tier denominator as a failure; an errored latency-only task counts in
|
||||
// latencyOnlyTasks and, as always, in no pass rate.
|
||||
//
|
||||
// v5 is ADDITIVE ONLY: execProfile records the execution profile the run was
|
||||
// benchmarked under (omitted when none was selected), so profile A/B captures
|
||||
// are self-describing and two reports can't be compared without noticing they
|
||||
// ran different postures. No existing field changed shape or meaning; a v4
|
||||
// consumer reading a v5 report misses only the new field.
|
||||
const TurnSchemaVersion = 5
|
||||
|
||||
// TurnRunner runs one benchmark task and reports its outcome plus the captured
|
||||
// per-turn trace. A non-nil Err means the run failed to execute (process crash);
|
||||
@@ -54,6 +86,10 @@ type TurnBenchConfig struct {
|
||||
Model string
|
||||
Mode string
|
||||
SelfCorrect bool
|
||||
// ExecProfile, when non-empty, runs every task under the named execution
|
||||
// profile (zero exec --exec-profile) and stamps it into the result so the
|
||||
// report is self-describing for profile A/B comparisons.
|
||||
ExecProfile string
|
||||
Version string
|
||||
Commit string
|
||||
// Iterations is how many times each task is run. The per-process `zero exec`
|
||||
@@ -89,10 +125,11 @@ type LatencySource struct {
|
||||
// ClassSummary is the per-class (task group) roll-up.
|
||||
//
|
||||
// Passed is the count that passed THIS class's oracle: a correctness pass for
|
||||
// edit/fix, a build pass for refactor, and always 0 for the no-oracle classes
|
||||
// (nav/longproc/longctx/parallel). Verified is how many tasks in the class
|
||||
// carry an oracle (correctness or build); LatencyOnly is how many carry none.
|
||||
// A latency-only class therefore reports Passed=0, Verified=0, LatencyOnly=Tasks.
|
||||
// the positive-oracle classes (edit/fix/nav/refactor), and always 0 for the
|
||||
// no-oracle classes (longproc/longctx/parallel) — the build tier is empty in v3
|
||||
// (refactor graduated to correctness). Verified is how many tasks in the class
|
||||
// carry an oracle; LatencyOnly is how many carry none. A latency-only class
|
||||
// therefore reports Passed=0, Verified=0, LatencyOnly=Tasks.
|
||||
type ClassSummary struct {
|
||||
Tasks int `json:"tasks"`
|
||||
Verified int `json:"verified"`
|
||||
@@ -107,13 +144,14 @@ type ClassSummary struct {
|
||||
// Pass/fail is split into three oracle tiers so it cannot be misread as a
|
||||
// blanket correctness verdict:
|
||||
// - Correctness (tasksVerified / tasksPassed / correctnessPassRate): tasks
|
||||
// with a positive oracle (edit/fix). This is the only pass rate that can
|
||||
// move with model quality.
|
||||
// - Build-only (buildCheckedTasks / buildPassedTasks / buildPassRate): tasks
|
||||
// whose oracle is a non-positive build check (refactor `go build`). A pass
|
||||
// means it compiles, not that the refactor is correct.
|
||||
// - Latency-only (latencyOnlyTasks): tasks with no oracle (nav/longproc/
|
||||
// longctx/parallel). They ran for latency and span attribution only and are
|
||||
// with a positive oracle (edit/fix/nav/refactor). This is the only pass
|
||||
// rate that can move with model quality.
|
||||
// - Build-only (buildCheckedTasks / buildPassedTasks / buildPassRate): the
|
||||
// non-positive build-check tier. Empty in v3 — refactor graduated to
|
||||
// correctness (its stamped oracle is positive) — so buildCheckedTasks is
|
||||
// always 0 and buildPassRate is 0. The fields remain for schema stability.
|
||||
// - Latency-only (latencyOnlyTasks): tasks with no oracle (longproc/longctx/
|
||||
// parallel). They ran for latency and span attribution only and are
|
||||
// excluded from every pass rate.
|
||||
//
|
||||
// tasksAttempted is still the total number of tasks run across all three tiers.
|
||||
@@ -124,11 +162,13 @@ type TurnBenchResult struct {
|
||||
Suite string `json:"suite"`
|
||||
Model string `json:"model"`
|
||||
Mode string `json:"mode,omitempty"`
|
||||
ExecProfile string `json:"execProfile,omitempty"`
|
||||
SelfCorrect bool `json:"selfCorrect"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Commit string `json:"commit,omitempty"`
|
||||
Date string `json:"date"`
|
||||
TasksAttempted int `json:"tasksAttempted"`
|
||||
TasksErrored int `json:"tasksErrored"`
|
||||
TasksVerified int `json:"tasksVerified"`
|
||||
TasksPassed int `json:"tasksPassed"`
|
||||
LatencyOnlyTasks int `json:"latencyOnlyTasks"`
|
||||
@@ -174,6 +214,19 @@ func RunTurnBench(ctx context.Context, set TaskSet, cfg TurnBenchConfig) (TurnBe
|
||||
if cfg.Runner == nil {
|
||||
return TurnBenchResult{}, errors.New("turn benchmark requires a runner")
|
||||
}
|
||||
// Canonicalize the profile once at the boundary so every consumer — the
|
||||
// child exec args, the stamped result, the summary — carries the same
|
||||
// catalog name regardless of the caller's casing/whitespace, and a direct
|
||||
// library caller cannot slip an unknown name into a report the way the CLI
|
||||
// (which validates at parse time) cannot.
|
||||
benchProfile := strings.TrimSpace(cfg.ExecProfile)
|
||||
if benchProfile != "" {
|
||||
profile, ok := execprofile.Lookup(benchProfile)
|
||||
if !ok {
|
||||
return TurnBenchResult{}, fmt.Errorf("unknown execution profile %q (valid: %s)", cfg.ExecProfile, strings.Join(execprofile.Names(), ", "))
|
||||
}
|
||||
benchProfile = profile.Name
|
||||
}
|
||||
iterations := cfg.Iterations
|
||||
if iterations < 1 {
|
||||
iterations = 1
|
||||
@@ -182,7 +235,7 @@ func RunTurnBench(ctx context.Context, set TaskSet, cfg TurnBenchConfig) (TurnBe
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
rc := RunContext{Model: cfg.Model, Mode: cfg.Mode, SelfCorrect: cfg.SelfCorrect}
|
||||
rc := RunContext{Model: cfg.Model, Mode: cfg.Mode, SelfCorrect: cfg.SelfCorrect, ExecProfile: benchProfile}
|
||||
|
||||
perSpanSamples := map[string][]float64{}
|
||||
classWalls := map[string][]float64{}
|
||||
@@ -212,6 +265,7 @@ func RunTurnBench(ctx context.Context, set TaskSet, cfg TurnBenchConfig) (TurnBe
|
||||
Suite: strings.TrimSpace(set.ID),
|
||||
Model: strings.TrimSpace(cfg.Model),
|
||||
Mode: strings.TrimSpace(cfg.Mode),
|
||||
ExecProfile: benchProfile,
|
||||
SelfCorrect: cfg.SelfCorrect,
|
||||
Version: strings.TrimSpace(cfg.Version),
|
||||
Commit: strings.TrimSpace(cfg.Commit),
|
||||
@@ -234,6 +288,7 @@ func RunTurnBench(ctx context.Context, set TaskSet, cfg TurnBenchConfig) (TurnBe
|
||||
// runner is cold-start, so a flaky pass on one iteration and a fail on
|
||||
// another is a real regression signal, not noise to average away.
|
||||
passedForTask := true
|
||||
erroredIterations := 0
|
||||
for iter := 0; iter < iterations; iter++ {
|
||||
outcome := cfg.Runner(ctx, task, rc)
|
||||
if outcome.TraceIssue != "" {
|
||||
@@ -251,6 +306,7 @@ func RunTurnBench(ctx context.Context, set TaskSet, cfg TurnBenchConfig) (TurnBe
|
||||
Message: fmt.Sprintf("task %s: %v", task.ID, outcome.Err),
|
||||
})
|
||||
passedForTask = false
|
||||
erroredIterations++
|
||||
continue
|
||||
}
|
||||
if !outcome.Passed {
|
||||
@@ -291,6 +347,14 @@ func RunTurnBench(ctx context.Context, set TaskSet, cfg TurnBenchConfig) (TurnBe
|
||||
// counters. A latency-only task (no verificationCommand) is never counted
|
||||
// in any pass rate even when the runner reports Passed — an exit-0
|
||||
// read-only run proves the turn ran, not that the answer was right.
|
||||
if erroredIterations == iterations {
|
||||
// Every iteration produced no accepted sample (spawn failure or a
|
||||
// harness error after the run). The task still counts in its tier
|
||||
// below — an oracle task as a failure, a latency-only task in no
|
||||
// pass rate as always — but the errored state is first-class so a
|
||||
// broken run can never print a clean-looking summary.
|
||||
result.TasksErrored++
|
||||
}
|
||||
hasOracle := len(task.VerificationCommand) > 0
|
||||
switch {
|
||||
case !hasOracle:
|
||||
@@ -432,17 +496,35 @@ func FormatTurnBenchSummary(result TurnBenchResult) string {
|
||||
"model: " + displayOrUnknown(result.Model),
|
||||
// The headline separates the three oracle tiers so an exit-0 read-only
|
||||
// task can never inflate a "pass rate" that reads as correctness:
|
||||
// correctness (positive oracle, edit/fix), build (non-positive `go build`,
|
||||
// refactor), and latency-only (no oracle, nav/longproc/longctx/parallel).
|
||||
// correctness (positive oracle: edit/fix/nav/refactor), build (empty in
|
||||
// v3 — refactor graduated to correctness), and latency-only (no oracle:
|
||||
// longproc/longctx/parallel).
|
||||
fmt.Sprintf("tasks: %d total | correctness %d/%d (%.0f%%) | build %d/%d (%.0f%%) | latency-only %d | %d iter",
|
||||
result.TasksAttempted,
|
||||
result.TasksPassed, result.TasksVerified, result.CorrectnessPassRate*100,
|
||||
result.BuildPassedTasks, result.BuildCheckedTasks, result.BuildPassRate*100,
|
||||
result.LatencyOnlyTasks, result.Iterations),
|
||||
}
|
||||
if result.TasksErrored > 0 {
|
||||
lines = append(lines, fmt.Sprintf("ERRORED: %d task(s) produced no accepted benchmark sample (spawn/crash or harness error) — errored oracle tasks count as failures in their tier's pass rate; latency-only tasks stay out of pass rates as always", result.TasksErrored))
|
||||
shown := 0
|
||||
for _, warning := range result.Warnings {
|
||||
if warning.Metric != "run" {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, " "+warning.Message)
|
||||
shown++
|
||||
if shown == 3 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if result.Mode != "" {
|
||||
lines = append(lines, "mode: "+result.Mode)
|
||||
}
|
||||
if result.ExecProfile != "" {
|
||||
lines = append(lines, "exec-profile: "+result.ExecProfile)
|
||||
}
|
||||
if len(result.TopLatency) > 0 {
|
||||
lines = append(lines, "top latency sources:")
|
||||
for _, src := range result.TopLatency {
|
||||
@@ -509,11 +591,13 @@ func NewTurnExecRunner(binary string, extraArgs ...string) TurnRunner {
|
||||
// fixture or bleed into a later iteration of the same task. When no
|
||||
// fixture is configured the agent runs in the caller's cwd as before.
|
||||
if fixture := strings.TrimSpace(task.WorkspaceFixture); fixture != "" {
|
||||
copyDir, cerr := copyFixture(fixture)
|
||||
copyDir, parent, cerr := copyFixture(fixture)
|
||||
if cerr != nil {
|
||||
return TurnTaskOutcome{Err: fmt.Errorf("isolate fixture: %w", cerr)}
|
||||
}
|
||||
defer os.RemoveAll(copyDir)
|
||||
// Clean the whole unique parent (which owns copyDir) so the
|
||||
// per-invocation scratch dir never leaks.
|
||||
defer os.RemoveAll(parent)
|
||||
task.WorkspaceFixture = copyDir
|
||||
}
|
||||
|
||||
@@ -577,6 +661,22 @@ func NewTurnExecRunner(binary string, extraArgs ...string) TurnRunner {
|
||||
if outcome.VerifyErr != "" {
|
||||
return outcome
|
||||
}
|
||||
// Stamp the compiler-backed oracle and capture the agent's final answer
|
||||
// BEFORE running the verification command. Both write into the fixture
|
||||
// copy (task.WorkspaceFixture, set to copyDir above) so runVerification —
|
||||
// which uses that same dir as cmd.Dir — sees them. Stamping happens only
|
||||
// after the agent run so the oracle test can't interfere with the agent's
|
||||
// own go build/test during the task (e.g. refactor-03's package-zeroapp
|
||||
// test would break a pre-rename build) and can't be pre-seen or tampered
|
||||
// with.
|
||||
if err := stampOracleAndAnswer(task, outBuf.Bytes()); err != nil {
|
||||
// Preserve the already-parsed trace, trace issue, and wall time: a
|
||||
// stamp failure is a harness error, but the run still produced a
|
||||
// trace worth attributing, so mutate the existing outcome rather than
|
||||
// returning a fresh one that drops Trace/TraceIssue.
|
||||
outcome.Err = fmt.Errorf("stamp oracle: %w", err)
|
||||
return outcome
|
||||
}
|
||||
if len(task.VerificationCommand) > 0 {
|
||||
if vOutcome := runVerification(ctx, task); !vOutcome.Passed {
|
||||
outcome.VerifyErr = strings.TrimSpace(vOutcome.Detail)
|
||||
@@ -584,7 +684,7 @@ func NewTurnExecRunner(binary string, extraArgs ...string) TurnRunner {
|
||||
}
|
||||
// A positive oracle (grep/test/build) passed: this is the only path
|
||||
// that sets Passed. A task with no verificationCommand is latency-only
|
||||
// (read-only nav/longproc/longctx/parallel): its exit 0 proves the turn
|
||||
// (read-only longproc/longctx/parallel): its exit 0 proves the turn
|
||||
// ran, not that the answer was right, so it never reports Passed and the
|
||||
// harness counts it in latencyOnlyTasks rather than any pass rate.
|
||||
outcome.Passed = true
|
||||
@@ -594,22 +694,41 @@ func NewTurnExecRunner(binary string, extraArgs ...string) TurnRunner {
|
||||
}
|
||||
|
||||
// copyFixture copies the fixture directory at src into a fresh temp dir and
|
||||
// returns the copy's path. Used to give each benchmark invocation an isolated
|
||||
// workspace so mutating tasks (edit/fix/refactor) can't dirty the checked-in
|
||||
// fixture or a later iteration.
|
||||
func copyFixture(src string) (string, error) {
|
||||
// returns the copy's path (dst) plus the unique parent dir that owns it (parent,
|
||||
// which the caller must RemoveAll to clean up dst and its sibling scratch space
|
||||
// together). Used to give each benchmark invocation an isolated workspace so
|
||||
// mutating tasks (edit/fix/refactor) can't dirty the checked-in fixture or a
|
||||
// later iteration.
|
||||
//
|
||||
// The copy is placed two levels BELOW the system temp root: a unique 0700
|
||||
// parent (created fresh per invocation via os.MkdirTemp, so concurrent runs and
|
||||
// different users can't share or traverse a predictable scratch dir) and the
|
||||
// fixture copy beneath it. Go ignores go.mod files in DIRECT children of the
|
||||
// system temp dir (a hijack guard), which would make the fixture's go.mod —
|
||||
// and thus every `go test ./...`/`go build ./...` oracle — fail with "cannot
|
||||
// find main module". Keeping the copy a grandchild of the temp root makes Go
|
||||
// respect the copied go.mod so the compiler-backed oracles actually run.
|
||||
func copyFixture(src string) (dst, parent string, err error) {
|
||||
info, err := os.Stat(src)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", "", err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return "", fmt.Errorf("fixture %q is not a directory", src)
|
||||
return "", "", fmt.Errorf("fixture %q is not a directory", src)
|
||||
}
|
||||
dst, err := os.MkdirTemp("", "zero-turn-fixture-*")
|
||||
// Unique, 0700-per-invocation parent directly under the temp root. The
|
||||
// fixture copy is created BENEATH it, so the copy is a grandchild of the
|
||||
// temp root and its go.mod is respected (see the doc comment above).
|
||||
parent, err = os.MkdirTemp(os.TempDir(), "zero-turn-bench-*")
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", "", err
|
||||
}
|
||||
err = filepath.WalkDir(src, func(path string, d fs.DirEntry, werr error) error {
|
||||
dst, err = os.MkdirTemp(parent, "zero-turn-fixture-*")
|
||||
if err != nil {
|
||||
os.RemoveAll(parent)
|
||||
return "", "", err
|
||||
}
|
||||
werr := filepath.WalkDir(src, func(path string, d fs.DirEntry, werr error) error {
|
||||
if werr != nil {
|
||||
return werr
|
||||
}
|
||||
@@ -627,11 +746,47 @@ func copyFixture(src string) (string, error) {
|
||||
}
|
||||
return os.WriteFile(target, data, 0o644)
|
||||
})
|
||||
if err != nil {
|
||||
os.RemoveAll(dst)
|
||||
return "", err
|
||||
if werr != nil {
|
||||
os.RemoveAll(parent) // removes dst too
|
||||
return "", "", werr
|
||||
}
|
||||
return dst, nil
|
||||
return dst, parent, nil
|
||||
}
|
||||
|
||||
// stampOracleAndAnswer writes the compiler-backed oracle test (when configured)
|
||||
// and the agent's captured final answer into the fixture copy, so the
|
||||
// verification command can compile the oracle and grep the answer. Both files
|
||||
// land in task.WorkspaceFixture, which the runner has already set to the
|
||||
// fixture copy and which runVerification uses as cmd.Dir. The answer file is
|
||||
// always written (empty when no final event was captured) so a nav answer-oracle
|
||||
// fails cleanly on a run that produced no answer rather than reading a stale or
|
||||
// absent file.
|
||||
//
|
||||
// A task that needs an oracle — a stamped oracle_test.go OR a verification
|
||||
// command that reads .zero-answer.txt — but has no workspace fixture is
|
||||
// rejected: there is nowhere safe to stamp, so runVerification would otherwise
|
||||
// run in the caller's cwd with the oracle never compiled (or the answer never
|
||||
// captured), which can read as a false pass. A fixtureless task with no oracle
|
||||
// and no verification (a pure latency-only run in the caller's cwd) is left
|
||||
// alone.
|
||||
func stampOracleAndAnswer(task BenchTask, outBuf []byte) error {
|
||||
dir := strings.TrimSpace(task.WorkspaceFixture)
|
||||
if dir == "" {
|
||||
if task.OracleTest != "" || len(task.VerificationCommand) > 0 {
|
||||
return fmt.Errorf("stamp oracle: task %q has an oracle or verification but no workspace fixture to stamp into", task.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if ot := task.OracleTest; ot != "" {
|
||||
if err := os.WriteFile(filepath.Join(dir, "oracle_test.go"), []byte(ot), 0o644); err != nil {
|
||||
return fmt.Errorf("write oracle_test.go: %w", err)
|
||||
}
|
||||
}
|
||||
answer := streamJSONFinalText(outBuf)
|
||||
if err := os.WriteFile(filepath.Join(dir, ".zero-answer.txt"), []byte(answer), 0o644); err != nil {
|
||||
return fmt.Errorf("write .zero-answer.txt: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildTurnExecArgs(task BenchTask, rc RunContext, tracePath string, extraArgs []string) []string {
|
||||
@@ -645,6 +800,9 @@ func buildTurnExecArgs(task BenchTask, rc RunContext, tracePath string, extraArg
|
||||
if rc.SelfCorrect {
|
||||
args = append(args, "--self-correct")
|
||||
}
|
||||
if profile := strings.TrimSpace(rc.ExecProfile); profile != "" {
|
||||
args = append(args, "--exec-profile", profile)
|
||||
}
|
||||
args = append(args, extraArgs...)
|
||||
args = append(args, task.Prompt)
|
||||
return args
|
||||
@@ -658,7 +816,15 @@ func ResolveBinary(explicit string) (string, error) {
|
||||
if _, err := os.Stat(v); err != nil {
|
||||
return "", fmt.Errorf("trace binary not found: %w", err)
|
||||
}
|
||||
return v, nil
|
||||
// Pin the explicit path to an absolute one: the turn runner sets each
|
||||
// child's cmd.Dir to a per-task fixture copy, so a relative path
|
||||
// (./zero, the Makefile default) that stats fine here would fail to
|
||||
// spawn from inside every fixture dir, silently erroring all tasks.
|
||||
absolute, err := filepath.Abs(v)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve trace binary path: %w", err)
|
||||
}
|
||||
return absolute, nil
|
||||
}
|
||||
if path, err := exec.LookPath("zero"); err == nil {
|
||||
return path, nil
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -51,21 +52,26 @@ func cannedTrace(genMs, toolMs int, tokens int64) *trace.TurnTrace {
|
||||
}
|
||||
|
||||
func TestRunTurnBenchAggregation(t *testing.T) {
|
||||
// v3 tier model: nav and refactor both carry a verificationCommand and are
|
||||
// NOT in buildOnlyClasses, so they count as correctness; longproc has no
|
||||
// oracle and is latency-only. The build tier is empty (buildOnlyClasses is
|
||||
// nil), matching the v3 baseline manifest where refactor graduated from
|
||||
// build to correctness.
|
||||
set := TaskSet{
|
||||
ID: "fake-suite",
|
||||
Tasks: []BenchTask{
|
||||
{ID: "t1", Class: "nav", Prompt: "p1"}, // latency-only
|
||||
{ID: "t2", Class: "nav", Prompt: "p2"}, // latency-only
|
||||
{ID: "t3", Class: "edit", Prompt: "p3", VerificationCommand: []string{"true"}}, // correctness
|
||||
{ID: "t4", Class: "refactor", Prompt: "p4", VerificationCommand: []string{"true"}}, // build-only
|
||||
{ID: "t1", Class: "nav", Prompt: "p1", VerificationCommand: []string{"true"}}, // correctness
|
||||
{ID: "t2", Class: "edit", Prompt: "p2", VerificationCommand: []string{"true"}}, // correctness
|
||||
{ID: "t3", Class: "refactor", Prompt: "p3", VerificationCommand: []string{"true"}}, // correctness
|
||||
{ID: "t4", Class: "longproc", Prompt: "p4"}, // latency-only
|
||||
},
|
||||
BuildOnlyClasses: []string{"refactor"},
|
||||
BuildOnlyClasses: nil,
|
||||
}
|
||||
canned := map[string]*trace.TurnTrace{
|
||||
"t1": cannedTrace(100, 10, 1000),
|
||||
"t2": cannedTrace(300, 10, 1000),
|
||||
"t3": cannedTrace(200, 50, 2000),
|
||||
"t4": cannedTrace(150, 20, 1500),
|
||||
"t2": cannedTrace(200, 50, 2000),
|
||||
"t3": cannedTrace(150, 20, 1500),
|
||||
"t4": cannedTrace(300, 10, 1000),
|
||||
}
|
||||
cfg := TurnBenchConfig{
|
||||
Model: "fake-model",
|
||||
@@ -77,33 +83,36 @@ func TestRunTurnBenchAggregation(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("RunTurnBench: %v", err)
|
||||
}
|
||||
// 4 tasks attempted; the tier split is 2 latency-only, 1 correctness, 1 build.
|
||||
// 4 tasks attempted; the v3 tier split is 3 correctness (nav, edit, refactor),
|
||||
// 1 latency-only (longproc), and 0 build (the build tier is empty).
|
||||
if result.TasksAttempted != 4 {
|
||||
t.Fatalf("attempted=%d, want 4", result.TasksAttempted)
|
||||
}
|
||||
if result.TasksVerified != 1 || result.TasksPassed != 1 {
|
||||
t.Fatalf("correctness verified=%d passed=%d, want 1/1", result.TasksVerified, result.TasksPassed)
|
||||
if result.TasksVerified != 3 || result.TasksPassed != 3 {
|
||||
t.Fatalf("correctness verified=%d passed=%d, want 3/3", result.TasksVerified, result.TasksPassed)
|
||||
}
|
||||
if result.LatencyOnlyTasks != 2 {
|
||||
t.Fatalf("latencyOnly=%d, want 2", result.LatencyOnlyTasks)
|
||||
if result.LatencyOnlyTasks != 1 {
|
||||
t.Fatalf("latencyOnly=%d, want 1", result.LatencyOnlyTasks)
|
||||
}
|
||||
if result.BuildCheckedTasks != 1 || result.BuildPassedTasks != 1 {
|
||||
t.Fatalf("build checked=%d passed=%d, want 1/1", result.BuildCheckedTasks, result.BuildPassedTasks)
|
||||
if result.BuildCheckedTasks != 0 || result.BuildPassedTasks != 0 {
|
||||
t.Fatalf("build checked=%d passed=%d, want 0/0 (empty build tier)", result.BuildCheckedTasks, result.BuildPassedTasks)
|
||||
}
|
||||
if result.CorrectnessPassRate != 1.0 {
|
||||
t.Fatalf("correctnessPassRate=%v, want 1.0", result.CorrectnessPassRate)
|
||||
}
|
||||
if result.BuildPassRate != 1.0 {
|
||||
t.Fatalf("buildPassRate=%v, want 1.0", result.BuildPassRate)
|
||||
// passRate returns 0 for a 0/0 denominator, so an empty build tier reports 0
|
||||
// (not NaN) and cannot be misread as a perfect build score.
|
||||
if result.BuildPassRate != 0 {
|
||||
t.Fatalf("buildPassRate=%v, want 0 (empty build tier is 0/0 -> 0)", result.BuildPassRate)
|
||||
}
|
||||
if len(result.CorrectnessClasses) != 1 || result.CorrectnessClasses[0] != "edit" {
|
||||
t.Fatalf("correctnessClasses=%v, want [edit]", result.CorrectnessClasses)
|
||||
if len(result.CorrectnessClasses) != 3 || result.CorrectnessClasses[0] != "edit" || result.CorrectnessClasses[1] != "nav" || result.CorrectnessClasses[2] != "refactor" {
|
||||
t.Fatalf("correctnessClasses=%v, want [edit nav refactor]", result.CorrectnessClasses)
|
||||
}
|
||||
if len(result.BuildOnlyClasses) != 1 || result.BuildOnlyClasses[0] != "refactor" {
|
||||
t.Fatalf("buildOnlyClasses=%v, want [refactor]", result.BuildOnlyClasses)
|
||||
if len(result.BuildOnlyClasses) != 0 {
|
||||
t.Fatalf("buildOnlyClasses=%v, want empty (v3: build tier empty)", result.BuildOnlyClasses)
|
||||
}
|
||||
if len(result.LatencyOnlyClasses) != 1 || result.LatencyOnlyClasses[0] != "nav" {
|
||||
t.Fatalf("latencyOnlyClasses=%v, want [nav]", result.LatencyOnlyClasses)
|
||||
if len(result.LatencyOnlyClasses) != 1 || result.LatencyOnlyClasses[0] != "longproc" {
|
||||
t.Fatalf("latencyOnlyClasses=%v, want [longproc]", result.LatencyOnlyClasses)
|
||||
}
|
||||
if result.SchemaVersion != TurnSchemaVersion {
|
||||
t.Fatalf("schemaVersion = %d, want %d", result.SchemaVersion, TurnSchemaVersion)
|
||||
@@ -112,8 +121,8 @@ func TestRunTurnBenchAggregation(t *testing.T) {
|
||||
t.Fatalf("date = %q", result.Date)
|
||||
}
|
||||
|
||||
// Per-span: generation appears in all four (100+300+200+150=750ms), tool in
|
||||
// all four (10+10+50+20=90ms). Count must equal the number of tasks * iterations.
|
||||
// Per-span: generation appears in all four (100+200+150+300=750ms), tool in
|
||||
// all four (10+50+20+10=90ms). Count must equal the number of tasks * iterations.
|
||||
gen := result.PerSpan[trace.SpanGeneration]
|
||||
if gen.Count != 4 {
|
||||
t.Fatalf("generation count = %d, want 4", gen.Count)
|
||||
@@ -135,7 +144,7 @@ func TestRunTurnBenchAggregation(t *testing.T) {
|
||||
t.Fatalf("top latency not ranked by share: %+v", result.TopLatency)
|
||||
}
|
||||
|
||||
// Totals: 4 model requests, 4 tool calls, input tokens 1000+1000+2000+1500=5500.
|
||||
// Totals: 4 model requests, 4 tool calls, input tokens 1000+2000+1500+1000=5500.
|
||||
if result.Totals.ModelRequests != 4 {
|
||||
t.Fatalf("modelRequests = %d, want 4", result.Totals.ModelRequests)
|
||||
}
|
||||
@@ -149,10 +158,10 @@ func TestRunTurnBenchAggregation(t *testing.T) {
|
||||
t.Fatalf("outputTokens = %d, want 2750", result.Totals.OutputTokens)
|
||||
}
|
||||
|
||||
// Per-class tier roll-up: nav is latency-only (0 verified, 2 latency-only),
|
||||
// edit is correctness (1/1 verified passed), refactor is build (1/1 passed).
|
||||
// Per-class tier roll-up: nav/edit/refactor are correctness (1/1 verified
|
||||
// passed, 0 latency-only); longproc is latency-only (0 verified, 1 latency).
|
||||
nav := result.PerClass["nav"]
|
||||
if nav.Tasks != 2 || nav.Verified != 0 || nav.Passed != 0 || nav.LatencyOnly != 2 {
|
||||
if nav.Tasks != 1 || nav.Verified != 1 || nav.Passed != 1 || nav.LatencyOnly != 0 {
|
||||
t.Fatalf("nav class = %+v", nav)
|
||||
}
|
||||
edit := result.PerClass["edit"]
|
||||
@@ -163,6 +172,10 @@ func TestRunTurnBenchAggregation(t *testing.T) {
|
||||
if refactor.Tasks != 1 || refactor.Verified != 1 || refactor.Passed != 1 || refactor.LatencyOnly != 0 {
|
||||
t.Fatalf("refactor class = %+v", refactor)
|
||||
}
|
||||
longproc := result.PerClass["longproc"]
|
||||
if longproc.Tasks != 1 || longproc.Verified != 0 || longproc.Passed != 0 || longproc.LatencyOnly != 1 {
|
||||
t.Fatalf("longproc class = %+v", longproc)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunTurnBenchLatencyOnlyNeverPassed asserts the honesty gate: a task with
|
||||
@@ -424,17 +437,31 @@ func TestCopyFixtureIsolatesSourceFromMutation(t *testing.T) {
|
||||
t.Fatalf("read source main.go: %v", err)
|
||||
}
|
||||
|
||||
copyDir, err := copyFixture(src)
|
||||
copyDir, parent, err := copyFixture(src)
|
||||
if err != nil {
|
||||
t.Fatalf("copyFixture: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(copyDir)
|
||||
defer os.RemoveAll(parent) // cleans copyDir too, since copyDir lives under parent
|
||||
|
||||
// The copy must be a real copy, not a symlink/alias of the source, and
|
||||
// mutating it must not touch the source.
|
||||
if copyDir == src {
|
||||
t.Fatalf("copyFixture returned the source dir, not a copy: %s", copyDir)
|
||||
}
|
||||
// The copy must be a grandchild of the system temp root (parent is a direct
|
||||
// child of Temp, copyDir is a direct child of parent). Go ignores go.mod in
|
||||
// direct children of Temp (a hijack guard), so this nesting is what lets the
|
||||
// compiler-backed oracles actually run in the copy. Compare via filepath.Clean
|
||||
// because os.TempDir() may carry a trailing slash (macOS $TMPDIR ends in
|
||||
// "/T/") while filepath.Dir strips it; the raw != would false-fail on macOS
|
||||
// even though the parent is genuinely a direct child.
|
||||
tempRoot := filepath.Clean(os.TempDir())
|
||||
if filepath.Dir(parent) != tempRoot {
|
||||
t.Fatalf("parent %q is not a direct child of temp root %q", parent, tempRoot)
|
||||
}
|
||||
if filepath.Dir(copyDir) != parent {
|
||||
t.Fatalf("copyDir %q is not nested under parent %q", copyDir, parent)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(copyDir, "main.go"), []byte("package main\n\n// mutated\nfunc main() {}\n"), 0o644); err != nil {
|
||||
t.Fatalf("mutate copy: %v", err)
|
||||
}
|
||||
@@ -453,3 +480,778 @@ func TestCopyFixtureIsolatesSourceFromMutation(t *testing.T) {
|
||||
t.Fatalf("file added to copy appeared in source: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunTurnBenchBuildOnlyMechanism exercises the build-only tier code path in
|
||||
// isolation. The v3 baseline manifest leaves this tier empty (refactor graduated
|
||||
// to correctness), but the mechanism is still supported for future use, so a
|
||||
// focused test keeps it covered: a class listed in buildOnlyClasses with a
|
||||
// verificationCommand counts in buildCheckedTasks/buildPassedTasks — never in
|
||||
// tasksVerified/correctnessPassRate — and a build-pass cannot leak into the
|
||||
// correctness number.
|
||||
func TestRunTurnBenchBuildOnlyMechanism(t *testing.T) {
|
||||
set := TaskSet{
|
||||
ID: "build-suite",
|
||||
Tasks: []BenchTask{
|
||||
{ID: "b1", Class: "buildcheck", Prompt: "p", VerificationCommand: []string{"true"}},
|
||||
},
|
||||
BuildOnlyClasses: []string{"buildcheck"},
|
||||
}
|
||||
canned := map[string]*trace.TurnTrace{"b1": cannedTrace(120, 30, 900)}
|
||||
result, err := RunTurnBench(context.Background(), set, TurnBenchConfig{
|
||||
Model: "fake-model",
|
||||
Runner: fakeTurnRunner(canned),
|
||||
Now: func() time.Time { return time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RunTurnBench: %v", err)
|
||||
}
|
||||
if result.BuildCheckedTasks != 1 || result.BuildPassedTasks != 1 {
|
||||
t.Fatalf("build checked=%d passed=%d, want 1/1", result.BuildCheckedTasks, result.BuildPassedTasks)
|
||||
}
|
||||
if result.BuildPassRate != 1.0 {
|
||||
t.Fatalf("buildPassRate=%v, want 1.0", result.BuildPassRate)
|
||||
}
|
||||
// A build-only task must NOT count toward correctness.
|
||||
if result.TasksVerified != 0 || result.TasksPassed != 0 {
|
||||
t.Fatalf("build-only leaked into correctness: verified=%d passed=%d, want 0/0",
|
||||
result.TasksVerified, result.TasksPassed)
|
||||
}
|
||||
if result.CorrectnessPassRate != 0 {
|
||||
t.Fatalf("correctnessPassRate=%v, want 0 with only build-only tasks", result.CorrectnessPassRate)
|
||||
}
|
||||
if len(result.BuildOnlyClasses) != 1 || result.BuildOnlyClasses[0] != "buildcheck" {
|
||||
t.Fatalf("buildOnlyClasses=%v, want [buildcheck]", result.BuildOnlyClasses)
|
||||
}
|
||||
if len(result.CorrectnessClasses) != 0 {
|
||||
t.Fatalf("correctnessClasses=%v, want empty", result.CorrectnessClasses)
|
||||
}
|
||||
bc := result.PerClass["buildcheck"]
|
||||
if bc.Tasks != 1 || bc.Verified != 1 || bc.Passed != 1 || bc.LatencyOnly != 0 {
|
||||
t.Fatalf("buildcheck class = %+v, want 1/1 verified passed", bc)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTurnSchemaVersion pins the schema bump so a shape change is a conscious
|
||||
// decision. v3 recorded the tier reclassification (refactor structural-positive
|
||||
// and nav answer-oracles moved into correctnessPassRate). v4 adds tasksErrored:
|
||||
// tasks whose every iteration died before the agent produced a run are now
|
||||
// first-class in the report instead of visible only in warnings, so a
|
||||
// spawn-broken run cannot print a clean-looking summary or exit 0. v5 is
|
||||
// additive only: execProfile stamps the execution profile the run was
|
||||
// benchmarked under so profile A/B reports are self-describing.
|
||||
func TestTurnSchemaVersion(t *testing.T) {
|
||||
if TurnSchemaVersion != 5 {
|
||||
t.Fatalf("TurnSchemaVersion = %d, want 5", TurnSchemaVersion)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStreamJSONFinalTextExtractsAnswer, TestStreamJSONFinalTextEmptyWhenNoFinal,
|
||||
// and TestStreamJSONFinalTextLastWins cover the pure capture helper that the
|
||||
// nav answer-oracle depends on: it scans stream-json for the terminal "final"
|
||||
// event and returns its text, "" when none was emitted, and the last when
|
||||
// multiple appear (the success path emits exactly one, but the incomplete path
|
||||
// can also emit one, so last-wins matches streamJSONExitCode's tie-break).
|
||||
func TestStreamJSONFinalTextExtractsAnswer(t *testing.T) {
|
||||
out := []byte(`{"type":"text","text":"thinking..."}
|
||||
{"type":"final","text":"the keys are port, name, and retries"}
|
||||
{"type":"run_end","exitCode":0}
|
||||
`)
|
||||
if got := streamJSONFinalText(out); got != "the keys are port, name, and retries" {
|
||||
t.Fatalf("streamJSONFinalText = %q, want the final text", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamJSONFinalTextEmptyWhenNoFinal(t *testing.T) {
|
||||
out := []byte(`{"type":"text","text":"hi"}
|
||||
{"type":"run_end","exitCode":0}
|
||||
`)
|
||||
if got := streamJSONFinalText(out); got != "" {
|
||||
t.Fatalf("streamJSONFinalText = %q, want empty when no final event", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamJSONFinalTextLastWins(t *testing.T) {
|
||||
out := []byte(`{"type":"final","text":"first"}
|
||||
{"type":"final","text":"second"}
|
||||
`)
|
||||
if got := streamJSONFinalText(out); got != "second" {
|
||||
t.Fatalf("streamJSONFinalText = %q, want second (last final wins)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// loadBaselineTask returns the named task from the checked-in baseline manifest.
|
||||
// The stub-binary oracle tests below run the REAL manifest oracles against the
|
||||
// REAL fixtures through the REAL NewTurnExecRunner, so a manifest edit that
|
||||
// breaks an oracle is caught here rather than only in production.
|
||||
func loadBaselineTask(t *testing.T, id string) BenchTask {
|
||||
t.Helper()
|
||||
set, err := LoadTaskSet(filepath.Join("manifests", "baseline.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTaskSet: %v", err)
|
||||
}
|
||||
for _, task := range set.Tasks {
|
||||
if task.ID == id {
|
||||
return task
|
||||
}
|
||||
}
|
||||
t.Fatalf("manifest has no task %q", id)
|
||||
return BenchTask{}
|
||||
}
|
||||
|
||||
// runTurnStub runs one manifest task through the production NewTurnExecRunner
|
||||
// with a stub "zero" binary whose body is a POSIX sh script (writeExecStub
|
||||
// skips on Windows). The stub is invoked with cmd.Dir set to the fixture copy,
|
||||
// so it can both emit canned stream-json AND mutate the copy (apply or omit the
|
||||
// fix) before the runner stamps the oracle and runs the verification command.
|
||||
func runTurnStub(t *testing.T, task BenchTask, stubBody string) TurnTaskOutcome {
|
||||
t.Helper()
|
||||
stub := writeExecStub(t, stubBody)
|
||||
return NewTurnExecRunner(stub)(context.Background(), task, RunContext{Model: "fake-model"})
|
||||
}
|
||||
|
||||
// assertVerifyFailed asserts an outcome failed specifically because the oracle
|
||||
// rejected the work — Passed is false, there is no harness error (Err nil), and
|
||||
// VerifyErr carries the surfaced failure detail. This is stronger than merely
|
||||
// checking Passed=false: it distinguishes a genuine oracle rejection from a
|
||||
// harness error (Err set) or a silent non-pass (Passed=false with no VerifyErr,
|
||||
// which would mean the failure never went through verification at all). The
|
||||
// gating stubs all emit a clean run_end (exitCode 0), so a non-empty VerifyErr
|
||||
// here can only come from the verification command failing.
|
||||
func assertVerifyFailed(t *testing.T, label string, outcome TurnTaskOutcome) {
|
||||
t.Helper()
|
||||
if outcome.Err != nil {
|
||||
t.Fatalf("%s: want a verify fail, got harness error: %v", label, outcome.Err)
|
||||
}
|
||||
if outcome.Passed {
|
||||
t.Fatalf("%s: want verify fail, got Passed=true", label)
|
||||
}
|
||||
if strings.TrimSpace(outcome.VerifyErr) == "" {
|
||||
t.Fatalf("%s: want verify fail, but VerifyErr is empty (failure did not come from verification): %+v", label, outcome)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Gating tests: the oracle FAILS the wrong thing (no-op / wrong answer) ---
|
||||
|
||||
// TestStampedOracleRejectsNoOpRefactor is the core #712 fix: refactor used to
|
||||
// live in the build tier where a no-op `go build ./...` passed. Now a no-op
|
||||
// agent (the stub emits a clean run_end but touches nothing) leaves the fixture
|
||||
// with no formatGreeting helper, so the stamped `var _ = formatGreeting` fails
|
||||
// to compile and `go test ./...` fails — the task is failed, not passed.
|
||||
func TestStampedOracleRejectsNoOpRefactor(t *testing.T) {
|
||||
task := loadBaselineTask(t, "refactor-01")
|
||||
outcome := runTurnStub(t, task, `echo '{"type":"run_end","exitCode":0}'
|
||||
`)
|
||||
assertVerifyFailed(t, "no-op refactor", outcome)
|
||||
}
|
||||
|
||||
// TestStampedOracleRejectsMissingField proves edit-03's oracle is structural: a
|
||||
// no-op leaves Config with no Label field, so `var _ string = Config{}.Label`
|
||||
// fails to compile and the task fails.
|
||||
func TestStampedOracleRejectsMissingField(t *testing.T) {
|
||||
task := loadBaselineTask(t, "edit-03")
|
||||
outcome := runTurnStub(t, task, `echo '{"type":"run_end","exitCode":0}'
|
||||
`)
|
||||
assertVerifyFailed(t, "missing-field edit-03", outcome)
|
||||
}
|
||||
|
||||
// TestNavAnswerOracleRejectsWrongAnswer proves the nav oracle greps the CAPTURED
|
||||
// answer, not the raw stream: the stub emits a final answer missing the
|
||||
// required keys, the harness writes it to .zero-answer.txt, and the compound
|
||||
// grep fails — so a plausible-but-wrong answer cannot pass nav-09.
|
||||
func TestNavAnswerOracleRejectsWrongAnswer(t *testing.T) {
|
||||
task := loadBaselineTask(t, "nav-09")
|
||||
outcome := runTurnStub(t, task, `echo '{"type":"final","text":"the keys are foo and bar"}'
|
||||
echo '{"type":"run_end","exitCode":0}'
|
||||
`)
|
||||
assertVerifyFailed(t, "wrong nav-09 answer", outcome)
|
||||
}
|
||||
|
||||
// TestNavNoFinalTextFails proves a run that produced no answer fails nav: with
|
||||
// no "final" event, .zero-answer.txt is empty and the grep finds nothing.
|
||||
func TestNavNoFinalTextFails(t *testing.T) {
|
||||
task := loadBaselineTask(t, "nav-09")
|
||||
outcome := runTurnStub(t, task, `echo '{"type":"run_end","exitCode":0}'
|
||||
`)
|
||||
assertVerifyFailed(t, "nav-09 no final", outcome)
|
||||
}
|
||||
|
||||
// TestEdit05RejectsRewordedDebugPrint proves edit-05's oracle catches a reword,
|
||||
// not just a deletion: changing `fmt.Println("debug: starting")` to
|
||||
// `fmt.Println("starting up")` leaves a fmt.Println string-literal call, so the
|
||||
// stamped oracle's `strings.Contains(main.go, fmt.Println(")` check fails. A
|
||||
// plain `! grep 'debug: starting'` would have rubber-stamped this reword.
|
||||
func TestEdit05RejectsRewordedDebugPrint(t *testing.T) {
|
||||
task := loadBaselineTask(t, "edit-05")
|
||||
outcome := runTurnStub(t, task, `sed 's/debug: starting/starting up/' main.go > .zero-tmp && mv .zero-tmp main.go
|
||||
echo '{"type":"run_end","exitCode":0}'
|
||||
`)
|
||||
assertVerifyFailed(t, "reworded edit-05 debug print", outcome)
|
||||
}
|
||||
|
||||
// TestEdit05RejectsRespelledDebugPrint proves the second edit-05 check (the
|
||||
// message text "debug: starting" must be absent) catches a respell onto a
|
||||
// different call form that the fmt.Println(" string-literal check misses: the
|
||||
// agent rewrites the line to fmt.Printf("debug: starting\n"), which still prints
|
||||
// the message but is no longer a fmt.Println string-literal. The
|
||||
// strings.Contains("debug: starting") assertion fails the task.
|
||||
func TestEdit05RejectsRespelledDebugPrint(t *testing.T) {
|
||||
task := loadBaselineTask(t, "edit-05")
|
||||
outcome := runTurnStub(t, task, `sed 's/fmt.Println("debug: starting")/fmt.Printf("debug: starting\\n")/' main.go > .zero-tmp && mv .zero-tmp main.go
|
||||
echo '{"type":"run_end","exitCode":0}'
|
||||
`)
|
||||
assertVerifyFailed(t, "respelled edit-05 debug print", outcome)
|
||||
}
|
||||
|
||||
// TestRefactor05RejectsStubbedCaller proves refactor-05's behavioral oracle
|
||||
// catches a wrong edit the negative grep alone would pass: the agent deletes
|
||||
// Wrapper (so ! grep 'func Wrapper' succeeds) but stubs greetWrapped to return
|
||||
// "" instead of inlining. The stamped TestGreetWrapped runs greetWrapped("x"),
|
||||
// gets "" not "hello, x", and fails — so deleting the target along with its
|
||||
// caller (or stubbing the caller) can no longer pass the oracle.
|
||||
func TestRefactor05RejectsStubbedCaller(t *testing.T) {
|
||||
task := loadBaselineTask(t, "refactor-05")
|
||||
outcome := runTurnStub(t, task, `sed -e 's/return Wrapper(name)/return ""/' -e '/^func Wrapper(name string) string {/,/^}/d' main.go > .zero-tmp && mv .zero-tmp main.go
|
||||
echo '{"type":"run_end","exitCode":0}'
|
||||
`)
|
||||
assertVerifyFailed(t, "stubbed refactor-05 caller", outcome)
|
||||
}
|
||||
|
||||
// TestRefactor04RejectsBareMapKept proves refactor-04's file-reading oracle
|
||||
// catches a no-op that the old exact-text shell grep would miss: the agent adds
|
||||
// an unused `type Stats map[string]int` (so the compile-time `var _ Stats`
|
||||
// passes) but keeps the bare `var stats = map[string]int{}` under the original
|
||||
// spacing. The spacing-tolerant regex in TestStatsNamedType still matches the
|
||||
// bare-map declaration, so the task fails — Record/Lookup never moved onto the
|
||||
// named type.
|
||||
func TestRefactor04RejectsBareMapKept(t *testing.T) {
|
||||
task := loadBaselineTask(t, "refactor-04")
|
||||
outcome := runTurnStub(t, task, `sed 's/type Config struct {/type Stats map[string]int\n\ntype Config struct {/' main.go > .zero-tmp && mv .zero-tmp main.go
|
||||
echo '{"type":"run_end","exitCode":0}'
|
||||
`)
|
||||
assertVerifyFailed(t, "refactor-04 bare-map kept", outcome)
|
||||
}
|
||||
|
||||
// TestEdit01IgnoresDocComment proves edit-01's scoped negative grep does NOT
|
||||
// false-fail a correct rename that leaves the doc comment mentioning the old
|
||||
// name. The stub renames the declaration (`MaxRetries =` -> `RetryLimit =`) but
|
||||
// leaves `// MaxRetries is the maximum...`; the scoped `! grep -RIn 'MaxRetries ='`
|
||||
// matches declaration sites only, so the comment is ignored, `var _ = RetryLimit`
|
||||
// compiles, and the task passes.
|
||||
func TestEdit01IgnoresDocComment(t *testing.T) {
|
||||
task := loadBaselineTask(t, "edit-01")
|
||||
outcome := runTurnStub(t, task, `sed 's/const MaxRetries = 3/const RetryLimit = 3/' main.go > .zero-tmp && mv .zero-tmp main.go
|
||||
echo '{"type":"run_end","exitCode":0}'
|
||||
`)
|
||||
if outcome.Err != nil {
|
||||
t.Fatalf("correct rename with kept doc comment should pass, got harness error: %v", outcome.Err)
|
||||
}
|
||||
if !outcome.Passed {
|
||||
t.Fatalf("edit-01 oracle must not false-fail a correct rename that leaves the doc comment: %+v", outcome)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Satisfiable tests: the oracle PASSES the right thing (real fix applied) ---
|
||||
|
||||
// TestStampedOraclePassesWhenRefactorHappened proves the refactor-01 oracle is
|
||||
// not an always-fail gate: when the stub applies the real refactor (extract
|
||||
// formatGreeting, call it from both callers), `var _ = formatGreeting` compiles
|
||||
// and the `hello, %s` grep-count is 1 (only the helper holds the literal), so
|
||||
// the task passes.
|
||||
func TestStampedOraclePassesWhenRefactorHappened(t *testing.T) {
|
||||
task := loadBaselineTask(t, "refactor-01")
|
||||
outcome := runTurnStub(t, task, `sed -e 's/return fmt.Sprintf("hello, %s", c.Name)/return formatGreeting(c.Name)/' -e 's/return fmt.Sprintf("hello, %s", name)/return formatGreeting(name)/' main.go > .zero-tmp
|
||||
cat >> .zero-tmp <<'EOF'
|
||||
|
||||
func formatGreeting(name string) string {
|
||||
return fmt.Sprintf("hello, %s", name)
|
||||
}
|
||||
EOF
|
||||
mv .zero-tmp main.go
|
||||
echo '{"type":"run_end","exitCode":0}'
|
||||
`)
|
||||
if outcome.Err != nil {
|
||||
t.Fatalf("real refactor should pass, got harness error: %v", outcome.Err)
|
||||
}
|
||||
if !outcome.Passed {
|
||||
t.Fatalf("refactor-01 oracle must pass when formatGreeting is extracted: %+v", outcome)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEdit03PassesWhenFieldAdded proves edit-03's oracle passes when the field
|
||||
// is actually added: Config gets a Label string field, so `var _ string =
|
||||
// Config{}.Label` compiles and the task passes.
|
||||
func TestEdit03PassesWhenFieldAdded(t *testing.T) {
|
||||
task := loadBaselineTask(t, "edit-03")
|
||||
outcome := runTurnStub(t, task, `awk '/Name string/ && !d {print; print "Label string"; d=1; next} {print}' main.go > .zero-tmp && mv .zero-tmp main.go
|
||||
echo '{"type":"run_end","exitCode":0}'
|
||||
`)
|
||||
if outcome.Err != nil {
|
||||
t.Fatalf("real field-add should pass, got harness error: %v", outcome.Err)
|
||||
}
|
||||
if !outcome.Passed {
|
||||
t.Fatalf("edit-03 oracle must pass when Label is added: %+v", outcome)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNav09PassesWithCorrectAnswer proves the nav-09 oracle passes when the
|
||||
// captured answer names all three config keys.
|
||||
func TestNav09PassesWithCorrectAnswer(t *testing.T) {
|
||||
task := loadBaselineTask(t, "nav-09")
|
||||
outcome := runTurnStub(t, task, `echo '{"type":"final","text":"config.json has three keys: port, name, and retries."}'
|
||||
echo '{"type":"run_end","exitCode":0}'
|
||||
`)
|
||||
if outcome.Err != nil {
|
||||
t.Fatalf("correct nav answer should pass, got harness error: %v", outcome.Err)
|
||||
}
|
||||
if !outcome.Passed {
|
||||
t.Fatalf("nav-09 oracle must pass when the answer names port/name/retries: %+v", outcome)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefactor05PassesWhenInlined proves refactor-05's oracle is not an
|
||||
// always-fail gate: when the stub applies the real inline (greetWrapped calls
|
||||
// GreetByName directly, Wrapper removed), TestGreetWrapped gets "hello, x" and
|
||||
// the ! grep 'func Wrapper' check passes, so the task passes.
|
||||
func TestRefactor05PassesWhenInlined(t *testing.T) {
|
||||
task := loadBaselineTask(t, "refactor-05")
|
||||
outcome := runTurnStub(t, task, `sed -e 's/return Wrapper(name)/return GreetByName(name)/' -e '/^func Wrapper(name string) string {/,/^}/d' main.go > .zero-tmp && mv .zero-tmp main.go
|
||||
echo '{"type":"run_end","exitCode":0}'
|
||||
`)
|
||||
if outcome.Err != nil {
|
||||
t.Fatalf("real inline should pass, got harness error: %v", outcome.Err)
|
||||
}
|
||||
if !outcome.Passed {
|
||||
t.Fatalf("refactor-05 oracle must pass when Wrapper is inlined into greetWrapped: %+v", outcome)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefactor04PassesWhenTyped proves refactor-04's oracle passes when the
|
||||
// bare map is actually replaced by the named type: the stub introduces
|
||||
// `type Stats map[string]int` and retypes the var to `var stats = Stats{}`,
|
||||
// so the bare-map regex no longer matches, Stats is referenced, `var _ Stats`
|
||||
// compiles, and go test passes.
|
||||
func TestRefactor04PassesWhenTyped(t *testing.T) {
|
||||
task := loadBaselineTask(t, "refactor-04")
|
||||
outcome := runTurnStub(t, task, `sed -e 's/type Config struct {/type Stats map[string]int\n\ntype Config struct {/' -e 's/var stats = map\[string\]int{}/var stats = Stats{}/' main.go > .zero-tmp && mv .zero-tmp main.go
|
||||
echo '{"type":"run_end","exitCode":0}'
|
||||
`)
|
||||
if outcome.Err != nil {
|
||||
t.Fatalf("real typed refactor should pass, got harness error: %v", outcome.Err)
|
||||
}
|
||||
if !outcome.Passed {
|
||||
t.Fatalf("refactor-04 oracle must pass when stats is typed as Stats: %+v", outcome)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStampOracleRejectsFixturelessOracleTask covers the fail-closed path in
|
||||
// stampOracleAndAnswer: a task that carries an oracle (a stamped OracleTest or a
|
||||
// verification command) but has no workspace fixture has nowhere safe to stamp,
|
||||
// so it must error rather than let runVerification run in the caller's cwd with
|
||||
// the oracle never compiled (or the answer never captured). A fixtureless task
|
||||
// with no oracle and no verification (a pure latency-only run in the caller's
|
||||
// cwd) is left alone. This is a direct unit test (no stub), so it runs on all
|
||||
// platforms.
|
||||
func TestStampOracleRejectsFixturelessOracleTask(t *testing.T) {
|
||||
// OracleTest set, no verificationCommand: isolates the `OracleTest != ""`
|
||||
// reject branch so a regression that only checked VerificationCommand would
|
||||
// fail here (the both-set case below would still pass it).
|
||||
withOracleTest := BenchTask{
|
||||
ID: "t",
|
||||
OracleTest: "package x\n\nvar _ = Missing\n",
|
||||
}
|
||||
if err := stampOracleAndAnswer(withOracleTest, nil); err == nil {
|
||||
t.Fatal("fixtureless task with an OracleTest must be rejected, got nil error")
|
||||
}
|
||||
// verificationCommand set, no OracleTest: isolates the other reject branch.
|
||||
withVerify := BenchTask{
|
||||
ID: "t",
|
||||
VerificationCommand: []string{"bash", "-c", "grep x .zero-answer.txt"},
|
||||
}
|
||||
if err := stampOracleAndAnswer(withVerify, nil); err == nil {
|
||||
t.Fatal("fixtureless task with a verificationCommand must be rejected, got nil error")
|
||||
}
|
||||
// Both set: the `||` rejects, and this guards against a regression that
|
||||
// inverted the logic to `&&`.
|
||||
withBoth := BenchTask{
|
||||
ID: "t",
|
||||
OracleTest: "package x\n\nvar _ = Missing\n",
|
||||
VerificationCommand: []string{"go", "test", "./..."},
|
||||
}
|
||||
if err := stampOracleAndAnswer(withBoth, nil); err == nil {
|
||||
t.Fatal("fixtureless task with both an OracleTest and a verificationCommand must be rejected, got nil error")
|
||||
}
|
||||
latencyOnly := BenchTask{ID: "t"}
|
||||
if err := stampOracleAndAnswer(latencyOnly, nil); err != nil {
|
||||
t.Fatalf("fixtureless latency-only task must not be rejected, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Count-oracle tests: anchored `^count: N$` with the exact expected count ---
|
||||
|
||||
// TestNav01CountOracleAcceptsExactCount proves nav-01's anchored count oracle
|
||||
// passes when the answer names the files AND states the exact count (5 — the
|
||||
// fixture has README.md, config.json, go.mod, main.go, main_test.go) on its own
|
||||
// line. printf emits the JSON with a literal \n escape so the captured answer
|
||||
// has "count: 5" on its own line.
|
||||
func TestNav01CountOracleAcceptsExactCount(t *testing.T) {
|
||||
task := loadBaselineTask(t, "nav-01")
|
||||
outcome := runTurnStub(t, task, `printf '%s\n' '{"type":"final","text":"main.go, config.json, go.mod, README.md, main_test.go\ncount: 5"}'
|
||||
printf '%s\n' '{"type":"run_end","exitCode":0}'
|
||||
`)
|
||||
if outcome.Err != nil {
|
||||
t.Fatalf("correct nav-01 answer should pass, got harness error: %v", outcome.Err)
|
||||
}
|
||||
if !outcome.Passed {
|
||||
t.Fatalf("nav-01 oracle must pass when the answer names the files and states count: 5: %+v", outcome)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNav01CountOracleRejectsWrongCount proves the anchored count oracle rejects
|
||||
// a wrong count: the file names are all present, but "count: 3" does not match
|
||||
// `^count: 5$`, so the task fails at verification.
|
||||
func TestNav01CountOracleRejectsWrongCount(t *testing.T) {
|
||||
task := loadBaselineTask(t, "nav-01")
|
||||
outcome := runTurnStub(t, task, `printf '%s\n' '{"type":"final","text":"main.go, config.json, go.mod, README.md, main_test.go\ncount: 3"}'
|
||||
printf '%s\n' '{"type":"run_end","exitCode":0}'
|
||||
`)
|
||||
assertVerifyFailed(t, "nav-01 wrong count", outcome)
|
||||
}
|
||||
|
||||
// TestNav04CountOracleRejectsSubstring proves the line anchor rejects a
|
||||
// substring: "the count: 1 is the number" contains "count: 1" (the right
|
||||
// value) but the line does not start with "count:", so `^count:[[:space:]]*1$`
|
||||
// does not match. A pre-anchor oracle (`count:\s*1`) would have rubber-stamped
|
||||
// this.
|
||||
func TestNav04CountOracleRejectsSubstring(t *testing.T) {
|
||||
task := loadBaselineTask(t, "nav-04")
|
||||
outcome := runTurnStub(t, task, `printf '%s\n' '{"type":"final","text":"the count: 1 is the number of test functions"}'
|
||||
printf '%s\n' '{"type":"run_end","exitCode":0}'
|
||||
`)
|
||||
assertVerifyFailed(t, "nav-04 substring count", outcome)
|
||||
}
|
||||
|
||||
// TestNav04CountOracleAcceptsExactCount proves the nav-04 oracle passes for a
|
||||
// real answer: the count is anchored on its own line AND the answer names the
|
||||
// one Test* function (TestGreet, from main_test.go). The fixture's ground truth
|
||||
// is exactly one test function, so count: 1 is right, and naming TestGreet is
|
||||
// the inspection-proof that the agent actually read main_test.go.
|
||||
func TestNav04CountOracleAcceptsExactCount(t *testing.T) {
|
||||
task := loadBaselineTask(t, "nav-04")
|
||||
outcome := runTurnStub(t, task, `printf '%s\n' '{"type":"final","text":"There is one test function: TestGreet in main_test.go.\ncount: 1"}'
|
||||
printf '%s\n' '{"type":"run_end","exitCode":0}'
|
||||
`)
|
||||
if outcome.Err != nil {
|
||||
t.Fatalf("correct nav-04 answer should pass, got harness error: %v", outcome.Err)
|
||||
}
|
||||
if !outcome.Passed {
|
||||
t.Fatalf("nav-04 oracle must pass when the answer names TestGreet and states count: 1 on its own line: %+v", outcome)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNav04CountOracleRejectsZeroGuess is the count=0 gameability gate: an agent
|
||||
// that never opens the workspace and always emits "count: 0" used to pass nav-04
|
||||
// when the ground truth was 0. The fixture now has one real Test* function, so
|
||||
// the ground truth is 1 and a clean "count: 0" fails.
|
||||
func TestNav04CountOracleRejectsZeroGuess(t *testing.T) {
|
||||
task := loadBaselineTask(t, "nav-04")
|
||||
outcome := runTurnStub(t, task, `printf '%s\n' '{"type":"final","text":"count: 0"}'
|
||||
printf '%s\n' '{"type":"run_end","exitCode":0}'
|
||||
`)
|
||||
assertVerifyFailed(t, "nav-04 zero-guess", outcome)
|
||||
}
|
||||
|
||||
// TestNav04CountOracleRejectsBlindCountOne is the count=1 gameability gate: once
|
||||
// the ground truth became 1, an agent that never opens the workspace and always
|
||||
// emits "count: 1" would pass a count-only oracle. nav-04's oracle also requires
|
||||
// the answer to name the test function (TestGreet), so a bare "count: 1" with no
|
||||
// named fact fails — proving the blind-count-one surface is closed.
|
||||
func TestNav04CountOracleRejectsBlindCountOne(t *testing.T) {
|
||||
task := loadBaselineTask(t, "nav-04")
|
||||
outcome := runTurnStub(t, task, `printf '%s\n' '{"type":"final","text":"count: 1"}'
|
||||
printf '%s\n' '{"type":"run_end","exitCode":0}'
|
||||
`)
|
||||
assertVerifyFailed(t, "nav-04 blind-count-one", outcome)
|
||||
}
|
||||
|
||||
// TestNav05CountOracleAcceptsExactCount proves nav-05's oracle passes for a real
|
||||
// answer: the count is anchored on its own line AND the answer names the file
|
||||
// holding the one TODO (main.go). Naming main.go is the inspection-proof that the
|
||||
// agent actually located the TODO rather than guessing the count.
|
||||
func TestNav05CountOracleAcceptsExactCount(t *testing.T) {
|
||||
task := loadBaselineTask(t, "nav-05")
|
||||
outcome := runTurnStub(t, task, `printf '%s\n' '{"type":"final","text":"One TODO in main.go.\ncount: 1"}'
|
||||
printf '%s\n' '{"type":"run_end","exitCode":0}'
|
||||
`)
|
||||
if outcome.Err != nil {
|
||||
t.Fatalf("correct nav-05 answer should pass, got harness error: %v", outcome.Err)
|
||||
}
|
||||
if !outcome.Passed {
|
||||
t.Fatalf("nav-05 oracle must pass when the answer names main.go and states count: 1: %+v", outcome)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNav05CountOracleRejectsZeroGuess is nav-05's count=0 gameability gate: the
|
||||
// fixture now has one real TODO, so a clean "count: 0" (the always-guess-zero
|
||||
// answer) fails.
|
||||
func TestNav05CountOracleRejectsZeroGuess(t *testing.T) {
|
||||
task := loadBaselineTask(t, "nav-05")
|
||||
outcome := runTurnStub(t, task, `printf '%s\n' '{"type":"final","text":"count: 0"}'
|
||||
printf '%s\n' '{"type":"run_end","exitCode":0}'
|
||||
`)
|
||||
assertVerifyFailed(t, "nav-05 zero-guess", outcome)
|
||||
}
|
||||
|
||||
// TestNav05CountOracleRejectsBlindCountOne is nav-05's count=1 gameability gate:
|
||||
// once the ground truth became 1, an agent that never opens the workspace and
|
||||
// always emits "count: 1" would pass a count-only oracle. nav-05's oracle also
|
||||
// requires the answer to name the file (main.go), so a bare "count: 1" with no
|
||||
// named file fails — proving the blind-count-one surface is closed.
|
||||
func TestNav05CountOracleRejectsBlindCountOne(t *testing.T) {
|
||||
task := loadBaselineTask(t, "nav-05")
|
||||
outcome := runTurnStub(t, task, `printf '%s\n' '{"type":"final","text":"count: 1"}'
|
||||
printf '%s\n' '{"type":"run_end","exitCode":0}'
|
||||
`)
|
||||
assertVerifyFailed(t, "nav-05 blind-count-one", outcome)
|
||||
}
|
||||
|
||||
// TestNav05CountOracleRejectsSubstring proves the line anchor still bites even
|
||||
// when the named fact is present: "the count: 1 is the number of TODOs in
|
||||
// main.go" names main.go and contains "count: 1" (the right value), but the
|
||||
// count does not stand on its own line, so `^count:[[:space:]]*1$` does not
|
||||
// match and the oracle fails. A pre-anchor oracle (`count:\s*1`) would have
|
||||
// rubber-stamped this. Mirrors TestNav04CountOracleRejectsSubstring.
|
||||
func TestNav05CountOracleRejectsSubstring(t *testing.T) {
|
||||
task := loadBaselineTask(t, "nav-05")
|
||||
outcome := runTurnStub(t, task, `printf '%s\n' '{"type":"final","text":"the count: 1 is the number of TODOs in main.go"}'
|
||||
printf '%s\n' '{"type":"run_end","exitCode":0}'
|
||||
`)
|
||||
assertVerifyFailed(t, "nav-05 substring count", outcome)
|
||||
}
|
||||
|
||||
// TestNav08CountOracleAcceptsStdlibAnswer proves nav-08's oracle passes when the
|
||||
// agent enumerated the imports across BOTH .go files and named the stdlib ones
|
||||
// (fmt from main.go, testing from main_test.go): the answer states the only
|
||||
// imports are stdlib so there are 0 third-party, with "count: 0" on its own
|
||||
// line. The fmt+testing requirement is the inspection-proof — it forces the
|
||||
// agent to have read all the files rather than blindly emitting "count: 0".
|
||||
func TestNav08CountOracleAcceptsStdlibAnswer(t *testing.T) {
|
||||
task := loadBaselineTask(t, "nav-08")
|
||||
outcome := runTurnStub(t, task, `printf '%s\n' '{"type":"final","text":"The only imports are fmt (main.go) and testing (main_test.go), both standard library, so there are no third-party imports.\ncount: 0"}'
|
||||
printf '%s\n' '{"type":"run_end","exitCode":0}'
|
||||
`)
|
||||
if outcome.Err != nil {
|
||||
t.Fatalf("correct nav-08 answer should pass, got harness error: %v", outcome.Err)
|
||||
}
|
||||
if !outcome.Passed {
|
||||
t.Fatalf("nav-08 oracle must pass when the answer names fmt+testing and states count: 0: %+v", outcome)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNav08CountOracleRejectsAnswerWithoutFmt proves the inspection-proof bites:
|
||||
// an answer that states the right count (0) but never names the stdlib imports it
|
||||
// examined ("No third-party imports. count: 0") fails, so an agent that didn't
|
||||
// actually look at the imports can't pass by guessing the count alone.
|
||||
func TestNav08CountOracleRejectsAnswerWithoutFmt(t *testing.T) {
|
||||
task := loadBaselineTask(t, "nav-08")
|
||||
outcome := runTurnStub(t, task, `printf '%s\n' '{"type":"final","text":"No third-party imports.\ncount: 0"}'
|
||||
printf '%s\n' '{"type":"run_end","exitCode":0}'
|
||||
`)
|
||||
assertVerifyFailed(t, "nav-08 no-fmt", outcome)
|
||||
}
|
||||
|
||||
// TestNav08CountOracleRejectsFmtOnlyNoTesting proves the tighter inspection-proof:
|
||||
// an answer that names fmt (from main.go) but misses testing (from main_test.go)
|
||||
// fails, so an agent that only read main.go — or a guesser that names the one
|
||||
// most-common stdlib import blind — can't pass. The agent must enumerate every
|
||||
// file's imports, which is what the prompt asks for.
|
||||
func TestNav08CountOracleRejectsFmtOnlyNoTesting(t *testing.T) {
|
||||
task := loadBaselineTask(t, "nav-08")
|
||||
outcome := runTurnStub(t, task, `printf '%s\n' '{"type":"final","text":"The only import is fmt, which is standard library. count: 0"}'
|
||||
printf '%s\n' '{"type":"run_end","exitCode":0}'
|
||||
`)
|
||||
assertVerifyFailed(t, "nav-08 fmt-only-no-testing", outcome)
|
||||
}
|
||||
|
||||
func TestResolveBinaryAbsolutizesExplicitPath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
name := "zero-probe.exe"
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte("x"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(cwd) })
|
||||
|
||||
resolved, err := ResolveBinary("./" + name)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveBinary: %v", err)
|
||||
}
|
||||
if !filepath.IsAbs(resolved) {
|
||||
t.Fatalf("ResolveBinary returned relative path %q; the turn runner sets cmd.Dir per task, so a relative binary fails to spawn from every fixture copy", resolved)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunTurnBenchCountsErroredTasks(t *testing.T) {
|
||||
set := TaskSet{
|
||||
ID: "errored-suite",
|
||||
Tasks: []BenchTask{
|
||||
{ID: "t1", Class: "nav", Prompt: "p1", VerificationCommand: []string{"true"}},
|
||||
{ID: "t2", Class: "longproc", Prompt: "p2"},
|
||||
},
|
||||
}
|
||||
cfg := TurnBenchConfig{
|
||||
Model: "fake-model",
|
||||
Iterations: 1,
|
||||
Runner: func(context.Context, BenchTask, RunContext) TurnTaskOutcome {
|
||||
return TurnTaskOutcome{Err: errors.New("fork/exec ./zero: file does not exist")}
|
||||
},
|
||||
Now: func() time.Time { return time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) },
|
||||
}
|
||||
result, err := RunTurnBench(context.Background(), set, cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("RunTurnBench: %v", err)
|
||||
}
|
||||
if result.TasksErrored != 2 {
|
||||
t.Fatalf("TasksErrored=%d, want 2 (every iteration of both tasks errored)", result.TasksErrored)
|
||||
}
|
||||
if len(result.Warnings) != 2 {
|
||||
t.Fatalf("warnings=%d, want 2 run warnings", len(result.Warnings))
|
||||
}
|
||||
summary := FormatTurnBenchSummary(result)
|
||||
if !strings.Contains(summary, "ERRORED: 2 task(s)") {
|
||||
t.Fatalf("summary does not surface the errored tasks:\n%s", summary)
|
||||
}
|
||||
if !strings.Contains(summary, "fork/exec ./zero") {
|
||||
t.Fatalf("summary does not echo the underlying run error:\n%s", summary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunTurnBenchPartialErrorStillCounts(t *testing.T) {
|
||||
set := TaskSet{
|
||||
ID: "partial-suite",
|
||||
Tasks: []BenchTask{
|
||||
{ID: "ok", Class: "nav", Prompt: "p", VerificationCommand: []string{"true"}},
|
||||
{ID: "dead", Class: "nav", Prompt: "p", VerificationCommand: []string{"true"}},
|
||||
},
|
||||
}
|
||||
canned := map[string]*trace.TurnTrace{"ok": cannedTrace(100, 10, 1000)}
|
||||
inner := fakeTurnRunner(canned)
|
||||
cfg := TurnBenchConfig{
|
||||
Model: "fake-model",
|
||||
Iterations: 1,
|
||||
Runner: func(ctx context.Context, task BenchTask, rc RunContext) TurnTaskOutcome {
|
||||
if task.ID == "dead" {
|
||||
return TurnTaskOutcome{Err: errors.New("spawn failed")}
|
||||
}
|
||||
return inner(ctx, task, rc)
|
||||
},
|
||||
Now: func() time.Time { return time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) },
|
||||
}
|
||||
result, err := RunTurnBench(context.Background(), set, cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("RunTurnBench: %v", err)
|
||||
}
|
||||
if result.TasksErrored != 1 {
|
||||
t.Fatalf("TasksErrored=%d, want 1", result.TasksErrored)
|
||||
}
|
||||
if result.TasksAttempted != 2 {
|
||||
t.Fatalf("TasksAttempted=%d, want 2", result.TasksAttempted)
|
||||
}
|
||||
}
|
||||
|
||||
// The exec-profile passthrough: the profile must reach every task's zero exec
|
||||
// invocation as --exec-profile (with the prompt staying last) and stay out of
|
||||
// the args entirely when unset.
|
||||
func TestBuildTurnExecArgsIncludesExecProfile(t *testing.T) {
|
||||
task := BenchTask{ID: "t", Prompt: "do the thing"}
|
||||
args := buildTurnExecArgs(task, RunContext{Model: "m", ExecProfile: "fast"}, "trace.ndjson", nil)
|
||||
found := false
|
||||
for i := 0; i < len(args)-1; i++ {
|
||||
if args[i] == "--exec-profile" && args[i+1] == "fast" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("args must carry --exec-profile fast, got %v", args)
|
||||
}
|
||||
if args[len(args)-1] != "do the thing" {
|
||||
t.Fatalf("prompt must stay the last argument, got %v", args)
|
||||
}
|
||||
|
||||
args = buildTurnExecArgs(task, RunContext{Model: "m"}, "trace.ndjson", nil)
|
||||
for _, arg := range args {
|
||||
if arg == "--exec-profile" {
|
||||
t.Fatalf("no profile configured, but args carry --exec-profile: %v", args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The configured profile must reach the runner's RunContext and be stamped
|
||||
// into the result, so a profile A/B report is self-describing. The boundary
|
||||
// canonicalizes (case/whitespace) so equivalent postures always carry the same
|
||||
// label, and rejects unknown names so a direct library caller cannot slip an
|
||||
// unvalidated profile into a report the way the CLI (parse-time check) cannot.
|
||||
func TestRunTurnBenchStampsExecProfile(t *testing.T) {
|
||||
set := TaskSet{
|
||||
ID: "profile-suite",
|
||||
Tasks: []BenchTask{{ID: "t1", Class: "longproc", Prompt: "p"}},
|
||||
}
|
||||
var gotProfile string
|
||||
cfg := TurnBenchConfig{
|
||||
Model: "fake-model",
|
||||
ExecProfile: " FAST ",
|
||||
Iterations: 1,
|
||||
Runner: func(_ context.Context, _ BenchTask, rc RunContext) TurnTaskOutcome {
|
||||
gotProfile = rc.ExecProfile
|
||||
return TurnTaskOutcome{Passed: true, WallMs: 10, Trace: cannedTrace(10, 1, 100)}
|
||||
},
|
||||
Now: func() time.Time { return time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) },
|
||||
}
|
||||
result, err := RunTurnBench(context.Background(), set, cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("RunTurnBench: %v", err)
|
||||
}
|
||||
if gotProfile != "fast" {
|
||||
t.Fatalf("runner RunContext.ExecProfile = %q, want the canonical fast", gotProfile)
|
||||
}
|
||||
if result.ExecProfile != "fast" {
|
||||
t.Fatalf("result.ExecProfile = %q, want the canonical fast", result.ExecProfile)
|
||||
}
|
||||
raw, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal result: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"execProfile":"fast"`) {
|
||||
t.Fatal("published JSON must carry execProfile")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunTurnBenchRejectsUnknownExecProfile(t *testing.T) {
|
||||
set := TaskSet{
|
||||
ID: "profile-suite",
|
||||
Tasks: []BenchTask{{ID: "t1", Class: "longproc", Prompt: "p"}},
|
||||
}
|
||||
ran := false
|
||||
cfg := TurnBenchConfig{
|
||||
Model: "fake-model",
|
||||
ExecProfile: "blanced",
|
||||
Iterations: 1,
|
||||
Runner: func(context.Context, BenchTask, RunContext) TurnTaskOutcome {
|
||||
ran = true
|
||||
return TurnTaskOutcome{Passed: true, WallMs: 10, Trace: cannedTrace(10, 1, 100)}
|
||||
},
|
||||
}
|
||||
_, err := RunTurnBench(context.Background(), set, cfg)
|
||||
if err == nil || !strings.Contains(err.Error(), "unknown execution profile") {
|
||||
t.Fatalf("err = %v, want an unknown-profile rejection", err)
|
||||
}
|
||||
if ran {
|
||||
t.Fatal("nothing may run under an unknown profile")
|
||||
}
|
||||
}
|
||||
@@ -108,6 +108,57 @@ 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
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
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: ®istry,
|
||||
})
|
||||
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: ®istry,
|
||||
})
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
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[:])
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
+41
-6
@@ -23,6 +23,15 @@ 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)
|
||||
}
|
||||
@@ -63,15 +72,19 @@ 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)
|
||||
return tool.run(ctx, args, nil, true)
|
||||
}
|
||||
|
||||
func (tool bashTool) RunWithSandbox(ctx context.Context, args map[string]any, engine *zeroSandbox.Engine) Result {
|
||||
return tool.run(ctx, args, engine)
|
||||
return tool.run(ctx, args, engine, true)
|
||||
}
|
||||
|
||||
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)
|
||||
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)
|
||||
if err != nil {
|
||||
return errorResult("Error: Invalid arguments for bash: " + err.Error())
|
||||
}
|
||||
@@ -172,7 +185,7 @@ func (tool bashTool) run(ctx context.Context, args map[string]any, engine *zeroS
|
||||
}
|
||||
}
|
||||
markLikelySandboxDenial(meta, plan, exitCode, stdoutText, stderrText)
|
||||
outText, errText, truncated := budgetBashCapture(stdoutText, stdout.total, stderrText, stderrTotal, meta)
|
||||
outText, errText, truncated := prepareBashOutput(stdoutText, stdout.total, stderrText, stderrTotal, meta, directBudget)
|
||||
return Result{
|
||||
Status: StatusError,
|
||||
Output: formatBashOutputWithShellHint(outText, errText, exitCode, meta),
|
||||
@@ -182,7 +195,7 @@ func (tool bashTool) run(ctx context.Context, args map[string]any, engine *zeroS
|
||||
}
|
||||
|
||||
markLikelySandboxDenial(meta, plan, exitCode, stdoutText, stderrText)
|
||||
outText, errText, truncated := budgetBashCapture(stdoutText, stdout.total, stderrText, stderrTotal, meta)
|
||||
outText, errText, truncated := prepareBashOutput(stdoutText, stdout.total, stderrText, stderrTotal, meta, directBudget)
|
||||
if meta[SandboxLikelyDeniedMeta] == "true" {
|
||||
return Result{
|
||||
Status: StatusError,
|
||||
@@ -415,6 +428,28 @@ 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
|
||||
|
||||
@@ -37,6 +37,18 @@ 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.
|
||||
|
||||
@@ -300,6 +300,18 @@ func TestInteractiveClassifications(t *testing.T) {
|
||||
|
||||
func TestReadOnlyClassifications(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
// PR6 audited concurrent-safe pure reads (mutex-guarded FileTracker,
|
||||
// independent scan I/O, independent HTTP). Others stay sequential until
|
||||
// their shared state is proven safe under concurrent Run.
|
||||
threadSafeReads := map[string]bool{
|
||||
"read_file": true,
|
||||
"read_minified_file": true,
|
||||
"list_directory": true,
|
||||
"glob": true,
|
||||
"grep": true,
|
||||
"skill": true,
|
||||
"web_fetch": true,
|
||||
}
|
||||
for _, name := range []string{"read_file", "read_minified_file", "list_directory", "glob", "grep", "lsp_navigate", "skill", "web_fetch", "web_search", "tool_search"} {
|
||||
var found Tool
|
||||
for _, tool := range BuiltinCatalog(root) {
|
||||
@@ -315,14 +327,38 @@ func TestReadOnlyClassifications(t *testing.T) {
|
||||
if caps.Effect != EffectReadOnly {
|
||||
t.Errorf("%s effect = %v, want ReadOnly", name, caps.Effect)
|
||||
}
|
||||
// Current catalog intentionally keeps read tools non-thread-safe
|
||||
// until each is audited for shared mutable state (FileTracker, etc.).
|
||||
if caps.ThreadSafe {
|
||||
t.Errorf("%s ThreadSafe=true without explicit audit", name)
|
||||
wantSafe := threadSafeReads[name]
|
||||
if caps.ThreadSafe != wantSafe {
|
||||
t.Errorf("%s ThreadSafe=%v, want %v", name, caps.ThreadSafe, wantSafe)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopedScanResourceKeys(t *testing.T) {
|
||||
// Workspace-wide scan → no keys (do not false-conflict every concurrent scan).
|
||||
if keys := scopedScanResourceKeys(nil); keys != nil {
|
||||
t.Fatalf("nil args = %v, want nil", keys)
|
||||
}
|
||||
if keys := scopedScanResourceKeys(map[string]any{}); keys != nil {
|
||||
t.Fatalf("empty args = %v, want nil", keys)
|
||||
}
|
||||
if keys := scopedScanResourceKeys(map[string]any{"cwd": "."}); keys != nil {
|
||||
t.Fatalf("cwd=. = %v, want nil", keys)
|
||||
}
|
||||
if keys := scopedScanResourceKeys(map[string]any{"path": "."}); keys != nil {
|
||||
t.Fatalf("path=. = %v, want nil", keys)
|
||||
}
|
||||
// Scoped path → directory: key (glob uses cwd; grep uses path).
|
||||
keys := scopedScanResourceKeys(map[string]any{"cwd": "internal/tools"})
|
||||
if len(keys) != 1 || keys[0] != ResourceKeyDirectory+NormalizeResourcePath("internal/tools") {
|
||||
t.Fatalf("cwd scoped = %v", keys)
|
||||
}
|
||||
keys = scopedScanResourceKeys(map[string]any{"path": "./pkg/../pkg/api"})
|
||||
if len(keys) != 1 || keys[0] != ResourceKeyDirectory+NormalizeResourcePath("./pkg/../pkg/api") {
|
||||
t.Fatalf("path scoped = %v", keys)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateBuiltinCatalogRejectsUnknownRaw(t *testing.T) {
|
||||
// Prove the gate is not a rubber stamp: raw Unknown+ThreadSafe is rejected.
|
||||
problems := ValidateCapabilities("ghost", ToolCapabilities{Effect: EffectUnknown, ThreadSafe: true})
|
||||
|
||||
@@ -427,6 +427,15 @@ 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)
|
||||
}
|
||||
@@ -475,11 +484,15 @@ 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)
|
||||
return tool.run(ctx, args, nil, true)
|
||||
}
|
||||
|
||||
func (tool execCommandTool) RunWithSandbox(ctx context.Context, args map[string]any, engine *zeroSandbox.Engine) Result {
|
||||
return tool.run(ctx, args, engine)
|
||||
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)
|
||||
}
|
||||
|
||||
func (tool execCommandTool) ExecSessions() []ExecSessionSnapshot {
|
||||
@@ -494,8 +507,8 @@ func (tool execCommandTool) StopAllExecSessions() []int {
|
||||
return tool.manager.stopAll()
|
||||
}
|
||||
|
||||
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)
|
||||
func (tool execCommandTool) run(ctx context.Context, args map[string]any, engine *zeroSandbox.Engine, directBudget bool) Result {
|
||||
commandText, err := execCommandArg(args)
|
||||
if err != nil {
|
||||
return errorResult("Error: Invalid arguments for exec_command: " + err.Error())
|
||||
}
|
||||
@@ -550,7 +563,7 @@ func (tool execCommandTool) run(ctx context.Context, args map[string]any, engine
|
||||
if exited {
|
||||
tool.manager.remove(session.id)
|
||||
}
|
||||
return execToolResult(execToolResultInput{
|
||||
return execToolResultWithBudget(execToolResultInput{
|
||||
commandText: commandText,
|
||||
output: output,
|
||||
outputBufferTruncated: outputTruncated,
|
||||
@@ -561,7 +574,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) {
|
||||
@@ -694,6 +707,10 @@ type writeStdinTool struct {
|
||||
manager *execSessionManager
|
||||
}
|
||||
|
||||
func (writeStdinTool) outputCategory(map[string]any) outputCategory {
|
||||
return outputCategoryProcess
|
||||
}
|
||||
|
||||
func NewWriteStdinTool(manager *execSessionManager) Tool {
|
||||
if manager == nil {
|
||||
manager = defaultExecSessionManager
|
||||
@@ -843,7 +860,15 @@ type execToolResultInput struct {
|
||||
}
|
||||
|
||||
func execToolResult(input execToolResultInput) Result {
|
||||
output, truncated := truncateExecOutput(input.output, input.maxOutputTokens)
|
||||
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)
|
||||
}
|
||||
meta := map[string]string{
|
||||
"cwd": input.relativeCwd,
|
||||
"tty": strconv.FormatBool(input.tty),
|
||||
|
||||
+24
-15
@@ -18,6 +18,8 @@ type globTool struct {
|
||||
scope PathScope
|
||||
}
|
||||
|
||||
func (globTool) outputCategory(map[string]any) outputCategory { return outputCategorySearch }
|
||||
|
||||
func NewGlobTool(workspaceRoot string) Tool {
|
||||
return NewScopedGlobTool(workspaceRoot, nil)
|
||||
}
|
||||
@@ -39,7 +41,7 @@ func NewScopedGlobTool(workspaceRoot string, scope PathScope) Tool {
|
||||
AdditionalProperties: false,
|
||||
},
|
||||
safety: readOnlySafety("Finds matching paths without reading contents or modifying files."),
|
||||
capabilities: ToolCapabilities{Effect: EffectReadOnly, ThreadSafe: false, ResourceKeys: workspaceResourceKeys},
|
||||
capabilities: ToolCapabilities{Effect: EffectReadOnly, ThreadSafe: true, ResourceKeys: scopedScanResourceKeys},
|
||||
},
|
||||
workspaceRoot: normalizeWorkspaceRoot(workspaceRoot),
|
||||
scope: scope,
|
||||
@@ -47,17 +49,25 @@ 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{})
|
||||
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)
|
||||
}
|
||||
|
||||
// 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))
|
||||
return tool.runWith(ctx, args, sandboxReadExcluder(engine), true)
|
||||
}
|
||||
|
||||
func (tool globTool) runWith(ctx context.Context, args map[string]any, exclude readExcluder) Result {
|
||||
func (tool globTool) runWith(ctx context.Context, args map[string]any, exclude readExcluder, directBudget bool) 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())
|
||||
@@ -110,24 +120,23 @@ 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)
|
||||
}
|
||||
budgeted := applyOutputBudget(output, searchOutputBudgetBytes, "increase limit or narrow cwd/pattern")
|
||||
meta := outputBudgetMeta(budgeted)
|
||||
meta := map[string]string{}
|
||||
meta["pattern"] = pattern
|
||||
if truncated || budgeted.Truncated {
|
||||
if truncated {
|
||||
meta["truncated"] = "true"
|
||||
if budgeted.Truncated {
|
||||
meta["truncation_reason"] = "byte_budget"
|
||||
} else {
|
||||
meta["truncation_reason"] = "limit"
|
||||
}
|
||||
meta["truncation_reason"] = "limit"
|
||||
}
|
||||
|
||||
return Result{
|
||||
result := Result{
|
||||
Status: StatusOK,
|
||||
Output: budgeted.Output,
|
||||
Truncated: truncated || budgeted.Truncated,
|
||||
Output: output,
|
||||
Truncated: 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) {
|
||||
|
||||
+30
-24
@@ -21,6 +21,8 @@ type grepTool struct {
|
||||
scope PathScope
|
||||
}
|
||||
|
||||
func (grepTool) outputCategory(map[string]any) outputCategory { return outputCategorySearch }
|
||||
|
||||
type grepMatch struct {
|
||||
file string
|
||||
line int
|
||||
@@ -54,7 +56,7 @@ func NewScopedGrepTool(workspaceRoot string, scope PathScope) Tool {
|
||||
AdditionalProperties: false,
|
||||
},
|
||||
safety: readOnlySafety("Searches file paths and matching lines without modifying files."),
|
||||
capabilities: ToolCapabilities{Effect: EffectReadOnly, ThreadSafe: false, ResourceKeys: workspaceResourceKeys},
|
||||
capabilities: ToolCapabilities{Effect: EffectReadOnly, ThreadSafe: true, ResourceKeys: scopedScanResourceKeys},
|
||||
},
|
||||
workspaceRoot: normalizeWorkspaceRoot(workspaceRoot),
|
||||
scope: scope,
|
||||
@@ -62,7 +64,15 @@ 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{})
|
||||
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)
|
||||
}
|
||||
|
||||
// RunWithSandbox runs the search while skipping subtrees the sandbox policy
|
||||
@@ -70,10 +80,10 @@ func (tool grepTool) Run(ctx context.Context, args map[string]any) Result {
|
||||
// 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))
|
||||
return tool.runWith(ctx, args, sandboxReadExcluder(engine), true)
|
||||
}
|
||||
|
||||
func (tool grepTool) runWith(ctx context.Context, args map[string]any, exclude readExcluder) Result {
|
||||
func (tool grepTool) runWith(ctx context.Context, args map[string]any, exclude readExcluder, directBudget bool) 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())
|
||||
@@ -154,7 +164,7 @@ func (tool grepTool) runWith(ctx context.Context, args map[string]any, exclude r
|
||||
}
|
||||
return errorResult("Error running grep: " + err.Error())
|
||||
}
|
||||
return collector.result()
|
||||
return applyDirectSearchBudget(collector.result(), directBudget, "narrow path/glob/pattern to continue")
|
||||
case "files_with_matches":
|
||||
collector := &grepFileListCollector{}
|
||||
if err := scanGrepMatches(ctx, resolvedRoot, target, globMatcher, exclude, absolutePaths, presenceGrepLineMatcher(compiled), collector.collect); err != nil {
|
||||
@@ -163,7 +173,7 @@ func (tool grepTool) runWith(ctx context.Context, args map[string]any, exclude r
|
||||
}
|
||||
return errorResult("Error running grep: " + err.Error())
|
||||
}
|
||||
return collector.result()
|
||||
return applyDirectSearchBudget(collector.result(), directBudget, "narrow path/glob/pattern to continue")
|
||||
default:
|
||||
collector := &grepContentCollector{headLimit: headLimit}
|
||||
if err := scanGrepMatches(ctx, resolvedRoot, target, globMatcher, exclude, absolutePaths, presenceGrepLineMatcher(compiled), collector.collect); err != nil {
|
||||
@@ -172,10 +182,17 @@ func (tool grepTool) runWith(ctx context.Context, args map[string]any, exclude r
|
||||
}
|
||||
return errorResult("Error running grep: " + err.Error())
|
||||
}
|
||||
return collector.result()
|
||||
return applyDirectSearchBudget(collector.result(), directBudget, "narrow path/glob/pattern or increase head_limit")
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -455,13 +472,7 @@ func (collector *grepFileListCollector) result() Result {
|
||||
return okResult("No matches found.")
|
||||
}
|
||||
sort.Strings(collector.files)
|
||||
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}
|
||||
return Result{Status: StatusOK, Output: strings.Join(collector.files, "\n")}
|
||||
}
|
||||
|
||||
type grepContentCollector struct {
|
||||
@@ -494,20 +505,15 @@ 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))
|
||||
}
|
||||
budgeted := applyOutputBudget(output, searchOutputBudgetBytes, "narrow path/glob/pattern or increase head_limit")
|
||||
meta := outputBudgetMeta(budgeted)
|
||||
if truncated || budgeted.Truncated {
|
||||
meta := map[string]string{}
|
||||
if truncated {
|
||||
meta["truncated"] = "true"
|
||||
if budgeted.Truncated {
|
||||
meta["truncation_reason"] = "byte_budget"
|
||||
} else {
|
||||
meta["truncation_reason"] = "head_limit"
|
||||
}
|
||||
meta["truncation_reason"] = "head_limit"
|
||||
}
|
||||
return Result{
|
||||
Status: StatusOK,
|
||||
Output: budgeted.Output,
|
||||
Truncated: truncated || budgeted.Truncated,
|
||||
Output: output,
|
||||
Truncated: truncated,
|
||||
Meta: meta,
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,10 @@ type listDirectoryTool struct {
|
||||
scope PathScope
|
||||
}
|
||||
|
||||
func (listDirectoryTool) outputCategory(map[string]any) outputCategory {
|
||||
return outputCategorySearch
|
||||
}
|
||||
|
||||
func NewListDirectoryTool(workspaceRoot string) Tool {
|
||||
return NewScopedListDirectoryTool(workspaceRoot, nil)
|
||||
}
|
||||
@@ -34,7 +38,7 @@ func NewScopedListDirectoryTool(workspaceRoot string, scope PathScope) Tool {
|
||||
AdditionalProperties: false,
|
||||
},
|
||||
safety: readOnlySafety("Lists directory entries without modifying files."),
|
||||
capabilities: ToolCapabilities{Effect: EffectReadOnly, ThreadSafe: false, ResourceKeys: directoryResourceKeys},
|
||||
capabilities: ToolCapabilities{Effect: EffectReadOnly, ThreadSafe: true, ResourceKeys: directoryResourceKeys},
|
||||
},
|
||||
workspaceRoot: normalizeWorkspaceRoot(workspaceRoot),
|
||||
scope: scope,
|
||||
@@ -42,6 +46,14 @@ 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)
|
||||
@@ -76,13 +88,11 @@ func (tool listDirectoryTool) Run(_ context.Context, args map[string]any) Result
|
||||
return okResult("Directory is empty: " + relativePath)
|
||||
}
|
||||
output := "Contents of " + relativePath + ":\n\n" + strings.Join(entries, "\n")
|
||||
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"
|
||||
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")
|
||||
}
|
||||
return Result{Status: StatusOK, Output: budgeted.Output, Truncated: budgeted.Truncated, Meta: meta}
|
||||
return result
|
||||
}
|
||||
|
||||
func listDirectoryEntries(path string, depth int, maxDepth int) ([]string, error) {
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
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))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -11,6 +12,167 @@ 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
|
||||
@@ -56,6 +218,7 @@ func estimatedTokensFromBytes(bytes int) int {
|
||||
|
||||
type outputBudgetBuilder struct {
|
||||
builder strings.Builder
|
||||
capture *boundedBuffer
|
||||
rawBytes int
|
||||
maxBytes int
|
||||
hint string
|
||||
@@ -65,17 +228,60 @@ 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.maxBytes <= 0 || builder.builder.Len() >= builder.maxBytes {
|
||||
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 {
|
||||
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,
|
||||
@@ -90,7 +296,12 @@ func (builder *outputBudgetBuilder) Result() outputBudgetResult {
|
||||
if budget < 0 {
|
||||
budget = 0
|
||||
}
|
||||
result.Output = utf8Prefix(output, budget) + marker
|
||||
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.Truncated = true
|
||||
result.EmittedBytes = len(result.Output)
|
||||
return result
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,23 +25,19 @@ 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 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.
|
||||
// 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.
|
||||
type selfBudgeting interface{ 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() {}
|
||||
// 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() {}
|
||||
|
||||
func resolveOutputCeilingTokens() int {
|
||||
raw := strings.TrimSpace(os.Getenv(outputCeilingEnv))
|
||||
@@ -61,6 +57,12 @@ 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
|
||||
}
|
||||
|
||||
@@ -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 saved to ") {
|
||||
if !strings.Contains(result.Output, "full output received by budgeting layer saved to ") {
|
||||
t.Fatalf("ceiling truncation must include a spill hint: %q", result.Output[:200])
|
||||
}
|
||||
start := strings.Index(result.Output, "full output saved to ") + len("full output saved to ")
|
||||
start := strings.Index(result.Output, "full output received by budgeting layer saved to ") + len("full output received by budgeting layer saved to ")
|
||||
end := strings.Index(result.Output[start:], " (grep")
|
||||
content, err := os.ReadFile(result.Output[start : start+end])
|
||||
if err != nil {
|
||||
@@ -123,11 +123,6 @@ 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 {
|
||||
@@ -137,4 +132,9 @@ 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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,8 @@ type readFileTool struct {
|
||||
scope PathScope
|
||||
}
|
||||
|
||||
func (readFileTool) outputCategory(map[string]any) outputCategory { return outputCategoryFile }
|
||||
|
||||
func NewReadFileTool(workspaceRoot string) Tool {
|
||||
return NewScopedReadFileTool(workspaceRoot, nil)
|
||||
}
|
||||
@@ -39,8 +41,10 @@ func NewScopedReadFileTool(workspaceRoot string, scope PathScope) Tool {
|
||||
AdditionalProperties: false,
|
||||
},
|
||||
safety: readOnlySafety("Reads file contents without modifying files."),
|
||||
// ThreadSafe=false: may update FileTracker session state on read.
|
||||
capabilities: ToolCapabilities{Effect: EffectReadOnly, ThreadSafe: false, ResourceKeys: fileResourceKeys},
|
||||
// ThreadSafe: FileTracker is mutex-guarded; concurrent reads of
|
||||
// distinct paths are safe. Same-path calls still serialize via
|
||||
// resource-key conflict detection in the agent parallel planner.
|
||||
capabilities: ToolCapabilities{Effect: EffectReadOnly, ThreadSafe: true, ResourceKeys: fileResourceKeys},
|
||||
},
|
||||
workspaceRoot: normalizeWorkspaceRoot(workspaceRoot),
|
||||
scope: scope,
|
||||
@@ -48,10 +52,14 @@ func NewScopedReadFileTool(workspaceRoot string, scope PathScope) Tool {
|
||||
}
|
||||
|
||||
func (tool readFileTool) Run(ctx context.Context, args map[string]any) Result {
|
||||
return tool.RunWithOptions(ctx, args, RunOptions{})
|
||||
return tool.run(args, RunOptions{}, true)
|
||||
}
|
||||
|
||||
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())
|
||||
@@ -84,10 +92,14 @@ func (tool readFileTool) RunWithOptions(_ context.Context, args map[string]any,
|
||||
// not the authoritative content hash.
|
||||
options.FileTracker.RecordHash(absolutePath, stats.hash, stats.info)
|
||||
|
||||
return renderReadFileRange(absolutePath, relativePath, stats.lines, startLine, endLine, maxLines)
|
||||
maxBytes := 0
|
||||
if directBudget {
|
||||
maxBytes = readOutputBudgetBytes
|
||||
}
|
||||
return renderReadFileRange(absolutePath, relativePath, stats.lines, startLine, endLine, maxLines, maxBytes)
|
||||
}
|
||||
|
||||
func renderReadFileRange(absolutePath string, relativePath string, total int, startLine int, endLine int, maxLines int) Result {
|
||||
func renderReadFileRange(absolutePath string, relativePath string, total int, startLine int, endLine int, maxLines int, maxBytes 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))
|
||||
}
|
||||
@@ -112,7 +124,16 @@ func renderReadFileRange(absolutePath string, relativePath string, total int, st
|
||||
header = fmt.Sprintf("File: %s (lines %d-%d of %d)", relativePath, startLine, lastLine, total)
|
||||
}
|
||||
|
||||
budgetedOutput := newOutputBudgetBuilder(readOutputBudgetBytes, "use start_line/end_line or max_lines to continue with a smaller range")
|
||||
// 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.WriteString(header)
|
||||
budgetedOutput.WriteString("\n\n")
|
||||
if err := appendReadFileRange(budgetedOutput, absolutePath, startLine, selectedLines, width); err != nil {
|
||||
@@ -126,10 +147,19 @@ func renderReadFileRange(absolutePath string, relativePath string, total int, st
|
||||
}
|
||||
|
||||
budgeted := budgetedOutput.Result()
|
||||
meta := outputBudgetMeta(budgeted)
|
||||
if budgeted.Truncated {
|
||||
meta := map[string]string{}
|
||||
if maxBytes > 0 || budgeted.Truncated {
|
||||
for key, value := range outputBudgetMeta(budgeted) {
|
||||
meta[key] = value
|
||||
}
|
||||
}
|
||||
if truncated || budgeted.Truncated {
|
||||
meta["truncated"] = "true"
|
||||
meta["truncation_reason"] = "byte_budget"
|
||||
if budgeted.Truncated {
|
||||
meta["truncation_reason"] = "byte_budget"
|
||||
} else {
|
||||
meta["truncation_reason"] = "max_lines"
|
||||
}
|
||||
}
|
||||
return Result{
|
||||
Status: StatusOK,
|
||||
|
||||
@@ -16,6 +16,10 @@ type readMinifiedFileTool struct {
|
||||
scope PathScope
|
||||
}
|
||||
|
||||
func (readMinifiedFileTool) outputCategory(map[string]any) outputCategory {
|
||||
return outputCategoryFile
|
||||
}
|
||||
|
||||
func NewReadMinifiedFileTool(workspaceRoot string) Tool {
|
||||
return NewScopedReadMinifiedFileTool(workspaceRoot, nil)
|
||||
}
|
||||
@@ -34,7 +38,7 @@ func NewScopedReadMinifiedFileTool(workspaceRoot string, scope PathScope) Tool {
|
||||
AdditionalProperties: false,
|
||||
},
|
||||
safety: readOnlySafety("Reads a minified view of file contents without modifying files."),
|
||||
capabilities: ToolCapabilities{Effect: EffectReadOnly, ThreadSafe: false, ResourceKeys: fileResourceKeys},
|
||||
capabilities: ToolCapabilities{Effect: EffectReadOnly, ThreadSafe: true, ResourceKeys: fileResourceKeys},
|
||||
},
|
||||
workspaceRoot: normalizeWorkspaceRoot(workspaceRoot),
|
||||
scope: scope,
|
||||
@@ -42,10 +46,14 @@ func NewScopedReadMinifiedFileTool(workspaceRoot string, scope PathScope) Tool {
|
||||
}
|
||||
|
||||
func (tool readMinifiedFileTool) Run(ctx context.Context, args map[string]any) Result {
|
||||
return tool.RunWithOptions(ctx, args, RunOptions{})
|
||||
return tool.run(args, RunOptions{}, true)
|
||||
}
|
||||
|
||||
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())
|
||||
@@ -92,23 +100,21 @@ func (tool readMinifiedFileTool) RunWithOptions(_ context.Context, args map[stri
|
||||
savedTokens = estimatedTokensFromBytes(savedBytes)
|
||||
}
|
||||
output := header + "\n\n" + result.Content
|
||||
budgeted := applyOutputBudget(output, readOutputBudgetBytes, "use read_file with start_line/end_line or max_lines for a smaller exact range")
|
||||
meta := outputBudgetMeta(budgeted)
|
||||
meta := map[string]string{}
|
||||
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(budgeted.EmittedBytes)
|
||||
meta["emitted_bytes"] = strconv.Itoa(len(output))
|
||||
meta["raw_lines"] = strconv.Itoa(rawLines)
|
||||
meta["emitted_lines"] = strconv.Itoa(minLines)
|
||||
meta["estimated_tokens_saved"] = strconv.Itoa(savedTokens)
|
||||
if budgeted.Truncated {
|
||||
meta["truncated"] = "true"
|
||||
meta["truncation_reason"] = "byte_budget"
|
||||
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")
|
||||
}
|
||||
|
||||
return Result{Status: StatusOK, Output: budgeted.Output, Truncated: budgeted.Truncated, Meta: meta}
|
||||
return toolResult
|
||||
}
|
||||
|
||||
// lineCount reports the number of newline-separated lines in s (an empty string
|
||||
|
||||
@@ -126,20 +126,25 @@ 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.
|
||||
ceilingExempt := false
|
||||
selfManagedOutput := false
|
||||
var tool Tool
|
||||
var ok bool
|
||||
defer func() {
|
||||
result = scrubResultSecrets(result)
|
||||
if !ceilingExempt {
|
||||
if selfManagedOutput {
|
||||
result = applySelfManagedOutputBudget(tool, name, args, result)
|
||||
} else {
|
||||
result = applyRegistryOutputBudget(tool, name, args, result)
|
||||
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 {
|
||||
ceilingExempt = true
|
||||
selfManagedOutput = true
|
||||
}
|
||||
if rejecter, ok := tool.(PrePermissionRejecter); ok {
|
||||
if res, rejected := rejecter.RejectBeforePermission(args); rejected {
|
||||
|
||||
@@ -127,6 +127,20 @@ func workspaceResourceKeys(_ map[string]any) []string {
|
||||
return []string{ResourceKeyWorkspace + "root"}
|
||||
}
|
||||
|
||||
// scopedScanResourceKeys returns conflict keys for directory-scoped scan tools
|
||||
// (glob, grep). Workspace-wide scans (missing / empty / ".") return nil so
|
||||
// they do not force a false conflict with every other concurrent scan —
|
||||
// ThreadSafe is the concurrency gate; keys refine same-directory collisions.
|
||||
// A non-default path/cwd yields a single directory: key.
|
||||
func scopedScanResourceKeys(args map[string]any) []string {
|
||||
path := firstStringArg(args, "path", "cwd", "dir", "directory")
|
||||
normalized := NormalizeResourcePath(path)
|
||||
if normalized == "" || normalized == "." {
|
||||
return nil
|
||||
}
|
||||
return []string{ResourceKeyDirectory + normalized}
|
||||
}
|
||||
|
||||
// multiFileResourceKeys collects path and paths[] arguments into file: keys.
|
||||
func multiFileResourceKeys(args map[string]any) []string {
|
||||
var keys []string
|
||||
|
||||
@@ -49,7 +49,7 @@ func NewSkillTool(dir string) *skillTool {
|
||||
AdditionalProperties: false,
|
||||
},
|
||||
safety: readOnlySafety("Reads a local skill file; gathers reusable instructions only."),
|
||||
capabilities: ToolCapabilities{Effect: EffectReadOnly, ThreadSafe: false},
|
||||
capabilities: ToolCapabilities{Effect: EffectReadOnly, ThreadSafe: true},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,8 +141,11 @@ func newWebFetchToolWithClientAndResolver(client *http.Client, resolver webFetch
|
||||
Reason: "Fetches remote URL content over the network.",
|
||||
AdvertiseInAuto: true,
|
||||
},
|
||||
// Network read; ThreadSafe=false until client proven concurrent-safe.
|
||||
capabilities: ToolCapabilities{Effect: EffectReadOnly, ThreadSafe: false, ResourceKeys: endpointResourceKeys},
|
||||
// Concurrent-safe: net/http.Client is documented as safe for
|
||||
// concurrent use; each call issues independent request I/O.
|
||||
// parallelSafeToolCall still requires PermissionAllow (prompted
|
||||
// network fetches stay sequential until auto-allowed).
|
||||
capabilities: ToolCapabilities{Effect: EffectReadOnly, ThreadSafe: true, ResourceKeys: endpointResourceKeys},
|
||||
},
|
||||
client: client,
|
||||
resolver: resolver,
|
||||
|
||||
@@ -117,6 +117,25 @@ 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
|
||||
}
|
||||
|
||||
@@ -177,6 +196,15 @@ 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
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
+21
-1
@@ -116,6 +116,21 @@ 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.
|
||||
@@ -136,12 +151,17 @@ 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 {
|
||||
if len(t.Spans) == 0 && len(t.Counters) == 0 && len(t.PrefixHashes) == 0 && len(t.OutputBudgets) == 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
|
||||
|
||||
@@ -188,6 +188,21 @@ 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.
|
||||
@@ -207,6 +222,7 @@ 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
|
||||
}
|
||||
|
||||
|
||||
+41
-11
@@ -32,6 +32,7 @@ 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
|
||||
@@ -53,6 +54,14 @@ const (
|
||||
CounterInputTokens = "input_tokens"
|
||||
CounterCachedInputTokens = "cached_input_tokens"
|
||||
CounterOutputTokens = "output_tokens"
|
||||
CounterPrewarmAttempts = "prewarm_attempts"
|
||||
CounterPrefixStable = "prefix_stable"
|
||||
CounterPrefixDrift = "prefix_drift"
|
||||
// CounterPostureEscalations counts one-shot in-run posture escalations by
|
||||
// the execution-profile controller. Deliberately distinct from
|
||||
// model_switches: a posture escalation changes loop policy knobs, never the
|
||||
// model or session.
|
||||
CounterPostureEscalations = "posture_escalations"
|
||||
)
|
||||
|
||||
// Span is one named wall interval attributed to part of a run. Each stamp is
|
||||
@@ -79,20 +88,36 @@ 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"`
|
||||
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"`
|
||||
}
|
||||
|
||||
// PrefixHash is one fingerprint of the prompt prefix emitted by an agent run.
|
||||
@@ -265,6 +290,11 @@ func OptionalEventKeys() []string {
|
||||
"counter:" + CounterAcceptanceChecks,
|
||||
"counter:" + CounterPollingTurn,
|
||||
"counter:" + CounterModelSwitches,
|
||||
"span:" + SpanProviderPrewarm,
|
||||
"counter:" + CounterPrewarmAttempts,
|
||||
"counter:" + CounterPrefixStable,
|
||||
"counter:" + CounterPrefixDrift,
|
||||
"counter:" + CounterPostureEscalations,
|
||||
"event:prefix_hash",
|
||||
}
|
||||
}
|
||||
@@ -577,3 +577,12 @@ func TestCounterPrecisionRoundTrip(t *testing.T) {
|
||||
t.Fatalf("counter round-trip = %d, want %d (float64 precision loss)", got, big)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOptionalEventKeysIncludePostureEscalations(t *testing.T) {
|
||||
for _, key := range OptionalEventKeys() {
|
||||
if key == "counter:"+CounterPostureEscalations {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("posture_escalations missing from OptionalEventKeys: %v", OptionalEventKeys())
|
||||
}
|
||||
@@ -464,13 +464,15 @@ func (m model) handleModelCommand(args string) (model, string) {
|
||||
// dedupe against the entry just persisted here, showing the same switch twice.
|
||||
config.RecentModelEntry{Provider: m.providerName, Model: target.modelID},
|
||||
)
|
||||
resetEffort := false
|
||||
if m.reasoningEffort != "" && !reasoningEffortAllowed(target.reasoningEfforts, m.reasoningEffort) {
|
||||
// Drop an unsupported carry-over preference and fall back to the
|
||||
// model's effective default for the new model.
|
||||
m.reasoningEffort = ""
|
||||
resetEffort = true
|
||||
}
|
||||
// Drop a known-unsupported preference, void any profile bookkeeping the
|
||||
// drop erased, and re-derive an active profile's per-model effort fill.
|
||||
// The ring is authoritative only for catalog-resolved targets
|
||||
// (target.entry non-nil); live-discovered/custom targets carry no support
|
||||
// knowledge, so an explicit preference survives onto them. resetEffort
|
||||
// reports a dropped preference nothing refilled (shown as a reset to
|
||||
// auto).
|
||||
var resetEffort bool
|
||||
m, resetEffort = m.reconcileEffortForModelSwitch(target.reasoningEfforts, target.entry != nil)
|
||||
effortLine := "effort: " + m.effortDisplay()
|
||||
if resetEffort {
|
||||
// Preference was dropped: show "auto" (model default applies), not a
|
||||
@@ -551,6 +553,13 @@ func (m model) switchProviderModel(providerName, modelID string) (model, string,
|
||||
m.providerProfile = target
|
||||
m.providerName = target.Name
|
||||
m.modelName = target.Model
|
||||
// An active profile's effort fill is per-model: re-derive it for the
|
||||
// destination, exactly like handleModelCommand does. No generic
|
||||
// unsupported-drop here: cross-provider targets are often custom models
|
||||
// the catalog cannot vouch for either way, so an explicit preference is
|
||||
// carried (pre-existing behavior) while the profile's own fill stays
|
||||
// conservative — it only ever applies where support is known.
|
||||
m = m.reconcileProfileAfterModelSwitch(m.availableReasoningEfforts())
|
||||
// Record the outgoing pair too — see the matching comment in
|
||||
// handleModelCommand for why (keeps the session's starting model from
|
||||
// silently dropping out of "Recent" on the first switch away from it).
|
||||
|
||||
@@ -41,6 +41,7 @@ const (
|
||||
commandAddDir
|
||||
commandSelfCorrect
|
||||
commandTurns
|
||||
commandProfile
|
||||
commandRetry
|
||||
commandEdit
|
||||
commandCopy
|
||||
@@ -252,6 +253,7 @@ var commandDefinitions = []commandDefinition{
|
||||
},
|
||||
{
|
||||
name: "/rewind",
|
||||
aliases: []string{"/undo"},
|
||||
usage: "/rewind [latest|<sequence>]",
|
||||
group: commandGroupSession,
|
||||
description: "Restore workspace files to a checkpoint and truncate the session.",
|
||||
@@ -286,6 +288,13 @@ var commandDefinitions = []commandDefinition{
|
||||
description: "Show or set the per-run tool-turn budget for this session (raise it for long multi-step tasks).",
|
||||
kind: commandTurns,
|
||||
},
|
||||
{
|
||||
name: "/profile",
|
||||
usage: "/profile [status|balanced|fast|thorough]",
|
||||
group: commandGroupSession,
|
||||
description: "Show or switch the execution profile for the next run (loop posture: turn budget, effort, self-correction, escalation; model selection is unchanged).",
|
||||
kind: commandProfile,
|
||||
},
|
||||
{
|
||||
name: "/retry",
|
||||
usage: "/retry",
|
||||
|
||||
@@ -985,6 +985,31 @@ func TestParseBackgroundTerminalCommands(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// /undo is an alias for /rewind (issue #698): previously it fell through to
|
||||
// commandUnknown.
|
||||
func TestUndoAliasesRewind(t *testing.T) {
|
||||
rewind, ok := resolveCommand("/rewind")
|
||||
if !ok {
|
||||
t.Fatal("expected /rewind to resolve")
|
||||
}
|
||||
alias, ok := resolveCommand("/undo")
|
||||
if !ok || alias.kind != rewind.kind {
|
||||
t.Fatalf("expected /undo to alias /rewind, got ok=%v command=%#v", ok, alias)
|
||||
}
|
||||
// End-to-end: a typed /undo dispatches to the rewind handler with args intact,
|
||||
// for both the "latest" and numeric-<sequence> forms of the /rewind contract.
|
||||
if parsed := parseCommand("/undo latest"); parsed.kind != commandRewind || parsed.text != "latest" {
|
||||
t.Fatalf("parseCommand(%q) = %#v, want commandRewind with text=latest", "/undo latest", parsed)
|
||||
}
|
||||
if parsed := parseCommand("/undo 123"); parsed.kind != commandRewind || parsed.text != "123" {
|
||||
t.Fatalf("parseCommand(%q) = %#v, want commandRewind with text=123", "/undo 123", parsed)
|
||||
}
|
||||
// Guard: without the alias it would be commandUnknown.
|
||||
if parsed := parseCommand("/undo"); parsed.kind == commandUnknown {
|
||||
t.Fatal("/undo resolved to commandUnknown — the alias is not wired")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandSelectionRequiresInputFromUsage(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
|
||||
+42
-14
@@ -128,20 +128,36 @@ type model struct {
|
||||
permissionMode agent.PermissionMode
|
||||
selfCorrectTests bool
|
||||
reasoningEffort modelregistry.ReasoningEffort
|
||||
responseStyle string
|
||||
keyBindings keyBindings
|
||||
themeMode themeMode // palette preference: auto (default), dark, light
|
||||
hasDarkBg bool // last terminal background-detection result (auto mode)
|
||||
userAgent string
|
||||
compactRequests int
|
||||
compactInFlight bool
|
||||
compactFrame int
|
||||
lastCompactResult *CompactResult
|
||||
lastCompactError string
|
||||
unpricedRequests int
|
||||
unpricedTokens int
|
||||
lastUsage usage.Normalized
|
||||
lastUsageSeen bool
|
||||
// Active execution profile (set by /profile; applies to the NEXT run).
|
||||
// The displaced/applied pairs let a switch or /profile balanced restore
|
||||
// exactly what the profile replaced while leaving later manual overrides
|
||||
// (/turns, Ctrl+T, /selfcorrect) alone: each knob is only reverted when it
|
||||
// still holds the value the profile applied.
|
||||
execProfileName string
|
||||
execProfileDisplacedMaxTurns int
|
||||
execProfileAppliedMaxTurns int
|
||||
execProfileAppliedEffort modelregistry.ReasoningEffort
|
||||
execProfileArmedSelfCorrect bool
|
||||
// The touched bits record explicit user choices made while a profile is
|
||||
// active (/turns, /effort, Ctrl+T, /selfcorrect); a touched knob is never
|
||||
// reverted, even when its value coincides with what the profile applied.
|
||||
execProfileTurnsTouched bool
|
||||
execProfileEffortTouched bool
|
||||
execProfileSelfCorrectTouched bool
|
||||
responseStyle string
|
||||
keyBindings keyBindings
|
||||
themeMode themeMode // palette preference: auto (default), dark, light
|
||||
hasDarkBg bool // last terminal background-detection result (auto mode)
|
||||
userAgent string
|
||||
compactRequests int
|
||||
compactInFlight bool
|
||||
compactFrame int
|
||||
lastCompactResult *CompactResult
|
||||
lastCompactError string
|
||||
unpricedRequests int
|
||||
unpricedTokens int
|
||||
lastUsage usage.Normalized
|
||||
lastUsageSeen bool
|
||||
// turnLatencySum / turnLatencyCount accumulate completed-run wall time so
|
||||
// /context can show a rolling average turn latency (the "is it slow?" signal).
|
||||
// Reset by /new.
|
||||
@@ -4346,6 +4362,18 @@ func (m model) dispatchCommand(command parsedCommand) (tea.Model, tea.Cmd) {
|
||||
m, text = m.handleTurnsCommand(command.text)
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
|
||||
return m, nil
|
||||
case commandProfile:
|
||||
// Same idle-session rule as /turns: switching the profile mutates the
|
||||
// turn budget (and its ZERO_MAX_TURNS propagation), so a change needs
|
||||
// an idle session; bare /profile (status) is always allowed.
|
||||
if m.pending && strings.TrimSpace(command.text) != "" && !strings.EqualFold(strings.TrimSpace(command.text), "status") {
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: "Profile\nFinish or stop the current run before switching the execution profile."})
|
||||
return m, nil
|
||||
}
|
||||
text := ""
|
||||
m, text = m.handleProfileCommand(command.text)
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
|
||||
return m, nil
|
||||
case commandTheme:
|
||||
// Bare `/theme` opens the popup picker (live preview on move, apply on
|
||||
// Enter), matching /model and /effort. An explicit `/theme auto|dark|light`
|
||||
|
||||
@@ -0,0 +1,432 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/modelregistry"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// profileSwitchModel builds a session on a model whose catalog entry supports
|
||||
// the fast profile's "low" effort, with a second saved provider whose custom
|
||||
// model has no catalog entry (so no effort ring) — the two directions the
|
||||
// model-switch reconciliation must handle.
|
||||
func profileSwitchModel(t *testing.T) model {
|
||||
t.Helper()
|
||||
return newModel(context.Background(), Options{
|
||||
ProviderName: "anthropic",
|
||||
ModelName: "claude-sonnet-4.5",
|
||||
Provider: &fakeProvider{},
|
||||
ProviderProfile: config.ProviderProfile{Name: "anthropic", CatalogID: "anthropic", Model: "claude-sonnet-4.5", APIKey: "k"},
|
||||
SavedProviders: []config.ProviderProfile{
|
||||
{Name: "anthropic", CatalogID: "anthropic", Model: "claude-sonnet-4.5", APIKey: "k"},
|
||||
{Name: "ollama", CatalogID: "ollama", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: "http://localhost:11434/v1", Model: "kimi-k2.7-code:cloud"},
|
||||
},
|
||||
NewProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
return &fakeProvider{}, nil
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// The profile's effort fill is per-model and must be re-derived on a model
|
||||
// switch: dropped when the destination does not support the level, refilled
|
||||
// when a later destination does, with the escalation's effort restore tracking
|
||||
// whether the profile currently governs the effort.
|
||||
func TestProfileEffortReconciledOnModelSwitch(t *testing.T) {
|
||||
m := profileSwitchModel(t)
|
||||
|
||||
m, _ = m.handleProfileCommand("fast")
|
||||
if m.reasoningEffort != "low" || m.agentOptions.Profile == nil || !m.agentOptions.Profile.Escalate.RestoreDefaultEffort {
|
||||
t.Fatalf("fast on a low-supporting model must fill low and arm the restore, got effort %q", m.reasoningEffort)
|
||||
}
|
||||
|
||||
// Supported -> unsupported: the profile-applied level must not survive
|
||||
// onto a model with no effort ring.
|
||||
m, text, ok, _ := m.switchProviderModel("ollama", "kimi-k2.7-code:cloud")
|
||||
if !ok {
|
||||
t.Fatalf("switch to ollama failed: %q", text)
|
||||
}
|
||||
if m.reasoningEffort != "" || m.execProfileAppliedEffort != "" {
|
||||
t.Fatalf("effort = %q applied = %q, want both cleared on an unsupported destination", m.reasoningEffort, m.execProfileAppliedEffort)
|
||||
}
|
||||
if m.agentOptions.Profile.Escalate.RestoreDefaultEffort {
|
||||
t.Fatal("the profile no longer governs the effort, so the restore must disarm")
|
||||
}
|
||||
if m.execProfileName != "fast" || m.agentOptions.MaxTurns != 30 {
|
||||
t.Fatalf("profile must stay active across the switch: name %q turns %d", m.execProfileName, m.agentOptions.MaxTurns)
|
||||
}
|
||||
|
||||
// Unsupported -> supported: switching (back) to a supporting model must
|
||||
// behave like selecting the profile there.
|
||||
m, text, ok, _ = m.switchProviderModel("anthropic", "claude-sonnet-4.5")
|
||||
if !ok {
|
||||
t.Fatalf("switch back to anthropic failed: %q", text)
|
||||
}
|
||||
if m.reasoningEffort != "low" || m.execProfileAppliedEffort != "low" {
|
||||
t.Fatalf("effort = %q applied = %q, want the profile's low refilled", m.reasoningEffort, m.execProfileAppliedEffort)
|
||||
}
|
||||
if !m.agentOptions.Profile.Escalate.RestoreDefaultEffort {
|
||||
t.Fatal("the profile governs the effort again, so the restore must re-arm")
|
||||
}
|
||||
}
|
||||
|
||||
// The direct /model path (reconcileEffortForModelSwitch, driven by the
|
||||
// target's authoritative ring) drops a KNOWN-unsupported effort — the TUI run
|
||||
// path forwards the effort unfiltered, so it must not survive. When that drop
|
||||
// voids a choice made under an active profile, the profile's effort
|
||||
// bookkeeping resets and the profile resumes governing: a later switch to a
|
||||
// supporting ring fills the profile's level again.
|
||||
func TestProfileEffortVoidedByModelSwitchDrop(t *testing.T) {
|
||||
m := profileSwitchModel(t)
|
||||
|
||||
m, _ = m.handleProfileCommand("fast")
|
||||
m, _ = m.handleEffortCommand("high")
|
||||
if m.reasoningEffort != "high" || !m.execProfileEffortTouched {
|
||||
t.Fatalf("setup: effort %q touched %v, want an explicit high", m.reasoningEffort, m.execProfileEffortTouched)
|
||||
}
|
||||
|
||||
// Destination with an AUTHORITATIVE empty ring (a catalog model without
|
||||
// effort controls): "high" is known-unsupported, the drop fires, and the
|
||||
// voided choice resets the profile's bookkeeping.
|
||||
m, reset := m.reconcileEffortForModelSwitch(nil, true)
|
||||
if !reset {
|
||||
t.Fatal("a dropped preference with nothing refilled must report the reset")
|
||||
}
|
||||
if m.reasoningEffort != "" {
|
||||
t.Fatalf("effort = %q, a known-unsupported level must not survive", m.reasoningEffort)
|
||||
}
|
||||
if m.execProfileEffortTouched || m.execProfileAppliedEffort != "" {
|
||||
t.Fatalf("voided choice must reset the bookkeeping: touched %v applied %q", m.execProfileEffortTouched, m.execProfileAppliedEffort)
|
||||
}
|
||||
if m.agentOptions.Profile.Escalate.RestoreDefaultEffort {
|
||||
t.Fatal("no profile-governed effort on this model, so the restore must be disarmed")
|
||||
}
|
||||
if m.execProfileName != "fast" || m.agentOptions.MaxTurns != 30 {
|
||||
t.Fatalf("profile must stay active: name %q turns %d", m.execProfileName, m.agentOptions.MaxTurns)
|
||||
}
|
||||
|
||||
// With the void cleared, a destination that supports the profile's level
|
||||
// behaves like selecting the profile there: the fill returns.
|
||||
ring := []modelregistry.ReasoningEffort{"low", "medium", "high"}
|
||||
m, reset = m.reconcileEffortForModelSwitch(ring, true)
|
||||
if reset {
|
||||
t.Fatal("a refill is not a reset to auto")
|
||||
}
|
||||
if m.reasoningEffort != "low" || m.execProfileAppliedEffort != "low" {
|
||||
t.Fatalf("effort = %q applied = %q, want the profile's low refilled", m.reasoningEffort, m.execProfileAppliedEffort)
|
||||
}
|
||||
if !m.agentOptions.Profile.Escalate.RestoreDefaultEffort {
|
||||
t.Fatal("the profile governs the effort again, so the restore must re-arm")
|
||||
}
|
||||
}
|
||||
|
||||
// A touched effort the destination DOES support survives the direct /model
|
||||
// path untouched — the drop only fires for known-unsupported levels.
|
||||
func TestProfileEffortTouchedSurvivesSupportedModelSwitch(t *testing.T) {
|
||||
m := profileSwitchModel(t)
|
||||
|
||||
m, _ = m.handleProfileCommand("fast")
|
||||
m, _ = m.handleEffortCommand("high")
|
||||
m, reset := m.reconcileEffortForModelSwitch([]modelregistry.ReasoningEffort{"low", "medium", "high"}, true)
|
||||
if reset {
|
||||
t.Fatal("nothing was dropped, so no reset")
|
||||
}
|
||||
if m.reasoningEffort != "high" || !m.execProfileEffortTouched {
|
||||
t.Fatalf("effort = %q touched %v, an explicit choice the destination supports must survive", m.reasoningEffort, m.execProfileEffortTouched)
|
||||
}
|
||||
if m.agentOptions.Profile.Escalate.RestoreDefaultEffort {
|
||||
t.Fatal("an explicit choice keeps the restore disarmed")
|
||||
}
|
||||
}
|
||||
|
||||
// An UNKNOWN ring (live-discovered/custom target with no catalog entry) is
|
||||
// not evidence of unsupported: an explicit preference survives, exactly as it
|
||||
// does on the cross-provider picker path, while the profile's own fill still
|
||||
// retreats to models where support is known.
|
||||
func TestProfileEffortUnknownRingPreservesExplicitChoice(t *testing.T) {
|
||||
m := profileSwitchModel(t)
|
||||
|
||||
m, _ = m.handleProfileCommand("fast")
|
||||
m, _ = m.handleEffortCommand("high")
|
||||
m, reset := m.reconcileEffortForModelSwitch(nil, false)
|
||||
if reset {
|
||||
t.Fatal("an unknown ring must not report a reset")
|
||||
}
|
||||
if m.reasoningEffort != "high" || !m.execProfileEffortTouched {
|
||||
t.Fatalf("effort = %q touched %v, an explicit choice must survive an unknown ring", m.reasoningEffort, m.execProfileEffortTouched)
|
||||
}
|
||||
|
||||
// Untouched profile fill on an unknown ring: the fill retreats (the
|
||||
// profile only governs where support is known), with clean bookkeeping.
|
||||
m2 := profileSwitchModel(t)
|
||||
m2, _ = m2.handleProfileCommand("fast")
|
||||
m2, reset = m2.reconcileEffortForModelSwitch(nil, false)
|
||||
if reset {
|
||||
t.Fatal("a profile-fill retreat is not an unsupported-preference reset")
|
||||
}
|
||||
if m2.reasoningEffort != "" || m2.execProfileAppliedEffort != "" {
|
||||
t.Fatalf("effort = %q applied = %q, the profile fill must retreat on an unknown ring", m2.reasoningEffort, m2.execProfileAppliedEffort)
|
||||
}
|
||||
if m2.agentOptions.Profile.Escalate.RestoreDefaultEffort {
|
||||
t.Fatal("no profile-governed effort, so the restore must be disarmed")
|
||||
}
|
||||
}
|
||||
|
||||
// An explicitly touched effort is the user's choice: the reconciliation must
|
||||
// leave it alone entirely across model switches.
|
||||
func TestProfileEffortTouchedSurvivesModelSwitch(t *testing.T) {
|
||||
m := profileSwitchModel(t)
|
||||
|
||||
m, _ = m.handleProfileCommand("fast")
|
||||
m, _ = m.handleEffortCommand("high")
|
||||
m, text, ok, _ := m.switchProviderModel("ollama", "kimi-k2.7-code:cloud")
|
||||
if !ok {
|
||||
t.Fatalf("switch to ollama failed: %q", text)
|
||||
}
|
||||
if m.reasoningEffort != "high" {
|
||||
t.Fatalf("effort = %q, an explicit choice must survive the switch untouched", m.reasoningEffort)
|
||||
}
|
||||
if m.agentOptions.Profile.Escalate.RestoreDefaultEffort {
|
||||
t.Fatal("an explicit choice keeps the restore disarmed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileCommandStatus(t *testing.T) {
|
||||
_, out := model{}.handleProfileCommand("")
|
||||
if !strings.Contains(out, "balanced (default)") {
|
||||
t.Fatalf("bare /profile must report the balanced default, got %q", out)
|
||||
}
|
||||
_, out = model{}.handleProfileCommand("status")
|
||||
if !strings.Contains(out, "balanced (default)") {
|
||||
t.Fatalf("/profile status must report the balanced default, got %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileCommandUnknownValue(t *testing.T) {
|
||||
got, out := model{}.handleProfileCommand("turbo")
|
||||
if !strings.Contains(out, "Usage:") {
|
||||
t.Fatalf("unknown profile must print usage, got %q", out)
|
||||
}
|
||||
if got.execProfileName != "" || got.agentOptions.Profile != nil {
|
||||
t.Fatal("unknown profile must not change any state")
|
||||
}
|
||||
}
|
||||
|
||||
// Fast applies to the next run: the turn budget drops, the escalation policy
|
||||
// is armed with the DISPLACED budget as its restore target, and effort stays
|
||||
// untouched when the session has no model-supported effort ring (model{}).
|
||||
func TestProfileCommandSetsFastPosture(t *testing.T) {
|
||||
m := model{}
|
||||
m.agentOptions.MaxTurns = 80
|
||||
|
||||
got, out := m.handleProfileCommand("fast")
|
||||
if got.agentOptions.MaxTurns != 30 {
|
||||
t.Fatalf("MaxTurns = %d, want the fast profile's 30", got.agentOptions.MaxTurns)
|
||||
}
|
||||
if got.execProfileName != "fast" {
|
||||
t.Fatalf("execProfileName = %q, want fast", got.execProfileName)
|
||||
}
|
||||
policy := got.agentOptions.Profile
|
||||
if policy == nil || policy.Escalate == nil {
|
||||
t.Fatal("fast must arm an escalation policy on agentOptions")
|
||||
}
|
||||
if policy.Escalate.MaxTurns != 80 {
|
||||
t.Fatalf("Escalate.MaxTurns = %d, want the displaced 80", policy.Escalate.MaxTurns)
|
||||
}
|
||||
if got.reasoningEffort != "" {
|
||||
t.Fatalf("effort = %q, must stay auto when the model exposes no effort ring", got.reasoningEffort)
|
||||
}
|
||||
if !strings.Contains(out, "fast") || !strings.Contains(out, "headless-only") {
|
||||
t.Fatalf("status must name the profile and the TUI signal asymmetry, got %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileCommandThoroughArmsSelfCorrect(t *testing.T) {
|
||||
m := model{}
|
||||
m.agentOptions.MaxTurns = 80
|
||||
|
||||
got, _ := m.handleProfileCommand("thorough")
|
||||
if got.agentOptions.MaxTurns != 160 {
|
||||
t.Fatalf("MaxTurns = %d, want thorough's 160", got.agentOptions.MaxTurns)
|
||||
}
|
||||
if !got.selfCorrectTests {
|
||||
t.Fatal("thorough must arm full self-correction")
|
||||
}
|
||||
if got.agentOptions.Profile != nil {
|
||||
t.Fatal("thorough has no escalation triggers, so the loop policy must stay nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileCommandBalancedRestoresDisplaced(t *testing.T) {
|
||||
m := model{}
|
||||
m.agentOptions.MaxTurns = 80
|
||||
|
||||
m, _ = m.handleProfileCommand("fast")
|
||||
got, _ := m.handleProfileCommand("balanced")
|
||||
if got.agentOptions.MaxTurns != 80 {
|
||||
t.Fatalf("MaxTurns = %d, want the displaced 80 restored", got.agentOptions.MaxTurns)
|
||||
}
|
||||
if got.execProfileName != "" || got.agentOptions.Profile != nil {
|
||||
t.Fatal("balanced must clear the profile state and loop policy")
|
||||
}
|
||||
}
|
||||
|
||||
// Profiles must not stack: switching fast -> thorough restores fast's
|
||||
// displacement first, so thorough displaces the ORIGINAL budget and a later
|
||||
// balanced lands back on it.
|
||||
func TestProfileCommandSwitchDoesNotStack(t *testing.T) {
|
||||
m := model{}
|
||||
m.agentOptions.MaxTurns = 80
|
||||
|
||||
m, _ = m.handleProfileCommand("fast")
|
||||
m, _ = m.handleProfileCommand("thorough")
|
||||
if m.agentOptions.MaxTurns != 160 {
|
||||
t.Fatalf("MaxTurns = %d, want thorough's 160", m.agentOptions.MaxTurns)
|
||||
}
|
||||
if m.execProfileDisplacedMaxTurns != 80 {
|
||||
t.Fatalf("displaced = %d, want the original 80 (not fast's 30)", m.execProfileDisplacedMaxTurns)
|
||||
}
|
||||
m, _ = m.handleProfileCommand("balanced")
|
||||
if m.agentOptions.MaxTurns != 80 {
|
||||
t.Fatalf("MaxTurns = %d, want the original 80 restored", m.agentOptions.MaxTurns)
|
||||
}
|
||||
}
|
||||
|
||||
// An explicit /turns while a profile is active is a pinned budget: it must
|
||||
// disarm the escalation's turn target (mirroring headless exec, where an
|
||||
// explicit --max-turns leaves nothing displaced) while the other triggers
|
||||
// stay armed, and it must survive a later revert.
|
||||
func TestProfileCommandTurnsPinDisarmsEscalationTarget(t *testing.T) {
|
||||
m := model{}
|
||||
m.agentOptions.MaxTurns = 80
|
||||
|
||||
m, _ = m.handleProfileCommand("fast")
|
||||
m, _ = m.handleTurnsCommand("20")
|
||||
if m.agentOptions.MaxTurns != 20 {
|
||||
t.Fatalf("MaxTurns = %d, want the pinned 20", m.agentOptions.MaxTurns)
|
||||
}
|
||||
policy := m.agentOptions.Profile
|
||||
if policy == nil || policy.Escalate == nil {
|
||||
t.Fatal("the profile's other escalation triggers must stay armed")
|
||||
}
|
||||
if policy.Escalate.MaxTurns != 0 {
|
||||
t.Fatalf("Escalate.MaxTurns = %d, want 0 (a pinned budget must never be raised by escalation)", policy.Escalate.MaxTurns)
|
||||
}
|
||||
if policy.Escalate.OnToolFailureStreak == 0 {
|
||||
t.Fatal("disarming the turn target must not disarm the other triggers")
|
||||
}
|
||||
m, _ = m.handleProfileCommand("balanced")
|
||||
if m.agentOptions.MaxTurns != 20 {
|
||||
t.Fatalf("MaxTurns = %d, the pinned 20 must survive the revert", m.agentOptions.MaxTurns)
|
||||
}
|
||||
}
|
||||
|
||||
// A manual override that COINCIDES with the profile's own value must still
|
||||
// survive the revert: touched beats value equality.
|
||||
func TestProfileCommandRevertLeavesCoincidingOverride(t *testing.T) {
|
||||
m := model{}
|
||||
m.agentOptions.MaxTurns = 80
|
||||
|
||||
m, _ = m.handleProfileCommand("fast")
|
||||
m, _ = m.handleTurnsCommand("30") // explicit, happens to equal fast's 30
|
||||
m, _ = m.handleProfileCommand("balanced")
|
||||
if m.agentOptions.MaxTurns != 30 {
|
||||
t.Fatalf("MaxTurns = %d, the explicit /turns 30 must survive even though it equals fast's value", m.agentOptions.MaxTurns)
|
||||
}
|
||||
|
||||
// Same for self-correct: /selfcorrect off then on under thorough is an
|
||||
// explicit opt-in, not the profile's arming.
|
||||
m2 := model{}
|
||||
m2.agentOptions.MaxTurns = 80
|
||||
m2, _ = m2.handleProfileCommand("thorough")
|
||||
m2, _ = m2.handleSelfCorrectCommand("off")
|
||||
m2, _ = m2.handleSelfCorrectCommand("on")
|
||||
m2, _ = m2.handleProfileCommand("balanced")
|
||||
if !m2.selfCorrectTests {
|
||||
t.Fatal("an explicit /selfcorrect on must survive the revert even though thorough also armed it")
|
||||
}
|
||||
}
|
||||
|
||||
// Reverting a profile whose displaced budget was zero (nothing was set before
|
||||
// it) must clear ZERO_MAX_TURNS: SetMaxTurnsEnv ignores zero, so without an
|
||||
// explicit unset, spawned sub-agents would keep the removed profile's budget.
|
||||
func TestProfileCommandRevertFromZeroClearsMaxTurnsEnv(t *testing.T) {
|
||||
t.Setenv(config.MaxTurnsEnv, "")
|
||||
m := model{} // MaxTurns 0: the profile displaces nothing
|
||||
|
||||
m, _ = m.handleProfileCommand("fast")
|
||||
if got := os.Getenv(config.MaxTurnsEnv); got != "30" {
|
||||
t.Fatalf("env after apply = %q, want the profile's 30", got)
|
||||
}
|
||||
m, _ = m.handleProfileCommand("balanced")
|
||||
if m.agentOptions.MaxTurns != 0 {
|
||||
t.Fatalf("MaxTurns = %d, want the displaced 0 restored", m.agentOptions.MaxTurns)
|
||||
}
|
||||
if got, present := os.LookupEnv(config.MaxTurnsEnv); present {
|
||||
t.Fatalf("env after revert = %q (present), want the key removed", got)
|
||||
}
|
||||
}
|
||||
|
||||
// An explicit effort choice after /profile must disarm the escalation's
|
||||
// effort restore (the effort analog of the /turns pin): a mid-run escalation
|
||||
// must never clear an effort the user pinned by hand. Needs a catalog model
|
||||
// with an effort ring so the profile's fill actually applies.
|
||||
func TestProfileCommandExplicitEffortDisarmsRestore(t *testing.T) {
|
||||
m := model{modelName: "claude-sonnet-4.5"}
|
||||
m.agentOptions.MaxTurns = 80
|
||||
|
||||
m, _ = m.handleProfileCommand("fast")
|
||||
if m.reasoningEffort != "low" {
|
||||
t.Fatalf("profile did not fill low effort for the test model: got %q", m.reasoningEffort)
|
||||
}
|
||||
policy := m.agentOptions.Profile
|
||||
if policy == nil || policy.Escalate == nil || !policy.Escalate.RestoreDefaultEffort {
|
||||
t.Fatal("profile-filled effort must arm the escalation effort restore")
|
||||
}
|
||||
|
||||
m, _ = m.handleEffortCommand("high")
|
||||
if m.reasoningEffort != "high" {
|
||||
t.Fatalf("effort = %q, want the explicit high", m.reasoningEffort)
|
||||
}
|
||||
policy = m.agentOptions.Profile
|
||||
if policy == nil || policy.Escalate == nil {
|
||||
t.Fatal("the escalation policy must stay armed")
|
||||
}
|
||||
if policy.Escalate.RestoreDefaultEffort {
|
||||
t.Fatal("an explicit /effort must disarm the effort restore")
|
||||
}
|
||||
if policy.Escalate.MaxTurns != 80 {
|
||||
t.Fatalf("Escalate.MaxTurns = %d, disarming effort must not touch the turn target", policy.Escalate.MaxTurns)
|
||||
}
|
||||
// And the explicit choice survives a later revert (touched bit).
|
||||
m, _ = m.handleProfileCommand("balanced")
|
||||
if m.reasoningEffort != "high" {
|
||||
t.Fatalf("effort = %q, the explicit high must survive the revert", m.reasoningEffort)
|
||||
}
|
||||
}
|
||||
|
||||
// A manual override made after selecting a profile survives the revert: the
|
||||
// profile only restores knobs that still hold the value it applied.
|
||||
func TestProfileCommandRevertLeavesManualOverride(t *testing.T) {
|
||||
m := model{}
|
||||
m.agentOptions.MaxTurns = 80
|
||||
|
||||
m, _ = m.handleProfileCommand("fast")
|
||||
m, _ = m.handleTurnsCommand("100")
|
||||
got, _ := m.handleProfileCommand("balanced")
|
||||
if got.agentOptions.MaxTurns != 100 {
|
||||
t.Fatalf("MaxTurns = %d, the user's explicit /turns 100 must survive the revert", got.agentOptions.MaxTurns)
|
||||
}
|
||||
// An explicit /selfcorrect choice armed BEFORE thorough must survive too.
|
||||
m2 := model{selfCorrectTests: true}
|
||||
m2.agentOptions.MaxTurns = 80
|
||||
m2, _ = m2.handleProfileCommand("thorough")
|
||||
m2, _ = m2.handleProfileCommand("balanced")
|
||||
if !m2.selfCorrectTests {
|
||||
t.Fatal("an explicit self-correct opt-in must survive profile revert")
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package tui
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -10,6 +11,7 @@ import (
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/execprofile"
|
||||
"github.com/Gitlawb/zero/internal/modelregistry"
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
"github.com/Gitlawb/zero/internal/usage"
|
||||
@@ -61,6 +63,7 @@ func (m model) handleEffortCommand(args string) (model, string) {
|
||||
}
|
||||
if args == "auto" {
|
||||
m.reasoningEffort = ""
|
||||
m = m.markProfileEffortTouched()
|
||||
return m, m.effortStatusCard("auto", "Reasoning effort selection will follow the active model/provider defaults.")
|
||||
}
|
||||
|
||||
@@ -78,6 +81,7 @@ func (m model) handleEffortCommand(args string) (model, string) {
|
||||
}
|
||||
|
||||
m.reasoningEffort = requested
|
||||
m = m.markProfileEffortTouched()
|
||||
return m, m.effortStatusCard(string(requested), "Reasoning effort preference is stored for this TUI session.")
|
||||
}
|
||||
|
||||
@@ -131,6 +135,104 @@ func (m model) effortText() string {
|
||||
})
|
||||
}
|
||||
|
||||
// markProfileEffortTouched records an explicit effort choice made while a
|
||||
// profile is active: the touched bit protects the choice from a later profile
|
||||
// revert, and disarming the policy's RestoreDefaultEffort stops a mid-run
|
||||
// escalation from clearing an effort the user pinned by hand.
|
||||
func (m model) markProfileEffortTouched() model {
|
||||
if m.execProfileName == "" {
|
||||
return m
|
||||
}
|
||||
m.execProfileEffortTouched = true
|
||||
return m.setProfileEffortRestore(false)
|
||||
}
|
||||
|
||||
// setProfileEffortRestore rewrites the armed policy's RestoreDefaultEffort on
|
||||
// a clone (an in-flight run may hold the current pointer), like the /turns
|
||||
// turn-target disarm. No-op when no escalation policy is armed or the flag
|
||||
// already matches.
|
||||
func (m model) setProfileEffortRestore(restore bool) model {
|
||||
if m.agentOptions.Profile == nil || m.agentOptions.Profile.Escalate == nil ||
|
||||
m.agentOptions.Profile.Escalate.RestoreDefaultEffort == restore {
|
||||
return m
|
||||
}
|
||||
policy := *m.agentOptions.Profile
|
||||
escalate := *policy.Escalate
|
||||
escalate.RestoreDefaultEffort = restore
|
||||
policy.Escalate = &escalate
|
||||
m.agentOptions.Profile = &policy
|
||||
return m
|
||||
}
|
||||
|
||||
// reconcileEffortForModelSwitch applies the effort rules for a model switch.
|
||||
// efforts is the destination's supported ring and ringKnown whether that ring
|
||||
// is authoritative (a catalog entry, whose ring may be legitimately empty) or
|
||||
// missing knowledge (a live-discovered/custom model the catalog cannot vouch
|
||||
// for either way).
|
||||
//
|
||||
// First the pre-existing generic rule: a KNOWN-unsupported preference is
|
||||
// dropped — the TUI run path forwards the effort to the provider unfiltered,
|
||||
// so it must not survive the switch, for a profile-touched effort exactly as
|
||||
// for a plain one. An UNKNOWN ring never drops an explicit preference: the
|
||||
// model may well support it, matching the cross-provider picker's behavior
|
||||
// for custom models. When a drop voids a choice living under an active
|
||||
// profile (the profile's own fill or a user override of it), the profile's
|
||||
// effort bookkeeping resets so a choice that no longer exists stops binding
|
||||
// and no stale applied/restore state remains.
|
||||
//
|
||||
// Then the profile rule: an active, untouched profile re-derives its fill for
|
||||
// the destination (conservative in both directions — the fill only ever
|
||||
// applies where support is known). The second return reports whether an
|
||||
// unsupported preference was dropped and nothing refilled (the caller's
|
||||
// "(unsupported preference reset)" display case).
|
||||
func (m model) reconcileEffortForModelSwitch(efforts []modelregistry.ReasoningEffort, ringKnown bool) (model, bool) {
|
||||
dropped := false
|
||||
if ringKnown && m.reasoningEffort != "" && !reasoningEffortAllowed(efforts, m.reasoningEffort) {
|
||||
m.reasoningEffort = ""
|
||||
dropped = true
|
||||
if m.execProfileName != "" {
|
||||
m.execProfileEffortTouched = false
|
||||
m.execProfileAppliedEffort = ""
|
||||
m = m.setProfileEffortRestore(false)
|
||||
}
|
||||
}
|
||||
m = m.reconcileProfileAfterModelSwitch(efforts)
|
||||
return m, dropped && m.reasoningEffort == ""
|
||||
}
|
||||
|
||||
// reconcileProfileAfterModelSwitch re-derives an active profile's effort fill
|
||||
// for the model the session just switched to, whose supported ring is passed
|
||||
// in. The fill is per-model, not a session preference: fast selected on a
|
||||
// model without "low" fills nothing, and switching to a model that supports
|
||||
// it should behave exactly like selecting the profile there (and the reverse
|
||||
// switch must not keep sending a level the destination does not support). An
|
||||
// explicitly touched effort is the user's choice and is never reconciled; the
|
||||
// escalation's RestoreDefaultEffort tracks whether the profile currently
|
||||
// governs the effort.
|
||||
func (m model) reconcileProfileAfterModelSwitch(efforts []modelregistry.ReasoningEffort) model {
|
||||
if m.execProfileName == "" || m.execProfileEffortTouched {
|
||||
return m
|
||||
}
|
||||
profile, ok := execprofile.Lookup(m.execProfileName)
|
||||
if !ok || profile.ReasoningEffort == "" {
|
||||
return m
|
||||
}
|
||||
want := modelregistry.ReasoningEffort(profile.ReasoningEffort)
|
||||
supported := reasoningEffortAllowed(efforts, want)
|
||||
filled := m.execProfileAppliedEffort != ""
|
||||
switch {
|
||||
case supported && !filled && m.reasoningEffort == "":
|
||||
m.reasoningEffort = want
|
||||
m.execProfileAppliedEffort = want
|
||||
m = m.setProfileEffortRestore(true)
|
||||
case !supported && filled && m.reasoningEffort == m.execProfileAppliedEffort:
|
||||
m.reasoningEffort = ""
|
||||
m.execProfileAppliedEffort = ""
|
||||
m = m.setProfileEffortRestore(false)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (m model) availableReasoningEfforts() []modelregistry.ReasoningEffort {
|
||||
if strings.TrimSpace(m.modelName) == "" {
|
||||
return nil
|
||||
@@ -161,6 +263,10 @@ func (m model) cycleReasoningEffort() (model, tea.Cmd) {
|
||||
if len(efforts) == 0 {
|
||||
return m, nil
|
||||
}
|
||||
// Every branch below changes the effort; a cycle while a profile is active
|
||||
// is an explicit user choice that must survive both a later profile revert
|
||||
// and any mid-run escalation.
|
||||
m = m.markProfileEffortTouched()
|
||||
if m.reasoningEffort == "" {
|
||||
m.reasoningEffort = efforts[0]
|
||||
return m, nil
|
||||
@@ -254,6 +360,12 @@ func (m model) handleSelfCorrectCommand(args string) (model, string) {
|
||||
default:
|
||||
return m, "Self-correct\nUsage: /selfcorrect [status|on|off|tests|full|lsp]"
|
||||
}
|
||||
// An explicit choice while a profile is active must survive a later
|
||||
// profile revert, even when it lands on the value the profile applied
|
||||
// (e.g. /selfcorrect off then on under thorough).
|
||||
if m.execProfileName != "" {
|
||||
m.execProfileSelfCorrectTouched = true
|
||||
}
|
||||
return m, m.selfCorrectText()
|
||||
}
|
||||
|
||||
@@ -278,9 +390,146 @@ func (m model) handleTurnsCommand(args string) (model, string) {
|
||||
// Propagate the budget to spawned sub-agents / swarm members (which inherit the
|
||||
// environment) so a delegated task gets the same budget, not config.json's default.
|
||||
config.SetMaxTurnsEnv(n)
|
||||
// An explicit /turns while a profile is active is a pinned budget: mirror
|
||||
// headless exec's rule (an explicit --max-turns leaves nothing displaced)
|
||||
// by disarming the escalation's turn target, so a mid-run escalation can
|
||||
// never raise the budget the user just pinned. The other escalation
|
||||
// triggers stay armed. Mark the knob touched so a later profile revert
|
||||
// leaves this choice alone even if n coincides with the profile's value.
|
||||
if m.execProfileName != "" {
|
||||
m.execProfileTurnsTouched = true
|
||||
if m.agentOptions.Profile != nil && m.agentOptions.Profile.Escalate != nil {
|
||||
policy := *m.agentOptions.Profile
|
||||
escalate := *policy.Escalate
|
||||
escalate.MaxTurns = 0
|
||||
policy.Escalate = &escalate
|
||||
m.agentOptions.Profile = &policy
|
||||
}
|
||||
}
|
||||
return m, m.turnsText()
|
||||
}
|
||||
|
||||
// handleProfileCommand shows or switches the session's execution profile: a
|
||||
// loop-posture bundle (turn budget, reasoning effort, self-correction,
|
||||
// escalation triggers) applied to the NEXT run, composing with the selected
|
||||
// model rather than replacing it. Switching first restores whatever the
|
||||
// previous profile displaced (profiles never stack), and balanced IS that
|
||||
// restore: the empty profile, so selecting it just clears the posture.
|
||||
func (m model) handleProfileCommand(args string) (model, string) {
|
||||
args = strings.TrimSpace(args)
|
||||
if args == "" || strings.EqualFold(args, "status") {
|
||||
return m, m.profileText()
|
||||
}
|
||||
profile, ok := execprofile.Lookup(args)
|
||||
if !ok {
|
||||
return m, "Profile\nUsage: /profile [status|" + strings.Join(execprofile.Names(), "|") + "] — switch the next run's loop posture."
|
||||
}
|
||||
m = m.revertExecProfile()
|
||||
if profile.Name == execprofile.Balanced.Name {
|
||||
return m, m.profileText()
|
||||
}
|
||||
// Turn budget: displace the session's current budget. The displaced value
|
||||
// becomes the escalation restore target, exactly like headless exec, and
|
||||
// the new budget propagates to spawned sub-agents the same way /turns does.
|
||||
displacedMaxTurns := 0
|
||||
if profile.MaxTurns > 0 {
|
||||
displacedMaxTurns = m.agentOptions.MaxTurns
|
||||
m.agentOptions.MaxTurns = profile.MaxTurns
|
||||
m.execProfileAppliedMaxTurns = profile.MaxTurns
|
||||
m.execProfileDisplacedMaxTurns = displacedMaxTurns
|
||||
config.SetMaxTurnsEnv(profile.MaxTurns)
|
||||
}
|
||||
// Reasoning effort: fill only when the session is on auto AND the active
|
||||
// model supports the profile's level, mirroring exec's supported-effort
|
||||
// gating. An explicit user choice always wins over the profile.
|
||||
if want := modelregistry.ReasoningEffort(profile.ReasoningEffort); want != "" && m.reasoningEffort == "" {
|
||||
if reasoningEffortAllowed(m.availableReasoningEfforts(), want) {
|
||||
m.reasoningEffort = want
|
||||
m.execProfileAppliedEffort = want
|
||||
}
|
||||
}
|
||||
// Self-correction is presence-only: a profile can arm it but never disarm
|
||||
// an explicit /selfcorrect choice.
|
||||
if profile.SelfCorrect && !m.selfCorrectTests {
|
||||
m.selfCorrectTests = true
|
||||
m.execProfileArmedSelfCorrect = true
|
||||
}
|
||||
m.agentOptions.Profile = profile.Policy(displacedMaxTurns, m.execProfileAppliedEffort != "")
|
||||
m.execProfileName = profile.Name
|
||||
return m, m.profileText()
|
||||
}
|
||||
|
||||
// revertExecProfile restores what the active profile displaced. Each knob is
|
||||
// restored only when the user has not explicitly touched it since the profile
|
||||
// was applied (the touched bits, set by /turns, /effort, Ctrl+T, and
|
||||
// /selfcorrect) AND it still holds the value the profile applied — so manual
|
||||
// overrides survive the revert even when they coincide with the profile's own
|
||||
// value (e.g. /turns 30 under fast, or /selfcorrect off-then-on under
|
||||
// thorough).
|
||||
func (m model) revertExecProfile() model {
|
||||
if m.execProfileName == "" {
|
||||
return m
|
||||
}
|
||||
if !m.execProfileTurnsTouched && m.execProfileAppliedMaxTurns > 0 && m.agentOptions.MaxTurns == m.execProfileAppliedMaxTurns {
|
||||
m.agentOptions.MaxTurns = m.execProfileDisplacedMaxTurns
|
||||
if m.execProfileDisplacedMaxTurns > 0 {
|
||||
config.SetMaxTurnsEnv(m.execProfileDisplacedMaxTurns)
|
||||
} else {
|
||||
// The profile's apply exported its budget to ZERO_MAX_TURNS (for
|
||||
// sub-agents), and SetMaxTurnsEnv ignores zero — so restoring a
|
||||
// zero displaced budget must clear the env explicitly or spawned
|
||||
// children would keep the removed profile's budget.
|
||||
_ = os.Unsetenv(config.MaxTurnsEnv)
|
||||
}
|
||||
}
|
||||
if !m.execProfileEffortTouched && m.execProfileAppliedEffort != "" && m.reasoningEffort == m.execProfileAppliedEffort {
|
||||
m.reasoningEffort = ""
|
||||
}
|
||||
if !m.execProfileSelfCorrectTouched && m.execProfileArmedSelfCorrect && m.selfCorrectTests {
|
||||
m.selfCorrectTests = false
|
||||
}
|
||||
m.agentOptions.Profile = nil
|
||||
m.execProfileName = ""
|
||||
m.execProfileDisplacedMaxTurns = 0
|
||||
m.execProfileAppliedMaxTurns = 0
|
||||
m.execProfileAppliedEffort = ""
|
||||
m.execProfileArmedSelfCorrect = false
|
||||
m.execProfileTurnsTouched = false
|
||||
m.execProfileEffortTouched = false
|
||||
m.execProfileSelfCorrectTouched = false
|
||||
return m
|
||||
}
|
||||
|
||||
func (m model) profileText() string {
|
||||
name := m.execProfileName
|
||||
if name == "" {
|
||||
name = execprofile.Balanced.Name + " (default)"
|
||||
}
|
||||
lines := []string{
|
||||
"execution profile: " + name,
|
||||
fmt.Sprintf("max tool-turns per run: %d", m.agentOptions.MaxTurns),
|
||||
"reasoning effort: " + m.effortDisplay(),
|
||||
}
|
||||
if m.agentOptions.Profile != nil && m.agentOptions.Profile.Escalate != nil {
|
||||
turnTarget := "keeps the pinned turn budget"
|
||||
if target := m.agentOptions.Profile.Escalate.MaxTurns; target > 0 {
|
||||
turnTarget = fmt.Sprintf("restores the turn budget to %d", target)
|
||||
}
|
||||
lines = append(lines,
|
||||
"escalation: armed — one-shot on a tool-failure streak, a failing self-correct cycle, or a critical-risk mutation; "+turnTarget,
|
||||
"note: the uncertain-completion signal is headless-only (the completion gate never runs interactively), so it cannot fire in the TUI")
|
||||
}
|
||||
return renderCommandOutput(commandOutput{
|
||||
Title: "Profile",
|
||||
Status: commandStatusOK,
|
||||
Sections: []commandSection{{
|
||||
Title: "State",
|
||||
Lines: lines,
|
||||
}},
|
||||
Hints: []string{"/profile " + strings.Join(execprofile.Names(), "|") + " switches the next run's loop posture (turn budget, effort, self-correction, escalation); pick the model separately with /model"},
|
||||
})
|
||||
}
|
||||
|
||||
func (m model) turnsText() string {
|
||||
return renderCommandOutput(commandOutput{
|
||||
Title: "Turns",
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
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 }
|
||||
@@ -0,0 +1,126 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user