Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
2 changes: 1 addition & 1 deletion docs/api/primitive-catalog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/canonical-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
38 changes: 37 additions & 1 deletion src/candidate-execution/protected-model-grant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ export interface RunProtectedAgentCandidateModelGrantResult<TResult> {
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.
*
Expand Down Expand Up @@ -93,7 +96,7 @@ export async function runProtectedAgentCandidateModelGrant<TResult>(

let settlement: AgentCandidateProtectedModelSettlement
try {
settlement = await options.port.settleGrant({
settlement = await settleProtectedModelGrant(options, {
executionId: options.reserve.executionId,
preparationId: options.reserve.preparationId,
grantDigest: reservation.digest,
Expand All @@ -113,3 +116,36 @@ export async function runProtectedAgentCandidateModelGrant<TResult>(
if (executionFailed) throw executionError
return { value, resolved, reservation, settlement }
}

async function settleProtectedModelGrant<TResult>(
options: RunProtectedAgentCandidateModelGrantOptions<TResult>,
input: AgentCandidateModelGrantSettleInput,
): Promise<AgentCandidateProtectedModelSettlement> {
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<void> {
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)
)
}
23 changes: 22 additions & 1 deletion src/runtime/supervise/bridge-executor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
replaySpawnTree,
} from '../../durable/spawn-journal'
import { spendFromUsageEvents } from './budget'
import { runtimeOwnedExecutorMaterialization } from './materialization'
import {
type BridgeModelCredential,
bridgeExecutor,
Expand Down Expand Up @@ -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<UsageEvent>,
),
).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) => {
Expand Down Expand Up @@ -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<UsageEvent>,
)
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,
Expand Down
21 changes: 20 additions & 1 deletion src/runtime/supervise/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2342,6 +2342,19 @@ async function* streamBridgeSession(args: StreamBridgeArgs): AsyncIterable<Usage
let turnKnownCostSubtotal = 0
let turnEstimatedCostSubtotal = 0
let interrupted = false
let profileMaterializationPublished = false
const publishProfileMaterialization = (): void => {
// 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({
Expand Down Expand Up @@ -2465,8 +2478,9 @@ async function* streamBridgeSession(args: StreamBridgeArgs): AsyncIterable<Usage
sawEstimatedUsd = true
}
}
args.onProfileMaterialization(activeRun.profileMaterialization!)
publishProfileMaterialization()
} catch (error) {
publishProfileMaterialization()
// A forceful steer first detaches this HTTP reader, then explicitly cancels
// the durable run and waits for terminal proof. Starting the resume turn
// before that acknowledgement would race two harness processes against one
Expand Down Expand Up @@ -2504,6 +2518,11 @@ async function* streamBridgeSession(args: StreamBridgeArgs): AsyncIterable<Usage
observation.activity.push({ at: Date.now(), kind: 'turn', label: `turn ${turns}` })
if (!sawTurnTokenUsage || !turnTokensKnown) tokensKnown = false
if (!sawTurnCostStatus || !turnUsdKnown) usdKnown = false
if (!sawTurnCostStatus || !turnUsdKnown) {
// Missing billing proof is not a free turn. Emit it explicitly so Scope's budget fold
// cannot default the observed dollar subtotal to known $0.
yield { kind: 'cost', usd: 0, usdKnown: false }
}
yield { kind: 'iteration' }
if (!interrupted && turnText) lastText = turnText

Expand Down
10 changes: 5 additions & 5 deletions src/testing/fixtures/agent-improvement-proposal.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"changedSurfaces": ["prompt"],
"digest": "sha256:f4b1f74326477910b5e45daab3c25a86b0f5274a41dc8c743f09871bda3ed90f",
"digest": "sha256:dd3e1c5770a5e309e1bcf627988f8a929f48cb9579239c27be3e0e8ea0928c0b",
"evaluation": {
"decision": {
"contributingChecks": [
Expand Down Expand Up @@ -4870,7 +4870,7 @@
],
"metadata": {
"fixture": "agent-improvement-proposal",
"runtimeVersion": "0.133.6"
"runtimeVersion": "0.133.7"
},
"objectives": [
{
Expand Down Expand Up @@ -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"
}
},
Expand All @@ -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"
}
6 changes: 3 additions & 3 deletions src/testing/fixtures/agent-profile-improvement-proposal.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"changedSurfaces": ["prompt", "skills"],
"digest": "sha256:8c1032f7eaccda248160a55e5176598d28bee8b46d3cc0ad47442ca74b3a714a",
"digest": "sha256:21dd7d12cf6f1a8c4ac099d8ae2788f0c9dce09be5608134c6bccd4ab1b6db95",
"evaluation": {
"decision": {
"contributingChecks": [
Expand Down Expand Up @@ -1715,7 +1715,7 @@
],
"metadata": {
"fixture": "agent-profile-improvement-proposal",
"runtimeVersion": "0.133.6"
"runtimeVersion": "0.133.7"
},
"objectives": [
{
Expand Down Expand Up @@ -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"
}
Expand Down
34 changes: 34 additions & 0 deletions tests/candidate-execution-model-port.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down