@vib-rato/agent-core 0.16.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 +852 -0
- package/README.md +493 -0
- package/dist/types/agent-loop.d.ts +229 -0
- package/dist/types/agent.d.ts +533 -0
- package/dist/types/append-only-context.d.ts +141 -0
- package/dist/types/attempt-scope.d.ts +84 -0
- package/dist/types/compaction/adaptive.d.ts +31 -0
- package/dist/types/compaction/branch-summarization.d.ts +103 -0
- package/dist/types/compaction/compaction.d.ts +330 -0
- package/dist/types/compaction/entries.d.ts +124 -0
- package/dist/types/compaction/errors.d.ts +26 -0
- package/dist/types/compaction/index.d.ts +12 -0
- package/dist/types/compaction/messages.d.ts +61 -0
- package/dist/types/compaction/openai.d.ts +65 -0
- package/dist/types/compaction/pruning.d.ts +130 -0
- package/dist/types/compaction/utils.d.ts +32 -0
- package/dist/types/compaction.d.ts +1 -0
- package/dist/types/harmony-leak.d.ts +100 -0
- package/dist/types/heap-eviction-retainers.test.d.ts +1 -0
- package/dist/types/image-placeholder-guard.d.ts +4 -0
- package/dist/types/index.d.ts +13 -0
- package/dist/types/proxy.d.ts +95 -0
- package/dist/types/run-collector.d.ts +223 -0
- package/dist/types/run-resource-ledger.d.ts +2 -0
- package/dist/types/telemetry.d.ts +605 -0
- package/dist/types/thinking.d.ts +18 -0
- package/dist/types/tool-dispatch-identity.d.ts +27 -0
- package/dist/types/types.d.ts +790 -0
- package/package.json +72 -0
- package/src/agent-loop.ts +5632 -0
- package/src/agent.ts +2437 -0
- package/src/append-only-context.ts +496 -0
- package/src/attempt-scope.ts +195 -0
- package/src/compaction/adaptive.ts +92 -0
- package/src/compaction/branch-summarization.ts +358 -0
- package/src/compaction/compaction.ts +1569 -0
- package/src/compaction/entries.ts +158 -0
- package/src/compaction/errors.ts +31 -0
- package/src/compaction/index.ts +13 -0
- package/src/compaction/messages.ts +212 -0
- package/src/compaction/openai.ts +580 -0
- package/src/compaction/prompts/auto-handoff-threshold-focus.md +1 -0
- package/src/compaction/prompts/branch-summary-context.md +5 -0
- package/src/compaction/prompts/branch-summary-preamble.md +2 -0
- package/src/compaction/prompts/branch-summary.md +30 -0
- package/src/compaction/prompts/compaction-short-summary.md +9 -0
- package/src/compaction/prompts/compaction-summary-context.md +5 -0
- package/src/compaction/prompts/compaction-summary.md +38 -0
- package/src/compaction/prompts/compaction-turn-prefix.md +17 -0
- package/src/compaction/prompts/compaction-update-summary.md +45 -0
- package/src/compaction/prompts/file-operations.md +10 -0
- package/src/compaction/prompts/handoff-document.md +56 -0
- package/src/compaction/prompts/summarization-system.md +3 -0
- package/src/compaction/pruning.ts +1026 -0
- package/src/compaction/utils.ts +189 -0
- package/src/compaction.ts +1 -0
- package/src/harmony-leak.ts +457 -0
- package/src/heap-eviction-retainers.test.ts +293 -0
- package/src/image-placeholder-guard.ts +20 -0
- package/src/index.ts +23 -0
- package/src/prompts/escaped-nonascii-recovery.md +3 -0
- package/src/prompts/repeated-tool-failure-recovery.md +1 -0
- package/src/proxy.ts +408 -0
- package/src/run-collector.ts +728 -0
- package/src/run-resource-ledger.ts +345 -0
- package/src/telemetry.ts +2161 -0
- package/src/thinking.ts +20 -0
- package/src/tool-dispatch-identity.ts +87 -0
- package/src/types.ts +882 -0
|
@@ -0,0 +1,496 @@
|
|
|
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 "@vib-rato/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
|
+
#sourceSystemPrompt: readonly string[] | null = null;
|
|
50
|
+
#sourceTools: AgentContext["tools"] | null = null;
|
|
51
|
+
#sourceIntentTracing: boolean | null = null;
|
|
52
|
+
|
|
53
|
+
get fingerprint(): string {
|
|
54
|
+
return this.#snapshot?.fingerprint ?? "<unbuilt>";
|
|
55
|
+
}
|
|
56
|
+
get version(): number {
|
|
57
|
+
return this.#version;
|
|
58
|
+
}
|
|
59
|
+
get built(): boolean {
|
|
60
|
+
return this.#snapshot !== null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
exportSnapshot(): StablePrefixSnapshot | null {
|
|
64
|
+
return this.#snapshot ? cloneJson(this.#snapshot) : null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
importSnapshot(snapshot: StablePrefixSnapshot, options: BuildOptions): void {
|
|
68
|
+
// The snapshot tools were already normalized by `takeSnapshot()` at export
|
|
69
|
+
// time. Re-normalizing the cloned JSON would apply `normalizeTools` a
|
|
70
|
+
// second time, which is not idempotent: a tool whose `intent` policy is a
|
|
71
|
+
// function resolves as "omit" at export (no `_i` injected), but the
|
|
72
|
+
// function value is dropped by `cloneJson`, so a second pass resolves the
|
|
73
|
+
// missing field as "optional" and injects `_i` — changing `parameters` and
|
|
74
|
+
// diverging the recomputed fingerprint from the stored one. Verify against
|
|
75
|
+
// the stored tools as-is; the deep clone still keeps `toContext()` results
|
|
76
|
+
// isolated from later mutation.
|
|
77
|
+
const systemPrompt = cloneJson(snapshot.systemPrompt);
|
|
78
|
+
const tools = cloneJson(snapshot.tools);
|
|
79
|
+
const fingerprint = computeFingerprint(systemPrompt, tools, options);
|
|
80
|
+
this.#sourceSystemPrompt = null;
|
|
81
|
+
this.#sourceTools = null;
|
|
82
|
+
this.#sourceIntentTracing = null;
|
|
83
|
+
if (fingerprint !== snapshot.fingerprint) {
|
|
84
|
+
throw new Error(
|
|
85
|
+
`StablePrefix.importSnapshot() fingerprint mismatch: expected ${fingerprint}, received ${snapshot.fingerprint}`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
this.#snapshot = { systemPrompt, tools, fingerprint };
|
|
89
|
+
this.#version++;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Build or rebuild from live context.
|
|
94
|
+
* Returns `true` if the prefix actually changed (cache miss imminent).
|
|
95
|
+
*/
|
|
96
|
+
build(context: AgentContext, options: BuildOptions): boolean {
|
|
97
|
+
if (
|
|
98
|
+
this.#snapshot &&
|
|
99
|
+
this.#sourceSystemPrompt === context.systemPrompt &&
|
|
100
|
+
this.#sourceTools === context.tools &&
|
|
101
|
+
this.#sourceIntentTracing === options.intentTracing
|
|
102
|
+
) {
|
|
103
|
+
const sourceFingerprint = takeSnapshot(context, options).fingerprint;
|
|
104
|
+
if (this.#snapshot.fingerprint === sourceFingerprint) return false;
|
|
105
|
+
}
|
|
106
|
+
const snapshot = takeSnapshot(context, options);
|
|
107
|
+
if (this.#snapshot && this.#snapshot.fingerprint === snapshot.fingerprint) {
|
|
108
|
+
this.#sourceSystemPrompt = context.systemPrompt;
|
|
109
|
+
this.#sourceTools = context.tools;
|
|
110
|
+
this.#sourceIntentTracing = options.intentTracing;
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
this.#snapshot = snapshot;
|
|
114
|
+
this.#sourceSystemPrompt = context.systemPrompt;
|
|
115
|
+
this.#sourceTools = context.tools;
|
|
116
|
+
this.#sourceIntentTracing = options.intentTracing;
|
|
117
|
+
this.#version++;
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Force rebuild on the next `build()` call. */
|
|
122
|
+
invalidate(): void {
|
|
123
|
+
this.#snapshot = null;
|
|
124
|
+
this.#sourceSystemPrompt = null;
|
|
125
|
+
this.#sourceTools = null;
|
|
126
|
+
this.#sourceIntentTracing = null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Returns the cached prefix.
|
|
131
|
+
* @throws if `build()` was never called.
|
|
132
|
+
*/
|
|
133
|
+
toContext(): { systemPrompt: string[]; tools: Tool[] } {
|
|
134
|
+
const s = this.#snapshot;
|
|
135
|
+
if (!s) throw new Error("StablePrefix.toContext() called before build()");
|
|
136
|
+
return { systemPrompt: cloneJson(s.systemPrompt), tools: cloneJson(s.tools) };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
// AppendOnlyLog
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Append-only message log at the `Message[]` (provider-level) layer.
|
|
146
|
+
*
|
|
147
|
+
* The only mutation path is `replaceTail()`, reserved for compaction.
|
|
148
|
+
* Every other operation is append-only.
|
|
149
|
+
*/
|
|
150
|
+
export class AppendOnlyLog {
|
|
151
|
+
#entries: Message[] = [];
|
|
152
|
+
|
|
153
|
+
get length(): number {
|
|
154
|
+
return this.#entries.length;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
append(message: any): void {
|
|
158
|
+
this.#entries.push(message);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
extend(messages: any[]): void {
|
|
162
|
+
for (const m of messages) this.#entries.push(m);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Replace the last entry — only legal for compaction. */
|
|
166
|
+
replaceTail(replacement: any): void {
|
|
167
|
+
const idx = this.#entries.length - 1;
|
|
168
|
+
if (idx >= 0) this.#entries[idx] = replacement;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Returns a shallow copy of all entries. */
|
|
172
|
+
toMessages(): Message[] {
|
|
173
|
+
return this.#entries.slice();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Direct readonly access for in-place inspection. */
|
|
177
|
+
entries(): readonly Message[] {
|
|
178
|
+
return this.#entries;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
clear(): void {
|
|
182
|
+
this.#entries = [];
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
// AppendOnlyContextManager
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Manages a stable prefix + append-only log for the agent loop.
|
|
192
|
+
*
|
|
193
|
+
* Call `build(context)` each turn to get a `Context` with stable
|
|
194
|
+
* `systemPrompt` and `tools` and append-only messages. Call
|
|
195
|
+
* `syncMessages(normalizedMessages)` after `convertToLlm` each
|
|
196
|
+
* turn to keep the log in sync.
|
|
197
|
+
*
|
|
198
|
+
* Example:
|
|
199
|
+
* ```
|
|
200
|
+
* const mgr = new AppendOnlyContextManager();
|
|
201
|
+
* const ctx = mgr.build(context); // first call snapshots prefix
|
|
202
|
+
* mgr.syncMessages(normalized); // grow the log
|
|
203
|
+
* ctx = mgr.build(context); // subsequent calls use cache
|
|
204
|
+
* ```
|
|
205
|
+
*/
|
|
206
|
+
export interface AppendOnlyContextManagerOptions {
|
|
207
|
+
/**
|
|
208
|
+
* Invoked whenever the stable prefix fingerprint changes on `build()` (a
|
|
209
|
+
* provider prompt-cache prefix reset). Used for per-session diagnostics; must
|
|
210
|
+
* not throw. `from` is `<unbuilt>` on the first build.
|
|
211
|
+
*/
|
|
212
|
+
readonly onPrefixChange?: (info: { from: string; to: string; version: number }) => void;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export class AppendOnlyContextManager {
|
|
216
|
+
readonly prefix = new StablePrefix();
|
|
217
|
+
readonly log = new AppendOnlyLog();
|
|
218
|
+
/** How many normalized messages were synced into the log as of the last sync. */
|
|
219
|
+
#lastSyncCount = 0;
|
|
220
|
+
/** Per-synced-message content hashes (rolling digest). Detects in-place rewrites without retaining a full serialized-history string. */
|
|
221
|
+
#syncedHashes: (number | bigint)[] = [];
|
|
222
|
+
/** Number of provider-normalized messages that were seeded before child-local messages. */
|
|
223
|
+
#seededPrefixCount = 0;
|
|
224
|
+
readonly #onPrefixChange: AppendOnlyContextManagerOptions["onPrefixChange"];
|
|
225
|
+
|
|
226
|
+
constructor(options: AppendOnlyContextManagerOptions = {}) {
|
|
227
|
+
this.#onPrefixChange = options.onPrefixChange;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
static forkFromSeed(args: {
|
|
231
|
+
prefixSnapshot?: StablePrefixSnapshot;
|
|
232
|
+
messages?: readonly Message[];
|
|
233
|
+
options: BuildOptions;
|
|
234
|
+
}): AppendOnlyContextManager {
|
|
235
|
+
const manager = new AppendOnlyContextManager();
|
|
236
|
+
if (args.prefixSnapshot) {
|
|
237
|
+
manager.prefix.importSnapshot(args.prefixSnapshot, args.options);
|
|
238
|
+
}
|
|
239
|
+
if (args.messages) {
|
|
240
|
+
manager.seedNormalizedMessages(args.messages);
|
|
241
|
+
}
|
|
242
|
+
return manager;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
build(context: AgentContext, options: BuildOptions): Context {
|
|
246
|
+
const previousFingerprint = this.prefix.fingerprint;
|
|
247
|
+
const changed = this.prefix.build(context, options);
|
|
248
|
+
if (changed && this.#onPrefixChange) {
|
|
249
|
+
this.#onPrefixChange({
|
|
250
|
+
from: previousFingerprint,
|
|
251
|
+
to: this.prefix.fingerprint,
|
|
252
|
+
version: this.prefix.version,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
const { systemPrompt, tools } = this.prefix.toContext();
|
|
256
|
+
return { systemPrompt, messages: this.log.toMessages(), tools };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Sync normalized (provider-level) messages into the append-only log.
|
|
261
|
+
*
|
|
262
|
+
* Detects both compaction (shorter array) and in-place rewrites
|
|
263
|
+
* (same length, changed content via a rolling digest).
|
|
264
|
+
*/
|
|
265
|
+
syncMessages(normalizedMessages: any[]): void {
|
|
266
|
+
const seededPrefixLength = this.#seededPrefixCount;
|
|
267
|
+
const includesSeedPrefix =
|
|
268
|
+
seededPrefixLength > 0 &&
|
|
269
|
+
normalizedMessages.length >= seededPrefixLength &&
|
|
270
|
+
this.#rangeHashesEqual(normalizedMessages, this.log.entries(), seededPrefixLength);
|
|
271
|
+
const messagesToSync =
|
|
272
|
+
seededPrefixLength > 0 && !includesSeedPrefix
|
|
273
|
+
? [...this.log.entries().slice(0, seededPrefixLength), ...normalizedMessages]
|
|
274
|
+
: normalizedMessages;
|
|
275
|
+
|
|
276
|
+
// Detect in-place rewrites of already-synced messages via per-message content
|
|
277
|
+
// hashes (no retained full serialized-history string; F5).
|
|
278
|
+
if (
|
|
279
|
+
this.#lastSyncCount > 0 &&
|
|
280
|
+
this.#lastSyncCount <= messagesToSync.length &&
|
|
281
|
+
this.#prefixChanged(messagesToSync, this.#lastSyncCount)
|
|
282
|
+
) {
|
|
283
|
+
if (this.#seededPrefixCount > 0) {
|
|
284
|
+
// F9: a seeded fork whose inherited prefix changed (e.g. after compaction)
|
|
285
|
+
// rebases onto the new provider context instead of throwing.
|
|
286
|
+
this.#rebaseToBaseline(messagesToSync, seededPrefixLength);
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
this.log.clear();
|
|
290
|
+
this.#lastSyncCount = 0;
|
|
291
|
+
this.#syncedHashes = [];
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Compaction — array shrunk. Seeded forks preserve the inherited prefix and
|
|
295
|
+
// append child-local deltas, so a shorter child array is not a compaction signal
|
|
296
|
+
// while a seed prefix is active; a genuine seeded compaction rebases (F9).
|
|
297
|
+
if (messagesToSync.length < this.#lastSyncCount) {
|
|
298
|
+
if (this.#seededPrefixCount > 0) {
|
|
299
|
+
this.#rebaseToBaseline(messagesToSync, seededPrefixLength);
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
this.log.clear();
|
|
303
|
+
this.#lastSyncCount = 0;
|
|
304
|
+
this.#syncedHashes = [];
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const newMsgs = messagesToSync.slice(this.#lastSyncCount);
|
|
308
|
+
for (const msg of newMsgs) {
|
|
309
|
+
this.log.append(cloneJson(msg));
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
this.#lastSyncCount = messagesToSync.length;
|
|
313
|
+
this.#syncedHashes = this.#hashRange(messagesToSync, 0, messagesToSync.length);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
seedNormalizedMessages(messages: readonly Message[], options?: { reset?: boolean }): void {
|
|
317
|
+
if (this.log.length > 0 && options?.reset !== true) {
|
|
318
|
+
throw new Error("AppendOnlyContextManager.seedNormalizedMessages() cannot seed a non-empty log without reset");
|
|
319
|
+
}
|
|
320
|
+
const clonedMessages = cloneJson([...messages]);
|
|
321
|
+
this.log.clear();
|
|
322
|
+
this.log.extend(clonedMessages);
|
|
323
|
+
this.#lastSyncCount = clonedMessages.length;
|
|
324
|
+
this.#syncedHashes = this.#hashRange(clonedMessages, 0, clonedMessages.length);
|
|
325
|
+
this.#seededPrefixCount = clonedMessages.length;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** Reset prefix + log for a model/provider switch while mode stays active. */
|
|
329
|
+
invalidateForModelChange(): void {
|
|
330
|
+
this.prefix.invalidate();
|
|
331
|
+
this.log.clear();
|
|
332
|
+
this.#lastSyncCount = 0;
|
|
333
|
+
this.#syncedHashes = [];
|
|
334
|
+
this.#seededPrefixCount = 0;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Reset the sync cursor AND clear the log. */
|
|
338
|
+
resetSyncCursor(): void {
|
|
339
|
+
this.log.clear();
|
|
340
|
+
this.#lastSyncCount = 0;
|
|
341
|
+
this.#syncedHashes = [];
|
|
342
|
+
this.#seededPrefixCount = 0;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
appendMessage(message: any): void {
|
|
346
|
+
this.log.append(message);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
replaceTailMessage(message: any): void {
|
|
350
|
+
this.log.replaceTail(message);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Release provider-normalized retainers as one history-rewrite transaction. */
|
|
354
|
+
releaseAfterHistoryRewrite(options: { preserveSeededPrefix?: boolean } = {}): void {
|
|
355
|
+
const seeded = options.preserveSeededPrefix === true ? this.#seededPrefixCount : 0;
|
|
356
|
+
const prefix = seeded > 0 ? this.log.entries().slice(0, seeded) : [];
|
|
357
|
+
this.log.clear();
|
|
358
|
+
if (prefix.length > 0) this.log.extend(prefix);
|
|
359
|
+
this.#lastSyncCount = prefix.length;
|
|
360
|
+
this.#seededPrefixCount = prefix.length;
|
|
361
|
+
this.#syncedHashes = this.#hashRange(prefix, 0, prefix.length);
|
|
362
|
+
this.invalidate();
|
|
363
|
+
}
|
|
364
|
+
invalidate(): void {
|
|
365
|
+
this.prefix.invalidate();
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
reset(context: AgentContext, options: BuildOptions): void {
|
|
369
|
+
this.prefix.invalidate();
|
|
370
|
+
this.log.clear();
|
|
371
|
+
this.#lastSyncCount = 0;
|
|
372
|
+
this.#syncedHashes = [];
|
|
373
|
+
this.#seededPrefixCount = 0;
|
|
374
|
+
this.prefix.build(context, options);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
#hashMessage(message: unknown): number | bigint {
|
|
378
|
+
return hashSource(JSON.stringify(message) ?? "null");
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
#hashRange(messages: readonly unknown[], start: number, end: number): (number | bigint)[] {
|
|
382
|
+
const out: (number | bigint)[] = [];
|
|
383
|
+
for (let i = start; i < end; i++) out.push(this.#hashMessage(messages[i]));
|
|
384
|
+
return out;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** True when the first `count` messages of `a` and `b` are content-equal by per-message hash. */
|
|
388
|
+
#rangeHashesEqual(a: readonly unknown[], b: readonly unknown[], count: number): boolean {
|
|
389
|
+
for (let i = 0; i < count; i++) {
|
|
390
|
+
if (this.#hashMessage(a[i]) !== this.#hashMessage(b[i])) return false;
|
|
391
|
+
}
|
|
392
|
+
return true;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** True when any of the first `count` already-synced messages changed content (in-place rewrite). */
|
|
396
|
+
#prefixChanged(messages: readonly unknown[], count: number): boolean {
|
|
397
|
+
if (count > this.#syncedHashes.length) return false;
|
|
398
|
+
for (let i = 0; i < count; i++) {
|
|
399
|
+
if (this.#hashMessage(messages[i]) !== this.#syncedHashes[i]) return true;
|
|
400
|
+
}
|
|
401
|
+
return false;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** F9: reset the log to a new provider-visible baseline after seeded compaction/rebase. */
|
|
405
|
+
#rebaseToBaseline(messages: readonly unknown[], seededPrefixCount = 0): void {
|
|
406
|
+
this.log.clear();
|
|
407
|
+
this.log.extend(messages.map(message => cloneJson(message)));
|
|
408
|
+
this.#lastSyncCount = messages.length;
|
|
409
|
+
this.#seededPrefixCount = seededPrefixCount;
|
|
410
|
+
this.#syncedHashes = this.#hashRange(messages, 0, messages.length);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// ---------------------------------------------------------------------------
|
|
415
|
+
// Snapshot helpers
|
|
416
|
+
// ---------------------------------------------------------------------------
|
|
417
|
+
|
|
418
|
+
function hashSource(source: string): number | bigint {
|
|
419
|
+
return typeof Bun !== "undefined" ? Bun.hash(source) : hashString32(source);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function hashString32(value: string): number {
|
|
423
|
+
let hash = 0;
|
|
424
|
+
for (let i = 0; i < value.length; i++) {
|
|
425
|
+
hash = ((hash << 5) - hash + value.charCodeAt(i)) | 0;
|
|
426
|
+
}
|
|
427
|
+
return hash >>> 0;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function takeSnapshot(context: AgentContext, options: BuildOptions): StablePrefixSnapshot {
|
|
431
|
+
const systemPrompt = [...context.systemPrompt];
|
|
432
|
+
const tools = normalizeTools(context.tools, options.intentTracing) ?? [];
|
|
433
|
+
return {
|
|
434
|
+
systemPrompt,
|
|
435
|
+
tools,
|
|
436
|
+
fingerprint: computeFingerprint(systemPrompt, tools, options),
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
export function cloneJson<T>(value: T): T {
|
|
441
|
+
return cloneJsonValue(value) as T;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function cloneJsonValue(value: unknown, key = "", applyToJson = true): unknown {
|
|
445
|
+
if (value === null) return null;
|
|
446
|
+
const type = typeof value;
|
|
447
|
+
if (type === "number") return Number.isFinite(value) ? value : null;
|
|
448
|
+
// JSON.stringify drops function/symbol/undefined values (object props
|
|
449
|
+
// omitted, array elements become null via the array walk below).
|
|
450
|
+
if (type === "undefined" || type === "function" || type === "symbol") return undefined;
|
|
451
|
+
if (type !== "object") return value;
|
|
452
|
+
if (applyToJson) {
|
|
453
|
+
// JSON.stringify performs a single Get of `toJSON` per holder/key and
|
|
454
|
+
// serializes the returned replacement WITHOUT re-dispatching the
|
|
455
|
+
// replacement's own toJSON at the same level (nested properties still
|
|
456
|
+
// dispatch normally). Mirror that exactly to keep byte parity.
|
|
457
|
+
const toJSON = (value as { toJSON?: unknown }).toJSON;
|
|
458
|
+
if (typeof toJSON === "function") {
|
|
459
|
+
return cloneJsonValue(toJSON.call(value, key), key, false);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
if (Array.isArray(value)) {
|
|
463
|
+
const cloned: unknown[] = new Array(value.length);
|
|
464
|
+
for (let i = 0; i < value.length; i++) {
|
|
465
|
+
const item = Object.hasOwn(value, i) ? cloneJsonValue(value[i], String(i)) : undefined;
|
|
466
|
+
cloned[i] = item === undefined ? null : item;
|
|
467
|
+
}
|
|
468
|
+
return cloned;
|
|
469
|
+
}
|
|
470
|
+
const cloned: Record<string, unknown> = {};
|
|
471
|
+
for (const key of Object.keys(value as object)) {
|
|
472
|
+
const clonedValue = cloneJsonValue((value as Record<string, unknown>)[key], key);
|
|
473
|
+
if (clonedValue !== undefined) cloned[key] = clonedValue;
|
|
474
|
+
}
|
|
475
|
+
return cloned;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function computeFingerprint(systemPrompt: string[], tools: Tool[], options: BuildOptions): string {
|
|
479
|
+
const payload = JSON.stringify({
|
|
480
|
+
s: systemPrompt,
|
|
481
|
+
t: tools.map(t => ({
|
|
482
|
+
n: t.name,
|
|
483
|
+
d: t.description,
|
|
484
|
+
p: t.parameters,
|
|
485
|
+
s: t.strict,
|
|
486
|
+
cf: t.customFormat,
|
|
487
|
+
cw: t.customWireName,
|
|
488
|
+
})),
|
|
489
|
+
i: options.intentTracing,
|
|
490
|
+
});
|
|
491
|
+
let hash = 0;
|
|
492
|
+
for (let i = 0; i < payload.length; i++) {
|
|
493
|
+
hash = ((hash << 5) - hash + payload.charCodeAt(i)) | 0;
|
|
494
|
+
}
|
|
495
|
+
return (hash >>> 0).toString(36);
|
|
496
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-attempt scope identity for request-scoped execution attribution.
|
|
3
|
+
*
|
|
4
|
+
* An AttemptScope is an immutable, frozen value allocated before every
|
|
5
|
+
* observable lifecycle emission for a single provider/agent attempt.
|
|
6
|
+
* It carries a stable `attemptId`, a monotonic `generation` (per-lineage),
|
|
7
|
+
* and a `lineage` discriminator that distinguishes the main attempt from
|
|
8
|
+
* concurrent side attempts (IRC background, ephemeral/btw turns).
|
|
9
|
+
*
|
|
10
|
+
* The `attemptId` + `generation` + `lineage` form the comparable identity.
|
|
11
|
+
* AttemptScope is structurally assignable to AttemptScopeRef in
|
|
12
|
+
* `packages/ai` so it can be carried through `SimpleStreamOptions` and
|
|
13
|
+
* provider hook signatures without a reverse dependency.
|
|
14
|
+
*/
|
|
15
|
+
export type AttemptLineage = "main" | `side:${string}`;
|
|
16
|
+
|
|
17
|
+
export interface AttemptScope {
|
|
18
|
+
readonly attemptId: string;
|
|
19
|
+
readonly generation: number;
|
|
20
|
+
readonly lineage: AttemptLineage;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function attemptScopesEqual(a: AttemptScope, b: AttemptScope): boolean {
|
|
24
|
+
return a.attemptId === b.attemptId && a.generation === b.generation && a.lineage === b.lineage;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Per-lineage currentness authority. Main and side attempts have separate
|
|
29
|
+
* instances so a side attempt never invalidates the main scope, and
|
|
30
|
+
* `forceAbort` advances only the main lineage.
|
|
31
|
+
*/
|
|
32
|
+
export interface LineageCurrentness {
|
|
33
|
+
readonly lineage: AttemptLineage;
|
|
34
|
+
/** True iff no successor scope with a greater generation was allocated in this lineage. */
|
|
35
|
+
isCurrent(scope: AttemptScope): boolean;
|
|
36
|
+
/** Allocate the next generation for the given attempt identity in this lineage. */
|
|
37
|
+
advance(attemptId: string): number;
|
|
38
|
+
/** Allocate the next generation in this lineage. */
|
|
39
|
+
/** Current generation value for this lineage. */
|
|
40
|
+
readonly current: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function createLineageCurrentness(lineage: AttemptLineage): LineageCurrentness {
|
|
44
|
+
let current = 0;
|
|
45
|
+
let currentAttemptId: string | undefined;
|
|
46
|
+
return {
|
|
47
|
+
lineage,
|
|
48
|
+
get current() {
|
|
49
|
+
return current;
|
|
50
|
+
},
|
|
51
|
+
isCurrent(scope: AttemptScope): boolean {
|
|
52
|
+
return scope.lineage === lineage && scope.generation === current && scope.attemptId === currentAttemptId;
|
|
53
|
+
},
|
|
54
|
+
advance(attemptId: string): number {
|
|
55
|
+
currentAttemptId = attemptId;
|
|
56
|
+
return ++current;
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Agent-owned authority over all attempt lineages. Owns the main lineage;
|
|
63
|
+
* side lineages are registered/removed with bounded lifecycle.
|
|
64
|
+
*
|
|
65
|
+
* This is the SINGLE source of currentness truth injected into
|
|
66
|
+
* AttemptRecordStore (packages/coding-agent). Every store operation
|
|
67
|
+
* calls `authority.isCurrent(scope)` and fails closed when the authority
|
|
68
|
+
* is missing or the scope is superseded.
|
|
69
|
+
*/
|
|
70
|
+
export interface AttemptScopeAuthority {
|
|
71
|
+
/** Register a side-lineage authority. Returns an unregister function. */
|
|
72
|
+
registerSide(lineage: AttemptLineage, auth: LineageCurrentness): () => void;
|
|
73
|
+
/** True iff the scope's lineage is known and its generation is current. */
|
|
74
|
+
isCurrent(scope: AttemptScope): boolean;
|
|
75
|
+
/** Advance the main lineage (called by forceAbort). Returns the new generation. */
|
|
76
|
+
advanceMain(): number;
|
|
77
|
+
/** Mint the next main-lineage scope. */
|
|
78
|
+
mintMain(): AttemptScope;
|
|
79
|
+
/**
|
|
80
|
+
* Atomically register a fresh side lineage, mint a side scope, and return
|
|
81
|
+
* both the scope and a dispose function. The authority knows the lineage
|
|
82
|
+
* BEFORE the scope is returned, so `isCurrent` succeeds immediately.
|
|
83
|
+
*/
|
|
84
|
+
mintSide(): { scope: AttemptScope; dispose: () => void };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface AttemptMinter {
|
|
88
|
+
mint(lineage: AttemptLineage): AttemptScope;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function createAttemptMinter(): AttemptMinter {
|
|
92
|
+
const generations = new Map<AttemptLineage, number>();
|
|
93
|
+
return {
|
|
94
|
+
mint(lineage: AttemptLineage): AttemptScope {
|
|
95
|
+
const gen = (generations.get(lineage) ?? 0) + 1;
|
|
96
|
+
generations.set(lineage, gen);
|
|
97
|
+
return Object.freeze({
|
|
98
|
+
attemptId: crypto.randomUUID(),
|
|
99
|
+
generation: gen,
|
|
100
|
+
lineage,
|
|
101
|
+
});
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const SIDE_LRU_CAP = 1024;
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Create the Agent-owned authority. Owns the main lineage and a bounded
|
|
110
|
+
* (LRU-capped) map of side lineages. Only RETIRED side authorities are
|
|
111
|
+
* eligible for LRU eviction; a live side attempt is never silently
|
|
112
|
+
* invalidated by a newer side registration.
|
|
113
|
+
*/
|
|
114
|
+
export function createAttemptScopeAuthority(): AttemptScopeAuthority {
|
|
115
|
+
const mainAuth = createLineageCurrentness("main");
|
|
116
|
+
const sideAuths = new Map<AttemptLineage, LineageCurrentness>();
|
|
117
|
+
const sideOrder: AttemptLineage[] = [];
|
|
118
|
+
const retiredSet = new Set<AttemptLineage>();
|
|
119
|
+
|
|
120
|
+
function evictRetiredIfNeeded(): void {
|
|
121
|
+
// Only evict RETIRED side authorities. A live side attempt is never
|
|
122
|
+
// evicted by a newer registration.
|
|
123
|
+
while (sideOrder.length > SIDE_LRU_CAP) {
|
|
124
|
+
const retiredIdx = sideOrder.findIndex(l => retiredSet.has(l));
|
|
125
|
+
if (retiredIdx < 0) break;
|
|
126
|
+
const [removed] = sideOrder.splice(retiredIdx, 1);
|
|
127
|
+
if (removed) {
|
|
128
|
+
sideAuths.delete(removed);
|
|
129
|
+
retiredSet.delete(removed);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function mintFor(lineage: AttemptLineage, auth: LineageCurrentness): AttemptScope {
|
|
135
|
+
const attemptId = crypto.randomUUID();
|
|
136
|
+
return Object.freeze({
|
|
137
|
+
attemptId,
|
|
138
|
+
generation: auth.advance(attemptId),
|
|
139
|
+
lineage,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
registerSide(lineage: AttemptLineage, auth: LineageCurrentness): () => void {
|
|
145
|
+
if (sideAuths.has(lineage)) {
|
|
146
|
+
const idx = sideOrder.indexOf(lineage);
|
|
147
|
+
if (idx >= 0) sideOrder.splice(idx, 1);
|
|
148
|
+
}
|
|
149
|
+
sideAuths.set(lineage, auth);
|
|
150
|
+
sideOrder.push(lineage);
|
|
151
|
+
evictRetiredIfNeeded();
|
|
152
|
+
return () => {
|
|
153
|
+
if (sideAuths.get(lineage) === auth) {
|
|
154
|
+
// Mark as retired but keep in maps until eviction.
|
|
155
|
+
// isCurrent returns false for retired lineages because
|
|
156
|
+
// the auth is still present but the scope is superseded
|
|
157
|
+
// by disposal (generation stays at its last value).
|
|
158
|
+
retiredSet.add(lineage);
|
|
159
|
+
evictRetiredIfNeeded();
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
},
|
|
163
|
+
isCurrent(scope: AttemptScope): boolean {
|
|
164
|
+
if (scope.lineage === "main") return mainAuth.isCurrent(scope);
|
|
165
|
+
if (retiredSet.has(scope.lineage)) return false;
|
|
166
|
+
const auth = sideAuths.get(scope.lineage);
|
|
167
|
+
return auth ? auth.isCurrent(scope) : false;
|
|
168
|
+
},
|
|
169
|
+
advanceMain(): number {
|
|
170
|
+
// Advance main lineage to a fresh attemptId so any previously-minted
|
|
171
|
+
// main scope becomes non-current. The next mintMain() will set the
|
|
172
|
+
// real attemptId for the new attempt.
|
|
173
|
+
return mainAuth.advance(crypto.randomUUID());
|
|
174
|
+
},
|
|
175
|
+
mintMain(): AttemptScope {
|
|
176
|
+
return mintFor("main", mainAuth);
|
|
177
|
+
},
|
|
178
|
+
mintSide(): { scope: AttemptScope; dispose: () => void } {
|
|
179
|
+
const lineage = `side:${crypto.randomUUID()}` as AttemptLineage;
|
|
180
|
+
const auth = createLineageCurrentness(lineage);
|
|
181
|
+
const unregister = this.registerSide(lineage, auth);
|
|
182
|
+
const scope = mintFor(lineage, auth);
|
|
183
|
+
return { scope, dispose: unregister };
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Immutable per-run attempt handle, carried through terminal/finalizer paths.
|
|
190
|
+
* Keyed by logicalRunId in the Agent's `#runHandles` map.
|
|
191
|
+
*/
|
|
192
|
+
export interface AttemptRunHandle {
|
|
193
|
+
readonly logicalRunId: number | import("./types.js").ManagedLogicalRunId;
|
|
194
|
+
readonly scope: AttemptScope;
|
|
195
|
+
}
|