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
9 changes: 1 addition & 8 deletions .pi/extensions/pi-workflow-engine/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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.`,
Expand Down
12 changes: 5 additions & 7 deletions .pi/extensions/pi-workflow-engine/src/dynamax.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
18 changes: 10 additions & 8 deletions USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |

Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion skills/workflow-authoring/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
15 changes: 6 additions & 9 deletions tests/dynamax.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
1 change: 1 addition & 0 deletions tests/workflow-extension-fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export interface CapturedTool {
readonly name: string;
readonly exposure?: string;
readonly promptGuidelines?: readonly string[];
readonly parameters: { readonly properties: Readonly<Record<string, unknown>> };
readonly reachesTools?: (input: Record<string, unknown>) => "all" | readonly string[];
execute(
toolCallId: string,
Expand Down
6 changes: 4 additions & 2 deletions tests/workflow-tool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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/);
Expand Down
Loading