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
+413
View File
@@ -0,0 +1,413 @@
/**
* The read side of the agent ledger.
*
* `agent_runs`, `agent_tasks` and `agent_actions` have been written to since
* the first wave and read by nothing. This service is what makes them visible:
* what Piggy has done, what is still queued, and what the whole thing has cost.
* Nothing here writes.
*
* Three decisions are worth stating, because each of them is a place where an
* audit surface can quietly start lying.
*
* **Cost is carried as an integer all the way to the browser.** The column is
* micro-cents — millionths of a cent — because a turn costs a fraction of a
* cent and rounding it per turn would drift. Nothing in this file divides; the
* conversion to money happens once, in the panel, against a labelled unit. A
* factor-of-100 error here would be the worst possible bug on this surface, so
* the unit is spelled out in the field name at every hop.
*
* **Scope is a predicate, not a filter applied afterwards.** A caller sees
* their own runs; a platform admin sees the workspace, because the ledger is
* the audit surface and an auditor who can only see their own spend is not an
* auditor. That is the opposite of `piggy-conversations.ts`, where an admin is
* deliberately NOT an exception — and the two are consistent: cost and outcome
* are the company's record, the transcript is the person's.
*
* **A conversation link is never handed across an ownership boundary.** An
* admin reading the workspace ledger sees that a run happened, what it cost and
* what it answered, but gets no doorway into somebody else's transcript. The
* link is resolved only against conversations the caller owns.
*/
import { and, desc, eq, gte, inArray, isNotNull, isNull, sql } from 'drizzle-orm';
import type { AgentTaskKind, AgentTaskOutcome } from '@pig/core';
import type { Database } from '@pig/db';
import { agentRuns, agentTasks, piggyConversations, users } from '@pig/db';
import type { Principal } from '../lib/auth';
/** How many runs the panel lists. A ledger, not an export. */
export const PIGGY_RUN_LIMIT = 25;
/** Outstanding tasks are all shown; finished ones are the recent tail. */
export const PIGGY_TASK_LIMIT = 12;
/** Long enough to identify a turn in a narrow column, short enough to fit. */
const SNIPPET_MAX = 180;
/**
* Where a run came from. A queued background task and a question typed into the
* workspace cost the same money and belong in the same ledger, but they are not
* the same event and a reader who cannot tell them apart cannot audit either.
*/
export type PiggyRunKind = 'chat' | 'task';
export interface PiggyRunSummary {
id: string;
kind: PiggyRunKind;
/** 'piggy', or a user's own connected client. */
agent: string;
/**
* Left as free text rather than narrowed to a union, because the column is
* free text: the worker and the chat relay both write it, and a status this
* service had never heard of would be silently mislabelled by a mapping. The
* panel styles the four known values and shows anything else as it is.
*/
status: string;
model: string | null;
/** The question, for a chat turn; the queued work, for a task run. */
label: string;
/** The first line of what Piggy answered. Null on a turn that said nothing. */
summary: string | null;
error: string | null;
inputTokens: number | null;
outputTokens: number | null;
/** Millionths of a cent. Divide by 100,000,000 for US dollars. */
costMicroCents: number | null;
startedAt: string;
finishedAt: string | null;
/** Null while the run is still going — the panel counts up from `startedAt`. */
durationMs: number | null;
/** The queued work this run drained, when it came from the queue. */
taskKind: AgentTaskKind | null;
/** Present only when the transcript belongs to the caller. See the header. */
conversation: { id: string; title: string } | null;
/**
* Whose turn it was — populated ONLY when that is somebody other than the
* caller, which is the only case where the answer is information. A viewer
* scoped to their own runs would otherwise read their own name on every row,
* and an admin reading the workspace could not tell at a glance which rows
* were theirs.
*/
principal: { id: string; name: string } | null;
}
/**
* What a queued task is doing, as one word.
*
* Derived rather than stored: the table records timestamps and an outcome, and
* "queued" versus "scheduled" versus "running" is a question about now. A
* lapsed lease is deliberately reported as queued rather than running — the
* worker holding it is gone, and a row that shows as running forever is how a
* stuck queue hides.
*/
export type PiggyTaskState = 'running' | 'queued' | 'scheduled' | AgentTaskOutcome;
export interface PiggyTaskSummary {
id: string;
kind: AgentTaskKind;
/** The account, contact or commitment id the work is about. */
subject: string;
/** Why it was queued. Written for a person to read. */
reason: string | null;
state: PiggyTaskState;
attempts: number;
maxAttempts: number;
priority: number;
/** Not eligible before this. In the future means scheduled, not late. */
dueAt: string;
startedAt: string | null;
finishedAt: string | null;
error: string | null;
}
/**
* The money question, in the unit the column stores.
*
* `turns` counts the month's runs, so the monthly figure can be read as an
* average per turn without a second request. Both windows are calendar
* boundaries in the API process's timezone, not rolling 24-hour spans: "today"
* that silently means "since this time yesterday" is a number nobody can
* reconcile against a provider's invoice.
*/
export interface PiggySpendSummary {
todayMicroCents: number;
monthMicroCents: number;
turns: number;
}
export interface PiggyActivityOverview {
runs: PiggyRunSummary[];
tasks: PiggyTaskSummary[];
spend: PiggySpendSummary;
}
/** Canonical UUID text. See `conversationIdOf` for the row this saved. */
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
function snippet(value: string | null | undefined): string | null {
if (!value) return null;
// First line only: an answer is often a table or a bulleted list, and pouring
// the whole of it into a ledger row turns the list into a wall.
const [first = ''] = value.trim().split('\n');
const line = first.trim();
if (!line) return null;
return line.length > SNIPPET_MAX ? `${line.slice(0, SNIPPET_MAX - 1).trimEnd()}` : line;
}
function readString(bag: Record<string, unknown> | null, key: string): string | null {
const value = bag?.[key];
return typeof value === 'string' && value.trim() ? value : null;
}
/**
* The conversation a run answered.
*
* `agent_runs.piggy_conversation_id` is the column that means this, and the
* chat relay does not yet populate it — it writes the id into the run's `input`
* blob instead. Reading both keeps the panel honest today without pretending
* the column is redundant; when the relay starts stamping it, this falls back
* to the column and the second arm becomes dead weight worth deleting.
*
* The value in `input` is whatever the client sent, and a real row in this
* database has `"conversationId": "drive-write-1"` in it, so it is validated
* rather than cast. An unguarded `::uuid` here would take the whole endpoint
* down with a Postgres syntax error on that one row.
*/
function conversationIdOf(row: {
piggyConversationId: string | null;
input: Record<string, unknown> | null;
}): string | null {
if (row.piggyConversationId) return row.piggyConversationId;
const claimed = readString(row.input, 'conversationId');
return claimed && UUID_PATTERN.test(claimed) ? claimed : null;
}
function humaniseKind(kind: string): string {
return kind.replaceAll('_', ' ').replace(/^./, (letter) => letter.toUpperCase());
}
function taskState(row: {
outcome: AgentTaskOutcome | null;
finishedAt: Date | null;
startedAt: Date | null;
leasedUntil: Date | null;
dueAt: Date;
}, now: Date): PiggyTaskState {
if (row.finishedAt || row.outcome) return row.outcome ?? 'succeeded';
if (row.leasedUntil && row.leasedUntil > now) return 'running';
return row.dueAt > now ? 'scheduled' : 'queued';
}
export class PiggyActivityService {
constructor(private readonly db: Database) {}
async overview(principal: Principal, now = new Date()): Promise<PiggyActivityOverview> {
const [runs, tasks, spend] = await Promise.all([
this.runs(principal),
this.tasks(principal, now),
this.spend(principal, now),
]);
return { runs, tasks, spend };
}
private async runs(principal: Principal): Promise<PiggyRunSummary[]> {
const rows = await this.db
.select({
id: agentRuns.id,
agent: agentRuns.agent,
status: agentRuns.status,
model: agentRuns.model,
summary: agentRuns.summary,
error: agentRuns.error,
input: agentRuns.input,
inputTokens: agentRuns.inputTokens,
outputTokens: agentRuns.outputTokens,
costMicroCents: agentRuns.costMicroCents,
startedAt: agentRuns.startedAt,
finishedAt: agentRuns.finishedAt,
agentTaskId: agentRuns.agentTaskId,
piggyConversationId: agentRuns.piggyConversationId,
taskKind: agentTasks.kind,
taskSubject: agentTasks.subject,
principalId: users.id,
principalName: users.name,
})
.from(agentRuns)
.leftJoin(agentTasks, eq(agentTasks.id, agentRuns.agentTaskId))
.leftJoin(users, eq(users.id, agentRuns.principalUserId))
.where(this.scope(principal))
.orderBy(desc(agentRuns.startedAt))
.limit(PIGGY_RUN_LIMIT);
const titles = await this.conversationTitles(principal, rows.map(conversationIdOf));
return rows.map((row) => {
const conversationId = conversationIdOf(row);
const title = conversationId ? titles.get(conversationId) : undefined;
const kind: PiggyRunKind = row.agentTaskId ? 'task' : 'chat';
const ask = snippet(readString(row.input, 'message'));
return {
id: row.id,
kind,
agent: row.agent,
status: row.status,
model: row.model,
// A run with neither a question nor a task kind is a row written before
// the turn got anywhere; naming it after its status beats an empty cell.
label:
ask ??
(row.taskKind ? humaniseKind(row.taskKind) : null) ??
(kind === 'task' ? 'Queued work' : 'Untitled turn'),
summary: snippet(row.summary),
error: row.error,
inputTokens: row.inputTokens,
outputTokens: row.outputTokens,
costMicroCents: row.costMicroCents,
startedAt: row.startedAt.toISOString(),
finishedAt: row.finishedAt?.toISOString() ?? null,
durationMs: row.finishedAt
? row.finishedAt.getTime() - row.startedAt.getTime()
: null,
taskKind: row.taskKind ?? null,
conversation: conversationId && title ? { id: conversationId, title } : null,
principal:
row.principalId && row.principalId !== principal.userId
? { id: row.principalId, name: row.principalName ?? 'Another member' }
: null,
};
});
}
/**
* Titles for the runs' conversations, and only for the caller's own.
*
* One statement for the whole page rather than a join per row, and the
* ownership predicate is in the statement — so a run belonging to somebody
* else simply resolves to no title, and the panel renders it without a link
* rather than with a link that 404s.
*/
private async conversationTitles(
principal: Principal,
ids: (string | null)[],
): Promise<Map<string, string>> {
const wanted = [...new Set(ids.filter((id): id is string => id !== null))];
if (wanted.length === 0) return new Map();
const rows = await this.db
.select({ id: piggyConversations.id, title: piggyConversations.title })
.from(piggyConversations)
.where(
and(
inArray(piggyConversations.id, wanted),
eq(piggyConversations.userId, principal.userId),
),
);
return new Map(rows.map((row) => [row.id, row.title]));
}
/**
* Outstanding work first, then the recent tail of finished work.
*
* Two statements rather than one: what is queued must never be truncated by a
* busy week of completions, and a finished-task list that grows without bound
* is not a panel. A failed task stays in the tail with its error — hiding a
* failure is how a queue looks healthy while nothing drains.
*/
private async tasks(principal: Principal, now: Date): Promise<PiggyTaskSummary[]> {
const columns = {
id: agentTasks.id,
kind: agentTasks.kind,
subject: agentTasks.subject,
reason: agentTasks.reason,
outcome: agentTasks.outcome,
attempts: agentTasks.attempts,
maxAttempts: agentTasks.maxAttempts,
priority: agentTasks.priority,
dueAt: agentTasks.dueAt,
leasedUntil: agentTasks.leasedUntil,
startedAt: agentTasks.startedAt,
finishedAt: agentTasks.finishedAt,
error: agentTasks.error,
};
const mine = principal.isPlatformAdmin
? undefined
: eq(agentTasks.requestedByUserId, principal.userId);
const [outstanding, finished] = await Promise.all([
this.db
.select(columns)
.from(agentTasks)
.where(and(isNull(agentTasks.finishedAt), mine))
.orderBy(agentTasks.dueAt)
.limit(PIGGY_TASK_LIMIT),
this.db
.select(columns)
.from(agentTasks)
.where(and(isNotNull(agentTasks.finishedAt), mine))
.orderBy(desc(agentTasks.finishedAt))
.limit(PIGGY_TASK_LIMIT),
]);
return [...outstanding, ...finished].map((row) => ({
id: row.id,
kind: row.kind,
subject: row.subject,
reason: row.reason,
state: taskState(row, now),
attempts: row.attempts,
maxAttempts: row.maxAttempts,
priority: row.priority,
dueAt: row.dueAt.toISOString(),
startedAt: row.startedAt?.toISOString() ?? null,
finishedAt: row.finishedAt?.toISOString() ?? null,
error: row.error,
}));
}
/**
* Today's and this month's spend, and the month's turn count.
*
* Summed as `double precision` rather than the column's `int`: a year of
* turns overflows int4 long before it troubles a double's 2^53 of integer
* precision, and `sum()` over a numeric would come back as a string and get
* quietly concatenated somewhere. The result is rounded back to an integer
* because the wire unit is micro-cents, which have no fractional part.
*/
private async spend(principal: Principal, now: Date): Promise<PiggySpendSummary> {
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
const dayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const [row] = await this.db
.select({
/*
* The boundary is bound as ISO text and cast in SQL. A raw fragment
* hands its parameters straight to the driver with none of the column
* mapping drizzle applies to `gte()`, and postgres.js answers a Date
* there with `ERR_INVALID_ARG_TYPE` — a 500 on the whole panel.
*/
today: sql<number>`coalesce(sum(${agentRuns.costMicroCents}) filter (
where ${agentRuns.startedAt} >= ${dayStart.toISOString()}::timestamptz
), 0)::double precision`,
month: sql<number>`coalesce(sum(${agentRuns.costMicroCents}), 0)::double precision`,
turns: sql<number>`count(*)::int`,
})
.from(agentRuns)
.where(and(gte(agentRuns.startedAt, monthStart), this.scope(principal)));
return {
todayMicroCents: Math.round(row?.today ?? 0),
monthMicroCents: Math.round(row?.month ?? 0),
turns: row?.turns ?? 0,
};
}
/**
* Whose ledger this is. Undefined widens to the workspace, which drizzle's
* `and()` treats as no predicate at all — deliberate, and the only place the
* admin exception is expressed.
*/
private scope(principal: Principal) {
return principal.isPlatformAdmin
? undefined
: eq(agentRuns.principalUserId, principal.userId);
}
}