@oh-my-pi/pi-agent-core 18.13.2 → 18.14.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/CHANGELOG.md CHANGED
@@ -2,6 +2,27 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.14.0] - 2026-10-29
6
+
7
+ ### Breaking Changes
8
+
9
+ - Removed the Bun event-loop workaround utilities from `@oh-my-pi/pi-agent-core/utils/yield`; agent loops no longer insert cooperative sleeps while streaming or between tool calls.
10
+
11
+ ### Added
12
+
13
+ - Added `AgentTool.approvalRestrictionPaths` so tools can report the paths they read, allowing restricted-folder approvals to cover those paths without remembering approval separately for each path.
14
+ - Added `Agent.logContext`, which adds custom context fields to every log record written during an agent run.
15
+
16
+ ## [18.13.3] - 2026-10-28
17
+
18
+ ### Changed
19
+
20
+ - Updated Bun engine requirement to ≥1.4.4
21
+
22
+ ### Fixed
23
+
24
+ - Fixed chat-completions context estimates counting past thinking that the request drops (no reasoning replay), which triggered compaction far below the threshold ([#15797](https://github.com/can1357/oh-my-pi/issues/15797), [#17231](https://github.com/can1357/oh-my-pi/pull/17231) by [@ghisguth](https://github.com/ghisguth))
25
+
5
26
  ## [18.13.2] - 2026-10-27
6
27
 
7
28
  ### Added
package/README.md CHANGED
@@ -21,7 +21,7 @@ const agent = new Agent({
21
21
  },
22
22
  });
23
23
 
24
- agent.subscribe((event) => {
24
+ agent.subscribe(event => {
25
25
  if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
26
26
  // Stream just the new text chunk
27
27
  process.stdout.write(event.assistantMessageEvent.delta);
@@ -219,7 +219,7 @@ await agent.waitForIdle(); // Wait for completion
219
219
  ### Events
220
220
 
221
221
  ```typescript
222
- const unsubscribe = agent.subscribe((event) => {
222
+ const unsubscribe = agent.subscribe(event => {
223
223
  console.log(event.type);
224
224
  });
225
225
  unsubscribe();
@@ -274,8 +274,8 @@ Handle custom types in `convertToLlm`:
274
274
 
275
275
  ```typescript
276
276
  const agent = new Agent({
277
- convertToLlm: (messages) =>
278
- messages.flatMap((m) => {
277
+ convertToLlm: messages =>
278
+ messages.flatMap(m => {
279
279
  if (m.role === "notification") return []; // Filter out
280
280
  return [m];
281
281
  }),
@@ -360,7 +360,7 @@ const context: AgentContext = {
360
360
 
361
361
  const config: AgentLoopConfig = {
362
362
  model: getModel("openai", "gpt-4o"),
363
- convertToLlm: (msgs) => msgs.filter((m) => ["user", "assistant", "toolResult"].includes(m.role)),
363
+ convertToLlm: msgs => msgs.filter(m => ["user", "assistant", "toolResult"].includes(m.role)),
364
364
  };
365
365
 
366
366
  const userMessage = { role: "user", content: "Hello", timestamp: Date.now() };
@@ -376,6 +376,7 @@ for await (const event of agentLoopContinue(context, config)) {
376
376
  ```
377
377
 
378
378
  ## Run-level telemetry
379
+
379
380
  Every `invoke_agent` produces two values alongside the OTEL spans:
380
381
 
381
382
  - **`AgentRunSummary`** — chat / tool / usage / cost / error counters bucketed
@@ -447,10 +448,7 @@ Callers that drive the loop multiple times (verify pass, benchmark harness)
447
448
  fold N summaries with `aggregateAgentRunSummaries` / `aggregateAgentRunCoverage`:
448
449
 
449
450
  ```typescript
450
- import {
451
- aggregateAgentRunSummaries,
452
- aggregateAgentRunCoverage,
453
- } from "@oh-my-pi/pi-agent";
451
+ import { aggregateAgentRunSummaries, aggregateAgentRunCoverage } from "@oh-my-pi/pi-agent";
454
452
 
455
453
  const summaries: AgentRunSummary[] = [];
456
454
  const coverages: AgentRunCoverage[] = [];
@@ -295,6 +295,11 @@ export declare class Agent {
295
295
  * Set the prompt cache key forwarded to providers.
296
296
  */
297
297
  set promptCacheKey(value: string | undefined);
298
+ /**
299
+ * Fields added to every log record written while this agent runs (the
300
+ * owning session's `sessionId`), so in-process agents' records stay apart.
301
+ */
302
+ logContext: Readonly<Record<string, unknown>> | undefined;
298
303
  /**
299
304
  * Static metadata forwarded to every API request when no resolver is installed
300
305
  * (e.g. `metadata.user_id` for Anthropic session attribution). Setting this
@@ -379,6 +384,13 @@ export declare class Agent {
379
384
  /** Strip tool descriptions from provider-bound specs; read per request. */
380
385
  get pruneToolDescriptions(): boolean;
381
386
  set pruneToolDescriptions(value: boolean);
387
+ /**
388
+ * Whether requests to `model` actually ship description-stripped tool specs.
389
+ * Owned dialects render full definitions in-band, so they never prune; the
390
+ * loop applies the same rule (`pruneToolDescriptions && !ownedDialect`).
391
+ * Context accounting reads this instead of the raw setting.
392
+ */
393
+ prunesToolDescriptions(model: Model | undefined): boolean;
382
394
  /** Inject/strip the intent field on tool calls; applies from the next prompt run. */
383
395
  get intentTracing(): boolean;
384
396
  set intentTracing(value: boolean);
@@ -7,7 +7,7 @@
7
7
  * compaction item as replacement history.
8
8
  */
9
9
  import type { Api, CodexCompactionContext, FetchImpl, Model, ProviderSessionState } from "@oh-my-pi/pi-ai";
10
- import { type OpenAICodexCompactionBody } from "@oh-my-pi/pi-ai/providers/openai-codex-responses";
10
+ import type { OpenAICodexCompactionBody } from "@oh-my-pi/pi-ai/providers/openai-codex-responses";
11
11
  import type { CatalogPolicyInputs } from "@oh-my-pi/pi-catalog/types";
12
12
  /** Retained-message budget Codex uses after streamed V2 compaction. */
13
13
  export declare const V2_RETAINED_MESSAGE_TOKEN_BUDGET = 64000;
@@ -15,4 +15,3 @@ export * from "./tool-context.js";
15
15
  export * from "./tokenizer.js";
16
16
  export * from "./tool-arguments.js";
17
17
  export * from "./types.js";
18
- export * from "./utils/yield.js";
@@ -9,6 +9,7 @@ export declare function tokenizerEncodingForModel(model: Pick<Model, "tokenizer"
9
9
  type TokenizerModel = Pick<Model, "tokenizer"> & snapcompact.ShapeTarget & {
10
10
  compat?: Model["compat"];
11
11
  thinking?: Model["thinking"];
12
+ reasoning?: boolean;
12
13
  };
13
14
  /** Identity of the reader whose replayed reasoning a {@link Tokenizer} counts; equal keys count it alike. */
14
15
  export declare function reasoningReplayKey(model: TokenizerModel | null | undefined): string;
@@ -1111,6 +1111,11 @@ export interface AgentTool<TParameters extends TSchema = TSchema, TDetails = any
1111
1111
  * approving the call can record them. Omit it (or return `undefined`) and the call prompts by tier.
1112
1112
  */
1113
1113
  approvalCacheTargets?: (args: unknown) => readonly string[] | undefined;
1114
+ /**
1115
+ * Paths a call reads beyond its working directory, matched against restricted folders when it
1116
+ * reports no `approvalCacheTargets`. Unlike cache targets they never key a remembered grant.
1117
+ */
1118
+ approvalRestrictionPaths?: (args: unknown) => readonly string[] | undefined;
1114
1119
  /** The main execution callback for this tool. */
1115
1120
  execute: AgentToolExecFn<TParameters, TDetails, TTheme>;
1116
1121
  /** Optional custom rendering for tool call display (returns UI component) */
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": "18.13.2",
4
+ "version": "18.14.0",
5
5
  "description": "General-purpose agent with transport abstraction, state management, and attachment support",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": {
@@ -38,29 +38,28 @@
38
38
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
39
39
  },
40
40
  "dependencies": {
41
- "@oh-my-pi/pi-ai": "18.13.2",
42
- "@oh-my-pi/pi-catalog": "18.13.2",
43
- "@oh-my-pi/pi-natives": "18.13.2",
44
- "@oh-my-pi/pi-utils": "18.13.2",
45
- "@oh-my-pi/pi-wire": "18.13.2",
46
- "@oh-my-pi/snapcompact": "18.13.2",
41
+ "@oh-my-pi/pi-ai": "18.14.0",
42
+ "@oh-my-pi/pi-catalog": "18.14.0",
43
+ "@oh-my-pi/pi-natives": "18.14.0",
44
+ "@oh-my-pi/pi-utils": "18.14.0",
45
+ "@oh-my-pi/pi-wire": "18.14.0",
46
+ "@oh-my-pi/snapcompact": "18.14.0",
47
47
  "@opentelemetry/api": "^1.9.1"
48
48
  },
49
49
  "devDependencies": {
50
- "@oh-my-pi/omptype": "18.13.2",
50
+ "@oh-my-pi/omptype": "18.14.0",
51
51
  "@opentelemetry/context-async-hooks": "^2.9.0",
52
52
  "@opentelemetry/sdk-trace-base": "^2.9.0",
53
53
  "@types/bun": "^1.3.14"
54
54
  },
55
55
  "engines": {
56
- "bun": ">=1.3.14"
56
+ "bun": ">=1.4.4"
57
57
  },
58
58
  "files": [
59
59
  "src",
60
60
  "README.md",
61
61
  "CHANGELOG.md",
62
62
  "LICENSE",
63
- "THIRD-PARTY-NOTICES.txt",
64
63
  "dist/types"
65
64
  ],
66
65
  "exports": {
package/src/agent-loop.ts CHANGED
@@ -109,7 +109,6 @@ import {
109
109
  isSoftToolRequirement,
110
110
  SPECULATIVE_STREAM_SESSION,
111
111
  } from "./types";
112
- import { yieldIfDue } from "./utils/yield";
113
112
 
114
113
  async function waitForProcessPause(config: AgentLoopConfig, signal?: AbortSignal): Promise<void> {
115
114
  try {
@@ -944,7 +943,8 @@ export function normalizeMessagesForProvider(
944
943
  });
945
944
  }
946
945
 
947
- const INTENT_FIELD_DESCRIPTION = "concise intent";
946
+ /** Schema description of the injected `i` field; `PI_INTENT_DESC` replaces it. */
947
+ const DEFAULT_INTENT_FIELD_DESCRIPTION = "concise intent";
948
948
  const INTENT_SCHEMA_UNION_KEYS = ["anyOf", "oneOf"] as const;
949
949
 
950
950
  // Memoize injection per input schema identity: normalizeTools runs on every
@@ -1021,7 +1021,7 @@ function injectIntentIntoSchema(
1021
1021
  ...schemaRecord,
1022
1022
  properties: {
1023
1023
  [INTENT_FIELD]: describeIntent
1024
- ? { type: "string", description: INTENT_FIELD_DESCRIPTION }
1024
+ ? { type: "string", description: Bun.env.PI_INTENT_DESC || DEFAULT_INTENT_FIELD_DESCRIPTION }
1025
1025
  : { type: "string" },
1026
1026
  ...properties,
1027
1027
  },
@@ -1072,9 +1072,8 @@ function resolveIntentMode(intent: AgentTool["intent"]): "require" | "optional"
1072
1072
  }
1073
1073
 
1074
1074
  /**
1075
- * Longest `i` value accepted as an intent. The injected field is described as
1076
- * a "concise intent" (INTENT_FIELD_DESCRIPTION); anything past this is a tool
1077
- * payload the model put in the wrong field, not a label.
1075
+ * Longest `i` value accepted as an intent; anything past this is a tool
1076
+ * payload the model put in the wrong field, not an intent.
1078
1077
  */
1079
1078
  const MAX_INTENT_LENGTH = 200;
1080
1079
 
@@ -1336,9 +1335,6 @@ async function runLoopBody(
1336
1335
  endAgentStream(stream, newMessages, telemetry, stepCounter.count);
1337
1336
  return;
1338
1337
  }
1339
- // Yield at the top of each iteration to prevent busy-wait when
1340
- // the agent loop is executing tool calls back-to-back.
1341
- await yieldIfDue();
1342
1338
  // Park at the turn boundary while the process-wide pause gate is
1343
1339
  // engaged (host /pause). An external abort releases the park so a
1344
1340
  // cancelled run still unwinds while everything else stays frozen.
@@ -2379,10 +2375,6 @@ async function streamAssistantResponse(
2379
2375
  return await finishAbortedStream();
2380
2376
  }
2381
2377
 
2382
- // Yield to the event loop periodically to prevent busy-wait
2383
- // when the LLM is streaming chunks faster than the loop can rest.
2384
- await yieldIfDue();
2385
-
2386
2378
  if (event.type === "toolcall_start") {
2387
2379
  const block = event.partial.content[event.contentIndex];
2388
2380
  if (block?.type === "toolCall") {
@@ -2790,14 +2782,7 @@ function buildToolCallAbortMessages(
2790
2782
  * `signal.reason` is the default `AbortError` `DOMException`) falls back to the
2791
2783
  * generic sentinel that downstream renderers treat as "no specific reason". */
2792
2784
  export function abortReasonText(signal: AbortSignal | undefined): string {
2793
- const scopedReason = toolScopedAbortReason(signal);
2794
- if (scopedReason) return scopedReason.message;
2795
- const reason = signal?.reason;
2796
- if (typeof reason === "string" && reason.trim().length > 0) return reason;
2797
- if (reason instanceof Error && reason.name !== "AbortError" && reason.message.trim().length > 0) {
2798
- return reason.message;
2799
- }
2800
- return "Request was aborted";
2785
+ return toolScopedAbortReason(signal)?.message ?? AIError.callerAbortReason(signal) ?? "Request was aborted";
2801
2786
  }
2802
2787
 
2803
2788
  function emitAbortedAssistantMessage(
@@ -3917,9 +3902,6 @@ async function executeToolCalls(
3917
3902
  await steeringWatchPromise?.catch(() => undefined);
3918
3903
  clearInterval(steeringWatchTimer);
3919
3904
  }
3920
- // Yield after batch tool execution to let GC and I/O catch up,
3921
- // especially when tool results are large (e.g. bash output).
3922
- await yieldIfDue();
3923
3905
 
3924
3906
  for (const record of records) {
3925
3907
  if (!record.toolResultMessage) {
package/src/agent.ts CHANGED
@@ -23,7 +23,7 @@ import {
23
23
  type ToolResultMessage,
24
24
  } from "@oh-my-pi/pi-ai";
25
25
  import type { Dialect } from "@oh-my-pi/pi-ai/dialect";
26
- import { openAIResponsesReplaysNativeHistory } from "@oh-my-pi/pi-ai/providers/openai-responses";
26
+ import { openAIResponsesReplaysNativeHistory } from "@oh-my-pi/pi-ai/providers/openai-responses-state";
27
27
  import type { HarmonyAuditEvent } from "@oh-my-pi/pi-ai/utils/harmony-leak";
28
28
  import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
29
29
  import { logger } from "@oh-my-pi/pi-utils";
@@ -69,7 +69,6 @@ import type {
69
69
  ToolChoiceDirective,
70
70
  } from "./types";
71
71
  import { isSoftToolRequirement } from "./types";
72
- import { EventLoopKeepalive } from "./utils/yield";
73
72
 
74
73
  /**
75
74
  * Default convertToLlm: Keep only LLM-compatible replay messages.
@@ -643,6 +642,12 @@ export class Agent {
643
642
  this.#promptCacheKey = value;
644
643
  }
645
644
 
645
+ /**
646
+ * Fields added to every log record written while this agent runs (the
647
+ * owning session's `sessionId`), so in-process agents' records stay apart.
648
+ */
649
+ logContext: Readonly<Record<string, unknown>> | undefined;
650
+
646
651
  /**
647
652
  * Static metadata forwarded to every API request when no resolver is installed
648
653
  * (e.g. `metadata.user_id` for Anthropic session attribution). Setting this
@@ -819,6 +824,23 @@ export class Agent {
819
824
  this.#pruneToolDescriptions = value;
820
825
  }
821
826
 
827
+ /**
828
+ * Whether requests to `model` actually ship description-stripped tool specs.
829
+ * Owned dialects render full definitions in-band, so they never prune; the
830
+ * loop applies the same rule (`pruneToolDescriptions && !ownedDialect`).
831
+ * Context accounting reads this instead of the raw setting.
832
+ */
833
+ prunesToolDescriptions(model: Model | undefined): boolean {
834
+ return this.#pruneToolDescriptions && model !== undefined && !this.#ownedDialect(model);
835
+ }
836
+
837
+ #ownedDialect(model: Model): Dialect | undefined {
838
+ return (
839
+ (this.#dialectResolver ? this.#dialectResolver(model) : this.#dialect) ??
840
+ resolveOwnedDialectFromEnv(Bun.env.PI_DIALECT)
841
+ );
842
+ }
843
+
822
844
  /** Inject/strip the intent field on tool calls; applies from the next prompt run. */
823
845
  get intentTracing(): boolean {
824
846
  return this.#intentTracing;
@@ -928,9 +950,7 @@ export class Agent {
928
950
  ): Promise<Context> {
929
951
  const model = this.#state.model;
930
952
  if (!model) throw new Error("No active model on agent");
931
- const ownedDialect =
932
- (this.#dialectResolver ? this.#dialectResolver(model) : this.#dialect) ??
933
- resolveOwnedDialectFromEnv(Bun.env.PI_DIALECT);
953
+ const ownedDialect = this.#ownedDialect(model);
934
954
  const messages = normalizeMessagesForProvider(llmMessages, model);
935
955
  const tools = ownedDialect
936
956
  ? []
@@ -1653,7 +1673,7 @@ export class Agent {
1653
1673
  promptOptions = imagesOrOptions as AgentPromptOptions | undefined;
1654
1674
  }
1655
1675
 
1656
- await this.#runLoop(msgs, promptOptions);
1676
+ await this.#inLogContext(() => this.#runLoop(msgs, promptOptions));
1657
1677
  }
1658
1678
 
1659
1679
  /**
@@ -1677,7 +1697,12 @@ export class Agent {
1677
1697
  return signals.length === 1 ? signals[0] : AbortSignal.any(signals);
1678
1698
  }
1679
1699
 
1680
- async continue(signal?: AbortSignal) {
1700
+ continue(signal?: AbortSignal): Promise<void> {
1701
+ // Queued-message hooks run before the loop and are this agent's work too.
1702
+ return this.#inLogContext(() => this.#continue(signal));
1703
+ }
1704
+
1705
+ async #continue(signal?: AbortSignal) {
1681
1706
  if (this.#state.isStreaming) {
1682
1707
  throw new AgentBusyError();
1683
1708
  }
@@ -1759,6 +1784,11 @@ export class Agent {
1759
1784
  }
1760
1785
  }
1761
1786
 
1787
+ /** Run `fn` with {@link logContext} on every record it logs. */
1788
+ #inLogContext<T>(fn: () => T): T {
1789
+ return this.logContext ? logger.withContext(this.logContext, fn) : fn();
1790
+ }
1791
+
1762
1792
  /**
1763
1793
  * Run the agent loop.
1764
1794
  * If messages are provided, starts a new conversation turn with those messages.
@@ -1774,7 +1804,6 @@ export class Agent {
1774
1804
  if (!model) throw new Error("No model configured");
1775
1805
 
1776
1806
  let skipInitialSteeringPoll = options?.skipInitialSteeringPoll === true;
1777
- using _ = new EventLoopKeepalive();
1778
1807
  if (!runStateClaimed) {
1779
1808
  const { promise, resolve } = Promise.withResolvers<void>();
1780
1809
  this.#runningPrompt = promise;
@@ -18,7 +18,6 @@ import type {
18
18
  Usage,
19
19
  } from "@oh-my-pi/pi-ai";
20
20
  import * as AIError from "@oh-my-pi/pi-ai/error";
21
- import { resolveAnthropicCompactionEffort } from "@oh-my-pi/pi-ai/providers/anthropic";
22
21
  import {
23
22
  supportsAnthropicCompaction,
24
23
  supportsBedrockConverseCompaction,
@@ -251,6 +250,14 @@ export async function requestAnthropicNativeCompaction(
251
250
  signal: AbortSignal | undefined,
252
251
  options: AnthropicNativeCompactionOptions,
253
252
  ): Promise<AnthropicNativeCompactionResponse> {
253
+ // Pure compaction policy checks must not load the provider; only an actual request needs it.
254
+ const reasoning = isAnthropicMessagesModel(model)
255
+ ? (await import("@oh-my-pi/pi-ai/providers/anthropic")).resolveAnthropicCompactionEffort(
256
+ model,
257
+ request.context.messages,
258
+ request.reasoning,
259
+ )
260
+ : request.reasoning;
254
261
  const response = await instrumentedCompleteSimple(
255
262
  model,
256
263
  request.context,
@@ -258,9 +265,7 @@ export async function requestAnthropicNativeCompaction(
258
265
  apiKey,
259
266
  signal,
260
267
  maxTokens: request.maxTokens,
261
- reasoning: isAnthropicMessagesModel(model)
262
- ? resolveAnthropicCompactionEffort(model, request.context.messages, request.reasoning)
263
- : request.reasoning,
268
+ reasoning,
264
269
  initiatorOverride: options.initiatorOverride,
265
270
  metadata: options.metadata,
266
271
  fetch: options.fetch,
@@ -11,11 +11,9 @@ import type { Api, CodexCompactionContext, FetchImpl, Model, ProviderSessionStat
11
11
  import * as AIError from "@oh-my-pi/pi-ai/error";
12
12
  import { createOpenAICodexCompactionRequestContext } from "@oh-my-pi/pi-ai/providers/openai-codex-compaction";
13
13
  import { applyCodexResponsesLiteShape } from "@oh-my-pi/pi-ai/providers/openai-codex/request-transformer";
14
- import {
15
- createOpenAICodexCompatibilityMetadata,
16
- openCodexCompactionEventStream,
17
- type OpenAICodexCompactionBody,
18
- type OpenAICodexCompatibilityMetadata,
14
+ import type {
15
+ OpenAICodexCompactionBody,
16
+ OpenAICodexCompatibilityMetadata,
19
17
  } from "@oh-my-pi/pi-ai/providers/openai-codex-responses";
20
18
  import {
21
19
  getOpenAIPromptCacheKey,
@@ -293,18 +291,21 @@ export async function requestCompactionV2Streaming(
293
291
 
294
292
  const retryWait = options?.retryWait ?? ((delayMs: number) => Bun.sleep(delayMs));
295
293
  const isCodexResponses = compactionV2Api(model) === "openai-codex-responses" || model.provider === "openai-codex";
296
- const codexMetadata =
297
- isCodexResponses && !shouldUseCodexProviderTransport(model)
298
- ? createOpenAICodexCompatibilityMetadata({
299
- sessionId: request.sessionId,
300
- providerSessionState: options?.providerSessionState,
301
- requestKind: "compaction",
302
- compaction: createOpenAICodexCompactionRequestContext({
303
- context: options?.codexCompaction,
304
- implementation: "responses_compaction_v2",
305
- }),
306
- })
307
- : undefined;
294
+ let codexMetadata: OpenAICodexCompatibilityMetadata | undefined;
295
+ if (isCodexResponses && !shouldUseCodexProviderTransport(model)) {
296
+ // Metadata generation is request work; importing compaction policy must not load the Codex transport.
297
+ const { createOpenAICodexCompatibilityMetadata } =
298
+ await import("@oh-my-pi/pi-ai/providers/openai-codex-responses");
299
+ codexMetadata = createOpenAICodexCompatibilityMetadata({
300
+ sessionId: request.sessionId,
301
+ providerSessionState: options?.providerSessionState,
302
+ requestKind: "compaction",
303
+ compaction: createOpenAICodexCompactionRequestContext({
304
+ context: options?.codexCompaction,
305
+ implementation: "responses_compaction_v2",
306
+ }),
307
+ });
308
+ }
308
309
  let lastError: Error | undefined;
309
310
 
310
311
  for (let attempt = 0; attempt <= V2_COMPACTION_MAX_RETRIES; attempt++) {
@@ -367,6 +368,8 @@ async function attemptCompactionV2Streaming(
367
368
  }
368
369
 
369
370
  if (shouldUseCodexProviderTransport(model)) {
371
+ // The native transport is only needed when sending a Codex compaction request.
372
+ const { openCodexCompactionEventStream } = await import("@oh-my-pi/pi-ai/providers/openai-codex-responses");
370
373
  const eventStream = await openCodexCompactionEventStream(model, body, {
371
374
  apiKey,
372
375
  signal,
@@ -27,12 +27,8 @@ import {
27
27
  import type { Dialect } from "@oh-my-pi/pi-ai/dialect";
28
28
  import * as AIError from "@oh-my-pi/pi-ai/error";
29
29
  import { createOpenAICodexCompactionRequestContext } from "@oh-my-pi/pi-ai/providers/openai-codex-compaction";
30
- import {
31
- buildTransformedCodexRequestBody,
32
- type OpenAICodexCompactionBody,
33
- } from "@oh-my-pi/pi-ai/providers/openai-codex-responses";
30
+ import type { OpenAICodexCompactionBody } from "@oh-my-pi/pi-ai/providers/openai-codex-responses";
34
31
  import type { InputItem as CodexInputItem } from "@oh-my-pi/pi-ai/providers/openai-codex/request-transformer";
35
- import { convertTools } from "@oh-my-pi/pi-ai/providers/openai-responses";
36
32
  import { buildResponsesInput, resolveOpenAICompatPolicy } from "@oh-my-pi/pi-ai/providers/openai-shared";
37
33
  import { isSurvivingAssistant } from "@oh-my-pi/pi-ai/providers/transform-messages";
38
34
  import { stripOpenAIResponsesOutputOnlyStatusesForReplay } from "@oh-my-pi/pi-ai/utils";
@@ -1890,6 +1886,9 @@ export async function compact(
1890
1886
  let codexBody: OpenAICodexCompactionBody | undefined;
1891
1887
  let remoteHistory: Array<Record<string, unknown>>;
1892
1888
  if (isCodexResponsesModel(model)) {
1889
+ // Load the serializer only when building a Codex compaction request, not session policy setup.
1890
+ const { buildTransformedCodexRequestBody } =
1891
+ await import("@oh-my-pi/pi-ai/providers/openai-codex-responses");
1893
1892
  const previousCodexInput: CodexInputItem[] = [];
1894
1893
  for (const item of previousReplacementHistory ?? []) {
1895
1894
  if (!isCodexInputItem(item)) {
@@ -1950,12 +1949,17 @@ export async function compact(
1950
1949
  ? codexBody.instructions
1951
1950
  : ""
1952
1951
  : remoteSystemPrompt.join("\n\n");
1952
+ // Responses serialization is needed only for this native compaction request.
1953
1953
  const tools = codexBody
1954
1954
  ? Array.isArray(codexBody.tools)
1955
1955
  ? codexBody.tools
1956
1956
  : undefined
1957
1957
  : summaryOptions.tools
1958
- ? convertTools(summaryOptions.tools, model.compat.supportsStrictMode, model)
1958
+ ? (await import("@oh-my-pi/pi-ai/providers/openai-responses")).convertTools(
1959
+ summaryOptions.tools,
1960
+ model.compat.supportsStrictMode,
1961
+ model,
1962
+ )
1959
1963
  : undefined;
1960
1964
  const trimmed = trimRemoteCompactionInputToContextWindow(
1961
1965
  remoteHistory,
@@ -19,7 +19,6 @@ import { attach, create, Flag, ProviderHttpError } from "@oh-my-pi/pi-ai/error";
19
19
  import { getCodexAttestationHeader } from "@oh-my-pi/pi-ai/providers/openai-codex-attestation";
20
20
  import { createOpenAICodexCompactionRequestContext } from "@oh-my-pi/pi-ai/providers/openai-codex-compaction";
21
21
  import { applyCodexResponsesLiteShape } from "@oh-my-pi/pi-ai/providers/openai-codex/request-transformer";
22
- import { createOpenAICodexCompatibilityMetadata } from "@oh-my-pi/pi-ai/providers/openai-codex-responses";
23
22
  import {
24
23
  applyInferenceHeaders,
25
24
  applySessionHeader,
@@ -950,6 +949,9 @@ export async function requestOpenAiRemoteCompaction(
950
949
 
951
950
  // Codex endpoints require additional auth headers
952
951
  if (isCodexResponses) {
952
+ // Keep transport loading on the actual Codex request path, not compaction policy inspection.
953
+ const { createOpenAICodexCompatibilityMetadata } =
954
+ await import("@oh-my-pi/pi-ai/providers/openai-codex-responses");
953
955
  const accountId = getCodexAccountId(apiKey);
954
956
  if (accountId) {
955
957
  headers[OPENAI_HEADERS.ACCOUNT_ID] = accountId;
@@ -2,7 +2,6 @@ The conversation above is the complete history to summarize. The API replaces ev
2
2
 
3
3
  {{#if extraContext}}
4
4
  {{extraContext}}
5
-
6
5
  {{/if}}
7
6
  {{basePrompt}}
8
7
  {{#if customInstructions}}
@@ -10,4 +9,4 @@ The conversation above is the complete history to summarize. The API replaces ev
10
9
  Additional focus: {{customInstructions}}
11
10
  {{/if}}
12
11
 
13
- You MUST NOT call any tools while writing the summary; respond with the summary text only.
12
+ You MUST NOT call any tools while writing the summary; respond with the summary text only.
package/src/index.ts CHANGED
@@ -32,5 +32,3 @@ export * from "./tokenizer";
32
32
  export * from "./tool-arguments";
33
33
  // Types
34
34
  export * from "./types";
35
- // Yield utilities for Bun event-loop busy-wait prevention
36
- export * from "./utils/yield";
package/src/telemetry.ts CHANGED
@@ -51,7 +51,6 @@ import {
51
51
  } from "@opentelemetry/api";
52
52
  import { AgentRunCollector, type AgentRunCoverage, type AgentRunSummary, type ToolStatus } from "./run-collector";
53
53
  import type { AgentTool } from "./types";
54
- import { EventLoopKeepalive } from "./utils/yield";
55
54
 
56
55
  /** Default tracer name. Override via {@link AgentTelemetryConfig.tracerName}. */
57
56
  export const DEFAULT_TRACER_NAME = "@oh-my-pi/pi-agent-core";
@@ -1840,13 +1839,6 @@ export async function instrumentedCompleteSimple<TApi extends Api>(
1840
1839
  options: SimpleStreamOptions,
1841
1840
  span: InstrumentedChatSpanOptions,
1842
1841
  ): Promise<AssistantMessage> {
1843
- // Oneshot LLM calls (handoff, compaction/branch summaries) run outside the
1844
- // agent `#runLoop`, which is where the EventLoopKeepalive normally lives.
1845
- // Without it, Bun's JSC loop stops servicing timers while parked on the
1846
- // long-lived completion promise, freezing any host spinner (e.g. the
1847
- // `/handoff` Loader) until an unrelated I/O event (a terminal resize)
1848
- // pokes the loop. Keep the loop healthy for the duration of the call.
1849
- using _keepalive = new EventLoopKeepalive();
1850
1842
  const { telemetry, parent, oneshotKind } = span;
1851
1843
  const stepNumber = span.stepNumber ?? -1;
1852
1844
  const reasoning = options.reasoning;
package/src/tokenizer.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { AssistantMessage, Model } from "@oh-my-pi/pi-ai";
2
2
  import { renderDemotedThinking } from "@oh-my-pi/pi-ai/dialect/demotion";
3
3
  import { ollamaChatDropsOwnThinking } from "@oh-my-pi/pi-ai/providers/ollama";
4
+ import { completionsDropsOwnThinking, isLiteLLMThinkingSignature } from "@oh-my-pi/pi-ai/providers/openai-completions";
4
5
  import { isColdReplayableResponsesTurn } from "@oh-my-pi/pi-ai/providers/openai-shared";
5
6
  import {
6
7
  isSameModelTurn,
@@ -41,7 +42,7 @@ export function tokenizerEncodingForModel(model: Pick<Model, "tokenizer"> | null
41
42
  }
42
43
 
43
44
  type TokenizerModel = Pick<Model, "tokenizer"> &
44
- snapcompact.ShapeTarget & { compat?: Model["compat"]; thinking?: Model["thinking"] };
45
+ snapcompact.ShapeTarget & { compat?: Model["compat"]; thinking?: Model["thinking"]; reasoning?: boolean };
45
46
 
46
47
  /** The model an assistant turn's native reasoning (signed thinking, Responses history) is replayed to. */
47
48
  interface ReasoningReplayReader {
@@ -213,6 +214,7 @@ export class Tokenizer {
213
214
  readonly frameBillingKey: string;
214
215
  readonly #reasoningReader: ReasoningReplayReader | undefined;
215
216
  readonly #dropsOllamaThinking: boolean;
217
+ readonly #dropsCompletionsThinking: boolean;
216
218
  /** Claude's own API and prefix-bound hosts keep another model's signature from their deployment. */
217
219
  readonly #readsDeploymentSignatures: boolean;
218
220
  readonly reasoningReplayKey: string;
@@ -251,6 +253,7 @@ export class Tokenizer {
251
253
  this.frameBillingKey = snapcompact.frameBillingKey(this.#frameBilling);
252
254
  this.#reasoningReader = reasoningReplayReader(model);
253
255
  this.#dropsOllamaThinking = ollamaChatDropsOwnThinking(model);
256
+ this.#dropsCompletionsThinking = completionsDropsOwnThinking(model);
254
257
  const compat = model?.compat;
255
258
  this.#readsDeploymentSignatures =
256
259
  model?.api === "anthropic-messages" &&
@@ -424,6 +427,9 @@ export class Tokenizer {
424
427
  const replayedReasoning = this.#replayedReasoningTokens(message);
425
428
  const ollamaReader = this.#reasoningReader?.api === "ollama-chat" ? this.#reasoningReader : undefined;
426
429
  const ownOllamaTurn = ollamaReader !== undefined && isSameModelTurn(message, ollamaReader);
430
+ const completionsReader = this.#dropsCompletionsThinking ? this.#reasoningReader : undefined;
431
+ const ownDroppedCompletionsTurn =
432
+ completionsReader !== undefined && isSameModelTurn(message, completionsReader);
427
433
  if (typeof replayedReasoning === "number") extra += replayedReasoning;
428
434
  const signaturesPriced = this.thinkingReplayTokens === "signature";
429
435
  // The replay's signature rule (`transformMessages`): an aborted or
@@ -457,6 +463,8 @@ export class Tokenizer {
457
463
  continue;
458
464
  }
459
465
  if (this.#dropsOllamaThinking && ownOllamaTurn) continue;
466
+ // Chat-completions drops it unless LiteLLM replays it in `thinking_blocks`.
467
+ if (ownDroppedCompletionsTurn && !isLiteLLMThinkingSignature(block.thinkingSignature)) continue;
460
468
  fragments.push(block.thinking);
461
469
  // Providers charge for the opaque signature/reasoning payload that
462
470
  // rides alongside the thinking text (OpenAI Responses encrypted
@@ -578,6 +586,7 @@ export class Tokenizer {
578
586
  reasoningReplayKey(model) === this.reasoningReplayKey &&
579
587
  resolveThinkingReplayTokens(model ?? {}) === this.thinkingReplayTokens &&
580
588
  ollamaChatDropsOwnThinking(model) === this.#dropsOllamaThinking &&
589
+ completionsDropsOwnThinking(model) === this.#dropsCompletionsThinking &&
581
590
  (options?.nativeHistoryReplay ?? true) === this.nativeHistoryReplay
582
591
  );
583
592
  }