diff --git a/Cargo.lock b/Cargo.lock index 75a2d45..7d7d568 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -194,7 +194,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -205,7 +205,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2313,7 +2313,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2576,7 +2576,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3808,7 +3808,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.57.0", + "windows-core 0.61.2", ] [[package]] @@ -4955,7 +4955,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5892,7 +5892,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6774,7 +6774,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6861,7 +6861,7 @@ dependencies = [ [[package]] name = "rx4" version = "0.6.5" -source = "git+https://github.com/tschk/rotary.git?rev=3aab31e794a49f9d2ae399232441439ae3f35823#3aab31e794a49f9d2ae399232441439ae3f35823" +source = "git+https://github.com/tschk/rotary.git?rev=74698cfb8dc4654336f4710fe9d1a9a14a340699#74698cfb8dc4654336f4710fe9d1a9a14a340699" dependencies = [ "async-trait", "cancellation-token", @@ -7472,7 +7472,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -7522,7 +7522,7 @@ dependencies = [ "cfg-if", "libc", "psm", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -8080,7 +8080,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -8753,7 +8753,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index a81a27f..189fafc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -102,7 +102,7 @@ boxlite = [] # Deliberately off: `builtin-tools` (apollo registers its own tools; also # pulls in the fff/git2/notify stack), `mcp` (apollo has src/mcp_server.rs), # `zkr-memory` (gates rx4's self_improve; apollo depends on zkr directly). -rx4 = { git = "https://github.com/tschk/rotary.git", rev = "3aab31e794a49f9d2ae399232441439ae3f35823", default-features = false, features = ["providers", "skills", "graph-memory"] } +rx4 = { git = "https://github.com/tschk/rotary.git", rev = "74698cfb8dc4654336f4710fe9d1a9a14a340699", default-features = false, features = ["providers", "skills", "graph-memory"] } # Async runtime (optimized features only) tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "io-util", "sync", "time", "fs", "signal", "process", "io-std"] } diff --git a/src/agent/mod.rs b/src/agent/mod.rs index def6b18..dd20113 100644 --- a/src/agent/mod.rs +++ b/src/agent/mod.rs @@ -20,8 +20,10 @@ pub use build_runner::{ pub use loop_runner::AgentRunner; pub use mode::{agent_mode_from_permission_profile, AgentMode, NullChannel}; pub use rotary_bridge::{ - apply_recorded_steps, build_rx4_skill_engine, chat_message_to_rx4, record_rx4_event, - register_apollo_tools, runtime_pty_worker, rx4_message_to_chat, RotaryAgentBridge, - RotaryBridgeConfig, RotaryProviderAdapter, Rx4TrajectoryRecorder, ToolHookContext, + apply_recorded_steps, build_rx4_skill_engine, chat_message_to_rx4, record_failure_notice, + record_recovery_action, record_recovery_kind, record_rx4_event, record_rx4_event_value, + record_spill_notice, record_tool_spill, register_apollo_tools, runtime_pty_worker, + rx4_message_to_chat, RotaryAgentBridge, RotaryBridgeConfig, RotaryProviderAdapter, + Rx4TrajectoryRecorder, ToolHookContext, }; pub use streaming::{stream_channel, StreamChunk, StreamReceiver, StreamSender}; diff --git a/src/agent/rotary_bridge.rs b/src/agent/rotary_bridge.rs index 7c6ce37..93ba185 100644 --- a/src/agent/rotary_bridge.rs +++ b/src/agent/rotary_bridge.rs @@ -20,6 +20,7 @@ use std::sync::Arc; use rx4::provider::{ Message, Provider as Rx4Provider, ProviderError as Rx4ProviderError, Role, StreamEvent, }; +use rx4::{RecoveryAction, RecoveryKind, SpillStatus}; use crate::agent::hooks::{run_post_hooks, run_pre_hooks, HookDecision, ToolHook}; use crate::agent::stream::{emit, AgentStreamEvent, AgentStreamTx}; @@ -207,7 +208,6 @@ pub fn record_rx4_event(recorder: &mut Rx4TrajectoryRecorder, event: &rx4::Event .pending .take() .unwrap_or_else(|| ("tool".to_string(), String::new())); - let spill = spill_locator(&result.content).map(str::to_string); recorder.steps.push(TrajectoryStep { step: recorder.steps.len() + 1, thought: None, @@ -217,16 +217,10 @@ pub fn record_rx4_event(recorder: &mut Rx4TrajectoryRecorder, event: &rx4::Event response: None, success: !result.is_error, }); - if let Some(locator) = spill { - recorder.steps.push(TrajectoryStep { - step: recorder.steps.len() + 1, - thought: None, - action: Some("spill".to_string()), - action_args: None, - observation: Some(locator), - response: None, - success: true, - }); + if result.spill.is_none() { + if let Some(locator) = spill_locator(&result.content) { + record_spill_notice(recorder, locator); + } } } rx4::Event::GuardrailWarning { tool, reason } @@ -277,6 +271,44 @@ pub fn record_rx4_event(recorder: &mut Rx4TrajectoryRecorder, event: &rx4::Event success: false, }); } + rx4::Event::Recovery { action, reason } => { + record_recovery_kind(recorder, *action, reason); + } + rx4::Event::ToolSpill { + status, + locator, + original_bytes, + } => { + record_tool_spill(recorder, *status, locator, *original_bytes); + } + rx4::Event::ProcessStart { + process_id, + program, + } => { + recorder.steps.push(TrajectoryStep { + step: recorder.steps.len() + 1, + thought: None, + action: Some("process_start".to_string()), + action_args: Some(process_id.clone()), + observation: Some(program.clone()), + response: None, + success: true, + }); + } + rx4::Event::ProcessEnd { + process_id, + exit_code, + } => { + recorder.steps.push(TrajectoryStep { + step: recorder.steps.len() + 1, + thought: None, + action: Some("process_end".to_string()), + action_args: Some(process_id.clone()), + observation: Some(exit_code.map(|code| code.to_string()).unwrap_or_default()), + response: None, + success: !matches!(exit_code, Some(code) if *code != 0), + }); + } rx4::Event::ProcessStdin { process_id, bytes } => { recorder.steps.push(TrajectoryStep { step: recorder.steps.len() + 1, @@ -329,6 +361,289 @@ pub fn record_rx4_event(recorder: &mut Rx4TrajectoryRecorder, event: &rx4::Event } } +pub fn record_recovery_action( + recorder: &mut Rx4TrajectoryRecorder, + action: &RecoveryAction, + source: &str, +) { + let stuck = source == "stuck_tool"; + let (name, args, observation, success) = match action { + RecoveryAction::Prefill(text) => { + if stuck { + ( + "stuck_tool", + Some("prefill".to_string()), + Some(text.clone()), + true, + ) + } else { + ( + "prefill", + Some(source.to_string()), + Some(text.clone()), + true, + ) + } + } + RecoveryAction::Nudge(text) => { + if stuck { + ( + "stuck_tool", + Some("nudge".to_string()), + Some(text.clone()), + true, + ) + } else { + ("nudge", Some(source.to_string()), Some(text.clone()), true) + } + } + RecoveryAction::Retry => { + if stuck { + ("stuck_tool", Some("retry".to_string()), None, false) + } else { + ("retry", Some(source.to_string()), None, false) + } + } + RecoveryAction::Halt(reason) => { + if stuck { + ( + "stuck_tool", + Some("halt".to_string()), + Some(reason.clone()), + false, + ) + } else { + ( + "halt", + Some(source.to_string()), + Some(reason.clone()), + false, + ) + } + } + }; + recorder.steps.push(TrajectoryStep { + step: recorder.steps.len() + 1, + thought: None, + action: Some(name.to_string()), + action_args: args, + observation, + response: None, + success, + }); +} + +pub fn record_recovery_kind( + recorder: &mut Rx4TrajectoryRecorder, + action: RecoveryKind, + reason: &str, +) { + let (name, success) = match action { + RecoveryKind::Prefill => ("prefill", true), + RecoveryKind::Nudge => ("nudge", true), + RecoveryKind::Retry => ("retry", false), + RecoveryKind::Halt => ("halt", false), + }; + recorder.steps.push(TrajectoryStep { + step: recorder.steps.len() + 1, + thought: None, + action: Some(name.to_string()), + action_args: None, + observation: (!reason.is_empty()).then(|| reason.to_string()), + response: None, + success, + }); +} + +pub fn record_tool_spill( + recorder: &mut Rx4TrajectoryRecorder, + status: SpillStatus, + locator: impl Into, + original_bytes: usize, +) { + let (args, success) = match status { + SpillStatus::Inline => ("inline", true), + SpillStatus::Spilled => ("spilled", true), + SpillStatus::SpillFailed => ("spill_failed", false), + }; + recorder.steps.push(TrajectoryStep { + step: recorder.steps.len() + 1, + thought: None, + action: Some("spill".to_string()), + action_args: Some(format!("{args}:{original_bytes}")), + observation: Some(locator.into()), + response: None, + success, + }); +} + +pub fn record_spill_notice(recorder: &mut Rx4TrajectoryRecorder, locator: impl Into) { + recorder.steps.push(TrajectoryStep { + step: recorder.steps.len() + 1, + thought: None, + action: Some("spill".to_string()), + action_args: None, + observation: Some(locator.into()), + response: None, + success: true, + }); +} + +pub fn record_failure_notice(recorder: &mut Rx4TrajectoryRecorder, message: impl Into) { + recorder.steps.push(TrajectoryStep { + step: recorder.steps.len() + 1, + thought: None, + action: Some("failure".to_string()), + action_args: None, + observation: Some(message.into()), + response: None, + success: false, + }); +} + +pub fn record_rx4_event_value(recorder: &mut Rx4TrajectoryRecorder, value: &serde_json::Value) { + let Some(ty) = value.get("type").and_then(|v| v.as_str()) else { + return; + }; + match ty { + "Prefill" => { + record_recovery_action( + recorder, + &RecoveryAction::Prefill(json_text(value, &["text", "message"])), + recovery_source(value), + ); + } + "Nudge" => { + record_recovery_action( + recorder, + &RecoveryAction::Nudge(json_text(value, &["text", "message"])), + recovery_source(value), + ); + } + "StuckTool" | "StuckToolRecovery" => { + record_recovery_action(recorder, &parse_recovery_action_field(value), "stuck_tool"); + } + "Recovery" => { + let reason = json_text(value, &["reason", "text", "message"]); + if let Some(kind) = value + .get("action") + .and_then(|v| v.as_str()) + .and_then(recovery_kind_from_name) + { + record_recovery_kind(recorder, kind, &reason); + } else { + record_recovery_action( + recorder, + &parse_recovery_action_field(value), + recovery_source(value), + ); + } + } + "Spill" | "SpillNotice" | "ToolSpill" => { + if let Some(status) = value.get("status").and_then(|v| v.as_str()) { + let status = match status { + "inline" => SpillStatus::Inline, + "spill_failed" => SpillStatus::SpillFailed, + _ => SpillStatus::Spilled, + }; + let original_bytes = value + .get("original_bytes") + .and_then(|v| v.as_u64()) + .unwrap_or(0) as usize; + record_tool_spill( + recorder, + status, + json_text(value, &["locator", "path", "observation"]), + original_bytes, + ); + } else { + record_spill_notice( + recorder, + json_text(value, &["locator", "path", "observation"]), + ); + } + } + "Failure" | "FailureNotice" | "ToolFailure" => { + record_failure_notice(recorder, json_text(value, &["message", "reason", "error"])); + } + _ => {} + } +} + +fn recovery_source(value: &serde_json::Value) -> &'static str { + let source = value + .get("source") + .and_then(|v| v.as_str()) + .or_else(|| value.get("kind").and_then(|v| v.as_str())) + .unwrap_or("empty_turn"); + if source.eq_ignore_ascii_case("stuck_tool") || source.eq_ignore_ascii_case("stuck-tool") { + "stuck_tool" + } else { + "empty_turn" + } +} + +fn json_text(value: &serde_json::Value, keys: &[&str]) -> String { + keys.iter() + .find_map(|key| value.get(*key).and_then(|v| v.as_str())) + .unwrap_or("") + .to_string() +} + +fn parse_recovery_action_field(value: &serde_json::Value) -> RecoveryAction { + let text = json_text(value, &["text", "reason", "message"]); + if let Some(action) = value.get("action") { + if let Some(name) = action.as_str() { + return recovery_action_from_name(name, text); + } + if let Some(obj) = action.as_object() { + if let Some(prefill) = obj.get("Prefill").and_then(|v| v.as_str()) { + return RecoveryAction::Prefill(prefill.to_string()); + } + if let Some(nudge) = obj.get("Nudge").and_then(|v| v.as_str()) { + return RecoveryAction::Nudge(nudge.to_string()); + } + if obj.contains_key("Retry") { + return RecoveryAction::Retry; + } + if let Some(halt) = obj.get("Halt").and_then(|v| v.as_str()) { + return RecoveryAction::Halt(halt.to_string()); + } + if let Some(ty) = obj.get("type").and_then(|v| v.as_str()) { + let inner = json_text(&serde_json::Value::Object(obj.clone()), &["text", "reason"]); + return recovery_action_from_name(ty, inner); + } + } + } + recovery_action_from_name( + value + .get("recovery") + .and_then(|v| v.as_str()) + .unwrap_or("Nudge"), + text, + ) +} + +fn recovery_action_from_name(name: &str, text: String) -> RecoveryAction { + match name { + "Prefill" | "prefill" => RecoveryAction::Prefill(text), + "Nudge" | "nudge" => RecoveryAction::Nudge(text), + "Retry" | "retry" => RecoveryAction::Retry, + "Halt" | "halt" => RecoveryAction::Halt(text), + _ => RecoveryAction::Nudge(text), + } +} + +fn recovery_kind_from_name(name: &str) -> Option { + match name { + "Prefill" | "prefill" => Some(RecoveryKind::Prefill), + "Nudge" | "nudge" => Some(RecoveryKind::Nudge), + "Retry" | "retry" => Some(RecoveryKind::Retry), + "Halt" | "halt" => Some(RecoveryKind::Halt), + _ => None, + } +} + fn spill_locator(content: &str) -> Option<&str> { let marker = "[truncated, full output at "; let start = content.find(marker)? + marker.len(); @@ -596,6 +911,7 @@ pub fn register_apollo_tools( content: result.output, is_error: result.is_error, error_kind: None, + spill: None, } }) }); @@ -1258,6 +1574,7 @@ mod tests { content: "hello".into(), is_error: false, error_kind: None, + spill: None, })); recorder.on_event(&rx4::Event::GuardrailStop { tool: "exec".into(), @@ -1298,4 +1615,188 @@ mod tests { assert_eq!(steps[5].action.as_deref(), Some("patch")); assert_eq!(steps[6].action.as_deref(), Some("recovery")); } + + #[test] + fn recovery_actions_become_trajectory_steps() { + let mut recorder = Rx4TrajectoryRecorder::default(); + record_recovery_action(&mut recorder, &rx4::recover_empty_turn(0, 3), "empty_turn"); + record_recovery_action(&mut recorder, &rx4::recover_empty_turn(1, 3), "empty_turn"); + record_recovery_action(&mut recorder, &RecoveryAction::Retry, "empty_turn"); + record_recovery_action(&mut recorder, &rx4::recover_empty_turn(2, 3), "empty_turn"); + record_recovery_action(&mut recorder, &rx4::recover_stuck_tool(0, 3), "stuck_tool"); + record_recovery_action(&mut recorder, &RecoveryAction::Retry, "stuck_tool"); + record_recovery_action(&mut recorder, &rx4::recover_stuck_tool(2, 3), "stuck_tool"); + let (steps, _) = recorder.take_steps(); + let actions: Vec<_> = steps + .iter() + .map(|step| { + ( + step.action.as_deref(), + step.action_args.as_deref(), + step.success, + ) + }) + .collect(); + assert_eq!( + actions, + [ + (Some("prefill"), Some("empty_turn"), true), + (Some("nudge"), Some("empty_turn"), true), + (Some("retry"), Some("empty_turn"), false), + (Some("halt"), Some("empty_turn"), false), + (Some("stuck_tool"), Some("nudge"), true), + (Some("stuck_tool"), Some("retry"), false), + (Some("stuck_tool"), Some("halt"), false), + ] + ); + } + + #[test] + fn typed_recovery_spill_and_process_events_become_trajectory_steps() { + let mut recorder = Rx4TrajectoryRecorder::default(); + recorder.on_event(&rx4::Event::Recovery { + action: RecoveryKind::Prefill, + reason: "Continue from where you left off.".into(), + }); + recorder.on_event(&rx4::Event::Recovery { + action: RecoveryKind::Nudge, + reason: "Your last turn was empty.".into(), + }); + recorder.on_event(&rx4::Event::Recovery { + action: RecoveryKind::Retry, + reason: String::new(), + }); + recorder.on_event(&rx4::Event::Recovery { + action: RecoveryKind::Halt, + reason: "empty turn limit reached (2/3)".into(), + }); + recorder.on_event(&rx4::Event::ToolSpill { + status: SpillStatus::Spilled, + locator: "file://spill.txt".into(), + original_bytes: 20_000, + }); + recorder.on_event(&rx4::Event::ToolSpill { + status: SpillStatus::SpillFailed, + locator: String::new(), + original_bytes: 20, + }); + recorder.on_event(&rx4::Event::ProcessStart { + process_id: "p1".into(), + program: "cat".into(), + }); + recorder.on_event(&rx4::Event::ProcessEnd { + process_id: "p1".into(), + exit_code: Some(0), + }); + recorder.on_event(&rx4::Event::ProcessEnd { + process_id: "p2".into(), + exit_code: Some(1), + }); + let (steps, _) = recorder.take_steps(); + let actions: Vec<_> = steps + .iter() + .map(|step| { + ( + step.action.as_deref(), + step.action_args.as_deref(), + step.observation.as_deref(), + step.success, + ) + }) + .collect(); + assert_eq!( + actions, + [ + ( + Some("prefill"), + None, + Some("Continue from where you left off."), + true + ), + (Some("nudge"), None, Some("Your last turn was empty."), true), + (Some("retry"), None, None, false), + ( + Some("halt"), + None, + Some("empty turn limit reached (2/3)"), + false + ), + ( + Some("spill"), + Some("spilled:20000"), + Some("file://spill.txt"), + true + ), + (Some("spill"), Some("spill_failed:20"), Some(""), false), + (Some("process_start"), Some("p1"), Some("cat"), true), + (Some("process_end"), Some("p1"), Some("0"), true), + (Some("process_end"), Some("p2"), Some("1"), false), + ] + ); + } + + #[test] + fn forthcoming_typed_event_values_become_trajectory_steps() { + let mut recorder = Rx4TrajectoryRecorder::default(); + record_rx4_event_value( + &mut recorder, + &serde_json::json!({"type": "Prefill", "text": "continue"}), + ); + record_rx4_event_value( + &mut recorder, + &serde_json::json!({"type": "Nudge", "text": "say something"}), + ); + record_rx4_event_value( + &mut recorder, + &serde_json::json!({ + "type": "StuckTool", + "action": "Nudge", + "text": "change args" + }), + ); + record_rx4_event_value( + &mut recorder, + &serde_json::json!({ + "type": "Recovery", + "action": "halt", + "reason": "empty turn limit reached" + }), + ); + record_rx4_event_value( + &mut recorder, + &serde_json::json!({"type": "Spill", "locator": "file://spill.txt"}), + ); + record_rx4_event_value( + &mut recorder, + &serde_json::json!({"type": "FailureNotice", "message": "tool blew up"}), + ); + let (steps, _) = recorder.take_steps(); + let actions: Vec<_> = steps + .iter() + .map(|step| { + ( + step.action.as_deref(), + step.action_args.as_deref(), + step.observation.as_deref(), + step.success, + ) + }) + .collect(); + assert_eq!( + actions, + [ + (Some("prefill"), Some("empty_turn"), Some("continue"), true), + ( + Some("nudge"), + Some("empty_turn"), + Some("say something"), + true + ), + (Some("stuck_tool"), Some("nudge"), Some("change args"), true), + (Some("halt"), None, Some("empty turn limit reached"), false), + (Some("spill"), None, Some("file://spill.txt"), true), + (Some("failure"), None, Some("tool blew up"), false), + ] + ); + } } diff --git a/tests/rx4_engine.rs b/tests/rx4_engine.rs index 787544b..11a8dda 100644 --- a/tests/rx4_engine.rs +++ b/tests/rx4_engine.rs @@ -17,8 +17,9 @@ use std::sync::{Arc, Mutex}; use apollo::agent::hooks::PermissionHook; use apollo::agent::mode::NullChannel; use apollo::agent::rotary_bridge::{ - record_rx4_event, runtime_pty_worker, RotaryAgentBridge, RotaryBridgeConfig, - Rx4TrajectoryRecorder, ToolHookContext, + record_recovery_action, record_rx4_event, record_rx4_event_value, record_spill_notice, + runtime_pty_worker, RotaryAgentBridge, RotaryBridgeConfig, Rx4TrajectoryRecorder, + ToolHookContext, }; use apollo::agent::AgentRunner; use apollo::channels::IncomingMessage; @@ -601,7 +602,7 @@ fn rx4_sandbox_escalate_records_retry_and_stays_fail_closed() { fn rx4_spilled_tool_result_records_a_spill_step() { let dir = tempfile::tempdir().unwrap(); let body = "x".repeat(20_000); - let spilled = rx4::tools::spill::bound_tool_output(&body, 1024, dir.path()).unwrap(); + let spilled = rx4::tools::spill::bound_tool_output(&body, 1024, dir.path()); assert!(spilled.spilled); assert!(rx4::tools::spill::locator_is_file(&spilled.locator)); @@ -614,13 +615,22 @@ fn rx4_spilled_tool_result_records_a_spill_step() { arguments: "{}".into(), }), ); + record_rx4_event( + &mut recorder, + &rx4::Event::ToolSpill { + status: spilled.status, + locator: spilled.locator.clone(), + original_bytes: spilled.original_bytes, + }, + ); record_rx4_event( &mut recorder, &rx4::Event::ToolExecutionEnd(rx4::ToolResult { id: "c-spill".into(), - content: spilled.preview, + content: spilled.preview.clone(), is_error: false, error_kind: None, + spill: Some(spilled.notice()), }), ); let (steps, _) = recorder.take_steps(); @@ -637,3 +647,121 @@ fn rx4_spilled_tool_result_records_a_spill_step() { .expect("spill step missing"); assert_eq!(spill.observation.as_deref(), Some(spilled.locator.as_str())); } + +#[test] +fn rx4_recovery_actions_are_recorded_on_the_trajectory() { + let mut recorder = Rx4TrajectoryRecorder::default(); + record_recovery_action(&mut recorder, &rx4::recover_empty_turn(0, 3), "empty_turn"); + record_recovery_action(&mut recorder, &rx4::recover_empty_turn(1, 3), "empty_turn"); + record_recovery_action(&mut recorder, &rx4::recover_stuck_tool(0, 3), "stuck_tool"); + record_recovery_action(&mut recorder, &rx4::RecoveryAction::Retry, "stuck_tool"); + let (steps, _) = recorder.take_steps(); + let actions: Vec<_> = steps + .iter() + .filter_map(|step| step.action.as_deref()) + .collect(); + assert_eq!(actions, ["prefill", "nudge", "stuck_tool", "stuck_tool"]); + assert_eq!(steps[2].action_args.as_deref(), Some("nudge")); + assert_eq!(steps[3].action_args.as_deref(), Some("retry")); +} + +#[test] +fn rx4_typed_notice_values_are_recorded_on_the_trajectory() { + let mut recorder = Rx4TrajectoryRecorder::default(); + record_rx4_event_value( + &mut recorder, + &serde_json::json!({"type": "Prefill", "text": "continue"}), + ); + record_rx4_event_value( + &mut recorder, + &serde_json::json!({"type": "Nudge", "text": "answer"}), + ); + record_rx4_event_value( + &mut recorder, + &serde_json::json!({ + "type": "StuckTool", + "action": "Halt", + "text": "stuck tool repeated 2 times (halt after 3)" + }), + ); + record_rx4_event_value( + &mut recorder, + &serde_json::json!({"type": "SpillNotice", "locator": ".rx4/spill/out.txt"}), + ); + record_rx4_event_value( + &mut recorder, + &serde_json::json!({"type": "FailureNotice", "message": "bounded write failed"}), + ); + record_spill_notice(&mut recorder, "file://already-mapped"); + let (steps, _) = recorder.take_steps(); + let actions: Vec<_> = steps + .iter() + .filter_map(|step| step.action.as_deref()) + .collect(); + assert_eq!( + actions, + [ + "prefill", + "nudge", + "stuck_tool", + "spill", + "failure", + "spill" + ] + ); + assert_eq!(steps[2].action_args.as_deref(), Some("halt")); + assert!(!steps[2].success); + assert_eq!(steps[3].observation.as_deref(), Some(".rx4/spill/out.txt")); +} + +#[test] +fn rx4_typed_recovery_spill_and_process_events_are_recorded() { + let mut recorder = Rx4TrajectoryRecorder::default(); + record_rx4_event( + &mut recorder, + &rx4::Event::Recovery { + action: rx4::RecoveryKind::Prefill, + reason: "Continue from where you left off.".into(), + }, + ); + record_rx4_event( + &mut recorder, + &rx4::Event::Recovery { + action: rx4::RecoveryKind::Nudge, + reason: "change args".into(), + }, + ); + record_rx4_event( + &mut recorder, + &rx4::Event::ToolSpill { + status: rx4::SpillStatus::Spilled, + locator: ".rx4/spill/out.txt".into(), + original_bytes: 2048, + }, + ); + record_rx4_event( + &mut recorder, + &rx4::Event::ProcessStart { + process_id: "p1".into(), + program: "cat".into(), + }, + ); + record_rx4_event( + &mut recorder, + &rx4::Event::ProcessEnd { + process_id: "p1".into(), + exit_code: Some(0), + }, + ); + let (steps, _) = recorder.take_steps(); + let actions: Vec<_> = steps + .iter() + .filter_map(|step| step.action.as_deref()) + .collect(); + assert_eq!( + actions, + ["prefill", "nudge", "spill", "process_start", "process_end"] + ); + assert_eq!(steps[2].action_args.as_deref(), Some("spilled:2048")); + assert_eq!(steps[2].observation.as_deref(), Some(".rx4/spill/out.txt")); +}