mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 17:56:14 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b549f5928f |
No files matched your search
@@ -1,11 +1,11 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Questions and setup help
|
||||
url: https://github.com/Twigpine/zero/discussions/categories/q-a
|
||||
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/Twigpine/zero/discussions/categories/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/Twigpine/zero/security/advisories/new
|
||||
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.
|
||||
@@ -5,7 +5,7 @@ body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
New feature ideas start in [Discussions Ideas](https://github.com/Twigpine/zero/discussions/categories/ideas),
|
||||
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
|
||||
|
||||
@@ -100,21 +100,19 @@ 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 the npm package named in package.json (@twigpine/zero): 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 @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.
|
||||
#
|
||||
# Auth is npm OIDC trusted publishing: no NPM_TOKEN secret. The package's
|
||||
# npmjs.com settings must list this repo + workflow file + the npm-publish
|
||||
# environment as a trusted publisher, and provenance is then generated
|
||||
# automatically. npm checks provenance against package.json `repository`,
|
||||
# so that must name this repository. Note npm only allows configuring a
|
||||
# trusted publisher on an EXISTING package, so a new package name starts
|
||||
# with a one-time manual `npm publish` of an empty 0.0.0 placeholder.
|
||||
# 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:
|
||||
@@ -127,7 +125,7 @@ jobs:
|
||||
# maintainer approves it in the Actions UI before anything reaches npm.
|
||||
environment:
|
||||
name: npm-publish
|
||||
url: https://www.npmjs.com/package/@twigpine/zero
|
||||
url: https://www.npmjs.com/package/@gitlawb/zero
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # OIDC token exchange for npm trusted publishing + provenance
|
||||
@@ -203,13 +201,47 @@ jobs:
|
||||
- name: Assemble npm packages
|
||||
run: node scripts/npm/build-platform-packages.mjs --artifacts-dir dist/artifacts --out-dir dist/npm
|
||||
|
||||
# Platform payloads first under the `platform` dist-tag, then the wrapper,
|
||||
# with the `latest` dist-tag checked before and after (platform versions
|
||||
# are semver PRERELEASES of the wrapper version and must never become
|
||||
# `latest`). npm applies publishes asynchronously, so the script waits
|
||||
# until npm shows every platform version before the wrapper goes out,
|
||||
# and until `latest` moves after it. Versions already on the registry
|
||||
# are skipped, so "Re-run failed jobs" is safe after a partial publish.
|
||||
- name: Publish npm packages
|
||||
# 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: scripts/npm/publish-packages.sh dist/npm
|
||||
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
|
||||
@@ -1 +1 @@
|
||||
{".":"0.12.0"}
|
||||
{".":"0.9.0"}
|
||||
@@ -6,94 +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.12.0](https://github.com/Twigpine/zero/compare/v0.11.0...v0.12.0) (2026-11-02)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **providers:** add MiniMax-M3.1-Flash to catalog ([#1180](https://github.com/Twigpine/zero/issues/1180)) ([d99f92b](https://github.com/Twigpine/zero/commit/d99f92b81a671f186cede81614c89b8e8a7f9451))
|
||||
* **sandbox:** support self-exec pattern to eliminate separate helper binary ([#946](https://github.com/Twigpine/zero/issues/946)) ([8c4e759](https://github.com/Twigpine/zero/commit/8c4e7597989ef9b3ca67b7703dfd15d92372aed2))
|
||||
* **skills:** install a skill's bundled files and point the skill tool at them ([#1167](https://github.com/Twigpine/zero/issues/1167)) ([1641506](https://github.com/Twigpine/zero/commit/164150633a8b2e6ae864ae02fd7105b201eeb68e))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **agent:** mark headless guardrail stops incomplete ([#1182](https://github.com/Twigpine/zero/issues/1182)) ([5aa54fe](https://github.com/Twigpine/zero/commit/5aa54fe1d2b419c9bbef73605a77919c9007cf7b))
|
||||
* bump Go to 1.26.10 for GO-2026-6809 ([#1168](https://github.com/Twigpine/zero/issues/1168)) ([6393a90](https://github.com/Twigpine/zero/commit/6393a90ed618a3536e09c02852f4ec829b97545b))
|
||||
* **chatgpt:** refresh subscription defaults and compatibility version ([#1179](https://github.com/Twigpine/zero/issues/1179)) ([f912f80](https://github.com/Twigpine/zero/commit/f912f80d002602e11ba0ef8548ecee58442874aa))
|
||||
* **deps:** refresh the lockfile to hono 4.13.13 ([#1171](https://github.com/Twigpine/zero/issues/1171)) ([de1443b](https://github.com/Twigpine/zero/commit/de1443b2edb44fd3747cc77200d54dc8cffcd0a7))
|
||||
* **oauth:** reuse the crash-safe secret store and sync encrypted publications ([#1165](https://github.com/Twigpine/zero/issues/1165)) ([84c1b15](https://github.com/Twigpine/zero/commit/84c1b1554076679a342e9cac61f8f8cdbaf3a429))
|
||||
* **redaction:** redact credentials whose trigger a greedy span swallowed ([#1158](https://github.com/Twigpine/zero/issues/1158)) ([a97db88](https://github.com/Twigpine/zero/commit/a97db8832fdba077f2d31bdf03ecef8d7c774fe1))
|
||||
* **sessions:** capture freeform apply_patch checkpoints ([#1183](https://github.com/Twigpine/zero/issues/1183)) ([6396208](https://github.com/Twigpine/zero/commit/63962081cf130c2171407936ce3beb7021d238b8))
|
||||
* **sessions:** preserve image attachment summaries on resume ([#1152](https://github.com/Twigpine/zero/issues/1152)) ([1e84849](https://github.com/Twigpine/zero/commit/1e848492e5c1919d187fb5e1eb5e7ef9bc6140dc)), closes [#1135](https://github.com/Twigpine/zero/issues/1135)
|
||||
* **tools:** grep stops where a file's line breaks stop ([#1160](https://github.com/Twigpine/zero/issues/1160)) ([aa9ba84](https://github.com/Twigpine/zero/commit/aa9ba8412eb8917daa2ca5cb3fdbd0a39276fa88))
|
||||
* **workspaceindex:** import gitsafe under the Twigpine module path ([#1153](https://github.com/Twigpine/zero/issues/1153)) ([ef4da13](https://github.com/Twigpine/zero/commit/ef4da1323f1097863ae278224b5f304c2fff909a))
|
||||
* **workspaceindex:** run Scan's git calls through gitsafe ([#1149](https://github.com/Twigpine/zero/issues/1149)) ([2ffe183](https://github.com/Twigpine/zero/commit/2ffe1831b0950b1463ec46d487d03a5fcb679cc6))
|
||||
|
||||
## [0.11.0](https://github.com/Twigpine/zero/compare/v0.10.0...v0.11.0) (2026-10-15)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **providers:** add Concentrate provider preset ([#1147](https://github.com/Twigpine/zero/issues/1147)) ([61b0936](https://github.com/Twigpine/zero/commit/61b09364f0f45b0fa8aebaf0a6c3d1c55dbe01c3))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **cli:** let providers subcommands run with no active provider ([#1143](https://github.com/Twigpine/zero/issues/1143)) ([d1a0d04](https://github.com/Twigpine/zero/commit/d1a0d048ee2c1e7cbfb75160ec4c9f099f3c4472))
|
||||
* **npm:** wait for npm to apply each publish, and drop the one-time @gitlawb/zero steps ([#1145](https://github.com/Twigpine/zero/issues/1145)) ([8e363fc](https://github.com/Twigpine/zero/commit/8e363fcaa5d51bf5b5b244692dc61bf8d4ae8799))
|
||||
* **tui,cli:** stop a repository's git config from running programs in Zero's own git calls ([#1133](https://github.com/Twigpine/zero/issues/1133)) ([640d936](https://github.com/Twigpine/zero/commit/640d936bfbe00c4cd3017ca35fb7b5c15d91a1c5))
|
||||
* **workspaceindex:** skip git-ignored and build-cache dirs in Scan only ([#1117](https://github.com/Twigpine/zero/issues/1117)) ([c88c77b](https://github.com/Twigpine/zero/commit/c88c77bf701039dca01d9786bf5648d779c949b3))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* **redaction:** add fast-path byte checks to eliminate regex allocations on clean strings ([#945](https://github.com/Twigpine/zero/issues/945)) ([ce2fcff](https://github.com/Twigpine/zero/commit/ce2fcffaf10d8e8c8c5a13e06fd7809f09fc9867))
|
||||
|
||||
## [0.10.0](https://github.com/Twigpine/zero/compare/v0.9.0...v0.10.0) (2026-10-15)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **notify:** make permission-prompt alerts on by default and discoverable ([#1001](https://github.com/Twigpine/zero/issues/1001)) ([7f8e5f0](https://github.com/Twigpine/zero/commit/7f8e5f08bb83371ce16cc54c97ab032a010026cf))
|
||||
* **npm:** publish as @twigpine/zero ([#1142](https://github.com/Twigpine/zero/issues/1142)) ([530563a](https://github.com/Twigpine/zero/commit/530563a0bb58d3cbadceb97b3ee234ef763124df))
|
||||
* **providers:** add GMI Cloud provider preset and partner listing ([#1127](https://github.com/Twigpine/zero/issues/1127)) ([1335ca6](https://github.com/Twigpine/zero/commit/1335ca6671023ecda81a3cfaae7e9f2ff5d6e0de))
|
||||
* **providers:** opt-in ZERO_RESPONSE_HEADER_TIMEOUT override for slow first-byte deployments ([#1106](https://github.com/Twigpine/zero/issues/1106)) ([ddc45f2](https://github.com/Twigpine/zero/commit/ddc45f2c8152fdd04b45dc11ed20d246fe7740a5))
|
||||
* **runtime:** add process-local per-model RPM admission limits ([#1116](https://github.com/Twigpine/zero/issues/1116)) ([eebb384](https://github.com/Twigpine/zero/commit/eebb384676fb567acc1f5a20dcbb17df8a03dc42))
|
||||
* **sessions:** add zero sessions prune, which never removes a session another process has open ([#1089](https://github.com/Twigpine/zero/issues/1089)) ([23c27e0](https://github.com/Twigpine/zero/commit/23c27e0778e3af54c04b8749d1eda846d3253f4e))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **acp:** persist the session permission mode across restarts ([#1134](https://github.com/Twigpine/zero/issues/1134)) ([bb96206](https://github.com/Twigpine/zero/commit/bb96206c2781e846fd13437113eec5790afe9c81)), closes [#1125](https://github.com/Twigpine/zero/issues/1125)
|
||||
* **acp:** persist token usage events for ACP sessions ([#1137](https://github.com/Twigpine/zero/issues/1137)) ([300cf62](https://github.com/Twigpine/zero/commit/300cf6275f8c94f06da3f00197d21cb8771842d9)), closes [#1118](https://github.com/Twigpine/zero/issues/1118)
|
||||
* **acp:** publish sub-run sessions loaded over ACP as view-only ([#1078](https://github.com/Twigpine/zero/issues/1078)) ([7f11491](https://github.com/Twigpine/zero/commit/7f11491078662b2d389c278f3ce5d41d956670c4))
|
||||
* **agent:** retry mid-stream transport aborts before tool dispatch ([#1076](https://github.com/Twigpine/zero/issues/1076)) ([b9ef1d2](https://github.com/Twigpine/zero/commit/b9ef1d271aa6ebc6d9adad2ef4ff0d695bb40da5))
|
||||
* **config:** sync atomic config replacements ([#1093](https://github.com/Twigpine/zero/issues/1093)) ([4d2e9f0](https://github.com/Twigpine/zero/commit/4d2e9f0d08472f7304652ef7896df587e1cc6c21))
|
||||
* **cron:** bound run history and read recent records from the tail ([#1094](https://github.com/Twigpine/zero/issues/1094)) ([42eb6ab](https://github.com/Twigpine/zero/commit/42eb6ab5fbe4ae9037f1f4cd42a00243c456a22e))
|
||||
* **cron:** preserve newer claims when overlapping runs complete ([#1090](https://github.com/Twigpine/zero/issues/1090)) ([2eb9d94](https://github.com/Twigpine/zero/commit/2eb9d94fac56d91d749ea4f96edcf1c87f79b48c))
|
||||
* **daemon:** bound pending sessions and prune on completion ([#1091](https://github.com/Twigpine/zero/issues/1091)) ([8a68660](https://github.com/Twigpine/zero/commit/8a68660161b04ce9668363faa14ff72c9fbad223))
|
||||
* **daemon:** do not retry workers that finished with a final exit code ([#1112](https://github.com/Twigpine/zero/issues/1112)) ([eefa978](https://github.com/Twigpine/zero/commit/eefa978f6183332b7416c70c25242bcdfba1a75c)), closes [#1100](https://github.com/Twigpine/zero/issues/1100)
|
||||
* **daemon:** stop worker process groups by their launch-time identity ([#1088](https://github.com/Twigpine/zero/issues/1088)) ([ab018b3](https://github.com/Twigpine/zero/commit/ab018b3098b5cb103cf8f90b8698d32a6687bdf7)), closes [#861](https://github.com/Twigpine/zero/issues/861)
|
||||
* **deps:** refresh the lockfile to patched hono and @hono/node-server ([#1080](https://github.com/Twigpine/zero/issues/1080)) ([aa52040](https://github.com/Twigpine/zero/commit/aa52040498e012c41d5e8f6027a91c18eff861d4))
|
||||
* **deps:** refresh the lockfile to patched shell-quote ([#1131](https://github.com/Twigpine/zero/issues/1131)) ([c56a4c4](https://github.com/Twigpine/zero/commit/c56a4c4e6e598504ab417e8cc4f5ab8d69f1b42f))
|
||||
* **execution:** reserve process capacity before launching ([#1092](https://github.com/Twigpine/zero/issues/1092)) ([bb3346a](https://github.com/Twigpine/zero/commit/bb3346a82883c19ed8815fe1e874f54950711d69))
|
||||
* **mcp:** forward image tool results on the existing image channel ([#1077](https://github.com/Twigpine/zero/issues/1077)) ([0324c1d](https://github.com/Twigpine/zero/commit/0324c1d4103328eb90cb24a0b582dba335255b5c))
|
||||
* **mcp:** refresh OAuth tokens for discovered endpoints and registered clients ([#1113](https://github.com/Twigpine/zero/issues/1113)) ([571af70](https://github.com/Twigpine/zero/commit/571af701bd3d8dfaf40f87b5b112b25e7def5b29)), closes [#1101](https://github.com/Twigpine/zero/issues/1101)
|
||||
* **oauth:** give every loopback callback server the bounded shutdown ([#1079](https://github.com/Twigpine/zero/issues/1079)) ([5473301](https://github.com/Twigpine/zero/commit/547330197b20ece591e077e876a4b8ffd015cb6e))
|
||||
* **providers:** send stable session headers to OpenCode ([#1060](https://github.com/Twigpine/zero/issues/1060)) ([99721c7](https://github.com/Twigpine/zero/commit/99721c762f37cd43ac511007a5f51d1846df959e))
|
||||
* **redaction:** redact adjacent AWS keys glued end to end ([#1095](https://github.com/Twigpine/zero/issues/1095)) ([df67ede](https://github.com/Twigpine/zero/commit/df67edec13345b73a85327adc003cacc7bdd8009))
|
||||
* **redaction:** redact credentials split by an invisible character ([#1067](https://github.com/Twigpine/zero/issues/1067)) ([e20b762](https://github.com/Twigpine/zero/commit/e20b7624261cef8336a8eb0fbdd44d536ad77fc2))
|
||||
* **sandbox:** say when a session's sandbox enforcement is degraded ([#1082](https://github.com/Twigpine/zero/issues/1082)) ([6b5852d](https://github.com/Twigpine/zero/commit/6b5852dc18bb256af1f25745697c2f2e3ea6f112))
|
||||
* **search:** redact every text field of session metadata, not ten of thirty ([#1073](https://github.com/Twigpine/zero/issues/1073)) ([9520f2b](https://github.com/Twigpine/zero/commit/9520f2bad7e09ce4a8d0d42be7ce77d2ae928ddd))
|
||||
* **security:** harden shell flag parsing, write roots, and formatters ([#1066](https://github.com/Twigpine/zero/issues/1066)) ([ac5fe0d](https://github.com/Twigpine/zero/commit/ac5fe0d21849b0f74af428dd3ca12e3b80a8b7aa))
|
||||
* **sessions:** keep a session lease for the life of the process, not of the Store ([#1144](https://github.com/Twigpine/zero/issues/1144)) ([883e5be](https://github.com/Twigpine/zero/commit/883e5bec24dac791d1d60618c7f4927f4659b687))
|
||||
* **tools:** hint when Windows resolves POSIX paths under root ([#1063](https://github.com/Twigpine/zero/issues/1063)) ([0c6f179](https://github.com/Twigpine/zero/commit/0c6f179a8443d4dfdfbbefe5cc6caf11c2a525f0))
|
||||
* **tui:** bound live dictation regions ([#985](https://github.com/Twigpine/zero/issues/985)) ([feb9927](https://github.com/Twigpine/zero/commit/feb992757c582c68f0397e070e206d4bf07071e1))
|
||||
* **tui:** show provider ownership in Recent and Favorites ([#1083](https://github.com/Twigpine/zero/issues/1083)) ([1697b7a](https://github.com/Twigpine/zero/commit/1697b7a5ce948e781877cdbe9223adac62c3fde6))
|
||||
* **tui:** stop held peer message Allow/Deny from deadlocking Update ([#1111](https://github.com/Twigpine/zero/issues/1111)) ([a195e88](https://github.com/Twigpine/zero/commit/a195e8848cec1a27b4fe1789ecc572b943c32186)), closes [#1099](https://github.com/Twigpine/zero/issues/1099)
|
||||
* unbreak main CI (cron claim tests, Go 1.26.9) ([#1124](https://github.com/Twigpine/zero/issues/1124)) ([2c418fc](https://github.com/Twigpine/zero/commit/2c418fc8ff6763de16703128f372fe526fddd331))
|
||||
|
||||
## [0.9.0](https://github.com/Gitlawb/zero/compare/v0.8.0...v0.9.0) (2026-09-15)
|
||||
|
||||
|
||||
|
||||
+5
-5
@@ -9,9 +9,9 @@ reports, feature requests, reproduction details, logs, and discussion on existin
|
||||
issues.
|
||||
|
||||
Questions and setup help belong in
|
||||
[GitHub Discussions](https://github.com/Twigpine/zero/discussions) (see
|
||||
[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/Twigpine/zero/discussions/categories/ideas) category;
|
||||
[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.
|
||||
|
||||
@@ -41,7 +41,7 @@ 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/Twigpine/zero/discussions/categories/ideas),
|
||||
[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.
|
||||
@@ -193,7 +193,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/Twigpine/zero/discussions/categories/ideas),
|
||||
[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
|
||||
@@ -222,7 +222,7 @@ contribution phase.
|
||||
|
||||
General questions, setup help, and "is this a bug or my config" uncertainty all
|
||||
go to
|
||||
[Discussions Q&A](https://github.com/Twigpine/zero/discussions/categories/q-a).
|
||||
[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.
|
||||
@@ -12,10 +12,9 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a>
|
||||
<img alt="Go 1.26.10+" src="https://img.shields.io/badge/Go-1.26.10+-00ADD8?logo=go&logoColor=white">
|
||||
<img alt="Go 1.26.9+" src="https://img.shields.io/badge/Go-1.26.9+-00ADD8?logo=go&logoColor=white">
|
||||
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
|
||||
<a href="https://discord.gg/CaQDS6wdFn"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white"></a>
|
||||
<a href="https://vercel.com/open-source-program"><img alt="Vercel OSS Program" src="https://vercel.com/oss/program-badge-2026.svg"></a>
|
||||
<br>
|
||||
<strong>English</strong> | <a href="README_ZH.md">中文</a>
|
||||
</p>
|
||||
@@ -88,18 +87,9 @@ zero exec --output-format stream-json < turns.jsonl
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="150" height="80">
|
||||
<a href="https://vercel.com/">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/vercel-logo-dark.svg">
|
||||
<img src="docs/assets/vercel-logo.svg" alt="Vercel logo" width="120">
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><a href="https://www.gmicloud.ai/"><strong>GMI Cloud</strong></a></td>
|
||||
<td align="center"><a href="https://vercel.com/"><strong>Vercel</strong></a></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
@@ -126,7 +116,7 @@ zero exec --output-format stream-json < turns.jsonl
|
||||
### npm
|
||||
|
||||
```bash
|
||||
npm install -g @twigpine/zero
|
||||
npm install -g @gitlawb/zero
|
||||
zero
|
||||
```
|
||||
|
||||
@@ -140,38 +130,26 @@ 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.
|
||||
|
||||
Installed Zero as `@gitlawb/zero`? The package moved to `@twigpine/zero` in
|
||||
0.10.0 and the old name gets no further releases. Both packages provide the
|
||||
`zero` command, so quit any running `zero`, then remove the old package first:
|
||||
|
||||
```bash
|
||||
npm uninstall -g @gitlawb/zero
|
||||
npm install -g @twigpine/zero
|
||||
```
|
||||
|
||||
Your config, sessions, and logins are kept; they live outside the package. For
|
||||
Bun, pnpm, and yarn, see [docs/INSTALL.md](docs/INSTALL.md#moving-from-gitlawbzero).
|
||||
|
||||
### Install scripts
|
||||
|
||||
Linux/macOS:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Twigpine/zero/main/scripts/install.sh | bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.sh | bash
|
||||
```
|
||||
|
||||
Windows PowerShell:
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/Twigpine/zero/main/scripts/install.ps1 | iex
|
||||
irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
### From source
|
||||
|
||||
Source builds require Go 1.26.10+.
|
||||
Source builds require Go 1.26.9+.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Twigpine/zero.git
|
||||
git clone https://github.com/Gitlawb/zero.git
|
||||
cd zero
|
||||
go run ./cmd/zero
|
||||
```
|
||||
@@ -183,18 +161,17 @@ If you are testing before the first public release, build from source:
|
||||
go build -o zero ./cmd/zero
|
||||
```
|
||||
|
||||
On Linux, native sandboxing needs Bubblewrap. The main `zero` binary re-executes
|
||||
itself as the helper when `zero-linux-sandbox` is absent. A separate helper next
|
||||
to `zero` or on `PATH` still takes precedence:
|
||||
On Linux, build the sandbox helper too if you want native sandboxing:
|
||||
|
||||
```bash
|
||||
go build -o zero-linux-sandbox ./cmd/zero-linux-sandbox # optional
|
||||
go build -o zero-seccomp ./cmd/zero-seccomp # optional compatibility wrapper
|
||||
go build -o zero-linux-sandbox ./cmd/zero-linux-sandbox
|
||||
go build -o zero-seccomp ./cmd/zero-seccomp # optional compatibility wrapper
|
||||
```
|
||||
|
||||
Put `zero` on `PATH` (`~/.local/bin` is a good default). macOS does not need an
|
||||
extra helper binary. Windows source builds can use the main `zero.exe` as their
|
||||
sandbox helper; release archives still ship standalone Windows helper executables.
|
||||
Put `zero` and `zero-linux-sandbox` in the same directory on `PATH`
|
||||
(`~/.local/bin` is a good default). macOS does not need an extra helper binary.
|
||||
Windows source builds can use the main `zero.exe` as their sandbox helper; release
|
||||
archives still ship standalone Windows helper executables.
|
||||
|
||||
More install details: [docs/INSTALL.md](docs/INSTALL.md).
|
||||
|
||||
@@ -227,7 +204,6 @@ export AIMLAPI_API_KEY=...
|
||||
export LONGCAT_API_KEY=...
|
||||
export FIREWORKS_API_KEY=...
|
||||
export GMI_API_KEY=...
|
||||
export CONCENTRATE_API_KEY=...
|
||||
export MINIMAX_API_KEY=...
|
||||
export MINIMAXI_API_KEY=...
|
||||
```
|
||||
@@ -256,12 +232,6 @@ To configure GMI Cloud directly, run:
|
||||
zero providers setup gmicloud --set-active
|
||||
```
|
||||
|
||||
To configure Concentrate directly, run:
|
||||
|
||||
```bash
|
||||
zero providers setup concentrate --set-active
|
||||
```
|
||||
|
||||
MiniMax presets use the Anthropic-compatible endpoints for the global and China
|
||||
regions:
|
||||
|
||||
@@ -397,76 +367,6 @@ and can rewrite every request through it, so this is the trust you accept by
|
||||
configuring one. Leave the variables unset for the checks to be enforced end to
|
||||
end.
|
||||
|
||||
The built-in `web_search` tool is registered only when a search backend is
|
||||
configured: `ZERO_WEBSEARCH_BASE_URL` (with an optional `ZERO_WEBSEARCH_API_KEY`)
|
||||
for a generic JSON endpoint, `ZERO_WEBSEARCH_PROVIDER=searxng` with a SearXNG
|
||||
base URL, or the AI/ML API Toolbox:
|
||||
|
||||
```bash
|
||||
export ZERO_WEBSEARCH_PROVIDER=aimlapi
|
||||
```
|
||||
|
||||
The AI/ML API backend runs the Toolbox tool `web/search`, and every search is
|
||||
billed to your AI/ML API balance: about $0.0013 per result, with a documented
|
||||
minimum of $0.0091 per search. Each search is sent with a $0.025 spending cap,
|
||||
which the Toolbox enforces. It is used only when
|
||||
`ZERO_WEBSEARCH_PROVIDER` is set to `aimlapi`; an AI/ML API key alone never
|
||||
enables it. The key is read from your user configuration only; provider
|
||||
profiles in a project `.zero` config are not used. It is the aimlapi profile's
|
||||
key (the active profile when it is aimlapi, otherwise the first aimlapi
|
||||
profile), including a key in the credential store, or `AIMLAPI_API_KEY` when no
|
||||
aimlapi profile exists. It is resolved again on every search;
|
||||
`ZERO_WEBSEARCH_API_KEY` is not used. When any aimlapi profile, or any profile
|
||||
that reads `AIMLAPI_API_KEY`, points at a base URL other than
|
||||
`https://api.aimlapi.com/v1`, or `AIMLAPI_INFERENCE_URL` is overridden, the key
|
||||
may belong to a proxy, so it is not sent and the search fails without a request.
|
||||
Requests go only to
|
||||
`https://tools.aimlapi.com` (`ZERO_WEBSEARCH_BASE_URL` is ignored and redirects
|
||||
are refused) and carry `X-AIMLAPI-Source: agent/zero` and an
|
||||
`X-AIMLAPI-Partner-ID` header. The `domains` argument is sent to the Toolbox as
|
||||
`include_domains`, and results are still filtered locally.
|
||||
|
||||
### AI/ML API Toolbox backend for `web_fetch`
|
||||
|
||||
`web_fetch` can fetch pages through the AI/ML API Toolbox tool
|
||||
`search1api/crawl` instead of a direct HTTP GET. It is off unless you choose it:
|
||||
|
||||
```bash
|
||||
export ZERO_WEBFETCH_PROVIDER=aimlapi
|
||||
```
|
||||
|
||||
Having an AI/ML API key never switches it on by itself, and any other value (or
|
||||
none) keeps the direct fetch. The variable is read on every fetch.
|
||||
|
||||
- **Billing:** Toolbox runs are billed to your AI/ML API balance, about
|
||||
$0.0013 per run. Each run is sent with `max_cost_usd` 0.005, so a run
|
||||
priced above that is refused before it starts and not charged. Rate-limited
|
||||
or gateway-failed requests are retried with the same idempotency key, so a
|
||||
retry is never billed twice.
|
||||
- **Key:** the key comes from your `aimlapi` provider profile (including a key
|
||||
in the credential store), or from `AIMLAPI_API_KEY` when there is no such
|
||||
profile. It is withheld, and `web_fetch` returns an error, when any `aimlapi`
|
||||
profile or any profile using `AIMLAPI_API_KEY` points at a base URL other than
|
||||
`https://api.aimlapi.com/v1`, or when `AIMLAPI_INFERENCE_URL` is overridden:
|
||||
the key may then belong to a proxy.
|
||||
- **Destination:** requests go only to the fixed `https://tools.aimlapi.com`
|
||||
endpoint, with `Authorization: Bearer <key>` and the attribution headers
|
||||
`X-AIMLAPI-Source: agent/zero` and `X-AIMLAPI-Partner-ID`. Redirects are
|
||||
refused. `HTTP_PROXY`/`HTTPS_PROXY` apply to these requests as to Zero's other
|
||||
aimlapi.com calls.
|
||||
- **No fallback:** without a usable key, or when the run fails, `web_fetch`
|
||||
returns an error and does not fetch the page directly. Unset the variable to
|
||||
go back to direct fetches.
|
||||
- **Remote fetch:** the page is fetched by a remote service, which may serve a
|
||||
cached copy, and the full URL (query string included) is sent to it. The URL
|
||||
checks above still run first, so loopback, private and metadata URLs are
|
||||
never handed to it. The result is Markdown with a
|
||||
`Backend: aimlapi (search1api/crawl)` line; `max_bytes` cuts the Markdown,
|
||||
and `format: "raw"` is refused because the original response is not
|
||||
available. The complete Toolbox run response is limited to 8 MiB before JSON
|
||||
decoding; larger responses return an error even if `max_bytes` is smaller.
|
||||
The remote run may already have been billed when this limit is detected.
|
||||
|
||||
For local dev servers, use shell commands such as `curl` through `exec_command`
|
||||
so the normal sandbox and permission policy applies. Long-running commands stay
|
||||
attached to a background terminal session and can be listed or stopped from the
|
||||
@@ -626,19 +526,19 @@ go run ./cmd/zero-release build --goos windows --goarch amd64 --output dist/zero
|
||||
Real-time chat happens on the [Discord server](https://discord.gg/CaQDS6wdFn).
|
||||
|
||||
Questions, setup help, ideas, and sharing all live in
|
||||
[GitHub Discussions](https://github.com/Twigpine/zero/discussions):
|
||||
[GitHub Discussions](https://github.com/Gitlawb/zero/discussions):
|
||||
|
||||
| Category | Use it for |
|
||||
|---|---|
|
||||
| [Q&A](https://github.com/Twigpine/zero/discussions/categories/q-a) | Setup help, provider/model configuration, "how do I" questions |
|
||||
| [Ideas](https://github.com/Twigpine/zero/discussions/categories/ideas) | Feature proposals and design discussion before any PR |
|
||||
| [Show and tell](https://github.com/Twigpine/zero/discussions/categories/show-and-tell) | Your skills, plugins, MCP setups, themes, and workflows |
|
||||
| [Announcements](https://github.com/Twigpine/zero/discussions/categories/announcements) | Releases and project news from the maintainers |
|
||||
| [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/Twigpine/zero/issues/new/choose); security reports
|
||||
[issues](https://github.com/Gitlawb/zero/issues/new/choose); security reports
|
||||
follow [SECURITY.md](SECURITY.md), never a public thread.
|
||||
|
||||
## Contributing
|
||||
|
||||
+15
-41
@@ -12,10 +12,9 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a>
|
||||
<img alt="Go 1.26.10+" src="https://img.shields.io/badge/Go-1.26.10+-00ADD8?logo=go&logoColor=white">
|
||||
<img alt="Go 1.26.9+" src="https://img.shields.io/badge/Go-1.26.9+-00ADD8?logo=go&logoColor=white">
|
||||
<img alt="25+ providers" src="https://img.shields.io/badge/providers-25+-34E2EA">
|
||||
<a href="https://discord.gg/CaQDS6wdFn"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white"></a>
|
||||
<a href="https://vercel.com/open-source-program"><img alt="Vercel OSS Program" src="https://vercel.com/oss/program-badge-2026.svg"></a>
|
||||
<br>
|
||||
<a href="README.md">English</a> | <strong>中文</strong>
|
||||
</p>
|
||||
@@ -86,18 +85,9 @@ zero exec --output-format stream-json < turns.jsonl
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" width="150" height="80">
|
||||
<a href="https://vercel.com/">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/vercel-logo-dark.svg">
|
||||
<img src="docs/assets/vercel-logo.svg" alt="Vercel logo" width="120">
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><a href="https://www.gmicloud.ai/"><strong>GMI Cloud</strong></a></td>
|
||||
<td align="center"><a href="https://vercel.com/"><strong>Vercel</strong></a></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
@@ -115,41 +105,32 @@ zero exec --output-format stream-json < turns.jsonl
|
||||
### npm
|
||||
|
||||
```bash
|
||||
npm install -g @twigpine/zero
|
||||
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)。
|
||||
|
||||
之前通过 `@gitlawb/zero` 安装过 Zero?从 0.10.0 起,npm 包已迁移到 `@twigpine/zero`,旧包名不再发布新版本。两个包都提供 `zero` 命令,所以请先退出正在运行的 `zero`,再卸载旧包:
|
||||
|
||||
```bash
|
||||
npm uninstall -g @gitlawb/zero
|
||||
npm install -g @twigpine/zero
|
||||
```
|
||||
|
||||
配置、会话和登录信息都会保留,它们存放在包之外。Bun、pnpm 和 yarn 的命令见 [docs/INSTALL.md](docs/INSTALL.md#moving-from-gitlawbzero)。
|
||||
|
||||
### 安装脚本
|
||||
|
||||
Linux/macOS:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Twigpine/zero/main/scripts/install.sh | bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.sh | bash
|
||||
```
|
||||
|
||||
Windows PowerShell:
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/Twigpine/zero/main/scripts/install.ps1 | iex
|
||||
irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
### 从源码构建
|
||||
|
||||
源码构建需要 Go 1.26.10+。
|
||||
源码构建需要 Go 1.26.9+。
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Twigpine/zero.git
|
||||
git clone https://github.com/Gitlawb/zero.git
|
||||
cd zero
|
||||
go run ./cmd/zero
|
||||
```
|
||||
@@ -160,14 +141,14 @@ go run ./cmd/zero
|
||||
go build -o zero ./cmd/zero
|
||||
```
|
||||
|
||||
在 Linux 上,原生沙箱需要 Bubblewrap。主程序 `zero` 在找不到 `zero-linux-sandbox` 时会以自身作为辅助进程重新执行。同目录或 PATH 上的独立 helper 仍优先:
|
||||
在 Linux 上,如果你需要原生沙箱,还需要构建沙箱辅助程序:
|
||||
|
||||
```bash
|
||||
go build -o zero-linux-sandbox ./cmd/zero-linux-sandbox # 可选
|
||||
go build -o zero-seccomp ./cmd/zero-seccomp # 可选的兼容性包装器
|
||||
go build -o zero-linux-sandbox ./cmd/zero-linux-sandbox
|
||||
go build -o zero-seccomp ./cmd/zero-seccomp # 可选的兼容性包装器
|
||||
```
|
||||
|
||||
将 `zero` 放在 `PATH` 上(`~/.local/bin` 是一个好的默认选择)。macOS 不需要额外的辅助二进制文件。Windows 源码构建可以使用主 `zero.exe` 作为沙箱辅助程序;发布包仍然附带独立的 Windows 辅助可执行文件。
|
||||
将 `zero` 和 `zero-linux-sandbox` 放在 `PATH` 上的同一目录中(`~/.local/bin` 是一个好的默认选择)。macOS 不需要额外的辅助二进制文件。Windows 源码构建可以使用主 `zero.exe` 作为沙箱辅助程序;发布包仍然附带独立的 Windows 辅助可执行文件。
|
||||
|
||||
更多安装细节:[docs/INSTALL.md](docs/INSTALL.md)。
|
||||
|
||||
@@ -196,7 +177,6 @@ export ANTHROPIC_API_KEY=...
|
||||
export GEMINI_API_KEY=...
|
||||
export LONGCAT_API_KEY=...
|
||||
export GMI_API_KEY=...
|
||||
export CONCENTRATE_API_KEY=...
|
||||
```
|
||||
|
||||
要直接配置美团 LongCat(LongCat-2.0),运行:
|
||||
@@ -211,12 +191,6 @@ zero providers setup longcat --set-active
|
||||
zero providers setup gmicloud --set-active
|
||||
```
|
||||
|
||||
要直接配置 Concentrate,运行:
|
||||
|
||||
```bash
|
||||
zero providers setup concentrate --set-active
|
||||
```
|
||||
|
||||
对于本地模型,运行 Ollama、LM Studio 或 [Atomic Chat](https://atomic.chat) 桌面应用,
|
||||
然后使用 `zero setup` 或 `zero providers detect`。使用 Atomic Chat 时,请先加载模型并启用
|
||||
本地 OpenAI 兼容 API(默认地址为 `http://127.0.0.1:1337/v1`),再选择 `atomic-chat-local`。
|
||||
@@ -384,14 +358,14 @@ go run ./cmd/zero-release build --goos windows --goarch amd64 --output dist/zero
|
||||
实时交流在 [Discord 服务器](https://discord.gg/CaQDS6wdFn) 进行。
|
||||
|
||||
提问、安装帮助、想法和分享都在
|
||||
[GitHub Discussions](https://github.com/Twigpine/zero/discussions):
|
||||
[GitHub Discussions](https://github.com/Gitlawb/zero/discussions):
|
||||
|
||||
| 分类 | 用途 |
|
||||
|---|---|
|
||||
| [Q&A](https://github.com/Twigpine/zero/discussions/categories/q-a) | 安装帮助、提供商/模型配置、"如何做"类问题 |
|
||||
| [Ideas](https://github.com/Twigpine/zero/discussions/categories/ideas) | 功能提议和 PR 之前的设计讨论 |
|
||||
| [Show and tell](https://github.com/Twigpine/zero/discussions/categories/show-and-tell) | 你的技能、插件、MCP 配置、主题和工作流 |
|
||||
| [Announcements](https://github.com/Twigpine/zero/discussions/categories/announcements) | 维护者发布的版本和项目动态 |
|
||||
| [Q&A](https://github.com/Gitlawb/zero/discussions/categories/q-a) | 安装帮助、提供商/模型配置、"如何做"类问题 |
|
||||
| [Ideas](https://github.com/Gitlawb/zero/discussions/categories/ideas) | 功能提议和 PR 之前的设计讨论 |
|
||||
| [Show and tell](https://github.com/Gitlawb/zero/discussions/categories/show-and-tell) | 你的技能、插件、MCP 配置、主题和工作流 |
|
||||
| [Announcements](https://github.com/Gitlawb/zero/discussions/categories/announcements) | 维护者发布的版本和项目动态 |
|
||||
|
||||
## 贡献
|
||||
|
||||
|
||||
+1
-13
@@ -33,16 +33,4 @@ Please include, where possible:
|
||||
depend on a user explicitly disabling the sandbox (`--skip-permissions-unsafe`), are generally
|
||||
out of scope — but please report anything you are unsure about.
|
||||
- Zero sends no telemetry. Network calls go to the model/tool providers you configure (and, for
|
||||
keyless web search, to Exa's hosted MCP endpoint at `https://mcp.exa.ai/mcp`). The built-in
|
||||
AI/ML API MCP server (`https://mcp.aimlapi.com/mcp`) is off until you run
|
||||
`zero mcp enable aimlapi`. Once enabled, the Bearer key follows the aimlapi
|
||||
profile resolution order (inline key, then the profile's `apiKeyEnv` — default
|
||||
`AIMLAPI_API_KEY` — then the credential store), or `AIMLAPI_API_KEY` alone when
|
||||
no aimlapi profile exists; a non-empty `AIMLAPI_API_KEY` can override a stored
|
||||
profile key for MCP billing. The automatic key is withheld if an aimlapi profile
|
||||
(or a profile reading `AIMLAPI_API_KEY`) uses a base URL other than
|
||||
`https://api.aimlapi.com/v1`, `AIMLAPI_INFERENCE_URL` points elsewhere, a
|
||||
non-empty user Authorization header is configured, OAuth is selected, project
|
||||
config has touched the server, or the key is missing or not a single token
|
||||
(including Unicode whitespace). See
|
||||
[docs/EXTENDING.md](docs/EXTENDING.md#built-in-servers).
|
||||
keyless web search, to Exa's hosted MCP endpoint at `https://mcp.exa.ai/mcp`).
|
||||
+5
-5
@@ -2,8 +2,8 @@
|
||||
|
||||
## Questions, setup help, configuration
|
||||
|
||||
Ask in [GitHub Discussions](https://github.com/Twigpine/zero/discussions). Use
|
||||
[Q&A](https://github.com/Twigpine/zero/discussions/categories/q-a) so answers can
|
||||
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:
|
||||
@@ -18,19 +18,19 @@ The fastest way to a useful answer is to include:
|
||||
## Bugs
|
||||
|
||||
If Zero does something wrong (crashes, wrong output, broken behavior), file a
|
||||
[bug report](https://github.com/Twigpine/zero/issues/new/choose). If you are not
|
||||
[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/Twigpine/zero/discussions/categories/ideas). Zero is
|
||||
[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/Twigpine/zero/security/advisories/new)
|
||||
[GitHub Security Advisories](https://github.com/Gitlawb/zero/security/advisories/new)
|
||||
(see SECURITY.md).
|
||||
+2
-2
@@ -2,7 +2,7 @@ name: ZERO
|
||||
description: >-
|
||||
Run the ZERO coding agent headlessly in a GitHub workflow. ZERO is
|
||||
model- and provider-agnostic: you supply the provider and API key as inputs.
|
||||
author: Twigpine
|
||||
author: Gitlawb
|
||||
|
||||
branding:
|
||||
icon: terminal
|
||||
@@ -110,7 +110,7 @@ inputs:
|
||||
zero-repo:
|
||||
description: Repository to install the ZERO release from (owner/repo).
|
||||
required: false
|
||||
default: Twigpine/zero
|
||||
default: Gitlawb/zero
|
||||
|
||||
outputs:
|
||||
exit-code:
|
||||
|
||||
+13
-63
@@ -1,63 +1,11 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { existsSync, readFileSync, writeSync } from 'node:fs';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
|
||||
const CURRENT_PACKAGE_NAME = '@twigpine/zero';
|
||||
// The npm package moved from @gitlawb/zero to @twigpine/zero in 0.10.0. That
|
||||
// release was also published once under the old name, so 0.9.0's `zero
|
||||
// upgrade` and anyone still installing the old name land on a working build
|
||||
// that says where the package went (docs/NPM_PACKAGING.md). Nothing is
|
||||
// published under the old name after that.
|
||||
const LEGACY_PACKAGE_NAME = '@gitlawb/zero';
|
||||
|
||||
// The npm name this wrapper was published under, read from its own
|
||||
// package.json so the name lives in one place. The platform payload alias and
|
||||
// every reinstall hint below derive from it.
|
||||
function readPackageName(root) {
|
||||
try {
|
||||
const { name } = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
|
||||
if (typeof name === 'string' && name !== '') return name;
|
||||
} catch {
|
||||
// An unreadable manifest must not stop the CLI from launching.
|
||||
}
|
||||
return CURRENT_PACKAGE_NAME;
|
||||
}
|
||||
|
||||
const packageName = readPackageName(packageRoot);
|
||||
|
||||
// Same note as legacyPackageSwitchMessage in internal/update/installmethod.go.
|
||||
// Uninstalling first is the only safe order: both packages own the `zero` bin,
|
||||
// so npm refuses to install the new one over the old (EEXIST), and forcing it
|
||||
// lets a later uninstall of the old package delete the shared bin.
|
||||
function legacyPackageNotice() {
|
||||
return (
|
||||
`[zero] Zero has moved to ${CURRENT_PACKAGE_NAME} on npm, and ${LEGACY_PACKAGE_NAME} gets no further releases.\n` +
|
||||
'To keep getting updates, quit zero and run:\n' +
|
||||
` npm uninstall -g ${LEGACY_PACKAGE_NAME}\n` +
|
||||
` npm install -g ${CURRENT_PACKAGE_NAME}\n` +
|
||||
'Uninstall first: both packages provide the `zero` command. With bun, pnpm or yarn, use its global remove and add commands instead.\n'
|
||||
);
|
||||
}
|
||||
|
||||
// Printed when the wrapper exits rather than before zero starts, because the
|
||||
// TUI takes over the screen and would hide anything printed first. Only on a
|
||||
// terminal, so scripts that capture stderr see nothing new. `zero update` and
|
||||
// `zero upgrade` print the same note themselves.
|
||||
if (packageName === LEGACY_PACKAGE_NAME && !['update', 'upgrade'].includes(process.argv[2])) {
|
||||
process.on('exit', () => {
|
||||
if (process.stderr.isTTY || process.env.ZERO_WRAPPER_SIMULATE_TTY === '1') {
|
||||
// Synchronous: an exit handler cannot wait for an asynchronous write.
|
||||
writeSync(2, legacyPackageNotice());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function zeroBinaryName(platform = process.platform) {
|
||||
return platform === 'win32' ? 'zero.exe' : 'zero';
|
||||
}
|
||||
@@ -133,10 +81,10 @@ 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 ${packageName} (project install)\n` +
|
||||
` bun pm -g trust ${packageName} (global install)\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": ["${packageName}"]\n` +
|
||||
' "trustedDependencies": ["@gitlawb/zero"]\n' +
|
||||
'to your project package.json and reinstall.\n' +
|
||||
'\n'
|
||||
);
|
||||
@@ -145,14 +93,14 @@ function bunRecoveryParagraph() {
|
||||
function buildFromSourceParagraph() {
|
||||
return (
|
||||
'If this platform has no prebuilt binary, build from source:\n' +
|
||||
'https://github.com/Twigpine/zero (go run ./cmd/zero, requires Go 1.26+).'
|
||||
'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 ${packageName}\n` +
|
||||
`(${packageName}-${process.platform}-${process.arch}), and the wrapper can\n` +
|
||||
'Normally npm installs it as an optional dependency of @gitlawb/zero\n' +
|
||||
`(@gitlawb/zero-${process.platform}-${process.arch}), and the wrapper can\n` +
|
||||
'also download it from the GitHub Release when it is missing.'
|
||||
);
|
||||
}
|
||||
@@ -165,7 +113,7 @@ function missingNativeRecoveryParagraphs(postinstallScript) {
|
||||
'\n' +
|
||||
'Things to try:\n' +
|
||||
' - reinstall without omitting optional dependencies:\n' +
|
||||
` npm install -g ${packageName}\n` +
|
||||
' npm install -g @gitlawb/zero\n' +
|
||||
' - run the downloader manually (needs write access to the package dir):\n' +
|
||||
` node "${postinstallScript}"\n` +
|
||||
'\n' +
|
||||
@@ -234,12 +182,12 @@ function missingNativeDoctorTextReport(postinstallScript) {
|
||||
);
|
||||
}
|
||||
|
||||
// The platform payload is a version of this same package, installed under an
|
||||
// npm: alias (see docs/NPM_PACKAGING.md). The alias name is derived from
|
||||
// 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 = `${packageName}-${process.platform}-${process.arch}`;
|
||||
const aliasedName = `@gitlawb/zero-${process.platform}-${process.arch}`;
|
||||
let manifestPath;
|
||||
try {
|
||||
manifestPath = createRequire(import.meta.url).resolve(`${aliasedName}/package.json`);
|
||||
@@ -279,6 +227,8 @@ function downloadMissingBinary(packageRoot) {
|
||||
});
|
||||
}
|
||||
|
||||
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
|
||||
function resolveNativeBinary() {
|
||||
const fromPlatformPackage = platformPackageBinary();
|
||||
if (fromPlatformPackage) return fromPlatformPackage;
|
||||
|
||||
@@ -5,7 +5,7 @@ package main
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/Twigpine/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/Twigpine/zero/internal/perfbench"
|
||||
"github.com/Gitlawb/zero/internal/perfbench"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Twigpine/zero/internal/perfbench"
|
||||
"github.com/Gitlawb/zero/internal/perfbench"
|
||||
)
|
||||
|
||||
func TestParseArgsUsesCliAndEnvOverrides(t *testing.T) {
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/Twigpine/zero/internal/perfbench"
|
||||
"github.com/Gitlawb/zero/internal/perfbench"
|
||||
)
|
||||
|
||||
// taskOptions configures the `zero-perf-bench tasks` subcommand: the reproducible
|
||||
|
||||
@@ -9,8 +9,8 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/Twigpine/zero/internal/execprofile"
|
||||
"github.com/Twigpine/zero/internal/perfbench"
|
||||
"github.com/Gitlawb/zero/internal/execprofile"
|
||||
"github.com/Gitlawb/zero/internal/perfbench"
|
||||
)
|
||||
|
||||
// turnOptions configures the `zero-perf-bench turn` subcommand: the per-turn
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/Twigpine/zero/internal/review"
|
||||
"github.com/Gitlawb/zero/internal/review"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/Twigpine/zero/internal/release"
|
||||
"github.com/Gitlawb/zero/internal/release"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"os/exec"
|
||||
"syscall"
|
||||
|
||||
"github.com/Twigpine/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
||||
@@ -3,7 +3,7 @@ package main
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/Twigpine/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
||||
@@ -3,7 +3,7 @@ package main
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/Twigpine/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
||||
+1
-5
@@ -3,13 +3,9 @@ package main
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/Twigpine/zero/internal/cli"
|
||||
"github.com/Twigpine/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/cli"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) > 1 && os.Args[1] == sandbox.LinuxSandboxHelperSubcommand {
|
||||
os.Exit(sandbox.RunLinuxSandboxHelper(os.Args[1:], os.Stderr))
|
||||
}
|
||||
os.Exit(cli.Run(os.Args[1:], os.Stdout, os.Stderr))
|
||||
}
|
||||
+2
-65
@@ -110,8 +110,6 @@ Discovery roots (earlier wins on name collisions):
|
||||
|
||||
A missing directory is fine — Zero just omits it. Management commands (`zero skill add` / `zero skill remove`) always write to the primary Zero dir only; `list` / `info` search primary + `~/.agents/skills`. `skill info` reports lock source/hash when present and whether the on-disk `SKILL.md` has drifted from the recorded hash.
|
||||
|
||||
For a skill installed since Zero started installing bundled files, `skill info` also reports drift in any file installed with it.
|
||||
|
||||
```text
|
||||
~/.local/share/zero/skills/ # primary (install/remove target)
|
||||
run-benchmarks/
|
||||
@@ -142,15 +140,6 @@ Only `name` and `description` are recognized in the frontmatter today. The `name
|
||||
|
||||
The `skill` tool lets the agent load any discovered skill by name (primary, agents, or plugin).
|
||||
|
||||
A skill can ship files beside its `SKILL.md`, in the [agentskills.io](https://agentskills.io/specification) layout: `scripts/`, `references/`, `assets/` or anything else the instructions refer to. `zero skill add` installs the whole skill directory, leaving out hidden entries such as `.git` and not following or copying symlinks (it lists any it skipped). A skill larger than 1,000 files or 32 MiB is refused. When the `skill` tool loads a skill that has such files, its output ends with the skill's directory and a list of the files, so instructions like "run `scripts/fill.py`" can be followed. Running a bundled script goes through the normal tool permissions; installing or loading a skill never runs anything.
|
||||
|
||||
```text
|
||||
pdf/
|
||||
SKILL.md
|
||||
scripts/fill.py
|
||||
references/form-fields.md
|
||||
```
|
||||
|
||||
## 4. Hooks
|
||||
|
||||
Hooks fire shell commands on lifecycle events. Configure them in JSON:
|
||||
@@ -203,11 +192,7 @@ Events the agent emits (in dispatch order):
|
||||
| `specialistStart` | A sub-agent is spawned | yes (specialist name) |
|
||||
| `specialistStop` | A sub-agent ends | yes (specialist name) |
|
||||
|
||||
For swarm members the matcher subject is the **agent type** (`subagent`, `teammate`, …), not a registered specialist profile name — swarm members run from an inline manifest keyed by agent type.
|
||||
|
||||
`specialistStart` fires once the child process has actually started; a launch that fails before exec (missing binary, spawn error) records no public lifecycle hooks. `specialistStop` fires once per terminal stop: completion, error, explicit stop of a recovered task, and a background task reaped by a later `TaskOutput` all share one durable claim, so a hook that notifies or cleans up does not run twice for the same sub-agent run.
|
||||
|
||||
A hook's exit code decides what happens next: `0` continues, non-zero blocks the tool call (`beforeTool`) or surfaces an error (`afterTool`). `sessionStart` / `sessionEnd` / `specialistStart` / `specialistStop` hooks are advisory (failures are audited but do not interrupt the run). 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.
|
||||
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.
|
||||
|
||||
@@ -261,52 +246,6 @@ 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.
|
||||
|
||||
#### Built-in servers
|
||||
|
||||
Zero seeds two MCP servers before your config is merged. Both show up in
|
||||
`zero mcp list`, and any field can be overridden in your user config.
|
||||
|
||||
- `exa` (`https://mcp.exa.ai/mcp`) is on by default for keyless web search.
|
||||
`zero mcp disable exa` turns it off.
|
||||
- `aimlapi` (`https://mcp.aimlapi.com/mcp`, the AI/ML API Toolbox, model
|
||||
discovery, image/video/audio generation, chat, embeddings and balance tools)
|
||||
is **off by default**, because its tool runs and generations are billed to
|
||||
your AI/ML API balance. Turn it on with `zero mcp enable aimlapi`, which
|
||||
writes only `"disabled": false` to your user config, and off again with
|
||||
`zero mcp disable aimlapi`. A project config cannot turn it on.
|
||||
|
||||
When `aimlapi` connects, Zero sends your AI/ML API key as
|
||||
`Authorization: Bearer <key>`. The key is resolved again on every request and
|
||||
is never written to `config.json`. With an aimlapi.com provider profile
|
||||
(`zero providers setup aimlapi`), the key is chosen in this order:
|
||||
|
||||
1. An inline key on the profile (if set in config).
|
||||
2. Otherwise the environment variable named by the profile's `apiKeyEnv`
|
||||
(the catalog default is `AIMLAPI_API_KEY`).
|
||||
3. Otherwise the key held in the credential store when the profile uses
|
||||
`apiKeyStored`.
|
||||
|
||||
When there is no aimlapi profile, Zero uses `AIMLAPI_API_KEY` alone. If both
|
||||
`AIMLAPI_API_KEY` and a stored profile key exist, step 2 wins over the store
|
||||
whenever the inline key is empty — MCP requests may bill the environment key's
|
||||
account, not the stored key.
|
||||
|
||||
No automatic key is sent when an aimlapi profile, or any profile reading
|
||||
`AIMLAPI_API_KEY`, uses a base URL other than `https://api.aimlapi.com/v1`, or
|
||||
when `AIMLAPI_INFERENCE_URL` is overridden, because that credential may belong
|
||||
to a proxy. No key is sent when the resolved value is not a single token (for
|
||||
example a comma- or newline-separated pool), when you set your own
|
||||
non-empty `Authorization` header, when the server uses `"auth": "oauth"`, or
|
||||
when project config touched the server. Without a key the server answers 401.
|
||||
To sign in with your aimlapi.com account instead, set `"auth": "oauth"` on the
|
||||
server and run `zero mcp oauth login aimlapi`.
|
||||
|
||||
Every request to exactly `https://mcp.aimlapi.com` (https, default port,
|
||||
whatever the server is named, including one you add by hand) also carries the
|
||||
attribution headers `X-AIMLAPI-Source: agent/zero` and `X-AIMLAPI-Partner-ID`,
|
||||
and gets the key under the rules above. Other MCP servers never receive these
|
||||
headers or the key.
|
||||
|
||||
### As a server — expose Zero's tools to another agent
|
||||
|
||||
```bash
|
||||
@@ -351,12 +290,10 @@ Install and manage:
|
||||
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 disable github-pr-review # sets "enabled": false in plugin.json
|
||||
zero plugins enable github-pr-review # sets "enabled": true in plugin.json
|
||||
zero plugins remove github-pr-review # alias: rm
|
||||
```
|
||||
|
||||
A plugin is active when it is present under a plugins directory and its `plugin.json` has `"enabled": true` (the default when the field is omitted). Use `zero plugins disable <id>` / `zero plugins enable <id>` to toggle that field without removing the plugin. Pass `--user` to target only user plugins and ignore `./.zero/plugins`; without it, a project plugin with the same id takes precedence. The toggle edits the `enabled` value in place — the rest of the manifest keeps its formatting, field order, and numeric literals — and serializes with `plugins add` / `plugins remove` on the same plugins root. Zero reads plugins when it starts, so the change applies to the next `zero exec` run or interactive session; an interactive session that is already running keeps the plugin tools, hooks, and skills it started with until you restart it.
|
||||
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.
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: Twigpine/zero@v1
|
||||
- uses: Gitlawb/zero@v1
|
||||
with:
|
||||
prompt: ${{ inputs.task }}
|
||||
provider: openai
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
| `github-token` | no | `${{ github.token }}` | Token used to post a PR comment. Requires `pull-requests: write`. |
|
||||
| `working-directory` | no | `${{ github.workspace }}` | Directory to run ZERO in. |
|
||||
| `zero-version` | no | (action ref → `latest`) | ZERO release version/tag to install, e.g. `v1.2.3` or `latest`. |
|
||||
| `zero-repo` | no | `Twigpine/zero` | Repository to install the ZERO release from. |
|
||||
| `zero-repo` | no | `Gitlawb/zero` | Repository to install the ZERO release from. |
|
||||
|
||||
## Outputs
|
||||
|
||||
@@ -101,7 +101,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: Twigpine/zero@v1
|
||||
- uses: Gitlawb/zero@v1
|
||||
with:
|
||||
prompt: |
|
||||
Investigate this issue and propose a fix.
|
||||
@@ -134,7 +134,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: Twigpine/zero@v1
|
||||
- uses: Gitlawb/zero@v1
|
||||
id: zero
|
||||
with:
|
||||
prompt-file: .github/zero/upgrade-deps.md
|
||||
@@ -176,7 +176,7 @@ jobs:
|
||||
- **Least-privilege tokens.** Grant only the permissions the workflow needs
|
||||
(`contents: write` to edit files, `pull-requests: write` to comment). The
|
||||
default `GITHUB_TOKEN` is scoped to the repository.
|
||||
- **Pin the action.** Reference a tag (`Twigpine/zero@v1`) or a commit SHA so a
|
||||
- **Pin the action.** Reference a tag (`Gitlawb/zero@v1`) or a commit SHA so a
|
||||
workflow run uses a known ZERO version. `zero-version` lets you pin the
|
||||
installed binary independently of the action ref.
|
||||
- **Linux and macOS runners** are supported; Windows runners are rejected with a
|
||||
|
||||
@@ -634,10 +634,6 @@ Core tool groups include:
|
||||
`HTTP_PROXY`/`HTTPS_PROXY` set, the proxy is dialed instead and resolves the
|
||||
target itself, so destination enforcement ends at the proxy; see the note in
|
||||
the README.
|
||||
With `ZERO_WEBFETCH_PROVIDER=aimlapi` (opt-in; a key alone does not select
|
||||
it), the same URL checks run and the page is then fetched as Markdown by the
|
||||
billed AI/ML API Toolbox tool `search1api/crawl`, with no fallback to a
|
||||
direct fetch; see the README.
|
||||
- **Local-control tools**: config-gated wrappers for browser, terminal, desktop,
|
||||
and artifact helpers backed by packages such as `internal/localcontrol` and
|
||||
`internal/browser`.
|
||||
|
||||
+30
-78
@@ -5,11 +5,11 @@ blessed one.
|
||||
|
||||
| Method | Command | Self-update |
|
||||
| --- | --- | --- |
|
||||
| Install script (Linux, macOS) | `curl -fsSL https://raw.githubusercontent.com/Twigpine/zero/main/scripts/install.sh \| bash` | `zero upgrade` |
|
||||
| Install script (Windows) | `irm https://raw.githubusercontent.com/Twigpine/zero/main/scripts/install.ps1 \| iex` | `zero upgrade` |
|
||||
| npm | `npm install -g @twigpine/zero` | `zero upgrade` (runs npm) |
|
||||
| Release archive | download from [Releases](https://github.com/Twigpine/zero/releases) | `zero upgrade` |
|
||||
| mise | `mise use -g ubi:Twigpine/zero` | `mise upgrade` |
|
||||
| Install script (Linux, macOS) | `curl -fsSL https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.sh \| bash` | `zero upgrade` |
|
||||
| Install script (Windows) | `irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 \| iex` | `zero upgrade` |
|
||||
| npm | `npm install -g @gitlawb/zero` | `zero upgrade` (runs npm) |
|
||||
| Release archive | download from [Releases](https://github.com/Gitlawb/zero/releases) | `zero upgrade` |
|
||||
| mise | `mise use -g ubi:Gitlawb/zero` | `mise upgrade` |
|
||||
| `go install` | `go install github.com/Gitlawb/zero/cmd/zero@latest` | rerun the command |
|
||||
| Source | `go build -o zero ./cmd/zero` | rebuild |
|
||||
|
||||
@@ -29,7 +29,7 @@ Release and verify its checksum.
|
||||
## npm
|
||||
|
||||
```bash
|
||||
npm install -g @twigpine/zero
|
||||
npm install -g @gitlawb/zero
|
||||
zero
|
||||
```
|
||||
|
||||
@@ -56,47 +56,15 @@ cannot write to keeps failing — rerun the install or the command below with
|
||||
sufficient permissions). To trigger the fetch manually:
|
||||
|
||||
```bash
|
||||
node "$(npm root -g)/@twigpine/zero/scripts/postinstall.mjs"
|
||||
node "$(npm root -g)/@gitlawb/zero/scripts/postinstall.mjs"
|
||||
```
|
||||
|
||||
### Moving from @gitlawb/zero
|
||||
|
||||
Zero was published as `@gitlawb/zero` up to 0.9.0. From 0.10.0 it is
|
||||
`@twigpine/zero`, and the old name gets no further releases.
|
||||
|
||||
Both packages provide the `zero` command, so npm refuses to install the new
|
||||
one while the old one is installed (`EEXIST`). Forcing it is worse: a later
|
||||
uninstall of the old package removes the shared `zero` command. Quit any
|
||||
running `zero` (on Windows a running `zero.exe` cannot be removed), then
|
||||
uninstall the old package before installing the new one:
|
||||
|
||||
```bash
|
||||
npm uninstall -g @gitlawb/zero
|
||||
npm install -g @twigpine/zero
|
||||
```
|
||||
|
||||
With another package manager, the same two steps:
|
||||
|
||||
```bash
|
||||
bun remove -g @gitlawb/zero && bun add -g @twigpine/zero
|
||||
pnpm remove -g @gitlawb/zero && pnpm add -g @twigpine/zero
|
||||
yarn global remove @gitlawb/zero && yarn global add @twigpine/zero
|
||||
```
|
||||
|
||||
Config, sessions, logins, and plugins live in your user config and home
|
||||
directories, not in the package, so they carry over unchanged.
|
||||
|
||||
0.10.0 was also published once as `@gitlawb/zero`, so an old install that
|
||||
updates lands on a working build. That build prints these steps when `zero`
|
||||
exits in a terminal, and `zero upgrade` and `zero update --check` print them
|
||||
too.
|
||||
|
||||
## Linux And macOS Script
|
||||
|
||||
Install the latest release:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Twigpine/zero/main/scripts/install.sh | bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.sh | bash
|
||||
```
|
||||
|
||||
From a checkout:
|
||||
@@ -121,7 +89,7 @@ scripts/install.sh --install-dir "$HOME/bin"
|
||||
|
||||
Defaults:
|
||||
|
||||
- Repository: `Twigpine/zero`
|
||||
- Repository: `Gitlawb/zero`
|
||||
- Version: latest GitHub release
|
||||
- Install path: `~/.local/bin/zero`
|
||||
|
||||
@@ -132,7 +100,7 @@ Requirements: Bash, `curl` or `wget`, `tar`, and `shasum` or `sha256sum`.
|
||||
Install the latest release:
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/Twigpine/zero/main/scripts/install.ps1 | iex
|
||||
irm https://raw.githubusercontent.com/Gitlawb/zero/main/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
From a checkout:
|
||||
@@ -155,7 +123,7 @@ powershell -ExecutionPolicy Bypass -File scripts/install.ps1 -InstallDir "$env:U
|
||||
|
||||
Defaults:
|
||||
|
||||
- Repository: `Twigpine/zero`
|
||||
- Repository: `Gitlawb/zero`
|
||||
- Version: latest GitHub release
|
||||
- Install path: `%LOCALAPPDATA%\zero\bin\zero.exe`
|
||||
|
||||
@@ -166,13 +134,13 @@ archives through its `ubi` backend, so there is nothing extra to publish and no
|
||||
registry in the middle:
|
||||
|
||||
```bash
|
||||
mise use -g ubi:Twigpine/zero
|
||||
mise use -g ubi:Gitlawb/zero
|
||||
```
|
||||
|
||||
Pin a version the same way you would any other tool:
|
||||
|
||||
```bash
|
||||
mise use -g ubi:Twigpine/[email protected]
|
||||
mise use -g ubi:Gitlawb/[email protected]
|
||||
```
|
||||
|
||||
Updates come from `mise upgrade`. `zero upgrade` also works, because a
|
||||
@@ -185,19 +153,13 @@ describe a version that is no longer on disk. Prefer `mise upgrade`.
|
||||
go install github.com/Gitlawb/zero/cmd/zero@latest
|
||||
```
|
||||
|
||||
Every release so far declares the module path `github.com/Gitlawb/zero`, so
|
||||
`go install` has to use that path; `github.com/Twigpine/zero/cmd/zero@latest`
|
||||
fails with "module declares its path as" until a release is tagged with the new
|
||||
module path.
|
||||
|
||||
This builds from source, so it needs Go 1.26.10+ and it does not go through the
|
||||
This builds from source, so it needs Go 1.26.9+ and it does not go through the
|
||||
release archives. Two consequences worth knowing before you pick it:
|
||||
|
||||
- On Linux, Bubblewrap is still required for native sandboxing. Zero resolves
|
||||
the helper in this order: an executable `zero-linux-sandbox` next to `zero`,
|
||||
then `PATH`, then self-exec of the main binary. Standalone helpers remain
|
||||
optional and preferred. See
|
||||
- On Linux you also need the sandbox helper, which is a separate binary and is
|
||||
not installed by this command. See
|
||||
[Sandbox Helpers For Source Builds](#sandbox-helpers-for-source-builds).
|
||||
Without it, native sandboxing is unavailable.
|
||||
- `zero upgrade` treats the result as a standalone install and will replace the
|
||||
binary with a release build rather than rebuilding from source. If you chose
|
||||
`go install` deliberately, rerun it instead.
|
||||
@@ -207,7 +169,7 @@ macOS and Windows need no extra helper.
|
||||
## From Source
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Twigpine/zero.git
|
||||
git clone https://github.com/Gitlawb/zero.git
|
||||
cd zero
|
||||
go run ./cmd/zero
|
||||
```
|
||||
@@ -218,32 +180,25 @@ Build a local binary:
|
||||
go build -o zero ./cmd/zero
|
||||
```
|
||||
|
||||
Source builds require Go 1.26.10+.
|
||||
Source builds require Go 1.26.9+.
|
||||
|
||||
### Sandbox Helpers For Source Builds
|
||||
|
||||
Release archives include the platform sandbox helpers. A source build of the
|
||||
main `zero` binary is enough for Linux native sandboxing. Zero resolves the
|
||||
helper in this order:
|
||||
Release archives include the platform sandbox helpers. If you build directly
|
||||
from source, build the helpers you need:
|
||||
|
||||
1. an executable `zero-linux-sandbox` next to `zero`
|
||||
2. then `PATH`
|
||||
3. then self-exec of the main binary
|
||||
|
||||
Bubblewrap must still be installed. Standalone helpers remain optional and
|
||||
preferred.
|
||||
|
||||
A separate helper remains supported if it sits next to `zero` or on `PATH`:
|
||||
Linux:
|
||||
|
||||
```bash
|
||||
go build -o zero ./cmd/zero
|
||||
go build -o zero-linux-sandbox ./cmd/zero-linux-sandbox # optional
|
||||
go build -o zero-seccomp ./cmd/zero-seccomp # optional compatibility wrapper
|
||||
go build -o zero-linux-sandbox ./cmd/zero-linux-sandbox
|
||||
go build -o zero-seccomp ./cmd/zero-seccomp
|
||||
```
|
||||
|
||||
Put `zero` on `PATH`, for example `~/.local/bin`. `zero-seccomp` is kept as a
|
||||
compatibility wrapper; the sandbox helper applies the Unix-socket filter itself
|
||||
when that sandbox option is enabled.
|
||||
Put `zero` and `zero-linux-sandbox` in the same directory on `PATH`, for example
|
||||
`~/.local/bin`. `zero-seccomp` is kept as a compatibility wrapper; the sandbox
|
||||
helper applies the Unix-socket filter itself when that sandbox option is enabled.
|
||||
Linux native sandboxing also requires Bubblewrap to be installed.
|
||||
|
||||
macOS uses the system sandbox and does not need an extra helper binary.
|
||||
|
||||
@@ -258,7 +213,7 @@ seccomp filter on Android:
|
||||
pkg install golang
|
||||
|
||||
# Build Zero for Android
|
||||
git clone https://github.com/Twigpine/zero.git
|
||||
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
|
||||
|
||||
@@ -347,10 +302,7 @@ See the [update guide](UPDATE.md) for update modes, flags, and platform details.
|
||||
overwriting a binary a package manager owns leaves that manager describing a
|
||||
version that is no longer there:
|
||||
|
||||
- **npm**: runs `npm install -g @twigpine/zero@latest` for you. An install of
|
||||
the old `@gitlawb/zero` package refuses instead and prints the steps in
|
||||
[Moving from @gitlawb/zero](#moving-from-gitlawbzero): that name gets no
|
||||
further releases, so npm would only reinstall the same version.
|
||||
- **npm**: runs `npm install -g @gitlawb/zero@latest` for you.
|
||||
- **Homebrew**: refuses, and tells you to run `brew upgrade zero`. Replacing the
|
||||
keg binary directly would be reverted by the next `brew upgrade` or
|
||||
`brew reinstall`.
|
||||
|
||||
+18
-77
@@ -1,6 +1,6 @@
|
||||
# npm packaging
|
||||
|
||||
How `@twigpine/zero` is put together on npm, why it is shaped this way, and the
|
||||
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
|
||||
@@ -8,7 +8,7 @@ rules the release pipeline must follow. Read this before touching
|
||||
|
||||
## Goals
|
||||
|
||||
A `npm install -g @twigpine/zero` must be **silent and self-contained**:
|
||||
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,
|
||||
@@ -25,15 +25,15 @@ The model is the one used by Codex, esbuild, and Biome: a tiny wrapper package
|
||||
plus per-platform payloads carrying the native binaries.
|
||||
|
||||
```
|
||||
@twigpine/zero <- wrapper: bin/zero.js + optionalDependencies
|
||||
├─ @twigpine/zero-darwin-arm64 -> npm:@twigpine/zero@{version}-darwin-arm64
|
||||
├─ @twigpine/zero-darwin-x64 -> npm:@twigpine/zero@{version}-darwin-x64
|
||||
├─ @twigpine/zero-linux-arm64 -> npm:@twigpine/zero@{version}-linux-arm64
|
||||
├─ @twigpine/zero-linux-x64 -> npm:@twigpine/zero@{version}-linux-x64
|
||||
└─ @twigpine/zero-win32-x64 -> npm:@twigpine/zero@{version}-win32-x64
|
||||
@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 `@twigpine/zero` package**,
|
||||
- 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.
|
||||
@@ -56,7 +56,7 @@ plus per-platform payloads carrying the native binaries.
|
||||
|
||||
### Binary resolution in `bin/zero.js`
|
||||
|
||||
1. Resolve `@twigpine/zero-<platform>-<arch>` and exec the `zero` binary from
|
||||
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.
|
||||
@@ -103,22 +103,10 @@ dependencies of the wrapper:
|
||||
platform binary (plus the musl variant on linux, which its launcher detects
|
||||
at runtime) — about 65 MB saved per platform package.
|
||||
|
||||
### One package name
|
||||
|
||||
The npm name lives in `package.json` and nothing else repeats it:
|
||||
`scripts/npm/build-platform-packages.mjs` names the wrapper, the platform
|
||||
versions, the alias names, and the alias targets from it, and `bin/zero.js`
|
||||
reads its own `package.json` to derive the alias it resolves. The Go side
|
||||
needs the name as a constant (`npmPackageName` in
|
||||
`internal/update/installmethod.go`), and a test holds it equal to
|
||||
`package.json`.
|
||||
|
||||
## Publishing rules (release pipeline)
|
||||
|
||||
These are the invariants `release-artifacts.yml` must hold. Breaking the first
|
||||
one is user-visible immediately. `scripts/npm/publish-packages.sh` implements
|
||||
them, and `internal/npmwrapper/publish_test.go` runs it against a fake
|
||||
registry.
|
||||
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
|
||||
@@ -130,67 +118,20 @@ registry.
|
||||
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 are on the registry 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. Ordering the uploads is not enough: npm applies a publish
|
||||
asynchronously, and in 0.10.0 a platform version appeared ten minutes
|
||||
after its upload returned, after the wrapper. The publish script waits
|
||||
until npm shows every platform version before it publishes the wrapper,
|
||||
and waits the same way for `latest` afterwards (up to 30 minutes in all).
|
||||
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. The publish script refuses to start when
|
||||
the wrapper's aliases do not match the platform payloads beside it.
|
||||
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. npm checks provenance against
|
||||
`package.json` `repository`, so it must name the repository the workflow
|
||||
runs in.
|
||||
5. **Re-runs are safe.** A version already on the registry is skipped rather
|
||||
than republished, so re-running a job that failed between the platform and
|
||||
wrapper publishes finishes the release. Only npm's E404 counts as "not
|
||||
published"; a lookup that fails any other way is retried and then stops the
|
||||
job before anything is published.
|
||||
trusted-publisher configuration.
|
||||
|
||||
`zero update` keeps working unchanged: it detects an npm install by finding a
|
||||
`package.json` named `@twigpine/zero` next to the running binary — true inside
|
||||
a platform payload too — and updates via `npm install -g @twigpine/zero@latest`.
|
||||
|
||||
## The move from `@gitlawb/zero`
|
||||
|
||||
Zero was published as `@gitlawb/zero` up to 0.9.0 and as `@twigpine/zero`
|
||||
from 0.10.0. There is no in-place migration: both packages own the `zero`
|
||||
bin, so npm refuses to install one over the other (`EEXIST`), and forcing it
|
||||
lets a later uninstall of the old package delete the shared bin. The user
|
||||
has to uninstall the old package and install the new one, in that order.
|
||||
|
||||
Deprecating the old package alone would not have reached existing users.
|
||||
0.9.0's `zero upgrade` runs `npm install -g @gitlawb/zero@latest`; with
|
||||
nothing newer under that name, npm reinstalls nothing, prints no deprecation
|
||||
warning (it only warns when it installs something), and Zero reports the
|
||||
update as done. So 0.10.0 was published once more under the old name, as a
|
||||
bridge:
|
||||
|
||||
- The release job assembled the same verified artifacts a second time with
|
||||
`build-platform-packages.mjs --package-name @gitlawb/zero` and published
|
||||
them with the same script, after the `@twigpine/zero` publish. The steps
|
||||
that did this ran once and have been removed from `release-artifacts.yml`.
|
||||
- The bridge build knows its name from the `package.json` beside it. The Go
|
||||
binary reports it as `InstallMethodNpmLegacy`: `zero upgrade` refuses
|
||||
with the switch steps instead of reinstalling it, and `zero update --check`
|
||||
adds them. `bin/zero.js` prints the same steps after `zero` exits, on a
|
||||
terminal only. This handling stays in the code, since only a build
|
||||
installed under the old name ever triggers it.
|
||||
- Updating from 0.9.0 installs that bridge build, so npm also shows the
|
||||
deprecation message.
|
||||
|
||||
`@gitlawb/zero` is deprecated on every version and no longer has a trusted
|
||||
publisher, so this release workflow cannot publish it again; another version
|
||||
would take a maintainer publishing by hand. Its versions stay on the
|
||||
registry: existing installs resolve their platform payload through them, so
|
||||
they must never be unpublished.
|
||||
`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
|
||||
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
Run this checklist when a PR changes npm distribution files such as
|
||||
`package.json`, `bin/zero.js`, `scripts/npm/build-platform-packages.mjs`,
|
||||
`scripts/npm/publish-packages.sh`, Go build or release tooling, or the npm
|
||||
`bin` wrapper.
|
||||
Go build or release tooling, or the npm `bin` wrapper.
|
||||
|
||||
## Required Checks
|
||||
|
||||
@@ -38,24 +37,24 @@ path:
|
||||
|
||||
```bash
|
||||
# platform-package path
|
||||
mkdir -p /tmp/zero-sim/node_modules/@twigpine
|
||||
cp -R dist/npm/wrapper /tmp/zero-sim/node_modules/@twigpine/zero
|
||||
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/@twigpine/zero-<platform>-<arch>"
|
||||
node /tmp/zero-sim/node_modules/@twigpine/zero/bin/zero.js --version
|
||||
"/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/@twigpine/zero-<platform>-<arch>"
|
||||
node /tmp/zero-sim/node_modules/@twigpine/zero/bin/zero.js --version
|
||||
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 (`@twigpine/zero`), version,
|
||||
- `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 `@twigpine/zero@<version>-<platform>-<arch>` with matching
|
||||
`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`
|
||||
|
||||
+4
-8
@@ -7,7 +7,7 @@ downloads, verifies, and installs it.
|
||||
```bash
|
||||
zero update --check
|
||||
zero update --check --json
|
||||
zero update --check --repo Twigpine/zero
|
||||
zero update --check --repo Gitlawb/zero
|
||||
zero update --check --target windows-x64
|
||||
|
||||
zero upgrade
|
||||
@@ -28,11 +28,7 @@ them explicitly; `zero upgrade` is `zero update` with `--apply` implied.
|
||||
|
||||
`--apply` installs the update in place:
|
||||
|
||||
- npm installs delegate to `npm install -g @twigpine/zero@latest`. An install
|
||||
of the old `@gitlawb/zero` package refuses, without a release check, and
|
||||
prints the steps to switch to `@twigpine/zero` (see
|
||||
[INSTALL.md](INSTALL.md#moving-from-gitlawbzero)); `--check` prints them
|
||||
too.
|
||||
- npm installs delegate to `npm install -g @gitlawb/zero@latest`.
|
||||
- Standalone installs download the release archive, verify its checksum,
|
||||
extract it, and atomically replace the running binary plus any installed
|
||||
optional sandbox helpers.
|
||||
@@ -42,7 +38,7 @@ them explicitly; `zero upgrade` is `zero update` with `--apply` implied.
|
||||
- `--target` cannot be combined with `--apply`; it only applies to `--check`,
|
||||
since applying always installs onto the current machine.
|
||||
- `--repo` and `--endpoint` are ignored when applying to an npm-managed
|
||||
install: that path delegates to `npm install -g @twigpine/zero@latest` and
|
||||
install: that path delegates to `npm install -g @gitlawb/zero@latest` and
|
||||
takes its release from the npm registry, not from GitHub. They still apply to
|
||||
`--check` there.
|
||||
- `--json` serializes Zero's final result. For npm-managed installs, npm may
|
||||
@@ -69,7 +65,7 @@ Endpoint resolution order:
|
||||
1. `--endpoint`
|
||||
2. `ZERO_UPDATE_RELEASE_URL`
|
||||
3. `--repo`
|
||||
4. `https://api.github.com/repos/Twigpine/zero/releases/latest`
|
||||
4. `https://api.github.com/repos/Gitlawb/zero/releases/latest`
|
||||
|
||||
Installer scripts download the matching release asset for the local platform and
|
||||
verify its `.sha256` file. If Zero is already installed, run `zero upgrade`
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="261" height="52" fill="none" viewBox="0 0 261 52"><path fill="white" d="M59.8 52H0L29.9 0zm67.82-38.45q4.9 0 8.81 2.13a15.5 15.5 0 0 1 6.22 6.32q2.3 4.2 2.38 10.26v2.06h-26.35q.27 4.4 2.58 6.92 2.38 2.47 6.36 2.47a8.4 8.4 0 0 0 7.76-4.93l9.16.67q-1.68 4.98-6.29 7.99t-10.63 3q-5.53 0-9.64-2.27a16 16 0 0 1-6.43-6.46 20 20 0 0 1-2.31-9.72q0-5.52 2.3-9.72a16 16 0 0 1 6.44-6.46 20 20 0 0 1 9.64-2.26m62.55 0q4.47 0 8.18 1.66a15.3 15.3 0 0 1 6.15 4.6q2.38 3 2.87 7.05l-9.23.47a8 8 0 0 0-2.8-5 7.7 7.7 0 0 0-5.17-1.86q-4.33 0-6.7 3-2.4 3-2.39 8.52t2.38 8.52q2.37 3 6.71 3 3.15 0 5.39-1.87 2.24-1.92 2.72-5.46l9.3.4a14.7 14.7 0 0 1-2.87 7.33 16 16 0 0 1-6.15 4.86 21 21 0 0 1-8.39 1.66q-5.53 0-9.64-2.26a16 16 0 0 1-6.44-6.46 20 20 0 0 1-2.3-9.72q0-5.52 2.3-9.72a16 16 0 0 1 6.44-6.46 20 20 0 0 1 9.64-2.26m38.66 0q4.9 0 8.8 2.13a15.5 15.5 0 0 1 6.22 6.32q2.31 4.2 2.38 10.26v2.06h-26.35q.28 4.4 2.58 6.92 2.38 2.47 6.36 2.47a8.4 8.4 0 0 0 7.77-4.93l9.15.67q-1.68 5-6.29 7.99t-10.62 3q-5.53 0-9.65-2.27a16 16 0 0 1-6.43-6.46 20 20 0 0 1-2.31-9.72q0-5.52 2.3-9.72a16 16 0 0 1 6.44-6.46 20 20 0 0 1 9.64-2.26M86.9 36.69l17.24-34.33h10.8L89.96 49.63h-6.12L58.85 2.36h10.81zm71.62-15.55a11 11 0 0 1 2.47-4.48q2.28-2.31 6.38-2.31h3.4v7.26h-3.47q-2.91 0-4.79.8a5.8 5.8 0 0 0-2.77 2.5q-.9 1.72-.9 4.37v20.35h-8.89V14.35h8.33zm101.73 28.5h-8.95V2.35h8.95zM127.62 20.26q-3.7 0-6 2.2-2.32 2.2-2.87 6.2h17.05q-.48-4.34-2.72-6.33a7.8 7.8 0 0 0-5.46-2.07m101.2 0q-3.7 0-6 2.2-2.31 2.2-2.87 6.2H237q-.5-4.34-2.73-6.33a7.8 7.8 0 0 0-5.46-2.07" style="fill:white;fill-opacity:1;"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.6 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="261" height="52" fill="none" viewBox="0 0 261 52"><path fill="black" d="M59.8 52H0L29.9 0zm67.82-38.45q4.9 0 8.81 2.13a15.5 15.5 0 0 1 6.22 6.32q2.3 4.2 2.38 10.26v2.06h-26.35q.27 4.4 2.58 6.92 2.38 2.47 6.36 2.47a8.4 8.4 0 0 0 7.76-4.93l9.16.67q-1.68 4.98-6.29 7.99t-10.63 3q-5.53 0-9.64-2.27a16 16 0 0 1-6.43-6.46 20 20 0 0 1-2.31-9.72q0-5.52 2.3-9.72a16 16 0 0 1 6.44-6.46 20 20 0 0 1 9.64-2.26m62.55 0q4.47 0 8.18 1.66a15.3 15.3 0 0 1 6.15 4.6q2.38 3 2.87 7.05l-9.23.47a8 8 0 0 0-2.8-5 7.7 7.7 0 0 0-5.17-1.86q-4.33 0-6.7 3-2.4 3-2.39 8.52t2.38 8.52q2.37 3 6.71 3 3.15 0 5.39-1.87 2.24-1.92 2.72-5.46l9.3.4a14.7 14.7 0 0 1-2.87 7.33 16 16 0 0 1-6.15 4.86 21 21 0 0 1-8.39 1.66q-5.53 0-9.64-2.26a16 16 0 0 1-6.44-6.46 20 20 0 0 1-2.3-9.72q0-5.52 2.3-9.72a16 16 0 0 1 6.44-6.46 20 20 0 0 1 9.64-2.26m38.66 0q4.9 0 8.8 2.13a15.5 15.5 0 0 1 6.22 6.32q2.31 4.2 2.38 10.26v2.06h-26.35q.28 4.4 2.58 6.92 2.38 2.47 6.36 2.47a8.4 8.4 0 0 0 7.77-4.93l9.15.67q-1.68 5-6.29 7.99t-10.62 3q-5.53 0-9.65-2.27a16 16 0 0 1-6.43-6.46 20 20 0 0 1-2.31-9.72q0-5.52 2.3-9.72a16 16 0 0 1 6.44-6.46 20 20 0 0 1 9.64-2.26M86.9 36.69l17.24-34.33h10.8L89.96 49.63h-6.12L58.85 2.36h10.81zm71.62-15.55a11 11 0 0 1 2.47-4.48q2.28-2.31 6.38-2.31h3.4v7.26h-3.47q-2.91 0-4.79.8a5.8 5.8 0 0 0-2.77 2.5q-.9 1.72-.9 4.37v20.35h-8.89V14.35h8.33zm101.73 28.5h-8.95V2.35h8.95zM127.62 20.26q-3.7 0-6 2.2-2.32 2.2-2.87 6.2h17.05q-.48-4.34-2.72-6.33a7.8 7.8 0 0 0-5.46-2.07m101.2 0q-3.7 0-6 2.2-2.31 2.2-2.87 6.2H237q-.5-4.34-2.73-6.33a7.8 7.8 0 0 0-5.46-2.07" style="fill:black;fill-opacity:1;"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.6 KiB |
@@ -1,6 +1,6 @@
|
||||
module github.com/Twigpine/zero
|
||||
module github.com/Gitlawb/zero
|
||||
|
||||
go 1.26.10
|
||||
go 1.26.9
|
||||
|
||||
require (
|
||||
charm.land/bubbles/v2 v2.1.1
|
||||
|
||||
+26
-84
@@ -13,18 +13,17 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/Twigpine/zero/internal/agent"
|
||||
"github.com/Twigpine/zero/internal/config"
|
||||
"github.com/Twigpine/zero/internal/modelregistry"
|
||||
"github.com/Twigpine/zero/internal/providercatalog"
|
||||
"github.com/Twigpine/zero/internal/providermodelcatalog"
|
||||
"github.com/Twigpine/zero/internal/providermodeldiscovery"
|
||||
"github.com/Twigpine/zero/internal/redaction"
|
||||
"github.com/Twigpine/zero/internal/sandbox"
|
||||
"github.com/Twigpine/zero/internal/sessions"
|
||||
"github.com/Twigpine/zero/internal/tools"
|
||||
"github.com/Twigpine/zero/internal/usage"
|
||||
"github.com/Twigpine/zero/internal/zeroruntime"
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/modelregistry"
|
||||
"github.com/Gitlawb/zero/internal/providercatalog"
|
||||
"github.com/Gitlawb/zero/internal/providermodelcatalog"
|
||||
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
|
||||
"github.com/Gitlawb/zero/internal/redaction"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/sessions"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// Deps are the ZERO capabilities the ACP Agent drives. The CLI fills these with
|
||||
@@ -214,7 +213,7 @@ func (a *Agent) handleSessionNew(ctx context.Context, params json.RawMessage) (a
|
||||
if err != nil {
|
||||
return nil, RPCError(codeInternalError, "create session: "+err.Error())
|
||||
}
|
||||
sess, _ := a.registerSession(meta.SessionID, root, nil, model, models, restrictModels, false, agent.PermissionModeAuto)
|
||||
sess, _ := a.registerSession(meta.SessionID, root, nil, model, models, restrictModels, false)
|
||||
return NewSessionResult{
|
||||
SessionID: sess.id,
|
||||
ConfigOptions: a.configOptions(sess),
|
||||
@@ -330,7 +329,7 @@ func (a *Agent) activatePersistedSession(ctx context.Context, p LoadSessionParam
|
||||
// Resume has already refused a non-resumable kind above, so only load can
|
||||
// reach here with one, and it publishes that session for viewing only.
|
||||
readOnly := !sessions.IsResumableKind(meta.SessionKind)
|
||||
sess, existed := a.registerSession(meta.SessionID, root, history, model, models, restrictModels, readOnly, restoredPermissionMode(meta.PermissionMode))
|
||||
sess, existed := a.registerSession(meta.SessionID, root, history, model, models, restrictModels, readOnly)
|
||||
if existed {
|
||||
_, messages, historyWarning, historyErr = a.refreshSessionHistory(sess, requireHistoryLog)
|
||||
if operation == persistedSessionResume && historyErr != nil {
|
||||
@@ -528,6 +527,8 @@ func (a *Agent) runTurn(ctx context.Context, sess *acpSession, userText string,
|
||||
queue := func(input sessions.AppendEventInput) {
|
||||
pendingEvents = append(pendingEvents, input)
|
||||
}
|
||||
queue(messageEvent("user", userText))
|
||||
|
||||
visionCache := make(map[string]bool)
|
||||
var visionCacheMu sync.Mutex
|
||||
supportsVision := func(modelID string) bool {
|
||||
@@ -551,15 +552,11 @@ func (a *Agent) runTurn(ctx context.Context, sess *acpSession, userText string,
|
||||
effectivePrompt = fmt.Sprintf("[Note: %s]\n\n%s", msg, userText)
|
||||
images = nil
|
||||
}
|
||||
// Match the TUI: only images actually sent to the model enter history.
|
||||
attachments := sessions.MessageAttachments{Images: len(images)}
|
||||
queue(messageEvent("user", userText, attachments))
|
||||
|
||||
opts := agent.Options{
|
||||
Cwd: sess.cwd,
|
||||
SessionID: sess.id,
|
||||
ProviderName: resolved.Provider.Name,
|
||||
ModelFamily: providercatalog.ModelFamilyFor(resolved.Provider.CatalogID),
|
||||
Model: resolved.Provider.Model,
|
||||
Registry: registry,
|
||||
Sandbox: sandboxEngine,
|
||||
@@ -570,12 +567,6 @@ func (a *Agent) runTurn(ctx context.Context, sess *acpSession, userText string,
|
||||
SupportsVision: supportsVision,
|
||||
OnText: note.text,
|
||||
OnReasoning: note.thought,
|
||||
// Persist token usage the same way `zero exec` does, so `zero usage report`
|
||||
// sees ACP sessions too. It joins the turn's buffered batch: a hard run
|
||||
// failure that aborts the turn drops its usage together with its history.
|
||||
OnUsage: func(u agent.Usage) {
|
||||
queue(usageEvent(u))
|
||||
},
|
||||
OnToolCall: func(call agent.ToolCall) {
|
||||
queue(toolCallEvent(call))
|
||||
note.toolCall(call)
|
||||
@@ -595,7 +586,7 @@ func (a *Agent) runTurn(ctx context.Context, sess *acpSession, userText string,
|
||||
agentPrompt := buildPrompt(sess.snapshotHistory(), effectivePrompt)
|
||||
result, runErr := a.deps.RunAgent(ctx, agentPrompt, provider, opts)
|
||||
if result.FinalAnswer != "" {
|
||||
queue(messageEvent("assistant", result.FinalAnswer, sessions.MessageAttachments{}))
|
||||
queue(messageEvent("assistant", result.FinalAnswer))
|
||||
}
|
||||
reason, stopErr := stopReasonFor(result, runErr)
|
||||
if stopErr != nil {
|
||||
@@ -616,7 +607,7 @@ func (a *Agent) runTurn(ctx context.Context, sess *acpSession, userText string,
|
||||
// one file write, which is the contiguity restored here — not a disk
|
||||
// transaction, and not a promise about crashes mid-fsync. Reported by @jatmn.
|
||||
persistenceErr := a.persistTurn(sess.id, pendingEvents)
|
||||
sess.appendHistory(turnRecord{user: attachments.WithContent(userText), assistant: result.FinalAnswer})
|
||||
sess.appendHistory(turnRecord{user: userText, assistant: result.FinalAnswer})
|
||||
a.warnPersistence(
|
||||
note,
|
||||
"save session history",
|
||||
@@ -704,9 +695,7 @@ func (a *Agent) handleSetMode(_ context.Context, params json.RawMessage) (any, e
|
||||
mode := agent.PermissionMode(p.ModeID)
|
||||
switch mode {
|
||||
case agent.PermissionModeAuto, agent.PermissionModeAsk, agent.PermissionModePlan:
|
||||
if err := a.updateMode(sess, mode); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sess.setMode(mode)
|
||||
(¬ifier{conn: a.conn, sessionID: sess.id}).currentMode(string(mode))
|
||||
return SetSessionModeResult{}, nil
|
||||
case agent.PermissionModeUnsafe:
|
||||
@@ -742,9 +731,7 @@ func (a *Agent) handleSetConfigOption(_ context.Context, params json.RawMessage)
|
||||
mode := agent.PermissionMode(p.Value)
|
||||
switch mode {
|
||||
case agent.PermissionModeAuto, agent.PermissionModeAsk, agent.PermissionModePlan:
|
||||
if err := a.updateMode(sess, mode); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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)
|
||||
@@ -786,35 +773,6 @@ func (a *Agent) updateModel(sess *acpSession, model string, restrictModels bool)
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateMode persists the mode BEFORE applying it, so a failed write fails the
|
||||
// request instead of leaving disk behind memory; the same order updateModel
|
||||
// uses. The caller holds sess.turnMu (both mode doors take it and it is not
|
||||
// reentrant, so this must not lock it again); the write under turnMu briefly
|
||||
// blocks that session's prompts, which is what makes the store authoritative.
|
||||
func (a *Agent) updateMode(sess *acpSession, mode agent.PermissionMode) error {
|
||||
if _, err := a.deps.Store.UpdateMode(sess.id, string(mode)); err != nil {
|
||||
return RPCError(codeInternalError, "save mode selection: "+err.Error())
|
||||
}
|
||||
sess.setMode(mode)
|
||||
return nil
|
||||
}
|
||||
|
||||
// restoredPermissionMode maps a persisted mode onto the modes ACP offers. A
|
||||
// session written before modes were persisted has none and keeps the historical
|
||||
// auto default. A stored value outside the three is never allowed to come back
|
||||
// weaker than ask, and unsafe can never be reached because ACP refuses to set
|
||||
// it in the first place.
|
||||
func restoredPermissionMode(saved string) agent.PermissionMode {
|
||||
switch mode := agent.PermissionMode(strings.TrimSpace(saved)); mode {
|
||||
case agent.PermissionModeAuto, agent.PermissionModeAsk, agent.PermissionModePlan:
|
||||
return mode
|
||||
case "":
|
||||
return agent.PermissionModeAuto
|
||||
default:
|
||||
return agent.PermissionModeAsk
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) handleCancel(_ context.Context, params json.RawMessage) {
|
||||
var p CancelParams
|
||||
if err := json.Unmarshal(params, &p); err != nil {
|
||||
@@ -932,23 +890,10 @@ func (a *Agent) persistTurn(sessionID string, inputs []sessions.AppendEventInput
|
||||
return err
|
||||
}
|
||||
|
||||
func messageEvent(role, content string, attachments sessions.MessageAttachments) sessions.AppendEventInput {
|
||||
payload := map[string]any{"role": role, "content": content}
|
||||
if role == "user" && (attachments.Images > 0 || attachments.Documents > 0) {
|
||||
payload["attachments"] = attachments
|
||||
}
|
||||
func messageEvent(role, content string) sessions.AppendEventInput {
|
||||
return sessions.AppendEventInput{
|
||||
Type: sessions.EventMessage,
|
||||
Payload: payload,
|
||||
}
|
||||
}
|
||||
|
||||
// usageEvent records one provider usage report in the same persisted shape the
|
||||
// exec runtime writes (usage.EventUsagePayload is the single writer).
|
||||
func usageEvent(u agent.Usage) sessions.AppendEventInput {
|
||||
return sessions.AppendEventInput{
|
||||
Type: sessions.EventUsage,
|
||||
Payload: usage.EventUsagePayload(u),
|
||||
Payload: map[string]any{"role": role, "content": content},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1100,16 +1045,14 @@ func (a *Agent) loadHistory(sessionID string, requireHistoryLog bool) ([]turnRec
|
||||
continue
|
||||
}
|
||||
var msg struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Attachments sessions.MessageAttachments `json:"attachments"`
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if json.Unmarshal(raw, &msg) != nil {
|
||||
continue
|
||||
}
|
||||
switch msg.Role {
|
||||
case "user":
|
||||
msg.Content = msg.Attachments.WithContent(msg.Content)
|
||||
messages = append(messages, persistedMessage{eventID: persistedMessageIdentity(sessionID, e), role: msg.Role, content: msg.Content})
|
||||
if havePending {
|
||||
records = append(records, turnRecord{user: pendingUser})
|
||||
@@ -1267,15 +1210,14 @@ func promptImages(blocks []ContentBlock) []zeroruntime.ImageBlock {
|
||||
// session is returned rather than orphaning its turn or resetting its mode/model.
|
||||
// The bool reports that case so persisted activation can re-read and apply disk
|
||||
// history under turnMu. history is set BEFORE first publication so no concurrent
|
||||
// prompt can read a half-initialized session. mode is the session's initial
|
||||
// permission mode: auto for a new session, the restored value for load/resume.
|
||||
func (a *Agent) registerSession(id, cwd string, history []turnRecord, model string, models []SessionConfigOptionValue, restrictModels bool, readOnly bool, mode agent.PermissionMode) (*acpSession, bool) {
|
||||
// prompt can read a half-initialized session.
|
||||
func (a *Agent) registerSession(id, cwd string, history []turnRecord, model string, models []SessionConfigOptionValue, restrictModels bool, readOnly bool) (*acpSession, bool) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if existing := a.sessions[id]; existing != nil {
|
||||
return existing, true
|
||||
}
|
||||
sess := &acpSession{id: id, cwd: cwd, mode: mode, model: model, models: models, restrictModels: restrictModels, history: history, readOnly: readOnly}
|
||||
sess := &acpSession{id: id, cwd: cwd, mode: agent.PermissionModeAuto, model: model, models: models, restrictModels: restrictModels, history: history, readOnly: readOnly}
|
||||
a.sessions[id] = sess
|
||||
return sess, false
|
||||
}
|
||||
|
||||
@@ -19,13 +19,13 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Twigpine/zero/internal/agent"
|
||||
"github.com/Twigpine/zero/internal/config"
|
||||
"github.com/Twigpine/zero/internal/providermodeldiscovery"
|
||||
"github.com/Twigpine/zero/internal/sandbox"
|
||||
"github.com/Twigpine/zero/internal/sessions"
|
||||
"github.com/Twigpine/zero/internal/tools"
|
||||
"github.com/Twigpine/zero/internal/zeroruntime"
|
||||
"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"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// fakeProvider streams a canned assistant message and ends the turn — enough to
|
||||
@@ -960,11 +960,7 @@ func TestACPHardTurnFailureDoesNotCommitHistory(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{
|
||||
SessionID: created.SessionID,
|
||||
Prompt: []ContentBlock{
|
||||
TextBlock("failed question"),
|
||||
ImageBlock(base64.StdEncoding.EncodeToString([]byte("failed image")), "image/png"),
|
||||
},
|
||||
SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("failed question")},
|
||||
}, &PromptResult{}); err == nil {
|
||||
t.Fatal("hard RunAgent failure was reported as a successful turn")
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/Twigpine/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
)
|
||||
|
||||
// ---- an offered option is an acceptable answer ----
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
package acp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Twigpine/zero/internal/agent"
|
||||
"github.com/Twigpine/zero/internal/config"
|
||||
"github.com/Twigpine/zero/internal/providermodeldiscovery"
|
||||
"github.com/Twigpine/zero/internal/sessions"
|
||||
"github.com/Twigpine/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
func TestACPImageAttachmentHistory(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
text string
|
||||
images int
|
||||
withoutVision bool
|
||||
want string
|
||||
}{
|
||||
{name: "image only", images: 1, want: "[1 image]"},
|
||||
{name: "text and images", text: "compare these", images: 2, want: "compare these [2 images]"},
|
||||
{name: "ignored by model", images: 1, withoutVision: true, want: ""},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
deps := testDeps(t)
|
||||
deps.DiscoverModels = func(context.Context, config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
|
||||
modalities := []string{"text", "image"}
|
||||
if tc.withoutVision {
|
||||
modalities = []string{"text"}
|
||||
}
|
||||
return []providermodeldiscovery.Model{{ID: "fake-model", InputModalities: modalities}}, nil
|
||||
}
|
||||
type observedTurn struct {
|
||||
prompt string
|
||||
images int
|
||||
}
|
||||
turns := make(chan observedTurn, 8)
|
||||
deps.RunAgent = func(_ context.Context, prompt string, _ zeroruntime.Provider, opts agent.Options) (agent.Result, error) {
|
||||
turns <- observedTurn{prompt: prompt, images: len(opts.Images)}
|
||||
return agent.Result{FinalAnswer: "received"}, nil
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
workspace := t.TempDir()
|
||||
h := newHarness(t, deps)
|
||||
defer h.stop()
|
||||
var created NewSessionResult
|
||||
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: workspace}, &created); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const imageData = "image-history-bytes-not-for-storage"
|
||||
encoded := base64.StdEncoding.EncodeToString([]byte(imageData))
|
||||
var blocks []ContentBlock
|
||||
if tc.text != "" {
|
||||
blocks = append(blocks, TextBlock(tc.text))
|
||||
}
|
||||
for range tc.images {
|
||||
blocks = append(blocks, ImageBlock(encoded, "image/png"))
|
||||
}
|
||||
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{SessionID: created.SessionID, Prompt: blocks}, &PromptResult{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
live := <-turns
|
||||
wantLiveImages := tc.images
|
||||
if tc.withoutVision {
|
||||
wantLiveImages = 0
|
||||
}
|
||||
if live.images != wantLiveImages {
|
||||
t.Fatalf("live images = %d, want %d", live.images, wantLiveImages)
|
||||
}
|
||||
events, err := deps.Store.ReadEvents(created.SessionID)
|
||||
if err != nil || len(events) < 2 {
|
||||
t.Fatalf("read completed turn: events=%+v err=%v", events, err)
|
||||
}
|
||||
var user struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Attachments struct {
|
||||
Images int `json:"images"`
|
||||
} `json:"attachments"`
|
||||
}
|
||||
if err := json.Unmarshal(events[0].Payload, &user); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if user.Role != "user" || user.Content != tc.text || user.Attachments.Images != wantLiveImages {
|
||||
t.Errorf("stored user event = %s, want original text and attachments.images=%d", events[0].Payload, wantLiveImages)
|
||||
}
|
||||
for _, event := range events {
|
||||
if bytes.Contains(event.Payload, []byte(encoded)) || bytes.Contains(event.Payload, []byte(imageData)) {
|
||||
t.Errorf("image data leaked into session event: %s", event.Payload)
|
||||
}
|
||||
}
|
||||
followUp := func(t *testing.T, client *clientHarness) {
|
||||
t.Helper()
|
||||
if err := client.client.Call(ctx, MethodSessionPrompt, PromptParams{
|
||||
SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("what did I send?")},
|
||||
}, &PromptResult{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := <-turns
|
||||
if want := "User: " + tc.want + "\n"; !strings.Contains(got.prompt, want) {
|
||||
t.Errorf("seeded prompt missing user history %q: %q", want, got.prompt)
|
||||
}
|
||||
if got.images != 0 {
|
||||
t.Errorf("historical images reattached: %d", got.images)
|
||||
}
|
||||
}
|
||||
followUp(t, h)
|
||||
h.stop()
|
||||
// Fresh agents and stores reproduce a process restart, with no shared
|
||||
// in-memory history to hide a missing attachment in the event log.
|
||||
for _, method := range []string{MethodSessionLoad, MethodSessionResume} {
|
||||
t.Run(method, func(t *testing.T) {
|
||||
restoredDeps := deps
|
||||
restoredDeps.Store = sessions.NewStore(sessions.StoreOptions{RootDir: deps.Store.RootDir})
|
||||
loader := newHarness(t, restoredDeps)
|
||||
defer loader.stop()
|
||||
if err := loader.client.Call(ctx, method, LoadSessionParams{SessionID: created.SessionID, Cwd: workspace}, &LoadSessionResult{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if method == MethodSessionLoad {
|
||||
select {
|
||||
case update := <-loader.notifications:
|
||||
if update.SessionUpdate != UpdateUserMessageChunk || update.Content.Text != tc.want {
|
||||
t.Errorf("replayed user = %+v, want %q", update, tc.want)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
t.Fatal("user turn was not replayed")
|
||||
}
|
||||
} else {
|
||||
select {
|
||||
case update := <-loader.notifications:
|
||||
t.Errorf("resume replayed transcript: %+v", update)
|
||||
default:
|
||||
}
|
||||
}
|
||||
followUp(t, loader)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestACPAttachmentHistoryReadsExistingSessionMessages(t *testing.T) {
|
||||
deps := testDeps(t)
|
||||
meta, err := deps.Store.Create(sessions.CreateInput{SessionID: "attachment-compatibility", Cwd: t.TempDir()})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i, payload := range []string{
|
||||
`{"role":"user","content":"old text"}`,
|
||||
`{"role":"user","content":"","attachments":{"images":1,"documents":2}}`,
|
||||
`{"role":"user","content":"still text","attachments":{"images":-1,"documents":1.5}}`,
|
||||
`{"role":"assistant","content":"answer","attachments":{"images":1}}`,
|
||||
} {
|
||||
if _, err := deps.Store.AppendEvent(meta.SessionID, sessions.AppendEventInput{Type: sessions.EventMessage, Payload: json.RawMessage(payload)}); err != nil {
|
||||
t.Fatalf("append message %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
a := &Agent{deps: deps}
|
||||
_, messages, _, err := a.loadHistory(meta.SessionID, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []string{"old text", "[1 image] [2 documents]", "still text", "answer"}
|
||||
if len(messages) != len(want) {
|
||||
t.Fatalf("messages = %+v", messages)
|
||||
}
|
||||
for i, text := range want {
|
||||
if messages[i].content != text {
|
||||
t.Errorf("message %d = %q, want %q", i, messages[i].content, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
package acp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Twigpine/zero/internal/agent"
|
||||
"github.com/Twigpine/zero/internal/sessions"
|
||||
"github.com/Twigpine/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// captureAgentOptions makes every turn record the agent.Options it was built
|
||||
// with, so a test can assert the permission mode the next prompt actually runs
|
||||
// with rather than only what the wire reports.
|
||||
func captureAgentOptions(deps *Deps) *agent.Options {
|
||||
var captured agent.Options
|
||||
deps.RunAgent = func(_ context.Context, _ string, _ zeroruntime.Provider, opts agent.Options) (agent.Result, error) {
|
||||
captured = opts
|
||||
return agent.Result{FinalAnswer: "ok"}, nil
|
||||
}
|
||||
return &captured
|
||||
}
|
||||
|
||||
func newHarnessT(t *testing.T, deps Deps) *clientHarness {
|
||||
t.Helper()
|
||||
h := newHarness(t, deps)
|
||||
t.Cleanup(h.stop)
|
||||
return h
|
||||
}
|
||||
|
||||
func mustCall(t *testing.T, ctx context.Context, h *clientHarness, method string, params any, result any) {
|
||||
t.Helper()
|
||||
if err := h.client.Call(ctx, method, params, result); err != nil {
|
||||
t.Fatalf("%s: %v", method, err)
|
||||
}
|
||||
}
|
||||
|
||||
// restartHarness replaces the live agent with a fresh one over the same store,
|
||||
// which is the process restart the persisted mode has to survive.
|
||||
func restartHarness(t *testing.T, h *clientHarness, deps Deps) *clientHarness {
|
||||
t.Helper()
|
||||
h.stop()
|
||||
return newHarnessT(t, deps)
|
||||
}
|
||||
|
||||
func modeOptionValue(t *testing.T, options []SessionConfigOption) string {
|
||||
t.Helper()
|
||||
for _, option := range options {
|
||||
if option.ID == configIDMode {
|
||||
return option.CurrentValue
|
||||
}
|
||||
}
|
||||
t.Fatalf("no mode config option in %+v", options)
|
||||
return ""
|
||||
}
|
||||
|
||||
func assertModeReported(t *testing.T, result LoadSessionResult, want agent.PermissionMode) {
|
||||
t.Helper()
|
||||
if result.Modes == nil || result.Modes.CurrentModeID != string(want) {
|
||||
t.Fatalf("modes = %+v, want currentModeId %q", result.Modes, want)
|
||||
}
|
||||
if got := modeOptionValue(t, result.ConfigOptions); got != string(want) {
|
||||
t.Fatalf("mode config option = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACPModePersistsAcrossRestartLoadAndResume(t *testing.T) {
|
||||
deps := testDeps(t)
|
||||
captured := captureAgentOptions(&deps)
|
||||
h := newHarnessT(t, deps)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
workspace := t.TempDir()
|
||||
var created NewSessionResult
|
||||
mustCall(t, ctx, h, MethodSessionNew, NewSessionParams{Cwd: workspace, McpServers: []McpServer{}}, &created)
|
||||
|
||||
mustCall(t, ctx, h, MethodSessionSetMode, SetSessionModeParams{SessionID: created.SessionID, ModeID: string(agent.PermissionModePlan)}, &SetSessionModeResult{})
|
||||
meta, err := deps.Store.Get(created.SessionID)
|
||||
if err != nil || meta == nil {
|
||||
t.Fatalf("store.Get: %v", err)
|
||||
}
|
||||
if meta.PermissionMode != string(agent.PermissionModePlan) {
|
||||
t.Fatalf("stored mode = %q, want plan", meta.PermissionMode)
|
||||
}
|
||||
|
||||
// LOAD after a restart: the response must report the stored mode, and the
|
||||
// next prompt must actually run with it.
|
||||
h = restartHarness(t, h, deps)
|
||||
var loaded LoadSessionResult
|
||||
mustCall(t, ctx, h, MethodSessionLoad, LoadSessionParams{SessionID: created.SessionID, Cwd: workspace, McpServers: []McpServer{}}, &loaded)
|
||||
assertModeReported(t, loaded, agent.PermissionModePlan)
|
||||
mustCall(t, ctx, h, MethodSessionPrompt, PromptParams{SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("continue")}}, &PromptResult{})
|
||||
if captured.PermissionMode != agent.PermissionModePlan {
|
||||
t.Fatalf("after load, agent.Options.PermissionMode = %q, want plan", captured.PermissionMode)
|
||||
}
|
||||
|
||||
// RESUME after another restart reports the same.
|
||||
h = restartHarness(t, h, deps)
|
||||
var resumed ResumeSessionResult
|
||||
mustCall(t, ctx, h, MethodSessionResume, ResumeSessionParams{SessionID: created.SessionID, Cwd: workspace, McpServers: []McpServer{}}, &resumed)
|
||||
assertModeReported(t, resumed, agent.PermissionModePlan)
|
||||
}
|
||||
|
||||
func TestACPModePersistsViaSetConfigOption(t *testing.T) {
|
||||
deps := testDeps(t)
|
||||
h := newHarnessT(t, deps)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
workspace := t.TempDir()
|
||||
var created NewSessionResult
|
||||
mustCall(t, ctx, h, MethodSessionNew, NewSessionParams{Cwd: workspace, McpServers: []McpServer{}}, &created)
|
||||
|
||||
var configured SetSessionConfigOptionResult
|
||||
mustCall(t, ctx, h, MethodSessionSetConfigOption, SetSessionConfigOptionParams{
|
||||
SessionID: created.SessionID, ConfigID: configIDMode, Value: string(agent.PermissionModeAsk),
|
||||
}, &configured)
|
||||
if got := modeOptionValue(t, configured.ConfigOptions); got != string(agent.PermissionModeAsk) {
|
||||
t.Fatalf("configured mode = %q, want ask", got)
|
||||
}
|
||||
meta, err := deps.Store.Get(created.SessionID)
|
||||
if err != nil || meta == nil {
|
||||
t.Fatalf("store.Get: %v", err)
|
||||
}
|
||||
if meta.PermissionMode != string(agent.PermissionModeAsk) {
|
||||
t.Fatalf("stored mode = %q, want ask", meta.PermissionMode)
|
||||
}
|
||||
|
||||
h = restartHarness(t, h, deps)
|
||||
var loaded LoadSessionResult
|
||||
mustCall(t, ctx, h, MethodSessionLoad, LoadSessionParams{SessionID: created.SessionID, Cwd: workspace, McpServers: []McpServer{}}, &loaded)
|
||||
assertModeReported(t, loaded, agent.PermissionModeAsk)
|
||||
}
|
||||
|
||||
// TestACPModeRestoreFallback pins the restore boundary: no stored value keeps
|
||||
// the historical auto default, and a stored value outside the three offered
|
||||
// modes must never come back weaker than ask (and never unsafe).
|
||||
func TestACPModeRestoreFallback(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
seed string // "" means metadata written before modes were persisted
|
||||
want agent.PermissionMode
|
||||
}{
|
||||
{name: "unset keeps auto", want: agent.PermissionModeAuto},
|
||||
{name: "ask preserved", seed: string(agent.PermissionModeAsk), want: agent.PermissionModeAsk},
|
||||
{name: "unknown falls back to ask", seed: "bogus", want: agent.PermissionModeAsk},
|
||||
{name: "unsafe never restored", seed: string(agent.PermissionModeUnsafe), want: agent.PermissionModeAsk},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
deps := testDeps(t)
|
||||
captured := captureAgentOptions(&deps)
|
||||
h := newHarnessT(t, deps)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
workspace := t.TempDir()
|
||||
var created NewSessionResult
|
||||
mustCall(t, ctx, h, MethodSessionNew, NewSessionParams{Cwd: workspace, McpServers: []McpServer{}}, &created)
|
||||
if tc.seed != "" {
|
||||
if _, err := deps.Store.UpdateMode(created.SessionID, tc.seed); err != nil {
|
||||
t.Fatalf("seed mode: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
h = restartHarness(t, h, deps)
|
||||
var loaded LoadSessionResult
|
||||
mustCall(t, ctx, h, MethodSessionLoad, LoadSessionParams{SessionID: created.SessionID, Cwd: workspace, McpServers: []McpServer{}}, &loaded)
|
||||
assertModeReported(t, loaded, tc.want)
|
||||
|
||||
mustCall(t, ctx, h, MethodSessionPrompt, PromptParams{SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("go")}}, &PromptResult{})
|
||||
if captured.PermissionMode != tc.want {
|
||||
t.Fatalf("next prompt PermissionMode = %q, want %q", captured.PermissionMode, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestACPSetModeWriteFailureKeepsLiveMode pins the store-first order: when the
|
||||
// mode cannot be saved the request fails and the live session is untouched.
|
||||
func TestACPSetModeWriteFailureKeepsLiveMode(t *testing.T) {
|
||||
deps := testDeps(t)
|
||||
captured := captureAgentOptions(&deps)
|
||||
h := newHarnessT(t, deps)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var created NewSessionResult
|
||||
mustCall(t, ctx, h, MethodSessionNew, NewSessionParams{Cwd: t.TempDir(), McpServers: []McpServer{}}, &created)
|
||||
|
||||
// Remove the metadata file so UpdateMode fails but the live session stays
|
||||
// promptable (turn persistence only warns when the file is gone).
|
||||
metadataPath := filepath.Join(deps.Store.RootDir, created.SessionID, sessions.MetadataFile)
|
||||
if err := os.Remove(metadataPath); err != nil {
|
||||
t.Fatalf("remove metadata: %v", err)
|
||||
}
|
||||
|
||||
err := h.client.Call(ctx, MethodSessionSetMode, SetSessionModeParams{SessionID: created.SessionID, ModeID: string(agent.PermissionModePlan)}, &SetSessionModeResult{})
|
||||
var rpcErr *rpcError
|
||||
if !errors.As(err, &rpcErr) || rpcErr.Code != codeInternalError {
|
||||
t.Fatalf("set_mode with an unsavable mode = %v, want internal error", err)
|
||||
}
|
||||
|
||||
mustCall(t, ctx, h, MethodSessionPrompt, PromptParams{SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("still auto")}}, &PromptResult{})
|
||||
if captured.PermissionMode != agent.PermissionModeAuto {
|
||||
t.Fatalf("failed set_mode changed the live mode: PermissionMode = %q, want auto", captured.PermissionMode)
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ package acp
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/Twigpine/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
)
|
||||
|
||||
// permission.go maps ZERO's permission prompt model onto ACP's
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Twigpine/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
)
|
||||
|
||||
func escalationRequest(escalates bool) agent.PermissionRequest {
|
||||
|
||||
@@ -3,7 +3,7 @@ package acp
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Twigpine/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
)
|
||||
|
||||
func TestBuildPermissionOptions(t *testing.T) {
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Twigpine/zero/internal/agent"
|
||||
"github.com/Twigpine/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
// translate.go maps ZERO's agent events onto ACP session/update payloads. The
|
||||
@@ -83,7 +83,7 @@ func browserToolDetails(name string) (*BrowserToolDetails, bool) {
|
||||
}
|
||||
}
|
||||
|
||||
const zeroBrowserMetaKey = "github.com/Twigpine/zero/browser"
|
||||
const zeroBrowserMetaKey = "github.com/Gitlawb/zero/browser"
|
||||
|
||||
// attachBrowserToolDetails stores ZERO's browser descriptor in ACP's reserved
|
||||
// extension channel. Keeping this in one helper prevents start, result, and
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Twigpine/zero/internal/agent"
|
||||
"github.com/Twigpine/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/agent"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
func browserDescriptor(t *testing.T, update ToolCallUpdate) BrowserToolDetails {
|
||||
|
||||
@@ -1,309 +0,0 @@
|
||||
package acp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Twigpine/zero/internal/agent"
|
||||
"github.com/Twigpine/zero/internal/sessions"
|
||||
"github.com/Twigpine/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// `zero exec` persists every provider usage report as a session usage event, so
|
||||
// `zero usage report --session <id>` can total a run. The ACP agent did not set
|
||||
// OnUsage, which dropped the token counts of every ACP session. A turn with two
|
||||
// provider requests (a tool round trip) must leave two usage events, in the shape
|
||||
// the usage report reads, next to the turn's messages.
|
||||
func TestACPPromptPersistsProviderUsage(t *testing.T) {
|
||||
deps := testDeps(t)
|
||||
deps.RunAgent = func(_ context.Context, _ string, _ zeroruntime.Provider, opts agent.Options) (agent.Result, error) {
|
||||
if opts.OnUsage == nil {
|
||||
t.Errorf("agent.Options.OnUsage is not wired: the ACP agent drops provider token usage")
|
||||
return agent.Result{FinalAnswer: "done"}, nil
|
||||
}
|
||||
opts.OnUsage(agent.Usage{InputTokens: 1200, OutputTokens: 34})
|
||||
opts.OnUsage(agent.Usage{InputTokens: 1300, OutputTokens: 21, CachedInputTokens: 900})
|
||||
return agent.Result{FinalAnswer: "done"}, nil
|
||||
}
|
||||
|
||||
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.Fatal(err)
|
||||
}
|
||||
var result PromptResult
|
||||
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{
|
||||
SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("count my tokens")},
|
||||
}, &result); err != nil {
|
||||
t.Fatalf("session/prompt: %v", err)
|
||||
}
|
||||
|
||||
events, err := deps.Store.ReadEvents(created.SessionID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
type usagePayload struct {
|
||||
PromptTokens int `json:"promptTokens"`
|
||||
CompletionTokens int `json:"completionTokens"`
|
||||
TotalTokens int `json:"totalTokens"`
|
||||
CachedInputTokens int `json:"cachedInputTokens"`
|
||||
}
|
||||
var got []usagePayload
|
||||
messages := 0
|
||||
for _, event := range events {
|
||||
switch event.Type {
|
||||
case sessions.EventUsage:
|
||||
var payload usagePayload
|
||||
if err := json.Unmarshal(event.Payload, &payload); err != nil {
|
||||
t.Fatalf("usage payload %s: %v", event.Payload, err)
|
||||
}
|
||||
assertUsagePayloadIsTokenCountsOnly(t, event.Payload)
|
||||
got = append(got, payload)
|
||||
case sessions.EventMessage:
|
||||
messages++
|
||||
}
|
||||
}
|
||||
want := []usagePayload{
|
||||
{PromptTokens: 1200, CompletionTokens: 34, TotalTokens: 1234},
|
||||
{PromptTokens: 1300, CompletionTokens: 21, TotalTokens: 1321, CachedInputTokens: 900},
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("usage events = %+v, want %+v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("usage event %d = %+v, want %+v", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
if messages != 2 {
|
||||
t.Fatalf("message events = %d, want the user and assistant messages of the turn", messages)
|
||||
}
|
||||
}
|
||||
|
||||
// Usage joins the turn's buffered batch, so a hard run failure that aborts the
|
||||
// turn leaves nothing durable, exactly as it does for the turn's messages.
|
||||
func TestACPHardTurnFailureDropsItsUsageWithTheTurn(t *testing.T) {
|
||||
deps := testDeps(t)
|
||||
deps.RunAgent = func(_ context.Context, _ string, _ zeroruntime.Provider, opts agent.Options) (agent.Result, error) {
|
||||
if opts.OnUsage == nil {
|
||||
t.Errorf("agent.Options.OnUsage is not wired: the ACP agent drops provider token usage")
|
||||
return agent.Result{}, errors.New("injected hard run failure")
|
||||
}
|
||||
opts.OnUsage(agent.Usage{InputTokens: 500, OutputTokens: 5})
|
||||
return agent.Result{}, errors.New("injected hard run failure")
|
||||
}
|
||||
|
||||
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.Fatal(err)
|
||||
}
|
||||
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{
|
||||
SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("failing turn")},
|
||||
}, &PromptResult{}); err == nil {
|
||||
t.Fatal("hard RunAgent failure was reported as a successful turn")
|
||||
}
|
||||
events, err := deps.Store.ReadEvents(created.SessionID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(events) != 0 {
|
||||
t.Fatalf("aborted turn left durable events: %+v", events)
|
||||
}
|
||||
}
|
||||
|
||||
// StopCancelled is a successful ACP outcome: the turn commits. Tokens the
|
||||
// provider already reported were spent, so they stay in the session log even
|
||||
// though the client cancelled. A hard error (the test above) is the path that
|
||||
// drops them.
|
||||
func TestACPCancelledTurnKeepsReportedUsage(t *testing.T) {
|
||||
deps := testDeps(t)
|
||||
deps.RunAgent = func(_ context.Context, _ string, _ zeroruntime.Provider, opts agent.Options) (agent.Result, error) {
|
||||
if opts.OnUsage == nil {
|
||||
t.Errorf("agent.Options.OnUsage is not wired: the ACP agent drops provider token usage")
|
||||
return agent.Result{}, context.Canceled
|
||||
}
|
||||
opts.OnUsage(agent.Usage{InputTokens: 80, OutputTokens: 3})
|
||||
return agent.Result{FinalAnswer: "partial"}, context.Canceled
|
||||
}
|
||||
|
||||
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.Fatal(err)
|
||||
}
|
||||
var result PromptResult
|
||||
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{
|
||||
SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("cancelled turn")},
|
||||
}, &result); err != nil {
|
||||
t.Fatalf("cancelled turn returned an RPC error: %v", err)
|
||||
}
|
||||
if result.StopReason != StopCancelled {
|
||||
t.Fatalf("stop reason = %q, want %q", result.StopReason, StopCancelled)
|
||||
}
|
||||
events, err := deps.Store.ReadEvents(created.SessionID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
usageEvents := 0
|
||||
for _, event := range events {
|
||||
if event.Type != sessions.EventUsage {
|
||||
continue
|
||||
}
|
||||
usageEvents++
|
||||
assertUsagePayloadIsTokenCountsOnly(t, event.Payload)
|
||||
var payload struct {
|
||||
PromptTokens int `json:"promptTokens"`
|
||||
CompletionTokens int `json:"completionTokens"`
|
||||
TotalTokens int `json:"totalTokens"`
|
||||
}
|
||||
if err := json.Unmarshal(event.Payload, &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload.PromptTokens != 80 || payload.CompletionTokens != 3 || payload.TotalTokens != 83 {
|
||||
t.Fatalf("cancelled-turn usage = %+v, want 80/3/83", payload)
|
||||
}
|
||||
}
|
||||
if usageEvents != 1 {
|
||||
t.Fatalf("usage events = %d, want the one report from the cancelled turn", usageEvents)
|
||||
}
|
||||
}
|
||||
|
||||
// Usage events sit in the same log session/load and session/resume read, but
|
||||
// history reconstruction skips them. The wire replay stays the conversation,
|
||||
// and the next turn's prompt stays the conversation: token counts never become
|
||||
// context and never cross the ACP wire.
|
||||
func TestACPLoadAndResumeIgnoreUsageEvents(t *testing.T) {
|
||||
const userLine = "unique-acp-user-line"
|
||||
const answer = "unique-acp-answer"
|
||||
deps := testDeps(t)
|
||||
deps.RunAgent = func(_ context.Context, _ string, _ zeroruntime.Provider, opts agent.Options) (agent.Result, error) {
|
||||
if opts.OnUsage == nil {
|
||||
t.Errorf("agent.Options.OnUsage is not wired: the ACP agent drops provider token usage")
|
||||
return agent.Result{FinalAnswer: answer}, nil
|
||||
}
|
||||
opts.OnUsage(agent.Usage{InputTokens: 1200, OutputTokens: 34})
|
||||
return agent.Result{FinalAnswer: answer}, nil
|
||||
}
|
||||
|
||||
workspace := t.TempDir()
|
||||
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: workspace}, &created); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{
|
||||
SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock(userLine)},
|
||||
}, &PromptResult{}); err != nil {
|
||||
t.Fatalf("session/prompt: %v", err)
|
||||
}
|
||||
h.stop()
|
||||
|
||||
events, err := deps.Store.ReadEvents(created.SessionID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sawUsage := false
|
||||
for _, event := range events {
|
||||
if event.Type == sessions.EventUsage {
|
||||
sawUsage = true
|
||||
assertUsagePayloadIsTokenCountsOnly(t, event.Payload)
|
||||
}
|
||||
}
|
||||
if !sawUsage {
|
||||
t.Fatal("session log has no usage event to ignore")
|
||||
}
|
||||
|
||||
loader := newHarness(t, deps)
|
||||
if err := loader.client.Call(ctx, MethodSessionLoad, LoadSessionParams{
|
||||
SessionID: created.SessionID, Cwd: workspace, McpServers: []McpServer{},
|
||||
}, &LoadSessionResult{}); err != nil {
|
||||
t.Fatalf("session/load: %v", err)
|
||||
}
|
||||
var replayed []string
|
||||
for {
|
||||
select {
|
||||
case update := <-loader.notifications:
|
||||
replayed = append(replayed, update.Content.Text)
|
||||
case <-time.After(150 * time.Millisecond):
|
||||
goto drained
|
||||
}
|
||||
}
|
||||
drained:
|
||||
loader.stop()
|
||||
if len(replayed) != 2 || replayed[0] != userLine || replayed[1] != answer {
|
||||
t.Fatalf("session/load replay = %#v, want the user line and the answer only", replayed)
|
||||
}
|
||||
for _, text := range replayed {
|
||||
if strings.Contains(text, "promptTokens") || strings.Contains(text, "1200") {
|
||||
t.Fatalf("session/load replayed usage onto the wire: %q", text)
|
||||
}
|
||||
}
|
||||
|
||||
var gotPrompt string
|
||||
deps.RunAgent = func(_ context.Context, prompt string, _ zeroruntime.Provider, _ agent.Options) (agent.Result, error) {
|
||||
gotPrompt = prompt
|
||||
return agent.Result{FinalAnswer: "continued"}, nil
|
||||
}
|
||||
resumer := newHarness(t, deps)
|
||||
defer resumer.stop()
|
||||
if err := resumer.client.Call(ctx, MethodSessionResume, ResumeSessionParams{
|
||||
SessionID: created.SessionID, Cwd: workspace, McpServers: []McpServer{},
|
||||
}, &ResumeSessionResult{}); err != nil {
|
||||
t.Fatalf("session/resume: %v", err)
|
||||
}
|
||||
if err := resumer.client.Call(ctx, MethodSessionPrompt, PromptParams{
|
||||
SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("carry on")},
|
||||
}, &PromptResult{}); err != nil {
|
||||
t.Fatalf("resumed session/prompt: %v", err)
|
||||
}
|
||||
if !strings.Contains(gotPrompt, userLine) || !strings.Contains(gotPrompt, answer) {
|
||||
t.Fatalf("resumed prompt = %q, want the prior conversation", gotPrompt)
|
||||
}
|
||||
if strings.Contains(gotPrompt, "promptTokens") || strings.Contains(gotPrompt, "1200") {
|
||||
t.Fatalf("resumed prompt contains persisted usage: %q", gotPrompt)
|
||||
}
|
||||
}
|
||||
|
||||
// usagePayloadAllowedKeys is the non-escalating EventUsagePayload shape. ACP
|
||||
// has no model escalation, and the writer must not grow a prompt, tool
|
||||
// argument, or other raw field.
|
||||
var usagePayloadAllowedKeys = map[string]bool{
|
||||
"promptTokens": true,
|
||||
"completionTokens": true,
|
||||
"totalTokens": true,
|
||||
"cachedInputTokens": true,
|
||||
"cacheWriteTokens": true,
|
||||
"reasoningTokens": true,
|
||||
}
|
||||
|
||||
func assertUsagePayloadIsTokenCountsOnly(t *testing.T, payload json.RawMessage) {
|
||||
t.Helper()
|
||||
var raw map[string]json.RawMessage
|
||||
if err := json.Unmarshal(payload, &raw); err != nil {
|
||||
t.Fatalf("usage payload %s: %v", payload, err)
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
t.Fatalf("usage payload is empty: %s", payload)
|
||||
}
|
||||
for key := range raw {
|
||||
if !usagePayloadAllowedKeys[key] {
|
||||
t.Fatalf("usage payload key %q is not a token count: %s", key, payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Twigpine/zero/internal/tools"
|
||||
"github.com/Twigpine/zero/internal/zeroruntime"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
func enabledSelfCorrector(t *testing.T) *SelfCorrector {
|
||||
|
||||
@@ -5,8 +5,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Twigpine/zero/internal/sandbox"
|
||||
"github.com/Twigpine/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
type normalizationRecordingShellTool struct {
|
||||
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Twigpine/zero/internal/tools"
|
||||
"github.com/Twigpine/zero/internal/zeroruntime"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// providerCallingAskUserThenAnswer returns a mock provider whose first turn calls
|
||||
|
||||
@@ -8,9 +8,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Twigpine/zero/internal/testutil"
|
||||
"github.com/Twigpine/zero/internal/tools"
|
||||
"github.com/Twigpine/zero/internal/zeroruntime"
|
||||
"github.com/Gitlawb/zero/internal/testutil"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
func TestAsyncDiagnosticsNilCollectorNoOps(t *testing.T) {
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
package agent
|
||||
|
||||
import "github.com/Twigpine/zero/internal/tools"
|
||||
|
||||
// CheckpointTargets returns the workspace-relative paths a tool call will mutate,
|
||||
// resolving its arguments exactly as executeToolCall does. Freeform apply_patch
|
||||
// input is normalized through the registered built-in tool, so absolute headers
|
||||
// are rewritten relative to the selected write root and cwd is set: checkpoint
|
||||
// capture and execution therefore agree on the files at stake.
|
||||
//
|
||||
// Best-effort: nil when the call has no capture targets or its arguments cannot
|
||||
// be resolved. Checkpoint capture never fails a run.
|
||||
func CheckpointTargets(workspaceRoot string, registry *tools.Registry, call ToolCall) []string {
|
||||
if registry == nil {
|
||||
return nil
|
||||
}
|
||||
args := map[string]any{}
|
||||
if call.Freeform {
|
||||
tool, ok := registry.Get(call.Name)
|
||||
if !ok || !tools.IsBuiltInApplyPatch(tool) {
|
||||
return nil
|
||||
}
|
||||
prepared, err := tools.PrepareFreeformApplyPatchArguments(tool, call.Arguments)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
args = prepared
|
||||
} else if call.Arguments != "" {
|
||||
if err := decodeToolArguments(call.Arguments, &args); err != nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return tools.MutationTargets(workspaceRoot, call.Name, args)
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/Twigpine/zero/internal/tools"
|
||||
)
|
||||
|
||||
func TestCheckpointTargets(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
// Absolute headers must be compared against the physical root: t.TempDir
|
||||
// differs from it on macOS (/var -> /private/var) and via Windows short names.
|
||||
if resolved, err := filepath.EvalSymlinks(root); err == nil {
|
||||
root = resolved
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "main.txt"), []byte("before\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
relativePatch := "*** Begin Patch\n*** Update File: main.txt\n@@\n-before\n+after\n*** End Patch\n"
|
||||
absolutePatch := "*** Begin Patch\n*** Update File: " + filepath.Join(root, "main.txt") + "\n@@\n-before\n+after\n*** End Patch\n"
|
||||
multiFilePatch := "*** Begin Patch\n*** Update File: main.txt\n@@\n-before\n+after\n*** Add File: notes.txt\n+new\n*** End Patch\n"
|
||||
jsonArgs, err := json.Marshal(map[string]any{"patch": relativePatch})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedApplyPatchTool(root, nil))
|
||||
unbuiltinRegistry := tools.NewRegistry()
|
||||
unbuiltinRegistry.Register(&customApplyPatchTool{})
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
registry *tools.Registry
|
||||
call ToolCall
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "freeform relative header",
|
||||
call: ToolCall{Name: "apply_patch", Arguments: relativePatch, Freeform: true},
|
||||
want: []string{"main.txt"},
|
||||
},
|
||||
{
|
||||
name: "freeform absolute header stays workspace-relative",
|
||||
call: ToolCall{Name: "apply_patch", Arguments: absolutePatch, Freeform: true},
|
||||
want: []string{"main.txt"},
|
||||
},
|
||||
{
|
||||
name: "freeform multi-file patch",
|
||||
call: ToolCall{Name: "apply_patch", Arguments: multiFilePatch, Freeform: true},
|
||||
want: []string{"main.txt", "notes.txt"},
|
||||
},
|
||||
{
|
||||
name: "json arguments",
|
||||
call: ToolCall{Name: "apply_patch", Arguments: string(jsonArgs)},
|
||||
want: []string{"main.txt"},
|
||||
},
|
||||
{
|
||||
name: "freeform traversal is rejected",
|
||||
call: ToolCall{Name: "apply_patch", Arguments: "*** Begin Patch\n*** Update File: ../escape.txt\n@@\n-x\n+y\n*** End Patch\n", Freeform: true},
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "freeform custom apply_patch is not prepared",
|
||||
registry: unbuiltinRegistry,
|
||||
call: ToolCall{Name: "apply_patch", Arguments: relativePatch, Freeform: true},
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "unknown freeform tool",
|
||||
call: ToolCall{Name: "read_file", Arguments: "raw", Freeform: true},
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "malformed json arguments",
|
||||
call: ToolCall{Name: "apply_patch", Arguments: "{"},
|
||||
want: nil,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
reg := registry
|
||||
if tc.registry != nil {
|
||||
reg = tc.registry
|
||||
}
|
||||
got := CheckpointTargets(root, reg, tc.call)
|
||||
if !slices.Equal(got, tc.want) {
|
||||
t.Fatalf("CheckpointTargets() = %#v, want %#v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if got := CheckpointTargets(root, nil, ToolCall{Name: "apply_patch", Arguments: relativePatch}); got != nil {
|
||||
t.Fatalf("CheckpointTargets(nil registry) = %#v, want nil", got)
|
||||
}
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/Twigpine/zero/internal/streamjson"
|
||||
"github.com/Twigpine/zero/internal/tools"
|
||||
)
|
||||
|
||||
// progressProbeTool records whether the loop handed it a progress callback.
|
||||
// streams mirrors what a real tool declares via tools.ChildProgressStreamer.
|
||||
type progressProbeTool struct {
|
||||
name string
|
||||
streams bool
|
||||
got *bool
|
||||
}
|
||||
|
||||
func (t *progressProbeTool) Name() string { return t.name }
|
||||
func (t *progressProbeTool) Description() string { return "probe" }
|
||||
func (t *progressProbeTool) Parameters() tools.Schema {
|
||||
return tools.Schema{Type: "object", Properties: map[string]tools.PropertySchema{}}
|
||||
}
|
||||
func (t *progressProbeTool) Safety() tools.Safety {
|
||||
return tools.Safety{SideEffect: tools.SideEffectRead, Permission: tools.PermissionAllow}
|
||||
}
|
||||
func (t *progressProbeTool) Run(context.Context, map[string]any) tools.Result {
|
||||
*t.got = false
|
||||
return tools.Result{Status: tools.StatusOK, Output: "ok"}
|
||||
}
|
||||
func (t *progressProbeTool) RunWithOptions(_ context.Context, _ map[string]any, options tools.RunOptions) tools.Result {
|
||||
*t.got = options.Progress != nil
|
||||
return tools.Result{Status: tools.StatusOK, Output: "ok"}
|
||||
}
|
||||
|
||||
// StreamsChildProgress makes this type implement tools.ChildProgressStreamer;
|
||||
// the flag drives the answer. A tool that never declares at all is modelled by
|
||||
// silentProbeTool below, which genuinely does not implement the interface.
|
||||
func (t *progressProbeTool) StreamsChildProgress() bool { return t.streams }
|
||||
|
||||
// silentProbeTool does NOT implement tools.ChildProgressStreamer at all — the
|
||||
// state every tool in the tree is in today except Task and orchestrate.
|
||||
type silentProbeTool struct {
|
||||
name string
|
||||
got *bool
|
||||
}
|
||||
|
||||
func (t *silentProbeTool) Name() string { return t.name }
|
||||
func (t *silentProbeTool) Description() string { return "probe" }
|
||||
func (t *silentProbeTool) Parameters() tools.Schema {
|
||||
return tools.Schema{Type: "object", Properties: map[string]tools.PropertySchema{}}
|
||||
}
|
||||
func (t *silentProbeTool) Safety() tools.Safety {
|
||||
return tools.Safety{SideEffect: tools.SideEffectRead, Permission: tools.PermissionAllow}
|
||||
}
|
||||
func (t *silentProbeTool) Run(context.Context, map[string]any) tools.Result {
|
||||
*t.got = false
|
||||
return tools.Result{Status: tools.StatusOK, Output: "ok"}
|
||||
}
|
||||
func (t *silentProbeTool) RunWithOptions(_ context.Context, _ map[string]any, options tools.RunOptions) tools.Result {
|
||||
*t.got = options.Progress != nil
|
||||
return tools.Result{Status: tools.StatusOK, Output: "ok"}
|
||||
}
|
||||
|
||||
func runProbe(t *testing.T, tool tools.Tool, got *bool, withSink bool) bool {
|
||||
t.Helper()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tool)
|
||||
options := Options{}
|
||||
if withSink {
|
||||
options.OnToolProgress = func(string, streamjson.Event) {}
|
||||
}
|
||||
result, err := executeToolCall(context.Background(),
|
||||
registry,
|
||||
ToolCall{ID: "call_1", Name: tool.Name(), Arguments: "{}"},
|
||||
PermissionModeUnsafe,
|
||||
options)
|
||||
if err != nil {
|
||||
t.Fatalf("executeToolCall: %v", err)
|
||||
}
|
||||
if result.Status == "" {
|
||||
t.Fatalf("probe produced no result")
|
||||
}
|
||||
return *got
|
||||
}
|
||||
|
||||
// THE PARITY OBLIGATION for un-gating the progress path.
|
||||
//
|
||||
// The relationship asserted here is EQUALITY, per RULES.md §3: a tool's
|
||||
// progress wiring before and after this change must be the same for every tool
|
||||
// that already had it and every tool that already lacked it. Only a tool that
|
||||
// newly DECLARES the interface may change.
|
||||
//
|
||||
// The name gate is gone, so the guarantee cannot be "Task still works" — it has
|
||||
// to be stated in terms of the declaration, which is what these cases do.
|
||||
func TestProgressCallbackFollowsTheDeclarationNotTheName(t *testing.T) {
|
||||
t.Run("a declaring tool receives it (Task's behaviour, preserved)", func(t *testing.T) {
|
||||
var got bool
|
||||
// Named something other than "Task" ON PURPOSE: under the old name gate
|
||||
// this case failed, which is the whole point of the change.
|
||||
if !runProbe(t, &progressProbeTool{name: "spawner", streams: true, got: &got}, &got, true) {
|
||||
t.Fatal("a tool declaring StreamsChildProgress must receive the callback")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a tool named Task that does NOT declare gets nothing", func(t *testing.T) {
|
||||
var got bool
|
||||
// The inverse of the old behaviour, and the reason a name is the wrong
|
||||
// key: identity is not capability.
|
||||
if runProbe(t, &silentProbeTool{name: "Task", got: &got}, &got, true) {
|
||||
t.Fatal("the callback must follow the declaration, not the name")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a non-declaring tool receives nothing (every other tool, unchanged)", func(t *testing.T) {
|
||||
var got bool
|
||||
if runProbe(t, &silentProbeTool{name: "read_file", got: &got}, &got, true) {
|
||||
t.Fatal("un-gating must not start handing a callback to tools that never had one")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a declaring tool that answers false gets nothing", func(t *testing.T) {
|
||||
var got bool
|
||||
if runProbe(t, &progressProbeTool{name: "spawner", streams: false, got: &got}, &got, true) {
|
||||
t.Fatal("StreamsChildProgress() == false must be honoured")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no sink means no callback, whatever the tool declares", func(t *testing.T) {
|
||||
var got bool
|
||||
if runProbe(t, &progressProbeTool{name: "spawner", streams: true, got: &got}, &got, false) {
|
||||
t.Fatal("without OnToolProgress there is nothing to forward to")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -5,8 +5,8 @@ import (
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/Twigpine/zero/internal/sandbox"
|
||||
"github.com/Twigpine/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"mvdan.cc/sh/v3/syntax"
|
||||
)
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ import (
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/Twigpine/zero/internal/sandbox"
|
||||
"github.com/Twigpine/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
func TestProposedCommandPrefixUsesSafeSimpleCommands(t *testing.T) {
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Twigpine/zero/internal/trace"
|
||||
"github.com/Twigpine/zero/internal/zeroruntime"
|
||||
"github.com/Gitlawb/zero/internal/trace"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// Session compaction.
|
||||
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Twigpine/zero/internal/tools"
|
||||
"github.com/Twigpine/zero/internal/zeroruntime"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// compactableHistory returns a history whose middle is large enough to trip
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Twigpine/zero/internal/zeroruntime"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
func TestCompactMessagesReturnsMetadataForManualCompaction(t *testing.T) {
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Twigpine/zero/internal/zeroruntime"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// Compaction preservation.
|
||||
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Twigpine/zero/internal/tools"
|
||||
"github.com/Twigpine/zero/internal/zeroruntime"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// stateConversation is a long enough conversation that Compact elides a middle
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/Twigpine/zero/internal/zeroruntime"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/Twigpine/zero/internal/zeroruntime"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
func TestClipBytesHandlesTinyLimitsOnRuneBoundaries(t *testing.T) {
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Twigpine/zero/internal/zeroruntime"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// recentEdits extracts each mutated file's path and a one-line note from the
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/Twigpine/zero/internal/zeroruntime"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
var markerPattern = regexp.MustCompile(`msg-\d+`)
|
||||
|
||||
@@ -6,9 +6,9 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Twigpine/zero/internal/tools"
|
||||
"github.com/Twigpine/zero/internal/trace"
|
||||
"github.com/Twigpine/zero/internal/zeroruntime"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/trace"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// --- Pure Compact() tests -------------------------------------------------
|
||||
|
||||
@@ -5,8 +5,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Twigpine/zero/internal/tools"
|
||||
"github.com/Twigpine/zero/internal/zeroruntime"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// someRequestContains reports whether any message sent to the provider across all
|
||||
|
||||
@@ -3,7 +3,7 @@ package agent
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/Twigpine/zero/internal/zeroruntime"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// Context budget.
|
||||
|
||||
@@ -3,7 +3,7 @@ package agent
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Twigpine/zero/internal/zeroruntime"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
func TestMeasureContextSplitsByCategory(t *testing.T) {
|
||||
|
||||
@@ -3,7 +3,7 @@ package agent
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/Twigpine/zero/internal/zeroruntime"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
type contextPlannerConfig struct {
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/Twigpine/zero/internal/zeroruntime"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
func TestContextPlannerPreservesProviderRequest(t *testing.T) {
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/Twigpine/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
// richMCPTool mimics a real MCP tool's schema (Exa/Firecrawl-style search): an
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Twigpine/zero/internal/tools"
|
||||
"github.com/Twigpine/zero/internal/zeroruntime"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
func TestOptionsDeferThresholdFieldExists(t *testing.T) {
|
||||
|
||||
@@ -4,8 +4,8 @@ import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/Twigpine/zero/internal/sandbox"
|
||||
"github.com/Twigpine/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
func TestExecuteToolCallCategorizesFilteredDenial(t *testing.T) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/Twigpine/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
// argsPermTool implements tools.ArgsPermissioner; plainTool does not.
|
||||
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Twigpine/zero/internal/tools"
|
||||
"github.com/Twigpine/zero/internal/zeroruntime"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// TestOptionsModelSwitcherFieldExists asserts the escalation hook is an
|
||||
|
||||
@@ -2,9 +2,8 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"github.com/Twigpine/zero/internal/specialist"
|
||||
"github.com/Twigpine/zero/internal/tools"
|
||||
"github.com/Twigpine/zero/internal/zeroruntime"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
func equalStringSlices(left []string, right []string) bool {
|
||||
@@ -40,15 +39,3 @@ func parsePreservedState(summaryContent string) (string, []skillEntry) {
|
||||
func partitionTools(registry *tools.Registry, permissionMode PermissionMode, options Options, loaded map[string]bool) ([]zeroruntime.ToolDefinition, string) {
|
||||
return partitionToolsCached(registry, permissionMode, options, loaded, nil)
|
||||
}
|
||||
|
||||
// Phase 2 additivity-proof seam. The identity test uses the REAL orchestrate
|
||||
// tool rather than a stub, so it exercises the actual Deferred() contract that
|
||||
// enforces the posture-off constraint. internal/specialist does not import
|
||||
// internal/agent, so this direction creates no cycle.
|
||||
const phase2ToolName = specialist.OrchestrateToolName
|
||||
|
||||
// registerPhase2ToolForTest registers the real tool with the posture OFF, which
|
||||
// is the condition the identity test is about.
|
||||
func registerPhase2ToolForTest(registry *tools.Registry) {
|
||||
registry.Register(&specialist.OrchestrateTool{PostureActive: func() bool { return false }})
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Twigpine/zero/internal/lsp"
|
||||
"github.com/Gitlawb/zero/internal/lsp"
|
||||
)
|
||||
|
||||
// fileDiagnosticsTimeout bounds one inline post-edit diagnostics check so a
|
||||
|
||||
@@ -8,9 +8,9 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Twigpine/zero/internal/sandbox"
|
||||
"github.com/Twigpine/zero/internal/tools"
|
||||
"github.com/Twigpine/zero/internal/zeroruntime"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
type customApplyPatchTool struct {
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/Twigpine/zero/internal/zeroruntime"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// Guardrail thresholds for the agent loop. These keep a runaway model from
|
||||
@@ -169,24 +168,7 @@ var selfReportPhrases = []string{
|
||||
var inabilityStems = []string{
|
||||
"i cannot ", "i can't ", "i can not ", "i could not ", "i couldn't ",
|
||||
"i am unable to", "i'm unable to", "i was unable to", "i wasn't able to",
|
||||
"i was not able to", "i do not have", "i don't have",
|
||||
"we are unable to", "we were unable to",
|
||||
// THE SUBJECTLESS STEM IS KEPT, and the heading it fired on is handled where
|
||||
// the heading is, not by deleting the stem.
|
||||
//
|
||||
// It was removed once because a completed audit's own section heading —
|
||||
// "**Unable to verify (1):** - MCP #3 claim was truncated" — reads as an
|
||||
// admission. But it is the ONLY stem that catches an admission with no
|
||||
// first-person subject, and removing it lost every one of those:
|
||||
//
|
||||
// "Unable to complete the task; the build never succeeded."
|
||||
// "The agent was unable to finish the migration."
|
||||
// "Unable to verify the fix, so the change is unverified."
|
||||
//
|
||||
// None name "i" or "we", so no other stem sees them. That traded one false
|
||||
// positive for three false negatives, in the direction this guard exists to
|
||||
// prevent. countedLabelSentence drops the heading shape instead.
|
||||
"unable to ",
|
||||
"i was not able to", "i do not have", "i don't have", "unable to ",
|
||||
"without being able to",
|
||||
}
|
||||
|
||||
@@ -197,19 +179,6 @@ var inabilityStems = []string{
|
||||
var successNegationTails = []string{
|
||||
"find any", "found any", "find a ", "see any", "detect any", "identify any",
|
||||
"reproduce", "spot any", "locate any",
|
||||
// A NEGATIVE SEARCH RESULT IS THE ANSWER, not a failure to produce one.
|
||||
//
|
||||
// The list above already encodes this — "I could not find any remaining
|
||||
// issues" is success — but only for the "any" phrasings. A finder reporting
|
||||
// "I could NOT find where AllowManifestToolAutoApproval is set to true in
|
||||
// production code" was marked INCOMPLETE after 53 tool calls and a 19,145
|
||||
// character audit, for doing precisely the job it was given: establishing
|
||||
// that something is not there.
|
||||
"find where", "find the", "find it", "find that", "find this",
|
||||
"found where", "found the",
|
||||
"locate where", "locate the", "locate it",
|
||||
"determine where", "identify where", "see where",
|
||||
"reproduce ", "confirm any", "observe any",
|
||||
}
|
||||
|
||||
// narrativeMarkers flag a sentence as RETELLING a past exchange rather than
|
||||
@@ -278,401 +247,11 @@ func admissionSentences(lower string) []string {
|
||||
// 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.
|
||||
// toolGrantMarkers flag a sentence as reporting WHICH TOOLS this run was given,
|
||||
// not whether the work was done.
|
||||
//
|
||||
// A read-only plan task is SUPPOSED to say this. One wrote "I don't have an
|
||||
// update_plan tool available in this specialist context (only read-only
|
||||
// exploration tools were provided)" and then delivered the complete answer —
|
||||
// helper name, file, line 214, full source — and was marked INCOMPLETE on the
|
||||
// "i don't have" stem. The prompt asks tasks to name their limits plainly; a
|
||||
// detector that punishes exactly that teaches the opposite.
|
||||
//
|
||||
// NARROW ON PURPOSE: the sentence must name a TOOL or a GRANT. "I do not have
|
||||
// enough evidence" is still an admission and still fires.
|
||||
// NARROWED AFTER AN AUDIT OF THIS VERY FIX. The first version listed a bare
|
||||
// " tool", which exempts any inability sentence that merely mentions one —
|
||||
// measured at 5/5 on ordinary phrasings:
|
||||
//
|
||||
// "I cannot run the build tool, so the change is unverified"
|
||||
// "I could not use the migration tool and the data is untouched"
|
||||
// "I was unable to invoke the formatting tool on the output"
|
||||
//
|
||||
// Those are genuine admissions, and silently exempting them is the WORSE
|
||||
// direction: a false positive costs a re-run, a false negative reports
|
||||
// unfinished work as done. The markers now have to be about what the run WAS
|
||||
// GIVEN, not about a tool being mentioned at all.
|
||||
var toolGrantMarkers = []string{
|
||||
"tool available", "tools available", "no such tool", "not available in this",
|
||||
"read-only tools", "read only tools", "only read-only", "only read only",
|
||||
"tools were provided", "tools were given", "toolset provided",
|
||||
"in this specialist context", "in this context only",
|
||||
"is not in my toolset", "not in my toolset", "not in this toolset",
|
||||
}
|
||||
|
||||
// objectiveFailureMarkers name the OBJECTIVE rather than a capability. A
|
||||
// sentence carrying one is about whether the job got done, so the tool-grant
|
||||
// exemption above does not apply to it however many tools it mentions.
|
||||
// VERB-ANCHORED, not bare nouns. "this task" alone was too crude: a task that
|
||||
// finished wrote "so i could not record a plan; the task is a single
|
||||
// read-and-report step and is now complete" — it names the task in order to
|
||||
// report SUCCESS, and a bare-noun override read that as failure. The marker has
|
||||
// to be the objective NOT BEING DONE, which needs the verb.
|
||||
var objectiveFailureMarkers = []string{
|
||||
"complete this task", "complete the task", "completing this task", "completing the task",
|
||||
"finish this task", "finish the task", "finishing this task",
|
||||
"complete it", "completing it", "finish it", "finishing it",
|
||||
// The objective and the assignment need the verb too, for the same reason
|
||||
// "this task" did. "the objective is met" and "the assignment is complete"
|
||||
// name the objective in order to report SUCCESS, and a bare noun read both
|
||||
// as failure — so a finished answer carrying a tool caveat was told it had
|
||||
// not finished, which is the worst thing this detector can do.
|
||||
"complete the objective", "completing the objective",
|
||||
"finish the objective", "finishing the objective",
|
||||
"meet the objective", "meeting the objective", "achieve the objective",
|
||||
"complete the assignment", "completing the assignment",
|
||||
"finish the assignment", "finishing the assignment",
|
||||
"as requested", "what was asked",
|
||||
"do this task", "perform this task", "carry out this task",
|
||||
}
|
||||
|
||||
// blockedWorkMarkers are what turns an absence-establishing sentence back into
|
||||
// an admission of failure.
|
||||
//
|
||||
// MEASURED, NOT ARGUED. The successNegationTails list above exists so a finder
|
||||
// reporting "I could not find where X is set" is not marked incomplete for doing
|
||||
// its job. But the stems it added — "find the", "reproduce ", "confirm any",
|
||||
// "observe any" — also head the most ordinary way of admitting defeat, and the
|
||||
// allowance fired on the whole sentence regardless of how it ended. Measured
|
||||
// against eleven genuine admissions, TEN passed the detector undetected:
|
||||
//
|
||||
// "I could not reproduce the crash, so the fix is unverified."
|
||||
// "I could not find the root cause; someone else will need to pick this up."
|
||||
// "I could not locate the source of the regression and have run out of ideas."
|
||||
//
|
||||
// That is the guard's entire purpose defeated — it is the last thing between a
|
||||
// stalled run and a report that reads like success.
|
||||
//
|
||||
// So the allowance now yields when the sentence ALSO says the work is blocked.
|
||||
// "I could not find where X is set in production code" still passes, because
|
||||
// nothing in it claims the objective was left undone; the three above do not.
|
||||
// strongAbsenceTails are the allowance tails carrying an explicit "any": the
|
||||
// model asserting it looked and found NOTHING. That is a finding whatever the
|
||||
// rest of the sentence says, so blockedWorkMarkers does not override them.
|
||||
var strongAbsenceTails = []string{
|
||||
"find any", "found any", "see any", "detect any", "identify any",
|
||||
"spot any", "locate any", "confirm any", "observe any",
|
||||
// The OBSERVATION family. Looking for a failure and not producing one is the
|
||||
// same kind of result as looking for an issue and not finding one, and
|
||||
// leaving these out meant "I could not reproduce any failure in the parser"
|
||||
// was not a strong absence — so an unrelated blocked statement in the next
|
||||
// sentence flipped a clean negative result into an admission.
|
||||
"reproduce any", "trigger any", "produce any", "hit any",
|
||||
"encounter any", "provoke any", "surface any", "measure any",
|
||||
}
|
||||
|
||||
// strongAbsenceObjects are the things whose ABSENCE IS THE RESULT: you go
|
||||
// looking for them precisely so you can report there are none, and finding none
|
||||
// is the work succeeding.
|
||||
//
|
||||
// WHAT FOLLOWS "any" DECIDES, and treating every "find any" as success was too
|
||||
// broad:
|
||||
//
|
||||
// "I could not find any remaining issues" -> a finding, the search succeeded
|
||||
// "I could not find any solution" -> an admission, the work did not
|
||||
//
|
||||
// Both carry the explicit "any". Only the object separates them, so only the
|
||||
// object can classify them.
|
||||
//
|
||||
// AN ALLOW-LIST, because this grants the exemption. A deny-list of deliverables
|
||||
// would have to anticipate every noun a model might reach for, and everything
|
||||
// forgotten would be waved through as success — the failure direction this
|
||||
// detector exists to prevent. An unrecognised object is simply not strong, which
|
||||
// leaves the sentence to the ordinary blocked-work handling rather than
|
||||
// flagging it outright.
|
||||
var strongAbsenceObjects = []string{
|
||||
"issue", "issues", "problem", "problems", "bug", "bugs", "defect", "defects",
|
||||
"error", "errors", "failure", "failures", "regression", "regressions",
|
||||
"evidence", "example", "examples", "occurrence", "occurrences",
|
||||
"instance", "instances", "reference", "references", "match", "matches",
|
||||
"caller", "callers", "usage", "usages", "use", "uses", "case", "cases",
|
||||
"vulnerability", "vulnerabilities", "leak", "leaks", "race", "races",
|
||||
"sign", "signs", "trace", "traces", "mention", "mentions", "difference", "differences",
|
||||
"blocker", "blockers", "gap", "gaps", "omission", "omissions", "discrepancy", "discrepancies",
|
||||
"conflict", "conflicts", "violation", "violations", "warning", "warnings",
|
||||
"call", "calls", "site", "sites", "callsite", "callsites", "consumer", "consumers",
|
||||
"dependency", "dependencies", "user", "users", "path", "paths",
|
||||
}
|
||||
|
||||
// absenceQualifiers sit between "any" and the object without changing it.
|
||||
var absenceQualifiers = []string{
|
||||
"remaining", "other", "further", "more", "additional", "obvious", "such",
|
||||
"outstanding", "new", "existing", "actual", "real", "clear", "direct",
|
||||
}
|
||||
|
||||
// strongAbsence reports whether tail is an "any"-family absence whose OBJECT
|
||||
// makes finding nothing the result rather than the shortfall.
|
||||
func strongAbsence(tail string) bool {
|
||||
for _, prefix := range strongAbsenceTails {
|
||||
if !strings.HasPrefix(tail, prefix) {
|
||||
continue
|
||||
}
|
||||
rest := strings.TrimSpace(tail[len(prefix):])
|
||||
for trimmed := true; trimmed; {
|
||||
trimmed = false
|
||||
for _, qualifier := range absenceQualifiers {
|
||||
if word, remainder, ok := cutFirstWord(rest); ok && word == qualifier {
|
||||
rest, trimmed = remainder, true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
word, _, ok := cutFirstWord(rest)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, object := range strongAbsenceObjects {
|
||||
if word == object {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// cutFirstWord returns the first bare word of text, lowercased by the caller's
|
||||
// own normalisation, with surrounding punctuation removed.
|
||||
func cutFirstWord(text string) (word string, rest string, ok bool) {
|
||||
text = strings.TrimLeft(text, " \t")
|
||||
end := 0
|
||||
for end < len(text) {
|
||||
c := text[end]
|
||||
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' {
|
||||
end++
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
if end == 0 {
|
||||
return "", text, false
|
||||
}
|
||||
return text[:end], text[end:], true
|
||||
}
|
||||
|
||||
var blockedWorkMarkers = []string{
|
||||
"unverified", "not verified", "cannot verify", "could not verify",
|
||||
"someone else", "will need to", "needs someone", "handed off", "hand off",
|
||||
// "someone else" and "will need to" are the two that also appear in
|
||||
// SUCCESSFUL reports, describing somebody else's future work — "I could not
|
||||
// find any remaining issues, though a follow-up will need to cover the
|
||||
// Windows path" is a finding. They are kept because they do carry a genuine
|
||||
// handoff-because-blocked ("someone else will need to pick this up"), and the
|
||||
// strongAbsenceTails exemption above is what stops them flipping a finding.
|
||||
"ran out of", "run out of", "out of time", "in the time available",
|
||||
"stopped there", "stopping here", "gave up", "giving up",
|
||||
"nothing was modified", "no changes were made", "left unchanged", "left undone",
|
||||
"may be inert", "is unresolved", "remains unresolved", "still broken",
|
||||
"so i cannot", "so i could not",
|
||||
// Saying the work is BLOCKED, in as many words. "I could not find the root
|
||||
// cause, so the work is blocked" carries the statement in the same sentence
|
||||
// and still passed, because every marker above names a symptom of being
|
||||
// blocked and none named the thing itself.
|
||||
"is blocked", "are blocked", "remains blocked", "stays blocked", "still blocked",
|
||||
"cannot proceed", "could not proceed", "can not proceed", "unable to proceed",
|
||||
"is unfinished", "remains unfinished", "left unfinished", "still unfinished",
|
||||
"is incomplete", "remains incomplete",
|
||||
"still unresolved", "still unverified",
|
||||
}
|
||||
|
||||
// bareInabilityStems are the two entries above that are STEMS rather than
|
||||
// descriptions of a blocked state. The stem loop below already scans them with
|
||||
// its own tail logic, and a sentence can carry one on its way to reporting
|
||||
// success: "so i could not record a plan; the task is a single read-and-report
|
||||
// step and is now complete" is a finished task that did not need the plan.
|
||||
var bareInabilityStems = []string{"so i cannot", "so i could not"}
|
||||
|
||||
// unambiguousFailureStates say the work is in a bad state, with no reading on
|
||||
// which it is a result.
|
||||
//
|
||||
// AN OBJECT CANNOT OUTRANK AN EXPLICIT STATE. strongAbsence returns true for
|
||||
// "evidence", and that suppressed every blocked-work marker in the sentence — so
|
||||
// "I could not find any evidence supporting the fix, so it remains unverified"
|
||||
// reported success while saying in as many words that the work is unverified.
|
||||
// The absence protection exists for ownership and follow-up wording, where "I
|
||||
// could not find any remaining issues, though a follow-up will need to cover the
|
||||
// Windows path" really is a finding. It was never meant to cover a sentence that
|
||||
// states the outcome.
|
||||
//
|
||||
// So this is the SHORT list: "unverified" and "still broken" have one reading,
|
||||
// while "someone else", "will need to" and "nothing was modified" have two and
|
||||
// stay ambiguous. Same-sentence only — a state in the NEXT sentence may belong
|
||||
// to another subject, which is what the lookahead's topic-shift guard is for.
|
||||
var unambiguousFailureStates = []string{
|
||||
"unverified", "not verified",
|
||||
"is unresolved", "remains unresolved",
|
||||
"still broken",
|
||||
"is blocked", "are blocked", "remains blocked", "stays blocked", "still blocked",
|
||||
"is unfinished", "remains unfinished", "left unfinished", "still unfinished",
|
||||
"still unresolved", "still unverified",
|
||||
"is incomplete", "remains incomplete",
|
||||
"cannot proceed", "could not proceed", "unable to proceed",
|
||||
"gave up", "giving up", "ran out of", "run out of",
|
||||
"left undone",
|
||||
}
|
||||
|
||||
// consequenceBoundaries separate what was looked for from what followed.
|
||||
//
|
||||
// THE STATE HAS TO BE THE OUTCOME, NOT PART OF WHAT WAS NEGATED. Matching
|
||||
// "is unresolved" anywhere in the sentence read the negated PROPOSITION as the
|
||||
// reported result:
|
||||
//
|
||||
// "I could not find any evidence that the issue is unresolved."
|
||||
//
|
||||
// That is a successful negative finding — there is no evidence the issue remains
|
||||
// unresolved — and it was marked incomplete, which is the opposite polarity of
|
||||
// the case the override was added for. What separates them is position: after a
|
||||
// consequence boundary the state is being asserted, inside a "that…" clause it is
|
||||
// the thing being denied.
|
||||
var consequenceBoundaries = []string{", so ", "; ", ", but ", ", therefore", ", leaving ", ", which "}
|
||||
|
||||
// reportedConsequence returns the part of a sentence that states the OUTCOME,
|
||||
// or "" when the sentence never turns to one. Everything before the first
|
||||
// boundary is what was searched for.
|
||||
func reportedConsequence(sentence string) string {
|
||||
earliest := -1
|
||||
width := 0
|
||||
for _, boundary := range consequenceBoundaries {
|
||||
if index := strings.Index(sentence, boundary); index >= 0 && (earliest < 0 || index < earliest) {
|
||||
earliest, width = index, len(boundary)
|
||||
}
|
||||
}
|
||||
if earliest < 0 {
|
||||
return ""
|
||||
}
|
||||
return sentence[earliest+width:]
|
||||
}
|
||||
|
||||
// blockedStateMarkers describe WORK LEFT BLOCKED — unverified, unresolved,
|
||||
// handed off, abandoned for time. Only these break the tool-grant exemption:
|
||||
// they say something about the state the work is in, which a tool caveat cannot
|
||||
// excuse, whereas a bare stem says only that one step did not happen.
|
||||
//
|
||||
// DERIVED, not copied. Two hand-maintained lists that must stay in step drift,
|
||||
// and the drift here would be silent in both directions.
|
||||
var blockedStateMarkers = func() []string {
|
||||
bare := make(map[string]bool, len(bareInabilityStems))
|
||||
for _, stem := range bareInabilityStems {
|
||||
bare[stem] = true
|
||||
}
|
||||
out := make([]string, 0, len(blockedWorkMarkers))
|
||||
for _, marker := range blockedWorkMarkers {
|
||||
if !bare[marker] {
|
||||
out = append(out, marker)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}()
|
||||
|
||||
// carriesTheConsequence reports whether the sentence after an allowance should
|
||||
// be read as that allowance's consequence.
|
||||
//
|
||||
// The lookahead exists because the consequence usually IS the next sentence, but
|
||||
// "usually" is not "always" — a message can turn to something else, and reading
|
||||
// a blocked statement about a different subject as this one's consequence is the
|
||||
// cost of the lookahead. A sentence that announces the change of subject, or
|
||||
// disclaims the thing as out of scope, is taken at its word.
|
||||
//
|
||||
// This does not catch every unrelated follow-on, and deliberately errs toward
|
||||
// reading the next sentence: an admission reported as success is the failure this
|
||||
// guard exists to prevent, and a message that says something is unverified has
|
||||
// said it whether or not it is the same something.
|
||||
func carriesTheConsequence(next string) bool {
|
||||
return !containsAny(next, topicShiftMarkers)
|
||||
}
|
||||
|
||||
// topicShiftMarkers say the message has moved on to something else.
|
||||
var topicShiftMarkers = []string{
|
||||
"separately", "unrelatedly", "unrelated", "as an aside", "aside from",
|
||||
"out of scope", "outside the scope", "not in scope", "for a different",
|
||||
"in a different", "on another", "elsewhere in", "in other news",
|
||||
}
|
||||
|
||||
// countedLabelSentence reports whether a sentence is a markdown LABEL that
|
||||
// introduces and counts a bucket of findings, rather than a claim about the
|
||||
// objective.
|
||||
//
|
||||
// "**Unable to verify (1):** - MCP #3 claim was truncated" is a section heading
|
||||
// in a COMPLETED audit. Read as a sentence it is indistinguishable from an
|
||||
// admission, which is why the subjectless stem was once deleted to silence it.
|
||||
//
|
||||
// NARROW ON PURPOSE: the sentence must BEGIN with the inability phrase, after
|
||||
// markdown emphasis, AND carry a parenthesised count. "Unable to complete the
|
||||
// task; the build never succeeded" begins the same way and has no count, so it
|
||||
// still fires — which is the whole reason this is preferable to dropping the
|
||||
// stem.
|
||||
func countedLabelSentence(sentence string) bool {
|
||||
trimmed := strings.TrimLeft(strings.TrimSpace(sentence), "-*#> \t")
|
||||
if !strings.HasPrefix(trimmed, "unable to ") {
|
||||
return false
|
||||
}
|
||||
return countedLabelSuffix.MatchString(sentence)
|
||||
}
|
||||
|
||||
var countedLabelSuffix = regexp.MustCompile(`\(\s*\d+\s*\)`)
|
||||
|
||||
func selfReportedIncompletion(text string) string {
|
||||
sentences := admissionSentences(strings.ToLower(stripQuoted(text)))
|
||||
for index, sentence := range sentences {
|
||||
// THE CONSEQUENCE IS OFTEN THE NEXT SENTENCE. The blocked-work override
|
||||
// only ever saw the sentence the allowance fired in, so the same
|
||||
// admission escaped or was caught purely on its punctuation:
|
||||
//
|
||||
// "I could not reproduce the crash, so the fix is unverified." caught
|
||||
// "I could not reproduce the crash. The fix is unverified." missed
|
||||
//
|
||||
// A full stop is not a claim that the work finished, and writing the
|
||||
// consequence as its own sentence is how most people write. The
|
||||
// blocked-work question is asked of this sentence AND the one after it;
|
||||
// everything else is still decided on the sentence alone, so a stem in
|
||||
// one sentence cannot be paired with an allowance tail in another.
|
||||
blockedContext := sentence
|
||||
if index+1 < len(sentences) && carriesTheConsequence(sentences[index+1]) {
|
||||
blockedContext += " " + sentences[index+1]
|
||||
}
|
||||
for _, sentence := range admissionSentences(strings.ToLower(stripQuoted(text))) {
|
||||
if containsAny(sentence, narrativeMarkers) {
|
||||
continue
|
||||
}
|
||||
// A counted label is a heading, not a claim about the objective.
|
||||
if countedLabelSentence(sentence) {
|
||||
continue
|
||||
}
|
||||
// A sentence about the tool grant is about CAPABILITY, not about the
|
||||
// objective — UNLESS it also says the task itself could not be done.
|
||||
//
|
||||
// THE OVERRIDE IS NOT OPTIONAL. Without it the exemption swallowed a
|
||||
// genuine failure: "I am unable to complete this task with the current
|
||||
// tool set … Only write_file is enabled … so I cannot inspect the
|
||||
// codebase." That task really did fail, and it mentions tools, so a bare
|
||||
// tool-marker check waved it through. Naming the task is what separates
|
||||
// "I lack a tool I did not need" from "I lack the tools this needed".
|
||||
// BLOCKED WORK BREAKS THE EXEMPTION TOO, not just a named objective. The
|
||||
// override above asks whether the sentence names the task, which a whole
|
||||
// class of real admissions never does:
|
||||
//
|
||||
// "No write tool is available in this context, so the fix is unverified."
|
||||
// "There is no edit tool available here, so the change remains unapplied."
|
||||
// "Write tools are not available in this setup, so the tests were never run."
|
||||
//
|
||||
// Every one mentions tools, none names the objective, and all four were
|
||||
// waved through. The tool caveat is the REASON the work is blocked, not a
|
||||
// reason to stop reading — so a sentence that also says something is
|
||||
// unverified, unapplied or untested goes on to the blocked-work handling
|
||||
// below instead of being exempted here.
|
||||
if containsAny(sentence, toolGrantMarkers) &&
|
||||
!containsAny(sentence, objectiveFailureMarkers) &&
|
||||
!containsAny(sentence, blockedStateMarkers) {
|
||||
continue
|
||||
}
|
||||
for _, phrase := range selfReportPhrases {
|
||||
if strings.Contains(sentence, phrase) {
|
||||
return selfReportReason(phrase)
|
||||
@@ -689,18 +268,7 @@ func selfReportedIncompletion(text string) string {
|
||||
}
|
||||
abs := start + rel
|
||||
tail := strings.TrimSpace(sentence[abs+len(stem):])
|
||||
// The allowance yields to a sentence that also says the work is
|
||||
// blocked: "could not reproduce the crash" is a finding, "could not
|
||||
// reproduce the crash, so the fix is unverified" is an admission,
|
||||
// and the tail prefix alone cannot tell them apart.
|
||||
strong := strongAbsence(tail)
|
||||
// The object decides whether finding nothing is a result; an
|
||||
// explicit failure state in the SAME sentence decides that it is
|
||||
// not, whatever the object.
|
||||
if strong && containsAny(reportedConsequence(sentence), unambiguousFailureStates) {
|
||||
strong = false
|
||||
}
|
||||
if !hasAnyPrefix(tail, successNegationTails) || (!strong && containsAny(blockedContext, blockedWorkMarkers)) {
|
||||
if !hasAnyPrefix(tail, successNegationTails) {
|
||||
return selfReportReason(strings.TrimSpace(stem) + " …")
|
||||
}
|
||||
start = abs + len(stem)
|
||||
|
||||
@@ -1,458 +0,0 @@
|
||||
package agent
|
||||
|
||||
import "testing"
|
||||
|
||||
// FOUR COMPLETED TASKS WERE MARKED INCOMPLETE, and the sentences below are
|
||||
// verbatim from those sessions.
|
||||
//
|
||||
// The detector reads message text — the one place in this feature that does —
|
||||
// and it is supposed to catch a model admitting it did not do the job. Instead
|
||||
// it caught four tasks that HAD done the job and were honestly naming a limit,
|
||||
// which is exactly what the plan-task prompt asks them to do. Two of them had
|
||||
// made 53 and 60 tool calls; one had written files.
|
||||
//
|
||||
// The cost of a false positive here is not cosmetic: the task is reported
|
||||
// failed, retried on another model, and rendered red in the plan panel.
|
||||
func TestAnHonestCaveatInsideDeliveredWorkIsNotAnAdmission(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
text string
|
||||
}{
|
||||
{
|
||||
// 53 tool calls, a 19,145-character audit. Establishing that
|
||||
// something is NOT there is the finding, not a failure to find one.
|
||||
name: "a negative search result is the answer",
|
||||
text: "I traced every code path where repo-committed files influence tool approval. " +
|
||||
"I could NOT find where `AllowManifestToolAutoApproval` is set to true in production code paths.",
|
||||
},
|
||||
{
|
||||
// A completed verification task categorising its own findings. The
|
||||
// bare "unable to" stem had no first-person subject and fired on a
|
||||
// markdown heading.
|
||||
name: "a report section heading",
|
||||
text: "## Verification Table\n\n**Verified (4):** all confirmed against source.\n" +
|
||||
"**Unable to verify (1):** - MCP #3 claim was truncated in the input.",
|
||||
},
|
||||
{
|
||||
// Delivered helper name, file, line 214 and full source. The task was
|
||||
// read-only by design and said so.
|
||||
name: "a statement about the tool grant",
|
||||
text: "I don't have an `update_plan` tool available in this specialist context " +
|
||||
"(only read-only exploration tools were provided). " +
|
||||
"The task is a single read-only assessment and I've already gathered all the needed evidence.",
|
||||
},
|
||||
{
|
||||
name: "read-only tools named plainly",
|
||||
text: "I did not run the tests because my tools are read-only, " +
|
||||
"so I can report only what the code and tests say statically.",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if reason := selfReportedIncompletion(tc.text); reason != "" {
|
||||
t.Fatalf("a completed task was marked incomplete: %s", reason)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// AND THE DETECTOR MUST STILL BITE. Widening it to stop punishing honesty must
|
||||
// not turn it off — these are the admissions it exists for, several of them
|
||||
// verbatim from the same set of sessions.
|
||||
func TestGenuineAdmissionsAreStillCaught(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
text string
|
||||
}{
|
||||
{
|
||||
name: "the measured workspace failure",
|
||||
text: "I cannot complete this task. The target directory `/Users/kratos/zm-lab/pkg/execprofile` " +
|
||||
"is outside the workspace boundary.",
|
||||
},
|
||||
{
|
||||
name: "an empty worktree",
|
||||
text: "This task cannot be completed in the current workspace because the relevant source tree is absent. " +
|
||||
"I cannot find BuildFinalResult anywhere.",
|
||||
},
|
||||
{
|
||||
name: "guessing",
|
||||
text: "I guessed at the retry semantics because the code was not reachable.",
|
||||
},
|
||||
{
|
||||
name: "fabrication",
|
||||
text: "I fabricated the line numbers to fill the table.",
|
||||
},
|
||||
{
|
||||
name: "stated doubt about the result",
|
||||
text: "The patch may not be correct — I did not run it.",
|
||||
},
|
||||
{
|
||||
name: "not enough evidence is still an admission",
|
||||
text: "I do not have enough evidence to answer the question.",
|
||||
},
|
||||
{
|
||||
name: "first-person unable, with a subject",
|
||||
text: "I was unable to determine which branch actually runs.",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if reason := selfReportedIncompletion(tc.text); reason == "" {
|
||||
t.Fatalf("a real admission was missed: %q", tc.text)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The tool-grant exemption is NARROW. A sentence has to be about tools or the
|
||||
// grant; "not enough evidence" is not, and must keep firing.
|
||||
func TestTheToolGrantExemptionDoesNotSwallowRealAdmissions(t *testing.T) {
|
||||
if reason := selfReportedIncompletion("I could not verify the claim and I do not have enough evidence."); reason == "" {
|
||||
t.Fatal("the tool-grant exemption swallowed an evidence admission")
|
||||
}
|
||||
if reason := selfReportedIncompletion("I don't have the file contents, so the answer is a guess."); reason == "" {
|
||||
t.Fatal("an admission about missing content was exempted as a tool statement")
|
||||
}
|
||||
}
|
||||
|
||||
// THE EXEMPTION MUST NOT SWALLOW A TASK THAT REALLY COULD NOT BE DONE.
|
||||
//
|
||||
// Verbatim from a session the first version of this fix regressed: the task
|
||||
// mentions tools, so a bare tool-marker check waved it through — but it names
|
||||
// the TASK as the thing that failed, which is the whole distinction.
|
||||
func TestATaskThatCouldNotBeDoneForLackOfToolsStillFails(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
text string
|
||||
}{
|
||||
{
|
||||
name: "the measured regression",
|
||||
text: "I am unable to complete this task with the current tool set. " +
|
||||
"Only `write_file` is enabled, so I cannot inspect the codebase to collect scan findings.",
|
||||
},
|
||||
{
|
||||
name: "named differently",
|
||||
text: "I could not finish the objective because no read tools were provided.",
|
||||
},
|
||||
{
|
||||
name: "cannot complete it",
|
||||
text: "I don't have the tools to complete it as requested.",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if reason := selfReportedIncompletion(tc.text); reason == "" {
|
||||
t.Fatalf("a task that genuinely could not be done was passed as complete: %q", tc.text)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// And the exemption must still work for the case it exists for: a tool that
|
||||
// was not needed, alongside delivered work.
|
||||
exempt := "I don't have an `update_plan` tool available in this specialist context " +
|
||||
"(only read-only exploration tools were provided)."
|
||||
if reason := selfReportedIncompletion(exempt); reason != "" {
|
||||
t.Fatalf("the override broke the exemption it guards: %s", reason)
|
||||
}
|
||||
}
|
||||
|
||||
// NAMING THE TASK TO REPORT SUCCESS IS NOT NAMING IT TO REPORT FAILURE.
|
||||
//
|
||||
// Verbatim from the session that the first override regressed. A task that had
|
||||
// finished wrote a footnote about a tool it did not need, in the same sentence
|
||||
// as its own completion statement — and a bare-noun override read "the task" as
|
||||
// an admission. The marker has to be the objective NOT being done.
|
||||
func TestNamingTheTaskWhileReportingSuccessIsNotAnAdmission(t *testing.T) {
|
||||
done := "- **note:** the update_plan tool is not available in my current toolset " +
|
||||
"(only read-only file tools), so i could not record a plan; " +
|
||||
"the task is a single read-and-report step and is now complete"
|
||||
if reason := selfReportedIncompletion(done); reason != "" {
|
||||
t.Fatalf("a finished task was marked incomplete for saying so: %s", reason)
|
||||
}
|
||||
// The failure form, which must still fire, differs only in the verb.
|
||||
failed := "the update_plan tool is not available, so i could not complete this task"
|
||||
if reason := selfReportedIncompletion(failed); reason == "" {
|
||||
t.Fatal("a task that could not be completed was passed as complete")
|
||||
}
|
||||
}
|
||||
|
||||
// AN INABILITY THAT MERELY MENTIONS A TOOL IS STILL AN ADMISSION.
|
||||
//
|
||||
// THE AUDIT FINDING THIS PINS. The first version of the exemption listed a bare
|
||||
// " tool", so any inability sentence mentioning one escaped — measured at 5/5 on
|
||||
// ordinary phrasings. That is the worse direction: a false positive costs a
|
||||
// re-run, a false negative reports unfinished work as done.
|
||||
func TestMentioningAToolDoesNotExemptAnAdmission(t *testing.T) {
|
||||
for _, text := range []string{
|
||||
"I cannot run the build tool, so the change is unverified",
|
||||
"I could not use the migration tool and the data is untouched",
|
||||
"I was unable to invoke the formatting tool on the output",
|
||||
"I don't have a working compiler tool here",
|
||||
"I cannot finish because the deploy tools are broken",
|
||||
} {
|
||||
if reason := selfReportedIncompletion(text); reason == "" {
|
||||
t.Errorf("a genuine admission was silently exempted: %q", text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// And the exemption still covers what it was built for: a run stating which
|
||||
// tools it was GIVEN, alongside delivered work.
|
||||
func TestTheGrantExemptionStillCoversWhatItWasBuiltFor(t *testing.T) {
|
||||
for _, text := range []string{
|
||||
"I don't have an `update_plan` tool available in this specialist context (only read-only exploration tools were provided).",
|
||||
"I did not run the tests because only read-only tools were provided, so I report what the code says statically.",
|
||||
"update_plan is not in my toolset, so I could not record a plan.",
|
||||
} {
|
||||
if reason := selfReportedIncompletion(text); reason != "" {
|
||||
t.Errorf("a run naming its own grant was marked incomplete: %q -> %s", text, reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NAMING THE OBJECTIVE TO REPORT SUCCESS, same trap as naming the task.
|
||||
//
|
||||
// "the objective" and "the assignment" were bare nouns in objectiveFailureMarkers
|
||||
// while "this task" had already been verb-anchored for exactly this reason. A
|
||||
// message that mentions a tool it lacked and then says the objective IS met was
|
||||
// read as saying the objective was not met — a finished answer told it had not
|
||||
// finished, which is the worst outcome this detector has.
|
||||
func TestNamingTheObjectiveWhileReportingSuccessIsNotAnAdmission(t *testing.T) {
|
||||
for _, done := range []string{
|
||||
"I do not have write tools available, but the objective is met: the config already sets the flag.",
|
||||
"No write tool is available to me, so the objective was achieved by reading alone.",
|
||||
"The assignment is complete; no shell was needed.",
|
||||
"I have no browser tool available here, yet the assignment is complete.",
|
||||
} {
|
||||
if reason := selfReportedIncompletion(done); reason != "" {
|
||||
t.Errorf("a finished answer was marked incomplete for naming its objective: %q -> %s", done, reason)
|
||||
}
|
||||
}
|
||||
// The failure forms differ only in the verb, and must still fire.
|
||||
for _, failed := range []string{
|
||||
"I could not complete the objective because the build never succeeded.",
|
||||
"I was unable to finish the assignment.",
|
||||
} {
|
||||
if reason := selfReportedIncompletion(failed); reason == "" {
|
||||
t.Errorf("an objective that was not met was passed as complete: %q", failed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A TOOL CAVEAT IS THE REASON WORK IS BLOCKED, NOT A REASON TO STOP READING.
|
||||
//
|
||||
// The tool-grant exemption asked only whether the sentence named the objective,
|
||||
// so an admission that named none was waved through on the strength of
|
||||
// mentioning tools:
|
||||
//
|
||||
// "No write tools available, so I could not verify the change."
|
||||
//
|
||||
// It breaks now on a blocked STATE — unverified, unresolved, handed off — but
|
||||
// deliberately not on the two bare inability stems in that list, because a
|
||||
// sentence carrying one can still be on its way to reporting success (see
|
||||
// TestNamingTheTaskWhileReportingSuccessIsNotAnAdmission, which this broke when
|
||||
// the whole list was used).
|
||||
func TestAToolCaveatDoesNotExcuseBlockedWork(t *testing.T) {
|
||||
for _, admission := range []string{
|
||||
"No write tools available, so I could not verify the change.",
|
||||
"There is no edit tool available here, so I could not verify the fix.",
|
||||
} {
|
||||
if reason := selfReportedIncompletion(admission); reason == "" {
|
||||
t.Errorf("a tool caveat excused blocked work: %q", admission)
|
||||
}
|
||||
}
|
||||
// The exemption still covers what it exists for: a tool that was not needed.
|
||||
for _, exempt := range []string{
|
||||
"No update_plan tool available in this specialist context, so I proceeded directly.",
|
||||
"There is no update_plan tool available in this specialist context (only read-only tools), so I have written the plan into this answer instead.",
|
||||
} {
|
||||
if reason := selfReportedIncompletion(exempt); reason != "" {
|
||||
t.Errorf("the blocked-state check broke the exemption it guards: %q -> %s", exempt, reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WHAT FOLLOWS "any" DECIDES WHETHER FINDING NOTHING IS THE RESULT.
|
||||
//
|
||||
// The "any"-family was treated as a finding whatever the object, so an admission
|
||||
// wearing the same words walked through:
|
||||
//
|
||||
// "I could not find any remaining issues" -> a finding, the search succeeded
|
||||
// "I could not find any solution" -> an admission, the work did not
|
||||
//
|
||||
// Both carry the explicit "any"; only the object separates them. The object list
|
||||
// is an ALLOW-LIST because it grants the exemption — a deny-list of deliverables
|
||||
// would wave through every noun nobody thought of, which is the wrong direction
|
||||
// for this detector to fail in.
|
||||
func TestAnAbsenceIsAFindingOnlyWhenTheObjectMakesItOne(t *testing.T) {
|
||||
for _, admission := range []string{
|
||||
"I could not find any solution, so the migration remains unresolved.",
|
||||
"I could not find any fix, so the build is still broken.",
|
||||
"I could not find any workaround; someone else will need to pick this up.",
|
||||
"I could not identify any approach, so this is unverified.",
|
||||
} {
|
||||
if selfReportedIncompletion(admission) == "" {
|
||||
t.Errorf("an admission wearing the words of a finding passed as complete: %q", admission)
|
||||
}
|
||||
}
|
||||
// The findings the allowance exists for are untouched, including the ones
|
||||
// carrying a blocked-work marker about somebody else's future work.
|
||||
for _, finding := range []string{
|
||||
"I could not find any remaining issues, though a follow-up will need to cover the Windows path.",
|
||||
"I could not find any blockers; someone else can take the release from here.",
|
||||
"I could not find any evidence of a leak, so nothing was modified.",
|
||||
"I did not see any further regressions, so someone else can ship it.",
|
||||
"I could not detect any races, and nothing was modified.",
|
||||
} {
|
||||
if reason := selfReportedIncompletion(finding); reason != "" {
|
||||
t.Errorf("a finding was reported as an admission: %q -> %s", finding, reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A FULL STOP IS NOT A CLAIM THAT THE WORK FINISHED.
|
||||
//
|
||||
// The blocked-work override only ever saw the sentence the allowance fired in,
|
||||
// so the SAME admission was caught or missed on its punctuation alone:
|
||||
//
|
||||
// "I could not reproduce the crash, so the fix is unverified." caught
|
||||
// "I could not reproduce the crash. The fix is unverified." missed
|
||||
//
|
||||
// Writing the consequence as its own sentence is how most people write, and half
|
||||
// of a reviewer's corpus of ordinary admissions escaped through it.
|
||||
//
|
||||
// Only the blocked-work question looks ahead. A stem in one sentence is still
|
||||
// never paired with an allowance tail in another.
|
||||
func TestTheConsequenceMayBeTheNextSentence(t *testing.T) {
|
||||
for _, admission := range []string{
|
||||
"I could not reproduce the crash, so the fix is unverified.",
|
||||
"I could not reproduce the crash. The fix is unverified.",
|
||||
"I could not locate the source of the regression and have run out of ideas.",
|
||||
"I could not locate the source of the regression. I have run out of ideas.",
|
||||
// "the work is blocked" says it in as many words, and every marker was a
|
||||
// SYMPTOM of being blocked rather than the thing itself.
|
||||
"I could not find the root cause, so the work is blocked.",
|
||||
"I could not find the root cause. The work is blocked.",
|
||||
"I could not get the integration test to run. The behaviour is therefore unverified.",
|
||||
"I could not apply the patch cleanly. Nothing was modified.",
|
||||
} {
|
||||
if selfReportedIncompletion(admission) == "" {
|
||||
t.Errorf("an admission escaped by putting its consequence in a second sentence: %q", admission)
|
||||
}
|
||||
}
|
||||
|
||||
// THE LOOKAHEAD'S OWN COST, guarded. A message that turns to other work must
|
||||
// not have that work's blocked state read as this result's consequence.
|
||||
for _, finding := range []string{
|
||||
"I searched the tree and could not find any other call sites. The rename is complete.",
|
||||
"I could not find any remaining usages of the deprecated helper. A future cleanup is blocked until the API freeze lifts.",
|
||||
"I could not reproduce any failure in the parser. The CI flake in the network suite remains unresolved and belongs to another team.",
|
||||
"I could not find any issues in the diff. Documentation for the new flag is unfinished, which was never part of this request.",
|
||||
"I could not reproduce the crash. Separately, the deploy step is unverified because it needs prod access.",
|
||||
"I could not find any other call sites. The follow-up work is blocked on a design decision, which is out of scope here.",
|
||||
} {
|
||||
if reason := selfReportedIncompletion(finding); reason != "" {
|
||||
t.Errorf("the lookahead read another subject's blocked state as this result's: %q -> %s", finding, reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AN UNAMBIGUOUS STATE THAT IS NOT ALSO A MARKER IS DEAD.
|
||||
//
|
||||
// unambiguousFailureStates only decides whether a recognised absence object
|
||||
// stops protecting the sentence; something still has to FIRE, and that is
|
||||
// blockedWorkMarkers. Adding "still blocked" to the first list and not the
|
||||
// second left it doing nothing at all, and the case looked handled because the
|
||||
// phrase appeared in the code.
|
||||
//
|
||||
// Two hand-maintained lists that must agree is the shape that drifts, so the
|
||||
// agreement is asserted rather than remembered.
|
||||
func TestEveryUnambiguousStateIsAlsoABlockedWorkMarker(t *testing.T) {
|
||||
for _, state := range unambiguousFailureStates {
|
||||
found := false
|
||||
for _, marker := range blockedWorkMarkers {
|
||||
if state == marker {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("%q outranks a strong absence but is not a blockedWorkMarker, so nothing fires on it", state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// EXPLICIT FAILURE STATES OUTRANK A RECOGNISED ABSENCE OBJECT.
|
||||
//
|
||||
// strongAbsence returns true for objects like "evidence", and that suppressed
|
||||
// every blocked-work marker in the sentence — so a message saying in as many
|
||||
// words that the work is unverified reported success. The absence protection is
|
||||
// for ownership and follow-up wording; it was never meant to cover a sentence
|
||||
// that states the outcome.
|
||||
func TestAnExplicitFailureStateOutranksTheAbsenceObject(t *testing.T) {
|
||||
for _, admission := range []string{
|
||||
"I could not find any evidence supporting the fix, so it remains unverified.",
|
||||
"I could not find any evidence for the cause, so the bug is unresolved.",
|
||||
"I could not find any way to make it work, so I gave up.",
|
||||
"I could not find any working approach; it is still broken.",
|
||||
"I could not find any issues, but the migration is still blocked.",
|
||||
} {
|
||||
if selfReportedIncompletion(admission) == "" {
|
||||
t.Errorf("an explicit failure state was overruled by the absence object: %q", admission)
|
||||
}
|
||||
}
|
||||
|
||||
// AMBIGUOUS wording still yields to the absence, which is the whole reason
|
||||
// the protection exists: ownership and follow-up read two ways, an explicit
|
||||
// state reads one.
|
||||
for _, finding := range []string{
|
||||
"I could not find any evidence that the flag is read in production.",
|
||||
"I could not find any remaining issues, though a follow-up will need to cover the Windows path.",
|
||||
"I could not find any blockers; someone else can take the release from here.",
|
||||
"I could not find any evidence of a leak, so nothing was modified.",
|
||||
"I could not detect any races, and nothing was modified.",
|
||||
// A state in the NEXT sentence may belong to another subject, so
|
||||
// same-sentence only.
|
||||
"I could not reproduce any failure in the parser. The CI flake in the network suite remains unresolved and belongs to another team.",
|
||||
"I could not find any issues in the diff. Documentation for the new flag is unfinished, which was never part of this request.",
|
||||
} {
|
||||
if reason := selfReportedIncompletion(finding); reason != "" {
|
||||
t.Errorf("a finding was flagged by ambiguous or another subject's wording: %q -> %s", finding, reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// THE STATE MUST BE THE OUTCOME, NOT PART OF WHAT WAS NEGATED.
|
||||
//
|
||||
// The override matched a failure state anywhere in the sentence, which read the
|
||||
// negated PROPOSITION as the reported result:
|
||||
//
|
||||
// "I could not find any evidence that the issue is unresolved."
|
||||
//
|
||||
// That is a successful negative finding — there is no evidence the issue remains
|
||||
// unresolved — and it was marked incomplete. Exactly the opposite polarity of the
|
||||
// case the override was added for, and the two have to be tested together or
|
||||
// fixing one just moves the error.
|
||||
func TestAStateInsideTheNegatedPropositionIsNotTheOutcome(t *testing.T) {
|
||||
for _, finding := range []string{
|
||||
"I could not find any evidence that the issue is unresolved.",
|
||||
"I could not find any evidence that the build is still broken.",
|
||||
"I could not find any evidence that the migration is incomplete.",
|
||||
"I could not find any sign that the deploy is blocked.",
|
||||
} {
|
||||
if reason := selfReportedIncompletion(finding); reason != "" {
|
||||
t.Errorf("a negated proposition was read as the reported outcome: %q -> %s", finding, reason)
|
||||
}
|
||||
}
|
||||
|
||||
// Both polarities together: after a consequence boundary the state IS the
|
||||
// outcome and must still fire.
|
||||
for _, admission := range []string{
|
||||
"I could not find any evidence supporting the fix, so it remains unverified.",
|
||||
"I could not find any evidence for the cause, so the bug is unresolved.",
|
||||
"I could not find any way to make it work, so I gave up.",
|
||||
"I could not find any working approach; it is still broken.",
|
||||
"I could not find any issues, but the migration is still blocked.",
|
||||
} {
|
||||
if selfReportedIncompletion(admission) == "" {
|
||||
t.Errorf("an outcome stated after a consequence boundary was missed: %q", admission)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Twigpine/zero/internal/tools"
|
||||
"github.com/Twigpine/zero/internal/zeroruntime"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// emptyTurn is a stream that produces no visible text and no tool calls.
|
||||
@@ -80,9 +80,6 @@ func TestRunStopsAfterConsecutiveEmptyTurns(t *testing.T) {
|
||||
if result.FinalAnswer == maxTurnsAnswer {
|
||||
t.Fatalf("no-output guard must stop before reaching maxTurns, got max-turns answer")
|
||||
}
|
||||
if result.Incomplete {
|
||||
t.Fatalf("interactive run must not be marked Incomplete; reason=%q", result.IncompleteReason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunResetsEmptyTurnCounterOnVisibleOutput(t *testing.T) {
|
||||
@@ -516,22 +513,6 @@ func repeatedFlakyTurns(n int) [][]zeroruntime.StreamEvent {
|
||||
return turns
|
||||
}
|
||||
|
||||
// alwaysSucceedingTool is the successful counterpart to alwaysFailingTool, used
|
||||
// to put a productive tool turn before an empty-turn streak.
|
||||
type alwaysSucceedingTool struct{}
|
||||
|
||||
func (alwaysSucceedingTool) Name() string { return "ok" }
|
||||
func (alwaysSucceedingTool) Description() string { return "always succeeds for testing" }
|
||||
func (alwaysSucceedingTool) Parameters() tools.Schema {
|
||||
return tools.Schema{Type: "object", AdditionalProperties: false}
|
||||
}
|
||||
func (alwaysSucceedingTool) Safety() tools.Safety {
|
||||
return tools.Safety{SideEffect: tools.SideEffectRead, Permission: tools.PermissionAllow}
|
||||
}
|
||||
func (alwaysSucceedingTool) Run(context.Context, map[string]any) tools.Result {
|
||||
return tools.Result{Status: tools.StatusOK, Output: "ok"}
|
||||
}
|
||||
|
||||
func TestRunStopsAfterRepeatedToolFailures(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(alwaysFailingTool{})
|
||||
@@ -548,51 +529,6 @@ func TestRunStopsAfterRepeatedToolFailures(t *testing.T) {
|
||||
if len(provider.requests) != toolFailureStopAt {
|
||||
t.Fatalf("expected stop at %d failures, made %d requests", toolFailureStopAt, len(provider.requests))
|
||||
}
|
||||
if result.Incomplete {
|
||||
t.Fatalf("interactive run must not be marked Incomplete; reason=%q", result.IncompleteReason)
|
||||
}
|
||||
}
|
||||
|
||||
// Under the headless completion gate both guardrail stops are unfinished runs,
|
||||
// so they must set Result.Incomplete and a reason; exec maps that to exit 4.
|
||||
// Thresholds and stop answers stay pinned by the two tests above.
|
||||
func TestHeadlessGuardStopsAreIncomplete(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
turns [][]zeroruntime.StreamEvent
|
||||
wantTurns int
|
||||
wantReason string
|
||||
}{
|
||||
{"empty_turns", [][]zeroruntime.StreamEvent{emptyTurn(), emptyTurn(), emptyTurn()}, maxEmptyTurns, "3 consecutive turns with no output"},
|
||||
{"empty_turns_after_progress", [][]zeroruntime.StreamEvent{
|
||||
toolTurn("ok-1", "ok", "{}"),
|
||||
emptyTurn(), emptyTurn(), emptyTurn(),
|
||||
}, maxEmptyTurns + 1, "3 consecutive turns with no output"},
|
||||
{"repeated_tool_failures", repeatedFlakyTurns(10), toolFailureStopAt, "flaky"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(alwaysFailingTool{})
|
||||
registry.Register(alwaysSucceedingTool{})
|
||||
result, err := Run(context.Background(), "go", &mockProvider{turns: tc.turns}, Options{
|
||||
Registry: registry,
|
||||
MaxTurns: 12,
|
||||
RequireCompletionSignal: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Turns != tc.wantTurns {
|
||||
t.Fatalf("guard did not trigger: turns=%d want=%d", result.Turns, tc.wantTurns)
|
||||
}
|
||||
if !result.Incomplete {
|
||||
t.Fatalf("guardrail stop under the headless gate must be Incomplete; final=%q", result.FinalAnswer)
|
||||
}
|
||||
if !strings.Contains(result.IncompleteReason, tc.wantReason) {
|
||||
t.Fatalf("IncompleteReason = %q, want it to mention %q", result.IncompleteReason, tc.wantReason)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunInjectsToolFailureHintWithSchema(t *testing.T) {
|
||||
@@ -618,91 +554,3 @@ func TestRunInjectsToolFailureHintWithSchema(t *testing.T) {
|
||||
t.Fatalf("expected a tool-failure hint on the 3rd turn, messages: %+v", provider.requests[2].Messages)
|
||||
}
|
||||
}
|
||||
|
||||
// An absence-establishing sentence is a finding; the same sentence that also
|
||||
// says the work is blocked is an admission. The allowance stems ("find the",
|
||||
// "reproduce ", "confirm any" …) head both, so the tail prefix alone cannot
|
||||
// separate them — measured against real phrasings, TEN of eleven genuine
|
||||
// admissions passed the detector before blockedWorkMarkers existed.
|
||||
func TestIncompletionAllowanceYieldsToBlockedWork(t *testing.T) {
|
||||
// Must FIRE: the inability is reported as leaving work undone.
|
||||
for _, admission := range []string{
|
||||
"I could not reproduce the crash, so the fix is unverified.",
|
||||
"I was unable to reproduce the reported behaviour and stopped there.",
|
||||
"I could not find the root cause; someone else will need to pick this up.",
|
||||
"I could not find the bug in the time available.",
|
||||
"I did not manage to reproduce the failure, so I cannot confirm the patch works.",
|
||||
"I could not locate the source of the regression and have run out of ideas.",
|
||||
"I could not find where to apply the change, so nothing was modified.",
|
||||
} {
|
||||
if selfReportedIncompletion(admission) == "" {
|
||||
t.Errorf("admission passed the detector: %q", admission)
|
||||
}
|
||||
}
|
||||
|
||||
// Must NOT fire: establishing that something is absent is the job, and the
|
||||
// motivating case is the audit that spent 53 tool calls proving a negative.
|
||||
for _, finding := range []string{
|
||||
"I could not find any remaining issues.",
|
||||
"I could NOT find where AllowManifestToolAutoApproval is set to true in production code.",
|
||||
"I could not find the flag being set anywhere outside tests, so the concern does not apply.",
|
||||
"I could not reproduce the reported exploit, which confirms the guard holds.",
|
||||
"I could not observe any regression across the suite.",
|
||||
"I could not confirm any leak; every path is bounded.",
|
||||
// Next steps and ownership belong in successful reports too. Both of
|
||||
// these were flagged when blockedWorkMarkers could override an explicit
|
||||
// "any", which is the model asserting exhaustive absence.
|
||||
"I could not find any remaining issues, though a follow-up will need to cover the Windows path.",
|
||||
"I could not find any blockers; someone else can take the release from here.",
|
||||
} {
|
||||
if reason := selfReportedIncompletion(finding); reason != "" {
|
||||
t.Errorf("finding wrongly flagged as incomplete: %q -> %q", finding, reason)
|
||||
}
|
||||
}
|
||||
|
||||
// NOT CAUGHT, and recorded rather than hidden. An explicit "any" is treated
|
||||
// as exhaustive absence, so an admission phrased that way passes:
|
||||
//
|
||||
// "I could not observe any effect, so the change may be inert."
|
||||
//
|
||||
// Measured across eleven admissions, four still pass, all of them either
|
||||
// "any"-phrased or single-clause with no blocked-work signal ("I failed to
|
||||
// reproduce it locally"). Catching those needs a different signal than
|
||||
// substring matching; tightening this list to reach them re-broke the
|
||||
// findings above every way I tried.
|
||||
}
|
||||
|
||||
// An admission with NO first-person subject must still fire. The subjectless
|
||||
// "unable to " stem was once deleted to silence a completed audit's section
|
||||
// heading, which also lost every impersonal admission — none of these names "i"
|
||||
// or "we", so no other stem sees them.
|
||||
func TestImpersonalAdmissionsAreStillCaught(t *testing.T) {
|
||||
for _, admission := range []string{
|
||||
"Unable to complete the task; the build never succeeded.",
|
||||
"The agent was unable to finish the migration.",
|
||||
"Unable to verify the fix, so the change is unverified.",
|
||||
} {
|
||||
if selfReportedIncompletion(admission) == "" {
|
||||
t.Errorf("an impersonal admission passed the detector: %q", admission)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The heading that motivated deleting the stem must stay exempt. It is a label
|
||||
// counting a bucket of findings in a COMPLETED audit, not a claim about the
|
||||
// objective — which is why it is recognised by shape rather than by removing a
|
||||
// stem that catches real admissions.
|
||||
func TestACountedHeadingIsNotAnAdmission(t *testing.T) {
|
||||
for _, heading := range []string{
|
||||
"**Unable to verify (1):** - MCP #3 claim was truncated",
|
||||
"Unable to reproduce (3):",
|
||||
} {
|
||||
if reason := selfReportedIncompletion(heading); reason != "" {
|
||||
t.Errorf("a counted heading was read as an admission: %q -> %q", heading, reason)
|
||||
}
|
||||
}
|
||||
// But the same opening WITHOUT a count is a real admission.
|
||||
if selfReportedIncompletion("Unable to verify the deployment; it never started.") == "" {
|
||||
t.Error("a subjectless admission with no count was exempted as a heading")
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Twigpine/zero/internal/hooks"
|
||||
"github.com/Twigpine/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/hooks"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
func TestAppendHookFeedbackFormatsOutput(t *testing.T) {
|
||||
|
||||
+20
-157
@@ -11,29 +11,20 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/Twigpine/zero/internal/execution"
|
||||
"github.com/Twigpine/zero/internal/hooks"
|
||||
"github.com/Twigpine/zero/internal/measurements"
|
||||
"github.com/Twigpine/zero/internal/modelregistry"
|
||||
"github.com/Twigpine/zero/internal/redaction"
|
||||
"github.com/Twigpine/zero/internal/sandbox"
|
||||
"github.com/Twigpine/zero/internal/streamjson"
|
||||
"github.com/Twigpine/zero/internal/tools"
|
||||
"github.com/Twigpine/zero/internal/trace"
|
||||
"github.com/Twigpine/zero/internal/zeroruntime"
|
||||
"github.com/Gitlawb/zero/internal/execution"
|
||||
"github.com/Gitlawb/zero/internal/hooks"
|
||||
"github.com/Gitlawb/zero/internal/modelregistry"
|
||||
"github.com/Gitlawb/zero/internal/redaction"
|
||||
"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"
|
||||
)
|
||||
|
||||
const maxTurnsAnswer = "Agent reached maximum number of turns without a final answer."
|
||||
const maxTokensAnswer = "Agent reached its token budget without a final answer."
|
||||
const maxTurnsFinalAnswerPrompt = "You have reached the tool-turn limit. Do not call tools. Give a concise final answer now: summarize what you completed, what you found, and any remaining blockers."
|
||||
|
||||
// maxTokensFinalAnswerPrompt is the same ending for a different reason, and it
|
||||
// says which. A run stopped for spend and a run stopped for round trips call for
|
||||
// the same next action from the model and a different explanation to the reader,
|
||||
// who otherwise reaches for the wrong lever — raising a turn count that was never
|
||||
// what ran out.
|
||||
const maxTokensFinalAnswerPrompt = "You have reached this run's token budget. Do not call tools. Give a concise final answer now: summarize what you completed, what you found, and any remaining blockers."
|
||||
|
||||
// maxStreamStallRetries bounds how many times a turn that timed out (idle/stall)
|
||||
// OR hit a mid-stream transport abort (#973) with no answer text yet is re-issued
|
||||
// on a fresh connection before giving up. Incomplete tool-call previews are
|
||||
@@ -187,10 +178,6 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
// this local, so no signatures change anywhere downstream.
|
||||
provider = sessionProvider{session: session}
|
||||
|
||||
// The turn's raw user text, carried to tools so a gate can tell "the user
|
||||
// asked for this" from "the model read a sentence that said to".
|
||||
options.userMessage = prompt
|
||||
|
||||
maxTurns := options.MaxTurns
|
||||
if maxTurns <= 0 {
|
||||
maxTurns = 12
|
||||
@@ -269,18 +256,6 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
// FileDiagnostics callback is wired; every method no-ops on nil.
|
||||
postEditDiagnostics := newAsyncDiagnostics(options.FileDiagnostics, options.Cwd)
|
||||
|
||||
// THE RUN'S OWN TIMINGS, so a final answer cannot report a number no command
|
||||
// here produced. Every tool result is read for `go test` durations; the final
|
||||
// answer is checked against them once before it is returned.
|
||||
//
|
||||
// POSTURE-GATED, and nil when the posture is off — every method no-ops on
|
||||
// nil, so a run that never heard of zeromaxing takes exactly the path it took
|
||||
// before this existed. See internal/measurements for the failure it is for.
|
||||
var measured *measurements.Ledger
|
||||
if options.Zeromaxing != ZeromaxingOff {
|
||||
measured = measurements.NewLedger()
|
||||
}
|
||||
|
||||
// loaded tracks deferred-eligible tools the model has pulled via tool_search
|
||||
// during THIS run. It is consulted by partitionTools each turn to expose a
|
||||
// loaded tool's full schema; it lives only for the run (v1 within-run scope).
|
||||
@@ -296,12 +271,6 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
// doesn't re-run the recursive schema→map conversion for every tool every turn.
|
||||
toolDefCache := map[string]zeroruntime.ToolDefinition{}
|
||||
|
||||
// SPEND, accumulated from what the provider actually reported rather than
|
||||
// estimated. Only read when options.MaxTokens > 0, so an unbounded run adds
|
||||
// one integer and changes nothing else.
|
||||
spentTokens := 0
|
||||
stoppedOnTokens := false
|
||||
|
||||
result = Result{Messages: copyMessages(messages)}
|
||||
dispatchSessionStart(ctx, options)
|
||||
defer func() {
|
||||
@@ -314,27 +283,6 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
for turn := 0; turn < maxTurns; turn++ {
|
||||
result.Turns = turn + 1
|
||||
|
||||
// CHECKED HERE, at the top of a turn, for the same reason the turn count
|
||||
// is: the previous turn's tool calls have run and their results are in
|
||||
// hand. Stopping mid-turn would discard work already paid for.
|
||||
if options.MaxTokens > 0 && spentTokens >= options.MaxTokens {
|
||||
stoppedOnTokens = true
|
||||
break
|
||||
}
|
||||
|
||||
// The zeromaxing posture's reminders. Appended to the CONVERSATION tail
|
||||
// as user-role messages — the same channel as the diagnostics nudge
|
||||
// below and the failure/plan hints later in the turn — and never into
|
||||
// the system prompt, which is built once per run and must stay
|
||||
// byte-stable so the provider's cached prefix survives. See
|
||||
// internal/agent/zeromaxing.go.
|
||||
for _, reminder := range zeromaxingReminders(options.Zeromaxing, result.Turns, options.OrchestrateAvailable) {
|
||||
messages = append(messages, zeroruntime.Message{
|
||||
Role: zeroruntime.MessageRoleUser,
|
||||
Content: reminder,
|
||||
})
|
||||
}
|
||||
|
||||
// Deliver background post-edit diagnostics from the previous turn's edits
|
||||
// BEFORE compaction so the nudge is part of the request being budgeted.
|
||||
// A brief wait at most (asyncDiagnosticsDrainTimeout); an unfinished check
|
||||
@@ -346,19 +294,6 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
})
|
||||
}
|
||||
|
||||
// A BACKGROUND PLAN that finished since the last turn. Same channel and
|
||||
// the same point in the turn as the diagnostics nudge above: the model
|
||||
// was told the plan was not finished and must not report it as done, so
|
||||
// this is the message that makes that promise good.
|
||||
if options.PlanCompletions != nil {
|
||||
if finished := options.PlanCompletions(); finished != "" {
|
||||
messages = append(messages, zeroruntime.Message{
|
||||
Role: zeroruntime.MessageRoleUser,
|
||||
Content: finished,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Build the per-turn tool list first so proactive compaction can include
|
||||
// the tool-definition tokens (they ride on every request) in its estimate.
|
||||
// partitionTools depends only on registry/permissions/options/loaded, not on
|
||||
@@ -620,11 +555,6 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
// assistant reply is appended below, after this, so `messages` is still the
|
||||
// sent request.
|
||||
compactor.calibrate(estimateTokens(messages)+estimateToolDefTokens(exposed), collected.Usage.InputTokens)
|
||||
// Accumulated at the same point the estimator is calibrated: past error
|
||||
// recovery, and past any reactive compaction that re-sent the request, so
|
||||
// this counts the exchange that actually happened rather than one that was
|
||||
// abandoned and replaced.
|
||||
spentTokens += collected.Usage.TotalTokens()
|
||||
|
||||
// Carry the turn's terminal stop reason so a final answer cut off at the
|
||||
// output token cap (or by a content filter) is reported as truncated. A
|
||||
@@ -657,14 +587,6 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
// counted toward the runaway cap so we stop before burning maxTurns.
|
||||
if guards.observeTurn(collected) {
|
||||
result.FinalAnswer = noOutputStopAnswer(result.Turns)
|
||||
// Under the headless completion gate this stop is unfinished work,
|
||||
// not a successful final answer: the guard exists precisely because
|
||||
// the model made no progress. Interactive runs keep the gate off, so
|
||||
// their behavior is unchanged.
|
||||
if options.RequireCompletionSignal {
|
||||
result.Incomplete = true
|
||||
result.IncompleteReason = "stopped after " + strconv.Itoa(guards.emptyTurns) + " consecutive turns with no output"
|
||||
}
|
||||
result.Messages = copyMessages(messages)
|
||||
return result, nil
|
||||
}
|
||||
@@ -732,23 +654,6 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
})
|
||||
continue
|
||||
}
|
||||
// THE ANSWER IS CHECKED AGAINST THE TRANSCRIPT before it is returned.
|
||||
// Same channel and the same gate as the diagnostics drain above, and
|
||||
// for the same reason: a final answer means there is no later turn, so
|
||||
// a number that contradicts what actually ran has to be raised now or
|
||||
// never. Each name is raised at most once by the ledger, so an answer
|
||||
// that comes back unchanged is returned rather than asked again.
|
||||
// ACROSS RUNS, because a final answer may summarise several commands
|
||||
// and this caller cannot say which one any given number came from.
|
||||
// Holding it to a single run would accuse the model of inventing a
|
||||
// number one of the other commands really printed.
|
||||
if nudge := measurements.Nudge(measured.ConflictsAcrossRuns(collected.Text)); nudge != "" {
|
||||
messages = append(messages, zeroruntime.Message{
|
||||
Role: zeroruntime.MessageRoleUser,
|
||||
Content: nudge,
|
||||
})
|
||||
continue
|
||||
}
|
||||
result.FinalAnswer = collected.Text
|
||||
result.Messages = copyMessages(messages)
|
||||
return result, nil
|
||||
@@ -837,14 +742,6 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
}
|
||||
options.Trace.Counter(trace.CounterToolCalls, 1)
|
||||
recordOutputBudgetTrace(options.Trace, toolResult)
|
||||
// Read before anything can truncate or summarize it: this is the only
|
||||
// place the command's own words are in hand — and the only place the
|
||||
// CALL that produced them is in hand too. Recording which tool and
|
||||
// which arguments printed a timing is what stops a claim about one
|
||||
// command being answered by a number a different one produced: a
|
||||
// `-race` run is routinely several times slower, which is the size of
|
||||
// discrepancy this check exists to catch.
|
||||
measured.Record(measurements.Run{Command: call.Name, Args: []string{call.Arguments}}, toolResult.Output)
|
||||
task.observe(taskStateEvent{kind: taskStateEventToolResult, arguments: call.Arguments, toolResult: toolResult})
|
||||
if options.OnToolResult != nil {
|
||||
options.OnToolResult(toolResult)
|
||||
@@ -911,13 +808,6 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
messages = appendAbortedToolResults(messages, collected.ToolCalls[index+1:])
|
||||
messages = append(messages, buildToolImageMessages()...)
|
||||
result.FinalAnswer = toolFailureStopAnswer(call.Name, outcome.Count)
|
||||
// Same headless contract as the empty-turn guard: halting on a
|
||||
// repeated failure is an unfinished run, not success. Gate-off
|
||||
// (interactive / --no-completion-gate) keeps the old behavior.
|
||||
if options.RequireCompletionSignal {
|
||||
result.Incomplete = true
|
||||
result.IncompleteReason = "the " + call.Name + " tool failed " + strconv.Itoa(outcome.Count) + " times in a row with the same error"
|
||||
}
|
||||
result.Messages = copyMessages(messages)
|
||||
return result, nil
|
||||
}
|
||||
@@ -1087,31 +977,22 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
})
|
||||
}
|
||||
finalExposed, _ := partitionToolsCached(registry, permissionMode, options, loaded, toolDefCache)
|
||||
// WHICH BOUND FIRED reaches the reader, because the two call for different
|
||||
// responses: a turn-count stop is a round-trip problem, a token stop is a
|
||||
// spend one, and reporting the first for the second sends someone to raise a
|
||||
// limit that was never what ran out.
|
||||
finalPrompt, exhaustedAnswer, limitName := maxTurnsFinalAnswerPrompt, maxTurnsAnswer, "max-turns limit"
|
||||
if stoppedOnTokens {
|
||||
finalPrompt, exhaustedAnswer, limitName = maxTokensFinalAnswerPrompt, maxTokensAnswer, "token budget"
|
||||
}
|
||||
|
||||
if answer, finalMessages, finishReason := finalAnswerAfterMaxTurns(ctx, provider, planner, messages, finalExposed, options, finalPrompt); strings.TrimSpace(answer) != "" {
|
||||
if answer, finalMessages, finishReason := finalAnswerAfterMaxTurns(ctx, provider, planner, messages, finalExposed, options); strings.TrimSpace(answer) != "" {
|
||||
result.FinalAnswer = answer
|
||||
result.FinishReason = finishReason
|
||||
result.Messages = copyMessages(finalMessages)
|
||||
if options.RequireCompletionSignal {
|
||||
result.Incomplete = true
|
||||
result.IncompleteReason = "reached the " + limitName + " without completing"
|
||||
result.IncompleteReason = "reached the max-turns limit without completing"
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
result.FinalAnswer = exhaustedAnswer
|
||||
result.FinalAnswer = maxTurnsAnswer
|
||||
result.Messages = copyMessages(messages)
|
||||
if options.RequireCompletionSignal {
|
||||
result.Incomplete = true
|
||||
result.IncompleteReason = "reached the " + limitName + " without a final answer"
|
||||
result.IncompleteReason = "reached the max-turns limit without a final answer"
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -1178,11 +1059,11 @@ func recordContextPlanTrace(recorder *trace.Recorder, plan contextPlan) {
|
||||
})
|
||||
}
|
||||
|
||||
func finalAnswerAfterMaxTurns(ctx context.Context, provider Provider, planner *contextPlanner, messages []zeroruntime.Message, toolDefs []zeroruntime.ToolDefinition, options Options, prompt string) (string, []zeroruntime.Message, string) {
|
||||
func finalAnswerAfterMaxTurns(ctx context.Context, provider Provider, planner *contextPlanner, messages []zeroruntime.Message, toolDefs []zeroruntime.ToolDefinition, options Options) (string, []zeroruntime.Message, string) {
|
||||
finalMessages := copyMessages(messages)
|
||||
finalMessages = append(finalMessages, zeroruntime.Message{
|
||||
Role: zeroruntime.MessageRoleUser,
|
||||
Content: prompt,
|
||||
Content: maxTurnsFinalAnswerPrompt,
|
||||
})
|
||||
// The max-turns final-answer call is a pre-content connect, often after a long
|
||||
// autonomous/cron run — route it through the reconnect helper so a single
|
||||
@@ -1593,17 +1474,10 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
|
||||
}
|
||||
args = shellExecutionArgsForApproval(call.Name, args, decisionAction, options)
|
||||
|
||||
// Wire the progress callback so the TUI sees live tool-call events from a
|
||||
// child agent process.
|
||||
//
|
||||
// Keyed on the TOOL'S OWN DECLARATION (tools.ChildProgressStreamer), not on
|
||||
// its name. This was `call.Name == "Task"`, which made the second
|
||||
// sub-agent-spawning tool run invisibly; `|| call.Name == "orchestrate"`
|
||||
// would have been the same defect one name later. A tool that spawns
|
||||
// children declares it, and every tool that does not keeps the nil callback
|
||||
// it has today.
|
||||
// Task tool: wire progress callback so the TUI sees live tool-call events
|
||||
// from the specialist child process.
|
||||
var progressCallback func(streamjson.Event)
|
||||
if options.OnToolProgress != nil && tools.StreamsChildProgress(tool) {
|
||||
if call.Name == "Task" && options.OnToolProgress != nil {
|
||||
toolCallID := call.ID
|
||||
onProgress := options.OnToolProgress
|
||||
progressCallback = func(event streamjson.Event) {
|
||||
@@ -1622,7 +1496,6 @@ func executeToolCall(ctx context.Context, registry *tools.Registry, call ToolCal
|
||||
ReasoningEffort: options.ReasoningEffort,
|
||||
Depth: options.Depth,
|
||||
Cwd: options.Cwd,
|
||||
UserMessage: options.userMessage,
|
||||
// Per-session file version tracker so write_file/edit_file refuse to clobber
|
||||
// a file that changed on disk outside Zero since it was last read.
|
||||
FileTracker: options.FileTracker,
|
||||
@@ -3051,7 +2924,7 @@ func availablePermissionDecisions(event PermissionEvent, args map[string]any, op
|
||||
decisions = append(decisions, PermissionDecisionAlwaysAllowPrefix)
|
||||
}
|
||||
}
|
||||
if options.Sandbox.CanPersistGrants() && permissionSupportsPersistentDecision(event.ToolName, options) && !filesystemSandboxPrompt(event) && !inlineAdditionalPermissions {
|
||||
if options.Sandbox.CanPersistGrants() && permissionSupportsPersistentDecision(event.ToolName) && !filesystemSandboxPrompt(event) && !inlineAdditionalPermissions {
|
||||
decisions = append(decisions, PermissionDecisionAlwaysAllow)
|
||||
}
|
||||
}
|
||||
@@ -3253,17 +3126,7 @@ func grantFilesystemForSandboxPrompt(event PermissionEvent, scope sandbox.Permis
|
||||
}, scope)
|
||||
}
|
||||
|
||||
func permissionSupportsPersistentDecision(toolName string, options Options) bool {
|
||||
// THE TOOL'S OWN DECLARATION FIRST. A name list cannot describe a tool whose
|
||||
// reach depends on its arguments, and it cannot cover a tool that RUNS the
|
||||
// ones already refused below — see tools.PersistentPermissionRefuser.
|
||||
if options.Registry != nil {
|
||||
if tool, found := options.Registry.Get(toolName); found {
|
||||
if refuser, ok := tool.(tools.PersistentPermissionRefuser); ok && refuser.RefusesPersistentPermission() {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
func permissionSupportsPersistentDecision(toolName string) bool {
|
||||
switch toolName {
|
||||
case "bash", "exec_command", "write_stdin", "apply_patch":
|
||||
return false
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/Twigpine/zero/internal/zeroruntime"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// imageEchoProvider records the messages of the first request it receives, then
|
||||
|
||||
+7
-143
@@ -13,13 +13,13 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Twigpine/zero/internal/execution"
|
||||
"github.com/Twigpine/zero/internal/hooks"
|
||||
"github.com/Twigpine/zero/internal/sandbox"
|
||||
"github.com/Twigpine/zero/internal/specmode"
|
||||
"github.com/Twigpine/zero/internal/tools"
|
||||
"github.com/Twigpine/zero/internal/trace"
|
||||
"github.com/Twigpine/zero/internal/zeroruntime"
|
||||
"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"
|
||||
)
|
||||
|
||||
type mockProvider struct {
|
||||
@@ -4361,142 +4361,6 @@ func TestPlanModeHonorsBeforeToolVeto(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// (h) The zeromaxing variant of TestRunPreservesRequestPrefixAcrossTurns.
|
||||
//
|
||||
// This is the test the whole reminder design exists to satisfy. The system
|
||||
// prompt and tool definitions are the provider's CACHED PREFIX; #760 made them
|
||||
// build once per run so they stay byte-identical across turns. The posture adds
|
||||
// per-turn text, so it is exactly the kind of change that silently destroys the
|
||||
// cache — roughly doubling input cost with nothing detecting it.
|
||||
//
|
||||
// The reminders are appended to the conversation TAIL as user-role messages, so
|
||||
// each turn's request must still be an exact prefix-extension of the previous
|
||||
// one, with identical tools and cache key.
|
||||
func TestRunPreservesRequestPrefixAcrossTurnsUnderZeromaxing(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.StreamEventToolCallStart, ToolCallID: "call-2", ToolName: "read_file"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-2", ArgumentsFragment: `{"path":"notes.txt"}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-2"},
|
||||
{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-zeromaxing",
|
||||
Zeromaxing: ZeromaxingEntering,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(provider.requests) != 3 {
|
||||
t.Fatalf("provider requests = %d, want 3", len(provider.requests))
|
||||
}
|
||||
|
||||
// Every request must extend the previous one exactly — nothing rewritten
|
||||
// above the append point, which is what keeps the cached prefix valid.
|
||||
for i := 1; i < len(provider.requests); i++ {
|
||||
prev, cur := provider.requests[i-1], provider.requests[i]
|
||||
if len(cur.Messages) < len(prev.Messages) ||
|
||||
!reflect.DeepEqual(cur.Messages[:len(prev.Messages)], prev.Messages) {
|
||||
t.Fatalf("request %d is not an exact prefix-extension of request %d:\nprev=%#v\ncur=%#v",
|
||||
i, i-1, prev.Messages, cur.Messages)
|
||||
}
|
||||
if !reflect.DeepEqual(prev.Tools, cur.Tools) {
|
||||
t.Fatalf("tool definitions drifted between turns %d and %d", i-1, i)
|
||||
}
|
||||
if cur.PromptCacheKey != prev.PromptCacheKey {
|
||||
t.Fatalf("prompt cache key drifted: %q -> %q", prev.PromptCacheKey, cur.PromptCacheKey)
|
||||
}
|
||||
}
|
||||
|
||||
// The system prompt — the cached prefix itself — must be byte-identical
|
||||
// across turns, and must contain NONE of the posture text.
|
||||
system := provider.requests[0].Messages[0]
|
||||
for i, request := range provider.requests {
|
||||
if !reflect.DeepEqual(request.Messages[0], system) {
|
||||
t.Fatalf("system message changed on turn %d — the cached prefix is broken", i+1)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{
|
||||
ZeromaxingEnterNotice, ZeromaxingBudgetNotice, ZeromaxingStillOnNotice, ZeromaxingExitNotice,
|
||||
ZeromaxingEvidenceNotice, ZeromaxingOrchestrateNotice,
|
||||
} {
|
||||
if strings.Contains(system.Content, forbidden) {
|
||||
t.Fatalf("posture reminder leaked into the SYSTEM PROMPT (above the cache breakpoint): %q", forbidden)
|
||||
}
|
||||
}
|
||||
|
||||
// ...and the reminders really did arrive, on schedule. Without this the
|
||||
// assertions above would pass just as happily if the feature did nothing.
|
||||
firstTurn := renderZeromaxingMessages(provider.requests[0].Messages)
|
||||
if !strings.Contains(firstTurn, ZeromaxingEnterNotice) || !strings.Contains(firstTurn, ZeromaxingBudgetNotice) {
|
||||
t.Fatalf("turn 1 must carry the enter + budget notices:\n%s", firstTurn)
|
||||
}
|
||||
if strings.Contains(firstTurn, ZeromaxingStillOnNotice) {
|
||||
t.Fatalf("turn 1 must NOT carry the still-on notice:\n%s", firstTurn)
|
||||
}
|
||||
secondOnly := renderZeromaxingMessages(provider.requests[1].Messages[len(provider.requests[0].Messages):])
|
||||
if !strings.Contains(secondOnly, ZeromaxingStillOnNotice) {
|
||||
t.Fatalf("turn 2 must carry the still-on notice:\n%s", secondOnly)
|
||||
}
|
||||
if strings.Contains(secondOnly, ZeromaxingEnterNotice) {
|
||||
t.Fatalf("turn 2 must NOT re-announce entry:\n%s", secondOnly)
|
||||
}
|
||||
}
|
||||
|
||||
// A run with the posture OFF must be byte-identical to one that never heard of
|
||||
// it — the no-regression half of the feature.
|
||||
func TestRunWithoutZeromaxingCarriesNoPostureText(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewReadFileTool(root))
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
{{Type: zeroruntime.StreamEventText, Content: "done"}, {Type: zeroruntime.StreamEventDone}},
|
||||
}}
|
||||
if _, err := Run(context.Background(), "hello", provider, Options{
|
||||
Cwd: root, Registry: registry, SessionID: "session-no-zeromaxing",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rendered := renderZeromaxingMessages(provider.requests[0].Messages)
|
||||
for _, forbidden := range []string{
|
||||
ZeromaxingEnterNotice, ZeromaxingBudgetNotice, ZeromaxingStillOnNotice, ZeromaxingExitNotice,
|
||||
ZeromaxingEvidenceNotice, ZeromaxingOrchestrateNotice,
|
||||
} {
|
||||
if strings.Contains(rendered, forbidden) {
|
||||
t.Fatalf("a posture-free run must carry no posture text, found %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// renderZeromaxingMessages flattens messages to one searchable string.
|
||||
func renderZeromaxingMessages(messages []zeroruntime.Message) string {
|
||||
var b strings.Builder
|
||||
for _, message := range messages {
|
||||
b.WriteString(string(message.Role))
|
||||
b.WriteString(": ")
|
||||
b.WriteString(message.Content)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// A cancelled permission on either sandbox-retry path must abort the run with a
|
||||
// recognisable cancellation, and must not run the escalated retry.
|
||||
//
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Twigpine/zero/internal/tools"
|
||||
"github.com/Twigpine/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// goTestTool stands in for a shell that ran `go test` and printed its result.
|
||||
type goTestTool struct{ output string }
|
||||
|
||||
func (goTestTool) Name() string { return "run_tests" }
|
||||
func (goTestTool) Description() string { return "run the test suite" }
|
||||
func (goTestTool) Parameters() tools.Schema {
|
||||
return tools.Schema{Type: "object", AdditionalProperties: false}
|
||||
}
|
||||
func (goTestTool) Safety() tools.Safety {
|
||||
return tools.Safety{SideEffect: tools.SideEffectNone, Permission: tools.PermissionAllow, AdvertiseInAuto: true}
|
||||
}
|
||||
func (t goTestTool) Run(context.Context, map[string]any) tools.Result {
|
||||
return tools.Result{Status: tools.StatusOK, Output: t.output}
|
||||
}
|
||||
|
||||
// callThenAnswer is a provider that calls run_tests, then gives finalAnswer, then
|
||||
// gives corrected — so a run that is sent back for a number gets somewhere to go.
|
||||
func callThenAnswer(finalAnswer, corrected string) *mockProvider {
|
||||
return &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
{
|
||||
{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "call-1", ToolName: "run_tests"},
|
||||
{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "call-1", ArgumentsFragment: `{}`},
|
||||
{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "call-1"},
|
||||
{Type: zeroruntime.StreamEventDone},
|
||||
},
|
||||
{{Type: zeroruntime.StreamEventText, Content: finalAnswer}, {Type: zeroruntime.StreamEventDone}},
|
||||
{{Type: zeroruntime.StreamEventText, Content: corrected}, {Type: zeroruntime.StreamEventDone}},
|
||||
}}
|
||||
}
|
||||
|
||||
const suiteOutput = `{"Action":"pass","Package":"github.com/x/y","Test":"TestChattyChild","Elapsed":0.86}
|
||||
{"Action":"pass","Package":"github.com/x/y","Elapsed":0.86}
|
||||
`
|
||||
|
||||
// A FINAL ANSWER THAT REPORTS A NUMBER THE RUN NEVER PRODUCED IS SENT BACK.
|
||||
//
|
||||
// The measured failure: a benchmark table where the same test read 0.86s in one
|
||||
// paste and 4.20s in the next, with nothing said about the difference. A prompt
|
||||
// rule cannot catch this — a model willing to write a number it did not measure
|
||||
// is equally willing to say it re-ran it. The transcript can.
|
||||
func TestAFinalAnswerContradictingTheTranscriptIsSentBack(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(goTestTool{output: suiteOutput})
|
||||
provider := callThenAnswer("github.com/x/y took 4.20s.", "Corrected: github.com/x/y took 0.86s.")
|
||||
|
||||
result, err := Run(context.Background(), "run the suite", provider, Options{
|
||||
Cwd: root, Registry: registry, SessionID: "session-measured",
|
||||
Zeromaxing: ZeromaxingActive,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(provider.requests) < 3 {
|
||||
t.Fatalf("the run ended after %d requests: the contradicted answer was accepted", len(provider.requests))
|
||||
}
|
||||
sentBack := renderZeromaxingMessages(provider.requests[2].Messages)
|
||||
for _, required := range []string{"github.com/x/y", "4.2s", "0.86s", "Re-run the command"} {
|
||||
if !strings.Contains(sentBack, required) {
|
||||
t.Errorf("the correction does not mention %q:\n%s", required, sentBack)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(result.FinalAnswer, "0.86s") {
|
||||
t.Errorf("the corrected answer was not the one returned: %q", result.FinalAnswer)
|
||||
}
|
||||
}
|
||||
|
||||
// AN HONEST ANSWER IS RETURNED UNTOUCHED. A tripwire that fires on a correct
|
||||
// report gets switched off, and then it catches nothing.
|
||||
func TestAnAnswerThatMatchesTheTranscriptIsReturnedAsIs(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(goTestTool{output: suiteOutput})
|
||||
provider := callThenAnswer("github.com/x/y took 0.86s.", "should never be reached")
|
||||
|
||||
result, err := Run(context.Background(), "run the suite", provider, Options{
|
||||
Cwd: root, Registry: registry, SessionID: "session-honest",
|
||||
Zeromaxing: ZeromaxingActive,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(provider.requests) != 2 {
|
||||
t.Fatalf("an honest answer cost %d requests, want 2: the check fired on a correct report", len(provider.requests))
|
||||
}
|
||||
if !strings.Contains(result.FinalAnswer, "0.86s") {
|
||||
t.Errorf("final answer = %q", result.FinalAnswer)
|
||||
}
|
||||
}
|
||||
|
||||
// THE CHECK IS PART OF THE POSTURE. With zeromaxing off the run must take
|
||||
// exactly the path it took before this existed — the same rule every other
|
||||
// posture behaviour follows.
|
||||
func TestThePostureOffRunIsNeverSentBackForANumber(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(goTestTool{output: suiteOutput})
|
||||
provider := callThenAnswer("github.com/x/y took 4.20s.", "should never be reached")
|
||||
|
||||
result, err := Run(context.Background(), "run the suite", provider, Options{
|
||||
Cwd: root, Registry: registry, SessionID: "session-posture-off",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(provider.requests) != 2 {
|
||||
t.Fatalf("a posture-off run cost %d requests, want 2: the check reached a path it must not", len(provider.requests))
|
||||
}
|
||||
if !strings.Contains(result.FinalAnswer, "4.20s") {
|
||||
t.Errorf("a posture-off run's answer was altered: %q", result.FinalAnswer)
|
||||
}
|
||||
}
|
||||
|
||||
// AN UNCORRECTED ANSWER IS RETURNED RATHER THAN ASKED AGAIN. The ledger raises
|
||||
// each name once, so a model that repeats the number ends the run instead of
|
||||
// looping until the turn budget is gone.
|
||||
func TestARepeatedNumberEndsTheRunRatherThanLooping(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(goTestTool{output: suiteOutput})
|
||||
// Says the same wrong number both times.
|
||||
provider := callThenAnswer("github.com/x/y took 4.20s.", "github.com/x/y took 4.20s.")
|
||||
|
||||
result, err := Run(context.Background(), "run the suite", provider, Options{
|
||||
Cwd: root, Registry: registry, SessionID: "session-stubborn",
|
||||
Zeromaxing: ZeromaxingActive,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(provider.requests) != 3 {
|
||||
t.Fatalf("the run cost %d requests, want exactly 3: one call, one answer, one correction", len(provider.requests))
|
||||
}
|
||||
if !strings.Contains(result.FinalAnswer, "4.20s") {
|
||||
t.Errorf("final answer = %q", result.FinalAnswer)
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/Twigpine/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
type safetyTool struct {
|
||||
|
||||
@@ -8,9 +8,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Twigpine/zero/internal/tools"
|
||||
"github.com/Twigpine/zero/internal/trace"
|
||||
"github.com/Twigpine/zero/internal/zeroruntime"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/trace"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// midStreamAbortProvider connects successfully but emits a transport-abort
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// THE PROVIDER OUTRANKS THE MODEL ID, because the provider knows and the id only
|
||||
// hints.
|
||||
//
|
||||
// A ChatGPT OAuth session was classified correctly for one reason: "gpt-5.5"
|
||||
// happens to begin with "gpt". Nothing obliges the next id to — OpenAI has
|
||||
// already shipped o1, o3 and o4 — and the prefix list has to be amended each
|
||||
// time to keep a first-party provider working. Asking the provider does not.
|
||||
func TestTheProviderFamilyWinsOverTheModelId(t *testing.T) {
|
||||
// An id no prefix arm matches, from a provider that declares OpenAI.
|
||||
if got := modelPromptAddendum("openai", "some-unreleased-id"); got != openAIPromptAddendum {
|
||||
t.Error("a provider that declares openai did not get the openai addendum for an unrecognised id")
|
||||
}
|
||||
if got := modelPromptAddendum("gemini", "some-unreleased-id"); got != geminiPromptAddendum {
|
||||
t.Error("a provider that declares gemini did not get the gemini addendum")
|
||||
}
|
||||
// Anthropic is aligned with the core prompt and takes no addendum, declared
|
||||
// or guessed. Asserted so "no addendum" stays a decision, not an accident.
|
||||
if got := modelPromptAddendum("anthropic", "some-unreleased-id"); got != "" {
|
||||
t.Errorf("anthropic gained an addendum: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A GATEWAY DECLARES NOTHING, and must fall back to the id — it is the only
|
||||
// thing that distinguishes openai/gpt-4.1 from z-ai/glm-4.6 on one endpoint.
|
||||
func TestAGatewayFallsBackToTheModelId(t *testing.T) {
|
||||
for _, testCase := range []struct {
|
||||
model string
|
||||
want string
|
||||
}{
|
||||
{"openai/gpt-4.1", openAIPromptAddendum},
|
||||
{"google/gemini-2.5-pro", geminiPromptAddendum},
|
||||
{"anthropic/claude-sonnet-4.5", ""},
|
||||
// Unclassified, and honestly so: no family addendum exists for it.
|
||||
{"z-ai/glm-4.6", ""},
|
||||
{"qwen3-coder:480b", ""},
|
||||
} {
|
||||
if got := modelPromptAddendum("", testCase.model); got != testCase.want {
|
||||
t.Errorf("%s: addendum = %q, want %q", testCase.model, truncateForTest(got), truncateForTest(testCase.want))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The wiring is not decoration: a family that reaches Options must reach the
|
||||
// prompt. Asserted through the real assembly rather than the helper, because
|
||||
// asserting the helper is how a value gets threaded to a layer that drops it.
|
||||
func TestTheDeclaredFamilyReachesTheAssembledPrompt(t *testing.T) {
|
||||
withProvider := BuildSystemPromptPreview(Options{ModelFamily: "openai", Model: "some-unreleased-id"})
|
||||
if !strings.Contains(withProvider, "Persist until the task is fully handled this turn") {
|
||||
t.Error("the openai guidance did not reach the assembled prompt when the provider declared it")
|
||||
}
|
||||
withoutProvider := BuildSystemPromptPreview(Options{Model: "some-unreleased-id"})
|
||||
if strings.Contains(withoutProvider, "Persist until the task is fully handled this turn") {
|
||||
t.Error("an unclassified model gained openai guidance from nowhere")
|
||||
}
|
||||
}
|
||||
|
||||
func truncateForTest(text string) string {
|
||||
if len(text) > 40 {
|
||||
return text[:40] + "..."
|
||||
}
|
||||
return text
|
||||
}
|
||||
@@ -8,10 +8,10 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Twigpine/zero/internal/hooks"
|
||||
"github.com/Twigpine/zero/internal/tools"
|
||||
"github.com/Twigpine/zero/internal/trace"
|
||||
"github.com/Twigpine/zero/internal/zeroruntime"
|
||||
"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 {
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"github.com/Twigpine/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
// Parallel read-ahead for tool batches. When a turn requests several
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Twigpine/zero/internal/tools"
|
||||
"github.com/Twigpine/zero/internal/zeroruntime"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// probeTool records execution overlap and ordering so tests can assert what
|
||||
|
||||
@@ -3,7 +3,7 @@ package agent
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Twigpine/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
func TestPartitionToolsActiveAppendsLoadedToolAfterEagerBlock(t *testing.T) {
|
||||
|
||||
@@ -5,8 +5,8 @@ import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/Twigpine/zero/internal/tools"
|
||||
"github.com/Twigpine/zero/internal/zeroruntime"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// countingSchemaTool records how many times its Parameters() schema is read, so a
|
||||
|
||||
@@ -4,8 +4,8 @@ import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/Twigpine/zero/internal/sandbox"
|
||||
"github.com/Twigpine/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
// promptShellTool is a minimal shell-style tool whose Safety requires a prompt,
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Twigpine/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
)
|
||||
|
||||
func TestPermissionScope(t *testing.T) {
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/Twigpine/zero/internal/sandbox"
|
||||
"github.com/Twigpine/zero/internal/tools"
|
||||
)
|
||||
|
||||
// refusingTool declares that its approval must never be remembered.
|
||||
type refusingTool struct{ name string }
|
||||
|
||||
func (t refusingTool) Name() string { return t.name }
|
||||
func (refusingTool) Description() string { return "" }
|
||||
func (refusingTool) Parameters() tools.Schema { return tools.Schema{} }
|
||||
func (refusingTool) Safety() tools.Safety { return tools.Safety{Permission: tools.PermissionPrompt} }
|
||||
func (refusingTool) Run(context.Context, map[string]any) tools.Result { return tools.Result{} }
|
||||
func (refusingTool) RefusesPersistentPermission() bool { return true }
|
||||
|
||||
// ordinaryTool declares nothing, so the name list decides for it.
|
||||
type ordinaryTool struct{ name string }
|
||||
|
||||
func (t ordinaryTool) Name() string { return t.name }
|
||||
func (ordinaryTool) Description() string { return "" }
|
||||
func (ordinaryTool) Parameters() tools.Schema { return tools.Schema{} }
|
||||
func (ordinaryTool) Safety() tools.Safety { return tools.Safety{Permission: tools.PermissionPrompt} }
|
||||
func (ordinaryTool) Run(context.Context, map[string]any) tools.Result { return tools.Result{} }
|
||||
|
||||
// A TOOL'S OWN REFUSAL WINS over the name list.
|
||||
//
|
||||
// The list refuses bash, exec_command, write_stdin and apply_patch because
|
||||
// "always allow" is too broad a standing grant for them. It cannot describe a
|
||||
// tool that RUNS those — and orchestrate can be given bash by a plan, so
|
||||
// remembering it would be strictly broader than the refusal the list enforces.
|
||||
func TestAToolCanRefuseToBeRemembered(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(refusingTool{name: "refuses"})
|
||||
registry.Register(ordinaryTool{name: "ordinary"})
|
||||
options := Options{Registry: registry}
|
||||
|
||||
if permissionSupportsPersistentDecision("refuses", options) {
|
||||
t.Fatal("a tool that refuses persistence was offered it")
|
||||
}
|
||||
if !permissionSupportsPersistentDecision("ordinary", options) {
|
||||
t.Fatal("an ordinary tool lost its persistent approval")
|
||||
}
|
||||
}
|
||||
|
||||
// The NAME LIST still applies to tools that declare nothing, so this adds a
|
||||
// rule rather than replacing one.
|
||||
func TestTheExistingNameRefusalsAreUnchanged(t *testing.T) {
|
||||
options := Options{Registry: tools.NewRegistry()}
|
||||
for _, name := range []string{"bash", "exec_command", "write_stdin", "apply_patch"} {
|
||||
if permissionSupportsPersistentDecision(name, options) {
|
||||
t.Errorf("%q must not be persistently approvable", name)
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"read_file", "write_file", "edit_file"} {
|
||||
if !permissionSupportsPersistentDecision(name, options) {
|
||||
t.Errorf("%q lost its persistent approval", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A nil registry must not crash and must not start refusing everything — the
|
||||
// name list is the fallback, not an empty answer.
|
||||
func TestPersistentDecisionsSurviveANilRegistry(t *testing.T) {
|
||||
if permissionSupportsPersistentDecision("bash", Options{}) {
|
||||
t.Fatal("bash became persistable with no registry")
|
||||
}
|
||||
if !permissionSupportsPersistentDecision("read_file", Options{}) {
|
||||
t.Fatal("read_file lost persistence with no registry")
|
||||
}
|
||||
}
|
||||
|
||||
// THE CONSEQUENCE, at the decision list a prompt actually offers. Asserting the
|
||||
// predicate alone would pass against a caller that ignored it.
|
||||
func TestARefusingToolIsNeverOfferedAlwaysAllow(t *testing.T) {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(refusingTool{name: "refuses"})
|
||||
registry.Register(ordinaryTool{name: "ordinary"})
|
||||
engine := sandbox.NewEngine(sandbox.EngineOptions{})
|
||||
options := Options{Registry: registry, Sandbox: engine}
|
||||
|
||||
offered := func(name string) bool {
|
||||
event := PermissionEvent{ToolName: name, Action: PermissionActionPrompt}
|
||||
for _, decision := range availablePermissionDecisions(event, nil, options) {
|
||||
if decision == PermissionDecisionAlwaysAllow {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
if offered("refuses") {
|
||||
t.Fatal("a refusing tool was offered always-allow in the prompt")
|
||||
}
|
||||
if !engine.CanPersistGrants() {
|
||||
t.Skip("this engine cannot persist grants, so the positive case is unreachable")
|
||||
}
|
||||
if !offered("ordinary") {
|
||||
t.Fatal("an ordinary tool lost always-allow; the refusal is being applied to everything")
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Twigpine/zero/internal/zeroruntime"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
func planUpdateTurn(status string) zeroruntime.CollectedStream {
|
||||
|
||||
@@ -1,215 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Twigpine/zero/internal/tools"
|
||||
"github.com/Twigpine/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
// THE ADDITIVITY PROOF.
|
||||
//
|
||||
// ZeroMaxing Phase 2 adds an `orchestrate` tool. The constraint proved here is
|
||||
// that REGISTERING THAT TOOL changes nothing while the posture is OFF: same
|
||||
// advertised tool set, same tool-definition bytes, same assembled system
|
||||
// prompt, therefore the same token count and the same provider request.
|
||||
//
|
||||
// Stated narrowly on purpose. "With the posture off, Zero is byte-identical to
|
||||
// a build without the feature" is the tempting phrasing and it is not true:
|
||||
// system_prompt.go drops the ~5 KB confirmation policy for any run that cannot
|
||||
// mutate, and runCanMutate is evaluated regardless of posture. An all-read-only
|
||||
// run — `zero exec --enabled-tools read_file,grep`, or a read-only specialist
|
||||
// child — therefore gets a smaller prompt than a build without the feature,
|
||||
// posture off. That drop is deliberate and fails closed; it is simply not part
|
||||
// of what this test proves, and the comparison below could not catch it anyway
|
||||
// since both registries take the same branch.
|
||||
//
|
||||
// The proof is a CONTROLLED COMPARISON rather than a frozen hash of the whole
|
||||
// prefix. Two registries are built that differ in exactly one thing — whether
|
||||
// the orchestrate tool is registered — and the posture-off output of both must
|
||||
// be byte-identical. That states the real property ("registering this tool
|
||||
// changes nothing while the posture is off"), it exercises the Deferred()
|
||||
// mechanism that enforces it, and it cannot rot: there is no constant to
|
||||
// regenerate and nothing to keep in step by hand.
|
||||
//
|
||||
// A frozen whole-prefix hash was tried first and is the wrong instrument here:
|
||||
// the assembled prompt embeds the working directory and the operating system,
|
||||
// so the constant would be machine- and platform-specific and every CI runner
|
||||
// would "fail" honestly-unchanged code. The definition-bytes hash below keeps
|
||||
// the frozen-golden idea where it IS portable.
|
||||
|
||||
// fixtureCwd is a fixed synthetic path. The prompt embeds the working
|
||||
// directory, so a real t.TempDir() would make any hash unstable across runs.
|
||||
const fixtureCwd = "/zero/fixture/workspace"
|
||||
|
||||
// baseFixtureRegistry is the control: a fixed, representative tool set with NO
|
||||
// orchestrate tool. Deliberately not the full core set — that would make the
|
||||
// golden hash churn on every unrelated tool change and train the reader to
|
||||
// regenerate it.
|
||||
func baseFixtureRegistry() *tools.Registry {
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedReadFileTool(fixtureCwd, nil))
|
||||
registry.Register(tools.NewScopedGrepTool(fixtureCwd, nil))
|
||||
registry.Register(tools.NewScopedGlobTool(fixtureCwd, nil))
|
||||
registry.Register(tools.NewScopedListDirectoryTool(fixtureCwd, nil))
|
||||
return registry
|
||||
}
|
||||
|
||||
// postureOffPrefix returns the three things a user pays for, for a posture-OFF
|
||||
// run against the given registry: the advertised tool names in emitted order,
|
||||
// the full tool-definition bytes, and the assembled system prompt.
|
||||
func postureOffPrefix(t *testing.T, registry *tools.Registry, mode PermissionMode) (names []string, definitions string, prompt string) {
|
||||
t.Helper()
|
||||
options := Options{
|
||||
Cwd: fixtureCwd,
|
||||
Registry: registry,
|
||||
SessionID: "session-posture-off-identity",
|
||||
Zeromaxing: ZeromaxingOff, // THE POINT: the posture is off.
|
||||
// Deferral ACTIVE. Without this the partition never consults Deferred()
|
||||
// at all, and this test would pass on the permission gate alone — which
|
||||
// it originally did, so a Deferred() regression went undetected by it.
|
||||
DeferThreshold: 1,
|
||||
}
|
||||
exposed, _ := partitionTools(registry, mode, options, nil)
|
||||
for _, definition := range exposed {
|
||||
names = append(names, definition.Name)
|
||||
}
|
||||
encoded, err := json.Marshal(exposed)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal tool definitions: %v", err)
|
||||
}
|
||||
return names, string(encoded), buildSystemPromptParts(options).prompt
|
||||
}
|
||||
|
||||
// (a) THE PROOF. Registering the orchestrate tool must change NOTHING that a
|
||||
// posture-off run sends: not the advertised set, not the definition bytes, not
|
||||
// the prompt. If this fails, the feature has stopped being additive and the
|
||||
// diff is what is wrong.
|
||||
func TestPostureOffPrefixUnchangedByRegisteringTheTool(t *testing.T) {
|
||||
// BOTH permission modes. Under auto the permission gate would hide the tool
|
||||
// even if Deferred() were broken, so auto alone proves nothing about the
|
||||
// mechanism; unsafe is where Deferred() is the ONLY thing standing between
|
||||
// the tool and the advertised set.
|
||||
for _, mode := range []PermissionMode{PermissionModeAuto, PermissionModeUnsafe} {
|
||||
t.Run(string(mode), func(t *testing.T) { assertPostureOffPrefixUnchanged(t, mode) })
|
||||
}
|
||||
}
|
||||
|
||||
func assertPostureOffPrefixUnchanged(t *testing.T, mode PermissionMode) {
|
||||
t.Helper()
|
||||
withoutNames, withoutDefs, withoutPrompt := postureOffPrefix(t, baseFixtureRegistry(), mode)
|
||||
|
||||
withTool := baseFixtureRegistry()
|
||||
registerPhase2ToolForTest(withTool)
|
||||
withNames, withDefs, withPrompt := postureOffPrefix(t, withTool, mode)
|
||||
|
||||
if !reflect.DeepEqual(withoutNames, withNames) {
|
||||
t.Fatalf("ADVERTISED TOOL SET CHANGED with the posture off:\n without: %v\n with: %v",
|
||||
withoutNames, withNames)
|
||||
}
|
||||
if withoutDefs != withDefs {
|
||||
t.Fatalf("TOOL DEFINITION BYTES CHANGED with the posture off (%d -> %d bytes)",
|
||||
len(withoutDefs), len(withDefs))
|
||||
}
|
||||
if withoutPrompt != withPrompt {
|
||||
t.Fatalf("ASSEMBLED PROMPT CHANGED with the posture off (%d -> %d bytes)",
|
||||
len(withoutPrompt), len(withPrompt))
|
||||
}
|
||||
// Guard against the test passing because the tool was never registered at
|
||||
// all: it must be present in the registry, just not advertised.
|
||||
if _, ok := withTool.Get(phase2ToolName); !ok {
|
||||
t.Fatalf("fixture did not register %q, so this test proves nothing", phase2ToolName)
|
||||
}
|
||||
}
|
||||
|
||||
// The same claim in terms a reader can check without diffing: with the posture
|
||||
// off the tool is registered but not advertised, and its name appears nowhere
|
||||
// in what the provider receives.
|
||||
func TestPostureOffDoesNotAdvertiseThePhase2Tool(t *testing.T) {
|
||||
registry := baseFixtureRegistry()
|
||||
registerPhase2ToolForTest(registry)
|
||||
names, definitions, prompt := postureOffPrefix(t, registry, PermissionModeUnsafe)
|
||||
|
||||
for _, name := range names {
|
||||
if name == phase2ToolName {
|
||||
t.Fatalf("posture off must not advertise %q, got %v", phase2ToolName, names)
|
||||
}
|
||||
}
|
||||
if strings.Contains(definitions, phase2ToolName) {
|
||||
t.Fatalf("posture off must not send %q in the tool definitions", phase2ToolName)
|
||||
}
|
||||
if strings.Contains(prompt, phase2ToolName) {
|
||||
t.Fatalf("posture off must not name %q in the system prompt", phase2ToolName)
|
||||
}
|
||||
}
|
||||
|
||||
// ...and the run-level counterpart: a posture-off conversation carries no
|
||||
// posture or orchestration text at all.
|
||||
func TestPostureOffRunCarriesNoPostureText(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
registry := tools.NewRegistry()
|
||||
registry.Register(tools.NewScopedReadFileTool(root, nil))
|
||||
registerPhase2ToolForTest(registry)
|
||||
provider := &mockProvider{turns: [][]zeroruntime.StreamEvent{
|
||||
{{Type: zeroruntime.StreamEventText, Content: "done"}, {Type: zeroruntime.StreamEventDone}},
|
||||
}}
|
||||
if _, err := Run(context.Background(), "hello", provider, Options{
|
||||
Cwd: root, Registry: registry, SessionID: "session-posture-off-run",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var rendered strings.Builder
|
||||
for _, message := range provider.requests[0].Messages {
|
||||
rendered.WriteString(message.Content)
|
||||
}
|
||||
if strings.Contains(rendered.String(), phase2ToolName) {
|
||||
t.Fatalf("a posture-off run must never name %q:\n%s", phase2ToolName, rendered.String())
|
||||
}
|
||||
for _, forbidden := range []string{
|
||||
ZeromaxingEnterNotice, ZeromaxingBudgetNotice, ZeromaxingStillOnNotice, ZeromaxingExitNotice,
|
||||
} {
|
||||
if strings.Contains(rendered.String(), forbidden) {
|
||||
t.Fatalf("a posture-off run must carry no posture text, found %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A frozen golden over the TOOL DEFINITION BYTES only. Definitions carry no
|
||||
// environment (no cwd, no OS), so unlike the whole prefix this IS portable and
|
||||
// a moved hash means a real schema change in the fixture's tools.
|
||||
//
|
||||
// MOVED AGAIN AFTER THE CURRENT MAIN REFRESH. Main's tool descriptions now
|
||||
// distinguish exact reads from minified exploratory reads while keeping the
|
||||
// same posture-off exposure contract. Before that, #867 reshaped read_file's
|
||||
// schema (the canonical
|
||||
// path/offset/limit contract) and reworded its description; before that, #838
|
||||
// reworded glob, grep, list_directory and read_file. Both are schema changes on
|
||||
// main's side rather than posture leaks from this branch, and the distinction was
|
||||
// proved rather than assumed: the first request body of a posture-off run was
|
||||
// compared byte for byte against a binary built from that same main, across
|
||||
// --auto low/medium/high/member and --use-spec, and all five were identical.
|
||||
// The absolute byte counts moved with main's new wording (33549 -> 31851 on
|
||||
// --auto low); the DIFFERENCE between the two binaries stayed zero, which is
|
||||
// the thing this guard exists to hold.
|
||||
const postureOffDefinitionsFingerprint = "35a12ffe0079ffb18a3342df5e0c3499836d2f2811b94707ae6c337cf5087197"
|
||||
|
||||
func TestPostureOffToolDefinitionsMatchGolden(t *testing.T) {
|
||||
registry := baseFixtureRegistry()
|
||||
registerPhase2ToolForTest(registry)
|
||||
_, definitions, _ := postureOffPrefix(t, registry, PermissionModeUnsafe)
|
||||
sum := sha256.Sum256([]byte(definitions))
|
||||
got := hex.EncodeToString(sum[:])
|
||||
if postureOffDefinitionsFingerprint == "PLACEHOLDER" {
|
||||
t.Fatalf("golden not captured yet; observed %s", got)
|
||||
}
|
||||
if got != postureOffDefinitionsFingerprint {
|
||||
t.Fatalf("posture-off tool definitions changed:\n got %s\n want %s\n%s",
|
||||
got, postureOffDefinitionsFingerprint, definitions)
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,8 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/Twigpine/zero/internal/sandbox"
|
||||
"github.com/Twigpine/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
)
|
||||
|
||||
// escalatingEngine allows unsandboxed escalation: no denied reads, so
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"github.com/Twigpine/zero/internal/sandbox"
|
||||
"github.com/Gitlawb/zero/internal/sandbox"
|
||||
)
|
||||
|
||||
// profileController observes per-turn run signals and decides at most one
|
||||
|
||||
@@ -5,10 +5,10 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Twigpine/zero/internal/sandbox"
|
||||
"github.com/Twigpine/zero/internal/tools"
|
||||
"github.com/Twigpine/zero/internal/trace"
|
||||
"github.com/Twigpine/zero/internal/zeroruntime"
|
||||
"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) {
|
||||
|
||||
Loaded 100 of 996 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user