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 | ||
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 30 | /// One message, as the API sends it: `snake_case`, or `fromNumber` from |
| 31 | /// before its bodies were. | |
| Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request | 32 | #[derive(Deserialize)] |
| 33 | struct Message { | |
| Agents ask each other, hand each other work, and answer | 34 | id: String, |
| Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request | 35 | author: String, |
| 36 | body: String, | |
| Agents ask each other, hand each other work, and answer | 37 | #[serde(default)] |
| 38 | kind: String, | |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 39 | #[serde(default, alias = "fromNumber")] |
| Agents ask each other, hand each other work, and answer | 40 | from_number: Option<u32>, |
| 41 | } | |
| 42 | ||
| 43 | /// One message as the agent should read it, with how to reply where it can. | |
| 44 | fn told(message: &Message) -> String { | |
| 45 | let from = message | |
| 46 | .from_number | |
| 47 | .map_or_else(|| message.author.clone(), |number| format!("The agent on #{number}")); | |
| 48 | match message.kind.as_str() { | |
| 49 | "question" => format!( | |
| 50 | "{from} asks you (message {}): {}\nAnswer it with the answer_message tool and that id.", | |
| 51 | message.id, message.body | |
| 52 | ), | |
| 53 | "handoff" => format!( | |
| 54 | "{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.", | |
| 55 | message.id, message.body | |
| 56 | ), | |
| 57 | "answer" => format!("{from} answered you: {}", message.body), | |
| 58 | _ => format!("{} says: {}", message.author, message.body), | |
| 59 | } | |
| Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request | 60 | } |
| 61 | ||
| 62 | fn now_ms() -> u128 { | |
| 63 | SystemTime::now() | |
| 64 | .duration_since(UNIX_EPOCH) | |
| 65 | .map(|elapsed| elapsed.as_millis()) | |
| 66 | .unwrap_or_default() | |
| 67 | } | |
| 68 | ||
| Messages reach the agent even as it finishes | 69 | /// The undelivered messages. Between steps, asks at most every few |
| 70 | /// seconds; when the agent is about to stop, always. | |
| 71 | fn take(stopping: bool) -> Option<Vec<Message>> { | |
| Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request | 72 | let config: Config = serde_json::from_str(&std::fs::read_to_string(CONFIG).ok()?).ok()?; |
| 73 | let last: u128 = std::fs::read_to_string(LAST_ASKED) | |
| 74 | .ok() | |
| 75 | .and_then(|text| text.trim().parse().ok()) | |
| 76 | .unwrap_or_default(); | |
| 77 | let now = now_ms(); | |
| Messages reach the agent even as it finishes | 78 | if !stopping && now.saturating_sub(last) < INTERVAL_MS { |
| Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request | 79 | return None; |
| 80 | } | |
| 81 | let _ = std::fs::write(LAST_ASKED, now.to_string()); | |
| 82 | let response = ureq::post(&format!( | |
| 83 | "{}/repos/{}/pulls/{}/messages/take", | |
| 84 | config.api, config.repo, config.number | |
| 85 | )) | |
| 86 | .set("Authorization", &format!("Bearer {}", config.token)) | |
| 87 | .send_json(serde_json::json!({})) | |
| 88 | .ok()?; | |
| 89 | response.into_json().ok() | |
| 90 | } | |
| 91 | ||
| 92 | pub fn main() -> i32 { | |
| Messages reach the agent even as it finishes | 93 | // Run as the Stop hook too, so a message sent while the agent was |
| 94 | // finishing is not lost: it keeps the agent going to act on it. | |
| 95 | let stopping = std::env::var("G1T_HOOK").as_deref() == Ok("stop"); | |
| 96 | 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 | 97 | return 0; |
| 98 | }; | |
| Agents ask each other, hand each other work, and answer | 99 | 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 | 100 | let context = format!( |
| Agents ask each other, hand each other work, and answer | 101 | "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 | 102 | said.join("\n\n") |
| 103 | ); | |
| Messages reach the agent even as it finishes | 104 | let output = if stopping { |
| 105 | serde_json::json!({ "decision": "block", "reason": context }) | |
| 106 | } else { | |
| Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request | 107 | serde_json::json!({ |
| 108 | "hookSpecificOutput": { | |
| 109 | "hookEventName": "PostToolUse", | |
| 110 | "additionalContext": context, | |
| 111 | } | |
| 112 | }) | |
| Messages reach the agent even as it finishes | 113 | }; |
| 114 | println!("{output}"); | |
| Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request | 115 | 0 |
| 116 | } |