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 <noreply@anthropic.com>
This commit is contained in:
parent
d6835fe0b0
commit
eb777d63ee
@ -29,6 +29,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
ca-certificates \
|
ca-certificates \
|
||||||
procps \
|
procps \
|
||||||
bash \
|
bash \
|
||||||
|
git \
|
||||||
|
curl \
|
||||||
chromium \
|
chromium \
|
||||||
fonts-liberation \
|
fonts-liberation \
|
||||||
fonts-dejavu-core \
|
fonts-dejavu-core \
|
||||||
|
|||||||
@ -441,6 +441,20 @@ rollback() {
|
|||||||
die "deploy failed; rolled back to $previous_sha"
|
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() {
|
main() {
|
||||||
parse_args "$@"
|
parse_args "$@"
|
||||||
validate_deploy_dir
|
validate_deploy_dir
|
||||||
@ -469,6 +483,7 @@ main() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
write_state "$previous_sha" "$target_sha" "success"
|
write_state "$previous_sha" "$target_sha" "success"
|
||||||
|
rebuild_computer_image "$previous_sha" "$target_sha"
|
||||||
log "deploy succeeded: $previous_sha -> $target_sha"
|
log "deploy succeeded: $previous_sha -> $target_sha"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import {
|
|||||||
lazyCatalogTools,
|
lazyCatalogTools,
|
||||||
loadCatalogEntry,
|
loadCatalogEntry,
|
||||||
NAME_INDEX_MAX_NAMES,
|
NAME_INDEX_MAX_NAMES,
|
||||||
|
prepareCatalogExecuteArguments,
|
||||||
resolveCatalogCall,
|
resolveCatalogCall,
|
||||||
SELECTED_SCHEMA_MAX_BYTES,
|
SELECTED_SCHEMA_MAX_BYTES,
|
||||||
searchCatalog,
|
searchCatalog,
|
||||||
@ -452,3 +453,40 @@ describe("lazy tool catalog", () => {
|
|||||||
).toBe("gmail:\n list, read, send\nhubspot:\n get_deal, list_contacts");
|
).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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -244,6 +244,26 @@ function assertSchemaSize(entry: CatalogEntry): void {
|
|||||||
if (schemaBytes > SELECTED_SCHEMA_MAX_BYTES) throw new Error("Tool schema is too large");
|
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<string, unknown>,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
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(
|
export function resolveCatalogCall(
|
||||||
call: ConnectorCall,
|
call: ConnectorCall,
|
||||||
entries: CatalogEntry[],
|
entries: CatalogEntry[],
|
||||||
|
|||||||
@ -254,6 +254,7 @@ vi.mock("./pi-openai-compatible-provider.js", () => ({
|
|||||||
registerOpenAiCompatibleRuntime: (models: unknown) => models,
|
registerOpenAiCompatibleRuntime: (models: unknown) => models,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
import { lazyCatalogTools } from "./lazy-tool-catalog.js";
|
||||||
import { maxToolCallsPerTurn, PiAgentRuntime } from "./pi-runtime.js";
|
import { maxToolCallsPerTurn, PiAgentRuntime } from "./pi-runtime.js";
|
||||||
import { TOOL_RESULT_TEXT_LIMIT } from "./pi-runtime-limits.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 () => {
|
it("does not claim a tool-only turn finished work the model never described", async () => {
|
||||||
const runtime = new PiAgentRuntime();
|
const runtime = new PiAgentRuntime();
|
||||||
const events: unknown[] = [];
|
const events: unknown[] = [];
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import { MODEL_TIERS, ModelTierSchema } from "@rakazo/contracts";
|
|||||||
import { getLogger } from "@rakazo/logging";
|
import { getLogger } from "@rakazo/logging";
|
||||||
import { isToolPauseResult } from "./approval-effect.js";
|
import { isToolPauseResult } from "./approval-effect.js";
|
||||||
import { DELEGATION_TOOL_NAMES, SUBAGENT_MODEL_TIER_DESCRIPTION } from "./builtin-tools.js";
|
import { DELEGATION_TOOL_NAMES, SUBAGENT_MODEL_TIER_DESCRIPTION } from "./builtin-tools.js";
|
||||||
|
import { CATALOG_EXECUTE, prepareCatalogExecuteArguments } from "./lazy-tool-catalog.js";
|
||||||
import {
|
import {
|
||||||
normalizeOpenAiToolParameters,
|
normalizeOpenAiToolParameters,
|
||||||
openAiToolParametersNeedNormalization,
|
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) : "",
|
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;
|
return raw as never;
|
||||||
},
|
},
|
||||||
execute: async (toolCallId, params): Promise<AgentToolResult<unknown>> => {
|
execute: async (toolCallId, params): Promise<AgentToolResult<unknown>> => {
|
||||||
|
|||||||
@ -48,6 +48,7 @@ function readLines(file: string) {
|
|||||||
interface Revision {
|
interface Revision {
|
||||||
build?: "ok" | "fail";
|
build?: "ok" | "fail";
|
||||||
health?: "ok" | "down";
|
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, "version.txt"), version);
|
||||||
write(path.join(checkout, "build.txt"), revision.build ?? "ok");
|
write(path.join(checkout, "build.txt"), revision.build ?? "ok");
|
||||||
write(path.join(checkout, "health.txt"), revision.health ?? "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("add", "-A");
|
||||||
git("commit", "-q", "-m", version);
|
git("commit", "-q", "-m", version);
|
||||||
return git("rev-parse", "HEAD");
|
return git("rev-parse", "HEAD");
|
||||||
@ -176,6 +181,19 @@ describe("evo deploy", () => {
|
|||||||
expect(f.state()).toMatchObject({ previousSha: f.a, deployedSha: f.b, status: "success" });
|
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", () => {
|
it("stops a failing build before the restart and keeps the served build", () => {
|
||||||
const f = deployFixture({ build: "fail" });
|
const f = deployFixture({ build: "fail" });
|
||||||
const result = f.run();
|
const result = f.run();
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user