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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user