Files
pig/apps/piggy/test/write-tools.test.ts
claude f0173440e4
CI / verify (push) Successful in 7m6s
CI / publish (push) Has been skipped
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>
2026-08-14 05:26:28 -07:00

499 lines
17 KiB
TypeScript

/**
* The write tools, up to but not through the transaction.
*
* What these cases pin is the promise the approval flow makes: that a change
* the user has not agreed to leaves the database exactly as it was. So the
* database here is a fake whose only real job is to COUNT how many transactions
* were opened, because "nothing was written" is not a claim about a row — it is
* a claim that no write was ever attempted, and a row check would pass just as
* happily against a write that failed for some other reason.
*
* `e2e/write-tools.test.ts` takes the applied path through a real Postgres and
* reads the audit row back. This file deliberately never reaches one: the unit
* suite runs in CI before the migration step, against a database with no
* tables.
*/
import assert from 'node:assert/strict';
import test from 'node:test';
import type { AgentToolResult, ExtensionContext } from '@earendil-works/pi-coding-agent';
import {
PIGGY_ALWAYS_CONFIRM_KINDS,
isGuardedKind,
requiresApproval,
type PiggyApprovalDecision,
type PiggyProposedChange,
} from '@pig/core';
import type { Principal } from '@pig/api/src/lib/auth';
import type { Database } from '@pig/db';
import { getTableName, type Table } from 'drizzle-orm';
import { createPigWriteTools, type PigWriteDetails } from '../src/write-tools';
const ctx = {} as ExtensionContext;
const ACCOUNT_ID = '11111111-1111-4111-8111-111111111111';
const DEAL_ID = '22222222-2222-4222-8222-222222222222';
/** A member of both pipelines: the ordinary GTM user, not an admin. */
function seller(overrides: Partial<Principal> = {}): Principal {
return {
userId: '33333333-3333-4333-8333-333333333333',
email: 'dana@primeintellect.ai',
name: 'Dana Okonjo',
isPlatformAdmin: false,
teams: [
{ team: 'demand', role: 'member' },
{ team: 'supply', role: 'member' },
],
via: 'jwt',
scopes: ['read', 'write'],
...overrides,
};
}
interface FakeDatabase {
db: Database;
/** Transactions opened. `executeMutation` opens exactly one per write. */
transactions: number;
}
/**
* Reads answer from a fixed table of rows; writes are counted and refused.
*
* The refusal matters as much as the count: a test that let a write "succeed"
* against a fake would be asserting on the fake. Anything that gets as far as
* opening a transaction here fails loudly.
*/
function fakeDatabase(rows: Record<string, Record<string, unknown>[]>): FakeDatabase {
const state: FakeDatabase = { transactions: 0, db: undefined as unknown as Database };
const selection = (table: Table) => ({
where: () => ({
limit: async () => rows[getTableName(table)] ?? [],
}),
});
// The shape drizzle exposes is far wider than the four calls these tools
// make, so the cast is to the handle rather than to `any` at each call site.
state.db = {
select: () => ({ from: (table: Table) => selection(table) }),
transaction: async () => {
state.transactions += 1;
throw new Error('the fake database refuses to write');
},
} as unknown as Database;
return state;
}
function tool(tools: ReturnType<typeof createPigWriteTools>, name: string) {
const found = tools.find((candidate) => candidate.name === name);
assert.ok(found, `${name} is not among ${tools.map((t) => t.name).join(', ')}`);
return found;
}
function detailsOf(result: { details: unknown }): PigWriteDetails {
return result.details as PigWriteDetails;
}
function textOf(result: AgentToolResult<unknown>): string {
const [first] = result.content;
return first?.type === 'text' ? first.text : '';
}
test('read_only mode offers no write tool at all', () => {
const { db } = fakeDatabase({});
const tools = createPigWriteTools({
db,
principal: seller(),
mode: 'read_only',
propose: async () => 'apply',
});
assert.deepEqual(tools, [], 'a read-only session must not be told writes are possible');
});
test('the write surface is exactly five pig_ tools, each teachable to the model', () => {
const { db } = fakeDatabase({});
const tools = createPigWriteTools({
db,
principal: seller(),
mode: 'confirm',
propose: async () => 'apply',
});
assert.deepEqual(
tools.map((candidate) => candidate.name).sort(),
[
'pig_create_contact',
'pig_create_task',
'pig_log_activity',
'pig_update_deal_stage',
'pig_update_record_fields',
],
'the write surface is closed, and grows only by decision',
);
for (const candidate of tools) {
// Without a snippet the tool is absent from the system prompt's tool list.
assert.ok(candidate.promptSnippet, `${candidate.name} has no promptSnippet`);
assert.ok(candidate.promptGuidelines?.length, `${candidate.name} teaches the model nothing`);
}
});
test('a confirm-mode write proposes first and touches nothing until it is answered', async () => {
const state = fakeDatabase({
accounts: [{ name: 'Northwind Robotics' }],
});
const proposed: Omit<PiggyProposedChange, 'id'>[] = [];
let released: ((decision: PiggyApprovalDecision) => void) | undefined;
const tools = createPigWriteTools({
db: state.db,
principal: seller(),
mode: 'confirm',
propose: async (change) => {
proposed.push(change);
// Held open, so the assertions below run at the exact moment a user is
// still looking at the card: the point at which nothing may have been
// written yet.
return new Promise<PiggyApprovalDecision>((resolve) => {
released = resolve;
});
},
});
const running = tool(tools, 'pig_log_activity').execute(
'call-1',
{
type: 'call',
subject: 'Pricing call with procurement',
body: 'They want H200 pricing before the board meets.',
accountId: ACCOUNT_ID,
},
undefined,
undefined,
ctx,
);
// Let the proposal be raised, then look at the world before answering.
await new Promise((resolve) => setImmediate(resolve));
assert.equal(proposed.length, 1, 'the change was proposed');
assert.equal(state.transactions, 0, 'no transaction was opened while the user was deciding');
const [change] = proposed;
assert.ok(change);
assert.equal(change.tool, 'pig_log_activity');
assert.equal(change.kind, 'activity');
assert.equal(change.summary, 'Log a call on Northwind Robotics');
assert.equal(change.record?.label, 'Northwind Robotics', 'the card names the record, not a uuid');
assert.deepEqual(
change.fields.map((field) => field.label),
['Type', 'Subject', 'Note'],
'the card shows the change field by field',
);
assert.ok(released, 'propose was never called');
released('reject');
const result = await running;
assert.equal(state.transactions, 0, 'a rejected change never reaches the database');
assert.equal(detailsOf(result).status, 'declined');
assert.match(
textOf(result),
/NOT SAVED/,
'the model is told plainly that nothing was written',
);
assert.match(textOf(result), /declined/i);
});
test('a stage change shows the value it is replacing, because a diff needs both', async () => {
const state = fakeDatabase({
demand_deals: [{ name: 'Northwind — H200 reserved', stage: 'proposal' }],
});
const proposed: Omit<PiggyProposedChange, 'id'>[] = [];
const tools = createPigWriteTools({
db: state.db,
principal: seller(),
mode: 'confirm',
propose: async (change) => {
proposed.push(change);
return 'reject';
},
});
await tool(tools, 'pig_update_deal_stage').execute(
'call-2',
{
dealType: 'demand',
dealId: DEAL_ID,
stage: 'procurement',
reason: 'Legal cleared the MSA this morning.',
},
undefined,
undefined,
ctx,
);
const [change] = proposed;
assert.ok(change);
assert.deepEqual(change.fields[0], {
label: 'Stage',
value: 'Procurement',
previous: 'Proposal',
});
assert.equal(state.transactions, 0);
});
test('auto mode writes without asking, because none of these kinds is guarded', async () => {
const state = fakeDatabase({ accounts: [{ name: 'Northwind Robotics' }] });
let asked = 0;
const tools = createPigWriteTools({
db: state.db,
principal: seller(),
mode: 'auto',
propose: async () => {
asked += 1;
return 'apply';
},
});
// The fake refuses every write, which is the point: what is asserted is that
// the tool got as far as opening a transaction with nobody asked.
await assert.rejects(
() =>
tool(tools, 'pig_log_activity').execute(
'call-3',
{ type: 'note', subject: 'Left a voicemail', accountId: ACCOUNT_ID },
undefined,
undefined,
ctx,
),
/refuses to write/,
);
assert.equal(asked, 0, 'auto mode does not ask for an ordinary activity');
assert.equal(state.transactions, 1, 'auto mode goes straight to the write');
});
test('a capability failure is reported to the model, not thrown into the stream', async () => {
const state = fakeDatabase({ accounts: [{ name: 'Northwind Robotics' }] });
const tools = createPigWriteTools({
db: state.db,
// A read-only credential in a session the user put into auto mode. The
// permission is the user's own, so this is an answer, not a fault.
principal: seller({ scopes: ['read'] }),
mode: 'auto',
propose: async () => 'apply',
});
const result = await tool(tools, 'pig_log_activity').execute(
'call-4',
{ type: 'note', subject: 'Left a voicemail', accountId: ACCOUNT_ID },
undefined,
undefined,
ctx,
);
assert.equal(state.transactions, 0, 'permission is checked before any transaction opens');
assert.equal(detailsOf(result).status, 'refused');
assert.equal(detailsOf(result).reason, 'insufficient_scope');
assert.match(textOf(result), /NOT SAVED/);
assert.match(textOf(result), /permission/i);
});
test('a capability the user lacks on this team is an answer, not a crash', async () => {
const state = fakeDatabase({
demand_deals: [{ name: 'Northwind — H200 reserved', stage: 'proposal' }],
});
const tools = createPigWriteTools({
db: state.db,
// Supply-side only. `updateDemandDealMutationDefinition` requires
// `deal:write` on `demand`, so this is the everyday case of a person being
// asked to move somebody else's deal — not a misconfiguration.
principal: seller({ teams: [{ team: 'supply', role: 'member' }] }),
mode: 'auto',
propose: async () => 'apply',
});
const result = await tool(tools, 'pig_update_deal_stage').execute(
'call-8',
{
dealType: 'demand',
dealId: DEAL_ID,
stage: 'procurement',
reason: 'They asked me to move it.',
},
undefined,
undefined,
ctx,
);
assert.equal(state.transactions, 0, 'permission is checked before any transaction opens');
assert.equal(detailsOf(result).status, 'refused');
assert.equal(detailsOf(result).reason, 'insufficient_permission');
// Thrown, this would end the turn on the user's own permissions, which reads
// to them as Piggy being broken rather than as PIG saying no.
assert.match(textOf(result), /NOT SAVED/);
assert.match(textOf(result), /deal:write/);
assert.match(textOf(result), /do not retry it/);
});
test('every kind the write surface proposes is one auto mode may apply', async () => {
const state = fakeDatabase({
accounts: [{ name: 'Northwind Robotics' }],
demand_deals: [{ name: 'Northwind — H200 reserved', stage: 'proposal' }],
});
const kinds = new Map<string, string>();
const tools = createPigWriteTools({
db: state.db,
principal: seller(),
mode: 'confirm',
propose: async (change) => {
kinds.set(change.tool, change.kind);
return 'reject';
},
});
// One call per tool, in confirm mode, so each one has to raise a card and
// name the kind it belongs to.
const calls: [string, Record<string, unknown>][] = [
['pig_log_activity', { type: 'note', subject: 'Left a voicemail', accountId: ACCOUNT_ID }],
[
'pig_create_contact',
{ accountId: ACCOUNT_ID, fullName: 'Marta Reyes', role: 'staff', title: 'VP Infrastructure' },
],
[
'pig_update_deal_stage',
{ dealType: 'demand', dealId: DEAL_ID, stage: 'procurement', reason: 'Legal cleared it.' },
],
[
'pig_update_record_fields',
{ recordType: 'account', recordId: ACCOUNT_ID, reason: 'Corrected on the call.', country: 'Germany' },
],
['pig_create_task', { title: 'Send the H200 quote', startsAt: '2026-09-01', accountId: ACCOUNT_ID }],
];
for (const [name, params] of calls) {
await tool(tools, name).execute('call-kind', params, undefined, undefined, ctx);
}
assert.deepEqual(
Object.fromEntries([...kinds].sort()),
{
pig_create_contact: 'contact',
pig_create_task: 'task',
pig_log_activity: 'activity',
pig_update_deal_stage: 'deal',
pig_update_record_fields: 'record',
},
'every write tool proposes a kind, and the kind is what the policy is read against',
);
assert.equal(state.transactions, 0, 'the whole sweep was declined, so nothing was written');
// `requiresApproval` is the single source of truth for the policy, so the
// claim "auto mode writes these without asking" is checked against it rather
// than restated here. A kind added to `PIGGY_ALWAYS_CONFIRM_KINDS` that a
// tool already uses would flip one of these and fail loudly.
for (const kind of kinds.values()) {
assert.equal(isGuardedKind(kind), false, `${kind} is a guarded kind`);
assert.equal(requiresApproval('auto', kind), false);
assert.equal(requiresApproval('confirm', kind), true);
assert.equal(requiresApproval('read_only', kind), true);
}
});
test('contracts, commitments, allocations and compliance stop even in auto mode', () => {
// No tool in `write-tools.ts` creates one of these today, and that is the
// point: the policy is stated once, in the protocol, so a tool added later
// inherits it rather than having to remember it. This is the assertion that
// makes `requiresApproval` the single source of truth rather than a comment.
assert.deepEqual(
[...PIGGY_ALWAYS_CONFIRM_KINDS],
['contract', 'commitment', 'allocation', 'compliance'],
);
for (const kind of PIGGY_ALWAYS_CONFIRM_KINDS) {
assert.equal(isGuardedKind(kind), true);
assert.equal(requiresApproval('auto', kind), true, `${kind} slipped through auto mode`);
assert.equal(requiresApproval('confirm', kind), true);
assert.equal(requiresApproval('read_only', kind), true);
}
// And an unguarded kind is only free in auto mode, never in the other two.
assert.equal(requiresApproval('auto', 'activity'), false);
assert.equal(requiresApproval('confirm', 'activity'), true);
});
test('an activity with nothing to attach to is refused before it is proposed', async () => {
const state = fakeDatabase({});
let asked = 0;
const tools = createPigWriteTools({
db: state.db,
principal: seller(),
mode: 'confirm',
propose: async () => {
asked += 1;
return 'apply';
},
});
const result = await tool(tools, 'pig_log_activity').execute(
'call-5',
{ type: 'note', subject: 'Nobody in particular' },
undefined,
undefined,
ctx,
);
assert.equal(asked, 0, 'the user is not asked to approve a change that cannot be made');
assert.equal(state.transactions, 0);
assert.equal(detailsOf(result).status, 'refused');
assert.equal(detailsOf(result).reason, 'no_target');
});
test('a field that does not belong to the record type is named, not silently dropped', async () => {
const state = fakeDatabase({ accounts: [{ name: 'Northwind Robotics' }] });
const tools = createPigWriteTools({
db: state.db,
principal: seller(),
mode: 'confirm',
propose: async () => 'apply',
});
const result = await tool(tools, 'pig_update_record_fields').execute(
'call-6',
{
recordType: 'account',
recordId: ACCOUNT_ID,
reason: 'Correcting after the call.',
probability: 0.4,
},
undefined,
undefined,
ctx,
);
assert.equal(state.transactions, 0);
assert.equal(detailsOf(result).reason, 'field_not_applicable');
assert.match(textOf(result), /probability/);
});
test('an unanswered proposal expires as a rejection rather than holding the turn open', async () => {
const state = fakeDatabase({ accounts: [{ name: 'Northwind Robotics' }] });
const tools = createPigWriteTools({
db: state.db,
principal: seller(),
mode: 'confirm',
// The user closed the tab. Nothing will ever resolve this.
propose: () => new Promise<PiggyApprovalDecision>(() => {}),
});
const abort = new AbortController();
const running = tool(tools, 'pig_log_activity').execute(
'call-7',
{ type: 'note', subject: 'Left a voicemail', accountId: ACCOUNT_ID },
abort.signal,
undefined,
ctx,
);
// The five-minute deadline is the backstop; an aborted turn must settle at
// once rather than waiting it out, because the connection is billed either
// way and nobody is reading the answer.
abort.abort();
const result = await running;
assert.equal(state.transactions, 0);
assert.equal(detailsOf(result).status, 'declined');
});