diff --git a/CHANGELOG.md b/CHANGELOG.md index 5120d240..a24cee4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # 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. +- 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 - 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/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/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 { 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)