flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/crates/runner/src/steer.rs

116 lines4,136 bytesCodeBlame
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
11use std::time::{SystemTime, UNIX_EPOCH};
12
13use serde::Deserialize;
14
15/// Where the harness leaves what this needs.
16pub const CONFIG: &str = "/work/g1t-steer.json";
17/// When it last asked, so that a burst of tool calls asks once.
18const LAST_ASKED: &str = "/work/.g1t-steer-at";
19/// How long to wait between asks.
20const INTERVAL_MS: u128 = 5_000;
21
22#[derive(Deserialize)]
23struct Config {
24 api: String,
25 token: String,
26 repo: String,
27 number: u32,
28}
29
30/// One message, as the API sends it: `snake_case`, or `fromNumber` from
31/// before its bodies were.
32#[derive(Deserialize)]
33struct Message {
34 id: String,
35 author: String,
36 body: String,
37 #[serde(default)]
38 kind: String,
39 #[serde(default, alias = "fromNumber")]
40 from_number: Option<u32>,
41}
42
43/// One message as the agent should read it, with how to reply where it can.
44fn 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 }
60}
61
62fn now_ms() -> u128 {
63 SystemTime::now()
64 .duration_since(UNIX_EPOCH)
65 .map(|elapsed| elapsed.as_millis())
66 .unwrap_or_default()
67}
68
69/// The undelivered messages. Between steps, asks at most every few
70/// seconds; when the agent is about to stop, always.
71fn take(stopping: bool) -> Option<Vec<Message>> {
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();
78 if !stopping && now.saturating_sub(last) < INTERVAL_MS {
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
92pub fn main() -> i32 {
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 {
97 return 0;
98 };
99 let said: Vec<String> = messages.iter().map(told).collect();
100 let context = format!(
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{}",
102 said.join("\n\n")
103 );
104 let output = if stopping {
105 serde_json::json!({ "decision": "block", "reason": context })
106 } else {
107 serde_json::json!({
108 "hookSpecificOutput": {
109 "hookEventName": "PostToolUse",
110 "additionalContext": context,
111 }
112 })
113 };
114 println!("{output}");
115 0
116}