g1t/crates/runner/src/reply.rs
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.
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 1 | //! Answers a question someone asked `@g1t-agent` in a comment, in the |
| 2 | //! thread it was asked in, changing nothing. | |
| 3 | //! | |
| 4 | //! The agent reads the repository as it is (the default branch for an | |
| 5 | //! issue, the pull request's head for a pull request) and writes its answer | |
| 6 | //! to a file. This program posts the answer as `g1t-agent`, with the | |
| 7 | //! agent's own token, which the agent itself never holds. | |
| 8 | //! | |
| 9 | //! Configuration comes from the environment: | |
| 10 | //! | |
| 11 | //! - `G1T_API`, `G1T_REPO`, `REPLY_NUMBER`: where the question was asked. | |
| 12 | //! - `G1T_AGENT_TOKEN`: g1t-agent's token for this run, to post the answer. | |
| 13 | //! - `GIT_REMOTE`, `GIT_REF`: what to read, and at which branch or commit. | |
| 14 | //! - `G1T_USER`, `G1T_TOKEN`: to read it, if it is private. | |
| 15 | //! - `PROMPT`: the question and what the agent is told around it. | |
| 16 | ||
| 17 | use std::path::Path; | |
| 18 | ||
| 19 | use anyhow::{Context, Result, bail}; | |
| 20 | ||
| 21 | use crate::report::Reporter; | |
| 22 | use crate::{WORKDIR, auth_option, env, git, harness}; | |
| 23 | ||
| 24 | const ANSWER_FILE: &str = "/work/answer.md"; | |
| 25 | const MAX_ANSWER_CHARS: usize = 20_000; | |
| 26 | ||
| 27 | const INSTRUCTIONS: &str = "Write your answer to /work/answer.md as Markdown, addressed to whoever asked: \ | |
| 28 | plain sentences, specific, naming files and functions where that helps, no headings and no emoji. \ | |
| 29 | Read the code before you answer; say so when you are not sure. Do not change any file in the repository, \ | |
| 30 | and do not post the answer with add_comment: it is posted in the thread for you. Then finish."; | |
| 31 | ||
| 32 | fn answer() -> Result<String> { | |
| 33 | let remote = env("GIT_REMOTE")?; | |
| 34 | let auth = auth_option(&env("G1T_USER")?, &env("G1T_TOKEN")?); | |
| 35 | let workdir = Path::new(WORKDIR); | |
| 36 | std::fs::create_dir_all("/work")?; | |
| 37 | git( | |
| 38 | Path::new("/work"), | |
| 39 | &["-c", &auth, "clone", "--quiet", &remote, WORKDIR], | |
| 40 | ) | |
| 41 | .context("could not clone the repository")?; | |
| 42 | if let Ok(reference) = env("GIT_REF") | |
| 43 | && !reference.is_empty() | |
| 44 | { | |
| 45 | // A commit the clone may not have fetched by name: fetch it. | |
| 46 | let _ = git(workdir, &["-c", &auth, "fetch", "--quiet", "origin", &reference]); | |
| 47 | git(workdir, &["checkout", "--quiet", "--detach", &reference]) | |
| 48 | .or_else(|_| git(workdir, &["checkout", "--quiet", "--detach", "FETCH_HEAD"])) | |
| 49 | .context("could not check out what the question is about")?; | |
| 50 | } | |
| 51 | let prompt = format!("{}\n\n{INSTRUCTIONS}", env("PROMPT")?); | |
| 52 | // Steps show on the agent run; the answer is what matters here. | |
| 53 | let mut reporter = Reporter::silent(); | |
| 54 | let summary = harness::run_claude(workdir, &prompt, &mut reporter)?; | |
| 55 | let written = std::fs::read_to_string(ANSWER_FILE).unwrap_or_default(); | |
| 56 | let answer = if written.trim().is_empty() { summary } else { written }; | |
| 57 | let answer: String = answer.trim().chars().take(MAX_ANSWER_CHARS).collect(); | |
| 58 | if answer.is_empty() { | |
| 59 | bail!("the agent wrote no answer"); | |
| 60 | } | |
| 61 | Ok(answer) | |
| 62 | } | |
| 63 | ||
| 64 | pub fn main() -> i32 { | |
| 65 | let (Ok(api), Ok(repo), Ok(number), Ok(token)) = ( | |
| 66 | env("G1T_API"), | |
| 67 | env("G1T_REPO"), | |
| 68 | env("REPLY_NUMBER"), | |
| 69 | env("G1T_AGENT_TOKEN"), | |
| 70 | ) else { | |
| 71 | eprintln!("g1t-runner: G1T_API, G1T_REPO, REPLY_NUMBER and G1T_AGENT_TOKEN must be set"); | |
| 72 | return 2; | |
| 73 | }; | |
| 74 | let secrets: Vec<String> = ["G1T_TOKEN", "G1T_AGENT_TOKEN", "ANTHROPIC_API_KEY", "BILLING_TOKEN", "AGENT_RUN_TOKEN"] | |
| 75 | .iter() | |
| 76 | .filter_map(|name| std::env::var(name).ok()) | |
| 77 | .filter(|secret| !secret.is_empty()) | |
| 78 | .collect(); | |
| 79 | let outcome = answer(); | |
| 80 | let body = match &outcome { | |
| 81 | Ok(answer) => answer.clone(), | |
| 82 | Err(error) => { | |
| 83 | eprintln!("g1t-runner: {error:#}"); | |
| 84 | "I could not answer this: the run failed before I had an answer. Its steps are on the Agents page.".to_owned() | |
| 85 | } | |
| 86 | }; | |
| 87 | // Whatever the agent wrote passes through here, so nothing it could | |
| 88 | // have read from its environment leaves in the answer. | |
| 89 | let body = secrets | |
| 90 | .iter() | |
| 91 | .fold(body, |text, secret| text.replace(secret, "[redacted]")); | |
| 92 | let posted = ureq::post(&format!("{api}/repos/{repo}/issues/{number}/comments")) | |
| 93 | .set("Authorization", &format!("Bearer {token}")) | |
| 94 | .send_json(serde_json::json!({ "body": body })); | |
| 95 | if let Err(error) = posted { | |
| 96 | eprintln!("g1t-runner: could not post the answer: {error:#}"); | |
| 97 | return 1; | |
| 98 | } | |
| 99 | i32::from(outcome.is_err()) | |
| 100 | } |