@frockbot/kernel-agent-loop 0.0.0 → 0.1.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 +26 -6
- package/src/agent.ts +172 -0
- package/src/index.test.ts +2891 -0
- package/src/index.ts +1219 -0
- package/tsconfig.json +13 -0
- package/README.md +0 -3
package/src/index.ts
ADDED
|
@@ -0,0 +1,1219 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type Agent,
|
|
3
|
+
type AgentFactory,
|
|
4
|
+
type AgentHandle,
|
|
5
|
+
type AgentInput,
|
|
6
|
+
type AgentOptions,
|
|
7
|
+
type AgentSendV1,
|
|
8
|
+
type AgentStatus,
|
|
9
|
+
type PreStepDecision,
|
|
10
|
+
} from "./agent.js";
|
|
11
|
+
import {
|
|
12
|
+
type CompositionPinV1,
|
|
13
|
+
decodeSkillRefsV1,
|
|
14
|
+
LlmEffectNotStartedError,
|
|
15
|
+
type LlmStreamEvent,
|
|
16
|
+
type NormalizedModelRequest,
|
|
17
|
+
type Session,
|
|
18
|
+
type SessionEvent,
|
|
19
|
+
type StepOutcome,
|
|
20
|
+
type ToolCall,
|
|
21
|
+
type ToolCallOccurrence,
|
|
22
|
+
type ToolExecutionResult,
|
|
23
|
+
type TurnTypeV1,
|
|
24
|
+
toolCallOccurrences,
|
|
25
|
+
turnEndReason,
|
|
26
|
+
validateSettledToolOccurrenceJournal,
|
|
27
|
+
validateToolOccurrenceJournal,
|
|
28
|
+
} from "@frockbot/kernel-contracts";
|
|
29
|
+
import { type Context, Service } from "cordis";
|
|
30
|
+
|
|
31
|
+
declare module "cordis" {
|
|
32
|
+
interface Events {
|
|
33
|
+
"agent/model-outcome-committed": (
|
|
34
|
+
agent: Agent,
|
|
35
|
+
requestId: string,
|
|
36
|
+
outcome: "completed" | "not-started",
|
|
37
|
+
) => Promise<void>;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface AgentLoopConfig {
|
|
42
|
+
maxSteps?: number;
|
|
43
|
+
/** The Composition generation this mounted root was pinned to at admission. */
|
|
44
|
+
composition: CompositionPinV1;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
type EffectAdmittingAgentOptions = AgentOptions & {
|
|
48
|
+
admitEffect(effect: {
|
|
49
|
+
kind: "model" | "tool";
|
|
50
|
+
effectId: string;
|
|
51
|
+
}): Promise<boolean>;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
declare module "cordis" {
|
|
55
|
+
interface Context {
|
|
56
|
+
agentLoop: AgentLoop;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface ModelResponse {
|
|
61
|
+
request: NormalizedModelRequest;
|
|
62
|
+
text: string;
|
|
63
|
+
toolCalls: ToolCall[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
type ModelReconciliation =
|
|
67
|
+
| { status: "recovered"; response: ModelResponse }
|
|
68
|
+
| { status: "unavailable"; reason: string };
|
|
69
|
+
|
|
70
|
+
class ModelEffectReconciliationRequiredError extends Error {
|
|
71
|
+
constructor(
|
|
72
|
+
readonly requestId: string,
|
|
73
|
+
message: string,
|
|
74
|
+
) {
|
|
75
|
+
super(message);
|
|
76
|
+
this.name = "ModelEffectReconciliationRequiredError";
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
class ToolEffectReconciliationRequiredError extends Error {
|
|
81
|
+
constructor(
|
|
82
|
+
readonly occurrenceId: string,
|
|
83
|
+
message: string,
|
|
84
|
+
) {
|
|
85
|
+
super(message);
|
|
86
|
+
this.name = "ToolEffectReconciliationRequiredError";
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Durable Stop won the final effect-admission transaction. */
|
|
91
|
+
class EffectAdmissionFencedError extends Error {
|
|
92
|
+
constructor(readonly effectId: string) {
|
|
93
|
+
super(`Effect "${effectId}" was fenced by durable Stop`);
|
|
94
|
+
this.name = "EffectAdmissionFencedError";
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function hasUnsettledExternalEffect(events: readonly SessionEvent[]): boolean {
|
|
99
|
+
let unresolvedRequestId: string | undefined;
|
|
100
|
+
for (const event of events) {
|
|
101
|
+
if (event.type === "model/request") {
|
|
102
|
+
unresolvedRequestId = event.request.requestId;
|
|
103
|
+
} else if (
|
|
104
|
+
(event.type === "assistant/message" ||
|
|
105
|
+
event.type === "model/effect-not-started") &&
|
|
106
|
+
event.requestId === unresolvedRequestId
|
|
107
|
+
) {
|
|
108
|
+
unresolvedRequestId = undefined;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (unresolvedRequestId) return true;
|
|
112
|
+
try {
|
|
113
|
+
return [...validateToolOccurrenceJournal(events).values()].some(
|
|
114
|
+
(entry) => entry.intent && !entry.result,
|
|
115
|
+
);
|
|
116
|
+
} catch {
|
|
117
|
+
// Invalid effect history is never safe to close as cancelled.
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
class ModelOutcomeSettlementRequiredError extends Error {
|
|
123
|
+
constructor(readonly cause: unknown) {
|
|
124
|
+
super("Durable model outcome settlement is pending");
|
|
125
|
+
this.name = "ModelOutcomeSettlementRequiredError";
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function modelFailureMessage(error: unknown): string {
|
|
130
|
+
return error instanceof Error && error.message
|
|
131
|
+
? error.message
|
|
132
|
+
: "Model provider response was lost";
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
class LoopAgent implements Agent {
|
|
136
|
+
readonly id: string;
|
|
137
|
+
readonly botId: string;
|
|
138
|
+
readonly session: Session;
|
|
139
|
+
#ctx: Context;
|
|
140
|
+
#options: EffectAdmittingAgentOptions;
|
|
141
|
+
#maxSteps: number;
|
|
142
|
+
#composition: CompositionPinV1;
|
|
143
|
+
/** The turn type every Turn of this Agent is admitted as. */
|
|
144
|
+
#turnType: TurnTypeV1;
|
|
145
|
+
/** The subagent role that turn type was admitted under, when it has one. */
|
|
146
|
+
#subagentRole: string | undefined;
|
|
147
|
+
#status: AgentStatus = "idle";
|
|
148
|
+
#inbox: AgentInput[] = [];
|
|
149
|
+
#activity: Promise<void> = Promise.resolve();
|
|
150
|
+
#controller: AbortController | undefined;
|
|
151
|
+
#disposeRequested = false;
|
|
152
|
+
#resumeRequested = false;
|
|
153
|
+
|
|
154
|
+
constructor(
|
|
155
|
+
ctx: Context,
|
|
156
|
+
session: Session,
|
|
157
|
+
options: EffectAdmittingAgentOptions,
|
|
158
|
+
maxSteps: number,
|
|
159
|
+
composition: CompositionPinV1,
|
|
160
|
+
) {
|
|
161
|
+
this.#ctx = ctx;
|
|
162
|
+
this.#composition = composition;
|
|
163
|
+
this.session = session;
|
|
164
|
+
this.botId = options.botId;
|
|
165
|
+
const explicitAgentId = (
|
|
166
|
+
options as AgentOptions & { agentId?: string }
|
|
167
|
+
).agentId?.trim();
|
|
168
|
+
this.id = explicitAgentId || options.sessionId;
|
|
169
|
+
this.#options = options;
|
|
170
|
+
this.#turnType = options.turnType ?? "chat";
|
|
171
|
+
this.#subagentRole = options.subagentRole;
|
|
172
|
+
this.#maxSteps = maxSteps;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
get status(): AgentStatus {
|
|
176
|
+
return this.#status;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
send(request: string | AgentSendV1): string {
|
|
180
|
+
if (this.#disposeRequested)
|
|
181
|
+
throw new Error(`agent "${this.id}" is disposing`);
|
|
182
|
+
const sent = typeof request === "string" ? { text: request } : request;
|
|
183
|
+
const normalized = sent.text.trim();
|
|
184
|
+
if (!normalized) throw new Error("agent input is empty");
|
|
185
|
+
// Decoded here rather than trusted: `send` is the kernel's inbound seam
|
|
186
|
+
// for an input, and an invoked Skill is durable state the moment
|
|
187
|
+
// `input/queued` is appended.
|
|
188
|
+
const skills =
|
|
189
|
+
sent.skills === undefined
|
|
190
|
+
? undefined
|
|
191
|
+
: decodeSkillRefsV1([...sent.skills], "agent input skills");
|
|
192
|
+
const input: AgentInput = {
|
|
193
|
+
messageId: crypto.randomUUID(),
|
|
194
|
+
text: normalized,
|
|
195
|
+
...(skills && skills.length > 0 ? { skills } : {}),
|
|
196
|
+
};
|
|
197
|
+
this.session.append({ type: "input/queued", ...input });
|
|
198
|
+
this.#inbox.push(input);
|
|
199
|
+
this.#ctx.emit("agent/inbox/inserted", this, input);
|
|
200
|
+
this.#wake();
|
|
201
|
+
return input.messageId;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
resume(): void {
|
|
205
|
+
if (this.#disposeRequested)
|
|
206
|
+
throw new Error(`agent "${this.id}" is disposing`);
|
|
207
|
+
if (
|
|
208
|
+
this.#status !== "idle" ||
|
|
209
|
+
this.#inbox.length > 0 ||
|
|
210
|
+
this.#resumeRequested
|
|
211
|
+
) {
|
|
212
|
+
throw new Error(`agent "${this.id}" cannot resume while active`);
|
|
213
|
+
}
|
|
214
|
+
this.#resumeRequested = true;
|
|
215
|
+
this.#wake();
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
cancel(reason: "user" | "shutdown" = "user"): void {
|
|
219
|
+
if (this.#status === "disposed") return;
|
|
220
|
+
this.#ctx.emit("agent/cancel-requested", this, reason);
|
|
221
|
+
const queued = this.#inbox.splice(0);
|
|
222
|
+
if (queued.length > 0) {
|
|
223
|
+
this.session.appendBatch(
|
|
224
|
+
queued.map((input) => ({
|
|
225
|
+
type: "input/cancelled" as const,
|
|
226
|
+
messageId: input.messageId,
|
|
227
|
+
reason,
|
|
228
|
+
})),
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
this.#controller?.abort(new Error(`agent cancelled by ${reason}`));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async whenIdle(): Promise<void> {
|
|
235
|
+
let activity: Promise<void>;
|
|
236
|
+
do {
|
|
237
|
+
activity = this.#activity;
|
|
238
|
+
await activity;
|
|
239
|
+
} while (activity !== this.#activity);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async dispose(): Promise<void> {
|
|
243
|
+
if (this.#disposeRequested) return this.whenIdle();
|
|
244
|
+
this.#disposeRequested = true;
|
|
245
|
+
this.cancel("shutdown");
|
|
246
|
+
await this.whenIdle();
|
|
247
|
+
this.#setStatus("disposed");
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
#setStatus(status: AgentStatus): void {
|
|
251
|
+
if (status === this.#status) return;
|
|
252
|
+
this.#status = status;
|
|
253
|
+
this.#ctx.emit("agent/status", this, status);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
#wake(): void {
|
|
257
|
+
if (
|
|
258
|
+
this.#disposeRequested ||
|
|
259
|
+
this.#status !== "idle" ||
|
|
260
|
+
(this.#inbox.length === 0 && !this.#resumeRequested)
|
|
261
|
+
) {
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
this.#controller = new AbortController();
|
|
265
|
+
this.#setStatus("running");
|
|
266
|
+
const activity = this.#drive(this.#controller.signal).finally(() => {
|
|
267
|
+
this.#controller = undefined;
|
|
268
|
+
if (!this.#disposeRequested) this.#setStatus("idle");
|
|
269
|
+
if (!this.#disposeRequested && this.#inbox.length > 0) this.#wake();
|
|
270
|
+
});
|
|
271
|
+
this.#activity = activity;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async #drive(signal: AbortSignal): Promise<void> {
|
|
275
|
+
if (this.#resumeRequested) {
|
|
276
|
+
this.#resumeRequested = false;
|
|
277
|
+
await this.#resumeTurn(signal);
|
|
278
|
+
}
|
|
279
|
+
while (!signal.aborted && this.#inbox.length > 0) {
|
|
280
|
+
await this.#runTurn(signal);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async #resumeTurn(signal: AbortSignal): Promise<void> {
|
|
285
|
+
let openTurn: number | undefined;
|
|
286
|
+
let latestStep = 0;
|
|
287
|
+
let latestStepStatus: "none" | "open" | "ended" = "none";
|
|
288
|
+
let latestStepOutcome: StepOutcome | undefined;
|
|
289
|
+
let unresolvedRequest: NormalizedModelRequest | undefined;
|
|
290
|
+
let definitiveNoEffect:
|
|
291
|
+
Extract<SessionEvent, { type: "model/effect-not-started" }> | undefined;
|
|
292
|
+
for (const event of this.session.events) {
|
|
293
|
+
if (event.type === "turn/start") {
|
|
294
|
+
openTurn = event.turn;
|
|
295
|
+
latestStep = 0;
|
|
296
|
+
latestStepStatus = "none";
|
|
297
|
+
latestStepOutcome = undefined;
|
|
298
|
+
unresolvedRequest = undefined;
|
|
299
|
+
definitiveNoEffect = undefined;
|
|
300
|
+
}
|
|
301
|
+
if (event.type === "turn/end" && event.turn === openTurn)
|
|
302
|
+
openTurn = undefined;
|
|
303
|
+
if (event.type === "step/start" && event.turn === openTurn) {
|
|
304
|
+
latestStep = Math.max(latestStep, event.step);
|
|
305
|
+
latestStepStatus = "open";
|
|
306
|
+
latestStepOutcome = undefined;
|
|
307
|
+
}
|
|
308
|
+
if (
|
|
309
|
+
event.type === "step/end" &&
|
|
310
|
+
event.turn === openTurn &&
|
|
311
|
+
event.step === latestStep
|
|
312
|
+
) {
|
|
313
|
+
latestStepStatus = "ended";
|
|
314
|
+
latestStepOutcome = event.outcome;
|
|
315
|
+
}
|
|
316
|
+
if (event.type === "model/request" && event.turn === openTurn) {
|
|
317
|
+
unresolvedRequest = event.request;
|
|
318
|
+
definitiveNoEffect = undefined;
|
|
319
|
+
}
|
|
320
|
+
if (
|
|
321
|
+
event.type === "model/effect-not-started" &&
|
|
322
|
+
event.requestId === unresolvedRequest?.requestId
|
|
323
|
+
) {
|
|
324
|
+
definitiveNoEffect = event;
|
|
325
|
+
}
|
|
326
|
+
if (
|
|
327
|
+
event.type === "assistant/message" &&
|
|
328
|
+
event.requestId === unresolvedRequest?.requestId
|
|
329
|
+
) {
|
|
330
|
+
unresolvedRequest = undefined;
|
|
331
|
+
definitiveNoEffect = undefined;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
if (openTurn === undefined)
|
|
335
|
+
throw new Error("session has no resumable turn");
|
|
336
|
+
let latestAssistant:
|
|
337
|
+
Extract<SessionEvent, { type: "assistant/message" }> | undefined;
|
|
338
|
+
for (const event of this.session.events) {
|
|
339
|
+
if (
|
|
340
|
+
event.type === "assistant/message" &&
|
|
341
|
+
event.turn === openTurn &&
|
|
342
|
+
event.step === latestStep
|
|
343
|
+
) {
|
|
344
|
+
latestAssistant = event;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
let openStep: number | undefined;
|
|
348
|
+
let turnOutcome: StepOutcome = "interrupted";
|
|
349
|
+
let turnReason: string | undefined;
|
|
350
|
+
let reconciliationRequired = false;
|
|
351
|
+
try {
|
|
352
|
+
if (latestAssistant) {
|
|
353
|
+
await this.#notifyModelOutcome(latestAssistant.requestId, "completed");
|
|
354
|
+
}
|
|
355
|
+
let nextStep = latestStep === 0 ? 1 : latestStep + 1;
|
|
356
|
+
if (unresolvedRequest) {
|
|
357
|
+
openStep = latestStep;
|
|
358
|
+
if (definitiveNoEffect) {
|
|
359
|
+
await this.#notifyModelOutcome(
|
|
360
|
+
definitiveNoEffect.requestId,
|
|
361
|
+
"not-started",
|
|
362
|
+
);
|
|
363
|
+
turnOutcome = "model-error";
|
|
364
|
+
turnReason = turnEndReason(definitiveNoEffect.reason);
|
|
365
|
+
this.#ctx.emit(
|
|
366
|
+
"agent/error",
|
|
367
|
+
this,
|
|
368
|
+
new LlmEffectNotStartedError(definitiveNoEffect.reason),
|
|
369
|
+
);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
const reconciliation = await this.#reconcileModel(
|
|
373
|
+
unresolvedRequest,
|
|
374
|
+
openTurn,
|
|
375
|
+
latestStep,
|
|
376
|
+
signal,
|
|
377
|
+
);
|
|
378
|
+
if (reconciliation.status === "unavailable") {
|
|
379
|
+
const existing = this.session.events.findLast(
|
|
380
|
+
(event) =>
|
|
381
|
+
event.type === "model/reconciliation-required" &&
|
|
382
|
+
event.requestId === unresolvedRequest.requestId,
|
|
383
|
+
);
|
|
384
|
+
if (
|
|
385
|
+
existing?.type !== "model/reconciliation-required" ||
|
|
386
|
+
existing.reason !== reconciliation.reason
|
|
387
|
+
) {
|
|
388
|
+
this.session.append({
|
|
389
|
+
type: "model/reconciliation-required",
|
|
390
|
+
turn: openTurn,
|
|
391
|
+
step: latestStep,
|
|
392
|
+
requestId: unresolvedRequest.requestId,
|
|
393
|
+
reason: reconciliation.reason,
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
reconciliationRequired = true;
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
const { response } = reconciliation;
|
|
400
|
+
this.session.append({
|
|
401
|
+
type: "assistant/message",
|
|
402
|
+
turn: openTurn,
|
|
403
|
+
step: latestStep,
|
|
404
|
+
requestId: response.request.requestId,
|
|
405
|
+
text: response.text,
|
|
406
|
+
toolCalls: response.toolCalls,
|
|
407
|
+
});
|
|
408
|
+
await this.session.flush();
|
|
409
|
+
signal.throwIfAborted();
|
|
410
|
+
await this.#notifyModelOutcome(response.request.requestId, "completed");
|
|
411
|
+
if (response.toolCalls.length === 0) {
|
|
412
|
+
this.session.append({
|
|
413
|
+
type: "step/end",
|
|
414
|
+
turn: openTurn,
|
|
415
|
+
step: latestStep,
|
|
416
|
+
outcome: "completed",
|
|
417
|
+
});
|
|
418
|
+
openStep = undefined;
|
|
419
|
+
turnOutcome = "completed";
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
const endsTurn = await this.#executeTools(
|
|
423
|
+
toolCallOccurrences(openTurn, latestStep, response.toolCalls),
|
|
424
|
+
signal,
|
|
425
|
+
);
|
|
426
|
+
signal.throwIfAborted();
|
|
427
|
+
this.session.append({
|
|
428
|
+
type: "step/end",
|
|
429
|
+
turn: openTurn,
|
|
430
|
+
step: latestStep,
|
|
431
|
+
outcome: "completed",
|
|
432
|
+
});
|
|
433
|
+
openStep = undefined;
|
|
434
|
+
if (endsTurn) {
|
|
435
|
+
turnOutcome = "completed";
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
nextStep = latestStep + 1;
|
|
439
|
+
} else if (latestStepStatus === "open" && latestAssistant) {
|
|
440
|
+
openStep = latestStep;
|
|
441
|
+
if (latestAssistant.toolCalls.length === 0) {
|
|
442
|
+
this.session.append({
|
|
443
|
+
type: "step/end",
|
|
444
|
+
turn: openTurn,
|
|
445
|
+
step: latestStep,
|
|
446
|
+
outcome: "completed",
|
|
447
|
+
});
|
|
448
|
+
openStep = undefined;
|
|
449
|
+
turnOutcome = "completed";
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
const occurrences = toolCallOccurrences(
|
|
453
|
+
openTurn,
|
|
454
|
+
latestStep,
|
|
455
|
+
latestAssistant.toolCalls,
|
|
456
|
+
);
|
|
457
|
+
const endsTurn = await this.#executeTools(occurrences, signal);
|
|
458
|
+
signal.throwIfAborted();
|
|
459
|
+
this.session.append({
|
|
460
|
+
type: "step/end",
|
|
461
|
+
turn: openTurn,
|
|
462
|
+
step: latestStep,
|
|
463
|
+
outcome: "completed",
|
|
464
|
+
});
|
|
465
|
+
openStep = undefined;
|
|
466
|
+
if (endsTurn) {
|
|
467
|
+
turnOutcome = "completed";
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
nextStep = latestStep + 1;
|
|
471
|
+
} else if (latestStepStatus === "ended") {
|
|
472
|
+
turnOutcome = latestStepOutcome ?? "interrupted";
|
|
473
|
+
if (
|
|
474
|
+
turnOutcome !== "completed" ||
|
|
475
|
+
!latestAssistant ||
|
|
476
|
+
latestAssistant.toolCalls.length === 0
|
|
477
|
+
) {
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
} else if (latestStepStatus === "open") {
|
|
481
|
+
nextStep = latestStep;
|
|
482
|
+
}
|
|
483
|
+
for (let step = nextStep; step <= this.#maxSteps; step += 1) {
|
|
484
|
+
signal.throwIfAborted();
|
|
485
|
+
openStep = step;
|
|
486
|
+
if (!(latestStepStatus === "open" && step === latestStep)) {
|
|
487
|
+
this.session.append({ type: "step/start", turn: openTurn, step });
|
|
488
|
+
}
|
|
489
|
+
const response = await this.#requestModel(openTurn, step, signal);
|
|
490
|
+
this.session.append({
|
|
491
|
+
type: "assistant/message",
|
|
492
|
+
turn: openTurn,
|
|
493
|
+
step,
|
|
494
|
+
requestId: response.request.requestId,
|
|
495
|
+
text: response.text,
|
|
496
|
+
toolCalls: response.toolCalls,
|
|
497
|
+
});
|
|
498
|
+
await this.session.flush();
|
|
499
|
+
signal.throwIfAborted();
|
|
500
|
+
await this.#notifyModelOutcome(response.request.requestId, "completed");
|
|
501
|
+
if (response.toolCalls.length === 0) {
|
|
502
|
+
this.session.append({
|
|
503
|
+
type: "step/end",
|
|
504
|
+
turn: openTurn,
|
|
505
|
+
step,
|
|
506
|
+
outcome: "completed",
|
|
507
|
+
});
|
|
508
|
+
openStep = undefined;
|
|
509
|
+
turnOutcome = "completed";
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
const endsTurn = await this.#executeTools(
|
|
513
|
+
toolCallOccurrences(openTurn, step, response.toolCalls),
|
|
514
|
+
signal,
|
|
515
|
+
);
|
|
516
|
+
signal.throwIfAborted();
|
|
517
|
+
this.session.append({
|
|
518
|
+
type: "step/end",
|
|
519
|
+
turn: openTurn,
|
|
520
|
+
step,
|
|
521
|
+
outcome: "completed",
|
|
522
|
+
});
|
|
523
|
+
openStep = undefined;
|
|
524
|
+
if (endsTurn) {
|
|
525
|
+
turnOutcome = "completed";
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
throw new Error(`agent exceeded ${this.#maxSteps} steps`);
|
|
530
|
+
} catch (error) {
|
|
531
|
+
if (
|
|
532
|
+
error instanceof ModelEffectReconciliationRequiredError ||
|
|
533
|
+
error instanceof ToolEffectReconciliationRequiredError ||
|
|
534
|
+
error instanceof ModelOutcomeSettlementRequiredError ||
|
|
535
|
+
(signal.aborted && hasUnsettledExternalEffect(this.session.events))
|
|
536
|
+
) {
|
|
537
|
+
reconciliationRequired = true;
|
|
538
|
+
this.#ctx.emit("agent/error", this, error);
|
|
539
|
+
} else if (
|
|
540
|
+
error instanceof EffectAdmissionFencedError ||
|
|
541
|
+
signal.aborted
|
|
542
|
+
) {
|
|
543
|
+
turnOutcome = "cancelled";
|
|
544
|
+
} else {
|
|
545
|
+
turnOutcome = "model-error";
|
|
546
|
+
turnReason = turnEndReason(modelFailureMessage(error));
|
|
547
|
+
this.#ctx.emit("agent/error", this, error);
|
|
548
|
+
}
|
|
549
|
+
} finally {
|
|
550
|
+
if (!reconciliationRequired) {
|
|
551
|
+
if (openStep !== undefined && turnOutcome === "cancelled") {
|
|
552
|
+
await this.#settleCancelledStep(openTurn, openStep);
|
|
553
|
+
}
|
|
554
|
+
if (openStep !== undefined) {
|
|
555
|
+
this.session.append({
|
|
556
|
+
type: "step/end",
|
|
557
|
+
turn: openTurn,
|
|
558
|
+
step: openStep,
|
|
559
|
+
outcome: turnOutcome,
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
this.session.append({
|
|
563
|
+
type: "turn/end",
|
|
564
|
+
turn: openTurn,
|
|
565
|
+
outcome: turnOutcome,
|
|
566
|
+
...(turnOutcome !== "completed" && turnReason !== undefined
|
|
567
|
+
? { reason: turnReason }
|
|
568
|
+
: {}),
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
await this.session.flush();
|
|
572
|
+
await this.#ctx.serial("agent/turn-stopping", this, openTurn);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
async #runTurn(signal: AbortSignal): Promise<void> {
|
|
577
|
+
const input = this.#inbox[0];
|
|
578
|
+
if (!input) return;
|
|
579
|
+
const turn = this.session.nextTurn();
|
|
580
|
+
this.session.appendBatch([
|
|
581
|
+
{ type: "turn/start", turn },
|
|
582
|
+
{
|
|
583
|
+
type: "composition/pinned",
|
|
584
|
+
turn,
|
|
585
|
+
generationId: this.#composition.generationId,
|
|
586
|
+
artifactSetHash: this.#composition.artifactSetHash,
|
|
587
|
+
},
|
|
588
|
+
{ type: "turn/admission", turn, turnType: this.#turnType },
|
|
589
|
+
{ type: "input/admitted", messageId: input.messageId, turn },
|
|
590
|
+
]);
|
|
591
|
+
await this.session.flush();
|
|
592
|
+
this.#inbox.shift();
|
|
593
|
+
this.#ctx.emit("agent/inbox/claimed", this, [input], turn);
|
|
594
|
+
|
|
595
|
+
let openStep: number | undefined;
|
|
596
|
+
let turnOutcome: StepOutcome = "interrupted";
|
|
597
|
+
let turnReason: string | undefined;
|
|
598
|
+
let reconciliationRequired = false;
|
|
599
|
+
try {
|
|
600
|
+
let inputs = [input];
|
|
601
|
+
for (let step = 1; step <= this.#maxSteps; step += 1) {
|
|
602
|
+
signal.throwIfAborted();
|
|
603
|
+
const decision = await this.#ctx.waterfall(
|
|
604
|
+
"agent/pre-step",
|
|
605
|
+
this,
|
|
606
|
+
inputs,
|
|
607
|
+
turn,
|
|
608
|
+
step,
|
|
609
|
+
() => Promise.resolve<PreStepDecision>({ kind: "enter", inputs }),
|
|
610
|
+
);
|
|
611
|
+
if (decision.kind === "reject") {
|
|
612
|
+
turnOutcome = "blocked";
|
|
613
|
+
turnReason = turnEndReason(decision.reason);
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
openStep = step;
|
|
618
|
+
this.session.append({ type: "step/start", turn, step });
|
|
619
|
+
for (const admitted of decision.inputs) {
|
|
620
|
+
this.session.append({
|
|
621
|
+
type: "user/message",
|
|
622
|
+
turn,
|
|
623
|
+
step,
|
|
624
|
+
messageId: admitted.messageId,
|
|
625
|
+
text: admitted.text,
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
const response = await this.#requestModel(turn, step, signal);
|
|
630
|
+
this.session.append({
|
|
631
|
+
type: "assistant/message",
|
|
632
|
+
turn,
|
|
633
|
+
step,
|
|
634
|
+
requestId: response.request.requestId,
|
|
635
|
+
text: response.text,
|
|
636
|
+
toolCalls: response.toolCalls,
|
|
637
|
+
});
|
|
638
|
+
await this.session.flush();
|
|
639
|
+
signal.throwIfAborted();
|
|
640
|
+
await this.#notifyModelOutcome(response.request.requestId, "completed");
|
|
641
|
+
|
|
642
|
+
if (response.toolCalls.length === 0) {
|
|
643
|
+
this.session.append({
|
|
644
|
+
type: "step/end",
|
|
645
|
+
turn,
|
|
646
|
+
step,
|
|
647
|
+
outcome: "completed",
|
|
648
|
+
});
|
|
649
|
+
openStep = undefined;
|
|
650
|
+
turnOutcome = "completed";
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
const endsTurn = await this.#executeTools(
|
|
655
|
+
toolCallOccurrences(turn, step, response.toolCalls),
|
|
656
|
+
signal,
|
|
657
|
+
);
|
|
658
|
+
signal.throwIfAborted();
|
|
659
|
+
this.session.append({
|
|
660
|
+
type: "step/end",
|
|
661
|
+
turn,
|
|
662
|
+
step,
|
|
663
|
+
outcome: "completed",
|
|
664
|
+
});
|
|
665
|
+
openStep = undefined;
|
|
666
|
+
// A tool result that ends the Turn closes it here: no further model
|
|
667
|
+
// request, and the log replays as a completed Turn with none.
|
|
668
|
+
if (endsTurn) {
|
|
669
|
+
turnOutcome = "completed";
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
inputs = [];
|
|
673
|
+
}
|
|
674
|
+
throw new Error(`agent exceeded ${this.#maxSteps} steps`);
|
|
675
|
+
} catch (error) {
|
|
676
|
+
if (
|
|
677
|
+
error instanceof ModelEffectReconciliationRequiredError ||
|
|
678
|
+
error instanceof ToolEffectReconciliationRequiredError ||
|
|
679
|
+
error instanceof ModelOutcomeSettlementRequiredError ||
|
|
680
|
+
(signal.aborted && hasUnsettledExternalEffect(this.session.events))
|
|
681
|
+
) {
|
|
682
|
+
reconciliationRequired = true;
|
|
683
|
+
this.#ctx.emit("agent/error", this, error);
|
|
684
|
+
} else if (
|
|
685
|
+
error instanceof EffectAdmissionFencedError ||
|
|
686
|
+
signal.aborted
|
|
687
|
+
) {
|
|
688
|
+
turnOutcome = "cancelled";
|
|
689
|
+
} else {
|
|
690
|
+
turnOutcome = "model-error";
|
|
691
|
+
turnReason = turnEndReason(modelFailureMessage(error));
|
|
692
|
+
this.#ctx.emit("agent/error", this, error);
|
|
693
|
+
}
|
|
694
|
+
} finally {
|
|
695
|
+
if (!reconciliationRequired) {
|
|
696
|
+
if (openStep !== undefined && turnOutcome === "cancelled") {
|
|
697
|
+
await this.#settleCancelledStep(turn, openStep);
|
|
698
|
+
}
|
|
699
|
+
if (openStep !== undefined) {
|
|
700
|
+
this.session.append({
|
|
701
|
+
type: "step/end",
|
|
702
|
+
turn,
|
|
703
|
+
step: openStep,
|
|
704
|
+
outcome: turnOutcome,
|
|
705
|
+
});
|
|
706
|
+
}
|
|
707
|
+
this.session.append({
|
|
708
|
+
type: "turn/end",
|
|
709
|
+
turn,
|
|
710
|
+
outcome: turnOutcome,
|
|
711
|
+
...(turnOutcome !== "completed" && turnReason !== undefined
|
|
712
|
+
? { reason: turnReason }
|
|
713
|
+
: {}),
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
await this.session.flush();
|
|
717
|
+
await this.#ctx.serial("agent/turn-stopping", this, turn);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
async #notifyModelOutcome(
|
|
722
|
+
requestId: string,
|
|
723
|
+
outcome: "completed" | "not-started",
|
|
724
|
+
): Promise<void> {
|
|
725
|
+
try {
|
|
726
|
+
await this.#ctx.serial(
|
|
727
|
+
"agent/model-outcome-committed",
|
|
728
|
+
this,
|
|
729
|
+
requestId,
|
|
730
|
+
outcome,
|
|
731
|
+
);
|
|
732
|
+
} catch (error) {
|
|
733
|
+
throw new ModelOutcomeSettlementRequiredError(error);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
async #requestModel(
|
|
738
|
+
turn: number,
|
|
739
|
+
step: number,
|
|
740
|
+
signal: AbortSignal,
|
|
741
|
+
): Promise<ModelResponse> {
|
|
742
|
+
validateSettledToolOccurrenceJournal(this.session.events);
|
|
743
|
+
const assembly = await this.#ctx.systemPrompt.assemble({
|
|
744
|
+
sessionId: this.session.id,
|
|
745
|
+
provider: this.#options.provider,
|
|
746
|
+
model: this.#options.model,
|
|
747
|
+
// The same turn type the tool catalog is trimmed to. A section that
|
|
748
|
+
// renders what a Turn may do would otherwise have to guess it.
|
|
749
|
+
turnType: this.#turnType,
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
while (true) {
|
|
753
|
+
const proposed: NormalizedModelRequest = {
|
|
754
|
+
requestId: crypto.randomUUID(),
|
|
755
|
+
provider: this.#options.provider,
|
|
756
|
+
model: this.#options.model,
|
|
757
|
+
system: assembly.text,
|
|
758
|
+
messages: this.session.deriveMessages(),
|
|
759
|
+
tools: this.#ctx.tools.schemas({
|
|
760
|
+
turnType: this.#turnType,
|
|
761
|
+
...(this.#subagentRole === undefined
|
|
762
|
+
? {}
|
|
763
|
+
: { subagentRole: this.#subagentRole }),
|
|
764
|
+
}),
|
|
765
|
+
...(this.#options.modelBinding
|
|
766
|
+
? { modelBinding: structuredClone(this.#options.modelBinding) }
|
|
767
|
+
: {}),
|
|
768
|
+
};
|
|
769
|
+
const request = await this.#ctx.waterfall(
|
|
770
|
+
"agent/request",
|
|
771
|
+
this,
|
|
772
|
+
proposed,
|
|
773
|
+
signal,
|
|
774
|
+
() => Promise.resolve(proposed),
|
|
775
|
+
);
|
|
776
|
+
this.session.append({ type: "model/request", turn, step, request });
|
|
777
|
+
await this.session.flush();
|
|
778
|
+
if (
|
|
779
|
+
!(await this.#options.admitEffect({
|
|
780
|
+
kind: "model",
|
|
781
|
+
effectId: request.requestId,
|
|
782
|
+
}))
|
|
783
|
+
) {
|
|
784
|
+
this.session.append({
|
|
785
|
+
type: "model/effect-not-started",
|
|
786
|
+
turn,
|
|
787
|
+
step,
|
|
788
|
+
requestId: request.requestId,
|
|
789
|
+
reason: "Durable Stop fenced provider execution",
|
|
790
|
+
});
|
|
791
|
+
await this.session.flush();
|
|
792
|
+
throw new EffectAdmissionFencedError(request.requestId);
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
try {
|
|
796
|
+
return await this.#consumeStream(request, turn, step, signal);
|
|
797
|
+
} catch (error) {
|
|
798
|
+
if (signal.aborted) {
|
|
799
|
+
const reason = `Model response outcome is uncertain after cancellation: ${modelFailureMessage(error)}`;
|
|
800
|
+
this.session.append({
|
|
801
|
+
type: "model/reconciliation-required",
|
|
802
|
+
turn,
|
|
803
|
+
step,
|
|
804
|
+
requestId: request.requestId,
|
|
805
|
+
reason,
|
|
806
|
+
});
|
|
807
|
+
await this.session.flush();
|
|
808
|
+
throw new ModelEffectReconciliationRequiredError(
|
|
809
|
+
request.requestId,
|
|
810
|
+
reason,
|
|
811
|
+
);
|
|
812
|
+
}
|
|
813
|
+
if (!(error instanceof LlmEffectNotStartedError)) {
|
|
814
|
+
const reason = `Model response outcome is uncertain: ${modelFailureMessage(error)}`;
|
|
815
|
+
this.session.append({
|
|
816
|
+
type: "model/reconciliation-required",
|
|
817
|
+
turn,
|
|
818
|
+
step,
|
|
819
|
+
requestId: request.requestId,
|
|
820
|
+
reason,
|
|
821
|
+
});
|
|
822
|
+
await this.session.flush();
|
|
823
|
+
throw new ModelEffectReconciliationRequiredError(
|
|
824
|
+
request.requestId,
|
|
825
|
+
reason,
|
|
826
|
+
);
|
|
827
|
+
}
|
|
828
|
+
this.session.append({
|
|
829
|
+
type: "model/effect-not-started",
|
|
830
|
+
turn,
|
|
831
|
+
step,
|
|
832
|
+
requestId: request.requestId,
|
|
833
|
+
reason: modelFailureMessage(error),
|
|
834
|
+
});
|
|
835
|
+
await this.session.flush();
|
|
836
|
+
await this.#notifyModelOutcome(request.requestId, "not-started");
|
|
837
|
+
const action = await this.#ctx.waterfall(
|
|
838
|
+
"agent/request-error",
|
|
839
|
+
this,
|
|
840
|
+
error,
|
|
841
|
+
signal,
|
|
842
|
+
() => Promise.resolve({ kind: "fail" as const }),
|
|
843
|
+
);
|
|
844
|
+
if (action.kind !== "retry") throw error;
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
async #consumeStream(
|
|
850
|
+
request: NormalizedModelRequest,
|
|
851
|
+
turn: number,
|
|
852
|
+
step: number,
|
|
853
|
+
signal: AbortSignal,
|
|
854
|
+
): Promise<ModelResponse> {
|
|
855
|
+
let text = "";
|
|
856
|
+
const toolCalls: ToolCall[] = [];
|
|
857
|
+
let receivedProviderEvent = false;
|
|
858
|
+
try {
|
|
859
|
+
for await (const event of this.#ctx.llm.stream(request, signal)) {
|
|
860
|
+
receivedProviderEvent = true;
|
|
861
|
+
signal.throwIfAborted();
|
|
862
|
+
this.#applyStreamEvent(
|
|
863
|
+
event,
|
|
864
|
+
request.requestId,
|
|
865
|
+
turn,
|
|
866
|
+
step,
|
|
867
|
+
toolCalls,
|
|
868
|
+
(delta) => {
|
|
869
|
+
text += delta;
|
|
870
|
+
},
|
|
871
|
+
);
|
|
872
|
+
}
|
|
873
|
+
} catch (error) {
|
|
874
|
+
if (receivedProviderEvent && error instanceof LlmEffectNotStartedError) {
|
|
875
|
+
throw new Error(
|
|
876
|
+
"Model provider reported no effect after returning response data",
|
|
877
|
+
);
|
|
878
|
+
}
|
|
879
|
+
throw error;
|
|
880
|
+
}
|
|
881
|
+
return { request, text, toolCalls };
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
async #reconcileModel(
|
|
885
|
+
request: NormalizedModelRequest,
|
|
886
|
+
turn: number,
|
|
887
|
+
step: number,
|
|
888
|
+
signal: AbortSignal,
|
|
889
|
+
): Promise<ModelReconciliation> {
|
|
890
|
+
const reconciliation = await this.#ctx.llm.reconcile(request, signal);
|
|
891
|
+
if (reconciliation.status === "unavailable") return reconciliation;
|
|
892
|
+
const durablePrefix = this.session.events.flatMap((event) =>
|
|
893
|
+
event.type === "assistant/chunk" &&
|
|
894
|
+
event.turn === turn &&
|
|
895
|
+
event.step === step &&
|
|
896
|
+
event.requestId === request.requestId
|
|
897
|
+
? [{ type: "text-delta" as const, text: event.text }]
|
|
898
|
+
: [],
|
|
899
|
+
);
|
|
900
|
+
const recoveredTextDeltas = reconciliation.events.flatMap((event) =>
|
|
901
|
+
event.type === "text-delta" ? [event] : [],
|
|
902
|
+
);
|
|
903
|
+
const prefixMatches = durablePrefix.every((event, index) => {
|
|
904
|
+
const recovered = recoveredTextDeltas[index];
|
|
905
|
+
return recovered?.text === event.text;
|
|
906
|
+
});
|
|
907
|
+
if (!prefixMatches || recoveredTextDeltas.length < durablePrefix.length) {
|
|
908
|
+
return {
|
|
909
|
+
status: "unavailable",
|
|
910
|
+
reason: `Provider-bound retrieval diverged from durable response prefix for request "${request.requestId}"`,
|
|
911
|
+
};
|
|
912
|
+
}
|
|
913
|
+
const finishIndexes = reconciliation.events.flatMap((event, index) =>
|
|
914
|
+
event.type === "finish" ? [index] : [],
|
|
915
|
+
);
|
|
916
|
+
if (
|
|
917
|
+
finishIndexes.length !== 1 ||
|
|
918
|
+
finishIndexes[0] !== reconciliation.events.length - 1
|
|
919
|
+
) {
|
|
920
|
+
return {
|
|
921
|
+
status: "unavailable",
|
|
922
|
+
reason: `Provider-bound retrieval returned an invalid event structure for request "${request.requestId}"`,
|
|
923
|
+
};
|
|
924
|
+
}
|
|
925
|
+
let text = "";
|
|
926
|
+
const toolCalls: ToolCall[] = [];
|
|
927
|
+
let textDeltaIndex = 0;
|
|
928
|
+
for (const event of reconciliation.events) {
|
|
929
|
+
signal.throwIfAborted();
|
|
930
|
+
const journalTextDelta =
|
|
931
|
+
event.type !== "text-delta" || textDeltaIndex >= durablePrefix.length;
|
|
932
|
+
this.#applyStreamEvent(
|
|
933
|
+
event,
|
|
934
|
+
request.requestId,
|
|
935
|
+
turn,
|
|
936
|
+
step,
|
|
937
|
+
toolCalls,
|
|
938
|
+
(delta) => {
|
|
939
|
+
text += delta;
|
|
940
|
+
},
|
|
941
|
+
journalTextDelta,
|
|
942
|
+
);
|
|
943
|
+
if (event.type === "text-delta") textDeltaIndex += 1;
|
|
944
|
+
}
|
|
945
|
+
return {
|
|
946
|
+
status: "recovered",
|
|
947
|
+
response: { request, text, toolCalls },
|
|
948
|
+
};
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
#applyStreamEvent(
|
|
952
|
+
event: LlmStreamEvent,
|
|
953
|
+
requestId: string,
|
|
954
|
+
turn: number,
|
|
955
|
+
step: number,
|
|
956
|
+
toolCalls: ToolCall[],
|
|
957
|
+
appendText: (text: string) => void,
|
|
958
|
+
journal = true,
|
|
959
|
+
): void {
|
|
960
|
+
if (event.type === "text-delta") {
|
|
961
|
+
appendText(event.text);
|
|
962
|
+
if (journal) {
|
|
963
|
+
this.session.append({
|
|
964
|
+
type: "assistant/chunk",
|
|
965
|
+
turn,
|
|
966
|
+
step,
|
|
967
|
+
requestId,
|
|
968
|
+
text: event.text,
|
|
969
|
+
});
|
|
970
|
+
}
|
|
971
|
+
} else if (event.type === "tool-call") {
|
|
972
|
+
toolCalls.push(event.call);
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
/**
|
|
977
|
+
* Runs every occurrence and reports whether any result ended the Turn. The
|
|
978
|
+
* boolean is per *result*, not per definition: one tool can end a Turn for
|
|
979
|
+
* one payload and not another, and the kernel never inspects which.
|
|
980
|
+
*/
|
|
981
|
+
async #executeTools(
|
|
982
|
+
occurrences: readonly ToolCallOccurrence[],
|
|
983
|
+
signal: AbortSignal,
|
|
984
|
+
): Promise<boolean> {
|
|
985
|
+
let endsTurn = false;
|
|
986
|
+
for (const occurrence of occurrences) {
|
|
987
|
+
signal.throwIfAborted();
|
|
988
|
+
const { call, occurrenceId, turn, step } = occurrence;
|
|
989
|
+
const journal = validateToolOccurrenceJournal(this.session.events);
|
|
990
|
+
const existing = journal.get(occurrenceId);
|
|
991
|
+
if (existing?.result) continue;
|
|
992
|
+
const context = {
|
|
993
|
+
botId: this.botId,
|
|
994
|
+
agentId: this.id,
|
|
995
|
+
sessionId: this.session.id,
|
|
996
|
+
effectId: occurrenceId,
|
|
997
|
+
toolCall: call,
|
|
998
|
+
compositionGenerationId: this.#composition.generationId,
|
|
999
|
+
turnType: this.#turnType,
|
|
1000
|
+
...(this.#subagentRole === undefined
|
|
1001
|
+
? {}
|
|
1002
|
+
: { subagentRole: this.#subagentRole }),
|
|
1003
|
+
signal,
|
|
1004
|
+
};
|
|
1005
|
+
const preparation = await this.#ctx.tools.prepare(call, context);
|
|
1006
|
+
signal.throwIfAborted();
|
|
1007
|
+
if (existing?.intent && preparation.kind !== "ready") {
|
|
1008
|
+
throw new ToolEffectReconciliationRequiredError(
|
|
1009
|
+
occurrenceId,
|
|
1010
|
+
`Tool effect "${occurrenceId}" cannot be reconciled because its definition is unavailable`,
|
|
1011
|
+
);
|
|
1012
|
+
}
|
|
1013
|
+
if (!existing?.intent) {
|
|
1014
|
+
this.session.append({
|
|
1015
|
+
type: "tool/call",
|
|
1016
|
+
turn,
|
|
1017
|
+
step,
|
|
1018
|
+
occurrenceId,
|
|
1019
|
+
name: call.name,
|
|
1020
|
+
input: call.input,
|
|
1021
|
+
});
|
|
1022
|
+
await this.session.flush();
|
|
1023
|
+
if (signal.aborted) {
|
|
1024
|
+
this.session.append({
|
|
1025
|
+
type: "tool/result",
|
|
1026
|
+
turn,
|
|
1027
|
+
step,
|
|
1028
|
+
occurrenceId,
|
|
1029
|
+
name: call.name,
|
|
1030
|
+
content: "Cancelled before tool execution started.",
|
|
1031
|
+
isError: true,
|
|
1032
|
+
status: "interrupted",
|
|
1033
|
+
});
|
|
1034
|
+
await this.session.flush();
|
|
1035
|
+
signal.throwIfAborted();
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
let result: ToolExecutionResult;
|
|
1039
|
+
if (existing?.intent) {
|
|
1040
|
+
if (preparation.kind !== "ready") {
|
|
1041
|
+
throw new ToolEffectReconciliationRequiredError(
|
|
1042
|
+
occurrenceId,
|
|
1043
|
+
`Tool effect "${occurrenceId}" cannot be reconciled because its definition is unavailable`,
|
|
1044
|
+
);
|
|
1045
|
+
}
|
|
1046
|
+
const reconciliation = await this.#ctx.tools.reconcilePrepared(
|
|
1047
|
+
preparation,
|
|
1048
|
+
context,
|
|
1049
|
+
);
|
|
1050
|
+
if (reconciliation.status === "unavailable") {
|
|
1051
|
+
throw new ToolEffectReconciliationRequiredError(
|
|
1052
|
+
occurrenceId,
|
|
1053
|
+
reconciliation.reason,
|
|
1054
|
+
);
|
|
1055
|
+
}
|
|
1056
|
+
result = reconciliation.result;
|
|
1057
|
+
} else if (preparation.kind === "denied") {
|
|
1058
|
+
result = preparation.result;
|
|
1059
|
+
this.#ctx.emit("tools/result", call, result);
|
|
1060
|
+
} else {
|
|
1061
|
+
if (
|
|
1062
|
+
!(await this.#options.admitEffect({
|
|
1063
|
+
kind: "tool",
|
|
1064
|
+
effectId: occurrenceId,
|
|
1065
|
+
}))
|
|
1066
|
+
) {
|
|
1067
|
+
this.session.append({
|
|
1068
|
+
type: "tool/result",
|
|
1069
|
+
turn,
|
|
1070
|
+
step,
|
|
1071
|
+
occurrenceId,
|
|
1072
|
+
name: call.name,
|
|
1073
|
+
content: "Cancelled before tool execution started.",
|
|
1074
|
+
isError: true,
|
|
1075
|
+
status: "interrupted",
|
|
1076
|
+
});
|
|
1077
|
+
await this.session.flush();
|
|
1078
|
+
throw new EffectAdmissionFencedError(occurrenceId);
|
|
1079
|
+
}
|
|
1080
|
+
try {
|
|
1081
|
+
result = await this.#ctx.tools.executePrepared(preparation, context);
|
|
1082
|
+
} catch (error) {
|
|
1083
|
+
if (signal.aborted || !preparation.idempotent) {
|
|
1084
|
+
throw new ToolEffectReconciliationRequiredError(
|
|
1085
|
+
occurrenceId,
|
|
1086
|
+
signal.aborted
|
|
1087
|
+
? `Tool effect "${occurrenceId}" outcome is uncertain after cancellation`
|
|
1088
|
+
: `Non-idempotent tool effect "${occurrenceId}" outcome is uncertain`,
|
|
1089
|
+
);
|
|
1090
|
+
}
|
|
1091
|
+
result = {
|
|
1092
|
+
content:
|
|
1093
|
+
error instanceof Error ? error.message : "Tool execution failed",
|
|
1094
|
+
isError: true,
|
|
1095
|
+
};
|
|
1096
|
+
this.#ctx.emit("tools/result", call, result);
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
if (result.endsTurn === true) endsTurn = true;
|
|
1100
|
+
this.session.append({
|
|
1101
|
+
type: "tool/result",
|
|
1102
|
+
turn,
|
|
1103
|
+
step,
|
|
1104
|
+
occurrenceId,
|
|
1105
|
+
name: call.name,
|
|
1106
|
+
content: result.content,
|
|
1107
|
+
isError: result.isError,
|
|
1108
|
+
status: "completed",
|
|
1109
|
+
...(result.attachments && result.attachments.length > 0
|
|
1110
|
+
? { attachments: result.attachments }
|
|
1111
|
+
: {}),
|
|
1112
|
+
});
|
|
1113
|
+
await this.session.flush();
|
|
1114
|
+
}
|
|
1115
|
+
return endsTurn;
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
async #settleCancelledStep(turn: number, step: number): Promise<void> {
|
|
1119
|
+
const assistant = this.session.events.findLast(
|
|
1120
|
+
(event) =>
|
|
1121
|
+
event.type === "assistant/message" &&
|
|
1122
|
+
event.turn === turn &&
|
|
1123
|
+
event.step === step,
|
|
1124
|
+
);
|
|
1125
|
+
if (
|
|
1126
|
+
!assistant ||
|
|
1127
|
+
assistant.type !== "assistant/message" ||
|
|
1128
|
+
assistant.toolCalls.length === 0
|
|
1129
|
+
) {
|
|
1130
|
+
return;
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
const journal = validateToolOccurrenceJournal(this.session.events);
|
|
1134
|
+
for (const occurrence of toolCallOccurrences(
|
|
1135
|
+
turn,
|
|
1136
|
+
step,
|
|
1137
|
+
assistant.toolCalls,
|
|
1138
|
+
)) {
|
|
1139
|
+
const entry = journal.get(occurrence.occurrenceId);
|
|
1140
|
+
if (!entry?.intent) {
|
|
1141
|
+
this.session.append({
|
|
1142
|
+
type: "tool/call",
|
|
1143
|
+
turn,
|
|
1144
|
+
step,
|
|
1145
|
+
occurrenceId: occurrence.occurrenceId,
|
|
1146
|
+
name: occurrence.call.name,
|
|
1147
|
+
input: occurrence.call.input,
|
|
1148
|
+
});
|
|
1149
|
+
}
|
|
1150
|
+
if (!entry?.result) {
|
|
1151
|
+
this.session.append({
|
|
1152
|
+
type: "tool/result",
|
|
1153
|
+
turn,
|
|
1154
|
+
step,
|
|
1155
|
+
occurrenceId: occurrence.occurrenceId,
|
|
1156
|
+
name: occurrence.call.name,
|
|
1157
|
+
content: "Cancelled before tool execution started.",
|
|
1158
|
+
isError: true,
|
|
1159
|
+
status: "interrupted",
|
|
1160
|
+
});
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
await this.session.flush();
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
export class AgentLoop extends Service implements AgentFactory {
|
|
1168
|
+
static inject = ["sessions", "systemPrompt", "llm", "tools", "agents"];
|
|
1169
|
+
private maxSteps: number;
|
|
1170
|
+
private composition: CompositionPinV1;
|
|
1171
|
+
private handles = new Set<AgentHandle>();
|
|
1172
|
+
|
|
1173
|
+
constructor(ctx: Context, config: AgentLoopConfig) {
|
|
1174
|
+
super(ctx, "agentLoop");
|
|
1175
|
+
this.composition = config.composition;
|
|
1176
|
+
this.maxSteps = config.maxSteps ?? 20;
|
|
1177
|
+
if (!Number.isInteger(this.maxSteps) || this.maxSteps <= 0) {
|
|
1178
|
+
throw new Error("agent-loop maxSteps must be a positive integer");
|
|
1179
|
+
}
|
|
1180
|
+
if (!this.composition?.generationId || !this.composition.artifactSetHash) {
|
|
1181
|
+
throw new Error("agent-loop requires a pinned Composition generation");
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
async create(options: AgentOptions): Promise<AgentHandle> {
|
|
1186
|
+
const session = this.ctx.sessions.create(options.sessionId);
|
|
1187
|
+
const agent = new LoopAgent(
|
|
1188
|
+
this.ctx,
|
|
1189
|
+
session,
|
|
1190
|
+
options as EffectAdmittingAgentOptions,
|
|
1191
|
+
this.maxSteps,
|
|
1192
|
+
this.composition,
|
|
1193
|
+
);
|
|
1194
|
+
const unregister = this.ctx.agents.register(agent);
|
|
1195
|
+
let disposed = false;
|
|
1196
|
+
let handle: AgentHandle;
|
|
1197
|
+
handle = {
|
|
1198
|
+
agent,
|
|
1199
|
+
dispose: async () => {
|
|
1200
|
+
if (disposed) return;
|
|
1201
|
+
disposed = true;
|
|
1202
|
+
await agent.dispose();
|
|
1203
|
+
unregister();
|
|
1204
|
+
this.ctx.sessions.disposeSession(options.sessionId);
|
|
1205
|
+
this.handles.delete(handle);
|
|
1206
|
+
},
|
|
1207
|
+
};
|
|
1208
|
+
this.handles.add(handle);
|
|
1209
|
+
return handle;
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
[Service.init](): () => Promise<void> {
|
|
1213
|
+
const unsetFactory = this.ctx.agents.setFactory(this);
|
|
1214
|
+
return async () => {
|
|
1215
|
+
await Promise.all([...this.handles].map((handle) => handle.dispose()));
|
|
1216
|
+
unsetFactory();
|
|
1217
|
+
};
|
|
1218
|
+
}
|
|
1219
|
+
}
|