@frockbot/kernel-agent-loop 0.3.5 → 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.ts +114 -1
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.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
|
}
|
|
@@ -103,6 +108,29 @@ class StepLimitReachedError extends Error {
|
|
|
103
108
|
}
|
|
104
109
|
}
|
|
105
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
|
+
|
|
106
134
|
/** Durable Stop won the final effect-admission transaction. */
|
|
107
135
|
class EffectAdmissionFencedError extends Error {
|
|
108
136
|
constructor(readonly effectId: string) {
|
|
@@ -173,6 +201,16 @@ class LoopAgent implements Agent {
|
|
|
173
201
|
#cancelDetail: string | undefined;
|
|
174
202
|
#disposeRequested = false;
|
|
175
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;
|
|
176
214
|
|
|
177
215
|
constructor(
|
|
178
216
|
ctx: Context,
|
|
@@ -180,6 +218,7 @@ class LoopAgent implements Agent {
|
|
|
180
218
|
options: EffectAdmittingAgentOptions,
|
|
181
219
|
maxSteps: number,
|
|
182
220
|
composition: CompositionPinV1,
|
|
221
|
+
turnDeadlineMs: number,
|
|
183
222
|
) {
|
|
184
223
|
this.#ctx = ctx;
|
|
185
224
|
this.#composition = composition;
|
|
@@ -193,6 +232,7 @@ class LoopAgent implements Agent {
|
|
|
193
232
|
this.#turnType = options.turnType ?? "chat";
|
|
194
233
|
this.#subagentRole = options.subagentRole;
|
|
195
234
|
this.#maxSteps = maxSteps;
|
|
235
|
+
this.#turnDeadlineMs = turnDeadlineMs;
|
|
196
236
|
}
|
|
197
237
|
|
|
198
238
|
get status(): AgentStatus {
|
|
@@ -255,6 +295,27 @@ class LoopAgent implements Agent {
|
|
|
255
295
|
this.#controller?.abort(new Error(`agent cancelled by ${reason}`));
|
|
256
296
|
}
|
|
257
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
|
+
|
|
258
319
|
async whenIdle(): Promise<void> {
|
|
259
320
|
let activity: Promise<void>;
|
|
260
321
|
do {
|
|
@@ -384,6 +445,7 @@ class LoopAgent implements Agent {
|
|
|
384
445
|
let turnOutcome: StepOutcome = "interrupted";
|
|
385
446
|
let turnReason: string | undefined;
|
|
386
447
|
let reconciliationRequired = false;
|
|
448
|
+
this.#armTurnDeadline();
|
|
387
449
|
try {
|
|
388
450
|
if (latestAssistant) {
|
|
389
451
|
await this.#notifyModelOutcome(latestAssistant.requestId, "completed");
|
|
@@ -617,6 +679,13 @@ class LoopAgent implements Agent {
|
|
|
617
679
|
) {
|
|
618
680
|
reconciliationRequired = true;
|
|
619
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);
|
|
620
689
|
} else if (
|
|
621
690
|
error instanceof EffectAdmissionFencedError ||
|
|
622
691
|
signal.aborted
|
|
@@ -633,6 +702,13 @@ class LoopAgent implements Agent {
|
|
|
633
702
|
this.#ctx.emit("agent/error", this, error);
|
|
634
703
|
}
|
|
635
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).
|
|
636
712
|
if (!reconciliationRequired) {
|
|
637
713
|
if (openStep !== undefined && turnOutcome === "cancelled") {
|
|
638
714
|
await this.#settleCancelledStep(openTurn, openStep);
|
|
@@ -685,6 +761,7 @@ class LoopAgent implements Agent {
|
|
|
685
761
|
let turnOutcome: StepOutcome = "interrupted";
|
|
686
762
|
let turnReason: string | undefined;
|
|
687
763
|
let reconciliationRequired = false;
|
|
764
|
+
this.#armTurnDeadline();
|
|
688
765
|
try {
|
|
689
766
|
let inputs = [input];
|
|
690
767
|
for (let step = 1; step <= this.#maxSteps; step += 1) {
|
|
@@ -784,6 +861,13 @@ class LoopAgent implements Agent {
|
|
|
784
861
|
) {
|
|
785
862
|
reconciliationRequired = true;
|
|
786
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);
|
|
787
871
|
} else if (
|
|
788
872
|
error instanceof EffectAdmissionFencedError ||
|
|
789
873
|
signal.aborted
|
|
@@ -800,6 +884,13 @@ class LoopAgent implements Agent {
|
|
|
800
884
|
this.#ctx.emit("agent/error", this, error);
|
|
801
885
|
}
|
|
802
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).
|
|
803
894
|
if (!reconciliationRequired) {
|
|
804
895
|
if (openStep !== undefined && turnOutcome === "cancelled") {
|
|
805
896
|
await this.#settleCancelledStep(turn, openStep);
|
|
@@ -857,7 +948,16 @@ class LoopAgent implements Agent {
|
|
|
857
948
|
turnType: this.#turnType,
|
|
858
949
|
});
|
|
859
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;
|
|
860
959
|
while (true) {
|
|
960
|
+
attempts += 1;
|
|
861
961
|
const proposedMessages = this.session.deriveMessages();
|
|
862
962
|
const messages = await this.#ctx.waterfall(
|
|
863
963
|
"agent/message-window",
|
|
@@ -962,14 +1062,24 @@ class LoopAgent implements Agent {
|
|
|
962
1062
|
});
|
|
963
1063
|
await this.session.flush();
|
|
964
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.
|
|
965
1069
|
const action = await this.#ctx.waterfall(
|
|
966
1070
|
"agent/request-error",
|
|
967
1071
|
this,
|
|
968
1072
|
error,
|
|
969
1073
|
signal,
|
|
970
|
-
() =>
|
|
1074
|
+
() =>
|
|
1075
|
+
Promise.resolve(
|
|
1076
|
+
attempts < MODEL_REQUEST_ATTEMPTS_V1
|
|
1077
|
+
? ({ kind: "retry" } as const)
|
|
1078
|
+
: ({ kind: "fail" } as const),
|
|
1079
|
+
),
|
|
971
1080
|
);
|
|
972
1081
|
if (action.kind !== "retry") throw error;
|
|
1082
|
+
this.#ctx.emit("agent/error", this, error);
|
|
973
1083
|
}
|
|
974
1084
|
}
|
|
975
1085
|
}
|
|
@@ -1313,6 +1423,7 @@ class LoopAgent implements Agent {
|
|
|
1313
1423
|
export class AgentLoop extends Service implements AgentFactory {
|
|
1314
1424
|
static inject = ["sessions", "systemPrompt", "llm", "tools", "agents"];
|
|
1315
1425
|
private maxSteps: number;
|
|
1426
|
+
private turnDeadlineMs: number;
|
|
1316
1427
|
private composition: CompositionPinV1;
|
|
1317
1428
|
private handles = new Set<AgentHandle>();
|
|
1318
1429
|
|
|
@@ -1320,6 +1431,7 @@ export class AgentLoop extends Service implements AgentFactory {
|
|
|
1320
1431
|
super(ctx, "agentLoop");
|
|
1321
1432
|
this.composition = config.composition;
|
|
1322
1433
|
this.maxSteps = config.maxSteps ?? 20;
|
|
1434
|
+
this.turnDeadlineMs = config.turnDeadlineMs ?? TURN_DEADLINE_MS_V1;
|
|
1323
1435
|
if (!Number.isInteger(this.maxSteps) || this.maxSteps <= 0) {
|
|
1324
1436
|
throw new Error("agent-loop maxSteps must be a positive integer");
|
|
1325
1437
|
}
|
|
@@ -1336,6 +1448,7 @@ export class AgentLoop extends Service implements AgentFactory {
|
|
|
1336
1448
|
options as EffectAdmittingAgentOptions,
|
|
1337
1449
|
this.maxSteps,
|
|
1338
1450
|
this.composition,
|
|
1451
|
+
this.turnDeadlineMs,
|
|
1339
1452
|
);
|
|
1340
1453
|
const unregister = this.ctx.agents.register(agent);
|
|
1341
1454
|
let disposed = false;
|