Skip to content

Commit 1fc8f75

Browse files
Merge pull request #1112 from corbitsdev/refactor/tools-shell-dedup
refactor(tools-shell): dedupe shared web-fetch, convert, signal, and timer helpers
2 parents f63644d + dc49be6 commit 1fc8f75

6 files changed

Lines changed: 92 additions & 59 deletions

File tree

‎src/exec/runner.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -916,7 +916,7 @@ export async function runExec(config: Config): Promise<ExecResult> {
916916
handshake,
917917
).catch((err: unknown) => {
918918
logger.warn("MCP connect failed: {error}", {
919-
error: err instanceof Error ? err.message : String(err),
919+
error: formatCaughtError(err),
920920
});
921921
});
922922
await awaitExecMcpThenResume(connecting, () => workflowHost.resume(), {

‎src/shell/background-shell.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,9 @@ export function createBackgroundShellRegistry(
155155
child.stdout?.on("data", (chunk: Buffer) => collector.append(chunk));
156156
child.stderr?.on("data", (chunk: Buffer) => collector.append(chunk));
157157
let timer: ReturnType<typeof setTimeout> | undefined;
158+
// Clearing an unset timer is a no-op, so one unguarded closure covers
159+
// every settle path below.
160+
const clearTimer = (): void => clearTimeout(timer);
158161
// Per-call timeout only — background has no 120s default. Cancel reuses
159162
// killProcessTree (same path as shell_collect action=cancel).
160163
if (args.timeoutMs !== undefined && args.timeoutMs > 0) {
@@ -164,11 +167,11 @@ export function createBackgroundShellRegistry(
164167
}, args.timeoutMs);
165168
}
166169
child.on("error", () => {
167-
if (timer !== undefined) clearTimeout(timer);
170+
clearTimer();
168171
finish(1, false);
169172
});
170173
child.on("exit", (code, sig) => {
171-
if (timer !== undefined) clearTimeout(timer);
174+
clearTimer();
172175
if (!running.has(id)) return;
173176
const exitCode = code ?? (sig !== null ? 128 : 1);
174177
const settle = (): void => finish(exitCode, false);

‎src/tools/html-convert.ts‎

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -25,20 +25,27 @@ function stripTagsAndScripts(html: string): string {
2525
.replace(/<!--[\s\S]*?-->/g, "");
2626
}
2727

28-
export function htmlToText(html: string): string {
29-
const cleaned = stripTagsAndScripts(html)
30-
.replace(/<br\s*\/?>/gi, "\n")
31-
.replace(/<\/(p|div|li|h[1-6]|tr)>/gi, "\n")
32-
.replace(/<[^>]+>/g, "");
33-
return decodeEntities(cleaned)
28+
function normalizeWhitespace(
29+
text: string,
30+
mapLine: (line: string) => string,
31+
): string {
32+
return text
3433
.replace(/[ \t]+/g, " ")
3534
.replace(/\n{3,}/g, "\n\n")
3635
.split("\n")
37-
.map((line) => line.trim())
36+
.map(mapLine)
3837
.join("\n")
3938
.trim();
4039
}
4140

41+
export function htmlToText(html: string): string {
42+
const cleaned = stripTagsAndScripts(html)
43+
.replace(/<br\s*\/?>/gi, "\n")
44+
.replace(/<\/(p|div|li|h[1-6]|tr)>/gi, "\n")
45+
.replace(/<[^>]+>/g, "");
46+
return normalizeWhitespace(decodeEntities(cleaned), (line) => line.trim());
47+
}
48+
4249
// Best-effort structural markdown: headings, bold/italic, links, list items.
4350
// Anything not recognized falls through to plain text extraction.
4451
export function htmlToMarkdown(html: string): string {
@@ -76,11 +83,5 @@ export function htmlToMarkdown(html: string): string {
7683
.replace(/<\/(p|div|tr|table|ul|ol)>/gi, "\n")
7784
.replace(/<br\s*\/?>/gi, "\n");
7885
const text = decodeEntities(working.replace(/<[^>]+>/g, ""));
79-
return text
80-
.replace(/[ \t]+/g, " ")
81-
.replace(/\n{3,}/g, "\n\n")
82-
.split("\n")
83-
.map((line) => line.replace(/[ \t]+$/g, ""))
84-
.join("\n")
85-
.trim();
86+
return normalizeWhitespace(text, (line) => line.replace(/[ \t]+$/g, ""));
8687
}

‎src/tools/web-fetch.test.ts‎

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ import {
1212

1313
let server: Server;
1414
let baseUrl: string;
15+
// Shared by every tool invocation below: none of these tests abort, so one
16+
// never-aborted signal covers the whole file.
17+
const neverAbortedSignal = new AbortController().signal;
1518
let handler: (
1619
req: import("node:http").IncomingMessage,
1720
res: import("node:http").ServerResponse,
@@ -194,7 +197,7 @@ describe("createExaMCPWebFetchTool", () => {
194197
name: "web_fetch",
195198
arguments: { url: "https://example.com", timeout: 1 },
196199
},
197-
new AbortController().signal,
200+
neverAbortedSignal,
198201
);
199202

200203
expect(result).toEqual({
@@ -214,11 +217,8 @@ describe("createExaMCPWebFetchTool", () => {
214217
arguments: { url: "ftp://example.com/file" },
215218
};
216219

217-
const nativeResult = await nativeRunner.run(
218-
call,
219-
new AbortController().signal,
220-
);
221-
const exaResult = await exaRunner.run(call, new AbortController().signal);
220+
const nativeResult = await nativeRunner.run(call, neverAbortedSignal);
221+
const exaResult = await exaRunner.run(call, neverAbortedSignal);
222222

223223
expect(nativeResult).toEqual({
224224
callId: "protocol-call",
@@ -249,8 +249,8 @@ describe("createExaMCPWebFetchTool", () => {
249249
};
250250

251251
const [nativeResult, exaResult] = await Promise.all([
252-
nativeRunner.run(call, new AbortController().signal),
253-
exaRunner.run(call, new AbortController().signal),
252+
nativeRunner.run(call, neverAbortedSignal),
253+
exaRunner.run(call, neverAbortedSignal),
254254
]);
255255

256256
expect(nativeResult).toEqual({
@@ -273,7 +273,7 @@ describe("createExaMCPWebFetchTool", () => {
273273
name: "web_fetch",
274274
arguments: { url: `${baseUrl}/`, format },
275275
},
276-
new AbortController().signal,
276+
neverAbortedSignal,
277277
);
278278
if (typeof result === "string")
279279
throw new Error("expected a full tool result");

‎src/tools/web-fetch.ts‎

Lines changed: 54 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,37 @@ const WebFetchArgs = type({
4343

4444
type WebFetchFormat = "text" | "markdown" | "html";
4545

46+
type ParsedWebFetchArgs =
47+
| { ok: true; url: string; format: WebFetchFormat; timeout: number }
48+
| { ok: false; error: string };
49+
50+
function parseWebFetchArgs(
51+
rawArgs: Record<string, unknown>,
52+
): ParsedWebFetchArgs {
53+
const parsed = WebFetchArgs(rawArgs);
54+
if (parsed instanceof type.errors) {
55+
return {
56+
ok: false,
57+
error:
58+
"Error: web_fetch requires a non-empty url (http/https); format and timeout are optional.",
59+
};
60+
}
61+
return {
62+
ok: true,
63+
url: parsed.url,
64+
format: parsed.format ?? "markdown",
65+
timeout: parsed.timeout ?? DEFAULT_TIMEOUT_S,
66+
};
67+
}
68+
69+
function truncationSuffix(truncated: boolean): string {
70+
return truncated ? `\n\n[content truncated at ${MAX_FETCH_BYTES} bytes]` : "";
71+
}
72+
73+
function fetchTimeoutMessage(url: string, seconds: number): string {
74+
return `Request to ${url} timed out after ${seconds}s. Retry with a larger timeout parameter (up to 120s) if the site is slow.`;
75+
}
76+
4677
export const webFetchDefinition: ToolDefinition = {
4778
name: "web_fetch",
4879
description:
@@ -166,7 +197,7 @@ export async function runWebFetch(
166197
if (err instanceof Error && err.name === "AbortError") {
167198
return {
168199
ok: false,
169-
error: `Request to ${currentUrl} timed out after ${clampedSeconds}s. Retry with a larger timeout parameter (up to 120s) if the site is slow.`,
200+
error: fetchTimeoutMessage(currentUrl, clampedSeconds),
170201
};
171202
}
172203
return {
@@ -233,18 +264,16 @@ export function createWebFetchTool(options?: WebFetchTimingOptions): AgentTool {
233264
return stringTool({
234265
definition: webFetchDefinition,
235266
handler: async (rawArgs: Record<string, unknown>): Promise<string> => {
236-
const parsed = WebFetchArgs(rawArgs);
237-
if (parsed instanceof type.errors) {
238-
return "Error: web_fetch requires a non-empty url (http/https); format and timeout are optional.";
239-
}
240-
const format = parsed.format ?? "markdown";
241-
const timeout = parsed.timeout ?? DEFAULT_TIMEOUT_S;
242-
const outcome = await runWebFetch(parsed.url, format, timeout, options);
267+
const parsed = parseWebFetchArgs(rawArgs);
268+
if (!parsed.ok) return parsed.error;
269+
const outcome = await runWebFetch(
270+
parsed.url,
271+
parsed.format,
272+
parsed.timeout,
273+
options,
274+
);
243275
if (!outcome.ok) return `Error: ${outcome.error}`;
244-
const suffix = outcome.truncated
245-
? `\n\n[content truncated at ${MAX_FETCH_BYTES} bytes]`
246-
: "";
247-
return outcome.content + suffix;
276+
return outcome.content + truncationSuffix(outcome.truncated);
248277
},
249278
});
250279
}
@@ -266,13 +295,9 @@ export function createExaMCPWebFetchTool(
266295
call: ToolCall,
267296
signal: AbortSignal,
268297
): Promise<ToolResult> => {
269-
const parsed = WebFetchArgs(call.arguments);
270-
if (parsed instanceof type.errors) {
271-
return {
272-
callId: call.id,
273-
content:
274-
"Error: web_fetch requires a non-empty url (http/https); format and timeout are optional.",
275-
};
298+
const parsed = parseWebFetchArgs(call.arguments);
299+
if (!parsed.ok) {
300+
return { callId: call.id, content: parsed.error };
276301
}
277302

278303
let url: URL;
@@ -291,17 +316,17 @@ export function createExaMCPWebFetchTool(
291316
};
292317
}
293318

294-
const format = parsed.format ?? "markdown";
295-
const timeout = parsed.timeout ?? DEFAULT_TIMEOUT_S;
319+
const format = parsed.format;
320+
const timeout = parsed.timeout;
296321
if (format !== "markdown") {
297322
const outcome = await runWebFetch(parsed.url, format, timeout);
298323
if (!outcome.ok) {
299324
return { callId: call.id, content: `Error: ${outcome.error}` };
300325
}
301-
const suffix = outcome.truncated
302-
? `\n\n[content truncated at ${MAX_FETCH_BYTES} bytes]`
303-
: "";
304-
return { callId: call.id, content: outcome.content + suffix };
326+
return {
327+
callId: call.id,
328+
content: outcome.content + truncationSuffix(outcome.truncated),
329+
};
305330
}
306331

307332
const timeoutSeconds = Math.min(Math.max(timeout, 1), MAX_TIMEOUT_S);
@@ -352,7 +377,10 @@ export function createExaMCPWebFetchTool(
352377
if (err === timeoutError) {
353378
return {
354379
callId: call.id,
355-
content: `Error: Request to ${parsed.url} timed out after ${timeoutSeconds}s. Retry with a larger timeout parameter (up to 120s) if the site is slow.`,
380+
content: `Error: ${fetchTimeoutMessage(
381+
parsed.url,
382+
timeoutSeconds,
383+
)}`,
356384
};
357385
}
358386
return {

‎src/tools/web-search.test.ts‎

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ const { createPermissionGate } = await import("../permission/gate.js");
4444

4545
const BUILTIN_EXA_MCP = createExaMCPServerConfig();
4646

47+
// Shared by every tool invocation below: none of these tests abort, so one
48+
// never-aborted signal covers the whole file.
49+
const neverAbortedSignal = new AbortController().signal;
50+
4751
async function connectConfiguredMCP(
4852
mcpServers?: ResolvedMCPServerConfig[],
4953
): Promise<void> {
@@ -123,7 +127,7 @@ describe("createWebSearchTool", () => {
123127
if (tool.kind !== "string") throw new Error("expected a string tool");
124128
const result = await tool.handler(
125129
{ query: "corbits code" },
126-
new AbortController().signal,
130+
neverAbortedSignal,
127131
);
128132
expect(result).toBe("mock result");
129133
expect(connectConfigs[0]?.url).toBe(EXA_MCP_URL);
@@ -148,7 +152,7 @@ describe("createWebSearchTool", () => {
148152
livecrawl: "preferred",
149153
contextMaxCharacters: 500,
150154
},
151-
new AbortController().signal,
155+
neverAbortedSignal,
152156
);
153157
expect(calls[0]?.args).toEqual({
154158
query: "q",
@@ -163,18 +167,15 @@ describe("createWebSearchTool", () => {
163167
process.env.CORBITS_WEB_SEARCH_PROVIDER = "parallel";
164168
const tool = createWebSearchTool();
165169
if (tool.kind !== "string") throw new Error("expected a string tool");
166-
await tool.handler({ query: "corbits code" }, new AbortController().signal);
170+
await tool.handler({ query: "corbits code" }, neverAbortedSignal);
167171
expect(connectConfigs[0]?.url).toBe(PARALLEL_MCP_URL);
168172
expect(calls[0]?.toolName).toBe("web_search");
169173
});
170174

171175
test("rejects an empty query before ever connecting", async () => {
172176
const tool = createWebSearchTool();
173177
if (tool.kind !== "string") throw new Error("expected a string tool");
174-
const result = await tool.handler(
175-
{ query: "" },
176-
new AbortController().signal,
177-
);
178+
const result = await tool.handler({ query: "" }, neverAbortedSignal);
178179
expect(result).toContain("Error");
179180
expect(connectConfigs.length).toBe(0);
180181
});

0 commit comments

Comments
 (0)