@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
@@ -36,7 +36,7 @@ function makeAgent(overrides: Record<string, any> = {}, provider: Record<string,
36
36
  provider: {
37
37
  model: "test-model",
38
38
  apiKey: "test-key",
39
- compatible: "openai",
39
+ protocol: "openai",
40
40
  options: { retries: 0 },
41
41
  ...provider,
42
42
  },
@@ -385,7 +385,7 @@ describe("working memory — Anthropic", () => {
385
385
  const provider = track(fakeProvider(() => anthropicText("ok")));
386
386
  const agent = makeAgent(
387
387
  { memory: new InMemoryAgentMemory(), workingMemory: { template: "# Memo\n- x:" } },
388
- { compatible: "anthropic", baseUrl: provider.url },
388
+ { protocol: "anthropic", baseUrl: provider.url },
389
389
  );
390
390
 
391
391
  await agent.generate("hi", { thread: "t1", resource: "u1" });
@@ -397,6 +397,8 @@ describe("working memory — Anthropic", () => {
397
397
 
398
398
  describe("working memory — the registry exposes nothing new by accident", () => {
399
399
  it("keeps `agents.memory` as the only store factory", () => {
400
- expect(Object.keys(agents.memory).sort()).toEqual(["InMemory", "Mongo", "Redis"]);
400
+ // `summarize` is the one deliberate addition: a **processor** factory (it needs
401
+ // your model and your prompt), not a store
402
+ expect(Object.keys(agents.memory).sort()).toEqual(["InMemory", "Mongo", "Redis", "summarize"]);
401
403
  });
402
404
  });
@@ -63,7 +63,7 @@ function makeAgent(
63
63
  provider: {
64
64
  model: "test-model",
65
65
  apiKey: "test-key",
66
- compatible: "openai",
66
+ protocol: "openai",
67
67
  // Deterministic by default: a test must not wait on the retry backoff
68
68
  options: { retries: 0, ...options },
69
69
  ...provider,
@@ -81,7 +81,7 @@ describe("agent … files", () => {
81
81
  provider: {
82
82
  model: "test-model",
83
83
  apiKey: "test-key",
84
- compatible: "openai",
84
+ protocol: "openai",
85
85
  options: { retries: 0 },
86
86
  ...providerOverrides,
87
87
  },
@@ -228,7 +228,7 @@ describe("agent … tools & thinking options", () => {
228
228
  provider: {
229
229
  model: "test-model",
230
230
  apiKey: "test-key",
231
- compatible: "openai",
231
+ protocol: "openai",
232
232
  options: { retries: 0 },
233
233
  ...providerOverrides,
234
234
  },
@@ -307,7 +307,7 @@ describe("agent … tools & thinking options", () => {
307
307
  expect(request(provider, 1).body.tool_choice).toBe("auto");
308
308
 
309
309
  const anthropic = track(fakeProvider(() => anthropicText("ok")));
310
- const forced = toolAgent({ compatible: "anthropic", baseUrl: anthropic.url }, { toolChoice: { name: "forecast" } });
310
+ const forced = toolAgent({ protocol: "anthropic", baseUrl: anthropic.url }, { toolChoice: { name: "forecast" } });
311
311
  await forced.generate("hi");
312
312
  expect(request(anthropic, 0).body.tool_choice).toEqual({ type: "tool", name: "forecast" });
313
313
  await forced.generate("hi", { toolChoice: "auto" });
@@ -347,7 +347,7 @@ describe("agent … tools & thinking options", () => {
347
347
  it("maps `thinking` to a real budget on Anthropic, and raises max_tokens above it", async () => {
348
348
  const provider = track(fakeProvider(() => anthropicText("ok")));
349
349
  const agent = toolAgent(
350
- { compatible: "anthropic", baseUrl: provider.url, options: { retries: 0, maxTokens: 2000, temperature: 0.5 } },
350
+ { protocol: "anthropic", baseUrl: provider.url, options: { retries: 0, maxTokens: 2000, temperature: 0.5 } },
351
351
  );
352
352
 
353
353
  await agent.generate("hi", { thinking: true });
@@ -377,7 +377,7 @@ describe("agent … tools & thinking options", () => {
377
377
  stop_reason: "tool_use",
378
378
  usage: A_USAGE,
379
379
  })));
380
- const agent = toolAgent({ compatible: "anthropic", baseUrl: provider.url });
380
+ const agent = toolAgent({ protocol: "anthropic", baseUrl: provider.url });
381
381
 
382
382
  await agent.generate("Weather?", { thinking: 'medium' });
383
383
 
@@ -409,7 +409,7 @@ describe("agent … tools & thinking options", () => {
409
409
  stop_reason: "end_turn",
410
410
  usage: A_USAGE,
411
411
  })));
412
- const agent = toolAgent({ compatible: "anthropic", baseUrl: provider.url });
412
+ const agent = toolAgent({ protocol: "anthropic", baseUrl: provider.url });
413
413
 
414
414
  const result = await agent.generate("go", { thinking: true });
415
415
 
@@ -430,7 +430,7 @@ describe("agent … tools & thinking options", () => {
430
430
  JSON.stringify({ type: "content_block_delta", index: 1, delta: { type: "text_delta", text: "Answer" } }),
431
431
  JSON.stringify({ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 3 } }),
432
432
  ])));
433
- const agent = toolAgent({ compatible: "anthropic", baseUrl: provider.url });
433
+ const agent = toolAgent({ protocol: "anthropic", baseUrl: provider.url });
434
434
 
435
435
  const stream = agent.stream("hi", { thinking: true });
436
436
  const kinds: string[] = [];
@@ -621,7 +621,7 @@ describe("Agent", () => {
621
621
  const agent = makeAgent({}, {}, { temperature: 0.2, maxTokens: 1000 });
622
622
  const declared = {
623
623
  model: "test-model",
624
- compatible: "openai" as const,
624
+ protocol: "openai" as const,
625
625
  options: { retries: 0, temperature: 0.2, maxTokens: 1000 },
626
626
  };
627
627
 
@@ -831,7 +831,7 @@ describe("agent.generate", () => {
831
831
  delete Bun.env.ANTHROPIC_API_KEY;
832
832
 
833
833
  try {
834
- const agent = makeAgent({}, { compatible: "anthropic", apiKey: undefined, baseUrl: provider.url });
834
+ const agent = makeAgent({}, { protocol: "anthropic", apiKey: undefined, baseUrl: provider.url });
835
835
  await expect(agent.generate("hi")).rejects.toMatchObject({ code: "AGENT_PROVIDER_NO_API_KEY" });
836
836
 
837
837
  Bun.env.ANTHROPIC_API_KEY = "from-env";
@@ -849,10 +849,10 @@ describe("agent.generate", () => {
849
849
 
850
850
  // ─── Anthropic ───────────────────────────────────────────────────────────
851
851
 
852
- describe("agent with an Anthropic-compatible provider", () => {
852
+ describe("agent speaking the Anthropic protocol", () => {
853
853
  it("puts the system prompt out of band and returns the text", async () => {
854
854
  const provider = track(fakeProvider(() => anthropicText("Bonjour")));
855
- const agent = makeAgent({}, { compatible: "anthropic", baseUrl: provider.url });
855
+ const agent = makeAgent({}, { protocol: "anthropic", baseUrl: provider.url });
856
856
 
857
857
  const result = await agent.generate("salut");
858
858
 
@@ -869,7 +869,7 @@ describe("agent with an Anthropic-compatible provider", () => {
869
869
  const provider = track(fakeProvider((_body, _req, hits) =>
870
870
  hits === 1 ? anthropicTool("forecast", { city: "Lyon" }) : anthropicText("Lyon: 21°C")));
871
871
 
872
- const agent = makeAgent({ tools: { forecast: forecastTool() } }, { compatible: "anthropic", baseUrl: provider.url });
872
+ const agent = makeAgent({ tools: { forecast: forecastTool() } }, { protocol: "anthropic", baseUrl: provider.url });
873
873
  const result = await agent.generate("Weather in Lyon?");
874
874
 
875
875
  expect(result.text).toBe("Lyon: 21°C");
@@ -888,7 +888,7 @@ describe("agent with an Anthropic-compatible provider", () => {
888
888
 
889
889
  it("streams text deltas and merges the partial usage", async () => {
890
890
  const provider = track(fakeProvider(() => anthropicStream(["Bon", "jour"])));
891
- const agent = makeAgent({}, { compatible: "anthropic", baseUrl: provider.url });
891
+ const agent = makeAgent({}, { protocol: "anthropic", baseUrl: provider.url });
892
892
 
893
893
  const stream = agent.stream("salut");
894
894
  let text = "";
@@ -1116,9 +1116,11 @@ describe("agent structured output", () => {
1116
1116
  const object = await agent.generateObject("Weather?", { schema });
1117
1117
 
1118
1118
  expect(object).toEqual({ city: "Paris", celsius: 21 });
1119
- // The schema was injected in the prompt and asked for a JSON object
1119
+ // The schema travels twice: in the prompt, and in the field the gateway really
1120
+ // validates against (`response_format.json_schema`, see `agent-provider.test.ts`)
1120
1121
  expect(request(provider, 0).body.messages[0].content).toContain("JSON Schema");
1121
- expect(request(provider, 0).body.response_format).toEqual({ type: "json_object" });
1122
+ expect(request(provider, 0).body.response_format.type).toBe("json_schema");
1123
+ expect(request(provider, 0).body.response_format.json_schema.schema).toBeDefined();
1122
1124
  });
1123
1125
 
1124
1126
  it("repairs an invalid answer with one extra turn", async () => {
@@ -180,7 +180,7 @@ describe("agents HTTP API — actions", () => {
180
180
  expect(res.body.agent.id).toBe("assistant");
181
181
  expect(res.body.agent.tools).toEqual(["echo"]);
182
182
  expect(res.body.agent.provider.model).toBe("test-model");
183
- expect(res.body.agent.provider.compatible).toBe("openai");
183
+ expect(res.body.agent.provider.protocol).toBe("openai");
184
184
  expect(res.body.agent.maxSteps).toBe(3);
185
185
  expect(res.body.agent.memory).toBe(true);
186
186
  expect(JSON.stringify(res.body)).not.toContain("test-key");
@@ -0,0 +1,196 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from "bun:test";
2
+ import { createApp } from "../server/hono";
3
+ import { formatConfig, cfg } from "../server/config";
4
+ import { syncTenants } from "../database/tenant";
5
+ import { useRest } from "../database/rest";
6
+
7
+ /**
8
+ * `api.privateFields` on a **collection reached through a join**.
9
+ *
10
+ * `func.omit` walks the whole response, but only with the matchers of the
11
+ * collection that was queried: an `$include`d document carries the *included*
12
+ * collection's fields, so that collection's `privateFields` must be applied too
13
+ * — at the path the join put it, and nowhere else. Same story for a `$lookup`
14
+ * inside an `aggregate` pipeline.
15
+ */
16
+
17
+ const TENANT = "private-fields";
18
+ const DB = "mongodb://localhost:27017/_API_PRIVATE_FIELDS_TEST";
19
+
20
+ let url = "";
21
+ let server: any;
22
+ let rest: InstanceType<typeof useRest>;
23
+ let internal: InstanceType<typeof useRest>;
24
+
25
+ async function call(collection: string, action: string, body?: any) {
26
+ const res = await fetch(`${url}/api/${TENANT}/collections/${collection}/${action}`, {
27
+ method: "POST",
28
+ headers: { "Content-Type": "application/json" },
29
+ body: body === undefined ? undefined : JSON.stringify(body),
30
+ });
31
+ return { status: res.status, body: (await res.json()) as any };
32
+ }
33
+
34
+ beforeAll(async () => {
35
+ formatConfig({
36
+ server: { port: 4000 },
37
+ tenants: [{ id: TENANT, dir: "src", database: { uri: DB } }],
38
+ });
39
+ await syncTenants();
40
+
41
+ (cfg as any).collections = [
42
+ {
43
+ _tenant_: TENANT,
44
+ slug: "authors",
45
+ fields: [
46
+ { name: "name", type: "string" },
47
+ { name: "password", type: "string" },
48
+ { name: "secret_token", type: "string" },
49
+ ],
50
+ api: { access: { "*": true }, privateFields: ["password", /^secret_/] },
51
+ },
52
+ {
53
+ _tenant_: TENANT,
54
+ slug: "posts",
55
+ fields: [
56
+ { name: "title", type: "string" },
57
+ // A public field that happens to match the *included* collection's
58
+ // matcher — it must survive: only the joined document is masked.
59
+ { name: "secret_note", type: "string" },
60
+ { name: "author", type: "relationship", relation: { to: "authors" } },
61
+ { name: "reviewers", type: "relationship", relation: { to: "authors", hasMany: true } },
62
+ ],
63
+ // The collection being queried declares no private field of its own:
64
+ // only the included one does.
65
+ api: { access: { "*": true } },
66
+ },
67
+ ];
68
+
69
+ rest = new useRest({ internal: false, tenant_id: TENANT });
70
+ internal = new useRest({ internal: true, tenant_id: TENANT });
71
+
72
+ const app = createApp();
73
+ server = Bun.serve({ port: 0, fetch: app.fetch });
74
+ url = server.url.href.replace(/\/$/, "");
75
+ });
76
+
77
+ afterAll(async () => {
78
+ for (const name of ["authors", "posts", "_locks_", "_audit_"]) {
79
+ try { await rest.db.collection(name).drop(); } catch (_) { /* first run */ }
80
+ }
81
+ server?.stop(true);
82
+ });
83
+
84
+ async function seed() {
85
+ await rest.db.collection("authors").deleteMany({});
86
+ await rest.db.collection("posts").deleteMany({});
87
+ const author = await rest.insertOne("authors", {
88
+ name: "Ada",
89
+ password: "s3cret-hash",
90
+ secret_token: "tok_live_123",
91
+ });
92
+ const post = await rest.insertOne("posts", {
93
+ title: "Hello",
94
+ secret_note: "public-note",
95
+ author: author._id,
96
+ reviewers: [author._id],
97
+ });
98
+ return { author, post };
99
+ }
100
+
101
+ describe("api.privateFields across a join", () => {
102
+ it("masks the included collection's private fields on `find`", async () => {
103
+ const { post } = await seed();
104
+
105
+ const res = await call("posts", "find", { params: { $match: { _id: post._id }, $include: ["author"] } });
106
+
107
+ expect(res.status).toBe(200);
108
+ const doc = res.body[0];
109
+ expect(doc.author.name).toBe("Ada");
110
+ expect(doc.author.password).toBeUndefined();
111
+ expect(doc.author.secret_token).toBeUndefined();
112
+ });
113
+
114
+ it("masks them on `findOne` too", async () => {
115
+ const { post } = await seed();
116
+
117
+ const res = await call("posts", "findOne", { id: post._id, params: { $include: ["author"] } });
118
+
119
+ expect(res.status).toBe(200);
120
+ expect(res.body.author.name).toBe("Ada");
121
+ expect(res.body.author.password).toBeUndefined();
122
+ expect(res.body.author.secret_token).toBeUndefined();
123
+ });
124
+
125
+ it("masks every element of a `hasMany` include", async () => {
126
+ const { post } = await seed();
127
+
128
+ const res = await call("posts", "find", { params: { $match: { _id: post._id }, $include: ["reviewers"] } });
129
+
130
+ expect(res.status).toBe(200);
131
+ const reviewers = res.body[0].reviewers;
132
+ expect(Array.isArray(reviewers)).toBe(true);
133
+ expect(reviewers[0].name).toBe("Ada");
134
+ expect(reviewers[0].password).toBeUndefined();
135
+ expect(reviewers[0].secret_token).toBeUndefined();
136
+ });
137
+
138
+ it("masks them on a `$lookup` of an `aggregate` pipeline", async () => {
139
+ const { post } = await seed();
140
+
141
+ const res = await call("posts", "aggregate", {
142
+ pipeline: [
143
+ { $match: { _id: post._id } },
144
+ { $lookup: { from: "authors", localField: "author", foreignField: "_id", as: "author" } },
145
+ { $unwind: "$author" },
146
+ ],
147
+ });
148
+
149
+ expect(res.status).toBe(200);
150
+ const doc = res.body[0];
151
+ expect(doc.author.name).toBe("Ada");
152
+ expect(doc.author.password).toBeUndefined();
153
+ expect(doc.author.secret_token).toBeUndefined();
154
+ });
155
+
156
+ it("still masks the queried collection's own private fields", async () => {
157
+ await seed();
158
+
159
+ const res = await call("authors", "find", {});
160
+
161
+ expect(res.status).toBe(200);
162
+ expect(res.body[0].name).toBe("Ada");
163
+ expect(res.body[0].password).toBeUndefined();
164
+ expect(res.body[0].secret_token).toBeUndefined();
165
+ });
166
+
167
+ it("masks the join only — the queried collection's own matching field survives", async () => {
168
+ const { post } = await seed();
169
+
170
+ const res = await call("posts", "find", { params: { $match: { _id: post._id }, $include: ["author"] } });
171
+
172
+ expect(res.status).toBe(200);
173
+ expect(res.body[0].secret_note).toBe("public-note");
174
+ });
175
+ });
176
+
177
+ describe("api.privateFields and the DB query cache", () => {
178
+ it("does not mutate the cached result (an internal read still sees every field)", async () => {
179
+ const { post } = await seed();
180
+ const params = { $match: { _id: post._id }, $include: ["author"] };
181
+
182
+ const viaHttp = await call("posts", "find", { params, options: { useCache: true } });
183
+ expect(viaHttp.body[0].author.password).toBeUndefined();
184
+
185
+ // Same query, same (warm) cache entry, but through the trusted internal API:
186
+ // the masked fields must still be there — masking is a response concern.
187
+ const viaRest: any = await internal.find("posts", params, { useCache: true });
188
+ expect(viaRest[0].author.password).toBe("s3cret-hash");
189
+ expect(viaRest[0].author.secret_token).toBe("tok_live_123");
190
+
191
+ // And the HTTP layer stays consistent on the second, cached response.
192
+ const again = await call("posts", "find", { params, options: { useCache: true } });
193
+ expect(again.body[0].author.name).toBe("Ada");
194
+ expect(again.body[0].author.password).toBeUndefined();
195
+ });
196
+ });
@@ -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 (file collections)
25
+ mode: "scan",
24
26
  runOnBoot: false,
25
27
  schedule: { interval: "1h" },
26
28
  destinations: [{ id: "backup", uri: DEST_DB }],
@@ -5,5 +5,5 @@ export default define.Agent({
5
5
  id: "support",
6
6
  description: "Answers support questions.",
7
7
  instructions: "You are a support agent.",
8
- provider: { model: "claude-test", compatible: "anthropic", apiKey: "fixture-key" },
8
+ provider: { model: "claude-test", protocol: "anthropic", apiKey: "fixture-key" },
9
9
  });
@@ -9,7 +9,7 @@ export default define.Agent({
9
9
  name: "Weather Agent",
10
10
  description: "Answers weather questions with a tool call",
11
11
  instructions: "You are a concise weather assistant.",
12
- provider: { model: "test-model", compatible: "openai", apiKey: "fixture-key" },
12
+ provider: { model: "test-model", protocol: "openai", apiKey: "fixture-key" },
13
13
  tools: {
14
14
  forecast: define.Tool({
15
15
  id: "forecast",
@@ -13,7 +13,7 @@ export default define.Agent({
13
13
  instructions: "You are a fixture agent.",
14
14
  provider: {
15
15
  model: "test-model",
16
- compatible: "openai",
16
+ protocol: "openai",
17
17
  apiKey: "test-key",
18
18
  baseUrl: Bun.env.AGENT_API_TEST_URL ?? "http://127.0.0.1:1",
19
19
  options: { retries: 0 },
@@ -10,7 +10,7 @@ export default define.Agent({
10
10
  instructions: "You are a plain agent.",
11
11
  provider: {
12
12
  model: "test-model",
13
- compatible: "openai",
13
+ protocol: "openai",
14
14
  apiKey: "test-key",
15
15
  baseUrl: Bun.env.AGENT_API_TEST_URL ?? "http://127.0.0.1:1",
16
16
  options: { retries: 0 },
@@ -7,7 +7,7 @@ export default define.Agent({
7
7
  instructions: "You are a private agent.",
8
8
  provider: {
9
9
  model: "test-model",
10
- compatible: "openai",
10
+ protocol: "openai",
11
11
  apiKey: "test-key",
12
12
  baseUrl: Bun.env.AGENT_API_TEST_URL ?? "http://127.0.0.1:1",
13
13
  options: { retries: 0 },
@@ -18,6 +18,9 @@ await bootApp({
18
18
  database: { uri: Bun.env.RO_SRC ?? "mongodb://localhost:27017/_RO_SRC" },
19
19
  replication: {
20
20
  enabled: true,
21
+ // Boot capabilities are under test here, not the replication engine:
22
+ // the scan mode keeps the assertions deterministic
23
+ mode: "scan",
21
24
  runOnBoot: true,
22
25
  schedule: { interval: "1h" },
23
26
  destinations: [{ id: "backup", uri: Bun.env.RO_DST ?? "mongodb://localhost:27017/_RO_DST" }],
@@ -11,6 +11,6 @@ export default define.Agent({
11
11
  id: "chat",
12
12
  description: "Fixture agent whose memory lives in a tenant-named collection.",
13
13
  instructions: "Fixture agent — no model call is ever made in these tests.",
14
- provider: { model: "test-model", compatible: "openai", apiKey: "fixture" },
14
+ provider: { model: "test-model", protocol: "openai", apiKey: "fixture" },
15
15
  memory: new agents.memory.Mongo({ collection: AGENT_THREADS }),
16
16
  });
@@ -0,0 +1,23 @@
1
+ import { define } from "../../../../index";
2
+
3
+ /**
4
+ * Fixture — an agent whose system prompt carries skills, declared every way a
5
+ * source can be: a glob of a folder, one file, a regex over the tenant, and the
6
+ * `SKILL.md` directory layout.
7
+ *
8
+ * The last two patterns overlap on purpose (`./docs/*.md` and the `docs` regex):
9
+ * a file matched twice is one skill.
10
+ */
11
+ export default define.Agent({
12
+ id: "docs",
13
+ description: "Answers from the tenant's own documentation.",
14
+ instructions: "Answer from the documentation you were given.",
15
+ provider: { model: "claude-test", protocol: "anthropic", apiKey: "fixture-key" },
16
+ skills: [
17
+ "./docs/*.md",
18
+ "./guide/ml.md",
19
+ /^docs\/.*\.md$/,
20
+ /^runbooks\/.*$/,
21
+ "./guides/**/SKILL.md",
22
+ ],
23
+ });
@@ -0,0 +1,14 @@
1
+ import { define } from "../../../../index";
2
+
3
+ /**
4
+ * Fixture — an agent whose skill is malformed (`broken/no-name.md` has no `name`).
5
+ *
6
+ * A broken skill refuses **this agent** at load, and must not hide the others.
7
+ */
8
+ export default define.Agent({
9
+ id: "incomplete",
10
+ description: "Carries a skill that does not follow the format.",
11
+ instructions: "Nothing to see here.",
12
+ provider: { model: "claude-test", protocol: "anthropic", apiKey: "fixture-key" },
13
+ skills: ["./broken/no-name.md"],
14
+ });
@@ -0,0 +1,6 @@
1
+ ---
2
+ name: Bad Name
3
+ description: The name is not kebab-case.
4
+ ---
5
+
6
+ Body.
@@ -0,0 +1,6 @@
1
+ ---
2
+ name: same-name
3
+ description: Two files of one agent cannot share a name — this is the first.
4
+ ---
5
+
6
+ Body.
@@ -0,0 +1,6 @@
1
+ ---
2
+ name: same-name
3
+ description: Two files of one agent cannot share a name — this is the second.
4
+ ---
5
+
6
+ Body.
@@ -0,0 +1,4 @@
1
+ ---
2
+ name: empty-body
3
+ description: Frontmatter only — nothing for the model to read.
4
+ ---
@@ -0,0 +1,7 @@
1
+ ---
2
+ name: long-compat
3
+ description: A skill whose compatibility line is too long.
4
+ compatibility: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
5
+ ---
6
+
7
+ Body.
@@ -0,0 +1,6 @@
1
+ ---
2
+ name: long-description
3
+ description: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
4
+ ---
5
+
6
+ Body.
@@ -0,0 +1,6 @@
1
+ ---
2
+ name: other-name
3
+ description: The parent directory is 'mismatch' — a SKILL.md must be named after it.
4
+ ---
5
+
6
+ Body.
@@ -0,0 +1,5 @@
1
+ ---
2
+ name: no-description
3
+ ---
4
+
5
+ A skill without a description — the model would never know when to read it.
@@ -0,0 +1,5 @@
1
+ ---
2
+ description: A skill without a name — the frontmatter is incomplete.
3
+ ---
4
+
5
+ Body.
@@ -0,0 +1,4 @@
1
+ # No frontmatter at all
2
+
3
+ A plain markdown file is refused: without a name and a description it cannot be
4
+ injected (the model would not know what it is).
@@ -0,0 +1,9 @@
1
+ ---
2
+ name: unknown-keys
3
+ description: Extra frontmatter keys are kept out of the prompt, not refused.
4
+ version: 3
5
+ author: platform
6
+ metadata: { owner: platform }
7
+ ---
8
+
9
+ Body.
@@ -0,0 +1,18 @@
1
+ ---
2
+ name: pdf-forms
3
+ description: Fill, flatten and merge PDF forms. Use it for any AcroForm work.
4
+ license: MIT
5
+ compatibility: Needs the `pdftk` binary on the host.
6
+ metadata:
7
+ owner: platform
8
+ reviewed: 2026-09-24
9
+ allowed-tools: read_pdf write_pdf
10
+ ---
11
+
12
+ # Filling a form
13
+
14
+ 1. Dump the fields: `pdftk form.pdf dump_data_fields`.
15
+ 2. Build the FDF, then fill it: `pdftk form.pdf fill_form data.fdf output out.pdf`.
16
+ 3. Flatten: `pdftk out.pdf output flat.pdf flatten`.
17
+
18
+ Never flatten before the signature step — it makes the form read-only.
@@ -0,0 +1,9 @@
1
+ ---
2
+ name: ml-conventions
3
+ description: The tenant's ML conventions — feature naming, training splits, review gates.
4
+ ---
5
+
6
+ # Conventions
7
+
8
+ - Features are `snake_case`, prefixed by their source (`crm_`, `billing_`).
9
+ - A model only ships with a held-out split and a written evaluation.
@@ -0,0 +1,9 @@
1
+ ---
2
+ name: pdf
3
+ description: Everything about the tenant's PDF pipeline, from templating to archiving.
4
+ ---
5
+
6
+ # PDF pipeline
7
+
8
+ The `name` matches the parent directory, which is what the `SKILL.md` layout
9
+ requires.
@@ -0,0 +1,9 @@
1
+ ---
2
+ name: incident-response
3
+ description: What to do when production breaks — paging, comms, post-mortem.
4
+ ---
5
+
6
+ # Incident response
7
+
8
+ 1. Acknowledge the page within five minutes.
9
+ 2. Open a channel, name an incident commander, keep a timeline.
@@ -0,0 +1,2 @@
1
+ This file is not markdown — a regex that walks the tenant picks it up, and the
2
+ loader must skip it (logged) instead of taking the agent down.