From 16c420b3002ceda875121b5a44a2b6b615006e2a Mon Sep 17 00:00:00 2001 From: timbrinded <79199034+timbrinded@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:32:42 +0100 Subject: [PATCH 01/11] chore: require pi 1.0 and build against its types Resolve the host-provided peers to pi 1.0.4 so the codemode-era extension APIs (tool exposure, isError results, system prompt sections, executeTool) typecheck. ToolInfo fixtures gain the now-required exposure, and the OAuth session test picks any catalog Codex model instead of the retired gpt-5.4. --- .../pi-workflow-engine/src/pi-compat.ts | 4 +- README.md | 2 +- USAGE.md | 4 +- bun.lock | 218 +++++++++--------- tests/agent-runner-fixtures.ts | 2 + tests/agent-runner-replay.test.ts | 2 + tests/agent-session-services.test.ts | 3 +- tests/pi-compat.test.ts | 10 +- 8 files changed, 128 insertions(+), 117 deletions(-) diff --git a/.pi/extensions/pi-workflow-engine/src/pi-compat.ts b/.pi/extensions/pi-workflow-engine/src/pi-compat.ts index ddb3f39..a423947 100644 --- a/.pi/extensions/pi-workflow-engine/src/pi-compat.ts +++ b/.pi/extensions/pi-workflow-engine/src/pi-compat.ts @@ -1,6 +1,6 @@ -const MINIMUM_PI_VERSION = "0.80.10"; +const MINIMUM_PI_VERSION = "1.0.0"; -const MINIMUM_PI_CORE = [0, 80, 10] as const; +const MINIMUM_PI_CORE = [1, 0, 0] as const; /** Fail during extension registration instead of failing later in a workflow. */ export function assertSupportedPiVersion(version: string): void { diff --git a/README.md b/README.md index 5570ac9..30b1e3c 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ one-off workflow for the task. ## Quick start -Requires **pi 0.80.10 or newer**. +Requires **pi 1.0.0 or newer**. ```bash pi install npm:pi-workflow-engine diff --git a/USAGE.md b/USAGE.md index 49c7262..1ae61f0 100644 --- a/USAGE.md +++ b/USAGE.md @@ -2,7 +2,7 @@ `pi-workflow-engine` adds zero-dependency Dynamax workflows to pi: opt into live, parallel subagents for the tasks that need a custom investigation instead of one long prompt. Most users only need these surfaces: -This release requires **pi 0.80.10 or newer**. pi remains host-provided rather +This release requires **pi 1.0.0 or newer**. pi remains host-provided rather than bundled by this package, and the extension rejects older runtimes at load time with an update message. @@ -160,7 +160,7 @@ The default effect is `shine`. Set `PI_DYNAMAX_EFFECT=static` for a motionless cue or `PI_DYNAMAX_EFFECT=off` to disable it. A non-empty `NO_COLOR` also disables the default cue; an explicit `PI_DYNAMAX_EFFECT` value takes precedence. -The implementation follows pi 0.80.10's [official `CustomEditor` +The implementation follows pi's [official `CustomEditor` pattern](https://pi.dev/docs/latest/extensions#custom-editor): it decorates only the editor's rendered lines, retaining the wrapped editor's input, cursor, deletion, paste, multiline, undo, completion, IME, and app-keybinding behavior. diff --git a/bun.lock b/bun.lock index 68f7ad3..d1cebad 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,5 @@ { - "lockfileVersion": 1, + "lockfileVersion": 2, "configVersion": 1, "workspaces": { "": { @@ -18,99 +18,117 @@ }, }, "packages": { - "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.91.1", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw=="], + "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.129.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1", "standardwebhooks": "^1.0.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-MH7LB20kNpGLUpPTe2OG5XvL/KhX8HUO9XyBaFjgFk6TLS4Xjt0VPIuMKywe7POKKPslX+QxSlVok9e+8kG5Nw=="], - "@aws-crypto/crc32": ["@aws-crypto/crc32@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg=="], + "@aws-sdk/client-bedrock-runtime": ["@aws-sdk/client-bedrock-runtime@3.1127.0", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/credential-provider-node": "^3.972.82", "@aws-sdk/eventstream-handler-node": "^3.972.34", "@aws-sdk/middleware-eventstream": "^3.972.29", "@aws-sdk/middleware-websocket": "^3.972.52", "@aws-sdk/token-providers": "3.1127.0", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/fetch-http-handler": "^5.7.2", "@smithy/node-http-handler": "^4.11.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-IDl/lrPb90aH+pZFHGNDmgH9nAUQj5PlZH1sJ3w7RikctyjHSnY3oNjZhrLoaBoQn/rNK0zsP6OHEqEhj2tdLA=="], - "@aws-crypto/sha256-browser": ["@aws-crypto/sha256-browser@5.2.0", "", { "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", "@aws-crypto/supports-web-crypto": "^5.2.0", "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-locate-window": "^3.0.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw=="], + "@aws-sdk/core": ["@aws-sdk/core@3.978.1", "", { "dependencies": { "@aws-sdk/types": "^3.974.6", "@aws-sdk/xml-builder": "^3.972.41", "@aws/lambda-invoke-store": "^0.3.0", "@smithy/core": "^3.35.0", "@smithy/signature-v4": "^5.7.3", "@smithy/types": "^4.19.0", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-LbY9aGsEiznDWmUc30Nwv3aIX/+dbwTx8KfS0yOC3NPYMO+O91e6jkT1azf34FwjOndq8/Q+RcVVZz5xnerwdg=="], - "@aws-crypto/sha256-js": ["@aws-crypto/sha256-js@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA=="], + "@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.72", "", { "dependencies": { "@aws-sdk/core": "^3.978.1", "@aws-sdk/types": "^3.974.6", "@smithy/core": "^3.35.0", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-xTKO/FWJPozTIXbozVnVGoNBhaGba8TBcx+KyUjRVeOlXE+dUc7GTR1cLvu0uTdIdmemzaFbqqCshXeZA1fZew=="], - "@aws-crypto/supports-web-crypto": ["@aws-crypto/supports-web-crypto@5.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg=="], + "@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.74", "", { "dependencies": { "@aws-sdk/core": "^3.978.1", "@aws-sdk/types": "^3.974.6", "@smithy/core": "^3.35.0", "@smithy/fetch-http-handler": "^5.8.0", "@smithy/node-http-handler": "^4.12.1", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-u91E/hT8f4d1xy0Jl7VG4nVKJ3lxbrZkoBTeSVoJdWBiSEUMwMS/9+e0H/aJVQV//Lt5wuzP+E69v4aRSsNTmw=="], - "@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="], + "@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.973.17", "", { "dependencies": { "@aws-sdk/core": "^3.978.1", "@aws-sdk/credential-provider-env": "^3.972.72", "@aws-sdk/credential-provider-http": "^3.972.74", "@aws-sdk/credential-provider-login": "^3.972.79", "@aws-sdk/credential-provider-process": "^3.972.72", "@aws-sdk/credential-provider-sso": "^3.973.16", "@aws-sdk/credential-provider-web-identity": "^3.972.78", "@aws-sdk/nested-clients": "^3.997.46", "@aws-sdk/types": "^3.974.6", "@smithy/core": "^3.35.0", "@smithy/credential-provider-imds": "^4.5.2", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-ged4KXdBkvIC81bLvNHHuQKdKak/VXhQTR1NWYTTqW0474nlmsxy9O/vlgTIohDDWH3xpBdtVMZRyjb+DnocDA=="], - "@aws-sdk/client-bedrock-runtime": ["@aws-sdk/client-bedrock-runtime@3.1048.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.11", "@aws-sdk/credential-provider-node": "^3.972.42", "@aws-sdk/eventstream-handler-node": "^3.972.16", "@aws-sdk/middleware-eventstream": "^3.972.12", "@aws-sdk/middleware-websocket": "^3.972.19", "@aws-sdk/token-providers": "3.1048.0", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/fetch-http-handler": "^5.4.2", "@smithy/node-http-handler": "^4.7.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ=="], + "@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.79", "", { "dependencies": { "@aws-sdk/core": "^3.978.1", "@aws-sdk/nested-clients": "^3.997.46", "@aws-sdk/types": "^3.974.6", "@smithy/core": "^3.35.0", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-L+Z85anONJd8MaiuraO4wRxATCdEejBZ3K3eymzWI5JPXa9sOS9CkIm72PBKqXKX+Z9p9NGMX5AIMXm0LEflgw=="], - "@aws-sdk/core": ["@aws-sdk/core@3.974.17", "", { "dependencies": { "@aws-sdk/types": "^3.973.10", "@aws-sdk/xml-builder": "^3.972.27", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/core": "^3.24.6", "@smithy/signature-v4": "^5.4.6", "@smithy/types": "^4.14.3", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-r8o4h2K7j6P9ngno+8ei0aK0U/4JwDb7A2fMMxGVoSqDN8AFlIzSDeZHME9LcVLR2codyhtr1WAAg+/nmkeeMA=="], + "@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.84", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.72", "@aws-sdk/credential-provider-http": "^3.972.74", "@aws-sdk/credential-provider-ini": "^3.973.17", "@aws-sdk/credential-provider-process": "^3.972.72", "@aws-sdk/credential-provider-sso": "^3.973.16", "@aws-sdk/credential-provider-web-identity": "^3.972.78", "@aws-sdk/types": "^3.974.6", "@smithy/core": "^3.35.0", "@smithy/credential-provider-imds": "^4.5.2", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-oHt854odINVwzwsh+c5x69j0ajm4DbqqqVJ+O1ECsCIZeMDAbzFpXItaqP7UZstJj/ATdTk/KFSH0LaNAgV+kA=="], - "@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.43", "", { "dependencies": { "@aws-sdk/core": "^3.974.17", "@aws-sdk/types": "^3.973.10", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-g0XVQKzaA/4cq1vz1IvCQwYM+1Pkv01J9yHDpCTXekVuGZRDEz0wqBQ1AuYTq7FM6uik4uBGH8Tb5d9YvgeA7g=="], + "@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.72", "", { "dependencies": { "@aws-sdk/core": "^3.978.1", "@aws-sdk/types": "^3.974.6", "@smithy/core": "^3.35.0", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-rLIp2xbMjX/k9/od7APpqq1ZgXXnV0pOL1Th3ZsL8Wu0TRtBsDTVS8iPqcfRFcHakFxPvR04OSTv2ka2qOb/2A=="], - "@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.45", "", { "dependencies": { "@aws-sdk/core": "^3.974.17", "@aws-sdk/types": "^3.973.10", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/node-http-handler": "^4.7.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-w9PuOoKCt6+xoESvY+zlV0u3PKQ0mVL259PcsVR6a3S/uYJJHnIi4r1NxdJHEcNldUVRIciltWnFMGBR4YEm3g=="], + "@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.973.16", "", { "dependencies": { "@aws-sdk/core": "^3.978.1", "@aws-sdk/nested-clients": "^3.997.46", "@aws-sdk/token-providers": "3.1138.0", "@aws-sdk/types": "^3.974.6", "@smithy/core": "^3.35.0", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-IGihaJfFZYacJJr/odqILCoK7W/mvrZ7cuK7ECn3sAu4vLC6u0V8bS7mCGbdugJ8Aum2tnvqmx0F2MRFp2rn9g=="], - "@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.48", "", { "dependencies": { "@aws-sdk/core": "^3.974.17", "@aws-sdk/credential-provider-env": "^3.972.43", "@aws-sdk/credential-provider-http": "^3.972.45", "@aws-sdk/credential-provider-login": "^3.972.47", "@aws-sdk/credential-provider-process": "^3.972.43", "@aws-sdk/credential-provider-sso": "^3.972.47", "@aws-sdk/credential-provider-web-identity": "^3.972.47", "@aws-sdk/nested-clients": "^3.997.15", "@aws-sdk/types": "^3.973.10", "@smithy/core": "^3.24.6", "@smithy/credential-provider-imds": "^4.3.7", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-+6BQ6Lrnc+EyAGElLRW6j+Sa+RirPHnIJsobvYO6nnyK+oGKmz1ne/ieclbLWyjyDKEU3/JVJWcWY3VLFPvGtQ=="], + "@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.78", "", { "dependencies": { "@aws-sdk/core": "^3.978.1", "@aws-sdk/nested-clients": "^3.997.46", "@aws-sdk/types": "^3.974.6", "@smithy/core": "^3.35.0", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-/y9WvNtlcPBGLR0qc1a+9J/xtYZfVczvLUOuXaVWylzttH7ewsxwHtjmiJSolNrVSDorIxHGHMU61CbonRkmwA=="], - "@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.47", "", { "dependencies": { "@aws-sdk/core": "^3.974.17", "@aws-sdk/nested-clients": "^3.997.15", "@aws-sdk/types": "^3.973.10", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-Iy2ebWVgrZBH05464uJiQYu6HSSiROnwVZptthEFXx2gWjo1ORCxEAFZB5Cr2MdfrSnZ+0QUPkZ1ZpCqpkUrLQ=="], + "@aws-sdk/eventstream-handler-node": ["@aws-sdk/eventstream-handler-node@3.972.35", "", { "dependencies": { "@aws-sdk/types": "^3.974.6", "@smithy/core": "^3.35.0", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-a8xilRoRaalvSPZdfrs0VY3/BPc0uYhXW56Z/k6nmZ5Vk430LHeowf9APIPq8utM3tMJSNGSSuJMlB9YrPsN+A=="], - "@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.50", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.43", "@aws-sdk/credential-provider-http": "^3.972.45", "@aws-sdk/credential-provider-ini": "^3.972.48", "@aws-sdk/credential-provider-process": "^3.972.43", "@aws-sdk/credential-provider-sso": "^3.972.47", "@aws-sdk/credential-provider-web-identity": "^3.972.47", "@aws-sdk/types": "^3.973.10", "@smithy/core": "^3.24.6", "@smithy/credential-provider-imds": "^4.3.7", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-b05Aelq5cqAvCCDQjCYacl0XmR8QhBNSqLbsdISkQmlQBa5oPS66zYPteWcSp5LswbpoIe552EUGjluKiadBig=="], + "@aws-sdk/middleware-eventstream": ["@aws-sdk/middleware-eventstream@3.972.30", "", { "dependencies": { "@aws-sdk/types": "^3.974.6", "@smithy/core": "^3.35.0", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-B6gvZlcnRBNWraKgyEjKsbhv+VjtbyHmpU7hmtTHzXpDSxHPpCJnYLpglEOOsF+SFne20DbZC5IVC1aNr2Pb4A=="], - "@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.43", "", { "dependencies": { "@aws-sdk/core": "^3.974.17", "@aws-sdk/types": "^3.973.10", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-GPokLNyvTfCmuaHk+v3GKVs4ZT3cMu5kgS2a+NPkOMt96cq6fSIK0g+mZHpGS6Cd4QGrPKesANEaLUKgOskTzg=="], + "@aws-sdk/middleware-websocket": ["@aws-sdk/middleware-websocket@3.972.54", "", { "dependencies": { "@aws-sdk/core": "^3.978.1", "@aws-sdk/types": "^3.974.6", "@smithy/core": "^3.35.0", "@smithy/fetch-http-handler": "^5.8.0", "@smithy/signature-v4": "^5.7.3", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-bhESdru8u8KosziH8jcVta/NM/dNreeRz2+2aU86si5bfBYOiJ6IiqD+9U4URUcU5WJO3Yw+jO3GLDIHlscXMQ=="], - "@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.47", "", { "dependencies": { "@aws-sdk/core": "^3.974.17", "@aws-sdk/nested-clients": "^3.997.15", "@aws-sdk/token-providers": "3.1060.0", "@aws-sdk/types": "^3.973.10", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-0AzvLrzlvJs0DzbeWGvNj+bX3Uzd7VNS6vDqCOdZzBlCGKGd78uxctJSW9iK/Rt/nxiJqpTvrYQlVJ4guVM2Dw=="], + "@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.46", "", { "dependencies": { "@aws-sdk/core": "^3.978.1", "@aws-sdk/signature-v4-multi-region": "^3.996.47", "@aws-sdk/types": "^3.974.6", "@smithy/core": "^3.35.0", "@smithy/fetch-http-handler": "^5.8.0", "@smithy/node-http-handler": "^4.12.1", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-oRxtBcka/JGHGs9l9p9IVajGoTP8vTPmoAzdHGy4Qcy9P5vPnDf6nhIeM/COQNY9k/OahImTRaLkHftoXvfcmQ=="], - "@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.47", "", { "dependencies": { "@aws-sdk/core": "^3.974.17", "@aws-sdk/nested-clients": "^3.997.15", "@aws-sdk/types": "^3.973.10", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-eksfbUErOejUAGWBAcNqaP7IX21oUOEo73d9R56k9Ua4d57qS90NEYkWJsuSGzTXMFulCu17qXJI/qGmM7hvoA=="], + "@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.47", "", { "dependencies": { "@aws-sdk/types": "^3.974.6", "@smithy/signature-v4": "^5.7.3", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-Zk08macMvQTHzQJCLJVkOlviVoqwYMrpXv4lmLN7b7sAbiMoOK7Go0NYdR5UeF+MW8LIbRmwrNy9u/5VvX1U5g=="], - "@aws-sdk/eventstream-handler-node": ["@aws-sdk/eventstream-handler-node@3.972.19", "", { "dependencies": { "@aws-sdk/types": "^3.973.10", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-MZhrsChY4jwEp7LQnNkcNSvF4KHjDC8es1pgu61h6L48fY7YgRqDfGRoT4ADd7lj4dB+gtOYITgmf7k4QQ2TKg=="], + "@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1127.0", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/nested-clients": "^3.997.44", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-Dv2TMWBshJ+tF6ahs2Sy5bh4Iabsd4GAQqVvE9XZmYmnoaVbpS2QKIKE/HRacc7bTtbjEEvP+laGzHvHlf1CiQ=="], - "@aws-sdk/middleware-eventstream": ["@aws-sdk/middleware-eventstream@3.972.15", "", { "dependencies": { "@aws-sdk/types": "^3.973.10", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-4qYsO6temM6rEawcxHpMPWnRSIiLzsKhuizMlXCVujj54Q+HoGkVlcxk8S+5ekq/hOBdkyRnQjNsZaeRBz60hg=="], + "@aws-sdk/types": ["@aws-sdk/types@3.974.6", "", { "dependencies": { "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-v/clNZzZnDxGyvpHMOGpJKVXFAExJzUNAAjaWGdcx8QAcXLGwTaOkw33p5SHAi0YAioK32xB3hWwOekRVfmfKg=="], - "@aws-sdk/middleware-websocket": ["@aws-sdk/middleware-websocket@3.972.25", "", { "dependencies": { "@aws-sdk/core": "^3.974.17", "@aws-sdk/types": "^3.973.10", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/signature-v4": "^5.4.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-1u/r6SYArJr5qBHWQzwGw8cQu32V5Rcx68qb4v+ZhHXFn6dGDtCG5ImyULCLxhTktibLTh2qaRHOoHmkTKCyvA=="], + "@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.41", "", { "dependencies": { "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-ctjVSyCMegrWfXlx6VqzSBFI6UqmQ5ZlnfMhdLIiWmhoH8UAQxSCP5N3OpG7X3k4LnS7ou74C4mt20+bfTW2aQ=="], - "@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.15", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.17", "@aws-sdk/signature-v4-multi-region": "^3.996.31", "@aws-sdk/types": "^3.973.10", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/node-http-handler": "^4.7.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-Fpri1/PXKMKveORZ7E00VLTlWS5DkfZkW70PUE+bOnpWpAeHAQLoiDHhkzN3kNWbbSsGg64+IZYiq/EZgME3Mg=="], + "@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.3.0", "", {}, "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ=="], - "@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.31", "", { "dependencies": { "@aws-sdk/types": "^3.973.10", "@smithy/signature-v4": "^5.4.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-Kn2up9SlG1KC6wRtwf0d7waTGF6rvp9DxYqB54x6UCKdQ6kyaXCqHL4WGb5vUJga5kS8FxnjhY0LqM28aMvnNQ=="], + "@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="], - "@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1048.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.11", "@aws-sdk/nested-clients": "^3.997.9", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA=="], + "@earendil-works/chord": ["@earendil-works/chord@1.0.4", "", { "dependencies": { "esbuild": "0.28.2" } }, "sha512-d2JZw2utoa/reoSmL07O71wsXiMDhIlnnxTb4LCNV+Oq8Evj2rGLvjVuYrlXZGbXUr0IDNfvd1RT/utCY1UhkQ=="], - "@aws-sdk/types": ["@aws-sdk/types@3.973.10", "", { "dependencies": { "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-992QrTO7G9qCvKD0fx1rMlqcL14plUcRAbwmqqYVsuF3GrqcvlAL9qxR+baMafarEZ+l7DUQ5lCMmt5mbMhF7g=="], + "@earendil-works/pi-agent-core": ["@earendil-works/pi-agent-core@1.0.4", "", { "dependencies": { "@earendil-works/pi-ai": "^1.0.4", "typebox": "1.3.27" } }, "sha512-/xGICE+N/+G9N7KfYFSxlE8G8fQcBlLHKiY+j1Ehz9vvb1zhvGneWosFK56d2qmA5oteafeG7yKr2nqU4ed9ig=="], - "@aws-sdk/util-locate-window": ["@aws-sdk/util-locate-window@3.965.5", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ=="], + "@earendil-works/pi-ai": ["@earendil-works/pi-ai@1.0.4", "", { "dependencies": { "@anthropic-ai/sdk": "0.129.0", "@aws-sdk/client-bedrock-runtime": "3.1127.0", "@earendil-works/pi-telemetry": "^1.0.4", "@google/genai": "2.21.0", "@smithy/node-http-handler": "4.12.1", "http-proxy-agent": "9.1.0", "https-proxy-agent": "9.1.0", "openai": "7.19.0", "partial-json": "0.1.7", "typebox": "1.3.27" }, "bin": { "pi-ai": "dist/cli.js" } }, "sha512-/Eu5R0gfor6wcmRcVyOmulUiZmsHJ0bKu5wXXnpCoSHF3y4FnW4fgnjRlLjNza8NJWXdvW5Uew90Zukx7Sh2rw=="], - "@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.27", "", { "dependencies": { "@smithy/types": "^4.14.3", "fast-xml-parser": "5.7.3", "tslib": "^2.6.2" } }, "sha512-hpsCXCOI436kxWpjtRuIHVvuPP81MOw8f18jzfZeg+UOiiOvlqWcmWChzEhJEu16cOC6+ku4ncBN+7rdt+DZ9g=="], + "@earendil-works/pi-codemode": ["@earendil-works/pi-codemode@1.0.4", "", { "dependencies": { "quickjs-wasi": "3.6.2" } }, "sha512-e198pjBsdEQJ/RCjJ9ZeYIjRaQYWUgtoZbPCmJ57auuOsiRsBXeSZ1aiWtTah0/7gx97WIOD1XqHvgRU0gOXtg=="], - "@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.2.4", "", {}, "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ=="], + "@earendil-works/pi-coding-agent": ["@earendil-works/pi-coding-agent@1.0.4", "", { "dependencies": { "@earendil-works/chord": "^1.0.4", "@earendil-works/pi-agent-core": "^1.0.4", "@earendil-works/pi-ai": "^1.0.4", "@earendil-works/pi-codemode": "^1.0.4", "@earendil-works/pi-mcp": "^1.0.4", "@earendil-works/pi-tui": "^1.0.4", "@silvia-odwyer/photon-node": "0.3.4", "brace-expansion": "5.0.12", "chalk": "6.0.0", "cross-spawn": "7.0.6", "diff": "8.0.4", "grok-mermaid": "0.2.3", "highlight.js": "10.7.3", "hosted-git-info": "9.0.3", "ignore": "7.0.8", "jiti": "2.7.0", "minimatch": "10.2.6", "proper-lockfile": "4.1.2", "quickjs-wasi": "3.6.2", "semver": "7.8.5", "typebox": "1.3.27", "undici": "8.10.2", "yaml": "2.9.0" }, "bin": { "pi": "dist/bundle/cli.js" } }, "sha512-+956nfMFHr5lDUVY/2Q4k+YzojzBuCaBXFgj0eSlXVGr7QVliVddKdc1Pz6yVg1dOlJQmb67doOVrlMsIcIdaw=="], - "@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="], + "@earendil-works/pi-mcp": ["@earendil-works/pi-mcp@1.0.4", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-I6SqOqrKED+XrjkkFIXY7Ze38QtXtjojPJlQsJ2v5soCPV4QI1bNxXDwQaqzZ2a+6pNN8meWrRw1qkSMbPy9QQ=="], + + "@earendil-works/pi-telemetry": ["@earendil-works/pi-telemetry@1.0.4", "", {}, "sha512-DL2eXXtnL5/VQkuQd97fL57GXTG1/zKHtMmDsrv8tGeJ5EgYYTWvzy44dqbYL0lD0Z22v/NOwqU3PHO75HVrqA=="], + + "@earendil-works/pi-tui": ["@earendil-works/pi-tui@1.0.4", "", { "dependencies": { "get-east-asian-width": "1.6.0", "marked": "18.0.11" } }, "sha512-TgvDJohtxvG3XWQID5w6bwEyBocywdG0F9yHZtP+Ywvtiq9RgeSMbeB8quUobk7/4ko6IvFOQo45AqIqNVr8Rg=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.28.2", "", { "os": "android", "cpu": "arm" }, "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.2", "", { "os": "android", "cpu": "arm64" }, "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A=="], - "@earendil-works/pi-agent-core": ["@earendil-works/pi-agent-core@0.80.10", "", { "dependencies": { "@earendil-works/pi-ai": "^0.80.10", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" } }, "sha512-nwnOR3SuLYGRFfyQm8ri4Nj5VGVAvAM9GuqQd3u7BUQj0d6hmD2F8w7OHAAjThE3CuySIdM+v8E22QJG6/RfCg=="], + "@esbuild/android-x64": ["@esbuild/android-x64@0.28.2", "", { "os": "android", "cpu": "x64" }, "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q=="], - "@earendil-works/pi-ai": ["@earendil-works/pi-ai@0.80.10", "", { "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", "@google/genai": "1.52.0", "@mistralai/mistralai": "2.2.6", "@opentelemetry/api": "1.9.0", "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6", "openai": "6.26.0", "partial-json": "0.1.7", "typebox": "1.1.38" }, "bin": { "pi-ai": "dist/cli.js" } }, "sha512-Moe/H8c87yacDGK9dPbWphZNjVsrb3nTrIHycOQJAkFEnY9PYxOOd74+ny44kATfPU9Dm7aTHefar3pZF+UKUA=="], + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw=="], - "@earendil-works/pi-coding-agent": ["@earendil-works/pi-coding-agent@0.80.10", "", { "dependencies": { "@earendil-works/pi-agent-core": "^0.80.10", "@earendil-works/pi-ai": "^0.80.10", "@earendil-works/pi-tui": "^0.80.10", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", "diff": "8.0.4", "glob": "13.0.6", "highlight.js": "10.7.3", "hosted-git-info": "9.0.3", "ignore": "7.0.5", "jiti": "2.7.0", "minimatch": "10.2.5", "proper-lockfile": "4.1.2", "semver": "7.8.0", "typebox": "1.1.38", "undici": "8.5.0", "yaml": "2.9.0" }, "optionalDependencies": { "@mariozechner/clipboard": "0.3.9" }, "bin": { "pi": "dist/cli.js" } }, "sha512-aL4apbupCHiVLSXASXvRzH4Q2vmtfrDa+0s909CJuVu/GgGylbDzr7oyF1mPmip5E+VxYYxKWmph4hV04wUcQg=="], + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw=="], - "@earendil-works/pi-tui": ["@earendil-works/pi-tui@0.80.10", "", { "dependencies": { "get-east-asian-width": "1.6.0", "marked": "18.0.5" } }, "sha512-c2JO29PbhKPEQ6fgHQKAl0WhwuFqzWfzspMmP+8B5tpDuP+0mvarRbKKg8gq4b+pQx/QX+6aVS4ko7deoyjQjg=="], + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw=="], - "@google/genai": ["@google/genai@1.52.0", "", { "dependencies": { "google-auth-library": "^10.3.0", "p-retry": "^4.6.2", "protobufjs": "^7.5.4", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.25.2" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q=="], + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg=="], - "@mariozechner/clipboard": ["@mariozechner/clipboard@0.3.9", "", { "optionalDependencies": { "@mariozechner/clipboard-darwin-arm64": "0.3.9", "@mariozechner/clipboard-darwin-universal": "0.3.9", "@mariozechner/clipboard-darwin-x64": "0.3.9", "@mariozechner/clipboard-linux-arm64-gnu": "0.3.9", "@mariozechner/clipboard-linux-arm64-musl": "0.3.9", "@mariozechner/clipboard-linux-riscv64-gnu": "0.3.9", "@mariozechner/clipboard-linux-x64-gnu": "0.3.9", "@mariozechner/clipboard-linux-x64-musl": "0.3.9", "@mariozechner/clipboard-win32-arm64-msvc": "0.3.9", "@mariozechner/clipboard-win32-x64-msvc": "0.3.9" } }, "sha512-ABnA53mdfkGZwOFUdZNv2S0CWGO/EIuPj8Vv9xmBFmSYg/qFc7ihO6q5FcQjvoE67kZpWkEc4AhD6B/os04yuA=="], + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.2", "", { "os": "linux", "cpu": "arm" }, "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w=="], - "@mariozechner/clipboard-darwin-arm64": ["@mariozechner/clipboard-darwin-arm64@0.3.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BfgV7vCEWZwJwZJw03r6bP5+tf0iI/ANuQYCxi9RNn7FrWB3yzGuMKCrNLRl6V761vXRdL8+OqZ0wd4TqlsNOQ=="], + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug=="], - "@mariozechner/clipboard-darwin-universal": ["@mariozechner/clipboard-darwin-universal@0.3.9", "", { "os": "darwin" }, "sha512-BGGR4iA9Z2shAjI65eI5xtyb3LYNlDW9X3gxKxDbqtbnREohsrqznov6zpKoIrsRWpzlYVEdKphS7ksJ0/ndSQ=="], + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ=="], - "@mariozechner/clipboard-darwin-x64": ["@mariozechner/clipboard-darwin-x64@0.3.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-4kURmCbS6nt8uYhtmWpUcJWyPHfmAr5dTpXD1nO3pIfa+TSQ9DbrGOYCKH+aEFW47XhQ4Vp8ZTszie+wfFvDKg=="], + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ=="], - "@mariozechner/clipboard-linux-arm64-gnu": ["@mariozechner/clipboard-linux-arm64-gnu@0.3.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-g59OkUGP2DDfCOIKypHeYgv2M55u/cKvXa5dSxFbEJ34XvIQMdcVmpKCkGUro3ZgefXiGVdwguvTMQGpHWzIXw=="], + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA=="], - "@mariozechner/clipboard-linux-arm64-musl": ["@mariozechner/clipboard-linux-arm64-musl@0.3.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-AGuJdgKsmJdm4Pych7kv3sqe591ERRaAHW3xjLooiFzn8J+PxUyof++7YZrB5Y5tpnTO+K18Og3taj2NpluCRQ=="], + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ=="], - "@mariozechner/clipboard-linux-riscv64-gnu": ["@mariozechner/clipboard-linux-riscv64-gnu@0.3.9", "", { "os": "linux", "cpu": "none" }, "sha512-DXBEAiuMpk7dhS1a9NzNxVAFi1vaKoPu7rQNgY8LIDLGrK3lnIp3nT10DUum+PKVJoJppIP+NAA8IZe4DMNDPw=="], + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA=="], - "@mariozechner/clipboard-linux-x64-gnu": ["@mariozechner/clipboard-linux-x64-gnu@0.3.9", "", { "os": "linux", "cpu": "x64" }, "sha512-WORrMLd6EpElEME7JRKfSaY34nW1P5LbdgK5YNCS1ncG2LqmITsSMEJ8nh2mpvxb3TxqbOOKgY7k9eMJYlW9Mw=="], + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg=="], - "@mariozechner/clipboard-linux-x64-musl": ["@mariozechner/clipboard-linux-x64-musl@0.3.9", "", { "os": "linux", "cpu": "x64" }, "sha512-/DHn+1DrfL6oRaPPWXaOKvonFFrni666fxd+zFqiQEfvBH0tsHVWjq9iqBk0oDp0qaPA72lIMy5BptxISBEhZQ=="], + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.2", "", { "os": "linux", "cpu": "x64" }, "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ=="], - "@mariozechner/clipboard-win32-arm64-msvc": ["@mariozechner/clipboard-win32-arm64-msvc@0.3.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-O5FHD3ErkMwMhNzAfu3ggy0ug4z7btZuoQgwwxlzPrwV2bxlD6WDpqBY4NCgICAgZdDKdp+loUEKVAVt8aYnhQ=="], + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw=="], - "@mariozechner/clipboard-win32-x64-msvc": ["@mariozechner/clipboard-win32-x64-msvc@0.3.9", "", { "os": "win32", "cpu": "x64" }, "sha512-ihQC3EufqEY81vhXBgVBtK4prL+wc62zJsSvxrgz7K1hsdt6OObz6v9p3Rn1OG3GJksTTKMJF0u/guMISHPhSA=="], + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.2", "", { "os": "none", "cpu": "x64" }, "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw=="], - "@mistralai/mistralai": ["@mistralai/mistralai@2.2.6", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.40.0", "ws": "^8.18.0", "zod": "^3.25.0 || ^4.0.0", "zod-to-json-schema": "^3.25.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0" }, "optionalPeers": ["@opentelemetry/api"] }, "sha512-W8pX7zHxjJvMIpw8JMxeJEleapXX0Q9NPszdNzqkM3MIEoIGPObdodujj+WHteXEvGfaP/AMwlNyRfEzSY6dQQ=="], + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ=="], - "@nodable/entities": ["@nodable/entities@2.1.1", "", {}, "sha512-Pig3HxDIoMgjdEH8OCf/dkcTmLFjJRjWuq8jSnklu284/TKOPibSRERmOykiwmyXTtv61mP+44f3GMx0tLAyjg=="], + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw=="], - "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q=="], - "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="], + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g=="], + + "@google/genai": ["@google/genai@2.21.0", "", { "dependencies": { "google-auth-library": "^10.3.0", "p-retry": "^4.6.2", "protobufjs": "^7.5.4", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.25.2" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-+PDtco2/Z0ONdzCGekCoCT+O1VJS9xJQNN4XzQpXG/t3El/SWWMkCWlFRO1KmivOHPa4Q0VjUYu1HBKCZ/v33Q=="], "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], @@ -124,35 +142,29 @@ "@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="], - "@protobufjs/inquire": ["@protobufjs/inquire@1.1.2", "", {}, "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw=="], - "@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="], "@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="], - "@protobufjs/utf8": ["@protobufjs/utf8@1.1.1", "", {}, "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg=="], + "@protobufjs/utf8": ["@protobufjs/utf8@1.1.2", "", {}, "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug=="], "@silvia-odwyer/photon-node": ["@silvia-odwyer/photon-node@0.3.4", "", {}, "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA=="], - "@smithy/core": ["@smithy/core@3.24.6", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-wBXDRup6UU97VKyaiRo8AssnfStPtG0oAAfpq/bC0a1YYau8pM86YB4kM6ccoVi1mS8l/UHbn9oDM+7uozr/ug=="], - - "@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.3.7", "", { "dependencies": { "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-xj8gq/bjFABAh6qWPSDCYcY3kzQIm4b561C+YnHH4zGq8rOgzQ3Shk+JGlpUxSd41UGiO6FkLdUCtNX1FAeHgg=="], - - "@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.4.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-FEwEYJ1jlBKdhe9TPzfghEi1bP55ZeEImlDkEa62bBBYzUcnB6RUCyuiS2mqKt6ZVjUbBgcNhzfIctH+Hevx9g=="], + "@smithy/core": ["@smithy/core@3.35.1", "", { "dependencies": { "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-i4YPS4B6ts7bjn7UwLnGjiZdprOvHvgGobFZsYK3GIY3E5hIqtj0rReU69BcTpGp+fvtraSNXeG1l+jtJvF55w=="], - "@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], + "@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.5.2", "", { "dependencies": { "@smithy/core": "^3.33.2", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg=="], - "@smithy/node-http-handler": ["@smithy/node-http-handler@4.7.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA=="], + "@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.8.0", "", { "dependencies": { "@smithy/core": "^3.33.3", "@smithy/types": "^4.18.0", "tslib": "^2.6.2" } }, "sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA=="], - "@smithy/signature-v4": ["@smithy/signature-v4@5.4.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-Ojg4B6oIDlIr1R86xCDJt1zJWnYa0VINmqdjfe9qxWjdRivHalZ3iSlQgVqYbW0MdpFOC5XfHEWsnbmdnpIILQ=="], + "@smithy/node-http-handler": ["@smithy/node-http-handler@4.12.1", "", { "dependencies": { "@smithy/core": "^3.33.3", "@smithy/types": "^4.18.0", "tslib": "^2.6.2" } }, "sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw=="], - "@smithy/types": ["@smithy/types@4.14.3", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-YupL0ZWmFtJexUN2cHzkvvF/b9pKrtAIfT1o7/oY/Ppu8IYeZ+lDPM5vZdQJaSeA132dJCqojjGC9NhXeF71VQ=="], + "@smithy/signature-v4": ["@smithy/signature-v4@5.7.4", "", { "dependencies": { "@smithy/core": "^3.35.0", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-tHy0K0VtqNd5Y7Y41h0a0Lhh0L1GzC08dTWg0F7vRJWFtTENg7IZikf3wQkanYIRdb7ngoIPMTmqgUi401fEeQ=="], - "@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], + "@smithy/types": ["@smithy/types@4.19.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q=="], - "@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], + "@stablelib/base64": ["@stablelib/base64@1.0.1", "", {}, "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ=="], - "@types/node": ["@types/node@22.20.0", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g=="], + "@types/node": ["@types/node@22.20.5", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA=="], "@types/retry": ["@types/retry@0.12.0", "", {}, "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA=="], @@ -196,7 +208,7 @@ "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], - "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + "agent-base": ["agent-base@9.0.0", "", {}, "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA=="], "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], @@ -206,55 +218,55 @@ "bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="], - "brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="], + "brace-expansion": ["brace-expansion@5.0.12", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ=="], "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], - "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + "chalk": ["chalk@6.0.0", "", {}, "sha512-2uNTXIuTTxk7ciZgAU1BQcgnchcG0xXnrs6jzkQfj9SsRa9M2s5zE8WT96hS6KmG4MzWHSrvH43DF1m4XRkrFg=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], - "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="], "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], - "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], + "esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="], - "fast-xml-builder": ["fast-xml-builder@1.2.0", "", { "dependencies": { "path-expression-matcher": "^1.5.0", "xml-naming": "^0.1.0" } }, "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q=="], + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], - "fast-xml-parser": ["fast-xml-parser@5.7.3", "", { "dependencies": { "@nodable/entities": "^2.1.0", "fast-xml-builder": "^1.1.7", "path-expression-matcher": "^1.5.0", "strnum": "^2.2.3" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg=="], + "fast-sha256": ["fast-sha256@1.3.0", "", {}, "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ=="], "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="], "formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="], - "gaxios": ["gaxios@7.1.4", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2" } }, "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA=="], + "gaxios": ["gaxios@7.3.1", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2" } }, "sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ=="], "gcp-metadata": ["gcp-metadata@8.1.2", "", { "dependencies": { "gaxios": "^7.0.0", "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" } }, "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg=="], "get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="], - "glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], - - "google-auth-library": ["google-auth-library@10.6.2", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.1.4", "gcp-metadata": "8.1.2", "google-logging-utils": "1.1.3", "jws": "^4.0.0" } }, "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw=="], + "google-auth-library": ["google-auth-library@10.9.1", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.1.4", "gcp-metadata": "8.1.2", "google-logging-utils": "1.1.3", "jws": "^4.0.0" } }, "sha512-i1ydyHrqcIxXkWh/uBmVkzCvIuq5yiK2ATndIe5XxKholrG/MTYP9xGYka4sQhrbIAgGjL2B6NOE7rFaiF3fXw=="], "google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="], "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "grok-mermaid": ["grok-mermaid@0.2.3", "", {}, "sha512-/4KopAbsjvuRP9MdPtlDjOHUmUVEohOX73JNcsWpzAtFxh+bq5+Dhb6gzvRieLDwIPQIR3/vy8V1NNTuz4Zsmg=="], + "highlight.js": ["highlight.js@10.7.3", "", {}, "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A=="], "hosted-git-info": ["hosted-git-info@9.0.3", "", { "dependencies": { "lru-cache": "^11.1.0" } }, "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg=="], - "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], + "http-proxy-agent": ["http-proxy-agent@9.1.0", "", { "dependencies": { "agent-base": "9.0.0", "debug": "^4.3.4", "proxy-agent-negotiate": "1.1.0" } }, "sha512-2NxoveTT58mjYT4n3RPTEfCZGLMbidoO8XEieXfpSYxu+PQJ1qpx4ypwH6N+uF9twBPIvRRgvkvW5HUTYWENig=="], - "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + "https-proxy-agent": ["https-proxy-agent@9.1.0", "", { "dependencies": { "agent-base": "9.0.0", "debug": "^4.3.4", "proxy-agent-negotiate": "1.1.0" } }, "sha512-ag87y7cJJ9/3+GxFr8Oy4O5faDsGRGnBGsJj/YjOSsSx/5eadKLYTMPlzuR6obgoCDDm0abAAZitXXQkMOPSpA=="], - "ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + "ignore": ["ignore@7.0.8", "", {}, "sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q=="], "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], @@ -270,13 +282,11 @@ "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], - "lru-cache": ["lru-cache@11.5.1", "", {}, "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A=="], + "lru-cache": ["lru-cache@11.5.3", "", {}, "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg=="], - "marked": ["marked@18.0.5", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w=="], + "marked": ["marked@18.0.11", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-HnslJfsZkRPBDJRHvVtAaWlZHEpSu7u8LgQuJCELjRKuWR+hpq4A7sLq3p8HaI9ypVoXDXxV34CsQJEe1+J5Aw=="], - "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], - - "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + "minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], @@ -284,27 +294,27 @@ "node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], - "openai": ["openai@6.26.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA=="], + "openai": ["openai@7.19.0", "", { "peerDependencies": { "@aws-sdk/credential-provider-node": ">=3.972.0 <4", "@smithy/hash-node": ">=4.3.0 <5", "@smithy/signature-v4": ">=5.4.0 <6", "undici": ">=5 <9", "ws": "^8.21.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["@aws-sdk/credential-provider-node", "@smithy/hash-node", "@smithy/signature-v4", "undici", "ws", "zod"] }, "sha512-MX2s3u2L5racTO0CC/SWpCOasJQBCJrqLKXK+l82cAhdeF8mPMBEe/gxMm0ZFa2xpKpOFLRjxv5afYEZbBXmbQ=="], "p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="], "partial-json": ["partial-json@0.1.7", "", {}, "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA=="], - "path-expression-matcher": ["path-expression-matcher@1.5.0", "", {}, "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ=="], - "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], - "path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="], - "proper-lockfile": ["proper-lockfile@4.1.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="], - "protobufjs": ["protobufjs@7.6.2", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-N9EiLovGEQOJSPF26Ij7qUGvahfEnq0eeYZ02aigIedkmz1qZSwjnP9SBITHJuF/6MYbIW4HDN8zdYjsjqJKXQ=="], + "protobufjs": ["protobufjs@7.6.6", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg=="], + + "proxy-agent-negotiate": ["proxy-agent-negotiate@1.1.0", "", { "peerDependencies": { "kerberos": "^2.0.0" }, "optionalPeers": ["kerberos"] }, "sha512-N8IBcM3UgCVzz2L2Lqv8DVntDnnC8/hiV4nEDUPkqq72TPUgYWjQc+bdZlBPZK9LzPAvOY//gAt0S0DApoOXWQ=="], + + "quickjs-wasi": ["quickjs-wasi@3.6.2", "", {}, "sha512-FCqGtGOrMgzUiIrMNMA2YnsOxCNwo31dzqXvclXUC6xeT35NJLKXQJsvbeCTjvoFAwZgEAPg8U6+KAPDGXn8Mg=="], "retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="], "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], - "semver": ["semver@7.8.0", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="], + "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], @@ -312,17 +322,17 @@ "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], - "strnum": ["strnum@2.3.0", "", {}, "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q=="], + "standardwebhooks": ["standardwebhooks@1.1.1", "", { "dependencies": { "@stablelib/base64": "^1.0.0", "fast-sha256": "^1.3.0" } }, "sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ=="], "ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "typebox": ["typebox@1.3.6", "", {}, "sha512-Sc8RA0NCMEFmApHNU9ZMzqcpQj46She44J8ffpLM/bdhLNUZKq7DJumcLcsFx1gRmDfQPgCgOmFFJ7rcnfWNyA=="], + "typebox": ["typebox@1.3.36", "", {}, "sha512-bu2Ec2Ti9B4IFSFj1FcZbtLNB/W7igQwxocMGJLAG2NTv1nI+ImtIA34YgHW4CVyGVlJqNXR1GJyLmdWgV3DuA=="], "typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], - "undici": ["undici@8.5.0", "", {}, "sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg=="], + "undici": ["undici@8.10.2", "", {}, "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ=="], "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], @@ -330,30 +340,26 @@ "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], - "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], - - "xml-naming": ["xml-naming@0.1.0", "", {}, "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw=="], + "ws": ["ws@8.22.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg=="], "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], - "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + "@aws-sdk/credential-provider-sso/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1138.0", "", { "dependencies": { "@aws-sdk/core": "^3.978.1", "@aws-sdk/nested-clients": "^3.997.46", "@aws-sdk/types": "^3.974.6", "@smithy/core": "^3.35.0", "@smithy/types": "^4.19.0", "tslib": "^2.6.2" } }, "sha512-GpyAr0DD63YOEmYFM6Df+gJuIgC92MMTiBK4FTKfxii5MJ9ge20epR7LyroulscYlG89J+ZB2ivFDPjvfQhzdw=="], - "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], + "@earendil-works/pi-agent-core/typebox": ["typebox@1.3.27", "", {}, "sha512-zu+jc1pcy4UiNThxikUr36f0Rybk9PEeCg/NE6adeWr/SKsdNO4EzZHYRDlv2YCVAfj3Odq3dESSo/jNyoBXzA=="], - "@aws-sdk/credential-provider-http/@smithy/node-http-handler": ["@smithy/node-http-handler@4.7.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-3fya8i7GrJilQouk4cZJKdy5k8MWQBpjfXrRNaXDedH8r779tr0jcxyH3+yoTmsluc2+vF4S343yFbnvu8ExDQ=="], + "@earendil-works/pi-ai/typebox": ["typebox@1.3.27", "", {}, "sha512-zu+jc1pcy4UiNThxikUr36f0Rybk9PEeCg/NE6adeWr/SKsdNO4EzZHYRDlv2YCVAfj3Odq3dESSo/jNyoBXzA=="], - "@aws-sdk/credential-provider-sso/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1060.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.17", "@aws-sdk/nested-clients": "^3.997.15", "@aws-sdk/types": "^3.973.10", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-6NZaMKkFhpaNiwLpHi1sZaYjidL/lCJE6ME6NxwA8gv9vQna+Kr0j4OFwVoz6tANRWM3WbGz6jiPsGX/Vkjwow=="], + "@earendil-works/pi-coding-agent/typebox": ["typebox@1.3.27", "", {}, "sha512-zu+jc1pcy4UiNThxikUr36f0Rybk9PEeCg/NE6adeWr/SKsdNO4EzZHYRDlv2YCVAfj3Odq3dESSo/jNyoBXzA=="], - "@aws-sdk/nested-clients/@smithy/node-http-handler": ["@smithy/node-http-handler@4.7.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-3fya8i7GrJilQouk4cZJKdy5k8MWQBpjfXrRNaXDedH8r779tr0jcxyH3+yoTmsluc2+vF4S343yFbnvu8ExDQ=="], + "gaxios/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], - "@earendil-works/pi-agent-core/typebox": ["typebox@1.1.38", "", {}, "sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA=="], - - "@earendil-works/pi-ai/typebox": ["typebox@1.1.38", "", {}, "sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA=="], + "p-retry/retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="], - "@earendil-works/pi-coding-agent/typebox": ["typebox@1.1.38", "", {}, "sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA=="], + "protobufjs/@types/node": ["@types/node@26.6.4", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg=="], - "p-retry/retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="], + "gaxios/https-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], - "protobufjs/@types/node": ["@types/node@22.19.19", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew=="], + "protobufjs/@types/node/undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], } } diff --git a/tests/agent-runner-fixtures.ts b/tests/agent-runner-fixtures.ts index 7c493d1..ab18db6 100644 --- a/tests/agent-runner-fixtures.ts +++ b/tests/agent-runner-fixtures.ts @@ -269,6 +269,7 @@ export const TEST_TOOL: ToolInfo = { description: TEST_TOOL_DEFINITION.description, parameters: TEST_TOOL_DEFINITION.parameters, promptGuidelines: [], + exposure: "direct", sourceInfo: { path: "builtin:read", source: "builtin", scope: "temporary", origin: "top-level" }, }; @@ -287,6 +288,7 @@ export function createToolInfo( description, parameters: TEST_TOOL_PARAMETERS, promptGuidelines: [], + exposure: "direct", sourceInfo, }; } diff --git a/tests/agent-runner-replay.test.ts b/tests/agent-runner-replay.test.ts index 6c0e838..46c4eca 100644 --- a/tests/agent-runner-replay.test.ts +++ b/tests/agent-runner-replay.test.ts @@ -159,6 +159,7 @@ test("tool-free structured agents can replay without fingerprinting the workspac description: finalTool.description, parameters: finalTool.parameters, promptGuidelines: finalTool.promptGuidelines ?? [], + exposure: "direct", sourceInfo: { path: "", source: "sdk", scope: "temporary", origin: "top-level" }, }]; }, @@ -230,6 +231,7 @@ test("a structured agent that needed a schema re-prompt is still journaled", asy description: finalTool.description, parameters: finalTool.parameters, promptGuidelines: [], + exposure: "direct" as const, sourceInfo: { path: "", source: "sdk", scope: "temporary", origin: "top-level" } as const, }; // Like pi, the active tool set drives the effective system prompt. diff --git a/tests/agent-session-services.test.ts b/tests/agent-session-services.test.ts index e68c7ed..8b6d2be 100644 --- a/tests/agent-session-services.test.ts +++ b/tests/agent-session-services.test.ts @@ -179,7 +179,8 @@ test("production session services load skills, tools, and host runtime providers assert.equal(hostRegistry.getProviderAuthStatus("stored-only").source, "stored"); const model = hostRegistry.find("runtime-only", "runtime-only-model"); const storedModel = hostRegistry.find("stored-only", "stored-only-model"); - const oauthModel = hostRegistry.find("openai-codex", "gpt-5.4"); + // Any catalog model of the OAuth provider works; pinning one id breaks when pi retires it. + const oauthModel = hostRegistry.getAll().find((candidate) => candidate.provider === "openai-codex"); assert.ok(model); assert.ok(storedModel); assert.ok(oauthModel); diff --git a/tests/pi-compat.test.ts b/tests/pi-compat.test.ts index b7dc545..cf63970 100644 --- a/tests/pi-compat.test.ts +++ b/tests/pi-compat.test.ts @@ -6,9 +6,9 @@ import { assertSupportedPiVersion } from "../.pi/extensions/pi-workflow-engine/s test("extension enforces the Pi SDK version required by its built-ins", () => { assert.doesNotThrow(() => assertSupportedPiVersion(VERSION)); - assert.doesNotThrow(() => assertSupportedPiVersion("0.80.10")); - assert.doesNotThrow(() => assertSupportedPiVersion("v0.81.0-beta.1")); - assert.throws(() => assertSupportedPiVersion("0.80.9"), /requires pi 0\.80\.10 or newer; detected "0\.80\.9"/); + assert.doesNotThrow(() => assertSupportedPiVersion("1.0.0")); + assert.doesNotThrow(() => assertSupportedPiVersion("v1.1.0-beta.1")); + assert.throws(() => assertSupportedPiVersion("0.99.2"), /requires pi 1\.0\.0 or newer; detected "0\.99\.2"/); assert.throws(() => assertSupportedPiVersion("development"), /Update pi before loading this extension/); }); @@ -17,6 +17,6 @@ test("installation docs state the minimum host Pi version", async () => { readFile("README.md", "utf8"), readFile("USAGE.md", "utf8"), ]); - assert.match(readme, /Requires \*\*pi 0\.80\.10 or newer\*\*/); - assert.match(usage, /requires \*\*pi 0\.80\.10 or newer\*\*/i); + assert.match(readme, /Requires \*\*pi 1\.0\.0 or newer\*\*/); + assert.match(usage, /requires \*\*pi 1\.0\.0 or newer\*\*/i); }); From 464b8383474d56b3afda501013355f9ae03994e4 Mon Sep 17 00:00:00 2001 From: timbrinded <79199034+timbrinded@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:34:12 +0100 Subject: [PATCH 02/11] feat(dynamax): send the opt-in as a system prompt section Returning systemPrompt from before_agent_start forces pi 1.0 to replace the whole prompt head for the run. A section is diffed and appended as a patch instead. The reminder shrinks to the opt-in itself: the authoring rules it repeated already reach the model as the workflow tool's guidelines, and the one-off authoring prompt no longer repeats them a third time. --- .pi/extensions/pi-workflow-engine/index.ts | 10 +----- .../pi-workflow-engine/src/dynamax.ts | 35 ++++++++----------- USAGE.md | 2 +- tests/dynamax.test.ts | 33 ++++++++++------- tests/workflow-options.test.ts | 5 ++- tests/workflow-tool.test.ts | 7 ++-- 6 files changed, 44 insertions(+), 48 deletions(-) diff --git a/.pi/extensions/pi-workflow-engine/index.ts b/.pi/extensions/pi-workflow-engine/index.ts index a75b0e0..457b738 100644 --- a/.pi/extensions/pi-workflow-engine/index.ts +++ b/.pi/extensions/pi-workflow-engine/index.ts @@ -195,15 +195,7 @@ export function buildTemporaryWorkflowAuthorPrompt(brief: string): string { User brief: ${brief.trim()} -Use the workflow tool with a script argument, not a saved workflow name. -The script must start with export const meta = { ... } and default-export an async workflow function. -Use the injected Type object for schemas. Do not import anything or use dynamic import(). -Set profile to "small", "medium", or "big" on each agent() call; use explicit model/thinkingLevel only for an intentional override. -Always pass a plain string as the first api.agent() argument; build prompts with template strings before calling agent(). -If using \`isolation: "worktree"\`, remember api.agent() returns \`{ result, patch, changed }\`; read \`.result\` for the agent answer and \`.patch\` for the diff. -When the run is budgeted, guard expensive loops with \`while (api.budget.total && api.budget.remaining() > N) { ... }\`; api.agent() throws once the budget is spent. -Subagents receive no skills by default. When the brief asks for a skill or a stage clearly benefits from one, pass \`skills: ["skill-name"]\` on that agent call only. -${ADAPTIVE_WORKFLOW_GUIDANCE} +Use the workflow tool with a script argument, not a saved workflow name, and follow its guidelines for inline scripts. Do not edit files unless the user explicitly requested edits.`; } diff --git a/.pi/extensions/pi-workflow-engine/src/dynamax.ts b/.pi/extensions/pi-workflow-engine/src/dynamax.ts index 247edd8..b86b53f 100644 --- a/.pi/extensions/pi-workflow-engine/src/dynamax.ts +++ b/.pi/extensions/pi-workflow-engine/src/dynamax.ts @@ -68,20 +68,13 @@ Adaptive multi-pass workflows are optional. Use a simple single-pass fan-out whe - synthesize the first-pass and follow-up results together. Do not generate a second pass when the first pass is sufficient, and do not invent iteration, quorum, graph, reduction, or retry primitives for this pattern.`; -export const DYNAMAX_REMINDER = ` -## dynamax workflow opt-in +/** System prompt section pi wraps as ``; adding or dropping it appends a section patch instead of rewriting the prompt. */ +export const DYNAMAX_SECTION = "dynamax"; -The user has opted into dynamax multi-agent orchestration. The workflow tool is permitted for this task. You may either run an existing named workflow or author a new inline workflow script when that best serves the user's request. - -Inline workflow rules: -- Use the injected Type object for schemas, for example Type.Object({ ok: Type.Boolean() }); do not import typebox. -- Do not use import statements or dynamic import() in inline workflow scripts. -- Set profile to "small", "medium", or "big" on each agent() call so routing remains explicit; use model/thinkingLevel only for an intentional override. -- Subagents receive no skills by default. Add \`skills: ["skill-name"]\` per agent only when that stage should load that skill; grant the smallest useful set. -- Provide exactly one of workflow.name or workflow.script. - -${ADAPTIVE_WORKFLOW_GUIDANCE} -`; +/** Only the opt-in itself: the authoring rules already reach the model as the workflow tool's guidelines. */ +export const DYNAMAX_REMINDER = + "The user opted into dynamax multi-agent orchestration for this request, so the workflow tool is permitted. " + + "Run a registered workflow by name, or author an inline workflow script when none fits, following the workflow tool's guidelines."; export function createDynamaxState(): DynamaxState { return { sticky: false, oneShotPending: false, turnActive: false }; @@ -140,11 +133,16 @@ export function dynamaxStatusText(state: DynamaxState, theme: Theme): string { return mode ? `${label}${dot(theme)}${theme.fg("muted", mode)}` : label; } -export function appendDynamaxSystemReminder(systemPrompt: string, state: DynamaxState): string { - if (!state.sticky && !state.oneShotPending) return systemPrompt; +/** + * Add the opt-in section for this run and consume a pending one-shot. pi rebuilds `sections` for every + * run, so an inactive run simply leaves it out and pi records the removal as a patch. + */ +export function applyDynamaxPromptSection(sections: Record, state: DynamaxState): boolean { + if (!state.sticky && !state.oneShotPending) return false; state.oneShotPending = false; state.turnActive = true; - return `${systemPrompt}\n\n${DYNAMAX_REMINDER.trim()}`; + sections[DYNAMAX_SECTION] = DYNAMAX_REMINDER; + return true; } export function registerDynamax(pi: ExtensionAPI, shortcuts: DynamaxShortcuts, options: DynamaxRegistrationOptions): DynamaxHandle { @@ -238,10 +236,7 @@ export function registerDynamax(pi: ExtensionAPI, shortcuts: DynamaxShortcuts, o pi.on("before_agent_start", (event, ctx) => { const runtime = getDynamaxRuntime(runtimes, ctx); - const systemPrompt = appendDynamaxSystemReminder(event.systemPrompt, runtime.state); - if (systemPrompt === event.systemPrompt) return undefined; - updateDynamaxSurfaces(ctx, runtime); - return { systemPrompt }; + if (applyDynamaxPromptSection(event.systemPromptOptions.sections, runtime.state)) updateDynamaxSurfaces(ctx, runtime); }); pi.on("agent_end", (_event, ctx) => { diff --git a/USAGE.md b/USAGE.md index 1ae61f0..3478e71 100644 --- a/USAGE.md +++ b/USAGE.md @@ -146,7 +146,7 @@ Sticky mode and inspector shortcut: The workflow inspector and code-review results viewer are also registered as first-class shortcuts shown by `/hotkeys`. The defaults are `ctrl+shift+m` for the inspector and `ctrl+shift+r` for the latest review results. `ctrl+o` is intentionally not used because pi already uses it for tool-output expansion and tree filtering. -When only the literal `dynamax` token is used, the opt-in is one-shot: the next agent run receives the workflow permission reminder, stays visibly active for that run, then clears after the run ends. When `/workflow:dynamax on` is used, the opt-in is sticky for the current pi session until `/workflow:dynamax off`. The footer status states the mode compactly: `◆ dynamax` while sticky, `◆ dynamax · next prompt` while a one-shot waits for its prompt, and `◆ dynamax · this turn` while that run is active; off mode clears it. A running workflow names itself in its own status next to it. +When only the literal `dynamax` token is used, the opt-in is one-shot: the next agent run receives a `` system prompt section that permits the workflow tool (pi appends it as a section patch, so the cached prompt head is kept), stays visibly active for that run, then clears after the run ends. When `/workflow:dynamax on` is used, the opt-in is sticky for the current pi session until `/workflow:dynamax off`. The footer status states the mode compactly: `◆ dynamax` while sticky, `◆ dynamax · next prompt` while a one-shot waits for its prompt, and `◆ dynamax · this turn` while that run is active; off mode clears it. A running workflow names itself in its own status next to it. ### Prompt editor cue diff --git a/tests/dynamax.test.ts b/tests/dynamax.test.ts index 26b8f5e..2b0432a 100644 --- a/tests/dynamax.test.ts +++ b/tests/dynamax.test.ts @@ -24,11 +24,12 @@ import { } from "../.pi/extensions/pi-workflow-engine/src/dynamax-shortcuts.ts"; import { ADAPTIVE_WORKFLOW_GUIDANCE, - appendDynamaxSystemReminder, + applyDynamaxPromptSection, clearDynamax, createDynamaxRuntime, createDynamaxState, DYNAMAX_REMINDER, + DYNAMAX_SECTION, DYNAMAX_STATUS_KEY, type DynamaxRegistrationOptions, type DynamaxRuntimeStore, @@ -41,7 +42,6 @@ import { registerDynamax, updateDynamaxSurfaces, } from "../.pi/extensions/pi-workflow-engine/src/dynamax.ts"; -import { isRecord } from "../.pi/extensions/pi-workflow-engine/src/guards.ts"; import { sessionKey } from "../.pi/extensions/pi-workflow-engine/src/session-identity.ts"; import { decorateDynamaxEditor, @@ -527,15 +527,18 @@ test("Dynamax command completes native on, off, and status arguments", async () assert.deepEqual(completions?.map((item) => item.value), ["on", "off"]); }); -test("dynamax one-shot state is consumed by system reminder", () => { +test("dynamax one-shot state is consumed by the prompt section", () => { const state = createDynamaxState(); markDynamaxOneShot(state); - const prompted = appendDynamaxSystemReminder("base", state); + const sections: Record = {}; + assert.equal(applyDynamaxPromptSection(sections, state), true); - assert.match(prompted, /dynamax workflow opt-in/); + assert.equal(sections[DYNAMAX_SECTION], DYNAMAX_REMINDER); assert.equal(state.oneShotPending, false); - assert.equal(appendDynamaxSystemReminder("base", state), "base"); + const nextRun: Record = {}; + assert.equal(applyDynamaxPromptSection(nextRun, state), false); + assert.deepEqual(nextRun, {}); }); test("dynamax reminder teaches optional adaptive multi-pass workflows", () => { @@ -544,8 +547,9 @@ test("dynamax reminder teaches optional adaptive multi-pass workflows", () => { assert.match(ADAPTIVE_WORKFLOW_GUIDANCE, /ordinary TypeScript conditionals or bounded loops/); assert.match(ADAPTIVE_WORKFLOW_GUIDANCE, /only when gaps exist/); assert.match(ADAPTIVE_WORKFLOW_GUIDANCE, /Do not generate a second pass when the first pass is sufficient/); - assert.ok(DYNAMAX_REMINDER.includes(ADAPTIVE_WORKFLOW_GUIDANCE)); - assert.match(DYNAMAX_REMINDER, /profile to "small", "medium", or "big"/); + // The reminder carries only the opt-in; the workflow tool's guidelines carry the authoring rules. + assert.ok(!DYNAMAX_REMINDER.includes(ADAPTIVE_WORKFLOW_GUIDANCE)); + assert.match(DYNAMAX_REMINDER, /workflow tool is permitted/); }); test("dynamax sticky mode remains active until cleared", () => { @@ -553,7 +557,9 @@ test("dynamax sticky mode remains active until cleared", () => { enableDynamaxSticky(state); assert.equal(isDynamaxActive(state), true); - assert.match(appendDynamaxSystemReminder("base", state), /workflow tool is permitted/); + const sections: Record = {}; + applyDynamaxPromptSection(sections, state); + assert.match(sections[DYNAMAX_SECTION] ?? "", /workflow tool is permitted/); assert.equal(state.sticky, true); assert.equal(isDynamaxActive(state), true); @@ -650,7 +656,7 @@ test("dynamax status states only the mode: sticky, armed for the next prompt, or const turn = createDynamaxState(); markDynamaxOneShot(turn); - appendDynamaxSystemReminder("base", turn); + applyDynamaxPromptSection({}, turn); assert.equal(dynamaxStatusText(turn, theme), "◆ dynamax · this turn"); }); @@ -665,8 +671,11 @@ test("one-shot Dynamax status remains visible for the active turn", async () => await input({ source: "interactive", text: "dynamax inspect this branch" }, ctx); assert.equal(ui.statuses.get(DYNAMAX_STATUS_KEY), "◆ dynamax · next prompt"); - const result = await beforeAgentStart({ systemPrompt: "base" }, ctx); - assert.match(isRecord(result) && typeof result.systemPrompt === "string" ? result.systemPrompt : "", /dynamax workflow opt-in/); + const sections: Record = {}; + const result = await beforeAgentStart({ systemPrompt: "base", systemPromptOptions: { sections } }, ctx); + // A section patch keeps pi's cached prompt head; returning systemPrompt would force a full replacement. + assert.equal(result, undefined); + assert.equal(sections[DYNAMAX_SECTION], DYNAMAX_REMINDER); assert.equal(ui.statuses.get(DYNAMAX_STATUS_KEY), "◆ dynamax · this turn"); await agentEnd({ messages: [] }, ctx); diff --git a/tests/workflow-options.test.ts b/tests/workflow-options.test.ts index f36ce76..1cbb3b4 100644 --- a/tests/workflow-options.test.ts +++ b/tests/workflow-options.test.ts @@ -300,7 +300,6 @@ test("buildTemporaryWorkflowAuthorPrompt asks for an inline one-shot workflow", assert.match(prompt, /dynamax author and run a temporary one-shot inline workflow/); assert.match(prompt, /inspect src and summarize risks/); assert.match(prompt, /workflow tool with a script argument, not a saved workflow name/); - assert.match(prompt, /Use the injected Type object/); - assert.match(prompt, /Set profile to "small", "medium", or "big"/); - assert.match(prompt, /skills: \["skill-name"]/); + // The authoring rules live in the workflow tool's guidelines, which the model already sees. + assert.doesNotMatch(prompt, /injected Type object/); }); diff --git a/tests/workflow-tool.test.ts b/tests/workflow-tool.test.ts index d6de61c..ff285c7 100644 --- a/tests/workflow-tool.test.ts +++ b/tests/workflow-tool.test.ts @@ -239,15 +239,16 @@ test("RPC command and inspector surfaces use native selection and text instead o assert.match(notifications.at(-1) ?? "", /Workflow inspector: rpc-inspection/); }); -test("temporary authoring, tool, and documentation guidance teach adaptive follow-up", () => { +test("tool and documentation guidance teach adaptive follow-up; the authoring prompt defers to them", () => { const temporaryPrompt = buildTemporaryWorkflowAuthorPrompt("investigate the parser"); const toolGuidance = captureWorkflowTool().promptGuidelines?.join("\n"); assert.ok(toolGuidance, "expected the registered workflow tool to provide prompt guidelines"); const usage = readFileSync(new URL("../USAGE.md", import.meta.url), "utf8"); - assert.ok(temporaryPrompt.includes(ADAPTIVE_WORKFLOW_GUIDANCE)); + // The authoring prompt defers to the tool's guidelines instead of repeating them. + assert.ok(!temporaryPrompt.includes(ADAPTIVE_WORKFLOW_GUIDANCE)); assert.ok(toolGuidance.includes(ADAPTIVE_WORKFLOW_GUIDANCE)); - for (const text of [temporaryPrompt, toolGuidance, usage]) { + for (const text of [toolGuidance, usage]) { assert.match(text, /first.pass/i); assert.match(text, /structured gap.analysis/i); assert.match(text, /follow.up/i); From 681ca7734d7b58f3dd9e83b8f06ec7ccbadbb9a8 Mon Sep 17 00:00:00 2001 From: timbrinded <79199034+timbrinded@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:36:11 +0100 Subject: [PATCH 03/11] feat(tool): report subagent usage, flag failed calls, keep workflow model-only - Synchronous workflow tool results carry the run's subagent usage, which pi adds to the session cost and footer; that spend was missing there before. - Invocation, compile, unknown-workflow and background-start failures return isError: true, so the model sees a failed call while the renderer keeps details. - The tool registers with exposure model-only, like codemode itself: scripts cannot start workflows, and codemode.mode "only" no longer hides it. --- .pi/extensions/pi-workflow-engine/index.ts | 38 +++++++++++-------- .../src/background-workflow-tool.ts | 3 ++ .../pi-workflow-engine/src/usage.ts | 8 ++++ tests/usage.test.ts | 26 ++++++++++++- tests/workflow-extension-fixtures.ts | 1 + tests/workflow-tool.test.ts | 17 +++++++++ 6 files changed, 76 insertions(+), 17 deletions(-) diff --git a/.pi/extensions/pi-workflow-engine/index.ts b/.pi/extensions/pi-workflow-engine/index.ts index 457b738..763c3f8 100644 --- a/.pi/extensions/pi-workflow-engine/index.ts +++ b/.pi/extensions/pi-workflow-engine/index.ts @@ -40,6 +40,7 @@ import { registerWorkflowRunCommand, WorkflowRunController } from "./src/workflo import { completeCurrentArgument, splitArgumentPrefix } from "./src/command-completions.ts"; import { assertSupportedPiVersion } from "./src/pi-compat.ts"; import { formatWorkflowInspection, workflowInspectionSnapshot } from "./src/ui/workflow-format.ts"; +import { workflowUsageForPi } from "./src/usage.ts"; /** Extension root (this file lives in /.pi/extensions/pi-workflow-engine/index.ts). */ const EXTENSION_DIR = fileURLToPath(new URL(".", import.meta.url)); @@ -211,9 +212,17 @@ export type WorkflowToolRequest = | { readonly kind: "inline"; readonly script: string } | { readonly kind: "error"; readonly error: "invalid_workflow_invocation"; readonly message: string }; -export interface WorkflowToolErrorResult { +export interface WorkflowToolErrorResult { readonly content: Array<{ readonly type: "text"; readonly text: string }>; - readonly details: { readonly error: "invalid_workflow_invocation" } | { readonly error: "inline_compile_error"; readonly message: string }; + readonly details: TDetails; + /** The model sees a failed call while the renderer still reads `details`. */ + readonly isError: true; +} + +type WorkflowToolErrorDetails = { readonly error: "invalid_workflow_invocation" } | { readonly error: "inline_compile_error"; readonly message: string }; + +function workflowToolError(text: string, details: TDetails): WorkflowToolErrorResult { + return { content: [{ type: "text", text }], details, isError: true }; } const INVALID_WORKFLOW_INVOCATION_MESSAGE = "Provide exactly one workflow name or inline workflow script."; @@ -228,11 +237,11 @@ export function normalizeWorkflowToolRequest(params: WorkflowToolRequestParams): } export function invalidWorkflowInvocationResult(): WorkflowToolErrorResult { - return { content: [{ type: "text", text: INVALID_WORKFLOW_INVOCATION_MESSAGE }], details: { error: "invalid_workflow_invocation" } }; + return workflowToolError(INVALID_WORKFLOW_INVOCATION_MESSAGE, { error: "invalid_workflow_invocation" }); } export function inlineCompileErrorResult(message: string): WorkflowToolErrorResult { - return { content: [{ type: "text", text: `Inline workflow did not compile: ${message}` }], details: { error: "inline_compile_error", message } }; + return workflowToolError(`Inline workflow did not compile: ${message}`, { error: "inline_compile_error", message }); } export type WorkflowPickerSelection = @@ -477,6 +486,10 @@ function registerWorkflowTool( description: "ONLY call workflow when the user opted into multi-agent orchestration via the literal token `dynamax`, sticky `/workflow:dynamax on`, an explicit request to run or author a workflow, or a command/skill instruction. Runs either a registered named workflow or an inline one-off workflow script (fan-out → verify → synthesize), synchronously by default or explicitly in the background.", promptSnippet: "Run an existing named workflow or an inline one-off workflow script", + // Like codemode itself, an orchestrating tool stays a top-level model action: from a codemode script a + // run would lose its tool row, hit the script's output cap, and parallel runs would each get their own + // concurrency cap. It also stays declared under `codemode.mode: "only"`, which hides direct tools. + exposure: "model-only", promptGuidelines: [ "Use workflow only when the user opted into workflow orchestration via `dynamax`, `/workflow:dynamax on`, an explicit request to run/author a workflow, or a command/skill instruction.", "Use workflow with `name` for existing registered workflows such as code-review, diagnose, refactor-scout, or perf-review.", @@ -557,16 +570,10 @@ function registerWorkflowTool( if (request.kind === "error") return invalidWorkflowInvocationResult(); const resumeFromRunId = params.resumeFromRunId?.trim(); if (params.resumeFromRunId !== undefined && resumeFromRunId === "") { - return { - content: [{ type: "text", text: "resumeFromRunId must be non-empty." }], - details: { error: "invalid_resume_from_run_id" }, - }; + return workflowToolError("resumeFromRunId must be non-empty.", { error: "invalid_resume_from_run_id" }); } if (params.resumeEditedWorkflow && !resumeFromRunId) { - return { - content: [{ type: "text", text: "resumeEditedWorkflow requires resumeFromRunId." }], - details: { error: "invalid_edited_workflow_resume" }, - }; + return workflowToolError("resumeEditedWorkflow requires resumeFromRunId.", { error: "invalid_edited_workflow_resume" }); } if (params.background) { const unavailable = backgroundUnavailableResult(ctx.mode); @@ -598,10 +605,7 @@ function registerWorkflowTool( const named = workflows.get(request.name); if (!named) { const available = [...workflows.keys()].join(", ") || "(none)"; - return { - content: [{ type: "text", text: `Unknown workflow "${request.name}". Available: ${available}` }], - details: { error: "unknown_workflow", available }, - }; + return workflowToolError(`Unknown workflow "${request.name}". Available: ${available}`, { error: "unknown_workflow", available }); } mod = named; resultName = request.name; @@ -644,6 +648,8 @@ function registerWorkflowTool( return { content: [{ type: "text", text: formatWorkflowResultForContext(envelope) }], details: envelope, + // pi counts tool-result usage toward the session cost; subagent spend was invisible there before. + usage: workflowUsageForPi(envelope.usage), }; }, }); diff --git a/.pi/extensions/pi-workflow-engine/src/background-workflow-tool.ts b/.pi/extensions/pi-workflow-engine/src/background-workflow-tool.ts index 71c9270..645b0d4 100644 --- a/.pi/extensions/pi-workflow-engine/src/background-workflow-tool.ts +++ b/.pi/extensions/pi-workflow-engine/src/background-workflow-tool.ts @@ -7,6 +7,7 @@ import { unknownErrorMessage } from "./unknown-error.ts"; export interface BackgroundWorkflowToolResult { readonly content: Array<{ readonly type: "text"; readonly text: string }>; readonly details: Readonly>; + readonly isError?: true; } export interface BackgroundWorkflowToolStartInput { @@ -25,6 +26,7 @@ export function backgroundUnavailableResult(mode: ExtensionContext["mode"]): Bac text: `Background workflows are unavailable in ${mode} mode because pi exits after the prompt. Run synchronously or use TUI/RPC mode.`, }], details: { error: "background_unavailable", mode }, + isError: true, }; } @@ -63,6 +65,7 @@ export async function startBackgroundWorkflowTool( return { content: [{ type: "text", text: `Background workflow did not start: ${message}` }], details: { error: "background_start_failed", message, runId }, + isError: true, }; } return { diff --git a/.pi/extensions/pi-workflow-engine/src/usage.ts b/.pi/extensions/pi-workflow-engine/src/usage.ts index bf766a4..5c63128 100644 --- a/.pi/extensions/pi-workflow-engine/src/usage.ts +++ b/.pi/extensions/pi-workflow-engine/src/usage.ts @@ -1,3 +1,4 @@ +import type { Usage } from "@earendil-works/pi-ai"; import { isFiniteNumber, isRecord } from "./guards.ts"; import { formatCount } from "./text.ts"; @@ -127,6 +128,13 @@ export function hasWorkflowUsage(snapshot: unknown): snapshot is WorkflowUsageSn return snapshot.totals.totalTokens > 0 || snapshot.totals.cost.total > 0; } +/** The run's subagent totals as pi's `Usage`, so a tool result adds them to the session's cost. */ +export function workflowUsageForPi(snapshot: unknown): Usage | undefined { + if (!hasWorkflowUsage(snapshot)) return undefined; + const { input, output, cacheRead, cacheWrite, totalTokens, cost } = snapshot.totals; + return { input, output, cacheRead, cacheWrite, totalTokens, cost: { ...cost } }; +} + export function formatWorkflowUsageLine(snapshot: unknown): string | undefined { if (!hasWorkflowUsage(snapshot)) return undefined; const parts = formatWorkflowUsageComponents(snapshot.totals); diff --git a/tests/usage.test.ts b/tests/usage.test.ts index 57dc92f..64d8cae 100644 --- a/tests/usage.test.ts +++ b/tests/usage.test.ts @@ -1,6 +1,11 @@ import assert from "node:assert/strict"; import { test } from "bun:test"; -import { createWorkflowUsageRecorder, emptyWorkflowUsageTotals, formatWorkflowUsageLine } from "../.pi/extensions/pi-workflow-engine/src/usage.ts"; +import { + createWorkflowUsageRecorder, + emptyWorkflowUsageTotals, + formatWorkflowUsageLine, + workflowUsageForPi, +} from "../.pi/extensions/pi-workflow-engine/src/usage.ts"; import type { WorkflowUsageSnapshot } from "../.pi/extensions/pi-workflow-engine/src/usage.ts"; function assistantUsage(overrides: { @@ -272,3 +277,22 @@ test("formatWorkflowUsageLine suppresses true zero usage", () => { assert.equal(recorder.snapshot().assistantMessages, 1); assert.equal(formatWorkflowUsageLine(recorder.snapshot()), undefined); }); + +test("workflow usage converts to pi's Usage so the session cost includes subagent spend", () => { + const recorder = createWorkflowUsageRecorder(); + assert.equal(workflowUsageForPi(recorder.snapshot()), undefined); + + recorder.recordAgentSession({ + label: "finder", + messages: [assistantUsage({ input: 100, output: 20, cacheRead: 300, cacheWrite: 40, costInput: 0.1, costOutput: 0.2, costCacheRead: 0.03, costCacheWrite: 0.04, costTotal: 0.37 })], + }); + + assert.deepEqual(workflowUsageForPi(recorder.snapshot()), { + input: 100, + output: 20, + cacheRead: 300, + cacheWrite: 40, + totalTokens: 460, + cost: { input: 0.1, output: 0.2, cacheRead: 0.03, cacheWrite: 0.04, total: 0.37 }, + }); +}); diff --git a/tests/workflow-extension-fixtures.ts b/tests/workflow-extension-fixtures.ts index f514098..0c648d1 100644 --- a/tests/workflow-extension-fixtures.ts +++ b/tests/workflow-extension-fixtures.ts @@ -9,6 +9,7 @@ import { export interface CapturedTool { readonly name: string; + readonly exposure?: string; readonly promptGuidelines?: readonly string[]; execute( toolCallId: string, diff --git a/tests/workflow-tool.test.ts b/tests/workflow-tool.test.ts index ff285c7..43f558c 100644 --- a/tests/workflow-tool.test.ts +++ b/tests/workflow-tool.test.ts @@ -390,6 +390,20 @@ test("inline compile errors are shaped for workflow tool results", () => { if (result.details.error !== "inline_compile_error") throw new Error("expected inline compile details"); assert.match(result.details.message, /meta/); assert.match(result.content[0]?.text ?? "", /Inline workflow did not compile/); + assert.equal(result.isError, true); +}); + +test("the workflow tool is a model-only orchestrator that codemode scripts cannot start", () => { + assert.equal(captureWorkflowTool().exposure, "model-only"); +}); + +test("workflow tool marks an unknown workflow as a failed call", async () => { + const result = await captureWorkflowTool().execute("call-unknown", { name: "does-not-exist" }, undefined, () => {}, HEADLESS_CTX); + + assert.ok(isRecord(result)); + assert.equal(result.isError, true); + assert.ok(isRecord(result.details)); + assert.equal(result.details.error, "unknown_workflow"); }); test("workflow tool rejects blank resumeFromRunId", async () => { @@ -408,6 +422,7 @@ export default async function run() { assert.ok(Array.isArray(content)); assert.equal(content[0]?.text, "resumeFromRunId must be non-empty."); assert.deepEqual(result.details, { error: "invalid_resume_from_run_id" }); + assert.equal(result.isError, true); }); test("workflow tool requires a prior run for edited-source reuse", async () => { @@ -425,6 +440,7 @@ test("workflow tool requires a prior run for edited-source reuse", async () => { assert.ok(Array.isArray(content)); assert.equal(content[0]?.text, "resumeEditedWorkflow requires resumeFromRunId."); assert.deepEqual(result.details, { error: "invalid_edited_workflow_resume" }); + assert.equal(result.isError, true); }); test("workflow tool rejects background mode in finite print execution", async () => { @@ -440,6 +456,7 @@ test("workflow tool rejects background mode in finite print execution", async () assert.ok(isRecord(result)); assert.deepEqual(result.details, { error: "background_unavailable", mode: "print" }); + assert.equal(result.isError, true); }); test("workflow tool returns a durable background run id and detaches from the initiating tool signal", async () => { From 4b2e8e78c5a6c05ba256f5376077674e00bbef9f Mon Sep 17 00:00:00 2001 From: timbrinded <79199034+timbrinded@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:43:17 +0100 Subject: [PATCH 04/11] feat(agents): codemode and host MCP tools for subagents Subagents are SDK sessions, which load none of pi's built-in extensions. - Agents that allowlist "codemode" get the codemode extension (mode on), so a stage can batch and filter tool calls in one script. final_answer is now model-only, so a script cannot answer for the agent. - In synchronous workflow tool runs, subagents reach host MCP tools through ctx.executeTool over the host's connections and permission handlers: tools named in the allowlist, or open-world research tools for the external-search hint. The matcher no longer rejects MCP tools for their built-in source and honours MCP readOnly/openWorld annotations. Runs without a bridge log the MCP tools they could not reach. - Tool-result usage inside a subagent (codemode classifier calls) now counts toward the run's usage and budget. --- .pi/extensions/pi-workflow-engine/index.ts | 7 +- .../src/agent-runner-types.ts | 3 + .../pi-workflow-engine/src/agent-session.ts | 48 +++++++- .../pi-workflow-engine/src/engine.ts | 1 + .../pi-workflow-engine/src/host-tools.ts | 57 +++++++++ .../src/tool-capabilities.ts | 10 +- .../pi-workflow-engine/src/types.ts | 3 + .../pi-workflow-engine/src/usage.ts | 14 ++- USAGE.md | 5 + tests/agent-runner-workspace-tools.test.ts | 108 ++++++++++++++++++ tests/agent-session-services.test.ts | 15 +++ tests/host-tools.test.ts | 25 ++++ tests/usage.test.ts | 19 +++ 13 files changed, 310 insertions(+), 5 deletions(-) create mode 100644 .pi/extensions/pi-workflow-engine/src/host-tools.ts create mode 100644 tests/host-tools.test.ts diff --git a/.pi/extensions/pi-workflow-engine/index.ts b/.pi/extensions/pi-workflow-engine/index.ts index 763c3f8..a554557 100644 --- a/.pi/extensions/pi-workflow-engine/index.ts +++ b/.pi/extensions/pi-workflow-engine/index.ts @@ -41,6 +41,7 @@ import { completeCurrentArgument, splitArgumentPrefix } from "./src/command-comp import { assertSupportedPiVersion } from "./src/pi-compat.ts"; import { formatWorkflowInspection, workflowInspectionSnapshot } from "./src/ui/workflow-format.ts"; import { workflowUsageForPi } from "./src/usage.ts"; +import { createHostToolBridge } from "./src/host-tools.ts"; /** Extension root (this file lives in /.pi/extensions/pi-workflow-engine/index.ts). */ const EXTENSION_DIR = fileURLToPath(new URL(".", import.meta.url)); @@ -500,6 +501,8 @@ function registerWorkflowTool( "Always pass a plain string as the first `api.agent()` argument; build prompts with template strings before calling agent().", "When using `isolation: \"worktree\"`, `api.agent()` returns `{ result, patch, changed }`; use `.result` for the answer and `.patch` for the isolated diff.", "If an inline subagent needs grep/find/code-search helpers, use `tools: [\"read\", \"bash\", \"grep\", \"find\", \"ls\"]` plus `toolHints: [\"search\"]` so installed tools such as ast-grep, mgrep, ffgrep, or fffind are discovered dynamically.", + "Add \"codemode\" to an inline agent's `tools` when it should batch or filter many tool calls in one script; its scripts can call only that agent's other allowed tools.", + "Host MCP tools reach subagents only in synchronous workflow tool runs: name them in `tools` (e.g. `mcp__server__tool`), or use `toolHints: [\"external-search\"]` to pick up MCP web research tools.", "`api.budget` exposes `{ total, spent(), remaining() }` (output tokens). When the run is budgeted, scale fleets from `budget.total` and guard loops with `while (budget.total && budget.remaining() > N) { await api.agent(...) }`; `api.agent()` throws once the ceiling is reached.", ADAPTIVE_WORKFLOW_GUIDANCE, "Set background: true only when the user explicitly wants the workflow to continue after this tool call; the tool returns a durable run ID and completion is delivered later.", @@ -643,7 +646,9 @@ function registerWorkflowTool( } // Marks the tool row as running (rendered `● running `) until the envelope replaces it. onUpdate?.({ content: [{ type: "text", text: `Running workflow ${resultName}.` }], details: { state: "running", name: resultName } }); - const envelope = await executeResolvedWorkflow(pi, ctx, resultName, mod, resultArgs, runOptions, perfRecorder); + // Only this synchronous path may bridge host MCP tools: executeTool ends with the tool call. + const hostTools = createHostToolBridge(() => pi.getAllTools(), ctx); + const envelope = await executeResolvedWorkflow(pi, ctx, resultName, mod, resultArgs, { ...runOptions, hostTools }, perfRecorder); reviewSessions.remember(ctx, envelope, runOptions); return { content: [{ type: "text", text: formatWorkflowResultForContext(envelope) }], diff --git a/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts b/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts index c741aa5..dcc1393 100644 --- a/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +++ b/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts @@ -8,6 +8,7 @@ import type { WorkflowBudget } from "./budget.ts"; import type { Semaphore } from "./concurrency.ts"; import type { WorkflowAgentLimiter } from "./agent-limits.ts"; import type { AgentRetryScheduler } from "./agent-retry.ts"; +import type { HostToolBridge } from "./host-tools.ts"; import type { ResolvedWorkflowModelProfiles } from "./model-profiles.ts"; import type { WorkflowJournal } from "./journal.ts"; import type { PerfSink } from "./perf.ts"; @@ -63,6 +64,8 @@ interface RunContextBase { budget: WorkflowBudget; journal: WorkflowJournal; worktrees: WorktreeRegistry; + /** Host MCP tools subagents may call; set only for a synchronous `workflow` tool run. */ + hostTools?: HostToolBridge; } /** Shared per-run context threaded into every agent() call. */ diff --git a/.pi/extensions/pi-workflow-engine/src/agent-session.ts b/.pi/extensions/pi-workflow-engine/src/agent-session.ts index b7c4250..cb2ccb4 100644 --- a/.pi/extensions/pi-workflow-engine/src/agent-session.ts +++ b/.pi/extensions/pi-workflow-engine/src/agent-session.ts @@ -2,9 +2,11 @@ import type { Api, AssistantMessage, Model } from "@earendil-works/pi-ai"; import { createAgentSessionFromServices, createAgentSessionServices, + createCodemodeExtension, defineTool, SessionManager, type CreateAgentSessionOptions, + type CreateAgentSessionServicesOptions, type ModelRegistry, type Skill, type ToolDefinition, @@ -23,11 +25,14 @@ import { } from "./structured-output.ts"; import { providerErrorFromMessages } from "./agent-retry.ts"; import { synchronizeWorkflowModelRuntime } from "./agent-session-providers.ts"; +import { hostToolProxy, isMcpToolName, type HostToolBridge } from "./host-tools.ts"; import { matchesAgentToolHint, WorkflowToolHintUnavailableError } from "./tool-capabilities.ts"; import { truncateText } from "./text.ts"; import { WORKFLOW_TOOL_NAME, type AgentToolHint } from "./types.ts"; export const FINAL_TOOL = "final_answer"; +/** pi's codemode tool; SDK sessions only have it when their resource loader registers the extension. */ +export const CODEMODE_TOOL = "codemode"; const SCHEMA_REPROMPT = `You ended your turn without calling the ${FINAL_TOOL} tool, so no result was recorded. ` + @@ -76,6 +81,8 @@ export async function openAgentSession(input: { description: "Return your final structured answer. This MUST be your last action — do not write a normal reply after calling it.", parameters: opts.schema, + // The agent's own terminal action: a codemode script must not answer on its behalf. + exposure: "model-only", async execute(_toolCallId, params) { captured = true; structuredResult = params; @@ -84,6 +91,7 @@ export async function openAgentSession(input: { }), ] : []; + customTools.push(...requestedHostTools(opts, rc.hostTools, (message) => rc.progress.log(`${label}: ${message}`))); let session: AgentRunnerSession | undefined; try { throwIfAborted(rc.signal); @@ -262,7 +270,7 @@ async function prepareAgentSessionResources(input: { const preparedSkills = prepareAgentSkillResources(skillOptions); const services = await createAgentSessionServices({ cwd, - resourceLoaderOptions: preparedSkills.resourceLoaderOptions, + resourceLoaderOptions: { ...preparedSkills.resourceLoaderOptions, ...codemodeExtension(opts) }, }); await synchronizeWorkflowModelRuntime({ host: rc.modelRegistry, @@ -289,6 +297,44 @@ async function prepareAgentSessionResources(input: { }; } +/** + * SDK sessions load none of pi's built-in extensions, so codemode is registered only for agents whose + * allowlist names it. Scripts can call just the agent's other allowed tools. Mode "on" keeps those + * tools declared whatever the user's `codemode.mode`, so the author's allowlist stays what the model sees. + */ +function codemodeExtension(opts: AgentExecutionOptions): Pick { + if (!opts.tools?.includes(CODEMODE_TOOL)) return {}; + return { extensionFactories: [{ name: CODEMODE_TOOL, factory: createCodemodeExtension({ mode: "on" }), replaceable: true }] }; +} + +type SessionResourceLoaderOptions = NonNullable; + +/** + * Proxies for the host MCP tools this agent asked for: those named in its allowlist, and open-world + * research tools when it hints `external-search`. Local `search` hints never bridge, because host tools + * run in the host session's working directory, not the agent's (an isolated worktree, for example). + */ +function requestedHostTools( + opts: AgentExecutionOptions, + bridge: HostToolBridge | undefined, + log: (message: string) => void, +): ToolDefinition[] { + const named = new Set((opts.tools ?? []).filter(isMcpToolName)); + const externalSearch = opts.toolHints?.includes("external-search") ?? false; + if (named.size === 0 && !externalSearch) return []; + const available = bridge?.tools() ?? []; + const selected = available.filter((tool) => named.has(tool.name) || (externalSearch && matchesAgentToolHint(tool, "external-search"))); + const missing = [...named].filter((name) => !selected.some((tool) => tool.name === name)); + if (missing.length > 0) { + log( + bridge + ? `host MCP tool${missing.length === 1 ? "" : "s"} not available: ${missing.join(", ")}` + : `MCP tools reach subagents only in runs started by the workflow tool; unavailable: ${missing.join(", ")}`, + ); + } + return bridge ? selected.map((tool) => hostToolProxy(tool, bridge)) : []; +} + function linkSessionAbort(signal: AbortSignal | undefined, session: AgentRunnerSession): () => void { if (!signal) return () => {}; const onAbort = () => { diff --git a/.pi/extensions/pi-workflow-engine/src/engine.ts b/.pi/extensions/pi-workflow-engine/src/engine.ts index 3c8c0b5..1f909f8 100644 --- a/.pi/extensions/pi-workflow-engine/src/engine.ts +++ b/.pi/extensions/pi-workflow-engine/src/engine.ts @@ -177,6 +177,7 @@ export async function runResolvedWorkflow( budget, journal, worktrees, + hostTools: resolvedOptions.hostTools, }; // perf.total_ms wraps the whole tree: sub-workflows run inside this span via api.workflow(). diff --git a/.pi/extensions/pi-workflow-engine/src/host-tools.ts b/.pi/extensions/pi-workflow-engine/src/host-tools.ts new file mode 100644 index 0000000..802a1e9 --- /dev/null +++ b/.pi/extensions/pi-workflow-engine/src/host-tools.ts @@ -0,0 +1,57 @@ +import type { AgentToolCallOutcome } from "@earendil-works/pi-agent-core"; +import { defineTool, type ExtensionToolContext, type ToolDefinition, type ToolInfo } from "@earendil-works/pi-coding-agent"; + +/** pi names MCP tools `mcp____`. */ +const MCP_TOOL_PREFIX = "mcp__"; + +export function isMcpToolName(name: string): boolean { + return name.startsWith(MCP_TOOL_PREFIX); +} + +/** + * Host-only tools a subagent can reach through the host session. Subagents are SDK sessions, which + * load none of pi's built-in extensions, so they connect no MCP servers of their own; proxies call the + * host's tools over its existing connections and through its `tool_call` permission handlers. + */ +export interface HostToolBridge { + /** Callable host MCP tools, read as each agent starts so servers that connected mid-run are included. */ + tools(): readonly ToolInfo[]; + execute(name: string, args: unknown, signal: AbortSignal | undefined): Promise; +} + +/** + * `executeTool` exists only while the host's tool call runs, so only a synchronous `workflow` tool run + * gets a bridge; `/workflow` commands and background runs that outlive the call do not. + */ +export function createHostToolBridge( + registeredTools: () => readonly ToolInfo[], + ctx: Pick, +): HostToolBridge { + return { + tools() { + const callable = new Set(ctx.tools.map((tool) => tool.name)); + return registeredTools().filter((tool) => isMcpToolName(tool.name) && callable.has(tool.name)); + }, + execute: (name, args, signal) => ctx.executeTool(name, args, { signal }), + }; +} + +/** + * A subagent tool that runs the host tool. It reports no usage: pi already adds nested calls' usage to + * the host's `workflow` tool result. + */ +export function hostToolProxy(tool: ToolInfo, bridge: HostToolBridge): ToolDefinition { + return defineTool({ + name: tool.name, + label: tool.name, + description: tool.description, + parameters: tool.parameters, + promptGuidelines: tool.promptGuidelines, + namespace: tool.namespace, + annotations: tool.annotations, + async execute(_toolCallId, params, signal) { + const outcome = await bridge.execute(tool.name, params, signal); + return { content: outcome.result.content, details: undefined, ...(outcome.isError ? { isError: true } : {}) }; + }, + }); +} diff --git a/.pi/extensions/pi-workflow-engine/src/tool-capabilities.ts b/.pi/extensions/pi-workflow-engine/src/tool-capabilities.ts index 286d90f..c7c495e 100644 --- a/.pi/extensions/pi-workflow-engine/src/tool-capabilities.ts +++ b/.pi/extensions/pi-workflow-engine/src/tool-capabilities.ts @@ -1,4 +1,5 @@ import type { ToolInfo } from "@earendil-works/pi-coding-agent"; +import { isMcpToolName } from "./host-tools.ts"; import type { AgentToolHint } from "./types.ts"; /** Raised before prompting when required semantic tool capabilities are unavailable. */ @@ -18,7 +19,9 @@ export function matchesAgentToolHint(tool: ToolInfo, hint: AgentToolHint): boole } function isExternalSearchLikeTool(tool: ToolInfo): boolean { - if (isMutationLikeToolName(tool.name) || tool.sourceInfo.source === "builtin") return false; + if (isMutationLikeToolName(tool.name) || isPiLocalTool(tool)) return false; + // MCP servers declare these hints: a tool that changes its environment or stays in a closed domain is not web research. + if (tool.annotations?.readOnlyHint === false || tool.annotations?.openWorldHint === false) return false; const name = tool.name.toLowerCase(); const description = [tool.description, ...(tool.promptGuidelines ?? [])].join(" ").toLowerCase(); const text = `${name} ${description}`; @@ -40,6 +43,11 @@ function isSearchLikeTool(tool: ToolInfo): boolean { return /\b(?:grep|find|search|ripgrep|rg|structural search|code search)\b/.test(tool.description.toLowerCase()); } +/** pi's own tools, such as read and bash. MCP tools also come from a built-in extension but reach outside it. */ +function isPiLocalTool(tool: ToolInfo): boolean { + return tool.sourceInfo.source === "builtin" && !isMcpToolName(tool.name); +} + function isMutationLikeToolName(name: string): boolean { return /(?:edit|write|replace|patch|apply|delete|remove|move|rename|create|commit|push)/.test(name.toLowerCase()); } diff --git a/.pi/extensions/pi-workflow-engine/src/types.ts b/.pi/extensions/pi-workflow-engine/src/types.ts index 47b04b5..2f84b20 100644 --- a/.pi/extensions/pi-workflow-engine/src/types.ts +++ b/.pi/extensions/pi-workflow-engine/src/types.ts @@ -10,6 +10,7 @@ import type { WorkflowLaneItemStatus, WorkflowProgressSnapshot } from "./progres import type { WorkflowUsageSnapshot } from "./usage.ts"; import type { WorktreeBaseline } from "./worktree.ts"; import type { WorkflowModelProfileName } from "./model-profiles.ts"; +import type { HostToolBridge } from "./host-tools.ts"; import type { WorkflowRunState } from "./workflow-run-record.ts"; export type { WorkflowLaneItemStatus } from "./progress-types.ts"; @@ -79,6 +80,8 @@ export interface WorkflowRunOptions { signal?: AbortSignal; /** Internal recorder override for command/tool invocation timing. */ perfRecorder?: PerfSink; + /** Internal bridge to the host session's MCP tools, available while a `workflow` tool call runs. */ + hostTools?: HostToolBridge; /** Resolve a sub-workflow reference to a module, enabling `api.workflow()`. When omitted, `api.workflow()` throws. */ resolveWorkflow?: (ref: WorkflowRef) => Promise; /** Called with the final performance snapshot when perf is enabled. */ diff --git a/.pi/extensions/pi-workflow-engine/src/usage.ts b/.pi/extensions/pi-workflow-engine/src/usage.ts index 5c63128..0b4b6d7 100644 --- a/.pi/extensions/pi-workflow-engine/src/usage.ts +++ b/.pi/extensions/pi-workflow-engine/src/usage.ts @@ -82,9 +82,14 @@ export class WorkflowUsageRecorder implements WorkflowUsageSink { const parsed = parseAssistantUsageMessage(message); return parsed ? [parsed] : []; }); - if (assistantMessages.length === 0) return; + // Tools that call models themselves, such as codemode running a classifier, report it on their result. + const toolUsage = input.messages.flatMap((message) => { + const parsed = parseToolResultUsage(message); + return parsed ? [parsed] : []; + }); + if (assistantMessages.length === 0 && toolUsage.length === 0) return; - const usage = sumTotals(assistantMessages.map((message) => message.usage)); + const usage = sumTotals([...assistantMessages.map((message) => message.usage), ...toolUsage]); const latestMetadata = assistantMessages.findLast((message) => message.provider !== undefined || message.model !== undefined); this.agents.push({ label: input.label, @@ -225,6 +230,11 @@ function parseAssistantUsageMessage(message: unknown): AssistantUsageMessage | u }; } +function parseToolResultUsage(message: unknown): WorkflowUsageTotals | undefined { + if (!isRecord(message) || message.role !== "toolResult") return undefined; + return parseUsageTotals(message.usage); +} + function parseUsageTotals(value: unknown): WorkflowUsageTotals | undefined { if (!isRecord(value)) return undefined; const input = finiteNumber(value.input); diff --git a/USAGE.md b/USAGE.md index 3478e71..f0dbd3b 100644 --- a/USAGE.md +++ b/USAGE.md @@ -423,6 +423,11 @@ overrides its profile. `tools` is a strict allowlist. If you set `tools: ["read", "bash"]`, extension tools such as `ast-grep`, `mgrep`, `fffind`, or `ffgrep` are hidden from that subagent. Add `toolHints: ["search"]` to dynamically expose installed grep/find/search-like tools while keeping the concrete base allowlist portable. Use `toolHints: ["external-search"]` for installed web search, browsing, and URL-extraction tools; `requireToolHints: true` fails before prompting unless each requested capability matched. The built-in advisory workflows use `tools: ["read", "bash", "grep", "find", "ls"]` plus `toolHints: ["search"]`. +Subagents are SDK sessions, so pi's built-in codemode and MCP extensions do not load in them. Two opt-ins cover that: + +- **Codemode.** Add `"codemode"` to an agent's `tools` to let it batch or filter many tool calls in one script, for example `tools: ["read", "grep", "codemode"]`. Scripts can call only that agent's other allowed tools, and never `final_answer`. Classifier calls a script makes count toward the run's usage and budget. +- **Host MCP tools.** In runs started by the `workflow` tool, subagents reach the host's MCP tools over its existing connections and through its permission handlers. Name a tool in `tools` (for example `"mcp__linear__create_issue"`), or use `toolHints: ["external-search"]` to pick up MCP web research tools; servers that declare a tool not read-only or closed-world are excluded from that hint. Host tools run in the host's working directory, so the local `search` hint never bridges them. `/workflow` commands and background runs outlive the host tool call that makes this possible, so their agents log the MCP tools they could not reach. + Subagents receive no skills by default. Opt in per agent with `skills: ["skill-name"]`; if `tools` is also restricted, the engine automatically keeps `read` available so the subagent can load the selected `SKILL.md`. When `skills` is omitted, clear prompt text such as `/skill:name`, `include skill name`, or `use the name skill` is also treated as an opt-in. Pass `skills: []` to suppress that inference. Set `model` only when a subagent should use a specific model. Bare ids keep the Anthropic shorthand; `provider/id` targets built-in, custom, or local providers. Explicit models override a requested profile; calls with neither inherit the host/session default. Malformed or unknown explicit refs fail fast. diff --git a/tests/agent-runner-workspace-tools.test.ts b/tests/agent-runner-workspace-tools.test.ts index 49261d4..81c55b2 100644 --- a/tests/agent-runner-workspace-tools.test.ts +++ b/tests/agent-runner-workspace-tools.test.ts @@ -4,6 +4,9 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "bun:test"; import { Type } from "typebox"; +import type { ExtensionToolContext, ToolInfo } from "@earendil-works/pi-coding-agent"; +import type { HostToolBridge } from "../.pi/extensions/pi-workflow-engine/src/host-tools.ts"; +import { isRecord } from "../.pi/extensions/pi-workflow-engine/src/guards.ts"; import type { CreateAgentSession } from "../.pi/extensions/pi-workflow-engine/src/agent-runner.ts"; import { WorkflowBudgetExceededError, @@ -536,3 +539,108 @@ test("required tool hints fail before prompting when no installed capability mat assert.equal(prompted, false); assert.equal(disposed, true); }); + +type ToolDefinitionLike = NonNullable[0]["customTools"]>[number]; +// Proxies never read their context: they forward to the host bridge. +const HOST_TOOL_TEST_CTX = {} as ExtensionToolContext; + +const MCP_SOURCE = { path: "builtin:mcp", source: "builtin", scope: "temporary", origin: "top-level" } as const; + +test("MCP tools match external-search despite their built-in source unless their annotations rule them out", () => { + const webSearch = createToolInfo("mcp__parallel__web_search", "Search the web and return page URLs", MCP_SOURCE); + assert.equal(matchesAgentToolHint(webSearch, "external-search"), true); + assert.equal(matchesAgentToolHint({ ...webSearch, annotations: { readOnlyHint: false } }, "external-search"), false); + assert.equal(matchesAgentToolHint({ ...webSearch, annotations: { openWorldHint: false } }, "external-search"), false); + assert.equal( + matchesAgentToolHint({ ...createToolInfo("web_fetch", "Fetch a web page", { ...MCP_SOURCE, path: "builtin:web_fetch" }) }, "external-search"), + false, + ); +}); + +function fakeHostTools(tools: readonly ToolInfo[]): HostToolBridge & { readonly calls: Array<{ name: string; args: unknown }> } { + const calls: Array<{ name: string; args: unknown }> = []; + return { + calls, + tools: () => tools, + async execute(name, args) { + calls.push({ name, args }); + return { + toolCall: { type: "toolCall", id: `host/${calls.length}`, name, arguments: {} }, + result: { content: [{ type: "text", text: `host ${name}` }], details: undefined }, + isError: name.endsWith("broken"), + }; + }, + }; +} + +test("external-search agents reach host MCP research tools through the host bridge, never local or mutating ones", async () => { + const bridge = fakeHostTools([ + createToolInfo("mcp__parallel__web_search", "Search the web and return page URLs", MCP_SOURCE), + createToolInfo("mcp__fff__grep", "Search local files with grep", MCP_SOURCE), + { ...createToolInfo("mcp__linear__search_issues", "Search the web of Linear issues", MCP_SOURCE), annotations: { readOnlyHint: false } }, + ]); + let customToolNames: string[] = []; + let activatedTools: readonly string[] = []; + let proxyResult: unknown; + const createSession: CreateAgentSession = async (options) => { + const customTools = options.customTools ?? []; + customToolNames = customTools.map((tool) => tool.name); + return { + session: createAgentRunnerSession({ + messages: [assistantTextMessage("")], + async prompt() { + const proxy = customTools.find((tool) => tool.name === "mcp__parallel__web_search"); + proxyResult = await proxy?.execute("call-1", { query: "pi codemode" }, undefined, undefined, HOST_TOOL_TEST_CTX); + await executeTestFinalAnswer(options, { ok: true }); + }, + subscribe() { + return () => {}; + }, + dispose() {}, + async abort() {}, + getAllTools() { + return [createToolInfo("read", "Read local files"), ...customTools.map((tool) => createToolInfo(tool.name, tool.description, { path: ``, source: "sdk", scope: "temporary", origin: "top-level" }))]; + }, + setActiveToolsByName(toolNames) { + activatedTools = toolNames; + }, + }), + }; + }; + + const result = await runAgent({ ...createRunContext({ createSession }), hostTools: bridge }, "research", { + tools: [], + toolHints: ["external-search"], + requireToolHints: true, + schema: Type.Object({ ok: Type.Boolean() }), + }); + + assert.deepEqual(result, { ok: true }); + assert.deepEqual(customToolNames, ["final_answer", "mcp__parallel__web_search"]); + assert.ok(activatedTools.includes("mcp__parallel__web_search")); + assert.deepEqual(bridge.calls, [{ name: "mcp__parallel__web_search", args: { query: "pi codemode" } }]); + assert.deepEqual(proxyResult, { content: [{ type: "text", text: "host mcp__parallel__web_search" }], details: undefined }); +}); + +test("an allowlisted MCP tool is bridged, its host failures stay failures, and runs without a bridge say why it is missing", async () => { + const bridge = fakeHostTools([createToolInfo("mcp__linear__broken", "Create a Linear issue", MCP_SOURCE)]); + let proxy: ToolDefinitionLike | undefined; + const createSession: CreateAgentSession = async (options) => { + proxy = options.customTools?.find((tool) => tool.name === "mcp__linear__broken"); + return createTextSession(); + }; + await runAgent({ ...createRunContext({ createSession }), hostTools: bridge }, "file it", { tools: ["read", "mcp__linear__broken"] }); + const failed = await proxy?.execute("call-1", {}, undefined, undefined, HOST_TOOL_TEST_CTX); + assert.ok(isRecord(failed)); + assert.equal(failed.isError, true); + + const progress = createProgress(); + let proxiedWithoutBridge: readonly string[] = []; + const unbridgedSession: CreateAgentSession = async (options) => { + proxiedWithoutBridge = (options.customTools ?? []).map((tool) => tool.name); + return createTextSession(); + }; + await runAgent(createRunContext({ createSession: unbridgedSession, progress }), "file it", { label: "filer", tools: ["read", "mcp__linear__broken"] }); + assert.deepEqual(proxiedWithoutBridge, []); + assert.ok(progress.events.some((event) => /MCP tools reach subagents only in runs started by the workflow tool; unavailable: mcp__linear__broken/.test(event))); +}); diff --git a/tests/agent-session-services.test.ts b/tests/agent-session-services.test.ts index 8b6d2be..8972192 100644 --- a/tests/agent-session-services.test.ts +++ b/tests/agent-session-services.test.ts @@ -14,6 +14,7 @@ import { Type } from "typebox"; import { WorkflowAgentLimiter } from "../.pi/extensions/pi-workflow-engine/src/agent-limits.ts"; import { defaultAgentRetryScheduler } from "../.pi/extensions/pi-workflow-engine/src/agent-retry.ts"; import { + CODEMODE_TOOL, FINAL_TOOL, openAgentSession, } from "../.pi/extensions/pi-workflow-engine/src/agent-session.ts"; @@ -232,6 +233,20 @@ test("production session services load skills, tools, and host runtime providers assert.ok(session.getActiveToolNames().includes(FINAL_TOOL)); assert.ok(session.getToolDefinition("read")); assert.ok(session.getToolDefinition(FINAL_TOOL)); + // SDK sessions load no built-ins: codemode exists only for agents that allowlist it. + assert.equal(session.getAllTools().some((tool) => tool.name === CODEMODE_TOOL), false); + const scripted = await openAgentSession({ + rc, + prompt: "Batch the reads.", + opts: { label: "codemode-session", tools: ["read", CODEMODE_TOOL], schema: Type.Object({ ok: Type.Boolean() }) }, + cwd, + model, + label: "codemode-session", + }); + sessions.push(scripted.session); + assert.deepEqual([...scripted.session.getActiveToolNames()].sort(), [CODEMODE_TOOL, FINAL_TOOL, "read"]); + // The agent answers itself; a script cannot call final_answer on its behalf. + assert.equal(scripted.session.getAllTools().find((tool) => tool.name === FINAL_TOOL)?.exposure, "model-only"); assert.ok("modelRuntime" in session); assert.ok(session.modelRuntime instanceof ModelRuntime); const childProvider = session.modelRuntime.getRegisteredProviderConfig("runtime-only"); diff --git a/tests/host-tools.test.ts b/tests/host-tools.test.ts new file mode 100644 index 0000000..f37d784 --- /dev/null +++ b/tests/host-tools.test.ts @@ -0,0 +1,25 @@ +import assert from "node:assert/strict"; +import { test } from "bun:test"; +import type { AgentTool } from "@earendil-works/pi-agent-core"; +import { createHostToolBridge } from "../.pi/extensions/pi-workflow-engine/src/host-tools.ts"; +import { createToolInfo } from "./agent-runner-fixtures.ts"; + +test("the host bridge offers only MCP tools the host can currently call, and forwards calls with the agent's signal", async () => { + const forwarded: Array<{ name: string; args: unknown; signal: AbortSignal | undefined }> = []; + const callable = (name: string): AgentTool => ({ name, label: name, description: name, parameters: createToolInfo(name).parameters, execute: async () => ({ content: [], details: undefined }) }); + const bridge = createHostToolBridge( + () => [createToolInfo("mcp__docs__search"), createToolInfo("mcp__offline__search"), createToolInfo("bash")], + { + tools: [callable("mcp__docs__search"), callable("bash")], + async executeTool(name, args, options) { + forwarded.push({ name, args, signal: options?.signal }); + return { toolCall: { type: "toolCall", id: "parent/1", name, arguments: {} }, result: { content: [], details: undefined }, isError: false }; + }, + }, + ); + + assert.deepEqual(bridge.tools().map((tool) => tool.name), ["mcp__docs__search"]); + const signal = new AbortController().signal; + await bridge.execute("mcp__docs__search", { q: "x" }, signal); + assert.deepEqual(forwarded, [{ name: "mcp__docs__search", args: { q: "x" }, signal }]); +}); diff --git a/tests/usage.test.ts b/tests/usage.test.ts index 64d8cae..36c3967 100644 --- a/tests/usage.test.ts +++ b/tests/usage.test.ts @@ -296,3 +296,22 @@ test("workflow usage converts to pi's Usage so the session cost includes subagen cost: { input: 0.1, output: 0.2, cacheRead: 0.03, cacheWrite: 0.04, total: 0.37 }, }); }); + +test("tool results that report usage, such as codemode classifier calls, count toward the agent", () => { + const recorder = createWorkflowUsageRecorder(); + + recorder.recordAgentSession({ + label: "scripted", + messages: [ + assistantUsage({ input: 100, output: 10, costTotal: 0.01 }), + { role: "toolResult", content: [], usage: { input: 40, output: 2, cacheRead: 0, cacheWrite: 0, totalTokens: 42, cost: { input: 0.004, output: 0.001, cacheRead: 0, cacheWrite: 0, total: 0.005 } } }, + { role: "toolResult", content: [] }, + ], + }); + + const agent = recorder.snapshot().agents[0]; + assert.equal(agent?.assistantMessages, 1); + assert.equal(agent?.usage.input, 140); + assert.equal(agent?.usage.output, 12); + assert.ok(Math.abs((agent?.usage.cost.total ?? 0) - 0.015) < 1e-9); +}); From 795a3744c1c4acdc125eaebee6f9dd23866f6743 Mon Sep 17 00:00:00 2001 From: timbrinded <79199034+timbrinded@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:45:48 +0100 Subject: [PATCH 05/11] feat(api): classify between stages with pi's classifier models api.classify(context, opts?) answers typed choice/score/bool questions about JSON state through ctx.modelRegistry.classify(), with the host's credentials and no chat session. It defaults to the first credentialed classifier, shares the run's concurrency cap and budget, records usage like an agent, and rejects with a recoverable error when no classifier is configured. It is not journaled. --- .pi/extensions/pi-workflow-engine/index.ts | 1 + .../pi-workflow-engine/src/classify.ts | 76 +++++++++++++ .../pi-workflow-engine/src/engine.ts | 2 + .../pi-workflow-engine/src/types.ts | 18 +++ USAGE.md | 23 +++- tests/advisory-provenance.test.ts | 5 +- tests/builtin-workflows.test.ts | 3 + tests/classify.test.ts | 105 ++++++++++++++++++ tests/inline-workflow.test.ts | 3 + tests/research-workflow.test.ts | 3 + tests/workflow-tool.test.ts | 3 + 11 files changed, 240 insertions(+), 2 deletions(-) create mode 100644 .pi/extensions/pi-workflow-engine/src/classify.ts create mode 100644 tests/classify.test.ts diff --git a/.pi/extensions/pi-workflow-engine/index.ts b/.pi/extensions/pi-workflow-engine/index.ts index a554557..3b883bd 100644 --- a/.pi/extensions/pi-workflow-engine/index.ts +++ b/.pi/extensions/pi-workflow-engine/index.ts @@ -502,6 +502,7 @@ function registerWorkflowTool( "When using `isolation: \"worktree\"`, `api.agent()` returns `{ result, patch, changed }`; use `.result` for the answer and `.patch` for the isolated diff.", "If an inline subagent needs grep/find/code-search helpers, use `tools: [\"read\", \"bash\", \"grep\", \"find\", \"ls\"]` plus `toolHints: [\"search\"]` so installed tools such as ast-grep, mgrep, ffgrep, or fffind are discovered dynamically.", "Add \"codemode\" to an inline agent's `tools` when it should batch or filter many tool calls in one script; its scripts can call only that agent's other allowed tools.", + "`api.classify({ state, questions }, { label })` answers typed choice/score/bool questions with a classifier model and no chat session; use it for cheap gates such as dedup or triage, and handle its rejection when no classifier is configured.", "Host MCP tools reach subagents only in synchronous workflow tool runs: name them in `tools` (e.g. `mcp__server__tool`), or use `toolHints: [\"external-search\"]` to pick up MCP web research tools.", "`api.budget` exposes `{ total, spent(), remaining() }` (output tokens). When the run is budgeted, scale fleets from `budget.total` and guard loops with `while (budget.total && budget.remaining() > N) { await api.agent(...) }`; `api.agent()` throws once the ceiling is reached.", ADAPTIVE_WORKFLOW_GUIDANCE, diff --git a/.pi/extensions/pi-workflow-engine/src/classify.ts b/.pi/extensions/pi-workflow-engine/src/classify.ts new file mode 100644 index 0000000..c2a5c59 --- /dev/null +++ b/.pi/extensions/pi-workflow-engine/src/classify.ts @@ -0,0 +1,76 @@ +import type { ClassifierAnswer, ClassifierApi, ClassifierContext, ClassifierModel } from "@earendil-works/pi-ai"; +import type { ModelRegistry } from "@earendil-works/pi-coding-agent"; +import type { RunContext } from "./agent-runner-types.ts"; +import { assertWorkflowBudgetAvailable } from "./budget.ts"; +import { throwIfAborted } from "./cancellation.ts"; +import type { ClassifyOptions } from "./types.ts"; + +type ClassifierRegistry = Pick; + +/** No classifier model is configured, or the requested one is unknown. Recoverable inside `parallel`. */ +export class WorkflowClassifierUnavailableError extends Error { + readonly code = "WORKFLOW_CLASSIFIER_UNAVAILABLE"; + + constructor(message: string) { + super(message); + this.name = "WorkflowClassifierUnavailableError"; + } +} + +/** The classifier answered with an error or no answer. Recoverable inside `parallel`. */ +export class WorkflowClassifierError extends Error { + readonly code = "WORKFLOW_CLASSIFIER_FAILED"; + + constructor(message: string) { + super(message); + this.name = "WorkflowClassifierError"; + } +} + +/** + * `api.classify()`: answer typed questions about JSON state with a classifier model through the host's + * credentials, without a chat session. It shares the run's concurrency cap and budget, records usage + * like an agent, and is not journaled, so a resumed run asks again. + */ +export async function runClassifier( + rc: RunContext, + context: ClassifierContext, + opts: ClassifyOptions = {}, +): Promise> { + const registry = rc.modelRegistry; + // Test runs inject a registry that can only resolve chat models. + if (!("classify" in registry)) throw new WorkflowClassifierUnavailableError("This run's model registry has no classifier models."); + return await rc.semaphore.run(async () => { + assertWorkflowBudgetAvailable(rc.budget); + const model = await resolveClassifierModel(registry, opts.model); + const label = opts.label ?? `classify:${model.provider}/${model.id}`; + const result = await registry.classify(model, context, { signal: rc.signal }); + throwIfAborted(rc.signal); + // One classifier response is one model response: recording it like one keeps per-model usage and the budget whole. + rc.usage.recordAgentSession({ + label, + phase: opts.phase, + messages: [{ role: "assistant", provider: result.provider, model: result.model, usage: result.usage }], + }); + if (result.stopReason !== "stop") { + throw new WorkflowClassifierError(`${label}: classifier ${result.stopReason}${result.errorMessage ? `: ${result.errorMessage}` : ""}`); + } + return result.answers; + }, { signal: rc.signal }); +} + +async function resolveClassifierModel(registry: ClassifierRegistry, ref: string | undefined): Promise> { + if (ref === undefined) { + const [available] = await registry.getAvailableOfType("classifier"); + if (!available) { + throw new WorkflowClassifierUnavailableError( + "No classifier model has working credentials. Configure one (for example TypeSafe Jev via OpenRouter) or pass { model: \"provider/id\" }.", + ); + } + return available; + } + const slash = ref.indexOf("/"); + const model = slash > 0 ? registry.getModelOfType("classifier", ref.slice(0, slash), ref.slice(slash + 1)) : undefined; + if (!model) throw new WorkflowClassifierUnavailableError(`Classifier model "${ref}" not found; expected "provider/id".`); + return model; +} diff --git a/.pi/extensions/pi-workflow-engine/src/engine.ts b/.pi/extensions/pi-workflow-engine/src/engine.ts index 1f909f8..705559c 100644 --- a/.pi/extensions/pi-workflow-engine/src/engine.ts +++ b/.pi/extensions/pi-workflow-engine/src/engine.ts @@ -7,6 +7,7 @@ import { resolveWorkflowModelProfiles, type ResolvedWorkflowModelProfiles } from import { abortReason, isWorkflowPauseError, linkAbortSignal, throwIfAborted } from "./cancellation.ts"; import { createBudget } from "./budget.ts"; import { runAgent, type AgentExecutionOptions, type RunContext } from "./agent-runner.ts"; +import { runClassifier } from "./classify.ts"; import { ProgressTracker } from "./progress.ts"; import { createPerfRecorder, formatPerfSummary, type PerfSink } from "./perf.ts"; import { createWorkflowUsageRecorder, type WorkflowUsageSink } from "./usage.ts"; @@ -402,6 +403,7 @@ export async function runWorkflowWithContext( const api: WorkflowApi = { agent, + classify: (context, classifyOpts) => runClassifier(rc, context, { ...classifyOpts, phase: scope.agentOptions({ phase: classifyOpts?.phase }).phase }), workflow, parallel: bindParallel({ signal: rc.signal, diff --git a/.pi/extensions/pi-workflow-engine/src/types.ts b/.pi/extensions/pi-workflow-engine/src/types.ts index 2f84b20..bde2983 100644 --- a/.pi/extensions/pi-workflow-engine/src/types.ts +++ b/.pi/extensions/pi-workflow-engine/src/types.ts @@ -1,4 +1,5 @@ import type { Static, TSchema } from "typebox"; +import type { ClassifierAnswer, ClassifierContext } from "@earendil-works/pi-ai"; import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; import type { WorkflowBudget } from "./budget.ts"; import type { Pipeline, WorkflowParallel } from "./concurrency.ts"; @@ -188,6 +189,16 @@ export interface AgentOptions { schema?: S; } +/** Options for `api.classify()`. */ +export interface ClassifyOptions { + /** Classifier model as "provider/id". Default: the first classifier model with working credentials. */ + model?: string; + /** Label for usage reporting. Default: `classify:/`. */ + label?: string; + /** Phase to attribute usage to. Default: the current phase. */ + phase?: string; +} + /** * The primitives injected into every workflow run. A workflow is any module that * exports `meta` plus a default `async (api: WorkflowApi) => result`. @@ -204,6 +215,13 @@ export interface WorkflowApi { agent(prompt: string, opts: AgentOptions & { schema: S }): Promise>; /** Run a subagent and return its final assistant text. */ agent(prompt: string, opts?: AgentOptions): Promise; + /** + * Answer typed `choice`/`score`/`bool` questions about JSON `state` with a classifier model, without + * starting a chat session: a cheap gate for triage, deduplication, or routing between stages. Answers + * are keyed by question id. Rejects with a recoverable error when no classifier is configured or the + * call fails; results are not journaled, so a resumed run classifies again. + */ + classify(context: ClassifierContext, opts?: ClassifyOptions): Promise>; /** * Run another registered workflow inline as a sub-step and return its result. The child shares * this run's concurrency cap, abort signal, and perf sink. Nests one level only: calling diff --git a/USAGE.md b/USAGE.md index f0dbd3b..d698531 100644 --- a/USAGE.md +++ b/USAGE.md @@ -109,7 +109,9 @@ command instead of selecting another model. as external web search, browsing, or HTTP(S) page extraction. Local grep/find tools do not satisfy this check. The workflow does not bundle a scraper or a paid API; install or enable the web capability you prefer, then restart or -reload pi so subagent sessions can see it. +reload pi so subagent sessions can see it. An MCP web search server also +qualifies when the host agent runs `research` through the `workflow` tool, +because only those runs can reach the host's MCP tools. The workflow bounds decomposition to four lanes and verification to twelve claims, while the engine's concurrency, total-agent, timeout, cancellation, and @@ -466,6 +468,25 @@ export default async function run({ workflow }: WorkflowApi) { Nesting is one level only: calling `workflow()` from inside a sub-workflow rejects. Resolution throws on an unknown name. Inside `parallel()` or `pipeline()`, recoverable branch errors become `null` results, so filter nulls before synthesis; a genuine run abort still rejects. +### Classify between stages + +`classify(context, opts?)` answers typed `choice`, `score`, or `bool` questions about JSON `state` with one of pi's classifier models (for example TypeSafe Jev or Cloudflare Clef), using the host's credentials and no chat session. It suits cheap gates between stages, such as deduplicating findings or deciding which items need a follow-up agent. + +```ts +const answers = await api.classify( + { + state: { finding }, + questions: { + followUp: { type: "bool", instructions: "Does this finding need verification?", criteria: { true: "Plausible but unproven", false: "Clearly settled" } }, + }, + }, + { label: `triage:${finding.id}` }, +); +if (answers.followUp?.type === "bool" && answers.followUp.probability > 0.5) { /* verify it */ } +``` + +Without `opts.model` ("provider/id"), the first classifier with working credentials is used. Calls share the run's concurrency cap, count toward usage and the budget, and are not journaled, so a resumed run classifies again. When no classifier is configured the call rejects with a recoverable error, so guard it or let `parallel()` turn it into `null`. + ### Where workflows live Guaranteed built-ins are statically registered: diff --git a/tests/advisory-provenance.test.ts b/tests/advisory-provenance.test.ts index b59318b..1f3250c 100644 --- a/tests/advisory-provenance.test.ts +++ b/tests/advisory-provenance.test.ts @@ -15,7 +15,10 @@ const candidate: AdvisoryCandidate = { summary: "Wrong condition", category: "bu function apiFor(handler: (prompt: string, options: AgentOptions) => unknown): WorkflowApi { return { agent: (async (prompt: string, options: AgentOptions = {}) => handler(prompt, options)) as WorkflowApi["agent"], parallel: bindParallel({}), pipeline, phase() {}, log() {}, progress() {}, args: "", cwd: process.cwd(), signal: undefined, - budget: { total: null, spent: () => 0, remaining: () => Infinity }, workflow: async () => undefined }; + budget: { total: null, spent: () => 0, remaining: () => Infinity }, workflow: async () => undefined, + classify: async () => { + throw new Error("no classifier is configured in this test"); + } }; } const material = async () => ({ ok: true as const, diff: "diff --git a/src/app.ts b/src/app.ts\n--- a/src/app.ts\n+++ b/src/app.ts\n@@ -1 +1,2 @@\n old\n+new\n", snapshot: { status: "unavailable" as const, reason: "test" } }); const workflows = [ diff --git a/tests/builtin-workflows.test.ts b/tests/builtin-workflows.test.ts index 5cfb07d..f0e7e39 100644 --- a/tests/builtin-workflows.test.ts +++ b/tests/builtin-workflows.test.ts @@ -56,6 +56,9 @@ function createScriptedApi(responses: unknown[], args = ""): ScriptedApi { logs, events, agent, + classify: async () => { + throw new Error("no classifier is configured in this test"); + }, workflow: async () => { throw new Error("sub-workflows are not enabled in these tests"); }, diff --git a/tests/classify.test.ts b/tests/classify.test.ts new file mode 100644 index 0000000..ef7b559 --- /dev/null +++ b/tests/classify.test.ts @@ -0,0 +1,105 @@ +import assert from "node:assert/strict"; +import { test } from "bun:test"; +import type { ClassifierContext, ClassifierResult } from "@earendil-works/pi-ai"; +import type { ModelRegistry } from "@earendil-works/pi-coding-agent"; +import { createBudget, WorkflowBudgetExceededError } from "../.pi/extensions/pi-workflow-engine/src/budget.ts"; +import { + runClassifier, + WorkflowClassifierError, + WorkflowClassifierUnavailableError, +} from "../.pi/extensions/pi-workflow-engine/src/classify.ts"; +import { createWorkflowUsageRecorder } from "../.pi/extensions/pi-workflow-engine/src/usage.ts"; +import type { CreateAgentSession, RunContext } from "../.pi/extensions/pi-workflow-engine/src/agent-runner.ts"; +import { createRunContext, createTextSession } from "./agent-runner-fixtures.ts"; + +type ClassifierRegistry = Pick; +type ClassifierModelRef = Parameters[0]; + +const JEV = { type: "classifier", provider: "typesafe", id: "jev-latest", name: "Jev", api: "typesafe-classifier", input: ["text"], contextWindow: 8192 } as unknown as ClassifierModelRef; +const CLEF = { ...JEV, provider: "cloudflare-workers-ai", id: "@cf/cloudflare/clef" } as ClassifierModelRef; + +const CONTEXT: ClassifierContext = { + state: { finding: "Null dereference in parser" }, + questions: { duplicate: { type: "bool", instructions: "Is this a duplicate?", criteria: { true: "Same defect", false: "Different defect" } } }, +}; + +function classifierResult(overrides: Partial = {}): ClassifierResult { + return { + api: "typesafe-classifier", + provider: "typesafe", + model: "jev-latest", + answers: { duplicate: { type: "bool", probability: 0.9 } }, + usage: { input: 30, output: 4, cacheRead: 0, cacheWrite: 0, totalTokens: 34, cost: { input: 0.001, output: 0.0005, cacheRead: 0, cacheWrite: 0, total: 0.0015 } }, + stopReason: "stop", + timestamp: Date.now(), + ...overrides, + } as ClassifierResult; +} + +function classifierRun(input: { + readonly available?: readonly ClassifierModelRef[]; + readonly result?: ClassifierResult; + readonly budget?: number; +} = {}): { rc: RunContext; usage: ReturnType; classified: ClassifierModelRef[] } { + const classified: ClassifierModelRef[] = []; + const registry: ClassifierRegistry = { + find: () => undefined, + async getAvailableOfType() { + return (input.available ?? [JEV]) as never; + }, + getModelOfType: ((_type: string, provider: string, id: string) => + [JEV, CLEF].find((model) => model.provider === provider && model.id === id)) as ClassifierRegistry["getModelOfType"], + async classify(model) { + classified.push(model); + return input.result ?? classifierResult(); + }, + }; + const usage = createWorkflowUsageRecorder(); + const createSession: CreateAgentSession = async () => createTextSession(); + const rc: RunContext = { + ...createRunContext({ createSession, usage, budget: createBudget(input.budget ?? null, usage) }), + createSession, + modelRegistry: registry, + }; + return { rc, usage, classified }; +} + +test("classify uses the first credentialed classifier and records its usage like an agent", async () => { + const { rc, usage, classified } = classifierRun(); + + const answers = await runClassifier(rc, CONTEXT, { phase: "Triage" }); + + assert.deepEqual(answers, { duplicate: { type: "bool", probability: 0.9 } }); + assert.deepEqual(classified, [JEV]); + const [agent] = usage.snapshot().agents; + assert.equal(agent?.label, "classify:typesafe/jev-latest"); + assert.equal(agent?.phase, "Triage"); + assert.equal(agent?.usage.output, 4); + assert.equal(rc.budget.spent(), 4); +}); + +test("classify resolves an explicit provider/id and rejects unknown refs or a host without classifiers", async () => { + const explicit = classifierRun(); + await runClassifier(explicit.rc, CONTEXT, { model: "cloudflare-workers-ai/@cf/cloudflare/clef", label: "dedup" }); + assert.deepEqual(explicit.classified, [CLEF]); + assert.equal(explicit.usage.snapshot().agents[0]?.label, "dedup"); + + await assert.rejects(() => runClassifier(explicit.rc, CONTEXT, { model: "jev-latest" }), WorkflowClassifierUnavailableError); + await assert.rejects(() => runClassifier(classifierRun({ available: [] }).rc, CONTEXT), /No classifier model has working credentials/); + await assert.rejects( + () => runClassifier(createRunContext({ createSession: async () => createTextSession() }), CONTEXT), + WorkflowClassifierUnavailableError, + ); +}); + +test("a failed classifier call still counts its usage, and a spent budget stops the call before it starts", async () => { + const failed = classifierRun({ result: classifierResult({ stopReason: "error", errorMessage: "rate limited", answers: {} }) }); + await assert.rejects(() => runClassifier(failed.rc, CONTEXT), (error: unknown) => + error instanceof WorkflowClassifierError && /classifier error: rate limited/.test(error.message)); + assert.equal(failed.usage.snapshot().totals.output, 4); + + const spent = classifierRun({ budget: 4 }); + await runClassifier(spent.rc, CONTEXT); + await assert.rejects(() => runClassifier(spent.rc, CONTEXT), WorkflowBudgetExceededError); + assert.equal(spent.classified.length, 1); +}); diff --git a/tests/inline-workflow.test.ts b/tests/inline-workflow.test.ts index d7b843c..4d6e46c 100644 --- a/tests/inline-workflow.test.ts +++ b/tests/inline-workflow.test.ts @@ -13,6 +13,9 @@ function createFakeApi(overrides: Partial = {}, onAgent?: (opts: Ag return { agent, + classify: async () => { + throw new Error("no classifier is configured in this test"); + }, workflow: async () => { throw new Error("sub-workflows are not enabled in this context"); }, diff --git a/tests/research-workflow.test.ts b/tests/research-workflow.test.ts index bee7945..9a80b07 100644 --- a/tests/research-workflow.test.ts +++ b/tests/research-workflow.test.ts @@ -42,6 +42,9 @@ function scriptedApi(responses: readonly unknown[], args: string): ScriptedApi { phases, events, agent, + classify: async () => { + throw new Error("no classifier is configured in this test"); + }, workflow: async () => { throw new Error("sub-workflows are disabled in this fixture"); }, diff --git a/tests/workflow-tool.test.ts b/tests/workflow-tool.test.ts index 43f558c..46617e5 100644 --- a/tests/workflow-tool.test.ts +++ b/tests/workflow-tool.test.ts @@ -148,6 +148,9 @@ function createFakeApi(overrides: Partial = {}): WorkflowApi { const agent = (async (_prompt: string, opts?: AgentOptions) => (opts?.schema ? { ok: true } : "agent text")) as WorkflowApi["agent"]; return { agent, + classify: async () => { + throw new Error("no classifier is configured in this test"); + }, workflow: async () => { throw new Error("sub-workflows are not enabled in this context"); }, From 82c4c1d0e35a8e7b13872702ac69f157697fd71a Mon Sep 17 00:00:00 2001 From: timbrinded <79199034+timbrinded@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:46:00 +0100 Subject: [PATCH 06/11] docs(agents): record the pi 1.0 subagent and prompt constraints --- AGENTS.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index b55cce2..6024053 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,6 +31,7 @@ A **workflow** (`.pi/extensions/pi-workflow-engine/workflows/*.ts`) exports `met - `agent(prompt, { schema?, profile?, model?, thinkingLevel?, tools?, label?, phase? })` — runs one subagent; with a typebox `schema` it returns validated structured data, else final text. - `parallel(thunks)` — concurrent barrier; recoverable failures become `null` slots and survivors continue. - `pipeline(items, ...stages)` — each item through all stages independently; recoverable item failures become `null`, with no barrier between stages. +- `classify(context, opts?)` — answers typed questions about JSON state with a pi classifier model (no chat session). - `phase(title)` / `log(msg)` — drive the live progress tree. Example: `.pi/extensions/pi-workflow-engine/workflows/code-review.ts` — Scope → per-angle Find → independent Verify → Synthesize. @@ -45,7 +46,9 @@ Example: `.pi/extensions/pi-workflow-engine/workflows/code-review.ts` — Scope - `.pi/extensions/pi-workflow-engine/src/progress.ts` — live phase/agent tree via `ctx.ui.setWidget`; stderr breadcrumbs when headless. - `.pi/extensions/pi-workflow-engine/src/discovery.ts` + `.pi/extensions/pi-workflow-engine/src/workflows.ts` — static registry (`BUILTIN_WORKFLOW_DEFINITIONS`) plus best-effort dynamic drop-in loading. - `.pi/extensions/pi-workflow-engine/src/inline-workflow.ts` — inline workflow compiler (`script` string → `WorkflowModule`) with pure-literal `export const meta` extraction and injected Type schemas. -- `.pi/extensions/pi-workflow-engine/src/dynamax.ts` — `dynamax` trigger/sticky state and reminder injection. +- `.pi/extensions/pi-workflow-engine/src/dynamax.ts` — `dynamax` trigger/sticky state and the `` system prompt section. +- `.pi/extensions/pi-workflow-engine/src/host-tools.ts` — bridge that lets subagents call host MCP tools via `ctx.executeTool`. +- `.pi/extensions/pi-workflow-engine/src/classify.ts` — `api.classify()` over `ctx.modelRegistry.classify()`. - `.pi/extensions/pi-workflow-engine/src/types.ts` — `WorkflowApi` / `WorkflowModule` / `AgentOptions` contracts. ## Critical non-obvious facts (read before editing) @@ -55,6 +58,8 @@ Example: `.pi/extensions/pi-workflow-engine/workflows/code-review.ts` — Scope - **`jiti` is NOT a virtual module.** A dynamically `import()`-ed drop-in workflow may resolve a *different* `typebox` than pi's bundled one, breaking schema validation. **Therefore guaranteed workflows must be statically imported and registered in `.pi/extensions/pi-workflow-engine/src/workflows.ts`** (they ride pi's jiti and share its typebox). Dynamic discovery is best-effort only. - **Inline workflow scripts must never use `import`/dynamic `import()`.** They compile in-process via `AsyncFunction` and receive the extension's injected Type value so `agent({ schema })` preserves pi's bundled TypeBox identity. The `export const meta` block must stay a pure literal so metadata can be validated before untrusted code runs. - **Inline workflow bodies must execute in the same VM as the extension.** `agent()` closes over live `RunContext` handles (`Semaphore`, `ProgressTracker`, model registry, abort signal); subprocess/stdin execution cannot access those handles without building a second orchestration system. +- **Subagents are SDK sessions, so pi's built-in extensions (codemode, MCP, tool_search) do not load in them.** Codemode is registered per agent only when its `tools` allowlist names it; MCP tools reach subagents only through `host-tools.ts`, which needs `ctx.executeTool`. That exists only on the context of a running tool call, so only synchronous `workflow` tool runs get the bridge, never `/workflow` commands or background runs. +- **Change the host prompt through `systemPromptOptions`, not by returning `systemPrompt` from `before_agent_start`.** A returned prompt forces pi to replace the whole prompt head; a section edit is appended as a patch and keeps the provider's prompt cache. - **Never call `session.setAutoRetryEnabled()` on a subagent.** In pi it is not session-scoped: it writes `retry.enabled` to the user's global `~/.pi/agent/settings.json`, changing retry for the host and every later session. Subagents inherit pi's in-session turn retry; `agentRetries` is the outer whole-agent restart. - **Set `profile` per `agent()` stage.** Use `small`, `medium`, or `big` so users can centrally configure exact model/effort routes; reserve `model` and `thinkingLevel` for intentional per-call overrides. - **`pi install` runs `npm install`** (not bun). `bun.lock` is for local dev only. From df177cd85c088b1bd57f955cd0a0a9de483e18e4 Mon Sep 17 00:00:00 2001 From: timbrinded <79199034+timbrinded@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:52:56 +0100 Subject: [PATCH 07/11] fix(research): say when MCP web search can count toward external-search A missing external-search capability now notes that MCP tools reach subagents only in runs started by the workflow tool, both in the hint error and in the research workflow's next step, instead of implying nothing is installed. --- .pi/extensions/pi-workflow-engine/src/agent-session.ts | 2 +- .../pi-workflow-engine/src/research-evidence.ts | 6 ++++-- .../pi-workflow-engine/src/tool-capabilities.ts | 8 ++++++-- tests/agent-runner-workspace-tools.test.ts | 4 +++- tests/research-workflow.test.ts | 3 ++- 5 files changed, 16 insertions(+), 7 deletions(-) diff --git a/.pi/extensions/pi-workflow-engine/src/agent-session.ts b/.pi/extensions/pi-workflow-engine/src/agent-session.ts index cb2ccb4..3ac808a 100644 --- a/.pi/extensions/pi-workflow-engine/src/agent-session.ts +++ b/.pi/extensions/pi-workflow-engine/src/agent-session.ts @@ -113,7 +113,7 @@ export async function openAgentSession(input: { if (!opts.tools?.includes(WORKFLOW_TOOL_NAME)) withholdWorkflowTool(session); if (opts.requireToolHints) { const missing = toolSelection.toolHints.filter((hint) => !matchedToolHints.has(hint)); - if (missing.length > 0) throw new WorkflowToolHintUnavailableError(missing); + if (missing.length > 0) throw new WorkflowToolHintUnavailableError(missing, { hostToolsReachable: rc.hostTools !== undefined }); } throwIfAborted(rc.signal); return { diff --git a/.pi/extensions/pi-workflow-engine/src/research-evidence.ts b/.pi/extensions/pi-workflow-engine/src/research-evidence.ts index d4f9c69..dc4a706 100644 --- a/.pi/extensions/pi-workflow-engine/src/research-evidence.ts +++ b/.pi/extensions/pi-workflow-engine/src/research-evidence.ts @@ -155,8 +155,10 @@ const UNAVAILABLE_RESEARCH = { nextStep: "Run `/workflow research ` and include any source, date, or geography constraints in the arguments.", }, "missing-capability": { - message: "Research could not start because pi exposed no installed external web-search or URL-extraction tool.", - nextStep: "Install or enable a pi tool that can search the web or extract HTTP(S) pages, then rerun the workflow.", + message: "Research could not start because its agents could reach no external web-search or URL-extraction tool.", + nextStep: + "Install or enable a pi tool that can search the web or extract HTTP(S) pages, then rerun the workflow. " + + "An MCP web search server counts only when the host agent runs research through the workflow tool, for example after `dynamax`.", }, "lanes-failed": { message: "Research could not gather direct-page evidence because one or more research lanes failed.", diff --git a/.pi/extensions/pi-workflow-engine/src/tool-capabilities.ts b/.pi/extensions/pi-workflow-engine/src/tool-capabilities.ts index c7c495e..f64f068 100644 --- a/.pi/extensions/pi-workflow-engine/src/tool-capabilities.ts +++ b/.pi/extensions/pi-workflow-engine/src/tool-capabilities.ts @@ -7,8 +7,12 @@ export class WorkflowToolHintUnavailableError extends Error { readonly code = "WORKFLOW_TOOL_HINT_UNAVAILABLE"; readonly hints: readonly AgentToolHint[]; - constructor(hints: readonly AgentToolHint[]) { - super(`Required workflow tool capability unavailable: ${hints.join(", ")}`); + constructor(hints: readonly AgentToolHint[], options: { readonly hostToolsReachable?: boolean } = {}) { + // Host MCP tools only count in runs that can reach them; say so rather than imply none are installed. + const mcpNote = hints.includes("external-search") && options.hostToolsReachable === false + ? " (MCP tools reach subagents only in runs started by the workflow tool)" + : ""; + super(`Required workflow tool capability unavailable: ${hints.join(", ")}${mcpNote}`); this.name = "WorkflowToolHintUnavailableError"; this.hints = [...hints]; } diff --git a/tests/agent-runner-workspace-tools.test.ts b/tests/agent-runner-workspace-tools.test.ts index 81c55b2..93b1d76 100644 --- a/tests/agent-runner-workspace-tools.test.ts +++ b/tests/agent-runner-workspace-tools.test.ts @@ -534,7 +534,9 @@ test("required tool hints fail before prompting when no installed capability mat toolHints: ["external-search"], requireToolHints: true, }), - WorkflowToolHintUnavailableError, + (error: unknown) => + error instanceof WorkflowToolHintUnavailableError && + /external-search \(MCP tools reach subagents only in runs started by the workflow tool\)/.test(error.message), ); assert.equal(prompted, false); assert.equal(disposed, true); diff --git a/tests/research-workflow.test.ts b/tests/research-workflow.test.ts index 9a80b07..97031c7 100644 --- a/tests/research-workflow.test.ts +++ b/tests/research-workflow.test.ts @@ -71,7 +71,8 @@ test("research returns actionable no-LLM results for empty questions and missing "What changed in the specification?", ); const missingResult = await research(missing); - assert.match(missingResult.answer, /no installed external web-search or URL-extraction tool/i); + assert.match(missingResult.answer, /could reach no external web-search or URL-extraction tool/i); + assert.match(missingResult.nextSteps.join("\n"), /MCP web search server counts only when the host agent runs research through the workflow tool/); assert.match(missingResult.nextSteps[0] ?? "", /Install or enable/); assert.equal(missing.calls.length, 1); assert.deepEqual(missing.calls[0]?.options?.toolHints, ["external-search"]); From 374f1c042c042bc8bd5516126cc1f2f0c926b44f Mon Sep 17 00:00:00 2001 From: timbrinded <79199034+timbrinded@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:03:25 +0100 Subject: [PATCH 08/11] fix: address PR review findings - Dynamax appends to a prompt an earlier extension forced; pi renders no sections then, so the opt-in was lost while the status said active. - The workflow tool's guidelines carry the per-agent profile rule that the Dynamax reminder and authoring prompt no longer repeat. - api.classify() is bounded by the per-agent timeout, records usage before the abort check, and parses model refs like agent(); test registries gain classifier stubs instead of a production branch. - An agent that names an MCP tool the run cannot reach fails before prompting, and pi's MCP resource tools count as MCP tools. - A failed synchronous run returns an isError result carrying the usage its agents spent instead of throwing it away. - Document that bridged tools act in the host session, including for isolated worktree agents. --- .pi/extensions/pi-workflow-engine/index.ts | 21 +++++++++++++-- .../src/agent-runner-types.ts | 2 +- .../pi-workflow-engine/src/agent-session.ts | 22 +++++----------- .../pi-workflow-engine/src/classify.ts | 26 ++++++++++++------- .../pi-workflow-engine/src/dynamax.ts | 7 ++++- .../pi-workflow-engine/src/host-tools.ts | 25 +++++++++++++++--- USAGE.md | 2 +- tests/agent-runner-fixtures.ts | 16 ++++++++++-- tests/agent-runner-workspace-tools.test.ts | 26 ++++++++++++------- tests/classify.test.ts | 20 +++++++++++--- tests/dynamax.test.ts | 13 ++++++++++ tests/workflow-tool.test.ts | 15 +++++++++-- 12 files changed, 146 insertions(+), 49 deletions(-) diff --git a/.pi/extensions/pi-workflow-engine/index.ts b/.pi/extensions/pi-workflow-engine/index.ts index 3b883bd..fcb4f58 100644 --- a/.pi/extensions/pi-workflow-engine/index.ts +++ b/.pi/extensions/pi-workflow-engine/index.ts @@ -40,7 +40,8 @@ import { registerWorkflowRunCommand, WorkflowRunController } from "./src/workflo import { completeCurrentArgument, splitArgumentPrefix } from "./src/command-completions.ts"; import { assertSupportedPiVersion } from "./src/pi-compat.ts"; import { formatWorkflowInspection, workflowInspectionSnapshot } from "./src/ui/workflow-format.ts"; -import { workflowUsageForPi } from "./src/usage.ts"; +import { workflowUsageForPi, type WorkflowUsageSnapshot } from "./src/usage.ts"; +import { unknownErrorMessage } from "./src/unknown-error.ts"; import { createHostToolBridge } from "./src/host-tools.ts"; /** Extension root (this file lives in /.pi/extensions/pi-workflow-engine/index.ts). */ @@ -498,6 +499,7 @@ function registerWorkflowTool( "Inline workflow scripts must use the injected `Type` object for schemas and must not contain imports or dynamic import().", "Inline scripts may compose registered workflows in-process via `api.workflow(\"\", args)` (e.g. `await api.workflow(\"code-review\", \"HEAD~3\")`); it returns the sub-workflow's result and nests one level only.", "Subagents receive no skills by default. In inline workflows, pass `skills: [\"skill-name\"]` per `agent()` call when the user asks for a skill or a stage should use one; grant only the needed skills.", + "Set `profile: \"small\"`, `\"medium\"`, or `\"big\"` on every `api.agent()` call so the user's central model routes apply; use `model`/`thinkingLevel` only for an intentional override.", "Always pass a plain string as the first `api.agent()` argument; build prompts with template strings before calling agent().", "When using `isolation: \"worktree\"`, `api.agent()` returns `{ result, patch, changed }`; use `.result` for the answer and `.patch` for the isolated diff.", "If an inline subagent needs grep/find/code-search helpers, use `tools: [\"read\", \"bash\", \"grep\", \"find\", \"ls\"]` plus `toolHints: [\"search\"]` so installed tools such as ast-grep, mgrep, ffgrep, or fffind are discovered dynamically.", @@ -649,7 +651,22 @@ function registerWorkflowTool( onUpdate?.({ content: [{ type: "text", text: `Running workflow ${resultName}.` }], details: { state: "running", name: resultName } }); // Only this synchronous path may bridge host MCP tools: executeTool ends with the tool call. const hostTools = createHostToolBridge(() => pi.getAllTools(), ctx); - const envelope = await executeResolvedWorkflow(pi, ctx, resultName, mod, resultArgs, { ...runOptions, hostTools }, perfRecorder); + // Keep the latest usage: a failed run still reports what its agents spent instead of dropping it with the error. + let spent: WorkflowUsageSnapshot | undefined; + const onUsageSnapshot = (snapshot: WorkflowUsageSnapshot) => { + spent = snapshot; + return runOptions.onUsageSnapshot?.(snapshot); + }; + let envelope: WorkflowResultEnvelope; + try { + envelope = await executeResolvedWorkflow(pi, ctx, resultName, mod, resultArgs, { ...runOptions, hostTools, onUsageSnapshot }, perfRecorder); + } catch (error) { + const message = unknownErrorMessage(error); + return { + ...workflowToolError(`Workflow ${resultName} failed: ${message}`, { error: "workflow_failed", name: resultName, message }), + usage: workflowUsageForPi(spent), + }; + } reviewSessions.remember(ctx, envelope, runOptions); return { content: [{ type: "text", text: formatWorkflowResultForContext(envelope) }], diff --git a/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts b/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts index dcc1393..532d7d0 100644 --- a/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +++ b/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts @@ -77,7 +77,7 @@ export type RunContext = RunContextBase & ( } | { /** Injected test sessions bypass Pi's resource loader and therefore do not resolve skills. */ - modelRegistry: Pick; + modelRegistry: Pick; createSession: CreateAgentSession; } ); diff --git a/.pi/extensions/pi-workflow-engine/src/agent-session.ts b/.pi/extensions/pi-workflow-engine/src/agent-session.ts index 3ac808a..34ad905 100644 --- a/.pi/extensions/pi-workflow-engine/src/agent-session.ts +++ b/.pi/extensions/pi-workflow-engine/src/agent-session.ts @@ -25,7 +25,7 @@ import { } from "./structured-output.ts"; import { providerErrorFromMessages } from "./agent-retry.ts"; import { synchronizeWorkflowModelRuntime } from "./agent-session-providers.ts"; -import { hostToolProxy, isMcpToolName, type HostToolBridge } from "./host-tools.ts"; +import { hostToolProxy, isMcpToolName, WorkflowHostToolUnavailableError, type HostToolBridge } from "./host-tools.ts"; import { matchesAgentToolHint, WorkflowToolHintUnavailableError } from "./tool-capabilities.ts"; import { truncateText } from "./text.ts"; import { WORKFLOW_TOOL_NAME, type AgentToolHint } from "./types.ts"; @@ -91,7 +91,7 @@ export async function openAgentSession(input: { }), ] : []; - customTools.push(...requestedHostTools(opts, rc.hostTools, (message) => rc.progress.log(`${label}: ${message}`))); + customTools.push(...requestedHostTools(opts, rc.hostTools)); let session: AgentRunnerSession | undefined; try { throwIfAborted(rc.signal); @@ -208,7 +208,7 @@ export async function promptAgentSession(input: { } } -function parseAgentModelRef(modelRef: string): { readonly provider: string; readonly id: string } { +export function parseAgentModelRef(modelRef: string): { readonly provider: string; readonly id: string } { const normalized = modelRef.trim(); if (normalized.length === 0) { throw new Error('Invalid agent model ref: expected a bare model id or "provider/id".'); @@ -313,25 +313,17 @@ type SessionResourceLoaderOptions = NonNullable void, -): ToolDefinition[] { +function requestedHostTools(opts: AgentExecutionOptions, bridge: HostToolBridge | undefined): ToolDefinition[] { const named = new Set((opts.tools ?? []).filter(isMcpToolName)); const externalSearch = opts.toolHints?.includes("external-search") ?? false; if (named.size === 0 && !externalSearch) return []; const available = bridge?.tools() ?? []; const selected = available.filter((tool) => named.has(tool.name) || (externalSearch && matchesAgentToolHint(tool, "external-search"))); const missing = [...named].filter((name) => !selected.some((tool) => tool.name === name)); - if (missing.length > 0) { - log( - bridge - ? `host MCP tool${missing.length === 1 ? "" : "s"} not available: ${missing.join(", ")}` - : `MCP tools reach subagents only in runs started by the workflow tool; unavailable: ${missing.join(", ")}`, - ); - } + if (missing.length > 0) throw new WorkflowHostToolUnavailableError(missing, bridge !== undefined); return bridge ? selected.map((tool) => hostToolProxy(tool, bridge)) : []; } diff --git a/.pi/extensions/pi-workflow-engine/src/classify.ts b/.pi/extensions/pi-workflow-engine/src/classify.ts index c2a5c59..9cc3e2c 100644 --- a/.pi/extensions/pi-workflow-engine/src/classify.ts +++ b/.pi/extensions/pi-workflow-engine/src/classify.ts @@ -1,6 +1,7 @@ import type { ClassifierAnswer, ClassifierApi, ClassifierContext, ClassifierModel } from "@earendil-works/pi-ai"; import type { ModelRegistry } from "@earendil-works/pi-coding-agent"; import type { RunContext } from "./agent-runner-types.ts"; +import { parseAgentModelRef } from "./agent-session.ts"; import { assertWorkflowBudgetAvailable } from "./budget.ts"; import { throwIfAborted } from "./cancellation.ts"; import type { ClassifyOptions } from "./types.ts"; @@ -29,8 +30,8 @@ export class WorkflowClassifierError extends Error { /** * `api.classify()`: answer typed questions about JSON state with a classifier model through the host's - * credentials, without a chat session. It shares the run's concurrency cap and budget, records usage - * like an agent, and is not journaled, so a resumed run asks again. + * credentials, without a chat session. It shares the run's concurrency cap, budget, and per-agent timeout, + * records usage like an agent, and is not journaled, so a resumed run asks again. */ export async function runClassifier( rc: RunContext, @@ -38,20 +39,24 @@ export async function runClassifier( opts: ClassifyOptions = {}, ): Promise> { const registry = rc.modelRegistry; - // Test runs inject a registry that can only resolve chat models. - if (!("classify" in registry)) throw new WorkflowClassifierUnavailableError("This run's model registry has no classifier models."); return await rc.semaphore.run(async () => { assertWorkflowBudgetAvailable(rc.budget); const model = await resolveClassifierModel(registry, opts.model); const label = opts.label ?? `classify:${model.provider}/${model.id}`; - const result = await registry.classify(model, context, { signal: rc.signal }); - throwIfAborted(rc.signal); - // One classifier response is one model response: recording it like one keeps per-model usage and the budget whole. + // A stalled classifier must not hold a concurrency slot past the limit agents get. + const timeout = AbortSignal.timeout(rc.agentTimeoutMs); + const result = await registry.classify(model, context, { signal: rc.signal ? AbortSignal.any([rc.signal, timeout]) : timeout }); + // One classifier response is one model response: recording it like one keeps per-model usage and the budget + // whole. Record before the abort check, since the provider charged for a call that returned as the run stopped. rc.usage.recordAgentSession({ label, phase: opts.phase, messages: [{ role: "assistant", provider: result.provider, model: result.model, usage: result.usage }], }); + throwIfAborted(rc.signal); + if (result.stopReason !== "stop" && timeout.aborted) { + throw new WorkflowClassifierError(`${label}: classifier timed out after ${rc.agentTimeoutMs}ms`); + } if (result.stopReason !== "stop") { throw new WorkflowClassifierError(`${label}: classifier ${result.stopReason}${result.errorMessage ? `: ${result.errorMessage}` : ""}`); } @@ -69,8 +74,9 @@ async function resolveClassifierModel(registry: ClassifierRegistry, ref: string } return available; } - const slash = ref.indexOf("/"); - const model = slash > 0 ? registry.getModelOfType("classifier", ref.slice(0, slash), ref.slice(slash + 1)) : undefined; - if (!model) throw new WorkflowClassifierUnavailableError(`Classifier model "${ref}" not found; expected "provider/id".`); + // Same ref rules as agent({ model }): malformed refs throw, bare ids mean Anthropic. + const { provider, id } = parseAgentModelRef(ref); + const model = registry.getModelOfType("classifier", provider, id); + if (!model) throw new WorkflowClassifierUnavailableError(`Classifier model "${ref}" not found (resolved as ${provider}/${id}).`); return model; } diff --git a/.pi/extensions/pi-workflow-engine/src/dynamax.ts b/.pi/extensions/pi-workflow-engine/src/dynamax.ts index b86b53f..98352df 100644 --- a/.pi/extensions/pi-workflow-engine/src/dynamax.ts +++ b/.pi/extensions/pi-workflow-engine/src/dynamax.ts @@ -236,7 +236,12 @@ export function registerDynamax(pi: ExtensionAPI, shortcuts: DynamaxShortcuts, o pi.on("before_agent_start", (event, ctx) => { const runtime = getDynamaxRuntime(runtimes, ctx); - if (applyDynamaxPromptSection(event.systemPromptOptions.sections, runtime.state)) updateDynamaxSurfaces(ctx, runtime); + const options = event.systemPromptOptions; + if (!applyDynamaxPromptSection(options.sections, runtime.state)) return undefined; + updateDynamaxSurfaces(ctx, runtime); + // An earlier extension forced the whole prompt, and pi renders no sections then; append to its text instead. + if (options.forceSystemPrompt === undefined) return undefined; + return { systemPrompt: `${options.forceSystemPrompt}\n\n<${DYNAMAX_SECTION}>\n${DYNAMAX_REMINDER}\n` }; }); pi.on("agent_end", (_event, ctx) => { diff --git a/.pi/extensions/pi-workflow-engine/src/host-tools.ts b/.pi/extensions/pi-workflow-engine/src/host-tools.ts index 802a1e9..b213c79 100644 --- a/.pi/extensions/pi-workflow-engine/src/host-tools.ts +++ b/.pi/extensions/pi-workflow-engine/src/host-tools.ts @@ -1,17 +1,36 @@ import type { AgentToolCallOutcome } from "@earendil-works/pi-agent-core"; import { defineTool, type ExtensionToolContext, type ToolDefinition, type ToolInfo } from "@earendil-works/pi-coding-agent"; -/** pi names MCP tools `mcp____`. */ +/** pi names MCP tools `mcp____`; its MCP extension also registers these resource tools. */ const MCP_TOOL_PREFIX = "mcp__"; +const MCP_RESOURCE_TOOLS = new Set(["list_mcp_resources", "list_mcp_resource_templates", "read_mcp_resource"]); +/** Mirrors pi's own `isMcpToolName`, which the package does not export. */ export function isMcpToolName(name: string): boolean { - return name.startsWith(MCP_TOOL_PREFIX); + return name.startsWith(MCP_TOOL_PREFIX) || MCP_RESOURCE_TOOLS.has(name); +} + +/** An agent named MCP tools it cannot reach. Raised before prompting so it never runs without them. */ +export class WorkflowHostToolUnavailableError extends Error { + readonly code = "WORKFLOW_HOST_TOOL_UNAVAILABLE"; + readonly tools: readonly string[]; + + constructor(tools: readonly string[], bridged: boolean) { + super( + bridged + ? `Host MCP tools not available: ${tools.join(", ")}` + : `MCP tools reach subagents only in runs started by the workflow tool; unavailable: ${tools.join(", ")}`, + ); + this.name = "WorkflowHostToolUnavailableError"; + this.tools = [...tools]; + } } /** * Host-only tools a subagent can reach through the host session. Subagents are SDK sessions, which * load none of pi's built-in extensions, so they connect no MCP servers of their own; proxies call the - * host's tools over its existing connections and through its `tool_call` permission handlers. + * host's tools over its existing connections and through its `tool_call` permission handlers. They act + * in the host's context, so a local MCP tool sees the main checkout even for an isolated worktree agent. */ export interface HostToolBridge { /** Callable host MCP tools, read as each agent starts so servers that connected mid-run are included. */ diff --git a/USAGE.md b/USAGE.md index d698531..7b2bc77 100644 --- a/USAGE.md +++ b/USAGE.md @@ -428,7 +428,7 @@ overrides its profile. Subagents are SDK sessions, so pi's built-in codemode and MCP extensions do not load in them. Two opt-ins cover that: - **Codemode.** Add `"codemode"` to an agent's `tools` to let it batch or filter many tool calls in one script, for example `tools: ["read", "grep", "codemode"]`. Scripts can call only that agent's other allowed tools, and never `final_answer`. Classifier calls a script makes count toward the run's usage and budget. -- **Host MCP tools.** In runs started by the `workflow` tool, subagents reach the host's MCP tools over its existing connections and through its permission handlers. Name a tool in `tools` (for example `"mcp__linear__create_issue"`), or use `toolHints: ["external-search"]` to pick up MCP web research tools; servers that declare a tool not read-only or closed-world are excluded from that hint. Host tools run in the host's working directory, so the local `search` hint never bridges them. `/workflow` commands and background runs outlive the host tool call that makes this possible, so their agents log the MCP tools they could not reach. +- **Host MCP tools.** In runs started by the `workflow` tool, subagents reach the host's MCP tools over its existing connections and through its permission handlers. Name a tool in `tools` (for example `"mcp__linear__create_issue"`), or use `toolHints: ["external-search"]` to pick up MCP web research tools; servers that declare a tool not read-only or closed-world are excluded from that hint. Bridged tools act in the host session, so the local `search` hint never bridges them, and an isolated worktree agent that names a local MCP tool sees the main checkout through it, not its worktree. `/workflow` commands and background runs outlive the host tool call that makes this possible: an agent there that names an MCP tool fails before prompting rather than running without it. Subagents receive no skills by default. Opt in per agent with `skills: ["skill-name"]`; if `tools` is also restricted, the engine automatically keeps `read` available so the subagent can load the selected `SKILL.md`. When `skills` is omitted, clear prompt text such as `/skill:name`, `include skill name`, or `use the name skill` is also treated as an opt-in. Pass `skills: []` to suppress that inference. diff --git a/tests/agent-runner-fixtures.ts b/tests/agent-runner-fixtures.ts index ab18db6..7d453af 100644 --- a/tests/agent-runner-fixtures.ts +++ b/tests/agent-runner-fixtures.ts @@ -100,12 +100,24 @@ export function assistantTextMessage(text: string): AssistantMessage { }; } -export function createRegistry(models: readonly Model[], calls: FindCall[] = []): Pick { +/** The registry surface injected test runs see: chat model lookup plus classifiers, which tests leave unconfigured. */ +export type TestModelRegistry = Pick; + +export function createRegistry(models: readonly Model[], calls: FindCall[] = []): TestModelRegistry { return { find(provider, modelId) { calls.push({ provider, modelId }); return models.find((model) => model.provider === provider && model.id === modelId); }, + async classify() { + throw new Error("no classifier is configured in this test"); + }, + async getAvailableOfType() { + return []; + }, + getModelOfType() { + return undefined; + }, }; } @@ -144,7 +156,7 @@ export function createProgress(): AgentProgress & { readonly events: string[] } export function createRunContext(input: { readonly createSession: CreateAgentSession; readonly hostModel?: Model; - readonly modelRegistry?: Pick; + readonly modelRegistry?: TestModelRegistry; readonly progress?: AgentProgress; readonly cwd?: string; readonly semaphore?: Semaphore; diff --git a/tests/agent-runner-workspace-tools.test.ts b/tests/agent-runner-workspace-tools.test.ts index 93b1d76..182f216 100644 --- a/tests/agent-runner-workspace-tools.test.ts +++ b/tests/agent-runner-workspace-tools.test.ts @@ -5,7 +5,7 @@ import { join } from "node:path"; import { test } from "bun:test"; import { Type } from "typebox"; import type { ExtensionToolContext, ToolInfo } from "@earendil-works/pi-coding-agent"; -import type { HostToolBridge } from "../.pi/extensions/pi-workflow-engine/src/host-tools.ts"; +import { WorkflowHostToolUnavailableError, type HostToolBridge } from "../.pi/extensions/pi-workflow-engine/src/host-tools.ts"; import { isRecord } from "../.pi/extensions/pi-workflow-engine/src/guards.ts"; import type { CreateAgentSession } from "../.pi/extensions/pi-workflow-engine/src/agent-runner.ts"; import { @@ -624,7 +624,7 @@ test("external-search agents reach host MCP research tools through the host brid assert.deepEqual(proxyResult, { content: [{ type: "text", text: "host mcp__parallel__web_search" }], details: undefined }); }); -test("an allowlisted MCP tool is bridged, its host failures stay failures, and runs without a bridge say why it is missing", async () => { +test("an allowlisted MCP tool is bridged, its host failures stay failures, and an unreachable one fails the agent", async () => { const bridge = fakeHostTools([createToolInfo("mcp__linear__broken", "Create a Linear issue", MCP_SOURCE)]); let proxy: ToolDefinitionLike | undefined; const createSession: CreateAgentSession = async (options) => { @@ -636,13 +636,21 @@ test("an allowlisted MCP tool is bridged, its host failures stay failures, and r assert.ok(isRecord(failed)); assert.equal(failed.isError, true); - const progress = createProgress(); - let proxiedWithoutBridge: readonly string[] = []; - const unbridgedSession: CreateAgentSession = async (options) => { - proxiedWithoutBridge = (options.customTools ?? []).map((tool) => tool.name); + // A named tool the run cannot reach fails the agent before any session starts, like requireToolHints. + let sessions = 0; + const countingSession: CreateAgentSession = async () => { + sessions += 1; return createTextSession(); }; - await runAgent(createRunContext({ createSession: unbridgedSession, progress }), "file it", { label: "filer", tools: ["read", "mcp__linear__broken"] }); - assert.deepEqual(proxiedWithoutBridge, []); - assert.ok(progress.events.some((event) => /MCP tools reach subagents only in runs started by the workflow tool; unavailable: mcp__linear__broken/.test(event))); + await assert.rejects( + () => runAgent(createRunContext({ createSession: countingSession }), "file it", { tools: ["read", "mcp__linear__broken"] }), + (error: unknown) => + error instanceof WorkflowHostToolUnavailableError && + /MCP tools reach subagents only in runs started by the workflow tool; unavailable: mcp__linear__broken/.test(error.message), + ); + await assert.rejects( + () => runAgent({ ...createRunContext({ createSession: countingSession }), hostTools: fakeHostTools([]) }, "file it", { tools: ["read", "read_mcp_resource"] }), + /Host MCP tools not available: read_mcp_resource/, + ); + assert.equal(sessions, 0); }); diff --git a/tests/classify.test.ts b/tests/classify.test.ts index ef7b559..cf91ee3 100644 --- a/tests/classify.test.ts +++ b/tests/classify.test.ts @@ -40,6 +40,8 @@ function classifierRun(input: { readonly available?: readonly ClassifierModelRef[]; readonly result?: ClassifierResult; readonly budget?: number; + readonly agentTimeoutMs?: number; + readonly classify?: ClassifierRegistry["classify"]; } = {}): { rc: RunContext; usage: ReturnType; classified: ClassifierModelRef[] } { const classified: ClassifierModelRef[] = []; const registry: ClassifierRegistry = { @@ -49,15 +51,15 @@ function classifierRun(input: { }, getModelOfType: ((_type: string, provider: string, id: string) => [JEV, CLEF].find((model) => model.provider === provider && model.id === id)) as ClassifierRegistry["getModelOfType"], - async classify(model) { + async classify(model, context, options) { classified.push(model); - return input.result ?? classifierResult(); + return input.classify ? await input.classify(model, context, options) : input.result ?? classifierResult(); }, }; const usage = createWorkflowUsageRecorder(); const createSession: CreateAgentSession = async () => createTextSession(); const rc: RunContext = { - ...createRunContext({ createSession, usage, budget: createBudget(input.budget ?? null, usage) }), + ...createRunContext({ createSession, usage, budget: createBudget(input.budget ?? null, usage), agentTimeoutMs: input.agentTimeoutMs }), createSession, modelRegistry: registry, }; @@ -103,3 +105,15 @@ test("a failed classifier call still counts its usage, and a spent budget stops await assert.rejects(() => runClassifier(spent.rc, CONTEXT), WorkflowBudgetExceededError); assert.equal(spent.classified.length, 1); }); + +test("a stalled classifier gives its concurrency slot back at the per-agent timeout", async () => { + const stalled = classifierRun({ + agentTimeoutMs: 20, + classify: (_model, _context, options) => + new Promise((resolve) => { + options?.signal?.addEventListener("abort", () => resolve(classifierResult({ stopReason: "aborted", answers: {} })), { once: true }); + }), + }); + + await assert.rejects(() => runClassifier(stalled.rc, CONTEXT, { label: "slow" }), /slow: classifier timed out after 20ms/); +}); diff --git a/tests/dynamax.test.ts b/tests/dynamax.test.ts index 2b0432a..f8342a9 100644 --- a/tests/dynamax.test.ts +++ b/tests/dynamax.test.ts @@ -682,6 +682,19 @@ test("one-shot Dynamax status remains visible for the active turn", async () => assert.equal(ui.statuses.get(DYNAMAX_STATUS_KEY), undefined); }); +test("Dynamax appends to a prompt an earlier extension forced, since pi renders no sections then", async () => { + const captured = captureDynamax("ctrl+shift+x"); + const input = captured.handlers.get("input")?.[0]; + const beforeAgentStart = captured.handlers.get("before_agent_start")?.[0]; + if (!input || !beforeAgentStart) throw new Error("expected Dynamax lifecycle handlers"); + const { ctx } = createFakeContext("forced-prompt"); + + await input({ source: "interactive", text: "dynamax review this" }, ctx); + const result = await beforeAgentStart({ systemPrompt: "Forced.", systemPromptOptions: { sections: {}, forceSystemPrompt: "Forced." } }, ctx); + + assert.deepEqual(result, { systemPrompt: `Forced.\n\n<${DYNAMAX_SECTION}>\n${DYNAMAX_REMINDER}\n` }); +}); + test("updateDynamaxSurfaces clears inactive UI", () => { const runtime = createDynamaxRuntime(); const { ctx, ui } = createFakeContext("session-a"); diff --git a/tests/workflow-tool.test.ts b/tests/workflow-tool.test.ts index 46617e5..c99fbe7 100644 --- a/tests/workflow-tool.test.ts +++ b/tests/workflow-tool.test.ts @@ -396,6 +396,10 @@ test("inline compile errors are shaped for workflow tool results", () => { assert.equal(result.isError, true); }); +test("the workflow tool's guidelines carry the profile rule the Dynamax reminder no longer repeats", () => { + assert.match(captureWorkflowTool().promptGuidelines?.join("\n") ?? "", /profile: \\?"small\\?".*every `api\.agent\(\)` call/); +}); + test("the workflow tool is a model-only orchestrator that codemode scripts cannot start", () => { assert.equal(captureWorkflowTool().exposure, "model-only"); }); @@ -524,7 +528,12 @@ export default async function run({ phase, log }) { } `; - await assert.rejects(() => extension.tool.execute("call-failing-inspect", { script }, undefined, () => {}, ctx), /boom/); + // A failed run comes back as a failed call, so the usage it spent can still reach the session. + const failed = await extension.tool.execute("call-failing-inspect", { script }, undefined, () => {}, ctx); + assert.ok(isRecord(failed)); + assert.equal(failed.isError, true); + assert.deepEqual(failed.details, { error: "workflow_failed", name: "failing-inspect-probe", message: "boom" }); + assert.match(JSON.stringify(failed.content), /Workflow failing-inspect-probe failed: boom/); await inspector.handler("", ctx); const inspection = notifications.at(-1) ?? ""; @@ -562,7 +571,9 @@ export default async function run({ phase }) { } `; - await assert.rejects(() => extension.tool.execute("call-failed-state", { script }, undefined, () => {}, ctx), /boom/); + const failed = await extension.tool.execute("call-failed-state", { script }, undefined, () => {}, ctx); + assert.ok(isRecord(failed)); + assert.equal(failed.isError, true); await inspector.handler("", ctx as ExtensionCommandContext); assert.match(customRenders().at(-1)?.[0] ?? "", /failed-state-probe.*✗ failed/); From d3594757a35b445c1795bd2e31ffcaac1ebe1566 Mon Sep 17 00:00:00 2001 From: timbrinded <79199034+timbrinded@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:24:51 +0100 Subject: [PATCH 09/11] refactor(classify): accept model preference lists; move model-ref parsing out of agent-session api.classify({ model: [...] }) uses the first listed classifier with working credentials. parseAgentModelRef moves to its own module so classifier code no longer depends on the session bridge (raised by review runs on this branch). --- .../pi-workflow-engine/src/agent-session.ts | 19 +------------------ .../pi-workflow-engine/src/classify.ts | 15 +++++++++++++-- .../pi-workflow-engine/src/model-ref.ts | 19 +++++++++++++++++++ .../pi-workflow-engine/src/types.ts | 7 +++++-- tests/classify.test.ts | 8 ++++++++ 5 files changed, 46 insertions(+), 22 deletions(-) create mode 100644 .pi/extensions/pi-workflow-engine/src/model-ref.ts diff --git a/.pi/extensions/pi-workflow-engine/src/agent-session.ts b/.pi/extensions/pi-workflow-engine/src/agent-session.ts index 34ad905..4265226 100644 --- a/.pi/extensions/pi-workflow-engine/src/agent-session.ts +++ b/.pi/extensions/pi-workflow-engine/src/agent-session.ts @@ -27,6 +27,7 @@ import { providerErrorFromMessages } from "./agent-retry.ts"; import { synchronizeWorkflowModelRuntime } from "./agent-session-providers.ts"; import { hostToolProxy, isMcpToolName, WorkflowHostToolUnavailableError, type HostToolBridge } from "./host-tools.ts"; import { matchesAgentToolHint, WorkflowToolHintUnavailableError } from "./tool-capabilities.ts"; +import { parseAgentModelRef } from "./model-ref.ts"; import { truncateText } from "./text.ts"; import { WORKFLOW_TOOL_NAME, type AgentToolHint } from "./types.ts"; @@ -208,24 +209,6 @@ export async function promptAgentSession(input: { } } -export function parseAgentModelRef(modelRef: string): { readonly provider: string; readonly id: string } { - const normalized = modelRef.trim(); - if (normalized.length === 0) { - throw new Error('Invalid agent model ref: expected a bare model id or "provider/id".'); - } - if (normalized !== modelRef) { - throw new Error(`Invalid agent model ref "${modelRef}": remove leading or trailing whitespace.`); - } - - const slash = modelRef.indexOf("/"); - if (slash === -1) return { provider: "anthropic", id: modelRef }; - const provider = modelRef.slice(0, slash); - const id = modelRef.slice(slash + 1); - if (provider.length === 0 || id.length === 0 || id.startsWith("/")) { - throw new Error(`Invalid agent model ref "${modelRef}": expected "provider/id".`); - } - return { provider, id }; -} interface AgentSessionResources { readonly selectedSkills: readonly Skill[]; diff --git a/.pi/extensions/pi-workflow-engine/src/classify.ts b/.pi/extensions/pi-workflow-engine/src/classify.ts index 9cc3e2c..9fdf277 100644 --- a/.pi/extensions/pi-workflow-engine/src/classify.ts +++ b/.pi/extensions/pi-workflow-engine/src/classify.ts @@ -1,7 +1,7 @@ import type { ClassifierAnswer, ClassifierApi, ClassifierContext, ClassifierModel } from "@earendil-works/pi-ai"; import type { ModelRegistry } from "@earendil-works/pi-coding-agent"; import type { RunContext } from "./agent-runner-types.ts"; -import { parseAgentModelRef } from "./agent-session.ts"; +import { parseAgentModelRef } from "./model-ref.ts"; import { assertWorkflowBudgetAvailable } from "./budget.ts"; import { throwIfAborted } from "./cancellation.ts"; import type { ClassifyOptions } from "./types.ts"; @@ -64,7 +64,18 @@ export async function runClassifier( }, { signal: rc.signal }); } -async function resolveClassifierModel(registry: ClassifierRegistry, ref: string | undefined): Promise> { +async function resolveClassifierModel( + registry: ClassifierRegistry, + ref: string | readonly string[] | undefined, +): Promise> { + if (ref !== undefined && typeof ref !== "string") { + const available = await registry.getAvailableOfType("classifier"); + const preferred = ref.map((candidate) => parseAgentModelRef(candidate)) + .map(({ provider, id }) => available.find((model) => model.provider === provider && model.id === id)) + .find((model) => model !== undefined); + if (!preferred) throw new WorkflowClassifierUnavailableError(`None of the classifier models ${ref.join(", ")} has working credentials.`); + return preferred; + } if (ref === undefined) { const [available] = await registry.getAvailableOfType("classifier"); if (!available) { diff --git a/.pi/extensions/pi-workflow-engine/src/model-ref.ts b/.pi/extensions/pi-workflow-engine/src/model-ref.ts new file mode 100644 index 0000000..e2b3afe --- /dev/null +++ b/.pi/extensions/pi-workflow-engine/src/model-ref.ts @@ -0,0 +1,19 @@ +/** Parse a model ref: a bare id is Anthropic shorthand, otherwise "provider/id". Malformed refs throw. */ +export function parseAgentModelRef(modelRef: string): { readonly provider: string; readonly id: string } { + const normalized = modelRef.trim(); + if (normalized.length === 0) { + throw new Error('Invalid agent model ref: expected a bare model id or "provider/id".'); + } + if (normalized !== modelRef) { + throw new Error(`Invalid agent model ref "${modelRef}": remove leading or trailing whitespace.`); + } + + const slash = modelRef.indexOf("/"); + if (slash === -1) return { provider: "anthropic", id: modelRef }; + const provider = modelRef.slice(0, slash); + const id = modelRef.slice(slash + 1); + if (provider.length === 0 || id.length === 0 || id.startsWith("/")) { + throw new Error(`Invalid agent model ref "${modelRef}": expected "provider/id".`); + } + return { provider, id }; +} diff --git a/.pi/extensions/pi-workflow-engine/src/types.ts b/.pi/extensions/pi-workflow-engine/src/types.ts index bde2983..a96a356 100644 --- a/.pi/extensions/pi-workflow-engine/src/types.ts +++ b/.pi/extensions/pi-workflow-engine/src/types.ts @@ -191,8 +191,11 @@ export interface AgentOptions { /** Options for `api.classify()`. */ export interface ClassifyOptions { - /** Classifier model as "provider/id". Default: the first classifier model with working credentials. */ - model?: string; + /** + * Classifier model as "provider/id", or refs in preference order of which the first with working + * credentials is used. Default: the first classifier model with working credentials. + */ + model?: string | readonly string[]; /** Label for usage reporting. Default: `classify:/`. */ label?: string; /** Phase to attribute usage to. Default: the current phase. */ diff --git a/tests/classify.test.ts b/tests/classify.test.ts index cf91ee3..bccf181 100644 --- a/tests/classify.test.ts +++ b/tests/classify.test.ts @@ -117,3 +117,11 @@ test("a stalled classifier gives its concurrency slot back at the per-agent time await assert.rejects(() => runClassifier(stalled.rc, CONTEXT, { label: "slow" }), /slow: classifier timed out after 20ms/); }); + +test("a model preference list uses the first ref with working credentials", async () => { + const run = classifierRun({ available: [CLEF] }); + await runClassifier(run.rc, CONTEXT, { model: ["typesafe/jev-latest", "cloudflare-workers-ai/@cf/cloudflare/clef"] }); + assert.deepEqual(run.classified, [CLEF]); + + await assert.rejects(() => runClassifier(classifierRun({ available: [] }).rc, CONTEXT, { model: ["typesafe/jev-latest"] }), WorkflowClassifierUnavailableError); +}); From 6c2ca3d9e858c1f5318634ac6d731145344c89e4 Mon Sep 17 00:00:00 2001 From: timbrinded <79199034+timbrinded@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:24:52 +0100 Subject: [PATCH 10/11] feat(advisory): merge candidates that describe the same defect before verification When a TypeSafe Jev classifier is reachable, the shared lens pipeline asks it whether each same-file pair of candidates is one defect and merges pairs at >= 0.8, keeping every source id, location, impact, and piece of evidence. Without Jev credentials it skips after one probe call. Measured with GPT-6.1 Sol on two code-review benchmarks with planted bugs (42 runs): verifier agents and cost fell ~40% (29 -> 18 agents, $0.60 -> $0.37; 22 -> 14 agents, $1.74 -> $0.89), and in every instrumented run each planted bug a finder proposed reached the final report. diagnose kept the true root cause ranked first in all runs. Codemode for finders and a classifier triage gate were also measured and left out: no recall gain, and no cost gain on top of the merge. --- .../src/advisory-evidence.ts | 15 ++-- .../src/workflow-advisory-utils.ts | 90 ++++++++++++++++++- USAGE.md | 16 ++++ tests/advisory-merge.test.ts | 49 ++++++++++ tests/advisory-scheduling.test.ts | 9 +- 5 files changed, 169 insertions(+), 10 deletions(-) create mode 100644 tests/advisory-merge.test.ts diff --git a/.pi/extensions/pi-workflow-engine/src/advisory-evidence.ts b/.pi/extensions/pi-workflow-engine/src/advisory-evidence.ts index 99c67b7..1d4077c 100644 --- a/.pi/extensions/pi-workflow-engine/src/advisory-evidence.ts +++ b/.pi/extensions/pi-workflow-engine/src/advisory-evidence.ts @@ -49,16 +49,19 @@ export function dedupeCandidates(candidat const key = candidateDedupKey(candidate); const previous = seen.get(key); if (!previous) seen.set(key, { ...candidate }); - else { - previous.sourceCandidateIds = [...new Set([...previous.sourceCandidateIds, ...candidate.sourceCandidateIds])]; - previous.locations = uniqueLocations([...previous.locations, ...candidate.locations]); - previous.impact = [...new Set([previous.impact, candidate.impact])].join("\n"); - previous.discoveryEvidence = [...new Set([...(previous.discoveryEvidence ?? []), ...(candidate.discoveryEvidence ?? [])])]; - } + else mergeCandidateInto(previous, candidate); } return [...seen.values()]; } +/** Fold a duplicate into the kept candidate, keeping every source id, location, impact, and piece of evidence. */ +export function mergeCandidateInto(kept: T, duplicate: T): void { + kept.sourceCandidateIds = [...new Set([...kept.sourceCandidateIds, ...duplicate.sourceCandidateIds])]; + kept.locations = uniqueLocations([...kept.locations, ...duplicate.locations]); + kept.impact = [...new Set([kept.impact, duplicate.impact])].join("\n"); + kept.discoveryEvidence = [...new Set([...(kept.discoveryEvidence ?? []), ...(duplicate.discoveryEvidence ?? [])])]; +} + export function uniqueLocations(locations: AdvisoryCandidate["locations"]): AdvisoryCandidate["locations"] { return [...new Map(locations.map((location) => [JSON.stringify(location), location])).values()]; } diff --git a/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts b/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts index 68543e1..6c31407 100644 --- a/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +++ b/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts @@ -1,5 +1,6 @@ import { AdvisoryCandidatesSchema, AdvisoryVerdictSchema, formatAdvisoryLocation, type AdvisoryCandidate, type AdvisoryFinding, type IdentifiedAdvisoryCandidate, type AdvisoryLocation, type AdvisoryReport, type AdvisoryVerdict } from "./advisory-schema.ts"; -import { AdvisorySynthesisSchema, SYNTHESIS_ID_INSTRUCTIONS, withAdvisoryCoverage, collectAdvisoryStage, dedupeCandidates, identifyCandidates, uniqueLocations, type AdvisoryStageCoverage, type AdvisorySynthesis } from "./advisory-evidence.ts"; +import { AdvisorySynthesisSchema, SYNTHESIS_ID_INSTRUCTIONS, withAdvisoryCoverage, collectAdvisoryStage, dedupeCandidates, identifyCandidates, mergeCandidateInto, uniqueLocations, type AdvisoryStageCoverage, type AdvisorySynthesis } from "./advisory-evidence.ts"; +import { WorkflowClassifierUnavailableError } from "./classify.ts"; import type { AgentOptions, WorkflowApi, WorkflowProgressEvent, WorkflowRunStats } from "./types.ts"; @@ -49,7 +50,7 @@ export function publishVerifiedKeptProgress( } export interface LensVerificationPipelineOptions { - api: Pick; + api: Pick; lenses: readonly AdvisoryLens[]; perLens: number; finderPhase?: "Find" | "Hypothesize"; @@ -90,7 +91,7 @@ export async function runLensVerificationPipeline( } return candidates; } })), coverage); - const candidates = dedupeCandidates(found.flat()); + const candidates = await mergeEquivalentCandidates(api, dedupeCandidates(found.flat())); const dropped = rawCandidates - candidates.length; if (dropped > 0) { api.progress({ type: "counter_delta", key: "dropped", label: "dropped", delta: dropped }); @@ -112,6 +113,89 @@ export async function runLensVerificationPipeline( return { verified, rawCandidates, dropped, coverage }; } +/** TypeSafe Jev routes in preference order. Jev is the classifier the duplicate merge was measured with. */ +export const DUPLICATE_CANDIDATE_CLASSIFIERS = [ + "typesafe/jev-latest", + "openrouter/~typesafe/jev-latest", + "openrouter/typesafe/jev-1.13", + "cloudflare-workers-ai/typesafe/jev", + "vercel-ai-gateway/typesafe-ai/jev", + "opencode/jev-1.13", + "opencode/jev-1.13-free", +] as const; + +/** Probability above which two candidates count as one defect. */ +const SAME_DEFECT_PROBABILITY = 0.8; + +/** + * Merge same-file candidates that a classifier judges to be one defect, so each defect gets one verifier. + * Lenses often report the same bug in different words; on GPT-6.1 Sol code reviews this cut verifier + * agents and cost by about 40% without losing a finding. Runs without a Jev classifier skip it. + */ +export async function mergeEquivalentCandidates( + api: Pick, + candidates: readonly T[], +): Promise { + const pairs: Array = []; + for (let first = 0; first < candidates.length; first++) { + for (let second = first + 1; second < candidates.length; second++) { + if (primaryLocation(candidates[first]!).file === primaryLocation(candidates[second]!).file) pairs.push([first, second]); + } + } + if (pairs.length === 0) return [...candidates]; + const sameDefect = async ([first, second]: readonly [number, number]): Promise => { + const answers = await api.classify({ + state: { first: candidateForClassifier(candidates[first]!), second: candidateForClassifier(candidates[second]!) }, + questions: { + same: { + type: "bool", + instructions: "Two code-review candidates were raised against the same file. Do they describe the same underlying defect (same root cause and same fix), even if worded differently?", + criteria: { true: "Same underlying defect", false: "Different defects" }, + }, + }, + }, { model: DUPLICATE_CANDIDATE_CLASSIFIERS, label: "dedup" }); + return answers.same?.type === "bool" ? answers.same.probability : 0; + }; + // The first call doubles as the availability probe, so a run without Jev makes one call, not one per pair. + let firstProbability: number; + try { + firstProbability = await sameDefect(pairs[0]!); + } catch (error) { + if (error instanceof WorkflowClassifierUnavailableError) return [...candidates]; + firstProbability = 0; + } + const rest = await api.parallel(pairs.slice(1).map((pair) => () => sameDefect(pair))); + const probabilities = [firstProbability, ...rest.map((probability) => probability ?? 0)]; + + // Union-find over confident pairs; the earliest candidate of each group absorbs the others. + const parent = candidates.map((_, index) => index); + const root = (index: number): number => (parent[index] === index ? index : (parent[index] = root(parent[index]!))); + pairs.forEach(([first, second], index) => { + if ((probabilities[index] ?? 0) < SAME_DEFECT_PROBABILITY) return; + const [keep, fold] = [root(first), root(second)].sort((a, b) => a - b) as [number, number]; + if (keep !== fold) parent[fold] = keep; + }); + const groups = new Map(); + candidates.forEach((candidate, index) => { + const group = groups.get(root(index)); + if (group) mergeCandidateInto(group, candidate); + else groups.set(root(index), { ...candidate }); + }); + const merged = candidates.length - groups.size; + if (merged > 0) api.log(`Merged ${merged} candidate(s) describing the same defect before verification`); + return [...groups.values()]; +} + +function candidateForClassifier(candidate: IdentifiedAdvisoryCandidate) { + return { + category: candidate.category, + summary: candidate.summary, + impact: candidate.impact, + location: formatLocation(candidate), + evidence: (candidate.discoveryEvidence ?? []).slice(0, 4), + }; +} + /** Stats for a lens review that stopped before discovery. */ export const EMPTY_LENS_REVIEW_STATS: Readonly = Object.freeze({ files: 0, candidates: 0, verified: 0, kept: 0, dropped: 0, refuted: 0 }); diff --git a/USAGE.md b/USAGE.md index 7b2bc77..dbc7050 100644 --- a/USAGE.md +++ b/USAGE.md @@ -601,6 +601,22 @@ fails, the result retains verified records and reports incomplete coverage. Synthesis selects or merges IDs; the workflow reconstructs evidence-bearing fields from those records. +### Duplicate candidates + +Lenses often report the same defect in different words. When a TypeSafe Jev +classifier is reachable (TypeSafe directly, OpenRouter, Cloudflare Workers AI, +Vercel AI Gateway, or OpenCode Zen), the four advisory workflows ask it whether +each pair of candidates in the same file describes one defect, and merge pairs +it is at least 80% sure of before verification, so each defect gets one +verifier. Merged candidates keep all of their source IDs, locations, impacts, +and evidence. Without Jev credentials the merge is skipped after one probe call. + +Measured on GPT-6.1 Sol code reviews with planted bugs, the merge cut verifier +agents and cost by about 40% (e.g. 29 → 18 agents, $0.60 → $0.37 list price on +a small diff; 22 → 14 agents, $1.74 → $0.89 on a 160 KB diff). In every +instrumented run, each planted bug that a finder proposed reached the final +report, with or without merging. + ### Selective adversarial challenges Add `--challenge` to an advisory workflow to challenge up to three uncertain or diff --git a/tests/advisory-merge.test.ts b/tests/advisory-merge.test.ts new file mode 100644 index 0000000..f8b0ea2 --- /dev/null +++ b/tests/advisory-merge.test.ts @@ -0,0 +1,49 @@ +import assert from "node:assert/strict"; +import { test } from "bun:test"; +import type { IdentifiedAdvisoryCandidate } from "../.pi/extensions/pi-workflow-engine/src/advisory-schema.ts"; +import { WorkflowClassifierUnavailableError } from "../.pi/extensions/pi-workflow-engine/src/classify.ts"; +import { bindParallel } from "../.pi/extensions/pi-workflow-engine/src/concurrency.ts"; +import type { WorkflowApi } from "../.pi/extensions/pi-workflow-engine/src/types.ts"; +import { DUPLICATE_CANDIDATE_CLASSIFIERS, mergeEquivalentCandidates } from "../.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts"; + +function candidate(id: string, file: string, summary: string): IdentifiedAdvisoryCandidate { + return { candidateId: id, sourceCandidateIds: [id], category: "bug", summary, impact: `impact of ${id}`, locations: [{ file, line: 1 }], discoveryEvidence: [`evidence ${id}`] }; +} + +test("same-file candidates the classifier calls one defect are verified once, keeping every source", async () => { + const asked: Array<{ pair: string; model: unknown }> = []; + const classify: WorkflowApi["classify"] = async (context, opts) => { + const pair = [context.state.first, context.state.second].map((side) => (side as { summary: string }).summary).join(" | "); + asked.push({ pair, model: opts?.model }); + return { same: { type: "bool", probability: pair === "off by one | extra item per page" ? 0.95 : 0.3 } }; + }; + const logs: string[] = []; + + const merged = await mergeEquivalentCandidates({ classify, parallel: bindParallel({}), log: (line) => logs.push(line) }, [ + candidate("a", "src/orders.ts", "off by one"), + candidate("b", "src/orders.ts", "missing await"), + candidate("c", "src/orders.ts", "extra item per page"), + candidate("d", "src/auth.ts", "off by one"), + ]); + + assert.deepEqual(merged.map((item) => item.sourceCandidateIds), [["a", "c"], ["b"], ["d"]]); + assert.deepEqual(merged[0]?.discoveryEvidence, ["evidence a", "evidence c"]); + // Only the three same-file pairs are compared, each against the measured Jev routes. + assert.equal(asked.length, 3); + assert.ok(asked.every((call) => call.model === DUPLICATE_CANDIDATE_CLASSIFIERS)); + assert.deepEqual(logs, ["Merged 1 candidate(s) describing the same defect before verification"]); +}); + +test("without a Jev classifier the merge makes one probe call and keeps every candidate", async () => { + let calls = 0; + const classify: WorkflowApi["classify"] = async () => { + calls += 1; + throw new WorkflowClassifierUnavailableError("none"); + }; + const candidates = [candidate("a", "src/x.ts", "one"), candidate("b", "src/x.ts", "two"), candidate("c", "src/x.ts", "three")]; + + const merged = await mergeEquivalentCandidates({ classify, parallel: bindParallel({}), log: () => {} }, candidates); + + assert.deepEqual(merged, candidates); + assert.equal(calls, 1); +}); diff --git a/tests/advisory-scheduling.test.ts b/tests/advisory-scheduling.test.ts index 657cf59..e3af295 100644 --- a/tests/advisory-scheduling.test.ts +++ b/tests/advisory-scheduling.test.ts @@ -4,6 +4,12 @@ import type { AdvisoryCandidate } from "../.pi/extensions/pi-workflow-engine/src import { bindParallel } from "../.pi/extensions/pi-workflow-engine/src/concurrency.ts"; import { runLensVerificationPipeline, type AdvisoryLens } from "../.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts"; import type { AgentOptions, WorkflowApi } from "../.pi/extensions/pi-workflow-engine/src/types.ts"; +import { WorkflowClassifierUnavailableError } from "../.pi/extensions/pi-workflow-engine/src/classify.ts"; + +/** A host without a classifier: the pipeline skips merging equivalent candidates. */ +const noClassifier: WorkflowApi["classify"] = async () => { + throw new WorkflowClassifierUnavailableError("no classifier in this test"); +}; const lenses: AdvisoryLens[] = [ { label: "alpha", category: "bug", text: "alpha lens" }, @@ -25,6 +31,7 @@ test("finder-barrier scheduling starts all finders before verifiers", async () = const result = await runLensVerificationPipeline({ api: { agent, + classify: noClassifier, parallel: bindParallel({ limit: 10 }), phase() {}, progress() {}, @@ -63,7 +70,7 @@ test("verifier output cannot replace discovery identities or evidence fields", a }; }) as WorkflowApi["agent"]; const result = await runLensVerificationPipeline({ - api: { agent, parallel: bindParallel({}), phase() {}, progress() {}, log() {} }, + api: { agent, classify: noClassifier, parallel: bindParallel({}), phase() {}, progress() {}, log() {} }, lenses: lenses.slice(0, 1), perLens: 1, finderPrompt: () => "find", verifierPrompt: () => "verify", }); From 9edd70bf4d31550f05c21c71e3d2005b358dd296 Mon Sep 17 00:00:00 2001 From: timbrinded <79199034+timbrinded@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:35:59 +0100 Subject: [PATCH 11/11] fix(advisory): address review of the duplicate-candidate merge - Complete linkage: a candidate joins a group only when it matches every member, so a vague candidate cannot chain two distinct defects together. - Folded wordings stay visible to the verifier as "Also reported as" evidence. - Any failed probe (bad key, rate limit) skips the merge after one call, with a log line; aborts still propagate. - Pair candidates by their changed-line anchor and repository-relative path. - Resolve each classifier route once per run instead of once per call, and log how many pairs are compared. Re-measured with GPT-6.1 Sol: 8/8 at 17 agents ($0.33) on the small benchmark, 7/8 at 15 agents ($1.10) on the large one. --- .../src/agent-runner-types.ts | 4 +- .../pi-workflow-engine/src/classify.ts | 16 ++- .../pi-workflow-engine/src/engine.ts | 1 + .../src/workflow-advisory-utils.ts | 65 +++++++---- USAGE.md | 11 +- tests/advisory-merge.test.ts | 104 ++++++++++++++---- tests/classify.test.ts | 17 ++- 7 files changed, 163 insertions(+), 55 deletions(-) diff --git a/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts b/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts index 532d7d0..98fc360 100644 --- a/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +++ b/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts @@ -1,4 +1,4 @@ -import type { Api, Model } from "@earendil-works/pi-ai"; +import type { Api, ClassifierApi, ClassifierModel, Model } from "@earendil-works/pi-ai"; import type { AgentSession, CreateAgentSessionOptions, @@ -66,6 +66,8 @@ interface RunContextBase { worktrees: WorktreeRegistry; /** Host MCP tools subagents may call; set only for a synchronous `workflow` tool run. */ hostTools?: HostToolBridge; + /** Classifier routes resolved this run, so repeated `api.classify()` calls skip the credential lookup. */ + classifierRoutes?: Map>>; } /** Shared per-run context threaded into every agent() call. */ diff --git a/.pi/extensions/pi-workflow-engine/src/classify.ts b/.pi/extensions/pi-workflow-engine/src/classify.ts index 9fdf277..8dda4da 100644 --- a/.pi/extensions/pi-workflow-engine/src/classify.ts +++ b/.pi/extensions/pi-workflow-engine/src/classify.ts @@ -41,7 +41,7 @@ export async function runClassifier( const registry = rc.modelRegistry; return await rc.semaphore.run(async () => { assertWorkflowBudgetAvailable(rc.budget); - const model = await resolveClassifierModel(registry, opts.model); + const model = await cachedClassifierModel(rc, registry, opts.model); const label = opts.label ?? `classify:${model.provider}/${model.id}`; // A stalled classifier must not hold a concurrency slot past the limit agents get. const timeout = AbortSignal.timeout(rc.agentTimeoutMs); @@ -64,6 +64,20 @@ export async function runClassifier( }, { signal: rc.signal }); } +/** One credential lookup per route and run; a route that failed to resolve keeps failing for the run. */ +function cachedClassifierModel( + rc: RunContext, + registry: ClassifierRegistry, + ref: string | readonly string[] | undefined, +): Promise> { + const key = JSON.stringify(ref ?? null); + const cached = rc.classifierRoutes?.get(key); + if (cached) return cached; + const resolved = resolveClassifierModel(registry, ref); + rc.classifierRoutes?.set(key, resolved); + return resolved; +} + async function resolveClassifierModel( registry: ClassifierRegistry, ref: string | readonly string[] | undefined, diff --git a/.pi/extensions/pi-workflow-engine/src/engine.ts b/.pi/extensions/pi-workflow-engine/src/engine.ts index 705559c..a4c9bd3 100644 --- a/.pi/extensions/pi-workflow-engine/src/engine.ts +++ b/.pi/extensions/pi-workflow-engine/src/engine.ts @@ -179,6 +179,7 @@ export async function runResolvedWorkflow( journal, worktrees, hostTools: resolvedOptions.hostTools, + classifierRoutes: new Map(), }; // perf.total_ms wraps the whole tree: sub-workflows run inside this span via api.workflow(). diff --git a/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts b/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts index 6c31407..43490ec 100644 --- a/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +++ b/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts @@ -1,6 +1,8 @@ import { AdvisoryCandidatesSchema, AdvisoryVerdictSchema, formatAdvisoryLocation, type AdvisoryCandidate, type AdvisoryFinding, type IdentifiedAdvisoryCandidate, type AdvisoryLocation, type AdvisoryReport, type AdvisoryVerdict } from "./advisory-schema.ts"; -import { AdvisorySynthesisSchema, SYNTHESIS_ID_INSTRUCTIONS, withAdvisoryCoverage, collectAdvisoryStage, dedupeCandidates, identifyCandidates, mergeCandidateInto, uniqueLocations, type AdvisoryStageCoverage, type AdvisorySynthesis } from "./advisory-evidence.ts"; +import { AdvisorySynthesisSchema, SYNTHESIS_ID_INSTRUCTIONS, withAdvisoryCoverage, collectAdvisoryStage, dedupeCandidates, identifyCandidates, mergeCandidateInto, repoPathCandidates, uniqueLocations, type AdvisoryStageCoverage, type AdvisorySynthesis } from "./advisory-evidence.ts"; +import { isFatalWorkflowError } from "./cancellation.ts"; import { WorkflowClassifierUnavailableError } from "./classify.ts"; +import { unknownErrorMessage } from "./unknown-error.ts"; import type { AgentOptions, WorkflowApi, WorkflowProgressEvent, WorkflowRunStats } from "./types.ts"; @@ -130,16 +132,17 @@ const SAME_DEFECT_PROBABILITY = 0.8; /** * Merge same-file candidates that a classifier judges to be one defect, so each defect gets one verifier. * Lenses often report the same bug in different words; on GPT-6.1 Sol code reviews this cut verifier - * agents and cost by about 40% without losing a finding. Runs without a Jev classifier skip it. + * agents and cost by about 40% without losing a finding. Runs without a reachable Jev classifier skip it. */ export async function mergeEquivalentCandidates( api: Pick, candidates: readonly T[], ): Promise { + const files = candidates.map(candidateFile); const pairs: Array = []; for (let first = 0; first < candidates.length; first++) { for (let second = first + 1; second < candidates.length; second++) { - if (primaryLocation(candidates[first]!).file === primaryLocation(candidates[second]!).file) pairs.push([first, second]); + if (files[first] === files[second]) pairs.push([first, second]); } } if (pairs.length === 0) return [...candidates]; @@ -156,34 +159,50 @@ export async function mergeEquivalentCandidates 1) api.log(`Comparing ${pairs.length} same-file candidate pairs for duplicates`); const rest = await api.parallel(pairs.slice(1).map((pair) => () => sameDefect(pair))); - const probabilities = [firstProbability, ...rest.map((probability) => probability ?? 0)]; - - // Union-find over confident pairs; the earliest candidate of each group absorbs the others. - const parent = candidates.map((_, index) => index); - const root = (index: number): number => (parent[index] === index ? index : (parent[index] = root(parent[index]!))); - pairs.forEach(([first, second], index) => { - if ((probabilities[index] ?? 0) < SAME_DEFECT_PROBABILITY) return; - const [keep, fold] = [root(first), root(second)].sort((a, b) => a - b) as [number, number]; - if (keep !== fold) parent[fold] = keep; + const probability = new Map(); + pairs.forEach(([first, second], index) => probability.set(`${first}:${second}`, index === 0 ? firstProbability : rest[index - 1] ?? 0)); + + // Complete linkage: a candidate joins a group only when it matches every member, so one vague candidate + // that resembles two different defects cannot chain them together. + const groups: number[][] = []; + candidates.forEach((_, index) => { + const group = groups.find((members) => + members.every((member) => (probability.get(`${member}:${index}`) ?? 0) >= SAME_DEFECT_PROBABILITY)); + if (group) group.push(index); + else groups.push([index]); }); - const groups = new Map(); - candidates.forEach((candidate, index) => { - const group = groups.get(root(index)); - if (group) mergeCandidateInto(group, candidate); - else groups.set(root(index), { ...candidate }); + const merged = groups.map(([keep, ...folded]) => { + const kept = { ...candidates[keep!]! }; + for (const index of folded) { + const duplicate = candidates[index]!; + mergeCandidateInto(kept, duplicate); + // The kept summary leads; keep folded wording visible to the verifier in case the classifier was wrong. + if (duplicate.summary !== kept.summary) { + kept.discoveryEvidence = [...new Set([...(kept.discoveryEvidence ?? []), `Also reported as: ${duplicate.summary}`])]; + } + } + return kept; }); - const merged = candidates.length - groups.size; - if (merged > 0) api.log(`Merged ${merged} candidate(s) describing the same defect before verification`); - return [...groups.values()]; + const folded = candidates.length - merged.length; + if (folded > 0) api.log(`Merged ${folded} candidate(s) describing the same defect before verification`); + return merged; +} + +/** Pair by the changed line when there is one, with the repository-relative path exact dedup uses. */ +function candidateFile(candidate: IdentifiedAdvisoryCandidate): string { + return repoPathCandidates((candidate.reviewAnchor ?? primaryLocation(candidate)).file)[1]; } function candidateForClassifier(candidate: IdentifiedAdvisoryCandidate) { diff --git a/USAGE.md b/USAGE.md index dbc7050..ce4ce52 100644 --- a/USAGE.md +++ b/USAGE.md @@ -606,10 +606,13 @@ fields from those records. Lenses often report the same defect in different words. When a TypeSafe Jev classifier is reachable (TypeSafe directly, OpenRouter, Cloudflare Workers AI, Vercel AI Gateway, or OpenCode Zen), the four advisory workflows ask it whether -each pair of candidates in the same file describes one defect, and merge pairs -it is at least 80% sure of before verification, so each defect gets one -verifier. Merged candidates keep all of their source IDs, locations, impacts, -and evidence. Without Jev credentials the merge is skipped after one probe call. +each pair of candidates in the same file describes one defect. Before +verification, a candidate joins a group only when the classifier is at least 80% +sure it matches every member, so each defect gets one verifier. Merged +candidates keep all of their source IDs, locations, impacts, and evidence, and +the verifier sees the other wordings as "Also reported as" evidence. If the +first comparison fails (no Jev credentials, a bad key, a rate limit), the merge +is skipped. Measured on GPT-6.1 Sol code reviews with planted bugs, the merge cut verifier agents and cost by about 40% (e.g. 29 → 18 agents, $0.60 → $0.37 list price on diff --git a/tests/advisory-merge.test.ts b/tests/advisory-merge.test.ts index f8b0ea2..07f9171 100644 --- a/tests/advisory-merge.test.ts +++ b/tests/advisory-merge.test.ts @@ -1,49 +1,105 @@ import assert from "node:assert/strict"; import { test } from "bun:test"; import type { IdentifiedAdvisoryCandidate } from "../.pi/extensions/pi-workflow-engine/src/advisory-schema.ts"; -import { WorkflowClassifierUnavailableError } from "../.pi/extensions/pi-workflow-engine/src/classify.ts"; +import { WorkflowAbortError } from "../.pi/extensions/pi-workflow-engine/src/cancellation.ts"; +import { WorkflowClassifierError, WorkflowClassifierUnavailableError } from "../.pi/extensions/pi-workflow-engine/src/classify.ts"; import { bindParallel } from "../.pi/extensions/pi-workflow-engine/src/concurrency.ts"; import type { WorkflowApi } from "../.pi/extensions/pi-workflow-engine/src/types.ts"; import { DUPLICATE_CANDIDATE_CLASSIFIERS, mergeEquivalentCandidates } from "../.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts"; -function candidate(id: string, file: string, summary: string): IdentifiedAdvisoryCandidate { - return { candidateId: id, sourceCandidateIds: [id], category: "bug", summary, impact: `impact of ${id}`, locations: [{ file, line: 1 }], discoveryEvidence: [`evidence ${id}`] }; +function candidate(id: string, file: string, summary: string, anchor?: string): IdentifiedAdvisoryCandidate { + return { + candidateId: id, sourceCandidateIds: [id], category: "bug", summary, impact: `impact of ${id}`, + locations: [{ file, line: 1 }], discoveryEvidence: [`evidence ${id}`], + ...(anchor ? { reviewAnchor: { file: anchor, line: 2 } } : {}), + }; } -test("same-file candidates the classifier calls one defect are verified once, keeping every source", async () => { - const asked: Array<{ pair: string; model: unknown }> = []; - const classify: WorkflowApi["classify"] = async (context, opts) => { +/** A classifier that answers "same defect" with the probability listed for that pair of summaries. */ +function classifierFor(same: Record, calls: Array<{ pair: string; model: unknown }> = []): WorkflowApi["classify"] { + return async (context, opts) => { const pair = [context.state.first, context.state.second].map((side) => (side as { summary: string }).summary).join(" | "); - asked.push({ pair, model: opts?.model }); - return { same: { type: "bool", probability: pair === "off by one | extra item per page" ? 0.95 : 0.3 } }; + calls.push({ pair, model: opts?.model }); + return { same: { type: "bool", probability: same[pair] ?? 0.1 } }; }; +} + +const run = (classify: WorkflowApi["classify"], candidates: IdentifiedAdvisoryCandidate[], logs: string[] = []) => + mergeEquivalentCandidates({ classify, parallel: bindParallel({}), log: (line) => logs.push(line) }, candidates); + +test("same-file candidates the classifier calls one defect are verified once, keeping every source and wording", async () => { + const calls: Array<{ pair: string; model: unknown }> = []; const logs: string[] = []; - const merged = await mergeEquivalentCandidates({ classify, parallel: bindParallel({}), log: (line) => logs.push(line) }, [ + const merged = await run(classifierFor({ "off by one | extra item per page": 0.95 }, calls), [ candidate("a", "src/orders.ts", "off by one"), candidate("b", "src/orders.ts", "missing await"), candidate("c", "src/orders.ts", "extra item per page"), candidate("d", "src/auth.ts", "off by one"), - ]); + ], logs); assert.deepEqual(merged.map((item) => item.sourceCandidateIds), [["a", "c"], ["b"], ["d"]]); - assert.deepEqual(merged[0]?.discoveryEvidence, ["evidence a", "evidence c"]); + assert.deepEqual(merged[0]?.discoveryEvidence, ["evidence a", "evidence c", "Also reported as: extra item per page"]); // Only the three same-file pairs are compared, each against the measured Jev routes. - assert.equal(asked.length, 3); - assert.ok(asked.every((call) => call.model === DUPLICATE_CANDIDATE_CLASSIFIERS)); - assert.deepEqual(logs, ["Merged 1 candidate(s) describing the same defect before verification"]); + assert.equal(calls.length, 3); + assert.ok(calls.every((call) => call.model === DUPLICATE_CANDIDATE_CLASSIFIERS)); + assert.deepEqual(logs, [ + "Comparing 3 same-file candidate pairs for duplicates", + "Merged 1 candidate(s) describing the same defect before verification", + ]); }); -test("without a Jev classifier the merge makes one probe call and keeps every candidate", async () => { - let calls = 0; - const classify: WorkflowApi["classify"] = async () => { - calls += 1; - throw new WorkflowClassifierUnavailableError("none"); - }; - const candidates = [candidate("a", "src/x.ts", "one"), candidate("b", "src/x.ts", "two"), candidate("c", "src/x.ts", "three")]; +test("a vague candidate that resembles two different defects cannot chain them into one", async () => { + const merged = await run(classifierFor({ + "missing await | error handling is wrong": 0.9, + "error handling is wrong | off by one": 0.9, + "missing await | off by one": 0.1, + }), [ + candidate("a", "src/orders.ts", "missing await"), + candidate("b", "src/orders.ts", "error handling is wrong"), + candidate("c", "src/orders.ts", "off by one"), + ]); - const merged = await mergeEquivalentCandidates({ classify, parallel: bindParallel({}), log: () => {} }, candidates); + assert.deepEqual(merged.map((item) => item.sourceCandidateIds), [["a", "b"], ["c"]]); +}); + +test("pairs use the changed-line anchor and the repository-relative path", async () => { + const calls: Array<{ pair: string; model: unknown }> = []; + await run(classifierFor({}, calls), [ + candidate("a", "./src/orders.ts", "one"), + candidate("b", "a/src/orders.ts", "two"), + candidate("c", "src/caller.ts", "three", "src/orders.ts"), + candidate("d", "src/orders.ts", "four", "src/other.ts"), + ]); + + assert.deepEqual(calls.map((call) => call.pair).sort(), ["one | three", "one | two", "two | three"]); +}); + +test("a failed probe skips the merge after one call, quietly when no Jev route is configured", async () => { + for (const [error, logged] of [ + [new WorkflowClassifierUnavailableError("none"), []], + [new WorkflowClassifierError("dedup: classifier error: 401 unauthorized"), ["Skipped merging duplicate candidates: dedup: classifier error: 401 unauthorized"]], + ] as const) { + let calls = 0; + const logs: string[] = []; + const candidates = [candidate("a", "src/x.ts", "one"), candidate("b", "src/x.ts", "two"), candidate("c", "src/x.ts", "three")]; + + const merged = await run(async () => { + calls += 1; + throw error; + }, candidates, logs); + + assert.deepEqual(merged, candidates); + assert.equal(calls, 1); + assert.deepEqual(logs, logged); + } +}); - assert.deepEqual(merged, candidates); - assert.equal(calls, 1); +test("an aborted run is not mistaken for a missing classifier", async () => { + await assert.rejects( + () => run(async () => { + throw new WorkflowAbortError("stopped"); + }, [candidate("a", "src/x.ts", "one"), candidate("b", "src/x.ts", "two")]), + WorkflowAbortError, + ); }); diff --git a/tests/classify.test.ts b/tests/classify.test.ts index bccf181..442a529 100644 --- a/tests/classify.test.ts +++ b/tests/classify.test.ts @@ -42,11 +42,13 @@ function classifierRun(input: { readonly budget?: number; readonly agentTimeoutMs?: number; readonly classify?: ClassifierRegistry["classify"]; -} = {}): { rc: RunContext; usage: ReturnType; classified: ClassifierModelRef[] } { +} = {}): { rc: RunContext; usage: ReturnType; classified: ClassifierModelRef[]; lookups: () => number } { const classified: ClassifierModelRef[] = []; + let lookups = 0; const registry: ClassifierRegistry = { find: () => undefined, async getAvailableOfType() { + lookups += 1; return (input.available ?? [JEV]) as never; }, getModelOfType: ((_type: string, provider: string, id: string) => @@ -62,8 +64,9 @@ function classifierRun(input: { ...createRunContext({ createSession, usage, budget: createBudget(input.budget ?? null, usage), agentTimeoutMs: input.agentTimeoutMs }), createSession, modelRegistry: registry, + classifierRoutes: new Map(), }; - return { rc, usage, classified }; + return { rc, usage, classified, lookups: () => lookups }; } test("classify uses the first credentialed classifier and records its usage like an agent", async () => { @@ -125,3 +128,13 @@ test("a model preference list uses the first ref with working credentials", asyn await assert.rejects(() => runClassifier(classifierRun({ available: [] }).rc, CONTEXT, { model: ["typesafe/jev-latest"] }), WorkflowClassifierUnavailableError); }); + +test("a run resolves each classifier route once, however many calls use it", async () => { + const run = classifierRun(); + const route = ["typesafe/jev-latest", "openrouter/~typesafe/jev-latest"]; + await Promise.all([runClassifier(run.rc, CONTEXT, { model: route }), runClassifier(run.rc, CONTEXT, { model: route }), runClassifier(run.rc, CONTEXT)]); + + assert.equal(run.classified.length, 3); + // One lookup for the preference list and one for the default route. + assert.equal(run.lookups(), 2); +});