From 61274c5f366a5a6103790d8c5f6e1d783d689aa5 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Tue, 13 Oct 2026 10:28:24 +0200 Subject: [PATCH 1/2] fix(server-utils): Read pi-durable 1.2.0 tool results from `output` pi-durable 1.2.0 renamed the tool result field `content` to `output`. A tool call outside a tool task phase, such as a tool that calls another tool, now records the result from `output` and falls back to `content` for older versions. The integration scenarios return `output` and install `^1.2.0`. The e2e apps run on the pinned 1.0.0 and on the latest version, so their tools set both fields. Co-Authored-By: Claude Opus 5.5 --- .../cloudflare-pi-durable/src/assistant.ts | 17 +++++++++++++---- .../node-pi-durable/src/server.mjs | 19 +++++++++++++------ .../pi-durable/scenario-interrupted.mjs | 2 +- .../tracing/pi-durable/scenario-retention.mjs | 2 +- .../tracing/pi-durable/scenario-subagent.mjs | 8 ++++---- .../tracing/pi-durable/scenario-tools.mjs | 6 ++---- .../suites/tracing/pi-durable/scenario.mjs | 2 +- .../suites/tracing/pi-durable/test.ts | 6 +++--- .../server-utils/src/ai/pi-durable/tools.ts | 2 +- .../server-utils/src/ai/pi-durable/types.ts | 3 +++ .../test/ai/lib/tracing/pi-durable.test.ts | 11 +++++++++++ 11 files changed, 53 insertions(+), 25 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-pi-durable/src/assistant.ts b/dev-packages/e2e-tests/test-applications/cloudflare-pi-durable/src/assistant.ts index bd0d25a8c5b3..adf0b0678283 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-pi-durable/src/assistant.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-pi-durable/src/assistant.ts @@ -6,6 +6,15 @@ import * as Sentry from '@sentry/cloudflare'; import { Agent } from 'agents'; import { PiHarness } from 'agents/harness/pi'; +type TextBlock = { type: 'text'; text: string }; + +// pi-durable 1.2.0 renamed the tool result field `content` to `output`. The app runs on 1.0.0 and on the +// latest version, so its tool results set both. +function textResult(text: string): { output: TextBlock[]; content: TextBlock[] } { + const blocks: TextBlock[] = [{ type: 'text', text }]; + return { output: blocks, content: blocks }; +} + /** * pi-durable hosted by the Agents SDK `PiHarness`: pi keeps its state in this object's SQLite * database, and the harness reopens pi when the object restarts. Nothing wires Sentry into @@ -46,9 +55,9 @@ export class Assistant extends Agent { parameters: Type.Object({ city: Type.String() }), // The manual span should nest under the SDK's `execute_tool` span. execute: async args => - Sentry.startSpan({ name: 'resolve-weather', attributes: { 'weather.city': args.city } }, () => ({ - content: [{ type: 'text', text: `It is 21 degrees and sunny in ${args.city}.` }], - })), + Sentry.startSpan({ name: 'resolve-weather', attributes: { 'weather.city': args.city } }, () => + textResult(`It is 21 degrees and sunny in ${args.city}.`), + ), }), defineTool({ name: 'fail_now', @@ -67,7 +76,7 @@ export class Assistant extends Agent { execute: async args => { const marker = `crashed:${args.job}`; if (await this.ctx.storage.get(marker)) { - return { content: [{ type: 'text', text: `Step of job ${args.job} completed.` }] }; + return textResult(`Step of job ${args.job} completed.`); } await this.ctx.storage.put(marker, true); await this.ctx.storage.sync(); diff --git a/dev-packages/e2e-tests/test-applications/node-pi-durable/src/server.mjs b/dev-packages/e2e-tests/test-applications/node-pi-durable/src/server.mjs index 79892991f03d..77987311b21c 100644 --- a/dev-packages/e2e-tests/test-applications/node-pi-durable/src/server.mjs +++ b/dev-packages/e2e-tests/test-applications/node-pi-durable/src/server.mjs @@ -24,6 +24,13 @@ if (!process.env.OPENROUTER_API_KEY) { throw new Error('OPENROUTER_API_KEY is not set (E2E_OPENROUTER_API_KEY in the e2e-tests .env)'); } +// pi-durable 1.2.0 renamed the tool result field `content` to `output`. The app runs on 1.0.0 and on the +// latest version, so its tool results set both. +const textResult = text => { + const blocks = [{ type: 'text', text }]; + return { output: blocks, content: blocks }; +}; + const dataDir = fileURLToPath(new URL('../.data/', import.meta.url)); mkdirSync(dataDir, { recursive: true }); @@ -37,9 +44,9 @@ const tools = [ parameters: Type.Object({ city: Type.String() }), // The manual span should nest under the SDK's `execute_tool` span. execute: async args => - Sentry.startSpan({ name: 'resolve-weather', attributes: { 'weather.city': args.city } }, () => ({ - content: [{ type: 'text', text: `It is 21 degrees and sunny in ${args.city}.` }], - })), + Sentry.startSpan({ name: 'resolve-weather', attributes: { 'weather.city': args.city } }, () => + textResult(`It is 21 degrees and sunny in ${args.city}.`), + ), }), defineTool({ name: 'fail_now', @@ -60,7 +67,7 @@ const tools = [ try { writeFileSync(`${dataDir}crashed-${args.job}`, 'crashed once', { flag: 'wx' }); } catch { - return { content: [{ type: 'text', text: `Step of job ${args.job} completed.` }] }; + return textResult(`Step of job ${args.job} completed.`); } process.exit(CRASH_EXIT_CODE); }, @@ -80,14 +87,14 @@ const tools = [ const request = { type: 'input', content: args.task, requestId: `delegate:${api.taskId}` }; const settled = await (await child.submit(request, toolContext)).wait(toolContext); if (settled.status !== 'done') { - return { isError: true, content: [{ type: 'text', text: `Subagent ended: ${settled.reason}` }] }; + return { isError: true, ...textResult(`Subagent ended: ${settled.reason}`) }; } const answer = await api.commit(tx => tx.entry(AssistantEntry, settled.answer), toolContext); const text = (answer?.model?.[0]?.content ?? []) .filter(part => part.type === 'text') .map(part => part.text) .join(''); - return { content: [{ type: 'text', text }] }; + return textResult(text); }, }), ]; diff --git a/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario-interrupted.mjs b/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario-interrupted.mjs index f0efbea32483..9d15ed6f3a47 100644 --- a/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario-interrupted.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario-interrupted.mjs @@ -65,7 +65,7 @@ registry.install( await new Promise((_, reject) => toolContext.abortSignal.addEventListener('abort', () => reject(new Error('tool aborted'))), ); - return { content: [] }; + return { output: [] }; }, }), ], diff --git a/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario-retention.mjs b/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario-retention.mjs index dc84d6387ba9..8887587a07c2 100644 --- a/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario-retention.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario-retention.mjs @@ -46,7 +46,7 @@ registry.install( tx => tx.createConversation({ ownership: { kind: 'task', taskId: api.taskId } }), toolContext, ); - return { content: [{ type: 'text', text: 'Subagent created.' }] }; + return { output: [{ type: 'text', text: 'Subagent created.' }] }; }, }), ], diff --git a/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario-subagent.mjs b/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario-subagent.mjs index da80b508f252..7e6f3413d3dd 100644 --- a/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario-subagent.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario-subagent.mjs @@ -70,7 +70,7 @@ registry.install( const settled = await ( await child.submit({ type: 'input', content: args.task }, toolContext) ).wait(toolContext); - return { content: [{ type: 'text', text: await answerText(api, settled, toolContext) }] }; + return { output: [{ type: 'text', text: await answerText(api, settled, toolContext) }] }; }, }), defineTool({ @@ -89,14 +89,14 @@ registry.install( const settled = await ( await child.submit({ type: 'input', content: 'Forked task.' }, toolContext) ).wait(toolContext); - return { content: [{ type: 'text', text: await answerText(api, settled, toolContext) }] }; + return { output: [{ type: 'text', text: await answerText(api, settled, toolContext) }] }; }, }), defineTool({ name: 'inner', description: 'A plain tool.', parameters: Type.Object({}), - execute: async () => ({ content: [{ type: 'text', text: 'INNER RESULT' }] }), + execute: async () => ({ output: [{ type: 'text', text: 'INNER RESULT' }] }), }), defineTool({ name: 'batch', @@ -105,7 +105,7 @@ registry.install( execute: async (_args, api, toolContext) => { const inner = (await api.agent(toolContext)).tools.find(tool => tool.name === 'inner'); await inner.execute({}, api, toolContext); - return { content: [{ type: 'text', text: 'OUTER RESULT' }] }; + return { output: [{ type: 'text', text: 'OUTER RESULT' }] }; }, }), ], diff --git a/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario-tools.mjs b/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario-tools.mjs index fc42ebf2091f..fd8a977149fc 100644 --- a/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario-tools.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario-tools.mjs @@ -73,7 +73,7 @@ registry.install( name: 'secret', description: 'Returns a secret.', parameters: Type.Object({}), - execute: async () => ({ content: [{ type: 'text', text: 'original secret value' }] }), + execute: async () => ({ output: [{ type: 'text', text: 'original secret value' }] }), }), defineTool({ name: 'fail_now', @@ -87,9 +87,7 @@ registry.install( hooks: [ hook(ToolTask, { afterTool: (call, result) => - call.name === 'secret' - ? { ...result, content: [{ type: 'text', text: 'redacted by afterTool' }] } - : undefined, + call.name === 'secret' ? { ...result, output: [{ type: 'text', text: 'redacted by afterTool' }] } : undefined, }), ], }), diff --git a/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario.mjs b/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario.mjs index 42309d5ba2d4..4c2dc1c87172 100644 --- a/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/pi-durable/scenario.mjs @@ -38,7 +38,7 @@ registry.install( name: 'get_weather', description: 'Get the current weather for a city.', parameters: Type.Object({ city: Type.String() }), - execute: async args => ({ content: [{ type: 'text', text: `It is 21 degrees and sunny in ${args.city}.` }] }), + execute: async args => ({ output: [{ type: 'text', text: `It is 21 degrees and sunny in ${args.city}.` }] }), }), defineTool({ name: 'broken', diff --git a/dev-packages/node-integration-tests/suites/tracing/pi-durable/test.ts b/dev-packages/node-integration-tests/suites/tracing/pi-durable/test.ts index 4333c9e0f415..068b389c60d2 100644 --- a/dev-packages/node-integration-tests/suites/tracing/pi-durable/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/pi-durable/test.ts @@ -30,9 +30,9 @@ import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runn // instead, guarded by the `min: 22` skip below. const PI_DURABLE_DEPENDENCIES = { additionalDependencies: { - '@earendil-works/pi-durable': '^1.0.0', - '@earendil-works/pi-ai': '^1.0.0', - '@earendil-works/chord': '^1.0.0', + '@earendil-works/pi-durable': '^1.2.0', + '@earendil-works/pi-ai': '^1.2.0', + '@earendil-works/chord': '^1.2.0', }, }; diff --git a/packages/server-utils/src/ai/pi-durable/tools.ts b/packages/server-utils/src/ai/pi-durable/tools.ts index 5d19f583dc14..8ccf21155549 100644 --- a/packages/server-utils/src/ai/pi-durable/tools.ts +++ b/packages/server-utils/src/ai/pi-durable/tools.ts @@ -138,7 +138,7 @@ export function instrumentTool(tool: PiTool, runs: PiRuns, options: GenAiOptions call.endTimestamp = timestampInSeconds(); if (!calls) { // No result entry follows a call outside a tool task phase, nor a nested call. - finishToolCall(call, { content: result?.content, isError: result?.isError }); + finishToolCall(call, { content: result?.output ?? result?.content, isError: result?.isError }); } return result; } catch (error) { diff --git a/packages/server-utils/src/ai/pi-durable/types.ts b/packages/server-utils/src/ai/pi-durable/types.ts index 6244b18af1e5..7c87317eb26a 100644 --- a/packages/server-utils/src/ai/pi-durable/types.ts +++ b/packages/server-utils/src/ai/pi-durable/types.ts @@ -50,6 +50,9 @@ export interface PiModels { } export interface PiToolExecutionResult { + /** The content blocks the tool returns for the model, in pi-durable 1.2.0 and later. */ + output?: unknown[]; + /** The content blocks the tool returns for the model, in pi-durable versions before 1.2.0. */ content?: unknown[]; isError?: boolean; } diff --git a/packages/server-utils/test/ai/lib/tracing/pi-durable.test.ts b/packages/server-utils/test/ai/lib/tracing/pi-durable.test.ts index c2b8f6e9b68e..d23b3310785e 100644 --- a/packages/server-utils/test/ai/lib/tracing/pi-durable.test.ts +++ b/packages/server-utils/test/ai/lib/tracing/pi-durable.test.ts @@ -617,6 +617,17 @@ describe('instrumentPiDurableHarnessOptions', () => { }); }); + it('reads the result of a call outside a tool phase from `output`', async () => { + const tool: PiTool = { + name: 'get_weather', + execute: async () => ({ output: [{ type: 'text', text: 'sunny' }] }), + }; + + await instrumentTool(tool, createRuns(), {}).execute({}, { callId: 'call_1' }, undefined); + + expect(spanToJSON(endedSpans[0]!).attributes[GEN_AI_TOOL_CALL_RESULT]).toBe('sunny'); + }); + it.each([ ['no content blocks', []], ['an empty text block', [{ type: 'text', text: '' }]], From 818ffe08b09fe8fbe406a2933c3eecbbfb15a6d2 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Tue, 13 Oct 2026 10:41:14 +0200 Subject: [PATCH 2/2] fixup! fix(server-utils): Read pi-durable 1.2.0 tool results from `output` --- .../cloudflare-pi-durable/package.json | 4 ++-- .../cloudflare-pi-durable/src/assistant.ts | 17 ++++------------- .../node-pi-durable/package.json | 6 +++--- .../node-pi-durable/src/server.mjs | 19 ++++++------------- 4 files changed, 15 insertions(+), 31 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-pi-durable/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-pi-durable/package.json index 6699912023e6..80c16c600e11 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-pi-durable/package.json +++ b/dev-packages/e2e-tests/test-applications/cloudflare-pi-durable/package.json @@ -12,8 +12,8 @@ "test:assert": "playwright test" }, "dependencies": { - "@earendil-works/pi-ai": "1.0.0", - "@earendil-works/pi-durable": "1.0.0", + "@earendil-works/pi-ai": "1.2.0", + "@earendil-works/pi-durable": "1.2.0", "@sentry/cloudflare": "file:../../packed/sentry-cloudflare-packed.tgz", "agents": "0.26.0" }, diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-pi-durable/src/assistant.ts b/dev-packages/e2e-tests/test-applications/cloudflare-pi-durable/src/assistant.ts index adf0b0678283..93e1236190cf 100644 --- a/dev-packages/e2e-tests/test-applications/cloudflare-pi-durable/src/assistant.ts +++ b/dev-packages/e2e-tests/test-applications/cloudflare-pi-durable/src/assistant.ts @@ -6,15 +6,6 @@ import * as Sentry from '@sentry/cloudflare'; import { Agent } from 'agents'; import { PiHarness } from 'agents/harness/pi'; -type TextBlock = { type: 'text'; text: string }; - -// pi-durable 1.2.0 renamed the tool result field `content` to `output`. The app runs on 1.0.0 and on the -// latest version, so its tool results set both. -function textResult(text: string): { output: TextBlock[]; content: TextBlock[] } { - const blocks: TextBlock[] = [{ type: 'text', text }]; - return { output: blocks, content: blocks }; -} - /** * pi-durable hosted by the Agents SDK `PiHarness`: pi keeps its state in this object's SQLite * database, and the harness reopens pi when the object restarts. Nothing wires Sentry into @@ -55,9 +46,9 @@ export class Assistant extends Agent { parameters: Type.Object({ city: Type.String() }), // The manual span should nest under the SDK's `execute_tool` span. execute: async args => - Sentry.startSpan({ name: 'resolve-weather', attributes: { 'weather.city': args.city } }, () => - textResult(`It is 21 degrees and sunny in ${args.city}.`), - ), + Sentry.startSpan({ name: 'resolve-weather', attributes: { 'weather.city': args.city } }, () => ({ + output: [{ type: 'text', text: `It is 21 degrees and sunny in ${args.city}.` }], + })), }), defineTool({ name: 'fail_now', @@ -76,7 +67,7 @@ export class Assistant extends Agent { execute: async args => { const marker = `crashed:${args.job}`; if (await this.ctx.storage.get(marker)) { - return textResult(`Step of job ${args.job} completed.`); + return { output: [{ type: 'text', text: `Step of job ${args.job} completed.` }] }; } await this.ctx.storage.put(marker, true); await this.ctx.storage.sync(); diff --git a/dev-packages/e2e-tests/test-applications/node-pi-durable/package.json b/dev-packages/e2e-tests/test-applications/node-pi-durable/package.json index eb9822667b3a..33bc22b4e00f 100644 --- a/dev-packages/e2e-tests/test-applications/node-pi-durable/package.json +++ b/dev-packages/e2e-tests/test-applications/node-pi-durable/package.json @@ -11,9 +11,9 @@ "test:assert": "OPENROUTER_API_KEY=$E2E_OPENROUTER_API_KEY playwright test" }, "dependencies": { - "@earendil-works/chord": "1.0.0", - "@earendil-works/pi-ai": "1.0.0", - "@earendil-works/pi-durable": "1.0.0", + "@earendil-works/chord": "1.2.0", + "@earendil-works/pi-ai": "1.2.0", + "@earendil-works/pi-durable": "1.2.0", "@sentry/node": "file:../../packed/sentry-node-packed.tgz" }, "devDependencies": { diff --git a/dev-packages/e2e-tests/test-applications/node-pi-durable/src/server.mjs b/dev-packages/e2e-tests/test-applications/node-pi-durable/src/server.mjs index 77987311b21c..c71c9f4096bf 100644 --- a/dev-packages/e2e-tests/test-applications/node-pi-durable/src/server.mjs +++ b/dev-packages/e2e-tests/test-applications/node-pi-durable/src/server.mjs @@ -24,13 +24,6 @@ if (!process.env.OPENROUTER_API_KEY) { throw new Error('OPENROUTER_API_KEY is not set (E2E_OPENROUTER_API_KEY in the e2e-tests .env)'); } -// pi-durable 1.2.0 renamed the tool result field `content` to `output`. The app runs on 1.0.0 and on the -// latest version, so its tool results set both. -const textResult = text => { - const blocks = [{ type: 'text', text }]; - return { output: blocks, content: blocks }; -}; - const dataDir = fileURLToPath(new URL('../.data/', import.meta.url)); mkdirSync(dataDir, { recursive: true }); @@ -44,9 +37,9 @@ const tools = [ parameters: Type.Object({ city: Type.String() }), // The manual span should nest under the SDK's `execute_tool` span. execute: async args => - Sentry.startSpan({ name: 'resolve-weather', attributes: { 'weather.city': args.city } }, () => - textResult(`It is 21 degrees and sunny in ${args.city}.`), - ), + Sentry.startSpan({ name: 'resolve-weather', attributes: { 'weather.city': args.city } }, () => ({ + output: [{ type: 'text', text: `It is 21 degrees and sunny in ${args.city}.` }], + })), }), defineTool({ name: 'fail_now', @@ -67,7 +60,7 @@ const tools = [ try { writeFileSync(`${dataDir}crashed-${args.job}`, 'crashed once', { flag: 'wx' }); } catch { - return textResult(`Step of job ${args.job} completed.`); + return { output: [{ type: 'text', text: `Step of job ${args.job} completed.` }] }; } process.exit(CRASH_EXIT_CODE); }, @@ -87,14 +80,14 @@ const tools = [ const request = { type: 'input', content: args.task, requestId: `delegate:${api.taskId}` }; const settled = await (await child.submit(request, toolContext)).wait(toolContext); if (settled.status !== 'done') { - return { isError: true, ...textResult(`Subagent ended: ${settled.reason}`) }; + return { isError: true, output: [{ type: 'text', text: `Subagent ended: ${settled.reason}` }] }; } const answer = await api.commit(tx => tx.entry(AssistantEntry, settled.answer), toolContext); const text = (answer?.model?.[0]?.content ?? []) .filter(part => part.type === 'text') .map(part => part.text) .join(''); - return textResult(text); + return { output: [{ type: 'text', text }] }; }, }), ];