@frockbot/kernel-agent-loop 0.3.5 → 0.3.7
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 +13 -15
- package/src/index.ts +130 -3
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.7",
|
|
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.7",
|
|
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.7",
|
|
20
|
+
"@frockbot/plugin-prompt": "0.3.7",
|
|
21
|
+
"@frockbot/plugin-tools": "0.3.7",
|
|
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
|
@@ -1223,7 +1223,7 @@ describe("AgentLoop", () => {
|
|
|
1223
1223
|
).toBe(false);
|
|
1224
1224
|
});
|
|
1225
1225
|
|
|
1226
|
-
test("
|
|
1226
|
+
test("settles a turn whose provider cannot retrieve the effect", async () => {
|
|
1227
1227
|
let streams = 0;
|
|
1228
1228
|
const provider: LlmProvider = {
|
|
1229
1229
|
id: "unretrievable",
|
|
@@ -1279,28 +1279,26 @@ describe("AgentLoop", () => {
|
|
|
1279
1279
|
model: "model-1",
|
|
1280
1280
|
});
|
|
1281
1281
|
|
|
1282
|
-
handle.agent.resume();
|
|
1283
|
-
await handle.agent.whenIdle();
|
|
1284
1282
|
handle.agent.resume();
|
|
1285
1283
|
await handle.agent.whenIdle();
|
|
1286
1284
|
|
|
1285
|
+
// A provider that declares no retrieval will never grow one, so the run
|
|
1286
|
+
// settles as a model error rather than parking on a reconciliation that
|
|
1287
|
+
// can never happen (and throwing out of the Durable Object).
|
|
1287
1288
|
expect(streams).toBe(0);
|
|
1288
|
-
expect(
|
|
1289
|
-
handle.agent.session.events.filter(
|
|
1290
|
-
(event) => event.type === "model/reconciliation-required",
|
|
1291
|
-
),
|
|
1292
|
-
).toEqual([
|
|
1293
|
-
expect.objectContaining({
|
|
1294
|
-
requestId: "unretrievable-effect-1",
|
|
1295
|
-
reason:
|
|
1296
|
-
'LLM provider "unretrievable" does not support provider-bound retrieval',
|
|
1297
|
-
}),
|
|
1298
|
-
]);
|
|
1299
1289
|
expect(
|
|
1300
1290
|
handle.agent.session.events.some(
|
|
1301
|
-
(event) => event.type === "
|
|
1291
|
+
(event) => event.type === "model/reconciliation-required",
|
|
1302
1292
|
),
|
|
1303
1293
|
).toBe(false);
|
|
1294
|
+
const turnEnd = handle.agent.session.events.findLast(
|
|
1295
|
+
(event) => event.type === "turn/end",
|
|
1296
|
+
);
|
|
1297
|
+
expect(turnEnd).toMatchObject({
|
|
1298
|
+
outcome: "model-error",
|
|
1299
|
+
reason:
|
|
1300
|
+
'LLM provider "unretrievable" does not support provider-bound retrieval',
|
|
1301
|
+
});
|
|
1304
1302
|
});
|
|
1305
1303
|
|
|
1306
1304
|
test("keeps an ambiguous dispatched effect open for provider reconciliation", async () => {
|
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
|
}
|
|
@@ -66,7 +71,8 @@ interface ModelResponse {
|
|
|
66
71
|
|
|
67
72
|
type ModelReconciliation =
|
|
68
73
|
| { status: "recovered"; response: ModelResponse }
|
|
69
|
-
| { status: "unavailable"; reason: string }
|
|
74
|
+
| { status: "unavailable"; reason: string }
|
|
75
|
+
| { status: "not-retrievable"; reason: string };
|
|
70
76
|
|
|
71
77
|
class ModelEffectReconciliationRequiredError extends Error {
|
|
72
78
|
constructor(
|
|
@@ -103,6 +109,29 @@ class StepLimitReachedError extends Error {
|
|
|
103
109
|
}
|
|
104
110
|
}
|
|
105
111
|
|
|
112
|
+
/**
|
|
113
|
+
* The longest a single Turn may run before the loop stops waiting for it.
|
|
114
|
+
*
|
|
115
|
+
* Nothing bounded a Turn's wall clock before this: one hung for seventeen
|
|
116
|
+
* minutes with an animated avatar and nothing else, and would have hung until
|
|
117
|
+
* the isolate died. Fifteen minutes is well past any Turn a person is watching
|
|
118
|
+
* and well inside the point at which they have concluded the product is broken.
|
|
119
|
+
*/
|
|
120
|
+
export const TURN_DEADLINE_MS_V1 = 15 * 60 * 1000;
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* How many times one step will send its model request.
|
|
124
|
+
*
|
|
125
|
+
* Two: the first attempt and one retry. It applies only to a failure the
|
|
126
|
+
* provider classified as never having started, so no retry can duplicate a
|
|
127
|
+
* call that may already have run.
|
|
128
|
+
*/
|
|
129
|
+
export const MODEL_REQUEST_ATTEMPTS_V1 = 2;
|
|
130
|
+
|
|
131
|
+
/** What a `turn/end` records when the Turn ran out of wall clock. */
|
|
132
|
+
export const TURN_DEADLINE_REASON_V1 =
|
|
133
|
+
"This Turn ran for 15 minutes without finishing and was stopped. Try sending it again.";
|
|
134
|
+
|
|
106
135
|
/** Durable Stop won the final effect-admission transaction. */
|
|
107
136
|
class EffectAdmissionFencedError extends Error {
|
|
108
137
|
constructor(readonly effectId: string) {
|
|
@@ -173,6 +202,16 @@ class LoopAgent implements Agent {
|
|
|
173
202
|
#cancelDetail: string | undefined;
|
|
174
203
|
#disposeRequested = false;
|
|
175
204
|
#resumeRequested = false;
|
|
205
|
+
/**
|
|
206
|
+
* The Turn's wall clock, rearmed for each Turn a wake runs.
|
|
207
|
+
*
|
|
208
|
+
* It aborts the same controller Stop uses, so nothing in the step loop has
|
|
209
|
+
* to learn about a second signal; the flag beside it is what tells the
|
|
210
|
+
* settlement that the abort was a deadline rather than a person.
|
|
211
|
+
*/
|
|
212
|
+
#turnDeadlineTimer: ReturnType<typeof setTimeout> | undefined;
|
|
213
|
+
#turnDeadlineReached = false;
|
|
214
|
+
#turnDeadlineMs: number;
|
|
176
215
|
|
|
177
216
|
constructor(
|
|
178
217
|
ctx: Context,
|
|
@@ -180,6 +219,7 @@ class LoopAgent implements Agent {
|
|
|
180
219
|
options: EffectAdmittingAgentOptions,
|
|
181
220
|
maxSteps: number,
|
|
182
221
|
composition: CompositionPinV1,
|
|
222
|
+
turnDeadlineMs: number,
|
|
183
223
|
) {
|
|
184
224
|
this.#ctx = ctx;
|
|
185
225
|
this.#composition = composition;
|
|
@@ -193,6 +233,7 @@ class LoopAgent implements Agent {
|
|
|
193
233
|
this.#turnType = options.turnType ?? "chat";
|
|
194
234
|
this.#subagentRole = options.subagentRole;
|
|
195
235
|
this.#maxSteps = maxSteps;
|
|
236
|
+
this.#turnDeadlineMs = turnDeadlineMs;
|
|
196
237
|
}
|
|
197
238
|
|
|
198
239
|
get status(): AgentStatus {
|
|
@@ -255,6 +296,27 @@ class LoopAgent implements Agent {
|
|
|
255
296
|
this.#controller?.abort(new Error(`agent cancelled by ${reason}`));
|
|
256
297
|
}
|
|
257
298
|
|
|
299
|
+
/**
|
|
300
|
+
* Start this Turn's clock. Any previous Turn's is cleared first, so a wake
|
|
301
|
+
* that runs three queued Turns gives each of them the full allowance rather
|
|
302
|
+
* than sharing one.
|
|
303
|
+
*/
|
|
304
|
+
#armTurnDeadline(): void {
|
|
305
|
+
this.#disarmTurnDeadline();
|
|
306
|
+
this.#turnDeadlineReached = false;
|
|
307
|
+
this.#turnDeadlineTimer = setTimeout(() => {
|
|
308
|
+
this.#turnDeadlineReached = true;
|
|
309
|
+
this.#controller?.abort(new Error(TURN_DEADLINE_REASON_V1));
|
|
310
|
+
}, this.#turnDeadlineMs);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
#disarmTurnDeadline(): void {
|
|
314
|
+
if (this.#turnDeadlineTimer !== undefined) {
|
|
315
|
+
clearTimeout(this.#turnDeadlineTimer);
|
|
316
|
+
this.#turnDeadlineTimer = undefined;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
258
320
|
async whenIdle(): Promise<void> {
|
|
259
321
|
let activity: Promise<void>;
|
|
260
322
|
do {
|
|
@@ -384,6 +446,7 @@ class LoopAgent implements Agent {
|
|
|
384
446
|
let turnOutcome: StepOutcome = "interrupted";
|
|
385
447
|
let turnReason: string | undefined;
|
|
386
448
|
let reconciliationRequired = false;
|
|
449
|
+
this.#armTurnDeadline();
|
|
387
450
|
try {
|
|
388
451
|
if (latestAssistant) {
|
|
389
452
|
await this.#notifyModelOutcome(latestAssistant.requestId, "completed");
|
|
@@ -411,6 +474,19 @@ class LoopAgent implements Agent {
|
|
|
411
474
|
latestStep,
|
|
412
475
|
signal,
|
|
413
476
|
);
|
|
477
|
+
if (reconciliation.status === "not-retrievable") {
|
|
478
|
+
// No later attempt can retrieve this effect, so the run settles now.
|
|
479
|
+
// The chunks already journaled stay in the session, so whatever the
|
|
480
|
+
// model produced before the interruption is still shown.
|
|
481
|
+
await this.#notifyModelOutcome(
|
|
482
|
+
unresolvedRequest.requestId,
|
|
483
|
+
"not-started",
|
|
484
|
+
);
|
|
485
|
+
turnOutcome = "model-error";
|
|
486
|
+
turnReason = turnEndReason(reconciliation.reason);
|
|
487
|
+
this.#ctx.emit("agent/error", this, new Error(reconciliation.reason));
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
414
490
|
if (reconciliation.status === "unavailable") {
|
|
415
491
|
const existing = this.session.events.findLast(
|
|
416
492
|
(event) =>
|
|
@@ -617,6 +693,13 @@ class LoopAgent implements Agent {
|
|
|
617
693
|
) {
|
|
618
694
|
reconciliationRequired = true;
|
|
619
695
|
this.#ctx.emit("agent/error", this, error);
|
|
696
|
+
} else if (this.#turnDeadlineReached) {
|
|
697
|
+
// Ahead of the cancellation branch on purpose: the deadline aborts the
|
|
698
|
+
// same controller Stop does, and a Turn the clock ended must not be
|
|
699
|
+
// reported to the person as one they stopped.
|
|
700
|
+
turnOutcome = "interrupted";
|
|
701
|
+
turnReason = turnEndReason(TURN_DEADLINE_REASON_V1);
|
|
702
|
+
this.#ctx.emit("agent/error", this, error);
|
|
620
703
|
} else if (
|
|
621
704
|
error instanceof EffectAdmissionFencedError ||
|
|
622
705
|
signal.aborted
|
|
@@ -633,6 +716,13 @@ class LoopAgent implements Agent {
|
|
|
633
716
|
this.#ctx.emit("agent/error", this, error);
|
|
634
717
|
}
|
|
635
718
|
} finally {
|
|
719
|
+
this.#disarmTurnDeadline();
|
|
720
|
+
// A Turn owed a reconciliation writes no `turn/end`: its model request
|
|
721
|
+
// has no durable outcome, and a `turn/end` would claim to know how it
|
|
722
|
+
// ended. That is right for as long as the run might still resume — and
|
|
723
|
+
// the moment it will not, the Turn is closed by whoever settles it, in
|
|
724
|
+
// `kernel-do`'s `settledEventsV1`. Closing it here instead would either
|
|
725
|
+
// lie about an outcome or make the run unresumable (ADR 0028).
|
|
636
726
|
if (!reconciliationRequired) {
|
|
637
727
|
if (openStep !== undefined && turnOutcome === "cancelled") {
|
|
638
728
|
await this.#settleCancelledStep(openTurn, openStep);
|
|
@@ -685,6 +775,7 @@ class LoopAgent implements Agent {
|
|
|
685
775
|
let turnOutcome: StepOutcome = "interrupted";
|
|
686
776
|
let turnReason: string | undefined;
|
|
687
777
|
let reconciliationRequired = false;
|
|
778
|
+
this.#armTurnDeadline();
|
|
688
779
|
try {
|
|
689
780
|
let inputs = [input];
|
|
690
781
|
for (let step = 1; step <= this.#maxSteps; step += 1) {
|
|
@@ -784,6 +875,13 @@ class LoopAgent implements Agent {
|
|
|
784
875
|
) {
|
|
785
876
|
reconciliationRequired = true;
|
|
786
877
|
this.#ctx.emit("agent/error", this, error);
|
|
878
|
+
} else if (this.#turnDeadlineReached) {
|
|
879
|
+
// Ahead of the cancellation branch on purpose: the deadline aborts the
|
|
880
|
+
// same controller Stop does, and a Turn the clock ended must not be
|
|
881
|
+
// reported to the person as one they stopped.
|
|
882
|
+
turnOutcome = "interrupted";
|
|
883
|
+
turnReason = turnEndReason(TURN_DEADLINE_REASON_V1);
|
|
884
|
+
this.#ctx.emit("agent/error", this, error);
|
|
787
885
|
} else if (
|
|
788
886
|
error instanceof EffectAdmissionFencedError ||
|
|
789
887
|
signal.aborted
|
|
@@ -800,6 +898,13 @@ class LoopAgent implements Agent {
|
|
|
800
898
|
this.#ctx.emit("agent/error", this, error);
|
|
801
899
|
}
|
|
802
900
|
} finally {
|
|
901
|
+
this.#disarmTurnDeadline();
|
|
902
|
+
// A Turn owed a reconciliation writes no `turn/end`: its model request
|
|
903
|
+
// has no durable outcome, and a `turn/end` would claim to know how it
|
|
904
|
+
// ended. That is right for as long as the run might still resume — and
|
|
905
|
+
// the moment it will not, the Turn is closed by whoever settles it, in
|
|
906
|
+
// `kernel-do`'s `settledEventsV1`. Closing it here instead would either
|
|
907
|
+
// lie about an outcome or make the run unresumable (ADR 0028).
|
|
803
908
|
if (!reconciliationRequired) {
|
|
804
909
|
if (openStep !== undefined && turnOutcome === "cancelled") {
|
|
805
910
|
await this.#settleCancelledStep(turn, openStep);
|
|
@@ -857,7 +962,16 @@ class LoopAgent implements Agent {
|
|
|
857
962
|
turnType: this.#turnType,
|
|
858
963
|
});
|
|
859
964
|
|
|
965
|
+
// One automatic retry, and only for a failure the provider itself
|
|
966
|
+
// classified as "the request never started" — a rejected key, an
|
|
967
|
+
// unresolvable binding, a connection refused before any byte was sent.
|
|
968
|
+
// Those are exactly the failures where retrying cannot duplicate anything,
|
|
969
|
+
// and the ones a person watching a blank screen would retry by hand. Every
|
|
970
|
+
// other failure is uncertain and is never retried, which is the whole of
|
|
971
|
+
// ADR 0024's durability contract.
|
|
972
|
+
let attempts = 0;
|
|
860
973
|
while (true) {
|
|
974
|
+
attempts += 1;
|
|
861
975
|
const proposedMessages = this.session.deriveMessages();
|
|
862
976
|
const messages = await this.#ctx.waterfall(
|
|
863
977
|
"agent/message-window",
|
|
@@ -962,14 +1076,24 @@ class LoopAgent implements Agent {
|
|
|
962
1076
|
});
|
|
963
1077
|
await this.session.flush();
|
|
964
1078
|
await this.#notifyModelOutcome(request.requestId, "not-started");
|
|
1079
|
+
// The durable evidence of a retry is the log itself: a `model/request`,
|
|
1080
|
+
// the `model/effect-not-started` just journaled against it, and then a
|
|
1081
|
+
// second `model/request`. A Package listening on `agent/request-error`
|
|
1082
|
+
// still has the final say in either direction.
|
|
965
1083
|
const action = await this.#ctx.waterfall(
|
|
966
1084
|
"agent/request-error",
|
|
967
1085
|
this,
|
|
968
1086
|
error,
|
|
969
1087
|
signal,
|
|
970
|
-
() =>
|
|
1088
|
+
() =>
|
|
1089
|
+
Promise.resolve(
|
|
1090
|
+
attempts < MODEL_REQUEST_ATTEMPTS_V1
|
|
1091
|
+
? ({ kind: "retry" } as const)
|
|
1092
|
+
: ({ kind: "fail" } as const),
|
|
1093
|
+
),
|
|
971
1094
|
);
|
|
972
1095
|
if (action.kind !== "retry") throw error;
|
|
1096
|
+
this.#ctx.emit("agent/error", this, error);
|
|
973
1097
|
}
|
|
974
1098
|
}
|
|
975
1099
|
}
|
|
@@ -1016,7 +1140,7 @@ class LoopAgent implements Agent {
|
|
|
1016
1140
|
signal: AbortSignal,
|
|
1017
1141
|
): Promise<ModelReconciliation> {
|
|
1018
1142
|
const reconciliation = await this.#ctx.llm.reconcile(request, signal);
|
|
1019
|
-
if (reconciliation.status
|
|
1143
|
+
if (reconciliation.status !== "recovered") return reconciliation;
|
|
1020
1144
|
const durablePrefix = this.session.events.flatMap((event) =>
|
|
1021
1145
|
event.type === "assistant/chunk" &&
|
|
1022
1146
|
event.turn === turn &&
|
|
@@ -1313,6 +1437,7 @@ class LoopAgent implements Agent {
|
|
|
1313
1437
|
export class AgentLoop extends Service implements AgentFactory {
|
|
1314
1438
|
static inject = ["sessions", "systemPrompt", "llm", "tools", "agents"];
|
|
1315
1439
|
private maxSteps: number;
|
|
1440
|
+
private turnDeadlineMs: number;
|
|
1316
1441
|
private composition: CompositionPinV1;
|
|
1317
1442
|
private handles = new Set<AgentHandle>();
|
|
1318
1443
|
|
|
@@ -1320,6 +1445,7 @@ export class AgentLoop extends Service implements AgentFactory {
|
|
|
1320
1445
|
super(ctx, "agentLoop");
|
|
1321
1446
|
this.composition = config.composition;
|
|
1322
1447
|
this.maxSteps = config.maxSteps ?? 20;
|
|
1448
|
+
this.turnDeadlineMs = config.turnDeadlineMs ?? TURN_DEADLINE_MS_V1;
|
|
1323
1449
|
if (!Number.isInteger(this.maxSteps) || this.maxSteps <= 0) {
|
|
1324
1450
|
throw new Error("agent-loop maxSteps must be a positive integer");
|
|
1325
1451
|
}
|
|
@@ -1336,6 +1462,7 @@ export class AgentLoop extends Service implements AgentFactory {
|
|
|
1336
1462
|
options as EffectAdmittingAgentOptions,
|
|
1337
1463
|
this.maxSteps,
|
|
1338
1464
|
this.composition,
|
|
1465
|
+
this.turnDeadlineMs,
|
|
1339
1466
|
);
|
|
1340
1467
|
const unregister = this.ctx.agents.register(agent);
|
|
1341
1468
|
let disposed = false;
|