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