flagon-io/g1t

public

Git for AI scale: a forge for thousands of agents working on the same code at once.

g1t/crates/runner/src/harness.rs

245 lines9,483 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.

Account dropdown, llms.txt onboarding, hosted agent runner (not yet deployed)1//! The agent harness. g1t does not implement its own agent loop: it runs
2//! Claude Code headless and translates its event stream into session
3//! entries.
4
5use std::io::{BufRead, BufReader};
6use std::path::Path;
7use std::process::{Command, Stdio};
8
9use anyhow::{Context, Result, bail};
10use serde_json::Value;
11
Agents and memory, checks and conflicts, profiles, slug renames, custom domains12use crate::progress::{self, Progress};
Account dropdown, llms.txt onboarding, hosted agent runner (not yet deployed)13use crate::report::{Entry, Reporter};
14
15const MAX_TURNS: &str = "80";
16
17/// A compact, readable rendering of a tool's input.
18fn describe_input(tool: &str, input: &Value) -> String {
19 let field = |name: &str| input.get(name).and_then(Value::as_str);
20 match tool {
21 "Bash" => field("command").unwrap_or_default().to_owned(),
22 "Read" | "Write" | "Edit" | "MultiEdit" | "NotebookEdit" => {
23 field("file_path").unwrap_or_default().to_owned()
24 }
25 "Glob" | "Grep" => field("pattern").unwrap_or_default().to_owned(),
26 _ => serde_json::to_string(input).unwrap_or_default(),
27 }
28}
29
30/// The text of a tool result, which is either a string or content blocks.
31fn result_text(content: &Value) -> String {
32 match content {
33 Value::String(text) => text.clone(),
34 Value::Array(blocks) => blocks
35 .iter()
36 .filter_map(|block| block.get("text").and_then(Value::as_str))
37 .collect::<Vec<_>>()
38 .join("\n"),
39 _ => String::new(),
40 }
41}
42
Agents as a team: lifecycle, merge queue, billing and a new shell43/// Tells g1t what the run cost, so that the workspace it was for can be
44/// charged. The run's own token, given to this sandbox and to nothing
45/// else, is the credential. Does nothing where runs are not billed.
46fn report_cost(cost_usd: f64, turns: u64) {
47 let (Ok(api), Ok(run), Ok(token)) = (
48 std::env::var("G1T_API"),
49 std::env::var("BILLING_RUN"),
50 std::env::var("BILLING_TOKEN"),
51 ) else {
52 return;
53 };
54 let sent = ureq::post(&format!("{api}/runs/{run}/usage")).send_json(serde_json::json!({
55 "token": token,
56 "cost_usd": cost_usd,
57 "turns": turns,
58 }));
59 if let Err(error) = sent {
60 eprintln!("g1t-runner: could not report what the run cost: {error}");
61 }
62}
63
Account dropdown, llms.txt onboarding, hosted agent runner (not yet deployed)64/// Records one line of Claude Code's `stream-json` output. Returns the final
65/// result when the line is the one that ends the run.
Agents and memory, checks and conflicts, profiles, slug renames, custom domains66fn handle_event(
67 event: &Value,
68 reporter: &mut Reporter,
69 progress: &mut Option<Progress>,
70) -> Option<Result<String>> {
Account dropdown, llms.txt onboarding, hosted agent runner (not yet deployed)71 let blocks = || {
72 event["message"]["content"]
73 .as_array()
74 .map(Vec::as_slice)
75 .unwrap_or_default()
76 };
77 match event["type"].as_str()? {
78 "assistant" => {
79 for block in blocks() {
80 match block["type"].as_str() {
81 Some("text") => {
82 let text = block["text"].as_str().unwrap_or_default().trim();
83 if !text.is_empty() {
84 reporter.record(Entry::new("message", text));
Agents and memory, checks and conflicts, profiles, slug renames, custom domains85 if let Some(progress) = progress {
86 progress.step(&format!("Said: {}", text.lines().next().unwrap_or_default()));
87 }
Account dropdown, llms.txt onboarding, hosted agent runner (not yet deployed)88 }
89 }
90 Some("tool_use") => {
91 let tool = block["name"].as_str().unwrap_or("tool");
Agents and memory, checks and conflicts, profiles, slug renames, custom domains92 if let Some(progress) = progress {
93 progress.step(&progress::describe_tool(tool, &block["input"]));
94 }
Account dropdown, llms.txt onboarding, hosted agent runner (not yet deployed)95 reporter.record(Entry::tool(
96 "tool_call",
97 tool,
98 &describe_input(tool, &block["input"]),
99 ));
100 }
101 _ => {}
102 }
103 }
104 None
105 }
106 "user" => {
107 for block in blocks() {
108 if block["type"] == "tool_result" {
109 let text = result_text(&block["content"]);
110 if !text.trim().is_empty() {
111 reporter.record(Entry::tool("tool_result", "result", text.trim()));
112 }
113 }
114 }
115 None
116 }
117 "result" => {
118 let text = event["result"].as_str().unwrap_or_default().to_owned();
Agents as a team: lifecycle, merge queue, billing and a new shell119 // What the run cost, as the harness worked it out, kept with
120 // the session so that spend can be read per pull request.
121 if let Some(cost) = event["total_cost_usd"].as_f64() {
122 let turns = event["num_turns"].as_u64().unwrap_or_default();
123 report_cost(cost, turns);
Agents and memory, checks and conflicts, profiles, slug renames, custom domains124 if let Some(progress) = progress {
125 progress.cost(cost, turns);
126 }
Agents as a team: lifecycle, merge queue, billing and a new shell127 reporter.record(Entry::new(
128 "note",
129 &format!("This run cost ${cost:.4} over {turns} turns."),
130 ));
131 }
Account dropdown, llms.txt onboarding, hosted agent runner (not yet deployed)132 Some(if event["is_error"].as_bool().unwrap_or(false) {
133 Err(anyhow::anyhow!("the agent reported an error: {text}"))
134 } else {
135 Ok(text)
136 })
137 }
138 _ => None,
139 }
140}
141
142/// Runs Claude Code on `prompt` in `workdir` and returns its closing
143/// summary.
144pub fn run_claude(workdir: &Path, prompt: &str, reporter: &mut Reporter) -> Result<String> {
Agents as a team: lifecycle, merge queue, billing and a new shell145 // g1t's own tools, through a token that can do a few things in this one
146 // repository: read its issues, pull requests and merge queue, open an
147 // issue, and comment.
148 let mut tools = Vec::new();
149 if let Ok(token) = std::env::var("G1T_AGENT_TOKEN") {
150 let config = serde_json::json!({
151 "mcpServers": {
152 "g1t": {
153 "type": "http",
154 "url": std::env::var("G1T_MCP").unwrap_or_else(|_| "https://mcp.g1t.sh".to_owned()),
155 "headers": { "Authorization": format!("Bearer {token}") },
156 }
157 }
158 });
159 let path = "/work/g1t-mcp.json";
160 if std::fs::write(path, config.to_string()).is_ok() {
161 tools = vec!["--mcp-config".to_owned(), path.to_owned()];
162 }
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request163 // People can message the agent while it works: after each tool call
164 // a hook asks g1t for messages and hands any to the agent.
165 if let (Ok(repo), Ok(number)) = (std::env::var("G1T_REPO"), std::env::var("PULL_NUMBER")) {
166 let steer = serde_json::json!({
167 "api": std::env::var("G1T_API").unwrap_or_else(|_| "https://api.g1t.sh".to_owned()),
168 "token": token,
169 "repo": repo,
170 "number": number.parse::<u32>().unwrap_or_default(),
171 });
172 let hooks = serde_json::json!({
173 "hooks": {
174 "PostToolUse": [{
175 "matcher": "*",
176 "hooks": [{ "type": "command", "command": "MODE=steer /usr/local/bin/g1t-runner", "timeout": 15 }],
177 }],
Messages reach the agent even as it finishes178 "Stop": [{
179 "hooks": [{ "type": "command", "command": "MODE=steer G1T_HOOK=stop /usr/local/bin/g1t-runner", "timeout": 15 }],
180 }],
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request181 }
182 });
183 let home = std::env::var("HOME").unwrap_or_else(|_| "/home/node".to_owned());
184 let settings = format!("{home}/.claude/settings.json");
185 if std::fs::write(crate::steer::CONFIG, steer.to_string()).is_ok()
186 && std::fs::create_dir_all(format!("{home}/.claude")).is_ok()
187 {
188 let _ = std::fs::write(settings, hooks.to_string());
189 }
190 }
Agents as a team: lifecycle, merge queue, billing and a new shell191 }
Agents and memory, checks and conflicts, profiles, slug renames, custom domains192 // How the run goes, step by step, for people watching it live.
193 let mut progress = Progress::from_env();
194 if let Some(progress) = &mut progress {
195 progress.step("Started the agent");
196 }
Account dropdown, llms.txt onboarding, hosted agent runner (not yet deployed)197 let mut child = Command::new("claude")
198 .current_dir(workdir)
Agents as a team: lifecycle, merge queue, billing and a new shell199 .args(&tools)
Account dropdown, llms.txt onboarding, hosted agent runner (not yet deployed)200 .args([
201 "--print",
202 prompt,
203 "--output-format",
204 "stream-json",
205 "--verbose",
206 "--max-turns",
207 MAX_TURNS,
208 // The sandbox is the permission boundary: it holds one fork and
209 // one short-lived token, and nothing else.
210 "--dangerously-skip-permissions",
211 ])
212 // The agent needs the model key and nothing else of ours.
213 .env_remove("G1T_TOKEN")
Agents as a team: lifecycle, merge queue, billing and a new shell214 .env_remove("REVIEW_TOKEN")
215 .env_remove("CHECK_TOKEN")
216 .env_remove("BILLING_TOKEN")
217 .env_remove("PLAN_TOKEN")
218 .env_remove("G1T_AGENT_TOKEN")
Agents and memory, checks and conflicts, profiles, slug renames, custom domains219 .env_remove("AGENT_RUN_TOKEN")
Account dropdown, llms.txt onboarding, hosted agent runner (not yet deployed)220 .stdin(Stdio::null())
221 .stdout(Stdio::piped())
222 .stderr(Stdio::inherit())
223 .spawn()
224 .context("could not start Claude Code")?;
225
226 let stdout = child.stdout.take().context("no output from Claude Code")?;
227 let mut outcome = None;
228 for line in BufReader::new(stdout).lines() {
229 let line = line?;
230 let Ok(event) = serde_json::from_str::<Value>(&line) else {
231 continue;
232 };
Agents and memory, checks and conflicts, profiles, slug renames, custom domains233 if let Some(result) = handle_event(&event, reporter, &mut progress) {
Account dropdown, llms.txt onboarding, hosted agent runner (not yet deployed)234 outcome = Some(result);
235 }
236 }
237 let status = child.wait()?;
Agents and memory, checks and conflicts, profiles, slug renames, custom domains238 if let Some(progress) = &mut progress {
239 progress.flush();
240 }
Account dropdown, llms.txt onboarding, hosted agent runner (not yet deployed)241 match outcome {
242 Some(result) => result,
243 None => bail!("Claude Code exited ({status}) without a result"),
244 }
245}