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,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user