@frockbot/kernel-agent-loop 0.3.16 → 0.3.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -5
- package/src/agent.ts +11 -1
- package/src/index.test.ts +178 -2
- package/src/index.ts +203 -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.18",
|
|
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.18",
|
|
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.18",
|
|
20
|
+
"@frockbot/plugin-prompt": "0.3.18",
|
|
21
|
+
"@frockbot/plugin-tools": "0.3.18",
|
|
22
22
|
"@types/bun": "1.4.0",
|
|
23
23
|
"@types/node": "26.2.0",
|
|
24
24
|
"typescript": "^7.0.2"
|
package/src/agent.ts
CHANGED
|
@@ -126,7 +126,17 @@ declare module "cordis" {
|
|
|
126
126
|
"agent/assistant-text": (
|
|
127
127
|
agent: Agent,
|
|
128
128
|
text: string,
|
|
129
|
-
position: {
|
|
129
|
+
position: {
|
|
130
|
+
turn: number;
|
|
131
|
+
step: number;
|
|
132
|
+
requestId: string;
|
|
133
|
+
/**
|
|
134
|
+
* The tools the same step is about to call, by name, so a listener
|
|
135
|
+
* can tell an acknowledgement the model *only* narrated from one it
|
|
136
|
+
* is also delivering through its own send tool.
|
|
137
|
+
*/
|
|
138
|
+
toolNames?: readonly string[];
|
|
139
|
+
},
|
|
130
140
|
) => Promise<void>;
|
|
131
141
|
}
|
|
132
142
|
}
|
package/src/index.test.ts
CHANGED
|
@@ -17,7 +17,7 @@ import { SystemPromptRegistry } from "@frockbot/plugin-prompt";
|
|
|
17
17
|
import { ToolRegistry } from "@frockbot/plugin-tools";
|
|
18
18
|
import { AgentRegistry, type AgentOptions } from "./agent.js";
|
|
19
19
|
import { Context, type Plugin } from "cordis";
|
|
20
|
-
import { AgentLoop } from "./index.js";
|
|
20
|
+
import { AgentLoop, STEP_LIMIT_REASON_V1 } from "./index.js";
|
|
21
21
|
|
|
22
22
|
const roots: Context[] = [];
|
|
23
23
|
const allowEffect = () => Promise.resolve(true);
|
|
@@ -151,6 +151,170 @@ afterEach(async () => {
|
|
|
151
151
|
});
|
|
152
152
|
|
|
153
153
|
describe("AgentLoop", () => {
|
|
154
|
+
test("journals one reported usage event for a model request", async () => {
|
|
155
|
+
const provider: LlmProvider = {
|
|
156
|
+
id: "reported-usage",
|
|
157
|
+
async *stream() {
|
|
158
|
+
yield { type: "text-delta", text: "done" };
|
|
159
|
+
yield {
|
|
160
|
+
type: "usage",
|
|
161
|
+
usage: {
|
|
162
|
+
inputTokens: 41,
|
|
163
|
+
outputTokens: 9,
|
|
164
|
+
cachedInputTokens: 7,
|
|
165
|
+
reasoningTokens: 3,
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
yield { type: "finish", reason: "completed" };
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
const root = await mountRuntime(provider);
|
|
172
|
+
const handle = await root.agents.create({
|
|
173
|
+
...allowEffectOptions,
|
|
174
|
+
botId: "bot-usage",
|
|
175
|
+
sessionId: "reported-usage",
|
|
176
|
+
provider: provider.id,
|
|
177
|
+
model: "priced-model",
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
handle.agent.send("count this");
|
|
181
|
+
await handle.agent.whenIdle();
|
|
182
|
+
|
|
183
|
+
expect(
|
|
184
|
+
handle.agent.session.events.filter(
|
|
185
|
+
(event) => event.type === "model/usage",
|
|
186
|
+
),
|
|
187
|
+
).toEqual([
|
|
188
|
+
expect.objectContaining({
|
|
189
|
+
type: "model/usage",
|
|
190
|
+
provider: "reported-usage",
|
|
191
|
+
model: "priced-model",
|
|
192
|
+
inputTokens: 41,
|
|
193
|
+
outputTokens: 9,
|
|
194
|
+
cachedInputTokens: 7,
|
|
195
|
+
reasoningTokens: 3,
|
|
196
|
+
estimated: false,
|
|
197
|
+
}),
|
|
198
|
+
]);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("estimates and marks usage when a provider reports no counts", async () => {
|
|
202
|
+
const provider: LlmProvider = {
|
|
203
|
+
id: "estimated-usage",
|
|
204
|
+
async *stream() {
|
|
205
|
+
yield { type: "text-delta", text: "an answer" };
|
|
206
|
+
yield { type: "finish", reason: "completed" };
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
const root = await mountRuntime(provider);
|
|
210
|
+
const handle = await root.agents.create({
|
|
211
|
+
...allowEffectOptions,
|
|
212
|
+
botId: "bot-usage",
|
|
213
|
+
sessionId: "estimated-usage",
|
|
214
|
+
provider: provider.id,
|
|
215
|
+
model: "unmetered-model",
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
handle.agent.send("count approximately");
|
|
219
|
+
await handle.agent.whenIdle();
|
|
220
|
+
|
|
221
|
+
const usage = handle.agent.session.events.filter(
|
|
222
|
+
(event) => event.type === "model/usage",
|
|
223
|
+
);
|
|
224
|
+
expect(usage).toHaveLength(1);
|
|
225
|
+
expect(usage[0]).toMatchObject({
|
|
226
|
+
type: "model/usage",
|
|
227
|
+
provider: "estimated-usage",
|
|
228
|
+
model: "unmetered-model",
|
|
229
|
+
estimated: true,
|
|
230
|
+
});
|
|
231
|
+
if (usage[0]?.type !== "model/usage") throw new Error("usage missing");
|
|
232
|
+
expect(usage[0].inputTokens).toBeGreaterThan(0);
|
|
233
|
+
expect(usage[0].outputTokens).toBeGreaterThan(0);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("journals a structured-output downgrade and typed validation failure", async () => {
|
|
237
|
+
const provider: LlmProvider = {
|
|
238
|
+
id: "structured-failure",
|
|
239
|
+
supports: { structuredOutput: "none" },
|
|
240
|
+
async *stream() {
|
|
241
|
+
yield {
|
|
242
|
+
type: "response-format-note",
|
|
243
|
+
note: {
|
|
244
|
+
code: "structured-output-downgraded",
|
|
245
|
+
requested: "json_schema",
|
|
246
|
+
effective: "prompt",
|
|
247
|
+
message: "Fake provider used prompt guidance",
|
|
248
|
+
},
|
|
249
|
+
};
|
|
250
|
+
yield { type: "text-delta", text: '{"answer":4}' };
|
|
251
|
+
yield { type: "finish", reason: "completed" };
|
|
252
|
+
},
|
|
253
|
+
};
|
|
254
|
+
const root = await mountRuntime(provider);
|
|
255
|
+
root.on("agent/request", async (_agent, _request, _signal, next) => ({
|
|
256
|
+
...(await next()),
|
|
257
|
+
responseFormat: {
|
|
258
|
+
type: "json_schema",
|
|
259
|
+
name: "answer",
|
|
260
|
+
schema: {
|
|
261
|
+
type: "object",
|
|
262
|
+
properties: { answer: { type: "string" } },
|
|
263
|
+
required: ["answer"],
|
|
264
|
+
additionalProperties: false,
|
|
265
|
+
},
|
|
266
|
+
},
|
|
267
|
+
}));
|
|
268
|
+
const handle = await root.agents.create({
|
|
269
|
+
...allowEffectOptions,
|
|
270
|
+
botId: "bot-structured-failure",
|
|
271
|
+
sessionId: "structured-failure",
|
|
272
|
+
provider: provider.id,
|
|
273
|
+
model: "fake",
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
handle.agent.send("answer as an object");
|
|
277
|
+
await handle.agent.whenIdle();
|
|
278
|
+
|
|
279
|
+
expect(
|
|
280
|
+
handle.agent.session.events.find(
|
|
281
|
+
(event) => event.type === "model/response-format-note",
|
|
282
|
+
),
|
|
283
|
+
).toMatchObject({
|
|
284
|
+
type: "model/response-format-note",
|
|
285
|
+
note: { effective: "prompt" },
|
|
286
|
+
});
|
|
287
|
+
expect(
|
|
288
|
+
handle.agent.session.events.find(
|
|
289
|
+
(event) => event.type === "model/response-failed",
|
|
290
|
+
),
|
|
291
|
+
).toMatchObject({
|
|
292
|
+
type: "model/response-failed",
|
|
293
|
+
failure: { code: "schema-mismatch" },
|
|
294
|
+
});
|
|
295
|
+
expect(
|
|
296
|
+
handle.agent.session.events.filter(
|
|
297
|
+
(event) => event.type === "model/usage",
|
|
298
|
+
),
|
|
299
|
+
).toEqual([
|
|
300
|
+
expect.objectContaining({
|
|
301
|
+
type: "model/usage",
|
|
302
|
+
provider: "structured-failure",
|
|
303
|
+
estimated: true,
|
|
304
|
+
}),
|
|
305
|
+
]);
|
|
306
|
+
expect(
|
|
307
|
+
handle.agent.session.events.some(
|
|
308
|
+
(event) => event.type === "model/reconciliation-required",
|
|
309
|
+
),
|
|
310
|
+
).toBe(false);
|
|
311
|
+
expect(
|
|
312
|
+
handle.agent.session.events.findLast(
|
|
313
|
+
(event) => event.type === "turn/end",
|
|
314
|
+
),
|
|
315
|
+
).toMatchObject({ type: "turn/end", outcome: "model-error" });
|
|
316
|
+
});
|
|
317
|
+
|
|
154
318
|
test("records exactly the hook-shaped request received by the provider", async () => {
|
|
155
319
|
let received: NormalizedModelRequest | undefined;
|
|
156
320
|
const provider: LlmProvider = {
|
|
@@ -1355,6 +1519,16 @@ describe("AgentLoop", () => {
|
|
|
1355
1519
|
"Model response outcome is uncertain: response lost after dispatch",
|
|
1356
1520
|
}),
|
|
1357
1521
|
);
|
|
1522
|
+
expect(
|
|
1523
|
+
handle.agent.session.events.filter(
|
|
1524
|
+
(event) => event.type === "model/usage",
|
|
1525
|
+
),
|
|
1526
|
+
).toEqual([
|
|
1527
|
+
expect.objectContaining({
|
|
1528
|
+
requestId: request.request.requestId,
|
|
1529
|
+
estimated: true,
|
|
1530
|
+
}),
|
|
1531
|
+
]);
|
|
1358
1532
|
expect(
|
|
1359
1533
|
handle.agent.session.events.some(
|
|
1360
1534
|
(event) => event.type === "step/end" || event.type === "turn/end",
|
|
@@ -3008,7 +3182,7 @@ describe("AgentLoop", () => {
|
|
|
3008
3182
|
expect(handle.agent.session.events.at(-1)).toMatchObject({
|
|
3009
3183
|
type: "turn/end",
|
|
3010
3184
|
outcome: "interrupted",
|
|
3011
|
-
reason:
|
|
3185
|
+
reason: STEP_LIMIT_REASON_V1,
|
|
3012
3186
|
});
|
|
3013
3187
|
// Nothing about the model failed, so nothing is reported as if it had.
|
|
3014
3188
|
expect(errors).toEqual([]);
|
|
@@ -3087,6 +3261,7 @@ describe("AgentLoop", () => {
|
|
|
3087
3261
|
root.on("agent/assistant-text", async (_agent, text, position) => {
|
|
3088
3262
|
order.push("assistant-text");
|
|
3089
3263
|
seen.push(`${position.turn}:${position.step}:${text}`);
|
|
3264
|
+
seen.push(`tools:${(position.toolNames ?? []).join(",")}`);
|
|
3090
3265
|
});
|
|
3091
3266
|
const handle = await root.agents.create({
|
|
3092
3267
|
...allowEffectOptions,
|
|
@@ -3100,6 +3275,7 @@ describe("AgentLoop", () => {
|
|
|
3100
3275
|
await handle.agent.whenIdle();
|
|
3101
3276
|
|
|
3102
3277
|
expect(seen[0]).toBe("1:1:On it — building it now.");
|
|
3278
|
+
expect(seen[1]).toBe("tools:build");
|
|
3103
3279
|
expect(order[0]).toBe("assistant-text");
|
|
3104
3280
|
expect(order).toContain("tool");
|
|
3105
3281
|
});
|
package/src/index.ts
CHANGED
|
@@ -13,12 +13,14 @@ import {
|
|
|
13
13
|
decodeSkillRefsV1,
|
|
14
14
|
LlmEffectNotStartedError,
|
|
15
15
|
type LlmStreamEvent,
|
|
16
|
+
type LlmUsageV1,
|
|
16
17
|
type LoopStepContinuationV1,
|
|
17
18
|
type NormalizedModelRequest,
|
|
18
19
|
ModelProviderFailureError,
|
|
19
20
|
type Session,
|
|
20
21
|
type SessionEvent,
|
|
21
22
|
type StepOutcome,
|
|
23
|
+
StructuredOutputValidationError,
|
|
22
24
|
type ToolCall,
|
|
23
25
|
type ToolCallOccurrence,
|
|
24
26
|
type ToolExecutionResult,
|
|
@@ -80,6 +82,31 @@ interface ModelResponse {
|
|
|
80
82
|
toolCalls: ToolCall[];
|
|
81
83
|
}
|
|
82
84
|
|
|
85
|
+
const TOKEN_ESTIMATE_BYTES_PER_TOKEN_V1 = 4;
|
|
86
|
+
|
|
87
|
+
function estimatedTokensV1(value: unknown): number {
|
|
88
|
+
const bytes = new TextEncoder().encode(JSON.stringify(value)).byteLength;
|
|
89
|
+
return Math.ceil(bytes / TOKEN_ESTIMATE_BYTES_PER_TOKEN_V1);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The provider-neutral fallback for transports that return no token counts.
|
|
94
|
+
* It is intentionally based on the exact normalized request and assembled
|
|
95
|
+
* response that are journaled, and is always marked estimated at the event.
|
|
96
|
+
*/
|
|
97
|
+
export function estimateModelUsageV1(
|
|
98
|
+
request: NormalizedModelRequest,
|
|
99
|
+
response: Pick<ModelResponse, "text" | "toolCalls">,
|
|
100
|
+
): LlmUsageV1 {
|
|
101
|
+
return {
|
|
102
|
+
inputTokens: estimatedTokensV1(request),
|
|
103
|
+
outputTokens: estimatedTokensV1({
|
|
104
|
+
text: response.text,
|
|
105
|
+
toolCalls: response.toolCalls,
|
|
106
|
+
}),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
83
110
|
type ModelReconciliation =
|
|
84
111
|
| { status: "recovered"; response: ModelResponse }
|
|
85
112
|
| { status: "unavailable"; reason: string }
|
|
@@ -115,11 +142,24 @@ class ToolEffectReconciliationRequiredError extends Error {
|
|
|
115
142
|
*/
|
|
116
143
|
class StepLimitReachedError extends Error {
|
|
117
144
|
constructor(readonly steps: number) {
|
|
118
|
-
|
|
145
|
+
// The sentence is written for the person, the way the Turn deadline's is:
|
|
146
|
+
// it is what reaches the chat bubble once `kernel-do` wraps it into the
|
|
147
|
+
// run's failure. The step count stays on the error for the log.
|
|
148
|
+
super(STEP_LIMIT_REASON_V1);
|
|
119
149
|
this.name = "StepLimitReachedError";
|
|
120
150
|
}
|
|
121
151
|
}
|
|
122
152
|
|
|
153
|
+
/**
|
|
154
|
+
* What a person is told when a reply used every step it was allowed. Bob
|
|
155
|
+
* (2026-09-04) ran a to-do applet build to the 64-step ceiling and the thread
|
|
156
|
+
* showed the generic "stopped before it finished" under a spinner that never
|
|
157
|
+
* ended; this names what happened and what to do, and stays true whatever
|
|
158
|
+
* the ceiling is.
|
|
159
|
+
*/
|
|
160
|
+
export const STEP_LIMIT_REASON_V1 =
|
|
161
|
+
"This Bot used all the steps it had for one reply and stopped. What it finished is saved. Send another message to carry on.";
|
|
162
|
+
|
|
123
163
|
/**
|
|
124
164
|
* The longest a single Turn may run before the loop stops waiting for it.
|
|
125
165
|
*
|
|
@@ -397,6 +437,8 @@ class LoopAgent implements Agent {
|
|
|
397
437
|
let unresolvedRequest: NormalizedModelRequest | undefined;
|
|
398
438
|
let definitiveNoEffect:
|
|
399
439
|
Extract<SessionEvent, { type: "model/effect-not-started" }> | undefined;
|
|
440
|
+
let definitiveResponseFailure:
|
|
441
|
+
Extract<SessionEvent, { type: "model/response-failed" }> | undefined;
|
|
400
442
|
for (const event of this.session.events) {
|
|
401
443
|
if (event.type === "turn/start") {
|
|
402
444
|
openTurn = event.turn;
|
|
@@ -405,6 +447,7 @@ class LoopAgent implements Agent {
|
|
|
405
447
|
latestStepOutcome = undefined;
|
|
406
448
|
unresolvedRequest = undefined;
|
|
407
449
|
definitiveNoEffect = undefined;
|
|
450
|
+
definitiveResponseFailure = undefined;
|
|
408
451
|
}
|
|
409
452
|
if (event.type === "turn/end" && event.turn === openTurn)
|
|
410
453
|
openTurn = undefined;
|
|
@@ -424,6 +467,7 @@ class LoopAgent implements Agent {
|
|
|
424
467
|
if (event.type === "model/request" && event.turn === openTurn) {
|
|
425
468
|
unresolvedRequest = event.request;
|
|
426
469
|
definitiveNoEffect = undefined;
|
|
470
|
+
definitiveResponseFailure = undefined;
|
|
427
471
|
}
|
|
428
472
|
if (
|
|
429
473
|
event.type === "model/effect-not-started" &&
|
|
@@ -431,6 +475,12 @@ class LoopAgent implements Agent {
|
|
|
431
475
|
) {
|
|
432
476
|
definitiveNoEffect = event;
|
|
433
477
|
}
|
|
478
|
+
if (
|
|
479
|
+
event.type === "model/response-failed" &&
|
|
480
|
+
event.requestId === unresolvedRequest?.requestId
|
|
481
|
+
) {
|
|
482
|
+
definitiveResponseFailure = event;
|
|
483
|
+
}
|
|
434
484
|
if (
|
|
435
485
|
event.type === "assistant/message" &&
|
|
436
486
|
event.requestId === unresolvedRequest?.requestId
|
|
@@ -464,6 +514,22 @@ class LoopAgent implements Agent {
|
|
|
464
514
|
let nextStep = latestStep === 0 ? 1 : latestStep + 1;
|
|
465
515
|
if (unresolvedRequest) {
|
|
466
516
|
openStep = latestStep;
|
|
517
|
+
if (definitiveResponseFailure) {
|
|
518
|
+
await this.#notifyModelOutcome(
|
|
519
|
+
definitiveResponseFailure.requestId,
|
|
520
|
+
"completed",
|
|
521
|
+
);
|
|
522
|
+
turnOutcome = "model-error";
|
|
523
|
+
turnReason = turnEndReason(definitiveResponseFailure.failure.message);
|
|
524
|
+
this.#ctx.emit(
|
|
525
|
+
"agent/error",
|
|
526
|
+
this,
|
|
527
|
+
new StructuredOutputValidationError(
|
|
528
|
+
definitiveResponseFailure.failure,
|
|
529
|
+
),
|
|
530
|
+
);
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
467
533
|
if (definitiveNoEffect) {
|
|
468
534
|
await this.#notifyModelOutcome(
|
|
469
535
|
definitiveNoEffect.requestId,
|
|
@@ -977,6 +1043,9 @@ class LoopAgent implements Agent {
|
|
|
977
1043
|
// The same turn type the tool catalog is trimmed to. A section that
|
|
978
1044
|
// renders what a Turn may do would otherwise have to guess it.
|
|
979
1045
|
turnType: this.#turnType,
|
|
1046
|
+
// Where the Turn is in its budget, so a section can warn the model
|
|
1047
|
+
// before the loop stops it.
|
|
1048
|
+
step: { current: step, max: this.#maxSteps },
|
|
980
1049
|
});
|
|
981
1050
|
|
|
982
1051
|
// One automatic retry, and only for a failure the provider itself
|
|
@@ -1054,6 +1123,11 @@ class LoopAgent implements Agent {
|
|
|
1054
1123
|
try {
|
|
1055
1124
|
return await this.#consumeStream(request, turn, step, signal);
|
|
1056
1125
|
} catch (error) {
|
|
1126
|
+
if (error instanceof StructuredOutputValidationError) {
|
|
1127
|
+
await this.session.flush();
|
|
1128
|
+
await this.#notifyModelOutcome(request.requestId, "completed");
|
|
1129
|
+
throw error;
|
|
1130
|
+
}
|
|
1057
1131
|
if (signal.aborted) {
|
|
1058
1132
|
const reason = `Model response outcome is uncertain after cancellation: ${modelFailureMessage(error)}`;
|
|
1059
1133
|
this.session.append({
|
|
@@ -1140,11 +1214,20 @@ class LoopAgent implements Agent {
|
|
|
1140
1214
|
): Promise<ModelResponse> {
|
|
1141
1215
|
let text = "";
|
|
1142
1216
|
const toolCalls: ToolCall[] = [];
|
|
1217
|
+
let usage: LlmUsageV1 | undefined;
|
|
1218
|
+
let structuredFailure:
|
|
1219
|
+
| Extract<
|
|
1220
|
+
LlmStreamEvent,
|
|
1221
|
+
{ type: "structured-output-failure" }
|
|
1222
|
+
>["failure"]
|
|
1223
|
+
| undefined;
|
|
1143
1224
|
let receivedProviderEvent = false;
|
|
1225
|
+
const startedAt = Date.now();
|
|
1144
1226
|
try {
|
|
1145
1227
|
for await (const event of this.#ctx.llm.stream(request, signal)) {
|
|
1146
|
-
receivedProviderEvent = true;
|
|
1228
|
+
if (event.type !== "response-format-note") receivedProviderEvent = true;
|
|
1147
1229
|
signal.throwIfAborted();
|
|
1230
|
+
if (event.type === "usage") usage = structuredClone(event.usage);
|
|
1148
1231
|
this.#applyStreamEvent(
|
|
1149
1232
|
event,
|
|
1150
1233
|
request.requestId,
|
|
@@ -1155,16 +1238,57 @@ class LoopAgent implements Agent {
|
|
|
1155
1238
|
text += delta;
|
|
1156
1239
|
},
|
|
1157
1240
|
);
|
|
1241
|
+
if (event.type === "structured-output-failure") {
|
|
1242
|
+
structuredFailure = event.failure;
|
|
1243
|
+
}
|
|
1158
1244
|
}
|
|
1159
1245
|
} catch (error) {
|
|
1160
1246
|
if (receivedProviderEvent && error instanceof ModelProviderFailureError) {
|
|
1161
|
-
|
|
1247
|
+
const invalidNoEffectClaim = new Error(
|
|
1162
1248
|
error.message ||
|
|
1163
1249
|
"Model provider reported a retryable failure after returning response data",
|
|
1164
1250
|
);
|
|
1251
|
+
this.#recordModelUsage(
|
|
1252
|
+
request,
|
|
1253
|
+
turn,
|
|
1254
|
+
step,
|
|
1255
|
+
usage,
|
|
1256
|
+
text,
|
|
1257
|
+
toolCalls,
|
|
1258
|
+
Math.max(0, Date.now() - startedAt),
|
|
1259
|
+
);
|
|
1260
|
+
throw invalidNoEffectClaim;
|
|
1261
|
+
}
|
|
1262
|
+
// Once dispatch may have begun, the call can have incurred spend even
|
|
1263
|
+
// when its terminal response is lost. Preserve the provider's partial
|
|
1264
|
+
// counts when present and otherwise write the same explicit estimate as
|
|
1265
|
+
// a successful unmetered stream. A definitive no-effect result is the
|
|
1266
|
+
// sole exception because the provider says no billable call occurred.
|
|
1267
|
+
if (!(error instanceof ModelProviderFailureError)) {
|
|
1268
|
+
this.#recordModelUsage(
|
|
1269
|
+
request,
|
|
1270
|
+
turn,
|
|
1271
|
+
step,
|
|
1272
|
+
usage,
|
|
1273
|
+
text,
|
|
1274
|
+
toolCalls,
|
|
1275
|
+
Math.max(0, Date.now() - startedAt),
|
|
1276
|
+
);
|
|
1165
1277
|
}
|
|
1166
1278
|
throw error;
|
|
1167
1279
|
}
|
|
1280
|
+
this.#recordModelUsage(
|
|
1281
|
+
request,
|
|
1282
|
+
turn,
|
|
1283
|
+
step,
|
|
1284
|
+
usage,
|
|
1285
|
+
text,
|
|
1286
|
+
toolCalls,
|
|
1287
|
+
Math.max(0, Date.now() - startedAt),
|
|
1288
|
+
);
|
|
1289
|
+
if (structuredFailure) {
|
|
1290
|
+
throw new StructuredOutputValidationError(structuredFailure);
|
|
1291
|
+
}
|
|
1168
1292
|
return { request, text, toolCalls };
|
|
1169
1293
|
}
|
|
1170
1294
|
|
|
@@ -1211,9 +1335,18 @@ class LoopAgent implements Agent {
|
|
|
1211
1335
|
}
|
|
1212
1336
|
let text = "";
|
|
1213
1337
|
const toolCalls: ToolCall[] = [];
|
|
1338
|
+
let usage: LlmUsageV1 | undefined;
|
|
1339
|
+
let structuredFailure:
|
|
1340
|
+
| Extract<
|
|
1341
|
+
LlmStreamEvent,
|
|
1342
|
+
{ type: "structured-output-failure" }
|
|
1343
|
+
>["failure"]
|
|
1344
|
+
| undefined;
|
|
1214
1345
|
let textDeltaIndex = 0;
|
|
1346
|
+
const startedAt = Date.now();
|
|
1215
1347
|
for (const event of reconciliation.events) {
|
|
1216
1348
|
signal.throwIfAborted();
|
|
1349
|
+
if (event.type === "usage") usage = structuredClone(event.usage);
|
|
1217
1350
|
const journalTextDelta =
|
|
1218
1351
|
event.type !== "text-delta" || textDeltaIndex >= durablePrefix.length;
|
|
1219
1352
|
this.#applyStreamEvent(
|
|
@@ -1228,6 +1361,23 @@ class LoopAgent implements Agent {
|
|
|
1228
1361
|
journalTextDelta,
|
|
1229
1362
|
);
|
|
1230
1363
|
if (event.type === "text-delta") textDeltaIndex += 1;
|
|
1364
|
+
if (event.type === "structured-output-failure") {
|
|
1365
|
+
structuredFailure = event.failure;
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
this.#recordModelUsage(
|
|
1369
|
+
request,
|
|
1370
|
+
turn,
|
|
1371
|
+
step,
|
|
1372
|
+
usage,
|
|
1373
|
+
text,
|
|
1374
|
+
toolCalls,
|
|
1375
|
+
Math.max(0, Date.now() - startedAt),
|
|
1376
|
+
);
|
|
1377
|
+
if (structuredFailure) {
|
|
1378
|
+
await this.session.flush();
|
|
1379
|
+
await this.#notifyModelOutcome(request.requestId, "completed");
|
|
1380
|
+
throw new StructuredOutputValidationError(structuredFailure);
|
|
1231
1381
|
}
|
|
1232
1382
|
return {
|
|
1233
1383
|
status: "recovered",
|
|
@@ -1235,6 +1385,39 @@ class LoopAgent implements Agent {
|
|
|
1235
1385
|
};
|
|
1236
1386
|
}
|
|
1237
1387
|
|
|
1388
|
+
#recordModelUsage(
|
|
1389
|
+
request: NormalizedModelRequest,
|
|
1390
|
+
turn: number,
|
|
1391
|
+
step: number,
|
|
1392
|
+
reported: LlmUsageV1 | undefined,
|
|
1393
|
+
text: string,
|
|
1394
|
+
toolCalls: readonly ToolCall[],
|
|
1395
|
+
latencyMs: number,
|
|
1396
|
+
): void {
|
|
1397
|
+
const existing = this.session.events.some(
|
|
1398
|
+
(event) =>
|
|
1399
|
+
event.type === "model/usage" && event.requestId === request.requestId,
|
|
1400
|
+
);
|
|
1401
|
+
if (existing) return;
|
|
1402
|
+
const usage =
|
|
1403
|
+
reported ??
|
|
1404
|
+
estimateModelUsageV1(request, { text, toolCalls: [...toolCalls] });
|
|
1405
|
+
this.session.append({
|
|
1406
|
+
type: "model/usage",
|
|
1407
|
+
turn,
|
|
1408
|
+
step,
|
|
1409
|
+
requestId: request.requestId,
|
|
1410
|
+
provider: request.provider,
|
|
1411
|
+
model: request.model,
|
|
1412
|
+
...(request.modelBinding
|
|
1413
|
+
? { modelBinding: structuredClone(request.modelBinding) }
|
|
1414
|
+
: {}),
|
|
1415
|
+
...usage,
|
|
1416
|
+
latencyMs,
|
|
1417
|
+
estimated: reported === undefined,
|
|
1418
|
+
});
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1238
1421
|
#applyStreamEvent(
|
|
1239
1422
|
event: LlmStreamEvent,
|
|
1240
1423
|
requestId: string,
|
|
@@ -1257,6 +1440,22 @@ class LoopAgent implements Agent {
|
|
|
1257
1440
|
}
|
|
1258
1441
|
} else if (event.type === "tool-call") {
|
|
1259
1442
|
toolCalls.push(event.call);
|
|
1443
|
+
} else if (event.type === "response-format-note") {
|
|
1444
|
+
this.session.append({
|
|
1445
|
+
type: "model/response-format-note",
|
|
1446
|
+
turn,
|
|
1447
|
+
step,
|
|
1448
|
+
requestId,
|
|
1449
|
+
note: event.note,
|
|
1450
|
+
});
|
|
1451
|
+
} else if (event.type === "structured-output-failure") {
|
|
1452
|
+
this.session.append({
|
|
1453
|
+
type: "model/response-failed",
|
|
1454
|
+
turn,
|
|
1455
|
+
step,
|
|
1456
|
+
requestId,
|
|
1457
|
+
failure: event.failure,
|
|
1458
|
+
});
|
|
1260
1459
|
}
|
|
1261
1460
|
}
|
|
1262
1461
|
|
|
@@ -1424,6 +1623,7 @@ class LoopAgent implements Agent {
|
|
|
1424
1623
|
turn,
|
|
1425
1624
|
step,
|
|
1426
1625
|
requestId: response.request.requestId,
|
|
1626
|
+
toolNames: response.toolCalls.map((call) => call.name),
|
|
1427
1627
|
});
|
|
1428
1628
|
}
|
|
1429
1629
|
|