From fe8e7b875f6c3d684c289f093057fcf13bb29060 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Thu, 13 Aug 2026 13:54:37 -0600 Subject: [PATCH 1/3] fix(runtime): preserve bridge terminal evidence and unknown cost --- src/runtime/supervise/bridge-executor.test.ts | 23 ++++++++++++++++++- src/runtime/supervise/runtime.ts | 21 ++++++++++++++++- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/src/runtime/supervise/bridge-executor.test.ts b/src/runtime/supervise/bridge-executor.test.ts index 7283c6f3..3e60a3e6 100644 --- a/src/runtime/supervise/bridge-executor.test.ts +++ b/src/runtime/supervise/bridge-executor.test.ts @@ -13,6 +13,7 @@ import { replaySpawnTree, } from '../../durable/spawn-journal' import { spendFromUsageEvents } from './budget' +import { runtimeOwnedExecutorMaterialization } from './materialization' import { type BridgeModelCredential, bridgeExecutor, @@ -311,6 +312,24 @@ describe('bridgeExecutor upstream-error propagation', () => { ) }) + it('publishes a terminal profile acknowledgement before rethrowing a provider error', async () => { + const body = [ + `data: ${JSON.stringify({ error: { message: 'provider failed' } })}`, + 'data: [DONE]', + '', + ].join('\n\n') + const stub = await startBridgeStub(body) + server = stub.server + const executor = makeExecutor(stub.url) + + await expect( + drain( + executor.execute('do the task', new AbortController().signal) as AsyncIterable, + ), + ).rejects.toThrow(/bridge stream error: provider failed/) + expect(runtimeOwnedExecutorMaterialization(executor)).toBeDefined() + }) + it('refuses an old bridge before any model POST', async () => { let posts = 0 server = createServer((req, res) => { @@ -849,9 +868,11 @@ describe('bridgeExecutor upstream-error propagation', () => { ) server = stub.server const executor = makeExecutor(stub.url) - await drain( + const events = await drain( executor.execute('do the task', new AbortController().signal) as AsyncIterable, ) + expect(events).toContainEqual({ kind: 'cost', usd: 0, usdKnown: false }) + expect(spendFromUsageEvents(events).usdKnown).toBe(false) expect(executor.resultArtifact().spent).toMatchObject({ tokens: { input: 3, output: 2 }, usd: 0, diff --git a/src/runtime/supervise/runtime.ts b/src/runtime/supervise/runtime.ts index 7828795e..bd18df24 100644 --- a/src/runtime/supervise/runtime.ts +++ b/src/runtime/supervise/runtime.ts @@ -2342,6 +2342,19 @@ async function* streamBridgeSession(args: StreamBridgeArgs): AsyncIterable { + // A receipt is terminal evidence only after the durable bridge run reached [DONE]. + // A provider error may follow that acknowledgement, so publish it before rethrowing. + if ( + !profileMaterializationPublished && + activeRun.terminal && + activeRun.profileMaterialization !== undefined + ) { + args.onProfileMaterialization(activeRun.profileMaterialization) + profileMaterializationPublished = true + } + } try { args.onProviderAttemptStart() for await (const chunk of streamDurableBridgeRun({ @@ -2465,8 +2478,9 @@ async function* streamBridgeSession(args: StreamBridgeArgs): AsyncIterable Date: Thu, 13 Aug 2026 13:54:37 -0600 Subject: [PATCH 2/3] chore(release): 0.133.7 --- CHANGELOG.md | 6 ++++++ docs/api/primitive-catalog.md | 2 +- docs/canonical-api.md | 2 +- package.json | 2 +- src/testing/fixtures/agent-improvement-proposal.json | 10 +++++----- .../fixtures/agent-profile-improvement-proposal.json | 6 +++--- 6 files changed, 17 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5120d240..d139ad65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.133.7 + +- Preserve cli-bridge profile materialization receipts when a terminal provider error follows the receipt. +- Emit an explicit unknown dollar-usage event when a bridge turn has no trusted billed-cost receipt. +- Consumers that require exact dollar settlement must treat `usdKnown: false` as unknown until a trusted provider or billing receipt is available. + ## 0.133.6 - The direct protected model-grant port accepts an optional caller-declared `maxTotalTokens` cap across input and output tokens. diff --git a/docs/api/primitive-catalog.md b/docs/api/primitive-catalog.md index 5605207f..c1c6ecf5 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.133.6` and `@tangle-network/agent-eval@0.145.2` 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.133.7` and `@tangle-network/agent-eval@0.145.2` 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 diff --git a/docs/canonical-api.md b/docs/canonical-api.md index 638700ca..23ac47f6 100644 --- a/docs/canonical-api.md +++ b/docs/canonical-api.md @@ -4,7 +4,7 @@ Generated signatures and the complete export list live in docs/api/. Run pnpm docs:freshness after editing this file. --> -> **Version 0.133.6.** +> **Version 0.133.7.** > [`docs/api/primitive-catalog.md`](./api/primitive-catalog.md) lists every export and import path. > `agent-eval` must satisfy `>=0.145.2 <0.146.0`. > `sandbox` must satisfy `>=0.21.1 <0.22.0`. diff --git a/package.json b/package.json index 2206c8d7..b73f01fa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tangle-network/agent-runtime", - "version": "0.133.6", + "version": "0.133.7", "description": "Shared task-lifecycle skeleton for agents: a recursive loop kernel for chat turns, one-shot tasks, and multi-attempt loops, with trace capture and eval-gated self-improvement. Domain behavior lives in adapters; scoring and ship-gates in @tangle-network/agent-eval.", "homepage": "https://github.com/tangle-network/agent-runtime#readme", "repository": { diff --git a/src/testing/fixtures/agent-improvement-proposal.json b/src/testing/fixtures/agent-improvement-proposal.json index 34321a2b..476af5c4 100644 --- a/src/testing/fixtures/agent-improvement-proposal.json +++ b/src/testing/fixtures/agent-improvement-proposal.json @@ -1,6 +1,6 @@ { "changedSurfaces": ["prompt"], - "digest": "sha256:f4b1f74326477910b5e45daab3c25a86b0f5274a41dc8c743f09871bda3ed90f", + "digest": "sha256:dd3e1c5770a5e309e1bcf627988f8a929f48cb9579239c27be3e0e8ea0928c0b", "evaluation": { "decision": { "contributingChecks": [ @@ -4870,7 +4870,7 @@ ], "metadata": { "fixture": "agent-improvement-proposal", - "runtimeVersion": "0.133.6" + "runtimeVersion": "0.133.7" }, "objectives": [ { @@ -4981,8 +4981,8 @@ "baselineContentHash": "sha256:5c21ee53e513fc604cb09754e21c392b24a424da0ef37dbf8f1ee4a8a0b08f09", "candidateContentHash": "sha256:60fcbb1c728194bd51d7d19cb732d1c3f1881dce7e0a6266b41c8b98cfd65693", "kind": "agent-eval-loop", - "recordDigest": "sha256:9e0a8384c31187fa102b043d870d7496993910e1e7a655f7e49cea29dc572bdd", - "runId": "agent-runtime-0.133.6-proposal-fixture", + "recordDigest": "sha256:d9a7fed49f117de78530dd56e4f03c34b3ce08e017bf5c6f7812c93d97297492", + "runId": "agent-runtime-0.133.7-proposal-fixture", "schema": "agent-candidate-experiment" } }, @@ -5009,5 +5009,5 @@ ], "kind": "agent-improvement-proposal", "proposedAt": "2026-07-10T01:00:00.000Z", - "runId": "agent-runtime-0.133.6-proposal-fixture" + "runId": "agent-runtime-0.133.7-proposal-fixture" } diff --git a/src/testing/fixtures/agent-profile-improvement-proposal.json b/src/testing/fixtures/agent-profile-improvement-proposal.json index 5f709f1d..a2507e4f 100644 --- a/src/testing/fixtures/agent-profile-improvement-proposal.json +++ b/src/testing/fixtures/agent-profile-improvement-proposal.json @@ -1,6 +1,6 @@ { "changedSurfaces": ["prompt", "skills"], - "digest": "sha256:8c1032f7eaccda248160a55e5176598d28bee8b46d3cc0ad47442ca74b3a714a", + "digest": "sha256:21dd7d12cf6f1a8c4ac099d8ae2788f0c9dce09be5608134c6bccd4ab1b6db95", "evaluation": { "decision": { "contributingChecks": [ @@ -1715,7 +1715,7 @@ ], "metadata": { "fixture": "agent-profile-improvement-proposal", - "runtimeVersion": "0.133.6" + "runtimeVersion": "0.133.7" }, "objectives": [ { @@ -1826,7 +1826,7 @@ "baselineContentHash": "sha256:21c495a37c418c10bde64fbaa188beddeed31f1f051ea60a6a6582a9ee0db704", "candidateContentHash": "sha256:103f77bc8481601eef1ad5fe6ba84a40dffabc3a44f421f8c8559121edab84e9", "kind": "agent-eval-loop", - "recordDigest": "sha256:86adb09c9680559bbe3e7ac5f7ae62cd3105c3a2c469fbcb0fbdb177e5325937", + "recordDigest": "sha256:620281149e673aa97c02fe7ebc5d1795786e1d06b74e17dfce5bcd89d31fe017", "runId": "profile-improvement-1", "schema": "agent-profile-improvement-experiment" } From 872abe1c200de4524819fca561d562a87425a4d1 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Thu, 13 Aug 2026 13:58:17 -0600 Subject: [PATCH 3/3] fix(candidate-execution): retry draining grant settlement --- CHANGELOG.md | 1 + .../protected-model-grant.ts | 38 ++++++++++++++++++- tests/candidate-execution-model-port.test.ts | 34 +++++++++++++++++ 3 files changed, 72 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d139ad65..a24cee4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - Preserve cli-bridge profile materialization receipts when a terminal provider error follows the receipt. - Emit an explicit unknown dollar-usage event when a bridge turn has no trusted billed-cost receipt. +- Retry only an explicit `candidate_grant_draining` settlement response until the caller deadline. - Consumers that require exact dollar settlement must treat `usdKnown: false` as unknown until a trusted provider or billing receipt is available. ## 0.133.6 diff --git a/src/candidate-execution/protected-model-grant.ts b/src/candidate-execution/protected-model-grant.ts index 8be126b7..6eb639b0 100644 --- a/src/candidate-execution/protected-model-grant.ts +++ b/src/candidate-execution/protected-model-grant.ts @@ -47,6 +47,9 @@ export interface RunProtectedAgentCandidateModelGrantResult { readonly settlement: AgentCandidateProtectedModelSettlement } +const SETTLEMENT_RETRY_INITIAL_DELAY_MS = 25 +const SETTLEMENT_RETRY_MAX_DELAY_MS = 1_000 + /** * Run one bounded unit under a protected model grant. * @@ -93,7 +96,7 @@ export async function runProtectedAgentCandidateModelGrant( let settlement: AgentCandidateProtectedModelSettlement try { - settlement = await options.port.settleGrant({ + settlement = await settleProtectedModelGrant(options, { executionId: options.reserve.executionId, preparationId: options.reserve.preparationId, grantDigest: reservation.digest, @@ -113,3 +116,36 @@ export async function runProtectedAgentCandidateModelGrant( if (executionFailed) throw executionError return { value, resolved, reservation, settlement } } + +async function settleProtectedModelGrant( + options: RunProtectedAgentCandidateModelGrantOptions, + input: AgentCandidateModelGrantSettleInput, +): Promise { + let delayMs = SETTLEMENT_RETRY_INITIAL_DELAY_MS + for (;;) { + try { + return await options.port.settleGrant(input) + } catch (error) { + if (!isCandidateGrantDrainingError(error)) throw error + const remainingMs = options.deadlineAtMs - Date.now() + if (remainingMs <= 0) throw error + await waitForSettlementRetry(Math.min(delayMs, remainingMs)) + if (Date.now() >= options.deadlineAtMs) throw error + delayMs = Math.min(delayMs * 2, SETTLEMENT_RETRY_MAX_DELAY_MS) + } + } +} + +function waitForSettlementRetry(delayMs: number): Promise { + return new Promise((resolve) => setTimeout(resolve, delayMs)) +} + +/** Only the gateway's explicit draining state is retryable; auth and ledger errors fail closed. */ +function isCandidateGrantDrainingError(error: unknown): boolean { + if (typeof error !== 'object' || error === null) return false + const candidate = error as { code?: unknown; message?: unknown } + if (candidate.code === 'candidate_grant_draining') return true + return ( + typeof candidate.message === 'string' && /\bcandidate_grant_draining\b/u.test(candidate.message) + ) +} diff --git a/tests/candidate-execution-model-port.test.ts b/tests/candidate-execution-model-port.test.ts index 288ea3d4..b0099551 100644 --- a/tests/candidate-execution-model-port.test.ts +++ b/tests/candidate-execution-model-port.test.ts @@ -781,6 +781,40 @@ describe('protected candidate model port', () => { expect(client.settleInputs).toEqual([settleInput('completed')]) }) + it('retries only an explicit draining settlement until the final ledger closes', async () => { + let attempts = 0 + const client = fakeClient({ + settle: async () => { + attempts += 1 + if (attempts === 1) { + throw Object.assign( + new Error('/v1/candidate-model-grants/settle failed: 409 candidate_grant_draining'), + { code: 'candidate_grant_draining', status: 409 }, + ) + } + return settlement([modelCall(1)]) + }, + }) + const port = createPort(client) + const { resolved: _resolved, ...reserve } = reserveInput() + + const result = await runProtectedAgentCandidateModelGrant({ + port, + resolve: { + requested: resolvedModel.requested, + harness: 'opencode', + reasoningEffort: resolvedModel.reasoningEffort, + }, + reserve, + deadlineAtMs: Date.now() + 2_000, + execute: async () => 'cell-result', + }) + + expect(result.value).toBe('cell-result') + expect(result.settlement).toEqual(settlement([modelCall(1)])) + expect(client.settleInputs).toHaveLength(2) + }) + it('settles a callback failure as failed and preserves the callback error', async () => { const client = fakeClient() const port = createPort(client)