@oh-my-pi/pi-agent-core 14.9.2 → 14.9.5

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,27 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [14.9.5] - 2026-05-12
6
+
7
+ ### Added
8
+
9
+ - Added an `isError?: boolean` field on `AgentToolResult` so tools can flag a non-throwing failure (e.g. an aggregator that catches per-entry errors). `coerceToolResult` preserves the flag and the agent loop surfaces it as a tool error on the wire.
10
+
11
+ ## [14.9.3] - 2026-05-10
12
+ ### Added
13
+
14
+ - Added `onHarmonyLeak` option on `Agent`/loop config to receive GPT-5 Harmony leak audit callbacks
15
+ - Added harmony-leak detection and audit exports to the package index for programmatic leak detection and recovery hooks
16
+
17
+ ### Changed
18
+
19
+ - Changed OpenAI Codex model runs to detect GPT-5 Harmony protocol leakage during streaming and automatically retry or recover tool calls instead of sending contaminated arguments downstream
20
+
21
+ ### Security
22
+
23
+ - Hardened tool-call handling against leaked `to=functions.*` protocol tails by truncating or retrying before execution
24
+ - Hardened failure handling so repeated GPT-5 Harmony leak mitigation is retried only up to two times before escalating to an explicit error
25
+
5
26
  ## [14.9.0] - 2026-05-10
6
27
  ### Added
7
28
 
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": "14.9.2",
4
+ "version": "14.9.5",
5
5
  "description": "General-purpose agent with transport abstraction, state management, and attachment support",
6
6
  "homepage": "https://github.com/can1357/oh-my-pi",
7
7
  "author": "Can Boluk",
@@ -35,9 +35,9 @@
35
35
  "fmt": "biome format --write ."
36
36
  },
37
37
  "dependencies": {
38
- "@oh-my-pi/pi-ai": "14.9.2",
39
- "@oh-my-pi/pi-natives": "14.9.2",
40
- "@oh-my-pi/pi-utils": "14.9.2"
38
+ "@oh-my-pi/pi-ai": "14.9.5",
39
+ "@oh-my-pi/pi-natives": "14.9.5",
40
+ "@oh-my-pi/pi-utils": "14.9.5"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@sinclair/typebox": "^0.34.49",
package/src/agent-loop.ts CHANGED
@@ -12,6 +12,15 @@ import {
12
12
  validateToolArguments,
13
13
  } from "@oh-my-pi/pi-ai";
14
14
  import { sanitizeText } from "@oh-my-pi/pi-natives";
15
+ import {
16
+ createHarmonyAuditEvent,
17
+ extractHarmonyRemoved,
18
+ type HarmonyDetection,
19
+ type HarmonyRecoveredToolCall,
20
+ isHarmonyLeakMitigationTarget,
21
+ recoverHarmonyToolCall,
22
+ signalListLabel,
23
+ } from "./harmony-leak";
15
24
  import type {
16
25
  AgentContext,
17
26
  AgentEvent,
@@ -25,6 +34,17 @@ import type {
25
34
  /** Sentinel returned by the abort race in `streamAssistantResponse`. */
26
35
  const ABORTED: unique symbol = Symbol("agent-loop-aborted");
27
36
 
37
+ class HarmonyLeakInterruption extends Error {
38
+ constructor(
39
+ readonly detection: HarmonyDetection,
40
+ readonly removed: string,
41
+ readonly recovered?: HarmonyRecoveredToolCall,
42
+ ) {
43
+ super(`Detected GPT-5 Harmony protocol leakage (${signalListLabel(detection.signals)})`);
44
+ this.name = "HarmonyLeakInterruption";
45
+ }
46
+ }
47
+
28
48
  /**
29
49
  * Normalize a value coming back from `tool.execute()` (or its streaming partial-update callback)
30
50
  * into a structurally valid {@link AgentToolResult}.
@@ -38,12 +58,17 @@ function coerceToolResult(raw: unknown): { result: AgentToolResult<any>; malform
38
58
  const rawObj = raw && typeof raw === "object" ? (raw as Record<string, unknown>) : null;
39
59
  const rawContent = rawObj?.content;
40
60
  const details = rawObj && "details" in rawObj ? rawObj.details : {};
61
+ // Tools may flag a non-throwing failure on the result itself (e.g. an
62
+ // aggregator that catches per-entry errors and synthesizes a combined
63
+ // result). Preserve the flag so agent-loop can surface it on the wire.
64
+ const explicitError = Boolean(rawObj && "isError" in rawObj && rawObj.isError);
41
65
 
42
66
  if (!Array.isArray(rawContent)) {
43
67
  return {
44
68
  result: {
45
69
  content: [{ type: "text", text: "Tool returned an invalid result: missing content array." }],
46
70
  details,
71
+ isError: true,
47
72
  },
48
73
  malformed: true,
49
74
  };
@@ -62,7 +87,7 @@ function coerceToolResult(raw: unknown): { result: AgentToolResult<any>; malform
62
87
  content.push(block as { type: "image"; data: string; mimeType: string });
63
88
  }
64
89
  }
65
- return { result: { content, details }, malformed: false };
90
+ return { result: { content, details, ...(explicitError ? { isError: true } : {}) }, malformed: false };
66
91
  }
67
92
 
68
93
  /**
@@ -255,6 +280,8 @@ async function runLoop(
255
280
  let firstTurn = true;
256
281
  // Check for steering messages at start (user may have typed while waiting)
257
282
  let pendingMessages: AgentMessage[] = (await config.getSteeringMessages?.()) || [];
283
+ let harmonyRetryAttempt = 0;
284
+ let harmonyTruncateResumeCount = 0;
258
285
 
259
286
  // Outer loop: continues when queued follow-up messages arrive after agent would stop
260
287
  while (true) {
@@ -285,7 +312,44 @@ async function runLoop(
285
312
  }
286
313
 
287
314
  // Stream assistant response
288
- const message = await streamAssistantResponse(currentContext, config, signal, stream, streamFn);
315
+ let recovered: HarmonyRecoveredToolCall | undefined;
316
+ let message: AssistantMessage;
317
+ try {
318
+ message = await streamAssistantResponse(
319
+ currentContext,
320
+ config,
321
+ signal,
322
+ stream,
323
+ streamFn,
324
+ harmonyRetryAttempt,
325
+ );
326
+ harmonyRetryAttempt = 0;
327
+ harmonyTruncateResumeCount = 0;
328
+ } catch (err) {
329
+ if (!(err instanceof HarmonyLeakInterruption)) throw err;
330
+ if (err.recovered) {
331
+ if (harmonyTruncateResumeCount >= 2) {
332
+ await emitHarmonyAudit(config, err, "escalated", harmonyRetryAttempt);
333
+ throw new Error(
334
+ `GPT-5 Harmony leak recurred after truncate-and-resume recovery (${signalListLabel(err.detection.signals)}).`,
335
+ );
336
+ }
337
+ harmonyTruncateResumeCount++;
338
+ recovered = err.recovered;
339
+ message = recovered.message;
340
+ await emitHarmonyAudit(config, err, "truncate_resume", harmonyRetryAttempt);
341
+ } else {
342
+ if (harmonyRetryAttempt >= 2) {
343
+ await emitHarmonyAudit(config, err, "escalated", harmonyRetryAttempt);
344
+ throw new Error(
345
+ `GPT-5 Harmony leak persisted after ${harmonyRetryAttempt} retries (${signalListLabel(err.detection.signals)}).`,
346
+ );
347
+ }
348
+ await emitHarmonyAudit(config, err, "abort_retry", harmonyRetryAttempt);
349
+ harmonyRetryAttempt++;
350
+ continue;
351
+ }
352
+ }
289
353
  newMessages.push(message);
290
354
  let steeringMessagesFromExecution: AgentMessage[] | undefined;
291
355
 
@@ -355,6 +419,23 @@ async function runLoop(
355
419
  stream.end(newMessages);
356
420
  }
357
421
 
422
+ async function emitHarmonyAudit(
423
+ config: AgentLoopConfig,
424
+ interruption: HarmonyLeakInterruption,
425
+ action: "truncate_resume" | "abort_retry" | "escalated",
426
+ retryN: number,
427
+ ): Promise<void> {
428
+ await config.onHarmonyLeak?.(
429
+ createHarmonyAuditEvent({
430
+ action,
431
+ detection: interruption.detection,
432
+ model: config.model,
433
+ retryN,
434
+ removed: interruption.removed,
435
+ }),
436
+ );
437
+ }
438
+
358
439
  /**
359
440
  * Stream an assistant response from the LLM.
360
441
  * This is where AgentMessage[] gets transformed to Message[] for the LLM.
@@ -365,6 +446,7 @@ async function streamAssistantResponse(
365
446
  signal: AbortSignal | undefined,
366
447
  stream: EventStream<AgentEvent, AgentMessage[]>,
367
448
  streamFn?: StreamFn,
449
+ harmonyRetryAttempt = 0,
368
450
  ): Promise<AssistantMessage> {
369
451
  // Apply context transform if configured (AgentMessage[] → AgentMessage[])
370
452
  let messages = context.messages;
@@ -397,33 +479,63 @@ async function streamAssistantResponse(
397
479
 
398
480
  const dynamicToolChoice = config.getToolChoice?.();
399
481
  const dynamicReasoning = config.getReasoning?.();
482
+ const harmonyMitigationEnabled = isHarmonyLeakMitigationTarget(config.model);
483
+ const harmonyAbortController = harmonyMitigationEnabled ? new AbortController() : undefined;
484
+ const requestSignal = harmonyAbortController
485
+ ? signal
486
+ ? AbortSignal.any([signal, harmonyAbortController.signal])
487
+ : harmonyAbortController.signal
488
+ : signal;
400
489
  const response = await streamFunction(config.model, llmContext, {
401
490
  ...config,
402
491
  apiKey: resolvedApiKey,
403
492
  metadata: resolvedMetadata,
404
493
  toolChoice: dynamicToolChoice ?? config.toolChoice,
405
494
  reasoning: dynamicReasoning ?? config.reasoning,
406
- signal,
495
+ temperature:
496
+ harmonyRetryAttempt > 0 && config.temperature !== undefined ? config.temperature + 0.05 : config.temperature,
497
+ signal: requestSignal,
407
498
  });
408
499
 
409
500
  let partialMessage: AssistantMessage | null = null;
410
501
  let addedPartial = false;
411
502
 
412
503
  const responseIterator = response[Symbol.asyncIterator]();
504
+
505
+ const _interruptForHarmonyLeak = (message: AssistantMessage, detection: HarmonyDetection): never => {
506
+ const recovered = recoverHarmonyToolCall(message, detection);
507
+ const removed = recovered?.removed ?? extractHarmonyRemoved(message, detection);
508
+ harmonyAbortController?.abort();
509
+ responseIterator.return?.()?.catch(() => {});
510
+ if (recovered) {
511
+ if (addedPartial) {
512
+ context.messages[context.messages.length - 1] = recovered.message;
513
+ } else {
514
+ context.messages.push(recovered.message);
515
+ stream.push({ type: "message_start", message: { ...recovered.message } });
516
+ }
517
+ stream.push({ type: "message_end", message: recovered.message });
518
+ throw new HarmonyLeakInterruption(detection, removed, recovered);
519
+ }
520
+ if (addedPartial) {
521
+ context.messages.pop();
522
+ }
523
+ throw new HarmonyLeakInterruption(detection, removed);
524
+ };
413
525
  // Set up a single abort race: register the abort listener once for the whole
414
526
  // stream and reuse the same race promise for every iterator.next() instead of
415
527
  // allocating Promise.withResolvers and add/removeEventListener per event.
416
528
  let abortRacePromise: Promise<typeof ABORTED> | undefined;
417
529
  let detachAbortListener: (() => void) | undefined;
418
- if (signal) {
419
- if (signal.aborted) {
530
+ if (requestSignal) {
531
+ if (requestSignal.aborted) {
420
532
  return emitAbortedAssistantMessage(partialMessage, addedPartial, context, config, stream);
421
533
  }
422
534
  const { promise, resolve } = Promise.withResolvers<typeof ABORTED>();
423
535
  const onAbort = () => resolve(ABORTED);
424
- signal.addEventListener("abort", onAbort, { once: true });
536
+ requestSignal.addEventListener("abort", onAbort, { once: true });
425
537
  abortRacePromise = promise;
426
- detachAbortListener = () => signal.removeEventListener("abort", onAbort);
538
+ detachAbortListener = () => requestSignal.removeEventListener("abort", onAbort);
427
539
  }
428
540
 
429
541
  try {
@@ -439,7 +551,7 @@ async function streamAssistantResponse(
439
551
  } else {
440
552
  next = await responseIterator.next();
441
553
  }
442
- if (signal?.aborted) {
554
+ if (requestSignal?.aborted) {
443
555
  return emitAbortedAssistantMessage(partialMessage, addedPartial, context, config, stream);
444
556
  }
445
557
  if (next.done) break;
@@ -720,7 +832,7 @@ async function executeToolCalls(
720
832
  );
721
833
  const coerced = coerceToolResult(rawResult);
722
834
  result = coerced.result;
723
- if (coerced.malformed) isError = true;
835
+ if (coerced.malformed || result.isError) isError = true;
724
836
  } catch (e) {
725
837
  result = {
726
838
  content: [{ type: "text", text: e instanceof Error ? e.message : String(e) }],
package/src/agent.ts CHANGED
@@ -21,6 +21,7 @@ import {
21
21
  type ToolResultMessage,
22
22
  } from "@oh-my-pi/pi-ai";
23
23
  import { agentLoop, agentLoopContinue } from "./agent-loop";
24
+ import type { HarmonyAuditEvent } from "./harmony-leak";
24
25
  import type {
25
26
  AgentContext,
26
27
  AgentEvent,
@@ -144,6 +145,11 @@ export interface AgentOptions {
144
145
  * Use this when abort decisions must happen before buffered events continue flowing.
145
146
  */
146
147
  onAssistantMessageEvent?: (message: AssistantMessage, event: AssistantMessageEvent) => void;
148
+
149
+ /**
150
+ * Called when GPT-5 Harmony protocol leakage is detected and mitigated.
151
+ */
152
+ onHarmonyLeak?: (event: HarmonyAuditEvent) => void | Promise<void>;
147
153
  /**
148
154
  * Custom token budgets for thinking levels (token-based providers only).
149
155
  */
@@ -264,6 +270,7 @@ export class Agent {
264
270
  #onResponse?: SimpleStreamOptions["onResponse"];
265
271
  #onSseEvent?: SimpleStreamOptions["onSseEvent"];
266
272
  #onAssistantMessageEvent?: (message: AssistantMessage, event: AssistantMessageEvent) => void;
273
+ #onHarmonyLeak?: (event: HarmonyAuditEvent) => void | Promise<void>;
267
274
 
268
275
  /** Buffered Cursor tool results with text length at time of call (for correct ordering) */
269
276
  #cursorToolResultBuffer: CursorToolResultEntry[] = [];
@@ -304,6 +311,7 @@ export class Agent {
304
311
  this.#intentTracing = opts.intentTracing === true;
305
312
  this.#getToolChoice = opts.getToolChoice;
306
313
  this.#onAssistantMessageEvent = opts.onAssistantMessageEvent;
314
+ this.#onHarmonyLeak = opts.onHarmonyLeak;
307
315
  }
308
316
 
309
317
  /**
@@ -861,6 +869,7 @@ export class Agent {
861
869
  transformToolCallArguments: this.#transformToolCallArguments,
862
870
  intentTracing: this.#intentTracing,
863
871
  onAssistantMessageEvent: this.#onAssistantMessageEvent,
872
+ onHarmonyLeak: this.#onHarmonyLeak,
864
873
  getToolChoice,
865
874
  getReasoning: () => this.#state.thinkingLevel,
866
875
  getSteeringMessages: async () => {
@@ -0,0 +1,428 @@
1
+ /**
2
+ * GPT-5 Harmony-header leakage detection and recovery.
3
+ *
4
+ * Background and policy: see `docs/ERRATA-GPT5-HARMONY.md`. This module
5
+ * implements §3 of that document: detection by signal fusion, plus a
6
+ * truncate-and-resume primitive for the `edit` tool when its input is in
7
+ * hashline DSL form. Other tools and surfaces fall through to
8
+ * abort-and-retry handled by the agent loop.
9
+ */
10
+ import type { AssistantMessage, Model, ToolCall } from "@oh-my-pi/pi-ai";
11
+
12
+ // Single source of truth for the marker pattern. `M` in the errata.
13
+ // Use a fresh non-global instance for `.test()` to avoid lastIndex pitfalls.
14
+ const MARKER_RE = /\bto=functions\.[A-Za-z_]\w*/g;
15
+ const HARMONY_RE = /<\|(start|end|channel|message|call|return)\|>/g;
16
+
17
+ // Channel-word adjacency (`C`): channel/role name appearing immediately before the marker.
18
+ const CHANNEL_WORD_RE = /\b(?:analysis|commentary|assistant|user|system|developer|tool)\s+to=functions\./;
19
+
20
+ // Glitch-token adjacency (`G`). The Japgolly literal is escaped so this regex
21
+ // source itself does not trip detection if the file is scanned (e.g. when
22
+ // editing this module via the same agent that detects).
23
+ const GLITCH_RE = /\b(?:changedFiles|RTLU|Jsii(?:_commentary)?|\x4aapgolly)\b/;
24
+
25
+ // Body-channel cascade (`B`): marker followed by ` code` then another marker
26
+ // within 200 chars. Single regex; no manual slicing needed.
27
+ const BODY_CASCADE_RE = /to=functions\.\w+\s+code\b[\s\S]{0,200}?to=functions\./;
28
+
29
+ // Fake-result framing (`R`): marker followed within 80 chars by Cell N: framing.
30
+ const FAKE_RESULT_RE = /to=functions\.\w+[\s\S]{0,80}?code_output\s*\nCell\s+\d+:/;
31
+
32
+ const FENCE_RE = /^\s*(?:```+|~~~+)/;
33
+
34
+ // Non-Latin scripts seen in the corpus: CJK + ext, Cyrillic, Thai, Georgian,
35
+ // Armenian, Kannada, Telugu, Devanagari, Arabic, Malayalam.
36
+ const SCRIPT_CLASS =
37
+ "\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF\u0400-\u04FF\u0E00-\u0E7F\u10A0-\u10FF\u0530-\u058F\u0C80-\u0CFF\u0C00-\u0C7F\u0900-\u097F\u0600-\u06FF\u0D00-\u0D7F";
38
+ const SCRIPT_RUN_RE = new RegExp(`[${SCRIPT_CLASS}]{2,}`, "u");
39
+ const _SCRIPT_CHAR_RE = new RegExp(`[${SCRIPT_CLASS}]`, "u");
40
+
41
+ // Recovery registry. Each entry's parser must recognize the configured
42
+ // sentinel (per-tool, see eval/parse.ts and hashline/parser.ts) and surface
43
+ // a warning to the model so it knows to re-issue any remaining work.
44
+ // `accepts` gates on input shape: tools whose contaminated input doesn't
45
+ // match the parser's expected DSL fall through to abort-and-retry.
46
+ //
47
+ // • `edit`: hashline DSL input begins with `@<path>`. Apply_patch envelopes
48
+ // (`*** Begin Patch …`) and JSON-schema variants are not recoverable —
49
+ // their parsers don't recognize `*** Abort`.
50
+ // • `eval`: any string is a parseable cell sequence (the parser is lenient
51
+ // and falls back to implicit-cell mode on bare strings).
52
+ interface RecoveryConfig {
53
+ sentinel: string;
54
+ accepts: (input: string) => boolean;
55
+ }
56
+ const RECOVERY_REGISTRY: Record<string, RecoveryConfig> = {
57
+ edit: {
58
+ sentinel: "\n*** Abort\n",
59
+ accepts: input => input.replace(/^\s+/, "").startsWith("@"),
60
+ },
61
+ eval: {
62
+ sentinel: "\n*** Abort\n",
63
+ accepts: () => true,
64
+ },
65
+ };
66
+
67
+ const SIGNAL_ORDER = ["M", "C", "G", "S", "B", "R", "T"] as const;
68
+
69
+ export type HarmonySignalClass = "H" | (typeof SIGNAL_ORDER)[number];
70
+
71
+ export type HarmonySurface = "assistant_text" | "assistant_thinking" | "tool_arg";
72
+
73
+ export interface HarmonySignal {
74
+ classes: HarmonySignalClass[];
75
+ start: number;
76
+ end: number;
77
+ text: string;
78
+ }
79
+
80
+ export interface HarmonyDetection {
81
+ surface: HarmonySurface;
82
+ contentIndex?: number;
83
+ toolName?: string;
84
+ toolCallId?: string;
85
+ signals: HarmonySignal[];
86
+ }
87
+
88
+ export interface HarmonyAuditEvent {
89
+ action: "truncate_resume" | "abort_retry" | "escalated";
90
+ surface: HarmonySurface;
91
+ signal: string;
92
+ retryN: number;
93
+ model: string;
94
+ provider: string;
95
+ toolName?: string;
96
+ removedLen: number;
97
+ removedSha8: string;
98
+ removedPreview: string;
99
+ removedBlob?: string;
100
+ }
101
+
102
+ export interface HarmonyRecoveredToolCall {
103
+ message: AssistantMessage;
104
+ removed: string;
105
+ }
106
+
107
+ /**
108
+ * Whether to run leak detection on responses from this model. We default-on
109
+ * for every openai-codex model rather than enumerating ids, so a future
110
+ * gpt-5.6 (or whatever) doesn't silently bypass the mitigation. Detection
111
+ * itself is cheap; the cost of missing a leak on a new model is not.
112
+ */
113
+ export function isHarmonyLeakMitigationTarget(model: Model): boolean {
114
+ return model.provider === "openai-codex";
115
+ }
116
+
117
+ export function signalListLabel(signals: readonly HarmonySignal[]): string {
118
+ const seen: string[] = [];
119
+ for (const signal of signals) {
120
+ const label = signal.classes.join("+");
121
+ if (!seen.includes(label)) seen.push(label);
122
+ }
123
+ return seen.join(",") || "none";
124
+ }
125
+
126
+ /**
127
+ * Detect harmony-protocol leakage in `text`. Returns undefined if clean.
128
+ *
129
+ * Trip rule: `H` alone, or `M` paired with at least one co-signal
130
+ * (`C`/`G`/`S`/`B`/`R`/`T`). Bare `M` does not trip — this document, its
131
+ * tests, and bug reports legitimately carry the marker.
132
+ *
133
+ * `parsedEnd`, when supplied, marks the byte at which a structurally valid
134
+ * tool-argument parse ends; markers strictly after it set the `T` co-signal.
135
+ * `contentIndex`/`toolName`/`toolCallId` flow through to the returned
136
+ * detection for downstream auditing.
137
+ */
138
+ export function detectHarmonyLeak(
139
+ text: string,
140
+ surface: HarmonySurface,
141
+ options: {
142
+ parsedEnd?: number;
143
+ contentIndex?: number;
144
+ toolName?: string;
145
+ toolCallId?: string;
146
+ } = {},
147
+ ): HarmonyDetection | undefined {
148
+ const fences = computeFenceRanges(text);
149
+ const signals: HarmonySignal[] = [];
150
+
151
+ for (const match of text.matchAll(HARMONY_RE)) {
152
+ const start = match.index ?? 0;
153
+ if (isInsideFence(fences, start)) continue;
154
+ signals.push(makeSignal(["H"], start, start + match[0].length, match[0]));
155
+ }
156
+
157
+ for (const match of text.matchAll(MARKER_RE)) {
158
+ const start = match.index ?? 0;
159
+ if (isInsideFence(fences, start)) continue;
160
+ const end = start + match[0].length;
161
+ const classes: HarmonySignalClass[] = ["M"];
162
+
163
+ const adjacent = text.slice(Math.max(0, start - 64), Math.min(text.length, end + 16));
164
+ const near = text.slice(Math.max(0, start - 16), Math.min(text.length, end + 16));
165
+ const forward = text.slice(start, Math.min(text.length, start + 240));
166
+
167
+ if (CHANNEL_WORD_RE.test(adjacent)) classes.push("C");
168
+ if (GLITCH_RE.test(near)) classes.push("G");
169
+ if (hasScriptMismatchNear(text, start, end)) classes.push("S");
170
+ if (BODY_CASCADE_RE.test(forward)) classes.push("B");
171
+ if (FAKE_RESULT_RE.test(forward)) classes.push("R");
172
+ if (options.parsedEnd !== undefined && start >= options.parsedEnd) classes.push("T");
173
+
174
+ // `M` alone never trips: legitimate documentation/tests carry it.
175
+ if (classes.length > 1) {
176
+ signals.push(makeSignal(classes, start, end, match[0]));
177
+ }
178
+ }
179
+
180
+ if (signals.length === 0) return undefined;
181
+ signals.sort((a, b) => a.start - b.start || a.end - b.end);
182
+ return {
183
+ surface,
184
+ contentIndex: options.contentIndex,
185
+ toolName: options.toolName,
186
+ toolCallId: options.toolCallId,
187
+ signals,
188
+ };
189
+ }
190
+
191
+ /** Scan an assistant message's content blocks; return the first detection. */
192
+ export function detectHarmonyLeakInAssistantMessage(message: AssistantMessage): HarmonyDetection | undefined {
193
+ for (let i = 0; i < message.content.length; i++) {
194
+ const block = message.content[i];
195
+ if (block.type === "text") {
196
+ const d = detectHarmonyLeak(block.text, "assistant_text", { contentIndex: i });
197
+ if (d) return d;
198
+ } else if (block.type === "thinking") {
199
+ const d = detectHarmonyLeak(block.thinking, "assistant_thinking", { contentIndex: i });
200
+ if (d) return d;
201
+ } else if (block.type === "toolCall") {
202
+ const argText = getToolArgumentText(block);
203
+ if (argText !== undefined) {
204
+ const d = detectHarmonyLeak(argText, "tool_arg", {
205
+ contentIndex: i,
206
+ toolName: block.name,
207
+ toolCallId: block.id,
208
+ });
209
+ if (d) return d;
210
+ }
211
+ }
212
+ }
213
+ return undefined;
214
+ }
215
+
216
+ /**
217
+ * Truncate a contaminated tool call at the start of the contaminated line and
218
+ * append the tool's recovery sentinel. Returns a recovered AssistantMessage
219
+ * (containing only the cleaned tool call), a synthetic continuation user
220
+ * message asking the model to re-issue the rest, and the removed substring
221
+ * for auditing. Returns undefined when the tool is not recovery-eligible or
222
+ * the truncation would leave nothing meaningful to dispatch.
223
+ *
224
+ * `providerPayload` is dropped from the recovered message: for Codex the
225
+ * encrypted reasoning blob is opaque/signed and we cannot validate that it is
226
+ * uncontaminated. The model re-reasons on the next turn.
227
+ */
228
+ export function recoverHarmonyToolCall(
229
+ message: AssistantMessage,
230
+ detection: HarmonyDetection,
231
+ ): HarmonyRecoveredToolCall | undefined {
232
+ if (detection.surface !== "tool_arg" || detection.contentIndex === undefined) return undefined;
233
+ const block = message.content[detection.contentIndex];
234
+ if (!block || block.type !== "toolCall") return undefined;
235
+
236
+ const config = RECOVERY_REGISTRY[block.name];
237
+ if (!config) return undefined;
238
+
239
+ const input = block.arguments?.input;
240
+ if (typeof input !== "string") return undefined;
241
+ if (!config.accepts(input)) return undefined;
242
+
243
+ const offset = detection.signals[0]?.start;
244
+ if (offset === undefined) return undefined;
245
+
246
+ const truncated = truncateAtLineAndAppendSentinel(input, offset, config.sentinel);
247
+ if (truncated === undefined) return undefined;
248
+
249
+ const cleanToolCall: ToolCall = {
250
+ ...block,
251
+ arguments: { ...block.arguments, input: truncated.clean },
252
+ };
253
+ const cleanMessage: AssistantMessage = {
254
+ ...message,
255
+ content: [cleanToolCall],
256
+ // Drop encrypted reasoning blob: opaque, possibly carries the leak forward.
257
+ providerPayload: undefined,
258
+ stopReason: "toolUse",
259
+ errorMessage: undefined,
260
+ };
261
+ return { message: cleanMessage, removed: truncated.removed };
262
+ }
263
+
264
+ /**
265
+ * Return the contaminated substring from `message` for audit purposes when
266
+ * recovery is not applicable (abort path). Walks from the first detected
267
+ * signal to end-of-content within the relevant block. Returns "" if the
268
+ * detection cannot be resolved against the message.
269
+ */
270
+ export function extractHarmonyRemoved(message: AssistantMessage, detection: HarmonyDetection): string {
271
+ if (detection.contentIndex === undefined) return "";
272
+ const block = message.content[detection.contentIndex];
273
+ if (!block) return "";
274
+ const start = detection.signals[0]?.start ?? 0;
275
+ if (block.type === "text") return block.text.slice(start);
276
+ if (block.type === "thinking") return block.thinking.slice(start);
277
+ if (block.type === "toolCall") {
278
+ const text = getToolArgumentText(block);
279
+ return text ? text.slice(start) : "";
280
+ }
281
+ return "";
282
+ }
283
+
284
+ export function createHarmonyAuditEvent(params: {
285
+ action: HarmonyAuditEvent["action"];
286
+ detection: HarmonyDetection;
287
+ model: Model;
288
+ retryN: number;
289
+ removed: string;
290
+ }): HarmonyAuditEvent {
291
+ return {
292
+ action: params.action,
293
+ surface: params.detection.surface,
294
+ signal: signalListLabel(params.detection.signals),
295
+ retryN: params.retryN,
296
+ model: params.model.id,
297
+ provider: params.model.provider,
298
+ toolName: params.detection.toolName,
299
+ removedLen: params.removed.length,
300
+ removedSha8: sha8(params.removed),
301
+ removedPreview: redactedJunkPreview(params.removed),
302
+ removedBlob: Bun.env.OMP_HARMONY_DEBUG === "1" ? params.removed : undefined,
303
+ };
304
+ }
305
+
306
+ // ─── internals ──────────────────────────────────────────────────────────────
307
+
308
+ function makeSignal(classes: HarmonySignalClass[], start: number, end: number, text: string): HarmonySignal {
309
+ if (classes[0] === "H") return { classes: ["H"], start, end, text };
310
+ const sorted: HarmonySignalClass[] = [];
311
+ for (const cls of SIGNAL_ORDER) {
312
+ if (classes.includes(cls)) sorted.push(cls);
313
+ }
314
+ return { classes: sorted, start, end, text };
315
+ }
316
+
317
+ /**
318
+ * Precompute fenced-code-block ranges once per text. Each range is a
319
+ * [start, end) span of bytes inside any ```/~~~ fence. O(n) once instead of
320
+ * O(n) per detected match.
321
+ */
322
+ function computeFenceRanges(text: string): Array<[number, number]> {
323
+ const ranges: Array<[number, number]> = [];
324
+ let inFence = false;
325
+ let fenceStart = 0;
326
+ let lineStart = 0;
327
+ while (lineStart <= text.length) {
328
+ const newline = text.indexOf("\n", lineStart);
329
+ const lineEnd = newline === -1 ? text.length : newline;
330
+ const line = text.slice(lineStart, lineEnd);
331
+ if (FENCE_RE.test(line)) {
332
+ if (inFence) {
333
+ ranges.push([fenceStart, lineEnd]);
334
+ inFence = false;
335
+ } else {
336
+ fenceStart = lineStart;
337
+ inFence = true;
338
+ }
339
+ }
340
+ if (newline === -1) break;
341
+ lineStart = newline + 1;
342
+ }
343
+ if (inFence) ranges.push([fenceStart, text.length]);
344
+ return ranges;
345
+ }
346
+
347
+ function isInsideFence(ranges: Array<[number, number]>, position: number): boolean {
348
+ for (const [start, end] of ranges) {
349
+ if (position >= start && position < end) return true;
350
+ if (start > position) break;
351
+ }
352
+ return false;
353
+ }
354
+
355
+ function hasScriptMismatchNear(text: string, start: number, end: number): boolean {
356
+ const near = text.slice(Math.max(0, start - 32), Math.min(text.length, end + 32));
357
+ if (!SCRIPT_RUN_RE.test(near)) return false;
358
+ const surrounding = text.slice(Math.max(0, start - 200), Math.min(text.length, end + 200));
359
+ if (surrounding.length === 0) return false;
360
+ let ascii = 0;
361
+ for (let i = 0; i < surrounding.length; i++) {
362
+ if (surrounding.charCodeAt(i) < 128) ascii++;
363
+ }
364
+ return ascii / surrounding.length >= 0.85;
365
+ }
366
+
367
+ /**
368
+ * Tool-call argument text used for detection scanning. For tools whose args
369
+ * include a free-form `input` string we scan that directly so reported byte
370
+ * offsets line up with the original. For everything else we fall back to a
371
+ * JSON-stringified blob so detection still fires; that path's offsets are
372
+ * NOT meaningful for slicing the original args, but the recovery path gates
373
+ * on `block.arguments.input` being a string and only ever slices that.
374
+ */
375
+ function getToolArgumentText(toolCall: ToolCall): string | undefined {
376
+ if (typeof toolCall.arguments?.input === "string") return toolCall.arguments.input;
377
+ try {
378
+ return JSON.stringify(toolCall.arguments);
379
+ } catch {
380
+ return undefined;
381
+ }
382
+ }
383
+
384
+ function truncateAtLineAndAppendSentinel(
385
+ input: string,
386
+ offset: number,
387
+ sentinel: string,
388
+ ): { clean: string; removed: string } | undefined {
389
+ const lineStart = offset <= 0 ? 0 : input.lastIndexOf("\n", offset - 1) + 1;
390
+ if (lineStart === 0) return undefined; // would cut everything
391
+ const head = input.slice(0, lineStart).replace(/\s+$/, "");
392
+ if (head.length === 0) return undefined;
393
+ return {
394
+ clean: head + sentinel,
395
+ removed: input.slice(lineStart),
396
+ };
397
+ }
398
+
399
+ function sha8(text: string): string {
400
+ return new Bun.CryptoHasher("sha256").update(text).digest("hex").slice(0, 8);
401
+ }
402
+
403
+ const PREVIEW_KEEP_RE = new RegExp(`[${SCRIPT_CLASS}\\s】【”“…」「、。]`, "u");
404
+ const PREVIEW_TOKEN_RE =
405
+ /^(?:to=functions\.[A-Za-z_]\w*|analysis|commentary|assistant|user|system|developer|tool|changedFiles|RTLU|Jsii(?:_commentary)?|\x4aapgolly)/;
406
+
407
+ /**
408
+ * Privacy-safe preview for the audit log: keeps marker/channel/glitch tokens,
409
+ * non-Latin script chars, and CJK punctuation; replaces everything else
410
+ * (potential source/secrets) with `·`. Sufficient to grow the glitch-token
411
+ * denylist from logs without exposing source content. Capped at 64 chars.
412
+ */
413
+ function redactedJunkPreview(text: string): string {
414
+ const source = text.slice(0, 64);
415
+ let out = "";
416
+ for (let i = 0; i < source.length; ) {
417
+ const tok = PREVIEW_TOKEN_RE.exec(source.slice(i));
418
+ if (tok) {
419
+ out += tok[0];
420
+ i += tok[0].length;
421
+ continue;
422
+ }
423
+ const ch = source[i] ?? "";
424
+ out += PREVIEW_KEEP_RE.test(ch) ? ch : "·";
425
+ i++;
426
+ }
427
+ return out;
428
+ }
package/src/index.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  export * from "./agent";
3
3
  // Loop functions
4
4
  export * from "./agent-loop";
5
+ export * from "./harmony-leak";
5
6
  // Proxy utilities
6
7
  export * from "./proxy";
7
8
  // Thinking selectors
package/src/types.ts CHANGED
@@ -14,6 +14,7 @@ import type {
14
14
  ToolResultMessage,
15
15
  } from "@oh-my-pi/pi-ai";
16
16
  import type { Static, TSchema } from "@sinclair/typebox";
17
+ import type { HarmonyAuditEvent } from "./harmony-leak";
17
18
 
18
19
  /** Stream function - can return sync or Promise for async config lookup */
19
20
  export type StreamFn = (
@@ -150,6 +151,11 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
150
151
  */
151
152
  onAssistantMessageEvent?: (message: AssistantMessage, event: AssistantMessageEvent) => void;
152
153
 
154
+ /**
155
+ * Called when GPT-5 Harmony protocol leakage is detected and mitigated.
156
+ */
157
+ onHarmonyLeak?: (event: HarmonyAuditEvent) => void | Promise<void>;
158
+
153
159
  /**
154
160
  * Dynamic tool choice override, resolved per LLM call.
155
161
  * When set and returns a value, overrides the static `toolChoice`.
@@ -217,6 +223,9 @@ export interface AgentToolResult<T = any, _TInput = unknown> {
217
223
  content: (TextContent | ImageContent)[];
218
224
  // Details to be displayed in a UI or logged
219
225
  details?: T;
226
+ // Marks a non-throwing failure (e.g. an aggregator catching per-entry errors).
227
+ // agent-loop honors this and surfaces it as a tool error on the wire.
228
+ isError?: boolean;
220
229
  }
221
230
 
222
231
  // Callback for streaming tool execution updates