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