@agent-native/core 0.175.3 → 0.175.4-nightly-20260826190205

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/clips/server/plugins/agent-chat.ts +3 -0
  3. package/corpus/templates/content/actions/_linked-local-document-edit.ts +167 -0
  4. package/corpus/templates/content/actions/edit-document.ts +181 -61
  5. package/corpus/templates/content/actions/import-content-source.ts +33 -1
  6. package/corpus/templates/content/actions/update-document.ts +2 -0
  7. package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +68 -0
  8. package/corpus/templates/content/app/components/editor/LinkedLocalDocumentAgentBridge.tsx +245 -0
  9. package/corpus/templates/content/app/lib/local-content-source-files.ts +96 -9
  10. package/corpus/templates/content/shared/api.ts +2 -0
  11. package/corpus/templates/content/shared/content-source.ts +14 -0
  12. package/corpus/templates/content/shared/document-text-edits.ts +39 -0
  13. package/corpus/templates/content/shared/nfm.ts +347 -1
  14. package/corpus/templates/slides/server/plugins/agent-chat.ts +2 -0
  15. package/dist/agent/engine/ai-sdk-engine.js +14 -4
  16. package/dist/agent/engine/anthropic-engine.js +6 -4
  17. package/dist/agent/engine/builder-engine.js +11 -4
  18. package/dist/agent/engine/tool-name.d.ts +9 -0
  19. package/dist/agent/engine/tool-name.js +56 -0
  20. package/dist/agent/engine/translate-ai-sdk.d.ts +5 -3
  21. package/dist/agent/engine/translate-ai-sdk.js +16 -13
  22. package/dist/agent/engine/translate-anthropic.d.ts +8 -6
  23. package/dist/agent/engine/translate-anthropic.js +21 -22
  24. package/dist/agent/production-agent.js +23 -5
  25. package/dist/collab/routes.d.ts +1 -1
  26. package/dist/collab/struct-routes.d.ts +1 -1
  27. package/dist/extensions/fetch-tool.js +73 -5
  28. package/dist/extensions/proxy-security.d.ts +5 -0
  29. package/dist/extensions/proxy-security.js +21 -13
  30. package/dist/mcp/screen-memory-stdio.d.ts +7 -7
  31. package/dist/notifications/routes.d.ts +3 -3
  32. package/dist/server/agent-chat/context-tools.js +5 -0
  33. package/dist/server/credential-provider.js +47 -10
  34. package/dist/server/realtime-token.d.ts +1 -1
  35. package/docs/content/webmcp.mdx +5 -5
  36. package/package.json +1 -1
@@ -7,6 +7,7 @@
7
7
  * `ModelMessage` shapes.
8
8
  */
9
9
  import { classifyProviderError, describeErrorWithCauses, } from "./error-detail.js";
10
+ import { createProviderToolNameMap, toEngineToolName, toProviderToolName, } from "./tool-name.js";
10
11
  import { backfillEngineMessagesToolResults } from "./translate-anthropic.js";
11
12
  // ---------------------------------------------------------------------------
12
13
  // EngineTool → AI SDK tool definition
@@ -19,7 +20,7 @@ import { backfillEngineMessagesToolResults } from "./translate-anthropic.js";
19
20
  * schema is wrapped in the SDK's runtime validator; fall back to the raw JSON
20
21
  * Schema object otherwise (mostly for unit tests that don't import `ai`).
21
22
  */
22
- export function engineToolsToAISDK(tools, jsonSchema) {
23
+ export function engineToolsToAISDK(tools, jsonSchema, toolNameMap = createProviderToolNameMap(tools)) {
23
24
  const result = {};
24
25
  for (const tool of tools) {
25
26
  const rawSchema = {
@@ -28,7 +29,8 @@ export function engineToolsToAISDK(tools, jsonSchema) {
28
29
  properties: tool.inputSchema.properties ?? {},
29
30
  required: tool.inputSchema.required ?? [],
30
31
  };
31
- result[tool.name] = {
32
+ const providerName = toProviderToolName(tool.name, toolNameMap);
33
+ result[providerName] = {
32
34
  description: tool.description,
33
35
  inputSchema: jsonSchema ? jsonSchema(rawSchema) : rawSchema,
34
36
  };
@@ -92,7 +94,7 @@ export function engineMessageToAISDK(msg, opts) {
92
94
  toolResultParts.push({
93
95
  type: "tool-result",
94
96
  toolCallId: part.toolCallId,
95
- toolName: part.toolName,
97
+ toolName: toProviderToolName(part.toolName, opts?.toolNameMap),
96
98
  output: toolResultOutputToAISDK(part, opts),
97
99
  });
98
100
  }
@@ -121,7 +123,7 @@ export function engineMessageToAISDK(msg, opts) {
121
123
  content.push({
122
124
  type: "tool-call",
123
125
  toolCallId: part.id,
124
- toolName: part.name,
126
+ toolName: toProviderToolName(part.name, opts?.toolNameMap),
125
127
  input: part.input,
126
128
  });
127
129
  }
@@ -152,7 +154,8 @@ export function engineMessageToAISDK(msg, opts) {
152
154
  throw new Error(`unknown EngineMessage role: ${msg.role}`);
153
155
  }
154
156
  export function engineMessagesToAISDK(messages, opts) {
155
- return backfillEngineMessagesToolResults(messages).flatMap((msg) => engineMessageToAISDK(msg, opts));
157
+ const toolNameMap = opts?.toolNameMap ?? createProviderToolNameMap([], messages);
158
+ return backfillEngineMessagesToolResults(messages).flatMap((msg) => engineMessageToAISDK(msg, { ...opts, toolNameMap }));
156
159
  }
157
160
  // ---------------------------------------------------------------------------
158
161
  // AI SDK TextStreamPart → EngineEvent
@@ -166,7 +169,7 @@ export function engineMessagesToAISDK(messages, opts) {
166
169
  * We absorb text/reasoning boundaries, forward text/reasoning/tool-input
167
170
  * deltas, and keep the terminal `tool-call`, `finish-step`, and `finish` parts.
168
171
  */
169
- export function aiSdkPartToEngineEvents(part) {
172
+ export function aiSdkPartToEngineEvents(part, toolNameMap) {
170
173
  const events = [];
171
174
  switch (part?.type) {
172
175
  case "text-delta":
@@ -187,14 +190,14 @@ export function aiSdkPartToEngineEvents(part) {
187
190
  events.push({
188
191
  type: "tool-input-start",
189
192
  id: part.id ?? part.toolCallId,
190
- name: part.toolName,
193
+ name: toEngineToolName(part.toolName, toolNameMap),
191
194
  });
192
195
  break;
193
196
  case "tool-input-delta":
194
197
  events.push({
195
198
  type: "tool-input-delta",
196
199
  id: part.id ?? part.toolCallId,
197
- name: part.toolName,
200
+ name: toEngineToolName(part.toolName, toolNameMap),
198
201
  text: typeof part.delta === "string"
199
202
  ? part.delta
200
203
  : typeof part.text === "string"
@@ -209,7 +212,7 @@ export function aiSdkPartToEngineEvents(part) {
209
212
  events.push({
210
213
  type: "tool-call",
211
214
  id: part.toolCallId,
212
- name: part.toolName,
215
+ name: toEngineToolName(part.toolName, toolNameMap),
213
216
  input: part.input ?? {},
214
217
  });
215
218
  break;
@@ -218,7 +221,7 @@ export function aiSdkPartToEngineEvents(part) {
218
221
  events.push({
219
222
  type: "tool-call-error",
220
223
  id: part.toolCallId,
221
- name: part.toolName,
224
+ name: toEngineToolName(part.toolName, toolNameMap),
222
225
  input: part.input ?? {},
223
226
  error: part.errorText ??
224
227
  (part.error instanceof Error
@@ -313,7 +316,7 @@ function usageEventFromLanguageModelUsage(usage) {
313
316
  * Reconstruct the assistant message content from an AI SDK v6 `StepResult`.
314
317
  * `step.content` is the canonical structured form — iterate it.
315
318
  */
316
- export function aiSdkStepToAssistantContent(step) {
319
+ export function aiSdkStepToAssistantContent(step, toolNameMap) {
317
320
  const parts = [];
318
321
  for (const part of step?.content ?? []) {
319
322
  if (part.type === "text" && part.text) {
@@ -333,7 +336,7 @@ export function aiSdkStepToAssistantContent(step) {
333
336
  parts.push({
334
337
  type: "tool-call",
335
338
  id: part.toolCallId,
336
- name: part.toolName,
339
+ name: toEngineToolName(part.toolName, toolNameMap),
337
340
  input: part.input,
338
341
  });
339
342
  }
@@ -341,7 +344,7 @@ export function aiSdkStepToAssistantContent(step) {
341
344
  parts.push({
342
345
  type: "tool-call",
343
346
  id: part.toolCallId,
344
- name: part.toolName,
347
+ name: toEngineToolName(part.toolName, toolNameMap),
345
348
  input: part.input ?? {},
346
349
  });
347
350
  }
@@ -13,9 +13,10 @@
13
13
  * (`engineMessagesToAnthropic`).
14
14
  */
15
15
  import type Anthropic from "@anthropic-ai/sdk";
16
+ import { type ProviderToolNameMap } from "./tool-name.js";
16
17
  import type { EngineTool, EngineMessage, EngineContentPart, EngineEvent } from "./types.js";
17
- export declare function engineToolToAnthropic(tool: EngineTool): Anthropic.Tool;
18
- export declare function engineToolsToAnthropic(tools: EngineTool[]): Anthropic.Tool[];
18
+ export declare function engineToolToAnthropic(tool: EngineTool, toolNameMap?: ProviderToolNameMap): Anthropic.Tool;
19
+ export declare function engineToolsToAnthropic(tools: EngineTool[], toolNameMap?: ProviderToolNameMap): Anthropic.Tool[];
19
20
  /** JSON.stringify for tool_use inputs; never throws. */
20
21
  export declare function stringifyToolUseInputForGateway(input: unknown): string;
21
22
  /** Same lead-in as structured-history replay when a tool_result cannot be paired. */
@@ -41,15 +42,16 @@ export declare function unmatchedToolResultReplayText(part: {
41
42
  export declare function backfillEngineMessagesToolResults(messages: EngineMessage[]): EngineMessage[];
42
43
  export declare function engineMessageToAnthropic(msg: EngineMessage, opts?: {
43
44
  builderGateway?: boolean;
45
+ toolNameMap?: ProviderToolNameMap;
44
46
  }): Anthropic.MessageParam;
45
47
  /** Messages for the Anthropic HTTP API (strict schema — no extra tool_result fields). */
46
- export declare function engineMessagesToAnthropic(messages: EngineMessage[]): Anthropic.MessageParam[];
48
+ export declare function engineMessagesToAnthropic(messages: EngineMessage[], toolNameMap?: ProviderToolNameMap): Anthropic.MessageParam[];
47
49
  /**
48
50
  * Messages for the Builder LLM gateway (Gemini-backed). Same Anthropic-shaped
49
51
  * envelope, but every `tool_result` includes `tool_name` and `tool_input`.
50
52
  */
51
- export declare function engineMessagesToBuilderGatewayAnthropic(messages: EngineMessage[]): Anthropic.MessageParam[];
52
- export declare function anthropicContentToEngine(content: Anthropic.ContentBlock[]): EngineContentPart[];
53
+ export declare function engineMessagesToBuilderGatewayAnthropic(messages: EngineMessage[], toolNameMap?: ProviderToolNameMap): Anthropic.MessageParam[];
54
+ export declare function anthropicContentToEngine(content: Anthropic.ContentBlock[], toolNameMap?: ProviderToolNameMap): EngineContentPart[];
53
55
  /**
54
56
  * Mutable state threaded across `anthropicChunkToEngineEvents` calls within a
55
57
  * single stream. Anthropic's `content_block_delta` chunks carry only the block
@@ -76,7 +78,7 @@ export declare function createAnthropicChunkStreamState(): AnthropicChunkStreamS
76
78
  * the engine, so omitting `state` simply drops the progress events without
77
79
  * changing tool dispatch.
78
80
  */
79
- export declare function anthropicChunkToEngineEvents(chunk: any, state?: AnthropicChunkStreamState): EngineEvent[];
81
+ export declare function anthropicChunkToEngineEvents(chunk: any, state?: AnthropicChunkStreamState, toolNameMap?: ProviderToolNameMap): EngineEvent[];
80
82
  /**
81
83
  * Tool arguments arrive split across an arbitrary number of deltas. Every
82
84
  * engine announces a tool call the moment the first delta lands, so a stream
@@ -12,6 +12,7 @@
12
12
  * that path. The native Anthropic API keeps the strict `tool_result` shape
13
13
  * (`engineMessagesToAnthropic`).
14
14
  */
15
+ import { createProviderToolNameMap, toEngineToolName, toProviderToolName, } from "./tool-name.js";
15
16
  // ---------------------------------------------------------------------------
16
17
  // EngineTool → Anthropic.Tool
17
18
  // ---------------------------------------------------------------------------
@@ -61,15 +62,16 @@ function normalizeAnthropicInputSchema(toolName, schema) {
61
62
  normalized.type = "object";
62
63
  return normalized;
63
64
  }
64
- export function engineToolToAnthropic(tool) {
65
+ export function engineToolToAnthropic(tool, toolNameMap) {
66
+ const providerName = toProviderToolName(tool.name, toolNameMap);
65
67
  return {
66
- name: tool.name,
68
+ name: providerName,
67
69
  description: tool.description,
68
70
  input_schema: normalizeAnthropicInputSchema(tool.name, tool.inputSchema),
69
71
  };
70
72
  }
71
- export function engineToolsToAnthropic(tools) {
72
- return tools.map(engineToolToAnthropic);
73
+ export function engineToolsToAnthropic(tools, toolNameMap = createProviderToolNameMap(tools)) {
74
+ return tools.map((tool) => engineToolToAnthropic(tool, toolNameMap));
73
75
  }
74
76
  // ---------------------------------------------------------------------------
75
77
  // Tool result backfill (Gemini / Builder gateway)
@@ -287,14 +289,14 @@ export function engineMessageToAnthropic(msg, opts) {
287
289
  : msg.content.filter(replayableAnthropicPart);
288
290
  return {
289
291
  role: msg.role,
290
- content: content.map((p) => enginePartToAnthropic(p, builderGateway)),
292
+ content: content.map((p) => enginePartToAnthropic(p, builderGateway, opts?.toolNameMap)),
291
293
  };
292
294
  }
293
295
  /** Messages for the Anthropic HTTP API (strict schema — no extra tool_result fields). */
294
- export function engineMessagesToAnthropic(messages) {
296
+ export function engineMessagesToAnthropic(messages, toolNameMap = createProviderToolNameMap([], messages)) {
295
297
  const normalized = backfillEngineMessagesToolResults(messages);
296
298
  return normalized.flatMap((m) => {
297
- const translated = engineMessageToAnthropic(m);
299
+ const translated = engineMessageToAnthropic(m, { toolNameMap });
298
300
  return translated.content.length > 0 ? [translated] : [];
299
301
  });
300
302
  }
@@ -302,11 +304,11 @@ export function engineMessagesToAnthropic(messages) {
302
304
  * Messages for the Builder LLM gateway (Gemini-backed). Same Anthropic-shaped
303
305
  * envelope, but every `tool_result` includes `tool_name` and `tool_input`.
304
306
  */
305
- export function engineMessagesToBuilderGatewayAnthropic(messages) {
307
+ export function engineMessagesToBuilderGatewayAnthropic(messages, toolNameMap = createProviderToolNameMap([], messages)) {
306
308
  const normalized = backfillEngineMessagesToolResults(messages);
307
- return normalized.map((m) => engineMessageToAnthropic(m, { builderGateway: true }));
309
+ return normalized.map((m) => engineMessageToAnthropic(m, { builderGateway: true, toolNameMap }));
308
310
  }
309
- function enginePartToAnthropic(part, builderGateway) {
311
+ function enginePartToAnthropic(part, builderGateway, toolNameMap) {
310
312
  switch (part.type) {
311
313
  case "text":
312
314
  return { type: "text", text: part.text };
@@ -339,24 +341,19 @@ function enginePartToAnthropic(part, builderGateway) {
339
341
  return {
340
342
  type: "tool_use",
341
343
  id: part.id,
342
- name: part.name,
344
+ name: toProviderToolName(part.name, toolNameMap),
343
345
  input: part.input,
344
346
  }; // tool_use is a ContentBlockParam in Anthropic SDK
345
347
  case "tool-result": {
346
348
  if (builderGateway) {
347
- const tool_name = part.toolName.trim();
349
+ const tool_name = toProviderToolName(part.toolName.trim(), toolNameMap);
348
350
  const tool_input = part.toolInput;
349
- // Gateway degrade: the Builder gateway multiplexes to non-Anthropic
350
- // models whose tool_result handling is string-only, so images are
351
- // dropped here. The content string already carries a `[image: …]`
352
- // note per image (appended by runToolCall), so the model still knows
353
- // an image existed and any https URL survives.
354
351
  return {
355
352
  type: "tool_result",
356
353
  tool_use_id: part.toolCallId,
357
354
  tool_name,
358
355
  tool_input,
359
- content: part.content,
356
+ content: toolResultContentToAnthropic(part),
360
357
  ...(part.isError ? { is_error: true } : {}),
361
358
  };
362
359
  }
@@ -417,7 +414,7 @@ function toolResultContentToAnthropic(part) {
417
414
  // ---------------------------------------------------------------------------
418
415
  // Anthropic.ContentBlock → EngineContentPart (from final message)
419
416
  // ---------------------------------------------------------------------------
420
- export function anthropicContentToEngine(content) {
417
+ export function anthropicContentToEngine(content, toolNameMap) {
421
418
  return content
422
419
  .map((block) => {
423
420
  if (block.type === "text") {
@@ -427,7 +424,7 @@ export function anthropicContentToEngine(content) {
427
424
  return {
428
425
  type: "tool-call",
429
426
  id: block.id,
430
- name: block.name,
427
+ name: toEngineToolName(block.name, toolNameMap),
431
428
  input: block.input,
432
429
  };
433
430
  }
@@ -471,13 +468,15 @@ export function createAnthropicChunkStreamState() {
471
468
  * the engine, so omitting `state` simply drops the progress events without
472
469
  * changing tool dispatch.
473
470
  */
474
- export function anthropicChunkToEngineEvents(chunk, state) {
471
+ export function anthropicChunkToEngineEvents(chunk, state, toolNameMap) {
475
472
  const events = [];
476
473
  if (chunk.type === "content_block_start") {
477
474
  const block = chunk.content_block;
478
475
  if (block?.type === "tool_use") {
479
476
  const id = typeof block.id === "string" ? block.id : undefined;
480
- const name = typeof block.name === "string" ? block.name : undefined;
477
+ const name = typeof block.name === "string"
478
+ ? toEngineToolName(block.name, toolNameMap)
479
+ : undefined;
481
480
  if (state && typeof chunk.index === "number" && id && name) {
482
481
  state.toolUseByIndex.set(chunk.index, { id, name });
483
482
  }
@@ -1895,7 +1895,7 @@ function seedReadOnlyToolResultsFromJournal(journal, actions) {
1895
1895
  })) {
1896
1896
  continue;
1897
1897
  }
1898
- cache.set(toolCallCacheKey(entry.tool, entry.input), result);
1898
+ cache.set(toolCallCacheKey(entry.tool, entry.input), { content: result });
1899
1899
  }
1900
1900
  return cache;
1901
1901
  }
@@ -1945,7 +1945,10 @@ function seedReadOnlyToolResultsFromHistory(messages, actions, seed) {
1945
1945
  continue;
1946
1946
  if (!isReusableReadOnlyToolResult(part))
1947
1947
  continue;
1948
- cache.set(toolCallCacheKey(call.name, call.input), part.content);
1948
+ cache.set(toolCallCacheKey(call.name, call.input), {
1949
+ content: part.content,
1950
+ ...(part.images?.length ? { images: part.images } : {}),
1951
+ });
1949
1952
  }
1950
1953
  }
1951
1954
  return cache;
@@ -2024,6 +2027,12 @@ function isReusableReadOnlyToolResult(part) {
2024
2027
  const lower = part.content.trim().toLowerCase();
2025
2028
  if (!lower)
2026
2029
  return false;
2030
+ // The ledger persists only the note, not image bytes. Re-execute an image
2031
+ // read when its payload is unavailable so a continuation can restore vision.
2032
+ if (!part.images?.length &&
2033
+ /\[image(?:\s+#\d+|\s*:)[^\n\]]*\battached(?:\s+#\d+|:)[^\n\]]*\]/i.test(part.content)) {
2034
+ return false;
2035
+ }
2027
2036
  return !(lower.startsWith("skipped duplicate read-only call to ") ||
2028
2037
  lower.startsWith("invalid action parameters for ") ||
2029
2038
  lower.startsWith("error running ") ||
@@ -4701,8 +4710,11 @@ export async function runAgentLoop(opts) {
4701
4710
  const cacheKey = actionEntry.readOnly === true && actionEntry.dedupe !== false
4702
4711
  ? toolCallCacheKey(toolCall.name, toolCall.input)
4703
4712
  : null;
4704
- if (cacheKey && readOnlyToolResultCache.has(cacheKey)) {
4705
- const previousResult = readOnlyToolResultCache.get(cacheKey) ?? "";
4713
+ const cachedResult = cacheKey
4714
+ ? readOnlyToolResultCache.get(cacheKey)
4715
+ : undefined;
4716
+ if (cacheKey && cachedResult) {
4717
+ const previousResult = cachedResult.content;
4706
4718
  // `contextMessages` (not `messages`) is what the model actually sees
4707
4719
  // this iteration — context-xray eviction/summarization and
4708
4720
  // observational-memory trimming can drop the earlier result from
@@ -4744,6 +4756,9 @@ export async function runAgentLoop(opts) {
4744
4756
  toolName: toolCall.name,
4745
4757
  toolInput: wireToolInput,
4746
4758
  content: result,
4759
+ ...(cachedResult.images?.length
4760
+ ? { images: cachedResult.images }
4761
+ : {}),
4747
4762
  };
4748
4763
  }
4749
4764
  if (opts.executionMode === "plan" &&
@@ -5006,7 +5021,10 @@ export async function runAgentLoop(opts) {
5006
5021
  if (!isError) {
5007
5022
  noteToolCallSucceeded(actionEntry);
5008
5023
  if (cacheKey) {
5009
- readOnlyToolResultCache.set(cacheKey, result);
5024
+ readOnlyToolResultCache.set(cacheKey, {
5025
+ content: result,
5026
+ ...(toolResultImages?.length ? { images: toolResultImages } : {}),
5027
+ });
5010
5028
  }
5011
5029
  else if (actionEntry.readOnly !== true) {
5012
5030
  // A genuine write invalidates all cached reads. A dedupe:false
@@ -26,8 +26,8 @@ export declare const getCollabState: import("h3").EventHandlerWithFetch<import("
26
26
  * Body: { update: string (base64), requestSource?: string }
27
27
  */
28
28
  export declare const postCollabUpdate: import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<{
29
- ok?: undefined;
30
29
  error: string;
30
+ ok?: undefined;
31
31
  } | {
32
32
  error?: undefined;
33
33
  ok: boolean;
@@ -13,8 +13,8 @@
13
13
  * Body: { json: any, fieldName?: string, type?: "map"|"array", requestSource?: string }
14
14
  */
15
15
  export declare const postCollabJson: import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<{
16
- ok?: undefined;
17
16
  error: string;
17
+ ok?: undefined;
18
18
  } | {
19
19
  error?: undefined;
20
20
  ok: boolean;
@@ -9,10 +9,44 @@
9
9
  * Values are resolved server-side AFTER the model emits the tool call —
10
10
  * the raw secret never enters the model's context.
11
11
  */
12
- import { collectSecretValues, MAX_EXTENSION_PROXY_RESPONSE_SIZE, normalizeExtensionProxyMethod, readResponseTextWithLimit, redactSecrets, redactString, sanitizeOutboundHeaders, } from "./proxy-security.js";
12
+ import { collectSecretValues, MAX_EXTENSION_PROXY_RESPONSE_SIZE, normalizeExtensionProxyMethod, readResponseBytesWithLimit, readResponseTextWithLimit, redactSecrets, redactString, sanitizeOutboundHeaders, } from "./proxy-security.js";
13
13
  import { createSsrfSafeDispatcher, isBlockedExtensionUrlWithDns, } from "./url-safety.js";
14
14
  import { formatWebContentResult, parseWebContentSearchOptions, processWebContent, } from "./web-content.js";
15
15
  const DEFAULT_TIMEOUT_MS = 15_000;
16
+ const VISION_IMAGE_CONTENT_TYPES = new Set([
17
+ "image/jpeg",
18
+ "image/png",
19
+ "image/gif",
20
+ "image/webp",
21
+ ]);
22
+ const CREDENTIAL_NAME_RE = /(?:^|[-_])(access[-_]?token|api[-_]?key|auth(?:orization)?|credential|expires?|key[-_]?pair[-_]?id|key|password|passwd|policy|secret|session|signature|sig|token|jwt|assertion)(?:$|[-_])/i;
23
+ function uint8ArrayToBase64(bytes) {
24
+ if (typeof Buffer !== "undefined")
25
+ return Buffer.from(bytes).toString("base64");
26
+ let binary = "";
27
+ const chunkSize = 0x8000;
28
+ for (let i = 0; i < bytes.length; i += chunkSize) {
29
+ binary += String.fromCharCode(...bytes.subarray(i, Math.min(i + chunkSize, bytes.length)));
30
+ }
31
+ return btoa(binary);
32
+ }
33
+ function hasCredentialBearingImageRequest(url, headers) {
34
+ let parsedUrl;
35
+ try {
36
+ parsedUrl = new URL(url);
37
+ }
38
+ catch {
39
+ return true;
40
+ }
41
+ if (parsedUrl.username || parsedUrl.password)
42
+ return true;
43
+ if (parsedUrl.hash)
44
+ return true;
45
+ if (Object.keys(headers).some((name) => CREDENTIAL_NAME_RE.test(name.replace(/([a-z])([A-Z])/g, "$1-$2")))) {
46
+ return true;
47
+ }
48
+ return [...parsedUrl.searchParams.keys()].some((name) => CREDENTIAL_NAME_RE.test(name.replace(/([a-z])([A-Z])/g, "$1-$2")));
49
+ }
16
50
  /**
17
51
  * Headers that mimic a current Chrome on macOS so anti-bot middleware (Cloudflare,
18
52
  * PerimeterX, Akamai) treats the request as a real user. We only fill in fields
@@ -84,7 +118,7 @@ export function createFetchToolEntry(opts = {}) {
84
118
  },
85
119
  responseMode: {
86
120
  type: "string",
87
- description: "How to return the response. Default: auto (HTML pages become clean markdown; JSON/text stays raw). Use raw for exact bytes, markdown/text for extracted readable content, links for just links, metadata for page metadata, or matches with search.",
121
+ description: "How to return the response. Default: auto (HTML pages become clean markdown; JSON/text stays raw; successful public image responses are attached as vision context). Use raw for exact bytes, markdown/text for extracted readable content, links for just links, metadata for page metadata, or matches with search.",
88
122
  enum: [
89
123
  "auto",
90
124
  "raw",
@@ -261,10 +295,46 @@ export function createFetchToolEntry(opts = {}) {
261
295
  }
262
296
  return `HTTP ${response.status} ${response.statusText}\n\nRedirect: ${redirectUrl ? redactString(redirectUrl, secretValues) : "(none)"}`;
263
297
  }
264
- // Check if caller wants to save to workspace file (before truncation).
298
+ const contentType = response.headers.get("content-type")?.split(";")[0].trim() ??
299
+ "text/plain";
300
+ // A fetched image is useful as model context, not as text decoded
301
+ // from arbitrary bytes. Pass bounded bytes through the well-known
302
+ // result-image field so vision providers never refetch this URL.
265
303
  const saveToFilePath = typeof args.saveToFile === "string"
266
304
  ? args.saveToFile.trim()
267
305
  : "";
306
+ const hasCredentials = allUsedKeys.length > 0 ||
307
+ hasCredentialBearingImageRequest(resolvedUrl, headers);
308
+ if (!saveToFilePath &&
309
+ method === "GET" &&
310
+ response.ok &&
311
+ VISION_IMAGE_CONTENT_TYPES.has(contentType.toLowerCase()) &&
312
+ resolvedUrl.startsWith("https://") &&
313
+ !hasCredentials) {
314
+ const imageBody = await readResponseBytesWithLimit(response, MAX_EXTENSION_PROXY_RESPONSE_SIZE);
315
+ if (imageBody.truncated) {
316
+ return `HTTP ${response.status} ${response.statusText}\n\nContent-Type: ${contentType}\n\nImage response too large to attach (${imageBody.size} bytes, max ${MAX_EXTENSION_PROXY_RESPONSE_SIZE}).`;
317
+ }
318
+ console.log("[fetch-tool] " +
319
+ method +
320
+ " image → " +
321
+ response.status +
322
+ " (" +
323
+ elapsed +
324
+ "ms, vision image)");
325
+ return {
326
+ status: response.status,
327
+ statusText: response.statusText,
328
+ contentType,
329
+ url: resolvedUrl,
330
+ _agentImages: [
331
+ {
332
+ data: uint8ArrayToBase64(imageBody.bytes),
333
+ mediaType: contentType.toLowerCase(),
334
+ },
335
+ ],
336
+ };
337
+ }
268
338
  let body;
269
339
  try {
270
340
  // When saving to file allow larger reads (20MB), otherwise cap at proxy limit.
@@ -278,8 +348,6 @@ export function createFetchToolEntry(opts = {}) {
278
348
  body = "(could not read response body)";
279
349
  }
280
350
  body = redactString(body, secretValues);
281
- const contentType = response.headers.get("content-type")?.split(";")[0].trim() ??
282
- "text/plain";
283
351
  let displayBody;
284
352
  let processedMode = "raw";
285
353
  try {
@@ -4,6 +4,11 @@ export declare function sanitizeOutboundHeaders(value: unknown): Record<string,
4
4
  export declare function collectSecretValues(...groups: Array<Array<string> | undefined>): string[];
5
5
  export declare function redactSecrets<T>(value: T, secretValues: string[]): T;
6
6
  export declare function redactString(text: string, secretValues: string[]): string;
7
+ export declare function readResponseBytesWithLimit(response: Response, maxBytes?: number): Promise<{
8
+ bytes: Uint8Array;
9
+ truncated: boolean;
10
+ size: number;
11
+ }>;
7
12
  export declare function readResponseTextWithLimit(response: Response, maxBytes?: number): Promise<{
8
13
  text: string;
9
14
  truncated: boolean;
@@ -99,11 +99,12 @@ function redactionCandidates(secret) {
99
99
  catch { }
100
100
  return [...candidates].sort((a, b) => b.length - a.length);
101
101
  }
102
- export async function readResponseTextWithLimit(response, maxBytes = MAX_EXTENSION_PROXY_RESPONSE_SIZE) {
102
+ export async function readResponseBytesWithLimit(response, maxBytes = MAX_EXTENSION_PROXY_RESPONSE_SIZE) {
103
103
  const contentLength = response.headers.get("content-length");
104
104
  if (contentLength && Number(contentLength) > maxBytes) {
105
+ await response.body?.cancel().catch(() => { });
105
106
  return {
106
- text: `(response too large - ${contentLength} bytes, max ${maxBytes})`,
107
+ bytes: new Uint8Array(),
107
108
  truncated: true,
108
109
  size: Number(contentLength),
109
110
  };
@@ -113,13 +114,13 @@ export async function readResponseTextWithLimit(response, maxBytes = MAX_EXTENSI
113
114
  const buffer = await response.arrayBuffer();
114
115
  if (buffer.byteLength > maxBytes) {
115
116
  return {
116
- text: `(response truncated - ${buffer.byteLength} bytes, max ${maxBytes})`,
117
+ bytes: new Uint8Array(),
117
118
  truncated: true,
118
119
  size: buffer.byteLength,
119
120
  };
120
121
  }
121
122
  return {
122
- text: new TextDecoder().decode(buffer),
123
+ bytes: new Uint8Array(buffer),
123
124
  truncated: false,
124
125
  size: buffer.byteLength,
125
126
  };
@@ -135,23 +136,30 @@ export async function readResponseTextWithLimit(response, maxBytes = MAX_EXTENSI
135
136
  total += value.byteLength;
136
137
  if (total > maxBytes) {
137
138
  await reader.cancel().catch(() => { });
138
- return {
139
- text: `(response truncated - ${total} bytes, max ${maxBytes})`,
140
- truncated: true,
141
- size: total,
142
- };
139
+ return { bytes: new Uint8Array(), truncated: true, size: total };
143
140
  }
144
141
  chunks.push(value);
145
142
  }
146
- const buffer = new Uint8Array(total);
143
+ const bytes = new Uint8Array(total);
147
144
  let offset = 0;
148
145
  for (const chunk of chunks) {
149
- buffer.set(chunk, offset);
146
+ bytes.set(chunk, offset);
150
147
  offset += chunk.byteLength;
151
148
  }
149
+ return { bytes, truncated: false, size: total };
150
+ }
151
+ export async function readResponseTextWithLimit(response, maxBytes = MAX_EXTENSION_PROXY_RESPONSE_SIZE) {
152
+ const result = await readResponseBytesWithLimit(response, maxBytes);
153
+ if (result.truncated) {
154
+ const contentLength = response.headers.get("content-length");
155
+ const text = contentLength && Number(contentLength) > maxBytes
156
+ ? `(response too large - ${contentLength} bytes, max ${maxBytes})`
157
+ : `(response truncated - ${result.size} bytes, max ${maxBytes})`;
158
+ return { text, truncated: true, size: result.size };
159
+ }
152
160
  return {
153
- text: new TextDecoder().decode(buffer),
161
+ text: new TextDecoder().decode(result.bytes),
154
162
  truncated: false,
155
- size: total,
163
+ size: result.size,
156
164
  };
157
165
  }