From 979f1a78101688656910f852405c7e967964ece7 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Sun, 9 Aug 2026 16:26:49 -0600 Subject: [PATCH 1/3] chore(ci): repin the packed cohort to the published set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cohort job packs three sibling repos at pinned refs and installs them beside the registry's sandbox, which floats inside the peer range. Those refs sat at interface 0.43.1 / eval 0.144.4 / knowledge 7.0.11 while sandbox moved to 0.19.3, and 0.19.3 binds its BACKEND_TYPE table to the canonical harness enum at import time — an enum that only gained 'prime' in interface 0.45. Every PR in the repo has been carrying a red check for a mismatch none of them introduced. Repinned to the set actually published today: interface 0.46.1, eval 0.144.6, knowledge 7.1.2. Both the CI job and the publish gate use the same three refs, so a release is verified against the cohort a consumer would really install. --- .github/workflows/ci.yml | 6 +++--- .github/workflows/publish.yml | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 799f5763..d938c795 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -81,21 +81,21 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: tangle-network/agent-sdk - ref: 36b8e3ecc57e9a62130eb2b0fdc1c3b5be01b338 # @tangle-network/agent-interface@0.43.1 + ref: 36a60d83e6eceb7d6e53cb67fb0936bda1cd1374 # @tangle-network/agent-interface@0.46.1 path: .cohort/agent-sdk persist-credentials: false - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: tangle-network/agent-eval - ref: cd4c3195fec79c79976ce2fbe43f307107544798 # v0.144.4 + ref: 73f473aea35a073719bdba04edbea29c1e7e3976 # v0.144.6 path: .cohort/agent-eval persist-credentials: false - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: tangle-network/agent-knowledge - ref: b5c82eb9a508b13a32e14353a867c197cda517b8 # v7.0.11 + ref: f3fbb2bc231ad45d654c0d90ea8a74f2825c58ba # v7.1.2 path: .cohort/agent-knowledge persist-credentials: false diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 1704deb8..ea7a691b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -135,7 +135,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: tangle-network/agent-sdk - ref: 36b8e3ecc57e9a62130eb2b0fdc1c3b5be01b338 # @tangle-network/agent-interface@0.43.1 + ref: 36a60d83e6eceb7d6e53cb67fb0936bda1cd1374 # @tangle-network/agent-interface@0.46.1 path: .cohort/agent-sdk persist-credentials: false @@ -143,7 +143,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: tangle-network/agent-eval - ref: cd4c3195fec79c79976ce2fbe43f307107544798 # v0.144.4 + ref: 73f473aea35a073719bdba04edbea29c1e7e3976 # v0.144.6 path: .cohort/agent-eval persist-credentials: false @@ -151,7 +151,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: tangle-network/agent-knowledge - ref: b5c82eb9a508b13a32e14353a867c197cda517b8 # v7.0.11 + ref: f3fbb2bc231ad45d654c0d90ea8a74f2825c58ba # v7.1.2 path: .cohort/agent-knowledge persist-credentials: false From cfef9411841d9f37d6fabe699ec4cb20b7975956 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Sun, 9 Aug 2026 16:29:33 -0600 Subject: [PATCH 2/3] =?UTF-8?q?chore(deps):=20move=20to=20the=20published?= =?UTF-8?q?=20cohort=20=E2=80=94=20interface=200.46.1,=20eval=200.144.6,?= =?UTF-8?q?=20knowledge=207.1.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Includes the mechanical half of the interface 0.44-0.46 migration: the profile capability's `systemPrompt` is no longer a boolean but a { replace, append } pair, and `prime` joined the closed BackendType union. Does NOT include the candidate-execution half; see the PR body. --- examples/coding-benchmark/profiles.ts | 3 + package.json | 6 +- pnpm-lock.yaml | 115 ++++++++++-------- pnpm-workspace.yaml | 12 +- src/runtime/environment-provider.test.ts | 2 +- src/runtime/environment-provider.ts | 5 +- .../tangle-sandbox-exact-process-provider.ts | 4 +- 7 files changed, 86 insertions(+), 61 deletions(-) diff --git a/examples/coding-benchmark/profiles.ts b/examples/coding-benchmark/profiles.ts index e5a069f4..2650db66 100644 --- a/examples/coding-benchmark/profiles.ts +++ b/examples/coding-benchmark/profiles.ts @@ -40,6 +40,9 @@ const harnessModel: Record = { 'cli-base': process.env.CLI_BASE_MODEL ?? 'openai/gpt-4.1-2025-04-14', // unreached by this example, but BackendType is a closed union — name them all 'kimi-code': 'moonshot/kimi-k2-2025-07-11', + // `prime` joined the canonical harness enum in interface 0.45; BackendType is a closed union, + // so the table names it even though this example never routes there. + prime: 'openai/gpt-5-codex-2025-09-15', amp: 'anthropic/claude-sonnet-4-5-2025-09-29', 'factory-droids': 'anthropic/claude-sonnet-4-5-2025-09-29', pi: 'openai/gpt-4.1-2025-04-14', diff --git a/package.json b/package.json index 7b872a3b..5b0d1d18 100644 --- a/package.json +++ b/package.json @@ -169,9 +169,9 @@ "license": "MIT", "packageManager": "pnpm@11.17.0", "peerDependencies": { - "@tangle-network/agent-eval": ">=0.144.4 <0.145.0", - "@tangle-network/agent-interface": ">=0.43.1 <0.44.0", - "@tangle-network/sandbox": ">=0.19.1 <0.20.0" + "@tangle-network/agent-eval": ">=0.144.6 <0.145.0", + "@tangle-network/agent-interface": ">=0.46.1 <0.47.0", + "@tangle-network/sandbox": ">=0.19.3 <0.20.0" }, "peerDependenciesMeta": { "@tangle-network/sandbox": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 831a8987..4f8120bb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,26 +10,26 @@ catalogs: specifier: 0.18.5 version: 0.18.5 '@tangle-network/agent-core': - specifier: 0.5.0 - version: 0.5.0 + specifier: 0.5.4 + version: 0.5.4 '@tangle-network/agent-eval': - specifier: 0.144.4 - version: 0.144.4 + specifier: 0.144.6 + version: 0.144.6 '@tangle-network/agent-interface': - specifier: 0.43.1 - version: 0.43.1 + specifier: 0.46.1 + version: 0.46.1 '@tangle-network/agent-knowledge': - specifier: 7.0.11 - version: 7.0.11 + specifier: 7.1.2 + version: 7.1.2 '@tangle-network/agent-profile-materialize': - specifier: 0.10.2 - version: 0.10.2 + specifier: 0.12.0 + version: 0.12.0 '@tangle-network/agent-trace-contract': specifier: ^1.0.2 version: 1.0.2 '@tangle-network/sandbox': - specifier: 0.19.1 - version: 0.19.1 + specifier: 0.19.3 + version: 0.19.3 '@types/node': specifier: 26.1.1 version: 26.1.1 @@ -52,13 +52,13 @@ importers: dependencies: '@tangle-network/agent-core': specifier: 'catalog:' - version: 0.5.0 + version: 0.5.4 '@tangle-network/agent-knowledge': specifier: 'catalog:' - version: 7.0.11 + version: 7.1.2 '@tangle-network/agent-profile-materialize': specifier: 'catalog:' - version: 0.10.2(@tangle-network/agent-interface@0.43.1) + version: 0.12.0(@tangle-network/agent-interface@0.46.1) '@tangle-network/agent-trace-contract': specifier: 'catalog:' version: 1.0.2 @@ -74,13 +74,13 @@ importers: version: 2.5.5 '@tangle-network/agent-eval': specifier: 'catalog:' - version: 0.144.4 + version: 0.144.6 '@tangle-network/agent-interface': specifier: 'catalog:' - version: 0.43.1 + version: 0.46.1 '@tangle-network/sandbox': specifier: 'catalog:' - version: 0.19.1(viem@2.54.6(typescript@6.0.3)(zod@4.4.3)) + version: 0.19.3(viem@2.54.6(typescript@6.0.3)(zod@4.4.3)) '@types/node': specifier: 'catalog:' version: 26.1.1 @@ -122,19 +122,19 @@ importers: dependencies: '@tangle-network/agent-eval': specifier: 'catalog:' - version: 0.144.4 + version: 0.144.6 '@tangle-network/agent-interface': specifier: 'catalog:' - version: 0.43.1 + version: 0.46.1 '@tangle-network/agent-knowledge': specifier: 'catalog:' - version: 7.0.11 + version: 7.1.2 '@tangle-network/agent-runtime': specifier: workspace:* version: link:.. '@tangle-network/sandbox': specifier: 'catalog:' - version: 0.19.1(viem@2.54.6(typescript@6.0.3)(zod@4.4.3)) + version: 0.19.3(viem@2.54.6(typescript@6.0.3)(zod@4.4.3)) devDependencies: '@arethetypeswrong/cli': specifier: 'catalog:' @@ -1108,32 +1108,38 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@tangle-network/agent-core@0.5.0': - resolution: {integrity: sha512-xjsTAZbtF0eXiUxzzNib4j49gYUhEDF7SCYhD8bkUeIJ2O8rfa0GGl7y3egi8SvsbKRhjbIojL+4mcmERbgUzw==} + '@tangle-network/agent-core@0.5.2': + resolution: {integrity: sha512-2zfr680Ay0feUR5ZBjdk0nezqkqneXuXluL+RPZxWX+DKKVhheDXhwITOaDxFcUgpKyQxZmE5/VEfTDWER+KjQ==} - '@tangle-network/agent-eval@0.144.4': - resolution: {integrity: sha512-tR9U3K+5sBp8cCP/YllnSJ3WCQkkdgJhAlvC06DxrxgJVjVIT0HDjJaMXWcTYXKi+2Fk0Pv0f2PW5NSD171gzQ==} + '@tangle-network/agent-core@0.5.4': + resolution: {integrity: sha512-k6gYv3BlagkfuWrGyTJH6mKUBgsLY6TXxizACqt0QF8a1/5uqy0UYc6R2Wo9nqQVJuaRxDnoiRf8YtsRVqA75g==} + + '@tangle-network/agent-eval@0.144.6': + resolution: {integrity: sha512-qYw5DjkYgNF79iTZUXCv0mv0Y0K1VyZbq9UHy7vLJRSchnaxqZWKWnaWEh0DYKwxc7pgdrPIqMMcF90HIxCmZA==} engines: {node: '>=20'} hasBin: true - '@tangle-network/agent-interface@0.43.1': - resolution: {integrity: sha512-nrJol7sGmLLp60S0dBk+QejIB/TXvOOVNjq/OCb23K0oAWURCFyaTWA0J8q2V/pJwxalOPDWovqptMgkcF6q1A==} + '@tangle-network/agent-interface@0.45.0': + resolution: {integrity: sha512-VCDI+ta79cTzQbUaos6v2rBMMvuQ/Ojt2VPGkdvfAcrYubP0JVD8qRvx76pViBwh6VS+truPgeU4VvGRI6kipg==} + + '@tangle-network/agent-interface@0.46.1': + resolution: {integrity: sha512-6a3GRkDxS+r6Bmlu8y6LQpiVE20oCYPzE2opb5o+AZeDzRW5KemyxreyYIprjgKfDrvBTMo0tgvC2JB51Sl84w==} - '@tangle-network/agent-knowledge@7.0.11': - resolution: {integrity: sha512-/vJk5aSFaUo18s3ucprmhtZWgmHA/d/LzoQi6+iulov6f/TOEoo/elK898dhwLkmK1AyzxwVHvjueq/xqiZrsg==} + '@tangle-network/agent-knowledge@7.1.2': + resolution: {integrity: sha512-VU2SODtRH5BIJzOT8QW7Q3/hToUjcAPsCUBSDRdZ/wcTldakfGWeChP+OTcDZ1dnwF3cYtlJNDwoliUT9vNTXw==} engines: {node: '>=20.19.0'} hasBin: true - '@tangle-network/agent-profile-materialize@0.10.2': - resolution: {integrity: sha512-u3MtUy8BD5odTWu/nK0b2XuieBSZMMx1kR9izwqIoR+c8pQaIRQiENNms9qeyQFLqqm7ewC1/Qq5oTtuihs/5g==} + '@tangle-network/agent-profile-materialize@0.12.0': + resolution: {integrity: sha512-SfTgqm4Q4HnuHcnjRJ//IrBTuiB8Hbk/Jx4sEKa9PxYL9v0Pc5yCKiSuc5YKEFEcM3NKckZbuc5L6zaq9pFELg==} peerDependencies: - '@tangle-network/agent-interface': '>=0.38.0 <0.44.0' + '@tangle-network/agent-interface': '>=0.45.0 <0.46.0' '@tangle-network/agent-trace-contract@1.0.2': resolution: {integrity: sha512-v7uMh56jkEp4vckevEU9xKsIatbs5dqzGPp69dFLSSXUVit0RP6VD6EANMXVlTCUk+6wVKBLHJx23XspVCEiIA==} - '@tangle-network/sandbox@0.19.1': - resolution: {integrity: sha512-SuSl+HbX8B5aX0TF0QYDNSAN+huFwl04Aypt5+pYay4TMUEqvDGrNOhuSFLUatAvPQQi1Z35oZcboH+PlOatsw==} + '@tangle-network/sandbox@0.19.3': + resolution: {integrity: sha512-cUfqoGfm+EigTLFHFV2qfnXlAetmbgXWNhKUB7qOV9x5HgUKP2jpUWbKLT0+vJsFmj4eCqE9kaIkzp4/Z8T6zg==} peerDependencies: '@mastra/core': ^1.36.0 '@modelcontextprotocol/sdk': ^1.29.0 @@ -2781,46 +2787,57 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@tangle-network/agent-core@0.5.0': + '@tangle-network/agent-core@0.5.2': + dependencies: + '@tangle-network/agent-interface': 0.45.0 + zod: 4.4.3 + + '@tangle-network/agent-core@0.5.4': dependencies: - '@tangle-network/agent-interface': 0.43.1 + '@tangle-network/agent-interface': 0.46.1 zod: 4.4.3 - '@tangle-network/agent-eval@0.144.4': + '@tangle-network/agent-eval@0.144.6': dependencies: '@asteasolutions/zod-to-openapi': 9.1.0(zod@4.4.3) '@hono/node-server': 2.0.12(hono@4.12.32) - '@tangle-network/agent-core': 0.5.0 - '@tangle-network/agent-interface': 0.43.1 + '@tangle-network/agent-core': 0.5.4 + '@tangle-network/agent-interface': 0.46.1 '@tangle-network/agent-trace-contract': 1.0.2 hono: 4.12.32 linear-sum-assignment: 1.0.9 re2js: 2.8.6 zod: 4.4.3 - '@tangle-network/agent-interface@0.43.1': + '@tangle-network/agent-interface@0.45.0': + dependencies: + '@noble/hashes': 1.8.0 + spdx-expression-parse: 5.0.0 + zod: 4.4.3 + + '@tangle-network/agent-interface@0.46.1': dependencies: '@noble/hashes': 1.8.0 spdx-expression-parse: 5.0.0 zod: 4.4.3 - '@tangle-network/agent-knowledge@7.0.11': + '@tangle-network/agent-knowledge@7.1.2': dependencies: - '@tangle-network/agent-eval': 0.144.4 - '@tangle-network/agent-interface': 0.43.1 + '@tangle-network/agent-eval': 0.144.6 + '@tangle-network/agent-interface': 0.46.1 proper-lockfile: 4.1.2 zod: 4.4.3 - '@tangle-network/agent-profile-materialize@0.10.2(@tangle-network/agent-interface@0.43.1)': + '@tangle-network/agent-profile-materialize@0.12.0(@tangle-network/agent-interface@0.46.1)': dependencies: - '@tangle-network/agent-interface': 0.43.1 + '@tangle-network/agent-interface': 0.46.1 '@tangle-network/agent-trace-contract@1.0.2': {} - '@tangle-network/sandbox@0.19.1(viem@2.54.6(typescript@6.0.3)(zod@4.4.3))': + '@tangle-network/sandbox@0.19.3(viem@2.54.6(typescript@6.0.3)(zod@4.4.3))': dependencies: - '@tangle-network/agent-core': 0.5.0 - '@tangle-network/agent-interface': 0.43.1 + '@tangle-network/agent-core': 0.5.2 + '@tangle-network/agent-interface': 0.45.0 zod: 4.4.3 optionalDependencies: viem: 2.54.6(typescript@6.0.3)(zod@4.4.3) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 4db9433f..f1611c6d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -17,14 +17,14 @@ allowBuilds: catalog: '@arethetypeswrong/cli': 0.18.5 - '@tangle-network/agent-core': 0.5.0 + '@tangle-network/agent-core': 0.5.4 '@types/node': 26.1.1 - '@tangle-network/agent-eval': 0.144.4 - '@tangle-network/agent-interface': 0.43.1 - '@tangle-network/agent-knowledge': 7.0.11 - '@tangle-network/agent-profile-materialize': 0.10.2 + '@tangle-network/agent-eval': 0.144.6 + '@tangle-network/agent-interface': 0.46.1 + '@tangle-network/agent-knowledge': 7.1.2 + '@tangle-network/agent-profile-materialize': 0.12.0 '@tangle-network/agent-trace-contract': ^1.0.2 - '@tangle-network/sandbox': 0.19.1 + '@tangle-network/sandbox': 0.19.3 publint: 0.3.22 tsdown: 0.22.14 tsx: 4.23.1 diff --git a/src/runtime/environment-provider.test.ts b/src/runtime/environment-provider.test.ts index 9d3c55c6..f1c02b03 100644 --- a/src/runtime/environment-provider.test.ts +++ b/src/runtime/environment-provider.test.ts @@ -1215,7 +1215,7 @@ function fakeCapabilities() { return { profile: { namedProfiles: true, - systemPrompt: true, + systemPrompt: { replace: true, append: true }, instructions: true, tools: true, permissions: true, diff --git a/src/runtime/environment-provider.ts b/src/runtime/environment-provider.ts index 1ab08772..42eaf8d5 100644 --- a/src/runtime/environment-provider.ts +++ b/src/runtime/environment-provider.ts @@ -1329,7 +1329,10 @@ function defaultTangleSandboxCapabilities(options: { return { profile: { namedProfiles: options.namedProfiles, - systemPrompt: true, + // Interface 0.44 split this from one boolean into the two things a backend can actually do + // with a caller's system prompt: REPLACE the harness's own, or APPEND to it. The sandbox + // materializes the whole profile, so it honors both. + systemPrompt: { replace: true, append: true }, instructions: true, tools: true, permissions: true, diff --git a/src/runtime/tangle-sandbox-exact-process-provider.ts b/src/runtime/tangle-sandbox-exact-process-provider.ts index 2aae309b..a3ee64dc 100644 --- a/src/runtime/tangle-sandbox-exact-process-provider.ts +++ b/src/runtime/tangle-sandbox-exact-process-provider.ts @@ -441,7 +441,9 @@ function exactProcessOnlyCapabilities(): AgentEnvironmentCapabilities { return { profile: { namedProfiles: false, - systemPrompt: false, + // Exact-process runs a command as given and interprets no part of a profile, so neither + // spelling is honored and a profile carrying one is refused rather than silently dropped. + systemPrompt: { replace: false, append: false }, instructions: false, tools: false, permissions: false, From 90d4a3f5d545187e80593f923403851a29ab0f9d Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Sun, 9 Aug 2026 18:51:41 -0600 Subject: [PATCH 3/3] fix(candidate-execution): re-key the system-prompt guard on the delivery that actually ships MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit agent-profile-materialize 0.12 moved codex's replacement prompt from an inline flag this repo projected to a delivery the materializer lowers itself: the prompt bytes land in the digested plan at .codex/system-prompt.md and the flags carry `-c model_instructions_file=…`, cwd-relative so the reference survives docker and jail path remapping. The prompt is NOT dropped — proven by executing the materializer: file bytes present, ownership 'materializer', flag exact. What the dep bump DID silently change: this repo's launch guard keyed on `plan.systemPrompt`, which the materializer no longer sets for codex, so the `undefined` early-return skipped every check and two refusal tests began resolving. The hazard the guard exists for is unchanged — the flag is only meaningful to the native binary's argument parser; handed to an arbitrary entrypoint or a wrapper it is inert argv, and the sealed candidate would claim an active prompt that never applied. Delivery detection now keys on the lowered flag itself, so the same three refusals (candidate-entrypoint, non-native executable, shadowing caller args) hold for materializer-lowered delivery, and the conflict check learned the new key: a caller `--config=model_instructions_file=…` would re-point codex's instructions at foreign bytes after the plan's flag. opencode: the materializer now refuses a candidate system prompt outright — its only replacement control binds to the agent the launcher selects, which a sealed plan cannot guarantee. That replaces this repo's opencode.json mutation (which patched both built-in agents and hoped one was selected) with an actionable fail-closed refusal; the dead projection is deleted and the refusal is pinned by a test. The capability returns via a binds-aware candidate materializer upstream, not a local workaround. Tests now pin BOTH halves of codex delivery — the exact flag pair AND the exact prompt bytes in the digested plan — plus the new shadow key, the opencode refusal, and the 30th materialization axis (appendSystemPrompt) interface 0.44 added. --- docs/api/primitive-catalog.md | 4 +- src/candidate-execution/system-prompt.ts | 216 +++++++++------------- tests/candidate-execution-prepare.test.ts | 72 +++++--- tests/exact-process-candidate.test.ts | 2 +- tests/profile-materialization.test.ts | 3 +- 5 files changed, 145 insertions(+), 152 deletions(-) diff --git a/docs/api/primitive-catalog.md b/docs/api/primitive-catalog.md index 0031ae1b..ac51d06b 100644 --- a/docs/api/primitive-catalog.md +++ b/docs/api/primitive-catalog.md @@ -7,7 +7,7 @@ # Primitive catalog — the never-stale anti-reinvention inventory -> **GENERATED** from `@tangle-network/agent-runtime@0.130.0` and `@tangle-network/agent-eval@0.144.4` by `scripts/gen-primitive-catalog.mjs`. Do NOT hand-edit — run `pnpm run docs:api`. This is the mechanical companion to the JUDGMENT in `canonical-api.md` (§2 decision table + §1.5 AgentProfile law): that doc says WHICH primitive to reach for and what NOT to build; this catalog proves WHAT exists. Per-symbol signatures + `file:line` live in the per-module pages under `docs/api/`. +> **GENERATED** from `@tangle-network/agent-runtime@0.130.0` and `@tangle-network/agent-eval@0.144.6` by `scripts/gen-primitive-catalog.mjs`. Do NOT hand-edit — run `pnpm run docs:api`. This is the mechanical companion to the JUDGMENT in `canonical-api.md` (§2 decision table + §1.5 AgentProfile law): that doc says WHICH primitive to reach for and what NOT to build; this catalog proves WHAT exists. Per-symbol signatures + `file:line` live in the per-module pages under `docs/api/`. ## 1. agent-runtime — own public surface @@ -275,7 +275,7 @@ Import from `@tangle-network/agent-runtime/agent` — 48 exports. | `unimplementedAgentRun` | function | Stub for agents whose `runtime.act` is not yet wired to the substrate's | | `validateProfileMaterialization` | function | Return every changed profile axis that the selected run path would drop. | | `validateSurfaces` | function | Validate an `AgentSurfaces` map on disk — missing paths fail loud at `defineAgent` time instead of silently skipping self-improvement edits. | -| `AGENT_PROFILE_MATERIALIZATION_AXES` | const | The 29 canonical AgentProfile leaves that can affect one execution. | +| `AGENT_PROFILE_MATERIALIZATION_AXES` | const | The 30 canonical AgentProfile leaves that can affect one execution. | | `controlProfileMaterialization` | const | Materialization contract for a raw process path that carries only control/identity fields. | | `fullProfileMaterialization` | const | Materialization contract for a run path that executes every canonical AgentProfile leaf. | | `promptControlProfileMaterialization` | const | Materialization contract for an injected inference function whose surrounding driver still | diff --git a/src/candidate-execution/system-prompt.ts b/src/candidate-execution/system-prompt.ts index 4648f9ec..1422c217 100644 --- a/src/candidate-execution/system-prompt.ts +++ b/src/candidate-execution/system-prompt.ts @@ -7,21 +7,21 @@ import type { import type { AgentCandidateWorkspacePlan, HarnessId, - PlanFile, } from '@tangle-network/agent-profile-materialize' const SYSTEM_PROMPT_FILE = '.tangle/system-prompt.md' /** - * How ONE harness expresses a replacement system prompt natively. Every difference between - * harnesses lives in a row: the executable that must be on the command line, the projection onto - * that harness's own control, and the launch arguments that would silently shadow the projection. + * How ONE harness expresses a replacement system prompt natively, for the harnesses whose plans + * still arrive with `plan.systemPrompt` set — the materializer delegates the lowering to the + * launcher for these (claude-code and pi both take a prompt-file flag on their own argv). * - * The differences here are REAL — codex takes a TOML config override, claude-code and pi take - * prompt-file flags with different spellings, opencode has no flag at all and needs a mutated - * `opencode.json`. Adding harness N+1 is one entry; a harness with NO entry is refused by - * {@link projectCandidateSystemPrompt} rather than launched with an unprojected prompt, so the - * conflict check inherits the same fail-closed default instead of returning early. + * codex and opencode are deliberately NOT rows here anymore. agent-profile-materialize 0.12 lowers + * codex's prompt itself — the bytes land in the plan at `.codex/system-prompt.md` and the flags + * carry `-c model_instructions_file=…` — and refuses opencode outright (its only replacement + * control binds to the agent selected at launch, which a workspace plan cannot guarantee). A plan + * whose `systemPrompt` is set for a harness with no row is refused rather than launched with an + * unprojected prompt. */ interface HarnessSystemPrompt { /** The native binary the launch must run for this projection to be provable. */ @@ -45,7 +45,15 @@ function argsSetSystemPromptFlag(values: readonly string[]): boolean { ) } -function argsSetCodexDeveloperInstructions(values: readonly string[]): boolean { +/** + * Codex config keys that decide the request's instructions. `model_instructions_file` replaces the + * whole instructions field (the materializer's own delivery key); `developer_instructions` injects + * developer-channel text beside it. A caller argv setting either would shadow or contaminate the + * profile's sealed prompt, so both refuse. + */ +const CODEX_INSTRUCTION_KEYS = ['model_instructions_file', 'developer_instructions'] as const + +function argsSetCodexInstructionOverride(values: readonly string[]): boolean { for (let index = 0; index < values.length; index++) { const value = values[index]! const config = @@ -54,7 +62,12 @@ function argsSetCodexDeveloperInstructions(values: readonly string[]): boolean { : value.startsWith('--config=') ? value.slice('--config='.length) : undefined - if (config?.trimStart().startsWith('developer_instructions=')) return true + if ( + config !== undefined && + CODEX_INSTRUCTION_KEYS.some((key) => config.trimStart().startsWith(`${key}=`)) + ) { + return true + } } return false } @@ -66,22 +79,6 @@ const HARNESS_SYSTEM_PROMPTS = { appendFlags(addSystemPromptFile(plan, systemPrompt), '--system-prompt-file', path), conflictsWithArgs: argsSetSystemPromptFlag, }, - codex: { - executable: 'codex', - project: (plan, systemPrompt) => - appendFlags(plan, '-c', `developer_instructions=${tomlString(systemPrompt)}`), - conflictsWithArgs: argsSetCodexDeveloperInstructions, - }, - opencode: { - executable: 'opencode', - project: (plan, systemPrompt) => ({ - ...plan, - files: projectOpenCodeSystemPrompt(plan.files, systemPrompt), - }), - // `opencode run` takes no system-prompt flag; the prompt lives in `opencode.json`, whose - // conflicts `projectOpenCodeSystemPrompt` rejects at the file level. - conflictsWithArgs: () => false, - }, pi: { executable: 'pi', project: (plan, systemPrompt, path) => @@ -90,6 +87,33 @@ const HARNESS_SYSTEM_PROMPTS = { }, } as const satisfies Partial> +/** + * Deliveries the MATERIALIZER already lowered into the plan, whose effect still rides argv. The + * prompt bytes are in the digested plan files, but the flag that makes the harness read them is + * only meaningful to the native binary's own argument parser — handed to any other executable it + * is inert argv, and the sealed candidate would claim an active prompt that never applied. + * + * That is the exact hazard the launch guard exists for, and it is how the guard was once silently + * disabled: when agent-profile-materialize 0.12 moved codex from an inline flag this module + * projected to file+flag it lowers itself, `plan.systemPrompt` stopped being set for codex, the + * old `systemPrompt === undefined` early-return skipped every check, and two refusal tests began + * resolving. Delivery detection therefore keys on the LOWERED FLAG, not on the field. + */ +interface MaterializedFlagDelivery { + readonly executable: string + readonly delivered: (plan: AgentCandidateWorkspacePlan) => boolean + readonly conflictsWithArgs: (values: readonly string[]) => boolean +} + +const MATERIALIZED_FLAG_DELIVERIES = { + codex: { + executable: 'codex', + delivered: (plan) => + plan.flags.some((flag) => flag.value.startsWith('model_instructions_file=')), + conflictsWithArgs: argsSetCodexInstructionOverride, + }, +} as const satisfies Partial> + /** Project a replacement system prompt onto the exact native process control. */ export function projectCandidateSystemPrompt( plan: AgentCandidateWorkspacePlan, @@ -97,7 +121,17 @@ export function projectCandidateSystemPrompt( systemPromptFilePath: string, ): AgentCandidateWorkspacePlan { const systemPrompt = plan.systemPrompt - if (systemPrompt === undefined) return plan + if (systemPrompt === undefined) { + // No launcher-delegated prompt — but the materializer may have lowered one into the plan + // whose flag only the native binary can honor. Same guards, no projection to apply. + const delivery = MATERIALIZED_FLAG_DELIVERIES[ + plan.harness as keyof typeof MATERIALIZED_FLAG_DELIVERIES + ] as MaterializedFlagDelivery | undefined + if (!delivery || !delivery.delivered(plan)) return plan + assertProvableNativeLaunch(plan.harness, delivery.executable, launch) + assertNoShadowingArgs(plan.harness, delivery.conflictsWithArgs, launch) + return plan + } const projection = HARNESS_SYSTEM_PROMPTS[plan.harness as keyof typeof HARNESS_SYSTEM_PROMPTS] as | HarnessSystemPrompt @@ -105,11 +139,25 @@ export function projectCandidateSystemPrompt( if (!projection) { throw new Error(`candidate system prompt has no native launch projection for ${plan.harness}`) } - const expectedExecutable = projection.executable + assertProvableNativeLaunch(plan.harness, projection.executable, launch) + assertNoShadowingArgs(plan.harness, projection.conflictsWithArgs, launch) + // The source-profile digest already binds the authored value. Sign only the + // native projection here so an inert systemPrompt field cannot look active. + return projection.project( + omitUnappliedSystemPrompt(plan), + systemPrompt.value, + systemPromptFilePath, + ) +} + +/** A prompt whose delivery rides argv is provable only on the native binary's own command line. */ +function assertProvableNativeLaunch( + harness: string, + expectedExecutable: string, + launch: AgentCandidateLaunch, +): asserts launch is AgentCandidateLaunch & { kind: 'container-command' } { if (launch.kind !== 'container-command') { - throw new Error( - `candidate-entrypoint launch cannot prove ${plan.harness} system-prompt replacement`, - ) + throw new Error(`candidate-entrypoint launch cannot prove ${harness} system-prompt replacement`) } if ( launch.executable !== expectedExecutable && @@ -119,22 +167,19 @@ export function projectCandidateSystemPrompt( ) ) { throw new Error( - `${plan.harness} system-prompt replacement requires the native ${expectedExecutable} executable`, + `${harness} system-prompt replacement requires the native ${expectedExecutable} executable`, ) } +} - if (projection.conflictsWithArgs((launch.args ?? []).map((value) => value.value))) { - throw new Error( - `${plan.harness} launch arguments conflict with the candidate profile system prompt`, - ) +function assertNoShadowingArgs( + harness: string, + conflictsWithArgs: (values: readonly string[]) => boolean, + launch: Extract, +): void { + if (conflictsWithArgs((launch.args ?? []).map((value) => value.value))) { + throw new Error(`${harness} launch arguments conflict with the candidate profile system prompt`) } - // The source-profile digest already binds the authored value. Sign only the - // native projection here so an inert systemPrompt field cannot look active. - return projection.project( - omitUnappliedSystemPrompt(plan), - systemPrompt.value, - systemPromptFilePath, - ) } function omitUnappliedSystemPrompt(plan: AgentCandidateWorkspacePlan): AgentCandidateWorkspacePlan { @@ -177,86 +222,3 @@ function addSystemPromptFile( ], } } - -function projectOpenCodeSystemPrompt(files: readonly PlanFile[], prompt: string): PlanFile[] { - const configIndex = files.findIndex((file) => file.relPath === 'opencode.json') - if (configIndex === -1) { - return [ - ...files, - { - relPath: 'opencode.json', - content: openCodeConfigWithPrompt({}, prompt), - source: 'generated', - }, - ] - } - - const configFile = files[configIndex]! - if (configFile.source !== 'generated') { - throw new Error('opencode system-prompt replacement requires generated opencode.json') - } - const config = parseJsonObject(configFile.content, 'generated opencode.json') - const output = [...files] - output[configIndex] = { - ...configFile, - content: openCodeConfigWithPrompt(config, prompt), - } - return output -} - -function openCodeConfigWithPrompt(config: Record, prompt: string): string { - const agent = optionalJsonObject(config.agent, 'generated opencode.json agent') - const build = optionalJsonObject(agent.build, 'generated opencode.json agent.build') - const plan = optionalJsonObject(agent.plan, 'generated opencode.json agent.plan') - assertCompatiblePrompt(build.prompt, prompt, 'build') - assertCompatiblePrompt(plan.prompt, prompt, 'plan') - return JSON.stringify( - { - $schema: 'https://opencode.ai/config.json', - ...config, - agent: { - ...agent, - build: { ...build, prompt }, - plan: { ...plan, prompt }, - }, - }, - null, - 2, - ) -} - -function parseJsonObject(content: string, label: string): Record { - let value: unknown - try { - value = JSON.parse(content) - } catch (cause) { - throw new Error(`${label} must contain valid JSON`, { cause }) - } - return jsonObject(value, label) -} - -function optionalJsonObject(value: unknown, label: string): Record { - return value === undefined ? {} : jsonObject(value, label) -} - -function jsonObject(value: unknown, label: string): Record { - if (value === null || typeof value !== 'object' || Array.isArray(value)) { - throw new Error(`${label} must be a JSON object`) - } - return value as Record -} - -function assertCompatiblePrompt(value: unknown, prompt: string, agent: string): void { - if (value !== undefined && value !== prompt) { - throw new Error(`generated opencode.json agent.${agent}.prompt conflicts with the profile`) - } -} - -function tomlString(value: string): string { - return `"${value - .replaceAll('\\', '\\\\') - .replaceAll('"', '\\"') - .replaceAll('\n', '\\n') - .replaceAll('\r', '\\r') - .replaceAll('\t', '\\t')}"` -} diff --git a/tests/candidate-execution-prepare.test.ts b/tests/candidate-execution-prepare.test.ts index 7ca1ff46..dd528887 100644 --- a/tests/candidate-execution-prepare.test.ts +++ b/tests/candidate-execution-prepare.test.ts @@ -36,20 +36,18 @@ afterEach(() => { describe('candidate execution preparation', () => { it.each([ { + // codex delivery is materializer-lowered file+flag since agent-profile-materialize 0.12: + // the prompt bytes live in the digested plan at .codex/system-prompt.md and the flag makes + // codex read them. The path is cwd-relative on purpose (survives docker/jail remapping). harness: 'codex', executable: '/usr/local/bin/codex', - flags: ['-c', 'developer_instructions="Native \\"prompt\\"\\nsecond line\\tend"'], + flags: ['-c', 'model_instructions_file=.codex/system-prompt.md'], }, { harness: 'claude-code', executable: 'claude', flags: ['--system-prompt-file', '/workspace/task/.tangle/system-prompt.md'], }, - { - harness: 'opencode', - executable: 'opencode', - flags: [], - }, { harness: 'pi', executable: 'pi', @@ -94,37 +92,69 @@ describe('candidate execution preparation', () => { ) expect(prepared.launch.args).not.toContain(value.task.task.instruction) - const openCodeConfig = prepared.profileActivation.files.find( - (file) => file.path === 'opencode.json', - ) const systemPromptFile = prepared.profileActivation.files.find( (file) => file.path === '.tangle/system-prompt.md', ) - if (harness === 'opencode') { - expect(JSON.parse(openCodeConfig?.content ?? '')).toMatchObject({ - instructions: ['.opencode/profile-instructions.md'], - agent: { - build: { prompt: systemPrompt }, - plan: { prompt: systemPrompt }, - }, - }) - expect(systemPromptFile).toBeUndefined() - } else if (harness === 'claude-code' || harness === 'pi') { - expect(openCodeConfig).toBeUndefined() + const codexPromptFile = prepared.profileActivation.files.find( + (file) => file.path === '.codex/system-prompt.md', + ) + if (harness === 'claude-code' || harness === 'pi') { + expect(codexPromptFile).toBeUndefined() expect(systemPromptFile?.content).toBe(systemPrompt) } else { - expect(openCodeConfig).toBeUndefined() + // The flag is only half the delivery; the digested plan must carry the exact bytes the + // flag points at, or the launch would reference a file that does not exist. expect(systemPromptFile).toBeUndefined() + expect(codexPromptFile?.content).toBe(systemPrompt) } }, ) + it('refuses an opencode candidate system prompt, because delivery cannot be guaranteed', async () => { + // agent-profile-materialize 0.12 refuses this outright: opencode's only replacement control + // is per-agent (`agent..prompt`), bound to whichever agent the launcher selects — a + // guarantee a sealed workspace plan cannot make. The refusal replaced this repo's earlier + // opencode.json mutation, which patched both built-in agents and hoped one was selected. + // Fail-closed is correct: no silent drop, an actionable reason, and the capability returns + // upstream via a binds-aware candidate materializer rather than a local workaround. + const value = fixture() + value.bundle = redigestBundle(value.bundle, { + profile: { + ...value.bundle.profile, + harness: 'opencode', + prompt: { ...value.bundle.profile.prompt, systemPrompt: 'Must be active.' }, + }, + execution: { + ...value.bundle.execution, + harness: 'opencode', + launch: { kind: 'container-command', executable: 'opencode' }, + }, + }) + bindCandidateFixtureBundle(value) + + await expect( + prepareAgentCandidateExecution( + await verifyAgentCandidateBundle(value.bundle, value.ports), + value.task, + value.ports, + ), + ).rejects.toThrow(/only system-prompt replacement is per-agent/) + }) + it.each([ { harness: 'codex', executable: 'codex', args: ['-c', 'developer_instructions="already set"'], }, + { + // The materializer's own delivery key: a caller argv setting it would re-point codex's + // instructions at a different file AFTER the plan's flag, silently replacing the sealed + // prompt with foreign bytes. + harness: 'codex', + executable: 'codex', + args: ['--config=model_instructions_file=/somewhere/else.md'], + }, { harness: 'claude-code', executable: 'claude', args: ['--system-prompt-file', '/elsewhere'] }, { harness: 'claude-code', executable: 'claude', args: ['--system-prompt=inline'] }, { harness: 'pi', executable: 'pi', args: ['--system-prompt', '/elsewhere'] }, diff --git a/tests/exact-process-candidate.test.ts b/tests/exact-process-candidate.test.ts index 2be24f83..a2fa9923 100644 --- a/tests/exact-process-candidate.test.ts +++ b/tests/exact-process-candidate.test.ts @@ -263,7 +263,7 @@ describe('exact process candidate experiment executor', () => { expect(spawnOperation?.signal).toBeInstanceOf(AbortSignal) expect(launch).toMatchObject({ executable: 'codex', - args: ['-c', 'developer_instructions="Candidate prompt."'], + args: ['-c', 'model_instructions_file=.codex/system-prompt.md'], cwd: '/workspace/task', stdin: fixture.task.task.instruction, timeoutMs: 0, diff --git a/tests/profile-materialization.test.ts b/tests/profile-materialization.test.ts index f2fb7255..f97d1f21 100644 --- a/tests/profile-materialization.test.ts +++ b/tests/profile-materialization.test.ts @@ -28,6 +28,7 @@ const EXPECTED_CANONICAL_AXES = [ 'version', 'tags', 'systemPrompt', + 'appendSystemPrompt', 'instructions', 'modelDefault', 'modelSmall', @@ -55,7 +56,7 @@ const EXPECTED_CANONICAL_AXES = [ ] as const describe('canonical axis set', () => { - it('is the exact 29-leaf set agent-interface publishes', () => { + it('is the exact 30-leaf set agent-interface publishes', () => { expect([...AGENT_PROFILE_MATERIALIZATION_AXES].sort()).toEqual( [...EXPECTED_CANONICAL_AXES].sort(), )