@slopus/happy-agent-base 0.0.0 → 0.0.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/LICENSE +21 -0
- package/README.md +241 -16
- package/dist/Agent.d.ts +31 -0
- package/dist/Agent.d.ts.map +1 -0
- package/dist/Agent.js +119 -0
- package/dist/Agent.js.map +1 -0
- package/dist/AgentBase.d.ts +93 -0
- package/dist/AgentBase.d.ts.map +1 -0
- package/dist/AgentBase.js +909 -0
- package/dist/AgentBase.js.map +1 -0
- package/dist/AgentBaseContext.d.ts +22 -0
- package/dist/AgentBaseContext.d.ts.map +1 -0
- package/dist/AgentBaseContext.js +33 -0
- package/dist/AgentBaseContext.js.map +1 -0
- package/dist/AgentBaseHooks.d.ts +68 -0
- package/dist/AgentBaseHooks.d.ts.map +1 -0
- package/dist/AgentBaseHooks.js +2 -0
- package/dist/AgentBaseHooks.js.map +1 -0
- package/dist/AgentBasePersistence.d.ts +60 -0
- package/dist/AgentBasePersistence.d.ts.map +1 -0
- package/dist/AgentBasePersistence.js +2 -0
- package/dist/AgentBasePersistence.js.map +1 -0
- package/dist/AgentBaseState.d.ts +11 -0
- package/dist/AgentBaseState.d.ts.map +1 -0
- package/dist/AgentBaseState.js +2 -0
- package/dist/AgentBaseState.js.map +1 -0
- package/dist/AgentFeature.d.ts +29 -0
- package/dist/AgentFeature.d.ts.map +1 -0
- package/dist/AgentFeature.js +2 -0
- package/dist/AgentFeature.js.map +1 -0
- package/dist/AgentFeatureAction.d.ts +16 -0
- package/dist/AgentFeatureAction.d.ts.map +1 -0
- package/dist/AgentFeatureAction.js +2 -0
- package/dist/AgentFeatureAction.js.map +1 -0
- package/dist/AgentProviders.d.ts +17 -0
- package/dist/AgentProviders.d.ts.map +1 -0
- package/dist/AgentProviders.js +29 -0
- package/dist/AgentProviders.js.map +1 -0
- package/dist/AgentTool.d.ts +47 -0
- package/dist/AgentTool.d.ts.map +1 -0
- package/dist/AgentTool.js +5 -0
- package/dist/AgentTool.js.map +1 -0
- package/dist/index.d.ts +10 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -1
- package/dist/index.js.map +1 -1
- package/package.json +42 -37
|
@@ -0,0 +1,909 @@
|
|
|
1
|
+
import { areProviderModelsCompatible } from "@slopus/happy-providers";
|
|
2
|
+
import { Value } from "@sinclair/typebox/value";
|
|
3
|
+
import { asyncLock, withLifetime } from "@steve.kite/stdlib";
|
|
4
|
+
import { withAgentBaseContext } from "./AgentBaseContext.js";
|
|
5
|
+
import { AgentProviders } from "./AgentProviders.js";
|
|
6
|
+
/** Race winner when an abort interrupts a wait on the stream or a running tool. */
|
|
7
|
+
const ABORTED = Symbol("aborted");
|
|
8
|
+
/**
|
|
9
|
+
* A single agent session over one provider. Messages arrive through two FIFO queues: steering
|
|
10
|
+
* messages inject as soon as the current assistant response and its tool batch finish, while
|
|
11
|
+
* sent messages wait until the agent would otherwise stop — no tool calls or steering remain.
|
|
12
|
+
* Each queue drains per its configured mode, and the conversation is durable through
|
|
13
|
+
* append-only persistence loaded on the first inference attempt.
|
|
14
|
+
*/
|
|
15
|
+
export class AgentBase {
|
|
16
|
+
id;
|
|
17
|
+
/**
|
|
18
|
+
* The agent's own copy of the initial state, mutable directly; every inference reads the
|
|
19
|
+
* current values.
|
|
20
|
+
*/
|
|
21
|
+
state;
|
|
22
|
+
#baseCtx;
|
|
23
|
+
#ctx;
|
|
24
|
+
#providers;
|
|
25
|
+
#providerId;
|
|
26
|
+
#persistence;
|
|
27
|
+
#model;
|
|
28
|
+
#effort;
|
|
29
|
+
#serviceTier;
|
|
30
|
+
#hooks;
|
|
31
|
+
/**
|
|
32
|
+
* Serializes every persistence operation together with its in-memory effect, so storage
|
|
33
|
+
* order always matches history order and a load never overlaps an append.
|
|
34
|
+
*/
|
|
35
|
+
#persistenceLock = asyncLock({ reentry: "block" });
|
|
36
|
+
#steeringMode;
|
|
37
|
+
#sendMode;
|
|
38
|
+
#session;
|
|
39
|
+
#messages = [];
|
|
40
|
+
#steering = [];
|
|
41
|
+
#sends = [];
|
|
42
|
+
#pendingTools = [];
|
|
43
|
+
#pendingSequence = 0;
|
|
44
|
+
#loaded;
|
|
45
|
+
#recoveryChecked = false;
|
|
46
|
+
#compaction;
|
|
47
|
+
#abortController;
|
|
48
|
+
#turnRequested = false;
|
|
49
|
+
#runPromise;
|
|
50
|
+
#closed = false;
|
|
51
|
+
constructor(ctx, options) {
|
|
52
|
+
this.id = options.id;
|
|
53
|
+
this.#baseCtx = ctx;
|
|
54
|
+
this.#providers = options.providers;
|
|
55
|
+
this.#providerId = options.provider;
|
|
56
|
+
this.#persistence = options.persistence;
|
|
57
|
+
this.#hooks = options.hooks ?? {};
|
|
58
|
+
this.state = {
|
|
59
|
+
instructions: options.initialState?.instructions ?? "",
|
|
60
|
+
tools: [...(options.initialState?.tools ?? [])],
|
|
61
|
+
};
|
|
62
|
+
this.#model = options.model;
|
|
63
|
+
this.#effort = options.effort;
|
|
64
|
+
this.#serviceTier = options.serviceTier;
|
|
65
|
+
// Everything the agent does — hooks and tool executions included — runs on a context
|
|
66
|
+
// carrying its provider and the currently effective model, effort, and service tier.
|
|
67
|
+
this.#ctx = this.#deriveCtx();
|
|
68
|
+
this.#steeringMode = options.steeringMode ?? "one-at-a-time";
|
|
69
|
+
this.#sendMode = options.sendMode ?? "one-at-a-time";
|
|
70
|
+
}
|
|
71
|
+
#deriveCtx() {
|
|
72
|
+
return withAgentBaseContext(this.#baseCtx, {
|
|
73
|
+
provider: this.#providerId,
|
|
74
|
+
model: this.#model,
|
|
75
|
+
effort: this.#effort,
|
|
76
|
+
serviceTier: this.#serviceTier,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Queue a user message that injects as soon as the current assistant response and its tool
|
|
81
|
+
* batch finish; steering always takes precedence over sent messages. The returned promise
|
|
82
|
+
* resolves once the durable write lands; it waits neither for the history load nor for the
|
|
83
|
+
* turn, and a failed write keeps the message out of the conversation entirely.
|
|
84
|
+
*/
|
|
85
|
+
async steer(ctx, message, options) {
|
|
86
|
+
await this.#enqueue(ctx, "steering.", this.#steering, message, options ?? {});
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Queue a user message that waits until the agent would otherwise stop — no tool calls or
|
|
90
|
+
* steering remain — before injecting. The returned promise resolves once the durable write
|
|
91
|
+
* lands; it waits neither for the history load nor for the turn, and a failed write keeps
|
|
92
|
+
* the message out of the conversation entirely.
|
|
93
|
+
*/
|
|
94
|
+
async send(ctx, message, options) {
|
|
95
|
+
await this.#enqueue(ctx, "send.", this.#sends, message, options ?? {});
|
|
96
|
+
}
|
|
97
|
+
async #enqueue(ctx, prefix, queue, message, options) {
|
|
98
|
+
if (this.#closed)
|
|
99
|
+
throw new Error("The agent has been closed.");
|
|
100
|
+
await this.#persistenceLock.runInLock(ctx, async (lockCtx) => {
|
|
101
|
+
const key = this.#queueKey(prefix);
|
|
102
|
+
await this.#persistence.writeValue(lockCtx, key, { message, options });
|
|
103
|
+
queue.push({ key, message, options });
|
|
104
|
+
this.#turnRequested = true;
|
|
105
|
+
this.#startRun();
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Start the loop without a new message: load the durable state and, if a turn was cut off —
|
|
110
|
+
* queued messages, a dispatched tool batch without results, or an unanswered user or tool
|
|
111
|
+
* message — continue it to completion. On an idle history this loads and does nothing more.
|
|
112
|
+
*/
|
|
113
|
+
start() {
|
|
114
|
+
if (this.#closed)
|
|
115
|
+
throw new Error("The agent has been closed.");
|
|
116
|
+
this.#startRun();
|
|
117
|
+
}
|
|
118
|
+
async waitForIdle() {
|
|
119
|
+
while (this.#runPromise !== undefined) {
|
|
120
|
+
await this.#runPromise;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Compact the conversation. The compaction waits for the active turn to end — or runs right
|
|
125
|
+
* away when idle — and replaces the compacted history with the provider's replacement
|
|
126
|
+
* context while keeping every message that joined the history after the snapshot. Calls made
|
|
127
|
+
* while a compaction is pending or running await that same compaction; the shared promise
|
|
128
|
+
* resolves when it completes and rejects when the provider reports failure.
|
|
129
|
+
*/
|
|
130
|
+
async compact(ctx) {
|
|
131
|
+
if (this.#closed)
|
|
132
|
+
throw new Error("The agent has been closed.");
|
|
133
|
+
return this.#ensureCompaction();
|
|
134
|
+
}
|
|
135
|
+
#ensureCompaction() {
|
|
136
|
+
if (this.#compaction === undefined) {
|
|
137
|
+
let resolve;
|
|
138
|
+
let reject;
|
|
139
|
+
const promise = new Promise((res, rej) => {
|
|
140
|
+
resolve = res;
|
|
141
|
+
reject = rej;
|
|
142
|
+
});
|
|
143
|
+
this.#compaction = { promise, resolve, reject };
|
|
144
|
+
this.#turnRequested = true;
|
|
145
|
+
this.#startRun();
|
|
146
|
+
}
|
|
147
|
+
return this.#compaction.promise;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* The system prompt for the next request: the hook's answer when one is provided, the
|
|
151
|
+
* mutable state otherwise. A throwing hook falls back to the state; it never fails the run.
|
|
152
|
+
*/
|
|
153
|
+
#instructions() {
|
|
154
|
+
try {
|
|
155
|
+
return this.#hooks.instructions?.(this.#ctx) ?? this.state.instructions;
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
return this.state.instructions;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* The tools for the next request or execution: the hook's answer when one is provided, the
|
|
163
|
+
* mutable state otherwise. A throwing hook falls back to the state; it never fails the run.
|
|
164
|
+
*/
|
|
165
|
+
#tools() {
|
|
166
|
+
try {
|
|
167
|
+
return this.#hooks.tools?.(this.#ctx) ?? this.state.tools;
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
return this.state.tools;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Cancel the active turn: stop consuming the inference stream, settle still-running tool
|
|
175
|
+
* calls as aborted error results, and drop the queued turn request. Blocks that already
|
|
176
|
+
* finished stay in the history; an unfinished block is dropped. Messages still waiting in
|
|
177
|
+
* the steering and send queues stay durable and join the next requested turn. Resolves
|
|
178
|
+
* once the loop has stopped; a no-op when the agent is idle.
|
|
179
|
+
*/
|
|
180
|
+
async abort() {
|
|
181
|
+
const run = this.#runPromise;
|
|
182
|
+
if (run === undefined)
|
|
183
|
+
return;
|
|
184
|
+
this.#turnRequested = false;
|
|
185
|
+
this.#abortController?.abort();
|
|
186
|
+
await run;
|
|
187
|
+
}
|
|
188
|
+
async close() {
|
|
189
|
+
if (this.#closed)
|
|
190
|
+
return;
|
|
191
|
+
this.#closed = true;
|
|
192
|
+
await this.#runPromise;
|
|
193
|
+
await this.#session?.destroy();
|
|
194
|
+
this.#session = undefined;
|
|
195
|
+
}
|
|
196
|
+
#startRun() {
|
|
197
|
+
if (this.#runPromise !== undefined)
|
|
198
|
+
return;
|
|
199
|
+
this.#runPromise = this.#runLoop().finally(() => {
|
|
200
|
+
this.#runPromise = undefined;
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
async #runLoop() {
|
|
204
|
+
// The outer loop reopens when an `afterAgentLoop` action requests more work, so the
|
|
205
|
+
// loop hooks always bracket a settled-to-settled span.
|
|
206
|
+
do {
|
|
207
|
+
this.#invokeHook(this.#hooks.beforeAgentLoop);
|
|
208
|
+
do {
|
|
209
|
+
this.#turnRequested = false;
|
|
210
|
+
this.#invokeHook(this.#hooks.beforeTurn);
|
|
211
|
+
await this.#runInference();
|
|
212
|
+
await this.#applyActions(this.#hooks.afterTurn);
|
|
213
|
+
} while (this.#turnRequested && !this.#closed);
|
|
214
|
+
await this.#applyActions(this.#hooks.afterAgentLoop);
|
|
215
|
+
} while (this.#turnRequested && !this.#closed);
|
|
216
|
+
}
|
|
217
|
+
#invokeHook(hook) {
|
|
218
|
+
try {
|
|
219
|
+
hook?.(this.#ctx);
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
// Hooks observe the run; they never fail it.
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Ask a lifecycle hook what to do next and carry its actions out: queue steering or sent
|
|
227
|
+
* messages through the ordinary durable path, or trigger a compaction. Every returned
|
|
228
|
+
* action is applied before the loop continues, so they all take effect at the same point.
|
|
229
|
+
* Neither a throwing hook nor a failing action ever fails the run.
|
|
230
|
+
*/
|
|
231
|
+
async #applyActions(hook) {
|
|
232
|
+
if (hook === undefined)
|
|
233
|
+
return;
|
|
234
|
+
let actions;
|
|
235
|
+
try {
|
|
236
|
+
actions = hook(this.#ctx);
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
for (const action of actions ?? []) {
|
|
242
|
+
try {
|
|
243
|
+
if (action.type === "compact") {
|
|
244
|
+
this.#ensureCompaction().catch(() => undefined);
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
const queue = action.type === "steer" ? this.#steering : this.#sends;
|
|
248
|
+
const prefix = action.type === "steer" ? "steering." : "send.";
|
|
249
|
+
await this.#enqueue(this.#ctx, prefix, queue, action.message, {});
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
// A hook-driven action must not fail the run.
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
async #runInference() {
|
|
257
|
+
// One abort scope per pass; a single shared promise keeps races from piling up
|
|
258
|
+
// listeners on the signal.
|
|
259
|
+
const abort = new AbortController();
|
|
260
|
+
this.#abortController = abort;
|
|
261
|
+
const abortPromise = new Promise((resolve) => {
|
|
262
|
+
abort.signal.addEventListener("abort", () => resolve(ABORTED), { once: true });
|
|
263
|
+
});
|
|
264
|
+
try {
|
|
265
|
+
// A failed load is not sticky: the cache resets so the next turn retries it.
|
|
266
|
+
this.#loaded ??= this.#loadHistory().catch((error) => {
|
|
267
|
+
this.#loaded = undefined;
|
|
268
|
+
throw error;
|
|
269
|
+
});
|
|
270
|
+
await this.#loaded;
|
|
271
|
+
// Resume a tool batch that was dispatched but cut off before its results landed, so
|
|
272
|
+
// the interrupted results reach the main store before any queued message.
|
|
273
|
+
const resumed = this.#pendingTools;
|
|
274
|
+
this.#pendingTools = [];
|
|
275
|
+
if (resumed.length > 0) {
|
|
276
|
+
await this.#runToolBatch(resumed, true, abort.signal, abortPromise);
|
|
277
|
+
}
|
|
278
|
+
// A response is owed without any injection when tool results from a resumed batch
|
|
279
|
+
// end the context, or — checked once, against the freshly loaded durable state —
|
|
280
|
+
// when a cut-off run left its trailing user or tool message unanswered. Afterwards
|
|
281
|
+
// a trailing user message can be legitimate: a response may have zero blocks.
|
|
282
|
+
let responseOwed = resumed.length > 0;
|
|
283
|
+
if (!this.#recoveryChecked) {
|
|
284
|
+
this.#recoveryChecked = true;
|
|
285
|
+
const last = this.#messages[this.#messages.length - 1];
|
|
286
|
+
responseOwed ||= last?.role === "user" || last?.role === "tool";
|
|
287
|
+
}
|
|
288
|
+
// Each cycle first drains the queues, then runs one inference. Steering injects at
|
|
289
|
+
// every stop between responses and always outranks sends; sent messages inject
|
|
290
|
+
// only when the agent would otherwise stop — no tool results or steering remain.
|
|
291
|
+
// Queue consumption happens only here, between inferences, so an injected message
|
|
292
|
+
// can never interleave with an active response's block records.
|
|
293
|
+
// The message of an error response that has not been recovered from yet. A later
|
|
294
|
+
// successful response clears it; a turn that ends while it is set has failed and
|
|
295
|
+
// surfaces it to the context as a system message.
|
|
296
|
+
let pendingError;
|
|
297
|
+
while (true) {
|
|
298
|
+
// An abort during the tool batch ends the turn here, before the next inference.
|
|
299
|
+
if (abort.signal.aborted) {
|
|
300
|
+
this.#emit({ type: "done", state: "cancelled" });
|
|
301
|
+
break;
|
|
302
|
+
}
|
|
303
|
+
let injected = await this.#consumeQueue(this.#steering, this.#steeringMode);
|
|
304
|
+
if (!injected && !responseOwed) {
|
|
305
|
+
injected = await this.#consumeQueue(this.#sends, this.#sendMode);
|
|
306
|
+
}
|
|
307
|
+
// Nothing to answer — a start() on an idle history, or the queues ran dry.
|
|
308
|
+
if (!injected && !responseOwed)
|
|
309
|
+
break;
|
|
310
|
+
const session = await this.#ensureSession();
|
|
311
|
+
this.#invokeHook(this.#hooks.beforeInference);
|
|
312
|
+
const stream = session.run(this.#ctx, {
|
|
313
|
+
context: {
|
|
314
|
+
instructions: this.#instructions(),
|
|
315
|
+
messages: [...this.#messages],
|
|
316
|
+
},
|
|
317
|
+
...(this.#model === undefined ? {} : { model: this.#model }),
|
|
318
|
+
...(this.#effort === undefined ? {} : { effort: this.#effort }),
|
|
319
|
+
...(this.#serviceTier === undefined
|
|
320
|
+
? {}
|
|
321
|
+
: { serviceTier: this.#serviceTier }),
|
|
322
|
+
});
|
|
323
|
+
const { content, state, errorMessage } = await this.#collect(stream, abortPromise);
|
|
324
|
+
this.#invokeHook(this.#hooks.afterInference);
|
|
325
|
+
if (content.length > 0) {
|
|
326
|
+
this.#messages.push({ role: "assistant", content });
|
|
327
|
+
}
|
|
328
|
+
responseOwed = false;
|
|
329
|
+
pendingError = state === "error" ? errorMessage : undefined;
|
|
330
|
+
if (state === "tool_call") {
|
|
331
|
+
const calls = content.filter((block) => block.type === "tool_call" && block.server !== true);
|
|
332
|
+
if (calls.length === 0)
|
|
333
|
+
continue;
|
|
334
|
+
await this.#runToolBatch(calls.map((call, index) => ({
|
|
335
|
+
key: this.#toolKey(index, call.callId),
|
|
336
|
+
call,
|
|
337
|
+
})), false, abort.signal, abortPromise);
|
|
338
|
+
responseOwed = true;
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
// A natural stop keeps draining, and so does a provider-reported error: the
|
|
342
|
+
// failed response never answers the queued messages, so they still get their
|
|
343
|
+
// fresh inference — each drain consumes from a finite queue, so a persistently
|
|
344
|
+
// failing provider cannot loop. A cancellation or a stream that ended without
|
|
345
|
+
// a done event ends the turn with the queues intact.
|
|
346
|
+
if (state !== "normal" && state !== "length" && state !== "error")
|
|
347
|
+
break;
|
|
348
|
+
}
|
|
349
|
+
if (pendingError !== undefined) {
|
|
350
|
+
await this.#appendFailure(pendingError);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
catch (error) {
|
|
354
|
+
this.#emit({
|
|
355
|
+
type: "done",
|
|
356
|
+
state: "error",
|
|
357
|
+
kind: "internal_error",
|
|
358
|
+
message: error instanceof Error ? error.message : String(error),
|
|
359
|
+
});
|
|
360
|
+
await this.#appendFailure(error instanceof Error ? error.message : String(error));
|
|
361
|
+
}
|
|
362
|
+
// The turn is over; a requested compaction runs now, before the next pass can start.
|
|
363
|
+
await this.#runCompaction();
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Run the pending compaction, if any. The snapshot is taken here, with the turn over and
|
|
367
|
+
* this pass being the only history writer, so nothing joins the history mid-compaction; the
|
|
368
|
+
* suffix copy still keeps any such message, defensively. The replacement is appended as a
|
|
369
|
+
* compaction record — the load-time reset point — and settles the shared promise for every
|
|
370
|
+
* caller awaiting it. A provider failure rejects them and leaves the history untouched.
|
|
371
|
+
*/
|
|
372
|
+
async #runCompaction() {
|
|
373
|
+
const pending = this.#compaction;
|
|
374
|
+
if (pending === undefined)
|
|
375
|
+
return;
|
|
376
|
+
try {
|
|
377
|
+
const session = await this.#ensureSession();
|
|
378
|
+
const snapshot = [...this.#messages];
|
|
379
|
+
const result = await session.compact(this.#ctx, {
|
|
380
|
+
context: { instructions: this.#instructions(), messages: snapshot },
|
|
381
|
+
...(this.#model === undefined ? {} : { model: this.#model }),
|
|
382
|
+
});
|
|
383
|
+
if (result.status === "failed") {
|
|
384
|
+
throw new Error(result.message);
|
|
385
|
+
}
|
|
386
|
+
if (result.status === "completed") {
|
|
387
|
+
await this.#persistenceLock.runInLock(this.#ctx, async (lockCtx) => {
|
|
388
|
+
const suffix = this.#messages.slice(snapshot.length);
|
|
389
|
+
const replaced = [...result.context.messages, ...suffix];
|
|
390
|
+
// Physically delete the superseded records and write the replacement —
|
|
391
|
+
// which keeps the messages that stay — in one atomic step.
|
|
392
|
+
await this.#persistence.transaction(lockCtx, async (txCtx) => {
|
|
393
|
+
await this.#persistence.clearRecords(txCtx);
|
|
394
|
+
await this.#persistence.append(txCtx, {
|
|
395
|
+
type: "compaction",
|
|
396
|
+
messages: replaced,
|
|
397
|
+
});
|
|
398
|
+
});
|
|
399
|
+
this.#messages = replaced;
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
this.#compaction = undefined;
|
|
403
|
+
pending.resolve();
|
|
404
|
+
}
|
|
405
|
+
catch (error) {
|
|
406
|
+
this.#compaction = undefined;
|
|
407
|
+
pending.reject(error);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* Surface a failed turn to the conversation as a system message, so the next inference sees
|
|
412
|
+
* what went wrong. Only unrecovered failures reach here — a later successful response in the
|
|
413
|
+
* same turn clears its error without a trace. Skipped when the history never loaded, since
|
|
414
|
+
* there is no context to append to; its own failure is swallowed, so surfacing a failure can
|
|
415
|
+
* never cause another.
|
|
416
|
+
*/
|
|
417
|
+
async #appendFailure(message) {
|
|
418
|
+
if (this.#loaded === undefined)
|
|
419
|
+
return;
|
|
420
|
+
const failure = {
|
|
421
|
+
role: "system",
|
|
422
|
+
content: [{ type: "text", text: `The last turn failed: ${message}` }],
|
|
423
|
+
};
|
|
424
|
+
try {
|
|
425
|
+
await this.#persistenceLock.runInLock(this.#ctx, async (lockCtx) => {
|
|
426
|
+
await this.#persistence.append(lockCtx, { type: "system", message: failure });
|
|
427
|
+
this.#messages.push(failure);
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
catch {
|
|
431
|
+
// The turn already failed; a failing write must not escalate it.
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* Move the oldest queued message — or, in "all" mode, every queued message — into the main
|
|
436
|
+
* context store and the in-memory history. The moves run in one transaction, so a message
|
|
437
|
+
* is never durable in both stores or neither, and memory changes only after the commit.
|
|
438
|
+
*/
|
|
439
|
+
async #consumeQueue(queue, mode) {
|
|
440
|
+
return await this.#persistenceLock.runInLock(this.#ctx, async (lockCtx) => {
|
|
441
|
+
if (queue.length === 0)
|
|
442
|
+
return false;
|
|
443
|
+
const count = mode === "all" ? queue.length : 1;
|
|
444
|
+
const batch = queue.slice(0, count);
|
|
445
|
+
// Settings carried by the consumed messages become the effective settings for the
|
|
446
|
+
// inference that follows, each defined field superseding the previous value. The
|
|
447
|
+
// effective values are persisted alongside the consumption so a restart keeps them.
|
|
448
|
+
let provider = this.#providerId;
|
|
449
|
+
let model = this.#model;
|
|
450
|
+
let effort = this.#effort;
|
|
451
|
+
let serviceTier = this.#serviceTier;
|
|
452
|
+
let changed = false;
|
|
453
|
+
for (const entry of batch) {
|
|
454
|
+
if (entry.options.provider !== undefined) {
|
|
455
|
+
provider = entry.options.provider;
|
|
456
|
+
changed = true;
|
|
457
|
+
}
|
|
458
|
+
if (entry.options.model !== undefined) {
|
|
459
|
+
model = entry.options.model;
|
|
460
|
+
changed = true;
|
|
461
|
+
}
|
|
462
|
+
if (entry.options.effort !== undefined) {
|
|
463
|
+
effort = entry.options.effort;
|
|
464
|
+
changed = true;
|
|
465
|
+
}
|
|
466
|
+
if (entry.options.serviceTier !== undefined) {
|
|
467
|
+
serviceTier = entry.options.serviceTier;
|
|
468
|
+
changed = true;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
// A provider or model change is checked against the provider-model compatibility
|
|
472
|
+
// matrix. An incompatible change resets the conversation: the history is erased
|
|
473
|
+
// completely, the old provider session is destroyed, and the `modelChanged` hook
|
|
474
|
+
// may inject one handoff system message at the very beginning of the fresh
|
|
475
|
+
// context. A compatible provider change keeps the history but still gets a fresh
|
|
476
|
+
// session, since a session is bound to the provider that created it.
|
|
477
|
+
const selectionChanged = provider !== this.#providerId || model !== this.#model;
|
|
478
|
+
let reset = false;
|
|
479
|
+
let injected;
|
|
480
|
+
if (selectionChanged) {
|
|
481
|
+
if (this.#model !== undefined && model !== undefined) {
|
|
482
|
+
const previousType = this.#providers.typeOf(this.#providerId);
|
|
483
|
+
const nextType = this.#providers.typeOf(provider);
|
|
484
|
+
reset =
|
|
485
|
+
previousType === null ||
|
|
486
|
+
nextType === null ||
|
|
487
|
+
!areProviderModelsCompatible({
|
|
488
|
+
modelId: this.#model,
|
|
489
|
+
providerId: this.#providerId,
|
|
490
|
+
providerType: previousType,
|
|
491
|
+
}, {
|
|
492
|
+
modelId: model,
|
|
493
|
+
providerId: provider,
|
|
494
|
+
providerType: nextType,
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
else {
|
|
498
|
+
// A selection without a model on either side cannot be judged compatible.
|
|
499
|
+
reset = model !== this.#model;
|
|
500
|
+
}
|
|
501
|
+
if (this.#hooks.modelChanged !== undefined && model !== undefined) {
|
|
502
|
+
const changeCtx = withAgentBaseContext(this.#baseCtx, {
|
|
503
|
+
provider,
|
|
504
|
+
model,
|
|
505
|
+
effort,
|
|
506
|
+
serviceTier,
|
|
507
|
+
});
|
|
508
|
+
try {
|
|
509
|
+
injected = this.#hooks.modelChanged(changeCtx, {
|
|
510
|
+
previousModel: this.#model,
|
|
511
|
+
model,
|
|
512
|
+
previousProvider: this.#providerId,
|
|
513
|
+
provider,
|
|
514
|
+
providers: this.#providers,
|
|
515
|
+
previousProviderInstance: this.#providers.get(this.#providerId),
|
|
516
|
+
providerInstance: this.#providers.get(provider),
|
|
517
|
+
wasReset: reset,
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
catch {
|
|
521
|
+
// Hooks observe the run; they never fail it.
|
|
522
|
+
}
|
|
523
|
+
if (!reset)
|
|
524
|
+
injected = undefined;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
await this.#persistence.transaction(lockCtx, async (txCtx) => {
|
|
528
|
+
if (reset) {
|
|
529
|
+
await this.#persistence.clearRecords(txCtx);
|
|
530
|
+
if (injected !== undefined) {
|
|
531
|
+
await this.#persistence.append(txCtx, {
|
|
532
|
+
type: "system",
|
|
533
|
+
message: injected,
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
for (const entry of batch) {
|
|
538
|
+
await this.#persistence.append(txCtx, {
|
|
539
|
+
type: "user",
|
|
540
|
+
message: entry.message,
|
|
541
|
+
});
|
|
542
|
+
await this.#persistence.deleteValue(txCtx, entry.key);
|
|
543
|
+
}
|
|
544
|
+
if (changed) {
|
|
545
|
+
await this.#persistence.writeValue(txCtx, "settings", {
|
|
546
|
+
provider,
|
|
547
|
+
...(model === undefined ? {} : { model }),
|
|
548
|
+
...(effort === undefined ? {} : { effort }),
|
|
549
|
+
...(serviceTier === undefined ? {} : { serviceTier }),
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
});
|
|
553
|
+
queue.splice(0, count);
|
|
554
|
+
if (reset) {
|
|
555
|
+
this.#messages = injected === undefined ? [] : [injected];
|
|
556
|
+
}
|
|
557
|
+
if (reset || provider !== this.#providerId) {
|
|
558
|
+
const session = this.#session;
|
|
559
|
+
this.#session = undefined;
|
|
560
|
+
try {
|
|
561
|
+
await session?.destroy();
|
|
562
|
+
}
|
|
563
|
+
catch {
|
|
564
|
+
// The change already committed; a failing destroy must not undo it.
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
this.#messages.push(...batch.map((entry) => entry.message));
|
|
568
|
+
if (changed) {
|
|
569
|
+
this.#providerId = provider;
|
|
570
|
+
this.#model = model;
|
|
571
|
+
this.#effort = effort;
|
|
572
|
+
this.#serviceTier = serviceTier;
|
|
573
|
+
this.#ctx = this.#deriveCtx();
|
|
574
|
+
}
|
|
575
|
+
return true;
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* Replace the in-memory state with the durable one. The persistence lock guarantees every
|
|
580
|
+
* message already in memory reached storage first, so the load result supersedes memory
|
|
581
|
+
* entirely: the main store rebuilds the context, and the sorted queue keys rebuild the
|
|
582
|
+
* not-yet-consumed queues. Consecutive block records reassemble into one assistant message.
|
|
583
|
+
*/
|
|
584
|
+
async #loadHistory() {
|
|
585
|
+
await this.#persistenceLock.runInLock(this.#ctx, async (lockCtx) => {
|
|
586
|
+
const records = await this.#persistence.load(lockCtx);
|
|
587
|
+
let restored = [];
|
|
588
|
+
for (const record of records) {
|
|
589
|
+
if (record.type === "compaction") {
|
|
590
|
+
// A compaction record carries the complete replacement context and
|
|
591
|
+
// supersedes everything before it.
|
|
592
|
+
restored = [...record.messages];
|
|
593
|
+
continue;
|
|
594
|
+
}
|
|
595
|
+
if (record.type === "user" || record.type === "tool" || record.type === "system") {
|
|
596
|
+
restored.push(record.message);
|
|
597
|
+
continue;
|
|
598
|
+
}
|
|
599
|
+
const last = restored[restored.length - 1];
|
|
600
|
+
if (last?.role === "assistant") {
|
|
601
|
+
restored[restored.length - 1] = {
|
|
602
|
+
role: "assistant",
|
|
603
|
+
content: [...last.content, record.block],
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
else {
|
|
607
|
+
restored.push({ role: "assistant", content: [record.block] });
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
const steering = await this.#persistence.readValues(lockCtx, "steering.");
|
|
611
|
+
const sends = await this.#persistence.readValues(lockCtx, "send.");
|
|
612
|
+
const pendingTools = await this.#persistence.readValues(lockCtx, "tool.");
|
|
613
|
+
const settings = await this.#persistence.readValues(lockCtx, "settings");
|
|
614
|
+
this.#messages = restored;
|
|
615
|
+
const entry = (key, value) => {
|
|
616
|
+
const envelope = value;
|
|
617
|
+
return { key, message: envelope.message, options: envelope.options ?? {} };
|
|
618
|
+
};
|
|
619
|
+
this.#steering = steering.map(({ key, value }) => entry(key, value));
|
|
620
|
+
this.#sends = sends.map(({ key, value }) => entry(key, value));
|
|
621
|
+
// The persisted settings are the complete effective triple from the last change; an
|
|
622
|
+
// absent field means that setting was effectively unset when it was written.
|
|
623
|
+
const persisted = settings[0]?.value;
|
|
624
|
+
if (persisted !== undefined) {
|
|
625
|
+
if (persisted.provider !== undefined)
|
|
626
|
+
this.#providerId = persisted.provider;
|
|
627
|
+
this.#model = persisted.model;
|
|
628
|
+
this.#effort = persisted.effort;
|
|
629
|
+
this.#serviceTier = persisted.serviceTier;
|
|
630
|
+
this.#ctx = this.#deriveCtx();
|
|
631
|
+
}
|
|
632
|
+
this.#pendingTools = pendingTools.map(({ key, value }) => ({
|
|
633
|
+
key,
|
|
634
|
+
call: value,
|
|
635
|
+
}));
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
/**
|
|
639
|
+
* Run one batch of tool calls. The whole batch is committed to the sorted store before any
|
|
640
|
+
* call executes, so a crash mid-batch leaves a durable record of the calls still owed a
|
|
641
|
+
* result. All calls run in parallel, but results land strictly in call order: a finished
|
|
642
|
+
* result waits until every earlier call in the batch has committed, and each commit appends
|
|
643
|
+
* the tool record and removes the pending entry in one transaction before memory changes.
|
|
644
|
+
* On resume, only durable tools execute again; the rest become error results. An abort
|
|
645
|
+
* settles every call still running as an aborted error result, so the batch always leaves a
|
|
646
|
+
* complete context behind.
|
|
647
|
+
*/
|
|
648
|
+
async #runToolBatch(entries, resume, signal, abortPromise) {
|
|
649
|
+
if (!resume) {
|
|
650
|
+
await this.#persistenceLock.runInLock(this.#ctx, (lockCtx) => this.#persistence.transaction(lockCtx, async (txCtx) => {
|
|
651
|
+
for (const entry of entries) {
|
|
652
|
+
await this.#persistence.writeValue(txCtx, entry.key, entry.call);
|
|
653
|
+
}
|
|
654
|
+
}));
|
|
655
|
+
}
|
|
656
|
+
const results = new Array(entries.length);
|
|
657
|
+
let committed = 0;
|
|
658
|
+
const commitReady = () => this.#persistenceLock.runInLock(this.#ctx, async (lockCtx) => {
|
|
659
|
+
while (committed < entries.length) {
|
|
660
|
+
const entry = entries[committed];
|
|
661
|
+
const result = results[committed];
|
|
662
|
+
if (entry === undefined || result === undefined)
|
|
663
|
+
return;
|
|
664
|
+
await this.#persistence.transaction(lockCtx, async (txCtx) => {
|
|
665
|
+
await this.#persistence.append(txCtx, {
|
|
666
|
+
type: "tool",
|
|
667
|
+
message: result,
|
|
668
|
+
});
|
|
669
|
+
await this.#persistence.deleteValue(txCtx, entry.key);
|
|
670
|
+
});
|
|
671
|
+
this.#messages.push(result);
|
|
672
|
+
committed += 1;
|
|
673
|
+
}
|
|
674
|
+
});
|
|
675
|
+
await Promise.all(entries.map(async (entry, index) => {
|
|
676
|
+
const outcome = resume && !this.#isDurable(entry.call)
|
|
677
|
+
? {
|
|
678
|
+
role: "tool",
|
|
679
|
+
callId: entry.call.callId,
|
|
680
|
+
content: [
|
|
681
|
+
{
|
|
682
|
+
type: "text",
|
|
683
|
+
text: "The tool call was interrupted by a restart and was not retried.",
|
|
684
|
+
},
|
|
685
|
+
],
|
|
686
|
+
isError: true,
|
|
687
|
+
}
|
|
688
|
+
: await Promise.race([
|
|
689
|
+
this.#executeToolCall(withLifetime(this.#ctx, signal), entry.call),
|
|
690
|
+
abortPromise,
|
|
691
|
+
]);
|
|
692
|
+
results[index] =
|
|
693
|
+
outcome === ABORTED
|
|
694
|
+
? {
|
|
695
|
+
role: "tool",
|
|
696
|
+
callId: entry.call.callId,
|
|
697
|
+
content: [{ type: "text", text: "The tool call was aborted." }],
|
|
698
|
+
isError: true,
|
|
699
|
+
}
|
|
700
|
+
: outcome;
|
|
701
|
+
await commitReady();
|
|
702
|
+
}));
|
|
703
|
+
}
|
|
704
|
+
#isDurable(call) {
|
|
705
|
+
const tool = this.#tools().find((candidate) => candidate.name === call.name && candidate.namespace === call.namespace);
|
|
706
|
+
return tool?.durable === true;
|
|
707
|
+
}
|
|
708
|
+
/** Sorted by position in the batch; only one batch is ever pending at a time. */
|
|
709
|
+
#toolKey(index, callId) {
|
|
710
|
+
return `tool.${String(index).padStart(6, "0")}.${callId}`;
|
|
711
|
+
}
|
|
712
|
+
/**
|
|
713
|
+
* Run one tool call; every failure becomes an error tool result instead of an exception.
|
|
714
|
+
* The context carries the turn's abort signal as its lifetime, so a running tool can
|
|
715
|
+
* observe cancellation and stop its own work.
|
|
716
|
+
*/
|
|
717
|
+
async #executeToolCall(ctx, call) {
|
|
718
|
+
const failure = (text) => ({
|
|
719
|
+
role: "tool",
|
|
720
|
+
callId: call.callId,
|
|
721
|
+
content: [{ type: "text", text }],
|
|
722
|
+
isError: true,
|
|
723
|
+
});
|
|
724
|
+
const tool = this.#tools().find((candidate) => candidate.name === call.name && candidate.namespace === call.namespace);
|
|
725
|
+
if (tool === undefined) {
|
|
726
|
+
return failure(`Tool "${call.name}" is not available.`);
|
|
727
|
+
}
|
|
728
|
+
if (call.incomplete === true) {
|
|
729
|
+
return failure("The tool call was incomplete and was not executed.");
|
|
730
|
+
}
|
|
731
|
+
let args;
|
|
732
|
+
try {
|
|
733
|
+
args = call.arguments.trim().length === 0 ? {} : JSON.parse(call.arguments);
|
|
734
|
+
}
|
|
735
|
+
catch {
|
|
736
|
+
return failure(`The arguments for "${call.name}" were not valid JSON.`);
|
|
737
|
+
}
|
|
738
|
+
if (tool.parameters !== undefined && !Value.Check(tool.parameters, args)) {
|
|
739
|
+
return failure(`The arguments for "${call.name}" did not match its schema.`);
|
|
740
|
+
}
|
|
741
|
+
try {
|
|
742
|
+
const result = await tool.execute(ctx, args);
|
|
743
|
+
if (!Value.Check(tool.returnType, result)) {
|
|
744
|
+
return failure(`Tool "${call.name}" returned an invalid result.`);
|
|
745
|
+
}
|
|
746
|
+
const content = tool.toLLM(result);
|
|
747
|
+
const isError = tool.isError?.(result) === true;
|
|
748
|
+
return {
|
|
749
|
+
role: "tool",
|
|
750
|
+
callId: call.callId,
|
|
751
|
+
content: [...content],
|
|
752
|
+
...(isError ? { isError: true } : {}),
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
catch (error) {
|
|
756
|
+
return failure(error instanceof Error ? error.message : String(error));
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
/** Sorted after every earlier key of its queue, within this process and across restarts. */
|
|
760
|
+
#queueKey(prefix) {
|
|
761
|
+
const time = String(Date.now()).padStart(14, "0");
|
|
762
|
+
const sequence = String(this.#pendingSequence++).padStart(6, "0");
|
|
763
|
+
return `${prefix}${time}.${sequence}`;
|
|
764
|
+
}
|
|
765
|
+
async #collect(stream, abortPromise) {
|
|
766
|
+
const content = [];
|
|
767
|
+
// Blocks that finished and were durably appended. An abort keeps exactly these, so the
|
|
768
|
+
// in-memory assistant message never diverges from what a reload would rebuild.
|
|
769
|
+
const persisted = [];
|
|
770
|
+
const toolCallIndexes = new Map();
|
|
771
|
+
const persist = async (block) => {
|
|
772
|
+
if (block === undefined)
|
|
773
|
+
return;
|
|
774
|
+
await this.#persistenceLock.runInLock(this.#ctx, (lockCtx) => this.#persistence.append(lockCtx, { type: "block", block }));
|
|
775
|
+
persisted.push(block);
|
|
776
|
+
};
|
|
777
|
+
const iterator = stream[Symbol.asyncIterator]();
|
|
778
|
+
while (true) {
|
|
779
|
+
const next = await Promise.race([iterator.next(), abortPromise]);
|
|
780
|
+
if (next === ABORTED) {
|
|
781
|
+
// Close the provider stream, drop the unfinished block, and end the turn.
|
|
782
|
+
void Promise.resolve(iterator.return?.()).catch(() => undefined);
|
|
783
|
+
this.#emit({ type: "done", state: "cancelled" });
|
|
784
|
+
return { content: persisted, state: "cancelled" };
|
|
785
|
+
}
|
|
786
|
+
if (next.done === true)
|
|
787
|
+
break;
|
|
788
|
+
const event = next.value;
|
|
789
|
+
this.#emit(event);
|
|
790
|
+
switch (event.type) {
|
|
791
|
+
case "text_start":
|
|
792
|
+
content.push({ type: "text", text: "" });
|
|
793
|
+
break;
|
|
794
|
+
case "text_delta": {
|
|
795
|
+
const last = content[content.length - 1];
|
|
796
|
+
if (last?.type === "text") {
|
|
797
|
+
content[content.length - 1] = {
|
|
798
|
+
type: "text",
|
|
799
|
+
text: last.text + event.delta,
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
break;
|
|
803
|
+
}
|
|
804
|
+
case "text_end": {
|
|
805
|
+
const last = content[content.length - 1];
|
|
806
|
+
await persist(last?.type === "text" ? last : undefined);
|
|
807
|
+
break;
|
|
808
|
+
}
|
|
809
|
+
case "reasoning_start":
|
|
810
|
+
content.push({ type: "reasoning", text: "" });
|
|
811
|
+
break;
|
|
812
|
+
case "reasoning_delta": {
|
|
813
|
+
const last = content[content.length - 1];
|
|
814
|
+
if (last?.type === "reasoning") {
|
|
815
|
+
content[content.length - 1] = {
|
|
816
|
+
...last,
|
|
817
|
+
text: (last.text ?? "") + event.delta,
|
|
818
|
+
};
|
|
819
|
+
}
|
|
820
|
+
break;
|
|
821
|
+
}
|
|
822
|
+
case "reasoning_end": {
|
|
823
|
+
const last = content[content.length - 1];
|
|
824
|
+
if (last?.type === "reasoning") {
|
|
825
|
+
const finished = {
|
|
826
|
+
...last,
|
|
827
|
+
...(event.reasoning === undefined
|
|
828
|
+
? {}
|
|
829
|
+
: { reasoning: event.reasoning }),
|
|
830
|
+
};
|
|
831
|
+
content[content.length - 1] = finished;
|
|
832
|
+
await persist(finished);
|
|
833
|
+
}
|
|
834
|
+
break;
|
|
835
|
+
}
|
|
836
|
+
case "toolcall_start":
|
|
837
|
+
toolCallIndexes.set(event.callId, content.length);
|
|
838
|
+
content.push({
|
|
839
|
+
type: "tool_call",
|
|
840
|
+
callId: event.callId,
|
|
841
|
+
name: event.name,
|
|
842
|
+
arguments: "",
|
|
843
|
+
...(event.namespace === undefined ? {} : { namespace: event.namespace }),
|
|
844
|
+
...(event.server === undefined ? {} : { server: event.server }),
|
|
845
|
+
...(event.vendor === undefined ? {} : { vendor: event.vendor }),
|
|
846
|
+
});
|
|
847
|
+
break;
|
|
848
|
+
case "toolcall_end": {
|
|
849
|
+
const index = toolCallIndexes.get(event.callId);
|
|
850
|
+
const block = index === undefined ? undefined : content[index];
|
|
851
|
+
if (index !== undefined && block?.type === "tool_call") {
|
|
852
|
+
const finished = {
|
|
853
|
+
...block,
|
|
854
|
+
arguments: event.arguments,
|
|
855
|
+
...(event.incomplete === undefined
|
|
856
|
+
? {}
|
|
857
|
+
: { incomplete: event.incomplete }),
|
|
858
|
+
};
|
|
859
|
+
content[index] = finished;
|
|
860
|
+
await persist(finished);
|
|
861
|
+
}
|
|
862
|
+
break;
|
|
863
|
+
}
|
|
864
|
+
// The provider settled a server tool call on its own backend and streams the
|
|
865
|
+
// result here. The agent simply ignores it: nothing to execute, nothing to
|
|
866
|
+
// store — the events still reach the hooks like every other event.
|
|
867
|
+
case "toolcall_result_start":
|
|
868
|
+
case "toolcall_result_delta":
|
|
869
|
+
case "toolcall_result_end":
|
|
870
|
+
break;
|
|
871
|
+
case "done":
|
|
872
|
+
return {
|
|
873
|
+
content,
|
|
874
|
+
state: event.state,
|
|
875
|
+
...(event.state === "error" ? { errorMessage: event.message } : {}),
|
|
876
|
+
};
|
|
877
|
+
default:
|
|
878
|
+
break;
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
return { content, state: undefined };
|
|
882
|
+
}
|
|
883
|
+
/**
|
|
884
|
+
* Create the provider session on first use, resolving the provider from the registry by its
|
|
885
|
+
* serializable ID at that moment; an unregistered ID fails the turn like any thrown error.
|
|
886
|
+
*/
|
|
887
|
+
async #ensureSession() {
|
|
888
|
+
if (this.#session === undefined) {
|
|
889
|
+
const provider = this.#providers.get(this.#providerId);
|
|
890
|
+
if (provider === null) {
|
|
891
|
+
throw new Error(`Provider "${this.#providerId}" is not registered.`);
|
|
892
|
+
}
|
|
893
|
+
this.#session = await provider.session(this.id, {
|
|
894
|
+
instructions: this.#instructions(),
|
|
895
|
+
tools: [...this.#tools()],
|
|
896
|
+
});
|
|
897
|
+
}
|
|
898
|
+
return this.#session;
|
|
899
|
+
}
|
|
900
|
+
#emit(event) {
|
|
901
|
+
try {
|
|
902
|
+
this.#hooks.onEvent?.(this.#ctx, event);
|
|
903
|
+
}
|
|
904
|
+
catch {
|
|
905
|
+
// Hooks observe the stream; they never fail a run.
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
//# sourceMappingURL=AgentBase.js.map
|