@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
|
@@ -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
|
|
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-…',
|
|
22
|
-
* provider: { model: 'llama3.1',
|
|
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
|
-
* `
|
|
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
|
-
/**
|
|
35
|
-
|
|
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
|
-
/**
|
|
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
|