@oh-my-pi/pi-agent-core 18.15.0 → 18.17.0

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/src/agent.ts CHANGED
@@ -62,6 +62,7 @@ import type {
62
62
  AsideBoundary,
63
63
  AsideMessage,
64
64
  PrepareQueuedMessages,
65
+ QueuedMessageGate,
65
66
  QueuedMessageQueue,
66
67
  SpeculativeToolExecutionConfig,
67
68
  StreamFn,
@@ -279,6 +280,8 @@ export interface AgentOptions {
279
280
 
280
281
  /** Enable intent tracing schema injection/stripping in the harness. */
281
282
  intentTracing?: boolean;
283
+ /** Schema description of the injected intent field; `PI_INTENT_DESC` overrides it. Default `concise intent`. */
284
+ intentDescription?: string;
282
285
  /**
283
286
  * Strip tool descriptions from provider-bound tool specs (top-level + nested
284
287
  * schema annotations). Use when the full catalog is rendered into the system
@@ -493,6 +496,7 @@ export class Agent {
493
496
  #resolveFallbackTool?: (name: string, advertised: readonly AgentTool<any>[]) => AgentTool<any> | undefined;
494
497
  #suggestFallbackToolNames?: () => Iterable<string>;
495
498
  #intentTracing: boolean;
499
+ #intentDescription?: string;
496
500
  #pruneToolDescriptions: boolean;
497
501
  #dialect?: Dialect;
498
502
  #dialectResolver?: (model: Model) => Dialect | undefined;
@@ -525,6 +529,12 @@ export class Agent {
525
529
  getApiKey?: (model: Model) => Promise<ApiKey | undefined> | ApiKey | undefined;
526
530
  /** Prepare actual queue deliveries after dequeue gates; commit runs only while ownership remains valid. */
527
531
  prepareQueuedMessages?: PrepareQueuedMessages;
532
+ /**
533
+ * Consulted synchronously whenever queued messages are removed for delivery and
534
+ * told about claimed messages that went back undelivered (see {@link QueuedMessageGate}).
535
+ * Unset, batches are taken as they are.
536
+ */
537
+ queuedMessageGate?: QueuedMessageGate;
528
538
  /**
529
539
  * Hook invoked after tool arguments are validated and before execution.
530
540
  * Reassign at any time to swap the implementation (e.g. on extension reload).
@@ -602,6 +612,7 @@ export class Agent {
602
612
  this.#resolveFallbackTool = opts.resolveFallbackTool;
603
613
  this.#suggestFallbackToolNames = opts.suggestFallbackToolNames;
604
614
  this.#intentTracing = opts.intentTracing === true;
615
+ this.#intentDescription = opts.intentDescription;
605
616
  this.#pruneToolDescriptions = opts.pruneToolDescriptions === true;
606
617
  this.#dialect = opts.dialect;
607
618
  this.#dialectResolver = opts.dialectResolver;
@@ -864,6 +875,11 @@ export class Agent {
864
875
  this.#intentTracing = value;
865
876
  }
866
877
 
878
+ /** The intent field's schema description this agent was built with (`PI_INTENT_DESC` still overrides it). */
879
+ get intentDescription(): string | undefined {
880
+ return this.#intentDescription;
881
+ }
882
+
867
883
  /** Abort the provider request on a fabricated tool result; applies from the next prompt run. */
868
884
  get abortOnFabricatedToolResult(): boolean | undefined {
869
885
  return this.#abortOnFabricatedToolResult;
@@ -970,6 +986,7 @@ export class Agent {
970
986
  ? []
971
987
  : (normalizeTools(this.#toolsForModel(model), {
972
988
  injectIntent: this.#intentTracing,
989
+ intentDescription: this.#intentDescription,
973
990
  pruneDescriptions: this.#pruneToolDescriptions,
974
991
  }) ?? []);
975
992
  let context: Context = { systemPrompt, messages, tools };
@@ -1042,12 +1059,14 @@ export class Agent {
1042
1059
  const runController = this.#abortController;
1043
1060
  if (!prepare) {
1044
1061
  this.#queuedMessageDeliveries.add({ queue, controller: runController, messages, next: 0, additional: [] });
1062
+ this.#emitQueueChanged();
1045
1063
  return messages;
1046
1064
  }
1047
1065
 
1048
1066
  const claim: QueuedMessageClaim = { messages, controller: new AbortController() };
1049
1067
  this.#queuedMessageClaims[queue] = claim;
1050
1068
  const preparationSignal = AbortSignal.any([signal, claim.controller.signal]);
1069
+ this.#emitQueueChanged();
1051
1070
  try {
1052
1071
  const preparation = await prepare(messages, preparationSignal, queue);
1053
1072
  signal.throwIfAborted();
@@ -1067,6 +1086,7 @@ export class Agent {
1067
1086
  next: 0,
1068
1087
  additional: additional ?? [],
1069
1088
  });
1089
+ this.#emitQueueChanged();
1070
1090
  return additional?.length ? [...messages, ...additional] : messages;
1071
1091
  } catch (error) {
1072
1092
  if (signal.aborted) throw error;
@@ -1086,6 +1106,7 @@ export class Agent {
1086
1106
  const claim = this.#queuedMessageClaims[queue];
1087
1107
  if (!claim) return;
1088
1108
  delete this.#queuedMessageClaims[queue];
1109
+ this.queuedMessageGate?.release(claim.messages);
1089
1110
  if (restore) {
1090
1111
  if (queue === "steering") {
1091
1112
  this.#steeringQueue = [...claim.messages, ...this.#steeringQueue];
@@ -1104,6 +1125,7 @@ export class Agent {
1104
1125
  // Live steering was taken before anything the run still holds undelivered.
1105
1126
  this.#liveSteered = this.#liveSteered.filter(entry => {
1106
1127
  if (entry.controller !== controller) return true;
1128
+ if (entry.message.role === "user") delete entry.message.liveSteered;
1107
1129
  restored.steering.push(entry.message);
1108
1130
  return false;
1109
1131
  });
@@ -1114,12 +1136,14 @@ export class Agent {
1114
1136
  restored[delivery.queue].push(delivery.messages[i]);
1115
1137
  }
1116
1138
  }
1139
+ if (restored.steering.length === 0 && restored.followUp.length === 0) return;
1140
+ this.queuedMessageGate?.release([...restored.steering, ...restored.followUp]);
1117
1141
  if (restored.steering.length > 0) {
1118
1142
  this.#steeringQueue = [...restored.steering, ...this.#steeringQueue];
1119
1143
  this.#notifySteeringWaiters();
1120
1144
  }
1121
1145
  if (restored.followUp.length > 0) this.#followUpQueue = [...restored.followUp, ...this.#followUpQueue];
1122
- if (restored.steering.length > 0 || restored.followUp.length > 0) this.#emitQueueChanged();
1146
+ this.#emitQueueChanged();
1123
1147
  }
1124
1148
 
1125
1149
  /** Move steering live steering took out of the queue-delivery records into {@link #liveSteered}. */
@@ -1177,6 +1201,9 @@ export class Agent {
1177
1201
  for (const message of [...pending, ...delivery.additional]) suppress(message, delivery.controller);
1178
1202
  }
1179
1203
  this.#queuedMessageDeliveries.clear();
1204
+ if (withdrawn.steering.length > 0 || withdrawn.followUp.length > 0) {
1205
+ this.queuedMessageGate?.release([...withdrawn.steering, ...withdrawn.followUp]);
1206
+ }
1180
1207
  if (hadLiveSteered) this.#emitQueueChanged();
1181
1208
  return withdrawn;
1182
1209
  }
@@ -1336,11 +1363,9 @@ export class Agent {
1336
1363
  this.#state.messages = ms.slice();
1337
1364
  }
1338
1365
 
1339
- /** Signal that the steering/follow-up queue contents may have changed. Every
1340
- * queue mutator below calls this once after mutating, so external layers
1341
- * (AgentSession's `queue_update` coalescing) have a single seam to observe
1342
- * enqueue, dequeue-on-delivery, clear, and restore without emits scattered
1343
- * across every call site that queues or dequeues a message. */
1366
+ /** Signal after a queue transition has settled its pending, claim, and
1367
+ * delivery state, so AgentSession's `queue_update` coalescing observes
1368
+ * a consistent snapshot across enqueue, preparation, delivery, and restore. */
1344
1369
  #emitQueueChanged(): void {
1345
1370
  for (const listener of this.#queueListeners) {
1346
1371
  try {
@@ -1507,6 +1532,19 @@ export class Agent {
1507
1532
  return claim ? [...claim.messages, ...this.#steeringQueue] : this.#steeringQueue;
1508
1533
  }
1509
1534
 
1535
+ #isUndeliveredQueuedMessage(message: AgentMessage): boolean {
1536
+ for (const entry of this.#liveSteered) {
1537
+ if (entry.message === message || entry.additional?.includes(message)) return true;
1538
+ }
1539
+ for (const delivery of this.#queuedMessageDeliveries) {
1540
+ if (delivery.additional.includes(message)) return true;
1541
+ for (let i = delivery.next; i < delivery.messages.length; i++) {
1542
+ if (delivery.messages[i] === message) return true;
1543
+ }
1544
+ }
1545
+ return false;
1546
+ }
1547
+
1510
1548
  /** Dequeued messages not yet in the transcript, e.g. steering a provider
1511
1549
  * took into its in-flight response via live steering. Aborting the run
1512
1550
  * requeues them, so the session's empty-submit interrupt counts them as
@@ -1553,17 +1591,50 @@ export class Agent {
1553
1591
  return queue.slice(0, count);
1554
1592
  }
1555
1593
 
1594
+ /**
1595
+ * The next delivery batch of `queue`. With a {@link queuedMessageGate}, the
1596
+ * gate sees each candidate batch first; what it vetoes leaves the queue and
1597
+ * the batch is recomputed, and candidates it claimed that the final batch no
1598
+ * longer holds are released again.
1599
+ */
1600
+ #nextDeliveryBatch(queue: QueuedMessageQueue): AgentMessage[] {
1601
+ const mode = queue === "steering" ? this.#steeringMode : this.#followUpMode;
1602
+ let batch = this.#dequeueMessages(queue === "steering" ? this.#steeringQueue : this.#followUpQueue, mode);
1603
+ const gate = this.queuedMessageGate;
1604
+ if (!gate || batch.length === 0) return batch;
1605
+ let claimedEarlier: AgentMessage[] | undefined;
1606
+ let dropped = false;
1607
+ while (batch.length > 0) {
1608
+ const veto = gate.claim(queue, batch);
1609
+ if (veto.length === 0) break;
1610
+ const drop = new Set(veto);
1611
+ const pending = queue === "steering" ? this.#steeringQueue : this.#followUpQueue;
1612
+ const kept = pending.filter(message => !drop.has(message));
1613
+ if (kept.length === pending.length) break;
1614
+ for (const message of batch) if (!drop.has(message)) (claimedEarlier ??= []).push(message);
1615
+ if (queue === "steering") this.#steeringQueue = kept;
1616
+ else this.#followUpQueue = kept;
1617
+ dropped = true;
1618
+ batch = this.#dequeueMessages(kept, mode);
1619
+ }
1620
+ if (claimedEarlier) {
1621
+ const final = new Set(batch);
1622
+ const stale = claimedEarlier.filter(message => !final.has(message));
1623
+ if (stale.length > 0) gate.release(stale);
1624
+ }
1625
+ if (dropped && batch.length === 0) this.#emitQueueChanged();
1626
+ return batch;
1627
+ }
1628
+
1556
1629
  #dequeueSteeringMessages(): AgentMessage[] {
1557
- const messages = this.#dequeueMessages(this.#steeringQueue, this.#steeringMode);
1630
+ const messages = this.#nextDeliveryBatch("steering");
1558
1631
  this.#steeringQueue = this.#steeringQueue.slice(messages.length);
1559
- if (messages.length > 0) this.#emitQueueChanged();
1560
1632
  return messages;
1561
1633
  }
1562
1634
 
1563
1635
  #dequeueFollowUpMessages(): AgentMessage[] {
1564
- const messages = this.#dequeueMessages(this.#followUpQueue, this.#followUpMode);
1636
+ const messages = this.#nextDeliveryBatch("followUp");
1565
1637
  this.#followUpQueue = this.#followUpQueue.slice(messages.length);
1566
- if (messages.length > 0) this.#emitQueueChanged();
1567
1638
  return messages;
1568
1639
  }
1569
1640
 
@@ -1991,6 +2062,7 @@ export class Agent {
1991
2062
  resolveFallbackTool: this.#resolveFallbackTool,
1992
2063
  suggestFallbackToolNames: this.#suggestFallbackToolNames,
1993
2064
  intentTracing: this.#intentTracing,
2065
+ intentDescription: this.#intentDescription,
1994
2066
  pruneToolDescriptions: this.#pruneToolDescriptions,
1995
2067
  dialect: this.#dialect,
1996
2068
  getDialect: this.#dialectResolver,
@@ -2024,6 +2096,7 @@ export class Agent {
2024
2096
  }
2025
2097
  return this.#dequeueSteeringMessagesAfterHooks(signal ?? loopSignal);
2026
2098
  },
2099
+ isUndeliveredQueuedMessage: message => this.#isUndeliveredQueuedMessage(message),
2027
2100
  hasSteeringMessages: () =>
2028
2101
  steeringQueueState(
2029
2102
  this.#steeringQueue,
@@ -82,6 +82,8 @@ export interface StablePrefixSnapshot {
82
82
  export interface BuildOptions {
83
83
  /** Inject the `i` intent field into tool schemas (must match agent-loop's normalizeTools). */
84
84
  intentTracing: boolean;
85
+ /** Schema description of the injected `i` field (must match agent-loop's normalizeTools). */
86
+ intentDescription?: string;
85
87
  /** Strip tool descriptions from the provider-bound specs (must match normalizeTools). */
86
88
  pruneToolDescriptions?: boolean;
87
89
  }
@@ -136,6 +138,7 @@ export class StablePrefix {
136
138
  // but changes any of these must miss the fast path.
137
139
  #lastToolKey: readonly string[] | undefined;
138
140
  #lastIntentTracing: boolean | undefined;
141
+ #lastIntentDescription: string | undefined;
139
142
  #lastPruneToolDescriptions: boolean | undefined;
140
143
 
141
144
  build(context: AgentContext, options: BuildOptions): boolean {
@@ -166,6 +169,7 @@ export class StablePrefix {
166
169
  #fastPathHit(context: AgentContext, options: BuildOptions, toolKeys: readonly string[]): boolean {
167
170
  if (
168
171
  this.#lastIntentTracing !== options.intentTracing ||
172
+ this.#lastIntentDescription !== options.intentDescription ||
169
173
  this.#lastPruneToolDescriptions !== options.pruneToolDescriptions
170
174
  ) {
171
175
  return false;
@@ -196,6 +200,7 @@ export class StablePrefix {
196
200
  #recordFastPathKey(context: AgentContext, options: BuildOptions, renderedToolKeys: readonly string[]): void {
197
201
  this.#lastPromptSegments = [...context.systemPrompt];
198
202
  this.#lastIntentTracing = options.intentTracing;
203
+ this.#lastIntentDescription = options.intentDescription;
199
204
  this.#lastPruneToolDescriptions = options.pruneToolDescriptions;
200
205
  this.#lastToolKey = renderedToolKeys;
201
206
  }
@@ -470,11 +475,7 @@ export class AppendOnlyContextManager {
470
475
 
471
476
  function takeSnapshot(context: AgentContext, options: BuildOptions): StablePrefixSnapshot {
472
477
  const systemPrompt = [...context.systemPrompt];
473
- const tools =
474
- normalizeTools(context.tools, {
475
- injectIntent: options.intentTracing,
476
- pruneDescriptions: options.pruneToolDescriptions,
477
- }) ?? [];
478
+ const tools = normalizeWireTools(context.tools, options);
478
479
  return {
479
480
  systemPrompt,
480
481
  tools,
@@ -482,20 +483,50 @@ function takeSnapshot(context: AgentContext, options: BuildOptions): StablePrefi
482
483
  };
483
484
  }
484
485
 
486
+ function normalizeWireTools(tools: AgentContext["tools"], options: BuildOptions): Tool[] {
487
+ return (
488
+ normalizeTools(tools, {
489
+ injectIntent: options.intentTracing,
490
+ intentDescription: options.intentDescription,
491
+ pruneDescriptions: options.pruneToolDescriptions,
492
+ }) ?? []
493
+ );
494
+ }
495
+
496
+ /** The fields of a provider-bound tool spec the wire carries; `normalizeTools` has rendered `examples` into the description. */
497
+ function wireToolFields(t: Tool) {
498
+ return {
499
+ n: t.name,
500
+ d: t.description,
501
+ p: t.parameters,
502
+ s: t.strict,
503
+ cf: t.customFormat,
504
+ cw: t.customWireName,
505
+ };
506
+ }
507
+
508
+ /**
509
+ * Fingerprint of the tool specs a request built with `options` sends: the
510
+ * tools half of the prefix fingerprint, for a caller that compares tool sets
511
+ * across requests. A same-name tool whose description, schema, examples,
512
+ * `strict`, `customFormat` or `customWireName` changed makes another one.
513
+ */
514
+ export function wireToolsFingerprint(tools: AgentContext["tools"], options: BuildOptions): string {
515
+ return hashPayload(JSON.stringify(normalizeWireTools(tools, options).map(wireToolFields)));
516
+ }
517
+
485
518
  function computeFingerprint(systemPrompt: string[], tools: Tool[], options: BuildOptions): string {
486
- const payload = JSON.stringify({
487
- s: systemPrompt,
488
- t: tools.map(t => ({
489
- n: t.name,
490
- d: t.description,
491
- p: t.parameters,
492
- s: t.strict,
493
- cf: t.customFormat,
494
- cw: t.customWireName,
495
- })),
496
- i: options.intentTracing,
497
- pd: options.pruneToolDescriptions,
498
- });
519
+ return hashPayload(
520
+ JSON.stringify({
521
+ s: systemPrompt,
522
+ t: tools.map(wireToolFields),
523
+ i: options.intentTracing,
524
+ pd: options.pruneToolDescriptions,
525
+ }),
526
+ );
527
+ }
528
+
529
+ function hashPayload(payload: string): string {
499
530
  let hash = 0;
500
531
  for (let i = 0; i < payload.length; i++) {
501
532
  hash = ((hash << 5) - hash + payload.charCodeAt(i)) | 0;
@@ -24,16 +24,12 @@ import {
24
24
  import branchSummaryPrompt from "./prompts/branch-summary.md" with { type: "text" };
25
25
  import branchSummaryPreamble from "./prompts/branch-summary-preamble.md" with { type: "text" };
26
26
  import {
27
- computeFileLists,
28
- createFileOps,
29
27
  extractFileOpsFromMessage,
30
- type FileOperations,
31
28
  incompleteOutputError,
32
29
  SUMMARIZATION_SYSTEM_PROMPT,
33
30
  serializeConversationForSummary,
34
31
  stripReadSelector,
35
32
  truncateToolResultForSummary,
36
- upsertFileOperations,
37
33
  } from "./utils";
38
34
 
39
35
  // ============================================================================
@@ -54,13 +50,11 @@ export interface BranchSummaryDetails {
54
50
  modifiedFiles: string[];
55
51
  }
56
52
 
57
- export type { FileOperations } from "./utils";
58
-
59
53
  export interface BranchPreparation {
60
54
  /** Messages extracted for summarization, in chronological order */
61
55
  messages: AgentMessage[];
62
56
  /** File operations extracted from tool calls */
63
- fileOps: FileOperations;
57
+ fileOps: snapcompact.FileOperations;
64
58
  /** Total estimated tokens in messages */
65
59
  totalTokens: number;
66
60
  }
@@ -252,7 +246,7 @@ export function prepareBranchEntries(
252
246
  tokenBudget: number = 0,
253
247
  ): BranchPreparation {
254
248
  const messages: AgentMessage[] = [];
255
- const fileOps = createFileOps();
249
+ const fileOps = snapcompact.createFileOps();
256
250
  let totalTokens = 0;
257
251
 
258
252
  // First pass: collect file ops from ALL entries (even if they don't fit in token budget)
@@ -400,8 +394,8 @@ export async function generateBranchSummary(
400
394
  summary = BRANCH_SUMMARY_PREAMBLE + summary;
401
395
 
402
396
  // Compute file lists and append to summary
403
- const { readFiles, modifiedFiles } = computeFileLists(fileOps);
404
- summary = upsertFileOperations(summary, readFiles, modifiedFiles, fileOps.read);
397
+ const { readFiles, modifiedFiles } = snapcompact.computeFileLists(fileOps);
398
+ summary = snapcompact.upsertFileOperations(summary, readFiles, modifiedFiles, fileOps.read);
405
399
 
406
400
  return {
407
401
  summary: summary || "No summary generated",
@@ -32,7 +32,7 @@ import {
32
32
  OPENAI_HEADER_VALUES,
33
33
  OPENAI_HEADERS,
34
34
  } from "@oh-my-pi/pi-catalog/wire/codex";
35
- import { $env, isUnexpectedSocketCloseMessage, logger, ptree, stringifyJson } from "@oh-my-pi/pi-utils";
35
+ import { $env, isUnexpectedSocketCloseMessage, logger, ptree, stringifyJson, toError } from "@oh-my-pi/pi-utils";
36
36
  import { appendAzureApiVersion, resolveAzureOpenAiBaseUrl } from "./azure-openai-endpoint";
37
37
  import { prepareBedrockCompactionRequest } from "./bedrock";
38
38
 
@@ -320,7 +320,7 @@ export async function requestCompactionV2Streaming(
320
320
  codexAccessPrograms: options?.codexAccessPrograms,
321
321
  });
322
322
  } catch (err) {
323
- const error = err instanceof Error ? err : new Error(String(err));
323
+ const error = toError(err);
324
324
  if (signal?.aborted) throw error;
325
325
 
326
326
  if (isRetryableCompactionError(error) && attempt < V2_COMPACTION_MAX_RETRIES) {
@@ -472,7 +472,6 @@ function buildCompactionV2Headers(
472
472
  headers[OPENAI_HEADERS.SESSION_ID] = routingSessionId;
473
473
  headers["x-client-request-id"] = routingSessionId;
474
474
  }
475
- headers[OPENAI_HEADERS.BETA] = OPENAI_HEADER_VALUES.BETA_RESPONSES;
476
475
  headers[OPENAI_HEADERS.ORIGINATOR] = OPENAI_HEADER_VALUES.ORIGINATOR_CODEX;
477
476
  headers[OPENAI_HEADERS.CODEX_BETA_FEATURES] = OPENAI_HEADER_VALUES.REMOTE_COMPACTION_V2;
478
477
  headers[OPENAI_HEADERS.ROUTING_HINT] = codexRoutingHint(request.body.model, undefined);
@@ -86,16 +86,12 @@ import handoffDocumentPrompt from "./prompts/handoff-document.md" with { type: "
86
86
  import snapcompactArchiveContextPrompt from "./prompts/snapcompact-archive-context.md" with { type: "text" };
87
87
 
88
88
  import {
89
- computeFileLists,
90
- createFileOps,
91
89
  escapeSummaryBoundaryTags,
92
90
  extractFileOpsFromMessage,
93
- type FileOperations,
94
91
  incompleteOutputError,
95
92
  SUMMARIZATION_SYSTEM_PROMPT,
96
93
  serializeConversationForSummary,
97
94
  stripReadSelector,
98
- upsertFileOperations,
99
95
  } from "./utils";
100
96
 
101
97
  // ============================================================================
@@ -115,8 +111,8 @@ function extractFileOperations(
115
111
  messages: AgentMessage[],
116
112
  entries: SessionEntry[],
117
113
  prevCompactionIndex: number,
118
- ): FileOperations {
119
- const fileOps = createFileOps();
114
+ ): snapcompact.FileOperations {
115
+ const fileOps = snapcompact.createFileOps();
120
116
 
121
117
  // Collect from previous compaction's details (if pi-generated)
122
118
  if (prevCompactionIndex >= 0) {
@@ -1384,7 +1380,7 @@ export interface CompactionPreparation {
1384
1380
  /** Preserved opaque compaction payload from the previous compaction, if any. */
1385
1381
  previousPreserveData?: Record<string, unknown>;
1386
1382
  /** File operations extracted from messagesToSummarize */
1387
- fileOps: FileOperations;
1383
+ fileOps: snapcompact.FileOperations;
1388
1384
  /** Compaction settions from settings.jsonl */
1389
1385
  settings: CompactionSettings;
1390
1386
  }
@@ -2343,8 +2339,8 @@ export async function compact(
2343
2339
  });
2344
2340
 
2345
2341
  // Compute file lists and append to summary
2346
- const { readFiles, modifiedFiles } = computeFileLists(fileOps);
2347
- summary = upsertFileOperations(summary, readFiles, modifiedFiles, fileOps.read);
2342
+ const { readFiles, modifiedFiles } = snapcompact.computeFileLists(fileOps);
2343
+ summary = snapcompact.upsertFileOperations(summary, readFiles, modifiedFiles, fileOps.read);
2348
2344
  if (nativeSummary !== undefined) {
2349
2345
  // The replayed block (summary and any `tool_changes`) stays
2350
2346
  // byte-identical to the API's output so it matches its signature. The
@@ -2352,7 +2348,7 @@ export async function compact(
2352
2348
  // summary message with the block and skips its text, so they would
2353
2349
  // otherwise be invisible to this provider. Every other provider keeps
2354
2350
  // reading the entry text.
2355
- const filesText = upsertFileOperations("", readFiles, modifiedFiles, fileOps.read) || undefined;
2351
+ const filesText = snapcompact.upsertFileOperations("", readFiles, modifiedFiles, fileOps.read) || undefined;
2356
2352
  preserveData = withAnthropicCompactionPreserveData(preserveData, {
2357
2353
  provider: model.provider,
2358
2354
  content: nativeSummary,
@@ -965,7 +965,6 @@ export async function requestOpenAiRemoteCompaction(
965
965
  if (attestation) {
966
966
  headers[OPENAI_HEADERS.ATTESTATION] = attestation;
967
967
  }
968
- headers[OPENAI_HEADERS.BETA] = OPENAI_HEADER_VALUES.BETA_RESPONSES;
969
968
  headers[OPENAI_HEADERS.ORIGINATOR] = OPENAI_HEADER_VALUES.ORIGINATOR_CODEX;
970
969
  // This compaction request sends no `service_tier`, so the hint is model-only.
971
970
  headers[OPENAI_HEADERS.ROUTING_HINT] = codexRoutingHint(request.model, undefined);
@@ -5,27 +5,27 @@
5
5
  import type { Message, ToolCall } from "@oh-my-pi/pi-ai";
6
6
  import { type Dialect, getDialectDefinition } from "@oh-my-pi/pi-ai/dialect";
7
7
  import { escapeHarmonyControlTokens } from "@oh-my-pi/pi-ai/utils/harmony-leak";
8
- import { formatGroupedPaths, prompt, stringifyJson } from "@oh-my-pi/pi-utils";
8
+ import { prompt, stringifyJson } from "@oh-my-pi/pi-utils";
9
+ import * as snapcompact from "@oh-my-pi/snapcompact";
9
10
  import type { AgentMessage } from "../types";
10
- import fileOperationsTemplate from "./prompts/file-operations.md" with { type: "text" };
11
11
  import summarizationSystemPrompt from "./prompts/summarization-system.md" with { type: "text" };
12
12
 
13
- // ============================================================================
14
- // File Operation Tracking
15
- // ============================================================================
13
+ /** @deprecated Import these from `@oh-my-pi/snapcompact`. */
14
+ export {
15
+ computeFileLists,
16
+ createFileOps,
17
+ type FileOperations,
18
+ isUrlSchemePath,
19
+ upsertFileOperations,
20
+ } from "@oh-my-pi/snapcompact";
16
21
 
17
- export interface FileOperations {
18
- read: Set<string>;
19
- written: Set<string>;
20
- edited: Set<string>;
21
- }
22
-
23
- export function createFileOps(): FileOperations {
24
- return {
25
- read: new Set(),
26
- written: new Set(),
27
- edited: new Set(),
28
- };
22
+ /** @deprecated Use `upsertFileOperations("", readFiles, modifiedFiles, readSet)` from `@oh-my-pi/snapcompact`. */
23
+ export function formatFileOperations(
24
+ readFiles: string[],
25
+ modifiedFiles: string[],
26
+ readSet?: ReadonlySet<string>,
27
+ ): string {
28
+ return snapcompact.upsertFileOperations("", readFiles, modifiedFiles, readSet);
29
29
  }
30
30
 
31
31
  /** Error text for a summarization call that hit its output limit (`stopReason: "length"`): partial text is never used. */
@@ -91,27 +91,10 @@ export function isAlternateFormReadSelector(sel: string): boolean {
91
91
  return sel.split(":").some(part => READ_ALTERNATE_FORM_RE.test(part));
92
92
  }
93
93
 
94
- /**
95
- * A real filesystem path never contains a `scheme://` URL. Tool-call paths that
96
- * do — `conflict://1`, `artifact://3`, `local://ctx.md`, `history://…`,
97
- * `issue://12`, `https://…`, and the tolerated `file.ts:conflict://1` prefix
98
- * form — are session-scoped or remote resources, not files the post-compaction
99
- * agent can re-ground on. Keep them out of the `<files>` summary.
100
- */
101
- const URL_SCHEME_RE = /[a-z][a-z0-9+.-]*:\/\//i;
102
-
103
- /**
104
- * Whether `path` references a `scheme://` URL (internal URI or web URL) rather
105
- * than a filesystem path that belongs in the compaction `<files>` summary.
106
- */
107
- export function isUrlSchemePath(path: string): boolean {
108
- return URL_SCHEME_RE.test(path);
109
- }
110
-
111
94
  /**
112
95
  * Extract file operations from tool calls in an assistant message.
113
96
  */
114
- export function extractFileOpsFromMessage(message: AgentMessage, fileOps: FileOperations): void {
97
+ export function extractFileOpsFromMessage(message: AgentMessage, fileOps: snapcompact.FileOperations): void {
115
98
  if (message.role !== "assistant") return;
116
99
  if (!("content" in message) || !Array.isArray(message.content)) return;
117
100
 
@@ -128,7 +111,7 @@ export function extractFileOpsFromMessage(message: AgentMessage, fileOps: FileOp
128
111
 
129
112
  // Internal URIs (conflict://, artifact://, local://, history://, …) and
130
113
  // web URLs are not re-groundable files — keep them out of `<files>`.
131
- if (isUrlSchemePath(path)) continue;
114
+ if (snapcompact.isUrlSchemePath(path)) continue;
132
115
 
133
116
  switch (block.name) {
134
117
  case "read":
@@ -144,68 +127,6 @@ export function extractFileOpsFromMessage(message: AgentMessage, fileOps: FileOp
144
127
  }
145
128
  }
146
129
 
147
- /**
148
- * Compute final file lists from file operations.
149
- * Returns readFiles (files only read, not modified) and modifiedFiles.
150
- */
151
- export function computeFileLists(fileOps: FileOperations): { readFiles: string[]; modifiedFiles: string[] } {
152
- // Drop any `scheme://` URLs (e.g. legacy `conflict://`/`artifact://` entries
153
- // rehydrated straight into `fileOps` from a pre-fix compaction summary) — only
154
- // real files belong in `<files>`. New tool-call scans are already filtered.
155
- const modified = new Set([...fileOps.edited, ...fileOps.written].filter(f => !isUrlSchemePath(f)));
156
- const readOnly = [...fileOps.read].filter(f => !isUrlSchemePath(f) && !modified.has(f)).sort();
157
- const modifiedFiles = [...modified].sort();
158
- return { readFiles: readOnly, modifiedFiles };
159
- }
160
-
161
- /**
162
- * Format file operations as one `<files>` tag: a grouped, prefix-folded
163
- * directory tree (find-tool shape — `# dir/` headers, bare basenames) with a
164
- * ` (Read)` / ` (Write)` / ` (RW)` marker per file instead of separate
165
- * read/modified lists. `readSet` is the cumulative read set (`fileOps.read`),
166
- * used to tell modified files that were also read (RW) from blind writes.
167
- */
168
- const FILE_OPERATION_SUMMARY_LIMIT = 20;
169
-
170
- function stripFileOperationTags(summary: string): string {
171
- // Legacy <read-files>/<modified-files> tags are still stripped so summaries
172
- // written before the combined <files> tag self-heal on the next compaction.
173
- return summary
174
- .replace(/<files>[\s\S]*?<\/files>\s*/g, "")
175
- .replace(/<read-files>[\s\S]*?<\/read-files>\s*/g, "")
176
- .replace(/<modified-files>[\s\S]*?<\/modified-files>\s*/g, "")
177
- .trimEnd();
178
- }
179
- export function formatFileOperations(
180
- readFiles: string[],
181
- modifiedFiles: string[],
182
- readSet?: ReadonlySet<string>,
183
- ): string {
184
- if (readFiles.length === 0 && modifiedFiles.length === 0) return "";
185
- const mode = new Map<string, "Read" | "Write" | "RW">();
186
- for (const file of readFiles) mode.set(file, "Read");
187
- for (const file of modifiedFiles) mode.set(file, readSet?.has(file) ? "RW" : "Write");
188
- const all = [...mode.keys()].sort();
189
- let files = formatGroupedPaths(all.slice(0, FILE_OPERATION_SUMMARY_LIMIT), path => ` (${mode.get(path)})`);
190
- if (all.length > FILE_OPERATION_SUMMARY_LIMIT) {
191
- files += `\n[…${all.length - FILE_OPERATION_SUMMARY_LIMIT} files elided…]`;
192
- }
193
- return prompt.render(fileOperationsTemplate, { files });
194
- }
195
-
196
- export function upsertFileOperations(
197
- summary: string,
198
- readFiles: string[],
199
- modifiedFiles: string[],
200
- readSet?: ReadonlySet<string>,
201
- ): string {
202
- const baseSummary = stripFileOperationTags(summary);
203
- const fileOperations = formatFileOperations(readFiles, modifiedFiles, readSet);
204
- if (!fileOperations) return baseSummary;
205
- if (!baseSummary) return fileOperations;
206
- return `${baseSummary}\n\n${fileOperations}`;
207
- }
208
-
209
130
  // ============================================================================
210
131
  // Message Serialization
211
132
  // ============================================================================
package/src/telemetry.ts CHANGED
@@ -1836,7 +1836,7 @@ export interface InstrumentedChatSpanOptions {
1836
1836
  export async function instrumentedCompleteSimple<TApi extends Api>(
1837
1837
  model: Model<TApi>,
1838
1838
  ctx: Context,
1839
- options: SimpleStreamOptions,
1839
+ options: SimpleStreamOptions & { onAttempt?: (message: AssistantMessage) => void },
1840
1840
  span: InstrumentedChatSpanOptions,
1841
1841
  ): Promise<AssistantMessage> {
1842
1842
  const { telemetry, parent, oneshotKind } = span;