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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
# Changelog

## 0.237.0

**A refused spawn says which budget channels ran short and by how much.** A `budget-exhausted` reservation now carries `shortfalls`: every channel that did not fit, each as `{ channel, requested, free }` (`ReservationShortfall`, exported). `scope.spawn` passes them through, and `spawn_worker` returns them with a reason a driver can act on, for example `the run pool refused this spawn: iterations has 58 free (this spawn asked for budget.maxIterations 100); budget.maxIterations at most 58 fits`.

Iterations can be requested at exactly `free`, because a driver's own turns charge none. Tokens and dollars cannot: the driver's next turn is metered from the same pool before its retry reaches admission, so the text says to ask for well under `free`. A channel closed by unmeasured spend (`closedByUnknownSpend`) says the run admits no further spawn at any budget.

Measured 2026-09-16 on a Discovery director placed on the Tangle sandbox: its first research child asked for 100 iterations against a 60-iteration pool, got "the run has no allocation left to give this worker", spent a throwaway 3-iteration probe worker to learn the pool still had room, and moved its research to local processes.

That same sentence was also the reply for every other refusal: `max-live-workers`, `depth-exceeded`, `duplicate-key`, `key-conflict`, `invalid-identity`, and `scope-aborted` were each reported as an empty pool. Each now names its own cause and a next step a driver can take. The `usd-unbudgeted`, `in-doubt`, and `scope-settled` texts are unchanged, and `usd-unbudgeted` still yields to an exhausted channel as before.

Resource validation (a child missing a root resource, a unit mismatch, an undeclared resource) now throws before any shortfall is computed, where before a short resource could return first. A consumer that compared a refusal with `toEqual({ ok: false, reason: 'budget-exhausted' })` now also receives `shortfalls`.

## 0.236.0

A root that ran to completion under budget, selected nothing, and **never spawned a child** now settles `no-winner` with reason `no-children-spawned`. It used to settle `all-children-down` with `downCount: 0`, which reads as a fleet failure to anyone who did not open the journal. Fifteen sandbox-placed directors settled that way in one week while the actual fault was that the root never recursed, and every reader went looking at the fleet.
Expand Down
5 changes: 3 additions & 2 deletions api-surface.json
Original file line number Diff line number Diff line change
Expand Up @@ -786,7 +786,7 @@
"BridgeSeam": "type 3ee6c139b2d1",
"Budget": "type d3e20424e44d",
"BudgetOverspend": "type 034717e51be1",
"BudgetPool": "type 423107bf3e84",
"BudgetPool": "type d4f7ca28e55f",
"BudgetPoolRestore": "type 09960d15dfe8",
"BudgetReadout": "type 0e2ea9923511",
"BudgetReconcileFault": "value 9e6d36f63406",
Expand Down Expand Up @@ -1127,6 +1127,7 @@
"ReproductionCheck": "type d866d16ccd22",
"ReservationHolder": "type 589e95ab1fb1",
"ReservationRejection": "type 95613aa46396",
"ReservationShortfall": "type 307614981aeb",
"ReservationStage": "type f37f95f31e56",
"ReservationTicket": "type 58a154a39d5e",
"ResolveDriveHarness": "type 14e24449c8f2",
Expand Down Expand Up @@ -1201,7 +1202,7 @@
"SandboxSteeringOptions": "type 526a58a22f51",
"SandboxToolPartState": "type 26cdd7a50965",
"SandboxUsageLedger": "type bb9c3e368071",
"Scope": "type edc3d5f40e17",
"Scope": "type 7d484633ce30",
"ScopeAnalyst": "type 1cd5ae8d0d15",
"ScopeAnalyzeInput": "type 98b6562915c6",
"ScopeArgs": "type e739f4591d93",
Expand Down
5 changes: 3 additions & 2 deletions 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.236.0` and `@tangle-network/agent-eval@0.182.0` 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.237.0` and `@tangle-network/agent-eval@0.182.0` 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 Expand Up @@ -423,7 +423,7 @@ Import from `@tangle-network/agent-runtime/intelligence` — 167 exports.

### Execution kernel — recursive atom, supervision, executors, round-synchronous loop

Import from `@tangle-network/agent-runtime/kernel` — 957 exports.
Import from `@tangle-network/agent-runtime/kernel` — 958 exports.

| Symbol | Kind | Summary |
|---|---|---|
Expand Down Expand Up @@ -954,6 +954,7 @@ Import from `@tangle-network/agent-runtime/kernel` — 957 exports.
| `RegistryAnalyzeProjection` | interface | Project a `ScopeAnalyzeInput` into the `AnalystRegistry.run` arguments. The registry runs over a |
| `RenderCorpusToInstructionsOptions` | interface | Project accreted corpus facts into an `AgentProfile`'s instruction seams — the learning-flywheel |
| `ReservationHolder` | interface | Who holds a reservation. Recorded at `reserve` and refined through `attribute` once admission |
| `ReservationShortfall` | interface | One budget channel a `budget-exhausted` reservation could not fit, with the amounts that |
| `ReservationTicket` | interface | Opaque, single-use reservation handle returned by `reserve` and consumed by |
| `ResolvedMcpServerLaunch` | interface | The spawn-ready strings for one stdio MCP server: profile config values |
| `ResolvedSupervisorProfile` | interface | The exact profile fields consumed by supervisor materialization. |
Expand Down
42 changes: 37 additions & 5 deletions docs/api/runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -12674,6 +12674,38 @@ The spawned node's id, once admission minted one. Absent for a reservation that

***

### ReservationShortfall

One budget channel a `budget-exhausted` reservation could not fit, with the amounts that
decided it. A refusal lists every channel that did not fit (`shortfalls`), so shrinking one
request is not answered by a second refusal on a channel the caller was never told about
(observed live: a director whose 100-iteration child was refused spent a probe worker to learn
the pool still admitted 3). `free` is a snapshot: live reservations return to it as their
workers settle, and a driver's own metered turns draw tokens and dollars from the same pool
before its next request arrives, so only `iterations` can be requested at exactly `free`.
`closedByUnknownSpend` means work with unmeasured usage ran under that enforced limit; the
channel then refuses every reservation for the rest of the run.

#### Properties

##### channel

> `readonly` **channel**: `"tokens"` \| `"iterations"` \| `"usd"` \| `` `resource:${string}` ``

##### requested

> `readonly` **requested**: `number`

##### free

> `readonly` **free**: `number`

##### closedByUnknownSpend?

> `readonly` `optional` **closedByUnknownSpend?**: `true`

***

### BudgetPoolRestore

State recovered from a prior process before new work is admitted. `committed` is measured spend
Expand All @@ -12699,7 +12731,7 @@ while the public readout remains explicitly unknown.

##### reserve()

> **reserve**(`b`, `holder?`): \{ `ok`: `true`; `ticket`: [`ReservationTicket`](#reservationticket); \} \| \{ `ok`: `false`; `reason`: [`ReservationRejection`](#reservationrejection); \}
> **reserve**(`b`, `holder?`): \{ `ok`: `true`; `ticket`: [`ReservationTicket`](#reservationticket); \} \| \{ `ok`: `false`; `reason`: [`ReservationRejection`](#reservationrejection); `shortfalls?`: readonly [`ReservationShortfall`](#reservationshortfall)[]; \}

Atomically reserve a child's full ceiling from the free balance. Fails closed
({ ok: false }) when the pool can't cover standard or named channels — the
Expand All @@ -12717,7 +12749,7 @@ caller inspects `ok` before `ticket`.

###### Returns

\{ `ok`: `true`; `ticket`: [`ReservationTicket`](#reservationticket); \} \| \{ `ok`: `false`; `reason`: [`ReservationRejection`](#reservationrejection); \}
\{ `ok`: `true`; `ticket`: [`ReservationTicket`](#reservationticket); \} \| \{ `ok`: `false`; `reason`: [`ReservationRejection`](#reservationrejection); `shortfalls?`: readonly [`ReservationShortfall`](#reservationshortfall)[]; \}

##### attribute()

Expand Down Expand Up @@ -22031,7 +22063,7 @@ One tree-wide view of simultaneous spawned work. Every nested scope reads the sa

##### spawn()

> **spawn**\<`C`\>(`agent`, `task`, `opts`): \{ `ok`: `true`; `handle`: [`Handle`](#handle-3)\<`C`\>; `prior?`: [`SpawnPrior`](#spawnprior)\<`C`\>; \} \| \{ `ok`: `false`; `reason`: [`SpawnRejection`](#spawnrejection); \}
> **spawn**\<`C`\>(`agent`, `task`, `opts`): \{ `ok`: `true`; `handle`: [`Handle`](#handle-3)\<`C`\>; `prior?`: [`SpawnPrior`](#spawnprior)\<`C`\>; \} \| \{ `ok`: `false`; `reason`: [`SpawnRejection`](#spawnrejection); `shortfalls?`: readonly [`ReservationShortfall`](#reservationshortfall)[]; \}

Spawn a child. For a fresh key or an unkeyed spawn, tree-wide worker admission happens before a
lazy factory is called, so a full worker allocation creates no worker, executor, or reservation.
Expand Down Expand Up @@ -22065,7 +22097,7 @@ work: it returns the committed result on `prior` (see `SpawnOpts.key`).

###### Returns

\{ `ok`: `true`; `handle`: [`Handle`](#handle-3)\<`C`\>; `prior?`: [`SpawnPrior`](#spawnprior)\<`C`\>; \} \| \{ `ok`: `false`; `reason`: [`SpawnRejection`](#spawnrejection); \}
\{ `ok`: `true`; `handle`: [`Handle`](#handle-3)\<`C`\>; `prior?`: [`SpawnPrior`](#spawnprior)\<`C`\>; \} \| \{ `ok`: `false`; `reason`: [`SpawnRejection`](#spawnrejection); `shortfalls?`: readonly [`ReservationShortfall`](#reservationshortfall)[]; \}

##### next()

Expand Down Expand Up @@ -23044,7 +23076,7 @@ One channel on which a settled reservation's measured spend exceeded what it res

##### channel

> `readonly` **channel**: [`SpendChannel`](#spendchannel) \| `"iterations"`
> `readonly` **channel**: `"iterations"` \| [`SpendChannel`](#spendchannel)

##### reserved

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.236.0.**
> **Version 0.237.0.**
> [`docs/api/primitive-catalog.md`](./api/primitive-catalog.md) lists every export and import path.
> `agent-eval` must satisfy `>=0.182.0 <0.183.0`.
> `sandbox` must satisfy `>=0.36.4 <0.42.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.236.0",
"version": "0.237.0",
"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
112 changes: 96 additions & 16 deletions src/mcp/tools/coordination.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,12 @@ import type {
ResultBlobStore,
Scope,
Settled,
SpawnRejection,
Spend,
Agent as SuperviseAgent,
WorkerTraceEvidence,
} from '../../runtime'
import { assertValidBudget } from '../../runtime/supervise/budget'
import { assertValidBudget, type ReservationShortfall } from '../../runtime/supervise/budget'
import type { DeliverableSpec } from '../../runtime/supervise/completion-gate'
import { type WatchTraceOptions, watchTrace } from '../../runtime/supervise/detector-monitor'
import { freeSlots } from '../../runtime/supervise/dispatch'
Expand Down Expand Up @@ -1430,6 +1431,84 @@ function spawnProfileArg(): Record<string, unknown> {
return spawnProfileArgCache
}

const BUDGET_FIELD: Readonly<Record<'tokens' | 'iterations' | 'usd', string>> = {
tokens: 'maxTokens',
iterations: 'maxIterations',
usd: 'maxUsd',
}

function budgetField(channel: ReservationShortfall['channel']): string {
return channel.startsWith('resource:')
? `resources.${channel.slice('resource:'.length)}.limit`
: BUDGET_FIELD[channel as 'tokens' | 'iterations' | 'usd']
}

/** One clause per short channel. Iterations can be requested at exactly `free`: a driver's own
* turns charge none. Tokens and dollars cannot: the driver's next turn is metered from the same
* pool before its retry reaches admission, so the clause says to leave room. */
function shortfallClause(shortfall: ReservationShortfall): string {
const { channel, requested, free } = shortfall
const field = budgetField(channel)
if (shortfall.closedByUnknownSpend === true) {
return `${channel} is closed: work with unmeasured ${channel} usage ran under the run's enforced limit, so this run admits no further spawn at any budget`
}
const asked = `this spawn asked for budget.${field} ${requested}`
if (free === 0) {
return `${channel} has nothing free (${asked}); an unused reservation returns only when its live worker settles`
}
if (channel === 'iterations' || channel.startsWith('resource:')) {
return `${channel} has ${free} free (${asked}); budget.${field} at most ${free} fits`
}
return `${channel} has ${free} free right now (${asked}); your own turns draw ${channel} from this same pool before a retry is admitted, so ask for well under ${free}`
}

/**
* The reason text `spawn_worker` returns for a refused spawn. Every rejection kind names its own
* cause: a live-worker cap, a depth limit, or a key collision is not an empty budget, and telling a
* driver "no allocation left" for those sends it after the wrong fix. A `budget-exhausted` refusal
* names every channel that did not fit and the amounts, so the driver can size its next request.
*/
export function spawnRefusalReason(
reason: SpawnRejection,
shortfalls: readonly ReservationShortfall[] | undefined,
pinned: {
readonly usdUnbudgeted: string
readonly inDoubt: string
readonly scopeSettled: string
},
): string {
switch (reason) {
case 'usd-unbudgeted':
return pinned.usdUnbudgeted
case 'in-doubt':
return pinned.inDoubt
case 'scope-settled':
return pinned.scopeSettled
case 'scope-aborted':
return 'this run stopped admitting work (it was cancelled, passed its deadline, or too many children went down); no further worker can start'
case 'depth-exceeded':
return "this spawn would exceed the run's maxDepth; a worker at the deepest level cannot start children of its own"
case 'max-live-workers':
return 'the run already has its maximum number of live workers; wait for one to settle (await_event), then spawn again'
case 'duplicate-key':
return 'a worker under this key is still live; wait for it to settle, or use a different key for different work'
case 'key-conflict':
return "this key is already recorded for a different profile or task in this run's journal; use a new key for different work"
case 'invalid-identity':
return 'a keyed spawn needs a complete execution identity to journal, and this profile and task did not produce one; spawn again without a key'
case 'budget-exhausted': {
if (shortfalls === undefined || shortfalls.length === 0) {
return "the conserved pool refused this spawn (budget-exhausted): the run's remaining budget cannot cover this worker's budget"
}
if (shortfalls.some((shortfall) => shortfall.closedByUnknownSpend === true)) {
const closed = shortfalls.find((shortfall) => shortfall.closedByUnknownSpend === true)!
return `the run pool refused this spawn: ${shortfallClause(closed)}; the caller must re-run with a measurable or larger root budget`
}
return `the run pool refused this spawn: ${shortfalls.map(shortfallClause).join('; ')}; or ask the caller for a larger root budget`
}
}
}

/** Build the driver's MCP tools over a live scope. */
export function createCoordinationTools(opts: CoordinationToolsOptions): CoordinationTools {
const deliverable = opts.deliverable
Expand Down Expand Up @@ -3051,21 +3130,22 @@ export function createCoordinationTools(opts: CoordinationToolsOptions): Coordin
}
: {
error: res.reason,
// A refusal a driver can ACT on. `usd-unbudgeted` is the one rejection that no
// retry can clear, so it says so: without this, a driver reads "budget" and walks
// its request down until it gives up.
reason:
res.reason === 'usd-unbudgeted'
? "this run's root budget declares no maxUsd, so a child budget naming maxUsd can never be admitted at any amount — spawn with a budget that omits maxUsd"
: res.reason === 'in-doubt'
? 'this key has a prior worker recorded as started without a terminal receipt; no replacement was started because that remote worker may still be running — inspect or recover the exact prior execution before retrying'
: // Nothing is exhausted and nothing was cancelled: this run's driver already
// finished and the supervisor is joining. A caller that reaches here is
// working past the end of its own request; the honest report is that the
// stage never started, not that it failed.
res.reason === 'scope-settled'
? 'this run has already reached its join barrier — its driver returned and the supervisor is settling, so no further worker can be started, joined, or paid for; record this stage as not started'
: `the conserved pool refused this spawn (${String(res.reason)}); the run has no allocation left to give this worker`,
// `usd-unbudgeted` is the one rejection no retry can clear, so it says so: without
// that, a driver reads "budget" and walks its request down until it gives up.
// A refusal a driver can ACT on: each kind says what happened and what to do next.
reason: spawnRefusalReason(res.reason, res.shortfalls, {
usdUnbudgeted:
"this run's root budget declares no maxUsd, so a child budget naming maxUsd can never be admitted at any amount — spawn with a budget that omits maxUsd",
inDoubt:
'this key has a prior worker recorded as started without a terminal receipt; no replacement was started because that remote worker may still be running — inspect or recover the exact prior execution before retrying',
// Nothing is exhausted and nothing was cancelled: this run's driver already
// finished and the supervisor is joining. A caller that reaches here is working
// past the end of its own request; the honest report is that the stage never
// started, not that it failed.
scopeSettled:
'this run has already reached its join barrier — its driver returned and the supervisor is settling, so no further worker can be started, joined, or paid for; record this stage as not started',
}),
...(res.shortfalls === undefined ? {} : { shortfalls: res.shortfalls }),
...(res.reason === 'usd-unbudgeted'
? {
hint:
Expand Down
1 change: 1 addition & 0 deletions src/runtime/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -639,6 +639,7 @@ export {
type LeakedReservation,
type ReservationHolder,
type ReservationRejection,
type ReservationShortfall,
type ReservationStage,
type ReservationTicket,
spendFromUsageEvents,
Expand Down
Loading
Loading