@anteros/core 0.0.44 → 0.0.47
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/LICENSE +21 -0
- package/README.md +1 -1
- package/database/field.crypto.ts +38 -13
- package/database/mongodbadapter.ts +85 -25
- package/database/vars.ts +22 -2
- package/index.ts +22 -0
- package/lib/agent.ts +707 -79
- package/lib/agentMemory.ts +199 -0
- package/lib/agents.ts +133 -52
- package/lib/attachment.ts +24 -3
- package/lib/define.ts +5 -1
- package/lib/eval.ts +123 -0
- package/lib/files.ts +21 -15
- package/lib/jsonSchema.ts +8 -0
- package/lib/mcp.ts +52 -30
- package/lib/messages.ts +202 -19
- package/lib/partialJson.ts +79 -0
- package/lib/providers.ts +26 -2
- package/lib/redact.ts +11 -0
- package/lib/tracing.ts +20 -0
- package/lib/zip.ts +23 -3
- package/package.json +4 -5
- package/server/agents.ts +66 -1
- package/server/api.ts +302 -20
- package/server/boot.ts +13 -4
- package/server/hono.ts +146 -6
- package/tests/access-log-body.test.ts +253 -0
- package/tests/agent-checkpoint.test.ts +195 -0
- package/tests/agent-hardening.test.ts +238 -0
- package/tests/agent-memory.test.ts +175 -1
- package/tests/agent-stream-advanced.test.ts +246 -0
- package/tests/agent-tracing-eval.test.ts +219 -0
- package/tests/agent-working-memory.test.ts +7 -4
- package/tests/agent.test.ts +54 -3
- package/tests/agents-api.test.ts +21 -0
- package/tests/api-external-guards.test.ts +304 -0
- package/tests/attachment.test.ts +63 -0
- package/tests/audit-redact.test.ts +26 -1
- package/tests/field-encryption.test.ts +47 -3
- package/tests/filters-hardening.test.ts +81 -0
- package/tests/fixtures/ai-tenant/ai/agents/support/billing.agent.ts +9 -0
- package/tests/fixtures/ai-tenant/ai/agents/support/billing.tool.ts +9 -0
- package/tests/fixtures/ai-tenant/ai/agents/support.agent.ts +9 -0
- package/tests/fixtures/ai-tenant/ai/agents/support.tool.ts +9 -0
- package/tests/fixtures/ai-tenant/ai/mcp/resources/readme.resource.ts +9 -0
- package/tests/fixtures/ai-tenant/ai/mcp/tools/echo.tool.ts +9 -0
- package/tests/fixtures/fake-provider.ts +33 -0
- package/tests/fixtures/tool-tenant/ai/agents/report.agent.ts +12 -0
- package/tests/fixtures/tool-tenant/ai/agents/report.tool.ts +12 -0
- package/tests/fixtures/vars-tenant/vars/open.var.ts +18 -0
- package/tests/hono.test.ts +81 -1
- package/tests/jwt-tenant.test.ts +165 -0
- package/tests/logger.test.ts +62 -10
- package/tests/messages.test.ts +88 -32
- package/tests/tool-runtime.test.ts +291 -0
- package/tests/tool-types.ts +33 -0
- package/tests/upload.test.ts +175 -3
- package/tests/vars-api.test.ts +73 -1
- package/tests/vars.test.ts +1 -1
- package/types/agent.d.ts +386 -4
- package/types/config.d.ts +56 -2
- package/types/file.d.ts +1 -1
- package/types/mcp.d.ts +2 -2
- package/utils/func.ts +20 -0
- package/utils/logger.ts +125 -33
package/lib/agent.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { AppError, fn } from "./error";
|
|
2
|
+
import { DEFAULT_REDACT_KEYS, isSensitiveKey, redactKeys } from "./redact";
|
|
2
3
|
import { toJsonSchema, validateWithSchema } from "./jsonSchema";
|
|
3
4
|
import { InMemoryAgentMemory, trimMessages } from "./agentMemory";
|
|
4
5
|
import { forMemory, resolveAttachments } from "./attachment";
|
|
5
|
-
import { normalizeMessages, serializeToolResult } from "./messages";
|
|
6
|
+
import { normalizeMessages, serializeToolResult, toolResultMedia } from "./messages";
|
|
6
7
|
import { mcpToolsFor } from "./mcpClient";
|
|
8
|
+
import { parsePartialJson } from "./partialJson";
|
|
7
9
|
import { skillsBlock } from "./skills";
|
|
10
|
+
import { trace } from "./tracing";
|
|
8
11
|
import {
|
|
9
12
|
EMPTY_USAGE,
|
|
10
13
|
resolveProvider,
|
|
@@ -29,12 +32,15 @@ import type {
|
|
|
29
32
|
AgentMessage,
|
|
30
33
|
AgentOptions,
|
|
31
34
|
AgentPendingApproval,
|
|
35
|
+
AgentRunState,
|
|
36
|
+
AgentRunStore,
|
|
32
37
|
AgentSkill,
|
|
33
38
|
AgentTextPart,
|
|
34
39
|
AgentThinkingBlock,
|
|
35
40
|
AgentThread,
|
|
36
41
|
AgentProvider,
|
|
37
42
|
AgentStreamChunk,
|
|
43
|
+
AgentStreamObjectResult,
|
|
38
44
|
AgentStreamResult,
|
|
39
45
|
AgentStep,
|
|
40
46
|
AgentStructuredOutput,
|
|
@@ -43,6 +49,10 @@ import type {
|
|
|
43
49
|
AgentToolContext,
|
|
44
50
|
AgentToolMessage,
|
|
45
51
|
AgentToolResultEntry,
|
|
52
|
+
AgentGuardrail,
|
|
53
|
+
AgentGuardrailContext,
|
|
54
|
+
AgentPricing,
|
|
55
|
+
AgentToolStats,
|
|
46
56
|
AgentTools,
|
|
47
57
|
AgentUsage,
|
|
48
58
|
AgentWorkingMemory,
|
|
@@ -264,11 +274,13 @@ async function applyProcessors(
|
|
|
264
274
|
function addUsage(base: AgentUsage, next: Partial<AgentUsage>): AgentUsage {
|
|
265
275
|
const inputTokens = base.inputTokens + (next.inputTokens ?? 0);
|
|
266
276
|
const outputTokens = base.outputTokens + (next.outputTokens ?? 0);
|
|
277
|
+
const cost = (base.cost ?? 0) + (next.cost ?? 0);
|
|
267
278
|
return {
|
|
268
279
|
inputTokens,
|
|
269
280
|
outputTokens,
|
|
270
281
|
totalTokens: inputTokens + outputTokens,
|
|
271
282
|
requests: base.requests + (next.requests ?? 0),
|
|
283
|
+
...(cost ? { cost } : {}),
|
|
272
284
|
};
|
|
273
285
|
}
|
|
274
286
|
|
|
@@ -328,6 +340,19 @@ function extractJson(text: string): { value?: any; error?: string } {
|
|
|
328
340
|
}
|
|
329
341
|
}
|
|
330
342
|
|
|
343
|
+
/**
|
|
344
|
+
* The object of a running answer — `undefined` until the text parses, a partial
|
|
345
|
+
* parse after that. It **drains** the run's text: a caller picks
|
|
346
|
+
* `partialObjectStream` **or** `textStream`, never both.
|
|
347
|
+
*/
|
|
348
|
+
async function* partialObjects<T>(text: AsyncIterable<string>): AsyncIterable<T | undefined> {
|
|
349
|
+
let buffer = "";
|
|
350
|
+
for await (const token of text) {
|
|
351
|
+
buffer += token;
|
|
352
|
+
yield parsePartialJson(buffer) as T | undefined;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
331
356
|
// ─── Default memory ──────────────────────────────────────────────────────
|
|
332
357
|
// The stores live in `lib/agentMemory.ts` (`agents.memory.InMemory` / `.Mongo`).
|
|
333
358
|
|
|
@@ -359,6 +384,142 @@ async function withTimeout<T>(work: Promise<T>, ms: number, name: string): Promi
|
|
|
359
384
|
}
|
|
360
385
|
}
|
|
361
386
|
|
|
387
|
+
/** Run `work` over `items` with at most `limit` in flight, preserving order. */
|
|
388
|
+
async function mapLimited<T, R>(items: T[], limit: number, work: (item: T, index: number) => Promise<R>): Promise<R[]> {
|
|
389
|
+
const out = new Array<R>(items.length);
|
|
390
|
+
let next = 0;
|
|
391
|
+
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
392
|
+
while (true) {
|
|
393
|
+
const index = next++;
|
|
394
|
+
if (index >= items.length) return;
|
|
395
|
+
out[index] = await work(items[index]!, index);
|
|
396
|
+
}
|
|
397
|
+
});
|
|
398
|
+
await Promise.all(workers);
|
|
399
|
+
return out;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** A prompt budget is an order of magnitude: 4 chars ≈ 1 token, no tokenizer dependency. */
|
|
403
|
+
function estimateTokens(message: AgentMessage): number {
|
|
404
|
+
const content = (message as any).content;
|
|
405
|
+
const text = typeof content === "string" ? content : JSON.stringify(content ?? "");
|
|
406
|
+
return Math.ceil((text?.length ?? 0) / 4) + 4;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** Drop the oldest turns until the prompt fits `budget`; the last turn always stays. */
|
|
410
|
+
function trimToTokens(messages: AgentMessage[], budget: number): AgentMessage[] {
|
|
411
|
+
let total = messages.reduce((sum, message) => sum + estimateTokens(message), 0);
|
|
412
|
+
let start = 0;
|
|
413
|
+
while (total > budget && start < messages.length - 1) {
|
|
414
|
+
total -= estimateTokens(messages[start]!);
|
|
415
|
+
start += 1;
|
|
416
|
+
}
|
|
417
|
+
return start ? messages.slice(start) : messages;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** The text a guardrail reads — a message's text parts joined. */
|
|
421
|
+
function messageText(message: AgentMessage | undefined): string {
|
|
422
|
+
if (!message) return "";
|
|
423
|
+
const content = (message as any).content;
|
|
424
|
+
if (typeof content === "string") return content;
|
|
425
|
+
return (content ?? [])
|
|
426
|
+
.filter((part: any) => part?.type === "text")
|
|
427
|
+
.map((part: any) => part.text)
|
|
428
|
+
.join("\n");
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Run one side of the guardrails. A check that throws **blocks the run** with
|
|
433
|
+
* `AGENT_GUARDRAIL_BLOCKED`, naming the guardrail and carrying its own code.
|
|
434
|
+
*/
|
|
435
|
+
async function runGuardrails(
|
|
436
|
+
list: AgentGuardrail[] | undefined,
|
|
437
|
+
value: string,
|
|
438
|
+
ctx: AgentGuardrailContext,
|
|
439
|
+
kind: "prompt" | "answer",
|
|
440
|
+
): Promise<void> {
|
|
441
|
+
for (const guardrail of list ?? []) {
|
|
442
|
+
try {
|
|
443
|
+
await guardrail.check(value, ctx);
|
|
444
|
+
} catch (err: any) {
|
|
445
|
+
throw new AppError(
|
|
446
|
+
`Guardrail '${guardrail.name}' blocked the ${kind}: ${err?.message ?? err}`,
|
|
447
|
+
{ status: 400, code: "AGENT_GUARDRAIL_BLOCKED", reason: err?.code ? String(err.code) : undefined },
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Check a **successful** tool result before the model reads it. A guardrail that
|
|
455
|
+
* throws blocks **that result only**: it becomes an error the model sees
|
|
456
|
+
* (`AGENT_GUARDRAIL_BLOCKED`), and the run goes on — a tool result is a turn of
|
|
457
|
+
* the conversation, not the answer. A failed result is not checked.
|
|
458
|
+
*/
|
|
459
|
+
async function guardToolResult(
|
|
460
|
+
entry: AgentToolResultEntry,
|
|
461
|
+
list: AgentGuardrail[],
|
|
462
|
+
ctx: AgentGuardrailContext,
|
|
463
|
+
): Promise<void> {
|
|
464
|
+
if (entry.error || !list.length) return;
|
|
465
|
+
|
|
466
|
+
const value = serializeToolResult(entry);
|
|
467
|
+
const toolCtx: AgentGuardrailContext = { ...ctx, tool: entry.name, toolCallId: entry.id };
|
|
468
|
+
|
|
469
|
+
for (const guardrail of list) {
|
|
470
|
+
try {
|
|
471
|
+
await guardrail.check(value, toolCtx);
|
|
472
|
+
} catch (err: any) {
|
|
473
|
+
entry.error = `Guardrail '${guardrail.name}' blocked the tool result: ${err?.message ?? err}`;
|
|
474
|
+
entry.errorCode = "AGENT_GUARDRAIL_BLOCKED";
|
|
475
|
+
delete entry.result;
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/** What a model call cost, in USD, from its token usage. */
|
|
482
|
+
function costOf(usage: AgentUsage, pricing: AgentPricing): number {
|
|
483
|
+
return (usage.inputTokens / 1_000_000) * pricing.input + (usage.outputTokens / 1_000_000) * pricing.output;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** Whether another endpoint may answer — 429, 5xx, network, timeout; never a 4xx. */
|
|
487
|
+
function worthFallback(err: any): boolean {
|
|
488
|
+
// The adapter reports the provider's own status under `reason.status`: a 400 from
|
|
489
|
+
// the upstream endpoint is the request's fault, and another model would fail too.
|
|
490
|
+
const providerStatus = Number(err?.reason?.status ?? 0);
|
|
491
|
+
if (providerStatus) return providerStatus === 429 || providerStatus >= 500;
|
|
492
|
+
|
|
493
|
+
const status = Number(err?.status ?? 0);
|
|
494
|
+
if (status === 429 || status === 504 || status >= 500) return true;
|
|
495
|
+
if (status) return false;
|
|
496
|
+
|
|
497
|
+
const text = String(err?.message ?? err);
|
|
498
|
+
return /network|fetch failed|timed out|timeout|ECONN|socket|ENOTFOUND|EAI_AGAIN/i.test(text);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** Fold one tool result into the run's aggregate (`result.toolStats`). */
|
|
502
|
+
function recordToolStats(stats: AgentToolStats, entry: AgentToolResultEntry): void {
|
|
503
|
+
stats.calls += 1;
|
|
504
|
+
stats.durationMs += entry.durationMs;
|
|
505
|
+
if (!entry.error) return;
|
|
506
|
+
stats.errors += 1;
|
|
507
|
+
const code = entry.errorCode ?? "AGENT_TOOL_FAILED";
|
|
508
|
+
stats.byCode[code] = (stats.byCode[code] ?? 0) + 1;
|
|
509
|
+
if (code === "AGENT_TOOL_TIMEOUT") stats.timeouts += 1;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** Fold a delegated run's stats into its parent's — the calls are the caller's too. */
|
|
513
|
+
function mergeToolStats(base: AgentToolStats, next: AgentToolStats): void {
|
|
514
|
+
base.calls += next.calls;
|
|
515
|
+
base.errors += next.errors;
|
|
516
|
+
base.timeouts += next.timeouts;
|
|
517
|
+
base.durationMs += next.durationMs;
|
|
518
|
+
for (const [code, count] of Object.entries(next.byCode)) {
|
|
519
|
+
base.byCode[code] = (base.byCode[code] ?? 0) + count;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
362
523
|
/**
|
|
363
524
|
* The tool calls the conversation has not answered yet — the ones of its last
|
|
364
525
|
* assistant turn that carries calls, minus the `tool` turns that follow. That is
|
|
@@ -380,11 +541,26 @@ function unansweredToolCalls(messages: AgentMessage[]): AgentToolCall[] {
|
|
|
380
541
|
|
|
381
542
|
/** The `tool` turn a result becomes in the conversation — one shape, one place. */
|
|
382
543
|
function toolTurnFor(result: AgentToolResultEntry): AgentMessage {
|
|
544
|
+
const media = toolResultMedia(result);
|
|
545
|
+
if (!media.length) {
|
|
546
|
+
return {
|
|
547
|
+
role: "tool",
|
|
548
|
+
toolCallId: result.id,
|
|
549
|
+
name: result.name,
|
|
550
|
+
content: serializeToolResult(result),
|
|
551
|
+
isError: !!result.error,
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
// Media travels **beside** the text: Anthropic accepts image/document blocks in a
|
|
555
|
+
// `tool_result`; a text-only protocol gets the marker `serializeToolResult` built.
|
|
556
|
+
const text = serializeToolResult(result);
|
|
557
|
+
const content: any[] = text ? [{ type: "text", text }] : [];
|
|
558
|
+
content.push(...media);
|
|
383
559
|
return {
|
|
384
560
|
role: "tool",
|
|
385
561
|
toolCallId: result.id,
|
|
386
562
|
name: result.name,
|
|
387
|
-
content
|
|
563
|
+
content,
|
|
388
564
|
isError: !!result.error,
|
|
389
565
|
};
|
|
390
566
|
}
|
|
@@ -396,10 +572,24 @@ function toolTurnFor(result: AgentToolResultEntry): AgentMessage {
|
|
|
396
572
|
* The sub-agent is built per call, bound to the **calling** `rest`: its own tools then
|
|
397
573
|
* run with the right tenant, session and transaction.
|
|
398
574
|
*/
|
|
575
|
+
/** What a delegated run must inherit from its parent. */
|
|
576
|
+
type Delegation = {
|
|
577
|
+
signal?: AbortSignal;
|
|
578
|
+
maxSteps?: number;
|
|
579
|
+
/**
|
|
580
|
+
* The parent's `emit`, set when the parent **streams**: the child then streams
|
|
581
|
+
* too, and every one of its chunks is re-emitted wrapped as `subagent`.
|
|
582
|
+
*/
|
|
583
|
+
emit?: (chunk: AgentStreamChunk) => void;
|
|
584
|
+
/** Folds the child's usage and tool stats into the parent's. */
|
|
585
|
+
onResult?: (result: { usage: AgentUsage; toolStats: AgentToolStats }) => void;
|
|
586
|
+
};
|
|
587
|
+
|
|
399
588
|
function subAgentTools(
|
|
400
589
|
declared: AgentConfig | AgentConfig[] | undefined,
|
|
401
590
|
rest: InstanceType<typeof useRest> | undefined,
|
|
402
591
|
tenant: string | undefined,
|
|
592
|
+
delegation?: Delegation,
|
|
403
593
|
): Array<[string, AgentTool]> {
|
|
404
594
|
const definitions = !declared ? [] : Array.isArray(declared) ? declared : [declared];
|
|
405
595
|
const out: Array<[string, AgentTool]> = [];
|
|
@@ -421,9 +611,57 @@ function subAgentTools(
|
|
|
421
611
|
required: ["input"],
|
|
422
612
|
additionalProperties: false,
|
|
423
613
|
},
|
|
424
|
-
execute: async ({ input, thread, resource }: any) => {
|
|
614
|
+
execute: async ({ input, thread, resource }: any, ctx) => {
|
|
615
|
+
if (!definition.description) {
|
|
616
|
+
throw ctx.error(`Agent '${id}' has no description — it cannot be delegated to`, {
|
|
617
|
+
status: 500, code: "AGENT_DELEGATION_INVALID",
|
|
618
|
+
});
|
|
619
|
+
}
|
|
425
620
|
const sub = new Agent(definition, { rest, tenant });
|
|
426
|
-
|
|
621
|
+
// The parent's lifetime is the child's: an aborted run must not
|
|
622
|
+
// leave a delegated model call running in the background
|
|
623
|
+
const inherited = {
|
|
624
|
+
thread,
|
|
625
|
+
resource,
|
|
626
|
+
...(delegation?.signal ? { signal: delegation.signal } : {}),
|
|
627
|
+
...(delegation?.maxSteps ? { maxSteps: delegation.maxSteps } : {}),
|
|
628
|
+
};
|
|
629
|
+
|
|
630
|
+
// A streaming parent gets a streaming child: the answer is still the
|
|
631
|
+
// tool result, it simply arrives as it is produced too
|
|
632
|
+
if (delegation?.emit) {
|
|
633
|
+
const run = sub.stream(input, inherited);
|
|
634
|
+
for await (const chunk of run.fullStream) {
|
|
635
|
+
delegation.emit({ type: "subagent", name: id, chunk });
|
|
636
|
+
}
|
|
637
|
+
const [text, finishReason, usage, stats] = await Promise.all([
|
|
638
|
+
run.text,
|
|
639
|
+
run.finishReason,
|
|
640
|
+
run.usage,
|
|
641
|
+
run.toolStats,
|
|
642
|
+
]);
|
|
643
|
+
delegation.onResult?.({ usage, toolStats: stats });
|
|
644
|
+
// A step is all-or-nothing: a child that paused for a decision
|
|
645
|
+
// cannot be resumed through a tool result
|
|
646
|
+
if (finishReason === "approval_required") {
|
|
647
|
+
throw ctx.error(
|
|
648
|
+
`Sub-agent '${id}' paused for approval — delegation cannot carry a decision; call it directly`,
|
|
649
|
+
{ status: 409, code: "AGENT_APPROVAL_IN_DELEGATION" },
|
|
650
|
+
);
|
|
651
|
+
}
|
|
652
|
+
return text;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
const answer = await sub.generate(input, inherited);
|
|
656
|
+
delegation?.onResult?.({ usage: answer.usage, toolStats: answer.toolStats });
|
|
657
|
+
// A step is all-or-nothing: a child that paused for a decision cannot
|
|
658
|
+
// be resumed through a tool result — say so instead of losing the call
|
|
659
|
+
if (answer.pendingApprovals?.length) {
|
|
660
|
+
throw ctx.error(
|
|
661
|
+
`Sub-agent '${id}' paused for approval (${answer.pendingApprovals.map((approval) => approval.name).join(", ")}) — delegation cannot carry a decision; call it directly`,
|
|
662
|
+
{ status: 409, code: "AGENT_APPROVAL_IN_DELEGATION" },
|
|
663
|
+
);
|
|
664
|
+
}
|
|
427
665
|
return answer.text;
|
|
428
666
|
},
|
|
429
667
|
}]);
|
|
@@ -438,6 +676,8 @@ type RunOptions = {
|
|
|
438
676
|
streaming: boolean;
|
|
439
677
|
controller: AbortController;
|
|
440
678
|
emit?: (chunk: AgentStreamChunk) => void;
|
|
679
|
+
/** The stored state a `resumeRun()` continues — its conversation and counters. */
|
|
680
|
+
resumeState?: AgentRunState;
|
|
441
681
|
};
|
|
442
682
|
|
|
443
683
|
class Agent {
|
|
@@ -505,10 +745,18 @@ class Agent {
|
|
|
505
745
|
|
|
506
746
|
// ── Provider ─────────────────────────────────────────────────────────
|
|
507
747
|
|
|
508
|
-
/** The provider as declared — the API key
|
|
748
|
+
/** The provider as declared — the API key **and sensitive headers** are not included. */
|
|
509
749
|
getProvider(): AgentProvider {
|
|
510
|
-
const { apiKey, ...rest } = this.#config.provider;
|
|
511
|
-
|
|
750
|
+
const { apiKey, headers, ...rest } = this.#config.provider;
|
|
751
|
+
const keys = redactKeys() ?? new Set(DEFAULT_REDACT_KEYS.map((key) => key.toLowerCase()));
|
|
752
|
+
const safeHeaders = headers
|
|
753
|
+
? Object.fromEntries(Object.entries(headers).filter(([name]) => !isSensitiveKey(name, keys)))
|
|
754
|
+
: undefined;
|
|
755
|
+
return {
|
|
756
|
+
...rest,
|
|
757
|
+
...(safeHeaders && Object.keys(safeHeaders).length ? { headers: safeHeaders } : {}),
|
|
758
|
+
options: { ...this.#config.provider.options },
|
|
759
|
+
};
|
|
512
760
|
}
|
|
513
761
|
|
|
514
762
|
/** Sampling and transport options as declared (`provider.options`). */
|
|
@@ -916,6 +1164,54 @@ class Agent {
|
|
|
916
1164
|
return result.object;
|
|
917
1165
|
}
|
|
918
1166
|
|
|
1167
|
+
/**
|
|
1168
|
+
* Continue a run that a crash, a restart or a provider failure interrupted —
|
|
1169
|
+
* from its **last completed step**, without paying for the conversation again.
|
|
1170
|
+
*
|
|
1171
|
+
* ```ts
|
|
1172
|
+
* const answer = await agent.resumeRun('run-42'); // 1 model call, not the whole loop
|
|
1173
|
+
* ```
|
|
1174
|
+
*
|
|
1175
|
+
* The store is the call's `checkpoint`, then the agent's. The resumed run keeps
|
|
1176
|
+
* the same `runId` and store, so its next steps are saved too and its success
|
|
1177
|
+
* clears the state. Completed steps are never replayed through the model: a
|
|
1178
|
+
* checkpoint sits on a step boundary, and the tool results of the last completed
|
|
1179
|
+
* step are already part of the stored conversation — the model is asked for the
|
|
1180
|
+
* **next** step only.
|
|
1181
|
+
*/
|
|
1182
|
+
async resumeRun<T = any>(runId: string, options: AgentCallOptions = {}): Promise<AgentGenerateResult<T>> {
|
|
1183
|
+
const store = options.checkpoint ?? this.#config.checkpoint;
|
|
1184
|
+
if (!store) {
|
|
1185
|
+
throw new AppError(
|
|
1186
|
+
`Agent '${this.getId() ?? "?"}' cannot resume run '${runId}': no \`checkpoint\` store`,
|
|
1187
|
+
{ status: 400, code: "AGENT_CHECKPOINT_REQUIRED" },
|
|
1188
|
+
);
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
const state = await store.load(runId);
|
|
1192
|
+
if (!state) {
|
|
1193
|
+
throw new AppError(`No checkpoint is stored for run '${runId}'`, {
|
|
1194
|
+
status: 404,
|
|
1195
|
+
code: "AGENT_CHECKPOINT_NOT_FOUND",
|
|
1196
|
+
});
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
// The conversation comes from the state — the input is inert, and so are the
|
|
1200
|
+
// injected turns, the attachments and a paused-run resume of the call
|
|
1201
|
+
const call: AgentCallOptions = { ...options, runId, checkpoint: store };
|
|
1202
|
+
delete call.resume;
|
|
1203
|
+
delete call.messages;
|
|
1204
|
+
delete call.files;
|
|
1205
|
+
|
|
1206
|
+
return this.#run<T>({
|
|
1207
|
+
input: "",
|
|
1208
|
+
call,
|
|
1209
|
+
streaming: false,
|
|
1210
|
+
controller: new AbortController(),
|
|
1211
|
+
resumeState: state,
|
|
1212
|
+
});
|
|
1213
|
+
}
|
|
1214
|
+
|
|
919
1215
|
/**
|
|
920
1216
|
* Run the agent and stream the answer token by token.
|
|
921
1217
|
* The promises of the returned object resolve when the **run** is over
|
|
@@ -936,12 +1232,15 @@ class Agent {
|
|
|
936
1232
|
const object = deferred<T | undefined>();
|
|
937
1233
|
const toolCalls = deferred<AgentToolCall[]>();
|
|
938
1234
|
const toolResults = deferred<AgentToolResultEntry[]>();
|
|
1235
|
+
const toolStats = deferred<AgentToolStats>();
|
|
1236
|
+
const modelUsed = deferred<string | undefined>();
|
|
1237
|
+
const budgetHit = deferred<{ limit: "tokens" | "cost" | "duration"; value: number } | undefined>();
|
|
939
1238
|
const steps = deferred<AgentStep[]>();
|
|
940
1239
|
const usage = deferred<AgentUsage>();
|
|
941
1240
|
const finishReason = deferred<string>();
|
|
942
1241
|
const messages = deferred<AgentMessage[]>();
|
|
943
1242
|
|
|
944
|
-
const deferreds = [text, reasoning, object, toolCalls, toolResults, steps, usage, finishReason, messages];
|
|
1243
|
+
const deferreds = [text, reasoning, object, toolCalls, toolResults, toolStats, modelUsed, budgetHit, steps, usage, finishReason, messages];
|
|
945
1244
|
|
|
946
1245
|
// The caller may only consume `textStream` — a rejected promise nobody
|
|
947
1246
|
// awaits must not become an unhandled rejection.
|
|
@@ -957,6 +1256,9 @@ class Agent {
|
|
|
957
1256
|
object.resolve(result.object);
|
|
958
1257
|
toolCalls.resolve(result.toolCalls);
|
|
959
1258
|
toolResults.resolve(result.toolResults);
|
|
1259
|
+
toolStats.resolve(result.toolStats);
|
|
1260
|
+
modelUsed.resolve(result.model);
|
|
1261
|
+
budgetHit.resolve(result.budget);
|
|
960
1262
|
steps.resolve(result.steps);
|
|
961
1263
|
usage.resolve(result.usage);
|
|
962
1264
|
finishReason.resolve(result.finishReason);
|
|
@@ -980,6 +1282,9 @@ class Agent {
|
|
|
980
1282
|
object: object.promise,
|
|
981
1283
|
toolCalls: toolCalls.promise,
|
|
982
1284
|
toolResults: toolResults.promise,
|
|
1285
|
+
toolStats: toolStats.promise,
|
|
1286
|
+
model: modelUsed.promise,
|
|
1287
|
+
budget: budgetHit.promise,
|
|
983
1288
|
steps: steps.promise,
|
|
984
1289
|
usage: usage.promise,
|
|
985
1290
|
finishReason: finishReason.promise,
|
|
@@ -988,6 +1293,36 @@ class Agent {
|
|
|
988
1293
|
};
|
|
989
1294
|
}
|
|
990
1295
|
|
|
1296
|
+
/**
|
|
1297
|
+
* Stream the answer into a typed object — `stream()` bounded to a schema.
|
|
1298
|
+
*
|
|
1299
|
+
* Every member of `stream()` is there (`text`, `object`, `usage`,
|
|
1300
|
+
* `fullStream`, `abort`…), plus `partialObjectStream`, which yields a
|
|
1301
|
+
* **best-effort** parse of the answer so far (`undefined` until the partial
|
|
1302
|
+
* JSON parses). The final object is still parsed and validated at the end:
|
|
1303
|
+
* `await result.object` is the one to trust.
|
|
1304
|
+
*
|
|
1305
|
+
* ```ts
|
|
1306
|
+
* const run = agent.streamObject('List 3 cities', { schema });
|
|
1307
|
+
* for await (const partial of run.partialObjectStream) {
|
|
1308
|
+
* if (partial) render(partial);
|
|
1309
|
+
* }
|
|
1310
|
+
* ```
|
|
1311
|
+
*
|
|
1312
|
+
* `partialObjectStream` consumes the run's text: choose it **or**
|
|
1313
|
+
* `textStream`, not both.
|
|
1314
|
+
*/
|
|
1315
|
+
streamObject<T = any>(input: AgentInput, options: AgentCallOptions = {}): AgentStreamObjectResult<T> {
|
|
1316
|
+
if (!options.schema && !options.structuredOutput) {
|
|
1317
|
+
throw new AppError("`streamObject` requires a `schema` (or `structuredOutput`)", {
|
|
1318
|
+
status: 500,
|
|
1319
|
+
code: "AGENT_SCHEMA_REQUIRED",
|
|
1320
|
+
});
|
|
1321
|
+
}
|
|
1322
|
+
const run = this.stream<T>(input, options);
|
|
1323
|
+
return { ...run, partialObjectStream: partialObjects<T>(run.textStream) };
|
|
1324
|
+
}
|
|
1325
|
+
|
|
991
1326
|
// ── Internals ────────────────────────────────────────────────────────
|
|
992
1327
|
|
|
993
1328
|
/** Provider merged with the call-level override — `options` merged too, not replaced. */
|
|
@@ -1001,6 +1336,22 @@ class Agent {
|
|
|
1001
1336
|
};
|
|
1002
1337
|
}
|
|
1003
1338
|
|
|
1339
|
+
/** The primary provider, then its fallbacks, each with its options merged in. */
|
|
1340
|
+
#providerChain(override?: Partial<AgentProvider>): AgentProvider[] {
|
|
1341
|
+
const primary = this.#provider(override);
|
|
1342
|
+
const fallbacks = primary.fallbacks ?? [];
|
|
1343
|
+
return [
|
|
1344
|
+
primary,
|
|
1345
|
+
...fallbacks.map((fallback) => ({
|
|
1346
|
+
...primary,
|
|
1347
|
+
...fallback,
|
|
1348
|
+
options: { ...primary.options, ...fallback.options },
|
|
1349
|
+
headers: { ...primary.headers, ...fallback.headers },
|
|
1350
|
+
fallbacks: undefined,
|
|
1351
|
+
})),
|
|
1352
|
+
];
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1004
1355
|
/**
|
|
1005
1356
|
* The tools of one run — `true` (all), `false` (none), an array of **names**
|
|
1006
1357
|
* (an allow-list) or extra tools to merge in.
|
|
@@ -1008,14 +1359,14 @@ class Agent {
|
|
|
1008
1359
|
* The declared agents and the **remote MCP servers** are resolved here, before the
|
|
1009
1360
|
* allow-list is applied, so every rule below treats them like any other tool.
|
|
1010
1361
|
*/
|
|
1011
|
-
async #collectTools(requested?: boolean | string[] | AgentTools): Promise<Map<string, AgentTool>> {
|
|
1362
|
+
async #collectTools(requested?: boolean | string[] | AgentTools, delegation?: Delegation): Promise<Map<string, AgentTool>> {
|
|
1012
1363
|
// `tools: false` — the run answers directly (and no remote is even contacted)
|
|
1013
1364
|
if (requested === false) return new Map();
|
|
1014
1365
|
|
|
1015
1366
|
const tools = new Map(this.#tools);
|
|
1016
1367
|
|
|
1017
1368
|
// The other agents this one may delegate to
|
|
1018
|
-
for (const [name, tool] of subAgentTools(this.#config.agents, this.#rest, this._tenant_)) {
|
|
1369
|
+
for (const [name, tool] of subAgentTools(this.#config.agents, this.#rest, this._tenant_, delegation)) {
|
|
1019
1370
|
if (!tools.has(name)) tools.set(name, tool);
|
|
1020
1371
|
}
|
|
1021
1372
|
|
|
@@ -1169,12 +1520,14 @@ class Agent {
|
|
|
1169
1520
|
const tool = tools.get(call.name);
|
|
1170
1521
|
if (!tool) {
|
|
1171
1522
|
entry.error = `Unknown tool '${call.name}'`;
|
|
1523
|
+
entry.errorCode = "AGENT_TOOL_UNKNOWN";
|
|
1172
1524
|
return done();
|
|
1173
1525
|
}
|
|
1174
1526
|
|
|
1175
1527
|
const { value, error } = await validateWithSchema(tool.inputSchema, call.args);
|
|
1176
1528
|
if (error) {
|
|
1177
1529
|
entry.error = `Invalid arguments for tool '${call.name}': ${error}`;
|
|
1530
|
+
entry.errorCode = "AGENT_TOOL_ARGS_INVALID";
|
|
1178
1531
|
return done();
|
|
1179
1532
|
}
|
|
1180
1533
|
|
|
@@ -1207,6 +1560,7 @@ class Agent {
|
|
|
1207
1560
|
return done();
|
|
1208
1561
|
} catch (err: any) {
|
|
1209
1562
|
entry.error = err?.message ?? String(err);
|
|
1563
|
+
entry.errorCode = err?.code ? String(err.code) : "AGENT_TOOL_FAILED";
|
|
1210
1564
|
return done();
|
|
1211
1565
|
}
|
|
1212
1566
|
}
|
|
@@ -1214,10 +1568,31 @@ class Agent {
|
|
|
1214
1568
|
async #run<T>(run: RunOptions): Promise<AgentGenerateResult<T>> {
|
|
1215
1569
|
const { input, call, streaming, controller } = run;
|
|
1216
1570
|
const emit = run.emit;
|
|
1571
|
+
// A `resumeRun()` continues a stored state: its conversation and counters are
|
|
1572
|
+
// the starting point, not the caller's input
|
|
1573
|
+
const resumeState = run.resumeState;
|
|
1574
|
+
|
|
1575
|
+
// The run's signal comes first: the delegated agents inherit it, and the
|
|
1576
|
+
// tools are collected before the first model call
|
|
1577
|
+
const signal = call.signal ? AbortSignal.any([call.signal, controller.signal]) : controller.signal;
|
|
1217
1578
|
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1579
|
+
// What delegated runs consume — folded into the parent's usage and stats at
|
|
1580
|
+
// the end. A streaming parent hands its `emit` down, so the child streams too.
|
|
1581
|
+
let delegatedUsage: AgentUsage = { ...EMPTY_USAGE };
|
|
1582
|
+
const delegatedToolStats: AgentToolStats = { calls: 0, errors: 0, byCode: {}, timeouts: 0, durationMs: 0 };
|
|
1583
|
+
const tools = await this.#collectTools(call.tools, {
|
|
1584
|
+
signal,
|
|
1585
|
+
maxSteps: call.maxSteps ?? this.#config.maxSteps,
|
|
1586
|
+
...(emit ? { emit } : {}),
|
|
1587
|
+
onResult: ({ usage, toolStats: stats }) => {
|
|
1588
|
+
delegatedUsage = addUsage(delegatedUsage, usage);
|
|
1589
|
+
mergeToolStats(delegatedToolStats, stats);
|
|
1590
|
+
},
|
|
1591
|
+
});
|
|
1592
|
+
const chain = this.#providerChain(call.provider);
|
|
1593
|
+
const provider = chain[0]!;
|
|
1594
|
+
// A resumed segment starts from the model that answered before the restart
|
|
1595
|
+
let model = resumeState?.model ?? provider.model;
|
|
1221
1596
|
|
|
1222
1597
|
const structured: AgentStructuredOutput | undefined = call.structuredOutput ?? (call.schema ? { schema: call.schema } : undefined);
|
|
1223
1598
|
const schema = structured?.schema;
|
|
@@ -1233,18 +1608,44 @@ class Agent {
|
|
|
1233
1608
|
// `…Id` spellings are still read as aliases.
|
|
1234
1609
|
const thread = call.memory?.thread ?? call.memory?.threadId ?? call.thread ?? call.threadId;
|
|
1235
1610
|
const resource = call.memory?.resource ?? call.memory?.resourceId ?? call.resource ?? call.resourceId;
|
|
1236
|
-
const signal = call.signal ? AbortSignal.any([call.signal, controller.signal]) : controller.signal;
|
|
1237
1611
|
|
|
1238
1612
|
// Per-tool deadline and tool parallelism: the call wins over the declaration
|
|
1239
1613
|
const toolTimeout = call.toolTimeoutMs ?? this.#config.toolTimeoutMs;
|
|
1240
1614
|
const parallel = call.parallelTools ?? this.#config.parallelTools ?? false;
|
|
1615
|
+
const maxParallelToolCalls = Math.max(
|
|
1616
|
+
1,
|
|
1617
|
+
Math.trunc(call.maxParallelToolCalls ?? this.#config.maxParallelToolCalls ?? 8),
|
|
1618
|
+
);
|
|
1241
1619
|
const savePartial = call.savePartial ?? this.#config.savePartial ?? false;
|
|
1242
1620
|
|
|
1621
|
+
// Durable checkpoint: the call's store wins over the declaration's, and both
|
|
1622
|
+
// the store and a run id are required — a run without identity cannot be
|
|
1623
|
+
// resumed, so it is not saved either
|
|
1624
|
+
const checkpointStore = call.checkpoint ?? this.#config.checkpoint;
|
|
1625
|
+
const runId = call.runId;
|
|
1626
|
+
const checkpointing = !!checkpointStore && !!runId;
|
|
1627
|
+
|
|
1628
|
+
// Run budget — a run that reaches it stops **cleanly**, it does not throw
|
|
1629
|
+
const maxTokens = call.maxTokens ?? this.#config.maxTokens;
|
|
1630
|
+
const maxDurationMs = call.maxDurationMs ?? this.#config.maxDurationMs;
|
|
1631
|
+
const maxCost = call.maxCost ?? this.#config.maxCost;
|
|
1632
|
+
const contextTokens = call.contextTokens ?? this.#config.contextTokens;
|
|
1633
|
+
const startedAt = Date.now();
|
|
1634
|
+
let budget: { limit: "tokens" | "cost" | "duration"; value: number } | undefined;
|
|
1635
|
+
|
|
1636
|
+
// Guardrails of this run: the declaration's, then the call's
|
|
1637
|
+
const inputGuardrails = [...(this.#config.guardrails?.input ?? []), ...(call.guardrails?.input ?? [])];
|
|
1638
|
+
const outputGuardrails = [...(this.#config.guardrails?.output ?? []), ...(call.guardrails?.output ?? [])];
|
|
1639
|
+
const toolGuardrails = [...(this.#config.guardrails?.tool ?? []), ...(call.guardrails?.tool ?? [])];
|
|
1640
|
+
|
|
1243
1641
|
// Resuming a paused run: the conversation is the caller's — the paused step is
|
|
1244
|
-
// resolved from it instead of being asked of the model again
|
|
1245
|
-
|
|
1642
|
+
// resolved from it instead of being asked of the model again. A checkpoint
|
|
1643
|
+
// resume has the same shape, with the stored conversation as the whole
|
|
1644
|
+
// history (already shaped by the processors of the interrupted run).
|
|
1645
|
+
const carried = resumeState?.messages;
|
|
1646
|
+
const resuming = !carried && call.resume ? await normalizeMessages(call.resume.messages) : undefined;
|
|
1246
1647
|
|
|
1247
|
-
const history = resuming ?? (thread && this.#config.memory
|
|
1648
|
+
const history = carried ?? resuming ?? (thread && this.#config.memory
|
|
1248
1649
|
? [...(await this.#config.memory.get(thread, this.#memoryCtx(resource)))]
|
|
1249
1650
|
: []);
|
|
1250
1651
|
|
|
@@ -1252,31 +1653,56 @@ class Agent {
|
|
|
1252
1653
|
// history and before the question, and saved with the rest of the
|
|
1253
1654
|
// conversation. A trailing assistant turn is legitimate ("continue from
|
|
1254
1655
|
// here"), so the block is not forced to end on a user turn.
|
|
1255
|
-
const injected = !resuming && call.messages ? await normalizeMessages(call.messages) : [];
|
|
1656
|
+
const injected = !carried && !resuming && call.messages ? await normalizeMessages(call.messages) : [];
|
|
1256
1657
|
|
|
1257
1658
|
// What this run **adds to the thread** — the injected turns and the input,
|
|
1258
1659
|
// with the attachments on the turn being asked. The history window and the
|
|
1259
1660
|
// `load` processors only shape the prompt: neither reaches the store.
|
|
1260
1661
|
// A resumed run adds nothing: its input **is** the conversation it was given.
|
|
1261
|
-
const additions: AgentMessage[] = resuming ? [] : [...injected, ...(await normalizeMessages(input))];
|
|
1662
|
+
const additions: AgentMessage[] = carried || resuming ? [] : [...injected, ...(await normalizeMessages(input))];
|
|
1262
1663
|
|
|
1263
1664
|
// Attachments of this run — read now, attached to the turn being asked
|
|
1264
|
-
if (!resuming && call.files) {
|
|
1665
|
+
if (!carried && !resuming && call.files) {
|
|
1265
1666
|
const parts = await resolveAttachments(call.files, { baseDir: call.filesBaseDir });
|
|
1266
1667
|
if (parts.length) attachToTurn(additions, parts);
|
|
1267
1668
|
}
|
|
1268
1669
|
|
|
1269
1670
|
// The history window: the thread is what the store keeps, the window is what
|
|
1270
|
-
// the prompt replays (a cheap bound on the prompt)
|
|
1671
|
+
// the prompt replays (a cheap bound on the prompt). A carried conversation is
|
|
1672
|
+
// continued as-is — the interrupted run already applied both bounds to it.
|
|
1271
1673
|
const window = call.lastMessages ?? this.#config.lastMessages;
|
|
1272
|
-
const replay = window && window > 0 ? trimMessages(history, window) : history;
|
|
1674
|
+
const replay = carried ?? (window && window > 0 ? trimMessages(history, window) : history);
|
|
1273
1675
|
|
|
1274
1676
|
const memoryCtx = this.#memoryCtx(resource, thread);
|
|
1275
1677
|
let messages: AgentMessage[] = [...replay, ...additions];
|
|
1276
1678
|
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1679
|
+
if (!carried) {
|
|
1680
|
+
// Memory processors, `load` side: they see exactly what the model will
|
|
1681
|
+
// see (history, injected turns, input and attachments included)
|
|
1682
|
+
messages = await applyProcessors(this.#config.processors, "load", messages, memoryCtx);
|
|
1683
|
+
|
|
1684
|
+
// The prompt budget, in tokens: the oldest turns are dropped first. It runs
|
|
1685
|
+
// after the processors (they may have shortened the thread) and before the
|
|
1686
|
+
// guardrails (which read the caller's prompt, not the window).
|
|
1687
|
+
if (contextTokens && contextTokens > 0) messages = trimToTokens(messages, contextTokens);
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
// Input guardrails — the caller's prompt, before any model call
|
|
1691
|
+
const guardrailCtx: AgentGuardrailContext = { agent: this, tenant: this._tenant_, thread, resource, signal };
|
|
1692
|
+
const lastUser = [...messages].reverse().find((message) => message.role === "user");
|
|
1693
|
+
await runGuardrails(inputGuardrails, messageText(lastUser), guardrailCtx, "prompt");
|
|
1694
|
+
|
|
1695
|
+
// Tracing — the caller's hook (the call's wins over the declaration's). It
|
|
1696
|
+
// sees plain events; turning them into spans is its job, not the run's.
|
|
1697
|
+
const traceHook = call.onTrace ?? this.#config.onTrace;
|
|
1698
|
+
trace(traceHook, {
|
|
1699
|
+
type: "run.start",
|
|
1700
|
+
at: Date.now(),
|
|
1701
|
+
...(this.#config.id ? { agentId: this.#config.id } : {}),
|
|
1702
|
+
...(call.runId ? { runId: call.runId } : {}),
|
|
1703
|
+
...(thread ? { thread } : {}),
|
|
1704
|
+
...(resource ? { resource } : {}),
|
|
1705
|
+
});
|
|
1280
1706
|
|
|
1281
1707
|
let system = call.instructions ?? await this.resolveInstructions({ thread, resource });
|
|
1282
1708
|
|
|
@@ -1303,11 +1729,87 @@ class Agent {
|
|
|
1303
1729
|
const steps: AgentStep[] = [];
|
|
1304
1730
|
const allToolCalls: AgentToolCall[] = [];
|
|
1305
1731
|
const allToolResults: AgentToolResultEntry[] = [];
|
|
1306
|
-
|
|
1732
|
+
// A resumed segment carries what the run already spent — a budget sees the
|
|
1733
|
+
// whole run, not just the part after the restart
|
|
1734
|
+
const toolStats: AgentToolStats = resumeState
|
|
1735
|
+
? { ...resumeState.toolStats, byCode: { ...resumeState.toolStats.byCode } }
|
|
1736
|
+
: { calls: 0, errors: 0, byCode: {}, timeouts: 0, durationMs: 0 };
|
|
1737
|
+
let usage: AgentUsage = resumeState ? { ...resumeState.usage } : { ...EMPTY_USAGE };
|
|
1307
1738
|
let text = "";
|
|
1308
1739
|
let reasoning = "";
|
|
1309
1740
|
let finishReason = "stop";
|
|
1310
1741
|
|
|
1742
|
+
// The durable checkpoint — written at a **step boundary** only: the assistant
|
|
1743
|
+
// turn and every tool turn it asked for are in, so the stored conversation is
|
|
1744
|
+
// always valid to replay. Best-effort: a store that fails is logged, never
|
|
1745
|
+
// fatal — the run is worth more than its checkpoint.
|
|
1746
|
+
const saveCheckpoint = async (completed: number): Promise<void> => {
|
|
1747
|
+
if (!checkpointing) return;
|
|
1748
|
+
const state: AgentRunState = {
|
|
1749
|
+
runId: runId!,
|
|
1750
|
+
...(this.#config.id ? { agentId: this.#config.id } : {}),
|
|
1751
|
+
step: completed,
|
|
1752
|
+
messages: messages.map((message) => ({ ...message })),
|
|
1753
|
+
usage: { ...usage },
|
|
1754
|
+
toolStats: { ...toolStats, byCode: { ...toolStats.byCode } },
|
|
1755
|
+
...(model ? { model } : {}),
|
|
1756
|
+
finishReason,
|
|
1757
|
+
at: new Date().toISOString(),
|
|
1758
|
+
};
|
|
1759
|
+
try {
|
|
1760
|
+
await checkpointStore!.save(runId!, state);
|
|
1761
|
+
} catch (err: any) {
|
|
1762
|
+
console.error(`The checkpoint of run '${runId}' was not saved: ${err?.message ?? err}`);
|
|
1763
|
+
}
|
|
1764
|
+
};
|
|
1765
|
+
|
|
1766
|
+
// The end of the run — traced once, on the answer or on the way out. The
|
|
1767
|
+
// delegated work is folded in first: it is part of what the caller pays for.
|
|
1768
|
+
const endRun = (reason: string, error?: unknown): void => {
|
|
1769
|
+
usage = addUsage(usage, delegatedUsage);
|
|
1770
|
+
mergeToolStats(toolStats, delegatedToolStats);
|
|
1771
|
+
trace(traceHook, {
|
|
1772
|
+
type: "run.end",
|
|
1773
|
+
at: Date.now(),
|
|
1774
|
+
finishReason: reason,
|
|
1775
|
+
durationMs: Date.now() - startedAt,
|
|
1776
|
+
usage,
|
|
1777
|
+
toolStats,
|
|
1778
|
+
...(error !== undefined
|
|
1779
|
+
? { error: String((error as any)?.message ?? error).split("\n")[0] }
|
|
1780
|
+
: {}),
|
|
1781
|
+
});
|
|
1782
|
+
};
|
|
1783
|
+
|
|
1784
|
+
// One tool call, traced — the start before it runs, the end after, whichever
|
|
1785
|
+
// path executes it (sequential, parallel, or a resumed step)
|
|
1786
|
+
const runTool = async (toolCall: AgentToolCall, step: number): Promise<AgentToolResultEntry> => {
|
|
1787
|
+
const toolStartedAt = Date.now();
|
|
1788
|
+
trace(traceHook, {
|
|
1789
|
+
type: "tool.start",
|
|
1790
|
+
at: toolStartedAt,
|
|
1791
|
+
step,
|
|
1792
|
+
tool: toolCall.name,
|
|
1793
|
+
toolCallId: toolCall.id,
|
|
1794
|
+
});
|
|
1795
|
+
let result: AgentToolResultEntry | undefined;
|
|
1796
|
+
try {
|
|
1797
|
+
result = await this.#executeTool(toolCall, tools, step, signal, toolTimeout);
|
|
1798
|
+
return result;
|
|
1799
|
+
} finally {
|
|
1800
|
+
const endedAt = Date.now();
|
|
1801
|
+
trace(traceHook, {
|
|
1802
|
+
type: "tool.end",
|
|
1803
|
+
at: endedAt,
|
|
1804
|
+
step,
|
|
1805
|
+
tool: toolCall.name,
|
|
1806
|
+
toolCallId: toolCall.id,
|
|
1807
|
+
durationMs: endedAt - toolStartedAt,
|
|
1808
|
+
...(result?.errorCode ? { errorCode: result.errorCode } : {}),
|
|
1809
|
+
});
|
|
1810
|
+
}
|
|
1811
|
+
};
|
|
1812
|
+
|
|
1311
1813
|
// What the model may use, and how — the framework picks no tool itself
|
|
1312
1814
|
const toolChoice = call.toolChoice ?? this.#config.toolChoice ?? "auto";
|
|
1313
1815
|
|
|
@@ -1337,7 +1839,10 @@ class Agent {
|
|
|
1337
1839
|
}
|
|
1338
1840
|
const result: AgentToolResultEntry = decision === false
|
|
1339
1841
|
? { id: toolCall.id, name: toolCall.name, args: toolCall.args, durationMs: 0, error: "Refused by the caller" }
|
|
1340
|
-
: await
|
|
1842
|
+
: await runTool(toolCall, 0);
|
|
1843
|
+
// Checked before it enters the conversation — a blocked result is an
|
|
1844
|
+
// error the model reads, and the run goes on
|
|
1845
|
+
if (decision !== false) await guardToolResult(result, toolGuardrails, guardrailCtx);
|
|
1341
1846
|
const turn = toolTurnFor(result);
|
|
1342
1847
|
messages.push(turn);
|
|
1343
1848
|
additions.push({ ...turn });
|
|
@@ -1347,13 +1852,63 @@ class Agent {
|
|
|
1347
1852
|
}
|
|
1348
1853
|
}
|
|
1349
1854
|
|
|
1855
|
+
// One model call, with the fallbacks: an endpoint that is rate-limited, down or
|
|
1856
|
+
// unreachable hands over to the next one — as long as nothing was streamed yet
|
|
1857
|
+
let streamed = false;
|
|
1858
|
+
const guardedEmit = emit
|
|
1859
|
+
? (chunk: AgentStreamChunk) => { streamed = true; emit(chunk); }
|
|
1860
|
+
: undefined;
|
|
1861
|
+
const chat = async (request: ModelChatRequest, step: number) => {
|
|
1862
|
+
const callStartedAt = Date.now();
|
|
1863
|
+
let lastError: any;
|
|
1864
|
+
for (const candidate of chain) {
|
|
1865
|
+
const adapter = resolveProvider(candidate);
|
|
1866
|
+
try {
|
|
1867
|
+
const turn = streaming && guardedEmit
|
|
1868
|
+
? await this.#chatStream(adapter, request, guardedEmit)
|
|
1869
|
+
: await this.#chat(adapter, request);
|
|
1870
|
+
model = candidate.model;
|
|
1871
|
+
if (candidate.pricing) {
|
|
1872
|
+
turn.usage = { ...turn.usage, cost: costOf(turn.usage, candidate.pricing) };
|
|
1873
|
+
}
|
|
1874
|
+
const endedAt = Date.now();
|
|
1875
|
+
trace(traceHook, {
|
|
1876
|
+
type: "model.end",
|
|
1877
|
+
at: endedAt,
|
|
1878
|
+
step,
|
|
1879
|
+
durationMs: endedAt - callStartedAt,
|
|
1880
|
+
// What answered, not what was tried: a fallback is the model
|
|
1881
|
+
model: candidate.model,
|
|
1882
|
+
usage: turn.usage,
|
|
1883
|
+
finishReason: turn.finishReason,
|
|
1884
|
+
});
|
|
1885
|
+
return turn;
|
|
1886
|
+
} catch (err: any) {
|
|
1887
|
+
lastError = err;
|
|
1888
|
+
// A stream that already reached the caller cannot be restarted
|
|
1889
|
+
if (signal.aborted || streamed || !worthFallback(err)) throw err;
|
|
1890
|
+
}
|
|
1891
|
+
}
|
|
1892
|
+
throw lastError;
|
|
1893
|
+
};
|
|
1894
|
+
|
|
1350
1895
|
try {
|
|
1351
|
-
// Nothing to ask the model while a decision is pending
|
|
1352
|
-
|
|
1896
|
+
// Nothing to ask the model while a decision is pending. A resumed run
|
|
1897
|
+
// starts at the **next** step: the completed ones are never replayed through
|
|
1898
|
+
// the model, they are simply already in the conversation.
|
|
1899
|
+
for (let step = resumeState?.step ?? 0; !pending.length && step < maxSteps; step++) {
|
|
1900
|
+
trace(traceHook, { type: "step.start", at: Date.now(), step });
|
|
1353
1901
|
if (signal.aborted) {
|
|
1354
1902
|
finishReason = "aborted";
|
|
1355
1903
|
break;
|
|
1356
1904
|
}
|
|
1905
|
+
// The wall-clock budget is checked between steps: a model call already
|
|
1906
|
+
// started is bounded by its own `provider.options.timeout`
|
|
1907
|
+
if (maxDurationMs !== undefined && Date.now() - startedAt >= maxDurationMs) {
|
|
1908
|
+
finishReason = "budget";
|
|
1909
|
+
budget = { limit: "duration", value: maxDurationMs };
|
|
1910
|
+
break;
|
|
1911
|
+
}
|
|
1357
1912
|
|
|
1358
1913
|
const request: ModelChatRequest = {
|
|
1359
1914
|
system,
|
|
@@ -1370,9 +1925,7 @@ class Agent {
|
|
|
1370
1925
|
: {}),
|
|
1371
1926
|
};
|
|
1372
1927
|
|
|
1373
|
-
const turn =
|
|
1374
|
-
? await this.#chatStream(adapter, request, emit)
|
|
1375
|
-
: await this.#chat(adapter, request);
|
|
1928
|
+
const turn = await chat(request, step);
|
|
1376
1929
|
|
|
1377
1930
|
usage = addUsage(usage, turn.usage);
|
|
1378
1931
|
text = turn.text;
|
|
@@ -1401,10 +1954,45 @@ class Agent {
|
|
|
1401
1954
|
additions.push({ ...assistantTurn });
|
|
1402
1955
|
allToolCalls.push(...turn.toolCalls);
|
|
1403
1956
|
|
|
1957
|
+
// The token/cost budget is checked **after** the assistant turn is in
|
|
1958
|
+
// the conversation: the paid answer is kept, and the calls it asked for
|
|
1959
|
+
// are answered with a refusal so the thread stays valid for a replay
|
|
1960
|
+
const overTokens = maxTokens !== undefined && usage.totalTokens > maxTokens;
|
|
1961
|
+
const overCost = maxCost !== undefined && (usage.cost ?? 0) > maxCost;
|
|
1962
|
+
if (overTokens || overCost) {
|
|
1963
|
+
for (const toolCall of turn.toolCalls) {
|
|
1964
|
+
const skipped: AgentToolResultEntry = {
|
|
1965
|
+
id: toolCall.id,
|
|
1966
|
+
name: toolCall.name,
|
|
1967
|
+
args: toolCall.args,
|
|
1968
|
+
durationMs: 0,
|
|
1969
|
+
error: "Skipped: the run budget was exhausted",
|
|
1970
|
+
errorCode: "AGENT_BUDGET_EXHAUSTED",
|
|
1971
|
+
};
|
|
1972
|
+
current.toolResults.push(skipped);
|
|
1973
|
+
allToolResults.push(skipped);
|
|
1974
|
+
recordToolStats(toolStats, skipped);
|
|
1975
|
+
const toolTurn = toolTurnFor(skipped);
|
|
1976
|
+
messages.push(toolTurn);
|
|
1977
|
+
additions.push({ ...toolTurn });
|
|
1978
|
+
emit?.({ type: "tool_result", toolResult: skipped });
|
|
1979
|
+
}
|
|
1980
|
+
budget = overTokens
|
|
1981
|
+
? { limit: "tokens", value: maxTokens! }
|
|
1982
|
+
: { limit: "cost", value: maxCost! };
|
|
1983
|
+
finishReason = "budget";
|
|
1984
|
+
steps.push(current);
|
|
1985
|
+
await call.onStepFinish?.(current);
|
|
1986
|
+
emit?.({ type: "step", step: current });
|
|
1987
|
+
await saveCheckpoint(step + 1);
|
|
1988
|
+
break;
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1404
1991
|
if (!turn.toolCalls.length) {
|
|
1405
1992
|
finishReason = turn.finishReason;
|
|
1406
1993
|
steps.push(current);
|
|
1407
1994
|
await call.onStepFinish?.(current);
|
|
1995
|
+
await saveCheckpoint(step + 1);
|
|
1408
1996
|
break;
|
|
1409
1997
|
}
|
|
1410
1998
|
|
|
@@ -1424,23 +2012,33 @@ class Agent {
|
|
|
1424
2012
|
}
|
|
1425
2013
|
|
|
1426
2014
|
// The tools of one step: sequential by default (two writes must not
|
|
1427
|
-
// interleave), together when the caller asks for it
|
|
1428
|
-
// attached in the model's order
|
|
2015
|
+
// interleave), together when the caller asks for it **or** when every
|
|
2016
|
+
// call is read-only. The results are attached in the model's order
|
|
2017
|
+
// either way, and at most `maxParallelToolCalls` run at once.
|
|
1429
2018
|
const entries: AgentToolResultEntry[] = [];
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
2019
|
+
const readOnlyStep = turn.toolCalls.length > 1
|
|
2020
|
+
&& turn.toolCalls.every((toolCall) => tools.get(toolCall.name)?.readOnly === true);
|
|
2021
|
+
if ((parallel || readOnlyStep) && turn.toolCalls.length > 1) {
|
|
2022
|
+
entries.push(...await mapLimited(
|
|
2023
|
+
turn.toolCalls,
|
|
2024
|
+
maxParallelToolCalls,
|
|
2025
|
+
(toolCall) => runTool(toolCall, step),
|
|
1433
2026
|
));
|
|
1434
2027
|
} else {
|
|
1435
2028
|
for (const toolCall of turn.toolCalls) {
|
|
1436
|
-
entries.push(await
|
|
2029
|
+
entries.push(await runTool(toolCall, step));
|
|
1437
2030
|
}
|
|
1438
2031
|
}
|
|
1439
2032
|
|
|
2033
|
+
// Tool guardrails: a **successful** result is checked before the model
|
|
2034
|
+
// reads it — a blocked one becomes an error and the run goes on
|
|
2035
|
+
for (const result of entries) await guardToolResult(result, toolGuardrails, guardrailCtx);
|
|
2036
|
+
|
|
1440
2037
|
turn.toolCalls.forEach((toolCall, index) => {
|
|
1441
2038
|
const result = entries[index]!;
|
|
1442
2039
|
current.toolResults.push(result);
|
|
1443
2040
|
allToolResults.push(result);
|
|
2041
|
+
recordToolStats(toolStats, result);
|
|
1444
2042
|
const toolTurn = toolTurnFor(result);
|
|
1445
2043
|
messages.push(toolTurn);
|
|
1446
2044
|
additions.push({ ...toolTurn });
|
|
@@ -1452,6 +2050,7 @@ class Agent {
|
|
|
1452
2050
|
emit?.({ type: "step", step: current });
|
|
1453
2051
|
finishReason = turn.finishReason;
|
|
1454
2052
|
if (step === maxSteps - 1) finishReason = "max_steps";
|
|
2053
|
+
await saveCheckpoint(step + 1);
|
|
1455
2054
|
}
|
|
1456
2055
|
} catch (err: any) {
|
|
1457
2056
|
if (signal.aborted) {
|
|
@@ -1462,6 +2061,7 @@ class Agent {
|
|
|
1462
2061
|
if (savePartial && thread && this.#config.memory && !call.memory?.readOnly) {
|
|
1463
2062
|
await this.#persist(thread, [...history, ...additions], memoryCtx).catch(() => {});
|
|
1464
2063
|
}
|
|
2064
|
+
endRun("error", err);
|
|
1465
2065
|
throw err;
|
|
1466
2066
|
}
|
|
1467
2067
|
}
|
|
@@ -1471,53 +2071,62 @@ class Agent {
|
|
|
1471
2071
|
|
|
1472
2072
|
// Structured output — one repair turn, then give up with a stable code.
|
|
1473
2073
|
let object: T | undefined;
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
const validated = await validateWithSchema(schema, candidate);
|
|
1480
|
-
if (validated.error) failure = validated.error;
|
|
1481
|
-
else candidate = validated.value;
|
|
1482
|
-
}
|
|
1483
|
-
|
|
1484
|
-
if (failure && text.trim()) {
|
|
1485
|
-
// One repair turn — `messages` already ends with the rejected answer.
|
|
1486
|
-
const repair = [...messages, {
|
|
1487
|
-
role: "user" as const,
|
|
1488
|
-
content: `That answer was rejected: ${failure}. Reply with a single valid JSON object matching the schema — no markdown, no commentary.`,
|
|
1489
|
-
}];
|
|
1490
|
-
const turn = await this.#chat(adapter, {
|
|
1491
|
-
system,
|
|
1492
|
-
messages: repair,
|
|
1493
|
-
temperature: 0,
|
|
1494
|
-
signal,
|
|
1495
|
-
json: true,
|
|
1496
|
-
...(jsonSchema ? { jsonSchema: { name: schemaName, schema: jsonSchema } } : {}),
|
|
1497
|
-
});
|
|
1498
|
-
|
|
1499
|
-
usage = addUsage(usage, turn.usage);
|
|
1500
|
-
text = turn.text;
|
|
1501
|
-
messages.push({ role: "assistant", content: text });
|
|
1502
|
-
additions.push({ role: "assistant", content: text });
|
|
1503
|
-
|
|
1504
|
-
const retry = extractJson(text);
|
|
1505
|
-
failure = retry.error;
|
|
2074
|
+
try {
|
|
2075
|
+
if (schema && !pending.length) {
|
|
2076
|
+
const attempt = extractJson(text);
|
|
2077
|
+
let candidate: any = attempt.value;
|
|
2078
|
+
let failure = attempt.error;
|
|
1506
2079
|
if (!failure) {
|
|
1507
|
-
const validated = await validateWithSchema(schema,
|
|
2080
|
+
const validated = await validateWithSchema(schema, candidate);
|
|
1508
2081
|
if (validated.error) failure = validated.error;
|
|
1509
2082
|
else candidate = validated.value;
|
|
1510
2083
|
}
|
|
1511
|
-
}
|
|
1512
2084
|
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
2085
|
+
if (failure && text.trim()) {
|
|
2086
|
+
// One repair turn — `messages` already ends with the rejected answer.
|
|
2087
|
+
const repair = [...messages, {
|
|
2088
|
+
role: "user" as const,
|
|
2089
|
+
content: `That answer was rejected: ${failure}. Reply with a single valid JSON object matching the schema — no markdown, no commentary.`,
|
|
2090
|
+
}];
|
|
2091
|
+
const turn = await chat({
|
|
2092
|
+
system,
|
|
2093
|
+
messages: repair,
|
|
2094
|
+
temperature: 0,
|
|
2095
|
+
signal,
|
|
2096
|
+
json: true,
|
|
2097
|
+
...(jsonSchema ? { jsonSchema: { name: schemaName, schema: jsonSchema } } : {}),
|
|
2098
|
+
}, Math.max(0, steps.length - 1));
|
|
2099
|
+
|
|
2100
|
+
usage = addUsage(usage, turn.usage);
|
|
2101
|
+
text = turn.text;
|
|
2102
|
+
messages.push({ role: "assistant", content: text });
|
|
2103
|
+
additions.push({ role: "assistant", content: text });
|
|
2104
|
+
|
|
2105
|
+
const retry = extractJson(text);
|
|
2106
|
+
failure = retry.error;
|
|
2107
|
+
if (!failure) {
|
|
2108
|
+
const validated = await validateWithSchema(schema, retry.value);
|
|
2109
|
+
if (validated.error) failure = validated.error;
|
|
2110
|
+
else candidate = validated.value;
|
|
2111
|
+
}
|
|
2112
|
+
}
|
|
2113
|
+
|
|
2114
|
+
if (failure) {
|
|
2115
|
+
throw new AppError(`Agent '${this.#config.id ?? "?"}' did not return valid structured output: ${failure}`, {
|
|
2116
|
+
status: 502,
|
|
2117
|
+
code: "AGENT_OUTPUT_INVALID",
|
|
2118
|
+
reason: text,
|
|
2119
|
+
});
|
|
2120
|
+
}
|
|
2121
|
+
object = candidate as T;
|
|
1519
2122
|
}
|
|
1520
|
-
|
|
2123
|
+
|
|
2124
|
+
// Output guardrails — the answer is checked **before** it is stored or
|
|
2125
|
+
// returned: a blocked answer is not persisted, and the caller gets the reason
|
|
2126
|
+
await runGuardrails(outputGuardrails, text, guardrailCtx, "answer");
|
|
2127
|
+
} catch (err) {
|
|
2128
|
+
endRun("error", err);
|
|
2129
|
+
throw err;
|
|
1521
2130
|
}
|
|
1522
2131
|
|
|
1523
2132
|
// A paused run stores **nothing**: the caller resumes with the conversation it
|
|
@@ -1531,12 +2140,31 @@ class Agent {
|
|
|
1531
2140
|
}
|
|
1532
2141
|
}
|
|
1533
2142
|
|
|
2143
|
+
// A run that came back with an answer has nothing to resume: its state is
|
|
2144
|
+
// cleared. A paused or aborted run keeps it — that is what the next call
|
|
2145
|
+
// continues from. Best-effort: a store that fails is logged, never fatal.
|
|
2146
|
+
if (checkpointing && !pending.length && finishReason !== "aborted" && finishReason !== "error") {
|
|
2147
|
+
try {
|
|
2148
|
+
await checkpointStore!.clear(runId!);
|
|
2149
|
+
} catch (err: any) {
|
|
2150
|
+
console.error(`The checkpoint of run '${runId}' was not cleared: ${err?.message ?? err}`);
|
|
2151
|
+
}
|
|
2152
|
+
}
|
|
2153
|
+
|
|
2154
|
+
// A delegated run's tokens and cost are the parent's: they are what the caller
|
|
2155
|
+
// pays for, and what a budget must see — `endRun` folds them in before
|
|
2156
|
+
// emitting the final event.
|
|
2157
|
+
endRun(finishReason);
|
|
2158
|
+
|
|
1534
2159
|
return {
|
|
1535
2160
|
text,
|
|
2161
|
+
model,
|
|
2162
|
+
...(budget ? { budget } : {}),
|
|
1536
2163
|
...(object !== undefined ? { object } : {}),
|
|
1537
2164
|
...(reasoning ? { reasoning } : {}),
|
|
1538
2165
|
toolCalls: allToolCalls,
|
|
1539
2166
|
toolResults: allToolResults,
|
|
2167
|
+
toolStats,
|
|
1540
2168
|
steps,
|
|
1541
2169
|
usage,
|
|
1542
2170
|
finishReason,
|