akanjs 3.1.0-rc.13 → 3.1.0-rc.14

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 (31) hide show
  1. package/package.json +1 -1
  2. package/service/agent.service.ts +21 -1
  3. package/service/predefinedAdaptor/anthropicLlm.ts +36 -16
  4. package/service/predefinedAdaptor/llm.adaptor.ts +2 -0
  5. package/service/predefinedAdaptor/openaiDialect.ts +53 -17
  6. package/store/agent/ScreenImage.ts +166 -0
  7. package/store/agent/StoreSurfaceSource.ts +36 -2
  8. package/store/agent/index.ts +2 -1
  9. package/types/service/agent.service.d.ts +2 -0
  10. package/types/service/predefinedAdaptor/anthropicLlm.d.ts +9 -4
  11. package/types/service/predefinedAdaptor/llm.adaptor.d.ts +2 -0
  12. package/types/service/predefinedAdaptor/openaiDialect.d.ts +20 -5
  13. package/types/store/agent/ScreenImage.d.ts +27 -0
  14. package/types/store/agent/StoreSurfaceSource.d.ts +1 -1
  15. package/types/store/agent/index.d.ts +2 -1
  16. package/types/vendor/use-agentic/Compaction.d.ts +3 -1
  17. package/types/vendor/use-agentic/ToolOutput.d.ts +6 -1
  18. package/types/vendor/use-agentic/ToolReturn.d.ts +18 -0
  19. package/types/vendor/use-agentic/Transcript.d.ts +7 -0
  20. package/types/vendor/use-agentic/index.d.ts +1 -0
  21. package/types/vendor/use-agentic/types.d.ts +6 -0
  22. package/ui/Agent/agentSessionOf.ts +15 -8
  23. package/vendor/use-agentic/Compaction.ts +38 -7
  24. package/vendor/use-agentic/ToolOutput.ts +25 -3
  25. package/vendor/use-agentic/ToolReturn.ts +32 -0
  26. package/vendor/use-agentic/ToolRunner.ts +15 -13
  27. package/vendor/use-agentic/Transcript.ts +38 -2
  28. package/vendor/use-agentic/WIRE.md +20 -0
  29. package/vendor/use-agentic/index.ts +1 -0
  30. package/vendor/use-agentic/types.ts +6 -0
  31. package/webkit/McpAppAgent.ts +12 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akanjs",
3
- "version": "3.1.0-rc.13",
3
+ "version": "3.1.0-rc.14",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -3,6 +3,7 @@ import type {
3
3
  AgentWireAttachment,
4
4
  AgentWireMessage,
5
5
  AgentWireReference,
6
+ AgentWireToolResult,
6
7
  LlmAccepts,
7
8
  LlmTurnRequest,
8
9
  } from "./predefinedAdaptor/llm.adaptor";
@@ -104,12 +105,21 @@ export class AgentService extends serve("agent" as const, ({ plug }) => ({
104
105
 
105
106
  /** Swaps an attachment the provider cannot read for a note; dropped silently, it is guessed at from its name. */
106
107
  static readable(request: LlmTurnRequest, accepts: LlmAccepts | undefined): LlmTurnRequest {
107
- if (!request.messages.some((message) => message.attachments?.length)) return request;
108
+ if (!request.messages.some(AgentService.carriesFiles)) return request;
108
109
  const messages = request.messages.map((message) => AgentService.readableMessage(message, accepts ?? {}));
109
110
  return { ...request, messages };
110
111
  }
111
112
 
113
+ private static carriesFiles(message: AgentWireMessage): boolean {
114
+ return !!message.attachments?.length || !!message.toolResults?.some((result) => result.attachments?.length);
115
+ }
116
+
112
117
  private static readableMessage(message: AgentWireMessage, accepts: LlmAccepts): AgentWireMessage {
118
+ if (message.role === "tool")
119
+ return {
120
+ ...message,
121
+ toolResults: (message.toolResults ?? []).map((result) => AgentService.readableResult(result, accepts)),
122
+ };
113
123
  const { attachments = [], ...rest } = message;
114
124
  if (!attachments.length) return message;
115
125
  const kept = attachments.filter((attachment) => AgentService.isReadable(attachment, accepts));
@@ -122,6 +132,16 @@ export class AgentService extends serve("agent" as const, ({ plug }) => ({
122
132
  };
123
133
  }
124
134
 
135
+ private static readableResult(result: AgentWireToolResult, accepts: LlmAccepts): AgentWireToolResult {
136
+ if (!result.attachments?.length) return result;
137
+ const attachments = result.attachments.map((attachment) =>
138
+ AgentService.isReadable(attachment, accepts)
139
+ ? attachment
140
+ : { name: attachment.name, mimeType: attachment.mimeType, text: AgentService.note(attachment) },
141
+ );
142
+ return { ...result, attachments };
143
+ }
144
+
125
145
  private static isReadable(attachment: AgentWireAttachment, accepts: LlmAccepts): boolean {
126
146
  if (attachment.text) return true;
127
147
  if (!attachment.data && !attachment.url) return false;
@@ -2,6 +2,7 @@ import { adapt } from "../adapt";
2
2
  import type {
3
3
  AgentWireAttachment,
4
4
  AgentWireMessage,
5
+ AgentWireToolResult,
5
6
  LlmAccepts,
6
7
  LlmAdaptor,
7
8
  LlmLimits,
@@ -14,12 +15,14 @@ import { LlmOverflow } from "./llmOverflow";
14
15
  import { eachSseData } from "./sseData";
15
16
 
16
17
  type AnthropicSource = { type: "base64"; media_type: string; data: string } | { type: "url"; url: string };
17
- type AnthropicBlock =
18
+ type AnthropicContentBlock =
18
19
  | { type: "text"; text: string }
19
20
  | { type: "image"; source: AnthropicSource }
20
- | { type: "document"; source: AnthropicSource }
21
+ | { type: "document"; source: AnthropicSource };
22
+ type AnthropicBlock =
23
+ | AnthropicContentBlock
21
24
  | { type: "tool_use"; id: string; name: string; input: Record<string, unknown> }
22
- | { type: "tool_result"; tool_use_id: string; content: string };
25
+ | { type: "tool_result"; tool_use_id: string; content: string | AnthropicContentBlock[] };
23
26
  interface AnthropicMessage {
24
27
  role: "user" | "assistant";
25
28
  content: AnthropicBlock[];
@@ -221,15 +224,7 @@ export class AnthropicLlm
221
224
  if (message.role === "tool")
222
225
  return {
223
226
  role: "user",
224
- content: (message.toolResults ?? []).map((result) => ({
225
- type: "tool_result" as const,
226
- tool_use_id: result.id,
227
- content: JSON.stringify({
228
- ...(result.result !== undefined ? { result: result.result } : {}),
229
- ...(result.changes?.length ? { changes: result.changes } : {}),
230
- ...(result.error ? { error: result.error } : {}),
231
- }),
232
- })),
227
+ content: (message.toolResults ?? []).map((result) => AnthropicLlm.toolResult(result, accepts)),
233
228
  };
234
229
  if (message.role === "assistant")
235
230
  return {
@@ -248,9 +243,36 @@ export class AnthropicLlm
248
243
  }
249
244
 
250
245
  static userContent(message: AgentWireMessage, accepts?: LlmAccepts): AnthropicBlock[] {
251
- const attachments = message.attachments ?? [];
252
246
  const notes: string[] = [];
253
- const blocks = attachments.flatMap((attachment): AnthropicBlock[] => {
247
+ const blocks = AnthropicLlm.attachmentBlocks(message.attachments ?? [], accepts, notes);
248
+ const text = [message.text, ...notes].filter(Boolean).join("\n\n");
249
+ return [...(text ? [{ type: "text" as const, text }] : []), ...blocks];
250
+ }
251
+
252
+ /** A string while the call returned no file, so a transcript without one posts what it always did. */
253
+ static toolResult(result: AgentWireToolResult, accepts?: LlmAccepts): AnthropicBlock {
254
+ const payload = JSON.stringify({
255
+ ...(result.result !== undefined ? { result: result.result } : {}),
256
+ ...(result.changes?.length ? { changes: result.changes } : {}),
257
+ ...(result.error ? { error: result.error } : {}),
258
+ });
259
+ if (!result.attachments?.length) return { type: "tool_result", tool_use_id: result.id, content: payload };
260
+ const notes: string[] = [];
261
+ const blocks = AnthropicLlm.attachmentBlocks(result.attachments, accepts, notes);
262
+ return {
263
+ type: "tool_result",
264
+ tool_use_id: result.id,
265
+ content: [{ type: "text", text: [payload, ...notes].join("\n\n") }, ...blocks],
266
+ };
267
+ }
268
+
269
+ /** Pushes a note for each file no block can carry, for the caller to fold into the text beside them. */
270
+ static attachmentBlocks(
271
+ attachments: AgentWireAttachment[],
272
+ accepts: LlmAccepts | undefined,
273
+ notes: string[],
274
+ ): AnthropicContentBlock[] {
275
+ return attachments.flatMap((attachment): AnthropicContentBlock[] => {
254
276
  if (attachment.text)
255
277
  return [
256
278
  {
@@ -271,8 +293,6 @@ export class AnthropicLlm
271
293
  notes.push(`[Attachment not read: ${attachment.name} (${attachment.mimeType}) — this API has no block for it.]`);
272
294
  return [];
273
295
  });
274
- const text = [message.text, ...notes].filter(Boolean).join("\n\n");
275
- return [...(text ? [{ type: "text" as const, text }] : []), ...blocks];
276
296
  }
277
297
 
278
298
  static typed(source: AnthropicSource, mimeType: string): AnthropicSource {
@@ -10,6 +10,8 @@ export interface AgentWireToolResult {
10
10
  result?: unknown;
11
11
  changes?: unknown[];
12
12
  error?: string;
13
+ /** Files the call handed the model, as content beside the JSON; one this model cannot read arrives as a `text` note. */
14
+ attachments?: AgentWireAttachment[];
13
15
  }
14
16
 
15
17
  /** Exactly one carrier reaches the model: `data` (inlined bytes), `url` (fetched by the provider) or `text`. */
@@ -1,4 +1,11 @@
1
- import type { AgentWireMessage, LlmAccepts, LlmTurnAnswer, LlmTurnRequest, LlmUsage } from "./llm.adaptor";
1
+ import type {
2
+ AgentWireAttachment,
3
+ AgentWireMessage,
4
+ LlmAccepts,
5
+ LlmTurnAnswer,
6
+ LlmTurnRequest,
7
+ LlmUsage,
8
+ } from "./llm.adaptor";
2
9
  import { eachSseData } from "./sseData";
3
10
 
4
11
  export interface OpenaiToolCall {
@@ -25,7 +32,8 @@ interface OpenaiStreamChunk {
25
32
  finish_reason?: string | null;
26
33
  }[];
27
34
  }
28
- type OpenaiContentPart = { type: "text"; text: string } | { type: "image_url"; image_url: { url: string } };
35
+ type OpenaiImagePart = { type: "image_url"; image_url: { url: string } };
36
+ type OpenaiContentPart = { type: "text"; text: string } | OpenaiImagePart;
29
37
  export interface OpenaiMessage {
30
38
  role: "system" | "user" | "assistant" | "tool";
31
39
  content: string | OpenaiContentPart[];
@@ -86,16 +94,7 @@ export class OpenaiDialect {
86
94
  content: `Summary of the earlier conversation, standing in for the messages it replaced:\n\n${message.text ?? ""}`,
87
95
  },
88
96
  ];
89
- if (message.role === "tool")
90
- return (message.toolResults ?? []).map((result) => ({
91
- role: "tool" as const,
92
- tool_call_id: result.id,
93
- content: JSON.stringify({
94
- ...(result.result !== undefined ? { result: result.result } : {}),
95
- ...(result.changes?.length ? { changes: result.changes } : {}),
96
- ...(result.error ? { error: result.error } : {}),
97
- }),
98
- }));
97
+ if (message.role === "tool") return OpenaiDialect.toolMessages(message, accepts);
99
98
  if (message.role === "assistant")
100
99
  return [
101
100
  {
@@ -116,8 +115,47 @@ export class OpenaiDialect {
116
115
  }
117
116
 
118
117
  static userContent(message: AgentWireMessage, accepts?: LlmAccepts): string | OpenaiContentPart[] {
119
- const attachments = message.attachments ?? [];
120
118
  const notes: string[] = [];
119
+ const { blocks, images } = OpenaiDialect.attachmentParts(message.attachments ?? [], accepts, notes);
120
+ const text = [message.text, ...blocks, ...notes].filter(Boolean).join("\n\n");
121
+ if (!images.length) return text;
122
+ return [...(text ? [{ type: "text" as const, text }] : []), ...images.map(({ part }) => part)];
123
+ }
124
+
125
+ static readonly toolPicturesHeading =
126
+ "Pictures the tool calls above returned, each after the name of the call that returned it. They are content, not something the user said.";
127
+
128
+ /**
129
+ * A `tool` message carries text alone in this dialect, so the pictures a call returned follow every tool message
130
+ * of the turn in one user message: between two tool messages it would break the run each call's answer has to sit in.
131
+ */
132
+ static toolMessages(message: AgentWireMessage, accepts?: LlmAccepts): OpenaiMessage[] {
133
+ const shown: OpenaiContentPart[] = [];
134
+ const answers = (message.toolResults ?? []).map((result): OpenaiMessage => {
135
+ const payload = JSON.stringify({
136
+ ...(result.result !== undefined ? { result: result.result } : {}),
137
+ ...(result.changes?.length ? { changes: result.changes } : {}),
138
+ ...(result.error ? { error: result.error } : {}),
139
+ });
140
+ const notes: string[] = [];
141
+ const { blocks, images } = OpenaiDialect.attachmentParts(result.attachments ?? [], accepts, notes);
142
+ for (const { name, part } of images)
143
+ shown.push({ type: "text", text: `[${result.name} returned ${name}]` }, part);
144
+ return { role: "tool", tool_call_id: result.id, content: [payload, ...blocks, ...notes].join("\n\n") };
145
+ });
146
+ if (!shown.length) return answers;
147
+ return [
148
+ ...answers,
149
+ { role: "user", content: [{ type: "text", text: OpenaiDialect.toolPicturesHeading }, ...shown] },
150
+ ];
151
+ }
152
+
153
+ /** Pushes a note for each file no part can carry, for the caller to fold into the text beside them. */
154
+ static attachmentParts(
155
+ attachments: AgentWireAttachment[],
156
+ accepts: LlmAccepts | undefined,
157
+ notes: string[],
158
+ ): { blocks: string[]; images: { name: string; part: OpenaiImagePart }[] } {
121
159
 
122
160
  const blocks = attachments.flatMap((attachment) =>
123
161
  attachment.text ? [`--- attachment: ${attachment.name} (${attachment.mimeType}) ---\n${attachment.text}`] : [],
@@ -138,11 +176,9 @@ export class OpenaiDialect {
138
176
  }
139
177
 
140
178
  const url = attachment.data ? `data:${mimeType};base64,${attachment.data}` : (attachment.url ?? "");
141
- return url ? [{ type: "image_url" as const, image_url: { url } }] : [];
179
+ return url ? [{ name: attachment.name, part: { type: "image_url" as const, image_url: { url } } }] : [];
142
180
  });
143
- const text = [message.text, ...blocks, ...notes].filter(Boolean).join("\n\n");
144
- if (!images.length) return text;
145
- return [...(text ? [{ type: "text" as const, text }] : []), ...images];
181
+ return { blocks, images };
146
182
  }
147
183
 
148
184
  static async consumeStream(
@@ -0,0 +1,166 @@
1
+ import { Binary } from "akanjs/base";
2
+ import { type MessageAttachment, ToolOutput } from "../../vendor/use-agentic";
3
+ import { ScreenTarget } from "./ScreenTarget";
4
+
5
+ export interface ScreenImageView {
6
+ attachment: MessageAttachment;
7
+ /** The picture's size and how it was sent, for a model asked about its proportions or resolution. */
8
+ size: string;
9
+ }
10
+
11
+ /**
12
+ * A picture on the screen, handed to the model as the browser drew it: redrawn into a JPEG no longer than
13
+ * `maxEdge`, inlined as bytes. Redrawing turns HEIC, AVIF and SVG into a type every provider reads, bounds what one
14
+ * picture costs, and leaves the provider nothing to download — a storage path or a `blob:` address it cannot reach,
15
+ * or a signed URL that expires while the conversation still carries it.
16
+ */
17
+ export class ScreenImage {
18
+ /** The long edge Anthropic downscales past; a bigger picture spends upload and latency on pixels nobody reads. */
19
+ static readonly maxEdge = 1568;
20
+ static readonly quality = 0.85;
21
+ static readonly loadMs = 10_000;
22
+
23
+ static readonly #typeByExtension = new Map([
24
+ ["jpg", "image/jpeg"],
25
+ ["jpeg", "image/jpeg"],
26
+ ["png", "image/png"],
27
+ ["gif", "image/gif"],
28
+ ["webp", "image/webp"],
29
+ ]);
30
+
31
+ /** The `<img>` showing `src` in `root`, matched on what readScreen prints, its absolute form, or the attribute. */
32
+ static find(src: string, root?: HTMLElement): HTMLImageElement | null {
33
+ const scope = root ?? (typeof document === "undefined" ? null : document.body);
34
+ if (!scope) return null;
35
+ for (const image of scope.querySelectorAll("img"))
36
+ if (
37
+ (image.currentSrc === src || image.src === src || image.getAttribute("src") === src) &&
38
+ ScreenTarget.visible(image)
39
+ )
40
+ return image;
41
+ return null;
42
+ }
43
+
44
+ static async view(image: HTMLImageElement): Promise<ScreenImageView> {
45
+ const src = image.currentSrc || image.src;
46
+ const name = ScreenImage.#nameOf(image, src);
47
+
48
+ const loaded = image.complete && image.naturalWidth > 0 ? image : await ScreenImage.#load(src, false);
49
+ const drawn =
50
+ (loaded ? await ScreenImage.#draw(loaded, name) : null) ??
51
+ (await ScreenImage.#drawFrom(await ScreenImage.#load(src, true), name));
52
+ if (drawn) return drawn;
53
+ const mimeType = ScreenImage.#typeOf(src);
54
+ if (mimeType && ScreenImage.publiclyFetchable(src))
55
+ return {
56
+ attachment: { name, mimeType, url: src },
57
+ size: `${ScreenImage.#sizeOf(image)}, sent by address because this page may not read its pixels`,
58
+ };
59
+ throw new Error(
60
+ `${name} cannot be viewed: its host does not let this page read the picture, and the model's provider cannot download it from ${src}. Tell the user you cannot see it.`,
61
+ );
62
+ }
63
+
64
+ /**
65
+ * Whether a provider could download `url` itself — an http(s) address off loopback, private, link-local and CGNAT
66
+ * ranges. It decides between an address and no picture at all; it is not an SSRF guard and must not become one.
67
+ */
68
+ static publiclyFetchable(url: string): boolean {
69
+ if (!/^https?:\/\//i.test(url)) return false;
70
+ let hostname: string;
71
+ try {
72
+ hostname = new URL(url).hostname.toLowerCase();
73
+ } catch {
74
+ return false;
75
+ }
76
+ if (hostname === "localhost" || hostname.endsWith(".localhost") || hostname.endsWith(".local")) return false;
77
+ if (hostname === "internal" || hostname.endsWith(".internal")) return false;
78
+ if (hostname.startsWith("[")) return !ScreenImage.#privateV6(hostname.slice(1, -1));
79
+ return !/^(?:0\.|10\.|127\.|169\.254\.|192\.168\.|172\.(?:1[6-9]|2\d|3[01])\.|100\.(?:6[4-9]|[7-9]\d|1[01]\d|12[0-7])\.)/.test(
80
+ hostname,
81
+ );
82
+ }
83
+
84
+ static #privateV6(address: string): boolean {
85
+ if (address === "::" || address === "::1" || address.startsWith("::ffff:")) return true;
86
+ const first = Number.parseInt(address.split(":")[0] || "0", 16);
87
+ return (first & 0xfe00) === 0xfc00 || (first & 0xffc0) === 0xfe80;
88
+ }
89
+
90
+ static async #drawFrom(image: HTMLImageElement | null, name: string): Promise<ScreenImageView | null> {
91
+ return image ? await ScreenImage.#draw(image, name) : null;
92
+ }
93
+
94
+ static async #draw(image: HTMLImageElement, name: string): Promise<ScreenImageView | null> {
95
+ const width = image.naturalWidth || image.width;
96
+ const height = image.naturalHeight || image.height;
97
+ if (!width || !height) return null;
98
+ const scale = Math.min(1, ScreenImage.maxEdge / Math.max(width, height));
99
+ const canvas = document.createElement("canvas");
100
+ canvas.width = Math.max(1, Math.round(width * scale));
101
+ canvas.height = Math.max(1, Math.round(height * scale));
102
+ const context = canvas.getContext("2d");
103
+ if (!context) return null;
104
+
105
+ context.fillStyle = "#ffffff";
106
+ context.fillRect(0, 0, canvas.width, canvas.height);
107
+ context.drawImage(image, 0, 0, canvas.width, canvas.height);
108
+ let blob: Blob | null;
109
+ try {
110
+ blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, "image/jpeg", ScreenImage.quality));
111
+ } catch {
112
+
113
+ return null;
114
+ }
115
+ if (!blob) return null;
116
+ const bytes = new Uint8Array(await blob.arrayBuffer());
117
+ if (bytes.length > ToolOutput.attachmentBytes)
118
+ throw new Error(`${name} is ${bytes.length} bytes even as a JPEG, past what one tool result may carry.`);
119
+ return {
120
+ attachment: { name, mimeType: "image/jpeg", data: Binary.toBase64(bytes) },
121
+ size: `${width}×${height}, sent as ${canvas.width}×${canvas.height} JPEG`,
122
+ };
123
+ }
124
+
125
+ static #load(src: string, cors: boolean): Promise<HTMLImageElement | null> {
126
+ return new Promise((resolve) => {
127
+ const image = new Image();
128
+ if (cors) image.crossOrigin = "anonymous";
129
+ const timer = setTimeout(() => resolve(null), ScreenImage.loadMs);
130
+ image.onload = () => {
131
+ clearTimeout(timer);
132
+ resolve(image);
133
+ };
134
+ image.onerror = () => {
135
+ clearTimeout(timer);
136
+ resolve(null);
137
+ };
138
+ image.src = src;
139
+ });
140
+ }
141
+
142
+ static #nameOf(image: HTMLImageElement, src: string): string {
143
+ const alt = image.getAttribute("alt")?.trim();
144
+ if (alt) return alt;
145
+ if (!/^https?:/i.test(src)) return "image";
146
+ try {
147
+ return decodeURIComponent(new URL(src).pathname.split("/").pop() ?? "") || "image";
148
+ } catch {
149
+ return "image";
150
+ }
151
+ }
152
+
153
+ static #typeOf(src: string): string | null {
154
+ let pathname: string;
155
+ try {
156
+ pathname = new URL(src).pathname;
157
+ } catch {
158
+ return null;
159
+ }
160
+ return ScreenImage.#typeByExtension.get(pathname.split(".").pop()?.toLowerCase() ?? "") ?? null;
161
+ }
162
+
163
+ static #sizeOf(image: HTMLImageElement): string {
164
+ return image.naturalWidth ? `${image.naturalWidth}×${image.naturalHeight}` : "size unknown";
165
+ }
166
+ }
@@ -1,7 +1,8 @@
1
1
  import { router } from "akanjs/client";
2
- import { AgenticSurface, type SurfaceSource, type ToolEntry } from "../../vendor/use-agentic";
2
+ import { AgenticSurface, type SurfaceSource, type ToolEntry, ToolReturn } from "../../vendor/use-agentic";
3
3
  import { AgentBridge } from "./AgentBridge";
4
4
  import { ScreenFlash } from "./ScreenFlash";
5
+ import { ScreenImage } from "./ScreenImage";
5
6
  import { ScreenReader } from "./ScreenReader";
6
7
  import { ScreenSettle } from "./ScreenSettle";
7
8
  import { ScreenTarget } from "./ScreenTarget";
@@ -9,7 +10,7 @@ import { ScreenTarget } from "./ScreenTarget";
9
10
  /** Every screen's built-in tools. A same-named hook tool shadows one at root scope only; a zone uses `builtins`. */
10
11
  export class StoreSurfaceSource implements SurfaceSource {
11
12
  /** What a session's `builtins` option selects from, in build order. */
12
- static readonly builtins = ["navigate", "goBack", "readScreen", "readState", "highlight"] as const;
13
+ static readonly builtins = ["navigate", "goBack", "readScreen", "readState", "highlight", "viewImage"] as const;
13
14
 
14
15
  #bridge: AgentBridge | null;
15
16
  readonly #builtins = new Map<string, ToolEntry[]>();
@@ -29,6 +30,7 @@ export class StoreSurfaceSource implements SurfaceSource {
29
30
  StoreSurfaceSource.#readScreen(viewKey),
30
31
  this.#readState(viewKey),
31
32
  StoreSurfaceSource.#highlight(viewKey),
33
+ StoreSurfaceSource.#viewImage(viewKey),
32
34
  ];
33
35
  this.#builtins.set(viewKey, builtins);
34
36
  }
@@ -158,6 +160,38 @@ export class StoreSurfaceSource implements SurfaceSource {
158
160
  };
159
161
  }
160
162
 
163
+ static #viewImage(viewKey: string): ToolEntry {
164
+ const absent = (src: string) =>
165
+ `No picture at ${src} is on screen now. Call readScreen with images: true and pass an address exactly as it prints one; a picture that has left the screen cannot be viewed.`;
166
+ return {
167
+ name: "viewImage",
168
+ description:
169
+ "Look at one picture on screen, to answer from what it shows rather than from its caption. Pass an [image] address readScreen printed with `images: true`. Each picture costs this turn over a thousand tokens, so view only the ones the answer needs.",
170
+ parameters: {
171
+ type: "object",
172
+ properties: { src: { type: "string", description: "The image address exactly as readScreen printed it." } },
173
+ required: ["src"],
174
+ additionalProperties: false,
175
+ },
176
+ settle: false,
177
+
178
+ guard: (args) => {
179
+ const src = typeof args.src === "string" ? args.src.trim() : "";
180
+ if (!src) return "viewImage needs a src.";
181
+ if (src.startsWith("data:"))
182
+ return "A data: address is the picture's bytes, not an address readScreen prints. Pass one readScreen printed.";
183
+ return ScreenImage.find(src, StoreSurfaceSource.#zoneRoot(viewKey)) ? true : absent(src);
184
+ },
185
+ run: async (args) => {
186
+ const src = String(args.src).trim();
187
+ const image = ScreenImage.find(src, StoreSurfaceSource.#zoneRoot(viewKey));
188
+ if (!image) throw new Error(absent(src));
189
+ const { attachment, size } = await ScreenImage.view(image);
190
+ return ToolReturn.attach(`Attached ${attachment.name}: ${size}.`, [attachment]);
191
+ },
192
+ };
193
+ }
194
+
161
195
  #readState(viewKey: string): ToolEntry {
162
196
  return {
163
197
  name: "readState",
@@ -1,11 +1,12 @@
1
1
 
2
- export { AgentAbort, AgentProgress, type AgentProgressStep } from "../../vendor/use-agentic";
2
+ export { AgentAbort, AgentProgress, type AgentProgressStep, ToolReturn } from "../../vendor/use-agentic";
3
3
  export * from "./AgentBridge";
4
4
  export * from "./AgentContext";
5
5
  export * from "./AgentCursor";
6
6
  export * from "./AgentPrompts";
7
7
  export * from "./AgentVisual";
8
8
  export * from "./ScreenFlash";
9
+ export * from "./ScreenImage";
9
10
  export * from "./ScreenReader";
10
11
  export * from "./ScreenSettle";
11
12
  export * from "./ScreenTarget";
@@ -31,7 +31,9 @@ export declare class AgentService extends AgentService_base {
31
31
  private static fenced;
32
32
  /** Swaps an attachment the provider cannot read for a note; dropped silently, it is guessed at from its name. */
33
33
  static readable(request: LlmTurnRequest, accepts: LlmAccepts | undefined): LlmTurnRequest;
34
+ private static carriesFiles;
34
35
  private static readableMessage;
36
+ private static readableResult;
35
37
  private static isReadable;
36
38
  private static note;
37
39
  }
@@ -1,4 +1,4 @@
1
- import type { AgentWireAttachment, AgentWireMessage, LlmAccepts, LlmAdaptor, LlmLimits, LlmOption, LlmTurnAnswer, LlmTurnRequest, LlmUsage } from "./llm.adaptor";
1
+ import type { AgentWireAttachment, AgentWireMessage, AgentWireToolResult, LlmAccepts, LlmAdaptor, LlmLimits, LlmOption, LlmTurnAnswer, LlmTurnRequest, LlmUsage } from "./llm.adaptor";
2
2
  type AnthropicSource = {
3
3
  type: "base64";
4
4
  media_type: string;
@@ -7,7 +7,7 @@ type AnthropicSource = {
7
7
  type: "url";
8
8
  url: string;
9
9
  };
10
- type AnthropicBlock = {
10
+ type AnthropicContentBlock = {
11
11
  type: "text";
12
12
  text: string;
13
13
  } | {
@@ -16,7 +16,8 @@ type AnthropicBlock = {
16
16
  } | {
17
17
  type: "document";
18
18
  source: AnthropicSource;
19
- } | {
19
+ };
20
+ type AnthropicBlock = AnthropicContentBlock | {
20
21
  type: "tool_use";
21
22
  id: string;
22
23
  name: string;
@@ -24,7 +25,7 @@ type AnthropicBlock = {
24
25
  } | {
25
26
  type: "tool_result";
26
27
  tool_use_id: string;
27
- content: string;
28
+ content: string | AnthropicContentBlock[];
28
29
  };
29
30
  interface AnthropicMessage {
30
31
  role: "user" | "assistant";
@@ -90,6 +91,10 @@ export declare class AnthropicLlm extends AnthropicLlm_base implements LlmAdapto
90
91
  static providerMessages(messages: AgentWireMessage[], accepts?: LlmAccepts): AnthropicMessage[];
91
92
  static providerMessage(message: AgentWireMessage, accepts?: LlmAccepts): AnthropicMessage;
92
93
  static userContent(message: AgentWireMessage, accepts?: LlmAccepts): AnthropicBlock[];
94
+ /** A string while the call returned no file, so a transcript without one posts what it always did. */
95
+ static toolResult(result: AgentWireToolResult, accepts?: LlmAccepts): AnthropicBlock;
96
+ /** Pushes a note for each file no block can carry, for the caller to fold into the text beside them. */
97
+ static attachmentBlocks(attachments: AgentWireAttachment[], accepts: LlmAccepts | undefined, notes: string[]): AnthropicContentBlock[];
93
98
  static typed(source: AnthropicSource, mimeType: string): AnthropicSource;
94
99
  /** Bytes beat a URL: the default storage serves a path only the app can resolve, so the provider cannot fetch it. */
95
100
  static sourceOf(attachment: AgentWireAttachment): AnthropicSource | null;
@@ -9,6 +9,8 @@ export interface AgentWireToolResult {
9
9
  result?: unknown;
10
10
  changes?: unknown[];
11
11
  error?: string;
12
+ /** Files the call handed the model, as content beside the JSON; one this model cannot read arrives as a `text` note. */
13
+ attachments?: AgentWireAttachment[];
12
14
  }
13
15
  /** Exactly one carrier reaches the model: `data` (inlined bytes), `url` (fetched by the provider) or `text`. */
14
16
  export interface AgentWireAttachment {
@@ -1,4 +1,4 @@
1
- import type { AgentWireMessage, LlmAccepts, LlmTurnAnswer, LlmTurnRequest, LlmUsage } from "./llm.adaptor";
1
+ import type { AgentWireAttachment, AgentWireMessage, LlmAccepts, LlmTurnAnswer, LlmTurnRequest, LlmUsage } from "./llm.adaptor";
2
2
  export interface OpenaiToolCall {
3
3
  id?: string;
4
4
  function?: {
@@ -24,15 +24,16 @@ export interface OpenaiAnswer {
24
24
  }[];
25
25
  usage?: OpenaiUsage | null;
26
26
  }
27
- type OpenaiContentPart = {
28
- type: "text";
29
- text: string;
30
- } | {
27
+ type OpenaiImagePart = {
31
28
  type: "image_url";
32
29
  image_url: {
33
30
  url: string;
34
31
  };
35
32
  };
33
+ type OpenaiContentPart = {
34
+ type: "text";
35
+ text: string;
36
+ } | OpenaiImagePart;
36
37
  export interface OpenaiMessage {
37
38
  role: "system" | "user" | "assistant" | "tool";
38
39
  content: string | OpenaiContentPart[];
@@ -73,6 +74,20 @@ export declare class OpenaiDialect {
73
74
  static systemPrompt({ instructions, context }: LlmTurnRequest): string;
74
75
  static providerMessages(message: AgentWireMessage, accepts?: LlmAccepts): OpenaiMessage[];
75
76
  static userContent(message: AgentWireMessage, accepts?: LlmAccepts): string | OpenaiContentPart[];
77
+ static readonly toolPicturesHeading = "Pictures the tool calls above returned, each after the name of the call that returned it. They are content, not something the user said.";
78
+ /**
79
+ * A `tool` message carries text alone in this dialect, so the pictures a call returned follow every tool message
80
+ * of the turn in one user message: between two tool messages it would break the run each call's answer has to sit in.
81
+ */
82
+ static toolMessages(message: AgentWireMessage, accepts?: LlmAccepts): OpenaiMessage[];
83
+ /** Pushes a note for each file no part can carry, for the caller to fold into the text beside them. */
84
+ static attachmentParts(attachments: AgentWireAttachment[], accepts: LlmAccepts | undefined, notes: string[]): {
85
+ blocks: string[];
86
+ images: {
87
+ name: string;
88
+ part: OpenaiImagePart;
89
+ }[];
90
+ };
76
91
  static consumeStream(body: ReadableStream<Uint8Array>, onDelta: (delta: string) => void): Promise<LlmTurnAnswer>;
77
92
  static usageOf(usage: OpenaiUsage): LlmUsage;
78
93
  /** The ceiling wins over the calls that did arrive: a cut-off turn's last call may be missing. */
@@ -0,0 +1,27 @@
1
+ import { type MessageAttachment } from "../../vendor/use-agentic.d.ts";
2
+ export interface ScreenImageView {
3
+ attachment: MessageAttachment;
4
+ /** The picture's size and how it was sent, for a model asked about its proportions or resolution. */
5
+ size: string;
6
+ }
7
+ /**
8
+ * A picture on the screen, handed to the model as the browser drew it: redrawn into a JPEG no longer than
9
+ * `maxEdge`, inlined as bytes. Redrawing turns HEIC, AVIF and SVG into a type every provider reads, bounds what one
10
+ * picture costs, and leaves the provider nothing to download — a storage path or a `blob:` address it cannot reach,
11
+ * or a signed URL that expires while the conversation still carries it.
12
+ */
13
+ export declare class ScreenImage {
14
+ #private;
15
+ /** The long edge Anthropic downscales past; a bigger picture spends upload and latency on pixels nobody reads. */
16
+ static readonly maxEdge = 1568;
17
+ static readonly quality = 0.85;
18
+ static readonly loadMs = 10000;
19
+ /** The `<img>` showing `src` in `root`, matched on what readScreen prints, its absolute form, or the attribute. */
20
+ static find(src: string, root?: HTMLElement): HTMLImageElement | null;
21
+ static view(image: HTMLImageElement): Promise<ScreenImageView>;
22
+ /**
23
+ * Whether a provider could download `url` itself — an http(s) address off loopback, private, link-local and CGNAT
24
+ * ranges. It decides between an address and no picture at all; it is not an SSRF guard and must not become one.
25
+ */
26
+ static publiclyFetchable(url: string): boolean;
27
+ }
@@ -4,7 +4,7 @@ import { AgentBridge } from "./AgentBridge.d.ts";
4
4
  export declare class StoreSurfaceSource implements SurfaceSource {
5
5
  #private;
6
6
  /** What a session's `builtins` option selects from, in build order. */
7
- static readonly builtins: readonly ["navigate", "goBack", "readScreen", "readState", "highlight"];
7
+ static readonly builtins: readonly ["navigate", "goBack", "readScreen", "readState", "highlight", "viewImage"];
8
8
  /** Without a bridge, `AgentBridge.of()` runs at the first `readState` call. */
9
9
  constructor(bridge?: AgentBridge);
10
10
  tools: (view?: string[]) => ToolEntry[];
@@ -1,10 +1,11 @@
1
- export { AgentAbort, AgentProgress, type AgentProgressStep } from "../../vendor/use-agentic.d.ts";
1
+ export { AgentAbort, AgentProgress, type AgentProgressStep, ToolReturn } from "../../vendor/use-agentic.d.ts";
2
2
  export * from "./AgentBridge.d.ts";
3
3
  export * from "./AgentContext.d.ts";
4
4
  export * from "./AgentCursor.d.ts";
5
5
  export * from "./AgentPrompts.d.ts";
6
6
  export * from "./AgentVisual.d.ts";
7
7
  export * from "./ScreenFlash.d.ts";
8
+ export * from "./ScreenImage.d.ts";
8
9
  export * from "./ScreenReader.d.ts";
9
10
  export * from "./ScreenSettle.d.ts";
10
11
  export * from "./ScreenTarget.d.ts";
@@ -1,4 +1,4 @@
1
- import type { ChatMessage, TurnLimits } from "./types.d.ts";
1
+ import type { ChatMessage, ToolCallResult, TurnLimits } from "./types.d.ts";
2
2
  export interface CompactOptions {
3
3
  /**
4
4
  * Estimated transcript tokens above which a turn summarizes its own history first — a ceiling on what each turn
@@ -50,6 +50,8 @@ export declare class Compaction {
50
50
  * more than the slack the estimate leaves.
51
51
  */
52
52
  static tokensOf(messages: readonly ChatMessage[]): number;
53
+ /** One tool result as it rides the wire, by the same rule. */
54
+ static resultTokensOf(result: ToolCallResult): number;
53
55
  /**
54
56
  * What the next request is estimated to cost, prompt and all: the provider's own count for the last turn that
55
57
  * reported one, plus four characters a token for what the transcript gained since. The provider's number is the
@@ -10,12 +10,17 @@ import type { ToolCallResult } from "./types.d.ts";
10
10
  *
11
11
  * Clipped rather than dropped, and the note says what happened: a model handed a value it cannot see the end of
12
12
  * asks a narrower question, where one handed nothing answers from the field names.
13
+ *
14
+ * A file the call attached is bounded apart: a picture is billed by its pixels, not by the length of its base64, so
15
+ * counting it as characters would clip every one. It meets the per-file ceiling a user's attachment meets instead.
13
16
  */
14
17
  export declare class ToolOutput {
15
18
  #private;
16
19
  /** Characters, ~5k tokens: comfortably above `ScreenReader.limit` and far below any provider's window. */
17
20
  static readonly limit = 20000;
18
- /** Four characters per token, over the JSON the result rides as — the same estimate `Compaction` places. */
21
+ /** Bytes of one attached file, as `maxAttachmentBytes` bounds a user's: a provider refuses a turn past it. */
22
+ static readonly attachmentBytes: number;
23
+ /** The estimate `Compaction` places: four characters per token over the JSON, and a flat rate per picture. */
19
24
  static tokensOf(result: ToolCallResult): number;
20
25
  static clipped(result: ToolCallResult): ToolCallResult;
21
26
  /**
@@ -0,0 +1,18 @@
1
+ import type { MessageAttachment } from "./types.d.ts";
2
+ /**
3
+ * What a tool's `run` returns when its answer includes files beside the value: `ToolReturn.attach(result, files)`.
4
+ * `ToolRunner` lifts the files into `ToolCallResult.attachments`, which a backend hands the model as content.
5
+ */
6
+ export declare class ToolReturn {
7
+ #private;
8
+ /** Interned, not an `instanceof`: one page can hold several bundled copies of this package. */
9
+ static readonly brand: unique symbol;
10
+ readonly result: unknown;
11
+ readonly attachments: MessageAttachment[];
12
+ constructor(result: unknown, attachments: MessageAttachment[]);
13
+ static attach(result: unknown, attachments: MessageAttachment[]): ToolReturn;
14
+ static split(value: unknown): {
15
+ result: unknown;
16
+ attachments: MessageAttachment[];
17
+ };
18
+ }
@@ -10,9 +10,16 @@ import type { ChatMessage } from "./types.d.ts";
10
10
  * transcript is assembled rather than where each hole is made.
11
11
  */
12
12
  export declare class Transcript {
13
+ #private;
13
14
  static readonly unanswered = "The turn was stopped before this call ran.";
14
15
  /** What one turn posts: no host-only message, no unanswered call, no result answering a call nobody sees. */
15
16
  static wire(messages: readonly ChatMessage[]): ChatMessage[];
17
+ /**
18
+ * A file a tool returned before the user's latest message, as a note in its place. A picture costs a turn over a
19
+ * thousand tokens and would ride every turn after the one that asked for it; a later question about it calls the
20
+ * tool again, which is why the note says to — and to do it while the picture is on screen, where a tool can reach it.
21
+ */
22
+ static seen(messages: readonly ChatMessage[]): ChatMessage[];
16
23
  /** A message without the provider's count for its turn — what the wire carries and what a compaction keeps. */
17
24
  static unmeasured(message: ChatMessage): ChatMessage;
18
25
  static sanitize(messages: readonly ChatMessage[]): ChatMessage[];
@@ -13,6 +13,7 @@ export * from "./Reference.d.ts";
13
13
  export * from "./sharedContext.d.ts";
14
14
  export * from "./surfaceContext.d.ts";
15
15
  export * from "./ToolOutput.d.ts";
16
+ export * from "./ToolReturn.d.ts";
16
17
  export * from "./ToolRunner.d.ts";
17
18
  export * from "./Transcript.d.ts";
18
19
  export * from "./types.d.ts";
@@ -144,6 +144,12 @@ export interface ToolCallResult {
144
144
  /** What the call changed on the surface — the whole report for a tool that returns nothing. */
145
145
  changes?: ResourceDiff[];
146
146
  error?: string;
147
+ /**
148
+ * Files the call handed the model — a picture it looked at — in the shape a user's attachment takes. A `result`
149
+ * cannot carry one: it reaches the model as JSON text, so a URL is read as letters and base64 as a wall of them.
150
+ * Outside the `ToolOutput` ceiling, and only the latest turn's ride the wire as bytes (`Transcript.wire`).
151
+ */
152
+ attachments?: MessageAttachment[];
147
153
  }
148
154
  /**
149
155
  * Why an assistant turn ended. `length` is the provider's own ceiling rather than the model's choice, so the turn
@@ -14,6 +14,7 @@ import {
14
14
  type AgentSessionOptions,
15
15
  type ChatMessage,
16
16
  type CompactOptions,
17
+ type MessageAttachment,
17
18
  type SessionHistory,
18
19
  type SurfaceView,
19
20
  Transcript,
@@ -100,18 +101,24 @@ export const sessionView = (surface: AgenticSurface, path: string[], builtins?:
100
101
  const restoredValue =
101
102
  "the conversation was restored from storage, which keeps what the user pointed at but not the value it held";
102
103
 
104
+ const pointerOf = ({ name, mimeType, url, ref }: MessageAttachment): MessageAttachment => ({
105
+ name,
106
+ mimeType,
107
+ ...(url ? { url } : {}),
108
+ ...(ref ? { ref } : {}),
109
+ });
110
+
103
111
  const withoutContent = (message: ChatMessage): ChatMessage => {
104
- if (!message.attachments?.length && !message.references?.length) return message;
112
+ const toolFiles = message.toolResults?.some((result) => result.attachments?.length);
113
+ if (!message.attachments?.length && !message.references?.length && !toolFiles) return message;
105
114
  return {
106
115
  ...message,
107
- ...(message.attachments?.length
116
+ ...(message.attachments?.length ? { attachments: message.attachments.map(pointerOf) } : {}),
117
+ ...(toolFiles
108
118
  ? {
109
- attachments: message.attachments.map(({ name, mimeType, url, ref }) => ({
110
- name,
111
- mimeType,
112
- ...(url ? { url } : {}),
113
- ...(ref ? { ref } : {}),
114
- })),
119
+ toolResults: message.toolResults?.map((result) =>
120
+ result.attachments?.length ? { ...result, attachments: result.attachments.map(pointerOf) } : result,
121
+ ),
115
122
  }
116
123
  : {}),
117
124
  ...(message.references?.length
@@ -1,4 +1,5 @@
1
- import type { ChatMessage, MessageAttachment, TurnLimits } from "./types";
1
+ import { Transcript } from "./Transcript";
2
+ import type { ChatMessage, MessageAttachment, ToolCallResult, TurnLimits } from "./types";
2
3
 
3
4
  export interface CompactOptions {
4
5
  /**
@@ -58,10 +59,17 @@ export class Compaction {
58
59
  */
59
60
  static tokensOf(messages: readonly ChatMessage[]): number {
60
61
  let chars = 0;
61
- for (const message of messages) if (!message.local) chars += Compaction.#charsOf(message);
62
+ for (const message of Transcript.seen(messages)) if (!message.local) chars += Compaction.#charsOf(message);
62
63
  return Math.ceil(chars / 4);
63
64
  }
64
65
 
66
+ /** One tool result as it rides the wire, by the same rule. */
67
+ static resultTokensOf(result: ToolCallResult): number {
68
+ const { attachments, images } = Compaction.#blanked(result.attachments);
69
+ const chars = JSON.stringify(images ? { ...result, attachments } : result).length;
70
+ return Math.ceil(chars / 4) + images * Compaction.imageTokens;
71
+ }
72
+
65
73
  /**
66
74
  * What the next request is estimated to cost, prompt and all: the provider's own count for the last turn that
67
75
  * reported one, plus four characters a token for what the transcript gained since. The provider's number is the
@@ -88,12 +96,32 @@ export class Compaction {
88
96
 
89
97
  static #charsOf(message: ChatMessage): number {
90
98
  const posted = message.usage ? { ...message, usage: undefined } : message;
91
- const images = message.attachments?.filter(Compaction.#isInlinedImage).length ?? 0;
99
+ const own = Compaction.#blanked(message.attachments);
100
+ let images = own.images;
101
+ const toolResults = message.toolResults?.map((result) => {
102
+ const blanked = Compaction.#blanked(result.attachments);
103
+ images += blanked.images;
104
+ return blanked.images ? { ...result, attachments: blanked.attachments } : result;
105
+ });
92
106
  if (!images) return JSON.stringify(posted).length;
93
- const attachments = message.attachments?.map((attachment) =>
94
- Compaction.#isInlinedImage(attachment) ? { ...attachment, data: "" } : attachment,
107
+ return (
108
+ JSON.stringify({ ...posted, attachments: own.attachments, toolResults }).length +
109
+ images * Compaction.imageTokens * 4
95
110
  );
96
- return JSON.stringify({ ...posted, attachments }).length + images * Compaction.imageTokens * 4;
111
+ }
112
+
113
+ static #blanked(attachments: MessageAttachment[] | undefined): {
114
+ attachments: MessageAttachment[] | undefined;
115
+ images: number;
116
+ } {
117
+ const images = attachments?.filter(Compaction.#isInlinedImage).length ?? 0;
118
+ if (!images) return { attachments, images };
119
+ return {
120
+ attachments: attachments?.map((attachment) =>
121
+ Compaction.#isInlinedImage(attachment) ? { ...attachment, data: "" } : attachment,
122
+ ),
123
+ images,
124
+ };
97
125
  }
98
126
 
99
127
  static #isInlinedImage(attachment: MessageAttachment): boolean {
@@ -185,13 +213,16 @@ export class Compaction {
185
213
  );
186
214
  for (const call of message.toolCalls ?? [])
187
215
  parts.push(`[called ${call.name} ${Compaction.#clip(JSON.stringify(call.args), 200)}]`);
188
- for (const result of message.toolResults ?? [])
216
+ for (const result of message.toolResults ?? []) {
189
217
  parts.push(
190
218
  `[${result.error ? "failed" : "result"} ${result.name}: ${Compaction.#clip(
191
219
  result.error ?? JSON.stringify(result.result ?? null),
192
220
  400,
193
221
  )}]`,
194
222
  );
223
+ for (const attachment of result.attachments ?? [])
224
+ parts.push(`[${result.name} returned ${attachment.name}, content not carried into this summary]`);
225
+ }
195
226
  if (message.error) parts.push(`[turn failed: ${message.error}]`);
196
227
  return `${message.role}: ${parts.join(" ")}`.trimEnd();
197
228
  }
@@ -1,4 +1,5 @@
1
- import type { ResourceDiff, ToolCallResult } from "./types";
1
+ import { Compaction } from "./Compaction";
2
+ import type { MessageAttachment, ResourceDiff, ToolCallResult } from "./types";
2
3
 
3
4
  /**
4
5
  * The ceiling on what one tool call may add to the transcript.
@@ -11,14 +12,20 @@ import type { ResourceDiff, ToolCallResult } from "./types";
11
12
  *
12
13
  * Clipped rather than dropped, and the note says what happened: a model handed a value it cannot see the end of
13
14
  * asks a narrower question, where one handed nothing answers from the field names.
15
+ *
16
+ * A file the call attached is bounded apart: a picture is billed by its pixels, not by the length of its base64, so
17
+ * counting it as characters would clip every one. It meets the per-file ceiling a user's attachment meets instead.
14
18
  */
15
19
  export class ToolOutput {
16
20
  /** Characters, ~5k tokens: comfortably above `ScreenReader.limit` and far below any provider's window. */
17
21
  static readonly limit = 20_000;
18
22
 
19
- /** Four characters per token, over the JSON the result rides as — the same estimate `Compaction` places. */
23
+ /** Bytes of one attached file, as `maxAttachmentBytes` bounds a user's: a provider refuses a turn past it. */
24
+ static readonly attachmentBytes = 4 * 1024 * 1024;
25
+
26
+ /** The estimate `Compaction` places: four characters per token over the JSON, and a flat rate per picture. */
20
27
  static tokensOf(result: ToolCallResult): number {
21
- return Math.ceil(JSON.stringify(result).length / 4);
28
+ return Compaction.resultTokensOf(result);
22
29
  }
23
30
 
24
31
  static clipped(result: ToolCallResult): ToolCallResult {
@@ -27,6 +34,21 @@ export class ToolOutput {
27
34
  ...(result.result !== undefined ? { result: ToolOutput.#within(result.result) } : {}),
28
35
  ...(result.changes?.length ? { changes: result.changes.map((change) => ToolOutput.#change(change)) } : {}),
29
36
  ...(result.error ? { error: ToolOutput.#text(result.error) } : {}),
37
+ ...(result.attachments?.length
38
+ ? { attachments: result.attachments.map((attachment) => ToolOutput.#attachment(attachment)) }
39
+ : {}),
40
+ };
41
+ }
42
+
43
+ static #attachment(attachment: MessageAttachment): MessageAttachment {
44
+ if (attachment.text) return { ...attachment, text: ToolOutput.#text(attachment.text) };
45
+
46
+ const bytes = Math.floor(((attachment.data?.length ?? 0) * 3) / 4);
47
+ if (bytes <= ToolOutput.attachmentBytes) return attachment;
48
+ return {
49
+ name: attachment.name,
50
+ mimeType: attachment.mimeType,
51
+ text: `[Not attached: ${bytes} bytes, past the ${ToolOutput.attachmentBytes}-byte ceiling on one file a tool may return. Tell the user it was too large to look at.]`,
30
52
  };
31
53
  }
32
54
 
@@ -0,0 +1,32 @@
1
+ import type { MessageAttachment } from "./types";
2
+
3
+ /**
4
+ * What a tool's `run` returns when its answer includes files beside the value: `ToolReturn.attach(result, files)`.
5
+ * `ToolRunner` lifts the files into `ToolCallResult.attachments`, which a backend hands the model as content.
6
+ */
7
+ export class ToolReturn {
8
+ /** Interned, not an `instanceof`: one page can hold several bundled copies of this package. */
9
+ static readonly brand = Symbol.for("use-agentic.ToolReturn");
10
+
11
+ readonly result: unknown;
12
+ readonly attachments: MessageAttachment[];
13
+
14
+ constructor(result: unknown, attachments: MessageAttachment[]) {
15
+ this.result = result;
16
+ this.attachments = attachments;
17
+ Object.defineProperty(this, ToolReturn.brand, { value: true });
18
+ }
19
+
20
+ static attach(result: unknown, attachments: MessageAttachment[]): ToolReturn {
21
+ return new ToolReturn(result, attachments);
22
+ }
23
+
24
+ static split(value: unknown): { result: unknown; attachments: MessageAttachment[] } {
25
+ if (!ToolReturn.#is(value)) return { result: value, attachments: [] };
26
+ return { result: value.result, attachments: value.attachments };
27
+ }
28
+
29
+ static #is(value: unknown): value is ToolReturn {
30
+ return typeof value === "object" && value !== null && ToolReturn.brand in value;
31
+ }
32
+ }
@@ -1,7 +1,9 @@
1
1
  import { AgentAbort } from "./AgentAbort";
2
2
  import { AgentProgress, type AgentProgressReport } from "./AgentProgress";
3
3
  import { ToolOutput } from "./ToolOutput";
4
+ import { ToolReturn } from "./ToolReturn";
4
5
  import type {
6
+ ResourceDiff,
5
7
  SurfaceView,
6
8
  ToolActivity,
7
9
  ToolCallRequest,
@@ -155,12 +157,7 @@ export class ToolRunner {
155
157
  const answered = await ask({ callId: call.id, name: call.name, args: call.args, render: entry.card }, signal);
156
158
  if ("error" in answered) return { ...base, error: answered.error };
157
159
  if (entry.settle !== false) await this.#host.settle?.();
158
- const changes = this.#surface.diffSince(before);
159
- return {
160
- ...base,
161
- ...(answered.result !== undefined ? { result: answered.result } : {}),
162
- ...(changes.length ? { changes } : {}),
163
- };
160
+ return ToolRunner.#answered(base, answered.result, this.#surface.diffSince(before));
164
161
  }
165
162
 
166
163
  async #execute(
@@ -180,7 +177,7 @@ export class ToolRunner {
180
177
  try {
181
178
 
182
179
  if (!this.#stillDecided(call, entry, message)) throw new Error(ToolRunner.#movedError(call.name));
183
- const result = await AgentAbort.run(signal, () =>
180
+ const returned = await AgentAbort.run(signal, () =>
184
181
  AgentProgress.run(
185
182
  (report) => this.#host.progress?.({ callId: call.id, report }),
186
183
  () => ToolRunner.raced(this.#surface.call(call.name, call.args), signal),
@@ -188,12 +185,7 @@ export class ToolRunner {
188
185
  );
189
186
 
190
187
  if (entry.settle !== false) await this.#host.settle?.();
191
- const changes = this.#surface.diffSince(before);
192
- return {
193
- ...base,
194
- ...(result !== undefined ? { result } : {}),
195
- ...(changes.length ? { changes } : {}),
196
- };
188
+ return ToolRunner.#answered(base, returned, this.#surface.diffSince(before));
197
189
  } catch (error) {
198
190
  failed = error instanceof Error ? error.message : String(error);
199
191
  return { ...base, error: failed };
@@ -224,6 +216,16 @@ export class ToolRunner {
224
216
  );
225
217
  }
226
218
 
219
+ static #answered(base: { id: string; name: string }, returned: unknown, changes: ResourceDiff[]): ToolCallResult {
220
+ const { result, attachments } = ToolReturn.split(returned);
221
+ return {
222
+ ...base,
223
+ ...(result !== undefined ? { result } : {}),
224
+ ...(changes.length ? { changes } : {}),
225
+ ...(attachments.length ? { attachments } : {}),
226
+ };
227
+ }
228
+
227
229
  static #movedError(name: string) {
228
230
  return `${name} changed on screen before it ran, so it did not run.`;
229
231
  }
@@ -1,4 +1,4 @@
1
- import type { ChatMessage } from "./types";
1
+ import type { ChatMessage, MessageAttachment, ToolCallResult } from "./types";
2
2
 
3
3
  /**
4
4
  * The transcript's own invariant, in one place because three paths break it: a turn stopped between a tool call
@@ -15,7 +15,43 @@ export class Transcript {
15
15
 
16
16
  /** What one turn posts: no host-only message, no unanswered call, no result answering a call nobody sees. */
17
17
  static wire(messages: readonly ChatMessage[]): ChatMessage[] {
18
- return Transcript.sanitize(messages.filter((message) => !message.local).map(Transcript.unmeasured));
18
+ return Transcript.sanitize(
19
+ Transcript.seen(messages.filter((message) => !message.local)).map(Transcript.unmeasured),
20
+ );
21
+ }
22
+
23
+ /**
24
+ * A file a tool returned before the user's latest message, as a note in its place. A picture costs a turn over a
25
+ * thousand tokens and would ride every turn after the one that asked for it; a later question about it calls the
26
+ * tool again, which is why the note says to — and to do it while the picture is on screen, where a tool can reach it.
27
+ */
28
+ static seen(messages: readonly ChatMessage[]): ChatMessage[] {
29
+ const latest = messages.findLastIndex((message) => message.role === "user" && !message.summary && !message.local);
30
+ if (latest <= 0) return [...messages];
31
+ return messages.map((message, at) =>
32
+ at < latest && message.toolResults?.some(Transcript.#carriesFile)
33
+ ? { ...message, toolResults: message.toolResults.map(Transcript.#noted) }
34
+ : message,
35
+ );
36
+ }
37
+
38
+ static #carriesFile(result: ToolCallResult): boolean {
39
+ return !!result.attachments?.some((attachment) => !attachment.text);
40
+ }
41
+
42
+ static #noted(result: ToolCallResult): ToolCallResult {
43
+ if (!Transcript.#carriesFile(result)) return result;
44
+ const attachments = result.attachments?.map(
45
+ (attachment): MessageAttachment =>
46
+ attachment.text
47
+ ? attachment
48
+ : {
49
+ name: attachment.name,
50
+ mimeType: attachment.mimeType,
51
+ text: `[Seen earlier and no longer attached. To look at it again, call ${result.name} again while it is on screen.]`,
52
+ },
53
+ );
54
+ return { ...result, attachments };
19
55
  }
20
56
 
21
57
  /** A message without the provider's count for its turn — what the wire carries and what a compaction keeps. */
@@ -64,6 +64,26 @@ of one export.
64
64
 
65
65
  Nothing here is stored: the wire carries the bytes for exactly one turn's request.
66
66
 
67
+ ### A tool result's files
68
+
69
+ A `toolResults` entry may carry `attachments` of the same shape — a picture the call looked at, which its `result`
70
+ cannot hold: that reaches the model as JSON text, so a URL in it is read as letters and base64 as a wall of them.
71
+
72
+ ```jsonc
73
+ { "role": "tool", "toolResults": [{ "id": "c3", "name": "viewImage", "result": "Attached card.jpg: 782×962, sent as 782×962 JPEG.",
74
+ "attachments": [{ "name": "card.jpg", "mimeType": "image/jpeg", "data": "/9j/4AAQ…" }] }] }
75
+ ```
76
+
77
+ The same rules apply, and a provider format with no place for a file inside a tool result is still no reason to
78
+ drop one. Hand it to the model in the nearest place it reads — a chat-completions `tool` message carries text alone,
79
+ so the pictures follow the turn's tool messages in one user message, each named after the call that returned it —
80
+ and replace what it cannot read with a note **in the attachment's place**, as a `text` attachment of the same name:
81
+ a tool message has no text of its own for a note to join.
82
+
83
+ The client sends a tool's file as bytes only in the latest turn. One returned before the user's last message arrives
84
+ as such a note, telling the model to call the tool again, since a picture costs a thousand tokens or more on every
85
+ turn it rides.
86
+
67
87
  ## Response
68
88
 
69
89
  `200` with a single JSON object:
@@ -13,6 +13,7 @@ export * from "./Reference";
13
13
  export * from "./sharedContext";
14
14
  export * from "./surfaceContext";
15
15
  export * from "./ToolOutput";
16
+ export * from "./ToolReturn";
16
17
  export * from "./ToolRunner";
17
18
  export * from "./Transcript";
18
19
  export * from "./types";
@@ -167,6 +167,12 @@ export interface ToolCallResult {
167
167
  /** What the call changed on the surface — the whole report for a tool that returns nothing. */
168
168
  changes?: ResourceDiff[];
169
169
  error?: string;
170
+ /**
171
+ * Files the call handed the model — a picture it looked at — in the shape a user's attachment takes. A `result`
172
+ * cannot carry one: it reaches the model as JSON text, so a URL is read as letters and base64 as a wall of them.
173
+ * Outside the `ToolOutput` ceiling, and only the latest turn's ride the wire as bytes (`Transcript.wire`).
174
+ */
175
+ attachments?: MessageAttachment[];
170
176
  }
171
177
 
172
178
  /**
@@ -4,6 +4,7 @@ import { Logger } from "akanjs/common";
4
4
  import { AgentContext, AgentVisual, ensureStoreSurface, ScreenSettle, StoreRegistry } from "akanjs/store";
5
5
  import {
6
6
  type AgenticSurface,
7
+ type MessageAttachment,
7
8
  type ToolApprovalRequest,
8
9
  type ToolCallResult,
9
10
  ToolOutput,
@@ -146,7 +147,7 @@ export class McpAppAgent {
146
147
  return JSON.stringify(tools);
147
148
  }
148
149
 
149
- static #resultOf({ result, changes, error }: ToolCallResult): McpToolResult {
150
+ static #resultOf({ result, changes, error, attachments = [] }: ToolCallResult): McpToolResult {
150
151
  if (error !== undefined) return { content: [{ type: "text", text: error }], isError: true };
151
152
  const text = changes?.length
152
153
  ? JSON.stringify({ ...(result !== undefined ? { result } : {}), changes })
@@ -155,6 +156,15 @@ export class McpAppAgent {
155
156
  : typeof result === "string"
156
157
  ? result
157
158
  : JSON.stringify(result);
158
- return { content: [{ type: "text", text }] };
159
+ return { content: [{ type: "text", text }, ...attachments.map(McpAppAgent.#contentOf)] };
160
+ }
161
+
162
+ static #contentOf({ name, mimeType, data, url, text }: MessageAttachment): McpToolResult["content"][number] {
163
+ if (data !== undefined && mimeType.startsWith("image/")) return { type: "image", data, mimeType };
164
+ if (text !== undefined) return { type: "text", text: `--- attachment: ${name} (${mimeType}) ---\n${text}` };
165
+ return {
166
+ type: "text",
167
+ text: `[${name} (${mimeType}) was not attached${url ? `; it is at ${url}` : ""}: an MCP tool result carries only pictures, as bytes.]`,
168
+ };
159
169
  }
160
170
  }