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

115 lines4,048 bytesCodeBlame

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 request1//! 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.
Messages reach the agent even as it finishes20const INTERVAL_MS: u128 = 5_000;
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request21
22#[derive(Deserialize)]
23struct 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 answer31#[serde(rename_all = "camelCase")]
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request32struct Message {
Agents ask each other, hand each other work, and answer33 id: String,
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request34 author: String,
35 body: String,
Agents ask each other, hand each other work, and answer36 #[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.
43fn 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 request59}
60
61fn 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 finishes68/// The undelivered messages. Between steps, asks at most every few
69/// seconds; when the agent is about to stop, always.
70fn take(stopping: bool) -> Option<Vec<Message>> {
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request71 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 finishes77 if !stopping && now.saturating_sub(last) < INTERVAL_MS {
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request78 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
91pub fn main() -> i32 {
Messages reach the agent even as it finishes92 // 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 request96 return 0;
97 };
Agents ask each other, hand each other work, and answer98 let said: Vec<String> = messages.iter().map(told).collect();
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request99 let context = format!(
Agents ask each other, hand each other work, and answer100 "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 request101 said.join("\n\n")
102 );
Messages reach the agent even as it finishes103 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 request106 serde_json::json!({
107 "hookSpecificOutput": {
108 "hookEventName": "PostToolUse",
109 "additionalContext": context,
110 }
111 })
Messages reach the agent even as it finishes112 };
113 println!("{output}");
Usage, like a hosting provider's: what agents cost, per day, task, repository and pull request114 0
115}