diff --git a/.changeset/write-auto-mkdir-parents.md b/.changeset/write-auto-mkdir-parents.md new file mode 100644 index 000000000..9c385bb47 --- /dev/null +++ b/.changeset/write-auto-mkdir-parents.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Create missing parent directories automatically when writing a file. diff --git a/docs/en/reference/tools.md b/docs/en/reference/tools.md index 83c1a2bef..91d43f406 100644 --- a/docs/en/reference/tools.md +++ b/docs/en/reference/tools.md @@ -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. diff --git a/docs/zh/reference/tools.md b/docs/zh/reference/tools.md index c288bb4d3..bf4527862 100644 --- a/docs/zh/reference/tools.md +++ b/docs/zh/reference/tools.md @@ -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` 不能相同。 diff --git a/packages/agent-core/src/tools/builtin/file/write.md b/packages/agent-core/src/tools/builtin/file/write.md index 360805e5c..453ab26f8 100644 --- a/packages/agent-core/src/tools/builtin/file/write.md +++ b/packages/agent-core/src/tools/builtin/file/write.md @@ -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. diff --git a/packages/agent-core/src/tools/builtin/file/write.ts b/packages/agent-core/src/tools/builtin/file/write.ts index f3c49e6a0..e61f5be66 100644 --- a/packages/agent-core/src/tools/builtin/file/write.ts +++ b/packages/agent-core/src/tools/builtin/file/write.ts @@ -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 { } private async execution(args: WriteInput, safePath: string): Promise { - 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 { } /** - * 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 { + private async ensureParentDirectory(safePath: string): Promise { 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; } diff --git a/packages/agent-core/test/tools/write.test.ts b/packages/agent-core/test/tools/write.test.ts index 2c52f1959..07cc29b3a 100644 --- a/packages/agent-core/test/tools/write.test.ts +++ b/packages/agent-core/test/tools/write.test.ts @@ -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(