g1t/crates/runner/src/steer.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.
| Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request | 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. | |
| Messages reach the agent even as it finishes | 20 | const INTERVAL_MS: u128 = 5_000; |
| Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request | 21 | |
| 22 | #[derive(Deserialize)] | |
| 23 | struct Config { | |
| 24 | api: String, | |
| 25 | token: String, | |
| 26 | repo: String, | |
| 27 | number: u32, | |
| 28 | } | |
| 29 | ||
| 30 | #[derive(Deserialize)] | |
| Agents ask each other, hand each other work, and answer | 31 | #[serde(rename_all = "camelCase")] |
| Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request | 32 | struct Message { |
| Agents ask each other, hand each other work, and answer | 33 | id: String, |
| Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request | 34 | author: String, |
| 35 | body: String, | |
| Agents ask each other, hand each other work, and answer | 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 | } | |
| Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request | 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 | ||
| Messages reach the agent even as it finishes | 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>> { | |
| Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request | 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(); | |
| Messages reach the agent even as it finishes | 77 | if !stopping && now.saturating_sub(last) < INTERVAL_MS { |
| Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request | 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 { | |
| Messages reach the agent even as it finishes | 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 { | |
| Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request | 96 | return 0; |
| 97 | }; | |
| Agents ask each other, hand each other work, and answer | 98 | let said: Vec<String> = messages.iter().map(told).collect(); |
| Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request | 99 | let context = format!( |
| Agents ask each other, hand each other work, and answer | 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{}", |
| Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request | 101 | said.join("\n\n") |
| 102 | ); | |
| Messages reach the agent even as it finishes | 103 | let output = if stopping { |
| 104 | serde_json::json!({ "decision": "block", "reason": context }) | |
| 105 | } else { | |
| Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request | 106 | serde_json::json!({ |
| 107 | "hookSpecificOutput": { | |
| 108 | "hookEventName": "PostToolUse", | |
| 109 | "additionalContext": context, | |
| 110 | } | |
| 111 | }) | |
| Messages reach the agent even as it finishes | 112 | }; |
| 113 | println!("{output}"); | |
| Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request | 114 | 0 |
| 115 | } |