f0173440e4
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>
254 lines
9.7 KiB
TypeScript
254 lines
9.7 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { mkdtempSync, rmSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import test, { after, before } from 'node:test';
|
|
import { defineTool, type ToolDefinition } from '@earendil-works/pi-coding-agent';
|
|
import { Type } from 'typebox';
|
|
|
|
const agentDir = mkdtempSync(join(tmpdir(), 'piggy-agent-test-'));
|
|
|
|
before(() => {
|
|
// The runtime reads its configuration from the environment, so the test has
|
|
// to supply one. The key is deliberately fake: nothing below reaches the
|
|
// endpoint, and a test that needs a live key is a test that fails in CI.
|
|
process.env.DATABASE_URL = 'postgres://pig:pig@localhost:54330/pig';
|
|
process.env.PIGGY_INTERNAL_TOKEN = 'test-internal-token-for-piggy-000000';
|
|
process.env.PRIME_API_KEY = 'test-key-not-used-offline';
|
|
process.env.PIGGY_AGENT_DIR = agentDir;
|
|
});
|
|
|
|
after(() => {
|
|
rmSync(agentDir, { recursive: true, force: true });
|
|
});
|
|
|
|
function fakePigTool(name: string): ToolDefinition {
|
|
return defineTool({
|
|
name,
|
|
label: name,
|
|
description: `Test double for ${name}.`,
|
|
promptSnippet: `${name}: test double.`,
|
|
parameters: Type.Object({}),
|
|
async execute() {
|
|
return { content: [{ type: 'text' as const, text: '{}' }], details: {} };
|
|
},
|
|
});
|
|
}
|
|
|
|
test('the session exposes exactly the tools it was handed, and nothing else', async () => {
|
|
const { createPiggySession } = await import('../src/agent/session');
|
|
const tools = [fakePigTool('pig_get_workspace_summary'), fakePigTool('pig_log_activity')];
|
|
const piggy = await createPiggySession({ mode: 'confirm', tools });
|
|
|
|
try {
|
|
const live = piggy.session.agent.state.tools.map((tool) => tool.name).sort();
|
|
|
|
// This is the security property of the whole harness swap, pinned rather
|
|
// than assumed. `noTools: 'all'` plus an explicit allowlist should make it
|
|
// impossible for a built-in to survive; if a future SDK changes the
|
|
// precedence between its tool sources, this is what notices.
|
|
assert.deepEqual(live, ['pig_get_workspace_summary', 'pig_log_activity']);
|
|
for (const forbidden of ['bash', 'ipython', 'python', 'read', 'write', 'edit', 'ls', 'grep', 'find']) {
|
|
assert.equal(live.includes(forbidden), false, `${forbidden} leaked into the tool set`);
|
|
}
|
|
} finally {
|
|
piggy.dispose();
|
|
}
|
|
});
|
|
|
|
test('a tool outside the PIG boundary never reaches the harness', async () => {
|
|
const { createPiggySession } = await import('../src/agent/session');
|
|
|
|
await assert.rejects(
|
|
() => createPiggySession({ mode: 'auto', tools: [fakePigTool('bash')] }),
|
|
/outside the PIG tool boundary/,
|
|
);
|
|
await assert.rejects(
|
|
() => createPiggySession({ mode: 'auto', tools: [fakePigTool('pig_run_shell')] }),
|
|
/outside the PIG tool boundary/,
|
|
);
|
|
await assert.rejects(
|
|
() => createPiggySession({ mode: 'auto', tools: [fakePigTool('summarise')] }),
|
|
/outside the PIG tool boundary/,
|
|
);
|
|
});
|
|
|
|
test('a tool that reads like a shell is refused however it is spelt', async () => {
|
|
const { createPiggySession } = await import('../src/agent/session');
|
|
|
|
// The prefix is a convention and a convention alone is not a boundary: the
|
|
// interesting attack is not a tool called `bash`, it is a tool called
|
|
// `pig_bash` added by somebody who read the rule as "start it with pig_".
|
|
for (const name of [
|
|
'pig_bash',
|
|
'pig_bash_run',
|
|
'pig_BASH',
|
|
'pig_shell_exec',
|
|
'pig_filesystem_list',
|
|
'pig_file_read',
|
|
'pig_file_write',
|
|
// Not `pig_` at all, which is the ordinary case: an agent tool from
|
|
// somewhere else in the repo wired in by mistake.
|
|
'PIG_get_margin_summary',
|
|
'get_margin_summary',
|
|
]) {
|
|
await assert.rejects(
|
|
() => createPiggySession({ mode: 'auto', tools: [fakePigTool(name)] }),
|
|
/outside the PIG tool boundary/,
|
|
`${name} was allowed through`,
|
|
);
|
|
}
|
|
});
|
|
|
|
test('two tools of the same name are refused rather than silently shadowed', async () => {
|
|
const { createPiggySession } = await import('../src/agent/session');
|
|
|
|
await assert.rejects(
|
|
() =>
|
|
createPiggySession({
|
|
mode: 'confirm',
|
|
tools: [fakePigTool('pig_log_activity'), fakePigTool('pig_log_activity')],
|
|
}),
|
|
/two tools named 'pig_log_activity'/,
|
|
);
|
|
|
|
// The realistic version: the same name arriving from the read set and the
|
|
// write set, with different descriptions and different bodies. Registered
|
|
// together, one silently shadows the other inside the harness — which is how
|
|
// a read tool ends up answering for a write tool of the same name — so the
|
|
// check is on the name alone and cannot be talked out of it by a tool that
|
|
// looks different in every other respect.
|
|
const readShaped = fakePigTool('pig_log_activity');
|
|
const writeShaped: ToolDefinition = {
|
|
...fakePigTool('pig_log_activity'),
|
|
description: 'A different tool that happens to share a name.',
|
|
};
|
|
await assert.rejects(
|
|
() => createPiggySession({ mode: 'confirm', tools: [readShaped, writeShaped] }),
|
|
/two tools named 'pig_log_activity'/,
|
|
);
|
|
});
|
|
|
|
test('a tool added after the session exists never becomes callable', async () => {
|
|
const { createPiggySession } = await import('../src/agent/session');
|
|
// Deliberately mutable, and deliberately the same array the caller keeps.
|
|
const tools: ToolDefinition[] = [fakePigTool('pig_get_workspace_summary')];
|
|
const piggy = await createPiggySession({ mode: 'confirm', tools });
|
|
|
|
try {
|
|
// The allowlist is decided once, at construction: `createPiggySession`
|
|
// copies the array into `customTools` and names it in `tools`. A caller who
|
|
// keeps a reference and pushes onto it later — a tool assembled per turn, a
|
|
// list built up as pages are visited — must not be able to widen a session
|
|
// that has already been checked.
|
|
tools.push(fakePigTool('pig_delete_everything'));
|
|
tools.push(fakePigTool('bash'));
|
|
|
|
const live = piggy.session.agent.state.tools.map((tool) => tool.name);
|
|
assert.deepEqual(live, ['pig_get_workspace_summary']);
|
|
} finally {
|
|
piggy.dispose();
|
|
}
|
|
});
|
|
|
|
test('the system prompt is Piggy, not the harness coding assistant', async () => {
|
|
const { createPiggySession } = await import('../src/agent/session');
|
|
const piggy = await createPiggySession({
|
|
mode: 'confirm',
|
|
tools: [fakePigTool('pig_get_workspace_summary')],
|
|
});
|
|
|
|
try {
|
|
// Without `await loader.reload()` the harness serves its stock preamble —
|
|
// "an expert coding assistant operating inside pi" — with no warning of any
|
|
// kind. The absence of that phrase is the only externally visible sign the
|
|
// reload happened.
|
|
assert.match(piggy.systemPrompt, /^You are Piggy/);
|
|
assert.equal(/coding assistant/i.test(piggy.session.systemPrompt), false);
|
|
assert.match(piggy.session.systemPrompt, /You are Piggy/);
|
|
// The tool has to appear in the live prompt, or a 30B model never calls
|
|
// it. The harness will not do this for us: `buildSystemPrompt` emits its
|
|
// own "Available tools" section only when no customPrompt is supplied, and
|
|
// replacing the coding preamble is not optional here — so the snippet is
|
|
// rendered by prompt.ts or it is dropped in silence.
|
|
assert.match(piggy.session.systemPrompt, /- pig_get_workspace_summary: test double\./);
|
|
} finally {
|
|
piggy.dispose();
|
|
}
|
|
});
|
|
|
|
test('the mode is in the prompt, because the tool list alone does not say it', async () => {
|
|
const { createPiggySession } = await import('../src/agent/session');
|
|
const tools = [fakePigTool('pig_log_activity')];
|
|
|
|
const confirm = await createPiggySession({ mode: 'confirm', tools });
|
|
const auto = await createPiggySession({ mode: 'auto', tools });
|
|
const readOnly = await createPiggySession({ mode: 'read_only', tools });
|
|
|
|
try {
|
|
assert.match(confirm.systemPrompt, /PROPOSES a change/);
|
|
assert.match(auto.systemPrompt, /take effect immediately/);
|
|
assert.match(readOnly.systemPrompt, /read-only mode/);
|
|
// The measured failure: nemotron rendering breakEvenPriceCents: 112 as
|
|
// "112 cents". Every mode carries the correction.
|
|
for (const prompt of [confirm.systemPrompt, auto.systemPrompt, readOnly.systemPrompt]) {
|
|
assert.match(prompt, /breakEvenPriceCents: 112 is \$1\.12/);
|
|
assert.match(prompt, /Never write a money figure in cents/);
|
|
}
|
|
} finally {
|
|
confirm.dispose();
|
|
auto.dispose();
|
|
readOnly.dispose();
|
|
}
|
|
});
|
|
|
|
test('history is replayed so a second turn knows what the first one said', async () => {
|
|
const { createPiggySession } = await import('../src/agent/session');
|
|
const piggy = await createPiggySession({
|
|
mode: 'read_only',
|
|
tools: [fakePigTool('pig_get_workspace_summary')],
|
|
history: [
|
|
{ role: 'user', content: 'What is utilisation on Northwind?' },
|
|
{ role: 'assistant', content: 'Northwind is at 38 per cent.' },
|
|
],
|
|
});
|
|
|
|
try {
|
|
const messages = piggy.session.agent.state.messages;
|
|
assert.equal(messages.length, 2);
|
|
assert.equal(messages[0]?.role, 'user');
|
|
assert.equal(messages[1]?.role, 'assistant');
|
|
} finally {
|
|
piggy.dispose();
|
|
}
|
|
});
|
|
|
|
test('a model outside the catalogue is refused before a request is made', async () => {
|
|
const { createPiggySession } = await import('../src/agent/session');
|
|
|
|
await assert.rejects(
|
|
() =>
|
|
createPiggySession({
|
|
mode: 'read_only',
|
|
modelId: 'openai/gpt-4o',
|
|
tools: [fakePigTool('pig_get_workspace_summary')],
|
|
}),
|
|
/not in the Piggy catalogue/,
|
|
);
|
|
});
|
|
|
|
test('the default model is the configured one', async () => {
|
|
const { createPiggySession } = await import('../src/agent/session');
|
|
const { piggyDefaultModelId } = await import('../src/agent/models');
|
|
const piggy = await createPiggySession({
|
|
mode: 'read_only',
|
|
tools: [fakePigTool('pig_get_workspace_summary')],
|
|
});
|
|
|
|
try {
|
|
assert.equal(piggy.modelId, piggyDefaultModelId());
|
|
} finally {
|
|
piggy.dispose();
|
|
}
|
|
});
|