@oh-my-pi/pi-agent-core 16.1.15 → 16.1.17
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 +17 -0
- package/dist/types/agent.d.ts +6 -1
- package/dist/types/compaction/compaction.d.ts +30 -1
- package/dist/types/utils/yield.d.ts +22 -3
- package/package.json +7 -7
- package/src/agent.ts +10 -2
- package/src/compaction/compaction.ts +67 -22
- package/src/utils/yield.ts +52 -15
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [16.1.17] - 2026-06-24
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Hardened the agent-loop cooperative yield against backward wall-clock jumps. A stale future timestamp left in the shared yield gate (NTP step, or a fake-timer test mocking `Date.now`) could make `yieldIfDue()` gate forever and stop yielding to the event loop; the gate now treats a backward clock delta as due and re-anchors. The gate is exposed as an injectable `YieldGate` (with `yieldIfDue()` retained as the shared singleton) so it can be exercised without mocking process-global timers.
|
|
10
|
+
|
|
11
|
+
## [16.1.16] - 2026-06-23
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- Added `generateHandoffFromContext(context, model, options)` to `@oh-my-pi/pi-agent-core/compaction`: runs the handoff oneshot against a fully-built provider `Context` (system prompt, normalized tools, transformed history, trailing handoff prompt) with `streamOptions` mirroring the live turn's cache routing, so a host that owns the transform pipeline can make the handoff request share the prompt cache the main turn populated. `generateHandoff(messages, …)` is unchanged and now delegates to it.
|
|
16
|
+
- Added an optional `systemPrompt` argument to `Agent.buildSideRequestContext(llmMessages, systemPrompt?)`, defaulting to the live agent prompt; callers can pin a different prompt (e.g. handoff generation, which uses the base prompt rather than a per-turn `before_agent_start` hook override).
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- Updated `buildSideRequestContext` to allow pinning custom system prompts
|
|
21
|
+
|
|
5
22
|
## [16.1.10] - 2026-06-21
|
|
6
23
|
|
|
7
24
|
### Fixed
|
package/dist/types/agent.d.ts
CHANGED
|
@@ -329,8 +329,13 @@ export declare class Agent {
|
|
|
329
329
|
* in-band dialect sessions stay tools-less (matching their no-native-tools wire
|
|
330
330
|
* shape and avoiding tool-markup leakage). `llmMessages` is already converted
|
|
331
331
|
* (and, in production, obfuscated) by the caller.
|
|
332
|
+
*
|
|
333
|
+
* `systemPrompt` defaults to the live agent prompt so the side request hits the
|
|
334
|
+
* same cached prefix as the main loop. Callers that must pin a different prompt
|
|
335
|
+
* (e.g. handoff generation, which uses the base prompt rather than a per-turn
|
|
336
|
+
* `before_agent_start` hook override) pass it explicitly.
|
|
332
337
|
*/
|
|
333
|
-
buildSideRequestContext(llmMessages: Message[]): Promise<Context>;
|
|
338
|
+
buildSideRequestContext(llmMessages: Message[], systemPrompt?: string[]): Promise<Context>;
|
|
334
339
|
subscribe(fn: (e: AgentEvent) => void): () => void;
|
|
335
340
|
setProviderResponseInterceptor(fn: SimpleStreamOptions["onResponse"] | undefined): void;
|
|
336
341
|
setRawSseEventInterceptor(fn: SimpleStreamOptions["onSseEvent"] | undefined): void;
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Pure functions for compaction logic. The session manager handles I/O,
|
|
5
5
|
* and after compaction the session is reloaded.
|
|
6
6
|
*/
|
|
7
|
-
import { type ApiKey, type FetchImpl, type MessageAttribution, type Model, type Tool, type Usage } from "@oh-my-pi/pi-ai";
|
|
7
|
+
import { type ApiKey, type Context, type FetchImpl, type MessageAttribution, type Model, type SimpleStreamOptions, type Tool, type Usage } from "@oh-my-pi/pi-ai";
|
|
8
8
|
import { type AgentTelemetry } from "../telemetry";
|
|
9
9
|
import { ThinkingLevel } from "../thinking";
|
|
10
10
|
import type { AgentMessage } from "../types";
|
|
@@ -177,6 +177,35 @@ export interface HandoffOptions {
|
|
|
177
177
|
thinkingLevel?: ThinkingLevel;
|
|
178
178
|
}
|
|
179
179
|
export declare function renderHandoffPrompt(customInstructions?: string): string;
|
|
180
|
+
export interface HandoffFromContextOptions {
|
|
181
|
+
/**
|
|
182
|
+
* Stream options mirrored from the live agent turn: `apiKey`, `signal`, the
|
|
183
|
+
* `sessionId`/`promptCacheKey` cache-routing pair, `serviceTier`, and the
|
|
184
|
+
* session's payload/response hooks. Sending the same routing + payload shape
|
|
185
|
+
* the main loop uses is what lets the handoff oneshot READ the provider
|
|
186
|
+
* prompt cache the live turn populated instead of cold-missing the whole
|
|
187
|
+
* prefix. `reasoning` and `toolChoice` are set internally and override
|
|
188
|
+
* anything provided here.
|
|
189
|
+
*/
|
|
190
|
+
streamOptions: SimpleStreamOptions;
|
|
191
|
+
/** See {@link HandoffOptions.telemetry}. */
|
|
192
|
+
telemetry?: AgentTelemetry;
|
|
193
|
+
/** See {@link HandoffOptions.thinkingLevel}. */
|
|
194
|
+
thinkingLevel?: ThinkingLevel;
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Run the handoff oneshot against a fully-built provider {@link Context}.
|
|
198
|
+
*
|
|
199
|
+
* The caller assembles `context` exactly like a live agent turn — same system
|
|
200
|
+
* prompt, normalized tools, transformed + obfuscated message history, with the
|
|
201
|
+
* trailing handoff-prompt message already appended — and supplies
|
|
202
|
+
* `streamOptions` that mirror the live turn's cache routing. That keeps the
|
|
203
|
+
* cache-preserving context construction in the host (which owns the transform
|
|
204
|
+
* pipeline) while this function centralizes the handoff request contract:
|
|
205
|
+
* `toolChoice: "none"`, clamped reasoning effort, oneshot telemetry, text-only
|
|
206
|
+
* extraction, and provider-error mapping.
|
|
207
|
+
*/
|
|
208
|
+
export declare function generateHandoffFromContext(context: Context, model: Model, options: HandoffFromContextOptions): Promise<string>;
|
|
180
209
|
export declare function generateHandoff(messages: AgentMessage[], model: Model, apiKey: ApiKey, options: HandoffOptions, signal?: AbortSignal): Promise<string>;
|
|
181
210
|
export interface CompactionPreparation {
|
|
182
211
|
/** UUID of first entry to keep */
|
|
@@ -29,9 +29,28 @@ export declare class EventLoopKeepalive {
|
|
|
29
29
|
[Symbol.dispose](): void;
|
|
30
30
|
}
|
|
31
31
|
/**
|
|
32
|
-
*
|
|
33
|
-
* once every {@link
|
|
34
|
-
*
|
|
32
|
+
* Cooperative yield gate. Sleeps for at least {@link YieldGateOptions.sleepMs}
|
|
33
|
+
* but at most once every {@link YieldGateOptions.intervalMs}; hot-path callers
|
|
34
|
+
* invoke it freely and only the slow path actually sleeps.
|
|
35
|
+
*
|
|
36
|
+
* The clock and sleep are injectable so tests drive the gate logic without
|
|
37
|
+
* touching process-global `Date.now`/`scheduler.wait` — globals a concurrent
|
|
38
|
+
* test file can restore mid-run, which previously made the shared gate flake.
|
|
39
|
+
*/
|
|
40
|
+
export interface YieldGateOptions {
|
|
41
|
+
now?: () => number;
|
|
42
|
+
sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
|
|
43
|
+
intervalMs?: number;
|
|
44
|
+
sleepMs?: number;
|
|
45
|
+
}
|
|
46
|
+
export declare class YieldGate {
|
|
47
|
+
#private;
|
|
48
|
+
constructor(opts?: YieldGateOptions);
|
|
49
|
+
yieldIfDue(signal?: AbortSignal): Promise<void>;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Yield to the Bun event loop, sleeping for at least 20 ms — but at most once
|
|
53
|
+
* every {@link YIELD_INTERVAL_MS} across all callers.
|
|
35
54
|
*/
|
|
36
55
|
export declare function yieldIfDue(): Promise<void>;
|
|
37
56
|
export declare class ExponentialYield {
|
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": "16.1.
|
|
4
|
+
"version": "16.1.17",
|
|
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,12 +35,12 @@
|
|
|
35
35
|
"fmt": "biome format --write ."
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@oh-my-pi/pi-ai": "16.1.
|
|
39
|
-
"@oh-my-pi/pi-catalog": "16.1.
|
|
40
|
-
"@oh-my-pi/pi-natives": "16.1.
|
|
41
|
-
"@oh-my-pi/pi-utils": "16.1.
|
|
42
|
-
"@oh-my-pi/pi-wire": "16.1.
|
|
43
|
-
"@oh-my-pi/snapcompact": "16.1.
|
|
38
|
+
"@oh-my-pi/pi-ai": "16.1.17",
|
|
39
|
+
"@oh-my-pi/pi-catalog": "16.1.17",
|
|
40
|
+
"@oh-my-pi/pi-natives": "16.1.17",
|
|
41
|
+
"@oh-my-pi/pi-utils": "16.1.17",
|
|
42
|
+
"@oh-my-pi/pi-wire": "16.1.17",
|
|
43
|
+
"@oh-my-pi/snapcompact": "16.1.17",
|
|
44
44
|
"@opentelemetry/api": "^1.9.1"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
package/src/agent.ts
CHANGED
|
@@ -677,8 +677,16 @@ export class Agent {
|
|
|
677
677
|
* in-band dialect sessions stay tools-less (matching their no-native-tools wire
|
|
678
678
|
* shape and avoiding tool-markup leakage). `llmMessages` is already converted
|
|
679
679
|
* (and, in production, obfuscated) by the caller.
|
|
680
|
+
*
|
|
681
|
+
* `systemPrompt` defaults to the live agent prompt so the side request hits the
|
|
682
|
+
* same cached prefix as the main loop. Callers that must pin a different prompt
|
|
683
|
+
* (e.g. handoff generation, which uses the base prompt rather than a per-turn
|
|
684
|
+
* `before_agent_start` hook override) pass it explicitly.
|
|
680
685
|
*/
|
|
681
|
-
async buildSideRequestContext(
|
|
686
|
+
async buildSideRequestContext(
|
|
687
|
+
llmMessages: Message[],
|
|
688
|
+
systemPrompt: string[] = this.#state.systemPrompt,
|
|
689
|
+
): Promise<Context> {
|
|
682
690
|
const model = this.#state.model;
|
|
683
691
|
if (!model) throw new Error("No active model on agent");
|
|
684
692
|
const ownedDialect = this.#dialect ?? resolveOwnedDialectFromEnv(Bun.env.PI_DIALECT);
|
|
@@ -691,7 +699,7 @@ export class Agent {
|
|
|
691
699
|
preferredDialect(model.id),
|
|
692
700
|
this.#pruneToolDescriptions,
|
|
693
701
|
) ?? []);
|
|
694
|
-
let context: Context = { systemPrompt
|
|
702
|
+
let context: Context = { systemPrompt, messages, tools };
|
|
695
703
|
if (this.#transformProviderContext) context = await this.#transformProviderContext(context, model);
|
|
696
704
|
return context;
|
|
697
705
|
}
|
|
@@ -8,12 +8,14 @@
|
|
|
8
8
|
import {
|
|
9
9
|
type ApiKey,
|
|
10
10
|
type AssistantMessage,
|
|
11
|
+
type Context,
|
|
11
12
|
Effort,
|
|
12
13
|
type FetchImpl,
|
|
13
14
|
type Message,
|
|
14
15
|
type MessageAttribution,
|
|
15
16
|
type Model,
|
|
16
17
|
ProviderHttpError,
|
|
18
|
+
type SimpleStreamOptions,
|
|
17
19
|
type Tool,
|
|
18
20
|
type Usage,
|
|
19
21
|
withAuth,
|
|
@@ -771,6 +773,61 @@ export function renderHandoffPrompt(customInstructions?: string): string {
|
|
|
771
773
|
});
|
|
772
774
|
}
|
|
773
775
|
|
|
776
|
+
export interface HandoffFromContextOptions {
|
|
777
|
+
/**
|
|
778
|
+
* Stream options mirrored from the live agent turn: `apiKey`, `signal`, the
|
|
779
|
+
* `sessionId`/`promptCacheKey` cache-routing pair, `serviceTier`, and the
|
|
780
|
+
* session's payload/response hooks. Sending the same routing + payload shape
|
|
781
|
+
* the main loop uses is what lets the handoff oneshot READ the provider
|
|
782
|
+
* prompt cache the live turn populated instead of cold-missing the whole
|
|
783
|
+
* prefix. `reasoning` and `toolChoice` are set internally and override
|
|
784
|
+
* anything provided here.
|
|
785
|
+
*/
|
|
786
|
+
streamOptions: SimpleStreamOptions;
|
|
787
|
+
/** See {@link HandoffOptions.telemetry}. */
|
|
788
|
+
telemetry?: AgentTelemetry;
|
|
789
|
+
/** See {@link HandoffOptions.thinkingLevel}. */
|
|
790
|
+
thinkingLevel?: ThinkingLevel;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
/**
|
|
794
|
+
* Run the handoff oneshot against a fully-built provider {@link Context}.
|
|
795
|
+
*
|
|
796
|
+
* The caller assembles `context` exactly like a live agent turn — same system
|
|
797
|
+
* prompt, normalized tools, transformed + obfuscated message history, with the
|
|
798
|
+
* trailing handoff-prompt message already appended — and supplies
|
|
799
|
+
* `streamOptions` that mirror the live turn's cache routing. That keeps the
|
|
800
|
+
* cache-preserving context construction in the host (which owns the transform
|
|
801
|
+
* pipeline) while this function centralizes the handoff request contract:
|
|
802
|
+
* `toolChoice: "none"`, clamped reasoning effort, oneshot telemetry, text-only
|
|
803
|
+
* extraction, and provider-error mapping.
|
|
804
|
+
*/
|
|
805
|
+
export async function generateHandoffFromContext(
|
|
806
|
+
context: Context,
|
|
807
|
+
model: Model,
|
|
808
|
+
options: HandoffFromContextOptions,
|
|
809
|
+
): Promise<string> {
|
|
810
|
+
const response = await instrumentedCompleteSimple(
|
|
811
|
+
model,
|
|
812
|
+
context,
|
|
813
|
+
{
|
|
814
|
+
...options.streamOptions,
|
|
815
|
+
reasoning: resolveCompactionEffort(model, options.thinkingLevel),
|
|
816
|
+
toolChoice: "none",
|
|
817
|
+
},
|
|
818
|
+
{ telemetry: options.telemetry, oneshotKind: "handoff" },
|
|
819
|
+
);
|
|
820
|
+
|
|
821
|
+
if (response.stopReason === "error") {
|
|
822
|
+
throw createSummarizationError("Handoff generation failed", response);
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
return response.content
|
|
826
|
+
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
827
|
+
.map(c => c.text)
|
|
828
|
+
.join("\n");
|
|
829
|
+
}
|
|
830
|
+
|
|
774
831
|
export async function generateHandoff(
|
|
775
832
|
messages: AgentMessage[],
|
|
776
833
|
model: Model,
|
|
@@ -789,32 +846,20 @@ export async function generateHandoff(
|
|
|
789
846
|
},
|
|
790
847
|
];
|
|
791
848
|
|
|
792
|
-
|
|
849
|
+
return generateHandoffFromContext(
|
|
850
|
+
{ systemPrompt: options.systemPrompt, messages: requestMessages, tools: options.tools },
|
|
793
851
|
model,
|
|
794
852
|
{
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
toolChoice: "none",
|
|
804
|
-
initiatorOverride: options.initiatorOverride,
|
|
805
|
-
metadata: options.metadata,
|
|
853
|
+
streamOptions: {
|
|
854
|
+
apiKey,
|
|
855
|
+
signal,
|
|
856
|
+
initiatorOverride: options.initiatorOverride,
|
|
857
|
+
metadata: options.metadata,
|
|
858
|
+
},
|
|
859
|
+
telemetry: options.telemetry,
|
|
860
|
+
thinkingLevel: options.thinkingLevel,
|
|
806
861
|
},
|
|
807
|
-
{ telemetry: options.telemetry, oneshotKind: "handoff" },
|
|
808
862
|
);
|
|
809
|
-
|
|
810
|
-
if (response.stopReason === "error") {
|
|
811
|
-
throw createSummarizationError("Handoff generation failed", response);
|
|
812
|
-
}
|
|
813
|
-
|
|
814
|
-
return response.content
|
|
815
|
-
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
816
|
-
.map(c => c.text)
|
|
817
|
-
.join("\n");
|
|
818
863
|
}
|
|
819
864
|
|
|
820
865
|
async function generateShortSummary(
|
package/src/utils/yield.ts
CHANGED
|
@@ -45,13 +45,6 @@ export class EventLoopKeepalive {
|
|
|
45
45
|
const YIELD_SLEEP_MS = 20;
|
|
46
46
|
const YIELD_INTERVAL_MS = 50;
|
|
47
47
|
|
|
48
|
-
/**
|
|
49
|
-
* Wall-clock timestamp of the last completed yield. Module-level so that
|
|
50
|
-
* tight loops sharing this helper collectively respect the gate, not just
|
|
51
|
-
* one caller at a time.
|
|
52
|
-
*/
|
|
53
|
-
let lastYieldAt = 0;
|
|
54
|
-
|
|
55
48
|
/**
|
|
56
49
|
* Sleep for at least `ms` milliseconds of wall-clock time.
|
|
57
50
|
* Retries the wait if it returns prematurely (which can happen when napi
|
|
@@ -76,15 +69,59 @@ async function sleepAtLeast(ms: number, signal?: AbortSignal): Promise<void> {
|
|
|
76
69
|
}
|
|
77
70
|
|
|
78
71
|
/**
|
|
79
|
-
*
|
|
80
|
-
* once every {@link
|
|
81
|
-
*
|
|
72
|
+
* Cooperative yield gate. Sleeps for at least {@link YieldGateOptions.sleepMs}
|
|
73
|
+
* but at most once every {@link YieldGateOptions.intervalMs}; hot-path callers
|
|
74
|
+
* invoke it freely and only the slow path actually sleeps.
|
|
75
|
+
*
|
|
76
|
+
* The clock and sleep are injectable so tests drive the gate logic without
|
|
77
|
+
* touching process-global `Date.now`/`scheduler.wait` — globals a concurrent
|
|
78
|
+
* test file can restore mid-run, which previously made the shared gate flake.
|
|
79
|
+
*/
|
|
80
|
+
export interface YieldGateOptions {
|
|
81
|
+
now?: () => number;
|
|
82
|
+
sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
|
|
83
|
+
intervalMs?: number;
|
|
84
|
+
sleepMs?: number;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export class YieldGate {
|
|
88
|
+
#lastYieldAt = 0;
|
|
89
|
+
readonly #now: () => number;
|
|
90
|
+
readonly #sleep: (ms: number, signal?: AbortSignal) => Promise<void>;
|
|
91
|
+
readonly #intervalMs: number;
|
|
92
|
+
readonly #sleepMs: number;
|
|
93
|
+
|
|
94
|
+
constructor(opts: YieldGateOptions = {}) {
|
|
95
|
+
this.#now = opts.now ?? (() => Date.now());
|
|
96
|
+
this.#sleep = opts.sleep ?? sleepAtLeast;
|
|
97
|
+
this.#intervalMs = opts.intervalMs ?? YIELD_INTERVAL_MS;
|
|
98
|
+
this.#sleepMs = opts.sleepMs ?? YIELD_SLEEP_MS;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async yieldIfDue(signal?: AbortSignal): Promise<void> {
|
|
102
|
+
const now = this.#now();
|
|
103
|
+
const elapsed = now - this.#lastYieldAt;
|
|
104
|
+
// `elapsed < 0` means the wall clock moved backward relative to the last
|
|
105
|
+
// yield (NTP step, fake-timer test, or a stale future timestamp left by
|
|
106
|
+
// another caller): treat it as due and re-anchor rather than gate forever.
|
|
107
|
+
if (elapsed >= 0 && elapsed < this.#intervalMs) return;
|
|
108
|
+
await this.#sleep(this.#sleepMs, signal);
|
|
109
|
+
this.#lastYieldAt = this.#now();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Process-wide gate shared by all hot-path callers so tight loops collectively
|
|
115
|
+
* respect the interval rather than each sleeping independently.
|
|
116
|
+
*/
|
|
117
|
+
const sharedYieldGate = new YieldGate();
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Yield to the Bun event loop, sleeping for at least 20 ms — but at most once
|
|
121
|
+
* every {@link YIELD_INTERVAL_MS} across all callers.
|
|
82
122
|
*/
|
|
83
|
-
export
|
|
84
|
-
|
|
85
|
-
if (now - lastYieldAt < YIELD_INTERVAL_MS) return;
|
|
86
|
-
await sleepAtLeast(YIELD_SLEEP_MS);
|
|
87
|
-
lastYieldAt = Date.now();
|
|
123
|
+
export function yieldIfDue(): Promise<void> {
|
|
124
|
+
return sharedYieldGate.yieldIfDue();
|
|
88
125
|
}
|
|
89
126
|
|
|
90
127
|
// ---------------------------------------------------------------------------
|