Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fuzzy-homes-read.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"eve": patch
---

Resolve leading `$HOME` paths in the built-in `read_file`, `write_file`, `glob`, and `grep` tools so agents can directly access packaged skill references advertised in their prompt.
2 changes: 2 additions & 0 deletions docs/concepts/default-harness.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ The shell and file tools (`bash`, `read_file`, `write_file`, `glob`, `grep`) run
| `load_skill` | Pull an on-demand [skill](../skills)'s instructions into the current turn. Present only when the agent declares skills. | App runtime |
| `connection_search` | Discover tools across declared [connections](../connections); matched tools become directly callable. Present only when the agent declares connections. | App runtime |

The model-facing file tools accept absolute paths and paths beginning with `$HOME/`. eve resolves `$HOME` against the sandbox before invoking non-shell file operations, so packaged skill references such as `$HOME/.agents/skills/<skill>/references/...` work consistently across `read_file`, `write_file`, `glob`, and `grep`.

Notes:

- **`agent`** is available only in the root session. Its child uses the root's instructions, tools, connections, and sandbox, but starts with fresh conversation history and fresh [state](../guides/state). The child receives neither `agent` nor `Workflow`; declared subagents do not receive the built-in `agent` either. See [Subagents](../subagents).
Expand Down
2 changes: 1 addition & 1 deletion docs/sandbox.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ The model already has shell and file access through the default tools:
| `glob` | find files by pattern |
| `grep` | search file contents |

All of them run with `/workspace` as the working directory. Any authored runtime function (a tool, a step, a model callback) can get a live sandbox handle with `ctx.getSandbox()`.
All of them run with `/workspace` as the working directory. The model-facing file tools accept both absolute paths and paths beginning with `$HOME/`; eve resolves the latter inside the sandbox before reading, writing, or searching. Any authored runtime function (a tool, a step, a model callback) can get a live sandbox handle with `ctx.getSandbox()`.

```ts title="agent/tools/run_analysis.ts"
import { defineTool } from "eve/tools";
Expand Down
7 changes: 3 additions & 4 deletions packages/eve/src/execution/sandbox/glob-tool.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { normalizeModelPath } from "#runtime/framework-tools/file-state.js";
import { validateAbsoluteFilePath } from "#execution/sandbox/require-sandbox.js";
import { resolveAbsoluteFilePath } from "#execution/sandbox/require-sandbox.js";
import type { SandboxSession } from "#shared/sandbox-session.js";
import { ripgrepIsAvailable } from "#execution/sandbox/ripgrep-probe.js";
import { shellQuote } from "#execution/sandbox/shell-quote.js";
Expand Down Expand Up @@ -49,9 +49,8 @@ export async function executeGlobOnSandbox(
): Promise<GlobResult> {
const effectivePath = args.path ?? DEFAULT_PATH;

validateAbsoluteFilePath(effectivePath);

const normalizedPath = normalizeModelPath(effectivePath);
const resolvedPath = await resolveAbsoluteFilePath(sandbox, effectivePath);
const normalizedPath = normalizeModelPath(resolvedPath);
const effectiveLimit = Math.min(Math.max(1, args.limit ?? DEFAULT_GLOB_LIMIT), MAX_GLOB_LIMIT);

const command = (await ripgrepIsAvailable(sandbox))
Expand Down
7 changes: 3 additions & 4 deletions packages/eve/src/execution/sandbox/grep-tool.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { normalizeModelPath } from "#runtime/framework-tools/file-state.js";
import { validateAbsoluteFilePath } from "#execution/sandbox/require-sandbox.js";
import { resolveAbsoluteFilePath } from "#execution/sandbox/require-sandbox.js";
import type { SandboxSession } from "#shared/sandbox-session.js";
import { ripgrepIsAvailable } from "#execution/sandbox/ripgrep-probe.js";
import { shellQuote } from "#execution/sandbox/shell-quote.js";
Expand Down Expand Up @@ -53,9 +53,8 @@ export async function executeGrepOnSandbox(
): Promise<GrepResult> {
const effectivePath = args.path ?? DEFAULT_PATH;

validateAbsoluteFilePath(effectivePath);

const normalizedPath = normalizeModelPath(effectivePath);
const resolvedPath = await resolveAbsoluteFilePath(sandbox, effectivePath);
const normalizedPath = normalizeModelPath(resolvedPath);
const effectiveLimit = Math.min(Math.max(1, args.limit ?? DEFAULT_GREP_LIMIT), MAX_GREP_LIMIT);
const contextLines = args.context !== undefined && args.context > 0 ? args.context : 0;

Expand Down
8 changes: 4 additions & 4 deletions packages/eve/src/execution/sandbox/read-file-tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
normalizeModelPath,
setReadFileStamp,
} from "#runtime/framework-tools/file-state.js";
import { validateAbsoluteFilePath } from "#execution/sandbox/require-sandbox.js";
import { resolveAbsoluteFilePath } from "#execution/sandbox/require-sandbox.js";
import type { SandboxSession } from "#shared/sandbox-session.js";
import { capLineLength, MAX_OUTPUT_BYTES } from "#execution/sandbox/truncate-output.js";

Expand Down Expand Up @@ -58,8 +58,8 @@ export async function executeReadFileOnSandbox(
): Promise<ReadFileResult> {
const { filePath, offset, limit } = args;

validateAbsoluteFilePath(filePath);
const normalizedPath = normalizeModelPath(filePath);
const resolvedPath = await resolveAbsoluteFilePath(sandbox, filePath);
const normalizedPath = normalizeModelPath(resolvedPath);

// ── Validate offset / limit ─────────────────────────────────────────
const effectiveOffset = offset ?? DEFAULT_OFFSET;
Expand All @@ -70,7 +70,7 @@ export async function executeReadFileOnSandbox(
}

// ── Read full file for fingerprinting ───────────────────────────────
const rawContent = await sandbox.readTextFile({ path: filePath });
const rawContent = await sandbox.readTextFile({ path: resolvedPath });

if (rawContent === null) {
throw new Error(
Expand Down
18 changes: 13 additions & 5 deletions packages/eve/src/execution/sandbox/require-sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { loadContext } from "#context/container.js";
import { SandboxKey } from "#context/keys.js";
import type { SandboxSession } from "#public/definitions/sandbox.js";
import { bindSandboxAbortSignal } from "#execution/sandbox/abort-bound-session.js";
import { resolveSandboxModelPath } from "#shared/skill-paths.js";

/**
* Resolves the active sandbox session from the runtime context.
Expand Down Expand Up @@ -33,14 +34,21 @@ export async function requireSandboxSession(abortSignal?: AbortSignal): Promise<
}

/**
* Validates that a model-supplied file path is absolute. Throws a
* descriptive error when the path does not start with `/`.
* Resolves a model-supplied `$HOME` prefix and validates that the resulting
* sandbox file path is absolute.
*/
export function validateAbsoluteFilePath(filePath: string): void {
if (!filePath.startsWith("/")) {
export async function resolveAbsoluteFilePath(
sandbox: SandboxSession,
filePath: string,
): Promise<string> {
const resolvedPath = await resolveSandboxModelPath({ path: filePath, sandbox });

if (!resolvedPath.startsWith("/")) {
throw new Error(
`filePath must be an absolute path. Received: "${filePath}". ` +
"Use an absolute path such as /workspace/foo.ts.",
"Use an absolute path such as /workspace/foo.ts or a path beginning with $HOME/.",
);
}

return resolvedPath;
}
12 changes: 6 additions & 6 deletions packages/eve/src/execution/sandbox/write-file-tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
ReadFileStateKey,
setReadFileStamp,
} from "#runtime/framework-tools/file-state.js";
import { validateAbsoluteFilePath } from "#execution/sandbox/require-sandbox.js";
import { resolveAbsoluteFilePath } from "#execution/sandbox/require-sandbox.js";
import type { SandboxSession } from "#shared/sandbox-session.js";

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -47,9 +47,9 @@ export async function executeWriteFileOnSandbox(
): Promise<WriteFileResult> {
const { filePath, content } = args;

validateAbsoluteFilePath(filePath);
const resolvedPath = await resolveAbsoluteFilePath(sandbox, filePath);
const ctx = loadContext();
const normalizedPath = normalizeModelPath(filePath);
const normalizedPath = normalizeModelPath(resolvedPath);
const targetKey = buildReadFileTargetKey(normalizedPath);

// ── Read current file ───────────────────────────────────────────────
Expand All @@ -58,11 +58,11 @@ export async function executeWriteFileOnSandbox(
// cost: the entire file is read and hashed before every write. A
// separate `exists()` primitive would avoid this for new files but
// would require a sandbox session API change.
const currentContent = await sandbox.readTextFile({ path: filePath });
const currentContent = await sandbox.readTextFile({ path: resolvedPath });

if (currentContent === null) {
// ── File does not exist — write immediately, no prior read needed ──
await sandbox.writeTextFile({ content, path: filePath });
await sandbox.writeTextFile({ content, path: resolvedPath });

const freshStamp = createReadFileStamp({
content,
Expand Down Expand Up @@ -101,7 +101,7 @@ export async function executeWriteFileOnSandbox(
}

// ── Write and refresh stamp ─────────────────────────────────────────
await sandbox.writeTextFile({ content, path: filePath });
await sandbox.writeTextFile({ content, path: resolvedPath });

const freshStamp = createReadFileStamp({
content,
Expand Down
2 changes: 1 addition & 1 deletion packages/eve/src/runtime/framework-tools/glob.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export const GLOB_INPUT_SCHEMA = z.strictObject({
.string()
.describe(
"The directory to search in. Defaults to /workspace. " +
"Must be an absolute path. Omit to use the default.",
"Must be an absolute path or begin with $HOME/. Omit to use the default.",
)
.optional(),
pattern: z
Expand Down
2 changes: 1 addition & 1 deletion packages/eve/src/runtime/framework-tools/grep.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export const GREP_INPUT_SCHEMA = z.strictObject({
.string()
.describe(
"The directory or file to search in. Defaults to /workspace. " +
"Must be an absolute path. Omit to use the default.",
"Must be an absolute path or begin with $HOME/. Omit to use the default.",
)
.optional(),
pattern: z
Expand Down
6 changes: 4 additions & 2 deletions packages/eve/src/runtime/framework-tools/read-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ import type { ToolExecuteOptions } from "#shared/tool-definition.js";
* model input contracts in sync without duplication.
*/
export const READ_FILE_INPUT_SCHEMA = z.strictObject({
filePath: z.string().describe("The absolute path to the file to read."),
filePath: z
.string()
.describe("The absolute path to the file to read. A leading $HOME is supported."),
limit: z
.number()
.int()
Expand Down Expand Up @@ -56,7 +58,7 @@ export const READ_FILE_TOOL_DEFINITION: ResolvedToolDefinition = {
"Read a file from the local filesystem. If the path does not exist, an error is returned.",
"",
"Usage:",
"- The filePath parameter should be an absolute path.",
"- The filePath parameter should be an absolute path or begin with $HOME/.",
"- By default, this tool returns up to 2000 lines from the start of the file.",
"- The offset parameter is the line number to start from (1-indexed).",
"- To read later sections, call this tool again with a larger offset.",
Expand Down
2 changes: 1 addition & 1 deletion packages/eve/src/runtime/framework-tools/write-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export const WRITE_FILE_INPUT_SCHEMA = z.strictObject({
content: z.string().describe("Complete replacement file contents."),
filePath: z
.string()
.describe("The absolute path to the file to write (must be absolute, not relative)."),
.describe("The absolute path to the file to write. A leading $HOME is supported."),
});

/**
Expand Down
35 changes: 35 additions & 0 deletions packages/eve/src/shared/skill-paths.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
MODEL_SKILL_ROOT,
formatFallbackSkillPath,
formatSkillModelPath,
resolveSandboxModelPath,
resolveSandboxSeedFilePath,
resolveSandboxSkillReadPaths,
resolveSandboxSkillRoot,
Expand Down Expand Up @@ -39,6 +40,34 @@ describe("skill path helpers", () => {
expect(sandbox.commandLog).toEqual([HOME_PROBE_COMMAND]);
});

it("expands a leading $HOME in model-supplied paths", async () => {
const sandbox = mockSandbox({
commands: {
[HOME_PROBE_COMMAND]: { exitCode: 0, stderr: "", stdout: "/home/agent/\n" },
},
});

await expect(
resolveSandboxModelPath({ path: "$HOME/notes/todo.md", sandbox: sandbox.session }),
).resolves.toBe("/home/agent/notes/todo.md");
await expect(
resolveSandboxModelPath({ path: "$HOME", sandbox: sandbox.session }),
).resolves.toBe("/home/agent");
expect(sandbox.commandLog).toEqual([HOME_PROBE_COMMAND]);
});

it("does not treat lookalike variables or embedded $HOME as home paths", async () => {
const sandbox = mockSandbox();

await expect(
resolveSandboxModelPath({ path: "$HOME_DIR/notes.md", sandbox: sandbox.session }),
).resolves.toBe("$HOME_DIR/notes.md");
await expect(
resolveSandboxModelPath({ path: "/workspace/$HOME/notes.md", sandbox: sandbox.session }),
).resolves.toBe("/workspace/$HOME/notes.md");
expect(sandbox.commandLog).toEqual([]);
});

it("falls back to /workspace/skills when HOME is unusable", async () => {
const sandbox = mockSandbox({
commands: {
Expand All @@ -49,6 +78,12 @@ describe("skill path helpers", () => {
await expect(resolveSandboxSkillRoot({ sandbox: sandbox.session })).resolves.toBe(
FALLBACK_SKILL_ROOT,
);
await expect(
resolveSandboxModelPath({
path: `${MODEL_SKILL_ROOT}/research/references/catalog.md`,
sandbox: sandbox.session,
}),
).resolves.toBe("/workspace/skills/research/references/catalog.md");
});

it("reads only from the resolved HOME skill root when HOME is usable", async () => {
Expand Down
Loading
Loading