g1t/services/runner/src/index.ts

198 lines6,816 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
Members can read a private repository's pull request forks21import { type ConfiguredModel, modelEnv } from "./model-env";
22
Hosted agents: sandboxes on Cloudflare Containers started from an intent23export interface RunnerEnv {
24 SANDBOX: DurableObjectNamespace<AttemptSandbox>;
25 IDENTITY: ServiceBinding;
Work service in Rust, with RFC 3339 timestamps26 WORK: ServiceBinding;
Hosted agents: sandboxes on Cloudflare Containers started from an intent27 /** Secret. The model key the hosted agent runs on. */
28 ANTHROPIC_API_KEY?: string;
29 /**
30 * Comma-separated usernames allowed to start hosted agents. Runs spend the
31 * key above, so this stays an allowlist until accounts bring their own.
32 */
33 HOSTED_AGENT_USERS: string;
g1t agents: model menu and optional AI Gateway routing34 /**
Show the model behind each choice; toolchains in the sandbox35 * The models offered, as JSON:
36 * `[{ id, label, description, modelName, model }]`. `modelName` is what
37 * people see; `model` is the identifier sent to the provider.
g1t agents: model menu and optional AI Gateway routing38 */
39 AGENT_MODELS: string;
40 /**
41 * A Cloudflare AI Gateway id. When set, model traffic goes through that
42 * gateway, which is where logging, spend limits, caching and fallback
43 * between providers are configured. Empty sends it to the provider
44 * directly.
45 */
46 AI_GATEWAY_ID: string;
47 CLOUDFLARE_ACCOUNT_ID: string;
48 /** Secret. Needed only if the gateway requires authentication. */
49 AI_GATEWAY_TOKEN?: string;
Hosted agents: sandboxes on Cloudflare Containers started from an intent50}
51
52const MAX_AGENTS_PER_RUN = 5;
53/** A run that takes longer than this has its token expire under it. */
54const TOKEN_TTL_SECONDS = 2 * 60 * 60;
Diffs on attempts; hosted agent presented as the g1t agent55/** How g1t's own agent is labelled. What runs behind it is g1t's choice. */
56const AGENT = "g1t-agent";
Hosted agents: sandboxes on Cloudflare Containers started from an intent57
Issues and pull requests replace intents and attempts58/** Which pull request a sandbox is working on, and as whom. */
59type Run = { actor: User; repo: RepoPath; number: number };
60type RunRequest = Run & { envVars: Record<string, string> };
Hosted agents: sandboxes on Cloudflare Containers started from an intent61
62/**
Issues and pull requests replace intents and attempts63 * One sandbox, for one pull request. The image's entrypoint is the g1t runner,
Hosted agents: sandboxes on Cloudflare Containers started from an intent64 * which does the work and exits; this class only starts it and cleans up
65 * if it dies without reporting.
66 */
67export class AttemptSandbox extends Container<RunnerEnv> {
68 sleepAfter = "45m";
69
70 async run(request: RunRequest): Promise<void> {
Issues and pull requests replace intents and attempts71 const { envVars, ...run } = request;
72 await this.ctx.storage.put("run", run);
73 await this.start({ envVars, enableInternet: true });
Hosted agents: sandboxes on Cloudflare Containers started from an intent74 }
75
76 override async onStop({ exitCode }: StopParams): Promise<void> {
77 if (exitCode === 0) return;
Issues and pull requests replace intents and attempts78 // The runner closes its own pull request when it fails. This covers a
79 // sandbox that was killed before it could; closing twice is refused
Hosted agents: sandboxes on Cloudflare Containers started from an intent80 // harmlessly.
Issues and pull requests replace intents and attempts81 const run = await this.ctx.storage.get<Run>("run");
82 if (run) await workClient(this.env.WORK).closePull(run.actor, run.repo, run.number);
g1t agents: model menu and optional AI Gateway routing83 }
84}
85
Issues and pull requests replace intents and attempts86function buildPrompt(issue: Issue, instructions: string): string {
Hosted agents: sandboxes on Cloudflare Containers started from an intent87 const parts = [
88 "You are a coding agent working in the git repository checked out in the current directory.",
Issues and pull requests replace intents and attempts89 `Issue #${issue.number}: ${issue.title}`,
90 issue.body,
Hosted agents: sandboxes on Cloudflare Containers started from an intent91 ];
Issues and pull requests replace intents and attempts92 if (issue.checks.length > 0) {
Hosted agents: sandboxes on Cloudflare Containers started from an intent93 parts.push(
Issues and pull requests replace intents and attempts94 `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 intent95 );
96 }
97 if (instructions) parts.push(instructions);
98 parts.push(
Members can read a private repository's pull request forks99 "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. It becomes the description of your pull request, so write it for a reviewer and leave out whether anything was committed or pushed.",
Hosted agents: sandboxes on Cloudflare Containers started from an intent100 );
101 return parts.filter(Boolean).join("\n\n");
102}
103
104export default class RunnerService
105 extends WorkerEntrypoint<RunnerEnv>
106 implements RunnerApi
107{
108 /** A Worker must have an event handler; this service is RPC-only. */
109 fetch(): Response {
110 return new Response("Not found\n", { status: 404 });
111 }
112
g1t agents: model menu and optional AI Gateway routing113 private configuredModels(): ConfiguredModel[] {
114 return JSON.parse(this.env.AGENT_MODELS);
115 }
116
117 private allowed(viewer: Viewer): boolean {
Hosted agents: sandboxes on Cloudflare Containers started from an intent118 if (!viewer || !this.env.ANTHROPIC_API_KEY) return false;
119 return this.env.HOSTED_AGENT_USERS.split(",")
120 .map((name) => name.trim())
121 .includes(viewer.username);
122 }
123
g1t agents: model menu and optional AI Gateway routing124 async models(viewer: Viewer): Promise<AgentModel[]> {
125 if (!this.allowed(viewer)) return [];
Show the model behind each choice; toolchains in the sandbox126 return this.configuredModels().map(({ id, label, description, modelName }) => ({
g1t agents: model menu and optional AI Gateway routing127 id,
128 label,
129 description,
Show the model behind each choice; toolchains in the sandbox130 modelName,
g1t agents: model menu and optional AI Gateway routing131 }));
132 }
133
Hosted agents: sandboxes on Cloudflare Containers started from an intent134 async run(
135 actor: User,
Issues and pull requests replace intents and attempts136 repo: RepoPath,
137 issueNumber: number,
Hosted agents: sandboxes on Cloudflare Containers started from an intent138 input: RunHostedInput,
Issues and pull requests replace intents and attempts139 ): Promise<Result<Pull[]>> {
g1t agents: model menu and optional AI Gateway routing140 if (!this.allowed(actor)) {
141 return fail("forbidden", "g1t agents are not enabled for your account.");
Hosted agents: sandboxes on Cloudflare Containers started from an intent142 }
g1t agents: model menu and optional AI Gateway routing143 const models = this.configuredModels();
144 const model = input.model
145 ? models.find((candidate) => candidate.id === input.model)
146 : models[0];
147 if (!model) return fail("invalid", "That model is not available.");
Hosted agents: sandboxes on Cloudflare Containers started from an intent148 const count = Math.min(Math.max(Math.trunc(input.count) || 1, 1), MAX_AGENTS_PER_RUN);
149 const identity = identityClient(this.env.IDENTITY);
Work service in Rust, with RFC 3339 timestamps150 const work = workClient(this.env.WORK);
Hosted agents: sandboxes on Cloudflare Containers started from an intent151
Issues and pull requests replace intents and attempts152 const found = await work.getIssue(repo, issueNumber, actor);
153 if (!found.ok) return found;
154 const { issue } = found.value;
155 const prompt = buildPrompt(issue, input.instructions?.trim() ?? "");
156
157 const pulls: Pull[] = [];
Hosted agents: sandboxes on Cloudflare Containers started from an intent158 for (let i = 0; i < count; i++) {
Issues and pull requests replace intents and attempts159 const opened = await work.openPull(actor, repo, {
160 issue: issue.number,
Hosted agents: sandboxes on Cloudflare Containers started from an intent161 agent: AGENT,
162 runtime: "hosted",
163 });
164 // The first failure is the answer; later ones mean some already run.
Issues and pull requests replace intents and attempts165 if (!opened.ok) return pulls.length ? ok(pulls) : opened;
166 const pull = opened.value;
Pull requests from branches167 // Opened without a branch, so it has a fork.
168 const fork = pull.fork!;
Issues and pull requests replace intents and attempts169 pulls.push(pull);
Hosted agents: sandboxes on Cloudflare Containers started from an intent170
171 // The sandbox acts as the person who started it, through a token
172 // that only lives as long as a run can.
173 const { token } = await identity.createAccessToken(
174 actor,
Issues and pull requests replace intents and attempts175 `g1t agent on ${repo.namespace}/${repo.name}#${pull.number}`,
Hosted agents: sandboxes on Cloudflare Containers started from an intent176 TOKEN_TTL_SECONDS,
177 );
Issues and pull requests replace intents and attempts178 const sandbox = this.env.SANDBOX.get(this.env.SANDBOX.idFromName(pull.id));
Hosted agents: sandboxes on Cloudflare Containers started from an intent179 await sandbox.run({
180 actor,
Issues and pull requests replace intents and attempts181 repo,
182 number: pull.number,
Hosted agents: sandboxes on Cloudflare Containers started from an intent183 envVars: {
184 G1T_API: "https://api.g1t.sh",
185 G1T_TOKEN: token,
186 G1T_USER: actor.username,
Issues and pull requests replace intents and attempts187 G1T_REPO: `${repo.namespace}/${repo.name}`,
188 PULL_NUMBER: String(pull.number),
Pull requests from branches189 GIT_REMOTE: `https://g1t.sh/${fork.namespace}/${fork.name}.git`,
Issues and pull requests replace intents and attempts190 COMMIT_MESSAGE: issue.title,
191 PROMPT: prompt,
g1t agents: model menu and optional AI Gateway routing192 ...modelEnv(this.env, model),
Hosted agents: sandboxes on Cloudflare Containers started from an intent193 },
194 });
195 }
Issues and pull requests replace intents and attempts196 return ok(pulls);
Hosted agents: sandboxes on Cloudflare Containers started from an intent197 }
198}