g1t/services/runner/src/index.ts

214 lines7,386 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,
Issues and pull requests replace intents and attempts6 type Issue,
7 type Pull,
8 type RepoPath,
Hosted agents: sandboxes on Cloudflare Containers started from an intent9 type Result,
10 type RunHostedInput,
11 type RunnerApi,
12 type ServiceBinding,
13 type User,
14 type Viewer,
15 fail,
16 identityClient,
17 ok,
Work service in Rust, with RFC 3339 timestamps18 workClient,
Hosted agents: sandboxes on Cloudflare Containers started from an intent19} from "@g1t/contracts";
20
21export interface RunnerEnv {
22 SANDBOX: DurableObjectNamespace<AttemptSandbox>;
23 IDENTITY: ServiceBinding;
Work service in Rust, with RFC 3339 timestamps24 WORK: ServiceBinding;
Hosted agents: sandboxes on Cloudflare Containers started from an intent25 /** 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;
g1t agents: model menu and optional AI Gateway routing32 /**
Show the model behind each choice; toolchains in the sandbox33 * 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.
g1t agents: model menu and optional AI Gateway routing36 */
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;
Hosted agents: sandboxes on Cloudflare Containers started from an intent48}
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;
Diffs on attempts; hosted agent presented as the g1t agent53/** How g1t's own agent is labelled. What runs behind it is g1t's choice. */
54const AGENT = "g1t-agent";
Hosted agents: sandboxes on Cloudflare Containers started from an intent55
Issues and pull requests replace intents and attempts56/** 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> };
Hosted agents: sandboxes on Cloudflare Containers started from an intent59
60/**
Issues and pull requests replace intents and attempts61 * One sandbox, for one pull request. The image's entrypoint is the g1t runner,
Hosted agents: sandboxes on Cloudflare Containers started from an intent62 * 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> {
Issues and pull requests replace intents and attempts69 const { envVars, ...run } = request;
70 await this.ctx.storage.put("run", run);
71 await this.start({ envVars, enableInternet: true });
Hosted agents: sandboxes on Cloudflare Containers started from an intent72 }
73
74 override async onStop({ exitCode }: StopParams): Promise<void> {
75 if (exitCode === 0) return;
Issues and pull requests replace intents and attempts76 // 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
Hosted agents: sandboxes on Cloudflare Containers started from an intent78 // harmlessly.
Issues and pull requests replace intents and attempts79 const run = await this.ctx.storage.get<Run>("run");
80 if (run) await workClient(this.env.WORK).closePull(run.actor, run.repo, run.number);
Hosted agents: sandboxes on Cloudflare Containers started from an intent81 }
82}
83
g1t agents: model menu and optional AI Gateway routing84type 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,
Show the model behind each choice; toolchains in the sandbox91 // Recorded at the top of the session, so anyone can see what ran.
92 AGENT_MODEL_NAME: `${model.modelName} (${model.label})`,
g1t agents: model menu and optional AI Gateway routing93 };
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
Issues and pull requests replace intents and attempts104function buildPrompt(issue: Issue, instructions: string): string {
Hosted agents: sandboxes on Cloudflare Containers started from an intent105 const parts = [
106 "You are a coding agent working in the git repository checked out in the current directory.",
Issues and pull requests replace intents and attempts107 `Issue #${issue.number}: ${issue.title}`,
108 issue.body,
Hosted agents: sandboxes on Cloudflare Containers started from an intent109 ];
Issues and pull requests replace intents and attempts110 if (issue.checks.length > 0) {
Hosted agents: sandboxes on Cloudflare Containers started from an intent111 parts.push(
Issues and pull requests replace intents and attempts112 `These commands must pass when you are done. Run them if the tools are installed:\n${issue.checks.map((check) => `- ${check}`).join("\n")}`,
Hosted agents: sandboxes on Cloudflare Containers started from an intent113 );
114 }
115 if (instructions) parts.push(instructions);
116 parts.push(
Issues and pull requests replace intents and attempts117 "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.",
Hosted agents: sandboxes on Cloudflare Containers started from an intent118 );
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
g1t agents: model menu and optional AI Gateway routing131 private configuredModels(): ConfiguredModel[] {
132 return JSON.parse(this.env.AGENT_MODELS);
133 }
134
135 private allowed(viewer: Viewer): boolean {
Hosted agents: sandboxes on Cloudflare Containers started from an intent136 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
g1t agents: model menu and optional AI Gateway routing142 async models(viewer: Viewer): Promise<AgentModel[]> {
143 if (!this.allowed(viewer)) return [];
Show the model behind each choice; toolchains in the sandbox144 return this.configuredModels().map(({ id, label, description, modelName }) => ({
g1t agents: model menu and optional AI Gateway routing145 id,
146 label,
147 description,
Show the model behind each choice; toolchains in the sandbox148 modelName,
g1t agents: model menu and optional AI Gateway routing149 }));
150 }
151
Hosted agents: sandboxes on Cloudflare Containers started from an intent152 async run(
153 actor: User,
Issues and pull requests replace intents and attempts154 repo: RepoPath,
155 issueNumber: number,
Hosted agents: sandboxes on Cloudflare Containers started from an intent156 input: RunHostedInput,
Issues and pull requests replace intents and attempts157 ): Promise<Result<Pull[]>> {
g1t agents: model menu and optional AI Gateway routing158 if (!this.allowed(actor)) {
159 return fail("forbidden", "g1t agents are not enabled for your account.");
Hosted agents: sandboxes on Cloudflare Containers started from an intent160 }
g1t agents: model menu and optional AI Gateway routing161 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.");
Hosted agents: sandboxes on Cloudflare Containers started from an intent166 const count = Math.min(Math.max(Math.trunc(input.count) || 1, 1), MAX_AGENTS_PER_RUN);
167 const identity = identityClient(this.env.IDENTITY);
Work service in Rust, with RFC 3339 timestamps168 const work = workClient(this.env.WORK);
Hosted agents: sandboxes on Cloudflare Containers started from an intent169
Issues and pull requests replace intents and attempts170 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[] = [];
Hosted agents: sandboxes on Cloudflare Containers started from an intent176 for (let i = 0; i < count; i++) {
Issues and pull requests replace intents and attempts177 const opened = await work.openPull(actor, repo, {
178 issue: issue.number,
Hosted agents: sandboxes on Cloudflare Containers started from an intent179 agent: AGENT,
180 runtime: "hosted",
181 });
182 // The first failure is the answer; later ones mean some already run.
Issues and pull requests replace intents and attempts183 if (!opened.ok) return pulls.length ? ok(pulls) : opened;
184 const pull = opened.value;
185 pulls.push(pull);
Hosted agents: sandboxes on Cloudflare Containers started from an intent186
187 // The sandbox acts as the person who started it, through a token
188 // that only lives as long as a run can.
189 const { token } = await identity.createAccessToken(
190 actor,
Issues and pull requests replace intents and attempts191 `g1t agent on ${repo.namespace}/${repo.name}#${pull.number}`,
Hosted agents: sandboxes on Cloudflare Containers started from an intent192 TOKEN_TTL_SECONDS,
193 );
Issues and pull requests replace intents and attempts194 const sandbox = this.env.SANDBOX.get(this.env.SANDBOX.idFromName(pull.id));
Hosted agents: sandboxes on Cloudflare Containers started from an intent195 await sandbox.run({
196 actor,
Issues and pull requests replace intents and attempts197 repo,
198 number: pull.number,
Hosted agents: sandboxes on Cloudflare Containers started from an intent199 envVars: {
200 G1T_API: "https://api.g1t.sh",
201 G1T_TOKEN: token,
202 G1T_USER: actor.username,
Issues and pull requests replace intents and attempts203 G1T_REPO: `${repo.namespace}/${repo.name}`,
204 PULL_NUMBER: String(pull.number),
205 GIT_REMOTE: `https://g1t.sh/${pull.fork.namespace}/${pull.fork.name}.git`,
206 COMMIT_MESSAGE: issue.title,
207 PROMPT: prompt,
g1t agents: model menu and optional AI Gateway routing208 ...modelEnv(this.env, model),
Hosted agents: sandboxes on Cloudflare Containers started from an intent209 },
210 });
211 }
Issues and pull requests replace intents and attempts212 return ok(pulls);
Hosted agents: sandboxes on Cloudflare Containers started from an intent213 }
214}