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