g1t/services/runner/src/index.ts

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