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