pr_01m47d15m3e54sn21z27rpy5n9/crates/runner/src/steer.rs
| 1 | //! Delivers people's messages to the agent while it works. |
| 2 | //! |
| 3 | //! Claude Code runs this as a hook after each of the agent's tool calls |
| 4 | //! (see `harness`). It asks g1t for messages the agent has not seen and, |
| 5 | //! if there are any, hands them to the agent as context for its next step. |
| 6 | //! It never fails the agent's run: any problem means no message this time. |
| 7 | //! |
| 8 | //! What it needs is in `/work/g1t-steer.json`, written by the harness: |
| 9 | //! the API, the agent's token, the repository and the pull request. |
| 10 | |
| 11 | use std::time::{SystemTime, UNIX_EPOCH}; |
| 12 | |
| 13 | use serde::Deserialize; |
| 14 | |
| 15 | /// Where the harness leaves what this needs. |
| 16 | pub const CONFIG: &str = "/work/g1t-steer.json"; |
| 17 | /// When it last asked, so that a burst of tool calls asks once. |
| 18 | const LAST_ASKED: &str = "/work/.g1t-steer-at"; |
| 19 | /// How long to wait between asks. |
| 20 | const INTERVAL_MS: u128 = 5_000; |
| 21 | |
| 22 | #[derive(Deserialize)] |
| 23 | struct Config { |
| 24 | api: String, |
| 25 | token: String, |
| 26 | repo: String, |
| 27 | number: u32, |
| 28 | } |
| 29 | |
| 30 | #[derive(Deserialize)] |
| 31 | #[serde(rename_all = "camelCase")] |
| 32 | struct Message { |
| 33 | id: String, |
| 34 | author: String, |
| 35 | body: String, |
| 36 | #[serde(default)] |
| 37 | kind: String, |
| 38 | #[serde(default)] |
| 39 | from_number: Option<u32>, |
| 40 | } |
| 41 | |
| 42 | /// One message as the agent should read it, with how to reply where it can. |
| 43 | fn told(message: &Message) -> String { |
| 44 | let from = message |
| 45 | .from_number |
| 46 | .map_or_else(|| message.author.clone(), |number| format!("The agent on #{number}")); |
| 47 | match message.kind.as_str() { |
| 48 | "question" => format!( |
| 49 | "{from} asks you (message {}): {}\nAnswer it with the answer_message tool and that id.", |
| 50 | message.id, message.body |
| 51 | ), |
| 52 | "handoff" => format!( |
| 53 | "{from} hands you work that belongs in your pull request (message {}): {}\nTake it on, or decline it if it is not yours, with the answer_message tool and that id.", |
| 54 | message.id, message.body |
| 55 | ), |
| 56 | "answer" => format!("{from} answered you: {}", message.body), |
| 57 | _ => format!("{} says: {}", message.author, message.body), |
| 58 | } |
| 59 | } |
| 60 | |
| 61 | fn now_ms() -> u128 { |
| 62 | SystemTime::now() |
| 63 | .duration_since(UNIX_EPOCH) |
| 64 | .map(|elapsed| elapsed.as_millis()) |
| 65 | .unwrap_or_default() |
| 66 | } |
| 67 | |
| 68 | /// The undelivered messages. Between steps, asks at most every few |
| 69 | /// seconds; when the agent is about to stop, always. |
| 70 | fn take(stopping: bool) -> Option<Vec<Message>> { |
| 71 | let config: Config = serde_json::from_str(&std::fs::read_to_string(CONFIG).ok()?).ok()?; |
| 72 | let last: u128 = std::fs::read_to_string(LAST_ASKED) |
| 73 | .ok() |
| 74 | .and_then(|text| text.trim().parse().ok()) |
| 75 | .unwrap_or_default(); |
| 76 | let now = now_ms(); |
| 77 | if !stopping && now.saturating_sub(last) < INTERVAL_MS { |
| 78 | return None; |
| 79 | } |
| 80 | let _ = std::fs::write(LAST_ASKED, now.to_string()); |
| 81 | let response = ureq::post(&format!( |
| 82 | "{}/repos/{}/pulls/{}/messages/take", |
| 83 | config.api, config.repo, config.number |
| 84 | )) |
| 85 | .set("Authorization", &format!("Bearer {}", config.token)) |
| 86 | .send_json(serde_json::json!({})) |
| 87 | .ok()?; |
| 88 | response.into_json().ok() |
| 89 | } |
| 90 | |
| 91 | pub fn main() -> i32 { |
| 92 | // Run as the Stop hook too, so a message sent while the agent was |
| 93 | // finishing is not lost: it keeps the agent going to act on it. |
| 94 | let stopping = std::env::var("G1T_HOOK").as_deref() == Ok("stop"); |
| 95 | let Some(messages) = take(stopping).filter(|messages| !messages.is_empty()) else { |
| 96 | return 0; |
| 97 | }; |
| 98 | let said: Vec<String> = messages.iter().map(told).collect(); |
| 99 | let context = format!( |
| 100 | "New messages on your pull request. A person's outranks your earlier instructions where they conflict; another agent's is a colleague's.\n\n{}", |
| 101 | said.join("\n\n") |
| 102 | ); |
| 103 | let output = if stopping { |
| 104 | serde_json::json!({ "decision": "block", "reason": context }) |
| 105 | } else { |
| 106 | serde_json::json!({ |
| 107 | "hookSpecificOutput": { |
| 108 | "hookEventName": "PostToolUse", |
| 109 | "additionalContext": context, |
| 110 | } |
| 111 | }) |
| 112 | }; |
| 113 | println!("{output}"); |
| 114 | 0 |
| 115 | } |