@oh-my-pi/pi-agent-core 15.5.4 → 15.5.7

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.
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import { type MessageAttribution, type Model, type Usage } from "@oh-my-pi/pi-ai";
8
8
  import { type AgentTelemetry } from "../telemetry";
9
+ import { ThinkingLevel } from "../thinking";
9
10
  import type { AgentMessage, AgentTool } from "../types";
10
11
  import type { SessionEntry } from "./entries";
11
12
  import { type ConvertToLlm } from "./messages";
@@ -115,6 +116,15 @@ export interface SummaryOptions {
115
116
  * or `compaction_turn_prefix`). `undefined` keeps the call paths zero-cost.
116
117
  */
117
118
  telemetry?: AgentTelemetry;
119
+ /**
120
+ * Active session thinking level. Threaded from `agent-session.ts` so
121
+ * compaction honors the user's `/model` thinking selection instead of
122
+ * silently overriding it with `Effort.High` (the historical default).
123
+ * `undefined` / `ThinkingLevel.Inherit` falls back to that historical
124
+ * default; `ThinkingLevel.Off` omits reasoning entirely. See
125
+ * `resolveCompactionEffort` for the conversion contract.
126
+ */
127
+ thinkingLevel?: ThinkingLevel;
118
128
  }
119
129
  export declare function generateSummary(currentMessages: AgentMessage[], model: Model, reserveTokens: number, apiKey: string, signal?: AbortSignal, customInstructions?: string, previousSummary?: string, options?: SummaryOptions): Promise<string>;
120
130
  export interface HandoffOptions {
@@ -131,6 +141,13 @@ export interface HandoffOptions {
131
141
  * wrapped in an OTEL chat span tagged with `pi.gen_ai.oneshot.kind = "handoff"`.
132
142
  */
133
143
  telemetry?: AgentTelemetry;
144
+ /**
145
+ * Active session thinking level. Threaded from `agent-session.ts` so
146
+ * handoff generation honors the user's `/model` thinking selection
147
+ * instead of silently overriding it with `Effort.High`. See
148
+ * `resolveCompactionEffort` for the conversion contract.
149
+ */
150
+ thinkingLevel?: ThinkingLevel;
134
151
  }
135
152
  export declare function renderHandoffPrompt(customInstructions?: string): string;
136
153
  export declare function generateHandoff(messages: AgentMessage[], model: Model, apiKey: string, options: HandoffOptions, signal?: AbortSignal): Promise<string>;
@@ -8,3 +8,4 @@ export * from "./run-collector";
8
8
  export * from "./telemetry";
9
9
  export * from "./thinking";
10
10
  export * from "./types";
11
+ export * from "./utils/yield";
@@ -1,20 +1,45 @@
1
1
  /**
2
2
  * Cooperative yield utility for preventing Bun event-loop busy-wait.
3
3
  *
4
- * Bun 1.3.x (JavaScriptCore) does not automatically yield to the kernel when
5
- * the microtask queue is continuously non-empty. In long-running agent loops
6
- * (LLM streaming, tool execution) this causes ~100% CPU usage even when the
7
- * process is simply waiting for I/O.
8
- *
9
- * `yieldIfDue()` uses a compensated sleep that retries `scheduler.wait()`
10
- * until the requested wall-clock duration has actually elapsed. This is
11
- * necessary because napi callbacks (e.g. `Shell.run` chunk callbacks via
12
- * `uv_async_send`) can wake the event loop prematurely, causing the timer
13
- * to return after only ~1–2 ms regardless of the requested duration.
14
- *
15
- * The minimum effective sleep is ~20 ms per yield; at ~30 yield calls/second
16
- * this gives 600 ms/second of kernel sleep → ~40% CPU under active load.
4
+ * ## Root Cause
5
+ *
6
+ * Bun 1.3.x (JavaScriptCore) event loop busy-waits (spins in userspace)
7
+ * when the only pending work is an unresolved Promise — even if there are
8
+ * active I/O watchers (stdin, child process pipes, etc.). The event loop
9
+ * continuously polls for microtask resolution instead of blocking in
10
+ * `epoll_wait`, consuming ~100% of a CPU core.
11
+ *
12
+ * This affects any `await` on a never-resolved Promise, including:
13
+ * - `Promise.withResolvers()` used for user input callbacks
14
+ * - `await proc.exited` for long-running child processes
15
+ * - Agent loop iterations waiting for the next tool call
16
+ *
17
+ * ## Fix
18
+ *
19
+ * A recurring `setInterval` keeps the event loop sleeping in `epoll_wait`.
20
+ * The `EventLoopKeepalive` class and `keepaliveWhile()` wrapper provide a
21
+ * clean way to install and clean up this keepalive timer.
22
+ *
23
+ * The older `yieldIfDue()` and `ExponentialYield` approaches (compensated
24
+ * sleep loops) are retained for the agent-loop hot-path where Promises
25
+ * resolve frequently and the keepalive alone is insufficient.
26
+ */
27
+ /**
28
+ * Await a Promise with an event-loop keepalive active.
29
+ *
30
+ * This is the primary fix for Bun's busy-wait on unresolved Promises.
31
+ * Use it wherever you `await` a Promise that may remain unresolved for
32
+ * an extended period (user input, long-running subprocess, etc.).
33
+ *
34
+ * ```ts
35
+ * // Before (100% CPU while waiting):
36
+ * const input = await mode.getUserInput();
37
+ *
38
+ * // After (proper epoll_wait sleep):
39
+ * const input = await keepaliveWhile(mode.getUserInput());
40
+ * ```
17
41
  */
42
+ export declare function keepaliveWhile<T>(promise: Promise<T>): Promise<T>;
18
43
  /**
19
44
  * Yield to the Bun event loop, sleeping for at least 20 ms — but at most
20
45
  * once every {@link YIELD_INTERVAL_MS}. Callers in hot paths can invoke
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": "15.5.4",
4
+ "version": "15.5.7",
5
5
  "description": "General-purpose agent with transport abstraction, state management, and attachment support",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -35,9 +35,9 @@
35
35
  "fmt": "biome format --write ."
36
36
  },
37
37
  "dependencies": {
38
- "@oh-my-pi/pi-ai": "15.5.4",
39
- "@oh-my-pi/pi-natives": "15.5.4",
40
- "@oh-my-pi/pi-utils": "15.5.4",
38
+ "@oh-my-pi/pi-ai": "15.5.7",
39
+ "@oh-my-pi/pi-natives": "15.5.7",
40
+ "@oh-my-pi/pi-utils": "15.5.7",
41
41
  "@opentelemetry/api": "^1.9.0"
42
42
  },
43
43
  "devDependencies": {
package/src/agent.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  /** Agent class that uses the agent-loop directly.
2
2
  * No transport abstraction - calls streamSimple via the loop.
3
3
  */
4
+
5
+ import { isPromise } from "node:util/types";
4
6
  import {
5
7
  type AssistantMessage,
6
8
  type AssistantMessageEvent,
@@ -1074,7 +1076,16 @@ export class Agent {
1074
1076
 
1075
1077
  #emit(e: AgentEvent) {
1076
1078
  for (const listener of this.#listeners) {
1077
- listener(e);
1079
+ try {
1080
+ const result = listener(e) as unknown;
1081
+ if (isPromise(result)) {
1082
+ result.catch(err => {
1083
+ console.error("Agent listener rejected:", err instanceof Error ? err.message : err);
1084
+ });
1085
+ }
1086
+ } catch (err) {
1087
+ console.error("Agent listener threw:", err instanceof Error ? err.message : err);
1088
+ }
1078
1089
  }
1079
1090
  }
1080
1091
 
@@ -7,6 +7,7 @@
7
7
 
8
8
  import {
9
9
  type AssistantMessage,
10
+ clampThinkingLevelForModel,
10
11
  Effort,
11
12
  type Message,
12
13
  type MessageAttribution,
@@ -16,6 +17,7 @@ import {
16
17
  import { countTokens } from "@oh-my-pi/pi-natives";
17
18
  import { logger, prompt } from "@oh-my-pi/pi-utils";
18
19
  import { type AgentTelemetry, instrumentedCompleteSimple } from "../telemetry";
20
+ import { ThinkingLevel } from "../thinking";
19
21
  import type { AgentMessage, AgentTool } from "../types";
20
22
  import type { CompactionEntry, SessionEntry } from "./entries";
21
23
  import { type ConvertToLlm, convertToLlm, createBranchSummaryMessage, createCustomMessage } from "./messages";
@@ -502,6 +504,52 @@ function formatAdditionalContext(context: string[] | undefined): string {
502
504
  return `<additional-context>\n${lines}\n</additional-context>\n\n`;
503
505
  }
504
506
 
507
+ /**
508
+ * Maps the non-special `ThinkingLevel` values to their `Effort` counterparts.
509
+ * Exhaustive over the union; throws for `Off`/`Inherit` to surface logic
510
+ * errors in callers that forgot to filter those out. Never use a TS cast for
511
+ * this — `ThinkingLevel` is a string-union over distinct concepts (Off /
512
+ * Inherit are not Efforts), and a cast hides the contract.
513
+ */
514
+ function effortFromThinkingLevel(level: ThinkingLevel): Effort {
515
+ switch (level) {
516
+ case ThinkingLevel.Minimal:
517
+ return Effort.Minimal;
518
+ case ThinkingLevel.Low:
519
+ return Effort.Low;
520
+ case ThinkingLevel.Medium:
521
+ return Effort.Medium;
522
+ case ThinkingLevel.High:
523
+ return Effort.High;
524
+ case ThinkingLevel.XHigh:
525
+ return Effort.XHigh;
526
+ case ThinkingLevel.Off:
527
+ case ThinkingLevel.Inherit:
528
+ throw new Error(`effortFromThinkingLevel: ${level} must be handled by caller`);
529
+ }
530
+ }
531
+
532
+ /**
533
+ * Resolves the reasoning effort to send on a compaction LLM call.
534
+ *
535
+ * - Explicit `Off` → `undefined` (omit reasoning entirely; the user said no thinking).
536
+ * - `undefined` / `Inherit` → historical `Effort.High` default → clamped per model
537
+ * (preserves current behavior for users who never touched the dial).
538
+ * - Explicit effort → respect user choice → clamped per model.
539
+ *
540
+ * The clamp routes through `clampThinkingLevelForModel`, which returns
541
+ * `undefined` for models with `compat.supportsReasoningEffort: false`
542
+ * (e.g. `xai-oauth/grok-build`). That `undefined` then flows through to the
543
+ * openai-responses mapper where `modelOmitsReasoningEffort` short-circuits
544
+ * the wire param — no `requireSupportedEffort` throw.
545
+ */
546
+ function resolveCompactionEffort(model: Model, level: ThinkingLevel | undefined): Effort | undefined {
547
+ if (level === ThinkingLevel.Off) return undefined;
548
+ const requested: Effort =
549
+ level === undefined || level === ThinkingLevel.Inherit ? Effort.High : effortFromThinkingLevel(level);
550
+ return clampThinkingLevelForModel(model, requested);
551
+ }
552
+
505
553
  /**
506
554
  * Generate a summary of the conversation using the LLM.
507
555
  * If previousSummary is provided, uses the update prompt to merge.
@@ -521,6 +569,15 @@ export interface SummaryOptions {
521
569
  * or `compaction_turn_prefix`). `undefined` keeps the call paths zero-cost.
522
570
  */
523
571
  telemetry?: AgentTelemetry;
572
+ /**
573
+ * Active session thinking level. Threaded from `agent-session.ts` so
574
+ * compaction honors the user's `/model` thinking selection instead of
575
+ * silently overriding it with `Effort.High` (the historical default).
576
+ * `undefined` / `ThinkingLevel.Inherit` falls back to that historical
577
+ * default; `ThinkingLevel.Off` omits reasoning entirely. See
578
+ * `resolveCompactionEffort` for the conversion contract.
579
+ */
580
+ thinkingLevel?: ThinkingLevel;
524
581
  }
525
582
 
526
583
  export async function generateSummary(
@@ -584,7 +641,7 @@ export async function generateSummary(
584
641
  maxTokens,
585
642
  signal,
586
643
  apiKey,
587
- reasoning: Effort.High,
644
+ reasoning: resolveCompactionEffort(model, options?.thinkingLevel),
588
645
  initiatorOverride: options?.initiatorOverride,
589
646
  metadata: options?.metadata,
590
647
  },
@@ -621,6 +678,13 @@ export interface HandoffOptions {
621
678
  * wrapped in an OTEL chat span tagged with `pi.gen_ai.oneshot.kind = "handoff"`.
622
679
  */
623
680
  telemetry?: AgentTelemetry;
681
+ /**
682
+ * Active session thinking level. Threaded from `agent-session.ts` so
683
+ * handoff generation honors the user's `/model` thinking selection
684
+ * instead of silently overriding it with `Effort.High`. See
685
+ * `resolveCompactionEffort` for the conversion contract.
686
+ */
687
+ thinkingLevel?: ThinkingLevel;
624
688
  }
625
689
 
626
690
  export function renderHandoffPrompt(customInstructions?: string): string {
@@ -658,7 +722,7 @@ export async function generateHandoff(
658
722
  {
659
723
  apiKey,
660
724
  signal,
661
- reasoning: Effort.High,
725
+ reasoning: resolveCompactionEffort(model, options.thinkingLevel),
662
726
  toolChoice: "none",
663
727
  initiatorOverride: options.initiatorOverride,
664
728
  metadata: options.metadata,
@@ -718,7 +782,7 @@ async function generateShortSummary(
718
782
  maxTokens,
719
783
  signal,
720
784
  apiKey,
721
- reasoning: Effort.High,
785
+ reasoning: resolveCompactionEffort(model, options?.thinkingLevel),
722
786
  initiatorOverride: options?.initiatorOverride,
723
787
  metadata: options?.metadata,
724
788
  },
@@ -905,6 +969,12 @@ export async function compact(
905
969
  metadata: options?.metadata,
906
970
  convertToLlm: options?.convertToLlm,
907
971
  telemetry: options?.telemetry,
972
+ // Honor /model thinking selection on every fan-out summarizer.
973
+ // Without this propagation, generateSummary / generateTurnPrefixSummary
974
+ // see options?.thinkingLevel === undefined and resolveCompactionEffort
975
+ // silently falls back to Effort.High — the same defect e07b47ee4 fixed
976
+ // at the call sites, leaked back in here. See resolveCompactionEffort.
977
+ thinkingLevel: options?.thinkingLevel,
908
978
  };
909
979
 
910
980
  let preserveData = withOpenAiRemoteCompactionPreserveData(previousPreserveData, undefined);
@@ -995,6 +1065,9 @@ export async function compact(
995
1065
  initiatorOverride: summaryOptions.initiatorOverride,
996
1066
  metadata: summaryOptions.metadata,
997
1067
  telemetry: summaryOptions.telemetry,
1068
+ // Same propagation as summaryOptions above — generateShortSummary
1069
+ // resolves its own reasoning via resolveCompactionEffort.
1070
+ thinkingLevel: options?.thinkingLevel,
998
1071
  },
999
1072
  );
1000
1073
 
@@ -1047,7 +1120,7 @@ async function generateTurnPrefixSummary(
1047
1120
  maxTokens,
1048
1121
  signal,
1049
1122
  apiKey,
1050
- reasoning: Effort.High,
1123
+ reasoning: resolveCompactionEffort(model, options?.thinkingLevel),
1051
1124
  initiatorOverride: options?.initiatorOverride,
1052
1125
  metadata: options?.metadata,
1053
1126
  },
package/src/index.ts CHANGED
@@ -17,3 +17,5 @@ export * from "./telemetry";
17
17
  export * from "./thinking";
18
18
  // Types
19
19
  export * from "./types";
20
+ // Yield utilities for Bun event-loop busy-wait prevention
21
+ export * from "./utils/yield";
@@ -1,23 +1,67 @@
1
1
  /**
2
2
  * Cooperative yield utility for preventing Bun event-loop busy-wait.
3
3
  *
4
- * Bun 1.3.x (JavaScriptCore) does not automatically yield to the kernel when
5
- * the microtask queue is continuously non-empty. In long-running agent loops
6
- * (LLM streaming, tool execution) this causes ~100% CPU usage even when the
7
- * process is simply waiting for I/O.
4
+ * ## Root Cause
8
5
  *
9
- * `yieldIfDue()` uses a compensated sleep that retries `scheduler.wait()`
10
- * until the requested wall-clock duration has actually elapsed. This is
11
- * necessary because napi callbacks (e.g. `Shell.run` chunk callbacks via
12
- * `uv_async_send`) can wake the event loop prematurely, causing the timer
13
- * to return after only ~1–2 ms regardless of the requested duration.
6
+ * Bun 1.3.x (JavaScriptCore) event loop busy-waits (spins in userspace)
7
+ * when the only pending work is an unresolved Promise — even if there are
8
+ * active I/O watchers (stdin, child process pipes, etc.). The event loop
9
+ * continuously polls for microtask resolution instead of blocking in
10
+ * `epoll_wait`, consuming ~100% of a CPU core.
14
11
  *
15
- * The minimum effective sleep is ~20 ms per yield; at ~30 yield calls/second
16
- * this gives 600 ms/second of kernel sleep → ~40% CPU under active load.
12
+ * This affects any `await` on a never-resolved Promise, including:
13
+ * - `Promise.withResolvers()` used for user input callbacks
14
+ * - `await proc.exited` for long-running child processes
15
+ * - Agent loop iterations waiting for the next tool call
16
+ *
17
+ * ## Fix
18
+ *
19
+ * A recurring `setInterval` keeps the event loop sleeping in `epoll_wait`.
20
+ * The `EventLoopKeepalive` class and `keepaliveWhile()` wrapper provide a
21
+ * clean way to install and clean up this keepalive timer.
22
+ *
23
+ * The older `yieldIfDue()` and `ExponentialYield` approaches (compensated
24
+ * sleep loops) are retained for the agent-loop hot-path where Promises
25
+ * resolve frequently and the keepalive alone is insufficient.
17
26
  */
18
27
 
19
28
  import { scheduler } from "node:timers/promises";
20
29
 
30
+ // ---------------------------------------------------------------------------
31
+ // EventLoopKeepalive — the primary fix for idle-state busy-wait
32
+ // ---------------------------------------------------------------------------
33
+
34
+ const KEEPALIVE_INTERVAL_MS = 86_400_000; // 24 hours — re-armed each interval
35
+
36
+ /**
37
+ * Await a Promise with an event-loop keepalive active.
38
+ *
39
+ * This is the primary fix for Bun's busy-wait on unresolved Promises.
40
+ * Use it wherever you `await` a Promise that may remain unresolved for
41
+ * an extended period (user input, long-running subprocess, etc.).
42
+ *
43
+ * ```ts
44
+ * // Before (100% CPU while waiting):
45
+ * const input = await mode.getUserInput();
46
+ *
47
+ * // After (proper epoll_wait sleep):
48
+ * const input = await keepaliveWhile(mode.getUserInput());
49
+ * ```
50
+ */
51
+ export async function keepaliveWhile<T>(promise: Promise<T>): Promise<T> {
52
+ const ka = setInterval(() => {}, KEEPALIVE_INTERVAL_MS);
53
+ try {
54
+ ka.unref();
55
+ return await promise;
56
+ } finally {
57
+ clearInterval(ka);
58
+ }
59
+ }
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // yieldIfDue — retained for agent-loop hot-path
63
+ // ---------------------------------------------------------------------------
64
+
21
65
  const YIELD_SLEEP_MS = 20;
22
66
  const YIELD_INTERVAL_MS = 50;
23
67
 
@@ -63,7 +107,9 @@ export async function yieldIfDue(): Promise<void> {
63
107
  lastYieldAt = Date.now();
64
108
  }
65
109
 
66
- // --- ExponentialYield ---
110
+ // ---------------------------------------------------------------------------
111
+ // ExponentialYield — retained for bash-executor long waits
112
+ // ---------------------------------------------------------------------------
67
113
 
68
114
  const EXP_DEFAULT_MIN_MS = 20;
69
115
  const EXP_DEFAULT_MAX_MS = 10_000;