Skip to main content

rhema_coding_agent/
agent.rs

1//! The coding-agent driver: a provider, the built-in workspace tools, and
2//! the per-turn loop over `rhema_core::run_turn`.
3//!
4//! The agent owns one [`ShellTools`] resolver for its lifetime. Approval
5//! arrives per turn; it is installed into the resolver before stepping and
6//! cleared after (see [`rhema_agent_tools::approval`] for why the hook travels
7//! as an `Arc`). The session is plain data — the driver may mutate it between
8//! turns, checkpoint it, or resume it from disk. A turn entering with
9//! committed-but-unanswered calls (a crash resume) revives first:
10//! replay-safe tools re-run, the rest are reported interrupted — see
11//! [`revive_dangling_calls`].
12
13use std::io;
14use std::path::Path;
15use std::sync::Arc;
16use std::sync::atomic::AtomicBool;
17
18use rhema_agent_tools::{Approval, ExpandPastes, ShellTools, revive_dangling_calls};
19use rhema_core::{
20    Agent, AgentError, ChatParams, CommitHook, EventSink, Message, ModelOutput, Role, Session,
21    ToolContext, run_turn,
22};
23
24use crate::config::CodingAgentConfig;
25
26/// Why a turn or a session operation failed.
27#[derive(Debug, PartialEq, Eq, thiserror::Error)]
28pub enum CodingAgentError {
29    /// The engine or the provider failed, including a turn that exceeded
30    /// the step budget (`AgentError::MaxIterations`).
31    #[error(transparent)]
32    Agent(#[from] AgentError),
33    /// A path resolved outside the workspace root. Inside a turn this
34    /// surfaces as an in-band `is_error` tool result instead (SPEC.md §4);
35    /// the variant exists for callers validating paths outside the tool
36    /// loop.
37    #[error("workspace escape: {0}")]
38    WorkspaceEscape(std::path::PathBuf),
39    /// The system-prompt template failed to render — bad syntax, unknown
40    /// filter — with the template position when known. The agent refuses
41    /// to start on a prompt it cannot fully render.
42    #[error("system prompt template: {0}")]
43    Template(String),
44}
45
46impl From<minijinja::Error> for CodingAgentError {
47    fn from(err: minijinja::Error) -> Self {
48        let line = err
49            .line()
50            .map(|line| format!(", line {line}"))
51            .unwrap_or_default();
52        Self::Template(format!("{err}{line}"))
53    }
54}
55
56/// The system prompt an agent stamps into every request, assembled
57/// outside an agent: `base` — the user's SYSTEM.md — when given, the
58/// built-in default otherwise, rendered over `workspace` as given, the
59/// discovered skills, and the built-in tool specs. The one assembly
60/// point: [`CodingAgent::new`] builds every agent's prompt here, and a
61/// driver that previews the prompt calls the same function, so what is
62/// previewed is what turns send.
63pub fn stamped_system(base: Option<&str>, workspace: &Path) -> Result<String, CodingAgentError> {
64    // The global skills directory may not exist (no HOME) — as normal
65    // as an empty one.
66    let global = rhema_config::Config::skills_dir().ok();
67    let skills = rhema_agent_tools::skills::discover(global.as_deref(), workspace);
68    rhema_agent_tools::render_system(
69        base.unwrap_or(crate::prompt::DEFAULT_SYSTEM_MD),
70        workspace,
71        &skills,
72        &rhema_agent_tools::ShellTools::builtin_specs(),
73    )
74    .map_err(CodingAgentError::from)
75}
76
77/// A coding agent: drives [`run_turn`] over the built-in tools, one turn at
78/// a time, keeping the history in a caller-visible [`Session`].
79pub struct CodingAgent {
80    agent: Agent,
81    config: CodingAgentConfig,
82    session: Session,
83    tools: Arc<ShellTools>,
84    turns: u64,
85}
86
87impl CodingAgent {
88    /// A fresh agent with an empty session. The workspace is canonicalized
89    /// for tool scoping; the system prompt defaults to the built-in
90    /// template rendered by [`crate::prompt::render_system`] unless
91    /// `config.system` replaces it. A base prompt that fails to render
92    /// fails the construction.
93    pub fn new(
94        provider: Arc<dyn rhema_core::ModelProvider>,
95        config: CodingAgentConfig,
96    ) -> Result<Self, CodingAgentError> {
97        // The paste expansion rides the provider seam, not the engine:
98        // the session keeps the compact form, and this one wrapper is
99        // every request path's way out.
100        let provider: Arc<dyn rhema_core::ModelProvider> = Arc::new(ExpandPastes::new(provider));
101        let tools = Arc::new(ShellTools::new(config.workspace.clone()));
102        let system = match config.system.clone() {
103            // An explicit total replacement is the escape hatch:
104            // verbatim, not templated.
105            Some(system) => system,
106            None => stamped_system(config.base_system.as_deref(), tools.workspace())?,
107        };
108        let agent = Agent::builder()
109            .provider(provider)
110            .resolver(tools.clone())
111            .system(system)
112            .params(ChatParams {
113                thinking: config.thinking,
114                ..ChatParams::new(config.model.clone())
115            })
116            .build()?;
117        Ok(Self {
118            agent,
119            config,
120            session: Session::default(),
121            tools,
122            turns: 0,
123        })
124    }
125
126    /// A fresh agent resuming `session` (e.g. from
127    /// [`CodingAgent::load_session_from`]); the next turn continues the
128    /// prior history.
129    pub fn from_session(
130        provider: Arc<dyn rhema_core::ModelProvider>,
131        config: CodingAgentConfig,
132        session: Session,
133    ) -> Result<Self, CodingAgentError> {
134        let mut agent = Self::new(provider, config)?;
135        agent.session = session;
136        Ok(agent)
137    }
138
139    /// Installs the commit-before-execute hook on the engine (see
140    /// [`rhema_core::CommitHook`]); `None` disables it. A driver that
141    /// persists the session sets this so a crash inside a tool call leaves
142    /// a committed intent — which the next turn's revive closes — rather
143    /// than no record at all.
144    pub fn set_commit_hook(&mut self, hook: Option<CommitHook>) {
145        self.agent.set_commit_hook(hook);
146    }
147
148    /// The conversation so far.
149    pub fn session(&self) -> &Session {
150        &self.session
151    }
152
153    /// The rendered system prompt this agent stamps into every request.
154    pub fn system(&self) -> &str {
155        self.agent.system()
156    }
157
158    /// Mutable access between turns; the engine owns the history only
159    /// inside a turn.
160    pub fn session_mut(&mut self) -> &mut Session {
161        &mut self.session
162    }
163
164    /// One turn: push the user input, loop steps until the model stops
165    /// requesting tools (capped at `max_turn_steps`), with `approval`
166    /// gating the side-effecting tools. Appends to the session in place;
167    /// the hook is cleared on the way out, success or failure. A history
168    /// entering with unanswered calls — a crash resume — revives first, so
169    /// the model sees a closed round, not a dangling one.
170    pub async fn turn(
171        &mut self,
172        input: impl Into<String>,
173        sink: &mut dyn EventSink,
174        approval: Arc<dyn Approval>,
175    ) -> Result<ModelOutput, CodingAgentError> {
176        self.turns += 1;
177        let ctx = ToolContext {
178            cwd: self.tools.workspace().to_path_buf(),
179            run_id: format!("turn-{}", self.turns),
180            cancelled: Arc::new(AtomicBool::new(false)),
181        };
182        revive_dangling_calls(&mut self.session, self.tools.as_ref(), &ctx).await;
183        self.tools.install_approval(approval);
184        let result = run_turn(
185            &mut self.agent,
186            &mut self.session,
187            Message::text(Role::User, input),
188            sink,
189            &ctx,
190            self.config.max_turn_steps,
191        )
192        .await;
193        self.tools.clear_approval();
194        Ok(result?)
195    }
196
197    /// Writes the session as pretty JSON to `path`.
198    pub fn save_session_to(&self, path: &impl AsRef<Path>) -> io::Result<()> {
199        let json = serde_json::to_string_pretty(&self.session)
200            .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?;
201        std::fs::write(path, json)
202    }
203
204    /// Reads a session written by [`CodingAgent::save_session_to`].
205    pub fn load_session_from(path: &impl AsRef<Path>) -> io::Result<Session> {
206        let raw = std::fs::read_to_string(path)?;
207        serde_json::from_str(&raw).map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))
208    }
209}
210
211#[cfg(test)]
212mod paste_tests {
213    use super::*;
214    use rhema_agent_tools::AllowAll;
215    use rhema_core::{
216        Content, Event, ModelProvider, ModelRequest, ProviderError, StopReason, Usage, async_trait,
217    };
218    use std::sync::Mutex;
219
220    /// Records the user text each request carried, then ends the turn.
221    struct Recording(Mutex<Vec<String>>);
222
223    #[async_trait]
224    impl ModelProvider for Recording {
225        async fn complete(
226            &self,
227            req: ModelRequest,
228            _sink: &mut dyn EventSink,
229        ) -> Result<ModelOutput, ProviderError> {
230            let mut user = String::new();
231            for message in &req.messages {
232                if message.role != Role::User {
233                    continue;
234                }
235                for content in &message.content {
236                    if let Content::Text(text) = content {
237                        user.push_str(text);
238                    }
239                }
240            }
241            self.0.lock().unwrap().push(user);
242            Ok(ModelOutput {
243                message: Message::text(Role::Assistant, "done"),
244                stop_reason: StopReason::EndTurn,
245                usage: Usage::default(),
246            })
247        }
248    }
249
250    struct NoopSink;
251
252    impl EventSink for NoopSink {
253        fn emit(&mut self, _event: Event<'_>) {}
254    }
255
256    #[tokio::test]
257    async fn the_provider_sees_pastes_expanded_and_the_session_keeps_the_chips() {
258        let payload = "l1\nl2\nl3\nl4";
259        let embedded = rhema_core::paste::embed(
260            &format!("look at {} this", rhema_core::paste::MARK),
261            &[payload.to_owned()],
262        );
263        let recording = Arc::new(Recording(Mutex::new(Vec::new())));
264        let mut agent = CodingAgent::new(
265            recording.clone(),
266            CodingAgentConfig::new("m", std::env::temp_dir()),
267        )
268        .unwrap();
269        agent
270            .turn(&embedded, &mut NoopSink, Arc::new(AllowAll))
271            .await
272            .unwrap();
273
274        // The session records the compact chip form.
275        assert_eq!(
276            agent.session().history[0],
277            Message::text(Role::User, embedded.clone())
278        );
279        // The provider saw the plain expansion, never the encoding.
280        let seen = recording.0.lock().unwrap();
281        assert_eq!(seen.len(), 1);
282        assert_eq!(seen[0], format!("look at {payload} this"));
283    }
284}