Skip to content

Commit f6e5b93

Browse files
committed
refactor(tools): derive tool inputSchemas from arktype types
Hand-written JSON Schema for web_fetch, web_search and delete_file duplicated the arktype arg types and had already drifted (no minLength, documented timeout intent living outside the schema). The types are now the single source via toJsonSchema with descriptions carried as arktype metadata; served shape keeps its exact key set (dialect: null) and generated-vs-hand diffs are pinned in tests. arktype moves 2.2.3 -> 2.2.8; an override keeps the first-party and vendored consumers on one type identity (two arktype versions in one program defeat portable type naming).
1 parent 1004268 commit f6e5b93

9 files changed

Lines changed: 201 additions & 71 deletions

File tree

‎bun.lock‎

Lines changed: 6 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,11 +72,12 @@
7272
"@intx/tools-posix": "workspace:*",
7373
"@intx/mailbox": "workspace:*",
7474
"@intx/harness": "workspace:*",
75-
"@intx/mime": "workspace:*"
75+
"@intx/mime": "workspace:*",
76+
"arktype": "^2.2.8"
7677
},
7778
"catalog": {
7879
"@types/semver": "^7.7.1",
79-
"arktype": "^2.2.3",
80+
"arktype": "^2.2.8",
8081
"better-auth": "^1.4.18",
8182
"drizzle-orm": "^0.45.1",
8283
"hono": "^4.11.9",

‎src/plugins/delete-file-plugin.test.ts‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,10 @@ import { tmpdir } from "node:os";
1515
import { join } from "node:path";
1616
import type { ToolCall, ToolResult } from "@intx/types/runtime";
1717

18-
import { deleteFilePlugin } from "./delete-file-plugin.js";
18+
import {
19+
deleteFilePlugin,
20+
DELETE_FILE_DEFINITION,
21+
} from "./delete-file-plugin.js";
1922
import { pathEscapePlugin } from "./path-escape-plugin.js";
2023
import { createPermissionGate } from "../permission/gate.js";
2124
import { permissionPlugin } from "./permission-plugin.js";
@@ -24,6 +27,27 @@ function call(path: unknown): ToolCall {
2427
return { id: "delete-call", name: "delete_file", arguments: { path } };
2528
}
2629

30+
// The served schema must stay the toJsonSchema projection of DeleteFileArgs:
31+
// pinning it here asserts the arktype-generated args type remains the single
32+
// source of truth (descriptions flow from the type's @ metadata, not from a
33+
// hand-written JSON copy).
34+
describe("DELETE_FILE_DEFINITION.inputSchema", () => {
35+
test("is generated from DeleteFileArgs via toJsonSchema", () => {
36+
expect(DELETE_FILE_DEFINITION.inputSchema).toEqual({
37+
type: "object",
38+
properties: {
39+
path: {
40+
type: "string",
41+
description:
42+
"Path to the file to delete, relative to the working directory or absolute",
43+
minLength: 1,
44+
},
45+
},
46+
required: ["path"],
47+
});
48+
});
49+
});
50+
2751
async function exists(path: string): Promise<boolean> {
2852
try {
2953
await stat(path);

‎src/plugins/delete-file-plugin.ts‎

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,25 +5,25 @@ import type { ExtraTool, ToolPlugin } from "@intx/tools-posix";
55
import type { ToolCall, ToolResult } from "@intx/types/runtime";
66
import { resolveWorkspacePath } from "../permission/path-restriction.js";
77
import type { RootsProvider } from "../permission/worktree-roots.js";
8+
import { toolInputSchema } from "../util/tool-input-schema.js";
89
import { formatChangeDiff } from "./change-diff.js";
910

10-
const DeleteFileArgs = type({ path: "string>0" });
11+
const DeleteFileArgs = type({
12+
path: [
13+
"string>0",
14+
"@",
15+
{
16+
description:
17+
"Path to the file to delete, relative to the working directory or absolute",
18+
},
19+
],
20+
});
1121

1222
export const DELETE_FILE_DEFINITION = {
1323
name: "delete_file",
1424
description:
1525
"Delete one file. Returns success when the file is deleted or already absent. Refuses directories; use this instead of shell rm for file deletion.",
16-
inputSchema: {
17-
type: "object",
18-
properties: {
19-
path: {
20-
type: "string",
21-
description:
22-
"Path to the file to delete, relative to the working directory or absolute",
23-
},
24-
},
25-
required: ["path"],
26-
},
26+
inputSchema: toolInputSchema(DeleteFileArgs),
2727
};
2828

2929
function errorResult(callId: string, message: string): ToolResult {

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

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
createWebFetchTool,
88
runWebFetch,
99
MAX_FETCH_BYTES,
10+
webFetchDefinition,
1011
} from "./web-fetch.js";
1112

1213
let server: Server;
@@ -264,3 +265,44 @@ describe("createExaMCPWebFetchTool", () => {
264265
expect(html.content).toContain("<h1>Heading</h1>");
265266
});
266267
});
268+
269+
// The served schema must stay the toJsonSchema projection of WebFetchArgs:
270+
// pinning it here asserts the arktype-generated args type remains the single
271+
// source of truth (descriptions flow from the type's @ metadata, not from a
272+
// hand-written JSON copy).
273+
describe("webFetchDefinition.inputSchema", () => {
274+
test("is generated from WebFetchArgs via toJsonSchema", () => {
275+
expect(webFetchDefinition.inputSchema).toEqual({
276+
type: "object",
277+
properties: {
278+
url: {
279+
type: "string",
280+
description: "URL to fetch (http or https only)",
281+
minLength: 1,
282+
},
283+
format: {
284+
anyOf: [
285+
{
286+
const: "html",
287+
description: "Output format. Defaults to markdown.",
288+
},
289+
{
290+
const: "markdown",
291+
description: "Output format. Defaults to markdown.",
292+
},
293+
{
294+
const: "text",
295+
description: "Output format. Defaults to markdown.",
296+
},
297+
],
298+
description: "Output format. Defaults to markdown.",
299+
},
300+
timeout: {
301+
type: "number",
302+
description: "Timeout in seconds (default 30, max 120).",
303+
},
304+
},
305+
required: ["url"],
306+
});
307+
});
308+
});

‎src/tools/web-fetch.ts‎

Lines changed: 15 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { ToolCall, ToolDefinition, ToolResult } from "@intx/types/runtime";
66
import { checkUrlForSsrf } from "./ssrf-guard.js";
77
import { htmlToMarkdown, htmlToText } from "./html-convert.js";
88
import { COMMAND_NAME } from "../branding.js";
9+
import { toolInputSchema } from "../util/tool-input-schema.js";
910
import type { MCPClient } from "../mcp/client.js";
1011
import pkg from "../../package.json" with { type: "json" };
1112

@@ -36,9 +37,19 @@ function honestUserAgent(): string {
3637
}
3738

3839
const WebFetchArgs = type({
39-
url: "string>0",
40-
"format?": "'text' | 'markdown' | 'html'",
41-
"timeout?": "number",
40+
url: ["string>0", "@", { description: "URL to fetch (http or https only)" }],
41+
"format?": [
42+
"'text' | 'markdown' | 'html'",
43+
"@",
44+
{ description: "Output format. Defaults to markdown." },
45+
],
46+
"timeout?": [
47+
"number",
48+
"@",
49+
{
50+
description: `Timeout in seconds (default ${DEFAULT_TIMEOUT_S}, max ${MAX_TIMEOUT_S}).`,
51+
},
52+
],
4253
});
4354

4455
type WebFetchFormat = "text" | "markdown" | "html";
@@ -78,22 +89,7 @@ export const webFetchDefinition: ToolDefinition = {
7889
name: "web_fetch",
7990
description:
8091
"Fetch a public web page (docs, articles) and return it as markdown by default. For a URL on a host a connected MCP server covers, use that server via tool_search first; web_fetch is for public pages, not authenticated apps.",
81-
inputSchema: {
82-
type: "object",
83-
properties: {
84-
url: { type: "string", description: "URL to fetch (http or https only)" },
85-
format: {
86-
type: "string",
87-
enum: ["text", "markdown", "html"],
88-
description: "Output format. Defaults to markdown.",
89-
},
90-
timeout: {
91-
type: "number",
92-
description: `Timeout in seconds (default ${DEFAULT_TIMEOUT_S}, max ${MAX_TIMEOUT_S}).`,
93-
},
94-
},
95-
required: ["url"],
96-
},
92+
inputSchema: toolInputSchema(WebFetchArgs),
9793
};
9894

9995
function acceptHeaderFor(format: WebFetchFormat): string {

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

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ const {
3636
resolveWebSearchProvider,
3737
EXA_MCP_URL,
3838
PARALLEL_MCP_URL,
39+
webSearchDefinition,
3940
} = await import("./web-search.js");
4041
const { createAgentToolset } = await import("../agent/tools.js");
4142
const { resolveMcpServers } = await import("../config/index.js");
@@ -180,3 +181,61 @@ describe("createWebSearchTool", () => {
180181
expect(connectConfigs.length).toBe(0);
181182
});
182183
});
184+
185+
// The served schema must stay the toJsonSchema projection of WebSearchArgs:
186+
// pinning it here asserts the arktype-generated args type remains the single
187+
// source of truth (descriptions flow from the type's @ metadata, not from a
188+
// hand-written JSON copy).
189+
describe("webSearchDefinition.inputSchema", () => {
190+
test("is generated from WebSearchArgs via toJsonSchema", () => {
191+
expect(webSearchDefinition.inputSchema).toEqual({
192+
type: "object",
193+
properties: {
194+
query: { type: "string", description: "Search query", minLength: 1 },
195+
numResults: {
196+
type: "number",
197+
description: "Maximum number of results (default 8).",
198+
},
199+
type: {
200+
anyOf: [
201+
{
202+
const: "auto",
203+
description: "Search depth/strategy. Defaults to auto.",
204+
},
205+
{
206+
const: "deep",
207+
description: "Search depth/strategy. Defaults to auto.",
208+
},
209+
{
210+
const: "fast",
211+
description: "Search depth/strategy. Defaults to auto.",
212+
},
213+
],
214+
description: "Search depth/strategy. Defaults to auto.",
215+
},
216+
livecrawl: {
217+
anyOf: [
218+
{
219+
const: "fallback",
220+
description:
221+
"Whether to prefer or only fall back to a live crawl. Defaults to fallback.",
222+
},
223+
{
224+
const: "preferred",
225+
description:
226+
"Whether to prefer or only fall back to a live crawl. Defaults to fallback.",
227+
},
228+
],
229+
description:
230+
"Whether to prefer or only fall back to a live crawl. Defaults to fallback.",
231+
},
232+
contextMaxCharacters: {
233+
type: "number",
234+
description:
235+
"Cap on returned context length in characters (default 10000).",
236+
},
237+
},
238+
required: ["query"],
239+
});
240+
});
241+
});

‎src/tools/web-search.ts‎

Lines changed: 29 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { AgentTool } from "@intx/agent";
44
import type { ToolDefinition } from "@intx/types/runtime";
55

66
import { connectMCPServer, type MCPClient } from "../mcp/client.js";
7+
import { toolInputSchema } from "../util/tool-input-schema.js";
78
import type { MCPServerConfig } from "../config/settings.js";
89
import { EXA_MCP_URL } from "../mcp/exa.js";
910

@@ -16,43 +17,39 @@ export const PARALLEL_MCP_URL = "https://search.parallel.ai/mcp";
1617
export type WebSearchProviderId = "exa" | "parallel";
1718

1819
const WebSearchArgs = type({
19-
query: "string>0",
20-
"numResults?": "number",
21-
"type?": "'auto' | 'fast' | 'deep'",
22-
"livecrawl?": "'fallback' | 'preferred'",
23-
"contextMaxCharacters?": "number",
20+
query: ["string>0", "@", { description: "Search query" }],
21+
"numResults?": [
22+
"number",
23+
"@",
24+
{ description: "Maximum number of results (default 8)." },
25+
],
26+
"type?": [
27+
"'auto' | 'fast' | 'deep'",
28+
"@",
29+
{ description: "Search depth/strategy. Defaults to auto." },
30+
],
31+
"livecrawl?": [
32+
"'fallback' | 'preferred'",
33+
"@",
34+
{
35+
description:
36+
"Whether to prefer or only fall back to a live crawl. Defaults to fallback.",
37+
},
38+
],
39+
"contextMaxCharacters?": [
40+
"number",
41+
"@",
42+
{
43+
description:
44+
"Cap on returned context length in characters (default 10000).",
45+
},
46+
],
2447
});
2548

2649
export const webSearchDefinition: ToolDefinition = {
2750
name: "web_search",
2851
description: "Search the web; returns ranked results as text.",
29-
inputSchema: {
30-
type: "object",
31-
properties: {
32-
query: { type: "string", description: "Search query" },
33-
numResults: {
34-
type: "number",
35-
description: "Maximum number of results (default 8).",
36-
},
37-
type: {
38-
type: "string",
39-
enum: ["auto", "fast", "deep"],
40-
description: "Search depth/strategy. Defaults to auto.",
41-
},
42-
livecrawl: {
43-
type: "string",
44-
enum: ["fallback", "preferred"],
45-
description:
46-
"Whether to prefer or only fall back to a live crawl. Defaults to fallback.",
47-
},
48-
contextMaxCharacters: {
49-
type: "number",
50-
description:
51-
"Cap on returned context length in characters (default 10000).",
52-
},
53-
},
54-
required: ["query"],
55-
},
52+
inputSchema: toolInputSchema(WebSearchArgs),
5653
};
5754

5855
// Provider selection is env-driven (settings.env now makes this configuration

‎src/util/tool-input-schema.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/**
2+
* Serves a tool's inputSchema from its declared arktype args type so the type
3+
* stays the single source of truth for what the model sees. `dialect: null`
4+
* drops the `$schema` doc key the wire shape is validated against without.
5+
*/
6+
export function toolInputSchema(argsType: {
7+
toJsonSchema(options?: { dialect: string | null }): unknown;
8+
}): Record<string, unknown> {
9+
return argsType.toJsonSchema({ dialect: null }) as Record<string, unknown>;
10+
}

0 commit comments

Comments
 (0)