Read and Search a Codebase

A read-only agent that greps and reads files in a mounted repo and reports what it found.

This recipe builds a code-investigation agent. Given a repo path and a question, it greps and reads files inside the repo, then returns a structured answer plus the list of files it consulted. The agent gets read, grep, find, ls — and nothing that writes.

Level 2 of 7. This is your first taste of tools and sandboxes. It assumes Level 1 — structured extraction, where the agent had no tools. Here you add file tools and mount a repo read-only.

Scope the tools down

The agent investigates; it never mutates. So you list exactly four tool ids:

ts
tools: ["ls", "grep", "find", "read"]

No write, no edit, no bash. Two reasons:

  • bash is unnecessary. grep, find, ls, and read cover discovery and reading. They run inside the sandbox and obey its mounts — you don’t need a shell to chain them.
  • A smaller toolset is a smaller blast radius. The model can only call what you list. Omitting write/edit means a read-only task stays read-only by construction, not by hoping the prompt holds.

Define the agent

search-agent.ts
ts
import { Type } from "@sinclair/typebox";
import { defineAgent } from "@drover/core";

export const codeSearch = defineAgent({
  id: "code-search",
  description: "Answer questions about a codebase by reading and grepping it.",
  systemPrompt: [
    "You investigate a code repository to answer a question.",
    "Use grep/find/ls to locate relevant files, read to inspect them.",
    "Cite every file you opened in files_consulted. Do not guess paths you",
    "did not read. Return JSON matching the outputSchema, no prose.",
  ].join(" "),
  inputSchema: Type.Object({
    repo_path: Type.String(),
    question: Type.String(),
  }),
  outputSchema: Type.Object({
    answer: Type.String(),
    files_consulted: Type.Array(Type.String()),
    snippets: Type.Optional(
      Type.Array(
        Type.Object({
          file: Type.String(),
          lines: Type.String(),
          text: Type.String(),
        }),
      ),
    ),
  }),
  model: "cheap",
  tools: ["ls", "grep", "find", "read"],
  quota: { maxTurns: 8 },
});

maxTurns: 8 gives the agent room for a few grep/read cycles before it must answer. Investigation is iterative — a flat-out cap of 2 would starve it.

Build the read-only sandbox

By default runAgent mounts the run’s cwd read-write. For investigation you want the opposite: mount the repo readonly so even a stray write (which isn’t in the toolset anyway) could not reach disk.

search-agent.ts
ts
import { createJustBashSandbox } from "@drover/sandbox-just-bash";

function readonlyRepo(repoPath: string) {
  return createJustBashSandbox({
    mounts: [{ source: repoPath, target: repoPath, mode: "readonly" }],
  });
}

Mounting source and target at the same path means absolute paths the model emits resolve to the same place you’d see on the host. The agent cannot escape the mount root — no .., absolute-path, or symlink traversal reaches outside repoPath. Anything you didn’t mount simply does not exist to the agent. Contrast the default:

SetupMountWhat the agent can touch
Default (sandbox omitted)run cwd, readwritereads and writes under cwd
This reciperepoPath, readonlyreads under repoPath; writes fail

The file tools honour the mount. read, grep, find, ls all operate inside the sandbox namespace and gate user paths through the adapter — there is no path outside the mount for them to reach. See Sandboxes for the full boundary model.

Run it and watch the investigation

Pass the sandbox and a cwd so relative paths the model uses resolve against the repo root:

search-agent.ts
ts
import { runAgent } from "@drover/facade";

const repoPath = process.argv[2] ?? process.cwd();

const handle = runAgent(
  codeSearch,
  { repo_path: repoPath, question: "Where is the model alias map defined?" },
  { sandbox: readonlyRepo(repoPath), cwd: repoPath },
);

for await (const e of handle.events) {
  if (e.kind === "tool_call_start") console.log("→ calling", e.toolName);
  if (e.kind === "tool_call_end") console.log("← done   ", e.toolName);
  if (e.kind === "assistant_text") console.log("[reason]", e.text);
}

const result = await handle.result;
console.log("status:", result.status);
if (result.status === "success" && result.output) {
  console.log("answer:", result.output.answer);
  console.log("files:", result.output.files_consulted);
}

The event stream is your window into the agent’s reasoning loop:

  • tool_call_start / tool_call_end bracket each tool invocation — watching them shows you the grep-then-read path the agent took.
  • assistant_text carries the model’s between-call reasoning.

A plausible run prints:

→ calling grep
← done    grep
[reason] Found DEFAULT_ALIASES in packages/model/src. Reading it.
→ calling read
← done    read
status: success
answer: Defined in @drover/model as DEFAULT_ALIASES (packages/model/src).
files: [ "packages/model/src/aliases.ts" ]

What you assembled

  • Built-in tools ls, grep, find, read from @drover/tools — discovery + reading, no mutation.
  • createJustBashSandbox with a single readonly mount — a real filesystem boundary; the agent can’t escape repoPath.
  • cwd in RunOptions so relative paths resolve against the repo root.
  • quota: { maxTurns: 8 } to bound the investigation loop.
  • An event loop filtering tool_call_start / tool_call_end / assistant_text to trace each step.

Level up

Type to search…

↑↓ navigate open esc close