/** * The bridge from PIG's zod-declared tools to Prime Agent's typebox ones. * * Two of these cases exist because the defect they pin is invisible to tsc and * survived a release each. * * The optional-parameter round trip is the first. `zodToJsonSchema(..., { * target: 'openAi' })` emits an optional field as required-and-nullable and * drops a `.describe()` attached to the optional wrapper, so a parameter that * reads as thoroughly documented in the source reaches the model with no * sentence at all and a demand that it be sent. Nothing about that typechecks. * * The snippet case is the second. A custom tool without `promptSnippet` is * registered, callable, and absent from the system prompt's tool list — so the * model never learns it exists, and the only symptom is Piggy declining to look * something up it is perfectly able to look up. */ import assert from 'node:assert/strict'; import test from 'node:test'; import type { ExtensionContext } from '@earendil-works/pi-coding-agent'; import type { Database } from '@pig/db'; import { z } from 'zod'; import { toPrimeTools } from '../src/agent/tool-bridge'; import { createInteractivePigTools } from '../src/chat-tools'; import { defineTool, type AgentTool } from '../src/provider'; /** The harness hands `execute` a context these tools never read. */ const ctx = {} as ExtensionContext; interface ParameterSchema { type: string; required?: string[]; properties?: Record; additionalProperties?: boolean; $schema?: string; } function schemaOf(tool: { parameters: unknown }): ParameterSchema { return tool.parameters as ParameterSchema; } function onlyTool(tool: AgentTool) { const [bridged] = toPrimeTools([tool]); assert.ok(bridged, 'the bridge returned no tool'); return bridged; } test('an optional parameter survives the bridge as optional, with its description', () => { const bridged = onlyTool( defineTool({ name: 'pig_probe', description: 'Probe the bridge. Never registered on a real session.', inputSchema: z .object({ needed: z.string().describe('The one required parameter.'), // Both spellings the existing tools use. `.nullish()` is what // `chat-tools.ts` and `page-tools.ts` write, to survive a model that // sends an explicit null; `.optional()` is the plain case. describedBeforeWrapper: z.number().int().describe('Horizon in days.').nullish(), describedAfterWrapper: z.string().optional().describe('A trailing note.'), }) .strict(), execute: async () => ({}), }), ); const schema = schemaOf(bridged); assert.deepEqual(schema.required, ['needed'], 'only the required parameter is required'); assert.equal( schema.properties?.describedBeforeWrapper?.description, 'Horizon in days.', 'a description applied before the optional wrapper reaches the model', ); assert.equal( schema.properties?.describedAfterWrapper?.description, 'A trailing note.', 'a description applied after the optional wrapper reaches the model too', ); assert.equal(schema.additionalProperties, false, 'a strict zod object stays closed'); // Meta about the document rather than about the parameters; the provider has // no use for it and it is paid for on every message. assert.equal(schema.$schema, undefined); }); test('every bridged tool carries a promptSnippet, or it is invisible to the model', () => { const bridged = toPrimeTools(createInteractivePigTools({} as Database, undefined)); assert.ok(bridged.length > 0); for (const tool of bridged) { assert.ok(tool.promptSnippet, `${tool.name} has no promptSnippet`); assert.ok(!tool.promptSnippet.includes('\n'), `${tool.name} snippet is not one line`); assert.ok(tool.label, `${tool.name} has no label`); assert.ok( tool.promptSnippet.length < tool.description.length, `${tool.name} snippet should be terser than its description`, ); } }); test('the boundary assertion is a second gate behind noTools', () => { const outsiders = ['bash_run', 'pig_bash', 'run_shell', 'read_file']; for (const name of outsiders) { assert.throws( () => toPrimeTools([ defineTool({ name, description: 'Should never reach the harness.', inputSchema: z.object({}).strict(), execute: async () => ({}), }), ]), /outside the PIG tool boundary/, `${name} was allowed through`, ); } }); test('a bridged tool returns the payload it returns today, byte for byte', async () => { const payload = { headline: 'Two commitments are idle.', idleHours: 1_200, cheapest: null }; const bridged = onlyTool( defineTool({ name: 'pig_probe_payload', description: 'Return a fixed payload.', inputSchema: z.object({ withinDays: z.number().int().nullish() }).strict(), execute: async () => payload, }), ); const result = await bridged.execute('call-1', { withinDays: null }, undefined, undefined, ctx); const [content] = result.content; assert.equal(content?.type, 'text'); assert.equal( content?.type === 'text' ? content.text : '', JSON.stringify(payload), 'the model sees the tool payload unchanged', ); assert.deepEqual( result.details, { tool: 'pig_probe_payload', result: payload }, 'the structured payload rides on details for the chat server', ); }); test('the zod schema, not the typebox one, is what actually guards execute', async () => { let executed = 0; const bridged = onlyTool( defineTool({ name: 'pig_probe_gate', description: 'Count executions.', inputSchema: z.object({ query: z.string().min(2).max(8) }).strict(), execute: async () => { executed += 1; return {}; }, }), ); // The harness forwards tool arguments untouched — it never checks them // against `parameters` — so anything the zod parse does not stop reaches a // query. Each of these is something a model has actually sent. for (const bad of [{ query: 'x' }, { query: 'x'.repeat(50) }, { query: 'ok', extra: 1 }, {}]) { await assert.rejects(() => bridged.execute('call', bad, undefined, undefined, ctx)); } assert.equal(executed, 0, 'no invalid call reached the tool body'); await bridged.execute('call', { query: 'Halcyon' }, undefined, undefined, ctx); assert.equal(executed, 1); });