@pylonsync/functions 0.4.21 → 0.4.23
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/dist/agent-internals.d.ts +13 -0
- package/dist/agent.d.ts +103 -0
- package/dist/index.d.ts +2 -0
- package/dist/types.d.ts +30 -6
- package/package.json +1 -1
- package/src/agent-internals.ts +161 -0
- package/src/agent.test.ts +472 -0
- package/src/agent.ts +430 -0
- package/src/index.ts +12 -0
- package/src/runtime.ts +28 -2
- package/src/types.ts +31 -6
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent() unit tests: the validator→JSON-Schema converter and the tool
|
|
3
|
+
* loop driven against a scripted mock ctx (no Bun runner, no LLM).
|
|
4
|
+
*/
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import {
|
|
7
|
+
agent,
|
|
8
|
+
isAgentDefinition,
|
|
9
|
+
validatorSchemaToJsonSchema,
|
|
10
|
+
} from "./agent";
|
|
11
|
+
import { v } from "./validators";
|
|
12
|
+
import type {
|
|
13
|
+
ActionCtx,
|
|
14
|
+
LlmCompleteRequest,
|
|
15
|
+
LlmCompleteResponse,
|
|
16
|
+
LlmStreamEvent,
|
|
17
|
+
} from "./types";
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// validator → JSON Schema
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
describe("validatorSchemaToJsonSchema", () => {
|
|
24
|
+
test("maps every validator shape losslessly", () => {
|
|
25
|
+
const schema = validatorSchemaToJsonSchema({
|
|
26
|
+
q: v.string(),
|
|
27
|
+
n: v.int(),
|
|
28
|
+
score: v.number(),
|
|
29
|
+
on: v.boolean(),
|
|
30
|
+
target: v.id("Doc"),
|
|
31
|
+
tags: v.array(v.string()),
|
|
32
|
+
opts: v.object({ deep: v.optional(v.boolean()) }),
|
|
33
|
+
kind: v.union(v.literal("a"), v.literal("b")),
|
|
34
|
+
blob: v.json(),
|
|
35
|
+
maybe: v.optional(v.string()),
|
|
36
|
+
});
|
|
37
|
+
expect(schema).toEqual({
|
|
38
|
+
type: "object",
|
|
39
|
+
properties: {
|
|
40
|
+
q: { type: "string" },
|
|
41
|
+
n: { type: "integer" },
|
|
42
|
+
score: { type: "number" },
|
|
43
|
+
on: { type: "boolean" },
|
|
44
|
+
target: { type: "string" },
|
|
45
|
+
tags: { type: "array", items: { type: "string" } },
|
|
46
|
+
opts: {
|
|
47
|
+
type: "object",
|
|
48
|
+
properties: { deep: { type: "boolean" } },
|
|
49
|
+
},
|
|
50
|
+
kind: { anyOf: [{ const: "a" }, { const: "b" }] },
|
|
51
|
+
blob: {},
|
|
52
|
+
maybe: { type: "string" },
|
|
53
|
+
},
|
|
54
|
+
required: ["q", "n", "score", "on", "target", "tags", "opts", "kind", "blob"],
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("empty schema produces an object schema with no required", () => {
|
|
59
|
+
expect(validatorSchemaToJsonSchema({})).toEqual({
|
|
60
|
+
type: "object",
|
|
61
|
+
properties: {},
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// Mock ctx harness
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
interface WriteOp {
|
|
71
|
+
op: string;
|
|
72
|
+
[key: string]: unknown;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface ReadOverride {
|
|
76
|
+
run?: Record<string, unknown>;
|
|
77
|
+
messages?: Array<Record<string, unknown>>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function mockCtx(script: LlmCompleteResponse[], read?: ReadOverride) {
|
|
81
|
+
const writes: WriteOp[] = [];
|
|
82
|
+
const streamed: Array<{ event?: string; data: string }> = [];
|
|
83
|
+
let call = 0;
|
|
84
|
+
const ctx = {
|
|
85
|
+
auth: { userId: "u1", isAdmin: false, tenantId: null, roles: [] },
|
|
86
|
+
stream: {
|
|
87
|
+
id: "st_test",
|
|
88
|
+
write(data: string) {
|
|
89
|
+
streamed.push({ data });
|
|
90
|
+
},
|
|
91
|
+
writeEvent(event: string, data: string) {
|
|
92
|
+
streamed.push({ event, data });
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
llm: {
|
|
96
|
+
async stream(
|
|
97
|
+
_req: LlmCompleteRequest,
|
|
98
|
+
onEvent: (e: LlmStreamEvent) => void,
|
|
99
|
+
): Promise<LlmCompleteResponse> {
|
|
100
|
+
const res = script[call];
|
|
101
|
+
call += 1;
|
|
102
|
+
if (!res) throw new Error("mock LLM script exhausted");
|
|
103
|
+
for (const block of res.content) {
|
|
104
|
+
if (block.type === "text") {
|
|
105
|
+
onEvent({ type: "text_delta", text: block.text });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
onEvent({ type: "done", stop_reason: res.stop_reason, usage: res.usage });
|
|
109
|
+
return res;
|
|
110
|
+
},
|
|
111
|
+
async complete() {
|
|
112
|
+
throw new Error("not used");
|
|
113
|
+
},
|
|
114
|
+
async embed() {
|
|
115
|
+
throw new Error("not used");
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
async runQuery(name: string, args: Record<string, unknown>) {
|
|
119
|
+
if (name === "__pylon_agent_read") {
|
|
120
|
+
return {
|
|
121
|
+
run: {
|
|
122
|
+
id: String(args.runId),
|
|
123
|
+
agent: "helper",
|
|
124
|
+
status: "completed",
|
|
125
|
+
...read?.run,
|
|
126
|
+
},
|
|
127
|
+
messages: read?.messages ?? [
|
|
128
|
+
{ id: "m1", seq: 1, role: "user", content: "earlier question" },
|
|
129
|
+
{
|
|
130
|
+
id: "m2",
|
|
131
|
+
seq: 2,
|
|
132
|
+
role: "assistant",
|
|
133
|
+
content: [{ type: "text", text: "earlier answer" }],
|
|
134
|
+
},
|
|
135
|
+
],
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
throw new Error(`unexpected query ${name}`);
|
|
139
|
+
},
|
|
140
|
+
async runMutation(name: string, args: Record<string, unknown>) {
|
|
141
|
+
if (name !== "__pylon_agent_write") throw new Error(`unexpected ${name}`);
|
|
142
|
+
writes.push(args as WriteOp);
|
|
143
|
+
if (args.op === "createRun") return { id: "run_1" };
|
|
144
|
+
if (args.op === "appendMessage") return { id: "m", seq: writes.length };
|
|
145
|
+
return { updated: true };
|
|
146
|
+
},
|
|
147
|
+
error(code: string, message: string) {
|
|
148
|
+
const err = new Error(message);
|
|
149
|
+
(err as { code?: string }).code = code;
|
|
150
|
+
return err;
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
return { ctx: ctx as unknown as ActionCtx, writes, streamed };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const done = (text: string): LlmCompleteResponse => ({
|
|
157
|
+
model: "m",
|
|
158
|
+
content: [{ type: "text", text }],
|
|
159
|
+
stop_reason: "end_turn",
|
|
160
|
+
usage: { input_tokens: 10, output_tokens: 5 },
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
const wantsTool = (
|
|
164
|
+
name: string,
|
|
165
|
+
input: Record<string, unknown>,
|
|
166
|
+
): LlmCompleteResponse => ({
|
|
167
|
+
model: "m",
|
|
168
|
+
content: [
|
|
169
|
+
{ type: "text", text: "Let me check." },
|
|
170
|
+
{ type: "tool_use", id: "tu_1", name, input },
|
|
171
|
+
],
|
|
172
|
+
stop_reason: "tool_use",
|
|
173
|
+
usage: { input_tokens: 20, output_tokens: 8 },
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
// The loop
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
describe("agent loop", () => {
|
|
181
|
+
test("agent() produces a tagged action with the standard args", () => {
|
|
182
|
+
const def = agent({ system: "hi" });
|
|
183
|
+
expect((def as unknown as Record<string, unknown>).type).toBe("action");
|
|
184
|
+
expect((def as unknown as Record<string, unknown>).auth).toBe("user");
|
|
185
|
+
expect(isAgentDefinition(def)).toBe(true);
|
|
186
|
+
expect(isAgentDefinition(agent({}))).toBe(true);
|
|
187
|
+
expect(isAgentDefinition({ type: "action" })).toBe(false);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("new run: tool round-trip, transcript order, terminal status", async () => {
|
|
191
|
+
const toolCalls: unknown[] = [];
|
|
192
|
+
const def = agent({
|
|
193
|
+
system: "You are helpful.",
|
|
194
|
+
tools: {
|
|
195
|
+
lookup: {
|
|
196
|
+
description: "Look something up",
|
|
197
|
+
args: { q: v.string() },
|
|
198
|
+
handler: async (_ctx, input) => {
|
|
199
|
+
toolCalls.push(input);
|
|
200
|
+
return { hits: 3 };
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
const { ctx, writes, streamed } = mockCtx([
|
|
206
|
+
wantsTool("lookup", { q: "pylon" }),
|
|
207
|
+
done("Found 3 hits."),
|
|
208
|
+
]);
|
|
209
|
+
|
|
210
|
+
const result = await (def.handler as unknown as (
|
|
211
|
+
c: ActionCtx,
|
|
212
|
+
a: Record<string, unknown>,
|
|
213
|
+
) => Promise<Record<string, unknown>>)(ctx, {
|
|
214
|
+
input: "search pylon",
|
|
215
|
+
__agentName: "helper",
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
expect(result.runId).toBe("run_1");
|
|
219
|
+
expect(result.text).toBe("Found 3 hits.");
|
|
220
|
+
expect(result.steps).toBe(2);
|
|
221
|
+
expect(result.usage).toEqual({ input_tokens: 30, output_tokens: 13 });
|
|
222
|
+
expect(toolCalls).toEqual([{ q: "pylon" }]);
|
|
223
|
+
|
|
224
|
+
// Write sequence: createRun, CLAIM (running, guarded, +streamId),
|
|
225
|
+
// user msg, assistant(tool_use), tool_result, assistant(final),
|
|
226
|
+
// completed. The claim precedes every message write so a losing
|
|
227
|
+
// racer never persists a stray turn.
|
|
228
|
+
expect(writes.map((w) => `${w.op}:${w.role ?? w.status ?? ""}`)).toEqual([
|
|
229
|
+
"createRun:",
|
|
230
|
+
"setStatus:running",
|
|
231
|
+
"appendMessage:user",
|
|
232
|
+
"appendMessage:assistant",
|
|
233
|
+
"appendMessage:user", // tool_result batch
|
|
234
|
+
"appendMessage:assistant",
|
|
235
|
+
"setStatus:completed",
|
|
236
|
+
]);
|
|
237
|
+
const running = writes.find((w) => w.status === "running")!;
|
|
238
|
+
expect(running.streamId).toBe("st_test");
|
|
239
|
+
expect(running.guardNotRunning).toBe(true);
|
|
240
|
+
expect(Number(running.staleMs)).toBeGreaterThan(0);
|
|
241
|
+
const toolResultMsg = writes[4];
|
|
242
|
+
const blocks = toolResultMsg.content as Array<Record<string, unknown>>;
|
|
243
|
+
expect(blocks[0].type).toBe("tool_result");
|
|
244
|
+
expect(blocks[0].tool_use_id).toBe("tu_1");
|
|
245
|
+
expect(JSON.parse(String(blocks[0].content))).toEqual({ hits: 3 });
|
|
246
|
+
|
|
247
|
+
// Streaming: text deltas + a typed tool event.
|
|
248
|
+
expect(streamed.some((s) => s.data === "Let me check.")).toBe(true);
|
|
249
|
+
const toolEvent = streamed.find((s) => s.event === "tool")!;
|
|
250
|
+
expect(JSON.parse(toolEvent.data)).toEqual({
|
|
251
|
+
name: "lookup",
|
|
252
|
+
input: { q: "pylon" },
|
|
253
|
+
isError: false,
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("invalid tool input and unknown tools become is_error results", async () => {
|
|
258
|
+
const def = agent({
|
|
259
|
+
tools: {
|
|
260
|
+
strict: {
|
|
261
|
+
description: "needs a number",
|
|
262
|
+
args: { n: v.int() },
|
|
263
|
+
handler: async () => "never reached",
|
|
264
|
+
},
|
|
265
|
+
},
|
|
266
|
+
});
|
|
267
|
+
const { ctx, writes } = mockCtx([
|
|
268
|
+
{
|
|
269
|
+
model: "m",
|
|
270
|
+
content: [
|
|
271
|
+
{ type: "tool_use", id: "tu_a", name: "strict", input: { n: "NaN" } },
|
|
272
|
+
{ type: "tool_use", id: "tu_b", name: "ghost", input: {} },
|
|
273
|
+
],
|
|
274
|
+
stop_reason: "tool_use",
|
|
275
|
+
usage: { input_tokens: 1, output_tokens: 1 },
|
|
276
|
+
},
|
|
277
|
+
done("Recovered."),
|
|
278
|
+
]);
|
|
279
|
+
|
|
280
|
+
const result = await (def.handler as unknown as (
|
|
281
|
+
c: ActionCtx,
|
|
282
|
+
a: Record<string, unknown>,
|
|
283
|
+
) => Promise<Record<string, unknown>>)(ctx, {
|
|
284
|
+
input: "go",
|
|
285
|
+
__agentName: "helper",
|
|
286
|
+
});
|
|
287
|
+
expect(result.text).toBe("Recovered.");
|
|
288
|
+
|
|
289
|
+
const toolResults = writes.find(
|
|
290
|
+
(w) =>
|
|
291
|
+
w.op === "appendMessage" &&
|
|
292
|
+
Array.isArray(w.content) &&
|
|
293
|
+
(w.content as Array<Record<string, unknown>>).some(
|
|
294
|
+
(b) => b.type === "tool_result",
|
|
295
|
+
),
|
|
296
|
+
)!;
|
|
297
|
+
const blocks = (toolResults.content as Array<Record<string, unknown>>).filter(
|
|
298
|
+
(b) => b.type === "tool_result",
|
|
299
|
+
);
|
|
300
|
+
expect(blocks).toHaveLength(2);
|
|
301
|
+
expect(blocks.every((b) => b.is_error === true)).toBe(true);
|
|
302
|
+
expect(String(blocks[1].content)).toContain('Unknown tool "ghost"');
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test("continuation loads history and rejects agent mismatch", async () => {
|
|
306
|
+
const def = agent({});
|
|
307
|
+
const { ctx, writes } = mockCtx([done("With context.")]);
|
|
308
|
+
const result = await (def.handler as unknown as (
|
|
309
|
+
c: ActionCtx,
|
|
310
|
+
a: Record<string, unknown>,
|
|
311
|
+
) => Promise<Record<string, unknown>>)(ctx, {
|
|
312
|
+
input: "follow-up",
|
|
313
|
+
runId: "run_9",
|
|
314
|
+
__agentName: "helper",
|
|
315
|
+
});
|
|
316
|
+
expect(result.runId).toBe("run_9");
|
|
317
|
+
// No createRun for continuations.
|
|
318
|
+
expect(writes.some((w) => w.op === "createRun")).toBe(false);
|
|
319
|
+
|
|
320
|
+
// Wrong agent name → AGENT_MISMATCH.
|
|
321
|
+
const other = mockCtx([done("nope")]);
|
|
322
|
+
await expect(
|
|
323
|
+
(def.handler as unknown as (
|
|
324
|
+
c: ActionCtx,
|
|
325
|
+
a: Record<string, unknown>,
|
|
326
|
+
) => Promise<unknown>)(other.ctx, {
|
|
327
|
+
input: "x",
|
|
328
|
+
runId: "run_9",
|
|
329
|
+
__agentName: "differentAgent",
|
|
330
|
+
}),
|
|
331
|
+
).rejects.toThrow(/belongs to agent/);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
test("a fresh running run is RUN_BUSY; a stale one is taken over", async () => {
|
|
335
|
+
const def = agent({ timeout: 600 });
|
|
336
|
+
const busy = mockCtx([done("never")], {
|
|
337
|
+
run: { status: "running", updatedAt: new Date().toISOString() },
|
|
338
|
+
});
|
|
339
|
+
await expect(
|
|
340
|
+
(def.handler as unknown as (
|
|
341
|
+
c: ActionCtx,
|
|
342
|
+
a: Record<string, unknown>,
|
|
343
|
+
) => Promise<unknown>)(busy.ctx, {
|
|
344
|
+
input: "x",
|
|
345
|
+
runId: "run_9",
|
|
346
|
+
__agentName: "helper",
|
|
347
|
+
}),
|
|
348
|
+
).rejects.toThrow(/already generating/);
|
|
349
|
+
expect(busy.writes).toHaveLength(0);
|
|
350
|
+
|
|
351
|
+
// updatedAt older than the timeout window → dead generation,
|
|
352
|
+
// continuation proceeds.
|
|
353
|
+
const stale = mockCtx([done("Took over.")], {
|
|
354
|
+
run: {
|
|
355
|
+
status: "running",
|
|
356
|
+
updatedAt: new Date(Date.now() - 700_000).toISOString(),
|
|
357
|
+
},
|
|
358
|
+
});
|
|
359
|
+
const result = await (def.handler as unknown as (
|
|
360
|
+
c: ActionCtx,
|
|
361
|
+
a: Record<string, unknown>,
|
|
362
|
+
) => Promise<Record<string, unknown>>)(stale.ctx, {
|
|
363
|
+
input: "continue",
|
|
364
|
+
runId: "run_9",
|
|
365
|
+
__agentName: "helper",
|
|
366
|
+
});
|
|
367
|
+
expect(result.text).toBe("Took over.");
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
test("dangling tool_use in history is repaired with error results", async () => {
|
|
371
|
+
const def = agent({});
|
|
372
|
+
const { ctx, writes } = mockCtx([done("Recovered context.")], {
|
|
373
|
+
messages: [
|
|
374
|
+
{ id: "m1", seq: 1, role: "user", content: "do the thing" },
|
|
375
|
+
{
|
|
376
|
+
id: "m2",
|
|
377
|
+
seq: 2,
|
|
378
|
+
role: "assistant",
|
|
379
|
+
content: [
|
|
380
|
+
{ type: "text", text: "On it." },
|
|
381
|
+
{ type: "tool_use", id: "tu_dead", name: "lookup", input: {} },
|
|
382
|
+
],
|
|
383
|
+
},
|
|
384
|
+
],
|
|
385
|
+
});
|
|
386
|
+
await (def.handler as unknown as (
|
|
387
|
+
c: ActionCtx,
|
|
388
|
+
a: Record<string, unknown>,
|
|
389
|
+
) => Promise<unknown>)(ctx, {
|
|
390
|
+
input: "still there?",
|
|
391
|
+
runId: "run_9",
|
|
392
|
+
__agentName: "helper",
|
|
393
|
+
});
|
|
394
|
+
// The repair batch persists BEFORE the new user turn.
|
|
395
|
+
const repair = writes[1];
|
|
396
|
+
expect(repair.op).toBe("appendMessage");
|
|
397
|
+
const blocks = repair.content as Array<Record<string, unknown>>;
|
|
398
|
+
expect(blocks[0].type).toBe("tool_result");
|
|
399
|
+
expect(blocks[0].tool_use_id).toBe("tu_dead");
|
|
400
|
+
expect(blocks[0].is_error).toBe(true);
|
|
401
|
+
expect(String(blocks[0].content)).toContain("interrupted");
|
|
402
|
+
expect(writes[2].content).toBe("still there?");
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
test("oversized tool results are truncated before persisting", async () => {
|
|
406
|
+
const def = agent({
|
|
407
|
+
tools: {
|
|
408
|
+
dump: {
|
|
409
|
+
description: "returns a lot",
|
|
410
|
+
handler: async () => "x".repeat(80 * 1024),
|
|
411
|
+
},
|
|
412
|
+
},
|
|
413
|
+
});
|
|
414
|
+
const { ctx, writes } = mockCtx([
|
|
415
|
+
wantsTool("dump", {}),
|
|
416
|
+
done("Done."),
|
|
417
|
+
]);
|
|
418
|
+
await (def.handler as unknown as (
|
|
419
|
+
c: ActionCtx,
|
|
420
|
+
a: Record<string, unknown>,
|
|
421
|
+
) => Promise<unknown>)(ctx, { input: "go", __agentName: "helper" });
|
|
422
|
+
const toolResults = writes.find(
|
|
423
|
+
(w) =>
|
|
424
|
+
w.op === "appendMessage" &&
|
|
425
|
+
Array.isArray(w.content) &&
|
|
426
|
+
(w.content as Array<Record<string, unknown>>).some(
|
|
427
|
+
(b) => b.type === "tool_result",
|
|
428
|
+
),
|
|
429
|
+
)!;
|
|
430
|
+
const block = (toolResults.content as Array<Record<string, unknown>>)[0];
|
|
431
|
+
const content = String(block.content);
|
|
432
|
+
expect(content.length).toBeLessThan(70 * 1024);
|
|
433
|
+
expect(content.endsWith("[truncated]")).toBe(true);
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
test("maxSteps overrun fails the run", async () => {
|
|
437
|
+
const def = agent({
|
|
438
|
+
maxSteps: 1,
|
|
439
|
+
tools: {
|
|
440
|
+
loop: {
|
|
441
|
+
description: "always called",
|
|
442
|
+
handler: async () => "again",
|
|
443
|
+
},
|
|
444
|
+
},
|
|
445
|
+
});
|
|
446
|
+
// The model asks for a tool every time — exceeds maxSteps=1.
|
|
447
|
+
const { ctx, writes } = mockCtx([
|
|
448
|
+
wantsTool("loop", {}),
|
|
449
|
+
wantsTool("loop", {}),
|
|
450
|
+
]);
|
|
451
|
+
await expect(
|
|
452
|
+
(def.handler as unknown as (
|
|
453
|
+
c: ActionCtx,
|
|
454
|
+
a: Record<string, unknown>,
|
|
455
|
+
) => Promise<unknown>)(ctx, { input: "go", __agentName: "helper" }),
|
|
456
|
+
).rejects.toThrow(/tool round-trips/);
|
|
457
|
+
const failed = writes.find((w) => w.status === "failed")!;
|
|
458
|
+
expect(String(failed.error)).toContain("tool round-trips");
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
test("llm failure marks the run failed and rethrows", async () => {
|
|
462
|
+
const def = agent({});
|
|
463
|
+
const { ctx, writes } = mockCtx([]); // script exhausted immediately
|
|
464
|
+
await expect(
|
|
465
|
+
(def.handler as unknown as (
|
|
466
|
+
c: ActionCtx,
|
|
467
|
+
a: Record<string, unknown>,
|
|
468
|
+
) => Promise<unknown>)(ctx, { input: "go", __agentName: "helper" }),
|
|
469
|
+
).rejects.toThrow(/script exhausted/);
|
|
470
|
+
expect(writes.some((w) => w.status === "failed")).toBe(true);
|
|
471
|
+
});
|
|
472
|
+
});
|