diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index b177d66e0..62152496e 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -776,6 +776,7 @@ - Fixed `/handoff` and auto-handoff skipping extension lifecycle hooks by emitting cancellable `session_before_switch` hooks and a `session_switch` with `reason: "handoff"` after the replacement session is ready ([#4434](https://github.com/can1357/oh-my-pi/issues/4434)). - Fixed TTSR stream interrupts so only the tool call whose stream matched a rule receives the rule-named abort result; sibling tool-call placeholders now use a neutral abort reason ([#2783](https://github.com/can1357/oh-my-pi/issues/2783)). - Fixed auto-compaction dead-ending in a warning loop ("Compaction freed too little context to make progress") when the single most-recent turn is itself over budget so `prepareCompaction` has nothing to summarize (`findCutPoint` never cuts inside a tool result). This `!preparation` short-circuit never ran the artifact-backed `shake` elide rescue that #3786 added to the post-maintenance guard, so snapcompact/context-full maintenance paused with no attempt to shrink the oversized tail. The dead-end now runs the same elide pass, re-prepares on the shrunken branch, and falls through to a normal compaction when the tail became summarizable — only pausing (single warning) when nothing is elide-eligible. ([#4786](https://github.com/can1357/oh-my-pi/issues/4786)) +- 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 diff --git a/packages/coding-agent/src/prompts/tools/bash.md b/packages/coding-agent/src/prompts/tools/bash.md index fb10f9d95..a79eadd39 100644 --- a/packages/coding-agent/src/prompts/tools/bash.md +++ b/packages/coding-agent/src/prompts/tools/bash.md @@ -1,6 +1,7 @@ Runs commands in a persistent shell session. Use ONLY for: single binary call or short pipeline that COMPUTES a fact (`wc -l`, `sort | uniq -c`, `comm`, `diff`). +GitHub repository file? MUST use `github` `file_read` when available; otherwise `read`. NEVER use `curl`/`wget`. {{#if hasLaunch}}Services, watchers, debuggers, REPLs → `hub` (`op:"start"`).{{/if}} {{#if hasEval}}Inline scripts, heredocs, shell control flow, `$(…)`, multi-stage pipelines, `&&`-chains, quote/JSON escaping → `eval` cells.{{else}}Inline scripts, heredocs, shell control flow, `$(…)`, multi-stage pipelines, `&&`-chains → purpose-built tool or checked-in script.{{/if}} diff --git a/packages/coding-agent/src/prompts/tools/github.md b/packages/coding-agent/src/prompts/tools/github.md index 3997cd105..1056d8ec8 100644 --- a/packages/coding-agent/src/prompts/tools/github.md +++ b/packages/coding-agent/src/prompts/tools/github.md @@ -1,8 +1,9 @@ -Op-based `gh` wrapper: repos, 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). +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 47610ccdb..5caf2d77d 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 browser) 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`. ## Selectors — append `:` to `path` (e.g. `src/foo.ts:50-200`, `src/foo.ts:raw`, `db.sqlite:users:42`) diff --git a/packages/coding-agent/src/tools/gh.ts b/packages/coding-agent/src/tools/gh.ts index eab2b04e2..2ecc4afbe 100644 --- a/packages/coding-agent/src/tools/gh.ts +++ b/packages/coding-agent/src/tools/gh.ts @@ -249,6 +249,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", @@ -259,10 +260,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"), @@ -2455,7 +2457,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); @@ -2480,6 +2482,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": @@ -2525,6 +2529,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 8c114f390..16595a04b 100644 --- a/packages/coding-agent/test/tools/gh.test.ts +++ b/packages/coding-agent/test/tools/gh.test.ts @@ -438,6 +438,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) => {