Skip to main content

rpi_cli/
extensions_actions.rs

1//! B5a — the `RuntimeActionHost` impl over the harness, lived in `rpi-cli`
2//! (NOT `rpi-harness`) so `rpi-extensions` stays a leaf in the crate DAG. The
3//! trait is defined in `rpi-extensions` (JSON + primitives only); this is the
4//! host side that bridges the 16 [`RuntimeActionId`] actions to the harness.
5//!
6//! 10 ops delegate to `harness.lane("main")` (the `AgentLane` surface backs
7//! `prompt_message`/`prompt_text`/`get_active_tools`/`set_active_tools`/
8//! `set_model`/`get_thinking_level`/`set_thinking_level`/`compact`/
9//! `navigate_tree`). Run-ops serialize via `acquire_run`'s `active_run` guard —
10//! a concurrent plugin invocation surfaces `lane_busy` as the harness error
11//! string, the right behavior (a plugin can't re-enter an active run).
12//!
13//! 6 non-lane ops:
14//! - `append_entry`/`set_session_name` → `harness.session().append_message`/
15//!   `set_name`.
16//! - `get_system_prompt` → `AgentHarness::get_system_prompt` (B5a accessor).
17//! - `new_session`/`fork`/`switch_session` → reuse `crate::session`'s
18//!   create/fork/open helpers + `harness.set_session`.
19//! - `reload` → handled by `ActionBridge.reload` (B5d); the host impl is the
20//!   "not configured" fallback for bridges without a callback.
21//!
22//! ## Construction ordering (the load-bearing wrinkle)
23//!
24//! Extensions load **before** the harness is created (extensions provide the
25//! tools the harness is built with), but the plugin stores the
26//! `ActionBridge`'s `user_data` pointer during `register` and that pointer
27//! must remain valid for the whole session. So the host cannot hold the
28//! `AgentHarness` directly — it holds an [`Arc<OnceLock<Arc<AgentHarness>>>`]
29//! that is **empty at register time** and **filled once** by
30//! [`HarnessActionHost::set_harness`] immediately after `AgentHarness::create`
31//! succeeds. No plugin can call a runtime action before the harness runs, so
32//! the cell is always set before the first `get()`. The `Arc<dyn
33//! RuntimeActionHost>` (and thus the `ActionBridge` pointer) is stable from
34//! construction, satisfying the FFI lifetime requirement.
35//!
36//! The impl needs the model `catalog` for `set_model(id)` (the lane wants a
37//! `Model`, not an id) and the `cwd` for session create/fork/switch. Both are
38//! held by `rpi-cli` at build time and moved into the host.
39
40use std::collections::BTreeMap;
41use std::path::PathBuf;
42use std::sync::{Arc, OnceLock};
43
44use rpi_ai::types::ThinkingLevel;
45use rpi_extensions::RuntimeActionHost;
46use rpi_harness::agent_harness::{AgentHarness, HarnessRunOutcome, NavigationOutcome};
47use rpi_harness::session::session::Session;
48use tokio::runtime::Handle;
49
50use crate::session::{default_session_dir, open_session_by_id};
51
52/// The `RuntimeActionHost` impl over an `AgentHarness`. Built once per session
53/// in [`crate::session::build`] — constructed **empty** before extension load
54/// (the harness doesn't exist yet), then filled via [`set_harness`](Self::set_harness)
55/// once `AgentHarness::create` succeeds. Carried inside the [`ActionBridge`] as
56/// `Arc<dyn RuntimeActionHost>`.
57///
58/// `runtime` is captured at build time so the bridge can spawn dispatch from
59/// any thread; the impl's async methods run ON that runtime (they are spawned
60/// by the trampoline), so they may freely await.
61pub struct HarnessActionHost {
62    /// Filled by `set_harness` after the harness exists. `get()` is infallible
63    /// once set; before that (only possible mid-build, before any plugin call)
64    /// methods return a "not ready" error.
65    harness: Arc<OnceLock<Arc<AgentHarness>>>,
66    /// The auth-filtered catalog (the same list the TUI `/model` selector
67    /// shows). `set_model(id)` resolves an id against this.
68    catalog: Vec<rpi_ai::Model>,
69    /// The session cwd — `new_session`/`fork`/`switch_session` need it to
70    /// locate the session dir.
71    cwd: PathBuf,
72    /// Parsed unknown long flags retained for extension consumption.
73    cli_flags: BTreeMap<String, serde_json::Value>,
74    /// The session's plugin-tool context (`cwd` + session id). The session
75    /// runtime actions (`new_session`/`fork`/`switch_session`) swap the session
76    /// the harness serves, and must republish the id here too — a plugin whose
77    /// tool state keyed off the old id would otherwise keep serving the session
78    /// it named before the swap.
79    tool_context: rpi_extensions::ToolCallContext,
80    #[allow(dead_code)]
81    runtime: Handle,
82}
83
84impl HarnessActionHost {
85    /// Build an **empty** host (no harness yet). The `runtime` is the handle
86    /// the bridge captured (kept only so the impl can name it for future
87    /// direct-spawn needs; the trampoline already spawns on the bridge's
88    /// runtime). Call [`set_harness`](Self::set_harness) once the harness is
89    /// created. Returns `(host, harness_cell)` where `harness_cell` is the
90    /// shared `OnceLock` the caller fills.
91    pub fn new_empty(
92        catalog: Vec<rpi_ai::Model>,
93        cwd: PathBuf,
94        runtime: Handle,
95        cli_flags: BTreeMap<String, serde_json::Value>,
96        tool_context: rpi_extensions::ToolCallContext,
97    ) -> (Self, Arc<OnceLock<Arc<AgentHarness>>>) {
98        let harness = Arc::new(OnceLock::new());
99        (
100            Self {
101                harness: Arc::clone(&harness),
102                catalog,
103                cwd,
104                cli_flags,
105                tool_context,
106                runtime,
107            },
108            harness,
109        )
110    }
111
112    /// Fill the harness cell. Call exactly once, immediately after
113    /// `AgentHarness::create` succeeds. Returns the host (for fluent chaining)
114    /// — the caller already holds the `Arc<dyn RuntimeActionHost>` from
115    /// construction; this just populates the cell that host reads.
116    pub fn set_harness(cell: &Arc<OnceLock<Arc<AgentHarness>>>, harness: Arc<AgentHarness>) {
117        // `set` panics if already set; that's the right failure (double-build
118        // is a programming error, not a runtime condition).
119        let _ = cell.set(harness);
120    }
121
122    /// Borrow the harness, or return a "not ready" error. Only reachable
123    /// mid-build before `set_harness`; once the harness runs, plugins can fire
124    /// actions and the cell is set.
125    fn harness(&self) -> Result<&AgentHarness, String> {
126        self.harness
127            .get()
128            .map(|h| h.as_ref())
129            .ok_or_else(|| "runtime action invoked before harness was built".to_string())
130    }
131
132    /// Resolve `id` (case-insensitive exact, then substring) against the
133    /// catalog. Mirrors the resolver's exact-id-first fallback.
134    fn resolve_model(&self, id: &str) -> Option<rpi_ai::Model> {
135        self.catalog
136            .iter()
137            .find(|m| m.id.eq_ignore_ascii_case(id))
138            .cloned()
139            .or_else(|| {
140                self.catalog
141                    .iter()
142                    .find(|m| m.id.to_ascii_lowercase().contains(&id.to_ascii_lowercase()))
143                    .cloned()
144            })
145    }
146}
147
148/// Helper: pull a string field `key` from `args` (object), or return `msg`.
149fn arg_str(args: &serde_json::Value, key: &str) -> Result<String, String> {
150    args.get(key)
151        .and_then(|v| v.as_str())
152        .map(|s| s.to_string())
153        .ok_or_else(|| format!("missing string field `{key}` in action args"))
154}
155
156/// Helper: pull an optional string field.
157fn arg_str_opt(args: &serde_json::Value, key: &str) -> Option<String> {
158    args.get(key)
159        .and_then(|v| v.as_str())
160        .map(|s| s.to_string())
161}
162
163/// Helper: pull a bool field (default `false`).
164fn arg_bool(args: &serde_json::Value, key: &str) -> bool {
165    args.get(key).and_then(|v| v.as_bool()).unwrap_or(false)
166}
167
168/// Helper: pull a string array field.
169fn arg_str_array(args: &serde_json::Value, key: &str) -> Result<Vec<String>, String> {
170    args.get(key)
171        .and_then(|v| v.as_array())
172        .map(|arr| {
173            arr.iter()
174                .filter_map(|v| v.as_str().map(|s| s.to_string()))
175                .collect()
176        })
177        .ok_or_else(|| format!("missing string-array field `{key}` in action args"))
178}
179
180/// Render a `HarnessRunOutcome` as JSON for the plugin. Only the terminal
181/// status + leaf id cross (the final message text is folded to a string; full
182/// assistant content is too rich for a v1 action result).
183fn run_outcome_json(outcome: HarnessRunOutcome) -> serde_json::Value {
184    match outcome {
185        HarnessRunOutcome::Completed {
186            leaf_id,
187            final_entry_id,
188            final_message,
189        } => {
190            serde_json::json!({
191                "status": "completed",
192                "leafId": leaf_id,
193                "finalEntryId": final_entry_id,
194                "text": assistant_text(&final_message),
195            })
196        }
197        HarnessRunOutcome::Aborted {
198            leaf_id,
199            final_entry_id,
200            final_message,
201        } => {
202            serde_json::json!({
203                "status": "aborted",
204                "leafId": leaf_id,
205                "finalEntryId": final_entry_id,
206                "text": assistant_text(&final_message),
207            })
208        }
209        HarnessRunOutcome::Failed {
210            leaf_id,
211            error,
212            final_entry_id,
213            final_message,
214        } => {
215            serde_json::json!({
216                "status": "failed",
217                "leafId": leaf_id,
218                "error": format!("{error:?}"),
219                "finalEntryId": final_entry_id,
220                "text": final_message.map(|m| assistant_text(&m)).unwrap_or_default(),
221            })
222        }
223        HarnessRunOutcome::Suspended {
224            leaf_id,
225            final_entry_id,
226            ..
227        } => {
228            serde_json::json!({
229                "status": "suspended",
230                "leafId": leaf_id,
231                "finalEntryId": final_entry_id,
232            })
233        }
234    }
235}
236
237/// Extract the concatenated text from an assistant message (the `text` blocks).
238fn assistant_text(msg: &rpi_ai::types::AssistantMessage) -> String {
239    msg.content
240        .iter()
241        .filter_map(|b| match b {
242            rpi_ai::types::Content::Text(t) => Some(t.text.as_str()),
243            _ => None,
244        })
245        .collect::<Vec<_>>()
246        .join("")
247}
248
249#[async_trait::async_trait]
250impl RuntimeActionHost for HarnessActionHost {
251    async fn get_cli_flag(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
252        let name = arg_str(&args, "name")?;
253        Ok(serde_json::json!({
254            "value": self.cli_flags.get(&name).cloned().unwrap_or(serde_json::Value::Null)
255        }))
256    }
257
258    async fn send_message(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
259        // `{"message": <AgentMessage json>}` — drive a full run from any message
260        // kind. Falls back to `{"text": "..."}` as a user-text shorthand.
261        let lane = self.harness()?.lane("main");
262        if let Some(text) = arg_str_opt(&args, "text") {
263            let result = lane
264                .prompt_text(&text, Vec::new())
265                .await
266                .map_err(|e| e.to_string())?;
267            return Ok(run_outcome_json(result.outcome));
268        }
269        let msg = args
270            .get("message")
271            .ok_or_else(|| "missing `message` or `text` field".to_string())?;
272        let message: rpi_agent::AgentMessage =
273            serde_json::from_value(msg.clone()).map_err(|e| format!("invalid message: {e}"))?;
274        let result = lane
275            .prompt_message(message)
276            .await
277            .map_err(|e| e.to_string())?;
278        Ok(run_outcome_json(result.outcome))
279    }
280
281    async fn send_user_message(
282        &self,
283        args: serde_json::Value,
284    ) -> Result<serde_json::Value, String> {
285        let text = arg_str(&args, "text")?;
286        let lane = self.harness()?.lane("main");
287        let result = lane
288            .prompt_text(&text, Vec::new())
289            .await
290            .map_err(|e| e.to_string())?;
291        Ok(run_outcome_json(result.outcome))
292    }
293
294    async fn append_entry(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
295        // `{"message": <AgentMessage json>}` appends a message entry; OR
296        // `{"customType": "...", "data": {...}}` appends a custom entry. No run
297        // is driven — the entry lands in the transcript only.
298        if let Some(custom_type) = arg_str_opt(&args, "customType") {
299            let data = args.get("data").cloned();
300            let id = self
301                .harness()?
302                .session()
303                .append_custom_entry(&custom_type, data)
304                .await
305                .map_err(|e| e.to_string())?;
306            return Ok(serde_json::json!({ "entryId": id }));
307        }
308        let msg = args
309            .get("message")
310            .ok_or_else(|| "missing `message` or `customType` field".to_string())?;
311        let message: rpi_agent::AgentMessage =
312            serde_json::from_value(msg.clone()).map_err(|e| format!("invalid message: {e}"))?;
313        let id = self
314            .harness()?
315            .session()
316            .append_message(message)
317            .await
318            .map_err(|e| e.to_string())?;
319        Ok(serde_json::json!({ "entryId": id }))
320    }
321
322    async fn set_session_name(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
323        let name = arg_str(&args, "name")?;
324        self.harness()?
325            .session()
326            .set_name(Some(&name))
327            .await
328            .map_err(|e| e.to_string())?;
329        Ok(serde_json::Value::Null)
330    }
331
332    async fn get_active_tools(
333        &self,
334        _args: serde_json::Value,
335    ) -> Result<serde_json::Value, String> {
336        let lane = self.harness()?.lane("main");
337        let tools = lane.get_active_tools().await.map_err(|e| e.to_string())?;
338        Ok(serde_json::json!({ "tools": tools }))
339    }
340
341    async fn set_active_tools(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
342        let tools = arg_str_array(&args, "tools")?;
343        let lane = self.harness()?.lane("main");
344        lane.set_active_tools(tools)
345            .await
346            .map_err(|e| e.to_string())?;
347        Ok(serde_json::Value::Null)
348    }
349
350    async fn set_model(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
351        let id = arg_str(&args, "model")?;
352        let model = self
353            .resolve_model(&id)
354            .ok_or_else(|| format!("model `{id}` not in catalog"))?;
355        let lane = self.harness()?.lane("main");
356        lane.set_model(model.clone())
357            .await
358            .map_err(|e| e.to_string())?;
359        Ok(serde_json::json!({ "model": model.id }))
360    }
361
362    async fn get_thinking_level(
363        &self,
364        _args: serde_json::Value,
365    ) -> Result<serde_json::Value, String> {
366        let lane = self.harness()?.lane("main");
367        let level = lane.get_thinking_level().await.map_err(|e| e.to_string())?;
368        Ok(serde_json::json!({ "level": level }))
369    }
370
371    async fn set_thinking_level(
372        &self,
373        args: serde_json::Value,
374    ) -> Result<serde_json::Value, String> {
375        let level_val = args
376            .get("level")
377            .ok_or_else(|| "missing `level` field".to_string())?;
378        let level: ThinkingLevel = if let Some(s) = level_val.as_str() {
379            serde_json::from_value(serde_json::Value::String(s.to_string()))
380                .map_err(|e| format!("invalid thinking level `{s}`: {e}"))?
381        } else {
382            serde_json::from_value(level_val.clone())
383                .map_err(|e| format!("invalid thinking level: {e}"))?
384        };
385        let lane = self.harness()?.lane("main");
386        lane.set_thinking_level(level)
387            .await
388            .map_err(|e| e.to_string())?;
389        Ok(serde_json::Value::Null)
390    }
391
392    async fn compact(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
393        let custom = arg_str_opt(&args, "customInstructions");
394        let lane = self.harness()?.lane("main");
395        let result = lane
396            .compact(custom.as_deref())
397            .await
398            .map_err(|e| e.to_string())?;
399        Ok(
400            serde_json::json!({ "runId": result.run_id, "outcome": format!("{:?}", result.outcome) }),
401        )
402    }
403
404    async fn get_system_prompt(
405        &self,
406        _args: serde_json::Value,
407    ) -> Result<serde_json::Value, String> {
408        let prompt = self
409            .harness()?
410            .get_system_prompt()
411            .await
412            .map_err(|e| e.to_string())?;
413        Ok(serde_json::json!({ "prompt": prompt }))
414    }
415
416    async fn new_session(&self, _args: serde_json::Value) -> Result<serde_json::Value, String> {
417        let cwd_str = self.cwd.to_string_lossy().to_string();
418        let dir = default_session_dir(&self.cwd);
419        std::fs::create_dir_all(&dir)
420            .map_err(|e| format!("create session dir {}: {e}", dir.display()))?;
421        let session = crate::session::create_jsonl_session(&dir, &cwd_str)
422            .await
423            .map_err(|e| format!("create session: {e}"))?;
424        let id = session.storage().metadata().id.clone();
425        crate::session::set_active_session(self.harness()?, session, Some(&self.tool_context))
426            .await?;
427        Ok(serde_json::json!({ "sessionId": id }))
428    }
429
430    async fn fork(&self, _args: serde_json::Value) -> Result<serde_json::Value, String> {
431        let cwd_str = self.cwd.to_string_lossy().to_string();
432        let new_session = crate::session::fork_session_storage(self.harness()?, &cwd_str)
433            .await
434            .map_err(|e| format!("fork session: {e}"))?;
435        let id = new_session.storage().metadata().id.clone();
436        crate::session::set_active_session(self.harness()?, new_session, Some(&self.tool_context))
437            .await?;
438        Ok(serde_json::json!({ "sessionId": id }))
439    }
440
441    async fn navigate_tree(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
442        let target_id = arg_str_opt(&args, "targetId");
443        let summarize = arg_bool(&args, "summarize");
444        let custom = arg_str_opt(&args, "customInstructions");
445        let label = arg_str_opt(&args, "label");
446        let lane = self.harness()?.lane("main");
447        let result = lane
448            .navigate_tree(
449                target_id.as_deref(),
450                summarize,
451                custom.as_deref(),
452                label.as_deref(),
453            )
454            .await
455            .map_err(|e| e.to_string())?;
456        let status = match &result.outcome {
457            NavigationOutcome::Completed { .. } => "completed",
458            NavigationOutcome::Declined { .. } => "declined",
459            NavigationOutcome::Aborted { .. } => "aborted",
460            NavigationOutcome::Failed { .. } => "failed",
461        };
462        Ok(serde_json::json!({ "runId": result.run_id, "status": status }))
463    }
464
465    async fn switch_session(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
466        let id = arg_str(&args, "id")?;
467        let cwd_str = self.cwd.to_string_lossy().to_string();
468        let new_session: Session = open_session_by_id(&id, &cwd_str)
469            .await
470            .map_err(|e| e.to_string())?;
471        let new_id = new_session.storage().metadata().id.clone();
472        crate::session::set_active_session(self.harness()?, new_session, Some(&self.tool_context))
473            .await?;
474        Ok(serde_json::json!({ "sessionId": new_id }))
475    }
476
477    async fn reload(&self, _args: serde_json::Value) -> Result<serde_json::Value, String> {
478        // Reached only when `ActionBridge.reload` is `None` (no /reload wired).
479        // B5d wires the reload callback at the bridge layer; this host impl is
480        // the "not configured" fallback.
481        Err("reload not configured (no /reload callback on this bridge)".to_string())
482    }
483}