@frockbot/kernel-agent-loop 0.1.4 → 0.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frockbot/kernel-agent-loop",
3
- "version": "0.1.4",
3
+ "version": "0.2.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -12,13 +12,13 @@
12
12
  "typecheck": "tsc --noEmit -p tsconfig.json"
13
13
  },
14
14
  "dependencies": {
15
- "@frockbot/kernel-contracts": "0.1.4",
15
+ "@frockbot/kernel-contracts": "0.2.0",
16
16
  "cordis": "4.0.0-rc.8"
17
17
  },
18
18
  "devDependencies": {
19
- "@frockbot/plugin-models": "0.1.4",
20
- "@frockbot/plugin-prompt": "0.1.4",
21
- "@frockbot/plugin-tools": "0.1.4",
19
+ "@frockbot/plugin-models": "0.2.0",
20
+ "@frockbot/plugin-prompt": "0.2.0",
21
+ "@frockbot/plugin-tools": "0.2.0",
22
22
  "@types/bun": "1.4.0",
23
23
  "@types/node": "26.2.0",
24
24
  "typescript": "^7.0.2"
package/src/agent.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import { type Context, Service } from "cordis";
2
2
  import type {
3
3
  ModelBindingSnapshot,
4
+ LoopAgentInputV1,
5
+ LoopAgentRuntimeV1,
4
6
  NormalizedModelRequest,
5
7
  Session,
6
8
  SkillRefV1,
@@ -38,7 +40,7 @@ export interface AgentOptions {
38
40
  modelBinding?: ModelBindingSnapshot;
39
41
  }
40
42
 
41
- export interface AgentInput {
43
+ export interface AgentInput extends LoopAgentInputV1 {
42
44
  messageId: string;
43
45
  text: string;
44
46
  /**
@@ -61,7 +63,7 @@ export type PreStepDecision =
61
63
 
62
64
  export type RequestErrorAction = { kind: "retry" } | { kind: "fail" };
63
65
 
64
- export interface Agent {
66
+ export interface Agent extends LoopAgentRuntimeV1 {
65
67
  readonly id: string;
66
68
  readonly botId: string;
67
69
  readonly session: Session;
@@ -87,22 +89,6 @@ declare module "cordis" {
87
89
  }
88
90
 
89
91
  interface Events {
90
- "agent/created": (agent: Agent) => void;
91
- "agent/disposed": (agent: Agent) => void;
92
- "agent/status": (agent: Agent, status: AgentStatus) => void;
93
- "agent/inbox/inserted": (agent: Agent, input: AgentInput) => void;
94
- "agent/inbox/claimed": (
95
- agent: Agent,
96
- inputs: AgentInput[],
97
- turn: number,
98
- ) => void;
99
- "agent/pre-step": (
100
- agent: Agent,
101
- inputs: AgentInput[],
102
- turn: number,
103
- step: number,
104
- next: () => Promise<PreStepDecision>,
105
- ) => Promise<PreStepDecision>;
106
92
  "agent/request": (
107
93
  agent: Agent,
108
94
  request: NormalizedModelRequest,
@@ -121,11 +107,6 @@ declare module "cordis" {
121
107
  outcome: "completed" | "not-started",
122
108
  ) => Promise<void>;
123
109
  "agent/turn-stopping": (agent: Agent, turn: number) => Promise<void>;
124
- "agent/cancel-requested": (
125
- agent: Agent,
126
- reason: "user" | "shutdown",
127
- ) => void;
128
- "agent/error": (agent: Agent, error: unknown) => void;
129
110
  }
130
111
  }
131
112
 
package/src/index.test.ts CHANGED
@@ -4,6 +4,7 @@ import {
4
4
  LlmEffectNotStartedError,
5
5
  type LlmReconciliationOutcome,
6
6
  type LlmProvider,
7
+ type NormalizedModelRequest,
7
8
  type LlmStreamEvent,
8
9
  type PersistSessionEvents,
9
10
  type SessionEvent,
@@ -150,6 +151,79 @@ afterEach(async () => {
150
151
  });
151
152
 
152
153
  describe("AgentLoop", () => {
154
+ test("records exactly the hook-shaped request received by the provider", async () => {
155
+ let received: NormalizedModelRequest | undefined;
156
+ const provider: LlmProvider = {
157
+ id: "hook-shaped-request",
158
+ async *stream(request) {
159
+ received = structuredClone(request);
160
+ yield { type: "finish", reason: "completed" };
161
+ },
162
+ };
163
+ const root = await mountRuntime(provider, {
164
+ name: "original_tool",
165
+ description: "The initially exposed tool.",
166
+ inputSchema: { type: "object" },
167
+ execute: () => Promise.resolve({ content: "ok", isError: false }),
168
+ });
169
+ root.on("system-prompt/assemble", async (_context, next) => {
170
+ const assembly = await next();
171
+ return {
172
+ text: `${assembly.text}\nHook-shaped system context.`,
173
+ sections: [
174
+ ...assembly.sections,
175
+ { id: "hook", text: "Hook-shaped system context." },
176
+ ],
177
+ };
178
+ });
179
+ root.on(
180
+ "agent/message-window",
181
+ async (_agent, messages, _turn, _step, _signal, next) => [
182
+ ...(await next()),
183
+ { role: "user" as const, content: `window:${messages.length}` },
184
+ ],
185
+ );
186
+ root.on(
187
+ "agent/tool-exposure",
188
+ async (_agent, _tools, _turn, _step, _signal, next) => {
189
+ await next();
190
+ return [
191
+ {
192
+ name: "hook_visible",
193
+ description: "Visible only in this shaped request.",
194
+ inputSchema: { type: "object" },
195
+ },
196
+ ];
197
+ },
198
+ );
199
+ const handle = await root.agents.create({
200
+ ...allowEffectOptions,
201
+ botId: "bot-hook-request",
202
+ sessionId: "hook-shaped-request",
203
+ provider: provider.id,
204
+ model: "model-1",
205
+ });
206
+
207
+ handle.agent.send("shape the request");
208
+ await handle.agent.whenIdle();
209
+
210
+ const recorded = handle.agent.session.events.find(
211
+ (event) => event.type === "model/request",
212
+ );
213
+ if (recorded?.type !== "model/request" || !received) {
214
+ throw new Error("model request was not recorded and received");
215
+ }
216
+ expect(recorded.request).toEqual(received);
217
+ expect(recorded.request.system).toContain("Hook-shaped system context.");
218
+ expect(recorded.request.messages.at(-1)).toEqual({
219
+ role: "user",
220
+ content: "window:1",
221
+ });
222
+ expect(recorded.request.tools.map((tool) => tool.name)).toEqual([
223
+ "hook_visible",
224
+ ]);
225
+ });
226
+
153
227
  test("fences a model after durable intent without invoking its provider", async () => {
154
228
  let streams = 0;
155
229
  const provider: LlmProvider = {
package/src/index.ts CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  decodeSkillRefsV1,
14
14
  LlmEffectNotStartedError,
15
15
  type LlmStreamEvent,
16
+ type LoopStepContinuationV1,
16
17
  type NormalizedModelRequest,
17
18
  type Session,
18
19
  type SessionEvent,
@@ -409,6 +410,12 @@ class LoopAgent implements Agent {
409
410
  signal.throwIfAborted();
410
411
  await this.#notifyModelOutcome(response.request.requestId, "completed");
411
412
  if (response.toolCalls.length === 0) {
413
+ const shouldStop = await this.#stepShouldStop(
414
+ openTurn,
415
+ latestStep,
416
+ { kind: "stop" },
417
+ signal,
418
+ );
412
419
  this.session.append({
413
420
  type: "step/end",
414
421
  turn: openTurn,
@@ -416,29 +423,44 @@ class LoopAgent implements Agent {
416
423
  outcome: "completed",
417
424
  });
418
425
  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;
426
+ if (shouldStop) {
427
+ turnOutcome = "completed";
428
+ return;
429
+ }
430
+ } else {
431
+ const endsTurn = await this.#executeTools(
432
+ toolCallOccurrences(openTurn, latestStep, response.toolCalls),
433
+ signal,
434
+ );
435
+ signal.throwIfAborted();
436
+ const shouldStop = await this.#stepShouldStop(
437
+ openTurn,
438
+ latestStep,
439
+ { kind: endsTurn ? "stop" : "continue" },
440
+ signal,
441
+ );
442
+ this.session.append({
443
+ type: "step/end",
444
+ turn: openTurn,
445
+ step: latestStep,
446
+ outcome: "completed",
447
+ });
448
+ openStep = undefined;
449
+ if (shouldStop) {
450
+ turnOutcome = "completed";
451
+ return;
452
+ }
437
453
  }
438
454
  nextStep = latestStep + 1;
439
455
  } else if (latestStepStatus === "open" && latestAssistant) {
440
456
  openStep = latestStep;
441
457
  if (latestAssistant.toolCalls.length === 0) {
458
+ const shouldStop = await this.#stepShouldStop(
459
+ openTurn,
460
+ latestStep,
461
+ { kind: "stop" },
462
+ signal,
463
+ );
442
464
  this.session.append({
443
465
  type: "step/end",
444
466
  turn: openTurn,
@@ -446,26 +468,35 @@ class LoopAgent implements Agent {
446
468
  outcome: "completed",
447
469
  });
448
470
  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;
471
+ if (shouldStop) {
472
+ turnOutcome = "completed";
473
+ return;
474
+ }
475
+ } else {
476
+ const occurrences = toolCallOccurrences(
477
+ openTurn,
478
+ latestStep,
479
+ latestAssistant.toolCalls,
480
+ );
481
+ const endsTurn = await this.#executeTools(occurrences, signal);
482
+ signal.throwIfAborted();
483
+ const shouldStop = await this.#stepShouldStop(
484
+ openTurn,
485
+ latestStep,
486
+ { kind: endsTurn ? "stop" : "continue" },
487
+ signal,
488
+ );
489
+ this.session.append({
490
+ type: "step/end",
491
+ turn: openTurn,
492
+ step: latestStep,
493
+ outcome: "completed",
494
+ });
495
+ openStep = undefined;
496
+ if (shouldStop) {
497
+ turnOutcome = "completed";
498
+ return;
499
+ }
469
500
  }
470
501
  nextStep = latestStep + 1;
471
502
  } else if (latestStepStatus === "ended") {
@@ -499,6 +530,12 @@ class LoopAgent implements Agent {
499
530
  signal.throwIfAborted();
500
531
  await this.#notifyModelOutcome(response.request.requestId, "completed");
501
532
  if (response.toolCalls.length === 0) {
533
+ const shouldStop = await this.#stepShouldStop(
534
+ openTurn,
535
+ step,
536
+ { kind: "stop" },
537
+ signal,
538
+ );
502
539
  this.session.append({
503
540
  type: "step/end",
504
541
  turn: openTurn,
@@ -506,24 +543,33 @@ class LoopAgent implements Agent {
506
543
  outcome: "completed",
507
544
  });
508
545
  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;
546
+ if (shouldStop) {
547
+ turnOutcome = "completed";
548
+ return;
549
+ }
550
+ } else {
551
+ const endsTurn = await this.#executeTools(
552
+ toolCallOccurrences(openTurn, step, response.toolCalls),
553
+ signal,
554
+ );
555
+ signal.throwIfAborted();
556
+ const shouldStop = await this.#stepShouldStop(
557
+ openTurn,
558
+ step,
559
+ { kind: endsTurn ? "stop" : "continue" },
560
+ signal,
561
+ );
562
+ this.session.append({
563
+ type: "step/end",
564
+ turn: openTurn,
565
+ step,
566
+ outcome: "completed",
567
+ });
568
+ openStep = undefined;
569
+ if (shouldStop) {
570
+ turnOutcome = "completed";
571
+ return;
572
+ }
527
573
  }
528
574
  }
529
575
  throw new Error(`agent exceeded ${this.#maxSteps} steps`);
@@ -640,6 +686,12 @@ class LoopAgent implements Agent {
640
686
  await this.#notifyModelOutcome(response.request.requestId, "completed");
641
687
 
642
688
  if (response.toolCalls.length === 0) {
689
+ const shouldStop = await this.#stepShouldStop(
690
+ turn,
691
+ step,
692
+ { kind: "stop" },
693
+ signal,
694
+ );
643
695
  this.session.append({
644
696
  type: "step/end",
645
697
  turn,
@@ -647,27 +699,35 @@ class LoopAgent implements Agent {
647
699
  outcome: "completed",
648
700
  });
649
701
  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;
702
+ if (shouldStop) {
703
+ turnOutcome = "completed";
704
+ return;
705
+ }
706
+ } else {
707
+ const endsTurn = await this.#executeTools(
708
+ toolCallOccurrences(turn, step, response.toolCalls),
709
+ signal,
710
+ );
711
+ signal.throwIfAborted();
712
+ const shouldStop = await this.#stepShouldStop(
713
+ turn,
714
+ step,
715
+ { kind: endsTurn ? "stop" : "continue" },
716
+ signal,
717
+ );
718
+ this.session.append({
719
+ type: "step/end",
720
+ turn,
721
+ step,
722
+ outcome: "completed",
723
+ });
724
+ openStep = undefined;
725
+ // A tool result that ends the Turn closes it here unless declared
726
+ // termination policy replaces that default for this step.
727
+ if (shouldStop) {
728
+ turnOutcome = "completed";
729
+ return;
730
+ }
671
731
  }
672
732
  inputs = [];
673
733
  }
@@ -750,18 +810,38 @@ class LoopAgent implements Agent {
750
810
  });
751
811
 
752
812
  while (true) {
813
+ const proposedMessages = this.session.deriveMessages();
814
+ const messages = await this.#ctx.waterfall(
815
+ "agent/message-window",
816
+ this,
817
+ proposedMessages,
818
+ turn,
819
+ step,
820
+ signal,
821
+ () => Promise.resolve(proposedMessages),
822
+ );
823
+ const proposedTools = this.#ctx.tools.schemas({
824
+ turnType: this.#turnType,
825
+ ...(this.#subagentRole === undefined
826
+ ? {}
827
+ : { subagentRole: this.#subagentRole }),
828
+ });
829
+ const tools = await this.#ctx.waterfall(
830
+ "agent/tool-exposure",
831
+ this,
832
+ proposedTools,
833
+ turn,
834
+ step,
835
+ signal,
836
+ () => Promise.resolve(proposedTools),
837
+ );
753
838
  const proposed: NormalizedModelRequest = {
754
839
  requestId: crypto.randomUUID(),
755
840
  provider: this.#options.provider,
756
841
  model: this.#options.model,
757
842
  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
- }),
843
+ messages,
844
+ tools,
765
845
  ...(this.#options.modelBinding
766
846
  ? { modelBinding: structuredClone(this.#options.modelBinding) }
767
847
  : {}),
@@ -1115,6 +1195,24 @@ class LoopAgent implements Agent {
1115
1195
  return endsTurn;
1116
1196
  }
1117
1197
 
1198
+ async #stepShouldStop(
1199
+ turn: number,
1200
+ step: number,
1201
+ proposed: LoopStepContinuationV1,
1202
+ signal: AbortSignal,
1203
+ ): Promise<boolean> {
1204
+ const decision = await this.#ctx.waterfall(
1205
+ "agent/step-continuation",
1206
+ this,
1207
+ proposed,
1208
+ turn,
1209
+ step,
1210
+ signal,
1211
+ () => Promise.resolve(proposed),
1212
+ );
1213
+ return decision.kind === "stop";
1214
+ }
1215
+
1118
1216
  async #settleCancelledStep(turn: number, step: number): Promise<void> {
1119
1217
  const assistant = this.session.events.findLast(
1120
1218
  (event) =>