@anteros/core 0.0.42 → 0.0.44
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/database/replication.ts +614 -26
- package/lib/agent.ts +279 -39
- package/lib/agentMemory.ts +103 -0
- package/lib/agents.ts +25 -2
- package/lib/define.ts +1 -1
- package/lib/mcpClient.ts +128 -0
- package/lib/providers.ts +169 -66
- package/lib/skills.ts +481 -0
- package/package.json +1 -1
- package/server/agents.ts +27 -6
- package/server/api.ts +74 -1
- package/tests/agent-mcp.test.ts +116 -0
- package/tests/agent-memory.test.ts +6 -6
- package/tests/agent-provider.test.ts +288 -0
- package/tests/agent-runtime.test.ts +369 -0
- package/tests/agent-skills.test.ts +177 -0
- package/tests/agent-summarize.test.ts +124 -0
- package/tests/agent-working-memory.test.ts +5 -3
- package/tests/agent.test.ts +18 -16
- package/tests/agents-api.test.ts +1 -1
- package/tests/api-private-fields.test.ts +196 -0
- package/tests/file-collection-replication.test.ts +2 -0
- package/tests/fixtures/agent-tenant/agents/support.agent.ts +1 -1
- package/tests/fixtures/agent-tenant/agents/weather.agent.ts +1 -1
- package/tests/fixtures/agents-api/agents/assistant.agent.ts +1 -1
- package/tests/fixtures/agents-api/agents/plain.agent.ts +1 -1
- package/tests/fixtures/agents-api/agents/private.agent.ts +1 -1
- package/tests/fixtures/repl-only/app.ts +3 -0
- package/tests/fixtures/replication-meta/agents/chat.agent.ts +1 -1
- package/tests/fixtures/skills-tenant/agents/docs.agent.ts +23 -0
- package/tests/fixtures/skills-tenant/agents/incomplete.agent.ts +14 -0
- package/tests/fixtures/skills-tenant/broken/bad-name.md +6 -0
- package/tests/fixtures/skills-tenant/broken/dup-a.md +6 -0
- package/tests/fixtures/skills-tenant/broken/dup-b.md +6 -0
- package/tests/fixtures/skills-tenant/broken/empty-body.md +4 -0
- package/tests/fixtures/skills-tenant/broken/long-compat.md +7 -0
- package/tests/fixtures/skills-tenant/broken/long-description.md +6 -0
- package/tests/fixtures/skills-tenant/broken/mismatch/SKILL.md +6 -0
- package/tests/fixtures/skills-tenant/broken/no-description.md +5 -0
- package/tests/fixtures/skills-tenant/broken/no-name.md +5 -0
- package/tests/fixtures/skills-tenant/broken/plain.md +4 -0
- package/tests/fixtures/skills-tenant/broken/unknown-key.md +9 -0
- package/tests/fixtures/skills-tenant/docs/pdf-forms.md +18 -0
- package/tests/fixtures/skills-tenant/guide/ml.md +9 -0
- package/tests/fixtures/skills-tenant/guides/pdf/SKILL.md +9 -0
- package/tests/fixtures/skills-tenant/runbooks/incident.md +9 -0
- package/tests/fixtures/skills-tenant/runbooks/notes.txt +2 -0
- package/tests/replication-change-stream.test.ts +240 -0
- package/tests/replication-control.test.ts +2 -0
- package/tests/replication-initial-sync.test.ts +2 -0
- package/tests/replication-meta.test.ts +3 -0
- package/tests/replication.test.ts +2 -0
- package/tests/vars-replication.test.ts +2 -0
- package/types/agent.d.ts +237 -7
- package/types/replication.d.ts +41 -4
package/lib/agent.ts
CHANGED
|
@@ -3,6 +3,8 @@ import { toJsonSchema, validateWithSchema } from "./jsonSchema";
|
|
|
3
3
|
import { InMemoryAgentMemory, trimMessages } from "./agentMemory";
|
|
4
4
|
import { forMemory, resolveAttachments } from "./attachment";
|
|
5
5
|
import { normalizeMessages, serializeToolResult } from "./messages";
|
|
6
|
+
import { mcpToolsFor } from "./mcpClient";
|
|
7
|
+
import { skillsBlock } from "./skills";
|
|
6
8
|
import {
|
|
7
9
|
EMPTY_USAGE,
|
|
8
10
|
resolveProvider,
|
|
@@ -26,6 +28,8 @@ import type {
|
|
|
26
28
|
AgentMemoryState,
|
|
27
29
|
AgentMessage,
|
|
28
30
|
AgentOptions,
|
|
31
|
+
AgentPendingApproval,
|
|
32
|
+
AgentSkill,
|
|
29
33
|
AgentTextPart,
|
|
30
34
|
AgentThinkingBlock,
|
|
31
35
|
AgentThread,
|
|
@@ -33,9 +37,11 @@ import type {
|
|
|
33
37
|
AgentStreamChunk,
|
|
34
38
|
AgentStreamResult,
|
|
35
39
|
AgentStep,
|
|
40
|
+
AgentStructuredOutput,
|
|
36
41
|
AgentTool,
|
|
37
42
|
AgentToolCall,
|
|
38
43
|
AgentToolContext,
|
|
44
|
+
AgentToolMessage,
|
|
39
45
|
AgentToolResultEntry,
|
|
40
46
|
AgentTools,
|
|
41
47
|
AgentUsage,
|
|
@@ -70,7 +76,7 @@ import type {
|
|
|
70
76
|
* const { text } = await agents.get('weather')!.generate('Weather in Paris?')
|
|
71
77
|
* ```
|
|
72
78
|
*
|
|
73
|
-
* The provider is declared inline — `{ model, apiKey,
|
|
79
|
+
* The provider is declared inline — `{ model, apiKey, protocol }` — with no
|
|
74
80
|
* SDK: `'openai'` (the default, and every OpenAI-compatible gateway) or
|
|
75
81
|
* `'anthropic'`.
|
|
76
82
|
*/
|
|
@@ -329,6 +335,103 @@ export { InMemoryAgentMemory };
|
|
|
329
335
|
|
|
330
336
|
// ─── The agent ───────────────────────────────────────────────────────────
|
|
331
337
|
|
|
338
|
+
/**
|
|
339
|
+
* Give up on a tool after `ms` — the promise is abandoned, not cancelled: a tool
|
|
340
|
+
* that must really stop has to watch `ctx.signal` (which carries the same deadline).
|
|
341
|
+
*/
|
|
342
|
+
async function withTimeout<T>(work: Promise<T>, ms: number, name: string): Promise<T> {
|
|
343
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
344
|
+
try {
|
|
345
|
+
return await Promise.race([
|
|
346
|
+
work,
|
|
347
|
+
new Promise<never>((_resolve, reject) => {
|
|
348
|
+
timer = setTimeout(
|
|
349
|
+
() => reject(new AppError(
|
|
350
|
+
`Tool '${name}' timed out after ${ms}ms`,
|
|
351
|
+
{ status: 504, code: "AGENT_TOOL_TIMEOUT" },
|
|
352
|
+
)),
|
|
353
|
+
ms,
|
|
354
|
+
);
|
|
355
|
+
}),
|
|
356
|
+
]);
|
|
357
|
+
} finally {
|
|
358
|
+
if (timer) clearTimeout(timer);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* The tool calls the conversation has not answered yet — the ones of its last
|
|
364
|
+
* assistant turn that carries calls, minus the `tool` turns that follow. That is
|
|
365
|
+
* exactly what a paused run left behind, and what a resume has to resolve.
|
|
366
|
+
*/
|
|
367
|
+
function unansweredToolCalls(messages: AgentMessage[]): AgentToolCall[] {
|
|
368
|
+
for (let index = messages.length - 1; index >= 0; index--) {
|
|
369
|
+
const message = messages[index]!;
|
|
370
|
+
if (message.role !== "assistant" || !message.toolCalls?.length) continue;
|
|
371
|
+
const answered = new Set(
|
|
372
|
+
messages.slice(index + 1)
|
|
373
|
+
.filter((turn): turn is AgentToolMessage => turn.role === "tool" && !!turn.toolCallId)
|
|
374
|
+
.map((turn) => turn.toolCallId),
|
|
375
|
+
);
|
|
376
|
+
return message.toolCalls.filter((call) => !answered.has(call.id));
|
|
377
|
+
}
|
|
378
|
+
return [];
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** The `tool` turn a result becomes in the conversation — one shape, one place. */
|
|
382
|
+
function toolTurnFor(result: AgentToolResultEntry): AgentMessage {
|
|
383
|
+
return {
|
|
384
|
+
role: "tool",
|
|
385
|
+
toolCallId: result.id,
|
|
386
|
+
name: result.name,
|
|
387
|
+
content: serializeToolResult(result),
|
|
388
|
+
isError: !!result.error,
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* The agents another agent may call, **exposed as tools** — the model decides when
|
|
394
|
+
* to delegate, exactly like any other tool.
|
|
395
|
+
*
|
|
396
|
+
* The sub-agent is built per call, bound to the **calling** `rest`: its own tools then
|
|
397
|
+
* run with the right tenant, session and transaction.
|
|
398
|
+
*/
|
|
399
|
+
function subAgentTools(
|
|
400
|
+
declared: AgentConfig | AgentConfig[] | undefined,
|
|
401
|
+
rest: InstanceType<typeof useRest> | undefined,
|
|
402
|
+
tenant: string | undefined,
|
|
403
|
+
): Array<[string, AgentTool]> {
|
|
404
|
+
const definitions = !declared ? [] : Array.isArray(declared) ? declared : [declared];
|
|
405
|
+
const out: Array<[string, AgentTool]> = [];
|
|
406
|
+
|
|
407
|
+
for (const definition of definitions) {
|
|
408
|
+
const id = definition?.id;
|
|
409
|
+
if (!id || out.some(([name]) => name === id)) continue;
|
|
410
|
+
|
|
411
|
+
out.push([id, {
|
|
412
|
+
id,
|
|
413
|
+
description: definition.description ?? `Delegate to the '${id}' agent.`,
|
|
414
|
+
inputSchema: {
|
|
415
|
+
type: "object",
|
|
416
|
+
properties: {
|
|
417
|
+
input: { type: "string", description: "What to ask the agent." },
|
|
418
|
+
thread: { type: "string", description: "Its memory thread, when the agent has a memory." },
|
|
419
|
+
resource: { type: "string", description: "Its caller identity, when the agent has a memory." },
|
|
420
|
+
},
|
|
421
|
+
required: ["input"],
|
|
422
|
+
additionalProperties: false,
|
|
423
|
+
},
|
|
424
|
+
execute: async ({ input, thread, resource }: any) => {
|
|
425
|
+
const sub = new Agent(definition, { rest, tenant });
|
|
426
|
+
const answer = await sub.generate(input, { thread, resource });
|
|
427
|
+
return answer.text;
|
|
428
|
+
},
|
|
429
|
+
}]);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
return out;
|
|
433
|
+
}
|
|
434
|
+
|
|
332
435
|
type RunOptions = {
|
|
333
436
|
input: AgentInput;
|
|
334
437
|
call: AgentCallOptions;
|
|
@@ -489,6 +592,16 @@ class Agent {
|
|
|
489
592
|
return this.#tools.delete(name);
|
|
490
593
|
}
|
|
491
594
|
|
|
595
|
+
// ── Skills ───────────────────────────────────────────────────────────
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* The resolved skills, in prompt order (by name). Read-only: they are resolved
|
|
599
|
+
* once at load, from the files `skills` points at — a run reads no file.
|
|
600
|
+
*/
|
|
601
|
+
getSkills(): AgentSkill[] {
|
|
602
|
+
return this.#config._skills_ ?? [];
|
|
603
|
+
}
|
|
604
|
+
|
|
492
605
|
// ── Memory ───────────────────────────────────────────────────────────
|
|
493
606
|
|
|
494
607
|
getMemory(): AgentMemory | undefined {
|
|
@@ -610,12 +723,16 @@ class Agent {
|
|
|
610
723
|
}
|
|
611
724
|
|
|
612
725
|
/** What a store needs to know — the client, the tenant, the caller. */
|
|
613
|
-
#memoryCtx(resourceId?: string): AgentMemoryContext {
|
|
726
|
+
#memoryCtx(resourceId?: string, threadId?: string): AgentMemoryContext {
|
|
614
727
|
return {
|
|
615
728
|
agentId: this.#config.id,
|
|
616
729
|
tenant: this._tenant_,
|
|
617
730
|
rest: this.#rest,
|
|
618
731
|
resourceId,
|
|
732
|
+
// The store and the thread are on the **run** context only: that is what a
|
|
733
|
+
// processor needs to cache something per thread
|
|
734
|
+
...(threadId ? { thread: threadId } : {}),
|
|
735
|
+
...(this.#config.memory ? { memory: this.#config.memory } : {}),
|
|
619
736
|
};
|
|
620
737
|
}
|
|
621
738
|
|
|
@@ -745,14 +862,20 @@ class Agent {
|
|
|
745
862
|
|
|
746
863
|
/** Serializable view — never exposes the API key. */
|
|
747
864
|
toJSON(): Record<string, any> {
|
|
865
|
+
// `protocol` is canonical: the deprecated `compatible` alias is normalized
|
|
866
|
+
// away here, so the API, the SDK and the audit only ever see one field.
|
|
867
|
+
const { compatible, ...provider } = this.getProvider();
|
|
748
868
|
return {
|
|
749
869
|
id: this.#config.id,
|
|
750
870
|
name: this.getName(),
|
|
871
|
+
/** What this prompt is — the version an audit entry carries. */
|
|
872
|
+
version: this.#config.version,
|
|
751
873
|
description: this.#config.description,
|
|
752
874
|
tenant: this._tenant_,
|
|
753
|
-
provider: { ...
|
|
875
|
+
provider: { ...provider, protocol: this.#config.provider.protocol ?? compatible ?? "openai" },
|
|
754
876
|
instructions: typeof this.#config.instructions === "string" ? this.#config.instructions : "[dynamic]",
|
|
755
877
|
tools: [...this.#tools.keys()],
|
|
878
|
+
skills: this.getSkills().map((skill) => ({ name: skill.name, description: skill.description })),
|
|
756
879
|
maxSteps: this.#config.maxSteps ?? DEFAULT_MAX_STEPS,
|
|
757
880
|
toolChoice: this.#config.toolChoice ?? "auto",
|
|
758
881
|
thinking: this.#config.thinking ?? false,
|
|
@@ -881,13 +1004,28 @@ class Agent {
|
|
|
881
1004
|
/**
|
|
882
1005
|
* The tools of one run — `true` (all), `false` (none), an array of **names**
|
|
883
1006
|
* (an allow-list) or extra tools to merge in.
|
|
1007
|
+
*
|
|
1008
|
+
* The declared agents and the **remote MCP servers** are resolved here, before the
|
|
1009
|
+
* allow-list is applied, so every rule below treats them like any other tool.
|
|
884
1010
|
*/
|
|
885
|
-
#collectTools(requested?: boolean | string[] | AgentTools): Map<string, AgentTool
|
|
886
|
-
// `tools: false` — the run answers directly
|
|
1011
|
+
async #collectTools(requested?: boolean | string[] | AgentTools): Promise<Map<string, AgentTool>> {
|
|
1012
|
+
// `tools: false` — the run answers directly (and no remote is even contacted)
|
|
887
1013
|
if (requested === false) return new Map();
|
|
888
1014
|
|
|
889
1015
|
const tools = new Map(this.#tools);
|
|
890
1016
|
|
|
1017
|
+
// The other agents this one may delegate to
|
|
1018
|
+
for (const [name, tool] of subAgentTools(this.#config.agents, this.#rest, this._tenant_)) {
|
|
1019
|
+
if (!tools.has(name)) tools.set(name, tool);
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
// The remote MCP servers — connected on the first run that needs them, and
|
|
1023
|
+
// memoized per server. A tool the agent declares itself wins over a remote one.
|
|
1024
|
+
for (const tool of await mcpToolsFor(this.#config.mcp)) {
|
|
1025
|
+
const name = tool.id ?? tool.name;
|
|
1026
|
+
if (name && !tools.has(name)) tools.set(name, tool);
|
|
1027
|
+
}
|
|
1028
|
+
|
|
891
1029
|
// `tools: ['forecast', 'echo']` — only these (an unknown name is ignored:
|
|
892
1030
|
// tool availability may legitimately vary between callers)
|
|
893
1031
|
if (Array.isArray(requested) && requested.every((entry) => typeof entry === "string")) {
|
|
@@ -1019,6 +1157,7 @@ class Agent {
|
|
|
1019
1157
|
tools: Map<string, AgentTool>,
|
|
1020
1158
|
step: number,
|
|
1021
1159
|
signal: AbortSignal,
|
|
1160
|
+
timeoutMs?: number,
|
|
1022
1161
|
): Promise<AgentToolResultEntry> {
|
|
1023
1162
|
const started = performance.now();
|
|
1024
1163
|
const entry: AgentToolResultEntry = { id: call.id, name: call.name, args: call.args, durationMs: 0 };
|
|
@@ -1039,21 +1178,32 @@ class Agent {
|
|
|
1039
1178
|
return done();
|
|
1040
1179
|
}
|
|
1041
1180
|
|
|
1181
|
+
// The deadline an outbound call must respect: the run's signal **and** the
|
|
1182
|
+
// tool's own, so a cooperative tool can abort itself instead of being abandoned
|
|
1183
|
+
const timeout = tool.timeoutMs ?? timeoutMs;
|
|
1184
|
+
const toolSignal = timeout && timeout > 0
|
|
1185
|
+
? AbortSignal.any([signal, AbortSignal.timeout(timeout)])
|
|
1186
|
+
: signal;
|
|
1187
|
+
|
|
1042
1188
|
const ctx: AgentToolContext = {
|
|
1043
1189
|
rest: this.#rest,
|
|
1044
1190
|
agent: this,
|
|
1045
1191
|
tenant: this._tenant_,
|
|
1046
1192
|
toolCallId: call.id,
|
|
1047
1193
|
step,
|
|
1048
|
-
signal,
|
|
1194
|
+
signal: toolSignal,
|
|
1049
1195
|
error: fn.error,
|
|
1050
1196
|
};
|
|
1051
1197
|
|
|
1052
1198
|
try {
|
|
1053
|
-
|
|
1054
|
-
?
|
|
1199
|
+
const work: Promise<any> = tool.execute
|
|
1200
|
+
? tool.execute(value, ctx)
|
|
1055
1201
|
// MCP tools take the framework's own context shape
|
|
1056
|
-
:
|
|
1202
|
+
: tool.exec!({ ...ctx, args: value, c: (this.#rest as any)?.c });
|
|
1203
|
+
|
|
1204
|
+
entry.result = timeout && timeout > 0
|
|
1205
|
+
? await withTimeout(work, timeout, call.name)
|
|
1206
|
+
: await work;
|
|
1057
1207
|
return done();
|
|
1058
1208
|
} catch (err: any) {
|
|
1059
1209
|
entry.error = err?.message ?? String(err);
|
|
@@ -1065,13 +1215,18 @@ class Agent {
|
|
|
1065
1215
|
const { input, call, streaming, controller } = run;
|
|
1066
1216
|
const emit = run.emit;
|
|
1067
1217
|
|
|
1068
|
-
const tools = this.#collectTools(call.tools);
|
|
1218
|
+
const tools = await this.#collectTools(call.tools);
|
|
1069
1219
|
const provider = this.#provider(call.provider);
|
|
1070
1220
|
const adapter = resolveProvider(provider);
|
|
1071
1221
|
|
|
1072
|
-
const structured = call.structuredOutput ?? (call.schema ? { schema: call.schema } : undefined);
|
|
1222
|
+
const structured: AgentStructuredOutput | undefined = call.structuredOutput ?? (call.schema ? { schema: call.schema } : undefined);
|
|
1073
1223
|
const schema = structured?.schema;
|
|
1074
1224
|
const jsonSchema = schema ? await toJsonSchema(schema) : undefined;
|
|
1225
|
+
// The name a provider accepts is `[a-zA-Z0-9_-]{1,64}` — an agent id is not
|
|
1226
|
+
// always that (`support.billing` is not)
|
|
1227
|
+
const schemaName = (structured?.name ?? this.#config.id ?? "response")
|
|
1228
|
+
.replace(/[^a-zA-Z0-9_-]+/g, "_")
|
|
1229
|
+
.slice(0, 64) || "response";
|
|
1075
1230
|
|
|
1076
1231
|
// `memory: { thread, resource }` (the Mastra-style shorthand) wins over the
|
|
1077
1232
|
// flat `thread` / `resource` options — both name the same thing, and the
|
|
@@ -1080,23 +1235,33 @@ class Agent {
|
|
|
1080
1235
|
const resource = call.memory?.resource ?? call.memory?.resourceId ?? call.resource ?? call.resourceId;
|
|
1081
1236
|
const signal = call.signal ? AbortSignal.any([call.signal, controller.signal]) : controller.signal;
|
|
1082
1237
|
|
|
1083
|
-
|
|
1238
|
+
// Per-tool deadline and tool parallelism: the call wins over the declaration
|
|
1239
|
+
const toolTimeout = call.toolTimeoutMs ?? this.#config.toolTimeoutMs;
|
|
1240
|
+
const parallel = call.parallelTools ?? this.#config.parallelTools ?? false;
|
|
1241
|
+
const savePartial = call.savePartial ?? this.#config.savePartial ?? false;
|
|
1242
|
+
|
|
1243
|
+
// 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
|
+
const resuming = call.resume ? await normalizeMessages(call.resume.messages) : undefined;
|
|
1246
|
+
|
|
1247
|
+
const history = resuming ?? (thread && this.#config.memory
|
|
1084
1248
|
? [...(await this.#config.memory.get(thread, this.#memoryCtx(resource)))]
|
|
1085
|
-
: [];
|
|
1249
|
+
: []);
|
|
1086
1250
|
|
|
1087
1251
|
// What the caller wants the run to **know**: injected after the thread
|
|
1088
1252
|
// history and before the question, and saved with the rest of the
|
|
1089
1253
|
// conversation. A trailing assistant turn is legitimate ("continue from
|
|
1090
1254
|
// here"), so the block is not forced to end on a user turn.
|
|
1091
|
-
const injected = call.messages ? await normalizeMessages(call.messages) : [];
|
|
1255
|
+
const injected = !resuming && call.messages ? await normalizeMessages(call.messages) : [];
|
|
1092
1256
|
|
|
1093
1257
|
// What this run **adds to the thread** — the injected turns and the input,
|
|
1094
1258
|
// with the attachments on the turn being asked. The history window and the
|
|
1095
1259
|
// `load` processors only shape the prompt: neither reaches the store.
|
|
1096
|
-
|
|
1260
|
+
// A resumed run adds nothing: its input **is** the conversation it was given.
|
|
1261
|
+
const additions: AgentMessage[] = resuming ? [] : [...injected, ...(await normalizeMessages(input))];
|
|
1097
1262
|
|
|
1098
1263
|
// Attachments of this run — read now, attached to the turn being asked
|
|
1099
|
-
if (call.files) {
|
|
1264
|
+
if (!resuming && call.files) {
|
|
1100
1265
|
const parts = await resolveAttachments(call.files, { baseDir: call.filesBaseDir });
|
|
1101
1266
|
if (parts.length) attachToTurn(additions, parts);
|
|
1102
1267
|
}
|
|
@@ -1106,7 +1271,7 @@ class Agent {
|
|
|
1106
1271
|
const window = call.lastMessages ?? this.#config.lastMessages;
|
|
1107
1272
|
const replay = window && window > 0 ? trimMessages(history, window) : history;
|
|
1108
1273
|
|
|
1109
|
-
const memoryCtx = this.#memoryCtx(resource);
|
|
1274
|
+
const memoryCtx = this.#memoryCtx(resource, thread);
|
|
1110
1275
|
let messages: AgentMessage[] = [...replay, ...additions];
|
|
1111
1276
|
|
|
1112
1277
|
// Memory processors, `load` side: they see exactly what the model will see
|
|
@@ -1115,6 +1280,12 @@ class Agent {
|
|
|
1115
1280
|
|
|
1116
1281
|
let system = call.instructions ?? await this.resolveInstructions({ thread, resource });
|
|
1117
1282
|
|
|
1283
|
+
// The skills are part of the instructions, and they never change: injected
|
|
1284
|
+
// **before** the working memory (which a run rewrites) so the prefix of the
|
|
1285
|
+
// prompt stays stable — and cacheable — across calls
|
|
1286
|
+
const skills = this.#config._skills_;
|
|
1287
|
+
if (skills?.length) system += `\n\n${skillsBlock(skills)}`;
|
|
1288
|
+
|
|
1118
1289
|
// The scratchpad is part of the prompt, right after the instructions
|
|
1119
1290
|
const stateKey = this.#workingMemoryKey({ thread, resource });
|
|
1120
1291
|
const working = this.#config.workingMemory;
|
|
@@ -1151,8 +1322,34 @@ class Agent {
|
|
|
1151
1322
|
const specs = await this.#toolSpecs(tools);
|
|
1152
1323
|
const offeredIds = [...tools.keys()];
|
|
1153
1324
|
|
|
1325
|
+
// A paused run left tool calls unanswered: they are resolved here, from the
|
|
1326
|
+
// caller's decisions, and the loop then continues from the completed step —
|
|
1327
|
+
// the model is never asked again for the step it already produced
|
|
1328
|
+
const pending: AgentPendingApproval[] = [];
|
|
1329
|
+
if (resuming) {
|
|
1330
|
+
for (const toolCall of unansweredToolCalls(messages)) {
|
|
1331
|
+
const tool = tools.get(toolCall.name);
|
|
1332
|
+
const decision = call.resume!.decisions?.[toolCall.id];
|
|
1333
|
+
// Undecided: the run pauses again, and nothing runs
|
|
1334
|
+
if (tool?.approval && decision === undefined) {
|
|
1335
|
+
pending.push({ toolCallId: toolCall.id, name: toolCall.name, args: toolCall.args });
|
|
1336
|
+
continue;
|
|
1337
|
+
}
|
|
1338
|
+
const result: AgentToolResultEntry = decision === false
|
|
1339
|
+
? { id: toolCall.id, name: toolCall.name, args: toolCall.args, durationMs: 0, error: "Refused by the caller" }
|
|
1340
|
+
: await this.#executeTool(toolCall, tools, 0, signal, toolTimeout);
|
|
1341
|
+
const turn = toolTurnFor(result);
|
|
1342
|
+
messages.push(turn);
|
|
1343
|
+
additions.push({ ...turn });
|
|
1344
|
+
allToolCalls.push(toolCall);
|
|
1345
|
+
allToolResults.push(result);
|
|
1346
|
+
emit?.({ type: "tool_result", toolResult: result });
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1154
1350
|
try {
|
|
1155
|
-
|
|
1351
|
+
// Nothing to ask the model while a decision is pending
|
|
1352
|
+
for (let step = 0; !pending.length && step < maxSteps; step++) {
|
|
1156
1353
|
if (signal.aborted) {
|
|
1157
1354
|
finishReason = "aborted";
|
|
1158
1355
|
break;
|
|
@@ -1166,6 +1363,11 @@ class Agent {
|
|
|
1166
1363
|
thinking,
|
|
1167
1364
|
signal,
|
|
1168
1365
|
json: !!schema,
|
|
1366
|
+
// What the provider can really enforce: `json_schema` on
|
|
1367
|
+
// OpenAI-compatible gateways, a forced tool on Anthropic
|
|
1368
|
+
...(jsonSchema
|
|
1369
|
+
? { jsonSchema: { name: schemaName, ...(structured?.description ? { description: structured.description } : {}), schema: jsonSchema } }
|
|
1370
|
+
: {}),
|
|
1169
1371
|
};
|
|
1170
1372
|
|
|
1171
1373
|
const turn = streaming && emit
|
|
@@ -1206,21 +1408,44 @@ class Agent {
|
|
|
1206
1408
|
break;
|
|
1207
1409
|
}
|
|
1208
1410
|
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1411
|
+
// A tool that needs a decision stops the run **before anything runs**: a
|
|
1412
|
+
// step is all-or-nothing, because every call of an assistant turn must be
|
|
1413
|
+
// answered before the conversation can go back to the model
|
|
1414
|
+
const waiting = turn.toolCalls.filter(
|
|
1415
|
+
(toolCall) => tools.get(toolCall.name)?.approval && call.resume?.decisions?.[toolCall.id] === undefined,
|
|
1416
|
+
);
|
|
1417
|
+
if (waiting.length) {
|
|
1418
|
+
pending.push(...waiting.map((toolCall) => ({
|
|
1215
1419
|
toolCallId: toolCall.id,
|
|
1216
1420
|
name: toolCall.name,
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1421
|
+
args: toolCall.args,
|
|
1422
|
+
})));
|
|
1423
|
+
break;
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
// The tools of one step: sequential by default (two writes must not
|
|
1427
|
+
// interleave), together when the caller asks for it. The results are
|
|
1428
|
+
// attached in the model's order either way.
|
|
1429
|
+
const entries: AgentToolResultEntry[] = [];
|
|
1430
|
+
if (parallel) {
|
|
1431
|
+
entries.push(...await Promise.all(
|
|
1432
|
+
turn.toolCalls.map((toolCall) => this.#executeTool(toolCall, tools, step, signal, toolTimeout)),
|
|
1433
|
+
));
|
|
1434
|
+
} else {
|
|
1435
|
+
for (const toolCall of turn.toolCalls) {
|
|
1436
|
+
entries.push(await this.#executeTool(toolCall, tools, step, signal, toolTimeout));
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
turn.toolCalls.forEach((toolCall, index) => {
|
|
1441
|
+
const result = entries[index]!;
|
|
1442
|
+
current.toolResults.push(result);
|
|
1443
|
+
allToolResults.push(result);
|
|
1444
|
+
const toolTurn = toolTurnFor(result);
|
|
1220
1445
|
messages.push(toolTurn);
|
|
1221
1446
|
additions.push({ ...toolTurn });
|
|
1222
1447
|
emit?.({ type: "tool_result", toolResult: result });
|
|
1223
|
-
}
|
|
1448
|
+
});
|
|
1224
1449
|
|
|
1225
1450
|
steps.push(current);
|
|
1226
1451
|
await call.onStepFinish?.(current);
|
|
@@ -1232,13 +1457,21 @@ class Agent {
|
|
|
1232
1457
|
if (signal.aborted) {
|
|
1233
1458
|
finishReason = "aborted";
|
|
1234
1459
|
} else {
|
|
1460
|
+
// Keep what the run produced before it died: the tokens are paid, and a
|
|
1461
|
+
// run that leaves no trace makes the caller pay them twice
|
|
1462
|
+
if (savePartial && thread && this.#config.memory && !call.memory?.readOnly) {
|
|
1463
|
+
await this.#persist(thread, [...history, ...additions], memoryCtx).catch(() => {});
|
|
1464
|
+
}
|
|
1235
1465
|
throw err;
|
|
1236
1466
|
}
|
|
1237
1467
|
}
|
|
1238
1468
|
|
|
1469
|
+
// A run that paused for a decision has no answer to validate
|
|
1470
|
+
if (pending.length) finishReason = "approval_required";
|
|
1471
|
+
|
|
1239
1472
|
// Structured output — one repair turn, then give up with a stable code.
|
|
1240
1473
|
let object: T | undefined;
|
|
1241
|
-
if (schema) {
|
|
1474
|
+
if (schema && !pending.length) {
|
|
1242
1475
|
const attempt = extractJson(text);
|
|
1243
1476
|
let candidate: any = attempt.value;
|
|
1244
1477
|
let failure = attempt.error;
|
|
@@ -1260,6 +1493,7 @@ class Agent {
|
|
|
1260
1493
|
temperature: 0,
|
|
1261
1494
|
signal,
|
|
1262
1495
|
json: true,
|
|
1496
|
+
...(jsonSchema ? { jsonSchema: { name: schemaName, schema: jsonSchema } } : {}),
|
|
1263
1497
|
});
|
|
1264
1498
|
|
|
1265
1499
|
usage = addUsage(usage, turn.usage);
|
|
@@ -1286,17 +1520,12 @@ class Agent {
|
|
|
1286
1520
|
object = candidate as T;
|
|
1287
1521
|
}
|
|
1288
1522
|
|
|
1289
|
-
|
|
1523
|
+
// A paused run stores **nothing**: the caller resumes with the conversation it
|
|
1524
|
+
// was handed back, and an assistant turn with unanswered calls would poison the
|
|
1525
|
+
// thread for the protocol
|
|
1526
|
+
if (thread && this.#config.memory && !call.memory?.readOnly && !pending.length) {
|
|
1290
1527
|
try {
|
|
1291
|
-
|
|
1292
|
-
// throw keeps the whole conversation out of the store (a guardrail)
|
|
1293
|
-
const stored = await applyProcessors(
|
|
1294
|
-
this.#config.processors,
|
|
1295
|
-
"save",
|
|
1296
|
-
[...history, ...additions], // the whole thread — never the window
|
|
1297
|
-
memoryCtx,
|
|
1298
|
-
);
|
|
1299
|
-
await this.#config.memory.save(thread, lightenForMemory(stored), memoryCtx);
|
|
1528
|
+
await this.#persist(thread, [...history, ...additions], memoryCtx);
|
|
1300
1529
|
} catch (err: any) {
|
|
1301
1530
|
console.error(`The conversation of thread '${thread}' was not saved: ${err?.message}`);
|
|
1302
1531
|
}
|
|
@@ -1312,8 +1541,19 @@ class Agent {
|
|
|
1312
1541
|
usage,
|
|
1313
1542
|
finishReason,
|
|
1314
1543
|
messages: [...history, ...additions],
|
|
1544
|
+
...(pending.length ? { pendingApprovals: pending } : {}),
|
|
1315
1545
|
};
|
|
1316
1546
|
}
|
|
1547
|
+
|
|
1548
|
+
/**
|
|
1549
|
+
* Store the thread. The `save` processors run last — they see what the run
|
|
1550
|
+
* produced, and a throw keeps the whole conversation out of the store (that is
|
|
1551
|
+
* the documented guardrail).
|
|
1552
|
+
*/
|
|
1553
|
+
async #persist(thread: string, messages: AgentMessage[], ctx: AgentMemoryContext): Promise<void> {
|
|
1554
|
+
const stored = await applyProcessors(this.#config.processors, "save", messages, ctx);
|
|
1555
|
+
await this.#config.memory!.save(thread, lightenForMemory(stored), ctx);
|
|
1556
|
+
}
|
|
1317
1557
|
}
|
|
1318
1558
|
|
|
1319
1559
|
/**
|
package/lib/agentMemory.ts
CHANGED
|
@@ -8,6 +8,7 @@ import type {
|
|
|
8
8
|
AgentMemory,
|
|
9
9
|
AgentMemoryContext,
|
|
10
10
|
AgentMemoryOptions,
|
|
11
|
+
AgentMemoryProcessor,
|
|
11
12
|
AgentMemoryState,
|
|
12
13
|
AgentMessage,
|
|
13
14
|
AgentThread,
|
|
@@ -15,6 +16,7 @@ import type {
|
|
|
15
16
|
RedisAgentMemoryOptions,
|
|
16
17
|
RedisClientLike,
|
|
17
18
|
} from "../types/agent";
|
|
19
|
+
import type { Agent } from "./agent";
|
|
18
20
|
|
|
19
21
|
/**
|
|
20
22
|
* Conversation stores — `agents.memory`.
|
|
@@ -86,6 +88,106 @@ function cloneMessages(messages: AgentMessage[]): AgentMessage[] {
|
|
|
86
88
|
return messages.map((message) => ({ ...message }));
|
|
87
89
|
}
|
|
88
90
|
|
|
91
|
+
// ─── Compaction (the summarising processor) ──────────────────────────────
|
|
92
|
+
|
|
93
|
+
/** Options of `agents.memory.summarize()`. */
|
|
94
|
+
export type SummarizeOptions = {
|
|
95
|
+
/** The agent that **writes** the summary — a cheap model is the whole point. */
|
|
96
|
+
agent: Agent;
|
|
97
|
+
/** Turns kept verbatim at the end of the thread (default 10). */
|
|
98
|
+
keep?: number;
|
|
99
|
+
/** Start compacting above this many messages (default `keep * 3`). */
|
|
100
|
+
threshold?: number;
|
|
101
|
+
/** Build the prompt from the turns being dropped (default: a transcript). */
|
|
102
|
+
prompt?: (dropped: AgentMessage[]) => string;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/** A conversation, as text — what the summariser is asked to condense. */
|
|
106
|
+
function transcript(messages: AgentMessage[]): string {
|
|
107
|
+
return messages
|
|
108
|
+
.map((message) => {
|
|
109
|
+
const content = typeof message.content === "string"
|
|
110
|
+
? message.content
|
|
111
|
+
: Array.isArray(message.content)
|
|
112
|
+
? message.content.map((part: any) => (part?.type === "text" ? part.text : `[${part?.type}]`)).join(" ")
|
|
113
|
+
: "";
|
|
114
|
+
return `${message.role}: ${content}`;
|
|
115
|
+
})
|
|
116
|
+
.join("\n");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** How a summary re-enters the conversation — one turn, clearly labelled. */
|
|
120
|
+
function summaryTurn(summary: string): AgentMessage {
|
|
121
|
+
return { role: "user", content: `[Summary of the conversation so far]\n${summary}` };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* A `load` processor that **compacts a thread that grew too long**: the turns older
|
|
126
|
+
* than the last `keep` are replaced by a summary written by another agent.
|
|
127
|
+
*
|
|
128
|
+
* ```ts
|
|
129
|
+
* const compact = new Agent({
|
|
130
|
+
* id: 'compact', description: 'Condenses a conversation.',
|
|
131
|
+
* instructions: 'You condense conversations into dense factual notes.',
|
|
132
|
+
* provider: { model: 'gpt-4o-mini' },
|
|
133
|
+
* });
|
|
134
|
+
*
|
|
135
|
+
* define.Agent({ …, memory, processors: [agents.memory.summarize({ agent: compact, keep: 12 })] });
|
|
136
|
+
* ```
|
|
137
|
+
*
|
|
138
|
+
* Why a processor and not a store option: the framework ships no token counter and
|
|
139
|
+
* no default model — the summariser is **yours**, and so is the prompt (a tenant
|
|
140
|
+
* condenses a support thread and a legal review very differently). The summary is
|
|
141
|
+
* **cached in the store's state** (for the thread it belongs to) and only refreshed
|
|
142
|
+
* once the window has moved on by `keep` turns, so a long conversation costs one
|
|
143
|
+
* summary per `keep` turns — and the prompt prefix stays stable between refreshes,
|
|
144
|
+
* which is what a provider cache wants.
|
|
145
|
+
*
|
|
146
|
+
* A store without `getState`/`setState` simply writes no summary: the thread is
|
|
147
|
+
* compacted on every run instead of once in a while.
|
|
148
|
+
*/
|
|
149
|
+
function summarize(options: SummarizeOptions): AgentMemoryProcessor {
|
|
150
|
+
const keep = Math.max(1, Math.floor(options.keep ?? 10));
|
|
151
|
+
const threshold = Math.max(keep + 1, Math.floor(options.threshold ?? keep * 3));
|
|
152
|
+
const build = options.prompt ?? ((dropped: AgentMessage[]) => [
|
|
153
|
+
"Summarize the conversation below into a dense factual note.",
|
|
154
|
+
"Keep names, identifiers, decisions, numbers and open questions. State what was asked and what was answered.",
|
|
155
|
+
"Reply with the summary only — no preamble.",
|
|
156
|
+
"",
|
|
157
|
+
transcript(dropped),
|
|
158
|
+
].join("\n"));
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
id: "summarize",
|
|
162
|
+
async load(messages, ctx) {
|
|
163
|
+
if (messages.length <= threshold) return;
|
|
164
|
+
|
|
165
|
+
const opening = messages[0]?.role === "system" || messages[0]?.role === "developer" ? messages[0] : undefined;
|
|
166
|
+
const body = opening ? messages.slice(1) : messages;
|
|
167
|
+
const dropped = body.slice(0, Math.max(0, body.length - keep));
|
|
168
|
+
if (!dropped.length) return;
|
|
169
|
+
|
|
170
|
+
const tail = body.slice(-keep);
|
|
171
|
+
const head = opening ? [opening] : [];
|
|
172
|
+
|
|
173
|
+
// Cached in the store's state: a summary recomputed on every turn would
|
|
174
|
+
// cost a model call each time **and** move the prompt prefix
|
|
175
|
+
const key = `summary:${ctx.thread ?? ctx.resourceId ?? "default"}`;
|
|
176
|
+
const cached = (await ctx.memory?.getState?.(key, ctx))?.value as { summary: string; until: number } | undefined;
|
|
177
|
+
if (cached && dropped.length - cached.until < keep) {
|
|
178
|
+
return [...head, summaryTurn(cached.summary), ...tail];
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const written = await options.agent.generate(build(dropped));
|
|
182
|
+
const summary = (written.text ?? "").trim();
|
|
183
|
+
if (!summary) return;
|
|
184
|
+
|
|
185
|
+
await ctx.memory?.setState?.(key, { value: { summary, until: dropped.length }, updatedAt: new Date() }, ctx);
|
|
186
|
+
return [...head, summaryTurn(summary), ...tail];
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
89
191
|
// ─── In-memory ──────────────────────────────────────────────────────────
|
|
90
192
|
|
|
91
193
|
/**
|
|
@@ -364,6 +466,7 @@ export class MongoAgentMemory implements AgentMemory {
|
|
|
364
466
|
|
|
365
467
|
export {
|
|
366
468
|
DEFAULT_MAX_MESSAGES,
|
|
469
|
+
summarize,
|
|
367
470
|
threadKey,
|
|
368
471
|
threadTitle,
|
|
369
472
|
trimMessages,
|