use std::collections::BTreeMap;
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, SendMessageResult,
};
use rpi_harness::session::session::Session;
use rpi_harness::session::types::{BranchBounds, EntryOrder, EntryQuery};
use rpi_harness::types::SendMessageOptions;
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,
cli_flags: BTreeMap<String, serde_json::Value>,
tool_context: rpi_extensions::ToolCallContext,
#[allow(dead_code)]
runtime: Handle,
}
impl HarnessActionHost {
pub fn new_empty(
catalog: Vec<rpi_ai::Model>,
cwd: PathBuf,
runtime: Handle,
cli_flags: BTreeMap<String, serde_json::Value>,
tool_context: rpi_extensions::ToolCallContext,
) -> (Self, Arc<OnceLock<Arc<AgentHarness>>>) {
let harness = Arc::new(OnceLock::new());
(
Self {
harness: Arc::clone(&harness),
catalog,
cwd,
cli_flags,
tool_context,
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 send_message_options(args: &serde_json::Value) -> Result<SendMessageOptions, String> {
let flat = args.get("triggerTurn").is_some() || args.get("deliverAs").is_some();
let value = if let Some(options) = args.get("options").filter(|value| !value.is_null()) {
if !options.is_object() {
return Err("send_message options must be an object".into());
}
if flat {
return Err(
"put triggerTurn/deliverAs either in options or at the top level, not both".into(),
);
}
options.clone()
} else {
let mut fields = serde_json::Map::new();
for key in ["triggerTurn", "deliverAs"] {
if let Some(value) = args.get(key) {
fields.insert(key.into(), value.clone());
}
}
serde_json::Value::Object(fields)
};
serde_json::from_value(value).map_err(|error| format!("invalid send_message options: {error}"))
}
fn send_message_input(args: &serde_json::Value) -> Result<rpi_agent::AgentMessage, String> {
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
.min(i64::MAX as u128) as i64;
if let Some(message) = args.get("message") {
if message.get("kind").is_some() {
return serde_json::from_value(message.clone())
.map_err(|error| format!("invalid message: {error}"));
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct CustomInput {
custom_type: String,
#[serde(default)]
content: Option<rpi_ai::types::UserContent>,
#[serde(default)]
display: bool,
#[serde(default)]
details: Option<serde_json::Value>,
}
let input: CustomInput = serde_json::from_value(message.clone())
.map_err(|error| format!("invalid custom message: {error}"))?;
return Ok(rpi_harness::messages::create_custom_message(
&input.custom_type,
input
.content
.unwrap_or(rpi_ai::types::UserContent::Blocks(Vec::new())),
input.display,
input.details,
timestamp,
));
}
let text =
arg_str(args, "text").map_err(|_| "missing `message` or `text` field".to_string())?;
Ok(rpi_agent::AgentMessage::User(
rpi_ai::types::UserMessage::new(text, timestamp),
))
}
fn send_message_result(result: SendMessageResult) -> serde_json::Value {
match result {
SendMessageResult::Appended { entry_id } => {
serde_json::json!({"status":"appended", "entryId":entry_id})
}
SendMessageResult::Queued { entry_id, queue } => {
if queue == rpi_harness::session::types::QueueKind::Context {
return serde_json::json!({"status":"queued", "entryId":entry_id, "queue":"context"});
}
let delivery = match queue {
rpi_harness::session::types::QueueKind::NextRun => "nextTurn",
_ => queue.as_str(),
};
serde_json::json!({"status":"queued", "entryId":entry_id, "deliverAs":delivery})
}
SendMessageResult::Run(run) => run_outcome_json(run.outcome),
}
}
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 get_cli_flag(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
let name = arg_str(&args, "name")?;
Ok(serde_json::json!({
"value": self.cli_flags.get(&name).cloned().unwrap_or(serde_json::Value::Null)
}))
}
async fn send_message(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
let options = send_message_options(&args)?;
let message = send_message_input(&args)?;
let lane = self.harness()?.lane("main");
let result = lane
.send_message(message, options)
.await
.map_err(|e| e.to_string())?;
Ok(send_message_result(result))
}
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()?
.append_message(message)
.await
.map_err(|e| e.to_string())?;
Ok(serde_json::json!({ "entryId": id }))
}
async fn get_session_branch(
&self,
_args: serde_json::Value,
) -> Result<serde_json::Value, String> {
let entries = self
.harness()?
.find_entries_on_branch(
&EntryQuery {
order: Some(EntryOrder::OldestFirst),
..Default::default()
},
&BranchBounds::default(),
)
.await
.map_err(|e| e.to_string())?;
let entries = entries
.iter()
.map(serde_json::to_value)
.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("serialize session branch: {e}"))?;
Ok(serde_json::json!({"entries": entries}))
}
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();
crate::session::set_active_session(self.harness()?, session, Some(&self.tool_context))
.await?;
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();
crate::session::set_active_session(self.harness()?, new_session, Some(&self.tool_context))
.await?;
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();
crate::session::set_active_session(self.harness()?, new_session, Some(&self.tool_context))
.await?;
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())
}
}
#[cfg(test)]
mod send_message_tests {
use super::*;
use rpi_harness::types::MessageDelivery;
#[test]
fn options_accept_flat_and_nested_forms_with_pi_defaults() {
assert_eq!(
send_message_options(&serde_json::json!({"text":"context"})).unwrap(),
SendMessageOptions::default()
);
let expected = SendMessageOptions {
trigger_turn: Some(false),
deliver_as: MessageDelivery::FollowUp,
};
for args in [
serde_json::json!({"text":"context", "triggerTurn":false, "deliverAs":"followUp"}),
serde_json::json!({"text":"context", "options":{"triggerTurn":false, "deliverAs":"followUp"}}),
] {
assert_eq!(send_message_options(&args).unwrap(), expected);
}
}
#[test]
fn options_reject_invalid_or_ambiguous_scheduling_instructions() {
for args in [
serde_json::json!({"triggerTurn":"false"}),
serde_json::json!({"deliverAs":"follow-up"}),
serde_json::json!({"options":{"typo":true}}),
serde_json::json!({"options":[], "text":"context"}),
serde_json::json!({"options":{"triggerTurn":true}, "triggerTurn":false}),
] {
assert!(send_message_options(&args).is_err(), "{args}");
}
}
#[test]
fn pi_custom_input_preserves_content_and_display_metadata() {
let message = send_message_input(&serde_json::json!({"message":{
"customType":"goal", "content":"goal state", "display":true, "details":{"step":2}
}}))
.unwrap();
let rpi_agent::AgentMessage::Custom(custom) = &message else {
panic!("custom")
};
assert_eq!(
custom.content,
vec![rpi_ai::types::Content::text("goal state")]
);
let data = rpi_harness::messages::custom_data(&message).unwrap();
assert_eq!(data.custom_type, "goal");
assert!(data.display);
assert_eq!(data.details, Some(serde_json::json!({"step":2})));
let stored = serde_json::to_value(&message).unwrap();
assert_eq!(
send_message_input(&serde_json::json!({"message":stored})).unwrap(),
message
);
}
#[test]
fn missing_custom_content_is_empty_and_queued_results_use_pi_delivery_names() {
let message = send_message_input(
&serde_json::json!({"message":{"customType":"goal", "content":null}}),
)
.unwrap();
assert!(
matches!(message, rpi_agent::AgentMessage::Custom(custom) if custom.content.is_empty())
);
assert_eq!(
send_message_result(SendMessageResult::Queued {
entry_id: "reserved".into(),
queue: rpi_harness::session::types::QueueKind::NextRun,
}),
serde_json::json!({"status":"queued", "entryId":"reserved", "deliverAs":"nextTurn"})
);
}
}