Files
pig/packages/db/src/schema/agent.ts
T
karti 76e3caa1cb
CI / verify (push) Successful in 3m33s
CI / publish (push) Has been skipped
Drop the Comp AI CRM acknowledgement
Nothing in PIG derives from that repository. The fact model, the leased
agent task queue and the `agentBrief` field are our own designs, and MIT's
attribution condition reaches copied source, not ideas — so the credit was
a courtesy that misstated where this code came from.

The one line worth keeping was never a credit: AGENTS.md's rule against
lifting component files out of somebody else's repo. It is restated
generically, and the shadcn-from-upstream guidance stays.

Buzz keeps its NOTICE entry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 23:41:10 -07:00

230 lines
8.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* The agent layer.
*
* Three ideas here, all of which exist to make an agent safe to point at a
* database that people make commercial decisions from.
*
* 1. **The API never calls the agent.** It writes a row to `agentTasks`. A
* worker leases rows and drains them. The queue therefore survives the agent
* being offline, a restart replays nothing that already finished, and no
* request thread is ever blocked on a model.
*
* 2. **Every derived claim carries its evidence.** Enrichment writes to `facts`
* with a score, a band, a source URL and a status — not directly to the
* record. Strong signals apply themselves; weak ones queue for a human. An
* agent permitted to write unattributed claims will eventually write a wrong
* one, and nobody will be able to tell which.
*
* 3. **Every outward action is idempotent.** `agentActions` carries a unique
* idempotency key, so a retried task cannot send the same message twice.
*/
import {
index,
integer,
jsonb,
numeric,
pgTable,
text,
timestamp,
uniqueIndex,
uuid,
} from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import {
agentTaskKindEnum,
agentTaskOutcomeEnum,
factBandEnum,
factStatusEnum,
} from './enums';
import { accounts, contacts } from './crm';
import { users } from './identity';
/**
* 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
* again when the lease lapses. No dead-letter babysitting, no stuck jobs.
*/
export const agentTasks = pgTable(
'agent_tasks',
{
id: uuid('id').primaryKey().defaultRandom(),
kind: agentTaskKindEnum('kind').notNull(),
/**
* What the task is about — an account id, a contact id, a commitment id.
* Free text because the referent varies by kind, and a foreign key per kind
* would mean a column per kind.
*/
subject: text('subject').notNull(),
/** Why this was queued. Shown to the user; keeps agent work explicable. */
reason: text('reason'),
payload: jsonb('payload').$type<Record<string, unknown>>(),
/** Higher runs first. */
priority: integer('priority').notNull().default(0),
/** Model calls this task may spend before giving up. */
budget: integer('budget').notNull().default(4),
attempts: integer('attempts').notNull().default(0),
maxAttempts: integer('max_attempts').notNull().default(3),
/** Not eligible to run before this time. Used for backoff and scheduling. */
dueAt: timestamp('due_at', { withTimezone: true }).notNull().defaultNow(),
/** Claimed until. Null means unclaimed; past means the claim has lapsed. */
leasedUntil: timestamp('leased_until', { withTimezone: true }),
leasedBy: text('leased_by'),
startedAt: timestamp('started_at', { withTimezone: true }),
finishedAt: timestamp('finished_at', { withTimezone: true }),
outcome: agentTaskOutcomeEnum('outcome'),
error: text('error'),
requestedByUserId: uuid('requested_by_user_id').references(() => users.id, {
onDelete: 'set null',
}),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
/**
* At most one *outstanding* task per (kind, subject).
*
* Partial on `finished_at IS NULL`, so re-enriching an account next month is
* fine while queueing the same enrichment twice today is not. Without this,
* a UI that queues work on page view will melt the agent.
*/
uniqueIndex('agent_tasks_pending_key')
.on(t.kind, t.subject)
.where(sql`${t.finishedAt} IS NULL`),
index('agent_tasks_claimable_idx').on(t.dueAt, t.priority),
index('agent_tasks_lease_idx').on(t.leasedUntil),
],
);
/**
* Evidence-bearing claims produced by the agent.
*
* A fact never overwrites a record directly. It records what was claimed, about
* which field, with what confidence, on what evidence — and only then, if the
* band is `verified`, is it applied. Everything weaker waits for a person.
*/
export const facts = pgTable(
'facts',
{
id: uuid('id').primaryKey().defaultRandom(),
accountId: uuid('account_id').references(() => accounts.id, { onDelete: 'cascade' }),
contactId: uuid('contact_id').references(() => contacts.id, { onDelete: 'cascade' }),
/** The field being claimed about, e.g. `title`, `supplierType`, `gpuCount`. */
field: text('field').notNull(),
value: text('value').notNull(),
/** 01. Mapped to a band by `bandForScore` in @pig/core. */
score: numeric('score', { precision: 4, scale: 3 }).notNull(),
band: factBandEnum('band').notNull(),
status: factStatusEnum('status').notNull().default('proposed'),
/** What the claim rests on: quotes, URLs, the reasoning that produced it. */
evidence: jsonb('evidence').$type<Record<string, unknown>>(),
sourceUrl: text('source_url'),
/** How it was derived — 'web_search', 'prime_api', 'inference'. */
method: text('method'),
/** The agent run that produced this, for tracing back. */
agentRunId: uuid('agent_run_id'),
decidedByUserId: uuid('decided_by_user_id').references(() => users.id, {
onDelete: 'set null',
}),
decidedAt: timestamp('decided_at', { withTimezone: true }),
observedAt: timestamp('observed_at', { withTimezone: true }).notNull().defaultNow(),
supersededAt: timestamp('superseded_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
index('facts_account_idx').on(t.accountId),
index('facts_contact_idx').on(t.contactId),
index('facts_status_idx').on(t.status),
index('facts_field_idx').on(t.field),
],
);
/** One execution of the agent, for cost accounting and debugging. */
export const agentRuns = pgTable(
'agent_runs',
{
id: uuid('id').primaryKey().defaultRandom(),
agentTaskId: uuid('agent_task_id').references(() => agentTasks.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. */
principalUserId: uuid('principal_user_id').references(() => users.id, {
onDelete: 'set null',
}),
status: text('status').notNull().default('running'),
model: text('model'),
inputTokens: integer('input_tokens'),
outputTokens: integer('output_tokens'),
costMicroCents: integer('cost_micro_cents'),
input: jsonb('input').$type<Record<string, unknown>>(),
result: jsonb('result').$type<Record<string, unknown>>(),
summary: text('summary'),
error: text('error'),
startedAt: timestamp('started_at', { withTimezone: true }).notNull().defaultNow(),
finishedAt: timestamp('finished_at', { withTimezone: true }),
},
(t) => [
index('agent_runs_task_idx').on(t.agentTaskId),
index('agent_runs_principal_idx').on(t.principalUserId),
],
);
/**
* Side effects the agent performed. Every row that touches the world outside
* PIG lands here first, keyed by an idempotency key, so a retry is a no-op
* rather than a second message to a customer.
*/
export const agentActions = pgTable(
'agent_actions',
{
id: uuid('id').primaryKey().defaultRandom(),
agentRunId: uuid('agent_run_id').references(() => agentRuns.id, { onDelete: 'cascade' }),
/** 'update_record' | 'send_slack' | 'post_buzz' | 'create_task' */
type: text('type').notNull(),
targetType: text('target_type'),
targetId: text('target_id'),
summary: text('summary'),
/**
* Unique. This is the whole safety mechanism: an action is attempted at
* most once, no matter how many times its task is retried.
*/
idempotencyKey: text('idempotency_key').notNull(),
status: text('status').notNull().default('pending'),
externalId: text('external_id'),
error: text('error'),
metadata: jsonb('metadata').$type<Record<string, unknown>>(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
uniqueIndex('agent_actions_idempotency_key').on(t.idempotencyKey),
index('agent_actions_run_idx').on(t.agentRunId),
],
);
export type AgentTask = typeof agentTasks.$inferSelect;
export type NewAgentTask = typeof agentTasks.$inferInsert;
export type Fact = typeof facts.$inferSelect;
export type NewFact = typeof facts.$inferInsert;
export type AgentRun = typeof agentRuns.$inferSelect;