@frockbot/kernel-agent-loop 0.1.3 → 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 +5 -5
- package/src/agent.ts +4 -23
- package/src/index.test.ts +74 -0
- package/src/index.ts +182 -84
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/kernel-agent-loop",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
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.
|
|
20
|
-
"@frockbot/plugin-prompt": "0.
|
|
21
|
-
"@frockbot/plugin-tools": "0.
|
|
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
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
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
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
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
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
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
|
|
759
|
-
tools
|
|
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) =>
|