Skip to main content

rpi_cli/
session.rs

1//! Harness construction + session-storage wiring. Mirrors the Rust-side
2//! equivalent of the TS `packages/coding-agent/src/core/sdk.ts`
3//! (`createAgentSession`) — build the env, tools, durable session storage, and
4//! `AgentHarnessOptions`, then `AgentHarness::create`.
5//!
6//! v1 scope cuts vs the TS SDK (tracked in `docs/m6-cli-open-questions.md`):
7//! - **No extension / skill / prompt-template / theme / context-file discovery.**
8//!   The harness `resources` stay empty; the system prompt is either the
9//!   caller's `--system-prompt` or the built-in default ([`default_system_prompt`]).
10//! - **No `--models` cycling, no `ModelRuntime`/multi-provider.** One model,
11//!   one provider (Anthropic), resolved up-front by [`crate::provider`].
12//! - **Built-in tools**: `read`, `bash`, `edit`, `write` plus the read-only
13//!   `grep`/`find`/`ls` (the TS `createCodingTools` default set). `grep`/`find`
14//!   use an in-process `FileSystem`+`regex`/`globset` implementation (documented
15//!   divergence from the TS `rg`/`fd` shell-out; see `docs/m4-tools-open-questions.md`).
16//! - **Session restore (`-c`/`-r`/`--session`)** is *partially* supported: a
17//!   fresh session is always created. The harness's `create` rejects sessions
18//!   that already have records (restore not implemented — M5f divergence #3),
19//!   so `-c`/`-r`/`--session` currently surface a clear "not implemented"
20//!   message rather than silently starting fresh. See [`SessionSelection`].
21
22use 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
44/// The built-in tool names v1 ships, in the order the TS `createCodingTools`
45/// registers them: the mutating set (`read`/`bash`/`edit`/`write`) followed by
46/// the read-only search set (`grep`/`find`/`ls`).
47pub const BUILTIN_TOOL_NAMES: &[&str] = &["read", "bash", "edit", "write", "grep", "find", "ls"];
48
49/// The default coding system prompt. A condensed port of the TS
50/// `packages/coding-agent/src/core/system-prompt.ts` base prompt — the
51/// pi-internal docs/skills/context-file sections are omitted (v1 has none of
52/// that machinery), leaving the role + tools + guidelines core.
53pub 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/// How the user asked to select a session. v1 only honors `NoSession`
77/// (ephemeral `InMemorySessionStorage`) and `New` (a fresh JSONL file). The
78/// continue/resume/specific-session paths are recognized but not wired (the
79/// harness rejects restore — see module docs).
80#[derive(Debug, Clone)]
81pub enum SessionSelection {
82    /// `--no-session`: ephemeral, in-memory, nothing persisted.
83    Ephemeral,
84    /// Fresh durable JSONL session under `--session-dir` (or the default dir).
85    New { dir: PathBuf, name: Option<String> },
86    /// `-c` / `-r` / `--session <id|path>`: requested an existing session.
87    /// v1 can't restore it, so [`build`] surfaces an error.
88    Existing { requested: String },
89}
90
91/// Decide the session selection from parsed args + the resolved cwd.
92pub 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
112/// The default session directory: `<cwd>/.pi/sessions`. Mirrors the TS
113/// `getDefaultSessionDir` (`.pi/agent/sessions` in TS; v1 uses `.pi/sessions`
114/// under the project — a documented divergence).
115pub fn default_session_dir(cwd: &Path) -> PathBuf {
116    cwd.join(".pi").join("sessions")
117}
118
119/// Build the `AgentHarness` from the resolved model + parsed args + cwd.
120///
121/// This is the v1 equivalent of TS `createAgentSession`. It:
122/// 1. Builds the `OsExecutionEnv` rooted at `cwd`.
123/// 2. Constructs the built-in tools (optionally filtered by `--tools`/
124///    `--exclude-tools`/`--no-tools`/`--no-builtin-tools`).
125/// 3. Resolves the session storage (ephemeral vs fresh JSONL vs restore-error).
126/// 4. Assembles `AgentHarnessOptions` and calls `AgentHarness::create`.
127///
128/// Returns the harness plus a `broadcast::Receiver<AgentEvent>` carrying the
129/// live `AgentEvent` stream from every run (backed by a `BroadcastEmitter`
130/// installed on the harness). Interactive mode drains this to render streaming
131/// responses; the non-interactive modes simply drop it.
132pub 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    // ---- Execution env + tools ----
146    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    // ---- Session storage ----
156    let selection = select_session(args, cwd);
157    let session = build_session(&selection, &cwd_str).await?;
158
159    // ---- System prompt ----
160    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        // Append each `--append-system-prompt` (text or, if it's a readable
168        // file path, the file contents — mirrors the TS behavior where the
169        // flag accepts either).
170        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    // ---- Options ----
180    // Install a BroadcastEmitter so the caller (the interactive TUI) can drain
181    // AgentEvents live as a run unfolds. The corresponding broadcast::Receiver
182    // is returned alongside the harness; non-interactive modes simply drop it.
183    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/// A harness-build error.
214#[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
224/// Build the tool list per `--tools`/`--exclude-tools`/`--no-tools`/
225/// `--no-builtin-tools`. Mirrors the TS `tools`/`excludeTools`/`noTools`
226/// resolution in `createAgentSession`.
227fn build_tools(ctx: &ExecutionToolContext, args: &Args) -> Vec<HarnessTool> {
228    if args.no_tools {
229        return Vec::new();
230    }
231    // Construct every built-in once (cheap; the allowlist filters below).
232    // Read-only search tools (grep/find/ls) take the same context and need no
233    // mutation queue — they go through the `FileSystem` trait only.
234    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    // `--no-builtin-tools` disables the built-in set but would keep
245    // extension/custom tools — v1 has none, so it's equivalent to `--no-tools`
246    // here. We honor it by clearing the built-ins.
247    if args.no_builtin_tools {
248        all.clear();
249    }
250
251    // Allowlist (`--tools`): keep only named built-ins.
252    if let Some(allow) = &args.tools {
253        all.retain(|(name, _)| allow.iter().any(|a| a == name));
254    }
255    // Denylist (`--exclude-tools`): drop named tools.
256    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
263/// Resolve the active tool names from the constructed tools when no explicit
264/// `--tools` allowlist was given. Mirrors the TS default: all registered tools
265/// active.
266fn 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        // The allowlist IS the active set (TS: `tools` doubles as the active
272        // set when provided). Keep order + only those that exist.
273        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    // Default: every constructed tool is active. If `--exclude-tools` dropped
277    // some, they're simply absent from `tools`, so this lands right.
278    tools.iter().map(|t| t.tool.schema().name.clone()).collect()
279}
280
281/// Build the `Session` facade for the chosen selection.
282async fn build_session(selection: &SessionSelection, cwd: &str) -> Result<Session, BuildError> {
283    match selection {
284        SessionSelection::Ephemeral => Ok(ephemeral_session()),
285        SessionSelection::New { dir, .. } => {
286            // Ensure the sessions directory exists, then create a fresh JSONL
287            // session file inside it.
288            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            // Map the request to the flag that produced it for a helpful message.
297            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
310/// A fresh ephemeral in-memory session (no persistence). Used for `--no-session`.
311fn 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
324/// Create a fresh JSONL session file under `dir` and wrap it in a `Session`.
325///
326/// Uses the `JsonlSessionRepo` over an `OsExecutionEnv`-backed `FileSystem`
327/// rooted at the cwd, so paths resolve consistently with the tools. Mirrors the
328/// TS `SessionManager.create` flow (header write + `JsonlSessionStorage` open).
329async 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    // A dedicated OS env for session-file I/O, rooted at the cwd so the repo's
336    // relative-path resolution matches the tool env.
337    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, // fresh uuidv7
349        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    // `JsonlSessionStorage` implements `SessionStorage`; wrap in the facade.
358    let storage_arc: Arc<dyn rpi_harness::session::types::SessionStorage> = Arc::new(storage);
359    Ok(Session::new(storage_arc, None))
360}
361
362/// Read an `--append-system-prompt` target: if it's a readable file path, return
363/// its contents; otherwise return `None` and let the caller use the literal.
364fn 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        // Sanity: the ephemeral path produces a usable Session facade (the
436        // harness build itself needs a provider; tested via the integration
437        // path in tests/build.rs instead).
438        let s = ephemeral_session();
439        let leaf = s.get_leaf_id().await;
440        assert!(leaf.is_ok());
441    }
442
443    // NOTE: `build_tools`/`active_tool_names` integration is exercised by the
444    // `tests/build.rs` harness-build test (needs a provider + multi-thread rt).
445}