Make every tool result say what it counted
CI / verify (push) Successful in 7m44s
CI / publish (push) Has been skipped

Asked how many capacity commitments were on the book, Piggy answered "3".
Production holds 5. It had called the idle-capacity tool, which filters to
blocks above an idle threshold, and read the length of that list as the size
of the book.

The system prompt already forbade this in terms — "never report a filtered
count as a total; pig_get_idle_capacity returns the blocks with idle hours,
not the book" — and the model did it anyway. That is the second time this
argument has been lost in the prompt, so it is settled in the payload
instead: a result that cannot describe its own scope will be misread
eventually, however firmly the prompt objects.

Every tool that returns a count or a collection now carries one shape:
what it covers, how many matched, out of how many, under which filters, and
whether the list was truncated. The denominators are read from the database
rather than inferred. The pre-formatted headline states the scope too, since
that is the sentence a small model quotes most readily — the idle tool now
opens "3 of 5 live capacity commitments on the book", which is the sentence
that makes the original mistake impossible to phrase.

Two details worth keeping. Record reads enumerate rather than filter, so
their scope states a boundary instead of a ratio: these are that record's own
figures, never book-wide totals. And the workspace summary's idle threshold
is deliberately recorded as 0, distinct from the idle tool's 0.25 — that
mismatch is why three different idle figures appeared across the UI, and
naming it in the data is how it stops being invisible.

Verified against the live model: the failing question now answers 5, demand
deals 13 and contracts 20 — each drawn from a payload whose filtered figure
was smaller — while "which blocks are sitting idle" still names exactly the
blocks that are.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
claude
2026-08-14 19:03:40 -07:00
parent 18d5f5bfc0
commit f2ef403ee9
6 changed files with 1117 additions and 113 deletions
+26 -5
View File
@@ -9,12 +9,16 @@ import {
demandDeals,
type Database,
} from '@pig/db';
import { and, desc, eq, inArray } from 'drizzle-orm';
import { and, count, desc, eq, inArray } from 'drizzle-orm';
import { z } from 'zod';
import { resultScope } from './page-tools';
import { defineTool, type AgentTool } from './provider';
const noInput = z.object({}).strict();
/** The per-collection cap here, matching `RELATED_LIMIT` in chat-tools. */
const RELATED_LIMIT = 100;
export function createAccountLifecycleTool(db: Database, accountId: string): AgentTool {
return defineTool({
name: 'pig_get_account_lifecycle',
@@ -23,11 +27,17 @@ export function createAccountLifecycleTool(db: Database, accountId: string): Age
execute: async () => {
const [account] = await db.select().from(accounts).where(eq(accounts.id, accountId)).limit(1);
if (!account) throw new Error('The account in focus no longer exists.');
const [deals, paperwork, recentActivity] = await Promise.all([
db.select().from(demandDeals).where(eq(demandDeals.accountId, accountId)).limit(100),
db.select().from(contracts).where(and(eq(contracts.accountId, accountId), eq(contracts.side, 'demand'))).limit(100),
const [deals, paperwork, recentActivity, dealsOnBook] = await Promise.all([
db.select().from(demandDeals).where(eq(demandDeals.accountId, accountId)).limit(RELATED_LIMIT),
db.select().from(contracts).where(and(eq(contracts.accountId, accountId), eq(contracts.side, 'demand'))).limit(RELATED_LIMIT),
db.select().from(activities).where(eq(activities.accountId, accountId)).orderBy(desc(activities.occurredAt)).limit(1),
// The denominator. This result is one account's slice of the book and
// every count in it is an account count; without the book's own figure
// beside them, "4 demand deals" is the only deal number in the payload
// and becomes the answer to a question about the whole book.
db.select({ value: count() }).from(demandDeals),
]);
const demandDealsOnBook = dealsOnBook[0]?.value ?? 0;
const dealIds = deals.map((deal) => deal.id);
const contractIds = paperwork.map((contract) => contract.id);
const [requests, reservations, obligations] = await Promise.all([
@@ -36,7 +46,18 @@ export function createAccountLifecycleTool(db: Database, accountId: string): Age
contractIds.length ? db.select().from(contractObligations).where(inArray(contractObligations.contractId, contractIds)) : [],
]);
return {
scope: resultScope({
covers: `belong to the account ${account.name}`,
matched: deals.length,
total: demandDealsOnBook,
totalLabel: 'demand deal(s) on the book',
listed: 0,
filters: { accountId, side: 'demand', rowCapPerCollection: RELATED_LIMIT },
truncated: deals.length >= RELATED_LIMIT || paperwork.length >= RELATED_LIMIT,
}),
account: { id: account.id, name: account.name },
demandDealsForThisAccount: deals.length,
demandDealsOnBook,
lifecycle: evaluateCustomerLifecycle({
accountId,
deals: deals.map((deal) => ({ ...deal })),
@@ -47,7 +68,7 @@ export function createAccountLifecycleTool(db: Database, accountId: string): Age
lastActivityAt: recentActivity[0]?.occurredAt ?? account.lastActivityAt,
lastActivityId: recentActivity[0]?.id,
}),
interpretation: 'Scores rank review attention. Capacity totals mean sold or reserved capacity, not customer workload utilization.',
interpretation: 'Scores rank review attention. Capacity totals mean sold or reserved capacity, not customer workload utilisation. Every figure here covers this one account, never the book.',
};
},
});