@frockbot/kernel-agent-loop 0.0.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts ADDED
@@ -0,0 +1,1219 @@
1
+ import {
2
+ type Agent,
3
+ type AgentFactory,
4
+ type AgentHandle,
5
+ type AgentInput,
6
+ type AgentOptions,
7
+ type AgentSendV1,
8
+ type AgentStatus,
9
+ type PreStepDecision,
10
+ } from "./agent.js";
11
+ import {
12
+ type CompositionPinV1,
13
+ decodeSkillRefsV1,
14
+ LlmEffectNotStartedError,
15
+ type LlmStreamEvent,
16
+ type NormalizedModelRequest,
17
+ type Session,
18
+ type SessionEvent,
19
+ type StepOutcome,
20
+ type ToolCall,
21
+ type ToolCallOccurrence,
22
+ type ToolExecutionResult,
23
+ type TurnTypeV1,
24
+ toolCallOccurrences,
25
+ turnEndReason,
26
+ validateSettledToolOccurrenceJournal,
27
+ validateToolOccurrenceJournal,
28
+ } from "@frockbot/kernel-contracts";
29
+ import { type Context, Service } from "cordis";
30
+
31
+ declare module "cordis" {
32
+ interface Events {
33
+ "agent/model-outcome-committed": (
34
+ agent: Agent,
35
+ requestId: string,
36
+ outcome: "completed" | "not-started",
37
+ ) => Promise<void>;
38
+ }
39
+ }
40
+
41
+ export interface AgentLoopConfig {
42
+ maxSteps?: number;
43
+ /** The Composition generation this mounted root was pinned to at admission. */
44
+ composition: CompositionPinV1;
45
+ }
46
+
47
+ type EffectAdmittingAgentOptions = AgentOptions & {
48
+ admitEffect(effect: {
49
+ kind: "model" | "tool";
50
+ effectId: string;
51
+ }): Promise<boolean>;
52
+ };
53
+
54
+ declare module "cordis" {
55
+ interface Context {
56
+ agentLoop: AgentLoop;
57
+ }
58
+ }
59
+
60
+ interface ModelResponse {
61
+ request: NormalizedModelRequest;
62
+ text: string;
63
+ toolCalls: ToolCall[];
64
+ }
65
+
66
+ type ModelReconciliation =
67
+ | { status: "recovered"; response: ModelResponse }
68
+ | { status: "unavailable"; reason: string };
69
+
70
+ class ModelEffectReconciliationRequiredError extends Error {
71
+ constructor(
72
+ readonly requestId: string,
73
+ message: string,
74
+ ) {
75
+ super(message);
76
+ this.name = "ModelEffectReconciliationRequiredError";
77
+ }
78
+ }
79
+
80
+ class ToolEffectReconciliationRequiredError extends Error {
81
+ constructor(
82
+ readonly occurrenceId: string,
83
+ message: string,
84
+ ) {
85
+ super(message);
86
+ this.name = "ToolEffectReconciliationRequiredError";
87
+ }
88
+ }
89
+
90
+ /** Durable Stop won the final effect-admission transaction. */
91
+ class EffectAdmissionFencedError extends Error {
92
+ constructor(readonly effectId: string) {
93
+ super(`Effect "${effectId}" was fenced by durable Stop`);
94
+ this.name = "EffectAdmissionFencedError";
95
+ }
96
+ }
97
+
98
+ function hasUnsettledExternalEffect(events: readonly SessionEvent[]): boolean {
99
+ let unresolvedRequestId: string | undefined;
100
+ for (const event of events) {
101
+ if (event.type === "model/request") {
102
+ unresolvedRequestId = event.request.requestId;
103
+ } else if (
104
+ (event.type === "assistant/message" ||
105
+ event.type === "model/effect-not-started") &&
106
+ event.requestId === unresolvedRequestId
107
+ ) {
108
+ unresolvedRequestId = undefined;
109
+ }
110
+ }
111
+ if (unresolvedRequestId) return true;
112
+ try {
113
+ return [...validateToolOccurrenceJournal(events).values()].some(
114
+ (entry) => entry.intent && !entry.result,
115
+ );
116
+ } catch {
117
+ // Invalid effect history is never safe to close as cancelled.
118
+ return true;
119
+ }
120
+ }
121
+
122
+ class ModelOutcomeSettlementRequiredError extends Error {
123
+ constructor(readonly cause: unknown) {
124
+ super("Durable model outcome settlement is pending");
125
+ this.name = "ModelOutcomeSettlementRequiredError";
126
+ }
127
+ }
128
+
129
+ function modelFailureMessage(error: unknown): string {
130
+ return error instanceof Error && error.message
131
+ ? error.message
132
+ : "Model provider response was lost";
133
+ }
134
+
135
+ class LoopAgent implements Agent {
136
+ readonly id: string;
137
+ readonly botId: string;
138
+ readonly session: Session;
139
+ #ctx: Context;
140
+ #options: EffectAdmittingAgentOptions;
141
+ #maxSteps: number;
142
+ #composition: CompositionPinV1;
143
+ /** The turn type every Turn of this Agent is admitted as. */
144
+ #turnType: TurnTypeV1;
145
+ /** The subagent role that turn type was admitted under, when it has one. */
146
+ #subagentRole: string | undefined;
147
+ #status: AgentStatus = "idle";
148
+ #inbox: AgentInput[] = [];
149
+ #activity: Promise<void> = Promise.resolve();
150
+ #controller: AbortController | undefined;
151
+ #disposeRequested = false;
152
+ #resumeRequested = false;
153
+
154
+ constructor(
155
+ ctx: Context,
156
+ session: Session,
157
+ options: EffectAdmittingAgentOptions,
158
+ maxSteps: number,
159
+ composition: CompositionPinV1,
160
+ ) {
161
+ this.#ctx = ctx;
162
+ this.#composition = composition;
163
+ this.session = session;
164
+ this.botId = options.botId;
165
+ const explicitAgentId = (
166
+ options as AgentOptions & { agentId?: string }
167
+ ).agentId?.trim();
168
+ this.id = explicitAgentId || options.sessionId;
169
+ this.#options = options;
170
+ this.#turnType = options.turnType ?? "chat";
171
+ this.#subagentRole = options.subagentRole;
172
+ this.#maxSteps = maxSteps;
173
+ }
174
+
175
+ get status(): AgentStatus {
176
+ return this.#status;
177
+ }
178
+
179
+ send(request: string | AgentSendV1): string {
180
+ if (this.#disposeRequested)
181
+ throw new Error(`agent "${this.id}" is disposing`);
182
+ const sent = typeof request === "string" ? { text: request } : request;
183
+ const normalized = sent.text.trim();
184
+ if (!normalized) throw new Error("agent input is empty");
185
+ // Decoded here rather than trusted: `send` is the kernel's inbound seam
186
+ // for an input, and an invoked Skill is durable state the moment
187
+ // `input/queued` is appended.
188
+ const skills =
189
+ sent.skills === undefined
190
+ ? undefined
191
+ : decodeSkillRefsV1([...sent.skills], "agent input skills");
192
+ const input: AgentInput = {
193
+ messageId: crypto.randomUUID(),
194
+ text: normalized,
195
+ ...(skills && skills.length > 0 ? { skills } : {}),
196
+ };
197
+ this.session.append({ type: "input/queued", ...input });
198
+ this.#inbox.push(input);
199
+ this.#ctx.emit("agent/inbox/inserted", this, input);
200
+ this.#wake();
201
+ return input.messageId;
202
+ }
203
+
204
+ resume(): void {
205
+ if (this.#disposeRequested)
206
+ throw new Error(`agent "${this.id}" is disposing`);
207
+ if (
208
+ this.#status !== "idle" ||
209
+ this.#inbox.length > 0 ||
210
+ this.#resumeRequested
211
+ ) {
212
+ throw new Error(`agent "${this.id}" cannot resume while active`);
213
+ }
214
+ this.#resumeRequested = true;
215
+ this.#wake();
216
+ }
217
+
218
+ cancel(reason: "user" | "shutdown" = "user"): void {
219
+ if (this.#status === "disposed") return;
220
+ this.#ctx.emit("agent/cancel-requested", this, reason);
221
+ const queued = this.#inbox.splice(0);
222
+ if (queued.length > 0) {
223
+ this.session.appendBatch(
224
+ queued.map((input) => ({
225
+ type: "input/cancelled" as const,
226
+ messageId: input.messageId,
227
+ reason,
228
+ })),
229
+ );
230
+ }
231
+ this.#controller?.abort(new Error(`agent cancelled by ${reason}`));
232
+ }
233
+
234
+ async whenIdle(): Promise<void> {
235
+ let activity: Promise<void>;
236
+ do {
237
+ activity = this.#activity;
238
+ await activity;
239
+ } while (activity !== this.#activity);
240
+ }
241
+
242
+ async dispose(): Promise<void> {
243
+ if (this.#disposeRequested) return this.whenIdle();
244
+ this.#disposeRequested = true;
245
+ this.cancel("shutdown");
246
+ await this.whenIdle();
247
+ this.#setStatus("disposed");
248
+ }
249
+
250
+ #setStatus(status: AgentStatus): void {
251
+ if (status === this.#status) return;
252
+ this.#status = status;
253
+ this.#ctx.emit("agent/status", this, status);
254
+ }
255
+
256
+ #wake(): void {
257
+ if (
258
+ this.#disposeRequested ||
259
+ this.#status !== "idle" ||
260
+ (this.#inbox.length === 0 && !this.#resumeRequested)
261
+ ) {
262
+ return;
263
+ }
264
+ this.#controller = new AbortController();
265
+ this.#setStatus("running");
266
+ const activity = this.#drive(this.#controller.signal).finally(() => {
267
+ this.#controller = undefined;
268
+ if (!this.#disposeRequested) this.#setStatus("idle");
269
+ if (!this.#disposeRequested && this.#inbox.length > 0) this.#wake();
270
+ });
271
+ this.#activity = activity;
272
+ }
273
+
274
+ async #drive(signal: AbortSignal): Promise<void> {
275
+ if (this.#resumeRequested) {
276
+ this.#resumeRequested = false;
277
+ await this.#resumeTurn(signal);
278
+ }
279
+ while (!signal.aborted && this.#inbox.length > 0) {
280
+ await this.#runTurn(signal);
281
+ }
282
+ }
283
+
284
+ async #resumeTurn(signal: AbortSignal): Promise<void> {
285
+ let openTurn: number | undefined;
286
+ let latestStep = 0;
287
+ let latestStepStatus: "none" | "open" | "ended" = "none";
288
+ let latestStepOutcome: StepOutcome | undefined;
289
+ let unresolvedRequest: NormalizedModelRequest | undefined;
290
+ let definitiveNoEffect:
291
+ Extract<SessionEvent, { type: "model/effect-not-started" }> | undefined;
292
+ for (const event of this.session.events) {
293
+ if (event.type === "turn/start") {
294
+ openTurn = event.turn;
295
+ latestStep = 0;
296
+ latestStepStatus = "none";
297
+ latestStepOutcome = undefined;
298
+ unresolvedRequest = undefined;
299
+ definitiveNoEffect = undefined;
300
+ }
301
+ if (event.type === "turn/end" && event.turn === openTurn)
302
+ openTurn = undefined;
303
+ if (event.type === "step/start" && event.turn === openTurn) {
304
+ latestStep = Math.max(latestStep, event.step);
305
+ latestStepStatus = "open";
306
+ latestStepOutcome = undefined;
307
+ }
308
+ if (
309
+ event.type === "step/end" &&
310
+ event.turn === openTurn &&
311
+ event.step === latestStep
312
+ ) {
313
+ latestStepStatus = "ended";
314
+ latestStepOutcome = event.outcome;
315
+ }
316
+ if (event.type === "model/request" && event.turn === openTurn) {
317
+ unresolvedRequest = event.request;
318
+ definitiveNoEffect = undefined;
319
+ }
320
+ if (
321
+ event.type === "model/effect-not-started" &&
322
+ event.requestId === unresolvedRequest?.requestId
323
+ ) {
324
+ definitiveNoEffect = event;
325
+ }
326
+ if (
327
+ event.type === "assistant/message" &&
328
+ event.requestId === unresolvedRequest?.requestId
329
+ ) {
330
+ unresolvedRequest = undefined;
331
+ definitiveNoEffect = undefined;
332
+ }
333
+ }
334
+ if (openTurn === undefined)
335
+ throw new Error("session has no resumable turn");
336
+ let latestAssistant:
337
+ Extract<SessionEvent, { type: "assistant/message" }> | undefined;
338
+ for (const event of this.session.events) {
339
+ if (
340
+ event.type === "assistant/message" &&
341
+ event.turn === openTurn &&
342
+ event.step === latestStep
343
+ ) {
344
+ latestAssistant = event;
345
+ }
346
+ }
347
+ let openStep: number | undefined;
348
+ let turnOutcome: StepOutcome = "interrupted";
349
+ let turnReason: string | undefined;
350
+ let reconciliationRequired = false;
351
+ try {
352
+ if (latestAssistant) {
353
+ await this.#notifyModelOutcome(latestAssistant.requestId, "completed");
354
+ }
355
+ let nextStep = latestStep === 0 ? 1 : latestStep + 1;
356
+ if (unresolvedRequest) {
357
+ openStep = latestStep;
358
+ if (definitiveNoEffect) {
359
+ await this.#notifyModelOutcome(
360
+ definitiveNoEffect.requestId,
361
+ "not-started",
362
+ );
363
+ turnOutcome = "model-error";
364
+ turnReason = turnEndReason(definitiveNoEffect.reason);
365
+ this.#ctx.emit(
366
+ "agent/error",
367
+ this,
368
+ new LlmEffectNotStartedError(definitiveNoEffect.reason),
369
+ );
370
+ return;
371
+ }
372
+ const reconciliation = await this.#reconcileModel(
373
+ unresolvedRequest,
374
+ openTurn,
375
+ latestStep,
376
+ signal,
377
+ );
378
+ if (reconciliation.status === "unavailable") {
379
+ const existing = this.session.events.findLast(
380
+ (event) =>
381
+ event.type === "model/reconciliation-required" &&
382
+ event.requestId === unresolvedRequest.requestId,
383
+ );
384
+ if (
385
+ existing?.type !== "model/reconciliation-required" ||
386
+ existing.reason !== reconciliation.reason
387
+ ) {
388
+ this.session.append({
389
+ type: "model/reconciliation-required",
390
+ turn: openTurn,
391
+ step: latestStep,
392
+ requestId: unresolvedRequest.requestId,
393
+ reason: reconciliation.reason,
394
+ });
395
+ }
396
+ reconciliationRequired = true;
397
+ return;
398
+ }
399
+ const { response } = reconciliation;
400
+ this.session.append({
401
+ type: "assistant/message",
402
+ turn: openTurn,
403
+ step: latestStep,
404
+ requestId: response.request.requestId,
405
+ text: response.text,
406
+ toolCalls: response.toolCalls,
407
+ });
408
+ await this.session.flush();
409
+ signal.throwIfAborted();
410
+ await this.#notifyModelOutcome(response.request.requestId, "completed");
411
+ if (response.toolCalls.length === 0) {
412
+ this.session.append({
413
+ type: "step/end",
414
+ turn: openTurn,
415
+ step: latestStep,
416
+ outcome: "completed",
417
+ });
418
+ openStep = undefined;
419
+ turnOutcome = "completed";
420
+ return;
421
+ }
422
+ const endsTurn = await this.#executeTools(
423
+ toolCallOccurrences(openTurn, latestStep, response.toolCalls),
424
+ signal,
425
+ );
426
+ signal.throwIfAborted();
427
+ this.session.append({
428
+ type: "step/end",
429
+ turn: openTurn,
430
+ step: latestStep,
431
+ outcome: "completed",
432
+ });
433
+ openStep = undefined;
434
+ if (endsTurn) {
435
+ turnOutcome = "completed";
436
+ return;
437
+ }
438
+ nextStep = latestStep + 1;
439
+ } else if (latestStepStatus === "open" && latestAssistant) {
440
+ openStep = latestStep;
441
+ if (latestAssistant.toolCalls.length === 0) {
442
+ this.session.append({
443
+ type: "step/end",
444
+ turn: openTurn,
445
+ step: latestStep,
446
+ outcome: "completed",
447
+ });
448
+ openStep = undefined;
449
+ turnOutcome = "completed";
450
+ return;
451
+ }
452
+ const occurrences = toolCallOccurrences(
453
+ openTurn,
454
+ latestStep,
455
+ latestAssistant.toolCalls,
456
+ );
457
+ const endsTurn = await this.#executeTools(occurrences, signal);
458
+ signal.throwIfAborted();
459
+ this.session.append({
460
+ type: "step/end",
461
+ turn: openTurn,
462
+ step: latestStep,
463
+ outcome: "completed",
464
+ });
465
+ openStep = undefined;
466
+ if (endsTurn) {
467
+ turnOutcome = "completed";
468
+ return;
469
+ }
470
+ nextStep = latestStep + 1;
471
+ } else if (latestStepStatus === "ended") {
472
+ turnOutcome = latestStepOutcome ?? "interrupted";
473
+ if (
474
+ turnOutcome !== "completed" ||
475
+ !latestAssistant ||
476
+ latestAssistant.toolCalls.length === 0
477
+ ) {
478
+ return;
479
+ }
480
+ } else if (latestStepStatus === "open") {
481
+ nextStep = latestStep;
482
+ }
483
+ for (let step = nextStep; step <= this.#maxSteps; step += 1) {
484
+ signal.throwIfAborted();
485
+ openStep = step;
486
+ if (!(latestStepStatus === "open" && step === latestStep)) {
487
+ this.session.append({ type: "step/start", turn: openTurn, step });
488
+ }
489
+ const response = await this.#requestModel(openTurn, step, signal);
490
+ this.session.append({
491
+ type: "assistant/message",
492
+ turn: openTurn,
493
+ step,
494
+ requestId: response.request.requestId,
495
+ text: response.text,
496
+ toolCalls: response.toolCalls,
497
+ });
498
+ await this.session.flush();
499
+ signal.throwIfAborted();
500
+ await this.#notifyModelOutcome(response.request.requestId, "completed");
501
+ if (response.toolCalls.length === 0) {
502
+ this.session.append({
503
+ type: "step/end",
504
+ turn: openTurn,
505
+ step,
506
+ outcome: "completed",
507
+ });
508
+ openStep = undefined;
509
+ turnOutcome = "completed";
510
+ return;
511
+ }
512
+ const endsTurn = await this.#executeTools(
513
+ toolCallOccurrences(openTurn, step, response.toolCalls),
514
+ signal,
515
+ );
516
+ signal.throwIfAborted();
517
+ this.session.append({
518
+ type: "step/end",
519
+ turn: openTurn,
520
+ step,
521
+ outcome: "completed",
522
+ });
523
+ openStep = undefined;
524
+ if (endsTurn) {
525
+ turnOutcome = "completed";
526
+ return;
527
+ }
528
+ }
529
+ throw new Error(`agent exceeded ${this.#maxSteps} steps`);
530
+ } catch (error) {
531
+ if (
532
+ error instanceof ModelEffectReconciliationRequiredError ||
533
+ error instanceof ToolEffectReconciliationRequiredError ||
534
+ error instanceof ModelOutcomeSettlementRequiredError ||
535
+ (signal.aborted && hasUnsettledExternalEffect(this.session.events))
536
+ ) {
537
+ reconciliationRequired = true;
538
+ this.#ctx.emit("agent/error", this, error);
539
+ } else if (
540
+ error instanceof EffectAdmissionFencedError ||
541
+ signal.aborted
542
+ ) {
543
+ turnOutcome = "cancelled";
544
+ } else {
545
+ turnOutcome = "model-error";
546
+ turnReason = turnEndReason(modelFailureMessage(error));
547
+ this.#ctx.emit("agent/error", this, error);
548
+ }
549
+ } finally {
550
+ if (!reconciliationRequired) {
551
+ if (openStep !== undefined && turnOutcome === "cancelled") {
552
+ await this.#settleCancelledStep(openTurn, openStep);
553
+ }
554
+ if (openStep !== undefined) {
555
+ this.session.append({
556
+ type: "step/end",
557
+ turn: openTurn,
558
+ step: openStep,
559
+ outcome: turnOutcome,
560
+ });
561
+ }
562
+ this.session.append({
563
+ type: "turn/end",
564
+ turn: openTurn,
565
+ outcome: turnOutcome,
566
+ ...(turnOutcome !== "completed" && turnReason !== undefined
567
+ ? { reason: turnReason }
568
+ : {}),
569
+ });
570
+ }
571
+ await this.session.flush();
572
+ await this.#ctx.serial("agent/turn-stopping", this, openTurn);
573
+ }
574
+ }
575
+
576
+ async #runTurn(signal: AbortSignal): Promise<void> {
577
+ const input = this.#inbox[0];
578
+ if (!input) return;
579
+ const turn = this.session.nextTurn();
580
+ this.session.appendBatch([
581
+ { type: "turn/start", turn },
582
+ {
583
+ type: "composition/pinned",
584
+ turn,
585
+ generationId: this.#composition.generationId,
586
+ artifactSetHash: this.#composition.artifactSetHash,
587
+ },
588
+ { type: "turn/admission", turn, turnType: this.#turnType },
589
+ { type: "input/admitted", messageId: input.messageId, turn },
590
+ ]);
591
+ await this.session.flush();
592
+ this.#inbox.shift();
593
+ this.#ctx.emit("agent/inbox/claimed", this, [input], turn);
594
+
595
+ let openStep: number | undefined;
596
+ let turnOutcome: StepOutcome = "interrupted";
597
+ let turnReason: string | undefined;
598
+ let reconciliationRequired = false;
599
+ try {
600
+ let inputs = [input];
601
+ for (let step = 1; step <= this.#maxSteps; step += 1) {
602
+ signal.throwIfAborted();
603
+ const decision = await this.#ctx.waterfall(
604
+ "agent/pre-step",
605
+ this,
606
+ inputs,
607
+ turn,
608
+ step,
609
+ () => Promise.resolve<PreStepDecision>({ kind: "enter", inputs }),
610
+ );
611
+ if (decision.kind === "reject") {
612
+ turnOutcome = "blocked";
613
+ turnReason = turnEndReason(decision.reason);
614
+ return;
615
+ }
616
+
617
+ openStep = step;
618
+ this.session.append({ type: "step/start", turn, step });
619
+ for (const admitted of decision.inputs) {
620
+ this.session.append({
621
+ type: "user/message",
622
+ turn,
623
+ step,
624
+ messageId: admitted.messageId,
625
+ text: admitted.text,
626
+ });
627
+ }
628
+
629
+ const response = await this.#requestModel(turn, step, signal);
630
+ this.session.append({
631
+ type: "assistant/message",
632
+ turn,
633
+ step,
634
+ requestId: response.request.requestId,
635
+ text: response.text,
636
+ toolCalls: response.toolCalls,
637
+ });
638
+ await this.session.flush();
639
+ signal.throwIfAborted();
640
+ await this.#notifyModelOutcome(response.request.requestId, "completed");
641
+
642
+ if (response.toolCalls.length === 0) {
643
+ this.session.append({
644
+ type: "step/end",
645
+ turn,
646
+ step,
647
+ outcome: "completed",
648
+ });
649
+ openStep = undefined;
650
+ turnOutcome = "completed";
651
+ return;
652
+ }
653
+
654
+ const endsTurn = await this.#executeTools(
655
+ toolCallOccurrences(turn, step, response.toolCalls),
656
+ signal,
657
+ );
658
+ signal.throwIfAborted();
659
+ this.session.append({
660
+ type: "step/end",
661
+ turn,
662
+ step,
663
+ outcome: "completed",
664
+ });
665
+ openStep = undefined;
666
+ // A tool result that ends the Turn closes it here: no further model
667
+ // request, and the log replays as a completed Turn with none.
668
+ if (endsTurn) {
669
+ turnOutcome = "completed";
670
+ return;
671
+ }
672
+ inputs = [];
673
+ }
674
+ throw new Error(`agent exceeded ${this.#maxSteps} steps`);
675
+ } catch (error) {
676
+ if (
677
+ error instanceof ModelEffectReconciliationRequiredError ||
678
+ error instanceof ToolEffectReconciliationRequiredError ||
679
+ error instanceof ModelOutcomeSettlementRequiredError ||
680
+ (signal.aborted && hasUnsettledExternalEffect(this.session.events))
681
+ ) {
682
+ reconciliationRequired = true;
683
+ this.#ctx.emit("agent/error", this, error);
684
+ } else if (
685
+ error instanceof EffectAdmissionFencedError ||
686
+ signal.aborted
687
+ ) {
688
+ turnOutcome = "cancelled";
689
+ } else {
690
+ turnOutcome = "model-error";
691
+ turnReason = turnEndReason(modelFailureMessage(error));
692
+ this.#ctx.emit("agent/error", this, error);
693
+ }
694
+ } finally {
695
+ if (!reconciliationRequired) {
696
+ if (openStep !== undefined && turnOutcome === "cancelled") {
697
+ await this.#settleCancelledStep(turn, openStep);
698
+ }
699
+ if (openStep !== undefined) {
700
+ this.session.append({
701
+ type: "step/end",
702
+ turn,
703
+ step: openStep,
704
+ outcome: turnOutcome,
705
+ });
706
+ }
707
+ this.session.append({
708
+ type: "turn/end",
709
+ turn,
710
+ outcome: turnOutcome,
711
+ ...(turnOutcome !== "completed" && turnReason !== undefined
712
+ ? { reason: turnReason }
713
+ : {}),
714
+ });
715
+ }
716
+ await this.session.flush();
717
+ await this.#ctx.serial("agent/turn-stopping", this, turn);
718
+ }
719
+ }
720
+
721
+ async #notifyModelOutcome(
722
+ requestId: string,
723
+ outcome: "completed" | "not-started",
724
+ ): Promise<void> {
725
+ try {
726
+ await this.#ctx.serial(
727
+ "agent/model-outcome-committed",
728
+ this,
729
+ requestId,
730
+ outcome,
731
+ );
732
+ } catch (error) {
733
+ throw new ModelOutcomeSettlementRequiredError(error);
734
+ }
735
+ }
736
+
737
+ async #requestModel(
738
+ turn: number,
739
+ step: number,
740
+ signal: AbortSignal,
741
+ ): Promise<ModelResponse> {
742
+ validateSettledToolOccurrenceJournal(this.session.events);
743
+ const assembly = await this.#ctx.systemPrompt.assemble({
744
+ sessionId: this.session.id,
745
+ provider: this.#options.provider,
746
+ model: this.#options.model,
747
+ // The same turn type the tool catalog is trimmed to. A section that
748
+ // renders what a Turn may do would otherwise have to guess it.
749
+ turnType: this.#turnType,
750
+ });
751
+
752
+ while (true) {
753
+ const proposed: NormalizedModelRequest = {
754
+ requestId: crypto.randomUUID(),
755
+ provider: this.#options.provider,
756
+ model: this.#options.model,
757
+ system: assembly.text,
758
+ messages: this.session.deriveMessages(),
759
+ tools: this.#ctx.tools.schemas({
760
+ turnType: this.#turnType,
761
+ ...(this.#subagentRole === undefined
762
+ ? {}
763
+ : { subagentRole: this.#subagentRole }),
764
+ }),
765
+ ...(this.#options.modelBinding
766
+ ? { modelBinding: structuredClone(this.#options.modelBinding) }
767
+ : {}),
768
+ };
769
+ const request = await this.#ctx.waterfall(
770
+ "agent/request",
771
+ this,
772
+ proposed,
773
+ signal,
774
+ () => Promise.resolve(proposed),
775
+ );
776
+ this.session.append({ type: "model/request", turn, step, request });
777
+ await this.session.flush();
778
+ if (
779
+ !(await this.#options.admitEffect({
780
+ kind: "model",
781
+ effectId: request.requestId,
782
+ }))
783
+ ) {
784
+ this.session.append({
785
+ type: "model/effect-not-started",
786
+ turn,
787
+ step,
788
+ requestId: request.requestId,
789
+ reason: "Durable Stop fenced provider execution",
790
+ });
791
+ await this.session.flush();
792
+ throw new EffectAdmissionFencedError(request.requestId);
793
+ }
794
+
795
+ try {
796
+ return await this.#consumeStream(request, turn, step, signal);
797
+ } catch (error) {
798
+ if (signal.aborted) {
799
+ const reason = `Model response outcome is uncertain after cancellation: ${modelFailureMessage(error)}`;
800
+ this.session.append({
801
+ type: "model/reconciliation-required",
802
+ turn,
803
+ step,
804
+ requestId: request.requestId,
805
+ reason,
806
+ });
807
+ await this.session.flush();
808
+ throw new ModelEffectReconciliationRequiredError(
809
+ request.requestId,
810
+ reason,
811
+ );
812
+ }
813
+ if (!(error instanceof LlmEffectNotStartedError)) {
814
+ const reason = `Model response outcome is uncertain: ${modelFailureMessage(error)}`;
815
+ this.session.append({
816
+ type: "model/reconciliation-required",
817
+ turn,
818
+ step,
819
+ requestId: request.requestId,
820
+ reason,
821
+ });
822
+ await this.session.flush();
823
+ throw new ModelEffectReconciliationRequiredError(
824
+ request.requestId,
825
+ reason,
826
+ );
827
+ }
828
+ this.session.append({
829
+ type: "model/effect-not-started",
830
+ turn,
831
+ step,
832
+ requestId: request.requestId,
833
+ reason: modelFailureMessage(error),
834
+ });
835
+ await this.session.flush();
836
+ await this.#notifyModelOutcome(request.requestId, "not-started");
837
+ const action = await this.#ctx.waterfall(
838
+ "agent/request-error",
839
+ this,
840
+ error,
841
+ signal,
842
+ () => Promise.resolve({ kind: "fail" as const }),
843
+ );
844
+ if (action.kind !== "retry") throw error;
845
+ }
846
+ }
847
+ }
848
+
849
+ async #consumeStream(
850
+ request: NormalizedModelRequest,
851
+ turn: number,
852
+ step: number,
853
+ signal: AbortSignal,
854
+ ): Promise<ModelResponse> {
855
+ let text = "";
856
+ const toolCalls: ToolCall[] = [];
857
+ let receivedProviderEvent = false;
858
+ try {
859
+ for await (const event of this.#ctx.llm.stream(request, signal)) {
860
+ receivedProviderEvent = true;
861
+ signal.throwIfAborted();
862
+ this.#applyStreamEvent(
863
+ event,
864
+ request.requestId,
865
+ turn,
866
+ step,
867
+ toolCalls,
868
+ (delta) => {
869
+ text += delta;
870
+ },
871
+ );
872
+ }
873
+ } catch (error) {
874
+ if (receivedProviderEvent && error instanceof LlmEffectNotStartedError) {
875
+ throw new Error(
876
+ "Model provider reported no effect after returning response data",
877
+ );
878
+ }
879
+ throw error;
880
+ }
881
+ return { request, text, toolCalls };
882
+ }
883
+
884
+ async #reconcileModel(
885
+ request: NormalizedModelRequest,
886
+ turn: number,
887
+ step: number,
888
+ signal: AbortSignal,
889
+ ): Promise<ModelReconciliation> {
890
+ const reconciliation = await this.#ctx.llm.reconcile(request, signal);
891
+ if (reconciliation.status === "unavailable") return reconciliation;
892
+ const durablePrefix = this.session.events.flatMap((event) =>
893
+ event.type === "assistant/chunk" &&
894
+ event.turn === turn &&
895
+ event.step === step &&
896
+ event.requestId === request.requestId
897
+ ? [{ type: "text-delta" as const, text: event.text }]
898
+ : [],
899
+ );
900
+ const recoveredTextDeltas = reconciliation.events.flatMap((event) =>
901
+ event.type === "text-delta" ? [event] : [],
902
+ );
903
+ const prefixMatches = durablePrefix.every((event, index) => {
904
+ const recovered = recoveredTextDeltas[index];
905
+ return recovered?.text === event.text;
906
+ });
907
+ if (!prefixMatches || recoveredTextDeltas.length < durablePrefix.length) {
908
+ return {
909
+ status: "unavailable",
910
+ reason: `Provider-bound retrieval diverged from durable response prefix for request "${request.requestId}"`,
911
+ };
912
+ }
913
+ const finishIndexes = reconciliation.events.flatMap((event, index) =>
914
+ event.type === "finish" ? [index] : [],
915
+ );
916
+ if (
917
+ finishIndexes.length !== 1 ||
918
+ finishIndexes[0] !== reconciliation.events.length - 1
919
+ ) {
920
+ return {
921
+ status: "unavailable",
922
+ reason: `Provider-bound retrieval returned an invalid event structure for request "${request.requestId}"`,
923
+ };
924
+ }
925
+ let text = "";
926
+ const toolCalls: ToolCall[] = [];
927
+ let textDeltaIndex = 0;
928
+ for (const event of reconciliation.events) {
929
+ signal.throwIfAborted();
930
+ const journalTextDelta =
931
+ event.type !== "text-delta" || textDeltaIndex >= durablePrefix.length;
932
+ this.#applyStreamEvent(
933
+ event,
934
+ request.requestId,
935
+ turn,
936
+ step,
937
+ toolCalls,
938
+ (delta) => {
939
+ text += delta;
940
+ },
941
+ journalTextDelta,
942
+ );
943
+ if (event.type === "text-delta") textDeltaIndex += 1;
944
+ }
945
+ return {
946
+ status: "recovered",
947
+ response: { request, text, toolCalls },
948
+ };
949
+ }
950
+
951
+ #applyStreamEvent(
952
+ event: LlmStreamEvent,
953
+ requestId: string,
954
+ turn: number,
955
+ step: number,
956
+ toolCalls: ToolCall[],
957
+ appendText: (text: string) => void,
958
+ journal = true,
959
+ ): void {
960
+ if (event.type === "text-delta") {
961
+ appendText(event.text);
962
+ if (journal) {
963
+ this.session.append({
964
+ type: "assistant/chunk",
965
+ turn,
966
+ step,
967
+ requestId,
968
+ text: event.text,
969
+ });
970
+ }
971
+ } else if (event.type === "tool-call") {
972
+ toolCalls.push(event.call);
973
+ }
974
+ }
975
+
976
+ /**
977
+ * Runs every occurrence and reports whether any result ended the Turn. The
978
+ * boolean is per *result*, not per definition: one tool can end a Turn for
979
+ * one payload and not another, and the kernel never inspects which.
980
+ */
981
+ async #executeTools(
982
+ occurrences: readonly ToolCallOccurrence[],
983
+ signal: AbortSignal,
984
+ ): Promise<boolean> {
985
+ let endsTurn = false;
986
+ for (const occurrence of occurrences) {
987
+ signal.throwIfAborted();
988
+ const { call, occurrenceId, turn, step } = occurrence;
989
+ const journal = validateToolOccurrenceJournal(this.session.events);
990
+ const existing = journal.get(occurrenceId);
991
+ if (existing?.result) continue;
992
+ const context = {
993
+ botId: this.botId,
994
+ agentId: this.id,
995
+ sessionId: this.session.id,
996
+ effectId: occurrenceId,
997
+ toolCall: call,
998
+ compositionGenerationId: this.#composition.generationId,
999
+ turnType: this.#turnType,
1000
+ ...(this.#subagentRole === undefined
1001
+ ? {}
1002
+ : { subagentRole: this.#subagentRole }),
1003
+ signal,
1004
+ };
1005
+ const preparation = await this.#ctx.tools.prepare(call, context);
1006
+ signal.throwIfAborted();
1007
+ if (existing?.intent && preparation.kind !== "ready") {
1008
+ throw new ToolEffectReconciliationRequiredError(
1009
+ occurrenceId,
1010
+ `Tool effect "${occurrenceId}" cannot be reconciled because its definition is unavailable`,
1011
+ );
1012
+ }
1013
+ if (!existing?.intent) {
1014
+ this.session.append({
1015
+ type: "tool/call",
1016
+ turn,
1017
+ step,
1018
+ occurrenceId,
1019
+ name: call.name,
1020
+ input: call.input,
1021
+ });
1022
+ await this.session.flush();
1023
+ if (signal.aborted) {
1024
+ this.session.append({
1025
+ type: "tool/result",
1026
+ turn,
1027
+ step,
1028
+ occurrenceId,
1029
+ name: call.name,
1030
+ content: "Cancelled before tool execution started.",
1031
+ isError: true,
1032
+ status: "interrupted",
1033
+ });
1034
+ await this.session.flush();
1035
+ signal.throwIfAborted();
1036
+ }
1037
+ }
1038
+ let result: ToolExecutionResult;
1039
+ if (existing?.intent) {
1040
+ if (preparation.kind !== "ready") {
1041
+ throw new ToolEffectReconciliationRequiredError(
1042
+ occurrenceId,
1043
+ `Tool effect "${occurrenceId}" cannot be reconciled because its definition is unavailable`,
1044
+ );
1045
+ }
1046
+ const reconciliation = await this.#ctx.tools.reconcilePrepared(
1047
+ preparation,
1048
+ context,
1049
+ );
1050
+ if (reconciliation.status === "unavailable") {
1051
+ throw new ToolEffectReconciliationRequiredError(
1052
+ occurrenceId,
1053
+ reconciliation.reason,
1054
+ );
1055
+ }
1056
+ result = reconciliation.result;
1057
+ } else if (preparation.kind === "denied") {
1058
+ result = preparation.result;
1059
+ this.#ctx.emit("tools/result", call, result);
1060
+ } else {
1061
+ if (
1062
+ !(await this.#options.admitEffect({
1063
+ kind: "tool",
1064
+ effectId: occurrenceId,
1065
+ }))
1066
+ ) {
1067
+ this.session.append({
1068
+ type: "tool/result",
1069
+ turn,
1070
+ step,
1071
+ occurrenceId,
1072
+ name: call.name,
1073
+ content: "Cancelled before tool execution started.",
1074
+ isError: true,
1075
+ status: "interrupted",
1076
+ });
1077
+ await this.session.flush();
1078
+ throw new EffectAdmissionFencedError(occurrenceId);
1079
+ }
1080
+ try {
1081
+ result = await this.#ctx.tools.executePrepared(preparation, context);
1082
+ } catch (error) {
1083
+ if (signal.aborted || !preparation.idempotent) {
1084
+ throw new ToolEffectReconciliationRequiredError(
1085
+ occurrenceId,
1086
+ signal.aborted
1087
+ ? `Tool effect "${occurrenceId}" outcome is uncertain after cancellation`
1088
+ : `Non-idempotent tool effect "${occurrenceId}" outcome is uncertain`,
1089
+ );
1090
+ }
1091
+ result = {
1092
+ content:
1093
+ error instanceof Error ? error.message : "Tool execution failed",
1094
+ isError: true,
1095
+ };
1096
+ this.#ctx.emit("tools/result", call, result);
1097
+ }
1098
+ }
1099
+ if (result.endsTurn === true) endsTurn = true;
1100
+ this.session.append({
1101
+ type: "tool/result",
1102
+ turn,
1103
+ step,
1104
+ occurrenceId,
1105
+ name: call.name,
1106
+ content: result.content,
1107
+ isError: result.isError,
1108
+ status: "completed",
1109
+ ...(result.attachments && result.attachments.length > 0
1110
+ ? { attachments: result.attachments }
1111
+ : {}),
1112
+ });
1113
+ await this.session.flush();
1114
+ }
1115
+ return endsTurn;
1116
+ }
1117
+
1118
+ async #settleCancelledStep(turn: number, step: number): Promise<void> {
1119
+ const assistant = this.session.events.findLast(
1120
+ (event) =>
1121
+ event.type === "assistant/message" &&
1122
+ event.turn === turn &&
1123
+ event.step === step,
1124
+ );
1125
+ if (
1126
+ !assistant ||
1127
+ assistant.type !== "assistant/message" ||
1128
+ assistant.toolCalls.length === 0
1129
+ ) {
1130
+ return;
1131
+ }
1132
+
1133
+ const journal = validateToolOccurrenceJournal(this.session.events);
1134
+ for (const occurrence of toolCallOccurrences(
1135
+ turn,
1136
+ step,
1137
+ assistant.toolCalls,
1138
+ )) {
1139
+ const entry = journal.get(occurrence.occurrenceId);
1140
+ if (!entry?.intent) {
1141
+ this.session.append({
1142
+ type: "tool/call",
1143
+ turn,
1144
+ step,
1145
+ occurrenceId: occurrence.occurrenceId,
1146
+ name: occurrence.call.name,
1147
+ input: occurrence.call.input,
1148
+ });
1149
+ }
1150
+ if (!entry?.result) {
1151
+ this.session.append({
1152
+ type: "tool/result",
1153
+ turn,
1154
+ step,
1155
+ occurrenceId: occurrence.occurrenceId,
1156
+ name: occurrence.call.name,
1157
+ content: "Cancelled before tool execution started.",
1158
+ isError: true,
1159
+ status: "interrupted",
1160
+ });
1161
+ }
1162
+ }
1163
+ await this.session.flush();
1164
+ }
1165
+ }
1166
+
1167
+ export class AgentLoop extends Service implements AgentFactory {
1168
+ static inject = ["sessions", "systemPrompt", "llm", "tools", "agents"];
1169
+ private maxSteps: number;
1170
+ private composition: CompositionPinV1;
1171
+ private handles = new Set<AgentHandle>();
1172
+
1173
+ constructor(ctx: Context, config: AgentLoopConfig) {
1174
+ super(ctx, "agentLoop");
1175
+ this.composition = config.composition;
1176
+ this.maxSteps = config.maxSteps ?? 20;
1177
+ if (!Number.isInteger(this.maxSteps) || this.maxSteps <= 0) {
1178
+ throw new Error("agent-loop maxSteps must be a positive integer");
1179
+ }
1180
+ if (!this.composition?.generationId || !this.composition.artifactSetHash) {
1181
+ throw new Error("agent-loop requires a pinned Composition generation");
1182
+ }
1183
+ }
1184
+
1185
+ async create(options: AgentOptions): Promise<AgentHandle> {
1186
+ const session = this.ctx.sessions.create(options.sessionId);
1187
+ const agent = new LoopAgent(
1188
+ this.ctx,
1189
+ session,
1190
+ options as EffectAdmittingAgentOptions,
1191
+ this.maxSteps,
1192
+ this.composition,
1193
+ );
1194
+ const unregister = this.ctx.agents.register(agent);
1195
+ let disposed = false;
1196
+ let handle: AgentHandle;
1197
+ handle = {
1198
+ agent,
1199
+ dispose: async () => {
1200
+ if (disposed) return;
1201
+ disposed = true;
1202
+ await agent.dispose();
1203
+ unregister();
1204
+ this.ctx.sessions.disposeSession(options.sessionId);
1205
+ this.handles.delete(handle);
1206
+ },
1207
+ };
1208
+ this.handles.add(handle);
1209
+ return handle;
1210
+ }
1211
+
1212
+ [Service.init](): () => Promise<void> {
1213
+ const unsetFactory = this.ctx.agents.setFactory(this);
1214
+ return async () => {
1215
+ await Promise.all([...this.handles].map((handle) => handle.dispose()));
1216
+ unsetFactory();
1217
+ };
1218
+ }
1219
+ }