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