brownian-code 2026.2.10
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/LICENSE +21 -0
- package/README.md +97 -0
- package/bin/brownian +25 -0
- package/env.example +21 -0
- package/package.json +87 -0
- package/src/agent/agent.test.ts +414 -0
- package/src/agent/agent.ts +385 -0
- package/src/agent/index.ts +27 -0
- package/src/agent/prompts.ts +271 -0
- package/src/agent/scratchpad.test.ts +482 -0
- package/src/agent/scratchpad.ts +526 -0
- package/src/agent/token-counter.test.ts +59 -0
- package/src/agent/token-counter.ts +33 -0
- package/src/agent/types.ts +137 -0
- package/src/cli.tsx +385 -0
- package/src/commands/builtin.test.ts +271 -0
- package/src/commands/builtin.ts +200 -0
- package/src/commands/registry.test.ts +188 -0
- package/src/commands/registry.ts +111 -0
- package/src/commands/types.ts +64 -0
- package/src/components/AgentEventView.tsx +487 -0
- package/src/components/AnswerBox.tsx +81 -0
- package/src/components/ApiKeyPrompt.tsx +75 -0
- package/src/components/CommandMenu.test.tsx +64 -0
- package/src/components/CommandMenu.tsx +38 -0
- package/src/components/CursorText.tsx +43 -0
- package/src/components/DebugPanel.tsx +48 -0
- package/src/components/ErrorBox.test.tsx +58 -0
- package/src/components/ErrorBox.tsx +26 -0
- package/src/components/HelpView.test.tsx +70 -0
- package/src/components/HelpView.tsx +61 -0
- package/src/components/HistoryItemView.tsx +108 -0
- package/src/components/Input.tsx +193 -0
- package/src/components/Intro.test.tsx +59 -0
- package/src/components/Intro.tsx +35 -0
- package/src/components/ModelSelector.tsx +288 -0
- package/src/components/StatusBar.test.tsx +78 -0
- package/src/components/StatusBar.tsx +56 -0
- package/src/components/WorkingIndicator.tsx +133 -0
- package/src/components/index.ts +23 -0
- package/src/e2e/agent-flow.test.ts +378 -0
- package/src/evals/components/EvalApp.tsx +206 -0
- package/src/evals/components/EvalCurrentQuestion.tsx +42 -0
- package/src/evals/components/EvalProgress.tsx +33 -0
- package/src/evals/components/EvalRecentResults.tsx +63 -0
- package/src/evals/components/EvalStats.tsx +49 -0
- package/src/evals/components/index.ts +5 -0
- package/src/evals/dataset/crypto_agent.csv +16 -0
- package/src/evals/run.ts +355 -0
- package/src/gateway/channels/whatsapp/auth-store.ts +15 -0
- package/src/gateway/channels/whatsapp/inbound.ts +86 -0
- package/src/gateway/channels/whatsapp/login.ts +28 -0
- package/src/gateway/channels/whatsapp/outbound.ts +27 -0
- package/src/gateway/channels/whatsapp/session.ts +69 -0
- package/src/gateway/config.ts +81 -0
- package/src/gateway/index.ts +62 -0
- package/src/hooks/useAgentRunner.ts +317 -0
- package/src/hooks/useDebugLogs.ts +22 -0
- package/src/hooks/useInputHistory.ts +106 -0
- package/src/hooks/useModelSelection.ts +249 -0
- package/src/hooks/useTextBuffer.test.ts +121 -0
- package/src/hooks/useTextBuffer.ts +97 -0
- package/src/index.tsx +74 -0
- package/src/mcp/cache.ts +205 -0
- package/src/mcp/client.test.ts +126 -0
- package/src/mcp/client.ts +145 -0
- package/src/mcp/index.ts +2 -0
- package/src/model/llm.test.ts +158 -0
- package/src/model/llm.ts +233 -0
- package/src/providers.ts +94 -0
- package/src/skills/index.ts +17 -0
- package/src/skills/loader.ts +73 -0
- package/src/skills/registry.ts +125 -0
- package/src/skills/types.ts +31 -0
- package/src/test-utils/mocks.ts +110 -0
- package/src/theme.ts +21 -0
- package/src/tools/browser/browser.ts +357 -0
- package/src/tools/browser/index.ts +1 -0
- package/src/tools/crypto/hive-tools.ts +171 -0
- package/src/tools/crypto/index.ts +1 -0
- package/src/tools/descriptions/browser.ts +105 -0
- package/src/tools/descriptions/crypto-search.ts +58 -0
- package/src/tools/descriptions/index.ts +8 -0
- package/src/tools/descriptions/web-fetch.ts +44 -0
- package/src/tools/descriptions/web-search.ts +26 -0
- package/src/tools/fetch/cache.ts +95 -0
- package/src/tools/fetch/external-content.ts +200 -0
- package/src/tools/fetch/index.ts +1 -0
- package/src/tools/fetch/web-fetch-utils.ts +122 -0
- package/src/tools/fetch/web-fetch.ts +371 -0
- package/src/tools/index.ts +12 -0
- package/src/tools/registry.ts +130 -0
- package/src/tools/search/exa.ts +43 -0
- package/src/tools/search/index.ts +2 -0
- package/src/tools/search/tavily.ts +35 -0
- package/src/tools/skill.ts +62 -0
- package/src/tools/types.ts +53 -0
- package/src/utils/ai-message.ts +26 -0
- package/src/utils/config.ts +54 -0
- package/src/utils/cost-calculator.test.ts +101 -0
- package/src/utils/cost-calculator.ts +74 -0
- package/src/utils/env.ts +101 -0
- package/src/utils/error-classifier.test.ts +146 -0
- package/src/utils/error-classifier.ts +91 -0
- package/src/utils/in-memory-chat-history.test.ts +291 -0
- package/src/utils/in-memory-chat-history.ts +224 -0
- package/src/utils/index.ts +19 -0
- package/src/utils/input-key-handlers.test.ts +155 -0
- package/src/utils/input-key-handlers.ts +64 -0
- package/src/utils/logger.ts +67 -0
- package/src/utils/long-term-chat-history.ts +138 -0
- package/src/utils/markdown-table.ts +227 -0
- package/src/utils/ollama.ts +37 -0
- package/src/utils/progress-channel.ts +84 -0
- package/src/utils/text-navigation.test.ts +222 -0
- package/src/utils/text-navigation.ts +81 -0
- package/src/utils/thinking-verbs.ts +29 -0
- package/src/utils/tokens.test.ts +163 -0
- package/src/utils/tokens.ts +67 -0
- package/src/utils/tool-description.ts +88 -0
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* web_fetch tool — lightweight one-shot page reader with caching.
|
|
3
|
+
*
|
|
4
|
+
* Core extraction logic ported from OpenClaw's src/agents/tools/web-fetch.ts (MIT license).
|
|
5
|
+
* Adapted for Brownian's LangChain DynamicStructuredTool + Zod framework.
|
|
6
|
+
*
|
|
7
|
+
* Differences from OpenClaw:
|
|
8
|
+
* - fetchWithSsrFGuard replaced with plain fetch + manual redirect handling
|
|
9
|
+
* - Firecrawl fallback removed (falls back to htmlToMarkdown instead)
|
|
10
|
+
* - Config resolution replaced with hardcoded defaults
|
|
11
|
+
* - Tool wrapper uses LangChain DynamicStructuredTool + Zod (not AnyAgentTool + TypeBox)
|
|
12
|
+
*/
|
|
13
|
+
import { DynamicStructuredTool } from '@langchain/core/tools';
|
|
14
|
+
import { z } from 'zod';
|
|
15
|
+
import { formatToolResult } from '../types.js';
|
|
16
|
+
import { wrapExternalContent, wrapWebContent } from './external-content.js';
|
|
17
|
+
import {
|
|
18
|
+
extractReadableContent,
|
|
19
|
+
htmlToMarkdown,
|
|
20
|
+
markdownToText,
|
|
21
|
+
truncateText,
|
|
22
|
+
type ExtractMode,
|
|
23
|
+
} from './web-fetch-utils.js';
|
|
24
|
+
import {
|
|
25
|
+
type CacheEntry,
|
|
26
|
+
DEFAULT_CACHE_TTL_MINUTES,
|
|
27
|
+
DEFAULT_TIMEOUT_SECONDS,
|
|
28
|
+
normalizeCacheKey,
|
|
29
|
+
readCache,
|
|
30
|
+
readResponseText,
|
|
31
|
+
resolveCacheTtlMs,
|
|
32
|
+
resolveTimeoutSeconds,
|
|
33
|
+
withTimeout,
|
|
34
|
+
writeCache,
|
|
35
|
+
} from './cache.js';
|
|
36
|
+
|
|
37
|
+
// ============================================================================
|
|
38
|
+
// Constants (identical to OpenClaw)
|
|
39
|
+
// ============================================================================
|
|
40
|
+
|
|
41
|
+
const DEFAULT_FETCH_MAX_CHARS = 50_000;
|
|
42
|
+
const DEFAULT_FETCH_MAX_REDIRECTS = 3;
|
|
43
|
+
const DEFAULT_ERROR_MAX_CHARS = 4_000;
|
|
44
|
+
const DEFAULT_FETCH_USER_AGENT =
|
|
45
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36";
|
|
46
|
+
|
|
47
|
+
// ============================================================================
|
|
48
|
+
// Cache (identical to OpenClaw)
|
|
49
|
+
// ============================================================================
|
|
50
|
+
|
|
51
|
+
const FETCH_CACHE = new Map<string, CacheEntry<Record<string, unknown>>>();
|
|
52
|
+
|
|
53
|
+
// ============================================================================
|
|
54
|
+
// Content wrapping (identical to OpenClaw)
|
|
55
|
+
// ============================================================================
|
|
56
|
+
|
|
57
|
+
const WEB_FETCH_WRAPPER_WITH_WARNING_OVERHEAD = wrapWebContent("", "web_fetch").length;
|
|
58
|
+
const WEB_FETCH_WRAPPER_NO_WARNING_OVERHEAD = wrapExternalContent("", {
|
|
59
|
+
source: "web_fetch",
|
|
60
|
+
includeWarning: false,
|
|
61
|
+
}).length;
|
|
62
|
+
|
|
63
|
+
function wrapWebFetchContent(
|
|
64
|
+
value: string,
|
|
65
|
+
maxChars: number,
|
|
66
|
+
): {
|
|
67
|
+
text: string;
|
|
68
|
+
truncated: boolean;
|
|
69
|
+
rawLength: number;
|
|
70
|
+
wrappedLength: number;
|
|
71
|
+
} {
|
|
72
|
+
if (maxChars <= 0) {
|
|
73
|
+
return { text: "", truncated: true, rawLength: 0, wrappedLength: 0 };
|
|
74
|
+
}
|
|
75
|
+
const includeWarning = maxChars >= WEB_FETCH_WRAPPER_WITH_WARNING_OVERHEAD;
|
|
76
|
+
const wrapperOverhead = includeWarning
|
|
77
|
+
? WEB_FETCH_WRAPPER_WITH_WARNING_OVERHEAD
|
|
78
|
+
: WEB_FETCH_WRAPPER_NO_WARNING_OVERHEAD;
|
|
79
|
+
if (wrapperOverhead > maxChars) {
|
|
80
|
+
const minimal = includeWarning
|
|
81
|
+
? wrapWebContent("", "web_fetch")
|
|
82
|
+
: wrapExternalContent("", { source: "web_fetch", includeWarning: false });
|
|
83
|
+
const truncatedWrapper = truncateText(minimal, maxChars);
|
|
84
|
+
return {
|
|
85
|
+
text: truncatedWrapper.text,
|
|
86
|
+
truncated: true,
|
|
87
|
+
rawLength: 0,
|
|
88
|
+
wrappedLength: truncatedWrapper.text.length,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
const maxInner = Math.max(0, maxChars - wrapperOverhead);
|
|
92
|
+
let truncated = truncateText(value, maxInner);
|
|
93
|
+
let wrappedText = includeWarning
|
|
94
|
+
? wrapWebContent(truncated.text, "web_fetch")
|
|
95
|
+
: wrapExternalContent(truncated.text, { source: "web_fetch", includeWarning: false });
|
|
96
|
+
|
|
97
|
+
if (wrappedText.length > maxChars) {
|
|
98
|
+
const excess = wrappedText.length - maxChars;
|
|
99
|
+
const adjustedMaxInner = Math.max(0, maxInner - excess);
|
|
100
|
+
truncated = truncateText(value, adjustedMaxInner);
|
|
101
|
+
wrappedText = includeWarning
|
|
102
|
+
? wrapWebContent(truncated.text, "web_fetch")
|
|
103
|
+
: wrapExternalContent(truncated.text, { source: "web_fetch", includeWarning: false });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
text: wrappedText,
|
|
108
|
+
truncated: truncated.truncated,
|
|
109
|
+
rawLength: truncated.text.length,
|
|
110
|
+
wrappedLength: wrappedText.length,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function wrapWebFetchField(value: string | undefined): string | undefined {
|
|
115
|
+
if (!value) {
|
|
116
|
+
return value;
|
|
117
|
+
}
|
|
118
|
+
return wrapExternalContent(value, { source: "web_fetch", includeWarning: false });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ============================================================================
|
|
122
|
+
// Helpers (identical to OpenClaw)
|
|
123
|
+
// ============================================================================
|
|
124
|
+
|
|
125
|
+
function normalizeContentType(value: string | null | undefined): string | undefined {
|
|
126
|
+
if (!value) {
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
const [raw] = value.split(";");
|
|
130
|
+
const trimmed = raw?.trim();
|
|
131
|
+
return trimmed || undefined;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function looksLikeHtml(value: string): boolean {
|
|
135
|
+
const trimmed = value.trimStart();
|
|
136
|
+
if (!trimmed) {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
const head = trimmed.slice(0, 256).toLowerCase();
|
|
140
|
+
return head.startsWith("<!doctype html") || head.startsWith("<html");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function formatWebFetchErrorDetail(params: {
|
|
144
|
+
detail: string;
|
|
145
|
+
contentType?: string | null;
|
|
146
|
+
maxChars: number;
|
|
147
|
+
}): string {
|
|
148
|
+
const { detail, contentType, maxChars } = params;
|
|
149
|
+
if (!detail) {
|
|
150
|
+
return "";
|
|
151
|
+
}
|
|
152
|
+
let text = detail;
|
|
153
|
+
const contentTypeLower = contentType?.toLowerCase();
|
|
154
|
+
if (contentTypeLower?.includes("text/html") || looksLikeHtml(detail)) {
|
|
155
|
+
const rendered = htmlToMarkdown(detail);
|
|
156
|
+
const withTitle = rendered.title ? `${rendered.title}\n${rendered.text}` : rendered.text;
|
|
157
|
+
text = markdownToText(withTitle);
|
|
158
|
+
}
|
|
159
|
+
const truncatedResult = truncateText(text.trim(), maxChars);
|
|
160
|
+
return truncatedResult.text;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function resolveMaxChars(value: unknown, fallback: number, cap: number): number {
|
|
164
|
+
const parsed = typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
165
|
+
const clamped = Math.max(100, Math.floor(parsed));
|
|
166
|
+
return Math.min(clamped, cap);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ============================================================================
|
|
170
|
+
// HTTP fetch with manual redirect handling (replaces OpenClaw's fetchWithSsrFGuard)
|
|
171
|
+
// ============================================================================
|
|
172
|
+
|
|
173
|
+
function isRedirectStatus(status: number): boolean {
|
|
174
|
+
return status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function fetchWithRedirects(params: {
|
|
178
|
+
url: string;
|
|
179
|
+
maxRedirects: number;
|
|
180
|
+
timeoutMs: number;
|
|
181
|
+
headers: Record<string, string>;
|
|
182
|
+
}): Promise<{ response: Response; finalUrl: string }> {
|
|
183
|
+
const signal = withTimeout(undefined, params.timeoutMs);
|
|
184
|
+
const visited = new Set<string>();
|
|
185
|
+
let currentUrl = params.url;
|
|
186
|
+
let redirectCount = 0;
|
|
187
|
+
|
|
188
|
+
while (true) {
|
|
189
|
+
let parsedUrl: URL;
|
|
190
|
+
try {
|
|
191
|
+
parsedUrl = new URL(currentUrl);
|
|
192
|
+
} catch {
|
|
193
|
+
throw new Error("Invalid URL: must be http or https");
|
|
194
|
+
}
|
|
195
|
+
if (!["http:", "https:"].includes(parsedUrl.protocol)) {
|
|
196
|
+
throw new Error("Invalid URL: must be http or https");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const response = await fetch(parsedUrl.toString(), {
|
|
200
|
+
redirect: "manual",
|
|
201
|
+
headers: params.headers,
|
|
202
|
+
signal,
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
if (isRedirectStatus(response.status)) {
|
|
206
|
+
const location = response.headers.get("location");
|
|
207
|
+
if (!location) {
|
|
208
|
+
throw new Error(`Redirect missing location header (${response.status})`);
|
|
209
|
+
}
|
|
210
|
+
redirectCount += 1;
|
|
211
|
+
if (redirectCount > params.maxRedirects) {
|
|
212
|
+
throw new Error(`Too many redirects (limit: ${params.maxRedirects})`);
|
|
213
|
+
}
|
|
214
|
+
const nextUrl = new URL(location, parsedUrl).toString();
|
|
215
|
+
if (visited.has(nextUrl)) {
|
|
216
|
+
throw new Error("Redirect loop detected");
|
|
217
|
+
}
|
|
218
|
+
visited.add(nextUrl);
|
|
219
|
+
currentUrl = nextUrl;
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return { response, finalUrl: currentUrl };
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ============================================================================
|
|
228
|
+
// Core fetch logic (ported from OpenClaw's runWebFetch, Firecrawl branches removed)
|
|
229
|
+
// ============================================================================
|
|
230
|
+
|
|
231
|
+
async function runWebFetch(params: {
|
|
232
|
+
url: string;
|
|
233
|
+
extractMode: ExtractMode;
|
|
234
|
+
maxChars: number;
|
|
235
|
+
maxRedirects: number;
|
|
236
|
+
timeoutSeconds: number;
|
|
237
|
+
cacheTtlMs: number;
|
|
238
|
+
userAgent: string;
|
|
239
|
+
}): Promise<Record<string, unknown>> {
|
|
240
|
+
const cacheKey = normalizeCacheKey(
|
|
241
|
+
`fetch:${params.url}:${params.extractMode}:${params.maxChars}`,
|
|
242
|
+
);
|
|
243
|
+
const cached = readCache(FETCH_CACHE, cacheKey);
|
|
244
|
+
if (cached) {
|
|
245
|
+
return { ...cached.value, cached: true };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
let parsedUrl: URL;
|
|
249
|
+
try {
|
|
250
|
+
parsedUrl = new URL(params.url);
|
|
251
|
+
} catch {
|
|
252
|
+
throw new Error("Invalid URL: must be http or https");
|
|
253
|
+
}
|
|
254
|
+
if (!["http:", "https:"].includes(parsedUrl.protocol)) {
|
|
255
|
+
throw new Error("Invalid URL: must be http or https");
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const start = Date.now();
|
|
259
|
+
const { response: res, finalUrl } = await fetchWithRedirects({
|
|
260
|
+
url: params.url,
|
|
261
|
+
maxRedirects: params.maxRedirects,
|
|
262
|
+
timeoutMs: params.timeoutSeconds * 1000,
|
|
263
|
+
headers: {
|
|
264
|
+
Accept: "*/*",
|
|
265
|
+
"User-Agent": params.userAgent,
|
|
266
|
+
"Accept-Language": "en-US,en;q=0.9",
|
|
267
|
+
},
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
if (!res.ok) {
|
|
271
|
+
const rawDetail = await readResponseText(res);
|
|
272
|
+
const detail = formatWebFetchErrorDetail({
|
|
273
|
+
detail: rawDetail,
|
|
274
|
+
contentType: res.headers.get("content-type"),
|
|
275
|
+
maxChars: DEFAULT_ERROR_MAX_CHARS,
|
|
276
|
+
});
|
|
277
|
+
const wrappedDetail = wrapWebFetchContent(detail || res.statusText, DEFAULT_ERROR_MAX_CHARS);
|
|
278
|
+
throw new Error(`Web fetch failed (${res.status}): ${wrappedDetail.text}`);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const contentType = res.headers.get("content-type") ?? "application/octet-stream";
|
|
282
|
+
const normalizedContentType = normalizeContentType(contentType) ?? "application/octet-stream";
|
|
283
|
+
const body = await readResponseText(res);
|
|
284
|
+
|
|
285
|
+
let title: string | undefined;
|
|
286
|
+
let extractor = "raw";
|
|
287
|
+
let text = body;
|
|
288
|
+
if (contentType.includes("text/html")) {
|
|
289
|
+
const readable = await extractReadableContent({
|
|
290
|
+
html: body,
|
|
291
|
+
url: finalUrl,
|
|
292
|
+
extractMode: params.extractMode,
|
|
293
|
+
});
|
|
294
|
+
if (readable?.text) {
|
|
295
|
+
text = readable.text;
|
|
296
|
+
title = readable.title;
|
|
297
|
+
extractor = "readability";
|
|
298
|
+
} else {
|
|
299
|
+
// Fallback to htmlToMarkdown (OpenClaw falls to Firecrawl here)
|
|
300
|
+
const rendered = htmlToMarkdown(body);
|
|
301
|
+
text = params.extractMode === "text" ? markdownToText(rendered.text) : rendered.text;
|
|
302
|
+
title = rendered.title;
|
|
303
|
+
extractor = "htmlToMarkdown";
|
|
304
|
+
}
|
|
305
|
+
} else if (contentType.includes("application/json")) {
|
|
306
|
+
try {
|
|
307
|
+
text = JSON.stringify(JSON.parse(body), null, 2);
|
|
308
|
+
extractor = "json";
|
|
309
|
+
} catch {
|
|
310
|
+
text = body;
|
|
311
|
+
extractor = "raw";
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const wrapped = wrapWebFetchContent(text, params.maxChars);
|
|
316
|
+
const wrappedTitle = title ? wrapWebFetchField(title) : undefined;
|
|
317
|
+
const payload = {
|
|
318
|
+
url: params.url,
|
|
319
|
+
finalUrl,
|
|
320
|
+
status: res.status,
|
|
321
|
+
contentType: normalizedContentType,
|
|
322
|
+
title: wrappedTitle,
|
|
323
|
+
extractMode: params.extractMode,
|
|
324
|
+
extractor,
|
|
325
|
+
truncated: wrapped.truncated,
|
|
326
|
+
length: wrapped.wrappedLength,
|
|
327
|
+
rawLength: wrapped.rawLength,
|
|
328
|
+
wrappedLength: wrapped.wrappedLength,
|
|
329
|
+
fetchedAt: new Date().toISOString(),
|
|
330
|
+
tookMs: Date.now() - start,
|
|
331
|
+
text: wrapped.text,
|
|
332
|
+
};
|
|
333
|
+
writeCache(FETCH_CACHE, cacheKey, payload, params.cacheTtlMs);
|
|
334
|
+
return payload;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// ============================================================================
|
|
338
|
+
// Tool definition (adapted for Brownian's LangChain + Zod framework)
|
|
339
|
+
// ============================================================================
|
|
340
|
+
|
|
341
|
+
export const webFetchTool = new DynamicStructuredTool({
|
|
342
|
+
name: 'web_fetch',
|
|
343
|
+
description:
|
|
344
|
+
'Fetch and extract readable content from a URL (HTML → markdown/text). Use for lightweight page access without browser automation.',
|
|
345
|
+
schema: z.object({
|
|
346
|
+
url: z.string().describe('HTTP or HTTPS URL to fetch.'),
|
|
347
|
+
extractMode: z
|
|
348
|
+
.enum(['markdown', 'text'])
|
|
349
|
+
.optional()
|
|
350
|
+
.describe('Extraction mode ("markdown" or "text"). Defaults to "markdown".'),
|
|
351
|
+
maxChars: z
|
|
352
|
+
.number()
|
|
353
|
+
.min(100)
|
|
354
|
+
.optional()
|
|
355
|
+
.describe('Maximum characters to return (truncates when exceeded).'),
|
|
356
|
+
}),
|
|
357
|
+
func: async (input) => {
|
|
358
|
+
const extractMode: ExtractMode = input.extractMode === 'text' ? 'text' : 'markdown';
|
|
359
|
+
const maxChars = resolveMaxChars(input.maxChars, DEFAULT_FETCH_MAX_CHARS, DEFAULT_FETCH_MAX_CHARS);
|
|
360
|
+
const result = await runWebFetch({
|
|
361
|
+
url: input.url,
|
|
362
|
+
extractMode,
|
|
363
|
+
maxChars,
|
|
364
|
+
maxRedirects: DEFAULT_FETCH_MAX_REDIRECTS,
|
|
365
|
+
timeoutSeconds: resolveTimeoutSeconds(undefined, DEFAULT_TIMEOUT_SECONDS),
|
|
366
|
+
cacheTtlMs: resolveCacheTtlMs(undefined, DEFAULT_CACHE_TTL_MINUTES),
|
|
367
|
+
userAgent: DEFAULT_FETCH_USER_AGENT,
|
|
368
|
+
});
|
|
369
|
+
return formatToolResult(result, [input.url]);
|
|
370
|
+
},
|
|
371
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Tool registry - the primary way to access tools and their descriptions
|
|
2
|
+
export { getToolRegistry, getTools, buildToolDescriptions } from './registry.js';
|
|
3
|
+
export type { RegisteredTool } from './registry.js';
|
|
4
|
+
|
|
5
|
+
// Individual tool exports (for backward compatibility and direct access)
|
|
6
|
+
export { tavilySearch } from './search/index.js';
|
|
7
|
+
|
|
8
|
+
// Tool descriptions
|
|
9
|
+
export {
|
|
10
|
+
WEB_SEARCH_DESCRIPTION,
|
|
11
|
+
CRYPTO_TOOLS_DESCRIPTION,
|
|
12
|
+
} from './descriptions/index.js';
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { StructuredToolInterface } from '@langchain/core/tools';
|
|
2
|
+
import { createCryptoTools } from './crypto/index.js';
|
|
3
|
+
import { exaSearch, tavilySearch } from './search/index.js';
|
|
4
|
+
import { skillTool, SKILL_TOOL_DESCRIPTION } from './skill.js';
|
|
5
|
+
import { webFetchTool } from './fetch/index.js';
|
|
6
|
+
import { browserTool } from './browser/index.js';
|
|
7
|
+
import { CRYPTO_TOOLS_DESCRIPTION, WEB_SEARCH_DESCRIPTION, WEB_FETCH_DESCRIPTION, BROWSER_DESCRIPTION } from './descriptions/index.js';
|
|
8
|
+
import { discoverSkills } from '../skills/index.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A registered tool with its rich description for system prompt injection.
|
|
12
|
+
*/
|
|
13
|
+
export interface RegisteredTool {
|
|
14
|
+
/** Tool name (must match the tool's name property) */
|
|
15
|
+
name: string;
|
|
16
|
+
/** The actual tool instance */
|
|
17
|
+
tool: StructuredToolInterface;
|
|
18
|
+
/** Rich description for system prompt (includes when to use, when not to use, etc.) */
|
|
19
|
+
description: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Get all registered tools with their descriptions.
|
|
24
|
+
* Conditionally includes tools based on environment configuration.
|
|
25
|
+
*
|
|
26
|
+
* @param _model - The model name (kept for API compatibility)
|
|
27
|
+
* @returns Array of registered tools
|
|
28
|
+
*/
|
|
29
|
+
export function getToolRegistry(_model: string): RegisteredTool[] {
|
|
30
|
+
const tools: RegisteredTool[] = [];
|
|
31
|
+
|
|
32
|
+
// Add all crypto tools (12 tools: schema + invoke + 10 categories)
|
|
33
|
+
// The rich description is attached to invoke_api_endpoint as the primary data tool
|
|
34
|
+
const cryptoTools = createCryptoTools();
|
|
35
|
+
for (const tool of cryptoTools) {
|
|
36
|
+
tools.push({
|
|
37
|
+
name: tool.name,
|
|
38
|
+
tool: tool as StructuredToolInterface,
|
|
39
|
+
description: tool.name === 'invoke_api_endpoint' ? CRYPTO_TOOLS_DESCRIPTION : tool.description,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Web fetch
|
|
44
|
+
tools.push({
|
|
45
|
+
name: 'web_fetch',
|
|
46
|
+
tool: webFetchTool,
|
|
47
|
+
description: WEB_FETCH_DESCRIPTION,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// Browser
|
|
51
|
+
tools.push({
|
|
52
|
+
name: 'browser',
|
|
53
|
+
tool: browserTool,
|
|
54
|
+
description: BROWSER_DESCRIPTION,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// Include web_search if Exa or Tavily API key is configured (Exa preferred)
|
|
58
|
+
if (process.env.EXASEARCH_API_KEY) {
|
|
59
|
+
tools.push({
|
|
60
|
+
name: 'web_search',
|
|
61
|
+
tool: exaSearch,
|
|
62
|
+
description: WEB_SEARCH_DESCRIPTION,
|
|
63
|
+
});
|
|
64
|
+
} else if (process.env.TAVILY_API_KEY) {
|
|
65
|
+
tools.push({
|
|
66
|
+
name: 'web_search',
|
|
67
|
+
tool: tavilySearch,
|
|
68
|
+
description: WEB_SEARCH_DESCRIPTION,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Include skill tool if any skills are available
|
|
73
|
+
const availableSkills = discoverSkills();
|
|
74
|
+
if (availableSkills.length > 0) {
|
|
75
|
+
tools.push({
|
|
76
|
+
name: 'skill',
|
|
77
|
+
tool: skillTool,
|
|
78
|
+
description: SKILL_TOOL_DESCRIPTION,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return tools;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Get just the tool instances for binding to the LLM.
|
|
87
|
+
*
|
|
88
|
+
* @param model - The model name
|
|
89
|
+
* @returns Array of tool instances
|
|
90
|
+
*/
|
|
91
|
+
export function getTools(model: string): StructuredToolInterface[] {
|
|
92
|
+
return getToolRegistry(model).map((t) => t.tool);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Build the tool descriptions section for the system prompt.
|
|
97
|
+
* Formats each tool's rich description with a header.
|
|
98
|
+
* Groups crypto tools together under a single section.
|
|
99
|
+
*
|
|
100
|
+
* @param model - The model name
|
|
101
|
+
* @returns Formatted string with all tool descriptions
|
|
102
|
+
*/
|
|
103
|
+
export function buildToolDescriptions(model: string): string {
|
|
104
|
+
const registry = getToolRegistry(model);
|
|
105
|
+
|
|
106
|
+
// Separate invoke_api_endpoint (carries the rich crypto description) from other tools
|
|
107
|
+
const cryptoMain = registry.find((t) => t.name === 'invoke_api_endpoint');
|
|
108
|
+
const otherTools = registry.filter((t) => t.name !== 'invoke_api_endpoint');
|
|
109
|
+
|
|
110
|
+
const sections: string[] = [];
|
|
111
|
+
|
|
112
|
+
// Crypto tools section (with the rich description from invoke_api_endpoint)
|
|
113
|
+
if (cryptoMain) {
|
|
114
|
+
sections.push(`### Crypto Research Tools\n\n${cryptoMain.description}`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Other tools get individual sections
|
|
118
|
+
for (const t of otherTools) {
|
|
119
|
+
// Skip individual crypto tools from getting their own header — they're covered by the crypto section
|
|
120
|
+
const isCryptoTool = t.name.startsWith('get_') && t.name.endsWith('_endpoints')
|
|
121
|
+
|| t.name === 'get_api_endpoint_schema'
|
|
122
|
+
|| t.name === 'invoke_api_endpoint';
|
|
123
|
+
|
|
124
|
+
if (!isCryptoTool) {
|
|
125
|
+
sections.push(`### ${t.name}\n\n${t.description}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return sections.join('\n\n');
|
|
130
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { DynamicStructuredTool } from '@langchain/core/tools';
|
|
2
|
+
import { ExaSearchResults } from '@langchain/exa';
|
|
3
|
+
import Exa from 'exa-js';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { formatToolResult, parseSearchResults } from '../types.js';
|
|
6
|
+
import { logger } from '@/utils';
|
|
7
|
+
|
|
8
|
+
// Lazily initialized to avoid errors when API key is not set
|
|
9
|
+
let exaTool: ExaSearchResults | null = null;
|
|
10
|
+
|
|
11
|
+
function getExaTool(): ExaSearchResults {
|
|
12
|
+
if (!exaTool) {
|
|
13
|
+
const client = new Exa(process.env.EXASEARCH_API_KEY);
|
|
14
|
+
// exa-js@2.x (root) vs exa-js@1.x (inside @langchain/exa) have
|
|
15
|
+
// incompatible private fields but are compatible at runtime.
|
|
16
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
17
|
+
exaTool = new ExaSearchResults({
|
|
18
|
+
client: client as any,
|
|
19
|
+
searchArgs: { numResults: 5, text: true },
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
return exaTool!;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const exaSearch = new DynamicStructuredTool({
|
|
26
|
+
name: 'web_search',
|
|
27
|
+
description:
|
|
28
|
+
'Search the web for current information on any topic. Returns relevant search results with URLs and content snippets.',
|
|
29
|
+
schema: z.object({
|
|
30
|
+
query: z.string().describe('The search query to look up on the web'),
|
|
31
|
+
}),
|
|
32
|
+
func: async (input) => {
|
|
33
|
+
try {
|
|
34
|
+
const result = await getExaTool().invoke(input.query);
|
|
35
|
+
const { parsed, urls } = parseSearchResults(result);
|
|
36
|
+
return formatToolResult(parsed, urls);
|
|
37
|
+
} catch (error) {
|
|
38
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
39
|
+
logger.error(`[Exa API] error: ${message}`);
|
|
40
|
+
throw new Error(`[Exa API] ${message}`);
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { DynamicStructuredTool } from '@langchain/core/tools';
|
|
2
|
+
import { TavilySearch } from '@langchain/tavily';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { formatToolResult, parseSearchResults } from '../types.js';
|
|
5
|
+
import { logger } from '../../utils/logger.js';
|
|
6
|
+
|
|
7
|
+
// Lazily initialized to avoid errors when API key is not set
|
|
8
|
+
let tavilyClient: TavilySearch | null = null;
|
|
9
|
+
|
|
10
|
+
function getTavilyClient(): TavilySearch {
|
|
11
|
+
if (!tavilyClient) {
|
|
12
|
+
tavilyClient = new TavilySearch({ maxResults: 5 });
|
|
13
|
+
}
|
|
14
|
+
return tavilyClient;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const tavilySearch = new DynamicStructuredTool({
|
|
18
|
+
name: 'web_search',
|
|
19
|
+
description:
|
|
20
|
+
'Search the web for current information on any topic. Returns relevant search results with URLs and content snippets.',
|
|
21
|
+
schema: z.object({
|
|
22
|
+
query: z.string().describe('The search query to look up on the web'),
|
|
23
|
+
}),
|
|
24
|
+
func: async (input) => {
|
|
25
|
+
try {
|
|
26
|
+
const result = await getTavilyClient().invoke({ query: input.query });
|
|
27
|
+
const { parsed, urls } = parseSearchResults(result);
|
|
28
|
+
return formatToolResult(parsed, urls);
|
|
29
|
+
} catch (error) {
|
|
30
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
31
|
+
logger.error(`[Tavily API] error: ${message}`);
|
|
32
|
+
throw new Error(`[Tavily API] ${message}`);
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { DynamicStructuredTool } from '@langchain/core/tools';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { getSkill, discoverSkills } from '../skills/index.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Rich description for the skill tool.
|
|
7
|
+
* Used in the system prompt to guide the LLM on when and how to use this tool.
|
|
8
|
+
*/
|
|
9
|
+
export const SKILL_TOOL_DESCRIPTION = `
|
|
10
|
+
Execute a skill to get specialized instructions for complex tasks.
|
|
11
|
+
|
|
12
|
+
## When to Use
|
|
13
|
+
|
|
14
|
+
- When the user's query matches an available skill's description
|
|
15
|
+
- For complex workflows that benefit from structured guidance (e.g., token analysis, protocol research)
|
|
16
|
+
- When you need step-by-step instructions for a specialized task
|
|
17
|
+
|
|
18
|
+
## When NOT to Use
|
|
19
|
+
|
|
20
|
+
- For simple queries that don't require specialized workflows
|
|
21
|
+
- When no available skill matches the task
|
|
22
|
+
- If you've already invoked the skill for this query (don't invoke twice)
|
|
23
|
+
|
|
24
|
+
## Usage Notes
|
|
25
|
+
|
|
26
|
+
- Invoke the skill IMMEDIATELY when relevant, as your first action
|
|
27
|
+
- The skill returns instructions that you should follow to complete the task
|
|
28
|
+
- Use the skill name exactly as listed in Available Skills
|
|
29
|
+
- Pass any relevant arguments (like token symbols or protocol names) via the args parameter
|
|
30
|
+
`.trim();
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Skill invocation tool.
|
|
34
|
+
* Loads and returns skill instructions for the agent to follow.
|
|
35
|
+
*/
|
|
36
|
+
export const skillTool = new DynamicStructuredTool({
|
|
37
|
+
name: 'skill',
|
|
38
|
+
description: 'Execute a skill to get specialized instructions for a task. Returns instructions to follow.',
|
|
39
|
+
schema: z.object({
|
|
40
|
+
skill: z.string().describe('Name of the skill to invoke'),
|
|
41
|
+
args: z.string().optional().describe('Optional arguments for the skill (e.g., protocol name, token symbol)'),
|
|
42
|
+
}),
|
|
43
|
+
func: async ({ skill, args }) => {
|
|
44
|
+
const skillDef = getSkill(skill);
|
|
45
|
+
|
|
46
|
+
if (!skillDef) {
|
|
47
|
+
const available = discoverSkills().map((s) => s.name).join(', ');
|
|
48
|
+
return `Error: Skill "${skill}" not found. Available skills: ${available || 'none'}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Return instructions with optional args context
|
|
52
|
+
let result = `## Skill: ${skillDef.name}\n\n`;
|
|
53
|
+
|
|
54
|
+
if (args) {
|
|
55
|
+
result += `**Arguments provided:** ${args}\n\n`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
result += skillDef.instructions;
|
|
59
|
+
|
|
60
|
+
return result;
|
|
61
|
+
},
|
|
62
|
+
});
|