g1t/services/runner/src/index.ts

216 lines7,459 bytesCodeBlame
1import { Container, type StopParams } from "@cloudflare/containers";
2import { WorkerEntrypoint } from "cloudflare:workers";
3
4import {
5 type AgentModel,
6 type Issue,
7 type Pull,
8 type RepoPath,
9 type Result,
10 type RunHostedInput,
11 type RunnerApi,
12 type ServiceBinding,
13 type User,
14 type Viewer,
15 fail,
16 identityClient,
17 ok,
18 workClient,
19} from "@g1t/contracts";
20
21export interface RunnerEnv {
22 SANDBOX: DurableObjectNamespace<AttemptSandbox>;
23 IDENTITY: ServiceBinding;
24 WORK: ServiceBinding;
25 /** Secret. The model key the hosted agent runs on. */
26 ANTHROPIC_API_KEY?: string;
27 /**
28 * Comma-separated usernames allowed to start hosted agents. Runs spend the
29 * key above, so this stays an allowlist until accounts bring their own.
30 */
31 HOSTED_AGENT_USERS: string;
32 /**
33 * The models offered, as JSON:
34 * `[{ id, label, description, modelName, model }]`. `modelName` is what
35 * people see; `model` is the identifier sent to the provider.
36 */
37 AGENT_MODELS: string;
38 /**
39 * A Cloudflare AI Gateway id. When set, model traffic goes through that
40 * gateway, which is where logging, spend limits, caching and fallback
41 * between providers are configured. Empty sends it to the provider
42 * directly.
43 */
44 AI_GATEWAY_ID: string;
45 CLOUDFLARE_ACCOUNT_ID: string;
46 /** Secret. Needed only if the gateway requires authentication. */
47 AI_GATEWAY_TOKEN?: string;
48}
49
50const MAX_AGENTS_PER_RUN = 5;
51/** A run that takes longer than this has its token expire under it. */
52const TOKEN_TTL_SECONDS = 2 * 60 * 60;
53/** How g1t's own agent is labelled. What runs behind it is g1t's choice. */
54const AGENT = "g1t-agent";
55
56/** Which pull request a sandbox is working on, and as whom. */
57type Run = { actor: User; repo: RepoPath; number: number };
58type RunRequest = Run & { envVars: Record<string, string> };
59
60/**
61 * One sandbox, for one pull request. The image's entrypoint is the g1t runner,
62 * which does the work and exits; this class only starts it and cleans up
63 * if it dies without reporting.
64 */
65export class AttemptSandbox extends Container<RunnerEnv> {
66 sleepAfter = "45m";
67
68 async run(request: RunRequest): Promise<void> {
69 const { envVars, ...run } = request;
70 await this.ctx.storage.put("run", run);
71 await this.start({ envVars, enableInternet: true });
72 }
73
74 override async onStop({ exitCode }: StopParams): Promise<void> {
75 if (exitCode === 0) return;
76 // The runner closes its own pull request when it fails. This covers a
77 // sandbox that was killed before it could; closing twice is refused
78 // harmlessly.
79 const run = await this.ctx.storage.get<Run>("run");
80 if (run) await workClient(this.env.WORK).closePull(run.actor, run.repo, run.number);
81 }
82}
83
84type ConfiguredModel = AgentModel & { model: string };
85
86/** Where the sandbox sends model requests, and what it sends with them. */
87function modelEnv(env: RunnerEnv, model: ConfiguredModel): Record<string, string> {
88 const vars: Record<string, string> = {
89 ANTHROPIC_API_KEY: env.ANTHROPIC_API_KEY!,
90 ANTHROPIC_MODEL: model.model,
91 // Recorded at the top of the session, so anyone can see what ran.
92 AGENT_MODEL_NAME: `${model.modelName} (${model.label})`,
93 };
94 if (env.AI_GATEWAY_ID) {
95 vars.ANTHROPIC_BASE_URL = `https://gateway.ai.cloudflare.com/v1/${env.CLOUDFLARE_ACCOUNT_ID}/${env.AI_GATEWAY_ID}/anthropic`;
96 if (env.AI_GATEWAY_TOKEN) {
97 vars.AI_GATEWAY_TOKEN = env.AI_GATEWAY_TOKEN;
98 vars.ANTHROPIC_CUSTOM_HEADERS = `cf-aig-authorization: Bearer ${env.AI_GATEWAY_TOKEN}`;
99 }
100 }
101 return vars;
102}
103
104function buildPrompt(issue: Issue, instructions: string): string {
105 const parts = [
106 "You are a coding agent working in the git repository checked out in the current directory.",
107 `Issue #${issue.number}: ${issue.title}`,
108 issue.body,
109 ];
110 if (issue.checks.length > 0) {
111 parts.push(
112 `These commands must pass when you are done. Run them if the tools are installed:\n${issue.checks.map((check) => `- ${check}`).join("\n")}`,
113 );
114 }
115 if (instructions) parts.push(instructions);
116 parts.push(
117 "Make the change and keep it focused on the issue. 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.",
118 );
119 return parts.filter(Boolean).join("\n\n");
120}
121
122export default class RunnerService
123 extends WorkerEntrypoint<RunnerEnv>
124 implements RunnerApi
125{
126 /** A Worker must have an event handler; this service is RPC-only. */
127 fetch(): Response {
128 return new Response("Not found\n", { status: 404 });
129 }
130
131 private configuredModels(): ConfiguredModel[] {
132 return JSON.parse(this.env.AGENT_MODELS);
133 }
134
135 private allowed(viewer: Viewer): boolean {
136 if (!viewer || !this.env.ANTHROPIC_API_KEY) return false;
137 return this.env.HOSTED_AGENT_USERS.split(",")
138 .map((name) => name.trim())
139 .includes(viewer.username);
140 }
141
142 async models(viewer: Viewer): Promise<AgentModel[]> {
143 if (!this.allowed(viewer)) return [];
144 return this.configuredModels().map(({ id, label, description, modelName }) => ({
145 id,
146 label,
147 description,
148 modelName,
149 }));
150 }
151
152 async run(
153 actor: User,
154 repo: RepoPath,
155 issueNumber: number,
156 input: RunHostedInput,
157 ): Promise<Result<Pull[]>> {
158 if (!this.allowed(actor)) {
159 return fail("forbidden", "g1t agents are not enabled for your account.");
160 }
161 const models = this.configuredModels();
162 const model = input.model
163 ? models.find((candidate) => candidate.id === input.model)
164 : models[0];
165 if (!model) return fail("invalid", "That model is not available.");
166 const count = Math.min(Math.max(Math.trunc(input.count) || 1, 1), MAX_AGENTS_PER_RUN);
167 const identity = identityClient(this.env.IDENTITY);
168 const work = workClient(this.env.WORK);
169
170 const found = await work.getIssue(repo, issueNumber, actor);
171 if (!found.ok) return found;
172 const { issue } = found.value;
173 const prompt = buildPrompt(issue, input.instructions?.trim() ?? "");
174
175 const pulls: Pull[] = [];
176 for (let i = 0; i < count; i++) {
177 const opened = await work.openPull(actor, repo, {
178 issue: issue.number,
179 agent: AGENT,
180 runtime: "hosted",
181 });
182 // The first failure is the answer; later ones mean some already run.
183 if (!opened.ok) return pulls.length ? ok(pulls) : opened;
184 const pull = opened.value;
185 // Opened without a branch, so it has a fork.
186 const fork = pull.fork!;
187 pulls.push(pull);
188
189 // The sandbox acts as the person who started it, through a token
190 // that only lives as long as a run can.
191 const { token } = await identity.createAccessToken(
192 actor,
193 `g1t agent on ${repo.namespace}/${repo.name}#${pull.number}`,
194 TOKEN_TTL_SECONDS,
195 );
196 const sandbox = this.env.SANDBOX.get(this.env.SANDBOX.idFromName(pull.id));
197 await sandbox.run({
198 actor,
199 repo,
200 number: pull.number,
201 envVars: {
202 G1T_API: "https://api.g1t.sh",
203 G1T_TOKEN: token,
204 G1T_USER: actor.username,
205 G1T_REPO: `${repo.namespace}/${repo.name}`,
206 PULL_NUMBER: String(pull.number),
207 GIT_REMOTE: `https://g1t.sh/${fork.namespace}/${fork.name}.git`,
208 COMMIT_MESSAGE: issue.title,
209 PROMPT: prompt,
210 ...modelEnv(this.env, model),
211 },
212 });
213 }
214 return ok(pulls);
215 }
216}