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.
- package/package.json +1 -1
- package/service/agent.service.ts +21 -1
- package/service/predefinedAdaptor/anthropicLlm.ts +36 -16
- package/service/predefinedAdaptor/llm.adaptor.ts +2 -0
- package/service/predefinedAdaptor/openaiDialect.ts +53 -17
- package/store/agent/ScreenImage.ts +166 -0
- package/store/agent/StoreSurfaceSource.ts +36 -2
- package/store/agent/index.ts +2 -1
- package/types/service/agent.service.d.ts +2 -0
- package/types/service/predefinedAdaptor/anthropicLlm.d.ts +9 -4
- package/types/service/predefinedAdaptor/llm.adaptor.d.ts +2 -0
- package/types/service/predefinedAdaptor/openaiDialect.d.ts +20 -5
- package/types/store/agent/ScreenImage.d.ts +27 -0
- package/types/store/agent/StoreSurfaceSource.d.ts +1 -1
- package/types/store/agent/index.d.ts +2 -1
- package/types/vendor/use-agentic/Compaction.d.ts +3 -1
- package/types/vendor/use-agentic/ToolOutput.d.ts +6 -1
- package/types/vendor/use-agentic/ToolReturn.d.ts +18 -0
- package/types/vendor/use-agentic/Transcript.d.ts +7 -0
- package/types/vendor/use-agentic/index.d.ts +1 -0
- package/types/vendor/use-agentic/types.d.ts +6 -0
- package/ui/Agent/agentSessionOf.ts +15 -8
- package/vendor/use-agentic/Compaction.ts +38 -7
- package/vendor/use-agentic/ToolOutput.ts +25 -3
- package/vendor/use-agentic/ToolReturn.ts +32 -0
- package/vendor/use-agentic/ToolRunner.ts +15 -13
- package/vendor/use-agentic/Transcript.ts +38 -2
- package/vendor/use-agentic/WIRE.md +20 -0
- package/vendor/use-agentic/index.ts +1 -0
- package/vendor/use-agentic/types.ts +6 -0
- package/webkit/McpAppAgent.ts +12 -2
package/package.json
CHANGED
package/service/agent.service.ts
CHANGED
|
@@ -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(
|
|
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
|
|
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 =
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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",
|
package/store/agent/index.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
94
|
-
|
|
107
|
+
return (
|
|
108
|
+
JSON.stringify({ ...posted, attachments: own.attachments, toolResults }).length +
|
|
109
|
+
images * Compaction.imageTokens * 4
|
|
95
110
|
);
|
|
96
|
-
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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:
|
|
@@ -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
|
/**
|
package/webkit/McpAppAgent.ts
CHANGED
|
@@ -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
|
}
|