@slopus/happy-agent-base 0.0.0 → 0.0.2
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 +640 -15
- package/dist/Agent.d.ts +86 -0
- package/dist/Agent.d.ts.map +1 -0
- package/dist/Agent.js +326 -0
- package/dist/Agent.js.map +1 -0
- package/dist/AgentBase.d.ts +340 -0
- package/dist/AgentBase.d.ts.map +1 -0
- package/dist/AgentBase.js +2295 -0
- package/dist/AgentBase.js.map +1 -0
- package/dist/AgentBaseHooks.d.ts +156 -0
- package/dist/AgentBaseHooks.d.ts.map +1 -0
- package/dist/AgentBaseHooks.js +2 -0
- package/dist/AgentBaseHooks.js.map +1 -0
- package/dist/AgentBasePending.d.ts +49 -0
- package/dist/AgentBasePending.d.ts.map +1 -0
- package/dist/AgentBasePending.js +73 -0
- package/dist/AgentBasePending.js.map +1 -0
- package/dist/AgentBaseState.d.ts +13 -0
- package/dist/AgentBaseState.d.ts.map +1 -0
- package/dist/AgentBaseState.js +2 -0
- package/dist/AgentBaseState.js.map +1 -0
- package/dist/AgentBaseStoreLock.d.ts +16 -0
- package/dist/AgentBaseStoreLock.d.ts.map +1 -0
- package/dist/AgentBaseStoreLock.js +37 -0
- package/dist/AgentBaseStoreLock.js.map +1 -0
- package/dist/AgentConfig.d.ts +67 -0
- package/dist/AgentConfig.d.ts.map +1 -0
- package/dist/AgentConfig.js +81 -0
- package/dist/AgentConfig.js.map +1 -0
- package/dist/AgentContexts.d.ts +40 -0
- package/dist/AgentContexts.d.ts.map +1 -0
- package/dist/AgentContexts.js +70 -0
- package/dist/AgentContexts.js.map +1 -0
- package/dist/AgentFeature.d.ts +119 -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/AgentKV.d.ts +63 -0
- package/dist/AgentKV.d.ts.map +1 -0
- package/dist/AgentKV.js +122 -0
- package/dist/AgentKV.js.map +1 -0
- package/dist/AgentModel.d.ts +17 -0
- package/dist/AgentModel.d.ts.map +1 -0
- package/dist/AgentModel.js +2 -0
- package/dist/AgentModel.js.map +1 -0
- package/dist/AgentPersistence.d.ts +95 -0
- package/dist/AgentPersistence.d.ts.map +1 -0
- package/dist/AgentPersistence.js +2 -0
- package/dist/AgentPersistence.js.map +1 -0
- package/dist/AgentProviders.d.ts +22 -0
- package/dist/AgentProviders.d.ts.map +1 -0
- package/dist/AgentProviders.js +35 -0
- package/dist/AgentProviders.js.map +1 -0
- package/dist/AgentRef.d.ts +41 -0
- package/dist/AgentRef.d.ts.map +1 -0
- package/dist/AgentRef.js +60 -0
- package/dist/AgentRef.js.map +1 -0
- package/dist/AgentStorage.d.ts +19 -0
- package/dist/AgentStorage.d.ts.map +1 -0
- package/dist/AgentStorage.js +16 -0
- package/dist/AgentStorage.js.map +1 -0
- package/dist/AgentSystem.d.ts +50 -0
- package/dist/AgentSystem.d.ts.map +1 -0
- package/dist/AgentSystem.js +2 -0
- package/dist/AgentSystem.js.map +1 -0
- package/dist/AgentSystemContext.d.ts +15 -0
- package/dist/AgentSystemContext.d.ts.map +1 -0
- package/dist/AgentSystemContext.js +20 -0
- package/dist/AgentSystemContext.js.map +1 -0
- package/dist/AgentSystemLocal.d.ts +103 -0
- package/dist/AgentSystemLocal.d.ts.map +1 -0
- package/dist/AgentSystemLocal.js +282 -0
- package/dist/AgentSystemLocal.js.map +1 -0
- package/dist/AgentSystemRef.d.ts +56 -0
- package/dist/AgentSystemRef.d.ts.map +1 -0
- package/dist/AgentSystemRef.js +75 -0
- package/dist/AgentSystemRef.js.map +1 -0
- package/dist/AgentTaskContext.d.ts +17 -0
- package/dist/AgentTaskContext.d.ts.map +1 -0
- package/dist/AgentTaskContext.js +26 -0
- package/dist/AgentTaskContext.js.map +1 -0
- package/dist/AgentTool.d.ts +87 -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 +24 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +31 -1
- package/dist/index.js.map +1 -1
- package/dist/models.d.ts +62 -0
- package/dist/models.d.ts.map +1 -0
- package/dist/models.js +185 -0
- package/dist/models.js.map +1 -0
- package/package.json +44 -37
|
@@ -0,0 +1,2295 @@
|
|
|
1
|
+
import { areProviderModelsCompatible } from "@slopus/happy-providers";
|
|
2
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { Value } from "@sinclair/typebox/value";
|
|
5
|
+
import { asyncLock, createContextNamespace, deterministicStringify, withLifetime, } from "@steve.kite/stdlib";
|
|
6
|
+
import { withAgentContext, withAgentKV, withAgentRunKV } from "./AgentContexts.js";
|
|
7
|
+
import { taskContextBeforeToolCall, withAgentTaskContext } from "./AgentTaskContext.js";
|
|
8
|
+
import { AgentKV } from "./AgentKV.js";
|
|
9
|
+
import { AGENT_BASE_PENDING_KEY, agentBasePendingStateOf, } from "./AgentBasePending.js";
|
|
10
|
+
import { agentBaseStoreLock, agentBaseWithStoreStill } from "./AgentBaseStoreLock.js";
|
|
11
|
+
import { AgentProviders } from "./AgentProviders.js";
|
|
12
|
+
/** Race winner when an abort interrupts a wait on the stream or a running tool. */
|
|
13
|
+
const ABORTED = Symbol("aborted");
|
|
14
|
+
/**
|
|
15
|
+
* The agents whose run loop the current execution is running inside. Hooks and tool executions
|
|
16
|
+
* receive a context carrying this, so an operation that would wait for the very loop it is part
|
|
17
|
+
* of can say so instead of hanging for ever.
|
|
18
|
+
*/
|
|
19
|
+
const insideTurn = createContextNamespace("agentInsideTurn", []);
|
|
20
|
+
/**
|
|
21
|
+
* The same fact as `insideTurn`, tracked by the runtime rather than carried by a context. Not
|
|
22
|
+
* every operation takes one — `close` is the whole agent's lifetime and has no call to carry it —
|
|
23
|
+
* and a context can always be one the caller kept from somewhere else, so the run loop marks its
|
|
24
|
+
* own execution too. Everything the loop awaits, however deep, is inside this scope; another
|
|
25
|
+
* agent's loop replaces the scope rather than extending it, because that loop is a lifetime of
|
|
26
|
+
* its own and outlives whatever happened to start it.
|
|
27
|
+
*/
|
|
28
|
+
const insideLoops = new AsyncLocalStorage();
|
|
29
|
+
/**
|
|
30
|
+
* How long a close asked for from inside the agent's own run loop waits for the shutdown before
|
|
31
|
+
* telling its caller it cannot be waited for. Long enough that a caller which has already let go
|
|
32
|
+
* hears the shutdown finish, short enough that one still holding the loop is told promptly.
|
|
33
|
+
*/
|
|
34
|
+
const INSIDE_CLOSE_REPORT_MS = 15;
|
|
35
|
+
/** Rolls a consumption back when every entry in its batch was already taken by another owner. */
|
|
36
|
+
const LOST_QUEUE_RACE = Symbol("lostQueueRace");
|
|
37
|
+
/**
|
|
38
|
+
* A single agent session over one provider. Messages arrive through two FIFO queues: steering
|
|
39
|
+
* messages inject as soon as the current assistant response and its tool batch finish, while
|
|
40
|
+
* sent messages wait until the agent would otherwise stop — no tool calls or steering remain.
|
|
41
|
+
* Each queue drains per its configured mode, and the conversation is durable through
|
|
42
|
+
* append-only persistence reloaded at the start of every turn.
|
|
43
|
+
*
|
|
44
|
+
* The rest of this comment is the list of promises the implementation has to keep. They are
|
|
45
|
+
* written down because most of them are invisible in ordinary use and only show themselves when
|
|
46
|
+
* a process dies, two owners share a store, or a caller races the loop — every one of them was
|
|
47
|
+
* bought with a bug found by `tests/chaos/`, and each has a focused test that fails without it.
|
|
48
|
+
*
|
|
49
|
+
* ## Serialization
|
|
50
|
+
*
|
|
51
|
+
* One lock serializes every persistence operation together with its in-memory effect, so storage
|
|
52
|
+
* order always matches history order and a load never overlaps an append. Anything that decides
|
|
53
|
+
* from durable state resolves that state inside the lock rather than capturing it beforehand; a
|
|
54
|
+
* reference taken before a wait can belong to a history that has since been replaced.
|
|
55
|
+
*
|
|
56
|
+
* ## Accepting a message
|
|
57
|
+
*
|
|
58
|
+
* A message is accepted exactly once, or not at all. Its durable write and the writes of every
|
|
59
|
+
* other message in the same batch commit in one transaction under one hold of the lock. So:
|
|
60
|
+
*
|
|
61
|
+
* - `steer` and `send` with `await: true` resolve only once the message is durable; a failed
|
|
62
|
+
* write keeps it out of the conversation entirely. Without the flag they return early, but the
|
|
63
|
+
* acceptance is the same one, and a close still waits for it.
|
|
64
|
+
* - Messages a hook returns from one decision are accepted as one batch. A caller arriving while
|
|
65
|
+
* that batch is being written lands after all of it, never between two halves of one thought.
|
|
66
|
+
* - Queue keys order by what the store already holds and end in a segment identifying their
|
|
67
|
+
* writer, so two owners accepting in the same millisecond may order arbitrarily but can never
|
|
68
|
+
* overwrite one another.
|
|
69
|
+
* - An agent holding a durable message never describes itself as settled.
|
|
70
|
+
*
|
|
71
|
+
* ## Consuming a message
|
|
72
|
+
*
|
|
73
|
+
* A consumption claims each entry with an atomic delete inside its own transaction, so one
|
|
74
|
+
* durable message is answered exactly once however many live owners hold it in memory. A batch
|
|
75
|
+
* that claims nothing rolls back having changed nothing. A message is never durable in both the
|
|
76
|
+
* queue and the context, or in neither, and memory changes only after the commit.
|
|
77
|
+
*
|
|
78
|
+
* ## Turns
|
|
79
|
+
*
|
|
80
|
+
* A turn answers the durable conversation, not the one this instance remembers: it reloads
|
|
81
|
+
* before it decides anything, so an appended message or a model switch from another owner is in
|
|
82
|
+
* force by the next turn. A turn that consumes the last queued work clears the request it just
|
|
83
|
+
* answered, rather than buying an extra turn with an empty queue and a full set of hooks.
|
|
84
|
+
*
|
|
85
|
+
* ## Tool calls
|
|
86
|
+
*
|
|
87
|
+
* A batch runs its calls at the same time, each in its own persistence scope, and commits their
|
|
88
|
+
* results in batch order. Results are matched back by call ID, so a response that used one ID
|
|
89
|
+
* twice has no answer the model could tell apart: that ID is kept once and refused before
|
|
90
|
+
* anything runs, since a refusal after the fact would not undo the side effect.
|
|
91
|
+
*
|
|
92
|
+
* The conversation never keeps a tool call the model will not get an answer for:
|
|
93
|
+
*
|
|
94
|
+
* - A batch is committed before any call in it runs, so a batch found uncommitted after a crash
|
|
95
|
+
* has certainly not run and is dispatched as the fresh batch it never became.
|
|
96
|
+
* - A response that emits a call but does not end in one, and a turn that fails while owing
|
|
97
|
+
* results, settle their own calls with error results before appending anything behind them.
|
|
98
|
+
* - A conversation loaded with a call stranded under later messages is repaired atomically at
|
|
99
|
+
* load, since the answer belongs beside its call rather than at the end.
|
|
100
|
+
* - A non-durable tool never runs twice; a durable one may.
|
|
101
|
+
*
|
|
102
|
+
* ## Compaction
|
|
103
|
+
*
|
|
104
|
+
* A compaction runs before a turn's first inference, so the model always receives a settled
|
|
105
|
+
* conversation. It replaces the history whole or not at all, and the suffix it preserves is
|
|
106
|
+
* rebuilt from the store inside the commit — from a record count taken at the snapshot, so work
|
|
107
|
+
* another owner committed while the provider was summarizing survives. A compaction nobody will
|
|
108
|
+
* carry out is rejected rather than left waiting.
|
|
109
|
+
*
|
|
110
|
+
* ## Model changes
|
|
111
|
+
*
|
|
112
|
+
* An incompatible provider or model change resets the conversation; a compatible one keeps it.
|
|
113
|
+
* Either way the change lands on one side or the other, never the old history under the new
|
|
114
|
+
* model. `modelChanged` runs inside the lock and is lent a store bound to that hold — a
|
|
115
|
+
* capability released when the hook returns, so it cannot be retained to bypass the lock later.
|
|
116
|
+
* A failing handoff rejects an incompatible switch outright rather than costing the history.
|
|
117
|
+
*
|
|
118
|
+
* ## Recovery
|
|
119
|
+
*
|
|
120
|
+
* Whether a restart owes a response is decided by the last durable record: a consumed message, a
|
|
121
|
+
* tool result, or a failure note is owed an answer, while a replacement written by a compaction
|
|
122
|
+
* is not a question and gets none.
|
|
123
|
+
*
|
|
124
|
+
* ## Abort
|
|
125
|
+
*
|
|
126
|
+
* An abort owns the whole turn. Its scope opens before any of the turn's work — its hooks and its
|
|
127
|
+
* loading as much as its inference — so a turn cancelled while it is still starting up never
|
|
128
|
+
* reaches the model at all, rather than being cancelled only once it was already talking.
|
|
129
|
+
*
|
|
130
|
+
* What a cancelled turn leaves behind is fixed:
|
|
131
|
+
*
|
|
132
|
+
* - Assistant blocks that finished stay in the history; a block still being streamed is dropped,
|
|
133
|
+
* because half a block is not something the model said.
|
|
134
|
+
* - Tool calls still running are settled in the conversation as aborted error results, so the
|
|
135
|
+
* history owes nothing, and the turn ends without waiting for the tools themselves. A tool that
|
|
136
|
+
* never notices cancellation therefore cannot hold the cancellation open — but it is still
|
|
137
|
+
* running, so the *next* provider request waits for it before reusing the stateful session.
|
|
138
|
+
* - Messages already queued stay durable and join the next requested turn. An abort cancels the
|
|
139
|
+
* turn, not the work waiting for one.
|
|
140
|
+
* - A compaction requested during the turn is rejected rather than left pending, because dropping
|
|
141
|
+
* the turn request drops the only thing that would have carried it out.
|
|
142
|
+
* - Exactly one terminal event is reported. A cancellation seen after a response already reported
|
|
143
|
+
* its own outcome adds nothing, since that response is over.
|
|
144
|
+
*
|
|
145
|
+
* `abort` signals and returns, because the cancellation is complete once it is signalled.
|
|
146
|
+
* `abort(ctx, { await: true })` additionally waits for the loop to stop, which is what an owner
|
|
147
|
+
* outside the agent usually wants and what code inside it must not ask for — see below.
|
|
148
|
+
*
|
|
149
|
+
* ## Close
|
|
150
|
+
*
|
|
151
|
+
* Close is a barrier, published before any of the shutdown runs. Nothing new is admitted from
|
|
152
|
+
* the moment it is called; everything already admitted is written and answered, and only then is
|
|
153
|
+
* the provider session destroyed. Every caller shares that one shutdown, including one
|
|
154
|
+
* reentering from inside session destruction, so a session is never destroyed twice.
|
|
155
|
+
*
|
|
156
|
+
* ## Hooks
|
|
157
|
+
*
|
|
158
|
+
* Hooks observe the run and never fail it. Neither a throwing hook nor a failing hook-driven
|
|
159
|
+
* action ends a turn.
|
|
160
|
+
*
|
|
161
|
+
* ## Re-entrancy
|
|
162
|
+
*
|
|
163
|
+
* A hook or a tool runs while the loop is waiting for it, so anything it asks of its own agent
|
|
164
|
+
* that only the loop can deliver would wait for itself. Rather than leave that to be remembered
|
|
165
|
+
* per operation, asking and waiting are separated everywhere:
|
|
166
|
+
*
|
|
167
|
+
* - `steer`, `send`, `abort` and `compact` are safe from anywhere in their asking form, which is
|
|
168
|
+
* the default. Each registers what it registers and returns; the loop acts on it afterwards.
|
|
169
|
+
* - `await: true` asks for the part only the loop can give. Contexts handed to hooks and tools
|
|
170
|
+
* record which agents' loops the execution is inside, so the flag is refused with an error that
|
|
171
|
+
* names the problem instead of hanging. The check is per agent: work inside one agent's loop
|
|
172
|
+
* may still wait on another's, which is what makes a subagent's report to its parent safe.
|
|
173
|
+
* - The refusal is uniform even where a particular wait would have happened to work. A tool that
|
|
174
|
+
* waits for its own abort, for instance, does unwind — the batch races each execution against
|
|
175
|
+
* cancellation — but that is a property of tool batches rather than of abort, and a rule that
|
|
176
|
+
* holds only in one position is worse than no rule.
|
|
177
|
+
* - A hook that wants a compaction has a better option than requesting one: the
|
|
178
|
+
* `{ type: "compact" }` action it returns lands exactly where the loop can act on it, in order
|
|
179
|
+
* with the rest of that decision.
|
|
180
|
+
* - `close` and `waitForIdle` take no context, so they cannot be checked and will simply hang.
|
|
181
|
+
* Both are nothing but a wait for the run to finish. Close the agent from the caller that owns
|
|
182
|
+
* its lifetime.
|
|
183
|
+
*
|
|
184
|
+
* `AgentRef` and `AgentSystemRef` drop the two unguarded waits, and are what code running inside
|
|
185
|
+
* an agent should be handed.
|
|
186
|
+
*/
|
|
187
|
+
export class AgentBase {
|
|
188
|
+
/** The caller-supplied session identity: the name of this agent's store and of its loop. */
|
|
189
|
+
id;
|
|
190
|
+
/**
|
|
191
|
+
* The agent's own copy of the initial state, mutable directly; every inference reads the
|
|
192
|
+
* current values.
|
|
193
|
+
*/
|
|
194
|
+
state;
|
|
195
|
+
/**
|
|
196
|
+
* The agent's own lifetime, without the selection on it. Every context the agent derives
|
|
197
|
+
* starts here, so a change of provider or model rebuilds one context from a known base
|
|
198
|
+
* rather than layering another value onto whatever the last one happened to carry.
|
|
199
|
+
*/
|
|
200
|
+
#baseCtx;
|
|
201
|
+
/** The base context extended with the effective selection and the agent's key-value store. */
|
|
202
|
+
#ctx;
|
|
203
|
+
/** The registry the provider ID is resolved through, each time a session is created. */
|
|
204
|
+
#providers;
|
|
205
|
+
/** The registry ID of the provider in force; durable, so a restart resumes on the same one. */
|
|
206
|
+
#providerId;
|
|
207
|
+
/** The append-only store behind the conversation, the queues and the persisted settings. */
|
|
208
|
+
#persistence;
|
|
209
|
+
/** The model in force. Changing it to an incompatible one resets the conversation. */
|
|
210
|
+
#model;
|
|
211
|
+
/** The reasoning effort in force. */
|
|
212
|
+
#effort;
|
|
213
|
+
/** The service tier in force. */
|
|
214
|
+
#serviceTier;
|
|
215
|
+
/** The single set of hooks the run is observed by and its configuration extended from. */
|
|
216
|
+
#hooks;
|
|
217
|
+
/**
|
|
218
|
+
* Serializes every persistence operation together with its in-memory effect, so storage
|
|
219
|
+
* order always matches history order and a load never overlaps an append. The lock belongs
|
|
220
|
+
* to the store, so an owner inspecting the same store sees only whole steps.
|
|
221
|
+
*/
|
|
222
|
+
#persistenceLock;
|
|
223
|
+
/** The session-scoped key-value store carried on every context the agent derives. */
|
|
224
|
+
#kv;
|
|
225
|
+
/**
|
|
226
|
+
* The store belonging to the run rather than to the conversation, erased in the transaction
|
|
227
|
+
* that settles the agent. What a run concludes about itself is worth nothing to the next one,
|
|
228
|
+
* and leaving it behind would let a restarted agent act on a decision made about work that is
|
|
229
|
+
* already over.
|
|
230
|
+
*/
|
|
231
|
+
#runKV;
|
|
232
|
+
/** Whether steering drains one message per response or all of them at once. */
|
|
233
|
+
#steeringMode;
|
|
234
|
+
/** Whether sends drain one message per response or all of them at once. */
|
|
235
|
+
#sendMode;
|
|
236
|
+
/** The provider session requests run on, created on first use and stateful thereafter. */
|
|
237
|
+
#session;
|
|
238
|
+
/** The provider-facing configuration the current session was created with. */
|
|
239
|
+
#sessionConfig;
|
|
240
|
+
/** The conversation as this instance last knew it, reloaded from the store every turn. */
|
|
241
|
+
#messages = [];
|
|
242
|
+
/** The durable steering queue, in the order its keys sort. */
|
|
243
|
+
#steering = [];
|
|
244
|
+
/** The durable send queue, in the order its keys sort. */
|
|
245
|
+
#sends = [];
|
|
246
|
+
/** A dispatched tool batch whose results have not all landed yet, and so has to be resumed. */
|
|
247
|
+
#pendingTools = [];
|
|
248
|
+
/**
|
|
249
|
+
* True when the pending tools were reconstructed from an unanswered trailing tool call
|
|
250
|
+
* rather than read from the durable batch, so the batch still has to be committed before
|
|
251
|
+
* anything runs.
|
|
252
|
+
*/
|
|
253
|
+
#pendingToolsUndispatched = false;
|
|
254
|
+
/** How many tool batches are running, so a caller can tell a waiting turn from a busy one. */
|
|
255
|
+
#toolsRunning = 0;
|
|
256
|
+
/** The in-flight or finished load of the durable state; cleared at the start of every turn. */
|
|
257
|
+
#loaded;
|
|
258
|
+
/**
|
|
259
|
+
* Identifies this instance's writes, so no other writer can produce one of its keys. It is a
|
|
260
|
+
* UUID rather than a number drawn from the general-purpose generator, because two owners of
|
|
261
|
+
* one store acknowledging a message each are relying on it to keep their keys apart, and a
|
|
262
|
+
* generator that can be seeded — or replaced — would let both of them claim the same one.
|
|
263
|
+
*/
|
|
264
|
+
#writer = randomUUID();
|
|
265
|
+
/**
|
|
266
|
+
* The kind of the last durable record, which says what the conversation is waiting for far
|
|
267
|
+
* more precisely than the message it ends on: a consumed message, a tool result or the note
|
|
268
|
+
* a failed turn leaves behind is owed a response, while a replacement written by a
|
|
269
|
+
* compaction is owed nothing at all.
|
|
270
|
+
*/
|
|
271
|
+
#lastRecordType;
|
|
272
|
+
/**
|
|
273
|
+
* Whether that last record was a replacement that ended on a message still owed an answer.
|
|
274
|
+
* Only the rewrite that wrote it can tell a summary's own last message from a suffix it kept.
|
|
275
|
+
*/
|
|
276
|
+
#lastRecordContinuesInference = false;
|
|
277
|
+
/**
|
|
278
|
+
* How many durable records the in-memory conversation accounts for: the ones it was loaded
|
|
279
|
+
* from, plus every one this instance has appended since. A rewrite replaces exactly those.
|
|
280
|
+
* Counting the store afresh would treat records this instance has never seen as already
|
|
281
|
+
* summarized and erase them; forgetting its own appends would carry records the summary
|
|
282
|
+
* already covers into the replacement a second time.
|
|
283
|
+
*/
|
|
284
|
+
#loadedRecordCount = 0;
|
|
285
|
+
/**
|
|
286
|
+
* Whether this instance has checked whether a cut-off run should resume inference. The
|
|
287
|
+
* question is only meaningful once, against the state the agent first loaded: afterwards a
|
|
288
|
+
* trailing user message is ordinary, since a response may legitimately have no blocks.
|
|
289
|
+
*/
|
|
290
|
+
#recoveryChecked = false;
|
|
291
|
+
/**
|
|
292
|
+
* The outstanding work this agent has recorded, held in memory exactly as the store holds
|
|
293
|
+
* it. Its presence is the whole of the active flag, so the one thing anyone outside can ask
|
|
294
|
+
* about the agent is answered from here without touching the disk.
|
|
295
|
+
*/
|
|
296
|
+
#pending;
|
|
297
|
+
/**
|
|
298
|
+
* The pending state this instance last wrote, so a write that would change nothing is
|
|
299
|
+
* skipped. The loop passes through the same stage many times in a turn, and a store is not
|
|
300
|
+
* worth touching to tell it what it already says.
|
|
301
|
+
*/
|
|
302
|
+
#pendingWritten;
|
|
303
|
+
/**
|
|
304
|
+
* The outstanding work this agent's store already held when this instance first wrote to it:
|
|
305
|
+
* what a process that died mid-run left behind, or nothing when the last run settled
|
|
306
|
+
* cleanly. It is what recovery decides from, in place of guessing from the transcript's
|
|
307
|
+
* shape.
|
|
308
|
+
*/
|
|
309
|
+
#inherited;
|
|
310
|
+
/** Whether that inherited record has been read; it can only be read before it is overwritten. */
|
|
311
|
+
#inheritedRead = false;
|
|
312
|
+
/**
|
|
313
|
+
* The compaction that has been asked for and not carried out yet, together with the promise
|
|
314
|
+
* every caller waiting for it shares. Requesting one while it is pending joins that promise
|
|
315
|
+
* rather than queueing a second compaction.
|
|
316
|
+
*/
|
|
317
|
+
#compaction;
|
|
318
|
+
/** The scope the current stretch of work is cancelled on, which an abort signals. */
|
|
319
|
+
#abortController;
|
|
320
|
+
/**
|
|
321
|
+
* Raised when close begins. Only the tool batch listens: a running tool may be waiting for
|
|
322
|
+
* the close itself, so the shutdown stops waiting for tools while still finishing the
|
|
323
|
+
* inference stream and everything already accepted.
|
|
324
|
+
*/
|
|
325
|
+
#closeController = new AbortController();
|
|
326
|
+
/**
|
|
327
|
+
* The true size of the conversation in tokens, as the provider last measured it. Durable,
|
|
328
|
+
* so a restart keeps knowing how large the conversation is, and cleared whenever the
|
|
329
|
+
* conversation is replaced.
|
|
330
|
+
*/
|
|
331
|
+
#contextTokens;
|
|
332
|
+
/** Whether the current turn was cancelled before it could finish. */
|
|
333
|
+
#turnAborted = false;
|
|
334
|
+
/** Whether something has asked for a turn that has not been answered yet. */
|
|
335
|
+
#turnRequested = false;
|
|
336
|
+
/** The run loop while it is running; the field is cleared once it has actually stopped. */
|
|
337
|
+
#runPromise;
|
|
338
|
+
/** The barrier: true from the moment close is called, and nothing new is admitted after it. */
|
|
339
|
+
#closed = false;
|
|
340
|
+
/** The one shutdown every closing caller shares, so a session is never destroyed twice. */
|
|
341
|
+
#closing;
|
|
342
|
+
/** Operations accepted from a caller and not finished yet; a close waits for every one. */
|
|
343
|
+
#admitted = new Set();
|
|
344
|
+
/**
|
|
345
|
+
* Work from an earlier response that is still unwinding: a provider stream that has not
|
|
346
|
+
* finished closing, or a tool that was settled in the conversation by an abort and is still
|
|
347
|
+
* running. The next request waits for it, because the session is stateful and its previous
|
|
348
|
+
* user has not let go. An abort does not wait for it: a stream or tool that ignores being
|
|
349
|
+
* cancelled must never be able to hold the cancellation open.
|
|
350
|
+
*/
|
|
351
|
+
#settling = new Set();
|
|
352
|
+
/**
|
|
353
|
+
* The part of that unwinding a close has to wait for: a response iterator still letting go
|
|
354
|
+
* of the provider session. Destroying the session underneath it would hand the provider two
|
|
355
|
+
* owners of one session at once.
|
|
356
|
+
*/
|
|
357
|
+
#streamCleanup = new Set();
|
|
358
|
+
/**
|
|
359
|
+
* A new agent, wired to its options and touching no storage at all. Use this for an identity
|
|
360
|
+
* with no durable state yet; whatever the agent needs from the store is read by its first
|
|
361
|
+
* turn.
|
|
362
|
+
*/
|
|
363
|
+
static create(ctx, options) {
|
|
364
|
+
return Promise.resolve(new AgentBase(ctx, options));
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* An agent for an identity that may already have durable state, with the one externally
|
|
368
|
+
* meaningful fact about that state — whether it has work left — read before it is handed
|
|
369
|
+
* back, so `active` is answerable straight away.
|
|
370
|
+
*
|
|
371
|
+
* Only the flag is read. The conversation, the queues and the settings are deliberately not:
|
|
372
|
+
* they are needed by the first turn and by nothing before it, so an owner resuming a hundred
|
|
373
|
+
* identities at startup pays for a hundred small reads rather than a hundred transcripts.
|
|
374
|
+
* The rest loads on the way into the turn that actually needs it.
|
|
375
|
+
*/
|
|
376
|
+
static async load(ctx, options) {
|
|
377
|
+
const agent = new AgentBase(ctx, options);
|
|
378
|
+
await agent.#loadPendingState();
|
|
379
|
+
return agent;
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Load the agent and set it going again if it has work left, or answer with nothing when it
|
|
383
|
+
* has none. This is how an owner coming up carries on whatever an earlier process was in the
|
|
384
|
+
* middle of: the whole question is one key, and an agent told to go picks its own work back
|
|
385
|
+
* up, so the caller has only to bring it into existence.
|
|
386
|
+
*
|
|
387
|
+
* An agent owing nothing is not handed back, because there is nothing to do with it that
|
|
388
|
+
* resolving it when something is actually wanted of it would not do better.
|
|
389
|
+
*/
|
|
390
|
+
static async loadActive(ctx, options) {
|
|
391
|
+
const agent = await AgentBase.load(ctx, options);
|
|
392
|
+
if (!agent.active)
|
|
393
|
+
return undefined;
|
|
394
|
+
agent.start();
|
|
395
|
+
return agent;
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* Read the outstanding work the store already holds, before this instance has written any of
|
|
399
|
+
* its own. It is both what `active` answers from and what an interrupted run is recognized
|
|
400
|
+
* by, so reading it here leaves the later stage writes nothing to learn from the store.
|
|
401
|
+
*/
|
|
402
|
+
async #loadPendingState() {
|
|
403
|
+
await this.#persistenceLock.runInLock(this.#ctx, async (lockCtx) => {
|
|
404
|
+
const stored = await agentBasePendingStateOf(lockCtx, this.#persistence);
|
|
405
|
+
this.#inherited = stored;
|
|
406
|
+
this.#inheritedRead = true;
|
|
407
|
+
this.#pending = stored;
|
|
408
|
+
this.#pendingWritten =
|
|
409
|
+
stored === undefined ? undefined : deterministicStringify(stored);
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* Build the agent from its options, without touching the store. Nothing is loaded here: the
|
|
414
|
+
* durable state is read by the first turn, so constructing one stays cheap even for a session
|
|
415
|
+
* nobody goes on to run. Private, because an agent is made by `create` or by `load`, and
|
|
416
|
+
* which of the two the caller means is worth saying.
|
|
417
|
+
*/
|
|
418
|
+
constructor(ctx, options) {
|
|
419
|
+
this.id = options.id;
|
|
420
|
+
// An agent is its own lifetime. Whatever call happened to construct it — a tool of
|
|
421
|
+
// another agent, most often — is not a loop this one runs inside, so an inherited
|
|
422
|
+
// marker is dropped rather than carried into work that outlives that call.
|
|
423
|
+
this.#baseCtx = insideTurn.set(ctx, [options.id]);
|
|
424
|
+
this.#providers = options.providers;
|
|
425
|
+
this.#providerId = options.provider;
|
|
426
|
+
this.#persistence = options.persistence;
|
|
427
|
+
this.#persistenceLock = agentBaseStoreLock(options.persistence);
|
|
428
|
+
this.#hooks = options.hooks ?? {};
|
|
429
|
+
this.state = {
|
|
430
|
+
instructions: options.initialState?.instructions ?? "",
|
|
431
|
+
tools: [...(options.initialState?.tools ?? [])],
|
|
432
|
+
};
|
|
433
|
+
this.#model = options.model;
|
|
434
|
+
this.#effort = options.effort;
|
|
435
|
+
this.#serviceTier = options.serviceTier;
|
|
436
|
+
this.#kv = new AgentKV(this.#persistence, `kv.${options.id}.`);
|
|
437
|
+
this.#runKV = this.#kv.scoped("run");
|
|
438
|
+
// Everything the agent does — hooks and tool executions included — runs on a context
|
|
439
|
+
// carrying its provider and the currently effective model, effort, and service tier.
|
|
440
|
+
this.#ctx = this.#deriveCtx();
|
|
441
|
+
this.#steeringMode = options.steeringMode ?? "one-at-a-time";
|
|
442
|
+
this.#sendMode = options.sendMode ?? "one-at-a-time";
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* The context everything the agent does runs on: its identity and effective selection, plus
|
|
446
|
+
* the session-scoped key-value store and the store of the run in progress. Rebuilt whenever
|
|
447
|
+
* the selection changes.
|
|
448
|
+
*/
|
|
449
|
+
#deriveCtx() {
|
|
450
|
+
const ctx = withAgentContext(this.#baseCtx, {
|
|
451
|
+
id: this.id,
|
|
452
|
+
provider: this.#providerId,
|
|
453
|
+
model: this.#model,
|
|
454
|
+
effort: this.#effort,
|
|
455
|
+
serviceTier: this.#serviceTier,
|
|
456
|
+
});
|
|
457
|
+
return withAgentRunKV(withAgentKV(ctx, this.#kv), this.#runKV);
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* Whether the agent has anything left to do. This is the only thing about an agent's state
|
|
461
|
+
* anyone outside it may read: the queues and the stage behind this answer are the run's own
|
|
462
|
+
* business, and can be cleared but never inspected. Even this is rarely wanted — it is here
|
|
463
|
+
* for the owner deciding which agents a restarted process has to resume.
|
|
464
|
+
*/
|
|
465
|
+
get active() {
|
|
466
|
+
return this.#pending !== undefined;
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* Record what the agent is doing, so a process that dies here is discovered owing exactly
|
|
470
|
+
* this. Writing runs on the caller's context: given a transaction's context it commits with
|
|
471
|
+
* whatever else that transaction is writing, which is how a consumed message and the
|
|
472
|
+
* inference it owes become durable as one fact rather than two.
|
|
473
|
+
*/
|
|
474
|
+
async #recordPending(ctx, pending) {
|
|
475
|
+
const serialized = deterministicStringify(pending);
|
|
476
|
+
if (this.#pendingWritten === serialized)
|
|
477
|
+
return;
|
|
478
|
+
await this.#persistence.writeValue(ctx, AGENT_BASE_PENDING_KEY, pending);
|
|
479
|
+
this.#pending = pending;
|
|
480
|
+
this.#pendingWritten = serialized;
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* Record the stage the run has reached, taking the store lock when not already inside it.
|
|
484
|
+
*
|
|
485
|
+
* The first of these also reads what the store already held, before overwriting it. That
|
|
486
|
+
* reading is the only chance to see it: from this point the record says what this instance
|
|
487
|
+
* is doing, and a run interrupted by a dead process would be indistinguishable from the one
|
|
488
|
+
* starting here.
|
|
489
|
+
*/
|
|
490
|
+
async #enterStage(stage) {
|
|
491
|
+
const pending = { stage };
|
|
492
|
+
if (deterministicStringify(pending) === this.#pendingWritten && this.#inheritedRead)
|
|
493
|
+
return;
|
|
494
|
+
try {
|
|
495
|
+
await this.#persistenceLock.runInLock(this.#ctx, async (lockCtx) => {
|
|
496
|
+
if (!this.#inheritedRead) {
|
|
497
|
+
this.#inheritedRead = true;
|
|
498
|
+
this.#inherited = await agentBasePendingStateOf(lockCtx, this.#persistence);
|
|
499
|
+
}
|
|
500
|
+
await this.#recordPending(lockCtx, pending);
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
catch {
|
|
504
|
+
// Losing the record costs recovery precision, never the work itself: the turn is
|
|
505
|
+
// already running and will answer whatever it was going to answer.
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
/**
|
|
509
|
+
* Erase the outstanding work, which is what makes the agent idle. Runs on the caller's
|
|
510
|
+
* context so it can be part of the transaction that settles the agent, letting a hook commit
|
|
511
|
+
* its own conclusion of the run alongside the fact that the run is over.
|
|
512
|
+
*/
|
|
513
|
+
async #clearPending(ctx) {
|
|
514
|
+
await this.#persistence.deleteValue(ctx, AGENT_BASE_PENDING_KEY);
|
|
515
|
+
this.#pending = undefined;
|
|
516
|
+
this.#pendingWritten = undefined;
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* Queue a user message that injects as soon as the current assistant response and its tool
|
|
520
|
+
* batch finish; steering always takes precedence over sent messages. Returns once the message
|
|
521
|
+
* has been handed to the agent, which never waits for the turn that answers it; with
|
|
522
|
+
* `await: true` it returns once the durable write has landed instead, and a failed write both
|
|
523
|
+
* rejects and keeps the message out of the conversation entirely.
|
|
524
|
+
*/
|
|
525
|
+
async steer(ctx, message, options) {
|
|
526
|
+
await this.#offer(ctx, "steering", message, options);
|
|
527
|
+
}
|
|
528
|
+
/**
|
|
529
|
+
* Queue a user message that waits until the agent would otherwise stop — no tool calls or
|
|
530
|
+
* steering remain — before injecting. Returns once the message has been handed to the agent,
|
|
531
|
+
* which never waits for the turn that answers it; with `await: true` it returns once the
|
|
532
|
+
* durable write has landed instead, and a failed write both rejects and keeps the message out
|
|
533
|
+
* of the conversation entirely.
|
|
534
|
+
*/
|
|
535
|
+
async send(ctx, message, options) {
|
|
536
|
+
await this.#offer(ctx, "send", message, options);
|
|
537
|
+
}
|
|
538
|
+
/**
|
|
539
|
+
* Hand one message to a durable queue. The acceptance runs whether or not the caller waits
|
|
540
|
+
* for it — an unwaited failure is still a message that never entered the conversation, and
|
|
541
|
+
* the agent's own close still drains it, so nothing is dropped by not looking.
|
|
542
|
+
*/
|
|
543
|
+
async #offer(ctx, kind, message, options) {
|
|
544
|
+
const { await: wait = false, ...settings } = options ?? {};
|
|
545
|
+
// Refusing the flag rather than the operation: a closed agent and a re-entrant wait are
|
|
546
|
+
// both caller mistakes, and both are reported before any work is started.
|
|
547
|
+
this.#assertCanWait(ctx, wait, kind === "steering" ? "a steered message" : "a sent message");
|
|
548
|
+
if (this.#closed)
|
|
549
|
+
throw new Error("The agent has been closed.");
|
|
550
|
+
const accepted = this.#enqueue(ctx, [{ kind, message, options: settings }]);
|
|
551
|
+
if (wait)
|
|
552
|
+
return accepted;
|
|
553
|
+
accepted.catch(() => undefined);
|
|
554
|
+
}
|
|
555
|
+
/**
|
|
556
|
+
* Refuse a wait that could never end. A hook or a tool runs while its agent's loop waits for
|
|
557
|
+
* it, so waiting for that same agent to finish anything is waiting for oneself; the request
|
|
558
|
+
* itself is always allowed, and asking for another agent is unaffected.
|
|
559
|
+
*/
|
|
560
|
+
#assertCanWait(ctx, wait, operation) {
|
|
561
|
+
if (!wait)
|
|
562
|
+
return;
|
|
563
|
+
if (!insideTurn.get(ctx).includes(this.id))
|
|
564
|
+
return;
|
|
565
|
+
throw new Error(`Waiting for ${operation} from inside the agent's own run loop would wait for a ` +
|
|
566
|
+
"turn that cannot finish. Drop `await: true` to ask for it and return.");
|
|
567
|
+
}
|
|
568
|
+
/**
|
|
569
|
+
* Whether the current execution is running inside this agent's own run loop, judged by the
|
|
570
|
+
* scope the loop marks itself with. This is what an operation carrying no context has to go
|
|
571
|
+
* on. Anything that takes a context asks the context instead: it names the caller, where
|
|
572
|
+
* this only describes what the loop happens to be running, and would mistake code the loop
|
|
573
|
+
* called into for code the loop is waiting on.
|
|
574
|
+
*/
|
|
575
|
+
#insideOwnLoop() {
|
|
576
|
+
return insideLoops.getStore()?.includes(this.id) === true;
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* Accept a batch of messages as one durable step. Every message is written under the same
|
|
580
|
+
* hold of the persistence lock and inside one transaction, so a caller arriving while a
|
|
581
|
+
* batch is being written lands after the whole batch rather than in the middle of it, and a
|
|
582
|
+
* failure admits none of them.
|
|
583
|
+
*/
|
|
584
|
+
async #enqueue(ctx, batch) {
|
|
585
|
+
if (batch.length === 0)
|
|
586
|
+
return;
|
|
587
|
+
if (this.#closed)
|
|
588
|
+
throw new Error("The agent has been closed.");
|
|
589
|
+
// Admitted: from here on the messages are the agent's responsibility, and a close that
|
|
590
|
+
// begins now waits for them rather than resolving over the top of them.
|
|
591
|
+
const admitted = this.#persistenceLock.runInLock(ctx, async (lockCtx) => {
|
|
592
|
+
const accepted = [];
|
|
593
|
+
await this.#persistence.transaction(lockCtx, async (txCtx) => {
|
|
594
|
+
for (const request of batch) {
|
|
595
|
+
const key = await this.#queueKey(txCtx, `${request.kind}.`);
|
|
596
|
+
await this.#persistence.writeValue(txCtx, key, {
|
|
597
|
+
message: request.message,
|
|
598
|
+
options: request.options,
|
|
599
|
+
});
|
|
600
|
+
accepted.push({ key, request });
|
|
601
|
+
}
|
|
602
|
+
// Accepting a message is what makes the work owed: the same transaction that
|
|
603
|
+
// admits it records that the agent owes an answer, so a process that dies right
|
|
604
|
+
// here is discovered still owing it rather than looking idle over a full queue.
|
|
605
|
+
await this.#recordPending(txCtx, { stage: "inference" });
|
|
606
|
+
});
|
|
607
|
+
for (const { key, request } of accepted) {
|
|
608
|
+
// The queue is resolved inside the lock: a history load running just before this
|
|
609
|
+
// one replaces the queue arrays wholesale, and a reference taken before the wait
|
|
610
|
+
// would push the message into an array nobody reads again.
|
|
611
|
+
const queue = request.kind === "steering" ? this.#steering : this.#sends;
|
|
612
|
+
queue.push({ key, message: request.message, options: request.options });
|
|
613
|
+
}
|
|
614
|
+
this.#turnRequested = true;
|
|
615
|
+
this.#startRun();
|
|
616
|
+
});
|
|
617
|
+
this.#admitted.add(admitted);
|
|
618
|
+
try {
|
|
619
|
+
await admitted;
|
|
620
|
+
}
|
|
621
|
+
finally {
|
|
622
|
+
this.#admitted.delete(admitted);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
/**
|
|
626
|
+
* Start the loop without a new message: load the durable state and, if a turn was cut off —
|
|
627
|
+
* queued messages, a dispatched tool batch without results, or an unanswered user or tool
|
|
628
|
+
* message — continue it to completion. On an idle history this loads and does nothing more.
|
|
629
|
+
*/
|
|
630
|
+
start() {
|
|
631
|
+
if (this.#closed)
|
|
632
|
+
throw new Error("The agent has been closed.");
|
|
633
|
+
this.#startRun();
|
|
634
|
+
}
|
|
635
|
+
/**
|
|
636
|
+
* Wait until the agent has nothing left to do. That includes work it has taken on but not yet
|
|
637
|
+
* started: an operation whose caller did not wait for it is registered from the moment it is
|
|
638
|
+
* called, so a message asked for and abandoned is still something this waits for, rather than
|
|
639
|
+
* a race between the caller's next line and the agent's own lock.
|
|
640
|
+
*/
|
|
641
|
+
async waitForIdle() {
|
|
642
|
+
while (this.#admitted.size > 0 || this.#runPromise !== undefined) {
|
|
643
|
+
await Promise.allSettled([...this.#admitted]);
|
|
644
|
+
await this.#runPromise;
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* Compact the conversation. The compaction waits for the active turn to end — or runs right
|
|
649
|
+
* away when idle — and replaces the compacted history with the provider's replacement context
|
|
650
|
+
* while keeping every message that joined the history after the snapshot.
|
|
651
|
+
*
|
|
652
|
+
* Returns once the compaction has been asked for; with `await: true` it returns once the
|
|
653
|
+
* compaction has run, rejecting when the provider reports failure or when nothing will carry
|
|
654
|
+
* it out. Callers that wait while a compaction is pending or running all wait for that same
|
|
655
|
+
* compaction rather than queueing another.
|
|
656
|
+
*/
|
|
657
|
+
async compact(ctx, options) {
|
|
658
|
+
const wait = options?.await ?? false;
|
|
659
|
+
this.#assertCanWait(ctx, wait, "a compaction");
|
|
660
|
+
if (this.#closed)
|
|
661
|
+
throw new Error("The agent has been closed.");
|
|
662
|
+
// A compaction runs between turns, so waiting for one waits for the target's current
|
|
663
|
+
// turn to end. That is safe from an ordinary caller, and safe from another agent going
|
|
664
|
+
// about its own business. It is not safe from inside a turn while the target is running
|
|
665
|
+
// a tool: that tool may be waiting for this caller's agent, and neither side can see the
|
|
666
|
+
// other half of the cycle. Such a request is refused outright rather than left standing,
|
|
667
|
+
// because the caller asked to be told when the conversation had been replaced — and a
|
|
668
|
+
// replacement carried out later, once nobody is waiting for it, is a different thing
|
|
669
|
+
// from what was asked for. Ask again from a caller that can wait, or without the wait.
|
|
670
|
+
if (wait && this.#toolsRunning > 0 && insideTurn.get(ctx).length > 0) {
|
|
671
|
+
throw new Error(`Agent ${JSON.stringify(this.id)} is running a tool, so a compaction cannot be ` +
|
|
672
|
+
"waited for from inside another agent's turn: the two could be waiting for " +
|
|
673
|
+
"each other. Ask for it without `await: true`, or from outside a turn.");
|
|
674
|
+
}
|
|
675
|
+
const compaction = this.#ensureCompaction();
|
|
676
|
+
if (wait)
|
|
677
|
+
return compaction;
|
|
678
|
+
compaction.catch(() => undefined);
|
|
679
|
+
}
|
|
680
|
+
/**
|
|
681
|
+
* End a compaction nobody will carry out. A compaction that already ran has settled its own
|
|
682
|
+
* promise, so this only ever reaches one that was requested and then abandoned.
|
|
683
|
+
*/
|
|
684
|
+
#settlePendingCompaction(reason) {
|
|
685
|
+
const pending = this.#compaction;
|
|
686
|
+
if (pending === undefined)
|
|
687
|
+
return;
|
|
688
|
+
this.#compaction = undefined;
|
|
689
|
+
pending.reject(new Error(reason));
|
|
690
|
+
}
|
|
691
|
+
/**
|
|
692
|
+
* The pending compaction, requesting one if none is pending. Every caller shares the same
|
|
693
|
+
* promise, and the request is what starts the loop that will carry it out.
|
|
694
|
+
*/
|
|
695
|
+
#ensureCompaction() {
|
|
696
|
+
if (this.#compaction === undefined) {
|
|
697
|
+
let resolve;
|
|
698
|
+
let reject;
|
|
699
|
+
const promise = new Promise((res, rej) => {
|
|
700
|
+
resolve = res;
|
|
701
|
+
reject = rej;
|
|
702
|
+
});
|
|
703
|
+
this.#compaction = { promise, resolve, reject };
|
|
704
|
+
this.#turnRequested = true;
|
|
705
|
+
this.#startRun();
|
|
706
|
+
}
|
|
707
|
+
return this.#compaction.promise;
|
|
708
|
+
}
|
|
709
|
+
/**
|
|
710
|
+
* Track work that outlived the response it belonged to, so the next request can wait for it.
|
|
711
|
+
* Failures are the unwinding work's own business and never reach the turn.
|
|
712
|
+
*/
|
|
713
|
+
#settleLater(work, kind) {
|
|
714
|
+
const tracked = work
|
|
715
|
+
.catch(() => undefined)
|
|
716
|
+
.finally(() => {
|
|
717
|
+
this.#settling.delete(tracked);
|
|
718
|
+
this.#streamCleanup.delete(tracked);
|
|
719
|
+
});
|
|
720
|
+
this.#settling.add(tracked);
|
|
721
|
+
// A close waits for the stream to let go of the session before destroying it, but never
|
|
722
|
+
// for a tool: a tool that ignores being abandoned would otherwise hold the shutdown open
|
|
723
|
+
// for ever, and it may well be blocked on that very shutdown.
|
|
724
|
+
if (kind === "stream")
|
|
725
|
+
this.#streamCleanup.add(tracked);
|
|
726
|
+
}
|
|
727
|
+
/** Wait until no response iterator is still releasing the provider session. */
|
|
728
|
+
async #streamsReleased() {
|
|
729
|
+
while (this.#streamCleanup.size > 0) {
|
|
730
|
+
await Promise.allSettled([...this.#streamCleanup]);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
/** Wait until nothing from an earlier response is still holding the provider session. */
|
|
734
|
+
async #settled() {
|
|
735
|
+
while (this.#settling.size > 0) {
|
|
736
|
+
await Promise.allSettled([...this.#settling]);
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
/**
|
|
740
|
+
* The system prompt for the next request: the mutable state extended by the hook's answer.
|
|
741
|
+
* Instructions and tools are correctness hooks — a failure here fails the turn loudly
|
|
742
|
+
* instead of silently running with a wrong configuration.
|
|
743
|
+
*/
|
|
744
|
+
async #instructions() {
|
|
745
|
+
const hooked = await this.#hooks.instructions?.(this.#ctx);
|
|
746
|
+
return [this.state.instructions, hooked ?? ""]
|
|
747
|
+
.filter((text) => text.length > 0)
|
|
748
|
+
.join("\n\n");
|
|
749
|
+
}
|
|
750
|
+
/**
|
|
751
|
+
* The tools for the next request or execution: the mutable state extended by the hook's
|
|
752
|
+
* answer. Two tools sharing one name and namespace are a configuration error that fails
|
|
753
|
+
* the turn, since the provider would receive ambiguous descriptors.
|
|
754
|
+
*/
|
|
755
|
+
async #tools() {
|
|
756
|
+
const hooked = await this.#hooks.tools?.(this.#ctx);
|
|
757
|
+
const tools = [...this.state.tools, ...(hooked ?? [])];
|
|
758
|
+
const names = new Set();
|
|
759
|
+
for (const tool of tools) {
|
|
760
|
+
const key = `${tool.namespace ?? ""}\u0000${tool.name}`;
|
|
761
|
+
if (names.has(key)) {
|
|
762
|
+
throw new Error(tool.namespace === undefined
|
|
763
|
+
? `Two tools are registered as "${tool.name}".`
|
|
764
|
+
: `Two tools are registered as "${tool.name}" in namespace "${tool.namespace}".`);
|
|
765
|
+
}
|
|
766
|
+
names.add(key);
|
|
767
|
+
}
|
|
768
|
+
return tools;
|
|
769
|
+
}
|
|
770
|
+
/**
|
|
771
|
+
* Cancel the active turn: stop consuming the inference stream, settle still-running tool calls
|
|
772
|
+
* as aborted error results, and drop the queued turn request. Blocks that already finished
|
|
773
|
+
* stay in the history; an unfinished block is dropped. Messages still waiting in the steering
|
|
774
|
+
* and send queues stay durable and join the next requested turn. A no-op when the agent is
|
|
775
|
+
* idle.
|
|
776
|
+
*
|
|
777
|
+
* Returns once the cancellation has been signalled, which is the point from which nothing
|
|
778
|
+
* more of that turn happens; with `await: true` it returns once the loop has actually
|
|
779
|
+
* unwound. The cancellation is identical either way — waiting only buys the answer about
|
|
780
|
+
* when it finished.
|
|
781
|
+
*/
|
|
782
|
+
async abort(ctx, options) {
|
|
783
|
+
const wait = options?.await ?? false;
|
|
784
|
+
this.#assertCanWait(ctx, wait, "an abort");
|
|
785
|
+
const run = this.#signalAbort();
|
|
786
|
+
if (run === undefined)
|
|
787
|
+
return;
|
|
788
|
+
// Dropping the turn request drops the only thing that would have carried out a
|
|
789
|
+
// compaction asked for during it, so its callers are told rather than left waiting for
|
|
790
|
+
// a turn that will never come. That happens once the loop has stopped, whether or not
|
|
791
|
+
// anyone here is waiting to see it.
|
|
792
|
+
if (wait) {
|
|
793
|
+
await run;
|
|
794
|
+
this.#settlePendingCompaction("The compaction was cancelled by an abort.");
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
void run
|
|
798
|
+
.catch(() => undefined)
|
|
799
|
+
.then(() => {
|
|
800
|
+
this.#settlePendingCompaction("The compaction was cancelled by an abort.");
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
/**
|
|
804
|
+
* Signal cancellation of the active turn. Answers with the run to wait for, or undefined
|
|
805
|
+
* when the agent was already idle and there was nothing to cancel.
|
|
806
|
+
*/
|
|
807
|
+
#signalAbort() {
|
|
808
|
+
const run = this.#runPromise;
|
|
809
|
+
if (run === undefined)
|
|
810
|
+
return undefined;
|
|
811
|
+
this.#turnRequested = false;
|
|
812
|
+
this.#abortController?.abort();
|
|
813
|
+
return run;
|
|
814
|
+
}
|
|
815
|
+
/**
|
|
816
|
+
* Stop the agent, without abandoning anything it had already taken on. Nothing new is
|
|
817
|
+
* admitted from the moment close is called, but a message accepted just before it is still
|
|
818
|
+
* written, still answered, and only then is the provider session destroyed — so a caller
|
|
819
|
+
* whose send resolved never has to wonder whether the close raced it. Closing twice awaits
|
|
820
|
+
* the same shutdown.
|
|
821
|
+
*
|
|
822
|
+
* A close is nothing but a wait, so it is refused from inside the agent's own run loop, where
|
|
823
|
+
* the wait could never end. It is the one operation with no context to check, so it asks the
|
|
824
|
+
* runtime instead.
|
|
825
|
+
*
|
|
826
|
+
* Running tool calls are the exception to finishing what was accepted. From the moment close
|
|
827
|
+
* begins, the batch stops waiting for them and settles them in the conversation as error
|
|
828
|
+
* results. A tool can perfectly well be blocked on this very close — that is how two agents
|
|
829
|
+
* closing each other through their tools would otherwise wedge — and no tool is worth
|
|
830
|
+
* letting the shutdown never finish.
|
|
831
|
+
*/
|
|
832
|
+
async close() {
|
|
833
|
+
// A close asked for from inside the loop is the one case that cannot cut the loop short,
|
|
834
|
+
// because the work it would abandon is the caller itself.
|
|
835
|
+
const fromInsideOwnLoop = this.#insideOwnLoop();
|
|
836
|
+
this.#closed = true;
|
|
837
|
+
if (!fromInsideOwnLoop)
|
|
838
|
+
this.#closeController.abort();
|
|
839
|
+
// The barrier is published before any of the shutdown runs. Destroying the provider
|
|
840
|
+
// session can reenter close, and a caller arriving then has to join this shutdown rather
|
|
841
|
+
// than start a second one that destroys the same session again.
|
|
842
|
+
this.#closing ??= (async () => {
|
|
843
|
+
await Promise.resolve();
|
|
844
|
+
// Each admitted operation can still request a turn, and that turn can be the thing
|
|
845
|
+
// that finishes the work, so both are drained until neither has anything left.
|
|
846
|
+
while (this.#admitted.size > 0 || this.#runPromise !== undefined) {
|
|
847
|
+
await Promise.allSettled([...this.#admitted]);
|
|
848
|
+
await this.#runPromise?.catch(() => undefined);
|
|
849
|
+
}
|
|
850
|
+
this.#settlePendingCompaction("The agent was closed before the compaction ran.");
|
|
851
|
+
// Close is the final ownership boundary for the provider session. A response
|
|
852
|
+
// iterator that has not finished releasing it still holds it, and destroying it
|
|
853
|
+
// underneath that cleanup hands the provider two owners at once.
|
|
854
|
+
await this.#streamsReleased();
|
|
855
|
+
await this.#session?.destroy();
|
|
856
|
+
this.#session = undefined;
|
|
857
|
+
})();
|
|
858
|
+
if (!fromInsideOwnLoop) {
|
|
859
|
+
await this.#closing;
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
862
|
+
// The caller is something the loop is waiting for. If the shutdown completes anyway the
|
|
863
|
+
// caller had already let go and hears the truth; if it does not, the loop is still
|
|
864
|
+
// waiting for this very caller, and saying so beats waiting for oneself for ever. The
|
|
865
|
+
// shutdown itself continues regardless — it is the report that is given up on.
|
|
866
|
+
const shutdown = this.#closing.then(() => true, () => true);
|
|
867
|
+
const settled = await Promise.race([
|
|
868
|
+
shutdown,
|
|
869
|
+
new Promise((resolve) => {
|
|
870
|
+
setTimeout(() => resolve(false), INSIDE_CLOSE_REPORT_MS).unref?.();
|
|
871
|
+
}),
|
|
872
|
+
]);
|
|
873
|
+
if (!settled) {
|
|
874
|
+
throw new Error("Closing the agent from inside its own run loop would wait for a turn that " +
|
|
875
|
+
"cannot finish. The shutdown was started and will complete once this " +
|
|
876
|
+
"caller returns.");
|
|
877
|
+
}
|
|
878
|
+
await this.#closing;
|
|
879
|
+
}
|
|
880
|
+
/**
|
|
881
|
+
* Make sure the run loop is running. A loop already in flight picks up the request on its
|
|
882
|
+
* next pass, so this never starts a second one.
|
|
883
|
+
*/
|
|
884
|
+
#startRun() {
|
|
885
|
+
if (this.#runPromise !== undefined)
|
|
886
|
+
return;
|
|
887
|
+
// The loop is a lifetime of its own, so it marks itself rather than inheriting whatever
|
|
888
|
+
// happened to start it — a tool of another agent, most often, which will be long gone.
|
|
889
|
+
this.#runPromise = insideLoops
|
|
890
|
+
.run([this.id], () => this.#runLoop())
|
|
891
|
+
.finally(() => {
|
|
892
|
+
this.#runPromise = undefined;
|
|
893
|
+
// A request that arrived while the loop was settling would otherwise be stranded:
|
|
894
|
+
// the loop had stopped checking, and the caller's own `#startRun` saw a run still
|
|
895
|
+
// in flight. Waiters re-check the field, so they pick this continuation up.
|
|
896
|
+
if (this.#turnRequested && !this.#closed) {
|
|
897
|
+
this.#startRun();
|
|
898
|
+
return;
|
|
899
|
+
}
|
|
900
|
+
this.#announceSettled();
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
/**
|
|
904
|
+
* Answer turns until nothing is asked for any more. The inner loop is one turn each: reload
|
|
905
|
+
* the durable state, ask the pre-turn hooks what to do, run the inference and its tools, then
|
|
906
|
+
* ask the post-turn hooks. The outer loop reopens when an `afterAgentLoop` action asks for
|
|
907
|
+
* more work, so the loop hooks always bracket a settled-to-settled span.
|
|
908
|
+
*/
|
|
909
|
+
async #runLoop() {
|
|
910
|
+
// The outer loop reopens when an `afterAgentLoop` action requests more work, so the
|
|
911
|
+
// loop hooks always bracket a settled-to-settled span.
|
|
912
|
+
do {
|
|
913
|
+
// The abort scope opens before the loop hook, not just before the turn. An abort
|
|
914
|
+
// owns everything the run does — its opening hook as much as its inference — so a
|
|
915
|
+
// run cancelled while it is still starting up never reaches the model at all.
|
|
916
|
+
let abort = this.#openAbortScope();
|
|
917
|
+
// The agent is working from here, and says so durably before it does anything a
|
|
918
|
+
// crash could interrupt. What it records is refined as the run reaches each stage;
|
|
919
|
+
// what matters at this point is that the record exists at all, since its absence is
|
|
920
|
+
// what a later process reads as an agent that finished.
|
|
921
|
+
await this.#enterStage("inference");
|
|
922
|
+
await this.#invokeHook(this.#hooks.beforeAgentLoop);
|
|
923
|
+
do {
|
|
924
|
+
this.#turnAborted = false;
|
|
925
|
+
// Claimed before any awaiting, so a request raised while the turn is still
|
|
926
|
+
// starting up survives into another turn instead of being cleared by it. The
|
|
927
|
+
// redundant turn this can cost is cheap: an empty queue drains without any
|
|
928
|
+
// inference.
|
|
929
|
+
this.#turnRequested = false;
|
|
930
|
+
// Every turn starts from the durable state rather than from what this instance
|
|
931
|
+
// last remembered. Another owner over the same store may have appended messages
|
|
932
|
+
// or changed the selection since, and answering out of a stale memory would
|
|
933
|
+
// reply to a conversation that no longer exists.
|
|
934
|
+
this.#loaded = undefined;
|
|
935
|
+
// The durable history has to be loaded before anything else: a turn that cannot
|
|
936
|
+
// read the conversation cannot answer it, and must not write to it either —
|
|
937
|
+
// appending to a conversation it cannot see is how a message ends up after a
|
|
938
|
+
// tool call nobody answered. The turn ends here instead, leaving everything
|
|
939
|
+
// durable exactly as it was for the next attempt.
|
|
940
|
+
const loadFailure = await this.#ensureLoaded().then(() => undefined, (error) => error);
|
|
941
|
+
if (loadFailure !== undefined) {
|
|
942
|
+
this.#emit({
|
|
943
|
+
type: "done",
|
|
944
|
+
state: "error",
|
|
945
|
+
kind: "internal_error",
|
|
946
|
+
message: loadFailure instanceof Error
|
|
947
|
+
? loadFailure.message
|
|
948
|
+
: String(loadFailure),
|
|
949
|
+
});
|
|
950
|
+
break;
|
|
951
|
+
}
|
|
952
|
+
await this.#applyActions(this.#hooks.beforeTurn, abort.signal, {
|
|
953
|
+
contextTokens: this.#contextTokens,
|
|
954
|
+
});
|
|
955
|
+
await this.#runInference(abort);
|
|
956
|
+
await this.#applyActions(this.#hooks.afterTurn, abort.signal, {
|
|
957
|
+
contextTokens: this.#contextTokens,
|
|
958
|
+
aborted: this.#turnAborted,
|
|
959
|
+
});
|
|
960
|
+
if (!this.#turnRequested || this.#closed)
|
|
961
|
+
break;
|
|
962
|
+
// Each turn cancels on its own scope. Reopening it here rather than at the top
|
|
963
|
+
// keeps the run's first turn under the scope its opening hook already ran in.
|
|
964
|
+
abort = this.#openAbortScope();
|
|
965
|
+
} while (true);
|
|
966
|
+
await this.#applyActions(this.#hooks.afterAgentLoop, abort.signal);
|
|
967
|
+
} while (this.#turnRequested && !this.#closed);
|
|
968
|
+
// Nothing is asked for any more, so the outstanding work is erased. That erasure is what
|
|
969
|
+
// makes the agent idle, and it commits together with whatever the settling hooks write,
|
|
970
|
+
// so no owner can ever see the agent finished without their conclusions or their
|
|
971
|
+
// conclusions without the agent being finished.
|
|
972
|
+
await this.#settleDurably();
|
|
973
|
+
}
|
|
974
|
+
/**
|
|
975
|
+
* Erase the outstanding work and let the transactional settling hooks write in the same
|
|
976
|
+
* transaction. A failure leaves the record in place: an agent wrongly believed to be working
|
|
977
|
+
* is resumed and finds nothing to do, while one wrongly believed to be finished is never
|
|
978
|
+
* resumed at all.
|
|
979
|
+
*/
|
|
980
|
+
async #settleDurably() {
|
|
981
|
+
try {
|
|
982
|
+
await this.#persistenceLock.runInLock(this.#ctx, (lockCtx) => this.#persistence.transaction(lockCtx, async (txCtx) => {
|
|
983
|
+
await this.#clearPending(txCtx);
|
|
984
|
+
await this.#invokeTransactionalSettle(txCtx);
|
|
985
|
+
// The run store is erased last, so a settling hook can still read what the
|
|
986
|
+
// run concluded and keep whatever part of it belongs to the conversation.
|
|
987
|
+
// It commits with the settlement: the run is over and its notes are gone as
|
|
988
|
+
// one fact, never one without the other.
|
|
989
|
+
await this.#clearRunStore(txCtx);
|
|
990
|
+
}));
|
|
991
|
+
}
|
|
992
|
+
catch {
|
|
993
|
+
// The run itself is over and succeeded; only the record of its ending failed.
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
/**
|
|
997
|
+
* Call the settling hooks that write inside the settling transaction. They run on a context
|
|
998
|
+
* that lives exactly as long as the transaction does, so a store they keep hold of cannot be
|
|
999
|
+
* used to write once the settlement has committed. A throwing hook rolls the settlement back
|
|
1000
|
+
* with it, because a hook here is writing a conclusion about the very fact being committed,
|
|
1001
|
+
* and half of that pair is worse than neither.
|
|
1002
|
+
*/
|
|
1003
|
+
async #invokeTransactionalSettle(txCtx) {
|
|
1004
|
+
const hook = this.#hooks.afterAgentSettledTransact;
|
|
1005
|
+
if (hook === undefined)
|
|
1006
|
+
return;
|
|
1007
|
+
const committed = new AbortController();
|
|
1008
|
+
try {
|
|
1009
|
+
const liveCtx = withLifetime(insideTurn.set(txCtx, []), committed.signal);
|
|
1010
|
+
const hookCtx = withAgentKV(liveCtx, this.#kv);
|
|
1011
|
+
await hook(withAgentRunKV(hookCtx, this.#runKV));
|
|
1012
|
+
}
|
|
1013
|
+
finally {
|
|
1014
|
+
committed.abort();
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
/**
|
|
1018
|
+
* Erase everything the run wrote about itself, inside the transaction that settles the agent:
|
|
1019
|
+
* the clear runs on that transaction's own context, so it commits with the settlement.
|
|
1020
|
+
*/
|
|
1021
|
+
async #clearRunStore(txCtx) {
|
|
1022
|
+
await this.#runKV.clear(txCtx);
|
|
1023
|
+
}
|
|
1024
|
+
/**
|
|
1025
|
+
* Open the scope the next stretch of work is cancelled on, and make it the one an abort
|
|
1026
|
+
* signals. A cancellation that arrived before this point is not carried into the new scope:
|
|
1027
|
+
* it cancelled the work it was aimed at, and that work is over.
|
|
1028
|
+
*/
|
|
1029
|
+
#openAbortScope() {
|
|
1030
|
+
const abort = new AbortController();
|
|
1031
|
+
this.#abortController = abort;
|
|
1032
|
+
return abort;
|
|
1033
|
+
}
|
|
1034
|
+
/**
|
|
1035
|
+
* Commit and announce the settle, once the loop has actually stopped rather than as its last
|
|
1036
|
+
* act. The difference matters to whoever is listening: a hook told the agent has settled is
|
|
1037
|
+
* being told something it can act on, and asking for a compaction — or anything else the
|
|
1038
|
+
* loop carries out — has to reach a loop that can still be started.
|
|
1039
|
+
*
|
|
1040
|
+
* The work is admitted rather than left to run loose, so an idle agent is one whose settle
|
|
1041
|
+
* has finished, and a close waits for it like anything else it took on.
|
|
1042
|
+
*/
|
|
1043
|
+
#announceSettled() {
|
|
1044
|
+
const announced = (async () => {
|
|
1045
|
+
// The settle runs once the loop has stopped, so its hook is not inside a turn and
|
|
1046
|
+
// its context does not claim to be: a compaction it waits for reaches a loop that
|
|
1047
|
+
// can still be started.
|
|
1048
|
+
await this.#invokeHookOn(insideTurn.set(this.#ctx, []), this.#hooks.afterAgentSettled);
|
|
1049
|
+
})();
|
|
1050
|
+
this.#admitted.add(announced);
|
|
1051
|
+
void announced.finally(() => this.#admitted.delete(announced));
|
|
1052
|
+
}
|
|
1053
|
+
/** Call an observing hook on the given context; a throwing hook is swallowed, never fatal. */
|
|
1054
|
+
async #invokeHookOn(ctx, hook, ...args) {
|
|
1055
|
+
try {
|
|
1056
|
+
await hook?.(ctx, ...args);
|
|
1057
|
+
}
|
|
1058
|
+
catch {
|
|
1059
|
+
// Hooks observe the run; they never fail it.
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
/** Call an observing hook on the agent's own context. */
|
|
1063
|
+
async #invokeHook(hook, ...args) {
|
|
1064
|
+
await this.#invokeHookOn(this.#ctx, hook, ...args);
|
|
1065
|
+
}
|
|
1066
|
+
/**
|
|
1067
|
+
* Ask a hook what to do next, on a scope that may be cancelled while the hook is still
|
|
1068
|
+
* thinking. An abort owns the whole of the turn it cancelled, including the answer of a hook
|
|
1069
|
+
* that was already running when it fired: carrying that answer out would open a fresh turn
|
|
1070
|
+
* out of work the caller had just cancelled. The answer is dropped rather than deferred,
|
|
1071
|
+
* since it was a decision about a turn that no longer exists.
|
|
1072
|
+
*/
|
|
1073
|
+
async #applyActions(hook, signal, ...args) {
|
|
1074
|
+
if (hook === undefined)
|
|
1075
|
+
return;
|
|
1076
|
+
let actions;
|
|
1077
|
+
try {
|
|
1078
|
+
actions = await hook(this.#ctx, ...args);
|
|
1079
|
+
}
|
|
1080
|
+
catch {
|
|
1081
|
+
return;
|
|
1082
|
+
}
|
|
1083
|
+
if (signal.aborted)
|
|
1084
|
+
return;
|
|
1085
|
+
await this.#carryOutActions(actions);
|
|
1086
|
+
}
|
|
1087
|
+
/**
|
|
1088
|
+
* Ask a lifecycle hook what to do next and carry its actions out: queue steering or sent
|
|
1089
|
+
* messages through the ordinary durable path, or trigger a compaction. Every returned
|
|
1090
|
+
* action is applied before the loop continues, so they all take effect at the same point.
|
|
1091
|
+
* Neither a throwing hook nor a failing action ever fails the run. Unlike `#applyActions`
|
|
1092
|
+
* this belongs to no turn's scope, so nothing can cancel the answer out from under it.
|
|
1093
|
+
*/
|
|
1094
|
+
async #applyActionsAlways(hook, ...args) {
|
|
1095
|
+
if (hook === undefined)
|
|
1096
|
+
return;
|
|
1097
|
+
let actions;
|
|
1098
|
+
try {
|
|
1099
|
+
actions = await hook(this.#ctx, ...args);
|
|
1100
|
+
}
|
|
1101
|
+
catch {
|
|
1102
|
+
return;
|
|
1103
|
+
}
|
|
1104
|
+
await this.#carryOutActions(actions);
|
|
1105
|
+
}
|
|
1106
|
+
/**
|
|
1107
|
+
* Carry out what a hook asked for. The messages came from one decision, so they are accepted
|
|
1108
|
+
* as one batch: a caller arriving while they are being written lands after all of them
|
|
1109
|
+
* rather than between two halves of the same thought.
|
|
1110
|
+
*/
|
|
1111
|
+
async #carryOutActions(actions) {
|
|
1112
|
+
const batch = [];
|
|
1113
|
+
const flush = async () => {
|
|
1114
|
+
const pending = batch.splice(0, batch.length);
|
|
1115
|
+
try {
|
|
1116
|
+
await this.#enqueue(this.#ctx, pending);
|
|
1117
|
+
}
|
|
1118
|
+
catch {
|
|
1119
|
+
// A hook-driven action must not fail the run.
|
|
1120
|
+
}
|
|
1121
|
+
};
|
|
1122
|
+
for (const action of actions ?? []) {
|
|
1123
|
+
if (action.type === "compact") {
|
|
1124
|
+
await flush();
|
|
1125
|
+
this.#ensureCompaction().catch(() => undefined);
|
|
1126
|
+
continue;
|
|
1127
|
+
}
|
|
1128
|
+
batch.push({
|
|
1129
|
+
kind: action.type === "steer" ? "steering" : "send",
|
|
1130
|
+
message: action.message,
|
|
1131
|
+
options: {},
|
|
1132
|
+
});
|
|
1133
|
+
}
|
|
1134
|
+
await flush();
|
|
1135
|
+
}
|
|
1136
|
+
/**
|
|
1137
|
+
* One turn's work: resume an interrupted tool batch, run a requested compaction, then cycle
|
|
1138
|
+
* between draining the queues and asking the model, dispatching each response's tool calls,
|
|
1139
|
+
* until nothing is owed an answer. Every failure is caught here and surfaced to the
|
|
1140
|
+
* conversation, so a turn ends with a complete context whatever went wrong.
|
|
1141
|
+
*/
|
|
1142
|
+
async #runInference(abort) {
|
|
1143
|
+
// One shared promise for the turn's scope keeps races from piling up listeners on the
|
|
1144
|
+
// signal, and a scope that was aborted before this point settles it immediately: a
|
|
1145
|
+
// listener added afterwards would never hear the event that already happened.
|
|
1146
|
+
const abortPromise = abort.signal.aborted
|
|
1147
|
+
? Promise.resolve(ABORTED)
|
|
1148
|
+
: new Promise((resolve) => {
|
|
1149
|
+
abort.signal.addEventListener("abort", () => resolve(ABORTED), { once: true });
|
|
1150
|
+
});
|
|
1151
|
+
try {
|
|
1152
|
+
await this.#ensureLoaded();
|
|
1153
|
+
// Resume a tool batch that was dispatched but cut off before its results landed, so
|
|
1154
|
+
// the interrupted results reach the main store before any queued message.
|
|
1155
|
+
const resumed = this.#pendingTools;
|
|
1156
|
+
const undispatched = this.#pendingToolsUndispatched;
|
|
1157
|
+
this.#pendingTools = [];
|
|
1158
|
+
this.#pendingToolsUndispatched = false;
|
|
1159
|
+
if (resumed.length > 0) {
|
|
1160
|
+
// A batch that was never committed has certainly not run — the commit precedes
|
|
1161
|
+
// every execution — so it is dispatched as the fresh batch it never got to be,
|
|
1162
|
+
// rather than resumed, which would refuse the non-durable calls.
|
|
1163
|
+
if (await this.#runToolBatch(resumed, !undispatched, abort.signal, abortPromise)) {
|
|
1164
|
+
return;
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
// A requested compaction runs before this turn's first inference, so the model
|
|
1168
|
+
// always receives a settled conversation — never one still owing tool results.
|
|
1169
|
+
await this.#runCompaction(abort.signal);
|
|
1170
|
+
// An inference is needed without any injection when tool results from a resumed batch
|
|
1171
|
+
// end the context, or — checked once, against the freshly loaded durable state —
|
|
1172
|
+
// when a cut-off run left its trailing user or tool message unanswered. Afterwards
|
|
1173
|
+
// a trailing user message can be legitimate: a response may have zero blocks.
|
|
1174
|
+
let needsInference = resumed.length > 0;
|
|
1175
|
+
if (!this.#recoveryChecked) {
|
|
1176
|
+
this.#recoveryChecked = true;
|
|
1177
|
+
needsInference ||= this.#resumesInterruptedRun();
|
|
1178
|
+
}
|
|
1179
|
+
// Each cycle first drains the queues, then runs one inference. Steering injects at
|
|
1180
|
+
// every stop between responses and always outranks sends; sent messages inject
|
|
1181
|
+
// only when the agent would otherwise stop — no tool results or steering remain.
|
|
1182
|
+
// Queue consumption happens only here, between inferences, so an injected message
|
|
1183
|
+
// can never interleave with an active response's block records.
|
|
1184
|
+
// The message of an error response that has not been recovered from yet. A later
|
|
1185
|
+
// successful response clears it; a turn that ends while it is set has failed and
|
|
1186
|
+
// surfaces it to the context as a system message.
|
|
1187
|
+
let pendingError;
|
|
1188
|
+
while (true) {
|
|
1189
|
+
// An abort during the tool batch ends the turn here, before the next inference.
|
|
1190
|
+
// A cancellation arriving when the turn has nothing left to do cancels nothing:
|
|
1191
|
+
// the last response already reported its own terminal event, and a second one
|
|
1192
|
+
// would contradict it for the same response.
|
|
1193
|
+
if (abort.signal.aborted) {
|
|
1194
|
+
const hasPendingWork = needsInference || this.#steering.length > 0 || this.#sends.length > 0;
|
|
1195
|
+
if (hasPendingWork)
|
|
1196
|
+
this.#emit({ type: "done", state: "cancelled" });
|
|
1197
|
+
break;
|
|
1198
|
+
}
|
|
1199
|
+
let injected = await this.#consumeQueue(this.#steering, this.#steeringMode, "steering.");
|
|
1200
|
+
if (!injected && !needsInference) {
|
|
1201
|
+
injected = await this.#consumeQueue(this.#sends, this.#sendMode, "send.");
|
|
1202
|
+
}
|
|
1203
|
+
// Nothing to answer — a start() on an idle history, or the queues ran dry.
|
|
1204
|
+
if (!injected && !needsInference)
|
|
1205
|
+
break;
|
|
1206
|
+
const instructions = await this.#instructions();
|
|
1207
|
+
const tools = await this.#tools();
|
|
1208
|
+
const session = await this.#ensureSession(instructions, tools);
|
|
1209
|
+
// Nothing from the previous response may still be holding the session — but
|
|
1210
|
+
// that unwinding was detached from an earlier abort precisely so it could never
|
|
1211
|
+
// hold a cancellation open, so a new cancellation must not start waiting for it
|
|
1212
|
+
// either.
|
|
1213
|
+
if ((await Promise.race([this.#settled(), abortPromise])) === ABORTED)
|
|
1214
|
+
continue;
|
|
1215
|
+
await this.#invokeHook(this.#hooks.beforeInference);
|
|
1216
|
+
const stream = session.run(this.#ctx, {
|
|
1217
|
+
context: {
|
|
1218
|
+
instructions,
|
|
1219
|
+
messages: [...this.#messages],
|
|
1220
|
+
},
|
|
1221
|
+
...(this.#model === undefined ? {} : { model: this.#model }),
|
|
1222
|
+
...(this.#effort === undefined ? {} : { effort: this.#effort }),
|
|
1223
|
+
...(this.#serviceTier === undefined ? {} : { serviceTier: this.#serviceTier }),
|
|
1224
|
+
});
|
|
1225
|
+
const { content, state, errorMessage, tokens } = await this.#collect(stream, abortPromise);
|
|
1226
|
+
// A cancelled or failed response measures nothing, so the conversation keeps
|
|
1227
|
+
// the last real measurement instead of forgetting how large it had become.
|
|
1228
|
+
if (tokens !== undefined) {
|
|
1229
|
+
await this.#recordContextTokens(tokens.input + tokens.output);
|
|
1230
|
+
}
|
|
1231
|
+
await this.#invokeHook(this.#hooks.afterInference, {
|
|
1232
|
+
state,
|
|
1233
|
+
tokens,
|
|
1234
|
+
...(errorMessage === undefined ? {} : { errorMessage }),
|
|
1235
|
+
});
|
|
1236
|
+
if (content.length > 0) {
|
|
1237
|
+
this.#messages.push({ role: "assistant", content });
|
|
1238
|
+
}
|
|
1239
|
+
needsInference = false;
|
|
1240
|
+
pendingError = state === "error" ? errorMessage : undefined;
|
|
1241
|
+
if (state !== "tool_call") {
|
|
1242
|
+
// A response can carry a tool call and still not end in one — a stream that
|
|
1243
|
+
// failed or was cut off after the call was emitted. Nothing will dispatch
|
|
1244
|
+
// it, so it is settled here rather than left in the conversation for ever.
|
|
1245
|
+
// A settling the store refused ends the turn instead: the call stays last,
|
|
1246
|
+
// where a later attempt can still answer it.
|
|
1247
|
+
if (!(await this.#settleUnansweredCalls("The response ended before this tool call was dispatched."))) {
|
|
1248
|
+
break;
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
if (state === "tool_call") {
|
|
1252
|
+
const calls = content.filter((block) => block.type === "tool_call" && block.server !== true);
|
|
1253
|
+
if (calls.length === 0)
|
|
1254
|
+
continue;
|
|
1255
|
+
const closedDuringTools = await this.#runToolBatch(calls.map((call, index) => ({
|
|
1256
|
+
key: this.#toolKey(index, call.callId),
|
|
1257
|
+
call,
|
|
1258
|
+
})), false, abort.signal, abortPromise);
|
|
1259
|
+
if (closedDuringTools)
|
|
1260
|
+
break;
|
|
1261
|
+
needsInference = true;
|
|
1262
|
+
continue;
|
|
1263
|
+
}
|
|
1264
|
+
// A natural stop keeps draining, and so does a provider-reported error: the
|
|
1265
|
+
// failed response never answers the queued messages, so they still get their
|
|
1266
|
+
// fresh inference — each drain consumes from a finite queue, so a persistently
|
|
1267
|
+
// failing provider cannot loop. A cancellation or a stream that ended without
|
|
1268
|
+
// a done event ends the turn with the queues intact.
|
|
1269
|
+
if (state !== "normal" && state !== "length" && state !== "error")
|
|
1270
|
+
break;
|
|
1271
|
+
}
|
|
1272
|
+
if (pendingError !== undefined &&
|
|
1273
|
+
this.#loaded !== undefined &&
|
|
1274
|
+
this.#unansweredCalls(this.#messages).length === 0) {
|
|
1275
|
+
await this.#appendFailure(pendingError);
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
catch (error) {
|
|
1279
|
+
this.#emit({
|
|
1280
|
+
type: "done",
|
|
1281
|
+
state: "error",
|
|
1282
|
+
kind: "internal_error",
|
|
1283
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1284
|
+
});
|
|
1285
|
+
// A turn that failed while it owed tool results must not leave them owed: the next
|
|
1286
|
+
// message would be appended after an unanswered call, which most providers reject
|
|
1287
|
+
// outright and no later turn would ever repair. When even that write is refused, the
|
|
1288
|
+
// note is not written either — the call stays last, and the next run settles it
|
|
1289
|
+
// before anything else is said.
|
|
1290
|
+
if (await this.#settleUnansweredCalls("The turn failed before this tool call finished.")) {
|
|
1291
|
+
await this.#appendFailure(error instanceof Error ? error.message : String(error));
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
this.#turnAborted = abort.signal.aborted;
|
|
1295
|
+
}
|
|
1296
|
+
/**
|
|
1297
|
+
* Whether this agent is picking up a run that was cut off rather than starting a fresh one,
|
|
1298
|
+
* and so owes an inference nobody asked for again.
|
|
1299
|
+
*
|
|
1300
|
+
* What is outstanding is read from the conversation: a tail that is a consumed message, a
|
|
1301
|
+
* tool result, or the note a failed turn left behind is owed an answer, while a replacement
|
|
1302
|
+
* written by a compaction is owed one only when the suffix it kept ends in a request.
|
|
1303
|
+
*
|
|
1304
|
+
* The pending record deliberately does not decide this, because one store may have several
|
|
1305
|
+
* live owners and there is only one record. An owner working right now leaves behind exactly
|
|
1306
|
+
* what a process that died would have left, so deciding from the record alone would have
|
|
1307
|
+
* each owner treat the others' work as abandoned and answer it a second time. What the
|
|
1308
|
+
* record adds is the knowledge that some run reached the model: a listener shown the
|
|
1309
|
+
* beginning of a block that will now never arrive is told to drop it. Only finished blocks
|
|
1310
|
+
* are ever persisted, so the conversation is intact and it is the view being corrected.
|
|
1311
|
+
*/
|
|
1312
|
+
#resumesInterruptedRun() {
|
|
1313
|
+
const owed = this.#lastRecordType === "user" ||
|
|
1314
|
+
this.#lastRecordType === "tool" ||
|
|
1315
|
+
this.#lastRecordType === "system" ||
|
|
1316
|
+
// A replacement record is not a question in itself, however it happens to end — but
|
|
1317
|
+
// it keeps the suffix that joined the conversation after its snapshot, and a consumed
|
|
1318
|
+
// message in that suffix still needs inference. Which kind of message ends the
|
|
1319
|
+
// replacement is not visible in the messages, so the rewrite that knew records it.
|
|
1320
|
+
(this.#lastRecordType === "compaction" && this.#lastRecordContinuesInference);
|
|
1321
|
+
if (owed && this.#inherited?.stage === "inference")
|
|
1322
|
+
this.#emit({ type: "block_reset" });
|
|
1323
|
+
return owed;
|
|
1324
|
+
}
|
|
1325
|
+
/** Load the durable state once. A failed load is not sticky: the next turn retries it. */
|
|
1326
|
+
async #ensureLoaded() {
|
|
1327
|
+
this.#loaded ??= this.#loadHistory().catch((error) => {
|
|
1328
|
+
this.#loaded = undefined;
|
|
1329
|
+
throw error;
|
|
1330
|
+
});
|
|
1331
|
+
await this.#loaded;
|
|
1332
|
+
}
|
|
1333
|
+
/**
|
|
1334
|
+
* Remember the conversation's true size, as the provider just measured it. The durable copy
|
|
1335
|
+
* lets a restarted agent keep knowing how large the conversation is without inferring it;
|
|
1336
|
+
* a failed write costs only that knowledge and never the response that produced it.
|
|
1337
|
+
*/
|
|
1338
|
+
async #recordContextTokens(tokens) {
|
|
1339
|
+
this.#contextTokens = tokens;
|
|
1340
|
+
try {
|
|
1341
|
+
await this.#persistenceLock.runInLock(this.#ctx, (lockCtx) => tokens === undefined
|
|
1342
|
+
? this.#persistence.deleteValue(lockCtx, "context")
|
|
1343
|
+
: this.#persistence.writeValue(lockCtx, "context", { tokens }));
|
|
1344
|
+
}
|
|
1345
|
+
catch {
|
|
1346
|
+
// A measurement is not worth failing a turn over; memory still carries it.
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
/**
|
|
1350
|
+
* Run the pending compaction, if any. The snapshot is taken before the turn's first
|
|
1351
|
+
* inference, with this pass being the only history writer, so nothing joins the history
|
|
1352
|
+
* mid-compaction; the suffix copy still keeps any such message, defensively. The replacement
|
|
1353
|
+
* is appended as a compaction record — the load-time reset point — and settles the shared
|
|
1354
|
+
* promise for every caller awaiting it. A provider failure rejects them and leaves the
|
|
1355
|
+
* history untouched.
|
|
1356
|
+
*/
|
|
1357
|
+
async #runCompaction(signal) {
|
|
1358
|
+
const pending = this.#compaction;
|
|
1359
|
+
if (pending === undefined)
|
|
1360
|
+
return;
|
|
1361
|
+
try {
|
|
1362
|
+
await this.#enterStage("compaction");
|
|
1363
|
+
const instructions = await this.#instructions();
|
|
1364
|
+
const session = await this.#ensureSession(instructions, await this.#tools());
|
|
1365
|
+
// The snapshot is the durable conversation, counted as records: everything appended
|
|
1366
|
+
// after this point is a suffix the replacement has to keep, whoever wrote it. Taking
|
|
1367
|
+
// the boundary from the store rather than from this instance's own memory means a
|
|
1368
|
+
// record another owner committed while the provider was summarizing survives the
|
|
1369
|
+
// clear-and-replace instead of being erased by it.
|
|
1370
|
+
// The boundary is the prefix this instance's memory was built from, not whatever
|
|
1371
|
+
// the store holds now: the provider is about to summarize that memory, and counting
|
|
1372
|
+
// a newer store would describe records it never saw as summarized.
|
|
1373
|
+
const snapshotCount = this.#loadedRecordCount;
|
|
1374
|
+
const snapshot = [...this.#messages];
|
|
1375
|
+
await this.#settled();
|
|
1376
|
+
// Provider compaction is this turn's work, so it runs on this turn's lifetime: an
|
|
1377
|
+
// abort reaches the provider operation itself rather than waiting for it to finish
|
|
1378
|
+
// work nobody wants any more.
|
|
1379
|
+
const result = await session.compact(withLifetime(this.#ctx, signal), {
|
|
1380
|
+
context: { instructions, messages: snapshot },
|
|
1381
|
+
...(this.#model === undefined ? {} : { model: this.#model }),
|
|
1382
|
+
});
|
|
1383
|
+
if (result.status === "failed") {
|
|
1384
|
+
throw new Error(result.message);
|
|
1385
|
+
}
|
|
1386
|
+
if (result.status === "completed") {
|
|
1387
|
+
await this.#persistenceLock.runInLock(this.#ctx, async (lockCtx) => {
|
|
1388
|
+
const records = await this.#persistence.load(lockCtx);
|
|
1389
|
+
const suffix = messagesFromRecords(records.slice(snapshotCount));
|
|
1390
|
+
const replaced = [...result.context.messages, ...suffix];
|
|
1391
|
+
// Only a message in the live suffix can require another inference. The
|
|
1392
|
+
// summary's own final message is provider-authored context, not a request.
|
|
1393
|
+
const continuesInference = suffix.length > 0 && needsInference(replaced);
|
|
1394
|
+
// Physically delete the superseded records and write the replacement —
|
|
1395
|
+
// which keeps the messages that stay — in one atomic step.
|
|
1396
|
+
await this.#recordTransaction(lockCtx, async (txCtx) => {
|
|
1397
|
+
await this.#persistence.clearRecords(txCtx);
|
|
1398
|
+
await this.#persistence.append(txCtx, {
|
|
1399
|
+
type: "compaction",
|
|
1400
|
+
messages: replaced,
|
|
1401
|
+
...(continuesInference ? { continuesInference: true } : {}),
|
|
1402
|
+
});
|
|
1403
|
+
});
|
|
1404
|
+
this.#messages = [...replaced];
|
|
1405
|
+
this.#lastRecordType = "compaction";
|
|
1406
|
+
this.#lastRecordContinuesInference = continuesInference;
|
|
1407
|
+
// The store is now the one replacement record, and memory is exactly it.
|
|
1408
|
+
this.#loadedRecordCount = 1;
|
|
1409
|
+
});
|
|
1410
|
+
// The conversation the measurement described is gone; its size is unknown
|
|
1411
|
+
// again until the next response measures the replacement.
|
|
1412
|
+
await this.#recordContextTokens(undefined);
|
|
1413
|
+
}
|
|
1414
|
+
this.#compaction = undefined;
|
|
1415
|
+
pending.resolve();
|
|
1416
|
+
}
|
|
1417
|
+
catch (error) {
|
|
1418
|
+
this.#compaction = undefined;
|
|
1419
|
+
pending.reject(error);
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
/**
|
|
1423
|
+
* Answer every call the last response left unanswered with an error result, and report
|
|
1424
|
+
* whether the conversation now owes none. The rule this keeps is that the durable
|
|
1425
|
+
* conversation never holds a tool call without its result: a call is settled while it is
|
|
1426
|
+
* still the last thing said, because a result appended after anything else would sit in the
|
|
1427
|
+
* wrong place, where nothing could put it right again. So a caller told the settling did not
|
|
1428
|
+
* happen must append nothing either — leaving the call last is what lets a later attempt,
|
|
1429
|
+
* here or after a restart, still answer it.
|
|
1430
|
+
*/
|
|
1431
|
+
async #settleUnansweredCalls(reason) {
|
|
1432
|
+
// Nothing is known about the conversation, so nothing may be said about it.
|
|
1433
|
+
if (this.#loaded === undefined)
|
|
1434
|
+
return false;
|
|
1435
|
+
const owed = this.#unansweredCalls(this.#messages);
|
|
1436
|
+
if (owed.length === 0)
|
|
1437
|
+
return true;
|
|
1438
|
+
let settled = false;
|
|
1439
|
+
try {
|
|
1440
|
+
await this.#persistenceLock.runInLock(this.#ctx, async (lockCtx) => {
|
|
1441
|
+
// A call the durable batch still holds belongs to the resume, which answers it
|
|
1442
|
+
// properly — and re-executes it when the tool is durable. Settling it here as
|
|
1443
|
+
// well would give the conversation two results for one call.
|
|
1444
|
+
const pending = await this.#persistence.readValues(lockCtx, "tool.");
|
|
1445
|
+
const dispatched = new Set(pending.map(({ value }) => value.callId));
|
|
1446
|
+
const results = owed
|
|
1447
|
+
.filter((call) => !dispatched.has(call.callId))
|
|
1448
|
+
.map((call) => toolFailure(call.callId, reason));
|
|
1449
|
+
if (results.length === 0)
|
|
1450
|
+
return;
|
|
1451
|
+
await this.#recordTransaction(lockCtx, async (txCtx) => {
|
|
1452
|
+
for (const result of results) {
|
|
1453
|
+
await this.#appendRecord(txCtx, { type: "tool", message: result });
|
|
1454
|
+
}
|
|
1455
|
+
});
|
|
1456
|
+
this.#messages.push(...results);
|
|
1457
|
+
});
|
|
1458
|
+
settled = true;
|
|
1459
|
+
}
|
|
1460
|
+
catch {
|
|
1461
|
+
// The turn is already failing; a restart settles what this could not, as long as
|
|
1462
|
+
// nothing is written over the top of the call in the meantime.
|
|
1463
|
+
}
|
|
1464
|
+
return settled;
|
|
1465
|
+
}
|
|
1466
|
+
/**
|
|
1467
|
+
* Append one record and keep count of it. Every record this instance writes is one more that
|
|
1468
|
+
* its memory accounts for, and a rewrite has to know exactly where its own knowledge ends —
|
|
1469
|
+
* so appending and counting are one step rather than two a caller could get out of order.
|
|
1470
|
+
*/
|
|
1471
|
+
async #appendRecord(ctx, record) {
|
|
1472
|
+
await this.#persistence.append(ctx, record);
|
|
1473
|
+
this.#loadedRecordCount += 1;
|
|
1474
|
+
}
|
|
1475
|
+
/**
|
|
1476
|
+
* A transaction whose effect on the record count unwinds with it. Records staged by a
|
|
1477
|
+
* transaction that rolls back were never written, and memory never took them either, so the
|
|
1478
|
+
* count must not go on claiming them.
|
|
1479
|
+
*/
|
|
1480
|
+
async #recordTransaction(ctx, work) {
|
|
1481
|
+
const counted = this.#loadedRecordCount;
|
|
1482
|
+
// The outstanding work unwinds with the records for the same reason: a stage staged by a
|
|
1483
|
+
// transaction that rolled back was never written, and memory claiming it would make the
|
|
1484
|
+
// agent skip the write that actually records what it is doing.
|
|
1485
|
+
const pending = this.#pending;
|
|
1486
|
+
const written = this.#pendingWritten;
|
|
1487
|
+
try {
|
|
1488
|
+
return await this.#persistence.transaction(ctx, work);
|
|
1489
|
+
}
|
|
1490
|
+
catch (error) {
|
|
1491
|
+
this.#loadedRecordCount = counted;
|
|
1492
|
+
this.#pending = pending;
|
|
1493
|
+
this.#pendingWritten = written;
|
|
1494
|
+
throw error;
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
/**
|
|
1498
|
+
* Surface a failed turn to the conversation as a system message, so the next inference sees
|
|
1499
|
+
* what went wrong. Only unrecovered failures reach here — a later successful response in the
|
|
1500
|
+
* same turn clears its error without a trace. Skipped when the history never loaded, since
|
|
1501
|
+
* there is no context to append to; its own failure is swallowed, so surfacing a failure can
|
|
1502
|
+
* never cause another.
|
|
1503
|
+
*/
|
|
1504
|
+
async #appendFailure(message) {
|
|
1505
|
+
if (this.#loaded === undefined)
|
|
1506
|
+
return;
|
|
1507
|
+
const failure = {
|
|
1508
|
+
role: "system",
|
|
1509
|
+
content: [{ type: "text", text: `The last turn failed: ${message}` }],
|
|
1510
|
+
};
|
|
1511
|
+
try {
|
|
1512
|
+
await this.#persistenceLock.runInLock(this.#ctx, async (lockCtx) => {
|
|
1513
|
+
await this.#appendRecord(lockCtx, { type: "system", message: failure });
|
|
1514
|
+
this.#messages.push(failure);
|
|
1515
|
+
});
|
|
1516
|
+
}
|
|
1517
|
+
catch {
|
|
1518
|
+
// The turn already failed; a failing write must not escalate it.
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
/**
|
|
1522
|
+
* Move the oldest queued message — or, in "all" mode, every queued message — into the main
|
|
1523
|
+
* context store and the in-memory history. The moves run in one transaction, so a message
|
|
1524
|
+
* is never durable in both stores or neither, and memory changes only after the commit.
|
|
1525
|
+
*/
|
|
1526
|
+
async #consumeQueue(queue, mode, prefix) {
|
|
1527
|
+
return await this.#persistenceLock.runInLock(this.#ctx, async (lockCtx) => {
|
|
1528
|
+
if (queue.length === 0)
|
|
1529
|
+
return false;
|
|
1530
|
+
// The durable queue, not memory, decides what is left to consume. Another owner over
|
|
1531
|
+
// the same store may have taken these entries already, and a message answered twice
|
|
1532
|
+
// is as wrong as one answered never.
|
|
1533
|
+
const durable = new Set((await this.#persistence.readValues(lockCtx, prefix)).map(({ key }) => key));
|
|
1534
|
+
const remaining = queue.filter((entry) => durable.has(entry.key));
|
|
1535
|
+
if (remaining.length !== queue.length)
|
|
1536
|
+
queue.splice(0, queue.length, ...remaining);
|
|
1537
|
+
if (queue.length === 0)
|
|
1538
|
+
return false;
|
|
1539
|
+
const count = mode === "all" ? queue.length : 1;
|
|
1540
|
+
const batch = queue.slice(0, count);
|
|
1541
|
+
// Settings carried by the consumed messages become the effective settings for the
|
|
1542
|
+
// inference that follows, each defined field superseding the previous value. The
|
|
1543
|
+
// effective values are persisted alongside the consumption so a restart keeps them.
|
|
1544
|
+
let provider = this.#providerId;
|
|
1545
|
+
let model = this.#model;
|
|
1546
|
+
let effort = this.#effort;
|
|
1547
|
+
let serviceTier = this.#serviceTier;
|
|
1548
|
+
let changed = false;
|
|
1549
|
+
for (const entry of batch) {
|
|
1550
|
+
if (entry.options.provider !== undefined) {
|
|
1551
|
+
provider = entry.options.provider;
|
|
1552
|
+
changed = true;
|
|
1553
|
+
}
|
|
1554
|
+
if (entry.options.model !== undefined) {
|
|
1555
|
+
model = entry.options.model;
|
|
1556
|
+
changed = true;
|
|
1557
|
+
}
|
|
1558
|
+
if (entry.options.effort !== undefined) {
|
|
1559
|
+
effort = entry.options.effort;
|
|
1560
|
+
changed = true;
|
|
1561
|
+
}
|
|
1562
|
+
if (entry.options.serviceTier !== undefined) {
|
|
1563
|
+
serviceTier = entry.options.serviceTier;
|
|
1564
|
+
changed = true;
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
// A provider or model change is checked against the provider-model compatibility
|
|
1568
|
+
// matrix. An incompatible change resets the conversation: the history is erased
|
|
1569
|
+
// completely, the old provider session is destroyed, and the `modelChanged` hook
|
|
1570
|
+
// may inject one handoff system message at the very beginning of the fresh
|
|
1571
|
+
// context. A compatible provider change keeps the history but still gets a fresh
|
|
1572
|
+
// session, since a session is bound to the provider that created it.
|
|
1573
|
+
const selectionChanged = provider !== this.#providerId || model !== this.#model;
|
|
1574
|
+
let reset = false;
|
|
1575
|
+
let injected;
|
|
1576
|
+
if (selectionChanged) {
|
|
1577
|
+
if (this.#model !== undefined && model !== undefined) {
|
|
1578
|
+
const previousType = this.#providers.typeOf(this.#providerId);
|
|
1579
|
+
const nextType = this.#providers.typeOf(provider);
|
|
1580
|
+
reset =
|
|
1581
|
+
previousType === null ||
|
|
1582
|
+
nextType === null ||
|
|
1583
|
+
!areProviderModelsCompatible({
|
|
1584
|
+
modelId: this.#model,
|
|
1585
|
+
providerId: this.#providerId,
|
|
1586
|
+
providerType: previousType,
|
|
1587
|
+
}, {
|
|
1588
|
+
modelId: model,
|
|
1589
|
+
providerId: provider,
|
|
1590
|
+
providerType: nextType,
|
|
1591
|
+
});
|
|
1592
|
+
}
|
|
1593
|
+
else {
|
|
1594
|
+
// A selection without a model on either side cannot be judged compatible.
|
|
1595
|
+
reset = model !== this.#model;
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
const consumed = [];
|
|
1599
|
+
try {
|
|
1600
|
+
await this.#recordTransaction(lockCtx, async (txCtx) => {
|
|
1601
|
+
if (selectionChanged) {
|
|
1602
|
+
if (this.#hooks.modelChanged !== undefined && model !== undefined) {
|
|
1603
|
+
// The hook runs while the persistence lock is held and inside the
|
|
1604
|
+
// transaction that commits the switch, so its store executes directly on
|
|
1605
|
+
// that transaction: what it writes lands and rolls back with the change
|
|
1606
|
+
// it was told about, never on its own. The context it is given ends with
|
|
1607
|
+
// the transaction, so a store it keeps cannot outlive the switch.
|
|
1608
|
+
const committed = new AbortController();
|
|
1609
|
+
// Derived from the transaction's own context, which is what makes
|
|
1610
|
+
// the hook's writes part of the switch rather than a second,
|
|
1611
|
+
// separate commit, and ending with it.
|
|
1612
|
+
const changeLifetime = withLifetime(withAgentContext(txCtx, {
|
|
1613
|
+
id: this.id,
|
|
1614
|
+
provider,
|
|
1615
|
+
model,
|
|
1616
|
+
effort,
|
|
1617
|
+
serviceTier,
|
|
1618
|
+
}), committed.signal);
|
|
1619
|
+
const changeCtx = withAgentRunKV(withAgentKV(changeLifetime, this.#kv), this.#runKV);
|
|
1620
|
+
try {
|
|
1621
|
+
injected = await this.#hooks.modelChanged(changeCtx, {
|
|
1622
|
+
previousModel: this.#model,
|
|
1623
|
+
model,
|
|
1624
|
+
previousProvider: this.#providerId,
|
|
1625
|
+
provider,
|
|
1626
|
+
providers: this.#providers,
|
|
1627
|
+
previousProviderInstance: this.#providers.get(this.#providerId),
|
|
1628
|
+
providerInstance: this.#providers.get(provider),
|
|
1629
|
+
wasReset: reset,
|
|
1630
|
+
});
|
|
1631
|
+
}
|
|
1632
|
+
catch {
|
|
1633
|
+
// A failing handoff must not cost the conversation: an incompatible
|
|
1634
|
+
// switch is rejected outright — the previous selection stays
|
|
1635
|
+
// effective and the history is not cleared. A compatible change
|
|
1636
|
+
// proceeds; the hook only observed it.
|
|
1637
|
+
if (reset) {
|
|
1638
|
+
provider = this.#providerId;
|
|
1639
|
+
model = this.#model;
|
|
1640
|
+
reset = false;
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
finally {
|
|
1644
|
+
// The store belonged to the hook's call, not to the hook.
|
|
1645
|
+
committed.abort();
|
|
1646
|
+
}
|
|
1647
|
+
if (!reset)
|
|
1648
|
+
injected = undefined;
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
consumed.length = 0;
|
|
1652
|
+
// Each entry is claimed as it is consumed: the delete answers whether this
|
|
1653
|
+
// owner is the one that took it, so a message shared by two live owners over
|
|
1654
|
+
// one store is answered exactly once. Claiming first also means losing the
|
|
1655
|
+
// whole batch rolls the transaction back before it has changed anything.
|
|
1656
|
+
for (const entry of batch) {
|
|
1657
|
+
const claimed = await this.#persistence.deleteValueIfPresent(txCtx, entry.key);
|
|
1658
|
+
if (claimed)
|
|
1659
|
+
consumed.push(entry);
|
|
1660
|
+
}
|
|
1661
|
+
if (consumed.length === 0)
|
|
1662
|
+
throw LOST_QUEUE_RACE;
|
|
1663
|
+
if (reset) {
|
|
1664
|
+
await this.#persistence.clearRecords(txCtx);
|
|
1665
|
+
this.#loadedRecordCount = 0;
|
|
1666
|
+
// The erased conversation is what the measurement described.
|
|
1667
|
+
await this.#persistence.deleteValue(txCtx, "context");
|
|
1668
|
+
if (injected !== undefined) {
|
|
1669
|
+
await this.#appendRecord(txCtx, {
|
|
1670
|
+
type: "system",
|
|
1671
|
+
message: injected,
|
|
1672
|
+
});
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1675
|
+
for (const entry of consumed) {
|
|
1676
|
+
await this.#appendRecord(txCtx, {
|
|
1677
|
+
type: "user",
|
|
1678
|
+
message: entry.message,
|
|
1679
|
+
});
|
|
1680
|
+
}
|
|
1681
|
+
if (changed) {
|
|
1682
|
+
await this.#persistence.writeValue(txCtx, "settings", {
|
|
1683
|
+
provider,
|
|
1684
|
+
...(model === undefined ? {} : { model }),
|
|
1685
|
+
...(effort === undefined ? {} : { effort }),
|
|
1686
|
+
...(serviceTier === undefined ? {} : { serviceTier }),
|
|
1687
|
+
});
|
|
1688
|
+
}
|
|
1689
|
+
// Consuming a message is precisely the act that makes an inference owed, so
|
|
1690
|
+
// the two commit as one. A crash cannot land between them and leave a
|
|
1691
|
+
// message in the conversation that nothing remembers having to answer.
|
|
1692
|
+
await this.#recordPending(txCtx, { stage: "inference" });
|
|
1693
|
+
});
|
|
1694
|
+
}
|
|
1695
|
+
catch (error) {
|
|
1696
|
+
if (error !== LOST_QUEUE_RACE)
|
|
1697
|
+
throw error;
|
|
1698
|
+
// Another owner answered all of them. They are gone from the store, so they are
|
|
1699
|
+
// dropped from memory too, and this turn simply has nothing to inject.
|
|
1700
|
+
queue.splice(0, count);
|
|
1701
|
+
return false;
|
|
1702
|
+
}
|
|
1703
|
+
queue.splice(0, count);
|
|
1704
|
+
if (reset) {
|
|
1705
|
+
this.#messages = injected === undefined ? [] : [injected];
|
|
1706
|
+
this.#contextTokens = undefined;
|
|
1707
|
+
}
|
|
1708
|
+
if (reset || provider !== this.#providerId) {
|
|
1709
|
+
const session = this.#session;
|
|
1710
|
+
this.#session = undefined;
|
|
1711
|
+
try {
|
|
1712
|
+
await session?.destroy();
|
|
1713
|
+
}
|
|
1714
|
+
catch {
|
|
1715
|
+
// The change already committed; a failing destroy must not undo it.
|
|
1716
|
+
}
|
|
1717
|
+
}
|
|
1718
|
+
this.#messages.push(...consumed.map((entry) => entry.message));
|
|
1719
|
+
// This turn is answering the request that these messages raised. A send accepted
|
|
1720
|
+
// while the turn was already running raised it again, and letting that stand would
|
|
1721
|
+
// buy an extra turn with an empty queue and a full set of lifecycle hooks.
|
|
1722
|
+
if (this.#steering.length === 0 &&
|
|
1723
|
+
this.#sends.length === 0 &&
|
|
1724
|
+
this.#compaction === undefined) {
|
|
1725
|
+
this.#turnRequested = false;
|
|
1726
|
+
}
|
|
1727
|
+
if (changed) {
|
|
1728
|
+
this.#providerId = provider;
|
|
1729
|
+
this.#model = model;
|
|
1730
|
+
this.#effort = effort;
|
|
1731
|
+
this.#serviceTier = serviceTier;
|
|
1732
|
+
this.#ctx = this.#deriveCtx();
|
|
1733
|
+
}
|
|
1734
|
+
return true;
|
|
1735
|
+
});
|
|
1736
|
+
}
|
|
1737
|
+
/**
|
|
1738
|
+
* Replace the in-memory state with the durable one. The persistence lock guarantees every
|
|
1739
|
+
* message already in memory reached storage first, so the load result supersedes memory
|
|
1740
|
+
* entirely: the main store rebuilds the context, and the sorted queue keys rebuild the
|
|
1741
|
+
* not-yet-consumed queues. Consecutive block records reassemble into one assistant message.
|
|
1742
|
+
*/
|
|
1743
|
+
async #loadHistory() {
|
|
1744
|
+
await this.#persistenceLock.runInLock(this.#ctx, async (lockCtx) => {
|
|
1745
|
+
const records = await this.#persistence.load(lockCtx);
|
|
1746
|
+
const last = records[records.length - 1];
|
|
1747
|
+
this.#lastRecordType = last?.type;
|
|
1748
|
+
this.#lastRecordContinuesInference =
|
|
1749
|
+
last?.type === "compaction" && last.continuesInference === true;
|
|
1750
|
+
this.#loadedRecordCount = records.length;
|
|
1751
|
+
let restored = messagesFromRecords(records);
|
|
1752
|
+
const steering = await this.#persistence.readValues(lockCtx, "steering.");
|
|
1753
|
+
const sends = await this.#persistence.readValues(lockCtx, "send.");
|
|
1754
|
+
const pendingTools = await this.#persistence.readValues(lockCtx, "tool.");
|
|
1755
|
+
const settings = await this.#persistence.readValues(lockCtx, "settings");
|
|
1756
|
+
const context = await this.#persistence.readValues(lockCtx, "context");
|
|
1757
|
+
this.#messages = restored;
|
|
1758
|
+
// The measured size of the restored context, so the first turn after a reload can
|
|
1759
|
+
// still decide whether it needs a compaction.
|
|
1760
|
+
const measured = context[0]?.value;
|
|
1761
|
+
this.#contextTokens = measured?.tokens;
|
|
1762
|
+
const entry = (key, value) => {
|
|
1763
|
+
const envelope = value;
|
|
1764
|
+
return { key, message: envelope.message, options: envelope.options ?? {} };
|
|
1765
|
+
};
|
|
1766
|
+
this.#steering = steering.map(({ key, value }) => entry(key, value));
|
|
1767
|
+
this.#sends = sends.map(({ key, value }) => entry(key, value));
|
|
1768
|
+
// The persisted settings are the complete effective triple from the last change; an
|
|
1769
|
+
// absent field means that setting was effectively unset when it was written.
|
|
1770
|
+
const persisted = settings[0]?.value;
|
|
1771
|
+
if (persisted !== undefined) {
|
|
1772
|
+
if (persisted.provider !== undefined)
|
|
1773
|
+
this.#providerId = persisted.provider;
|
|
1774
|
+
this.#model = persisted.model;
|
|
1775
|
+
this.#effort = persisted.effort;
|
|
1776
|
+
this.#serviceTier = persisted.serviceTier;
|
|
1777
|
+
this.#ctx = this.#deriveCtx();
|
|
1778
|
+
}
|
|
1779
|
+
this.#pendingTools = pendingTools.map(({ key, value }) => ({
|
|
1780
|
+
key,
|
|
1781
|
+
call: value,
|
|
1782
|
+
}));
|
|
1783
|
+
this.#pendingToolsUndispatched = false;
|
|
1784
|
+
if (this.#pendingTools.length === 0) {
|
|
1785
|
+
// A crash between the response's last block and the batch commit leaves calls
|
|
1786
|
+
// the conversation still owes results for, with nothing durable to resume: the
|
|
1787
|
+
// context would keep an unanswered tool call for ever, which most providers
|
|
1788
|
+
// reject outright. They are recovered as the batch that was about to be
|
|
1789
|
+
// dispatched.
|
|
1790
|
+
const owed = this.#unansweredCalls(restored);
|
|
1791
|
+
if (owed.length > 0) {
|
|
1792
|
+
this.#pendingTools = owed.map((call, index) => ({
|
|
1793
|
+
key: this.#toolKey(index, call.callId),
|
|
1794
|
+
call,
|
|
1795
|
+
}));
|
|
1796
|
+
this.#pendingToolsUndispatched = true;
|
|
1797
|
+
}
|
|
1798
|
+
}
|
|
1799
|
+
});
|
|
1800
|
+
}
|
|
1801
|
+
/**
|
|
1802
|
+
* Run one batch of tool calls. The whole batch is committed to the sorted store before any
|
|
1803
|
+
* call executes, so a crash mid-batch leaves a durable record of the calls still owed a
|
|
1804
|
+
* result. All calls run in parallel, but results land strictly in call order: a finished
|
|
1805
|
+
* result waits until every earlier call in the batch has committed, and each commit appends
|
|
1806
|
+
* the tool record and removes the pending entry in one transaction before memory changes.
|
|
1807
|
+
* On resume, only durable tools execute again; the rest become error results. An abort
|
|
1808
|
+
* settles every call still running as an aborted error result, so the batch always leaves a
|
|
1809
|
+
* complete context behind.
|
|
1810
|
+
*/
|
|
1811
|
+
async #runToolBatch(entries, resume, signal, abortPromise) {
|
|
1812
|
+
if (!resume) {
|
|
1813
|
+
await this.#persistenceLock.runInLock(this.#ctx, (lockCtx) => this.#recordTransaction(lockCtx, async (txCtx) => {
|
|
1814
|
+
for (const entry of entries) {
|
|
1815
|
+
await this.#persistence.writeValue(txCtx, entry.key, entry.call);
|
|
1816
|
+
}
|
|
1817
|
+
// The batch and the stage that describes it commit together. A crash can
|
|
1818
|
+
// then never find calls owed with no record of a run owing them, nor a run
|
|
1819
|
+
// recorded as running tools that were never written.
|
|
1820
|
+
await this.#recordPending(txCtx, { stage: "tools" });
|
|
1821
|
+
}));
|
|
1822
|
+
}
|
|
1823
|
+
else {
|
|
1824
|
+
await this.#enterStage("tools");
|
|
1825
|
+
}
|
|
1826
|
+
const results = new Array(entries.length);
|
|
1827
|
+
// Every execution actually started, whether or not its result reached the conversation.
|
|
1828
|
+
const running = [];
|
|
1829
|
+
let closedDuringTools = false;
|
|
1830
|
+
let committed = 0;
|
|
1831
|
+
// A failed commit ends the turn, and the turn records its own failure at the tail. A
|
|
1832
|
+
// sibling still running at that moment no longer owns the append-only tail: its result
|
|
1833
|
+
// would land behind the failure record, where no later turn could make sense of it. So
|
|
1834
|
+
// the first failed commit closes the batch to every result that was not committed yet.
|
|
1835
|
+
let commitFailed = false;
|
|
1836
|
+
const commitReady = async () => {
|
|
1837
|
+
if (commitFailed)
|
|
1838
|
+
return;
|
|
1839
|
+
try {
|
|
1840
|
+
await this.#persistenceLock.runInLock(this.#ctx, async (lockCtx) => {
|
|
1841
|
+
while (committed < entries.length) {
|
|
1842
|
+
const entry = entries[committed];
|
|
1843
|
+
const result = results[committed];
|
|
1844
|
+
if (entry === undefined || result === undefined)
|
|
1845
|
+
return;
|
|
1846
|
+
await this.#recordTransaction(lockCtx, async (txCtx) => {
|
|
1847
|
+
await this.#appendRecord(txCtx, {
|
|
1848
|
+
type: "tool",
|
|
1849
|
+
message: result,
|
|
1850
|
+
});
|
|
1851
|
+
// The call is answered, so what was kept only to let it be retried
|
|
1852
|
+
// goes with it. What the tool itself wrote under its own call scope
|
|
1853
|
+
// stays: that is the tool's state, not the batch's bookkeeping, and
|
|
1854
|
+
// an owner may still want to read what a finished call recorded.
|
|
1855
|
+
await this.#persistence.deleteValue(txCtx, entry.key);
|
|
1856
|
+
});
|
|
1857
|
+
this.#messages.push(result);
|
|
1858
|
+
committed += 1;
|
|
1859
|
+
}
|
|
1860
|
+
// The batch is fully answered, so its results are what the model is owed a
|
|
1861
|
+
// response to. Recording that here means a crash between the last result and
|
|
1862
|
+
// the next request resumes as an inference rather than as a finished batch.
|
|
1863
|
+
if (committed === entries.length) {
|
|
1864
|
+
await this.#recordPending(lockCtx, { stage: "inference" });
|
|
1865
|
+
}
|
|
1866
|
+
});
|
|
1867
|
+
}
|
|
1868
|
+
catch (error) {
|
|
1869
|
+
commitFailed = true;
|
|
1870
|
+
throw error;
|
|
1871
|
+
}
|
|
1872
|
+
};
|
|
1873
|
+
this.#toolsRunning += 1;
|
|
1874
|
+
const batch = Promise.all(entries.map(async (entry, index) => {
|
|
1875
|
+
let outcome;
|
|
1876
|
+
if (resume && !(await this.#isDurable(entry.call))) {
|
|
1877
|
+
outcome = toolFailure(entry.call.callId, "The tool call was interrupted by a restart and was not retried.");
|
|
1878
|
+
}
|
|
1879
|
+
else {
|
|
1880
|
+
const execution = this.#executeToolCall(withLifetime(this.#ctx, signal), entry.call);
|
|
1881
|
+
running.push(execution);
|
|
1882
|
+
outcome = await Promise.race([execution, abortPromise, this.#closingTools()]);
|
|
1883
|
+
}
|
|
1884
|
+
if (outcome === ABORTED && !signal.aborted)
|
|
1885
|
+
closedDuringTools = true;
|
|
1886
|
+
results[index] =
|
|
1887
|
+
outcome === ABORTED
|
|
1888
|
+
? {
|
|
1889
|
+
role: "tool",
|
|
1890
|
+
callId: entry.call.callId,
|
|
1891
|
+
content: [
|
|
1892
|
+
{
|
|
1893
|
+
type: "text",
|
|
1894
|
+
text: signal.aborted
|
|
1895
|
+
? "The tool call was aborted."
|
|
1896
|
+
: "The tool call was abandoned when the agent closed.",
|
|
1897
|
+
},
|
|
1898
|
+
],
|
|
1899
|
+
isError: true,
|
|
1900
|
+
}
|
|
1901
|
+
: outcome;
|
|
1902
|
+
await commitReady();
|
|
1903
|
+
}));
|
|
1904
|
+
try {
|
|
1905
|
+
await batch;
|
|
1906
|
+
}
|
|
1907
|
+
finally {
|
|
1908
|
+
this.#toolsRunning -= 1;
|
|
1909
|
+
}
|
|
1910
|
+
// An abort settles the call in the conversation, but it does not settle the call: the
|
|
1911
|
+
// tool is still running, and the session must not make its next request while that work
|
|
1912
|
+
// is in flight. The batch does not wait for it, so a tool that never notices the abort
|
|
1913
|
+
// cannot hold the turn open.
|
|
1914
|
+
this.#settleLater(Promise.allSettled(running), "tool");
|
|
1915
|
+
return closedDuringTools;
|
|
1916
|
+
}
|
|
1917
|
+
/**
|
|
1918
|
+
* Settles once close begins, so a batch stops waiting for tools that a shutdown may itself
|
|
1919
|
+
* be blocking. A close that has already begun settles it at once, since a listener added
|
|
1920
|
+
* afterwards would never hear the event that already happened.
|
|
1921
|
+
*/
|
|
1922
|
+
#closingTools() {
|
|
1923
|
+
if (this.#closeController.signal.aborted)
|
|
1924
|
+
return Promise.resolve(ABORTED);
|
|
1925
|
+
return new Promise((resolve) => {
|
|
1926
|
+
this.#closeController.signal.addEventListener("abort", () => resolve(ABORTED), {
|
|
1927
|
+
once: true,
|
|
1928
|
+
});
|
|
1929
|
+
});
|
|
1930
|
+
}
|
|
1931
|
+
/** Whether this call's tool may safely be executed again after a restart interrupted it. */
|
|
1932
|
+
async #isDurable(call) {
|
|
1933
|
+
const tool = (await this.#tools()).find((candidate) => candidate.name === call.name && candidate.namespace === call.namespace);
|
|
1934
|
+
return tool?.durable === true;
|
|
1935
|
+
}
|
|
1936
|
+
/**
|
|
1937
|
+
* The tool calls a restored conversation ends on without any results. Only a trailing
|
|
1938
|
+
* response can hold them: results are appended immediately after the batch that produced
|
|
1939
|
+
* them, so any earlier call is already settled.
|
|
1940
|
+
*/
|
|
1941
|
+
#unansweredCalls(messages) {
|
|
1942
|
+
const last = messages[messages.length - 1];
|
|
1943
|
+
if (last?.role !== "assistant")
|
|
1944
|
+
return [];
|
|
1945
|
+
return last.content.filter((block) => block.type === "tool_call" && block.server !== true);
|
|
1946
|
+
}
|
|
1947
|
+
/** Sorted by position in the batch; only one batch is ever pending at a time. */
|
|
1948
|
+
#toolKey(index, callId) {
|
|
1949
|
+
return `tool.${String(index).padStart(6, "0")}.${callId}`;
|
|
1950
|
+
}
|
|
1951
|
+
/**
|
|
1952
|
+
* Run one tool call; every failure becomes an error tool result instead of an exception.
|
|
1953
|
+
* The context carries the turn's abort signal as its lifetime, so a running tool can
|
|
1954
|
+
* observe cancellation and stop its own work.
|
|
1955
|
+
*/
|
|
1956
|
+
async #executeToolCall(ctx, call) {
|
|
1957
|
+
const failure = (text) => ({
|
|
1958
|
+
role: "tool",
|
|
1959
|
+
callId: call.callId,
|
|
1960
|
+
content: [{ type: "text", text }],
|
|
1961
|
+
isError: true,
|
|
1962
|
+
});
|
|
1963
|
+
const tool = (await this.#tools()).find((candidate) => candidate.name === call.name && candidate.namespace === call.namespace);
|
|
1964
|
+
if (tool === undefined) {
|
|
1965
|
+
return failure(`Tool "${call.name}" is not available.`);
|
|
1966
|
+
}
|
|
1967
|
+
if (call.incomplete === true) {
|
|
1968
|
+
return failure("The tool call was incomplete and was not executed.");
|
|
1969
|
+
}
|
|
1970
|
+
let args;
|
|
1971
|
+
try {
|
|
1972
|
+
args = call.arguments.trim().length === 0 ? {} : JSON.parse(call.arguments);
|
|
1973
|
+
}
|
|
1974
|
+
catch {
|
|
1975
|
+
return failure(`The arguments for "${call.name}" were not valid JSON.`);
|
|
1976
|
+
}
|
|
1977
|
+
if (tool.parameters !== undefined && !Value.Check(tool.parameters, args)) {
|
|
1978
|
+
return failure(`The arguments for "${call.name}" did not match its schema.`);
|
|
1979
|
+
}
|
|
1980
|
+
try {
|
|
1981
|
+
// A tool execution persists under its own call ID, never in another call's scope.
|
|
1982
|
+
const callCtx = withAgentTaskContext(withAgentRunKV(withAgentKV(ctx, this.#kv.scoped("call", call.callId)), this.#runKV.scoped("call", call.callId)), taskContextBeforeToolCall(this.#messages, call.callId));
|
|
1983
|
+
let executed;
|
|
1984
|
+
const execute = () => (executed ??= Promise.resolve().then(async () => await tool.execute(callCtx, args)));
|
|
1985
|
+
const result = this.#hooks.aroundToolExecution === undefined
|
|
1986
|
+
? await execute()
|
|
1987
|
+
: await this.#hooks.aroundToolExecution(callCtx, {
|
|
1988
|
+
callId: call.callId,
|
|
1989
|
+
tool,
|
|
1990
|
+
arguments: args,
|
|
1991
|
+
execute,
|
|
1992
|
+
});
|
|
1993
|
+
if (!Value.Check(tool.returnType, result)) {
|
|
1994
|
+
return failure(`Tool "${call.name}" returned an invalid result.`);
|
|
1995
|
+
}
|
|
1996
|
+
const content = tool.toLLM(result);
|
|
1997
|
+
const isError = tool.isError?.(result) === true;
|
|
1998
|
+
return {
|
|
1999
|
+
role: "tool",
|
|
2000
|
+
callId: call.callId,
|
|
2001
|
+
content: [...content],
|
|
2002
|
+
...(isError ? { isError: true } : {}),
|
|
2003
|
+
};
|
|
2004
|
+
}
|
|
2005
|
+
catch (error) {
|
|
2006
|
+
return failure(error instanceof Error ? error.message : String(error));
|
|
2007
|
+
}
|
|
2008
|
+
}
|
|
2009
|
+
/**
|
|
2010
|
+
* A key that sorts after every entry the queue already holds and belongs to no other
|
|
2011
|
+
* writer. The order comes from the store rather than from a counter this instance keeps,
|
|
2012
|
+
* because a restarted agent starts counting again and would otherwise reuse a key. The
|
|
2013
|
+
* trailing writer segment settles the rest: two owners that read the same tail at the same
|
|
2014
|
+
* millisecond still produce different keys, so an acknowledged message can never be
|
|
2015
|
+
* overwritten by one accepted elsewhere — only ordered arbitrarily against it, which is all
|
|
2016
|
+
* that simultaneous acceptance can mean. Reading the tail also keeps the order right when
|
|
2017
|
+
* the clock goes backwards.
|
|
2018
|
+
*/
|
|
2019
|
+
async #queueKey(ctx, prefix) {
|
|
2020
|
+
const existing = await this.#persistence.readValues(ctx, prefix);
|
|
2021
|
+
const last = existing[existing.length - 1]?.key;
|
|
2022
|
+
const time = String(Date.now()).padStart(14, "0");
|
|
2023
|
+
const key = (slot, sequence) => `${prefix}${slot}.${String(sequence).padStart(6, "0")}.${this.#writer}`;
|
|
2024
|
+
if (last === undefined)
|
|
2025
|
+
return key(time, 0);
|
|
2026
|
+
const [lastSlot, lastSequence] = last.slice(prefix.length).split(".");
|
|
2027
|
+
if (lastSlot === undefined || time > lastSlot)
|
|
2028
|
+
return key(time, 0);
|
|
2029
|
+
// The queue already holds an entry from this millisecond, or from one still to come on
|
|
2030
|
+
// a clock that went backwards: continue the sequence rather than starting it again.
|
|
2031
|
+
return key(lastSlot, Number(lastSequence) + 1);
|
|
2032
|
+
}
|
|
2033
|
+
/**
|
|
2034
|
+
* Consume one response stream into the assistant message it spells out, appending each block
|
|
2035
|
+
* to the store as it finishes and reporting every event to the hooks. What comes back is what
|
|
2036
|
+
* the model actually finished saying: a response cut off mid-block keeps the finished blocks
|
|
2037
|
+
* alone, so memory never differs from what a reload would rebuild.
|
|
2038
|
+
*/
|
|
2039
|
+
async #collect(stream, abortPromise) {
|
|
2040
|
+
const content = [];
|
|
2041
|
+
// Blocks that finished and were durably appended. An abort keeps exactly these, so the
|
|
2042
|
+
// in-memory assistant message never diverges from what a reload would rebuild.
|
|
2043
|
+
const persisted = [];
|
|
2044
|
+
const toolCallIndexes = new Map();
|
|
2045
|
+
const persist = async (block) => {
|
|
2046
|
+
if (block === undefined)
|
|
2047
|
+
return;
|
|
2048
|
+
await this.#persistenceLock.runInLock(this.#ctx, (lockCtx) => this.#appendRecord(lockCtx, { type: "block", block }));
|
|
2049
|
+
persisted.push(block);
|
|
2050
|
+
};
|
|
2051
|
+
const iterator = stream[Symbol.asyncIterator]();
|
|
2052
|
+
// A response usually ends before its stream does — at the done event, or at an abort —
|
|
2053
|
+
// and the provider holds a connection behind that stream. Whichever way this method
|
|
2054
|
+
// leaves, an unfinished stream is asked to close, so nothing is left dangling. The
|
|
2055
|
+
// closure is not awaited: a provider that stalls while cleaning up must not stall the
|
|
2056
|
+
// turn, exactly as an abort must not wait for it either.
|
|
2057
|
+
let exhausted = false;
|
|
2058
|
+
try {
|
|
2059
|
+
while (true) {
|
|
2060
|
+
const next = await Promise.race([iterator.next(), abortPromise]);
|
|
2061
|
+
if (next === ABORTED) {
|
|
2062
|
+
// Drop the unfinished block and end the turn.
|
|
2063
|
+
this.#emit({ type: "done", state: "cancelled" });
|
|
2064
|
+
return { content: persisted, state: "cancelled" };
|
|
2065
|
+
}
|
|
2066
|
+
if (next.done === true) {
|
|
2067
|
+
exhausted = true;
|
|
2068
|
+
break;
|
|
2069
|
+
}
|
|
2070
|
+
const event = next.value;
|
|
2071
|
+
this.#emit(event);
|
|
2072
|
+
switch (event.type) {
|
|
2073
|
+
case "text_start":
|
|
2074
|
+
content.push({ type: "text", text: "" });
|
|
2075
|
+
break;
|
|
2076
|
+
case "text_delta": {
|
|
2077
|
+
const last = content[content.length - 1];
|
|
2078
|
+
if (last?.type === "text") {
|
|
2079
|
+
content[content.length - 1] = {
|
|
2080
|
+
type: "text",
|
|
2081
|
+
text: last.text + event.delta,
|
|
2082
|
+
};
|
|
2083
|
+
}
|
|
2084
|
+
break;
|
|
2085
|
+
}
|
|
2086
|
+
case "text_end": {
|
|
2087
|
+
const last = content[content.length - 1];
|
|
2088
|
+
await persist(last?.type === "text" ? last : undefined);
|
|
2089
|
+
break;
|
|
2090
|
+
}
|
|
2091
|
+
case "reasoning_start":
|
|
2092
|
+
content.push({ type: "reasoning", text: "" });
|
|
2093
|
+
break;
|
|
2094
|
+
case "reasoning_delta": {
|
|
2095
|
+
const last = content[content.length - 1];
|
|
2096
|
+
if (last?.type === "reasoning") {
|
|
2097
|
+
content[content.length - 1] = {
|
|
2098
|
+
...last,
|
|
2099
|
+
text: (last.text ?? "") + event.delta,
|
|
2100
|
+
};
|
|
2101
|
+
}
|
|
2102
|
+
break;
|
|
2103
|
+
}
|
|
2104
|
+
case "reasoning_end": {
|
|
2105
|
+
const last = content[content.length - 1];
|
|
2106
|
+
if (last?.type === "reasoning") {
|
|
2107
|
+
const finished = {
|
|
2108
|
+
...last,
|
|
2109
|
+
...(event.reasoning === undefined
|
|
2110
|
+
? {}
|
|
2111
|
+
: { reasoning: event.reasoning }),
|
|
2112
|
+
};
|
|
2113
|
+
content[content.length - 1] = finished;
|
|
2114
|
+
await persist(finished);
|
|
2115
|
+
}
|
|
2116
|
+
break;
|
|
2117
|
+
}
|
|
2118
|
+
case "toolcall_start":
|
|
2119
|
+
toolCallIndexes.set(event.callId, content.length);
|
|
2120
|
+
content.push({
|
|
2121
|
+
type: "tool_call",
|
|
2122
|
+
callId: event.callId,
|
|
2123
|
+
name: event.name,
|
|
2124
|
+
arguments: "",
|
|
2125
|
+
...(event.namespace === undefined
|
|
2126
|
+
? {}
|
|
2127
|
+
: { namespace: event.namespace }),
|
|
2128
|
+
...(event.server === undefined ? {} : { server: event.server }),
|
|
2129
|
+
...(event.vendor === undefined ? {} : { vendor: event.vendor }),
|
|
2130
|
+
});
|
|
2131
|
+
break;
|
|
2132
|
+
case "toolcall_end": {
|
|
2133
|
+
const index = toolCallIndexes.get(event.callId);
|
|
2134
|
+
const block = index === undefined ? undefined : content[index];
|
|
2135
|
+
if (index !== undefined && block?.type === "tool_call") {
|
|
2136
|
+
const finished = {
|
|
2137
|
+
...block,
|
|
2138
|
+
arguments: event.arguments,
|
|
2139
|
+
...(event.incomplete === undefined
|
|
2140
|
+
? {}
|
|
2141
|
+
: { incomplete: event.incomplete }),
|
|
2142
|
+
};
|
|
2143
|
+
content[index] = finished;
|
|
2144
|
+
await persist(finished);
|
|
2145
|
+
}
|
|
2146
|
+
break;
|
|
2147
|
+
}
|
|
2148
|
+
// The provider settled a server tool call on its own backend and streams the
|
|
2149
|
+
// result here. The agent simply ignores it: nothing to execute, nothing to
|
|
2150
|
+
// store — the events still reach the hooks like every other event.
|
|
2151
|
+
case "toolcall_result_start":
|
|
2152
|
+
case "toolcall_result_delta":
|
|
2153
|
+
case "toolcall_result_end":
|
|
2154
|
+
break;
|
|
2155
|
+
case "done":
|
|
2156
|
+
return {
|
|
2157
|
+
// Only blocks that finished, which are exactly the blocks that were
|
|
2158
|
+
// durably appended. A response ending mid-block leaves half of
|
|
2159
|
+
// something the model never finished saying, and keeping that in
|
|
2160
|
+
// memory alone would make the next live request differ from the one
|
|
2161
|
+
// a restart would rebuild from the store.
|
|
2162
|
+
content: persisted,
|
|
2163
|
+
state: event.state,
|
|
2164
|
+
...(event.state === "error" ? { errorMessage: event.message } : {}),
|
|
2165
|
+
...(event.state === "normal" ||
|
|
2166
|
+
event.state === "tool_call" ||
|
|
2167
|
+
event.state === "length"
|
|
2168
|
+
? { tokens: event.tokens }
|
|
2169
|
+
: {}),
|
|
2170
|
+
};
|
|
2171
|
+
default:
|
|
2172
|
+
break;
|
|
2173
|
+
}
|
|
2174
|
+
}
|
|
2175
|
+
return { content, state: undefined };
|
|
2176
|
+
}
|
|
2177
|
+
finally {
|
|
2178
|
+
// A done event ends the response, not the provider's ownership of its session. The
|
|
2179
|
+
// closure is requested here and waited for before the next request, rather than
|
|
2180
|
+
// now: a stream that has been told to stop and has not yet must not be able to hold
|
|
2181
|
+
// this turn — or an abort — open.
|
|
2182
|
+
if (!exhausted) {
|
|
2183
|
+
this.#settleLater(Promise.resolve(iterator.return?.()), "stream");
|
|
2184
|
+
}
|
|
2185
|
+
}
|
|
2186
|
+
}
|
|
2187
|
+
/**
|
|
2188
|
+
* Create the provider session on first use — or recreate it when the provider-facing
|
|
2189
|
+
* configuration changed, so the model always sees the tool descriptors the agent would
|
|
2190
|
+
* actually execute. The provider is resolved from the registry by its serializable ID at
|
|
2191
|
+
* that moment; an unregistered ID fails the turn like any thrown error.
|
|
2192
|
+
*/
|
|
2193
|
+
async #ensureSession(instructions, tools) {
|
|
2194
|
+
const key = sessionConfigKey(instructions, tools);
|
|
2195
|
+
if (this.#session !== undefined && this.#sessionConfig !== key) {
|
|
2196
|
+
const session = this.#session;
|
|
2197
|
+
this.#session = undefined;
|
|
2198
|
+
// The session being replaced may still be held by a response iterator that has not
|
|
2199
|
+
// finished unwinding. Destroying it first would tear it out from under that cleanup.
|
|
2200
|
+
await this.#settled();
|
|
2201
|
+
try {
|
|
2202
|
+
await session.destroy();
|
|
2203
|
+
}
|
|
2204
|
+
catch {
|
|
2205
|
+
// The stale session is abandoned either way.
|
|
2206
|
+
}
|
|
2207
|
+
}
|
|
2208
|
+
if (this.#session === undefined) {
|
|
2209
|
+
const provider = this.#providers.get(this.#providerId);
|
|
2210
|
+
if (provider === null) {
|
|
2211
|
+
throw new Error(`Provider "${this.#providerId}" is not registered.`);
|
|
2212
|
+
}
|
|
2213
|
+
this.#session = await provider.session(this.id, {
|
|
2214
|
+
instructions,
|
|
2215
|
+
tools: [...tools],
|
|
2216
|
+
});
|
|
2217
|
+
this.#sessionConfig = key;
|
|
2218
|
+
}
|
|
2219
|
+
return this.#session;
|
|
2220
|
+
}
|
|
2221
|
+
/** Report one stream event to the hooks. Hooks observe the stream; they never fail a run. */
|
|
2222
|
+
#emit(event) {
|
|
2223
|
+
try {
|
|
2224
|
+
this.#hooks.onEvent?.(this.#ctx, event);
|
|
2225
|
+
}
|
|
2226
|
+
catch {
|
|
2227
|
+
// Hooks observe the stream; they never fail a run.
|
|
2228
|
+
}
|
|
2229
|
+
}
|
|
2230
|
+
}
|
|
2231
|
+
/** Whether a conversation ends on something the model has not answered. */
|
|
2232
|
+
function needsInference(messages) {
|
|
2233
|
+
const last = messages[messages.length - 1];
|
|
2234
|
+
return last?.role === "user" || last?.role === "tool" || last?.role === "system";
|
|
2235
|
+
}
|
|
2236
|
+
/**
|
|
2237
|
+
* The conversation a run of records spells out. A compaction record carries the complete
|
|
2238
|
+
* replacement context and supersedes everything before it; consecutive blocks belong to one
|
|
2239
|
+
* response. Used both to restore the whole history and to read the tail of it, which is why it
|
|
2240
|
+
* takes any run of records rather than the store itself.
|
|
2241
|
+
*/
|
|
2242
|
+
function messagesFromRecords(records) {
|
|
2243
|
+
let messages = [];
|
|
2244
|
+
for (const record of records) {
|
|
2245
|
+
if (record.type === "compaction") {
|
|
2246
|
+
messages = [...record.messages];
|
|
2247
|
+
continue;
|
|
2248
|
+
}
|
|
2249
|
+
if (record.type === "user" || record.type === "tool" || record.type === "system") {
|
|
2250
|
+
messages.push(record.message);
|
|
2251
|
+
continue;
|
|
2252
|
+
}
|
|
2253
|
+
const last = messages[messages.length - 1];
|
|
2254
|
+
if (last?.role === "assistant") {
|
|
2255
|
+
messages[messages.length - 1] = {
|
|
2256
|
+
role: "assistant",
|
|
2257
|
+
content: [...last.content, record.block],
|
|
2258
|
+
};
|
|
2259
|
+
}
|
|
2260
|
+
else {
|
|
2261
|
+
messages.push({ role: "assistant", content: [record.block] });
|
|
2262
|
+
}
|
|
2263
|
+
}
|
|
2264
|
+
return messages;
|
|
2265
|
+
}
|
|
2266
|
+
/** The result that stands in for a call the agent could not, or must not, carry out. */
|
|
2267
|
+
function toolFailure(callId, reason) {
|
|
2268
|
+
return {
|
|
2269
|
+
role: "tool",
|
|
2270
|
+
callId,
|
|
2271
|
+
content: [{ type: "text", text: reason }],
|
|
2272
|
+
isError: true,
|
|
2273
|
+
};
|
|
2274
|
+
}
|
|
2275
|
+
/**
|
|
2276
|
+
* The provider-facing identity of a session configuration. Only descriptor fields the provider
|
|
2277
|
+
* sees participate, so re-created tool objects with identical descriptors do not churn the
|
|
2278
|
+
* session.
|
|
2279
|
+
*/
|
|
2280
|
+
function sessionConfigKey(instructions, tools) {
|
|
2281
|
+
return deterministicStringify([
|
|
2282
|
+
instructions,
|
|
2283
|
+
tools.map((tool) => [
|
|
2284
|
+
tool.name,
|
|
2285
|
+
tool.namespace ?? null,
|
|
2286
|
+
tool.namespaceDescription ?? null,
|
|
2287
|
+
tool.description ?? null,
|
|
2288
|
+
tool.parameters ?? null,
|
|
2289
|
+
tool.defer ?? null,
|
|
2290
|
+
tool.server ?? null,
|
|
2291
|
+
tool.grammar ?? null,
|
|
2292
|
+
]),
|
|
2293
|
+
]);
|
|
2294
|
+
}
|
|
2295
|
+
//# sourceMappingURL=AgentBase.js.map
|