Files
pig/apps/piggy/test/turn-budget.test.ts
T
claude f0173440e4
CI / verify (push) Successful in 7m6s
CI / publish (push) Has been skipped
Put Piggy on Prime Agent, and let it write to the book
Piggy was a hand-rolled OpenAI tool loop. It is now a Prime Agent session —
Prime Intellect's own harness, embedded as a Node library — answering from
PIG's tools and, for the first time, able to put information into the CRM
rather than only read it out.

The harness is a coding agent, so the first job was taking the coding agent
away from it. `noTools: 'all'` plus an explicit allowlist leaves the model
with PIG's ten `pig_*` tools and no bash, no filesystem, no IPython. That
holds under attack: a hostile extension, a skill and a settings file planted
in the agent's own directory, then `setActiveToolsByName` called with every
built-in, still leaves ten tools, all ours. Both lines are load-bearing —
`noTools` alone registers nothing, and the allowlist is what admits our own.

Writing is gated rather than assumed. A change is proposed, not made: the
tool returns a description, the transcript renders a diff card, and nothing
reaches the database until someone presses Apply. Contracts, commitments,
allocations and compliance always stop for a human whatever the mode. Every
write runs through `executeMutation` as the calling user, so their
capabilities and the audit trail apply exactly as they would to a human's.

Four things about the SDK are wrong in its own documentation and cost a
debugging cycle each: models.json does not resolve an env var name for
`apiKey`, it sends the literal string; there is no built-in prime-inference
provider in 0.84.1; a ResourceLoader you pass in is never reloaded for you;
and the stock system prompt is a coding-assistant prompt that must be
replaced — but replacing it also silently removes the tool list, because the
harness only renders that section when it owns the prompt. AGENTS.md records
all four.

The expensive one was thinking level. The harness defaults to `medium`, and
nemotron spent an entire 4,096-token budget reasoning and returned an empty
answer. `low` was worse; `off` omits the parameter so the endpoint's default
wins. An explicit `reasoning_effort: none` via `thinkingLevelMap` took a turn
from 6,195 output tokens to 149.

And a turn is now bounded. The harness loop is `while (true)` with no
iteration cap; a runaway on a frontier model would have eaten the credit it
is supposed to report on. Ceilings on model calls and tokens, enforced both
through the harness hook and independently from the event stream, plus a
per-user daily spend limit — and the ledger now records spend on turns that
fail, which it previously discarded.

Signing in lands on /piggy, which is a workspace: conversations down one
side, the agent in the middle, what it did and what it cost beside it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 05:26:28 -07:00

264 lines
10 KiB
TypeScript

/**
* The cost ceiling, proved against the real harness rather than argued for.
*
* `@earendil-works/pi-agent-core`'s `agent-loop.js` is a `while (true)` with
* four exits: the model stops asking for tools, it errors, the run is aborted,
* or `shouldStopAfterTurn` returns true. Nothing in it counts iterations and
* nothing in it counts tokens, so a model that keeps asking for one more tool
* call keeps buying model calls until somebody stops it.
*
* Every test here drives that real loop — real `createAgentSession`, real tool
* execution, real event stream — with the provider swapped for a stand-in that
* always asks for another call. `Agent.streamFunction` is a public, mutable
* property and is the only seam that lets an offline test spend "money": the
* alternative is a live endpoint and a real bill, which is not a test.
*/
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test, { after, before } from 'node:test';
import { defineTool, type AgentSession, type ToolDefinition } from '@earendil-works/pi-coding-agent';
import { Type } from 'typebox';
import { createTurnBudget, observeTurn, type PiggySession } from '../src/agent/session';
import type { PiggyTurnLimits } from '../src/config';
const agentDir = mkdtempSync(join(tmpdir(), 'piggy-budget-test-'));
before(() => {
process.env.DATABASE_URL = 'postgres://pig:pig@localhost:54330/pig';
process.env.PIGGY_INTERNAL_TOKEN = 'test-internal-token-for-piggy-000000';
process.env.PRIME_API_KEY = 'test-key-not-used-offline';
process.env.PIGGY_AGENT_DIR = agentDir;
});
after(() => {
rmSync(agentDir, { recursive: true, force: true });
});
function limits(overrides: Partial<PiggyTurnLimits> = {}): PiggyTurnLimits {
return { maxModelCalls: 8, maxTurnTokens: 40_000, dailyLimitCents: 0, ...overrides };
}
/** A tool that always succeeds, so the loop is never stopped by a tool failing. */
function alwaysAnswers(): ToolDefinition {
return defineTool({
name: 'pig_get_workspace_summary',
label: 'Workspace summary',
description: 'Test double: always answers.',
promptSnippet: 'pig_get_workspace_summary: test double.',
parameters: Type.Object({}),
async execute() {
return { content: [{ type: 'text' as const, text: '{"ok":true}' }], details: { ok: true } };
},
});
}
/** The harness's stream function, reached through the object that owns it. */
type StreamFunction = AgentSession['agent']['streamFunction'];
type StreamResult = Awaited<ReturnType<StreamFunction>>;
interface Provocation {
/** How many times the loop asked the provider for another response. */
calls: number;
}
/**
* A provider that always asks for another tool call.
*
* This is the runaway in its purest form: every response is a well-formed
* assistant message whose only content is a tool call, which is precisely the
* condition `agent-loop.js` uses to decide it has more to do. `relentUntil`
* exists only so the control test — the one that shows nothing else stops this
* — terminates: without a cap of our own, the loop's own stopping condition
* never arrives.
*/
function provokeAnotherCall(
session: PiggySession,
usagePerCall: { input: number; output: number },
relentAfter = Number.POSITIVE_INFINITY,
): Provocation {
const provocation: Provocation = { calls: 0 };
const model = session.session.agent.state.model;
const stream: StreamFunction = () => {
provocation.calls += 1;
const relent = provocation.calls >= relentAfter;
const message = {
role: 'assistant',
content: relent
? [{ type: 'text', text: 'Done.' }]
: [
{
type: 'toolCall',
id: `call_${provocation.calls}`,
name: 'pig_get_workspace_summary',
arguments: {},
},
],
api: model.api,
provider: model.provider,
model: model.id,
usage: {
input: usagePerCall.input,
output: usagePerCall.output,
cacheRead: 0,
cacheWrite: 0,
totalTokens: usagePerCall.input + usagePerCall.output,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: relent ? 'stop' : 'toolUse',
timestamp: Date.now(),
};
// An empty event sequence with a result is a shape the loop handles: it
// falls through to `response.result()` and emits the message itself. The
// cast is the same one the chat-server tests make — building all forty
// fields of a streamed AssistantMessage would test the double, not the cap.
return {
[Symbol.asyncIterator]: () => ({ next: async () => ({ done: true as const, value: undefined }) }),
result: async () => message,
} as unknown as StreamResult;
};
session.session.agent.streamFunction = stream;
return provocation;
}
test('nothing in the harness stops a model that keeps asking for another call', async () => {
const { createPiggySession } = await import('../src/agent/session');
// Deliberately no budget: this is the finding, reproduced. The loop runs as
// many model calls as the model asks for, and the only reason this test
// terminates is that the stand-in provider gives up after twenty.
const piggy = await createPiggySession({ mode: 'read_only', tools: [alwaysAnswers()] });
try {
const provocation = provokeAnotherCall(piggy, { input: 5_000, output: 150 }, 20);
await piggy.session.prompt('How are we doing?');
assert.equal(provocation.calls, 20);
} finally {
piggy.dispose();
}
});
test('the model-call ceiling stops the runaway at exactly its ceiling', async () => {
const { createPiggySession } = await import('../src/agent/session');
const budget = createTurnBudget(limits({ maxModelCalls: 3 }));
const piggy = await createPiggySession({
mode: 'read_only',
tools: [alwaysAnswers()],
budget,
});
try {
// Never relents. Without the ceiling this call does not return.
const provocation = provokeAnotherCall(piggy, { input: 5_000, output: 150 });
await piggy.session.prompt('How are we doing?');
assert.equal(provocation.calls, 3, 'the loop bought more calls than the ceiling allows');
assert.equal(budget.breach?.limit, 'model_calls');
assert.equal(budget.breach?.ceiling, 3);
assert.equal(budget.breach?.modelCalls, 3);
// The stop is graceful: the loop ends of its own accord rather than being
// aborted, so the turn settles instead of spinning.
assert.equal(budget.overran, false);
} finally {
piggy.dispose();
}
});
test('the token ceiling stops a turn whose calls are few and enormous', async () => {
const { createPiggySession } = await import('../src/agent/session');
// A cap on calls alone is escapable: eight calls of a hundred thousand tokens
// is a hundred times a normal turn while never reaching the call ceiling.
const budget = createTurnBudget(limits({ maxModelCalls: 100, maxTurnTokens: 30_000 }));
const piggy = await createPiggySession({
mode: 'read_only',
tools: [alwaysAnswers()],
budget,
});
try {
const provocation = provokeAnotherCall(piggy, { input: 12_000, output: 500 });
await piggy.session.prompt('Summarise everything.');
// 12,500 per call, so the third call is the one that passes 30,000.
assert.equal(provocation.calls, 3);
assert.equal(budget.breach?.limit, 'tokens');
assert.equal(budget.breach?.tokens, 37_500);
assert.equal(budget.breach?.ceiling, 30_000);
} finally {
piggy.dispose();
}
});
test('input tokens count, because input is what a tool-heavy turn is billed for', async () => {
const { createPiggySession } = await import('../src/agent/session');
// Measured on the live stack: a two-tool turn on the default model is 12,099
// input and 166 output. A ceiling that counted only output would have let
// that turn run 70 times over before noticing.
const budget = createTurnBudget(limits({ maxModelCalls: 100, maxTurnTokens: 12_000 }));
const piggy = await createPiggySession({
mode: 'read_only',
tools: [alwaysAnswers()],
budget,
});
try {
const provocation = provokeAnotherCall(piggy, { input: 6_000, output: 20 });
await piggy.session.prompt('Summarise everything.');
assert.equal(provocation.calls, 2);
assert.equal(budget.breach?.limit, 'tokens');
} finally {
piggy.dispose();
}
});
test('a turn well inside both ceilings is never interfered with', async () => {
const { createPiggySession } = await import('../src/agent/session');
const budget = createTurnBudget(limits());
const piggy = await createPiggySession({
mode: 'read_only',
tools: [alwaysAnswers()],
budget,
});
try {
// The measured shape of a real two-tool turn: three model calls, ~12,265
// tokens. It must finish on the model's own terms.
const provocation = provokeAnotherCall(piggy, { input: 4_000, output: 90 }, 3);
await piggy.session.prompt('Which supplier has the lowest utilisation?');
assert.equal(provocation.calls, 3);
assert.equal(budget.breach, undefined);
assert.equal(budget.modelCalls, 3);
assert.equal(budget.tokens, 12_270);
} finally {
piggy.dispose();
}
});
test('two counters of the same turn merge rather than halving the ceiling', () => {
// The in-loop hook and the chat server both report what they have seen, and
// they are describing the same model calls. Summing them would cut every
// ceiling in half and stop honest turns; `observeTurn` takes the larger
// reading instead.
const budget = createTurnBudget(limits({ maxModelCalls: 4 }));
observeTurn(budget, 1, 3_000);
observeTurn(budget, 1, 3_000);
observeTurn(budget, 2, 6_000);
observeTurn(budget, 2, 6_000);
assert.equal(budget.modelCalls, 2);
assert.equal(budget.tokens, 6_000);
assert.equal(budget.breach, undefined);
});
test('a model call after the ceiling is recorded as an overrun, not ignored', () => {
// What it looks like when the in-loop stop does not hold — a harness upgrade
// that claims `shouldStopAfterTurn` for itself, say. The operator has to be
// able to see that the graceful brake failed and the hard one was needed.
const budget = createTurnBudget(limits({ maxModelCalls: 2 }));
observeTurn(budget, 1, 1_000);
observeTurn(budget, 2, 2_000);
assert.equal(budget.breach?.limit, 'model_calls');
assert.equal(budget.overran, false);
observeTurn(budget, 3, 3_000);
assert.equal(budget.overran, true);
// The breach itself is never rewritten: it records where the line was crossed.
assert.equal(budget.breach?.modelCalls, 2);
});