@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
@@ -0,0 +1,240 @@
1
+ /**
2
+ * Change-stream replication — the **default** mode (`replication.mode` unset).
3
+ *
4
+ * What separates it from the scan engine, and what these cases pin down:
5
+ * - nothing has to be triggered: a write reaches the destination on its own;
6
+ * - deletes come from the oplog, so a collection **without hooks** propagates them
7
+ * too (the scan engine can only see applicative tombstones, which is why an
8
+ * `_audit_` entry deleted out of band used to survive on the destination);
9
+ * - the position is a resume token, not a date cursor;
10
+ * - the stream owns a collection, so a one-shot run leaves it alone;
11
+ * - `reset()` re-arms the stream, which cold-starts again.
12
+ */
13
+ import { describe, it, expect, beforeAll, afterAll } from "bun:test";
14
+ import { MongoClient } from "mongodb";
15
+ import { useRest } from "../database/rest";
16
+ import { formatConfig, cfg } from "../server/config";
17
+ import { syncTenants } from "../database/tenant";
18
+ import { syncCollections } from "../database/collection";
19
+ import { syncVars } from "../database/vars";
20
+ import {
21
+ startReplication,
22
+ stopReplication,
23
+ replicateTenant,
24
+ getReplicationState,
25
+ resetReplication,
26
+ } from "../database/replication";
27
+ import { AUDIT_COLLECTION } from "../database/audit";
28
+
29
+ const ORDERS_T = "cs-orders";
30
+ const ORDERS_DIR = "packages/core/tests/fixtures/replication-meta";
31
+ const ORDERS_SRC = "mongodb://localhost:27017/_CS_SRC";
32
+ const ORDERS_DST = "mongodb://localhost:27017/_CS_DST";
33
+ const DEST_ID = "backup";
34
+
35
+ const VARS_T = "cs-vars";
36
+ const VARS_DIR = "packages/core/tests/fixtures/vars-tenant";
37
+ const VARS_SRC = "mongodb://localhost:27017/_CS_VARS_SRC";
38
+ const VARS_DST = "mongodb://localhost:27017/_CS_VARS_DST";
39
+
40
+ let rest: InstanceType<typeof useRest>;
41
+ let restVars: InstanceType<typeof useRest>;
42
+ let src: MongoClient;
43
+ let dest: MongoClient;
44
+ let varsDest: MongoClient;
45
+
46
+ /** Poll `check` until it holds — a change stream is asynchronous by nature. */
47
+ async function waitFor(check: () => Promise<boolean>, timeout = 8_000, step = 50): Promise<boolean> {
48
+ const deadline = Date.now() + timeout;
49
+ while (Date.now() < deadline) {
50
+ if (await check()) return true;
51
+ await Bun.sleep(step);
52
+ }
53
+ return false;
54
+ }
55
+
56
+ const ordersDest = () => dest.db().collection("orders");
57
+ const auditDest = () => dest.db().collection(AUDIT_COLLECTION);
58
+ const varsOnDest = (ns: string, key: string) => varsDest.db().collection("_vars_").findOne({ ns, key });
59
+
60
+ beforeAll(async () => {
61
+ formatConfig({
62
+ server: { port: 4000 },
63
+ tenants: [
64
+ {
65
+ id: ORDERS_T,
66
+ dir: ORDERS_DIR,
67
+ database: { uri: ORDERS_SRC },
68
+ // No `mode`: change streams are the default. No `key`, no `batchSize`,
69
+ // no `schedule` either — the stream is the whole engine.
70
+ replication: { runOnBoot: false, destinations: [{ id: DEST_ID, uri: ORDERS_DST }] },
71
+ },
72
+ {
73
+ id: VARS_T,
74
+ dir: VARS_DIR,
75
+ database: { uri: VARS_SRC },
76
+ replication: { runOnBoot: false, destinations: [{ id: "backup", uri: VARS_DST }] },
77
+ },
78
+ ],
79
+ });
80
+
81
+ await syncTenants();
82
+ await syncCollections();
83
+ await syncVars();
84
+ await startReplication();
85
+
86
+ rest = new useRest({ tenant_id: ORDERS_T });
87
+ restVars = new useRest({ tenant_id: VARS_T });
88
+ src = new MongoClient(ORDERS_SRC, { serverSelectionTimeoutMS: 3000 });
89
+ dest = new MongoClient(ORDERS_DST, { serverSelectionTimeoutMS: 3000 });
90
+ varsDest = new MongoClient(VARS_DST, { serverSelectionTimeoutMS: 3000 });
91
+ await Promise.all([src.connect(), dest.connect(), varsDest.connect()]);
92
+ });
93
+
94
+ afterAll(async () => {
95
+ await stopReplication();
96
+ for (const uri of [ORDERS_SRC, ORDERS_DST, VARS_SRC, VARS_DST]) {
97
+ try {
98
+ const client = new MongoClient(uri);
99
+ await client.connect();
100
+ await client.db().dropDatabase();
101
+ await client.close();
102
+ } catch (_) {}
103
+ }
104
+ cfg.agents = [];
105
+ cfg.agentMemories = [];
106
+ try { await src.close(); } catch (_) {}
107
+ try { await dest.close(); } catch (_) {}
108
+ try { await varsDest.close(); } catch (_) {}
109
+ });
110
+
111
+ describe("replication — changeStream mode", () => {
112
+ it("replicates a write on its own, with no run to trigger", async () => {
113
+ const marker = `cs-insert-${crypto.randomUUID()}`;
114
+ await rest.insertOne("orders", { title: marker });
115
+
116
+ // No `replicateTenant()` call anywhere: the stream is the engine
117
+ expect(await waitFor(async () => Boolean(await ordersDest().findOne({ title: marker })))).toBe(true);
118
+ });
119
+
120
+ it("replicates an update in place", async () => {
121
+ const marker = `cs-update-${crypto.randomUUID()}`;
122
+ const doc: any = await rest.insertOne("orders", { title: marker });
123
+ expect(await waitFor(async () => Boolean(await ordersDest().findOne({ title: marker })))).toBe(true);
124
+
125
+ await rest.updateOne("orders", doc._id, { $set: { title: `${marker}-paid` } });
126
+ expect(await waitFor(async () => (await ordersDest().countDocuments({ title: `${marker}-paid` })) === 1)).toBe(true);
127
+ });
128
+
129
+ it("propagates a delete without writing a tombstone", async () => {
130
+ const marker = `cs-delete-${crypto.randomUUID()}`;
131
+ const doc: any = await rest.insertOne("orders", { title: marker });
132
+ expect(await waitFor(async () => Boolean(await ordersDest().findOne({ title: marker })))).toBe(true);
133
+
134
+ await rest.deleteOne("orders", doc._id);
135
+ expect(await waitFor(async () => (await ordersDest().countDocuments({ title: marker })) === 0)).toBe(true);
136
+
137
+ // The oplog carried it: the hook + tombstone path is not even installed here
138
+ const tombstones = await src.db().collection("_replication_").countDocuments({ type: "delete", collection: "orders" });
139
+ expect(tombstones).toBe(0);
140
+ });
141
+
142
+ it("propagates a delete on a framework collection no hook can see", async () => {
143
+ const marker = `cs-audit-${crypto.randomUUID()}`;
144
+ await rest.audit.addActivities([{
145
+ internal: true,
146
+ trace: { id: crypto.randomUUID() },
147
+ meta: {},
148
+ operation: {
149
+ tenant: ORDERS_T, action: marker, collection: "orders",
150
+ status: "success", input: null, result: null, error: null, duration: 0, transaction: false,
151
+ },
152
+ ts: new Date(),
153
+ } as any]);
154
+ expect(await waitFor(async () => Boolean(await auditDest().findOne({ "operation.action": marker })))).toBe(true);
155
+
156
+ // Out of band: no applicative hook runs, no tombstone is written — the scan
157
+ // engine would leave this entry on the destination forever
158
+ await src.db().collection(AUDIT_COLLECTION).deleteOne({ "operation.action": marker } as any);
159
+
160
+ expect(await waitFor(async () => (await auditDest().countDocuments({ "operation.action": marker })) === 0)).toBe(true);
161
+ }, 20_000);
162
+
163
+ it("stores a resume token in `_replication_`, tagged with the mode", async () => {
164
+ const [state] = await getReplicationState(ORDERS_T, DEST_ID, "orders");
165
+ expect(state?.mode).toBe("changeStream");
166
+ expect(state?.resumeToken).toBeDefined();
167
+ expect(state?.status).toBe("watching");
168
+ });
169
+
170
+ it("leaves a streamed collection alone when a run is triggered", async () => {
171
+ const results = await replicateTenant(ORDERS_T);
172
+
173
+ // The stream owns it: a scan on top would let a stale read overwrite a fresh
174
+ // event, so the run reports nothing for that collection
175
+ expect(results.find((result) => result.collection === "orders")).toBeUndefined();
176
+ });
177
+
178
+ it("cold-starts again after `reset()` — the position is really cleared", async () => {
179
+ const marker = `cs-reset-${crypto.randomUUID()}`;
180
+ await rest.insertOne("orders", { title: marker });
181
+ expect(await waitFor(async () => Boolean(await ordersDest().findOne({ title: marker })))).toBe(true);
182
+
183
+ // Lose the copy, then forget the position: only a backfill can bring it back
184
+ await ordersDest().deleteMany({});
185
+ const reset = await resetReplication(ORDERS_T, { destination: DEST_ID, collection: "orders" });
186
+ expect(reset.state).toBeGreaterThanOrEqual(1);
187
+
188
+ expect(await waitFor(async () => (await ordersDest().countDocuments({ title: marker })) === 1, 20_000)).toBe(true);
189
+ }, 30_000);
190
+
191
+ it("applies the collection's document filter to the stream (`_vars_` namespaces)", async () => {
192
+ const marker = `cs-vars-${crypto.randomUUID()}`;
193
+ await restVars.vars.set("config", marker, "REPLICATED");
194
+ await restVars.vars.set("private", "secret", marker);
195
+
196
+ expect(await waitFor(async () => Boolean(await varsOnDest("config", marker)))).toBe(true);
197
+ // `private.var.ts` opts out: the filter is on the post-image, so the stream
198
+ // never even writes it
199
+ expect(await varsOnDest("private", "secret")).toBeNull();
200
+ });
201
+ });
202
+
203
+ describe("replication — changeStream shutdown", () => {
204
+ it("stops writing once replication is stopped", async () => {
205
+ await stopReplication();
206
+
207
+ const marker = `cs-after-stop-${crypto.randomUUID()}`;
208
+ await rest.insertOne("orders", { title: marker });
209
+ await Bun.sleep(1_500); // long enough for a stream that is still alive
210
+
211
+ expect(await ordersDest().countDocuments({ title: marker })).toBe(0);
212
+ });
213
+
214
+ it("does not wait forever on a collection another writer owns", async () => {
215
+ // A stream elsewhere already owns `orders`; this boot must not block on it
216
+ await rest.lock(`replication:${DEST_ID}:orders`, 60_000);
217
+
218
+ const startedAt = Date.now();
219
+ await startReplication();
220
+ const elapsed = Date.now() - startedAt;
221
+
222
+ expect(elapsed).toBeLessThan(25_000);
223
+
224
+ // …and the collections it *could* take are still watched: an entry written
225
+ // with no run reaches the destination while `orders` is skipped
226
+ const marker = `cs-partial-${crypto.randomUUID()}`;
227
+ await rest.audit.addActivities([{
228
+ internal: true,
229
+ trace: { id: crypto.randomUUID() },
230
+ meta: {},
231
+ operation: {
232
+ tenant: ORDERS_T, action: marker, collection: "orders",
233
+ status: "success", input: null, result: null, error: null, duration: 0, transaction: false,
234
+ },
235
+ ts: new Date(),
236
+ } as any]);
237
+
238
+ expect(await waitFor(async () => (await auditDest().countDocuments({ "operation.action": marker })) === 1)).toBe(true);
239
+ }, 60_000);
240
+ });
@@ -28,6 +28,8 @@ beforeAll(async () => {
28
28
  dir: "src",
29
29
  database: { uri: SRC_DB },
30
30
  replication: {
31
+ // These cases exercise the scan engine (cursor, tombstones, seeding)
32
+ mode: "scan",
31
33
  runOnBoot: false,
32
34
  schedule: { interval: "1h" },
33
35
  destinations: [{ id: DEST_ID, uri: DEST_DB }],
@@ -21,6 +21,8 @@ beforeAll(async () => {
21
21
  dir: "src",
22
22
  database: { uri: SRC_DB },
23
23
  replication: {
24
+ // These cases exercise the scan engine's `initialSync` seeding
25
+ mode: "scan",
24
26
  runOnBoot: false,
25
27
  schedule: { interval: "1h" },
26
28
  destinations: [{ id: DEST_ID, uri: DEST_DB }],
@@ -42,6 +42,8 @@ beforeAll(async () => {
42
42
  // Opt-in retention, so the destination's copy is pruned the same way
43
43
  audit: { retention: "30d" },
44
44
  replication: {
45
+ // These cases exercise the scan engine (meta collections, exclude)
46
+ mode: "scan",
45
47
  runOnBoot: false,
46
48
  schedule: { interval: "1h" },
47
49
  destinations: [{ id: "backup", uri: ALL_T_DEST }],
@@ -52,6 +54,7 @@ beforeAll(async () => {
52
54
  dir: DIR,
53
55
  database: { uri: OFF_T_DB },
54
56
  replication: {
57
+ mode: "scan",
55
58
  runOnBoot: false,
56
59
  schedule: { interval: "1h" },
57
60
  exclude: ["audit", "workflows", "locks", "replication", "vars", "memory"],
@@ -26,6 +26,8 @@ beforeAll(async () => {
26
26
  dir: "src",
27
27
  database: { uri: SRC_DB },
28
28
  replication: {
29
+ // These cases exercise the scan engine (cursor, tombstones, initial sync)
30
+ mode: "scan",
29
31
  runOnBoot: false,
30
32
  schedule: { interval: "1h" },
31
33
  destinations: [{ id: DEST_ID, uri: DEST_DB }],
@@ -21,6 +21,8 @@ beforeAll(async () => {
21
21
  dir: "packages/core/tests/fixtures/vars-tenant",
22
22
  database: { uri: SRC_DB },
23
23
  replication: {
24
+ // These cases exercise the scan engine (vars namespace filter)
25
+ mode: "scan",
24
26
  runOnBoot: false,
25
27
  schedule: { interval: "1h" },
26
28
  destinations: [{ id: "backup", uri: DEST_DB }],
package/types/agent.d.ts CHANGED
@@ -10,7 +10,14 @@ import type { Agent } from "../lib/agent";
10
10
  * `'openai'` (the default) also covers every OpenAI-compatible gateway
11
11
  * (Groq, Mistral, OpenRouter, Ollama, LM Studio, vLLM…) — only `baseUrl` changes.
12
12
  */
13
- export type AgentCompatible = "openai" | "anthropic";
13
+ export type AgentProtocol = "openai" | "anthropic";
14
+
15
+ /**
16
+ * @deprecated Renamed to `AgentProtocol` — the field was named after a property
17
+ * of the remote endpoint, when what it really selects is the shape of the
18
+ * request and response on the wire.
19
+ */
20
+ export type AgentCompatible = AgentProtocol;
14
21
 
15
22
  /**
16
23
  * Model provider — **where** the agent's reasoning comes from: the model, the
@@ -18,21 +25,30 @@ export type AgentCompatible = "openai" | "anthropic";
18
25
  *
19
26
  * ```ts
20
27
  * provider: { model: 'gpt-4o-mini', apiKey: 'sk-…' } // OpenAI
21
- * provider: { model: 'claude-sonnet-4-5', apiKey: 'sk-ant-…', compatible: 'anthropic' }
22
- * provider: { model: 'llama3.1', compatible: 'openai', baseUrl: 'http://localhost:11434/v1' }
28
+ * provider: { model: 'claude-sonnet-4-5', apiKey: 'sk-ant-…', protocol: 'anthropic' }
29
+ * provider: { model: 'llama3.1', protocol: 'openai', baseUrl: 'http://localhost:11434/v1' }
23
30
  * ```
24
31
  *
25
32
  * The key falls back to the environment when omitted:
26
33
  * `ANTEROS_AI_API_KEY`, then `OPENAI_API_KEY` / `ANTHROPIC_API_KEY` according to
27
- * `compatible`. It is therefore never required in the source.
34
+ * `protocol`. It is therefore never required in the source.
28
35
  */
29
36
  export type AgentProvider = {
30
37
  /** Model id sent to the API (e.g. `gpt-4o-mini`, `claude-sonnet-4-5`). */
31
38
  model: string;
32
39
  /** API key — falls back to the environment (see above). */
33
40
  apiKey?: string;
34
- /** Wire protocol (default `'openai'`). */
35
- compatible?: AgentCompatible;
41
+ /**
42
+ * Wire protocol of the endpoint (default `'openai'`). `'openai'` is the
43
+ * Chat Completions API — the one every OpenAI-compatible gateway
44
+ * (Groq, Mistral, Ollama…) speaks too — `'anthropic'` is the Messages API.
45
+ */
46
+ protocol?: AgentProtocol;
47
+ /**
48
+ * @deprecated Alias of `protocol`, kept so a definition written before the
49
+ * rename keeps working. `protocol` wins when both are set.
50
+ */
51
+ compatible?: AgentProtocol;
36
52
  /** API root override — default `https://api.openai.com/v1` or `https://api.anthropic.com`. */
37
53
  baseUrl?: string;
38
54
  /** Extra headers sent with every request. */
@@ -60,6 +76,15 @@ export type AgentOptions = {
60
76
  timeout?: number;
61
77
  /** Retries on network errors, 429 and 5xx (default 2). */
62
78
  retries?: number;
79
+ /**
80
+ * Mark the stable prefix of the prompt for the provider's **prompt cache**
81
+ * (default `false`). Anthropic only: a `cache_control` breakpoint is set on the
82
+ * system prompt (instructions + skills + working memory) and on the last tool,
83
+ * so the instructions are billed once instead of on every run.
84
+ *
85
+ * Ignored by an OpenAI-compatible gateway, which caches on its own.
86
+ */
87
+ cache?: boolean;
63
88
  };
64
89
 
65
90
  /** Text part of a multimodal message. */
@@ -345,6 +370,21 @@ export type AgentTool = {
345
370
  execute?: (input: any, ctx: AgentToolContext) => any | Promise<any>;
346
371
  /** MCP-compatible handler (`define.McpTool`) — adapted automatically. */
347
372
  exec?: (ctx: any) => any | Promise<any>;
373
+ /**
374
+ * Give up on this tool after N ms (default: the agent's `toolTimeoutMs`). The
375
+ * model is told it timed out and the run continues — an outbound call is the one
376
+ * thing the framework cannot bound for you.
377
+ */
378
+ timeoutMs?: number;
379
+ /**
380
+ * Ask the caller before running this tool (a payment, a delete, an email).
381
+ *
382
+ * The run **pauses** with `finishReason: 'approval_required'` and
383
+ * `result.pendingApprovals`; the caller resumes with
384
+ * `{ resume: { messages: result.messages, decisions } }`. Nothing is executed
385
+ * before a decision, and a refusal is reported to the model as a tool error.
386
+ */
387
+ approval?: boolean;
348
388
  };
349
389
 
350
390
  /** What a tool must carry to be **declared** — `define.Tool` enforces the id. */
@@ -429,6 +469,14 @@ export type AgentMemoryContext = {
429
469
  * boundary: gate `history` / `clear` with `api.access`.
430
470
  */
431
471
  resourceId?: string;
472
+ /** Memory thread of the run — what a processor keys a per-thread cache on. */
473
+ thread?: string;
474
+ /**
475
+ * The store of the agent running — so a processor can read and write the
476
+ * scratchpad state of a thread (see `agents.memory.summarize`). Exposed on the
477
+ * **run** context only.
478
+ */
479
+ memory?: AgentMemory;
432
480
  };
433
481
 
434
482
  /** A thread as listed by a memory store (`agents.memory.Mongo().list()`). */
@@ -540,11 +588,50 @@ export type RedisAgentMemoryOptions = AgentMemoryOptions & {
540
588
  /** Structured output request — the answer is parsed and validated against `schema`. */
541
589
  export type AgentStructuredOutput = {
542
590
  schema: Joi.Schema | any;
543
- /** Only used by OpenAI-compatible providers (`response_format.json_schema`). */
591
+ /**
592
+ * Name of the schema on the wire — `response_format.json_schema.name`
593
+ * (default: the agent id, sanitized).
594
+ */
544
595
  name?: string;
596
+ /** What the object holds, sent to the provider next to the schema. */
545
597
  description?: string;
546
598
  };
547
599
 
600
+ /**
601
+ * A tool call that is waiting for its caller's decision — see
602
+ * `AgentTool.approval`. Served on `result.pendingApprovals`.
603
+ */
604
+ export type AgentPendingApproval = {
605
+ toolCallId: string;
606
+ name: string;
607
+ args: any;
608
+ };
609
+
610
+ /**
611
+ * What a paused run needs to go on: **the conversation it stopped on**, and one
612
+ * decision per pending call.
613
+ *
614
+ * ```ts
615
+ * const paused = await agent.generate('Refund order A-1', { thread: 't1' });
616
+ * if (paused.finishReason === 'approval_required') {
617
+ * await agent.generate('ignored — the run resumes from `messages`', {
618
+ * thread: 't1',
619
+ * resume: { messages: paused.messages, decisions: { [paused.pendingApprovals![0]!.toolCallId]: true } },
620
+ * });
621
+ * }
622
+ * ```
623
+ *
624
+ * The paused step is **not replayed through the model**: the approved tools run and
625
+ * the loop continues, so approving costs exactly one model call. A call left without
626
+ * a decision stays pending — the run pauses again.
627
+ */
628
+ export type AgentResume = {
629
+ /** The conversation to continue — `result.messages` of the paused run. */
630
+ messages: AgentMessage[] | AgentMessageInput[];
631
+ /** One decision per pending call, by `toolCallId` — `true` runs it, `false` refuses it. */
632
+ decisions: Record<string, boolean>;
633
+ };
634
+
548
635
  export type AgentCallOptions = {
549
636
  /** Thread to read from / write to the memory (same as `memory.thread`). */
550
637
  thread?: string;
@@ -643,6 +730,31 @@ export type AgentCallOptions = {
643
730
  };
644
731
  /** Called after each model call, before its tools run. */
645
732
  onStepFinish?: (step: AgentStep) => void | Promise<void>;
733
+ /**
734
+ * Give up on a single tool after N ms (default: the agent's `toolTimeoutMs`).
735
+ * A tool that times out is reported to the model as an error, and the run goes on.
736
+ */
737
+ toolTimeoutMs?: number;
738
+ /**
739
+ * Run the tools of one step **in parallel** (default `false` — sequential).
740
+ *
741
+ * Only safe when the tools do not write to the same place: two calls of the same
742
+ * tool with the same target can interleave. Read-only tools are the point.
743
+ */
744
+ parallelTools?: boolean;
745
+ /**
746
+ * Store what the run produced even when it **fails** (default `false`).
747
+ *
748
+ * Without it, a provider error at step 3 throws and the whole exchange is lost —
749
+ * the tokens are paid and the thread never hears about it. With it, the thread
750
+ * keeps the turns up to the failure, and the next call resumes from there.
751
+ */
752
+ savePartial?: boolean;
753
+ /**
754
+ * Continue a run that paused for an approval instead of starting a new one —
755
+ * see `AgentResume`.
756
+ */
757
+ resume?: AgentResume;
646
758
  };
647
759
 
648
760
  export type AgentGenerateResult<T = any> = {
@@ -658,6 +770,12 @@ export type AgentGenerateResult<T = any> = {
658
770
  finishReason: string;
659
771
  /** The complete conversation, ready to be persisted. */
660
772
  messages: AgentMessage[];
773
+ /**
774
+ * Calls waiting for a decision — present (and `finishReason`
775
+ * `'approval_required'`) when a tool declaring `approval: true` was called.
776
+ * Resume the run with `{ resume: { messages, decisions } }`.
777
+ */
778
+ pendingApprovals?: AgentPendingApproval[];
661
779
  error?: string;
662
780
  };
663
781
 
@@ -773,9 +891,72 @@ export type AgentApi = {
773
891
  * Schema returned by the `object` action. Declared **server-side** on purpose:
774
892
  * a client never dictates the shape, and the answer is really validated.
775
893
  */
894
+ /** Schema returned by the `object` action. Declared **server-side** on purpose:
895
+ * a client never dictates the shape, and the answer is really validated.
896
+ */
776
897
  object?: AgentStructuredOutput;
777
898
  };
778
899
 
900
+ /**
901
+ * How an agent declares the documents injected into its system prompt — links to
902
+ * markdown files, nothing else:
903
+ *
904
+ * - a **string** is a path or a glob — `.md` only, resolved from the **tenant
905
+ * directory** (an absolute path is used as-is): `'./docs/*.md'`, `'./guide/ml.md'`,
906
+ * `'./guides/**\/SKILL.md'`
907
+ * - a **RegExp** is matched against every file of the tenant directory (`.md` only):
908
+ * `/^runbooks\/.*\.md$/`
909
+ */
910
+ export type AgentSkillSource = string | RegExp;
911
+
912
+ /**
913
+ * A resolved skill — one markdown file with a frontmatter header:
914
+ *
915
+ * ```md
916
+ * ---
917
+ * name: pdf-forms
918
+ * description: Fill, flatten and merge PDF forms. Use for any AcroForm work.
919
+ * license: MIT
920
+ * compatibility: Needs the `pdftk` binary.
921
+ * metadata: { owner: platform }
922
+ * allowed-tools: read_pdf write_pdf
923
+ * ---
924
+ *
925
+ * # Filling a form
926
+ * …
927
+ * ```
928
+ *
929
+ * `name` and `description` are required (the name is kebab-case, 1-64 characters,
930
+ * and must match the containing directory when the file is a `SKILL.md`);
931
+ * everything else is optional. Unknown keys are kept out of the prompt and logged,
932
+ * and a file that is not markdown is never a skill — as is one whose body is empty:
933
+ * a skill *is* the text the model reads.
934
+ *
935
+ * The **`content`** is what the model reads — it is part of the system prompt of
936
+ * every run, so it costs tokens on every call: keep skills short and factual.
937
+ */
938
+ export type AgentSkill = {
939
+ /** Kebab-case identifier, unique per agent. */
940
+ name: string;
941
+ /** What the skill does and when to use it (≤ 1024 characters). */
942
+ description: string;
943
+ /** License name or reference to a bundled license file. */
944
+ license?: string;
945
+ /** Environment requirements (≤ 500 characters). */
946
+ compatibility?: string;
947
+ /** Arbitrary key-value pairs — metadata only, never injected. */
948
+ metadata?: Record<string, any>;
949
+ /**
950
+ * Tools the skill may use. Parsed and served by `getSkills()`, **not enforced**
951
+ * by the run (a skill is always in the prompt — there is no activation to scope).
952
+ */
953
+ allowedTools?: string[];
954
+ /** The markdown body — injected as-is, frontmatter stripped. */
955
+ content: string;
956
+ /** Where it came from, relative to the tenant directory. */
957
+ source: string;
958
+ };
959
+
779
960
  /**
780
961
  * An agent definition — `{tenant.dir}/agents/**\/*.agent.ts`, declared with
781
962
  * `define.Agent({ … })`.
@@ -787,6 +968,13 @@ export type AgentConfig = {
787
968
  /** Identifier — required, unique per tenant. */
788
969
  id?: string;
789
970
  name?: string;
971
+ /**
972
+ * What this prompt **is** — a free string (`'2026-09-24'`, `'v3'`, a commit).
973
+ *
974
+ * Like a workflow's `version`, it is served by `info` and written on every audit
975
+ * entry, so an answer can be traced back to the prompt that produced it.
976
+ */
977
+ version?: string;
790
978
  /** What the agent is for — required (served by `info`, read by humans). */
791
979
  description?: string;
792
980
  instructions: AgentInstructions;
@@ -795,6 +983,30 @@ export type AgentConfig = {
795
983
  memory?: AgentMemory;
796
984
  /** Max model calls per run (default 5). */
797
985
  maxSteps?: number;
986
+ /** Default deadline for one tool call, in ms — see `AgentTool.timeoutMs`. */
987
+ toolTimeoutMs?: number;
988
+ /** Run the tools of one step in parallel by default — see `AgentCallOptions`. */
989
+ parallelTools?: boolean;
990
+ /** Store a failed run's conversation by default — see `AgentCallOptions`. */
991
+ savePartial?: boolean;
992
+ /**
993
+ * Other agents exposed to this one **as tools** — the model decides when to
994
+ * delegate: `agents: [researcher]` (an agent definition, usually the default
995
+ * export of another agent file).
996
+ *
997
+ * Each one becomes a tool named after its `id`, described by its `description`,
998
+ * taking `{ input }` and answering with the sub-agent's text. A local tool with
999
+ * the same name wins.
1000
+ */
1001
+ agents?: AgentConfig | AgentConfig[];
1002
+ /**
1003
+ * Remote **MCP servers** whose tools this agent may use: `['https://host/mcp']`
1004
+ * or `{ docs: 'https://docs/mcp' }` (the name is for the logs).
1005
+ *
1006
+ * Connected once, at first use. A remote tool keeps its name; a tool the agent
1007
+ * declares itself wins over a remote one of the same name.
1008
+ */
1009
+ mcp?: string[] | Record<string, string>;
798
1010
  /** Default tool choice for every run (default `'auto'`). */
799
1011
  toolChoice?: AgentToolChoice;
800
1012
  /** Default reasoning for every run (default off) — overridable per call. */
@@ -805,6 +1017,24 @@ export type AgentConfig = {
805
1017
  * round stores several messages, so count is a proxy, not a measure).
806
1018
  */
807
1019
  lastMessages?: number;
1020
+ /**
1021
+ * **Markdown documents injected into the system prompt** of every run —
1022
+ * resolved once at load, from `{tenant.dir}`:
1023
+ *
1024
+ * ```ts
1025
+ * define.Agent({
1026
+ * skills: ['./docs/*.md', './guide/ml.md', /^runbooks\/.*\.md$/],
1027
+ * })
1028
+ * ```
1029
+ *
1030
+ * Each file carries a frontmatter header (`name`, `description`, `license`,
1031
+ * `compatibility`, `metadata`, `allowed-tools`) and its body is what the model
1032
+ * reads. A file that is not markdown is skipped (logged); a malformed skill
1033
+ * refuses **the agent** at load — the others keep loading. See `AgentSkill`.
1034
+ */
1035
+ skills?: AgentSkillSource[];
1036
+ /** @internal — resolved from `skills` by the loader. */
1037
+ _skills_?: AgentSkill[];
808
1038
  /**
809
1039
  * The agent's durable scratchpad — see `AgentWorkingMemory`. Needs a store that
810
1040
  * supports it (the three built-in ones do) and, for the default scope, a