From c8039d1e4853a73bb5366dd5149f1697faf79807 Mon Sep 17 00:00:00 2001 From: roboomp Date: Tue, 14 Jul 2026 16:31:48 +0000 Subject: [PATCH] fix(github): added repository file reads - Added a read-only file_read operation backed by GitHub's contents API. - Routed GitHub repository file requests away from curl and wget. - Covered branch-aware file reads with a regression test. Fixes #4805 --- packages/coding-agent/CHANGELOG.md | 4 ++ .../coding-agent/src/prompts/tools/bash.md | 2 + .../coding-agent/src/prompts/tools/github.md | 7 +++- .../coding-agent/src/prompts/tools/read.md | 1 + packages/coding-agent/src/tools/gh.ts | 42 ++++++++++++++++++- packages/coding-agent/test/tools/gh.test.ts | 29 +++++++++++++ 6 files changed, 82 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 3a03a1977..c1ed3c843 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -6,6 +6,10 @@ - Memoized non-message token totals (system prompt, tool schemas, skills) so the per-turn compaction and context-threshold paths recompute them at most once per input change instead of on every call. `getContextBreakdown` and `#estimateStoredContextTokens` previously re-tokenized the system prompt and every tool's wire schema (per-tool `JSON.stringify`) several times per turn over inputs that change at most once per turn. +### Fixed + +- Fixed GitHub-hosted repository file reads falling back to `curl` by adding a dedicated `github` file-read operation and explicit tool-routing guidance ([#4805](https://github.com/can1357/oh-my-pi/issues/4805)). + ## [16.3.11] - 2026-07-06 ### Changed diff --git a/packages/coding-agent/src/prompts/tools/bash.md b/packages/coding-agent/src/prompts/tools/bash.md index 04bcca23c..362c12798 100644 --- a/packages/coding-agent/src/prompts/tools/bash.md +++ b/packages/coding-agent/src/prompts/tools/bash.md @@ -6,6 +6,8 @@ The shell invokes **real binaries** with simple args. It is NOT full GNU Bash. Use bash ONLY for: a single binary call, or one short pipeline that COMPUTES a fact and does not depend on shell-specific regex/quoting (`wc -l`, `sort | uniq -c`, `comm`, `diff`, a checksum, `git status`). +GitHub repository file? MUST use `github` `file_read` when available; otherwise `read`. NEVER use `curl`/`wget`. + Anything below → `eval` cell, not bash: - Inline interpreter scripts (`-e`/`-c`/`--eval`) when an eval runtime exists for that language - Heredocs (`<`/`pr://`. PR diffs: `pr:///diff` (file listing), `pr:///diff/` (file slice, 1-indexed), `pr:///diff/all` (full diff). +Op-based `gh` wrapper: repos, repository files, PRs, search, checkout, push, Actions watch. Read an issue/PR via `issue://`/`pr://`. PR diffs: `pr:///diff` (file listing), `pr:///diff/` (file slice, 1-indexed), `pr:///diff/all` (full diff). Pick op via `op`. Beyond the field descriptions, per op: - `repo_view` — omit `repo` to view the current checkout. +- `file_read` — reads `path` from `repo`; omit `repo` for the current checkout and `branch` for its default branch. - `pr_create` — `head` defaults to the current branch. - `pr_checkout` — checks PR(s) out into dedicated git worktrees, not your working tree; pass an array of `pr` to batch multiple in one call. - `pr_push` — requires the branch to have been checked out first via `op: pr_checkout`. @@ -15,3 +16,7 @@ Pick op via `op`. Beyond the field descriptions, per op: Concise summary per op. `run_watch` failures save full logs to a session artifact. + + +GitHub-hosted repository file? MUST use `file_read`; NEVER `curl`/`wget`. + diff --git a/packages/coding-agent/src/prompts/tools/read.md b/packages/coding-agent/src/prompts/tools/read.md index 554dc4cab..fa9ec9118 100644 --- a/packages/coding-agent/src/prompts/tools/read.md +++ b/packages/coding-agent/src/prompts/tools/read.md @@ -3,6 +3,7 @@ Read files, directories, archives, SQLite, images, documents, internal resources - SHOULD parallelize independent reads. - SHOULD use `read` (not a browser tool) for web content; browser only when `read` can't deliver. +- GitHub repository file? MUST use `github` `file_read` when available; otherwise `read`. NEVER use `curl`/`wget`. ## Parameters diff --git a/packages/coding-agent/src/tools/gh.ts b/packages/coding-agent/src/tools/gh.ts index a4c016b60..650afbe67 100644 --- a/packages/coding-agent/src/tools/gh.ts +++ b/packages/coding-agent/src/tools/gh.ts @@ -247,6 +247,7 @@ const RUN_FAILURE_CONCLUSIONS = new Set(["failure", "timed_out", "cancelled", "a const JOB_FAILURE_CONCLUSIONS = new Set(["failure", "timed_out", "cancelled", "action_required"]); const GITHUB_READONLY_OPS: ReadonlySet = new Set([ "repo_view", + "file_read", "search_issues", "search_prs", "search_code", @@ -257,10 +258,11 @@ const GITHUB_READONLY_OPS: ReadonlySet = new Set([ const githubSchema = type({ op: type( - "'repo_view' | 'pr_create' | 'pr_checkout' | 'pr_push' | 'search_issues' | 'search_prs' | 'search_code' | 'search_commits' | 'search_repos' | 'run_watch'", + "'repo_view' | 'file_read' | 'pr_create' | 'pr_checkout' | 'pr_push' | 'search_issues' | 'search_prs' | 'search_code' | 'search_commits' | 'search_repos' | 'run_watch'", ).describe("github operation"), "repo?": type("string").describe("owner/repo"), "branch?": type("string").describe("branch"), + "path?": type("string").describe("repository-relative file path"), "pr?": type("string | string[]").describe("pr number, url, or branch"), "force?": type("boolean").describe("reset existing local branch"), "forceWithLease?": type("boolean").describe("force-with-lease push"), @@ -2453,7 +2455,7 @@ export class GithubTool implements AgentTool const op = typeof rawOp === "string" ? rawOp : ""; return GITHUB_READONLY_OPS.has(op) ? "read" : "exec"; }; - readonly summary = "Interact with GitHub issues, pull requests, and repositories"; + readonly summary = "Interact with GitHub repositories, files, pull requests, and Actions"; readonly loadMode = "discoverable"; readonly label = "GitHub"; readonly description = prompt.render(githubDescription); @@ -2478,6 +2480,8 @@ export class GithubTool implements AgentTool switch (params.op) { case "repo_view": return executeRepoView(this.session, params, signal); + case "file_read": + return executeFileRead(this.session, params, signal); case "pr_create": return executePrCreate(this.session, params, signal); case "pr_checkout": @@ -2523,6 +2527,40 @@ async function executeRepoView( return buildTextResult(formatRepoView(data, { repo, branch }), data.url); } +async function executeFileRead( + session: ToolSession, + params: GithubInput, + signal: AbortSignal | undefined, +): Promise> { + const repo = await resolveGitHubRepo(session.cwd, normalizeOptionalString(params.repo), undefined, signal); + const filePath = requireNonEmpty(normalizeOptionalString(params.path), "path"); + if (filePath.startsWith("/")) { + throw new ToolError("path must be repository-relative"); + } + const branch = normalizeOptionalString(params.branch); + const endpointPath = filePath + .split("/") + .map(segment => encodeURIComponent(segment)) + .join("/"); + const args = [ + "api", + `/repos/${repo}/contents/${endpointPath}`, + "--method", + "GET", + "-H", + "Accept: application/vnd.github.raw+json", + ]; + if (branch) { + args.push("-f", `ref=${branch}`); + } + const text = await git.github.text(session.cwd, args, signal, { + repoProvided: true, + trimOutput: false, + }); + const sourceUrl = `https://github.com/${repo}/blob/${encodeURIComponent(branch ?? "HEAD")}/${endpointPath}`; + return buildTextResult(text, sourceUrl, { repo, branch }); +} + // ──────────────────────────────────────────────────────────────────────────── // Cached issue/PR view fetchers // diff --git a/packages/coding-agent/test/tools/gh.test.ts b/packages/coding-agent/test/tools/gh.test.ts index ddb885130..7349c6e71 100644 --- a/packages/coding-agent/test/tools/gh.test.ts +++ b/packages/coding-agent/test/tools/gh.test.ts @@ -319,6 +319,35 @@ describe("github tool", () => { expect(text).toContain("Topics: cli, github"); }); + it("reads repository files through the GitHub contents API", async () => { + const textSpy = vi.spyOn(git.github, "text").mockResolvedValue('{"version":"16.3.11"}\n'); + const tool = new GithubTool(createSession()); + const result = await tool.execute("file-read", { + op: "file_read", + repo: "can1357/oh-my-pi", + branch: "main", + path: "packages/coding-agent/package.json", + }); + const text = result.content[0]?.type === "text" ? result.content[0].text : ""; + + expect(text).toBe('{"version":"16.3.11"}\n'); + expect(textSpy).toHaveBeenCalledWith( + "/tmp/test", + [ + "api", + "/repos/can1357/oh-my-pi/contents/packages/coding-agent/package.json", + "--method", + "GET", + "-H", + "Accept: application/vnd.github.raw+json", + "-f", + "ref=main", + ], + undefined, + { repoProvided: true, trimOutput: false }, + ); + }); + it("creates a pull request via gh and renders the resulting summary", async () => { const textCalls: string[][] = []; const textSpy = vi.spyOn(git.github, "text").mockImplementation(async (_cwd, args) => {