Skip to content

Commit 82b6997

Browse files
committed
Implement writeFile tool
1 parent 33c8ad4 commit 82b6997

6 files changed

Lines changed: 193 additions & 0 deletions

File tree

src/ai/system-prompt.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export type InteractionMode = "text" | "voice";
99

1010
export interface ToolAvailability {
1111
readFile: boolean;
12+
writeFile: boolean;
1213
runBash: boolean;
1314
webSearch: boolean;
1415
fetchUrls: boolean;
@@ -64,6 +65,7 @@ export function buildDaemonSystemPrompt(options: SystemPromptOptions = {}): stri
6465
function normalizeToolAvailability(toolAvailability?: Partial<ToolAvailability>): ToolAvailability {
6566
return {
6667
readFile: toolAvailability?.readFile ?? true,
68+
writeFile: toolAvailability?.writeFile ?? true,
6769
runBash: toolAvailability?.runBash ?? true,
6870
webSearch: toolAvailability?.webSearch ?? true,
6971
fetchUrls: toolAvailability?.fetchUrls ?? true,
@@ -243,6 +245,19 @@ Fetch multiple URLs in one call:
243245
By default it reads up to 2000 lines from the start when no offset/limit are provided.
244246
For partial reads, you must provide both a 0-based line offset and a line limit.
245247
`,
248+
writeFile: `
249+
### 'writeFile' (local file writer)
250+
Use this to write content to files. Creates new files or overwrites existing ones.
251+
Automatically creates parent directories if they don't exist.
252+
253+
**CRITICAL: Always report the correct file location to the user**
254+
- When you write a file, explicitly tell the user the full path where it was saved
255+
- If the file is in the workspace, say "I have saved it to my workspace at: [full path]"
256+
- If the file is in the current working directory, say "I have saved it to: [path]"
257+
- Do NOT give commands like "cat filename" or "open filename" unless the file is actually in the current working directory
258+
- For files in the workspace, give the full path: "cat /full/path/to/file" or tell the user to navigate there first
259+
`,
260+
246261
subagent: `
247262
### 'subagent'
248263
Call this tool to spawn subagents for specific tasks.
@@ -260,6 +275,7 @@ function buildToolDefinitions(availability: ToolAvailability): string {
260275
if (availability.groundingManager) blocks.push(TOOL_SECTIONS.groundingManager);
261276
if (availability.runBash) blocks.push(TOOL_SECTIONS.runBash);
262277
if (availability.readFile) blocks.push(TOOL_SECTIONS.readFile);
278+
if (availability.writeFile) blocks.push(TOOL_SECTIONS.writeFile);
263279
if (availability.subagent) blocks.push(TOOL_SECTIONS.subagent);
264280

265281
const webNote =

src/ai/tools/tool-registry.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { runBash } from "./run-bash";
88
import { subagent } from "./subagents";
99
import { todoManager } from "./todo-manager";
1010
import { webSearch } from "./web-search";
11+
import { writeFile } from "./write-file";
1112

1213
import type { ToolToggleId, ToolToggles } from "../../types";
1314
import { detectLocalPlaywrightChromium } from "../../utils/js-rendering";
@@ -40,6 +41,7 @@ type ToolGateResult = {
4041

4142
const TOOL_REGISTRY: ToolEntry[] = [
4243
{ id: "readFile", toggleKey: "readFile", tool: readFile },
44+
{ id: "writeFile", toggleKey: "writeFile", tool: writeFile },
4345
{ id: "runBash", toggleKey: "runBash", tool: runBash },
4446
{ id: "webSearch", toggleKey: "webSearch", tool: webSearch, gate: gateExa },
4547
{ id: "fetchUrls", toggleKey: "fetchUrls", tool: fetchUrls, gate: gateExa },
@@ -68,6 +70,7 @@ async function gateRenderUrl(): Promise<ToolGateResult> {
6870
function normalizeToggles(toggles?: ToolToggles): ToolToggles {
6971
return {
7072
readFile: toggles?.readFile ?? true,
73+
writeFile: toggles?.writeFile ?? true,
7174
runBash: toggles?.runBash ?? true,
7275
webSearch: toggles?.webSearch ?? true,
7376
fetchUrls: toggles?.fetchUrls ?? true,
@@ -166,6 +169,7 @@ export async function buildToolSet(
166169
export function getToolLabels(): Record<ToolId, string> {
167170
return {
168171
readFile: "readFile",
172+
writeFile: "writeFile",
169173
runBash: "runBash",
170174
webSearch: "webSearch",
171175
fetchUrls: "fetchUrls",
@@ -179,6 +183,7 @@ export function getToolLabels(): Record<ToolId, string> {
179183
export function getDefaultToolOrder(): ToolId[] {
180184
return [
181185
"readFile",
186+
"writeFile",
182187
"runBash",
183188
"webSearch",
184189
"fetchUrls",
@@ -192,6 +197,7 @@ export function getDefaultToolOrder(): ToolId[] {
192197
export function createToolAvailabilitySnapshot(availability: ToolAvailabilityMap): Record<ToolId, boolean> {
193198
return {
194199
readFile: availability.readFile?.enabled ?? false,
200+
writeFile: availability.writeFile?.enabled ?? false,
195201
runBash: availability.runBash?.enabled ?? false,
196202
webSearch: availability.webSearch?.enabled ?? false,
197203
fetchUrls: availability.fetchUrls?.enabled ?? false,

src/ai/tools/write-file.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import fs from "node:fs";
2+
import path from "node:path";
3+
import { tool } from "ai";
4+
import { z } from "zod";
5+
6+
export const writeFile = tool({
7+
description:
8+
"Write content to a file. Creates the file if it doesn't exist, or overwrites it if it does. Supports append mode to add content to existing files. Use this to create scripts, save outputs, write configuration files, or generate any text-based file.",
9+
inputSchema: z.object({
10+
path: z
11+
.string()
12+
.describe("Path to the file to write. Can be absolute or relative to the current working directory."),
13+
content: z.string().describe("The content to write to the file."),
14+
append: z
15+
.boolean()
16+
.optional()
17+
.default(false)
18+
.describe("If true, append to the file instead of overwriting. Creates the file if it doesn't exist."),
19+
}),
20+
execute: async ({ path: filePath, content, append }) => {
21+
try {
22+
const resolvedPath = path.resolve(filePath);
23+
const dir = path.dirname(resolvedPath);
24+
25+
// Create parent directories if they don't exist
26+
if (!fs.existsSync(dir)) {
27+
fs.mkdirSync(dir, { recursive: true });
28+
}
29+
30+
// Write or append to the file
31+
if (append) {
32+
fs.appendFileSync(resolvedPath, content, "utf8");
33+
} else {
34+
fs.writeFileSync(resolvedPath, content, "utf8");
35+
}
36+
37+
return {
38+
success: true,
39+
path: resolvedPath,
40+
bytesWritten: Buffer.byteLength(content, "utf8"),
41+
};
42+
} catch (error: unknown) {
43+
const err = error instanceof Error ? error : new Error(String(error));
44+
return {
45+
success: false,
46+
path: filePath,
47+
error: err.message,
48+
};
49+
}
50+
},
51+
});

src/components/tool-layouts/layouts/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import "./bash";
22
import "./web-search";
33
import "./url-tools";
44
import "./read-file";
5+
import "./write-file.tsx";
56
import "./subagent";
67
import "./todo";
78
import "./system-info";
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { pathToFiletype } from "@opentui/core";
2+
import React from "react";
3+
import { COLORS, REASONING_MARKDOWN_STYLE } from "../../../ui/constants";
4+
import { registerToolLayout } from "../registry";
5+
import type { ToolHeader, ToolLayoutConfig, ToolLayoutRenderProps } from "../types";
6+
7+
type UnknownRecord = Record<string, unknown>;
8+
9+
function isRecord(value: unknown): value is UnknownRecord {
10+
return typeof value === "object" && value !== null && !Array.isArray(value);
11+
}
12+
13+
function extractPath(input: unknown): string | null {
14+
if (!isRecord(input)) return null;
15+
if ("path" in input && typeof input.path === "string") {
16+
return input.path;
17+
}
18+
return null;
19+
}
20+
21+
function extractContent(input: unknown): string | null {
22+
if (!isRecord(input)) return null;
23+
if ("content" in input && typeof input.content === "string") {
24+
return input.content;
25+
}
26+
return null;
27+
}
28+
29+
function extractAppend(input: unknown): boolean {
30+
if (!isRecord(input)) return false;
31+
if ("append" in input && typeof input.append === "boolean") {
32+
return input.append;
33+
}
34+
return false;
35+
}
36+
37+
function WriteFileBody({ call, result }: ToolLayoutRenderProps) {
38+
if (!isRecord(result)) return null;
39+
if (result.success === false && typeof result.error === "string") {
40+
return (
41+
<box paddingLeft={2}>
42+
<text>
43+
<span fg={COLORS.STATUS_FAILED}>{`error: ${result.error}`}</span>
44+
</text>
45+
</box>
46+
);
47+
}
48+
if (result.success !== true) return null;
49+
50+
const content = extractContent(call.input) ?? "";
51+
const path = extractPath(call.input) ?? "";
52+
53+
// Detect filetype from path for syntax highlighting
54+
const filetype = pathToFiletype(path);
55+
56+
// Format content preview
57+
let previewContent = "";
58+
if (content.trim()) {
59+
const MAX_LINES = 4;
60+
const MAX_CHARS = 160;
61+
const contentLines = content
62+
.split("\n")
63+
.slice(0, MAX_LINES)
64+
.map((line) => (line.length > MAX_CHARS ? `${line.slice(0, MAX_CHARS - 1)}…` : line));
65+
66+
const totalLines = content.split("\n").length;
67+
if (totalLines > MAX_LINES) {
68+
contentLines.push(`... (${totalLines - MAX_LINES} more lines)`);
69+
}
70+
previewContent = contentLines.join("\n");
71+
} else {
72+
previewContent = "(empty file)";
73+
}
74+
75+
return (
76+
<box flexDirection="column" paddingLeft={2} marginTop={0}>
77+
<box
78+
borderStyle="single"
79+
borderColor={COLORS.TOOL_INPUT_BORDER}
80+
paddingLeft={1}
81+
paddingRight={1}
82+
paddingTop={0}
83+
paddingBottom={0}
84+
>
85+
<code
86+
content={previewContent}
87+
filetype={filetype}
88+
syntaxStyle={REASONING_MARKDOWN_STYLE}
89+
conceal={true}
90+
drawUnstyledText={false}
91+
/>
92+
</box>
93+
</box>
94+
);
95+
}
96+
97+
export const writeFileLayout: ToolLayoutConfig = {
98+
abbreviation: "write",
99+
100+
getHeader: (input): ToolHeader | null => {
101+
const path = extractPath(input);
102+
if (!path) return null;
103+
const append = extractAppend(input);
104+
const filetype = pathToFiletype(path);
105+
106+
const parts: string[] = [];
107+
if (filetype) parts.push(filetype);
108+
if (append) parts.push("append");
109+
110+
const secondary = parts.length > 0 ? parts.join(" · ") : undefined;
111+
return { primary: path, secondary, secondaryStyle: "dim" };
112+
},
113+
114+
renderBody: WriteFileBody,
115+
};
116+
117+
registerToolLayout("writeFile", writeFileLayout);

src/types/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,7 @@ export type VoiceInteractionType = "direct" | "review";
269269

270270
export type ToolToggleId =
271271
| "readFile"
272+
| "writeFile"
272273
| "runBash"
273274
| "webSearch"
274275
| "fetchUrls"
@@ -281,6 +282,7 @@ export type ToolToggles = Record<ToolToggleId, boolean>;
281282

282283
export const DEFAULT_TOOL_TOGGLES: ToolToggles = {
283284
readFile: true,
285+
writeFile: true,
284286
runBash: true,
285287
webSearch: true,
286288
fetchUrls: true,

0 commit comments

Comments
 (0)