Skip to content

[bug] A Repo Coder cannot find a file — no search tool, repo_tree stops at 4 levels without saying so, and repo_git's path is ignored by 4 of its 5 commands #508

Closed

Description

@serge-ivo

A Repo Coder has no way to FIND a file — and the tool that looks like it can silently ignores the parameter that would

The repo-local connector gives a Repo Coder four read tools: repo_tree, repo_read_file,
repo_git, repo_remote (workers/api/src/lib/connectors/repo-local.ts:120-250, complete list).
None of them searches. There is no grep, no filename match, no content match — grepped for
repo_grep, repo_search, repo_find across the worker and the runner: 0 hits. So locating a
file means walking the tree by hand, and when the tree runs out the model guesses.

What that looks like in one turn

Heartfull (f8ddc272-0390-4826-8812-94989e3d2ebd, agent coder-repo), 2026-08-11 22:28:39,
answering "Check why host role can't manage events via portal, add ticket to allow this".
18 tool calls in that one turn: 1 github_list_issues, 5 repo_tree, 12 repo_read_file
of which 3 failed —

❌ repo_read_file  Error: Runner /coding/read-file → 400: {"error":"ENOENT: no such file or
                   directory, stat '/Users/serge/dev/heartfull/pla…
❌ repo_read_file  Error: Runner /coding/read-file → 400: {"error":"not a regular file:
                   admin/lib/features/events/ui/pages"}
❌ repo_read_file  Error: Runner /coding/read-file → 400: {"error":"not a regular file:
                   admin/lib/app"}

Two of the three are the model passing a directory to repo_read_file. That is the tell: it had
been shown that directory as a leaf with nothing under it, and read it as a file.

Mechanism 1 — repo_tree stops at 4 levels and never says so

packages/browser-runner/src/coding/inspect.ts:129-164:

export function repoTree(workDir, relPath = ".", maxDepth = 3, maxEntries = 500) {
    const depthCap = Math.max(1, Math.min(4, maxDepth));      // :131
    …
    if (entries.length >= entryCap) { truncated = true; return …; }   // :146-149
    …
    if (it.isDirectory()) {
        entries.push({ path: rel, type: "dir" });
        if (depth + 1 < depthCap) queue.push({ dir: abs, depth: depth + 1 });   // :152-154
    }

Two facts compose into the bug:

  1. maxDepth is clamped to 4 and the schema never says so. The tool advertises
    maxDepth: "How many folder levels deep to walk (default 3)" (repo-local.ts:130) with no
    ceiling. A model asking for maxDepth: 10 silently gets 4. Walking from the repo root, the
    deepest path it can ever list is four segments. The file this turn was actually about is
    admin/lib/features/events/ui/pages/event_form_dialog.dart — seven. It is not reachable
    from the root in any number of calls that do not already know the answer.
  2. Depth truncation sets no flag. truncated is assigned in exactly one place, the entry cap
    (:147). A directory at the depth boundary is emitted as an entry and its children are simply
    not queued — so repo-local.ts:151-157 renders it as admin/lib/features/events/ with
    res.truncated === false, i.e. without the "(truncated — narrow with path)" note.

From the model's side, "this folder is empty" and "this folder is deeper than the cap" are the same
observation. That is why it called repo_read_file on a directory, twice.

Mechanism 2 — repo_git's path is honoured by 1 of its 5 commands

repo_git advertises path: "Limit the command to one file or folder (optional)."
(repo-local.ts:214). runRepoGit resolves it (inspect.ts:93) and hands it to gitArgv, which
uses it only for diff (inspect.ts:44-63):

case "status":    return ["status", "--short", "--branch"];   // relPath dropped
case "diff":      return opts.relPath ? ["diff", "--", opts.relPath] : ["diff"];
case "diff-stat": return ["diff", "--stat"];                  // relPath dropped
case "log":       return ["log", "--oneline", "-n", String(clampN)];   // relPath dropped
case "ls-files":  return ["ls-files"];                        // relPath dropped

So repo_git {cmd:"ls-files", path:"admin/lib/features/events"} returns every tracked file in the
repository
, capped at 12 KB (repo-local.ts:38 CAPS.git) and truncated mid-list, rather than
the folder asked for. git ls-files -- <path> is the file-finder this agent has been missing the
whole time, and the parameter to reach it is already in the schema and already validated —
it is just dropped one function later.

The existing test only covers the one command that works
(packages/browser-runner/src/coding/inspect.test.ts:39-41, "only appends a path after a literal
-- separator"
, asserted for diff alone), so nothing fails.

What this costs beyond the failed calls

The chat's own account of a file is unreliable when it could not locate it. Same instance,
2026-08-12 00:02:25, the agent told the owner the fix was going into
app/lib/features/events/ui/widgets/event_form_dialog.dart; the run's completion message
(00:06:46) reports the change landed in admin/lib/features/events/ui/pages/event_form_dialog.dart.
The first path does not exist. (Inferred, not proven: I did not reproduce that the wrong path came
from the depth cap specifically — the agent had read the correct file 40 minutes earlier. I record
it as the visible consequence of an agent that navigates by guessing, not as a second mechanism.)

What to do — cheapest first

Step 1 (cloud only, ships today, no CLI release). Tell the truth in the two descriptions:
maxDepth is capped at 4 and a listing stops there; when a folder appears with no children,
call repo_tree again with path set to it rather than assuming it is empty or a file. Same for
repo_git's path: say it applies to diff only, until step 2 lands. This alone removes the
directory-as-file calls, which were 2 of the 3 failures measured.

Step 2 (runner) — honour path where git already supports it. gitArgv gains
["ls-files", "--", relPath], ["diff", "--stat", "--", relPath], ["log", "--oneline", "-n", N, "--", relPath]. Same -- discipline, same resolveInside validation, no new endpoint, no new
surface. ls-files narrowed by folder is a real file-finder.

Step 3 (runner + connector) — the actual search tool: repo_grep. git grep -n -I --untracked -e <pattern> -- <path> behind the existing /coding/git endpoint (packages/browser-runner/src/server.ts:240)
as a whitelisted command, or a sibling endpoint if the argv shape does not fit. scope: "read",
capped like the others (12 KB), fixed-position argv with the pattern after -e so it can never be
read as a flag, path after --. Declared on coder-repo and local-repo-chat by migration in the
0101/0108 shape.

Deployment constraint, stated because it decides the order. The runner ships inside the
published CLI (@proagentstore/cli, currently 0.4.48; @proagentstore/browser-runner 0.2.8),
so steps 2 and 3 reach a user only after a version bump and a CI publish — and an old runner will
400 on an unknown cmd. The new tool must therefore report "your runner is too old — run
npm i -g @proagentstore/cli" rather than surfacing a raw 400, and step 1 must not depend on either.

Alternatives considered and rejected

  • Just raise the depth cap. Rejected: 500 entries is the other cap, and a deep monorepo blows it
    before it gets deep — the owner's repos are exactly this shape. It also leaves the silent-stop
    problem, which is the part that produced wrong tool calls.
  • Let the model use the Engine to grep. That is the current workaround and it is the same
    detour [bug] A coder agent can open a GitHub issue and then never touch it — no comment, close, relabel or assign tool, so closing one ticket costs a whole Engine run #507 documents for GitHub writes: a chat question costs a coding run on the owner's
    machine, and is impossible for any agent with no runner-side session.
  • A find/ripgrep shell-out. Rejected: the connector's stated design is "no user string ever
    becomes a git token except a resolveInside-validated path after a literal --"
    (inspect.ts:11-14). git grep keeps that property; a shell does not.
  • Making repo_tree return truncatedByDepth. Worth doing as part of step 2, but it is not a
    substitute for step 1: the flag has to be rendered into the tool result the model reads, and the
    description fix ships without a runner release.

Acceptance criteria

  • repo_git {cmd:"ls-files", path:"admin/lib/features/events"} returns only that subtree.
  • inspect.test.ts asserts the -- path for ls-files, diff-stat and log, not just diff.
  • repo_tree on a folder at the depth cap makes the stop visible in the tool result — the model
    can tell "empty" from "deeper than I showed you".
  • repo_grep finds a symbol in a 7-segment path in ONE call, on the Heartfull checkout, and the
    answer cites the real path.
  • Replaying the 2026-08-11 22:28 question produces zero repo_read_file calls whose argument
    is a directory.
  • Against a runner older than the release, the new tool reports the version problem in words;
    it does not surface Runner /coding/git → 400.

Regression risk

  • A new git command is a new argv. The whole safety argument of this connector is the fixed-argv
    whitelist; git grep takes a user-supplied pattern, which nothing here has done before. It must
    go after -e at a fixed position, never concatenated, and the existing "throws on an unknown
    command" test must be joined by one asserting a pattern that looks like a flag stays a pattern.
  • Output volume. git grep on a broad pattern can return megabytes; the 12 KB cap must be
    applied to it and reported as truncated, or one call eats the context window the tool exists to
    save.
  • Old runners. Covered by the last criterion; without it this fix converts "no search tool" into
    "search tool that errors", which is worse for anyone who has not upgraded.

Activity

  1. serge-ivo commented on Aug 12, 2026

    @serge-ivo
    ContributorAuthor

    Fixed in 7ea6359, with a fix-forward in f4aa59c. Both on main; CI green on f4aa59c; API Worker deployed; CLI published.

    All three causes addressed.

    1. repo_git's path now reaches all five commands, each after git's literal -- separator, so the "no user string becomes a git token except a validated path after --" property is unchanged. status is included too — the description can then say "applies to every command" with no caveat, and a caveat is something a model has to reason about and can get wrong.
    2. repo_tree's depth stop is visible. Each directory whose contents were not listed is marked, and the listing says it stopped. Worded "contents not listed" rather than "has more inside" on purpose: confirming the latter costs a readdir per boundary directory and the model's next move — call again with path — is the same either way. maxDepth's ceiling of 4 is now stated in the schema.
    3. repo_find + repo_grep, two tools over one runner endpoint POST /coding/search.

    Shape of the search tool, and why. Two tools rather than one with a mode: that argument would make "am I looking for a name or a usage?" a parameter the model must get right on the exact turn where it is already lost. One runner endpoint keeps a single version skew to reason about.

    Bounding was the real decision. A byte cap alone moves the problem into TOOL_RESULT_MAX_CHARS — how #503 happened. Every bound is COUNTED: 50 results total, git's own --max-count=5 per file so a minified bundle cannot spend the budget, 160 chars per line, 4MB stdout ceiling because git's output is read into memory before any of those apply. Worst case ~11KB. A cut list reports showing 50 of 812, which is what tells the model to narrow rather than conclude.

    Fixed strings, not regexes (-F) — a deliberate deviation from the argv sketched above. foo( is the obvious thing to search for in code and an invalid regex, and no pattern the model invents can become a pathological match on the owner's own machine. In path mode the pattern never becomes a git token at all: the filter runs in JS over ls-files --cached --others --exclude-standard, so an uncommitted file is found and :(exclude)src matches nothing rather than doing something.

    THE OWNER MUST UPGRADE HIS RUNNER. His Mac mini is on 0.4.45. npm i -g @proagentstore/cli (now 0.4.49, published by CI on this commit — verified with npm view) and restart pags up. Below that, repo_find/repo_grep 404 and report "this machine's runner is too old — it needs CLI 0.4.49 or newer" rather than a raw 404, and repo_git warns that the path filter was ignored and the output covers the whole repository. Both description fixes work on any runner today.

    Verified. 27 runner tests including a file seven segments down that no repo_tree call could reach, the :(exclude) pathspec case, an uncommitted file, and the counted-truncation report; 46 connector tests including the old-runner 404 and the "does not blame the CLI for a 500" half. Migration 0121_repo_search_tools.sql confirmed applied to remote D1 in the deploy log (✅), declaring both tools on coder-repo and local-repo-chat; coder/coder-lead excluded because neither declares a repo_* tool.

    Not verified. No live agent turn — that needs the owner's machine on 0.4.49 and a session. The declaration is confirmed applied, not exercised.

    Note on f4aa59c. 7ea6359 went out with registry.test.ts still asserting four repo-local tools, so main was briefly red on a count that same commit made wrong: several sessions share this checkout and branch, and a concurrent session pushed before the amend carrying that fix could be. Nothing lost; fixed forward within minutes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingconnectorsConnector + tool framework

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions