Files
pig/apps/piggy/src/chat-server.ts
T
claude 18d5f5bfc0
CI / verify (push) Successful in 7m10s
CI / publish (push) Has been skipped
Make Piggy part of the product rather than a guest in it
Piggy arrived as a chat panel bolted onto a CRM and then grew a workspace
around it. The layout was already right — the audit found the approval card
to be the best-designed object in the repo, and the account page's empty
panels less finished than anything in the workspace. What was wrong was
vocabulary: nobody had written the small things down, so both halves kept
inventing them.

Piggy was drawn with five different marks — a pig in the dock, a sparkle in
the sidebar and again on the model picker, a speech bubble on the Ask
buttons, and a stock robot glyph on every assistant message, which is the
one people look at most. There is now one mark. The composer, which is the
first control in the product since sign-in lands on /piggy, was the only
un-adapted shadcn field left: 6px radius against a 12px Send button it sat
8px from. A stat tile had been reinvented six times at three numeral scales,
and the same uppercase micro-label existed in five variants, two of them one
tab apart in the same rail. There were 63 hand-written font sizes: not a
scale, sixty-three opinions.

Underneath that, the focus ring was invisible. The global rule used
ring-accent, which Tailwind deliberately aliases onto the hover tint, so the
ring measured 1.01:1 against the light canvas — no visible focus indicator
anywhere in the product, for any accent, in either theme. It is ring-brand
now and measures 17:1. The warning, positive and info tones were darkened
until each clears 4.5:1 on a card, on inset and on its own chip, and the
light canvas moved to 98% so a card lifts without leaning on its shadow.

The mobile work is the part worth reading. A landscape phone gave the
transcript 28% of the viewport and a keyboard-up phone 16%, against a 45%
floor — and the fixed tab bar painted over the composer, covering the safety
sentence and half the Send button, because two source comments asserted the
bar stood down on short viewports and it never had. Both fixed and measured
by hit-testing rather than by screenshot. The composer itself was 64px tall
for a blank second line nobody typed, because the auto-resize effect sizes
to scrollHeight and scrollHeight counts rows — a CSS height could not win
against an inline style, so the attribute was the honest lever.

Verified across both themes driven through the app's own control: no
horizontal overflow on 15 routes at four viewports, 672 stat values that fit,
297 labels at exactly 11px/500, Escape returning focus to its opener rather
than the body on every overlay, and a rejected write no longer reporting
"Succeeded" with a green check.

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

1593 lines
65 KiB
TypeScript

import { randomUUID, timingSafeEqual } from 'node:crypto';
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
import { and, eq, gte, sql } from 'drizzle-orm';
import { z } from 'zod';
import {
PIGGY_MODES,
PIGGY_PAGE_ROUTES,
PIGGY_RECORD_TYPES,
TEAMS,
TEAM_ROLES,
type PiggyApprovalDecision,
type PiggyChatEvent,
type PiggyModelOption,
type PiggyProposedChange,
} from '@pig/core';
import { agentRuns, type Database } from '@pig/db';
import type { Principal } from '@pig/api/src/lib/auth';
import type { AgentSessionEvent, ToolDefinition } from '@earendil-works/pi-coding-agent';
import { piggyModelCatalogue } from './agent/models';
import {
createPiggySession,
createTurnBudget,
observeTurn,
type CreatePiggySessionOptions,
type PiggySession,
type PiggyTurnBreach,
type PiggyTurnBudget,
} from './agent/session';
import { toPrimeTools } from './agent/tool-bridge';
import {
loadPiggyStallLimits,
loadPiggyTurnLimits,
type PiggyStallLimits,
type PiggyTurnLimits,
} from './config';
import { assertPigToolBoundary, type PiggyChatContext } from './chat';
import { createInteractivePigTools } from './chat-tools';
import { createPigWriteTools, type PigWriteToolDeps } from './write-tools';
/**
* Derived from the @pig/core tuples rather than retyped, because this schema
* is `.strict()` and so is the relay's: a context shape one of them has not
* been told about is a 400, not a degraded answer. `route` is a closed set
* because a docked panel publishes it on every navigation, and free text there
* would put arbitrary client strings into a model prompt on every page change.
*/
const contextSchema = z.discriminatedUnion('type', [
z
.object({
type: z.enum(PIGGY_RECORD_TYPES),
id: z.string().uuid(),
label: z.string().max(240).optional(),
})
.strict(),
z
.object({
type: z.literal('page'),
route: z.enum(PIGGY_PAGE_ROUTES),
label: z.string().max(240).optional(),
})
.strict(),
]);
/**
* The calling user, as the relay authenticated them.
*
* The relay used to send a bare `principalUserId`, and the tools ran unscoped.
* That was survivable while Piggy could only read. It is not survivable now that
* it writes: `executeMutation` enforces capabilities against `teams` and stamps
* the audit activity with this identity, so a turn that arrives without one has
* no honest way to write at all. `.strict()` because an unrecognised field here
* means the relay has drifted from this contract, and the safest reading of a
* drifted identity is to refuse it rather than to guess which half is current.
*/
const principalSchema = z
.object({
userId: z.string().uuid(),
email: z.string().max(320),
name: z.string().max(240),
isPlatformAdmin: z.boolean(),
teams: z.array(z.object({ team: z.enum(TEAMS), role: z.enum(TEAM_ROLES) }).strict()).max(16),
via: z.enum(['jwt', 'api_key', 'development']),
apiKeyId: z.string().optional(),
scopes: z.array(z.string().max(64)).max(32),
})
.strict();
export const piggyChatRequestSchema = z
.object({
principal: principalSchema,
message: z.string().trim().min(1).max(4_000),
history: z
.array(
z.object({
role: z.enum(['user', 'assistant']),
content: z.string().min(1).max(8_000),
}),
)
.max(20)
.optional(),
context: contextSchema.optional(),
mode: z.enum(PIGGY_MODES),
modelId: z.string().min(1).max(160).optional(),
/**
* The client's own id for the thread, and the first half of an approval's
* address. Required rather than defaulted: a decision answered against a
* conversation id the server invented would resolve nothing.
*/
conversationId: z.string().min(1).max(120),
})
.strict();
export const piggyApprovalRequestSchema = z
.object({
conversationId: z.string().min(1).max(120),
changeId: z.string().uuid(),
decision: z.enum(['apply', 'reject']),
})
.strict();
/**
* How long a proposed write may wait for a human.
*
* Not a nicety. The turn stays open across an approval, so an unanswered card
* holds an inference connection — one that is being billed — for as long as it
* is unanswered. Five minutes is long enough to read a diff card and think, and
* short enough that a closed laptop cannot pin a connection until the process
* restarts. The write tools enforce their own deadline over the same decision;
* both settle as a rejection, so the two can race without disagreeing.
*/
export const PIGGY_APPROVAL_TIMEOUT_MS = 5 * 60 * 1_000;
/** Injected by the tests, which must not open a session against real inference. */
export type PiggySessionFactory = (
options: CreatePiggySessionOptions,
) => Promise<PiggySession>;
export interface PiggyChatServerOptions {
host?: string;
port: number;
internalToken: string;
allowNonLoopback?: boolean;
/**
* The picker's catalogue, served at `/internal/models` so no client hard-codes
* a model list, and the price list this server bills against. One source, so a
* model cannot be offered at a price nothing charges.
*/
models?: readonly PiggyModelOption[];
createSession?: PiggySessionFactory;
createWriteTools?: (deps: PigWriteToolDeps) => ToolDefinition[];
createReadTools?: (db: Database, context: PiggyChatContext | undefined) => ToolDefinition[];
approvalTimeoutMs?: number;
/**
* What one turn, and one user's day, may cost. Read from the environment when
* absent, because `main.ts` deliberately passes this server only the socket
* and the token: a deployment should have one place to set a ceiling, not
* two that can disagree.
*/
limits?: PiggyTurnLimits;
/**
* How long a turn may go silent before the endpoint is presumed to have gone
* quiet. Read from the environment when absent, for the same reason as
* `limits`.
*/
stallLimits?: PiggyStallLimits;
}
export function startPiggyChatServer(db: Database, options: PiggyChatServerOptions): Server {
const host = options.host ?? '127.0.0.1';
if (!isLoopback(host) && !options.allowNonLoopback) {
throw new Error('Piggy chat must bind to loopback; expose it only through the authenticated CRM API.');
}
if (options.internalToken.length < 32) {
throw new Error('PIGGY_INTERNAL_TOKEN must contain at least 32 characters.');
}
const resolved: ResolvedOptions = {
models: options.models ?? piggyModelCatalogue(),
createSession: options.createSession ?? createPiggySession,
createWriteTools: options.createWriteTools ?? createPigWriteTools,
createReadTools:
options.createReadTools ??
((database, context) => toPrimeTools(createInteractivePigTools(database, context))),
approvals: new ApprovalRegistry(options.approvalTimeoutMs ?? PIGGY_APPROVAL_TIMEOUT_MS),
// Resolved once, at bind time, so a malformed ceiling fails the process
// rather than the first user to ask a question.
limits: options.limits ?? loadPiggyTurnLimits(),
stall: options.stallLimits ?? loadPiggyStallLimits(),
};
const defaultModel = defaultModelOption(resolved.models);
const server = createServer(async (request, response) => {
// Unauthenticated on purpose: a container healthcheck and a load balancer
// have no token, and this says nothing an attacker on loopback could not
// learn by watching the port.
if (request.method === 'GET' && request.url === '/internal/health') {
respondJson(response, 200, { ok: true, service: 'piggy-chat', model: defaultModel.id });
return;
}
const route = `${request.method} ${request.url}`;
if (
route !== 'POST /internal/chat' &&
route !== 'POST /internal/approve' &&
route !== 'GET /internal/models'
) {
response.writeHead(404).end();
return;
}
if (!tokenMatches(request.headers.authorization, options.internalToken)) {
respondJson(response, 401, { error: 'Unauthorised internal request.' });
return;
}
// A bare array, because the contract is `PiggyModelOption[]`: the relay
// forwards the catalogue rather than reshaping it.
if (route === 'GET /internal/models') {
respondJson(response, 200, resolved.models);
return;
}
if (route === 'POST /internal/approve') {
await handleApproval(request, response, resolved.approvals);
return;
}
await handleChatTurn(request, response, db, resolved);
});
server.listen(options.port, host);
return server;
}
interface ResolvedOptions {
models: readonly PiggyModelOption[];
createSession: PiggySessionFactory;
createWriteTools: (deps: PigWriteToolDeps) => ToolDefinition[];
createReadTools: (db: Database, context: PiggyChatContext | undefined) => ToolDefinition[];
approvals: ApprovalRegistry;
limits: PiggyTurnLimits;
stall: PiggyStallLimits;
}
/**
* The model a request that names none gets.
*
* Falling back to the first entry rather than throwing keeps a catalogue that
* forgot its `isDefault` flag serviceable: an unflagged catalogue is a
* configuration slip, not a reason to refuse every conversation.
*/
function defaultModelOption(models: readonly PiggyModelOption[]): PiggyModelOption {
const flagged = models.find((model) => model.isDefault);
if (flagged) return flagged;
const first = models[0];
if (!first) throw new Error('Piggy chat needs at least one model in its catalogue.');
return first;
}
// -------------------------------------------------------------- the stall
/** Which silence ended the turn. */
type StallPhase = 'first_progress' | 'idle';
/** A turn the endpoint stopped answering, and how long it was given first. */
interface TurnStall {
phase: StallPhase;
/** How long the turn had been silent when the deadline bit. */
waitedMs: number;
/** The deadline it passed, in its own units. */
ceilingMs: number;
}
/**
* The events that mean the model itself is working.
*
* Deliberately narrower than "any event". The harness announces `agent_start`
* and `turn_start` the instant a prompt is submitted, before a byte has left the
* process, so counting those as progress would start the first-progress clock
* and satisfy it in the same tick — which is exactly the hang this guard is for.
* Once a turn has genuinely started, any event at all is accepted as a sign of
* life, because by then the harness is demonstrably running its loop.
*/
const PROGRESS_EVENTS: ReadonlySet<AgentSessionEvent['type']> = new Set([
'message_update',
'tool_execution_start',
'turn_end',
]);
/**
* How long a stalled turn is given to unwind itself before the server stops
* waiting for it.
*
* `session.abort()` should reject the in-flight request and settle `prompt()`
* promptly. Should. The whole reason this guard exists is that the layer holding
* the socket had no deadline of its own, so trusting the same layer to honour an
* abort — and leaving the browser hanging if it does not — would rebuild the bug
* one level up. Two seconds is long enough for a clean unwind and short enough
* that nobody watches it.
*/
const STALL_UNWIND_GRACE_MS = 2_000;
/**
* The turn-level stall detector.
*
* It watches the session's event stream rather than the HTTP call, because the
* HTTP call belongs to the harness and this must be a guard the harness cannot
* swallow. Two deadlines, and the distinction is the point of the whole class:
*
* first progress — nothing has arrived since `prompt()` was called. The turn
* never started; the request went out and the endpoint did
* not answer.
* idle — the turn started and then went quiet. This clock restarts
* on every event, so a long answer that keeps arriving runs
* as long as it likes. A flat overall deadline would kill
* exactly the legitimate long turns this product wants.
*
* `parked` is the third rule and the one that would otherwise break the write
* flow. A `confirm`-mode turn sits inside `propose()` waiting up to five minutes
* for a human, and by design that produces no events whatsoever. That is not a
* stall, it is the product working, so the clock is pushed forward for as long
* as the rendezvous holds an unanswered card.
*/
class TurnStallWatchdog {
private phaseStartedAt = Date.now();
private progressed = false;
private timer?: ReturnType<typeof setTimeout>;
private stopped = false;
private fired = false;
private abandon: (() => void) | undefined;
/**
* Resolves only when a stalled turn's harness has not unwound within the
* grace period. Raced against `prompt()` so a hung request cannot hold the
* browser open even if the abort is ignored.
*/
readonly abandoned: Promise<void>;
constructor(
private readonly limits: PiggyStallLimits,
/** True while a proposed write is waiting on a human. */
private readonly parked: () => boolean,
private readonly onStall: (stall: TurnStall) => void,
) {
this.abandoned = new Promise<void>((resolve) => {
this.abandon = resolve;
});
}
/** Starts the first-progress clock. Call immediately before `prompt()`. */
start(): void {
this.phaseStartedAt = Date.now();
this.arm();
}
/** One event off the session stream. */
observe(type: AgentSessionEvent['type']): void {
const wasProgressing = this.progressed;
if (PROGRESS_EVENTS.has(type)) this.progressed = true;
else if (!this.progressed) return;
this.phaseStartedAt = Date.now();
if (!wasProgressing) {
/*
* The one reset that must not wait for the sleeping timer.
*
* Every other reset only ever pushes the deadline later, and a timer that
* wakes too early simply re-arms for what is left. The first progress
* event is different: it swaps the generous first-progress window for the
* tighter idle one, and a timer already asleep on the longer of the two
* cannot notice until it has expired. Measured, before this line existed:
* a turn with a 120ms idle deadline that fell silent after its first token
* ran for 30,001ms — the first-progress window — which is the hang this
* whole guard is for, wearing the guard's own clothes.
*/
this.rearm();
}
}
/**
* The human rendezvous moved — a card was raised, or answered.
*
* Not a model event, but not silence either, and it closes the narrow window
* where an approval settles a moment before the deadline it was suspending
* would have fired.
*/
touch(): void {
this.phaseStartedAt = Date.now();
}
stop(): void {
this.stopped = true;
if (this.timer) clearTimeout(this.timer);
}
private window(): number {
return this.progressed ? this.limits.idleMs : this.limits.firstProgressMs;
}
private rearm(): void {
if (this.timer) clearTimeout(this.timer);
this.arm();
}
/**
* One self-rescheduling timer rather than a poll: it sleeps exactly as long as
* the current deadline has left, and every reset simply moves the deadline it
* wakes up to compare against.
*/
private arm(): void {
if (this.stopped || this.fired) return;
const waited = Date.now() - this.phaseStartedAt;
const ceilingMs = this.window();
if (waited < ceilingMs) {
this.timer = setTimeout(() => this.arm(), ceilingMs - waited);
// A deadline must never be a reason for the process to stay alive.
this.timer.unref?.();
return;
}
if (this.parked()) {
// Waiting on a person, not on the endpoint. The clock restarts from now,
// so the turn gets a full window once the card is answered.
this.phaseStartedAt = Date.now();
this.arm();
return;
}
this.fired = true;
this.onStall({ phase: this.progressed ? 'idle' : 'first_progress', waitedMs: waited, ceilingMs });
this.timer = setTimeout(() => this.abandon?.(), STALL_UNWIND_GRACE_MS);
this.timer.unref?.();
}
}
/**
* Tell the user the endpoint went quiet, and tell the ledger which silence it
* was.
*
* A distinct code, because an operator must be able to tell three different
* events apart without reading a log: `inference_stalled` is the endpoint saying
* nothing, `inference_failed` is the model or the endpoint reporting a fault,
* and `turn_limit_exceeded` is PIG's own policy stopping a turn that was working
* perfectly well. They want three different responses — wait, investigate, and
* nothing at all — so they must not share a name.
*/
function reportStall(
stall: TurnStall,
spend: ChatRunOutcome,
emit: (event: PiggyChatEvent) => void,
): void {
spend.stall = stall;
spend.error =
`turn stopped by the ${stall.phase} deadline: no session event for ${stall.waitedMs}ms, ` +
`deadline ${stall.ceilingMs}ms`;
console.warn(`[piggy] ${spend.error}`);
emit({
type: 'error',
message:
stall.phase === 'first_progress'
? 'Piggy asked the inference endpoint and it never answered, so nothing was attempted. That is the endpoint rather than your question — try again shortly.'
: 'The inference endpoint went quiet part way through this answer, so it is incomplete. Try again shortly.',
code: 'inference_stalled',
});
}
// ------------------------------------------------------------------- the turn
async function handleChatTurn(
request: IncomingMessage,
response: ServerResponse,
db: Database,
options: ResolvedOptions,
): Promise<void> {
let body: z.infer<typeof piggyChatRequestSchema>;
try {
body = piggyChatRequestSchema.parse(JSON.parse(await readBoundedBody(request, 32_768)));
} catch {
// Every failure reachable here — an oversized body, malformed JSON, a
// context arm this schema has not been told about, a principal the relay
// shaped differently — genuinely is the caller's. Nothing below may borrow
// this message: a fault raised mid-turn is an upstream fault, and reporting
// it as invalid input told the user their question was malformed when it
// was not.
respondJson(response, 400, { error: 'Invalid Piggy chat request.' });
return;
}
const model = body.modelId
? options.models.find((entry) => entry.id === body.modelId)
: defaultModelOption(options.models);
if (!model) {
// Silently answering on a substitute would leave the `meta` event as the
// only trace of the swap, and the ledger would bill the wrong price.
respondJson(response, 400, { error: 'Unknown Piggy model.' });
return;
}
const principal: Principal = body.principal;
const abort = new AbortController();
response.on('close', () => abort.abort());
/*
* The day's ceiling is checked before anything is opened, so a refusal costs
* one indexed sum and writes no run row: nothing was spent, and a ledger full
* of zero-cost refusals would make the spend panel harder to read, not
* easier. It is streamed rather than returned as a 429 because the relay
* turns every non-200 from this server into a bare "Piggy chat service did
* not respond" 502, which tells the user nothing they can act on.
*/
const overspentMicroCents = await spentInLastDay(db, principal.userId, options.limits);
if (overspentMicroCents !== null) {
console.warn(
`[piggy] refusing a turn for ${principal.userId}: ${(overspentMicroCents / 1_000_000).toFixed(2)}c spent in 24h, ceiling ${options.limits.dailyLimitCents}c`,
);
beginStream(response);
writeFrame(response, {
type: 'meta',
model: model.id,
mode: body.mode,
conversationId: body.conversationId,
});
writeFrame(response, {
type: 'error',
message: `You have spent ${formatCents(overspentMicroCents)} on Piggy in the last 24 hours, and the limit is ${formatCents(options.limits.dailyLimitCents * 1_000_000)} per person per day. It frees up again as those turns age past 24 hours.`,
code: 'daily_spend_exceeded',
});
response.end();
return;
}
const run = await startChatRun(db, {
principalUserId: principal.userId,
model: model.id,
message: body.message,
context: body.context,
historyTurns: body.history?.length ?? 0,
mode: body.mode,
conversationId: body.conversationId,
});
const spend: ChatRunOutcome = { toolCalls: 0, approvalsRequested: 0, approvalsApplied: 0 };
const budget = createTurnBudget(options.limits);
beginStream(response);
// Declared before `emit` because `emit` feeds it: an approval being raised or
// answered is movement on the turn, and it is the only movement the session's
// own event stream never reports.
let watchdog: TurnStallWatchdog | undefined;
const emit = (event: PiggyChatEvent): void => {
recordEvent(spend, event);
if (event.type === 'approval_required' || event.type === 'approval_resolved') {
watchdog?.touch();
}
// An abandoned turn still has approvals to cancel and a session to unwind,
// and both emit as they settle. Writing those to a closed socket would
// throw inside the unwinding and take the ledger down with it.
if (response.writableEnded || abort.signal.aborted) return;
writeFrame(response, event);
};
const turn = options.approvals.open(body.conversationId, emit);
let session: PiggySession | undefined;
/*
* Declared out here, not inside the try, because it is the only record of
* what this turn actually spent and it has to survive every way out.
*
* Measured, on a real turn: the model made three tool calls, was billed for
* every model call behind them, and then the endpoint answered 429. The run
* closed as `failed` with inputTokens, outputTokens and costMicroCents all
* NULL — because the ledger was only ever fed from the `done` frame, which a
* failed turn never emits. The same held for an abandoned turn, which is the
* commoner case: somebody navigates away mid-answer and the tokens already
* generated vanish from the spend panel. A spend panel that quietly omits
* every turn that went wrong is answering the wrong question, and it errs in
* the reassuring direction, which is the worst way for it to be wrong.
*/
const state: TurnState = { inputTokens: 0, outputTokens: 0, modelCalls: 0, retries: 0 };
try {
const tools = buildToolSet(db, options, {
principal,
mode: body.mode,
context: body.context,
propose: turn.propose,
});
// Asserted here as well as inside `createPiggySession`, because this is the
// list this file assembled and the assertion should fail next to the
// assembly rather than one call deeper.
assertPigToolBoundary(tools);
emit({ type: 'meta', model: model.id, mode: body.mode, conversationId: body.conversationId });
session = await options.createSession({
mode: body.mode,
modelId: model.id,
tools,
context: body.context,
history: body.history,
budget,
});
// A reader who leaves must stop the generation, not merely stop reading it:
// an undisposed session keeps the inference socket open and billing.
const disposeOnAbort = (): void => session?.dispose();
abort.signal.addEventListener('abort', disposeOnAbort, { once: true });
/*
* The second counter, and the one that does not depend on the harness.
*
* `createPiggySession` installs a `shouldStopAfterTurn` hook that stops the
* loop before it buys another model call, which is the graceful stop and
* the one that should always fire. This is what happens if it does not: the
* harness already claims `beforeToolCall` and `prepareNextTurnWithContext`
* on the same object for its own purposes, so a version that starts using
* `shouldStopAfterTurn` too would take our only in-loop brake away with no
* error and no log. `turn_end` is emitted once per model call whatever the
* harness does with its hooks, and aborting the session stops a run that
* ignores everything else.
*/
const enforceBudget = (): void => {
observeTurn(budget, state.modelCalls, state.inputTokens + state.outputTokens);
if (!budget.breach || state.stopping) return;
state.stopping = true;
// Fire and forget: `AgentSession.abort` awaits the run going idle, and
// this is called from inside that run's event handling. Awaiting it here
// would be a deadlock.
void session?.session.abort().catch(() => {});
};
/*
* The third brake, and the only one that fires when the harness is not
* running at all.
*
* `shouldStopAfterTurn` and the `turn_end` counter above both need the loop
* to be turning; a turn stuck on a request the endpoint never answers turns
* nothing, spends nothing and trips neither. It is stopped the same way a
* runaway is — abort the session — and reported as its own thing, because
* "the endpoint went quiet" and "the turn cost too much" are not the same
* news for either the user or the operator.
*/
watchdog = new TurnStallWatchdog(options.stall, turn.hasPending, (stall) => {
if (state.stopping) return;
state.stopping = true;
state.stalled = stall;
// Fire and forget, for the same reason the budget's abort is: this runs on
// a timer inside the run, and awaiting the run going idle from within it
// would deadlock.
void session?.session.abort().catch(() => {});
});
/*
* The fourth brake, and the only one that stops the turn to protect the
* transcript rather than the bill.
*
* `piggyAgentSettings` buys most of its retries inside the SDK's request
* wrapper, where nothing has been delivered yet and a retry is invisible by
* construction. This is the other kind: the harness's session-level retry
* fires after the response has already started, discards the errored
* assistant message and generates a replacement. Measured against a stubbed
* endpoint, a turn that had streamed "Idle is " before the stream dropped
* came back as "Idle is Idle is $12,000." — the reader is shown a sentence
* twice, in a panel whose whole job is to be trusted about numbers.
*
* So the rule is the simple one rather than the clever one: retry only
* before the first delta. Once anything has been delivered the turn is
* stopped and reported as incomplete, which is what the stall guard already
* says about a half-finished answer and what the reader can act on. Aborting
* during the backoff is enough: `AgentSession.abort` cancels the retry sleep
* before it re-drives (agent-session.js:1168-1172).
*/
const refuseReplay = (): void => {
if (!state.delivered || state.stopping) return;
state.stopping = true;
state.replayRefused = true;
// Fire and forget, for the same reason the other two brakes are: this
// runs inside the run's own event handling, and `abort` awaits the run
// going idle.
void session?.session.abort().catch(() => {});
};
const unsubscribe = session.session.subscribe((event) => {
watchdog?.observe(event.type);
translateSessionEvent(event, emit, state);
if (event.type === 'turn_end') enforceBudget();
if (event.type === 'auto_retry_start') refuseReplay();
});
try {
watchdog.start();
const prompt = session.session.prompt(body.message);
// Two handlers on one promise. The race is what ends the turn when an
// aborted harness does not unwind; this `catch` is what keeps the prompt's
// eventual rejection from becoming an unhandled rejection once the race
// has already been decided against it. Awaiting `prompt` still throws, so
// the ordinary failure path is untouched.
void prompt.catch(() => {});
await Promise.race([prompt, watchdog.abandoned]);
} finally {
unsubscribe();
watchdog.stop();
abort.signal.removeEventListener('abort', disposeOnAbort);
}
if (state.stalled) {
// Checked before the abort state, and it costs nothing to do so: a reader
// who had already left would have disposed the session and settled the
// prompt long before this deadline could bite, so a stall recorded here
// came first and is the reason the turn ended.
reportStall(state.stalled, spend, emit);
} else if (abort.signal.aborted) {
// The reader left. The harness may have resolved the prompt rather than
// rejecting it, and recording that as a completed turn would report an
// answer nobody received as delivered.
spend.aborted = true;
} else if (cutShortByBudget(budget, state)) {
reportBudgetBreach(budget, spend, emit);
} else if (state.replayRefused || state.errorMessage !== undefined) {
reportInferenceFailure(state, spend, emit);
} else {
// A turn that passed a ceiling on its own last call still records the
// breach below, because that is the reading an operator tuning the
// ceiling needs; it is not reported to the user, because nothing was
// taken away from them.
spend.breach = budget.breach;
emit({
type: 'done',
inputTokens: state.inputTokens || null,
outputTokens: state.outputTokens || null,
costMicroCents: costMicroCents(state, model),
...(state.stopReason && state.stopReason !== 'stop' ? { finishReason: state.stopReason } : {}),
});
spend.completed = true;
}
response.end();
} catch (error) {
// Read before the error frame is written: ending the response fires
// 'close' as well, so a reading of the abort state taken afterwards
// cannot tell a reader who walked away from one who got the answer.
spend.aborted = abort.signal.aborted;
const failure = error instanceof Error ? error.message : String(error);
// Server-side, with the real reason. The client gets none of it: the
// upstream body is echoed into these messages and is not ours to relay.
console.error('[piggy] chat turn failed:', failure);
if (!spend.aborted && state.stalled) {
// The abort this server fires at a silent endpoint usually surfaces here,
// as a rejected prompt rather than a resolved one. What ended the turn is
// still the stall, and saying "Piggy chat failed" would send an operator
// hunting a fault in code that behaved correctly.
reportStall(state.stalled, spend, emit);
if (!response.writableEnded) response.end();
} else if (!spend.aborted && budget.breach) {
// The abort this server fires to stop a runaway can surface here as a
// rejected prompt rather than as a resolved one. The ceiling is what
// ended the turn; reporting it as "Piggy chat failed" would send an
// operator hunting a fault that is really a policy.
reportBudgetBreach(budget, spend, emit);
if (!response.writableEnded) response.end();
} else if (!spend.aborted && (state.replayRefused || state.errorMessage !== undefined)) {
// The abort this server fires to refuse a replay surfaces here as a
// rejected prompt. What ended the turn is the upstream fault that
// provoked the retry, and the reader is owed that reason rather than a
// bare "Piggy chat failed" for a decision this server took on purpose.
reportInferenceFailure(state, spend, emit);
if (!response.writableEnded) response.end();
} else {
spend.error = failure;
if (!response.writableEnded) {
const frame: PiggyChatEvent = {
type: 'error',
message: 'Piggy chat failed.',
code: 'agent_failed',
};
response.end(`${JSON.stringify(frame)}\n`);
}
}
} finally {
// Whatever happened, nothing may be left waiting on this turn: an approval
// it owns has nobody left to answer it, and its promise is holding a tool
// call open inside a session that is about to be disposed.
turn.cancelAll();
session?.dispose();
// The tokens are billed by the provider when they are generated, not when
// the answer is delivered, so the ledger is fed from what the turn actually
// consumed rather than from the `done` frame — which a failed or abandoned
// turn never emits. `??=` because a completed turn has already recorded the
// identical figures through `done`, and this must not overwrite them with a
// second reading taken later.
spend.inputTokens ??= state.inputTokens || null;
spend.outputTokens ??= state.outputTokens || null;
spend.costMicroCents ??= costMicroCents(state, model);
spend.modelCalls = state.modelCalls;
spend.overran = budget.overran;
// `??=` because a reported failure has already written the reading it was
// measured against, and a turn that recovered still needs its count here.
spend.attempts ??= state.retries + 1;
spend.retryReason ??= state.retryReason;
// In a finally so that every exit closes the row, including the exit
// that is not a fault at all: a reader who navigates away aborts the
// turn mid-answer. A row left `running` cannot be told from a turn still
// in flight by any later query — which is exactly the query a per-user
// daily cap would have to make.
await finishChatRun(db, run, spend);
}
}
/**
* Read tools always; write tools only outside `read_only`.
*
* `createPigWriteTools` returns nothing in `read_only` of its own accord, so
* this is belt and braces — but it is the belt that is visible from here, and a
* tool the model is never shown is a tool it cannot be talked into calling.
*/
function buildToolSet(
db: Database,
options: ResolvedOptions,
turn: {
principal: Principal;
mode: PigWriteToolDeps['mode'];
context?: PiggyChatContext;
propose: PigWriteToolDeps['propose'];
},
): ToolDefinition[] {
const tools = [...options.createReadTools(db, turn.context)];
if (turn.mode !== 'read_only') {
tools.push(
...options.createWriteTools({
db,
principal: turn.principal,
mode: turn.mode,
propose: turn.propose,
}),
);
}
return tools;
}
interface TurnState {
inputTokens: number;
outputTokens: number;
/** Model round trips seen, which is one per `turn_end`. */
modelCalls: number;
stopReason?: string;
errorMessage?: string;
/** The session has already been told to stop; do not tell it twice. */
stopping?: boolean;
/** Set when the stall watchdog, rather than the model, ended the turn. */
stalled?: TurnStall;
/**
* Turn-level retries the harness announced, which is one per `auto_retry_start`.
*
* It counts the retries that were visible. The provider-level ones — the four
* attempts `piggyAgentSettings` buys inside the SDK's own request wrapper —
* produce no assistant message and no session event, so no honest counter can
* see them from here. An operator reading `agent_runs.result.inference` should
* therefore read `attempts` as "times this turn had to be started again after
* the endpoint had already begun answering", not as a count of HTTP requests.
*/
retries: number;
/** How the endpoint described the fault that caused the last retry. */
retryReason?: string;
/** Content deltas have been written to the reader's transcript. */
delivered?: boolean;
/** A retry was refused because it would have replayed a delivered answer. */
replayRefused?: boolean;
}
/**
* Was the answer actually taken away from the user?
*
* A turn that passes a ceiling on the same model call it was going to finish on
* has lost nothing: the model said `stop`, the loop was ending anyway, and the
* answer in front of the user is complete. Reporting that as a cut-off answer
* would teach people to distrust complete answers. Anything else — `toolUse`,
* which means the model had asked for another round trip, or `length`, which
* means its message was truncated mid-flight — was genuinely cut short.
*/
function cutShortByBudget(budget: PiggyTurnBudget, state: TurnState): boolean {
return budget.breach !== undefined && state.stopReason !== 'stop';
}
/**
* Tell the user what happened, and tell the ledger why.
*
* The user gets an `error` frame rather than a `done` frame, because the
* transcript must settle as an incomplete answer: `done` after a truncated
* answer presents it as the whole of what Piggy had to say. The operator gets
* the counts, in `agent_runs.error` and in `result.limit`, so "cut off for
* cost" can be told apart from "failed" without reading a log.
*/
function reportBudgetBreach(
budget: PiggyTurnBudget,
spend: ChatRunOutcome,
emit: (event: PiggyChatEvent) => void,
): void {
const breach = budget.breach;
if (!breach) return;
spend.breach = breach;
spend.error =
`turn stopped by the ${breach.limit} ceiling: ${breach.modelCalls} model calls, ` +
`${breach.tokens} tokens, ceiling ${breach.ceiling}` +
(budget.overran ? ' (the in-loop stop did not hold; the session was aborted)' : '');
console.warn(`[piggy] ${spend.error}`);
emit({
type: 'error',
message:
breach.limit === 'model_calls'
? `Piggy stopped after ${breach.modelCalls} step${breach.modelCalls === 1 ? '' : 's'}, which is the most one question may take, so this answer is incomplete. Ask for one thing at a time.`
: `Piggy reached the size limit for a single question (${breach.tokens.toLocaleString('en-GB')} tokens), so this answer is incomplete. Ask for one thing at a time.`,
code: 'turn_limit_exceeded',
});
}
/**
* Is this the endpoint throttling us, or something that will fail again?
*
* Matched on the text because that is all there is: the harness reports a
* failed model call as an `errorMessage` on the assistant message and keeps no
* status code, so by the time the fault reaches PIG the HTTP response is long
* gone. The strings are the ones Prime Inference actually sends — `429:
* {"message":"Rate limit reached. Please retry shortly.","type":
* "rate_limit_exceeded","code":"rate_limited"}` — plus the shapes the SDK
* substitutes when it never got a body, and `ResourceExhausted`, which is what
* a gRPC-backed model behind the same endpoint says instead.
*/
function isRateLimited(errorMessage: string): boolean {
return /\b429\b|rate.?limit|rate_limited|too many requests|resourceexhausted/i.test(errorMessage);
}
/**
* Tell the user which fault it was, and tell the ledger how hard we tried.
*
* `inference_rate_limited` is its own code because it is the one inference
* fault the person at the keyboard can do something about: waiting ten seconds
* genuinely fixes it, and it is not worth an operator's pager. `inference_failed`
* keeps its old meaning — something broke and somebody should look — so the two
* must not share a name any more than `inference_stalled` and
* `turn_limit_exceeded` do.
*
* The counts go to `agent_runs`, not to the browser. "Piggy tried four times"
* is not a sentence that helps anybody decide what to type next; it is exactly
* the sentence an operator needs when deciding whether today's rate limiting is
* worse than yesterday's.
*/
function reportInferenceFailure(
state: TurnState,
spend: ChatRunOutcome,
emit: (event: PiggyChatEvent) => void,
): void {
const errorMessage = state.errorMessage ?? 'the endpoint failed without saying why';
const rateLimited = isRateLimited(errorMessage);
const attempts = state.retries + 1;
// Server-side, with the real reason and the counts. The upstream body is
// echoed into this and is not ours to relay to a browser.
spend.error =
`${errorMessage} (${attempts} attempt${attempts === 1 ? '' : 's'}` +
(state.replayRefused ? ', retry refused: part of the answer had already been delivered' : '') +
')';
spend.attempts = attempts;
console.error('[piggy] chat turn ended in an inference error:', spend.error);
if (state.replayRefused) {
// The reader keeps what arrived, and is told plainly that it is not all of
// it. Restarting would have shown them the first half twice.
emit({
type: 'error',
message: rateLimited
? 'The inference endpoint started rate limiting us part way through this answer, so it is incomplete. Piggy will not restart it, because that would repeat what you have already been shown. Ask again in a few seconds.'
: 'The inference endpoint failed part way through this answer, so it is incomplete. Piggy will not restart it, because that would repeat what you have already been shown. Ask again shortly.',
code: rateLimited ? 'inference_rate_limited' : 'inference_failed',
});
return;
}
emit({
type: 'error',
message: rateLimited
? `Prime Inference is rate limiting us, so this question was never answered. Piggy asked ${attempts} time${attempts === 1 ? '' : 's'} and was turned away each time. Wait a few seconds and ask again; the refused attempts generated nothing, so none of this was charged to you.`
: 'Piggy could not finish this answer.',
code: rateLimited ? 'inference_rate_limited' : 'inference_failed',
});
}
/**
* The harness's vocabulary, narrowed to PIG's.
*
* Prime Agent emits a dozen event types; the product renders eight. Everything
* else — queue updates, compaction, retry bookkeeping, thinking-level changes —
* is dropped here, on the server. That is the whole point of PIG having its own
* vocabulary: a harness upgrade that adds an event is a server change, and the
* web app never learns which harness Piggy runs on.
*/
function translateSessionEvent(
event: AgentSessionEvent,
emit: (event: PiggyChatEvent) => void,
state: TurnState,
): void {
switch (event.type) {
case 'message_update': {
/*
* Nothing more of this turn may reach the transcript once a replay has
* been refused.
*
* Aborting the session should be enough, and the whole reason that guard
* exists is that a harness which cannot unwind is precisely the fault
* being defended against — the stall watchdog already races `prompt()`
* for the same reason. A harness that carried on regardless would stream
* the replacement answer over the top of the one the reader already has,
* which is the duplication the abort was meant to prevent. Dropping the
* deltas here makes exactly-once a property of PIG's own code rather than
* a favour from somebody else's.
*/
if (state.replayRefused) return;
const streamed = event.assistantMessageEvent;
// Recorded before either is written, because it is what makes a retry
// unsafe: from here on the reader has part of a turn in front of them,
// and starting it again would show them a second copy. Reasoning counts
// as well as answer text — it is a rendered panel, not a private
// scratchpad, and a restarted turn would repeat that too.
if (streamed.type === 'text_delta') {
state.delivered = true;
emit({ type: 'content_delta', delta: streamed.delta });
}
if (streamed.type === 'thinking_delta') {
state.delivered = true;
emit({ type: 'reasoning_delta', delta: streamed.delta });
}
return;
}
/*
* The harness is about to restart a turn that failed.
*
* Announced, backed off and then re-driven by `AgentSession`, which
* discards the errored assistant message and continues. It is counted here
* rather than dropped with the rest of the harness's vocabulary because a
* turn that needed two goes and a turn that needed one look identical in
* the ledger otherwise, and "how often is this happening" is the first
* question an operator asks about a rate limit.
*/
case 'auto_retry_start':
state.retries += 1;
state.retryReason = event.errorMessage;
console.warn(
`[piggy] retrying a chat turn after ${event.errorMessage} (attempt ${event.attempt} of ${event.maxAttempts}, in ${event.delayMs}ms)`,
);
return;
case 'tool_execution_start':
emit({ type: 'tool_call', id: event.toolCallId, name: event.toolName, arguments: event.args });
return;
case 'tool_execution_end':
emit(
event.isError
? {
type: 'tool_result',
id: event.toolCallId,
name: event.toolName,
ok: false,
error: toolResultText(event.result) ?? 'The tool failed without saying why.',
}
: {
type: 'tool_result',
id: event.toolCallId,
name: event.toolName,
ok: true,
result: toolResultValue(event.result),
},
);
return;
case 'turn_end': {
// Usage arrives per model call, and one turn of conversation makes
// several when tools are involved, so it accumulates rather than
// overwrites. Taking only the last call under-reported the cost of
// precisely the turns that cost the most.
const assistant = readAssistantMessage(event.message);
if (!assistant) return;
// One `turn_end` is one model round trip, whatever the harness does with
// its own hooks, which is what makes this a counter the loop cannot hide
// from.
state.modelCalls += 1;
state.inputTokens += assistant.inputTokens;
state.outputTokens += assistant.outputTokens;
state.stopReason = assistant.stopReason ?? state.stopReason;
// Set on a fault and CLEARED on the next model call that is not one.
//
// It used to latch, and that was the second half of the production
// failure: the harness retries a 429 of its own accord and often
// succeeds, but the errored `turn_end` had already stamped this field, so
// a turn that recovered and streamed a perfectly good answer was still
// closed as `inference_failed` with the 429 in the ledger. The only way a
// model call can follow an errored one at all is that something retried
// it, so the later reading is the one that describes how the turn ended.
//
// Unless the retry was one this server refused. Then the replacement
// answer was never delivered, whatever the harness did with it, and
// letting its success clear the fault would report a turn as finished on
// the strength of an answer nobody was shown.
if (!state.replayRefused) state.errorMessage = assistant.errorMessage;
return;
}
default:
return;
}
}
/**
* What the panel renders for a tool result.
*
* Both bridges put the structured answer in `details` — the read bridge as
* `{ tool, result }`, the write tools as their own status record — so the
* stream carries the object rather than the JSON string that encoded it, and
* nothing here re-parses what was just serialised. A tool that supplied no
* details falls back to the text the model itself was shown, which is the only
* other honest thing to show a reader.
*/
function toolResultValue(result: unknown): unknown {
const details = readDetails(result);
if (details && 'result' in details) return details.result;
if (details) return details;
return toolResultText(result);
}
function readDetails(result: unknown): Record<string, unknown> | null {
if (typeof result !== 'object' || result === null) return null;
const details = (result as { details?: unknown }).details;
if (typeof details !== 'object' || details === null || Array.isArray(details)) return null;
const record = details as Record<string, unknown>;
return Object.keys(record).length > 0 ? record : null;
}
function toolResultText(result: unknown): string | undefined {
if (typeof result !== 'object' || result === null) return undefined;
const content = (result as { content?: unknown }).content;
if (!Array.isArray(content)) return undefined;
const parts: string[] = [];
for (const item of content) {
if (typeof item !== 'object' || item === null) continue;
const text = (item as { text?: unknown }).text;
if (typeof text === 'string') parts.push(text);
}
return parts.length > 0 ? parts.join('\n') : undefined;
}
/**
* Usage off an assistant message, without widening anything to `any`.
*
* `AgentMessage` is a union that includes the harness's own custom message
* types, which carry no usage at all, so the shape is checked rather than
* asserted.
*/
function readAssistantMessage(message: unknown): {
inputTokens: number;
outputTokens: number;
stopReason?: string;
errorMessage?: string;
} | null {
if (typeof message !== 'object' || message === null) return null;
const candidate = message as {
role?: unknown;
usage?: { input?: unknown; output?: unknown };
stopReason?: unknown;
errorMessage?: unknown;
};
if (candidate.role !== 'assistant') return null;
return {
inputTokens: typeof candidate.usage?.input === 'number' ? candidate.usage.input : 0,
outputTokens: typeof candidate.usage?.output === 'number' ? candidate.usage.output : 0,
...(typeof candidate.stopReason === 'string' ? { stopReason: candidate.stopReason } : {}),
// Only a genuine error. 'aborted' is the reader leaving, and reporting that
// as a fault would turn every closed tab into a failed run.
...(typeof candidate.errorMessage === 'string' && candidate.stopReason === 'error'
? { errorMessage: candidate.errorMessage }
: {}),
};
}
// -------------------------------------------------------------- the approvals
/**
* The mid-turn rendezvous.
*
* NDJSON only goes one way, so a write that needs a human cannot be answered on
* the stream that asked for it. The tool's `propose` call parks a promise here;
* the decision arrives as a separate POST from the relay and resolves it; the
* tool then performs its mutation and reports what really happened as its own
* tool result, where the model can read it too. Three properties are
* load-bearing:
*
* single-use — an id is deleted the instant it settles, so a decision
* replayed by an impatient client cannot apply a change twice.
* deadlined — an unanswered card settles as a rejection after five minutes
* rather than holding a billed inference connection for ever.
* turn-owned — an abandoned turn rejects every approval it opened, so no
* tool call is left awaiting a reader who has gone.
*/
class ApprovalRegistry {
private readonly pending = new Map<string, { settle: SettleApproval }>();
constructor(private readonly timeoutMs: number) {}
open(
conversationId: string,
emit: (event: PiggyChatEvent) => void,
): {
propose: PigWriteToolDeps['propose'];
cancelAll: () => void;
/**
* True while this turn is waiting on a human.
*
* Published because the stall watchdog has to be able to tell a turn parked
* on `propose()` from a turn nobody is answering: the parked one emits
* nothing for up to five minutes by design, and killing it would break the
* write flow entirely. `owned` is the honest source — it holds exactly the
* cards this turn has raised and not yet settled.
*/
hasPending: () => boolean;
} {
const owned = new Set<string>();
const propose: PigWriteToolDeps['propose'] = (draft) =>
new Promise<PiggyApprovalDecision>((resolve) => {
const change: PiggyProposedChange = { ...draft, id: randomUUID() };
const key = approvalKey(conversationId, change.id);
let settled = false;
const settle: SettleApproval = (decision, reason) => {
if (settled) return;
settled = true;
clearTimeout(timer);
this.pending.delete(key);
owned.delete(key);
emit(approvalResolved(change.id, decision, reason));
resolve(decision);
};
const timer = setTimeout(() => settle('reject', 'timed_out'), this.timeoutMs);
// The deadline must not be a reason for the process to stay alive.
timer.unref?.();
this.pending.set(key, { settle });
owned.add(key);
emit({ type: 'approval_required', change });
});
const cancelAll = (): void => {
for (const key of [...owned]) this.pending.get(key)?.settle('reject', 'cancelled');
};
return { propose, cancelAll, hasPending: () => owned.size > 0 };
}
/** False when nothing is pending: an unknown id, or one already settled. */
resolve(conversationId: string, changeId: string, decision: PiggyApprovalDecision): boolean {
const entry = this.pending.get(approvalKey(conversationId, changeId));
if (!entry) return false;
entry.settle(decision, 'answered');
return true;
}
}
type ApprovalReason = 'answered' | 'timed_out' | 'cancelled';
type SettleApproval = (decision: PiggyApprovalDecision, reason: ApprovalReason) => void;
/**
* The conversation is part of the key, not decoration: change ids are random,
* but keying on them alone would let one conversation's decision settle
* another's card if a client ever replayed the wrong body.
*/
function approvalKey(conversationId: string, changeId: string): string {
return `${conversationId} ${changeId}`;
}
/**
* `ok` says whether a human answered, not whether the write succeeded — the
* write happens inside the tool after this resolves, and its outcome is
* reported as that tool's result, where the model sees it as well.
*/
function approvalResolved(
changeId: string,
decision: PiggyApprovalDecision,
reason: ApprovalReason,
): PiggyChatEvent {
if (reason === 'answered') return { type: 'approval_resolved', changeId, decision, ok: true };
return {
type: 'approval_resolved',
changeId,
decision,
ok: false,
error:
reason === 'timed_out'
? 'Nobody answered within five minutes, so the change was not applied.'
: 'The conversation ended before this was answered, so the change was not applied.',
};
}
async function handleApproval(
request: IncomingMessage,
response: ServerResponse,
approvals: ApprovalRegistry,
): Promise<void> {
let body: z.infer<typeof piggyApprovalRequestSchema>;
try {
body = piggyApprovalRequestSchema.parse(JSON.parse(await readBoundedBody(request, 4_096)));
} catch {
respondJson(response, 400, { error: 'Invalid Piggy approval decision.' });
return;
}
if (!approvals.resolve(body.conversationId, body.changeId, body.decision)) {
// Not a fault of the person answering: the card timed out, the turn was
// abandoned, or they double-clicked. The panel settles the card and says so.
respondJson(response, 404, { error: 'No approval is pending for that change.' });
return;
}
respondJson(response, 202, { ok: true });
}
// ------------------------------------------------------------------ the ledger
/**
* The chat's cost ledger.
*
* `agent_runs` existed and only the queued worker ever wrote to it, so every
* token the docked panel spent was invisible: nothing in the API or the web app
* could answer "what has Piggy cost today", let alone cap it per user. A chat
* turn is one run, with `agent_task_id` left null — the column is nullable for
* precisely this case, a run with no queued task behind it.
*
* A failure to write the ledger never fails the answer. Losing the accounting
* for one turn is a smaller harm than refusing to talk to the user because a
* bookkeeping insert did not land.
*/
interface ChatRunOutcome {
toolCalls: number;
approvalsRequested: number;
approvalsApplied: number;
answer?: string;
inputTokens?: number | null;
outputTokens?: number | null;
costMicroCents?: number | null;
/** The stream ran to its end. */
completed?: boolean;
/** The reader hung up before it did. */
aborted?: boolean;
error?: string;
/** Model round trips this turn made. */
modelCalls?: number;
/** The ceiling this turn passed, if it passed one. */
breach?: PiggyTurnBreach;
/** The silence that ended this turn, if one did. */
stall?: TurnStall;
/**
* Times the turn had to be started again, the first go included.
*
* Written for every turn rather than only the failed ones, because the
* question an operator has is "how often is the endpoint making us retry",
* and a column that only ever appears on failures cannot answer it: a day
* where every turn needed two attempts and succeeded looks, in that ledger,
* exactly like a day where none of them did.
*/
attempts?: number;
/** How the endpoint described the fault behind the last of them. */
retryReason?: string;
/** The loop kept going after the ceiling and had to be aborted. */
overran?: boolean;
}
function recordEvent(outcome: ChatRunOutcome, event: PiggyChatEvent): void {
if (event.type === 'content_delta') outcome.answer = (outcome.answer ?? '') + event.delta;
if (event.type === 'tool_call') outcome.toolCalls += 1;
if (event.type === 'approval_required') outcome.approvalsRequested += 1;
if (event.type === 'approval_resolved' && event.decision === 'apply') {
outcome.approvalsApplied += 1;
}
if (event.type === 'done') {
outcome.inputTokens = event.inputTokens;
outcome.outputTokens = event.outputTokens;
outcome.costMicroCents = event.costMicroCents;
}
// Never overwritten: an error frame carries the sanitised message the browser
// is allowed to see, and the ledger has usually already been given the real
// upstream reason, which is the one an operator needs.
if (event.type === 'error') outcome.error ??= event.message;
}
async function startChatRun(
db: Database,
input: {
principalUserId: string;
model: string;
message: string;
context?: z.infer<typeof contextSchema>;
historyTurns: number;
mode: string;
conversationId: string;
},
): Promise<string | null> {
try {
const [run] = await db
.insert(agentRuns)
.values({
principalUserId: input.principalUserId,
model: input.model,
input: {
surface: 'chat',
message: input.message,
context: input.context ?? null,
historyTurns: input.historyTurns,
mode: input.mode,
conversationId: input.conversationId,
},
})
.returning({ id: agentRuns.id });
return run?.id ?? null;
} catch (error) {
console.error('[piggy] could not open an agent run for this chat turn:', error);
return null;
}
}
async function finishChatRun(
db: Database,
runId: string | null,
outcome: ChatRunOutcome,
): Promise<void> {
if (!runId) return;
const summary = outcome.answer?.trim();
try {
await db
.update(agentRuns)
.set({
// An abandoned turn is not a failed one — the answer was fine, the
// reader left — and counting it as failed would make the failure rate
// read as an outage every time somebody closed a tab. A turn cut off by
// its cost ceiling is the same shape of event: nothing broke, it was
// stopped. It shares `aborted` rather than inventing a status the
// activity panel has never been told about, and `error` and
// `result.limit` say which of the two it was.
status:
outcome.completed ? 'succeeded' : outcome.aborted || outcome.breach ? 'aborted' : 'failed',
summary: summary || null,
result: {
toolCalls: outcome.toolCalls,
approvalsRequested: outcome.approvalsRequested,
approvalsApplied: outcome.approvalsApplied,
modelCalls: outcome.modelCalls ?? 0,
// What it took to get an answer at all. `attempts: 1` is the healthy
// reading and the common one; anything above it is the endpoint
// making the product slower, which is a trend rather than an
// incident and so belongs in a column rather than in a log line.
inference: {
attempts: outcome.attempts ?? 1,
...(outcome.retryReason ? { retryReason: outcome.retryReason } : {}),
},
...(outcome.breach
? {
limit: {
reason: outcome.breach.limit,
ceiling: outcome.breach.ceiling,
modelCalls: outcome.breach.modelCalls,
tokens: outcome.breach.tokens,
overran: outcome.overran ?? false,
},
}
: {}),
// A stalled turn closes as `failed`, not as `aborted`: unlike a
// ceiling, which is PIG stopping a turn that was working, this is the
// upstream not answering, and an operator watching the failure rate
// should see it. `stall` says which silence it was, so it can still be
// told from a fault without reading a log.
...(outcome.stall
? {
stall: {
phase: outcome.stall.phase,
waitedMs: outcome.stall.waitedMs,
ceilingMs: outcome.stall.ceilingMs,
},
}
: {}),
},
inputTokens: outcome.inputTokens ?? null,
outputTokens: outcome.outputTokens ?? null,
costMicroCents: outcome.costMicroCents ?? null,
error: outcome.error ?? null,
finishedAt: new Date(),
})
.where(eq(agentRuns.id, runId));
} catch (error) {
console.error(`[piggy] could not close agent run ${runId}:`, error);
}
}
/**
* Tokens are billed per million, so cents-per-million multiplied by tokens is
* already micro-cents. Doing it that way keeps the whole calculation in
* integers rather than rounding a fraction of a cent per turn and drifting.
*
* The catalogue publishes dollars per million tokens, because that is the unit
* every provider quotes; the hundred here is the one conversion, done once,
* beside the arithmetic that consumes it.
*/
function costMicroCents(state: TurnState, model: PiggyModelOption): number | null {
if (state.inputTokens === 0 && state.outputTokens === 0) return null;
return Math.round(
state.inputTokens * model.costPerMTokIn * 100 + state.outputTokens * model.costPerMTokOut * 100,
);
}
const DAY_MS = 24 * 60 * 60 * 1_000;
/**
* What this user has spent on Piggy in the last 24 hours — but only when it is
* over the ceiling, so the caller has one thing to check rather than two.
*
* The per-turn ceilings bound one question. This bounds a day, which is the
* axis the relay's limiter cannot see: it counts messages, and thirty messages
* an hour is a different amount of money on the cheapest model in the picker
* than on the dearest. `agent_runs` already carries the per-turn cost and is
* indexed on the principal, so this is one indexed sum.
*
* It counts every run for the person, chat and queued work alike, because the
* credit does not care which surface spent it.
*
* A failed query allows the turn. The reasons this can fail are the database
* being unreachable or the schema having moved — and in the first case every
* tool the turn could call is broken too, so the turn fails on its own merits a
* moment later. Refusing to talk to anybody because a bookkeeping sum did not
* come back would be a self-inflicted outage; the per-turn ceilings still hold
* in the meantime.
*/
async function spentInLastDay(
db: Database,
principalUserId: string,
limits: PiggyTurnLimits,
): Promise<number | null> {
if (limits.dailyLimitCents <= 0) return null;
try {
const rows = await db
.select({ spent: sql<string | null>`sum(${agentRuns.costMicroCents})` })
.from(agentRuns)
.where(
and(
eq(agentRuns.principalUserId, principalUserId),
gte(agentRuns.startedAt, new Date(Date.now() - DAY_MS)),
),
);
// `sum()` of an integer column comes back as a numeric, which the driver
// hands over as a string so that a bigint cannot be silently rounded.
const spent = Number(rows[0]?.spent ?? 0);
if (!Number.isFinite(spent)) return null;
return spent >= limits.dailyLimitCents * 1_000_000 ? spent : null;
} catch (error) {
console.error('[piggy] could not read the daily spend; allowing the turn:', error);
return null;
}
}
/** Micro-cents as a person reads them. */
function formatCents(microCents: number): string {
return `$${(microCents / 100_000_000).toFixed(2)}`;
}
// -------------------------------------------------------------------- plumbing
function beginStream(response: ServerResponse): void {
response.writeHead(200, {
'content-type': 'application/x-ndjson; charset=utf-8',
'cache-control': 'no-cache, no-transform',
'x-content-type-options': 'nosniff',
});
}
function writeFrame(response: ServerResponse, event: PiggyChatEvent): void {
response.write(`${JSON.stringify(event)}\n`);
}
function respondJson(response: ServerResponse, status: number, body: unknown): void {
response.writeHead(status, { 'content-type': 'application/json' });
response.end(JSON.stringify(body));
}
function tokenMatches(header: string | undefined, expected: string): boolean {
const supplied = header?.startsWith('Bearer ') ? header.slice(7) : '';
const suppliedBytes = Buffer.from(supplied);
const expectedBytes = Buffer.from(expected);
return (
suppliedBytes.length === expectedBytes.length &&
timingSafeEqual(suppliedBytes, expectedBytes)
);
}
async function readBoundedBody(request: IncomingMessage, maximumBytes: number): Promise<string> {
const chunks: Buffer[] = [];
let size = 0;
for await (const chunk of request) {
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
size += bytes.length;
if (size > maximumBytes) throw new Error('Piggy chat request is too large.');
chunks.push(bytes);
}
return Buffer.concat(chunks).toString('utf8');
}
function isLoopback(host: string): boolean {
return host === '127.0.0.1' || host === '::1' || host === 'localhost';
}