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
@@ -0,0 +1,211 @@
/**
* The first thing anyone sees after signing in.
*
* It has one job that the old blank transcript did not have: Piggy can write
* now, and nobody will discover that by typing into a box. So the openers are
* in two columns — what it can find out, and what it can get done — and the
* second column says plainly that a change is proposed and waits for a person.
*
* The read openers come from `piggySuggestions`, which picks them by the one
* read tool this context resolves to, so every line is one Piggy can ground.
* The write openers are held here because there is no equivalent table for them
* yet, and they are written against the same constraint: each one is answerable
* with the tools a `/piggy` turn is actually given — the workspace summary, the
* record lookups, the renewals list — and none of them names a record that only
* exists in the demo book.
*
* Pressing a write opener while Piggy is in Read only moves it to Ask first.
* That is a change to a permission, so it is never silent: the card says so
* before it is pressed, and the mode control in the header changes with it. Ask
* first cannot write unattended — it proposes, and the Apply button is the
* person — so the escalation this performs is from "no tools" to "a proposal
* you must approve", which is the thing the user just asked for by pressing it.
*/
import { ArrowRight, PenLine, Search } from 'lucide-react';
import type { PiggyChatContext, PiggyMode } from '@pig/core';
import { piggySuggestions } from '@/lib/piggy-suggestions';
import { PiggyMark } from '@/components/PiggyMark';
import { cn } from '@/components/ui';
/**
* Openers that end in a change to the book.
*
* Every write tool takes a record id, and none of the tools a `/piggy` turn is
* given returns one from the page context alone — so each of these is a lookup
* followed by a write, and none of them names a record. Naming one would make
* them land beautifully on the seeded demo book and fail on the first real
* deployment, which is the opposite of the trade this file should make.
*
* The consequence is stated to the user rather than hidden: where the sentence
* does not identify the record, Piggy asks which one instead of choosing. That
* is the behaviour a CRM should have, and it is measurably what the default
* model does — see the note under the column.
*/
const WRITE_STARTERS = [
'Find the block furthest from break-even and log a note on its account.',
'Look up the contract renewing soonest and log a call about extending it.',
'Add a task to chase the account we have not spoken to in a month.',
];
const READ_STARTERS_SHOWN = 3;
export function PiggyWorkspaceStarters({
context,
mode,
canWrite,
onAsk,
onAskWithChange,
narrow = false,
}: {
context?: PiggyChatContext;
/** Only to word the note. The escalation itself belongs to the thread. */
mode: PiggyMode;
canWrite: boolean;
onAsk: (text: string) => void;
/**
* An opener that ends in a write. The thread raises the mode first and sends
* once the conversation is holding the new one — `send` reads the mode out of
* the conversation, so sending in the same tick would ask for a change with
* the write tools still withheld.
*/
onAskWithChange: (text: string) => void;
/** The middle column is under ~40rem: stack the two groups. */
narrow?: boolean;
}) {
/*
* Two openers each on a phone, three on a desktop.
*
* Not a taste decision: the transcript sticks to the bottom of its
* scrollport, so anything taller than the viewport opens with its own
* heading scrolled off the top. Measured at 393x852 the six-opener version
* overran by about 180px, which put the pig, the headline and the first
* column header above the fold on the screen that is supposed to introduce
* the product.
*/
const perGroup = narrow ? 2 : READ_STARTERS_SHOWN;
const reads = piggySuggestions(context).slice(0, perGroup);
const writes = WRITE_STARTERS.slice(0, perGroup);
return (
// `flex-1` rather than `h-full`: the conversation viewport's content element
// is sized by its children, so a percentage height resolves to nothing.
// Centred where there is room to spare, and tight where there is not: at
// 393x852 the six-line version needs every one of these 40 pixels to land
// whole above the composer.
<div
className={cn(
'mx-auto flex w-full max-w-3xl flex-1 flex-col justify-center',
narrow ? 'gap-4 py-1' : 'gap-6 py-6',
)}
>
<div className="flex flex-col items-center text-center">
<PiggyMark className={cn('text-fg', narrow ? 'size-8' : 'size-11')} aria-hidden />
<h2
className={cn(
'font-semibold tracking-tight',
narrow ? 'mt-2' : 'mt-3',
narrow ? 'text-base' : 'text-lg sm:text-xl',
)}
>
Ask across the book and now, act on the answer.
</h2>
<p className={cn('mt-1.5 max-w-xl text-muted', narrow ? 'text-xs leading-5' : 'text-sm leading-6')}>
{narrow
? 'Piggy reads your PIG records through scoped tools. Switched to Ask first, it drafts changes for you to approve.'
: 'Piggy reads your PIG records through scoped tools, with no shell, filesystem or browser. Switched to Ask first, it also drafts changes: each one arrives as a card you read and approve, and nothing reaches the book until you do.'}
</p>
</div>
<div className={cn('grid gap-4', narrow ? 'grid-cols-1' : 'sm:grid-cols-2')}>
<StarterGroup
icon={<Search aria-hidden className="size-3.5" />}
title="Look something up"
/* Dropped on a phone, where the two columns are stacked and every
line costs: the hero above has just said the same thing, and the
note that has to survive is the one about writing. */
note={narrow ? null : 'Answered from your records, with the rows it read attached.'}
>
{reads.map((suggestion) => (
<StarterButton key={suggestion} onClick={() => onAsk(suggestion)}>
{suggestion}
</StarterButton>
))}
</StarterGroup>
<StarterGroup
icon={<PenLine aria-hidden className="size-3.5" />}
title="Get something done"
note={
canWrite
? mode === 'read_only'
? 'These switch Piggy to Ask first: it proposes the change, you press Apply. It asks which record if your line does not say.'
: 'Piggy shows you exactly what it would write, and asks which record if your line does not say.'
: 'Your access does not allow changing records, so Piggy can only read.'
}
>
{writes.map((suggestion) => (
<StarterButton
key={suggestion}
disabled={!canWrite}
onClick={() => onAskWithChange(suggestion)}
>
{suggestion}
</StarterButton>
))}
</StarterGroup>
</div>
</div>
);
}
function StarterGroup({
icon,
title,
note,
children,
}: {
icon: React.ReactNode;
title: string;
note: string | null;
children: React.ReactNode;
}) {
return (
<section className="flex min-w-0 flex-col gap-2">
<h3 className="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-muted">
{icon}
{title}
</h3>
<div className="flex flex-col gap-1.5">{children}</div>
{note ? <p className="text-[11px] leading-4 text-muted">{note}</p> : null}
</section>
);
}
function StarterButton({
children,
onClick,
disabled = false,
}: {
children: React.ReactNode;
onClick: () => void;
disabled?: boolean;
}) {
return (
<button
type="button"
disabled={disabled}
onClick={onClick}
className={cn(
'group flex min-h-11 w-full items-center gap-2 rounded-lg border border-border bg-surface',
'px-3 py-2 text-left text-sm leading-5 transition-colors',
'hover:border-fg/20 hover:bg-surface-2 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-surface',
)}
>
<span className="min-w-0 flex-1">{children}</span>
<ArrowRight
aria-hidden
className="size-3.5 shrink-0 text-muted opacity-0 transition-opacity group-hover:opacity-100"
/>
</button>
);
}