@ai-sdk/harness-pi 1.0.97 → 1.0.99

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-sdk/harness-pi",
3
- "version": "1.0.97",
3
+ "version": "1.0.99",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "sideEffects": false,
@@ -26,7 +26,7 @@
26
26
  }
27
27
  },
28
28
  "dependencies": {
29
- "@ai-sdk/harness": "1.0.95",
29
+ "@ai-sdk/harness": "1.0.97",
30
30
  "@ai-sdk/provider-utils": "5.0.34",
31
31
  "@earendil-works/pi-ai": "0.74.2",
32
32
  "@earendil-works/pi-coding-agent": "^0.84.3",
@@ -37,7 +37,7 @@
37
37
  "zod": "^3.25.76 || ^4.1.8"
38
38
  },
39
39
  "devDependencies": {
40
- "@ai-sdk/sandbox-just-bash": "1.0.95",
40
+ "@ai-sdk/sandbox-just-bash": "1.0.97",
41
41
  "@types/node": "22.19.19",
42
42
  "@vercel/ai-tsconfig": "0.0.0",
43
43
  "tsup": "^8.5.1",
package/src/pi-events.ts CHANGED
@@ -12,6 +12,14 @@ export const piSessionEventSchema = z.looseObject({
12
12
  .looseObject({
13
13
  type: z.string().optional(),
14
14
  delta: z.string().optional(),
15
+ // `toolcall_start` / `toolcall_delta` / `toolcall_end` address a content
16
+ // block by index rather than by tool call id. The id and name live in the
17
+ // partial assistant message at that index, which Pi fills in before the
18
+ // first delta arrives.
19
+ contentIndex: z.number().optional(),
20
+ partial: z
21
+ .looseObject({ content: z.array(z.unknown()).optional() })
22
+ .optional(),
15
23
  })
16
24
  .optional(),
17
25
  toolCallId: z.string().optional(),
@@ -28,6 +28,13 @@ export interface PiTranslatorState {
28
28
  reasoningStarted: boolean;
29
29
  /** Tool-call id → tool name (used to fill in `toolName` on results). */
30
30
  observedToolNames: Map<string, string>;
31
+ /**
32
+ * Content-block index → tool-call id for tool inputs that are still
33
+ * streaming. Pi addresses `toolcall_*` events by `contentIndex`, while the
34
+ * harness stream parts are keyed by the tool call id, so the id is resolved
35
+ * once at `toolcall_start` and reused for the deltas that follow.
36
+ */
37
+ streamingToolInputIds: Map<number, string>;
31
38
  /** Tool ids requested by the current assistant message but not yet completed. */
32
39
  pendingStepToolCallIds: Set<string>;
33
40
  /** Total tool calls requested by the current assistant message. */
@@ -85,6 +92,7 @@ export function createPiTranslatorState(
85
92
  currentReasoningId: undefined,
86
93
  reasoningStarted: false,
87
94
  observedToolNames: new Map(),
95
+ streamingToolInputIds: new Map(),
88
96
  pendingStepToolCallIds: new Set(),
89
97
  stepToolCallCount: undefined,
90
98
  stepOpen: false,
@@ -154,6 +162,48 @@ function resolveToolName(
154
162
  return { wire: common ?? nativeName, native: nativeName };
155
163
  }
156
164
 
165
+ /**
166
+ * How a tool call is dispatched, from the native tool name. Pi runs its
167
+ * builtin tools and MCP tools itself; everything else is handed back to the
168
+ * harness host. `tool-input-start` reports the same flags as the `tool-call`
169
+ * that follows it so a consumer does not have to wait for the call to know
170
+ * who will execute it.
171
+ */
172
+ function resolveToolDispatch(
173
+ state: PiTranslatorState,
174
+ nativeName: string,
175
+ ): { isMcpTool: boolean; providerExecuted: boolean } {
176
+ const isMcpTool =
177
+ !state.hostToolNames.has(nativeName) &&
178
+ (nativeName === 'mcp' || nativeName.startsWith('mcp__'));
179
+ return {
180
+ isMcpTool,
181
+ providerExecuted: state.builtinToolNames.has(nativeName) || isMcpTool,
182
+ };
183
+ }
184
+
185
+ /**
186
+ * The `{ id, name }` of the tool call a `toolcall_*` event refers to, read out
187
+ * of the partial assistant message it carries. Returns undefined when the
188
+ * block is missing or not a tool call yet, in which case the input is left
189
+ * unstreamed — the complete `tool-call` still arrives at `tool_execution_start`.
190
+ */
191
+ function readStreamingToolCall(
192
+ event: PiSessionEvent,
193
+ ): { contentIndex: number; id: string; name: string } | undefined {
194
+ const update = event.assistantMessageEvent;
195
+ const contentIndex = update?.contentIndex;
196
+ if (typeof contentIndex !== 'number') return undefined;
197
+ const block = update?.partial?.content?.[contentIndex];
198
+ if (!block || typeof block !== 'object') return undefined;
199
+ const record = block as Record<string, unknown>;
200
+ if (record.type !== 'toolCall') return undefined;
201
+ const { id, name } = record;
202
+ if (typeof id !== 'string' || id.length === 0) return undefined;
203
+ if (typeof name !== 'string' || name.length === 0) return undefined;
204
+ return { contentIndex, id, name };
205
+ }
206
+
157
207
  function finishStep(state: PiTranslatorState): HarnessV1StreamPart[] {
158
208
  if (!state.stepOpen || state.pendingStepToolCallIds.size > 0) return [];
159
209
  state.stepOpen = false;
@@ -225,6 +275,8 @@ export function translatePiEvent(
225
275
  state.currentTextId = undefined;
226
276
  state.currentReasoningId = undefined;
227
277
  state.reasoningStarted = false;
278
+ // Content-block indices restart with every assistant message.
279
+ state.streamingToolInputIds.clear();
228
280
  return [];
229
281
  }
230
282
 
@@ -282,6 +334,43 @@ export function translatePiEvent(
282
334
  });
283
335
  return parts;
284
336
  }
337
+ // Tool inputs stream as raw JSON text, the same way text and reasoning
338
+ // stream. Surfacing them lets a consumer show what the model is writing
339
+ // before the call is complete, instead of waiting for the whole input to
340
+ // land at `tool_execution_start`.
341
+ if (update.type === 'toolcall_start') {
342
+ const call = readStreamingToolCall(event);
343
+ if (!call) return [];
344
+ const { wire, native } = resolveToolName(state, call.name);
345
+ const { isMcpTool, providerExecuted } = resolveToolDispatch(
346
+ state,
347
+ native,
348
+ );
349
+ state.streamingToolInputIds.set(call.contentIndex, call.id);
350
+ return [
351
+ {
352
+ type: 'tool-input-start',
353
+ id: call.id,
354
+ toolName: wire,
355
+ ...(providerExecuted ? { providerExecuted: true } : {}),
356
+ ...(isMcpTool ? { dynamic: true } : {}),
357
+ },
358
+ ];
359
+ }
360
+ if (update.type === 'toolcall_delta' || update.type === 'toolcall_end') {
361
+ const contentIndex = update.contentIndex;
362
+ if (typeof contentIndex !== 'number') return [];
363
+ const id = state.streamingToolInputIds.get(contentIndex);
364
+ // Without a start there is no id to attach the input to. Dropping it
365
+ // is safe: the complete input still arrives with the `tool-call`.
366
+ if (id === undefined) return [];
367
+ if (update.type === 'toolcall_end') {
368
+ state.streamingToolInputIds.delete(contentIndex);
369
+ return [{ type: 'tool-input-end', id }];
370
+ }
371
+ if (typeof update.delta !== 'string') return [];
372
+ return [{ type: 'tool-input-delta', id, delta: update.delta }];
373
+ }
285
374
  return [];
286
375
  }
287
376
 
@@ -331,10 +420,10 @@ export function translatePiEvent(
331
420
  if (!event.toolCallId || !event.toolName) return [];
332
421
  const { wire, native } = resolveToolName(state, event.toolName);
333
422
  state.observedToolNames.set(event.toolCallId, wire);
334
- const isMcpTool =
335
- !state.hostToolNames.has(native) &&
336
- (native === 'mcp' || native.startsWith('mcp__'));
337
- const providerExecuted = state.builtinToolNames.has(native) || isMcpTool;
423
+ const { isMcpTool, providerExecuted } = resolveToolDispatch(
424
+ state,
425
+ native,
426
+ );
338
427
  if (isMcpTool) state.dynamicToolCallIds.add(event.toolCallId);
339
428
  const input = serializeToolOutput(event.args ?? event.input ?? {});
340
429
  return [