Skip to content
Open
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
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@
cp package.json README.md LICENSE release/
tar -czf codewhip-${{ inputs.version }}.tar.gz -C release .
- name: Create Release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
with:
files: codewhip-${{ inputs.version }}.tar.gz
tag_name: ${{ inputs.version }}
Expand Down
69 changes: 69 additions & 0 deletions src/budget.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import test from "node:test";
import assert from "node:assert/strict";
import { allocateChildBudgets } from "./budget.js";

/**
* The child budget split (`src/budget.ts`).
*
* This module had no test file at all while the arithmetic it owned was
* wrong: `Math.max(1, …)` clamps made each child look owed at least one token,
* so four children on a two-token remainder each drew 1 and the SUM exceeded
* the pool it was split from — while `parentBudget` still reported 2. The
* invariant that matters is the sum, so it is pinned over a table rather than
* by the examples that follow.
*/

test("the sum of allocations never exceeds the parent's remaining budget", () => {
for (const parent of [0, 1, 2, 3, 4, 5, 9, 10, 99, 100, 250_000]) {
for (const count of [1, 2, 3, 4]) {
const r = allocateChildBudgets(parent, count);
const sum = r.children.reduce((s, c) => s + c.allocated, 0);
assert.ok(
sum + r.coordinationBudget <= parent,
`parent=${parent} count=${count}: children ${sum} + coord ${r.coordinationBudget} > ${parent}`
);
assert.equal(r.parentBudget, parent, "the reported pool is the real pool, not a rounded one");
assert.equal(r.children.length, count, "every child is still reported, funded or not");
}
}
});

test("a tight pool zeroes the children it cannot fund instead of flooring them", () => {
const r = allocateChildBudgets(2, 4);
assert.deepEqual(r.children.map((c) => c.allocated), [0, 0, 0, 0]);
assert.equal(r.coordinationBudget, 1, "the remainder stays with the coordination reserve");
// The old clamp produced [1,1,1,1] here — 4 tokens out of a 2-token pool.
assert.equal(r.children.reduce((s, c) => s + c.allocated, 0), 0);
});

test("the default 85% child share / 15% coordination reserve", () => {
const r = allocateChildBudgets(100, 1);
assert.equal(r.children[0]?.allocated, 85);
assert.equal(r.coordinationBudget, 15);
const two = allocateChildBudgets(100, 2);
assert.equal(two.children[0]?.allocated, 42);
assert.equal(two.children[1]?.allocated, 42);
assert.equal(two.coordinationBudget, 15, "the reserve is held once, not per child");
});

test("a negative or zero pool allocates nothing and reports no NaN", () => {
for (const parent of [0, -5, -1000]) {
const r = allocateChildBudgets(parent, 4);
assert.equal(r.children.reduce((s, c) => s + c.allocated, 0), 0);
assert.equal(r.coordinationBudget, 0);
for (const c of r.children) assert.equal(Number.isFinite(c.allocated), true);
}
});

test("childrenCount 0 allocates an empty list, not an infinity", () => {
const r = allocateChildBudgets(1000, 0);
assert.deepEqual(r.children, []);
assert.equal(r.coordinationBudget, 150, "the reserve is the pool's own remainder");
});

test("an explicit childShare overrides the default and still honours the sum", () => {
const r = allocateChildBudgets(100, 2, { childShare: 0.5 });
assert.equal(r.children[0]?.allocated, 25);
assert.equal(r.coordinationBudget, 50);
assert.ok(r.children.reduce((s, c) => s + c.allocated, 0) + r.coordinationBudget <= 100);
});
29 changes: 26 additions & 3 deletions src/budget.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,21 @@
import { sha256Hex } from "./hash.js";

/**
* A ledger masquerading as an allocation — narrowed to its two real lines.
*
* This module answers exactly one question: given what the parent has left,
* how much does each child get, and how much stays with the parent? The
* `used`/`status` fields are carried by no caller and the hashed child ids are
* a constant string over the index, so they exist to be read, not to be
* written; the invariant that matters is `Σ allocated ≤ parentRemaining`, which
* is pinned by `src/budget.test.ts` over a table of (parent, count) pairs
* rather than by the examples in it.
*
* Surfaces must read `allocated === 0` as "not enough pool for this child" and
* say so: the child runner deliberately will not re-floor a 0 into 1 (see
* `runChildAgent`), because a floor is how the split stops being a split.
*/

export type ChildAllocation = {
id: string;
allocated: number;
Expand Down Expand Up @@ -28,10 +44,17 @@ export function allocateChildBudgets(
): BudgetAllocationResult {
const childShare = options.childShare ?? DEFAULT_CHILD_SHARE;

const budgetForChildren = Math.max(1, Math.floor(parentRemaining * childShare));
const coordBudget = Math.max(0, parentRemaining - budgetForChildren);
// No `Math.max(1, …)` clamp anywhere. A clamp makes each child look owed at
// least one token, so four children on a 2-token remainder each get 1 and
// the SUM exceeds the pool it was split from — the over-allocation that
// made "children are capped by the parent's budget" false exactly when the
// budget was tight enough to matter. The remainder belongs to the
// coordination reserve and is reported as unallocated, not distributed.
const pool = Math.max(0, Math.floor(parentRemaining));
const budgetForChildren = Math.floor(pool * childShare);
const coordBudget = pool - budgetForChildren;

const perChild = Math.max(1, Math.floor(budgetForChildren / childrenCount));
const perChild = childrenCount <= 0 ? 0 : Math.floor(budgetForChildren / childrenCount);
const children: ChildAllocation[] = [];

for (let i = 0; i < childrenCount; i++) {
Expand Down
10 changes: 10 additions & 0 deletions src/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -952,6 +952,11 @@ export async function agentLoop(args: LoopArgs): Promise<LoopResult> {
onChildEvent: (text) => emit("tool", text),
remainingBudget: args.tokenBudget === undefined ? undefined : Math.max(0, args.tokenBudget - (promptTokens + completionTokens)),
rotationModels: args.models,
// The run's dollar ceiling, forwarded to a SINGLE child (see
// ToolContext.costCheck: a fan-out holding the same closure
// would each see only its own buckets and report a ceiling it
// spends MAX_FANOUT times).
...(args.costCheck === undefined ? {} : { costCheck: args.costCheck }),
retryWait: args.retryWait,
compactTokens: args.compactTokens,
disallowedTools: args.disallowedTools,
Expand Down Expand Up @@ -1436,6 +1441,11 @@ export async function agentLoop(args: LoopArgs): Promise<LoopResult> {
remainingBudget:
args.tokenBudget === undefined ? undefined : Math.max(0, args.tokenBudget - (promptTokens + completionTokens)),
rotationModels: args.models,
// The run's dollar ceiling, forwarded to a SINGLE child (see
// ToolContext.costCheck: a fan-out holding the same closure
// would each see only its own buckets and report a ceiling it
// spends MAX_FANOUT times).
...(args.costCheck === undefined ? {} : { costCheck: args.costCheck }),
retryWait: args.retryWait,
compactTokens: args.compactTokens,
disallowedTools: args.disallowedTools,
Expand Down
18 changes: 17 additions & 1 deletion src/subagents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -562,6 +562,14 @@ export type ChildRunOptions = {
parentRunId?: string;
/** Parent's `--debug` sink — a child's decisions join the same log. */
debug?: Debug;
/**
* The run's dollar ceiling, forwarded to a SINGLE child only. A fan-out
* deliberately does not get it: four concurrent children each holding the
* same closure each see only their own buckets, so each would believe it
* alone owns the ceiling and the fan-out would spend four times what it
* reports. Its ceiling stays at the parent's turn boundary.
*/
costCheck?: LoopArgs["costCheck"];
/** Progress lines, already prefixed with the agent name. */
onEvent?: (text: string) => void;
/** Per-child wall clock — the child's signal aborts when it fires. */
Expand Down Expand Up @@ -603,9 +611,17 @@ export async function runChildAgent(opts: ChildRunOptions): Promise<ChildRunResu
...(opts.debug === undefined ? {} : { debug: opts.debug }),
...(opts.parentRunId === undefined ? {} : { parentRunId: opts.parentRunId }),
...(opts.roots === undefined ? {} : { roots: opts.roots }),
...(opts.tokenBudget === undefined ? {} : { tokenBudget: Math.max(1, opts.tokenBudget) }),
// No floor: a 0 allocation is the split saying "this child gets nothing",
// and re-flooring it to 1 is how four children once drew four tokens out
// of a two-token pool. The token ceiling is enforced in the loop against
// this number as-is.
...(opts.tokenBudget === undefined ? {} : { tokenBudget: opts.tokenBudget }),
...(opts.compactTokens === undefined ? {} : { compactTokens: opts.compactTokens }),
...(opts.retryWait === undefined ? {} : { retryWait: opts.retryWait }),
// One dollar ceiling, enforced inside the child too — the parent's own
// check evaluates at its next turn, so without this a child runs its whole
// (bounded, 85%-share) budget before the ceiling is read at all.
...(opts.costCheck === undefined ? {} : { costCheck: opts.costCheck }),
// Rotation only makes sense on the parent's model family; a per-agent
// model override rides a different id space — never forward candidates.
...(opts.models !== undefined && opts.agent.model === undefined && opts.models.length > 0 ? { models: opts.models } : {}),
Expand Down
4 changes: 4 additions & 0 deletions src/tools/child-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ export function runChild(
disallowedTools: ctx.disallowedTools,
parentRunId: ctx.parentRunId,
debug: ctx.debug,
// The parent's dollar ceiling rides a single child only (delegate_many's
// four concurrent holders would each see only their own buckets, so the
// fan-out keeps its ceiling at the parent's turn boundary).
...(ctx.costCheck === undefined ? {} : { costCheck: ctx.costCheck }),
deadlineMs: child.deadlineMs,
...(ctx.onChildEvent === undefined ? {} : { onEvent: ctx.onChildEvent }),
});
Expand Down
13 changes: 11 additions & 2 deletions src/tools/delegate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,18 +63,27 @@ export async function runDelegate(ctx: ToolContext, args: { agent: string; task:
const budget = ctx.remainingBudget === undefined
? undefined
: allocateChildBudgets(ctx.remainingBudget, 1);
const share = budget?.children[0]?.allocated;
ctx.onChildEvent?.(`[${agent.name}] started: ${args.task.slice(0, 120)}`);
if (share === 0) {
// The pool cannot fund this child. Spawning it would over-allocate the
// parent's own ceiling, so it is refused with the numbers, not run.
return {
ok: false,
output: `delegate: the parent's remaining token pool (${ctx.remainingBudget}) is too small to fund ${agent.name} (allocated 0) — spend fewer steps, raise --token-budget, or do the work in this run`,
};
}
if (budget !== undefined) {
ctx.onChildEvent?.(
`[budget] parent=${budget.parentBudget} coord=${budget.coordinationBudget}` +
` child=${budget.children[0]?.allocated ?? 0} (85% share, 15% reserve)`
);
}
ctx.onChildEvent?.(`[${agent.name}] started: ${args.task.slice(0, 120)}`);
const r = await runChild(ctx, {
agent,
task: args.task,
signal,
tokenBudget: budget?.children[0]?.allocated,
tokenBudget: share,
deadlineMs: DELEGATE_TIMEOUT_MS,
});
ctx.onChildUsage?.(r.usageByModel);
Expand Down
48 changes: 36 additions & 12 deletions src/tools/delegate_many.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,38 +91,62 @@ export async function runDelegateMany(ctx: ToolContext, args: { entries: Entry[]
const budget = ctx.remainingBudget === undefined
? undefined
: allocateChildBudgets(ctx.remainingBudget, agents.length);
// The visible start is unconditional. It used to sit inside the
// `budget !== undefined` branch, so a fan-out on a run without a token
// budget started several children with nothing between the last event and
// the first report — the exact "silent silence" the line exists to prevent.
for (const { name, task } of agents) {
ctx.onChildEvent?.(`[${name}] started: ${task.slice(0, 120)}`);
}
// `allocated === 0` is the split saying "the pool cannot fund this child".
// Running it anyway is how four children once drew four tokens out of a
// two-token pool, so a zero is dropped — and named, because a silently
// dropped perspective is the unaccountable outcome this codebase refuses.
const dropped = agents.filter((_, i) => budget?.children[i]?.allocated === 0);
if (dropped.length > 0) {
ctx.onChildEvent?.(
`[budget] dropped ${dropped.length} child(ren): ${dropped.map((d) => d.name).join(", ")} — each allocated 0 of the parent's remaining pool (${ctx.remainingBudget})`
);
}
const runnable = agents
.map((a, i) => ({ entry: a, index: i, tokens: budget?.children[i]?.allocated }))
.filter((r) => r.tokens === undefined || r.tokens > 0);
if (runnable.length === 0) {
return {
ok: false,
output: `delegate_many: the parent's remaining token pool (${ctx.remainingBudget}) cannot fund any child (each allocated 0) — spend fewer steps, raise --token-budget, or do the work in this run`,
};
}
if (budget !== undefined) {
for (const { name, task } of agents) {
ctx.onChildEvent?.(`[${name}] started: ${task.slice(0, 120)}`);
}
ctx.onChildEvent?.(
`[budget] parent=${budget.parentBudget} coord=${budget.coordinationBudget}` +
` children=${budget.children.reduce((s, c) => s + c.allocated, 0)} (${agents.length} children, 85% share, 15% reserve)`
` children=${runnable.reduce((s, r) => s + (r.tokens ?? 0), 0)} (${runnable.length} of ${agents.length} children, 85% share, 15% reserve)`
);
}
const runs = await Promise.all(
agents.map((agentEntry, i) =>
runnable.map((r) =>
runChild(ctx, {
agent: agentEntry.def,
task: agentEntry.task,
agent: r.entry.def,
task: r.entry.task,
signal,
tokenBudget: budget?.children[i]?.allocated,
tokenBudget: r.tokens,
deadlineMs: DELEGATE_TIMEOUT_MS,
})
)
);
const lines: string[] = [];
const runIds: string[] = [];
let failed = 0;
runs.forEach((r, i) => {
runs.forEach((r, k) => {
ctx.onChildUsage?.(r.usageByModel);
const name = agents[i]?.name ?? `agent${i + 1}`;
const name = runnable[k]?.entry.name ?? `agent${k + 1}`;
const slot = runnable[k]?.index ?? k;
if (r.ok) {
runIds.push(r.runId);
lines.push(`#${i + 1} [${name}]\n${r.text}`);
lines.push(`#${slot + 1} [${name}]\n${r.text}`);
} else {
failed += 1;
lines.push(`#${i + 1} [${name}] FAILED: ${r.error}`);
lines.push(`#${slot + 1} [${name}] FAILED: ${r.error}`);
}
});
const tail = runIds.length > 0 ? `\n[subagent runIds: ${runIds.join(", ")}]` : "";
Expand Down
9 changes: 9 additions & 0 deletions src/tools/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { ChatPort } from "../provider-port.js";
import type { UsageBucket } from "../outcomes.js";
import type { Debug } from "../debug.js";
import type { ToolFilter } from "../tool-filter.js";
import type { StopReason } from "../loop.js";
import type { AgentDef } from "../subagents.js";

/**
Expand Down Expand Up @@ -96,6 +97,14 @@ export type ToolContext = {
/** Same-provider rotation candidates + 429 retry-wait, forwarded so a
* rate-limited child rotates instead of dying (parallel fan-out friendly). */
rotationModels?: string[];
/**
* The run's dollar ceiling as the parent evaluates it. Forwarded to a SINGLE
* child (not a fan-out: four concurrent children each holding the same
* closure each see only their own buckets, so the fan-out would report a
* ceiling it spends four times). Same shape as `LoopArgs.costCheck`, and
* injected by the surface — core never imports a price table.
*/
costCheck?: (buckets: UsageBucket[]) => { stopReason: StopReason; message: string } | null;
retryWait?: boolean;
/** Parent's compaction ceiling (children share the transcript-size policy). */
compactTokens?: number;
Expand Down
Loading