@pontia/pi-client-plugin 0.6.4 → 0.6.6
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 +1 -1
- package/src/index.ts +7 -0
- package/src/live-output.ts +220 -4
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -880,6 +880,13 @@ export function createPontiaPiExtension(
|
|
|
880
880
|
}
|
|
881
881
|
});
|
|
882
882
|
|
|
883
|
+
const publishToolExecution = (event: unknown) => {
|
|
884
|
+
if (activeTurn && !activeTurn.ended) activeTurn.liveOutput.toolExecution(event);
|
|
885
|
+
};
|
|
886
|
+
pi.on("tool_execution_start", publishToolExecution);
|
|
887
|
+
pi.on("tool_execution_update", publishToolExecution);
|
|
888
|
+
pi.on("tool_execution_end", publishToolExecution);
|
|
889
|
+
|
|
883
890
|
pi.on("message_end", async (event, ctx) => {
|
|
884
891
|
if (!activeTurn || activeTurn.ended) return;
|
|
885
892
|
await reportContextUsageFromHookEvent(event, ctx);
|
package/src/live-output.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import type { TurnContext } from "./context.js";
|
|
3
|
-
import { RpcError, type PiConnection } from "./control-socket.js";
|
|
3
|
+
import { MAX_RPC_FRAME_BYTES, RpcError, type PiConnection } from "./control-socket.js";
|
|
4
4
|
import { asRecord, optionalString } from "./values.js";
|
|
5
5
|
|
|
6
6
|
const DEFAULT_BATCH_DELAY_MS = 75;
|
|
@@ -15,6 +15,20 @@ export type ManagedToolUse = {
|
|
|
15
15
|
| { type: "bash"; command: string; timeout?: number };
|
|
16
16
|
};
|
|
17
17
|
|
|
18
|
+
export interface LiveToolStatus {
|
|
19
|
+
phase: "running" | "ended";
|
|
20
|
+
outcome?: "succeeded" | "failed" | "rejected" | "interrupted";
|
|
21
|
+
}
|
|
22
|
+
export interface LiveToolOutput {
|
|
23
|
+
text: string;
|
|
24
|
+
truncated: boolean;
|
|
25
|
+
}
|
|
26
|
+
export interface LiveToolResult {
|
|
27
|
+
content?: unknown;
|
|
28
|
+
error?: unknown;
|
|
29
|
+
truncated: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
18
32
|
export type LiveOutputItem =
|
|
19
33
|
| { kind: "assistant_text"; item_id: string; text: string }
|
|
20
34
|
| {
|
|
@@ -24,9 +38,16 @@ export type LiveOutputItem =
|
|
|
24
38
|
tool_name: string;
|
|
25
39
|
arguments: unknown;
|
|
26
40
|
managed_tool_use?: ManagedToolUse;
|
|
41
|
+
status?: LiveToolStatus;
|
|
42
|
+
output?: LiveToolOutput;
|
|
43
|
+
result?: LiveToolResult;
|
|
27
44
|
};
|
|
28
45
|
|
|
29
46
|
export type LiveOutputUpdate =
|
|
47
|
+
| { type: "tool_status_updated"; call_id: string; status: LiveToolStatus }
|
|
48
|
+
| { type: "tool_result"; call_id: string; result: LiveToolResult }
|
|
49
|
+
| { type: "tool_output_delta"; call_id: string; delta: string; truncated: boolean }
|
|
50
|
+
| { type: "tool_output_updated"; call_id: string; text: string; truncated: boolean }
|
|
30
51
|
| { type: "assistant_text_delta"; item_id: string; delta: string }
|
|
31
52
|
| {
|
|
32
53
|
type: "tool_call";
|
|
@@ -46,6 +67,7 @@ export interface CompleteToolCall {
|
|
|
46
67
|
export interface LiveOutputPublisherLike {
|
|
47
68
|
appendText(delta: string): void;
|
|
48
69
|
appendToolCall(toolCall: CompleteToolCall): void;
|
|
70
|
+
toolExecution(event: unknown): void;
|
|
49
71
|
close(): Promise<void>;
|
|
50
72
|
}
|
|
51
73
|
|
|
@@ -93,6 +115,7 @@ export class LiveOutputPublisher implements LiveOutputPublisherLike {
|
|
|
93
115
|
if (this.closing || this.disabled || delta.length === 0) return;
|
|
94
116
|
let item = this.items.at(-1);
|
|
95
117
|
if (item?.kind !== "assistant_text") {
|
|
118
|
+
if (this.items.length >= 256) return;
|
|
96
119
|
item = { kind: "assistant_text", item_id: this.nextItemId("text"), text: "" };
|
|
97
120
|
this.items.push(item);
|
|
98
121
|
}
|
|
@@ -107,7 +130,10 @@ export class LiveOutputPublisher implements LiveOutputPublisherLike {
|
|
|
107
130
|
this.items.some((item) => item.kind === "tool_call" && item.call_id === toolCall.callId)
|
|
108
131
|
)
|
|
109
132
|
return;
|
|
133
|
+
if (this.items.length >= 256) return;
|
|
110
134
|
const managedToolUse = managedToolUseFor(toolCall);
|
|
135
|
+
const inputBytes = jsonBytes([toolCall.arguments, managedToolUse ?? null]);
|
|
136
|
+
if (inputBytes > 64 * 1024 || this.toolBytes() + inputBytes > 1024 * 1024) return;
|
|
111
137
|
const item: LiveOutputItem = {
|
|
112
138
|
kind: "tool_call",
|
|
113
139
|
item_id: this.nextItemId("tool"),
|
|
@@ -127,6 +153,100 @@ export class LiveOutputPublisher implements LiveOutputPublisherLike {
|
|
|
127
153
|
});
|
|
128
154
|
}
|
|
129
155
|
|
|
156
|
+
toolExecution(event: unknown): void {
|
|
157
|
+
if (this.closing || this.disabled) return;
|
|
158
|
+
const native = asRecord(event);
|
|
159
|
+
const callId = optionalString(native?.toolCallId);
|
|
160
|
+
if (!native || !callId) return;
|
|
161
|
+
if (
|
|
162
|
+
(native.type === "tool_execution_start" || native.type === "tool_execution_update") &&
|
|
163
|
+
"args" in native
|
|
164
|
+
) {
|
|
165
|
+
const toolName = optionalString(native.toolName);
|
|
166
|
+
if (toolName) this.appendToolCall({ callId, toolName, arguments: native.args });
|
|
167
|
+
}
|
|
168
|
+
const item = this.items.find(
|
|
169
|
+
(item): item is Extract<LiveOutputItem, { kind: "tool_call" }> =>
|
|
170
|
+
item.kind === "tool_call" && item.call_id === callId,
|
|
171
|
+
);
|
|
172
|
+
if (!item) return;
|
|
173
|
+
if (native.type === "tool_execution_start") {
|
|
174
|
+
if (item.status) return;
|
|
175
|
+
item.status = { phase: "running" };
|
|
176
|
+
this.enqueue({ type: "tool_status_updated", call_id: callId, status: item.status });
|
|
177
|
+
} else if (native.type === "tool_execution_update") {
|
|
178
|
+
if (item.status?.phase === "ended" || !("partialResult" in native)) return;
|
|
179
|
+
const partial = asRecord(native.partialResult);
|
|
180
|
+
const content = partial?.content;
|
|
181
|
+
const text = Array.isArray(content)
|
|
182
|
+
? content
|
|
183
|
+
.map((block) =>
|
|
184
|
+
typeof asRecord(block)?.text === "string"
|
|
185
|
+
? asRecord(block)!.text
|
|
186
|
+
: JSON.stringify(block),
|
|
187
|
+
)
|
|
188
|
+
.join("\n")
|
|
189
|
+
: typeof native.partialResult === "string"
|
|
190
|
+
? native.partialResult
|
|
191
|
+
: JSON.stringify(native.partialResult);
|
|
192
|
+
if (text === undefined) return;
|
|
193
|
+
const budget = Math.max(
|
|
194
|
+
0,
|
|
195
|
+
1024 * 1024 - this.toolBytes() + escapedTextBytes(item.output?.text ?? ""),
|
|
196
|
+
);
|
|
197
|
+
const retained = truncateText(text, budget);
|
|
198
|
+
const truncated = retained !== text || nativeTruncated(native.partialResult);
|
|
199
|
+
if (item.output?.text === retained && item.output.truncated === truncated) return;
|
|
200
|
+
item.output = { text: retained, truncated };
|
|
201
|
+
this.enqueue({ type: "tool_output_updated", call_id: callId, ...item.output });
|
|
202
|
+
} else if (native.type === "tool_execution_end") {
|
|
203
|
+
if (native.result !== undefined) {
|
|
204
|
+
const truncated = nativeTruncated(native.result);
|
|
205
|
+
const result: LiveToolResult =
|
|
206
|
+
native.isError === true
|
|
207
|
+
? { error: native.result, truncated }
|
|
208
|
+
: { content: native.result, truncated };
|
|
209
|
+
const budget = Math.max(0, 1024 * 1024 - this.toolBytes() + resultBytes(item.result));
|
|
210
|
+
if (resultBytes(result) > budget) {
|
|
211
|
+
const key = native.isError === true ? "error" : "content";
|
|
212
|
+
const value = result[key];
|
|
213
|
+
result[key] = truncateText(
|
|
214
|
+
typeof value === "string" ? value : JSON.stringify(value),
|
|
215
|
+
budget,
|
|
216
|
+
);
|
|
217
|
+
result.truncated = true;
|
|
218
|
+
}
|
|
219
|
+
if (JSON.stringify(item.result) !== JSON.stringify(result)) {
|
|
220
|
+
item.result = result;
|
|
221
|
+
this.enqueue({ type: "tool_result", call_id: callId, result });
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
const status: LiveToolStatus = {
|
|
225
|
+
phase: "ended",
|
|
226
|
+
...(typeof native.isError === "boolean"
|
|
227
|
+
? { outcome: native.isError ? ("failed" as const) : ("succeeded" as const) }
|
|
228
|
+
: {}),
|
|
229
|
+
};
|
|
230
|
+
if (!item.status?.outcome && JSON.stringify(item.status) !== JSON.stringify(status)) {
|
|
231
|
+
item.status = status;
|
|
232
|
+
this.enqueue({ type: "tool_status_updated", call_id: callId, status });
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
private toolBytes(): number {
|
|
238
|
+
return this.items.reduce(
|
|
239
|
+
(size, item) =>
|
|
240
|
+
item.kind === "tool_call"
|
|
241
|
+
? size +
|
|
242
|
+
jsonBytes([item.arguments, item.managed_tool_use ?? null]) +
|
|
243
|
+
escapedTextBytes(item.output?.text ?? "") +
|
|
244
|
+
resultBytes(item.result)
|
|
245
|
+
: size,
|
|
246
|
+
0,
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
130
250
|
async close(): Promise<void> {
|
|
131
251
|
if (this.closing || this.disabled) return;
|
|
132
252
|
this.closing = true;
|
|
@@ -146,7 +266,13 @@ export class LiveOutputPublisher implements LiveOutputPublisherLike {
|
|
|
146
266
|
|
|
147
267
|
private enqueue(update: LiveOutputUpdate): void {
|
|
148
268
|
this.sequence += 1;
|
|
149
|
-
this.pending.push({ sequence: this.sequence, update });
|
|
269
|
+
this.pending.push({ sequence: this.sequence, update: structuredClone(update) });
|
|
270
|
+
// Bound retries during arbitrarily long disconnects. The snapshot covers
|
|
271
|
+
// everything omitted from the delta queue.
|
|
272
|
+
if (this.pending.length > 256 || jsonBytes(this.pending) > 1024 * 1024) {
|
|
273
|
+
this.pending.length = 0;
|
|
274
|
+
this.needsSnapshot = true;
|
|
275
|
+
}
|
|
150
276
|
this.scheduleFlush(this.batchDelayMs);
|
|
151
277
|
}
|
|
152
278
|
|
|
@@ -193,6 +319,7 @@ export class LiveOutputPublisher implements LiveOutputPublisherLike {
|
|
|
193
319
|
private async flushCurrent(): Promise<boolean> {
|
|
194
320
|
if (this.sequence === 0) return true;
|
|
195
321
|
const targetSequence = this.sequence;
|
|
322
|
+
this.boundSnapshotToFrame();
|
|
196
323
|
const response = this.needsSnapshot
|
|
197
324
|
? await this.publish({
|
|
198
325
|
...this.baseRequest(),
|
|
@@ -211,11 +338,60 @@ export class LiveOutputPublisher implements LiveOutputPublisherLike {
|
|
|
211
338
|
(entry) => entry.sequence > response.accepted_sequence,
|
|
212
339
|
);
|
|
213
340
|
this.pending.splice(0, firstUnaccepted === -1 ? this.pending.length : firstUnaccepted);
|
|
214
|
-
this.needsSnapshot =
|
|
215
|
-
|
|
341
|
+
this.needsSnapshot =
|
|
342
|
+
response.resync_required ||
|
|
343
|
+
(this.sequence > response.accepted_sequence &&
|
|
344
|
+
this.pending[0]?.sequence !== response.accepted_sequence + 1);
|
|
345
|
+
if ((this.pending.length > 0 || this.needsSnapshot) && !this.closing)
|
|
346
|
+
this.scheduleFlush(this.batchDelayMs);
|
|
216
347
|
return !this.needsSnapshot;
|
|
217
348
|
}
|
|
218
349
|
|
|
350
|
+
private boundSnapshotToFrame(): void {
|
|
351
|
+
const excess = () =>
|
|
352
|
+
jsonBytes({
|
|
353
|
+
jsonrpc: "2.0",
|
|
354
|
+
id: `pi:${Number.MAX_SAFE_INTEGER}`,
|
|
355
|
+
method: "liveOutput.publish",
|
|
356
|
+
params: {
|
|
357
|
+
...this.baseRequest(),
|
|
358
|
+
type: "snapshot",
|
|
359
|
+
sequence: this.sequence,
|
|
360
|
+
items: this.items,
|
|
361
|
+
},
|
|
362
|
+
}) - MAX_RPC_FRAME_BYTES;
|
|
363
|
+
if (excess() <= 0) return;
|
|
364
|
+
let changed = false;
|
|
365
|
+
for (const item of this.items) {
|
|
366
|
+
if (item.kind !== "tool_call") continue;
|
|
367
|
+
if (item.output && excess() > 0 && item.output.text) {
|
|
368
|
+
item.output.text = truncateText(
|
|
369
|
+
item.output.text,
|
|
370
|
+
Math.max(0, escapedTextBytes(item.output.text) - excess()),
|
|
371
|
+
);
|
|
372
|
+
item.output.truncated = true;
|
|
373
|
+
changed = true;
|
|
374
|
+
}
|
|
375
|
+
if (item.result) {
|
|
376
|
+
for (const key of ["content", "error"] as const) {
|
|
377
|
+
if (!(key in item.result) || excess() <= 0) continue;
|
|
378
|
+
const value = item.result[key];
|
|
379
|
+
const budget = Math.max(0, jsonBytes(value) - excess() - 2);
|
|
380
|
+
item.result[key] = truncateText(
|
|
381
|
+
typeof value === "string" ? value : JSON.stringify(value),
|
|
382
|
+
budget,
|
|
383
|
+
);
|
|
384
|
+
item.result.truncated = true;
|
|
385
|
+
changed = true;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
if (changed) {
|
|
390
|
+
this.pending.length = 0;
|
|
391
|
+
this.needsSnapshot = true;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
219
395
|
private async publishAppend(targetSequence: number): Promise<LiveOutputResponse | undefined> {
|
|
220
396
|
const updates = this.pending.filter((entry) => entry.sequence <= targetSequence);
|
|
221
397
|
if (updates.length === 0)
|
|
@@ -270,6 +446,46 @@ export class LiveOutputPublisher implements LiveOutputPublisherLike {
|
|
|
270
446
|
}
|
|
271
447
|
}
|
|
272
448
|
|
|
449
|
+
function nativeTruncated(value: unknown): boolean {
|
|
450
|
+
const result = asRecord(value);
|
|
451
|
+
const details = asRecord(result?.details);
|
|
452
|
+
return (
|
|
453
|
+
result?.truncated === true ||
|
|
454
|
+
details?.truncated === true ||
|
|
455
|
+
asRecord(details?.truncation)?.truncated === true ||
|
|
456
|
+
asRecord(result?.structuredContent)?.truncated === true
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function byteLength(text: string): number {
|
|
461
|
+
return Buffer.byteLength(text, "utf8");
|
|
462
|
+
}
|
|
463
|
+
function jsonBytes(value: unknown): number {
|
|
464
|
+
return byteLength(JSON.stringify(value) ?? "");
|
|
465
|
+
}
|
|
466
|
+
function resultBytes(result?: LiveToolResult): number {
|
|
467
|
+
if (!result) return 0;
|
|
468
|
+
return ["content", "error"].reduce((size, key) => {
|
|
469
|
+
if (!(key in result)) return size;
|
|
470
|
+
const value = result[key as "content" | "error"];
|
|
471
|
+
return size + (typeof value === "string" ? escapedTextBytes(value) : jsonBytes(value));
|
|
472
|
+
}, 0);
|
|
473
|
+
}
|
|
474
|
+
function escapedTextBytes(text: string): number {
|
|
475
|
+
return jsonBytes(text) - 2;
|
|
476
|
+
}
|
|
477
|
+
function truncateText(text: string, budget: number): string {
|
|
478
|
+
if (escapedTextBytes(text) <= budget) return text;
|
|
479
|
+
let end = 0;
|
|
480
|
+
let bytes = 0;
|
|
481
|
+
for (const char of text) {
|
|
482
|
+
bytes += escapedTextBytes(char);
|
|
483
|
+
if (bytes > budget) break;
|
|
484
|
+
end += char.length;
|
|
485
|
+
}
|
|
486
|
+
return text.slice(0, end);
|
|
487
|
+
}
|
|
488
|
+
|
|
273
489
|
function managedToolUseFor(toolCall: CompleteToolCall): ManagedToolUse | undefined {
|
|
274
490
|
const input = asRecord(toolCall.arguments);
|
|
275
491
|
if (!input) return undefined;
|