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,68 @@
|
||||
-- Generated by drizzle-kit, then trimmed. Three statements it emitted are not
|
||||
-- here, and deleting them is the point rather than an oversight.
|
||||
--
|
||||
-- 0005, 0012 and 0013 were hand-written and left no snapshot, so drizzle
|
||||
-- diffed against 0011 and re-proposed their work: `ALTER TYPE pig_team_role
|
||||
-- ADD VALUE 'viewer'` (0012, and without the IF NOT EXISTS that made that one
|
||||
-- safe) and a drop-and-add of the learn_resources provider CHECK (0013). On
|
||||
-- any database those migrations have already touched, the first fails outright
|
||||
-- and the second is a needless drop of a live constraint. The 0014 snapshot
|
||||
-- beside this file is kept in full, because it describes the schema as it
|
||||
-- actually is — which is what re-synchronises the snapshot chain with the
|
||||
-- hand-written migrations for whoever generates 0015.
|
||||
--
|
||||
-- Everything below is additive: two new tables and one new nullable column.
|
||||
-- Nothing here reads or rewrites an existing row, so it cannot fail on data.
|
||||
CREATE TABLE "piggy_conversations" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"title" text NOT NULL,
|
||||
"model" text,
|
||||
"mode" text,
|
||||
"read_capability" text DEFAULT 'book:read' NOT NULL,
|
||||
"context" jsonb,
|
||||
"last_message_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "piggy_conversations_mode_check" CHECK ("piggy_conversations"."mode" IS NULL OR "piggy_conversations"."mode" IN ('read_only', 'confirm', 'auto')),
|
||||
CONSTRAINT "piggy_conversations_read_capability_check" CHECK ("piggy_conversations"."read_capability" IN ('book:read', 'economics:read', 'team:read')),
|
||||
CONSTRAINT "piggy_conversations_title_check" CHECK (length("piggy_conversations"."title") > 0)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "piggy_messages" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"conversation_id" uuid NOT NULL,
|
||||
"seq" integer NOT NULL,
|
||||
"role" text NOT NULL,
|
||||
"content" text DEFAULT '' NOT NULL,
|
||||
"reasoning" text,
|
||||
"model" text,
|
||||
"mode" text,
|
||||
"input_tokens" integer,
|
||||
"output_tokens" integer,
|
||||
"cost_micro_cents" integer,
|
||||
"finish_reason" text,
|
||||
"tool_call_id" text,
|
||||
"tool_name" text,
|
||||
"tool_arguments" jsonb,
|
||||
"tool_result" jsonb,
|
||||
"tool_ok" boolean,
|
||||
"approval_id" text,
|
||||
"approval_change" jsonb,
|
||||
"approval_decision" text,
|
||||
"approval_decided_at" timestamp with time zone,
|
||||
"error" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "piggy_messages_role_check" CHECK ("piggy_messages"."role" IN ('user', 'assistant', 'tool')),
|
||||
CONSTRAINT "piggy_messages_mode_check" CHECK ("piggy_messages"."mode" IS NULL OR "piggy_messages"."mode" IN ('read_only', 'confirm', 'auto')),
|
||||
CONSTRAINT "piggy_messages_approval_decision_check" CHECK ("piggy_messages"."approval_decision" IS NULL OR "piggy_messages"."approval_decision" IN ('apply', 'reject')),
|
||||
CONSTRAINT "piggy_messages_seq_check" CHECK ("piggy_messages"."seq" >= 0)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "agent_runs" ADD COLUMN "piggy_conversation_id" uuid;--> statement-breakpoint
|
||||
ALTER TABLE "piggy_conversations" ADD CONSTRAINT "piggy_conversations_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "piggy_messages" ADD CONSTRAINT "piggy_messages_conversation_id_piggy_conversations_id_fk" FOREIGN KEY ("conversation_id") REFERENCES "public"."piggy_conversations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "agent_runs" ADD CONSTRAINT "agent_runs_piggy_conversation_id_piggy_conversations_id_fk" FOREIGN KEY ("piggy_conversation_id") REFERENCES "public"."piggy_conversations"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "piggy_conversations_user_recent_idx" ON "piggy_conversations" USING btree ("user_id","last_message_at" DESC NULLS LAST);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "piggy_messages_conversation_seq_key" ON "piggy_messages" USING btree ("conversation_id","seq");--> statement-breakpoint
|
||||
CREATE INDEX "agent_runs_piggy_conversation_idx" ON "agent_runs" USING btree ("piggy_conversation_id");
|
||||
File diff suppressed because it is too large
Load Diff
@@ -99,6 +99,13 @@
|
||||
"when": 1786700000000,
|
||||
"tag": "0013_learn_self_hosted_provider",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 14,
|
||||
"version": "7",
|
||||
"when": 1786800000000,
|
||||
"tag": "0014_piggy_conversations",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -17,8 +17,15 @@
|
||||
*
|
||||
* 3. **Every outward action is idempotent.** `agentActions` carries a unique
|
||||
* idempotency key, so a retried task cannot send the same message twice.
|
||||
*
|
||||
* 4. **The conversation is ours, not the harness's.** `piggyConversations` and
|
||||
* `piggyMessages` hold Piggy's transcripts, including the tools it ran and
|
||||
* the approvals it was given. The long note above those tables explains why
|
||||
* the agent harness's own session store is deliberately unused.
|
||||
*/
|
||||
import {
|
||||
boolean,
|
||||
check,
|
||||
index,
|
||||
integer,
|
||||
jsonb,
|
||||
@@ -30,6 +37,13 @@ import {
|
||||
uuid,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import {
|
||||
PIGGY_MODES,
|
||||
READ_CAPABILITIES,
|
||||
type PiggyApprovalDecision,
|
||||
type PiggyChatContext,
|
||||
type PiggyProposedChange,
|
||||
} from '@pig/core';
|
||||
import {
|
||||
agentTaskKindEnum,
|
||||
agentTaskOutcomeEnum,
|
||||
@@ -39,6 +53,14 @@ import {
|
||||
import { accounts, contacts } from './crm';
|
||||
import { users } from './identity';
|
||||
|
||||
/**
|
||||
* Render a value set as a SQL `IN` list from the ontology constant. Same
|
||||
* reasoning as `learn.ts`: a vocabulary typed out again in a CHECK is a
|
||||
* vocabulary that drifts from the one the application validates against.
|
||||
*/
|
||||
const inList = (values: readonly string[]) =>
|
||||
sql.raw(values.map((value) => `'${value}'`).join(', '));
|
||||
|
||||
/**
|
||||
* The work queue. Leased rather than locked: a worker claims a row by stamping
|
||||
* `leasedUntil` into the future, and a crashed worker's rows become claimable
|
||||
@@ -150,6 +172,210 @@ export const facts = pgTable(
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* What a transcript row can be.
|
||||
*
|
||||
* `tool` is a first-class role rather than an assistant message with metadata
|
||||
* hung off it, because the renderer draws it as its own card and because a
|
||||
* turn can call several tools between two sentences.
|
||||
*/
|
||||
export const PIGGY_MESSAGE_ROLES = ['user', 'assistant', 'tool'] as const;
|
||||
export type PiggyMessageRole = (typeof PIGGY_MESSAGE_ROLES)[number];
|
||||
|
||||
/**
|
||||
* `PiggyApprovalDecision` as a value.
|
||||
*
|
||||
* @pig/core states it as a type union, and a CHECK constraint needs values.
|
||||
* `satisfies` stops this list drifting into something the protocol does not
|
||||
* admit. The other direction — a decision added to the union and not here —
|
||||
* the compiler cannot see, so it fails as a rejected insert the first time
|
||||
* that decision is recorded, which is loud rather than silent.
|
||||
*/
|
||||
const PIGGY_APPROVAL_DECISIONS = [
|
||||
'apply',
|
||||
'reject',
|
||||
] as const satisfies readonly PiggyApprovalDecision[];
|
||||
|
||||
/**
|
||||
* Piggy's conversations, and why they are stored here rather than by the
|
||||
* harness.
|
||||
*
|
||||
* Prime Agent ships a `SessionManager` with file-backed sessions, and PIG
|
||||
* deliberately does not use it for storage. Three reasons, all of which look
|
||||
* like an unnecessary complication until the fourth week in production:
|
||||
*
|
||||
* - PIG's source of truth is Postgres. A transcript is a record of what the
|
||||
* company was told about its own book; it belongs with the book.
|
||||
* - A session must be per-user and stay that way. Ownership here is a column
|
||||
* and a predicate on every query, not a filename someone could guess.
|
||||
* - The chat server runs in a container and will one day run in two. A file
|
||||
* under the agent directory survives neither a restart nor a second
|
||||
* replica, and the failure is silent: a user's history simply empties.
|
||||
*
|
||||
* So the harness is handed `SessionManager.inMemory()` for the length of one
|
||||
* turn and the history is rehydrated from these tables. If you are here to
|
||||
* "simplify" that by pointing the harness at its own store, this is the note
|
||||
* saying it was considered and refused.
|
||||
*
|
||||
* The transcript keeps evidence, not just prose. Reopening a conversation has
|
||||
* to show what Piggy DID — which tool ran, with what arguments, what came
|
||||
* back, what was proposed for approval and how the user answered — because the
|
||||
* product's whole claim is that an agent writing to a CRM is auditable. A
|
||||
* transcript that reloads as a wall of assistant text quietly withdraws it.
|
||||
* The authoritative record of a mutation is still the `activities` row written
|
||||
* by `executeMutation`; this is the record of the conversation that led to it.
|
||||
*/
|
||||
export const piggyConversations = pgTable(
|
||||
'piggy_conversations',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
|
||||
/**
|
||||
* The owner, and the only person who may read it. `cascade` rather than
|
||||
* the `set null` used for the audit-ish references above: an ownerless
|
||||
* transcript is not a historical record anyone can act on, it is a pile of
|
||||
* somebody's private questions with no one left to answer for them.
|
||||
*/
|
||||
userId: uuid('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
|
||||
/** Derived from the first user message, renameable. Never empty. */
|
||||
title: text('title').notNull(),
|
||||
|
||||
/**
|
||||
* The last turn's model and mode, so reopening a conversation restores the
|
||||
* picker rather than silently answering the next question on a different
|
||||
* model from the one the rest of the thread was answered by.
|
||||
*/
|
||||
model: text('model'),
|
||||
mode: text('mode', { enum: PIGGY_MODES }),
|
||||
|
||||
/**
|
||||
* The capability the transcript itself requires, raised by the relay to
|
||||
* whatever the turn's context needed.
|
||||
*
|
||||
* Without this, a person demoted out of `economics:read` keeps a doorway
|
||||
* to supplier cost and break-even: they cannot ask for margin any more,
|
||||
* but yesterday's answer is still sitting in their history. The floor in
|
||||
* READ_RULES cannot see it, because a path-keyed table cannot know what a
|
||||
* particular conversation was told.
|
||||
*/
|
||||
readCapability: text('read_capability', { enum: READ_CAPABILITIES })
|
||||
.notNull()
|
||||
.default('book:read'),
|
||||
|
||||
/** Where the conversation was opened from, for the sidebar's subtitle. */
|
||||
context: jsonb('context').$type<PiggyChatContext>(),
|
||||
|
||||
/**
|
||||
* When the transcript last gained a message — the sidebar's sort key.
|
||||
* Separate from `updatedAt` on purpose: renaming a conversation is not
|
||||
* activity, and should not shuffle it to the top of somebody's history.
|
||||
*/
|
||||
lastMessageAt: timestamp('last_message_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
/** The only listing query there is: my conversations, most recent first. */
|
||||
index('piggy_conversations_user_recent_idx').on(t.userId, t.lastMessageAt.desc()),
|
||||
check('piggy_conversations_mode_check', sql`${t.mode} IS NULL OR ${t.mode} IN (${inList(PIGGY_MODES)})`),
|
||||
check(
|
||||
'piggy_conversations_read_capability_check',
|
||||
sql`${t.readCapability} IN (${inList(READ_CAPABILITIES)})`,
|
||||
),
|
||||
check('piggy_conversations_title_check', sql`length(${t.title}) > 0`),
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* One row per entry the transcript renders: a user message, an assistant
|
||||
* message, or a tool call together with its approval record.
|
||||
*
|
||||
* The tool call and its approval share a row deliberately. They are one event
|
||||
* to a reader — "Piggy offered to log a call, you approved it, it wrote" — and
|
||||
* splitting them across rows means a reload can show the offer without the
|
||||
* answer, which is the one rendering that misleads.
|
||||
*
|
||||
* Persistence is best-effort with respect to the mutation: a crash between the
|
||||
* write and this row loses the transcript entry, never the audit trail.
|
||||
*/
|
||||
export const piggyMessages = pgTable(
|
||||
'piggy_messages',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
conversationId: uuid('conversation_id')
|
||||
.notNull()
|
||||
.references(() => piggyConversations.id, { onDelete: 'cascade' }),
|
||||
|
||||
/**
|
||||
* Position in the transcript. `created_at` is not enough: a tool call and
|
||||
* its result are written in the same millisecond, and a Date carries no
|
||||
* more precision than that, so ordering on the timestamp alone renders
|
||||
* them in an arbitrary order about a third of the time.
|
||||
*/
|
||||
seq: integer('seq').notNull(),
|
||||
|
||||
role: text('role', { enum: PIGGY_MESSAGE_ROLES }).notNull(),
|
||||
/** Empty for a tool row, whose content is the call and its result. */
|
||||
content: text('content').notNull().default(''),
|
||||
/** Kept for reasoning models: what it thought, as the transcript showed it. */
|
||||
reasoning: text('reasoning'),
|
||||
|
||||
/** Which model answered. It varies per turn now that the user chooses. */
|
||||
model: text('model'),
|
||||
/** The mode the turn ran in — an approval card means nothing without it. */
|
||||
mode: text('mode', { enum: PIGGY_MODES }),
|
||||
|
||||
inputTokens: integer('input_tokens'),
|
||||
outputTokens: integer('output_tokens'),
|
||||
/** Micro-cents, matching `agent_runs.cost_micro_cents`. */
|
||||
costMicroCents: integer('cost_micro_cents'),
|
||||
/** `length` when the answer was cut short by the token budget. */
|
||||
finishReason: text('finish_reason'),
|
||||
|
||||
toolCallId: text('tool_call_id'),
|
||||
toolName: text('tool_name'),
|
||||
toolArguments: jsonb('tool_arguments').$type<Record<string, unknown>>(),
|
||||
toolResult: jsonb('tool_result').$type<Record<string, unknown>>(),
|
||||
toolOk: boolean('tool_ok'),
|
||||
|
||||
/**
|
||||
* The approval record, stored as it was shown rather than as a pointer to
|
||||
* the record it touched. The summary and field values a user actually
|
||||
* agreed to are the evidence; re-deriving them from the row later would
|
||||
* show what the record says now, which is a different claim.
|
||||
*/
|
||||
approvalId: text('approval_id'),
|
||||
approvalChange: jsonb('approval_change').$type<PiggyProposedChange>(),
|
||||
/** Null means never answered — a turn that timed out or was abandoned. */
|
||||
approvalDecision: text('approval_decision', { enum: PIGGY_APPROVAL_DECISIONS }),
|
||||
approvalDecidedAt: timestamp('approval_decided_at', { withTimezone: true }),
|
||||
|
||||
/** A failed turn or a failed tool. Shown, not swallowed. */
|
||||
error: text('error'),
|
||||
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
/**
|
||||
* Ordering and the append guard in one index. A conversation is always
|
||||
* read whole and in order, so this is also the only index it needs — its
|
||||
* leading column serves the `conversation_id` lookups on its own.
|
||||
*/
|
||||
uniqueIndex('piggy_messages_conversation_seq_key').on(t.conversationId, t.seq),
|
||||
check('piggy_messages_role_check', sql`${t.role} IN (${inList(PIGGY_MESSAGE_ROLES)})`),
|
||||
check('piggy_messages_mode_check', sql`${t.mode} IS NULL OR ${t.mode} IN (${inList(PIGGY_MODES)})`),
|
||||
check(
|
||||
'piggy_messages_approval_decision_check',
|
||||
sql`${t.approvalDecision} IS NULL OR ${t.approvalDecision} IN (${inList(PIGGY_APPROVAL_DECISIONS)})`,
|
||||
),
|
||||
check('piggy_messages_seq_check', sql`${t.seq} >= 0`),
|
||||
],
|
||||
);
|
||||
|
||||
/** One execution of the agent, for cost accounting and debugging. */
|
||||
export const agentRuns = pgTable(
|
||||
'agent_runs',
|
||||
@@ -159,6 +385,18 @@ export const agentRuns = pgTable(
|
||||
onDelete: 'set null',
|
||||
}),
|
||||
|
||||
/**
|
||||
* The Piggy conversation this run answered, when it was one — which makes
|
||||
* "what has this thread cost?" a sum over this column rather than a join
|
||||
* through the transcript. `set null` because the spend happened whatever
|
||||
* became of the conversation: deleting a thread must not delete the record
|
||||
* of the credit it burned.
|
||||
*/
|
||||
piggyConversationId: uuid('piggy_conversation_id').references(
|
||||
() => piggyConversations.id,
|
||||
{ onDelete: 'set null' },
|
||||
),
|
||||
|
||||
/** Which agent — 'piggy', or a user's own connected client. */
|
||||
agent: text('agent').notNull().default('piggy'),
|
||||
/** The person on whose behalf this ran. */
|
||||
@@ -183,6 +421,8 @@ export const agentRuns = pgTable(
|
||||
(t) => [
|
||||
index('agent_runs_task_idx').on(t.agentTaskId),
|
||||
index('agent_runs_principal_idx').on(t.principalUserId),
|
||||
/** Per-conversation spend is one indexed scan, not a table sweep. */
|
||||
index('agent_runs_piggy_conversation_idx').on(t.piggyConversationId),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -227,3 +467,7 @@ export type NewAgentTask = typeof agentTasks.$inferInsert;
|
||||
export type Fact = typeof facts.$inferSelect;
|
||||
export type NewFact = typeof facts.$inferInsert;
|
||||
export type AgentRun = typeof agentRuns.$inferSelect;
|
||||
export type PiggyConversation = typeof piggyConversations.$inferSelect;
|
||||
export type NewPiggyConversation = typeof piggyConversations.$inferInsert;
|
||||
export type PiggyMessage = typeof piggyMessages.$inferSelect;
|
||||
export type NewPiggyMessage = typeof piggyMessages.$inferInsert;
|
||||
|
||||
Reference in New Issue
Block a user