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(
133 resolved: &ResolvedModel,
134 args: &Args,
135 cwd: &Path,
136) -> Result<
137 (
138 AgentHarness,
139 tokio::sync::broadcast::Receiver<rpi_agent::AgentEvent>,
140 ),
141 BuildError,
142> {
143 let cwd_str = cwd.to_string_lossy().to_string();
144
145 let env = Arc::new(OsExecutionEnv::with_cwd(cwd.to_path_buf()));
147 let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
148 let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
149 let _registry = Arc::new(MutationQueueRegistry::new());
150 let ctx = ExecutionToolContext::new(env_dyn, Some(mut_env));
151
152 let tools = build_tools(&ctx, args);
153 let active = active_tool_names(&tools, args);
154
155 let selection = select_session(args, cwd);
157 let session = build_session(&selection, &cwd_str).await?;
158
159 let base_prompt = args
161 .system_prompt
162 .clone()
163 .unwrap_or_else(|| default_system_prompt(&cwd_str));
164 let system_prompt = if args.append_system_prompt.is_empty() {
165 base_prompt
166 } else {
167 let mut out = base_prompt;
171 for extra in &args.append_system_prompt {
172 let text = read_append_target(extra).unwrap_or_else(|| extra.clone());
173 out.push_str("\n\n");
174 out.push_str(&text);
175 }
176 out
177 };
178
179 let (emitter, event_rx) = rpi_agent::events::BroadcastEmitter::new(256);
184 let emitter: Arc<dyn rpi_agent::AgentEmitter> = Arc::new(emitter);
185
186 let options = AgentHarnessOptions {
187 model: resolved.model.clone(),
188 thinking_level: resolved.thinking_level,
189 active_tool_names: active,
190 tools,
191 system_prompt: Some(system_prompt),
192 resources: AgentHarnessResources::empty(),
193 stream_options: Default::default(),
194 retry: RetryPolicy::default(),
195 compaction: CompactionSettings::default(),
196 steering_mode: Default::default(),
197 follow_up_mode: Default::default(),
198 tool_execution: HarnessToolExecution::default(),
199 drive: DrivingMode::default(),
200 session,
201 models: vec![resolved.provider.clone() as Arc<dyn Provider>],
202 to_provider_messages: None,
203 entry_projectors: Default::default(),
204 agent_emitter: Some(emitter),
205 };
206
207 AgentHarness::create(options)
208 .await
209 .map(|harness| (harness, event_rx))
210 .map_err(|e| BuildError::HarnessCreate(e.to_string()))
211}
212
213#[derive(Debug, thiserror::Error)]
215pub enum BuildError {
216 #[error("Could not create the session directory: {0}")]
217 SessionDir(String),
218 #[error("Session restore is not implemented in v1 (requested: {requested}). Start a fresh session instead (drop {flag}).")]
219 RestoreNotImplemented { requested: String, flag: &'static str },
220 #[error("Could not build the harness: {0}")]
221 HarnessCreate(String),
222}
223
224fn build_tools(ctx: &ExecutionToolContext, args: &Args) -> Vec<HarnessTool> {
228 if args.no_tools {
229 return Vec::new();
230 }
231 let mut all: Vec<(&'static str, HarnessTool)> = vec![
235 ("read", HarnessTool::new(create_read_tool(ctx, None))),
236 ("bash", HarnessTool::new(create_bash_tool(ctx, None))),
237 ("edit", HarnessTool::new(create_edit_tool(ctx))),
238 ("write", HarnessTool::new(create_write_tool(ctx))),
239 ("grep", HarnessTool::new(create_grep_tool(ctx, None))),
240 ("find", HarnessTool::new(create_find_tool(ctx, None))),
241 ("ls", HarnessTool::new(create_ls_tool(ctx, None))),
242 ];
243
244 if args.no_builtin_tools {
248 all.clear();
249 }
250
251 if let Some(allow) = &args.tools {
253 all.retain(|(name, _)| allow.iter().any(|a| a == name));
254 }
255 if let Some(deny) = &args.exclude_tools {
257 all.retain(|(name, _)| !deny.iter().any(|d| d == name));
258 }
259
260 all.into_iter().map(|(_, t)| t.with_replay(ToolReplay::Safe)).collect()
261}
262
263fn active_tool_names(tools: &[HarnessTool], args: &Args) -> Vec<String> {
267 if args.no_tools {
268 return Vec::new();
269 }
270 if let Some(allow) = &args.tools {
271 let names: Vec<String> = tools.iter().map(|t| t.tool.schema().name.clone()).collect();
274 return allow.iter().filter(|a| names.iter().any(|n| n == *a)).cloned().collect();
275 }
276 tools.iter().map(|t| t.tool.schema().name.clone()).collect()
279}
280
281async fn build_session(selection: &SessionSelection, cwd: &str) -> Result<Session, BuildError> {
283 match selection {
284 SessionSelection::Ephemeral => Ok(ephemeral_session()),
285 SessionSelection::New { dir, .. } => {
286 std::fs::create_dir_all(dir)
289 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
290 let session = create_jsonl_session(dir, cwd)
291 .await
292 .map_err(|e| BuildError::SessionDir(format!("{}: {e}", dir.display())))?;
293 Ok(session)
294 }
295 SessionSelection::Existing { requested } => {
296 let flag = match requested.as_str() {
298 "--continue" => "--continue",
299 "--resume" => "--resume",
300 _ => "--session",
301 };
302 Err(BuildError::RestoreNotImplemented {
303 requested: requested.clone(),
304 flag,
305 })
306 }
307 }
308}
309
310fn ephemeral_session() -> Session {
312 let storage = Arc::new(InMemorySessionStorage::new(
313 SessionMetadata {
314 id: "ephemeral".into(),
315 created_at: 0,
316 parent_session_id: None,
317 },
318 Arc::new(SystemClock),
319 Arc::new(DefaultIdGenerator::new()),
320 ));
321 Session::new(storage, None)
322}
323
324async fn create_jsonl_session(dir: &Path, cwd: &str) -> Result<Session, String> {
330 use rpi_harness::session::jsonl::{
331 JsonlSessionCreateOptions, JsonlSessionRepo, JsonlSessionRepoOptions,
332 };
333 use rpi_tools::FileSystem;
334
335 let env = Arc::new(OsExecutionEnv::with_cwd(PathBuf::from(cwd)));
338 let fs: Arc<dyn FileSystem> = env.clone();
339
340 let repo = JsonlSessionRepo::with_env_cwd(JsonlSessionRepoOptions {
341 fs: fs.clone(),
342 sessions_root: dir.to_string_lossy().into_owned(),
343 clock: Arc::new(SystemClock),
344 ids: Arc::new(DefaultIdGenerator::new()),
345 });
346
347 let opts = JsonlSessionCreateOptions {
348 id: None, parent_session_id: None,
350 cwd: cwd.to_string(),
351 metadata: None,
352 };
353 let storage = repo
354 .create_typed(&opts)
355 .await
356 .map_err(|e| format!("create session: {e}"))?;
357 let storage_arc: Arc<dyn rpi_harness::session::types::SessionStorage> = Arc::new(storage);
359 Ok(Session::new(storage_arc, None))
360}
361
362fn read_append_target(target: &str) -> Option<String> {
365 let path = Path::new(target);
366 if path.is_file() {
367 std::fs::read_to_string(path).ok()
368 } else {
369 None
370 }
371}
372
373#[cfg(test)]
374mod tests {
375 use super::*;
376 use crate::args::Args;
377
378 #[test]
379 fn default_prompt_mentions_cwd_and_tools() {
380 let p = default_system_prompt("/tmp/proj");
381 assert!(p.contains("/tmp/proj"));
382 assert!(p.contains("read"));
383 assert!(p.contains("bash"));
384 assert!(p.contains("edit"));
385 assert!(p.contains("write"));
386 assert!(p.contains("grep"));
387 assert!(p.contains("find"));
388 assert!(p.contains("ls"));
389 }
390
391 #[test]
392 fn select_ephemeral_when_no_session() {
393 let args = Args { no_session: true, ..Args::default() };
394 let cwd = Path::new("/tmp");
395 assert!(matches!(select_session(&args, cwd), SessionSelection::Ephemeral));
396 }
397
398 #[test]
399 fn select_existing_for_continue() {
400 let args = Args { continue_session: true, ..Args::default() };
401 let cwd = Path::new("/tmp");
402 assert!(matches!(
403 select_session(&args, cwd),
404 SessionSelection::Existing { .. }
405 ));
406 }
407
408 #[test]
409 fn select_new_with_custom_dir() {
410 let args = Args {
411 session_dir: Some(PathBuf::from("/tmp/sess")),
412 ..Args::default()
413 };
414 let cwd = Path::new("/tmp");
415 match select_session(&args, cwd) {
416 SessionSelection::New { dir, .. } => assert_eq!(dir, PathBuf::from("/tmp/sess")),
417 other => panic!("expected New, got {other:?}"),
418 }
419 }
420
421 #[test]
422 fn select_new_default_dir() {
423 let args = Args::default();
424 let cwd = Path::new("/proj");
425 match select_session(&args, cwd) {
426 SessionSelection::New { dir, .. } => {
427 assert_eq!(dir, Path::new("/proj/.pi/sessions"));
428 }
429 other => panic!("expected New, got {other:?}"),
430 }
431 }
432
433 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
434 async fn ephemeral_session_builds_roundtrips() {
435 let s = ephemeral_session();
439 let leaf = s.get_leaf_id().await;
440 assert!(leaf.is_ok());
441 }
442
443 }