1use std::collections::BTreeMap;
41use std::path::PathBuf;
42use std::sync::{Arc, OnceLock, RwLock};
43
44use rpi_ai::types::ThinkingLevel;
45use rpi_extensions::RuntimeActionHost;
46use rpi_harness::agent_harness::{
47 AgentHarness, HarnessRunOutcome, NavigationOutcome, SendMessageResult, WeakAgentHarness,
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<WeakAgentHarness>>,
69 catalog: RwLock<Vec<rpi_ai::Model>>,
72 cwd: PathBuf,
75 cli_flags: BTreeMap<String, serde_json::Value>,
77 tool_context: rpi_extensions::ToolCallContext,
83 children: crate::child_agents::ChildAgents,
84 side_questions: crate::side_questions::SideQuestions,
85 #[allow(dead_code)]
86 runtime: Handle,
87}
88
89impl HarnessActionHost {
90 pub fn new_empty(
97 catalog: Vec<rpi_ai::Model>,
98 cwd: PathBuf,
99 runtime: Handle,
100 cli_flags: BTreeMap<String, serde_json::Value>,
101 tool_context: rpi_extensions::ToolCallContext,
102 ) -> (Self, Arc<OnceLock<WeakAgentHarness>>) {
103 let harness = Arc::new(OnceLock::new());
104 (
105 Self {
106 harness: Arc::clone(&harness),
107 catalog: RwLock::new(catalog),
108 cwd,
109 cli_flags,
110 tool_context,
111 children: crate::child_agents::ChildAgents::default(),
112 side_questions: crate::side_questions::SideQuestions::default(),
113 runtime,
114 },
115 harness,
116 )
117 }
118
119 pub fn set_catalog(&self, catalog: Vec<rpi_ai::Model>) {
120 *self
121 .catalog
122 .write()
123 .unwrap_or_else(|error| error.into_inner()) = catalog;
124 }
125
126 pub fn set_harness(cell: &Arc<OnceLock<WeakAgentHarness>>, harness: Arc<AgentHarness>) {
131 let _ = cell.set(harness.downgrade());
132 }
133
134 fn harness(&self) -> Result<AgentHarness, String> {
137 self.harness
138 .get()
139 .ok_or_else(|| "runtime action invoked before harness was built".to_string())?
140 .upgrade()
141 .ok_or_else(|| "runtime action invoked after harness was dropped".to_string())
142 }
143
144 fn resolve_model(&self, id: &str) -> Option<rpi_ai::Model> {
147 let catalog = self.catalog.read().ok()?;
148 catalog
149 .iter()
150 .find(|m| m.id.eq_ignore_ascii_case(id))
151 .cloned()
152 .or_else(|| {
153 catalog
154 .iter()
155 .find(|m| m.id.to_ascii_lowercase().contains(&id.to_ascii_lowercase()))
156 .cloned()
157 })
158 }
159}
160
161fn arg_str(args: &serde_json::Value, key: &str) -> Result<String, String> {
163 args.get(key)
164 .and_then(|v| v.as_str())
165 .map(|s| s.to_string())
166 .ok_or_else(|| format!("missing string field `{key}` in action args"))
167}
168
169fn arg_str_opt(args: &serde_json::Value, key: &str) -> Option<String> {
171 args.get(key)
172 .and_then(|v| v.as_str())
173 .map(|s| s.to_string())
174}
175
176fn arg_bool(args: &serde_json::Value, key: &str) -> bool {
178 args.get(key).and_then(|v| v.as_bool()).unwrap_or(false)
179}
180
181fn arg_str_array(args: &serde_json::Value, key: &str) -> Result<Vec<String>, String> {
183 args.get(key)
184 .and_then(|v| v.as_array())
185 .map(|arr| {
186 arr.iter()
187 .filter_map(|v| v.as_str().map(|s| s.to_string()))
188 .collect()
189 })
190 .ok_or_else(|| format!("missing string-array field `{key}` in action args"))
191}
192
193pub(crate) fn run_outcome_json(outcome: HarnessRunOutcome) -> serde_json::Value {
197 match outcome {
198 HarnessRunOutcome::Completed {
199 leaf_id,
200 final_entry_id,
201 final_message,
202 } => {
203 serde_json::json!({
204 "status": "completed",
205 "leafId": leaf_id,
206 "finalEntryId": final_entry_id,
207 "text": assistant_text(&final_message),
208 })
209 }
210 HarnessRunOutcome::Aborted {
211 leaf_id,
212 final_entry_id,
213 final_message,
214 } => {
215 serde_json::json!({
216 "status": "aborted",
217 "leafId": leaf_id,
218 "finalEntryId": final_entry_id,
219 "text": assistant_text(&final_message),
220 })
221 }
222 HarnessRunOutcome::Failed {
223 leaf_id,
224 error,
225 final_entry_id,
226 final_message,
227 } => {
228 serde_json::json!({
229 "status": "failed",
230 "leafId": leaf_id,
231 "error": format!("{error:?}"),
232 "finalEntryId": final_entry_id,
233 "text": final_message.map(|m| assistant_text(&m)).unwrap_or_default(),
234 })
235 }
236 HarnessRunOutcome::Suspended {
237 leaf_id,
238 final_entry_id,
239 ..
240 } => {
241 serde_json::json!({
242 "status": "suspended",
243 "leafId": leaf_id,
244 "finalEntryId": final_entry_id,
245 })
246 }
247 }
248}
249
250fn send_message_options(args: &serde_json::Value) -> Result<SendMessageOptions, String> {
253 let flat = args.get("triggerTurn").is_some() || args.get("deliverAs").is_some();
254 let value = if let Some(options) = args.get("options").filter(|value| !value.is_null()) {
255 if !options.is_object() {
256 return Err("send_message options must be an object".into());
257 }
258 if flat {
259 return Err(
260 "put triggerTurn/deliverAs either in options or at the top level, not both".into(),
261 );
262 }
263 options.clone()
264 } else {
265 let mut fields = serde_json::Map::new();
266 for key in ["triggerTurn", "deliverAs"] {
267 if let Some(value) = args.get(key) {
268 fields.insert(key.into(), value.clone());
269 }
270 }
271 serde_json::Value::Object(fields)
272 };
273 serde_json::from_value(value).map_err(|error| format!("invalid send_message options: {error}"))
274}
275
276fn send_message_input(args: &serde_json::Value) -> Result<rpi_agent::AgentMessage, String> {
277 let timestamp = std::time::SystemTime::now()
278 .duration_since(std::time::UNIX_EPOCH)
279 .unwrap_or_default()
280 .as_millis()
281 .min(i64::MAX as u128) as i64;
282 if let Some(message) = args.get("message") {
283 if message.get("kind").is_some() {
284 return serde_json::from_value(message.clone())
285 .map_err(|error| format!("invalid message: {error}"));
286 }
287 #[derive(serde::Deserialize)]
290 #[serde(rename_all = "camelCase")]
291 struct CustomInput {
292 custom_type: String,
293 #[serde(default)]
294 content: Option<rpi_ai::types::UserContent>,
295 #[serde(default)]
296 display: bool,
297 #[serde(default)]
298 details: Option<serde_json::Value>,
299 }
300 let input: CustomInput = serde_json::from_value(message.clone())
301 .map_err(|error| format!("invalid custom message: {error}"))?;
302 return Ok(rpi_harness::messages::create_custom_message(
303 &input.custom_type,
304 input
305 .content
306 .unwrap_or(rpi_ai::types::UserContent::Blocks(Vec::new())),
307 input.display,
308 input.details,
309 timestamp,
310 ));
311 }
312 let text =
313 arg_str(args, "text").map_err(|_| "missing `message` or `text` field".to_string())?;
314 Ok(rpi_agent::AgentMessage::User(
315 rpi_ai::types::UserMessage::new(text, timestamp),
316 ))
317}
318
319fn send_message_result(result: SendMessageResult) -> serde_json::Value {
320 match result {
321 SendMessageResult::Appended { entry_id } => {
322 serde_json::json!({"status":"appended", "entryId":entry_id})
323 }
324 SendMessageResult::Queued { entry_id, queue } => {
325 if queue == rpi_harness::session::types::QueueKind::Context {
326 return serde_json::json!({"status":"queued", "entryId":entry_id, "queue":"context"});
327 }
328 let delivery = match queue {
329 rpi_harness::session::types::QueueKind::NextRun => "nextTurn",
330 _ => queue.as_str(),
331 };
332 serde_json::json!({"status":"queued", "entryId":entry_id, "deliverAs":delivery})
333 }
334 SendMessageResult::Run(run) => run_outcome_json(run.outcome),
335 }
336}
337
338pub(crate) fn assistant_text(msg: &rpi_ai::types::AssistantMessage) -> String {
340 msg.content
341 .iter()
342 .filter_map(|b| match b {
343 rpi_ai::types::Content::Text(t) => Some(t.text.as_str()),
344 _ => None,
345 })
346 .collect::<Vec<_>>()
347 .join("")
348}
349
350#[async_trait::async_trait]
351impl RuntimeActionHost for HarnessActionHost {
352 fn cancel_side_questions(&self, owner: u64) {
353 self.side_questions.cancel_owner(owner);
354 }
355 fn side_question_revision(&self) -> u64 {
356 self.side_questions.revision()
357 }
358 fn side_question_views(&self) -> Vec<serde_json::Value> {
359 self.side_questions.views()
360 }
361 async fn side_question_ui(
362 &self,
363 owner: u64,
364 thread_id: String,
365 args: serde_json::Value,
366 ) -> Result<serde_json::Value, String> {
367 self.side_questions
368 .ui_handle(&self.harness()?, owner, thread_id, args)
369 .await
370 }
371 async fn side_question(
372 &self,
373 owner: u64,
374 args: serde_json::Value,
375 sink: rpi_extensions::SideQuestionSink,
376 ) -> Result<serde_json::Value, String> {
377 self.side_questions
378 .handle(&self.harness()?, owner, args, sink)
379 .await
380 }
381
382 fn cancel_child_agents(&self, owner: u64) {
383 self.children.cancel_owner(owner);
384 }
385 fn replace_model_catalog(&self, models: Vec<rpi_ai::Model>) {
386 self.set_catalog(models);
387 }
388 async fn get_execution_context(
389 &self,
390 _args: serde_json::Value,
391 ) -> Result<serde_json::Value, String> {
392 let harness = self.harness()?;
393 let mut context = harness.execution_context().map_err(|e| e.to_string())?;
394 if let Some(serde_json::Value::Object(child)) =
395 rpi_agent::tool_execution_scope::execution_context()
396 {
397 context
398 .as_object_mut()
399 .expect("execution context is an object")
400 .extend(child);
401 }
402 context["cwd"] = serde_json::json!(self.cwd);
403 context["sessionId"] = serde_json::json!(harness.session().storage().metadata().id);
404 Ok(context)
405 }
406
407 async fn child_agent(
408 &self,
409 owner: u64,
410 args: serde_json::Value,
411 ) -> Result<serde_json::Value, String> {
412 if rpi_agent::tool_execution_scope::is_child_execution() {
413 return Err("recursive child agents are not allowed".into());
414 }
415 if args.get("op").and_then(serde_json::Value::as_str) == Some("start") {
416 self.children.handle(&self.harness()?, owner, args).await
417 } else {
418 self.children.handle_existing(owner, args)
420 }
421 }
422
423 async fn get_cli_flag(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
424 let name = arg_str(&args, "name")?;
425 Ok(serde_json::json!({
426 "value": self.cli_flags.get(&name).cloned().unwrap_or(serde_json::Value::Null)
427 }))
428 }
429
430 async fn send_message(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
431 let options = send_message_options(&args)?;
432 let message = send_message_input(&args)?;
433 let lane = self.harness()?.lane("main");
434 let result = lane
435 .send_message(message, options)
436 .await
437 .map_err(|e| e.to_string())?;
438 Ok(send_message_result(result))
439 }
440
441 async fn send_user_message(
442 &self,
443 args: serde_json::Value,
444 ) -> Result<serde_json::Value, String> {
445 let text = arg_str(&args, "text")?;
446 let lane = self.harness()?.lane("main");
447 let result = lane
448 .prompt_text(&text, Vec::new())
449 .await
450 .map_err(|e| e.to_string())?;
451 Ok(run_outcome_json(result.outcome))
452 }
453
454 async fn append_entry(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
455 if let Some(custom_type) = arg_str_opt(&args, "customType") {
461 let data = args.get("data").cloned();
462 let id = self
463 .harness()?
464 .session()
465 .append_custom_entry(&custom_type, data)
466 .await
467 .map_err(|e| e.to_string())?;
468 return Ok(serde_json::json!({ "entryId": id }));
469 }
470 let msg = args
471 .get("message")
472 .ok_or_else(|| "missing `message` or `customType` field".to_string())?;
473 let message: rpi_agent::AgentMessage =
474 serde_json::from_value(msg.clone()).map_err(|e| format!("invalid message: {e}"))?;
475 let id = self
476 .harness()?
477 .append_message(message)
478 .await
479 .map_err(|e| e.to_string())?;
480 Ok(serde_json::json!({ "entryId": id }))
481 }
482
483 async fn get_session_branch(
484 &self,
485 _args: serde_json::Value,
486 ) -> Result<serde_json::Value, String> {
487 let entries = self
488 .harness()?
489 .find_entries_on_branch(
490 &EntryQuery {
491 order: Some(EntryOrder::OldestFirst),
492 ..Default::default()
493 },
494 &BranchBounds::default(),
495 )
496 .await
497 .map_err(|e| e.to_string())?;
498 let entries = entries
499 .iter()
500 .map(serde_json::to_value)
501 .collect::<Result<Vec<_>, _>>()
502 .map_err(|e| format!("serialize session branch: {e}"))?;
503 Ok(serde_json::json!({"entries": entries}))
504 }
505
506 async fn set_session_name(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
507 let name = arg_str(&args, "name")?;
508 self.harness()?
509 .session()
510 .set_name(Some(&name))
511 .await
512 .map_err(|e| e.to_string())?;
513 Ok(serde_json::Value::Null)
514 }
515
516 async fn get_active_tools(
517 &self,
518 _args: serde_json::Value,
519 ) -> Result<serde_json::Value, String> {
520 let lane = self.harness()?.lane("main");
521 let tools = lane.get_active_tools().await.map_err(|e| e.to_string())?;
522 Ok(serde_json::json!({ "tools": tools }))
523 }
524
525 async fn set_active_tools(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
526 let tools = arg_str_array(&args, "tools")?;
527 let lane = self.harness()?.lane("main");
528 lane.set_active_tools(tools)
529 .await
530 .map_err(|e| e.to_string())?;
531 Ok(serde_json::Value::Null)
532 }
533
534 async fn set_model(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
535 let id = arg_str(&args, "model")?;
536 let model = self
537 .resolve_model(&id)
538 .ok_or_else(|| format!("model `{id}` not in catalog"))?;
539 let lane = self.harness()?.lane("main");
540 lane.set_model(model.clone())
541 .await
542 .map_err(|e| e.to_string())?;
543 Ok(serde_json::json!({ "model": model.id }))
544 }
545
546 async fn get_thinking_level(
547 &self,
548 _args: serde_json::Value,
549 ) -> Result<serde_json::Value, String> {
550 let lane = self.harness()?.lane("main");
551 let level = lane.get_thinking_level().await.map_err(|e| e.to_string())?;
552 Ok(serde_json::json!({ "level": level }))
553 }
554
555 async fn set_thinking_level(
556 &self,
557 args: serde_json::Value,
558 ) -> Result<serde_json::Value, String> {
559 let level_val = args
560 .get("level")
561 .ok_or_else(|| "missing `level` field".to_string())?;
562 let level: ThinkingLevel = if let Some(s) = level_val.as_str() {
563 serde_json::from_value(serde_json::Value::String(s.to_string()))
564 .map_err(|e| format!("invalid thinking level `{s}`: {e}"))?
565 } else {
566 serde_json::from_value(level_val.clone())
567 .map_err(|e| format!("invalid thinking level: {e}"))?
568 };
569 let lane = self.harness()?.lane("main");
570 lane.set_thinking_level(level)
571 .await
572 .map_err(|e| e.to_string())?;
573 Ok(serde_json::Value::Null)
574 }
575
576 async fn compact(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
577 let custom = arg_str_opt(&args, "customInstructions");
578 let lane = self.harness()?.lane("main");
579 let result = lane
580 .compact(custom.as_deref())
581 .await
582 .map_err(|e| e.to_string())?;
583 Ok(
584 serde_json::json!({ "runId": result.run_id, "outcome": format!("{:?}", result.outcome) }),
585 )
586 }
587
588 async fn get_system_prompt(
589 &self,
590 _args: serde_json::Value,
591 ) -> Result<serde_json::Value, String> {
592 let prompt = self
593 .harness()?
594 .get_system_prompt()
595 .await
596 .map_err(|e| e.to_string())?;
597 Ok(serde_json::json!({ "prompt": prompt }))
598 }
599
600 async fn new_session(&self, _args: serde_json::Value) -> Result<serde_json::Value, String> {
601 let harness = self.harness()?;
602 let cwd_str = self.cwd.to_string_lossy().to_string();
603 let dir = default_session_dir(&self.cwd);
604 std::fs::create_dir_all(&dir)
605 .map_err(|e| format!("create session dir {}: {e}", dir.display()))?;
606 let session = crate::session::create_jsonl_session(&dir, &cwd_str)
607 .await
608 .map_err(|e| format!("create session: {e}"))?;
609 let id = session.storage().metadata().id.clone();
610 crate::session::set_active_session(&harness, session, Some(&self.tool_context)).await?;
611 Ok(serde_json::json!({ "sessionId": id }))
612 }
613
614 async fn fork(&self, _args: serde_json::Value) -> Result<serde_json::Value, String> {
615 let harness = self.harness()?;
616 let cwd_str = self.cwd.to_string_lossy().to_string();
617 let new_session = crate::session::fork_session_storage(&harness, &cwd_str)
618 .await
619 .map_err(|e| format!("fork session: {e}"))?;
620 let id = new_session.storage().metadata().id.clone();
621 crate::session::set_active_session(&harness, new_session, Some(&self.tool_context)).await?;
622 Ok(serde_json::json!({ "sessionId": id }))
623 }
624
625 async fn navigate_tree(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
626 let target_id = arg_str_opt(&args, "targetId");
627 let summarize = arg_bool(&args, "summarize");
628 let custom = arg_str_opt(&args, "customInstructions");
629 let label = arg_str_opt(&args, "label");
630 let lane = self.harness()?.lane("main");
631 let result = lane
632 .navigate_tree(
633 target_id.as_deref(),
634 summarize,
635 custom.as_deref(),
636 label.as_deref(),
637 )
638 .await
639 .map_err(|e| e.to_string())?;
640 let status = match &result.outcome {
641 NavigationOutcome::Completed { .. } => "completed",
642 NavigationOutcome::Declined { .. } => "declined",
643 NavigationOutcome::Aborted { .. } => "aborted",
644 NavigationOutcome::Failed { .. } => "failed",
645 };
646 Ok(serde_json::json!({ "runId": result.run_id, "status": status }))
647 }
648
649 async fn switch_session(&self, args: serde_json::Value) -> Result<serde_json::Value, String> {
650 let harness = self.harness()?;
651 let id = arg_str(&args, "id")?;
652 let cwd_str = self.cwd.to_string_lossy().to_string();
653 let new_session: Session = open_session_by_id(&id, &cwd_str)
654 .await
655 .map_err(|e| e.to_string())?;
656 let new_id = new_session.storage().metadata().id.clone();
657 crate::session::set_active_session(&harness, new_session, Some(&self.tool_context)).await?;
658 Ok(serde_json::json!({ "sessionId": new_id }))
659 }
660
661 async fn reload(&self, _args: serde_json::Value) -> Result<serde_json::Value, String> {
662 Err("reload not configured (no /reload callback on this bridge)".to_string())
666 }
667}
668
669#[cfg(test)]
670mod lifetime_tests;
671
672#[cfg(test)]
673mod send_message_tests {
674 use super::*;
675 use rpi_harness::types::MessageDelivery;
676
677 #[test]
678 fn options_accept_flat_and_nested_forms_with_pi_defaults() {
679 assert_eq!(
680 send_message_options(&serde_json::json!({"text":"context"})).unwrap(),
681 SendMessageOptions::default()
682 );
683 let expected = SendMessageOptions {
684 trigger_turn: Some(false),
685 deliver_as: MessageDelivery::FollowUp,
686 };
687 for args in [
688 serde_json::json!({"text":"context", "triggerTurn":false, "deliverAs":"followUp"}),
689 serde_json::json!({"text":"context", "options":{"triggerTurn":false, "deliverAs":"followUp"}}),
690 ] {
691 assert_eq!(send_message_options(&args).unwrap(), expected);
692 }
693 }
694
695 #[test]
696 fn options_reject_invalid_or_ambiguous_scheduling_instructions() {
697 for args in [
698 serde_json::json!({"triggerTurn":"false"}),
699 serde_json::json!({"deliverAs":"follow-up"}),
700 serde_json::json!({"options":{"typo":true}}),
701 serde_json::json!({"options":[], "text":"context"}),
702 serde_json::json!({"options":{"triggerTurn":true}, "triggerTurn":false}),
703 ] {
704 assert!(send_message_options(&args).is_err(), "{args}");
705 }
706 }
707
708 #[test]
709 fn pi_custom_input_preserves_content_and_display_metadata() {
710 let message = send_message_input(&serde_json::json!({"message":{
711 "customType":"goal", "content":"goal state", "display":true, "details":{"step":2}
712 }}))
713 .unwrap();
714 let rpi_agent::AgentMessage::Custom(custom) = &message else {
715 panic!("custom")
716 };
717 assert_eq!(
718 custom.content,
719 vec![rpi_ai::types::Content::text("goal state")]
720 );
721 let data = rpi_harness::messages::custom_data(&message).unwrap();
722 assert_eq!(data.custom_type, "goal");
723 assert!(data.display);
724 assert_eq!(data.details, Some(serde_json::json!({"step":2})));
725 let stored = serde_json::to_value(&message).unwrap();
727 assert_eq!(
728 send_message_input(&serde_json::json!({"message":stored})).unwrap(),
729 message
730 );
731 }
732
733 #[test]
734 fn missing_custom_content_is_empty_and_queued_results_use_pi_delivery_names() {
735 let message = send_message_input(
736 &serde_json::json!({"message":{"customType":"goal", "content":null}}),
737 )
738 .unwrap();
739 assert!(
740 matches!(message, rpi_agent::AgentMessage::Custom(custom) if custom.content.is_empty())
741 );
742 assert_eq!(
743 send_message_result(SendMessageResult::Queued {
744 entry_id: "reserved".into(),
745 queue: rpi_harness::session::types::QueueKind::NextRun,
746 }),
747 serde_json::json!({"status":"queued", "entryId":"reserved", "deliverAs":"nextTurn"})
748 );
749 }
750}