@slopus/happy-agent-base 0.0.1 → 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.
Files changed (95) hide show
  1. package/README.md +434 -34
  2. package/dist/Agent.d.ts +64 -9
  3. package/dist/Agent.d.ts.map +1 -1
  4. package/dist/Agent.js +247 -40
  5. package/dist/Agent.js.map +1 -1
  6. package/dist/AgentBase.d.ts +270 -23
  7. package/dist/AgentBase.d.ts.map +1 -1
  8. package/dist/AgentBase.js +1728 -342
  9. package/dist/AgentBase.js.map +1 -1
  10. package/dist/AgentBaseHooks.d.ts +108 -20
  11. package/dist/AgentBaseHooks.d.ts.map +1 -1
  12. package/dist/AgentBasePending.d.ts +49 -0
  13. package/dist/AgentBasePending.d.ts.map +1 -0
  14. package/dist/AgentBasePending.js +73 -0
  15. package/dist/AgentBasePending.js.map +1 -0
  16. package/dist/AgentBaseState.d.ts +2 -0
  17. package/dist/AgentBaseState.d.ts.map +1 -1
  18. package/dist/AgentBaseStoreLock.d.ts +16 -0
  19. package/dist/AgentBaseStoreLock.d.ts.map +1 -0
  20. package/dist/AgentBaseStoreLock.js +37 -0
  21. package/dist/AgentBaseStoreLock.js.map +1 -0
  22. package/dist/AgentConfig.d.ts +67 -0
  23. package/dist/AgentConfig.d.ts.map +1 -0
  24. package/dist/AgentConfig.js +81 -0
  25. package/dist/AgentConfig.js.map +1 -0
  26. package/dist/AgentContexts.d.ts +40 -0
  27. package/dist/AgentContexts.d.ts.map +1 -0
  28. package/dist/AgentContexts.js +70 -0
  29. package/dist/AgentContexts.js.map +1 -0
  30. package/dist/AgentFeature.d.ts +105 -15
  31. package/dist/AgentFeature.d.ts.map +1 -1
  32. package/dist/AgentKV.d.ts +63 -0
  33. package/dist/AgentKV.d.ts.map +1 -0
  34. package/dist/AgentKV.js +122 -0
  35. package/dist/AgentKV.js.map +1 -0
  36. package/dist/AgentModel.d.ts +17 -0
  37. package/dist/AgentModel.d.ts.map +1 -0
  38. package/dist/AgentModel.js +2 -0
  39. package/dist/AgentModel.js.map +1 -0
  40. package/dist/AgentPersistence.d.ts +95 -0
  41. package/dist/AgentPersistence.d.ts.map +1 -0
  42. package/dist/AgentPersistence.js +2 -0
  43. package/dist/AgentPersistence.js.map +1 -0
  44. package/dist/AgentProviders.d.ts +5 -0
  45. package/dist/AgentProviders.d.ts.map +1 -1
  46. package/dist/AgentProviders.js +6 -0
  47. package/dist/AgentProviders.js.map +1 -1
  48. package/dist/AgentRef.d.ts +41 -0
  49. package/dist/AgentRef.d.ts.map +1 -0
  50. package/dist/AgentRef.js +60 -0
  51. package/dist/AgentRef.js.map +1 -0
  52. package/dist/AgentStorage.d.ts +19 -0
  53. package/dist/AgentStorage.d.ts.map +1 -0
  54. package/dist/AgentStorage.js +16 -0
  55. package/dist/AgentStorage.js.map +1 -0
  56. package/dist/AgentSystem.d.ts +50 -0
  57. package/dist/AgentSystem.d.ts.map +1 -0
  58. package/dist/AgentSystem.js +2 -0
  59. package/dist/AgentSystem.js.map +1 -0
  60. package/dist/AgentSystemContext.d.ts +15 -0
  61. package/dist/AgentSystemContext.d.ts.map +1 -0
  62. package/dist/AgentSystemContext.js +20 -0
  63. package/dist/AgentSystemContext.js.map +1 -0
  64. package/dist/AgentSystemLocal.d.ts +103 -0
  65. package/dist/AgentSystemLocal.d.ts.map +1 -0
  66. package/dist/AgentSystemLocal.js +282 -0
  67. package/dist/AgentSystemLocal.js.map +1 -0
  68. package/dist/AgentSystemRef.d.ts +56 -0
  69. package/dist/AgentSystemRef.d.ts.map +1 -0
  70. package/dist/AgentSystemRef.js +75 -0
  71. package/dist/AgentSystemRef.js.map +1 -0
  72. package/dist/AgentTaskContext.d.ts +17 -0
  73. package/dist/AgentTaskContext.d.ts.map +1 -0
  74. package/dist/AgentTaskContext.js +26 -0
  75. package/dist/AgentTaskContext.js.map +1 -0
  76. package/dist/AgentTool.d.ts +40 -0
  77. package/dist/AgentTool.d.ts.map +1 -1
  78. package/dist/AgentTool.js.map +1 -1
  79. package/dist/index.d.ts +20 -6
  80. package/dist/index.d.ts.map +1 -1
  81. package/dist/index.js +23 -2
  82. package/dist/index.js.map +1 -1
  83. package/dist/models.d.ts +62 -0
  84. package/dist/models.d.ts.map +1 -0
  85. package/dist/models.js +185 -0
  86. package/dist/models.js.map +1 -0
  87. package/package.json +5 -3
  88. package/dist/AgentBaseContext.d.ts +0 -22
  89. package/dist/AgentBaseContext.d.ts.map +0 -1
  90. package/dist/AgentBaseContext.js +0 -33
  91. package/dist/AgentBaseContext.js.map +0 -1
  92. package/dist/AgentBasePersistence.d.ts +0 -60
  93. package/dist/AgentBasePersistence.d.ts.map +0 -1
  94. package/dist/AgentBasePersistence.js +0 -2
  95. package/dist/AgentBasePersistence.js.map +0 -1
package/dist/AgentBase.js CHANGED
@@ -1,59 +1,430 @@
1
1
  import { areProviderModelsCompatible } from "@slopus/happy-providers";
2
+ import { AsyncLocalStorage } from "node:async_hooks";
3
+ import { randomUUID } from "node:crypto";
2
4
  import { Value } from "@sinclair/typebox/value";
3
- import { asyncLock, withLifetime } from "@steve.kite/stdlib";
4
- import { withAgentBaseContext } from "./AgentBaseContext.js";
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";
5
11
  import { AgentProviders } from "./AgentProviders.js";
6
12
  /** Race winner when an abort interrupts a wait on the stream or a running tool. */
7
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");
8
37
  /**
9
38
  * A single agent session over one provider. Messages arrive through two FIFO queues: steering
10
39
  * messages inject as soon as the current assistant response and its tool batch finish, while
11
40
  * sent messages wait until the agent would otherwise stop — no tool calls or steering remain.
12
41
  * Each queue drains per its configured mode, and the conversation is durable through
13
- * append-only persistence loaded on the first inference attempt.
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.
14
186
  */
15
187
  export class AgentBase {
188
+ /** The caller-supplied session identity: the name of this agent's store and of its loop. */
16
189
  id;
17
190
  /**
18
191
  * The agent's own copy of the initial state, mutable directly; every inference reads the
19
192
  * current values.
20
193
  */
21
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
+ */
22
200
  #baseCtx;
201
+ /** The base context extended with the effective selection and the agent's key-value store. */
23
202
  #ctx;
203
+ /** The registry the provider ID is resolved through, each time a session is created. */
24
204
  #providers;
205
+ /** The registry ID of the provider in force; durable, so a restart resumes on the same one. */
25
206
  #providerId;
207
+ /** The append-only store behind the conversation, the queues and the persisted settings. */
26
208
  #persistence;
209
+ /** The model in force. Changing it to an incompatible one resets the conversation. */
27
210
  #model;
211
+ /** The reasoning effort in force. */
28
212
  #effort;
213
+ /** The service tier in force. */
29
214
  #serviceTier;
215
+ /** The single set of hooks the run is observed by and its configuration extended from. */
30
216
  #hooks;
31
217
  /**
32
218
  * Serializes every persistence operation together with its in-memory effect, so storage
33
- * order always matches history order and a load never overlaps an append.
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.
34
230
  */
35
- #persistenceLock = asyncLock({ reentry: "block" });
231
+ #runKV;
232
+ /** Whether steering drains one message per response or all of them at once. */
36
233
  #steeringMode;
234
+ /** Whether sends drain one message per response or all of them at once. */
37
235
  #sendMode;
236
+ /** The provider session requests run on, created on first use and stateful thereafter. */
38
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. */
39
241
  #messages = [];
242
+ /** The durable steering queue, in the order its keys sort. */
40
243
  #steering = [];
244
+ /** The durable send queue, in the order its keys sort. */
41
245
  #sends = [];
246
+ /** A dispatched tool batch whose results have not all landed yet, and so has to be resumed. */
42
247
  #pendingTools = [];
43
- #pendingSequence = 0;
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. */
44
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
+ */
45
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
+ */
46
317
  #compaction;
318
+ /** The scope the current stretch of work is cancelled on, which an abort signals. */
47
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. */
48
335
  #turnRequested = false;
336
+ /** The run loop while it is running; the field is cleared once it has actually stopped. */
49
337
  #runPromise;
338
+ /** The barrier: true from the moment close is called, and nothing new is admitted after it. */
50
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
+ */
51
418
  constructor(ctx, options) {
52
419
  this.id = options.id;
53
- this.#baseCtx = ctx;
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]);
54
424
  this.#providers = options.providers;
55
425
  this.#providerId = options.provider;
56
426
  this.#persistence = options.persistence;
427
+ this.#persistenceLock = agentBaseStoreLock(options.persistence);
57
428
  this.#hooks = options.hooks ?? {};
58
429
  this.state = {
59
430
  instructions: options.initialState?.instructions ?? "",
@@ -62,48 +433,194 @@ export class AgentBase {
62
433
  this.#model = options.model;
63
434
  this.#effort = options.effort;
64
435
  this.#serviceTier = options.serviceTier;
436
+ this.#kv = new AgentKV(this.#persistence, `kv.${options.id}.`);
437
+ this.#runKV = this.#kv.scoped("run");
65
438
  // Everything the agent does — hooks and tool executions included — runs on a context
66
439
  // carrying its provider and the currently effective model, effort, and service tier.
67
440
  this.#ctx = this.#deriveCtx();
68
441
  this.#steeringMode = options.steeringMode ?? "one-at-a-time";
69
442
  this.#sendMode = options.sendMode ?? "one-at-a-time";
70
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
+ */
71
449
  #deriveCtx() {
72
- return withAgentBaseContext(this.#baseCtx, {
450
+ const ctx = withAgentContext(this.#baseCtx, {
451
+ id: this.id,
73
452
  provider: this.#providerId,
74
453
  model: this.#model,
75
454
  effort: this.#effort,
76
455
  serviceTier: this.#serviceTier,
77
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;
78
517
  }
79
518
  /**
80
519
  * Queue a user message that injects as soon as the current assistant response and its tool
81
- * batch finish; steering always takes precedence over sent messages. The returned promise
82
- * resolves once the durable write lands; it waits neither for the history load nor for the
83
- * turn, and a failed write keeps the message out of the conversation entirely.
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.
84
524
  */
85
525
  async steer(ctx, message, options) {
86
- await this.#enqueue(ctx, "steering.", this.#steering, message, options ?? {});
526
+ await this.#offer(ctx, "steering", message, options);
87
527
  }
88
528
  /**
89
529
  * Queue a user message that waits until the agent would otherwise stop — no tool calls or
90
- * steering remain — before injecting. The returned promise resolves once the durable write
91
- * lands; it waits neither for the history load nor for the turn, and a failed write keeps
92
- * the message out of the conversation entirely.
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.
93
534
  */
94
535
  async send(ctx, message, options) {
95
- await this.#enqueue(ctx, "send.", this.#sends, message, options ?? {});
536
+ await this.#offer(ctx, "send", message, options);
96
537
  }
97
- async #enqueue(ctx, prefix, queue, message, options) {
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");
98
548
  if (this.#closed)
99
549
  throw new Error("The agent has been closed.");
100
- await this.#persistenceLock.runInLock(ctx, async (lockCtx) => {
101
- const key = this.#queueKey(prefix);
102
- await this.#persistence.writeValue(lockCtx, key, { message, options });
103
- queue.push({ key, message, options });
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
+ }
104
614
  this.#turnRequested = true;
105
615
  this.#startRun();
106
616
  });
617
+ this.#admitted.add(admitted);
618
+ try {
619
+ await admitted;
620
+ }
621
+ finally {
622
+ this.#admitted.delete(admitted);
623
+ }
107
624
  }
108
625
  /**
109
626
  * Start the loop without a new message: load the durable state and, if a turn was cut off —
@@ -115,23 +632,66 @@ export class AgentBase {
115
632
  throw new Error("The agent has been closed.");
116
633
  this.#startRun();
117
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
+ */
118
641
  async waitForIdle() {
119
- while (this.#runPromise !== undefined) {
642
+ while (this.#admitted.size > 0 || this.#runPromise !== undefined) {
643
+ await Promise.allSettled([...this.#admitted]);
120
644
  await this.#runPromise;
121
645
  }
122
646
  }
123
647
  /**
124
648
  * Compact the conversation. The compaction waits for the active turn to end — or runs right
125
- * away when idle — and replaces the compacted history with the provider's replacement
126
- * context while keeping every message that joined the history after the snapshot. Calls made
127
- * while a compaction is pending or running await that same compaction; the shared promise
128
- * resolves when it completes and rejects when the provider reports failure.
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.
129
656
  */
130
- async compact(ctx) {
657
+ async compact(ctx, options) {
658
+ const wait = options?.await ?? false;
659
+ this.#assertCanWait(ctx, wait, "a compaction");
131
660
  if (this.#closed)
132
661
  throw new Error("The agent has been closed.");
133
- return this.#ensureCompaction();
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));
134
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
+ */
135
695
  #ensureCompaction() {
136
696
  if (this.#compaction === undefined) {
137
697
  let resolve;
@@ -147,143 +707,474 @@ export class AgentBase {
147
707
  return this.#compaction.promise;
148
708
  }
149
709
  /**
150
- * The system prompt for the next request: the hook's answer when one is provided, the
151
- * mutable state otherwise. A throwing hook falls back to the state; it never fails the run.
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.
152
712
  */
153
- #instructions() {
154
- try {
155
- return this.#hooks.instructions?.(this.#ctx) ?? this.state.instructions;
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]);
156
731
  }
157
- catch {
158
- return this.state.instructions;
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]);
159
737
  }
160
738
  }
161
739
  /**
162
- * The tools for the next request or execution: the hook's answer when one is provided, the
163
- * mutable state otherwise. A throwing hook falls back to the state; it never fails the run.
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.
164
743
  */
165
- #tools() {
166
- try {
167
- return this.#hooks.tools?.(this.#ctx) ?? this.state.tools;
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);
168
767
  }
169
- catch {
170
- return this.state.tools;
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;
171
796
  }
797
+ void run
798
+ .catch(() => undefined)
799
+ .then(() => {
800
+ this.#settlePendingCompaction("The compaction was cancelled by an abort.");
801
+ });
172
802
  }
173
803
  /**
174
- * Cancel the active turn: stop consuming the inference stream, settle still-running tool
175
- * calls as aborted error results, and drop the queued turn request. Blocks that already
176
- * finished stay in the history; an unfinished block is dropped. Messages still waiting in
177
- * the steering and send queues stay durable and join the next requested turn. Resolves
178
- * once the loop has stopped; a no-op when the agent is idle.
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.
179
806
  */
180
- async abort() {
807
+ #signalAbort() {
181
808
  const run = this.#runPromise;
182
809
  if (run === undefined)
183
- return;
810
+ return undefined;
184
811
  this.#turnRequested = false;
185
812
  this.#abortController?.abort();
186
- await run;
813
+ return run;
187
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
+ */
188
832
  async close() {
189
- if (this.#closed)
190
- return;
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();
191
836
  this.#closed = true;
192
- await this.#runPromise;
193
- await this.#session?.destroy();
194
- this.#session = undefined;
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;
195
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
+ */
196
884
  #startRun() {
197
885
  if (this.#runPromise !== undefined)
198
886
  return;
199
- this.#runPromise = this.#runLoop().finally(() => {
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(() => {
200
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();
201
901
  });
202
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
+ */
203
909
  async #runLoop() {
204
910
  // The outer loop reopens when an `afterAgentLoop` action requests more work, so the
205
911
  // loop hooks always bracket a settled-to-settled span.
206
912
  do {
207
- this.#invokeHook(this.#hooks.beforeAgentLoop);
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);
208
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.
209
929
  this.#turnRequested = false;
210
- this.#invokeHook(this.#hooks.beforeTurn);
211
- await this.#runInference();
212
- await this.#applyActions(this.#hooks.afterTurn);
213
- } while (this.#turnRequested && !this.#closed);
214
- await this.#applyActions(this.#hooks.afterAgentLoop);
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);
215
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();
216
973
  }
217
- #invokeHook(hook) {
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() {
218
981
  try {
219
- hook?.(this.#ctx);
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);
220
1057
  }
221
1058
  catch {
222
1059
  // Hooks observe the run; they never fail it.
223
1060
  }
224
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
+ }
225
1087
  /**
226
1088
  * Ask a lifecycle hook what to do next and carry its actions out: queue steering or sent
227
1089
  * messages through the ordinary durable path, or trigger a compaction. Every returned
228
1090
  * action is applied before the loop continues, so they all take effect at the same point.
229
- * Neither a throwing hook nor a failing action ever fails the run.
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.
230
1093
  */
231
- async #applyActions(hook) {
1094
+ async #applyActionsAlways(hook, ...args) {
232
1095
  if (hook === undefined)
233
1096
  return;
234
1097
  let actions;
235
1098
  try {
236
- actions = hook(this.#ctx);
1099
+ actions = await hook(this.#ctx, ...args);
237
1100
  }
238
1101
  catch {
239
1102
  return;
240
1103
  }
241
- for (const action of actions ?? []) {
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);
242
1115
  try {
243
- if (action.type === "compact") {
244
- this.#ensureCompaction().catch(() => undefined);
245
- continue;
246
- }
247
- const queue = action.type === "steer" ? this.#steering : this.#sends;
248
- const prefix = action.type === "steer" ? "steering." : "send.";
249
- await this.#enqueue(this.#ctx, prefix, queue, action.message, {});
1116
+ await this.#enqueue(this.#ctx, pending);
250
1117
  }
251
1118
  catch {
252
1119
  // A hook-driven action must not fail the run.
253
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
+ });
254
1133
  }
1134
+ await flush();
255
1135
  }
256
- async #runInference() {
257
- // One abort scope per pass; a single shared promise keeps races from piling up
258
- // listeners on the signal.
259
- const abort = new AbortController();
260
- this.#abortController = abort;
261
- const abortPromise = new Promise((resolve) => {
262
- abort.signal.addEventListener("abort", () => resolve(ABORTED), { once: true });
263
- });
264
- try {
265
- // A failed load is not sticky: the cache resets so the next turn retries it.
266
- this.#loaded ??= this.#loadHistory().catch((error) => {
267
- this.#loaded = undefined;
268
- throw error;
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 });
269
1150
  });
270
- await this.#loaded;
1151
+ try {
1152
+ await this.#ensureLoaded();
271
1153
  // Resume a tool batch that was dispatched but cut off before its results landed, so
272
1154
  // the interrupted results reach the main store before any queued message.
273
1155
  const resumed = this.#pendingTools;
1156
+ const undispatched = this.#pendingToolsUndispatched;
274
1157
  this.#pendingTools = [];
1158
+ this.#pendingToolsUndispatched = false;
275
1159
  if (resumed.length > 0) {
276
- await this.#runToolBatch(resumed, true, abort.signal, abortPromise);
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
+ }
277
1166
  }
278
- // A response is owed without any injection when tool results from a resumed batch
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
279
1171
  // end the context, or — checked once, against the freshly loaded durable state —
280
1172
  // when a cut-off run left its trailing user or tool message unanswered. Afterwards
281
1173
  // a trailing user message can be legitimate: a response may have zero blocks.
282
- let responseOwed = resumed.length > 0;
1174
+ let needsInference = resumed.length > 0;
283
1175
  if (!this.#recoveryChecked) {
284
1176
  this.#recoveryChecked = true;
285
- const last = this.#messages[this.#messages.length - 1];
286
- responseOwed ||= last?.role === "user" || last?.role === "tool";
1177
+ needsInference ||= this.#resumesInterruptedRun();
287
1178
  }
288
1179
  // Each cycle first drains the queues, then runs one inference. Steering injects at
289
1180
  // every stop between responses and always outranks sends; sent messages inject
@@ -296,46 +1187,78 @@ export class AgentBase {
296
1187
  let pendingError;
297
1188
  while (true) {
298
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.
299
1193
  if (abort.signal.aborted) {
300
- this.#emit({ type: "done", state: "cancelled" });
1194
+ const hasPendingWork = needsInference || this.#steering.length > 0 || this.#sends.length > 0;
1195
+ if (hasPendingWork)
1196
+ this.#emit({ type: "done", state: "cancelled" });
301
1197
  break;
302
1198
  }
303
- let injected = await this.#consumeQueue(this.#steering, this.#steeringMode);
304
- if (!injected && !responseOwed) {
305
- injected = await this.#consumeQueue(this.#sends, this.#sendMode);
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.");
306
1202
  }
307
1203
  // Nothing to answer — a start() on an idle history, or the queues ran dry.
308
- if (!injected && !responseOwed)
1204
+ if (!injected && !needsInference)
309
1205
  break;
310
- const session = await this.#ensureSession();
311
- this.#invokeHook(this.#hooks.beforeInference);
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);
312
1216
  const stream = session.run(this.#ctx, {
313
1217
  context: {
314
- instructions: this.#instructions(),
1218
+ instructions,
315
1219
  messages: [...this.#messages],
316
1220
  },
317
1221
  ...(this.#model === undefined ? {} : { model: this.#model }),
318
1222
  ...(this.#effort === undefined ? {} : { effort: this.#effort }),
319
- ...(this.#serviceTier === undefined
320
- ? {}
321
- : { serviceTier: this.#serviceTier }),
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 }),
322
1235
  });
323
- const { content, state, errorMessage } = await this.#collect(stream, abortPromise);
324
- this.#invokeHook(this.#hooks.afterInference);
325
1236
  if (content.length > 0) {
326
1237
  this.#messages.push({ role: "assistant", content });
327
1238
  }
328
- responseOwed = false;
1239
+ needsInference = false;
329
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
+ }
330
1251
  if (state === "tool_call") {
331
1252
  const calls = content.filter((block) => block.type === "tool_call" && block.server !== true);
332
1253
  if (calls.length === 0)
333
1254
  continue;
334
- await this.#runToolBatch(calls.map((call, index) => ({
1255
+ const closedDuringTools = await this.#runToolBatch(calls.map((call, index) => ({
335
1256
  key: this.#toolKey(index, call.callId),
336
1257
  call,
337
1258
  })), false, abort.signal, abortPromise);
338
- responseOwed = true;
1259
+ if (closedDuringTools)
1260
+ break;
1261
+ needsInference = true;
339
1262
  continue;
340
1263
  }
341
1264
  // A natural stop keeps draining, and so does a provider-reported error: the
@@ -346,7 +1269,9 @@ export class AgentBase {
346
1269
  if (state !== "normal" && state !== "length" && state !== "error")
347
1270
  break;
348
1271
  }
349
- if (pendingError !== undefined) {
1272
+ if (pendingError !== undefined &&
1273
+ this.#loaded !== undefined &&
1274
+ this.#unansweredCalls(this.#messages).length === 0) {
350
1275
  await this.#appendFailure(pendingError);
351
1276
  }
352
1277
  }
@@ -357,27 +1282,102 @@ export class AgentBase {
357
1282
  kind: "internal_error",
358
1283
  message: error instanceof Error ? error.message : String(error),
359
1284
  });
360
- await this.#appendFailure(error instanceof Error ? error.message : String(error));
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.
361
1347
  }
362
- // The turn is over; a requested compaction runs now, before the next pass can start.
363
- await this.#runCompaction();
364
1348
  }
365
1349
  /**
366
- * Run the pending compaction, if any. The snapshot is taken here, with the turn over and
367
- * this pass being the only history writer, so nothing joins the history mid-compaction; the
368
- * suffix copy still keeps any such message, defensively. The replacement is appended as a
369
- * compaction record — the load-time reset point — and settles the shared promise for every
370
- * caller awaiting it. A provider failure rejects them and leaves the history untouched.
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.
371
1356
  */
372
- async #runCompaction() {
1357
+ async #runCompaction(signal) {
373
1358
  const pending = this.#compaction;
374
1359
  if (pending === undefined)
375
1360
  return;
376
1361
  try {
377
- const session = await this.#ensureSession();
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;
378
1374
  const snapshot = [...this.#messages];
379
- const result = await session.compact(this.#ctx, {
380
- context: { instructions: this.#instructions(), messages: snapshot },
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 },
381
1381
  ...(this.#model === undefined ? {} : { model: this.#model }),
382
1382
  });
383
1383
  if (result.status === "failed") {
@@ -385,19 +1385,31 @@ export class AgentBase {
385
1385
  }
386
1386
  if (result.status === "completed") {
387
1387
  await this.#persistenceLock.runInLock(this.#ctx, async (lockCtx) => {
388
- const suffix = this.#messages.slice(snapshot.length);
1388
+ const records = await this.#persistence.load(lockCtx);
1389
+ const suffix = messagesFromRecords(records.slice(snapshotCount));
389
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);
390
1394
  // Physically delete the superseded records and write the replacement —
391
1395
  // which keeps the messages that stay — in one atomic step.
392
- await this.#persistence.transaction(lockCtx, async (txCtx) => {
1396
+ await this.#recordTransaction(lockCtx, async (txCtx) => {
393
1397
  await this.#persistence.clearRecords(txCtx);
394
1398
  await this.#persistence.append(txCtx, {
395
1399
  type: "compaction",
396
1400
  messages: replaced,
1401
+ ...(continuesInference ? { continuesInference: true } : {}),
397
1402
  });
398
1403
  });
399
- this.#messages = replaced;
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;
400
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);
401
1413
  }
402
1414
  this.#compaction = undefined;
403
1415
  pending.resolve();
@@ -407,6 +1419,81 @@ export class AgentBase {
407
1419
  pending.reject(error);
408
1420
  }
409
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
+ }
410
1497
  /**
411
1498
  * Surface a failed turn to the conversation as a system message, so the next inference sees
412
1499
  * what went wrong. Only unrecovered failures reach here — a later successful response in the
@@ -423,7 +1510,7 @@ export class AgentBase {
423
1510
  };
424
1511
  try {
425
1512
  await this.#persistenceLock.runInLock(this.#ctx, async (lockCtx) => {
426
- await this.#persistence.append(lockCtx, { type: "system", message: failure });
1513
+ await this.#appendRecord(lockCtx, { type: "system", message: failure });
427
1514
  this.#messages.push(failure);
428
1515
  });
429
1516
  }
@@ -436,8 +1523,17 @@ export class AgentBase {
436
1523
  * context store and the in-memory history. The moves run in one transaction, so a message
437
1524
  * is never durable in both stores or neither, and memory changes only after the commit.
438
1525
  */
439
- async #consumeQueue(queue, mode) {
1526
+ async #consumeQueue(queue, mode, prefix) {
440
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);
441
1537
  if (queue.length === 0)
442
1538
  return false;
443
1539
  const count = mode === "all" ? queue.length : 1;
@@ -498,61 +1594,116 @@ export class AgentBase {
498
1594
  // A selection without a model on either side cannot be judged compatible.
499
1595
  reset = model !== this.#model;
500
1596
  }
501
- if (this.#hooks.modelChanged !== undefined && model !== undefined) {
502
- const changeCtx = withAgentBaseContext(this.#baseCtx, {
503
- provider,
504
- model,
505
- effort,
506
- serviceTier,
507
- });
508
- try {
509
- injected = this.#hooks.modelChanged(changeCtx, {
510
- previousModel: this.#model,
511
- model,
512
- previousProvider: this.#providerId,
513
- provider,
514
- providers: this.#providers,
515
- previousProviderInstance: this.#providers.get(this.#providerId),
516
- providerInstance: this.#providers.get(provider),
517
- wasReset: reset,
518
- });
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
+ }
519
1650
  }
520
- catch {
521
- // Hooks observe the run; they never fail it.
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);
522
1660
  }
523
- if (!reset)
524
- injected = undefined;
525
- }
526
- }
527
- await this.#persistence.transaction(lockCtx, async (txCtx) => {
528
- if (reset) {
529
- await this.#persistence.clearRecords(txCtx);
530
- if (injected !== undefined) {
531
- await this.#persistence.append(txCtx, {
532
- type: "system",
533
- message: injected,
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,
534
1679
  });
535
1680
  }
536
- }
537
- for (const entry of batch) {
538
- await this.#persistence.append(txCtx, {
539
- type: "user",
540
- message: entry.message,
541
- });
542
- await this.#persistence.deleteValue(txCtx, entry.key);
543
- }
544
- if (changed) {
545
- await this.#persistence.writeValue(txCtx, "settings", {
546
- provider,
547
- ...(model === undefined ? {} : { model }),
548
- ...(effort === undefined ? {} : { effort }),
549
- ...(serviceTier === undefined ? {} : { serviceTier }),
550
- });
551
- }
552
- });
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
+ }
553
1703
  queue.splice(0, count);
554
1704
  if (reset) {
555
1705
  this.#messages = injected === undefined ? [] : [injected];
1706
+ this.#contextTokens = undefined;
556
1707
  }
557
1708
  if (reset || provider !== this.#providerId) {
558
1709
  const session = this.#session;
@@ -564,7 +1715,15 @@ export class AgentBase {
564
1715
  // The change already committed; a failing destroy must not undo it.
565
1716
  }
566
1717
  }
567
- this.#messages.push(...batch.map((entry) => entry.message));
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
+ }
568
1727
  if (changed) {
569
1728
  this.#providerId = provider;
570
1729
  this.#model = model;
@@ -584,34 +1743,22 @@ export class AgentBase {
584
1743
  async #loadHistory() {
585
1744
  await this.#persistenceLock.runInLock(this.#ctx, async (lockCtx) => {
586
1745
  const records = await this.#persistence.load(lockCtx);
587
- let restored = [];
588
- for (const record of records) {
589
- if (record.type === "compaction") {
590
- // A compaction record carries the complete replacement context and
591
- // supersedes everything before it.
592
- restored = [...record.messages];
593
- continue;
594
- }
595
- if (record.type === "user" || record.type === "tool" || record.type === "system") {
596
- restored.push(record.message);
597
- continue;
598
- }
599
- const last = restored[restored.length - 1];
600
- if (last?.role === "assistant") {
601
- restored[restored.length - 1] = {
602
- role: "assistant",
603
- content: [...last.content, record.block],
604
- };
605
- }
606
- else {
607
- restored.push({ role: "assistant", content: [record.block] });
608
- }
609
- }
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);
610
1752
  const steering = await this.#persistence.readValues(lockCtx, "steering.");
611
1753
  const sends = await this.#persistence.readValues(lockCtx, "send.");
612
1754
  const pendingTools = await this.#persistence.readValues(lockCtx, "tool.");
613
1755
  const settings = await this.#persistence.readValues(lockCtx, "settings");
1756
+ const context = await this.#persistence.readValues(lockCtx, "context");
614
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;
615
1762
  const entry = (key, value) => {
616
1763
  const envelope = value;
617
1764
  return { key, message: envelope.message, options: envelope.options ?? {} };
@@ -633,6 +1780,22 @@ export class AgentBase {
633
1780
  key,
634
1781
  call: value,
635
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
+ }
636
1799
  });
637
1800
  }
638
1801
  /**
@@ -647,64 +1810,140 @@ export class AgentBase {
647
1810
  */
648
1811
  async #runToolBatch(entries, resume, signal, abortPromise) {
649
1812
  if (!resume) {
650
- await this.#persistenceLock.runInLock(this.#ctx, (lockCtx) => this.#persistence.transaction(lockCtx, async (txCtx) => {
1813
+ await this.#persistenceLock.runInLock(this.#ctx, (lockCtx) => this.#recordTransaction(lockCtx, async (txCtx) => {
651
1814
  for (const entry of entries) {
652
1815
  await this.#persistence.writeValue(txCtx, entry.key, entry.call);
653
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" });
654
1821
  }));
655
1822
  }
1823
+ else {
1824
+ await this.#enterStage("tools");
1825
+ }
656
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;
657
1830
  let committed = 0;
658
- const commitReady = () => this.#persistenceLock.runInLock(this.#ctx, async (lockCtx) => {
659
- while (committed < entries.length) {
660
- const entry = entries[committed];
661
- const result = results[committed];
662
- if (entry === undefined || result === undefined)
663
- return;
664
- await this.#persistence.transaction(lockCtx, async (txCtx) => {
665
- await this.#persistence.append(txCtx, {
666
- type: "tool",
667
- message: result,
668
- });
669
- await this.#persistence.deleteValue(txCtx, entry.key);
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
+ }
670
1866
  });
671
- this.#messages.push(result);
672
- committed += 1;
673
1867
  }
674
- });
675
- await Promise.all(entries.map(async (entry, index) => {
676
- const outcome = resume && !this.#isDurable(entry.call)
677
- ? {
678
- role: "tool",
679
- callId: entry.call.callId,
680
- content: [
681
- {
682
- type: "text",
683
- text: "The tool call was interrupted by a restart and was not retried.",
684
- },
685
- ],
686
- isError: true,
687
- }
688
- : await Promise.race([
689
- this.#executeToolCall(withLifetime(this.#ctx, signal), entry.call),
690
- abortPromise,
691
- ]);
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;
692
1886
  results[index] =
693
1887
  outcome === ABORTED
694
1888
  ? {
695
1889
  role: "tool",
696
1890
  callId: entry.call.callId,
697
- content: [{ type: "text", text: "The tool call was aborted." }],
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
+ ],
698
1899
  isError: true,
699
1900
  }
700
1901
  : outcome;
701
1902
  await commitReady();
702
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;
703
1916
  }
704
- #isDurable(call) {
705
- const tool = this.#tools().find((candidate) => candidate.name === call.name && candidate.namespace === call.namespace);
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);
706
1934
  return tool?.durable === true;
707
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
+ }
708
1947
  /** Sorted by position in the batch; only one batch is ever pending at a time. */
709
1948
  #toolKey(index, callId) {
710
1949
  return `tool.${String(index).padStart(6, "0")}.${callId}`;
@@ -721,7 +1960,7 @@ export class AgentBase {
721
1960
  content: [{ type: "text", text }],
722
1961
  isError: true,
723
1962
  });
724
- const tool = this.#tools().find((candidate) => candidate.name === call.name && candidate.namespace === call.namespace);
1963
+ const tool = (await this.#tools()).find((candidate) => candidate.name === call.name && candidate.namespace === call.namespace);
725
1964
  if (tool === undefined) {
726
1965
  return failure(`Tool "${call.name}" is not available.`);
727
1966
  }
@@ -739,7 +1978,18 @@ export class AgentBase {
739
1978
  return failure(`The arguments for "${call.name}" did not match its schema.`);
740
1979
  }
741
1980
  try {
742
- const result = await tool.execute(ctx, args);
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
+ });
743
1993
  if (!Value.Check(tool.returnType, result)) {
744
1994
  return failure(`Tool "${call.name}" returned an invalid result.`);
745
1995
  }
@@ -756,12 +2006,36 @@ export class AgentBase {
756
2006
  return failure(error instanceof Error ? error.message : String(error));
757
2007
  }
758
2008
  }
759
- /** Sorted after every earlier key of its queue, within this process and across restarts. */
760
- #queueKey(prefix) {
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;
761
2022
  const time = String(Date.now()).padStart(14, "0");
762
- const sequence = String(this.#pendingSequence++).padStart(6, "0");
763
- return `${prefix}${time}.${sequence}`;
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);
764
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
+ */
765
2039
  async #collect(stream, abortPromise) {
766
2040
  const content = [];
767
2041
  // Blocks that finished and were durably appended. An abort keeps exactly these, so the
@@ -771,132 +2045,180 @@ export class AgentBase {
771
2045
  const persist = async (block) => {
772
2046
  if (block === undefined)
773
2047
  return;
774
- await this.#persistenceLock.runInLock(this.#ctx, (lockCtx) => this.#persistence.append(lockCtx, { type: "block", block }));
2048
+ await this.#persistenceLock.runInLock(this.#ctx, (lockCtx) => this.#appendRecord(lockCtx, { type: "block", block }));
775
2049
  persisted.push(block);
776
2050
  };
777
2051
  const iterator = stream[Symbol.asyncIterator]();
778
- while (true) {
779
- const next = await Promise.race([iterator.next(), abortPromise]);
780
- if (next === ABORTED) {
781
- // Close the provider stream, drop the unfinished block, and end the turn.
782
- void Promise.resolve(iterator.return?.()).catch(() => undefined);
783
- this.#emit({ type: "done", state: "cancelled" });
784
- return { content: persisted, state: "cancelled" };
785
- }
786
- if (next.done === true)
787
- break;
788
- const event = next.value;
789
- this.#emit(event);
790
- switch (event.type) {
791
- case "text_start":
792
- content.push({ type: "text", text: "" });
793
- break;
794
- case "text_delta": {
795
- const last = content[content.length - 1];
796
- if (last?.type === "text") {
797
- content[content.length - 1] = {
798
- type: "text",
799
- text: last.text + event.delta,
800
- };
801
- }
802
- break;
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" };
803
2065
  }
804
- case "text_end": {
805
- const last = content[content.length - 1];
806
- await persist(last?.type === "text" ? last : undefined);
2066
+ if (next.done === true) {
2067
+ exhausted = true;
807
2068
  break;
808
2069
  }
809
- case "reasoning_start":
810
- content.push({ type: "reasoning", text: "" });
811
- break;
812
- case "reasoning_delta": {
813
- const last = content[content.length - 1];
814
- if (last?.type === "reasoning") {
815
- content[content.length - 1] = {
816
- ...last,
817
- text: (last.text ?? "") + event.delta,
818
- };
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;
819
2085
  }
820
- break;
821
- }
822
- case "reasoning_end": {
823
- const last = content[content.length - 1];
824
- if (last?.type === "reasoning") {
825
- const finished = {
826
- ...last,
827
- ...(event.reasoning === undefined
828
- ? {}
829
- : { reasoning: event.reasoning }),
830
- };
831
- content[content.length - 1] = finished;
832
- await persist(finished);
2086
+ case "text_end": {
2087
+ const last = content[content.length - 1];
2088
+ await persist(last?.type === "text" ? last : undefined);
2089
+ break;
833
2090
  }
834
- break;
835
- }
836
- case "toolcall_start":
837
- toolCallIndexes.set(event.callId, content.length);
838
- content.push({
839
- type: "tool_call",
840
- callId: event.callId,
841
- name: event.name,
842
- arguments: "",
843
- ...(event.namespace === undefined ? {} : { namespace: event.namespace }),
844
- ...(event.server === undefined ? {} : { server: event.server }),
845
- ...(event.vendor === undefined ? {} : { vendor: event.vendor }),
846
- });
847
- break;
848
- case "toolcall_end": {
849
- const index = toolCallIndexes.get(event.callId);
850
- const block = index === undefined ? undefined : content[index];
851
- if (index !== undefined && block?.type === "tool_call") {
852
- const finished = {
853
- ...block,
854
- arguments: event.arguments,
855
- ...(event.incomplete === undefined
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
856
2126
  ? {}
857
- : { incomplete: event.incomplete }),
858
- };
859
- content[index] = finished;
860
- await persist(finished);
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;
861
2147
  }
862
- break;
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;
863
2173
  }
864
- // The provider settled a server tool call on its own backend and streams the
865
- // result here. The agent simply ignores it: nothing to execute, nothing to
866
- // store — the events still reach the hooks like every other event.
867
- case "toolcall_result_start":
868
- case "toolcall_result_delta":
869
- case "toolcall_result_end":
870
- break;
871
- case "done":
872
- return {
873
- content,
874
- state: event.state,
875
- ...(event.state === "error" ? { errorMessage: event.message } : {}),
876
- };
877
- default:
878
- break;
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");
879
2184
  }
880
2185
  }
881
- return { content, state: undefined };
882
2186
  }
883
2187
  /**
884
- * Create the provider session on first use, resolving the provider from the registry by its
885
- * serializable ID at that moment; an unregistered ID fails the turn like any thrown error.
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.
886
2192
  */
887
- async #ensureSession() {
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
+ }
888
2208
  if (this.#session === undefined) {
889
2209
  const provider = this.#providers.get(this.#providerId);
890
2210
  if (provider === null) {
891
2211
  throw new Error(`Provider "${this.#providerId}" is not registered.`);
892
2212
  }
893
2213
  this.#session = await provider.session(this.id, {
894
- instructions: this.#instructions(),
895
- tools: [...this.#tools()],
2214
+ instructions,
2215
+ tools: [...tools],
896
2216
  });
2217
+ this.#sessionConfig = key;
897
2218
  }
898
2219
  return this.#session;
899
2220
  }
2221
+ /** Report one stream event to the hooks. Hooks observe the stream; they never fail a run. */
900
2222
  #emit(event) {
901
2223
  try {
902
2224
  this.#hooks.onEvent?.(this.#ctx, event);
@@ -906,4 +2228,68 @@ export class AgentBase {
906
2228
  }
907
2229
  }
908
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
+ }
909
2295
  //# sourceMappingURL=AgentBase.js.map