@theoremjs/agents 0.3.4-next.387.g8bea7ff → 0.3.4-next.401.gec4c582
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.
|
@@ -14,6 +14,7 @@ import { AgentCallFailed, agentToolOrigin } from './agent.js';
|
|
|
14
14
|
import { coordinateTool } from './coordinate.js';
|
|
15
15
|
import { dispatchToolBody, failedActivity, failedActivityOf, failureEvent, messageOf, networkBlocked, networkBlockedEvent, requestChecks, sourceEvents, startToolExecution, toolCallArguments, toolEvent, } from './events.js';
|
|
16
16
|
import { HandlerEvents } from './handler-events.js';
|
|
17
|
+
import { fetchToolImages } from './image-fetch.js';
|
|
17
18
|
import { formatToolFailureForModel, formatToolResult, frameToolFailure } from './model-text.js';
|
|
18
19
|
import { operationUnavailable, operationWireValue } from './operation.js';
|
|
19
20
|
import { checkPermission, gateDetails, isGateResumeDenied, isGateResumeGranted, isResumeContinuation, } from './permission.js';
|
|
@@ -275,8 +276,15 @@ async function* settleToolCall(args) {
|
|
|
275
276
|
denied = true;
|
|
276
277
|
}
|
|
277
278
|
}
|
|
278
|
-
if (!failure
|
|
279
|
-
yield*
|
|
279
|
+
if (!failure) {
|
|
280
|
+
modelResult = yield* citeAndShow({
|
|
281
|
+
base,
|
|
282
|
+
sources,
|
|
283
|
+
images: args.images,
|
|
284
|
+
outputRaw,
|
|
285
|
+
modelResult,
|
|
286
|
+
});
|
|
287
|
+
}
|
|
280
288
|
yield failure
|
|
281
289
|
? failureEvent(base, { ...failure, message: String(yield* guard.shown(failure.message)) }, formatToolResult(modelResult))
|
|
282
290
|
: completeEvent(base, {
|
|
@@ -295,6 +303,35 @@ async function* settleToolCall(args) {
|
|
|
295
303
|
...stageSettlement(post),
|
|
296
304
|
};
|
|
297
305
|
}
|
|
306
|
+
/** The settled output's cited sources, then the pictures its tool names; the result carrying them. */
|
|
307
|
+
async function* citeAndShow(args) {
|
|
308
|
+
const { base, sources, images, outputRaw, modelResult } = args;
|
|
309
|
+
if (sources)
|
|
310
|
+
yield* sourceEvents(base, sources, outputRaw);
|
|
311
|
+
if (!images || outputRaw === undefined)
|
|
312
|
+
return modelResult;
|
|
313
|
+
return yield* withFetchedImages(base, modelResult, images, outputRaw);
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* The result with the pictures its tool names, fetched after the guard and the stages passed it.
|
|
317
|
+
* The addresses fetched are written into the result's text, so the egress rule on pictures the
|
|
318
|
+
* model shows counts them as given by a tool.
|
|
319
|
+
*/
|
|
320
|
+
async function* withFetchedImages(base, result, images, outputRaw) {
|
|
321
|
+
const fetched = await images(outputRaw);
|
|
322
|
+
if (fetched.warning)
|
|
323
|
+
yield toolEvent(base, { phase: 'warning', warning: fetched.warning });
|
|
324
|
+
if (fetched.parts.length === 0)
|
|
325
|
+
return result;
|
|
326
|
+
const note = `\n[Pictures shown with this result: ${fetched.urls.join(' ')}]`; // labels-exempt: a marker the egress rule reads the addresses from, not prose (P2)
|
|
327
|
+
return {
|
|
328
|
+
...result,
|
|
329
|
+
parts: [...(result.parts ?? []), ...fetched.parts],
|
|
330
|
+
...(result.modelText !== undefined
|
|
331
|
+
? { modelText: result.modelText + note }
|
|
332
|
+
: { finding: result.finding + note }),
|
|
333
|
+
};
|
|
334
|
+
}
|
|
298
335
|
/** What the after-call stages add to a settlement: a turn to stop, input to hand the model. */
|
|
299
336
|
function stageSettlement(post) {
|
|
300
337
|
return {
|
|
@@ -474,6 +511,7 @@ agent, beginBody) {
|
|
|
474
511
|
throwIfAborted(ctx.signal);
|
|
475
512
|
const settleOutcome = (outcome) => settleBodyOutcome({
|
|
476
513
|
outcome,
|
|
514
|
+
ctx,
|
|
477
515
|
tool,
|
|
478
516
|
base,
|
|
479
517
|
callId,
|
|
@@ -667,7 +705,7 @@ function authorizeSavedDisclosure(args, tool, outcome) {
|
|
|
667
705
|
}
|
|
668
706
|
/** Settles an outcome the kernel reached without a handler's own result: a remote body, or a sign-in. */
|
|
669
707
|
async function* settleBodyOutcome(args) {
|
|
670
|
-
const { outcome, tool, base, callId, safeInput, stages, guard, labels } = args;
|
|
708
|
+
const { outcome, tool, base, callId, safeInput, stages, guard, labels, ctx } = args;
|
|
671
709
|
const name = tool.name;
|
|
672
710
|
if (outcome.kind === 'gated') {
|
|
673
711
|
if (outcome.gate.kind === 'confirmation') {
|
|
@@ -703,6 +741,9 @@ async function* settleBodyOutcome(args) {
|
|
|
703
741
|
guard,
|
|
704
742
|
provisional: { outputRaw: outcome.outputRaw, modelResult: outcome.modelResult },
|
|
705
743
|
sources: tool.sources,
|
|
744
|
+
...(tool.type !== 'function' && tool.images
|
|
745
|
+
? { images: (output) => fetchToolImages(tool.images, output, ctx) }
|
|
746
|
+
: {}),
|
|
706
747
|
labels: tool.labels,
|
|
707
748
|
reproject: makeReproject((v) => parseToolOutput(tool.output, v),
|
|
708
749
|
// why: A hook's edit replaces the value; the media the tool returned stays.
|
|
@@ -1097,11 +1138,12 @@ async function* settleByType(tools, tool, safeInput, fullCtx, base, snapshot, st
|
|
|
1097
1138
|
});
|
|
1098
1139
|
}
|
|
1099
1140
|
const guard = resultGuard(tools, tool, fullCtx, snapshot, stages?.span);
|
|
1100
|
-
const
|
|
1141
|
+
const body = tool.type === 'http'
|
|
1101
1142
|
? yield* executeHttpTool(tool, safeInput, fullCtx, base, stages, beginBody, new HandlerEvents(base, guard.handlerEvents(stages?.drafts === true)))
|
|
1102
1143
|
: yield* executeMcpTool(tool, safeInput, fullCtx, base, stages, tools.mcpSessions, beginBody);
|
|
1103
1144
|
return yield* settleBodyOutcome({
|
|
1104
|
-
outcome:
|
|
1145
|
+
outcome: body,
|
|
1146
|
+
ctx: fullCtx,
|
|
1105
1147
|
tool,
|
|
1106
1148
|
base,
|
|
1107
1149
|
callId: base.callId,
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { InteractionPart, ToolWarning } from '../turn-events.js';
|
|
2
|
+
import type { ToolContext } from './types.js';
|
|
3
|
+
/** The most pictures one call hands the model. */
|
|
4
|
+
export declare const MAX_TOOL_IMAGES = 4;
|
|
5
|
+
/**
|
|
6
|
+
* The pictures a tool's `images(output)` names, fetched through the profile's network policy (every
|
|
7
|
+
* hop, redirects included, no tool credentials) and handed to the model as image parts. A picture
|
|
8
|
+
* that is not an https address, blocked, slow, too big or not an image is left out and reported in
|
|
9
|
+
* one `images_unfetched` warning: the call itself stays whole. `urls` are the ones fetched.
|
|
10
|
+
*/
|
|
11
|
+
export declare function fetchToolImages(images: (output: never) => string[], output: unknown, ctx: ToolContext): Promise<{
|
|
12
|
+
parts: InteractionPart[];
|
|
13
|
+
urls: string[];
|
|
14
|
+
warning?: ToolWarning;
|
|
15
|
+
}>;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { fetchGuarded } from '../../guardrails/network.js';
|
|
2
|
+
import { bytesToBase64 } from '../util/base64.js';
|
|
3
|
+
import { toolNetworkPolicy } from './events.js';
|
|
4
|
+
/** The most pictures one call hands the model. */
|
|
5
|
+
export const MAX_TOOL_IMAGES = 4;
|
|
6
|
+
/** The largest picture fetched; a larger one is left out, not cut. */
|
|
7
|
+
const MAX_TOOL_IMAGE_BYTES = 5 * 1024 * 1024;
|
|
8
|
+
/** The most one call hands the model in all, so pictures cannot crowd a request past a provider's limit. */
|
|
9
|
+
const MAX_TOOL_IMAGES_TOTAL_BYTES = 10 * 1024 * 1024;
|
|
10
|
+
/** How long each picture may take, so a slow host cannot hold the call. */
|
|
11
|
+
const IMAGE_FETCH_TIMEOUT_MS = 15_000;
|
|
12
|
+
const IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']);
|
|
13
|
+
/** The body, read up to `maxBytes`; `undefined` when it runs past. */
|
|
14
|
+
async function readCappedBytes(response, maxBytes) {
|
|
15
|
+
const declared = Number(response.headers.get('content-length'));
|
|
16
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
17
|
+
await response.body?.cancel().catch(() => { });
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
if (!response.body)
|
|
21
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
22
|
+
const reader = response.body.getReader();
|
|
23
|
+
const chunks = [];
|
|
24
|
+
let total = 0;
|
|
25
|
+
for (;;) {
|
|
26
|
+
const { done, value } = await reader.read();
|
|
27
|
+
if (done)
|
|
28
|
+
break;
|
|
29
|
+
total += value.byteLength;
|
|
30
|
+
if (total > maxBytes) {
|
|
31
|
+
await reader.cancel().catch(() => { });
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
chunks.push(value);
|
|
35
|
+
}
|
|
36
|
+
const bytes = new Uint8Array(total);
|
|
37
|
+
let at = 0;
|
|
38
|
+
for (const chunk of chunks) {
|
|
39
|
+
bytes.set(chunk, at);
|
|
40
|
+
at += chunk.byteLength;
|
|
41
|
+
}
|
|
42
|
+
return bytes;
|
|
43
|
+
}
|
|
44
|
+
async function fetchOne(url, ctx) {
|
|
45
|
+
const timeout = AbortSignal.timeout(IMAGE_FETCH_TIMEOUT_MS);
|
|
46
|
+
const signal = ctx.signal ? AbortSignal.any([ctx.signal, timeout]) : timeout;
|
|
47
|
+
const response = await fetchGuarded(url, { method: 'GET', headers: { Accept: 'image/*' }, signal }, {
|
|
48
|
+
policy: toolNetworkPolicy(ctx),
|
|
49
|
+
followRedirects: true,
|
|
50
|
+
resolveHost: ctx.resolveHost,
|
|
51
|
+
});
|
|
52
|
+
if (!response.ok)
|
|
53
|
+
throw new Error(`HTTP ${response.status}`); // labels-exempt: developer contract / internal diagnostic — not end-user or model copy (P2)
|
|
54
|
+
const mimeType = (response.headers.get('content-type') ?? '').split(';')[0]?.trim().toLowerCase();
|
|
55
|
+
if (!mimeType || !IMAGE_TYPES.has(mimeType)) {
|
|
56
|
+
await response.body?.cancel().catch(() => { });
|
|
57
|
+
throw new Error(`not a png, jpeg, gif or webp image (${mimeType || 'no type'})`); // labels-exempt: developer contract / internal diagnostic — not end-user or model copy (P2)
|
|
58
|
+
}
|
|
59
|
+
const bytes = await readCappedBytes(response, MAX_TOOL_IMAGE_BYTES);
|
|
60
|
+
if (!bytes)
|
|
61
|
+
throw new Error(`larger than ${MAX_TOOL_IMAGE_BYTES} bytes`); // labels-exempt: developer contract / internal diagnostic — not end-user or model copy (P2)
|
|
62
|
+
return { type: 'image', mimeType, data: bytesToBase64(bytes) };
|
|
63
|
+
}
|
|
64
|
+
/** `url` when it is an absolute https address; otherwise the reason it is not. */
|
|
65
|
+
function httpsAddress(url) {
|
|
66
|
+
if (typeof url !== 'string')
|
|
67
|
+
return { reason: 'an address that is not text' }; // labels-exempt: developer contract / internal diagnostic — not end-user or model copy (P2)
|
|
68
|
+
try {
|
|
69
|
+
if (new URL(url).protocol === 'https:')
|
|
70
|
+
return { url };
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
// why: an unparseable address is reported below as a reason, not thrown.
|
|
74
|
+
}
|
|
75
|
+
return { reason: `${url.slice(0, 80)}: not an absolute https address` }; // labels-exempt: developer contract / internal diagnostic — not end-user or model copy (P2)
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* The pictures a tool's `images(output)` names, fetched through the profile's network policy (every
|
|
79
|
+
* hop, redirects included, no tool credentials) and handed to the model as image parts. A picture
|
|
80
|
+
* that is not an https address, blocked, slow, too big or not an image is left out and reported in
|
|
81
|
+
* one `images_unfetched` warning: the call itself stays whole. `urls` are the ones fetched.
|
|
82
|
+
*/
|
|
83
|
+
export async function fetchToolImages(images, output, ctx) {
|
|
84
|
+
const reasons = [];
|
|
85
|
+
const named = [];
|
|
86
|
+
try {
|
|
87
|
+
for (const raw of new Set(images(output))) {
|
|
88
|
+
const address = httpsAddress(raw);
|
|
89
|
+
if ('url' in address)
|
|
90
|
+
named.push(address.url);
|
|
91
|
+
else
|
|
92
|
+
reasons.push(address.reason);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
// labels-exempt: developer contract / internal diagnostic — not end-user or model copy (P2)
|
|
97
|
+
return {
|
|
98
|
+
parts: [],
|
|
99
|
+
urls: [],
|
|
100
|
+
warning: unfetched([`the images hook threw: ${String(err)}`]), // labels-exempt: developer contract / internal diagnostic — not end-user or model copy (P2)
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
const wanted = named.slice(0, MAX_TOOL_IMAGES);
|
|
104
|
+
const settled = await Promise.allSettled(wanted.map((url) => fetchOne(url, ctx)));
|
|
105
|
+
const parts = [];
|
|
106
|
+
const urls = [];
|
|
107
|
+
let total = 0;
|
|
108
|
+
settled.forEach((result, index) => {
|
|
109
|
+
const url = wanted[index];
|
|
110
|
+
if (result.status === 'rejected') {
|
|
111
|
+
const why = result.reason instanceof Error ? result.reason.message : String(result.reason);
|
|
112
|
+
reasons.push(`${new URL(url).hostname}: ${why}`);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const size = 'data' in result.value ? result.value.data.length : 0;
|
|
116
|
+
if (total + size > MAX_TOOL_IMAGES_TOTAL_BYTES) {
|
|
117
|
+
reasons.push(`${new URL(url).hostname}: past the ${MAX_TOOL_IMAGES_TOTAL_BYTES} byte total`); // labels-exempt: developer contract / internal diagnostic — not end-user or model copy (P2)
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
total += size;
|
|
121
|
+
parts.push(result.value);
|
|
122
|
+
urls.push(url);
|
|
123
|
+
});
|
|
124
|
+
return {
|
|
125
|
+
parts,
|
|
126
|
+
urls,
|
|
127
|
+
...(reasons.length ? { warning: unfetched(reasons) } : {}),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
function unfetched(reasons) {
|
|
131
|
+
return {
|
|
132
|
+
code: 'images_unfetched',
|
|
133
|
+
severity: 'warning',
|
|
134
|
+
message: `Some pictures the tool named were left out. ${reasons.join('; ')}`, // labels-exempt: developer contract / internal diagnostic — not end-user or model copy (P2)
|
|
135
|
+
};
|
|
136
|
+
}
|
|
@@ -160,6 +160,16 @@ export interface ToolOutputHooks<TOut = unknown> {
|
|
|
160
160
|
/** A source that fails `sourceSchema`, or a throw, is a `sources_invalid` warning and is not cited. */
|
|
161
161
|
sources?: (output: TOut) => Source[];
|
|
162
162
|
}
|
|
163
|
+
/** For a tool whose result names pictures by address; function tools return the bytes themselves as `parts`. */
|
|
164
|
+
export interface ToolImageHooks<TOut = unknown> {
|
|
165
|
+
/**
|
|
166
|
+
* The https addresses of pictures in `output` the model should see, at most `MAX_TOOL_IMAGES`
|
|
167
|
+
* of them. After the result passes the output guard, the kernel fetches each one through the
|
|
168
|
+
* profile's network policy, without the tool's credentials, and hands the model the image as a
|
|
169
|
+
* part; one it cannot fetch is an `images_unfetched` warning, never a failed call.
|
|
170
|
+
*/
|
|
171
|
+
images?: (output: TOut) => string[];
|
|
172
|
+
}
|
|
163
173
|
export interface FunctionToolDef<TIn = unknown, TOut = unknown> extends ToolBase, ToolHostHooks<TIn>, ToolOutputHooks<TOut> {
|
|
164
174
|
type: 'function';
|
|
165
175
|
input: z.ZodType<TIn>;
|
|
@@ -201,7 +211,7 @@ export interface ToolAuthConfig {
|
|
|
201
211
|
clientId?: string;
|
|
202
212
|
redirectUri?: string;
|
|
203
213
|
}
|
|
204
|
-
export interface HttpToolDef<TIn = unknown, TOut = unknown> extends ToolBase, ToolHostHooks<TIn>, ToolOutputHooks<TOut> {
|
|
214
|
+
export interface HttpToolDef<TIn = unknown, TOut = unknown> extends ToolBase, ToolHostHooks<TIn>, ToolOutputHooks<TOut>, ToolImageHooks<TOut> {
|
|
205
215
|
type: 'http';
|
|
206
216
|
input: z.ZodType<TIn>;
|
|
207
217
|
output: z.ZodType<TOut>;
|
|
@@ -228,7 +238,7 @@ export interface HttpToolLimit {
|
|
|
228
238
|
requests: number;
|
|
229
239
|
windowMs: number;
|
|
230
240
|
}
|
|
231
|
-
export interface McpToolDef<TIn = unknown, TOut = unknown> extends ToolBase, ToolHostHooks<TIn>, ToolOutputHooks<TOut> {
|
|
241
|
+
export interface McpToolDef<TIn = unknown, TOut = unknown> extends ToolBase, ToolHostHooks<TIn>, ToolOutputHooks<TOut>, ToolImageHooks<TOut> {
|
|
232
242
|
type: 'mcp';
|
|
233
243
|
input: z.ZodType<TIn>;
|
|
234
244
|
output: z.ZodType<TOut>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@theoremjs/agents",
|
|
3
|
-
"version": "0.3.4-next.
|
|
3
|
+
"version": "0.3.4-next.401.gec4c582",
|
|
4
4
|
"description": "A flat TypeScript agent kernel for typed profiles, deterministic turn execution, registered tools, provider adapters, guardrails, and host-injected traces.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agent",
|