feat(coding-agent/tools): added image and binary file support to github file-read

- Switch github file-read tool to request json contents and handle base64 encoding.
- Add support for parsing image attachments and detecting binary files from github contents.
This commit is contained in:
can1357
2026-08-20 05:52:02 +02:00
parent bd5764aad4
commit 63b1146064
2 changed files with 159 additions and 9 deletions
+95 -5
View File
@@ -6,9 +6,17 @@ import type {
AgentToolUpdateCallback,
ToolApprovalDecision,
} from "@oh-my-pi/pi-agent-core";
import { prompt, untilAborted } from "@oh-my-pi/pi-utils";
import {
BINARY_SNIFF_BYTES,
formatBytes,
isProbablyBinaryHeader,
parseImageMetadata,
prompt,
untilAborted,
} from "@oh-my-pi/pi-utils";
import githubDescription from "../prompts/tools/github.md" with { type: "text" };
import * as git from "../utils/git";
import { loadImageAttachmentInput, webpExclusionForModel } from "../utils/image-loading";
import type { ToolSession } from ".";
import { buildTextResult, normalizeOptionalString, requireNonEmpty, resolveGitHubRepo } from "./gh-common";
import { executePrCheckout, executePrCreate, executePrPush } from "./gh-pr-checkout";
@@ -23,6 +31,7 @@ import {
import { executeRepoView } from "./gh-view";
import type { OutputMeta } from "./output-meta";
import { ToolError } from "./tool-errors";
import { toolResult } from "./tool-result";
export { parsePositiveDecimalInt, resolveDefaultRepoMemoized } from "./gh-common";
export {
@@ -83,6 +92,34 @@ const githubSchema = type({
type GithubInput = typeof githubSchema.infer;
interface GitHubContentsFile {
type?: string;
encoding?: string;
size?: number;
content?: string;
html_url?: string | null;
}
type GitHubContentsResponse = GitHubContentsFile | GitHubContentsFile[];
function isGitHubContentsFile(response: GitHubContentsResponse): response is GitHubContentsFile {
return !Array.isArray(response) && response.type === "file";
}
function buildBinaryFileReadResult(
filePath: string,
size: number,
sourceUrl: string,
repo: string,
branch: string | undefined,
): AgentToolResult<GhToolDetails> {
return buildTextResult(
`[Cannot read binary file '${filePath}' (${formatBytes(size)}); not valid UTF-8 text. Open ${sourceUrl} to view it.]`,
sourceUrl,
{ repo, branch },
);
}
export interface GhToolDetails {
meta?: OutputMeta;
artifactId?: string;
@@ -232,15 +269,68 @@ async function executeFileRead(
"--method",
"GET",
"-H",
"Accept: application/vnd.github.raw+json",
"Accept: application/vnd.github+json",
"-H",
"Accept-Encoding: identity",
];
if (branch) {
args.push("-f", `ref=${branch}`);
}
const text = await git.github.text(session.cwd, args, signal, {
const response = await git.github.json<GitHubContentsResponse>(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 });
if (!isGitHubContentsFile(response)) {
throw new ToolError(`GitHub path '${filePath}' is not a file.`);
}
const fallbackSourceUrl = `https://github.com/${repo}/blob/${encodeURIComponent(branch ?? "HEAD")}/${endpointPath}`;
const sourceUrl = response.html_url || fallbackSourceUrl;
if (response.encoding !== "base64" || typeof response.content !== "string") {
const size =
typeof response.size === "number" && response.size >= 0 ? formatBytes(response.size) : "unknown size";
return buildTextResult(
`[GitHub did not return file bytes for '${filePath}' (${size}). Open ${sourceUrl} to view it.]`,
sourceUrl,
{ repo, branch },
);
}
const encoded = response.content.replaceAll(/\s/g, "");
const bytes = Buffer.from(encoded, "base64");
const imageMetadata = parseImageMetadata(bytes);
if (imageMetadata) {
const image = await loadImageAttachmentInput({
image: { type: "image", data: encoded, mimeType: imageMetadata.mimeType },
label: filePath,
uri: sourceUrl,
autoResize: session.settings.get("images.autoResize"),
excludeWebP: webpExclusionForModel(session.getActiveModel?.()),
});
if (image) {
const dimensions =
imageMetadata.width !== undefined && imageMetadata.height !== undefined
? `\nDimensions: ${imageMetadata.width}x${imageMetadata.height}`
: "";
return toolResult<GhToolDetails>({ repo, branch })
.content([
{
type: "text",
text: `Image file: ${filePath}\nMIME: ${image.mimeType}\nSize: ${formatBytes(bytes.byteLength)}${dimensions}`,
},
{ type: "image", data: image.data, mimeType: image.mimeType },
])
.sourceUrl(sourceUrl)
.done();
}
}
if (isProbablyBinaryHeader(bytes.subarray(0, BINARY_SNIFF_BYTES))) {
return buildBinaryFileReadResult(filePath, bytes.byteLength, sourceUrl, repo, branch);
}
try {
return buildTextResult(new TextDecoder("utf-8", { fatal: true }).decode(bytes), sourceUrl, { repo, branch });
} catch {
return buildBinaryFileReadResult(filePath, bytes.byteLength, sourceUrl, repo, branch);
}
}
+64 -4
View File
@@ -19,6 +19,9 @@ import * as git from "@oh-my-pi/pi-coding-agent/utils/git";
import * as piUtils from "@oh-my-pi/pi-utils";
import { $which, getAgentDir, hashPath, removeWithRetries, setAgentDir, WhichCachePolicy } from "@oh-my-pi/pi-utils";
const TINY_PNG_BASE64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==";
// Isolate every `git` invocation in this file from the developer's host
// configuration. The fixture spawns dozens of git subprocesses against tiny
// throwaway repos; any leak from `~/.gitconfig` or system config (LFS filters,
@@ -433,8 +436,14 @@ 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');
it("reads repository text through GitHub's JSON contents API", async () => {
const jsonSpy = vi.spyOn(git.github, "json").mockResolvedValue({
type: "file",
encoding: "base64",
size: 20,
content: Buffer.from('{"version":"16.3.11"}\n').toString("base64"),
html_url: "https://github.com/can1357/oh-my-pi/blob/main/packages/coding-agent/package.json",
});
const tool = new GithubTool(createSession());
const result = await tool.execute("file-read", {
op: "file_read",
@@ -445,7 +454,7 @@ describe("github tool", () => {
const text = result.content[0]?.type === "text" ? result.content[0].text : "";
expect(text).toBe('{"version":"16.3.11"}\n');
expect(textSpy).toHaveBeenCalledWith(
expect(jsonSpy).toHaveBeenCalledWith(
"/tmp/test",
[
"api",
@@ -453,7 +462,9 @@ describe("github tool", () => {
"--method",
"GET",
"-H",
"Accept: application/vnd.github.raw+json",
"Accept: application/vnd.github+json",
"-H",
"Accept-Encoding: identity",
"-f",
"ref=main",
],
@@ -462,6 +473,55 @@ describe("github tool", () => {
);
});
it("returns GitHub images as model image content", async () => {
vi.spyOn(git.github, "json").mockResolvedValue({
type: "file",
encoding: "base64",
size: Buffer.byteLength(TINY_PNG_BASE64, "base64"),
content: TINY_PNG_BASE64,
html_url: "https://github.com/anomalyco/opencode/blob/main/packages/web/src/assets/lander/screenshot.png",
});
const tool = new GithubTool(
createSession("/tmp/test", Settings.isolated({ "github.enabled": true, "images.autoResize": false })),
);
const result = await tool.execute("file-read", {
op: "file_read",
repo: "anomalyco/opencode",
branch: "main",
path: "packages/web/src/assets/lander/screenshot.png",
});
const text = result.content[0]?.type === "text" ? result.content[0].text : "";
expect(text).toContain("Image file: packages/web/src/assets/lander/screenshot.png");
expect(result.content).toContainEqual({
type: "image",
data: TINY_PNG_BASE64,
mimeType: "image/png",
});
});
it("identifies files GitHub returns without inline bytes", async () => {
const sourceUrl = "https://github.com/anomalyco/opencode/blob/main/packages/web/src/assets/lander/screenshot.png";
vi.spyOn(git.github, "json").mockResolvedValue({
type: "file",
encoding: "none",
size: 2 * 1024 * 1024,
html_url: sourceUrl,
});
const tool = new GithubTool(createSession());
const result = await tool.execute("file-read", {
op: "file_read",
repo: "anomalyco/opencode",
branch: "main",
path: "packages/web/src/assets/lander/screenshot.png",
});
const text = result.content[0]?.type === "text" ? result.content[0].text : "";
expect(text).toContain("GitHub did not return file bytes");
expect(text).toContain("packages/web/src/assets/lander/screenshot.png");
expect(text).toContain(sourceUrl);
});
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) => {