use std::path::PathBuf;
use std::sync::{Arc, OnceLock};
use rpi_ai::types::ThinkingLevel;
use rpi_extensions::RuntimeActionHost;
use rpi_harness::agent_harness::{AgentHarness, HarnessRunOutcome, NavigationOutcome};
use rpi_harness::session::session::Session;
use tokio::runtime::Handle;
use crate::session::{default_session_dir, open_session_by_id};
pub struct HarnessActionHost {
harness: Arc<OnceLock<Arc<AgentHarness>>>,
catalog: Vec<rpi_ai::Model>,
cwd: PathBuf,
#[allow(dead_code)]
runtime: Handle,
}
impl HarnessActionHost {
pub fn new_empty(
catalog: Vec<rpi_ai::Model>,
cwd: PathBuf,
runtime: Handle,
) -> (Self, Arc<OnceLock<Arc<AgentHarness>>>) {
let harness = Arc::new(OnceLock::new());
(
Self { harness: Arc::clone(&harness), catalog, cwd, runtime },
harness,
)
}
pub fn set_harness(cell: &Arc<OnceLock<Arc<AgentHarness>>>, harness: Arc<AgentHarness>) {
let _ = cell.set(harness);
}
fn harness(&self) -> Result<&AgentHarness, String> {
self.harness
.get()
.map(|h| h.as_ref())
.ok_or_else(|| "runtime action invoked before harness was built".to_string())
}
fn resolve_model(&self, id: &str) -> Option<rpi_ai::Model> {
self.catalog
.iter()
.find(|m| m.id.eq_ignore_ascii_case(id))
.cloned()
.or_else(|| {
self.catalog
.iter()
.find(|m| m.id.to_ascii_lowercase().contains(&id.to_ascii_lowercase()))
.cloned()
})
}
}
fn arg_str(args: &serde_json::Value, key: &str) -> Result<String, String> {
args.get(key)
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.ok_or_else(|| format!("missing string field `{key}` in action args"))
}
fn arg_str_opt(args: &serde_json::Value, key: &str) -> Option<String> {
args.get(key).and_then(|v| v.as_str()).map(|s| s.to_string())
}
fn arg_bool(args: &serde_json::Value, key: &str) -> bool {
args.get(key).and_then(|v| v.as_bool()).unwrap_or(false)
}
fn arg_str_array(args: &serde_json::Value, key: &str) -> Result<Vec<String>, String> {
args.get(key)
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
})
.ok_or_else(|| format!("missing string-array field `{key}` in action args"))
}
fn run_outcome_json(outcome: HarnessRunOutcome) -> serde_json::Value {
match outcome {
HarnessRunOutcome::Completed { leaf_id, final_entry_id, final_message } => {
serde_json::json!({
"status": "completed",
"leafId": leaf_id,
"finalEntryId": final_entry_id,
"text": assistant_text(&final_message),
})
}
HarnessRunOutcome::Aborted { leaf_id, final_entry_id, final_message } => {
serde_json::json!({
"status": "aborted",
"leafId": leaf_id,
"finalEntryId": final_entry_id,
"text": assistant_text(&final_message),
})
}
HarnessRunOutcome::Failed { leaf_id, error, final_entry_id, final_message } => {
serde_json::json!({
"status": "failed",
"leafId": leaf_id,
"error": format!("{error:?}"),
"finalEntryId": final_entry_id,
"text": final_message.map(|m| assistant_text(&m)).unwrap_or_default(),
})
}
HarnessRunOutcome::Suspended { leaf_id, final_entry_id, .. } => {
serde_json::json!({
"status": "suspended",
"leafId": leaf_id,
"finalEntryId": final_entry_id,
})
}
}
}
fn assistant_text(msg: &rpi_ai::types::AssistantMessage) -> String {
msg.content
.iter()
.filter_map(|b| match b {
rpi_ai::types::Content::Text(t) => Some(t.text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("")
}
#[async_trait::async_trait]
impl RuntimeActionHost for HarnessActionHost {
async fn send_message(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
let lane = self.harness()?.lane("main");
if let Some(text) = arg_str_opt(&args, "text") {
let result = lane.prompt_text(&text, Vec::new()).await.map_err(|e| e.to_string())?;
return Ok(run_outcome_json(result.outcome));
}
let msg = args
.get("message")
.ok_or_else(|| "missing `message` or `text` field".to_string())?;
let message: rpi_agent::AgentMessage =
serde_json::from_value(msg.clone()).map_err(|e| format!("invalid message: {e}"))?;
let result = lane.prompt_message(message).await.map_err(|e| e.to_string())?;
Ok(run_outcome_json(result.outcome))
}
async fn send_user_message(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
let text = arg_str(&args, "text")?;
let lane = self.harness()?.lane("main");
let result = lane.prompt_text(&text, Vec::new()).await.map_err(|e| e.to_string())?;
Ok(run_outcome_json(result.outcome))
}
async fn append_entry(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
if let Some(custom_type) = arg_str_opt(&args, "customType") {
let data = args.get("data").cloned();
let id = self
.harness()?
.session()
.append_custom_entry(&custom_type, data)
.await
.map_err(|e| e.to_string())?;
return Ok(serde_json::json!({ "entryId": id }));
}
let msg = args
.get("message")
.ok_or_else(|| "missing `message` or `customType` field".to_string())?;
let message: rpi_agent::AgentMessage =
serde_json::from_value(msg.clone()).map_err(|e| format!("invalid message: {e}"))?;
let id = self
.harness()?
.session()
.append_message(message)
.await
.map_err(|e| e.to_string())?;
Ok(serde_json::json!({ "entryId": id }))
}
async fn set_session_name(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
let name = arg_str(&args, "name")?;
self.harness()?
.session()
.set_name(Some(&name))
.await
.map_err(|e| e.to_string())?;
Ok(serde_json::Value::Null)
}
async fn get_active_tools(&self, _args: serde_json::Value) -> Result<serde_json::Value, String> {
let lane = self.harness()?.lane("main");
let tools = lane.get_active_tools().await.map_err(|e| e.to_string())?;
Ok(serde_json::json!({ "tools": tools }))
}
async fn set_active_tools(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
let tools = arg_str_array(&args, "tools")?;
let lane = self.harness()?.lane("main");
lane.set_active_tools(tools).await.map_err(|e| e.to_string())?;
Ok(serde_json::Value::Null)
}
async fn set_model(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
let id = arg_str(&args, "model")?;
let model = self
.resolve_model(&id)
.ok_or_else(|| format!("model `{id}` not in catalog"))?;
let lane = self.harness()?.lane("main");
lane.set_model(model.clone()).await.map_err(|e| e.to_string())?;
Ok(serde_json::json!({ "model": model.id }))
}
async fn get_thinking_level(&self, _args: serde_json::Value) -> Result<serde_json::Value, String> {
let lane = self.harness()?.lane("main");
let level = lane.get_thinking_level().await.map_err(|e| e.to_string())?;
Ok(serde_json::json!({ "level": level }))
}
async fn set_thinking_level(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
let level_val = args
.get("level")
.ok_or_else(|| "missing `level` field".to_string())?;
let level: ThinkingLevel = if let Some(s) = level_val.as_str() {
serde_json::from_value(serde_json::Value::String(s.to_string()))
.map_err(|e| format!("invalid thinking level `{s}`: {e}"))?
} else {
serde_json::from_value(level_val.clone())
.map_err(|e| format!("invalid thinking level: {e}"))?
};
let lane = self.harness()?.lane("main");
lane.set_thinking_level(level).await.map_err(|e| e.to_string())?;
Ok(serde_json::Value::Null)
}
async fn compact(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
let custom = arg_str_opt(&args, "customInstructions");
let lane = self.harness()?.lane("main");
let result = lane
.compact(custom.as_deref())
.await
.map_err(|e| e.to_string())?;
Ok(serde_json::json!({ "runId": result.run_id, "outcome": format!("{:?}", result.outcome) }))
}
async fn get_system_prompt(&self, _args: serde_json::Value) -> Result<serde_json::Value, String> {
let prompt = self.harness()?
.get_system_prompt()
.await
.map_err(|e| e.to_string())?;
Ok(serde_json::json!({ "prompt": prompt }))
}
async fn new_session(&self, _args: serde_json::Value) -> Result<serde_json::Value, String> {
let cwd_str = self.cwd.to_string_lossy().to_string();
let dir = default_session_dir(&self.cwd);
std::fs::create_dir_all(&dir)
.map_err(|e| format!("create session dir {}: {e}", dir.display()))?;
let session = crate::session::create_jsonl_session(&dir, &cwd_str)
.await
.map_err(|e| format!("create session: {e}"))?;
let id = session.storage().metadata().id.clone();
self.harness()?
.set_session(session)
.await
.map_err(|e| e.to_string())?;
Ok(serde_json::json!({ "sessionId": id }))
}
async fn fork(&self, _args: serde_json::Value) -> Result<serde_json::Value, String> {
let cwd_str = self.cwd.to_string_lossy().to_string();
let new_session = crate::session::fork_session_storage(self.harness()?, &cwd_str)
.await
.map_err(|e| format!("fork session: {e}"))?;
let id = new_session.storage().metadata().id.clone();
self.harness()?
.set_session(new_session)
.await
.map_err(|e| e.to_string())?;
Ok(serde_json::json!({ "sessionId": id }))
}
async fn navigate_tree(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
let target_id = arg_str_opt(&args, "targetId");
let summarize = arg_bool(&args, "summarize");
let custom = arg_str_opt(&args, "customInstructions");
let label = arg_str_opt(&args, "label");
let lane = self.harness()?.lane("main");
let result = lane
.navigate_tree(target_id.as_deref(), summarize, custom.as_deref(), label.as_deref())
.await
.map_err(|e| e.to_string())?;
let status = match &result.outcome {
NavigationOutcome::Completed { .. } => "completed",
NavigationOutcome::Declined { .. } => "declined",
NavigationOutcome::Aborted { .. } => "aborted",
NavigationOutcome::Failed { .. } => "failed",
};
Ok(serde_json::json!({ "runId": result.run_id, "status": status }))
}
async fn switch_session(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
let id = arg_str(&args, "id")?;
let cwd_str = self.cwd.to_string_lossy().to_string();
let new_session: Session =
open_session_by_id(&id, &cwd_str).await.map_err(|e| e.to_string())?;
let new_id = new_session.storage().metadata().id.clone();
self.harness()?
.set_session(new_session)
.await
.map_err(|e| e.to_string())?;
Ok(serde_json::json!({ "sessionId": new_id }))
}
async fn reload(&self, _args: serde_json::Value) -> Result<serde_json::Value, String> {
Err("reload not configured (no /reload callback on this bridge)".to_string())
}
}