mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
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:
parent
ee69e16dc8
commit
4b837d6bfb
6 changed files with 59 additions and 22 deletions
5
.changeset/write-auto-mkdir-parents.md
Normal file
5
.changeset/write-auto-mkdir-parents.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
---
|
||||
|
||||
Create missing parent directories automatically when writing a 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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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` 不能相同。
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue