feat: auto-create missing parent directories when writing files (#1065)

The Write tool previously failed when a parent directory was missing, forcing a manual mkdir round trip. It now creates missing parents recursively before writing.
This commit is contained in:
7Sageer 2026-06-24 14:05:27 +08:00 committed by GitHub
parent ee69e16dc8
commit 4b837d6bfb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 59 additions and 22 deletions

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Create missing parent directories automatically when writing a file.

View file

@ -19,7 +19,7 @@ File tools handle reading, writing, and searching the local filesystem — the f
**`Read`** accepts a file path (`path`) plus optional `line_offset` (starting line number; negative values count from the end) and `n_lines` (maximum number of lines to read). Returns at most 1000 lines or 100 KB per call; content beyond that limit is accompanied by a truncation notice. If the file is an image or video, the tool suggests using `ReadMediaFile` instead.
**`Write`** accepts `path`, `content`, and an optional `mode` (`overwrite` or `append`; defaults to overwrite). The parent directory must already exist; `append` mode appends content to the end of the file without automatically adding a newline.
**`Write`** accepts `path`, `content`, and an optional `mode` (`overwrite` or `append`; defaults to overwrite). Missing parent directories are created automatically; `append` mode appends content to the end of the file without automatically adding a newline.
**`Edit`** accepts `path`, `old_string` (the exact text to replace), and `new_string` (the replacement text). By default it replaces only one unique match; if the same content appears multiple times in the file, the tool returns an error and suggests using `replace_all: true`. `old_string` and `new_string` must not be identical.

View file

@ -19,7 +19,7 @@
**`Read`** 接受文件路径(`path`)以及可选的 `line_offset`(起始行号,支持负数从末尾倒数)和 `n_lines`(读取行数上限)。单次最多返回 1000 行或 100 KB超出部分会附带截断提示。如果文件是图片或视频工具会提示改用 `ReadMediaFile`
**`Write`** 接受 `path``content` 和可选的 `mode``overwrite``append`,默认覆盖)。父目录必须已存在`append` 模式将内容追加到文件末尾,不自动添加换行。
**`Write`** 接受 `path``content` 和可选的 `mode``overwrite``append`,默认覆盖)。缺失的父目录会自动创建`append` 模式将内容追加到文件末尾,不自动添加换行。
**`Edit`** 接受 `path``old_string`(要替换的精确文本)和 `new_string`(替换后的文本)。默认只替换唯一一处匹配,若文件中存在多处相同内容会报错并提示使用 `replace_all: true``old_string``new_string` 不能相同。

View file

@ -1,6 +1,6 @@
Create, append to, or replace a file entirely.
- The parent directory must already exist.
- Missing parent directories are created automatically (like `mkdir(parents=True, exist_ok=True)`).
- Mode defaults to overwrite; append adds content at EOF without adding a newline.
- Write is NOT ALLOWED for incremental changes to existing files, including trivial, one-line, quick, or cosmetic edits. Use Edit instead.
- Use Write only when the file does not exist, you intend a complete replacement, or the new contents have little continuity with the old contents.

View file

@ -1,7 +1,8 @@
/**
* WriteTool overwrite or append to a file.
*
* Creates the file if it does not exist; parent directory must already exist.
* Creates the file if it does not exist. Missing parent directories are
* created automatically, mirroring `mkdir(parents=True, exist_ok=True)`.
* Path access policy is resolved before any Kaos I/O.
*/
@ -27,7 +28,7 @@ export const WriteInputSchema = z.object({
path: z
.string()
.describe(
'Path to the file to create, append to, or completely overwrite. Relative paths resolve against the working directory; a path outside the working directory must be absolute. The parent directory must already exist.',
'Path to the file to create, append to, or completely overwrite. Relative paths resolve against the working directory; a path outside the working directory must be absolute. Missing parent directories are created automatically.',
),
content: z
.string()
@ -82,7 +83,7 @@ export class WriteTool implements BuiltinTool<WriteInput> {
}
private async execution(args: WriteInput, safePath: string): Promise<ExecutableToolResult> {
const parentError = await this.checkParentDirectory(safePath);
const parentError = await this.ensureParentDirectory(safePath);
if (parentError !== undefined) {
return { isError: true, output: parentError };
}
@ -117,23 +118,34 @@ export class WriteTool implements BuiltinTool<WriteInput> {
}
/**
* Best-effort check that the parent directory exists and is a directory.
* Best-effort check that the parent directory is usable, creating it when
* it is missing.
*
* If the parent (or any ancestor) does not exist, it is created
* recursively mirroring Python's `Path.mkdir(parents=True,
* exist_ok=True)` — so the agent does not need a separate `mkdir` round
* trip before writing into a fresh subfolder. An existing parent that is
* not a directory is still a hard error. Any other `stat` failure
* (permissions, an environment without `stat`) is treated as
* inconclusive: the check is skipped and the write proceeds, surfacing
* the real I/O error if any.
*
* The path schema documents this precondition; probing it up front turns a
* bare `ENOENT` from the underlying write into an actionable message.
* Returns an error string when the precondition is definitively violated,
* or `undefined` otherwise. Any other `stat` failure (permissions, an
* environment without `stat`) is treated as inconclusive: the check is
* skipped and the write proceeds, surfacing the real I/O error if any.
* or `undefined` otherwise.
*/
private async checkParentDirectory(safePath: string): Promise<string | undefined> {
private async ensureParentDirectory(safePath: string): Promise<string | undefined> {
const parent = dirname(safePath);
let stat;
try {
stat = await this.kaos.stat(parent);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return `Parent directory does not exist: ${parent}. Create it before writing this file.`;
try {
await this.kaos.mkdir(parent, { parents: true, existOk: true });
return undefined;
} catch (mkdirError) {
return mkdirError instanceof Error ? mkdirError.message : String(mkdirError);
}
}
return undefined;
}

View file

@ -190,21 +190,38 @@ describe('WriteTool', () => {
expect(result.output).toContain('Appended 5 bytes');
});
it('reports a friendly error when the parent directory does not exist', async () => {
it('creates missing parent directories automatically before writing', async () => {
const enoent = Object.assign(new Error('ENOENT: no such file or directory'), {
code: 'ENOENT',
});
const stat = vi.fn().mockRejectedValue(enoent);
const mkdir = vi.fn().mockResolvedValue(undefined);
const writeText = vi.fn().mockResolvedValue(4);
const tool = new WriteTool(createFakeKaos({ stat, writeText }), PERMISSIVE_WORKSPACE);
const tool = new WriteTool(createFakeKaos({ stat, mkdir, writeText }), PERMISSIVE_WORKSPACE);
const result = await executeTool(tool,
context({ path: '/tmp/missing-dir/file.txt', content: 'data' }),
);
expect(result).toMatchObject({ isError: true });
expect(result.output).toContain('/tmp/missing-dir');
expect(result.output).toMatch(/parent directory/i);
expect(result.isError).toBeFalsy();
expect(mkdir).toHaveBeenCalledWith('/tmp/missing-dir', { parents: true, existOk: true });
expect(writeText).toHaveBeenCalledWith('/tmp/missing-dir/file.txt', 'data');
});
it('surfaces mkdir failures when a missing parent cannot be created', async () => {
const enoent = Object.assign(new Error('ENOENT: no such file or directory'), {
code: 'ENOENT',
});
const stat = vi.fn().mockRejectedValue(enoent);
const mkdir = vi.fn().mockRejectedValue(new Error('permission denied'));
const writeText = vi.fn().mockResolvedValue(4);
const tool = new WriteTool(createFakeKaos({ stat, mkdir, writeText }), PERMISSIVE_WORKSPACE);
const result = await executeTool(tool,
context({ path: '/tmp/missing-dir/file.txt', content: 'data' }),
);
expect(result).toMatchObject({ isError: true, output: 'permission denied' });
expect(writeText).not.toHaveBeenCalled();
});
@ -310,9 +327,12 @@ describe('WriteTool', () => {
expect(writeText).toHaveBeenCalledWith('/tmp/empty.txt', '');
});
it('reports a parent-directory-does-not-exist message when the directory is missing', async () => {
// py surfaces `parent directory does not exist` so the model can `mkdir`
// before retrying. TS currently forwards whatever the host throws.
it('still reports parent-directory ENOENT surfaced by writeText itself', async () => {
// When the proactive parent check is inconclusive (e.g. the environment
// has no `stat`) and the underlying write then fails with ENOENT — for
// example a parent directory removed between the check and the write —
// the tool still surfaces a clear "parent directory does not exist"
// message rather than a raw host error.
const writeText = vi
.fn()
.mockRejectedValue(