g1t/services/runner/src/index.ts

206 lines7,188 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 {
g1t agents: model menu and optional AI Gateway routing5 type AgentModel,
Hosted agents: sandboxes on Cloudflare Containers started from an intent6 type Attempt,
7 type Intent,
8 type Result,
9 type RunHostedInput,
10 type RunnerApi,
11 type ServiceBinding,
12 type User,
13 type Viewer,
14 fail,
15 identityClient,
16 ok,
Work service in Rust, with RFC 3339 timestamps17 workClient,
Hosted agents: sandboxes on Cloudflare Containers started from an intent18} from "@g1t/contracts";
19
20export interface RunnerEnv {
21 SANDBOX: DurableObjectNamespace<AttemptSandbox>;
22 IDENTITY: ServiceBinding;
Work service in Rust, with RFC 3339 timestamps23 WORK: ServiceBinding;
Hosted agents: sandboxes on Cloudflare Containers started from an intent24 /** 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;
g1t agents: model menu and optional AI Gateway routing31 /**
Show the model behind each choice; toolchains in the sandbox32 * 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.
g1t agents: model menu and optional AI Gateway routing35 */
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;
Hosted agents: sandboxes on Cloudflare Containers started from an intent47}
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;
Diffs on attempts; hosted agent presented as the g1t agent52/** How g1t's own agent is labelled. What runs behind it is g1t's choice. */
53const AGENT = "g1t-agent";
Hosted agents: sandboxes on Cloudflare Containers started from an intent54
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");
Work service in Rust, with RFC 3339 timestamps79 if (run) await workClient(this.env.WORK).abandonAttempt(run.actor, run.attemptId);
Hosted agents: sandboxes on Cloudflare Containers started from an intent80 }
81}
82
g1t agents: model menu and optional AI Gateway routing83type 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,
Show the model behind each choice; toolchains in the sandbox90 // Recorded at the top of the session, so anyone can see what ran.
91 AGENT_MODEL_NAME: `${model.modelName} (${model.label})`,
g1t agents: model menu and optional AI Gateway routing92 };
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
Hosted agents: sandboxes on Cloudflare Containers started from an intent103function 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
g1t agents: model menu and optional AI Gateway routing130 private configuredModels(): ConfiguredModel[] {
131 return JSON.parse(this.env.AGENT_MODELS);
132 }
133
134 private allowed(viewer: Viewer): boolean {
Hosted agents: sandboxes on Cloudflare Containers started from an intent135 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
g1t agents: model menu and optional AI Gateway routing141 async models(viewer: Viewer): Promise<AgentModel[]> {
142 if (!this.allowed(viewer)) return [];
Show the model behind each choice; toolchains in the sandbox143 return this.configuredModels().map(({ id, label, description, modelName }) => ({
g1t agents: model menu and optional AI Gateway routing144 id,
145 label,
146 description,
Show the model behind each choice; toolchains in the sandbox147 modelName,
g1t agents: model menu and optional AI Gateway routing148 }));
149 }
150
Hosted agents: sandboxes on Cloudflare Containers started from an intent151 async run(
152 actor: User,
153 intentId: string,
154 input: RunHostedInput,
155 ): Promise<Result<Attempt[]>> {
g1t agents: model menu and optional AI Gateway routing156 if (!this.allowed(actor)) {
157 return fail("forbidden", "g1t agents are not enabled for your account.");
Hosted agents: sandboxes on Cloudflare Containers started from an intent158 }
g1t agents: model menu and optional AI Gateway routing159 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.");
Hosted agents: sandboxes on Cloudflare Containers started from an intent164 const count = Math.min(Math.max(Math.trunc(input.count) || 1, 1), MAX_AGENTS_PER_RUN);
165 const identity = identityClient(this.env.IDENTITY);
Work service in Rust, with RFC 3339 timestamps166 const work = workClient(this.env.WORK);
Hosted agents: sandboxes on Cloudflare Containers started from an intent167
168 const attempts: Attempt[] = [];
169 for (let i = 0; i < count; i++) {
Work service in Rust, with RFC 3339 timestamps170 const started = await work.startAttempt(actor, intentId, {
Hosted agents: sandboxes on Cloudflare Containers started from an intent171 agent: AGENT,
172 runtime: "hosted",
173 });
174 // The first failure is the answer; later ones mean some already run.
175 if (!started.ok) return attempts.length ? ok(attempts) : started;
176 const attempt = started.value;
177 attempts.push(attempt);
178
Work service in Rust, with RFC 3339 timestamps179 const found = await work.getAttempt(attempt.id, actor);
Hosted agents: sandboxes on Cloudflare Containers started from an intent180 if (!found.ok) return found;
181 // The sandbox acts as the person who started it, through a token
182 // that only lives as long as a run can.
183 const { token } = await identity.createAccessToken(
184 actor,
185 `Hosted attempt ${attempt.id}`,
186 TOKEN_TTL_SECONDS,
187 );
188 const sandbox = this.env.SANDBOX.get(this.env.SANDBOX.idFromName(attempt.id));
189 await sandbox.run({
190 actor,
191 attemptId: attempt.id,
192 envVars: {
193 G1T_API: "https://api.g1t.sh",
194 G1T_TOKEN: token,
195 G1T_USER: actor.username,
196 ATTEMPT_ID: attempt.id,
197 GIT_REMOTE: `https://g1t.sh/${attempt.fork.namespace}/${attempt.fork.name}.git`,
198 COMMIT_MESSAGE: found.value.intent.title,
199 PROMPT: buildPrompt(found.value.intent, input.instructions?.trim() ?? ""),
g1t agents: model menu and optional AI Gateway routing200 ...modelEnv(this.env, model),
Hosted agents: sandboxes on Cloudflare Containers started from an intent201 },
202 });
203 }
204 return ok(attempts);
205 }
206}