@anteros/core 0.0.40 → 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 (130) hide show
  1. package/database/audit.ts +143 -0
  2. package/database/collection.ts +6 -4
  3. package/database/deleteLog.ts +98 -0
  4. package/database/file.ts +7 -6
  5. package/database/mongodbadapter.ts +420 -185
  6. package/database/replication.ts +858 -0
  7. package/database/schema.ts +140 -125
  8. package/database/tenant.ts +4 -0
  9. package/database/ttl.ts +68 -0
  10. package/database/vars.ts +405 -0
  11. package/database/workflow.ts +466 -240
  12. package/index.ts +16 -0
  13. package/lib/agent.ts +1327 -0
  14. package/lib/agentMemory.ts +586 -0
  15. package/lib/agents.ts +157 -0
  16. package/lib/ai.ts +0 -0
  17. package/lib/api.ts +246 -0
  18. package/lib/attachment.ts +351 -0
  19. package/lib/audit.ts +271 -0
  20. package/lib/auditResult.ts +133 -0
  21. package/lib/define.ts +62 -1
  22. package/lib/endpoints.ts +80 -0
  23. package/lib/files.ts +66 -12
  24. package/lib/jsonSchema.ts +183 -0
  25. package/lib/lifecycle.ts +147 -0
  26. package/lib/load.ts +17 -0
  27. package/lib/mcp.ts +3 -2
  28. package/lib/messages.ts +299 -0
  29. package/lib/providers.ts +867 -0
  30. package/lib/redact.ts +117 -0
  31. package/lib/routes.ts +14 -1
  32. package/lib/scriptWorker.ts +4 -1
  33. package/lib/scripts.ts +6 -1
  34. package/lib/sockets.ts +3 -1
  35. package/lib/workflow.ts +209 -37
  36. package/lib/zip.ts +109 -0
  37. package/package.json +1 -1
  38. package/server/access.ts +96 -0
  39. package/server/agents.ts +451 -0
  40. package/server/api.ts +121 -95
  41. package/server/boot.ts +196 -53
  42. package/server/flags.ts +136 -0
  43. package/server/hono.ts +9 -4
  44. package/server/mcp.ts +17 -63
  45. package/server/routes.ts +19 -24
  46. package/tests/agent-memory.test.ts +617 -0
  47. package/tests/agent-working-memory.test.ts +402 -0
  48. package/tests/agent.test.ts +1239 -0
  49. package/tests/agents-api.test.ts +593 -0
  50. package/tests/api.test.ts +51 -0
  51. package/tests/attachment.test.ts +241 -0
  52. package/tests/audit-file.test.ts +296 -0
  53. package/tests/audit-operations.test.ts +184 -0
  54. package/tests/audit-redact.test.ts +259 -0
  55. package/tests/audit-results.test.ts +188 -0
  56. package/tests/audit-retention-config.test.ts +76 -0
  57. package/tests/audit-run-action.test.ts +120 -0
  58. package/tests/audit.test.ts +320 -0
  59. package/tests/db-cache.test.ts +7 -7
  60. package/tests/endpoints.test.ts +80 -0
  61. package/tests/file-collection-replication.test.ts +98 -0
  62. package/tests/fixtures/agent-tenant/agents/support.agent.ts +9 -0
  63. package/tests/fixtures/agent-tenant/agents/weather.agent.ts +21 -0
  64. package/tests/fixtures/agents-api/agents/assistant.agent.ts +46 -0
  65. package/tests/fixtures/agents-api/agents/plain.agent.ts +23 -0
  66. package/tests/fixtures/agents-api/agents/private.agent.ts +15 -0
  67. package/tests/fixtures/agents-api/collections/notes.model.ts +26 -0
  68. package/tests/fixtures/agents-api/routes/probe.route.ts +21 -0
  69. package/tests/fixtures/agents-api/services/probe.service.ts +19 -0
  70. package/tests/fixtures/attachment/data.csv +2 -0
  71. package/tests/fixtures/fake-provider.ts +128 -0
  72. package/tests/fixtures/lc-disabled/lifecycle.ts +11 -0
  73. package/tests/fixtures/lc-hang/lifecycle.ts +13 -0
  74. package/tests/fixtures/lc-normal/lifecycle.ts +23 -0
  75. package/tests/fixtures/lc-throw/lifecycle.ts +6 -0
  76. package/tests/fixtures/repl-only/app.ts +26 -0
  77. package/tests/fixtures/repl-only/tenant/collections/orders.model.ts +14 -0
  78. package/tests/fixtures/repl-only/tenant/lifecycle.ts +14 -0
  79. package/tests/fixtures/repl-only/tenant/scripts/probe.run.ts +8 -0
  80. package/tests/fixtures/replication-meta/agents/chat.agent.ts +16 -0
  81. package/tests/fixtures/replication-meta/collections/counters.model.ts +13 -0
  82. package/tests/fixtures/replication-meta/collections/orders.model.ts +9 -0
  83. package/tests/fixtures/replication-meta/workflows/run.workflow.ts +8 -0
  84. package/tests/fixtures/vars-tenant/vars/config.var.ts +23 -0
  85. package/tests/fixtures/vars-tenant/vars/private.var.ts +9 -0
  86. package/tests/fixtures/workflow-engine/workflows/failing.workflow.ts +34 -0
  87. package/tests/fixtures/workflow-engine/workflows/flaky.workflow.ts +23 -0
  88. package/tests/fixtures/workflow-engine/workflows/hang.workflow.ts +17 -0
  89. package/tests/fixtures/workflow-engine/workflows/simple.workflow.ts +19 -0
  90. package/tests/fixtures/workflow-engine/workflows/slow.workflow.ts +23 -0
  91. package/tests/fixtures/workflow-engine/workflows/versioned.workflow.ts +19 -0
  92. package/tests/fixtures/workflow-tenant/workflows/broken.workflow.ts +3 -0
  93. package/tests/fixtures/workflow-tenant/workflows/disabled.workflow.ts +8 -0
  94. package/tests/fixtures/workflow-tenant/workflows/invoice.workflow.ts +21 -0
  95. package/tests/fixtures/workflow-tenant/workflows/no-id.workflow.ts +7 -0
  96. package/tests/fixtures/workflow-tenant/workflows/order-duplicate.workflow.ts +13 -0
  97. package/tests/fixtures/workflow-tenant/workflows/order.workflow.ts +17 -0
  98. package/tests/hono.test.ts +6 -3
  99. package/tests/lifecycle.test.ts +96 -0
  100. package/tests/locks.test.ts +120 -0
  101. package/tests/mcp.test.ts +11 -11
  102. package/tests/messages.test.ts +258 -0
  103. package/tests/replication-control.test.ts +128 -0
  104. package/tests/replication-initial-sync.test.ts +86 -0
  105. package/tests/replication-meta.test.ts +243 -0
  106. package/tests/replication-only.test.ts +358 -0
  107. package/tests/replication.test.ts +188 -0
  108. package/tests/upload.test.ts +141 -8
  109. package/tests/vars-api.test.ts +123 -0
  110. package/tests/vars-replication.test.ts +96 -0
  111. package/tests/vars.test.ts +216 -0
  112. package/tests/workflow-engine.test.ts +340 -0
  113. package/tests/workflow-retention.test.ts +103 -0
  114. package/tests/workflow.test.ts +186 -0
  115. package/types/agent.d.ts +837 -0
  116. package/types/collection.d.ts +3 -0
  117. package/types/config.d.ts +80 -0
  118. package/types/file.d.ts +11 -8
  119. package/types/hook.d.ts +6 -0
  120. package/types/lifecycle.d.ts +77 -0
  121. package/types/mcp.d.ts +11 -1
  122. package/types/replication.d.ts +228 -0
  123. package/types/route.d.ts +7 -1
  124. package/types/scripts.d.ts +6 -0
  125. package/types/service.d.ts +7 -1
  126. package/types/tenant.d.ts +36 -0
  127. package/types/vars.d.ts +147 -0
  128. package/types/websocket.d.ts +6 -0
  129. package/types/workflow.d.ts +50 -3
  130. package/utils/func.ts +67 -52
@@ -0,0 +1,183 @@
1
+ import type Joi from "joi";
2
+
3
+ /**
4
+ * Schema helpers shared by the MCP server and the agent runtime.
5
+ *
6
+ * A tenant declares its schemas in Joi (the collection field DSL, `v`), but the
7
+ * outside world speaks JSON Schema: the MCP protocol and every model provider
8
+ * (`tools[].function.parameters` / `tools[].input_schema`) want JSON Schema.
9
+ * Both callers use the same converter so a tool looks identical wherever it is
10
+ * exposed. zod and plain JSON Schema objects are accepted as well.
11
+ */
12
+
13
+ // ─── Joi → JSON Schema ───────────────────────────────────────────────────
14
+
15
+ function joiTypeToJson(type: string): string {
16
+ switch (type) {
17
+ case 'number': return 'number';
18
+ case 'integer': return 'integer';
19
+ case 'boolean': return 'boolean';
20
+ case 'date': return 'string';
21
+ default: return 'string';
22
+ }
23
+ }
24
+
25
+ /** Minimal Joi → JSON Schema converter (covers the common field types). */
26
+ function joiToJsonSchema(schema: Joi.Schema, describe = schema.describe() as any): any {
27
+ const node: any = {};
28
+ const type = describe?.type;
29
+
30
+ switch (type) {
31
+ case 'object': {
32
+ node.type = 'object';
33
+ const properties: Record<string, any> = {};
34
+ const required: string[] = [];
35
+ for (const [key, child] of Object.entries(describe.keys ?? {})) {
36
+ properties[key] = joiToJsonSchema(child as any, child);
37
+ if ((child as any)?.flags?.presence === 'required') required.push(key);
38
+ }
39
+ node.properties = properties;
40
+ if (required.length) node.required = required;
41
+ break;
42
+ }
43
+ case 'array': {
44
+ node.type = 'array';
45
+ if (describe.items?.length) node.items = joiToJsonSchema(describe.items[0], describe.items[0]);
46
+ break;
47
+ }
48
+ case 'alternatives': {
49
+ // Joi alternatives → anyOf (only the simple `try` list is covered)
50
+ const matches = (describe.matches ?? []).map((m: any) => joiToJsonSchema(m.schema, m.schema));
51
+ if (matches.length) node.anyOf = matches;
52
+ else node.type = 'object';
53
+ break;
54
+ }
55
+ default: {
56
+ node.type = joiTypeToJson(type);
57
+ if (type === 'date') node.format = 'date-time';
58
+ if (type === 'string') {
59
+ for (const rule of describe.rules ?? []) {
60
+ if (['email', 'uri', 'uuid', 'isoDate', 'ip'].includes(rule.name)) {
61
+ node.format = rule.name;
62
+ }
63
+ if (rule.name === 'pattern' && rule.args?.regex) {
64
+ node.pattern = String(rule.args.regex).replace(/^\/|\/[gimsuy]*$/g, '');
65
+ }
66
+ }
67
+ }
68
+ if (describe.valids?.length && describe.valids.length <= 50) {
69
+ node.enum = describe.valids;
70
+ }
71
+ break;
72
+ }
73
+ }
74
+
75
+ if (describe?.flags?.description) node.description = describe.flags.description;
76
+ return node;
77
+ }
78
+
79
+ // ─── Detection ───────────────────────────────────────────────────────────
80
+
81
+ function isJoiSchema(schema: any): boolean {
82
+ return !!schema && typeof schema === 'object'
83
+ && typeof schema.describe === 'function' && typeof schema.validate === 'function';
84
+ }
85
+
86
+ function isZodSchema(schema: any): boolean {
87
+ return !!schema && typeof schema === 'object'
88
+ && typeof schema.safeParse === 'function' && (!!schema._zod || !!schema._def);
89
+ }
90
+
91
+ function isJsonSchema(schema: any): boolean {
92
+ return !!schema && typeof schema === 'object'
93
+ && (typeof schema.type === 'string' || !!schema.properties || !!schema.anyOf || !!schema.oneOf || !!schema.allOf);
94
+ }
95
+
96
+ // ─── zod → JSON Schema (zod is imported only when a zod schema is used) ──
97
+
98
+ let zodModule: any | undefined;
99
+
100
+ async function ensureZod(): Promise<any | null> {
101
+ if (zodModule !== undefined) return zodModule;
102
+ try {
103
+ zodModule = await import("zod");
104
+ } catch {
105
+ zodModule = null;
106
+ }
107
+ return zodModule;
108
+ }
109
+
110
+ /** An open object schema — the fallback when nothing usable was declared. */
111
+ function openObjectSchema(): any {
112
+ return { type: 'object', properties: {}, additionalProperties: true };
113
+ }
114
+
115
+ /**
116
+ * Normalize a Joi / zod / JSON Schema into a JSON Schema object.
117
+ * `undefined` → an open object (the provider requires *some* schema).
118
+ */
119
+ async function toJsonSchema(schema?: any): Promise<any> {
120
+ if (schema === undefined || schema === null) return openObjectSchema();
121
+ if (isJoiSchema(schema)) return joiToJsonSchema(schema);
122
+ if (isZodSchema(schema)) {
123
+ const zod = await ensureZod();
124
+ const convert = zod?.toJSONSchema ?? zod?.z?.toJSONSchema;
125
+ if (typeof convert !== 'function') {
126
+ console.error('zod schema support requires zod >= 4 (`z.toJSONSchema`) — schema ignored');
127
+ return openObjectSchema();
128
+ }
129
+ try {
130
+ return convert(schema, { unrepresentable: 'any' });
131
+ } catch (err: any) {
132
+ console.error(`Failed to convert the zod schema to JSON Schema: ${err?.message}`);
133
+ return openObjectSchema();
134
+ }
135
+ }
136
+ if (isJsonSchema(schema)) return schema;
137
+ // Unknown shape (a boolean schema, a `{}`…) — pass it through
138
+ return typeof schema === 'object' ? schema : openObjectSchema();
139
+ }
140
+
141
+ // ─── Validation ──────────────────────────────────────────────────────────
142
+
143
+ export type SchemaValidation = { value: any; error: string | null };
144
+
145
+ /**
146
+ * Validate a value against a declared schema.
147
+ * Joi and zod are enforced; a plain JSON Schema is not (there is no validator
148
+ * in the dependency set) — the value is returned untouched.
149
+ */
150
+ async function validateWithSchema(schema: any, value: any): Promise<SchemaValidation> {
151
+ if (schema === undefined || schema === null) return { value, error: null };
152
+
153
+ if (isJoiSchema(schema)) {
154
+ const { error, value: validated } = schema.validate(value, {
155
+ allowUnknown: true,
156
+ stripUnknown: false,
157
+ abortEarly: false,
158
+ convert: true,
159
+ });
160
+ return { value: validated, error: error ? error.message : null };
161
+ }
162
+
163
+ if (isZodSchema(schema)) {
164
+ const parsed = schema.safeParse(value);
165
+ if (parsed.success) return { value: parsed.data, error: null };
166
+ const first = parsed.error?.issues?.[0];
167
+ const path = first?.path?.length ? `${first.path.join('.')}: ` : '';
168
+ return { value, error: `${path}${first?.message ?? parsed.error?.message ?? 'invalid'}` };
169
+ }
170
+
171
+ return { value, error: null };
172
+ }
173
+
174
+ export {
175
+ joiTypeToJson,
176
+ joiToJsonSchema,
177
+ isJoiSchema,
178
+ isZodSchema,
179
+ isJsonSchema,
180
+ openObjectSchema,
181
+ toJsonSchema,
182
+ validateWithSchema,
183
+ };
@@ -0,0 +1,147 @@
1
+ import path from "path";
2
+ import fs from "fs/promises";
3
+ import { cfg } from "../server/config";
4
+ import { useRest } from "../database/rest";
5
+ import { createAgents } from "./agents";
6
+ import { createApi } from "./api";
7
+ import { logger } from "../utils/logger";
8
+ import {
9
+ replicationNow,
10
+ getReplicationState,
11
+ resetReplication,
12
+ seedReplication,
13
+ } from "../database/replication";
14
+ import type { Lifecycle, LifecycleReason } from "../types/lifecycle";
15
+ import type { Tenant } from "../types/tenant";
16
+ import type { TenantReplicationApi } from "../types/replication";
17
+
18
+ /**
19
+ * Tenant-scoped replication API — like `rest`, the tenant is implicit. Built per
20
+ * tenant for the lifecycle context, so hooks never pass a tenant id.
21
+ */
22
+ function tenantReplication(tenantId: string): TenantReplicationApi {
23
+ return {
24
+ now: () => replicationNow(tenantId),
25
+ state: (destinationId?: string, collection?: string) => getReplicationState(tenantId, destinationId, collection),
26
+ reset: (opts) => resetReplication(tenantId, opts),
27
+ seed: (value, opts) => seedReplication(tenantId, value, opts),
28
+ };
29
+ }
30
+
31
+ /** Lifecycles by tenant id — one `{tenant.dir}/lifecycle.ts` per tenant. */
32
+ const lifecycles = new Map<string, Lifecycle>();
33
+ /** Ensures `onDestroy` runs at most once per process. */
34
+ let destroyed = false;
35
+
36
+ /**
37
+ * Load `{tenant.dir}/lifecycle.ts` for every tenant. A missing file is fine —
38
+ * a tenant simply has no lifecycle.
39
+ */
40
+ async function loadLifecycles(): Promise<void> {
41
+ lifecycles.clear();
42
+ destroyed = false;
43
+ for (const tenant of cfg.tenants ?? []) {
44
+ try {
45
+ const file = path.join(process.cwd(), tenant.dir, "lifecycle.ts");
46
+ if (!(await fs.exists(file))) continue;
47
+ const module = await import(file);
48
+ const lifecycle = module?.default;
49
+ if (lifecycle?._isLifecycle_) {
50
+ lifecycles.set(tenant.id, { ...lifecycle, _tenant_: tenant.id });
51
+ }
52
+ } catch (err: any) {
53
+ logger.file("error", "lifecycle: failed to load", { tenant: tenant.id, error: err?.message });
54
+ }
55
+ }
56
+ }
57
+
58
+ /** Tenant-scoped context passed to every hook. */
59
+ function buildContext(tenant: Tenant, extra?: Record<string, any>) {
60
+ const rest = new useRest({ tenant_id: tenant.id });
61
+ return {
62
+ tenant,
63
+ rest,
64
+ // Tenant-scoped LLM registry — a lifecycle hook can seed/refresh an agent
65
+ agents: createAgents(tenant.id, rest),
66
+ api: createApi(rest),
67
+ logger,
68
+ cfg,
69
+ replication: tenantReplication(tenant.id),
70
+ ...(extra ?? {}),
71
+ } as any;
72
+ }
73
+
74
+ /**
75
+ * Run every `beforeBoot` hook, sequentially in `cfg.tenants` order. Blocking:
76
+ * a throwing hook aborts the boot (fail-fast).
77
+ */
78
+ async function runBeforeBoot(): Promise<void> {
79
+ for (const tenant of cfg.tenants ?? []) {
80
+ const lifecycle = lifecycles.get(tenant.id);
81
+ if (!lifecycle?.enabled || !lifecycle.beforeBoot) continue;
82
+ try {
83
+ await lifecycle.beforeBoot(buildContext(tenant));
84
+ logger.file("lifecycle: beforeBoot", { tenant: tenant.id });
85
+ } catch (err: any) {
86
+ logger.error(`lifecycle beforeBoot failed for tenant '${tenant.id}'`, { error: err?.message, stack: err?.stack });
87
+ throw new Error(`[${tenant.id}] lifecycle beforeBoot failed: ${err?.message ?? err}`);
88
+ }
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Run every `afterBoot` hook once the server is listening. Errors are logged
94
+ * but never fatal — the server is already serving.
95
+ */
96
+ async function runAfterBoot(server: any, io: any): Promise<void> {
97
+ for (const tenant of cfg.tenants ?? []) {
98
+ const lifecycle = lifecycles.get(tenant.id);
99
+ if (!lifecycle?.enabled || !lifecycle.afterBoot) continue;
100
+ try {
101
+ await lifecycle.afterBoot(buildContext(tenant, { io, server }));
102
+ logger.file("lifecycle: afterBoot", { tenant: tenant.id });
103
+ } catch (err: any) {
104
+ logger.error(`lifecycle afterBoot failed for tenant '${tenant.id}'`, { error: err?.message });
105
+ }
106
+ }
107
+ }
108
+
109
+ /** Bound a promise with a timeout that rejects. */
110
+ function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
111
+ let timer: ReturnType<typeof setTimeout>;
112
+ const timeout = new Promise<never>((_, reject) => {
113
+ timer = setTimeout(() => reject(new Error(message)), ms);
114
+ });
115
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)) as Promise<T>;
116
+ }
117
+
118
+ /**
119
+ * Run every `onDestroy` hook on shutdown (once per process), before replication
120
+ * stops and DB clients close. Each hook is bounded by `destroyTimeout`.
121
+ */
122
+ async function runOnDestroy(reason: LifecycleReason): Promise<void> {
123
+ if (destroyed) return;
124
+ destroyed = true;
125
+
126
+ for (const tenant of cfg.tenants ?? []) {
127
+ const lifecycle = lifecycles.get(tenant.id);
128
+ if (!lifecycle?.enabled || !lifecycle.onDestroy) continue;
129
+ try {
130
+ await withTimeout(
131
+ Promise.resolve(lifecycle.onDestroy(buildContext(tenant, { reason }))),
132
+ lifecycle.destroyTimeout ?? 10_000,
133
+ `lifecycle onDestroy timed out after ${lifecycle.destroyTimeout ?? 10_000}ms`,
134
+ );
135
+ logger.file("lifecycle: onDestroy", { tenant: tenant.id, reason });
136
+ } catch (err: any) {
137
+ logger.error(`lifecycle onDestroy failed for tenant '${tenant.id}'`, { error: err?.message });
138
+ }
139
+ }
140
+ }
141
+
142
+ export {
143
+ loadLifecycles,
144
+ runBeforeBoot,
145
+ runAfterBoot,
146
+ runOnDestroy,
147
+ };
package/lib/load.ts ADDED
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Import a tenant definition file (`*.model.ts`, `*.workflow.ts`, …) for a loader.
3
+ *
4
+ * A broken file (syntax error, throwing module) must never hide the other
5
+ * definitions — nor the other tenants: the failure is logged and `null` is
6
+ * returned, so the caller simply skips that file.
7
+ */
8
+ async function importDefinition(file: string, kind: string): Promise<any | null> {
9
+ try {
10
+ return await import(file)
11
+ } catch (err: any) {
12
+ console.error(`Failed to load ${kind} '${file}':`, err?.message)
13
+ return null
14
+ }
15
+ }
16
+
17
+ export { importDefinition }
package/lib/mcp.ts CHANGED
@@ -3,6 +3,7 @@ import { cfg } from "../server/config"
3
3
  import path from "path"
4
4
  import fs from "fs/promises"
5
5
  import type { McpTool, McpResource } from "../types/mcp"
6
+ import { importDefinition } from "./load"
6
7
 
7
8
  /**
8
9
  * Load MCP tools & resources from each tenant's `mcp/` folder:
@@ -26,7 +27,7 @@ async function syncMcpTools() {
26
27
  if (await fs.exists(TOOLS_PATH)) {
27
28
  const toolsGlob = new Glob(path.join(TOOLS_PATH, '**/*.tool.ts'))
28
29
  for await (let file of toolsGlob.scan('.')) {
29
- let module = await import(file)
30
+ let module = await importDefinition(file, 'MCP tool')
30
31
  if (module?.default?._isMcpTool_ && module?.default?.enabled !== false) {
31
32
  tools.push({ ...module?.default, _tenant_: tenant.id })
32
33
  }
@@ -38,7 +39,7 @@ async function syncMcpTools() {
38
39
  if (await fs.exists(RESOURCES_PATH)) {
39
40
  const resourcesGlob = new Glob(path.join(RESOURCES_PATH, '**/*.resource.ts'))
40
41
  for await (let file of resourcesGlob.scan('.')) {
41
- let module = await import(file)
42
+ let module = await importDefinition(file, 'MCP resource')
42
43
  if (module?.default?._isMcpResource_ && module?.default?.enabled !== false) {
43
44
  resources.push({ ...module?.default, _tenant_: tenant.id })
44
45
  }
@@ -0,0 +1,299 @@
1
+ import { AppError } from "./error";
2
+ import { MAX_FILE_BYTES } from "./attachment";
3
+ import type {
4
+ AgentAssistantMessage,
5
+ AgentContentPart,
6
+ AgentInput,
7
+ AgentMediaPart,
8
+ AgentMessage,
9
+ AgentMessageInput,
10
+ AgentToolCall,
11
+ AgentToolCallPart,
12
+ AgentToolResultEntry,
13
+ AgentToolResultPart,
14
+ } from "../types/agent";
15
+
16
+ /**
17
+ * The message model — one shape for every provider.
18
+ *
19
+ * A caller may hand over anything reasonable: a bare `string`, one message, a
20
+ * whole conversation, or the **unified content parts** another LLM library uses
21
+ * (`text`, `image`, `file`, `tool-call`, `tool-result`). Everything is normalized
22
+ * here, once, into the canonical `AgentMessage[]` the runtime, the providers and
23
+ * the memory all speak:
24
+ *
25
+ * | Input | Canonical result |
26
+ * | --- | --- |
27
+ * | `'hi'` | `{ role: 'user', content: 'hi' }` |
28
+ * | `{ role: 'user', content: [{ type: 'text', … }] }` | the same message, media parts kept |
29
+ * | `{ role: 'user', content: [{ type: 'tool-result', … }] }` | the `tool` message it really is |
30
+ * | `{ role: 'assistant', content: [{ type: 'tool-call', … }] }` | `toolCalls` on the assistant turn |
31
+ * | `{ role: 'developer', … }` | kept as-is (mapped per protocol by the adapters) |
32
+ *
33
+ * Two rules worth knowing:
34
+ *
35
+ * - **Nothing is dropped silently.** A part the role cannot carry (an image on an
36
+ * assistant turn, a `tool-call` on a user turn) is refused with `INVALID_INPUT`
37
+ * rather than ignored — a conversation quietly losing a turn shows up as a wrong
38
+ * answer, three calls later.
39
+ * - **A remote file is downloaded here** (10 MB cap, same as an attachment):
40
+ * OpenAI-compatible endpoints only accept base64 for a document, so inlining it
41
+ * once keeps `file: { url }` working on every provider. A remote **image** is
42
+ * left as a URL — both protocols fetch it themselves.
43
+ *
44
+ * Only the fields of the canonical message survive: an unknown key on the way in
45
+ * (a stray `threadId`, a provider flag…) never reaches the wire.
46
+ */
47
+
48
+ const ROLES = new Set(["system", "developer", "user", "assistant", "tool"]);
49
+
50
+ function invalid(message: string): AppError {
51
+ return new AppError(message, { status: 400, code: "INVALID_INPUT" });
52
+ }
53
+
54
+ /** A tool result as the model reads it — the same rendering the runtime uses. */
55
+ function serializeToolResult(entry: AgentToolResultEntry): string {
56
+ if (entry.error) return `Error: ${entry.error}`;
57
+ const result = entry.result;
58
+ if (typeof result === "string") return result;
59
+ if (result && typeof result === "object" && Array.isArray((result as any).content)) {
60
+ const texts = (result as any).content
61
+ .filter((block: any) => block?.type === "text" && typeof block.text === "string")
62
+ .map((block: any) => block.text);
63
+ if (texts.length) return texts.join("\n");
64
+ }
65
+ if (result === undefined || result === null) return "OK";
66
+ try {
67
+ return JSON.stringify(result);
68
+ } catch {
69
+ return String(result);
70
+ }
71
+ }
72
+
73
+ /** The text of a part array — a `system` message is text and nothing else. */
74
+ function systemText(parts: AgentContentPart[], role: string): string {
75
+ const foreign = parts.find((part) => part?.type !== "text");
76
+ if (foreign) throw invalid(`A \`${role}\` message only carries \`text\` parts`);
77
+ return parts.map((part: any) => part.text).join("\n");
78
+ }
79
+
80
+ function isHttpUrl(url: string | undefined): boolean {
81
+ return typeof url === "string" && /^https?:\/\//i.test(url);
82
+ }
83
+
84
+ function checkAddress(part: AgentContentPart, role: string): void {
85
+ const address = (part as { url?: string }).url;
86
+ if (!address) return;
87
+ if (isHttpUrl(address) || address.startsWith("data:")) return;
88
+ throw invalid(`A \`${part.type}\` part of a ${role} message needs an \`https://\`, a \`data:\` URL or base64 \`data\``);
89
+ }
90
+
91
+ /** A `file` part, with its remote payload fetched and inlined when there is one. */
92
+ async function inlineRemoteFile(part: AgentMediaPart): Promise<AgentMediaPart> {
93
+ if (part.type !== "file" || !isHttpUrl(part.url)) return part;
94
+
95
+ const url = part.url!;
96
+ let bytes: Uint8Array;
97
+ try {
98
+ const response = await fetch(url);
99
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
100
+ bytes = new Uint8Array(await response.arrayBuffer());
101
+ } catch (err: any) {
102
+ throw new AppError(`Cannot download '${url}': ${err?.message ?? "unreachable"}`, {
103
+ status: 400, code: "AGENT_FILE_NOT_FOUND",
104
+ });
105
+ }
106
+
107
+ if (bytes.byteLength > MAX_FILE_BYTES) {
108
+ throw new AppError(
109
+ `'${url}' is ${Math.round(bytes.byteLength / 1024 / 1024)}MB — the limit is ${MAX_FILE_BYTES / 1024 / 1024}MB`,
110
+ { status: 413, code: "AGENT_FILE_TOO_LARGE" },
111
+ );
112
+ }
113
+
114
+ const name = part.name ?? url.split("?")[0]!.split("/").pop() ?? "document.pdf";
115
+ return { type: "file", name, ...(part.mimeType ? { mimeType: part.mimeType } : {}), data: Buffer.from(bytes).toString("base64") };
116
+ }
117
+
118
+ /** One media part, validated (and fetched when it is a remote file). */
119
+ async function mediaPart(part: AgentContentPart, role: string): Promise<AgentMediaPart> {
120
+ if (part?.type === "text") {
121
+ if (typeof part.text !== "string") throw invalid(`A \`text\` part of a ${role} message needs a \`text\``);
122
+ return { type: "text", text: part.text };
123
+ }
124
+
125
+ if (part?.type === "image" || part?.type === "file") {
126
+ if (!part.data && !part.url) throw invalid(`A \`${part.type}\` part needs a \`url\` or a \`data\``);
127
+ checkAddress(part, role);
128
+
129
+ // Only what is there: an explicit `undefined` would be stored as `null`
130
+ const address = part.url ? { url: part.url } : { data: part.data };
131
+ const media: AgentMediaPart = part.type === "image"
132
+ ? { type: "image", ...address, ...(part.mimeType ? { mimeType: part.mimeType } : {}) }
133
+ : {
134
+ type: "file",
135
+ ...address,
136
+ ...(part.name ? { name: part.name } : {}),
137
+ ...(part.mimeType ? { mimeType: part.mimeType } : {}),
138
+ };
139
+ return media.type === "file" ? await inlineRemoteFile(media) : media;
140
+ }
141
+
142
+ throw invalid(`A \`${(part as any)?.type ?? "unknown"}\` part is not valid on a \`${role}\` message`);
143
+ }
144
+
145
+ /** An `assistant` turn: text (and the tool calls it asked for) — nothing else. */
146
+ function assistantContent(content: string | AgentContentPart[] | null | undefined, role: string): string | null {
147
+ if (content === null || content === undefined) return null;
148
+ if (typeof content === "string") return content;
149
+
150
+ const media = content.find((part) => part?.type === "image" || part?.type === "file");
151
+ if (media) throw invalid(`A \`${media.type}\` part is not valid on a \`${role}\` message`);
152
+
153
+ return content
154
+ .filter((part): part is Extract<AgentContentPart, { type: "text" }> => part?.type === "text")
155
+ .map((part) => part.text)
156
+ .join("") || null;
157
+ }
158
+
159
+ /** A `tool-call` part → the canonical tool call the providers are given back. */
160
+ function toToolCall(part: AgentToolCallPart): AgentToolCall {
161
+ if (!part?.id || !part?.name) throw invalid("A `tool-call` part needs an `id` and a `name`");
162
+ const raw = part.args ?? part.arguments ?? {};
163
+ const args = raw && typeof raw === "object" ? (raw as Record<string, any>) : {};
164
+ const call: AgentToolCall = { id: part.id, name: part.name, args };
165
+ if (part.argsText) call.argsText = part.argsText;
166
+ else if (typeof part.arguments === "string") call.argsText = part.arguments;
167
+ return call;
168
+ }
169
+
170
+ /** A `tool-result` part → the `tool` message that answers one call. */
171
+ function toToolMessage(part: AgentToolResultPart): AgentMessage {
172
+ if (!part?.id) throw invalid("A `tool-result` part needs the `id` of the call it answers");
173
+ const name = part.name ?? "tool";
174
+ return {
175
+ role: "tool",
176
+ toolCallId: part.id,
177
+ name,
178
+ content: serializeToolResult({
179
+ id: part.id,
180
+ name,
181
+ args: {},
182
+ result: part.result,
183
+ error: part.error,
184
+ durationMs: 0,
185
+ }),
186
+ ...(part.error ? { isError: true } : {}),
187
+ };
188
+ }
189
+
190
+ /**
191
+ * Everything a caller passed → the canonical conversation.
192
+ *
193
+ * Structural only: the input is **appended** to whatever a memory already holds —
194
+ * this function never touches the thread.
195
+ */
196
+ async function normalizeMessages(input: AgentInput): Promise<AgentMessage[]> {
197
+ const list: AgentMessageInput[] = typeof input === "string"
198
+ ? [{ role: "user", content: input }]
199
+ : Array.isArray(input) ? input : [input];
200
+
201
+ const out: AgentMessage[] = [];
202
+
203
+ for (const message of list) {
204
+ if (!message || typeof message !== "object" || typeof (message as any).role !== "string") {
205
+ throw invalid("Every message needs a `role`");
206
+ }
207
+
208
+ const role = message.role as string;
209
+ if (!ROLES.has(role)) throw invalid(`Unknown message role '${role}'`);
210
+
211
+ const source = message as any;
212
+ const content = source.content;
213
+ const named = source.name ? { name: String(source.name) } : {};
214
+
215
+ switch (role) {
216
+ case "system":
217
+ case "developer": {
218
+ const text = typeof content === "string"
219
+ ? content
220
+ : Array.isArray(content)
221
+ ? systemText(content, role)
222
+ : (() => { throw invalid(`A \`${role}\` message needs a \`content\``); })();
223
+ // An empty instruction is a mistake, not a message
224
+ if (!text.trim()) throw invalid(`A \`${role}\` message needs a \`content\``);
225
+ out.push({ role, content: text, ...named } as AgentMessage);
226
+ break;
227
+ }
228
+
229
+ case "user": {
230
+ if (typeof content === "string") {
231
+ out.push({ role: "user", content, ...named });
232
+ break;
233
+ }
234
+ if (!Array.isArray(content)) throw invalid("A `user` message needs a `content`");
235
+
236
+ const parts: AgentMediaPart[] = [];
237
+ const results: AgentMessage[] = [];
238
+ for (const part of content) {
239
+ if (part?.type === "tool-result") results.push(toToolMessage(part));
240
+ else if (part?.type === "tool-call") {
241
+ throw invalid("A `tool-call` part belongs to an `assistant` message");
242
+ } else parts.push(await mediaPart(part, role));
243
+ }
244
+ if (!parts.length && !results.length) throw invalid("A `user` message needs a `content`");
245
+
246
+ // A turn that is *only* tool results does not leave an empty user turn behind
247
+ if (parts.length) out.push({ role: "user", content: parts, ...named });
248
+ // A tool round expressed as `user` parts becomes the turns it means
249
+ out.push(...results);
250
+ break;
251
+ }
252
+
253
+ case "assistant": {
254
+ const inline = Array.isArray(content)
255
+ ? content.filter((part): part is AgentToolCallPart => part?.type === "tool-call")
256
+ : [];
257
+ const declared = source.toolCalls as AgentToolCall[] | undefined;
258
+ const thinking = source.thinking as AgentAssistantMessage["thinking"];
259
+
260
+ out.push({
261
+ role: "assistant",
262
+ content: assistantContent(content, role),
263
+ ...(declared?.length || inline.length
264
+ ? { toolCalls: [...(declared ?? []), ...inline.map(toToolCall)] }
265
+ : {}),
266
+ ...(thinking?.length ? { thinking } : {}),
267
+ ...named,
268
+ });
269
+ break;
270
+ }
271
+
272
+ case "tool": {
273
+ if (typeof content === "string") {
274
+ if (!source.toolCallId) throw invalid("A `tool` message needs a `toolCallId`");
275
+ out.push({
276
+ role: "tool",
277
+ toolCallId: String(source.toolCallId),
278
+ name: source.name ? String(source.name) : "tool",
279
+ content,
280
+ ...(source.isError ? { isError: true } : {}),
281
+ });
282
+ break;
283
+ }
284
+ if (!Array.isArray(content)) throw invalid("A `tool` message needs a `content`");
285
+ if (!content.length) throw invalid("A `tool` message needs a `content`");
286
+
287
+ for (const part of content) {
288
+ if (part?.type !== "tool-result") throw invalid("A `tool` message only carries `tool-result` parts");
289
+ out.push(toToolMessage(part));
290
+ }
291
+ break;
292
+ }
293
+ }
294
+ }
295
+
296
+ return out;
297
+ }
298
+
299
+ export { normalizeMessages, serializeToolResult };