1use 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::{
47 AgentHarness, HarnessRunOutcome, NavigationOutcome, SendMessageResult,
48};
49use rpi_harness::session::session::Session;
50use rpi_harness::session::types::{BranchBounds, EntryOrder, EntryQuery};
51use rpi_harness::types::SendMessageOptions;
52use tokio::runtime::Handle;
53
54use crate::session::{default_session_dir, open_session_by_id};
55
56pub struct HarnessActionHost {
66 harness: Arc<OnceLock<Arc<AgentHarness>>>,
70 catalog: Vec<rpi_ai::Model>,
73 cwd: PathBuf,
76 cli_flags: BTreeMap<String, serde_json::Value>,
78 tool_context: rpi_extensions::ToolCallContext,
84 #[allow(dead_code)]
85 runtime: Handle,
86}
87
88impl HarnessActionHost {
89 pub fn new_empty(
96 catalog: Vec<rpi_ai::Model>,
97 cwd: PathBuf,
98 runtime: Handle,
99 cli_flags: BTreeMap<String, serde_json::Value>,
100 tool_context: rpi_extensions::ToolCallContext,
101 ) -> (Self, Arc<OnceLock<Arc<AgentHarness>>>) {
102 let harness = Arc::new(OnceLock::new());
103 (
104 Self {
105 harness: Arc::clone(&harness),
106 catalog,
107 cwd,
108 cli_flags,
109 tool_context,
110 runtime,
111 },
112 harness,
113 )
114 }
115
116 pub fn set_harness(cell: &Arc<OnceLock<Arc<AgentHarness>>>, harness: Arc<AgentHarness>) {
121 let _ = cell.set(harness);
124 }
125
126 fn harness(&self) -> Result<&AgentHarness, String> {
130 self.harness
131 .get()
132 .map(|h| h.as_ref())
133 .ok_or_else(|| "runtime action invoked before harness was built".to_string())
134 }
135
136 fn resolve_model(&self, id: &str) -> Option<rpi_ai::Model> {
139 self.catalog
140 .iter()
141 .find(|m| m.id.eq_ignore_ascii_case(id))
142 .cloned()
143 .or_else(|| {
144 self.catalog
145 .iter()
146 .find(|m| m.id.to_ascii_lowercase().contains(&id.to_ascii_lowercase()))
147 .cloned()
148 })
149 }
150}
151
152fn arg_str(args: &serde_json::Value, key: &str) -> Result<String, String> {
154 args.get(key)
155 .and_then(|v| v.as_str())
156 .map(|s| s.to_string())
157 .ok_or_else(|| format!("missing string field `{key}` in action args"))
158}
159
160fn arg_str_opt(args: &serde_json::Value, key: &str) -> Option<String> {
162 args.get(key)
163 .and_then(|v| v.as_str())
164 .map(|s| s.to_string())
165}
166
167fn arg_bool(args: &serde_json::Value, key: &str) -> bool {
169 args.get(key).and_then(|v| v.as_bool()).unwrap_or(false)
170}
171
172fn arg_str_array(args: &serde_json::Value, key: &str) -> Result<Vec<String>, String> {
174 args.get(key)
175 .and_then(|v| v.as_array())
176 .map(|arr| {
177 arr.iter()
178 .filter_map(|v| v.as_str().map(|s| s.to_string()))
179 .collect()
180 })
181 .ok_or_else(|| format!("missing string-array field `{key}` in action args"))
182}
183
184fn run_outcome_json(outcome: HarnessRunOutcome) -> serde_json::Value {
188 match outcome {
189 HarnessRunOutcome::Completed {
190 leaf_id,
191 final_entry_id,
192 final_message,
193 } => {
194 serde_json::json!({
195 "status": "completed",
196 "leafId": leaf_id,
197 "finalEntryId": final_entry_id,
198 "text": assistant_text(&final_message),
199 })
200 }
201 HarnessRunOutcome::Aborted {
202 leaf_id,
203 final_entry_id,
204 final_message,
205 } => {
206 serde_json::json!({
207 "status": "aborted",
208 "leafId": leaf_id,
209 "finalEntryId": final_entry_id,
210 "text": assistant_text(&final_message),
211 })
212 }
213 HarnessRunOutcome::Failed {
214 leaf_id,
215 error,
216 final_entry_id,
217 final_message,
218 } => {
219 serde_json::json!({
220 "status": "failed",
221 "leafId": leaf_id,
222 "error": format!("{error:?}"),
223 "finalEntryId": final_entry_id,
224 "text": final_message.map(|m| assistant_text(&m)).unwrap_or_default(),
225 })
226 }
227 HarnessRunOutcome::Suspended {
228 leaf_id,
229 final_entry_id,
230 ..
231 } => {
232 serde_json::json!({
233 "status": "suspended",
234 "leafId": leaf_id,
235 "finalEntryId": final_entry_id,
236 })
237 }
238 }
239}
240
241fn send_message_options(args: &serde_json::Value) -> Result<SendMessageOptions, String> {
244 let flat = args.get("triggerTurn").is_some() || args.get("deliverAs").is_some();
245 let value = if let Some(options) = args.get("options").filter(|value| !value.is_null()) {
246 if !options.is_object() {
247 return Err("send_message options must be an object".into());
248 }
249 if flat {
250 return Err(
251 "put triggerTurn/deliverAs either in options or at the top level, not both".into(),
252 );
253 }
254 options.clone()
255 } else {
256 let mut fields = serde_json::Map::new();
257 for key in ["triggerTurn", "deliverAs"] {
258 if let Some(value) = args.get(key) {
259 fields.insert(key.into(), value.clone());
260 }
261 }
262 serde_json::Value::Object(fields)
263 };
264 serde_json::from_value(value).map_err(|error| format!("invalid send_message options: {error}"))
265}
266
267fn send_message_input(args: &serde_json::Value) -> Result<rpi_agent::AgentMessage, String> {
268 let timestamp = std::time::SystemTime::now()
269 .duration_since(std::time::UNIX_EPOCH)
270 .unwrap_or_default()
271 .as_millis()
272 .min(i64::MAX as u128) as i64;
273 if let Some(message) = args.get("message") {
274 if message.get("kind").is_some() {
275 return serde_json::from_value(message.clone())
276 .map_err(|error| format!("invalid message: {error}"));
277 }
278 #[derive(serde::Deserialize)]
281 #[serde(rename_all = "camelCase")]
282 struct CustomInput {
283 custom_type: String,
284 #[serde(default)]
285 content: Option<rpi_ai::types::UserContent>,
286 #[serde(default)]
287 display: bool,
288 #[serde(default)]
289 details: Option<serde_json::Value>,
290 }
291 let input: CustomInput = serde_json::from_value(message.clone())
292 .map_err(|error| format!("invalid custom message: {error}"))?;
293 return Ok(rpi_harness::messages::create_custom_message(
294 &input.custom_type,
295 input
296 .content
297 .unwrap_or(rpi_ai::types::UserContent::Blocks(Vec::new())),
298 input.display,
299 input.details,
300 timestamp,
301 ));
302 }
303 let text =
304 arg_str(args, "text").map_err(|_| "missing `message` or `text` field".to_string())?;
305 Ok(rpi_agent::AgentMessage::User(
306 rpi_ai::types::UserMessage::new(text, timestamp),
307 ))
308}
309
310fn send_message_result(result: SendMessageResult) -> serde_json::Value {
311 match result {
312 SendMessageResult::Appended { entry_id } => {
313 serde_json::json!({"status":"appended", "entryId":entry_id})
314 }
315 SendMessageResult::Queued { entry_id, queue } => {
316 if queue == rpi_harness::session::types::QueueKind::Context {
317 return serde_json::json!({"status":"queued", "entryId":entry_id, "queue":"context"});
318 }
319 let delivery = match queue {
320 rpi_harness::session::types::QueueKind::NextRun => "nextTurn",
321 _ => queue.as_str(),
322 };
323 serde_json::json!({"status":"queued", "entryId":entry_id, "deliverAs":delivery})
324 }
325 SendMessageResult::Run(run) => run_outcome_json(run.outcome),
326 }
327}
328
329fn assistant_text(msg: &rpi_ai::types::AssistantMessage) -> String {
331 msg.content
332 .iter()
333 .filter_map(|b| match b {
334 rpi_ai::types::Content::Text(t) => Some(t.text.as_str()),
335 _ => None,
336 })
337 .collect::<Vec<_>>()
338 .join("")
339}
340
341#[async_trait::async_trait]
342impl RuntimeActionHost for HarnessActionHost {
343 async fn get_cli_flag(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
344 let name = arg_str(&args, "name")?;
345 Ok(serde_json::json!({
346 "value": self.cli_flags.get(&name).cloned().unwrap_or(serde_json::Value::Null)
347 }))
348 }
349
350 async fn send_message(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
351 let options = send_message_options(&args)?;
352 let message = send_message_input(&args)?;
353 let lane = self.harness()?.lane("main");
354 let result = lane
355 .send_message(message, options)
356 .await
357 .map_err(|e| e.to_string())?;
358 Ok(send_message_result(result))
359 }
360
361 async fn send_user_message(
362 &self,
363 args: serde_json::Value,
364 ) -> Result<serde_json::Value, String> {
365 let text = arg_str(&args, "text")?;
366 let lane = self.harness()?.lane("main");
367 let result = lane
368 .prompt_text(&text, Vec::new())
369 .await
370 .map_err(|e| e.to_string())?;
371 Ok(run_outcome_json(result.outcome))
372 }
373
374 async fn append_entry(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
375 if let Some(custom_type) = arg_str_opt(&args, "customType") {
381 let data = args.get("data").cloned();
382 let id = self
383 .harness()?
384 .session()
385 .append_custom_entry(&custom_type, data)
386 .await
387 .map_err(|e| e.to_string())?;
388 return Ok(serde_json::json!({ "entryId": id }));
389 }
390 let msg = args
391 .get("message")
392 .ok_or_else(|| "missing `message` or `customType` field".to_string())?;
393 let message: rpi_agent::AgentMessage =
394 serde_json::from_value(msg.clone()).map_err(|e| format!("invalid message: {e}"))?;
395 let id = self
396 .harness()?
397 .append_message(message)
398 .await
399 .map_err(|e| e.to_string())?;
400 Ok(serde_json::json!({ "entryId": id }))
401 }
402
403 async fn get_session_branch(
404 &self,
405 _args: serde_json::Value,
406 ) -> Result<serde_json::Value, String> {
407 let entries = self
408 .harness()?
409 .find_entries_on_branch(
410 &EntryQuery {
411 order: Some(EntryOrder::OldestFirst),
412 ..Default::default()
413 },
414 &BranchBounds::default(),
415 )
416 .await
417 .map_err(|e| e.to_string())?;
418 let entries = entries
419 .iter()
420 .map(serde_json::to_value)
421 .collect::<Result<Vec<_>, _>>()
422 .map_err(|e| format!("serialize session branch: {e}"))?;
423 Ok(serde_json::json!({"entries": entries}))
424 }
425
426 async fn set_session_name(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
427 let name = arg_str(&args, "name")?;
428 self.harness()?
429 .session()
430 .set_name(Some(&name))
431 .await
432 .map_err(|e| e.to_string())?;
433 Ok(serde_json::Value::Null)
434 }
435
436 async fn get_active_tools(
437 &self,
438 _args: serde_json::Value,
439 ) -> Result<serde_json::Value, String> {
440 let lane = self.harness()?.lane("main");
441 let tools = lane.get_active_tools().await.map_err(|e| e.to_string())?;
442 Ok(serde_json::json!({ "tools": tools }))
443 }
444
445 async fn set_active_tools(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
446 let tools = arg_str_array(&args, "tools")?;
447 let lane = self.harness()?.lane("main");
448 lane.set_active_tools(tools)
449 .await
450 .map_err(|e| e.to_string())?;
451 Ok(serde_json::Value::Null)
452 }
453
454 async fn set_model(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
455 let id = arg_str(&args, "model")?;
456 let model = self
457 .resolve_model(&id)
458 .ok_or_else(|| format!("model `{id}` not in catalog"))?;
459 let lane = self.harness()?.lane("main");
460 lane.set_model(model.clone())
461 .await
462 .map_err(|e| e.to_string())?;
463 Ok(serde_json::json!({ "model": model.id }))
464 }
465
466 async fn get_thinking_level(
467 &self,
468 _args: serde_json::Value,
469 ) -> Result<serde_json::Value, String> {
470 let lane = self.harness()?.lane("main");
471 let level = lane.get_thinking_level().await.map_err(|e| e.to_string())?;
472 Ok(serde_json::json!({ "level": level }))
473 }
474
475 async fn set_thinking_level(
476 &self,
477 args: serde_json::Value,
478 ) -> Result<serde_json::Value, String> {
479 let level_val = args
480 .get("level")
481 .ok_or_else(|| "missing `level` field".to_string())?;
482 let level: ThinkingLevel = if let Some(s) = level_val.as_str() {
483 serde_json::from_value(serde_json::Value::String(s.to_string()))
484 .map_err(|e| format!("invalid thinking level `{s}`: {e}"))?
485 } else {
486 serde_json::from_value(level_val.clone())
487 .map_err(|e| format!("invalid thinking level: {e}"))?
488 };
489 let lane = self.harness()?.lane("main");
490 lane.set_thinking_level(level)
491 .await
492 .map_err(|e| e.to_string())?;
493 Ok(serde_json::Value::Null)
494 }
495
496 async fn compact(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
497 let custom = arg_str_opt(&args, "customInstructions");
498 let lane = self.harness()?.lane("main");
499 let result = lane
500 .compact(custom.as_deref())
501 .await
502 .map_err(|e| e.to_string())?;
503 Ok(
504 serde_json::json!({ "runId": result.run_id, "outcome": format!("{:?}", result.outcome) }),
505 )
506 }
507
508 async fn get_system_prompt(
509 &self,
510 _args: serde_json::Value,
511 ) -> Result<serde_json::Value, String> {
512 let prompt = self
513 .harness()?
514 .get_system_prompt()
515 .await
516 .map_err(|e| e.to_string())?;
517 Ok(serde_json::json!({ "prompt": prompt }))
518 }
519
520 async fn new_session(&self, _args: serde_json::Value) -> Result<serde_json::Value, String> {
521 let cwd_str = self.cwd.to_string_lossy().to_string();
522 let dir = default_session_dir(&self.cwd);
523 std::fs::create_dir_all(&dir)
524 .map_err(|e| format!("create session dir {}: {e}", dir.display()))?;
525 let session = crate::session::create_jsonl_session(&dir, &cwd_str)
526 .await
527 .map_err(|e| format!("create session: {e}"))?;
528 let id = session.storage().metadata().id.clone();
529 crate::session::set_active_session(self.harness()?, session, Some(&self.tool_context))
530 .await?;
531 Ok(serde_json::json!({ "sessionId": id }))
532 }
533
534 async fn fork(&self, _args: serde_json::Value) -> Result<serde_json::Value, String> {
535 let cwd_str = self.cwd.to_string_lossy().to_string();
536 let new_session = crate::session::fork_session_storage(self.harness()?, &cwd_str)
537 .await
538 .map_err(|e| format!("fork session: {e}"))?;
539 let id = new_session.storage().metadata().id.clone();
540 crate::session::set_active_session(self.harness()?, new_session, Some(&self.tool_context))
541 .await?;
542 Ok(serde_json::json!({ "sessionId": id }))
543 }
544
545 async fn navigate_tree(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
546 let target_id = arg_str_opt(&args, "targetId");
547 let summarize = arg_bool(&args, "summarize");
548 let custom = arg_str_opt(&args, "customInstructions");
549 let label = arg_str_opt(&args, "label");
550 let lane = self.harness()?.lane("main");
551 let result = lane
552 .navigate_tree(
553 target_id.as_deref(),
554 summarize,
555 custom.as_deref(),
556 label.as_deref(),
557 )
558 .await
559 .map_err(|e| e.to_string())?;
560 let status = match &result.outcome {
561 NavigationOutcome::Completed { .. } => "completed",
562 NavigationOutcome::Declined { .. } => "declined",
563 NavigationOutcome::Aborted { .. } => "aborted",
564 NavigationOutcome::Failed { .. } => "failed",
565 };
566 Ok(serde_json::json!({ "runId": result.run_id, "status": status }))
567 }
568
569 async fn switch_session(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
570 let id = arg_str(&args, "id")?;
571 let cwd_str = self.cwd.to_string_lossy().to_string();
572 let new_session: Session = open_session_by_id(&id, &cwd_str)
573 .await
574 .map_err(|e| e.to_string())?;
575 let new_id = new_session.storage().metadata().id.clone();
576 crate::session::set_active_session(self.harness()?, new_session, Some(&self.tool_context))
577 .await?;
578 Ok(serde_json::json!({ "sessionId": new_id }))
579 }
580
581 async fn reload(&self, _args: serde_json::Value) -> Result<serde_json::Value, String> {
582 Err("reload not configured (no /reload callback on this bridge)".to_string())
586 }
587}
588
589#[cfg(test)]
590mod send_message_tests {
591 use super::*;
592 use rpi_harness::types::MessageDelivery;
593
594 #[test]
595 fn options_accept_flat_and_nested_forms_with_pi_defaults() {
596 assert_eq!(
597 send_message_options(&serde_json::json!({"text":"context"})).unwrap(),
598 SendMessageOptions::default()
599 );
600 let expected = SendMessageOptions {
601 trigger_turn: Some(false),
602 deliver_as: MessageDelivery::FollowUp,
603 };
604 for args in [
605 serde_json::json!({"text":"context", "triggerTurn":false, "deliverAs":"followUp"}),
606 serde_json::json!({"text":"context", "options":{"triggerTurn":false, "deliverAs":"followUp"}}),
607 ] {
608 assert_eq!(send_message_options(&args).unwrap(), expected);
609 }
610 }
611
612 #[test]
613 fn options_reject_invalid_or_ambiguous_scheduling_instructions() {
614 for args in [
615 serde_json::json!({"triggerTurn":"false"}),
616 serde_json::json!({"deliverAs":"follow-up"}),
617 serde_json::json!({"options":{"typo":true}}),
618 serde_json::json!({"options":[], "text":"context"}),
619 serde_json::json!({"options":{"triggerTurn":true}, "triggerTurn":false}),
620 ] {
621 assert!(send_message_options(&args).is_err(), "{args}");
622 }
623 }
624
625 #[test]
626 fn pi_custom_input_preserves_content_and_display_metadata() {
627 let message = send_message_input(&serde_json::json!({"message":{
628 "customType":"goal", "content":"goal state", "display":true, "details":{"step":2}
629 }}))
630 .unwrap();
631 let rpi_agent::AgentMessage::Custom(custom) = &message else {
632 panic!("custom")
633 };
634 assert_eq!(
635 custom.content,
636 vec![rpi_ai::types::Content::text("goal state")]
637 );
638 let data = rpi_harness::messages::custom_data(&message).unwrap();
639 assert_eq!(data.custom_type, "goal");
640 assert!(data.display);
641 assert_eq!(data.details, Some(serde_json::json!({"step":2})));
642 let stored = serde_json::to_value(&message).unwrap();
644 assert_eq!(
645 send_message_input(&serde_json::json!({"message":stored})).unwrap(),
646 message
647 );
648 }
649
650 #[test]
651 fn missing_custom_content_is_empty_and_queued_results_use_pi_delivery_names() {
652 let message = send_message_input(
653 &serde_json::json!({"message":{"customType":"goal", "content":null}}),
654 )
655 .unwrap();
656 assert!(
657 matches!(message, rpi_agent::AgentMessage::Custom(custom) if custom.content.is_empty())
658 );
659 assert_eq!(
660 send_message_result(SendMessageResult::Queued {
661 entry_id: "reserved".into(),
662 queue: rpi_harness::session::types::QueueKind::NextRun,
663 }),
664 serde_json::json!({"status":"queued", "entryId":"reserved", "deliverAs":"nextTurn"})
665 );
666 }
667}