1use 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#[derive(Debug, PartialEq, Eq, thiserror::Error)]
28pub enum CodingAgentError {
29 #[error(transparent)]
32 Agent(#[from] AgentError),
33 #[error("workspace escape: {0}")]
38 WorkspaceEscape(std::path::PathBuf),
39 #[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
56pub fn stamped_system(base: Option<&str>, workspace: &Path) -> Result<String, CodingAgentError> {
64 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
77pub struct CodingAgent {
80 agent: Agent,
81 config: CodingAgentConfig,
82 session: Session,
83 tools: Arc<ShellTools>,
84 turns: u64,
85}
86
87impl CodingAgent {
88 pub fn new(
94 provider: Arc<dyn rhema_core::ModelProvider>,
95 config: CodingAgentConfig,
96 ) -> Result<Self, CodingAgentError> {
97 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 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 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 pub fn set_commit_hook(&mut self, hook: Option<CommitHook>) {
145 self.agent.set_commit_hook(hook);
146 }
147
148 pub fn session(&self) -> &Session {
150 &self.session
151 }
152
153 pub fn system(&self) -> &str {
155 self.agent.system()
156 }
157
158 pub fn session_mut(&mut self) -> &mut Session {
161 &mut self.session
162 }
163
164 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 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 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 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 assert_eq!(
276 agent.session().history[0],
277 Message::text(Role::User, embedded.clone())
278 );
279 let seen = recording.0.lock().unwrap();
281 assert_eq!(seen.len(), 1);
282 assert_eq!(seen[0], format!("look at {payload} this"));
283 }
284}