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

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.
@@ -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.6",
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.6",
39
+ "@oh-my-pi/pi-natives": "15.5.6",
40
+ "@oh-my-pi/pi-utils": "15.5.6",
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
 
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;