pr_01m47d24b0e6n91zwymwxg0vpx/crates/runner/src/harness.rs

138 lines4,801 bytesCodeBlame
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
12use crate::report::{Entry, Reporter};
13
14const MAX_TURNS: &str = "80";
15
16/// A compact, readable rendering of a tool's input.
17fn describe_input(tool: &str, input: &Value) -> String {
18 let field = |name: &str| input.get(name).and_then(Value::as_str);
19 match tool {
20 "Bash" => field("command").unwrap_or_default().to_owned(),
21 "Read" | "Write" | "Edit" | "MultiEdit" | "NotebookEdit" => {
22 field("file_path").unwrap_or_default().to_owned()
23 }
24 "Glob" | "Grep" => field("pattern").unwrap_or_default().to_owned(),
25 _ => serde_json::to_string(input).unwrap_or_default(),
26 }
27}
28
29/// The text of a tool result, which is either a string or content blocks.
30fn result_text(content: &Value) -> String {
31 match content {
32 Value::String(text) => text.clone(),
33 Value::Array(blocks) => blocks
34 .iter()
35 .filter_map(|block| block.get("text").and_then(Value::as_str))
36 .collect::<Vec<_>>()
37 .join("\n"),
38 _ => String::new(),
39 }
40}
41
42/// Records one line of Claude Code's `stream-json` output. Returns the final
43/// result when the line is the one that ends the run.
44fn handle_event(event: &Value, reporter: &mut Reporter) -> Option<Result<String>> {
45 let blocks = || {
46 event["message"]["content"]
47 .as_array()
48 .map(Vec::as_slice)
49 .unwrap_or_default()
50 };
51 match event["type"].as_str()? {
52 "assistant" => {
53 for block in blocks() {
54 match block["type"].as_str() {
55 Some("text") => {
56 let text = block["text"].as_str().unwrap_or_default().trim();
57 if !text.is_empty() {
58 reporter.record(Entry::new("message", text));
59 }
60 }
61 Some("tool_use") => {
62 let tool = block["name"].as_str().unwrap_or("tool");
63 reporter.record(Entry::tool(
64 "tool_call",
65 tool,
66 &describe_input(tool, &block["input"]),
67 ));
68 }
69 _ => {}
70 }
71 }
72 None
73 }
74 "user" => {
75 for block in blocks() {
76 if block["type"] == "tool_result" {
77 let text = result_text(&block["content"]);
78 if !text.trim().is_empty() {
79 reporter.record(Entry::tool("tool_result", "result", text.trim()));
80 }
81 }
82 }
83 None
84 }
85 "result" => {
86 let text = event["result"].as_str().unwrap_or_default().to_owned();
87 Some(if event["is_error"].as_bool().unwrap_or(false) {
88 Err(anyhow::anyhow!("the agent reported an error: {text}"))
89 } else {
90 Ok(text)
91 })
92 }
93 _ => None,
94 }
95}
96
97/// Runs Claude Code on `prompt` in `workdir` and returns its closing
98/// summary.
99pub fn run_claude(workdir: &Path, prompt: &str, reporter: &mut Reporter) -> Result<String> {
100 let mut child = Command::new("claude")
101 .current_dir(workdir)
102 .args([
103 "--print",
104 prompt,
105 "--output-format",
106 "stream-json",
107 "--verbose",
108 "--max-turns",
109 MAX_TURNS,
110 // The sandbox is the permission boundary: it holds one fork and
111 // one short-lived token, and nothing else.
112 "--dangerously-skip-permissions",
113 ])
114 // The agent needs the model key and nothing else of ours.
115 .env_remove("G1T_TOKEN")
116 .stdin(Stdio::null())
117 .stdout(Stdio::piped())
118 .stderr(Stdio::inherit())
119 .spawn()
120 .context("could not start Claude Code")?;
121
122 let stdout = child.stdout.take().context("no output from Claude Code")?;
123 let mut outcome = None;
124 for line in BufReader::new(stdout).lines() {
125 let line = line?;
126 let Ok(event) = serde_json::from_str::<Value>(&line) else {
127 continue;
128 };
129 if let Some(result) = handle_event(&event, reporter) {
130 outcome = Some(result);
131 }
132 }
133 let status = child.wait()?;
134 match outcome {
135 Some(result) => result,
136 None => bail!("Claude Code exited ({status}) without a result"),
137 }
138}