mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 01:09:46 -10:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
533b41495d | ||
|
|
bb66a44ab3 | ||
|
|
6f97c90316 |
No files matched your search
@@ -1,11 +1,5 @@
|
||||
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,22 +1,12 @@
|
||||
name: Feature request (previously discussed)
|
||||
description: File only after your idea gained traction in Discussions Ideas or a maintainer asked for a tracking issue
|
||||
name: Feature request
|
||||
description: Propose a new capability or change
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
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
|
||||
Please 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:
|
||||
|
||||
@@ -100,10 +100,9 @@ jobs:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
# govulncheck, deadcode, and golangci-lint each resolve a toolchain from
|
||||
# their own modules, which can drop below the version go.mod requires and
|
||||
# then fail to load our packages. Pin GOTOOLCHAIN to the go.mod toolchain
|
||||
# so all three run under it.
|
||||
# govulncheck and deadcode resolve a toolchain from their own modules, which
|
||||
# can drop below the version go.mod requires and then fail to load our
|
||||
# packages. Pin GOTOOLCHAIN to the go.mod toolchain so both run under it.
|
||||
- name: Pin toolchain from go.mod
|
||||
run: |
|
||||
toolchain="$(awk '/^toolchain /{print $2}' go.mod)"
|
||||
@@ -121,13 +120,3 @@ jobs:
|
||||
- name: deadcode (advisory)
|
||||
continue-on-error: true
|
||||
run: go run golang.org/x/tools/cmd/[email protected] -test=false ./...
|
||||
|
||||
# Advisory: catches what deadcode's whole-program reachability analysis
|
||||
# doesn't, unused private functions/assignments reachable within a
|
||||
# package but never actually called, plus staticcheck-style correctness
|
||||
# and readability issues. Scoped to a few linters rather than the full
|
||||
# default battery, and non-blocking, while the existing findings across
|
||||
# the repo are cleaned up incrementally (see #527).
|
||||
- name: golangci-lint (advisory)
|
||||
continue-on-error: true
|
||||
run: go run github.com/golangci/golangci-lint/v2/cmd/[email protected] run --enable-only unused,ineffassign,staticcheck ./...
|
||||
@@ -97,21 +97,16 @@ jobs:
|
||||
gh release create "$tag" --repo "$GITHUB_REPOSITORY" --title "$tag" --generate-notes 2>/dev/null || true
|
||||
gh release upload "$tag" dist/release/* --repo "$GITHUB_REPOSITORY" --clobber
|
||||
|
||||
# Publishes @gitlawb/zero to npm: first the five platform payloads (versions
|
||||
# of the SAME package at suffixed versions like X.Y.Z-linux-x64, carrying the
|
||||
# native binary + vendored helpers), then the wrapper at X.Y.Z whose
|
||||
# optionalDependencies alias those platform versions. Platform versions MUST
|
||||
# publish first — the wrapper pins them exactly, so the reverse order would
|
||||
# open a window where installs resolve aliases that 404. See
|
||||
# docs/NPM_PACKAGING.md for the full contract.
|
||||
# Publishes the npm wrapper (@gitlawb/zero) once every platform's release
|
||||
# assets are live — the package's postinstall downloads its binary from the
|
||||
# GitHub Release, so publishing before the assets exist would ship a package
|
||||
# that fails to install.
|
||||
#
|
||||
# Auth is npm OIDC trusted publishing: no NPM_TOKEN secret. The package's
|
||||
# npmjs.com settings must list this repo + workflow file as a trusted
|
||||
# publisher, and provenance is then generated automatically. Note npm only
|
||||
# allows configuring a trusted publisher on an EXISTING package, so the very
|
||||
# first @gitlawb/zero publish is a one-time manual `npm publish` bootstrap.
|
||||
# Because the platform payloads are versions of the same package, they reuse
|
||||
# that single trusted-publisher configuration.
|
||||
publish-npm:
|
||||
name: Publish npm package
|
||||
needs: package
|
||||
@@ -161,11 +156,10 @@ jobs:
|
||||
console.log(`npm ${version} supports trusted publishing.`);
|
||||
'
|
||||
|
||||
# Probe the exact URLs the wrapper's first-run downloader builds
|
||||
# (internal/release/release.go asset-name scheme) as an anonymous client
|
||||
# — install.sh/install.ps1 and `zero update` read them too. This also
|
||||
# blocks publishing while the repo is still private: private release
|
||||
# assets 404 without auth, exactly as they would for an installing user.
|
||||
# Probe the exact URLs postinstall will build (internal/release/release.go
|
||||
# asset-name scheme) as an anonymous client. This also blocks publishing
|
||||
# while the repo is still private — private release assets 404 without
|
||||
# auth, exactly as they would for an installing user.
|
||||
- name: Verify release assets are downloadable
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -179,64 +173,12 @@ jobs:
|
||||
"zero-v${version}-windows-x64.zip"; do
|
||||
for url in "${base}/${asset}" "${base}/${asset}.sha256"; do
|
||||
if ! curl -fsSLI -o /dev/null "$url"; then
|
||||
echo "::error::${url} is not publicly downloadable — the wrapper's fallback downloader and install.sh would fail. Is the repo public and are all assets uploaded?" >&2
|
||||
echo "::error::${url} is not publicly downloadable — npm postinstall would fail. Is the repo public and are all assets uploaded?" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
done
|
||||
echo "all release assets for v${version} are publicly downloadable."
|
||||
|
||||
- name: Download release artifacts
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
pattern: zero-*
|
||||
path: dist/artifacts
|
||||
merge-multiple: true
|
||||
|
||||
- name: Assemble npm packages
|
||||
run: node scripts/npm/build-platform-packages.mjs --artifacts-dir dist/artifacts --out-dir dist/npm
|
||||
|
||||
# Platform versions are semver PRERELEASES of the wrapper version
|
||||
# (X.Y.Z-linux-x64), so they must never publish under the default
|
||||
# `latest` dist-tag — that would make `npm install @gitlawb/zero`
|
||||
# deliver a platform payload as the CLI. Publish them under a dedicated
|
||||
# tag and assert `latest` survived before the wrapper goes out.
|
||||
- name: Publish platform packages to npm
|
||||
shell: bash
|
||||
run: |
|
||||
version="$(node -p "require('./package.json').version")"
|
||||
for dir in dist/npm/platforms/*/; do
|
||||
echo "publishing ${dir}"
|
||||
(cd "$dir" && npm publish --access public --tag platform)
|
||||
done
|
||||
# Tolerate a failed lookup (registry lag, or no latest tag yet on a
|
||||
# bootstrap publish): under set -e a bare failure here would abort
|
||||
# AFTER the platform payloads went out, leaving no wrapper and a
|
||||
# re-run that fails on republishing existing versions. An empty
|
||||
# value falls through the guard below.
|
||||
latest="$(npm view @gitlawb/zero dist-tags.latest || true)"
|
||||
case "$latest" in
|
||||
"${version}-"*)
|
||||
echo "::error::dist-tag latest points at platform version ${latest} — a platform publish clobbered it. Fix the dist-tag before the wrapper publish." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
echo "dist-tag latest is ${latest} (unchanged by platform publishes)."
|
||||
|
||||
- name: Publish wrapper to npm
|
||||
shell: bash
|
||||
run: |
|
||||
version="$(node -p "require('./package.json').version")"
|
||||
(cd dist/npm/wrapper && npm publish --access public)
|
||||
# Confirm the wrapper owns `latest` (retry: registry metadata can lag
|
||||
# a publish by a few seconds).
|
||||
for attempt in 1 2 3 4 5; do
|
||||
latest="$(npm view @gitlawb/zero dist-tags.latest || true)"
|
||||
if [ "$latest" = "$version" ]; then
|
||||
echo "dist-tag latest is ${version}."
|
||||
exit 0
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
echo "::error::dist-tag latest is ${latest}, expected ${version} after the wrapper publish." >&2
|
||||
exit 1
|
||||
- name: Publish to npm
|
||||
run: npm publish --access public
|
||||
-16
@@ -27,10 +27,6 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
.cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Go build/module caches (GOCACHE/GOMODCACHE pointed here for local runs)
|
||||
.gocache/
|
||||
.gomodcache/
|
||||
|
||||
# IntelliJ based IDEs
|
||||
.idea
|
||||
|
||||
@@ -41,20 +37,8 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
/zero
|
||||
/zero.exe
|
||||
/zero-windows-command-runner.exe
|
||||
/zero-windows-sandbox-setup.exe
|
||||
/zero-skills
|
||||
/.zero-binary-version
|
||||
/snake-game.html
|
||||
|
||||
# Superpowers skill artifacts (brainstorm mockups, specs, plans) — local only
|
||||
.superpowers/
|
||||
docs/superpowers/
|
||||
|
||||
# Termux/Android build artifacts
|
||||
zero-termux-arm64
|
||||
zero-linux-sandbox
|
||||
zero-seccomp
|
||||
|
||||
# Generated benchmark reports are evidence for one configuration, never tree state
|
||||
internal/perfbench/reports/*.json
|
||||
@@ -1 +1 @@
|
||||
{".":"0.5.0"}
|
||||
{".":"0.1.0"}
|
||||
@@ -1,71 +1,319 @@
|
||||
# Repository Guidelines for Zero
|
||||
---
|
||||
description: Extending Zero — how to write AGENTS.md files, custom specialists, skills, hooks, MCP servers, and plugins for the open-source CLI coding agent.
|
||||
globs: "*.go, *.js, *.md, *.json, *.toml, *.yaml, *.yml"
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
These instructions apply to all work in this repository. For the user-facing
|
||||
guide to extending Zero with specialists, hooks, plugins, MCP, and skills, see
|
||||
[docs/EXTENDING.md](docs/EXTENDING.md).
|
||||
# Extending Zero
|
||||
|
||||
## 1. Contribution and Pull Request Rules
|
||||
Zero is an open-source terminal coding agent. Out of the box it does the obvious things — read, edit, run, search — but the design point of the project is that **every surface is configurable**. This document is the user-facing guide for that configuration.
|
||||
|
||||
- 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.
|
||||
If you only want to *use* Zero, the [README](README.md) is enough. This page is for the other three jobs:
|
||||
|
||||
## 2. Repository and Implementation Conventions
|
||||
1. Tell the agent about *your* project (drop an `AGENTS.md` in your repo).
|
||||
2. Add new specialist sub-agents.
|
||||
3. Wire Zero into the rest of your toolchain (MCP, skills, hooks, plugins).
|
||||
|
||||
- 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.
|
||||
## 1. Drop a project `AGENTS.md`
|
||||
|
||||
## 3. Required Validation
|
||||
When Zero starts in a directory, it looks for project-level instructions and injects them into the system prompt. The lookup walks from your current working directory **up to the nearest git root** and reads the first matching file at each level — general rules at the repo root, more specific rules in sub-trees. Files are labeled with their directory in the prompt (e.g. `## Project guidelines (services/api/AGENTS.md)`).
|
||||
|
||||
Run validation from the repository root before committing, opening a pull
|
||||
request, or completing an implementation task:
|
||||
Accepted file names, in priority order at each level:
|
||||
|
||||
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).
|
||||
| Path | Notes |
|
||||
| --- | --- |
|
||||
| `./AGENTS.md` | The classic spot — committed to your repo, shared with the team. |
|
||||
| `./ZERO.md` | Brand-specific alias. Same format, lower priority. |
|
||||
| `./.zero/AGENTS.md` | Project-local, hidden, gitignored. Personal notes that stay out of git. |
|
||||
|
||||
`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.
|
||||
Matching is **case-insensitive** on the basename, so `AGENTS.md`, `Agents.md`, and `agents.md` resolve to the same file on Windows and macOS. The git-tracked filename in this repo is `AGENTS.md` — keep that on case-sensitive filesystems (Linux, the WSL filesystem, or a CI runner) to match what the loader looks for.
|
||||
|
||||
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.
|
||||
Both files use the same format. YAML frontmatter is optional; the markdown body is loaded as instructions for the agent. Zero reads the file once at session start, so changes take effect on the next `zero` launch — not mid-session.
|
||||
|
||||
```markdown
|
||||
# Project conventions for <your project>
|
||||
|
||||
- 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.
|
||||
```
|
||||
|
||||
Tips:
|
||||
|
||||
- Keep each file under ~8 KiB. Zero caps the **total** across all matched files at 32 KiB; everything past the cap is dropped.
|
||||
- Re-state rules in the imperative voice: "Run `make lint`", not "you should consider running the linter".
|
||||
- Don't put secrets, model IDs, or environment-specific paths in `AGENTS.md`. Use `config.json` for those.
|
||||
- In a monorepo, drop a narrower `AGENTS.md` in each sub-tree (e.g. `services/api/AGENTS.md`). Zero picks those up automatically when you launch from inside the sub-tree.
|
||||
- A YAML frontmatter block (`---\n...\n---`) at the top is preserved verbatim in the injected prompt but is not parsed for `globs:` or `alwaysApply:` scoping today — keep the body self-contained.
|
||||
|
||||
## 2. Custom specialists
|
||||
|
||||
Specialists are Zero's sub-agents. Three scopes, in priority order:
|
||||
|
||||
| Scope | Path | Shared? |
|
||||
| --- | --- | --- |
|
||||
| Built-in | compiled into Zero | yes — `worker`, `explorer`, `code-review` |
|
||||
| User | `~/.config/zero/specialists/*.md` | no — your machine only |
|
||||
| Project | `./.zero/specialists/*.md` | yes — the repo team |
|
||||
|
||||
Project overrides user overrides built-in when names collide.
|
||||
|
||||
A specialist is a markdown manifest with frontmatter and a system prompt:
|
||||
|
||||
```markdown
|
||||
---
|
||||
description: Reviews API changes for breaking-change risk and missing tests.
|
||||
tools: read-only,plan
|
||||
---
|
||||
|
||||
You review API changes. For every changed hunk in `internal/api/` or any file
|
||||
that ends in `_api.go`:
|
||||
|
||||
1. Confirm the public signature is backward-compatible, or note the breaking
|
||||
change explicitly with the migration path.
|
||||
2. Confirm a corresponding test exists in `internal/api/*_test.go` and that
|
||||
the new behaviour is exercised.
|
||||
3. Flag any new exported symbol without a doc comment.
|
||||
|
||||
Reply with one JSON object per finding: `{"file", "line", "severity", "message", "fix"}`.
|
||||
```
|
||||
|
||||
CLI management (the prompt is passed inline via `--prompt`):
|
||||
|
||||
```bash
|
||||
zero specialist list
|
||||
zero specialist show api-reviewer
|
||||
zero specialist create api-reviewer \
|
||||
--project \
|
||||
--description "Reviews API changes" \
|
||||
--tools read-only,plan \
|
||||
--prompt "$(cat api-reviewer.md)"
|
||||
zero specialist edit api-reviewer --project
|
||||
zero specialist delete api-reviewer --project
|
||||
zero specialist path # prints the resolved specialists directory
|
||||
```
|
||||
|
||||
The full format spec (frontmatter fields, tool scopes, prompt conventions) is in [`docs/SPECIALISTS.md`](docs/SPECIALISTS.md).
|
||||
|
||||
> **Roadmap.** An in-UI specialist manager (create / edit / delete / preview) is on the backlog. Today you use the `zero specialist` CLI subcommands above.
|
||||
|
||||
## 3. Skills
|
||||
|
||||
Skills are markdown instruction packs the agent can pull in on demand. Each skill is a directory containing a `SKILL.md`. Skills are **user-level only** in this version — there's no project-scoped skill directory yet, so anything you want shared with the team goes in `AGENTS.md` (section 1) or as a hook (section 4).
|
||||
|
||||
Discovery root: `$ZERO_SKILLS_DIR` → `$XDG_DATA_HOME/zero/skills` → `~/.local/share/zero/skills/`. A missing directory is fine — Zero just reports "no skills".
|
||||
|
||||
```
|
||||
~/.local/share/zero/skills/
|
||||
run-benchmarks/
|
||||
SKILL.md
|
||||
write-changelog/
|
||||
SKILL.md
|
||||
```
|
||||
|
||||
`SKILL.md` format:
|
||||
|
||||
```markdown
|
||||
---
|
||||
description: Run the project's benchmark suite and summarize the deltas.
|
||||
---
|
||||
|
||||
# Run benchmarks
|
||||
|
||||
1. `make bench` — captures the wall-clock and RSS before and after.
|
||||
2. `benchstat before.txt after.txt` — diffs the two.
|
||||
3. Report any regression > 5% with the function name and the previous value.
|
||||
```
|
||||
|
||||
Only `name` and `description` are recognized in the frontmatter today. The `name` defaults to the directory name. If two skills declare the same name, the one in the lexicographically-first directory wins — duplicates are dropped silently. Plugin-declared skills (section 6) are not yet merged into the loader, so a `skills:` entry inside a plugin's `plugin.json` is not visible to the `skill` tool today.
|
||||
|
||||
The `skill` core tool lets the agent load any discovered skill by name.
|
||||
|
||||
## 4. Hooks
|
||||
|
||||
Hooks fire shell commands on lifecycle events. Configure them in JSON:
|
||||
|
||||
- User: `~/.config/zero/hooks.json`
|
||||
- Project: `./.zero/hooks.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"enabled": true,
|
||||
"hooks": [
|
||||
{
|
||||
"id": "block-rm-rf",
|
||||
"event": "beforeTool",
|
||||
"matcher": "bash",
|
||||
"command": "/usr/local/bin/zero-hook-block-rmrf.sh",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"id": "log-session",
|
||||
"event": "sessionStart",
|
||||
"command": "/usr/local/bin/zero-hook-log.sh",
|
||||
"enabled": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The `args` array (when present) is passed verbatim to `exec.CommandContext`. The actual hook payload — event name, matcher, tool call id, tool name, tool input, tool output, status — is delivered to the command as **JSON on stdin**, not via `${...}` substitution. A typical handler reads stdin and decides what to do:
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
# /usr/local/bin/zero-hook-block-rmrf.sh
|
||||
set -euo pipefail
|
||||
payload="$(cat)"
|
||||
if printf '%s' "$payload" | grep -q '"input":"[^"]*rm[[:space:]]+-rf'; then
|
||||
echo "refusing rm -rf" >&2
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
Events the agent emits (in dispatch order):
|
||||
|
||||
| Event | Fires when | Matcher allowed? |
|
||||
| --- | --- | --- |
|
||||
| `beforeTool` | A tool is about to run | yes (tool name) |
|
||||
| `afterTool` | A tool just returned | yes (tool name) |
|
||||
| `sessionStart` | A session begins | no |
|
||||
| `sessionEnd` | A session ends | no |
|
||||
| `specialistStart` | A sub-agent is spawned | yes (specialist name) |
|
||||
| `specialistStop` | A sub-agent ends | yes (specialist name) |
|
||||
|
||||
A hook's exit code decides what happens next: `0` continues, non-zero blocks the tool call (`beforeTool`) or surfaces an error (`afterTool`). Hook execution is recorded in the audit log; the audit is reachable from the agent's view of past actions, not from a dedicated `zero doctor` check.
|
||||
|
||||
> **Roadmap.** An in-UI hooks manager is on the backlog. Today you edit the JSON directly.
|
||||
|
||||
## 5. MCP — Model Context Protocol
|
||||
|
||||
Zero is both an **MCP client** (it can call external MCP servers) and an **MCP server** (other agents can call its tools).
|
||||
|
||||
### As a client — configure MCP servers in `config.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"mcp": {
|
||||
"servers": {
|
||||
"docs": {
|
||||
"type": "stdio",
|
||||
"command": "docs-mcp",
|
||||
"args": ["--port", "7777"]
|
||||
},
|
||||
"github": {
|
||||
"type": "http",
|
||||
"url": "https://api.example.com/mcp",
|
||||
"headers": { "Authorization": "Bearer YOUR_TOKEN_HERE" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Manage via CLI:
|
||||
|
||||
```bash
|
||||
zero mcp add docs --type stdio -- docs-mcp --port 7777
|
||||
zero mcp add github --type http --url https://api.example.com/mcp \
|
||||
--header "Authorization=Bearer YOUR_TOKEN_HERE"
|
||||
zero mcp list
|
||||
zero mcp check docs
|
||||
zero mcp remove github
|
||||
zero mcp oauth login github
|
||||
```
|
||||
|
||||
Servers are merged from user and project configs (project wins on conflicts). Token-bearing values in `config.json` are sent verbatim — there is no `${env:...}` expansion — so prefer one of:
|
||||
|
||||
- A wrapper script that sources the secret and execs the real command.
|
||||
- A `--header` value produced by command substitution (`"Authorization=Bearer $(print-token)"`) in a private shell config that you keep out of git.
|
||||
- A secret manager that injects the env var your MCP server reads on its own (the `command` and `args` then run inside that environment).
|
||||
|
||||
### As a server — expose Zero's tools to another agent
|
||||
|
||||
```bash
|
||||
zero serve --mcp
|
||||
```
|
||||
|
||||
The server speaks MCP over stdio. Configure it from the receiving side as a `stdio` server whose command is `zero serve --mcp`.
|
||||
|
||||
## 6. Plugins
|
||||
|
||||
A plugin is a self-contained directory that bundles tools, hooks, and skills for one capability. Plugins live at:
|
||||
|
||||
- User: `~/.config/zero/plugins/<id>/`
|
||||
- Project: `./.zero/plugins/<id>/`
|
||||
|
||||
Each plugin has a `plugin.json` manifest:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "github-pr-review",
|
||||
"name": "GitHub PR Review",
|
||||
"description": "Adds review tools for GitHub PRs.",
|
||||
"version": "1.0.0",
|
||||
"tools": [
|
||||
{ "name": "list_prs", "command": "./tools/list_prs.sh" }
|
||||
],
|
||||
"hooks": [
|
||||
{ "name": "pre-merge-check", "event": "beforeTool", "command": "./hooks/pre-merge.sh" }
|
||||
],
|
||||
"skills": [
|
||||
{ "path": "./skills/review-checklist/SKILL.md" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Install and manage:
|
||||
|
||||
```bash
|
||||
zero plugins add ./github-pr-review # copy into ~/.config/zero/plugins/ or ./.zero/plugins/
|
||||
zero plugins list
|
||||
zero plugins remove github-pr-review # alias: rm
|
||||
```
|
||||
|
||||
A plugin is enabled by being present in the plugins directory and disabled by removing it (or by the user setting `"enabled": false` in its `plugin.json`). Plugins are not enabled or disabled by a CLI subcommand today.
|
||||
|
||||
Plugin commands run with the plugin directory as their working directory. Use relative paths; the loader resolves them at activation time.
|
||||
|
||||
> **Roadmap.** An in-UI plugins manager (browse, install, enable / disable) is on the backlog. Today you use the `zero plugins` CLI subcommands above. Skills declared inside a plugin's `plugin.json` are not yet merged into the `skill` tool's discovery (see section 3).
|
||||
|
||||
## 7. Configuration locations
|
||||
|
||||
Three layers, applied in order (later layers override earlier ones):
|
||||
|
||||
| Layer | Path | Notes |
|
||||
| --- | --- | --- |
|
||||
| Built-in defaults | compiled in | Lowest priority. |
|
||||
| User config | `~/.config/zero/config.json` | Your machine. Never committed. |
|
||||
| Project config | `./.zero/config.json` | The repo. Committed (or not, your call). |
|
||||
| CLI flags | `--model`, `--mode`, ... | Highest priority, per-invocation. |
|
||||
| Environment | `ZERO_*` | Provider commands, secrets, skills dir override. |
|
||||
|
||||
The user config holds things that should follow the user across projects (default provider, default model, theme). The project config holds things the team agreed on (provider catalog, sandbox policies, model restrictions).
|
||||
|
||||
The sandbox `additionalWriteRoots` key is **ignored in project config** by design — a checked-out repo cannot widen its own sandbox. Set it in the user config or pass `--add-dir` per-invocation.
|
||||
|
||||
## 8. End-to-end example
|
||||
|
||||
A team that wants every contributor's Zero to behave the same way commits:
|
||||
|
||||
- `AGENTS.md` — project conventions, build commands, do-not-edit lists.
|
||||
- `.zero/config.json` — provider catalog, default model, allowed tools.
|
||||
- `.zero/specialists/api-reviewer.md` — the team's PR-review specialist.
|
||||
- `.zero/hooks.json` — block `rm -rf` and `git push --force` on `beforeTool`.
|
||||
- `.zero/plugins/internal-tooling/` — a plugin that adds the team's internal CLI tools to the agent's toolset.
|
||||
|
||||
Each contributor adds only:
|
||||
|
||||
- `~/.config/zero/config.json` — their personal API keys, theme, default mode.
|
||||
- `~/.local/share/zero/skills/` — personal skills they keep across projects.
|
||||
|
||||
That's it. Run `zero` from the repo root and the agent has the team's full instruction set, every contributor's personal setup, and nothing else.
|
||||
|
||||
## 9. Reference
|
||||
|
||||
- [README](README.md) — install, quickstart, command reference.
|
||||
- [docs/SPECIALISTS.md](docs/SPECIALISTS.md) — full specialist manifest spec.
|
||||
- [docs/STREAM_JSON_PROTOCOL.md](docs/STREAM_JSON_PROTOCOL.md) — `zero exec` I/O contract.
|
||||
- [docs/INSTALL.md](docs/INSTALL.md) — install from source or release.
|
||||
+4
-215
@@ -6,212 +6,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) once the first release is
|
||||
tagged. Until then, source builds report the version `dev`.
|
||||
|
||||
## [0.5.0](https://github.com/Gitlawb/zero/compare/v0.4.0...v0.5.0) (2026-07-22)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **plugins:** add zero plugins info command ([#773](https://github.com/Gitlawb/zero/issues/773)) ([2479884](https://github.com/Gitlawb/zero/commit/2479884dea49580c853a53536c3bbab22ce8a2bf))
|
||||
* **sandbox:** disable the sandbox via config ([#687](https://github.com/Gitlawb/zero/issues/687)) ([#746](https://github.com/Gitlawb/zero/issues/746)) ([a21a052](https://github.com/Gitlawb/zero/commit/a21a052a4a32ce9cb3c93c94350cd24e138532cd))
|
||||
* **sandbox:** unify command execution and enforcement ([#781](https://github.com/Gitlawb/zero/issues/781)) ([96859c9](https://github.com/Gitlawb/zero/commit/96859c9bd16f6dad4e332efc7e68178b9116118a))
|
||||
* **tui:** add /undo as an alias for /rewind ([#698](https://github.com/Gitlawb/zero/issues/698)) ([#747](https://github.com/Gitlawb/zero/issues/747)) ([8c6d302](https://github.com/Gitlawb/zero/commit/8c6d3022bd7801eaad6a0440bb21a27221887297))
|
||||
* **tui:** add isolated /btw conversations ([#748](https://github.com/Gitlawb/zero/issues/748)) ([2e267bd](https://github.com/Gitlawb/zero/commit/2e267bdbee4a77813e93a7ab51f0c575b66cee8c))
|
||||
* **tui:** permission prompt takes free-text feedback inline ([#780](https://github.com/Gitlawb/zero/issues/780)) ([3967d49](https://github.com/Gitlawb/zero/commit/3967d49d64998341af8ef6d7523fc63aeb2a5a7a))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **agent:** stop write_stdin session_id probing thrash ([#702](https://github.com/Gitlawb/zero/issues/702)) ([#749](https://github.com/Gitlawb/zero/issues/749)) ([fbf8598](https://github.com/Gitlawb/zero/commit/fbf85984f679058125951fe2d5e4f200e09a3e2f))
|
||||
* **cli:** warn when ZERO_PROVIDER overrides a providers-use selection ([#767](https://github.com/Gitlawb/zero/issues/767)) ([3524f79](https://github.com/Gitlawb/zero/commit/3524f795e5fdbb827166a84f03351cedfc9eba30))
|
||||
* **doctor:** detect missing native binary during runtime checks ([#450](https://github.com/Gitlawb/zero/issues/450)) ([7796022](https://github.com/Gitlawb/zero/commit/77960229b839dca856616f846839aa773f2923f7))
|
||||
* **lsp:** make the real-gopls check opt-in so a broken gopls can't fail the suite ([#684](https://github.com/Gitlawb/zero/issues/684)) ([#766](https://github.com/Gitlawb/zero/issues/766)) ([b1f4173](https://github.com/Gitlawb/zero/commit/b1f41735a7f6b7928a1875e986a4dca39d106cb9))
|
||||
* make extension installs transactional ([#762](https://github.com/Gitlawb/zero/issues/762)) ([baa4be1](https://github.com/Gitlawb/zero/commit/baa4be13ac5321da4e9f53e864dd1cd395481200))
|
||||
* **oauth:** refuse redirects on credential POSTs ([#729](https://github.com/Gitlawb/zero/issues/729)) ([#741](https://github.com/Gitlawb/zero/issues/741)) ([974fc03](https://github.com/Gitlawb/zero/commit/974fc036c2f9a194722f2e8fddbb4fdbf797effe))
|
||||
* **oauth:** validate discovered endpoints before merge/use ([#511](https://github.com/Gitlawb/zero/issues/511)) ([#739](https://github.com/Gitlawb/zero/issues/739)) ([ce4a996](https://github.com/Gitlawb/zero/commit/ce4a996ffac4482e704f0fd61b3e442398fb2401))
|
||||
* **perfbench:** absolutize the bench binary and make errored tasks first-class ([#730](https://github.com/Gitlawb/zero/issues/730)) ([dbd9443](https://github.com/Gitlawb/zero/commit/dbd94430143df6754d68551d1028ad8f15b82f1b))
|
||||
* **perfbench:** grant write tools so mutating tasks measure real edits ([#763](https://github.com/Gitlawb/zero/issues/763)) ([e1975c1](https://github.com/Gitlawb/zero/commit/e1975c1b396b3236ed648870230b4f85863b1d03))
|
||||
* **perfbench:** keep the stamped answer file out of negative oracle greps ([#737](https://github.com/Gitlawb/zero/issues/737)) ([015452c](https://github.com/Gitlawb/zero/commit/015452c1c98a39eabb021324182094e188a8bd47))
|
||||
* **providers:** stop "provider not found" for env-derived profiles ([#716](https://github.com/Gitlawb/zero/issues/716)) ([4cbd144](https://github.com/Gitlawb/zero/commit/4cbd144d11e5cb67bc5fea46f5b294562dac7a1a))
|
||||
* **sandbox:** AST second opinion for interactive-command bypasses ([#473](https://github.com/Gitlawb/zero/issues/473)) ([#745](https://github.com/Gitlawb/zero/issues/745)) ([f079b90](https://github.com/Gitlawb/zero/commit/f079b90f82dac7b7ae0864b279dc9094e31a6627))
|
||||
* **sandbox:** bind Windows elevated ACL setup to one no-follow handle ([#765](https://github.com/Gitlawb/zero/issues/765)) ([4945684](https://github.com/Gitlawb/zero/commit/4945684fa26aa5994eda59dcabedc817423c535d))
|
||||
* **sandbox:** don't auto-allow shell when re-entrancy skips wrapping ([#727](https://github.com/Gitlawb/zero/issues/727)) ([#744](https://github.com/Gitlawb/zero/issues/744)) ([6849011](https://github.com/Gitlawb/zero/commit/684901165d1b7f50a8bb4af31b1c1951e6926a79))
|
||||
* **tools:** give write_stdin's invalid-session errors the same recovery guidance ([#749](https://github.com/Gitlawb/zero/issues/749) follow-up) ([#768](https://github.com/Gitlawb/zero/issues/768)) ([da9fb50](https://github.com/Gitlawb/zero/commit/da9fb50f549f6701eb136fc99f6178d0772d9334))
|
||||
* **tools:** read_file recovers a backwards line range instead of erroring ([#779](https://github.com/Gitlawb/zero/issues/779)) ([89bdc67](https://github.com/Gitlawb/zero/commit/89bdc6719a1e1b3a3ef0e36b91a7839b3efdfba9))
|
||||
* **tui:** cache settled alt-screen transcript ([#647](https://github.com/Gitlawb/zero/issues/647)) ([d74ceb1](https://github.com/Gitlawb/zero/commit/d74ceb11271ed68a21c19248210e098f411805fb))
|
||||
* **tui:** model rows labelled by id when the description is prose; keep the sidebar under the / palette ([#775](https://github.com/Gitlawb/zero/issues/775)) ([b30c397](https://github.com/Gitlawb/zero/commit/b30c3971b40b06ba52ad649ba9dc0ad560d4be4c))
|
||||
* **tui:** stop the permission card clashing on cool themes ([#778](https://github.com/Gitlawb/zero/issues/778)) ([722bb31](https://github.com/Gitlawb/zero/commit/722bb3121682d9cd9cd4bc6c127e9014d719262a))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* **agent:** concurrent read-only tool batches via capability gate ([#715](https://github.com/Gitlawb/zero/issues/715)) ([31d45d5](https://github.com/Gitlawb/zero/commit/31d45d5f14e915acb9946e8b8eb81632c48f126a))
|
||||
* **agent:** execution profiles with one-shot posture escalation (PR10b+PR10c) ([#740](https://github.com/Gitlawb/zero/issues/740)) ([378d538](https://github.com/Gitlawb/zero/commit/378d538e240c289e57821e9f9628f76034419d01))
|
||||
* **agent:** posture-escalation signals and controller (PR10a) ([#736](https://github.com/Gitlawb/zero/issues/736)) ([af875df](https://github.com/Gitlawb/zero/commit/af875df58775484304fc65586bd6c74552ad01a2))
|
||||
* **agent:** preserve prompt cache prefixes ([#760](https://github.com/Gitlawb/zero/issues/760)) ([739a47e](https://github.com/Gitlawb/zero/commit/739a47e3eac92c3decc8734f52a4d99c7480c3ca))
|
||||
* **openai:** optimized turn session — background prewarm and prefix telemetry (PR8) ([#723](https://github.com/Gitlawb/zero/issues/723)) ([60dc84e](https://github.com/Gitlawb/zero/commit/60dc84e7a38c5544ebc047f3cfaf4625dd1e83b5))
|
||||
* **output:** add token-aware semantic output budgeting (PR11) ([#717](https://github.com/Gitlawb/zero/issues/717)) ([e5670c4](https://github.com/Gitlawb/zero/commit/e5670c427ff39c628fb0822fd5c9317ee2174583))
|
||||
* **providers:** provider capabilities and default turn-session adapter (PR7) ([#720](https://github.com/Gitlawb/zero/issues/720)) ([30e2c3f](https://github.com/Gitlawb/zero/commit/30e2c3f7ffa1d5e487bd10b59d4e823cda191d48))
|
||||
* **turn-bench:** Phase 0 — strengthen oracles so pass rate can't be misread as correctness ([#712](https://github.com/Gitlawb/zero/issues/712)) ([727ad4d](https://github.com/Gitlawb/zero/commit/727ad4d321fab45d0cf40f8535522e3d94e55c4a))
|
||||
|
||||
## [0.4.0](https://github.com/Gitlawb/zero/compare/v0.3.0...v0.4.0) (2026-07-17)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **aimlapi:** AI/ML API provider with guided onboarding (top-up + key issuance) ([#655](https://github.com/Gitlawb/zero/issues/655)) ([6b9c2f0](https://github.com/Gitlawb/zero/commit/6b9c2f0c083e02bcd3aad68fb7af0501a9e7cd61))
|
||||
* **cli:** show ZERO wordmark on --version ([#673](https://github.com/Gitlawb/zero/issues/673)) ([9acb411](https://github.com/Gitlawb/zero/commit/9acb4113cc3337b3f361a16278e9cf11ca105e34))
|
||||
* **cli:** wire MCP serve WorkspaceRoot and --add-dir scope ([#694](https://github.com/Gitlawb/zero/issues/694)) ([75a78e7](https://github.com/Gitlawb/zero/commit/75a78e715bf23154f69c8c79cb58eb4b535b2a2a))
|
||||
* **npm:** ship the native binary as platform optionalDependencies ([#626](https://github.com/Gitlawb/zero/issues/626)) ([5e1405d](https://github.com/Gitlawb/zero/commit/5e1405d0b7abff5b3ccb3cfdb66d64d6d3322922))
|
||||
* **perf:** emit prompt-prefix hash fingerprint per turn ([#704](https://github.com/Gitlawb/zero/issues/704)) ([1c5c6e7](https://github.com/Gitlawb/zero/commit/1c5c6e78a8a0e228bdf53d7be90934fbce9d98c3))
|
||||
* **providers:** add AI/ML API preset (rebased onto main) ([#621](https://github.com/Gitlawb/zero/issues/621)) ([d66a9dd](https://github.com/Gitlawb/zero/commit/d66a9dda69c32aa59f4bea903cfefe00d4b7adef))
|
||||
* **providers:** refresh MiniMax model coverage ([#665](https://github.com/Gitlawb/zero/issues/665)) ([fa3052a](https://github.com/Gitlawb/zero/commit/fa3052a1422a4ad30a3a6295829564f42dee31a8))
|
||||
* **skills:** discover shared ~/.agents/skills with multi-root skill loading ([#696](https://github.com/Gitlawb/zero/issues/696)) ([7d57999](https://github.com/Gitlawb/zero/commit/7d579996b43741a2e57e3e38fcdd8484fcfc34e9))
|
||||
* **tui:** Ctrl+X leader chords and emacs menu navigation ([#699](https://github.com/Gitlawb/zero/issues/699)) ([7f669f4](https://github.com/Gitlawb/zero/commit/7f669f455021be51319ee3b8298cd48a17f745c7))
|
||||
* **tui:** press up to edit queued messages ([#656](https://github.com/Gitlawb/zero/issues/656)) ([4c986d3](https://github.com/Gitlawb/zero/commit/4c986d327b5ed26338d295c23bea5839681db8d0))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **acp:** make truncateHint rune-safe ([#614](https://github.com/Gitlawb/zero/issues/614)) ([ddc4927](https://github.com/Gitlawb/zero/commit/ddc4927aac5544bf4dd2c46615aeda5a81c96576))
|
||||
* **agent,tui:** resolve git branch detection when starting Zero in subdirectories ([#613](https://github.com/Gitlawb/zero/issues/613)) ([0184581](https://github.com/Gitlawb/zero/commit/0184581ec234ea414e0a47bc33e8b0f4ddfb497b))
|
||||
* **agent:** raise default and deep-mode turn budgets ([#650](https://github.com/Gitlawb/zero/issues/650)) ([635c93a](https://github.com/Gitlawb/zero/commit/635c93af51ebc20f3e0917917e55a79edfe27c35))
|
||||
* **cli:** prevent consuming positional arguments as flag values ([#619](https://github.com/Gitlawb/zero/issues/619)) ([5b4f48d](https://github.com/Gitlawb/zero/commit/5b4f48d2dcb66402c13bde0c3cfe9c9371da19fb))
|
||||
* **config:** enforce MCP trust boundary so project config cannot override user disable ([#609](https://github.com/Gitlawb/zero/issues/609)) ([4d8c31c](https://github.com/Gitlawb/zero/commit/4d8c31cf16a3080344e3be7039408fcae71c075d)), closes [#512](https://github.com/Gitlawb/zero/issues/512)
|
||||
* **config:** surface unknown/typo'd config fields instead of silently dropping them ([#645](https://github.com/Gitlawb/zero/issues/645)) ([893b7b4](https://github.com/Gitlawb/zero/commit/893b7b424cc203a2fcf92327a4e25c84286a90e0))
|
||||
* **cron:** prevent cron job Mutate from clobbering concurrent updates ([#630](https://github.com/Gitlawb/zero/issues/630)) ([e4bd703](https://github.com/Gitlawb/zero/commit/e4bd703cfb28dab2dfa3c2ddba46237e1bb2e164))
|
||||
* **daemon:** handle os.ErrPermission as collision during O_EXCL lock creation ([#616](https://github.com/Gitlawb/zero/issues/616)) ([8ea5384](https://github.com/Gitlawb/zero/commit/8ea53841a5d54c37a153779ae86bea010659433c))
|
||||
* **exec:** stop false INCOMPLETE downgrades on conversational final messages ([#608](https://github.com/Gitlawb/zero/issues/608)) ([b6117af](https://github.com/Gitlawb/zero/commit/b6117af86d6bc87a4ee66910e99d76cb16b03fed))
|
||||
* harden MCP credential boundaries ([#597](https://github.com/Gitlawb/zero/issues/597)) ([fdddb05](https://github.com/Gitlawb/zero/commit/fdddb05ba84b1600ae6c3a20028bf83afe474c44))
|
||||
* **hooks:** fail closed on launch failures for beforeTool hooks ([#629](https://github.com/Gitlawb/zero/issues/629)) ([dc06fe7](https://github.com/Gitlawb/zero/commit/dc06fe72caf45f72d2cba1e8a835c0f5b405c1e8))
|
||||
* **hooks:** run sessionEnd hooks after Esc/Ctrl+C interrupts ([#606](https://github.com/Gitlawb/zero/issues/606)) ([824ecdb](https://github.com/Gitlawb/zero/commit/824ecdbcf9c467c35ef4e2666770fdadcb5bf402))
|
||||
* **keyring:** pass generic password via stdin on macOS ([#574](https://github.com/Gitlawb/zero/issues/574)) ([91ea6de](https://github.com/Gitlawb/zero/commit/91ea6ded7503538834a84d090f78670a363c62d3))
|
||||
* **lock:** prevent POSIX lock file overwrite and leak on Windows/Unix ([#628](https://github.com/Gitlawb/zero/issues/628)) ([da41c3a](https://github.com/Gitlawb/zero/commit/da41c3a75b782d6e0836fe13346321e40a90fbb4))
|
||||
* **openai:** omit prompt_cache_key for openai-compatible providers ([#636](https://github.com/Gitlawb/zero/issues/636)) ([1af5882](https://github.com/Gitlawb/zero/commit/1af58828eb3c22567599c000736c913a290959d2)), closes [#624](https://github.com/Gitlawb/zero/issues/624)
|
||||
* **plugins:** resolve relative executable paths against plugin root ([#627](https://github.com/Gitlawb/zero/issues/627)) ([2efe6d5](https://github.com/Gitlawb/zero/commit/2efe6d539e29374b3ef39c2290bdea81f33a228b))
|
||||
* **sandbox:** remove windowsWriteRestricted flag to fix DenyRead bypass ([#612](https://github.com/Gitlawb/zero/issues/612)) ([3d96ac7](https://github.com/Gitlawb/zero/commit/3d96ac7e55c760a97f28c0e6ceaf1ec3b4ab717a))
|
||||
* **sandbox:** scrub dynamic credential env vars ([#682](https://github.com/Gitlawb/zero/issues/682)) ([9043bae](https://github.com/Gitlawb/zero/commit/9043baedcff7776c7373645b57563cac06b31847))
|
||||
* **sandbox:** scrub sensitive credentials from sandbox environment ([#660](https://github.com/Gitlawb/zero/issues/660)) ([6fc1220](https://github.com/Gitlawb/zero/commit/6fc1220f6ac66fb3ae67b637cbbed7068d2213c0))
|
||||
* **sandbox:** unblock git fetch/commit/add under the write-restricted sandbox ([#654](https://github.com/Gitlawb/zero/issues/654)) ([5c4815a](https://github.com/Gitlawb/zero/commit/5c4815a66ed07d9cf90b825adfd936d3ac07639d))
|
||||
* **sandbox:** use WRITE_RESTRICTED token when no DenyRead paths are configured ([#658](https://github.com/Gitlawb/zero/issues/658)) ([a5d2e32](https://github.com/Gitlawb/zero/commit/a5d2e327c8681671aa8a9e5378801215b747edcf))
|
||||
* **securefile,credstore:** call Sync on temp file before close and rename ([#631](https://github.com/Gitlawb/zero/issues/631)) ([212734a](https://github.com/Gitlawb/zero/commit/212734adf3b5f982e22385161205aa1afe4634fe))
|
||||
* **securefile:** reclaim stale lock files to prevent permanent DOS ([#615](https://github.com/Gitlawb/zero/issues/615)) ([8536cc8](https://github.com/Gitlawb/zero/commit/8536cc87f7a885f9e436d6ef28f5f325201623dc))
|
||||
* **swarm:** wait for job.Runs directly in scheduler skip test ([#667](https://github.com/Gitlawb/zero/issues/667)) ([1bb6b57](https://github.com/Gitlawb/zero/commit/1bb6b5745af90321d1e657a12a1976cded5dd1bd))
|
||||
* **tools:** classify silent wrapped Windows command failures as sandbox denials ([#659](https://github.com/Gitlawb/zero/issues/659)) ([8bd9742](https://github.com/Gitlawb/zero/commit/8bd9742fa95c41b93ea4e718628aed0ff3ae9dd0))
|
||||
* **tools:** preserve SysProcAttr during PTY fallback ([#618](https://github.com/Gitlawb/zero/issues/618)) ([f78b36c](https://github.com/Gitlawb/zero/commit/f78b36c770daa4577a9f99265b18a354454e36eb))
|
||||
* **tui:** resolve pending askUser callbacks to prevent runner hangs ([#620](https://github.com/Gitlawb/zero/issues/620)) ([aa73a76](https://github.com/Gitlawb/zero/commit/aa73a76f1bd1b6fe97bac2fbff2d61b7474139f2))
|
||||
* **tui:** stop the composer cursor blinking while typing or unfocused ([#672](https://github.com/Gitlawb/zero/issues/672)) ([2b42cd5](https://github.com/Gitlawb/zero/commit/2b42cd567b96d6f7e0818594e53ff31cce1e42e9))
|
||||
* **windows:** resolve absolute path for taskkill to prevent hijacking ([#617](https://github.com/Gitlawb/zero/issues/617)) ([2db00ee](https://github.com/Gitlawb/zero/commit/2db00ee3d57db97e0fbe23cb8628e8bcb47f6f09))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* **tools:** add explicit effect metadata for safe concurrency ([#705](https://github.com/Gitlawb/zero/issues/705)) ([8ef8576](https://github.com/Gitlawb/zero/commit/8ef8576df7d0775a5b815bc0776a794cadb75c34))
|
||||
|
||||
## [0.3.0](https://github.com/Gitlawb/zero/compare/v0.2.0...v0.3.0) (2026-07-09)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* gate project-scoped hooks, plugins, and MCP servers behind workspace trust ([#529](https://github.com/Gitlawb/zero/issues/529)) ([a880ce8](https://github.com/Gitlawb/zero/commit/a880ce80a6ec72da511fb9bdf6dd69291c72a64b))
|
||||
* **modelregistry:** infer reasoning efforts for Hunyuan and vendor-prefixed model ids ([#599](https://github.com/Gitlawb/zero/issues/599)) ([92a92ce](https://github.com/Gitlawb/zero/commit/92a92ceb29dc63f5216ab140ef9a6dd9afe17df8))
|
||||
* **providers:** OAuth login profiles and list-first /provider manager ([#560](https://github.com/Gitlawb/zero/issues/560)) ([1655056](https://github.com/Gitlawb/zero/commit/16550569c1be615cfaf244dab25909ec37f6dee6))
|
||||
* **tui:** remember recent provider+model selections in /model picker ([#568](https://github.com/Gitlawb/zero/issues/568)) ([d0c4e62](https://github.com/Gitlawb/zero/commit/d0c4e62cd429a0614c15296934c740a08bc0e07b))
|
||||
* **tui:** show CLI version on the startup home screen ([#538](https://github.com/Gitlawb/zero/issues/538)) ([fd69233](https://github.com/Gitlawb/zero/commit/fd69233e334f1823a06b5794085a9255b3abdfa8))
|
||||
* voice dictation (speech-to-text) ([#557](https://github.com/Gitlawb/zero/issues/557)) ([87158a1](https://github.com/Gitlawb/zero/commit/87158a1c90b4f91fc5f2bb8178ebaf46d7654680))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* address bugs found in a multi-agent codebase audit ([#481](https://github.com/Gitlawb/zero/issues/481)) ([008bc9b](https://github.com/Gitlawb/zero/commit/008bc9b3f3ba13c7d4822b9559b020f381ff555b))
|
||||
* **agent:** keep tools exposed for max-turn finalization ([#533](https://github.com/Gitlawb/zero/issues/533)) ([3f0503b](https://github.com/Gitlawb/zero/commit/3f0503bc2312ae29d5ade784d8824dc9a3524958))
|
||||
* **auth:** persist OpenRouter API key after login ([#595](https://github.com/Gitlawb/zero/issues/595)) ([2a062aa](https://github.com/Gitlawb/zero/commit/2a062aa4014c6cd5e20a57dad4a685f88966f109))
|
||||
* bump Go to 1.26.5 for crypto/tls fix (GO-2026-5856) ([#607](https://github.com/Gitlawb/zero/issues/607)) ([a7cfb99](https://github.com/Gitlawb/zero/commit/a7cfb99fed7b88ebc09a2f251cb82864d3c2cade))
|
||||
* gitignore Windows sandbox helpers and npm version marker ([#578](https://github.com/Gitlawb/zero/issues/578)) ([25653f6](https://github.com/Gitlawb/zero/commit/25653f686c016f95b81ceb7ff5d5452d37c4d4f3))
|
||||
* **mcp:** silence startup warning for unconfigured default servers ([#563](https://github.com/Gitlawb/zero/issues/563)) ([302f58b](https://github.com/Gitlawb/zero/commit/302f58bb5f2a03ec7230354ed4747e4e55c16c50))
|
||||
* **mcp:** skip RFC 8414 discovery when OAuth endpoints are preconfigured ([#586](https://github.com/Gitlawb/zero/issues/586)) ([8a52d98](https://github.com/Gitlawb/zero/commit/8a52d98cad7cd0086dee9aede4ce477e432bd385))
|
||||
* **modelregistry:** reject oversized models.dev cache responses ([#602](https://github.com/Gitlawb/zero/issues/602)) ([66a6396](https://github.com/Gitlawb/zero/commit/66a63964149fb2f07e646e5f1987627c5cd9ac28))
|
||||
* **provider:** stop dropping custom no-auth providers on restart ([#558](https://github.com/Gitlawb/zero/issues/558)) ([ba99fa8](https://github.com/Gitlawb/zero/commit/ba99fa8d487fb28f2700e0ff10b2a25c75303cf7))
|
||||
* **sandbox:** Windows-appropriate suggestions when blocking interactive commands ([#414](https://github.com/Gitlawb/zero/issues/414)) ([ba4c007](https://github.com/Gitlawb/zero/commit/ba4c00755dc1c31b3dcca18e50b20f62c6bf5d1f))
|
||||
* **tools:** block MSYS and WSL shells under the Windows sandbox ([#587](https://github.com/Gitlawb/zero/issues/587)) ([0666818](https://github.com/Gitlawb/zero/commit/066681855b80e3baf5d07d7397610b25f724e353))
|
||||
* **tools:** platform-specific pager suggestions, quote/caret-safe cd detection ([#543](https://github.com/Gitlawb/zero/issues/543)) ([8b248f4](https://github.com/Gitlawb/zero/commit/8b248f4e1198dc86ab332a697fba4cf520823cbd))
|
||||
* **tools:** Windows cmd.exe quoting guidance and clipboard escaping fix ([#468](https://github.com/Gitlawb/zero/issues/468)) ([f10ed0c](https://github.com/Gitlawb/zero/commit/f10ed0c893ce6de08923f143d681ba96f0fcfe3a))
|
||||
* **tui:** bypass toggleSidebar and toggleMouse global shortcuts when composer is non-empty ([#576](https://github.com/Gitlawb/zero/issues/576)) ([c7346fb](https://github.com/Gitlawb/zero/commit/c7346fbcf01fe7e70ed2fcfccbf9965e5985727b))
|
||||
* **tui:** paste protection for Termux char-by-char paste ([#573](https://github.com/Gitlawb/zero/issues/573)) ([8e9149f](https://github.com/Gitlawb/zero/commit/8e9149f1a296bebebf95cae9dd2a7c5156c9dbb6))
|
||||
* **tui:** update picker_test for switchProviderModel 4-value return ([#589](https://github.com/Gitlawb/zero/issues/589)) ([8f15650](https://github.com/Gitlawb/zero/commit/8f156506dc92449bd24caa14e36f28477ba00fff))
|
||||
* **update:** clearer error on unsupported release platform (android/termux) ([#603](https://github.com/Gitlawb/zero/issues/603)) ([1fc9b2d](https://github.com/Gitlawb/zero/commit/1fc9b2d25c79e089f34cb7b5b6a7f7c7b8233123))
|
||||
* **update:** support safe symlink extraction during updates ([#575](https://github.com/Gitlawb/zero/issues/575)) ([ce9cb91](https://github.com/Gitlawb/zero/commit/ce9cb912958a3d9ae0b052bfebb849c28e0e719b))
|
||||
* warn about untracked scratch files left behind after a run ([#571](https://github.com/Gitlawb/zero/issues/571)) ([062328b](https://github.com/Gitlawb/zero/commit/062328b632a2d27353a6d47c522bbd22d7282539))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* **grep:** stop content scan after head limit ([#601](https://github.com/Gitlawb/zero/issues/601)) ([8a05e64](https://github.com/Gitlawb/zero/commit/8a05e6486f7a63281b869064db03dfc5531e6a04))
|
||||
|
||||
## [0.2.0](https://github.com/Gitlawb/zero/compare/v0.1.0...v0.2.0) (2026-07-06)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add --auto flag for LLM-generated commit messages ([#423](https://github.com/Gitlawb/zero/issues/423)) ([b0abde7](https://github.com/Gitlawb/zero/commit/b0abde7d0697e808480cd59d69a6f4d0c6320475))
|
||||
* add zero changes push and pr subcommands, and extra repo-info metrics ([#391](https://github.com/Gitlawb/zero/issues/391)) ([2312abe](https://github.com/Gitlawb/zero/commit/2312abe5ddd95f4c6ef373cfb61cc03092f48cdd))
|
||||
* agent quality, caching, retry, and tooling upgrades ([#506](https://github.com/Gitlawb/zero/issues/506)) ([3c81fea](https://github.com/Gitlawb/zero/commit/3c81fea22873ee3df7fc97b10cb4f77792706c4b))
|
||||
* **agent:** curb over-engineering the solution in the editing discipline ([#517](https://github.com/Gitlawb/zero/issues/517)) ([f4c998a](https://github.com/Gitlawb/zero/commit/f4c998ac30c4f07ff313a2d706791e857293be49)), closes [#516](https://github.com/Gitlawb/zero/issues/516)
|
||||
* **agent:** inject per-user config.UserConfigDir()/zero/ZERO.md guidelines into system prompt ([#475](https://github.com/Gitlawb/zero/issues/475)) ([7b10aab](https://github.com/Gitlawb/zero/commit/7b10aab74bf14a01166b2cea22deab79bba9850b))
|
||||
* **openai:** forward prompt_cache_key for server-side prefix cache routing ([#515](https://github.com/Gitlawb/zero/issues/515)) ([87e7e69](https://github.com/Gitlawb/zero/commit/87e7e69afd18b5539579856f3a61c6a95bc445ae))
|
||||
* **providers:** add `zero providers models` to discover a provider's models ([#386](https://github.com/Gitlawb/zero/issues/386)) ([0bc8074](https://github.com/Gitlawb/zero/commit/0bc8074c97b0310e4a9d70c3f967003ee5e8a59f))
|
||||
* **providers:** add KiloCode and OpenCode provider support ([#388](https://github.com/Gitlawb/zero/issues/388)) ([b1ccb6d](https://github.com/Gitlawb/zero/commit/b1ccb6d9c1875377f5e5ea81a1304edd1e41ab4f))
|
||||
* **providers:** add Meituan LongCat catalog preset ([#424](https://github.com/Gitlawb/zero/issues/424)) ([b4275e3](https://github.com/Gitlawb/zero/commit/b4275e350472b2490212bf814709819d354c1216))
|
||||
* **providers:** split minimax zai into intl cn ([#398](https://github.com/Gitlawb/zero/issues/398)) ([aaad4d2](https://github.com/Gitlawb/zero/commit/aaad4d271270f41af837b6f3b60ae80beba0c645))
|
||||
* require manual approval before npm publish + drop release-as pin ([#369](https://github.com/Gitlawb/zero/issues/369)) ([bd89a1f](https://github.com/Gitlawb/zero/commit/bd89a1f451643c1b65ec803070abc7b116631ebe))
|
||||
* **sandbox:** unelevated Windows fallback tier instead of prompts-only degrade ([#427](https://github.com/Gitlawb/zero/issues/427)) ([b9ddd6f](https://github.com/Gitlawb/zero/commit/b9ddd6f42138312a1fee8d8bb67c46c8eb1dea2f))
|
||||
* support shift enter for composer newlines ([#462](https://github.com/Gitlawb/zero/issues/462)) ([daf65e0](https://github.com/Gitlawb/zero/commit/daf65e0af9a040314d4ab337b0ad59c55416b7bc))
|
||||
* **tui:** /loop — repeat a prompt or command on an interval or self-paced ([#502](https://github.com/Gitlawb/zero/issues/502)) ([387fe67](https://github.com/Gitlawb/zero/commit/387fe67ee7cd81317c9c969f5906a4437080fea3))
|
||||
* **tui:** add search/filter to provider picker in setup wizard ([#400](https://github.com/Gitlawb/zero/issues/400)) ([2fcea71](https://github.com/Gitlawb/zero/commit/2fcea71778d23e050c93409c471aef45b68c1621))
|
||||
* **update:** add zero upgrade command to apply self-updates ([#461](https://github.com/Gitlawb/zero/issues/461)) ([5f36349](https://github.com/Gitlawb/zero/commit/5f36349c1884e81fa9bc66bb5fe813b627e897b7))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **action:** keep provider key scoped to zero step ([#448](https://github.com/Gitlawb/zero/issues/448)) ([407a927](https://github.com/Gitlawb/zero/commit/407a92739ff508cba32d2c12b3f36f0efcdd54c3))
|
||||
* add android platform support for Termux npm install ([#455](https://github.com/Gitlawb/zero/issues/455)) ([9bd93c6](https://github.com/Gitlawb/zero/commit/9bd93c62f8d57fb74057284aa66a1b6e1429dcdd)), closes [#449](https://github.com/Gitlawb/zero/issues/449)
|
||||
* **agent:** reject a malformed additional_permissions payload before prompting ([#453](https://github.com/Gitlawb/zero/issues/453)) ([e4f760e](https://github.com/Gitlawb/zero/commit/e4f760ee8bd57299cd2fcb37e8e23130037c2607))
|
||||
* allow non-TLS connections to private-network provider endpoints ([#444](https://github.com/Gitlawb/zero/issues/444)) ([1d86384](https://github.com/Gitlawb/zero/commit/1d8638466ca31517eb9db2b9353d3dce1cbeeabc))
|
||||
* **auth:** route zero auth login chatgpt to the dedicated ChatGPT flow ([#443](https://github.com/Gitlawb/zero/issues/443)) ([305a62c](https://github.com/Gitlawb/zero/commit/305a62c954ca6cec00bc58d5398f933415156aff))
|
||||
* **config:** fall back to a usable saved provider instead of forcing full re-onboarding ([#410](https://github.com/Gitlawb/zero/issues/410)) ([c60ad87](https://github.com/Gitlawb/zero/commit/c60ad8729f79bb841114d352ee2d2fe29d5d0e41))
|
||||
* **config:** let a gateway ANTHROPIC_BASE_URL resolve as anthropic-compatible ([#497](https://github.com/Gitlawb/zero/issues/497)) ([30dd7c3](https://github.com/Gitlawb/zero/commit/30dd7c3112ad22d42fa12b5addd4e38f4beda42a)), closes [#479](https://github.com/Gitlawb/zero/issues/479)
|
||||
* **config:** unbrick first-run setup — default google/anthropic models, enter setup on fixable config errors ([#385](https://github.com/Gitlawb/zero/issues/385)) ([72eed06](https://github.com/Gitlawb/zero/commit/72eed06b4f94c43d75d31fe54a58d2f566de059e))
|
||||
* **config:** use ~/.config on macOS and enter setup when no provider ([#371](https://github.com/Gitlawb/zero/issues/371)) ([#372](https://github.com/Gitlawb/zero/issues/372)) ([027a8f2](https://github.com/Gitlawb/zero/commit/027a8f2768b17b89f5c8270887f156e2ccda69ea))
|
||||
* **docs:** rename AGENTS.MD > AGENTS.md ([#438](https://github.com/Gitlawb/zero/issues/438)) ([4266baf](https://github.com/Gitlawb/zero/commit/4266baf222df583ed2078b776687f12d496475b5))
|
||||
* **gemini:** strip unsupported JSON Schema fields from tool declarations ([#374](https://github.com/Gitlawb/zero/issues/374)) ([39e7100](https://github.com/Gitlawb/zero/commit/39e7100674150144a1152e3110c64c7cf0321d64)), closes [#373](https://github.com/Gitlawb/zero/issues/373)
|
||||
* **install:** persist install dir to user PATH on Windows ([#407](https://github.com/Gitlawb/zero/issues/407)) ([bdb1b0e](https://github.com/Gitlawb/zero/commit/bdb1b0ecd15859b1712a6037d296dace7f9c3c3f))
|
||||
* **mcp:** block cross-origin credential redirects ([#396](https://github.com/Gitlawb/zero/issues/396)) ([f915f70](https://github.com/Gitlawb/zero/commit/f915f70e5a3096e2419fa8d961a0f84a626fa4a9))
|
||||
* **oauth:** treat Windows ERROR_ACCESS_DENIED as lock contention in createSecretFile ([#445](https://github.com/Gitlawb/zero/issues/445)) ([d05e914](https://github.com/Gitlawb/zero/commit/d05e9148a7f79f67d1d3c31fca2775f21fbd331e))
|
||||
* **openai:** handle Ollama reasoning stream deltas ([#486](https://github.com/Gitlawb/zero/issues/486)) ([f6c0606](https://github.com/Gitlawb/zero/commit/f6c060631e18e082dda24cc4dc0903c31c2120d6))
|
||||
* preserve conversation context in exec prompts ([#460](https://github.com/Gitlawb/zero/issues/460)) ([949ee43](https://github.com/Gitlawb/zero/commit/949ee43f71e5cb7fab4695c5cb7b442fe4ecfbf7))
|
||||
* **provider-wizard:** allow multiple custom OpenAI-compatible providers ([#403](https://github.com/Gitlawb/zero/issues/403)) ([3fbbd28](https://github.com/Gitlawb/zero/commit/3fbbd28e4c586822cc4312c86232d94befe56e87))
|
||||
* **sandbox:** fix nested pipe creation under the Windows restricted token ([#456](https://github.com/Gitlawb/zero/issues/456)) ([563a6db](https://github.com/Gitlawb/zero/commit/563a6dbe91e65d5daeefd7626e8a77e30a6d8fb2))
|
||||
* **sandbox:** gate /tmp test assertions on GOOS, not path existence ([#426](https://github.com/Gitlawb/zero/issues/426)) ([f653dca](https://github.com/Gitlawb/zero/commit/f653dcac363fb69ad7be5b35e6e0fa6d2bce476d))
|
||||
* **sandbox:** self-heal a corrupt unelevated setup marker ([#437](https://github.com/Gitlawb/zero/issues/437)) ([8d0c5fe](https://github.com/Gitlawb/zero/commit/8d0c5feccb8bdbfb015df0508aa6e3bcbd1fd0e8))
|
||||
* **specialist:** cap max specialist nesting depth ([#491](https://github.com/Gitlawb/zero/issues/491)) ([177442c](https://github.com/Gitlawb/zero/commit/177442cfe4015bd8df04cc9894f98b468ee796d4))
|
||||
* Termux/Android support — PRoot scroll, SIGSYS sandbox, build docs ([#509](https://github.com/Gitlawb/zero/issues/509)) ([0f69d99](https://github.com/Gitlawb/zero/commit/0f69d995e9b586b774f66c066b21abab5e03024a))
|
||||
* **tools:** Block MSYS coreutils under Windows sandbox ([#476](https://github.com/Gitlawb/zero/issues/476)) ([81aad58](https://github.com/Gitlawb/zero/commit/81aad58d97839e51c068e2f08907618991fdc3fb))
|
||||
* **tools:** CRLF line ending mismatch in edit_file tool on Windows ([#378](https://github.com/Gitlawb/zero/issues/378)) ([33dc7ae](https://github.com/Gitlawb/zero/commit/33dc7ae2cc82c5389675531e1416856dae7151ce))
|
||||
* **tools:** fix cmd.exe /S/C corrupting commands with embedded quotes ([#465](https://github.com/Gitlawb/zero/issues/465)) ([190241b](https://github.com/Gitlawb/zero/commit/190241bd593f43211b766e0b13c8e89802d4bb37))
|
||||
* **tools:** flag piped POSIX utilities before running on Windows ([#412](https://github.com/Gitlawb/zero/issues/412)) ([5658a36](https://github.com/Gitlawb/zero/commit/5658a366274fc59a9d5336b06a21019c9c25cbf1))
|
||||
* **tools:** make grep and glob respect run cancellation ([#464](https://github.com/Gitlawb/zero/issues/464)) ([ba6c026](https://github.com/Gitlawb/zero/commit/ba6c0264697b7d7ed479f6e782fba9700a481e3d))
|
||||
* **tools:** require permission before web_search requests ([#382](https://github.com/Gitlawb/zero/issues/382)) ([960db96](https://github.com/Gitlawb/zero/commit/960db9660e4e31dc588fe8f7d6f116ff5e225566))
|
||||
* **tui:** compose help overlay through the viewport overlay pipeline ([#421](https://github.com/Gitlawb/zero/issues/421)) ([5b2b4de](https://github.com/Gitlawb/zero/commit/5b2b4dea1aaf9e0f68baa25e97e83296fb17b1a2))
|
||||
* **tui:** keep the profile name on /model switch so the stored key resolves ([#441](https://github.com/Gitlawb/zero/issues/441)) ([9134148](https://github.com/Gitlawb/zero/commit/9134148f4df3e4e556fba6c2f8babfdf6fcfeee1)), closes [#440](https://github.com/Gitlawb/zero/issues/440)
|
||||
* **tui:** resolve every permission request so the agent can't deadlock ([#397](https://github.com/Gitlawb/zero/issues/397)) ([952788f](https://github.com/Gitlawb/zero/commit/952788f72d32957659fe004521fcc8372b9ba9b4))
|
||||
* **tui:** show an M suffix for million-scale token counts ([#457](https://github.com/Gitlawb/zero/issues/457)) ([0562e3b](https://github.com/Gitlawb/zero/commit/0562e3bef7df2328610a48a1e81632a8da4aec64))
|
||||
* **tui:** title /model rows by model name, not the catalog description ([#395](https://github.com/Gitlawb/zero/issues/395)) ([cdf9d83](https://github.com/Gitlawb/zero/commit/cdf9d839ae57a729f292f36f7c5b0c67b41b288d))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* cache TUI model registry ([#496](https://github.com/Gitlawb/zero/issues/496)) ([e7d88b4](https://github.com/Gitlawb/zero/commit/e7d88b4b518049733da25a8447c00144bd1da518))
|
||||
* universal tool-output ceiling with spill + async post-edit diagnostics ([#518](https://github.com/Gitlawb/zero/issues/518)) ([95ccd5b](https://github.com/Gitlawb/zero/commit/95ccd5bc327f6fb464ff0239f7229de789f578dc))
|
||||
|
||||
## 0.1.0 (2026-07-02)
|
||||
|
||||
|
||||
@@ -228,19 +22,14 @@ tagged. Until then, source builds report the version `dev`.
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- Shared multi-agent skills discovery: when present, `~/.agents/skills` is searched after the primary
|
||||
Zero skills dir (and before plugin skill roots). `zero skills list` / `info` and the runtime `skill`
|
||||
tool share one multi-root discovery path; install/remove/lock still target only the Zero skills directory.
|
||||
- `SECURITY.md` with a private vulnerability-reporting path, `CODE_OF_CONDUCT.md`, this changelog, and
|
||||
GitHub issue/PR templates.
|
||||
- Interactive `/theme` picker: bare `/theme` opens a popup that live-previews each palette as you move
|
||||
and applies on select (Esc reverts).
|
||||
- Twelve built-in color themes alongside the `dark`/`light` built-ins — `dracula`, `nord`, `gruvbox`,
|
||||
`tokyo-night`, `catppuccin`, `one-dark`, `solarized-dark`, `rose-pine`, `everforest`,
|
||||
`solarized-light`, `dune`, and `neon` — selectable via `/theme <name>`, `--theme <name>`, or
|
||||
`ZERO_THEME`. Every palette is contrast-audited to WCAG AA, and the new presets are additionally
|
||||
audited after xterm-256 downsampling; see [docs/THEMES.md](docs/THEMES.md). The built-in light
|
||||
theme was reworked for legibility.
|
||||
- Ten built-in color themes alongside the `dark`/`light` built-ins — `dracula`, `nord`, `gruvbox`,
|
||||
`tokyo-night`, `catppuccin`, `one-dark`, `solarized-dark`, `rose-pine`, `everforest`, and
|
||||
`solarized-light` — selectable via `/theme <name>`, `--theme <name>`, or `ZERO_THEME`. Every palette
|
||||
is contrast-audited to WCAG AA. The built-in light theme was reworked for legibility.
|
||||
- `--theme <name>` flag for the TUI, accepting `auto` or any registered theme (previously only the
|
||||
`ZERO_THEME` env var existed).
|
||||
- "Accessibility / Appearance" section in the README documenting `NO_COLOR`, `ZERO_THEME`, `/theme`,
|
||||
|
||||
+5
-48
@@ -8,13 +8,6 @@ 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.
|
||||
|
||||
@@ -40,11 +33,7 @@ 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. 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.
|
||||
2. Open a feature request describing the problem, use case, and expected behavior.
|
||||
3. Add useful context to an existing issue, such as logs, screenshots, examples,
|
||||
affected versions, or reproduction cases.
|
||||
|
||||
@@ -57,8 +46,7 @@ All community pull requests require an approved parent issue.
|
||||
|
||||
Before starting implementation work:
|
||||
|
||||
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.
|
||||
1. Open an issue using the appropriate issue template.
|
||||
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.
|
||||
@@ -67,28 +55,6 @@ An approved issue means only that the specific issue has been accepted for work.
|
||||
It does not approve unrelated future work, future pull requests, or future issues
|
||||
from the same author. Each issue is reviewed case by case.
|
||||
|
||||
## Contribution Volume and Batching
|
||||
|
||||
To keep triage and review sustainable while the project stabilizes, please keep
|
||||
your contribution flow to a size the core team can realistically work through:
|
||||
|
||||
- One issue per bug. File each defect as its own issue with its own
|
||||
reproduction. Please do not bundle several unrelated findings into a single
|
||||
"audit" issue; separate issues are far easier to triage, approve, and close
|
||||
individually.
|
||||
- Keep only a few items open at a time. If you already have several issues or
|
||||
pull requests awaiting review, let those move through the queue before opening
|
||||
more. A large simultaneous burst from one contributor slows review for
|
||||
everyone, even when each item is valid.
|
||||
- AI-assisted contributions are welcome, held to the same bar. Automated audits
|
||||
and generated patches are fine, but each issue and pull request must be
|
||||
individually verified, scoped, and reproducible before you open it. Please do
|
||||
not post bulk machine-generated findings unfiltered.
|
||||
|
||||
These apply to all contributors and are about review bandwidth, not the value of
|
||||
any single report. Maintainers may ask you to consolidate, split, or hold
|
||||
submissions to keep the queue manageable.
|
||||
|
||||
## Pull Request Requirements
|
||||
|
||||
Before opening a community pull request, make sure all of the following are true:
|
||||
@@ -167,12 +133,7 @@ Clear bug reports are the most useful way to help the project during this phase.
|
||||
|
||||
## Submitting a Feature Request
|
||||
|
||||
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:
|
||||
When filing a feature request, please include:
|
||||
|
||||
- The problem you are trying to solve.
|
||||
- The behavior or functionality you are requesting.
|
||||
@@ -195,9 +156,5 @@ contribution phase.
|
||||
|
||||
## Getting Help
|
||||
|
||||
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.
|
||||
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.
|
||||
@@ -1,7 +1,7 @@
|
||||
# Zero build/test/lint targets. AGENTS.md says "Build with `make`" and "Run `make
|
||||
# lint` before opening a PR" — these targets back those instructions.
|
||||
.DEFAULT_GOAL := build
|
||||
.PHONY: build build-all test test-race vet fmt fmt-check lint tidy clean baseline help
|
||||
.PHONY: build build-all test test-race vet fmt fmt-check lint tidy clean help
|
||||
|
||||
# Build the main CLI binary into ./zero.
|
||||
build:
|
||||
@@ -40,19 +40,5 @@ clean:
|
||||
rm -f zero
|
||||
go clean ./...
|
||||
|
||||
# Run the per-turn benchmark harness over the checked-in baseline manifest and
|
||||
# write the JSON result to internal/perfbench/reports/baseline.json. Requires a
|
||||
# built `zero` binary and a model; set ZERO_BENCH_MODEL (required) and
|
||||
# ZERO_BENCH_BINARY (defaults to ./zero) to configure the run. The report is
|
||||
# machine-specific and regenerated, not hand-edited.
|
||||
baseline: build
|
||||
@if [ -z "$(ZERO_BENCH_MODEL)" ]; then echo "Set ZERO_BENCH_MODEL (and optionally ZERO_BENCH_BINARY) before running 'make baseline'"; exit 2; fi
|
||||
@ZERO_BIN="$${ZERO_BENCH_BINARY:-./zero}"; \
|
||||
go run ./cmd/zero-perf-bench turn \
|
||||
--suite internal/perfbench/manifests/baseline.json \
|
||||
--model $(ZERO_BENCH_MODEL) \
|
||||
--binary "$$ZERO_BIN" \
|
||||
--output internal/perfbench/reports/baseline.json
|
||||
|
||||
help:
|
||||
@echo "Targets: build (default), build-all, test, test-quick, vet, fmt, fmt-check, lint, tidy, clean, baseline"
|
||||
@echo "Targets: build (default), build-all, test, test-quick, vet, fmt, fmt-check, lint, tidy, clean"
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a>
|
||||
<img alt="Go 1.26.5+" src="https://img.shields.io/badge/Go-1.26.5+-00ADD8?logo=go&logoColor=white">
|
||||
<img alt="Go 1.25+" src="https://img.shields.io/badge/Go-1.25+-00ADD8?logo=go&logoColor=white">
|
||||
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
|
||||
<br>
|
||||
<strong>English</strong> | <a href="README_ZH.md">中文</a>
|
||||
@@ -49,15 +49,34 @@ npm install -g @gitlawb/zero
|
||||
zero
|
||||
```
|
||||
|
||||
The npm package is a small wrapper whose platform build (Linux and macOS on
|
||||
x64/arm64, Windows on x64 — including the browser/terminal control helpers)
|
||||
installs as an optional dependency straight from the npm registry — no install
|
||||
scripts, no downloads outside npm. Bun, pnpm, and yarn work the same way with
|
||||
no trust or approval steps. Installs that skip optional dependencies
|
||||
(`--omit=optional`) still work: the wrapper fetches the binary from the
|
||||
matching GitHub Release whenever it is missing. Windows on ARM runs the x64
|
||||
build under emulation. See [docs/NPM_PACKAGING.md](docs/NPM_PACKAGING.md) for
|
||||
how the package is put together.
|
||||
The npm package installs a small wrapper plus the matching Zero binary for your
|
||||
platform from GitHub Releases. It supports Linux, macOS, and Windows on x64 and
|
||||
arm64.
|
||||
|
||||
### Bun
|
||||
|
||||
Bun does not run dependency lifecycle scripts by default, so the `postinstall`
|
||||
that fetches the Zero binary is skipped and the first run fails with
|
||||
`No native binary found next to the npm wrapper`.
|
||||
|
||||
The simplest fix is to trust the package after installing, which runs the
|
||||
blocked postinstall. This works for project and global installs:
|
||||
|
||||
```bash
|
||||
# project install
|
||||
bun add @gitlawb/zero
|
||||
bun pm trust @gitlawb/zero
|
||||
|
||||
# global install
|
||||
bun add -g @gitlawb/zero
|
||||
bun pm -g trust @gitlawb/zero
|
||||
```
|
||||
|
||||
Alternatives: allow the postinstall up front by adding
|
||||
`"trustedDependencies": ["@gitlawb/zero"]` to your project's package.json
|
||||
before `bun add`, or run the installer manually
|
||||
(`node node_modules/@gitlawb/zero/scripts/postinstall.mjs`) on Bun versions
|
||||
that do not have `bun pm trust`.
|
||||
|
||||
### Install scripts
|
||||
|
||||
@@ -75,7 +94,7 @@ irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | ie
|
||||
|
||||
### From source
|
||||
|
||||
Source builds require Go 1.26.5+.
|
||||
Source builds require Go 1.25+.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Gitlawb/zero.git
|
||||
@@ -129,16 +148,7 @@ the key in the wizard:
|
||||
export OPENAI_API_KEY=sk-...
|
||||
export ANTHROPIC_API_KEY=...
|
||||
export GEMINI_API_KEY=...
|
||||
export AIMLAPI_API_KEY=...
|
||||
export LONGCAT_API_KEY=...
|
||||
export MINIMAX_API_KEY=...
|
||||
export MINIMAXI_API_KEY=...
|
||||
```
|
||||
|
||||
To configure AI/ML API directly, run:
|
||||
|
||||
```bash
|
||||
zero providers setup aimlapi --set-active
|
||||
```
|
||||
|
||||
To configure Meituan LongCat (LongCat-2.0) directly, run:
|
||||
@@ -147,33 +157,6 @@ To configure Meituan LongCat (LongCat-2.0) directly, run:
|
||||
zero providers setup longcat --set-active
|
||||
```
|
||||
|
||||
MiniMax presets use the Anthropic-compatible endpoints for the global and China
|
||||
regions:
|
||||
|
||||
```bash
|
||||
zero providers add minimax --set-active
|
||||
zero providers add minimaxi-cn --set-active
|
||||
```
|
||||
|
||||
To use the OpenAI-compatible endpoints instead, add a custom compatible profile
|
||||
for the required region:
|
||||
|
||||
```bash
|
||||
zero providers add custom-openai-compatible \
|
||||
--name minimax-openai \
|
||||
--model MiniMax-M3 \
|
||||
--base-url https://api.minimax.io/v1 \
|
||||
--api-key-env MINIMAX_API_KEY \
|
||||
--set-active
|
||||
|
||||
zero providers add custom-openai-compatible \
|
||||
--name minimax-cn-openai \
|
||||
--model MiniMax-M3 \
|
||||
--base-url https://api.minimaxi.com/v1 \
|
||||
--api-key-env MINIMAXI_API_KEY \
|
||||
--set-active
|
||||
```
|
||||
|
||||
For local models, run Ollama or LM Studio and then use `zero setup` or
|
||||
`zero providers detect`.
|
||||
|
||||
@@ -191,11 +174,9 @@ Useful controls:
|
||||
|---|---|
|
||||
| `Enter` | send the prompt |
|
||||
| `/` | open slash-command suggestions |
|
||||
| `Ctrl+X` then letter | common slash commands (e.g. `m` → `/model`; `Ctrl+X` `?` for full list) |
|
||||
| `Ctrl+P` / `Ctrl+N` | previous / next item in menus (arrows still work) |
|
||||
| `Shift+Tab` | cycle permission mode |
|
||||
| `Ctrl+B` | show/hide the sidebar |
|
||||
| `Ctrl+C` | cancel, exit, or return from a `/btw` conversation |
|
||||
| `Ctrl+C` | cancel or exit |
|
||||
|
||||
Common slash commands:
|
||||
|
||||
@@ -205,8 +186,6 @@ Common slash commands:
|
||||
| `/spec`, `/plan` | draft and review a plan before building |
|
||||
| `/image` | attach an image for vision-capable models |
|
||||
| `/resume`, `/rewind` | continue or roll back local sessions |
|
||||
| `/btw [question]` | ask in an isolated fork without adding the side conversation to the main session |
|
||||
| `/loop` | repeat a prompt or custom `/command` on an interval (`/loop 5m /babysit-prs`) or self-paced |
|
||||
| `/compact`, `/context` | manage context usage |
|
||||
| `/permissions`, `/tools` | inspect available tools and policy |
|
||||
| `/add-dir` | allow an extra write directory for this session |
|
||||
@@ -304,45 +283,12 @@ zero cron scheduled agent jobs
|
||||
zero update check for newer releases
|
||||
```
|
||||
|
||||
## Extending Zero
|
||||
|
||||
### Project and personal instructions
|
||||
|
||||
Zero appends project-specific guidance to the system prompt from the first
|
||||
`AGENTS.md`, `ZERO.md`, or `.zero/AGENTS.md` file found in each directory from
|
||||
the git root down to your current working directory (checked in that order
|
||||
per directory). Files are injected general-to-specific, capped at 8 KiB per
|
||||
file and 32 KiB total.
|
||||
|
||||
A personal `ZERO.md` under `config.UserConfigDir()/zero/ZERO.md`
|
||||
(`$XDG_CONFIG_HOME/zero/ZERO.md` or `~/.config/zero/ZERO.md` on Linux/macOS,
|
||||
`%AppData%\Roaming\zero\ZERO.md` on Windows) applies across every workspace, ahead of any project guidelines.
|
||||
|
||||
### Plugins
|
||||
|
||||
Plugins are discovered from `~/.config/zero/plugins/<name>/plugin.json` (user
|
||||
scope — `$XDG_CONFIG_HOME` or `~/.config` on every OS, independent of the
|
||||
`config.UserConfigDir()` path used above) and `<cwd>/.zero/plugins/<name>/plugin.json`
|
||||
(project scope — resolved from the current working directory, not the repo
|
||||
root), and managed with `zero plugins`. A manifest can declare:
|
||||
|
||||
- `tools` — custom tools (`command`, `args`, `inputSchema`, and a
|
||||
`permission` of `prompt` or `deny`; `allow` is honored only when manifest tool
|
||||
auto-approval is enabled)
|
||||
- `hooks` — commands run on `beforeTool`, `afterTool`, `sessionStart`, or
|
||||
`sessionEnd`
|
||||
- `prompts` and `skills` — additional prompt/skill files
|
||||
|
||||
MCP servers (`zero mcp`) and standalone markdown skills (`zero skills`) use
|
||||
the same extension points and can also be wired up outside of a plugin
|
||||
manifest.
|
||||
|
||||
## Appearance And Accessibility
|
||||
|
||||
| Control | Effect |
|
||||
|---|---|
|
||||
| `NO_COLOR=<anything>` | disables color output |
|
||||
| `ZERO_THEME=<name>` | selects the startup theme (`auto`, `dark`, `light`, or a color theme like `dracula`, `nord`, `gruvbox`, `tokyo-night`, `catppuccin`, `one-dark`, `solarized-dark`, `rose-pine`, `everforest`, `neon`, `solarized-light`, `dune`) |
|
||||
| `ZERO_THEME=<name>` | selects the startup theme (`auto`, `dark`, `light`, or a color theme like `dracula`, `nord`, `gruvbox`, `tokyo-night`, `catppuccin`, `one-dark`, `solarized-dark`, `rose-pine`, `everforest`, `solarized-light`) |
|
||||
| `--theme <name>` | selects the TUI theme from the CLI (same names) |
|
||||
| `/theme` | opens the theme picker inside the TUI (live preview; `/theme <name>` switches directly) |
|
||||
| `ZERO_NO_FADE=1` | disables streaming fade animation |
|
||||
@@ -359,41 +305,7 @@ 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:
|
||||
|
||||
1. **Formatting**: Run `go fmt ./...` (or `make fmt`).
|
||||
2. **Vetting**: Run `go vet ./...` (or `make vet`).
|
||||
3. **Linting**: Run `go run github.com/golangci/golangci-lint/v2/cmd/[email protected] run --enable-only unused,ineffassign,staticcheck ./...`.
|
||||
4. **Vulnerability Scan**: Run `go run golang.org/x/vuln/cmd/[email protected] ./...`.
|
||||
|
||||
If you prefer to install these tools globally on your path, you can run:
|
||||
|
||||
```bash
|
||||
# Install golangci-lint
|
||||
go install github.com/golangci/golangci-lint/v2/cmd/[email protected]
|
||||
|
||||
# Install govulncheck
|
||||
go install golang.org/x/vuln/cmd/[email protected]
|
||||
```
|
||||
|
||||
The installed binaries land in `$GOBIN` when it is set, otherwise in
|
||||
`$GOPATH/bin` (default `~/go/bin`). That directory must be on your `PATH` to
|
||||
run them directly. If it isn't, add it:
|
||||
|
||||
```bash
|
||||
gobin="$(go env GOBIN)"; [ -z "$gobin" ] && gobin="$(go env GOPATH)/bin"
|
||||
case ":$PATH:" in *":$gobin:"*) ;; *) export PATH="$PATH:$gobin" ;; esac
|
||||
```
|
||||
|
||||
### Cross-Compile Examples
|
||||
Cross-compile examples:
|
||||
|
||||
```bash
|
||||
go run ./cmd/zero-release build --goos linux --goarch amd64
|
||||
@@ -404,7 +316,6 @@ go run ./cmd/zero-release build --goos windows --goarch amd64 --output dist/zero
|
||||
|
||||
- [Install](docs/INSTALL.md)
|
||||
- [Update flow](docs/UPDATE.md)
|
||||
- [Themes](docs/THEMES.md)
|
||||
- [Stream-JSON protocol](docs/STREAM_JSON_PROTOCOL.md)
|
||||
- [Specialists](docs/SPECIALISTS.md)
|
||||
- [GitHub Action](docs/GITHUB_ACTION.md)
|
||||
@@ -412,24 +323,6 @@ 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
|
||||
|
||||
+22
-5
@@ -6,7 +6,7 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a>
|
||||
<img alt="Go 1.26.5+" src="https://img.shields.io/badge/Go-1.26.5+-00ADD8?logo=go&logoColor=white">
|
||||
<img alt="Go 1.25+" src="https://img.shields.io/badge/Go-1.25+-00ADD8?logo=go&logoColor=white">
|
||||
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
|
||||
<br>
|
||||
<a href="README.md">English</a> | <strong>中文</strong>
|
||||
@@ -38,7 +38,25 @@ npm install -g @gitlawb/zero
|
||||
zero
|
||||
```
|
||||
|
||||
npm 包是一个小型包装器,其平台构建(Linux 和 macOS 的 x64/arm64、Windows 的 x64,包含浏览器/终端控制辅助工具)作为可选依赖直接从 npm registry 安装——没有安装脚本,也不会从 npm 之外下载任何内容。Bun、pnpm 和 yarn 的行为完全一致,无需任何信任或批准步骤。跳过可选依赖的安装(`--omit=optional`)也能工作:只要二进制文件缺失,包装器就会从对应的 GitHub Release 下载。Windows on ARM 通过模拟运行 x64 构建。详见 [docs/NPM_PACKAGING.md](docs/NPM_PACKAGING.md)。
|
||||
npm 包安装一个小型包装器以及与你平台匹配的 Zero 二进制文件(从 GitHub Releases 获取)。支持 Linux、macOS 和 Windows 的 x64 和 arm64。
|
||||
|
||||
### Bun
|
||||
|
||||
Bun 默认不运行依赖的生命周期脚本,因此获取 Zero 二进制文件的 `postinstall` 会被跳过,首次运行会失败并显示 `No native binary found next to the npm wrapper`。
|
||||
|
||||
最简单的解决方法是安装后信任该包,这会运行被阻止的 postinstall。项目安装和全局安装均适用:
|
||||
|
||||
```bash
|
||||
# 项目安装
|
||||
bun add @gitlawb/zero
|
||||
bun pm trust @gitlawb/zero
|
||||
|
||||
# 全局安装
|
||||
bun add -g @gitlawb/zero
|
||||
bun pm -g trust @gitlawb/zero
|
||||
```
|
||||
|
||||
其他方式:在 `bun add` 之前将 `"trustedDependencies": ["@gitlawb/zero"]` 添加到项目的 package.json 以提前允许 postinstall;或在不支持 `bun pm trust` 的旧版 Bun 上手动运行安装程序(`node node_modules/@gitlawb/zero/scripts/postinstall.mjs`)。
|
||||
|
||||
### 安装脚本
|
||||
|
||||
@@ -56,7 +74,7 @@ irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | ie
|
||||
|
||||
### 从源码构建
|
||||
|
||||
源码构建需要 Go 1.26.5+。
|
||||
源码构建需要 Go 1.25+。
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Gitlawb/zero.git
|
||||
@@ -234,7 +252,7 @@ zero update 检查更新版本
|
||||
| 控制 | 效果 |
|
||||
|---|---|
|
||||
| `NO_COLOR=<任意值>` | 禁用颜色输出 |
|
||||
| `ZERO_THEME=<名称>` | 选择启动主题(`auto`、`dark`、`light`,或颜色主题如 `dracula`、`nord`、`gruvbox`、`tokyo-night`、`catppuccin`、`one-dark`、`solarized-dark`、`rose-pine`、`everforest`、`neon`、`solarized-light`、`dune`) |
|
||||
| `ZERO_THEME=<名称>` | 选择启动主题(`auto`、`dark`、`light`,或颜色主题如 `dracula`、`nord`、`gruvbox`、`tokyo-night`、`catppuccin`、`one-dark`、`solarized-dark`、`rose-pine`、`everforest`、`solarized-light`) |
|
||||
| `--theme <名称>` | 从 CLI 选择 TUI 主题(相同名称) |
|
||||
| `/theme` | 在 TUI 中打开主题选择器(实时预览;`/theme <名称>` 直接切换) |
|
||||
| `ZERO_NO_FADE=1` | 禁用流式淡入动画 |
|
||||
@@ -261,7 +279,6 @@ go run ./cmd/zero-release build --goos windows --goarch amd64 --output dist/zero
|
||||
|
||||
- [安装](docs/INSTALL.md)
|
||||
- [更新流程](docs/UPDATE.md)
|
||||
- [主题](docs/THEMES.md)
|
||||
- [Stream-JSON 协议](docs/STREAM_JSON_PROTOCOL.md)
|
||||
- [专家](docs/SPECIALISTS.md)
|
||||
- [GitHub Action](docs/GITHUB_ACTION.md)
|
||||
|
||||
-36
@@ -1,36 +0,0 @@
|
||||
# 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).
|
||||
+4
-4
@@ -180,7 +180,6 @@ runs:
|
||||
ZERO_INPUT_ADD_DIR: ${{ inputs.add-dir }}
|
||||
ZERO_INPUT_WORKTREE: ${{ inputs.worktree }}
|
||||
ZERO_INPUT_OUTPUT_FORMAT: ${{ inputs.output-format }}
|
||||
ACTION_PATH: ${{ github.action_path }}
|
||||
RUNNER_TEMP: ${{ runner.temp }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -248,9 +247,10 @@ runs:
|
||||
echo "exit-code=${code}" >> "${GITHUB_OUTPUT}"
|
||||
echo "output-file=${output_file}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
# Best-effort one-line summary. Structured output ends with lifecycle
|
||||
# metadata, so parse final/error events instead of tailing stdout.
|
||||
summary="$(node "${ACTION_PATH}/scripts/action-summary.mjs" "${ZERO_INPUT_OUTPUT_FORMAT}" "${output_file}" || true)"
|
||||
# Best-effort one-line summary: last non-empty line of the captured output.
|
||||
summary="$(grep -v '^[[:space:]]*$' "${output_file}" | tail -n 1 || true)"
|
||||
# Keep the summary single-line and bounded for the step output.
|
||||
summary="$(printf '%s' "${summary}" | cut -c1-280)"
|
||||
{
|
||||
echo "summary<<ZERO_EOF"
|
||||
printf '%s\n' "${summary}"
|
||||
|
||||
+25
-222
@@ -2,7 +2,6 @@
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
@@ -40,11 +39,6 @@ function resolveHelper(packageRoot, name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Legacy path: helpers installed as npm dependencies of the wrapper. Current
|
||||
// installs vendor the helpers inside the platform package instead (a helpers/
|
||||
// directory next to the binary, found by the Go side on its own), so this
|
||||
// manifest is usually empty — it only matters for a downloaded-binary install
|
||||
// whose wrapper still carries helper dependencies.
|
||||
function localControlHelperManifest(packageRoot) {
|
||||
const helpers = {};
|
||||
for (const name of ['agent-browser', 'tuistory']) {
|
||||
@@ -55,229 +49,38 @@ 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
|
||||
// resolve and we fall through to the downloader.
|
||||
function platformPackageBinary() {
|
||||
const aliasedName = `@gitlawb/zero-${process.platform}-${process.arch}`;
|
||||
let manifestPath;
|
||||
try {
|
||||
manifestPath = createRequire(import.meta.url).resolve(`${aliasedName}/package.json`);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const candidate = join(dirname(manifestPath), zeroBinaryName());
|
||||
return existsSync(candidate) ? candidate : null;
|
||||
}
|
||||
|
||||
// Mirrors the release matrix (internal/release/release.go): the platforms a
|
||||
// GitHub Release asset exists for. android maps to the linux asset; there is
|
||||
// no windows-arm64 asset (Windows on ARM runs the x64 build under emulation).
|
||||
function releaseAssetAvailable() {
|
||||
const platform = { linux: 'linux', android: 'linux', darwin: 'macos', win32: 'windows' }[
|
||||
process.platform
|
||||
];
|
||||
const arch = process.arch === 'x64' || process.arch === 'arm64' ? process.arch : null;
|
||||
if (!platform || !arch) return false;
|
||||
return !(platform === 'windows' && arch === 'arm64');
|
||||
}
|
||||
|
||||
// Fallback for installs without a platform package (--omit=optional, package
|
||||
// managers that skip optionalDependencies): fetch the binary next to the
|
||||
// wrapper with the same checksum-verified downloader that used to run as
|
||||
// postinstall. Deliberately NOT cached on failure — a transient network error
|
||||
// self-heals on the next run — so this retries on every invocation until a
|
||||
// binary is in place. Platforms with no release asset skip the attempt.
|
||||
function downloadMissingBinary(packageRoot) {
|
||||
const downloadScript = join(packageRoot, 'scripts', 'postinstall.mjs');
|
||||
if (!existsSync(downloadScript) || !releaseAssetAvailable()) return;
|
||||
console.error(
|
||||
'[zero] no platform package installed — fetching the native binary from the GitHub Release (retried on each run until it succeeds).',
|
||||
);
|
||||
spawnSync(process.execPath, [downloadScript], {
|
||||
stdio: ['ignore', 'inherit', 'inherit'],
|
||||
});
|
||||
}
|
||||
|
||||
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
const nativePath = join(packageRoot, zeroBinaryName());
|
||||
const localControlHelpers = localControlHelperManifest(packageRoot);
|
||||
|
||||
function resolveNativeBinary() {
|
||||
const fromPlatformPackage = platformPackageBinary();
|
||||
if (fromPlatformPackage) return fromPlatformPackage;
|
||||
const downloaded = join(packageRoot, zeroBinaryName());
|
||||
if (existsSync(downloaded)) return downloaded;
|
||||
downloadMissingBinary(packageRoot);
|
||||
return existsSync(downloaded) ? downloaded : null;
|
||||
}
|
||||
|
||||
const nativePath = resolveNativeBinary();
|
||||
|
||||
if (!nativePath) {
|
||||
if (!existsSync(nativePath)) {
|
||||
const postinstallScript = join(packageRoot, 'scripts', 'postinstall.mjs');
|
||||
|
||||
// `zero doctor` is the diagnostic command: when the native binary is missing
|
||||
// it's the one invocation that must NOT bail with the generic wrapper error,
|
||||
// because that's exactly the blind alley issue #405 reports. Instead, surface
|
||||
// a doctor-shaped FAIL line for the runtime so the user's diagnostic finds the
|
||||
// real cause. We branch on a literal 'doctor' subcommand only (matching `zero
|
||||
// doctor` and `zero doctor --connectivity`), preserving the existing bail for
|
||||
// every other invocation (exec, providers list, TUI, --version, etc.).
|
||||
const argv = process.argv.slice(2);
|
||||
const isDoctor = argv.length > 0 && argv[0] === 'doctor';
|
||||
if (isDoctor) {
|
||||
const parsed = parseDoctorArgs(argv.slice(1));
|
||||
if (parsed.kind === 'help') {
|
||||
process.stdout.write(DOCTOR_HELP);
|
||||
process.exit(0);
|
||||
}
|
||||
if (parsed.kind === 'error') {
|
||||
process.stderr.write(`[zero] ${parsed.message}\n`);
|
||||
process.exit(EXIT_USAGE);
|
||||
}
|
||||
|
||||
if (parsed.json) {
|
||||
process.stdout.write(JSON.stringify(missingNativeDoctorJSONReport(postinstallScript), null, 2) + '\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
process.stdout.write(missingNativeDoctorTextReport(postinstallScript) + '\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.error(formatGenericMissingNativeBinaryMessage(postinstallScript));
|
||||
const ranByBun = process.execPath.includes('bun') || !!process.versions?.bun;
|
||||
console.error(
|
||||
'[zero] No native binary found next to the npm wrapper.\n' +
|
||||
'The platform binary is fetched at install time by a postinstall script,\n' +
|
||||
'which did not run (or was skipped) for this install.\n' +
|
||||
'\n' +
|
||||
'Fix it now by running the installer manually:\n' +
|
||||
` node "${postinstallScript}"\n` +
|
||||
'\n' +
|
||||
(ranByBun
|
||||
? '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'
|
||||
: '') +
|
||||
'If that fails, build from source: https://github.com/Gitlawb/zero\n' +
|
||||
'(go run ./cmd/zero, requires Go 1.25+).',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const env = { ...process.env };
|
||||
const localControlHelpers = localControlHelperManifest(packageRoot);
|
||||
if (localControlHelpers) {
|
||||
env.ZERO_LOCAL_CONTROL_HELPERS = localControlHelpers;
|
||||
} else {
|
||||
|
||||
@@ -39,9 +39,6 @@ func run(args []string, getenv func(string) string, stdout io.Writer, stderr io.
|
||||
if len(args) > 0 && args[0] == "tasks" {
|
||||
return runTasksCommand(args[1:], getenv, stdout, stderr)
|
||||
}
|
||||
if len(args) > 0 && args[0] == "turn" {
|
||||
return runTurnCommand(args[1:], getenv, stdout, stderr)
|
||||
}
|
||||
options, err := parseArgs(args, getenv)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(stderr, err.Error())
|
||||
@@ -209,7 +206,6 @@ func helpText() string {
|
||||
return strings.Join([]string{
|
||||
"Usage: zero-perf-bench [options]",
|
||||
" zero-perf-bench tasks [options] (Terminal-Bench-style task harness; see `tasks --help`)",
|
||||
" zero-perf-bench turn [options] (per-turn tracing benchmark; see `turn --help`)",
|
||||
"",
|
||||
"Options:",
|
||||
" --iterations <n> Measured samples to collect (default: 5)",
|
||||
|
||||
@@ -4,8 +4,6 @@ import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/perfbench"
|
||||
)
|
||||
|
||||
func TestParseArgsUsesCliAndEnvOverrides(t *testing.T) {
|
||||
@@ -94,18 +92,3 @@ 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)
|
||||
}
|
||||
}
|
||||
@@ -1,279 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/execprofile"
|
||||
"github.com/Gitlawb/zero/internal/perfbench"
|
||||
)
|
||||
|
||||
// turnOptions configures the `zero-perf-bench turn` subcommand: the per-turn
|
||||
// benchmark harness that runs ZERO headlessly with --trace, parses each turn's
|
||||
// NDJSON trace, and records per-span latency plus the top controllable latency
|
||||
// sources — the Phase 0 baseline's "do not proceed until" criterion.
|
||||
type turnOptions struct {
|
||||
SuitePath string
|
||||
Model string
|
||||
Mode string
|
||||
ExecProfile string
|
||||
SelfCorrect bool
|
||||
Binary string
|
||||
Iterations int
|
||||
Version string
|
||||
Commit string
|
||||
Output string
|
||||
JSON bool
|
||||
DryRun bool
|
||||
Help bool
|
||||
}
|
||||
|
||||
func runTurnCommand(args []string, getenv func(string) string, stdout io.Writer, stderr io.Writer) int {
|
||||
options, err := parseTurnArgs(args, getenv)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(stderr, err.Error())
|
||||
return 2
|
||||
}
|
||||
if options.Help {
|
||||
_, _ = fmt.Fprint(stdout, turnHelpText())
|
||||
return 0
|
||||
}
|
||||
|
||||
set, err := perfbench.LoadTaskSet(options.SuitePath)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(stderr, "[zero] Turn benchmark failed: "+err.Error())
|
||||
return 1
|
||||
}
|
||||
|
||||
// The dry-run path records a zero-iteration run without a binary, so the
|
||||
// manifest loads and the report path is exercised in CI without a model.
|
||||
if options.DryRun {
|
||||
_, _ = fmt.Fprintln(stdout, "[zero] turn benchmark: dry run (no agent invoked)")
|
||||
return 0
|
||||
}
|
||||
|
||||
binary, err := perfbench.ResolveBinary(options.Binary)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(stderr, "[zero] Turn benchmark failed: "+err.Error())
|
||||
return 2
|
||||
}
|
||||
|
||||
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,
|
||||
Iterations: options.Iterations,
|
||||
Runner: perfbench.NewTurnExecRunner(binary),
|
||||
})
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(stderr, "[zero] Turn benchmark failed: "+err.Error())
|
||||
return 1
|
||||
}
|
||||
|
||||
if options.Output != "" {
|
||||
if err := writeTurnReport(options.Output, result); err != nil {
|
||||
_, _ = fmt.Fprintln(stderr, "[zero] Turn benchmark failed: "+err.Error())
|
||||
return 1
|
||||
}
|
||||
}
|
||||
if options.JSON {
|
||||
if err := perfbench.WriteTurnBenchJSON(stdout, result); err != nil {
|
||||
_, _ = fmt.Fprintln(stderr, "[zero] Turn benchmark failed: "+err.Error())
|
||||
return 1
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
func parseTurnArgs(args []string, getenv func(string) string) (turnOptions, error) {
|
||||
options := turnOptions{
|
||||
Iterations: 1,
|
||||
Version: strings.TrimSpace(getenv("ZERO_BENCH_VERSION")),
|
||||
Commit: strings.TrimSpace(getenv("ZERO_BENCH_COMMIT")),
|
||||
}
|
||||
for index := 0; index < len(args); index++ {
|
||||
arg := args[index]
|
||||
flag, inlineValue := splitFlagValue(arg)
|
||||
switch flag {
|
||||
case "--suite":
|
||||
value, next, err := readOptionValue(args, inlineValue, index, flag)
|
||||
if err != nil {
|
||||
return options, err
|
||||
}
|
||||
options.SuitePath = value
|
||||
index = next
|
||||
case "--model":
|
||||
value, next, err := readOptionValue(args, inlineValue, index, flag)
|
||||
if err != nil {
|
||||
return options, err
|
||||
}
|
||||
options.Model = value
|
||||
index = next
|
||||
case "--mode":
|
||||
value, next, err := readOptionValue(args, inlineValue, index, flag)
|
||||
if err != nil {
|
||||
return options, err
|
||||
}
|
||||
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 {
|
||||
return options, err
|
||||
}
|
||||
options.Binary = value
|
||||
index = next
|
||||
case "--iterations":
|
||||
value, next, err := readOptionValue(args, inlineValue, index, flag)
|
||||
if err != nil {
|
||||
return options, err
|
||||
}
|
||||
parsed, err := parsePositiveInteger(flag, value)
|
||||
if err != nil {
|
||||
return options, err
|
||||
}
|
||||
options.Iterations = parsed
|
||||
index = next
|
||||
case "--version":
|
||||
value, next, err := readOptionValue(args, inlineValue, index, flag)
|
||||
if err != nil {
|
||||
return options, err
|
||||
}
|
||||
options.Version = value
|
||||
index = next
|
||||
case "--commit":
|
||||
value, next, err := readOptionValue(args, inlineValue, index, flag)
|
||||
if err != nil {
|
||||
return options, err
|
||||
}
|
||||
options.Commit = value
|
||||
index = next
|
||||
case "--output":
|
||||
value, next, err := readOptionValue(args, inlineValue, index, flag)
|
||||
if err != nil {
|
||||
return options, err
|
||||
}
|
||||
options.Output = value
|
||||
index = next
|
||||
case "--self-correct":
|
||||
if strings.Contains(arg, "=") {
|
||||
return options, fmt.Errorf("%s does not accept a value", flag)
|
||||
}
|
||||
options.SelfCorrect = true
|
||||
case "--json":
|
||||
if strings.Contains(arg, "=") {
|
||||
return options, fmt.Errorf("%s does not accept a value", flag)
|
||||
}
|
||||
options.JSON = true
|
||||
case "--dry-run":
|
||||
if strings.Contains(arg, "=") {
|
||||
return options, fmt.Errorf("%s does not accept a value", flag)
|
||||
}
|
||||
options.DryRun = true
|
||||
case "-h", "--help":
|
||||
if strings.Contains(arg, "=") {
|
||||
return options, fmt.Errorf("%s does not accept a value", flag)
|
||||
}
|
||||
options.Help = true
|
||||
default:
|
||||
return options, fmt.Errorf("unknown option: %s", arg)
|
||||
}
|
||||
}
|
||||
if options.Help {
|
||||
return options, nil
|
||||
}
|
||||
if strings.TrimSpace(options.SuitePath) == "" {
|
||||
return options, fmt.Errorf("--suite is required")
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
func writeTurnReport(path string, result perfbench.TurnBenchResult) error {
|
||||
if dir := filepath.Dir(path); dir != "" {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
var buffer bytes.Buffer
|
||||
if err := perfbench.WriteTurnBenchJSON(&buffer, result); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, buffer.Bytes(), 0o644)
|
||||
}
|
||||
|
||||
func turnHelpText() string {
|
||||
return strings.Join([]string{
|
||||
"Usage: zero-perf-bench turn [options]",
|
||||
"",
|
||||
"Runs ZERO headlessly with --trace against a per-turn benchmark task set and",
|
||||
"records per-span latency plus the top controllable latency sources (the",
|
||||
"Phase 0 baseline's \"do not proceed until\" criterion). Each task is a fresh",
|
||||
"`zero exec` process, so iterations are cold-start samples; a warm path needs",
|
||||
"an in-process runner (future work).",
|
||||
"",
|
||||
"Options:",
|
||||
" --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)",
|
||||
" --version <v> Record the ZERO version (default: $ZERO_BENCH_VERSION)",
|
||||
" --commit <sha> Record the ZERO commit (default: $ZERO_BENCH_COMMIT)",
|
||||
" --output <path> Write the JSON result to path",
|
||||
" --json Print only the JSON result",
|
||||
" --dry-run Load the manifest and exit without invoking the agent",
|
||||
" -h, --help Show this help",
|
||||
}, "\n") + "\n"
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,341 +0,0 @@
|
||||
# Extending Zero
|
||||
|
||||
Zero is an open-source terminal coding agent. Out of the box it does the obvious things — read, edit, run, search — but the design point of the project is that **every surface is configurable**. This document is the user-facing guide for that configuration.
|
||||
|
||||
If you only want to *use* Zero, the [README](../README.md) is enough. This page is for the other three jobs:
|
||||
|
||||
1. Tell the agent about *your* project (drop an `AGENTS.md` in your repo).
|
||||
2. Add new specialist sub-agents.
|
||||
3. Wire Zero into the rest of your toolchain (MCP, skills, hooks, plugins).
|
||||
|
||||
## 1. Drop a project `AGENTS.md`
|
||||
|
||||
When Zero starts in a directory, it looks for project-level instructions and injects them into the system prompt. The lookup walks from your current working directory **up to the nearest git root** and reads the first matching file at each level — general rules at the repo root, more specific rules in sub-trees. Files are labeled with their directory in the prompt (e.g. `## Project guidelines (services/api/AGENTS.md)`).
|
||||
|
||||
Accepted file names, in priority order at each level:
|
||||
|
||||
| Path | Notes |
|
||||
| --- | --- |
|
||||
| `./AGENTS.md` | The classic spot — committed to your repo, shared with the team. |
|
||||
| `./ZERO.md` | Brand-specific alias. Same format, lower priority. |
|
||||
| `./.zero/AGENTS.md` | Project-local, hidden, gitignored. Personal notes that stay out of git. |
|
||||
|
||||
Matching is **case-insensitive** on the basename, so `AGENTS.md`, `Agents.md`, and `agents.md` resolve to the same file on Windows and macOS. The git-tracked filename in this repo is `AGENTS.md` — keep that on case-sensitive filesystems (Linux, the WSL filesystem, or a CI runner) to match what the loader looks for.
|
||||
|
||||
Both files use the same format. YAML frontmatter is optional; the markdown body is loaded as instructions for the agent. Zero reads the file once at session start, so changes take effect on the next `zero` launch — not mid-session.
|
||||
|
||||
```markdown
|
||||
# Project conventions for <your project>
|
||||
|
||||
- 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.
|
||||
```
|
||||
|
||||
Tips:
|
||||
|
||||
- Keep each file under ~8 KiB. Zero caps the **total** across all matched files at 32 KiB; everything past the cap is dropped.
|
||||
- Re-state rules in the imperative voice: "Run `make lint`", not "you should consider running the linter".
|
||||
- Don't put secrets, model IDs, or environment-specific paths in `AGENTS.md`. Use `config.json` for those.
|
||||
- In a monorepo, drop a narrower `AGENTS.md` in each sub-tree (e.g. `services/api/AGENTS.md`). Zero picks those up automatically when you launch from inside the sub-tree.
|
||||
- A YAML frontmatter block (`---\n...\n---`) at the top is preserved verbatim in the injected prompt but is not parsed for `globs:` or `alwaysApply:` scoping today — keep the body self-contained.
|
||||
|
||||
### Personal guidelines, across every project
|
||||
|
||||
For preferences that follow *you*, not a specific repo (tone, tooling habits, workflow), drop a `ZERO.md` in your user config directory: `~/.config/zero/ZERO.md` on Linux/macOS, `%AppData%\zero\ZERO.md` on Windows — the same directory as `config.json` and your personal specialists. Same format and 8 KiB cap as the project files above, and the same case-insensitive basename match.
|
||||
|
||||
This file is injected as its own `## User guidelines` section, before the project's `AGENTS.md`/`ZERO.md`, and is labeled as personal preference in the prompt: project guidelines are the later, more specific instruction and take precedence over it when the two conflict.
|
||||
|
||||
## 2. Custom specialists
|
||||
|
||||
Specialists are Zero's sub-agents. Three scopes, in priority order:
|
||||
|
||||
| Scope | Path | Shared? |
|
||||
| --- | --- | --- |
|
||||
| Built-in | compiled into Zero | yes — `worker`, `explorer`, `code-review` |
|
||||
| User | `~/.config/zero/specialists/*.md` | no — your machine only |
|
||||
| Project | `./.zero/specialists/*.md` | yes — the repo team |
|
||||
|
||||
Project overrides user overrides built-in when names collide.
|
||||
|
||||
A specialist is a markdown manifest with frontmatter and a system prompt:
|
||||
|
||||
```markdown
|
||||
---
|
||||
description: Reviews API changes for breaking-change risk and missing tests.
|
||||
tools: read-only,plan
|
||||
---
|
||||
|
||||
You review API changes. For every changed hunk in `internal/api/` or any file
|
||||
that ends in `_api.go`:
|
||||
|
||||
1. Confirm the public signature is backward-compatible, or note the breaking
|
||||
change explicitly with the migration path.
|
||||
2. Confirm a corresponding test exists in `internal/api/*_test.go` and that
|
||||
the new behaviour is exercised.
|
||||
3. Flag any new exported symbol without a doc comment.
|
||||
|
||||
Reply with one JSON object per finding: `{"file", "line", "severity", "message", "fix"}`.
|
||||
```
|
||||
|
||||
CLI management (the prompt is passed inline via `--prompt`):
|
||||
|
||||
```bash
|
||||
zero specialist list
|
||||
zero specialist show api-reviewer
|
||||
zero specialist create api-reviewer \
|
||||
--project \
|
||||
--description "Reviews API changes" \
|
||||
--tools read-only,plan \
|
||||
--prompt "$(cat api-reviewer.md)"
|
||||
zero specialist edit api-reviewer --project
|
||||
zero specialist delete api-reviewer --project
|
||||
zero specialist path # prints the resolved specialists directory
|
||||
```
|
||||
|
||||
The full format spec (frontmatter fields, tool scopes, prompt conventions) is in [`docs/SPECIALISTS.md`](SPECIALISTS.md).
|
||||
|
||||
> **Roadmap.** An in-UI specialist manager (create / edit / delete / preview) is on the backlog. Today you use the `zero specialist` CLI subcommands above.
|
||||
|
||||
## 3. Skills
|
||||
|
||||
Skills are markdown instruction packs the agent can pull in on demand. Each skill is a directory containing a `SKILL.md`. Standalone project skill directories are not supported in this version (shared project-wide skills must go in `AGENTS.md` or as a hook). However, project plugins (section 6) may bundle skills, which are merged into the active run.
|
||||
|
||||
Discovery roots (earlier wins on name collisions):
|
||||
|
||||
1. **Primary Zero dir** — `$ZERO_SKILLS_DIR` if set, else `$XDG_DATA_HOME/zero/skills`, else `~/.local/share/zero/skills/`
|
||||
2. **Shared multi-agent dir** — `~/.agents/skills/` when present (read-only discovery; never an install target)
|
||||
3. **Plugin skill roots** — skills bundled by active plugins (section 6)
|
||||
|
||||
A missing directory is fine — Zero just omits it. Management commands (`zero skills add` / `remove` / `lock`) always write to the primary Zero dir only; `list` / `info` search primary + `~/.agents/skills`.
|
||||
|
||||
```text
|
||||
~/.local/share/zero/skills/ # primary (install/remove/lock target)
|
||||
run-benchmarks/
|
||||
SKILL.md
|
||||
write-changelog/
|
||||
SKILL.md
|
||||
|
||||
~/.agents/skills/ # optional shared multi-agent root
|
||||
shared-review/
|
||||
SKILL.md
|
||||
```
|
||||
|
||||
`SKILL.md` format:
|
||||
|
||||
```markdown
|
||||
---
|
||||
description: Run the project's benchmark suite and summarize the deltas.
|
||||
---
|
||||
|
||||
# Run benchmarks
|
||||
|
||||
1. `make bench` — captures the wall-clock and RSS before and after.
|
||||
2. `benchstat before.txt after.txt` — diffs the two.
|
||||
3. Report any regression > 5% with the function name and the previous value.
|
||||
```
|
||||
|
||||
Only `name` and `description` are recognized in the frontmatter today. The `name` defaults to the directory name. Within a single skills root, duplicate names are resolved by lexicographic directory order. Across roots, Zero loads the primary directory first, then `~/.agents/skills`, then plugin skill roots; earlier roots win name collisions. Plugin-declared skills (section 6) are merged into the active agent run at plugin activation time, so bundled skills appear in the available skills list and can be loaded with the `skill` tool.
|
||||
|
||||
The `skill` tool lets the agent load any discovered skill by name (primary, agents, or plugin).
|
||||
|
||||
## 4. Hooks
|
||||
|
||||
Hooks fire shell commands on lifecycle events. Configure them in JSON:
|
||||
|
||||
- User: `~/.config/zero/hooks.json`
|
||||
- Project: `./.zero/hooks.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"enabled": true,
|
||||
"hooks": [
|
||||
{
|
||||
"id": "block-rm-rf",
|
||||
"event": "beforeTool",
|
||||
"matcher": "bash",
|
||||
"command": "/usr/local/bin/zero-hook-block-rmrf.sh",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"id": "log-session",
|
||||
"event": "sessionStart",
|
||||
"command": "/usr/local/bin/zero-hook-log.sh",
|
||||
"enabled": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The `args` array (when present) is passed verbatim to `exec.CommandContext`. The actual hook payload — event name, matcher, tool call id, tool name, tool input, tool output, status — is delivered to the command as **JSON on stdin**, not via `${...}` substitution. A typical handler reads stdin and decides what to do:
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
# /usr/local/bin/zero-hook-block-rmrf.sh
|
||||
set -euo pipefail
|
||||
payload="$(cat)"
|
||||
if printf '%s' "$payload" | grep -q '"input":"[^"]*rm[[:space:]]+-rf'; then
|
||||
echo "refusing rm -rf" >&2
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
Events the agent emits (in dispatch order):
|
||||
|
||||
| Event | Fires when | Matcher allowed? |
|
||||
| --- | --- | --- |
|
||||
| `beforeTool` | A tool is about to run | yes (tool name) |
|
||||
| `afterTool` | A tool just returned | yes (tool name) |
|
||||
| `sessionStart` | A session begins | no |
|
||||
| `sessionEnd` | A session ends | no |
|
||||
| `specialistStart` | A sub-agent is spawned | yes (specialist name) |
|
||||
| `specialistStop` | A sub-agent ends | yes (specialist name) |
|
||||
|
||||
A hook's exit code decides what happens next: `0` continues, non-zero blocks the tool call (`beforeTool`) or surfaces an error (`afterTool`). Hook execution is recorded in the audit log; the audit is reachable from the agent's view of past actions, not from a dedicated `zero doctor` check.
|
||||
|
||||
> **Roadmap.** An in-UI hooks manager is on the backlog. Today you edit the JSON directly.
|
||||
|
||||
## 5. MCP — Model Context Protocol
|
||||
|
||||
Zero is both an **MCP client** (it can call external MCP servers) and an **MCP server** (other agents can call its tools).
|
||||
|
||||
### As a client — configure MCP servers in `config.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"mcp": {
|
||||
"servers": {
|
||||
"docs": {
|
||||
"type": "stdio",
|
||||
"command": "docs-mcp",
|
||||
"args": ["--port", "7777"]
|
||||
},
|
||||
"github": {
|
||||
"type": "http",
|
||||
"url": "https://api.example.com/mcp",
|
||||
"headers": { "Authorization": "Bearer YOUR_TOKEN_HERE" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Manage via CLI:
|
||||
|
||||
```bash
|
||||
zero mcp add docs --type stdio -- docs-mcp --port 7777
|
||||
zero mcp add github --type http --url https://api.example.com/mcp \
|
||||
--header "Authorization=Bearer YOUR_TOKEN_HERE"
|
||||
zero mcp list
|
||||
zero mcp check docs
|
||||
zero mcp remove github
|
||||
zero mcp oauth login github
|
||||
```
|
||||
|
||||
Servers are merged from user and project configs (project wins on conflicts). Token-bearing values in `config.json` are sent verbatim — there is no `${env:...}` expansion — so prefer one of:
|
||||
|
||||
- A wrapper script that sources the secret and execs the real command.
|
||||
- A `--header` value produced by command substitution (`"Authorization=Bearer $(print-token)"`) in a private shell config that you keep out of git.
|
||||
- A secret manager that injects the env var your MCP server reads on its own (the `command` and `args` then run inside that environment).
|
||||
|
||||
Project MCP config remains supported for shared team servers, but a project layer
|
||||
that changes an existing server's target (`url`, `command`, or `args`) does not
|
||||
inherit user credentials. Clear or replace `headers`, `env`, or `oauth` explicitly,
|
||||
or use a new server name. OAuth tokens are bound to the resolved server identity,
|
||||
so changing a server target can require `zero mcp oauth login <server>` again.
|
||||
OAuth server names ending in `.<32 hex chars>` are reserved for token storage.
|
||||
|
||||
### As a server — expose Zero's tools to another agent
|
||||
|
||||
```bash
|
||||
zero serve --mcp
|
||||
zero serve --mcp -C /path/to/workspace
|
||||
zero serve --mcp --add-dir /path/to/extra
|
||||
```
|
||||
|
||||
The server speaks MCP over stdio. Configure it from the receiving side as a `stdio` server whose command is `zero serve --mcp`.
|
||||
|
||||
`-C/--cwd` sets the workspace root exposed as MCP resources and used by core tools. `--add-dir` (repeatable) widens that resource/tool scope beyond the workspace without granting the sandbox temp roots used by interactive runs.
|
||||
## 6. Plugins
|
||||
|
||||
A plugin is a self-contained directory that bundles tools, hooks, and skills for one capability. Plugins live at:
|
||||
|
||||
- User: `~/.config/zero/plugins/<id>/`
|
||||
- Project: `./.zero/plugins/<id>/`
|
||||
|
||||
Each plugin has a `plugin.json` manifest:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "github-pr-review",
|
||||
"name": "GitHub PR Review",
|
||||
"description": "Adds review tools for GitHub PRs.",
|
||||
"version": "1.0.0",
|
||||
"tools": [
|
||||
{ "name": "list_prs", "command": "./tools/list_prs.sh" }
|
||||
],
|
||||
"hooks": [
|
||||
{ "name": "pre-merge-check", "event": "beforeTool", "command": "./hooks/pre-merge.sh" }
|
||||
],
|
||||
"skills": [
|
||||
{ "path": "./skills/review-checklist/SKILL.md" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Install and manage:
|
||||
|
||||
```bash
|
||||
zero plugins add ./github-pr-review # copy into ~/.config/zero/plugins/ or ./.zero/plugins/
|
||||
zero plugins list
|
||||
zero plugins info github-pr-review # show manifest path, enabled state, lock metadata
|
||||
zero plugins remove github-pr-review # alias: rm
|
||||
```
|
||||
|
||||
A plugin is enabled by being present in the plugins directory and disabled by removing it (or by the user setting `"enabled": false` in its `plugin.json`). Plugins are not enabled or disabled by a CLI subcommand today.
|
||||
|
||||
Plugin commands run with the plugin directory as their working directory. Use relative paths; the loader resolves them at activation time.
|
||||
|
||||
> **Roadmap.** An in-UI plugins manager (browse, install, enable / disable) is on the backlog. Today you use the `zero plugins` CLI subcommands above.
|
||||
|
||||
## 7. Configuration locations
|
||||
|
||||
Five configuration sources, in precedence order (later sources override earlier ones):
|
||||
|
||||
| Source | Path / Key | Notes |
|
||||
| --- | --- | --- |
|
||||
| Built-in defaults | compiled in | Lowest priority. |
|
||||
| User config | `~/.config/zero/config.json` | Your machine. Never committed. |
|
||||
| Project config | `./.zero/config.json` | The repo. Committed (or not, your call). |
|
||||
| Environment | `ZERO_*` | Provider commands, secrets, skills dir override. |
|
||||
| CLI flags | `--model`, `--mode`, ... | Highest priority, per-invocation. |
|
||||
|
||||
The user config holds things that should follow the user across projects (default provider, default model, theme). The project config holds things the team agreed on (provider catalog, sandbox policies, model restrictions).
|
||||
|
||||
The sandbox `additionalWriteRoots` key is **ignored in project config** by design — a checked-out repo cannot widen its own sandbox. Set it in the user config or pass `--add-dir` per-invocation.
|
||||
|
||||
## 8. End-to-end example
|
||||
|
||||
A team that wants every contributor's Zero to behave the same way commits:
|
||||
|
||||
- `AGENTS.md` — project conventions, build commands, do-not-edit lists.
|
||||
- `.zero/config.json` — provider catalog, default model, allowed tools.
|
||||
- `.zero/specialists/api-reviewer.md` — the team's PR-review specialist.
|
||||
- `.zero/hooks.json` — block `rm -rf` on `beforeTool`.
|
||||
- `.zero/plugins/internal-tooling/` — a plugin that adds the team's internal CLI tools to the agent's toolset.
|
||||
|
||||
Each contributor adds only:
|
||||
|
||||
- `~/.config/zero/config.json` — their personal API keys, theme, default mode.
|
||||
- `~/.config/zero/ZERO.md` — personal preferences that follow them across every project (see section 1).
|
||||
- `~/.local/share/zero/skills/` — personal skills they keep across projects.
|
||||
|
||||
That's it. Run `zero` from the repo root and the agent has the team's full instruction set, every contributor's personal setup, and nothing else.
|
||||
|
||||
## 9. Reference
|
||||
|
||||
- [README](../README.md) — install, quickstart, command reference.
|
||||
- [docs/SPECIALISTS.md](SPECIALISTS.md) — full specialist manifest spec.
|
||||
- [docs/STREAM_JSON_PROTOCOL.md](STREAM_JSON_PROTOCOL.md) — `zero exec` I/O contract.
|
||||
- [docs/INSTALL.md](INSTALL.md) — install from source or release.
|
||||
@@ -1,801 +0,0 @@
|
||||
# How Zero Works
|
||||
|
||||
This document explains Zero's architecture at a high level: the layers, module
|
||||
boundaries, data flow, and runtime concepts behind the interactive TUI and the
|
||||
headless `zero exec` mode.
|
||||
|
||||
It is written as a progressive map: each high-level section introduces the
|
||||
concepts you need for orientation, then points to the later section that explains
|
||||
the mechanics in more detail.
|
||||
|
||||
## Big Picture
|
||||
|
||||
Zero is a Go CLI application with two main user-facing surfaces:
|
||||
|
||||
- **Interactive TUI** (`zero`): a Bubble Tea application that renders chat,
|
||||
tool cards, permission prompts, model/provider pickers, and session controls.
|
||||
- **Headless exec** (`zero exec ...`): a scriptable command runner that uses the
|
||||
same agent loop and tools, but writes text, JSON, or stream-JSON events.
|
||||
|
||||
Both surfaces converge on the same core agent runtime:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
User[User / automation] --> CLI[cmd/zero -> internal/cli]
|
||||
|
||||
CLI --> TUI[Interactive TUI<br/>internal/tui]
|
||||
CLI --> Exec[Headless exec<br/>internal/cli/exec.go]
|
||||
CLI --> ACP[ACP / editor bridge<br/>internal/acp]
|
||||
|
||||
TUI --> Agent[Agent loop<br/>internal/agent]
|
||||
Exec --> Agent
|
||||
ACP --> Agent
|
||||
|
||||
Agent --> Provider[Provider runtime<br/>internal/zeroruntime.Provider]
|
||||
Provider --> APIs[OpenAI / Anthropic / Gemini / compatible APIs]
|
||||
|
||||
Agent --> Registry[Tool registry<br/>internal/tools.Registry]
|
||||
Registry --> CoreTools[Core tools<br/>read/edit/shell/search/plan]
|
||||
Registry --> MCP[MCP tools<br/>internal/mcp]
|
||||
Registry --> Specialists[Specialist tools<br/>internal/specialist]
|
||||
Registry --> Plugins[Plugin tools<br/>internal/plugins]
|
||||
Registry --> LocalControl[Browser / terminal / desktop helpers]
|
||||
|
||||
Agent --> Sandbox[Permission + sandbox engine<br/>internal/sandbox]
|
||||
Agent --> Hooks[Hooks dispatcher<br/>internal/hooks]
|
||||
Agent --> Sessions[Session event store<br/>internal/sessions]
|
||||
```
|
||||
|
||||
The important split is:
|
||||
|
||||
- `internal/tui` is the **presentation layer**. For the mechanics, see
|
||||
[Presentation Layer: Bubble Tea TUI](#presentation-layer-bubble-tea-tui) and
|
||||
[How the TUI and Agent Loop Connect](#how-the-tui-and-agent-loop-connect).
|
||||
- `internal/agent` is the **reasoning/tool loop**. For the per-turn mechanics,
|
||||
see [Agent Loop](#agent-loop), [Tool Execution Lifecycle](#tool-execution-lifecycle),
|
||||
and [Context Budget and Compaction](#context-budget-and-compaction).
|
||||
- `internal/tools`, `internal/sandbox`, and provider packages are the **runtime
|
||||
capability layer**. For the details, see [Runtime Provider Layer](#runtime-provider-layer),
|
||||
[Tools and Capabilities](#tools-and-capabilities), and
|
||||
[Permission and Sandbox Model](#permission-and-sandbox-model).
|
||||
- `internal/sessions` is the **durable event log** used for resume, fork,
|
||||
rewind, sub-agents, and history. See [Session Storage and History](#session-storage-and-history).
|
||||
|
||||
## Architecture Spine
|
||||
|
||||
The easiest way to understand Zero is not as a set of independent packages, but
|
||||
as one spine with several mechanisms attached to it:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Surface[Surface<br/>TUI / exec / ACP] --> Run[Run request<br/>prompt + runtime options]
|
||||
Run --> Agent[Agent loop<br/>turns, tools, recovery]
|
||||
Agent --> Capabilities[Capabilities<br/>providers + tools + sandbox]
|
||||
Capabilities --> Events[Runtime events<br/>text, tool calls, permissions, usage]
|
||||
Events --> Surface
|
||||
Events --> Sessions[Session log<br/>resume, fork, rewind, history]
|
||||
|
||||
Config[Config / guidelines / skills] --> Run
|
||||
Extensions[MCP / specialists / plugins / hooks] --> Capabilities
|
||||
```
|
||||
|
||||
The **TUI + agent loop** is the main story because it explains the normal user
|
||||
experience: type a prompt, watch the model stream, approve tool use, see file
|
||||
changes, and receive a final answer. Other mechanisms are still important, but
|
||||
most of them attach to the same spine:
|
||||
|
||||
| Mechanism | Where it attaches | What it changes |
|
||||
| --- | --- | --- |
|
||||
| Config, provider profiles, model registry | Before a run starts | Which provider/model is used and what defaults apply. |
|
||||
| Project/user guidelines, skills, specialists context | Prompt construction | What instructions and available capabilities the model sees. |
|
||||
| Tool registry, MCP, plugins, local-control helpers | Capability layer | Which actions the model may request. |
|
||||
| Permission policy, sandbox, command-prefix grants | Tool execution | Whether a requested action can run, needs approval, or is denied. |
|
||||
| Hooks | Tool lifecycle | Optional before/after observers or blockers around tool calls. |
|
||||
| Sessions, checkpoints, compaction metadata | Event persistence/context | What can be resumed, forked, rewound, or summarized for later turns. |
|
||||
| Stream-JSON | Surface layer | How the same runtime events are emitted for automation instead of rendered in the TUI. |
|
||||
|
||||
So this document focuses first on the TUI/agent-loop bridge, then explains the
|
||||
supporting mechanisms in terms of where they connect to that bridge.
|
||||
|
||||
Use this as the reading path:
|
||||
|
||||
| If you see this concept in a high-level flow | Read this detail section |
|
||||
| --- | --- |
|
||||
| Surface submits a run | [Startup Flow](#startup-flow), then [How the TUI and Agent Loop Connect](#how-the-tui-and-agent-loop-connect) |
|
||||
| Agent builds context and advances turns | [Agent Loop](#agent-loop) |
|
||||
| “Maybe compact context” | [Context Budget and Compaction](#context-budget-and-compaction) |
|
||||
| Model requests a tool | [Tool Execution Lifecycle](#tool-execution-lifecycle), then [Tools and Capabilities](#tools-and-capabilities) |
|
||||
| Tool needs approval or sandbox access | [Permission and Sandbox Model](#permission-and-sandbox-model) |
|
||||
| Events appear in the UI or stream-JSON | [How the TUI and Agent Loop Connect](#how-the-tui-and-agent-loop-connect), [Stream-JSON Protocol](#stream-json-protocol) |
|
||||
| Resume, fork, rewind, or sub-agent history | [Session Storage and History](#session-storage-and-history) |
|
||||
|
||||
## Main Packages
|
||||
|
||||
The sections above describe the architecture in concepts. If you want to connect
|
||||
those concepts to implementation details, use this table as the code map before
|
||||
reading the deeper flows below.
|
||||
|
||||
| Package | Role |
|
||||
| --- | --- |
|
||||
| `cmd/zero` | Tiny binary entrypoint; calls `internal/cli.Run`. |
|
||||
| `internal/cli` | Command parsing, config resolution, provider creation, registry setup, TUI/exec/ACP launch paths. |
|
||||
| `internal/tui` | Bubble Tea model/update/view code, transcript rendering, composer, prompts, slash commands, setup wizard. |
|
||||
| `internal/agent` | Core loop that sends messages to the model, streams output, executes tools, handles permissions, compaction, retries, and final answers. |
|
||||
| `internal/zeroruntime` | Provider-neutral message, tool-call, stream, usage, and image types. Providers implement `Provider.StreamCompletion`. |
|
||||
| `internal/providers` | Factory that turns a resolved provider profile into an OpenAI, Anthropic, Gemini, Codex, or compatible provider adapter. |
|
||||
| `internal/tools` | Tool interface, registry, core read/write/shell/network tools, local-control wrappers, tool safety metadata, redaction, output ceilings. |
|
||||
| `internal/sandbox` | Permission policy, path/network checks, grant store, command-prefix approvals, and platform sandbox backends. |
|
||||
| `internal/sessions` | Local append-only session storage: `metadata.json` + `events.jsonl`. |
|
||||
| `internal/config` | User/project config resolution, active provider, preferences, sandbox/tool settings. |
|
||||
| `internal/mcp` | MCP server config, client runtime, permission store, and MCP tool registration. |
|
||||
| `internal/specialist` / `internal/swarm` | Sub-agent manifests and team/member orchestration exposed as tools. |
|
||||
| `internal/localcontrol` / `internal/browser` | Runtime helpers used by config-gated local-control tool wrappers. |
|
||||
| `internal/plugins` / `internal/skills` / `internal/hooks` | Extension surfaces loaded into the agent context, tool registry, or tool lifecycle. |
|
||||
| `internal/streamjson` | Machine-readable protocol for `zero exec --input-format/--output-format stream-json`. |
|
||||
|
||||
## Startup Flow
|
||||
|
||||
### Interactive TUI (`zero`)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Main as cmd/zero
|
||||
participant CLI as internal/cli
|
||||
participant Config as internal/config
|
||||
participant TUI as internal/tui
|
||||
participant Agent as internal/agent
|
||||
|
||||
Main->>CLI: Run(args)
|
||||
CLI->>Config: resolve config for workspace
|
||||
CLI->>CLI: build provider
|
||||
CLI->>CLI: build tool registry
|
||||
CLI->>CLI: register core, config-gated local-control, specialists, MCP, plugins
|
||||
CLI->>CLI: create sandbox engine + session store
|
||||
CLI->>TUI: run Bubble Tea with Options
|
||||
TUI->>TUI: Init / Update / View event loop
|
||||
TUI->>Agent: agent.Run on submitted prompt
|
||||
```
|
||||
|
||||
The TUI owns terminal interaction. It tracks composer state, transcript rows,
|
||||
scrolling, permission dialogs, model/provider pickers, setup wizard state, and
|
||||
visual updates. When the user submits a prompt, the TUI builds `agent.Options`
|
||||
with callbacks like `OnText`, `OnToolCall`, `OnToolResult`, `OnPermission`,
|
||||
`OnAskUser`, and `OnUsage`. Those callbacks convert runtime events into visible
|
||||
transcript rows and session events.
|
||||
|
||||
### Headless exec (`zero exec`)
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[Parse exec flags] --> B[Resolve config + provider]
|
||||
B --> C[Build tool registry]
|
||||
C --> D[Resolve prompt / stream-json input / images]
|
||||
D --> E[Prepare or resume session]
|
||||
E --> F[Create stream-json/text/json writer]
|
||||
F --> G[agent.Run]
|
||||
G --> H[Write final output and exit code]
|
||||
```
|
||||
|
||||
`zero exec` uses the same `agent.Run` loop as the TUI, but without interactive
|
||||
terminal state. It can persist sessions, resume/fork existing sessions, run in
|
||||
stream-JSON mode for automation, and always requires a stronger completion
|
||||
signal so a no-tool assistant response is not treated as success while work is
|
||||
still pending.
|
||||
|
||||
## Agent Loop
|
||||
|
||||
The core loop lives in `internal/agent.Run`. Conceptually, every turn follows
|
||||
this cycle:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Start[Build system prompt + user prompt] --> Expose[Partition visible tools]
|
||||
Expose --> Compact[Maybe compact context]
|
||||
Compact --> Request[Send CompletionRequest to provider]
|
||||
Request --> Stream[Collect streamed text, reasoning, usage, tool calls]
|
||||
|
||||
Stream --> HasTools{Tool calls?}
|
||||
HasTools -- No --> FinalCheck{Final answer acceptable?}
|
||||
FinalCheck -- Yes --> Done[Return Result]
|
||||
FinalCheck -- No --> Nudge[Append user nudge and continue]
|
||||
|
||||
HasTools -- Yes --> Execute[Execute tool calls]
|
||||
Execute --> Permission[Permission / sandbox / hooks]
|
||||
Permission --> ToolResult[Append tool result messages]
|
||||
ToolResult --> Diagnostics[Post-edit diagnostics / self-correct]
|
||||
Diagnostics --> Expose
|
||||
```
|
||||
|
||||
Key responsibilities of the loop:
|
||||
|
||||
1. **Build prompt state** — combines the system prompt, project/user guidelines,
|
||||
loaded skills/specialist instructions, the current user prompt, and images. The
|
||||
resulting message list is the model-facing context that later tool results are
|
||||
appended to. Resumed session history is replayed by the caller before a new run,
|
||||
not passed as a separate `agent.Run` history field.
|
||||
2. **Expose tools** — starts from the registry built by the CLI, then filters
|
||||
tool definitions by permission mode, enabled or disabled filters, and
|
||||
deferred-loading rules. Tool schemas also count toward context budget, so they
|
||||
matter for compaction decisions.
|
||||
3. **Call provider** — sends a provider-neutral `zeroruntime.CompletionRequest`.
|
||||
4. **Stream events** — forwards text, reasoning, tool-call deltas, and usage to
|
||||
the caller via callbacks.
|
||||
5. **Execute tools** — decodes tool arguments, checks filters, applies
|
||||
permissions and sandbox policy, dispatches hooks, runs the registered tool,
|
||||
redacts output, and appends a tool message. See
|
||||
[Tool Execution Lifecycle](#tool-execution-lifecycle).
|
||||
6. **Recover and guard** — handles malformed tool calls, repeated failures,
|
||||
empty turns, context pressure, stream reconnect/retry, and max-turn fallback.
|
||||
Context pressure is handled by [Context Budget and Compaction](#context-budget-and-compaction).
|
||||
7. **Finalize** — returns a final answer or a structured incomplete/stop reason.
|
||||
|
||||
The model never directly mutates the workspace. It asks for tool calls; Zero
|
||||
validates and executes those calls through the registry, permission policy, and
|
||||
sandbox engine.
|
||||
|
||||
## Tool Execution Lifecycle
|
||||
|
||||
A tool call is part of the model conversation, not a side channel that bypasses
|
||||
it. The model proposes an action, Zero decides whether that action is allowed to
|
||||
run, the tool returns a structured result, and that result becomes context for the
|
||||
next provider request.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Model as LLM / provider
|
||||
participant Agent as agent.Run
|
||||
participant Policy as filters + permissions + sandbox
|
||||
participant Hooks as hooks
|
||||
participant Tool as tool implementation
|
||||
participant Surface as TUI / exec callbacks
|
||||
participant Transcript as model conversation + session events
|
||||
|
||||
Agent->>Model: request with messages + available tool schemas
|
||||
Model-->>Agent: assistant text and/or tool_call(name, args)
|
||||
Agent-->>Surface: stream text / tool-call preview
|
||||
Agent->>Agent: collect and decode tool arguments
|
||||
Agent->>Policy: check tool filters, permission mode, sandbox policy
|
||||
|
||||
alt denied before execution
|
||||
Policy-->>Agent: denied result
|
||||
Agent-->>Surface: tool failed/denied event
|
||||
Agent->>Transcript: append tool result explaining denial
|
||||
else needs user approval
|
||||
Policy-->>Surface: permission request
|
||||
Surface-->>Policy: allow / deny / remember decision
|
||||
Policy-->>Agent: decision
|
||||
end
|
||||
|
||||
alt approved to execute
|
||||
Agent->>Hooks: beforeTool hooks
|
||||
Hooks-->>Agent: allow / block / annotate
|
||||
Agent->>Tool: execute(ctx, args)
|
||||
Tool-->>Agent: output, error, metadata
|
||||
Agent->>Hooks: afterTool hooks
|
||||
Agent->>Agent: redact secrets, apply semantic output budget, enforce byte ceiling
|
||||
Agent->>Agent: classify success/failure
|
||||
Agent-->>Surface: OnToolResult callback
|
||||
Agent->>Transcript: append tool result message
|
||||
end
|
||||
|
||||
Agent->>Model: next request includes prior assistant tool_call + tool result
|
||||
Model-->>Agent: continue with more tools or final answer
|
||||
```
|
||||
|
||||
Conceptually, one requested tool goes through these phases:
|
||||
|
||||
1. **Proposal** — the provider streams assistant content and may emit one or more
|
||||
tool calls. Zero can show the partial tool arguments in the TUI while they are
|
||||
still streaming, but nothing has executed yet.
|
||||
2. **Decode and gate** — the agent validates JSON arguments, checks whether the
|
||||
tool is enabled, applies permission mode, asks the sandbox engine about path,
|
||||
network, shell, or command-prefix constraints, and may invoke permission
|
||||
callbacks.
|
||||
3. **Execute** — the selected registry entry runs locally, through MCP, as a
|
||||
specialist/sub-agent, or through a plugin/local-control helper. Hooks can run
|
||||
before or after this step.
|
||||
4. **Normalize result** — Zero turns the outcome into an agent `ToolResult`: a
|
||||
success, failure, denial, cancellation, timeout, or validation error with
|
||||
output/metadata that may be redacted or truncated.
|
||||
5. **Append to context** — the result is appended as a tool-result message paired
|
||||
with the assistant tool call. The next model request includes that result, so
|
||||
the model can inspect it and decide what to do next.
|
||||
6. **Render and persist** — the result is surfaced to the TUI, stream-JSON
|
||||
writer, or ACP client. TUI and exec record rich tool/session events for
|
||||
resume/history; ACP currently persists conversational messages and streams
|
||||
tool/permission updates to the client.
|
||||
|
||||
Tool success and failure are both informative context. A successful `read_file`
|
||||
result gives the model file contents; a failed `edit_file` result gives the model
|
||||
an error such as “old string not found”; a denied shell command tells the model
|
||||
that the action is not currently permitted. The model is expected to use that
|
||||
feedback to continue, choose another tool, ask the user, or explain the blocker.
|
||||
|
||||
The judgement that “information is sufficient” is made by the model, not by the
|
||||
TUI. Zero controls the loop mechanics: it keeps appending messages, enforces
|
||||
safety, retries or nudges around malformed/empty/incomplete turns, and stops on
|
||||
hard limits. But the semantic decision to call another tool versus produce a
|
||||
final answer comes from the next provider response. In other words:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Result[Tool result appended to conversation] --> Next[Next provider request]
|
||||
Next --> Decide{Model response}
|
||||
Decide -- More information/action needed --> MoreTools[Request another tool]
|
||||
MoreTools --> Result
|
||||
Decide -- User input needed --> AskUser[Call ask_user or explain question]
|
||||
AskUser --> Result
|
||||
Decide -- Enough information --> Final[Final assistant answer]
|
||||
Decide -- Invalid / empty / unsafe --> Guard[Agent guardrail, retry, denial, or stop reason]
|
||||
Guard --> Next
|
||||
```
|
||||
|
||||
This is why tool output is usually more than UI decoration. It is part of the
|
||||
working memory for the next model turn, subject to context-window pressure and
|
||||
compaction. The TUI may summarize or prettify it for the user, but the agent loop
|
||||
also stores the machine-facing result that the model sees.
|
||||
|
||||
## Context Budget and Compaction
|
||||
|
||||
The `Maybe compact context` step in the agent-loop diagram is Zero's context-window
|
||||
pressure valve. It is not a separate memory engine; it rewrites the current
|
||||
model-facing conversation so the next provider request fits within the configured
|
||||
context window.
|
||||
|
||||
Compaction is enabled only when the run knows a positive `ContextWindow`. When it
|
||||
is enabled, the loop does this near the top of each turn:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Tools[Partition visible tools] --> Estimate[Estimate messages + tool-definition tokens]
|
||||
Estimate --> Under{Under threshold?}
|
||||
Under -- Yes --> Keep[Keep context as-is]
|
||||
Under -- No --> Grown{Grown past low-water mark?}
|
||||
Grown -- No --> Keep
|
||||
Grown -- Yes --> Prune[Prune stale large tool outputs]
|
||||
Prune --> Recheck{Back under threshold?}
|
||||
Recheck -- Yes --> KeepPruned[Use pruned context]
|
||||
Recheck -- No --> Summarize[Summarize oldest middle]
|
||||
Summarize --> Replace[system messages + summary + recent suffix]
|
||||
```
|
||||
|
||||
The trigger is intentionally approximate:
|
||||
|
||||
- Zero estimates request size using message text, images, tool-call arguments, and
|
||||
the tool definitions advertised on that turn.
|
||||
- The proactive threshold is roughly **two-thirds of the context window**.
|
||||
- Provider usage from completed turns is used to calibrate the rough estimate over
|
||||
time, so the budget gauge and compaction decision track the provider more
|
||||
closely after a few turns.
|
||||
- A low-water mark prevents the loop from re-summarizing the same already-compacted
|
||||
history on every turn.
|
||||
|
||||
When compaction actually runs, it tries to preserve the parts most likely to be
|
||||
needed by the model:
|
||||
|
||||
- leading system messages stay verbatim;
|
||||
- the most recent turns stay verbatim (`CompactionPreserveLast`, defaulting to the
|
||||
agent's normal preserve count);
|
||||
- the preserved suffix is widened to a provider-valid turn boundary so tool-result
|
||||
messages are not replayed without their corresponding assistant tool call;
|
||||
- the older middle is summarized into one user-role message labeled
|
||||
`[Summary of earlier conversation]`;
|
||||
- structured state that should not be paraphrased away, such as active plan/loaded
|
||||
skill state, is preserved alongside the prose summary.
|
||||
|
||||
There are two compaction paths:
|
||||
|
||||
1. **Proactive compaction** runs before the provider call when the estimated
|
||||
request is over threshold.
|
||||
2. **Reactive compaction** runs after a provider returns a context-limit style
|
||||
error. Zero compacts once and retries that same turn; if the provider still
|
||||
rejects the request, the error is surfaced rather than looping forever.
|
||||
|
||||
Compaction failures are conservative. If the summarizer fails during proactive
|
||||
compaction, Zero keeps the original messages and tries to continue. It does not
|
||||
drop conversation history just to make room.
|
||||
|
||||
## Presentation Layer: Bubble Tea TUI
|
||||
|
||||
`internal/tui` is a Bubble Tea application. The usual Bubble Tea shape applies:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Init[model.Init] --> Update[model.Update]
|
||||
Update --> View[model.View]
|
||||
View --> Terminal[Terminal frame]
|
||||
Terminal --> Msg[tea.Msg: key, mouse, window, agent event]
|
||||
Msg --> Update
|
||||
```
|
||||
|
||||
The TUI is intentionally not the agent brain. It is responsible for:
|
||||
|
||||
- collecting user input from the composer;
|
||||
- rendering assistant text, reasoning, tool cards, permissions, plans, usage,
|
||||
files, and sidebars;
|
||||
- handling slash commands and modal surfaces;
|
||||
- converting agent callbacks into Bubble Tea messages;
|
||||
- persisting visible session events;
|
||||
- allowing live provider/model/permission changes.
|
||||
|
||||
When a prompt is submitted, the TUI starts a background command that invokes
|
||||
`agent.Run`. Streaming callbacks send messages back into the Bubble Tea update
|
||||
loop, so rendering remains responsive while the agent is working.
|
||||
|
||||
## How the TUI and Agent Loop Connect
|
||||
|
||||
A common source of confusion is that Zero has two event loops running at once:
|
||||
|
||||
1. **Bubble Tea loop** — owns terminal input, in-memory UI state, and frames.
|
||||
2. **Agent loop** — owns model turns, tool execution, permissions, retries, and
|
||||
stop conditions.
|
||||
|
||||
They are connected by `tea.Cmd` and `agent.Options` callbacks. The TUI never
|
||||
runs provider/tool logic directly inside `Update`; instead, submit handling gates
|
||||
and dispatches a prompt, the TUI starts a background command, and that command
|
||||
calls `agent.Run` with callbacks that send runtime events back to the Bubble Tea
|
||||
program. Function names in this section are illustrative code anchors, not API
|
||||
boundaries.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant TUI as Bubble Tea model<br/>internal/tui
|
||||
participant Cmd as tea.Cmd goroutine<br/>runAgentWithOptions
|
||||
participant Agent as agent.Run<br/>internal/agent
|
||||
participant Provider as Provider.StreamCompletion
|
||||
participant Tools as Tool registry + sandbox
|
||||
participant Store as Session store
|
||||
|
||||
User->>TUI: type prompt / press Enter
|
||||
TUI->>TUI: validate/gate submit and append visible user row
|
||||
TUI->>Cmd: launch tea.Cmd with runID + cancellable context
|
||||
Cmd->>Agent: agent.Run(prompt, provider, options)
|
||||
Agent->>Provider: stream completion request
|
||||
Provider-->>Agent: text / reasoning / tool-call deltas
|
||||
Agent-->>Cmd: OnText / OnReasoning / OnToolCallStart callbacks
|
||||
Cmd-->>TUI: runtimeMessageSink(tea.Msg)
|
||||
TUI->>TUI: Update appends/updates transcript rows
|
||||
TUI-->>User: View renders new frame
|
||||
|
||||
Agent->>Tools: execute collected tool call
|
||||
Tools->>Tools: permission, sandbox, hooks, redaction
|
||||
Tools-->>Agent: tool result
|
||||
Agent-->>Cmd: OnToolCall / OnToolResult / OnPermission / OnUsage
|
||||
Cmd->>Store: batch session events for resume/history
|
||||
Cmd-->>TUI: final agentResponseMsg
|
||||
TUI->>TUI: mark run complete, flush rows/events, unlock composer
|
||||
```
|
||||
|
||||
In the TUI path, the same event can have three representations:
|
||||
|
||||
| Runtime thing | Agent representation | TUI representation | Session representation |
|
||||
| --- | --- | --- | --- |
|
||||
| Assistant text | streamed provider text forwarded through `OnText` | incremental assistant row text | assistant message event |
|
||||
| Reasoning preview | `OnReasoning` callback | collapsible/preview reasoning row | usually not the same as final assistant text |
|
||||
| Tool call | `agent.ToolCall` from collected provider stream | tool card, specialist card, or plan panel update | `EventToolCall` |
|
||||
| Tool result | `agent.ToolResult` | result card/status, changed files, plan/specialist updates | `EventToolResult` |
|
||||
| Permission prompt/audit | `PermissionRequest` / `PermissionEvent` | modal prompt or permission row | permission request/decision events |
|
||||
| Usage/context | provider usage + measured context | context gauge / usage metadata | usage event |
|
||||
|
||||
### One interactive turn, end to end
|
||||
|
||||
For a normal prompt in the interactive TUI:
|
||||
|
||||
1. Submit handling validates composer state and command gates, then dispatches
|
||||
the prompt to the launch path.
|
||||
2. The launch path appends immediate UI rows, creates a cancellable run context,
|
||||
and starts `runAgent` as a `tea.Cmd`; run bookkeeping assigns the `runID` used
|
||||
to correlate later events.
|
||||
3. `runAgentWithOptions` copies the current UI-controlled runtime choices into
|
||||
`agent.Options`: provider/model labels, permission mode, cwd, session ID,
|
||||
images, registry, response style, context window, and self-correction hooks.
|
||||
4. The command wraps agent callbacks. For example:
|
||||
- `OnText` sends incremental assistant text into the TUI message sink.
|
||||
- `OnToolCallStart` / `OnToolCallDelta` show live tool-argument streaming.
|
||||
- `OnToolCall` creates visible tool rows and appends session events.
|
||||
- `OnToolResult` creates result rows, updates plan/specialist side panels, and
|
||||
records changed files.
|
||||
- `OnPermissionRequest` sends a prompt to the TUI and blocks the agent loop
|
||||
until the user answers or the run context is cancelled.
|
||||
- `OnAskUser` does the same for model-initiated clarification questions.
|
||||
5. `agent.Run` builds provider-neutral messages and tool definitions, streams a
|
||||
provider response, executes any requested tools through the registry/sandbox,
|
||||
appends tool results back into the model conversation, and repeats until it has
|
||||
a final answer or a stop condition.
|
||||
6. When `agent.Run` returns, the command emits `agentResponseMsg`. The Bubble Tea
|
||||
loop marks the run complete, flushes queued rows/session events, updates usage
|
||||
and file state, and re-enables the composer.
|
||||
|
||||
The important architectural point: **the TUI is stateful and reactive, but the
|
||||
agent loop is the source of runtime truth for model/tool progress**. Presentation
|
||||
state is derived from callback events and persisted session events; workspace
|
||||
changes happen only through tools executed by the agent runtime.
|
||||
|
||||
### Feature flows use the same bridge
|
||||
|
||||
For one interactive turn, the supporting systems do not run beside the TUI/agent
|
||||
loop; they run inside or around it:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Submit[TUI submit] --> Options[Build agent.Options]
|
||||
Options --> Context[Assemble context<br/>config, guidelines, history, skills]
|
||||
Context --> ProviderCall[Provider stream]
|
||||
ProviderCall --> StreamEvents[Text/reasoning/tool-call events]
|
||||
StreamEvents --> Render[TUI render callbacks]
|
||||
|
||||
ProviderCall --> ToolCall{Tool call?}
|
||||
ToolCall -- No --> Final[Final response]
|
||||
ToolCall -- Yes --> ToolGate[Tool gate<br/>filters, permissions, sandbox]
|
||||
ToolGate --> HooksBefore[beforeTool hooks]
|
||||
HooksBefore --> Execute[Tool / MCP / specialist / plugin]
|
||||
Execute --> HooksAfter[afterTool hooks]
|
||||
HooksAfter --> ToolResult[Tool result message]
|
||||
ToolResult --> Persist[Session events]
|
||||
ToolResult --> Context
|
||||
|
||||
Final --> Persist
|
||||
Persist --> Resume[Resume / fork / rewind / parent metadata]
|
||||
```
|
||||
|
||||
Most interactive features are variations of that same bridge rather than separate
|
||||
architectures:
|
||||
|
||||
- **Slash commands** either mutate TUI state directly (`/model`, `/permission`,
|
||||
`/help`) or start short `tea.Cmd` jobs that later return a message to `Update`
|
||||
(`/doctor`, `/search`, `/compact`, session commands).
|
||||
- **Permissions** sit between a model-requested tool call and actual execution:
|
||||
the agent blocks on `OnPermissionRequest`, the TUI surfaces a modal and sends
|
||||
the decision back through a channel, and the session log records the
|
||||
request/decision events.
|
||||
- **Plans, specialists/sub-agents, and ask-user prompts** all follow the same
|
||||
runtime pattern: the model requests a capability, the agent records the
|
||||
call/result in its conversation, and the TUI mirrors the visible state as plan
|
||||
panels, specialist cards, or question forms. Specialist session metadata can
|
||||
carry parent/root and sub-agent/spec fields, with richer linkage depending on
|
||||
the specialist or swarm path.
|
||||
- **Streaming tool writes** start as provider tool-call deltas before execution;
|
||||
the TUI shows the arguments live, then replaces/augments them with the actual
|
||||
`ToolResult` after the tool runs.
|
||||
- **Compaction** is an agent-loop context-management step; the TUI may show that
|
||||
it happened, but the purpose is to keep the next provider request within budget.
|
||||
- **Stream-JSON** is another surface over the same callbacks: `zero exec`
|
||||
serializes the events as JSON protocol events instead of Bubble Tea views.
|
||||
|
||||
So a feature-specific deep dive should usually explain: what user event starts it,
|
||||
which `tea.Msg`/callback carries it, which agent/tool event is authoritative, and
|
||||
which session events make it resumable.
|
||||
|
||||
## Runtime Provider Layer
|
||||
|
||||
Providers are hidden behind this interface in `internal/zeroruntime`:
|
||||
|
||||
```go
|
||||
type Provider interface {
|
||||
StreamCompletion(ctx context.Context, request CompletionRequest) (<-chan StreamEvent, error)
|
||||
}
|
||||
```
|
||||
|
||||
The agent loop only knows about provider-neutral messages, tool definitions,
|
||||
stream events, reasoning blocks, images, and token usage. Provider adapters in
|
||||
`internal/providers/*` translate those neutral types to provider-specific APIs:
|
||||
|
||||
- OpenAI and OpenAI-compatible APIs;
|
||||
- Anthropic and Anthropic-compatible APIs;
|
||||
- Gemini;
|
||||
- Codex / ChatGPT OAuth-backed runtime.
|
||||
|
||||
`internal/providers.New` resolves the configured model through the model
|
||||
registry/catalog, normalizes API model IDs and base URLs, attaches API keys or
|
||||
OAuth resolvers, and returns the concrete adapter.
|
||||
|
||||
## Tools and Capabilities
|
||||
|
||||
Tools implement a common interface and are registered by name in
|
||||
`internal/tools.Registry`.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
AgentToolCall[Tool call from model] --> Decode[Decode JSON args]
|
||||
Decode --> Filter[Enabled/disabled + permission-mode filter]
|
||||
Filter --> Precheck[Pre-permission rejecters]
|
||||
Precheck --> SandboxEval[Sandbox Evaluate]
|
||||
SandboxEval --> Prompt{Needs user approval?}
|
||||
Prompt -- Yes --> Decision[Permission callback]
|
||||
Prompt -- No --> Run[Registry.RunWithOptions]
|
||||
Decision --> Run
|
||||
Run --> Redact[Redact secrets]
|
||||
Redact --> Budget[Token-aware semantic output budget]
|
||||
Budget --> Ceiling[Existing hard byte ceiling]
|
||||
Ceiling --> Message[Return one tool result to model]
|
||||
```
|
||||
|
||||
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,
|
||||
skills, `ask_user`, permission requests.
|
||||
- **Write tools**: `write_file`, `edit_file`, `apply_patch`, `update_plan`.
|
||||
- **Shell tools**: `exec_command`, `write_stdin`, legacy `bash`.
|
||||
- **Network tools**: `web_fetch` and optional web search backend.
|
||||
- **Local-control tools**: config-gated wrappers for browser, terminal, desktop,
|
||||
and artifact helpers backed by packages such as `internal/localcontrol` and
|
||||
`internal/browser`.
|
||||
- **Extension tools**: MCP tools, specialists/sub-agents, swarm tools, plugins.
|
||||
|
||||
Tool definitions include safety metadata: side-effect class, permission level,
|
||||
reason, and whether prompt-gated tools are advertised in auto-like modes. The
|
||||
agent uses this metadata plus per-turn filters and deferred-loading state to
|
||||
choose which registered tools the model can see and whether a call can run
|
||||
immediately.
|
||||
|
||||
## Permission and Sandbox Model
|
||||
|
||||
Zero has two related but separate checks:
|
||||
|
||||
1. **Tool permission metadata** says whether a tool is allowed, denied, or needs
|
||||
approval.
|
||||
2. **Sandbox policy** evaluates the actual request: path scope, network access,
|
||||
destructive shell patterns, explicit escalation, persistent/session grants,
|
||||
and platform isolation availability.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Call[Tool call] --> Safety[Tool Safety metadata]
|
||||
Call --> Scope[Workspace/additional roots scope]
|
||||
Safety --> Engine[Sandbox Engine Evaluate]
|
||||
Scope --> Engine
|
||||
Engine --> Allow[Allow]
|
||||
Engine --> Prompt[Prompt user]
|
||||
Engine --> Deny[Deny]
|
||||
Prompt --> Grant[Turn/session/persistent grant]
|
||||
Grant --> Execute[Execute tool]
|
||||
Allow --> Execute
|
||||
Deny --> Result[Return denied tool result]
|
||||
```
|
||||
|
||||
The sandbox engine can:
|
||||
|
||||
- auto-allow workspace writes for safe file-edit tools;
|
||||
- block reads/writes outside the allowed scope;
|
||||
- prompt for recoverable outside-workspace access;
|
||||
- deny or prompt for network depending on policy;
|
||||
- detect destructive shell commands;
|
||||
- run shell commands through a platform backend when available;
|
||||
- remember session or persistent grants;
|
||||
- support narrow command-prefix approvals.
|
||||
|
||||
The user-facing TUI and `zero exec` mode both route permission requests through
|
||||
callbacks, but the TUI can show interactive modals while headless mode generally
|
||||
returns non-interactive fallback behavior unless a protocol/client handles it.
|
||||
|
||||
## Session Storage and History
|
||||
|
||||
Sessions live locally under Zero's data directory. A session directory contains:
|
||||
|
||||
- `metadata.json` — session ID, title, cwd, provider/model, parent/root IDs,
|
||||
spec/sub-agent fields, timestamps, and event count.
|
||||
- `events.jsonl` — append-only event log with messages, tool calls/results,
|
||||
permissions, usage, checkpoints, forks, compaction, and specs. TUI and exec use
|
||||
the richer event stream; ACP currently records conversational message history
|
||||
while streaming tool and permission updates to the client.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
TUI[TUI callbacks] --> Store[internal/sessions.Store]
|
||||
Exec[exec recorder] --> Store
|
||||
Store --> Metadata[metadata.json]
|
||||
Store --> Events[events.jsonl]
|
||||
Events --> Resume[resume]
|
||||
Events --> Fork[fork]
|
||||
Events --> Rewind[rewind/checkpoints]
|
||||
Events --> Children[parent/root metadata]
|
||||
```
|
||||
|
||||
This is why sessions can be resumed, forked, rewound, and inspected without a
|
||||
remote service. Zero stores the local event history; providers only receive the
|
||||
messages needed for the current completion request.
|
||||
|
||||
## Stream-JSON Protocol
|
||||
|
||||
`zero exec --output-format stream-json` emits newline-delimited JSON events for
|
||||
integrations and CI. The protocol is implemented in `internal/streamjson` and is
|
||||
specified in [STREAM_JSON_PROTOCOL.md](STREAM_JSON_PROTOCOL.md).
|
||||
|
||||
Common output event types include:
|
||||
|
||||
- `run_start`, `text`, `reasoning`, `tool_call`, `tool_result`;
|
||||
- `permission`, `permission_request`, `permission_decision`;
|
||||
- `checkpoint`, `restore`, `usage`, `final`, `warning`, `error`, `run_end`.
|
||||
|
||||
Input stream-JSON can provide `prompt` or `message` events and image payloads.
|
||||
The CLI parses those events, resolves a single prompt, validates/decodes images,
|
||||
and passes the result into the same agent loop used everywhere else.
|
||||
|
||||
## Extensibility Flow
|
||||
|
||||
Zero's extension surfaces are loaded before the agent loop starts:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Config[Config resolution] --> Registry[Tool registry]
|
||||
Registry --> Core[Core tools]
|
||||
Registry --> MCP[MCP server tools]
|
||||
Registry --> Specialist[Specialist / Task tools]
|
||||
Registry --> Plugin[Plugin tools]
|
||||
|
||||
Config --> Prompt[System prompt context]
|
||||
Prompt --> Guidelines[AGENTS.md / ZERO.md]
|
||||
Prompt --> Skills[Skills list]
|
||||
Prompt --> SpecialistContext[Specialist descriptions]
|
||||
|
||||
ToolLifecycle[Tool lifecycle] --> Hooks[beforeTool / afterTool hooks]
|
||||
```
|
||||
|
||||
- **Project/user guidelines** become prompt context.
|
||||
- **Skills** are instruction packs the model can load on demand.
|
||||
- **Specialists** are sub-agents callable through the `Task` tool.
|
||||
- **MCP servers** contribute external tools.
|
||||
- **Plugins** can add tools, hooks, and skill roots. Bootstrap always registers a
|
||||
multi-root skill tool: primary Zero skills dir, optional `~/.agents/skills`,
|
||||
then plugin skill roots (earlier wins). `internal/skills` owns that merge via
|
||||
`LoadFromRoots`; single-root `Load` remains for install/write.
|
||||
- **Hooks** can observe or block tool lifecycle events.
|
||||
|
||||
## End-to-End Data Flow
|
||||
|
||||
A typical interactive edit request looks like this:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as User
|
||||
participant T as TUI
|
||||
participant A as Agent loop
|
||||
participant P as Provider
|
||||
participant R as Tool registry
|
||||
participant S as Sandbox
|
||||
participant FS as Workspace/session files
|
||||
|
||||
U->>T: Type prompt and press Enter
|
||||
T->>A: agent.Run(prompt, provider, options)
|
||||
A->>P: CompletionRequest(messages, tool definitions)
|
||||
P-->>A: Stream text + tool call edit_file
|
||||
A->>S: Evaluate edit_file args
|
||||
S-->>A: allow/prompt/deny decision
|
||||
A->>T: permission callback if prompt needed
|
||||
T-->>A: user decision
|
||||
A->>R: Run edit_file
|
||||
R->>FS: Write workspace file
|
||||
R-->>A: Tool result + changed files
|
||||
A->>P: Next CompletionRequest with tool result
|
||||
P-->>A: Final answer
|
||||
A-->>T: Result + callbacks
|
||||
T->>FS: Append session events
|
||||
T-->>U: Render final transcript
|
||||
```
|
||||
|
||||
The same request in `zero exec` follows the same provider/tool/sandbox path, but
|
||||
streaming events are written to stdout/stderr instead of rendered in Bubble Tea.
|
||||
|
||||
## Mental Model
|
||||
|
||||
When you work on Zero, start by locating the change on the four-layer spine from
|
||||
[Architecture Spine](#architecture-spine) — Surface, Agent loop, Capabilities,
|
||||
Persistence — then jump to the package that owns that boundary:
|
||||
|
||||
- UI behavior? Start in `internal/tui`.
|
||||
- Command-line behavior? Start in `internal/cli`.
|
||||
- Model/tool-turn behavior? Start in `internal/agent`.
|
||||
- A new capability? Register a tool in `internal/tools` or an extension surface.
|
||||
- Safety policy? Start in `internal/sandbox` and agent permission wiring.
|
||||
- Resume/history behavior? Start in `internal/sessions` and session recorders.
|
||||
+50
-78
@@ -4,11 +4,10 @@ Zero is distributed as:
|
||||
|
||||
- an npm package, `@gitlawb/zero`
|
||||
- release archives on GitHub Releases
|
||||
- source builds with Go 1.26.5+
|
||||
- source builds with Go 1.25+
|
||||
|
||||
The install scripts download a platform-specific release archive and require a
|
||||
published GitHub Release for the requested version. The npm package is
|
||||
self-contained: the platform binary installs from the npm registry.
|
||||
The npm package and install scripts download a platform-specific release archive.
|
||||
They require a published GitHub Release for the requested version.
|
||||
|
||||
## npm
|
||||
|
||||
@@ -17,32 +16,59 @@ npm install -g @gitlawb/zero
|
||||
zero
|
||||
```
|
||||
|
||||
The package supports Linux and macOS on x64 and arm64, and Windows on x64
|
||||
(Windows on ARM runs the x64 build under emulation). It installs a small
|
||||
`zero` wrapper plus, as an optional dependency, a platform payload with the
|
||||
native binary and the bundled browser/terminal control helpers. There are no
|
||||
install scripts and nothing is downloaded from outside the npm registry, so
|
||||
the install is silent and works identically under npm, Bun, pnpm, and yarn —
|
||||
no trust or approval steps. See [NPM_PACKAGING.md](NPM_PACKAGING.md) for the
|
||||
package architecture.
|
||||
The package supports Linux, macOS, and Windows on x64 and arm64. It installs the
|
||||
`zero` command and downloads the matching release binary during `postinstall`.
|
||||
|
||||
Requirements:
|
||||
|
||||
- Node.js 18+
|
||||
- network access to npm
|
||||
- network access to npm and GitHub Releases
|
||||
|
||||
If the install skipped optional dependencies (`npm install --omit=optional`,
|
||||
or a package manager configured to do so), the wrapper falls back to
|
||||
downloading the binary from the matching GitHub Release, with checksum
|
||||
verification. The fetch is retried on each run until a binary is in place (a
|
||||
transient network failure heals itself; an install directory the current user
|
||||
cannot write to keeps failing — rerun the install or the command below with
|
||||
sufficient permissions). To trigger the fetch manually:
|
||||
## Bun
|
||||
|
||||
Bun is "default-secure" and does not run lifecycle scripts of installed
|
||||
dependencies (only the installing project's own scripts), so the `postinstall`
|
||||
that fetches the Zero binary is silently skipped. The first run then fails with
|
||||
`No native binary found next to the npm wrapper`.
|
||||
|
||||
The simplest fix is to trust the package after installing, which runs the
|
||||
blocked postinstall. This works for project and global installs:
|
||||
|
||||
```bash
|
||||
node "$(npm root -g)/@gitlawb/zero/scripts/postinstall.mjs"
|
||||
# project install
|
||||
bun add @gitlawb/zero
|
||||
bun pm trust @gitlawb/zero
|
||||
|
||||
# global install
|
||||
bun add -g @gitlawb/zero
|
||||
bun pm -g trust @gitlawb/zero
|
||||
```
|
||||
|
||||
`bun pm untrusted` (or `bun pm -g untrusted`) lists the blocked postinstalls if
|
||||
you want to inspect before trusting.
|
||||
|
||||
Alternatively, allow the postinstall to run at install time by adding the
|
||||
package to your project's `trustedDependencies` before installing:
|
||||
|
||||
```json
|
||||
{
|
||||
"trustedDependencies": ["@gitlawb/zero"]
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
bun add @gitlawb/zero
|
||||
```
|
||||
|
||||
On Bun versions that do not have `bun pm trust`, run the installer manually
|
||||
after installing:
|
||||
|
||||
```bash
|
||||
node node_modules/@gitlawb/zero/scripts/postinstall.mjs
|
||||
```
|
||||
|
||||
Reference: <https://bun.sh/docs/pm/lifecycle>
|
||||
|
||||
## Linux And macOS Script
|
||||
|
||||
Install the latest release:
|
||||
@@ -125,7 +151,7 @@ Build a local binary:
|
||||
go build -o zero ./cmd/zero
|
||||
```
|
||||
|
||||
Source builds require Go 1.26.5+.
|
||||
Source builds require Go 1.25+.
|
||||
|
||||
### Sandbox Helpers For Source Builds
|
||||
|
||||
@@ -147,61 +173,6 @@ Linux native sandboxing also requires Bubblewrap to be installed.
|
||||
|
||||
macOS uses the system sandbox and does not need an extra helper binary.
|
||||
|
||||
### Termux (Android)
|
||||
|
||||
Zero can run natively on Android via [Termux](https://termux.dev/). Build with
|
||||
`GOOS=android` to avoid the `faccessat2` syscall that is blocked by Samsung's
|
||||
seccomp filter on Android:
|
||||
|
||||
```bash
|
||||
# Install Go in Termux
|
||||
pkg install golang
|
||||
|
||||
# Build Zero for Android
|
||||
git clone https://github.com/Gitlawb/zero.git
|
||||
cd zero
|
||||
CGO_ENABLED=0 GOOS=android GOARCH=arm64 go build -ldflags="-s -w" -o zero ./cmd/zero
|
||||
|
||||
# Move into PATH
|
||||
mv zero ~/.local/bin/
|
||||
```
|
||||
|
||||
> **Why `GOOS=android`?** Go 1.26+ detects `runtime.GOOS == "android"` and skips
|
||||
> the `faccessat2` syscall inside `os/exec.findExecutable`, falling back to
|
||||
> permission-bit checks. Without this flag, Android's seccomp sends SIGSYS and
|
||||
> kills the process whenever Zero looks up a binary on `PATH` (git, sh, etc.).
|
||||
|
||||
**DNS.** Android does not expose `/etc/resolv.conf`. Go's pure-Go DNS resolver
|
||||
needs one. Use `proot` to bind-mount Termux's resolver config:
|
||||
|
||||
```bash
|
||||
pkg install proot
|
||||
proot -b "$PREFIX/etc/resolv.conf:/etc/resolv.conf" zero
|
||||
```
|
||||
|
||||
Create a wrapper at `~/.local/bin/zero` to avoid typing proot every time:
|
||||
|
||||
```bash
|
||||
#!/data/data/com.termux/files/usr/bin/bash
|
||||
exec proot -b "$PREFIX/etc/resolv.conf:/etc/resolv.conf" ~/.local/bin/zero.bin "$@"
|
||||
```
|
||||
|
||||
**Scroll.** On native Termux (not under PRoot), mouse scrolling works out of the
|
||||
box. The TUI uses Bubble Tea's `AllMotion` mouse mode by default. If you run Zero
|
||||
inside PRoot (e.g. through proot-distro), the scroll fix activates `CellMotion`
|
||||
to avoid PRoot's ptrace interference with the 1003 escape sequence.
|
||||
|
||||
**Providers.** Zero works with any OpenAI-compatible provider on Termux. For
|
||||
example, to use OpenCode Zen's free tier:
|
||||
|
||||
```bash
|
||||
zero providers add opencode \
|
||||
--name opencode \
|
||||
--model deepseek-v4-flash-free \
|
||||
--base-url https://opencode.ai/zen/v1 \
|
||||
--set-active
|
||||
```
|
||||
|
||||
Windows source builds can use the main `zero.exe` as the command runner and setup
|
||||
helper through Zero's built-in self-dispatch path. If you want a release-style
|
||||
layout anyway, build the standalone helper executables next to `zero.exe`:
|
||||
@@ -226,7 +197,8 @@ Supported targets:
|
||||
- `linux-arm64`
|
||||
- `macos-x64`
|
||||
- `macos-arm64`
|
||||
- `windows-x64` (Windows on ARM runs this build under emulation)
|
||||
- `windows-x64`
|
||||
- `windows-arm64`
|
||||
|
||||
Each archive must have a matching `.sha256` file. The install scripts download
|
||||
both files, verify the checksum, and then copy the binary into the install
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
# npm packaging
|
||||
|
||||
How `@gitlawb/zero` is put together on npm, why it is shaped this way, and the
|
||||
rules the release pipeline must follow. Read this before touching
|
||||
`package.json`, `bin/zero.js`, `scripts/postinstall.mjs`,
|
||||
`scripts/npm/build-platform-packages.mjs`, or the npm-publish steps of
|
||||
`release-artifacts.yml`.
|
||||
|
||||
## Goals
|
||||
|
||||
A `npm install -g @gitlawb/zero` must be **silent and self-contained**:
|
||||
|
||||
- No `EBADENGINE` warnings — ours or from any package in the dependency tree.
|
||||
- No install scripts anywhere in the tree, so npm's `allow-scripts` gating,
|
||||
Bun's blocked-by-default lifecycle scripts, and pnpm's strict mode all
|
||||
install zero without prompts, trust ceremonies, or broken binaries.
|
||||
- No network fetches outside the npm registry at install time. GitHub being
|
||||
down or rate-limited must not break `npm install`.
|
||||
- Browser control (`agent-browser`) and terminal control (`tuistory`) work out
|
||||
of the box.
|
||||
|
||||
## Architecture
|
||||
|
||||
The model is the one used by Codex, esbuild, and Biome: a tiny wrapper package
|
||||
plus per-platform payloads carrying the native binaries.
|
||||
|
||||
```
|
||||
@gitlawb/zero <- wrapper: bin/zero.js + optionalDependencies
|
||||
├─ @gitlawb/zero-darwin-arm64 -> npm:@gitlawb/zero@{version}-darwin-arm64
|
||||
├─ @gitlawb/zero-darwin-x64 -> npm:@gitlawb/zero@{version}-darwin-x64
|
||||
├─ @gitlawb/zero-linux-arm64 -> npm:@gitlawb/zero@{version}-linux-arm64
|
||||
├─ @gitlawb/zero-linux-x64 -> npm:@gitlawb/zero@{version}-linux-x64
|
||||
└─ @gitlawb/zero-win32-x64 -> npm:@gitlawb/zero@{version}-win32-x64
|
||||
```
|
||||
|
||||
- The platform "packages" are **versions of the same `@gitlawb/zero` package**,
|
||||
published at suffixed versions (`0.4.0-linux-x64`) and referenced through
|
||||
`npm:` aliases in `optionalDependencies`. One package name means one npm
|
||||
trusted-publisher configuration — no new publish credentials per platform.
|
||||
The alias suffixes use Node's `process.platform`/`process.arch` names so
|
||||
`bin/zero.js` can derive its platform package directly.
|
||||
- Each platform version sets `os` and `cpu`, so npm installs exactly one of
|
||||
them and skips the rest.
|
||||
- A platform payload is assembled from the platform's release archive by
|
||||
`scripts/npm/build-platform-packages.mjs` and contains the `zero` binary,
|
||||
the platform's sandbox helpers, and the vendored `helpers/` tree (see
|
||||
below). It has no `bin`, no scripts, and no dependencies — it is inert on
|
||||
its own; the wrapper execs the binary out of it.
|
||||
- The published wrapper has **no scripts and no dependencies** — the
|
||||
assembly script strips both from the repo `package.json` and injects the
|
||||
exact-version `optionalDependencies`. (The repo `package.json` keeps
|
||||
`agent-browser`/`tuistory` in `dependencies` purely as the version pins for
|
||||
the vendored helpers tree; they are never installed by consumers.)
|
||||
- There is **no windows-arm64 build** (matches the release matrix); Windows on
|
||||
ARM runs the x64 build under emulation via the first-run fallback.
|
||||
|
||||
### Binary resolution in `bin/zero.js`
|
||||
|
||||
1. Resolve `@gitlawb/zero-<platform>-<arch>` and exec the `zero` binary from
|
||||
it. This wins over any previously downloaded copy — the platform version is
|
||||
pinned to the wrapper release.
|
||||
2. Fall back to a binary previously downloaded next to the wrapper.
|
||||
3. If neither exists (`--omit=optional`, package managers that skip optional
|
||||
dependencies), run the **fallback downloader**: `scripts/postinstall.mjs`,
|
||||
the exact logic that used to run as a postinstall hook (HTTPS-only, SHA-256
|
||||
verified against the release's own checksum file, no zip-slip), invoked by
|
||||
the wrapper itself. Failures are deliberately not cached — the fetch
|
||||
retries on every run until a binary is in place, so a transient network
|
||||
error self-heals. Platforms with no release asset (anything outside the
|
||||
matrix above, including windows-arm64) skip the attempt entirely rather
|
||||
than probing the network each run.
|
||||
4. If the download is impossible too, print build-from-source guidance.
|
||||
|
||||
There is deliberately **no `scripts.postinstall`** in any published
|
||||
`package.json`. The downloader exists only as a first-run fallback.
|
||||
|
||||
### Vendored helpers (`helpers/`)
|
||||
|
||||
`agent-browser` (Apache-2.0, vercel-labs/agent-browser) and `tuistory` are
|
||||
**vendored binaries/packages inside the platform payload**, not npm
|
||||
dependencies of the wrapper:
|
||||
|
||||
- As a dependency, agent-browser's `engines: { node: ">=24", pnpm: ">=11" }`
|
||||
and postinstall script produce `EBADENGINE` and `allow-scripts` warnings for
|
||||
every installer. As a vendored tree it produces none, because npm never
|
||||
resolves it.
|
||||
- `zero-release package` already stages the helpers tree into every release
|
||||
archive (`stageLocalControlHelpers` runs `npm ci` from the repo's
|
||||
`package.json` pins + lockfile on the native builder). The assembly script
|
||||
reuses that staged tree, so npm installs and `install.sh` installs get
|
||||
identical helpers.
|
||||
- The Go binary resolves helpers from `<binary dir>/helpers/node_modules/.bin`
|
||||
on its own (`internal/localcontrol/browser.go`, `adjacentHelper`) — no
|
||||
wrapper involvement, no configuration.
|
||||
- **Symlink materialization:** `npm pack` silently drops symlinks, and npm's
|
||||
own `.bin` shims are symlinks on POSIX. The assembly script rewrites every
|
||||
`.bin` symlink into a relocatable `#!/bin/sh` exec shim and dereferences any
|
||||
other symlink, so the vendored tree survives publishing. (Verified
|
||||
empirically; nested `node_modules` under `helpers/` IS packed — only the
|
||||
package-root `node_modules` is always ignored.)
|
||||
- **Binary pruning:** agent-browser ships one ~11 MB native binary per
|
||||
platform (7 total). The assembly script keeps only the payload's own
|
||||
platform binary (plus the musl variant on linux, which its launcher detects
|
||||
at runtime) — about 65 MB saved per platform package.
|
||||
|
||||
## Publishing rules (release pipeline)
|
||||
|
||||
These are the invariants `release-artifacts.yml` must hold. Breaking the first
|
||||
one is user-visible immediately.
|
||||
|
||||
1. **Platform versions must never become `latest`.** `0.4.0-linux-x64` is a
|
||||
semver *prerelease* of `0.4.0`; publishing it without an explicit
|
||||
non-`latest` dist-tag would clobber `latest` and users would install a
|
||||
platform payload as the CLI. The workflow publishes platform versions with
|
||||
`--tag platform` and asserts `latest` survived before the wrapper publish,
|
||||
then asserts `latest` equals the wrapper version after it. Both lookups
|
||||
tolerate a failed `npm view` (registry lag, or no `latest` tag yet on a
|
||||
bootstrap publish) — a missing value must not abort the job between the
|
||||
platform and wrapper publishes, which would strand a half-published
|
||||
release.
|
||||
2. **Platform versions publish before the wrapper.** The wrapper's
|
||||
`optionalDependencies` pin exact suffixed versions; publishing the wrapper
|
||||
first would create a window where installs resolve aliases that 404.
|
||||
3. **Exact-version pinning.** The wrapper at `X.Y.Z` references platform
|
||||
versions `X.Y.Z-<platform>-<arch>` exactly — never ranges — so a wrapper
|
||||
and its binaries can never skew.
|
||||
4. All publishes go through the existing npm OIDC trusted-publishing flow and
|
||||
the `npm-publish` environment gate; no tokens. Because the platform
|
||||
payloads are versions of the same package, they reuse the single
|
||||
trusted-publisher configuration.
|
||||
|
||||
`zero update` keeps working unchanged: it detects an npm install by finding a
|
||||
`package.json` named `@gitlawb/zero` next to the running binary — true inside
|
||||
a platform payload too — and updates via `npm install -g @gitlawb/zero@latest`.
|
||||
|
||||
## Runbook: bumping the vendored helpers
|
||||
|
||||
The vendored helper versions are pinned by the repo `package.json`
|
||||
`dependencies` + `package-lock.json`; the release build vendors exactly what
|
||||
the lockfile resolves. No `^range` surprises ship: a release carries what its
|
||||
lockfile pinned at build time.
|
||||
|
||||
- Routine bump: `npm install agent-browser@<version>` (or `tuistory@…`) in the
|
||||
repo to update pin + lockfile, verify the browser/terminal tools end-to-end,
|
||||
ship with the next release.
|
||||
- Security response: if a vendored helper publishes a security fix, the bump
|
||||
is release-worthy on its own — cut a patch release of zero carrying only the
|
||||
pin change.
|
||||
- Attribution: agent-browser is Apache-2.0; its LICENSE ships inside the
|
||||
vendored package directory (`helpers/node_modules/agent-browser/`), which
|
||||
satisfies redistribution attribution.
|
||||
|
||||
## History / rationale
|
||||
|
||||
Until v0.3.x the npm package was a wrapper with a `postinstall` downloader and
|
||||
`agent-browser`/`tuistory` as regular dependencies. That produced three
|
||||
warnings on every install (`EBADENGINE` from agent-browser's `node >=24`
|
||||
engines pin, and `allow-scripts` warnings for both postinstall scripts), broke
|
||||
silently under Bun's default script blocking, and coupled `npm install` to
|
||||
GitHub Releases availability. The platform-package model removes the warnings
|
||||
structurally — there is nothing left in the tree for a package manager to warn
|
||||
about — rather than asking users to approve or suppress them.
|
||||
+10
-45
@@ -1,8 +1,8 @@
|
||||
# npm Wrapper Smoke Checklist
|
||||
|
||||
Run this checklist when a PR changes npm distribution files such as
|
||||
`package.json`, `bin/zero.js`, `scripts/npm/build-platform-packages.mjs`,
|
||||
Go build or release tooling, or the npm `bin` wrapper.
|
||||
`package.json`, `bin/zero.js`, Go build or release tooling, or the npm `bin`
|
||||
wrapper.
|
||||
|
||||
## Required Checks
|
||||
|
||||
@@ -21,52 +21,17 @@ go run ./cmd/zero-release build
|
||||
go run ./cmd/zero-release smoke
|
||||
```
|
||||
|
||||
## End-To-End Assembly Smoke
|
||||
|
||||
Build a real archive for the host platform and assemble the npm payloads from
|
||||
it (this is exactly what the publish job does):
|
||||
|
||||
```bash
|
||||
go run ./cmd/zero-release package
|
||||
node scripts/npm/build-platform-packages.mjs \
|
||||
--artifacts-dir dist/release --out-dir dist/npm --only <platform>-<arch>
|
||||
```
|
||||
|
||||
Then simulate an installed layout and run the wrapper through each resolution
|
||||
path:
|
||||
|
||||
```bash
|
||||
# platform-package path
|
||||
mkdir -p /tmp/zero-sim/node_modules/@gitlawb
|
||||
cp -R dist/npm/wrapper /tmp/zero-sim/node_modules/@gitlawb/zero
|
||||
cp -R dist/npm/platforms/zero-<platform>-<arch> \
|
||||
"/tmp/zero-sim/node_modules/@gitlawb/zero-<platform>-<arch>"
|
||||
node /tmp/zero-sim/node_modules/@gitlawb/zero/bin/zero.js --version
|
||||
|
||||
# first-run download fallback (delete the platform package first)
|
||||
rm -rf "/tmp/zero-sim/node_modules/@gitlawb/zero-<platform>-<arch>"
|
||||
node /tmp/zero-sim/node_modules/@gitlawb/zero/bin/zero.js --version
|
||||
```
|
||||
|
||||
## Checklist
|
||||
|
||||
- `package.json` has the expected package name (`@gitlawb/zero`), version,
|
||||
`bin.zero` entry, and NO `scripts` entries — the published package must be
|
||||
free of lifecycle scripts (see NPM_PACKAGING.md).
|
||||
- `scripts/npm/build-platform-packages.mjs` emits platform payloads whose
|
||||
`package.json` is `@gitlawb/zero@<version>-<platform>-<arch>` with matching
|
||||
`os`/`cpu`, contains the executable binary and the vendored `helpers/` tree
|
||||
with regular-file (non-symlink) `.bin` shims, and a wrapper payload with no
|
||||
scripts, no dependencies, and the full five-alias `optionalDependencies`
|
||||
matrix.
|
||||
- `scripts/postinstall.mjs` (the first-run fallback) resolves the correct
|
||||
release asset name/URL per platform (`ZERO_INSTALL_DRY_RUN=1` prints the
|
||||
plan), verifies the downloaded archive's SHA-256, and extracts only the
|
||||
known binary basenames into place. `ZERO_SKIP_DOWNLOAD=1` opts out cleanly
|
||||
(exit 0) and an unsupported platform/arch is a non-fatal skip.
|
||||
- The wrapper prefers the platform package binary over a previously downloaded
|
||||
one, and `agent-browser`/`tuistory` run from the platform package's
|
||||
`helpers/node_modules/.bin` shims.
|
||||
`bin.zero` entry, and exactly one `scripts` entry — the `postinstall` hook.
|
||||
- `scripts/postinstall.mjs` resolves the correct release asset name/URL per
|
||||
platform (`ZERO_INSTALL_DRY_RUN=1` prints the plan), verifies the downloaded
|
||||
archive's SHA-256, and extracts only the known binary basenames into place.
|
||||
`ZERO_SKIP_DOWNLOAD=1` opts out cleanly (exit 0) and an unsupported
|
||||
platform/arch is a non-fatal skip.
|
||||
- The wrapper binary resolves through the package `bin` entry and
|
||||
`node_modules/.bin` in a package-install smoke test.
|
||||
- The built binary exits 0 for `zero --version` or `zero --help`.
|
||||
- `zero --version` reports `zero <package.json version>`.
|
||||
- Release packaging still emits the expected archive and checksum names when
|
||||
|
||||
@@ -17,7 +17,6 @@ Zero.
|
||||
|
||||
## Maintainer Docs
|
||||
|
||||
- [How Zero Works](HOW_ZERO_WORKS.md)
|
||||
- [npm wrapper smoke checklist](NPM_WRAPPER_SMOKE.md)
|
||||
- [Benchmarks](BENCHMARK.md)
|
||||
- [Performance benchmarks](PERFORMANCE.md)
|
||||
|
||||
@@ -41,7 +41,6 @@ events when present.
|
||||
|
||||
```json
|
||||
{ "schemaVersion": 2, "type": "run_start", "runId": "run_20260603_abc123", "sessionId": "zero_20260603100000_abc123", "cwd": "/repo", "provider": "openai", "model": "gpt-4.1", "apiModel": "gpt-4.1" }
|
||||
{ "schemaVersion": 2, "type": "reasoning", "runId": "run_20260603_abc123", "delta": "Thinking..." }
|
||||
{ "schemaVersion": 2, "type": "text", "runId": "run_20260603_abc123", "delta": "..." }
|
||||
{ "schemaVersion": 2, "type": "tool_call", "runId": "run_20260603_abc123", "id": "call_1", "name": "read_file", "args": { "path": "README.md" }, "sideEffect": "read" }
|
||||
{ "schemaVersion": 2, "type": "permission_request", "runId": "run_20260603_abc123", "id": "call_2", "name": "write_file", "action": "prompt", "permission": "prompt", "permissionMode": "ask", "sideEffect": "write", "reason": "Creates or overwrites files." }
|
||||
@@ -52,10 +51,6 @@ events when present.
|
||||
{ "schemaVersion": 2, "type": "run_end", "runId": "run_20260603_abc123", "status": "success", "exitCode": 0 }
|
||||
```
|
||||
|
||||
`reasoning` events carry live model reasoning/status deltas for providers that
|
||||
stream them separately from answer text. They are liveness/progress events only:
|
||||
they are not folded into `text` or the final answer.
|
||||
|
||||
Permission events may include structured sandbox metadata:
|
||||
|
||||
```json
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
# TUI Themes
|
||||
|
||||
Zero's TUI ships a set of built-in color themes. Pick one with `--theme <name>`,
|
||||
the `ZERO_THEME` environment variable, or the `/theme <name>` command while
|
||||
running. `auto` (the default) follows the terminal's detected background.
|
||||
|
||||
Run `/theme` with no argument to open a picker: move through the list to
|
||||
live-preview each theme, press Enter to apply the highlighted one, or Esc to
|
||||
cancel and restore the previously active theme. Run `/theme list` to print
|
||||
the active theme and the registered names without opening the picker.
|
||||
|
||||
## Dune (`dune`)
|
||||
|
||||
A warm sand-and-cream palette: sand/cream surface, charcoal ink, and a soft
|
||||
amber accent.
|
||||
|
||||
## Neon (`neon`)
|
||||
|
||||
A neon-on-black palette: pitch-black surface, bright green ink, and a cyan accent.
|
||||
|
||||
## Adding a theme
|
||||
|
||||
Every theme is a `palette` (a table of color hex tokens) plus one entry in
|
||||
`themeRegistry`, both in `internal/tui/theme_palettes.go`. `buildTheme` in
|
||||
`internal/tui/theme.go` turns a `palette` into the resolved `lipgloss.Style`
|
||||
set every renderer reads from the active `zeroTheme`. Adding a theme means
|
||||
adding a new `palette{...}` literal, a `themeRegistry` entry, and test
|
||||
coverage for the new palette (see below).
|
||||
|
||||
Registry-wide tests in `internal/tui/theme_select_test.go` assert the basic
|
||||
WCAG AA text tokens, the gray-ramp order, the diff word-span pairs, and the
|
||||
selected-row band for every entry. The rendered-surface invariants beyond
|
||||
those (permission surfaces, selected-row secondary text, diff gutters, and
|
||||
the xterm-256 downsampling checks) are asserted per palette, not against the
|
||||
whole registry: `TestExtendedThemeContrastInvariants` and
|
||||
`TestExtendedThemeANSI256Contrast` enumerate the palettes they cover. A new
|
||||
theme must be added to those tests (or given equivalent palette-specific
|
||||
assertions), or CI can stay green while its permission, selected-row, and
|
||||
diff surfaces ship unreadable.
|
||||
@@ -1,31 +1,32 @@
|
||||
module github.com/Gitlawb/zero
|
||||
|
||||
go 1.26.5
|
||||
go 1.25.0
|
||||
|
||||
toolchain go1.26.4
|
||||
|
||||
require (
|
||||
charm.land/bubbles/v2 v2.1.1
|
||||
charm.land/bubbletea/v2 v2.0.8
|
||||
charm.land/lipgloss/v2 v2.0.5
|
||||
charm.land/bubbles/v2 v2.1.0
|
||||
charm.land/bubbletea/v2 v2.0.7
|
||||
charm.land/lipgloss/v2 v2.0.4
|
||||
github.com/alecthomas/chroma/v2 v2.27.0
|
||||
github.com/atotto/clipboard v0.1.4
|
||||
github.com/aymanbagabas/go-udiff v0.4.1
|
||||
github.com/charmbracelet/colorprofile v0.4.3
|
||||
github.com/charmbracelet/x/ansi v0.11.7
|
||||
github.com/charmbracelet/x/term v0.2.2
|
||||
github.com/coder/websocket v1.8.15
|
||||
github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728
|
||||
golang.org/x/sys v0.47.0
|
||||
golang.org/x/sys v0.46.0
|
||||
mvdan.cc/sh/v3 v3.13.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7 // indirect
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260615092913-2399af76d5b1 // indirect
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20260615092313-b57e5e6d29bb // indirect
|
||||
github.com/charmbracelet/x/termios v0.1.1 // indirect
|
||||
github.com/charmbracelet/x/windows v0.2.2 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||
github.com/dlclark/regexp2/v2 v2.5.0 // indirect
|
||||
github.com/dlclark/regexp2/v2 v2.2.1 // indirect
|
||||
github.com/go-quicktest/qt v1.102.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.24 // indirect
|
||||
@@ -34,5 +35,5 @@ require (
|
||||
github.com/rogpeppe/go-internal v1.15.0 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/exp v0.0.0-20260611194520-c48552f49976 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/sync v0.21.0 // indirect
|
||||
)
|
||||
@@ -1,9 +1,9 @@
|
||||
charm.land/bubbles/v2 v2.1.1 h1:7r55WzBxpo/R3z98hGmY7KKPd3ET6vsf0Fb9sDHOV60=
|
||||
charm.land/bubbles/v2 v2.1.1/go.mod h1:GE6M31gaWZVXzGw73OeuTTgy4lX+OtkH0E5ymnNsHxo=
|
||||
charm.land/bubbletea/v2 v2.0.8 h1:SxTJMhCAI3lbPmy4SgX5LWZ24AdINr4I6UEqzZvYJuY=
|
||||
charm.land/bubbletea/v2 v2.0.8/go.mod h1:2SkdgoTXluXJHOUwAoRlRXF/28vklb1rFl6GcgV1/ss=
|
||||
charm.land/lipgloss/v2 v2.0.5 h1:kbNxgeeUOYv5J0YdpxFjfvf3dFvqH8Aci4zB6xqFtrY=
|
||||
charm.land/lipgloss/v2 v2.0.5/go.mod h1:9oqhxt4yxIMe6q5A4kHr44DremZk7J9UNh74GlWa5nc=
|
||||
charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g=
|
||||
charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY=
|
||||
charm.land/bubbletea/v2 v2.0.7 h1:7qw2tTAVar7m7klOPBYfTB0mniv/RuexsYwMRNxSeL0=
|
||||
charm.land/bubbletea/v2 v2.0.7/go.mod h1:DGW2q8gvzHnOpMpZTORs0aySVHCox5C+2Svk0fci1qs=
|
||||
charm.land/lipgloss/v2 v2.0.4 h1:lcPeVtcp23SNra7lHy8iYE4UC2aIipVQ47sbGyyxR5Q=
|
||||
charm.land/lipgloss/v2 v2.0.4/go.mod h1:0653x8epbZSzdDfO/XPS1a/uYPOBeSsCssOpJOqDzik=
|
||||
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||
github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs=
|
||||
@@ -16,8 +16,8 @@ github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ
|
||||
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
|
||||
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
|
||||
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7 h1:3FmWoGNWK4STvqg0O0Aeav2T7rodWJAPeF0QpH+8gFw=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7/go.mod h1:f/jRa757WUmaOZrbPspXymbg/GnbF+rwe4OLsG7aXYo=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260615092913-2399af76d5b1 h1:4+r3uOJ69ueRBt4okgEfWZeXs3BD36HcDBmOIAUlETk=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260615092913-2399af76d5b1/go.mod h1:f/jRa757WUmaOZrbPspXymbg/GnbF+rwe4OLsG7aXYo=
|
||||
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
|
||||
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20260615092313-b57e5e6d29bb h1:m1Uub9uM7meZ8ofVyeRfK7M/knCi3sF2gKn9kldVW0I=
|
||||
@@ -32,10 +32,8 @@ github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSE
|
||||
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
|
||||
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||
github.com/dlclark/regexp2/v2 v2.5.0 h1:liNiWIPCvCh5HBcYfsjd+P16AG79fwd6T1Toy2gOtEA=
|
||||
github.com/dlclark/regexp2/v2 v2.5.0/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
|
||||
github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0=
|
||||
github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
|
||||
github.com/go-quicktest/qt v1.102.0 h1:HSQxCeh5YZH3EL3W39ixjtyaEhcWSXQHtHnMBzSs474=
|
||||
github.com/go-quicktest/qt v1.102.0/go.mod h1:p4lGIVX+8Wa6ZPNDvqcxq36XpUDLh42FLetFU7odllI=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
@@ -62,9 +60,9 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M=
|
||||
golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
mvdan.cc/sh/v3 v3.13.1 h1:DP3TfgZhDkT7lerUdnp6PTGKyxxzz6T+cOlY/xEvfWk=
|
||||
mvdan.cc/sh/v3 v3.13.1/go.mod h1:lXJ8SexMvEVcHCoDvAGLZgFJ9Wsm2sulmoNEXGhYZD0=
|
||||
+48
-232
@@ -5,15 +5,11 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/providercatalog"
|
||||
"github.com/Gitlawb/zero/internal/providermodelcatalog"
|
||||
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
@@ -26,10 +22,9 @@ import (
|
||||
// means the editor only hosts the thread — ZERO owns BYOK and telemetry-free
|
||||
// operation.
|
||||
type Deps struct {
|
||||
ResolveConfig func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error)
|
||||
DiscoverModels func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error)
|
||||
NewProvider func(profile config.ProviderProfile) (zeroruntime.Provider, error)
|
||||
RunAgent func(ctx context.Context, prompt string, provider zeroruntime.Provider, opts agent.Options) (agent.Result, error)
|
||||
ResolveConfig func(workspaceRoot string, overrides config.Overrides) (config.ResolvedConfig, error)
|
||||
NewProvider func(profile config.ProviderProfile) (zeroruntime.Provider, error)
|
||||
RunAgent func(ctx context.Context, prompt string, provider zeroruntime.Provider, opts agent.Options) (agent.Result, error)
|
||||
// BuildWorkspace builds the SCOPED tool registry and the sandbox engine for a
|
||||
// validated workspace root, so ACP shell tools (bash/exec_command) are confined
|
||||
// exactly like the exec surface — never run unconfined on the host.
|
||||
@@ -64,16 +59,13 @@ type acpSession struct {
|
||||
// turnMu serializes prompt turns for one session: concurrent session/prompt
|
||||
// calls run one at a time so they can't interleave history or clobber the
|
||||
// single cancel slot.
|
||||
turnMu sync.Mutex
|
||||
modelMu sync.Mutex
|
||||
turnMu sync.Mutex
|
||||
|
||||
mu sync.Mutex
|
||||
mode agent.PermissionMode
|
||||
model string
|
||||
models []SessionConfigOptionValue
|
||||
restrictModels bool
|
||||
cancel context.CancelFunc
|
||||
history []turnRecord
|
||||
mu sync.Mutex
|
||||
mode agent.PermissionMode
|
||||
model string // override; "" => config default
|
||||
cancel context.CancelFunc
|
||||
history []turnRecord
|
||||
}
|
||||
|
||||
// NewAgent builds the ACP server and registers its method handlers on conn.
|
||||
@@ -90,6 +82,9 @@ func NewAgent(conn *Conn, deps Deps) *Agent {
|
||||
return a
|
||||
}
|
||||
|
||||
// Serve runs the connection read loop until the stream closes or ctx is done.
|
||||
func (a *Agent) Serve(ctx context.Context) error { return a.conn.Serve(ctx) }
|
||||
|
||||
// ---- initialize ----
|
||||
|
||||
func (a *Agent) handleInitialize(_ context.Context, params json.RawMessage) (any, error) {
|
||||
@@ -124,7 +119,7 @@ func (a *Agent) handleInitialize(_ context.Context, params json.RawMessage) (any
|
||||
|
||||
// ---- session lifecycle ----
|
||||
|
||||
func (a *Agent) handleSessionNew(ctx context.Context, params json.RawMessage) (any, error) {
|
||||
func (a *Agent) handleSessionNew(_ context.Context, params json.RawMessage) (any, error) {
|
||||
var p NewSessionParams
|
||||
if err := json.Unmarshal(params, &p); err != nil {
|
||||
return nil, RPCError(codeInvalidParams, "invalid session/new params")
|
||||
@@ -133,23 +128,18 @@ func (a *Agent) handleSessionNew(ctx context.Context, params json.RawMessage) (a
|
||||
if err != nil {
|
||||
return nil, RPCError(codeInvalidParams, err.Error())
|
||||
}
|
||||
model, models, restrictModels, err := a.resolveModelChoices(ctx, root)
|
||||
if err != nil {
|
||||
return nil, RPCError(codeInternalError, "config: "+err.Error())
|
||||
}
|
||||
meta, err := a.deps.Store.Create(sessions.CreateInput{Title: "ACP session", Cwd: root, ModelID: model})
|
||||
meta, err := a.deps.Store.Create(sessions.CreateInput{Title: "ACP session", Cwd: root})
|
||||
if err != nil {
|
||||
return nil, RPCError(codeInternalError, "create session: "+err.Error())
|
||||
}
|
||||
sess := a.registerSession(meta.SessionID, root, nil, model, models, restrictModels)
|
||||
sess := a.registerSession(meta.SessionID, root, nil)
|
||||
return NewSessionResult{
|
||||
SessionID: sess.id,
|
||||
ConfigOptions: a.configOptions(sess),
|
||||
Modes: a.modeState(sess),
|
||||
SessionID: sess.id,
|
||||
Modes: a.modeState(sess),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *Agent) handleSessionLoad(ctx context.Context, params json.RawMessage) (any, error) {
|
||||
func (a *Agent) handleSessionLoad(_ context.Context, params json.RawMessage) (any, error) {
|
||||
var p LoadSessionParams
|
||||
if err := json.Unmarshal(params, &p); err != nil {
|
||||
return nil, RPCError(codeInvalidParams, "invalid session/load params")
|
||||
@@ -169,27 +159,10 @@ func (a *Agent) handleSessionLoad(ctx context.Context, params json.RawMessage) (
|
||||
// Load history BEFORE publishing the session so no concurrent prompt observes
|
||||
// a half-initialized session (registerSession sets history under the lock and
|
||||
// reuses an already-live session rather than orphaning its in-flight turn).
|
||||
history, historyErr := a.loadHistory(meta.SessionID)
|
||||
model, models, restrictModels, err := a.resolveModelChoices(ctx, root)
|
||||
if err != nil {
|
||||
return nil, RPCError(codeInternalError, "config: "+err.Error())
|
||||
}
|
||||
if persistedModel := strings.TrimSpace(meta.ModelID); persistedModel != "" && (!restrictModels || modelChoiceExists(models, persistedModel)) {
|
||||
model = persistedModel
|
||||
if !modelChoiceExists(models, persistedModel) {
|
||||
models = append(models, SessionConfigOptionValue{Value: persistedModel, Name: persistedModel})
|
||||
}
|
||||
}
|
||||
sess := a.registerSession(meta.SessionID, root, history, model, models, restrictModels)
|
||||
a.warnPersistence(
|
||||
¬ifier{conn: a.conn, sessionID: sess.id},
|
||||
"load session history",
|
||||
"Could not load session history. The session is open, but earlier turns may be missing until storage recovers.",
|
||||
historyErr,
|
||||
)
|
||||
history := a.loadHistory(meta.SessionID)
|
||||
sess := a.registerSession(meta.SessionID, root, history)
|
||||
return LoadSessionResult{
|
||||
ConfigOptions: a.configOptions(sess),
|
||||
Modes: a.modeState(sess),
|
||||
Modes: a.modeState(sess),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -280,14 +253,7 @@ func (a *Agent) runTurn(ctx context.Context, sess *acpSession, userText string,
|
||||
if stopErr != nil {
|
||||
return "", RPCError(codeInternalError, stopErr.Error())
|
||||
}
|
||||
if err := a.persistTurn(sess, userText, result.FinalAnswer); err != nil {
|
||||
a.warnPersistence(
|
||||
note,
|
||||
"save session history",
|
||||
"Could not save session history. This turn is available in memory, but future resume may miss it until storage recovers.",
|
||||
err,
|
||||
)
|
||||
}
|
||||
a.persistTurn(sess, userText, result.FinalAnswer)
|
||||
return reason, nil
|
||||
}
|
||||
|
||||
@@ -374,27 +340,11 @@ func (a *Agent) handleSetConfigOption(_ context.Context, params json.RawMessage)
|
||||
if sess == nil {
|
||||
return nil, RPCError(codeInvalidParams, "unknown session: "+p.SessionID)
|
||||
}
|
||||
switch p.ConfigID {
|
||||
case configIDModel:
|
||||
model := strings.TrimSpace(p.Value)
|
||||
if err := a.updateModel(sess, model, sess.restrictModels); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case configIDMode:
|
||||
mode := agent.PermissionMode(p.Value)
|
||||
switch mode {
|
||||
case agent.PermissionModeAuto, agent.PermissionModeAsk:
|
||||
sess.setMode(mode)
|
||||
(¬ifier{conn: a.conn, sessionID: sess.id}).currentMode(string(mode))
|
||||
case agent.PermissionModeUnsafe:
|
||||
return nil, RPCError(codeInvalidParams, "mode not permitted over ACP: "+p.Value)
|
||||
default:
|
||||
return nil, RPCError(codeInvalidParams, "unknown mode: "+p.Value)
|
||||
}
|
||||
default:
|
||||
if p.ConfigID != configIDModel {
|
||||
return nil, RPCError(codeInvalidParams, "unknown config option: "+p.ConfigID)
|
||||
}
|
||||
return SetSessionConfigOptionResult{ConfigOptions: a.configOptions(sess)}, nil
|
||||
sess.setModel(p.Value)
|
||||
return SetSessionConfigOptionResult{}, nil
|
||||
}
|
||||
|
||||
func (a *Agent) handleZeroSetModel(_ context.Context, params json.RawMessage) (any, error) {
|
||||
@@ -406,24 +356,8 @@ func (a *Agent) handleZeroSetModel(_ context.Context, params json.RawMessage) (a
|
||||
if sess == nil {
|
||||
return nil, RPCError(codeInvalidParams, "unknown session: "+p.SessionID)
|
||||
}
|
||||
model := strings.TrimSpace(p.Model)
|
||||
if err := a.updateModel(sess, model, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ZeroSetModelResult{Model: model}, nil
|
||||
}
|
||||
|
||||
func (a *Agent) updateModel(sess *acpSession, model string, restrictModels bool) error {
|
||||
sess.modelMu.Lock()
|
||||
defer sess.modelMu.Unlock()
|
||||
if restrictModels && !sess.hasModel(model) {
|
||||
return RPCError(codeInvalidParams, "unknown model: "+model)
|
||||
}
|
||||
if _, err := a.deps.Store.UpdateModel(sess.id, model); err != nil {
|
||||
return RPCError(codeInternalError, "save model selection: "+err.Error())
|
||||
}
|
||||
sess.setModel(model)
|
||||
return nil
|
||||
sess.setModel(p.Model)
|
||||
return ZeroSetModelResult{Model: p.Model}, nil
|
||||
}
|
||||
|
||||
func (a *Agent) handleCancel(_ context.Context, params json.RawMessage) {
|
||||
@@ -450,106 +384,31 @@ func (a *Agent) modeState(s *acpSession) *SessionModeState {
|
||||
}
|
||||
}
|
||||
|
||||
// resolveModelChoices advertises authenticated live models when discovery is
|
||||
// available. The configured model is always retained as the sole fallback.
|
||||
func (a *Agent) resolveModelChoices(ctx context.Context, cwd string) (string, []SessionConfigOptionValue, bool, error) {
|
||||
resolved, err := a.deps.ResolveConfig(cwd, config.Overrides{})
|
||||
if err != nil {
|
||||
return "", nil, false, err
|
||||
}
|
||||
selected := strings.TrimSpace(resolved.Provider.Model)
|
||||
options := make([]SessionConfigOptionValue, 0, 8)
|
||||
seen := make(map[string]bool)
|
||||
add := func(id, description string) {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" || seen[id] {
|
||||
return
|
||||
}
|
||||
seen[id] = true
|
||||
options = append(options, SessionConfigOptionValue{Value: id, Name: id, Description: description})
|
||||
}
|
||||
add(selected, "")
|
||||
descriptor, knownProvider := providercatalog.Get(resolved.Provider.CatalogID)
|
||||
restrictModels := knownProvider && !descriptor.Custom
|
||||
if a.deps.DiscoverModels != nil {
|
||||
discovered, discoverErr := a.deps.DiscoverModels(ctx, resolved.Provider)
|
||||
if ctx.Err() != nil {
|
||||
return "", nil, false, ctx.Err()
|
||||
}
|
||||
if discoverErr == nil && len(discovered) > 0 {
|
||||
for _, model := range discovered {
|
||||
if providermodelcatalog.ModelIDAllowedForProvider(resolved.Provider.CatalogID, model.ID) {
|
||||
add(model.ID, model.Description)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return selected, options, restrictModels, nil
|
||||
}
|
||||
|
||||
func modelChoiceExists(models []SessionConfigOptionValue, model string) bool {
|
||||
for _, option := range models {
|
||||
if option.Value == model {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (a *Agent) configOptions(s *acpSession) []SessionConfigOption {
|
||||
model, models, mode := s.configState()
|
||||
return []SessionConfigOption{{
|
||||
ID: configIDModel,
|
||||
Name: "Model",
|
||||
Description: "Model used for this session.",
|
||||
Category: configCategoryModel,
|
||||
Type: configOptionTypeSelect,
|
||||
CurrentValue: model,
|
||||
Options: models,
|
||||
}, {
|
||||
ID: configIDMode,
|
||||
Name: "Mode",
|
||||
Description: "Permission mode used for this session.",
|
||||
Category: configCategoryMode,
|
||||
Type: configOptionTypeSelect,
|
||||
CurrentValue: string(mode),
|
||||
Options: []SessionConfigOptionValue{
|
||||
{Value: string(agent.PermissionModeAuto), Name: "Auto", Description: "Run safe tools automatically; ask before risky ones."},
|
||||
{Value: string(agent.PermissionModeAsk), Name: "Ask", Description: "Ask before every tool that changes state."},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
// ---- persistence + continuity ----
|
||||
|
||||
func (a *Agent) persistTurn(sess *acpSession, user, assistant string) error {
|
||||
defer sess.appendHistory(turnRecord{user: user, assistant: assistant})
|
||||
func (a *Agent) persistTurn(sess *acpSession, user, assistant string) {
|
||||
if a.deps.Store != nil {
|
||||
_, _ = a.deps.Store.AppendEvent(sess.id, sessions.AppendEventInput{
|
||||
Type: sessions.EventMessage,
|
||||
Payload: map[string]any{"role": "user", "content": user},
|
||||
})
|
||||
if assistant != "" {
|
||||
_, _ = a.deps.Store.AppendEvent(sess.id, sessions.AppendEventInput{
|
||||
Type: sessions.EventMessage,
|
||||
Payload: map[string]any{"role": "assistant", "content": assistant},
|
||||
})
|
||||
}
|
||||
}
|
||||
sess.appendHistory(turnRecord{user: user, assistant: assistant})
|
||||
}
|
||||
|
||||
func (a *Agent) loadHistory(sessionID string) []turnRecord {
|
||||
if a.deps.Store == nil {
|
||||
return nil
|
||||
}
|
||||
events := []sessions.AppendEventInput{
|
||||
{
|
||||
Type: sessions.EventMessage,
|
||||
Payload: map[string]any{"role": "user", "content": user},
|
||||
},
|
||||
}
|
||||
if assistant != "" {
|
||||
events = append(events, sessions.AppendEventInput{
|
||||
Type: sessions.EventMessage,
|
||||
Payload: map[string]any{"role": "assistant", "content": assistant},
|
||||
})
|
||||
}
|
||||
_, err := a.deps.Store.AppendEvents(sess.id, events)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *Agent) loadHistory(sessionID string) ([]turnRecord, error) {
|
||||
if a.deps.Store == nil {
|
||||
return nil, nil
|
||||
}
|
||||
events, err := a.deps.Store.ReadEvents(sessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil
|
||||
}
|
||||
var records []turnRecord
|
||||
var pendingUser string
|
||||
@@ -585,21 +444,7 @@ func (a *Agent) loadHistory(sessionID string) ([]turnRecord, error) {
|
||||
if havePending {
|
||||
records = append(records, turnRecord{user: pendingUser})
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
|
||||
func (a *Agent) warnPersistence(note *notifier, action string, message string, err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
sessionID := ""
|
||||
if note != nil {
|
||||
sessionID = note.sessionID
|
||||
}
|
||||
log.Printf("zero acp: failed to %s for session %s: %v", action, sessionID, err)
|
||||
if note != nil {
|
||||
note.text("\n\n[zero warning] " + message + "\n")
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
// buildPrompt prepends prior conversation as context, since agent.Run drives a
|
||||
@@ -647,13 +492,13 @@ func promptImages(blocks []ContentBlock) []zeroruntime.ImageBlock {
|
||||
// session is returned unchanged rather than orphaning its turn or resetting its
|
||||
// mode/model. history is set BEFORE publishing so no concurrent prompt can read a
|
||||
// half-initialized session.
|
||||
func (a *Agent) registerSession(id, cwd string, history []turnRecord, model string, models []SessionConfigOptionValue, restrictModels bool) *acpSession {
|
||||
func (a *Agent) registerSession(id, cwd string, history []turnRecord) *acpSession {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if existing := a.sessions[id]; existing != nil {
|
||||
return existing
|
||||
}
|
||||
sess := &acpSession{id: id, cwd: cwd, mode: agent.PermissionModeAuto, model: model, models: models, restrictModels: restrictModels, history: history}
|
||||
sess := &acpSession{id: id, cwd: cwd, mode: agent.PermissionModeAuto, history: history}
|
||||
a.sessions[id] = sess
|
||||
return sess
|
||||
}
|
||||
@@ -692,18 +537,7 @@ func (s *acpSession) currentMode() agent.PermissionMode {
|
||||
}
|
||||
|
||||
func (s *acpSession) setModel(model string) {
|
||||
model = strings.TrimSpace(model)
|
||||
s.mu.Lock()
|
||||
found := false
|
||||
for _, option := range s.models {
|
||||
if option.Value == model {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found && model != "" {
|
||||
s.models = append(s.models, SessionConfigOptionValue{Value: model, Name: model})
|
||||
}
|
||||
s.model = model
|
||||
s.mu.Unlock()
|
||||
}
|
||||
@@ -714,24 +548,6 @@ func (s *acpSession) currentModel() string {
|
||||
return s.model
|
||||
}
|
||||
|
||||
func (s *acpSession) hasModel(model string) bool {
|
||||
model = strings.TrimSpace(model)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for _, option := range s.models {
|
||||
if option.Value == model {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *acpSession) configState() (string, []SessionConfigOptionValue, agent.PermissionMode) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.model, append([]SessionConfigOptionValue(nil), s.models...), s.mode
|
||||
}
|
||||
|
||||
func (s *acpSession) appendHistory(rec turnRecord) {
|
||||
s.mu.Lock()
|
||||
s.history = append(s.history, rec)
|
||||
|
||||
+1
-284
@@ -3,18 +3,14 @@ package acp
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
@@ -139,12 +135,6 @@ func TestACPEndToEndPrompt(t *testing.T) {
|
||||
if newRes.Modes == nil || newRes.Modes.CurrentModeID != string(agent.PermissionModeAuto) {
|
||||
t.Fatalf("expected auto mode, got %+v", newRes.Modes)
|
||||
}
|
||||
if len(newRes.ConfigOptions) != 2 || newRes.ConfigOptions[0].ID != configIDModel || newRes.ConfigOptions[0].CurrentValue != "fake-model" {
|
||||
t.Fatalf("model config option = %+v, want fake-model fallback", newRes.ConfigOptions)
|
||||
}
|
||||
if newRes.ConfigOptions[1].ID != configIDMode || newRes.ConfigOptions[1].CurrentValue != string(agent.PermissionModeAuto) {
|
||||
t.Fatalf("mode config option = %+v", newRes.ConfigOptions[1])
|
||||
}
|
||||
|
||||
// session/prompt
|
||||
var promptRes PromptResult
|
||||
@@ -164,201 +154,6 @@ func TestACPEndToEndPrompt(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestACPModelConfigOptionsCatalogSelectionAndLoad(t *testing.T) {
|
||||
deps := testDeps(t)
|
||||
deps.ResolveConfig = func(_ string, o config.Overrides) (config.ResolvedConfig, error) {
|
||||
model := "gpt-5.5"
|
||||
if o.Provider.Model != "" {
|
||||
model = o.Provider.Model
|
||||
}
|
||||
return config.ResolvedConfig{Provider: config.ProviderProfile{
|
||||
Name: "ChatGPT", CatalogID: "chatgpt", Model: model,
|
||||
}}, nil
|
||||
}
|
||||
deps.DiscoverModels = func(_ context.Context, _ config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
|
||||
return []providermodeldiscovery.Model{
|
||||
{ID: " gpt-5.4-mini ", Description: "Fast"},
|
||||
{ID: "gpt-5.5", Description: "duplicate configured model"},
|
||||
}, nil
|
||||
}
|
||||
h := newHarness(t, deps)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var created NewSessionResult
|
||||
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir()}, &created); err != nil {
|
||||
t.Fatalf("session/new: %v", err)
|
||||
}
|
||||
option := created.ConfigOptions[0]
|
||||
if option.CurrentValue != "gpt-5.5" || len(option.Options) < 2 {
|
||||
t.Fatalf("new model option = %+v", option)
|
||||
}
|
||||
for _, choice := range option.Options {
|
||||
if choice.Name != choice.Value {
|
||||
t.Fatalf("model choice name = %q, want model id %q", choice.Name, choice.Value)
|
||||
}
|
||||
}
|
||||
var selected SetSessionConfigOptionResult
|
||||
if err := h.client.Call(ctx, MethodSessionSetConfigOption, SetSessionConfigOptionParams{
|
||||
SessionID: created.SessionID, ConfigID: configIDModel, Value: " gpt-5.4-mini ",
|
||||
}, &selected); err != nil {
|
||||
t.Fatalf("set_config_option: %v", err)
|
||||
}
|
||||
if got := selected.ConfigOptions[0].CurrentValue; got != "gpt-5.4-mini" {
|
||||
t.Fatalf("selected model = %q", got)
|
||||
}
|
||||
if err := h.client.Call(ctx, MethodSessionSetConfigOption, SetSessionConfigOptionParams{
|
||||
SessionID: created.SessionID, ConfigID: configIDModel, Value: "not-advertised",
|
||||
}, &SetSessionConfigOptionResult{}); err == nil {
|
||||
t.Fatal("unknown standard model selection was accepted")
|
||||
}
|
||||
h.stop()
|
||||
h = newHarness(t, deps)
|
||||
defer h.stop()
|
||||
var loaded LoadSessionResult
|
||||
if err := h.client.Call(ctx, MethodSessionLoad, LoadSessionParams{SessionID: created.SessionID}, &loaded); err != nil {
|
||||
t.Fatalf("session/load: %v", err)
|
||||
}
|
||||
if len(loaded.ConfigOptions) != 2 || loaded.ConfigOptions[0].CurrentValue != "gpt-5.4-mini" {
|
||||
t.Fatalf("load model option = %+v", loaded.ConfigOptions)
|
||||
}
|
||||
if loaded.ConfigOptions[1].CurrentValue != string(agent.PermissionModeAuto) {
|
||||
t.Fatalf("load mode option = %+v", loaded.ConfigOptions[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestACPModelDiscoveryFailureUsesConfiguredFallbackOnly(t *testing.T) {
|
||||
deps := testDeps(t)
|
||||
deps.ResolveConfig = func(_ string, _ config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.ResolvedConfig{Provider: config.ProviderProfile{
|
||||
Name: "ChatGPT", CatalogID: "chatgpt", Model: " configured-model ",
|
||||
}}, nil
|
||||
}
|
||||
deps.DiscoverModels = func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
|
||||
return nil, fmt.Errorf("discovery unavailable")
|
||||
}
|
||||
h := newHarness(t, deps)
|
||||
defer h.stop()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var created NewSessionResult
|
||||
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir()}, &created); err != nil {
|
||||
t.Fatalf("session/new: %v", err)
|
||||
}
|
||||
option := created.ConfigOptions[0]
|
||||
if option.CurrentValue != "configured-model" || len(option.Options) != 1 || option.Options[0].Value != "configured-model" {
|
||||
t.Fatalf("fallback model option = %+v", option)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACPCustomProviderAllowsUnadvertisedModel(t *testing.T) {
|
||||
deps := testDeps(t)
|
||||
deps.ResolveConfig = func(_ string, o config.Overrides) (config.ResolvedConfig, error) {
|
||||
model := "configured-model"
|
||||
if o.Provider.Model != "" {
|
||||
model = o.Provider.Model
|
||||
}
|
||||
return config.ResolvedConfig{Provider: config.ProviderProfile{
|
||||
Name: "Custom", CatalogID: "custom-openai-compatible", Model: model,
|
||||
}}, nil
|
||||
}
|
||||
h := newHarness(t, deps)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var created NewSessionResult
|
||||
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir()}, &created); err != nil {
|
||||
t.Fatalf("session/new: %v", err)
|
||||
}
|
||||
var selected SetSessionConfigOptionResult
|
||||
if err := h.client.Call(ctx, MethodSessionSetConfigOption, SetSessionConfigOptionParams{
|
||||
SessionID: created.SessionID, ConfigID: configIDModel, Value: " vendor/model ",
|
||||
}, &selected); err != nil {
|
||||
t.Fatalf("set custom model: %v", err)
|
||||
}
|
||||
if got := selected.ConfigOptions[0].CurrentValue; got != "vendor/model" {
|
||||
t.Fatalf("custom model = %q", got)
|
||||
}
|
||||
h.stop()
|
||||
h = newHarness(t, deps)
|
||||
defer h.stop()
|
||||
var loaded LoadSessionResult
|
||||
if err := h.client.Call(ctx, MethodSessionLoad, LoadSessionParams{SessionID: created.SessionID}, &loaded); err != nil {
|
||||
t.Fatalf("session/load: %v", err)
|
||||
}
|
||||
option := loaded.ConfigOptions[0]
|
||||
if option.CurrentValue != "vendor/model" || !modelChoiceExists(option.Options, "vendor/model") {
|
||||
t.Fatalf("loaded custom model option = %+v", option)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACPModelDiscoveryFiltersProviderIncompatibleModels(t *testing.T) {
|
||||
a := &Agent{deps: Deps{
|
||||
ResolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.ResolvedConfig{Provider: config.ProviderProfile{
|
||||
CatalogID: "opencode-go-anthropic-compatible", Model: "minimax-m3",
|
||||
}}, nil
|
||||
},
|
||||
DiscoverModels: func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
|
||||
return []providermodeldiscovery.Model{{ID: "qwen3.7-plus"}, {ID: "claude-sonnet-4.5"}}, nil
|
||||
},
|
||||
}}
|
||||
_, options, restricted, err := a.resolveModelChoices(context.Background(), t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !restricted || !modelChoiceExists(options, "qwen3.7-plus") || modelChoiceExists(options, "claude-sonnet-4.5") {
|
||||
t.Fatalf("filtered model options = %+v, restricted=%v", options, restricted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACPModelDiscoveryHonorsCancellation(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
a := &Agent{deps: Deps{
|
||||
ResolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
return config.ResolvedConfig{Provider: config.ProviderProfile{Model: "configured-model"}}, nil
|
||||
},
|
||||
DiscoverModels: func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
|
||||
return nil, context.Canceled
|
||||
},
|
||||
}}
|
||||
if _, _, _, err := a.resolveModelChoices(ctx, t.TempDir()); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("resolve error = %v, want context canceled", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACPConfigOptionWireSchema(t *testing.T) {
|
||||
b, err := json.Marshal(SessionConfigOption{
|
||||
ID: "model", Name: "Model", Description: "desc", Category: "model",
|
||||
Type: configOptionTypeSelect, CurrentValue: "m1",
|
||||
Options: []SessionConfigOptionValue{{Value: "m1", Name: "m1", Description: "choice"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var wire map[string]any
|
||||
if err := json.Unmarshal(b, &wire); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, key := range []string{"type", "category", "currentValue", "options"} {
|
||||
if _, ok := wire[key]; !ok {
|
||||
t.Errorf("wire field %q absent: %s", key, b)
|
||||
}
|
||||
}
|
||||
if _, ok := wire["value"]; ok {
|
||||
t.Errorf("obsolete option value field present: %s", b)
|
||||
}
|
||||
if _, ok := wire["values"]; ok {
|
||||
t.Errorf("obsolete values field present: %s", b)
|
||||
}
|
||||
choice := wire["options"].([]any)[0].(map[string]any)
|
||||
if choice["value"] != "m1" {
|
||||
t.Errorf("options[].value = %#v", choice["value"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestACPUnknownSessionPromptErrors(t *testing.T) {
|
||||
h := newHarness(t, testDeps(t))
|
||||
defer h.stop()
|
||||
@@ -384,15 +179,6 @@ func TestACPSetModeUpdatesSession(t *testing.T) {
|
||||
if err := h.client.Call(ctx, MethodSessionSetMode, SetSessionModeParams{SessionID: newRes.SessionID, ModeID: string(agent.PermissionModeAsk)}, &SetSessionModeResult{}); err != nil {
|
||||
t.Fatalf("set_mode ask: %v", err)
|
||||
}
|
||||
var configured SetSessionConfigOptionResult
|
||||
if err := h.client.Call(ctx, MethodSessionSetConfigOption, SetSessionConfigOptionParams{
|
||||
SessionID: newRes.SessionID, ConfigID: configIDMode, Value: string(agent.PermissionModeAuto),
|
||||
}, &configured); err != nil {
|
||||
t.Fatalf("set_config_option mode: %v", err)
|
||||
}
|
||||
if got := configured.ConfigOptions[1].CurrentValue; got != string(agent.PermissionModeAuto) {
|
||||
t.Fatalf("configured mode = %q", got)
|
||||
}
|
||||
// Unsafe must be rejected over ACP — a client can't self-grant no-prompt host access.
|
||||
if err := h.client.Call(ctx, MethodSessionSetMode, SetSessionModeParams{SessionID: newRes.SessionID, ModeID: string(agent.PermissionModeUnsafe)}, &SetSessionModeResult{}); err == nil {
|
||||
t.Fatal("expected Unsafe mode to be rejected over ACP")
|
||||
@@ -456,77 +242,8 @@ func TestACPRejectsInvalidCwd(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestACPPromptWarnsWhenTurnPersistenceFails(t *testing.T) {
|
||||
deps := testDeps(t)
|
||||
h := newHarness(t, deps)
|
||||
defer h.stop()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var newRes NewSessionResult
|
||||
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir(), McpServers: []McpServer{}}, &newRes); err != nil {
|
||||
t.Fatalf("session/new: %v", err)
|
||||
}
|
||||
metadataPath := filepath.Join(deps.Store.RootDir, newRes.SessionID, sessions.MetadataFile)
|
||||
if err := os.Remove(metadataPath); err != nil {
|
||||
t.Fatalf("remove metadata: %v", err)
|
||||
}
|
||||
|
||||
var promptRes PromptResult
|
||||
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{
|
||||
SessionID: newRes.SessionID,
|
||||
Prompt: []ContentBlock{TextBlock("hi")},
|
||||
}, &promptRes); err != nil {
|
||||
t.Fatalf("session/prompt: %v", err)
|
||||
}
|
||||
if promptRes.StopReason != StopEndTurn {
|
||||
t.Fatalf("stopReason = %q, want %q", promptRes.StopReason, StopEndTurn)
|
||||
}
|
||||
got := drainTextUntil(t, h.updates, func(text string) bool {
|
||||
return strings.Contains(text, "Hello from ZERO") &&
|
||||
strings.Contains(text, "Could not save session history")
|
||||
})
|
||||
if !strings.Contains(got, "Could not save session history") {
|
||||
t.Fatalf("streamed text = %q, want persistence warning", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACPLoadWarnsWhenHistoryReadFails(t *testing.T) {
|
||||
deps := testDeps(t)
|
||||
cwd := t.TempDir()
|
||||
meta, err := deps.Store.Create(sessions.CreateInput{Title: "ACP session", Cwd: cwd})
|
||||
if err != nil {
|
||||
t.Fatalf("create session: %v", err)
|
||||
}
|
||||
eventsPath := filepath.Join(deps.Store.RootDir, meta.SessionID, sessions.EventsFile)
|
||||
if err := os.WriteFile(eventsPath, []byte("{bad json}\n"), 0o600); err != nil {
|
||||
t.Fatalf("write corrupt events: %v", err)
|
||||
}
|
||||
h := newHarness(t, deps)
|
||||
defer h.stop()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := h.client.Call(ctx, MethodSessionLoad, LoadSessionParams{SessionID: meta.SessionID, Cwd: cwd, McpServers: []McpServer{}}, &LoadSessionResult{}); err != nil {
|
||||
t.Fatalf("session/load: %v", err)
|
||||
}
|
||||
got := drainTextUntil(t, h.updates, func(text string) bool {
|
||||
return strings.Contains(text, "Could not load session history")
|
||||
})
|
||||
if !strings.Contains(got, "Could not load session history") {
|
||||
t.Fatalf("streamed text = %q, want load warning", got)
|
||||
}
|
||||
}
|
||||
|
||||
// drainText collects streamed chunks for a short window and concatenates them.
|
||||
func drainText(t *testing.T, ch <-chan string) string {
|
||||
t.Helper()
|
||||
return drainTextUntil(t, ch, func(text string) bool {
|
||||
return strings.Contains(text, "Hello from ZERO")
|
||||
})
|
||||
}
|
||||
|
||||
func drainTextUntil(t *testing.T, ch <-chan string, done func(string) bool) string {
|
||||
t.Helper()
|
||||
var b strings.Builder
|
||||
deadline := time.After(2 * time.Second)
|
||||
@@ -534,7 +251,7 @@ func drainTextUntil(t *testing.T, ch <-chan string, done func(string) bool) stri
|
||||
select {
|
||||
case s := <-ch:
|
||||
b.WriteString(s)
|
||||
if done(b.String()) {
|
||||
if strings.Contains(b.String(), "Hello from ZERO") {
|
||||
return b.String()
|
||||
}
|
||||
case <-deadline:
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
// Test seams: helpers only test code uses, kept out of the production binary.
|
||||
package acp
|
||||
|
||||
import "context"
|
||||
|
||||
// Serve runs the connection read loop until the stream closes or ctx is done.
|
||||
func (a *Agent) Serve(ctx context.Context) error { return a.conn.Serve(ctx) }
|
||||
|
||||
func ImageBlock(base64Data, mimeType string) ContentBlock {
|
||||
return ContentBlock{Type: "image", Data: base64Data, MimeType: mimeType}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package acp
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
@@ -71,8 +70,8 @@ func primaryArgHint(rawArgs string) string {
|
||||
func truncateHint(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
const max = 60
|
||||
if utf8.RuneCountInString(s) > max {
|
||||
return string([]rune(s)[:max]) + "…"
|
||||
if len(s) > max {
|
||||
return s[:max] + "…"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
package acp
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
@@ -128,35 +126,3 @@ func TestPromptText(t *testing.T) {
|
||||
t.Fatalf("promptText = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolTitleTruncateHintRuneSafe(t *testing.T) {
|
||||
// A 61-character string containing multi-byte UTF-8 runes (emojis / CJK characters).
|
||||
// We want to verify that it is truncated without cutting any runes or producing invalid UTF-8.
|
||||
longPath := "📁/项目/非常长的路径名称/测试/🚀/emoji-and-cjk-characters-which-are-very-long-and-exceed-sixty-characters"
|
||||
|
||||
// Create JSON args for read_file
|
||||
rawArgs := `{"path":"` + longPath + `"}`
|
||||
got := toolTitle("read_file", rawArgs)
|
||||
|
||||
expectedPrefix := "read_file "
|
||||
if !strings.HasPrefix(got, expectedPrefix) {
|
||||
t.Fatalf("expected title to start with %q, got %q", expectedPrefix, got)
|
||||
}
|
||||
|
||||
hint := strings.TrimPrefix(got, expectedPrefix)
|
||||
// Hint should end with the ellipsis character
|
||||
if !strings.HasSuffix(hint, "…") {
|
||||
t.Fatalf("expected truncated hint to end with ellipsis, got %q", hint)
|
||||
}
|
||||
|
||||
// Check that we don't have invalid UTF-8 runes
|
||||
if !utf8.ValidString(hint) {
|
||||
t.Fatalf("truncated hint is not a valid UTF-8 string: %q", hint)
|
||||
}
|
||||
|
||||
// The rune count of the hint (excluding ellipsis) should be exactly 60
|
||||
runes := []rune(strings.TrimSuffix(hint, "…"))
|
||||
if len(runes) != 60 {
|
||||
t.Fatalf("expected exactly 60 runes before ellipsis, got %d (hint: %q)", len(runes), hint)
|
||||
}
|
||||
}
|
||||
+19
-19
@@ -103,6 +103,10 @@ type ContentBlock struct {
|
||||
|
||||
func TextBlock(text string) ContentBlock { return ContentBlock{Type: "text", Text: text} }
|
||||
|
||||
func ImageBlock(base64Data, mimeType string) ContentBlock {
|
||||
return ContentBlock{Type: "image", Data: base64Data, MimeType: mimeType}
|
||||
}
|
||||
|
||||
// ---- sessions ----
|
||||
|
||||
// McpServer mirrors the editor-provided MCP server entry. ZERO owns its own MCP
|
||||
@@ -229,6 +233,10 @@ func ToolContent(block ContentBlock) ToolCallContent {
|
||||
return ToolCallContent{Type: "content", Content: &block}
|
||||
}
|
||||
|
||||
func ToolDiff(path, oldText, newText string) ToolCallContent {
|
||||
return ToolCallContent{Type: "diff", Path: path, OldText: oldText, NewText: newText}
|
||||
}
|
||||
|
||||
type ToolCallLocation struct {
|
||||
Path string `json:"path"`
|
||||
Line *int `json:"line,omitempty"`
|
||||
@@ -329,22 +337,19 @@ type SetSessionModeParams struct {
|
||||
|
||||
type SetSessionModeResult struct{}
|
||||
|
||||
// ---- session config options ----
|
||||
// ---- session config options (model selection) ----
|
||||
|
||||
type SessionConfigOptionValue struct {
|
||||
Value string `json:"value"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type SessionConfigOption struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Category string `json:"category,omitempty"`
|
||||
Type string `json:"type"`
|
||||
CurrentValue string `json:"currentValue"`
|
||||
Options []SessionConfigOptionValue `json:"options"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Value string `json:"value"`
|
||||
Values []SessionConfigOptionValue `json:"values,omitempty"`
|
||||
}
|
||||
|
||||
type SetSessionConfigOptionParams struct {
|
||||
@@ -368,11 +373,6 @@ type ZeroSetModelResult struct {
|
||||
Model string `json:"model"`
|
||||
}
|
||||
|
||||
const (
|
||||
configIDModel = "model"
|
||||
configIDMode = "mode"
|
||||
|
||||
configOptionTypeSelect = "select"
|
||||
configCategoryModel = "model"
|
||||
configCategoryMode = "mode"
|
||||
)
|
||||
// configIDModel is the SessionConfigOption id ZERO uses to expose model choice
|
||||
// through the standard session/set_config_option method.
|
||||
const configIDModel = "model"
|
||||
@@ -72,26 +72,6 @@ func TestSelfReportedIncompletionMatching(t *testing.T) {
|
||||
{"I replaced the placeholder value with the computed key.", false},
|
||||
{"The retry uses exponential backoff as a fallback.", false},
|
||||
{"Without proper error handling the function would crash, so I added it.", false},
|
||||
// real false positive (conversational recap): retelling a PAST exchange is
|
||||
// not an admission about the current objective — the sentence carries its
|
||||
// own narrative context ("You asked …", "at the time"):
|
||||
{"You asked if I could work autonomously on zero if you gave me a GitHub account — I was honest that my sandbox had no repo, no build system, and no network/integration wired up yet, so I couldn't actually do it at the time.", false},
|
||||
{"When we last spoke, I couldn't reach the GitHub API from the sandbox.", false},
|
||||
{"Last session I was unable to build the project; that is what we should retry now.", false},
|
||||
// quoted admissions (the model citing its own earlier message or a log
|
||||
// line) must NOT be flagged:
|
||||
{`My earlier reply said "I couldn't do it" but the tooling is now installed.`, false},
|
||||
{"The run log contains `I cannot access the network`, which is expected inside the sandbox.", false},
|
||||
// current-objective admissions must STILL be flagged, including past tense
|
||||
// and multi-sentence finals whose other sentences are narrative:
|
||||
{"I couldn't complete the refactor.", true},
|
||||
{"I hit an error I could not resolve, so the migration is unfinished.", true},
|
||||
{"You asked me to port the parser. I couldn't finish the second half.", true},
|
||||
// unbalanced delimiters must not swallow the tail (only BALANCED quoted
|
||||
// spans are stripped), so an admission after a stray quote still fires:
|
||||
{"The log ends with a stray \" and I couldn't finish the migration.", true},
|
||||
{"There is an unterminated `code span and I cannot proceed with the fix.", true},
|
||||
{"The doc opens with “a curly quote and I was unable to complete the task.", true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := selfReportedIncompletion(c.text) != ""
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
func TestExecuteToolCallRejectsMissingAdditionalPermissionsBeforePrompt(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedBashTool(root, nil))
|
||||
registry.Register(tools.NewBashTool(root))
|
||||
|
||||
promptCalls := 0
|
||||
result, err := executeToolCall(
|
||||
@@ -51,7 +51,7 @@ func TestExecuteToolCallRejectsMissingAdditionalPermissionsBeforePrompt(t *testi
|
||||
func TestExecuteToolCallRejectsAdditionalPermissionsWithoutFlagBeforePrompt(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedBashTool(root, nil))
|
||||
registry.Register(tools.NewBashTool(root))
|
||||
|
||||
promptCalls := 0
|
||||
result, err := executeToolCall(
|
||||
@@ -88,7 +88,7 @@ func TestExecuteToolCallRejectsAdditionalPermissionsWithoutFlagBeforePrompt(t *t
|
||||
func TestExecuteToolCallMissingAdditionalPermissionsErrorIsActionable(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedBashTool(root, nil))
|
||||
registry.Register(tools.NewBashTool(root))
|
||||
|
||||
result, err := executeToolCall(
|
||||
context.Background(),
|
||||
@@ -117,7 +117,7 @@ func TestExecuteToolCallMissingAdditionalPermissionsErrorIsActionable(t *testing
|
||||
func TestExecuteToolCallStillPromptsForValidAdditionalPermissions(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedBashTool(root, nil))
|
||||
registry.Register(tools.NewBashTool(root))
|
||||
|
||||
promptCalls := 0
|
||||
_, err := executeToolCall(
|
||||
|
||||
@@ -141,11 +141,9 @@ func TestRunAskUserCancellationAbortsRun(t *testing.T) {
|
||||
registry := registryWithAskUser()
|
||||
args := `{"questions":[{"question":"Which framework?"}]}`
|
||||
provider := providerCallingAskUserThenAnswer(args, "done")
|
||||
var toolResults []ToolResult
|
||||
|
||||
result, err := Run(context.Background(), "clarify", provider, Options{
|
||||
Registry: registry,
|
||||
OnToolResult: func(result ToolResult) { toolResults = append(toolResults, result) },
|
||||
Registry: registry,
|
||||
OnAskUser: func(_ context.Context, _ AskUserRequest) (AskUserResponse, error) {
|
||||
return AskUserResponse{}, context.Canceled
|
||||
},
|
||||
@@ -160,9 +158,6 @@ func TestRunAskUserCancellationAbortsRun(t *testing.T) {
|
||||
if len(provider.requests) != 1 {
|
||||
t.Fatalf("expected the run to stop after the canceled ask_user (1 turn), got %d", len(provider.requests))
|
||||
}
|
||||
if len(toolResults) != 1 || toolResults[0].ToolCallID == "" {
|
||||
t.Fatalf("cancellation must emit exactly one result for the call, got %#v", toolResults)
|
||||
}
|
||||
// The recorded tool result must reflect cancellation, not a synthetic answer.
|
||||
var toolMsg string
|
||||
for _, m := range result.Messages {
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Post-edit LSP diagnostics used to run synchronously inside edit_file /
|
||||
// write_file: every edit blocked its tool result on a fresh-publish wait with
|
||||
// a quiet-period debounce (≥300ms floor that reset on each publish, 10s cap) —
|
||||
// the largest recurring latency tax in an edit-heavy session. Collection is
|
||||
// now asynchronous: a file is enqueued the moment a mutating tool finishes, a
|
||||
// single worker checks it while the rest of the tool batch (and any
|
||||
// self-correct verification) runs, and the loop drains completed results just
|
||||
// before building the NEXT provider request, appending errors as a user-role
|
||||
// nudge. The model always issues another request after an edit turn — it has
|
||||
// to read its tool results — so it still sees introduced errors at the same
|
||||
// decision point as before, without any tool call stalling on the language
|
||||
// server.
|
||||
//
|
||||
// A file re-edited before its check runs is checked once in its final state
|
||||
// (the checker re-reads the file); a file re-edited after its check completed
|
||||
// is re-enqueued and the newer result wins. When the worker is still busy at
|
||||
// drain time, the drain gives up after a short wait and the results are
|
||||
// delivered on the following turn instead — they stay accurate because any
|
||||
// further edit re-enqueues the file.
|
||||
|
||||
// asyncDiagnosticsDrainTimeout bounds how long a turn waits on the in-flight
|
||||
// check before deferring delivery to the next turn. Most checks finish well
|
||||
// under it (debounce floor + analysis); a cold language server on a large
|
||||
// repo does not get to stall the loop the way the old inline 10s cap could.
|
||||
// A var so tests can shorten the wait.
|
||||
var asyncDiagnosticsDrainTimeout = 3 * time.Second
|
||||
|
||||
// asyncDiagnosticsFinalDrainTimeout is the wait used when the run is about to
|
||||
// FINALIZE (natural completion or the max-turns summary): there is no later
|
||||
// turn to defer to, so the gate waits out the old inline-era budget rather
|
||||
// than silently dropping an error the last edit introduced. A var so tests
|
||||
// can shorten the wait.
|
||||
var asyncDiagnosticsFinalDrainTimeout = 10 * time.Second
|
||||
|
||||
// asyncDiagnosticsNudge prefixes the drained diagnostics blocks; phrased like
|
||||
// the old inline block so the model reacts the same way.
|
||||
const asyncDiagnosticsNudge = "Diagnostics after your recent edits (fix any errors you introduced):\n"
|
||||
|
||||
type asyncDiagnostics struct {
|
||||
check func(context.Context, string) string
|
||||
workspaceRoot string
|
||||
|
||||
mu sync.Mutex
|
||||
queue []string
|
||||
queued map[string]bool
|
||||
results map[string]string
|
||||
// working is non-nil while the worker goroutine runs and is closed when it
|
||||
// exits, so drain can wait for quiescence without polling.
|
||||
working chan struct{}
|
||||
}
|
||||
|
||||
// newAsyncDiagnostics returns nil when check is nil, and every method on a nil
|
||||
// receiver is a no-op — callers wire it unconditionally.
|
||||
func newAsyncDiagnostics(check func(context.Context, string) string, workspaceRoot string) *asyncDiagnostics {
|
||||
if check == nil {
|
||||
return nil
|
||||
}
|
||||
return &asyncDiagnostics{
|
||||
check: check,
|
||||
workspaceRoot: workspaceRoot,
|
||||
queued: map[string]bool{},
|
||||
results: map[string]string{},
|
||||
}
|
||||
}
|
||||
|
||||
// enqueue schedules changed files for a background check. Paths are
|
||||
// workspace-relative from Result.ChangedFiles (absolute when under a granted
|
||||
// extra write root), mirroring SelfCorrector's resolution.
|
||||
func (diagnostics *asyncDiagnostics) enqueue(ctx context.Context, files []string) {
|
||||
if diagnostics == nil || len(files) == 0 {
|
||||
return
|
||||
}
|
||||
diagnostics.mu.Lock()
|
||||
defer diagnostics.mu.Unlock()
|
||||
for _, file := range files {
|
||||
path := diagnostics.absPath(file)
|
||||
if diagnostics.queued[path] {
|
||||
continue
|
||||
}
|
||||
diagnostics.queued[path] = true
|
||||
// A completed result for this file describes a pre-edit state now.
|
||||
delete(diagnostics.results, path)
|
||||
diagnostics.queue = append(diagnostics.queue, path)
|
||||
}
|
||||
if diagnostics.working == nil && len(diagnostics.queue) > 0 {
|
||||
done := make(chan struct{})
|
||||
diagnostics.working = done
|
||||
go diagnostics.work(ctx, done)
|
||||
}
|
||||
}
|
||||
|
||||
func (diagnostics *asyncDiagnostics) work(ctx context.Context, done chan struct{}) {
|
||||
defer close(done)
|
||||
for {
|
||||
diagnostics.mu.Lock()
|
||||
if len(diagnostics.queue) == 0 || ctx.Err() != nil {
|
||||
diagnostics.working = nil
|
||||
diagnostics.mu.Unlock()
|
||||
return
|
||||
}
|
||||
path := diagnostics.queue[0]
|
||||
diagnostics.queue = diagnostics.queue[1:]
|
||||
// Un-mark before checking so an edit that lands DURING the check
|
||||
// re-enqueues the file and it is checked again in its final state.
|
||||
delete(diagnostics.queued, path)
|
||||
diagnostics.mu.Unlock()
|
||||
|
||||
block := diagnostics.check(ctx, path)
|
||||
|
||||
diagnostics.mu.Lock()
|
||||
if block != "" {
|
||||
diagnostics.results[path] = block
|
||||
} else {
|
||||
delete(diagnostics.results, path)
|
||||
}
|
||||
diagnostics.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// drain waits briefly for the worker to go quiet, then formats and consumes
|
||||
// all completed error blocks as one nudge. Returns "" when there is nothing
|
||||
// to report or the worker is still busy (results then surface next turn).
|
||||
func (diagnostics *asyncDiagnostics) drain(ctx context.Context) string {
|
||||
return diagnostics.drainWithin(ctx, asyncDiagnosticsDrainTimeout)
|
||||
}
|
||||
|
||||
// drainFinal is drain with the finalization budget: called before the run's
|
||||
// last model request, where an undelivered result would otherwise be lost.
|
||||
func (diagnostics *asyncDiagnostics) drainFinal(ctx context.Context) string {
|
||||
return diagnostics.drainWithin(ctx, asyncDiagnosticsFinalDrainTimeout)
|
||||
}
|
||||
|
||||
func (diagnostics *asyncDiagnostics) drainWithin(ctx context.Context, timeout time.Duration) string {
|
||||
if diagnostics == nil {
|
||||
return ""
|
||||
}
|
||||
diagnostics.mu.Lock()
|
||||
busy := diagnostics.working
|
||||
diagnostics.mu.Unlock()
|
||||
if busy != nil {
|
||||
timer := time.NewTimer(timeout)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-busy:
|
||||
case <-timer.C:
|
||||
return ""
|
||||
case <-ctx.Done():
|
||||
return ""
|
||||
}
|
||||
}
|
||||
diagnostics.mu.Lock()
|
||||
defer diagnostics.mu.Unlock()
|
||||
if len(diagnostics.results) == 0 {
|
||||
return ""
|
||||
}
|
||||
paths := make([]string, 0, len(diagnostics.results))
|
||||
for path := range diagnostics.results {
|
||||
paths = append(paths, path)
|
||||
}
|
||||
sort.Strings(paths)
|
||||
blocks := make([]string, 0, len(paths))
|
||||
for _, path := range paths {
|
||||
blocks = append(blocks, diagnostics.results[path])
|
||||
}
|
||||
diagnostics.results = map[string]string{}
|
||||
return asyncDiagnosticsNudge + strings.Join(blocks, "\n")
|
||||
}
|
||||
|
||||
func (diagnostics *asyncDiagnostics) absPath(path string) string {
|
||||
if filepath.IsAbs(path) {
|
||||
return path
|
||||
}
|
||||
return filepath.Join(diagnostics.workspaceRoot, path)
|
||||
}
|
||||
@@ -1,338 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
func TestAsyncDiagnosticsNilCollectorNoOps(t *testing.T) {
|
||||
var diagnostics *asyncDiagnostics
|
||||
if diagnostics != newAsyncDiagnostics(nil, "/ws") {
|
||||
t.Fatal("nil check must yield a nil collector")
|
||||
}
|
||||
diagnostics.enqueue(context.Background(), []string{"a.go"})
|
||||
if nudge := diagnostics.drain(context.Background()); nudge != "" {
|
||||
t.Fatalf("nil collector drain = %q, want empty", nudge)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAsyncDiagnosticsCollectsAndDrainsOnce(t *testing.T) {
|
||||
// A real absolute root: a literal "/ws" is not filepath.IsAbs on Windows
|
||||
// (no drive letter), which is exactly what the assertions below check.
|
||||
root := t.TempDir()
|
||||
var mu sync.Mutex
|
||||
var checked []string
|
||||
check := func(_ context.Context, absPath string) string {
|
||||
mu.Lock()
|
||||
checked = append(checked, absPath)
|
||||
mu.Unlock()
|
||||
if filepath.Base(absPath) == "clean.go" {
|
||||
return ""
|
||||
}
|
||||
return "ERR " + filepath.Base(absPath)
|
||||
}
|
||||
diagnostics := newAsyncDiagnostics(check, root)
|
||||
diagnostics.enqueue(context.Background(), []string{"broken.go", "clean.go"})
|
||||
|
||||
nudge := diagnostics.drain(context.Background())
|
||||
if !strings.HasPrefix(nudge, asyncDiagnosticsNudge) {
|
||||
t.Fatalf("nudge missing prefix: %q", nudge)
|
||||
}
|
||||
if !strings.Contains(nudge, "ERR broken.go") || strings.Contains(nudge, "clean.go") {
|
||||
t.Fatalf("nudge = %q, want broken.go errors only", nudge)
|
||||
}
|
||||
mu.Lock()
|
||||
for _, path := range checked {
|
||||
if !filepath.IsAbs(path) || !strings.HasPrefix(path, root+string(filepath.Separator)) {
|
||||
t.Fatalf("check received %q, want path under workspace root %q", path, root)
|
||||
}
|
||||
}
|
||||
mu.Unlock()
|
||||
if second := diagnostics.drain(context.Background()); second != "" {
|
||||
t.Fatalf("second drain = %q, want empty (results are consumed)", second)
|
||||
}
|
||||
}
|
||||
|
||||
// A check still running at drain time defers delivery to the next drain
|
||||
// instead of blocking the turn.
|
||||
func TestAsyncDiagnosticsSlowCheckDefersToNextDrain(t *testing.T) {
|
||||
previous := asyncDiagnosticsDrainTimeout
|
||||
asyncDiagnosticsDrainTimeout = 20 * time.Millisecond
|
||||
defer func() { asyncDiagnosticsDrainTimeout = previous }()
|
||||
|
||||
release := make(chan struct{})
|
||||
check := func(_ context.Context, absPath string) string {
|
||||
<-release
|
||||
return "ERR " + filepath.Base(absPath)
|
||||
}
|
||||
diagnostics := newAsyncDiagnostics(check, "/ws")
|
||||
diagnostics.enqueue(context.Background(), []string{"slow.go"})
|
||||
|
||||
start := time.Now()
|
||||
if nudge := diagnostics.drain(context.Background()); nudge != "" {
|
||||
t.Fatalf("drain during in-flight check = %q, want empty", nudge)
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > time.Second {
|
||||
t.Fatalf("drain blocked %v, want bounded by the (shortened) timeout", elapsed)
|
||||
}
|
||||
|
||||
close(release)
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for {
|
||||
if nudge := diagnostics.drain(context.Background()); nudge != "" {
|
||||
if !strings.Contains(nudge, "ERR slow.go") {
|
||||
t.Fatalf("deferred nudge = %q", nudge)
|
||||
}
|
||||
return
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("deferred diagnostics never delivered")
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// Re-enqueueing a file after its check completed replaces the stale result.
|
||||
func TestAsyncDiagnosticsReEditReplacesResult(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
response := "ERR old"
|
||||
check := func(context.Context, string) string {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return response
|
||||
}
|
||||
diagnostics := newAsyncDiagnostics(check, "/ws")
|
||||
|
||||
diagnostics.enqueue(context.Background(), []string{"a.go"})
|
||||
waitForIdle(t, diagnostics)
|
||||
mu.Lock()
|
||||
response = "ERR new"
|
||||
mu.Unlock()
|
||||
diagnostics.enqueue(context.Background(), []string{"a.go"})
|
||||
|
||||
nudge := diagnostics.drain(context.Background())
|
||||
if !strings.Contains(nudge, "ERR new") || strings.Contains(nudge, "ERR old") {
|
||||
t.Fatalf("nudge = %q, want only the re-check result", nudge)
|
||||
}
|
||||
}
|
||||
|
||||
func waitForIdle(t *testing.T, diagnostics *asyncDiagnostics) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for {
|
||||
diagnostics.mu.Lock()
|
||||
busy := diagnostics.working
|
||||
diagnostics.mu.Unlock()
|
||||
if busy == nil {
|
||||
return
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("worker never went idle")
|
||||
}
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// changedFilesTool is a fake mutating tool reporting a changed file without
|
||||
// touching the filesystem.
|
||||
type changedFilesTool struct{}
|
||||
|
||||
func (changedFilesTool) Name() string { return "fake_edit" }
|
||||
func (changedFilesTool) Description() string { return "test mutating tool" }
|
||||
func (changedFilesTool) Parameters() tools.Schema {
|
||||
return tools.Schema{Type: "object", Properties: map[string]tools.PropertySchema{}}
|
||||
}
|
||||
func (changedFilesTool) Safety() tools.Safety {
|
||||
return tools.Safety{SideEffect: tools.SideEffectWrite, Permission: tools.PermissionAllow, Reason: "test"}
|
||||
}
|
||||
func (changedFilesTool) Run(context.Context, map[string]any) tools.Result {
|
||||
return tools.Result{Status: tools.StatusOK, Output: "edited", ChangedFiles: []string{"main.go"}}
|
||||
}
|
||||
|
||||
// End-to-end through Run: an edit turn does NOT carry diagnostics in the tool
|
||||
// result (the old inline path), but the next request contains the background
|
||||
// diagnostics nudge, before the model finalizes.
|
||||
func TestRunDeliversAsyncDiagnosticsNudgeNextTurn(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(changedFilesTool{})
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "fake_edit"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
},
|
||||
}
|
||||
root := t.TempDir()
|
||||
var checkedPath string
|
||||
var mu sync.Mutex
|
||||
|
||||
result, err := Run(context.Background(), "fix main.go", provider, Options{
|
||||
Registry: registry,
|
||||
Cwd: root,
|
||||
FileDiagnostics: func(_ context.Context, absPath string) string {
|
||||
mu.Lock()
|
||||
checkedPath = absPath
|
||||
mu.Unlock()
|
||||
return "main.go:1:1 error: boom"
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("final answer = %q", result.FinalAnswer)
|
||||
}
|
||||
if len(provider.requests) != 2 {
|
||||
t.Fatalf("expected 2 provider turns, got %d", len(provider.requests))
|
||||
}
|
||||
|
||||
// The tool result itself must NOT block on / embed diagnostics.
|
||||
for _, message := range provider.requests[1].Messages {
|
||||
if message.Role == zeroruntime.MessageRoleTool && strings.Contains(message.Content, "boom") {
|
||||
t.Fatalf("diagnostics leaked into the tool result: %q", message.Content)
|
||||
}
|
||||
}
|
||||
// The nudge must be present in the second request as a user message.
|
||||
found := false
|
||||
for _, message := range provider.requests[1].Messages {
|
||||
if message.Role == zeroruntime.MessageRoleUser && strings.HasPrefix(message.Content, asyncDiagnosticsNudge) {
|
||||
if !strings.Contains(message.Content, "boom") {
|
||||
t.Fatalf("nudge missing diagnostics: %q", message.Content)
|
||||
}
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("no async diagnostics nudge in the follow-up request")
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if want := filepath.Join(root, "main.go"); checkedPath != want {
|
||||
t.Fatalf("checked path = %q, want %q", checkedPath, want)
|
||||
}
|
||||
}
|
||||
|
||||
// When the run hits the maxTurns ceiling right after an edit turn, the
|
||||
// final-answer request must still carry the diagnostics nudge — otherwise an
|
||||
// error introduced by the last edit would go unreported in the summary.
|
||||
func TestRunDrainsAsyncDiagnosticsBeforeMaxTurnsFinalAnswer(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(changedFilesTool{})
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "fake_edit"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "summary"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := Run(context.Background(), "fix main.go", provider, Options{
|
||||
Registry: registry,
|
||||
Cwd: t.TempDir(),
|
||||
MaxTurns: 1,
|
||||
FileDiagnostics: func(context.Context, string) string {
|
||||
return "main.go:1:1 error: boom"
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FinalAnswer != "summary" {
|
||||
t.Fatalf("final answer = %q", result.FinalAnswer)
|
||||
}
|
||||
if len(provider.requests) != 2 {
|
||||
t.Fatalf("expected the max-turns final-answer request, got %d requests", len(provider.requests))
|
||||
}
|
||||
found := false
|
||||
for _, message := range provider.requests[1].Messages {
|
||||
if message.Role == zeroruntime.MessageRoleUser && strings.HasPrefix(message.Content, asyncDiagnosticsNudge) {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("max-turns final-answer request missing the diagnostics nudge")
|
||||
}
|
||||
}
|
||||
|
||||
// When the model finalizes while a slow check is still in flight (the
|
||||
// per-turn drain missed it), the finalization gate must wait it out and give
|
||||
// the model one more turn with the nudge instead of dropping the errors.
|
||||
func TestRunFinalizationGateDeliversLateDiagnostics(t *testing.T) {
|
||||
previous := asyncDiagnosticsDrainTimeout
|
||||
asyncDiagnosticsDrainTimeout = time.Millisecond
|
||||
defer func() { asyncDiagnosticsDrainTimeout = previous }()
|
||||
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(changedFilesTool{})
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "fake_edit"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "done after fix"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := Run(context.Background(), "fix main.go", provider, Options{
|
||||
Registry: registry,
|
||||
Cwd: t.TempDir(),
|
||||
FileDiagnostics: func(context.Context, string) string {
|
||||
// Slower than the (shortened) per-turn drain, well under the
|
||||
// finalization budget.
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
return "main.go:1:1 error: boom"
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FinalAnswer != "done after fix" {
|
||||
t.Fatalf("final answer = %q, want the post-nudge answer", result.FinalAnswer)
|
||||
}
|
||||
if len(provider.requests) != 3 {
|
||||
t.Fatalf("expected a third request carrying the late nudge, got %d requests", len(provider.requests))
|
||||
}
|
||||
found := false
|
||||
for _, message := range provider.requests[2].Messages {
|
||||
if message.Role == zeroruntime.MessageRoleUser && strings.HasPrefix(message.Content, asyncDiagnosticsNudge) {
|
||||
if !strings.Contains(message.Content, "boom") {
|
||||
t.Fatalf("nudge missing diagnostics: %q", message.Content)
|
||||
}
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("finalization gate did not deliver the late diagnostics nudge")
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
@@ -28,20 +27,10 @@ func proposedCommandPrefix(toolName string, args map[string]any) []string {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// Only propose approving a prefix of one segment when every other segment
|
||||
// in the command is independently known-safe. Once a prefix is approved,
|
||||
// shellExecutionArgsForApproval escalates the whole command (every
|
||||
// segment, not just the approved one) to bypass the sandbox, so offering a
|
||||
// prefix that leaves an MSYS-prone (or otherwise unsafe) segment uncovered
|
||||
// would let that segment run unsandboxed without ever being reviewed.
|
||||
for index, tokens := range segments {
|
||||
if knownSafeCommandSegment(tokens) {
|
||||
continue
|
||||
for _, tokens := range segments {
|
||||
if !knownSafeCommandSegment(tokens) && sandbox.ValidCommandPrefix(tokens) {
|
||||
return append([]string(nil), tokens...)
|
||||
}
|
||||
if !sandbox.ValidCommandPrefix(tokens) || !otherSegmentsKnownSafe(segments, index) {
|
||||
return nil
|
||||
}
|
||||
return append([]string(nil), tokens...)
|
||||
}
|
||||
if len(segments) == 0 || !sandbox.ValidCommandPrefix(segments[0]) {
|
||||
return nil
|
||||
@@ -49,20 +38,6 @@ func proposedCommandPrefix(toolName string, args map[string]any) []string {
|
||||
return append([]string(nil), segments[0]...)
|
||||
}
|
||||
|
||||
// otherSegmentsKnownSafe reports whether every segment other than the one at
|
||||
// skip is known-safe on its own.
|
||||
func otherSegmentsKnownSafe(segments [][]string, skip int) bool {
|
||||
for index, tokens := range segments {
|
||||
if index == skip {
|
||||
continue
|
||||
}
|
||||
if !knownSafeCommandSegment(tokens) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func matchCommandPrefix(toolName string, args map[string]any, options Options) (sandbox.CommandPrefixGrant, bool, bool) {
|
||||
if !isShellCommandTool(toolName) || options.Sandbox == nil {
|
||||
return sandbox.CommandPrefixGrant{}, false, false
|
||||
@@ -193,6 +168,10 @@ func cleanPrefixRule(prefix []string) []string {
|
||||
return cleaned
|
||||
}
|
||||
|
||||
func safeRequestedPrefix(prefix []string, command []string) bool {
|
||||
return safeRequestedPrefixForSegments(prefix, [][]string{command})
|
||||
}
|
||||
|
||||
func safeRequestedPrefixForSegments(prefix []string, segments [][]string) bool {
|
||||
if len(prefix) == 0 || !sandbox.ValidCommandPrefix(prefix) {
|
||||
return false
|
||||
@@ -217,6 +196,14 @@ func safeRequestedPrefixForSegments(prefix []string, segments [][]string) bool {
|
||||
return matched
|
||||
}
|
||||
|
||||
func safeShellCommandTokens(command string) ([]string, bool) {
|
||||
segments, ok := safeShellCommandSegments(command)
|
||||
if !ok || len(segments) != 1 {
|
||||
return nil, false
|
||||
}
|
||||
return segments[0], true
|
||||
}
|
||||
|
||||
func safeShellCommandSegments(command string) ([][]string, bool) {
|
||||
command = strings.TrimSpace(command)
|
||||
if command == "" {
|
||||
@@ -288,11 +275,7 @@ func knownSafeCommandSegment(command []string) bool {
|
||||
if len(command) == 0 {
|
||||
return false
|
||||
}
|
||||
name := commandName(command[0])
|
||||
if runtime.GOOS == "windows" && tools.MsysProneCommandName(name) {
|
||||
return false
|
||||
}
|
||||
switch name {
|
||||
switch commandName(command[0]) {
|
||||
case "cat", "cd", "cut", "echo", "expr", "false", "grep", "head", "id",
|
||||
"ls", "nl", "paste", "pwd", "rev", "seq", "stat", "tail", "tr",
|
||||
"true", "uname", "uniq", "wc", "which", "whoami":
|
||||
@@ -501,3 +484,15 @@ func hasStringPrefix(values []string, prefix []string) bool {
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func equalStringSlices(left []string, right []string) bool {
|
||||
if len(left) != len(right) {
|
||||
return false
|
||||
}
|
||||
for index := range left {
|
||||
if left[index] != right[index] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
func TestProposedCommandPrefixUsesSafeSimpleCommands(t *testing.T) {
|
||||
@@ -37,37 +35,12 @@ func TestProposedCommandPrefixHonorsValidatedRequestedPrefix(t *testing.T) {
|
||||
|
||||
func TestProposedCommandPrefixSupportsSegmentedCommands(t *testing.T) {
|
||||
got := proposedCommandPrefix("bash", map[string]any{"command": "ps aux | head -5"})
|
||||
if runtime.GOOS == "windows" {
|
||||
// head is MSYS-prone on Windows (#458), so proposedCommandPrefix must
|
||||
// not offer "ps aux" as a reusable prefix here: approving it would
|
||||
// escalate the whole command, including the uncovered head segment,
|
||||
// to bypass the sandbox unreviewed. See
|
||||
// TestProposedCommandPrefixRejectsPrefixLeavingUnsafeTailUncovered for
|
||||
// the platform-independent regression coverage of this behavior.
|
||||
if got != nil {
|
||||
t.Fatalf("expected no prefix on Windows because head is MSYS-prone, got %#v", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
want := []string{"ps", "aux"}
|
||||
if !equalStringSlices(got, want) {
|
||||
t.Fatalf("prefix = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProposedCommandPrefixRejectsPrefixLeavingUnsafeTailUncovered guards
|
||||
// against proposedCommandPrefix offering to approve one segment of a
|
||||
// multi-segment command (e.g. "ps aux") while a different segment (e.g. "npm
|
||||
// install") is not known-safe. shellExecutionArgsForApproval escalates the
|
||||
// entire command once any prefix is approved, so an uncovered unsafe segment
|
||||
// would bypass the sandbox unreviewed. Uses npm, which is never known-safe on
|
||||
// any platform, so the assertion does not depend on runtime.GOOS.
|
||||
func TestProposedCommandPrefixRejectsPrefixLeavingUnsafeTailUncovered(t *testing.T) {
|
||||
if got := proposedCommandPrefix("bash", map[string]any{"command": "ps aux && npm install"}); got != nil {
|
||||
t.Fatalf("expected no prefix because npm segment is not known-safe, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProposedCommandPrefixHonorsRequestedPrefixAcrossSegments(t *testing.T) {
|
||||
got := proposedCommandPrefix("bash", map[string]any{
|
||||
"command": "git status --short && git status --branch",
|
||||
@@ -147,35 +120,13 @@ func TestProposedCommandPrefixRejectsUnsafeLaunchers(t *testing.T) {
|
||||
func TestMatchCommandPrefixCoversSegmentedCommandWithSafeTail(t *testing.T) {
|
||||
engine := sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: t.TempDir()})
|
||||
engine.GrantCommandPrefixForSession("bash", []string{"ps", "aux"})
|
||||
// head is MSYS-prone on Windows (#458) and must not count as a known-safe tail.
|
||||
command := "ps aux | head -5"
|
||||
if runtime.GOOS == "windows" {
|
||||
command = "ps aux | echo ok"
|
||||
}
|
||||
|
||||
grant, ok, session := matchCommandPrefix("bash", map[string]any{"command": command}, Options{Sandbox: engine})
|
||||
grant, ok, session := matchCommandPrefix("bash", map[string]any{"command": "ps aux | head -5"}, Options{Sandbox: engine})
|
||||
if !ok || !session || !equalStringSlices(grant.Prefix, []string{"ps", "aux"}) {
|
||||
t.Fatalf("match = %#v ok=%v session=%v, want session ps aux prefix", grant, ok, session)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKnownSafeCommandSegmentRejectsMsysProneOnWindows(t *testing.T) {
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Skip("windows-only known-safe MSYS guard")
|
||||
}
|
||||
for _, command := range [][]string{{"head", "-5"}, {"cat", "file.txt"}, {"grep", "pat"}} {
|
||||
if knownSafeCommandSegment(command) {
|
||||
t.Fatalf("expected %q to be unsafe on Windows, got known-safe", command)
|
||||
}
|
||||
}
|
||||
if !knownSafeCommandSegment([]string{"echo", "ok"}) {
|
||||
t.Fatal("expected echo to remain known-safe on Windows")
|
||||
}
|
||||
if !tools.MsysProneCommandName("head") {
|
||||
t.Fatal("expected head to be MSYS-prone")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchCommandPrefixRejectsUncoveredSegment(t *testing.T) {
|
||||
engine := sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: t.TempDir()})
|
||||
engine.GrantCommandPrefixForSession("bash", []string{"ps", "aux"})
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/trace"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
@@ -56,10 +55,6 @@ type CompactionOptions struct {
|
||||
// injected so Compact stays pure and testable; the agent loop wires it to a
|
||||
// real provider call.
|
||||
Summarize func(toSummarize []zeroruntime.Message) (string, error)
|
||||
// taskState is a snapshot supplied by the running agent. Its immutable
|
||||
// objective is always preserved; mutable fields are admitted only when its
|
||||
// plan projection still matches the transcript.
|
||||
taskState *taskStateSnapshot
|
||||
}
|
||||
|
||||
// CompactionResult is the metadata-bearing result returned by CompactMessages.
|
||||
@@ -224,7 +219,7 @@ func CompactMessages(messages []zeroruntime.Message, opts CompactionOptions) (Co
|
||||
|
||||
// Preserve structured state (active plan + loaded skills) from the elided
|
||||
// middle verbatim, so it is not lost or paraphrased away by the prose summary.
|
||||
content := appendPreservedState(summaryLabel+"\n"+summary, middle, opts.taskState)
|
||||
content := appendPreservedState(summaryLabel+"\n"+summary, middle)
|
||||
|
||||
compacted := make([]zeroruntime.Message, 0, systemEnd+1+(len(messages)-boundary))
|
||||
compacted = append(compacted, messages[:systemEnd]...)
|
||||
@@ -330,55 +325,15 @@ type compactionState struct {
|
||||
// OnText is deliberately NOT forwarded (compaction stays invisible to the user),
|
||||
// but its token COST must still be counted so usage reports and budgets include it.
|
||||
onUsage func(Usage)
|
||||
task *taskState
|
||||
|
||||
// calibrationRatio scales the raw byte/4 token estimate toward the provider's
|
||||
// real prompt-token count. ApproxTextTokens over-counts code-heavy content by
|
||||
// ~15-20%, which would trip compaction early (at ~60% of true capacity). It
|
||||
// starts at 1.0 and converges via an EMA as each turn reports actual usage, so
|
||||
// later turns compact nearer to real capacity. Zero is treated as 1.0.
|
||||
calibrationRatio float64
|
||||
}
|
||||
|
||||
// calibrate folds one turn's (rawEstimate, actualPromptTokens) sample into the
|
||||
// running calibration ratio. A single sample is clamped to a sane band so an
|
||||
// outlier (a huge cache-read turn, a provider-overhead spike) can't skew the
|
||||
// estimate enough to disable or thrash compaction.
|
||||
func (state *compactionState) calibrate(rawEstimate int, actualPromptTokens int) {
|
||||
if !state.enabled || rawEstimate <= 0 || actualPromptTokens <= 0 {
|
||||
return
|
||||
}
|
||||
sample := float64(actualPromptTokens) / float64(rawEstimate)
|
||||
if sample < 0.5 {
|
||||
sample = 0.5
|
||||
} else if sample > 2.0 {
|
||||
sample = 2.0
|
||||
}
|
||||
if state.calibrationRatio <= 0 {
|
||||
state.calibrationRatio = 1.0
|
||||
}
|
||||
const alpha = 0.3 // weight on the newest sample; smooths jitter across turns
|
||||
state.calibrationRatio = state.calibrationRatio*(1-alpha) + sample*alpha
|
||||
}
|
||||
|
||||
// calibratedTokens applies the learned ratio to a raw estimate. Before any sample
|
||||
// arrives (ratio unset) it returns the raw estimate unchanged.
|
||||
func (state *compactionState) calibratedTokens(raw int) int {
|
||||
if state.calibrationRatio <= 0 {
|
||||
return raw
|
||||
}
|
||||
return int(float64(raw) * state.calibrationRatio)
|
||||
}
|
||||
|
||||
func newCompactionState(options Options, task *taskState) *compactionState {
|
||||
state := &compactionState{
|
||||
func newCompactionState(options Options) *compactionState {
|
||||
return &compactionState{
|
||||
enabled: options.ContextWindow > 0,
|
||||
threshold: compactionThreshold(options.ContextWindow),
|
||||
preserveLast: options.CompactionPreserveLast,
|
||||
onUsage: options.OnUsage,
|
||||
task: task,
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
// maybeCompact runs proactive compaction at the top of a turn. It returns the
|
||||
@@ -398,7 +353,7 @@ func (state *compactionState) maybeCompact(
|
||||
// the messages; both the threshold check and the shrink check below use the
|
||||
// same term so they stay consistent.
|
||||
toolTokens := estimateToolDefTokens(tools)
|
||||
size := state.calibratedTokens(estimateTokens(messages) + toolTokens)
|
||||
size := estimateTokens(messages) + toolTokens
|
||||
if size <= state.threshold {
|
||||
return messages
|
||||
}
|
||||
@@ -415,7 +370,7 @@ func (state *compactionState) maybeCompact(
|
||||
// entirely and preserve recent turns verbatim.
|
||||
if pruned, reclaimed := pruneStaleToolOutput(messages, state.preserveLast); reclaimed > 0 {
|
||||
messages = pruned
|
||||
size = state.calibratedTokens(estimateTokens(messages) + toolTokens)
|
||||
size = estimateTokens(messages) + toolTokens
|
||||
if size <= state.threshold {
|
||||
state.lowWaterMark = size
|
||||
return messages
|
||||
@@ -425,26 +380,19 @@ func (state *compactionState) maybeCompact(
|
||||
compacted, err := Compact(messages, CompactionOptions{
|
||||
PreserveLast: state.preserveLast,
|
||||
Summarize: summarizeClosure(ctx, provider, state.onUsage),
|
||||
taskState: state.task.snapshotForCompaction(messages),
|
||||
})
|
||||
if err != nil {
|
||||
// Summarizer failed: keep the original history. The reactive path (or a
|
||||
// later turn) can try again; we never drop messages on failure here.
|
||||
return messages
|
||||
}
|
||||
newSize := state.calibratedTokens(estimateTokens(compacted) + toolTokens)
|
||||
newSize := estimateTokens(compacted) + toolTokens
|
||||
if newSize >= size {
|
||||
// Compaction did not actually shrink anything (e.g. nothing to
|
||||
// summarize). Leave the history untouched and don't churn next turn.
|
||||
state.lowWaterMark = size
|
||||
return messages
|
||||
}
|
||||
// Only count a compaction when it actually shrank the history, so the
|
||||
// compaction counter reflects real context reductions rather than paid
|
||||
// no-ops that left the token budget untouched.
|
||||
if r := trace.FromContext(ctx); r != nil {
|
||||
r.Counter(trace.CounterCompactionCount, 1)
|
||||
}
|
||||
state.lowWaterMark = newSize
|
||||
return compacted
|
||||
}
|
||||
@@ -476,7 +424,6 @@ func (state *compactionState) recover(
|
||||
result, compactErr := Compact(messages, CompactionOptions{
|
||||
PreserveLast: state.preserveLast,
|
||||
Summarize: summarizeClosure(ctx, provider, state.onUsage),
|
||||
taskState: state.task.snapshotForCompaction(messages),
|
||||
})
|
||||
if compactErr != nil {
|
||||
// A genuine compaction attempt was made (and failed): the budget is spent
|
||||
@@ -495,13 +442,9 @@ func (state *compactionState) recover(
|
||||
// one-shot budget now so a provider that keeps returning context-limit errors
|
||||
// after a successful compaction can't loop forever. Store the low-water mark in
|
||||
// the SAME combined (messages + tool-defs) domain maybeCompact uses, so the
|
||||
// proactive shrink-guard compares like with like. Count it now that the shrink
|
||||
// is confirmed, so the counter mirrors maybeCompact's real-reduction policy.
|
||||
if r := trace.FromContext(ctx); r != nil {
|
||||
r.Counter(trace.CounterCompactionCount, 1)
|
||||
}
|
||||
// proactive shrink-guard compares like with like.
|
||||
state.reactiveAttempted = true
|
||||
state.lowWaterMark = state.calibratedTokens(estimateTokens(result) + estimateToolDefTokens(tools))
|
||||
state.lowWaterMark = estimateTokens(result) + estimateToolDefTokens(tools)
|
||||
return result, true, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
package agent
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCalibrationConvergesTowardActual(t *testing.T) {
|
||||
state := &compactionState{enabled: true}
|
||||
|
||||
// Before any sample, the estimate is unchanged.
|
||||
if got := state.calibratedTokens(1000); got != 1000 {
|
||||
t.Fatalf("uncalibrated tokens = %d, want 1000", got)
|
||||
}
|
||||
|
||||
// The estimator over-counts: our raw estimate is 1000 but the provider reports
|
||||
// 850 (a 0.85 ratio). Feeding that sample repeatedly should pull the ratio
|
||||
// toward 0.85, so a 1000-token raw estimate calibrates downward.
|
||||
for range 20 {
|
||||
state.calibrate(1000, 850)
|
||||
}
|
||||
got := state.calibratedTokens(1000)
|
||||
if got >= 1000 || got < 820 || got > 880 {
|
||||
t.Fatalf("calibrated tokens = %d, want ~850 after convergence", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalibrateIgnoresDegenerateSamples(t *testing.T) {
|
||||
state := &compactionState{enabled: true}
|
||||
state.calibrate(0, 500) // zero estimate
|
||||
state.calibrate(500, 0) // zero actual
|
||||
if state.calibrationRatio != 0 {
|
||||
t.Fatalf("degenerate samples must not move the ratio, got %v", state.calibrationRatio)
|
||||
}
|
||||
// Disabled state never calibrates.
|
||||
disabled := &compactionState{enabled: false}
|
||||
disabled.calibrate(1000, 850)
|
||||
if disabled.calibrationRatio != 0 {
|
||||
t.Fatalf("disabled compaction must not calibrate, got %v", disabled.calibrationRatio)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalibrateClampsOutliers(t *testing.T) {
|
||||
state := &compactionState{enabled: true}
|
||||
// A wild outlier (10x) is clamped to 2.0, so one bad sample can't blow up the
|
||||
// ratio and disable compaction.
|
||||
for range 50 {
|
||||
state.calibrate(1000, 10000)
|
||||
}
|
||||
if state.calibrationRatio > 2.0 {
|
||||
t.Fatalf("ratio should be clamped at 2.0, got %v", state.calibrationRatio)
|
||||
}
|
||||
}
|
||||
@@ -20,20 +20,6 @@ const (
|
||||
toolNameUpdatePlan = "update_plan"
|
||||
toolNameToolSearch = "tool_search"
|
||||
toolNameSkill = "skill"
|
||||
toolNameWriteFile = "write_file"
|
||||
toolNameEditFile = "edit_file"
|
||||
)
|
||||
|
||||
// maxRecentEdits caps how many edited files are carried across a compaction, and
|
||||
// maxEditNoteBytes caps each edit's one-line note, so a long editing run can't
|
||||
// bloat the preserved-state block.
|
||||
const (
|
||||
maxRecentEdits = 20
|
||||
maxEditNoteBytes = 160
|
||||
// maxTaskObjectiveBytes keeps the original objective recognizable after
|
||||
// compaction without allowing an unusually large prompt to recreate the
|
||||
// context pressure the compaction just removed.
|
||||
maxTaskObjectiveBytes = 512
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -80,14 +66,32 @@ func extractLatestPlan(messages []zeroruntime.Message) string {
|
||||
// ({"plan":[{content,status,...}]}) as terse status-tagged bullet lines. Returns
|
||||
// "" on malformed arguments or an empty plan.
|
||||
func formatPlanArguments(arguments string) string {
|
||||
plan, ok := parseTaskPlan(arguments)
|
||||
if !ok {
|
||||
var parsed struct {
|
||||
Plan []struct {
|
||||
Content string `json:"content"`
|
||||
Step string `json:"step"`
|
||||
Status string `json:"status"`
|
||||
Notes string `json:"notes"`
|
||||
} `json:"plan"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(strings.TrimSpace(arguments)), &parsed); err != nil {
|
||||
return ""
|
||||
}
|
||||
lines := make([]string, 0, len(plan))
|
||||
for _, item := range plan {
|
||||
line := "- [" + item.Status + "] " + item.Content
|
||||
if notes := item.Notes; notes != "" {
|
||||
lines := make([]string, 0, len(parsed.Plan))
|
||||
for _, item := range parsed.Plan {
|
||||
content := strings.TrimSpace(item.Content)
|
||||
if content == "" {
|
||||
content = strings.TrimSpace(item.Step)
|
||||
}
|
||||
if content == "" {
|
||||
continue
|
||||
}
|
||||
status := strings.TrimSpace(item.Status)
|
||||
if status == "" {
|
||||
status = "pending"
|
||||
}
|
||||
line := "- [" + status + "] " + content
|
||||
if notes := strings.TrimSpace(item.Notes); notes != "" {
|
||||
line += "\n Notes: " + notes
|
||||
}
|
||||
lines = append(lines, line)
|
||||
@@ -96,123 +100,12 @@ func formatPlanArguments(arguments string) string {
|
||||
}
|
||||
|
||||
// skillEntry is a named preserved body. It began as loaded-skill state and is
|
||||
// reused for loaded tools, project instruction blocks, and recent file edits.
|
||||
// reused for loaded tools and project instruction blocks.
|
||||
type skillEntry struct {
|
||||
name string
|
||||
body string
|
||||
}
|
||||
|
||||
// recentEdits returns the files mutated by write_file/edit_file calls in messages
|
||||
// — latest note per path, in last-seen order — as skillEntry{name: path, body:
|
||||
// note}. After compaction elides the editing turns, this tells the model WHAT it
|
||||
// changed in each file (from the tool's result) so it need not re-read to
|
||||
// rediscover its own footprint. Capped at maxRecentEdits paths.
|
||||
//
|
||||
// Ordering is by LAST edit, not first: re-editing a file moves it to the newest
|
||||
// position so the tail cap below keeps the file the model most recently touched
|
||||
// rather than an earlier, now-stale entry.
|
||||
func recentEdits(messages []zeroruntime.Message) []skillEntry {
|
||||
pathByID := map[string]string{}
|
||||
sequence := make([]string, 0)
|
||||
for _, message := range messages {
|
||||
for _, call := range message.ToolCalls {
|
||||
if call.Name != toolNameWriteFile && call.Name != toolNameEditFile {
|
||||
continue
|
||||
}
|
||||
path := editPathFromArguments(call.Arguments)
|
||||
if path == "" {
|
||||
continue
|
||||
}
|
||||
if call.ID != "" {
|
||||
pathByID[call.ID] = path
|
||||
}
|
||||
sequence = append(sequence, path)
|
||||
}
|
||||
}
|
||||
order := lastSeenOrder(sequence)
|
||||
if len(order) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
noteByPath := map[string]string{}
|
||||
for _, message := range messages {
|
||||
if message.Role != zeroruntime.MessageRoleTool || message.ToolCallID == "" {
|
||||
continue
|
||||
}
|
||||
if path, ok := pathByID[message.ToolCallID]; ok {
|
||||
noteByPath[path] = editNote(message.Content)
|
||||
}
|
||||
}
|
||||
|
||||
// Keep the most recent maxRecentEdits paths (the tail of last-seen order).
|
||||
if len(order) > maxRecentEdits {
|
||||
order = order[len(order)-maxRecentEdits:]
|
||||
}
|
||||
entries := make([]skillEntry, 0, len(order))
|
||||
for _, path := range order {
|
||||
entries = append(entries, skillEntry{name: path, body: noteByPath[path]})
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
// lastSeenOrder dedupes paths keeping each at its LAST occurrence, preserving
|
||||
// chronological order otherwise. A re-edited path therefore lands at the newest
|
||||
// position rather than staying pinned to where it first appeared.
|
||||
func lastSeenOrder(paths []string) []string {
|
||||
lastIdx := make(map[string]int, len(paths))
|
||||
for i, p := range paths {
|
||||
lastIdx[p] = i
|
||||
}
|
||||
order := make([]string, 0, len(lastIdx))
|
||||
for i, p := range paths {
|
||||
if lastIdx[p] == i {
|
||||
order = append(order, p)
|
||||
}
|
||||
}
|
||||
return order
|
||||
}
|
||||
|
||||
// editPathFromArguments pulls the target file path from a write_file/edit_file
|
||||
// call's JSON arguments (path/file/file_path/filename aliases). Returns "" on
|
||||
// malformed arguments or no path.
|
||||
func editPathFromArguments(arguments string) string {
|
||||
var parsed struct {
|
||||
Path string `json:"path"`
|
||||
File string `json:"file"`
|
||||
FilePath string `json:"file_path"`
|
||||
Filename string `json:"filename"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(strings.TrimSpace(arguments)), &parsed); err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, candidate := range []string{parsed.Path, parsed.File, parsed.FilePath, parsed.Filename} {
|
||||
if s := strings.TrimSpace(candidate); s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// editNote reduces an edit tool's result to a single short line (its first
|
||||
// non-empty line, byte-capped on a rune boundary) for the preserved summary.
|
||||
func editNote(content string) string {
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
s := strings.TrimSpace(line)
|
||||
if s == "" {
|
||||
continue
|
||||
}
|
||||
if len(s) > maxEditNoteBytes {
|
||||
limit := maxEditNoteBytes
|
||||
for limit > 0 && !utf8.RuneStart(s[limit]) {
|
||||
limit--
|
||||
}
|
||||
s = s[:limit] + "…"
|
||||
}
|
||||
return s
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// loadedSkills returns the skills loaded via the skill tool in messages — the
|
||||
// latest body per name, in first-seen order — matching each skill tool call to
|
||||
// its tool result by id.
|
||||
@@ -417,30 +310,11 @@ func capBody(body string) string {
|
||||
// preservedState is the JSON shape of the carried-across-compaction block.
|
||||
type preservedState struct {
|
||||
Plan string `json:"plan,omitempty"`
|
||||
Task *preservedTaskState `json:"task,omitempty"`
|
||||
RecentEdits []preservedEdit `json:"recent_edits,omitempty"`
|
||||
Tools []preservedTool `json:"tools,omitempty"`
|
||||
Skills []preservedSkill `json:"skills,omitempty"`
|
||||
ProjectInstructions []preservedInstruction `json:"project_instructions,omitempty"`
|
||||
}
|
||||
|
||||
type preservedTaskState struct {
|
||||
Objective string `json:"objective"`
|
||||
Status taskStatus `json:"status,omitempty"`
|
||||
Pending int `json:"pending,omitempty"`
|
||||
InProgress int `json:"in_progress,omitempty"`
|
||||
Completed int `json:"completed,omitempty"`
|
||||
Failed int `json:"failed,omitempty"`
|
||||
VerificationPassed int `json:"verification_passed,omitempty"`
|
||||
VerificationFailed int `json:"verification_failed,omitempty"`
|
||||
VerificationOutcome Outcome `json:"verification_outcome,omitempty"`
|
||||
}
|
||||
|
||||
type preservedEdit struct {
|
||||
Path string `json:"path"`
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
type preservedTool struct {
|
||||
Name string `json:"name"`
|
||||
Body string `json:"body"`
|
||||
@@ -463,27 +337,8 @@ type preservedInstruction struct {
|
||||
// may live only inside the injected summary message, which on a later compaction
|
||||
// lands in middle with no real tool calls left to extract. Fresh tool calls and
|
||||
// instruction blocks override the carried-forward copy by name/source.
|
||||
func appendPreservedState(summary string, middle []zeroruntime.Message, taskSnapshot *taskStateSnapshot) string {
|
||||
func appendPreservedState(summary string, middle []zeroruntime.Message) string {
|
||||
priorState := parsePreservedStateBlock(latestSummaryContent(middle))
|
||||
task := priorState.Task
|
||||
if taskSnapshot != nil {
|
||||
task = &preservedTaskState{
|
||||
Objective: capTaskObjective(taskSnapshot.Objective),
|
||||
}
|
||||
// Plan parity corroborates only the mutable task projection. The objective
|
||||
// comes directly from the run prompt and is immutable, so it must survive
|
||||
// even after compaction removes the plan tool call needed for comparison.
|
||||
if taskSnapshot.PlanParity == taskPlanParityMatch {
|
||||
task.Status = taskSnapshot.Status
|
||||
task.Pending = taskSnapshot.Plan.Pending
|
||||
task.InProgress = taskSnapshot.Plan.InProgress
|
||||
task.Completed = taskSnapshot.Plan.Completed
|
||||
task.Failed = taskSnapshot.Plan.Failed
|
||||
task.VerificationPassed = taskSnapshot.Verification.Passed
|
||||
task.VerificationFailed = taskSnapshot.Verification.Failed
|
||||
task.VerificationOutcome = taskSnapshot.Verification.LastOutcome
|
||||
}
|
||||
}
|
||||
|
||||
// Plan: a fresh update_plan in middle is authoritative; otherwise carry the
|
||||
// plan preserved by an earlier compaction.
|
||||
@@ -492,12 +347,6 @@ func appendPreservedState(summary string, middle []zeroruntime.Message, taskSnap
|
||||
plan = priorState.Plan
|
||||
}
|
||||
|
||||
// Recent edits: merge edits preserved earlier with fresh write_file/edit_file
|
||||
// results in middle (newer note per path wins AND moves to the newest
|
||||
// position), so the tail cap keeps the files the model most recently touched
|
||||
// across repeated compactions rather than dropping a just-re-edited file.
|
||||
edits := capRecentEdits(mergeRecentEdits(preservedEditsToEntries(priorState.RecentEdits), recentEdits(middle)))
|
||||
|
||||
// Tools: preserve deferred tool_search schemas from the transcript. Fresh
|
||||
// loads override older carried copies by name.
|
||||
tools := mergeSkillEntries(preservedToolsToEntries(priorState.Tools), loadedToolSchemas(middle))
|
||||
@@ -511,89 +360,19 @@ func appendPreservedState(summary string, middle []zeroruntime.Message, taskSnap
|
||||
projectInstructionEntries(middle),
|
||||
)
|
||||
|
||||
if block := formatPreservedState(plan, task, edits, tools, skills, instructions); block != "" {
|
||||
if block := formatPreservedState(plan, tools, skills, instructions); block != "" {
|
||||
summary += "\n\n" + block
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
func capTaskObjective(objective string) string {
|
||||
objective = strings.TrimSpace(objective)
|
||||
if len(objective) <= maxTaskObjectiveBytes {
|
||||
return objective
|
||||
}
|
||||
const suffix = "…"
|
||||
limit := maxTaskObjectiveBytes - len(suffix)
|
||||
for limit > 0 && !utf8.RuneStart(objective[limit]) {
|
||||
limit--
|
||||
}
|
||||
return strings.TrimSpace(objective[:limit]) + suffix
|
||||
}
|
||||
|
||||
// mergeRecentEdits overlays fresh edits onto edits preserved by an earlier
|
||||
// compaction. Unlike mergeSkillEntries (which keeps refreshed entries in their
|
||||
// original slot), a path touched again by a fresh edit MOVES to the newest
|
||||
// position and takes the fresh note, so capRecentEdits — which keeps only the
|
||||
// most-recent tail — never drops a file the model just re-edited. Paths present
|
||||
// only in the older set keep their relative order, ahead of the fresh ones.
|
||||
func mergeRecentEdits(older, newer []skillEntry) []skillEntry {
|
||||
if len(newer) == 0 {
|
||||
return older
|
||||
}
|
||||
freshBody := make(map[string]string, len(newer))
|
||||
freshOrder := make([]string, 0, len(newer))
|
||||
for _, e := range newer {
|
||||
if _, ok := freshBody[e.name]; !ok {
|
||||
freshOrder = append(freshOrder, e.name)
|
||||
}
|
||||
freshBody[e.name] = e.body
|
||||
}
|
||||
merged := make([]skillEntry, 0, len(older)+len(freshOrder))
|
||||
// Older entries that were NOT re-edited keep their position, oldest first.
|
||||
for _, e := range older {
|
||||
if _, refreshed := freshBody[e.name]; refreshed {
|
||||
continue // re-appended at its newest position below
|
||||
}
|
||||
merged = append(merged, e)
|
||||
}
|
||||
// Fresh edits follow, in last-seen order, each carrying the fresh note — so
|
||||
// the most recently touched files sit at the tail the cap preserves.
|
||||
for _, name := range freshOrder {
|
||||
merged = append(merged, skillEntry{name: name, body: freshBody[name]})
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
// capRecentEdits bounds the preserved edit list to the most recent maxRecentEdits
|
||||
// entries after a merge, so repeated compactions can't grow it without limit.
|
||||
func capRecentEdits(entries []skillEntry) []skillEntry {
|
||||
if len(entries) > maxRecentEdits {
|
||||
return entries[len(entries)-maxRecentEdits:]
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func preservedEditsToEntries(edits []preservedEdit) []skillEntry {
|
||||
entries := make([]skillEntry, 0, len(edits))
|
||||
for _, e := range edits {
|
||||
if e.Path == "" {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, skillEntry{name: e.Path, body: e.Note})
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
// formatPreservedState renders state as the labelled, single-line
|
||||
// JSON block. Returns "" when there is nothing to preserve.
|
||||
func formatPreservedState(plan string, task *preservedTaskState, edits, tools, skills, instructions []skillEntry) string {
|
||||
if plan == "" && task == nil && len(edits) == 0 && len(tools) == 0 && len(skills) == 0 && len(instructions) == 0 {
|
||||
func formatPreservedState(plan string, tools, skills, instructions []skillEntry) string {
|
||||
if plan == "" && len(tools) == 0 && len(skills) == 0 && len(instructions) == 0 {
|
||||
return ""
|
||||
}
|
||||
state := preservedState{Plan: plan, Task: task}
|
||||
for _, e := range edits {
|
||||
state.RecentEdits = append(state.RecentEdits, preservedEdit{Path: e.name, Note: e.body})
|
||||
}
|
||||
state := preservedState{Plan: plan}
|
||||
for _, t := range tools {
|
||||
state.Tools = append(state.Tools, preservedTool{Name: t.name, Body: t.body})
|
||||
}
|
||||
@@ -610,6 +389,15 @@ func formatPreservedState(plan string, task *preservedTaskState, edits, tools, s
|
||||
return preservedStateLabel + "\n" + string(encoded)
|
||||
}
|
||||
|
||||
// parsePreservedState recovers the plan + skills from a prior summary's preserved
|
||||
// block. JSON escaping makes this lossless even when a skill body contains
|
||||
// markdown headings, code fences, or quotes. Returns ("", nil) when absent or
|
||||
// malformed.
|
||||
func parsePreservedState(summaryContent string) (string, []skillEntry) {
|
||||
state := parsePreservedStateBlock(summaryContent)
|
||||
return state.Plan, preservedSkillsToEntries(state.Skills)
|
||||
}
|
||||
|
||||
func parsePreservedStateBlock(summaryContent string) preservedState {
|
||||
idx := strings.LastIndex(summaryContent, preservedStateLabel)
|
||||
if idx < 0 {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
@@ -60,103 +59,6 @@ func TestCompactPreservesActivePlan(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactPreservesBoundedTaskContext(t *testing.T) {
|
||||
objective := strings.Repeat("世", maxTaskObjectiveBytes)
|
||||
task := newTaskState(objective, nil)
|
||||
task.observe(taskStateEvent{kind: taskStateEventPlan, arguments: `{"plan":[{"content":"write code","status":"in_progress"},{"content":"add tests","status":"pending"}]}`})
|
||||
messages := stateConversation()
|
||||
compacted, err := Compact(messages, CompactionOptions{
|
||||
PreserveLast: 2,
|
||||
Summarize: func([]zeroruntime.Message) (string, error) { return "SUMMARY", nil },
|
||||
taskState: task.snapshotForCompaction(messages),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Compact: %v", err)
|
||||
}
|
||||
state := parsePreservedStateBlock(compacted[1].Content)
|
||||
if state.Task == nil || state.Task.Status != taskStatusActive || state.Task.InProgress != 1 || state.Task.Pending != 1 {
|
||||
t.Fatalf("unexpected compact task state: %#v", state.Task)
|
||||
}
|
||||
if len(state.Task.Objective) > maxTaskObjectiveBytes || !utf8.ValidString(state.Task.Objective) {
|
||||
t.Fatalf("objective was not safely bounded: %d bytes %q", len(state.Task.Objective), state.Task.Objective)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactPreservesObjectiveAfterPlanParityMismatch(t *testing.T) {
|
||||
prior := preservedState{Task: &preservedTaskState{Objective: "stale objective", Status: taskStatusActive, Pending: 1}}
|
||||
encoded, err := json.Marshal(prior)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
messages := []zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleSystem, Content: "system"},
|
||||
{Role: zeroruntime.MessageRoleUser, Content: summaryLabel + "\nold\n\n" + preservedStateLabel + "\n" + string(encoded)},
|
||||
{Role: zeroruntime.MessageRoleAssistant, Content: "continuing"},
|
||||
{Role: zeroruntime.MessageRoleUser, Content: "more"},
|
||||
{Role: zeroruntime.MessageRoleAssistant, Content: "working"},
|
||||
{Role: zeroruntime.MessageRoleUser, Content: "again"},
|
||||
{Role: zeroruntime.MessageRoleAssistant, Content: "done"},
|
||||
}
|
||||
compacted, err := Compact(messages, CompactionOptions{
|
||||
PreserveLast: 2,
|
||||
Summarize: func([]zeroruntime.Message) (string, error) { return "SUMMARY", nil },
|
||||
taskState: &taskStateSnapshot{
|
||||
Objective: "current objective",
|
||||
PlanParity: taskPlanParityMismatch,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Compact: %v", err)
|
||||
}
|
||||
state := parsePreservedStateBlock(compacted[1].Content)
|
||||
if state.Task == nil || state.Task.Objective != "current objective" {
|
||||
t.Fatalf("immutable objective was lost on plan mismatch: %#v", state.Task)
|
||||
}
|
||||
if state.Task.Status != "" || state.Task.Pending != 0 {
|
||||
t.Fatalf("uncorroborated mutable task fields survived plan mismatch: %#v", state.Task)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskObjectiveSurvivesRepeatedCompactionWithoutPlanRefresh(t *testing.T) {
|
||||
task := newTaskState("keep this objective", nil)
|
||||
task.observe(taskStateEvent{kind: taskStateEventPlan, arguments: `{"plan":[{"content":"write code","status":"in_progress"},{"content":"add tests","status":"pending"}]}`})
|
||||
messages := stateConversation()
|
||||
|
||||
first, err := Compact(messages, CompactionOptions{
|
||||
PreserveLast: 2,
|
||||
Summarize: func([]zeroruntime.Message) (string, error) { return "FIRST", nil },
|
||||
taskState: task.snapshotForCompaction(messages),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("first Compact: %v", err)
|
||||
}
|
||||
secondInput := append(append([]zeroruntime.Message{}, first...),
|
||||
zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: "more"},
|
||||
zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, Content: "working"},
|
||||
zeroruntime.Message{Role: zeroruntime.MessageRoleUser, Content: "again"},
|
||||
zeroruntime.Message{Role: zeroruntime.MessageRoleAssistant, Content: "done"},
|
||||
)
|
||||
snapshot := task.snapshotForCompaction(secondInput)
|
||||
if snapshot.PlanParity != taskPlanParityMismatch {
|
||||
t.Fatalf("plan call should be absent after first compaction, parity=%q", snapshot.PlanParity)
|
||||
}
|
||||
second, err := Compact(secondInput, CompactionOptions{
|
||||
PreserveLast: 2,
|
||||
Summarize: func([]zeroruntime.Message) (string, error) { return "SECOND", nil },
|
||||
taskState: snapshot,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("second Compact: %v", err)
|
||||
}
|
||||
state := parsePreservedStateBlock(second[1].Content)
|
||||
if state.Task == nil || state.Task.Objective != "keep this objective" {
|
||||
t.Fatalf("objective lost after second compaction: %#v", state.Task)
|
||||
}
|
||||
if state.Task.Status != "" || state.Task.InProgress != 0 {
|
||||
t.Fatalf("uncorroborated mutable fields survived second compaction: %#v", state.Task)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactPreservesLoadedSkills(t *testing.T) {
|
||||
summary := compactStateConversation(t, stateConversation())
|
||||
if !strings.Contains(summary, preservedStateLabel) {
|
||||
@@ -381,10 +283,12 @@ func TestExtractLatestPlanReturnsMostRecent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatPlanArgumentsRejectsUnsupportedStepAlias(t *testing.T) {
|
||||
func TestFormatPlanArgumentsAcceptsStepAlias(t *testing.T) {
|
||||
got := formatPlanArguments(`{"plan":[{"step":"write failing test","status":"in_progress"},{"content":"keep existing shape","status":"pending"}]}`)
|
||||
if got != "" {
|
||||
t.Fatalf("unsupported alias should reject the whole plan like update_plan, got %q", got)
|
||||
for _, want := range []string{"- [in_progress] write failing test", "- [pending] keep existing shape"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("expected %q in formatted plan, got %q", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// recentEdits extracts each mutated file's path and a one-line note from the
|
||||
// matching tool result, latest note per path in last-seen order.
|
||||
func TestRecentEditsExtractsPathsAndNotes(t *testing.T) {
|
||||
messages := []zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{
|
||||
{ID: "e1", Name: "write_file", Arguments: `{"path":"internal/foo.go","content":"package foo"}`},
|
||||
}},
|
||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "e1", Content: "Wrote internal/foo.go (12 lines)"},
|
||||
{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{
|
||||
{ID: "e2", Name: "edit_file", Arguments: `{"path":"internal/bar.go","old_string":"a","new_string":"b"}`},
|
||||
}},
|
||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "e2", Content: "Applied edit to internal/bar.go"},
|
||||
}
|
||||
|
||||
edits := recentEdits(messages)
|
||||
if len(edits) != 2 {
|
||||
t.Fatalf("expected 2 edited files, got %d: %#v", len(edits), edits)
|
||||
}
|
||||
if edits[0].name != "internal/foo.go" || !strings.Contains(edits[0].body, "12 lines") {
|
||||
t.Fatalf("first edit = %#v, want foo.go with its note", edits[0])
|
||||
}
|
||||
if edits[1].name != "internal/bar.go" || !strings.Contains(edits[1].body, "Applied edit") {
|
||||
t.Fatalf("second edit = %#v, want bar.go with its note", edits[1])
|
||||
}
|
||||
}
|
||||
|
||||
// After compaction elides the editing turns, the preserved-state block still
|
||||
// names the edited files and what changed, so the model needn't re-read them.
|
||||
func TestCompactionPreservesRecentEdits(t *testing.T) {
|
||||
messages := []zeroruntime.Message{
|
||||
{Role: zeroruntime.MessageRoleSystem, Content: "system"},
|
||||
{Role: zeroruntime.MessageRoleUser, Content: "add a flag"},
|
||||
{Role: zeroruntime.MessageRoleAssistant, Content: "editing", ToolCalls: []zeroruntime.ToolCall{
|
||||
{ID: "e1", Name: "write_file", Arguments: `{"path":"cmd/main.go","content":"..."}`},
|
||||
}},
|
||||
{Role: zeroruntime.MessageRoleTool, ToolCallID: "e1", Content: "Wrote cmd/main.go (adds --version flag)"},
|
||||
{Role: zeroruntime.MessageRoleAssistant, Content: "done"},
|
||||
{Role: zeroruntime.MessageRoleUser, Content: "continue"},
|
||||
{Role: zeroruntime.MessageRoleAssistant, Content: "continuing"},
|
||||
}
|
||||
summary := compactStateConversation(t, messages)
|
||||
|
||||
state := parsePreservedStateBlock(summary)
|
||||
if len(state.RecentEdits) != 1 {
|
||||
t.Fatalf("expected 1 preserved edit, got %#v", state.RecentEdits)
|
||||
}
|
||||
if state.RecentEdits[0].Path != "cmd/main.go" || !strings.Contains(state.RecentEdits[0].Note, "--version") {
|
||||
t.Fatalf("preserved edit = %#v, want cmd/main.go + its note", state.RecentEdits[0])
|
||||
}
|
||||
}
|
||||
|
||||
// A fresh edit note for a path overrides the one carried from an earlier
|
||||
// compaction (newer wins) and moves the path to the newest position rather than
|
||||
// duplicating it or leaving it pinned to its old slot.
|
||||
func TestRecentEditsMergeNewerWins(t *testing.T) {
|
||||
prior := preservedState{RecentEdits: []preservedEdit{{Path: "a.go", Note: "old note"}, {Path: "z.go", Note: "z"}}}
|
||||
older := preservedEditsToEntries(prior.RecentEdits)
|
||||
newer := []skillEntry{{name: "a.go", body: "new note"}, {name: "b.go", body: "added"}}
|
||||
|
||||
merged := mergeRecentEdits(older, newer)
|
||||
// z.go (untouched) keeps its lead; a.go is re-edited so it takes the new note
|
||||
// and moves behind z.go, ahead of the brand-new b.go: [z.go, a.go, b.go].
|
||||
if len(merged) != 3 {
|
||||
t.Fatalf("expected z.go + refreshed a.go + b.go, got %#v", merged)
|
||||
}
|
||||
if merged[0].name != "z.go" {
|
||||
t.Fatalf("untouched z.go should stay first, got %#v", merged)
|
||||
}
|
||||
if merged[1].name != "a.go" || merged[1].body != "new note" {
|
||||
t.Fatalf("re-edited a.go should move to the newest edits with the new note, got %#v", merged[1])
|
||||
}
|
||||
if merged[2].name != "b.go" {
|
||||
t.Fatalf("brand-new b.go should be last, got %#v", merged[2])
|
||||
}
|
||||
}
|
||||
|
||||
// Regression: once more than maxRecentEdits distinct files are tracked, re-editing
|
||||
// an early file must move it into the capped tail rather than leaving it to be
|
||||
// dropped — otherwise the next compaction omits exactly the file the model most
|
||||
// recently touched.
|
||||
func TestRecentEditsCapKeepsReeditedFile(t *testing.T) {
|
||||
// Earlier compaction preserved exactly maxRecentEdits files, f0 the oldest.
|
||||
older := make([]skillEntry, 0, maxRecentEdits)
|
||||
for i := 0; i < maxRecentEdits; i++ {
|
||||
older = append(older, skillEntry{name: fmt.Sprintf("f%d.go", i), body: "old"})
|
||||
}
|
||||
// Fresh window re-edits the OLDEST file (f0) and adds a brand-new file, so the
|
||||
// merged list exceeds the cap and something must be dropped.
|
||||
newer := []skillEntry{{name: "f0.go", body: "re-edited"}, {name: "fresh.go", body: "new"}}
|
||||
|
||||
merged := capRecentEdits(mergeRecentEdits(older, newer))
|
||||
if len(merged) != maxRecentEdits {
|
||||
t.Fatalf("cap should keep %d entries, got %d", maxRecentEdits, len(merged))
|
||||
}
|
||||
|
||||
var f0 *skillEntry
|
||||
for i := range merged {
|
||||
if merged[i].name == "f0.go" {
|
||||
f0 = &merged[i]
|
||||
}
|
||||
if merged[i].name == "f1.go" {
|
||||
t.Fatalf("f1.go (now the least-recently touched) should have been dropped, got %#v", merged)
|
||||
}
|
||||
}
|
||||
if f0 == nil {
|
||||
t.Fatalf("re-edited f0.go must survive the cap, got %#v", merged)
|
||||
}
|
||||
if f0.body != "re-edited" {
|
||||
t.Fatalf("surviving f0.go should carry the fresh note, got %q", f0.body)
|
||||
}
|
||||
}
|
||||
@@ -336,7 +336,7 @@ func TestRunProactiveCompactionTriggers(t *testing.T) {
|
||||
}
|
||||
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedReadFileTool(t.TempDir(), nil))
|
||||
registry.Register(tools.NewReadFileTool(t.TempDir()))
|
||||
|
||||
result, err := Run(context.Background(), strings.Repeat("y", 8000), provider, Options{
|
||||
Registry: registry,
|
||||
@@ -364,7 +364,7 @@ func TestRunNoCompactionWhenContextWindowZero(t *testing.T) {
|
||||
},
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedReadFileTool(t.TempDir(), nil))
|
||||
registry.Register(tools.NewReadFileTool(t.TempDir()))
|
||||
|
||||
_, err := Run(context.Background(), strings.Repeat("y", 8000), provider, Options{
|
||||
Registry: registry,
|
||||
@@ -427,7 +427,7 @@ func TestRunReactiveCompactionRecovers(t *testing.T) {
|
||||
// the compaction closure. read_file also returns a sizeable result that
|
||||
// bloats the history.
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedReadFileTool(t.TempDir(), nil))
|
||||
registry.Register(tools.NewReadFileTool(t.TempDir()))
|
||||
|
||||
// ContextWindow large enough that proactive compaction never triggers, so
|
||||
// only the reactive path can save the run.
|
||||
@@ -491,7 +491,7 @@ func TestRunReactiveRetryDoesNotDoubleEmitText(t *testing.T) {
|
||||
finalText: "recovered",
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedReadFileTool(t.TempDir(), nil))
|
||||
registry.Register(tools.NewReadFileTool(t.TempDir()))
|
||||
|
||||
var deltas []string
|
||||
result, err := Run(context.Background(), strings.Repeat("z", 6000), provider, Options{
|
||||
@@ -581,7 +581,7 @@ func TestCompactNeverProducesConsecutiveUserMessages(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRecoverNoopDoesNotConsumeReactiveBudget(t *testing.T) {
|
||||
st := newCompactionState(Options{ContextWindow: 1000, CompactionPreserveLast: 2}, nil)
|
||||
st := newCompactionState(Options{ContextWindow: 1000, CompactionPreserveLast: 2})
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
|
||||
{Type: zeroruntime.StreamEventText, Content: "SUMMARY"}, {Type: zeroruntime.StreamEventDone},
|
||||
}}}
|
||||
@@ -628,7 +628,7 @@ func TestRecoverNoopDoesNotConsumeReactiveBudget(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRecoverDisabledIsNoop(t *testing.T) {
|
||||
st := newCompactionState(Options{ContextWindow: 0}, nil)
|
||||
st := newCompactionState(Options{ContextWindow: 0})
|
||||
msgs := []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "x"}}
|
||||
called := false
|
||||
// recover must not invoke the provider/summarize when disabled, even on a
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
package agent
|
||||
|
||||
// CompletionDecision is the deterministic outcome of evaluating a text-only
|
||||
// assistant turn. Uncertain means the loop needs one bounded follow-up action
|
||||
// before it can finalize the run.
|
||||
type CompletionDecision string
|
||||
|
||||
const (
|
||||
CompletionUncertain CompletionDecision = "uncertain"
|
||||
CompletionComplete CompletionDecision = "complete"
|
||||
CompletionIncomplete CompletionDecision = "incomplete"
|
||||
)
|
||||
|
||||
type completionAction string
|
||||
|
||||
const (
|
||||
completionActionNone completionAction = ""
|
||||
completionActionContinue completionAction = "continue"
|
||||
completionActionSemanticCheck completionAction = "semantic_check"
|
||||
)
|
||||
|
||||
type completionEvaluation struct {
|
||||
Decision CompletionDecision
|
||||
Action completionAction
|
||||
Reason string
|
||||
}
|
||||
|
||||
// completionPolicy owns the small amount of state needed to keep uncertain
|
||||
// decisions bounded. requireSemanticCheck is set only for run profiles that
|
||||
// already opted into self-correction; ordinary completion remains entirely
|
||||
// local and adds no model call.
|
||||
type completionPolicy struct {
|
||||
continueNudges int
|
||||
requireSemanticCheck bool
|
||||
semanticCheckRequested bool
|
||||
}
|
||||
|
||||
func newCompletionPolicy(requireSemanticCheck bool) *completionPolicy {
|
||||
return &completionPolicy{requireSemanticCheck: requireSemanticCheck}
|
||||
}
|
||||
|
||||
func (policy *completionPolicy) evaluate(text string, context completionContext) 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 || context.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}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCompletionPolicyLocalEvidenceDecidesWithoutSemanticCheck(t *testing.T) {
|
||||
policy := newCompletionPolicy(false)
|
||||
|
||||
complete := policy.evaluate("Done. All required checks pass.", completionContext{})
|
||||
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.", completionContext{})
|
||||
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:", completionContext{PlanPending: 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:", completionContext{PlanPending: 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.", completionContext{PlanPending: 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.", completionContext{PlanPending: 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.", completionContext{})
|
||||
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.", completionContext{})
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcceptanceVerificationNudgeIncludesBoundedObjective(t *testing.T) {
|
||||
objective := "make the requested behavior observable"
|
||||
nudge := acceptanceVerificationNudge(objective)
|
||||
if !strings.Contains(nudge, "Task objective: "+objective) {
|
||||
t.Fatalf("semantic check was not grounded in the objective: %q", nudge)
|
||||
}
|
||||
}
|
||||
@@ -150,7 +150,7 @@ func assertNoDeferredDiscoveryMessage(t *testing.T, request zeroruntime.Completi
|
||||
func TestPartitionToolsInactiveIsByteIdenticalAndDropsToolSearch(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
registry.Register(fakeDeferredTool{name: "mcp__srv__a", desc: "tool a"})
|
||||
registry.Register(fakeToolSearchTool{})
|
||||
|
||||
@@ -305,8 +305,8 @@ func TestPartitionToolsActiveExcludesDisabledDeferredFromDiscoveryAndExposed(t *
|
||||
func TestPartitionToolsActiveHidesUnloadedExposesLoaded(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil)) // non-deferred builtin
|
||||
registry.Register(fakeToolSearchTool{}) // non-deferred, must stay exposed
|
||||
registry.Register(tools.NewReadFileTool(root)) // non-deferred builtin
|
||||
registry.Register(fakeToolSearchTool{}) // non-deferred, must stay exposed
|
||||
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha tool"})
|
||||
registry.Register(fakeDeferredTool{name: "mcp__srv__beta", desc: "beta tool"})
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
func TestExecuteToolCallCategorizesFilteredDenial(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
|
||||
result, _ := executeToolCall(context.Background(), registry, ToolCall{ID: "c1", Name: "read_file", Arguments: `{"path":"x"}`}, PermissionModeAuto, Options{
|
||||
DisabledTools: []string{"read_file"},
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
// Test seams: helpers only test code uses, kept out of the production binary.
|
||||
package agent
|
||||
|
||||
import (
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
func equalStringSlices(left []string, right []string) bool {
|
||||
if len(left) != len(right) {
|
||||
return false
|
||||
}
|
||||
for index := range left {
|
||||
if left[index] != right[index] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// parsePreservedState recovers the plan + skills from a prior summary's preserved
|
||||
// block. JSON escaping makes this lossless even when a skill body contains
|
||||
// markdown headings, code fences, or quotes. Returns ("", nil) when absent or
|
||||
// malformed.
|
||||
func parsePreservedState(summaryContent string) (string, []skillEntry) {
|
||||
state := parsePreservedStateBlock(summaryContent)
|
||||
return state.Plan, preservedSkillsToEntries(state.Skills)
|
||||
}
|
||||
|
||||
// partitionTools builds the per-turn advertised tool list and optional
|
||||
// tool_search discovery text. INACTIVE (DeferThreshold <= 0 or the eligible count is
|
||||
// below it): every visible tool is exposed with its full schema EXCEPT tool_search
|
||||
// (dropped so it is never advertised when it cannot help), and the discovery text is
|
||||
// empty — byte-identical to the pre-deferral output. ACTIVE: a deferred-eligible
|
||||
// tool is exposed only when loaded[name]; otherwise it is hidden and searchable
|
||||
// through tool_search. Non-deferred tools (including tool_search) are always
|
||||
// exposed. The exposed slice is alpha-sorted by name, matching the legacy order
|
||||
// so the inactive path is stable.
|
||||
func partitionTools(registry *tools.Registry, permissionMode PermissionMode, options Options, loaded map[string]bool) ([]zeroruntime.ToolDefinition, string) {
|
||||
return partitionToolsCached(registry, permissionMode, options, loaded, nil)
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/lsp"
|
||||
)
|
||||
|
||||
// fileDiagnosticsTimeout bounds one inline post-edit diagnostics check so a
|
||||
// slow or wedged language server can never hang a tool call; on timeout the
|
||||
// edit simply reports without a diagnostics block.
|
||||
const fileDiagnosticsTimeout = 10 * time.Second
|
||||
|
||||
// NewFileDiagnostics adapts an *lsp.Manager to the per-edit inline diagnostics
|
||||
// callback (tools.RunOptions.Diagnostics): it reads the just-written file,
|
||||
// checks it against the file's language server, and formats error-severity
|
||||
// diagnostics for the model. Warnings and hints are excluded — nagging about
|
||||
// style on every edit is noise, while a type error the edit just introduced is
|
||||
// exactly what the model should see before its next step. Diagnostics are
|
||||
// rendered with workspace-relative paths: the absolute path would put the
|
||||
// local username/home directory into the model prompt and session transcript
|
||||
// on every edit. Returns nil when manager is nil, disabling inline diagnostics
|
||||
// entirely.
|
||||
func NewFileDiagnostics(manager *lsp.Manager, workspaceRoot string) func(context.Context, string) string {
|
||||
if manager == nil {
|
||||
return nil
|
||||
}
|
||||
return func(ctx context.Context, absPath string) string {
|
||||
text, err := os.ReadFile(absPath)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
checkCtx, cancel := context.WithTimeout(ctx, fileDiagnosticsTimeout)
|
||||
defer cancel()
|
||||
diagnostics, err := manager.Check(checkCtx, absPath, string(text))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
errors := lsp.FilterBySeverity(diagnostics, lsp.SeverityError)
|
||||
if len(errors) == 0 {
|
||||
return ""
|
||||
}
|
||||
return lsp.FormatDiagnostics(diagnosticsDisplayPath(workspaceRoot, absPath), errors)
|
||||
}
|
||||
}
|
||||
|
||||
// diagnosticsDisplayPath renders absPath relative to the workspace root for
|
||||
// model-facing output, falling back to the file's base name when the path is
|
||||
// outside the workspace (a bare name still identifies the file without
|
||||
// exposing the directory layout).
|
||||
func diagnosticsDisplayPath(workspaceRoot, absPath string) string {
|
||||
if workspaceRoot != "" {
|
||||
if rel, err := filepath.Rel(workspaceRoot, absPath); err == nil && !strings.HasPrefix(rel, "..") {
|
||||
return rel
|
||||
}
|
||||
}
|
||||
return filepath.Base(absPath)
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Diagnostics are model-facing: absolute paths would leak the local username
|
||||
// and directory layout into the prompt and session transcript on every edit.
|
||||
func TestDiagnosticsDisplayPath(t *testing.T) {
|
||||
root := filepath.Join("/Users", "someone", "project")
|
||||
cases := []struct {
|
||||
root, abs, want string
|
||||
}{
|
||||
{root, filepath.Join(root, "internal", "a.go"), filepath.Join("internal", "a.go")},
|
||||
{root, filepath.Join("/etc", "other.go"), "other.go"}, // outside root -> base name only
|
||||
{"", filepath.Join("/home", "user", "x.go"), "x.go"}, // no root -> base name only
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := diagnosticsDisplayPath(c.root, c.abs); got != c.want {
|
||||
t.Errorf("diagnosticsDisplayPath(%q, %q) = %q, want %q", c.root, c.abs, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
+40
-138
@@ -1,6 +1,7 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -21,12 +22,6 @@ const (
|
||||
// calls have executed since the last update_plan call.
|
||||
staleToolCallThreshold = 10
|
||||
|
||||
// stalePlanTurnThreshold injects the same one-shot reminder once this many
|
||||
// turns have passed since the last update_plan while plan items are still
|
||||
// pending — the turn-based complement to staleToolCallThreshold, catching a
|
||||
// plan that drifts stale across many turns that each make few tool calls.
|
||||
stalePlanTurnThreshold = 8
|
||||
|
||||
// toolOnlyProgressReminderAt injects a one-shot progress nudge after this
|
||||
// many consecutive turns contain tool calls but no visible assistant text.
|
||||
// It does not stop the run; it tells the model to synthesize what it already
|
||||
@@ -48,12 +43,8 @@ const (
|
||||
toolFailureHintAt = 2
|
||||
// toolFailureStopAt halts the run after a tool fails this many times in a row
|
||||
// with the same error, so NO model (weak or strong) burns turns looping on a
|
||||
// bad call. Set to 6 (not 4): a corrective hint fires at toolFailureHintAt (2),
|
||||
// and a model iterating on a genuinely tricky edit can legitimately fail a few
|
||||
// times after the hint while converging — stopping at 4 cut those runs short.
|
||||
// The streak still resets the moment the tool succeeds or hits a different
|
||||
// error, so this only affects true same-error loops.
|
||||
toolFailureStopAt = 6
|
||||
// bad call.
|
||||
toolFailureStopAt = 4
|
||||
|
||||
// maxContinueNudges bounds how many times the headless completion gate
|
||||
// (Options.RequireCompletionSignal) re-prompts a model that stopped without a
|
||||
@@ -126,8 +117,9 @@ func endsWithContinuationCue(text string) bool {
|
||||
// which the update_plan tool coerces to "pending") counts as remaining, matching
|
||||
// that tool's own status normalization.
|
||||
func planStatusRemaining(status string) bool {
|
||||
switch normalizeTaskPlanStatus(status) {
|
||||
case "completed", "failed":
|
||||
switch strings.ToLower(strings.TrimSpace(status)) {
|
||||
case "completed", "complete", "done", "finished", "resolved", "✓", "x", "[x]",
|
||||
"failed", "fail", "error", "errored", "blocked", "cancelled", "canceled", "abandoned", "skipped":
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
@@ -181,112 +173,35 @@ var successNegationTails = []string{
|
||||
"reproduce", "spot any", "locate any",
|
||||
}
|
||||
|
||||
// narrativeMarkers flag a sentence as RETELLING a past exchange rather than
|
||||
// reporting the outcome of the current objective, so an inability admission in
|
||||
// that sentence is about THEN, not NOW. Grounded in a real false positive: a
|
||||
// conversational recap ("You asked if I could work autonomously … I was honest
|
||||
// that my sandbox had no repo … so I couldn't actually do it at the time") was
|
||||
// downgraded to INCOMPLETE on the quoted "i couldn't " stem even though the
|
||||
// current turn's objective (summarize where we left off) was fully met. Every
|
||||
// marker is second-person or explicitly past-referential, so a first-person
|
||||
// admission about the current task ("I couldn't complete the refactor") is
|
||||
// never masked.
|
||||
var narrativeMarkers = []string{
|
||||
"you asked", "you said", "you wanted", "you mentioned",
|
||||
"we discussed", "we talked", "we covered",
|
||||
"when we last", "last time", "last session", "previous session",
|
||||
"previous conversation", "earlier session", "earlier conversation",
|
||||
"at the time", "back then",
|
||||
}
|
||||
|
||||
// stripQuoted removes spans enclosed in double quotes (straight or curly) or
|
||||
// backticks, so an admission the model merely QUOTES — its own earlier message,
|
||||
// a log line, an error string — cannot fire the detector. Only BALANCED spans
|
||||
// are removed: an opening delimiter that never closes is kept as literal text,
|
||||
// so a stray quote cannot swallow the rest of the message (and with it a
|
||||
// genuine admission the detector must see). Single quotes are left alone: they
|
||||
// are overwhelmingly apostrophes ("couldn't"), and treating them as quote
|
||||
// delimiters would swallow the text between two contractions.
|
||||
func stripQuoted(s string) string {
|
||||
var b strings.Builder
|
||||
var span strings.Builder // pending text since the open delimiter, kept if it never closes
|
||||
open := rune(0)
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case open != 0:
|
||||
if (open == '"' && r == '"') || (open == '“' && r == '”') || (open == '`' && r == '`') {
|
||||
open = 0
|
||||
span.Reset()
|
||||
continue
|
||||
}
|
||||
span.WriteRune(r)
|
||||
case r == '"' || r == '“' || r == '`':
|
||||
open = r
|
||||
span.WriteRune(r)
|
||||
default:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
b.WriteString(span.String()) // dangling delimiter: restore the span verbatim
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// admissionSentences splits lowered text into sentence-ish fragments so the
|
||||
// detector judges each claim in its own context. Newlines split too (markdown
|
||||
// bullets are separate claims); the exact boundaries only need to keep an
|
||||
// admission next to its own narrative/negation context, not be grammatical.
|
||||
func admissionSentences(lower string) []string {
|
||||
return strings.FieldsFunc(lower, func(r rune) bool {
|
||||
return r == '.' || r == '!' || r == '?' || r == '\n'
|
||||
})
|
||||
}
|
||||
|
||||
// selfReportedIncompletion returns a short reason when the model's final text
|
||||
// admits it guessed or could not meet the objective, else "". Case-insensitive.
|
||||
// Matching is per sentence, after dropping quoted spans, and a sentence that
|
||||
// retells a past exchange (narrativeMarkers) is skipped entirely — an admission
|
||||
// must be the model's own report about the CURRENT objective, not general
|
||||
// language that merely resembles one.
|
||||
func selfReportedIncompletion(text string) string {
|
||||
for _, sentence := range admissionSentences(strings.ToLower(stripQuoted(text))) {
|
||||
if containsAny(sentence, narrativeMarkers) {
|
||||
continue
|
||||
lower := strings.ToLower(text)
|
||||
for _, phrase := range selfReportPhrases {
|
||||
if strings.Contains(lower, phrase) {
|
||||
return selfReportReason(phrase)
|
||||
}
|
||||
for _, phrase := range selfReportPhrases {
|
||||
if strings.Contains(sentence, phrase) {
|
||||
return selfReportReason(phrase)
|
||||
}
|
||||
for _, stem := range inabilityStems {
|
||||
// Scan EVERY occurrence of the stem, not just the first: an earlier
|
||||
// success-negation use ("I could not find any examples, so I could not
|
||||
// implement it") must not mask a later genuine admission with the same stem.
|
||||
for start := 0; ; {
|
||||
rel := strings.Index(lower[start:], stem)
|
||||
if rel < 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
for _, stem := range inabilityStems {
|
||||
// Scan EVERY occurrence of the stem, not just the first: an earlier
|
||||
// success-negation use ("I could not find any examples, so I could not
|
||||
// implement it") must not mask a later genuine admission with the same stem.
|
||||
for start := 0; ; {
|
||||
rel := strings.Index(sentence[start:], stem)
|
||||
if rel < 0 {
|
||||
break
|
||||
}
|
||||
abs := start + rel
|
||||
tail := strings.TrimSpace(sentence[abs+len(stem):])
|
||||
if !hasAnyPrefix(tail, successNegationTails) {
|
||||
return selfReportReason(strings.TrimSpace(stem) + " …")
|
||||
}
|
||||
start = abs + len(stem)
|
||||
abs := start + rel
|
||||
tail := strings.TrimSpace(lower[abs+len(stem):])
|
||||
if !hasAnyPrefix(tail, successNegationTails) {
|
||||
return selfReportReason(strings.TrimSpace(stem) + " …")
|
||||
}
|
||||
start = abs + len(stem)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func containsAny(s string, subs []string) bool {
|
||||
for _, sub := range subs {
|
||||
if strings.Contains(s, sub) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func selfReportReason(marker string) string {
|
||||
return `the final message admits the objective was not met ("` + marker + `")`
|
||||
}
|
||||
@@ -305,8 +220,8 @@ func hasAnyPrefix(s string, prefixes []string) bool {
|
||||
// the bug-hunt surfaced: well-formed output treated as correct; pre-existing tests
|
||||
// passing treated as the objective being met; and a result that merely matches a
|
||||
// baseline the task asked to beat or improve. General — no task-specific content.
|
||||
func acceptanceVerificationNudge(objective string) string {
|
||||
nudge := "Before this task can be marked complete, " + acceptanceNudgeMarker + " — " +
|
||||
func acceptanceVerificationNudge() string {
|
||||
return "Before this task can be marked complete, " + acceptanceNudgeMarker + " — " +
|
||||
"NOT the shape or format of your output, NOT that pre-existing tests pass, and NOT that your " +
|
||||
"result merely matches a baseline you were asked to beat or improve. " +
|
||||
"Re-read the original task, then run a concrete check that exercises the actual requirement: " +
|
||||
@@ -314,10 +229,6 @@ func acceptanceVerificationNudge(objective string) string {
|
||||
"thing the task asked you to produce, recover, fix, or optimize. " +
|
||||
"If that check passes, reply PASS and cite the evidence. " +
|
||||
"If it does not pass — or you cannot run such a check — say so plainly and keep working; do not claim success."
|
||||
if objective = capTaskObjective(objective); objective != "" {
|
||||
nudge += "\n\nTask objective: " + objective
|
||||
}
|
||||
return nudge
|
||||
}
|
||||
|
||||
// toolFailureHintMarker is a stable substring for tests.
|
||||
@@ -443,13 +354,8 @@ type guardState struct {
|
||||
emptyTurns int
|
||||
totalToolCalls int
|
||||
toolCallsSincePlanUpdate int
|
||||
// turnsSincePlanUpdate counts turns (not individual tool calls) since the last
|
||||
// update_plan, so a plan that goes stale across many low-tool-call turns is
|
||||
// still caught — the tool-call counter alone can take many turns to trip when
|
||||
// the model makes only one call per turn.
|
||||
turnsSincePlanUpdate int
|
||||
planEverCalled bool
|
||||
notCalledReminderSent bool
|
||||
planEverCalled bool
|
||||
notCalledReminderSent bool
|
||||
// staleReminderSent records whether the stale reminder has already fired for
|
||||
// the current stale interval. It is cleared when a plan update opens a new
|
||||
// interval, making the reminder one-shot per interval rather than per turn.
|
||||
@@ -508,9 +414,8 @@ func (state *guardState) observeToolResult(name string, failed bool, output stri
|
||||
func (state *guardState) observeTurn(collected zeroruntime.CollectedStream) (stop bool) {
|
||||
hasToolCalls := len(collected.ToolCalls) > 0
|
||||
hasVisibleText := strings.TrimSpace(collected.Text) != ""
|
||||
hasReasoning := collected.HasReasoning || len(collected.ReasoningBlocks) > 0
|
||||
|
||||
if hasToolCalls || hasVisibleText || hasReasoning {
|
||||
if hasToolCalls || hasVisibleText {
|
||||
state.emptyTurns = 0
|
||||
} else {
|
||||
state.emptyTurns++
|
||||
@@ -522,16 +427,11 @@ func (state *guardState) observeTurn(collected zeroruntime.CollectedStream) (sto
|
||||
state.toolOnlyReminderSent = false
|
||||
}
|
||||
|
||||
// One turn has passed; the plan-update below resets this to 0 when the model
|
||||
// refreshes the plan this turn.
|
||||
state.turnsSincePlanUpdate++
|
||||
|
||||
for _, call := range collected.ToolCalls {
|
||||
state.totalToolCalls++
|
||||
if call.Name == planToolName {
|
||||
state.planEverCalled = true
|
||||
state.toolCallsSincePlanUpdate = 0
|
||||
state.turnsSincePlanUpdate = 0
|
||||
// A fresh plan update opens a new stale interval.
|
||||
state.staleReminderSent = false
|
||||
// Record how many items remain so the completion gate knows whether
|
||||
@@ -555,12 +455,16 @@ func (state *guardState) pendingPlanItems() bool {
|
||||
// many items are still remaining. Malformed arguments leave the prior count
|
||||
// unchanged (best-effort — the plan panel itself tolerates the same).
|
||||
func (state *guardState) observePlanUpdate(arguments string) {
|
||||
plan, ok := parseTaskPlan(arguments)
|
||||
if !ok {
|
||||
var parsed struct {
|
||||
Plan []struct {
|
||||
Status string `json:"status"`
|
||||
} `json:"plan"`
|
||||
}
|
||||
if json.Unmarshal([]byte(arguments), &parsed) != nil {
|
||||
return
|
||||
}
|
||||
pending := 0
|
||||
for _, item := range plan {
|
||||
for _, item := range parsed.Plan {
|
||||
if planStatusRemaining(item.Status) {
|
||||
pending++
|
||||
}
|
||||
@@ -581,12 +485,10 @@ func (state *guardState) progressReminder() string {
|
||||
// number of turns completed so far).
|
||||
func (state *guardState) planReminder(turn int) string {
|
||||
// STALE reminder takes priority: a long run without a plan update is the
|
||||
// stronger signal. Fires on either the tool-call streak OR a turn streak with
|
||||
// pending items (so a plan drifting stale across many low-call turns is caught,
|
||||
// while a fully-completed plan is left alone). One-shot per stale interval.
|
||||
if state.planEverCalled && !state.staleReminderSent &&
|
||||
(state.toolCallsSincePlanUpdate >= staleToolCallThreshold ||
|
||||
(state.turnsSincePlanUpdate >= stalePlanTurnThreshold && state.planItemsPending > 0)) {
|
||||
// stronger signal. One-shot per stale interval.
|
||||
if state.planEverCalled &&
|
||||
!state.staleReminderSent &&
|
||||
state.toolCallsSincePlanUpdate >= staleToolCallThreshold {
|
||||
state.staleReminderSent = true
|
||||
return planStaleReminder(state.toolCallsSincePlanUpdate)
|
||||
}
|
||||
|
||||
@@ -22,14 +22,6 @@ func textTurn(content string) []zeroruntime.StreamEvent {
|
||||
}
|
||||
}
|
||||
|
||||
// reasoningTurn produces live reasoning without visible assistant text.
|
||||
func reasoningTurn(content string) []zeroruntime.StreamEvent {
|
||||
return []zeroruntime.StreamEvent{
|
||||
{Type: zeroruntime.StreamEventReasoning, Content: content},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
}
|
||||
}
|
||||
|
||||
// toolTurn produces a turn that calls a named tool with the given args JSON.
|
||||
func toolTurn(callID string, toolName string, args string) []zeroruntime.StreamEvent {
|
||||
return []zeroruntime.StreamEvent{
|
||||
@@ -109,36 +101,11 @@ func TestRunResetsEmptyTurnCounterOnVisibleOutput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunResetsEmptyTurnCounterOnReasoning(t *testing.T) {
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
reasoningTurn("thinking 1"),
|
||||
reasoningTurn("thinking 2"),
|
||||
reasoningTurn("thinking 3"),
|
||||
textTurn("done"),
|
||||
},
|
||||
}
|
||||
|
||||
result, err := Run(context.Background(), "go", provider, Options{
|
||||
Registry: tools.NewRegistry(),
|
||||
MaxTurns: 12,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("expected reasoning-only turns to keep the run live until final answer, got %q", result.FinalAnswer)
|
||||
}
|
||||
if len(provider.requests) != 4 {
|
||||
t.Fatalf("expected 4 turns, got %d", len(provider.requests))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunResetsEmptyTurnCounterOnToolCall(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeAgentTestFile(t, root+"/notes.txt", "alpha")
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
@@ -169,37 +136,6 @@ func TestRunResetsEmptyTurnCounterOnToolCall(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The whole point of #702's fix: the unknown-session error's normalized
|
||||
// signature must not vary with the session id, so a model probing ids 1, 2,
|
||||
// 3, … produces one repeated signature the failure guard can count. If the id
|
||||
// leaked into the first 80 normalized chars, each probe would reset the streak
|
||||
// and the halt would never fire.
|
||||
func TestUnknownExecSessionErrorSignatureIsIDInvariant(t *testing.T) {
|
||||
a := errorSignature(tools.UnknownExecSessionError(1))
|
||||
b := errorSignature(tools.UnknownExecSessionError(999999))
|
||||
if a != b {
|
||||
t.Fatalf("unknown-session signature varies with id:\n %q\n %q", a, b)
|
||||
}
|
||||
}
|
||||
|
||||
// End to end: with an id-invariant signature, probing a different unknown id
|
||||
// each turn now trips the repeated-failure halt at toolFailureStopAt, where
|
||||
// before the fix it never would.
|
||||
func TestUnknownExecSessionProbingTripsFailureHalt(t *testing.T) {
|
||||
var state guardState
|
||||
var stoppedAt int
|
||||
for i := 1; i <= toolFailureStopAt; i++ {
|
||||
out := state.observeToolResult(tools.WriteStdinToolName, true, tools.UnknownExecSessionError(i))
|
||||
if out.Stop {
|
||||
stoppedAt = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if stoppedAt != toolFailureStopAt {
|
||||
t.Fatalf("probing distinct unknown ids stopped at %d, want %d", stoppedAt, toolFailureStopAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuardStateResetsToolOnlyStreakOnEmptyNonToolTurn(t *testing.T) {
|
||||
var state guardState
|
||||
toolOnly := zeroruntime.CollectedStream{
|
||||
@@ -260,7 +196,7 @@ func TestRunInjectsPlanNotCalledReminderForMultiStepTask(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeAgentTestFile(t, root+"/notes.txt", "alpha")
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
@@ -291,7 +227,7 @@ func TestRunDoesNotInjectPlanReminderForTrivialTask(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeAgentTestFile(t, root+"/notes.txt", "alpha")
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
@@ -319,7 +255,7 @@ func TestRunDoesNotInjectNotCalledReminderWhenPlanUsed(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeAgentTestFile(t, root+"/notes.txt", "alpha")
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
registry.Register(tools.NewUpdatePlanTool())
|
||||
|
||||
provider := &mockProvider{
|
||||
@@ -349,7 +285,7 @@ func TestRunInjectsStalePlanReminderAfterManyToolCalls(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeAgentTestFile(t, root+"/notes.txt", "alpha")
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
registry.Register(tools.NewUpdatePlanTool())
|
||||
|
||||
// Turn 1 calls update_plan (so the not-called reminder never triggers), then
|
||||
@@ -383,7 +319,7 @@ func TestRunStalePlanReminderIsOneShotPerInterval(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeAgentTestFile(t, root+"/notes.txt", "alpha")
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
registry.Register(tools.NewUpdatePlanTool())
|
||||
|
||||
turns := [][]zeroruntime.StreamEvent{
|
||||
@@ -418,7 +354,7 @@ func TestRunInjectsToolOnlyProgressReminder(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeAgentTestFile(t, root+"/notes.txt", "alpha")
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
|
||||
turns := make([][]zeroruntime.StreamEvent, 0, toolOnlyProgressReminderAt+1)
|
||||
for i := 0; i < toolOnlyProgressReminderAt; i++ {
|
||||
|
||||
+144
-570
File diff suppressed because it is too large.
Load diff
+47
-392
@@ -4,21 +4,15 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/execution"
|
||||
"github.com/Gitlawb/zero/internal/hooks"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/specmode"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/trace"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
@@ -27,59 +21,6 @@ type mockProvider struct {
|
||||
requests []zeroruntime.CompletionRequest
|
||||
}
|
||||
|
||||
func TestTypedExecutionOutcomeOverridesLegacySandboxHeuristics(t *testing.T) {
|
||||
engine := sandbox.NewEngine(sandbox.EngineOptions{WorkspaceRoot: t.TempDir(), Policy: sandbox.DefaultPolicy()})
|
||||
call := ToolCall{Name: tools.ExecCommandToolName}
|
||||
options := Options{Sandbox: engine}
|
||||
legacyMeta := map[string]string{
|
||||
tools.SandboxLikelyDeniedMeta: "true",
|
||||
tools.SandboxDenialKindMeta: tools.SandboxDenialKindNetwork,
|
||||
}
|
||||
|
||||
applicationFailure := tools.Result{
|
||||
Status: tools.StatusError,
|
||||
Meta: legacyMeta,
|
||||
ExecutionOutcome: &execution.Outcome{
|
||||
State: execution.StateFailed,
|
||||
Kind: execution.OutcomeApplicationFailure,
|
||||
Exit: &execution.Exit{Code: 1},
|
||||
},
|
||||
}
|
||||
if sandboxDeniedNetworkRetryCandidate(call, nil, applicationFailure, options) || sandboxRestrictedShellRetryCandidate(call, nil, applicationFailure, options) {
|
||||
t.Fatal("typed application failure must not be retried from legacy sandbox-like text")
|
||||
}
|
||||
|
||||
protectedDenial := applicationFailure
|
||||
protectedDenial.ExecutionOutcome = &execution.Outcome{
|
||||
State: execution.StateDenied,
|
||||
Kind: execution.OutcomeEnforcementDenied,
|
||||
Denial: &execution.Denial{
|
||||
Capability: execution.Capability{Kind: execution.CapabilityProtectedMetadata, Scope: "/workspace/.zero"},
|
||||
Source: execution.DenialSourceConfiguredPolicy,
|
||||
Reason: "protected metadata",
|
||||
NextAction: execution.DenialNextActionRequestApproval,
|
||||
},
|
||||
}
|
||||
if sandboxRestrictedShellRetryCandidate(call, nil, protectedDenial, options) {
|
||||
t.Fatal("narrow protected-metadata denial must not become an unrestricted retry")
|
||||
}
|
||||
|
||||
networkDenial := protectedDenial
|
||||
networkDenial.ExecutionOutcome = &execution.Outcome{
|
||||
State: execution.StateDenied,
|
||||
Kind: execution.OutcomeEnforcementDenied,
|
||||
Denial: &execution.Denial{
|
||||
Capability: execution.Capability{Kind: execution.CapabilityExternalNetwork},
|
||||
Source: execution.DenialSourcePlatformSandbox,
|
||||
Reason: "external network denied",
|
||||
NextAction: execution.DenialNextActionRequestApproval,
|
||||
},
|
||||
}
|
||||
if !sandboxDeniedNetworkRetryCandidate(call, nil, networkDenial, options) {
|
||||
t.Fatal("typed external-network denial should use the narrow network approval path")
|
||||
}
|
||||
}
|
||||
|
||||
func (provider *mockProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
|
||||
provider.requests = append(provider.requests, request)
|
||||
|
||||
@@ -96,140 +37,6 @@ func (provider *mockProvider) StreamCompletion(ctx context.Context, request zero
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func TestRunDispatchesSessionLifecycleHooks(t *testing.T) {
|
||||
audit, err := hooks.NewAuditStore(hooks.AuditStoreOptions{AuditPath: filepath.Join(t.TempDir(), "audit.jsonl")})
|
||||
if err != nil {
|
||||
t.Fatalf("NewAuditStore: %v", err)
|
||||
}
|
||||
dispatcher := hooks.NewDispatcher(hooks.DispatcherOptions{
|
||||
Config: hooks.Config{
|
||||
Enabled: true,
|
||||
Hooks: []hooks.Definition{
|
||||
{ID: "zero.session-start", Event: hooks.EventSessionStart, Command: "zero-missing-hook-command", Enabled: true},
|
||||
{ID: "zero.session-end", Event: hooks.EventSessionEnd, Command: "zero-missing-hook-command", Enabled: true},
|
||||
},
|
||||
},
|
||||
Audit: audit,
|
||||
})
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
}}}
|
||||
|
||||
_, err = Run(context.Background(), "hi", provider, Options{
|
||||
SessionID: "session-123",
|
||||
Cwd: t.TempDir(),
|
||||
ProviderName: "test-provider",
|
||||
Model: "test-model",
|
||||
Hooks: dispatcher,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
|
||||
events, err := audit.ReadEvents()
|
||||
if err != nil {
|
||||
t.Fatalf("ReadEvents: %v", err)
|
||||
}
|
||||
started := map[hooks.Event]int{}
|
||||
completed := map[hooks.Event]int{}
|
||||
for _, event := range events {
|
||||
switch event.Type {
|
||||
case "hook_execution_started":
|
||||
started[event.Event]++
|
||||
case "hook_execution_completed":
|
||||
completed[event.Event]++
|
||||
}
|
||||
}
|
||||
if started[hooks.EventSessionStart] != 1 || completed[hooks.EventSessionStart] != 1 {
|
||||
t.Fatalf("sessionStart audit counts started/completed = %d/%d, events=%#v", started[hooks.EventSessionStart], completed[hooks.EventSessionStart], events)
|
||||
}
|
||||
if started[hooks.EventSessionEnd] != 1 || completed[hooks.EventSessionEnd] != 1 {
|
||||
t.Fatalf("sessionEnd audit counts started/completed = %d/%d, events=%#v", started[hooks.EventSessionEnd], completed[hooks.EventSessionEnd], events)
|
||||
}
|
||||
}
|
||||
|
||||
// cancelingProvider simulates an Esc/Ctrl+C abort: it cancels the run's own
|
||||
// context mid-stream (as the TUI's interrupt handler would) and returns no
|
||||
// events, so Run observes ctx.Err() and returns early with an already-canceled
|
||||
// context in scope for the deferred sessionEnd dispatch.
|
||||
type cancelingProvider struct {
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
func (provider *cancelingProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
|
||||
provider.cancel()
|
||||
ch := make(chan zeroruntime.StreamEvent)
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// TestRunDispatchesSessionEndHookAfterContextCancellation is a regression test
|
||||
// for a bug where an interrupted run (Esc/Ctrl+C) canceled ctx before the
|
||||
// deferred sessionEnd dispatch ran, so Hooks.Dispatch's context.WithTimeout(ctx, ...)
|
||||
// derived an already-canceled context and the hook command never actually launched.
|
||||
func TestRunDispatchesSessionEndHookAfterContextCancellation(t *testing.T) {
|
||||
goBinary, err := exec.LookPath("go")
|
||||
if err != nil {
|
||||
// The test binary runs on the build machine, so its build-time GOROOT
|
||||
// still identifies the toolchain that produced it.
|
||||
goRoot := runtime.GOROOT() //nolint:staticcheck // Safe for this non-portable test binary.
|
||||
goBinary = filepath.Join(goRoot, "bin", "go")
|
||||
if runtime.GOOS == "windows" {
|
||||
goBinary += ".exe"
|
||||
}
|
||||
if _, statErr := os.Stat(goBinary); statErr != nil {
|
||||
t.Skipf("go binary unavailable on PATH or in GOROOT: %v", statErr)
|
||||
}
|
||||
}
|
||||
audit, err := hooks.NewAuditStore(hooks.AuditStoreOptions{AuditPath: filepath.Join(t.TempDir(), "audit.jsonl")})
|
||||
if err != nil {
|
||||
t.Fatalf("NewAuditStore: %v", err)
|
||||
}
|
||||
dispatcher := hooks.NewDispatcher(hooks.DispatcherOptions{
|
||||
Config: hooks.Config{
|
||||
Enabled: true,
|
||||
Hooks: []hooks.Definition{
|
||||
{ID: "zero.session-end", Event: hooks.EventSessionEnd, Command: goBinary, Args: []string{"version"}, Enabled: true},
|
||||
},
|
||||
},
|
||||
Audit: audit,
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
provider := &cancelingProvider{cancel: cancel}
|
||||
|
||||
_, err = Run(ctx, "hi", provider, Options{
|
||||
SessionID: "session-cancel",
|
||||
Cwd: t.TempDir(),
|
||||
ProviderName: "test-provider",
|
||||
Model: "test-model",
|
||||
Hooks: dispatcher,
|
||||
})
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("Run err = %v, want context.Canceled", err)
|
||||
}
|
||||
|
||||
events, err := audit.ReadEvents()
|
||||
if err != nil {
|
||||
t.Fatalf("ReadEvents: %v", err)
|
||||
}
|
||||
var completedStatus hooks.AuditStatus
|
||||
found := false
|
||||
for _, event := range events {
|
||||
if event.Type == "hook_execution_completed" && event.Event == hooks.EventSessionEnd {
|
||||
completedStatus = event.Status
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("no sessionEnd hook_execution_completed audit event, events=%#v", events)
|
||||
}
|
||||
if completedStatus != hooks.AuditCompleted {
|
||||
t.Fatalf("sessionEnd hook status = %q, want %q (hook must actually run despite ctx cancellation)", completedStatus, hooks.AuditCompleted)
|
||||
}
|
||||
}
|
||||
|
||||
type recordingWebSearchTool struct {
|
||||
calls []map[string]any
|
||||
}
|
||||
@@ -863,6 +670,9 @@ func TestRunReportsTruncationFinishReason(t *testing.T) {
|
||||
if result.FinishReason != zeroruntime.FinishReasonLength {
|
||||
t.Fatalf("FinishReason = %q, want %q", result.FinishReason, zeroruntime.FinishReasonLength)
|
||||
}
|
||||
if !result.Truncated() {
|
||||
t.Fatal("Truncated() = false, want true for a length-capped response")
|
||||
}
|
||||
if result.TruncationNotice() == "" {
|
||||
t.Fatal("TruncationNotice() empty for a truncated response")
|
||||
}
|
||||
@@ -880,7 +690,7 @@ func TestRunNormalCompletionIsNotTruncated(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.TruncationNotice() != "" {
|
||||
if result.Truncated() || result.TruncationNotice() != "" {
|
||||
t.Fatalf("normal completion reported as truncated: reason=%q", result.FinishReason)
|
||||
}
|
||||
}
|
||||
@@ -957,7 +767,7 @@ func TestRunEmitsUsageEvents(t *testing.T) {
|
||||
func TestRunAdvertisesRuntimeToolDefinitions(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{{
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
@@ -1164,9 +974,9 @@ func TestRunRequestsPermissionBeforeWebSearchExecution(t *testing.T) {
|
||||
func TestRunFiltersAdvertisedTools(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
registry.Register(tools.NewScopedGrepTool(root, nil))
|
||||
registry.Register(tools.NewScopedWriteFileTool(root, nil))
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
registry.Register(tools.NewGrepTool(root))
|
||||
registry.Register(tools.NewWriteFileTool(root))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{{
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
@@ -1194,7 +1004,7 @@ func TestRunFiltersAdvertisedTools(t *testing.T) {
|
||||
func TestRunRejectsFilteredToolCalls(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
@@ -1231,7 +1041,7 @@ func TestRunRejectsFilteredToolCalls(t *testing.T) {
|
||||
func TestRunRejectsToolCallsOutsideEnabledList(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
@@ -1269,7 +1079,7 @@ func TestRunExecutesToolCallThroughRegistry(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeAgentTestFile(t, filepath.Join(root, "notes.txt"), "alpha\nbeta\n")
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
@@ -1310,50 +1120,10 @@ func TestRunExecutesToolCallThroughRegistry(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPreservesRequestPrefixAcrossTurns(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeAgentTestFile(t, filepath.Join(root, "notes.txt"), "alpha\n")
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "read_file"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"path":"notes.txt"}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
}}
|
||||
|
||||
if _, err := Run(context.Background(), "read notes", provider, Options{
|
||||
Cwd: root,
|
||||
Registry: registry,
|
||||
SessionID: "session-stable-prefix",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(provider.requests) != 2 {
|
||||
t.Fatalf("provider requests = %d, want 2", len(provider.requests))
|
||||
}
|
||||
first, second := provider.requests[0], provider.requests[1]
|
||||
if len(second.Messages) < len(first.Messages) || !reflect.DeepEqual(second.Messages[:len(first.Messages)], first.Messages) {
|
||||
t.Fatalf("first request messages must be an exact prefix of the second:\nfirst=%#v\nsecond=%#v", first.Messages, second.Messages)
|
||||
}
|
||||
if !reflect.DeepEqual(first.Tools, second.Tools) {
|
||||
t.Fatalf("tool definitions drifted between turns:\nfirst=%#v\nsecond=%#v", first.Tools, second.Tools)
|
||||
}
|
||||
if first.PromptCacheKey != "session-stable-prefix" || second.PromptCacheKey != first.PromptCacheKey {
|
||||
t.Fatalf("prompt cache key must remain stable: first=%q second=%q", first.PromptCacheKey, second.PromptCacheKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSanitizesMalformedToolCallArgumentsBeforeRetry(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
@@ -1421,7 +1191,7 @@ func TestRunRecoversFirstObjectFromConcatenatedToolArgs(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
@@ -1468,8 +1238,8 @@ func TestRunDefersSelfCorrectFeedbackUntilAfterToolBatch(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedWriteFileTool(root, nil))
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
registry.Register(tools.NewWriteFileTool(root))
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
@@ -1544,7 +1314,7 @@ func TestRunBatchesSelfCorrectOncePerTurn(t *testing.T) {
|
||||
// after a later call in the same turn supersedes it.
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedWriteFileTool(root, nil))
|
||||
registry.Register(tools.NewWriteFileTool(root))
|
||||
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
@@ -1601,7 +1371,7 @@ func TestRunBatchesSelfCorrectOncePerTurn(t *testing.T) {
|
||||
func TestRunDeniesPromptToolWithoutUnsafePermission(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedWriteFileTool(root, nil))
|
||||
registry.Register(tools.NewWriteFileTool(root))
|
||||
provider := providerCallingWriteFileThenAnswer("write denied")
|
||||
var permissionEvents []PermissionEvent
|
||||
|
||||
@@ -1644,7 +1414,7 @@ func TestRunDeniesPromptToolWithoutUnsafePermission(t *testing.T) {
|
||||
func TestRunRequestsPromptToolPermissionBeforeExecution(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedWriteFileTool(root, nil))
|
||||
registry.Register(tools.NewWriteFileTool(root))
|
||||
provider := providerCallingWriteFileThenAnswer("write approved")
|
||||
var requests []PermissionRequest
|
||||
var permissionEvents []PermissionEvent
|
||||
@@ -1700,7 +1470,7 @@ func TestRunRequestsPromptToolPermissionBeforeExecution(t *testing.T) {
|
||||
func TestRunAllowsWorkspaceWriteWithoutPromptWhenSandboxPolicyPermits(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedWriteFileTool(root, nil))
|
||||
registry.Register(tools.NewWriteFileTool(root))
|
||||
provider := providerCallingWriteFileThenAnswer("write done")
|
||||
var permissionEvents []PermissionEvent
|
||||
|
||||
@@ -1749,7 +1519,7 @@ func TestRunAllowsWorkspaceWriteWithoutPromptWhenSandboxPolicyPermits(t *testing
|
||||
func TestRunDeniesPromptToolWhenPermissionRequestDenied(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedWriteFileTool(root, nil))
|
||||
registry.Register(tools.NewWriteFileTool(root))
|
||||
provider := providerCallingWriteFileThenAnswer("write denied")
|
||||
var requests []PermissionRequest
|
||||
var permissionEvents []PermissionEvent
|
||||
@@ -1797,7 +1567,7 @@ func TestRunDeniesPromptToolWhenPermissionRequestDenied(t *testing.T) {
|
||||
func TestRunAbortsWhenPermissionRequestCanceled(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedWriteFileTool(root, nil))
|
||||
registry.Register(tools.NewWriteFileTool(root))
|
||||
provider := providerCallingWriteFileThenAnswer("write should not continue")
|
||||
var permissionEvents []PermissionEvent
|
||||
|
||||
@@ -1893,7 +1663,7 @@ func TestRunPersistsAlwaysAllowPermissionDecision(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedWriteFileTool(root, nil))
|
||||
registry.Register(tools.NewWriteFileTool(root))
|
||||
provider := providerCallingWriteFileThenAnswer("write approved")
|
||||
var permissionEvents []PermissionEvent
|
||||
policy := sandbox.DefaultPolicy()
|
||||
@@ -1965,7 +1735,7 @@ func TestRunSessionAllowSkipsMatchingPromptWithoutPersistentGrant(t *testing.T)
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedWriteFileTool(root, nil))
|
||||
registry.Register(tools.NewWriteFileTool(root))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
@@ -2046,7 +1816,7 @@ func TestRunSessionAllowSkipsMatchingPromptWithoutPersistentGrant(t *testing.T)
|
||||
func TestRunCommandPrefixApprovalSkipsLaterMatchingBashPrompt(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedBashTool(root, nil))
|
||||
registry.Register(tools.NewBashTool(root))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
@@ -2175,11 +1945,6 @@ func TestRunCommandPrefixApprovalBypassesSandboxForMatchingShellCalls(t *testing
|
||||
|
||||
func TestRunCommandPrefixApprovalCoversSegmentedShellWithSafeTail(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
segmentedCommand := `ps aux | head -5`
|
||||
if runtime.GOOS == "windows" {
|
||||
// head is MSYS-prone on Windows (#458) and no longer counts as a known-safe tail.
|
||||
segmentedCommand = `ps aux | echo ok`
|
||||
}
|
||||
retryTool := &sandboxDeniedRetryTool{}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(retryTool)
|
||||
@@ -2193,7 +1958,7 @@ func TestRunCommandPrefixApprovalCoversSegmentedShellWithSafeTail(t *testing.T)
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-2", ToolName: "bash"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-2", ArgumentsFragment: fmt.Sprintf(`{"command":%q}`, segmentedCommand)},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-2", ArgumentsFragment: `{"command":"ps aux | head -5"}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-2"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
@@ -2248,7 +2013,7 @@ func TestRunPersistentCommandPrefixApprovalSkipsFutureSessionPrompt(t *testing.T
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedBashTool(root, nil))
|
||||
registry.Register(tools.NewBashTool(root))
|
||||
policy := sandbox.DefaultPolicy()
|
||||
policy.Network = sandbox.NetworkAllow
|
||||
|
||||
@@ -2355,7 +2120,7 @@ func TestRunPersistentCommandPrefixStillPromptsForNetwork(t *testing.T) {
|
||||
t.Fatalf("seed command prefix: %v", err)
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedBashTool(root, nil))
|
||||
registry.Register(tools.NewBashTool(root))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
@@ -2404,11 +2169,7 @@ func TestRunApprovedNetworkBashPromptAppliesTurnNetworkGrant(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
command := "PATH=.:$PATH curl https://example.com"
|
||||
if runtime.GOOS == "windows" {
|
||||
if windowsTestUsesPowerShell() {
|
||||
command = `$env:PATH = '.;' + $env:PATH; curl.cmd https://example.com`
|
||||
} else {
|
||||
command = "set PATH=.;%PATH% && curl https://example.com"
|
||||
}
|
||||
command = "set PATH=.;%PATH% && curl https://example.com"
|
||||
fakeCurl := filepath.Join(root, "curl.cmd")
|
||||
if err := os.WriteFile(fakeCurl, []byte("@echo fake curl %*\r\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -2420,7 +2181,7 @@ func TestRunApprovedNetworkBashPromptAppliesTurnNetworkGrant(t *testing.T) {
|
||||
}
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedBashTool(root, nil))
|
||||
registry.Register(tools.NewBashTool(root))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
@@ -2487,7 +2248,7 @@ func TestRunApprovedNetworkBashPromptAppliesTurnNetworkGrant(t *testing.T) {
|
||||
func TestRunDoesNotOfferPrefixApprovalForUnsafeBashCommand(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedBashTool(root, nil))
|
||||
registry.Register(tools.NewBashTool(root))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
@@ -2529,17 +2290,13 @@ func TestRunDoesNotOfferPrefixApprovalForUnsafeBashCommand(t *testing.T) {
|
||||
|
||||
func TestRunPromptsForDestructiveShellInsteadOfSandboxDeny(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
command := "echo rm -rf /"
|
||||
if runtime.GOOS == "windows" && windowsTestUsesPowerShell() {
|
||||
command = `Write-Output 'rm -rf /'`
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedBashTool(root, nil))
|
||||
registry.Register(tools.NewBashTool(root))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "bash"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"command":` + quoteJSONString(command) + `}`},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{"command":"echo rm -rf /"}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
@@ -2603,7 +2360,7 @@ func TestRunAlwaysAllowWithoutSandboxStillAllowsCall(t *testing.T) {
|
||||
// prior code denied it because persistPermissionGrant errors when Sandbox==nil.
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedWriteFileTool(root, nil))
|
||||
registry.Register(tools.NewWriteFileTool(root))
|
||||
provider := providerCallingWriteFileThenAnswer("write approved")
|
||||
var permissionEvents []PermissionEvent
|
||||
|
||||
@@ -2635,12 +2392,6 @@ func TestRunAlwaysAllowWithoutSandboxStillAllowsCall(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func windowsTestUsesPowerShell() bool {
|
||||
executable, _ := tools.HostShellCommand("")
|
||||
name := strings.ToLower(filepath.Base(executable))
|
||||
return strings.Contains(name, "powershell") || strings.HasPrefix(name, "pwsh")
|
||||
}
|
||||
|
||||
func containsPermissionDecision(decisions []PermissionDecisionAction, want PermissionDecisionAction) bool {
|
||||
for _, decision := range decisions {
|
||||
if decision == want {
|
||||
@@ -2673,7 +2424,7 @@ func TestRunCancellationPreservesContextCanceledIdentity(t *testing.T) {
|
||||
func TestRunGrantsPromptToolInUnsafeMode(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedWriteFileTool(root, nil))
|
||||
registry.Register(tools.NewWriteFileTool(root))
|
||||
provider := providerCallingWriteFileThenAnswer("write done")
|
||||
var permissionEvents []PermissionEvent
|
||||
|
||||
@@ -2725,7 +2476,7 @@ func TestRunEmitsPermissionEventForPersistentSandboxGrant(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedWriteFileTool(root, nil))
|
||||
registry.Register(tools.NewWriteFileTool(root))
|
||||
provider := providerCallingWriteFileThenAnswer("write done")
|
||||
var permissionEvents []PermissionEvent
|
||||
|
||||
@@ -2904,7 +2655,7 @@ func TestRunAppliesSandboxEvenInUnsafeMode(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
outside := filepath.Join(tempDirOutsideDefaultTemp(t), "escape.txt")
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedWriteFileTool(root, nil))
|
||||
registry.Register(tools.NewWriteFileTool(root))
|
||||
provider := providerCallingWritePathThenAnswer(outside, "sandbox handled")
|
||||
var permissionEvents []PermissionEvent
|
||||
|
||||
@@ -2953,7 +2704,7 @@ func TestRunStopsAfterMaxTurns(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeAgentTestFile(t, filepath.Join(root, "notes.txt"), "alpha")
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "read_file"},
|
||||
@@ -2983,7 +2734,7 @@ func TestRunRequestsFinalAnswerAfterMaxTurns(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeAgentTestFile(t, filepath.Join(root, "notes.txt"), "alpha")
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
@@ -3011,11 +2762,11 @@ func TestRunRequestsFinalAnswerAfterMaxTurns(t *testing.T) {
|
||||
t.Fatalf("expected final answer from finalization turn, got %q", result.FinalAnswer)
|
||||
}
|
||||
if len(provider.requests) != 2 {
|
||||
t.Fatalf("expected finalization request after max turns, got %d requests", len(provider.requests))
|
||||
t.Fatalf("expected final no-tools request after max turns, got %d requests", len(provider.requests))
|
||||
}
|
||||
finalRequest := provider.requests[1]
|
||||
if toolDefinitionByName(finalRequest.Tools, "read_file") == nil {
|
||||
t.Fatalf("finalization request must keep prior tools exposed for strict provider replay, got %#v", finalRequest.Tools)
|
||||
if len(finalRequest.Tools) != 0 {
|
||||
t.Fatalf("finalization request must not advertise tools, got %#v", finalRequest.Tools)
|
||||
}
|
||||
lastMessage := finalRequest.Messages[len(finalRequest.Messages)-1]
|
||||
if lastMessage.Role != zeroruntime.MessageRoleUser || !strings.Contains(lastMessage.Content, "tool-turn limit") {
|
||||
@@ -3136,24 +2887,6 @@ func TestGitBranchForPromptResolvesRelativeWorktreeGitdir(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitBranchForPromptInSubdirectory(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
gitdir := filepath.Join(root, ".git")
|
||||
if err := os.MkdirAll(gitdir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(gitdir, "HEAD"), []byte("ref: refs/heads/main\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
subdir := filepath.Join(root, "sub", "dir")
|
||||
if err := os.MkdirAll(subdir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := gitBranchForPrompt(subdir); got != "main" {
|
||||
t.Fatalf("gitBranchForPrompt in subdirectory = %q, want main", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSystemPromptInjectsWorkspaceContext(t *testing.T) {
|
||||
cwd := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(cwd, "AGENTS.md"), []byte("Always run `make lint` before committing."), 0o644); err != nil {
|
||||
@@ -3174,16 +2907,8 @@ func TestBuildSystemPromptInjectsHostShellContext(t *testing.T) {
|
||||
t.Fatalf("expected operating system in environment block, got %q", prompt)
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
wants := []string{"workdir/cwd", "MSYS binaries"}
|
||||
if windowsTestUsesPowerShell() {
|
||||
wants = append(wants, "PowerShell", "Get-ChildItem", "Select-String", "$env:NAME", "require_escalated")
|
||||
} else {
|
||||
wants = append(wants, "cmd.exe", "double quotes")
|
||||
}
|
||||
for _, want := range wants {
|
||||
if !strings.Contains(prompt, want) {
|
||||
t.Fatalf("expected selected Windows shell guidance to mention %q, got %q", want, prompt)
|
||||
}
|
||||
if !strings.Contains(prompt, "Windows cmd.exe syntax") || !strings.Contains(prompt, "cwd argument") {
|
||||
t.Fatalf("expected Windows cmd.exe shell guidance in prompt, got %q", prompt)
|
||||
}
|
||||
} else if !strings.Contains(prompt, "/bin/sh syntax") {
|
||||
t.Fatalf("expected POSIX shell guidance in prompt, got %q", prompt)
|
||||
@@ -3237,7 +2962,7 @@ func TestBuildSystemPromptAllowsSpecModeOverride(t *testing.T) {
|
||||
func TestSpecDraftAdvertisesOnlySafeDraftTools(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
for _, tool := range tools.CoreToolsScoped(root, nil) {
|
||||
for _, tool := range tools.CoreTools(root) {
|
||||
registry.Register(tool)
|
||||
}
|
||||
specmode.RegisterDraftTools(registry, root, nil)
|
||||
@@ -3274,7 +2999,7 @@ func TestSpecDraftAdvertisesOnlySafeDraftTools(t *testing.T) {
|
||||
func TestSpecDraftDeniesHiddenToolCalls(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedWriteFileTool(root, nil))
|
||||
registry.Register(tools.NewWriteFileTool(root))
|
||||
provider := providerCallingWriteFileThenAnswer("done")
|
||||
|
||||
result, err := Run(context.Background(), "draft", provider, Options{
|
||||
@@ -3307,7 +3032,7 @@ func TestSpecDraftDeniesHiddenToolCalls(t *testing.T) {
|
||||
func TestSpecDraftDeniesBashToolCalls(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedBashTool(root, nil))
|
||||
registry.Register(tools.NewBashTool(root))
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
@@ -3448,7 +3173,7 @@ func TestRunSurfacesDroppedToolCallAlongsideValidCall(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
@@ -3645,73 +3370,3 @@ func TestRunDoesNotFlagCleanToolOutput(t *testing.T) {
|
||||
t.Errorf("clean output should not get a reminder, got %q", captured.Output)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunTracingWrapperStampsUsage verifies the per-turn tracing setup in Run:
|
||||
// a wired recorder is Started, OnUsage is wrapped so it still forwards to the
|
||||
// caller's callback AND stamps token counters, and the run completes.
|
||||
func TestRunTracingWrapperStampsUsage(t *testing.T) {
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
|
||||
{Type: zeroruntime.StreamEventUsage, Usage: zeroruntime.Usage{InputTokens: 100, CachedInputTokens: 20, OutputTokens: 40}},
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
}}}
|
||||
onUsageCalls := 0
|
||||
rec := trace.NewRecorder("tracing-session", "run-1", "test")
|
||||
if _, err := Run(context.Background(), "hi", provider, Options{
|
||||
SessionID: "tracing-session",
|
||||
Cwd: t.TempDir(),
|
||||
ProviderName: "test-provider",
|
||||
Model: "test-model",
|
||||
Trace: rec,
|
||||
OnUsage: func(Usage) { onUsageCalls++ },
|
||||
}); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
tr := rec.Finish()
|
||||
if tr.StartedAt.IsZero() {
|
||||
t.Fatal("tracing wrapper did not Start the recorder")
|
||||
}
|
||||
// FirstTokenAt must stamp even though no OnText/OnReasoning user callback is
|
||||
// set: a headless traced run (e.g. `zero exec --trace`) sets Trace but no UI
|
||||
// callbacks, so the loop installs trace-only forwarding handlers to capture
|
||||
// TTFT. Without them FirstTokenAt stays zero and the trace loses its signal.
|
||||
if tr.FirstTokenAt.IsZero() {
|
||||
t.Fatal("FirstTokenAt not stamped for a traced run with no OnText/OnReasoning callbacks")
|
||||
}
|
||||
if got := tr.Counter(trace.CounterInputTokens); got != 100 {
|
||||
t.Fatalf("input token counter = %d, want 100", got)
|
||||
}
|
||||
if got := tr.Counter(trace.CounterCachedInputTokens); got != 20 {
|
||||
t.Fatalf("cached input token counter = %d, want 20", got)
|
||||
}
|
||||
if got := tr.Counter(trace.CounterOutputTokens); got != 40 {
|
||||
t.Fatalf("output token counter = %d, want 40", got)
|
||||
}
|
||||
if onUsageCalls == 0 {
|
||||
t.Fatal("wrapped OnUsage did not forward to the caller's callback")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunNilTraceForwardsUsage verifies a nil recorder leaves the loop
|
||||
// byte-identical: OnUsage is not wrapped, so the caller's callback still fires
|
||||
// and nothing panics.
|
||||
func TestRunNilTraceForwardsUsage(t *testing.T) {
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{{
|
||||
{Type: zeroruntime.StreamEventUsage, Usage: zeroruntime.Usage{InputTokens: 7}},
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
}}}
|
||||
onUsageCalls := 0
|
||||
if _, err := Run(context.Background(), "hi", provider, Options{
|
||||
SessionID: "nil-trace-session",
|
||||
Cwd: t.TempDir(),
|
||||
ProviderName: "test-provider",
|
||||
Model: "test-model",
|
||||
OnUsage: func(Usage) { onUsageCalls++ },
|
||||
}); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if onUsageCalls == 0 {
|
||||
t.Fatal("OnUsage not forwarded when Trace is nil")
|
||||
}
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/hooks"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/trace"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
type propagationOutputTool struct {
|
||||
output string
|
||||
}
|
||||
|
||||
func TestOutputBudgetHookHelperProcess(t *testing.T) {
|
||||
for index, arg := range os.Args {
|
||||
if arg != "--zero-output-budget-hook" || index+1 >= len(os.Args) {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stdout.WriteString(os.Args[index+1]); err != nil {
|
||||
os.Exit(2)
|
||||
}
|
||||
os.Exit(0)
|
||||
}
|
||||
}
|
||||
|
||||
func largeOutputBudgetHookDispatcher() *hooks.Dispatcher {
|
||||
feedback := strings.Repeat("hook feedback ", 200)
|
||||
return hooks.NewDispatcher(hooks.DispatcherOptions{Config: hooks.Config{
|
||||
Enabled: true,
|
||||
Hooks: []hooks.Definition{{
|
||||
ID: "large-feedback",
|
||||
Event: hooks.EventAfterTool,
|
||||
Matcher: "propagation_output",
|
||||
Command: os.Args[0],
|
||||
Args: []string{
|
||||
"-test.run=TestOutputBudgetHookHelperProcess",
|
||||
"--",
|
||||
"--zero-output-budget-hook",
|
||||
feedback,
|
||||
},
|
||||
Enabled: true,
|
||||
}},
|
||||
}})
|
||||
}
|
||||
|
||||
func (tool propagationOutputTool) Name() string { return "propagation_output" }
|
||||
func (tool propagationOutputTool) Description() string { return "returns output for propagation tests" }
|
||||
func (tool propagationOutputTool) Parameters() tools.Schema {
|
||||
return tools.Schema{Type: "object", AdditionalProperties: false}
|
||||
}
|
||||
func (tool propagationOutputTool) Safety() tools.Safety {
|
||||
return tools.Safety{SideEffect: tools.SideEffectRead, Permission: tools.PermissionAllow, Reason: "test read"}
|
||||
}
|
||||
func (tool propagationOutputTool) Run(context.Context, map[string]any) tools.Result {
|
||||
return tools.Result{Status: tools.StatusOK, Output: tool.output}
|
||||
}
|
||||
|
||||
func TestExecuteToolCallPropagatesOutputTruncation(t *testing.T) {
|
||||
t.Setenv("TMPDIR", t.TempDir())
|
||||
t.Setenv("ZERO_TOOL_OUTPUT_CEILING_TOKENS", "80")
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(propagationOutputTool{output: strings.Repeat("large output\n", 1000)})
|
||||
|
||||
result, abortErr := executeToolCall(context.Background(), registry, ToolCall{
|
||||
ID: "call-budget",
|
||||
Name: "propagation_output",
|
||||
Arguments: `{}`,
|
||||
}, PermissionModeAuto, Options{Cwd: t.TempDir()})
|
||||
if abortErr != nil {
|
||||
t.Fatalf("executeToolCall abort error: %v", abortErr)
|
||||
}
|
||||
if !result.Truncated {
|
||||
t.Fatalf("agent ToolResult lost tools.Result.Truncated: %#v", result)
|
||||
}
|
||||
if result.Meta["spill_path"] == "" {
|
||||
t.Fatalf("agent ToolResult lost spill metadata: %#v", result.Meta)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordOutputBudgetTraceUsesOnlyCompactMetadata(t *testing.T) {
|
||||
recorder := trace.NewRecorder("session", "run", "")
|
||||
recorder.Start()
|
||||
recordOutputBudgetTrace(recorder, ToolResult{
|
||||
Name: "grep",
|
||||
Truncated: true,
|
||||
Output: "SECRET OUTPUT MUST NOT ENTER TRACE",
|
||||
Meta: map[string]string{
|
||||
"output_budget_category": "search",
|
||||
"output_budget_original_bytes": "1000",
|
||||
"output_budget_retained_bytes": "100",
|
||||
"output_budget_estimated_original_tokens": "250",
|
||||
"output_budget_estimated_retained_tokens": "25",
|
||||
"output_budget_reason": "semantic_search_budget",
|
||||
"output_budget_spill_created": "true",
|
||||
"spill_path": "/secret/path/not-for-trace",
|
||||
},
|
||||
})
|
||||
events := recorder.Finish().OutputBudgets
|
||||
if len(events) != 1 {
|
||||
t.Fatalf("events = %#v", events)
|
||||
}
|
||||
event := events[0]
|
||||
if event.Tool != "grep" || event.Category != "search" || event.OriginalBytes != 1000 || event.RetainedBytes != 100 || !event.SpillCreated {
|
||||
t.Fatalf("unexpected trace event: %#v", event)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteToolCallRebudgetsOversizedAfterToolFeedback(t *testing.T) {
|
||||
t.Setenv("ZERO_TOOL_OUTPUT_CEILING_TOKENS", "80")
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(propagationOutputTool{output: "tool output"})
|
||||
dispatcher := largeOutputBudgetHookDispatcher()
|
||||
|
||||
result, abortErr := executeToolCall(context.Background(), registry, ToolCall{
|
||||
ID: "call-hook-budget",
|
||||
Name: "propagation_output",
|
||||
Arguments: `{}`,
|
||||
}, PermissionModeAuto, Options{Hooks: dispatcher})
|
||||
if abortErr != nil {
|
||||
t.Fatalf("executeToolCall abort error: %v", abortErr)
|
||||
}
|
||||
if !result.Truncated || len(result.Output) > 80*4 {
|
||||
t.Fatalf("afterTool feedback bypassed output budget: truncated=%t bytes=%d meta=%#v", result.Truncated, len(result.Output), result.Meta)
|
||||
}
|
||||
if result.Meta["output_budget_category"] == "" || result.Meta["output_budget_retained_bytes"] != strconv.Itoa(len(result.Output)) {
|
||||
t.Fatalf("post-hook budget metadata does not describe final output: %#v", result.Meta)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunTraceReflectsPostHookBudget(t *testing.T) {
|
||||
t.Setenv("ZERO_TOOL_OUTPUT_CEILING_TOKENS", "80")
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(propagationOutputTool{output: "tool output"})
|
||||
dispatcher := largeOutputBudgetHookDispatcher()
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-hook-trace", ToolName: "propagation_output"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-hook-trace", ArgumentsFragment: `{}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-hook-trace"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{{Type: zeroruntime.StreamEventText, Content: "done"}, {Type: zeroruntime.StreamEventDone}},
|
||||
}}
|
||||
recorder := trace.NewRecorder("session", "run", "")
|
||||
var toolResults []ToolResult
|
||||
if _, err := Run(context.Background(), "budget hook", provider, Options{
|
||||
Registry: registry,
|
||||
Hooks: dispatcher,
|
||||
Trace: recorder,
|
||||
OnToolResult: func(result ToolResult) { toolResults = append(toolResults, result) },
|
||||
}); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if len(toolResults) != 1 || !toolResults[0].Truncated {
|
||||
t.Fatalf("tool result = %#v, want one truncated post-hook result", toolResults)
|
||||
}
|
||||
events := recorder.Finish().OutputBudgets
|
||||
if len(events) != 1 || !events[0].Truncated || events[0].RetainedBytes != len(toolResults[0].Output) {
|
||||
t.Fatalf("trace does not describe final post-hook output: events=%#v result=%#v", events, toolResults[0])
|
||||
}
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
// Parallel read-ahead for tool batches. When a turn requests several
|
||||
// independent lookups (read_file + grep + glob is the common shape), executing
|
||||
// them one after another serializes pure I/O waits. A consecutive run of
|
||||
// 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
|
||||
|
||||
// precomputedToolResult is one parallel read-ahead execution, keyed back to
|
||||
// its batch index by the caller.
|
||||
type precomputedToolResult struct {
|
||||
result ToolResult
|
||||
abortErr error
|
||||
}
|
||||
|
||||
// parallelSafeToolCall reports whether call may run concurrently with its
|
||||
// neighbors 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 {
|
||||
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
|
||||
// behind one mutex: a permission prompt (a sandbox preflight can demand one
|
||||
// even for an auto-allowed read) must never appear twice at once on an
|
||||
// interactive front-end, and OnPermission event handlers append to shared
|
||||
// session-recording state without their own locking — two batched reads under
|
||||
// a granted extra root would otherwise race (the pre-batch serial loop never
|
||||
// had two callbacks in flight at once).
|
||||
func executeParallelReadBatch(ctx context.Context, registry *tools.Registry, calls []ToolCall, start, end int, permissionMode PermissionMode, options Options) []precomputedToolResult {
|
||||
batchOptions := options
|
||||
var callbackMutex sync.Mutex
|
||||
if options.OnPermissionRequest != nil {
|
||||
inner := options.OnPermissionRequest
|
||||
batchOptions.OnPermissionRequest = func(ctx context.Context, request PermissionRequest) (PermissionDecision, error) {
|
||||
callbackMutex.Lock()
|
||||
defer callbackMutex.Unlock()
|
||||
return inner(ctx, request)
|
||||
}
|
||||
}
|
||||
if options.OnPermission != nil {
|
||||
inner := options.OnPermission
|
||||
batchOptions.OnPermission = func(event PermissionEvent) {
|
||||
callbackMutex.Lock()
|
||||
defer callbackMutex.Unlock()
|
||||
inner(event)
|
||||
}
|
||||
}
|
||||
|
||||
results := make([]precomputedToolResult, end-start)
|
||||
semaphore := make(chan struct{}, maxParallelReadTools)
|
||||
var waitGroup sync.WaitGroup
|
||||
for index := start; index < end; index++ {
|
||||
waitGroup.Add(1)
|
||||
go func(index int) {
|
||||
defer waitGroup.Done()
|
||||
semaphore <- struct{}{}
|
||||
defer func() { <-semaphore }()
|
||||
result, abortErr := executeToolCall(ctx, registry, calls[index], permissionMode, batchOptions)
|
||||
results[index-start] = precomputedToolResult{result: result, abortErr: abortErr}
|
||||
}(index)
|
||||
}
|
||||
waitGroup.Wait()
|
||||
return results
|
||||
}
|
||||
@@ -1,308 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// probeTool records execution overlap and ordering so tests can assert what
|
||||
// actually ran concurrently.
|
||||
type probeTool struct {
|
||||
name string
|
||||
sideEffect tools.SideEffect
|
||||
delay time.Duration
|
||||
// shared, when set, receives this probe's start/end entries too, so a test
|
||||
// can observe ordering ACROSS probes (e.g. reads vs a write barrier).
|
||||
shared *probeLog
|
||||
|
||||
mu sync.Mutex
|
||||
active int
|
||||
maxActive int
|
||||
log []string
|
||||
}
|
||||
|
||||
// probeLog is a mutex-guarded event log shared between probes.
|
||||
type probeLog struct {
|
||||
mu sync.Mutex
|
||||
entries []string
|
||||
}
|
||||
|
||||
func (log *probeLog) append(entry string) {
|
||||
log.mu.Lock()
|
||||
defer log.mu.Unlock()
|
||||
log.entries = append(log.entries, entry)
|
||||
}
|
||||
|
||||
func (log *probeLog) snapshot() []string {
|
||||
log.mu.Lock()
|
||||
defer log.mu.Unlock()
|
||||
return append([]string(nil), log.entries...)
|
||||
}
|
||||
|
||||
func (tool *probeTool) Name() string { return tool.name }
|
||||
func (tool *probeTool) Description() string { return "test probe tool" }
|
||||
func (tool *probeTool) Parameters() tools.Schema {
|
||||
return tools.Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]tools.PropertySchema{"id": {Type: "string"}},
|
||||
AdditionalProperties: false,
|
||||
}
|
||||
}
|
||||
func (tool *probeTool) Safety() tools.Safety {
|
||||
return tools.Safety{SideEffect: tool.sideEffect, Permission: tools.PermissionAllow, Reason: "test"}
|
||||
}
|
||||
|
||||
// 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()
|
||||
tool.active++
|
||||
if tool.active > tool.maxActive {
|
||||
tool.maxActive = tool.active
|
||||
}
|
||||
tool.log = append(tool.log, "start:"+id)
|
||||
tool.mu.Unlock()
|
||||
if tool.shared != nil {
|
||||
tool.shared.append("start:" + id)
|
||||
}
|
||||
time.Sleep(tool.delay)
|
||||
if tool.shared != nil {
|
||||
tool.shared.append("end:" + id)
|
||||
}
|
||||
tool.mu.Lock()
|
||||
tool.active--
|
||||
tool.log = append(tool.log, "end:"+id)
|
||||
tool.mu.Unlock()
|
||||
return tools.Result{Status: tools.StatusOK, Output: "probe " + id}
|
||||
}
|
||||
|
||||
func probeCallEvents(callID, toolName, id string) []zeroruntime.StreamEvent {
|
||||
return []zeroruntime.StreamEvent{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: callID, ToolName: toolName},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: callID, ArgumentsFragment: fmt.Sprintf(`{"id":%q}`, id)},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: callID},
|
||||
}
|
||||
}
|
||||
|
||||
func TestParallelSafeToolCall(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(&probeTool{name: "probe_read", sideEffect: tools.SideEffectRead})
|
||||
registry.Register(&probeTool{name: "probe_write", sideEffect: tools.SideEffectWrite})
|
||||
// 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{}) {
|
||||
t.Fatal("auto-allowed read tool must be parallel-safe")
|
||||
}
|
||||
if parallelSafeToolCall(registry, call("probe_write", `{"id":"a"}`), Options{}) {
|
||||
t.Fatal("mutating tool must not be parallel-safe")
|
||||
}
|
||||
if parallelSafeToolCall(registry, call("unknown_tool", `{}`), Options{}) {
|
||||
t.Fatal("unknown tool must not be parallel-safe")
|
||||
}
|
||||
if parallelSafeToolCall(registry, call("probe_read", `{"id":`), Options{}) {
|
||||
t.Fatal("undecodable arguments must not be parallel-safe")
|
||||
}
|
||||
if parallelSafeToolCall(registry, call("ask_user", `{}`), Options{}) {
|
||||
t.Fatal("loop-intercepted tools must stay sequential")
|
||||
}
|
||||
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) {
|
||||
probe := &probeTool{name: "probe_read", sideEffect: tools.SideEffectRead, delay: 60 * time.Millisecond}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(probe)
|
||||
|
||||
turnOne := append(probeCallEvents("call-1", "probe_read", "a"), probeCallEvents("call-2", "probe_read", "b")...)
|
||||
turnOne = append(turnOne, probeCallEvents("call-3", "probe_read", "c")...)
|
||||
turnOne = append(turnOne, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone})
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
turnOne,
|
||||
{{Type: zeroruntime.StreamEventText, Content: "done"}, {Type: zeroruntime.StreamEventDone}},
|
||||
},
|
||||
}
|
||||
|
||||
var results []ToolResult
|
||||
_, err := Run(context.Background(), "probe", provider, Options{
|
||||
Registry: registry,
|
||||
OnToolResult: func(result ToolResult) { results = append(results, result) },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if probe.maxActive < 2 {
|
||||
t.Fatalf("consecutive read-only calls must overlap, max concurrency was %d", probe.maxActive)
|
||||
}
|
||||
// Results must still be recorded in original call order.
|
||||
if len(results) != 3 || results[0].ToolCallID != "call-1" || results[1].ToolCallID != "call-2" || results[2].ToolCallID != "call-3" {
|
||||
t.Fatalf("tool results out of order: %#v", results)
|
||||
}
|
||||
messages := provider.requests[1].Messages
|
||||
var toolOrder []string
|
||||
for _, message := range messages {
|
||||
if message.Role == zeroruntime.MessageRoleTool {
|
||||
toolOrder = append(toolOrder, message.ToolCallID)
|
||||
}
|
||||
}
|
||||
if len(toolOrder) != 3 || toolOrder[0] != "call-1" || toolOrder[1] != "call-2" || toolOrder[2] != "call-3" {
|
||||
t.Fatalf("recorded tool messages out of order: %v", toolOrder)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunParallelReadsNeverSpanMutatingCall(t *testing.T) {
|
||||
shared := &probeLog{}
|
||||
read := &probeTool{name: "probe_read", sideEffect: tools.SideEffectRead, delay: 30 * time.Millisecond, shared: shared}
|
||||
write := &probeTool{name: "probe_write", sideEffect: tools.SideEffectWrite, shared: shared}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(read)
|
||||
registry.Register(write)
|
||||
|
||||
turnOne := append(probeCallEvents("call-1", "probe_read", "r1"), probeCallEvents("call-2", "probe_read", "r2")...)
|
||||
turnOne = append(turnOne, probeCallEvents("call-3", "probe_write", "w")...)
|
||||
turnOne = append(turnOne, probeCallEvents("call-4", "probe_read", "r3")...)
|
||||
turnOne = append(turnOne, probeCallEvents("call-5", "probe_read", "r4")...)
|
||||
turnOne = append(turnOne, zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone})
|
||||
provider := &mockProvider{
|
||||
turns: [][]zeroruntime.StreamEvent{
|
||||
turnOne,
|
||||
{{Type: zeroruntime.StreamEventText, Content: "done"}, {Type: zeroruntime.StreamEventDone}},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := Run(context.Background(), "probe", provider, Options{Registry: registry})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Cross-probe ordering on the SHARED log: the write must start only after
|
||||
// both first-batch reads finished, and both second-batch reads must start
|
||||
// only after the write finished — batches never cross a mutating call.
|
||||
log := shared.snapshot()
|
||||
index := func(entry string) int {
|
||||
for i, e := range log {
|
||||
if e == entry {
|
||||
return i
|
||||
}
|
||||
}
|
||||
t.Fatalf("entry %q missing from shared log: %v", entry, log)
|
||||
return -1
|
||||
}
|
||||
writeStart, writeEnd := index("start:w"), index("end:w")
|
||||
if firstBatchMaxEnd := max(index("end:r1"), index("end:r2")); writeStart < firstBatchMaxEnd {
|
||||
t.Fatalf("write started before the first read batch finished: %v", log)
|
||||
}
|
||||
if secondBatchMinStart := min(index("start:r3"), index("start:r4")); secondBatchMinStart < writeEnd {
|
||||
t.Fatalf("second read batch started before the write finished: %v", log)
|
||||
}
|
||||
if read.maxActive < 2 {
|
||||
t.Fatalf("reads within a batch must overlap, max concurrency was %d", read.maxActive)
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ func TestPartitionToolsActiveAppendsLoadedToolAfterEagerBlock(t *testing.T) {
|
||||
// "read_file".
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil)) // non-deferred, "read_file"
|
||||
registry.Register(tools.NewReadFileTool(root)) // non-deferred, "read_file"
|
||||
registry.Register(fakeToolSearchTool{})
|
||||
registry.Register(fakeDeferredTool{name: "mcp__srv__alpha", desc: "alpha"})
|
||||
registry.Register(fakeDeferredTool{name: "mcp__srv__beta", desc: "beta"})
|
||||
@@ -34,7 +34,7 @@ func TestPartitionToolsActiveAppendsLoadedToolAfterEagerBlock(t *testing.T) {
|
||||
t.Fatalf("expected read_file, tool_search and loaded alpha all exposed, got %#v", exposed)
|
||||
}
|
||||
// The eager tools must precede the loaded deferred tool (appended, not inserted).
|
||||
if readPos >= alphaPos || searchPos >= alphaPos {
|
||||
if !(readPos < alphaPos && searchPos < alphaPos) {
|
||||
t.Fatalf("loaded deferred tool must be appended after the eager block; got read=%d search=%d alpha=%d", readPos, searchPos, alphaPos)
|
||||
}
|
||||
// beta was never loaded — it stays hidden.
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// countingSchemaTool records how many times its Parameters() schema is read, so a
|
||||
// test can prove the definition cache avoids re-rendering across turns.
|
||||
type countingSchemaTool struct {
|
||||
name string
|
||||
calls *int
|
||||
}
|
||||
|
||||
func (t countingSchemaTool) Name() string { return t.name }
|
||||
func (t countingSchemaTool) Description() string { return "counts schema reads" }
|
||||
func (t countingSchemaTool) Parameters() tools.Schema {
|
||||
*t.calls++
|
||||
return tools.Schema{Type: "object", AdditionalProperties: false, Properties: map[string]tools.PropertySchema{
|
||||
"x": {Type: "string"},
|
||||
}}
|
||||
}
|
||||
func (t countingSchemaTool) Safety() tools.Safety {
|
||||
return tools.Safety{SideEffect: tools.SideEffectRead, Permission: tools.PermissionAllow}
|
||||
}
|
||||
func (t countingSchemaTool) Run(_ context.Context, _ map[string]any) tools.Result {
|
||||
return tools.Result{Status: tools.StatusOK}
|
||||
}
|
||||
|
||||
// The cache renders a tool's schema once and reuses it across calls, and its
|
||||
// output is identical to the uncached path.
|
||||
func TestPartitionToolsCacheRendersOnceAndMatchesUncached(t *testing.T) {
|
||||
calls := 0
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(countingSchemaTool{name: "alpha", calls: &calls})
|
||||
registry.Register(countingSchemaTool{name: "beta", calls: &calls})
|
||||
|
||||
uncached, _ := partitionTools(registry, PermissionModeAuto, Options{}, map[string]bool{})
|
||||
|
||||
base := calls
|
||||
cache := map[string]zeroruntime.ToolDefinition{}
|
||||
first, _ := partitionToolsCached(registry, PermissionModeAuto, Options{}, map[string]bool{}, cache)
|
||||
rendersAfterFirst := calls - base
|
||||
second, _ := partitionToolsCached(registry, PermissionModeAuto, Options{}, map[string]bool{}, cache)
|
||||
rendersAfterSecond := calls - base
|
||||
|
||||
if rendersAfterFirst != 2 {
|
||||
t.Fatalf("first cached call should render 2 tools once each, rendered %d times", rendersAfterFirst)
|
||||
}
|
||||
if rendersAfterSecond != 2 {
|
||||
t.Fatalf("second cached call must reuse the cache (no new schema reads), total renders = %d", rendersAfterSecond)
|
||||
}
|
||||
if !reflect.DeepEqual(first, second) {
|
||||
t.Fatal("cached calls must return identical definitions")
|
||||
}
|
||||
if !reflect.DeepEqual(first, uncached) {
|
||||
t.Fatal("cached output must match the uncached partitionTools output")
|
||||
}
|
||||
}
|
||||
@@ -68,61 +68,3 @@ func TestBuildPermissionEventKeepsAllowForOrdinaryAllowedCommand(t *testing.T) {
|
||||
t.Fatal("shouldRequestPermission must be false for an ordinary sandbox-allowed command")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPermissionEventReusesCoveredAdditionalPermissions(t *testing.T) {
|
||||
call := ToolCall{ID: "call_3", Name: "bash", Arguments: `{}`}
|
||||
args := map[string]any{
|
||||
"command": "npm install",
|
||||
"sandbox_permissions": string(tools.SandboxPermissionsWithAdditionalPermissions),
|
||||
"additional_permissions": map[string]any{
|
||||
"network": map[string]any{"enabled": true},
|
||||
},
|
||||
}
|
||||
decision := &sandbox.Decision{Action: sandbox.ActionAllow, AutoAllowed: true}
|
||||
|
||||
if shouldRequestPermission(promptShellTool{}, args, true, decision) {
|
||||
t.Fatal("an already-covered capability must not trigger another prompt")
|
||||
}
|
||||
event, ok := buildPermissionEvent(call, promptShellTool{}, args, true, PermissionModeAsk, Options{}, decision)
|
||||
if !ok {
|
||||
t.Fatal("covered capability should still produce an auditable allow event")
|
||||
}
|
||||
if event.Action != PermissionActionAllow {
|
||||
t.Fatalf("Action = %q, want allow for an already-covered capability", event.Action)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPermissionEventOmitsGenericShellReasonFromPrompt(t *testing.T) {
|
||||
call := ToolCall{ID: "call_4", Name: "bash", Arguments: `{}`}
|
||||
args := map[string]any{"command": "make test"}
|
||||
decision := &sandbox.Decision{Action: sandbox.ActionPrompt, Reason: "runs a shell command"}
|
||||
|
||||
event, ok := buildPermissionEvent(call, promptShellTool{}, args, false, PermissionModeAsk, Options{}, decision)
|
||||
if !ok {
|
||||
t.Fatal("generic shell approval must produce an event")
|
||||
}
|
||||
if event.Reason != "" {
|
||||
t.Fatalf("user-facing reason = %q, want omitted generic tool text", event.Reason)
|
||||
}
|
||||
if event.DecisionReason != "runs a shell command" {
|
||||
t.Fatalf("decision reason = %q, want raw audit reason retained", event.DecisionReason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPermissionEventPrefersPolicyReasonOverModelJustification(t *testing.T) {
|
||||
call := ToolCall{ID: "call_5", Name: "bash", Arguments: `{}`}
|
||||
args := map[string]any{
|
||||
"command": "curl https://example.test",
|
||||
"sandbox_permissions": string(tools.SandboxPermissionsRequireEscalated),
|
||||
"justification": "Run without sandboxing.",
|
||||
}
|
||||
decision := &sandbox.Decision{Action: sandbox.ActionPrompt, Reason: sandbox.ReasonNetworkBlocked}
|
||||
|
||||
event, ok := buildPermissionEvent(call, promptShellTool{}, args, false, PermissionModeAsk, Options{}, decision)
|
||||
if !ok {
|
||||
t.Fatal("policy approval must produce an event")
|
||||
}
|
||||
if event.Reason != sandbox.ReasonNetworkBlocked {
|
||||
t.Fatalf("user-facing reason = %q, want policy reason %q", event.Reason, sandbox.ReasonNetworkBlocked)
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
func planUpdateTurn(status string) zeroruntime.CollectedStream {
|
||||
return zeroruntime.CollectedStream{ToolCalls: []zeroruntime.ToolCall{
|
||||
{Name: "update_plan", Arguments: `{"plan":[{"content":"step one","status":"` + status + `"}]}`},
|
||||
}}
|
||||
}
|
||||
|
||||
// Turn-based staleness: a plan that goes many turns without an update — while
|
||||
// items are still pending — draws the stale reminder even though few tool calls
|
||||
// have run (the tool-call streak alone would not have tripped).
|
||||
func TestPlanReminderFiresOnTurnStaleness(t *testing.T) {
|
||||
state := newGuardState()
|
||||
state.observeTurn(planUpdateTurn("in_progress"))
|
||||
|
||||
// Text-only turns advance the turn counter without adding tool calls.
|
||||
for range stalePlanTurnThreshold {
|
||||
state.observeTurn(zeroruntime.CollectedStream{Text: "still working…"})
|
||||
}
|
||||
|
||||
if state.toolCallsSincePlanUpdate >= staleToolCallThreshold {
|
||||
t.Fatalf("precondition: the tool-call trigger must not fire (%d calls)", state.toolCallsSincePlanUpdate)
|
||||
}
|
||||
if got := state.planReminder(stalePlanTurnThreshold + 2); !strings.Contains(got, planStaleReminderMarker) {
|
||||
t.Fatalf("expected a turn-based stale reminder, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A fully-completed plan is NOT stale: no pending items means the turn-based
|
||||
// trigger stays quiet even after many turns, so a finished plan isn't nagged.
|
||||
func TestPlanReminderSkipsTurnStalenessWhenComplete(t *testing.T) {
|
||||
state := newGuardState()
|
||||
state.observeTurn(planUpdateTurn("completed"))
|
||||
|
||||
for range stalePlanTurnThreshold + 3 {
|
||||
state.observeTurn(zeroruntime.CollectedStream{Text: "wrapping up…"})
|
||||
}
|
||||
|
||||
if got := state.planReminder(stalePlanTurnThreshold + 5); got != "" {
|
||||
t.Fatalf("a completed plan must not draw a stale reminder, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A plan update resets the turn counter, so the stale reminder does not fire
|
||||
// right after a fresh update.
|
||||
func TestPlanUpdateResetsTurnStaleness(t *testing.T) {
|
||||
state := newGuardState()
|
||||
state.observeTurn(planUpdateTurn("in_progress"))
|
||||
for range stalePlanTurnThreshold - 1 {
|
||||
state.observeTurn(zeroruntime.CollectedStream{Text: "working…"})
|
||||
}
|
||||
// Refresh the plan — resets the turn streak.
|
||||
state.observeTurn(planUpdateTurn("in_progress"))
|
||||
if got := state.planReminder(stalePlanTurnThreshold + 1); got != "" {
|
||||
t.Fatalf("a just-refreshed plan must not be stale, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,345 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -14,13 +14,13 @@ import (
|
||||
// deliberately) or trimmed — the per-turn floor should not creep up silently.
|
||||
//
|
||||
// Measured baselines (2026-07): base system prompt ~3160 tokens; the tools a normal
|
||||
// (auto permission-mode) interactive turn sends ~3230 tokens. The tool figure is
|
||||
// the auto-mode advertised set, including exec_command: auto mode must expose both
|
||||
// process creation and write_stdin process interaction or the latter is unusable
|
||||
// without a session id. Higher-risk tools that do not opt into auto remain excluded.
|
||||
// (auto permission-mode) interactive turn sends ~2430 tokens. The tool figure is
|
||||
// the auto-mode advertised set, not the full core registry — higher-risk tools that
|
||||
// only advertise in other modes are excluded, matching what a real turn actually
|
||||
// pays.
|
||||
const (
|
||||
maxBaseSystemPromptTokens = 3500
|
||||
maxEagerToolSchemaTokens = 3550
|
||||
maxEagerToolSchemaTokens = 2700
|
||||
)
|
||||
|
||||
func TestSystemPromptTokenBudget(t *testing.T) {
|
||||
|
||||
@@ -1,244 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// promptSubstrings are the cacheable components of the prompt a run sends to a
|
||||
// model. systemPrompt is the exact joined system-message content. The remaining
|
||||
// fields retain useful diagnostic boundaries within that prompt and tool list.
|
||||
type promptSubstrings struct {
|
||||
systemPrompt string
|
||||
baseInstructions string
|
||||
confirmationPolicy string
|
||||
projectContext string
|
||||
skills string
|
||||
tools string
|
||||
schema string
|
||||
}
|
||||
|
||||
// buildPromptSubstrings builds the prompt and retains both its exact joined
|
||||
// form and narrower diagnostic components. Callers that already built the
|
||||
// prompt should use buildPromptSubstringsFromParts to avoid repeating workspace
|
||||
// reads.
|
||||
func buildPromptSubstrings(options Options, exposed []zeroruntime.ToolDefinition) promptSubstrings {
|
||||
return buildPromptSubstringsFromParts(buildSystemPromptParts(options), exposed)
|
||||
}
|
||||
|
||||
func buildPromptSubstringsFromParts(parts systemPromptParts, exposed []zeroruntime.ToolDefinition) promptSubstrings {
|
||||
toolsSubstr, schemaSubstr := toolSubstrings(exposed)
|
||||
|
||||
return promptSubstrings{
|
||||
systemPrompt: parts.prompt,
|
||||
baseInstructions: parts.baseInstructions,
|
||||
confirmationPolicy: parts.confirmationPolicy,
|
||||
projectContext: parts.projectContext,
|
||||
skills: parts.skills,
|
||||
tools: toolsSubstr,
|
||||
schema: schemaSubstr,
|
||||
}
|
||||
}
|
||||
|
||||
// toolSubstrings preserves the emitted tool order. Names and descriptions are
|
||||
// fingerprinted separately from schemas so either kind of drift is identifiable.
|
||||
// Order is part of request identity: silently sorting here would hide a provider-
|
||||
// visible reordering and could report a false cache hit.
|
||||
func toolSubstrings(exposed []zeroruntime.ToolDefinition) (toolsSubstr, schemaSubstr string) {
|
||||
if len(exposed) == 0 {
|
||||
return "", ""
|
||||
}
|
||||
var toolsSB, schemaSB strings.Builder
|
||||
for _, def := range exposed {
|
||||
writeLengthPrefixed(&toolsSB, def.Name)
|
||||
writeLengthPrefixed(&toolsSB, def.Description)
|
||||
// Parameters is rendered to a canonical JSON string per tool. The
|
||||
// schema-render cache in internal/agent (used by partitionToolsCached)
|
||||
// guarantees the same render is byte-identical across turns for the
|
||||
// same tool, so this substring is a stable hash input.
|
||||
writeLengthPrefixed(&schemaSB, def.Name)
|
||||
writeLengthPrefixed(&schemaSB, canonicalSchemaString(def.Parameters))
|
||||
}
|
||||
return toolsSB.String(), schemaSB.String()
|
||||
}
|
||||
|
||||
func writeLengthPrefixed(sb *strings.Builder, value string) {
|
||||
fmt.Fprintf(sb, "%d:", len(value))
|
||||
sb.WriteString(value)
|
||||
}
|
||||
|
||||
// canonicalSchemaString renders a tool's parameter schema to a stable string.
|
||||
// tools.ToolDef.Parameters is a map[string]any in the common case; Go's
|
||||
// fmt.Sprintf("%v", m) iterates a map in random order, which would produce
|
||||
// a different hash for the same Parameters value across calls and defeat
|
||||
// the fingerprint. encoding/json marshals maps with keys sorted
|
||||
// alphabetically, so json.Marshal is the primary stable render. The
|
||||
// fallback for the rare non-JSON-compatible value (functions, channels,
|
||||
// NaN/Inf floats, cyclic references) is a stable key-sorted stringifier
|
||||
// that walks the value with sorted keys for maps and a leading
|
||||
// "__non_json:" prefix so a future schema change is visible in the trace
|
||||
// (a SchemaHash collision from the same value) rather than a silent hash
|
||||
// drift.
|
||||
//
|
||||
// Note: SchemaHash is a stability signal, not a wire-identity signal. The
|
||||
// bytes json.Marshal produces for a Go map[string]any may differ from
|
||||
// the bytes the provider actually sends on the wire (provider encoders
|
||||
// use their own JSON conventions; an Anthropic schema may serialize with
|
||||
// different whitespace, key order, or number formatting). A consumer
|
||||
// must NOT assume SchemaHash matches the provider's wire schema — only
|
||||
// that two turns with the same Go-side Parameters produce the same
|
||||
// SchemaHash. This is sufficient for the trace's purpose (drift
|
||||
// detection) but not for a content-based equality check.
|
||||
func canonicalSchemaString(params map[string]any) string {
|
||||
if len(params) == 0 {
|
||||
return ""
|
||||
}
|
||||
data, err := json.Marshal(params)
|
||||
if err != nil {
|
||||
return "__non_json:" + stableStringify(params)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
// stableStringify renders v to a deterministic string. Maps have their keys
|
||||
// sorted alphabetically; slices are walked in order; primitives use their
|
||||
// natural Go format. The function is recursive but bounded by the size of
|
||||
// the input, so a cyclic reference is not reachable here (the json.Marshal
|
||||
// fallback is hit when json.Marshal itself returns an error, which for the
|
||||
// common case is a non-JSON-compatible value, not a cycle — cycles are
|
||||
// rare in tool schemas and acceptable to mis-render in the fallback path
|
||||
// since the trace's contract is "the hash is stable for the same input,"
|
||||
// not "the fallback is lossless"). Used only when json.Marshal fails.
|
||||
func stableStringify(v any) string {
|
||||
var sb strings.Builder
|
||||
writeStable(&sb, v)
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func writeStable(sb *strings.Builder, v any) {
|
||||
switch x := v.(type) {
|
||||
case nil:
|
||||
sb.WriteString("null")
|
||||
case bool:
|
||||
if x {
|
||||
sb.WriteString("true")
|
||||
} else {
|
||||
sb.WriteString("false")
|
||||
}
|
||||
case string:
|
||||
sb.WriteByte('"')
|
||||
sb.WriteString(x)
|
||||
sb.WriteByte('"')
|
||||
case float64:
|
||||
// Use %g for compact, deterministic float rendering. NaN and Inf are
|
||||
// not JSON-encodable (which is why we are in the fallback path) and
|
||||
// render as "NaN" / "+Inf" / "-Inf" — distinct, stable strings.
|
||||
fmt.Fprintf(sb, "%g", x)
|
||||
case float32:
|
||||
fmt.Fprintf(sb, "%g", float64(x))
|
||||
case int:
|
||||
fmt.Fprintf(sb, "%d", x)
|
||||
case int64:
|
||||
fmt.Fprintf(sb, "%d", x)
|
||||
case int32:
|
||||
fmt.Fprintf(sb, "%d", x)
|
||||
case uint:
|
||||
fmt.Fprintf(sb, "%d", x)
|
||||
case uint64:
|
||||
fmt.Fprintf(sb, "%d", x)
|
||||
case uint32:
|
||||
fmt.Fprintf(sb, "%d", x)
|
||||
case map[string]any:
|
||||
sb.WriteByte('{')
|
||||
keys := make([]string, 0, len(x))
|
||||
for k := range x {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for i, k := range keys {
|
||||
if i > 0 {
|
||||
sb.WriteByte(',')
|
||||
}
|
||||
sb.WriteByte('"')
|
||||
sb.WriteString(k)
|
||||
sb.WriteString(`":`)
|
||||
writeStable(sb, x[k])
|
||||
}
|
||||
sb.WriteByte('}')
|
||||
case []any:
|
||||
sb.WriteByte('[')
|
||||
for i, item := range x {
|
||||
if i > 0 {
|
||||
sb.WriteByte(',')
|
||||
}
|
||||
writeStable(sb, item)
|
||||
}
|
||||
sb.WriteByte(']')
|
||||
default:
|
||||
// Last-resort: include the type so distinct values produce distinct
|
||||
// strings even if their default format collides. Without the type
|
||||
// tag, fmt.Sprintf("%v", x) for two different types could produce
|
||||
// the same bytes (rare, but the type prefix makes it impossible).
|
||||
fmt.Fprintf(sb, "<%T:%v>", x, x)
|
||||
}
|
||||
}
|
||||
|
||||
// ComputePrefixFingerprint builds and fingerprints a prompt for callers that do
|
||||
// not already have one. The agent loop uses computePrefixFingerprint with the
|
||||
// prompt parts it built once at run start, ensuring tracing observes the exact
|
||||
// request content without repeating workspace I/O.
|
||||
func ComputePrefixFingerprint(options Options, exposed []zeroruntime.ToolDefinition) prefixFingerprint {
|
||||
return computePrefixFingerprint(buildPromptSubstrings(options, exposed))
|
||||
}
|
||||
|
||||
func computePrefixFingerprint(subs promptSubstrings) prefixFingerprint {
|
||||
systemPrompt := sha256hex(subs.systemPrompt)
|
||||
base := sha256hex(subs.baseInstructions)
|
||||
policy := sha256hex(subs.confirmationPolicy)
|
||||
project := sha256hex(subs.projectContext)
|
||||
skills := sha256hex(subs.skills)
|
||||
toolsH := sha256hex(subs.tools)
|
||||
schema := sha256hex(subs.schema)
|
||||
complete := sha256hex(strings.Join([]string{
|
||||
systemPrompt, toolsH, schema,
|
||||
}, "|"))
|
||||
return prefixFingerprint{
|
||||
SystemPromptHash: systemPrompt,
|
||||
BaseInstructionsHash: base,
|
||||
ConfirmationPolicyHash: policy,
|
||||
ProjectContextHash: project,
|
||||
SkillsHash: skills,
|
||||
ToolsHash: toolsH,
|
||||
SchemaHash: schema,
|
||||
CompletePrefixHash: complete,
|
||||
}
|
||||
}
|
||||
|
||||
// prefixFingerprint is the agent-side shape of a prompt-prefix fingerprint. It
|
||||
// is converted to a trace.PrefixHash at the loop boundary (see EmitPrefixHash
|
||||
// in loop.go) so the trace package does not need to import this one. The field
|
||||
// names match the trace.PrefixHash JSON tags 1:1.
|
||||
type prefixFingerprint struct {
|
||||
SystemPromptHash string
|
||||
BaseInstructionsHash string
|
||||
ConfirmationPolicyHash string
|
||||
ProjectContextHash string
|
||||
SkillsHash string
|
||||
ToolsHash string
|
||||
SchemaHash string
|
||||
CompletePrefixHash string
|
||||
}
|
||||
|
||||
// sha256hex returns the hex-encoded SHA-256 of s. Empty input produces the
|
||||
// hash of the empty string, which is a constant; callers that want to
|
||||
// distinguish "absent" from "empty" should check s before calling.
|
||||
func sha256hex(s string) string {
|
||||
sum := sha256.Sum256([]byte(s))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
@@ -1,249 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// TestComputePrefixFingerprintStableAcrossCalls asserts the headline property
|
||||
// the trace is meant to expose: two ComputePrefixFingerprint calls over the
|
||||
// same Options and the same exposed tool list produce byte-identical hashes.
|
||||
// This is the regression catch for "did anyone introduce non-determinism into
|
||||
// the prompt prefix."
|
||||
func TestComputePrefixFingerprintStableAcrossCalls(t *testing.T) {
|
||||
opts := Options{Cwd: t.TempDir(), SystemPrompt: "core"}
|
||||
exposed := []zeroruntime.ToolDefinition{
|
||||
{Name: "read_file", Parameters: map[string]any{"type": "object"}},
|
||||
{Name: "grep", Parameters: map[string]any{"type": "object"}},
|
||||
}
|
||||
first := ComputePrefixFingerprint(opts, exposed)
|
||||
second := ComputePrefixFingerprint(opts, exposed)
|
||||
if first != second {
|
||||
t.Fatalf("fingerprint must be stable across calls with the same input:\n first=%+v\n second=%+v", first, second)
|
||||
}
|
||||
if first.CompletePrefixHash == "" {
|
||||
t.Fatalf("CompletePrefixHash must be non-empty for a non-trivial prompt")
|
||||
}
|
||||
}
|
||||
|
||||
// Tool order and descriptions are provider-visible and therefore part of the
|
||||
// cacheable prefix identity.
|
||||
func TestComputePrefixFingerprintToolsHashMatchesEmittedOrderAndDescriptions(t *testing.T) {
|
||||
opts := Options{Cwd: t.TempDir(), SystemPrompt: "core"}
|
||||
a := []zeroruntime.ToolDefinition{
|
||||
{Name: "read_file", Description: "read", Parameters: map[string]any{"schema": "schema-read"}},
|
||||
{Name: "grep", Description: "search", Parameters: map[string]any{"schema": "schema-grep"}},
|
||||
}
|
||||
b := []zeroruntime.ToolDefinition{
|
||||
{Name: "grep", Description: "search", Parameters: map[string]any{"schema": "schema-grep"}},
|
||||
{Name: "read_file", Description: "read", Parameters: map[string]any{"schema": "schema-read"}},
|
||||
}
|
||||
fpA := ComputePrefixFingerprint(opts, a)
|
||||
fpB := ComputePrefixFingerprint(opts, b)
|
||||
if fpA.ToolsHash == fpB.ToolsHash {
|
||||
t.Fatalf("ToolsHash must change when emitted order changes: a=%s b=%s", fpA.ToolsHash, fpB.ToolsHash)
|
||||
}
|
||||
c := append([]zeroruntime.ToolDefinition(nil), a...)
|
||||
c[0].Description = "read a file"
|
||||
fpC := ComputePrefixFingerprint(opts, c)
|
||||
if fpA.ToolsHash == fpC.ToolsHash {
|
||||
t.Fatalf("ToolsHash must change when a description changes: a=%s c=%s", fpA.ToolsHash, fpC.ToolsHash)
|
||||
}
|
||||
}
|
||||
|
||||
// TestComputePrefixFingerprintSchemaChangeChangesSchemaHash asserts that a
|
||||
// change to a tool's Parameters schema is visible in the SchemaHash. A schema
|
||||
// edit that should be cache-stable (e.g. field reorder, doc tweak) will move
|
||||
// the hash and surface in the trace; a schema edit that legitimately
|
||||
// invalidates the cache also moves the hash, which is the right behavior.
|
||||
func TestComputePrefixFingerprintSchemaChangeChangesSchemaHash(t *testing.T) {
|
||||
opts := Options{Cwd: t.TempDir(), SystemPrompt: "core"}
|
||||
a := []zeroruntime.ToolDefinition{
|
||||
{Name: "read_file", Parameters: map[string]any{"version": "v1"}},
|
||||
}
|
||||
b := []zeroruntime.ToolDefinition{
|
||||
{Name: "read_file", Parameters: map[string]any{"version": "v2"}},
|
||||
}
|
||||
fpA := ComputePrefixFingerprint(opts, a)
|
||||
fpB := ComputePrefixFingerprint(opts, b)
|
||||
if fpA.SchemaHash == fpB.SchemaHash {
|
||||
t.Fatalf("SchemaHash must change when tool Parameters change: a=%s b=%s", fpA.SchemaHash, fpB.SchemaHash)
|
||||
}
|
||||
}
|
||||
|
||||
// TestComputePrefixFingerprintSystemPromptChangeChangesBaseHash asserts the
|
||||
// most common drift path: a system-prompt edit (e.g. an agent.md update, a
|
||||
// model addendum change) must move the base-instructions hash, which moves
|
||||
// the complete-prefix hash, which is what the trace is for.
|
||||
func TestComputePrefixFingerprintSystemPromptChangeChangesBaseHash(t *testing.T) {
|
||||
optsA := Options{Cwd: t.TempDir(), SystemPrompt: "core v1"}
|
||||
optsB := Options{Cwd: t.TempDir(), SystemPrompt: "core v2"}
|
||||
exposed := []zeroruntime.ToolDefinition{{Name: "noop", Parameters: map[string]any{}}}
|
||||
fpA := ComputePrefixFingerprint(optsA, exposed)
|
||||
fpB := ComputePrefixFingerprint(optsB, exposed)
|
||||
if fpA.BaseInstructionsHash == fpB.BaseInstructionsHash {
|
||||
t.Fatalf("BaseInstructionsHash must change when SystemPrompt changes: a=%s b=%s", fpA.BaseInstructionsHash, fpB.BaseInstructionsHash)
|
||||
}
|
||||
if fpA.CompletePrefixHash == fpB.CompletePrefixHash {
|
||||
t.Fatalf("CompletePrefixHash must change when any sub-hash changes: a=%s b=%s", fpA.CompletePrefixHash, fpB.CompletePrefixHash)
|
||||
}
|
||||
}
|
||||
|
||||
// CompletePrefixHash represents the exact joined system prompt and the ordered
|
||||
// tool definitions. Narrower system-section hashes remain diagnostic only.
|
||||
func TestComputePrefixFingerprintCompleteIsAggregateOfSubHashes(t *testing.T) {
|
||||
opts := Options{Cwd: t.TempDir(), SystemPrompt: "core"}
|
||||
exposed := []zeroruntime.ToolDefinition{{Name: "read_file", Parameters: map[string]any{"k": "v"}}}
|
||||
fp := ComputePrefixFingerprint(opts, exposed)
|
||||
// Manually compute what the canonical join should be.
|
||||
expected := sha256hex(strings.Join([]string{
|
||||
fp.SystemPromptHash,
|
||||
fp.ToolsHash,
|
||||
fp.SchemaHash,
|
||||
}, "|"))
|
||||
if fp.CompletePrefixHash != expected {
|
||||
t.Fatalf("CompletePrefixHash must equal sha256hex of the exact system, tools, and schema hashes:\n got: %s\n expected: %s", fp.CompletePrefixHash, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputePrefixFingerprintCoversEverySystemPromptSection(t *testing.T) {
|
||||
base := Options{Cwd: t.TempDir(), SystemPrompt: "core", Model: "gpt-5"}
|
||||
changed := base
|
||||
changed.ResponseStyle = "concise"
|
||||
|
||||
fpBase := ComputePrefixFingerprint(base, nil)
|
||||
fpChanged := ComputePrefixFingerprint(changed, nil)
|
||||
if fpBase.SystemPromptHash == fpChanged.SystemPromptHash {
|
||||
t.Fatal("SystemPromptHash must change when any rendered system section changes")
|
||||
}
|
||||
if fpBase.CompletePrefixHash == fpChanged.CompletePrefixHash {
|
||||
t.Fatal("CompletePrefixHash must include the exact full system prompt")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildPromptSubstringsDefaultOptions asserts the invariants of the
|
||||
// substrings helper for default Options: which substrings are non-empty,
|
||||
// which are empty, and that the substring-to-hash round-trip is lossless.
|
||||
func TestBuildPromptSubstringsDefaultOptions(t *testing.T) {
|
||||
opts := Options{
|
||||
Cwd: t.TempDir(),
|
||||
SystemPrompt: "test core",
|
||||
}
|
||||
subs := buildPromptSubstrings(opts, nil)
|
||||
// Invariants the trace depends on (default Options):
|
||||
// 1. baseInstructions substring equals the core system prompt bytes.
|
||||
// 2. confirmationPolicy substring is non-empty (the embedded policy
|
||||
// is always present, post TrimSpace).
|
||||
// 3. skills, tools, and schema substrings are empty (no skills or
|
||||
// tools configured for default Options).
|
||||
if subs.baseInstructions != "test core" {
|
||||
t.Fatalf("baseInstructions substring must equal the core system prompt: got %q want %q", subs.baseInstructions, "test core")
|
||||
}
|
||||
if subs.systemPrompt != buildSystemPrompt(opts) {
|
||||
t.Fatal("systemPrompt substring must equal the exact joined system prompt")
|
||||
}
|
||||
if subs.confirmationPolicy == "" {
|
||||
t.Fatalf("confirmationPolicy substring must be non-empty (the embedded policy is always present)")
|
||||
}
|
||||
if subs.skills != "" {
|
||||
t.Fatalf("skills substring must be empty for default Options: got %q", subs.skills)
|
||||
}
|
||||
// Round-trip: the corresponding fingerprint hashes must equal
|
||||
// sha256hex of the substrings, with no truncation or reformatting
|
||||
// between the two layers.
|
||||
fp := ComputePrefixFingerprint(opts, nil)
|
||||
if fp.SystemPromptHash != sha256hex(subs.systemPrompt) {
|
||||
t.Fatalf("SystemPromptHash in fingerprint must equal sha256hex of the joined prompt")
|
||||
}
|
||||
if fp.BaseInstructionsHash != sha256hex(subs.baseInstructions) {
|
||||
t.Fatalf("BaseInstructionsHash in fingerprint must equal sha256hex of the substring")
|
||||
}
|
||||
if fp.ConfirmationPolicyHash != sha256hex(subs.confirmationPolicy) {
|
||||
t.Fatalf("ConfirmationPolicyHash in fingerprint must equal sha256hex of the substring")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCanonicalSchemaStringStableForMapParameters asserts the headline
|
||||
// property the trace needs: the same map[string]any produces the same
|
||||
// canonical string across calls, even though Go's fmt.Sprintf("%v", m)
|
||||
// iterates a map in random order. json.Marshal sorts map keys
|
||||
// alphabetically, which is the fix.
|
||||
func TestCanonicalSchemaStringStableForMapParameters(t *testing.T) {
|
||||
params := map[string]any{
|
||||
"description": "read a file",
|
||||
"properties": map[string]any{
|
||||
"path": map[string]any{"type": "string"},
|
||||
},
|
||||
"required": []any{"path"},
|
||||
"type": "object",
|
||||
}
|
||||
first := canonicalSchemaString(params)
|
||||
second := canonicalSchemaString(params)
|
||||
if first != second {
|
||||
t.Fatalf("canonicalSchemaString must be stable across calls for the same Parameters:\n first=%s\n second=%s", first, second)
|
||||
}
|
||||
if first == "" {
|
||||
t.Fatal("canonicalSchemaString must produce a non-empty string for a non-empty map")
|
||||
}
|
||||
// Two maps with the same key/value pairs (different declaration
|
||||
// order in source) must produce identical canonical strings. This
|
||||
// is the property that defeats Go's map iteration randomization.
|
||||
reordered := map[string]any{
|
||||
"type": "object",
|
||||
"required": []any{"path"},
|
||||
"properties": map[string]any{
|
||||
"path": map[string]any{"type": "string"},
|
||||
},
|
||||
"description": "read a file",
|
||||
}
|
||||
if canonicalSchemaString(params) != canonicalSchemaString(reordered) {
|
||||
t.Fatal("canonicalSchemaString must produce identical output for maps with the same key/value pairs in different declaration orders")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCanonicalSchemaStringFallbackStableForNonJSONValue exercises the
|
||||
// non-JSON fallback in canonicalSchemaString: when Parameters contains a
|
||||
// value json.Marshal cannot encode (here, a channel), the function must
|
||||
// fall back to a key-sorted stringification that is stable across calls
|
||||
// and across map-iteration order. The previous fallback used
|
||||
// fmt.Sprintf("%v", params), which iterates the map in random order and
|
||||
// produced a different hash for the same value across calls — defeating
|
||||
// the fingerprint. The current fallback is stable by construction.
|
||||
func TestCanonicalSchemaStringFallbackStableForNonJSONValue(t *testing.T) {
|
||||
ch := make(chan int) // channels are not JSON-encodable
|
||||
params := map[string]any{
|
||||
"description": "a tool with a non-JSON value",
|
||||
"channel": ch,
|
||||
"z_first": 1,
|
||||
"a_second": 2,
|
||||
}
|
||||
first := canonicalSchemaString(params)
|
||||
second := canonicalSchemaString(params)
|
||||
if first == "" {
|
||||
t.Fatal("canonicalSchemaString must produce a non-empty string for the fallback path")
|
||||
}
|
||||
if first != second {
|
||||
t.Fatalf("canonicalSchemaString fallback must be stable across calls:\n first=%s\n second=%s", first, second)
|
||||
}
|
||||
// A re-ordered map (same key/value pairs, different declaration order)
|
||||
// must produce the same fallback string. This is the property the
|
||||
// key-sorted stringifier provides that fmt.Sprintf("%v", m) does not.
|
||||
reordered := map[string]any{
|
||||
"a_second": 2,
|
||||
"z_first": 1,
|
||||
"channel": ch,
|
||||
"description": "a tool with a non-JSON value",
|
||||
}
|
||||
if canonicalSchemaString(params) != canonicalSchemaString(reordered) {
|
||||
t.Fatalf("canonicalSchemaString fallback must produce identical output for maps with the same key/value pairs in different declaration orders:\n first=%s\n second=%s", first, canonicalSchemaString(reordered))
|
||||
}
|
||||
// The fallback must include the "__non_json:" prefix so a consumer
|
||||
// can tell the bytes are not a JSON-marshaled schema (a hash collision
|
||||
// between a json.Marshal result and a stableStringify result is
|
||||
// possible in theory; the prefix makes the source observable).
|
||||
if !strings.HasPrefix(first, "__non_json:") {
|
||||
t.Fatalf("canonicalSchemaString fallback must start with the __non_json: prefix, got: %s", first)
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,9 @@ package agent
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/errhint"
|
||||
"github.com/Gitlawb/zero/internal/trace"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
@@ -20,20 +17,10 @@ import (
|
||||
// times. We retry ONLY the connect (not a partially-consumed stream), so no
|
||||
// already-forwarded OnText is ever duplicated.
|
||||
const (
|
||||
// maxStreamReconnects is how many times the connect is re-issued after a
|
||||
// transient disconnect. 4 (not 2): with jittered exponential backoff this rides
|
||||
// out a multi-second network blip (~0.5s + 1s + 2s + 4s worst case) instead of
|
||||
// dying on a 2s hiccup and re-burning every token on a restart.
|
||||
maxStreamReconnects = 4
|
||||
// streamReconnectMax caps a single backoff so the tail attempts don't wait
|
||||
// minutes on a long outage.
|
||||
streamReconnectMax = 8 * time.Second
|
||||
maxStreamReconnects = 2
|
||||
streamReconnectBase = 500 * time.Millisecond
|
||||
)
|
||||
|
||||
// streamReconnectBase is the first-retry delay (doubled each subsequent attempt).
|
||||
// A var, not a const, so tests can shrink it to keep the exhaustion path fast.
|
||||
var streamReconnectBase = 500 * time.Millisecond
|
||||
|
||||
// reconnectNotifier is called before each retry with the 1-based attempt number
|
||||
// and the max, so the caller can surface a "Reconnecting N/max" notice. Nil is
|
||||
// fine.
|
||||
@@ -75,32 +62,22 @@ func stallRetryNoticeFor(options Options) reconnectNotifier {
|
||||
// already past its deadline is returned immediately (no retry) — those have
|
||||
// their own handling (compaction for context-limit, image-rejection, etc.).
|
||||
func streamWithReconnect(ctx context.Context, provider Provider, request zeroruntime.CompletionRequest, notify reconnectNotifier) (<-chan zeroruntime.StreamEvent, error) {
|
||||
recorder := trace.FromContext(ctx)
|
||||
stream, err := provider.StreamCompletion(ctx, request)
|
||||
if err == nil {
|
||||
if recorder != nil {
|
||||
recorder.Counter(trace.CounterModelRequests, 1)
|
||||
}
|
||||
return stream, nil
|
||||
}
|
||||
for attempt := 1; attempt <= maxStreamReconnects; attempt++ {
|
||||
if !shouldReconnect(ctx, err) {
|
||||
return nil, err
|
||||
}
|
||||
if recorder != nil {
|
||||
recorder.Counter(trace.CounterReconnectCount, 1)
|
||||
}
|
||||
if notify != nil {
|
||||
notify(attempt, maxStreamReconnects)
|
||||
}
|
||||
if waitErr := sleepWithContext(ctx, jitteredBackoff(attempt)); waitErr != nil {
|
||||
if waitErr := sleepWithContext(ctx, backoffFor(attempt)); waitErr != nil {
|
||||
return nil, err // ctx cancelled while waiting; surface the original error
|
||||
}
|
||||
stream, err = provider.StreamCompletion(ctx, request)
|
||||
if err == nil {
|
||||
if recorder != nil {
|
||||
recorder.Counter(trace.CounterModelRequests, 1)
|
||||
}
|
||||
return stream, nil
|
||||
}
|
||||
}
|
||||
@@ -119,19 +96,6 @@ func shouldReconnect(ctx context.Context, err error) bool {
|
||||
if isContextLimitError(msg) || isImageRejectionError(err) {
|
||||
return false
|
||||
}
|
||||
// HTTP 5xx statuses are non-reconnectable and must be excluded BEFORE the
|
||||
// transport substring match below — otherwise "504 Gateway Timeout" would slip
|
||||
// through on the generic "timeout" needle. 503 already exhausted
|
||||
// providerio.SendWithRetry (retrying is a redundant double-retry); 500/502/504
|
||||
// are non-idempotent by providerio's rule — the completion POST may already
|
||||
// have reached the model before the upstream/gateway gave up, so replaying the
|
||||
// connect risks duplicate billable work. Digit-boundary matched so an
|
||||
// incidental "504" inside a latency/id number is not mistaken for a status.
|
||||
if errhint.HasStatusCode(msg, "500", "502", "503", "504") {
|
||||
return false
|
||||
}
|
||||
// Transport-level disconnects only. A genuine transport failure (EOF, reset,
|
||||
// refused, timeout) means no response was received, which is safe to reconnect.
|
||||
for _, needle := range []string{
|
||||
"eof",
|
||||
"connection reset",
|
||||
@@ -142,6 +106,8 @@ func shouldReconnect(ctx context.Context, err error) bool {
|
||||
"timed out",
|
||||
"temporarily unavailable",
|
||||
"i/o timeout",
|
||||
"503",
|
||||
"502",
|
||||
"server closed",
|
||||
"unexpected end",
|
||||
} {
|
||||
@@ -152,31 +118,14 @@ func shouldReconnect(ctx context.Context, err error) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// backoffFor is the deterministic exponential base delay for a 1-based attempt,
|
||||
// capped at streamReconnectMax. Jitter is layered on separately (jitteredBackoff).
|
||||
func backoffFor(attempt int) time.Duration {
|
||||
d := streamReconnectBase
|
||||
for i := 1; i < attempt; i++ {
|
||||
if d >= streamReconnectMax {
|
||||
return streamReconnectMax
|
||||
}
|
||||
d *= 2
|
||||
}
|
||||
if d > streamReconnectMax {
|
||||
d = streamReconnectMax
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// jitteredBackoff adds up to 50% random jitter on top of backoffFor so concurrent
|
||||
// runs (swarm members, a cron fleet) that all trip on the same outage don't
|
||||
// reconnect in lockstep and hammer a recovering endpoint. Never shorter than the
|
||||
// deterministic base, so backoff still grows attempt over attempt.
|
||||
func jitteredBackoff(attempt int) time.Duration {
|
||||
base := backoffFor(attempt)
|
||||
return base + time.Duration(rand.Int63n(int64(base/2)+1))
|
||||
}
|
||||
|
||||
func sleepWithContext(ctx context.Context, d time.Duration) error {
|
||||
timer := time.NewTimer(d)
|
||||
defer timer.Stop()
|
||||
|
||||
@@ -44,10 +44,6 @@ func TestStreamWithReconnectRecoversFromTransientDisconnect(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestStreamWithReconnectGivesUpAfterMax(t *testing.T) {
|
||||
// Shrink the backoff so exhausting all retries stays fast (real base would
|
||||
// sleep ~7.5s across 4 attempts).
|
||||
defer func(orig time.Duration) { streamReconnectBase = orig }(streamReconnectBase)
|
||||
streamReconnectBase = time.Millisecond
|
||||
// Always fails with a disconnect error → exhausts retries and returns it.
|
||||
p := &flakyProvider{failBefore: 99, failErr: errors.New("connection reset by peer")}
|
||||
_, err := streamWithReconnect(context.Background(), p, zeroruntime.CompletionRequest{}, nil)
|
||||
@@ -91,7 +87,7 @@ func TestShouldReconnectClassification(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
disconnects := []string{
|
||||
"unexpected EOF", "connection reset by peer", "broken pipe",
|
||||
"i/o timeout", "server closed the connection", "connection refused",
|
||||
"i/o timeout", "503 Service Unavailable", "server closed the connection",
|
||||
}
|
||||
for _, m := range disconnects {
|
||||
if !shouldReconnect(ctx, errors.New(m)) {
|
||||
@@ -101,13 +97,6 @@ func TestShouldReconnectClassification(t *testing.T) {
|
||||
notDisconnects := []string{
|
||||
"context length exceeded", "invalid api key", "model not found",
|
||||
"400 bad request: unsupported parameter",
|
||||
// HTTP 5xx statuses are handled by providerio.SendWithRetry (503) or are
|
||||
// non-idempotent (500/502/504); the reconnect path must not double-retry
|
||||
// them. "504 Gateway Timeout" must NOT slip through on the generic "timeout"
|
||||
// transport signal — a gateway timeout is non-idempotent, since the upstream
|
||||
// may have already processed the completion POST before giving up.
|
||||
"503 Service Unavailable", "502 Bad Gateway",
|
||||
"504 Gateway Timeout", "500 Internal Server Error",
|
||||
}
|
||||
for _, m := range notDisconnects {
|
||||
if shouldReconnect(ctx, errors.New(m)) {
|
||||
@@ -123,24 +112,6 @@ func TestBackoffGrows(t *testing.T) {
|
||||
if backoffFor(2) != 2*streamReconnectBase {
|
||||
t.Fatalf("attempt 2 backoff = %v, want %v", backoffFor(2), 2*streamReconnectBase)
|
||||
}
|
||||
// The exponential base is capped so late attempts don't wait minutes.
|
||||
if got := backoffFor(20); got != streamReconnectMax {
|
||||
t.Fatalf("attempt 20 backoff = %v, want cap %v", got, streamReconnectMax)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJitteredBackoffStaysInBounds(t *testing.T) {
|
||||
// Jitter never drops below the deterministic base and never exceeds base*1.5,
|
||||
// so backoff still grows attempt over attempt while decorrelating retries.
|
||||
for attempt := 1; attempt <= 5; attempt++ {
|
||||
base := backoffFor(attempt)
|
||||
for i := 0; i < 200; i++ {
|
||||
got := jitteredBackoff(attempt)
|
||||
if got < base || got > base+base/2 {
|
||||
t.Fatalf("attempt %d jittered backoff %v out of [%v, %v]", attempt, got, base, base+base/2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconnectNoticeRoutesThroughReasoning(t *testing.T) {
|
||||
|
||||
@@ -180,7 +180,7 @@ func TestInlineAdditionalPermissionsRequiresPromptUnderAutoAllow(t *testing.T) {
|
||||
},
|
||||
}
|
||||
decision := &sandbox.Decision{Action: sandbox.ActionAllow, AutoAllowed: true}
|
||||
if !shouldRequestPermission(tools.NewScopedBashTool(t.TempDir(), nil), args, false, decision) {
|
||||
if !shouldRequestPermission(tools.NewBashTool(t.TempDir()), args, false, decision) {
|
||||
t.Fatal("inline additional permissions must request approval even when a sandboxed shell would otherwise auto-allow")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
|
||||
"github.com/Gitlawb/zero/internal/lsp"
|
||||
"github.com/Gitlawb/zero/internal/redaction"
|
||||
"github.com/Gitlawb/zero/internal/trace"
|
||||
"github.com/Gitlawb/zero/internal/verify"
|
||||
)
|
||||
|
||||
@@ -134,9 +133,7 @@ func (sc *SelfCorrector) inspect(ctx context.Context, changedFiles []string) Cor
|
||||
}
|
||||
|
||||
if sc.cfg.IncludeTests && sc.verifier != nil {
|
||||
verifySpan := trace.FromContext(ctx).Span(trace.SpanVerification)
|
||||
vr, err := sc.verifier.Verify(ctx)
|
||||
verifySpan.End()
|
||||
if err != nil {
|
||||
// "No plan / no tests" is not an error (DetectPlan returns an empty
|
||||
// plan), so a non-nil error means verification could not run at all —
|
||||
|
||||
@@ -7,18 +7,14 @@ import (
|
||||
)
|
||||
|
||||
func TestSkillsContextCapsLongList(t *testing.T) {
|
||||
// 60 skills with realistic trigger-rich descriptions (~180 chars each, under
|
||||
// the 200-rune truncation) total well past the 4096-byte list budget, so the
|
||||
// overflow summary must kick in while the block stays bounded.
|
||||
longDesc := strings.Repeat("use when the request touches deployments, release notes, or verification; ", 2) + "number "
|
||||
skills := make([]SkillInfo, 0, 60)
|
||||
for i := 0; i < 60; i++ {
|
||||
skills := make([]SkillInfo, 0, 40)
|
||||
for i := 0; i < 40; i++ {
|
||||
n := strconv.Itoa(i)
|
||||
skills = append(skills, SkillInfo{Name: "skill-" + n, Description: longDesc + n})
|
||||
skills = append(skills, SkillInfo{Name: "skill-" + n, Description: "does something useful, number " + n})
|
||||
}
|
||||
got := skillsContext(Options{Skills: skills})
|
||||
if len(got) > skillsContextListBudget+1000 {
|
||||
t.Fatalf("skills block should stay bounded near the %d-byte budget, got %d bytes:\n%s", skillsContextListBudget, len(got), got)
|
||||
if len(got) > 1200 {
|
||||
t.Fatalf("skills block should stay bounded, got %d bytes:\n%s", len(got), got)
|
||||
}
|
||||
if !strings.Contains(got, "more (call skill") {
|
||||
t.Fatalf("expected an overflow summary line, got:\n%s", got)
|
||||
@@ -29,27 +25,6 @@ func TestSkillsContextCapsLongList(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A realistic mid-size skill set (20 skills, trigger-rich descriptions) must be
|
||||
// listed in FULL — no overflow summary. This pins the fix for the old 640-byte
|
||||
// budget, under which skills past ~#6 were invisible to the model and therefore
|
||||
// never triggered.
|
||||
func TestSkillsContextListsRealisticSetInFull(t *testing.T) {
|
||||
desc := "Use when the user asks about deployments, release notes, or pre-merge verification runs."
|
||||
skills := make([]SkillInfo, 0, 20)
|
||||
for i := 0; i < 20; i++ {
|
||||
skills = append(skills, SkillInfo{Name: "skill-" + strconv.Itoa(i), Description: desc})
|
||||
}
|
||||
got := skillsContext(Options{Skills: skills})
|
||||
if strings.Contains(got, "more (call skill") {
|
||||
t.Fatalf("20 described skills must all be listed without overflow, got:\n%s", got)
|
||||
}
|
||||
for i := 0; i < 20; i++ {
|
||||
if !strings.Contains(got, "- skill-"+strconv.Itoa(i)+":") {
|
||||
t.Fatalf("skill-%d missing from the list:\n%s", i, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillsContext(t *testing.T) {
|
||||
if got := skillsContext(Options{}); got != "" {
|
||||
t.Fatalf("no skills should yield an empty section, got %q", got)
|
||||
|
||||
+44
-170
@@ -9,9 +9,7 @@ import (
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/repomap"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/workspaceseed"
|
||||
)
|
||||
|
||||
@@ -40,14 +38,6 @@ const fallbackSystemPrompt = "You are Zero, a terminal coding agent. Help with t
|
||||
// general-to-specific order.
|
||||
var projectContextFiles = []string{"AGENTS.md", "ZERO.md", ".zero/AGENTS.md"}
|
||||
|
||||
// userContextFile is the per-user instruction file, resolved under
|
||||
// config.UserConfigDir()/zero/ alongside the rest of Zero's per-user config
|
||||
// (config.json, commands, specialists) so users can keep personal guidance out
|
||||
// of individual repositories.
|
||||
const userContextFile = "ZERO.md"
|
||||
|
||||
var userConfigDirForPrompt = config.UserConfigDir
|
||||
|
||||
// maxProjectContextBytes caps how much of a single project doc is injected so
|
||||
// a large guidelines file can't blow the context budget.
|
||||
const maxProjectContextBytes = 8 << 10 // 8 KiB per file
|
||||
@@ -71,22 +61,6 @@ const (
|
||||
// guidelines), and the safety confirmation policy. It is built once per run so
|
||||
// every turn shares one (cacheable) system turn.
|
||||
func buildSystemPrompt(options Options) string {
|
||||
return buildSystemPromptParts(options).prompt
|
||||
}
|
||||
|
||||
// systemPromptParts retains both the exact joined prompt and the diagnostic
|
||||
// sections used by prompt-prefix tracing. Building these together prevents the
|
||||
// tracing path from re-reading workspace state and guarantees that the hash is
|
||||
// computed from the same prompt bytes placed in the request.
|
||||
type systemPromptParts struct {
|
||||
prompt string
|
||||
baseInstructions string
|
||||
confirmationPolicy string
|
||||
projectContext string
|
||||
skills string
|
||||
}
|
||||
|
||||
func buildSystemPromptParts(options Options) systemPromptParts {
|
||||
core := strings.TrimSpace(options.SystemPrompt)
|
||||
if core == "" {
|
||||
core = strings.TrimSpace(coreSystemPrompt)
|
||||
@@ -107,38 +81,22 @@ func buildSystemPromptParts(options Options) systemPromptParts {
|
||||
if seed := workspaceSeedContext(options.Cwd); seed != "" {
|
||||
sections = append(sections, seed)
|
||||
}
|
||||
// User guidelines are injected before workspace/project guidelines so the
|
||||
// project's AGENTS.md/ZERO.md is the later, more specific instruction
|
||||
// block. See userGuidelines for the explicit precedence note carried in
|
||||
// the section text itself.
|
||||
if user := userGuidelines(); user != "" {
|
||||
sections = append(sections, user)
|
||||
}
|
||||
project := workspaceContext(options.Cwd)
|
||||
if project != "" {
|
||||
sections = append(sections, project)
|
||||
if ws := workspaceContext(options.Cwd); ws != "" {
|
||||
sections = append(sections, ws)
|
||||
}
|
||||
if delegation := specialistDelegationContext(options); delegation != "" {
|
||||
sections = append(sections, delegation)
|
||||
}
|
||||
skillsBlock := skillsContext(options)
|
||||
if skillsBlock != "" {
|
||||
if skillsBlock := skillsContext(options); skillsBlock != "" {
|
||||
sections = append(sections, skillsBlock)
|
||||
}
|
||||
if style := responseStyleContext(options); style != "" {
|
||||
sections = append(sections, style)
|
||||
}
|
||||
policy := strings.TrimSpace(confirmationPolicy)
|
||||
if policy != "" {
|
||||
if policy := strings.TrimSpace(confirmationPolicy); policy != "" {
|
||||
sections = append(sections, policy)
|
||||
}
|
||||
return systemPromptParts{
|
||||
prompt: strings.Join(sections, "\n\n"),
|
||||
baseInstructions: core,
|
||||
confirmationPolicy: policy,
|
||||
projectContext: project,
|
||||
skills: skillsBlock,
|
||||
}
|
||||
return strings.Join(sections, "\n\n")
|
||||
}
|
||||
|
||||
// responseStyleContext renders the operator-selected reply style (TUI /style) as
|
||||
@@ -217,19 +175,10 @@ func specialistDelegationContext(options Options) string {
|
||||
// when no skills are installed, so a skill-less run reproduces the previous prompt
|
||||
// byte-for-byte.
|
||||
// skillsContextListBudget bounds the bytes spent listing individual skills so a
|
||||
// workspace with an extreme number of them can't bloat every turn's prompt.
|
||||
// Skills past the budget are summarized as a count rather than dropped — the
|
||||
// model can still load any of them by name (an unknown name returns the full
|
||||
// list from the skill tool).
|
||||
//
|
||||
// The budget is deliberately generous — roughly 18 skills with maximally long
|
||||
// (200-rune, ASCII) descriptions, or 40+ with typical terser ones; non-ASCII
|
||||
// descriptions consume it faster since the budget counts bytes. This list is
|
||||
// the model's ONLY discovery surface, so a skill squeezed out of it effectively
|
||||
// never triggers. The previous 640-byte cap silently broke invocation for
|
||||
// anyone with more than ~6 skills — the model cannot match a request against a
|
||||
// skill it cannot see.
|
||||
const skillsContextListBudget = 4096
|
||||
// workspace with dozens of them can't bloat every turn's prompt. Skills past the
|
||||
// budget are summarized as a count rather than dropped — the model can still load
|
||||
// any of them by name (an unknown name returns the full list from the skill tool).
|
||||
const skillsContextListBudget = 640
|
||||
|
||||
func skillsContext(options Options) string {
|
||||
if len(options.Skills) == 0 {
|
||||
@@ -237,7 +186,7 @@ func skillsContext(options Options) string {
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("<available_skills>\n")
|
||||
b.WriteString("Reusable, on-demand instruction sets you can load with the skill tool. Before acting on a request, scan this list; when the request matches a skill's name or description, call skill with its exact name FIRST and follow the loaded guidance — do not guess names, do not skip a matching skill, and do not substitute your own approach for its instructions.\n")
|
||||
b.WriteString("Reusable, on-demand instruction sets you can load with the skill tool. When a request matches one, call skill with its exact name to pull in its guidance before acting — do not guess names or skip a relevant skill.\n")
|
||||
listed, spent, omitted := 0, 0, 0
|
||||
for _, info := range options.Skills {
|
||||
name := strings.TrimSpace(info.Name)
|
||||
@@ -267,11 +216,9 @@ func skillsContext(options Options) string {
|
||||
}
|
||||
|
||||
// truncateForSkillLine keeps a skill's one-line description short so a single
|
||||
// verbose description can't dominate the skills-list budget. The cap is roomy on
|
||||
// purpose: the description carries the skill's trigger conditions ("use when…"),
|
||||
// and truncating those is what stops the model from ever invoking the skill.
|
||||
// verbose description can't dominate the skills-list budget.
|
||||
func truncateForSkillLine(desc string) string {
|
||||
const maxDescRunes = 200
|
||||
const maxDescRunes = 100
|
||||
runes := []rune(desc)
|
||||
if len(runes) <= maxDescRunes {
|
||||
return desc
|
||||
@@ -311,13 +258,17 @@ func workspaceContext(cwd string) string {
|
||||
b.WriteString("<environment>\n")
|
||||
b.WriteString("Working directory: " + cwd + "\n")
|
||||
b.WriteString("Operating system: " + runtime.GOOS + "\n")
|
||||
b.WriteString(tools.HostShellEnvironmentGuidance() + "\n")
|
||||
if runtime.GOOS == "windows" {
|
||||
b.WriteString("Shell syntax: Windows cmd.exe syntax for exec_command/bash tools; prefer the workdir/cwd argument instead of cd when changing directories.\n")
|
||||
} else {
|
||||
b.WriteString("Shell syntax: /bin/sh syntax for exec_command/bash tools; prefer the workdir/cwd argument instead of cd when changing directories.\n")
|
||||
}
|
||||
if branch := gitBranchForPrompt(cwd); branch != "" {
|
||||
b.WriteString("Git branch: " + branch + "\n")
|
||||
}
|
||||
b.WriteString("</environment>")
|
||||
|
||||
b.WriteString(projectGuidelines(cwd, FindProjectGitRoot(cwd)))
|
||||
b.WriteString(projectGuidelines(cwd, findProjectGitRoot(cwd)))
|
||||
if repoMap := repoMapContext(cwd); repoMap != "" {
|
||||
b.WriteString("\n\n## Repo map\n\n" + repoMap)
|
||||
}
|
||||
@@ -361,7 +312,13 @@ func projectGuidelines(cwd, gitRoot string) string {
|
||||
if remaining := maxProjectContextTotalBytes - totalUsed; remaining < limit {
|
||||
limit = remaining
|
||||
}
|
||||
content = truncateGuidelineContent(content, limit)
|
||||
if len(content) > limit {
|
||||
cut := limit
|
||||
for cut > 0 && !utf8.RuneStart(content[cut]) {
|
||||
cut--
|
||||
}
|
||||
content = content[:cut] + "\n… (truncated)"
|
||||
}
|
||||
label := projectGuidelineLabel(match, gitRoot)
|
||||
b.WriteString("\n\n## Project guidelines (" + label + ")\n\n" + content)
|
||||
totalUsed += len(content)
|
||||
@@ -369,92 +326,6 @@ func projectGuidelines(cwd, gitRoot string) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// userGuidelines returns the per-user ZERO.md instructions block, if present.
|
||||
// The file lives in config.UserConfigDir()/zero/ next to Zero's other
|
||||
// per-user config; the basename match is case-insensitive so a file saved as
|
||||
// zero.md still resolves on case-sensitive filesystems, mirroring the project
|
||||
// guideline loader. The section carries an explicit precedence note because
|
||||
// this is a global, personal preferences file: it is injected earlier in the
|
||||
// prompt than the project's AGENTS.md/ZERO.md (see buildSystemPrompt), and
|
||||
// the note keeps that precedence unambiguous even if a model weighs later
|
||||
// context more heavily than section order alone implies.
|
||||
func userGuidelines() string {
|
||||
configDir, err := userConfigDirForPrompt()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
configDir = strings.TrimSpace(configDir)
|
||||
if configDir == "" {
|
||||
return ""
|
||||
}
|
||||
match := findCaseInsensitiveFile(filepath.Join(configDir, "zero"), userContextFile)
|
||||
if match == "" {
|
||||
return ""
|
||||
}
|
||||
data, err := os.ReadFile(match)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
content := strings.TrimSpace(string(data))
|
||||
if content == "" {
|
||||
return ""
|
||||
}
|
||||
content = truncateGuidelineContent(content, maxProjectContextBytes)
|
||||
return "## User guidelines (" + filepath.Base(match) + ")\n\n" +
|
||||
"These are the operator's personal preferences, not project policy. " +
|
||||
"Where they conflict with a repository's project guidelines below (AGENTS.md/ZERO.md), the project guidelines take precedence.\n\n" +
|
||||
content
|
||||
}
|
||||
|
||||
// truncationMarker is appended to guideline content that was cut short.
|
||||
const truncationMarker = "\n… (truncated)"
|
||||
|
||||
// truncateGuidelineContent caps content at limit bytes without splitting a
|
||||
// UTF-8 rune, appending a truncation marker when anything was cut. Space for
|
||||
// the marker is reserved before choosing the cut point. When limit is too
|
||||
// small to fit the marker itself, the marker is dropped instead, so the
|
||||
// returned string never exceeds limit for any non-negative limit.
|
||||
func truncateGuidelineContent(content string, limit int) string {
|
||||
if len(content) <= limit {
|
||||
return content
|
||||
}
|
||||
if limit <= 0 {
|
||||
return ""
|
||||
}
|
||||
cut := limit - len(truncationMarker)
|
||||
if cut < 0 {
|
||||
cut = 0
|
||||
}
|
||||
for cut > 0 && !utf8.RuneStart(content[cut]) {
|
||||
cut--
|
||||
}
|
||||
if truncated := content[:cut] + truncationMarker; len(truncated) <= limit {
|
||||
return truncated
|
||||
}
|
||||
// limit is smaller than the marker itself: fall back to a hard,
|
||||
// rune-safe cut at limit bytes with no marker rather than exceeding it.
|
||||
end := limit
|
||||
for end > 0 && !utf8.RuneStart(content[end]) {
|
||||
end--
|
||||
}
|
||||
return content[:end]
|
||||
}
|
||||
|
||||
// findCaseInsensitiveFile returns the on-disk path of the regular file in dir
|
||||
// whose name matches basename case-insensitively, or "" when absent.
|
||||
func findCaseInsensitiveFile(dir, basename string) string {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() && strings.EqualFold(e.Name(), basename) {
|
||||
return filepath.Join(dir, e.Name())
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// projectGuidelineDirs returns the directory chain from gitRoot to cwd
|
||||
// (inclusive), in root-to-leaf order. If gitRoot is empty or unreachable
|
||||
// from cwd, the chain collapses to [cwd].
|
||||
@@ -507,16 +378,23 @@ func projectGuidelineLabel(match, gitRoot string) string {
|
||||
// Returns "" when nothing matches.
|
||||
func findProjectContextFile(dir string) string {
|
||||
for _, name := range projectContextFiles {
|
||||
baseLower := strings.ToLower(filepath.Base(name))
|
||||
// Walk to the file's parent through dir with case-insensitive segment
|
||||
// matching, then find a regular file with the same basename. This works
|
||||
// on both case-sensitive and case-insensitive filesystems and always
|
||||
// returns the on-disk filename.
|
||||
// matching, then find a regular file with the same lowercased
|
||||
// basename. This works on both case-sensitive and case-insensitive
|
||||
// filesystems and always returns the on-disk filename.
|
||||
parent, ok := resolveDirCaseInsensitive(filepath.Dir(filepath.Join(dir, name)), dir)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if match := findCaseInsensitiveFile(parent, filepath.Base(name)); match != "" {
|
||||
return match
|
||||
entries, err := os.ReadDir(parent)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() && strings.ToLower(e.Name()) == baseLower {
|
||||
return filepath.Join(parent, e.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
@@ -564,17 +442,17 @@ func resolveDirCaseInsensitive(target, anchor string) (string, bool) {
|
||||
return cur, true
|
||||
}
|
||||
|
||||
// FindProjectGitRoot returns the nearest ancestor of cwd that contains a
|
||||
// findProjectGitRoot returns the nearest ancestor of cwd that contains a
|
||||
// .git entry (file or directory). Returns "" when no git root is found, so
|
||||
// the caller can fall back to cwd-only lookup.
|
||||
func FindProjectGitRoot(cwd string) string {
|
||||
func findProjectGitRoot(cwd string) string {
|
||||
cwd = strings.TrimSpace(cwd)
|
||||
if cwd == "" {
|
||||
return ""
|
||||
}
|
||||
cur := cwd
|
||||
for {
|
||||
if HasGitMetadata(cur) {
|
||||
if hasGitMetadata(cur) {
|
||||
return cur
|
||||
}
|
||||
parent := filepath.Dir(cur)
|
||||
@@ -585,7 +463,7 @@ func FindProjectGitRoot(cwd string) string {
|
||||
}
|
||||
}
|
||||
|
||||
func HasGitMetadata(dir string) bool {
|
||||
func hasGitMetadata(dir string) bool {
|
||||
gitPath := filepath.Join(dir, ".git")
|
||||
info, err := os.Stat(gitPath)
|
||||
if err != nil {
|
||||
@@ -641,11 +519,7 @@ func repoMapContext(cwd string) string {
|
||||
// cwd, handling both a regular checkout (.git dir) and a worktree (.git file).
|
||||
// Returns "" on any problem — the prompt simply omits the branch segment.
|
||||
func gitBranchForPrompt(cwd string) string {
|
||||
gitRoot := FindProjectGitRoot(cwd)
|
||||
if gitRoot == "" {
|
||||
return ""
|
||||
}
|
||||
gitPath := filepath.Join(gitRoot, ".git")
|
||||
gitPath := filepath.Join(cwd, ".git")
|
||||
info, err := os.Stat(gitPath)
|
||||
if err != nil {
|
||||
return ""
|
||||
@@ -661,10 +535,10 @@ func gitBranchForPrompt(cwd string) string {
|
||||
return ""
|
||||
}
|
||||
// In worktree mode the gitdir is often RELATIVE (e.g.
|
||||
// "gitdir: ../.git/worktrees/<name>") — resolve it against the worktree root (gitRoot), not the
|
||||
// "gitdir: ../.git/worktrees/<name>") — resolve it against cwd, not the
|
||||
// process working directory, or HEAD lookup fails and we drop the branch.
|
||||
if !filepath.IsAbs(dir) {
|
||||
dir = filepath.Join(gitRoot, dir)
|
||||
dir = filepath.Join(cwd, dir)
|
||||
}
|
||||
headPath = filepath.Join(dir, "HEAD")
|
||||
}
|
||||
|
||||
@@ -63,10 +63,6 @@ work.
|
||||
targeted diffs. Match the existing indentation, imports, and idioms. Match the
|
||||
file's comment density: do not add explanatory comments unless the user asks or
|
||||
the code is already comment-dense.
|
||||
- Solve the problem as posed, not a more general version of it. Add no
|
||||
speculative abstraction, configurability, or handling for cases that cannot
|
||||
occur, and nothing the user did not ask for. A small diff can still be
|
||||
over-built; if a 200-line solution could be 50, rewrite it.
|
||||
- Preserve behavior you were not asked to change. Do not delete or rewrite code
|
||||
you did not author unless the task requires it; if you must, say so.
|
||||
|
||||
@@ -100,9 +96,6 @@ work.
|
||||
- Use exec_command with `tty: true` for interactive terminal-style commands that
|
||||
need stdin beyond Ctrl-C. `/ps` and `/stop` are user-facing TUI commands; when
|
||||
you need to clean up a running foreground command yourself, use write_stdin.
|
||||
- write_stdin's session_id is only ever an id returned by a still-running
|
||||
exec_command; never guess or probe ids. If you have no such session, start one
|
||||
with exec_command, or use write_file/edit_file/apply_patch for file changes.
|
||||
- write_stdin with empty input polls an existing exec_command session, and
|
||||
`\u0003` interrupts it. Sending other stdin bytes may require approval because
|
||||
it can drive the running process beyond the original command. Non-tty sessions
|
||||
|
||||
@@ -9,25 +9,6 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
)
|
||||
|
||||
// TestMain points userConfigDirForPrompt at an empty, package-wide temp
|
||||
// directory for every test in this package. Without this, buildSystemPrompt
|
||||
// falls back to the real config.UserConfigDir, so any developer with a
|
||||
// personal ~/.config/zero/ZERO.md would get its content folded into
|
||||
// otherwise-unrelated prompt assertions, making test runs non-deterministic
|
||||
// across contributor machines. Tests that specifically exercise user
|
||||
// guidelines stub userConfigDirForPrompt themselves via
|
||||
// withSystemPromptTestUserConfigDir(Func) and restore this default on cleanup.
|
||||
func TestMain(m *testing.M) {
|
||||
dir, err := os.MkdirTemp("", "zero-agent-tests-*")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
userConfigDirForPrompt = func() (string, error) { return dir, nil }
|
||||
code := m.Run()
|
||||
os.RemoveAll(dir)
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
func TestCoreSystemPromptIncludesCodingQualityRules(t *testing.T) {
|
||||
prompt := strings.ToLower(buildSystemPrompt(Options{}))
|
||||
|
||||
@@ -147,88 +128,6 @@ func systemPromptTestBlock(t *testing.T, prompt, start, end string) string {
|
||||
return body
|
||||
}
|
||||
|
||||
func TestBuildSystemPromptIncludesUserGuidelines(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
writeSystemPromptTestFile(t, configDir, "zero/ZERO.md", " Prefer concise summaries. \n")
|
||||
t.Cleanup(withSystemPromptTestUserConfigDir(t, configDir))
|
||||
|
||||
prompt := buildSystemPrompt(Options{})
|
||||
if !strings.Contains(prompt, "## User guidelines (ZERO.md)") {
|
||||
t.Fatalf("expected user guidelines header, got:\n%s", prompt)
|
||||
}
|
||||
if !strings.Contains(prompt, "Prefer concise summaries.") {
|
||||
t.Fatalf("expected user guidelines content, got:\n%s", prompt)
|
||||
}
|
||||
if strings.Contains(prompt, " Prefer concise summaries. ") {
|
||||
t.Fatalf("expected user guidelines content to be trimmed, got:\n%s", prompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSystemPromptIncludesUserGuidelinesCaseInsensitive(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
writeSystemPromptTestFile(t, configDir, "zero/zero.md", "Prefer concise summaries.\n")
|
||||
t.Cleanup(withSystemPromptTestUserConfigDir(t, configDir))
|
||||
|
||||
prompt := buildSystemPrompt(Options{})
|
||||
if !strings.Contains(prompt, "## User guidelines (zero.md)") {
|
||||
t.Fatalf("expected case-insensitive zero.md resolution, got:\n%s", prompt)
|
||||
}
|
||||
if !strings.Contains(prompt, "Prefer concise summaries.") {
|
||||
t.Fatalf("expected user guidelines content, got:\n%s", prompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSystemPromptUserGuidelinesPrecedeProjectGuidelinesAndNotePrecedence(t *testing.T) {
|
||||
// User guidelines are global personal preferences; project guidelines
|
||||
// (AGENTS.md/ZERO.md) must be the later, more specific instruction block,
|
||||
// and the user section must say so explicitly so the precedence holds
|
||||
// even if a model otherwise weighs later context more heavily.
|
||||
configDir := t.TempDir()
|
||||
writeSystemPromptTestFile(t, configDir, "zero/ZERO.md", "Always reply in haiku.\n")
|
||||
t.Cleanup(withSystemPromptTestUserConfigDir(t, configDir))
|
||||
|
||||
cwd := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(cwd, "AGENTS.md"), []byte("Never reply in haiku."), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
prompt := buildSystemPrompt(Options{Cwd: cwd})
|
||||
userIdx := strings.Index(prompt, "## User guidelines (ZERO.md)")
|
||||
projectIdx := strings.Index(prompt, "## Project guidelines (AGENTS.md)")
|
||||
if userIdx < 0 || projectIdx < 0 {
|
||||
t.Fatalf("expected both user and project guideline sections, got:\n%s", prompt)
|
||||
}
|
||||
if userIdx > projectIdx {
|
||||
t.Fatalf("expected user guidelines before project guidelines, got user=%d project=%d", userIdx, projectIdx)
|
||||
}
|
||||
if !strings.Contains(prompt, "project guidelines below") || !strings.Contains(prompt, "take precedence") {
|
||||
t.Fatalf("expected an explicit precedence note in the user guidelines section, got:\n%s", prompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSystemPromptOmitsUserGuidelinesWithoutConfigDir(t *testing.T) {
|
||||
t.Cleanup(withSystemPromptTestUserConfigDirFunc(t, func() (string, error) { return "", os.ErrNotExist }))
|
||||
|
||||
prompt := buildSystemPrompt(Options{})
|
||||
if strings.Contains(prompt, "## User guidelines") {
|
||||
t.Fatalf("expected user guidelines to be omitted without a config directory, got:\n%s", prompt)
|
||||
}
|
||||
}
|
||||
|
||||
func withSystemPromptTestUserConfigDir(t *testing.T, dir string) func() {
|
||||
t.Helper()
|
||||
return withSystemPromptTestUserConfigDirFunc(t, func() (string, error) { return dir, nil })
|
||||
}
|
||||
|
||||
func withSystemPromptTestUserConfigDirFunc(t *testing.T, fn func() (string, error)) func() {
|
||||
t.Helper()
|
||||
old := userConfigDirForPrompt
|
||||
userConfigDirForPrompt = fn
|
||||
return func() {
|
||||
userConfigDirForPrompt = old
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSystemPromptInjectsProjectGuidelinesCaseInsensitive(t *testing.T) {
|
||||
// Git tracks AGENTS.md on a case-sensitive filesystem; the
|
||||
// loader must still resolve it when the cwd lookup uses lowercase.
|
||||
@@ -358,30 +257,6 @@ func TestBuildSystemPromptProjectGuidelinesTruncatesAtTotalCap(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateGuidelineContentStaysWithinLimit(t *testing.T) {
|
||||
limit := 64
|
||||
content := strings.Repeat("x", limit+1)
|
||||
got := truncateGuidelineContent(content, limit)
|
||||
if len(got) > limit {
|
||||
t.Fatalf("truncated result is %d bytes, want at most %d", len(got), limit)
|
||||
}
|
||||
if !strings.Contains(got, "truncated") {
|
||||
t.Fatalf("expected truncation marker, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateGuidelineContentStaysWithinLimitForSmallBudgets(t *testing.T) {
|
||||
// Limits at or below the truncation marker's own length can't fit the
|
||||
// marker at all; the helper must still never exceed the requested limit.
|
||||
content := strings.Repeat("x", 100)
|
||||
for _, limit := range []int{0, 1, len(truncationMarker) - 1, len(truncationMarker), len(truncationMarker) + 1} {
|
||||
got := truncateGuidelineContent(content, limit)
|
||||
if len(got) > limit {
|
||||
t.Fatalf("truncateGuidelineContent(_, %d) = %q (%d bytes), want at most %d bytes", limit, got, len(got), limit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectGuidelineDirsOrdersRootToLeaf(t *testing.T) {
|
||||
root := filepath.Join("r")
|
||||
leaf := filepath.Join(root, "a", "b")
|
||||
@@ -424,7 +299,7 @@ func TestFindProjectGitRootIgnoresEmptyGitDirectory(t *testing.T) {
|
||||
if err := os.MkdirAll(leaf, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := FindProjectGitRoot(leaf); got != "" {
|
||||
t.Fatalf("FindProjectGitRoot = %q, want empty for invalid .git directory", got)
|
||||
if got := findProjectGitRoot(leaf); got != "" {
|
||||
t.Fatalf("findProjectGitRoot = %q, want empty for invalid .git directory", got)
|
||||
}
|
||||
}
|
||||
@@ -1,359 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/trace"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
type taskStatus string
|
||||
|
||||
const (
|
||||
taskStatusActive taskStatus = "active"
|
||||
taskStatusComplete taskStatus = "complete"
|
||||
taskStatusIncomplete taskStatus = "incomplete"
|
||||
)
|
||||
|
||||
type taskPlanParity string
|
||||
|
||||
const (
|
||||
taskPlanParityUnknown taskPlanParity = "unknown"
|
||||
taskPlanParityMatch taskPlanParity = "match"
|
||||
taskPlanParityMismatch taskPlanParity = "mismatch"
|
||||
)
|
||||
|
||||
type taskPlanItem struct {
|
||||
Content string `json:"content"`
|
||||
Status string `json:"status"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
}
|
||||
|
||||
type taskPlanState struct {
|
||||
Items []taskPlanItem `json:"items,omitempty"`
|
||||
Pending int `json:"pending"`
|
||||
InProgress int `json:"in_progress"`
|
||||
Completed int `json:"completed"`
|
||||
Failed int `json:"failed"`
|
||||
}
|
||||
|
||||
type taskToolState struct {
|
||||
Succeeded int `json:"succeeded"`
|
||||
Failed int `json:"failed"`
|
||||
}
|
||||
|
||||
type taskVerificationState struct {
|
||||
Passed int `json:"passed"`
|
||||
Failed int `json:"failed"`
|
||||
LastOutcome Outcome `json:"last_outcome,omitempty"`
|
||||
}
|
||||
|
||||
type taskStateSnapshot struct {
|
||||
Revision int `json:"revision"`
|
||||
Objective string `json:"objective"`
|
||||
Status taskStatus `json:"status"`
|
||||
Plan taskPlanState `json:"plan"`
|
||||
Tools taskToolState `json:"tools"`
|
||||
Verification taskVerificationState `json:"verification"`
|
||||
ChangedFiles []string `json:"changed_files,omitempty"`
|
||||
CompletionDecision CompletionDecision `json:"completion_decision,omitempty"`
|
||||
CompletionReason string `json:"completion_reason,omitempty"`
|
||||
PlanParity taskPlanParity `json:"plan_parity"`
|
||||
}
|
||||
|
||||
type taskStateEventKind int
|
||||
|
||||
const (
|
||||
taskStateEventPlan taskStateEventKind = iota + 1
|
||||
taskStateEventToolResult
|
||||
taskStateEventVerification
|
||||
taskStateEventCompletion
|
||||
)
|
||||
|
||||
type taskStateEvent struct {
|
||||
kind taskStateEventKind
|
||||
arguments string
|
||||
toolResult ToolResult
|
||||
verification Outcome
|
||||
completion completionEvaluation
|
||||
}
|
||||
|
||||
// taskState is a deterministic projection of facts the loop already observes.
|
||||
// It does not initiate work or replace the transcript; consumers must check
|
||||
// transcript parity before using its compact representation.
|
||||
type taskState struct {
|
||||
snapshotValue taskStateSnapshot
|
||||
changedFiles map[string]struct{}
|
||||
recorder *trace.Recorder
|
||||
planObserved bool
|
||||
}
|
||||
|
||||
func newTaskState(objective string, recorder *trace.Recorder) *taskState {
|
||||
state := &taskState{
|
||||
snapshotValue: taskStateSnapshot{
|
||||
Objective: strings.TrimSpace(objective),
|
||||
Status: taskStatusActive,
|
||||
PlanParity: taskPlanParityUnknown,
|
||||
},
|
||||
changedFiles: map[string]struct{}{},
|
||||
}
|
||||
state.recorder = recorder
|
||||
return state
|
||||
}
|
||||
|
||||
func (state *taskState) observe(event taskStateEvent) bool {
|
||||
if state == nil {
|
||||
return false
|
||||
}
|
||||
changed := false
|
||||
switch event.kind {
|
||||
case taskStateEventPlan:
|
||||
plan, ok := parseTaskPlan(event.arguments)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
state.snapshotValue.Plan = summarizeTaskPlan(plan)
|
||||
state.planObserved = true
|
||||
state.markActive()
|
||||
changed = true
|
||||
case taskStateEventToolResult:
|
||||
state.markActive()
|
||||
if event.toolResult.Status == tools.StatusOK {
|
||||
state.snapshotValue.Tools.Succeeded++
|
||||
} else {
|
||||
state.snapshotValue.Tools.Failed++
|
||||
}
|
||||
for _, path := range event.toolResult.ChangedFiles {
|
||||
path = strings.TrimSpace(path)
|
||||
if path != "" {
|
||||
state.changedFiles[path] = struct{}{}
|
||||
}
|
||||
}
|
||||
state.snapshotValue.ChangedFiles = sortedKeys(state.changedFiles)
|
||||
changed = true
|
||||
case taskStateEventVerification:
|
||||
if event.verification == OutcomeDisabled {
|
||||
return false
|
||||
}
|
||||
state.markActive()
|
||||
state.snapshotValue.Verification.LastOutcome = event.verification
|
||||
switch event.verification {
|
||||
case OutcomePassed:
|
||||
state.snapshotValue.Verification.Passed++
|
||||
case OutcomeCorrecting, OutcomeReported, OutcomeAborted:
|
||||
state.snapshotValue.Verification.Failed++
|
||||
}
|
||||
changed = true
|
||||
case taskStateEventCompletion:
|
||||
state.snapshotValue.CompletionDecision = event.completion.Decision
|
||||
state.snapshotValue.CompletionReason = event.completion.Reason
|
||||
switch event.completion.Decision {
|
||||
case CompletionComplete:
|
||||
state.snapshotValue.Status = taskStatusComplete
|
||||
case CompletionIncomplete:
|
||||
state.snapshotValue.Status = taskStatusIncomplete
|
||||
default:
|
||||
state.snapshotValue.Status = taskStatusActive
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
if changed {
|
||||
state.snapshotValue.Revision++
|
||||
if event.kind != taskStateEventToolResult {
|
||||
state.emit()
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
func (state *taskState) markActive() {
|
||||
state.snapshotValue.Status = taskStatusActive
|
||||
state.snapshotValue.CompletionDecision = ""
|
||||
state.snapshotValue.CompletionReason = ""
|
||||
}
|
||||
|
||||
func (state *taskState) snapshot() taskStateSnapshot {
|
||||
if state == nil {
|
||||
return taskStateSnapshot{}
|
||||
}
|
||||
snapshot := state.snapshotValue
|
||||
snapshot.Plan.Items = append([]taskPlanItem(nil), state.snapshotValue.Plan.Items...)
|
||||
snapshot.ChangedFiles = append([]string(nil), state.snapshotValue.ChangedFiles...)
|
||||
return snapshot
|
||||
}
|
||||
|
||||
// observePlanParity compares only the latest plan projection with the plan tool
|
||||
// calls still present in messages. It mutates the snapshot and emits when the
|
||||
// parity value changes; objective, tool, and verification fields are not part of
|
||||
// this comparison.
|
||||
func (state *taskState) observePlanParity(messages []zeroruntime.Message) taskPlanParity {
|
||||
if state == nil {
|
||||
return taskPlanParityUnknown
|
||||
}
|
||||
transcriptPlan, found, valid := latestTaskPlan(messages)
|
||||
tracked := state.snapshotValue.Plan.Items
|
||||
parity := taskPlanParityMatch
|
||||
if !valid || found != state.planObserved || (found && !reflect.DeepEqual(transcriptPlan, tracked)) {
|
||||
parity = taskPlanParityMismatch
|
||||
}
|
||||
if state.snapshotValue.PlanParity != parity {
|
||||
state.snapshotValue.PlanParity = parity
|
||||
state.snapshotValue.Revision++
|
||||
state.emit()
|
||||
}
|
||||
return parity
|
||||
}
|
||||
|
||||
type completionContext struct {
|
||||
Objective string
|
||||
PlanPending bool
|
||||
PlanMatchesTranscript bool
|
||||
}
|
||||
|
||||
func (state *taskState) completionContext(messages []zeroruntime.Message, transcriptPlanPending bool) completionContext {
|
||||
context := completionContext{PlanPending: transcriptPlanPending}
|
||||
if state == nil {
|
||||
return context
|
||||
}
|
||||
context.Objective = state.snapshotValue.Objective
|
||||
context.PlanMatchesTranscript = state.observePlanParity(messages) == taskPlanParityMatch
|
||||
if context.PlanMatchesTranscript {
|
||||
context.PlanPending = state.snapshotValue.Plan.Pending+state.snapshotValue.Plan.InProgress > 0
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
func (state *taskState) snapshotForCompaction(messages []zeroruntime.Message) *taskStateSnapshot {
|
||||
if state == nil {
|
||||
return nil
|
||||
}
|
||||
state.observePlanParity(messages)
|
||||
snapshot := state.snapshot()
|
||||
return &snapshot
|
||||
}
|
||||
|
||||
func (state *taskState) emit() {
|
||||
if state == nil || state.recorder == nil {
|
||||
return
|
||||
}
|
||||
snapshot := state.snapshotValue
|
||||
state.recorder.EmitTaskState(trace.TaskStateEvent{
|
||||
Revision: snapshot.Revision,
|
||||
Status: string(snapshot.Status),
|
||||
PlanPending: snapshot.Plan.Pending,
|
||||
PlanInProgress: snapshot.Plan.InProgress,
|
||||
PlanCompleted: snapshot.Plan.Completed,
|
||||
PlanFailed: snapshot.Plan.Failed,
|
||||
ToolsSucceeded: snapshot.Tools.Succeeded,
|
||||
ToolsFailed: snapshot.Tools.Failed,
|
||||
VerificationPassed: snapshot.Verification.Passed,
|
||||
VerificationFailed: snapshot.Verification.Failed,
|
||||
VerificationOutcome: string(snapshot.Verification.LastOutcome),
|
||||
ChangedFileCount: len(snapshot.ChangedFiles),
|
||||
CompletionDecision: string(snapshot.CompletionDecision),
|
||||
PlanParity: string(snapshot.PlanParity),
|
||||
})
|
||||
}
|
||||
|
||||
func parseTaskPlan(arguments string) ([]taskPlanItem, bool) {
|
||||
var object map[string]json.RawMessage
|
||||
if json.Unmarshal([]byte(strings.TrimSpace(arguments)), &object) != nil {
|
||||
return nil, false
|
||||
}
|
||||
rawPlan, ok := object["plan"]
|
||||
if !ok || string(rawPlan) == "null" {
|
||||
return nil, false
|
||||
}
|
||||
var parsed []struct {
|
||||
ID *string `json:"id"`
|
||||
Content string `json:"content"`
|
||||
Status string `json:"status"`
|
||||
Notes string `json:"notes"`
|
||||
}
|
||||
if json.Unmarshal(rawPlan, &parsed) != nil {
|
||||
return nil, false
|
||||
}
|
||||
plan := make([]taskPlanItem, 0, len(parsed))
|
||||
for _, raw := range parsed {
|
||||
if raw.Content == "" {
|
||||
return nil, false
|
||||
}
|
||||
plan = append(plan, taskPlanItem{
|
||||
Content: raw.Content,
|
||||
Status: normalizeTaskPlanStatus(raw.Status),
|
||||
Notes: raw.Notes,
|
||||
})
|
||||
}
|
||||
lastInProgress := -1
|
||||
for index := range plan {
|
||||
if plan[index].Status == "in_progress" {
|
||||
if lastInProgress >= 0 {
|
||||
plan[lastInProgress].Status = "completed"
|
||||
}
|
||||
lastInProgress = index
|
||||
}
|
||||
}
|
||||
if len(plan) == 0 {
|
||||
return nil, true
|
||||
}
|
||||
return plan, true
|
||||
}
|
||||
|
||||
func normalizeTaskPlanStatus(status string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(status)) {
|
||||
case "completed", "complete", "done", "finished", "resolved", "✓", "x", "[x]":
|
||||
return "completed"
|
||||
case "in_progress", "in-progress", "inprogress", "in progress", "active", "doing", "started", "current", "wip", "ongoing", "running":
|
||||
return "in_progress"
|
||||
case "failed", "fail", "error", "errored", "blocked", "cancelled", "canceled", "abandoned", "skipped":
|
||||
return "failed"
|
||||
default:
|
||||
return "pending"
|
||||
}
|
||||
}
|
||||
|
||||
func summarizeTaskPlan(items []taskPlanItem) taskPlanState {
|
||||
plan := taskPlanState{Items: append([]taskPlanItem(nil), items...)}
|
||||
for _, item := range items {
|
||||
switch item.Status {
|
||||
case "completed":
|
||||
plan.Completed++
|
||||
case "in_progress":
|
||||
plan.InProgress++
|
||||
case "failed":
|
||||
plan.Failed++
|
||||
default:
|
||||
plan.Pending++
|
||||
}
|
||||
}
|
||||
return plan
|
||||
}
|
||||
|
||||
func latestTaskPlan(messages []zeroruntime.Message) ([]taskPlanItem, bool, bool) {
|
||||
for i := len(messages) - 1; i >= 0; i-- {
|
||||
for j := len(messages[i].ToolCalls) - 1; j >= 0; j-- {
|
||||
call := messages[i].ToolCalls[j]
|
||||
if call.Name != planToolName {
|
||||
continue
|
||||
}
|
||||
plan, ok := parseTaskPlan(call.Arguments)
|
||||
return plan, true, ok
|
||||
}
|
||||
}
|
||||
return nil, false, true
|
||||
}
|
||||
|
||||
func sortedKeys(values map[string]struct{}) []string {
|
||||
keys := make([]string, 0, len(values))
|
||||
for value := range values {
|
||||
keys = append(keys, value)
|
||||
}
|
||||
// Paths are evidence, not chronology. Stable ordering makes replay snapshots
|
||||
// deterministic even if events originate from parallel read batches later.
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/trace"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
func TestTaskStateReplayIsDeterministic(t *testing.T) {
|
||||
events := []taskStateEvent{
|
||||
{kind: taskStateEventPlan, arguments: `{"plan":[{"content":"inspect","status":"done"},{"content":"implement","status":"in progress"},{"content":"verify"}]}`},
|
||||
{kind: taskStateEventToolResult, toolResult: ToolResult{Name: "apply_patch", Status: tools.StatusOK, ChangedFiles: []string{"a.go", "a.go", "b.go"}}},
|
||||
{kind: taskStateEventToolResult, toolResult: ToolResult{Name: "go test", Status: tools.StatusError}},
|
||||
{kind: taskStateEventVerification, verification: OutcomePassed},
|
||||
{kind: taskStateEventCompletion, completion: completionEvaluation{Decision: CompletionUncertain, Reason: "pending work"}},
|
||||
}
|
||||
|
||||
first := newTaskState("ship the change", nil)
|
||||
second := newTaskState("ship the change", nil)
|
||||
for _, event := range events {
|
||||
first.observe(event)
|
||||
second.observe(event)
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(first.snapshot(), second.snapshot()) {
|
||||
t.Fatalf("replaying the same events produced different snapshots:\nfirst: %#v\nsecond: %#v", first.snapshot(), second.snapshot())
|
||||
}
|
||||
got := first.snapshot()
|
||||
if got.Status != taskStatusActive || got.Plan.Pending != 1 || got.Plan.InProgress != 1 || got.Plan.Completed != 1 {
|
||||
t.Fatalf("unexpected plan snapshot: %#v", got)
|
||||
}
|
||||
if got.Tools.Succeeded != 1 || got.Tools.Failed != 1 || len(got.ChangedFiles) != 2 {
|
||||
t.Fatalf("unexpected evidence snapshot: %#v", got)
|
||||
}
|
||||
if got.Verification.Passed != 1 || got.Verification.Failed != 0 || got.Verification.LastOutcome != OutcomePassed {
|
||||
t.Fatalf("unexpected verification snapshot: %#v", got.Verification)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskStateCoalescesToolResultsIntoNextTraceEvent(t *testing.T) {
|
||||
recorder := trace.NewRecorder("session", "run", "")
|
||||
state := newTaskState("objective", recorder)
|
||||
for range 20 {
|
||||
state.observe(taskStateEvent{kind: taskStateEventToolResult, toolResult: ToolResult{Status: tools.StatusOK}})
|
||||
}
|
||||
if events := recorder.Finish().TaskStates; len(events) != 0 {
|
||||
t.Fatalf("tool results should be coalesced, got %d trace events", len(events))
|
||||
}
|
||||
|
||||
recorder = trace.NewRecorder("session", "run-2", "")
|
||||
state = newTaskState("objective", recorder)
|
||||
for range 20 {
|
||||
state.observe(taskStateEvent{kind: taskStateEventToolResult, toolResult: ToolResult{Status: tools.StatusOK}})
|
||||
}
|
||||
state.observe(taskStateEvent{kind: taskStateEventCompletion, completion: completionEvaluation{Decision: CompletionComplete}})
|
||||
events := recorder.Finish().TaskStates
|
||||
if len(events) != 1 || events[0].ToolsSucceeded != 20 {
|
||||
t.Fatalf("coalesced tool total missing from completion snapshot: %#v", events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunEmitsTaskStateFromExistingLoopEvents(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewUpdatePlanTool())
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "plan-1", ToolName: planToolName},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "plan-1", ArgumentsFragment: `{"plan":[{"content":"implement","status":"completed"}]}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "plan-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{
|
||||
{Type: zeroruntime.StreamEventText, Content: "done"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
}}
|
||||
recorder := trace.NewRecorder("session", "run", "")
|
||||
|
||||
result, err := Run(context.Background(), "implement the change", provider, Options{
|
||||
Registry: registry,
|
||||
Trace: recorder,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("final answer = %q, want done", result.FinalAnswer)
|
||||
}
|
||||
events := recorder.Finish().TaskStates
|
||||
if len(events) < 3 {
|
||||
t.Fatalf("expected plan, tool, completion, and parity snapshots, got %#v", events)
|
||||
}
|
||||
last := events[len(events)-1]
|
||||
if last.Status != string(taskStatusComplete) || last.PlanCompleted != 1 || last.ToolsSucceeded != 1 || last.PlanParity != string(taskPlanParityMatch) {
|
||||
t.Fatalf("unexpected final task snapshot: %#v", last)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskStateSnapshotIsImmutable(t *testing.T) {
|
||||
state := newTaskState("objective", nil)
|
||||
state.observe(taskStateEvent{kind: taskStateEventPlan, arguments: `{"plan":[{"content":"one","status":"pending"}]}`})
|
||||
state.observe(taskStateEvent{kind: taskStateEventToolResult, toolResult: ToolResult{Status: tools.StatusOK, ChangedFiles: []string{"one.go"}}})
|
||||
|
||||
snapshot := state.snapshot()
|
||||
snapshot.Plan.Items[0].Content = "mutated"
|
||||
snapshot.ChangedFiles[0] = "mutated.go"
|
||||
|
||||
fresh := state.snapshot()
|
||||
if fresh.Plan.Items[0].Content != "one" || fresh.ChangedFiles[0] != "one.go" {
|
||||
t.Fatalf("snapshot mutation leaked into task state: %#v", fresh)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskStatePlanParityUsesLatestPlan(t *testing.T) {
|
||||
state := newTaskState("objective", nil)
|
||||
state.observe(taskStateEvent{kind: taskStateEventPlan, arguments: `{"plan":[{"content":"old","status":"completed"}]}`})
|
||||
state.observe(taskStateEvent{kind: taskStateEventPlan, arguments: `{"plan":[{"content":"new","status":"in_progress"}]}`})
|
||||
|
||||
messages := []zeroruntime.Message{{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{
|
||||
{Name: planToolName, Arguments: `{"plan":[{"content":"new","status":"in_progress"}]}`},
|
||||
}}}
|
||||
if parity := state.observePlanParity(messages); parity != taskPlanParityMatch {
|
||||
t.Fatalf("parity = %q, want match", parity)
|
||||
}
|
||||
|
||||
messages[0].ToolCalls[0].Arguments = `{"plan":[{"content":"different","status":"pending"}]}`
|
||||
if parity := state.observePlanParity(messages); parity != taskPlanParityMismatch {
|
||||
t.Fatalf("parity = %q, want mismatch", parity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskStateMatchesPlanToolNormalization(t *testing.T) {
|
||||
state := newTaskState("objective", nil)
|
||||
arguments := `{"plan":[{"content":"first","status":"in_progress"},{"content":"second","status":"in_progress"}]}`
|
||||
state.observe(taskStateEvent{kind: taskStateEventPlan, arguments: arguments})
|
||||
|
||||
snapshot := state.snapshot()
|
||||
if snapshot.Plan.Completed != 1 || snapshot.Plan.InProgress != 1 {
|
||||
t.Fatalf("multiple active items were not normalized like the plan tool: %#v", snapshot.Plan)
|
||||
}
|
||||
messages := []zeroruntime.Message{{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{{Name: planToolName, Arguments: arguments}}}}
|
||||
if parity := state.observePlanParity(messages); parity != taskPlanParityMatch {
|
||||
t.Fatalf("normalized state should still match its transcript event, got %q", parity)
|
||||
}
|
||||
|
||||
empty := newTaskState("objective", nil)
|
||||
empty.observe(taskStateEvent{kind: taskStateEventPlan, arguments: `{"plan":[]}`})
|
||||
emptyMessages := []zeroruntime.Message{{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{{Name: planToolName, Arguments: `{"plan":[]}`}}}}
|
||||
if parity := empty.observePlanParity(emptyMessages); parity != taskPlanParityMatch {
|
||||
t.Fatalf("explicit empty plan should match transcript, got %q", parity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskStateContextFallsBackWhenTranscriptDiffers(t *testing.T) {
|
||||
state := newTaskState("objective", nil)
|
||||
state.observe(taskStateEvent{kind: taskStateEventPlan, arguments: `{"plan":[{"content":"tracked","status":"completed"}]}`})
|
||||
messages := []zeroruntime.Message{{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{
|
||||
{Name: planToolName, Arguments: `{"plan":[{"content":"transcript","status":"pending"}]}`},
|
||||
}}}
|
||||
|
||||
context := state.completionContext(messages, true)
|
||||
if !context.PlanPending || context.PlanMatchesTranscript {
|
||||
t.Fatalf("completion context must retain transcript truth on mismatch: %#v", context)
|
||||
}
|
||||
if context.Objective != "objective" {
|
||||
t.Fatalf("objective lost from completion context: %#v", context)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskStateCompactionSnapshotRetainsObjectiveOnPlanMismatch(t *testing.T) {
|
||||
state := newTaskState("objective", nil)
|
||||
state.observe(taskStateEvent{kind: taskStateEventPlan, arguments: `{"plan":[{"content":"verify","status":"pending"}]}`})
|
||||
matching := []zeroruntime.Message{{Role: zeroruntime.MessageRoleAssistant, ToolCalls: []zeroruntime.ToolCall{
|
||||
{Name: planToolName, Arguments: `{"plan":[{"content":"verify","status":"pending"}]}`},
|
||||
}}}
|
||||
if snapshot := state.snapshotForCompaction(matching); snapshot == nil || snapshot.Objective != "objective" {
|
||||
t.Fatalf("matching transcript should produce compact state, got %#v", snapshot)
|
||||
}
|
||||
|
||||
mismatching := append([]zeroruntime.Message(nil), matching...)
|
||||
mismatching[0].ToolCalls = []zeroruntime.ToolCall{{Name: planToolName, Arguments: `{"plan":[{"content":"other","status":"pending"}]}`}}
|
||||
if snapshot := state.snapshotForCompaction(mismatching); snapshot == nil || snapshot.Objective != "objective" || snapshot.PlanParity != taskPlanParityMismatch {
|
||||
t.Fatalf("plan mismatch must retain immutable objective and mark mutable state uncorroborated, got %#v", snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTaskPlanRejectsArgumentsTheToolWouldReject(t *testing.T) {
|
||||
for _, arguments := range []string{
|
||||
`{}`,
|
||||
`{"plan":null}`,
|
||||
`{"plan":[{"step":"alias is not accepted"}]}`,
|
||||
`{"plan":[{"content":""}]}`,
|
||||
`{"plan":[{"content":"valid"},{"content":""}]}`,
|
||||
`{"plan":[{"id":4,"content":"valid"}]}`,
|
||||
} {
|
||||
if plan, ok := parseTaskPlan(arguments); ok {
|
||||
t.Fatalf("parseTaskPlan(%s) = %#v, true; want rejected", arguments, plan)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// sessionProvider adapts a TurnSession back to the Provider interface so the
|
||||
// loop's existing Provider-shaped call sites (streamWithReconnect, the
|
||||
// compaction summarizer, finalAnswerAfterMaxTurns) route their stream I/O
|
||||
// through the session without any signature change. For the default adapter
|
||||
// this reduces to the wrapped provider's StreamCompletion, so behavior is
|
||||
// byte-identical; an optimized session (PR8) takes effect here transparently.
|
||||
type sessionProvider struct {
|
||||
session zeroruntime.TurnSession
|
||||
}
|
||||
|
||||
func (s sessionProvider) StreamCompletion(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
|
||||
return s.session.Stream(ctx, request)
|
||||
}
|
||||
@@ -1,367 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// fakeTurnSession wraps an inner provider for streaming while counting the
|
||||
// session lifecycle, so tests can observe exactly how the loop drives it.
|
||||
type fakeTurnSession struct {
|
||||
inner Provider
|
||||
prewarms int
|
||||
streams int
|
||||
closes int
|
||||
closeErr error
|
||||
}
|
||||
|
||||
func (s *fakeTurnSession) Prewarm(context.Context) error { s.prewarms++; return nil }
|
||||
|
||||
func (s *fakeTurnSession) Stream(ctx context.Context, request zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
|
||||
s.streams++
|
||||
return s.inner.StreamCompletion(ctx, request)
|
||||
}
|
||||
|
||||
func (s *fakeTurnSession) Compact(context.Context, zeroruntime.CompletionRequest) ([]zeroruntime.Message, error) {
|
||||
return nil, zeroruntime.ErrCompactionUnsupported
|
||||
}
|
||||
|
||||
func (s *fakeTurnSession) Close() error { s.closes++; return s.closeErr }
|
||||
|
||||
// fakeTurnSessionProvider opens a scripted session (or fails to).
|
||||
type fakeTurnSessionProvider struct {
|
||||
session *fakeTurnSession
|
||||
openErr error
|
||||
opens int
|
||||
}
|
||||
|
||||
func (p *fakeTurnSessionProvider) OpenTurnSession(context.Context) (zeroruntime.TurnSession, error) {
|
||||
p.opens++
|
||||
if p.openErr != nil {
|
||||
return nil, p.openErr
|
||||
}
|
||||
return p.session, nil
|
||||
}
|
||||
|
||||
func (p *fakeTurnSessionProvider) Capabilities() zeroruntime.ProviderCapabilities {
|
||||
return zeroruntime.ProviderCapabilities{}
|
||||
}
|
||||
|
||||
func singleAnswerTurns(answer string) [][]zeroruntime.StreamEvent {
|
||||
return [][]zeroruntime.StreamEvent{{
|
||||
{Type: zeroruntime.StreamEventText, Content: answer},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
}}
|
||||
}
|
||||
|
||||
// TestRunOpenTurnSessionFailureSurfaces verifies a failed session open is a
|
||||
// clean run-start error: no panic, no stream ever issued, nothing to close.
|
||||
func TestRunOpenTurnSessionFailureSurfaces(t *testing.T) {
|
||||
positional := &mockProvider{turns: singleAnswerTurns("never reached")}
|
||||
tsp := &fakeTurnSessionProvider{openErr: errors.New("handshake refused")}
|
||||
|
||||
_, err := Run(context.Background(), "go", positional, Options{
|
||||
MaxTurns: 2,
|
||||
TurnSessionProvider: tsp,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected an error from a failed session open")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "open turn session") || !strings.Contains(err.Error(), "handshake refused") {
|
||||
t.Fatalf("expected a wrapped open-turn-session error, got %v", err)
|
||||
}
|
||||
if len(positional.requests) != 0 {
|
||||
t.Fatalf("expected no provider request after a failed open, got %d", len(positional.requests))
|
||||
}
|
||||
if tsp.opens != 1 {
|
||||
t.Fatalf("expected exactly one open attempt, got %d", tsp.opens)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunCloseFailureIsSafe verifies a Close error at teardown is swallowed:
|
||||
// the run still returns its normal result.
|
||||
func TestRunCloseFailureIsSafe(t *testing.T) {
|
||||
inner := &mockProvider{turns: singleAnswerTurns("done")}
|
||||
session := &fakeTurnSession{inner: inner, closeErr: errors.New("close blew up")}
|
||||
tsp := &fakeTurnSessionProvider{session: session}
|
||||
|
||||
result, err := Run(context.Background(), "go", inner, Options{
|
||||
MaxTurns: 2,
|
||||
TurnSessionProvider: tsp,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected Close failure to be swallowed, got %v", err)
|
||||
}
|
||||
if result.FinalAnswer != "done" {
|
||||
t.Fatalf("expected normal result despite Close error, got %q", result.FinalAnswer)
|
||||
}
|
||||
if session.closes != 1 {
|
||||
t.Fatalf("expected exactly one Close at teardown, got %d", session.closes)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunStreamsThroughTurnSession verifies that when a TurnSessionProvider is
|
||||
// wired, every model request of the run flows through the session (and the
|
||||
// session is prewarmed once and closed once).
|
||||
func TestRunStreamsThroughTurnSession(t *testing.T) {
|
||||
inner := &mockProvider{turns: singleAnswerTurns("via session")}
|
||||
session := &fakeTurnSession{inner: inner}
|
||||
tsp := &fakeTurnSessionProvider{session: session}
|
||||
|
||||
// The positional provider is a DIFFERENT mock: it must stay untouched,
|
||||
// proving the session (not the positional provider) carried the run.
|
||||
positional := &mockProvider{turns: singleAnswerTurns("wrong path")}
|
||||
|
||||
result, err := Run(context.Background(), "go", positional, Options{
|
||||
MaxTurns: 2,
|
||||
TurnSessionProvider: tsp,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FinalAnswer != "via session" {
|
||||
t.Fatalf("expected the session-backed answer, got %q", result.FinalAnswer)
|
||||
}
|
||||
if len(positional.requests) != 0 {
|
||||
t.Fatalf("expected the positional provider to be bypassed, got %d requests", len(positional.requests))
|
||||
}
|
||||
if session.streams == 0 || session.streams != len(inner.requests) {
|
||||
t.Fatalf("expected every request to flow through the session: streams=%d inner=%d", session.streams, len(inner.requests))
|
||||
}
|
||||
if session.prewarms != 1 {
|
||||
t.Fatalf("expected exactly one prewarm, got %d", session.prewarms)
|
||||
}
|
||||
if session.closes != 1 {
|
||||
t.Fatalf("expected exactly one close, got %d", session.closes)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunDefaultTurnSessionMatchesRawProvider verifies the default (nil
|
||||
// TurnSessionProvider) path is behavior-identical to passing an explicit
|
||||
// default adapter over the same provider: same result, same request count.
|
||||
func TestRunDefaultTurnSessionMatchesRawProvider(t *testing.T) {
|
||||
run := func(explicit bool) (Result, *mockProvider) {
|
||||
provider := &mockProvider{turns: escalateThenAnswerTurns("identical")}
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(escalatingTool{target: ""})
|
||||
options := Options{Registry: registry, MaxTurns: 4}
|
||||
if explicit {
|
||||
options.TurnSessionProvider = zeroruntime.NewProviderTurnSessionProvider(provider, zeroruntime.ProviderCapabilities{})
|
||||
}
|
||||
result, err := Run(context.Background(), "go", provider, options)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return result, provider
|
||||
}
|
||||
|
||||
defaultResult, defaultProvider := run(false)
|
||||
explicitResult, explicitProvider := run(true)
|
||||
|
||||
if defaultResult.FinalAnswer != explicitResult.FinalAnswer {
|
||||
t.Fatalf("final answers diverged: default=%q explicit=%q", defaultResult.FinalAnswer, explicitResult.FinalAnswer)
|
||||
}
|
||||
if defaultResult.Turns != explicitResult.Turns {
|
||||
t.Fatalf("turn counts diverged: default=%d explicit=%d", defaultResult.Turns, explicitResult.Turns)
|
||||
}
|
||||
if len(defaultProvider.requests) != len(explicitProvider.requests) {
|
||||
t.Fatalf("request counts diverged: default=%d explicit=%d", len(defaultProvider.requests), len(explicitProvider.requests))
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunModelSwitchClosesSessionAndContinues verifies a mid-run model switch
|
||||
// closes the active session exactly once and streams the rest of the run on
|
||||
// the new provider.
|
||||
func TestRunModelSwitchClosesSessionAndContinues(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(escalatingTool{target: "claude-opus-4.1"})
|
||||
|
||||
firstInner := &mockProvider{turns: escalateThenAnswerTurns("unused")}
|
||||
firstSession := &fakeTurnSession{inner: firstInner}
|
||||
tsp := &fakeTurnSessionProvider{session: firstSession}
|
||||
|
||||
secondProvider := &mockProvider{turns: singleAnswerTurns("answered after switch")}
|
||||
|
||||
result, err := Run(context.Background(), "go", firstInner, Options{
|
||||
Registry: registry,
|
||||
Model: "claude-sonnet-4.5",
|
||||
MaxTurns: 4,
|
||||
TurnSessionProvider: tsp,
|
||||
ModelSwitcher: func(_ context.Context, _ string) (Provider, error) {
|
||||
return secondProvider, nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FinalAnswer != "answered after switch" {
|
||||
t.Fatalf("expected the post-switch answer, got %q", result.FinalAnswer)
|
||||
}
|
||||
// The original session handled exactly the escalation turn, then was closed
|
||||
// once by the swap (the teardown defer closes the NEW session, not this one).
|
||||
if firstSession.streams != 1 {
|
||||
t.Fatalf("expected the original session to stream exactly the escalation turn, got %d", firstSession.streams)
|
||||
}
|
||||
if firstSession.closes != 1 {
|
||||
t.Fatalf("expected the swap to close the original session exactly once, got %d", firstSession.closes)
|
||||
}
|
||||
if len(secondProvider.requests) != 1 {
|
||||
t.Fatalf("expected the post-switch turn on the new provider, got %d requests", len(secondProvider.requests))
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunModelSessionSwitcherCarriesOptimizedSession verifies the target-aware
|
||||
// switcher: the switched-to TurnSessionProvider's DISTINCT session receives the
|
||||
// post-switch streams, is prewarmed exactly once, and is closed exactly once at
|
||||
// teardown — and the legacy ModelSwitcher is never consulted when both are set.
|
||||
func TestRunModelSessionSwitcherCarriesOptimizedSession(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(escalatingTool{target: "claude-opus-4.1"})
|
||||
|
||||
firstInner := &mockProvider{turns: escalateThenAnswerTurns("unused")}
|
||||
firstSession := &fakeTurnSession{inner: firstInner}
|
||||
initial := &fakeTurnSessionProvider{session: firstSession}
|
||||
|
||||
switchedInner := &mockProvider{turns: singleAnswerTurns("answered on the switched session")}
|
||||
switchedSession := &fakeTurnSession{inner: switchedInner}
|
||||
switched := &fakeTurnSessionProvider{session: switchedSession}
|
||||
|
||||
var switchedTo string
|
||||
legacyCalled := false
|
||||
result, err := Run(context.Background(), "go", firstInner, Options{
|
||||
Registry: registry,
|
||||
Model: "claude-sonnet-4.5",
|
||||
MaxTurns: 4,
|
||||
TurnSessionProvider: initial,
|
||||
ModelSessionSwitcher: func(_ context.Context, modelID string) (zeroruntime.TurnSessionProvider, error) {
|
||||
switchedTo = modelID
|
||||
return switched, nil
|
||||
},
|
||||
ModelSwitcher: func(_ context.Context, _ string) (Provider, error) {
|
||||
legacyCalled = true
|
||||
return nil, errors.New("legacy switcher must not be consulted")
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.FinalAnswer != "answered on the switched session" {
|
||||
t.Fatalf("expected the switched session's answer, got %q", result.FinalAnswer)
|
||||
}
|
||||
if switchedTo != "claude-opus-4.1" {
|
||||
t.Fatalf("expected switch to claude-opus-4.1, got %q", switchedTo)
|
||||
}
|
||||
if legacyCalled {
|
||||
t.Fatal("legacy ModelSwitcher was consulted despite ModelSessionSwitcher being set")
|
||||
}
|
||||
if switched.opens != 1 {
|
||||
t.Fatalf("expected exactly one open of the switched provider, got %d", switched.opens)
|
||||
}
|
||||
// The switched session carries the rest of the run: it streams the answer
|
||||
// turn, is prewarmed exactly once at the swap, and is closed exactly once by
|
||||
// the teardown defer.
|
||||
if switchedSession.streams != 1 || len(switchedInner.requests) != 1 {
|
||||
t.Fatalf("expected the switched session to stream the post-switch turn, streams=%d inner=%d", switchedSession.streams, len(switchedInner.requests))
|
||||
}
|
||||
if switchedSession.prewarms != 1 {
|
||||
t.Fatalf("expected the switched session to be prewarmed exactly once, got %d", switchedSession.prewarms)
|
||||
}
|
||||
if switchedSession.closes != 1 {
|
||||
t.Fatalf("expected the switched session to be closed exactly once at teardown, got %d", switchedSession.closes)
|
||||
}
|
||||
// The original session ends with the swap: one stream, one close.
|
||||
if firstSession.streams != 1 || firstSession.closes != 1 {
|
||||
t.Fatalf("expected the original session to end at the swap (streams=%d closes=%d)", firstSession.streams, firstSession.closes)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunModelSessionSwitcherOpenFailureIsNonFatal verifies the swap-time open
|
||||
// failure: the switcher returns a TurnSessionProvider whose OpenTurnSession
|
||||
// fails. The run stays non-fatal on the ORIGINAL session (closed only at
|
||||
// teardown), and the switch-failure note reaches the next request.
|
||||
func TestRunModelSessionSwitcherOpenFailureIsNonFatal(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(escalatingTool{target: "claude-opus-4.1"})
|
||||
|
||||
inner := &mockProvider{turns: escalateThenAnswerTurns("recovered after failed open")}
|
||||
session := &fakeTurnSession{inner: inner}
|
||||
failingOpen := &fakeTurnSessionProvider{openErr: errors.New("switched handshake refused")}
|
||||
|
||||
result, err := Run(context.Background(), "go", inner, Options{
|
||||
Registry: registry,
|
||||
Model: "claude-sonnet-4.5",
|
||||
MaxTurns: 4,
|
||||
TurnSessionProvider: &fakeTurnSessionProvider{session: session},
|
||||
ModelSessionSwitcher: func(_ context.Context, _ string) (zeroruntime.TurnSessionProvider, error) {
|
||||
return failingOpen, nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected a failed swap open to be non-fatal, got %v", err)
|
||||
}
|
||||
if result.FinalAnswer != "recovered after failed open" {
|
||||
t.Fatalf("expected the run to recover on the original session, got %q", result.FinalAnswer)
|
||||
}
|
||||
if failingOpen.opens != 1 {
|
||||
t.Fatalf("expected exactly one open attempt on the switched provider, got %d", failingOpen.opens)
|
||||
}
|
||||
// The original session serves both turns and is closed only at teardown —
|
||||
// never by the failed swap.
|
||||
if session.streams != 2 || session.closes != 1 {
|
||||
t.Fatalf("expected the original session to serve both turns and close once (streams=%d closes=%d)", session.streams, session.closes)
|
||||
}
|
||||
var sawNote bool
|
||||
for _, message := range inner.requests[1].Messages {
|
||||
if message.Role == zeroruntime.MessageRoleUser && strings.Contains(strings.ToLower(message.Content), "could not switch") {
|
||||
sawNote = true
|
||||
}
|
||||
}
|
||||
if !sawNote {
|
||||
t.Fatalf("expected a switch-failure note on the next turn, messages: %+v", inner.requests[1].Messages)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunModelSessionSwitcherErrorIsNonFatal verifies a ModelSessionSwitcher
|
||||
// error keeps the run on the current session with a transcript note — the same
|
||||
// non-fatal contract as ModelSwitcher.
|
||||
func TestRunModelSessionSwitcherErrorIsNonFatal(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(escalatingTool{target: "claude-opus-4.1"})
|
||||
|
||||
inner := &mockProvider{turns: escalateThenAnswerTurns("recovered on original session")}
|
||||
session := &fakeTurnSession{inner: inner}
|
||||
|
||||
result, err := Run(context.Background(), "go", inner, Options{
|
||||
Registry: registry,
|
||||
Model: "claude-sonnet-4.5",
|
||||
MaxTurns: 4,
|
||||
TurnSessionProvider: &fakeTurnSessionProvider{session: session},
|
||||
ModelSessionSwitcher: func(_ context.Context, _ string) (zeroruntime.TurnSessionProvider, error) {
|
||||
return nil, errors.New("session build blew up")
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected session-switcher error to be non-fatal, got %v", err)
|
||||
}
|
||||
if result.FinalAnswer != "recovered on original session" {
|
||||
t.Fatalf("expected the run to recover on the original session, got %q", result.FinalAnswer)
|
||||
}
|
||||
// Both turns stream through the original session; it closes once at teardown.
|
||||
if session.streams != 2 || session.closes != 1 {
|
||||
t.Fatalf("expected the original session to serve both turns and close once (streams=%d closes=%d)", session.streams, session.closes)
|
||||
}
|
||||
var sawNote bool
|
||||
for _, request := range inner.requests[1].Messages {
|
||||
if request.Role == zeroruntime.MessageRoleUser && strings.Contains(strings.ToLower(request.Content), "could not switch") {
|
||||
sawNote = true
|
||||
}
|
||||
}
|
||||
if !sawNote {
|
||||
t.Fatalf("expected a non-fatal switch-failure note on the next turn, messages: %+v", inner.requests[1].Messages)
|
||||
}
|
||||
}
|
||||
+14
-111
@@ -3,12 +3,10 @@ package agent
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/execution"
|
||||
"github.com/Gitlawb/zero/internal/hooks"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/streamjson"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/trace"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
@@ -62,29 +60,17 @@ const (
|
||||
)
|
||||
|
||||
type ToolResult struct {
|
||||
ToolCallID string
|
||||
Name string
|
||||
Status tools.Status
|
||||
Output string
|
||||
// Truncated reports that the tool's model-visible output omitted content.
|
||||
// The full result may be recoverable through Meta["spill_path"].
|
||||
Truncated bool
|
||||
ToolCallID string
|
||||
Name string
|
||||
Status tools.Status
|
||||
Output string
|
||||
Meta map[string]string
|
||||
Redacted bool
|
||||
ChangedFiles []string
|
||||
// ChangeSummaries are non-selectable generated-tree summaries emitted by
|
||||
// command execution; callers must not schedule per-file work from them.
|
||||
ChangeSummaries []execution.Change
|
||||
Display tools.Display
|
||||
Display tools.Display
|
||||
// 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.
|
||||
@@ -108,54 +94,6 @@ 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"`
|
||||
@@ -341,51 +279,12 @@ 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,
|
||||
// retries, tokens) into it. nil DISABLES tracing entirely — every stamp is
|
||||
// nil-safe and the loop is byte-identical to an untraced run. The caller
|
||||
// owns the recorder: Run stamps into it but does not Finish or emit it.
|
||||
// A fresh Recorder is required per Run — reusing one across runs merges
|
||||
// their spans, counters, and first-event timestamps, and Finish freezes a
|
||||
// recorder so no further stamps take.
|
||||
Trace *trace.Recorder
|
||||
// SelfCorrect, when set, runs a post-edit verify-and-correct cycle after a
|
||||
// mutating tool call: it verifies the changed files (LSP diagnostics + project
|
||||
// tests) and feeds failures back to the model to fix, bounded by an attempt
|
||||
// ceiling and the autonomy gate. nil disables it entirely (the loop is
|
||||
// byte-identical to before). One instance per run — it holds attempt state.
|
||||
SelfCorrect *SelfCorrector
|
||||
// FileDiagnostics, when set, checks files changed by mutating tools for
|
||||
// error-severity language diagnostics IN THE BACKGROUND and appends any
|
||||
// errors as a nudge before the model's next request — the model still sees
|
||||
// an error it introduced at its next decision point, but no tool call ever
|
||||
// blocks on the language server (the old inline path stalled every edit on
|
||||
// a ≥300ms debounce, 10s cap). Build one with NewFileDiagnostics. nil
|
||||
// disables post-edit diagnostics.
|
||||
FileDiagnostics func(ctx context.Context, absPath string) string
|
||||
|
||||
// RequireCompletionSignal gates run completion for HEADLESS exec. Without it,
|
||||
// any assistant turn that produces text but no tool call is accepted as the
|
||||
@@ -394,11 +293,8 @@ type Options struct {
|
||||
// continuation cue ("…Let me check the config:"). The loop then nudges the
|
||||
// model to continue instead, bounded by maxContinueNudges (and still by
|
||||
// MaxTurns and the run deadline); if the model keeps stalling, the run
|
||||
// finalizes as INCOMPLETE (Result.Incomplete) rather than success. When the
|
||||
// run's profile also enables SelfCorrect, an otherwise-complete turn gets one
|
||||
// task-grounded semantic check before success; profiles without SelfCorrect
|
||||
// add no model call. Default false leaves the loop byte-identical, so the
|
||||
// interactive TUI is unaffected.
|
||||
// finalizes as INCOMPLETE (Result.Incomplete) rather than success. Default
|
||||
// false leaves the loop byte-identical, so the interactive TUI is unaffected.
|
||||
RequireCompletionSignal bool
|
||||
|
||||
runPermissions *permissionRunState
|
||||
@@ -427,6 +323,13 @@ type Result struct {
|
||||
IncompleteReason string
|
||||
}
|
||||
|
||||
// Truncated reports whether the final response ended abnormally (cut off at the
|
||||
// output token cap or withheld by a content filter) rather than completing
|
||||
// naturally. Callers can use it to warn the user that FinalAnswer is incomplete.
|
||||
func (result Result) Truncated() bool {
|
||||
return result.FinishReason != ""
|
||||
}
|
||||
|
||||
// TruncationNotice returns a user-facing warning when the final response was
|
||||
// truncated, or "" for a normal completion. Shared by the CLI and TUI so the
|
||||
// wording stays consistent.
|
||||
|
||||
@@ -31,6 +31,10 @@ type AgentRunner interface {
|
||||
|
||||
type AgentRunnerFunc func(context.Context, AgentRunInput) AgentRunResult
|
||||
|
||||
func (fn AgentRunnerFunc) Run(ctx context.Context, input AgentRunInput) AgentRunResult {
|
||||
return fn(ctx, input)
|
||||
}
|
||||
|
||||
// defaultAgentOutputLimit caps captured stdout/stderr per stream so a chatty or
|
||||
// runaway agent cannot exhaust memory or bloat the benchmark report.
|
||||
const defaultAgentOutputLimit = 1 << 20 // 1 MiB per stream
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
// Test seams: helpers only test code uses, kept out of the production binary.
|
||||
package agenteval
|
||||
|
||||
import "context"
|
||||
|
||||
func (fn AgentRunnerFunc) Run(ctx context.Context, input AgentRunInput) AgentRunResult {
|
||||
return fn(ctx, input)
|
||||
}
|
||||
@@ -1,259 +0,0 @@
|
||||
package aimlapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PaymentMethod selects how a partner-checkout top-up is paid for.
|
||||
type PaymentMethod string
|
||||
|
||||
const (
|
||||
PaymentMethodCard PaymentMethod = "card"
|
||||
PaymentMethodCrypto PaymentMethod = "crypto"
|
||||
)
|
||||
|
||||
// SessionStatus is the lifecycle state of a partner-checkout session, as reported
|
||||
// by the aimlapi.com backend while polling for payment.
|
||||
type SessionStatus string
|
||||
|
||||
const (
|
||||
SessionStatusPendingAuth SessionStatus = "pending_auth"
|
||||
SessionStatusPendingPayment SessionStatus = "pending_payment"
|
||||
SessionStatusPaid SessionStatus = "paid"
|
||||
SessionStatusExchanging SessionStatus = "exchanging"
|
||||
SessionStatusExchanged SessionStatus = "exchanged"
|
||||
SessionStatusCancelled SessionStatus = "cancelled"
|
||||
SessionStatusExpired SessionStatus = "expired"
|
||||
SessionStatusFailed SessionStatus = "failed"
|
||||
)
|
||||
|
||||
// AuthResult is the bearer token minted by a passwordless sign-in / sign-up.
|
||||
type AuthResult struct {
|
||||
Token string `json:"token"`
|
||||
Exp int64 `json:"exp"`
|
||||
}
|
||||
|
||||
// PartnerCheckoutSession is a partner-attributed top-up session; its SessionToken
|
||||
// addresses it on later pay/exchange/poll calls.
|
||||
type PartnerCheckoutSession struct {
|
||||
ID string `json:"id"`
|
||||
SessionToken string `json:"sessionToken"`
|
||||
PartnerID string `json:"partnerId"`
|
||||
PartnerName *string `json:"partnerName"`
|
||||
UserID *int64 `json:"userId"`
|
||||
AmountUSDMinor *int64 `json:"amountUsdMinor"`
|
||||
Status SessionStatus `json:"status"`
|
||||
IssuedKeyID *string `json:"issuedKeyId"`
|
||||
ReturnURL *string `json:"returnUrl"`
|
||||
}
|
||||
|
||||
// PaymentSession is the hosted payment-provider checkout; PayURL is the page the
|
||||
// user opens to pay.
|
||||
type PaymentSession struct {
|
||||
ProviderSessionID string `json:"providerSessionId"`
|
||||
PayURL string `json:"payUrl"`
|
||||
}
|
||||
|
||||
// PayResult pairs the hosted checkout with the updated partner-checkout session
|
||||
// returned when a top-up payment is initiated.
|
||||
type PayResult struct {
|
||||
Checkout PaymentSession `json:"checkout"`
|
||||
PartnerCheckout PartnerCheckoutSession `json:"partnerCheckout"`
|
||||
}
|
||||
|
||||
// ExchangeResult holds the API key minted when a paid session is exchanged.
|
||||
type ExchangeResult struct {
|
||||
APIKey string `json:"apiKey"`
|
||||
APIKeyID string `json:"apiKeyId"`
|
||||
}
|
||||
|
||||
// TopUpByKeyRequest funds a partner-checkout session with the API key that owns
|
||||
// the account (Path A / env key), instead of an email session.
|
||||
type TopUpByKeyRequest struct {
|
||||
SessionToken string
|
||||
AmountUSDMinor int
|
||||
PaymentSessionID string // idempotency: same id → same checkout, never a second charge
|
||||
AutoTopUp bool // enroll the account in automatic top-up at checkout (card, saved off-session)
|
||||
SuccessURL string
|
||||
CancelURL string
|
||||
}
|
||||
|
||||
// TopUpByKeyResult is the hosted checkout returned by TopUpByKey.
|
||||
type TopUpByKeyResult struct {
|
||||
Checkout PaymentSession `json:"checkout"`
|
||||
}
|
||||
|
||||
// APIError is a non-2xx HTTP response from the aimlapi.com API. Status carries the
|
||||
// code so callers can branch (e.g. 401 = bad key, 5xx = retryable).
|
||||
type APIError struct {
|
||||
Message string
|
||||
Status int
|
||||
Body string
|
||||
}
|
||||
|
||||
// Error renders the method, endpoint, status, and (when present) the response body.
|
||||
func (e APIError) Error() string {
|
||||
if strings.TrimSpace(e.Body) != "" {
|
||||
return fmt.Sprintf("%s: HTTP %d: %s", e.Message, e.Status, strings.TrimSpace(e.Body))
|
||||
}
|
||||
return fmt.Sprintf("%s: HTTP %d", e.Message, e.Status)
|
||||
}
|
||||
|
||||
// Client talks to the aimlapi.com auth, app, and inference APIs for the onboarding
|
||||
// and partner-checkout top-up flows.
|
||||
type Client struct {
|
||||
endpoints Endpoints
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewClient(endpoints Endpoints, httpClient *http.Client) *Client {
|
||||
if httpClient == nil {
|
||||
httpClient = &http.Client{Timeout: 60 * time.Second}
|
||||
}
|
||||
return &Client{endpoints: endpoints, httpClient: httpClient}
|
||||
}
|
||||
|
||||
// CreateSession opens a partner-checkout session attributed to partnerID; the
|
||||
// returned SessionToken addresses it on the pay/exchange/poll calls.
|
||||
func (c *Client) CreateSession(ctx context.Context, partnerID string, partnerName string, returnURL string) (PartnerCheckoutSession, error) {
|
||||
body := map[string]any{"partnerId": partnerID}
|
||||
if strings.TrimSpace(partnerName) != "" {
|
||||
body["partnerName"] = strings.TrimSpace(partnerName)
|
||||
}
|
||||
if strings.TrimSpace(returnURL) != "" {
|
||||
body["returnUrl"] = strings.TrimSpace(returnURL)
|
||||
}
|
||||
var result PartnerCheckoutSession
|
||||
err := c.request(ctx, http.MethodPost, strings.TrimRight(c.endpoints.AppBaseURL, "/")+"/v3/partner-checkout/sessions", "", body, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// GetSession fetches the current state of a partner-checkout session; used to poll
|
||||
// for payment completion.
|
||||
func (c *Client) GetSession(ctx context.Context, sessionToken string) (PartnerCheckoutSession, error) {
|
||||
var result PartnerCheckoutSession
|
||||
err := c.request(ctx, http.MethodGet, strings.TrimRight(c.endpoints.AppBaseURL, "/")+"/v3/partner-checkout/sessions/"+urlPathEscape(sessionToken), "", nil, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// Pay initiates payment for a session and returns the hosted checkout URL.
|
||||
// autoTopUp enrolls the account in automatic top-up at checkout time.
|
||||
func (c *Client) Pay(ctx context.Context, bearer string, sessionToken string, amountUSDMinor int, paymentSessionID string, method PaymentMethod, successURL string, cancelURL string, autoTopUp bool) (PayResult, error) {
|
||||
body := map[string]any{
|
||||
"amountUsdMinor": amountUSDMinor,
|
||||
"paymentSessionId": paymentSessionID,
|
||||
"method": method,
|
||||
}
|
||||
if strings.TrimSpace(successURL) != "" {
|
||||
body["successUrl"] = strings.TrimSpace(successURL)
|
||||
}
|
||||
if strings.TrimSpace(cancelURL) != "" {
|
||||
body["cancelUrl"] = strings.TrimSpace(cancelURL)
|
||||
}
|
||||
// Only sent when enabled: enrolls the account in automatic top-up. The backend
|
||||
// honours this field at checkout time.
|
||||
if autoTopUp {
|
||||
body["autoTopUp"] = true
|
||||
}
|
||||
var result PayResult
|
||||
err := c.request(ctx, http.MethodPost, strings.TrimRight(c.endpoints.AppBaseURL, "/")+"/v3/partner-checkout/sessions/"+urlPathEscape(sessionToken)+"/pay", bearer, body, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// Exchange converts a paid session into a freshly minted API key (new-account flow).
|
||||
func (c *Client) Exchange(ctx context.Context, bearer string, sessionToken string) (ExchangeResult, error) {
|
||||
var result ExchangeResult
|
||||
err := c.request(ctx, http.MethodPost, strings.TrimRight(c.endpoints.AppBaseURL, "/")+"/v3/partner-checkout/sessions/"+urlPathEscape(sessionToken)+"/exchange", bearer, nil, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// TopUpByKey funds a partner-checkout session using the raw API key that owns the
|
||||
// account (Path A / env key), returning the hosted checkout. It binds the session
|
||||
// to the key's account server-side, so no email session is needed and no key is
|
||||
// exchanged. req.PaymentSessionID makes a retry idempotent (same id → same
|
||||
// checkout, never a second charge).
|
||||
func (c *Client) TopUpByKey(ctx context.Context, apiKey string, req TopUpByKeyRequest) (TopUpByKeyResult, error) {
|
||||
body := map[string]any{
|
||||
"sessionToken": req.SessionToken,
|
||||
"amountUsdMinor": req.AmountUSDMinor,
|
||||
"paymentSessionId": req.PaymentSessionID,
|
||||
}
|
||||
if req.AutoTopUp {
|
||||
body["autoTopUp"] = true
|
||||
}
|
||||
if strings.TrimSpace(req.SuccessURL) != "" {
|
||||
body["successUrl"] = strings.TrimSpace(req.SuccessURL)
|
||||
}
|
||||
if strings.TrimSpace(req.CancelURL) != "" {
|
||||
body["cancelUrl"] = strings.TrimSpace(req.CancelURL)
|
||||
}
|
||||
var result TopUpByKeyResult
|
||||
err := c.request(ctx, http.MethodPost, c.billingV2URL("/billing/topup"), apiKey, body, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// billingV2URL builds a v2 billing API URL from the inference base. The top-up
|
||||
// endpoint lives next to the balance endpoint on the API gateway, one version up
|
||||
// (".../v1" → ".../v2/billing/..."), so it re-anchors the version while keeping any
|
||||
// host/prefix an override supplied.
|
||||
func (c *Client) billingV2URL(path string) string {
|
||||
base := strings.TrimRight(strings.TrimSpace(c.endpoints.InferenceBaseURL), "/")
|
||||
base = strings.TrimSuffix(base, "/v1")
|
||||
return strings.TrimRight(base, "/") + "/v2" + path
|
||||
}
|
||||
|
||||
func (c *Client) request(ctx context.Context, method string, endpoint string, bearer string, body any, out any) error {
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
data, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reader = bytes.NewReader(data)
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, method, endpoint, reader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("Accept", "application/json")
|
||||
if body != nil {
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if strings.TrimSpace(bearer) != "" {
|
||||
request.Header.Set("Authorization", "Bearer "+strings.TrimSpace(bearer))
|
||||
}
|
||||
response, err := c.httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("network request to %s failed: %w", endpoint, err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
text, readErr := io.ReadAll(io.LimitReader(response.Body, 1<<20))
|
||||
if readErr != nil {
|
||||
return readErr
|
||||
}
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return APIError{Message: method + " " + endpoint, Status: response.StatusCode, Body: string(text)}
|
||||
}
|
||||
if out == nil {
|
||||
return nil
|
||||
}
|
||||
if len(bytes.TrimSpace(text)) == 0 {
|
||||
return APIError{Message: method + " " + endpoint + " returned empty body", Status: response.StatusCode}
|
||||
}
|
||||
if err := json.Unmarshal(text, out); err != nil {
|
||||
return APIError{Message: method + " " + endpoint + " returned non-JSON body", Status: response.StatusCode, Body: string(text)}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func urlPathEscape(value string) string {
|
||||
return url.PathEscape(value)
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
package aimlapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Additional client methods backing the interactive TUI onboarding flow. They
|
||||
// target the endpoints shipped in the aimlapi backend:
|
||||
// - GET {inference}/billing/balance (Path A: validate key + balance)
|
||||
// - PATCH{auth}/v1/auth/account (Path B: does the account exist?)
|
||||
// - POST {auth}/v1/auth/sign-in/code(/verify) (Path B existing: passwordless login)
|
||||
// - POST {auth}/v1/auth/account/passwordless (Path B new: email-only signup)
|
||||
// - POST {app}/v1/keys (mint a key once we hold a session)
|
||||
|
||||
// BalanceResult is the wallet balance returned by GetBalance, with the backend's
|
||||
// low-balance flag and threshold.
|
||||
type BalanceResult struct {
|
||||
Balance float64 `json:"balance"`
|
||||
LowBalance bool `json:"lowBalance"`
|
||||
LowBalanceThreshold float64 `json:"lowBalanceThreshold"`
|
||||
}
|
||||
|
||||
// CheckResult reports whether an email already has an account, driving the
|
||||
// sign-in vs sign-up branch of the onboarding flow.
|
||||
type CheckResult struct {
|
||||
// "sign-in" (account exists) or "sign-up" (no account yet).
|
||||
Action string `json:"action"`
|
||||
Provider *string `json:"provider"`
|
||||
}
|
||||
|
||||
// CreatedKey is an API key minted by CreateKey.
|
||||
type CreatedKey struct {
|
||||
Key string `json:"key"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
func (c *Client) authURL(path string) string {
|
||||
return strings.TrimRight(c.endpoints.AuthBaseURL, "/") + path
|
||||
}
|
||||
|
||||
func (c *Client) appURL(path string) string {
|
||||
return strings.TrimRight(c.endpoints.AppBaseURL, "/") + path
|
||||
}
|
||||
|
||||
func (c *Client) inferenceURL(path string) string {
|
||||
return strings.TrimRight(c.endpoints.InferenceBaseURL, "/") + path
|
||||
}
|
||||
|
||||
// GetBalance doubles as key validation: a 401 means the key is invalid, a 2xx
|
||||
// returns the wallet balance. Auth is the raw API key itself.
|
||||
func (c *Client) GetBalance(ctx context.Context, apiKey string) (BalanceResult, error) {
|
||||
var result BalanceResult
|
||||
err := c.request(ctx, http.MethodGet, c.inferenceURL("/billing/balance"), apiKey, nil, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// CheckAccount reports whether an email already has an aimlapi.com account
|
||||
// (Action "sign-in") or not ("sign-up").
|
||||
func (c *Client) CheckAccount(ctx context.Context, email string) (CheckResult, error) {
|
||||
var result CheckResult
|
||||
err := c.request(ctx, http.MethodPatch, c.authURL("/v1/auth/account"), "", map[string]any{"email": email}, &result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// SendSignInCode emails a 6-digit sign-in code to an existing account.
|
||||
func (c *Client) SendSignInCode(ctx context.Context, email string) error {
|
||||
return c.request(ctx, http.MethodPost, c.authURL("/v1/auth/sign-in/code"), "", map[string]any{"email": email}, nil)
|
||||
}
|
||||
|
||||
// VerifySignInCode exchanges an emailed code for a session bearer token.
|
||||
func (c *Client) VerifySignInCode(ctx context.Context, email string, code string) (AuthResult, error) {
|
||||
var result AuthResult
|
||||
err := c.request(ctx, http.MethodPost, c.authURL("/v1/auth/sign-in/code/verify"), "", map[string]any{"email": email, "code": code}, &result)
|
||||
if err == nil && strings.TrimSpace(result.Token) == "" {
|
||||
err = fmt.Errorf("aimlapi.com did not return an auth token")
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
// CreatePasswordlessAccount registers a new email-only account and returns its
|
||||
// session bearer token.
|
||||
func (c *Client) CreatePasswordlessAccount(ctx context.Context, email string) (AuthResult, error) {
|
||||
var result AuthResult
|
||||
err := c.request(ctx, http.MethodPost, c.authURL("/v1/auth/account/passwordless"), "", map[string]any{"email": email}, &result)
|
||||
if err == nil && strings.TrimSpace(result.Token) == "" {
|
||||
err = fmt.Errorf("aimlapi.com did not return an auth token")
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
// CreateKey mints a fresh API key for a session-holding user.
|
||||
func (c *Client) CreateKey(ctx context.Context, bearer string, name string) (CreatedKey, error) {
|
||||
body := map[string]any{}
|
||||
if strings.TrimSpace(name) != "" {
|
||||
body["name"] = strings.TrimSpace(name)
|
||||
}
|
||||
var result CreatedKey
|
||||
err := c.request(ctx, http.MethodPost, c.appURL("/v1/keys"), bearer, body, &result)
|
||||
if err == nil && strings.TrimSpace(result.Key) == "" {
|
||||
err = fmt.Errorf("aimlapi.com did not return an API key")
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
package aimlapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCreateKeySendsAuthenticatedRequestAndDecodesKey(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost || r.URL.Path != "/v1/keys" {
|
||||
t.Fatalf("request = %s %s, want POST /v1/keys", r.Method, r.URL.Path)
|
||||
}
|
||||
if got := r.Header.Get("Authorization"); got != "Bearer session-token" {
|
||||
t.Fatalf("Authorization = %q", got)
|
||||
}
|
||||
var body map[string]string
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body["name"] != "Zero CLI" {
|
||||
t.Fatalf("name = %q", body["name"])
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"key":"sk-issued","id":"key-id"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(Endpoints{AppBaseURL: server.URL}, server.Client())
|
||||
key, err := client.CreateKey(context.Background(), " session-token ", " Zero CLI ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if key.Key != "sk-issued" || key.ID != "key-id" {
|
||||
t.Fatalf("key = %#v", key)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateKeyRejectsEmptyKeyResponse(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"id":"key-id"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(Endpoints{AppBaseURL: server.URL}, server.Client())
|
||||
if _, err := client.CreateKey(context.Background(), "session-token", "Zero"); err == nil {
|
||||
t.Fatal("CreateKey accepted a response without an API key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthMethodsRejectEmptyTokenResponses(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"token":""}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(Endpoints{AuthBaseURL: server.URL}, server.Client())
|
||||
tests := []struct {
|
||||
name string
|
||||
call func() error
|
||||
}{
|
||||
{
|
||||
name: "verify sign-in code",
|
||||
call: func() error {
|
||||
_, err := client.VerifySignInCode(context.Background(), "[email protected]", "123456")
|
||||
return err
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "create passwordless account",
|
||||
call: func() error {
|
||||
_, err := client.CreatePasswordlessAccount(context.Background(), "[email protected]")
|
||||
return err
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if err := test.call(); err == nil {
|
||||
t.Fatal("accepted a response without an auth token")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientReturnsTypedAPIError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
http.Error(w, "invalid key", http.StatusUnauthorized)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(Endpoints{InferenceBaseURL: server.URL}, server.Client())
|
||||
_, err := client.GetBalance(context.Background(), "bad-key")
|
||||
var apiErr APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
t.Fatalf("error = %T %v, want APIError", err, err)
|
||||
}
|
||||
if apiErr.Status != http.StatusUnauthorized || apiErr.Body != "invalid key\n" {
|
||||
t.Fatalf("APIError = %#v", apiErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTypedResponseRejectsEmptySuccessBody(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(Endpoints{InferenceBaseURL: server.URL}, server.Client())
|
||||
if _, err := client.GetBalance(context.Background(), "key"); err == nil {
|
||||
t.Fatal("GetBalance accepted an empty successful response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBodylessOperationAcceptsEmptySuccessBody(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(Endpoints{AuthBaseURL: server.URL}, server.Client())
|
||||
if err := client.SendSignInCode(context.Background(), "[email protected]"); err != nil {
|
||||
t.Fatalf("SendSignInCode() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAmountUSD(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
want int
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "default", want: DefaultAmountUSDMinor},
|
||||
{name: "minimum", value: "20", want: 2000},
|
||||
{name: "cents", value: "25.49", want: 2549},
|
||||
{name: "below minimum", value: "19.99", wantErr: true},
|
||||
{name: "above maximum", value: "10001", wantErr: true},
|
||||
{name: "not a number", value: "twenty", wantErr: true},
|
||||
{name: "nan", value: "NaN", wantErr: true},
|
||||
{name: "infinity", value: "Inf", wantErr: true},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
got, err := ParseAmountUSD(test.value)
|
||||
if (err != nil) != test.wantErr {
|
||||
t.Fatalf("ParseAmountUSD(%q) error = %v", test.value, err)
|
||||
}
|
||||
if got != test.want {
|
||||
t.Fatalf("ParseAmountUSD(%q) = %d, want %d", test.value, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
package aimlapi
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Endpoints struct {
|
||||
AuthBaseURL string
|
||||
AppBaseURL string
|
||||
InferenceBaseURL string
|
||||
PayBaseURL string
|
||||
// VerificationBaseURL is the aimlapi web-app frontend base. The browser is sent
|
||||
// back here after the partner checkout completes (see BuildPartnerReturnURL), so
|
||||
// it must point at the same environment's front as the one the CLI is talking to
|
||||
// to land the user there. Empty falls back to DefaultReturnURL.
|
||||
VerificationBaseURL string
|
||||
}
|
||||
|
||||
const (
|
||||
DefaultPartnerID = "part_62yQoGYDq4Yqnrj2R1iGrDNJ"
|
||||
DefaultPartnerName = "Gitlawb"
|
||||
PartnerHeaderName = "X-AIMLAPI-Partner-ID"
|
||||
// DefaultReturnURL is the https fallback the browser is sent to after the
|
||||
// checkout / OAuth consent finishes when no frontend base is known. It must be
|
||||
// a real web page the browser can open — NOT a custom scheme like
|
||||
// zero://aimlapi/complete, which fails with "the scheme does not have a
|
||||
// registered handler" because the CLI registers no OS protocol handler. The
|
||||
// CLI learns of success by polling; this URL is purely the browser's landing.
|
||||
DefaultReturnURL = "https://aimlapi.com/app"
|
||||
DefaultModel = "anthropic/claude-sonnet-5"
|
||||
|
||||
MinAmountUSDMinor = 2000
|
||||
MaxAmountUSDMinor = 1000000
|
||||
DefaultAmountUSDMinor = 2500
|
||||
)
|
||||
|
||||
func ResolveEndpoints() Endpoints {
|
||||
return Endpoints{
|
||||
AuthBaseURL: envOrDefault("AIMLAPI_AUTH_URL", "https://auth.aimlapi.com"),
|
||||
AppBaseURL: envOrDefault("AIMLAPI_APP_URL", "https://app.aimlapi.com"),
|
||||
InferenceBaseURL: envOrDefault("AIMLAPI_INFERENCE_URL", "https://api.aimlapi.com/v1"),
|
||||
PayBaseURL: envOrDefault("AIMLAPI_PAY_URL", "https://pay.aimlapi.com"),
|
||||
VerificationBaseURL: envOrDefault("AIMLAPI_VERIFICATION_BASE_URL", "https://aimlapi.com/app"),
|
||||
}
|
||||
}
|
||||
|
||||
// ResolvePartnerID applies the same explicit-option, environment, and default
|
||||
// precedence everywhere partner attribution is used. Keeping checkout creation
|
||||
// and saved inference profiles on one resolver prevents them from attributing
|
||||
// the same user to different partners.
|
||||
func ResolvePartnerID(explicit string) string {
|
||||
if value := strings.TrimSpace(explicit); value != "" {
|
||||
return value
|
||||
}
|
||||
return envOrDefault("AIMLAPI_PARTNER_ID", DefaultPartnerID)
|
||||
}
|
||||
|
||||
// WithResolvedPartnerHeader returns a copy with the effective partner ID set.
|
||||
// Header matching is case-insensitive so an existing spelling is replaced rather
|
||||
// than duplicated. Callers remain responsible for applying catalog attribution
|
||||
// only to the canonical AIMLAPI endpoint.
|
||||
func WithResolvedPartnerHeader(headers map[string]string) map[string]string {
|
||||
resolved := make(map[string]string, len(headers)+1)
|
||||
for key, value := range headers {
|
||||
if strings.EqualFold(strings.TrimSpace(key), PartnerHeaderName) {
|
||||
continue
|
||||
}
|
||||
resolved[key] = value
|
||||
}
|
||||
resolved[PartnerHeaderName] = ResolvePartnerID("")
|
||||
return resolved
|
||||
}
|
||||
|
||||
func BuildPartnerCheckoutReturnURLs(appBaseURL string, sessionToken string) (successURL string, cancelURL string) {
|
||||
base := strings.TrimRight(strings.TrimSpace(appBaseURL), "/")
|
||||
if base == "" {
|
||||
// With no base there is nothing to anchor an absolute URL to. Return empty
|
||||
// so the caller omits the return URLs entirely (client.Pay skips empty ones,
|
||||
// leaving the backend's generic checkout) instead of emitting a broken
|
||||
// relative "/checkout?...".
|
||||
return "", ""
|
||||
}
|
||||
token := url.QueryEscape(sessionToken)
|
||||
return base + "/checkout?checkout=success&partnerCheckout=1&sessionToken=" + token,
|
||||
base + "/checkout?checkout=cancel&partnerCheckout=1&sessionToken=" + token
|
||||
}
|
||||
|
||||
// BuildPartnerReturnURL returns the https page the browser is sent back to once
|
||||
// the partner checkout or OAuth consent completes — the aimlapi web app the user
|
||||
// opened the flow from. frontendBaseURL is normally endpoints.VerificationBaseURL
|
||||
// (the front that hosts the login/consent page), so the return follows the same
|
||||
// environment. AIMLAPI_RETURN_URL overrides it outright; an empty base falls back
|
||||
// to DefaultReturnURL. It is deliberately NOT a custom scheme (zero://…): no
|
||||
// browser can hand a custom scheme off without an OS-registered handler, which a
|
||||
// CLI does not install.
|
||||
func BuildPartnerReturnURL(frontendBaseURL string) string {
|
||||
if override := strings.TrimSpace(os.Getenv("AIMLAPI_RETURN_URL")); override != "" {
|
||||
return override
|
||||
}
|
||||
if base := strings.TrimRight(strings.TrimSpace(frontendBaseURL), "/"); base != "" {
|
||||
return base
|
||||
}
|
||||
return DefaultReturnURL
|
||||
}
|
||||
|
||||
func envOrDefault(name string, fallback string) string {
|
||||
if value := strings.TrimSpace(os.Getenv(name)); value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
package aimlapi
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestResolveEndpointsDefaults(t *testing.T) {
|
||||
t.Setenv("AIMLAPI_AUTH_URL", "")
|
||||
t.Setenv("AIMLAPI_APP_URL", "")
|
||||
t.Setenv("AIMLAPI_INFERENCE_URL", "")
|
||||
t.Setenv("AIMLAPI_PAY_URL", "")
|
||||
|
||||
endpoints := ResolveEndpoints()
|
||||
if endpoints.AuthBaseURL != "https://auth.aimlapi.com" ||
|
||||
endpoints.AppBaseURL != "https://app.aimlapi.com" ||
|
||||
endpoints.InferenceBaseURL != "https://api.aimlapi.com/v1" ||
|
||||
endpoints.PayBaseURL != "https://pay.aimlapi.com" {
|
||||
t.Fatalf("unexpected endpoints: %#v", endpoints)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvedPartnerHeaderUsesEnvironmentOverrideWithoutDuplicates(t *testing.T) {
|
||||
t.Setenv("AIMLAPI_PARTNER_ID", "part_override")
|
||||
|
||||
headers := WithResolvedPartnerHeader(map[string]string{
|
||||
"x-aimlapi-partner-id": "part_catalog",
|
||||
"X-AIMLAPI-Integration-Repo": "Gitlawb/zero",
|
||||
})
|
||||
|
||||
if got := headers[PartnerHeaderName]; got != "part_override" {
|
||||
t.Fatalf("partner header = %q, want part_override", got)
|
||||
}
|
||||
if _, ok := headers["x-aimlapi-partner-id"]; ok {
|
||||
t.Fatalf("case-insensitive partner header was duplicated: %#v", headers)
|
||||
}
|
||||
if got := headers["X-AIMLAPI-Integration-Repo"]; got != "Gitlawb/zero" {
|
||||
t.Fatalf("unrelated header changed: %#v", headers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPartnerReturnURL(t *testing.T) {
|
||||
t.Setenv("AIMLAPI_RETURN_URL", "")
|
||||
// The frontend base (the web app the flow was opened from) is returned as-is,
|
||||
// trailing slash trimmed — an https page the browser can actually land on.
|
||||
if got := BuildPartnerReturnURL("https://aimlapi.com/app/"); got != "https://aimlapi.com/app" {
|
||||
t.Fatalf("return URL = %q, want the frontend base", got)
|
||||
}
|
||||
// An empty base falls back to the https default, never a custom scheme.
|
||||
if got := BuildPartnerReturnURL(""); got != DefaultReturnURL {
|
||||
t.Fatalf("empty base return URL = %q, want %q", got, DefaultReturnURL)
|
||||
}
|
||||
|
||||
t.Setenv("AIMLAPI_RETURN_URL", "https://example.test/done")
|
||||
if got := BuildPartnerReturnURL("https://aimlapi.com/app"); got != "https://example.test/done" {
|
||||
t.Fatalf("env override return URL = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
package aimlapi
|
||||
|
||||
// UI strings shared by the first-run and provider-wizard onboarding surfaces.
|
||||
|
||||
const (
|
||||
// Path A — existing API key. Pick-path prompt + option labels.
|
||||
MsgAPIKeyInputPrompt = "Enter your aimlapi.com key."
|
||||
MsgAPIKeyInvalid = "API key is invalid. Please make sure you enter a valid aimlapi.com key."
|
||||
MsgPickPathPrompt = "Do you have aimlapi.com key?"
|
||||
MsgPickPathHaveKey = "I already have aimlapi.com key"
|
||||
MsgPickPathHaveHint = "Proceed to paste the key"
|
||||
MsgPickPathNewUser = "I am a new user"
|
||||
MsgPickPathNewHint = "One click set up"
|
||||
// Path B — email.
|
||||
MsgEnterEmail = "Enter your email.\nTo access aimlapi.com dashboard."
|
||||
MsgEmailInvalid = "Email format is incorrect."
|
||||
MsgAccountActionInvalid = "aimlapi.com returned an unsupported account action. Please try again."
|
||||
// %s = email
|
||||
MsgCodeSent = "We sent a 6-digit code to %s.\nEnter it below to continue."
|
||||
MsgCodeIncorrect = "Code you've entered is incorrect."
|
||||
|
||||
// Balance check.
|
||||
MsgLowBalance = "Your aimlapi.com balance is running low.\nIt is recommended to top up your balance."
|
||||
MsgEverythingRuns = "Everything is ready."
|
||||
// Low-balance choices (spec wording).
|
||||
MsgLowBalanceTopUp = "Sure, let's do that"
|
||||
MsgLowBalanceSkip = "I'll skip topping up the balance for now"
|
||||
|
||||
// Top-up workflow.
|
||||
MsgTopUpPrompt = "Add credits.\nEnter an amount (min $20)."
|
||||
MsgAmountRequired = "Please enter a top-up amount."
|
||||
// %s = checkout URL
|
||||
MsgTopUpBrowserFallback = "If the browser did not open automatically please use this link to top up your account: %s"
|
||||
MsgTopUpFailed = "Top up failed. Please try again."
|
||||
MsgTopUpSuccess = "Top-up successful."
|
||||
|
||||
// Success — key delivered.
|
||||
// %s = amount in dollars (e.g. "25")
|
||||
MsgTopUpAddedFmt = "$%s has been added to your balance."
|
||||
// %s = email
|
||||
MsgSuccessMagicLink = "We've emailed you a magic link to %s. Use it to access your aimlapi.com account and review your usage and balance."
|
||||
)
|
||||
@@ -1,26 +0,0 @@
|
||||
package aimlapi
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidEmailRejectsIncompleteDomains(t *testing.T) {
|
||||
tests := []struct {
|
||||
value string
|
||||
valid bool
|
||||
}{
|
||||
{value: "[email protected]", valid: true},
|
||||
{value: "[email protected]", valid: true},
|
||||
{value: "123@123.", valid: false},
|
||||
{value: "123@123", valid: false},
|
||||
{value: "[email protected]", valid: false},
|
||||
{value: "[email protected]", valid: false},
|
||||
{value: "[email protected]", valid: false},
|
||||
{value: "[email protected]", valid: false},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.value, func(t *testing.T) {
|
||||
if got := ValidEmail(test.value); got != test.valid {
|
||||
t.Fatalf("ValidEmail(%q) = %v, want %v", test.value, got, test.valid)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,335 +0,0 @@
|
||||
package aimlapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// StreamTopUpOptions drives a session-based partner-checkout top-up for the
|
||||
// interactive (TUI) onboarding. The caller already holds a user session —
|
||||
// obtained from an email-code sign-in (Path B existing) or a
|
||||
// passwordless new account (Path B sign-up) — so this skips authentication and
|
||||
// runs create-session → pay → wait-for-payment, optionally exchanging the paid
|
||||
// session into a fresh key (new accounts) versus keeping the caller's key.
|
||||
type StreamTopUpOptions struct {
|
||||
SessionToken string // user session bearer (required)
|
||||
AmountUSD string // dollars; parsed via ParseAmountUSD (min $20)
|
||||
// InferenceBaseURL pins key-bound billing and the returned provider profile to
|
||||
// the endpoint that validated the credential. Empty uses ResolveEndpoints.
|
||||
InferenceBaseURL string
|
||||
Method PaymentMethod
|
||||
AutoTopUp bool // enroll the account in automatic top-up at checkout time
|
||||
Exchange bool // exchange the paid session into a new key (new accounts)
|
||||
Model string
|
||||
PartnerID string
|
||||
PartnerName string
|
||||
NoOpen bool
|
||||
|
||||
// ResumeSessionToken, when set, resumes that partner-checkout session instead
|
||||
// of opening a new one, so a retry after an ambiguous failure (a lost Pay/
|
||||
// Exchange response) can never double-charge or strand an already-paid session.
|
||||
ResumeSessionToken string
|
||||
// PaymentSessionID is generated once per amount/auto-top-up intent and reused
|
||||
// on retry so an ambiguous Pay response cannot create a second checkout.
|
||||
PaymentSessionID string
|
||||
|
||||
HTTPClient *http.Client
|
||||
OpenBrowser func(string) error
|
||||
OnStatus func(Status, string)
|
||||
// OnSession reports the partner-checkout session token the moment it exists (a
|
||||
// fresh CreateSession, or the resumed one), so the caller can retain it and
|
||||
// resume on a later failure. It fires with "" when the session is dead and the
|
||||
// retained token should be dropped (a fresh run must start over).
|
||||
OnSession func(sessionToken string)
|
||||
}
|
||||
|
||||
// topupPhase is where StreamTopUp enters its create-checkout → poll → exchange
|
||||
// pipeline. A fresh run starts at phasePay; a resume skips ahead to whatever the
|
||||
// existing session's status makes safe.
|
||||
type topupPhase int
|
||||
|
||||
const (
|
||||
phasePay topupPhase = iota // (re-)open the Stripe checkout, then poll + exchange
|
||||
phasePoll // checkout already open; poll for payment, never re-pay
|
||||
phaseExchange // already paid; exchange only (recovers a lost key)
|
||||
phaseWaitExchange // an exchange claim is in flight; poll, never call Exchange twice
|
||||
)
|
||||
|
||||
// StreamTopUp performs the browser-checkout top-up and reports progress via
|
||||
// OnStatus.
|
||||
func StreamTopUp(ctx context.Context, options StreamTopUpOptions) (ProvisionedKey, error) {
|
||||
endpoints := ResolveEndpoints()
|
||||
if baseURL := strings.TrimSpace(options.InferenceBaseURL); baseURL != "" {
|
||||
endpoints.InferenceBaseURL = baseURL
|
||||
}
|
||||
client := NewClient(endpoints, options.HTTPClient)
|
||||
partnerID := ResolvePartnerID(options.PartnerID)
|
||||
partnerName := firstNonEmpty(options.PartnerName, DefaultPartnerName)
|
||||
method := options.Method
|
||||
if method != PaymentMethodCrypto {
|
||||
method = PaymentMethodCard
|
||||
}
|
||||
amount, err := ParseAmountUSD(options.AmountUSD)
|
||||
if err != nil {
|
||||
return ProvisionedKey{}, err
|
||||
}
|
||||
if strings.TrimSpace(options.SessionToken) == "" {
|
||||
return ProvisionedKey{}, fmt.Errorf("a session is required to top up")
|
||||
}
|
||||
if strings.TrimSpace(options.PaymentSessionID) == "" {
|
||||
return ProvisionedKey{}, fmt.Errorf("a payment session id is required to top up")
|
||||
}
|
||||
|
||||
status(options.OnStatus, StatusCreatingSession, "")
|
||||
sessionToken, phase, err := resolveTopupSession(ctx, client, options, partnerID, partnerName, endpoints)
|
||||
if err != nil {
|
||||
return ProvisionedKey{}, err
|
||||
}
|
||||
// Retain the live token so an ambiguous failure below can resume this session
|
||||
// rather than open a second checkout on the next attempt.
|
||||
reportSession(options.OnSession, sessionToken)
|
||||
|
||||
if phase <= phasePay {
|
||||
successURL, cancelURL := BuildPartnerCheckoutReturnURLs(endpoints.PayBaseURL, sessionToken)
|
||||
status(options.OnStatus, StatusOpeningCheckout, "")
|
||||
pay, err := client.Pay(ctx, options.SessionToken, sessionToken, amount, options.PaymentSessionID, method, successURL, cancelURL, options.AutoTopUp)
|
||||
if err != nil {
|
||||
return ProvisionedKey{}, err
|
||||
}
|
||||
checkoutURL, err := validatedCheckoutURL(pay.Checkout.PayURL)
|
||||
if err != nil {
|
||||
return ProvisionedKey{}, err
|
||||
}
|
||||
announceCheckout(options.OnStatus, options.OpenBrowser, options.NoOpen, checkoutURL)
|
||||
}
|
||||
|
||||
paidToken := sessionToken
|
||||
if phase <= phasePoll {
|
||||
status(options.OnStatus, StatusWaitingPayment, "")
|
||||
paid, err := pollUntilPaid(ctx, client, sessionToken, options.OnSession)
|
||||
if err != nil {
|
||||
return ProvisionedKey{}, err
|
||||
}
|
||||
paidToken = paid.SessionToken
|
||||
if paid.Status == SessionStatusExchanging {
|
||||
// Another/lost request claimed exchange while we were polling payment.
|
||||
// Carry that fact into the provisioning branch so it follows the claim
|
||||
// instead of issuing a second single-flight Exchange request.
|
||||
phase = phaseWaitExchange
|
||||
}
|
||||
}
|
||||
|
||||
result := ProvisionedKey{
|
||||
BaseURL: endpoints.InferenceBaseURL,
|
||||
Model: firstNonEmpty(options.Model, DefaultModel),
|
||||
}
|
||||
if options.Exchange {
|
||||
status(options.OnStatus, StatusProvisioningKey, "")
|
||||
if phase == phaseWaitExchange {
|
||||
return ProvisionedKey{}, pollUntilExchangeSettled(ctx, client, sessionToken, options.OnSession)
|
||||
}
|
||||
exchange, err := client.Exchange(ctx, options.SessionToken, paidToken)
|
||||
if err != nil {
|
||||
return ProvisionedKey{}, err
|
||||
}
|
||||
if strings.TrimSpace(exchange.APIKey) == "" {
|
||||
return ProvisionedKey{}, fmt.Errorf("aimlapi.com did not return an API key")
|
||||
}
|
||||
result.APIKey = exchange.APIKey
|
||||
result.APIKeyID = exchange.APIKeyID
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// resolveTopupSession returns the partner-checkout session token to drive and the
|
||||
// phase to enter. A fresh run opens a new session at phasePay. A resume inspects
|
||||
// the existing session and enters the earliest phase that cannot re-charge:
|
||||
// - pending_auth → phasePay: payment never started, so (re-)Pay is safe.
|
||||
// - pending_payment → phasePoll: a checkout is already open; poll, never re-Pay.
|
||||
// - paid → phaseExchange: exchange the paid session once.
|
||||
// - exchanging → phaseWaitExchange: another/lost request owns the atomic
|
||||
// exchange claim; poll it, never issue a second Exchange request.
|
||||
// - exchanged → terminal: the one-shot raw key is gone; point at dashboard
|
||||
// recovery and keep the token so a re-run repeats that guidance, not a charge.
|
||||
// - cancelled/expired/failed → terminal: drop the dead token so a re-run is fresh.
|
||||
func resolveTopupSession(ctx context.Context, client *Client, options StreamTopUpOptions, partnerID, partnerName string, endpoints Endpoints) (string, topupPhase, error) {
|
||||
resume := strings.TrimSpace(options.ResumeSessionToken)
|
||||
if resume == "" {
|
||||
session, err := client.CreateSession(ctx, partnerID, partnerName, BuildPartnerReturnURL(endpoints.VerificationBaseURL))
|
||||
if err != nil {
|
||||
return "", phasePay, err
|
||||
}
|
||||
return session.SessionToken, phasePay, nil
|
||||
}
|
||||
session, err := client.GetSession(ctx, resume)
|
||||
if err != nil {
|
||||
if isTerminalSessionAPIError(err) {
|
||||
reportSession(options.OnSession, "")
|
||||
}
|
||||
return "", phasePay, err
|
||||
}
|
||||
switch session.Status {
|
||||
case SessionStatusPendingAuth:
|
||||
return resume, phasePay, nil
|
||||
case SessionStatusPendingPayment:
|
||||
return resume, phasePoll, nil
|
||||
case SessionStatusPaid:
|
||||
return resume, phaseExchange, nil
|
||||
case SessionStatusExchanging:
|
||||
return resume, phaseWaitExchange, nil
|
||||
case SessionStatusExchanged:
|
||||
return "", phasePay, fmt.Errorf("session was already exchanged; rotate the key from the aimlapi.com dashboard")
|
||||
default:
|
||||
reportSession(options.OnSession, "") // drop the dead token → next run starts fresh
|
||||
return "", phasePay, fmt.Errorf("payment %s; re-run the top-up to try again", session.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func reportSession(callback func(string), sessionToken string) {
|
||||
if callback != nil {
|
||||
callback(sessionToken)
|
||||
}
|
||||
}
|
||||
|
||||
// announceCheckout opens the hosted checkout in the browser (unless suppressed)
|
||||
// and reports the URL either way, so the TUI can always show the direct link.
|
||||
func announceCheckout(onStatus func(Status, string), open func(string) error, noOpen bool, checkoutURL string) {
|
||||
if noOpen || open == nil {
|
||||
status(onStatus, StatusOpeningCheckout, checkoutURL)
|
||||
return
|
||||
}
|
||||
if err := open(checkoutURL); err != nil {
|
||||
status(onStatus, StatusOpeningCheckout, "Open manually: "+checkoutURL)
|
||||
return
|
||||
}
|
||||
status(onStatus, StatusOpeningCheckout, checkoutURL)
|
||||
}
|
||||
|
||||
func validatedCheckoutURL(value string) (string, error) {
|
||||
checkoutURL := strings.TrimSpace(value)
|
||||
parsed, err := url.Parse(checkoutURL)
|
||||
if err != nil || !parsed.IsAbs() || !strings.EqualFold(parsed.Scheme, "https") || parsed.Hostname() == "" || parsed.User != nil {
|
||||
return "", fmt.Errorf("payment provider did not return a valid HTTPS checkout URL")
|
||||
}
|
||||
return checkoutURL, nil
|
||||
}
|
||||
|
||||
// StreamTopUpByKeyOptions drives a top-up funded with the API key that already
|
||||
// owns the account (Path A pasted key, or AIMLAPI_API_KEY). Unlike StreamTopUp it
|
||||
// needs no email session and never exchanges a key — the caller already holds one,
|
||||
// so it only funds the wallet: create-session → pay-by-key → wait-for-payment.
|
||||
type StreamTopUpByKeyOptions struct {
|
||||
APIKey string // raw key that owns the account (required)
|
||||
AmountUSD string // dollars; parsed via ParseAmountUSD (min $20)
|
||||
InferenceBaseURL string // validated key endpoint; empty uses ResolveEndpoints
|
||||
AutoTopUp bool // enroll the account in automatic top-up at checkout time
|
||||
PartnerID string
|
||||
PartnerName string
|
||||
NoOpen bool
|
||||
|
||||
// ResumeSessionToken resumes an existing partner-checkout session instead of
|
||||
// opening a new one. PaymentSessionID is the idempotency handle carried across
|
||||
// retries so a re-issued pay returns the same checkout, never a second charge.
|
||||
ResumeSessionToken string
|
||||
PaymentSessionID string // required; generate once via NewPaymentSessionID, reuse on retry
|
||||
|
||||
HTTPClient *http.Client
|
||||
OpenBrowser func(string) error
|
||||
OnStatus func(Status, string)
|
||||
OnSession func(sessionToken string)
|
||||
}
|
||||
|
||||
// StreamTopUpByKey funds the account behind APIKey via the hosted checkout and
|
||||
// reports progress through OnStatus. It returns an empty ProvisionedKey: the
|
||||
// pasted/env key is unchanged, only the balance grows.
|
||||
func StreamTopUpByKey(ctx context.Context, options StreamTopUpByKeyOptions) (ProvisionedKey, error) {
|
||||
endpoints := ResolveEndpoints()
|
||||
if baseURL := strings.TrimSpace(options.InferenceBaseURL); baseURL != "" {
|
||||
endpoints.InferenceBaseURL = baseURL
|
||||
}
|
||||
client := NewClient(endpoints, options.HTTPClient)
|
||||
partnerID := ResolvePartnerID(options.PartnerID)
|
||||
partnerName := firstNonEmpty(options.PartnerName, DefaultPartnerName)
|
||||
amount, err := ParseAmountUSD(options.AmountUSD)
|
||||
if err != nil {
|
||||
return ProvisionedKey{}, err
|
||||
}
|
||||
if strings.TrimSpace(options.APIKey) == "" {
|
||||
return ProvisionedKey{}, fmt.Errorf("an API key is required to top up")
|
||||
}
|
||||
if strings.TrimSpace(options.PaymentSessionID) == "" {
|
||||
return ProvisionedKey{}, fmt.Errorf("a payment session id is required to top up")
|
||||
}
|
||||
|
||||
status(options.OnStatus, StatusCreatingSession, "")
|
||||
sessionToken, phase, err := resolveByKeySession(ctx, client, options, partnerID, partnerName, endpoints)
|
||||
if err != nil {
|
||||
return ProvisionedKey{}, err
|
||||
}
|
||||
reportSession(options.OnSession, sessionToken)
|
||||
|
||||
if phase <= phasePay {
|
||||
successURL, cancelURL := BuildPartnerCheckoutReturnURLs(endpoints.PayBaseURL, sessionToken)
|
||||
status(options.OnStatus, StatusOpeningCheckout, "")
|
||||
pay, err := client.TopUpByKey(ctx, options.APIKey, TopUpByKeyRequest{
|
||||
SessionToken: sessionToken,
|
||||
AmountUSDMinor: amount,
|
||||
PaymentSessionID: options.PaymentSessionID,
|
||||
AutoTopUp: options.AutoTopUp,
|
||||
SuccessURL: successURL,
|
||||
CancelURL: cancelURL,
|
||||
})
|
||||
if err != nil {
|
||||
return ProvisionedKey{}, err
|
||||
}
|
||||
checkoutURL, err := validatedCheckoutURL(pay.Checkout.PayURL)
|
||||
if err != nil {
|
||||
return ProvisionedKey{}, err
|
||||
}
|
||||
announceCheckout(options.OnStatus, options.OpenBrowser, options.NoOpen, checkoutURL)
|
||||
}
|
||||
|
||||
if phase <= phasePoll {
|
||||
status(options.OnStatus, StatusWaitingPayment, "")
|
||||
if _, err := pollUntilPaid(ctx, client, sessionToken, options.OnSession); err != nil {
|
||||
return ProvisionedKey{}, err
|
||||
}
|
||||
}
|
||||
// No exchange: the account is funded and the caller keeps its existing key.
|
||||
return ProvisionedKey{}, nil
|
||||
}
|
||||
|
||||
// resolveByKeySession mirrors resolveTopupSession for the by-key flow. Because
|
||||
// by-key never exchanges, a paid/exchanging/exchanged session all mean "already
|
||||
// funded, done" (phaseExchange skips straight past pay + poll to the empty result).
|
||||
func resolveByKeySession(ctx context.Context, client *Client, options StreamTopUpByKeyOptions, partnerID, partnerName string, endpoints Endpoints) (string, topupPhase, error) {
|
||||
resume := strings.TrimSpace(options.ResumeSessionToken)
|
||||
if resume == "" {
|
||||
session, err := client.CreateSession(ctx, partnerID, partnerName, BuildPartnerReturnURL(endpoints.VerificationBaseURL))
|
||||
if err != nil {
|
||||
return "", phasePay, err
|
||||
}
|
||||
return session.SessionToken, phasePay, nil
|
||||
}
|
||||
session, err := client.GetSession(ctx, resume)
|
||||
if err != nil {
|
||||
if isTerminalSessionAPIError(err) {
|
||||
reportSession(options.OnSession, "")
|
||||
}
|
||||
return "", phasePay, err
|
||||
}
|
||||
switch session.Status {
|
||||
case SessionStatusPendingAuth:
|
||||
return resume, phasePay, nil
|
||||
case SessionStatusPendingPayment:
|
||||
return resume, phasePoll, nil
|
||||
case SessionStatusPaid, SessionStatusExchanging, SessionStatusExchanged:
|
||||
return resume, phaseExchange, nil
|
||||
default:
|
||||
reportSession(options.OnSession, "") // drop the dead token → next run starts fresh
|
||||
return "", phasePay, fmt.Errorf("payment %s; re-run the top-up to try again", session.Status)
|
||||
}
|
||||
}
|
||||
@@ -1,457 +0,0 @@
|
||||
package aimlapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStreamTopUpByKeyFundsWithoutExchange(t *testing.T) {
|
||||
var topupCalls, exchangeCalls int
|
||||
var topupBody map[string]any
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/partner-checkout/sessions"):
|
||||
_ = json.NewEncoder(w).Encode(PartnerCheckoutSession{SessionToken: "pcs_tok", Status: SessionStatusPendingAuth})
|
||||
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/v2/billing/topup"):
|
||||
topupCalls++
|
||||
_ = json.NewDecoder(r.Body).Decode(&topupBody)
|
||||
_ = json.NewEncoder(w).Encode(TopUpByKeyResult{Checkout: PaymentSession{PayURL: "https://pay/checkout"}})
|
||||
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/partner-checkout/sessions/"):
|
||||
_ = json.NewEncoder(w).Encode(PartnerCheckoutSession{SessionToken: "pcs_tok", Status: SessionStatusPaid})
|
||||
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/exchange"):
|
||||
exchangeCalls++
|
||||
t.Errorf("by-key must never exchange a key")
|
||||
default:
|
||||
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
t.Setenv("AIMLAPI_APP_URL", server.URL)
|
||||
t.Setenv("AIMLAPI_INFERENCE_URL", server.URL+"/v1")
|
||||
|
||||
var opened string
|
||||
result, err := StreamTopUpByKey(context.Background(), StreamTopUpByKeyOptions{
|
||||
APIKey: "sk-user",
|
||||
AmountUSD: "20",
|
||||
AutoTopUp: true,
|
||||
PaymentSessionID: "pay-1",
|
||||
OpenBrowser: func(u string) error { opened = u; return nil },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StreamTopUpByKey = %v", err)
|
||||
}
|
||||
if result.APIKey != "" {
|
||||
t.Fatalf("by-key must not mint a key, got %q", result.APIKey)
|
||||
}
|
||||
if topupCalls != 1 || exchangeCalls != 0 {
|
||||
t.Fatalf("topup=%d exchange=%d, want 1/0", topupCalls, exchangeCalls)
|
||||
}
|
||||
if opened != "https://pay/checkout" {
|
||||
t.Fatalf("opened checkout = %q", opened)
|
||||
}
|
||||
if topupBody["autoTopUp"] != true || topupBody["paymentSessionId"] != "pay-1" {
|
||||
t.Fatalf("topup body missing autoTopUp/paymentSessionId: %#v", topupBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatedCheckoutURLRequiresAbsoluteHTTPS(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "https", value: " https://pay.example.test/checkout?id=1 ", want: "https://pay.example.test/checkout?id=1"},
|
||||
{name: "empty", wantErr: true},
|
||||
{name: "relative", value: "/checkout", wantErr: true},
|
||||
{name: "http", value: "http://pay.example.test/checkout", wantErr: true},
|
||||
{name: "file", value: "file:///tmp/checkout", wantErr: true},
|
||||
{name: "custom protocol", value: "wallet://checkout/123", wantErr: true},
|
||||
{name: "userinfo", value: "https://[email protected]/checkout", wantErr: true},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
got, err := validatedCheckoutURL(test.value)
|
||||
if (err != nil) != test.wantErr {
|
||||
t.Fatalf("validatedCheckoutURL(%q) error = %v", test.value, err)
|
||||
}
|
||||
if got != test.want {
|
||||
t.Fatalf("validatedCheckoutURL(%q) = %q, want %q", test.value, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamTopUpByKeyPinsValidatedInferenceEndpoint(t *testing.T) {
|
||||
validatedCalls := 0
|
||||
validated := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/partner-checkout/sessions"):
|
||||
_ = json.NewEncoder(w).Encode(PartnerCheckoutSession{SessionToken: "pcs_tok", Status: SessionStatusPendingAuth})
|
||||
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/v2/billing/topup"):
|
||||
validatedCalls++
|
||||
_ = json.NewEncoder(w).Encode(TopUpByKeyResult{Checkout: PaymentSession{PayURL: "https://pay/checkout"}})
|
||||
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/partner-checkout/sessions/"):
|
||||
_ = json.NewEncoder(w).Encode(PartnerCheckoutSession{SessionToken: "pcs_tok", Status: SessionStatusPaid})
|
||||
default:
|
||||
t.Errorf("unexpected validated-endpoint request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer validated.Close()
|
||||
override := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Errorf("validated key leaked to process override: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer override.Close()
|
||||
t.Setenv("AIMLAPI_APP_URL", validated.URL)
|
||||
t.Setenv("AIMLAPI_INFERENCE_URL", override.URL)
|
||||
|
||||
_, err := StreamTopUpByKey(context.Background(), StreamTopUpByKeyOptions{
|
||||
APIKey: "production-key",
|
||||
AmountUSD: "20",
|
||||
InferenceBaseURL: validated.URL,
|
||||
PaymentSessionID: "payment-1",
|
||||
OpenBrowser: func(string) error { return nil },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StreamTopUpByKey() error = %v", err)
|
||||
}
|
||||
if validatedCalls != 1 {
|
||||
t.Fatalf("validated endpoint top-up calls = %d, want 1", validatedCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamTopUpEmailRetrySendsStablePaymentSessionID(t *testing.T) {
|
||||
getCalls := 0
|
||||
var paymentSessionID string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/partner-checkout/sessions/"):
|
||||
status := SessionStatusPendingAuth
|
||||
if getCalls > 0 {
|
||||
status = SessionStatusPaid
|
||||
}
|
||||
getCalls++
|
||||
_ = json.NewEncoder(w).Encode(PartnerCheckoutSession{SessionToken: "pcs_retry", Status: status})
|
||||
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/pay"):
|
||||
var body map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
paymentSessionID, _ = body["paymentSessionId"].(string)
|
||||
_ = json.NewEncoder(w).Encode(PayResult{Checkout: PaymentSession{PayURL: "https://pay/checkout"}})
|
||||
default:
|
||||
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
t.Setenv("AIMLAPI_APP_URL", server.URL)
|
||||
|
||||
_, err := StreamTopUp(context.Background(), StreamTopUpOptions{
|
||||
SessionToken: "bearer",
|
||||
ResumeSessionToken: "pcs_retry",
|
||||
PaymentSessionID: "payment-stable",
|
||||
AmountUSD: "20",
|
||||
NoOpen: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StreamTopUp() error = %v", err)
|
||||
}
|
||||
if paymentSessionID != "payment-stable" {
|
||||
t.Fatalf("paymentSessionId = %q, want stable retry id", paymentSessionID)
|
||||
}
|
||||
}
|
||||
|
||||
// resumeServer is a partner-checkout backend fake that fails the test if the
|
||||
// re-charging endpoints (create-session / pay) are ever hit, and serves a
|
||||
// scripted GetSession status sequence so a resume can be driven deterministically.
|
||||
type resumeServer struct {
|
||||
t *testing.T
|
||||
statuses []string // GetSession status per call, last value repeats
|
||||
getCalls int
|
||||
payCalls int
|
||||
createCall int
|
||||
exchanged int
|
||||
}
|
||||
|
||||
func (rs *resumeServer) handler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/partner-checkout/sessions"):
|
||||
rs.createCall++
|
||||
rs.t.Errorf("resume must not create a new session")
|
||||
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/pay"):
|
||||
rs.payCalls++
|
||||
rs.t.Errorf("resume must not re-pay (double charge)")
|
||||
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/exchange"):
|
||||
rs.exchanged++
|
||||
_ = json.NewEncoder(w).Encode(ExchangeResult{APIKey: "sk-resumed", APIKeyID: "key_1"})
|
||||
case r.Method == http.MethodGet:
|
||||
status := rs.statuses[len(rs.statuses)-1]
|
||||
if rs.getCalls < len(rs.statuses) {
|
||||
status = rs.statuses[rs.getCalls]
|
||||
}
|
||||
rs.getCalls++
|
||||
_ = json.NewEncoder(w).Encode(PartnerCheckoutSession{SessionToken: "pc_tok", Status: SessionStatus(status)})
|
||||
default:
|
||||
rs.t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestStreamTopUpResumePaidExchangesWithoutRecharge(t *testing.T) {
|
||||
rs := &resumeServer{t: t, statuses: []string{"paid"}}
|
||||
server := httptest.NewServer(rs.handler())
|
||||
defer server.Close()
|
||||
t.Setenv("AIMLAPI_APP_URL", server.URL)
|
||||
|
||||
var seen string
|
||||
result, err := StreamTopUp(context.Background(), StreamTopUpOptions{
|
||||
SessionToken: "bearer",
|
||||
ResumeSessionToken: "pc_tok",
|
||||
PaymentSessionID: "payment-1",
|
||||
AmountUSD: "20",
|
||||
Exchange: true,
|
||||
NoOpen: true,
|
||||
OnSession: func(token string) { seen = token },
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StreamTopUp resume = %v", err)
|
||||
}
|
||||
if result.APIKey != "sk-resumed" {
|
||||
t.Fatalf("APIKey = %q, want recovered key", result.APIKey)
|
||||
}
|
||||
if rs.exchanged != 1 {
|
||||
t.Fatalf("exchange calls = %d, want 1", rs.exchanged)
|
||||
}
|
||||
if seen != "pc_tok" {
|
||||
t.Fatalf("OnSession token = %q, want retained pc_tok", seen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamTopUpResumePendingPaymentPollsNeverRepays(t *testing.T) {
|
||||
// First GetSession (resume resolution) sees the still-open checkout; the next
|
||||
// (poll) sees it settle. Pay must never be called — the fake fails the test if
|
||||
// it is.
|
||||
rs := &resumeServer{t: t, statuses: []string{"pending_payment", "paid"}}
|
||||
server := httptest.NewServer(rs.handler())
|
||||
defer server.Close()
|
||||
t.Setenv("AIMLAPI_APP_URL", server.URL)
|
||||
|
||||
result, err := StreamTopUp(context.Background(), StreamTopUpOptions{
|
||||
SessionToken: "bearer",
|
||||
ResumeSessionToken: "pc_tok",
|
||||
PaymentSessionID: "payment-1",
|
||||
AmountUSD: "20",
|
||||
Exchange: true,
|
||||
NoOpen: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StreamTopUp resume = %v", err)
|
||||
}
|
||||
if result.APIKey != "sk-resumed" {
|
||||
t.Fatalf("APIKey = %q, want recovered key", result.APIKey)
|
||||
}
|
||||
if rs.payCalls != 0 {
|
||||
t.Fatalf("pay calls = %d, want 0", rs.payCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamTopUpPollingDeadSessionClearsRetainedToken(t *testing.T) {
|
||||
rs := &resumeServer{t: t, statuses: []string{"pending_payment", "expired"}}
|
||||
server := httptest.NewServer(rs.handler())
|
||||
defer server.Close()
|
||||
t.Setenv("AIMLAPI_APP_URL", server.URL)
|
||||
|
||||
seen := []string{}
|
||||
_, err := StreamTopUp(context.Background(), StreamTopUpOptions{
|
||||
SessionToken: "bearer",
|
||||
ResumeSessionToken: "pc_tok",
|
||||
PaymentSessionID: "payment-1",
|
||||
AmountUSD: "20",
|
||||
Exchange: true,
|
||||
NoOpen: true,
|
||||
OnSession: func(token string) { seen = append(seen, token) },
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "payment expired") {
|
||||
t.Fatalf("err = %v, want expired payment error", err)
|
||||
}
|
||||
if len(seen) == 0 || seen[len(seen)-1] != "" {
|
||||
t.Fatalf("OnSession events = %#v, want terminal clear", seen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollUntilPaidTerminal4xxClearsRetainedToken(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "session rejected", http.StatusNotFound)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
seen := "retained"
|
||||
_, err := pollUntilPaid(context.Background(), NewClient(Endpoints{AppBaseURL: server.URL}, server.Client()), "pc_missing", func(token string) {
|
||||
seen = token
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("pollUntilPaid() error = nil, want terminal 4xx")
|
||||
}
|
||||
if seen != "" {
|
||||
t.Fatalf("retained session = %q, want cleared", seen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPollUntilExchangeSettledClearsDeadSession(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
statusCode int
|
||||
status SessionStatus
|
||||
}{
|
||||
{name: "terminal API rejection", statusCode: http.StatusNotFound},
|
||||
{name: "expired exchange claim", status: SessionStatusExpired},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
if test.statusCode != 0 {
|
||||
http.Error(w, "session rejected", test.statusCode)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(PartnerCheckoutSession{SessionToken: "pcs_dead", Status: test.status})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
seen := "retained"
|
||||
err := pollUntilExchangeSettled(context.Background(), NewClient(Endpoints{AppBaseURL: server.URL}, server.Client()), "pcs_dead", func(token string) {
|
||||
seen = token
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("pollUntilExchangeSettled() error = nil")
|
||||
}
|
||||
if seen != "" {
|
||||
t.Fatalf("retained session = %q, want cleared", seen)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResumeHelpersClearOnlyRejectedSessions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
status int
|
||||
wantClear bool
|
||||
}{
|
||||
{name: "terminal 4xx", status: http.StatusNotFound, wantClear: true},
|
||||
{name: "retryable 5xx", status: http.StatusServiceUnavailable, wantClear: false},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
http.Error(w, "session lookup failed", test.status)
|
||||
}))
|
||||
defer server.Close()
|
||||
client := NewClient(Endpoints{AppBaseURL: server.URL}, server.Client())
|
||||
|
||||
for _, flow := range []struct {
|
||||
name string
|
||||
resolve func(func(string)) error
|
||||
}{
|
||||
{
|
||||
name: "session bearer",
|
||||
resolve: func(onSession func(string)) error {
|
||||
_, _, err := resolveTopupSession(context.Background(), client, StreamTopUpOptions{
|
||||
ResumeSessionToken: "pc_rejected", OnSession: onSession,
|
||||
}, DefaultPartnerID, DefaultPartnerName, Endpoints{})
|
||||
return err
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "api key",
|
||||
resolve: func(onSession func(string)) error {
|
||||
_, _, err := resolveByKeySession(context.Background(), client, StreamTopUpByKeyOptions{
|
||||
ResumeSessionToken: "pc_rejected", OnSession: onSession,
|
||||
}, DefaultPartnerID, DefaultPartnerName, Endpoints{})
|
||||
return err
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(flow.name, func(t *testing.T) {
|
||||
seen := "retained"
|
||||
if err := flow.resolve(func(token string) { seen = token }); err == nil {
|
||||
t.Fatal("resolve error = nil")
|
||||
}
|
||||
if cleared := seen == ""; cleared != test.wantClear {
|
||||
t.Fatalf("session cleared = %v, want %v", cleared, test.wantClear)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamTopUpResumeExchangedReturnsRecoveryError(t *testing.T) {
|
||||
rs := &resumeServer{t: t, statuses: []string{"exchanged"}}
|
||||
server := httptest.NewServer(rs.handler())
|
||||
defer server.Close()
|
||||
t.Setenv("AIMLAPI_APP_URL", server.URL)
|
||||
|
||||
_, err := StreamTopUp(context.Background(), StreamTopUpOptions{
|
||||
SessionToken: "bearer",
|
||||
ResumeSessionToken: "pc_tok",
|
||||
PaymentSessionID: "payment-1",
|
||||
AmountUSD: "20",
|
||||
Exchange: true,
|
||||
NoOpen: true,
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "already exchanged") {
|
||||
t.Fatalf("err = %v, want an already-exchanged recovery error", err)
|
||||
}
|
||||
if rs.exchanged != 0 {
|
||||
t.Fatalf("exchange calls = %d, want 0 (no second key)", rs.exchanged)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamTopUpResumeExchangingPollsWithoutSecondExchange(t *testing.T) {
|
||||
rs := &resumeServer{t: t, statuses: []string{"exchanging", "exchanged"}}
|
||||
server := httptest.NewServer(rs.handler())
|
||||
defer server.Close()
|
||||
t.Setenv("AIMLAPI_APP_URL", server.URL)
|
||||
|
||||
_, err := StreamTopUp(context.Background(), StreamTopUpOptions{
|
||||
SessionToken: "bearer",
|
||||
ResumeSessionToken: "pc_tok",
|
||||
PaymentSessionID: "payment-1",
|
||||
AmountUSD: "20",
|
||||
Exchange: true,
|
||||
NoOpen: true,
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "already exchanged") {
|
||||
t.Fatalf("err = %v, want dashboard recovery guidance", err)
|
||||
}
|
||||
if rs.exchanged != 0 {
|
||||
t.Fatalf("exchange calls = %d, want 0 while an exchange claim is in flight", rs.exchanged)
|
||||
}
|
||||
if rs.createCall != 0 || rs.payCalls != 0 {
|
||||
t.Fatalf("resume created/paid a new checkout: create=%d pay=%d", rs.createCall, rs.payCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamTopUpPollObservesExchangingWithoutSecondExchange(t *testing.T) {
|
||||
rs := &resumeServer{t: t, statuses: []string{"pending_payment", "exchanging", "exchanged"}}
|
||||
server := httptest.NewServer(rs.handler())
|
||||
defer server.Close()
|
||||
t.Setenv("AIMLAPI_APP_URL", server.URL)
|
||||
|
||||
_, err := StreamTopUp(context.Background(), StreamTopUpOptions{
|
||||
SessionToken: "bearer",
|
||||
ResumeSessionToken: "pc_tok",
|
||||
PaymentSessionID: "payment-1",
|
||||
AmountUSD: "20",
|
||||
Exchange: true,
|
||||
NoOpen: true,
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "already exchanged") {
|
||||
t.Fatalf("err = %v, want dashboard recovery guidance", err)
|
||||
}
|
||||
if rs.exchanged != 0 {
|
||||
t.Fatalf("exchange calls = %d, want 0 after poll observed an existing claim", rs.exchanged)
|
||||
}
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
package aimlapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Status is a progress phase reported by StreamTopUp through its OnStatus callback.
|
||||
type Status string
|
||||
|
||||
const (
|
||||
StatusCreatingSession Status = "creating-session"
|
||||
StatusOpeningCheckout Status = "opening-checkout"
|
||||
StatusWaitingPayment Status = "waiting-payment"
|
||||
StatusProvisioningKey Status = "provisioning-key"
|
||||
)
|
||||
|
||||
// ProvisionedKey is the outcome of a top-up: the (optionally minted) API key plus
|
||||
// the inference base URL and model to write into the provider profile.
|
||||
type ProvisionedKey struct {
|
||||
APIKey string
|
||||
APIKeyID string
|
||||
BaseURL string
|
||||
Model string
|
||||
}
|
||||
|
||||
const (
|
||||
pollInterval = 3 * time.Second
|
||||
pollTimeout = 20 * time.Minute
|
||||
)
|
||||
|
||||
// ParseAmountUSD parses a dollar string into USD minor units (cents), enforcing the
|
||||
// min/max top-up bounds. An empty value yields the default amount.
|
||||
func ParseAmountUSD(value string) (int, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return DefaultAmountUSDMinor, nil
|
||||
}
|
||||
dollars, err := strconv.ParseFloat(value, 64)
|
||||
// NaN/±Inf parse without error and slip past the min/max comparisons below
|
||||
// (every ordered comparison with NaN is false), so reject them explicitly
|
||||
// before the cents conversion turns them into a garbage minor-unit amount.
|
||||
if err != nil || math.IsNaN(dollars) || math.IsInf(dollars, 0) || dollars <= 0 {
|
||||
return 0, fmt.Errorf("invalid amount %q; pass a positive USD amount", value)
|
||||
}
|
||||
minor := int(dollars*100 + 0.5)
|
||||
if minor < MinAmountUSDMinor {
|
||||
return 0, fmt.Errorf("minimum top-up is $%d", MinAmountUSDMinor/100)
|
||||
}
|
||||
if minor > MaxAmountUSDMinor {
|
||||
return 0, fmt.Errorf("maximum top-up is $%d", MaxAmountUSDMinor/100)
|
||||
}
|
||||
return minor, nil
|
||||
}
|
||||
|
||||
// NewPaymentSessionID returns a random UUIDv4-shaped idempotency id for a top-up.
|
||||
// The caller generates it once per top-up intent and reuses it on retry so the
|
||||
// backend (and Stripe) coalesce retries onto the same checkout instead of a
|
||||
// second charge.
|
||||
func NewPaymentSessionID() (string, error) {
|
||||
var b [16]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return "", err
|
||||
}
|
||||
b[6] = (b[6] & 0x0f) | 0x40 // version 4
|
||||
b[8] = (b[8] & 0x3f) | 0x80 // variant 10
|
||||
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]), nil
|
||||
}
|
||||
|
||||
func pollUntilPaid(ctx context.Context, client *Client, sessionToken string, onSession func(string)) (PartnerCheckoutSession, error) {
|
||||
deadline := time.Now().Add(pollTimeout)
|
||||
for time.Now().Before(deadline) {
|
||||
session, err := client.GetSession(ctx, sessionToken)
|
||||
if err != nil {
|
||||
// Context cancellation/deadline is terminal (also covers an in-flight
|
||||
// request cancelled mid-poll, whose transport error wraps ctx.Err()).
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return PartnerCheckoutSession{}, err
|
||||
}
|
||||
// A 4xx API response is terminal. Everything else — a 5xx or a transient
|
||||
// transport error (non-APIError, e.g. a dropped connection during the
|
||||
// wait) — is retried until the poll deadline.
|
||||
if isTerminalSessionAPIError(err) {
|
||||
reportSession(onSession, "")
|
||||
return PartnerCheckoutSession{}, err
|
||||
}
|
||||
if err := sleepContext(ctx, pollInterval); err != nil {
|
||||
return PartnerCheckoutSession{}, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
switch session.Status {
|
||||
case SessionStatusPaid, SessionStatusExchanging:
|
||||
return session, nil
|
||||
case SessionStatusExchanged:
|
||||
return PartnerCheckoutSession{}, fmt.Errorf("session was already exchanged; rotate the key from the aimlapi.com dashboard")
|
||||
case SessionStatusCancelled, SessionStatusExpired, SessionStatusFailed:
|
||||
// The checkout cannot recover from these states. Drop the retained token
|
||||
// (and the TUI's paired by-key idempotency ID) now so the very next retry
|
||||
// creates a fresh payment intent.
|
||||
reportSession(onSession, "")
|
||||
return PartnerCheckoutSession{}, fmt.Errorf("payment %s; re-run the top-up to try again", session.Status)
|
||||
default:
|
||||
if err := sleepContext(ctx, pollInterval); err != nil {
|
||||
return PartnerCheckoutSession{}, err
|
||||
}
|
||||
}
|
||||
}
|
||||
return PartnerCheckoutSession{}, fmt.Errorf("timed out waiting for payment; re-run once the payment clears")
|
||||
}
|
||||
|
||||
func isTerminalSessionAPIError(err error) bool {
|
||||
var apiErr APIError
|
||||
return errors.As(err, &apiErr) && apiErr.Status >= http.StatusBadRequest && apiErr.Status < http.StatusInternalServerError
|
||||
}
|
||||
|
||||
// pollUntilExchangeSettled follows an exchange claim that was already in flight
|
||||
// when a retry resumed the session. Exchange is single-flight and the raw key is
|
||||
// returned only to the winning request, so issuing Exchange again is never safe.
|
||||
// Once the claim completes, direct the user to the dashboard recovery path.
|
||||
func pollUntilExchangeSettled(ctx context.Context, client *Client, sessionToken string, onSession func(string)) error {
|
||||
deadline := time.Now().Add(pollTimeout)
|
||||
for time.Now().Before(deadline) {
|
||||
session, err := client.GetSession(ctx, sessionToken)
|
||||
if err != nil {
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return err
|
||||
}
|
||||
if isTerminalSessionAPIError(err) {
|
||||
reportSession(onSession, "")
|
||||
return err
|
||||
}
|
||||
if err := sleepContext(ctx, pollInterval); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
switch session.Status {
|
||||
case SessionStatusExchanged:
|
||||
return fmt.Errorf("session was already exchanged; rotate the key from the aimlapi.com dashboard")
|
||||
case SessionStatusExchanging:
|
||||
if err := sleepContext(ctx, pollInterval); err != nil {
|
||||
return err
|
||||
}
|
||||
case SessionStatusCancelled, SessionStatusExpired, SessionStatusFailed:
|
||||
reportSession(onSession, "")
|
||||
return fmt.Errorf("key provisioning %s; rotate the key from the aimlapi.com dashboard", session.Status)
|
||||
default:
|
||||
return fmt.Errorf("key provisioning returned to %s; re-run the top-up", session.Status)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("timed out waiting for key provisioning; retry to check the same session")
|
||||
}
|
||||
|
||||
func sleepContext(ctx context.Context, delay time.Duration) error {
|
||||
timer := time.NewTimer(delay)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func status(callback func(Status, string), value Status, detail string) {
|
||||
if callback != nil {
|
||||
callback(value, detail)
|
||||
}
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if value = strings.TrimSpace(value); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
package aimlapi
|
||||
|
||||
import (
|
||||
"net/mail"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ValidEmail performs the lightweight validation used before starting the
|
||||
// passwordless onboarding request.
|
||||
func ValidEmail(value string) bool {
|
||||
value = strings.TrimSpace(value)
|
||||
address, err := mail.ParseAddress(value)
|
||||
if err != nil || address.Address != value {
|
||||
return false
|
||||
}
|
||||
at := strings.LastIndexByte(value, '@')
|
||||
if at <= 0 || at == len(value)-1 {
|
||||
return false
|
||||
}
|
||||
domain := value[at+1:]
|
||||
if strings.HasPrefix(domain, ".") || strings.HasSuffix(domain, ".") || strings.Contains(domain, "..") {
|
||||
return false
|
||||
}
|
||||
dot := strings.LastIndexByte(domain, '.')
|
||||
if dot <= 0 || dot == len(domain)-1 {
|
||||
return false
|
||||
}
|
||||
tld := domain[dot+1:]
|
||||
if len(tld) < 2 {
|
||||
return false
|
||||
}
|
||||
for _, char := range tld {
|
||||
if char < 'A' || char > 'Z' && char < 'a' || char > 'z' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -3,10 +3,11 @@
|
||||
package background
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/execution"
|
||||
)
|
||||
|
||||
// terminationGracePeriod is how long a process has to exit after SIGTERM before
|
||||
@@ -21,7 +22,10 @@ var (
|
||||
// the negative PID (the group), so any process the child forks dies with it
|
||||
// instead of being orphaned. Must be called before cmd.Start.
|
||||
func ConfigureChildProcessGroup(cmd *exec.Cmd) {
|
||||
execution.ConfigureProcessGroup(cmd)
|
||||
if cmd.SysProcAttr == nil {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{}
|
||||
}
|
||||
cmd.SysProcAttr.Setpgid = true
|
||||
}
|
||||
|
||||
// terminateProcess stops a background process. It first asks politely with
|
||||
@@ -37,5 +41,71 @@ func ConfigureChildProcessGroup(cmd *exec.Cmd) {
|
||||
// back to signalling only the individual PID, which also avoids reporting a false
|
||||
// success when a non-leader group-signal returns ESRCH.
|
||||
func terminateProcess(pid int) error {
|
||||
return execution.TerminateProcessTree(pid, terminationGracePeriod, terminationPollInterval)
|
||||
// Guard pid <= 1: kill(-0) would target our OWN process group and kill(-1)
|
||||
// every process we can signal. A real child PID is always > 1, so never let a
|
||||
// bogus 0/1 expand into either.
|
||||
if pid <= 1 {
|
||||
return fmt.Errorf("refusing to terminate invalid pid %d", pid)
|
||||
}
|
||||
|
||||
// Signal the whole group only when pid leads its own group; otherwise the
|
||||
// individual process. See the doc comment for why a non-leader is signalled
|
||||
// directly rather than via its (foreign) group.
|
||||
target := pid
|
||||
if pgid, err := syscall.Getpgid(pid); err == nil {
|
||||
if pgid == pid {
|
||||
target = -pid
|
||||
}
|
||||
} else if processGoneError(err) {
|
||||
return nil // already gone
|
||||
}
|
||||
|
||||
alive := func() bool { return syscall.Kill(target, syscall.Signal(0)) == nil }
|
||||
|
||||
if err := syscall.Kill(target, syscall.SIGTERM); err != nil {
|
||||
if processGoneError(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Poll liveness so we return promptly once it exits, rather than always
|
||||
// waiting out the full grace period.
|
||||
deadline := time.Now().Add(terminationGracePeriod)
|
||||
for time.Now().Before(deadline) {
|
||||
if !alive() {
|
||||
return nil
|
||||
}
|
||||
time.Sleep(terminationPollInterval)
|
||||
}
|
||||
if !alive() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Still alive after the grace period: force-kill.
|
||||
if err := syscall.Kill(target, syscall.SIGKILL); err != nil && !processGoneError(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
// SIGKILL is asynchronous: the kernel may not have reaped it yet. Poll again
|
||||
// so this helper only reports success once the target is actually gone —
|
||||
// otherwise the caller gets nil while descendants are still racing.
|
||||
deadline = time.Now().Add(terminationGracePeriod)
|
||||
for time.Now().Before(deadline) {
|
||||
if !alive() {
|
||||
return nil
|
||||
}
|
||||
time.Sleep(terminationPollInterval)
|
||||
}
|
||||
if alive() {
|
||||
return fmt.Errorf("process %d did not exit after SIGKILL", pid)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// processGoneError reports whether an error means the process group has already
|
||||
// exited (so termination is effectively done). syscall.Kill reports ESRCH when
|
||||
// no process in the target group remains.
|
||||
func processGoneError(err error) bool {
|
||||
return errors.Is(err, syscall.ESRCH)
|
||||
}
|
||||
@@ -3,16 +3,23 @@
|
||||
package background
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/execution"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// ConfigureChildProcessGroup is a no-op on Windows: process-tree termination is
|
||||
// delegated to execution.TerminateProcessTree, so no launch-time process-group
|
||||
// ConfigureChildProcessGroup is a no-op on Windows: terminateProcess uses
|
||||
// `taskkill /T` to kill the whole process tree, so no launch-time process-group
|
||||
// setup is required (the POSIX build sets Setpgid here instead).
|
||||
func ConfigureChildProcessGroup(cmd *exec.Cmd) { execution.ConfigureProcessGroup(cmd) }
|
||||
func ConfigureChildProcessGroup(cmd *exec.Cmd) {}
|
||||
|
||||
func terminateProcess(pid int) error {
|
||||
return execution.TerminateProcessTree(pid, 0, 0)
|
||||
if err := exec.Command("taskkill", "/T", "/F", "/PID", strconv.Itoa(pid)).Run(); err == nil {
|
||||
return nil
|
||||
}
|
||||
process, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return process.Kill()
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
@@ -10,7 +9,6 @@ import (
|
||||
"github.com/Gitlawb/zero/internal/acp"
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
@@ -45,9 +43,6 @@ func runACP(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int
|
||||
conn := acp.NewConn(deps.stdin, stdout)
|
||||
acp.NewAgent(conn, acp.Deps{
|
||||
ResolveConfig: deps.resolveConfig,
|
||||
DiscoverModels: func(ctx context.Context, profile config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
|
||||
return defaultDiscoverProviderModels(ctx, discoveryCredentialProfile(profile))
|
||||
},
|
||||
// deps.newProvider is wrapped in fillAppDeps to apply the stored API key,
|
||||
// so ACP is authenticated for apiKeyStored profiles like every other
|
||||
// surface — no ACP-specific credential handling needed.
|
||||
|
||||
Loaded 100 of 731 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user