@anteros/core 0.0.41 → 0.0.42

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 (102) 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 +110 -32
  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 +1327 -0
  11. package/lib/agentMemory.ts +586 -0
  12. package/lib/agents.ts +157 -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/messages.ts +299 -0
  25. package/lib/providers.ts +867 -0
  26. package/lib/routes.ts +14 -1
  27. package/lib/scriptWorker.ts +4 -1
  28. package/lib/scripts.ts +6 -1
  29. package/lib/sockets.ts +3 -1
  30. package/lib/workflow.ts +209 -37
  31. package/lib/zip.ts +109 -0
  32. package/package.json +1 -1
  33. package/server/access.ts +96 -0
  34. package/server/agents.ts +451 -0
  35. package/server/api.ts +14 -72
  36. package/server/boot.ts +11 -0
  37. package/server/hono.ts +8 -3
  38. package/server/mcp.ts +17 -63
  39. package/server/routes.ts +19 -24
  40. package/tests/agent-memory.test.ts +617 -0
  41. package/tests/agent-working-memory.test.ts +402 -0
  42. package/tests/agent.test.ts +1239 -0
  43. package/tests/agents-api.test.ts +593 -0
  44. package/tests/api.test.ts +51 -0
  45. package/tests/attachment.test.ts +241 -0
  46. package/tests/audit-operations.test.ts +1 -1
  47. package/tests/audit-redact.test.ts +29 -3
  48. package/tests/audit-run-action.test.ts +1 -1
  49. package/tests/endpoints.test.ts +80 -0
  50. package/tests/fixtures/agent-tenant/agents/support.agent.ts +9 -0
  51. package/tests/fixtures/agent-tenant/agents/weather.agent.ts +21 -0
  52. package/tests/fixtures/agents-api/agents/assistant.agent.ts +46 -0
  53. package/tests/fixtures/agents-api/agents/plain.agent.ts +23 -0
  54. package/tests/fixtures/agents-api/agents/private.agent.ts +15 -0
  55. package/tests/fixtures/agents-api/collections/notes.model.ts +26 -0
  56. package/tests/fixtures/agents-api/routes/probe.route.ts +21 -0
  57. package/tests/fixtures/agents-api/services/probe.service.ts +19 -0
  58. package/tests/fixtures/attachment/data.csv +2 -0
  59. package/tests/fixtures/fake-provider.ts +128 -0
  60. package/tests/fixtures/replication-meta/agents/chat.agent.ts +16 -0
  61. package/tests/fixtures/replication-meta/collections/counters.model.ts +13 -0
  62. package/tests/fixtures/replication-meta/collections/orders.model.ts +9 -0
  63. package/tests/fixtures/replication-meta/workflows/run.workflow.ts +8 -0
  64. package/tests/fixtures/vars-tenant/vars/private.var.ts +4 -1
  65. package/tests/fixtures/workflow-engine/workflows/failing.workflow.ts +34 -0
  66. package/tests/fixtures/workflow-engine/workflows/flaky.workflow.ts +23 -0
  67. package/tests/fixtures/workflow-engine/workflows/hang.workflow.ts +17 -0
  68. package/tests/fixtures/workflow-engine/workflows/simple.workflow.ts +19 -0
  69. package/tests/fixtures/workflow-engine/workflows/slow.workflow.ts +23 -0
  70. package/tests/fixtures/workflow-engine/workflows/versioned.workflow.ts +19 -0
  71. package/tests/fixtures/workflow-tenant/workflows/broken.workflow.ts +3 -0
  72. package/tests/fixtures/workflow-tenant/workflows/disabled.workflow.ts +8 -0
  73. package/tests/fixtures/workflow-tenant/workflows/invoice.workflow.ts +21 -0
  74. package/tests/fixtures/workflow-tenant/workflows/no-id.workflow.ts +7 -0
  75. package/tests/fixtures/workflow-tenant/workflows/order-duplicate.workflow.ts +13 -0
  76. package/tests/fixtures/workflow-tenant/workflows/order.workflow.ts +17 -0
  77. package/tests/hono.test.ts +6 -3
  78. package/tests/locks.test.ts +120 -0
  79. package/tests/mcp.test.ts +11 -11
  80. package/tests/messages.test.ts +258 -0
  81. package/tests/replication-control.test.ts +2 -2
  82. package/tests/replication-meta.test.ts +243 -0
  83. package/tests/upload.test.ts +44 -12
  84. package/tests/vars-api.test.ts +1 -1
  85. package/tests/vars-replication.test.ts +3 -2
  86. package/tests/workflow-engine.test.ts +340 -0
  87. package/tests/workflow-retention.test.ts +103 -0
  88. package/tests/workflow.test.ts +186 -0
  89. package/types/agent.d.ts +837 -0
  90. package/types/config.d.ts +16 -0
  91. package/types/file.d.ts +7 -7
  92. package/types/hook.d.ts +6 -0
  93. package/types/lifecycle.d.ts +6 -0
  94. package/types/mcp.d.ts +11 -1
  95. package/types/replication.d.ts +29 -0
  96. package/types/route.d.ts +7 -1
  97. package/types/scripts.d.ts +6 -0
  98. package/types/service.d.ts +7 -1
  99. package/types/tenant.d.ts +13 -0
  100. package/types/vars.d.ts +2 -2
  101. package/types/websocket.d.ts +6 -0
  102. package/types/workflow.d.ts +50 -3
@@ -0,0 +1,451 @@
1
+ import type { Hono } from "hono";
2
+ import { cfg } from "./config";
3
+ import { AppError } from "../lib/error";
4
+ import { useRest } from "../database/rest";
5
+ import { createAgents } from "../lib/agents";
6
+ import { getTenant } from "../database/tenant";
7
+ import { requestCtxStorage } from "../lib/asyncContextStorage";
8
+ import { evaluateAccess, errorResponse } from "./access";
9
+ import { patterns } from "../lib/endpoints";
10
+ import type { HonoVariables } from "./env";
11
+ import type { Agent } from "../lib/agent";
12
+ import type {
13
+ AgentCallOptions,
14
+ AgentGenerateResult,
15
+ AgentInput,
16
+ AgentMessageInput,
17
+ } from "../types/agent";
18
+
19
+ /**
20
+ * Agents HTTP API — `POST /api/:tenant_id/agents/:agent/:action`.
21
+ *
22
+ * Same shape as the services API (`/api/:tenant_id/services/:service/:action`) and
23
+ * the same access model (`api.access`, **no rules = denied**).
24
+ *
25
+ * ```ts
26
+ * // define.Agent({ api: { access: { generate: (ctx) => !!ctx.token.value } } })
27
+ * POST /api/v1/agents/support/generate { "input": "Where is my order?" }
28
+ * POST /api/v1/agents/support/stream { "input": "…", "thread": "user-42" } → SSE
29
+ * ```
30
+ *
31
+ * A client can only send **what it is allowed to choose**: the input, a memory
32
+ * thread, injected turns and a *lower* `maxSteps` / `lastMessages`. The provider
33
+ * (model, endpoint, key), the options and the structured-output schema stay
34
+ * server-side — changing the model or the endpoint of a declared agent is a
35
+ * deployment decision, not a request one.
36
+ */
37
+
38
+ const AGENT_PREFIX = patterns.agent;
39
+
40
+ /** What an HTTP call may carry. Nothing else is read. */
41
+ type AgentRequestBody = {
42
+ /** A string (one user turn), one message, or a whole conversation. */
43
+ input?: string | AgentMessageInput | AgentMessageInput[];
44
+ /**
45
+ * Turns **injected** into the run — after the thread, before the `input`. They
46
+ * are sent to the model *and* saved with the rest of the conversation.
47
+ */
48
+ messages?: AgentMessageInput | AgentMessageInput[];
49
+ /** Memory thread. */
50
+ thread?: string;
51
+ /** @deprecated Alias of `thread`. */
52
+ threadId?: string;
53
+ /** Opaque caller identity, forwarded to the instructions function. */
54
+ resource?: string;
55
+ /** @deprecated Alias of `resource`. */
56
+ resourceId?: string;
57
+ /** Lower the run's step ceiling (never raise it). */
58
+ maxSteps?: number;
59
+ /** Lower the replayed-history window (never raise it past the declaration). */
60
+ lastMessages?: number;
61
+ /** `threads` — how many threads to return (default 20). */
62
+ limit?: number;
63
+ /**
64
+ * Files and images sent with the run — base64, a `File`/`Blob` never crosses the
65
+ * wire as such. `{ name: 'invoice.pdf', data: '<base64>' }`.
66
+ */
67
+ files?: Array<{
68
+ /** File name — its extension decides how it is read (`report.pdf`, `data.csv`, `photo.png`). */
69
+ name: string;
70
+ mimeType?: string;
71
+ /** Base64 (default) or UTF-8 text with `encoding: 'utf8'`. */
72
+ data: string;
73
+ encoding?: "base64" | "utf8";
74
+ }>;
75
+ /** Read the thread without writing the run back. */
76
+ readOnly?: boolean;
77
+ /** Mastra-style shorthand — `memory: { thread, resource, readOnly }`. */
78
+ memory?: {
79
+ thread?: string;
80
+ /** @deprecated Alias of `thread`. */
81
+ threadId?: string;
82
+ resource?: string;
83
+ /** @deprecated Alias of `resource`. */
84
+ resourceId?: string;
85
+ readOnly?: boolean;
86
+ };
87
+ };
88
+
89
+ /** `memory.thread` first, then the flat `thread` (and its `threadId` alias). */
90
+ function resolveThread(body: AgentRequestBody | undefined): string | undefined {
91
+ const thread = body?.memory?.thread ?? body?.memory?.threadId ?? body?.thread ?? body?.threadId;
92
+ return typeof thread === 'string' && thread.trim() ? thread : undefined;
93
+ }
94
+
95
+ /** `memory.resource` first, then the flat `resource` (and its `resourceId` alias). */
96
+ function resolveResource(body: AgentRequestBody | undefined): string | undefined {
97
+ const resource = body?.memory?.resource ?? body?.memory?.resourceId ?? body?.resource ?? body?.resourceId;
98
+ return typeof resource === 'string' && resource.trim() ? resource : undefined;
99
+ }
100
+
101
+ /** Cap on the prompt kept in the audit trail. */
102
+ const AUDIT_INPUT_MAX = 2000;
103
+
104
+ /**
105
+ * The input of a run — a string, **one** message, or a whole conversation. The
106
+ * shape itself (roles, content parts, tool parts) is validated by
107
+ * `normalizeMessages`, shared with the in-process runtime: the two cannot drift.
108
+ */
109
+ function normalizeInput(body: AgentRequestBody | undefined): AgentInput {
110
+ const input = body?.input;
111
+ if (typeof input === "string") {
112
+ if (!input.trim()) throw new AppError('`input` is required', { status: 400, code: 'INPUT_REQUIRED' });
113
+ return input;
114
+ }
115
+ if (Array.isArray(input)) {
116
+ if (!input.length) throw new AppError('`input` is required', { status: 400, code: 'INPUT_REQUIRED' });
117
+ return input as AgentInput;
118
+ }
119
+ if (input && typeof input === "object" && typeof (input as any).role === "string") {
120
+ return input as AgentInput;
121
+ }
122
+ throw new AppError('`input` is required (a string, a message or an array of messages)', {
123
+ status: 400, code: 'INPUT_REQUIRED',
124
+ });
125
+ }
126
+
127
+ function requireThread(body: AgentRequestBody | undefined): string {
128
+ const thread = resolveThread(body);
129
+ if (!thread) {
130
+ throw new AppError('`thread` is required (or `memory.thread`)', { status: 400, code: 'THREAD_ID_REQUIRED' });
131
+ }
132
+ return thread;
133
+ }
134
+
135
+ /** A client may lower the step ceiling, never raise it — the cost is the tenant's. */
136
+ function resolveMaxSteps(requested: unknown, agent: Agent): number {
137
+ const declared = agent.getConfig().maxSteps ?? 5;
138
+ const asked = Number(requested);
139
+ if (!Number.isFinite(asked) || asked <= 0) return declared;
140
+ return Math.max(1, Math.min(Math.floor(asked), declared));
141
+ }
142
+
143
+ /**
144
+ * The history window of a request: a client may shrink the prompt, never grow it
145
+ * past what the agent declares (with no declaration, the store cap is the only
146
+ * bound that matters — and it is already applied on read).
147
+ */
148
+ function resolveLastMessages(requested: unknown, agent: Agent): number | undefined {
149
+ const declared = agent.getConfig().lastMessages;
150
+ const asked = Number(requested);
151
+ if (!Number.isFinite(asked) || asked <= 0) return declared;
152
+ if (!declared) return Math.floor(asked);
153
+ return Math.max(1, Math.min(Math.floor(asked), declared));
154
+ }
155
+
156
+ /** The answer's envelope — the conversation itself is available through `history`. */
157
+ function presentResult(result: AgentGenerateResult) {
158
+ return {
159
+ text: result.text,
160
+ // Same envelope as `generate()` in-process: the reasoning travels with the run
161
+ ...(result.reasoning ? { reasoning: result.reasoning } : {}),
162
+ toolCalls: result.toolCalls,
163
+ toolResults: result.toolResults,
164
+ steps: result.steps,
165
+ usage: result.usage,
166
+ finishReason: result.finishReason,
167
+ };
168
+ }
169
+
170
+ /** The prompt is a parameter — capped, and redacted like any other payload. */
171
+ function auditInput(input: AgentInput | undefined) {
172
+ if (typeof input === 'string') {
173
+ return input.length > AUDIT_INPUT_MAX
174
+ ? { text: input.slice(0, AUDIT_INPUT_MAX), length: input.length, truncated: true }
175
+ : { text: input };
176
+ }
177
+ return { messages: Array.isArray(input) ? input.length : 1 };
178
+ }
179
+
180
+ type AuditOptions = {
181
+ rest: InstanceType<typeof useRest>;
182
+ agentId: string;
183
+ action: string;
184
+ input?: AgentInput;
185
+ thread?: string;
186
+ result?: AgentGenerateResult;
187
+ error?: { message: string; code?: string };
188
+ started: number;
189
+ };
190
+
191
+ /** One audit entry per call, at the same place as `_vars_:<ns>` — `_agents_:<id>`. */
192
+ async function auditAgent({
193
+ rest, agentId, action, input, thread, result, error, started,
194
+ }: AuditOptions): Promise<void> {
195
+ await rest.audit.log({
196
+ action: `agent.${action}`,
197
+ collection: `_agents_:${agentId}`,
198
+ input: { agent: agentId, thread, ...auditInput(input) },
199
+ // Never the answer, never the tool arguments: the envelope only.
200
+ result: result
201
+ ? {
202
+ finishReason: result.finishReason,
203
+ usage: result.usage,
204
+ toolCalls: result.toolCalls.map((call) => ({ name: call.name })),
205
+ steps: result.steps,
206
+ }
207
+ : undefined,
208
+ error,
209
+ duration: Date.now() - started,
210
+ }).catch(() => {});
211
+ }
212
+
213
+ /** The streamed answer is one SSE event per runtime chunk, then a `done` event. */
214
+ function streamResponse(
215
+ c: any,
216
+ agent: Agent,
217
+ input: AgentInput,
218
+ callOptions: AgentCallOptions,
219
+ audit: Omit<AuditOptions, 'input' | 'thread' | 'result' | 'error'>,
220
+ ): Response {
221
+ const encoder = new TextEncoder();
222
+ const stream = agent.stream(input, callOptions);
223
+
224
+ const body = new ReadableStream<Uint8Array>({
225
+ async start(controller) {
226
+ const send = (payload: any) => {
227
+ try {
228
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(payload)}\n\n`));
229
+ } catch {
230
+ // The client is gone — the run is already aborted by its signal.
231
+ }
232
+ };
233
+
234
+ try {
235
+ for await (const chunk of stream.fullStream) send(chunk);
236
+
237
+ const result = {
238
+ text: await stream.text,
239
+ object: await stream.object,
240
+ toolCalls: await stream.toolCalls,
241
+ toolResults: await stream.toolResults,
242
+ steps: await stream.steps,
243
+ usage: await stream.usage,
244
+ finishReason: await stream.finishReason,
245
+ messages: [],
246
+ } as AgentGenerateResult;
247
+
248
+ send({
249
+ type: 'done',
250
+ result: presentResult(result),
251
+ ...(result.object !== undefined ? { object: result.object } : {}),
252
+ });
253
+ await auditAgent({ ...audit, result });
254
+ } catch (err: any) {
255
+ send({
256
+ type: 'error',
257
+ message: err?.message ?? 'Agent stream failed',
258
+ code: err?.code ?? 'INTERNAL_AGENT_ERROR',
259
+ });
260
+ await auditAgent({
261
+ ...audit,
262
+ error: { message: err?.message, code: err?.code || 'INTERNAL_AGENT_ERROR' },
263
+ });
264
+ } finally {
265
+ try { controller.close(); } catch { /* already closed */ }
266
+ }
267
+ },
268
+ cancel() {
269
+ // Client disconnected mid-run: stop paying for tokens.
270
+ stream.abort('client disconnected');
271
+ },
272
+ });
273
+
274
+ return new Response(body, {
275
+ headers: {
276
+ 'Content-Type': 'text/event-stream; charset=utf-8',
277
+ 'Cache-Control': 'no-cache, no-transform',
278
+ 'Connection': 'keep-alive',
279
+ // nginx: never buffer an event stream
280
+ 'X-Accel-Buffering': 'no',
281
+ },
282
+ });
283
+ }
284
+
285
+ function initializeAgents(app: Hono<{ Variables: HonoVariables }>) {
286
+ app.post(AGENT_PREFIX, async (c) => {
287
+ const started = Date.now();
288
+ let rest: InstanceType<typeof useRest> | undefined;
289
+ let tenant_id = '';
290
+ let agentId = '';
291
+ let action = '';
292
+
293
+ try {
294
+ const contentType = c.req.header('Content-Type');
295
+ let body: AgentRequestBody | undefined;
296
+ if (contentType?.includes('application/json')) {
297
+ // `info` legitimately carries no body: an empty one is `{}`, a
298
+ // malformed one is an error (never silently ignored).
299
+ const raw = await c.req.text();
300
+ if (raw.trim()) {
301
+ try {
302
+ body = JSON.parse(raw);
303
+ } catch {
304
+ throw new AppError('Invalid JSON body', { status: 400, code: 'INVALID_JSON_BODY' });
305
+ }
306
+ }
307
+ }
308
+
309
+ const params = c.req.param() as { tenant_id: string; agent: string; action: string };
310
+ tenant_id = params.tenant_id;
311
+ agentId = params.agent;
312
+ action = params.action;
313
+
314
+ if (!tenant_id) throw new AppError('Tenant ID is required', { status: 400, code: 'TENANT_ID_REQUIRED' });
315
+ if (!agentId) throw new AppError('Agent is required', { status: 400, code: 'AGENT_REQUIRED' });
316
+ if (!action) throw new AppError('Action is required', { status: 400, code: 'ACTION_REQUIRED' });
317
+ if (!getTenant(tenant_id)) throw new AppError('Tenant not found', { status: 400, code: 'TENANT_NOT_FOUND' });
318
+
319
+ requestCtxStorage.set('tenant_id', tenant_id);
320
+
321
+ // Only agents declared with `define.Agent` are reachable, and their
322
+ // `api.access` gates every action (no rules = denied).
323
+ rest = new useRest({ internal: false, tenant_id });
324
+ const agent = createAgents(tenant_id, rest).get(agentId);
325
+ if (!agent) throw new AppError(`Agent '${agentId}' not found`, { status: 400, code: 'AGENT_NOT_FOUND' });
326
+
327
+ const api = agent.getConfig().api;
328
+ const needsInput = action === 'generate' || action === 'stream' || action === 'object';
329
+ const input = needsInput ? normalizeInput(body) : undefined;
330
+ // `memory: { thread, resource }` or the flat `thread` / `resource`
331
+ const thread = resolveThread(body);
332
+ const resource = resolveResource(body);
333
+ const readOnly = body?.readOnly ?? body?.memory?.readOnly ?? false;
334
+
335
+ await evaluateAccess(api?.access as any, action, rest, `agents:${agentId}`, c, {
336
+ agent,
337
+ action,
338
+ body,
339
+ input,
340
+ thread,
341
+ // The deprecated alias, so a rule written against it keeps working
342
+ threadId: thread,
343
+ });
344
+
345
+ const callOptions: AgentCallOptions = {
346
+ thread,
347
+ resource,
348
+ maxSteps: resolveMaxSteps(body?.maxSteps, agent),
349
+ lastMessages: resolveLastMessages(body?.lastMessages, agent),
350
+ // Injected turns: the same normalization as the input, so a client can
351
+ // seed context without building one big `input` array
352
+ ...(body?.messages ? { messages: body.messages as AgentInput } : {}),
353
+ // Stop the run when the client disconnects (no tokens past the wire)
354
+ signal: c.req.raw.signal,
355
+ ...(body?.files?.length ? { files: body.files } : {}),
356
+ ...(readOnly ? { memory: { readOnly: true } } : {}),
357
+ };
358
+
359
+ if (action === 'stream') {
360
+ return streamResponse(c, agent, input as AgentInput, callOptions, {
361
+ rest, agentId, action, started,
362
+ });
363
+ }
364
+
365
+ let result: AgentGenerateResult | undefined;
366
+ let response: any;
367
+
368
+ switch (action) {
369
+ case 'info':
370
+ // No model call, no token spent
371
+ response = { agent: agent.toJSON() };
372
+ break;
373
+
374
+ case 'history': {
375
+ if (!agent.getMemory()) {
376
+ throw new AppError(`Agent '${agentId}' declares no memory`, { status: 400, code: 'AGENT_NO_MEMORY' });
377
+ }
378
+ // The same `resource` the run used: it namespaces the thread
379
+ response = {
380
+ messages: await agent.getMessages(requireThread(body), { resource }),
381
+ };
382
+ break;
383
+ }
384
+
385
+ case 'threads': {
386
+ if (!agent.getMemory()) {
387
+ throw new AppError(`Agent '${agentId}' declares no memory`, { status: 400, code: 'AGENT_NO_MEMORY' });
388
+ }
389
+ if (!agent.getMemory()?.list) {
390
+ throw new AppError(
391
+ `The memory of agent '${agentId}' does not list threads`,
392
+ { status: 400, code: 'AGENT_MEMORY_NO_LIST' },
393
+ );
394
+ }
395
+ response = { threads: await agent.listThreads({ resource, limit: body?.limit }) };
396
+ break;
397
+ }
398
+
399
+ case 'clear': {
400
+ if (!agent.getMemory()) {
401
+ throw new AppError(`Agent '${agentId}' declares no memory`, { status: 400, code: 'AGENT_NO_MEMORY' });
402
+ }
403
+ await agent.clearMessages(requireThread(body), { resource });
404
+ response = { ok: true };
405
+ break;
406
+ }
407
+
408
+ case 'generate': {
409
+ const generated = await agent.generate(input as AgentInput, callOptions);
410
+ result = generated;
411
+ response = presentResult(generated);
412
+ break;
413
+ }
414
+
415
+ case 'object': {
416
+ const structuredOutput = api?.object;
417
+ if (!structuredOutput?.schema) {
418
+ throw new AppError(
419
+ `Agent '${agentId}' declares no \`api.object\` schema`,
420
+ { status: 400, code: 'AGENT_NO_OBJECT_SCHEMA' },
421
+ );
422
+ }
423
+ const generated = await agent.generate(input as AgentInput, { ...callOptions, structuredOutput });
424
+ result = generated;
425
+ response = { ...presentResult(generated), object: generated.object };
426
+ break;
427
+ }
428
+
429
+ default:
430
+ throw new AppError(`Action '${action}' not found`, { status: 400, code: 'ACTION_NOT_FOUND' });
431
+ }
432
+
433
+ await auditAgent({ rest, agentId, action, input, thread, result, started });
434
+ return c.json(response);
435
+ } catch (err: any) {
436
+ if (cfg?.debug) console.error(err);
437
+ if (rest && tenant_id && agentId) {
438
+ await rest.audit.log({
439
+ action: `agent.${action || 'unknown'}`,
440
+ collection: `_agents_:${agentId}`,
441
+ input: { agent: agentId },
442
+ error: { message: err?.message, code: err?.code || 'INTERNAL_AGENT_ERROR' },
443
+ duration: Date.now() - started,
444
+ }).catch(() => {});
445
+ }
446
+ return errorResponse(c, err);
447
+ }
448
+ });
449
+ }
450
+
451
+ export { initializeAgents, AGENT_PREFIX };
package/server/api.ts CHANGED
@@ -3,6 +3,7 @@ import { cfg, safePublicConfig } from "./config";
3
3
  import type { ApiOptions } from "../types/api";
4
4
  import { getCollection } from "../database/collection";
5
5
  import { getFileCollection } from "../database/file";
6
+ import { getTenant } from "../database/tenant";
6
7
  import { AppError, fn } from "../lib/error";
7
8
  import * as cookie from "hono/cookie"
8
9
  import { useRest } from "../database/rest";
@@ -17,12 +18,16 @@ import * as os from 'node:os';
17
18
  import { basename } from 'node:path';
18
19
  import { handleUpload, handleServe, handleDelete, type FileResult } from "../lib/files";
19
20
  import { getVarsDefinition } from "../database/vars";
20
- import { getTenant } from "../database/tenant";
21
- const API_PREFIX = '/api/:tenant_id/:collection/:action';
22
- const SERVICE_PREFIX = '/services/:tenant_id/:service/:action';
23
- const VARS_PREFIX = '/vars/:tenant_id/:action';
24
- const UPLOAD_PREFIX = '/upload/:tenant_id/:collection';
25
- const FILES_PREFIX = '/files/:tenant_id/:collection/:file';
21
+ import { endpoints, patterns } from "../lib/endpoints";
22
+ import { getAccessToken, errorResponse, evaluateAccess } from "./access";
23
+
24
+ // Every surface hangs off `/api/:tenant_id` — the patterns live in `lib/endpoints.ts`,
25
+ // next to the builders a client (and the SDK) uses, so a path is written once.
26
+ const API_PREFIX = patterns.collection;
27
+ const SERVICE_PREFIX = patterns.service;
28
+ const VARS_PREFIX = patterns.vars;
29
+ const UPLOAD_PREFIX = patterns.upload;
30
+ const FILES_PREFIX = patterns.file;
26
31
 
27
32
 
28
33
  const ActionsValues = [
@@ -44,26 +49,6 @@ const ActionsValues = [
44
49
 
45
50
  // ─── Helpers ───────────────────────────────────────────────────────────────
46
51
 
47
- function getAccessToken(c: any) {
48
- const t = c.get('token');
49
- return {
50
- value: (t?.value ?? null) as string | null,
51
- decoded: (t?.decoded ?? null) as Record<string, unknown> | null,
52
- provided: (t?.provided ?? false) as boolean,
53
- expired: (t?.expired ?? false) as boolean,
54
- };
55
- }
56
-
57
- function errorResponse(c: any, err: any) {
58
- const isAppError = err instanceof AppError;
59
- const status = isAppError ? Number(err.status) : 500;
60
- return c.json({
61
- message: isAppError ? err.message : 'Internal server error',
62
- code: isAppError ? err.code : 'INTERNAL_SERVER_ERROR',
63
- meta: isAppError ? err.meta : undefined,
64
- }, status);
65
- }
66
-
67
52
  function stripReadOnlyData(col: any, body: any) {
68
53
  if (col?.api?.readOnlyFields?.length && body?.data) {
69
54
  body.data = func.omit(body.data, col.api.readOnlyFields);
@@ -76,52 +61,9 @@ function stripReadOnlyUpdate(col: any, body: any) {
76
61
  }
77
62
  }
78
63
 
79
- // ─── Access control helpers ──────────────────────────────────────────────────
80
-
81
- async function evaluateAccess(
82
- access: { [key: string]: boolean | ((ctx: any) => boolean | Promise<boolean>) | undefined } | undefined,
83
- operation: string,
84
- rest: InstanceType<typeof useRest>,
85
- label: string,
86
- c: any,
87
- ): Promise<void> {
88
- if (!access) {
89
- throw new AppError('Access denied', { status: 401, code: 'ACCESS_DENIED' });
90
- }
91
-
92
- const hasWildcard = access['*'] !== undefined;
93
- const hasSpecific = access[operation] !== undefined;
94
-
95
- if (!hasWildcard && !hasSpecific) {
96
- throw new AppError('Access denied', { status: 401, code: 'ACCESS_DENIED' });
97
- }
98
-
99
- const rule = hasSpecific ? access[operation] : access['*'];
100
- if (rule === undefined) return;
101
-
102
- // Boolean `true` → allow without requiring a token
103
- if (typeof rule === 'boolean') {
104
- if (!rule) {
105
- throw new AppError(`${label} not allowed`, { status: 401, code: 'ACCESS_DENIED' });
106
- }
107
- return;
108
- }
109
-
110
- // Function → requires a valid token
111
- const accessToken = getAccessToken(c);
112
-
113
- if (accessToken.expired) {
114
- throw new AppError('Token expired', { status: 401, code: 'TOKEN_EXPIRED' });
115
- }
116
- if (!accessToken.value) {
117
- throw new AppError('Authentication required', { status: 401, code: 'AUTH_REQUIRED' });
118
- }
119
-
120
- const allowed = await (rule as Function)({ rest, error: fn.error, jwt: func.jwt, token: accessToken });
121
- if (!allowed) {
122
- throw new AppError(`${label} not allowed`, { status: 401, code: 'ACCESS_DENIED' });
123
- }
124
- }
64
+ // ─── Access control ─────────────────────────────────────────────────────────
65
+ // `evaluateAccess` / `errorResponse` / `getAccessToken` live in `./access`
66
+ // (shared with the agents API).
125
67
 
126
68
  async function checkFileAccess(
127
69
  access: { [key: string]: boolean | ((ctx: any) => boolean | Promise<boolean>) | undefined } | undefined,
package/server/boot.ts CHANGED
@@ -14,6 +14,7 @@ import { syncFileCollections } from '../database/file'
14
14
  import { syncVars } from '../database/vars'
15
15
  import { startReplication, stopReplication } from '../database/replication'
16
16
  import { loadLifecycles, runBeforeBoot, runAfterBoot, runOnDestroy } from '../lib/lifecycle'
17
+ import { workflowStats } from '../lib/workflow'
17
18
 
18
19
  import { loadRoutes } from '../lib/routes'
19
20
  import { runScripts } from '../lib/scripts'
@@ -22,6 +23,7 @@ import { loadServices } from '../lib/services'
22
23
  import { loadSockets } from '../lib/sockets'
23
24
  import { syncWorkflows } from '../lib/workflow'
24
25
  import { syncMcpTools } from '../lib/mcp'
26
+ import { syncAgents, agentsStats } from '../lib/agents'
25
27
  import { loadTenantsMiddlewares } from '../lib/middleware'
26
28
  import crypto from 'node:crypto';
27
29
  import os from 'node:os';
@@ -87,6 +89,14 @@ function renderBanner(opts: {
87
89
  }
88
90
  box += `Replication: ${replicationInfo}`.gray.bold + '\n'
89
91
 
92
+ // Workflows loaded from `{tenant.dir}/workflows/**/*.workflow.ts`
93
+ const wf = workflowStats();
94
+ box += `Workflows: ${wf.total ? `${wf.total}`.green.bold + ` (${wf.tenants.join(', ')})`.gray : 'none'.gray}\n`.gray.bold
95
+
96
+ // Agents loaded from `{tenant.dir}/agents/**/*.agent.ts`
97
+ const ag = agentsStats();
98
+ box += `Agents: ${ag.total ? `${ag.total}`.green.bold + ` (${ag.tenants.join(', ')})`.gray : 'none'.gray}\n`.gray.bold
99
+
90
100
  // Only shown when something is disabled — the default banner stays compact
91
101
  if (off.length) {
92
102
  box += `Mode: ${capabilitiesLabel(caps)}`.gray.bold + ` (${off.join(', ')} off)`.yellow + '\n';
@@ -144,6 +154,7 @@ async function bootApp(options: BootAppOptions = {} as BootAppOptions) {
144
154
  if (caps.sockets) await loadSockets(); // load websocket handlers
145
155
  await syncWorkflows();
146
156
  await syncMcpTools(); // load MCP tools per tenant (mcp/**/*.tool.ts)
157
+ await syncAgents(); // load agents per tenant (agents/**/*.agent.ts)
147
158
  await loadTenantsMiddlewares();
148
159
  await loadRoutes(); // load routes
149
160
  }
package/server/hono.ts CHANGED
@@ -11,8 +11,10 @@ import { Redis } from "ioredis"
11
11
  import { cfg } from "./config"
12
12
  import { rateLimit, createRedisStore, createMemoryStore, type RateLimitStore } from "./security"
13
13
  import { initializeRoutes } from "./routes";
14
+ import { patterns } from "../lib/endpoints";
14
15
  import { asyncContextStorage, requestCtxStorage } from "../lib/asyncContextStorage";
15
16
  import { initializeApi } from "./api";
17
+ import { initializeAgents } from "./agents";
16
18
  import { initializeMcp } from "./mcp";
17
19
  import { getGlobalMiddlewares } from "../lib/middleware";
18
20
  import { jwt } from "../utils/func";
@@ -326,8 +328,8 @@ function createApp(): Hono<{ Variables: HonoVariables }> {
326
328
  keyGenerator: keyByClientIp(''),
327
329
  }));
328
330
 
329
- // stricter limit for login endpoints
330
- app.use('/api/*/login', rateLimit({
331
+ // stricter limit for login endpoints — the login is a collection action
332
+ app.use(patterns.login, rateLimit({
331
333
  windowMs: cfg.server.rateLimit?.login?.windowMs ?? 60_000,
332
334
  max: cfg.server.rateLimit?.login?.max ?? 10,
333
335
  store,
@@ -399,7 +401,10 @@ function createApp(): Hono<{ Variables: HonoVariables }> {
399
401
  // initialize api
400
402
  initializeApi(app);
401
403
 
402
- // initialize MCP (tools served at /mcp/:tenant_id)
404
+ // initialize the agents API (/api/:tenant_id/agents/:agent/:action)
405
+ initializeAgents(app);
406
+
407
+ // initialize MCP (tools served at /api/:tenant_id/mcp)
403
408
  initializeMcp(app);
404
409
 
405
410