@oh-my-pi/pi-agent-core 15.2.4 → 15.3.1
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 +6 -0
- package/dist/types/agent-loop.d.ts +2 -1
- package/dist/types/agent.d.ts +8 -0
- package/dist/types/append-only-context.d.ts +113 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/types.d.ts +10 -0
- package/package.json +4 -4
- package/src/agent-loop.ts +14 -7
- package/src/agent.ts +17 -0
- package/src/append-only-context.ts +297 -0
- package/src/index.ts +2 -0
- package/src/types.ts +10 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [15.3.0] - 2026-05-25
|
|
6
|
+
### Fixed
|
|
7
|
+
|
|
8
|
+
- Fixed `transformContext` receiving the loop config object as the `signal` argument instead of the actual `AbortSignal`, so hooks that check `signal.aborted` or call `signal.addEventListener` now work correctly under abort/timeout conditions
|
|
9
|
+
- Fixed `appendOnlyContext` not being re-evaluated after `setModel()` — the mode was decided once at session construction based on the initial model's provider, so switching from/to DeepSeek (or changing `provider.appendOnlyContext`) mid-session produced incorrect mode behavior
|
|
10
|
+
|
|
5
11
|
## [15.2.3] - 2026-05-22
|
|
6
12
|
### Added
|
|
7
13
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Agent loop that works with AgentMessage throughout.
|
|
3
3
|
* Transforms to Message[] only at the LLM call boundary.
|
|
4
4
|
*/
|
|
5
|
-
import { EventStream } from "@oh-my-pi/pi-ai";
|
|
5
|
+
import { type Context, EventStream } from "@oh-my-pi/pi-ai";
|
|
6
6
|
import { type AgentRunCoverage, type AgentRunSummary } from "./run-collector";
|
|
7
7
|
import type { AgentContext, AgentEvent, AgentLoopConfig, AgentMessage, StreamFn } from "./types";
|
|
8
8
|
/**
|
|
@@ -52,3 +52,4 @@ export declare function agentLoopContinueDetailed(context: AgentContext, config:
|
|
|
52
52
|
readonly detailed: () => Promise<AgentLoopDetailedResult>;
|
|
53
53
|
};
|
|
54
54
|
export declare const INTENT_FIELD = "_i";
|
|
55
|
+
export declare function normalizeTools(tools: AgentContext["tools"], injectIntent: boolean): Context["tools"];
|
package/dist/types/agent.d.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* No transport abstraction - calls streamSimple via the loop.
|
|
3
3
|
*/
|
|
4
4
|
import { type AssistantMessage, type AssistantMessageEvent, type CursorExecHandlers, type CursorToolResultHandler, type Effort, type ImageContent, type Message, type Model, type ProviderSessionState, type ServiceTier, type SimpleStreamOptions, type ThinkingBudgets, type ToolChoice } from "@oh-my-pi/pi-ai";
|
|
5
|
+
import type { AppendOnlyContextManager } from "./append-only-context";
|
|
5
6
|
import type { HarmonyAuditEvent } from "./harmony-leak";
|
|
6
7
|
import type { AgentEvent, AgentLoopConfig, AgentMessage, AgentState, AgentTool, AgentToolContext, StreamFn, ToolCallContext } from "./types";
|
|
7
8
|
export declare class AgentBusyError extends Error {
|
|
@@ -144,6 +145,11 @@ export interface AgentOptions {
|
|
|
144
145
|
* {@link AgentLoopConfig.telemetry} for the full surface.
|
|
145
146
|
*/
|
|
146
147
|
telemetry?: AgentLoopConfig["telemetry"];
|
|
148
|
+
/**
|
|
149
|
+
* Immutable context mode — stabilizes system prompt + tool spec bytes
|
|
150
|
+
* across turns so DeepSeek/Anthropic prefix caches hit at maximum rate.
|
|
151
|
+
*/
|
|
152
|
+
appendOnlyContext?: AppendOnlyContextManager;
|
|
147
153
|
}
|
|
148
154
|
export interface AgentPromptOptions {
|
|
149
155
|
toolChoice?: ToolChoice;
|
|
@@ -261,6 +267,8 @@ export declare class Agent {
|
|
|
261
267
|
*/
|
|
262
268
|
set maxRetryDelayMs(value: number | undefined);
|
|
263
269
|
get state(): AgentState;
|
|
270
|
+
get appendOnlyContext(): AppendOnlyContextManager | undefined;
|
|
271
|
+
setAppendOnlyContext(manager?: AppendOnlyContextManager): void;
|
|
264
272
|
subscribe(fn: (e: AgentEvent) => void): () => void;
|
|
265
273
|
setProviderResponseInterceptor(fn: SimpleStreamOptions["onResponse"] | undefined): void;
|
|
266
274
|
setRawSseEventInterceptor(fn: SimpleStreamOptions["onSseEvent"] | undefined): void;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Append-only context mode — stabilizes the byte prefix sent to the LLM
|
|
3
|
+
* across turns so provider prefix caches (DeepSeek, Anthropic, etc.)
|
|
4
|
+
* hit at the maximum possible rate.
|
|
5
|
+
*
|
|
6
|
+
* Two mechanisms:
|
|
7
|
+
*
|
|
8
|
+
* 1. **StablePrefix** — system prompt + tool specs are computed once
|
|
9
|
+
* and frozen. Subsequent turns reuse the exact same byte sequence
|
|
10
|
+
* unless `invalidate()` is called (e.g. after MCP reconnect).
|
|
11
|
+
*
|
|
12
|
+
* 2. **AppendOnlyLog** — messages only grow; prior turns are never
|
|
13
|
+
* re-serialized. Combined with a stable prefix, only the user's new
|
|
14
|
+
* message delta is a cache miss each turn.
|
|
15
|
+
*/
|
|
16
|
+
import type { Context, Message, Tool } from "@oh-my-pi/pi-ai";
|
|
17
|
+
import type { AgentContext } from "./types";
|
|
18
|
+
/** Frozen system prompt + tool spec snapshot. */
|
|
19
|
+
export interface StablePrefixSnapshot {
|
|
20
|
+
systemPrompt: string[];
|
|
21
|
+
tools: Tool[];
|
|
22
|
+
fingerprint: string;
|
|
23
|
+
}
|
|
24
|
+
/** Options threaded through `build()` so the snapshot reflects loop-time settings. */
|
|
25
|
+
export interface BuildOptions {
|
|
26
|
+
/** Inject the `_i` intent field into tool schemas (must match agent-loop's normalizeTools). */
|
|
27
|
+
intentTracing: boolean;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* A frozen prefix (system prompt + tools) that produces stable byte
|
|
31
|
+
* sequences across `build()` calls.
|
|
32
|
+
*
|
|
33
|
+
* The first `build()` snapshots the live state. Subsequent calls reuse
|
|
34
|
+
* the cached copy until `invalidate()` is called or the live state's
|
|
35
|
+
* fingerprint changes.
|
|
36
|
+
*/
|
|
37
|
+
export declare class StablePrefix {
|
|
38
|
+
#private;
|
|
39
|
+
get fingerprint(): string;
|
|
40
|
+
get version(): number;
|
|
41
|
+
get built(): boolean;
|
|
42
|
+
/**
|
|
43
|
+
* Build or rebuild from live context.
|
|
44
|
+
* Returns `true` if the prefix actually changed (cache miss imminent).
|
|
45
|
+
*/
|
|
46
|
+
build(context: AgentContext, options: BuildOptions): boolean;
|
|
47
|
+
/** Force rebuild on the next `build()` call. */
|
|
48
|
+
invalidate(): void;
|
|
49
|
+
/**
|
|
50
|
+
* Returns the cached prefix.
|
|
51
|
+
* @throws if `build()` was never called.
|
|
52
|
+
*/
|
|
53
|
+
toContext(): {
|
|
54
|
+
systemPrompt: string[];
|
|
55
|
+
tools: Tool[];
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Append-only message log at the `Message[]` (provider-level) layer.
|
|
60
|
+
*
|
|
61
|
+
* The only mutation path is `replaceTail()`, reserved for compaction.
|
|
62
|
+
* Every other operation is append-only.
|
|
63
|
+
*/
|
|
64
|
+
export declare class AppendOnlyLog {
|
|
65
|
+
#private;
|
|
66
|
+
get length(): number;
|
|
67
|
+
append(message: any): void;
|
|
68
|
+
extend(messages: any[]): void;
|
|
69
|
+
/** Replace the last entry — only legal for compaction. */
|
|
70
|
+
replaceTail(replacement: any): void;
|
|
71
|
+
/** Returns a shallow copy of all entries. */
|
|
72
|
+
toMessages(): Message[];
|
|
73
|
+
/** Direct readonly access for in-place inspection. */
|
|
74
|
+
entries(): readonly Message[];
|
|
75
|
+
clear(): void;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Manages a stable prefix + append-only log for the agent loop.
|
|
79
|
+
*
|
|
80
|
+
* Call `build(context)` each turn to get a `Context` with stable
|
|
81
|
+
* `systemPrompt` and `tools` and append-only messages. Call
|
|
82
|
+
* `syncMessages(normalizedMessages)` after `convertToLlm` each
|
|
83
|
+
* turn to keep the log in sync.
|
|
84
|
+
*
|
|
85
|
+
* Example:
|
|
86
|
+
* ```
|
|
87
|
+
* const mgr = new AppendOnlyContextManager();
|
|
88
|
+
* const ctx = mgr.build(context); // first call snapshots prefix
|
|
89
|
+
* mgr.syncMessages(normalized); // grow the log
|
|
90
|
+
* ctx = mgr.build(context); // subsequent calls use cache
|
|
91
|
+
* ```
|
|
92
|
+
*/
|
|
93
|
+
export declare class AppendOnlyContextManager {
|
|
94
|
+
#private;
|
|
95
|
+
readonly prefix: StablePrefix;
|
|
96
|
+
readonly log: AppendOnlyLog;
|
|
97
|
+
build(context: AgentContext, options: BuildOptions): Context;
|
|
98
|
+
/**
|
|
99
|
+
* Sync normalized (provider-level) messages into the append-only log.
|
|
100
|
+
*
|
|
101
|
+
* Detects both compaction (shorter array) and in-place rewrites
|
|
102
|
+
* (same length, changed content via a rolling digest).
|
|
103
|
+
*/
|
|
104
|
+
syncMessages(normalizedMessages: any[]): void;
|
|
105
|
+
/** Reset prefix + log for a model/provider switch while mode stays active. */
|
|
106
|
+
invalidateForModelChange(): void;
|
|
107
|
+
/** Reset the sync cursor AND clear the log. */
|
|
108
|
+
resetSyncCursor(): void;
|
|
109
|
+
appendMessage(message: any): void;
|
|
110
|
+
replaceTailMessage(message: any): void;
|
|
111
|
+
invalidate(): void;
|
|
112
|
+
reset(context: AgentContext, options: BuildOptions): void;
|
|
113
|
+
}
|
package/dist/types/index.d.ts
CHANGED
package/dist/types/types.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { AssistantMessage, AssistantMessageEvent, AssistantMessageEventStream, Effort, ImageContent, Message, Model, SimpleStreamOptions, Static, streamSimple, TextContent, Tool, ToolChoice, ToolResultMessage, TSchema } from "@oh-my-pi/pi-ai";
|
|
2
|
+
import type { AppendOnlyContextManager } from "./append-only-context";
|
|
2
3
|
import type { HarmonyAuditEvent } from "./harmony-leak";
|
|
3
4
|
import type { AgentRunCoverage, AgentRunSummary } from "./run-collector";
|
|
4
5
|
import type { AgentTelemetryConfig } from "./telemetry";
|
|
@@ -122,6 +123,15 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
|
|
|
122
123
|
* then strips from arguments before executing tools.
|
|
123
124
|
*/
|
|
124
125
|
intentTracing?: boolean;
|
|
126
|
+
/**
|
|
127
|
+
* Append-only context mode — stabilizes system prompt + tool spec bytes
|
|
128
|
+
* across turns so provider prefix caches hit at maximum rate.
|
|
129
|
+
*
|
|
130
|
+
* When set, the loop reads messages from the append-only log (stable
|
|
131
|
+
* byte prefix) and caches system prompt + tools. Tools exclude per-turn
|
|
132
|
+
* `_i` intent fields.
|
|
133
|
+
*/
|
|
134
|
+
appendOnlyContext?: AppendOnlyContextManager;
|
|
125
135
|
/**
|
|
126
136
|
* Inspect assistant streaming events before they are published to the outer agent event stream.
|
|
127
137
|
* Callers may abort synchronously to stop consuming buffered provider events.
|
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.3.1",
|
|
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.3.1",
|
|
39
|
+
"@oh-my-pi/pi-natives": "15.3.1",
|
|
40
|
+
"@oh-my-pi/pi-utils": "15.3.1",
|
|
41
41
|
"@opentelemetry/api": "^1.9.0"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
package/src/agent-loop.ts
CHANGED
|
@@ -363,7 +363,7 @@ function injectIntentIntoSchema(schema: unknown, mode: "require" | "optional" =
|
|
|
363
363
|
};
|
|
364
364
|
}
|
|
365
365
|
|
|
366
|
-
function normalizeTools(tools: AgentContext["tools"], injectIntent: boolean): Context["tools"] {
|
|
366
|
+
export function normalizeTools(tools: AgentContext["tools"], injectIntent: boolean): Context["tools"] {
|
|
367
367
|
injectIntent = injectIntent && Bun.env.PI_NO_INTENT !== "1";
|
|
368
368
|
return tools?.map(t => {
|
|
369
369
|
const intentMode = resolveIntentMode(t.intent);
|
|
@@ -647,12 +647,19 @@ async function streamAssistantResponse(
|
|
|
647
647
|
const llmMessages = await config.convertToLlm(messages);
|
|
648
648
|
const normalizedMessages = normalizeMessagesForProvider(llmMessages, config.model);
|
|
649
649
|
|
|
650
|
-
// Build LLM context
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
650
|
+
// Build LLM context — append-only mode caches system prompt + tools
|
|
651
|
+
// AND keeps an append-only message log so prior-turn bytes are stable.
|
|
652
|
+
let llmContext: Context;
|
|
653
|
+
if (config.appendOnlyContext) {
|
|
654
|
+
config.appendOnlyContext.syncMessages(normalizedMessages);
|
|
655
|
+
llmContext = config.appendOnlyContext.build(context, { intentTracing: !!config.intentTracing });
|
|
656
|
+
} else {
|
|
657
|
+
llmContext = {
|
|
658
|
+
systemPrompt: context.systemPrompt,
|
|
659
|
+
messages: normalizedMessages,
|
|
660
|
+
tools: normalizeTools(context.tools, !!config.intentTracing),
|
|
661
|
+
};
|
|
662
|
+
}
|
|
656
663
|
|
|
657
664
|
const streamFunction = streamFn || streamSimple;
|
|
658
665
|
|
package/src/agent.ts
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
type ToolResultMessage,
|
|
22
22
|
} from "@oh-my-pi/pi-ai";
|
|
23
23
|
import { agentLoop, agentLoopContinue } from "./agent-loop";
|
|
24
|
+
import type { AppendOnlyContextManager } from "./append-only-context";
|
|
24
25
|
import type { HarmonyAuditEvent } from "./harmony-leak";
|
|
25
26
|
import type {
|
|
26
27
|
AgentContext,
|
|
@@ -227,6 +228,11 @@ export interface AgentOptions {
|
|
|
227
228
|
* {@link AgentLoopConfig.telemetry} for the full surface.
|
|
228
229
|
*/
|
|
229
230
|
telemetry?: AgentLoopConfig["telemetry"];
|
|
231
|
+
/**
|
|
232
|
+
* Immutable context mode — stabilizes system prompt + tool spec bytes
|
|
233
|
+
* across turns so DeepSeek/Anthropic prefix caches hit at maximum rate.
|
|
234
|
+
*/
|
|
235
|
+
appendOnlyContext?: AppendOnlyContextManager;
|
|
230
236
|
}
|
|
231
237
|
|
|
232
238
|
export interface AgentPromptOptions {
|
|
@@ -292,6 +298,7 @@ export class Agent {
|
|
|
292
298
|
#onHarmonyLeak?: (event: HarmonyAuditEvent) => void | Promise<void>;
|
|
293
299
|
#onBeforeYield?: () => Promise<void> | void;
|
|
294
300
|
#telemetry?: AgentLoopConfig["telemetry"];
|
|
301
|
+
#appendOnlyContext?: AppendOnlyContextManager;
|
|
295
302
|
|
|
296
303
|
/** Buffered Cursor tool results with text length at time of call (for correct ordering) */
|
|
297
304
|
#cursorToolResultBuffer: CursorToolResultEntry[] = [];
|
|
@@ -346,6 +353,7 @@ export class Agent {
|
|
|
346
353
|
this.beforeToolCall = opts.beforeToolCall;
|
|
347
354
|
this.afterToolCall = opts.afterToolCall;
|
|
348
355
|
this.#telemetry = opts.telemetry;
|
|
356
|
+
this.#appendOnlyContext = opts.appendOnlyContext;
|
|
349
357
|
}
|
|
350
358
|
|
|
351
359
|
/**
|
|
@@ -541,6 +549,14 @@ export class Agent {
|
|
|
541
549
|
return this.#state;
|
|
542
550
|
}
|
|
543
551
|
|
|
552
|
+
get appendOnlyContext(): AppendOnlyContextManager | undefined {
|
|
553
|
+
return this.#appendOnlyContext;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
setAppendOnlyContext(manager?: AppendOnlyContextManager): void {
|
|
557
|
+
this.#appendOnlyContext = manager;
|
|
558
|
+
}
|
|
559
|
+
|
|
544
560
|
subscribe(fn: (e: AgentEvent) => void): () => void {
|
|
545
561
|
this.#listeners.add(fn);
|
|
546
562
|
return () => this.#listeners.delete(fn);
|
|
@@ -925,6 +941,7 @@ export class Agent {
|
|
|
925
941
|
cursorOnToolResult,
|
|
926
942
|
transformToolCallArguments: this.#transformToolCallArguments,
|
|
927
943
|
intentTracing: this.#intentTracing,
|
|
944
|
+
appendOnlyContext: this.#appendOnlyContext,
|
|
928
945
|
beforeToolCall: this.beforeToolCall ? (ctx, signal) => this.beforeToolCall?.(ctx, signal) : undefined,
|
|
929
946
|
afterToolCall: this.afterToolCall ? (ctx, signal) => this.afterToolCall?.(ctx, signal) : undefined,
|
|
930
947
|
onAssistantMessageEvent: this.#onAssistantMessageEvent,
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Append-only context mode — stabilizes the byte prefix sent to the LLM
|
|
3
|
+
* across turns so provider prefix caches (DeepSeek, Anthropic, etc.)
|
|
4
|
+
* hit at the maximum possible rate.
|
|
5
|
+
*
|
|
6
|
+
* Two mechanisms:
|
|
7
|
+
*
|
|
8
|
+
* 1. **StablePrefix** — system prompt + tool specs are computed once
|
|
9
|
+
* and frozen. Subsequent turns reuse the exact same byte sequence
|
|
10
|
+
* unless `invalidate()` is called (e.g. after MCP reconnect).
|
|
11
|
+
*
|
|
12
|
+
* 2. **AppendOnlyLog** — messages only grow; prior turns are never
|
|
13
|
+
* re-serialized. Combined with a stable prefix, only the user's new
|
|
14
|
+
* message delta is a cache miss each turn.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { Context, Message, Tool } from "@oh-my-pi/pi-ai";
|
|
18
|
+
import { normalizeTools } from "./agent-loop";
|
|
19
|
+
import type { AgentContext } from "./types";
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// StablePrefix (formerly ImmutablePrefix)
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
/** Frozen system prompt + tool spec snapshot. */
|
|
26
|
+
export interface StablePrefixSnapshot {
|
|
27
|
+
systemPrompt: string[];
|
|
28
|
+
tools: Tool[];
|
|
29
|
+
fingerprint: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Options threaded through `build()` so the snapshot reflects loop-time settings. */
|
|
33
|
+
export interface BuildOptions {
|
|
34
|
+
/** Inject the `_i` intent field into tool schemas (must match agent-loop's normalizeTools). */
|
|
35
|
+
intentTracing: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A frozen prefix (system prompt + tools) that produces stable byte
|
|
40
|
+
* sequences across `build()` calls.
|
|
41
|
+
*
|
|
42
|
+
* The first `build()` snapshots the live state. Subsequent calls reuse
|
|
43
|
+
* the cached copy until `invalidate()` is called or the live state's
|
|
44
|
+
* fingerprint changes.
|
|
45
|
+
*/
|
|
46
|
+
export class StablePrefix {
|
|
47
|
+
#snapshot: StablePrefixSnapshot | null = null;
|
|
48
|
+
#version = 0;
|
|
49
|
+
|
|
50
|
+
get fingerprint(): string {
|
|
51
|
+
return this.#snapshot?.fingerprint ?? "<unbuilt>";
|
|
52
|
+
}
|
|
53
|
+
get version(): number {
|
|
54
|
+
return this.#version;
|
|
55
|
+
}
|
|
56
|
+
get built(): boolean {
|
|
57
|
+
return this.#snapshot !== null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Build or rebuild from live context.
|
|
62
|
+
* Returns `true` if the prefix actually changed (cache miss imminent).
|
|
63
|
+
*/
|
|
64
|
+
build(context: AgentContext, options: BuildOptions): boolean {
|
|
65
|
+
const snapshot = takeSnapshot(context, options);
|
|
66
|
+
if (this.#snapshot && this.#snapshot.fingerprint === snapshot.fingerprint) {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
this.#snapshot = snapshot;
|
|
70
|
+
this.#version++;
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Force rebuild on the next `build()` call. */
|
|
75
|
+
invalidate(): void {
|
|
76
|
+
this.#snapshot = null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Returns the cached prefix.
|
|
81
|
+
* @throws if `build()` was never called.
|
|
82
|
+
*/
|
|
83
|
+
toContext(): { systemPrompt: string[]; tools: Tool[] } {
|
|
84
|
+
const s = this.#snapshot;
|
|
85
|
+
if (!s) throw new Error("StablePrefix.toContext() called before build()");
|
|
86
|
+
return { systemPrompt: s.systemPrompt, tools: s.tools };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
// AppendOnlyLog
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Append-only message log at the `Message[]` (provider-level) layer.
|
|
96
|
+
*
|
|
97
|
+
* The only mutation path is `replaceTail()`, reserved for compaction.
|
|
98
|
+
* Every other operation is append-only.
|
|
99
|
+
*/
|
|
100
|
+
export class AppendOnlyLog {
|
|
101
|
+
#entries: Message[] = [];
|
|
102
|
+
|
|
103
|
+
get length(): number {
|
|
104
|
+
return this.#entries.length;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
append(message: any): void {
|
|
108
|
+
this.#entries.push(message);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
extend(messages: any[]): void {
|
|
112
|
+
for (const m of messages) this.#entries.push(m);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Replace the last entry — only legal for compaction. */
|
|
116
|
+
replaceTail(replacement: any): void {
|
|
117
|
+
const idx = this.#entries.length - 1;
|
|
118
|
+
if (idx >= 0) this.#entries[idx] = replacement;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Returns a shallow copy of all entries. */
|
|
122
|
+
toMessages(): Message[] {
|
|
123
|
+
return this.#entries.slice();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Direct readonly access for in-place inspection. */
|
|
127
|
+
entries(): readonly Message[] {
|
|
128
|
+
return this.#entries;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
clear(): void {
|
|
132
|
+
this.#entries = [];
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
// AppendOnlyContextManager
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Manages a stable prefix + append-only log for the agent loop.
|
|
142
|
+
*
|
|
143
|
+
* Call `build(context)` each turn to get a `Context` with stable
|
|
144
|
+
* `systemPrompt` and `tools` and append-only messages. Call
|
|
145
|
+
* `syncMessages(normalizedMessages)` after `convertToLlm` each
|
|
146
|
+
* turn to keep the log in sync.
|
|
147
|
+
*
|
|
148
|
+
* Example:
|
|
149
|
+
* ```
|
|
150
|
+
* const mgr = new AppendOnlyContextManager();
|
|
151
|
+
* const ctx = mgr.build(context); // first call snapshots prefix
|
|
152
|
+
* mgr.syncMessages(normalized); // grow the log
|
|
153
|
+
* ctx = mgr.build(context); // subsequent calls use cache
|
|
154
|
+
* ```
|
|
155
|
+
*/
|
|
156
|
+
export class AppendOnlyContextManager {
|
|
157
|
+
readonly prefix = new StablePrefix();
|
|
158
|
+
readonly log = new AppendOnlyLog();
|
|
159
|
+
/** How many normalized messages were synced into the log as of the last sync. */
|
|
160
|
+
#lastSyncCount = 0;
|
|
161
|
+
/** Rolling digest of synced message content — detects in-place rewrites. */
|
|
162
|
+
#syncedDigest = 0;
|
|
163
|
+
|
|
164
|
+
build(context: AgentContext, options: BuildOptions): Context {
|
|
165
|
+
this.prefix.build(context, options);
|
|
166
|
+
const { systemPrompt, tools } = this.prefix.toContext();
|
|
167
|
+
return { systemPrompt, messages: this.log.toMessages(), tools };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Sync normalized (provider-level) messages into the append-only log.
|
|
172
|
+
*
|
|
173
|
+
* Detects both compaction (shorter array) and in-place rewrites
|
|
174
|
+
* (same length, changed content via a rolling digest).
|
|
175
|
+
*/
|
|
176
|
+
syncMessages(normalizedMessages: any[]): void {
|
|
177
|
+
// Detect in-place rewrites of already-synced messages.
|
|
178
|
+
if (
|
|
179
|
+
this.#lastSyncCount > 0 &&
|
|
180
|
+
this.#lastSyncCount <= normalizedMessages.length &&
|
|
181
|
+
this.#computeDigest(normalizedMessages.slice(0, this.#lastSyncCount)) !== this.#syncedDigest
|
|
182
|
+
) {
|
|
183
|
+
this.log.clear();
|
|
184
|
+
this.#lastSyncCount = 0;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Compaction — array shrunk.
|
|
188
|
+
if (normalizedMessages.length < this.#lastSyncCount) {
|
|
189
|
+
this.log.clear();
|
|
190
|
+
this.#lastSyncCount = 0;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const newMsgs = normalizedMessages.slice(this.#lastSyncCount);
|
|
194
|
+
for (const msg of newMsgs) {
|
|
195
|
+
this.log.append(msg);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
this.#lastSyncCount = normalizedMessages.length;
|
|
199
|
+
this.#syncedDigest = this.#computeDigest(normalizedMessages);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Reset prefix + log for a model/provider switch while mode stays active. */
|
|
203
|
+
invalidateForModelChange(): void {
|
|
204
|
+
this.prefix.invalidate();
|
|
205
|
+
this.log.clear();
|
|
206
|
+
this.#lastSyncCount = 0;
|
|
207
|
+
this.#syncedDigest = 0;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Reset the sync cursor AND clear the log. */
|
|
211
|
+
resetSyncCursor(): void {
|
|
212
|
+
this.log.clear();
|
|
213
|
+
this.#lastSyncCount = 0;
|
|
214
|
+
this.#syncedDigest = 0;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
appendMessage(message: any): void {
|
|
218
|
+
this.log.append(message);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
replaceTailMessage(message: any): void {
|
|
222
|
+
this.log.replaceTail(message);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
invalidate(): void {
|
|
226
|
+
this.prefix.invalidate();
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
reset(context: AgentContext, options: BuildOptions): void {
|
|
230
|
+
this.prefix.invalidate();
|
|
231
|
+
this.log.clear();
|
|
232
|
+
this.#lastSyncCount = 0;
|
|
233
|
+
this.#syncedDigest = 0;
|
|
234
|
+
this.prefix.build(context, options);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Deterministic digest over every field the provider may serialize — role,
|
|
239
|
+
* content, tool calls (both `toolCalls` and OpenAI-wire `tool_calls`),
|
|
240
|
+
* `tool_call_id`, `name`, `id`. Hashed with the same FNV-style rolling
|
|
241
|
+
* accumulator so in-place rewrites of *any* of these fields are visible.
|
|
242
|
+
*/
|
|
243
|
+
#computeDigest(messages: readonly unknown[]): number {
|
|
244
|
+
let hash = 0;
|
|
245
|
+
for (let i = 0; i < messages.length; i++) {
|
|
246
|
+
const msg = messages[i];
|
|
247
|
+
if (!msg || typeof msg !== "object") continue;
|
|
248
|
+
const m = msg as Record<string, unknown>;
|
|
249
|
+
const payload = JSON.stringify({
|
|
250
|
+
r: m.role ?? null,
|
|
251
|
+
c: m.content ?? null,
|
|
252
|
+
tc: m.toolCalls ?? m.tool_calls ?? null,
|
|
253
|
+
tcid: m.tool_call_id ?? null,
|
|
254
|
+
n: m.name ?? null,
|
|
255
|
+
id: m.id ?? null,
|
|
256
|
+
});
|
|
257
|
+
for (let j = 0; j < payload.length; j++) {
|
|
258
|
+
hash = ((hash << 5) - hash + payload.charCodeAt(j)) | 0;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return hash >>> 0;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ---------------------------------------------------------------------------
|
|
266
|
+
// Snapshot helpers
|
|
267
|
+
// ---------------------------------------------------------------------------
|
|
268
|
+
|
|
269
|
+
function takeSnapshot(context: AgentContext, options: BuildOptions): StablePrefixSnapshot {
|
|
270
|
+
const systemPrompt = [...context.systemPrompt];
|
|
271
|
+
const tools = normalizeTools(context.tools, options.intentTracing) ?? [];
|
|
272
|
+
return {
|
|
273
|
+
systemPrompt,
|
|
274
|
+
tools,
|
|
275
|
+
fingerprint: computeFingerprint(systemPrompt, tools, options),
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function computeFingerprint(systemPrompt: string[], tools: Tool[], options: BuildOptions): string {
|
|
280
|
+
const payload = JSON.stringify({
|
|
281
|
+
s: systemPrompt,
|
|
282
|
+
t: tools.map(t => ({
|
|
283
|
+
n: t.name,
|
|
284
|
+
d: t.description,
|
|
285
|
+
p: t.parameters,
|
|
286
|
+
s: t.strict,
|
|
287
|
+
cf: t.customFormat,
|
|
288
|
+
cw: t.customWireName,
|
|
289
|
+
})),
|
|
290
|
+
i: options.intentTracing,
|
|
291
|
+
});
|
|
292
|
+
let hash = 0;
|
|
293
|
+
for (let i = 0; i < payload.length; i++) {
|
|
294
|
+
hash = ((hash << 5) - hash + payload.charCodeAt(i)) | 0;
|
|
295
|
+
}
|
|
296
|
+
return (hash >>> 0).toString(36);
|
|
297
|
+
}
|
package/src/index.ts
CHANGED
package/src/types.ts
CHANGED
|
@@ -15,6 +15,7 @@ import type {
|
|
|
15
15
|
ToolResultMessage,
|
|
16
16
|
TSchema,
|
|
17
17
|
} from "@oh-my-pi/pi-ai";
|
|
18
|
+
import type { AppendOnlyContextManager } from "./append-only-context";
|
|
18
19
|
import type { HarmonyAuditEvent } from "./harmony-leak";
|
|
19
20
|
import type { AgentRunCoverage, AgentRunSummary } from "./run-collector";
|
|
20
21
|
import type { AgentTelemetryConfig } from "./telemetry";
|
|
@@ -154,6 +155,15 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
|
|
|
154
155
|
* then strips from arguments before executing tools.
|
|
155
156
|
*/
|
|
156
157
|
intentTracing?: boolean;
|
|
158
|
+
/**
|
|
159
|
+
* Append-only context mode — stabilizes system prompt + tool spec bytes
|
|
160
|
+
* across turns so provider prefix caches hit at maximum rate.
|
|
161
|
+
*
|
|
162
|
+
* When set, the loop reads messages from the append-only log (stable
|
|
163
|
+
* byte prefix) and caches system prompt + tools. Tools exclude per-turn
|
|
164
|
+
* `_i` intent fields.
|
|
165
|
+
*/
|
|
166
|
+
appendOnlyContext?: AppendOnlyContextManager;
|
|
157
167
|
|
|
158
168
|
/**
|
|
159
169
|
* Inspect assistant streaming events before they are published to the outer agent event stream.
|