@oh-my-pi/pi-agent-core 17.0.9 → 17.1.1
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/CHANGELOG.md +26 -0
- package/dist/types/compaction/compaction.d.ts +1 -1
- package/dist/types/telemetry.d.ts +19 -1
- package/dist/types/types.d.ts +18 -1
- package/package.json +7 -7
- package/src/agent-loop.ts +213 -23
- package/src/agent.ts +19 -0
- package/src/compaction/compaction.ts +26 -22
- package/src/compaction/openai.ts +153 -15
- package/src/compaction/utils.ts +12 -1
- package/src/telemetry.ts +46 -5
- package/src/types.ts +21 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,32 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [17.1.1] - 2026-07-24
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added the provider-neutral native computer-call lifecycle, preserving observation outputs and input actions across pending and acknowledged tool results.
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- Queued steering no longer hard-aborts non-interruptible tools (e.g. `bash`): it aborts interruptible waits only and raises a cooperative steering signal (`ToolCallContext.steeringSignal`) that long-running tools may observe to finish early or background themselves. The mid-batch steering/IRC watch now runs for every tool batch instead of only batches containing an interruptible tool.
|
|
14
|
+
|
|
15
|
+
## [17.1.0] - 2026-07-24
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- Added support for tracking Cloudflare AI Gateway cache status (hit, miss, bypass, unknown) on chat spans.
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- Improved tool execution steering behavior: queued steering now cooperatively signals long-running, non-interruptible tools (via ToolCallContext.steeringSignal) to allow graceful early termination or backgrounding, rather than hard-aborting them.
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
- Fixed an out-of-memory (OOM) crash caused by an infinite loop when a steer or follow-up message was queued on an agent session with an empty transcript.
|
|
28
|
+
- Fixed an issue where switching providers or models on a session could lose compacted history; the agent now correctly falls back to a portable local summary if the new model cannot replay the prior provider's remote-compaction payload.
|
|
29
|
+
- Fixed a compaction failure with Anthropic models where serializing prior assistant reasoning inside <thinking> tags triggered reasoning_extraction refusals.
|
|
30
|
+
|
|
5
31
|
## [17.0.8] - 2026-07-22
|
|
6
32
|
|
|
7
33
|
### Fixed
|
|
@@ -280,7 +280,7 @@ export interface CompactionPreparation {
|
|
|
280
280
|
/** Compaction settions from settings.jsonl */
|
|
281
281
|
settings: CompactionSettings;
|
|
282
282
|
}
|
|
283
|
-
export declare function prepareCompaction(pathEntries: SessionEntry[], settings: CompactionSettings,
|
|
283
|
+
export declare function prepareCompaction(pathEntries: SessionEntry[], settings: CompactionSettings, activeModel?: Model): CompactionPreparation | undefined;
|
|
284
284
|
/**
|
|
285
285
|
* Generate summaries for compaction using prepared data.
|
|
286
286
|
* Returns CompactionResult - SessionManager adds id/parentId when saving.
|
|
@@ -104,7 +104,9 @@ export declare const enum PiGenAIAttr {
|
|
|
104
104
|
GatewayName = "pi.gen_ai.gateway.name",
|
|
105
105
|
GatewayEndpoint = "pi.gen_ai.gateway.endpoint",
|
|
106
106
|
GatewayCallId = "pi.gen_ai.gateway.call_id",
|
|
107
|
-
GatewayRoutedTo = "pi.gen_ai.gateway.routed_to"
|
|
107
|
+
GatewayRoutedTo = "pi.gen_ai.gateway.routed_to",
|
|
108
|
+
/** Cloudflare AI Gateway response-cache status (`cf-aig-cache-status`), never prompt-cache. */
|
|
109
|
+
GatewayResponseCacheStatus = "pi.gen_ai.gateway.response_cache.status"
|
|
108
110
|
}
|
|
109
111
|
/** GenAI operation names — values for {@link GenAIAttr.OperationName}. */
|
|
110
112
|
export declare const GenAIOperation: {
|
|
@@ -413,6 +415,22 @@ export interface GatewayHeaderDetection {
|
|
|
413
415
|
* {@link ProviderResponseMetadata.headers} produces.
|
|
414
416
|
*/
|
|
415
417
|
export declare function detectGatewayFromHeaders(headers: Readonly<Record<string, string>> | undefined): GatewayHeaderDetection | undefined;
|
|
418
|
+
/**
|
|
419
|
+
* Bounded Cloudflare AI Gateway response-cache statuses emitted on
|
|
420
|
+
* {@link PiGenAIAttr.GatewayResponseCacheStatus}. Distinct from provider
|
|
421
|
+
* prompt-cache token counters (`gen_ai.usage.cache_*`).
|
|
422
|
+
*
|
|
423
|
+
* Cloudflare documents `HIT` / `MISS` on `cf-aig-cache-status`; `bypass` covers
|
|
424
|
+
* skip-cache / CDN-aligned BYPASS values. Any other present value is `unknown`.
|
|
425
|
+
*/
|
|
426
|
+
export type GatewayResponseCacheStatus = "hit" | "miss" | "bypass" | "unknown";
|
|
427
|
+
/**
|
|
428
|
+
* Classify Cloudflare AI Gateway `cf-aig-cache-status` into a bounded
|
|
429
|
+
* response-cache status. Returns `undefined` when the allow-listed header is
|
|
430
|
+
* absent so non-Cloudflare traffic stays unaffected. Does not read TTL,
|
|
431
|
+
* skip-cache, custom-key, or other Cloudflare headers.
|
|
432
|
+
*/
|
|
433
|
+
export declare function classifyGatewayResponseCacheStatus(headers: Readonly<Record<string, string>> | undefined): GatewayResponseCacheStatus | undefined;
|
|
416
434
|
export interface ManualChatToolCallTelemetry {
|
|
417
435
|
readonly toolCallId: string;
|
|
418
436
|
readonly toolName: string;
|
package/dist/types/types.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ApiKey, AssistantMessage, AssistantMessageEvent, AssistantMessageEventStream, Context, Effort, ImageContent, Message, Model, ServiceTier, SimpleStreamOptions, Static, streamSimple, TextContent, Tool, ToolChoice, ToolResultMessage, TSchema } from "@oh-my-pi/pi-ai";
|
|
1
|
+
import type { ApiKey, AssistantMessage, AssistantMessageEvent, AssistantMessageEventStream, Context, Effort, ImageContent, Message, Model, ServiceTier, SimpleStreamOptions, Static, streamSimple, TextContent, Tool, ToolCallProviderMetadata, ToolChoice, ToolResultMessage, ToolResultProviderMetadata, TSchema } from "@oh-my-pi/pi-ai";
|
|
2
2
|
import type { Dialect } from "@oh-my-pi/pi-ai/dialect";
|
|
3
3
|
import type { HarmonyAuditEvent } from "@oh-my-pi/pi-ai/utils/harmony-leak";
|
|
4
4
|
import type { AppendOnlyContextManager } from "./append-only-context.js";
|
|
@@ -399,6 +399,17 @@ export interface ToolCallContext {
|
|
|
399
399
|
id: string;
|
|
400
400
|
name: string;
|
|
401
401
|
}>;
|
|
402
|
+
/** Provider-native metadata for the current call, when present. */
|
|
403
|
+
providerMetadata?: ToolCallProviderMetadata;
|
|
404
|
+
/**
|
|
405
|
+
* Cooperative steering signal: aborted when a queued user/steering message
|
|
406
|
+
* (or an interrupting peer IRC) is detected while this tool batch runs.
|
|
407
|
+
* Unlike the hard abort signal it NEVER kills the tool — long-running
|
|
408
|
+
* tools MAY observe it (via `ctx.toolCall.steeringSignal`) to finish early
|
|
409
|
+
* or background themselves so the message injects promptly; ignoring it is
|
|
410
|
+
* always safe (the message injects at the next batch boundary).
|
|
411
|
+
*/
|
|
412
|
+
steeringSignal?: AbortSignal;
|
|
402
413
|
}
|
|
403
414
|
/** A single tool-call content block emitted by an assistant message. */
|
|
404
415
|
export type AgentToolCall = Extract<AssistantMessage["content"][number], {
|
|
@@ -428,6 +439,8 @@ export interface AfterToolCallResult {
|
|
|
428
439
|
content?: (TextContent | ImageContent)[];
|
|
429
440
|
/** If provided, replaces the tool result details payload in full. */
|
|
430
441
|
details?: unknown;
|
|
442
|
+
/** If provided, replaces the provider-native result metadata in full. */
|
|
443
|
+
providerMetadata?: ToolResultProviderMetadata;
|
|
431
444
|
/** If provided, replaces the error flag carried with the tool result. */
|
|
432
445
|
isError?: boolean;
|
|
433
446
|
/** If provided, replaces the contextually-useless flag carried with the tool result. */
|
|
@@ -503,6 +516,8 @@ export interface AgentToolResult<T = any, _TInput = unknown> {
|
|
|
503
516
|
content: (TextContent | ImageContent)[];
|
|
504
517
|
details?: T;
|
|
505
518
|
isError?: boolean;
|
|
519
|
+
/** Provider-native metadata that must survive into history replay unchanged. */
|
|
520
|
+
providerMetadata?: ToolResultProviderMetadata;
|
|
506
521
|
/** Marks the result as contextually useless: safe for compaction to elide once consumed (e.g. zero matches, wait timeout). Ignored when isError is set. */
|
|
507
522
|
useless?: boolean;
|
|
508
523
|
}
|
|
@@ -533,6 +548,7 @@ export type ToolLoadMode = "essential" | "discoverable";
|
|
|
533
548
|
* - bare tier ("read" / "write" / "exec") — static classification.
|
|
534
549
|
* - object form — adds a `reason` (shown in the prompt) and/or `override: true`
|
|
535
550
|
* (force-prompt even in modes that would otherwise auto-approve this tier).
|
|
551
|
+
* `policy: "deny"` blocks the call at the approval gate.
|
|
536
552
|
* - function — dynamic, given parsed args. Returns either form above.
|
|
537
553
|
*
|
|
538
554
|
* Omitted approvals are treated as "exec" by callers that enforce approvals.
|
|
@@ -541,6 +557,7 @@ export type ToolApprovalDecision = ToolTier | {
|
|
|
541
557
|
tier: ToolTier;
|
|
542
558
|
reason?: string;
|
|
543
559
|
override?: boolean;
|
|
560
|
+
policy?: "allow" | "deny" | "prompt";
|
|
544
561
|
};
|
|
545
562
|
export type ToolApproval = ToolApprovalDecision | ((args: unknown) => ToolApprovalDecision);
|
|
546
563
|
/**
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-agent-core",
|
|
4
|
-
"version": "17.
|
|
4
|
+
"version": "17.1.1",
|
|
5
5
|
"description": "General-purpose agent with transport abstraction, state management, and attachment support",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -35,12 +35,12 @@
|
|
|
35
35
|
"fmt": "biome format --write ."
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@oh-my-pi/pi-ai": "17.
|
|
39
|
-
"@oh-my-pi/pi-catalog": "17.
|
|
40
|
-
"@oh-my-pi/pi-natives": "17.
|
|
41
|
-
"@oh-my-pi/pi-utils": "17.
|
|
42
|
-
"@oh-my-pi/pi-wire": "17.
|
|
43
|
-
"@oh-my-pi/snapcompact": "17.
|
|
38
|
+
"@oh-my-pi/pi-ai": "17.1.1",
|
|
39
|
+
"@oh-my-pi/pi-catalog": "17.1.1",
|
|
40
|
+
"@oh-my-pi/pi-natives": "17.1.1",
|
|
41
|
+
"@oh-my-pi/pi-utils": "17.1.1",
|
|
42
|
+
"@oh-my-pi/pi-wire": "17.1.1",
|
|
43
|
+
"@oh-my-pi/snapcompact": "17.1.1",
|
|
44
44
|
"@opentelemetry/api": "^1.9.1"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
package/src/agent-loop.ts
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
import {
|
|
6
6
|
type AssistantMessage,
|
|
7
7
|
type AssistantMessageEvent,
|
|
8
|
+
type ComputerAction,
|
|
9
|
+
type ComputerSafetyCheck,
|
|
8
10
|
type Context,
|
|
9
11
|
EventStream,
|
|
10
12
|
isApiKeyResolver,
|
|
@@ -12,8 +14,10 @@ import {
|
|
|
12
14
|
seedApiKeyResolver,
|
|
13
15
|
streamSimple,
|
|
14
16
|
stripSchemaDescriptions,
|
|
17
|
+
type ToolCallProviderMetadata,
|
|
15
18
|
type ToolChoice,
|
|
16
19
|
type ToolResultMessage,
|
|
20
|
+
type ToolResultProviderMetadata,
|
|
17
21
|
type TSchema,
|
|
18
22
|
toolWireSchema,
|
|
19
23
|
validateToolArguments,
|
|
@@ -106,6 +110,7 @@ const MAX_SOFT_TOOL_ESCALATIONS = 3;
|
|
|
106
110
|
function hardToolChoiceBlocks(choice: ToolChoice | undefined, requiredTool: string): boolean {
|
|
107
111
|
if (choice === undefined) return false;
|
|
108
112
|
if (typeof choice === "string") return choice === "none";
|
|
113
|
+
if (choice.type === "computer") return requiredTool !== "computer";
|
|
109
114
|
const name = choice.type === "tool" ? choice.name : "function" in choice ? choice.function.name : choice.name;
|
|
110
115
|
return name !== requiredTool;
|
|
111
116
|
}
|
|
@@ -180,6 +185,160 @@ export function resolveOwnedDialectFromEnv(value: string | undefined): Dialect |
|
|
|
180
185
|
type AssistantContentBlock = AssistantMessage["content"][number];
|
|
181
186
|
type AssistantToolCallBlock = Extract<AssistantContentBlock, { type: "toolCall" }>;
|
|
182
187
|
|
|
188
|
+
function snapshotComputerSafetyChecks(value: unknown): ComputerSafetyCheck[] | undefined {
|
|
189
|
+
if (!Array.isArray(value)) return undefined;
|
|
190
|
+
const checks: ComputerSafetyCheck[] = [];
|
|
191
|
+
for (const raw of value) {
|
|
192
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
|
|
193
|
+
const check = raw as Record<string, unknown>;
|
|
194
|
+
if (typeof check.id !== "string" || check.id.length === 0) return undefined;
|
|
195
|
+
if (check.code !== undefined && check.code !== null && typeof check.code !== "string") return undefined;
|
|
196
|
+
if (check.message !== undefined && check.message !== null && typeof check.message !== "string") return undefined;
|
|
197
|
+
checks.push({
|
|
198
|
+
id: check.id,
|
|
199
|
+
...(check.code !== undefined ? { code: check.code as string | null } : {}),
|
|
200
|
+
...(check.message !== undefined ? { message: check.message as string | null } : {}),
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
return checks;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function isFiniteCoordinate(value: unknown): value is number {
|
|
207
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function hasValidComputerKeys(value: unknown, optional: boolean): boolean {
|
|
211
|
+
return (
|
|
212
|
+
(optional && value === undefined) ||
|
|
213
|
+
value === null ||
|
|
214
|
+
(Array.isArray(value) && value.every(key => typeof key === "string"))
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function snapshotComputerAction(value: unknown): ComputerAction | undefined {
|
|
219
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
220
|
+
const action = value as Record<string, unknown>;
|
|
221
|
+
switch (action.type) {
|
|
222
|
+
case "click":
|
|
223
|
+
if (
|
|
224
|
+
!(["left", "right", "wheel", "back", "forward"] as unknown[]).includes(action.button) ||
|
|
225
|
+
!isFiniteCoordinate(action.x) ||
|
|
226
|
+
!isFiniteCoordinate(action.y) ||
|
|
227
|
+
!hasValidComputerKeys(action.keys, true)
|
|
228
|
+
)
|
|
229
|
+
return undefined;
|
|
230
|
+
break;
|
|
231
|
+
case "double_click":
|
|
232
|
+
if (
|
|
233
|
+
!isFiniteCoordinate(action.x) ||
|
|
234
|
+
!isFiniteCoordinate(action.y) ||
|
|
235
|
+
!hasValidComputerKeys(action.keys, false)
|
|
236
|
+
)
|
|
237
|
+
return undefined;
|
|
238
|
+
break;
|
|
239
|
+
case "drag":
|
|
240
|
+
if (
|
|
241
|
+
!Array.isArray(action.path) ||
|
|
242
|
+
!action.path.every(
|
|
243
|
+
point =>
|
|
244
|
+
point &&
|
|
245
|
+
typeof point === "object" &&
|
|
246
|
+
isFiniteCoordinate((point as Record<string, unknown>).x) &&
|
|
247
|
+
isFiniteCoordinate((point as Record<string, unknown>).y),
|
|
248
|
+
) ||
|
|
249
|
+
!hasValidComputerKeys(action.keys, true)
|
|
250
|
+
)
|
|
251
|
+
return undefined;
|
|
252
|
+
break;
|
|
253
|
+
case "keypress":
|
|
254
|
+
if (!Array.isArray(action.keys) || !action.keys.every(key => typeof key === "string")) return undefined;
|
|
255
|
+
break;
|
|
256
|
+
case "move":
|
|
257
|
+
if (!isFiniteCoordinate(action.x) || !isFiniteCoordinate(action.y) || !hasValidComputerKeys(action.keys, true))
|
|
258
|
+
return undefined;
|
|
259
|
+
break;
|
|
260
|
+
case "screenshot":
|
|
261
|
+
case "wait":
|
|
262
|
+
break;
|
|
263
|
+
case "scroll":
|
|
264
|
+
if (
|
|
265
|
+
!isFiniteCoordinate(action.x) ||
|
|
266
|
+
!isFiniteCoordinate(action.y) ||
|
|
267
|
+
!isFiniteCoordinate(action.scroll_x) ||
|
|
268
|
+
!isFiniteCoordinate(action.scroll_y) ||
|
|
269
|
+
!hasValidComputerKeys(action.keys, true)
|
|
270
|
+
)
|
|
271
|
+
return undefined;
|
|
272
|
+
break;
|
|
273
|
+
case "type":
|
|
274
|
+
if (typeof action.text !== "string") return undefined;
|
|
275
|
+
break;
|
|
276
|
+
default:
|
|
277
|
+
return undefined;
|
|
278
|
+
}
|
|
279
|
+
return structuredCloneJSON(action) as ComputerAction;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function snapshotToolCallProviderMetadata(value: unknown): ToolCallProviderMetadata | undefined {
|
|
283
|
+
if (value === undefined) return undefined;
|
|
284
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
285
|
+
const metadata = value as Record<string, unknown>;
|
|
286
|
+
if (
|
|
287
|
+
metadata.type !== "computer" ||
|
|
288
|
+
typeof metadata.providerItemId !== "string" ||
|
|
289
|
+
metadata.providerItemId.length === 0
|
|
290
|
+
)
|
|
291
|
+
return undefined;
|
|
292
|
+
if (!Array.isArray(metadata.actions) || metadata.actions.length === 0) return undefined;
|
|
293
|
+
const actions = metadata.actions.map(snapshotComputerAction);
|
|
294
|
+
if (actions.some(action => action === undefined)) return undefined;
|
|
295
|
+
const pendingSafetyChecks = snapshotComputerSafetyChecks(metadata.pendingSafetyChecks);
|
|
296
|
+
if (!pendingSafetyChecks) return undefined;
|
|
297
|
+
return {
|
|
298
|
+
type: "computer",
|
|
299
|
+
providerItemId: metadata.providerItemId,
|
|
300
|
+
actions: actions as ComputerAction[],
|
|
301
|
+
pendingSafetyChecks,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function snapshotToolResultProviderMetadata(value: unknown): {
|
|
306
|
+
metadata?: ToolResultProviderMetadata;
|
|
307
|
+
malformed: boolean;
|
|
308
|
+
} {
|
|
309
|
+
if (value === undefined) return { malformed: false };
|
|
310
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return { malformed: true };
|
|
311
|
+
const metadata = value as Record<string, unknown>;
|
|
312
|
+
if (
|
|
313
|
+
metadata.type !== "computer" ||
|
|
314
|
+
!metadata.screenshot ||
|
|
315
|
+
typeof metadata.screenshot !== "object" ||
|
|
316
|
+
Array.isArray(metadata.screenshot)
|
|
317
|
+
) {
|
|
318
|
+
return { malformed: true };
|
|
319
|
+
}
|
|
320
|
+
const screenshot = metadata.screenshot as Record<string, unknown>;
|
|
321
|
+
const hasImageUrl = Object.hasOwn(screenshot, "image_url");
|
|
322
|
+
const hasFileId = Object.hasOwn(screenshot, "file_id");
|
|
323
|
+
if (screenshot.type !== "computer_screenshot" || hasImageUrl === hasFileId) return { malformed: true };
|
|
324
|
+
if (hasImageUrl && (typeof screenshot.image_url !== "string" || screenshot.image_url.length === 0))
|
|
325
|
+
return { malformed: true };
|
|
326
|
+
if (hasFileId && (typeof screenshot.file_id !== "string" || screenshot.file_id.length === 0))
|
|
327
|
+
return { malformed: true };
|
|
328
|
+
const acknowledgedSafetyChecks = snapshotComputerSafetyChecks(metadata.acknowledgedSafetyChecks);
|
|
329
|
+
if (!acknowledgedSafetyChecks) return { malformed: true };
|
|
330
|
+
return {
|
|
331
|
+
malformed: false,
|
|
332
|
+
metadata: {
|
|
333
|
+
type: "computer",
|
|
334
|
+
screenshot: hasImageUrl
|
|
335
|
+
? { type: "computer_screenshot", image_url: screenshot.image_url as string }
|
|
336
|
+
: { type: "computer_screenshot", file_id: screenshot.file_id as string },
|
|
337
|
+
acknowledgedSafetyChecks,
|
|
338
|
+
},
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
183
342
|
function snapshotAssistantContentBlock(block: AssistantContentBlock): AssistantContentBlock {
|
|
184
343
|
switch (block.type) {
|
|
185
344
|
case "text":
|
|
@@ -192,7 +351,11 @@ function snapshotAssistantContentBlock(block: AssistantContentBlock): AssistantC
|
|
|
192
351
|
case "fallback":
|
|
193
352
|
return { ...block, from: { ...block.from }, to: { ...block.to } };
|
|
194
353
|
case "toolCall":
|
|
195
|
-
return {
|
|
354
|
+
return {
|
|
355
|
+
...block,
|
|
356
|
+
arguments: structuredCloneJSON(block.arguments),
|
|
357
|
+
providerMetadata: snapshotToolCallProviderMetadata(block.providerMetadata),
|
|
358
|
+
};
|
|
196
359
|
}
|
|
197
360
|
}
|
|
198
361
|
|
|
@@ -268,6 +431,10 @@ function coerceToolResult(raw: unknown): { result: AgentToolResult<unknown>; mal
|
|
|
268
431
|
const rawObj = raw && typeof raw === "object" ? (raw as Record<string, unknown>) : null;
|
|
269
432
|
const rawContent = rawObj?.content;
|
|
270
433
|
const details = rawObj && "details" in rawObj ? rawObj.details : {};
|
|
434
|
+
const providerMetadataResult = snapshotToolResultProviderMetadata(
|
|
435
|
+
rawObj && "providerMetadata" in rawObj ? rawObj.providerMetadata : undefined,
|
|
436
|
+
);
|
|
437
|
+
const providerMetadata = providerMetadataResult.metadata;
|
|
271
438
|
// Tools may flag a non-throwing failure on the result itself (e.g. an
|
|
272
439
|
// aggregator that catches per-entry errors and synthesizes a combined
|
|
273
440
|
// result). Preserve the flag so agent-loop can surface it on the wire.
|
|
@@ -312,7 +479,13 @@ function coerceToolResult(raw: unknown): { result: AgentToolResult<unknown>; mal
|
|
|
312
479
|
text: `Tool returned an invalid result: ${invalidBlocks} content block${invalidBlocks === 1 ? "" : "s"} had an unsupported shape.`,
|
|
313
480
|
});
|
|
314
481
|
}
|
|
315
|
-
|
|
482
|
+
if (providerMetadataResult.malformed) {
|
|
483
|
+
content.push({
|
|
484
|
+
type: "text",
|
|
485
|
+
text: "Tool returned an invalid result: computer providerMetadata had an unsupported shape.",
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
const isError = explicitError || invalidBlocks > 0 || providerMetadataResult.malformed;
|
|
316
489
|
// Anthropic rejects tool_result blocks with is_error: true and empty content.
|
|
317
490
|
if (isError && !hasSubstantiveToolResultContent(content)) {
|
|
318
491
|
content.length = 0;
|
|
@@ -322,10 +495,11 @@ function coerceToolResult(raw: unknown): { result: AgentToolResult<unknown>; mal
|
|
|
322
495
|
result: {
|
|
323
496
|
content,
|
|
324
497
|
details,
|
|
498
|
+
providerMetadata,
|
|
325
499
|
...(isError ? { isError: true } : {}),
|
|
326
500
|
...(useless && !isError ? { useless: true } : {}),
|
|
327
501
|
},
|
|
328
|
-
malformed: invalidBlocks > 0,
|
|
502
|
+
malformed: invalidBlocks > 0 || providerMetadataResult.malformed,
|
|
329
503
|
};
|
|
330
504
|
}
|
|
331
505
|
|
|
@@ -1817,13 +1991,18 @@ async function executeToolCalls(
|
|
|
1817
1991
|
const shouldInterruptImmediately = interruptMode !== "wait";
|
|
1818
1992
|
const steeringAbortController = new AbortController();
|
|
1819
1993
|
const ircAbortController = new AbortController();
|
|
1820
|
-
//
|
|
1821
|
-
//
|
|
1822
|
-
//
|
|
1823
|
-
//
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1994
|
+
// Cooperative channel: aborted when queued steering (or an interrupting
|
|
1995
|
+
// peer IRC) is detected mid-batch. Tools receive it via tool context
|
|
1996
|
+
// (`ctx.steeringSignal`) and MAY react — e.g. an auto-backgroundable bash
|
|
1997
|
+
// backgrounds itself so the message injects promptly — but it never kills
|
|
1998
|
+
// anything; ignoring it is always safe.
|
|
1999
|
+
const steeringSoftController = new AbortController();
|
|
2000
|
+
// Interruptible tools (pure waits: hub wait, vibe) observe steering +
|
|
2001
|
+
// external + IRC aborts. Every other tool sees ONLY the external signal:
|
|
2002
|
+
// neither queued steering nor a peer IRC ever hard-kills a partially
|
|
2003
|
+
// side-effecting foreground tool (e.g. `bash`) — those get the cooperative
|
|
2004
|
+
// steeringSignal above, and the message injects at the next boundary.
|
|
2005
|
+
const nonInterruptibleSignal: AbortSignal = signal ?? new AbortController().signal;
|
|
1827
2006
|
const interruptibleSignal: AbortSignal = signal
|
|
1828
2007
|
? AbortSignal.any([signal, steeringAbortController.signal, ircAbortController.signal])
|
|
1829
2008
|
: AbortSignal.any([steeringAbortController.signal, ircAbortController.signal]);
|
|
@@ -1889,13 +2068,16 @@ async function executeToolCalls(
|
|
|
1889
2068
|
}
|
|
1890
2069
|
}
|
|
1891
2070
|
if (steeringQueued) {
|
|
1892
|
-
// Queued steering
|
|
1893
|
-
//
|
|
2071
|
+
// Queued steering hard-aborts only interruptible waits and raises the
|
|
2072
|
+
// cooperative soft signal for everything else: the boundary dequeue
|
|
2073
|
+
// below injects the message as soon as running tools finish (or
|
|
2074
|
+
// background themselves), and not-yet-started tools are skipped.
|
|
1894
2075
|
// Idempotent — a second steer poll after the abort is a no-op.
|
|
1895
2076
|
if (!steeringAbortController.signal.aborted) {
|
|
1896
2077
|
interruptState.triggered = true;
|
|
1897
2078
|
interruptState.source = steeringSource ?? "unknown";
|
|
1898
2079
|
steeringAbortController.abort();
|
|
2080
|
+
steeringSoftController.abort();
|
|
1899
2081
|
}
|
|
1900
2082
|
return;
|
|
1901
2083
|
}
|
|
@@ -1903,11 +2085,13 @@ async function executeToolCalls(
|
|
|
1903
2085
|
// must not re-abort, and (unlike steering above) never re-consume a queue.
|
|
1904
2086
|
if (interruptState.triggered) return;
|
|
1905
2087
|
if (hasIrcInterrupts && (await hasIrcInterrupts())) {
|
|
1906
|
-
// Peer IRC
|
|
1907
|
-
//
|
|
2088
|
+
// Peer IRC hard-aborts interruptible waits only; foreground tools keep
|
|
2089
|
+
// running (no partial side effects) but get the cooperative soft
|
|
2090
|
+
// signal so backgroundable work can step aside for the peer message.
|
|
1908
2091
|
interruptState.triggered = true;
|
|
1909
2092
|
interruptState.source = "irc";
|
|
1910
2093
|
ircAbortController.abort();
|
|
2094
|
+
steeringSoftController.abort();
|
|
1911
2095
|
}
|
|
1912
2096
|
};
|
|
1913
2097
|
|
|
@@ -1937,6 +2121,7 @@ async function executeToolCalls(
|
|
|
1937
2121
|
toolName: toolCall.name,
|
|
1938
2122
|
content: result.content,
|
|
1939
2123
|
details: result.details,
|
|
2124
|
+
providerMetadata: result.providerMetadata,
|
|
1940
2125
|
isError,
|
|
1941
2126
|
...(result.useless && !isError ? { useless: true } : {}),
|
|
1942
2127
|
timestamp: Date.now(),
|
|
@@ -2090,12 +2275,18 @@ async function executeToolCalls(
|
|
|
2090
2275
|
: effectiveArgs;
|
|
2091
2276
|
record.args = executionArgs;
|
|
2092
2277
|
|
|
2278
|
+
// The cooperative steering signal rides the loop-owned
|
|
2279
|
+
// ToolCallContext (surfacing as `ctx.toolCall.steeringSignal`):
|
|
2280
|
+
// AgentToolContext itself is app-built via declaration merging, so
|
|
2281
|
+
// the loop cannot construct or extend one structurally.
|
|
2093
2282
|
const toolContext = getToolContext
|
|
2094
2283
|
? getToolContext({
|
|
2095
2284
|
batchId,
|
|
2096
2285
|
index,
|
|
2097
2286
|
total: toolCalls.length,
|
|
2098
2287
|
toolCalls: toolCallInfos,
|
|
2288
|
+
steeringSignal: steeringSoftController.signal,
|
|
2289
|
+
providerMetadata: toolCall.providerMetadata,
|
|
2099
2290
|
})
|
|
2100
2291
|
: undefined;
|
|
2101
2292
|
const rawResult = await tool.execute(
|
|
@@ -2148,6 +2339,7 @@ async function executeToolCalls(
|
|
|
2148
2339
|
content: after.content ?? result.content,
|
|
2149
2340
|
details: after.details ?? result.details,
|
|
2150
2341
|
isError: after.isError ?? result.isError,
|
|
2342
|
+
providerMetadata: after.providerMetadata ?? result.providerMetadata,
|
|
2151
2343
|
useless: after.useless ?? result.useless,
|
|
2152
2344
|
});
|
|
2153
2345
|
result = coerced.result;
|
|
@@ -2237,16 +2429,14 @@ async function executeToolCalls(
|
|
|
2237
2429
|
}
|
|
2238
2430
|
}
|
|
2239
2431
|
|
|
2240
|
-
// While
|
|
2241
|
-
//
|
|
2242
|
-
//
|
|
2243
|
-
//
|
|
2244
|
-
// promptly. Gated on immediate-interrupt
|
|
2245
|
-
// checkSteering is idempotent (no-op once triggered).
|
|
2432
|
+
// While tool calls are in flight, queued steering or interrupting IRC would
|
|
2433
|
+
// otherwise wait out the tools' own window. Poll only non-consuming queues:
|
|
2434
|
+
// detection hard-aborts interruptible waits, soft-signals cooperative tools
|
|
2435
|
+
// (auto-background bash), and skips not-yet-started tools, so the boundary
|
|
2436
|
+
// dequeue below injects the message promptly. Gated on immediate-interrupt
|
|
2437
|
+
// mode; checkSteering is idempotent (no-op once triggered).
|
|
2246
2438
|
const watchSteeringWhileRunning =
|
|
2247
|
-
shouldInterruptImmediately &&
|
|
2248
|
-
(hasSteeringMessages !== undefined || hasIrcInterrupts !== undefined) &&
|
|
2249
|
-
records.some(record => record.interruptible);
|
|
2439
|
+
shouldInterruptImmediately && (hasSteeringMessages !== undefined || hasIrcInterrupts !== undefined);
|
|
2250
2440
|
const steeringWatchTimer = watchSteeringWhileRunning
|
|
2251
2441
|
? setInterval(() => void checkSteering(), STEERING_INTERRUPT_POLL_MS)
|
|
2252
2442
|
: undefined;
|
package/src/agent.ts
CHANGED
|
@@ -72,6 +72,9 @@ function refreshToolChoiceForActiveTools(
|
|
|
72
72
|
if (!toolChoice || typeof toolChoice === "string") {
|
|
73
73
|
return toolChoice;
|
|
74
74
|
}
|
|
75
|
+
if (toolChoice.type === "computer") {
|
|
76
|
+
return tools.some(tool => tool.native?.type === "computer") ? toolChoice : undefined;
|
|
77
|
+
}
|
|
75
78
|
|
|
76
79
|
const toolName =
|
|
77
80
|
toolChoice.type === "tool"
|
|
@@ -1047,6 +1050,22 @@ export class Agent {
|
|
|
1047
1050
|
|
|
1048
1051
|
const messages = this.#state.messages;
|
|
1049
1052
|
if (messages.length === 0) {
|
|
1053
|
+
// An empty transcript has nothing to resume, but a queued steer/follow-up
|
|
1054
|
+
// must still be delivered as the opening turn — mirroring the assistant-tail
|
|
1055
|
+
// branch below. Throwing here leaves the message undeliverable, and idle-drain
|
|
1056
|
+
// callers (AgentSession#scheduleQueuedMessageDrain) re-arm continue() on every
|
|
1057
|
+
// microtask because hasQueuedMessages() never clears, spinning an unbounded
|
|
1058
|
+
// allocation loop until OOM (issue #6344).
|
|
1059
|
+
const queuedSteering = this.#dequeueSteeringMessages();
|
|
1060
|
+
if (queuedSteering.length > 0) {
|
|
1061
|
+
await this.#runLoop(queuedSteering, { skipInitialSteeringPoll: true });
|
|
1062
|
+
return;
|
|
1063
|
+
}
|
|
1064
|
+
const queuedFollowUp = this.#dequeueFollowUpMessages();
|
|
1065
|
+
if (queuedFollowUp.length > 0) {
|
|
1066
|
+
await this.#runLoop(queuedFollowUp);
|
|
1067
|
+
return;
|
|
1068
|
+
}
|
|
1050
1069
|
throw new Error("No messages to continue from");
|
|
1051
1070
|
}
|
|
1052
1071
|
if (messages[messages.length - 1].role === "assistant") {
|
|
@@ -1130,33 +1130,37 @@ export interface CompactionPreparation {
|
|
|
1130
1130
|
}
|
|
1131
1131
|
|
|
1132
1132
|
/**
|
|
1133
|
-
* Whether a prior compaction's
|
|
1134
|
-
*
|
|
1135
|
-
*
|
|
1136
|
-
*
|
|
1137
|
-
*
|
|
1138
|
-
*
|
|
1133
|
+
* Whether a prior remote compaction's provider-native replay can still be read
|
|
1134
|
+
* by the active model — the model that assembles the request context on every
|
|
1135
|
+
* turn. A local compaction (no remote preserve) always can: it holds a real
|
|
1136
|
+
* textual summary. A remote compaction (V2 or V1) only can when the active model
|
|
1137
|
+
* shares the blob's provider AND remote replay is still enabled; otherwise the
|
|
1138
|
+
* active model's encoder drops the payload (see `getOpenAIResponsesHistoryPayload`)
|
|
1139
|
+
* and only the opaque placeholder summary survives, so the caller must re-expand
|
|
1140
|
+
* the originals into a portable local summary rather than strand that history.
|
|
1141
|
+
*
|
|
1142
|
+
* Judged against the ACTIVE model, not the compaction candidate set: a role
|
|
1143
|
+
* model (e.g. `modelRoles.smol`) that still maps to the blob's provider does not
|
|
1144
|
+
* let the active model replay it, so keying reuse on "any candidate shares the
|
|
1145
|
+
* provider" left a provider-switched session permanently context-less (#6343).
|
|
1139
1146
|
*/
|
|
1140
|
-
function
|
|
1147
|
+
function remotePreserveReusable(
|
|
1141
1148
|
preserveData: Record<string, unknown> | undefined,
|
|
1142
|
-
|
|
1149
|
+
activeModel: Model,
|
|
1143
1150
|
settings: CompactionSettings,
|
|
1144
1151
|
): boolean {
|
|
1145
1152
|
const remote = getCompactionV2PreserveData(preserveData) ?? getPreservedOpenAiRemoteCompactionData(preserveData);
|
|
1146
1153
|
if (!remote) return true;
|
|
1147
1154
|
if (settings.remoteEnabled === false) return false;
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
if (v2Ok || shouldUseOpenAiRemoteCompaction(model)) return true;
|
|
1152
|
-
}
|
|
1153
|
-
return false;
|
|
1155
|
+
if (remote.provider !== activeModel.provider) return false;
|
|
1156
|
+
const v2Ok = settings.remoteStreamingV2Enabled !== false && shouldUseCompactionV2Streaming(activeModel);
|
|
1157
|
+
return v2Ok || shouldUseOpenAiRemoteCompaction(activeModel);
|
|
1154
1158
|
}
|
|
1155
1159
|
|
|
1156
1160
|
export function prepareCompaction(
|
|
1157
1161
|
pathEntries: SessionEntry[],
|
|
1158
1162
|
settings: CompactionSettings,
|
|
1159
|
-
|
|
1163
|
+
activeModel?: Model,
|
|
1160
1164
|
): CompactionPreparation | undefined {
|
|
1161
1165
|
if (pathEntries.length > 0 && pathEntries[pathEntries.length - 1].type === "compaction") {
|
|
1162
1166
|
return undefined;
|
|
@@ -1165,13 +1169,13 @@ export function prepareCompaction(
|
|
|
1165
1169
|
let prevCompactionIndex = -1;
|
|
1166
1170
|
for (let i = pathEntries.length - 1; i >= 0; i--) {
|
|
1167
1171
|
if (pathEntries[i].type !== "compaction") continue;
|
|
1168
|
-
// Skip a prior remote compaction (V2 or V1) whose provider-native replay
|
|
1169
|
-
//
|
|
1170
|
-
//
|
|
1171
|
-
//
|
|
1172
|
-
//
|
|
1172
|
+
// Skip a prior remote compaction (V2 or V1) whose provider-native replay the
|
|
1173
|
+
// active model cannot read: its summary is only an opaque placeholder, so
|
|
1174
|
+
// re-expand its original messages and summarize them locally rather than
|
|
1175
|
+
// stranding that history. compact() still reuses the payload when the active
|
|
1176
|
+
// model can replay it (same provider, remote enabled).
|
|
1173
1177
|
const entry = pathEntries[i] as CompactionEntry;
|
|
1174
|
-
if (
|
|
1178
|
+
if (activeModel && !remotePreserveReusable(entry.preserveData, activeModel, settings)) {
|
|
1175
1179
|
continue;
|
|
1176
1180
|
}
|
|
1177
1181
|
prevCompactionIndex = i;
|
|
@@ -1502,7 +1506,7 @@ export async function compact(
|
|
|
1502
1506
|
// summarization so a successful remote compaction never pays for a second,
|
|
1503
1507
|
// redundant LLM round. If a LATER compaction cannot reuse this payload,
|
|
1504
1508
|
// prepareCompaction re-expands the original messages and summarizes them
|
|
1505
|
-
// locally then (see
|
|
1509
|
+
// locally then (see remotePreserveReusable).
|
|
1506
1510
|
const usedTokens = getCompactionV2PreserveData(preserveData)?.usedTokens ?? 0;
|
|
1507
1511
|
summary =
|
|
1508
1512
|
"Remote compaction preserved provider-native history for this session." +
|
package/src/compaction/openai.ts
CHANGED
|
@@ -20,6 +20,7 @@ import { applyCodexResponsesLiteShape } from "@oh-my-pi/pi-ai/providers/openai-c
|
|
|
20
20
|
import {
|
|
21
21
|
createOpenAICodexCompactionRequestContext,
|
|
22
22
|
createOpenAICodexCompatibilityMetadata,
|
|
23
|
+
getCodexAttestationHeader,
|
|
23
24
|
} from "@oh-my-pi/pi-ai/providers/openai-codex-responses";
|
|
24
25
|
import { parseAzureDeploymentNameMap, parseTextSignature } from "@oh-my-pi/pi-ai/providers/openai-shared";
|
|
25
26
|
import { transformMessages } from "@oh-my-pi/pi-ai/providers/transform-messages";
|
|
@@ -43,7 +44,7 @@ import {
|
|
|
43
44
|
OPENAI_HEADER_VALUES,
|
|
44
45
|
OPENAI_HEADERS,
|
|
45
46
|
} from "@oh-my-pi/pi-catalog/wire/codex";
|
|
46
|
-
import { $env, logger, stringifyJson } from "@oh-my-pi/pi-utils";
|
|
47
|
+
import { $env, logger, stringifyJson, structuredCloneJSON } from "@oh-my-pi/pi-utils";
|
|
47
48
|
|
|
48
49
|
export * from "./compaction-v2-streaming";
|
|
49
50
|
|
|
@@ -254,6 +255,7 @@ function addOpenAiCallIds(
|
|
|
254
255
|
items: Array<Record<string, unknown>>,
|
|
255
256
|
knownCallIds: Set<string>,
|
|
256
257
|
customCallIds: Set<string>,
|
|
258
|
+
computerCallIds: Set<string>,
|
|
257
259
|
): void {
|
|
258
260
|
for (const item of items) {
|
|
259
261
|
if (typeof item.call_id !== "string") continue;
|
|
@@ -262,10 +264,57 @@ function addOpenAiCallIds(
|
|
|
262
264
|
} else if (item.type === "custom_tool_call") {
|
|
263
265
|
knownCallIds.add(item.call_id);
|
|
264
266
|
customCallIds.add(item.call_id);
|
|
267
|
+
} else if (item.type === "computer_call") {
|
|
268
|
+
knownCallIds.add(item.call_id);
|
|
269
|
+
computerCallIds.add(item.call_id);
|
|
265
270
|
}
|
|
266
271
|
}
|
|
267
272
|
}
|
|
268
273
|
|
|
274
|
+
function computerHistoryNote(item: Record<string, unknown>): Record<string, unknown> {
|
|
275
|
+
const serialized = stringifyJson(item) ?? "";
|
|
276
|
+
return {
|
|
277
|
+
type: "message",
|
|
278
|
+
id: `msg_${Bun.hash(`computer-history:${serialized}`).toString(36)}`,
|
|
279
|
+
role: "assistant",
|
|
280
|
+
content: [
|
|
281
|
+
{
|
|
282
|
+
type: "output_text",
|
|
283
|
+
text: `[Previous computer history unavailable to this model]: ${serialized}`,
|
|
284
|
+
annotations: [],
|
|
285
|
+
},
|
|
286
|
+
],
|
|
287
|
+
status: "completed",
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function adaptComputerHistoryForCompaction(
|
|
292
|
+
items: Array<Record<string, unknown>>,
|
|
293
|
+
supportsComputerUse: boolean,
|
|
294
|
+
): Array<Record<string, unknown>> {
|
|
295
|
+
if (supportsComputerUse) return items;
|
|
296
|
+
return items.map(item =>
|
|
297
|
+
item.type === "computer_call" || item.type === "computer_call_output" ? computerHistoryNote(item) : item,
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function computerFailureNote(call: Record<string, unknown>, output: string): Record<string, unknown> {
|
|
302
|
+
const serialized = stringifyJson(call) ?? "";
|
|
303
|
+
return {
|
|
304
|
+
type: "message",
|
|
305
|
+
id: `msg_${Bun.hash(`computer-failure:${serialized}:${output}`).toString(36)}`,
|
|
306
|
+
role: "assistant",
|
|
307
|
+
content: [
|
|
308
|
+
{
|
|
309
|
+
type: "output_text",
|
|
310
|
+
text: `[Computer call failed before a screenshot was recorded]: ${serialized}${output ? `\n${output}` : ""}`,
|
|
311
|
+
annotations: [],
|
|
312
|
+
},
|
|
313
|
+
],
|
|
314
|
+
status: "completed",
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
269
318
|
// ============================================================================
|
|
270
319
|
// Native history construction (responses-API shape)
|
|
271
320
|
// ============================================================================
|
|
@@ -287,20 +336,32 @@ export function buildOpenAiNativeHistory(
|
|
|
287
336
|
model: Model,
|
|
288
337
|
previousReplacementHistory?: Array<Record<string, unknown>>,
|
|
289
338
|
): Array<Record<string, unknown>> {
|
|
290
|
-
const input: Array<Record<string, unknown>> = previousReplacementHistory
|
|
339
|
+
const input: Array<Record<string, unknown>> = previousReplacementHistory
|
|
340
|
+
? adaptComputerHistoryForCompaction([...previousReplacementHistory], model.supportsComputerUse === true)
|
|
341
|
+
: [];
|
|
291
342
|
const transformedMessages = transformMessages(messages, model, id => normalizeOpenAiCompactionToolCallId(id));
|
|
292
343
|
|
|
293
344
|
let msgIndex = 0;
|
|
294
345
|
const knownCallIds = new Set<string>();
|
|
295
346
|
const customCallIds = new Set<string>();
|
|
296
|
-
|
|
347
|
+
const computerCallIds = new Set<string>();
|
|
348
|
+
const demotedComputerCallIds = new Set<string>();
|
|
349
|
+
addOpenAiCallIds(input, knownCallIds, customCallIds, computerCallIds);
|
|
297
350
|
for (const message of transformedMessages) {
|
|
298
351
|
if (message.role === "user" || message.role === "developer") {
|
|
299
352
|
const providerPayload = (message as { providerPayload?: AssistantMessage["providerPayload"] }).providerPayload;
|
|
300
|
-
const
|
|
301
|
-
if (
|
|
353
|
+
const rawHistoryItems = getOpenAIResponsesHistoryItems(providerPayload, model.provider);
|
|
354
|
+
if (rawHistoryItems) {
|
|
355
|
+
if (model.supportsComputerUse !== true) {
|
|
356
|
+
for (const item of rawHistoryItems) {
|
|
357
|
+
if (item.type === "computer_call" && typeof item.call_id === "string") {
|
|
358
|
+
demotedComputerCallIds.add(item.call_id);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
const historyItems = adaptComputerHistoryForCompaction(rawHistoryItems, model.supportsComputerUse === true);
|
|
302
363
|
input.push(...historyItems);
|
|
303
|
-
addOpenAiCallIds(historyItems, knownCallIds, customCallIds);
|
|
364
|
+
addOpenAiCallIds(historyItems, knownCallIds, customCallIds, computerCallIds);
|
|
304
365
|
msgIndex++;
|
|
305
366
|
continue;
|
|
306
367
|
}
|
|
@@ -341,14 +402,27 @@ export function buildOpenAiNativeHistory(
|
|
|
341
402
|
assistant.provider,
|
|
342
403
|
);
|
|
343
404
|
if (providerPayload) {
|
|
405
|
+
if (!providerPayload.dt) demotedComputerCallIds.clear();
|
|
406
|
+
if (model.supportsComputerUse !== true) {
|
|
407
|
+
for (const item of providerPayload.items) {
|
|
408
|
+
if (item.type === "computer_call" && typeof item.call_id === "string") {
|
|
409
|
+
demotedComputerCallIds.add(item.call_id);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
const historyItems = adaptComputerHistoryForCompaction(
|
|
414
|
+
providerPayload.items,
|
|
415
|
+
model.supportsComputerUse === true,
|
|
416
|
+
);
|
|
344
417
|
if (providerPayload.dt) {
|
|
345
|
-
input.push(...
|
|
346
|
-
addOpenAiCallIds(
|
|
418
|
+
input.push(...historyItems);
|
|
419
|
+
addOpenAiCallIds(historyItems, knownCallIds, customCallIds, computerCallIds);
|
|
347
420
|
} else {
|
|
348
|
-
input.splice(0, input.length, ...
|
|
421
|
+
input.splice(0, input.length, ...historyItems);
|
|
349
422
|
knownCallIds.clear();
|
|
350
423
|
customCallIds.clear();
|
|
351
|
-
|
|
424
|
+
computerCallIds.clear();
|
|
425
|
+
addOpenAiCallIds(input, knownCallIds, customCallIds, computerCallIds);
|
|
352
426
|
}
|
|
353
427
|
msgIndex++;
|
|
354
428
|
continue;
|
|
@@ -394,6 +468,25 @@ export function buildOpenAiNativeHistory(
|
|
|
394
468
|
|
|
395
469
|
if (block.type === "toolCall") {
|
|
396
470
|
const normalized = normalizeResponsesToolCallId(block.id, block.customWireName ? "ctc" : "fc");
|
|
471
|
+
if (block.providerMetadata?.type === "computer") {
|
|
472
|
+
const computerCall = {
|
|
473
|
+
type: "computer_call",
|
|
474
|
+
id: block.providerMetadata.providerItemId,
|
|
475
|
+
call_id: normalized.callId,
|
|
476
|
+
actions: structuredCloneJSON(block.providerMetadata.actions),
|
|
477
|
+
pending_safety_checks: structuredCloneJSON(block.providerMetadata.pendingSafetyChecks),
|
|
478
|
+
status: "completed",
|
|
479
|
+
};
|
|
480
|
+
if (model.supportsComputerUse !== true) {
|
|
481
|
+
input.push(computerHistoryNote(computerCall));
|
|
482
|
+
demotedComputerCallIds.add(normalized.callId);
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
knownCallIds.add(normalized.callId);
|
|
486
|
+
computerCallIds.add(normalized.callId);
|
|
487
|
+
input.push(computerCall);
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
397
490
|
let itemId: string | undefined = normalized.itemId;
|
|
398
491
|
if (
|
|
399
492
|
isDifferentModel &&
|
|
@@ -430,17 +523,58 @@ export function buildOpenAiNativeHistory(
|
|
|
430
523
|
|
|
431
524
|
if (message.role === "toolResult") {
|
|
432
525
|
const normalized = normalizeResponsesToolCallId(message.toolCallId);
|
|
433
|
-
if (!knownCallIds.has(normalized.callId)) {
|
|
434
|
-
msgIndex++;
|
|
435
|
-
continue;
|
|
436
|
-
}
|
|
437
|
-
|
|
438
526
|
const textOutput = message.content
|
|
439
527
|
.filter(block => block.type === "text")
|
|
440
528
|
.map(block => block.text)
|
|
441
529
|
.join("\n");
|
|
442
530
|
const hasImages = message.content.some(block => block.type === "image");
|
|
443
531
|
const outputText = textOutput.length > 0 ? textOutput : hasImages ? "(see attached image)" : "";
|
|
532
|
+
if (demotedComputerCallIds.has(normalized.callId)) {
|
|
533
|
+
const resultItem =
|
|
534
|
+
message.providerMetadata?.type === "computer"
|
|
535
|
+
? {
|
|
536
|
+
type: "computer_call_output",
|
|
537
|
+
call_id: normalized.callId,
|
|
538
|
+
output: structuredCloneJSON(message.providerMetadata.screenshot),
|
|
539
|
+
acknowledged_safety_checks: structuredCloneJSON(
|
|
540
|
+
message.providerMetadata.acknowledgedSafetyChecks,
|
|
541
|
+
),
|
|
542
|
+
}
|
|
543
|
+
: { type: "computer_call_output", call_id: normalized.callId, error: outputText };
|
|
544
|
+
input.push(computerHistoryNote(resultItem));
|
|
545
|
+
demotedComputerCallIds.delete(normalized.callId);
|
|
546
|
+
msgIndex++;
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
if (!knownCallIds.has(normalized.callId)) {
|
|
550
|
+
msgIndex++;
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
553
|
+
if (computerCallIds.has(normalized.callId)) {
|
|
554
|
+
if (message.providerMetadata?.type === "computer") {
|
|
555
|
+
input.push({
|
|
556
|
+
type: "computer_call_output",
|
|
557
|
+
call_id: normalized.callId,
|
|
558
|
+
output: structuredCloneJSON(message.providerMetadata.screenshot),
|
|
559
|
+
acknowledged_safety_checks: structuredCloneJSON(message.providerMetadata.acknowledgedSafetyChecks),
|
|
560
|
+
});
|
|
561
|
+
msgIndex++;
|
|
562
|
+
continue;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
const callIndex = input.findLastIndex(
|
|
566
|
+
item => item.type === "computer_call" && item.call_id === normalized.callId,
|
|
567
|
+
);
|
|
568
|
+
if (callIndex >= 0) {
|
|
569
|
+
const [call] = input.splice(callIndex, 1);
|
|
570
|
+
if (call) input.splice(callIndex, 0, computerFailureNote(call, outputText));
|
|
571
|
+
}
|
|
572
|
+
knownCallIds.delete(normalized.callId);
|
|
573
|
+
computerCallIds.delete(normalized.callId);
|
|
574
|
+
msgIndex++;
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
577
|
+
|
|
444
578
|
input.push({
|
|
445
579
|
type: customCallIds.has(normalized.callId) ? "custom_tool_call_output" : "function_call_output",
|
|
446
580
|
call_id: normalized.callId,
|
|
@@ -517,6 +651,10 @@ export async function requestOpenAiRemoteCompaction(
|
|
|
517
651
|
if (accountId) {
|
|
518
652
|
headers[OPENAI_HEADERS.ACCOUNT_ID] = accountId;
|
|
519
653
|
}
|
|
654
|
+
const attestation = await getCodexAttestationHeader(accountId);
|
|
655
|
+
if (attestation) {
|
|
656
|
+
headers[OPENAI_HEADERS.ATTESTATION] = attestation;
|
|
657
|
+
}
|
|
520
658
|
headers[OPENAI_HEADERS.BETA] = OPENAI_HEADER_VALUES.BETA_RESPONSES;
|
|
521
659
|
headers[OPENAI_HEADERS.ORIGINATOR] = OPENAI_HEADER_VALUES.ORIGINATOR_CODEX;
|
|
522
660
|
Object.assign(
|
package/src/compaction/utils.ts
CHANGED
|
@@ -234,10 +234,21 @@ export function serializeConversation(messages: Message[], dialect?: Dialect): s
|
|
|
234
234
|
}
|
|
235
235
|
}
|
|
236
236
|
if (dialect) {
|
|
237
|
+
// Claude's classifier refuses inputs that reproduce the model's own
|
|
238
|
+
// reasoning as text ("reasoning_extraction"), and the anthropic dialect
|
|
239
|
+
// otherwise renders thinking verbatim inside <thinking> tags. Reasoning is
|
|
240
|
+
// ephemeral and low-signal for a summary, so drop it from Anthropic-target
|
|
241
|
+
// summary input. Other dialects (e.g. Harmony) carry reasoning natively in
|
|
242
|
+
// their transcript format and keep it.
|
|
243
|
+
const dropThinking = dialect === "anthropic";
|
|
237
244
|
const processed: Message[] = [];
|
|
238
245
|
for (const msg of messages) {
|
|
239
246
|
if (msg.role === "assistant") {
|
|
240
|
-
const content = msg.content.filter(
|
|
247
|
+
const content = msg.content.filter(
|
|
248
|
+
block =>
|
|
249
|
+
(block.type !== "toolCall" || !uselessCallIds.has(block.id)) &&
|
|
250
|
+
(!dropThinking || block.type !== "thinking"),
|
|
251
|
+
);
|
|
241
252
|
if (content.length > 0) processed.push(content.length === msg.content.length ? msg : { ...msg, content });
|
|
242
253
|
continue;
|
|
243
254
|
}
|
package/src/telemetry.ts
CHANGED
|
@@ -157,6 +157,8 @@ export const enum PiGenAIAttr {
|
|
|
157
157
|
GatewayEndpoint = "pi.gen_ai.gateway.endpoint",
|
|
158
158
|
GatewayCallId = "pi.gen_ai.gateway.call_id",
|
|
159
159
|
GatewayRoutedTo = "pi.gen_ai.gateway.routed_to",
|
|
160
|
+
/** Cloudflare AI Gateway response-cache status (`cf-aig-cache-status`), never prompt-cache. */
|
|
161
|
+
GatewayResponseCacheStatus = "pi.gen_ai.gateway.response_cache.status",
|
|
160
162
|
}
|
|
161
163
|
|
|
162
164
|
/** GenAI operation names — values for {@link GenAIAttr.OperationName}. */
|
|
@@ -1278,17 +1280,56 @@ export function detectGatewayFromHeaders(
|
|
|
1278
1280
|
return undefined;
|
|
1279
1281
|
}
|
|
1280
1282
|
|
|
1283
|
+
/**
|
|
1284
|
+
* Bounded Cloudflare AI Gateway response-cache statuses emitted on
|
|
1285
|
+
* {@link PiGenAIAttr.GatewayResponseCacheStatus}. Distinct from provider
|
|
1286
|
+
* prompt-cache token counters (`gen_ai.usage.cache_*`).
|
|
1287
|
+
*
|
|
1288
|
+
* Cloudflare documents `HIT` / `MISS` on `cf-aig-cache-status`; `bypass` covers
|
|
1289
|
+
* skip-cache / CDN-aligned BYPASS values. Any other present value is `unknown`.
|
|
1290
|
+
*/
|
|
1291
|
+
export type GatewayResponseCacheStatus = "hit" | "miss" | "bypass" | "unknown";
|
|
1292
|
+
|
|
1293
|
+
/**
|
|
1294
|
+
* Classify Cloudflare AI Gateway `cf-aig-cache-status` into a bounded
|
|
1295
|
+
* response-cache status. Returns `undefined` when the allow-listed header is
|
|
1296
|
+
* absent so non-Cloudflare traffic stays unaffected. Does not read TTL,
|
|
1297
|
+
* skip-cache, custom-key, or other Cloudflare headers.
|
|
1298
|
+
*/
|
|
1299
|
+
export function classifyGatewayResponseCacheStatus(
|
|
1300
|
+
headers: Readonly<Record<string, string>> | undefined,
|
|
1301
|
+
): GatewayResponseCacheStatus | undefined {
|
|
1302
|
+
if (!headers) return undefined;
|
|
1303
|
+
const raw = headers["cf-aig-cache-status"];
|
|
1304
|
+
if (raw == null) return undefined;
|
|
1305
|
+
switch (raw.trim().toLowerCase()) {
|
|
1306
|
+
case "hit":
|
|
1307
|
+
return "hit";
|
|
1308
|
+
case "miss":
|
|
1309
|
+
return "miss";
|
|
1310
|
+
case "bypass":
|
|
1311
|
+
return "bypass";
|
|
1312
|
+
default:
|
|
1313
|
+
return "unknown";
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1281
1317
|
function applyGatewayAttributes(
|
|
1282
1318
|
span: Span,
|
|
1283
1319
|
headers: Readonly<Record<string, string>> | undefined,
|
|
1284
1320
|
baseUrl: string | undefined,
|
|
1285
1321
|
): void {
|
|
1286
1322
|
const gateway = detectGatewayFromHeaders(headers);
|
|
1287
|
-
if (
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1323
|
+
if (gateway) {
|
|
1324
|
+
span.setAttribute(PiGenAIAttr.GatewayName, gateway.name);
|
|
1325
|
+
if (baseUrl) span.setAttribute(PiGenAIAttr.GatewayEndpoint, baseUrl);
|
|
1326
|
+
if (gateway.callId) span.setAttribute(PiGenAIAttr.GatewayCallId, gateway.callId);
|
|
1327
|
+
if (gateway.routedTo) span.setAttribute(PiGenAIAttr.GatewayRoutedTo, gateway.routedTo);
|
|
1328
|
+
}
|
|
1329
|
+
const responseCacheStatus = classifyGatewayResponseCacheStatus(headers);
|
|
1330
|
+
if (responseCacheStatus) {
|
|
1331
|
+
span.setAttribute(PiGenAIAttr.GatewayResponseCacheStatus, responseCacheStatus);
|
|
1332
|
+
}
|
|
1292
1333
|
}
|
|
1293
1334
|
|
|
1294
1335
|
interface AppliedCostEstimate {
|
package/src/types.ts
CHANGED
|
@@ -14,8 +14,10 @@ import type {
|
|
|
14
14
|
streamSimple,
|
|
15
15
|
TextContent,
|
|
16
16
|
Tool,
|
|
17
|
+
ToolCallProviderMetadata,
|
|
17
18
|
ToolChoice,
|
|
18
19
|
ToolResultMessage,
|
|
20
|
+
ToolResultProviderMetadata,
|
|
19
21
|
TSchema,
|
|
20
22
|
} from "@oh-my-pi/pi-ai";
|
|
21
23
|
import type { Dialect } from "@oh-my-pi/pi-ai/dialect";
|
|
@@ -458,6 +460,17 @@ export interface ToolCallContext {
|
|
|
458
460
|
index: number;
|
|
459
461
|
total: number;
|
|
460
462
|
toolCalls: Array<{ id: string; name: string }>;
|
|
463
|
+
/** Provider-native metadata for the current call, when present. */
|
|
464
|
+
providerMetadata?: ToolCallProviderMetadata;
|
|
465
|
+
/**
|
|
466
|
+
* Cooperative steering signal: aborted when a queued user/steering message
|
|
467
|
+
* (or an interrupting peer IRC) is detected while this tool batch runs.
|
|
468
|
+
* Unlike the hard abort signal it NEVER kills the tool — long-running
|
|
469
|
+
* tools MAY observe it (via `ctx.toolCall.steeringSignal`) to finish early
|
|
470
|
+
* or background themselves so the message injects promptly; ignoring it is
|
|
471
|
+
* always safe (the message injects at the next batch boundary).
|
|
472
|
+
*/
|
|
473
|
+
steeringSignal?: AbortSignal;
|
|
461
474
|
}
|
|
462
475
|
|
|
463
476
|
/** A single tool-call content block emitted by an assistant message. */
|
|
@@ -488,6 +501,8 @@ export interface AfterToolCallResult {
|
|
|
488
501
|
content?: (TextContent | ImageContent)[];
|
|
489
502
|
/** If provided, replaces the tool result details payload in full. */
|
|
490
503
|
details?: unknown;
|
|
504
|
+
/** If provided, replaces the provider-native result metadata in full. */
|
|
505
|
+
providerMetadata?: ToolResultProviderMetadata;
|
|
491
506
|
/** If provided, replaces the error flag carried with the tool result. */
|
|
492
507
|
isError?: boolean;
|
|
493
508
|
/** If provided, replaces the contextually-useless flag carried with the tool result. */
|
|
@@ -574,6 +589,8 @@ export interface AgentToolResult<T = any, _TInput = unknown> {
|
|
|
574
589
|
// Marks a non-throwing failure (e.g. an aggregator catching per-entry errors).
|
|
575
590
|
// agent-loop honors this and surfaces it as a tool error on the wire.
|
|
576
591
|
isError?: boolean;
|
|
592
|
+
/** Provider-native metadata that must survive into history replay unchanged. */
|
|
593
|
+
providerMetadata?: ToolResultProviderMetadata;
|
|
577
594
|
/** Marks the result as contextually useless: safe for compaction to elide once consumed (e.g. zero matches, wait timeout). Ignored when isError is set. */
|
|
578
595
|
useless?: boolean;
|
|
579
596
|
}
|
|
@@ -610,11 +627,14 @@ export type ToolLoadMode = "essential" | "discoverable";
|
|
|
610
627
|
* - bare tier ("read" / "write" / "exec") — static classification.
|
|
611
628
|
* - object form — adds a `reason` (shown in the prompt) and/or `override: true`
|
|
612
629
|
* (force-prompt even in modes that would otherwise auto-approve this tier).
|
|
630
|
+
* `policy: "deny"` blocks the call at the approval gate.
|
|
613
631
|
* - function — dynamic, given parsed args. Returns either form above.
|
|
614
632
|
*
|
|
615
633
|
* Omitted approvals are treated as "exec" by callers that enforce approvals.
|
|
616
634
|
*/
|
|
617
|
-
export type ToolApprovalDecision =
|
|
635
|
+
export type ToolApprovalDecision =
|
|
636
|
+
| ToolTier
|
|
637
|
+
| { tier: ToolTier; reason?: string; override?: boolean; policy?: "allow" | "deny" | "prompt" };
|
|
618
638
|
export type ToolApproval = ToolApprovalDecision | ((args: unknown) => ToolApprovalDecision);
|
|
619
639
|
|
|
620
640
|
/**
|