pr_01m47d24b0e6n91zwymwxg0vpx/services/runner/src/index.ts

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