1use std::path::{Path, PathBuf};
23use std::sync::Arc;
24
25use rpi_ai::Provider;
26use rpi_harness::agent_harness::AgentHarness;
27use rpi_harness::session::memory::{InMemorySessionStorage, SystemClock};
28use rpi_harness::session::session::DefaultIdGenerator;
29use rpi_harness::session::types::SessionMetadata;
30use rpi_harness::session::Session;
31use rpi_harness::types::{
32 AgentHarnessOptions, AgentHarnessResources, CompactionSettings, DrivingMode,
33 HarnessToolExecution, HarnessTool, RetryPolicy, ToolReplay,
34};
35use rpi_tools::{
36 create_bash_tool, create_edit_tool, create_find_tool, create_grep_tool, create_ls_tool,
37 create_read_tool, create_write_tool, ExecutionToolContext, MutationQueueRegistry,
38 OsExecutionEnv,
39};
40
41use crate::args::Args;
42use crate::provider::ResolvedModel;
43
44pub const BUILTIN_TOOL_NAMES: &[&str] = &["read", "bash", "edit", "write", "grep", "find", "ls"];
48
49pub fn default_system_prompt(cwd: &str) -> String {
54 format!(
55 "You are an expert coding assistant operating inside pi, a coding agent harness. \
56You help users by reading files, executing commands, editing code, and writing new files.
57
58Available tools:
59- read — Read file contents
60- bash — Execute shell commands
61- edit — Find/replace edits to existing files
62- write — Create or overwrite files
63- grep — Search file contents for a pattern
64- find — Search for files by glob pattern
65- ls — List directory contents
66
67Guidelines:
68- Be concise in your responses
69- Show file paths clearly when working with files
70- Prefer the smallest change that solves the problem
71
72Current working directory: {cwd}"
73 )
74}
75
76#[derive(Debug, Clone)]
81pub enum SessionSelection {
82 Ephemeral,
84 New { dir: PathBuf, name: Option<String> },
86 Existing { requested: String },
89}
90
91pub fn select_session(args: &Args, cwd: &Path) -> SessionSelection {
93 if args.no_session {
94 return SessionSelection::Ephemeral;
95 }
96 if args.continue_session {
97 return SessionSelection::Existing { requested: "--continue".into() };
98 }
99 if args.resume {
100 return SessionSelection::Existing { requested: "--resume".into() };
101 }
102 if let Some(s) = &args.session {
103 return SessionSelection::Existing { requested: s.clone() };
104 }
105 let dir = args
106 .session_dir
107 .clone()
108 .unwrap_or_else(|| default_session_dir(cwd));
109 SessionSelection::New { dir, name: args.name.clone() }
110}
111
112pub fn default_session_dir(cwd: &Path) -> PathBuf {
116 cwd.join(".pi").join("sessions")
117}
118
119pub async fn build(
128 resolved: &ResolvedModel,
129 args: &Args,
130 cwd: &Path,
131) -> Result<AgentHarness, BuildError> {
132 let cwd_str = cwd.to_string_lossy().to_string();
133
134 let env = Arc::new(OsExecutionEnv::with_cwd(cwd.to_path_buf()));
136 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
137 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
138 let _registry = Arc::new(MutationQueueRegistry::new());
139 let ctx = ExecutionToolContext::new(env_dyn, Some(mut_env));
140
141 let tools = build_tools(&ctx, args);
142 let active = active_tool_names(&tools, args);
143
144 let selection = select_session(args, cwd);
146 let session = build_session(&selection, &cwd_str).await?;
147
148 let base_prompt = args
150 .system_prompt
151 .clone()
152 .unwrap_or_else(|| default_system_prompt(&cwd_str));
153 let system_prompt = if args.append_system_prompt.is_empty() {
154 base_prompt
155 } else {
156 let mut out = base_prompt;
160 for extra in &args.append_system_prompt {
161 let text = read_append_target(extra).unwrap_or_else(|| extra.clone());
162 out.push_str("\n\n");
163 out.push_str(&text);
164 }
165 out
166 };
167
168 let options = AgentHarnessOptions {
170 model: resolved.model.clone(),
171 thinking_level: resolved.thinking_level,
172 active_tool_names: active,
173 tools,
174 system_prompt: Some(system_prompt),
175 resources: AgentHarnessResources::empty(),
176 stream_options: Default::default(),
177 retry: RetryPolicy::default(),
178 compaction: CompactionSettings::default(),
179 steering_mode: Default::default(),
180 follow_up_mode: Default::default(),
181 tool_execution: HarnessToolExecution::default(),
182 drive: DrivingMode::default(),
183 session,
184 models: vec![resolved.provider.clone() as Arc<dyn Provider>],
185 to_provider_messages: None,
186 entry_projectors: Default::default(),
187 };
188
189 AgentHarness::create(options)
190 .await
191 .map_err(|e| BuildError::HarnessCreate(e.to_string()))
192}
193
194#[derive(Debug, thiserror::Error)]
196pub enum BuildError {
197 #[error("Could not create the session directory: {0}")]
198 SessionDir(String),
199 #[error("Session restore is not implemented in v1 (requested: {requested}). Start a fresh session instead (drop {flag}).")]
200 RestoreNotImplemented { requested: String, flag: &'static str },
201 #[error("Could not build the harness: {0}")]
202 HarnessCreate(String),
203}
204
205fn build_tools(ctx: &ExecutionToolContext, args: &Args) -> Vec<HarnessTool> {
209 if args.no_tools {
210 return Vec::new();
211 }
212 let mut all: Vec<(&'static str, HarnessTool)> = vec![
216 ("read", HarnessTool::new(create_read_tool(ctx, None))),
217 ("bash", HarnessTool::new(create_bash_tool(ctx, None))),
218 ("edit", HarnessTool::new(create_edit_tool(ctx))),
219 ("write", HarnessTool::new(create_write_tool(ctx))),
220 ("grep", HarnessTool::new(create_grep_tool(ctx, None))),
221 ("find", HarnessTool::new(create_find_tool(ctx, None))),
222 ("ls", HarnessTool::new(create_ls_tool(ctx, None))),
223 ];
224
225 if args.no_builtin_tools {
229 all.clear();
230 }
231
232 if let Some(allow) = &args.tools {
234 all.retain(|(name, _)| allow.iter().any(|a| a == name));
235 }
236 if let Some(deny) = &args.exclude_tools {
238 all.retain(|(name, _)| !deny.iter().any(|d| d == name));
239 }
240
241 all.into_iter().map(|(_, t)| t.with_replay(ToolReplay::Safe)).collect()
242}
243
244fn active_tool_names(tools: &[HarnessTool], args: &Args) -> Vec<String> {
248 if args.no_tools {
249 return Vec::new();
250 }
251 if let Some(allow) = &args.tools {
252 let names: Vec<String> = tools.iter().map(|t| t.tool.schema().name.clone()).collect();
255 return allow.iter().filter(|a| names.iter().any(|n| n == *a)).cloned().collect();
256 }
257 tools.iter().map(|t| t.tool.schema().name.clone()).collect()
260}
261
262async fn build_session(selection: &SessionSelection, cwd: &str) -> Result<Session, BuildError> {
264 match selection {
265 SessionSelection::Ephemeral => Ok(ephemeral_session()),
266 SessionSelection::New { dir, .. } => {
267 std::fs::create_dir_all(dir)
270 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
271 let session = create_jsonl_session(dir, cwd)
272 .await
273 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
274 Ok(session)
275 }
276 SessionSelection::Existing { requested } => {
277 let flag = match requested.as_str() {
279 "--continue" => "--continue",
280 "--resume" => "--resume",
281 _ => "--session",
282 };
283 Err(BuildError::RestoreNotImplemented {
284 requested: requested.clone(),
285 flag,
286 })
287 }
288 }
289}
290
291fn ephemeral_session() -> Session {
293 let storage = Arc::new(InMemorySessionStorage::new(
294 SessionMetadata {
295 id: "ephemeral".into(),
296 created_at: 0,
297 parent_session_id: None,
298 },
299 Arc::new(SystemClock),
300 Arc::new(DefaultIdGenerator::new()),
301 ));
302 Session::new(storage, None)
303}
304
305async fn create_jsonl_session(dir: &Path, cwd: &str) -> Result<Session, String> {
311 use rpi_harness::session::jsonl::{
312 JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
313 };
314 use rpi_tools::FileSystem;
315
316 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
319 let fs: Arc<dyn FileSystem> = env.clone();
320
321 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
322 fs: fs.clone(),
323 sessions_root: dir.to_string_lossy().into_owned(),
324 clock: Arc::new(SystemClock),
325 ids: Arc::new(DefaultIdGenerator::new()),
326 });
327
328 let opts = JsonlSessionCreateOptions {
329 id: None, parent_session_id: None,
331 cwd: cwd.to_string(),
332 metadata: None,
333 };
334 let storage = repo
335 .create_typed(&opts)
336 .await
337 .map_err(|e| format!("create session: {e}"))?;
338 let storage_arc: Arc<dyn rpi_harness::session::types::SessionStorage> = Arc::new(storage);
340 Ok(Session::new(storage_arc, None))
341}
342
343fn read_append_target(target: &str) -> Option<String> {
346 let path = Path::new(target);
347 if path.is_file() {
348 std::fs::read_to_string(path).ok()
349 } else {
350 None
351 }
352}
353
354#[cfg(test)]
355mod tests {
356 use super::*;
357 use crate::args::Args;
358
359 #[test]
360 fn default_prompt_mentions_cwd_and_tools() {
361 let p = default_system_prompt("/tmp/proj");
362 assert!(p.contains("/tmp/proj"));
363 assert!(p.contains("read"));
364 assert!(p.contains("bash"));
365 assert!(p.contains("edit"));
366 assert!(p.contains("write"));
367 assert!(p.contains("grep"));
368 assert!(p.contains("find"));
369 assert!(p.contains("ls"));
370 }
371
372 #[test]
373 fn select_ephemeral_when_no_session() {
374 let args = Args { no_session: true, ..Args::default() };
375 let cwd = Path::new("/tmp");
376 assert!(matches!(select_session(&args, cwd), SessionSelection::Ephemeral));
377 }
378
379 #[test]
380 fn select_existing_for_continue() {
381 let args = Args { continue_session: true, ..Args::default() };
382 let cwd = Path::new("/tmp");
383 assert!(matches!(
384 select_session(&args, cwd),
385 SessionSelection::Existing { .. }
386 ));
387 }
388
389 #[test]
390 fn select_new_with_custom_dir() {
391 let args = Args {
392 session_dir: Some(PathBuf::from("/tmp/sess")),
393 ..Args::default()
394 };
395 let cwd = Path::new("/tmp");
396 match select_session(&args, cwd) {
397 SessionSelection::New { dir, .. } => assert_eq!(dir, PathBuf::from("/tmp/sess")),
398 other => panic!("expected New, got {other:?}"),
399 }
400 }
401
402 #[test]
403 fn select_new_default_dir() {
404 let args = Args::default();
405 let cwd = Path::new("/proj");
406 match select_session(&args, cwd) {
407 SessionSelection::New { dir, .. } => {
408 assert_eq!(dir, Path::new("/proj/.pi/sessions"));
409 }
410 other => panic!("expected New, got {other:?}"),
411 }
412 }
413
414 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
415 async fn ephemeral_session_builds_roundtrips() {
416 let s = ephemeral_session();
420 let leaf = s.get_leaf_id().await;
421 assert!(leaf.is_ok());
422 }
423
424 }