@inflexa-ai/harness 0.45.0 → 0.45.1
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/dist/agents/conversation-agent.d.ts +10 -0
- package/dist/agents/conversation-agent.js +19 -1
- package/dist/agents/sandbox/shared.d.ts +23 -2
- package/dist/agents/sandbox/shared.js +51 -18
- package/dist/agents/sandbox/translational-safety-agent.js +26 -0
- package/dist/agents/sandbox/types.d.ts +24 -1
- package/dist/agents/sandbox-catalog.d.ts +14 -0
- package/dist/agents/sandbox-catalog.js +19 -0
- package/dist/app/approve-plan.d.ts +5 -2
- package/dist/app/approve-plan.js +1 -1
- package/dist/app/chat-turn.js +6 -3
- package/dist/config/environment-stores.d.ts +4 -2
- package/dist/contracts/chat-events.d.ts +12 -2
- package/dist/contracts/chat-events.js +1 -1
- package/dist/contracts/chat-frame.js +6 -2
- package/dist/contracts/index.d.ts +1 -1
- package/dist/contracts/schemas/chat-events.d.ts +25 -0
- package/dist/contracts/schemas/chat-events.js +7 -0
- package/dist/contracts/schemas/chat-parts.d.ts +39 -0
- package/dist/contracts/schemas/chat-parts.js +17 -0
- package/dist/contracts/schemas/index.d.ts +1 -1
- package/dist/contracts/schemas/index.js +1 -1
- package/dist/execution/dbos-workflow-purger.d.ts +2 -0
- package/dist/execution/dbos-workflow-purger.js +21 -14
- package/dist/execution/run-canceler.d.ts +33 -11
- package/dist/execution/run-canceler.js +129 -27
- package/dist/execution/run-end-record.d.ts +30 -0
- package/dist/execution/run-end-record.js +27 -0
- package/dist/index.d.ts +9 -5
- package/dist/index.js +11 -1
- package/dist/loop/call-guard.d.ts +50 -0
- package/dist/loop/call-guard.js +90 -0
- package/dist/loop/compaction.d.ts +5 -0
- package/dist/loop/compaction.js +17 -0
- package/dist/loop/run-agent.d.ts +9 -0
- package/dist/loop/run-agent.js +15 -1
- package/dist/loop/run-to-terminal.js +4 -1
- package/dist/loop/types.d.ts +6 -0
- package/dist/memory/conversation-display-recorder.d.ts +0 -2
- package/dist/memory/conversation-display-recorder.js +28 -3
- package/dist/memory/thread-history.d.ts +34 -7
- package/dist/memory/thread-history.js +151 -42
- package/dist/prompts/briefing.d.ts +57 -3
- package/dist/prompts/briefing.js +129 -2
- package/dist/prompts/planner.js +13 -2
- package/dist/provenance/seam.d.ts +23 -15
- package/dist/provenance/seam.js +7 -5
- package/dist/providers/ai-sdk.js +87 -7
- package/dist/providers/types.d.ts +6 -0
- package/dist/reference-data/catalog.js +141 -2
- package/dist/report-model/authoring-grammar.d.ts +26 -26
- package/dist/sandbox/image-packages.d.ts +26 -4
- package/dist/sandbox/image-packages.js +18 -4
- package/dist/schemas/data-profile-schemas.d.ts +12 -12
- package/dist/schemas/plan-schemas.d.ts +57 -3
- package/dist/schemas/plan-schemas.js +1 -1
- package/dist/schemas/workflow-state.d.ts +113 -0
- package/dist/schemas/workflow-state.js +54 -0
- package/dist/state/artifacts.d.ts +17 -1
- package/dist/state/artifacts.js +21 -3
- package/dist/state/index.d.ts +2 -2
- package/dist/state/index.js +1 -1
- package/dist/state/runs.d.ts +43 -9
- package/dist/state/runs.js +85 -14
- package/dist/state/step-executions.d.ts +9 -0
- package/dist/state/step-executions.js +22 -0
- package/dist/tools/ad-hoc-router.d.ts +2 -0
- package/dist/tools/ad-hoc-router.js +18 -2
- package/dist/tools/capability-data.d.ts +36 -0
- package/dist/tools/capability-data.js +73 -0
- package/dist/tools/execute-analysis.d.ts +37 -9
- package/dist/tools/execute-analysis.js +131 -21
- package/dist/tools/knowledge/check.d.ts +60 -0
- package/dist/tools/knowledge/check.js +92 -0
- package/dist/tools/knowledge/client.d.ts +382 -0
- package/dist/tools/knowledge/client.js +302 -0
- package/dist/tools/knowledge/environment.d.ts +78 -0
- package/dist/tools/knowledge/environment.js +105 -0
- package/dist/tools/knowledge/index.d.ts +49 -0
- package/dist/tools/knowledge/index.js +70 -0
- package/dist/tools/knowledge/local-slots.d.ts +62 -0
- package/dist/tools/knowledge/local-slots.js +177 -0
- package/dist/tools/knowledge/pin.d.ts +14 -0
- package/dist/tools/knowledge/pin.js +17 -0
- package/dist/tools/knowledge/recommend.d.ts +92 -0
- package/dist/tools/knowledge/recommend.js +98 -0
- package/dist/tools/knowledge/situation.d.ts +143 -0
- package/dist/tools/knowledge/situation.js +164 -0
- package/dist/tools/knowledge/skeleton.d.ts +69 -0
- package/dist/tools/knowledge/skeleton.js +202 -0
- package/dist/tools/knowledge/template.d.ts +137 -0
- package/dist/tools/knowledge/template.js +267 -0
- package/dist/tools/research/generate-plan.d.ts +23 -0
- package/dist/tools/research/generate-plan.js +203 -12
- package/dist/tools/research/guarded-address.d.ts +20 -0
- package/dist/tools/research/guarded-address.js +148 -0
- package/dist/tools/research/searxng-engine.d.ts +40 -0
- package/dist/tools/research/searxng-engine.js +120 -0
- package/dist/tools/research/web-fetch.d.ts +124 -0
- package/dist/tools/research/web-fetch.js +627 -0
- package/dist/tools/research/web-search.d.ts +75 -0
- package/dist/tools/research/web-search.js +112 -0
- package/dist/tools/sandbox/list-available-packages.d.ts +7 -4
- package/dist/tools/sandbox/list-available-packages.js +12 -4
- package/dist/tools/sandbox/list-available-refs.d.ts +21 -0
- package/dist/tools/sandbox/list-available-refs.js +60 -3
- package/dist/tools/sandbox/report-blocker.js +5 -1
- package/dist/tools/terminal-text.d.ts +11 -0
- package/dist/tools/terminal-text.js +19 -0
- package/dist/tools/workspace/decision-record.d.ts +81 -0
- package/dist/tools/workspace/decision-record.js +117 -0
- package/dist/tools/workspace/edit-file.d.ts +3 -1
- package/dist/tools/workspace/edit-file.js +20 -2
- package/dist/tools/workspace/execute-command.d.ts +17 -0
- package/dist/tools/workspace/execute-command.js +28 -1
- package/dist/tools/workspace/index.d.ts +1 -0
- package/dist/tools/workspace/index.js +1 -0
- package/dist/tools/workspace/mutator.d.ts +1 -1
- package/dist/workflows/execute-analysis.d.ts +58 -18
- package/dist/workflows/execute-analysis.js +273 -58
- package/dist/workflows/sandbox-step.d.ts +12 -0
- package/package.json +5 -1
|
@@ -0,0 +1,627 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `web_fetch` — the page-reading step of the conversation agent's web research.
|
|
3
|
+
*
|
|
4
|
+
* One URL in, extracted text out: HTML becomes Markdown (Readability over a
|
|
5
|
+
* `linkedom` DOM, then Turndown, with the body text as the fallback), a PDF
|
|
6
|
+
* becomes the text of its first 100 pages through `pdf-parse`, and a text or
|
|
7
|
+
* JSON body passes through. A bad scheme, a denied approval, an HTTP error, an
|
|
8
|
+
* oversized body, a redirect chain past the limit, and a body the tool cannot
|
|
9
|
+
* extract are all normal results on the ok channel — never a failure of the
|
|
10
|
+
* turn.
|
|
11
|
+
*
|
|
12
|
+
* The address guard reads the address the connection itself uses, because a
|
|
13
|
+
* separate name resolution before a `fetch` races the resolver (DNS rebinding):
|
|
14
|
+
* a literal host classifies before the request is sent — the connection layer
|
|
15
|
+
* runs no name lookup for a literal — and a name classifies inside the `lookup`
|
|
16
|
+
* hook of `node:http`/`node:https`, over every address of the resolution,
|
|
17
|
+
* because the connect layer falls back to a later address after an earlier one
|
|
18
|
+
* fails. A guarded hop is destroyed before the user is asked, and an approval
|
|
19
|
+
* re-sends the hop.
|
|
20
|
+
*/
|
|
21
|
+
import { lookup as dnsLookup } from "node:dns";
|
|
22
|
+
import { request as httpRequest } from "node:http";
|
|
23
|
+
import { request as httpsRequest } from "node:https";
|
|
24
|
+
import { isIP } from "node:net";
|
|
25
|
+
import { BrotliDecompress, Gunzip, Inflate } from "node:zlib";
|
|
26
|
+
import { ok } from "neverthrow";
|
|
27
|
+
import { Readability } from "@mozilla/readability";
|
|
28
|
+
import { parseHTML } from "linkedom";
|
|
29
|
+
import TurndownService from "turndown";
|
|
30
|
+
import { z } from "zod";
|
|
31
|
+
import { keptTextOf, pairSafeIndex, TOOL_RESULT_CAP, toolOutputRef } from "../../loop/tool-output.js";
|
|
32
|
+
import { AskRejectedError } from "../approval/contract.js";
|
|
33
|
+
import { defineTool } from "../define-tool.js";
|
|
34
|
+
import { HARNESS_USER_AGENT } from "../lib/api-utils.js";
|
|
35
|
+
import { isGuardedAddress } from "./guarded-address.js";
|
|
36
|
+
const MAX_REDIRECTS = 5;
|
|
37
|
+
const HOP_TIMEOUT_MS = 120_000;
|
|
38
|
+
const HTML_TEXT_LIMIT_BYTES = 5 * 1024 * 1024;
|
|
39
|
+
const PDF_LIMIT_BYTES = 20 * 1024 * 1024;
|
|
40
|
+
const PDF_PAGE_CAP = 100;
|
|
41
|
+
const MAX_TEXT_CHARS = 30_000;
|
|
42
|
+
// The loop JSON-escapes the text of a result, and an escape can make a text two to six times longer.
|
|
43
|
+
const RESULT_BUDGET = TOOL_RESULT_CAP - 512;
|
|
44
|
+
const defaultHopRequest = (url, options) => {
|
|
45
|
+
const send = url.protocol === "https:" ? httpsRequest : httpRequest;
|
|
46
|
+
// A pooled socket skips the lookup hook, and the hook is the only guard for a name host, thus every hop opens its own connection.
|
|
47
|
+
return send(url, { agent: false, headers: options.headers, lookup: options.lookup, timeout: options.timeoutMs });
|
|
48
|
+
};
|
|
49
|
+
const markdownConverter = new TurndownService();
|
|
50
|
+
/** The redirect statuses the tool follows itself; node follows none. */
|
|
51
|
+
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
52
|
+
class GuardedHopSignal extends Error {
|
|
53
|
+
constructor() {
|
|
54
|
+
super("the resolution holds a guarded address");
|
|
55
|
+
this.name = "GuardedHopSignal";
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function abortError() {
|
|
59
|
+
const error = new Error("The turn was aborted before web_fetch finished.");
|
|
60
|
+
error.name = "AbortError";
|
|
61
|
+
return error;
|
|
62
|
+
}
|
|
63
|
+
function hopTimeoutError(url) {
|
|
64
|
+
return new Error(`web_fetch timed out after ${HOP_TIMEOUT_MS}ms on ${url.toString()}.`);
|
|
65
|
+
}
|
|
66
|
+
function isAbort(error, ctx) {
|
|
67
|
+
return error.name === "AbortError" || ctx.signal.aborted;
|
|
68
|
+
}
|
|
69
|
+
/** The origin of an `always` approval: scheme, host, and the port the connection uses, with the default filled in. */
|
|
70
|
+
function originOf(url) {
|
|
71
|
+
const port = url.port !== "" ? url.port : url.protocol === "https:" ? "443" : "80";
|
|
72
|
+
return `${url.protocol}//${url.hostname}:${port}`;
|
|
73
|
+
}
|
|
74
|
+
function firstHeaderOf(headers, name) {
|
|
75
|
+
const value = headers[name];
|
|
76
|
+
if (value === undefined)
|
|
77
|
+
return undefined;
|
|
78
|
+
return Array.isArray(value) ? value[0] : value;
|
|
79
|
+
}
|
|
80
|
+
function mimeTypeOf(headers) {
|
|
81
|
+
return (firstHeaderOf(headers, "content-type") ?? "").split(";")[0]?.trim().toLowerCase() ?? "";
|
|
82
|
+
}
|
|
83
|
+
function bodyLimitOf(contentType) {
|
|
84
|
+
return contentType === "application/pdf" ? PDF_LIMIT_BYTES : HTML_TEXT_LIMIT_BYTES;
|
|
85
|
+
}
|
|
86
|
+
function hopHeaders() {
|
|
87
|
+
return {
|
|
88
|
+
"user-agent": HARNESS_USER_AGENT,
|
|
89
|
+
Accept: "text/html,application/xhtml+xml,application/pdf,text/*,application/json;q=0.9,*/*;q=0.5",
|
|
90
|
+
"Accept-Encoding": "identity",
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
function stripAddressBrackets(hostname) {
|
|
94
|
+
return hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
|
|
95
|
+
}
|
|
96
|
+
/** Node derives a Basic Authorization header from the user:pass of a URL; a web_fetch hop carries no credentials. */
|
|
97
|
+
function withoutCredentials(url) {
|
|
98
|
+
const clean = new URL(url.toString());
|
|
99
|
+
clean.username = "";
|
|
100
|
+
clean.password = "";
|
|
101
|
+
return clean;
|
|
102
|
+
}
|
|
103
|
+
async function askForOrigin(url, origin, ctx) {
|
|
104
|
+
const request = {
|
|
105
|
+
title: "web_fetch: read a page from a guarded address",
|
|
106
|
+
command: url.toString(),
|
|
107
|
+
grantKey: `web_fetch:${origin}`,
|
|
108
|
+
detail: `The address of ${url.hostname} is in a guarded range (loopback, private, link-local, or reserved). ` +
|
|
109
|
+
`An "always" answer approves every later web_fetch of the origin ${origin}; a "once" answer approves this call only.`,
|
|
110
|
+
};
|
|
111
|
+
try {
|
|
112
|
+
await ctx.ask(request);
|
|
113
|
+
return { kind: "approved" };
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
if (error instanceof AskRejectedError) {
|
|
117
|
+
return {
|
|
118
|
+
kind: "denied",
|
|
119
|
+
output: {
|
|
120
|
+
status: "refused",
|
|
121
|
+
text: `web_fetch did not read ${url.toString()}: the guarded address ${url.hostname} was not approved.`,
|
|
122
|
+
...(error.feedback !== undefined ? { feedback: error.feedback } : {}),
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
throw error;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Send one hop. The `lookup` hook classifies every address of each resolution
|
|
131
|
+
* before the connect can use any of it: when a guarded address appears and the
|
|
132
|
+
* origin is not approved, the hook answers nothing and destroys the request, so
|
|
133
|
+
* no socket opens while the user reads the prompt.
|
|
134
|
+
*/
|
|
135
|
+
function sendHop(url, state) {
|
|
136
|
+
return new Promise((resolve, reject) => {
|
|
137
|
+
const { ctx } = state;
|
|
138
|
+
let settled = false;
|
|
139
|
+
let liveResponse;
|
|
140
|
+
const finish = () => {
|
|
141
|
+
settled = true;
|
|
142
|
+
ctx.signal.removeEventListener("abort", onAbort);
|
|
143
|
+
};
|
|
144
|
+
const settle = (outcome) => {
|
|
145
|
+
if (settled)
|
|
146
|
+
return;
|
|
147
|
+
finish();
|
|
148
|
+
if (outcome.kind !== "response")
|
|
149
|
+
clearDeadline();
|
|
150
|
+
resolve(outcome);
|
|
151
|
+
};
|
|
152
|
+
const fail = (error) => {
|
|
153
|
+
if (settled)
|
|
154
|
+
return;
|
|
155
|
+
finish();
|
|
156
|
+
clearDeadline();
|
|
157
|
+
reject(error);
|
|
158
|
+
};
|
|
159
|
+
const onAbort = () => {
|
|
160
|
+
request.destroy(abortError());
|
|
161
|
+
};
|
|
162
|
+
ctx.signal.addEventListener("abort", onAbort, { once: true });
|
|
163
|
+
const request = state.hopRequest(url, {
|
|
164
|
+
headers: hopHeaders(),
|
|
165
|
+
timeoutMs: HOP_TIMEOUT_MS,
|
|
166
|
+
lookup: (hostname, options, callback) => {
|
|
167
|
+
state.lookup(hostname, { ...options, all: true }, (error, addresses) => {
|
|
168
|
+
if (error) {
|
|
169
|
+
callback(error, "");
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
const guarded = addresses.some((entry) => isGuardedAddress(entry.address));
|
|
173
|
+
if (guarded && !state.approvedOrigins.has(originOf(url))) {
|
|
174
|
+
// With no callback the connect never starts, and the destroy
|
|
175
|
+
// carries the signal out through the error event.
|
|
176
|
+
request.destroy(new GuardedHopSignal());
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (options.all) {
|
|
180
|
+
callback(null, addresses);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
// The connect asked for one address (address-family selection off), and node rejects an array there with ERR_INVALID_IP_ADDRESS.
|
|
184
|
+
const first = addresses[0];
|
|
185
|
+
if (first === undefined) {
|
|
186
|
+
const notFound = new Error(`getaddrinfo ENOTFOUND ${hostname}`);
|
|
187
|
+
notFound.code = "ENOTFOUND";
|
|
188
|
+
callback(notFound, "");
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
callback(null, first.address, first.family);
|
|
192
|
+
});
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
// The socket-idle timeout below does not bound a server that trickles bytes, so a wall clock bounds the whole hop.
|
|
196
|
+
const deadline = setTimeout(() => {
|
|
197
|
+
const error = hopTimeoutError(url);
|
|
198
|
+
liveResponse?.destroy(error);
|
|
199
|
+
request.destroy(error);
|
|
200
|
+
}, HOP_TIMEOUT_MS);
|
|
201
|
+
deadline.unref();
|
|
202
|
+
const clearDeadline = () => clearTimeout(deadline);
|
|
203
|
+
request.on("timeout", () => {
|
|
204
|
+
request.destroy(hopTimeoutError(url));
|
|
205
|
+
});
|
|
206
|
+
request.on("error", (error) => {
|
|
207
|
+
if (error instanceof GuardedHopSignal) {
|
|
208
|
+
settle({ kind: "guarded" });
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
// An abort of the turn propagates; every other transport failure is a normal result.
|
|
212
|
+
if (isAbort(error, ctx)) {
|
|
213
|
+
fail(error);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
settle({ kind: "failed", output: { status: "request_failed", text: `web_fetch could not read ${url.toString()}.`, detail: error.message } });
|
|
217
|
+
});
|
|
218
|
+
request.on("response", (response) => {
|
|
219
|
+
liveResponse = response;
|
|
220
|
+
settle({ kind: "response", response, dispose: clearDeadline });
|
|
221
|
+
});
|
|
222
|
+
request.end();
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Read the body of one response and count the decoded bytes: the limit stops
|
|
227
|
+
* the read, so a compressed body that decodes past the limit refuses at the
|
|
228
|
+
* decoded boundary and never buffers the bomb.
|
|
229
|
+
*/
|
|
230
|
+
function readBody(response, encoding, limitBytes, ctx) {
|
|
231
|
+
return new Promise((resolve) => {
|
|
232
|
+
let settled = false;
|
|
233
|
+
const chunks = [];
|
|
234
|
+
let total = 0;
|
|
235
|
+
const settle = (result) => {
|
|
236
|
+
if (settled)
|
|
237
|
+
return;
|
|
238
|
+
settled = true;
|
|
239
|
+
ctx.signal.removeEventListener("abort", onAbort);
|
|
240
|
+
resolve(result);
|
|
241
|
+
};
|
|
242
|
+
const onDecodedChunk = (chunk) => {
|
|
243
|
+
total += chunk.length;
|
|
244
|
+
if (total > limitBytes) {
|
|
245
|
+
response.destroy();
|
|
246
|
+
decoder?.destroy();
|
|
247
|
+
settle({ kind: "tooLarge" });
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
chunks.push(chunk);
|
|
251
|
+
};
|
|
252
|
+
const onAbort = () => {
|
|
253
|
+
response.destroy(abortError());
|
|
254
|
+
};
|
|
255
|
+
const decoder = encoding === "gzip" ? new Gunzip() : encoding === "deflate" ? new Inflate() : encoding === "br" ? new BrotliDecompress() : undefined;
|
|
256
|
+
ctx.signal.addEventListener("abort", onAbort, { once: true });
|
|
257
|
+
if (decoder === undefined) {
|
|
258
|
+
response.on("data", onDecodedChunk);
|
|
259
|
+
response.on("end", () => settle({ kind: "ok", body: Buffer.concat(chunks) }));
|
|
260
|
+
response.on("error", (error) => settle({ kind: "failed", error }));
|
|
261
|
+
}
|
|
262
|
+
else {
|
|
263
|
+
const stream = decoder;
|
|
264
|
+
stream.on("data", onDecodedChunk);
|
|
265
|
+
stream.on("end", () => settle({ kind: "ok", body: Buffer.concat(chunks) }));
|
|
266
|
+
stream.on("error", (error) => {
|
|
267
|
+
response.destroy();
|
|
268
|
+
settle({ kind: "failed", error });
|
|
269
|
+
});
|
|
270
|
+
response.on("data", (chunk) => {
|
|
271
|
+
// A write that returns false says the decoder buffer is full: pause the encoded side until it drains.
|
|
272
|
+
if (stream.write(chunk))
|
|
273
|
+
return;
|
|
274
|
+
response.pause?.();
|
|
275
|
+
stream.once("drain", () => response.resume?.());
|
|
276
|
+
});
|
|
277
|
+
response.on("end", () => {
|
|
278
|
+
stream.end();
|
|
279
|
+
});
|
|
280
|
+
response.on("error", (error) => {
|
|
281
|
+
stream.destroy(error);
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
/** The nesting bound of the HTML extraction guard. Blink caps its parser DOM depth at the same number, so a page a browser flattens must not cost minutes here either. */
|
|
287
|
+
const MAX_HTML_NESTING = 512;
|
|
288
|
+
/** Elements that never take content: an opening tag opens no nesting level. */
|
|
289
|
+
const VOID_TAGS = new Set(["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr"]);
|
|
290
|
+
/** Elements whose content is raw text: the scanner skips to their close tag, because markup inside them is not structure. */
|
|
291
|
+
const RAW_TEXT_TAGS = new Set(["script", "style", "textarea", "title", "xmp", "noscript", "iframe", "noembed"]);
|
|
292
|
+
/** An opening tag in a family closes its open peers before it nests — the implied-end rules that keep a list or a table row of any length flat. */
|
|
293
|
+
const IMPLIED_CLOSE_PEERS = {
|
|
294
|
+
li: new Set(["li"]),
|
|
295
|
+
dt: new Set(["dt", "dd"]),
|
|
296
|
+
dd: new Set(["dt", "dd"]),
|
|
297
|
+
p: new Set(["p"]),
|
|
298
|
+
option: new Set(["option"]),
|
|
299
|
+
optgroup: new Set(["option", "optgroup"]),
|
|
300
|
+
td: new Set(["td", "th"]),
|
|
301
|
+
th: new Set(["td", "th"]),
|
|
302
|
+
tr: new Set(["tr", "td", "th"]),
|
|
303
|
+
thead: new Set(["thead", "tbody", "tfoot", "tr", "td", "th"]),
|
|
304
|
+
tbody: new Set(["thead", "tbody", "tfoot", "tr", "td", "th"]),
|
|
305
|
+
tfoot: new Set(["thead", "tbody", "tfoot", "tr", "td", "th"]),
|
|
306
|
+
};
|
|
307
|
+
/**
|
|
308
|
+
* Estimate the deepest element nesting of an HTML document in one linear scan, without building a DOM.
|
|
309
|
+
* Deep nesting is the input shape that makes the linkedom/Readability pair burn CPU for minutes on a
|
|
310
|
+
* body under the byte limit, and the extraction is synchronous, so no deadline can interrupt it — the
|
|
311
|
+
* bound must gate the parse, not watch it. The estimate never parses: void elements and self-closing
|
|
312
|
+
* tags open no level, raw-text elements skip to their close, quoted attribute values hide their `<`,
|
|
313
|
+
* and an implied-close family pops its open peers, so a list or a table of any length stays flat and a
|
|
314
|
+
* stray close tag pops to its nearest matching open.
|
|
315
|
+
*/
|
|
316
|
+
export function htmlNestingDepth(html) {
|
|
317
|
+
let max = 0;
|
|
318
|
+
const stack = [];
|
|
319
|
+
const tagName = (start, end) => html
|
|
320
|
+
.slice(start, end)
|
|
321
|
+
.match(/^[a-zA-Z][a-zA-Z0-9:-]*/)?.[0]
|
|
322
|
+
?.toLowerCase() ?? "";
|
|
323
|
+
let i = 0;
|
|
324
|
+
while (i < html.length) {
|
|
325
|
+
if (html[i] !== "<") {
|
|
326
|
+
i++;
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
if (html.startsWith("<!--", i)) {
|
|
330
|
+
const end = html.indexOf("-->", i + 4);
|
|
331
|
+
i = end === -1 ? html.length : end + 3;
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
if (html.startsWith("<!", i) || html.startsWith("<?", i)) {
|
|
335
|
+
const end = html.indexOf(">", i);
|
|
336
|
+
i = end === -1 ? html.length : end + 1;
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
if (html.startsWith("</", i)) {
|
|
340
|
+
const end = html.indexOf(">", i);
|
|
341
|
+
const name = end === -1 ? "" : tagName(i + 2, end);
|
|
342
|
+
for (let k = stack.length - 1; k >= 0; k--) {
|
|
343
|
+
if (stack[k] === name) {
|
|
344
|
+
stack.length = k;
|
|
345
|
+
break;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
i = end === -1 ? html.length : end + 1;
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
let j = i + 1;
|
|
352
|
+
let quote = "";
|
|
353
|
+
while (j < html.length) {
|
|
354
|
+
const c = html[j];
|
|
355
|
+
if (quote !== "") {
|
|
356
|
+
if (c === quote)
|
|
357
|
+
quote = "";
|
|
358
|
+
}
|
|
359
|
+
else if (c === '"' || c === "'") {
|
|
360
|
+
quote = c;
|
|
361
|
+
}
|
|
362
|
+
else if (c === ">") {
|
|
363
|
+
break;
|
|
364
|
+
}
|
|
365
|
+
j++;
|
|
366
|
+
}
|
|
367
|
+
if (j >= html.length)
|
|
368
|
+
break;
|
|
369
|
+
const name = tagName(i + 1, j);
|
|
370
|
+
const selfClosed = html[j - 1] === "/";
|
|
371
|
+
i = j + 1;
|
|
372
|
+
if (name === "")
|
|
373
|
+
continue;
|
|
374
|
+
if (RAW_TEXT_TAGS.has(name)) {
|
|
375
|
+
const close = new RegExp(`</${name}(?=[\\s/>])`, "gi");
|
|
376
|
+
close.lastIndex = i;
|
|
377
|
+
const found = close.exec(html);
|
|
378
|
+
i = found === null ? html.length : found.index + found[0].length;
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
if (selfClosed || VOID_TAGS.has(name))
|
|
382
|
+
continue;
|
|
383
|
+
const peers = IMPLIED_CLOSE_PEERS[name];
|
|
384
|
+
if (peers !== undefined) {
|
|
385
|
+
while (stack.length > 0 && peers.has(stack[stack.length - 1]))
|
|
386
|
+
stack.pop();
|
|
387
|
+
}
|
|
388
|
+
stack.push(name);
|
|
389
|
+
if (stack.length > max) {
|
|
390
|
+
max = stack.length;
|
|
391
|
+
if (max > MAX_HTML_NESTING)
|
|
392
|
+
return max;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
return max;
|
|
396
|
+
}
|
|
397
|
+
function htmlToMarkdown(body) {
|
|
398
|
+
const dom = parseHTML(body.toString("utf8"));
|
|
399
|
+
const article = new Readability(dom.document).parse();
|
|
400
|
+
if (article?.content)
|
|
401
|
+
return markdownConverter.turndown(article.content).trim();
|
|
402
|
+
return (dom.document.body?.textContent ?? "").trim();
|
|
403
|
+
}
|
|
404
|
+
/** Hostile HTML can throw inside the extraction stack; the body text, then the status union, catch the throw. A page that nests past the bound never reaches a parser, because the parse itself is the stall. */
|
|
405
|
+
function htmlExtractionOf(body) {
|
|
406
|
+
const html = body.toString("utf8");
|
|
407
|
+
if (htmlNestingDepth(html) > MAX_HTML_NESTING) {
|
|
408
|
+
return { kind: "unextractable", detail: `the HTML nests deeper than ${MAX_HTML_NESTING} elements` };
|
|
409
|
+
}
|
|
410
|
+
try {
|
|
411
|
+
return { kind: "text", text: htmlToMarkdown(body) };
|
|
412
|
+
}
|
|
413
|
+
catch {
|
|
414
|
+
try {
|
|
415
|
+
const dom = parseHTML(html);
|
|
416
|
+
return { kind: "text", text: (dom.document.body?.textContent ?? "").trim() };
|
|
417
|
+
}
|
|
418
|
+
catch {
|
|
419
|
+
return { kind: "unextractable", detail: "the HTML extraction failed" };
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
let pdfParseModule;
|
|
424
|
+
async function extractBody(contentType, body) {
|
|
425
|
+
if (contentType === "text/html" || contentType === "application/xhtml+xml") {
|
|
426
|
+
return htmlExtractionOf(body);
|
|
427
|
+
}
|
|
428
|
+
if (contentType === "application/pdf") {
|
|
429
|
+
// `pdf-parse` makes `os.availableParallelism()` return `undefined` process-wide under Bun when its module loads,
|
|
430
|
+
// so the public barrel must never import it statically (inflexa-ai/inflexa#701). The first PDF loads it once.
|
|
431
|
+
pdfParseModule ??= await import("pdf-parse");
|
|
432
|
+
const parser = new pdfParseModule.PDFParse({ data: body });
|
|
433
|
+
try {
|
|
434
|
+
const result = await parser.getText({ first: PDF_PAGE_CAP });
|
|
435
|
+
const pdfCut = result.total > result.pages.length ? { totalPages: result.total, pagesRead: result.pages.length } : undefined;
|
|
436
|
+
return { kind: "text", text: result.text.trim(), pdfCut };
|
|
437
|
+
}
|
|
438
|
+
catch (error) {
|
|
439
|
+
return { kind: "unextractable", detail: `the PDF parser failed: ${error instanceof Error ? error.message : String(error)}` };
|
|
440
|
+
}
|
|
441
|
+
finally {
|
|
442
|
+
await parser.destroy();
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
if (contentType.startsWith("text/") || contentType === "application/json" || contentType.endsWith("+json")) {
|
|
446
|
+
return { kind: "text", text: body.toString("utf8") };
|
|
447
|
+
}
|
|
448
|
+
return { kind: "unextractable" };
|
|
449
|
+
}
|
|
450
|
+
/** Build the ok result for one returned text, so the serialized-cap shrink rebuilds the same fields each round. */
|
|
451
|
+
function okResultOf(base, fullText, kept, shown) {
|
|
452
|
+
return {
|
|
453
|
+
...base,
|
|
454
|
+
...(kept !== undefined ? { kept } : {}),
|
|
455
|
+
text: shown,
|
|
456
|
+
cut: shown.length < fullText.length,
|
|
457
|
+
...(shown.length < fullText.length && kept === undefined ? { remainderNotKept: true } : {}),
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
async function fetchOneUrl(rawUrl, deps, ctx) {
|
|
461
|
+
let parsed;
|
|
462
|
+
try {
|
|
463
|
+
parsed = new URL(rawUrl);
|
|
464
|
+
}
|
|
465
|
+
catch {
|
|
466
|
+
return { status: "invalid_url", text: `web_fetch cannot read ${rawUrl}: it is not a valid URL.` };
|
|
467
|
+
}
|
|
468
|
+
const approvedOrigins = new Set();
|
|
469
|
+
const state = {
|
|
470
|
+
lookup: deps.lookup ?? dnsLookup,
|
|
471
|
+
hopRequest: deps.hopRequest ?? defaultHopRequest,
|
|
472
|
+
approvedOrigins,
|
|
473
|
+
ctx,
|
|
474
|
+
};
|
|
475
|
+
let redirects = 0;
|
|
476
|
+
for (let current = parsed;;) {
|
|
477
|
+
if (current.protocol !== "http:" && current.protocol !== "https:") {
|
|
478
|
+
return { status: "unsupported_scheme", scheme: current.protocol, text: `web_fetch reads http and https only, not ${current.protocol}` };
|
|
479
|
+
}
|
|
480
|
+
if (current.username !== "" || current.password !== "")
|
|
481
|
+
current = withoutCredentials(current);
|
|
482
|
+
const origin = originOf(current);
|
|
483
|
+
const literal = stripAddressBrackets(current.hostname);
|
|
484
|
+
if (isIP(literal) !== 0 && isGuardedAddress(literal) && !approvedOrigins.has(origin)) {
|
|
485
|
+
const asked = await askForOrigin(current, origin, ctx);
|
|
486
|
+
if (asked.kind === "denied")
|
|
487
|
+
return asked.output;
|
|
488
|
+
approvedOrigins.add(origin);
|
|
489
|
+
}
|
|
490
|
+
const hop = await sendHop(current, state);
|
|
491
|
+
if (hop.kind === "failed")
|
|
492
|
+
return hop.output;
|
|
493
|
+
if (hop.kind === "guarded") {
|
|
494
|
+
const asked = await askForOrigin(current, origin, ctx);
|
|
495
|
+
if (asked.kind === "denied")
|
|
496
|
+
return asked.output;
|
|
497
|
+
approvedOrigins.add(origin);
|
|
498
|
+
continue;
|
|
499
|
+
}
|
|
500
|
+
const response = hop.response;
|
|
501
|
+
try {
|
|
502
|
+
const status = response.statusCode ?? 0;
|
|
503
|
+
const location = firstHeaderOf(response.headers, "location");
|
|
504
|
+
if (REDIRECT_STATUSES.has(status) && location !== undefined) {
|
|
505
|
+
response.destroy();
|
|
506
|
+
if (redirects >= MAX_REDIRECTS) {
|
|
507
|
+
return {
|
|
508
|
+
status: "redirect_limit",
|
|
509
|
+
text: `web_fetch follows at most ${MAX_REDIRECTS} redirects and gave up after ${redirects} on ${rawUrl}.`,
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
redirects += 1;
|
|
513
|
+
try {
|
|
514
|
+
current = new URL(location, current);
|
|
515
|
+
}
|
|
516
|
+
catch {
|
|
517
|
+
return { status: "request_failed", text: `A redirect of ${current.toString()} names a target that is not a valid URL: ${location}` };
|
|
518
|
+
}
|
|
519
|
+
continue;
|
|
520
|
+
}
|
|
521
|
+
if (status < 200 || status >= 300) {
|
|
522
|
+
response.destroy();
|
|
523
|
+
return { status: "http_error", httpStatus: status, text: `The page at ${current.toString()} answered HTTP ${status}.` };
|
|
524
|
+
}
|
|
525
|
+
const contentType = mimeTypeOf(response.headers);
|
|
526
|
+
const limitBytes = bodyLimitOf(contentType);
|
|
527
|
+
const encoding = (firstHeaderOf(response.headers, "content-encoding") ?? "").trim().toLowerCase();
|
|
528
|
+
if (encoding !== "" && encoding !== "identity" && encoding !== "gzip" && encoding !== "deflate" && encoding !== "br") {
|
|
529
|
+
response.destroy();
|
|
530
|
+
return {
|
|
531
|
+
status: "request_failed",
|
|
532
|
+
text: `The page at ${current.toString()} sent the content encoding ${encoding}, which web_fetch cannot decode.`,
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
// A Content-Length names the encoded bytes, and an encoding can make them longer than the decoded body, so it gates an identity body only.
|
|
536
|
+
const encoded = encoding === "gzip" || encoding === "deflate" || encoding === "br";
|
|
537
|
+
const contentLength = Number.parseInt(firstHeaderOf(response.headers, "content-length") ?? "", 10);
|
|
538
|
+
if (!encoded && Number.isFinite(contentLength) && contentLength > limitBytes) {
|
|
539
|
+
response.destroy();
|
|
540
|
+
return {
|
|
541
|
+
status: "body_too_large",
|
|
542
|
+
limitBytes,
|
|
543
|
+
contentType,
|
|
544
|
+
text: `The body of ${current.toString()} is ${contentLength} bytes, over the limit of ${limitBytes} bytes for ${contentType === "" ? "this content type" : contentType}.`,
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
const body = await readBody(response, encoding, limitBytes, ctx);
|
|
548
|
+
if (body.kind === "tooLarge") {
|
|
549
|
+
return {
|
|
550
|
+
status: "body_too_large",
|
|
551
|
+
limitBytes,
|
|
552
|
+
contentType,
|
|
553
|
+
text: `The body of ${current.toString()} passed the limit of ${limitBytes} bytes for ${contentType === "" ? "this content type" : contentType} while it was read.`,
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
if (body.kind === "failed") {
|
|
557
|
+
if (isAbort(body.error, ctx))
|
|
558
|
+
throw body.error;
|
|
559
|
+
return { status: "request_failed", text: `web_fetch could not read the body of ${current.toString()}.`, detail: body.error.message };
|
|
560
|
+
}
|
|
561
|
+
const extracted = await extractBody(contentType, body.body);
|
|
562
|
+
if (extracted.kind === "unextractable") {
|
|
563
|
+
const named = contentType === "" ? "(no content type)" : contentType;
|
|
564
|
+
return {
|
|
565
|
+
status: "unextractable",
|
|
566
|
+
contentType,
|
|
567
|
+
text: `web_fetch cannot extract text from the content type ${named}.`,
|
|
568
|
+
...(extracted.detail !== undefined ? { detail: extracted.detail } : {}),
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
const fullText = extracted.text.trim();
|
|
572
|
+
let shown = fullText.length > MAX_TEXT_CHARS ? fullText.slice(0, pairSafeIndex(fullText, MAX_TEXT_CHARS, -1)) : fullText;
|
|
573
|
+
const base = {
|
|
574
|
+
status: "ok",
|
|
575
|
+
finalUrl: current.toString(),
|
|
576
|
+
contentType,
|
|
577
|
+
totalLength: fullText.length,
|
|
578
|
+
...(extracted.pdfCut !== undefined ? { pdfCut: extracted.pdfCut } : {}),
|
|
579
|
+
};
|
|
580
|
+
let output = okResultOf(base, fullText, undefined, shown);
|
|
581
|
+
while (JSON.stringify(output).length > RESULT_BUDGET && shown.length > 0) {
|
|
582
|
+
shown = shown.slice(0, pairSafeIndex(shown, Math.floor((shown.length * 3) / 4), -1));
|
|
583
|
+
output = okResultOf(base, fullText, undefined, shown);
|
|
584
|
+
}
|
|
585
|
+
const analysisId = ctx.session.scope.analysisId;
|
|
586
|
+
// The shrink loop can cut a text that passed the 30,000-character cut, so the store decision reads the final shown length.
|
|
587
|
+
let kept;
|
|
588
|
+
if (shown.length < fullText.length && deps.toolOutputStore !== undefined) {
|
|
589
|
+
const content = keptTextOf(fullText);
|
|
590
|
+
const ref = toolOutputRef(`${analysisId}:${ctx.invocationId}`);
|
|
591
|
+
const put = await deps.toolOutputStore.put({
|
|
592
|
+
analysisId,
|
|
593
|
+
ref,
|
|
594
|
+
toolName: "web_fetch",
|
|
595
|
+
toolCallId: ctx.invocationId,
|
|
596
|
+
...(ctx.session.scope.threadId !== undefined ? { threadId: ctx.session.scope.threadId } : {}),
|
|
597
|
+
content,
|
|
598
|
+
totalLength: fullText.length,
|
|
599
|
+
});
|
|
600
|
+
if (put.isOk())
|
|
601
|
+
kept = { ref, keptLength: content.length };
|
|
602
|
+
}
|
|
603
|
+
return okResultOf({ ...base, ...(kept !== undefined ? { kept } : {}) }, fullText, kept, shown);
|
|
604
|
+
}
|
|
605
|
+
finally {
|
|
606
|
+
hop.dispose();
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
export function createWebFetchTool(deps = {}) {
|
|
611
|
+
return defineTool({
|
|
612
|
+
id: "web_fetch",
|
|
613
|
+
description: "Read one web page from its http or https URL and get its text: an HTML page becomes Markdown, " +
|
|
614
|
+
"a PDF becomes the text of at most its first 100 pages, and a plain-text or JSON body passes through. " +
|
|
615
|
+
"The tool returns at most 30,000 characters; a longer text is cut, and the result carries a reference " +
|
|
616
|
+
"through which `read_tool_output` reads the remainder when the harness kept it.\n" +
|
|
617
|
+
"ABSENCE AND FAILURE ARE NORMAL: the statuses invalid_url (a URL the tool cannot parse), " +
|
|
618
|
+
"unsupported_scheme (a scheme that is not http or https), refused (a guarded address the user did not " +
|
|
619
|
+
"approve), http_error, redirect_limit, body_too_large, unextractable, and request_failed each name what " +
|
|
620
|
+
"happened. Report the status and continue, do not retry unchanged.",
|
|
621
|
+
inputSchema: z.object({
|
|
622
|
+
url: z.string().min(1).max(2048).describe("The http or https URL of one page to read, for example a page a web_search result named."),
|
|
623
|
+
}),
|
|
624
|
+
describeCall: ({ url }) => url,
|
|
625
|
+
execute: async ({ url }, ctx) => ok(await fetchOneUrl(url, deps, ctx)),
|
|
626
|
+
});
|
|
627
|
+
}
|