@oh-my-pi/pi-agent-core 15.4.2 → 15.5.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,17 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [15.5.0] - 2026-05-26
6
+ ### Added
7
+
8
+ - Added `approval` support to `AgentTool` declarations with the new `ToolTier` and `ToolApproval` APIs, allowing tools to declare capability tiers (`read`, `write`, or `exec`) and optional override/reason metadata for approval gating
9
+ - Added `formatApprovalDetails` on `AgentTool` to append custom detail text or lines to approval prompts
10
+ - Added exported `ToolTier` and `ToolApproval` type aliases for tool approval declarations
11
+
12
+ ### Fixed
13
+
14
+ - Fixed chat-request telemetry storing the raw scoped `serviceTier` value (`"openai-only"`/`"claude-only"`) in `OpenAIAttr.RequestServiceTier` instead of the resolved wire value (`"priority"`). Dashboards and alerts filtering on the concrete tier name (`service_tier == "priority"`) were broken by the scoped placeholder; `buildChatRequestAttributes` now runs the tier through `resolveServiceTier(serviceTier, provider)` before recording, keeping the `shouldSendServiceTier` gate intact so non-OpenAI providers continue to omit the attribute entirely.
15
+
5
16
  ## [15.3.0] - 2026-05-25
6
17
  ### Fixed
7
18
 
@@ -311,6 +311,23 @@ export interface RenderResultOptions {
311
311
  /** Current spinner frame index for animated elements (optional) */
312
312
  spinnerFrame?: number;
313
313
  }
314
+ /** Capability tier a tool exercises. Determines which approval modes auto-approve it. */
315
+ export type ToolTier = "read" | "write" | "exec";
316
+ /**
317
+ * Per-tool approval declaration.
318
+ * - bare tier ("read" / "write" / "exec") — static classification.
319
+ * - object form — adds a `reason` (shown in the prompt) and/or `override: true`
320
+ * (force-prompt even in modes that would otherwise auto-approve this tier).
321
+ * - function — dynamic, given parsed args. Returns either form above.
322
+ *
323
+ * Omitted approvals are treated as "exec" by callers that enforce approvals.
324
+ */
325
+ export type ToolApprovalDecision = ToolTier | {
326
+ tier: ToolTier;
327
+ reason?: string;
328
+ override?: boolean;
329
+ };
330
+ export type ToolApproval = ToolApprovalDecision | ((args: unknown) => ToolApprovalDecision);
314
331
  /**
315
332
  * Context passed to tool execution.
316
333
  * Apps can extend via declaration merging.
@@ -346,6 +363,10 @@ export interface AgentTool<TParameters extends TSchema = TSchema, TDetails = any
346
363
  * - function: `_i` is NOT injected; intent is derived dynamically from (potentially partial / streaming) args.
347
364
  */
348
365
  intent?: "omit" | "optional" | "require" | ((args: Partial<Static<TParameters>>) => string | undefined);
366
+ /** Capability tier declaration used by approval gates. Omitted means "exec". */
367
+ approval?: ToolApproval;
368
+ /** Lines appended after the standard approval prompt header. */
369
+ formatApprovalDetails?: (args: unknown) => string | string[] | undefined;
349
370
  /** The main execution callback for this tool. */
350
371
  execute: AgentToolExecFn<TParameters, TDetails, TTheme>;
351
372
  /** Optional custom rendering for tool call display (returns UI component) */
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Cooperative yield utility for preventing Bun event-loop busy-wait.
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.
17
+ */
18
+ /**
19
+ * Yield to the Bun event loop, sleeping for at least 20 ms — but at most
20
+ * once every {@link YIELD_INTERVAL_MS}. Callers in hot paths can invoke
21
+ * this freely; only the slow path actually sleeps.
22
+ */
23
+ export declare function yieldIfDue(): Promise<void>;
24
+ export declare class ExponentialYield {
25
+ #private;
26
+ constructor(opts?: {
27
+ minMs?: number;
28
+ maxMs?: number;
29
+ multiplier?: number;
30
+ });
31
+ notifyActivity(): void;
32
+ sleep(signal?: AbortSignal): Promise<number>;
33
+ /**
34
+ * Race `racers` against an exponentially-backed-off cooperative yield.
35
+ * The losing sleep is cancelled as soon as a racer settles, so no stray
36
+ * timers keep the event loop alive past the racer's resolution.
37
+ */
38
+ race<T>(racers: Array<Promise<T>>): Promise<T>;
39
+ }
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.4.2",
4
+ "version": "15.5.0",
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.4.2",
39
- "@oh-my-pi/pi-natives": "15.4.2",
40
- "@oh-my-pi/pi-utils": "15.4.2",
38
+ "@oh-my-pi/pi-ai": "15.5.0",
39
+ "@oh-my-pi/pi-natives": "15.5.0",
40
+ "@oh-my-pi/pi-utils": "15.5.0",
41
41
  "@opentelemetry/api": "^1.9.0"
42
42
  },
43
43
  "devDependencies": {
package/src/agent-loop.ts CHANGED
@@ -48,6 +48,7 @@ import type {
48
48
  AgentToolResult,
49
49
  StreamFn,
50
50
  } from "./types";
51
+ import { yieldIfDue } from "./utils/yield";
51
52
 
52
53
  /** Sentinel returned by the abort race in `streamAssistantResponse`. */
53
54
  const ABORTED: unique symbol = Symbol("agent-loop-aborted");
@@ -463,6 +464,9 @@ async function runLoopBody(
463
464
 
464
465
  // Inner loop: process tool calls and steering messages
465
466
  while (hasMoreToolCalls || pendingMessages.length > 0) {
467
+ // Yield at the top of each iteration to prevent busy-wait when
468
+ // the agent loop is executing tool calls back-to-back.
469
+ await yieldIfDue();
466
470
  if (!firstTurn) {
467
471
  stream.push({ type: "turn_start" });
468
472
  } else {
@@ -785,6 +789,9 @@ async function streamAssistantResponse(
785
789
  if (next.done) break;
786
790
 
787
791
  const event = next.value;
792
+ // Yield to the event loop periodically to prevent busy-wait
793
+ // when the LLM is streaming chunks faster than the loop can rest.
794
+ await yieldIfDue();
788
795
 
789
796
  switch (event.type) {
790
797
  case "start":
@@ -1208,6 +1215,9 @@ async function executeToolCalls(
1208
1215
  }
1209
1216
 
1210
1217
  await Promise.allSettled(tasks);
1218
+ // Yield after batch tool execution to let GC and I/O catch up,
1219
+ // especially when tool results are large (e.g. bash output).
1220
+ await yieldIfDue();
1211
1221
 
1212
1222
  for (const record of records) {
1213
1223
  if (!record.toolResultMessage) {
package/src/telemetry.ts CHANGED
@@ -30,6 +30,7 @@ import {
30
30
  completeSimple,
31
31
  type Message,
32
32
  type Model,
33
+ resolveServiceTier,
33
34
  type ServiceTier,
34
35
  type SimpleStreamOptions,
35
36
  type StopReason,
@@ -749,7 +750,8 @@ function buildChatRequestAttributes(stepNumber: number, request: ChatRequestSnap
749
750
  attrs[GenAIAttr.RequestStopSequences] = [...request.stopSequences];
750
751
  }
751
752
  if (request.serviceTier && shouldSendServiceTier(request.serviceTier, provider)) {
752
- attrs[OpenAIAttr.RequestServiceTier] = request.serviceTier;
753
+ const resolved = resolveServiceTier(request.serviceTier, provider);
754
+ if (resolved) attrs[OpenAIAttr.RequestServiceTier] = resolved;
753
755
  }
754
756
  if (request.reasoningEffort) attrs[PiGenAIAttr.RequestReasoningEffort] = request.reasoningEffort;
755
757
  const toolChoice = serializeToolChoice(request.toolChoice);
package/src/types.ts CHANGED
@@ -369,6 +369,21 @@ export interface RenderResultOptions {
369
369
  spinnerFrame?: number;
370
370
  }
371
371
 
372
+ /** Capability tier a tool exercises. Determines which approval modes auto-approve it. */
373
+ export type ToolTier = "read" | "write" | "exec";
374
+
375
+ /**
376
+ * Per-tool approval declaration.
377
+ * - bare tier ("read" / "write" / "exec") — static classification.
378
+ * - object form — adds a `reason` (shown in the prompt) and/or `override: true`
379
+ * (force-prompt even in modes that would otherwise auto-approve this tier).
380
+ * - function — dynamic, given parsed args. Returns either form above.
381
+ *
382
+ * Omitted approvals are treated as "exec" by callers that enforce approvals.
383
+ */
384
+ export type ToolApprovalDecision = ToolTier | { tier: ToolTier; reason?: string; override?: boolean };
385
+ export type ToolApproval = ToolApprovalDecision | ((args: unknown) => ToolApprovalDecision);
386
+
372
387
  /**
373
388
  * Context passed to tool execution.
374
389
  * Apps can extend via declaration merging.
@@ -418,6 +433,12 @@ export interface AgentTool<TParameters extends TSchema = TSchema, TDetails = any
418
433
  */
419
434
  intent?: "omit" | "optional" | "require" | ((args: Partial<Static<TParameters>>) => string | undefined);
420
435
 
436
+ /** Capability tier declaration used by approval gates. Omitted means "exec". */
437
+ approval?: ToolApproval;
438
+
439
+ /** Lines appended after the standard approval prompt header. */
440
+ formatApprovalDetails?: (args: unknown) => string | string[] | undefined;
441
+
421
442
  /** The main execution callback for this tool. */
422
443
  execute: AgentToolExecFn<TParameters, TDetails, TTheme>;
423
444
 
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Cooperative yield utility for preventing Bun event-loop busy-wait.
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.
17
+ */
18
+
19
+ import { scheduler } from "node:timers/promises";
20
+
21
+ const YIELD_SLEEP_MS = 20;
22
+ const YIELD_INTERVAL_MS = 50;
23
+
24
+ /**
25
+ * Wall-clock timestamp of the last completed yield. Module-level so that
26
+ * tight loops sharing this helper collectively respect the gate, not just
27
+ * one caller at a time.
28
+ */
29
+ let lastYieldAt = 0;
30
+
31
+ /**
32
+ * Sleep for at least `ms` milliseconds of wall-clock time.
33
+ * Retries the wait if it returns prematurely (which can happen when napi
34
+ * callbacks wake the event loop via `uv_async_send`). When `signal` is
35
+ * provided, the wait is cancellable and silently returns on abort instead
36
+ * of throwing — callers race against another promise that decides what to
37
+ * do next.
38
+ */
39
+ async function sleepAtLeast(ms: number, signal?: AbortSignal): Promise<void> {
40
+ const start = performance.now();
41
+ let remaining = ms;
42
+ while (remaining > 0) {
43
+ if (signal?.aborted) return;
44
+ try {
45
+ await scheduler.wait(remaining, { signal });
46
+ } catch (err) {
47
+ if ((err as { name?: string })?.name === "AbortError") return;
48
+ throw err;
49
+ }
50
+ remaining = ms - (performance.now() - start);
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Yield to the Bun event loop, sleeping for at least 20 ms — but at most
56
+ * once every {@link YIELD_INTERVAL_MS}. Callers in hot paths can invoke
57
+ * this freely; only the slow path actually sleeps.
58
+ */
59
+ export async function yieldIfDue(): Promise<void> {
60
+ const now = Date.now();
61
+ if (now - lastYieldAt < YIELD_INTERVAL_MS) return;
62
+ await sleepAtLeast(YIELD_SLEEP_MS);
63
+ lastYieldAt = Date.now();
64
+ }
65
+
66
+ // --- ExponentialYield ---
67
+
68
+ const EXP_DEFAULT_MIN_MS = 20;
69
+ const EXP_DEFAULT_MAX_MS = 10_000;
70
+ const EXP_DEFAULT_MULTIPLIER = 2;
71
+
72
+ export class ExponentialYield {
73
+ #currentMs: number;
74
+ readonly #minMs: number;
75
+ readonly #maxMs: number;
76
+ readonly #multiplier: number;
77
+
78
+ constructor(opts?: { minMs?: number; maxMs?: number; multiplier?: number }) {
79
+ this.#minMs = opts?.minMs ?? EXP_DEFAULT_MIN_MS;
80
+ this.#maxMs = opts?.maxMs ?? EXP_DEFAULT_MAX_MS;
81
+ this.#multiplier = opts?.multiplier ?? EXP_DEFAULT_MULTIPLIER;
82
+ this.#currentMs = this.#minMs;
83
+ }
84
+
85
+ notifyActivity(): void {
86
+ this.#currentMs = this.#minMs;
87
+ }
88
+
89
+ async sleep(signal?: AbortSignal): Promise<number> {
90
+ const ms = this.#currentMs;
91
+ await sleepAtLeast(ms, signal);
92
+ this.#currentMs = Math.min(this.#currentMs * this.#multiplier, this.#maxMs);
93
+ return ms;
94
+ }
95
+
96
+ /**
97
+ * Race `racers` against an exponentially-backed-off cooperative yield.
98
+ * The losing sleep is cancelled as soon as a racer settles, so no stray
99
+ * timers keep the event loop alive past the racer's resolution.
100
+ */
101
+ async race<T>(racers: Array<Promise<T>>): Promise<T> {
102
+ const racer = Promise.race(racers);
103
+ const controller = new AbortController();
104
+ try {
105
+ const yieldMarker = Symbol("exp-yield");
106
+ for (;;) {
107
+ const result = await Promise.race<T | typeof yieldMarker>([
108
+ racer,
109
+ this.sleep(controller.signal).then(() => yieldMarker as T | typeof yieldMarker),
110
+ ]);
111
+ if (result !== yieldMarker) {
112
+ this.notifyActivity();
113
+ return result;
114
+ }
115
+ }
116
+ } finally {
117
+ controller.abort();
118
+ }
119
+ }
120
+ }