@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 +11 -0
- package/dist/types/types.d.ts +21 -0
- package/dist/types/utils/yield.d.ts +39 -0
- package/package.json +4 -4
- package/src/agent-loop.ts +10 -0
- package/src/telemetry.ts +3 -1
- package/src/types.ts +21 -0
- package/src/utils/yield.ts +120 -0
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
|
|
package/dist/types/types.d.ts
CHANGED
|
@@ -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
|
+
"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.
|
|
39
|
-
"@oh-my-pi/pi-natives": "15.
|
|
40
|
-
"@oh-my-pi/pi-utils": "15.
|
|
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
|
-
|
|
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
|
+
}
|