|
|
|
@@ -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;
|
|
|
|
|