@anteros/core 0.0.42 → 0.0.44

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/database/replication.ts +614 -26
  2. package/lib/agent.ts +279 -39
  3. package/lib/agentMemory.ts +103 -0
  4. package/lib/agents.ts +25 -2
  5. package/lib/define.ts +1 -1
  6. package/lib/mcpClient.ts +128 -0
  7. package/lib/providers.ts +169 -66
  8. package/lib/skills.ts +481 -0
  9. package/package.json +1 -1
  10. package/server/agents.ts +27 -6
  11. package/server/api.ts +74 -1
  12. package/tests/agent-mcp.test.ts +116 -0
  13. package/tests/agent-memory.test.ts +6 -6
  14. package/tests/agent-provider.test.ts +288 -0
  15. package/tests/agent-runtime.test.ts +369 -0
  16. package/tests/agent-skills.test.ts +177 -0
  17. package/tests/agent-summarize.test.ts +124 -0
  18. package/tests/agent-working-memory.test.ts +5 -3
  19. package/tests/agent.test.ts +18 -16
  20. package/tests/agents-api.test.ts +1 -1
  21. package/tests/api-private-fields.test.ts +196 -0
  22. package/tests/file-collection-replication.test.ts +2 -0
  23. package/tests/fixtures/agent-tenant/agents/support.agent.ts +1 -1
  24. package/tests/fixtures/agent-tenant/agents/weather.agent.ts +1 -1
  25. package/tests/fixtures/agents-api/agents/assistant.agent.ts +1 -1
  26. package/tests/fixtures/agents-api/agents/plain.agent.ts +1 -1
  27. package/tests/fixtures/agents-api/agents/private.agent.ts +1 -1
  28. package/tests/fixtures/repl-only/app.ts +3 -0
  29. package/tests/fixtures/replication-meta/agents/chat.agent.ts +1 -1
  30. package/tests/fixtures/skills-tenant/agents/docs.agent.ts +23 -0
  31. package/tests/fixtures/skills-tenant/agents/incomplete.agent.ts +14 -0
  32. package/tests/fixtures/skills-tenant/broken/bad-name.md +6 -0
  33. package/tests/fixtures/skills-tenant/broken/dup-a.md +6 -0
  34. package/tests/fixtures/skills-tenant/broken/dup-b.md +6 -0
  35. package/tests/fixtures/skills-tenant/broken/empty-body.md +4 -0
  36. package/tests/fixtures/skills-tenant/broken/long-compat.md +7 -0
  37. package/tests/fixtures/skills-tenant/broken/long-description.md +6 -0
  38. package/tests/fixtures/skills-tenant/broken/mismatch/SKILL.md +6 -0
  39. package/tests/fixtures/skills-tenant/broken/no-description.md +5 -0
  40. package/tests/fixtures/skills-tenant/broken/no-name.md +5 -0
  41. package/tests/fixtures/skills-tenant/broken/plain.md +4 -0
  42. package/tests/fixtures/skills-tenant/broken/unknown-key.md +9 -0
  43. package/tests/fixtures/skills-tenant/docs/pdf-forms.md +18 -0
  44. package/tests/fixtures/skills-tenant/guide/ml.md +9 -0
  45. package/tests/fixtures/skills-tenant/guides/pdf/SKILL.md +9 -0
  46. package/tests/fixtures/skills-tenant/runbooks/incident.md +9 -0
  47. package/tests/fixtures/skills-tenant/runbooks/notes.txt +2 -0
  48. package/tests/replication-change-stream.test.ts +240 -0
  49. package/tests/replication-control.test.ts +2 -0
  50. package/tests/replication-initial-sync.test.ts +2 -0
  51. package/tests/replication-meta.test.ts +3 -0
  52. package/tests/replication.test.ts +2 -0
  53. package/tests/vars-replication.test.ts +2 -0
  54. package/types/agent.d.ts +237 -7
  55. package/types/replication.d.ts +41 -4
package/lib/skills.ts ADDED
@@ -0,0 +1,481 @@
1
+ import { Glob } from "bun";
2
+ import path from "node:path";
3
+ import fs from "node:fs/promises";
4
+ import { AppError } from "./error";
5
+ import { logger } from "../utils/logger";
6
+ import type { AgentSkill, AgentSkillSource } from "../types/agent";
7
+
8
+ /**
9
+ * Agent **skills** — markdown documents declared on an agent
10
+ * (`skills: ['./docs/*.md', './guide/ml.md', /^runbooks\/.*\.md$/]`), resolved
11
+ * once at load and injected into the **system prompt** of every run.
12
+ *
13
+ * A skill file is markdown with a frontmatter header:
14
+ *
15
+ * ```md
16
+ * ---
17
+ * name: pdf-forms
18
+ * description: Fill, flatten and merge PDF forms. Use for any AcroForm work.
19
+ * license: MIT
20
+ * compatibility: Needs the `pdftk` binary.
21
+ * metadata: { owner: platform }
22
+ * allowed-tools: read_pdf write_pdf
23
+ * ---
24
+ *
25
+ * # Filling a form
26
+ * …
27
+ * ```
28
+ *
29
+ * `name` and `description` are required; the body is what the model reads.
30
+ */
31
+
32
+ /** Kebab-case, and short — the name identifies a skill in the prompt. */
33
+ const SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
34
+ const MAX_NAME_CHARS = 64;
35
+ const MAX_DESCRIPTION_CHARS = 1024;
36
+ const MAX_COMPATIBILITY_CHARS = 500;
37
+ /**
38
+ * Per-file ceiling. A skill is a prompt document, read on **every** call: past
39
+ * this, the agent is better served by a tool than by a document (same order of
40
+ * magnitude as the attachment text limit).
41
+ */
42
+ const MAX_SKILL_CHARS = 200_000;
43
+
44
+ /** The keys the format defines — anything else is kept out of the prompt. */
45
+ const KNOWN_KEYS = new Set(["name", "description", "license", "compatibility", "metadata", "allowed-tools", "allowedTools"]);
46
+
47
+ /* ------------------------------------------------------------------ */
48
+ /* Frontmatter */
49
+ /* ------------------------------------------------------------------ */
50
+
51
+ type Line = { indent: number; text: string };
52
+
53
+ /** Drop a trailing ` # comment`, unless the `#` sits inside a quoted value. */
54
+ function stripComment(line: string): string {
55
+ let quote: string | null = null;
56
+ for (let i = 0; i < line.length; i++) {
57
+ const char = line[i]!;
58
+ if (quote) {
59
+ if (char === quote) quote = null;
60
+ continue;
61
+ }
62
+ if (char === '"' || char === "'") {
63
+ quote = char;
64
+ continue;
65
+ }
66
+ if (char === "#" && (i === 0 || /\s/.test(line[i - 1]!))) {
67
+ return line.slice(0, i).replace(/\s+$/, "");
68
+ }
69
+ }
70
+ return line;
71
+ }
72
+
73
+ /** Index of the `:` that separates a key from its value (outside quotes). */
74
+ function keySeparator(text: string): number {
75
+ let quote: string | null = null;
76
+ for (let i = 0; i < text.length; i++) {
77
+ const char = text[i]!;
78
+ if (quote) {
79
+ if (char === quote) quote = null;
80
+ continue;
81
+ }
82
+ if (char === '"' || char === "'") {
83
+ quote = char;
84
+ continue;
85
+ }
86
+ if (char === ":" && (i === text.length - 1 || /\s/.test(text[i + 1]!))) return i;
87
+ }
88
+ return -1;
89
+ }
90
+
91
+ function unquote(text: string): string {
92
+ if (text.length >= 2 && (text[0] === '"' || text[0] === "'") && text[text.length - 1] === text[0]) {
93
+ const body = text.slice(1, -1);
94
+ return text[0] === '"' ? body.replace(/\\"/g, '"') : body.replace(/''/g, "'");
95
+ }
96
+ return text;
97
+ }
98
+
99
+ function scalar(text: string): any {
100
+ const raw = text.trim();
101
+ if (raw.startsWith("[") && raw.endsWith("]")) {
102
+ const inner = raw.slice(1, -1).trim();
103
+ return inner ? splitTopLevel(inner, ",").map((item) => scalar(item)).filter((item) => item !== undefined) : [];
104
+ }
105
+ if (raw.startsWith("{") && raw.endsWith("}")) {
106
+ const inner = raw.slice(1, -1).trim();
107
+ if (!inner) return {};
108
+ const out: Record<string, any> = {};
109
+ for (const pair of splitTopLevel(inner, ",")) {
110
+ const separator = keySeparator(pair.trim());
111
+ if (separator === -1) continue;
112
+ out[unquote(pair.slice(0, separator).trim())] = scalar(pair.slice(separator + 1).trim());
113
+ }
114
+ return out;
115
+ }
116
+ if (raw.length >= 2 && (raw[0] === '"' || raw[0] === "'") && raw[raw.length - 1] === raw[0]) return unquote(raw);
117
+ if (raw === "" || raw === "null" || raw === "~") return null;
118
+ if (raw === "true") return true;
119
+ if (raw === "false") return false;
120
+ if (/^-?\d+(?:\.\d+)?$/.test(raw)) return Number(raw);
121
+ return raw;
122
+ }
123
+
124
+ /** Split on a separator that is outside quotes and outside `[…]` / `{…}`. */
125
+ function splitTopLevel(text: string, separator: string): string[] {
126
+ const parts: string[] = [];
127
+ let depth = 0;
128
+ let quote: string | null = null;
129
+ let current = "";
130
+
131
+ for (const char of text) {
132
+ if (quote) {
133
+ current += char;
134
+ if (char === quote) quote = null;
135
+ continue;
136
+ }
137
+ if (char === '"' || char === "'") {
138
+ quote = char;
139
+ current += char;
140
+ continue;
141
+ }
142
+ if (char === "[" || char === "{") depth += 1;
143
+ if (char === "]" || char === "}") depth -= 1;
144
+ if (char === separator && depth === 0) {
145
+ parts.push(current.trim());
146
+ current = "";
147
+ continue;
148
+ }
149
+ current += char;
150
+ }
151
+ parts.push(current.trim());
152
+ return parts;
153
+ }
154
+
155
+ /**
156
+ * The block reader behind `parseYaml()` — mappings and sequences, nested by
157
+ * indentation, `[a, b]` inline.
158
+ */
159
+ function parseBlock(lines: Line[], start: number, indent: number): [any, number] {
160
+ const first = lines[start];
161
+ if (!first) return [{}, start];
162
+
163
+ // Sequence: `- value`, or `-` followed by a deeper block
164
+ if (first.text === "-" || first.text.startsWith("- ")) {
165
+ const out: any[] = [];
166
+ let index = start;
167
+ while (index < lines.length) {
168
+ const line = lines[index]!;
169
+ if (line.indent !== first.indent || !(line.text === "-" || line.text.startsWith("- "))) break;
170
+ const inline = line.text.slice(1).trim();
171
+ index += 1;
172
+ if (inline) {
173
+ out.push(scalar(inline));
174
+ continue;
175
+ }
176
+ const next = lines[index];
177
+ if (next && next.indent > first.indent) {
178
+ const [nested, after] = parseBlock(lines, index, next.indent);
179
+ out.push(nested);
180
+ index = after;
181
+ } else {
182
+ out.push(null);
183
+ }
184
+ }
185
+ return [out, index];
186
+ }
187
+
188
+ // Mapping
189
+ const out: Record<string, any> = {};
190
+ let index = start;
191
+ while (index < lines.length) {
192
+ const line = lines[index]!;
193
+ if (line.indent < indent) break;
194
+ if (line.indent > indent) {
195
+ index += 1; // a stray deeper line: ignore rather than guess
196
+ continue;
197
+ }
198
+ const separator = keySeparator(line.text);
199
+ if (separator === -1) {
200
+ index += 1;
201
+ continue;
202
+ }
203
+ const key = unquote(line.text.slice(0, separator).trim());
204
+ const rest = line.text.slice(separator + 1).trim();
205
+ index += 1;
206
+ if (rest) {
207
+ out[key] = scalar(rest);
208
+ continue;
209
+ }
210
+ const next = lines[index];
211
+ if (next && next.indent > indent) {
212
+ const [nested, after] = parseBlock(lines, index, next.indent);
213
+ out[key] = nested;
214
+ index = after;
215
+ } else {
216
+ out[key] = null;
217
+ }
218
+ }
219
+ return [out, index];
220
+ }
221
+
222
+ /**
223
+ * A dependency-free reader for the frontmatter of a skill file — a **documented
224
+ * subset of YAML**, the same way `lib/zip.ts` reads an archive without a library.
225
+ *
226
+ * Handled: `key: value` (quoted or not, `true`/`false`/`null`/numbers), blocks
227
+ * nested by indentation, sequences (`- item`), and the inline forms — `[a, b]`,
228
+ * `{ key: value }`.
229
+ * Not handled, on purpose: multi-line scalars (`|`, `>`), anchors, tags, multiple
230
+ * documents. A skill file does not need them.
231
+ */
232
+ function parseYaml(block: string): Record<string, any> {
233
+ const lines: Line[] = [];
234
+ for (const raw of block.split("\n")) {
235
+ if (!raw.trim()) continue;
236
+ const stripped = stripComment(raw);
237
+ if (!stripped.trim()) continue;
238
+ lines.push({ indent: stripped.length - stripped.trimStart().length, text: stripped.trim() });
239
+ }
240
+ const [value] = parseBlock(lines, 0, lines[0]?.indent ?? 0);
241
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
242
+ }
243
+
244
+ /**
245
+ * Split a skill file into its frontmatter and its body. No frontmatter at all is
246
+ * not an error here — validation says so, with a message that names the file.
247
+ */
248
+ function parseFrontmatter(text: string): { data: Record<string, any>; content: string } {
249
+ const normalized = text.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n");
250
+ if (!/^---\s*\n/.test(normalized)) return { data: {}, content: normalized.trim() };
251
+
252
+ const end = normalized.indexOf("\n---", 3);
253
+ if (end === -1) return { data: {}, content: normalized.trim() };
254
+
255
+ const data = parseYaml(normalized.slice(normalized.indexOf("\n") + 1, end + 1));
256
+ const content = normalized.slice(end + 4).replace(/^\s*\n/, "").trim();
257
+ return { data, content };
258
+ }
259
+
260
+ /* ------------------------------------------------------------------ */
261
+ /* Resolution */
262
+ /* ------------------------------------------------------------------ */
263
+
264
+ function invalid(skill: string, message: string): AppError {
265
+ return new AppError(`Skill '${skill}': ${message}`, { status: 500, code: "AGENT_SKILL_INVALID" });
266
+ }
267
+
268
+ /** The body a skill must carry — it *is* the skill. */
269
+ function requireContent(content: any, label: string): string {
270
+ const body = typeof content === "string" ? content.trim() : "";
271
+ if (!body) throw invalid(label, "`content` is required — a skill is the text the model reads");
272
+ return body;
273
+ }
274
+
275
+ function asString(value: any): string | undefined {
276
+ if (value === undefined || value === null || value === "") return undefined;
277
+ return typeof value === "string" ? value : String(value);
278
+ }
279
+
280
+ /**
281
+ * The files a source selects, as absolute paths. A **string** is a path or a glob
282
+ * (``.` = the tenant directory); a **RegExp** is matched against every file of the
283
+ * tenant directory, on the relative POSIX path (`runbooks/incident.md`).
284
+ *
285
+ * The glob is split in two — the directory chain that holds no wildcard, then the
286
+ * pattern — so `Bun.Glob` always scans from a real root instead of depending on the
287
+ * process cwd (a relative tenant dir would otherwise be prefixed twice).
288
+ */
289
+ async function matchFiles(source: AgentSkillSource, baseDir: string): Promise<string[]> {
290
+ if (source instanceof RegExp) {
291
+ // `g`/`y` carry a `lastIndex` between calls — one regex must not skip files
292
+ const pattern = new RegExp(source.source, source.flags.replace(/[gy]/g, ""));
293
+ const found: string[] = [];
294
+ for await (const file of new Glob("**/*").scan(baseDir)) {
295
+ const relative = file.split(path.sep).join("/");
296
+ if (pattern.test(relative)) found.push(path.join(baseDir, file));
297
+ }
298
+ return found;
299
+ }
300
+
301
+ const pattern = String(source ?? "").trim();
302
+ if (!pattern) return [];
303
+
304
+ const target = path.resolve(baseDir, pattern);
305
+ const parts = target.split(path.sep);
306
+ let wildcard = parts.findIndex((part) => /[*?[\]{}]/.test(part));
307
+ if (wildcard === -1) wildcard = parts.length - 1; // a plain file: scan its directory
308
+
309
+ const root = parts.slice(0, wildcard).join(path.sep) || path.sep;
310
+ const rest = parts.slice(wildcard).join("/");
311
+
312
+ const found: string[] = [];
313
+ for await (const file of new Glob(rest).scan(root)) {
314
+ found.push(path.resolve(root, file));
315
+ }
316
+ return found;
317
+ }
318
+
319
+ /**
320
+ * Validate the header of a skill file. `label` names the source in the messages
321
+ * (the path relative to the tenant directory).
322
+ */
323
+ function validateSkill(data: Record<string, any>, label: string): Omit<AgentSkill, "content" | "source"> {
324
+ const name = asString(data.name);
325
+ if (!name) throw invalid(label, "`name` is required");
326
+ if (name.length > MAX_NAME_CHARS || !SKILL_NAME.test(name)) {
327
+ throw invalid(label, `\`name\` must be lowercase alphanumeric with hyphens, 1-${MAX_NAME_CHARS} characters (got '${name}')`);
328
+ }
329
+ // The directory-skill layout (`pdf/SKILL.md`): the name is the directory's, so a
330
+ // folder copied or renamed cannot serve a skill under a stale identity
331
+ if (label && path.basename(label).toLowerCase() === "skill.md") {
332
+ const parent = path.basename(path.dirname(label));
333
+ if (parent !== name) throw invalid(label, `\`name\` must match the parent directory ('${parent}') for a SKILL.md`);
334
+ }
335
+
336
+ const description = asString(data.description);
337
+ if (!description) throw invalid(label, "`description` is required");
338
+ if (description.length > MAX_DESCRIPTION_CHARS) {
339
+ throw invalid(label, `\`description\` is ${description.length} characters — over the ${MAX_DESCRIPTION_CHARS} limit`);
340
+ }
341
+
342
+ const compatibility = asString(data.compatibility);
343
+ if (compatibility && compatibility.length > MAX_COMPATIBILITY_CHARS) {
344
+ throw invalid(label, `\`compatibility\` is ${compatibility.length} characters — over the ${MAX_COMPATIBILITY_CHARS} limit`);
345
+ }
346
+
347
+ const metadata = data.metadata ?? undefined;
348
+ if (metadata !== undefined && (typeof metadata !== "object" || Array.isArray(metadata))) {
349
+ throw invalid(label, "`metadata` must be a mapping of key-value pairs");
350
+ }
351
+
352
+ const allowed = data["allowed-tools"] ?? data.allowedTools;
353
+ let allowedTools: string[] | undefined;
354
+ if (Array.isArray(allowed)) allowedTools = allowed.map((item) => String(item));
355
+ else if (typeof allowed === "string") allowedTools = allowed.split(/\s+/).filter(Boolean);
356
+ else if (allowed !== undefined && allowed !== null) throw invalid(label, "`allowed-tools` must be a space-separated list");
357
+
358
+ return {
359
+ name,
360
+ description,
361
+ license: asString(data.license),
362
+ compatibility,
363
+ metadata: metadata as Record<string, any> | undefined,
364
+ allowedTools,
365
+ };
366
+ }
367
+
368
+ /** Read one file and validate its frontmatter against the skill format. */
369
+ async function readSkill(file: string, relative: string): Promise<AgentSkill> {
370
+ const raw = await fs.readFile(file, "utf8");
371
+ if (raw.length > MAX_SKILL_CHARS) {
372
+ throw new AppError(
373
+ `Skill '${relative}' is ${raw.length} characters — over the ${MAX_SKILL_CHARS} limit (a skill is injected in every prompt)`,
374
+ { status: 500, code: "AGENT_SKILL_TOO_LARGE" },
375
+ );
376
+ }
377
+
378
+ const { data, content } = parseFrontmatter(raw);
379
+
380
+ for (const key of Object.keys(data)) {
381
+ if (!KNOWN_KEYS.has(key)) {
382
+ logger.file("warn", "agent skill: unknown frontmatter key ignored", { skill: relative, key });
383
+ }
384
+ }
385
+
386
+ return { ...validateSkill(data, relative), content: requireContent(content, relative), source: relative };
387
+ }
388
+
389
+ /**
390
+ * Resolve the `skills` of an agent into the documents injected into its system
391
+ * prompt, in a stable order (by name).
392
+ *
393
+ * A source is a **string** — a path or a glob — or a **RegExp**, a walk of the
394
+ * tenant: a skill is a **link to markdown files**, never a declaration. A file that
395
+ * is **not markdown** is skipped with a line in the log rather than refused: a
396
+ * pattern meant to catch a folder must not take an agent down because a `README.txt`
397
+ * lives beside the skills. Everything else — a missing `name`, a name that is not
398
+ * kebab-case, a description over the limit, an empty body, two skills sharing a name
399
+ * — refuses the agent at load, so a typo never changes the prompt silently (the
400
+ * loader logs it and keeps the other agents).
401
+ */
402
+ async function resolveSkills(
403
+ sources: AgentSkillSource[] | undefined,
404
+ options: { baseDir: string; agentId?: string },
405
+ ): Promise<AgentSkill[]> {
406
+ if (!sources?.length) return [];
407
+
408
+ const byFile = new Map<string, AgentSkill>();
409
+
410
+ for (const source of sources) {
411
+ const files = await matchFiles(source, options.baseDir);
412
+ // A pattern that selects nothing is a typo, not a choice — say so
413
+ if (!files.length) {
414
+ logger.file("warn", "agent skill: pattern matched no file", {
415
+ agent: options.agentId,
416
+ pattern: source instanceof RegExp ? source.source : source,
417
+ });
418
+ }
419
+ for (const file of files) {
420
+ const relative = path.relative(options.baseDir, file).split(path.sep).join("/");
421
+ if (byFile.has(relative)) continue;
422
+
423
+ if (!relative.toLowerCase().endsWith(".md")) {
424
+ logger.file("warn", "agent skill skipped: not markdown", {
425
+ agent: options.agentId,
426
+ skill: relative,
427
+ });
428
+ continue;
429
+ }
430
+
431
+ byFile.set(relative, await readSkill(file, relative));
432
+ }
433
+ }
434
+
435
+ const skills = [...byFile.values()].sort((a, b) => a.name.localeCompare(b.name));
436
+ const seen = new Map<string, string>();
437
+ for (const skill of skills) {
438
+ const previous = seen.get(skill.name);
439
+ if (previous) {
440
+ throw new AppError(
441
+ `Skill '${skill.source}': the name '${skill.name}' is already used by '${previous}' — two skills of an agent must not share one`,
442
+ { status: 500, code: "AGENT_SKILL_DUPLICATE" },
443
+ );
444
+ }
445
+ seen.set(skill.name, skill.source);
446
+ }
447
+
448
+ return skills;
449
+ }
450
+
451
+ /**
452
+ * The system-prompt block of the skills. Static by nature — no store, nothing
453
+ * resolved per run — which is why it is injected **before** the working memory:
454
+ * the prefix of the prompt stays cacheable.
455
+ */
456
+ function skillsBlock(skills: AgentSkill[]): string {
457
+ if (!skills.length) return "";
458
+
459
+ const parts = [
460
+ "## Skills",
461
+ "",
462
+ "Reference documents you can rely on. Read the ones that match the task at hand instead of guessing, and apply them as written.",
463
+ ];
464
+
465
+ for (const skill of skills) {
466
+ parts.push("", `### ${skill.name}`, "", skill.description);
467
+ if (skill.content) parts.push("", skill.content);
468
+ }
469
+
470
+ return parts.join("\n");
471
+ }
472
+
473
+ export {
474
+ MAX_SKILL_CHARS,
475
+ MAX_DESCRIPTION_CHARS,
476
+ MAX_COMPATIBILITY_CHARS,
477
+ MAX_NAME_CHARS,
478
+ parseFrontmatter,
479
+ resolveSkills,
480
+ skillsBlock,
481
+ };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@anteros/core",
3
3
  "module": "index.ts",
4
- "version": "0.0.42",
4
+ "version": "0.0.44",
5
5
  "type": "module",
6
6
  "description": "Anteros Core",
7
7
  "private": false,
package/server/agents.ts CHANGED
@@ -14,6 +14,7 @@ import type {
14
14
  AgentGenerateResult,
15
15
  AgentInput,
16
16
  AgentMessageInput,
17
+ AgentResume,
17
18
  } from "../types/agent";
18
19
 
19
20
  /**
@@ -74,6 +75,11 @@ type AgentRequestBody = {
74
75
  }>;
75
76
  /** Read the thread without writing the run back. */
76
77
  readOnly?: boolean;
78
+ /**
79
+ * Continue a run that paused for an approval — the `resume` payload `generate`
80
+ * handed back (`{ messages, decisions }`). `input` is then not needed.
81
+ */
82
+ resume?: AgentResume;
77
83
  /** Mastra-style shorthand — `memory: { thread, resource, readOnly }`. */
78
84
  memory?: {
79
85
  thread?: string;
@@ -164,6 +170,15 @@ function presentResult(result: AgentGenerateResult) {
164
170
  steps: result.steps,
165
171
  usage: result.usage,
166
172
  finishReason: result.finishReason,
173
+ // A paused run hands back the conversation to resume with: nothing is stored
174
+ // until the run completes, and an assistant turn with unanswered calls would
175
+ // poison the thread
176
+ ...(result.pendingApprovals?.length
177
+ ? {
178
+ pendingApprovals: result.pendingApprovals,
179
+ resume: { messages: result.messages },
180
+ }
181
+ : {}),
167
182
  };
168
183
  }
169
184
 
@@ -180,6 +195,8 @@ function auditInput(input: AgentInput | undefined) {
180
195
  type AuditOptions = {
181
196
  rest: InstanceType<typeof useRest>;
182
197
  agentId: string;
198
+ /** The prompt's version, when the agent declares one — what produced the answer. */
199
+ version?: string;
183
200
  action: string;
184
201
  input?: AgentInput;
185
202
  thread?: string;
@@ -190,12 +207,12 @@ type AuditOptions = {
190
207
 
191
208
  /** One audit entry per call, at the same place as `_vars_:<ns>` — `_agents_:<id>`. */
192
209
  async function auditAgent({
193
- rest, agentId, action, input, thread, result, error, started,
210
+ rest, agentId, version, action, input, thread, result, error, started,
194
211
  }: AuditOptions): Promise<void> {
195
212
  await rest.audit.log({
196
213
  action: `agent.${action}`,
197
214
  collection: `_agents_:${agentId}`,
198
- input: { agent: agentId, thread, ...auditInput(input) },
215
+ input: { agent: agentId, ...(version ? { version } : {}), thread, ...auditInput(input) },
199
216
  // Never the answer, never the tool arguments: the envelope only.
200
217
  result: result
201
218
  ? {
@@ -288,6 +305,7 @@ function initializeAgents(app: Hono<{ Variables: HonoVariables }>) {
288
305
  let rest: InstanceType<typeof useRest> | undefined;
289
306
  let tenant_id = '';
290
307
  let agentId = '';
308
+ let agentVersion: string | undefined;
291
309
  let action = '';
292
310
 
293
311
  try {
@@ -323,9 +341,11 @@ function initializeAgents(app: Hono<{ Variables: HonoVariables }>) {
323
341
  rest = new useRest({ internal: false, tenant_id });
324
342
  const agent = createAgents(tenant_id, rest).get(agentId);
325
343
  if (!agent) throw new AppError(`Agent '${agentId}' not found`, { status: 400, code: 'AGENT_NOT_FOUND' });
344
+ agentVersion = agent.getConfig().version;
326
345
 
327
346
  const api = agent.getConfig().api;
328
- const needsInput = action === 'generate' || action === 'stream' || action === 'object';
347
+ // A resume carries its own conversation: no input to ask for
348
+ const needsInput = (action === 'generate' || action === 'stream' || action === 'object') && !body?.resume;
329
349
  const input = needsInput ? normalizeInput(body) : undefined;
330
350
  // `memory: { thread, resource }` or the flat `thread` / `resource`
331
351
  const thread = resolveThread(body);
@@ -353,12 +373,13 @@ function initializeAgents(app: Hono<{ Variables: HonoVariables }>) {
353
373
  // Stop the run when the client disconnects (no tokens past the wire)
354
374
  signal: c.req.raw.signal,
355
375
  ...(body?.files?.length ? { files: body.files } : {}),
376
+ ...(body?.resume ? { resume: body.resume } : {}),
356
377
  ...(readOnly ? { memory: { readOnly: true } } : {}),
357
378
  };
358
379
 
359
380
  if (action === 'stream') {
360
381
  return streamResponse(c, agent, input as AgentInput, callOptions, {
361
- rest, agentId, action, started,
382
+ rest, agentId, action, started, version: agent.getConfig().version,
362
383
  });
363
384
  }
364
385
 
@@ -430,7 +451,7 @@ function initializeAgents(app: Hono<{ Variables: HonoVariables }>) {
430
451
  throw new AppError(`Action '${action}' not found`, { status: 400, code: 'ACTION_NOT_FOUND' });
431
452
  }
432
453
 
433
- await auditAgent({ rest, agentId, action, input, thread, result, started });
454
+ await auditAgent({ rest, agentId, action, input, thread, result, started, version: agent.getConfig().version });
434
455
  return c.json(response);
435
456
  } catch (err: any) {
436
457
  if (cfg?.debug) console.error(err);
@@ -438,7 +459,7 @@ function initializeAgents(app: Hono<{ Variables: HonoVariables }>) {
438
459
  await rest.audit.log({
439
460
  action: `agent.${action || 'unknown'}`,
440
461
  collection: `_agents_:${agentId}`,
441
- input: { agent: agentId },
462
+ input: { agent: agentId, ...(agentVersion ? { version: agentVersion } : {}) },
442
463
  error: { message: err?.message, code: err?.code || 'INTERNAL_AGENT_ERROR' },
443
464
  duration: Date.now() - started,
444
465
  }).catch(() => {});
package/server/api.ts CHANGED
@@ -61,6 +61,75 @@ function stripReadOnlyUpdate(col: any, body: any) {
61
61
  }
62
62
  }
63
63
 
64
+ /**
65
+ * The collections a request pulls documents **into** its response — an `$include`
66
+ * on `find`/`findOne`, a `$lookup` / `$graphLookup` anywhere in an `aggregate`
67
+ * pipeline (`$facet` branches and nested lookups included).
68
+ *
69
+ * Returns the path each joined document lands at, paired with the target
70
+ * collection's `api.privateFields`. `func.omit` only knows the *queried*
71
+ * collection's matchers, so a joined document — which carries the *included*
72
+ * collection's fields — has to be masked separately, at its own path.
73
+ */
74
+ function embeddedPrivateFields(col: any, body: any): Array<{ path: string[]; keys: (string | RegExp)[] }> {
75
+ const found: Array<{ path: string[]; keys: (string | RegExp)[] }> = [];
76
+
77
+ const keysOf = (slug: string): (string | RegExp)[] =>
78
+ (cfg.collections?.find((c) => c.slug === slug && c._tenant_ === col._tenant_)?.api?.privateFields) ?? [];
79
+
80
+ const push = (slug: string, path?: string) => {
81
+ if (!path) return;
82
+ const keys = keysOf(slug);
83
+ if (keys.length) found.push({ path: path.split('.'), keys });
84
+ };
85
+
86
+ // find / findOne — `$include`
87
+ for (const inc of body?.params?.$include ?? []) {
88
+ if (typeof inc === 'string') {
89
+ const field = col.fields?.find((f: any) => f.name === inc);
90
+ if (field?.relation?.to) push(field.relation.to, inc);
91
+ } else if (inc?.from) {
92
+ push(inc.from, inc.as || inc.localField);
93
+ }
94
+ }
95
+
96
+ // aggregate — every `$lookup` / `$graphLookup`, nested ones included
97
+ const walk = (stages: any, prefix: string) => {
98
+ if (!Array.isArray(stages)) return;
99
+ for (const stage of stages) {
100
+ if (!stage || typeof stage !== 'object') continue;
101
+ const lookup = stage.$lookup ?? stage.$graphLookup;
102
+ if (lookup?.from && typeof lookup.as === 'string') {
103
+ const path = prefix ? `${prefix}.${lookup.as}` : lookup.as;
104
+ push(lookup.from, path);
105
+ walk(lookup.pipeline, path);
106
+ }
107
+ if (stage.$facet && typeof stage.$facet === 'object') {
108
+ for (const branch of Object.values(stage.$facet)) walk(branch, prefix);
109
+ }
110
+ }
111
+ };
112
+ walk(body?.pipeline, '');
113
+
114
+ return found;
115
+ }
116
+
117
+ /**
118
+ * Apply `func.omit` **at `path` only**, walking through arrays (a `$include` of a
119
+ * `hasMany`). Each node on the way is shallow-copied, so a cached result is never
120
+ * mutated in place.
121
+ */
122
+ function omitAtPath(node: any, path: string[], keys: (string | RegExp)[]): any {
123
+ if (node === null || node === undefined) return node;
124
+ if (Array.isArray(node)) return node.map((item) => omitAtPath(item, path, keys));
125
+ if (typeof node !== 'object') return node;
126
+
127
+ const [head, ...rest] = path;
128
+ if (!head || !(head in node)) return node;
129
+
130
+ return { ...node, [head]: rest.length ? omitAtPath(node[head], rest, keys) : func.omit(node[head], keys) };
131
+ }
132
+
64
133
  // ─── Access control ─────────────────────────────────────────────────────────
65
134
  // `evaluateAccess` / `errorResponse` / `getAccessToken` live in `./access`
66
135
  // (shared with the agents API).
@@ -313,10 +382,14 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
313
382
  response = await handler(rest, collection, body);
314
383
  }
315
384
 
316
- // Strip private fields from response
385
+ // Strip private fields from response — the queried collection's own,
386
+ // then each collection it embeds via `$include` / `$lookup`
317
387
  if (privateFields.length > 0) {
318
388
  response = func.omit(response!, privateFields, fieldOrder);
319
389
  }
390
+ for (const { path, keys } of embeddedPrivateFields(col, body)) {
391
+ response = omitAtPath(response, path, keys);
392
+ }
320
393
 
321
394
  return c.json(response);
322
395