From eb777d63ee11d8a7f1d3ccefb7736038292ccf8a Mon Sep 17 00:00:00 2001 From: cynarAI <155274210+cynarAI@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:49:20 +0200 Subject: [PATCH] Let the agent use GitHub tools and git on its computer (#201) Catalog execute calls now accept arguments sent as a JSON string or flattened next to the id, the computer image ships git and curl, and the evo deploy rebuilds that image when its sources change. Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ Co-authored-by: Claude --- infra/sandboxes/computer/Dockerfile | 2 + ops/evo-deploy/deploy.sh | 15 +++++++ .../adapters/src/lazy-tool-catalog.test.ts | 38 +++++++++++++++++ packages/adapters/src/lazy-tool-catalog.ts | 20 +++++++++ .../src/pi-runtime-tool-dispatch.test.ts | 41 +++++++++++++++++++ packages/adapters/src/pi-runtime.ts | 4 ++ packages/testkit/src/evo-deploy.test.ts | 18 ++++++++ 7 files changed, 138 insertions(+) diff --git a/infra/sandboxes/computer/Dockerfile b/infra/sandboxes/computer/Dockerfile index 9e256bc..b0f0d17 100644 --- a/infra/sandboxes/computer/Dockerfile +++ b/infra/sandboxes/computer/Dockerfile @@ -29,6 +29,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates \ procps \ bash \ + git \ + curl \ chromium \ fonts-liberation \ fonts-dejavu-core \ diff --git a/ops/evo-deploy/deploy.sh b/ops/evo-deploy/deploy.sh index dbd6881..4a8b6b8 100755 --- a/ops/evo-deploy/deploy.sh +++ b/ops/evo-deploy/deploy.sh @@ -441,6 +441,20 @@ rollback() { die "deploy failed; rolled back to $previous_sha" } +# New computers start from rakazo/computer:local, which only `pnpm sandbox:build` refreshes. +# Rebuild it when the revision changed its sources; a failure leaves the old image and warns. +rebuild_computer_image() { + local previous_sha="$1" target_sha="$2" + git -C "$EVO_DEPLOY_DIR" diff --quiet "$previous_sha" "$target_sha" -- infra/sandboxes/computer && return 0 + if ! command -v docker >/dev/null 2>&1; then + log "WARNING: the computer image changed but docker is unavailable; run pnpm sandbox:build" + return 0 + fi + log "rebuilding the computer image" + run_shell "cd $(printf '%q' "$EVO_DEPLOY_DIR") && pnpm sandbox:build" >&2 || + log "WARNING: the computer image rebuild failed; run pnpm sandbox:build" +} + main() { parse_args "$@" validate_deploy_dir @@ -469,6 +483,7 @@ main() { fi write_state "$previous_sha" "$target_sha" "success" + rebuild_computer_image "$previous_sha" "$target_sha" log "deploy succeeded: $previous_sha -> $target_sha" } diff --git a/packages/adapters/src/lazy-tool-catalog.test.ts b/packages/adapters/src/lazy-tool-catalog.test.ts index 9dffd9b..20a43de 100644 --- a/packages/adapters/src/lazy-tool-catalog.test.ts +++ b/packages/adapters/src/lazy-tool-catalog.test.ts @@ -11,6 +11,7 @@ import { lazyCatalogTools, loadCatalogEntry, NAME_INDEX_MAX_NAMES, + prepareCatalogExecuteArguments, resolveCatalogCall, SELECTED_SCHEMA_MAX_BYTES, searchCatalog, @@ -452,3 +453,40 @@ describe("lazy tool catalog", () => { ).toBe("gmail:\n list, read, send\nhubspot:\n get_deal, list_contacts"); }); }); + +describe("prepareCatalogExecuteArguments", () => { + it("keeps an arguments object as it is", () => { + expect(prepareCatalogExecuteArguments({ id: "a", arguments: { owner: "o" } })).toEqual({ + id: "a", + arguments: { owner: "o" }, + }); + }); + + it("parses arguments sent as a JSON string", () => { + expect( + prepareCatalogExecuteArguments({ id: "a", arguments: '{"owner":"o","repo":"r"}' }), + ).toEqual({ + id: "a", + arguments: { owner: "o", repo: "r" }, + }); + expect(prepareCatalogExecuteArguments({ id: "a", arguments: "" })).toEqual({ + id: "a", + arguments: {}, + }); + }); + + it("collects arguments flattened next to the id", () => { + expect(prepareCatalogExecuteArguments({ id: "a", owner: "o" })).toEqual({ + id: "a", + arguments: { owner: "o" }, + }); + expect(prepareCatalogExecuteArguments({ id: "a" })).toEqual({ id: "a", arguments: {} }); + }); + + it("leaves unparseable strings for schema validation to reject", () => { + expect(prepareCatalogExecuteArguments({ id: "a", arguments: "owner=o" })).toEqual({ + id: "a", + arguments: "owner=o", + }); + }); +}); diff --git a/packages/adapters/src/lazy-tool-catalog.ts b/packages/adapters/src/lazy-tool-catalog.ts index abc88ea..aa82d8c 100644 --- a/packages/adapters/src/lazy-tool-catalog.ts +++ b/packages/adapters/src/lazy-tool-catalog.ts @@ -244,6 +244,26 @@ function assertSchemaSize(entry: CatalogEntry): void { if (schemaBytes > SELECTED_SCHEMA_MAX_BYTES) throw new Error("Tool schema is too large"); } +/** + * Smaller models often send execute arguments as a JSON string or flatten them next to `id`; + * accept both so the call reaches the tool instead of failing schema validation. + */ +export function prepareCatalogExecuteArguments( + raw: Record, +): Record { + const { id, arguments: nested, ...rest } = raw; + let args: unknown = nested; + if (typeof args === "string") { + try { + args = args.trim() ? JSON.parse(args) : {}; + } catch { + return raw; + } + } + if (args === undefined || args === null) args = rest; + return { id, arguments: args }; +} + export function resolveCatalogCall( call: ConnectorCall, entries: CatalogEntry[], diff --git a/packages/adapters/src/pi-runtime-tool-dispatch.test.ts b/packages/adapters/src/pi-runtime-tool-dispatch.test.ts index 160f89d..9f1b395 100644 --- a/packages/adapters/src/pi-runtime-tool-dispatch.test.ts +++ b/packages/adapters/src/pi-runtime-tool-dispatch.test.ts @@ -254,6 +254,7 @@ vi.mock("./pi-openai-compatible-provider.js", () => ({ registerOpenAiCompatibleRuntime: (models: unknown) => models, })); +import { lazyCatalogTools } from "./lazy-tool-catalog.js"; import { maxToolCallsPerTurn, PiAgentRuntime } from "./pi-runtime.js"; import { TOOL_RESULT_TEXT_LIMIT } from "./pi-runtime-limits.js"; @@ -563,6 +564,46 @@ describe("Pi connector tool dispatch", () => { ); }); + it("passes catalog execute arguments sent as a JSON string as an object", async () => { + const [, , executeWrapper] = lazyCatalogTools("installed", "installed", "API"); + fakeAgentState.invoke = { + name: "installed_execute_tool", + args: { id: "github::list_repos", arguments: '{"owner":"octo"}' }, + }; + const executeTool = vi.fn(async () => ({ ok: true })); + const runtime = new PiAgentRuntime(); + + for await (const _event of runtime.run( + { + botId: "b", + threadId: "t", + runId: "r", + prompt: "list my repositories", + instructions: "Use the GitHub tools.", + history: [], + tools: [executeWrapper!], + model: { provider: "test", id: "dispatch-test-model" }, + executeTool, + }, + { + operationId: "1", + traceId: "1", + spaceId: "w", + userId: "u", + signal: new AbortController().signal, + }, + )) { + // Exhaust the runtime event stream so tool execution completes. + } + + expect(executeTool).toHaveBeenCalledWith( + "installed_execute_tool", + { id: "github::list_repos", arguments: { owner: "octo" } }, + "call-1", + executeWrapper!.route, + ); + }); + it("does not claim a tool-only turn finished work the model never described", async () => { const runtime = new PiAgentRuntime(); const events: unknown[] = []; diff --git a/packages/adapters/src/pi-runtime.ts b/packages/adapters/src/pi-runtime.ts index 7c71d6c..c41d382 100644 --- a/packages/adapters/src/pi-runtime.ts +++ b/packages/adapters/src/pi-runtime.ts @@ -18,6 +18,7 @@ import { MODEL_TIERS, ModelTierSchema } from "@rakazo/contracts"; import { getLogger } from "@rakazo/logging"; import { isToolPauseResult } from "./approval-effect.js"; import { DELEGATION_TOOL_NAMES, SUBAGENT_MODEL_TIER_DESCRIPTION } from "./builtin-tools.js"; +import { CATALOG_EXECUTE, prepareCatalogExecuteArguments } from "./lazy-tool-catalog.js"; import { normalizeOpenAiToolParameters, openAiToolParametersNeedNormalization, @@ -685,6 +686,9 @@ function toAgentTool(tool: ConnectorTool, host: ToolHost, exposedName: string): bot_id: raw.bot_id ? String(raw.bot_id) : raw.botId ? String(raw.botId) : "", }; } + if (tool.route?.toolName === CATALOG_EXECUTE && !tool.route.resourceId) { + return prepareCatalogExecuteArguments(raw); + } return raw as never; }, execute: async (toolCallId, params): Promise> => { diff --git a/packages/testkit/src/evo-deploy.test.ts b/packages/testkit/src/evo-deploy.test.ts index c9b9638..6a7f40c 100644 --- a/packages/testkit/src/evo-deploy.test.ts +++ b/packages/testkit/src/evo-deploy.test.ts @@ -48,6 +48,7 @@ function readLines(file: string) { interface Revision { build?: "ok" | "fail"; health?: "ok" | "down"; + computer?: string; } /** @@ -117,6 +118,10 @@ printf 'restart %s %s\\n' "$(cat "$EVO_DEPLOY_DIR/version.txt")" "$served" >> "$ write(path.join(checkout, "version.txt"), version); write(path.join(checkout, "build.txt"), revision.build ?? "ok"); write(path.join(checkout, "health.txt"), revision.health ?? "ok"); + write( + path.join(checkout, "infra/sandboxes/computer/Dockerfile"), + revision.computer ?? "FROM a\n", + ); git("add", "-A"); git("commit", "-q", "-m", version); return git("rev-parse", "HEAD"); @@ -176,6 +181,19 @@ describe("evo deploy", () => { expect(f.state()).toMatchObject({ previousSha: f.a, deployedSha: f.b, status: "success" }); }); + it("rebuilds the computer image only when its sources changed", () => { + const unchanged = deployFixture({}); + write(path.join(unchanged.checkout, "../bin/docker"), "#!/bin/bash\n", 0o755); + expect(unchanged.run().status).toBe(0); + expect(unchanged.commands()).not.toContain("B sandbox:build"); + + const changed = deployFixture({ computer: "FROM b\n" }); + write(path.join(changed.checkout, "../bin/docker"), "#!/bin/bash\n", 0o755); + const result = changed.run(); + expect(result.status, result.stderr).toBe(0); + expect(changed.commands().at(-1)).toBe("B sandbox:build"); + }); + it("stops a failing build before the restart and keeps the served build", () => { const f = deployFixture({ build: "fail" }); const result = f.run();