diff --git a/.pi/extensions/pi-workflow-engine/index.ts b/.pi/extensions/pi-workflow-engine/index.ts index 33daabe..b6de18f 100644 --- a/.pi/extensions/pi-workflow-engine/index.ts +++ b/.pi/extensions/pi-workflow-engine/index.ts @@ -646,13 +646,6 @@ function registerWorkflowTool( description: "Maximum delay accepted from a provider reset hint before automatic resume", }), ), - budget: Type.Optional( - Type.Integer({ - minimum: WORKFLOW_BUDGET_MIN, - maximum: WORKFLOW_BUDGET_MAX, - description: "Optional output-token ceiling for the run; agent() throws once it is exceeded", - }), - ), perf: Type.Optional(Type.Boolean({ description: "Include workflow performance timing aggregates in the result details" })), resumeFromRunId: Type.Optional(Type.String({ minLength: 1, description: "Workflow run id to resume from by replaying matching completed agent results" })), resumeEditedWorkflow: Type.Optional( @@ -681,7 +674,7 @@ function registerWorkflowTool( if (unavailable) return unavailable; } const turnBudget = dynamax.turnBudget(ctx); - const budget = resolveTurnBudget(params.budget, turnBudget, { min: WORKFLOW_BUDGET_MIN, max: WORKFLOW_BUDGET_MAX }); + const budget = resolveTurnBudget(turnBudget, { min: WORKFLOW_BUDGET_MIN, max: WORKFLOW_BUDGET_MAX }); if (budget.kind === "spent") { return workflowToolError( `The user's +${budget.total} output-token budget for this turn is spent by its earlier workflow runs. Do not start another workflow this turn unless the user raises the budget.`, diff --git a/.pi/extensions/pi-workflow-engine/src/dynamax.ts b/.pi/extensions/pi-workflow-engine/src/dynamax.ts index e69b2e4..5c24613 100644 --- a/.pi/extensions/pi-workflow-engine/src/dynamax.ts +++ b/.pi/extensions/pi-workflow-engine/src/dynamax.ts @@ -147,20 +147,18 @@ export type DynamaxTurnBudgetResolution = | { readonly kind: "spent"; readonly total: number }; /** - * A run's budget. Without a turn pool it is the explicit one, if any. The user's pool caps every run in - * its turn: the remainder, clamped to the run budget range, is the budget, and an explicit one can only - * lower it. A pool with less than `min` left is spent. + * A tool run's budget. Only the user sets one: without a turn pool this adds none, and the pool caps + * every run in its turn at the remainder, clamped to the run budget range. A pool with less than `min` + * left is spent. */ export function resolveTurnBudget( - explicit: number | undefined, pool: DynamaxTurnBudget | undefined, range: { readonly min: number; readonly max: number }, ): DynamaxTurnBudgetResolution { - if (pool === undefined) return { kind: "budget", budget: explicit }; + if (pool === undefined) return { kind: "budget", budget: undefined }; const remaining = pool.remaining(); if (remaining < range.min) return { kind: "spent", total: pool.total }; - const cap = Math.min(range.max, Math.floor(remaining)); - return { kind: "budget", budget: explicit === undefined ? cap : Math.min(explicit, cap) }; + return { kind: "budget", budget: Math.min(range.max, Math.floor(remaining)) }; } export function markDynamaxOneShot(state: DynamaxState): void { diff --git a/USAGE.md b/USAGE.md index a01df5b..cb778b9 100644 --- a/USAGE.md +++ b/USAGE.md @@ -606,7 +606,7 @@ Only tune these when a workflow is too slow, too expensive, or too noisy: | `--max-agents=N` / `PI_WORKFLOW_MAX_AGENTS=N` | Cap live model calls across the complete run, including sub-workflows. Default `64`; values are clamped to `1`–`10000`. Replay hits do not consume the cap. The `workflow` tool field is `maxAgents`. This is independent of the `/workflow:size` prompt guideline. | | `--agent-timeout-ms=N` / `PI_WORKFLOW_AGENT_TIMEOUT_MS=N` | Abort one live agent after this duration. Default `1800000` (30 minutes); values are clamped to `1000`–`86400000`. The session is aborted and its isolated worktree is cleaned up. The `workflow` tool field is `agentTimeoutMs`. | | `--agent-retries=N` / `PI_WORKFLOW_AGENT_RETRIES=N` | Restart the whole agent after a transient provider or transport failure that pi's in-session turn retry could not recover. Default `0`; values are clamped to `0`–`10`. Backoff starts at one second, doubles, and caps at 30 seconds. The `workflow` tool field is `agentRetries`. | -| `--budget=N` / `PI_WORKFLOW_BUDGET=N` | Set an output-token ceiling across recorded subagent attempts, including failed provider attempts. `agent()` throws `WorkflowBudgetExceededError` before starting another model request once the ceiling is reached; agents already running may overshoot because the engine does not reserve per-agent estimates. | +| `--budget=N` / `PI_WORKFLOW_BUDGET=N` | Set an output-token ceiling across recorded subagent attempts, including failed provider attempts. `agent()` throws `WorkflowBudgetExceededError` before starting another model request once the ceiling is reached; agents already running may overshoot because the engine does not reserve per-agent estimates. The `workflow` tool has no budget field; see [Turn budget directive](#turn-budget-directive). | | `--perf` / `PI_WORKFLOW_PERF=1` | Include internal timing aggregates. Usage/cost totals are reported separately from perf. | | `PI_WORKFLOW_LANE_ITEM_LIMIT=N` | Cap retained progress lane items. | @@ -636,16 +636,18 @@ do not count, and values under 1,000 tokens are ignored so a `+1` in ordinary prose never caps a run. The first such token in a typed prompt wins; prompts that extensions send are not read. -While the pool is active, a `workflow` tool call without its own `budget` runs -with the pool's remainder as its budget (clamped to the tool's budget range), -and each synchronous run's output tokens are subtracted once it finishes, -failed or not. When less than one token remains, the tool refuses the call and -tells the agent the turn's budget is spent. An explicit `budget` on the call -can only lower that ceiling, is refused the same way once the pool is spent, and -its spend is subtracted too. The pool clears when the run that +While the pool is active, every `workflow` tool call runs with the pool's +remainder as its budget (clamped to the run budget range), and each +synchronous run's output tokens are subtracted once it finishes, failed or not. +When less than one token remains, the tool refuses the call and tells the agent +the turn's budget is spent. The pool clears when the run that received the prompt settles, so it still covers the run after pi retries a provider error or compacts an overflowing context within that run. +The `workflow` tool has no `budget` field, so the host agent cannot choose a +ceiling: one it guesses can run out before a run's final stage. Without a pool, +a tool run takes `PI_WORKFLOW_BUDGET` if it is set and is otherwise unbudgeted. + Background runs receive the current remainder as their budget but do not draw the pool down, because their usage lands after the turn. Parallel `workflow` calls in one assistant message each see the same remainder before any of them diff --git a/skills/workflow-authoring/SKILL.md b/skills/workflow-authoring/SKILL.md index 85c7256..f6e7826 100644 --- a/skills/workflow-authoring/SKILL.md +++ b/skills/workflow-authoring/SKILL.md @@ -129,7 +129,7 @@ const keep = answers.real?.type === "bool" ? answers.real.probability > 0.5 : tr ## Budget -`api.budget.total` is the output-token ceiling (`null` when unset); `spent()` and `remaining()` are live. When the user's prompt carries a `+500k`-style directive, every run that turn is capped at the remaining pool (its own `budget` can only lower that), and synchronous runs draw the pool down; once it is spent the tool refuses further runs that turn. Once the ceiling is reached `agent()` rejects with `WorkflowBudgetExceededError` (a `null` slot inside `parallel`); agents already running can overshoot. Scale fleets from `total`, and guard every loop with it so an unbudgeted run still terminates: +`api.budget.total` is the output-token ceiling (`null` when unset); `spent()` and `remaining()` are live. Only the user sets a budget: the `workflow` tool takes none. When the user's prompt carries a `+500k`-style directive, every run that turn is capped at the remaining pool, and synchronous runs draw the pool down; once it is spent the tool refuses further runs that turn. Once the ceiling is reached `agent()` rejects with `WorkflowBudgetExceededError` (a `null` slot inside `parallel`); agents already running can overshoot. Scale fleets from `total`, and guard every loop with it so an unbudgeted run still terminates: ```js const width = api.budget.total ? Math.max(2, Math.min(8, Math.floor(api.budget.total / 40_000))) : 4; diff --git a/tests/dynamax.test.ts b/tests/dynamax.test.ts index 8f739c5..59a9350 100644 --- a/tests/dynamax.test.ts +++ b/tests/dynamax.test.ts @@ -670,21 +670,18 @@ test("a standalone +N directive sets a turn budget; other plus signs do not", () } }); -test("a turn budget pool caps every run until it is spent; an explicit budget can only lower the cap", () => { +test("a turn budget pool caps every run until it is spent; without one it adds no budget", () => { const range = { min: 1, max: 1_000_000 }; const pool = createDynamaxTurnBudget(50_000); - assert.deepEqual(resolveTurnBudget(undefined, undefined, range), { kind: "budget", budget: undefined }); - assert.deepEqual(resolveTurnBudget(undefined, pool, range), { kind: "budget", budget: 50_000 }); + assert.deepEqual(resolveTurnBudget(undefined, range), { kind: "budget", budget: undefined }); + assert.deepEqual(resolveTurnBudget(pool, range), { kind: "budget", budget: 50_000 }); pool.spend(30_000); pool.spend(Number.NaN); - assert.deepEqual(resolveTurnBudget(undefined, pool, range), { kind: "budget", budget: 20_000 }); - assert.deepEqual(resolveTurnBudget(7_000, pool, range), { kind: "budget", budget: 7_000 }); - assert.deepEqual(resolveTurnBudget(30_000, pool, range), { kind: "budget", budget: 20_000 }); - assert.deepEqual(resolveTurnBudget(undefined, createDynamaxTurnBudget(5_000_000), range), { kind: "budget", budget: 1_000_000 }); + assert.deepEqual(resolveTurnBudget(pool, range), { kind: "budget", budget: 20_000 }); + assert.deepEqual(resolveTurnBudget(createDynamaxTurnBudget(5_000_000), range), { kind: "budget", budget: 1_000_000 }); pool.spend(25_000); assert.equal(pool.remaining(), 0); - assert.deepEqual(resolveTurnBudget(undefined, pool, range), { kind: "spent", total: 50_000 }); - assert.deepEqual(resolveTurnBudget(7_000, pool, range), { kind: "spent", total: 50_000 }); + assert.deepEqual(resolveTurnBudget(pool, range), { kind: "spent", total: 50_000 }); }); test("the input hook sets the turn pool from user prompts only, and the run's settling clears it", async () => { diff --git a/tests/workflow-extension-fixtures.ts b/tests/workflow-extension-fixtures.ts index 53ddaea..b167c80 100644 --- a/tests/workflow-extension-fixtures.ts +++ b/tests/workflow-extension-fixtures.ts @@ -12,6 +12,7 @@ export interface CapturedTool { readonly name: string; readonly exposure?: string; readonly promptGuidelines?: readonly string[]; + readonly parameters: { readonly properties: Readonly> }; readonly reachesTools?: (input: Record) => "all" | readonly string[]; execute( toolCallId: string, diff --git a/tests/workflow-tool.test.ts b/tests/workflow-tool.test.ts index 5d2ed53..c3c5112 100644 --- a/tests/workflow-tool.test.ts +++ b/tests/workflow-tool.test.ts @@ -516,7 +516,7 @@ test("the workflow tool is a model-only orchestrator that codemode scripts canno assert.equal(captureWorkflowTool().exposure, "model-only"); }); -test("a +N prompt directive defaults the run budget to the turn pool until the run settles", async () => { +test("the host cannot budget a tool run; a +N directive caps it until the run settles", async () => { const extension = captureWorkflowExtension(); const input = extension.handlers.get("input")?.[0]; if (!input) throw new Error("expected an input handler"); @@ -533,9 +533,11 @@ export default async function run({ budget }) { const budgetOf = async (params: { budget?: number } = {}) => toolResultText(await extension.tool.execute("call-turn-budget", { script, ...params }, undefined, () => {}, ctx)); + // The host model cannot set a ceiling: the ones it guessed ran out before a run's final synthesis. + assert.equal("budget" in extension.tool.parameters.properties, false); await input({ source: "interactive", text: "+5k run the probe" }, ctx); assert.match(await budgetOf(), /budget 5000/); - assert.match(await budgetOf({ budget: 1234 }), /budget 1234/); + assert.match(await budgetOf({ budget: 1234 }), /budget 5000/); // A provider retry or overflow compaction ends a loop with agent_end and continues the same run. await emit("agent_end", { type: "agent_end", messages: [] }); assert.match(await budgetOf(), /budget 5000/);