@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.
- package/LICENSE +21 -0
- package/README.md +1 -1
- package/database/field.crypto.ts +38 -13
- package/database/mongodbadapter.ts +85 -25
- package/database/vars.ts +22 -2
- package/index.ts +22 -0
- package/lib/agent.ts +707 -79
- package/lib/agentMemory.ts +199 -0
- package/lib/agents.ts +133 -52
- package/lib/attachment.ts +24 -3
- package/lib/define.ts +5 -1
- package/lib/eval.ts +123 -0
- package/lib/files.ts +21 -15
- package/lib/jsonSchema.ts +8 -0
- package/lib/mcp.ts +52 -30
- package/lib/messages.ts +202 -19
- package/lib/partialJson.ts +79 -0
- package/lib/providers.ts +26 -2
- package/lib/redact.ts +11 -0
- package/lib/tracing.ts +20 -0
- package/lib/zip.ts +23 -3
- package/package.json +4 -5
- package/server/agents.ts +66 -1
- package/server/api.ts +302 -20
- package/server/boot.ts +13 -4
- package/server/hono.ts +146 -6
- package/tests/access-log-body.test.ts +253 -0
- package/tests/agent-checkpoint.test.ts +195 -0
- package/tests/agent-hardening.test.ts +238 -0
- package/tests/agent-memory.test.ts +175 -1
- package/tests/agent-stream-advanced.test.ts +246 -0
- package/tests/agent-tracing-eval.test.ts +219 -0
- package/tests/agent-working-memory.test.ts +7 -4
- package/tests/agent.test.ts +54 -3
- package/tests/agents-api.test.ts +21 -0
- package/tests/api-external-guards.test.ts +304 -0
- package/tests/attachment.test.ts +63 -0
- package/tests/audit-redact.test.ts +26 -1
- package/tests/field-encryption.test.ts +47 -3
- package/tests/filters-hardening.test.ts +81 -0
- package/tests/fixtures/ai-tenant/ai/agents/support/billing.agent.ts +9 -0
- package/tests/fixtures/ai-tenant/ai/agents/support/billing.tool.ts +9 -0
- package/tests/fixtures/ai-tenant/ai/agents/support.agent.ts +9 -0
- package/tests/fixtures/ai-tenant/ai/agents/support.tool.ts +9 -0
- package/tests/fixtures/ai-tenant/ai/mcp/resources/readme.resource.ts +9 -0
- package/tests/fixtures/ai-tenant/ai/mcp/tools/echo.tool.ts +9 -0
- package/tests/fixtures/fake-provider.ts +33 -0
- package/tests/fixtures/tool-tenant/ai/agents/report.agent.ts +12 -0
- package/tests/fixtures/tool-tenant/ai/agents/report.tool.ts +12 -0
- package/tests/fixtures/vars-tenant/vars/open.var.ts +18 -0
- package/tests/hono.test.ts +81 -1
- package/tests/jwt-tenant.test.ts +165 -0
- package/tests/logger.test.ts +62 -10
- package/tests/messages.test.ts +88 -32
- package/tests/tool-runtime.test.ts +291 -0
- package/tests/tool-types.ts +33 -0
- package/tests/upload.test.ts +175 -3
- package/tests/vars-api.test.ts +73 -1
- package/tests/vars.test.ts +1 -1
- package/types/agent.d.ts +386 -4
- package/types/config.d.ts +56 -2
- package/types/file.d.ts +1 -1
- package/types/mcp.d.ts +2 -2
- package/utils/func.ts +20 -0
- 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
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
//
|
|
454
|
-
|
|
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
|
|
10
|
-
* {tenant.dir}/mcp/tools/**\/*.tool.ts
|
|
11
|
-
* {tenant.dir}/mcp/resources/**\/*.resource.ts
|
|
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
|
-
|
|
16
|
-
const
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
96
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|