melchizedek-agents 1.13.0 → 1.14.0

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/.env.example CHANGED
@@ -295,7 +295,10 @@ PUBLIC_URL=
295
295
  # OpenAPI tools: the exact environment variables an `auth:` may name. Unset, any
296
296
  # variable but the framework's own settings (this file's) is allowed.
297
297
  # OPENAPI_CREDENTIAL_ENVS=WEATHER_API_KEY,CRM_TOKEN
298
- # A2A_BODY_LIMIT=1mb
298
+ # Inline files in one message, decoded bytes; 0 refuses file parts. A file by URL is always refused.
299
+ # A2A_MAX_FILE_BYTES=10485760
300
+ # Set by default to hold A2A_MAX_FILE_BYTES as base64.
301
+ # A2A_BODY_LIMIT=15mb
299
302
  # How long SIGTERM waits for running tasks before canceling them.
300
303
  # A2A_SHUTDOWN_GRACE_MS=25000
301
304
  # Of that, how long /readyz fails while requests are still served, so a load
package/CHANGELOG.md CHANGED
@@ -4,6 +4,24 @@ Consumers of the package read this file; it records changes to the
4
4
  **published API surface** (the exports map in `package.json`, the bins,
5
5
  the starter pack and the templates), not the repo's full history.
6
6
 
7
+ ## 1.14.0 — 2026-11-01
8
+
9
+ **Files in a message.** The A2A server takes inline file parts (`raw`, or 0.3's
10
+ `file.bytes`): images and documents the agent's model reads go to it as blobs,
11
+ text types as a text part, and anything else ends `rejected` naming what it
12
+ does read. `A2A_MAX_FILE_BYTES` (default 10 MiB) caps one message's files, and
13
+ 0 restores the old refusal; `A2A_BODY_LIMIT` now defaults to what holds that
14
+ limit as base64. A file by URL is still refused. The agent card's
15
+ `defaultInputModes` lists the types.
16
+
17
+ **`attachmentSupport(model)`** (from the barrel) says which file types a
18
+ model's path takes and how large, for a caller that converts a file first.
19
+
20
+ **Claude reads PDFs.** A `application/pdf` blob is a `document` block; it was
21
+ dropped. The chat-completions adapters no longer label a non-image blob as an
22
+ image, which failed the whole request; it is left out with a warning. A failed
23
+ call's recorded request keeps a file's size, not its bytes.
24
+
7
25
  ## 1.13.0 — 2026-10-24
8
26
 
9
27
  **A Prompt Writer template.** `templates/prompt_writer.yaml` writes
package/DOCUMENTATION.md CHANGED
@@ -1056,7 +1056,7 @@ running". The lock is in-process by default and a Postgres advisory lock
1056
1056
  with `DATABASE_URL`, so it holds across instances. `tasks/cancel` stops a running task, including the model call
1057
1057
  in flight, and the task ends `canceled`. Final states: `completed`,
1058
1058
  `failed` (the message names the stage and the provider's reason),
1059
- `canceled`, `rejected` (a file part, an empty message, or the server at
1059
+ `canceled`, `rejected` (a file its model does not read, a file by URL, an empty message, or the server at
1060
1060
  capacity). Message parts may be `text` or `data` (sent to the model as
1061
1061
  JSON); `file` parts are refused.
1062
1062
 
@@ -309,8 +309,10 @@ export interface A2AAppOptions {
309
309
  };
310
310
  /** Express `trust proxy` setting (hop count, boolean, or subnet list). */
311
311
  trustProxy?: number | boolean | string;
312
- /** JSON body size limit, e.g. "1mb". */
312
+ /** JSON body size limit, e.g. "1mb". An inline file is base64 in this body: leave room for `maxFileBytes`. */
313
313
  bodyLimit?: string;
314
+ /** Largest total of inline files in one message, decoded bytes. Undefined or 0 refuses file parts. */
315
+ maxFileBytes?: number;
314
316
  /**
315
317
  * Allowlist of agent ids reachable at /:agentId/*. Undefined = any id with
316
318
  * a file in the deployment's own agents directory. Ids listed here may also
@@ -386,6 +388,8 @@ export declare function compileAgentCard(config: SyndicateYamlConfig, opts: {
386
388
  identity?: IdentityScheme;
387
389
  version: string;
388
390
  byok?: boolean;
391
+ /** The server takes file parts: the card names what this agent's model reads. */
392
+ files?: boolean;
389
393
  }): AgentCard;
390
394
  /**
391
395
  * The app name a syndicate's sessions and memory are stored under: its
@@ -73,6 +73,7 @@ import { setCredentialHosts } from '../tools/credentialHosts.js';
73
73
  import { syndicateCredentialHostProblems } from '../tools/credentialUses.js';
74
74
  import { syndicateOAuthHostProblems } from '../tools/oauthTools.js';
75
75
  import { credentialValue } from '../models/credentialSource.js';
76
+ import { attachmentSupport } from '../models/attachments.js';
76
77
  const SURFACE_HEADERS = [
77
78
  ['x-surface', 'name'],
78
79
  ['x-surface-guild', 'guild'],
@@ -183,6 +184,7 @@ const scopeUserBuilder = async () => {
183
184
  */
184
185
  export function compileAgentCard(config, opts) {
185
186
  const base = `${opts.baseUrl.replace(/\/$/, '')}${opts.routePrefix ?? ''}`;
187
+ const fileSupport = opts.files ? attachmentSupport(config.orchestrator?.model) : undefined;
186
188
  const securitySchemes = {};
187
189
  if (opts.byok) {
188
190
  securitySchemes.apiKey = {
@@ -242,7 +244,7 @@ export function compileAgentCard(config, opts) {
242
244
  },
243
245
  securitySchemes,
244
246
  securityRequirements: Object.keys(requirement).length ? [{ schemes: requirement }] : [],
245
- defaultInputModes: ['text/plain', 'application/json'],
247
+ defaultInputModes: ['text/plain', 'application/json', ...(fileSupport ? [...fileSupport.images, ...fileSupport.documents, ...fileSupport.media] : [])],
246
248
  defaultOutputModes: ['text/plain'],
247
249
  skills: (config.subagents ?? []).map((sub) => ({
248
250
  id: sub.name,
@@ -541,6 +543,7 @@ export async function createA2AApp(options) {
541
543
  memoryService: services.memoryService,
542
544
  compileFor,
543
545
  taskTimeoutMs: options.taskTimeoutMs,
546
+ maxFileBytes: options.maxFileBytes,
544
547
  streamText: options.streamText,
545
548
  turnLock,
546
549
  turnLockWaitMs: options.turnLockWaitMs,
@@ -556,6 +559,7 @@ export async function createA2AApp(options) {
556
559
  });
557
560
  const version = executor.configHashFor().slice(0, 12);
558
561
  const cardFor = (base) => compileAgentCard(cfg, {
562
+ files: !!options.maxFileBytes,
559
563
  baseUrl: base,
560
564
  routePrefix,
561
565
  bearer: !!options.serverSecret,
@@ -26,6 +26,7 @@ import type { TaskRecord } from '../observability/metrics.ts';
26
26
  import type { AuditSink } from '../observability/audit.ts';
27
27
  import type { Policy } from './policy.ts';
28
28
  import type { Metadata } from '../observability/metadata.ts';
29
+ import type { AttachmentSupport } from '../models/attachments.ts';
29
30
  /** Per-request caller context, set by the server's identity middleware. */
30
31
  export interface A2AContext {
31
32
  /** The caller's provider key in BYOK mode (or from resolveRequest); '' otherwise. */
@@ -108,17 +109,24 @@ export declare function describeTurnError(error: {
108
109
  }): string;
109
110
  /** The failed task's message for a turn result. */
110
111
  export declare function describeFailedTurn(result: SyndicateTurnResult): string;
112
+ /** What this server takes as a file part: the bytes it allows and what the agent's model reads. */
113
+ export interface FilePartPolicy {
114
+ /** Largest total of files in one message, decoded bytes. */
115
+ maxBytes: number;
116
+ support: AttachmentSupport;
117
+ }
111
118
  /**
112
119
  * A2A message parts → model parts. Text passes through; a `data` part
113
120
  * becomes its JSON as text (callers inject structured payloads this way).
114
- * File parts (`url` or `raw` content) are refused with a clear error rather
115
- * than silently blanked: a file URL fetched server-side is an SSRF surface,
116
- * and inline bytes need a size policy this server does not yet have.
121
+ * An inline file (`raw` content, or 0.3's `file.bytes`) becomes a blob part
122
+ * the agent's model reads, or text when it is text, within `files`; anything
123
+ * else is refused with a clear error rather than silently blanked. A file by
124
+ * URL is always refused: fetching one server-side is an SSRF surface.
117
125
  *
118
126
  * Accepts A2A 1.0 parts (`content.$case`) and, for callers that hand in
119
127
  * plain objects, the 0.3 shapes (`kind: 'text' | 'data' | 'file'`).
120
128
  */
121
- export declare function a2aPartsToMessage(rawParts: unknown[]): {
129
+ export declare function a2aPartsToMessage(rawParts: unknown[], files?: FilePartPolicy): {
122
130
  parts: MessagePart[];
123
131
  refused?: string;
124
132
  };
@@ -132,6 +140,8 @@ export interface ExecutorOptions {
132
140
  compileFor: (ctx: A2AContext) => CompileOptions;
133
141
  /** Wall-clock budget per task in ms; 0 or undefined = none. */
134
142
  taskTimeoutMs?: number;
143
+ /** Largest total of inline files in one message, decoded bytes; 0 or undefined refuses file parts. */
144
+ maxFileBytes?: number;
135
145
  /** Shared across every executor on the server. */
136
146
  limiter: TaskLimiter;
137
147
  /** The agent id this executor serves ('' for the default syndicate). */
@@ -27,6 +27,7 @@ import { isWorkflowSyndicate } from '../workflow.js';
27
27
  import { describeInput } from '../runtime/syndicateTurn.js';
28
28
  import { describeConsent } from '../runtime/credentials.js';
29
29
  import { MAX_META_KEYS, metaAttributes, requestMetadata } from '../observability/metadata.js';
30
+ import { attachmentSupport, takesBlob } from '../models/attachments.js';
30
31
  export const requestContextStorage = new AsyncLocalStorage();
31
32
  /** The app name every A2A session and memory silo is stored under. */
32
33
  export const A2A_APP_NAME = 'melchizedek-a2a';
@@ -143,19 +144,62 @@ export function describeFailedTurn(result) {
143
144
  }
144
145
  return `Error: [${error.code}] The agent run failed — ${describeTurnError(error)}`;
145
146
  }
147
+ /** Types read as text whatever the model: they go to it as a text part. */
148
+ const TEXT_FILE = /^(text\/[\w.+-]+|application\/(json|xml|yaml|x-yaml|x-ndjson|csv))$/;
149
+ /** A file's name as the model reads it: printable and short, since the caller chose it. */
150
+ const fileLabel = (name) => (typeof name === 'string' ? name : '').replace(/[^\p{L}\p{N} ._()-]/gu, '_').slice(0, 120) || 'file';
151
+ /** One inline file as model parts, or why it is refused. */
152
+ function filePart(bytes, declared, name, policy) {
153
+ const type = String(declared ?? '').split(';')[0].trim().toLowerCase();
154
+ const shown = type.replace(/[^\x20-\x7e]/g, '?').slice(0, 64) || 'a file with no type';
155
+ if (TEXT_FILE.test(type)) {
156
+ let text;
157
+ try {
158
+ text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
159
+ }
160
+ catch {
161
+ return `The file part declared as ${shown} is not UTF-8 text.`;
162
+ }
163
+ return { text: `<attached_file name="${fileLabel(name)}" type="${shown}">\n${text}\n</attached_file>` };
164
+ }
165
+ if (!takesBlob(policy.support, type))
166
+ return `This agent's model does not read ${shown}. It reads ${[...policy.support.images, ...policy.support.documents, ...policy.support.media].join(', ')} and text.`;
167
+ const limit = policy.support.images.includes(type) ? policy.support.maxImageBytes : policy.support.maxFileBytes;
168
+ if (bytes.length > limit)
169
+ return `A ${shown} file may be at most ${limit} bytes for this agent's model; this one is ${bytes.length}.`;
170
+ return { inlineData: { mimeType: type, data: bytes.toString('base64') } };
171
+ }
146
172
  /**
147
173
  * A2A message parts → model parts. Text passes through; a `data` part
148
174
  * becomes its JSON as text (callers inject structured payloads this way).
149
- * File parts (`url` or `raw` content) are refused with a clear error rather
150
- * than silently blanked: a file URL fetched server-side is an SSRF surface,
151
- * and inline bytes need a size policy this server does not yet have.
175
+ * An inline file (`raw` content, or 0.3's `file.bytes`) becomes a blob part
176
+ * the agent's model reads, or text when it is text, within `files`; anything
177
+ * else is refused with a clear error rather than silently blanked. A file by
178
+ * URL is always refused: fetching one server-side is an SSRF surface.
152
179
  *
153
180
  * Accepts A2A 1.0 parts (`content.$case`) and, for callers that hand in
154
181
  * plain objects, the 0.3 shapes (`kind: 'text' | 'data' | 'file'`).
155
182
  */
156
- export function a2aPartsToMessage(rawParts) {
183
+ export function a2aPartsToMessage(rawParts, files) {
157
184
  const parts = [];
158
- const refusal = 'File parts are not supported by this server; send text or data parts.';
185
+ const refusal = files ? 'A file part given by URL is not fetched by this server; send its bytes.' : 'File parts are not supported by this server; send text or data parts.';
186
+ let total = 0;
187
+ /** Adds one inline file; a string is why the message is refused. */
188
+ const addFile = (value, declared, name) => {
189
+ if (!files)
190
+ return refusal;
191
+ const bytes = typeof value === 'string' ? Buffer.from(value, 'base64') : value instanceof Uint8Array ? Buffer.from(value) : null;
192
+ if (!bytes || bytes.length === 0)
193
+ return 'A file part has no bytes.';
194
+ total += bytes.length;
195
+ if (total > files.maxBytes)
196
+ return `The files in one message may total at most ${files.maxBytes} bytes on this server.`;
197
+ const part = filePart(bytes, declared, name, files);
198
+ if (typeof part === 'string')
199
+ return part;
200
+ parts.push(part);
201
+ return undefined;
202
+ };
159
203
  for (const raw of rawParts) {
160
204
  if (typeof raw === 'string') {
161
205
  parts.push({ text: raw });
@@ -168,8 +212,13 @@ export function a2aPartsToMessage(rawParts) {
168
212
  parts.push({ text: content.value });
169
213
  else if (content.$case === 'data')
170
214
  parts.push({ text: JSON.stringify(content.value) });
171
- else if (content.$case === 'url' || content.$case === 'raw')
215
+ else if (content.$case === 'url')
172
216
  return { parts, refused: refusal };
217
+ else if (content.$case === 'raw') {
218
+ const refused = addFile(content.value, p.mediaType, p.filename);
219
+ if (refused)
220
+ return { parts, refused };
221
+ }
173
222
  continue;
174
223
  }
175
224
  const kind = p.kind ?? (p.text !== undefined ? 'text' : p.data !== undefined ? 'data' : p.file !== undefined ? 'file' : undefined);
@@ -181,7 +230,11 @@ export function a2aPartsToMessage(rawParts) {
181
230
  parts.push({ text: JSON.stringify(p.data) });
182
231
  }
183
232
  else if (kind === 'file') {
184
- return { parts, refused: refusal };
233
+ if (p.file?.bytes === undefined)
234
+ return { parts, refused: refusal };
235
+ const refused = addFile(p.file.bytes, p.file.mimeType, p.file.name);
236
+ if (refused)
237
+ return { parts, refused };
185
238
  }
186
239
  }
187
240
  return { parts };
@@ -606,7 +659,7 @@ export class SyndicateExecutor {
606
659
  throw new Error('No authentication context available.');
607
660
  ctxForReport = ctx;
608
661
  const rawParts = message?.parts ?? message?.content ?? [];
609
- const mapped = a2aPartsToMessage(rawParts);
662
+ const mapped = a2aPartsToMessage(rawParts, this.opts.maxFileBytes ? { maxBytes: this.opts.maxFileBytes, support: attachmentSupport(this.opts.config.orchestrator?.model, { callerKey: !!ctx.apiKey }) } : undefined);
610
663
  const refused = mapped.refused;
611
664
  let parts = mapped.parts;
612
665
  if (refused) {
@@ -68,6 +68,8 @@ export type { MemoryStore } from './memory/store.ts';
68
68
  export { validateSyndicateConfig, syndicateJsonSchema, SyndicateValidationError } from './syndicateSchema.ts';
69
69
  export { PROVIDERS, providerForModel, providerKeyPresent, providerStatuses, logProviderStatuses, resolveModel, describeCapabilities, capabilitySummary, planTransport, gatewayConfig, gatewayProblem, gatewayUsable, GATEWAYS, } from './models/registry.ts';
70
70
  export { loadEnv } from './loadEnv.ts';
71
+ export { attachmentSupport, takesBlob } from './models/attachments.ts';
72
+ export type { AttachmentSupport } from './models/attachments.ts';
71
73
  export { setCredentialSource, credentialValue } from './models/credentialSource.ts';
72
74
  export type { CredentialSource } from './models/credentialSource.ts';
73
75
  export { PROJECT_MANIFEST, projectManifestSchema, ProjectManifestError, findProject, findProjectRoot, loadProject, parseProjectManifest, resolveAgentsDir, listProjectSyndicates, checkProject, type Project, type ProjectManifest, type ProjectCheck, } from './project.ts';
package/dist/lib/index.js CHANGED
@@ -54,6 +54,8 @@ export { PROVIDERS, providerForModel, providerKeyPresent, providerStatuses, logP
54
54
  // subpath until 0.16.0.
55
55
  describeCapabilities, capabilitySummary, planTransport, gatewayConfig, gatewayProblem, gatewayUsable, GATEWAYS, } from './models/registry.js';
56
56
  export { loadEnv } from './loadEnv.js';
57
+ // Which files a model's path takes in a user message, and how large.
58
+ export { attachmentSupport, takesBlob } from './models/attachments.js';
57
59
  // Where credentials are read, by environment variable name (ADR 0133).
58
60
  export { setCredentialSource, credentialValue } from './models/credentialSource.js';
59
61
  // Project manifest, melchizedek.json (ADR 0138).
@@ -0,0 +1,42 @@
1
+ /**
2
+ * lib/models/attachments.ts — which files a model's path takes in a user
3
+ * message, and how large.
4
+ *
5
+ * WHY this file exists:
6
+ * A file reaches a model as a blob part, and what an adapter can send
7
+ * differs by provider: Gemini takes audio and video, the Messages API and
8
+ * the Responses API take PDFs, a chat-completions path takes images only. A
9
+ * caller that converts or shrinks a file first (the desktop app), and the
10
+ * A2A server that refuses one, read the same table, so neither learns of a
11
+ * mismatch from a provider's 400.
12
+ *
13
+ * Limits are the provider's published ones, set a little under where the
14
+ * base64 a request carries is what is counted. `maxRequestBytes` is decoded
15
+ * bytes across a whole request, and a conversation's earlier files are in it.
16
+ */
17
+ export interface AttachmentSupport {
18
+ /** Image types sent as images. */
19
+ images: readonly string[];
20
+ /** Document types sent as documents; anything else must arrive as text. */
21
+ documents: readonly string[];
22
+ /** Audio and video types sent as they are. */
23
+ media: readonly string[];
24
+ /** The longest image edge worth sending, in pixels: past it the provider shrinks the image or refuses it. */
25
+ maxImageEdge: number;
26
+ /** Largest image, decoded bytes. */
27
+ maxImageBytes: number;
28
+ /** Largest document or media file, decoded bytes. */
29
+ maxFileBytes: number;
30
+ /** Largest total of files in one request, decoded bytes. */
31
+ maxRequestBytes: number;
32
+ }
33
+ /**
34
+ * What `model` takes on the path it will actually run on. A gateway speaks
35
+ * chat completions whichever provider is behind it, so a direct-only type is
36
+ * not offered there. An id no path serves reads as the plain table.
37
+ */
38
+ export declare function attachmentSupport(model: unknown, opts?: {
39
+ callerKey?: boolean;
40
+ }): AttachmentSupport;
41
+ /** Whether a media type is sent as a blob on this path (not whether it fits). */
42
+ export declare function takesBlob(support: AttachmentSupport, mediaType: string): boolean;
@@ -0,0 +1,80 @@
1
+ /**
2
+ * lib/models/attachments.ts — which files a model's path takes in a user
3
+ * message, and how large.
4
+ *
5
+ * WHY this file exists:
6
+ * A file reaches a model as a blob part, and what an adapter can send
7
+ * differs by provider: Gemini takes audio and video, the Messages API and
8
+ * the Responses API take PDFs, a chat-completions path takes images only. A
9
+ * caller that converts or shrinks a file first (the desktop app), and the
10
+ * A2A server that refuses one, read the same table, so neither learns of a
11
+ * mismatch from a provider's 400.
12
+ *
13
+ * Limits are the provider's published ones, set a little under where the
14
+ * base64 a request carries is what is counted. `maxRequestBytes` is decoded
15
+ * bytes across a whole request, and a conversation's earlier files are in it.
16
+ */
17
+ import { planTransport } from './gateway.js';
18
+ const MB = 1024 * 1024;
19
+ /** Any path that takes a data URI: the chat-completions adapters, Ollama and the gateways. */
20
+ const PLAIN = {
21
+ images: ['image/jpeg', 'image/png'],
22
+ documents: [],
23
+ media: [],
24
+ maxImageEdge: 1568,
25
+ maxImageBytes: 8 * MB,
26
+ maxFileBytes: 0,
27
+ maxRequestBytes: 14 * MB,
28
+ };
29
+ const DIRECT = {
30
+ anthropic: {
31
+ images: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
32
+ documents: ['application/pdf'],
33
+ media: [],
34
+ maxImageEdge: 1568,
35
+ maxImageBytes: 3.75 * MB,
36
+ maxFileBytes: 22 * MB,
37
+ maxRequestBytes: 22 * MB,
38
+ },
39
+ openai: {
40
+ images: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
41
+ documents: ['application/pdf'],
42
+ media: [],
43
+ maxImageEdge: 2048,
44
+ maxImageBytes: 20 * MB,
45
+ maxFileBytes: 32 * MB,
46
+ maxRequestBytes: 36 * MB,
47
+ },
48
+ gemini: {
49
+ images: ['image/jpeg', 'image/png', 'image/webp', 'image/heic', 'image/heif'],
50
+ documents: ['application/pdf'],
51
+ media: [
52
+ 'audio/wav', 'audio/mp3', 'audio/mpeg', 'audio/aiff', 'audio/aac', 'audio/ogg', 'audio/flac',
53
+ 'video/mp4', 'video/mpeg', 'video/quicktime', 'video/webm',
54
+ ],
55
+ maxImageEdge: 3072,
56
+ maxImageBytes: 14 * MB,
57
+ maxFileBytes: 14 * MB,
58
+ maxRequestBytes: 14 * MB,
59
+ },
60
+ };
61
+ /**
62
+ * What `model` takes on the path it will actually run on. A gateway speaks
63
+ * chat completions whichever provider is behind it, so a direct-only type is
64
+ * not offered there. An id no path serves reads as the plain table.
65
+ */
66
+ export function attachmentSupport(model, opts = {}) {
67
+ if (typeof model !== 'string' || !model)
68
+ return PLAIN;
69
+ try {
70
+ const plan = planTransport(model, opts);
71
+ return (plan.transport === 'direct' && DIRECT[plan.provider]) || PLAIN;
72
+ }
73
+ catch {
74
+ return PLAIN;
75
+ }
76
+ }
77
+ /** Whether a media type is sent as a blob on this path (not whether it fits). */
78
+ export function takesBlob(support, mediaType) {
79
+ return support.images.includes(mediaType) || support.documents.includes(mediaType) || support.media.includes(mediaType);
80
+ }
@@ -837,7 +837,12 @@ export class ChatCompletionsAdapter {
837
837
  else if (part.data) {
838
838
  // Inline only: a data URI for vision models. A URL blob is not
839
839
  // sent (Moonshot takes no public image URLs).
840
- images.push({ type: 'image_url', image_url: { url: `data:${part.mimeType || 'image/png'};base64,${part.data}` } });
840
+ const type = !part.mimeType || part.mimeType === 'application/octet-stream' ? 'image/png' : part.mimeType;
841
+ // This wire has images only; a PDF labelled as one would fail the whole request.
842
+ if (type.startsWith('image/'))
843
+ images.push({ type: 'image_url', image_url: { url: `data:${type};base64,${part.data}` } });
844
+ else
845
+ console.warn(`⚠ A file part (${type.replace(/[^\x20-\x7e]/g, '?').slice(0, 64)}) is not sent: this path takes images only.`);
841
846
  }
842
847
  }
843
848
  const text = texts.join('\n');
@@ -105,6 +105,16 @@ function imageBlockFor(part, urlImages) {
105
105
  const named = (type) => type.replace(/[^\x20-\x7e]/g, '?').slice(0, 64);
106
106
  const declared = String(part.mimeType ?? '').toLowerCase();
107
107
  const untyped = UNTYPED.has(declared);
108
+ if (declared === 'application/pdf') {
109
+ if (part.data !== undefined)
110
+ return part.data ? { type: 'document', source: { type: 'base64', media_type: 'application/pdf', data: part.data } } : undefined;
111
+ const uri = String(part.url ?? '');
112
+ if (!uri)
113
+ return undefined;
114
+ if (!/^https:\/\//i.test(uri))
115
+ return `non-https URL (${/^[a-z][a-z0-9+.-]*:/i.exec(uri)?.[0] ?? 'no scheme'})`;
116
+ return urlImages ? { type: 'document', source: { type: 'url', url: uri } } : URL_SOURCE;
117
+ }
108
118
  if (part.data !== undefined) {
109
119
  if (!part.data)
110
120
  return undefined;
@@ -628,7 +628,7 @@ function safePayloadJson(value) {
628
628
  /** What `llm.payload.request` records for a failed call: the ModelRequest less its signal, else the older `llmRequest`. */
629
629
  function payloadRequest(meta) {
630
630
  if (meta.request === undefined)
631
- return meta.llmRequest;
631
+ return withoutFileBytes(meta.llmRequest);
632
632
  let request;
633
633
  try {
634
634
  request = typeof meta.request === 'function' ? meta.request() : meta.request;
@@ -637,7 +637,18 @@ function payloadRequest(meta) {
637
637
  return undefined;
638
638
  }
639
639
  const { signal: _signal, ...recorded } = request;
640
- return recorded;
640
+ return withoutFileBytes(recorded);
641
+ }
642
+ /** A copy with each file's bytes replaced by their count: a request's base64 would fill the payload's cap and say nothing a reader can use. */
643
+ function withoutFileBytes(value) {
644
+ if (Array.isArray(value))
645
+ return value.map(withoutFileBytes);
646
+ if (!value || typeof value !== 'object')
647
+ return value;
648
+ const v = value;
649
+ if (typeof v.mimeType === 'string' && typeof v.data === 'string')
650
+ return { ...v, data: `[${Math.floor((v.data.length * 3) / 4)} bytes not recorded]` };
651
+ return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, withoutFileBytes(x)]));
641
652
  }
642
653
  /**
643
654
  * Providers throw errors whose message IS the API's JSON error body
@@ -46,7 +46,8 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
46
46
  * A2A_AUTH_FAILURE_MAX failed authentications per window per IP (30)
47
47
  * A2A_TRUST_PROXY Express trust-proxy: hop count, true/false, or subnets
48
48
  * (false; required with PUBLIC_URL)
49
- * A2A_BODY_LIMIT JSON body limit ("1mb")
49
+ * A2A_MAX_FILE_BYTES inline files in one message, decoded bytes (10485760; 0 refuses file parts)
50
+ * A2A_BODY_LIMIT JSON body limit ("1mb", or "15mb" with file parts on)
50
51
  * A2A_KEY_MODE server (default): models run on the server's keys;
51
52
  * byok: the caller's X-API-Key pays. Under A2A_AUTH=secret
52
53
  * the mode also scopes data: X-User-Id in server mode,
@@ -479,7 +480,9 @@ export async function startServer(syndicateName = 'syndicate.yaml', extensions =
479
480
  authFailureLimit: { windowMs: 15 * 60 * 1000, max: envInt('A2A_AUTH_FAILURE_MAX', 30) },
480
481
  trustProxy: envTrustProxy(),
481
482
  ...(limitStore ? { limitStore } : {}),
482
- bodyLimit: process.env.A2A_BODY_LIMIT?.trim() || '1mb',
483
+ maxFileBytes: envInt('A2A_MAX_FILE_BYTES', 10 * 1024 * 1024),
484
+ // Base64 is 4/3 of the bytes; the body must hold what the file limit allows.
485
+ bodyLimit: process.env.A2A_BODY_LIMIT?.trim() || `${Math.max(1, Math.ceil((envInt('A2A_MAX_FILE_BYTES', 10 * 1024 * 1024) * 4) / 3 / (1024 * 1024)) + 1)}mb`,
483
486
  servedAgents,
484
487
  registryAgents,
485
488
  keyMode,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "melchizedek-agents",
3
- "version": "1.13.0",
3
+ "version": "1.14.0",
4
4
  "description": "A multi-model, multi-agent orchestration framework — YAML-defined syndicates on the engine's own agent loop, Supabase or Postgres session persistence, pgvector long-term memory. Companion repo to the lyceumagents.com curriculum.",
5
5
  "type": "module",
6
6
  "license": "MIT",