@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.
Files changed (55) hide show
  1. package/database/replication.ts +614 -26
  2. package/lib/agent.ts +279 -39
  3. package/lib/agentMemory.ts +103 -0
  4. package/lib/agents.ts +25 -2
  5. package/lib/define.ts +1 -1
  6. package/lib/mcpClient.ts +128 -0
  7. package/lib/providers.ts +169 -66
  8. package/lib/skills.ts +481 -0
  9. package/package.json +1 -1
  10. package/server/agents.ts +27 -6
  11. package/server/api.ts +74 -1
  12. package/tests/agent-mcp.test.ts +116 -0
  13. package/tests/agent-memory.test.ts +6 -6
  14. package/tests/agent-provider.test.ts +288 -0
  15. package/tests/agent-runtime.test.ts +369 -0
  16. package/tests/agent-skills.test.ts +177 -0
  17. package/tests/agent-summarize.test.ts +124 -0
  18. package/tests/agent-working-memory.test.ts +5 -3
  19. package/tests/agent.test.ts +18 -16
  20. package/tests/agents-api.test.ts +1 -1
  21. package/tests/api-private-fields.test.ts +196 -0
  22. package/tests/file-collection-replication.test.ts +2 -0
  23. package/tests/fixtures/agent-tenant/agents/support.agent.ts +1 -1
  24. package/tests/fixtures/agent-tenant/agents/weather.agent.ts +1 -1
  25. package/tests/fixtures/agents-api/agents/assistant.agent.ts +1 -1
  26. package/tests/fixtures/agents-api/agents/plain.agent.ts +1 -1
  27. package/tests/fixtures/agents-api/agents/private.agent.ts +1 -1
  28. package/tests/fixtures/repl-only/app.ts +3 -0
  29. package/tests/fixtures/replication-meta/agents/chat.agent.ts +1 -1
  30. package/tests/fixtures/skills-tenant/agents/docs.agent.ts +23 -0
  31. package/tests/fixtures/skills-tenant/agents/incomplete.agent.ts +14 -0
  32. package/tests/fixtures/skills-tenant/broken/bad-name.md +6 -0
  33. package/tests/fixtures/skills-tenant/broken/dup-a.md +6 -0
  34. package/tests/fixtures/skills-tenant/broken/dup-b.md +6 -0
  35. package/tests/fixtures/skills-tenant/broken/empty-body.md +4 -0
  36. package/tests/fixtures/skills-tenant/broken/long-compat.md +7 -0
  37. package/tests/fixtures/skills-tenant/broken/long-description.md +6 -0
  38. package/tests/fixtures/skills-tenant/broken/mismatch/SKILL.md +6 -0
  39. package/tests/fixtures/skills-tenant/broken/no-description.md +5 -0
  40. package/tests/fixtures/skills-tenant/broken/no-name.md +5 -0
  41. package/tests/fixtures/skills-tenant/broken/plain.md +4 -0
  42. package/tests/fixtures/skills-tenant/broken/unknown-key.md +9 -0
  43. package/tests/fixtures/skills-tenant/docs/pdf-forms.md +18 -0
  44. package/tests/fixtures/skills-tenant/guide/ml.md +9 -0
  45. package/tests/fixtures/skills-tenant/guides/pdf/SKILL.md +9 -0
  46. package/tests/fixtures/skills-tenant/runbooks/incident.md +9 -0
  47. package/tests/fixtures/skills-tenant/runbooks/notes.txt +2 -0
  48. package/tests/replication-change-stream.test.ts +240 -0
  49. package/tests/replication-control.test.ts +2 -0
  50. package/tests/replication-initial-sync.test.ts +2 -0
  51. package/tests/replication-meta.test.ts +3 -0
  52. package/tests/replication.test.ts +2 -0
  53. package/tests/vars-replication.test.ts +2 -0
  54. package/types/agent.d.ts +237 -7
  55. 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, compatible }` — with no
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: { ...this.getProvider(), compatible: this.#config.provider.compatible ?? "openai" },
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
- entry.result = tool.execute
1054
- ? await tool.execute(value, ctx)
1199
+ const work: Promise<any> = tool.execute
1200
+ ? tool.execute(value, ctx)
1055
1201
  // MCP tools take the framework's own context shape
1056
- : await tool.exec!({ ...ctx, args: value, c: (this.#rest as any)?.c });
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
- const history = thread && this.#config.memory
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
- const additions: AgentMessage[] = [...injected, ...(await normalizeMessages(input))];
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
- for (let step = 0; step < maxSteps; step++) {
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
- for (const toolCall of turn.toolCalls) {
1210
- const result = await this.#executeTool(toolCall, tools, step, signal);
1211
- current.toolResults.push(result);
1212
- allToolResults.push(result);
1213
- const toolTurn: AgentMessage = {
1214
- role: "tool",
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
- content: serializeToolResult(result),
1218
- isError: !!result.error,
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
- if (thread && this.#config.memory && !call.memory?.readOnly) {
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
- // `save` processors run last: they see what the run produced, and a
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
  /**
@@ -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,