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`.
127pub 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    // ---- Execution env + tools ----
135    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    // ---- Session storage ----
145    let selection = select_session(args, cwd);
146    let session = build_session(&selection, &cwd_str).await?;
147
148    // ---- System prompt ----
149    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        // Append each `--append-system-prompt` (text or, if it's a readable
157        // file path, the file contents — mirrors the TS behavior where the
158        // flag accepts either).
159        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    // ---- Options ----
169    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/// A harness-build error.
195#[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
205/// Build the tool list per `--tools`/`--exclude-tools`/`--no-tools`/
206/// `--no-builtin-tools`. Mirrors the TS `tools`/`excludeTools`/`noTools`
207/// resolution in `createAgentSession`.
208fn build_tools(ctx: &ExecutionToolContext, args: &Args) -> Vec<HarnessTool> {
209    if args.no_tools {
210        return Vec::new();
211    }
212    // Construct every built-in once (cheap; the allowlist filters below).
213    // Read-only search tools (grep/find/ls) take the same context and need no
214    // mutation queue — they go through the `FileSystem` trait only.
215    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    // `--no-builtin-tools` disables the built-in set but would keep
226    // extension/custom tools — v1 has none, so it's equivalent to `--no-tools`
227    // here. We honor it by clearing the built-ins.
228    if args.no_builtin_tools {
229        all.clear();
230    }
231
232    // Allowlist (`--tools`): keep only named built-ins.
233    if let Some(allow) = &args.tools {
234        all.retain(|(name, _)| allow.iter().any(|a| a == name));
235    }
236    // Denylist (`--exclude-tools`): drop named tools.
237    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
244/// Resolve the active tool names from the constructed tools when no explicit
245/// `--tools` allowlist was given. Mirrors the TS default: all registered tools
246/// active.
247fn 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        // The allowlist IS the active set (TS: `tools` doubles as the active
253        // set when provided). Keep order + only those that exist.
254        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    // Default: every constructed tool is active. If `--exclude-tools` dropped
258    // some, they're simply absent from `tools`, so this lands right.
259    tools.iter().map(|t| t.tool.schema().name.clone()).collect()
260}
261
262/// Build the `Session` facade for the chosen selection.
263async fn build_session(selection: &SessionSelection, cwd: &str) -> Result<Session, BuildError> {
264    match selection {
265        SessionSelection::Ephemeral => Ok(ephemeral_session()),
266        SessionSelection::New { dir, .. } => {
267            // Ensure the sessions directory exists, then create a fresh JSONL
268            // session file inside it.
269            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            // Map the request to the flag that produced it for a helpful message.
278            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
291/// A fresh ephemeral in-memory session (no persistence). Used for `--no-session`.
292fn 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
305/// Create a fresh JSONL session file under `dir` and wrap it in a `Session`.
306///
307/// Uses the `JsonlSessionRepo` over an `OsExecutionEnv`-backed `FileSystem`
308/// rooted at the cwd, so paths resolve consistently with the tools. Mirrors the
309/// TS `SessionManager.create` flow (header write + `JsonlSessionStorage` open).
310async 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    // A dedicated OS env for session-file I/O, rooted at the cwd so the repo's
317    // relative-path resolution matches the tool env.
318    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, // fresh uuidv7
330        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    // `JsonlSessionStorage` implements `SessionStorage`; wrap in the facade.
339    let storage_arc: Arc<dyn rpi_harness::session::types::SessionStorage> = Arc::new(storage);
340    Ok(Session::new(storage_arc, None))
341}
342
343/// Read an `--append-system-prompt` target: if it's a readable file path, return
344/// its contents; otherwise return `None` and let the caller use the literal.
345fn 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        // Sanity: the ephemeral path produces a usable Session facade (the
417        // harness build itself needs a provider; tested via the integration
418        // path in tests/build.rs instead).
419        let s = ephemeral_session();
420        let leaf = s.get_leaf_id().await;
421        assert!(leaf.is_ok());
422    }
423
424    // NOTE: `build_tools`/`active_tool_names` integration is exercised by the
425    // `tests/build.rs` harness-build test (needs a provider + multi-thread rt).
426}