@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 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, compactionModels?: readonly Model[]): CompactionPreparation | undefined;
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;
@@ -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.0.9",
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.0.9",
39
- "@oh-my-pi/pi-catalog": "17.0.9",
40
- "@oh-my-pi/pi-natives": "17.0.9",
41
- "@oh-my-pi/pi-utils": "17.0.9",
42
- "@oh-my-pi/pi-wire": "17.0.9",
43
- "@oh-my-pi/snapcompact": "17.0.9",
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 { ...block, arguments: structuredCloneJSON(block.arguments) };
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
- const isError = explicitError || invalidBlocks > 0;
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
- // Interruptible tools observe steering + external + IRC aborts; every other
1821
- // tool only sees steering + external, so an IRC-only interrupt never kills a
1822
- // partially side-effecting foreground tool (e.g. `bash`) running alongside a
1823
- // pure wait (e.g. `job` poll).
1824
- const nonInterruptibleSignal: AbortSignal = signal
1825
- ? AbortSignal.any([signal, steeringAbortController.signal])
1826
- : steeringAbortController.signal;
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 upgrades an in-flight IRC interrupt: it aborts the
1893
- // shared signal so foreground tools stop as they do for a user Esc.
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 only aborts interruptible waits: a foreground bash / write
1907
- // mid-execution keeps running so we never leave partial side effects.
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 an interruptible tool call is in flight (e.g. a `hub` wait blocking
2241
- // on external work), queued steering or interrupting IRC would otherwise
2242
- // wait out the tool's own window. Poll only non-consuming queues and abort
2243
- // the shared tool signal so the boundary dequeue below injects the message
2244
- // promptly. Gated on immediate-interrupt mode + an interruptible call;
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 preserve data can be carried forward by the
1134
- * upcoming compaction. A local compaction (no remote preserve) always can it
1135
- * holds a real textual summary. A remote compaction (V2 or V1) only can when
1136
- * some candidate model shares its provider AND remote replay is still enabled;
1137
- * otherwise its provider-native replay is dead weight and only the opaque
1138
- * placeholder summary survives, so the caller must re-expand the originals.
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 remotePreserveReusableByAny(
1147
+ function remotePreserveReusable(
1141
1148
  preserveData: Record<string, unknown> | undefined,
1142
- models: readonly Model[],
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
- for (const model of models) {
1149
- if (remote.provider !== model.provider) continue;
1150
- const v2Ok = settings.remoteStreamingV2Enabled !== false && shouldUseCompactionV2Streaming(model);
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
- compactionModels: readonly Model[] = [],
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
- // none of the upcoming compaction candidates can reuse: its summary is only
1170
- // an opaque placeholder, so re-expand its original messages and summarize
1171
- // them locally rather than stranding that history. compact() still reuses it
1172
- // when a candidate can (same provider, remote enabled).
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 (compactionModels.length > 0 && !remotePreserveReusableByAny(entry.preserveData, compactionModels, settings)) {
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 remotePreserveReusableByAny).
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." +
@@ -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 ? [...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
- addOpenAiCallIds(input, knownCallIds, customCallIds);
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 historyItems = getOpenAIResponsesHistoryItems(providerPayload, model.provider);
301
- if (historyItems) {
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(...providerPayload.items);
346
- addOpenAiCallIds(providerPayload.items, knownCallIds, customCallIds);
418
+ input.push(...historyItems);
419
+ addOpenAiCallIds(historyItems, knownCallIds, customCallIds, computerCallIds);
347
420
  } else {
348
- input.splice(0, input.length, ...providerPayload.items);
421
+ input.splice(0, input.length, ...historyItems);
349
422
  knownCallIds.clear();
350
423
  customCallIds.clear();
351
- addOpenAiCallIds(input, knownCallIds, customCallIds);
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(
@@ -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(block => block.type !== "toolCall" || !uselessCallIds.has(block.id));
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 (!gateway) return;
1288
- span.setAttribute(PiGenAIAttr.GatewayName, gateway.name);
1289
- if (baseUrl) span.setAttribute(PiGenAIAttr.GatewayEndpoint, baseUrl);
1290
- if (gateway.callId) span.setAttribute(PiGenAIAttr.GatewayCallId, gateway.callId);
1291
- if (gateway.routedTo) span.setAttribute(PiGenAIAttr.GatewayRoutedTo, gateway.routedTo);
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 = ToolTier | { tier: ToolTier; reason?: string; override?: boolean };
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
  /**