Skip to content

Commit c8a1208

Browse files
JPeer264claude
andcommitted
feat(server-utils): Add instrumentation for pi-durable
pi-durable has no telemetry hooks, so `piDurableIntegration` wraps the `models` and `registry` passed to `Harness.open()`. Each run becomes its own `invoke_agent` trace with `chat` and `execute_tool` children, and a subagent run nests under the tool call that started it. Messages are mapped to the gen_ai conventions, and the system prompt and tools are read from pi-durable's positional system messages. Tool spans record the result the model receives. Failures pi-durable only reports, and task phases that throw, are captured. Throws of the built-in coding tools are not, because they report expected results to the model. On Cloudflare it also covers pi-durable in a Durable Object through the Agents SDK `PiHarness`, including requests that pi-ai sends through the Workers AI binding. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 79eb85a commit c8a1208

35 files changed

Lines changed: 3448 additions & 4 deletions

File tree

‎dev-packages/bun-integration-tests/node-suites/excludes.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ export const NO_AUTO_INSTRUMENTATION = [
123123
'suites/tracing/openai/v6/test.ts',
124124
'suites/tracing/openai/v7/test.ts',
125125
'suites/tracing/orchestrion-lazy-registration/test.ts',
126+
'suites/tracing/pi-durable/test.ts',
126127
'suites/tracing/postgres-streamed/test.ts',
127128
'suites/tracing/postgres/test.ts',
128129
'suites/tracing/postgresjs-streamed/test.ts',
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import * as Sentry from '@sentry/node';
2+
import { loggingTransport } from '@sentry-internal/node-integration-tests';
3+
4+
Sentry.init({
5+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
6+
release: '1.0',
7+
tracesSampleRate: 1.0,
8+
transport: loggingTransport,
9+
...(process.env.PI_DURABLE_RECORDING === 'off'
10+
? { dataCollection: { genAI: { inputs: false, outputs: false } } }
11+
: {}),
12+
});
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { BACKGROUND_CONTEXT } from '@earendil-works/chord/context';
2+
import { createModels } from '@earendil-works/pi-ai/models';
3+
import { fauxAssistantMessage, fauxProvider } from '@earendil-works/pi-ai/providers/faux';
4+
import { createRegistry, Harness, MemoryStorage } from '@earendil-works/pi-durable';
5+
6+
const context = BACKGROUND_CONTEXT;
7+
8+
// A context window so small that pi-durable compacts the conversation before it can answer the
9+
// third question. The compaction request belongs to that run.
10+
const faux = fauxProvider({ provider: 'faux', models: [{ id: 'tiny', contextWindow: 60 }] });
11+
const models = createModels();
12+
models.setProvider(faux.provider);
13+
faux.setResponses([
14+
fauxAssistantMessage('First answer with quite a lot of detail so the transcript grows beyond the window.'),
15+
fauxAssistantMessage('Second answer with even more detail so that the next request needs a compaction first.'),
16+
fauxAssistantMessage('Summary of the conversation.'),
17+
fauxAssistantMessage('Third answer.'),
18+
]);
19+
20+
const harness = await Harness.open(
21+
new MemoryStorage(),
22+
{
23+
models,
24+
registry: createRegistry(),
25+
settings: { compaction: { enabled: true, reserveTokens: 0, backgroundTokens: 0, keepRecentTokens: 1 } },
26+
},
27+
context,
28+
);
29+
const root = await harness.root(context, { agent: { model: { provider: 'faux', modelId: 'tiny' } } });
30+
for (const content of [
31+
'First question with some words in it.',
32+
'Second question with some more words.',
33+
'Third question.',
34+
]) {
35+
await (await root.submit({ type: 'input', content }, context)).wait(context);
36+
}
37+
await harness.waitForIdle(context);
38+
await harness.close(context);
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import * as Sentry from '@sentry/node';
2+
import { BACKGROUND_CONTEXT } from '@earendil-works/chord/context';
3+
import { Type } from '@earendil-works/pi-ai';
4+
import { createModels } from '@earendil-works/pi-ai/models';
5+
import { fauxAssistantMessage, fauxProvider, fauxToolCall } from '@earendil-works/pi-ai/providers/faux';
6+
import { createRegistry, defineExtension, defineTool, Harness, MemoryStorage } from '@earendil-works/pi-durable';
7+
8+
const context = BACKGROUND_CONTEXT;
9+
10+
// Two conversations run at the same time, each submitted from its own request with its own scope
11+
// data. Each response is derived from the transcript, and the delays make the two runs interleave.
12+
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
13+
const respond = async transcript => {
14+
const lastUser = [...transcript.messages].reverse().find(message => message.role === 'user');
15+
const prompt =
16+
typeof lastUser.content === 'string' ? lastUser.content : lastUser.content.map(part => part.text).join('');
17+
const afterTool = transcript.messages.some(message => message.role === 'toolResult');
18+
await delay(prompt.includes('A') ? 30 : 10);
19+
return afterTool
20+
? fauxAssistantMessage(`Answer for ${prompt}`)
21+
: fauxAssistantMessage(fauxToolCall('work', { tag: prompt }, { id: `call_${prompt.at(-1)}` }), {
22+
stopReason: 'toolUse',
23+
});
24+
};
25+
const faux = fauxProvider({ provider: 'faux', models: [{ id: 'faux-model' }] });
26+
const models = createModels();
27+
models.setProvider(faux.provider);
28+
faux.setResponses([respond, respond, respond, respond]);
29+
30+
const registry = createRegistry();
31+
registry.install(
32+
defineExtension({
33+
name: 'app',
34+
tools: [
35+
defineTool({
36+
name: 'work',
37+
description: 'Works for a while, then fails.',
38+
parameters: Type.Object({ tag: Type.String() }),
39+
execute: async args => {
40+
await delay(args.tag.includes('A') ? 10 : 30);
41+
throw new Error(`work failed for ${args.tag}`);
42+
},
43+
}),
44+
],
45+
}),
46+
);
47+
48+
const harness = await Harness.open(new MemoryStorage(), { models, registry }, context);
49+
const model = { provider: 'faux', modelId: 'faux-model' };
50+
const a = await harness.createConversation({ ownership: { kind: 'ownerless' }, agent: { model } }, context);
51+
const b = await harness.createConversation({ ownership: { kind: 'ownerless' }, agent: { model } }, context);
52+
53+
// Each request has its own isolation scope, as it would in a server, with data of its own.
54+
const ask = (conversation, request) =>
55+
Sentry.withIsolationScope(async isolationScope => {
56+
isolationScope.setTag('request', request);
57+
isolationScope.setConversationId(`scope-of-${request}`);
58+
await (await conversation.submit({ type: 'input', content: request }, context)).wait(context);
59+
});
60+
await Promise.all([ask(a, 'request A'), ask(b, 'request B')]);
61+
await harness.close(context);
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import * as Sentry from '@sentry/node';
2+
import { BACKGROUND_CONTEXT } from '@earendil-works/chord/context';
3+
import { Type } from '@earendil-works/pi-ai';
4+
import { createModels } from '@earendil-works/pi-ai/models';
5+
import { fauxAssistantMessage, fauxProvider, fauxToolCall } from '@earendil-works/pi-ai/providers/faux';
6+
import { createRegistry, defineExtension, defineTool, Harness, MemoryStorage } from '@earendil-works/pi-durable';
7+
8+
const context = BACKGROUND_CONTEXT;
9+
10+
// Six runs that do not end with an answer:
11+
// 1. the model request fails (`stopReason` error), with retries off;
12+
// 2. the provider throws, which faults the generation task;
13+
// 3. the run is aborted while the `slow` tool waits;
14+
// 4. the run is aborted while the model request is in flight;
15+
// 5. the Harness closes while the `slow` tool waits;
16+
// 6. in a second Harness, the model request fails and the Harness closes right after `wait()`.
17+
const started = new Map();
18+
const toolStarted = job => new Promise(resolve => started.set(job, resolve));
19+
20+
const faux = fauxProvider({ provider: 'faux', models: [{ id: 'faux-model' }] });
21+
const realModels = createModels();
22+
realModels.setProvider(faux.provider);
23+
// The faux provider turns a throwing response into an error message, so the throw of a broken
24+
// provider is staged in front of pi-ai: the request after `breakNextRequest()` throws synchronously.
25+
let brokenRequests = 0;
26+
const breakNextRequest = () => brokenRequests++;
27+
const models = new Proxy(realModels, {
28+
get(target, property) {
29+
const value = Reflect.get(target, property, target);
30+
if (property === 'streamSimple') {
31+
return (...args) => {
32+
if (brokenRequests > 0) {
33+
brokenRequests--;
34+
throw new Error('provider broke');
35+
}
36+
return value.apply(target, args);
37+
};
38+
}
39+
return typeof value === 'function' ? value.bind(target) : value;
40+
},
41+
});
42+
faux.setResponses([
43+
fauxAssistantMessage('', { stopReason: 'error', errorMessage: 'provider exploded' }),
44+
fauxAssistantMessage(fauxToolCall('slow', { job: 'abort' }, { id: 'call_abort' }), { stopReason: 'toolUse' }),
45+
async (_transcript, options) => {
46+
started.get('request')();
47+
await new Promise(resolve => options.signal.addEventListener('abort', resolve));
48+
return fauxAssistantMessage('partial', { stopReason: 'aborted' });
49+
},
50+
fauxAssistantMessage(fauxToolCall('slow', { job: 'close' }, { id: 'call_close' }), { stopReason: 'toolUse' }),
51+
fauxAssistantMessage('', { stopReason: 'error', errorMessage: 'provider exploded again' }),
52+
]);
53+
54+
const registry = createRegistry();
55+
registry.install(
56+
defineExtension({
57+
name: 'app',
58+
tools: [
59+
defineTool({
60+
name: 'slow',
61+
description: 'Waits until it is aborted.',
62+
parameters: Type.Object({ job: Type.String() }),
63+
execute: async (args, _api, toolContext) => {
64+
started.get(args.job)();
65+
await new Promise((_, reject) =>
66+
toolContext.abortSignal.addEventListener('abort', () => reject(new Error('tool aborted'))),
67+
);
68+
return { content: [] };
69+
},
70+
}),
71+
],
72+
}),
73+
);
74+
75+
const harness = await Harness.open(
76+
new MemoryStorage(),
77+
{ models, registry, settings: { retry: { enabled: false } } },
78+
context,
79+
);
80+
const root = await harness.root(context, { agent: { model: { provider: 'faux', modelId: 'faux-model' } } });
81+
82+
await (await root.submit({ type: 'input', content: 'Fail please.' }, context)).wait(context);
83+
breakNextRequest();
84+
await (await root.submit({ type: 'input', content: 'Break please.' }, context)).wait(context);
85+
86+
const abortRun = toolStarted('abort');
87+
const aborted = await root.submit({ type: 'input', content: 'Run slow, then abort.' }, context);
88+
await abortRun;
89+
await root.abort(context);
90+
await aborted.wait(context);
91+
92+
const requestRun = toolStarted('request');
93+
const abortedRequest = await root.submit({ type: 'input', content: 'Answer slowly, then abort.' }, context);
94+
await requestRun;
95+
await root.abort(context);
96+
await abortedRequest.wait(context);
97+
98+
const closeRun = toolStarted('close');
99+
await root.submit({ type: 'input', content: 'Run slow, then close.' }, context);
100+
await closeRun;
101+
await harness.close(context);
102+
103+
const second = await Harness.open(
104+
new MemoryStorage(),
105+
{ models, registry, settings: { retry: { enabled: false } } },
106+
context,
107+
);
108+
const secondRoot = await second.root(context, { agent: { model: { provider: 'faux', modelId: 'faux-model' } } });
109+
await (await secondRoot.submit({ type: 'input', content: 'Fail, then close.' }, context)).wait(context);
110+
await second.close(context);
111+
112+
// Everything captured during the runs is sent before this sentinel.
113+
await Sentry.flush(2000);
114+
Sentry.captureMessage('pi-durable interrupted done');
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { BACKGROUND_CONTEXT } from '@earendil-works/chord/context';
2+
import { createModels } from '@earendil-works/pi-ai/models';
3+
import { fauxAssistantMessage, fauxProvider } from '@earendil-works/pi-ai/providers/faux';
4+
import {
5+
createRegistry,
6+
defineExtension,
7+
defineTask,
8+
GenerationTask,
9+
Harness,
10+
hook,
11+
MemoryStorage,
12+
} from '@earendil-works/pi-durable';
13+
14+
const context = BACKGROUND_CONTEXT;
15+
16+
// Failures pi-durable does not propagate: a hook that throws (pi-durable reports it and keeps the
17+
// run going) and a durable task whose phase throws (the scheduler faults the task). A compaction
18+
// started while the conversation is idle runs outside any run.
19+
const faux = fauxProvider({ provider: 'faux', models: [{ id: 'faux-model' }] });
20+
const models = createModels();
21+
models.setProvider(faux.provider);
22+
faux.setResponses([
23+
fauxAssistantMessage('First answer with some detail.'),
24+
fauxAssistantMessage('Second answer with more detail.'),
25+
fauxAssistantMessage('Summary of the conversation.'),
26+
]);
27+
28+
const Charge = defineTask({
29+
name: 'app.charge',
30+
version: 1,
31+
initial: () => ({ phase: 'charge' }),
32+
phases: {
33+
charge: async () => {
34+
throw new Error('card declined');
35+
},
36+
},
37+
abort: async (_task, runtime, taskContext) => {
38+
await runtime.commit(() => ({ status: 'terminal', outcome: { status: 'aborted' } }), taskContext);
39+
},
40+
});
41+
42+
const registry = createRegistry();
43+
registry.install(
44+
defineExtension({
45+
name: 'app',
46+
tasks: [Charge],
47+
hooks: [
48+
hook(GenerationTask, {
49+
afterResponse: () => {
50+
throw new Error('afterResponse hook failed');
51+
},
52+
}),
53+
],
54+
}),
55+
);
56+
57+
const harness = await Harness.open(
58+
new MemoryStorage(),
59+
{ models, registry, settings: { compaction: { keepRecentTokens: 1 } } },
60+
context,
61+
);
62+
const root = await harness.root(context, { agent: { model: { provider: 'faux', modelId: 'faux-model' } } });
63+
await (await root.submit({ type: 'input', content: 'First question.' }, context)).wait(context);
64+
await (await root.submit({ type: 'input', content: 'Second question.' }, context)).wait(context);
65+
66+
const charge = await root.commit(tx => tx.createTask(Charge, {}, { ownership: { kind: 'conversation' } }), context);
67+
await harness.waitForTask(charge, context);
68+
69+
await harness.waitForTask(await root.compact(undefined, context), context);
70+
await harness.close(context);

0 commit comments

Comments
 (0)