@anteros/core 0.0.44 → 0.0.47

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 (65) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1 -1
  3. package/database/field.crypto.ts +38 -13
  4. package/database/mongodbadapter.ts +85 -25
  5. package/database/vars.ts +22 -2
  6. package/index.ts +22 -0
  7. package/lib/agent.ts +707 -79
  8. package/lib/agentMemory.ts +199 -0
  9. package/lib/agents.ts +133 -52
  10. package/lib/attachment.ts +24 -3
  11. package/lib/define.ts +5 -1
  12. package/lib/eval.ts +123 -0
  13. package/lib/files.ts +21 -15
  14. package/lib/jsonSchema.ts +8 -0
  15. package/lib/mcp.ts +52 -30
  16. package/lib/messages.ts +202 -19
  17. package/lib/partialJson.ts +79 -0
  18. package/lib/providers.ts +26 -2
  19. package/lib/redact.ts +11 -0
  20. package/lib/tracing.ts +20 -0
  21. package/lib/zip.ts +23 -3
  22. package/package.json +4 -5
  23. package/server/agents.ts +66 -1
  24. package/server/api.ts +302 -20
  25. package/server/boot.ts +13 -4
  26. package/server/hono.ts +146 -6
  27. package/tests/access-log-body.test.ts +253 -0
  28. package/tests/agent-checkpoint.test.ts +195 -0
  29. package/tests/agent-hardening.test.ts +238 -0
  30. package/tests/agent-memory.test.ts +175 -1
  31. package/tests/agent-stream-advanced.test.ts +246 -0
  32. package/tests/agent-tracing-eval.test.ts +219 -0
  33. package/tests/agent-working-memory.test.ts +7 -4
  34. package/tests/agent.test.ts +54 -3
  35. package/tests/agents-api.test.ts +21 -0
  36. package/tests/api-external-guards.test.ts +304 -0
  37. package/tests/attachment.test.ts +63 -0
  38. package/tests/audit-redact.test.ts +26 -1
  39. package/tests/field-encryption.test.ts +47 -3
  40. package/tests/filters-hardening.test.ts +81 -0
  41. package/tests/fixtures/ai-tenant/ai/agents/support/billing.agent.ts +9 -0
  42. package/tests/fixtures/ai-tenant/ai/agents/support/billing.tool.ts +9 -0
  43. package/tests/fixtures/ai-tenant/ai/agents/support.agent.ts +9 -0
  44. package/tests/fixtures/ai-tenant/ai/agents/support.tool.ts +9 -0
  45. package/tests/fixtures/ai-tenant/ai/mcp/resources/readme.resource.ts +9 -0
  46. package/tests/fixtures/ai-tenant/ai/mcp/tools/echo.tool.ts +9 -0
  47. package/tests/fixtures/fake-provider.ts +33 -0
  48. package/tests/fixtures/tool-tenant/ai/agents/report.agent.ts +12 -0
  49. package/tests/fixtures/tool-tenant/ai/agents/report.tool.ts +12 -0
  50. package/tests/fixtures/vars-tenant/vars/open.var.ts +18 -0
  51. package/tests/hono.test.ts +81 -1
  52. package/tests/jwt-tenant.test.ts +165 -0
  53. package/tests/logger.test.ts +62 -10
  54. package/tests/messages.test.ts +88 -32
  55. package/tests/tool-runtime.test.ts +291 -0
  56. package/tests/tool-types.ts +33 -0
  57. package/tests/upload.test.ts +175 -3
  58. package/tests/vars-api.test.ts +73 -1
  59. package/tests/vars.test.ts +1 -1
  60. package/types/agent.d.ts +386 -4
  61. package/types/config.d.ts +56 -2
  62. package/types/file.d.ts +1 -1
  63. package/types/mcp.d.ts +2 -2
  64. package/utils/func.ts +20 -0
  65. package/utils/logger.ts +125 -33
package/lib/files.ts CHANGED
@@ -254,11 +254,22 @@ export async function handleUpload(options: UploadOptions): Promise<FileResult>
254
254
  });
255
255
  }
256
256
 
257
- // Validate MIME type
258
- const mimetype = file.type || 'application/octet-stream';
259
- if (col.upload?.allowedMimeTypes?.length && !col.upload.allowedMimeTypes.includes(mimetype)) {
260
- throw new AppError(`MIME type '${mimetype}' not allowed`, {
261
- code: 'MIMETYPE_NOT_ALLOWED', status: 400,
257
+ // Validate MIME type — compare the **base** type: a browser sends `text/html;charset=utf-8`
258
+ const mimetype = (file.type || 'application/octet-stream').split(';')[0]!.trim().toLowerCase();
259
+ if (col.upload?.allowedMimeTypes?.length) {
260
+ const allowed = col.upload.allowedMimeTypes.map((type: string) => type.split(';')[0]!.trim().toLowerCase());
261
+ if (!allowed.includes(mimetype)) {
262
+ throw new AppError(`MIME type '${mimetype}' not allowed`, {
263
+ code: 'MIMETYPE_NOT_ALLOWED', status: 400,
264
+ });
265
+ }
266
+ }
267
+
268
+ // Size first (#51): never buffer an oversized upload just to reject it later.
269
+ const maxSize = col.upload?.maxSize ?? 100 * 1024 * 1024; // 100MB per-file default
270
+ if (file.size > maxSize) {
271
+ throw new AppError(`File too large (max ${Math.round(maxSize / 1024 / 1024)}MB)`, {
272
+ code: 'FILE_TOO_LARGE', status: 413,
262
273
  });
263
274
  }
264
275
 
@@ -284,14 +295,6 @@ export async function handleUpload(options: UploadOptions): Promise<FileResult>
284
295
  });
285
296
  }
286
297
 
287
- // Validate file size
288
- const maxSize = col.upload?.maxSize ?? 10 * 1024 * 1024; // 10MB default
289
- if (file.size > maxSize) {
290
- throw new AppError(`File too large (max ${Math.round(maxSize / 1024 / 1024)}MB)`, {
291
- code: 'FILE_TOO_LARGE', status: 413,
292
- });
293
- }
294
-
295
298
  // Timestamps (ISO strings) — stored and returned in the response
296
299
  const now = new Date()
297
300
 
@@ -450,8 +453,11 @@ export async function handleServe(
450
453
  return { stream: new ReadableStream({ start(controller) { controller.enqueue(data); controller.close(); } }), mimetype: newMime, size: data.length };
451
454
  }
452
455
 
453
- // Flaw 4: SVG is served as an attachment (never inline) to prevent stored XSS
454
- if (isSvg) {
456
+ // #37: only raster images (and PDFs) may render inline. Anything else — HTML,
457
+ // SVG, JS, unknown — is served as a **download**, so an uploaded file can never
458
+ // execute on the API origin (stored XSS).
459
+ const INLINE_MIME = /^image\/(png|jpe?g|gif|webp|avif|bmp)$/i;
460
+ if (isSvg || !(INLINE_MIME.test(mimetype) || mimetype === 'application/pdf')) {
455
461
  return { stream, mimetype, attachment: true };
456
462
  }
457
463
 
package/lib/jsonSchema.ts CHANGED
@@ -38,6 +38,10 @@ function joiToJsonSchema(schema: Joi.Schema, describe = schema.describe() as any
38
38
  }
39
39
  node.properties = properties;
40
40
  if (required.length) node.required = required;
41
+ // The model is told to stay inside the declared shape. Joi still tolerates
42
+ // unknown keys at validation time (`allowUnknown`) — the schema is the
43
+ // stricter of the two on purpose: a stray key costs tokens and attention.
44
+ node.additionalProperties = describe.flags?.unknown === true;
41
45
  break;
42
46
  }
43
47
  case 'array': {
@@ -73,6 +77,10 @@ function joiToJsonSchema(schema: Joi.Schema, describe = schema.describe() as any
73
77
  }
74
78
 
75
79
  if (describe?.flags?.description) node.description = describe.flags.description;
80
+ // A default is part of what the model should know: it can omit a field instead of
81
+ // inventing a value. A function default cannot travel — it is applied at validation.
82
+ const fallback = describe?.flags?.default;
83
+ if (fallback !== undefined && typeof fallback !== 'function') node.default = fallback;
76
84
  return node;
77
85
  }
78
86
 
package/lib/mcp.ts CHANGED
@@ -2,53 +2,75 @@ import { Glob } from "bun"
2
2
  import { cfg } from "../server/config"
3
3
  import path from "path"
4
4
  import fs from "fs/promises"
5
+ import { logger } from "../utils/logger"
5
6
  import type { McpTool, McpResource } from "../types/mcp"
6
7
  import { importDefinition } from "./load"
7
8
 
8
9
  /**
9
- * Load MCP tools & resources from each tenant's `mcp/` folder:
10
- * {tenant.dir}/mcp/tools/**\/*.tool.ts → tools
11
- * {tenant.dir}/mcp/resources/**\/*.resource.ts → resources
10
+ * Load MCP tools & resources from each tenant's AI folder:
11
+ * {tenant.dir}/ai/mcp/tools/**\/*.tool.ts → tools
12
+ * {tenant.dir}/ai/mcp/resources/**\/*.resource.ts → resources
13
+ *
14
+ * The legacy `{tenant.dir}/mcp/` layout is still read, with one deprecation
15
+ * warning per tenant; the `ai/` definition wins when both declare the same name.
12
16
  */
13
17
  async function syncMcpTools() {
14
18
  try {
15
- const tools: McpTool[] = []
16
- const resources: McpResource[] = []
19
+ /** Keyed by `tenant:name`, so a migrated file replaces its old copy. */
20
+ const tools = new Map<string, McpTool>()
21
+ const resources = new Map<string, McpResource>()
22
+
17
23
  for (let tenant of cfg.tenants ?? []) {
18
24
  const TENANT_PATH = path.join(process.cwd(), tenant.dir)
19
- const MCP_PATH = path.join(TENANT_PATH, 'mcp')
20
- let exist = await fs.exists(MCP_PATH)
21
- if (!exist) continue;
22
- const isDirectory = await (await fs.stat(MCP_PATH)).isDirectory()
23
- if (!isDirectory) continue;
24
-
25
- // tools: mcp/tools/**/*.tool.ts
26
- const TOOLS_PATH = path.join(MCP_PATH, 'tools')
27
- if (await fs.exists(TOOLS_PATH)) {
28
- const toolsGlob = new Glob(path.join(TOOLS_PATH, '**/*.tool.ts'))
29
- for await (let file of toolsGlob.scan('.')) {
30
- let module = await importDefinition(file, 'MCP tool')
31
- if (module?.default?._isMcpTool_ && module?.default?.enabled !== false) {
32
- tools.push({ ...module?.default, _tenant_: tenant.id })
25
+
26
+ // Legacy first: an `ai/mcp` file of the same name replaces it.
27
+ const roots = [
28
+ { dir: path.join(TENANT_PATH, 'mcp'), legacy: true },
29
+ { dir: path.join(TENANT_PATH, 'ai', 'mcp'), legacy: false },
30
+ ]
31
+
32
+ for (const root of roots) {
33
+ let exist = await fs.exists(root.dir)
34
+ if (!exist) continue
35
+ const isDirectory = await (await fs.stat(root.dir)).isDirectory()
36
+ if (!isDirectory) continue
37
+ if (root.legacy) {
38
+ logger.warn(
39
+ `Tenant '${tenant.id}': '${path.join(tenant.dir, 'mcp')}' is deprecated — ` +
40
+ `move it to '${path.join(tenant.dir, 'ai', 'mcp')}'`,
41
+ )
42
+ }
43
+
44
+ // tools: <root>/tools/**/*.tool.ts
45
+ const TOOLS_PATH = path.join(root.dir, 'tools')
46
+ if (await fs.exists(TOOLS_PATH)) {
47
+ const toolsGlob = new Glob(path.join(TOOLS_PATH, '**/*.tool.ts'))
48
+ for await (let file of toolsGlob.scan('.')) {
49
+ let module = await importDefinition(file, 'MCP tool')
50
+ if (module?.default?._isMcpTool_ && module?.default?.enabled !== false) {
51
+ const tool = { ...module.default, _tenant_: tenant.id } as McpTool
52
+ tools.set(`${tenant.id}:${tool.name}`, tool)
53
+ }
33
54
  }
34
55
  }
35
- }
36
56
 
37
- // resources: mcp/resources/**/*.resource.ts
38
- const RESOURCES_PATH = path.join(MCP_PATH, 'resources')
39
- if (await fs.exists(RESOURCES_PATH)) {
40
- const resourcesGlob = new Glob(path.join(RESOURCES_PATH, '**/*.resource.ts'))
41
- for await (let file of resourcesGlob.scan('.')) {
42
- let module = await importDefinition(file, 'MCP resource')
43
- if (module?.default?._isMcpResource_ && module?.default?.enabled !== false) {
44
- resources.push({ ...module?.default, _tenant_: tenant.id })
57
+ // resources: <root>/resources/**/*.resource.ts
58
+ const RESOURCES_PATH = path.join(root.dir, 'resources')
59
+ if (await fs.exists(RESOURCES_PATH)) {
60
+ const resourcesGlob = new Glob(path.join(RESOURCES_PATH, '**/*.resource.ts'))
61
+ for await (let file of resourcesGlob.scan('.')) {
62
+ let module = await importDefinition(file, 'MCP resource')
63
+ if (module?.default?._isMcpResource_ && module?.default?.enabled !== false) {
64
+ const resource = { ...module.default, _tenant_: tenant.id } as McpResource
65
+ resources.set(`${tenant.id}:${resource.name}`, resource)
66
+ }
45
67
  }
46
68
  }
47
69
  }
48
70
  }
49
71
 
50
- cfg.mcpTools = tools
51
- cfg.mcpResources = resources
72
+ cfg.mcpTools = [...tools.values()]
73
+ cfg.mcpResources = [...resources.values()]
52
74
 
53
75
  } catch (err: any) {
54
76
  console.error(err?.message)
package/lib/messages.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import { AppError } from "./error";
2
+ import { isIP } from "node:net";
3
+ import { lookup } from "node:dns/promises";
2
4
  import { MAX_FILE_BYTES } from "./attachment";
3
5
  import type {
4
6
  AgentAssistantMessage,
@@ -53,7 +55,9 @@ function invalid(message: string): AppError {
53
55
 
54
56
  /** A tool result as the model reads it — the same rendering the runtime uses. */
55
57
  function serializeToolResult(entry: AgentToolResultEntry): string {
56
- if (entry.error) return `Error: ${entry.error}`;
58
+ // The code travels with the message, so the model can branch on the cause
59
+ // (`ORDER_NOT_FOUND`, `AGENT_TOOL_TIMEOUT`…) instead of parsing English.
60
+ if (entry.error) return entry.errorCode ? `Error [${entry.errorCode}]: ${entry.error}` : `Error: ${entry.error}`;
57
61
  const result = entry.result;
58
62
  if (typeof result === "string") return result;
59
63
  if (result && typeof result === "object" && Array.isArray((result as any).content)) {
@@ -61,6 +65,10 @@ function serializeToolResult(entry: AgentToolResultEntry): string {
61
65
  .filter((block: any) => block?.type === "text" && typeof block.text === "string")
62
66
  .map((block: any) => block.text);
63
67
  if (texts.length) return texts.join("\n");
68
+ // Media-only result — the text answer is a marker, the media travels beside it.
69
+ if ((result as any).content.some((block: any) => MEDIA_BLOCK.test(String(block?.type)))) {
70
+ return toolMediaOf((result as any).content).map(mediaMarker).join("\n");
71
+ }
64
72
  }
65
73
  if (result === undefined || result === null) return "OK";
66
74
  try {
@@ -70,6 +78,51 @@ function serializeToolResult(entry: AgentToolResultEntry): string {
70
78
  }
71
79
  }
72
80
 
81
+ /** A tool result block that carries bytes (MCP spells documents `document`). */
82
+ /** A media block a tool result may carry — text is handled separately. */
83
+ type ToolMediaPart = Extract<AgentMediaPart, { type: "image" } | { type: "file" }>;
84
+
85
+ const MEDIA_BLOCK = /^(image|file|document)$/;
86
+
87
+ function toolMediaOf(content: any[]): ToolMediaPart[] {
88
+ const media: ToolMediaPart[] = [];
89
+ for (const block of content) {
90
+ const type = String(block?.type ?? "");
91
+ if (!MEDIA_BLOCK.test(type)) continue;
92
+ const mimeType = typeof block.mimeType === "string"
93
+ ? block.mimeType
94
+ : (typeof block.mime_type === "string" ? block.mime_type : undefined);
95
+ if (type === "image") {
96
+ const data = typeof block.data === "string" ? block.data : undefined;
97
+ const url = typeof block.url === "string" ? block.url : undefined;
98
+ if (data || url) media.push({ type: "image", ...(url ? { url } : { data }), ...(mimeType ? { mimeType } : {}) });
99
+ continue;
100
+ }
101
+ const data = typeof block.data === "string" ? block.data : undefined;
102
+ const url = typeof block.url === "string" ? block.url : undefined;
103
+ if (!data && !url) continue;
104
+ media.push({
105
+ type: "file",
106
+ ...(url ? { url } : { data }),
107
+ ...(mimeType ? { mimeType } : {}),
108
+ ...(block.name ?? block.filename ? { name: String(block.name ?? block.filename) } : {}),
109
+ });
110
+ }
111
+ return media;
112
+ }
113
+
114
+ /** What a text-only protocol (OpenAI) shows of a media tool result. */
115
+ function mediaMarker(part: ToolMediaPart): string {
116
+ return part.type === "image" ? `[image: ${part.mimeType ?? "image/png"}]` : `[file: ${part.name ?? part.mimeType ?? "file"}]`;
117
+ }
118
+
119
+ /** The media a tool returned, when its result is `{ content: […] }`. */
120
+ function toolResultMedia(entry: AgentToolResultEntry): ToolMediaPart[] {
121
+ const result = entry.result;
122
+ if (entry.error || !result || typeof result !== "object" || !Array.isArray((result as any).content)) return [];
123
+ return toolMediaOf((result as any).content);
124
+ }
125
+
73
126
  /** The text of a part array — a `system` message is text and nothing else. */
74
127
  function systemText(parts: AgentContentPart[], role: string): string {
75
128
  const foreign = parts.find((part) => part?.type !== "text");
@@ -88,28 +141,158 @@ function checkAddress(part: AgentContentPart, role: string): void {
88
141
  throw invalid(`A \`${part.type}\` part of a ${role} message needs an \`https://\`, a \`data:\` URL or base64 \`data\``);
89
142
  }
90
143
 
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;
144
+ /** Remote files: public HTTPS only, bounded time and size — SSRF hardening (issue #30). */
145
+ const REMOTE_FILE_TIMEOUT_MS = 10_000;
146
+ const REMOTE_FILE_MAX_REDIRECTS = 3;
147
+
148
+ /** Refuse an address that is not publicly routable (loopback, LAN, cloud metadata…). */
149
+ function isPrivateAddress(address: string): boolean {
150
+ const plain = address.replace(/^\[|\]$/g, "").toLowerCase();
151
+ const family = isIP(plain);
152
+
153
+ if (family === 4) {
154
+ const [a, b] = plain.split(".").map(Number) as [number, number, number, number];
155
+ if (a === 0 || a === 10 || a === 127) return true;
156
+ if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT
157
+ if (a === 169 && b === 254) return true; // link-local, cloud metadata
158
+ if (a === 172 && b >= 16 && b <= 31) return true;
159
+ if (a === 192 && b === 168) return true;
160
+ if (a === 192 && b === 0) return true; // IETF / TEST-NET-1
161
+ if (a === 198 && (b === 18 || b === 19)) return true; // benchmark
162
+ if (a === 198 && b === 51) return true; // TEST-NET-2
163
+ if (a === 203 && b === 0) return true; // TEST-NET-3
164
+ return a >= 224; // multicast, reserved, broadcast
165
+ }
94
166
 
95
- const url = part.url!;
96
- let bytes: Uint8Array;
167
+ if (family === 6) {
168
+ if (plain.startsWith("::")) return true; // ::1, ::, ::ffff:… (IPv4-mapped)
169
+ if (plain.startsWith("fe80:")) return true; // link-local
170
+ if (/^f[cd][0-9a-f]{2}:/.test(plain)) return true; // unique local fc00::/7
171
+ if (plain.startsWith("ff")) return true; // multicast
172
+ if (plain.startsWith("2001:db8")) return true; // documentation
173
+ if (plain.startsWith("2002:")) return true; // 6to4
174
+ if (plain.startsWith("64:ff9b:")) return true; // NAT64 — may map a private IPv4
175
+ return false;
176
+ }
177
+
178
+ return true; // not an IP: the caller resolves the hostname first
179
+ }
180
+
181
+ function remoteFileError(code: "AGENT_FILE_INVALID" | "AGENT_FILE_NOT_FOUND", message: string): AppError {
182
+ return new AppError(message, { status: 400, code });
183
+ }
184
+
185
+ /** Parse one hop: HTTPS, no credentials, and only public addresses. */
186
+ async function assertPublicRemoteUrl(raw: string): Promise<URL> {
187
+ let url: URL;
97
188
  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
- });
189
+ url = new URL(raw);
190
+ } catch {
191
+ throw remoteFileError("AGENT_FILE_INVALID", "The remote file URL is invalid");
192
+ }
193
+ if (url.protocol !== "https:") {
194
+ throw remoteFileError("AGENT_FILE_INVALID", "Remote files must use HTTPS");
195
+ }
196
+ if (url.username || url.password) {
197
+ throw remoteFileError("AGENT_FILE_INVALID", "Remote file URLs must not carry credentials");
198
+ }
199
+
200
+ const host = url.hostname.replace(/^\[|\]$/g, "");
201
+ if (!host) throw remoteFileError("AGENT_FILE_INVALID", "The remote file URL has no host");
202
+
203
+ if (isIP(host)) {
204
+ if (isPrivateAddress(host)) {
205
+ throw remoteFileError("AGENT_FILE_INVALID", "The remote file URL points to a private address");
206
+ }
207
+ return url;
208
+ }
209
+
210
+ let resolved: Array<{ address: string }>;
211
+ try {
212
+ resolved = await lookup(host, { all: true, verbatim: true });
213
+ } catch {
214
+ throw remoteFileError("AGENT_FILE_NOT_FOUND", "The remote file host could not be resolved");
105
215
  }
216
+ if (!resolved.length || resolved.some(({ address }) => isPrivateAddress(address))) {
217
+ throw remoteFileError("AGENT_FILE_INVALID", "The remote file URL points to a private address");
218
+ }
219
+ return url;
220
+ }
221
+
222
+ /** Follows a redirect only after re-validating the target — no hop may reach a private address. */
223
+ async function fetchRemoteFile(raw: string): Promise<Response> {
224
+ let current = raw;
225
+ for (let hop = 0; hop <= REMOTE_FILE_MAX_REDIRECTS; hop++) {
226
+ const url = await assertPublicRemoteUrl(current);
227
+
228
+ let response: Response;
229
+ try {
230
+ response = await fetch(url, {
231
+ redirect: "manual",
232
+ signal: AbortSignal.timeout(REMOTE_FILE_TIMEOUT_MS),
233
+ });
234
+ } catch {
235
+ throw remoteFileError("AGENT_FILE_NOT_FOUND", "The remote file could not be downloaded");
236
+ }
106
237
 
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
- );
238
+ if (response.status >= 300 && response.status < 400) {
239
+ const location = response.headers.get("location");
240
+ if (!location) throw remoteFileError("AGENT_FILE_NOT_FOUND", "The remote file could not be downloaded");
241
+ current = new URL(location, url).href;
242
+ continue;
243
+ }
244
+ if (!response.ok) throw remoteFileError("AGENT_FILE_NOT_FOUND", "The remote file could not be downloaded");
245
+ return response;
112
246
  }
247
+ throw remoteFileError("AGENT_FILE_INVALID", "The remote file redirects too many times");
248
+ }
249
+
250
+ /** Reads the body with a hard cap — from `Content-Length` and while streaming. */
251
+ async function readRemoteBytes(response: Response): Promise<Uint8Array> {
252
+ const limit = MAX_FILE_BYTES;
253
+ const tooLarge = () => new AppError(
254
+ `The remote file exceeds the ${limit / 1024 / 1024}MB limit`,
255
+ { status: 413, code: "AGENT_FILE_TOO_LARGE" },
256
+ );
257
+
258
+ const declared = Number(response.headers.get("content-length") ?? "0");
259
+ if (Number.isFinite(declared) && declared > limit) throw tooLarge();
260
+ if (!response.body) return new Uint8Array(await response.arrayBuffer());
261
+
262
+ const reader = response.body.getReader();
263
+ const chunks: Uint8Array[] = [];
264
+ let total = 0;
265
+ try {
266
+ for (;;) {
267
+ const { done, value } = await reader.read();
268
+ if (done) break;
269
+ total += value.byteLength;
270
+ if (total > limit) {
271
+ await reader.cancel().catch(() => {});
272
+ throw tooLarge();
273
+ }
274
+ chunks.push(value);
275
+ }
276
+ } catch (err) {
277
+ if (err instanceof AppError) throw err;
278
+ throw remoteFileError("AGENT_FILE_NOT_FOUND", "The remote file could not be downloaded");
279
+ }
280
+
281
+ const bytes = new Uint8Array(total);
282
+ let offset = 0;
283
+ for (const chunk of chunks) {
284
+ bytes.set(chunk, offset);
285
+ offset += chunk.byteLength;
286
+ }
287
+ return bytes;
288
+ }
289
+
290
+ /** A `file` part, with its remote payload fetched and inlined when there is one. */
291
+ async function inlineRemoteFile(part: AgentMediaPart): Promise<AgentMediaPart> {
292
+ if (part.type !== "file" || !isHttpUrl(part.url)) return part;
293
+
294
+ const url = part.url!;
295
+ const bytes = await readRemoteBytes(await fetchRemoteFile(url));
113
296
 
114
297
  const name = part.name ?? url.split("?")[0]!.split("/").pop() ?? "document.pdf";
115
298
  return { type: "file", name, ...(part.mimeType ? { mimeType: part.mimeType } : {}), data: Buffer.from(bytes).toString("base64") };
@@ -296,4 +479,4 @@ async function normalizeMessages(input: AgentInput): Promise<AgentMessage[]> {
296
479
  return out;
297
480
  }
298
481
 
299
- export { normalizeMessages, serializeToolResult };
482
+ export { normalizeMessages, serializeToolResult, toolResultMedia };
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Best-effort JSON for a stream: what the model has produced so far, repaired
3
+ * enough to parse, or `undefined` while it still makes no sense.
4
+ *
5
+ * Used by `agent.streamObject()` — the answer of a structured run is a JSON
6
+ * object, and a caller usually wants to render it before the last token arrives.
7
+ */
8
+
9
+ /** Drop the markdown fence a model wraps its JSON in (` ```json … ``` `). */
10
+ function unfence(text: string): string {
11
+ let candidate = text.trim();
12
+ // An opening fence, whose language tag may itself be partial (` ```jso `)
13
+ candidate = candidate.replace(/^```[a-zA-Z0-9_-]*\s*/, "");
14
+ candidate = candidate.replace(/\s*```\s*$/, "");
15
+ return candidate.trim();
16
+ }
17
+
18
+ /**
19
+ * Parse a **partial** JSON document — the text of an answer still streaming.
20
+ *
21
+ * The scan tracks string state (and escapes, so `"a\"b"` is one string), closes
22
+ * the open string, then the open objects and arrays, drops a dangling `,` or `:`
23
+ * and parses. Anything that still fails returns `undefined`: a partial answer is
24
+ * best-effort by definition, never an error.
25
+ *
26
+ * ```ts
27
+ * parsePartialJson('{"cities": [{"name": "Par') // { cities: [{ name: 'Par' }] }
28
+ * parsePartialJson('{"cities": [1,') // { cities: [1] }
29
+ * parsePartialJson('```json\n{"a":') // undefined
30
+ * ```
31
+ */
32
+ export function parsePartialJson(text: string): unknown {
33
+ if (typeof text !== "string") return undefined;
34
+
35
+ const source = unfence(text);
36
+ if (!source) return undefined;
37
+
38
+ const open: Array<"{" | "["> = [];
39
+ let inString = false;
40
+ let escaped = false;
41
+
42
+ for (const char of source) {
43
+ if (inString) {
44
+ if (escaped) {
45
+ escaped = false;
46
+ } else if (char === "\\") {
47
+ escaped = true;
48
+ } else if (char === "\"") {
49
+ inString = false;
50
+ }
51
+ continue;
52
+ }
53
+
54
+ if (char === "\"") inString = true;
55
+ else if (char === "{" || char === "[") open.push(char);
56
+ else if (char === "}" || char === "]") open.pop();
57
+ }
58
+
59
+ let repaired = source;
60
+
61
+ // A dangling escape would escape the quote we are about to add
62
+ if (inString) {
63
+ if (escaped) repaired = repaired.slice(0, -1);
64
+ repaired += "\"";
65
+ }
66
+
67
+ // A key or a value waiting for its other half cannot be completed
68
+ repaired = repaired.replace(/[,:]\s*$/, "");
69
+
70
+ for (let index = open.length - 1; index >= 0; index--) {
71
+ repaired += open[index] === "{" ? "}" : "]";
72
+ }
73
+
74
+ try {
75
+ return JSON.parse(repaired);
76
+ } catch {
77
+ return undefined;
78
+ }
79
+ }
package/lib/providers.ts CHANGED
@@ -201,6 +201,23 @@ function openaiContent(content: string | AgentMediaPart[]): any {
201
201
  });
202
202
  }
203
203
 
204
+ /** A tool message's text, with a marker for the media a text-only protocol cannot take. */
205
+ function toolText(content: string | AgentMediaPart[]): string {
206
+ if (typeof content === "string") return content;
207
+ return content.map((part) => {
208
+ switch (part.type) {
209
+ case "text":
210
+ return part.text;
211
+ case "image":
212
+ return `[image: ${part.mimeType ?? "image/png"}]`;
213
+ case "file":
214
+ return `[file: ${part.name ?? part.mimeType ?? "file"}]`;
215
+ default:
216
+ return "";
217
+ }
218
+ }).filter(Boolean).join("\n");
219
+ }
220
+
204
221
  /** `AgentMessage[]` → OpenAI `messages[]`. */
205
222
  function openaiMessages(system: string | undefined, messages: AgentMessage[]): any[] {
206
223
  const out: any[] = [];
@@ -229,7 +246,8 @@ function openaiMessages(system: string | undefined, messages: AgentMessage[]): a
229
246
  break;
230
247
  }
231
248
  case "tool":
232
- out.push({ role: "tool", tool_call_id: message.toolCallId, content: message.content });
249
+ // OpenAI's tool message is text-only: a media result arrives as a marker
250
+ out.push({ role: "tool", tool_call_id: message.toolCallId, content: toolText(message.content) });
233
251
  break;
234
252
  }
235
253
  }
@@ -296,7 +314,13 @@ function anthropicMessages(messages: AgentMessage[]): { system: string | undefin
296
314
  break;
297
315
  }
298
316
  case "tool": {
299
- const block: any = { type: "tool_result", tool_use_id: message.toolCallId, content: message.content };
317
+ // A string stays a string; a part array (text + image/document) is
318
+ // converted block by block — `tool_result` accepts both.
319
+ const block: any = {
320
+ type: "tool_result",
321
+ tool_use_id: message.toolCallId,
322
+ content: typeof message.content === "string" ? message.content : anthropicContent(message.content),
323
+ };
300
324
  if (message.isError) block.is_error = true;
301
325
  const last = out[out.length - 1];
302
326
  if (last?.role === "user" && Array.isArray(last.content)
package/lib/redact.ts CHANGED
@@ -100,6 +100,16 @@ function redact(value: any, keys: Set<string>, depth = 0): any {
100
100
  return changed ? out : value
101
101
  }
102
102
 
103
+ /**
104
+ * Redact secrets embedded in a **free-text** message (`password=…`, `token: …`).
105
+ * The key-based redactor cannot see those — audit error messages need this.
106
+ */
107
+ const TEXT_SECRET = /((?:password|passwd|pwd|token|secret|api[_-]?key|authorization)\s*[:=]\s*)([^\s,;]+)/gi;
108
+
109
+ function redactText(value: string): string {
110
+ return value.replace(TEXT_SECRET, "$1[redacted]");
111
+ }
112
+
103
113
  /** Redact an audit payload — `input`, `result`, `meta` or `request` */
104
114
  function redactPayload(value: any): any {
105
115
  const keys = redactKeys()
@@ -113,5 +123,6 @@ export {
113
123
  redactKeys,
114
124
  isSensitiveKey,
115
125
  redactPayload,
126
+ redactText,
116
127
  redact,
117
128
  }
package/lib/tracing.ts ADDED
@@ -0,0 +1,20 @@
1
+ import type { AgentTraceEvent } from "../types/agent";
2
+
3
+ /** Receives the milestones of a run — see `AgentTraceEvent`. */
4
+ export type AgentTracer = (event: AgentTraceEvent) => void;
5
+
6
+ /**
7
+ * Hand one event to a tracer.
8
+ *
9
+ * A tracer is an **observer**: a hook that throws is swallowed, so a broken
10
+ * exporter can never fail the run it watches. The hook is synchronous — a
11
+ * promise it returns is not awaited (and is not the framework's to settle).
12
+ */
13
+ export function trace(tracer: AgentTracer | undefined, event: AgentTraceEvent): void {
14
+ if (!tracer) return;
15
+ try {
16
+ tracer(event);
17
+ } catch {
18
+ // Observability must never be a failure mode of the run
19
+ }
20
+ }