Put Piggy on Prime Agent, and let it write to the book
CI / verify (push) Successful in 7m6s
CI / publish (push) Has been skipped

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>
This commit is contained in:
claude
2026-08-14 05:26:28 -07:00
parent 99d165b5e5
commit f0173440e4
77 changed files with 28108 additions and 1672 deletions
+333 -22
View File
@@ -1,6 +1,13 @@
import { useEffect, useRef, useState } from 'react';
import type { PiggyChatContext } from '@pig/core';
import { ApiError, getSupabase } from './api';
import type {
PiggyApprovalDecision,
PiggyChatContext,
PiggyChatEvent,
PiggyMode,
PiggyModelOption,
PiggyProposedChange,
} from '@pig/core';
import { ApiError, get, getSupabase, post } from './api';
/**
* Re-exported from @pig/core rather than declared here. The old local copy was
@@ -21,18 +28,33 @@ export interface PiggyChatTurn {
content: string;
}
export type PiggyChatEvent =
| { type: 'meta'; model: string }
| { type: 'reasoning_delta'; delta: string }
| { type: 'content_delta'; delta: string }
| { type: 'tool_call'; id: string; name: string; arguments: unknown }
| { type: 'tool_result'; id: string; name: string; ok: boolean; result?: unknown; error?: string }
| { type: 'done'; inputTokens: number | null; outputTokens: number | null }
| { type: 'error'; message: string };
/**
* The event union comes from @pig/core now, and the local copy is gone.
*
* It was declared here as well, which was survivable while the server only ever
* added fields, and stopped being survivable the moment a turn could pause on an
* approval: a client that has not been told about `approval_required` folds it
* into nothing, the card never appears, and the turn sits open until the agent's
* five-minute timeout rejects a change the user was never shown.
*/
export type { PiggyChatEvent, PiggyMode, PiggyModelOption, PiggyProposedChange };
/**
* What Piggy may do without being asked again.
*
* `read_only` is the default here for the same reason the relay defaults to it:
* write tools are something the user turns on, never something a forgotten
* field turns on for them.
*/
export const PIGGY_DEFAULT_MODE: PiggyMode = 'read_only';
export interface PiggyStatus {
enabled: boolean;
canUse: boolean;
/** What a client with no stored preference should open in. */
mode: PiggyMode;
/** The deployment's default model, or null when the agent cannot be asked. */
modelId: string | null;
}
/**
@@ -74,12 +96,33 @@ export interface ToolStep {
startedAt: number;
}
/**
* A write Piggy has proposed and not made, as the transcript holds it.
*
* The states are deliberately more than "pending or done". `submitting` exists
* because the decision travels on a second request while the turn's own stream
* stays open, so there is a real interval in which the user has answered and
* nothing has happened yet; and `error` sits alongside `pending` rather than
* replacing it, because a decision that did not reach the relay leaves the
* change exactly as it was — still waiting, still answerable.
*/
export interface ApprovalStep {
change: PiggyProposedChange;
state: 'pending' | 'submitting' | 'applied' | 'rejected' | 'failed';
/** What the user answered, once they have. */
decision?: PiggyApprovalDecision;
/** Why the decision could not be delivered, or why the write itself failed. */
error?: string;
}
export interface TranscriptMessage {
id: string;
role: 'user' | 'assistant';
content: string;
reasoning?: string;
tools?: ToolStep[];
/** Writes this turn proposed, in the order they were proposed. */
approvals?: ApprovalStep[];
error?: string;
pending?: boolean;
/** The user pressed stop. The answer is as complete as it will ever be. */
@@ -97,8 +140,18 @@ export interface TranscriptMessage {
retryableAt?: number;
/** From the `meta` event: which model actually answered. */
model?: string;
/** From the `meta` event: what Piggy was allowed to do while answering. */
mode?: PiggyMode;
inputTokens?: number | null;
outputTokens?: number | null;
/** Whole micro-cents this turn cost, when the provider reported usage. */
costMicroCents?: number | null;
/**
* The provider's own word for why the answer stopped. `length` means the
* token budget cut it off mid-sentence, which is a different thing from the
* connection dying and reads differently to the user.
*/
finishReason?: string;
}
/**
@@ -109,7 +162,7 @@ export interface TranscriptMessage {
* union it consumes.
*/
export function applyEvent(message: TranscriptMessage, event: PiggyChatEvent): TranscriptMessage {
if (event.type === 'meta') return { ...message, model: event.model };
if (event.type === 'meta') return { ...message, model: event.model, mode: event.mode };
if (event.type === 'content_delta') return { ...message, content: message.content + event.delta };
if (event.type === 'reasoning_delta') return { ...message, reasoning: (message.reasoning ?? '') + event.delta };
if (event.type === 'tool_call') {
@@ -138,13 +191,71 @@ export function applyEvent(message: TranscriptMessage, event: PiggyChatEvent): T
),
};
}
if (event.type === 'approval_required') {
// Parked, not applied. Nothing in the CRM has changed at this point and the
// card must not suggest otherwise — the tool is holding its own turn open
// waiting for the answer this event asks for.
const already = (message.approvals ?? []).some((entry) => entry.change.id === event.change.id);
if (already) return message;
return {
...message,
approvals: [...(message.approvals ?? []), { change: event.change, state: 'pending' }],
};
}
if (event.type === 'approval_resolved') {
return {
...message,
approvals: (message.approvals ?? []).map((entry) =>
entry.change.id === event.changeId ? settleApproval(entry, event) : entry,
),
};
}
if (event.type === 'done') {
return { ...message, pending: false, inputTokens: event.inputTokens, outputTokens: event.outputTokens };
return {
...message,
pending: false,
inputTokens: event.inputTokens,
outputTokens: event.outputTokens,
costMicroCents: event.costMicroCents,
finishReason: event.finishReason,
/**
* A turn cannot end with a write still waiting: the agent resolves every
* pending change before it settles, and one still `pending` here means the
* timeout rejected it. Leaving the card mid-flight would keep offering
* buttons that no longer answer anything.
*/
approvals: message.approvals?.map((entry) =>
entry.state === 'pending' || entry.state === 'submitting'
? { ...entry, state: 'failed', error: 'This change expired before it was answered.' }
: entry,
),
};
}
if (event.type === 'error') return { ...message, pending: false, error: event.message };
return message;
}
/**
* The authoritative outcome of an approval: the agent has now either performed
* the write or not, and says which. It is the only thing allowed to move a card
* to `applied`, so no failure path can leave the transcript claiming a change
* was saved.
*/
function settleApproval(
entry: ApprovalStep,
event: Extract<PiggyChatEvent, { type: 'approval_resolved' }>,
): ApprovalStep {
if (!event.ok) {
return { ...entry, state: 'failed', decision: event.decision, error: event.error };
}
return {
...entry,
state: event.decision === 'apply' ? 'applied' : 'rejected',
decision: event.decision,
error: undefined,
};
}
/**
* A turn worth offering a re-send for: one that ended without an answer
* through no choice of the user's. A stopped turn is excluded deliberately —
@@ -271,6 +382,26 @@ export interface PiggyConversation {
draft: string;
setDraft: (value: string) => void;
running: boolean;
/**
* How far Piggy may act, and in which model. Owned here rather than by the
* controls that set them, because they belong to the conversation: the panel
* that draws the pickers is unmounted every time the sheet closes, and a mode
* that reset itself to read-only behind a closed overlay would be a silent
* change to what the next question is allowed to do.
*/
mode: PiggyMode;
setMode: (mode: PiggyMode) => void;
/** Undefined means "whatever the deployment's default is" — never a guess. */
modelId: string | undefined;
setModelId: (modelId: string | undefined) => void;
/** Assigned by the relay on the first turn; needed to answer an approval. */
conversationId: string | undefined;
/**
* Answer a proposed write. Resolves once the decision has been delivered, not
* once the write has happened — the outcome arrives on the open stream as an
* `approval_resolved` event, which is the only thing that marks a card applied.
*/
approve: (changeId: string, decision: PiggyApprovalDecision) => void;
/**
* Send `text`, or the composer draft when it is omitted — a suggestion chip
* and the retry button both have something to say and no reason to make the
@@ -300,13 +431,53 @@ export interface PiggyConversation {
export function usePiggyConversation({
context,
initialPrompt = '',
initialMode = PIGGY_DEFAULT_MODE,
initialModelId,
initialMessages,
initialConversationId,
}: {
context?: PiggyChatContext;
initialPrompt?: string;
/** Seeds only. The conversation owns both afterwards; see `PiggyConversation`. */
initialMode?: PiggyMode;
initialModelId?: string;
/**
* A transcript this conversation is resuming, read back from
* `GET /api/piggy/conversations/:id`.
*
* A seed, like everything else here: it is applied at mount and never again,
* so a caller reopening a different thread must remount the hook (the
* workspace keys it on the conversation id). Without it the workspace could
* list history it had no way of putting back on screen, and the relay builds
* a turn's prompt from the `history` the client sends — so an unseeded hook
* would also continue a reopened thread having forgotten every word of it.
*/
initialMessages?: TranscriptMessage[];
/**
* The stored conversation this thread continues, when it is not a new one.
*
* Sent with the first turn so the relay carries on the same conversation
* rather than minting a second id for a thread the sidebar already lists,
* and so an approval posted before any `meta` event has an id to travel with.
*/
initialConversationId?: string;
} = {}): PiggyConversation {
const [messages, setMessages] = useState<TranscriptMessage[]>([]);
const [messages, setMessages] = useState<TranscriptMessage[]>(initialMessages ?? []);
const [draft, setDraft] = useState(initialPrompt);
const [running, setRunning] = useState(false);
const [mode, setMode] = useState<PiggyMode>(initialMode);
const [modelId, setModelId] = useState<string | undefined>(initialModelId);
const [conversationId, setConversationId] = useState<string | undefined>(initialConversationId);
/**
* The id `approve` posts with.
*
* A ref as well as state because an approval can be answered in the same
* frame the `meta` event arrived in — the card is drawn from a `setMessages`
* that React may commit before it commits `setConversationId`, and posting an
* approval with no conversation is a 400 the user reads as Piggy losing their
* change.
*/
const conversationRef = useRef<string | undefined>(initialConversationId);
/**
* The gate `send` actually reads, because `running` cannot close in time.
*
@@ -369,7 +540,21 @@ export function usePiggyConversation({
// about what React has committed.
let settled = false;
try {
for await (const event of streamPiggyChat({ message, history, context }, abort.signal)) {
const request = {
message,
history,
context,
mode,
modelId,
// Sent from the second turn on, so the agent can keep one thread rather
// than starting a fresh one under every question.
conversationId: conversationRef.current,
};
for await (const event of streamPiggyChat(request, abort.signal)) {
if (event.type === 'meta' && event.conversationId !== conversationRef.current) {
conversationRef.current = event.conversationId;
setConversationId(event.conversationId);
}
if (event.type === 'done' || event.type === 'error') settled = true;
updateTurn(assistantId, (turn) => applyEvent(turn, event));
}
@@ -377,20 +562,29 @@ export function usePiggyConversation({
// The body closed mid-answer. `readNdjson` returns normally when that
// happens, so without this the turn stays `pending` forever and a dead
// connection is indistinguishable from Piggy still thinking.
updateTurn(assistantId, (turn) => ({ ...turn, pending: false, truncated: true }));
updateTurn(assistantId, (turn) =>
strandApprovals({ ...turn, pending: false, truncated: true }),
);
}
} catch (error) {
if (abort.signal.aborted) {
// Aborting rejects the read, so neither `done` nor `error` ever
// arrives and nothing else will clear `pending` — which left the
// docked panel spinning across every subsequent navigation.
updateTurn(assistantId, (turn) => ({ ...turn, pending: false, stopped: true }));
updateTurn(assistantId, (turn) =>
strandApprovals({ ...turn, pending: false, stopped: true }),
);
} else {
const failure = describeFailure(error);
setMessages((current) =>
current.map((entry) => {
if (entry.id === assistantId) {
return { ...entry, pending: false, error: failure.message, retryableAt: failure.retryableAt };
return strandApprovals({
...entry,
pending: false,
error: failure.message,
retryableAt: failure.retryableAt,
});
}
// The question is marked, not deleted: the user's words stay on
// screen to be re-sent, and `toChatHistory` knows to keep a turn
@@ -420,23 +614,140 @@ export function usePiggyConversation({
void send(question.content, messages.slice(0, index - 1));
};
/**
* Answer a proposed write.
*
* Optimistic only as far as honesty allows: the card moves to `submitting` so
* the buttons stop inviting a second press, and no further. Only the
* `approval_resolved` event that comes back on the open stream can say the
* change was applied, because only the agent knows whether `executeMutation`
* accepted it. A POST that fails puts the card back where it was, with the
* reason attached — the change really is still pending at the agent, so
* offering the buttons again is the truth rather than a courtesy.
*/
const approve = async (changeId: string, decision: PiggyApprovalDecision) => {
const conversation = conversationRef.current;
// Only the turn actually holding the card is rewritten. Mapping every
// message would give the whole transcript new identities and re-render a
// long conversation on each button press.
const settleCard = (change: (entry: ApprovalStep) => ApprovalStep) =>
setMessages((current) =>
current.map((entry) =>
entry.approvals?.some((approval) => approval.change.id === changeId)
? {
...entry,
approvals: entry.approvals.map((approval) =>
approval.change.id === changeId ? change(approval) : approval,
),
}
: entry,
),
);
if (!conversation) {
settleCard((entry) => ({
...entry,
error: 'Piggy has not identified this conversation yet.',
}));
return;
}
settleCard((entry) => ({ ...entry, state: 'submitting', decision, error: undefined }));
try {
await post<{ ok: boolean }>('/api/piggy/approve', {
conversationId: conversation,
changeId,
decision,
});
} catch (error) {
/*
* A 404 is not a delivery failure, it is the change being over.
*
* The relay answers `approval_not_pending` when the agent no longer holds
* the card: the five-minute deadline rejected it, or the turn was
* abandoned. Putting the card back to `pending` there — which is what
* this did for every failure alike — leaves it reading "Needs you" with a
* live Apply button over a decision that can never be delivered, so the
* user presses it and gets the same 404 for ever. Every OTHER failure
* really does leave the change pending at the agent, and for those
* offering the buttons again is the truth rather than a courtesy.
*/
const settled = error instanceof ApiError && error.code === 'approval_not_pending';
settleCard((entry) => ({
...entry,
state: settled ? 'failed' : 'pending',
decision: undefined,
error: error instanceof Error ? error.message : 'That decision did not reach Piggy.',
}));
}
};
return {
messages,
draft,
setDraft,
running,
mode,
setMode,
modelId,
setModelId,
conversationId,
approve: (changeId, decision) => void approve(changeId, decision),
send: (text, fromTranscript) => void send(text, fromTranscript),
stop: () => abortRef.current?.abort(),
retry,
};
}
/**
* A turn that ended without the agent's word on its pending writes.
*
* Stop, a dropped connection and a refused request all leave the stream that
* `approval_resolved` would have arrived on closed for good. The change may
* genuinely still be waiting at the agent until its five-minute timeout, but
* nothing this client does can answer it any more, so the card says so instead
* of showing buttons that post into a conversation nobody is reading.
*/
function strandApprovals(message: TranscriptMessage): TranscriptMessage {
if (!message.approvals?.length) return message;
return {
...message,
approvals: message.approvals.map((entry) =>
entry.state === 'pending' || entry.state === 'submitting'
? { ...entry, state: 'failed', error: 'This turn ended before the change was answered.' }
: entry,
),
};
}
/**
* The models this deployment offers, for the picker.
*
* Served by the relay from the agent's own catalogue rather than a list kept
* here, because the relay refuses any model id that is not in it — a hard-coded
* option that has been retired upstream would be a menu entry whose only effect
* is a 400.
*/
export function fetchPiggyModels(): Promise<{
models: PiggyModelOption[];
defaultModelId: string | null;
}> {
return get('/api/piggy/models');
}
export interface PiggyChatRequest {
message: string;
history?: PiggyChatTurn[];
context?: PiggyChatContext;
/** Omitted, the relay reads it as `read_only`. Sent explicitly all the same. */
mode?: PiggyMode;
/** Must be one the relay's catalogue lists, or the turn is a 400. */
modelId?: string;
conversationId?: string;
}
export async function* streamPiggyChat(
request: {
message: string;
history?: PiggyChatTurn[];
context?: PiggyChatContext;
},
request: PiggyChatRequest,
signal?: AbortSignal,
): AsyncGenerator<PiggyChatEvent> {
const supabase = getSupabase();