@anteros/core 0.0.41 → 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 (133) hide show
  1. package/database/audit.ts +9 -59
  2. package/database/collection.ts +3 -1
  3. package/database/deleteLog.ts +67 -3
  4. package/database/file.ts +4 -3
  5. package/database/mongodbadapter.ts +103 -42
  6. package/database/replication.ts +724 -58
  7. package/database/ttl.ts +68 -0
  8. package/database/workflow.ts +466 -240
  9. package/index.ts +6 -0
  10. package/lib/agent.ts +1567 -0
  11. package/lib/agentMemory.ts +689 -0
  12. package/lib/agents.ts +180 -0
  13. package/lib/ai.ts +0 -0
  14. package/lib/api.ts +246 -0
  15. package/lib/attachment.ts +351 -0
  16. package/lib/auditResult.ts +13 -0
  17. package/lib/define.ts +43 -1
  18. package/lib/endpoints.ts +80 -0
  19. package/lib/files.ts +2 -1
  20. package/lib/jsonSchema.ts +183 -0
  21. package/lib/lifecycle.ts +7 -1
  22. package/lib/load.ts +17 -0
  23. package/lib/mcp.ts +3 -2
  24. package/lib/mcpClient.ts +128 -0
  25. package/lib/messages.ts +299 -0
  26. package/lib/providers.ts +970 -0
  27. package/lib/routes.ts +14 -1
  28. package/lib/scriptWorker.ts +4 -1
  29. package/lib/scripts.ts +6 -1
  30. package/lib/skills.ts +481 -0
  31. package/lib/sockets.ts +3 -1
  32. package/lib/workflow.ts +209 -37
  33. package/lib/zip.ts +109 -0
  34. package/package.json +1 -1
  35. package/server/access.ts +96 -0
  36. package/server/agents.ts +472 -0
  37. package/server/api.ts +80 -65
  38. package/server/boot.ts +11 -0
  39. package/server/hono.ts +8 -3
  40. package/server/mcp.ts +17 -63
  41. package/server/routes.ts +19 -24
  42. package/tests/agent-mcp.test.ts +116 -0
  43. package/tests/agent-memory.test.ts +617 -0
  44. package/tests/agent-provider.test.ts +288 -0
  45. package/tests/agent-runtime.test.ts +369 -0
  46. package/tests/agent-skills.test.ts +177 -0
  47. package/tests/agent-summarize.test.ts +124 -0
  48. package/tests/agent-working-memory.test.ts +404 -0
  49. package/tests/agent.test.ts +1241 -0
  50. package/tests/agents-api.test.ts +593 -0
  51. package/tests/api-private-fields.test.ts +196 -0
  52. package/tests/api.test.ts +51 -0
  53. package/tests/attachment.test.ts +241 -0
  54. package/tests/audit-operations.test.ts +1 -1
  55. package/tests/audit-redact.test.ts +29 -3
  56. package/tests/audit-run-action.test.ts +1 -1
  57. package/tests/endpoints.test.ts +80 -0
  58. package/tests/file-collection-replication.test.ts +2 -0
  59. package/tests/fixtures/agent-tenant/agents/support.agent.ts +9 -0
  60. package/tests/fixtures/agent-tenant/agents/weather.agent.ts +21 -0
  61. package/tests/fixtures/agents-api/agents/assistant.agent.ts +46 -0
  62. package/tests/fixtures/agents-api/agents/plain.agent.ts +23 -0
  63. package/tests/fixtures/agents-api/agents/private.agent.ts +15 -0
  64. package/tests/fixtures/agents-api/collections/notes.model.ts +26 -0
  65. package/tests/fixtures/agents-api/routes/probe.route.ts +21 -0
  66. package/tests/fixtures/agents-api/services/probe.service.ts +19 -0
  67. package/tests/fixtures/attachment/data.csv +2 -0
  68. package/tests/fixtures/fake-provider.ts +128 -0
  69. package/tests/fixtures/repl-only/app.ts +3 -0
  70. package/tests/fixtures/replication-meta/agents/chat.agent.ts +16 -0
  71. package/tests/fixtures/replication-meta/collections/counters.model.ts +13 -0
  72. package/tests/fixtures/replication-meta/collections/orders.model.ts +9 -0
  73. package/tests/fixtures/replication-meta/workflows/run.workflow.ts +8 -0
  74. package/tests/fixtures/skills-tenant/agents/docs.agent.ts +23 -0
  75. package/tests/fixtures/skills-tenant/agents/incomplete.agent.ts +14 -0
  76. package/tests/fixtures/skills-tenant/broken/bad-name.md +6 -0
  77. package/tests/fixtures/skills-tenant/broken/dup-a.md +6 -0
  78. package/tests/fixtures/skills-tenant/broken/dup-b.md +6 -0
  79. package/tests/fixtures/skills-tenant/broken/empty-body.md +4 -0
  80. package/tests/fixtures/skills-tenant/broken/long-compat.md +7 -0
  81. package/tests/fixtures/skills-tenant/broken/long-description.md +6 -0
  82. package/tests/fixtures/skills-tenant/broken/mismatch/SKILL.md +6 -0
  83. package/tests/fixtures/skills-tenant/broken/no-description.md +5 -0
  84. package/tests/fixtures/skills-tenant/broken/no-name.md +5 -0
  85. package/tests/fixtures/skills-tenant/broken/plain.md +4 -0
  86. package/tests/fixtures/skills-tenant/broken/unknown-key.md +9 -0
  87. package/tests/fixtures/skills-tenant/docs/pdf-forms.md +18 -0
  88. package/tests/fixtures/skills-tenant/guide/ml.md +9 -0
  89. package/tests/fixtures/skills-tenant/guides/pdf/SKILL.md +9 -0
  90. package/tests/fixtures/skills-tenant/runbooks/incident.md +9 -0
  91. package/tests/fixtures/skills-tenant/runbooks/notes.txt +2 -0
  92. package/tests/fixtures/vars-tenant/vars/private.var.ts +4 -1
  93. package/tests/fixtures/workflow-engine/workflows/failing.workflow.ts +34 -0
  94. package/tests/fixtures/workflow-engine/workflows/flaky.workflow.ts +23 -0
  95. package/tests/fixtures/workflow-engine/workflows/hang.workflow.ts +17 -0
  96. package/tests/fixtures/workflow-engine/workflows/simple.workflow.ts +19 -0
  97. package/tests/fixtures/workflow-engine/workflows/slow.workflow.ts +23 -0
  98. package/tests/fixtures/workflow-engine/workflows/versioned.workflow.ts +19 -0
  99. package/tests/fixtures/workflow-tenant/workflows/broken.workflow.ts +3 -0
  100. package/tests/fixtures/workflow-tenant/workflows/disabled.workflow.ts +8 -0
  101. package/tests/fixtures/workflow-tenant/workflows/invoice.workflow.ts +21 -0
  102. package/tests/fixtures/workflow-tenant/workflows/no-id.workflow.ts +7 -0
  103. package/tests/fixtures/workflow-tenant/workflows/order-duplicate.workflow.ts +13 -0
  104. package/tests/fixtures/workflow-tenant/workflows/order.workflow.ts +17 -0
  105. package/tests/hono.test.ts +6 -3
  106. package/tests/locks.test.ts +120 -0
  107. package/tests/mcp.test.ts +11 -11
  108. package/tests/messages.test.ts +258 -0
  109. package/tests/replication-change-stream.test.ts +240 -0
  110. package/tests/replication-control.test.ts +4 -2
  111. package/tests/replication-initial-sync.test.ts +2 -0
  112. package/tests/replication-meta.test.ts +246 -0
  113. package/tests/replication.test.ts +2 -0
  114. package/tests/upload.test.ts +44 -12
  115. package/tests/vars-api.test.ts +1 -1
  116. package/tests/vars-replication.test.ts +5 -2
  117. package/tests/workflow-engine.test.ts +340 -0
  118. package/tests/workflow-retention.test.ts +103 -0
  119. package/tests/workflow.test.ts +186 -0
  120. package/types/agent.d.ts +1067 -0
  121. package/types/config.d.ts +16 -0
  122. package/types/file.d.ts +7 -7
  123. package/types/hook.d.ts +6 -0
  124. package/types/lifecycle.d.ts +6 -0
  125. package/types/mcp.d.ts +11 -1
  126. package/types/replication.d.ts +70 -4
  127. package/types/route.d.ts +7 -1
  128. package/types/scripts.d.ts +6 -0
  129. package/types/service.d.ts +7 -1
  130. package/types/tenant.d.ts +13 -0
  131. package/types/vars.d.ts +2 -2
  132. package/types/websocket.d.ts +6 -0
  133. package/types/workflow.d.ts +50 -3
@@ -0,0 +1,351 @@
1
+ import path from "path";
2
+ import { AppError } from "./error";
3
+ import { openZip, type ZipArchive } from "./zip";
4
+ import type { AgentAttachment, AgentMediaPart, AgentTextPart } from "../types/agent";
5
+
6
+ /**
7
+ * Attachments — images and documents handed to a model with a run.
8
+ *
9
+ * An LLM only understands three things: **text**, **images** and — for some
10
+ * models — **documents** (PDF). So every file is resolved to one of those:
11
+ *
12
+ * | Input | What the model receives |
13
+ * | --- | --- |
14
+ * | `.md` `.txt` `.json` `.csv` `.tsv` `.yaml` `.xml` `.html` `.log`, code… | the **text**, under a header naming the file |
15
+ * | `.png` `.jpg` `.jpeg` `.webp` `.gif` | an **image** part (base64) — native vision |
16
+ * | `.pdf` | a **file** part (base64) — native document input, where the model supports it |
17
+ * | `.docx` | the **text** extracted from `word/document.xml` (no dependency: the file is a ZIP) |
18
+ * | `.xlsx` | the **rows**, one CSV block per sheet, from the workbook parts |
19
+ * | `.doc` (legacy), anything else | refused — `AGENT_FILE_UNSUPPORTED` |
20
+ *
21
+ * The extraction of `.docx` / `.xlsx` is a deliberate simplification: it reads
22
+ * the text content, not the layout, the styles or the embedded objects. Good
23
+ * enough for a model; never a replacement for a document renderer.
24
+ */
25
+
26
+ /** Text formats read as-is — the cheapest and most faithful thing to do. */
27
+ const TEXT_EXTENSIONS = new Set([
28
+ "md", "markdown", "mdx", "txt", "text", "log",
29
+ "json", "jsonc", "ndjson", "csv", "tsv",
30
+ "yaml", "yml", "toml", "ini", "env",
31
+ "xml", "html", "htm", "svg",
32
+ "ts", "tsx", "js", "jsx", "mjs", "cjs", "py", "rb", "go", "rs", "java", "php",
33
+ "sql", "sh", "bash", "zsh", "css", "scss", "vue", "svelte", "graphql", "gql",
34
+ ]);
35
+
36
+ /** Image formats both providers accept natively. */
37
+ const IMAGE_MIME: Record<string, string> = {
38
+ png: "image/png",
39
+ jpg: "image/jpeg",
40
+ jpeg: "image/jpeg",
41
+ webp: "image/webp",
42
+ gif: "image/gif",
43
+ };
44
+
45
+ /** Formats the model can receive as a document part. */
46
+ const DOCUMENT_MIME: Record<string, string> = {
47
+ pdf: "application/pdf",
48
+ };
49
+
50
+ /** Extensions worth naming when we refuse a file. */
51
+ const OFFICE_BINARY: Record<string, string> = {
52
+ doc: "the legacy .doc format is not readable — convert it to .docx or PDF",
53
+ xls: "the legacy .xls format is not readable — convert it to .xlsx or CSV",
54
+ ppt: "the legacy .ppt format is not readable — convert it to .pptx or PDF",
55
+ pptx: "PowerPoint is not extracted — convert it to PDF",
56
+ };
57
+
58
+ /** One file never exceeds this — a model is not a file server. */
59
+ const MAX_FILE_BYTES = 10 * 1024 * 1024;
60
+ /** Inlined text is capped too: a 40 MB CSV must not become a 40 MB prompt. */
61
+ const MAX_TEXT_CHARS = 200_000;
62
+
63
+ function extensionOf(name: string): string {
64
+ return path.extname(name).replace(/^\./, "").toLowerCase();
65
+ }
66
+
67
+ function unsupported(name: string): AppError {
68
+ const extension = extensionOf(name);
69
+ const hint = OFFICE_BINARY[extension];
70
+ return new AppError(
71
+ hint
72
+ ? `Cannot read '${name}': ${hint}`
73
+ : `Cannot read '${name}': supported attachments are images (png, jpg, webp, gif), PDF, `
74
+ + `docx, xlsx and text formats (md, txt, json, csv, yaml, code…)`,
75
+ { status: 400, code: "AGENT_FILE_UNSUPPORTED" },
76
+ );
77
+ }
78
+
79
+ function tooLarge(name: string, size: number): AppError {
80
+ return new AppError(
81
+ `'${name}' is ${Math.round(size / 1024 / 1024)}MB — the limit is ${MAX_FILE_BYTES / 1024 / 1024}MB`,
82
+ { status: 413, code: "AGENT_FILE_TOO_LARGE" },
83
+ );
84
+ }
85
+
86
+ function toBase64(bytes: Uint8Array): string {
87
+ return Buffer.from(bytes).toString("base64");
88
+ }
89
+
90
+ /** Decode the XML entities an Office part can contain. */
91
+ function decodeEntities(text: string): string {
92
+ return text
93
+ .replace(/&lt;/g, "<")
94
+ .replace(/&gt;/g, ">")
95
+ .replace(/&quot;/g, '"')
96
+ .replace(/&apos;/g, "'")
97
+ .replace(/&#(\d+);/g, (_, code) => String.fromCharCode(Number(code)))
98
+ .replace(/&amp;/g, "&");
99
+ }
100
+
101
+ function stripTags(xml: string): string {
102
+ return decodeEntities(xml.replace(/<[^>]*>/g, ""));
103
+ }
104
+
105
+ // ─── Word ────────────────────────────────────────────────────────────────
106
+
107
+ /** `word/document.xml` → plain text (paragraphs, tabs and breaks preserved). */
108
+ function docxText(zip: ZipArchive): string {
109
+ const document = zip.read("word/document.xml");
110
+ if (!document) throw new AppError("Not a readable .docx (no word/document.xml)", {
111
+ status: 400, code: "AGENT_FILE_INVALID",
112
+ });
113
+
114
+ const xml = new TextDecoder().decode(document);
115
+ const text = xml
116
+ .replace(/<w:tab\b[^>]*\/>/g, "\t")
117
+ .replace(/<w:br\b[^>]*\/>/g, "\n")
118
+ .replace(/<\/w:p>/g, "\n")
119
+ .replace(/<[^>]*>/g, "");
120
+
121
+ return decodeEntities(text)
122
+ .replace(/[ \t]+\n/g, "\n")
123
+ .replace(/\n{3,}/g, "\n\n")
124
+ .trim();
125
+ }
126
+
127
+ // ─── Excel ───────────────────────────────────────────────────────────────
128
+
129
+ /** `xl/sharedStrings.xml` → the strings the cells refer to by index. */
130
+ function xlsxSharedStrings(zip: ZipArchive): string[] {
131
+ const shared = zip.read("xl/sharedStrings.xml");
132
+ if (!shared) return [];
133
+
134
+ const xml = new TextDecoder().decode(shared);
135
+ return [...xml.matchAll(/<si\b[^>]*>([\s\S]*?)<\/si>/g)].map((match) =>
136
+ [...(match[1] ?? "").matchAll(/<t\b[^>]*>([\s\S]*?)<\/t>/g)]
137
+ .map((run) => decodeEntities(run[1] ?? ""))
138
+ .join(""));
139
+ }
140
+
141
+ /** `B7` → 1 (0-based column index), so gaps in a row are preserved. */
142
+ function columnOf(reference: string): number {
143
+ const letters = /^([A-Z]+)/.exec(reference)?.[1] ?? "";
144
+ let index = 0;
145
+ for (const letter of letters) index = index * 26 + (letter.charCodeAt(0) - 64);
146
+ return Math.max(0, index - 1);
147
+ }
148
+
149
+ function csvCell(value: string): string {
150
+ return /[",\n]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value;
151
+ }
152
+
153
+ /** One worksheet → CSV rows (values, not formulas — a model reads values). */
154
+ function xlsxSheet(xml: string, shared: string[]): string[] {
155
+ const rows: string[] = [];
156
+
157
+ for (const rowMatch of xml.matchAll(/<row\b[^>]*>([\s\S]*?)<\/row>/g)) {
158
+ const cells: string[] = [];
159
+ for (const cellMatch of (rowMatch[1] ?? "").matchAll(/<c\b([^>]*)>([\s\S]*?)<\/c>/g)) {
160
+ const attributes = cellMatch[1] ?? "";
161
+ const body = cellMatch[2] ?? "";
162
+ const type = /\bt="([^"]+)"/.exec(attributes)?.[1];
163
+ const reference = /\br="([^"]+)"/.exec(attributes)?.[1] ?? "";
164
+ const position = reference ? columnOf(reference) : cells.length;
165
+
166
+ let value = "";
167
+ if (type === "s") {
168
+ const index = Number(stripTags(/\<v\>([\s\S]*?)\<\/v\>/.exec(body)?.[1] ?? ""));
169
+ value = shared[index] ?? "";
170
+ } else if (type === "inlineStr") {
171
+ value = [...body.matchAll(/<t\b[^>]*>([\s\S]*?)<\/t>/g)]
172
+ .map((run) => decodeEntities(run[1] ?? "")).join("");
173
+ } else {
174
+ value = decodeEntities(/\<v\>([\s\S]*?)\<\/v\>/.exec(body)?.[1] ?? "");
175
+ }
176
+
177
+ while (cells.length < position) cells.push("");
178
+ cells[position] = value;
179
+ }
180
+ rows.push(cells.map(csvCell).join(","));
181
+ }
182
+
183
+ return rows;
184
+ }
185
+
186
+ /** Every worksheet, in tab order, as `### <sheet name>` + CSV rows. */
187
+ function xlsxText(zip: ZipArchive): string {
188
+ const sheets = zip.names()
189
+ .filter((name) => /^xl\/worksheets\/sheet\d+\.xml$/.test(name))
190
+ .sort((a, b) => Number(/(\d+)\.xml$/.exec(a)?.[1]) - Number(/(\d+)\.xml$/.exec(b)?.[1]));
191
+
192
+ if (!sheets.length) throw new AppError("Not a readable .xlsx (no worksheet)", {
193
+ status: 400, code: "AGENT_FILE_INVALID",
194
+ });
195
+
196
+ const shared = xlsxSharedStrings(zip);
197
+
198
+ return sheets.map((sheet, index) => {
199
+ const xml = new TextDecoder().decode(zip.read(sheet) ?? new Uint8Array());
200
+ return `### Sheet ${index + 1}\n\n${xlsxSheet(xml, shared).join("\n")}`;
201
+ }).join("\n\n");
202
+ }
203
+
204
+ // ─── Resolution ──────────────────────────────────────────────────────────
205
+
206
+ /** What a caller can attach — a path (server-side), a `Blob`/`File`, raw bytes, or base64. */
207
+ export type NormalizedAttachment = {
208
+ name: string;
209
+ mimeType: string;
210
+ bytes: Uint8Array;
211
+ };
212
+
213
+ /**
214
+ * Read what the caller attached. A string is a **path** on the server (read
215
+ * now, at call time) — the SDK sends base64 instead, since a browser has no
216
+ * filesystem.
217
+ */
218
+ async function materialize(attachment: AgentAttachment, baseDir: string): Promise<NormalizedAttachment> {
219
+ if (typeof attachment === "string") {
220
+ const file = path.resolve(baseDir, attachment);
221
+ if (!(await Bun.file(file).exists())) {
222
+ throw new AppError(`Attachment '${attachment}' not found`, { status: 400, code: "AGENT_FILE_NOT_FOUND" });
223
+ }
224
+ const bytes = new Uint8Array(await Bun.file(file).arrayBuffer());
225
+ return { name: path.basename(file), mimeType: "", bytes };
226
+ }
227
+
228
+ if (attachment instanceof Uint8Array) {
229
+ return { name: "attachment", mimeType: "", bytes: attachment };
230
+ }
231
+
232
+ if (typeof Blob !== "undefined" && attachment instanceof Blob) {
233
+ const name = (attachment as File).name ?? "attachment";
234
+ return {
235
+ name,
236
+ mimeType: (attachment as File).type ?? "",
237
+ bytes: new Uint8Array(await attachment.arrayBuffer()),
238
+ };
239
+ }
240
+
241
+ const explicit = attachment as { name?: string; mimeType?: string; data: string | Uint8Array; encoding?: "base64" | "utf8" };
242
+ const name = explicit.name ?? "attachment";
243
+ if (explicit.data instanceof Uint8Array) {
244
+ return { name, mimeType: explicit.mimeType ?? "", bytes: explicit.data };
245
+ }
246
+
247
+ const encoding = explicit.encoding ?? "base64";
248
+ return {
249
+ name,
250
+ mimeType: explicit.mimeType ?? "",
251
+ bytes: encoding === "utf8"
252
+ ? new TextEncoder().encode(explicit.data)
253
+ : new Uint8Array(Buffer.from(explicit.data, "base64")),
254
+ };
255
+ }
256
+
257
+ /**
258
+ * Turn attachments into **content parts** the providers understand.
259
+ *
260
+ * @returns the parts to append to the current user turn (`[]` when there is
261
+ * nothing to attach). Throws `AGENT_FILE_*` when a file cannot be sent — a
262
+ * silently dropped attachment is worse than a loud failure.
263
+ */
264
+ async function resolveAttachments(
265
+ attachments: AgentAttachment | AgentAttachment[] | undefined,
266
+ options: { baseDir?: string } = {},
267
+ ): Promise<AgentMediaPart[]> {
268
+ if (!attachments) return [];
269
+ const list = Array.isArray(attachments) ? attachments : [attachments];
270
+ if (!list.length) return [];
271
+
272
+ const baseDir = options.baseDir ?? process.cwd();
273
+ const parts: AgentMediaPart[] = [];
274
+
275
+ for (const attachment of list) {
276
+ const file = await materialize(attachment, baseDir);
277
+ if (file.bytes.byteLength > MAX_FILE_BYTES) throw tooLarge(file.name, file.bytes.byteLength);
278
+
279
+ const extension = extensionOf(file.name);
280
+ const declared = file.mimeType.toLowerCase();
281
+ const mimeType = declared || IMAGE_MIME[extension] || DOCUMENT_MIME[extension] || "";
282
+
283
+ // 1. Images — native vision on both protocols
284
+ const imageMime = IMAGE_MIME[extension] ?? (declared.startsWith("image/") ? declared : "");
285
+ if (imageMime) {
286
+ parts.push({ type: "image", data: toBase64(file.bytes), mimeType: imageMime });
287
+ continue;
288
+ }
289
+
290
+ // 2/3. PDF — a document part (the provider decides what it does with it)
291
+ const documentMime = DOCUMENT_MIME[extension] ?? (declared === "application/pdf" ? declared : "");
292
+ if (documentMime) {
293
+ parts.push({ type: "file", name: file.name, mimeType: documentMime, data: toBase64(file.bytes) });
294
+ continue;
295
+ }
296
+
297
+ // 4. Office documents — extracted to text, no dependency
298
+ if (extension === "docx" || extension === "xlsx") {
299
+ const text = extension === "docx"
300
+ ? docxText(openZip(file.bytes))
301
+ : xlsxText(openZip(file.bytes));
302
+ parts.push({ type: "text", text: textPart(file.name, text) });
303
+ continue;
304
+ }
305
+
306
+ // 5. Text formats — read as-is
307
+ if (TEXT_EXTENSIONS.has(extension) || declared.startsWith("text/") || declared === "application/json") {
308
+ const text = new TextDecoder().decode(file.bytes);
309
+ parts.push({ type: "text", text: textPart(file.name, text) });
310
+ continue;
311
+ }
312
+
313
+ throw unsupported(file.name);
314
+ }
315
+
316
+ return parts;
317
+ }
318
+
319
+ /** A file's text under a header naming it — the model must know where it comes from. */
320
+ function textPart(name: string, text: string): string {
321
+ const trimmed = text.trim();
322
+ if (trimmed.length <= MAX_TEXT_CHARS) return `--- ${name} ---\n${trimmed}`;
323
+ return `--- ${name} (truncated) ---\n${trimmed.slice(0, MAX_TEXT_CHARS)}`;
324
+ }
325
+
326
+ /**
327
+ * What a **memory** keeps of an attachment. Conversations are text: storing a
328
+ * 5 MB image in every turn would fill the store for nothing, and the model
329
+ * already read it once. The reply and the question stay verbatim.
330
+ */
331
+ function attachmentNote(part: AgentMediaPart): string {
332
+ if (part.type === "image") return "[image]";
333
+ if (part.type === "file") return `[file ${part.name ?? "document"}]`;
334
+ const header = /^--- (.+?) ---/.exec(part.text)?.[1];
335
+ return header ? `[file ${header}]` : "";
336
+ }
337
+
338
+ /** A part as it should be *stored* — the heavy payloads replaced by a note. */
339
+ function forMemory(part: AgentMediaPart): AgentTextPart {
340
+ if (part.type === "text") return part;
341
+ return { type: "text", text: attachmentNote(part) };
342
+ }
343
+
344
+ export {
345
+ MAX_FILE_BYTES,
346
+ MAX_TEXT_CHARS,
347
+ docxText,
348
+ forMemory,
349
+ resolveAttachments,
350
+ xlsxText,
351
+ };
@@ -107,6 +107,19 @@ function summarizeAuditResult(action: string, result: any): any {
107
107
  return typeof result === 'string' ? result : null
108
108
  case 'dropIndexes':
109
109
  return result?.nIndexesWas !== undefined ? { nIndexesWas: result.nIndexesWas } : null
110
+ case 'agent.generate':
111
+ case 'agent.stream':
112
+ case 'agent.object':
113
+ // The model's answer is never stored — only the envelope that proves the
114
+ // run happened and what it cost (nothing the caller did not already send).
115
+ return result ? {
116
+ finishReason: result.finishReason ?? null,
117
+ usage: result.usage ?? null,
118
+ tools: Array.isArray(result.toolCalls)
119
+ ? result.toolCalls.map((call: any) => call?.name).filter(Boolean)
120
+ : [],
121
+ steps: Array.isArray(result.steps) ? result.steps.length : null,
122
+ } : null
110
123
  default:
111
124
  // Anything unknown: a document must never be stored.
112
125
  return null
package/lib/define.ts CHANGED
@@ -159,14 +159,54 @@ function Middleware(config: GlobalMiddlewareConfig): GlobalMiddlewareConfig {
159
159
  }
160
160
 
161
161
  import type { WorkflowDefinition } from "../types/workflow";
162
+ import type { AgentConfig, AgentTool, AgentToolDefinition } from "../types/agent";
162
163
 
163
164
  export function Workflow<TData = any>(workflow: WorkflowDefinition<TData>): WorkflowDefinition<TData> {
164
165
  return {
165
166
  ...workflow,
167
+ enabled: workflow.enabled ?? true,
166
168
  _isWorkflow_: true,
167
169
  }
168
170
  }
169
171
 
172
+ /**
173
+ * Declares an LLM agent — `{tenant.dir}/agents/**\/*.agent.ts`.
174
+ *
175
+ * `id` and `description` are **required**: the id identifies it (and looks it up),
176
+ * the description says what it is for — it is served by `info` and read by humans.
177
+ *
178
+ * @example
179
+ * ```ts
180
+ * export default define.Agent({
181
+ * id: 'weather',
182
+ * description: 'Answers weather questions with a live forecast.',
183
+ * instructions: 'You are a concise weather assistant.',
184
+ * provider: { model: 'gpt-4o-mini', protocol: 'openai' },
185
+ * tools: { forecast },
186
+ * })
187
+ * ```
188
+ */
189
+ function Agent(config: AgentConfig & { id: string; description: string }): AgentConfig {
190
+ return {
191
+ ...config,
192
+ enabled: config.enabled ?? true,
193
+ _isAgent_: true,
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Declares an agent tool — `define.Tool({ id, description, inputSchema, execute })`.
199
+ * The `id` is **required** (it is the name the model calls).
200
+ * A `define.McpTool` is accepted by an agent as-is.
201
+ */
202
+ function Tool(tool: AgentToolDefinition): AgentToolDefinition {
203
+ return {
204
+ ...tool,
205
+ enabled: tool.enabled ?? true,
206
+ _isTool_: true,
207
+ }
208
+ }
209
+
170
210
 
171
211
 
172
212
  export const define = {
@@ -186,5 +226,7 @@ export const define = {
186
226
  McpTool,
187
227
  McpResource,
188
228
  Lifecycle,
189
- Vars
229
+ Vars,
230
+ Agent,
231
+ Tool,
190
232
  }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * The URL of everything — one place.
3
+ *
4
+ * Every framework surface lives under a **single namespace**, `/api/:tenant_id`,
5
+ * and a **family** segment says what it is:
6
+ *
7
+ * | Surface | Route |
8
+ * | --- | --- |
9
+ * | Collections (CRUD, actions, login…) | `POST /api/:tenant_id/collections/:collection/:action` |
10
+ * | Services | `POST /api/:tenant_id/services/:service/:action` |
11
+ * | Variables | `POST /api/:tenant_id/vars/:action` |
12
+ * | Upload | `POST /api/:tenant_id/upload/:collection` |
13
+ * | Files (serve, delete) | `GET\|DELETE /api/:tenant_id/files/:collection/:file` |
14
+ * | Agents | `POST /api/:tenant_id/agents/:agent/:action` |
15
+ * | MCP (tools & resources) | `GET\|POST /api/:tenant_id/mcp` |
16
+ *
17
+ * Two reasons for the shape. A tenant's own routes (`routes: { prefix: '/api/v1' }`)
18
+ * are mounted **before** this one, so they always win — and nothing is ambiguous:
19
+ * a collection may be called `services` or `login` without ever colliding with the
20
+ * family segment, since a collection always sits under `/collections/`.
21
+ *
22
+ * `patterns` is what the server registers (Hono patterns); `endpoints` is what a
23
+ * client builds — the two are read together, so a path is never written twice.
24
+ */
25
+
26
+ /** The family segments — the component right after the tenant id. */
27
+ const FAMILIES = ["collections", "services", "vars", "upload", "files", "agents", "mcp"] as const;
28
+
29
+ const patterns = {
30
+ collection: "/api/:tenant_id/collections/:collection/:action",
31
+ /** The login action of an auth-enabled collection — its own rate limit. */
32
+ login: "/api/:tenant_id/collections/:collection/login",
33
+ service: "/api/:tenant_id/services/:service/:action",
34
+ vars: "/api/:tenant_id/vars/:action",
35
+ upload: "/api/:tenant_id/upload/:collection",
36
+ file: "/api/:tenant_id/files/:collection/:file",
37
+ agent: "/api/:tenant_id/agents/:agent/:action",
38
+ mcp: "/api/:tenant_id/mcp",
39
+ } as const;
40
+
41
+ /** One path segment, encoded. */
42
+ function segment(value: string): string {
43
+ return encodeURIComponent(String(value));
44
+ }
45
+
46
+ const endpoints = {
47
+ /** Everything of a collection — `find`, `insertOne`, `login`, a custom action… */
48
+ collection: (tenant: string, collection: string, action: string) =>
49
+ `/api/${segment(tenant)}/collections/${segment(collection)}/${segment(action)}`,
50
+
51
+ service: (tenant: string, service: string, action: string) =>
52
+ `/api/${segment(tenant)}/services/${segment(service)}/${segment(action)}`,
53
+
54
+ /** Variables — the namespace and the key travel in the body. */
55
+ vars: (tenant: string, action: string) =>
56
+ `/api/${segment(tenant)}/vars/${segment(action)}`,
57
+
58
+ upload: (tenant: string, collection: string) =>
59
+ `/api/${segment(tenant)}/upload/${segment(collection)}`,
60
+
61
+ /** One file of a file collection — the document id, or its filename. */
62
+ file: (tenant: string, collection: string, file: string) =>
63
+ `/api/${segment(tenant)}/files/${segment(collection)}/${segment(file)}`,
64
+
65
+ agent: (tenant: string, agent: string, action: string) =>
66
+ `/api/${segment(tenant)}/agents/${segment(agent)}/${segment(action)}`,
67
+
68
+ mcp: (tenant: string) => `/api/${segment(tenant)}/mcp`,
69
+ } as const;
70
+
71
+ /**
72
+ * Is this a segment the framework owns? A tenant route mounted at
73
+ * `/api/:tenant_id/<family>/…` is registered **first** and would shadow the
74
+ * framework API silently — the route loader warns about it.
75
+ */
76
+ function isReservedFamily(name: string): boolean {
77
+ return (FAMILIES as readonly string[]).includes(name);
78
+ }
79
+
80
+ export { endpoints, FAMILIES, isReservedFamily, patterns, segment };
package/lib/files.ts CHANGED
@@ -5,6 +5,7 @@ import { toBson } from "../utils/func";
5
5
  import { useRest } from "../database/rest";
6
6
  import { cfg } from "../server/config";
7
7
  import { AppError } from "./error";
8
+ import { endpoints } from "./endpoints";
8
9
  import { S3Client } from "bun";
9
10
  import type { FileCollection } from "../types/file";
10
11
  import path from "path";
@@ -360,7 +361,7 @@ export async function handleUpload(options: UploadOptions): Promise<FileResult>
360
361
  const { path: filepath, size } = await storage.save(tenant_id, collection, file, { id: _id, mimetype, subpath });
361
362
 
362
363
  // Update MongoDB with actual file info
363
- const url = `/files/${tenant_id}/${collection}/${filename}`;
364
+ const url = endpoints.file(tenant_id, collection, filename);
364
365
  try {
365
366
  const rest = new useRest({ tenant_id, internal: true, useHook: false, useCustomApi: false });
366
367
  await rest.db.collection(collection).updateOne(