@canonmsg/agent-sdk 8.5.0 → 8.6.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/README.md CHANGED
@@ -356,8 +356,8 @@ agent.on('message', async ({ messages, media }) => {
356
356
  });
357
357
  ```
358
358
 
359
- - `media.materialize(message?)` downloads the message's attachments on demand into `~/.canon/media-cache`.
360
- - `media.uploadFile(path, options?)` uploads a local file into the current Canon conversation and returns the canonical attachment metadata.
359
+ - `media.materialize(message?, options?)` streams attachments on demand into `~/.canon/media-cache`. Automatic materialization is capped at 10 MiB per attachment before and during the download. A runtime that deliberately needs a larger input can pass `maxBytes`, up to 100 MiB. Failed or oversized attachments are reported through optional `onError`; valid siblings are still returned. Cancellation still aborts the complete operation. Cache files are published through an atomic rename. The cache is persistent and currently has no aggregate quota or automatic eviction, so hosted-agent operators should monitor or periodically clear it.
360
+ - `media.uploadFile(path, options?)` streams a local file (up to 100 MiB) through Canon's private resumable-capable upload lane and returns `{ uploadId, url, attachment }`. The attachment carries the same server-issued `uploadId`, allowing a subsequent durable message/card write to retain the temporary upload. File bytes are never base64-buffered in the SDK. The current helper uses one streamed PUT; a failed application-level attempt starts a new Canon session rather than resuming an acknowledged byte range in the old session.
361
361
  - `media.replyWithFile(path, text?, options?)` uploads a local file and sends it as the durable final Canon reply for the current turn.
362
362
 
363
363
  GIFs are regular image attachments. Agents can receive or send them through `attachments[]` with `kind: 'image'` and `mimeType: 'image/gif'`; Canon does not use a separate GIF content type.
@@ -0,0 +1,14 @@
1
+ import type { FileHandle } from 'node:fs/promises';
2
+ export type BoundedFileReadFailure = 'not-file' | 'too-large';
3
+ export declare class BoundedFileReadError extends Error {
4
+ readonly failure: BoundedFileReadFailure;
5
+ constructor(failure: BoundedFileReadFailure);
6
+ }
7
+ type ReadableFileHandle = Pick<FileHandle, 'stat' | 'read'>;
8
+ /**
9
+ * Read a regular file through an already-open handle without ever requesting
10
+ * more than maxBytes + 1. The extra byte detects a file that grew after stat
11
+ * without allowing an unbounded path-based read.
12
+ */
13
+ export declare function readBoundedRegularFileHandle(handle: ReadableFileHandle, maxBytes: number): Promise<Buffer>;
14
+ export {};
@@ -0,0 +1,34 @@
1
+ export class BoundedFileReadError extends Error {
2
+ failure;
3
+ constructor(failure) {
4
+ super(failure);
5
+ this.failure = failure;
6
+ this.name = 'BoundedFileReadError';
7
+ }
8
+ }
9
+ /**
10
+ * Read a regular file through an already-open handle without ever requesting
11
+ * more than maxBytes + 1. The extra byte detects a file that grew after stat
12
+ * without allowing an unbounded path-based read.
13
+ */
14
+ export async function readBoundedRegularFileHandle(handle, maxBytes) {
15
+ if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) {
16
+ throw new Error('maxBytes must be a positive safe integer');
17
+ }
18
+ const fileStat = await handle.stat();
19
+ if (!fileStat.isFile())
20
+ throw new BoundedFileReadError('not-file');
21
+ if (fileStat.size > maxBytes)
22
+ throw new BoundedFileReadError('too-large');
23
+ const buffer = Buffer.allocUnsafe(maxBytes + 1);
24
+ let totalBytes = 0;
25
+ while (totalBytes < buffer.byteLength) {
26
+ const { bytesRead } = await handle.read(buffer, totalBytes, buffer.byteLength - totalBytes, totalBytes);
27
+ if (bytesRead === 0)
28
+ break;
29
+ totalBytes += bytesRead;
30
+ }
31
+ if (totalBytes > maxBytes)
32
+ throw new BoundedFileReadError('too-large');
33
+ return buffer.subarray(0, totalBytes);
34
+ }
@@ -2028,7 +2028,10 @@ export class CanonAgent {
2028
2028
  return result;
2029
2029
  }
2030
2030
  };
2031
- const uploadFile = (filePath, options) => uploadMediaFile(this.apiClient, conversationId, filePath, options);
2031
+ const uploadFile = (filePath, options) => uploadMediaFile(this.apiClient, conversationId, filePath, {
2032
+ ...(options ?? {}),
2033
+ signal: options?.signal ?? abortController.signal,
2034
+ });
2032
2035
  const replyWithFile = async (filePath, text = '', options) => {
2033
2036
  throwIfAborted();
2034
2037
  try {
@@ -2057,6 +2060,8 @@ export class CanonAgent {
2057
2060
  ...(options?.fileName ? { fileName: options.fileName } : {}),
2058
2061
  ...(options?.mimeType ? { mimeType: options.mimeType } : {}),
2059
2062
  ...(options?.durationMs != null ? { durationMs: options.durationMs } : {}),
2063
+ ...(options?.fetchImpl ? { fetchImpl: options.fetchImpl } : {}),
2064
+ signal: options?.signal ?? abortController.signal,
2060
2065
  });
2061
2066
  durableMessageSequence += 1;
2062
2067
  await sleep(FINAL_MESSAGE_HANDOFF_MS);
@@ -2108,6 +2113,7 @@ export class CanonAgent {
2108
2113
  agentId: agent.agentId,
2109
2114
  conversationId,
2110
2115
  ...(options ?? {}),
2116
+ signal: options?.signal ?? abortController.signal,
2111
2117
  });
2112
2118
  },
2113
2119
  uploadFile,
package/dist/index.d.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  export { CanonAgent } from './canon-agent.js';
2
2
  export type { AgentContactsAPI, AgentConversationsAPI, AgentUsersAPI } from './canon-agent.js';
3
3
  export { ApprovalManager, buildApprovalOutcome, buildApprovalReply, buildApprovalRequest, CanonApiError, DEFAULT_APPROVAL_CONFIG, generateApprovalId, HOST_ADMISSION_ACTION_CAPABILITIES, HOST_ADMISSION_ACTIONS_DISABLED, parseApprovalReplyMetadata, parseApprovalRequestMetadata, parseSessionRule, redactSecrets, } from '@canonmsg/core';
4
- export type { ApprovalConfig, ApprovalNativeRequestMetadata, ApprovalOutcomeMetadata, ApprovalReplyMetadata, ApprovalRequestCategory, ApprovalRequestDetail, ApprovalRequestMetadata, ApprovalResult, ApprovalRisk, CanonContact, CanonRuntimeActivityItem, CanonRuntimeActivityKind, CanonRuntimeActivityStatus, CanonRuntimeTurnModeActivation, CanonRuntimeTurnModeDescriptor, CanonRuntimeTurnModeScope, CanonRuntimeFact, CanonRuntimeFactGroup, CanonResolveAdmissionResult, ContactAddedPayload, ContactCardPayload, ContactRemovedPayload, ContactSource, HostAdmissionActionCapabilities, ParticipationSuppressedPayload, ResolveAdmissionTargetInput, ResolvedAdmissionState, ResolvedAdmissionTargetSummary, ResolvedTargetAdmissionPayload, RuntimeInputAnswers, RuntimeInputChoice, RuntimeInputKind, RuntimeInputNativeMetadata, RuntimeInputQuestion, SessionRule, } from '@canonmsg/core';
4
+ export type { ApprovalConfig, ApprovalNativeRequestMetadata, ApprovalOutcomeMetadata, ApprovalReplyMetadata, ApprovalRequestCategory, ApprovalRequestDetail, ApprovalRequestMetadata, ApprovalResult, ApprovalRisk, CanonContact, CanonRuntimeActivityItem, CanonRuntimeActivityKind, CanonRuntimeActivityStatus, CanonRuntimeTurnModeActivation, CanonRuntimeTurnModeDescriptor, CanonRuntimeTurnModeScope, CanonRuntimeFact, CanonRuntimeFactGroup, CanonResolveAdmissionResult, ContactAddedPayload, ContactCardPayload, ContactRemovedPayload, ContactSource, HostAdmissionActionCapabilities, ParticipationSuppressedPayload, ResolveAdmissionTargetInput, ResolvedAdmissionState, ResolvedAdmissionTargetSummary, ResolvedTargetAdmissionPayload, ResumableMediaUploadResult, RuntimeInputAnswers, RuntimeInputChoice, RuntimeInputKind, RuntimeInputNativeMetadata, RuntimeInputQuestion, SessionRule, } from '@canonmsg/core';
5
5
  export { SessionManager } from './session-manager.js';
6
- export { DEFAULT_MEDIA_CACHE_DIR, getCodexImagePath, getMessageAttachments, inferUploadMimeType, isAnthropicImageAttachment, materializeAttachment, materializeMessageMedia, materializeReplyContextMedia, resolveAttachmentMimeType, sendMediaFileMessage, toAnthropicImageBlock, uploadMediaFile, } from './media.js';
7
- export type { AnthropicImageBlock, AnthropicImageMimeType, MaterializeMediaOptions, MaterializedCanonAttachment, MaterializedCanonReplyContext, ReplyWithFileOptions, UploadMediaFileOptions, } from './media.js';
6
+ export { DEFAULT_ANTHROPIC_REQUEST_HEADROOM_BYTES, DEFAULT_MEDIA_CACHE_DIR, DEFAULT_MEDIA_MATERIALIZATION_BYTES, MAX_ANTHROPIC_IMAGE_RAW_BYTES, MAX_ANTHROPIC_REQUEST_BYTES, MAX_CANON_MEDIA_BYTES, getCodexImagePath, getMessageAttachments, inferUploadMimeType, isAnthropicImageAttachment, materializeAttachment, materializeMessageMedia, materializeReplyContextMedia, resolveAttachmentMimeType, sendMediaFileMessage, toAnthropicImageBlock, toAnthropicImageBlocksWithinBudget, uploadMediaFile, } from './media.js';
7
+ export type { AnthropicImageBlock, AnthropicImageBudgetOptions, AnthropicImageMimeType, MaterializeMediaOptions, MaterializedCanonAttachment, MaterializedCanonReplyContext, ReplyWithFileOptions, UploadMediaFileOptions, } from './media.js';
8
8
  export type { SessionConfig, Session } from './session-manager.js';
9
9
  export type { CanonAgentTurnVerbosityOption } from './turn-verbosity-option.js';
10
10
  export type { AgentContext, CanonGroupContext, CanonKnownRecentParticipant, CanonMembershipChange, CanonContactRequest, GroupInviteRequirements, CanonMessage, CanonConversation, CanonConversationsPage, CanonConversationsPageOptions, CanonReplyContext, CanonSelfContext, CanonTurnContextV2, CanonRuntimeDescriptor, MessageUpdatedPayload, SendContextualMessageOptions, SendContextualMessageResult, SendContextualSelfContextInput, SendMessageOptions, CreateConversationOptions, CreateConversationResult, DirectSessionSelection, TurnVerbosity, TurnVerbosityConfig, } from '@canonmsg/core';
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
1
  export { CanonAgent } from './canon-agent.js';
2
2
  export { ApprovalManager, buildApprovalOutcome, buildApprovalReply, buildApprovalRequest, CanonApiError, DEFAULT_APPROVAL_CONFIG, generateApprovalId, HOST_ADMISSION_ACTION_CAPABILITIES, HOST_ADMISSION_ACTIONS_DISABLED, parseApprovalReplyMetadata, parseApprovalRequestMetadata, parseSessionRule, redactSecrets, } from '@canonmsg/core';
3
3
  export { SessionManager } from './session-manager.js';
4
- export { DEFAULT_MEDIA_CACHE_DIR, getCodexImagePath, getMessageAttachments, inferUploadMimeType, isAnthropicImageAttachment, materializeAttachment, materializeMessageMedia, materializeReplyContextMedia, resolveAttachmentMimeType, sendMediaFileMessage, toAnthropicImageBlock, uploadMediaFile, } from './media.js';
4
+ export { DEFAULT_ANTHROPIC_REQUEST_HEADROOM_BYTES, DEFAULT_MEDIA_CACHE_DIR, DEFAULT_MEDIA_MATERIALIZATION_BYTES, MAX_ANTHROPIC_IMAGE_RAW_BYTES, MAX_ANTHROPIC_REQUEST_BYTES, MAX_CANON_MEDIA_BYTES, getCodexImagePath, getMessageAttachments, inferUploadMimeType, isAnthropicImageAttachment, materializeAttachment, materializeMessageMedia, materializeReplyContextMedia, resolveAttachmentMimeType, sendMediaFileMessage, toAnthropicImageBlock, toAnthropicImageBlocksWithinBudget, uploadMediaFile, } from './media.js';
package/dist/media.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { CanonClient, type CanonReplyContext, type CanonMessage, type MediaAttachment, type SendMessageOptions } from '@canonmsg/core';
1
+ import { CanonClient, type CanonReplyContext, type CanonMessage, type MediaAttachment, type ResumableMediaUploadResult, type SendMessageOptions } from '@canonmsg/core';
2
2
  export interface MaterializeMediaOptions {
3
3
  agentId: string;
4
4
  conversationId: string;
@@ -6,11 +6,20 @@ export interface MaterializeMediaOptions {
6
6
  rootDir?: string;
7
7
  fetchImpl?: typeof fetch;
8
8
  signal?: AbortSignal;
9
+ /**
10
+ * Maximum bytes written to disk per attachment. Automatic materialization
11
+ * defaults to 10 MiB; runtimes may opt in up to Canon's 100 MiB upload cap.
12
+ */
13
+ maxBytes?: number;
14
+ /** Called for each attachment that could not be materialized. */
15
+ onError?: (error: unknown, attachment: MediaAttachment, index: number) => void;
9
16
  }
10
17
  export interface UploadMediaFileOptions {
11
18
  fileName?: string;
12
19
  mimeType?: string;
13
20
  durationMs?: number;
21
+ fetchImpl?: typeof fetch;
22
+ signal?: AbortSignal;
14
23
  }
15
24
  export interface ReplyWithFileOptions extends Omit<SendMessageOptions, 'attachments' | 'contentType'>, UploadMediaFileOptions {
16
25
  }
@@ -44,7 +53,27 @@ export interface AnthropicImageBlock {
44
53
  data: string;
45
54
  };
46
55
  }
56
+ export interface AnthropicImageBudgetOptions {
57
+ /** Text content that shares the Anthropic request with the image blocks. */
58
+ promptText: string;
59
+ /** Optional lower per-image limit for providers with smaller image caps. */
60
+ maxImageRawBytes?: number;
61
+ /** Optional lower total request ceiling. Defaults to Anthropic's 32 MB limit. */
62
+ maxRequestBytes?: number;
63
+ /** Bytes reserved for SDK/request framing outside these content blocks. */
64
+ requestHeadroomBytes?: number;
65
+ }
47
66
  export declare const DEFAULT_MEDIA_CACHE_DIR: string;
67
+ export declare const DEFAULT_MEDIA_MATERIALIZATION_BYTES: number;
68
+ export declare const MAX_CANON_MEDIA_BYTES: number;
69
+ /**
70
+ * Seven MiB expands to just under 10 MB when base64-encoded. This is suitable
71
+ * for the direct Anthropic transport used by the Claude Code host. Providers
72
+ * with smaller encoded-image limits must pass a lower maxImageRawBytes value.
73
+ */
74
+ export declare const MAX_ANTHROPIC_IMAGE_RAW_BYTES: number;
75
+ export declare const MAX_ANTHROPIC_REQUEST_BYTES = 32000000;
76
+ export declare const DEFAULT_ANTHROPIC_REQUEST_HEADROOM_BYTES: number;
48
77
  export declare function getMessageAttachments(message: Pick<CanonMessage, 'attachments'>): MediaAttachment[];
49
78
  export declare function materializeAttachment(attachment: MediaAttachment, options: MaterializeMediaOptions & {
50
79
  index?: number;
@@ -52,10 +81,7 @@ export declare function materializeAttachment(attachment: MediaAttachment, optio
52
81
  export declare function materializeMessageMedia(message: Pick<CanonMessage, 'id' | 'attachments'>, options: Omit<MaterializeMediaOptions, 'messageId'>): Promise<MaterializedCanonAttachment[]>;
53
82
  export declare function materializeReplyContextMedia(replyContext: CanonReplyContext | null, options: Omit<MaterializeMediaOptions, 'messageId'>): Promise<MaterializedCanonReplyContext>;
54
83
  export declare function inferUploadMimeType(filePath: string, overrideMimeType?: string): string;
55
- export declare function uploadMediaFile(client: CanonClient, conversationId: string, filePath: string, options?: UploadMediaFileOptions): Promise<{
56
- url: string;
57
- attachment: MediaAttachment;
58
- }>;
84
+ export declare function uploadMediaFile(client: CanonClient, conversationId: string, filePath: string, options?: UploadMediaFileOptions): Promise<ResumableMediaUploadResult>;
59
85
  export declare function sendMediaFileMessage(client: CanonClient, conversationId: string, filePath: string, text?: string, options?: ReplyWithFileOptions): Promise<{
60
86
  messageId: string;
61
87
  }>;
@@ -75,7 +101,16 @@ export declare function isAnthropicImageAttachment(attachment: MaterializedCanon
75
101
  * Callers should first check `isAnthropicImageAttachment`; this function
76
102
  * throws if the MIME type is not supported.
77
103
  */
78
- export declare function toAnthropicImageBlock(attachment: MaterializedCanonAttachment): Promise<AnthropicImageBlock>;
104
+ export declare function toAnthropicImageBlock(attachment: MaterializedCanonAttachment, options?: {
105
+ maxRawBytes?: number;
106
+ }): Promise<AnthropicImageBlock>;
107
+ /**
108
+ * Convert only the supported images that fit both the direct-image limit and
109
+ * the aggregate request budget. Skipped images remain represented by the
110
+ * paths already rendered into promptText; one unavailable image never drops
111
+ * valid siblings.
112
+ */
113
+ export declare function toAnthropicImageBlocksWithinBudget(attachments: ReadonlyArray<MaterializedCanonAttachment>, options: AnthropicImageBudgetOptions): Promise<AnthropicImageBlock[]>;
79
114
  /**
80
115
  * Return the local path to pass to Codex via its `-i/--image` flag when the
81
116
  * attachment is an image Codex can consume. Codex accepts the same MIME types
package/dist/media.js CHANGED
@@ -1,6 +1,11 @@
1
- import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
1
+ import { createReadStream, createWriteStream } from 'node:fs';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { mkdir, open, rename, stat, unlink } from 'node:fs/promises';
2
4
  import { basename, dirname, extname, join } from 'node:path';
5
+ import { Readable, Transform } from 'node:stream';
6
+ import { pipeline } from 'node:stream/promises';
3
7
  import { CANON_DIR, renderCanonHostInboundContent, } from '@canonmsg/core';
8
+ import { BoundedFileReadError, readBoundedRegularFileHandle, } from './bounded-file-read.js';
4
9
  const ANTHROPIC_IMAGE_MIME_TYPES = new Set([
5
10
  'image/jpeg',
6
11
  'image/png',
@@ -8,6 +13,16 @@ const ANTHROPIC_IMAGE_MIME_TYPES = new Set([
8
13
  'image/webp',
9
14
  ]);
10
15
  export const DEFAULT_MEDIA_CACHE_DIR = join(CANON_DIR, 'media-cache');
16
+ export const DEFAULT_MEDIA_MATERIALIZATION_BYTES = 10 * 1024 * 1024;
17
+ export const MAX_CANON_MEDIA_BYTES = 100 * 1024 * 1024;
18
+ /**
19
+ * Seven MiB expands to just under 10 MB when base64-encoded. This is suitable
20
+ * for the direct Anthropic transport used by the Claude Code host. Providers
21
+ * with smaller encoded-image limits must pass a lower maxImageRawBytes value.
22
+ */
23
+ export const MAX_ANTHROPIC_IMAGE_RAW_BYTES = 7 * 1024 * 1024;
24
+ export const MAX_ANTHROPIC_REQUEST_BYTES = 32_000_000;
25
+ export const DEFAULT_ANTHROPIC_REQUEST_HEADROOM_BYTES = 4 * 1024 * 1024;
11
26
  const EXTENSION_BY_MIME = {
12
27
  'application/json': 'json',
13
28
  'application/pdf': 'pdf',
@@ -100,6 +115,64 @@ async function fileExists(path) {
100
115
  return false;
101
116
  }
102
117
  }
118
+ function resolveMaxBytes(value) {
119
+ if (value === undefined)
120
+ return DEFAULT_MEDIA_MATERIALIZATION_BYTES;
121
+ if (!Number.isSafeInteger(value) || value <= 0 || value > MAX_CANON_MEDIA_BYTES) {
122
+ throw new Error(`maxBytes must be an integer between 1 and ${MAX_CANON_MEDIA_BYTES}`);
123
+ }
124
+ return value;
125
+ }
126
+ function reportMaterializationError(options, error, attachment, index) {
127
+ try {
128
+ options.onError?.(error, attachment, index);
129
+ }
130
+ catch {
131
+ // Diagnostics must never turn a recoverable sibling failure into a failed
132
+ // message materialization.
133
+ }
134
+ }
135
+ async function materializeAvailableAttachments(inputs, options) {
136
+ const settled = await Promise.allSettled(inputs.map((input) => input.run()));
137
+ options.signal?.throwIfAborted();
138
+ const materialized = [];
139
+ let firstError;
140
+ let hasFailure = false;
141
+ settled.forEach((result, resultIndex) => {
142
+ if (result.status === 'fulfilled') {
143
+ materialized.push(result.value);
144
+ return;
145
+ }
146
+ const input = inputs[resultIndex];
147
+ if (!hasFailure) {
148
+ firstError = result.reason;
149
+ hasFailure = true;
150
+ }
151
+ reportMaterializationError(options, result.reason, input.attachment, input.index);
152
+ });
153
+ // Preserve the strict all-failed behavior so callers still learn when no
154
+ // bytes were available, while allowing valid siblings through unchanged.
155
+ if (materialized.length === 0 && hasFailure) {
156
+ throw firstError;
157
+ }
158
+ return materialized;
159
+ }
160
+ function byteLimitTransform(maxBytes) {
161
+ let total = 0;
162
+ return {
163
+ stream: new Transform({
164
+ transform(chunk, _encoding, callback) {
165
+ total += chunk.length;
166
+ if (total > maxBytes) {
167
+ callback(new Error(`Canon media exceeds the ${maxBytes}-byte materialization limit`));
168
+ return;
169
+ }
170
+ callback(null, chunk);
171
+ },
172
+ }),
173
+ bytesRead: () => total,
174
+ };
175
+ }
103
176
  export function getMessageAttachments(message) {
104
177
  return Array.isArray(message.attachments) ? message.attachments : [];
105
178
  }
@@ -113,7 +186,25 @@ export async function materializeAttachment(attachment, options) {
113
186
  rootDir: options.rootDir,
114
187
  });
115
188
  await mkdir(dirname(path), { recursive: true });
189
+ const maxBytes = resolveMaxBytes(options.maxBytes);
190
+ const expectedBytes = typeof attachment.sizeBytes === 'number'
191
+ && Number.isSafeInteger(attachment.sizeBytes)
192
+ && attachment.sizeBytes >= 0
193
+ ? attachment.sizeBytes
194
+ : null;
195
+ if (expectedBytes !== null && expectedBytes > maxBytes) {
196
+ throw new Error(`Canon media exceeds the ${maxBytes}-byte materialization limit`);
197
+ }
116
198
  let responseMimeType = null;
199
+ if (await fileExists(path)) {
200
+ const info = await stat(path);
201
+ if (info.size > maxBytes) {
202
+ throw new Error(`Cached Canon media exceeds the ${maxBytes}-byte materialization limit`);
203
+ }
204
+ if (expectedBytes !== null && info.size !== expectedBytes) {
205
+ await unlink(path).catch(() => { });
206
+ }
207
+ }
117
208
  if (!(await fileExists(path))) {
118
209
  const fetchImpl = ensureFetch(options.fetchImpl);
119
210
  const response = await fetchImpl(attachment.url, {
@@ -123,8 +214,33 @@ export async function materializeAttachment(attachment, options) {
123
214
  throw new Error(`Failed to download Canon media (${response.status} ${response.statusText})`);
124
215
  }
125
216
  responseMimeType = response.headers.get('content-type');
126
- const body = Buffer.from(await response.arrayBuffer());
127
- await writeFile(path, body, { mode: 0o644 });
217
+ const contentLength = response.headers.get('content-length');
218
+ const declaredLength = contentLength === null ? null : Number(contentLength);
219
+ if (declaredLength !== null && Number.isFinite(declaredLength) && declaredLength > maxBytes) {
220
+ throw new Error(`Canon media exceeds the ${maxBytes}-byte materialization limit`);
221
+ }
222
+ if (expectedBytes !== null
223
+ && declaredLength !== null
224
+ && Number.isFinite(declaredLength)
225
+ && declaredLength !== expectedBytes) {
226
+ throw new Error('Canon media response length does not match attachment metadata');
227
+ }
228
+ if (!response.body) {
229
+ throw new Error('Canon media response had no body');
230
+ }
231
+ const tempPath = `${path}.${process.pid}.${randomUUID()}.part`;
232
+ const limiter = byteLimitTransform(maxBytes);
233
+ try {
234
+ await pipeline(Readable.fromWeb(response.body), limiter.stream, createWriteStream(tempPath, { mode: 0o600, flags: 'wx' }));
235
+ if (expectedBytes !== null && limiter.bytesRead() !== expectedBytes) {
236
+ throw new Error('Canon media response length does not match attachment metadata');
237
+ }
238
+ await rename(tempPath, path);
239
+ }
240
+ catch (error) {
241
+ await unlink(tempPath).catch(() => { });
242
+ throw error;
243
+ }
128
244
  }
129
245
  return {
130
246
  ...attachment,
@@ -142,23 +258,32 @@ export async function materializeAttachment(attachment, options) {
142
258
  }
143
259
  export async function materializeMessageMedia(message, options) {
144
260
  const attachments = getMessageAttachments(message);
145
- return Promise.all(attachments.map((attachment, index) => materializeAttachment(attachment, {
146
- ...options,
147
- messageId: message.id,
261
+ return materializeAvailableAttachments(attachments.map((attachment, index) => ({
262
+ attachment,
148
263
  index,
149
- })));
264
+ run: () => materializeAttachment(attachment, {
265
+ ...options,
266
+ messageId: message.id,
267
+ index,
268
+ }),
269
+ })), options);
150
270
  }
151
271
  export async function materializeReplyContextMedia(replyContext, options) {
152
272
  if (!replyContext?.found || !replyContext.attachments?.length) {
153
273
  return { replyContext, materialized: [] };
154
274
  }
155
- const materialized = (await Promise.all(replyContext.attachments.map((attachment, index) => attachment.url
156
- ? materializeAttachment(attachment, {
157
- ...options,
158
- messageId: replyContext.messageId,
159
- index,
160
- })
161
- : Promise.resolve(null)))).filter((attachment) => attachment !== null);
275
+ const materializable = replyContext.attachments.flatMap((attachment, index) => attachment.url
276
+ ? [{
277
+ attachment,
278
+ index,
279
+ run: () => materializeAttachment(attachment, {
280
+ ...options,
281
+ messageId: replyContext.messageId,
282
+ index,
283
+ }),
284
+ }]
285
+ : []);
286
+ const materialized = await materializeAvailableAttachments(materializable, options);
162
287
  return {
163
288
  replyContext: {
164
289
  ...replyContext,
@@ -179,10 +304,77 @@ export function inferUploadMimeType(filePath, overrideMimeType) {
179
304
  return MIME_BY_EXTENSION[extname(filePath).toLowerCase()] ?? 'application/octet-stream';
180
305
  }
181
306
  export async function uploadMediaFile(client, conversationId, filePath, options) {
182
- const buffer = await readFile(filePath);
307
+ options?.signal?.throwIfAborted();
308
+ const fileStat = await stat(filePath);
309
+ if (!fileStat.isFile())
310
+ throw new Error(`Canon media path is not a file: ${filePath}`);
311
+ if (fileStat.size <= 0)
312
+ throw new Error('Canon media file is empty');
313
+ if (fileStat.size > MAX_CANON_MEDIA_BYTES) {
314
+ throw new Error(`Canon media exceeds the ${MAX_CANON_MEDIA_BYTES}-byte upload limit`);
315
+ }
183
316
  const mimeType = inferUploadMimeType(filePath, options?.mimeType);
184
317
  const fileName = options?.fileName ?? basename(filePath);
185
- const uploaded = await client.uploadMedia(conversationId, buffer.toString('base64'), mimeType, fileName);
318
+ const session = await client.createResumableMediaUpload(conversationId, fileStat.size, mimeType, fileName);
319
+ options?.signal?.throwIfAborted();
320
+ if (typeof session.uploadId !== 'string' || session.uploadId.length === 0) {
321
+ throw new Error('Canon returned an invalid resumable upload ID');
322
+ }
323
+ if (session.sizeBytes !== fileStat.size) {
324
+ throw new Error('Canon resumable upload size does not match the local file');
325
+ }
326
+ if (typeof session.mimeType !== 'string' || session.mimeType.length === 0) {
327
+ throw new Error('Canon returned an invalid resumable upload MIME type');
328
+ }
329
+ let uploadUrl;
330
+ try {
331
+ uploadUrl = new URL(session.uploadUrl);
332
+ }
333
+ catch {
334
+ throw new Error('Canon returned an invalid resumable upload URL');
335
+ }
336
+ if (uploadUrl.protocol !== 'https:') {
337
+ throw new Error('Canon returned a non-HTTPS resumable upload URL');
338
+ }
339
+ if (uploadUrl.hostname !== 'storage.googleapis.com'
340
+ || uploadUrl.username.length > 0
341
+ || uploadUrl.password.length > 0) {
342
+ throw new Error('Canon returned an untrusted resumable upload URL');
343
+ }
344
+ const fetchImpl = ensureFetch(options?.fetchImpl);
345
+ const uploadResponse = await fetchImpl(uploadUrl, {
346
+ method: 'PUT',
347
+ headers: {
348
+ 'Content-Type': session.mimeType,
349
+ 'Content-Length': String(fileStat.size),
350
+ 'Content-Range': `bytes 0-${fileStat.size - 1}/${fileStat.size}`,
351
+ },
352
+ body: createReadStream(filePath),
353
+ signal: options?.signal,
354
+ // Node requires this for streaming request bodies. It is intentionally
355
+ // outside the DOM RequestInit type but supported by the built-in fetch.
356
+ duplex: 'half',
357
+ });
358
+ if (!uploadResponse.ok) {
359
+ throw new Error(`Failed to upload Canon media (${uploadResponse.status} ${uploadResponse.statusText})`);
360
+ }
361
+ options?.signal?.throwIfAborted();
362
+ const finalized = await client.finalizeResumableMediaUpload(session.uploadId);
363
+ options?.signal?.throwIfAborted();
364
+ if (finalized.uploadId !== session.uploadId) {
365
+ throw new Error('Canon resumable upload result does not match its session');
366
+ }
367
+ if (finalized.attachment.uploadId !== undefined
368
+ && finalized.attachment.uploadId !== session.uploadId) {
369
+ throw new Error('Canon resumable upload attachment does not match its session');
370
+ }
371
+ const uploaded = {
372
+ ...finalized,
373
+ attachment: {
374
+ ...finalized.attachment,
375
+ uploadId: session.uploadId,
376
+ },
377
+ };
186
378
  if (uploaded.attachment.kind === 'audio'
187
379
  && typeof options?.durationMs === 'number'
188
380
  && Number.isFinite(options.durationMs)
@@ -198,12 +390,15 @@ export async function uploadMediaFile(client, conversationId, filePath, options)
198
390
  return uploaded;
199
391
  }
200
392
  export async function sendMediaFileMessage(client, conversationId, filePath, text = '', options) {
201
- const { fileName, mimeType, durationMs, ...sendOptions } = options ?? {};
393
+ const { fileName, mimeType, durationMs, fetchImpl, signal, ...sendOptions } = options ?? {};
202
394
  const uploaded = await uploadMediaFile(client, conversationId, filePath, {
203
395
  ...(fileName ? { fileName } : {}),
204
396
  ...(mimeType ? { mimeType } : {}),
205
397
  ...(durationMs != null ? { durationMs } : {}),
398
+ ...(fetchImpl ? { fetchImpl } : {}),
399
+ ...(signal ? { signal } : {}),
206
400
  });
401
+ signal?.throwIfAborted();
207
402
  return client.sendMessage(conversationId, text, {
208
403
  ...sendOptions,
209
404
  contentType: uploaded.attachment.kind,
@@ -247,12 +442,39 @@ export function isAnthropicImageAttachment(attachment) {
247
442
  * Callers should first check `isAnthropicImageAttachment`; this function
248
443
  * throws if the MIME type is not supported.
249
444
  */
250
- export async function toAnthropicImageBlock(attachment) {
445
+ export async function toAnthropicImageBlock(attachment, options) {
251
446
  if (!isAnthropicImageAttachment(attachment)) {
252
447
  throw new Error(`Canon attachment ${attachment.index} is not a supported Anthropic image (kind=${attachment.kind}, mime=${attachment.mimeType ?? 'unknown'})`);
253
448
  }
449
+ const maxRawBytes = options?.maxRawBytes ?? MAX_ANTHROPIC_IMAGE_RAW_BYTES;
450
+ if (!Number.isSafeInteger(maxRawBytes)
451
+ || maxRawBytes <= 0
452
+ || maxRawBytes > MAX_ANTHROPIC_IMAGE_RAW_BYTES) {
453
+ throw new Error(`maxRawBytes must be an integer between 1 and ${MAX_ANTHROPIC_IMAGE_RAW_BYTES}`);
454
+ }
254
455
  const mediaType = resolveAttachmentMimeType(attachment);
255
- const buffer = await readFile(attachment.path);
456
+ const buffer = await readAnthropicImageBytes(attachment, maxRawBytes);
457
+ return buildAnthropicImageBlock(buffer, mediaType);
458
+ }
459
+ async function readAnthropicImageBytes(attachment, maxRawBytes) {
460
+ const handle = await open(attachment.path, 'r');
461
+ try {
462
+ return await readBoundedRegularFileHandle(handle, maxRawBytes);
463
+ }
464
+ catch (error) {
465
+ if (error instanceof BoundedFileReadError) {
466
+ if (error.failure === 'not-file') {
467
+ throw new Error(`Canon attachment ${attachment.index} is not a file`);
468
+ }
469
+ throw new Error(`Canon attachment ${attachment.index} exceeds the ${maxRawBytes}-byte Anthropic image limit`);
470
+ }
471
+ throw error;
472
+ }
473
+ finally {
474
+ await handle.close();
475
+ }
476
+ }
477
+ function buildAnthropicImageBlock(buffer, mediaType) {
256
478
  return {
257
479
  type: 'image',
258
480
  source: {
@@ -262,6 +484,87 @@ export async function toAnthropicImageBlock(attachment) {
262
484
  },
263
485
  };
264
486
  }
487
+ function resolveBoundedInteger(input) {
488
+ const value = input.value ?? input.fallback;
489
+ const minimum = input.allowZero ? 0 : 1;
490
+ if (!Number.isSafeInteger(value) || value < minimum || value > input.maximum) {
491
+ throw new Error(`${input.name} must be an integer between ${minimum} and ${input.maximum}`);
492
+ }
493
+ return value;
494
+ }
495
+ function encodedBase64Length(rawBytes) {
496
+ return Math.ceil(rawBytes / 3) * 4;
497
+ }
498
+ function serializedTextBlockBytes(promptText) {
499
+ return Buffer.byteLength(JSON.stringify({ type: 'text', text: promptText }), 'utf8');
500
+ }
501
+ function serializedImageBlockBytes(rawBytes, mediaType) {
502
+ const framingBytes = Buffer.byteLength(JSON.stringify({
503
+ type: 'image',
504
+ source: {
505
+ type: 'base64',
506
+ media_type: mediaType,
507
+ data: '',
508
+ },
509
+ }), 'utf8');
510
+ return framingBytes + encodedBase64Length(rawBytes);
511
+ }
512
+ /**
513
+ * Convert only the supported images that fit both the direct-image limit and
514
+ * the aggregate request budget. Skipped images remain represented by the
515
+ * paths already rendered into promptText; one unavailable image never drops
516
+ * valid siblings.
517
+ */
518
+ export async function toAnthropicImageBlocksWithinBudget(attachments, options) {
519
+ const maxImageRawBytes = resolveBoundedInteger({
520
+ value: options.maxImageRawBytes,
521
+ fallback: MAX_ANTHROPIC_IMAGE_RAW_BYTES,
522
+ maximum: MAX_ANTHROPIC_IMAGE_RAW_BYTES,
523
+ name: 'maxImageRawBytes',
524
+ });
525
+ const maxRequestBytes = resolveBoundedInteger({
526
+ value: options.maxRequestBytes,
527
+ fallback: MAX_ANTHROPIC_REQUEST_BYTES,
528
+ maximum: MAX_ANTHROPIC_REQUEST_BYTES,
529
+ name: 'maxRequestBytes',
530
+ });
531
+ const requestHeadroomBytes = resolveBoundedInteger({
532
+ value: options.requestHeadroomBytes,
533
+ fallback: DEFAULT_ANTHROPIC_REQUEST_HEADROOM_BYTES,
534
+ maximum: maxRequestBytes,
535
+ name: 'requestHeadroomBytes',
536
+ allowZero: true,
537
+ });
538
+ const contentBudgetBytes = maxRequestBytes - requestHeadroomBytes;
539
+ // Exact JSON content-array framing: opening/closing brackets around the
540
+ // text block, then one comma before every native image block.
541
+ let usedBytes = 2 + serializedTextBlockBytes(options.promptText);
542
+ if (usedBytes > contentBudgetBytes)
543
+ return [];
544
+ const blocks = [];
545
+ for (const attachment of attachments) {
546
+ if (!isAnthropicImageAttachment(attachment))
547
+ continue;
548
+ try {
549
+ const mediaType = resolveAttachmentMimeType(attachment);
550
+ const buffer = await readAnthropicImageBytes(attachment, maxImageRawBytes);
551
+ const nextBlockBytes = 1 + serializedImageBlockBytes(buffer.byteLength, mediaType);
552
+ if (usedBytes + nextBlockBytes > contentBudgetBytes)
553
+ continue;
554
+ const block = buildAnthropicImageBlock(buffer, mediaType);
555
+ const actualBlockBytes = 1 + Buffer.byteLength(JSON.stringify(block), 'utf8');
556
+ if (usedBytes + actualBlockBytes > contentBudgetBytes)
557
+ continue;
558
+ blocks.push(block);
559
+ usedBytes += actualBlockBytes;
560
+ }
561
+ catch {
562
+ // A missing, changed, or unreadable file stays as its prompt path. Keep
563
+ // inspecting siblings instead of failing the whole model request.
564
+ }
565
+ }
566
+ return blocks;
567
+ }
265
568
  /**
266
569
  * Return the local path to pass to Codex via its `-i/--image` flag when the
267
570
  * attachment is an image Codex can consume. Codex accepts the same MIME types
package/dist/types.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- export type { AddMemberResult, GroupInviteRequirements, AgentClientType, CanonGroupContext, CanonRuntimeActivityItem, CanonRuntimeActivityKind, CanonRuntimeActivityStatus, CanonRuntimeDescriptor, CanonRuntimeFact, CanonRuntimeFactGroup, CanonRuntimePrimitiveId, CanonRuntimeProvenance, CanonTurnContextV2, CanonMessage, CanonConversation, CanonReplyContext, CanonContact, CanonContactRequest, CanonResolveAdmissionResult, ContactAddedPayload, ContactRemovedPayload, ContactSource, AgentContext, ResolveAdmissionTargetInput, ResolvedAdmissionState, ResolvedAdmissionTargetSummary, ResolvedTargetAdmissionPayload, CanonSelfContext, CreateConversationResult, DirectSessionSelection, SendContextualMessageOptions, SendContextualMessageResult, SendContextualSelfContextInput, SendMessageOptions, SessionConfig, VerbSessionConfig, CreateConversationOptions, TurnLifecycleState, TurnOutputBlock, TurnOutputBlockInput, ApprovalNativeRequestMetadata, ApprovalRequestCategory, ApprovalRequestDetail, ApprovalRequestMetadata, ApprovalRisk, ApprovalReplyMetadata, ApprovalOutcomeMetadata, RuntimeInputChoice, RuntimeInputAnswers, RuntimeInputKind, RuntimeInputNativeMetadata, RuntimeInputQuestion, RuntimeCardNativeMetadata, RuntimeCardV1, SessionRule, ApprovalResult, } from '@canonmsg/core';
2
- import type { AddMemberResult, CanonGroupContext, CanonMessage, CanonConversation, CanonReplyContext, ContactCardPayload, CanonRuntimeActionDispatch, CanonRuntimePrimitiveId, CanonRuntimeProvenance, CanonTurnContextV2, ApprovalNativeRequestMetadata, ApprovalRequestCategory, ApprovalRequestDetail, ApprovalResult, ApprovalRisk, RuntimeInputChoice, RuntimeInputAnswers, RuntimeInputKind, RuntimeInputNativeMetadata, RuntimeInputQuestion, RuntimeCardNativeMetadata, RuntimeCardV1, SendMessageOptions, SendContextualSelfContextInput, VerbSessionConfig, DirectSessionSelection, TurnOutputBlock, TurnOutputBlockInput } from '@canonmsg/core';
1
+ export type { AddMemberResult, GroupInviteRequirements, AgentClientType, CanonGroupContext, CanonRuntimeActivityItem, CanonRuntimeActivityKind, CanonRuntimeActivityStatus, CanonRuntimeDescriptor, CanonRuntimeFact, CanonRuntimeFactGroup, CanonRuntimePrimitiveId, CanonRuntimeProvenance, CanonTurnContextV2, CanonMessage, CanonConversation, CanonReplyContext, CanonContact, CanonContactRequest, CanonResolveAdmissionResult, ContactAddedPayload, ContactRemovedPayload, ContactSource, AgentContext, ResolveAdmissionTargetInput, ResolvedAdmissionState, ResolvedAdmissionTargetSummary, ResolvedTargetAdmissionPayload, CanonSelfContext, CreateConversationResult, DirectSessionSelection, SendContextualMessageOptions, SendContextualMessageResult, SendContextualSelfContextInput, SendMessageOptions, SessionConfig, VerbSessionConfig, CreateConversationOptions, TurnLifecycleState, TurnOutputBlock, TurnOutputBlockInput, ApprovalNativeRequestMetadata, ApprovalRequestCategory, ApprovalRequestDetail, ApprovalRequestMetadata, ApprovalRisk, ApprovalReplyMetadata, ApprovalOutcomeMetadata, RuntimeInputChoice, RuntimeInputAnswers, RuntimeInputKind, RuntimeInputNativeMetadata, RuntimeInputQuestion, RuntimeCardNativeMetadata, RuntimeCardV1, ResumableMediaUploadResult, SessionRule, ApprovalResult, } from '@canonmsg/core';
2
+ import type { AddMemberResult, CanonGroupContext, CanonMessage, CanonConversation, CanonReplyContext, ContactCardPayload, CanonRuntimeActionDispatch, CanonRuntimePrimitiveId, CanonRuntimeProvenance, CanonTurnContextV2, ApprovalNativeRequestMetadata, ApprovalRequestCategory, ApprovalRequestDetail, ApprovalResult, ApprovalRisk, RuntimeInputChoice, RuntimeInputAnswers, RuntimeInputKind, RuntimeInputNativeMetadata, RuntimeInputQuestion, RuntimeCardNativeMetadata, RuntimeCardV1, ResumableMediaUploadResult, SendMessageOptions, SendContextualSelfContextInput, VerbSessionConfig, DirectSessionSelection, TurnOutputBlock, TurnOutputBlockInput } from '@canonmsg/core';
3
3
  import type { MaterializeMediaOptions, MaterializedCanonAttachment, ReplyWithFileOptions, UploadMediaFileOptions } from './media.js';
4
4
  export interface ProgressMessageOptions extends SendMessageOptions {
5
5
  /**
@@ -230,10 +230,7 @@ export interface MessageHandlerContext {
230
230
  /** Canon-managed local media access for the current conversation. */
231
231
  media: {
232
232
  materialize: (message?: CanonMessage, options?: Omit<MaterializeMediaOptions, 'agentId' | 'conversationId' | 'messageId'>) => Promise<MaterializedCanonAttachment[]>;
233
- uploadFile: (filePath: string, options?: UploadMediaFileOptions) => Promise<{
234
- url: string;
235
- attachment: import('@canonmsg/core').MediaAttachment;
236
- }>;
233
+ uploadFile: (filePath: string, options?: UploadMediaFileOptions) => Promise<ResumableMediaUploadResult>;
237
234
  replyWithFile: (filePath: string, text?: string, options?: ReplyWithFileOptions) => Promise<{
238
235
  messageId: string;
239
236
  }>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/agent-sdk",
3
- "version": "8.5.0",
3
+ "version": "8.6.0",
4
4
  "description": "Canon Agent SDK — build AI agents that participate in Canon conversations",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -28,7 +28,7 @@
28
28
  "node": ">=18.0.0"
29
29
  },
30
30
  "dependencies": {
31
- "@canonmsg/core": "^10.3.0"
31
+ "@canonmsg/core": "^10.4.0"
32
32
  },
33
33
  "publishConfig": {
34
34
  "access": "public"