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