@oh-my-pi/pi-agent-core 17.1.0 → 17.1.2

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,22 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [17.1.2] - 2026-07-24
6
+
7
+ ### Added
8
+
9
+ - Added `resolveFallbackTool` option to allow routing unadvertised tool calls to host-side transports (e.g., device mounts)
10
+
11
+ ## [17.1.1] - 2026-07-24
12
+
13
+ ### Added
14
+
15
+ - Added the provider-neutral native computer-call lifecycle, preserving observation outputs and input actions across pending and acknowledged tool results.
16
+
17
+ ### Changed
18
+
19
+ - 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.
20
+
5
21
  ## [17.1.0] - 2026-07-24
6
22
 
7
23
  ### Added
@@ -134,6 +134,12 @@ export interface AgentOptions {
134
134
  * Use for deobfuscating secrets or rewriting arguments.
135
135
  */
136
136
  transformToolCallArguments?: (args: Record<string, unknown>, toolName: string) => Record<string, unknown>;
137
+ /**
138
+ * Resolve a tool call whose name matched no advertised tool. Lets hosts
139
+ * route calls to tools exposed through side transports (e.g. `xd://`
140
+ * device mounts) instead of failing with "Tool not found".
141
+ */
142
+ resolveFallbackTool?: (name: string) => AgentTool<any> | undefined;
137
143
  /** Enable intent tracing schema injection/stripping in the harness. */
138
144
  intentTracing?: boolean;
139
145
  /**
@@ -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";
@@ -230,6 +230,13 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
230
230
  * Use for deobfuscating secrets or rewriting arguments.
231
231
  */
232
232
  transformToolCallArguments?: (args: Record<string, unknown>, toolName: string) => Record<string, unknown>;
233
+ /**
234
+ * Resolve a tool call whose name matched no advertised tool (including
235
+ * `customWireName` aliases). Lets hosts route calls to tools they expose
236
+ * through side transports (e.g. `xd://` device mounts) instead of failing
237
+ * with "Tool not found". Returning `undefined` keeps the failure.
238
+ */
239
+ resolveFallbackTool?: (name: string) => AgentTool<any> | undefined;
233
240
  /**
234
241
  * Enable intent tracing for tool calls.
235
242
  * When enabled, the harness injects a `string` field into tool schemas sent to the model,
@@ -399,6 +406,8 @@ export interface ToolCallContext {
399
406
  id: string;
400
407
  name: string;
401
408
  }>;
409
+ /** Provider-native metadata for the current call, when present. */
410
+ providerMetadata?: ToolCallProviderMetadata;
402
411
  /**
403
412
  * Cooperative steering signal: aborted when a queued user/steering message
404
413
  * (or an interrupting peer IRC) is detected while this tool batch runs.
@@ -437,6 +446,8 @@ export interface AfterToolCallResult {
437
446
  content?: (TextContent | ImageContent)[];
438
447
  /** If provided, replaces the tool result details payload in full. */
439
448
  details?: unknown;
449
+ /** If provided, replaces the provider-native result metadata in full. */
450
+ providerMetadata?: ToolResultProviderMetadata;
440
451
  /** If provided, replaces the error flag carried with the tool result. */
441
452
  isError?: boolean;
442
453
  /** If provided, replaces the contextually-useless flag carried with the tool result. */
@@ -512,6 +523,8 @@ export interface AgentToolResult<T = any, _TInput = unknown> {
512
523
  content: (TextContent | ImageContent)[];
513
524
  details?: T;
514
525
  isError?: boolean;
526
+ /** Provider-native metadata that must survive into history replay unchanged. */
527
+ providerMetadata?: ToolResultProviderMetadata;
515
528
  /** Marks the result as contextually useless: safe for compaction to elide once consumed (e.g. zero matches, wait timeout). Ignored when isError is set. */
516
529
  useless?: boolean;
517
530
  }
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.1.0",
4
+ "version": "17.1.2",
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.1.0",
39
- "@oh-my-pi/pi-catalog": "17.1.0",
40
- "@oh-my-pi/pi-natives": "17.1.0",
41
- "@oh-my-pi/pi-utils": "17.1.0",
42
- "@oh-my-pi/pi-wire": "17.1.0",
43
- "@oh-my-pi/snapcompact": "17.1.0",
38
+ "@oh-my-pi/pi-ai": "17.1.2",
39
+ "@oh-my-pi/pi-catalog": "17.1.2",
40
+ "@oh-my-pi/pi-natives": "17.1.2",
41
+ "@oh-my-pi/pi-utils": "17.1.2",
42
+ "@oh-my-pi/pi-wire": "17.1.2",
43
+ "@oh-my-pi/snapcompact": "17.1.2",
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":
@@ -189,10 +348,16 @@ function snapshotAssistantContentBlock(block: AssistantContentBlock): AssistantC
189
348
  return { ...block };
190
349
  case "redactedThinking":
191
350
  return { ...block };
351
+ case "anthropicServerTool":
352
+ return { ...block, block: structuredCloneJSON(block.block) };
192
353
  case "fallback":
193
354
  return { ...block, from: { ...block.from }, to: { ...block.to } };
194
355
  case "toolCall":
195
- return { ...block, arguments: structuredCloneJSON(block.arguments) };
356
+ return {
357
+ ...block,
358
+ arguments: structuredCloneJSON(block.arguments),
359
+ providerMetadata: snapshotToolCallProviderMetadata(block.providerMetadata),
360
+ };
196
361
  }
197
362
  }
198
363
 
@@ -268,6 +433,10 @@ function coerceToolResult(raw: unknown): { result: AgentToolResult<unknown>; mal
268
433
  const rawObj = raw && typeof raw === "object" ? (raw as Record<string, unknown>) : null;
269
434
  const rawContent = rawObj?.content;
270
435
  const details = rawObj && "details" in rawObj ? rawObj.details : {};
436
+ const providerMetadataResult = snapshotToolResultProviderMetadata(
437
+ rawObj && "providerMetadata" in rawObj ? rawObj.providerMetadata : undefined,
438
+ );
439
+ const providerMetadata = providerMetadataResult.metadata;
271
440
  // Tools may flag a non-throwing failure on the result itself (e.g. an
272
441
  // aggregator that catches per-entry errors and synthesizes a combined
273
442
  // result). Preserve the flag so agent-loop can surface it on the wire.
@@ -312,7 +481,13 @@ function coerceToolResult(raw: unknown): { result: AgentToolResult<unknown>; mal
312
481
  text: `Tool returned an invalid result: ${invalidBlocks} content block${invalidBlocks === 1 ? "" : "s"} had an unsupported shape.`,
313
482
  });
314
483
  }
315
- const isError = explicitError || invalidBlocks > 0;
484
+ if (providerMetadataResult.malformed) {
485
+ content.push({
486
+ type: "text",
487
+ text: "Tool returned an invalid result: computer providerMetadata had an unsupported shape.",
488
+ });
489
+ }
490
+ const isError = explicitError || invalidBlocks > 0 || providerMetadataResult.malformed;
316
491
  // Anthropic rejects tool_result blocks with is_error: true and empty content.
317
492
  if (isError && !hasSubstantiveToolResultContent(content)) {
318
493
  content.length = 0;
@@ -322,10 +497,11 @@ function coerceToolResult(raw: unknown): { result: AgentToolResult<unknown>; mal
322
497
  result: {
323
498
  content,
324
499
  details,
500
+ providerMetadata,
325
501
  ...(isError ? { isError: true } : {}),
326
502
  ...(useless && !isError ? { useless: true } : {}),
327
503
  },
328
- malformed: invalidBlocks > 0,
504
+ malformed: invalidBlocks > 0 || providerMetadataResult.malformed,
329
505
  };
330
506
  }
331
507
 
@@ -1799,6 +1975,7 @@ async function executeToolCalls(
1799
1975
  interruptMode = "immediate",
1800
1976
  getToolContext,
1801
1977
  transformToolCallArguments,
1978
+ resolveFallbackTool,
1802
1979
  intentTracing,
1803
1980
  beforeToolCall,
1804
1981
  afterToolCall,
@@ -1841,7 +2018,10 @@ async function executeToolCalls(
1841
2018
  // determinism if both somehow collide.
1842
2019
  const tool =
1843
2020
  tools?.find(t => t.name === toolCall.name) ??
1844
- tools?.find(t => t.customWireName !== undefined && t.customWireName === toolCall.name);
2021
+ tools?.find(t => t.customWireName !== undefined && t.customWireName === toolCall.name) ??
2022
+ // Not in the advertised set: let the host route side-transport tools
2023
+ // (e.g. xd:// device mounts) called by their top-level name.
2024
+ resolveFallbackTool?.(toolCall.name);
1845
2025
  const args = toolCall.arguments as Record<string, unknown>;
1846
2026
  const interruptibleMode = tool?.interruptible;
1847
2027
  let interruptible = false;
@@ -1947,6 +2127,7 @@ async function executeToolCalls(
1947
2127
  toolName: toolCall.name,
1948
2128
  content: result.content,
1949
2129
  details: result.details,
2130
+ providerMetadata: result.providerMetadata,
1950
2131
  isError,
1951
2132
  ...(result.useless && !isError ? { useless: true } : {}),
1952
2133
  timestamp: Date.now(),
@@ -2111,6 +2292,7 @@ async function executeToolCalls(
2111
2292
  total: toolCalls.length,
2112
2293
  toolCalls: toolCallInfos,
2113
2294
  steeringSignal: steeringSoftController.signal,
2295
+ providerMetadata: toolCall.providerMetadata,
2114
2296
  })
2115
2297
  : undefined;
2116
2298
  const rawResult = await tool.execute(
@@ -2163,6 +2345,7 @@ async function executeToolCalls(
2163
2345
  content: after.content ?? result.content,
2164
2346
  details: after.details ?? result.details,
2165
2347
  isError: after.isError ?? result.isError,
2348
+ providerMetadata: after.providerMetadata ?? result.providerMetadata,
2166
2349
  useless: after.useless ?? result.useless,
2167
2350
  });
2168
2351
  result = coerced.result;
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"
@@ -238,6 +241,13 @@ export interface AgentOptions {
238
241
  */
239
242
  transformToolCallArguments?: (args: Record<string, unknown>, toolName: string) => Record<string, unknown>;
240
243
 
244
+ /**
245
+ * Resolve a tool call whose name matched no advertised tool. Lets hosts
246
+ * route calls to tools exposed through side transports (e.g. `xd://`
247
+ * device mounts) instead of failing with "Tool not found".
248
+ */
249
+ resolveFallbackTool?: (name: string) => AgentTool<any> | undefined;
250
+
241
251
  /** Enable intent tracing schema injection/stripping in the harness. */
242
252
  intentTracing?: boolean;
243
253
  /**
@@ -375,6 +385,7 @@ export class Agent {
375
385
  #kimiApiFormat?: "openai" | "anthropic";
376
386
  #preferWebsockets?: boolean;
377
387
  #transformToolCallArguments?: (args: Record<string, unknown>, toolName: string) => Record<string, unknown>;
388
+ #resolveFallbackTool?: (name: string) => AgentTool<any> | undefined;
378
389
  #intentTracing: boolean;
379
390
  #pruneToolDescriptions: boolean;
380
391
  #dialect?: Dialect;
@@ -455,6 +466,7 @@ export class Agent {
455
466
  this.#kimiApiFormat = opts.kimiApiFormat;
456
467
  this.#preferWebsockets = opts.preferWebsockets;
457
468
  this.#transformToolCallArguments = opts.transformToolCallArguments;
469
+ this.#resolveFallbackTool = opts.resolveFallbackTool;
458
470
  this.#intentTracing = opts.intentTracing === true;
459
471
  this.#pruneToolDescriptions = opts.pruneToolDescriptions === true;
460
472
  this.#dialect = opts.dialect;
@@ -1190,6 +1202,7 @@ export class Agent {
1190
1202
  cwd: this.#cwd,
1191
1203
  getCwd: this.#cwdResolver,
1192
1204
  transformToolCallArguments: this.#transformToolCallArguments,
1205
+ resolveFallbackTool: this.#resolveFallbackTool,
1193
1206
  intentTracing: this.#intentTracing,
1194
1207
  pruneToolDescriptions: this.#pruneToolDescriptions,
1195
1208
  dialect: this.#dialect,
@@ -427,6 +427,14 @@ function computeMessageTokens(message: AgentMessage, options?: { excludeEncrypte
427
427
  // Encrypted reasoning blob the provider still bills for on replay;
428
428
  // excluded from the compaction floor for the same reason as above.
429
429
  if (!options?.excludeEncryptedReasoning) fragments.push(block.data);
430
+ } else if (block.type === "anthropicServerTool") {
431
+ // Native Anthropic server-tool call/result replayed verbatim on the
432
+ // wire (server_tool_use input, web_search_tool_result
433
+ // encrypted_content). Opaque provider-replay state the provider still
434
+ // bills for on same-provider replay; excluded from the compaction
435
+ // floor like other encrypted reasoning because its local byte size
436
+ // diverges from provider billing.
437
+ if (!options?.excludeEncryptedReasoning) fragments.push(stringifyJson(block.block) ?? "null");
430
438
  }
431
439
  }
432
440
  break;
@@ -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(
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";
@@ -274,6 +276,13 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
274
276
  * Use for deobfuscating secrets or rewriting arguments.
275
277
  */
276
278
  transformToolCallArguments?: (args: Record<string, unknown>, toolName: string) => Record<string, unknown>;
279
+ /**
280
+ * Resolve a tool call whose name matched no advertised tool (including
281
+ * `customWireName` aliases). Lets hosts route calls to tools they expose
282
+ * through side transports (e.g. `xd://` device mounts) instead of failing
283
+ * with "Tool not found". Returning `undefined` keeps the failure.
284
+ */
285
+ resolveFallbackTool?: (name: string) => AgentTool<any> | undefined;
277
286
 
278
287
  /**
279
288
  * Enable intent tracing for tool calls.
@@ -458,6 +467,8 @@ export interface ToolCallContext {
458
467
  index: number;
459
468
  total: number;
460
469
  toolCalls: Array<{ id: string; name: string }>;
470
+ /** Provider-native metadata for the current call, when present. */
471
+ providerMetadata?: ToolCallProviderMetadata;
461
472
  /**
462
473
  * Cooperative steering signal: aborted when a queued user/steering message
463
474
  * (or an interrupting peer IRC) is detected while this tool batch runs.
@@ -497,6 +508,8 @@ export interface AfterToolCallResult {
497
508
  content?: (TextContent | ImageContent)[];
498
509
  /** If provided, replaces the tool result details payload in full. */
499
510
  details?: unknown;
511
+ /** If provided, replaces the provider-native result metadata in full. */
512
+ providerMetadata?: ToolResultProviderMetadata;
500
513
  /** If provided, replaces the error flag carried with the tool result. */
501
514
  isError?: boolean;
502
515
  /** If provided, replaces the contextually-useless flag carried with the tool result. */
@@ -583,6 +596,8 @@ export interface AgentToolResult<T = any, _TInput = unknown> {
583
596
  // Marks a non-throwing failure (e.g. an aggregator catching per-entry errors).
584
597
  // agent-loop honors this and surfaces it as a tool error on the wire.
585
598
  isError?: boolean;
599
+ /** Provider-native metadata that must survive into history replay unchanged. */
600
+ providerMetadata?: ToolResultProviderMetadata;
586
601
  /** Marks the result as contextually useless: safe for compaction to elide once consumed (e.g. zero matches, wait timeout). Ignored when isError is set. */
587
602
  useless?: boolean;
588
603
  }