Skip to main content

rhema_coding_agent/
environment.rs

1//! The startup environment report: machine facts for the system prompt,
2//! probed once before the first model call so no turn is spent
3//! rediscovering them. The claw's daemon writes the same report at boot
4//! (`ENVIRONMENT.md`); here the report renders straight into the
5//! prompt's environment section.
6//!
7//! Every text the report carries is data: [`environment.yaml`] at the
8//! crate root — beside SYSTEM.md — holds the OS name mapping, the probe
9//! inventory, and the report, notice, and unavailable texts as Jinja
10//! templates over the facts this module gathers. The Rust here is
11//! mechanics alone: run the probe, shape the facts, render the
12//! template. The file is baked in (`include_str!`), not read at
13//! runtime: the report must not turn machine-dependent, and the cloud
14//! frontends have no filesystem to read it from anyway.
15//!
16//! The probe is synchronous on purpose — agent construction is sync —
17//! and best-effort: one `bash -c` under a short budget on a fixed
18//! script, a failure degrading to the `unavailable` text, never a
19//! failed startup. It describes the host, which is where commands run
20//! unless a sandbox re-points them; a sandboxed session takes
21//! [`remote_notice`] instead, because host facts would be lies about a
22//! container. The report's date is stamped once at construction: it
23//! rides the system prompt, which stays byte-stable for a session's
24//! life.
25//!
26//! [`environment.yaml`]: crate::environment::manifest
27
28use std::collections::BTreeMap;
29use std::io::Read;
30use std::path::Path;
31use std::process::{Command, Stdio};
32use std::sync::OnceLock;
33use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
34
35use rhema_agent_tools::render_template;
36use serde_json::json;
37
38/// One inventoried tool.
39#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
40pub struct Probe {
41    /// The executable the probe looks for and the report names — the
42    /// command the model would type, not the project's name for it.
43    pub executable: String,
44    /// Guidance that joins the report's notes only when `executable`
45    /// answered. Advice about a missing tool would be the stale kind.
46    #[serde(default)]
47    pub advice: Option<String>,
48}
49
50/// The report's data: the inventory, the OS name mapping, and the
51/// texts (Jinja templates) the report renders.
52#[derive(Debug, Clone, PartialEq, serde::Deserialize)]
53pub struct Manifest {
54    /// The friendly name for each `uname -s` system value; a system
55    /// without a row keeps its raw uname spelling.
56    #[serde(default)]
57    pub os_names: BTreeMap<String, String>,
58    /// The probed executables, in report order.
59    pub probe: Vec<Probe>,
60    /// The session facts every shape places by reference — written
61    /// once, never duplicated in the texts.
62    pub facts: String,
63    /// The report a local session renders.
64    pub report: String,
65    /// The message a sandboxed session shows instead.
66    pub notice: String,
67    /// The message a failed probe degrades to.
68    pub unavailable: String,
69}
70
71/// The baked-in manifest (`environment.yaml` at the crate root), parsed
72/// once. A manifest that does not parse falls to [`fallback_report`]:
73/// the drift test is where that break belongs, never a startup.
74pub fn manifest() -> &'static Manifest {
75    static MANIFEST: OnceLock<Manifest> = OnceLock::new();
76    MANIFEST.get_or_init(|| {
77        serde_yaml::from_str(include_str!("../environment.yaml")).unwrap_or_else(|_| Manifest {
78            os_names: BTreeMap::new(),
79            probe: Vec::new(),
80            facts: String::new(),
81            report: String::new(),
82            notice: String::new(),
83            unavailable: String::new(),
84        })
85    })
86}
87
88/// An executable name the probe script can carry verbatim: letters,
89/// digits, dots, dashes, underscores — nothing that could close the
90/// `"$t"` quoting and become shell of its own.
91fn shell_safe(executable: &str) -> bool {
92    !executable.is_empty()
93        && executable
94            .chars()
95            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_'))
96}
97
98/// The probe's whole budget: long enough for a cold tool list, short
99/// enough that a hung shell cannot stall a startup past it.
100const PROBE_BUDGET: Duration = Duration::from_secs(5);
101
102/// How often a running probe is polled for exit.
103const POLL_STEP: Duration = Duration::from_millis(10);
104
105/// The report-side bound on the probe's stdout: the inventory is
106/// fixed-size, so this is a guard against a pathological shell, not an
107/// expectation.
108const OUTPUT_CAP: usize = 4 * 1024;
109
110/// The probe script: OS, shell, one presence line per manifest entry —
111/// versions stay out; presence is the fact that changes how the model
112/// should work — and the workspace's git branch when it is a checkout.
113/// POSIX sh throughout — every executor in the tree runs `bash -c`.
114fn probe_script(manifest: &Manifest) -> String {
115    let tools: Vec<&str> = manifest
116        .probe
117        .iter()
118        .filter(|entry| shell_safe(&entry.executable))
119        .map(|entry| entry.executable.as_str())
120        .collect();
121    format!(
122        r#"
123uname -sm
124printf 'shell: %s\n' "${{SHELL:-unset}}"
125for t in {tools}; do
126  if command -v "$t" >/dev/null 2>&1; then
127    printf '%s: present\n' "$t"
128  else
129    printf '%s: absent\n' "$t"
130  fi
131done
132if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
133  printf 'git branch: %s\n' "$(git branch --show-current 2>/dev/null || printf detached)"
134fi
135"#,
136        tools = tools.join(" ")
137    )
138}
139
140/// The host report: what `bash` on this machine, rooted at `workspace`,
141/// would answer at startup — carrying `model`, the model the session
142/// runs, and `harness`, the frontend assembling it. Never fails: a
143/// probe that cannot run renders the unavailable text.
144pub fn host_report(workspace: &Path, model: &str, harness: &str) -> String {
145    let facts = facts(model, harness);
146    match run_probe(manifest(), workspace) {
147        Ok(output) => render_report(manifest(), workspace, &facts, &output),
148        Err(reason) => unavailable(&facts, &reason),
149    }
150}
151
152/// The section a sandboxed session renders instead of host facts: the
153/// report's honesty rule — commands run somewhere a host probe cannot
154/// see, so it claims none and names the kind. The sandbox-side
155/// `workspace` is carried — where the model's commands run is not a
156/// host fact.
157pub fn remote_notice(kind: &str, workspace: &str, model: &str, harness: &str) -> String {
158    let manifest = manifest();
159    let facts = facts(model, harness);
160    let rendered = render_text(
161        &manifest.notice,
162        &facts.context(manifest, json!({ "kind": kind, "workspace": workspace })),
163    );
164    if rendered.is_empty() {
165        fallback_report(&facts)
166    } else {
167        rendered
168    }
169}
170
171/// The facts every report shape carries, however the probe went.
172struct Facts {
173    /// The construction-time UTC stamp, to the minute.
174    date: String,
175    /// The model the session runs.
176    model: String,
177    /// The frontend assembling the agent.
178    harness: String,
179}
180
181impl Facts {
182    /// The template context every text extends, carrying the composed
183    /// `facts` block — the yaml's one copy of the session facts,
184    /// placed by reference, never re-typed in a text.
185    fn context(&self, manifest: &Manifest, extra: serde_json::Value) -> serde_json::Value {
186        let serde_json::Value::Object(mut map) = extra else {
187            return serde_json::Value::Null;
188        };
189        map.insert("date".into(), json!(self.date));
190        map.insert("model".into(), json!(self.model));
191        map.insert("harness".into(), json!(self.harness));
192        let facts = render_template(
193            &manifest.facts,
194            &json!({ "date": self.date, "model": self.model, "harness": self.harness }),
195        )
196        .unwrap_or_default();
197        map.insert("facts".into(), json!(facts));
198        serde_json::Value::Object(map)
199    }
200}
201
202fn facts(model: &str, harness: &str) -> Facts {
203    Facts {
204        date: utc_now(),
205        model: model.to_owned(),
206        harness: harness.to_owned(),
207    }
208}
209
210/// Renders one manifest text over its context; an empty text (the
211/// unparsed-manifest case) falls to [`fallback_report`].
212fn render_text(text: &str, context: &serde_json::Value) -> String {
213    if text.is_empty() {
214        return String::new();
215    }
216    render_template(text, context).unwrap_or_default()
217}
218
219/// Shapes the probe's bounded stdout into the report over the manifest:
220/// the first two lines are the OS and the shell, the presence lines
221/// follow, and every answered executable with advice earns its note.
222fn render_report(manifest: &Manifest, workspace: &Path, facts: &Facts, output: &str) -> String {
223    let mut lines = output.lines().filter(|line| !line.trim().is_empty());
224    let os = os_label(manifest, lines.next().unwrap_or("unknown"));
225    let shell = lines
226        .next()
227        .and_then(|line| line.strip_prefix("shell: "))
228        .unwrap_or("unknown")
229        .to_owned();
230    let rest: Vec<&str> = lines.collect();
231    let branch = rest
232        .iter()
233        .find_map(|l| l.strip_prefix("git branch: "))
234        .map(str::to_owned);
235    let tools: Vec<serde_json::Value> = manifest
236        .probe
237        .iter()
238        .map(|entry| {
239            let present = rest
240                .iter()
241                .any(|l| *l == format!("{}: present", entry.executable));
242            json!({ "name": entry.executable, "state": if present { "present" } else { "absent" } })
243        })
244        .collect();
245    let notes: Vec<String> = manifest
246        .probe
247        .iter()
248        .filter(|entry| {
249            entry.advice.is_some()
250                && rest
251                    .iter()
252                    .any(|l| *l == format!("{}: present", entry.executable))
253        })
254        .filter_map(|entry| entry.advice.clone())
255        .collect();
256    let context = facts.context(
257        manifest,
258        json!({
259            "os": os,
260            "shell": shell,
261            "workspace": workspace.display().to_string(),
262            "branch": branch,
263            "tools": tools,
264            "notes": notes,
265        }),
266    );
267    let rendered = render_text(&manifest.report, &context);
268    if rendered.is_empty() {
269        fallback_report(facts)
270    } else {
271        rendered
272    }
273}
274
275/// The unavailable text over the failure reason.
276fn unavailable(facts: &Facts, reason: &str) -> String {
277    let manifest = manifest();
278    let rendered = render_text(
279        &manifest.unavailable,
280        &facts.context(manifest, json!({ "reason": reason })),
281    );
282    if rendered.is_empty() {
283        fallback_report(facts)
284    } else {
285        rendered
286    }
287}
288
289/// The mechanism's last resort, when the manifest itself did not parse:
290/// the facts the shell-independent clock still names, no template.
291fn fallback_report(facts: &Facts) -> String {
292    format!(
293        "## Environment\n\n\
294         The report's data did not parse; ask the shell (`uname -sm`,\n\
295         `command -v <tool>`) when a fact matters.\n\n\
296         - Date: {}\n- Model: {}\n- Harness: {}\n",
297        facts.date, facts.model, facts.harness
298    )
299}
300
301/// The OS line: the mapping's friendly name with the raw uname spelling
302/// beside it — `macOS (Darwin arm64)` — or the raw spelling alone when
303/// the manifest maps no friendly name.
304fn os_label(manifest: &Manifest, raw: &str) -> String {
305    let Some((system, _rest)) = raw.split_once(' ') else {
306        return raw.to_owned();
307    };
308    match manifest.os_names.get(&system.to_lowercase()) {
309        Some(friendly) => format!("{friendly} ({raw})"),
310        None => raw.to_owned(),
311    }
312}
313
314/// Runs the probe script with `workspace` as working directory and
315/// returns its bounded stdout. Stdin is null so a shell that wrongly
316/// reads it sees EOF; anything still running at the deadline is killed
317/// and reaped.
318fn run_probe(manifest: &Manifest, workspace: &Path) -> Result<String, String> {
319    let mut child = Command::new("bash")
320        .arg("-c")
321        .arg(probe_script(manifest))
322        .current_dir(workspace)
323        .stdin(Stdio::null())
324        .stdout(Stdio::piped())
325        .stderr(Stdio::null())
326        .spawn()
327        .map_err(|err| format!("cannot spawn the probe: {err}"))?;
328    let deadline = Instant::now() + PROBE_BUDGET;
329    let status = loop {
330        match child.try_wait() {
331            Ok(Some(status)) => break Some(status),
332            Ok(None) if Instant::now() >= deadline => break None,
333            Ok(None) => std::thread::sleep(POLL_STEP),
334            Err(err) => return Err(format!("cannot wait on the probe: {err}")),
335        }
336    };
337    let Some(status) = status else {
338        let _ = child.kill();
339        let _ = child.wait();
340        return Err(format!("no exit within {PROBE_BUDGET:?}"));
341    };
342    if !status.success() {
343        return Err(format!("probe exited: {status}"));
344    }
345    let mut bytes = Vec::new();
346    if let Some(mut stdout) = child.stdout.take() {
347        let _ = (&mut stdout)
348            .take(OUTPUT_CAP as u64)
349            .read_to_end(&mut bytes);
350    }
351    Ok(String::from_utf8_lossy(&bytes).into_owned())
352}
353
354/// The wall-clock UTC stamp, to the minute — the one timestamp shape
355/// the reports carry, stamped once at construction.
356fn utc_now() -> String {
357    let secs = SystemTime::now()
358        .duration_since(UNIX_EPOCH)
359        .unwrap_or_default()
360        .as_secs();
361    let (year, month, day) = civil_from_days((secs / 86_400) as i64);
362    let secs_of_day = secs % 86_400;
363    format!(
364        "{year:04}-{month:02}-{day:02} {:02}:{:02} UTC",
365        secs_of_day / 3_600,
366        secs_of_day % 3_600 / 60
367    )
368}
369
370/// Days since the Unix epoch to (year, month, day), UTC — the civil
371/// calendar without a clock dependency.
372fn civil_from_days(days: i64) -> (i64, u32, u32) {
373    let z = days + 719_468;
374    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
375    let doe = z - era * 146_097;
376    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
377    let year = yoe + era * 400;
378    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
379    let mp = (5 * doy + 2) / 153;
380    let day = (doy - (153 * mp + 2) / 5 + 1) as u32;
381    let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
382    (if month <= 2 { year + 1 } else { year }, month, day)
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388
389    /// The baked manifest parses and every text renders over sample
390    /// facts — the drift test a hand edit fails: a manifest that stops
391    /// parsing falls to the fallback at runtime, so the break belongs
392    /// in a test, not a startup.
393    #[test]
394    fn the_baked_manifest_parses_and_every_text_renders() {
395        let manifest = manifest();
396        assert!(!manifest.probe.is_empty(), "an empty inventory is a bug");
397        assert!(!manifest.report.is_empty());
398        assert!(
399            !manifest.facts.is_empty(),
400            "the facts block is written once"
401        );
402        let facts = facts("m", "h");
403        for text in [
404            &manifest.facts,
405            &manifest.report,
406            &manifest.notice,
407            &manifest.unavailable,
408        ] {
409            let context = facts.context(
410                manifest,
411                json!({
412                    "os": "macOS (Darwin arm64)", "shell": "/bin/zsh",
413                    "workspace": "/ws", "branch": "main",
414                    "tools": [{"name": "rg", "state": "present"}], "notes": ["n"],
415                    "kind": "k", "reason": "r",
416                }),
417            );
418            let rendered = render_template(text, &context).expect("the text renders");
419            assert!(!rendered.trim().is_empty());
420        }
421    }
422
423    /// The host report carries its facts — date, model, harness, the OS
424    /// line, one presence line per manifest entry, and the branch on a
425    /// checkout — with no versions anywhere. Runs real bash, like the
426    /// claw's environment tests.
427    #[test]
428    fn the_host_report_carries_facts_and_presence_without_versions() {
429        let manifest = manifest();
430        let report = host_report(Path::new("."), "glm-5.3", "rhema test");
431        assert!(report.starts_with("## Environment\n"), "{report}");
432        for fact in [
433            "- Date: ",
434            "- Model: glm-5.3",
435            "- Harness: rhema test",
436            "### Machine",
437            "- OS: ",
438            "### Workspace",
439            "- CWD: ",
440            "### Command-line tools",
441        ] {
442            assert!(report.contains(fact), "missing {fact}: {report}");
443        }
444        for entry in &manifest.probe {
445            assert!(
446                report.contains(&format!("- {}: ", entry.executable)),
447                "no line for {}: {report}",
448                entry.executable
449            );
450        }
451        assert!(report.contains("- git branch: "), "{report}");
452        assert!(
453            !report.contains("version"),
454            "presence, not versions: {report}"
455        );
456    }
457
458    /// The OS line maps the friendly name beside the raw spelling and
459    /// keeps unmapped systems raw.
460    #[test]
461    fn the_os_line_maps_friendly_names_beside_the_raw() {
462        let manifest = manifest();
463        assert_eq!(os_label(manifest, "Darwin arm64"), "macOS (Darwin arm64)");
464        assert_eq!(os_label(manifest, "SunOS sun4u"), "SunOS sun4u");
465    }
466
467    /// Advice joins the notes only when its executable answered — the
468    /// ripgrep note is data like any other entry's.
469    #[test]
470    fn advice_follows_presence_not_the_tool_name() {
471        let manifest = manifest();
472        let facts = facts("m", "h");
473        let with = render_report(
474            manifest,
475            Path::new("/ws"),
476            &facts,
477            "Darwin arm64\nshell: /bin/zsh\nrg: present\nfd: absent\ngit branch: main\n",
478        );
479        assert!(with.contains("Notes:"), "{with}");
480        assert!(with.contains("Use `rg`"), "{with}");
481        let without = render_report(
482            manifest,
483            Path::new("/ws"),
484            &facts,
485            "Darwin arm64\nshell: /bin/zsh\nrg: absent\nfd: absent\n",
486        );
487        assert!(!without.contains("Notes:"), "{without}");
488        assert!(without.contains("- rg: absent"), "{without}");
489    }
490
491    /// The notice names the kind, the model, and the harness, and
492    /// claims no host facts.
493    #[test]
494    fn the_notice_names_the_kind_workspace_and_no_host_facts() {
495        let notice = remote_notice("docker", "/sandbox/ws", "glm-5.3", "rhema test");
496        assert!(notice.contains("docker sandbox"), "{notice}");
497        assert!(notice.contains("- Workspace: /sandbox/ws"), "{notice}");
498        assert!(notice.contains("- Model: glm-5.3"), "{notice}");
499        assert!(notice.contains("- Harness: rhema test"), "{notice}");
500        assert!(!notice.contains("- OS: "), "{notice}");
501    }
502
503    /// A manifest entry the shell-safety filter refuses never reaches
504    /// the script: quoting in a probe is not executable data.
505    #[test]
506    fn unsafe_executables_never_reach_the_script() {
507        let mut manifest = manifest().clone();
508        manifest.probe.push(Probe {
509            executable: "x; rm -rf /".into(),
510            advice: None,
511        });
512        let script = probe_script(&manifest);
513        assert!(!script.contains("rm -rf"), "{script}");
514    }
515
516    /// A probe that cannot run degrades to the unavailable text, facts
517    /// and all.
518    #[test]
519    fn a_missing_workspace_renders_the_unavailable_text() {
520        let report = host_report(Path::new("/definitely/not/here"), "m", "h");
521        assert!(report.contains("did not answer"), "{report}");
522        assert!(report.contains("- Model: m"), "{report}");
523    }
524
525    /// The stamp is the civil UTC calendar: two anchors pin the shape.
526    #[test]
527    fn the_stamp_is_civil_utc() {
528        assert_eq!(civil_from_days(0), (1970, 1, 1));
529        assert_eq!(civil_from_days(19_723), (2024, 1, 1));
530    }
531}