@frockbot/kernel-agent-loop 0.3.4 → 0.3.6
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/deadlines.test.ts +246 -0
- package/src/index.test.ts +89 -0
- package/src/index.ts +160 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/kernel-agent-loop",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.6",
|
|
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.3.
|
|
15
|
+
"@frockbot/kernel-contracts": "0.3.6",
|
|
16
16
|
"cordis": "4.0.0-rc.8"
|
|
17
17
|
},
|
|
18
18
|
"devDependencies": {
|
|
19
|
-
"@frockbot/plugin-models": "0.3.
|
|
20
|
-
"@frockbot/plugin-prompt": "0.3.
|
|
21
|
-
"@frockbot/plugin-tools": "0.3.
|
|
19
|
+
"@frockbot/plugin-models": "0.3.6",
|
|
20
|
+
"@frockbot/plugin-prompt": "0.3.6",
|
|
21
|
+
"@frockbot/plugin-tools": "0.3.6",
|
|
22
22
|
"@types/bun": "1.4.0",
|
|
23
23
|
"@types/node": "26.2.0",
|
|
24
24
|
"typescript": "^7.0.2"
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
// Two Turns that never ended: one hung for seventeen minutes with nothing on
|
|
2
|
+
// screen because nothing bounded a Turn's wall clock, and a provider that
|
|
3
|
+
// rejected a request before it started took the whole Turn down rather than
|
|
4
|
+
// being tried once more.
|
|
5
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
6
|
+
import {
|
|
7
|
+
LlmEffectNotStartedError,
|
|
8
|
+
type LlmProvider,
|
|
9
|
+
SessionStore,
|
|
10
|
+
} from "@frockbot/kernel-contracts";
|
|
11
|
+
import { LlmRegistry } from "@frockbot/plugin-models";
|
|
12
|
+
import { SystemPromptRegistry } from "@frockbot/plugin-prompt";
|
|
13
|
+
import { ToolRegistry } from "@frockbot/plugin-tools";
|
|
14
|
+
import { AgentRegistry } from "./agent.js";
|
|
15
|
+
import { Context, type Plugin } from "cordis";
|
|
16
|
+
import { AgentLoop, TURN_DEADLINE_REASON_V1 } from "./index.js";
|
|
17
|
+
|
|
18
|
+
const roots: Context[] = [];
|
|
19
|
+
const allowEffect = () => Promise.resolve(true);
|
|
20
|
+
|
|
21
|
+
afterEach(async () => {
|
|
22
|
+
await Promise.all(roots.splice(0).map((root) => root.fiber.dispose()));
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
async function mount(
|
|
26
|
+
provider: LlmProvider,
|
|
27
|
+
config: { turnDeadlineMs?: number } = {},
|
|
28
|
+
): Promise<Context> {
|
|
29
|
+
const root = new Context();
|
|
30
|
+
roots.push(root);
|
|
31
|
+
await root.plugin(SessionStore, {});
|
|
32
|
+
await root.plugin(SystemPromptRegistry);
|
|
33
|
+
await root.plugin(LlmRegistry);
|
|
34
|
+
await root.plugin(ToolRegistry);
|
|
35
|
+
await root.plugin(AgentRegistry);
|
|
36
|
+
const promptPlugin: Plugin.Function = (ctx) =>
|
|
37
|
+
ctx.systemPrompt.register({ id: "identity", render: () => "Be useful." });
|
|
38
|
+
promptPlugin.inject = ["systemPrompt"];
|
|
39
|
+
const providerPlugin: Plugin.Function = (ctx) => ctx.llm.register(provider);
|
|
40
|
+
providerPlugin.inject = ["llm"];
|
|
41
|
+
await root.plugin(promptPlugin);
|
|
42
|
+
await root.plugin(providerPlugin);
|
|
43
|
+
await root.plugin(AgentLoop, {
|
|
44
|
+
maxSteps: 4,
|
|
45
|
+
composition: {
|
|
46
|
+
generationId: "generation-1",
|
|
47
|
+
artifactSetHash: "a".repeat(64),
|
|
48
|
+
},
|
|
49
|
+
...config,
|
|
50
|
+
});
|
|
51
|
+
return root;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe("a Turn that runs out of wall clock", () => {
|
|
55
|
+
test("ends as interrupted, saying why, instead of hanging", async () => {
|
|
56
|
+
const provider: LlmProvider = {
|
|
57
|
+
id: "silent",
|
|
58
|
+
// A provider that accepted the request and will never answer. This is
|
|
59
|
+
// the seventeen-minute Turn, reproduced.
|
|
60
|
+
// eslint-disable-next-line require-yield
|
|
61
|
+
async *stream(_request, signal) {
|
|
62
|
+
await new Promise((_resolve, reject) => {
|
|
63
|
+
signal.addEventListener("abort", () => reject(signal.reason), {
|
|
64
|
+
once: true,
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
const root = await mount(provider, { turnDeadlineMs: 25 });
|
|
70
|
+
const handle = await root.agents.create({
|
|
71
|
+
botId: "deadline-bot",
|
|
72
|
+
sessionId: "deadline",
|
|
73
|
+
provider: "silent",
|
|
74
|
+
model: "test-model",
|
|
75
|
+
admitEffect: allowEffect,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
handle.agent.send("Take your time.");
|
|
79
|
+
await handle.agent.whenIdle();
|
|
80
|
+
|
|
81
|
+
// The model effect is unsettled, so the Turn is owed a reconciliation
|
|
82
|
+
// rather than a clean end — but it *ended*, which is the whole point, and
|
|
83
|
+
// ADR 0028 settles it from there.
|
|
84
|
+
const journal = handle.agent.session.events;
|
|
85
|
+
expect(
|
|
86
|
+
journal.some((event) => event.type === "model/reconciliation-required"),
|
|
87
|
+
).toBe(true);
|
|
88
|
+
expect(handle.agent.status).toBe("idle");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("is reported as the deadline, never as a Stop the person did not press", async () => {
|
|
92
|
+
const provider: LlmProvider = {
|
|
93
|
+
id: "never-reached",
|
|
94
|
+
// eslint-disable-next-line require-yield
|
|
95
|
+
async *stream() {
|
|
96
|
+
throw new Error("the Turn should never have got this far");
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
const root = await mount(provider, { turnDeadlineMs: 25 });
|
|
100
|
+
// Stalling before the first model request leaves no uncertain effect, so
|
|
101
|
+
// the Turn settles on its own terms and the reason it carries is the one
|
|
102
|
+
// under test. The deadline aborts the same controller Stop does; the
|
|
103
|
+
// person must not be told they stopped it.
|
|
104
|
+
let stalled: (() => void) | undefined;
|
|
105
|
+
root.on("agent/pre-step", async (_agent, _inputs, _turn, _step, next) => {
|
|
106
|
+
await new Promise<void>((_resolve, reject) => {
|
|
107
|
+
stalled = () => reject(new Error("stalled"));
|
|
108
|
+
});
|
|
109
|
+
return next();
|
|
110
|
+
});
|
|
111
|
+
// The deadline aborts the loop's controller, which the stalled hook does
|
|
112
|
+
// not itself watch; releasing it here stands in for whatever slow thing a
|
|
113
|
+
// real Turn was waiting on noticing that nobody is waiting any more.
|
|
114
|
+
setTimeout(() => stalled?.(), 60);
|
|
115
|
+
const handle = await root.agents.create({
|
|
116
|
+
botId: "deadline-reason-bot",
|
|
117
|
+
sessionId: "deadline-reason",
|
|
118
|
+
provider: "never-reached",
|
|
119
|
+
model: "test-model",
|
|
120
|
+
admitEffect: allowEffect,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
handle.agent.send("Stall before the model.");
|
|
124
|
+
await handle.agent.whenIdle();
|
|
125
|
+
|
|
126
|
+
const end = handle.agent.session.events.findLast(
|
|
127
|
+
(event) => event.type === "turn/end",
|
|
128
|
+
);
|
|
129
|
+
if (end?.type !== "turn/end") throw new Error("the Turn never ended");
|
|
130
|
+
expect(end.outcome).toBe("interrupted");
|
|
131
|
+
expect(end.reason).toBe(TURN_DEADLINE_REASON_V1);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe("a model request the provider says never started", () => {
|
|
136
|
+
test("is tried once more, and the Turn succeeds on the retry", async () => {
|
|
137
|
+
let attempts = 0;
|
|
138
|
+
const provider: LlmProvider = {
|
|
139
|
+
id: "flaky-binding",
|
|
140
|
+
async *stream() {
|
|
141
|
+
attempts += 1;
|
|
142
|
+
if (attempts === 1) {
|
|
143
|
+
throw new LlmEffectNotStartedError(
|
|
144
|
+
"model binding was not resolvable",
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
yield { type: "text-delta", text: "Second time lucky." } as const;
|
|
148
|
+
yield { type: "finish", reason: "completed" } as const;
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
const root = await mount(provider);
|
|
152
|
+
const handle = await root.agents.create({
|
|
153
|
+
botId: "retry-bot",
|
|
154
|
+
sessionId: "retry",
|
|
155
|
+
provider: "flaky-binding",
|
|
156
|
+
model: "test-model",
|
|
157
|
+
admitEffect: allowEffect,
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
handle.agent.send("Say hello.");
|
|
161
|
+
await handle.agent.whenIdle();
|
|
162
|
+
|
|
163
|
+
expect(attempts).toBe(2);
|
|
164
|
+
expect(handle.agent.session.events.at(-1)).toMatchObject({
|
|
165
|
+
type: "turn/end",
|
|
166
|
+
outcome: "completed",
|
|
167
|
+
});
|
|
168
|
+
// The retry is not a hidden event type: the durable log already shows the
|
|
169
|
+
// attempt that did not start and the one that replaced it.
|
|
170
|
+
expect(
|
|
171
|
+
handle.agent.session.events.filter(
|
|
172
|
+
(event) => event.type === "model/request",
|
|
173
|
+
),
|
|
174
|
+
).toHaveLength(2);
|
|
175
|
+
expect(
|
|
176
|
+
handle.agent.session.events.filter(
|
|
177
|
+
(event) => event.type === "model/effect-not-started",
|
|
178
|
+
),
|
|
179
|
+
).toHaveLength(1);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("is not retried a second time", async () => {
|
|
183
|
+
let attempts = 0;
|
|
184
|
+
const provider: LlmProvider = {
|
|
185
|
+
id: "always-rejects",
|
|
186
|
+
async *stream() {
|
|
187
|
+
attempts += 1;
|
|
188
|
+
throw new LlmEffectNotStartedError("invalid api key");
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
const root = await mount(provider);
|
|
192
|
+
const handle = await root.agents.create({
|
|
193
|
+
botId: "no-retry-bot",
|
|
194
|
+
sessionId: "no-retry",
|
|
195
|
+
provider: "always-rejects",
|
|
196
|
+
model: "test-model",
|
|
197
|
+
admitEffect: allowEffect,
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
handle.agent.send("Say hello.");
|
|
201
|
+
await handle.agent.whenIdle();
|
|
202
|
+
|
|
203
|
+
expect(attempts).toBe(2);
|
|
204
|
+
expect(handle.agent.session.events.at(-1)).toMatchObject({
|
|
205
|
+
type: "turn/end",
|
|
206
|
+
outcome: "model-error",
|
|
207
|
+
reason: "invalid api key",
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("an uncertain failure is never retried", async () => {
|
|
212
|
+
let attempts = 0;
|
|
213
|
+
const provider: LlmProvider = {
|
|
214
|
+
id: "uncertain",
|
|
215
|
+
async *stream() {
|
|
216
|
+
attempts += 1;
|
|
217
|
+
// Not classified as unstarted: the call may well have run, so trying
|
|
218
|
+
// again would be a silent duplicate.
|
|
219
|
+
throw new Error("connection reset mid-stream");
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
const root = await mount(provider);
|
|
223
|
+
const handle = await root.agents.create({
|
|
224
|
+
botId: "uncertain-bot",
|
|
225
|
+
sessionId: "uncertain",
|
|
226
|
+
provider: "uncertain",
|
|
227
|
+
model: "test-model",
|
|
228
|
+
admitEffect: allowEffect,
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
handle.agent.send("Say hello.");
|
|
232
|
+
await handle.agent.whenIdle();
|
|
233
|
+
|
|
234
|
+
expect(attempts).toBe(1);
|
|
235
|
+
expect(
|
|
236
|
+
handle.agent.session.events.some(
|
|
237
|
+
(event) => event.type === "model/reconciliation-required",
|
|
238
|
+
),
|
|
239
|
+
).toBe(true);
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
// Named so a reader who greps for the reason string finds where it is set.
|
|
244
|
+
test("the deadline reason tells the person what to do about it", () => {
|
|
245
|
+
expect(TURN_DEADLINE_REASON_V1).toContain("Try sending it again");
|
|
246
|
+
});
|
package/src/index.test.ts
CHANGED
|
@@ -2970,4 +2970,93 @@ describe("AgentLoop", () => {
|
|
|
2970
2970
|
expect(end).toMatchObject({ type: "turn/end", outcome: "completed" });
|
|
2971
2971
|
expect(end && Object.hasOwn(end, "reason")).toBe(false);
|
|
2972
2972
|
});
|
|
2973
|
+
|
|
2974
|
+
test("reaching the step limit is reported as stopping, not as a model error", async () => {
|
|
2975
|
+
const provider: LlmProvider = {
|
|
2976
|
+
id: "never-stops",
|
|
2977
|
+
async *stream() {
|
|
2978
|
+
yield {
|
|
2979
|
+
type: "tool-call",
|
|
2980
|
+
call: {
|
|
2981
|
+
id: `call-${crypto.randomUUID()}`,
|
|
2982
|
+
name: "loop_tool",
|
|
2983
|
+
input: {},
|
|
2984
|
+
},
|
|
2985
|
+
};
|
|
2986
|
+
yield { type: "finish", reason: "tool-calls" };
|
|
2987
|
+
},
|
|
2988
|
+
};
|
|
2989
|
+
const errors: unknown[] = [];
|
|
2990
|
+
const root = await mountRuntime(provider, {
|
|
2991
|
+
name: "loop_tool",
|
|
2992
|
+
description: "Never ends the Turn.",
|
|
2993
|
+
inputSchema: { type: "object" },
|
|
2994
|
+
execute: () => Promise.resolve({ content: "again", isError: false }),
|
|
2995
|
+
});
|
|
2996
|
+
root.on("agent/error", (_agent, error) => {
|
|
2997
|
+
errors.push(error);
|
|
2998
|
+
});
|
|
2999
|
+
const handle = await root.agents.create({
|
|
3000
|
+
...allowEffectOptions,
|
|
3001
|
+
botId: "step-limit-bot",
|
|
3002
|
+
sessionId: "step-limit",
|
|
3003
|
+
provider: "never-stops",
|
|
3004
|
+
model: "test-model",
|
|
3005
|
+
});
|
|
3006
|
+
|
|
3007
|
+
handle.agent.send("keep going");
|
|
3008
|
+
await handle.agent.whenIdle();
|
|
3009
|
+
|
|
3010
|
+
expect(handle.agent.session.events.at(-1)).toMatchObject({
|
|
3011
|
+
type: "turn/end",
|
|
3012
|
+
outcome: "interrupted",
|
|
3013
|
+
reason: "stopped after 4 steps",
|
|
3014
|
+
});
|
|
3015
|
+
// Nothing about the model failed, so nothing is reported as if it had.
|
|
3016
|
+
expect(errors).toEqual([]);
|
|
3017
|
+
});
|
|
3018
|
+
|
|
3019
|
+
test("a Turn whose first flush fails ends once instead of spinning", async () => {
|
|
3020
|
+
let streams = 0;
|
|
3021
|
+
const provider: LlmProvider = {
|
|
3022
|
+
id: "persist-fails",
|
|
3023
|
+
async *stream() {
|
|
3024
|
+
streams += 1;
|
|
3025
|
+
yield { type: "finish", reason: "completed" };
|
|
3026
|
+
},
|
|
3027
|
+
};
|
|
3028
|
+
let writes = 0;
|
|
3029
|
+
const root = await mountRuntime(
|
|
3030
|
+
provider,
|
|
3031
|
+
undefined,
|
|
3032
|
+
// Storage that is simply gone: every durable write rejects.
|
|
3033
|
+
() => {
|
|
3034
|
+
writes += 1;
|
|
3035
|
+
return Promise.reject(new Error("durable storage is unavailable"));
|
|
3036
|
+
},
|
|
3037
|
+
);
|
|
3038
|
+
const handle = await root.agents.create({
|
|
3039
|
+
...allowEffectOptions,
|
|
3040
|
+
botId: "persist-bot",
|
|
3041
|
+
sessionId: "persist-fails",
|
|
3042
|
+
provider: "persist-fails",
|
|
3043
|
+
model: "test-model",
|
|
3044
|
+
});
|
|
3045
|
+
|
|
3046
|
+
handle.agent.send("say something");
|
|
3047
|
+
// The failure reaches the caller, exactly once: the input was claimed
|
|
3048
|
+
// before the flush, so nothing hands it back to be started again.
|
|
3049
|
+
await expect(handle.agent.whenIdle()).rejects.toThrow(
|
|
3050
|
+
"durable storage is unavailable",
|
|
3051
|
+
);
|
|
3052
|
+
const attempts = writes;
|
|
3053
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
3054
|
+
expect(writes).toBe(attempts);
|
|
3055
|
+
expect(streams).toBe(0);
|
|
3056
|
+
expect(
|
|
3057
|
+
handle.agent.session.events.filter(
|
|
3058
|
+
(event) => event.type === "turn/start",
|
|
3059
|
+
),
|
|
3060
|
+
).toHaveLength(1);
|
|
3061
|
+
});
|
|
2973
3062
|
});
|
package/src/index.ts
CHANGED
|
@@ -41,6 +41,11 @@ declare module "cordis" {
|
|
|
41
41
|
|
|
42
42
|
export interface AgentLoopConfig {
|
|
43
43
|
maxSteps?: number;
|
|
44
|
+
/**
|
|
45
|
+
* The wall clock one Turn is allowed, in milliseconds. Defaults to
|
|
46
|
+
* {@link TURN_DEADLINE_MS_V1}; named by a caller only to test it.
|
|
47
|
+
*/
|
|
48
|
+
turnDeadlineMs?: number;
|
|
44
49
|
/** The Composition generation this mounted root was pinned to at admission. */
|
|
45
50
|
composition: CompositionPinV1;
|
|
46
51
|
}
|
|
@@ -88,6 +93,44 @@ class ToolEffectReconciliationRequiredError extends Error {
|
|
|
88
93
|
}
|
|
89
94
|
}
|
|
90
95
|
|
|
96
|
+
/**
|
|
97
|
+
* The Turn used every step it was allowed.
|
|
98
|
+
*
|
|
99
|
+
* Not a model error: nothing failed, and everything the Turn did in those
|
|
100
|
+
* steps is durable. It is reported as what it is — a Turn that stopped after
|
|
101
|
+
* so many steps — so the person is told the Bot ran out of room rather than
|
|
102
|
+
* that their model broke.
|
|
103
|
+
*/
|
|
104
|
+
class StepLimitReachedError extends Error {
|
|
105
|
+
constructor(readonly steps: number) {
|
|
106
|
+
super(`stopped after ${steps} steps`);
|
|
107
|
+
this.name = "StepLimitReachedError";
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The longest a single Turn may run before the loop stops waiting for it.
|
|
113
|
+
*
|
|
114
|
+
* Nothing bounded a Turn's wall clock before this: one hung for seventeen
|
|
115
|
+
* minutes with an animated avatar and nothing else, and would have hung until
|
|
116
|
+
* the isolate died. Fifteen minutes is well past any Turn a person is watching
|
|
117
|
+
* and well inside the point at which they have concluded the product is broken.
|
|
118
|
+
*/
|
|
119
|
+
export const TURN_DEADLINE_MS_V1 = 15 * 60 * 1000;
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* How many times one step will send its model request.
|
|
123
|
+
*
|
|
124
|
+
* Two: the first attempt and one retry. It applies only to a failure the
|
|
125
|
+
* provider classified as never having started, so no retry can duplicate a
|
|
126
|
+
* call that may already have run.
|
|
127
|
+
*/
|
|
128
|
+
export const MODEL_REQUEST_ATTEMPTS_V1 = 2;
|
|
129
|
+
|
|
130
|
+
/** What a `turn/end` records when the Turn ran out of wall clock. */
|
|
131
|
+
export const TURN_DEADLINE_REASON_V1 =
|
|
132
|
+
"This Turn ran for 15 minutes without finishing and was stopped. Try sending it again.";
|
|
133
|
+
|
|
91
134
|
/** Durable Stop won the final effect-admission transaction. */
|
|
92
135
|
class EffectAdmissionFencedError extends Error {
|
|
93
136
|
constructor(readonly effectId: string) {
|
|
@@ -158,6 +201,16 @@ class LoopAgent implements Agent {
|
|
|
158
201
|
#cancelDetail: string | undefined;
|
|
159
202
|
#disposeRequested = false;
|
|
160
203
|
#resumeRequested = false;
|
|
204
|
+
/**
|
|
205
|
+
* The Turn's wall clock, rearmed for each Turn a wake runs.
|
|
206
|
+
*
|
|
207
|
+
* It aborts the same controller Stop uses, so nothing in the step loop has
|
|
208
|
+
* to learn about a second signal; the flag beside it is what tells the
|
|
209
|
+
* settlement that the abort was a deadline rather than a person.
|
|
210
|
+
*/
|
|
211
|
+
#turnDeadlineTimer: ReturnType<typeof setTimeout> | undefined;
|
|
212
|
+
#turnDeadlineReached = false;
|
|
213
|
+
#turnDeadlineMs: number;
|
|
161
214
|
|
|
162
215
|
constructor(
|
|
163
216
|
ctx: Context,
|
|
@@ -165,6 +218,7 @@ class LoopAgent implements Agent {
|
|
|
165
218
|
options: EffectAdmittingAgentOptions,
|
|
166
219
|
maxSteps: number,
|
|
167
220
|
composition: CompositionPinV1,
|
|
221
|
+
turnDeadlineMs: number,
|
|
168
222
|
) {
|
|
169
223
|
this.#ctx = ctx;
|
|
170
224
|
this.#composition = composition;
|
|
@@ -178,6 +232,7 @@ class LoopAgent implements Agent {
|
|
|
178
232
|
this.#turnType = options.turnType ?? "chat";
|
|
179
233
|
this.#subagentRole = options.subagentRole;
|
|
180
234
|
this.#maxSteps = maxSteps;
|
|
235
|
+
this.#turnDeadlineMs = turnDeadlineMs;
|
|
181
236
|
}
|
|
182
237
|
|
|
183
238
|
get status(): AgentStatus {
|
|
@@ -240,6 +295,27 @@ class LoopAgent implements Agent {
|
|
|
240
295
|
this.#controller?.abort(new Error(`agent cancelled by ${reason}`));
|
|
241
296
|
}
|
|
242
297
|
|
|
298
|
+
/**
|
|
299
|
+
* Start this Turn's clock. Any previous Turn's is cleared first, so a wake
|
|
300
|
+
* that runs three queued Turns gives each of them the full allowance rather
|
|
301
|
+
* than sharing one.
|
|
302
|
+
*/
|
|
303
|
+
#armTurnDeadline(): void {
|
|
304
|
+
this.#disarmTurnDeadline();
|
|
305
|
+
this.#turnDeadlineReached = false;
|
|
306
|
+
this.#turnDeadlineTimer = setTimeout(() => {
|
|
307
|
+
this.#turnDeadlineReached = true;
|
|
308
|
+
this.#controller?.abort(new Error(TURN_DEADLINE_REASON_V1));
|
|
309
|
+
}, this.#turnDeadlineMs);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
#disarmTurnDeadline(): void {
|
|
313
|
+
if (this.#turnDeadlineTimer !== undefined) {
|
|
314
|
+
clearTimeout(this.#turnDeadlineTimer);
|
|
315
|
+
this.#turnDeadlineTimer = undefined;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
243
319
|
async whenIdle(): Promise<void> {
|
|
244
320
|
let activity: Promise<void>;
|
|
245
321
|
do {
|
|
@@ -272,11 +348,23 @@ class LoopAgent implements Agent {
|
|
|
272
348
|
}
|
|
273
349
|
this.#controller = new AbortController();
|
|
274
350
|
this.#setStatus("running");
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
351
|
+
let failed = false;
|
|
352
|
+
const activity = this.#drive(this.#controller.signal)
|
|
353
|
+
.catch((error: unknown) => {
|
|
354
|
+
// A Turn that could not even journal its own start throws out of
|
|
355
|
+
// `#drive`. Re-waking on the inbox it left behind would append another
|
|
356
|
+
// `turn/start`, fail the same way, and spin — so the failure ends the
|
|
357
|
+
// waking and reaches whoever is awaiting this Turn.
|
|
358
|
+
failed = true;
|
|
359
|
+
throw error;
|
|
360
|
+
})
|
|
361
|
+
.finally(() => {
|
|
362
|
+
this.#controller = undefined;
|
|
363
|
+
if (!this.#disposeRequested) this.#setStatus("idle");
|
|
364
|
+
if (!this.#disposeRequested && !failed && this.#inbox.length > 0) {
|
|
365
|
+
this.#wake();
|
|
366
|
+
}
|
|
367
|
+
});
|
|
280
368
|
this.#activity = activity;
|
|
281
369
|
}
|
|
282
370
|
|
|
@@ -357,6 +445,7 @@ class LoopAgent implements Agent {
|
|
|
357
445
|
let turnOutcome: StepOutcome = "interrupted";
|
|
358
446
|
let turnReason: string | undefined;
|
|
359
447
|
let reconciliationRequired = false;
|
|
448
|
+
this.#armTurnDeadline();
|
|
360
449
|
try {
|
|
361
450
|
if (latestAssistant) {
|
|
362
451
|
await this.#notifyModelOutcome(latestAssistant.requestId, "completed");
|
|
@@ -580,7 +669,7 @@ class LoopAgent implements Agent {
|
|
|
580
669
|
}
|
|
581
670
|
}
|
|
582
671
|
}
|
|
583
|
-
throw new
|
|
672
|
+
throw new StepLimitReachedError(this.#maxSteps);
|
|
584
673
|
} catch (error) {
|
|
585
674
|
if (
|
|
586
675
|
error instanceof ModelEffectReconciliationRequiredError ||
|
|
@@ -590,18 +679,36 @@ class LoopAgent implements Agent {
|
|
|
590
679
|
) {
|
|
591
680
|
reconciliationRequired = true;
|
|
592
681
|
this.#ctx.emit("agent/error", this, error);
|
|
682
|
+
} else if (this.#turnDeadlineReached) {
|
|
683
|
+
// Ahead of the cancellation branch on purpose: the deadline aborts the
|
|
684
|
+
// same controller Stop does, and a Turn the clock ended must not be
|
|
685
|
+
// reported to the person as one they stopped.
|
|
686
|
+
turnOutcome = "interrupted";
|
|
687
|
+
turnReason = turnEndReason(TURN_DEADLINE_REASON_V1);
|
|
688
|
+
this.#ctx.emit("agent/error", this, error);
|
|
593
689
|
} else if (
|
|
594
690
|
error instanceof EffectAdmissionFencedError ||
|
|
595
691
|
signal.aborted
|
|
596
692
|
) {
|
|
597
693
|
turnOutcome = "cancelled";
|
|
598
694
|
turnReason = this.#cancelDetail;
|
|
695
|
+
} else if (error instanceof StepLimitReachedError) {
|
|
696
|
+
// The Turn ran out of room, which is not a failure of the model.
|
|
697
|
+
turnOutcome = "interrupted";
|
|
698
|
+
turnReason = turnEndReason(error.message);
|
|
599
699
|
} else {
|
|
600
700
|
turnOutcome = "model-error";
|
|
601
701
|
turnReason = turnEndReason(modelFailureMessage(error));
|
|
602
702
|
this.#ctx.emit("agent/error", this, error);
|
|
603
703
|
}
|
|
604
704
|
} finally {
|
|
705
|
+
this.#disarmTurnDeadline();
|
|
706
|
+
// A Turn owed a reconciliation writes no `turn/end`: its model request
|
|
707
|
+
// has no durable outcome, and a `turn/end` would claim to know how it
|
|
708
|
+
// ended. That is right for as long as the run might still resume — and
|
|
709
|
+
// the moment it will not, the Turn is closed by whoever settles it, in
|
|
710
|
+
// `kernel-do`'s `settledEventsV1`. Closing it here instead would either
|
|
711
|
+
// lie about an outcome or make the run unresumable (ADR 0028).
|
|
605
712
|
if (!reconciliationRequired) {
|
|
606
713
|
if (openStep !== undefined && turnOutcome === "cancelled") {
|
|
607
714
|
await this.#settleCancelledStep(openTurn, openStep);
|
|
@@ -643,14 +750,18 @@ class LoopAgent implements Agent {
|
|
|
643
750
|
{ type: "turn/admission", turn, turnType: this.#turnType },
|
|
644
751
|
{ type: "input/admitted", messageId: input.messageId, turn },
|
|
645
752
|
]);
|
|
646
|
-
|
|
753
|
+
// Claimed before the flush, not after: the input has been journaled as
|
|
754
|
+
// admitted, and leaving it in the inbox while the write settles meant a
|
|
755
|
+
// failed first flush handed it straight back to `#wake`.
|
|
647
756
|
this.#inbox.shift();
|
|
757
|
+
await this.session.flush();
|
|
648
758
|
this.#ctx.emit("agent/inbox/claimed", this, [input], turn);
|
|
649
759
|
|
|
650
760
|
let openStep: number | undefined;
|
|
651
761
|
let turnOutcome: StepOutcome = "interrupted";
|
|
652
762
|
let turnReason: string | undefined;
|
|
653
763
|
let reconciliationRequired = false;
|
|
764
|
+
this.#armTurnDeadline();
|
|
654
765
|
try {
|
|
655
766
|
let inputs = [input];
|
|
656
767
|
for (let step = 1; step <= this.#maxSteps; step += 1) {
|
|
@@ -740,7 +851,7 @@ class LoopAgent implements Agent {
|
|
|
740
851
|
}
|
|
741
852
|
inputs = [];
|
|
742
853
|
}
|
|
743
|
-
throw new
|
|
854
|
+
throw new StepLimitReachedError(this.#maxSteps);
|
|
744
855
|
} catch (error) {
|
|
745
856
|
if (
|
|
746
857
|
error instanceof ModelEffectReconciliationRequiredError ||
|
|
@@ -750,18 +861,36 @@ class LoopAgent implements Agent {
|
|
|
750
861
|
) {
|
|
751
862
|
reconciliationRequired = true;
|
|
752
863
|
this.#ctx.emit("agent/error", this, error);
|
|
864
|
+
} else if (this.#turnDeadlineReached) {
|
|
865
|
+
// Ahead of the cancellation branch on purpose: the deadline aborts the
|
|
866
|
+
// same controller Stop does, and a Turn the clock ended must not be
|
|
867
|
+
// reported to the person as one they stopped.
|
|
868
|
+
turnOutcome = "interrupted";
|
|
869
|
+
turnReason = turnEndReason(TURN_DEADLINE_REASON_V1);
|
|
870
|
+
this.#ctx.emit("agent/error", this, error);
|
|
753
871
|
} else if (
|
|
754
872
|
error instanceof EffectAdmissionFencedError ||
|
|
755
873
|
signal.aborted
|
|
756
874
|
) {
|
|
757
875
|
turnOutcome = "cancelled";
|
|
758
876
|
turnReason = this.#cancelDetail;
|
|
877
|
+
} else if (error instanceof StepLimitReachedError) {
|
|
878
|
+
// The Turn ran out of room, which is not a failure of the model.
|
|
879
|
+
turnOutcome = "interrupted";
|
|
880
|
+
turnReason = turnEndReason(error.message);
|
|
759
881
|
} else {
|
|
760
882
|
turnOutcome = "model-error";
|
|
761
883
|
turnReason = turnEndReason(modelFailureMessage(error));
|
|
762
884
|
this.#ctx.emit("agent/error", this, error);
|
|
763
885
|
}
|
|
764
886
|
} finally {
|
|
887
|
+
this.#disarmTurnDeadline();
|
|
888
|
+
// A Turn owed a reconciliation writes no `turn/end`: its model request
|
|
889
|
+
// has no durable outcome, and a `turn/end` would claim to know how it
|
|
890
|
+
// ended. That is right for as long as the run might still resume — and
|
|
891
|
+
// the moment it will not, the Turn is closed by whoever settles it, in
|
|
892
|
+
// `kernel-do`'s `settledEventsV1`. Closing it here instead would either
|
|
893
|
+
// lie about an outcome or make the run unresumable (ADR 0028).
|
|
765
894
|
if (!reconciliationRequired) {
|
|
766
895
|
if (openStep !== undefined && turnOutcome === "cancelled") {
|
|
767
896
|
await this.#settleCancelledStep(turn, openStep);
|
|
@@ -819,7 +948,16 @@ class LoopAgent implements Agent {
|
|
|
819
948
|
turnType: this.#turnType,
|
|
820
949
|
});
|
|
821
950
|
|
|
951
|
+
// One automatic retry, and only for a failure the provider itself
|
|
952
|
+
// classified as "the request never started" — a rejected key, an
|
|
953
|
+
// unresolvable binding, a connection refused before any byte was sent.
|
|
954
|
+
// Those are exactly the failures where retrying cannot duplicate anything,
|
|
955
|
+
// and the ones a person watching a blank screen would retry by hand. Every
|
|
956
|
+
// other failure is uncertain and is never retried, which is the whole of
|
|
957
|
+
// ADR 0024's durability contract.
|
|
958
|
+
let attempts = 0;
|
|
822
959
|
while (true) {
|
|
960
|
+
attempts += 1;
|
|
823
961
|
const proposedMessages = this.session.deriveMessages();
|
|
824
962
|
const messages = await this.#ctx.waterfall(
|
|
825
963
|
"agent/message-window",
|
|
@@ -924,14 +1062,24 @@ class LoopAgent implements Agent {
|
|
|
924
1062
|
});
|
|
925
1063
|
await this.session.flush();
|
|
926
1064
|
await this.#notifyModelOutcome(request.requestId, "not-started");
|
|
1065
|
+
// The durable evidence of a retry is the log itself: a `model/request`,
|
|
1066
|
+
// the `model/effect-not-started` just journaled against it, and then a
|
|
1067
|
+
// second `model/request`. A Package listening on `agent/request-error`
|
|
1068
|
+
// still has the final say in either direction.
|
|
927
1069
|
const action = await this.#ctx.waterfall(
|
|
928
1070
|
"agent/request-error",
|
|
929
1071
|
this,
|
|
930
1072
|
error,
|
|
931
1073
|
signal,
|
|
932
|
-
() =>
|
|
1074
|
+
() =>
|
|
1075
|
+
Promise.resolve(
|
|
1076
|
+
attempts < MODEL_REQUEST_ATTEMPTS_V1
|
|
1077
|
+
? ({ kind: "retry" } as const)
|
|
1078
|
+
: ({ kind: "fail" } as const),
|
|
1079
|
+
),
|
|
933
1080
|
);
|
|
934
1081
|
if (action.kind !== "retry") throw error;
|
|
1082
|
+
this.#ctx.emit("agent/error", this, error);
|
|
935
1083
|
}
|
|
936
1084
|
}
|
|
937
1085
|
}
|
|
@@ -1275,6 +1423,7 @@ class LoopAgent implements Agent {
|
|
|
1275
1423
|
export class AgentLoop extends Service implements AgentFactory {
|
|
1276
1424
|
static inject = ["sessions", "systemPrompt", "llm", "tools", "agents"];
|
|
1277
1425
|
private maxSteps: number;
|
|
1426
|
+
private turnDeadlineMs: number;
|
|
1278
1427
|
private composition: CompositionPinV1;
|
|
1279
1428
|
private handles = new Set<AgentHandle>();
|
|
1280
1429
|
|
|
@@ -1282,6 +1431,7 @@ export class AgentLoop extends Service implements AgentFactory {
|
|
|
1282
1431
|
super(ctx, "agentLoop");
|
|
1283
1432
|
this.composition = config.composition;
|
|
1284
1433
|
this.maxSteps = config.maxSteps ?? 20;
|
|
1434
|
+
this.turnDeadlineMs = config.turnDeadlineMs ?? TURN_DEADLINE_MS_V1;
|
|
1285
1435
|
if (!Number.isInteger(this.maxSteps) || this.maxSteps <= 0) {
|
|
1286
1436
|
throw new Error("agent-loop maxSteps must be a positive integer");
|
|
1287
1437
|
}
|
|
@@ -1298,6 +1448,7 @@ export class AgentLoop extends Service implements AgentFactory {
|
|
|
1298
1448
|
options as EffectAdmittingAgentOptions,
|
|
1299
1449
|
this.maxSteps,
|
|
1300
1450
|
this.composition,
|
|
1451
|
+
this.turnDeadlineMs,
|
|
1301
1452
|
);
|
|
1302
1453
|
const unregister = this.ctx.agents.register(agent);
|
|
1303
1454
|
let disposed = false;
|