Skip to content

Commit 495c992

Browse files
Merge pull request #1425 from corbitsdev/cl-10652-arktype-228-generate-inputschema-via-tojsonschema-delete-ha
refactor(tools): derive tool inputSchemas from arktype types
2 parents 301e383 + 88acffc commit 495c992

11 files changed

Lines changed: 306 additions & 77 deletions

‎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/session/stream-journal.test.ts‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,60 @@ describe("createCycleTextRecorder", () => {
124124
expect(records[0]?.error?.message).toBe("aborted");
125125
});
126126

127+
test("a reader never observes a torn partial.jsonl record", async () => {
128+
// appendFile creates the file before the line lands, so a poller can read
129+
// empty or truncated JSON. Every observed byte must already parse.
130+
for (let trial = 0; trial < 30; trial++) {
131+
const temp = createTempDir("stream-journal-race-");
132+
try {
133+
const recorder = createCycleTextRecorder(() => temp.dir);
134+
recorder.handleEvent({
135+
type: "inference.error",
136+
data: {
137+
error: {
138+
category: "rate_limit",
139+
message: "429 too many requests",
140+
statusCode: 429,
141+
},
142+
},
143+
} as unknown as ReactorEmittedEvent);
144+
145+
const started = Date.now();
146+
for (;;) {
147+
let raw: string;
148+
try {
149+
raw = await readFile(join(temp.dir, PARTIAL_FILE), "utf8");
150+
} catch (err) {
151+
if (Date.now() - started >= 2_000) throw err;
152+
continue;
153+
}
154+
const records = parsePartialRaw(raw);
155+
expect(records).toHaveLength(1);
156+
expect(records[0]?.text).toBe("");
157+
expect(records[0]?.error?.statusCode).toBe(429);
158+
break;
159+
}
160+
} finally {
161+
temp.cleanup();
162+
}
163+
}
164+
});
165+
166+
test("two flushes append both records", async () => {
167+
const recorder = createCycleTextRecorder(() => dir);
168+
recorder.handleEvent(delta("one"));
169+
await recorder.flush("cancelled");
170+
recorder.handleEvent(delta("two"));
171+
await recorder.flush("interrupted");
172+
173+
const records = await readPartialRecords();
174+
expect(records.map((record) => record.text)).toEqual(["one", "two"]);
175+
expect(records.map((record) => record.reason)).toEqual([
176+
"cancelled",
177+
"interrupted",
178+
]);
179+
});
180+
127181
test("inference.error with empty cycle text still writes a partial with the error payload", async () => {
128182
// Live failures can emit inference.error with no streamed text; the
129183
// partial must still land so category/message survive.

‎src/session/stream-journal.ts‎

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { appendFile } from "node:fs/promises";
1+
import { readFile, rename, rm, writeFile } from "node:fs/promises";
22
import { join } from "node:path";
33

44
import type { ReactorEmittedEvent } from "@intx/inference";
@@ -21,6 +21,44 @@ export const PARTIAL_FILE = "partial.jsonl";
2121
/** Cap on retained cycle text; older text is dropped from the front. */
2222
export const CYCLE_TEXT_CAP_CHARS = 262_144;
2323

24+
function isEnoent(err: unknown): boolean {
25+
return (
26+
typeof err === "object" &&
27+
err !== null &&
28+
"code" in err &&
29+
(err as { code?: unknown }).code === "ENOENT"
30+
);
31+
}
32+
33+
let partialTmpCounter = 0;
34+
35+
/**
36+
* Append one JSONL line so readers never observe a torn record. `appendFile`
37+
* creates the destination before the bytes land; a poller then reads empty or
38+
* truncated JSON. Rewrite the whole file through a temp sibling and `rename`
39+
* so the path appears only with complete lines.
40+
*/
41+
async function appendPartialLine(dir: string, line: string): Promise<void> {
42+
const target = join(dir, PARTIAL_FILE);
43+
let prior = "";
44+
try {
45+
prior = await readFile(target, "utf8");
46+
} catch (err) {
47+
if (!isEnoent(err)) throw err;
48+
}
49+
const tmp = join(
50+
dir,
51+
`.${PARTIAL_FILE}.${process.pid}.${(partialTmpCounter += 1)}.tmp`,
52+
);
53+
try {
54+
await writeFile(tmp, prior + line, "utf8");
55+
await rename(tmp, target);
56+
} catch (err) {
57+
await rm(tmp, { force: true }).catch(() => undefined);
58+
throw err;
59+
}
60+
}
61+
2462
/** Append a streamed token to the cycle buffer, keeping only the tail. */
2563
export function appendCycleText(
2664
text: string,
@@ -105,6 +143,9 @@ export function createCycleTextRecorder(
105143
let cycleText = "";
106144
let cycleThinkingText = "";
107145
let closed = false;
146+
// Overlapping flushes (inference.error is not awaited) must not both read
147+
// the same prior bytes and rename away the other line.
148+
let publishChain: Promise<void> = Promise.resolve();
108149

109150
const writeRecord = async (
110151
reason: PartialFlushReason,
@@ -132,12 +173,16 @@ export function createCycleTextRecorder(
132173
record.thinkingText = thinkingText;
133174
}
134175
if (error !== undefined) record.error = error;
176+
const line = `${JSON.stringify(record)}\n`;
177+
const run = publishChain.then(() =>
178+
appendPartialLine(resolveContextDir(), line),
179+
);
180+
publishChain = run.then(
181+
() => undefined,
182+
() => undefined,
183+
);
135184
try {
136-
await appendFile(
137-
join(resolveContextDir(), PARTIAL_FILE),
138-
`${JSON.stringify(record)}\n`,
139-
"utf8",
140-
);
185+
await run;
141186
} catch (err) {
142187
getLogger([LOG_NAMESPACE_ROOT, "session", "partial"]).warn(
143188
"failed to write partial stream output: {error}",

‎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 {

0 commit comments

Comments
 (0)