Put Piggy on Prime Agent, and let it write to the book
Piggy was a hand-rolled OpenAI tool loop. It is now a Prime Agent session — Prime Intellect's own harness, embedded as a Node library — answering from PIG's tools and, for the first time, able to put information into the CRM rather than only read it out. The harness is a coding agent, so the first job was taking the coding agent away from it. `noTools: 'all'` plus an explicit allowlist leaves the model with PIG's ten `pig_*` tools and no bash, no filesystem, no IPython. That holds under attack: a hostile extension, a skill and a settings file planted in the agent's own directory, then `setActiveToolsByName` called with every built-in, still leaves ten tools, all ours. Both lines are load-bearing — `noTools` alone registers nothing, and the allowlist is what admits our own. Writing is gated rather than assumed. A change is proposed, not made: the tool returns a description, the transcript renders a diff card, and nothing reaches the database until someone presses Apply. Contracts, commitments, allocations and compliance always stop for a human whatever the mode. Every write runs through `executeMutation` as the calling user, so their capabilities and the audit trail apply exactly as they would to a human's. Four things about the SDK are wrong in its own documentation and cost a debugging cycle each: models.json does not resolve an env var name for `apiKey`, it sends the literal string; there is no built-in prime-inference provider in 0.84.1; a ResourceLoader you pass in is never reloaded for you; and the stock system prompt is a coding-assistant prompt that must be replaced — but replacing it also silently removes the tool list, because the harness only renders that section when it owns the prompt. AGENTS.md records all four. The expensive one was thinking level. The harness defaults to `medium`, and nemotron spent an entire 4,096-token budget reasoning and returned an empty answer. `low` was worse; `off` omits the parameter so the endpoint's default wins. An explicit `reasoning_effort: none` via `thinkingLevelMap` took a turn from 6,195 output tokens to 149. And a turn is now bounded. The harness loop is `while (true)` with no iteration cap; a runaway on a frontier model would have eaten the credit it is supposed to report on. Ceilings on model calls and tokens, enforced both through the harness hook and independently from the event stream, plus a per-user daily spend limit — and the ledger now records spend on turns that fail, which it previously discarded. Signing in lands on /piggy, which is a workspace: conversations down one side, the agent in the middle, what it did and what it cost beside it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user