diff --git a/packages/core/src/environment/tools/edit-file.ts b/packages/core/src/environment/tools/edit-file.ts index 5e762a8..81c7414 100644 --- a/packages/core/src/environment/tools/edit-file.ts +++ b/packages/core/src/environment/tools/edit-file.ts @@ -28,6 +28,7 @@ import type { ToolDefinitionConfig } from "../../interfaces.js"; import type { BuiltinTool, ToolExecutionContext, ToolResult } from "./types.js"; import { atomicWriteFile } from "./file-utils.js"; import { buildReplacementHunks, renderHunk } from "./diff.js"; +import { missingPathHint } from "./path-hint.js"; /** Tool name constant (used only within this tool module, never exposed to Environment). */ export const EDIT_FILE_NAME = "edit_file"; @@ -113,9 +114,12 @@ export function createEditFileTool(definition: ToolDefinitionConfig): BuiltinToo } catch (err) { if (signal?.aborted) return { stopReason: "aborted" }; const code = (err as NodeJS.ErrnoException).code; - if (code === "ENOENT") { + // ENOTDIR is the same mistake seen one segment later (a file used as a directory), + // so it gets the same diagnosis instead of a raw errno message. + if (code === "ENOENT" || code === "ENOTDIR") { + const hint = await missingPathHint(resolved); yield delta( - `File not found: "${filePath}". edit_file only edits existing files — check the path, or use write_file to create it.`, + `File not found: "${filePath}". edit_file only edits existing files — check the path (absolute paths are supported), or use write_file to create it.${hint}`, ); } else { const message = err instanceof Error ? err.message : String(err); diff --git a/packages/core/src/environment/tools/path-hint.ts b/packages/core/src/environment/tools/path-hint.ts new file mode 100644 index 0000000..394b78c --- /dev/null +++ b/packages/core/src/environment/tools/path-hint.ts @@ -0,0 +1,74 @@ +/** + * path-hint — shared missing-path diagnostic for the file tools (read_file / edit_file). + * + * A bare "File not found" regularly sends the model down the wrong road: it retries + * relative/absolute variants of the same wrong path instead of questioning the path itself + * (issue #138 — a missing `agent_state/` segment read as "absolute paths are rejected"). + * The cure is to show where the path leaves reality: walk up to the deepest existing + * ancestor and list the entries closest in name to the missing segment, so the next call + * can be the right one. Failure-path only — the extra stats/readdir never run on success. + */ +import path from "node:path"; +import { readdir, stat } from "node:fs/promises"; +import type { Dirent } from "node:fs"; +import { modelVisiblePath } from "../../internal/model-visible-path.js"; + +/** Max directory entries named in the hint; the rest collapse into a "+N more" count. */ +const MAX_LISTED_ENTRIES = 8; + +/** + * Entry labels ranked by longest shared case-insensitive prefix with the missing name + * (near-misses like `agent_state/` vs `AGENTS.md` surface first), ties alphabetical. + * Directories get a trailing "/" so the model can tell what one more segment would hit. + */ +function rankEntries(entries: Dirent[], missing: string): string[] { + const target = missing.toLowerCase(); + const scored = entries.map((entry) => { + const name = entry.name.toLowerCase(); + const max = Math.min(name.length, target.length); + let prefix = 0; + while (prefix < max && name[prefix] === target[prefix]) prefix += 1; + return { entry, prefix }; + }); + scored.sort((a, b) => b.prefix - a.prefix || a.entry.name.localeCompare(b.entry.name)); + return scored.map(({ entry }) => (entry.isDirectory() ? `${entry.name}/` : entry.name)); +} + +/** + * Diagnostic sentence(s) for a path that failed to stat, prefixed with a space so callers + * can append it verbatim to their own message ("" when nothing useful can be said). + * Reports the deepest existing ancestor, the first missing segment, and the ancestor's + * nearest-named entries — or that the "ancestor" is a file (the ENOTDIR case). + */ +export async function missingPathHint(resolved: string): Promise { + let ancestor = path.dirname(resolved); + let missing = path.basename(resolved); + for (;;) { + try { + const st = await stat(ancestor); + if (!st.isDirectory()) { + return ` Note: "${modelVisiblePath(ancestor)}" exists but is a file, not a directory.`; + } + break; + } catch { + // Any stat failure (missing, unreadable) keeps the walk going: report the deepest + // ancestor that is *visibly* there. dirname is a fixpoint at the filesystem root. + const parent = path.dirname(ancestor); + if (parent === ancestor) return ""; + missing = path.basename(ancestor); + ancestor = parent; + } + } + const head = ` The directory "${modelVisiblePath(ancestor)}" exists but has no entry "${missing}".`; + let entries: Dirent[]; + try { + entries = await readdir(ancestor, { withFileTypes: true }); + } catch { + return head; + } + if (entries.length === 0) return `${head} It is empty.`; + const labels = rankEntries(entries, missing); + const shown = labels.slice(0, MAX_LISTED_ENTRIES); + const hidden = labels.length - shown.length; + return `${head} It contains: ${shown.join(", ")}${hidden > 0 ? ` (+${hidden} more)` : ""}.`; +} diff --git a/packages/core/src/environment/tools/read-file.ts b/packages/core/src/environment/tools/read-file.ts index 118b4d9..160bc7e 100644 --- a/packages/core/src/environment/tools/read-file.ts +++ b/packages/core/src/environment/tools/read-file.ts @@ -33,6 +33,7 @@ import { open, realpath, stat } from "node:fs/promises"; import { partialToolCallOutput } from "../../omnimessage/index.js"; import type { OmniMessage } from "../../omnimessage/index.js"; import type { ToolDefinitionConfig } from "../../interfaces.js"; +import { missingPathHint } from "./path-hint.js"; import type { BuiltinTool, ToolExecutionContext, ToolResult } from "./types.js"; /** Tool name constant (used only within this tool module, never exposed to Environment). */ @@ -299,9 +300,12 @@ export function createReadFileTool(definition: ToolDefinitionConfig): BuiltinToo } catch (err) { if (signal?.aborted) return { stopReason: "aborted" }; const code = (err as NodeJS.ErrnoException).code; - if (code === "ENOENT") { + // ENOTDIR is the same mistake seen one segment later (a file used as a directory), + // so it gets the same diagnosis instead of a raw errno message. + if (code === "ENOENT" || code === "ENOTDIR") { + const hint = await missingPathHint(resolved); yield delta( - `File not found: "${filePath}". Check the path — relative paths resolve against the workspace (${modelVisiblePath(ctx.workspaceDir)}).`, + `File not found: "${filePath}". Absolute paths are supported; relative paths resolve against the workspace (${modelVisiblePath(ctx.workspaceDir)}).${hint}`, ); } else { const message = err instanceof Error ? err.message : String(err); diff --git a/packages/core/test/file-tools.test.ts b/packages/core/test/file-tools.test.ts index c1cdc71..9ffaad3 100644 --- a/packages/core/test/file-tools.test.ts +++ b/packages/core/test/file-tools.test.ts @@ -31,6 +31,7 @@ import { WRITE_FILE_NAME, createWriteFileTool } from "../src/environment/tools/w import type { BuiltinTool, ToolResult } from "../src/environment/tools/types.js"; import type { OmniMessage } from "../src/omnimessage/index.js"; import type { ToolDefinitionConfig } from "../src/interfaces.js"; +import { modelVisiblePath } from "../src/internal/model-visible-path.js"; function def(name: string, permission: "r" | "rw"): ToolDefinitionConfig { return { name, description: "test", permission }; @@ -128,6 +129,51 @@ describe("read_file", () => { expect(text).toContain("File not found"); expect(text).toContain("missing.txt"); expect(text).toContain("workspace"); + expect(text).toContain("Absolute paths are supported"); + expect(text).toContain( + `The directory "${modelVisiblePath(tmp)}" exists but has no entry "missing.txt".`, + ); + expect(text).toContain("It is empty."); + }); + + it("points at the nearest existing directory and its closest-named entries", async () => { + await mkdir(path.join(tmp, "agent_state")); + await writeFile(path.join(tmp, "agent_state", "AGENTS.md"), "persona\n"); + await writeFile(path.join(tmp, "notes.txt"), "n\n"); + const { result, text } = await run(tool(), { file_path: path.join(tmp, "AGENTS.md") }, tmp); + expect(result?.stopReason).toBe("failed"); + expect(text).toContain( + `The directory "${modelVisiblePath(tmp)}" exists but has no entry "AGENTS.md".`, + ); + // Ranked by shared prefix with the missing name: agent_state/ first, dirs marked with "/". + expect(text).toContain("It contains: agent_state/, notes.txt"); + }); + + it("reports the first missing segment of a deeper path", async () => { + const missing = path.join(tmp, "nope", "deep", "file.txt"); + const { result, text } = await run(tool(), { file_path: missing }, tmp); + expect(result?.stopReason).toBe("failed"); + expect(text).toContain( + `The directory "${modelVisiblePath(tmp)}" exists but has no entry "nope".`, + ); + }); + + it("says so when a path segment is a file, not a directory", async () => { + await writeFile(path.join(tmp, "a.txt"), "x\n"); + const inner = path.join(tmp, "a.txt", "inner.txt"); + const { result, text } = await run(tool(), { file_path: inner }, tmp); + expect(result?.stopReason).toBe("failed"); + expect(text).toContain("File not found"); + expect(text).toContain( + `Note: "${modelVisiblePath(path.join(tmp, "a.txt"))}" exists but is a file, not a directory.`, + ); + }); + + it("caps the entry listing and counts the rest", async () => { + for (let i = 0; i < 10; i += 1) await writeFile(path.join(tmp, `e${i}.txt`), "x\n"); + const { text } = await run(tool(), { file_path: "zz.txt" }, tmp); + expect(text).toContain("(+2 more)"); + expect(text).not.toContain("e8.txt"); }); it("fails when the path is a directory", async () => { @@ -250,6 +296,9 @@ describe("edit_file", () => { expect(result?.stopReason).toBe("failed"); expect(text).toContain("File not found"); expect(text).toContain("write_file"); + expect(text).toContain( + `The directory "${modelVisiblePath(tmp)}" exists but has no entry "nope.txt".`, + ); }); it("fails when the path is a directory", async () => { diff --git a/packages/skills/skills/agent-creation/SKILL.md b/packages/skills/skills/agent-creation/SKILL.md index edcb1e7..d911eb8 100644 --- a/packages/skills/skills/agent-creation/SKILL.md +++ b/packages/skills/skills/agent-creation/SKILL.md @@ -3,8 +3,8 @@ name: agent-creation description: Create or configure an Agent State from a user requirement by writing AGENTS.md, setting identity metadata, and installing only needed Skills. short_description: Turn a requirement into a working agent. short_description_zh: 把需求变成可用的 Agent。 -version: 6 -updated: 2026-07-29T17:20:58Z +version: 7 +updated: 2026-08-03T04:03:59Z --- # Agent Creation @@ -89,7 +89,7 @@ mkdir -p "$TARGET/agent_state/skills" "$TARGET/agent_state/memory" "$TARGET/agen cp "$APP_DATA_DIR/agents/default_agent/agent_state/system_config.yaml" "$TARGET/agent_state/" ``` -Then set the top-level `name`, `description`, and `version: 1`, set `model.thinking_level` to the resolved value, write `AGENTS.md`, and install only the Skills required by the user's requirement. Do not persist the resolved provider/model pair in the Agent State. +Then set the top-level `name`, `description`, and `version: 1`, set `model.thinking_level` to the resolved value, write `agent_state/AGENTS.md` (it lives under `agent_state/`, not at the agent directory root), and install only the Skills required by the user's requirement. Do not persist the resolved provider/model pair in the Agent State. ## Validate and report