lynkr 8.0.0 → 8.0.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.
Files changed (102) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/src/api/openai-router.js +34 -2
  4. package/src/clients/standard-tools.js +23 -0
  5. package/src/config/index.js +20 -0
  6. package/src/orchestrator/index.js +2 -2
  7. package/src/server.js +2 -12
  8. package/src/tools/index.js +4 -0
  9. package/src/tools/lazy-loader.js +7 -0
  10. package/src/tools/tinyfish.js +358 -0
  11. package/src/tools/truncate.js +1 -0
  12. package/.github/FUNDING.yml +0 -15
  13. package/.github/workflows/README.md +0 -215
  14. package/.github/workflows/ci.yml +0 -69
  15. package/.github/workflows/index.yml +0 -62
  16. package/.github/workflows/web-tools-tests.yml +0 -56
  17. package/CITATIONS.bib +0 -6
  18. package/DEPLOYMENT.md +0 -1001
  19. package/LYNKR-TUI-PLAN.md +0 -984
  20. package/PERFORMANCE-REPORT.md +0 -866
  21. package/PLAN-per-client-model-routing.md +0 -252
  22. package/docs/42642f749da6234f41b6b425c3bb07c9.txt +0 -1
  23. package/docs/BingSiteAuth.xml +0 -4
  24. package/docs/docs-style.css +0 -478
  25. package/docs/docs.html +0 -198
  26. package/docs/google5be250e608e6da39.html +0 -1
  27. package/docs/index.html +0 -577
  28. package/docs/index.md +0 -584
  29. package/docs/robots.txt +0 -4
  30. package/docs/sitemap.xml +0 -44
  31. package/docs/style.css +0 -1223
  32. package/docs/toon-integration-spec.md +0 -130
  33. package/documentation/README.md +0 -101
  34. package/documentation/api.md +0 -806
  35. package/documentation/claude-code-cli.md +0 -679
  36. package/documentation/codex-cli.md +0 -397
  37. package/documentation/contributing.md +0 -571
  38. package/documentation/cursor-integration.md +0 -734
  39. package/documentation/docker.md +0 -874
  40. package/documentation/embeddings.md +0 -762
  41. package/documentation/faq.md +0 -713
  42. package/documentation/features.md +0 -403
  43. package/documentation/headroom.md +0 -519
  44. package/documentation/installation.md +0 -758
  45. package/documentation/memory-system.md +0 -476
  46. package/documentation/production.md +0 -636
  47. package/documentation/providers.md +0 -1009
  48. package/documentation/routing.md +0 -476
  49. package/documentation/testing.md +0 -629
  50. package/documentation/token-optimization.md +0 -325
  51. package/documentation/tools.md +0 -697
  52. package/documentation/troubleshooting.md +0 -969
  53. package/final-test.js +0 -33
  54. package/headroom-sidecar/config.py +0 -93
  55. package/headroom-sidecar/requirements.txt +0 -14
  56. package/headroom-sidecar/server.py +0 -451
  57. package/monitor-agents.sh +0 -31
  58. package/scripts/audit-log-reader.js +0 -399
  59. package/scripts/compact-dictionary.js +0 -204
  60. package/scripts/test-deduplication.js +0 -448
  61. package/src/db/database.sqlite +0 -0
  62. package/te +0 -11622
  63. package/test/README.md +0 -212
  64. package/test/azure-openai-config.test.js +0 -213
  65. package/test/azure-openai-error-resilience.test.js +0 -238
  66. package/test/azure-openai-format-conversion.test.js +0 -354
  67. package/test/azure-openai-integration.test.js +0 -287
  68. package/test/azure-openai-routing.test.js +0 -175
  69. package/test/azure-openai-streaming.test.js +0 -171
  70. package/test/bedrock-integration.test.js +0 -457
  71. package/test/comprehensive-test-suite.js +0 -928
  72. package/test/config-validation.test.js +0 -207
  73. package/test/cursor-integration.test.js +0 -484
  74. package/test/format-conversion.test.js +0 -578
  75. package/test/hybrid-routing-integration.test.js +0 -269
  76. package/test/hybrid-routing-performance.test.js +0 -428
  77. package/test/llamacpp-integration.test.js +0 -882
  78. package/test/lmstudio-integration.test.js +0 -347
  79. package/test/memory/extractor.test.js +0 -398
  80. package/test/memory/retriever.test.js +0 -613
  81. package/test/memory/retriever.test.js.bak +0 -585
  82. package/test/memory/search.test.js +0 -537
  83. package/test/memory/search.test.js.bak +0 -389
  84. package/test/memory/store.test.js +0 -344
  85. package/test/memory/store.test.js.bak +0 -312
  86. package/test/memory/surprise.test.js +0 -300
  87. package/test/memory-performance.test.js +0 -472
  88. package/test/openai-integration.test.js +0 -683
  89. package/test/openrouter-error-resilience.test.js +0 -418
  90. package/test/passthrough-mode.test.js +0 -385
  91. package/test/performance-benchmark.js +0 -351
  92. package/test/performance-tests.js +0 -528
  93. package/test/routing.test.js +0 -225
  94. package/test/toon-compression.test.js +0 -131
  95. package/test/web-tools.test.js +0 -329
  96. package/test-agents-simple.js +0 -43
  97. package/test-cli-connection.sh +0 -33
  98. package/test-learning-unit.js +0 -126
  99. package/test-learning.js +0 -112
  100. package/test-parallel-agents.sh +0 -124
  101. package/test-parallel-direct.js +0 -155
  102. package/test-subagents.sh +0 -117
package/README.md CHANGED
@@ -50,7 +50,7 @@ Lynkr is a **self-hosted proxy server** that unlocks Claude Code CLI , Cursor ID
50
50
  npm install -g pino-pretty
51
51
  npm install -g lynkr
52
52
 
53
- lynk start
53
+ lynkr start
54
54
  ```
55
55
 
56
56
  **Option 2: Git Clone**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lynkr",
3
- "version": "8.0.0",
3
+ "version": "8.0.1",
4
4
  "description": "Self-hosted Claude Code & Cursor proxy with Databricks,AWS BedRock,Azure adapters, openrouter, Ollama,llamacpp,LM Studio, workspace tooling, and MCP integration.",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -126,6 +126,13 @@ const CLIENT_TOOL_MAPPINGS = {
126
126
  mapArgs: (a) => ({
127
127
  query: a.query || ""
128
128
  })
129
+ },
130
+ "WebAgent": {
131
+ name: "web_agent",
132
+ mapArgs: (a) => ({
133
+ url: a.url || "",
134
+ goal: a.goal || ""
135
+ })
129
136
  }
130
137
  },
131
138
 
@@ -181,6 +188,13 @@ const CLIENT_TOOL_MAPPINGS = {
181
188
  path: a.path || a.directory || ".",
182
189
  recursive: false
183
190
  })
191
+ },
192
+ "WebAgent": {
193
+ name: "web_agent",
194
+ mapArgs: (a) => ({
195
+ url: a.url || "",
196
+ goal: a.goal || ""
197
+ })
184
198
  }
185
199
  },
186
200
 
@@ -237,6 +251,13 @@ const CLIENT_TOOL_MAPPINGS = {
237
251
  path: a.path || a.directory || ".",
238
252
  recursive: false
239
253
  })
254
+ },
255
+ "WebAgent": {
256
+ name: "web_agent",
257
+ mapArgs: (a) => ({
258
+ url: a.url || "",
259
+ goal: a.goal || ""
260
+ })
240
261
  }
241
262
  },
242
263
 
@@ -287,6 +308,13 @@ const CLIENT_TOOL_MAPPINGS = {
287
308
  mapArgs: (a) => ({
288
309
  filepath: a.path || a.directory || "."
289
310
  })
311
+ },
312
+ "WebAgent": {
313
+ name: "web_agent",
314
+ mapArgs: (a) => ({
315
+ url: a.url || "",
316
+ goal: a.goal || ""
317
+ })
290
318
  }
291
319
  }
292
320
  };
@@ -406,8 +434,10 @@ router.post("/chat/completions", async (req, res) => {
406
434
  // that have a mapping in CLIENT_TOOL_MAPPINGS — this ensures clients like
407
435
  // Codex don't see tools they can't handle (Task, WebFetch, NotebookEdit)
408
436
  // while Claude Code (unknown client) gets the full IDE_SAFE_TOOLS set.
437
+ // Skip injection if client explicitly opted out (tool_choice: "none" or empty tools array).
409
438
  const clientType = detectClient(req.headers);
410
- if (!anthropicRequest.tools || anthropicRequest.tools.length === 0) {
439
+ const clientExplicitlyDisabledTools = req.body.tool_choice === "none" || Array.isArray(req.body.tools);
440
+ if (!clientExplicitlyDisabledTools && (!anthropicRequest.tools || anthropicRequest.tools.length === 0)) {
411
441
  const clientMappings = CLIENT_TOOL_MAPPINGS[clientType];
412
442
  const clientTools = clientMappings
413
443
  ? IDE_SAFE_TOOLS.filter(t => clientMappings[t.name])
@@ -1450,8 +1480,10 @@ router.post("/responses", async (req, res) => {
1450
1480
  }, "After Chat→Anthropic conversion");
1451
1481
 
1452
1482
  // Inject tools if client didn't send any (same two-layer filtering as chat/completions).
1483
+ // Skip injection if client explicitly opted out (tool_choice: "none" or empty tools array).
1453
1484
  const clientType = detectClient(req.headers);
1454
- if (!anthropicRequest.tools || anthropicRequest.tools.length === 0) {
1485
+ const clientExplicitlyDisabledTools = req.body.tool_choice === "none" || Array.isArray(req.body.tools);
1486
+ if (!clientExplicitlyDisabledTools && (!anthropicRequest.tools || anthropicRequest.tools.length === 0)) {
1455
1487
  const clientMappings = CLIENT_TOOL_MAPPINGS[clientType];
1456
1488
  const clientTools = clientMappings
1457
1489
  ? IDE_SAFE_TOOLS.filter(t => clientMappings[t.name])
@@ -380,6 +380,29 @@ EXAMPLE: User says "explore this project" → Call Task with subagent_type="Expl
380
380
  required: ["url", "prompt"]
381
381
  }
382
382
  },
383
+ {
384
+ name: "WebAgent",
385
+ description: "Launches a browser agent to navigate a website and accomplish a goal. Use when you need to interact with dynamic web content (click buttons, fill forms, extract data from JS-rendered pages) beyond what a simple HTTP fetch can do. Returns structured JSON. Takes 10-60 seconds.",
386
+ input_schema: {
387
+ type: "object",
388
+ properties: {
389
+ url: {
390
+ type: "string",
391
+ description: "Target URL to navigate to"
392
+ },
393
+ goal: {
394
+ type: "string",
395
+ description: "What to accomplish on the page. Be specific about what data to extract or actions to take."
396
+ },
397
+ browser_profile: {
398
+ type: "string",
399
+ enum: ["lite", "stealth"],
400
+ description: "lite (default, faster) or stealth (for bot-protected sites)"
401
+ }
402
+ },
403
+ required: ["url", "goal"]
404
+ }
405
+ },
383
406
  {
384
407
  name: "NotebookEdit",
385
408
  description: "Completely replaces the contents of a specific cell in a Jupyter notebook (.ipynb file). Use for editing interactive documents that combine code, text, and visualizations.",
@@ -397,6 +397,14 @@ const webFetchBodyPreviewMax = Number.parseInt(process.env.WEB_FETCH_BODY_PREVIE
397
397
  const webSearchRetryEnabled = process.env.WEB_SEARCH_RETRY_ENABLED !== "false"; // default true
398
398
  const webSearchMaxRetries = Number.parseInt(process.env.WEB_SEARCH_MAX_RETRIES ?? "2", 10);
399
399
 
400
+ // TinyFish AI Browser Automation configuration
401
+ const tinyfishApiKey = process.env.TINYFISH_API_KEY?.trim() || null;
402
+ const tinyfishEndpoint = process.env.TINYFISH_ENDPOINT?.trim() || "https://agent.tinyfish.ai/v1/automation/run-sse";
403
+ const tinyfishBrowserProfile = process.env.TINYFISH_BROWSER_PROFILE?.trim() || "lite";
404
+ const tinyfishTimeoutMs = parseInt(process.env.TINYFISH_TIMEOUT_MS ?? "120000", 10);
405
+ const tinyfishProxyEnabled = process.env.TINYFISH_PROXY_ENABLED === "true";
406
+ const tinyfishProxyCountry = process.env.TINYFISH_PROXY_COUNTRY?.trim() || "US";
407
+
400
408
  const policyMaxSteps = Number.parseInt(process.env.POLICY_MAX_STEPS ?? "8", 10);
401
409
  const policyMaxToolCalls = Number.parseInt(process.env.POLICY_MAX_TOOL_CALLS ?? "12", 10);
402
410
  const policyToolLoopThreshold = Number.parseInt(process.env.POLICY_TOOL_LOOP_THRESHOLD ?? "10", 10);
@@ -660,6 +668,14 @@ var config = {
660
668
  retryEnabled: webSearchRetryEnabled,
661
669
  maxRetries: Number.isNaN(webSearchMaxRetries) ? 2 : webSearchMaxRetries,
662
670
  },
671
+ tinyfish: {
672
+ apiKey: tinyfishApiKey,
673
+ endpoint: tinyfishEndpoint,
674
+ browserProfile: tinyfishBrowserProfile,
675
+ timeoutMs: Number.isNaN(tinyfishTimeoutMs) ? 120000 : tinyfishTimeoutMs,
676
+ proxyEnabled: tinyfishProxyEnabled,
677
+ proxyCountry: tinyfishProxyCountry,
678
+ },
663
679
  policy: {
664
680
  maxStepsPerTurn: Number.isNaN(policyMaxSteps) ? 8 : policyMaxSteps,
665
681
  maxToolCallsPerTurn: Number.isNaN(policyMaxToolCalls) ? 12 : policyMaxToolCalls,
@@ -938,6 +954,10 @@ function reloadConfig() {
938
954
  config.modelProvider.fallbackProvider = (process.env.FALLBACK_PROVIDER ?? "databricks").toLowerCase();
939
955
  config.modelProvider.suggestionModeModel = (process.env.SUGGESTION_MODE_MODEL ?? "default").trim();
940
956
 
957
+ // TinyFish config reload
958
+ config.tinyfish.apiKey = process.env.TINYFISH_API_KEY?.trim() || null;
959
+ config.tinyfish.browserProfile = process.env.TINYFISH_BROWSER_PROFILE?.trim() || "lite";
960
+
941
961
  config.toon.enabled = process.env.TOON_ENABLED === "true";
942
962
  const newToonMinBytes = Number.parseInt(process.env.TOON_MIN_BYTES ?? "4096", 10);
943
963
  config.toon.minBytes = Number.isNaN(newToonMinBytes) ? 4096 : newToonMinBytes;
@@ -1358,7 +1358,7 @@ function sanitizePayload(payload) {
1358
1358
  clean.tools = selectedTools.length > 0 ? selectedTools : undefined;
1359
1359
  }
1360
1360
 
1361
- clean.stream = payload.stream ?? false;
1361
+ clean.stream = payload?.stream ?? false;
1362
1362
 
1363
1363
  if (
1364
1364
  config.modelProvider?.type === "azure-anthropic" &&
@@ -2248,7 +2248,7 @@ IMPORTANT TOOL USAGE RULES:
2248
2248
  const serverSideToolCalls = [];
2249
2249
  const clientSideToolCalls = [];
2250
2250
 
2251
- const SERVER_SIDE_TOOLS = new Set(["task", "web_search", "web_fetch", "websearch", "webfetch"]);
2251
+ const SERVER_SIDE_TOOLS = new Set(["task", "web_search", "web_fetch", "websearch", "webfetch", "web_agent"]);
2252
2252
 
2253
2253
  for (const call of toolCalls) {
2254
2254
  const toolName = (call.function?.name ?? call.name ?? "").toLowerCase();
package/src/server.js CHANGED
@@ -78,18 +78,8 @@ function createApp() {
78
78
  // Metrics collection
79
79
  app.use(metricsMiddleware);
80
80
 
81
- // Enable compression for all responses (gzip/deflate)
82
- app.use(compression({
83
- level: 6, // Balanced compression level
84
- threshold: 1024, // Only compress responses > 1KB
85
- filter: (req, res) => {
86
- // Don't compress event streams
87
- if (res.getHeader('Content-Type') === 'text/event-stream') {
88
- return false;
89
- }
90
- return compression.filter(req, res);
91
- }
92
- }));
81
+ // Note: If using a tunnel (ngrok, Cloudflare Tunnel) and seeing BrotliDecompressionError,
82
+ // start ngrok with: ngrok http 8081 --request-header-remove "Accept-Encoding"
93
83
 
94
84
  app.use(express.json({ limit: config.server.jsonLimit }));
95
85
  app.use(sessionMiddleware);
@@ -30,6 +30,10 @@ const TOOL_ALIASES = {
30
30
  WebSearch: "web_search",
31
31
  web_fetch: "web_fetch",
32
32
  webfetch: "web_fetch",
33
+ web_agent: "web_agent",
34
+ webagent: "web_agent",
35
+ WebAgent: "web_agent",
36
+ tinyfish: "web_agent",
33
37
  task: "fs_write",
34
38
  write: "fs_write",
35
39
  filewrite: "fs_write",
@@ -57,6 +57,11 @@ const TOOL_CATEGORIES = {
57
57
  loader: () => require('./tasks').registerTaskTools,
58
58
  priority: 2,
59
59
  },
60
+ tinyfish: {
61
+ keywords: ['tinyfish', 'web_agent', 'automate', 'scrape', 'extract', 'crawl', 'browser'],
62
+ loader: () => require('./tinyfish').registerTinyFishTools,
63
+ priority: 2,
64
+ },
60
65
  tests: {
61
66
  keywords: ['test', 'jest', 'mocha', 'pytest', 'unittest', 'spec', 'coverage', 'assert'],
62
67
  loader: () => require('./tests').registerTestTools,
@@ -277,6 +282,8 @@ function loadCategoryForTool(toolName) {
277
282
  'workspace_mcp_servers': 'mcp',
278
283
 
279
284
  // Agent task
285
+ // TinyFish (web agent)
286
+ 'web_agent': 'tinyfish',
280
287
  'agent_task': 'agentTask',
281
288
  };
282
289
 
@@ -0,0 +1,358 @@
1
+ const { URL } = require("url");
2
+ const { Agent } = require("undici");
3
+ const config = require("../config");
4
+ const logger = require("../logger");
5
+ const { registerTool } = require(".");
6
+
7
+ /**
8
+ * Dedicated HTTP agent for TinyFish SSE streams.
9
+ * The default webAgent in web-client.js has a 30s bodyTimeout which is too
10
+ * short for browser-automation tasks that can take up to 120s.
11
+ */
12
+ const sseAgent = new Agent({
13
+ connections: 10,
14
+ pipelining: 1,
15
+ keepAliveTimeout: 60000,
16
+ connectTimeout: 15000,
17
+ bodyTimeout: 0, // no body timeout — we manage timeout via AbortController
18
+ headersTimeout: 15000,
19
+ maxRedirections: 3,
20
+ strictContentLength: false,
21
+ });
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Argument normalisers
25
+ // ---------------------------------------------------------------------------
26
+
27
+ function normalizeUrl(args) {
28
+ const raw = args.url ?? args.uri ?? args.href ?? args.target_url;
29
+ if (typeof raw !== "string" || raw.trim().length === 0) {
30
+ throw new Error("web_agent requires a non-empty url string.");
31
+ }
32
+ // Validate URL
33
+ try {
34
+ new URL(raw.trim());
35
+ } catch {
36
+ throw new Error(`web_agent received an invalid URL: ${raw}`);
37
+ }
38
+ return raw.trim();
39
+ }
40
+
41
+ function normalizeGoal(args) {
42
+ const goal = args.goal ?? args.task ?? args.prompt ?? args.instruction;
43
+ if (typeof goal !== "string" || goal.trim().length === 0) {
44
+ throw new Error("web_agent requires a non-empty goal string.");
45
+ }
46
+ return goal.trim();
47
+ }
48
+
49
+ function resolveBrowserProfile(args) {
50
+ const profile = args.browser_profile ?? args.browserProfile ?? config.tinyfish.browserProfile;
51
+ if (profile === "stealth") return "stealth";
52
+ return "lite";
53
+ }
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // SSE stream consumer
57
+ // ---------------------------------------------------------------------------
58
+
59
+ async function consumeSSEStream(response, timeoutMs) {
60
+ const reader = response.body.getReader();
61
+ const decoder = new TextDecoder();
62
+ let buffer = "";
63
+ const startTime = Date.now();
64
+
65
+ try {
66
+ while (true) {
67
+ if (Date.now() - startTime > timeoutMs) {
68
+ const err = new Error(`TinyFish SSE stream timed out after ${timeoutMs}ms`);
69
+ err.code = "ETIMEDOUT";
70
+ err.status = 504;
71
+ throw err;
72
+ }
73
+
74
+ const { done, value } = await reader.read();
75
+ if (done) break;
76
+
77
+ buffer += decoder.decode(value, { stream: true });
78
+
79
+ // SSE events are separated by double newlines
80
+ const parts = buffer.split("\n\n");
81
+ // Keep the last (possibly incomplete) chunk in the buffer
82
+ buffer = parts.pop() || "";
83
+
84
+ for (const part of parts) {
85
+ // Extract the data: line(s)
86
+ const lines = part.split("\n");
87
+ let dataStr = "";
88
+ for (const line of lines) {
89
+ if (line.startsWith("data: ")) {
90
+ dataStr += line.slice(6);
91
+ } else if (line.startsWith("data:")) {
92
+ dataStr += line.slice(5);
93
+ }
94
+ }
95
+
96
+ if (!dataStr) continue;
97
+
98
+ let event;
99
+ try {
100
+ event = JSON.parse(dataStr);
101
+ } catch {
102
+ // Not valid JSON — skip this SSE frame
103
+ logger.debug({ raw: dataStr.slice(0, 200) }, "TinyFish: non-JSON SSE frame, skipping");
104
+ continue;
105
+ }
106
+
107
+ logger.debug(
108
+ { type: event.type, status: event.status },
109
+ "TinyFish SSE event"
110
+ );
111
+
112
+ if (event.type === "COMPLETE" || event.type === "complete") {
113
+ const status = (event.status ?? "").toUpperCase();
114
+ if (status === "COMPLETED" || status === "SUCCESS") {
115
+ return event.resultJson ?? event.result ?? event.data ?? event;
116
+ }
117
+ // Task failed
118
+ const errMsg = event.error ?? event.message ?? "TinyFish task failed";
119
+ const err = new Error(typeof errMsg === "string" ? errMsg : JSON.stringify(errMsg));
120
+ err.code = "TINYFISH_TASK_FAILED";
121
+ err.status = 502;
122
+ throw err;
123
+ }
124
+ }
125
+ }
126
+
127
+ // Stream ended without a COMPLETE event
128
+ const err = new Error("TinyFish SSE stream ended without a COMPLETE event");
129
+ err.code = "TINYFISH_INCOMPLETE";
130
+ err.status = 502;
131
+ throw err;
132
+ } finally {
133
+ reader.releaseLock();
134
+ }
135
+ }
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // Core API call
139
+ // ---------------------------------------------------------------------------
140
+
141
+ async function callTinyFishAPI({ url, goal, browserProfile, proxyConfig, timeoutMs }) {
142
+ const endpoint = config.tinyfish.endpoint;
143
+ const apiKey = config.tinyfish.apiKey;
144
+
145
+ if (!apiKey) {
146
+ return {
147
+ ok: false,
148
+ status: 503,
149
+ content: JSON.stringify({
150
+ error: "tinyfish_not_configured",
151
+ message:
152
+ "TinyFish API key is not configured. Set TINYFISH_API_KEY in your .env file. Get a key from https://tinyfish.ai",
153
+ }, null, 2),
154
+ };
155
+ }
156
+
157
+ const body = {
158
+ url,
159
+ goal,
160
+ browserProfile,
161
+ };
162
+
163
+ if (proxyConfig) {
164
+ body.proxy = proxyConfig;
165
+ }
166
+
167
+ const controller = new AbortController();
168
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
169
+
170
+ try {
171
+ const response = await fetch(endpoint, {
172
+ method: "POST",
173
+ headers: {
174
+ "Content-Type": "application/json",
175
+ "X-API-Key": apiKey,
176
+ Accept: "text/event-stream",
177
+ },
178
+ body: JSON.stringify(body),
179
+ signal: controller.signal,
180
+ dispatcher: sseAgent,
181
+ });
182
+
183
+ // Handle non-2xx responses before attempting SSE parse
184
+ if (!response.ok) {
185
+ const text = await response.text().catch(() => "");
186
+ const shouldRetry = response.status === 429 || response.status >= 500;
187
+
188
+ if (shouldRetry) {
189
+ // Retry once with 2s backoff
190
+ logger.warn(
191
+ { status: response.status, body: text.slice(0, 200) },
192
+ "TinyFish API error, retrying once"
193
+ );
194
+ await new Promise((r) => setTimeout(r, 2000));
195
+
196
+ const retryController = new AbortController();
197
+ const retryTimeout = setTimeout(() => retryController.abort(), timeoutMs);
198
+ try {
199
+ const retryResponse = await fetch(endpoint, {
200
+ method: "POST",
201
+ headers: {
202
+ "Content-Type": "application/json",
203
+ "X-API-Key": apiKey,
204
+ Accept: "text/event-stream",
205
+ },
206
+ body: JSON.stringify(body),
207
+ signal: retryController.signal,
208
+ dispatcher: sseAgent,
209
+ });
210
+
211
+ if (!retryResponse.ok) {
212
+ const retryText = await retryResponse.text().catch(() => "");
213
+ const err = new Error(
214
+ `TinyFish API error (${retryResponse.status}): ${retryResponse.statusText}`
215
+ );
216
+ err.status = retryResponse.status;
217
+ err.body = retryText;
218
+ throw err;
219
+ }
220
+
221
+ const result = await consumeSSEStream(retryResponse, timeoutMs);
222
+ return {
223
+ ok: true,
224
+ status: 200,
225
+ result,
226
+ };
227
+ } finally {
228
+ clearTimeout(retryTimeout);
229
+ }
230
+ }
231
+
232
+ const err = new Error(
233
+ `TinyFish API error (${response.status}): ${response.statusText}`
234
+ );
235
+ err.status = response.status;
236
+ err.body = text;
237
+ throw err;
238
+ }
239
+
240
+ const result = await consumeSSEStream(response, timeoutMs);
241
+ return {
242
+ ok: true,
243
+ status: 200,
244
+ result,
245
+ };
246
+ } catch (error) {
247
+ if (error.name === "AbortError") {
248
+ const err = new Error(`TinyFish request timed out after ${timeoutMs}ms`);
249
+ err.code = "ETIMEDOUT";
250
+ err.status = 504;
251
+ throw err;
252
+ }
253
+ throw error;
254
+ } finally {
255
+ clearTimeout(timeout);
256
+ }
257
+ }
258
+
259
+ // ---------------------------------------------------------------------------
260
+ // Tool registration
261
+ // ---------------------------------------------------------------------------
262
+
263
+ function registerTinyFishTool() {
264
+ registerTool(
265
+ "web_agent",
266
+ async ({ args = {} }) => {
267
+ const url = normalizeUrl(args);
268
+ const goal = normalizeGoal(args);
269
+ const browserProfile = resolveBrowserProfile(args);
270
+ const timeoutMs = config.tinyfish.timeoutMs;
271
+
272
+ // Build proxy config if enabled
273
+ let proxyConfig = null;
274
+ if (config.tinyfish.proxyEnabled) {
275
+ proxyConfig = {
276
+ enabled: true,
277
+ country: config.tinyfish.proxyCountry,
278
+ };
279
+ }
280
+
281
+ try {
282
+ const response = await callTinyFishAPI({
283
+ url,
284
+ goal,
285
+ browserProfile,
286
+ proxyConfig,
287
+ timeoutMs,
288
+ });
289
+
290
+ // Guard clause: not configured
291
+ if (!response.ok && response.status === 503) {
292
+ return response;
293
+ }
294
+
295
+ const resultStr =
296
+ typeof response.result === "string"
297
+ ? response.result
298
+ : JSON.stringify(response.result, null, 2);
299
+
300
+ logger.debug(
301
+ {
302
+ url,
303
+ goal: goal.slice(0, 100),
304
+ browserProfile,
305
+ resultLength: resultStr.length,
306
+ },
307
+ "TinyFish web_agent completed"
308
+ );
309
+
310
+ return {
311
+ ok: true,
312
+ status: 200,
313
+ content: resultStr,
314
+ metadata: {
315
+ url,
316
+ goal,
317
+ browserProfile,
318
+ resultLength: resultStr.length,
319
+ },
320
+ };
321
+ } catch (err) {
322
+ logger.error(
323
+ { err, url, goal: goal.slice(0, 100) },
324
+ "web_agent request failed"
325
+ );
326
+ return {
327
+ ok: false,
328
+ status: err.status ?? 500,
329
+ content: JSON.stringify(
330
+ {
331
+ error: err.code ?? "web_agent_failed",
332
+ message: err.message,
333
+ url,
334
+ ...(err.status ? { http_status: err.status } : {}),
335
+ },
336
+ null,
337
+ 2
338
+ ),
339
+ metadata: {
340
+ url,
341
+ goal,
342
+ error_code: err.code,
343
+ ...(err.status ? { http_status: err.status } : {}),
344
+ },
345
+ };
346
+ }
347
+ },
348
+ { category: "tinyfish" }
349
+ );
350
+ }
351
+
352
+ function registerTinyFishTools() {
353
+ registerTinyFishTool();
354
+ }
355
+
356
+ module.exports = {
357
+ registerTinyFishTools,
358
+ };
@@ -8,6 +8,7 @@ const TRUNCATION_LIMITS = {
8
8
  Glob: { maxChars: 8000, strategy: 'head' },
9
9
  WebFetch: { maxChars: 16000, strategy: 'head' },
10
10
  WebSearch: { maxChars: 12000, strategy: 'head' },
11
+ WebAgent: { maxChars: 16000, strategy: 'head' },
11
12
  LSP: { maxChars: 8000, strategy: 'head' },
12
13
  Edit: { maxChars: 8000, strategy: 'middle' },
13
14
  Write: { maxChars: 8000, strategy: 'middle' },
@@ -1,15 +0,0 @@
1
- # These are supported funding model platforms
2
-
3
- github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
4
- patreon: # Replace with a single Patreon username
5
- open_collective: # Replace with a single Open Collective username
6
- ko_fi: # Replace with a single Ko-fi username
7
- tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
8
- community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
9
- liberapay: # Replace with a single Liberapay username
10
- issuehunt: # Replace with a single IssueHunt username
11
- lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
12
- polar: # Replace with a single Polar username
13
- buy_me_a_coffee: srinivasveera
14
- thanks_dev: # Replace with a single thanks.dev username
15
- custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']