g1t/services/runner/src/index.ts

149 lines5,130 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Hosted agents: sandboxes on Cloudflare Containers started from an intent1import { Container, type StopParams } from "@cloudflare/containers";
2import { WorkerEntrypoint } from "cloudflare:workers";
3
4import {
5 type Attempt,
6 type Intent,
7 type Result,
8 type RunHostedInput,
9 type RunnerApi,
10 type ServiceBinding,
11 type User,
12 type Viewer,
13 type WorkApi,
14 fail,
15 identityClient,
16 ok,
17} from "@g1t/contracts";
18
19export interface RunnerEnv {
20 SANDBOX: DurableObjectNamespace<AttemptSandbox>;
21 IDENTITY: ServiceBinding;
22 WORK: WorkApi;
23 /** Secret. The model key the hosted agent runs on. */
24 ANTHROPIC_API_KEY?: string;
25 /**
26 * Comma-separated usernames allowed to start hosted agents. Runs spend the
27 * key above, so this stays an allowlist until accounts bring their own.
28 */
29 HOSTED_AGENT_USERS: string;
30}
31
32const MAX_AGENTS_PER_RUN = 5;
33/** A run that takes longer than this has its token expire under it. */
34const TOKEN_TTL_SECONDS = 2 * 60 * 60;
Diffs on attempts; hosted agent presented as the g1t agent35/** How g1t's own agent is labelled. What runs behind it is g1t's choice. */
36const AGENT = "g1t-agent";
Hosted agents: sandboxes on Cloudflare Containers started from an intent37
38type RunRequest = { actor: User; attemptId: string; envVars: Record<string, string> };
39
40/**
41 * One sandbox, for one attempt. The image's entrypoint is the g1t runner,
42 * which does the work and exits; this class only starts it and cleans up
43 * if it dies without reporting.
44 */
45export class AttemptSandbox extends Container<RunnerEnv> {
46 sleepAfter = "45m";
47
48 async run(request: RunRequest): Promise<void> {
49 await this.ctx.storage.put("run", {
50 actor: request.actor,
51 attemptId: request.attemptId,
52 });
53 await this.start({ envVars: request.envVars, enableInternet: true });
54 }
55
56 override async onStop({ exitCode }: StopParams): Promise<void> {
57 if (exitCode === 0) return;
58 // The runner abandons its own attempt when it fails. This covers a
59 // sandbox that was killed before it could; abandoning twice is refused
60 // harmlessly.
61 const run = await this.ctx.storage.get<Pick<RunRequest, "actor" | "attemptId">>("run");
62 if (run) await this.env.WORK.abandonAttempt(run.actor, run.attemptId);
63 }
64}
65
66function buildPrompt(intent: Intent, instructions: string): string {
67 const parts = [
68 "You are a coding agent working in the git repository checked out in the current directory.",
69 `Goal: ${intent.title}`,
70 intent.brief,
71 ];
72 if (intent.checks.length > 0) {
73 parts.push(
74 `These commands must pass when you are done. Run them if the tools are installed:\n${intent.checks.map((check) => `- ${check}`).join("\n")}`,
75 );
76 }
77 if (instructions) parts.push(instructions);
78 parts.push(
79 "Make the change and keep it focused on the goal. Commit your work with a clear message. Do not push; that is done for you. Finish with a short summary of what you changed and why.",
80 );
81 return parts.filter(Boolean).join("\n\n");
82}
83
84export default class RunnerService
85 extends WorkerEntrypoint<RunnerEnv>
86 implements RunnerApi
87{
88 /** A Worker must have an event handler; this service is RPC-only. */
89 fetch(): Response {
90 return new Response("Not found\n", { status: 404 });
91 }
92
93 async available(viewer: Viewer): Promise<boolean> {
94 if (!viewer || !this.env.ANTHROPIC_API_KEY) return false;
95 return this.env.HOSTED_AGENT_USERS.split(",")
96 .map((name) => name.trim())
97 .includes(viewer.username);
98 }
99
100 async run(
101 actor: User,
102 intentId: string,
103 input: RunHostedInput,
104 ): Promise<Result<Attempt[]>> {
105 if (!(await this.available(actor))) {
106 return fail("forbidden", "Hosted agents are not enabled for your account.");
107 }
108 const count = Math.min(Math.max(Math.trunc(input.count) || 1, 1), MAX_AGENTS_PER_RUN);
109 const identity = identityClient(this.env.IDENTITY);
110
111 const attempts: Attempt[] = [];
112 for (let i = 0; i < count; i++) {
113 const started = await this.env.WORK.startAttempt(actor, intentId, {
114 agent: AGENT,
115 runtime: "hosted",
116 });
117 // The first failure is the answer; later ones mean some already run.
118 if (!started.ok) return attempts.length ? ok(attempts) : started;
119 const attempt = started.value;
120 attempts.push(attempt);
121
122 const found = await this.env.WORK.getAttempt(attempt.id, actor);
123 if (!found.ok) return found;
124 // The sandbox acts as the person who started it, through a token
125 // that only lives as long as a run can.
126 const { token } = await identity.createAccessToken(
127 actor,
128 `Hosted attempt ${attempt.id}`,
129 TOKEN_TTL_SECONDS,
130 );
131 const sandbox = this.env.SANDBOX.get(this.env.SANDBOX.idFromName(attempt.id));
132 await sandbox.run({
133 actor,
134 attemptId: attempt.id,
135 envVars: {
136 G1T_API: "https://api.g1t.sh",
137 G1T_TOKEN: token,
138 G1T_USER: actor.username,
139 ATTEMPT_ID: attempt.id,
140 GIT_REMOTE: `https://g1t.sh/${attempt.fork.namespace}/${attempt.fork.name}.git`,
141 COMMIT_MESSAGE: found.value.intent.title,
142 PROMPT: buildPrompt(found.value.intent, input.instructions?.trim() ?? ""),
143 ANTHROPIC_API_KEY: this.env.ANTHROPIC_API_KEY!,
144 },
145 });
146 }
147 return ok(attempts);
148 }
149}