@canonmsg/agent-sdk 8.5.0 → 8.7.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, MediaAttachment, VideoProcessingStatus, VideoProcessingErrorCode, 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,9 +115,75 @@ 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
  }
179
+ function isMaterializableAttachment(attachment) {
180
+ // Missing status is the legacy-ready contract. Processing URLs are reserved
181
+ // future capabilities and failed videos intentionally have no playable
182
+ // object, so neither should trigger a network request from an agent runtime.
183
+ return attachment.kind !== 'video'
184
+ || attachment.processingStatus === undefined
185
+ || attachment.processingStatus === 'ready';
186
+ }
106
187
  export async function materializeAttachment(attachment, options) {
107
188
  const path = buildCachePath({
108
189
  agentId: options.agentId,
@@ -113,7 +194,25 @@ export async function materializeAttachment(attachment, options) {
113
194
  rootDir: options.rootDir,
114
195
  });
115
196
  await mkdir(dirname(path), { recursive: true });
197
+ const maxBytes = resolveMaxBytes(options.maxBytes);
198
+ const expectedBytes = typeof attachment.sizeBytes === 'number'
199
+ && Number.isSafeInteger(attachment.sizeBytes)
200
+ && attachment.sizeBytes >= 0
201
+ ? attachment.sizeBytes
202
+ : null;
203
+ if (expectedBytes !== null && expectedBytes > maxBytes) {
204
+ throw new Error(`Canon media exceeds the ${maxBytes}-byte materialization limit`);
205
+ }
116
206
  let responseMimeType = null;
207
+ if (await fileExists(path)) {
208
+ const info = await stat(path);
209
+ if (info.size > maxBytes) {
210
+ throw new Error(`Cached Canon media exceeds the ${maxBytes}-byte materialization limit`);
211
+ }
212
+ if (expectedBytes !== null && info.size !== expectedBytes) {
213
+ await unlink(path).catch(() => { });
214
+ }
215
+ }
117
216
  if (!(await fileExists(path))) {
118
217
  const fetchImpl = ensureFetch(options.fetchImpl);
119
218
  const response = await fetchImpl(attachment.url, {
@@ -123,8 +222,33 @@ export async function materializeAttachment(attachment, options) {
123
222
  throw new Error(`Failed to download Canon media (${response.status} ${response.statusText})`);
124
223
  }
125
224
  responseMimeType = response.headers.get('content-type');
126
- const body = Buffer.from(await response.arrayBuffer());
127
- await writeFile(path, body, { mode: 0o644 });
225
+ const contentLength = response.headers.get('content-length');
226
+ const declaredLength = contentLength === null ? null : Number(contentLength);
227
+ if (declaredLength !== null && Number.isFinite(declaredLength) && declaredLength > maxBytes) {
228
+ throw new Error(`Canon media exceeds the ${maxBytes}-byte materialization limit`);
229
+ }
230
+ if (expectedBytes !== null
231
+ && declaredLength !== null
232
+ && Number.isFinite(declaredLength)
233
+ && declaredLength !== expectedBytes) {
234
+ throw new Error('Canon media response length does not match attachment metadata');
235
+ }
236
+ if (!response.body) {
237
+ throw new Error('Canon media response had no body');
238
+ }
239
+ const tempPath = `${path}.${process.pid}.${randomUUID()}.part`;
240
+ const limiter = byteLimitTransform(maxBytes);
241
+ try {
242
+ await pipeline(Readable.fromWeb(response.body), limiter.stream, createWriteStream(tempPath, { mode: 0o600, flags: 'wx' }));
243
+ if (expectedBytes !== null && limiter.bytesRead() !== expectedBytes) {
244
+ throw new Error('Canon media response length does not match attachment metadata');
245
+ }
246
+ await rename(tempPath, path);
247
+ }
248
+ catch (error) {
249
+ await unlink(tempPath).catch(() => { });
250
+ throw error;
251
+ }
128
252
  }
129
253
  return {
130
254
  ...attachment,
@@ -142,23 +266,34 @@ export async function materializeAttachment(attachment, options) {
142
266
  }
143
267
  export async function materializeMessageMedia(message, options) {
144
268
  const attachments = getMessageAttachments(message);
145
- return Promise.all(attachments.map((attachment, index) => materializeAttachment(attachment, {
146
- ...options,
147
- messageId: message.id,
148
- index,
149
- })));
269
+ return materializeAvailableAttachments(attachments.flatMap((attachment, index) => (isMaterializableAttachment(attachment)
270
+ ? [{
271
+ attachment,
272
+ index,
273
+ run: () => materializeAttachment(attachment, {
274
+ ...options,
275
+ messageId: message.id,
276
+ index,
277
+ }),
278
+ }]
279
+ : [])), options);
150
280
  }
151
281
  export async function materializeReplyContextMedia(replyContext, options) {
152
282
  if (!replyContext?.found || !replyContext.attachments?.length) {
153
283
  return { replyContext, materialized: [] };
154
284
  }
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);
285
+ const materializable = replyContext.attachments.flatMap((attachment, index) => attachment.url && isMaterializableAttachment(attachment)
286
+ ? [{
287
+ attachment,
288
+ index,
289
+ run: () => materializeAttachment(attachment, {
290
+ ...options,
291
+ messageId: replyContext.messageId,
292
+ index,
293
+ }),
294
+ }]
295
+ : []);
296
+ const materialized = await materializeAvailableAttachments(materializable, options);
162
297
  return {
163
298
  replyContext: {
164
299
  ...replyContext,
@@ -179,10 +314,77 @@ export function inferUploadMimeType(filePath, overrideMimeType) {
179
314
  return MIME_BY_EXTENSION[extname(filePath).toLowerCase()] ?? 'application/octet-stream';
180
315
  }
181
316
  export async function uploadMediaFile(client, conversationId, filePath, options) {
182
- const buffer = await readFile(filePath);
317
+ options?.signal?.throwIfAborted();
318
+ const fileStat = await stat(filePath);
319
+ if (!fileStat.isFile())
320
+ throw new Error(`Canon media path is not a file: ${filePath}`);
321
+ if (fileStat.size <= 0)
322
+ throw new Error('Canon media file is empty');
323
+ if (fileStat.size > MAX_CANON_MEDIA_BYTES) {
324
+ throw new Error(`Canon media exceeds the ${MAX_CANON_MEDIA_BYTES}-byte upload limit`);
325
+ }
183
326
  const mimeType = inferUploadMimeType(filePath, options?.mimeType);
184
327
  const fileName = options?.fileName ?? basename(filePath);
185
- const uploaded = await client.uploadMedia(conversationId, buffer.toString('base64'), mimeType, fileName);
328
+ const session = await client.createResumableMediaUpload(conversationId, fileStat.size, mimeType, fileName);
329
+ options?.signal?.throwIfAborted();
330
+ if (typeof session.uploadId !== 'string' || session.uploadId.length === 0) {
331
+ throw new Error('Canon returned an invalid resumable upload ID');
332
+ }
333
+ if (session.sizeBytes !== fileStat.size) {
334
+ throw new Error('Canon resumable upload size does not match the local file');
335
+ }
336
+ if (typeof session.mimeType !== 'string' || session.mimeType.length === 0) {
337
+ throw new Error('Canon returned an invalid resumable upload MIME type');
338
+ }
339
+ let uploadUrl;
340
+ try {
341
+ uploadUrl = new URL(session.uploadUrl);
342
+ }
343
+ catch {
344
+ throw new Error('Canon returned an invalid resumable upload URL');
345
+ }
346
+ if (uploadUrl.protocol !== 'https:') {
347
+ throw new Error('Canon returned a non-HTTPS resumable upload URL');
348
+ }
349
+ if (uploadUrl.hostname !== 'storage.googleapis.com'
350
+ || uploadUrl.username.length > 0
351
+ || uploadUrl.password.length > 0) {
352
+ throw new Error('Canon returned an untrusted resumable upload URL');
353
+ }
354
+ const fetchImpl = ensureFetch(options?.fetchImpl);
355
+ const uploadResponse = await fetchImpl(uploadUrl, {
356
+ method: 'PUT',
357
+ headers: {
358
+ 'Content-Type': session.mimeType,
359
+ 'Content-Length': String(fileStat.size),
360
+ 'Content-Range': `bytes 0-${fileStat.size - 1}/${fileStat.size}`,
361
+ },
362
+ body: createReadStream(filePath),
363
+ signal: options?.signal,
364
+ // Node requires this for streaming request bodies. It is intentionally
365
+ // outside the DOM RequestInit type but supported by the built-in fetch.
366
+ duplex: 'half',
367
+ });
368
+ if (!uploadResponse.ok) {
369
+ throw new Error(`Failed to upload Canon media (${uploadResponse.status} ${uploadResponse.statusText})`);
370
+ }
371
+ options?.signal?.throwIfAborted();
372
+ const finalized = await client.finalizeResumableMediaUpload(session.uploadId);
373
+ options?.signal?.throwIfAborted();
374
+ if (finalized.uploadId !== session.uploadId) {
375
+ throw new Error('Canon resumable upload result does not match its session');
376
+ }
377
+ if (finalized.attachment.uploadId !== undefined
378
+ && finalized.attachment.uploadId !== session.uploadId) {
379
+ throw new Error('Canon resumable upload attachment does not match its session');
380
+ }
381
+ const uploaded = {
382
+ ...finalized,
383
+ attachment: {
384
+ ...finalized.attachment,
385
+ uploadId: session.uploadId,
386
+ },
387
+ };
186
388
  if (uploaded.attachment.kind === 'audio'
187
389
  && typeof options?.durationMs === 'number'
188
390
  && Number.isFinite(options.durationMs)
@@ -198,12 +400,15 @@ export async function uploadMediaFile(client, conversationId, filePath, options)
198
400
  return uploaded;
199
401
  }
200
402
  export async function sendMediaFileMessage(client, conversationId, filePath, text = '', options) {
201
- const { fileName, mimeType, durationMs, ...sendOptions } = options ?? {};
403
+ const { fileName, mimeType, durationMs, fetchImpl, signal, ...sendOptions } = options ?? {};
202
404
  const uploaded = await uploadMediaFile(client, conversationId, filePath, {
203
405
  ...(fileName ? { fileName } : {}),
204
406
  ...(mimeType ? { mimeType } : {}),
205
407
  ...(durationMs != null ? { durationMs } : {}),
408
+ ...(fetchImpl ? { fetchImpl } : {}),
409
+ ...(signal ? { signal } : {}),
206
410
  });
411
+ signal?.throwIfAborted();
207
412
  return client.sendMessage(conversationId, text, {
208
413
  ...sendOptions,
209
414
  contentType: uploaded.attachment.kind,
@@ -247,12 +452,39 @@ export function isAnthropicImageAttachment(attachment) {
247
452
  * Callers should first check `isAnthropicImageAttachment`; this function
248
453
  * throws if the MIME type is not supported.
249
454
  */
250
- export async function toAnthropicImageBlock(attachment) {
455
+ export async function toAnthropicImageBlock(attachment, options) {
251
456
  if (!isAnthropicImageAttachment(attachment)) {
252
457
  throw new Error(`Canon attachment ${attachment.index} is not a supported Anthropic image (kind=${attachment.kind}, mime=${attachment.mimeType ?? 'unknown'})`);
253
458
  }
459
+ const maxRawBytes = options?.maxRawBytes ?? MAX_ANTHROPIC_IMAGE_RAW_BYTES;
460
+ if (!Number.isSafeInteger(maxRawBytes)
461
+ || maxRawBytes <= 0
462
+ || maxRawBytes > MAX_ANTHROPIC_IMAGE_RAW_BYTES) {
463
+ throw new Error(`maxRawBytes must be an integer between 1 and ${MAX_ANTHROPIC_IMAGE_RAW_BYTES}`);
464
+ }
254
465
  const mediaType = resolveAttachmentMimeType(attachment);
255
- const buffer = await readFile(attachment.path);
466
+ const buffer = await readAnthropicImageBytes(attachment, maxRawBytes);
467
+ return buildAnthropicImageBlock(buffer, mediaType);
468
+ }
469
+ async function readAnthropicImageBytes(attachment, maxRawBytes) {
470
+ const handle = await open(attachment.path, 'r');
471
+ try {
472
+ return await readBoundedRegularFileHandle(handle, maxRawBytes);
473
+ }
474
+ catch (error) {
475
+ if (error instanceof BoundedFileReadError) {
476
+ if (error.failure === 'not-file') {
477
+ throw new Error(`Canon attachment ${attachment.index} is not a file`);
478
+ }
479
+ throw new Error(`Canon attachment ${attachment.index} exceeds the ${maxRawBytes}-byte Anthropic image limit`);
480
+ }
481
+ throw error;
482
+ }
483
+ finally {
484
+ await handle.close();
485
+ }
486
+ }
487
+ function buildAnthropicImageBlock(buffer, mediaType) {
256
488
  return {
257
489
  type: 'image',
258
490
  source: {
@@ -262,6 +494,87 @@ export async function toAnthropicImageBlock(attachment) {
262
494
  },
263
495
  };
264
496
  }
497
+ function resolveBoundedInteger(input) {
498
+ const value = input.value ?? input.fallback;
499
+ const minimum = input.allowZero ? 0 : 1;
500
+ if (!Number.isSafeInteger(value) || value < minimum || value > input.maximum) {
501
+ throw new Error(`${input.name} must be an integer between ${minimum} and ${input.maximum}`);
502
+ }
503
+ return value;
504
+ }
505
+ function encodedBase64Length(rawBytes) {
506
+ return Math.ceil(rawBytes / 3) * 4;
507
+ }
508
+ function serializedTextBlockBytes(promptText) {
509
+ return Buffer.byteLength(JSON.stringify({ type: 'text', text: promptText }), 'utf8');
510
+ }
511
+ function serializedImageBlockBytes(rawBytes, mediaType) {
512
+ const framingBytes = Buffer.byteLength(JSON.stringify({
513
+ type: 'image',
514
+ source: {
515
+ type: 'base64',
516
+ media_type: mediaType,
517
+ data: '',
518
+ },
519
+ }), 'utf8');
520
+ return framingBytes + encodedBase64Length(rawBytes);
521
+ }
522
+ /**
523
+ * Convert only the supported images that fit both the direct-image limit and
524
+ * the aggregate request budget. Skipped images remain represented by the
525
+ * paths already rendered into promptText; one unavailable image never drops
526
+ * valid siblings.
527
+ */
528
+ export async function toAnthropicImageBlocksWithinBudget(attachments, options) {
529
+ const maxImageRawBytes = resolveBoundedInteger({
530
+ value: options.maxImageRawBytes,
531
+ fallback: MAX_ANTHROPIC_IMAGE_RAW_BYTES,
532
+ maximum: MAX_ANTHROPIC_IMAGE_RAW_BYTES,
533
+ name: 'maxImageRawBytes',
534
+ });
535
+ const maxRequestBytes = resolveBoundedInteger({
536
+ value: options.maxRequestBytes,
537
+ fallback: MAX_ANTHROPIC_REQUEST_BYTES,
538
+ maximum: MAX_ANTHROPIC_REQUEST_BYTES,
539
+ name: 'maxRequestBytes',
540
+ });
541
+ const requestHeadroomBytes = resolveBoundedInteger({
542
+ value: options.requestHeadroomBytes,
543
+ fallback: DEFAULT_ANTHROPIC_REQUEST_HEADROOM_BYTES,
544
+ maximum: maxRequestBytes,
545
+ name: 'requestHeadroomBytes',
546
+ allowZero: true,
547
+ });
548
+ const contentBudgetBytes = maxRequestBytes - requestHeadroomBytes;
549
+ // Exact JSON content-array framing: opening/closing brackets around the
550
+ // text block, then one comma before every native image block.
551
+ let usedBytes = 2 + serializedTextBlockBytes(options.promptText);
552
+ if (usedBytes > contentBudgetBytes)
553
+ return [];
554
+ const blocks = [];
555
+ for (const attachment of attachments) {
556
+ if (!isAnthropicImageAttachment(attachment))
557
+ continue;
558
+ try {
559
+ const mediaType = resolveAttachmentMimeType(attachment);
560
+ const buffer = await readAnthropicImageBytes(attachment, maxImageRawBytes);
561
+ const nextBlockBytes = 1 + serializedImageBlockBytes(buffer.byteLength, mediaType);
562
+ if (usedBytes + nextBlockBytes > contentBudgetBytes)
563
+ continue;
564
+ const block = buildAnthropicImageBlock(buffer, mediaType);
565
+ const actualBlockBytes = 1 + Buffer.byteLength(JSON.stringify(block), 'utf8');
566
+ if (usedBytes + actualBlockBytes > contentBudgetBytes)
567
+ continue;
568
+ blocks.push(block);
569
+ usedBytes += actualBlockBytes;
570
+ }
571
+ catch {
572
+ // A missing, changed, or unreadable file stays as its prompt path. Keep
573
+ // inspecting siblings instead of failing the whole model request.
574
+ }
575
+ }
576
+ return blocks;
577
+ }
265
578
  /**
266
579
  * Return the local path to pass to Codex via its `-i/--image` flag when the
267
580
  * 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, MediaAttachment, VideoProcessingStatus, VideoProcessingErrorCode, 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.7.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.5.0"
32
32
  },
33
33
  "publishConfig": {
34
34
  "access": "public"