tinker-agent 1.0.65
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/README.md +173 -0
- package/package.json +78 -0
- package/patches/markdansi@0.3.2.patch +37 -0
- package/src/agent/context-builder.ts +43 -0
- package/src/agent/context-meter.ts +310 -0
- package/src/agent/loop.ts +525 -0
- package/src/agent/runtime-session.ts +1212 -0
- package/src/agent/session-ledger.ts +828 -0
- package/src/agent/turn-cancellation.ts +44 -0
- package/src/agent/types.ts +77 -0
- package/src/cli/config.ts +283 -0
- package/src/cli/index.ts +29 -0
- package/src/cli/model-profiles.ts +289 -0
- package/src/cli/run-runner.ts +107 -0
- package/src/cli/tui-runner.tsx +290 -0
- package/src/context/compiled-context-hash.ts +138 -0
- package/src/context/compiled-context-validator.ts +209 -0
- package/src/context/context-manager.ts +362 -0
- package/src/context/context-policy.ts +8 -0
- package/src/context/context-protocol-validator.ts +463 -0
- package/src/context/context-revision-compiler.ts +281 -0
- package/src/context/context-revision.ts +111 -0
- package/src/context/context-source.ts +30 -0
- package/src/context/context-swap-renderer.ts +272 -0
- package/src/context/protocol-frame.ts +240 -0
- package/src/context/swap-planner.ts +725 -0
- package/src/events/append-private-file.ts +16 -0
- package/src/events/bash-result-detail.ts +70 -0
- package/src/events/composite-event-sink.ts +82 -0
- package/src/events/event-sink.ts +16 -0
- package/src/events/jsonl-event-log.ts +13 -0
- package/src/events/observation-text-log.ts +195 -0
- package/src/events/stdout-event-printer.ts +396 -0
- package/src/events/types.ts +263 -0
- package/src/ids/runtime-id.ts +68 -0
- package/src/ids/uuid-v7.ts +5 -0
- package/src/instructions/project-instructions.ts +242 -0
- package/src/mcp/mcp-config.ts +144 -0
- package/src/mcp/mcp-manager.ts +216 -0
- package/src/mcp/mcp-tool-executor.ts +178 -0
- package/src/model/committed-prefix-auditor.ts +68 -0
- package/src/model/fake-model-client.ts +280 -0
- package/src/model/model-client.ts +64 -0
- package/src/model/model-context-profile.ts +134 -0
- package/src/model/model-request-preflight.ts +120 -0
- package/src/model/openai-chat-mapping.ts +444 -0
- package/src/model/openai-chat-model-client.ts +190 -0
- package/src/model/prompt-prefix-hash.ts +47 -0
- package/src/model/token-estimator.ts +148 -0
- package/src/observation/observation-builder.ts +481 -0
- package/src/session/resume-projection.ts +616 -0
- package/src/session/session-catalog.ts +270 -0
- package/src/session/session-errors.ts +121 -0
- package/src/session/session-history-reader.ts +535 -0
- package/src/session/session-lock.ts +291 -0
- package/src/session/session-schema.ts +741 -0
- package/src/session/session-store.ts +3067 -0
- package/src/session/sqlite-session-ledger.ts +153 -0
- package/src/tools/bash-task.ts +617 -0
- package/src/tools/bash.ts +450 -0
- package/src/tools/cwd-state.ts +22 -0
- package/src/tools/edit.ts +428 -0
- package/src/tools/file-diff.ts +116 -0
- package/src/tools/glob.ts +202 -0
- package/src/tools/grep.ts +550 -0
- package/src/tools/hash.ts +9 -0
- package/src/tools/path-safety.ts +33 -0
- package/src/tools/read.ts +319 -0
- package/src/tools/recall.ts +400 -0
- package/src/tools/registry.ts +213 -0
- package/src/tools/ripgrep.ts +220 -0
- package/src/tools/task-list.ts +59 -0
- package/src/tools/task-output-snapshot.ts +47 -0
- package/src/tools/task-output-tool.ts +62 -0
- package/src/tools/task-output.ts +159 -0
- package/src/tools/task-stop.ts +59 -0
- package/src/tools/task-tool-args.ts +29 -0
- package/src/tools/types.ts +330 -0
- package/src/tools/web-fetch/backend.ts +27 -0
- package/src/tools/web-fetch/browser-backend.ts +126 -0
- package/src/tools/web-fetch/exa-backend.ts +172 -0
- package/src/tools/web-fetch/index.ts +298 -0
- package/src/tools/web-fetch/local-backend.ts +267 -0
- package/src/tools/web-fetch/refiner.ts +78 -0
- package/src/tools/web-fetch/route.ts +95 -0
- package/src/tools/web-search.ts +300 -0
- package/src/tools/write.ts +244 -0
- package/src/tui/app.tsx +497 -0
- package/src/tui/components/assistant-markdown.tsx +47 -0
- package/src/tui/components/background-tasks.tsx +92 -0
- package/src/tui/components/bash-result-view.tsx +47 -0
- package/src/tui/components/context-status.tsx +127 -0
- package/src/tui/components/diff-view.tsx +151 -0
- package/src/tui/components/file-viewer.tsx +212 -0
- package/src/tui/components/footer.tsx +60 -0
- package/src/tui/components/header.tsx +21 -0
- package/src/tui/components/model-picker.tsx +142 -0
- package/src/tui/components/prompt-input.tsx +432 -0
- package/src/tui/components/resume-session-picker.tsx +273 -0
- package/src/tui/components/timeline.tsx +121 -0
- package/src/tui/context-format.ts +24 -0
- package/src/tui/event-store.ts +865 -0
- package/src/tui/git-branch.ts +23 -0
- package/src/tui/line-editor.ts +157 -0
- package/src/tui/prompt-history.ts +94 -0
- package/src/tui/slash-commands.ts +126 -0
- package/src/tui/tui-projection-policy.ts +35 -0
- package/src/tui/tui-projection-store.ts +123 -0
- package/src/tui/tui-session-controller.ts +170 -0
- package/src/tui/view-file.ts +122 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { extractMarkdownFromHtml } from "./local-backend";
|
|
2
|
+
import type { WebFetchBackend, WebFetchBackendResult } from "./backend";
|
|
3
|
+
import { cancellationError, throwIfTurnCancelled } from "../../agent/turn-cancellation";
|
|
4
|
+
|
|
5
|
+
export type BrowserBackendOptions = {
|
|
6
|
+
timeoutMs?: number;
|
|
7
|
+
settleDelayMs?: number;
|
|
8
|
+
maxHtmlChars?: number;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
12
|
+
// Give page JavaScript time to render after the load event fires; JS-driven
|
|
13
|
+
// pages are the whole reason this backend exists.
|
|
14
|
+
const DEFAULT_SETTLE_DELAY_MS = 1000;
|
|
15
|
+
const DEFAULT_MAX_HTML_CHARS = 5 * 1024 * 1024;
|
|
16
|
+
|
|
17
|
+
export function isBrowserBackendAvailable(): boolean {
|
|
18
|
+
return typeof Bun !== "undefined" && typeof Bun.WebView === "function";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function createBrowserWebFetchBackend(
|
|
22
|
+
options: BrowserBackendOptions = {},
|
|
23
|
+
): WebFetchBackend {
|
|
24
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
25
|
+
const settleDelayMs = options.settleDelayMs ?? DEFAULT_SETTLE_DELAY_MS;
|
|
26
|
+
const maxHtmlChars = options.maxHtmlChars ?? DEFAULT_MAX_HTML_CHARS;
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
route: "local-browser",
|
|
30
|
+
async fetch(input, context): Promise<WebFetchBackendResult> {
|
|
31
|
+
throwIfTurnCancelled(context.signal);
|
|
32
|
+
if (!isBrowserBackendAvailable()) {
|
|
33
|
+
return {
|
|
34
|
+
ok: false,
|
|
35
|
+
error: "Bun.WebView is not available in this Bun version.",
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const view = new Bun.WebView({ width: 1280, height: 800 });
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
await withTimeout(
|
|
43
|
+
view.navigate(input.url),
|
|
44
|
+
timeoutMs,
|
|
45
|
+
`Browser navigation timed out after ${timeoutMs}ms.`,
|
|
46
|
+
context.signal,
|
|
47
|
+
);
|
|
48
|
+
await withTimeout(
|
|
49
|
+
Bun.sleep(settleDelayMs),
|
|
50
|
+
timeoutMs,
|
|
51
|
+
`Browser settle delay timed out after ${timeoutMs}ms.`,
|
|
52
|
+
context.signal,
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
const html = await withTimeout(
|
|
56
|
+
view.evaluate<string>("document.documentElement.outerHTML"),
|
|
57
|
+
timeoutMs,
|
|
58
|
+
`Reading the rendered page timed out after ${timeoutMs}ms.`,
|
|
59
|
+
context.signal,
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
if (typeof html !== "string" || html.trim() === "") {
|
|
63
|
+
return { ok: false, error: "The browser returned an empty document." };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (html.length > maxHtmlChars) {
|
|
67
|
+
return {
|
|
68
|
+
ok: false,
|
|
69
|
+
error: `The rendered page exceeds the ${maxHtmlChars} character limit.`,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const extracted = extractMarkdownFromHtml(html);
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
ok: true,
|
|
77
|
+
finalUrl: view.url || input.url,
|
|
78
|
+
title: extracted.title ?? (view.title || undefined),
|
|
79
|
+
markdown: extracted.markdown,
|
|
80
|
+
};
|
|
81
|
+
} catch (error) {
|
|
82
|
+
if (context.signal.aborted) {
|
|
83
|
+
throw cancellationError(context.signal, error);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
ok: false,
|
|
88
|
+
error: `Browser rendering failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
89
|
+
};
|
|
90
|
+
} finally {
|
|
91
|
+
view.close();
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function withTimeout<T>(
|
|
98
|
+
promise: Promise<T>,
|
|
99
|
+
timeoutMs: number,
|
|
100
|
+
message: string,
|
|
101
|
+
signal: AbortSignal,
|
|
102
|
+
): Promise<T> {
|
|
103
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
104
|
+
let onAbort: (() => void) | undefined;
|
|
105
|
+
const timeout = new Promise<never>((_, reject) => {
|
|
106
|
+
timer = setTimeout(() => reject(new Error(message)), timeoutMs);
|
|
107
|
+
});
|
|
108
|
+
const cancellation = new Promise<never>((_, reject) => {
|
|
109
|
+
onAbort = () => reject(cancellationError(signal));
|
|
110
|
+
if (signal.aborted) {
|
|
111
|
+
onAbort();
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
return await Promise.race([promise, timeout, cancellation]);
|
|
120
|
+
} finally {
|
|
121
|
+
clearTimeout(timer);
|
|
122
|
+
if (onAbort !== undefined) {
|
|
123
|
+
signal.removeEventListener("abort", onAbort);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import type { WebFetchBackend, WebFetchBackendResult } from "./backend";
|
|
2
|
+
import { cancellationError, throwIfTurnCancelled } from "../../agent/turn-cancellation";
|
|
3
|
+
|
|
4
|
+
export type ExaBackendOptions = {
|
|
5
|
+
apiKey: string;
|
|
6
|
+
baseUrl?: string;
|
|
7
|
+
fetchImpl?: typeof fetch;
|
|
8
|
+
timeoutMs?: number;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const DEFAULT_BASE_URL = "https://api.exa.ai";
|
|
12
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
13
|
+
const TEXT_MAX_CHARACTERS = 20_000;
|
|
14
|
+
const LIVECRAWL_TIMEOUT_MS = 10_000;
|
|
15
|
+
|
|
16
|
+
export function createExaWebFetchBackend(options: ExaBackendOptions): WebFetchBackend {
|
|
17
|
+
if (options.apiKey.trim() === "") {
|
|
18
|
+
throw new Error("Exa WebFetch backend requires a non-empty API key.");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
|
|
22
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
23
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
route: "exa",
|
|
27
|
+
async fetch(input, context): Promise<WebFetchBackendResult> {
|
|
28
|
+
throwIfTurnCancelled(context.signal);
|
|
29
|
+
let response: Response;
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
const requestSignal = AbortSignal.any([
|
|
33
|
+
context.signal,
|
|
34
|
+
AbortSignal.timeout(timeoutMs),
|
|
35
|
+
]);
|
|
36
|
+
response = await fetchImpl(`${baseUrl}/contents`, {
|
|
37
|
+
method: "POST",
|
|
38
|
+
headers: {
|
|
39
|
+
"content-type": "application/json",
|
|
40
|
+
"x-api-key": options.apiKey,
|
|
41
|
+
},
|
|
42
|
+
body: JSON.stringify({
|
|
43
|
+
urls: [input.url],
|
|
44
|
+
text: { maxCharacters: TEXT_MAX_CHARACTERS },
|
|
45
|
+
summary: { query: input.prompt },
|
|
46
|
+
highlights: { query: input.prompt },
|
|
47
|
+
livecrawlTimeout: LIVECRAWL_TIMEOUT_MS,
|
|
48
|
+
}),
|
|
49
|
+
signal: requestSignal,
|
|
50
|
+
});
|
|
51
|
+
} catch (error) {
|
|
52
|
+
if (context.signal.aborted) {
|
|
53
|
+
throw cancellationError(context.signal, error);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return { ok: false, error: requestErrorMessage(error, timeoutMs) };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let responseText: string;
|
|
60
|
+
try {
|
|
61
|
+
responseText = await response.text();
|
|
62
|
+
} catch (error) {
|
|
63
|
+
if (context.signal.aborted) {
|
|
64
|
+
throw cancellationError(context.signal, error);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return { ok: false, error: requestErrorMessage(error, timeoutMs) };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
throwIfTurnCancelled(context.signal);
|
|
71
|
+
|
|
72
|
+
if (!response.ok) {
|
|
73
|
+
return {
|
|
74
|
+
ok: false,
|
|
75
|
+
httpStatusCode: response.status,
|
|
76
|
+
error: `Exa /contents returned HTTP ${response.status}: ${extractApiError(responseText)}`,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
let payload: unknown;
|
|
81
|
+
try {
|
|
82
|
+
payload = JSON.parse(responseText);
|
|
83
|
+
} catch {
|
|
84
|
+
return { ok: false, error: "Exa /contents returned a non-JSON response body." };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (!isRecord(payload)) {
|
|
88
|
+
return { ok: false, error: "Exa /contents returned an unexpected response." };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const status = Array.isArray(payload.statuses)
|
|
92
|
+
? asRecord(payload.statuses[0])
|
|
93
|
+
: undefined;
|
|
94
|
+
const statusError = asRecord(status?.error);
|
|
95
|
+
|
|
96
|
+
if (status?.status === "error") {
|
|
97
|
+
const tag =
|
|
98
|
+
typeof statusError?.tag === "string" ? statusError.tag : "CRAWL_FAILED";
|
|
99
|
+
const httpStatusCode =
|
|
100
|
+
typeof statusError?.httpStatusCode === "number"
|
|
101
|
+
? statusError.httpStatusCode
|
|
102
|
+
: undefined;
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
ok: false,
|
|
106
|
+
errorTag: tag,
|
|
107
|
+
httpStatusCode,
|
|
108
|
+
error: `Exa could not fetch the page: ${tag}${httpStatusCode === undefined ? "" : ` (HTTP ${httpStatusCode})`}.`,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const result = Array.isArray(payload.results)
|
|
113
|
+
? asRecord(payload.results[0])
|
|
114
|
+
: undefined;
|
|
115
|
+
|
|
116
|
+
if (result === undefined) {
|
|
117
|
+
return { ok: false, error: "Exa /contents response contains no results." };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
ok: true,
|
|
122
|
+
finalUrl: typeof result.url === "string" ? result.url : undefined,
|
|
123
|
+
title: typeof result.title === "string" ? result.title : undefined,
|
|
124
|
+
publishedDate:
|
|
125
|
+
typeof result.publishedDate === "string" ? result.publishedDate : undefined,
|
|
126
|
+
markdown: typeof result.text === "string" ? result.text : undefined,
|
|
127
|
+
refined: typeof result.summary === "string" ? result.summary : undefined,
|
|
128
|
+
highlights: Array.isArray(result.highlights)
|
|
129
|
+
? result.highlights.filter(
|
|
130
|
+
(entry): entry is string => typeof entry === "string",
|
|
131
|
+
)
|
|
132
|
+
: undefined,
|
|
133
|
+
source: status?.source === "cached" ? "cached" : "crawled",
|
|
134
|
+
costDollars: extractTotalCost(payload.costDollars),
|
|
135
|
+
};
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function extractTotalCost(value: unknown): number | undefined {
|
|
141
|
+
const record = asRecord(value);
|
|
142
|
+
return typeof record?.total === "number" ? record.total : undefined;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function extractApiError(responseText: string): string {
|
|
146
|
+
try {
|
|
147
|
+
const payload: unknown = JSON.parse(responseText);
|
|
148
|
+
if (isRecord(payload) && typeof payload.error === "string") {
|
|
149
|
+
return payload.error;
|
|
150
|
+
}
|
|
151
|
+
} catch {
|
|
152
|
+
// Fall through to the raw body.
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return responseText === "" ? "(empty response body)" : responseText;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function requestErrorMessage(error: unknown, timeoutMs: number): string {
|
|
159
|
+
if (error instanceof Error && error.name === "TimeoutError") {
|
|
160
|
+
return `Exa /contents request timed out after ${timeoutMs}ms.`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return `Exa /contents request failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
167
|
+
return isRecord(value) ? value : undefined;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
171
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
172
|
+
}
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { cancellationError, throwIfTurnCancelled } from "../../agent/turn-cancellation";
|
|
2
|
+
import { defineToolExecutor } from "../types";
|
|
3
|
+
import type { ToolExecutionContext, ToolExecutor, WebFetchRawResult } from "../types";
|
|
4
|
+
import type { WebFetchBackend, WebFetchBackendResult, WebFetchRoute } from "./backend";
|
|
5
|
+
import {
|
|
6
|
+
createBrowserWebFetchBackend,
|
|
7
|
+
isBrowserBackendAvailable,
|
|
8
|
+
} from "./browser-backend";
|
|
9
|
+
import { createExaWebFetchBackend } from "./exa-backend";
|
|
10
|
+
import { createLocalWebFetchBackend } from "./local-backend";
|
|
11
|
+
import type { Refiner } from "./refiner";
|
|
12
|
+
import { decideRoute, isPrivateHost, shouldEscalateToBrowser } from "./route";
|
|
13
|
+
|
|
14
|
+
type WebFetchArgs = {
|
|
15
|
+
url: string;
|
|
16
|
+
prompt: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type WebFetchToolOptions = {
|
|
20
|
+
exaApiKey?: string;
|
|
21
|
+
refiner?: Refiner;
|
|
22
|
+
fetchImpl?: typeof fetch;
|
|
23
|
+
refineThreshold?: number;
|
|
24
|
+
cacheTtlMs?: number;
|
|
25
|
+
browserBackend?: WebFetchBackend | false;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const WEB_FETCH_DEFAULT_REFINE_THRESHOLD = 2000;
|
|
29
|
+
export const WEB_FETCH_DEFAULT_CACHE_TTL_MS = 15 * 60 * 1000;
|
|
30
|
+
|
|
31
|
+
type CacheEntry = {
|
|
32
|
+
expiresAt: number;
|
|
33
|
+
result: WebFetchRawResult;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export function createWebFetchToolExecutor(
|
|
37
|
+
options: WebFetchToolOptions = {},
|
|
38
|
+
): ToolExecutor {
|
|
39
|
+
const localBackend = createLocalWebFetchBackend({ fetchImpl: options.fetchImpl });
|
|
40
|
+
const exaBackend: WebFetchBackend | undefined =
|
|
41
|
+
options.exaApiKey !== undefined && options.exaApiKey.trim() !== ""
|
|
42
|
+
? createExaWebFetchBackend({
|
|
43
|
+
apiKey: options.exaApiKey,
|
|
44
|
+
fetchImpl: options.fetchImpl,
|
|
45
|
+
})
|
|
46
|
+
: undefined;
|
|
47
|
+
const browserBackend: WebFetchBackend | undefined =
|
|
48
|
+
options.browserBackend === false
|
|
49
|
+
? undefined
|
|
50
|
+
: (options.browserBackend ??
|
|
51
|
+
(isBrowserBackendAvailable() ? createBrowserWebFetchBackend() : undefined));
|
|
52
|
+
const refineThreshold =
|
|
53
|
+
options.refineThreshold ??
|
|
54
|
+
parsePositiveInteger(
|
|
55
|
+
process.env.TINKER_WEBFETCH_REFINE_THRESHOLD,
|
|
56
|
+
WEB_FETCH_DEFAULT_REFINE_THRESHOLD,
|
|
57
|
+
);
|
|
58
|
+
const cacheTtlMs = options.cacheTtlMs ?? WEB_FETCH_DEFAULT_CACHE_TTL_MS;
|
|
59
|
+
const cache = new Map<string, CacheEntry>();
|
|
60
|
+
|
|
61
|
+
return defineToolExecutor("web_fetch", {
|
|
62
|
+
definition: {
|
|
63
|
+
name: "WebFetch",
|
|
64
|
+
description: [
|
|
65
|
+
"Fetch content from a URL and process it according to the prompt.",
|
|
66
|
+
"- Small pages are returned as markdown; large pages are condensed into an answer to the prompt",
|
|
67
|
+
"- Supports public web pages as well as localhost and private-network URLs",
|
|
68
|
+
"- HTTP URLs on the public web are upgraded to HTTPS",
|
|
69
|
+
"- If the page redirects to a different host, the redirect URL is returned; call WebFetch again with that URL",
|
|
70
|
+
"- Use it to read documentation, pages found via WebSearch, or local dev server responses",
|
|
71
|
+
].join("\n"),
|
|
72
|
+
parameters: {
|
|
73
|
+
type: "object",
|
|
74
|
+
additionalProperties: false,
|
|
75
|
+
properties: {
|
|
76
|
+
url: {
|
|
77
|
+
type: "string",
|
|
78
|
+
format: "uri",
|
|
79
|
+
description: "The URL to fetch content from",
|
|
80
|
+
},
|
|
81
|
+
prompt: {
|
|
82
|
+
type: "string",
|
|
83
|
+
minLength: 1,
|
|
84
|
+
description: "The prompt to run on the fetched content",
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
required: ["url", "prompt"],
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
async execute(
|
|
91
|
+
args,
|
|
92
|
+
_call,
|
|
93
|
+
context: ToolExecutionContext,
|
|
94
|
+
): Promise<WebFetchRawResult> {
|
|
95
|
+
throwIfTurnCancelled(context.signal);
|
|
96
|
+
const parsed = parseWebFetchArgs(args);
|
|
97
|
+
|
|
98
|
+
if (!parsed.ok) {
|
|
99
|
+
return { ok: false, url: "", error: parsed.error };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const input = parsed.value;
|
|
103
|
+
const url = normalizeUrl(input.url);
|
|
104
|
+
const cacheKey = `${url.toString()}\n${input.prompt}`;
|
|
105
|
+
pruneCache(cache);
|
|
106
|
+
|
|
107
|
+
const cached = cache.get(cacheKey);
|
|
108
|
+
if (cached !== undefined) {
|
|
109
|
+
return { ...cached.result, cacheHit: true };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
throwIfTurnCancelled(context.signal);
|
|
113
|
+
|
|
114
|
+
let route = decideRoute(url, {
|
|
115
|
+
hasExaBackend: exaBackend !== undefined,
|
|
116
|
+
hasBrowserBackend: browserBackend !== undefined,
|
|
117
|
+
});
|
|
118
|
+
const backends: Record<WebFetchRoute, WebFetchBackend | undefined> = {
|
|
119
|
+
local: localBackend,
|
|
120
|
+
exa: exaBackend,
|
|
121
|
+
"local-browser": browserBackend,
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const startedAt = Date.now();
|
|
125
|
+
let backendResult: WebFetchBackendResult = await (
|
|
126
|
+
backends[route] ?? localBackend
|
|
127
|
+
).fetch(
|
|
128
|
+
{
|
|
129
|
+
url: url.toString(),
|
|
130
|
+
prompt: input.prompt,
|
|
131
|
+
},
|
|
132
|
+
context,
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
throwIfTurnCancelled(context.signal);
|
|
136
|
+
|
|
137
|
+
if (
|
|
138
|
+
route === "local" &&
|
|
139
|
+
browserBackend !== undefined &&
|
|
140
|
+
shouldEscalateToBrowser(backendResult)
|
|
141
|
+
) {
|
|
142
|
+
const browserResult = await browserBackend.fetch(
|
|
143
|
+
{
|
|
144
|
+
url: url.toString(),
|
|
145
|
+
prompt: input.prompt,
|
|
146
|
+
},
|
|
147
|
+
context,
|
|
148
|
+
);
|
|
149
|
+
throwIfTurnCancelled(context.signal);
|
|
150
|
+
if (browserResult.ok && (browserResult.markdown ?? "").trim() !== "") {
|
|
151
|
+
backendResult = browserResult;
|
|
152
|
+
route = "local-browser";
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const durationMs = Date.now() - startedAt;
|
|
157
|
+
|
|
158
|
+
const base: WebFetchRawResult = {
|
|
159
|
+
ok: false,
|
|
160
|
+
url: url.toString(),
|
|
161
|
+
route,
|
|
162
|
+
durationMs,
|
|
163
|
+
finalUrl: backendResult.finalUrl,
|
|
164
|
+
title: backendResult.title,
|
|
165
|
+
publishedDate: backendResult.publishedDate,
|
|
166
|
+
source: backendResult.source,
|
|
167
|
+
costDollars: backendResult.costDollars,
|
|
168
|
+
errorTag: backendResult.errorTag,
|
|
169
|
+
httpStatusCode: backendResult.httpStatusCode,
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
if (!backendResult.ok) {
|
|
173
|
+
return { ...base, error: backendResult.error ?? "Unknown error." };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (backendResult.redirectUrl !== undefined) {
|
|
177
|
+
return { ...base, ok: true, redirectUrl: backendResult.redirectUrl };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const markdown = backendResult.markdown ?? "";
|
|
181
|
+
if (markdown.trim() === "") {
|
|
182
|
+
return { ...base, error: "The page returned no readable content." };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
let result: WebFetchRawResult;
|
|
186
|
+
|
|
187
|
+
if (markdown.length <= refineThreshold) {
|
|
188
|
+
result = { ...base, ok: true, refined: false, content: markdown };
|
|
189
|
+
} else if (backendResult.refined !== undefined) {
|
|
190
|
+
result = {
|
|
191
|
+
...base,
|
|
192
|
+
ok: true,
|
|
193
|
+
refined: true,
|
|
194
|
+
content: backendResult.refined,
|
|
195
|
+
highlights: backendResult.highlights,
|
|
196
|
+
};
|
|
197
|
+
} else if (options.refiner === undefined) {
|
|
198
|
+
return {
|
|
199
|
+
...base,
|
|
200
|
+
error: `The page content is ${markdown.length} characters and no refiner is configured to condense it.`,
|
|
201
|
+
};
|
|
202
|
+
} else {
|
|
203
|
+
try {
|
|
204
|
+
const refined = await options.refiner.refine(
|
|
205
|
+
{
|
|
206
|
+
url: url.toString(),
|
|
207
|
+
prompt: input.prompt,
|
|
208
|
+
content: markdown,
|
|
209
|
+
},
|
|
210
|
+
context,
|
|
211
|
+
);
|
|
212
|
+
result = { ...base, ok: true, refined: true, content: refined };
|
|
213
|
+
} catch (error) {
|
|
214
|
+
if (context.signal.aborted) {
|
|
215
|
+
throw cancellationError(context.signal, error);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
...base,
|
|
220
|
+
error: `Failed to refine the page content: ${error instanceof Error ? error.message : String(error)}`,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
throwIfTurnCancelled(context.signal);
|
|
226
|
+
result.durationMs = Date.now() - startedAt;
|
|
227
|
+
cache.set(cacheKey, { expiresAt: Date.now() + cacheTtlMs, result });
|
|
228
|
+
return result;
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function parseWebFetchArgs(
|
|
234
|
+
args: unknown,
|
|
235
|
+
): { ok: true; value: WebFetchArgs } | { ok: false; error: string } {
|
|
236
|
+
if (!isRecord(args)) {
|
|
237
|
+
return { ok: false, error: "WebFetch arguments must be an object." };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (typeof args.url !== "string" || args.url.trim() === "") {
|
|
241
|
+
return { ok: false, error: "WebFetch.url must be a non-empty string." };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
let url: URL;
|
|
245
|
+
try {
|
|
246
|
+
url = new URL(args.url.trim());
|
|
247
|
+
} catch {
|
|
248
|
+
return { ok: false, error: `WebFetch.url is not a valid URL: ${args.url}` };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
252
|
+
return {
|
|
253
|
+
ok: false,
|
|
254
|
+
error: `WebFetch.url must use http or https, received ${url.protocol.replace(":", "")}.`,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (typeof args.prompt !== "string" || args.prompt.trim() === "") {
|
|
259
|
+
return { ok: false, error: "WebFetch.prompt must be a non-empty string." };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return {
|
|
263
|
+
ok: true,
|
|
264
|
+
value: { url: url.toString(), prompt: args.prompt.trim() },
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function normalizeUrl(rawUrl: string): URL {
|
|
269
|
+
const url = new URL(rawUrl);
|
|
270
|
+
|
|
271
|
+
if (url.protocol === "http:" && url.port === "" && !isPrivateHost(url.hostname)) {
|
|
272
|
+
url.protocol = "https:";
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return url;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function pruneCache(cache: Map<string, CacheEntry>): void {
|
|
279
|
+
const now = Date.now();
|
|
280
|
+
for (const [key, entry] of cache) {
|
|
281
|
+
if (entry.expiresAt <= now) {
|
|
282
|
+
cache.delete(key);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function parsePositiveInteger(value: string | undefined, fallback: number): number {
|
|
288
|
+
if (value === undefined || value.trim() === "") {
|
|
289
|
+
return fallback;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const parsed = Number(value);
|
|
293
|
+
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
297
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
298
|
+
}
|