diff --git a/crates/persisting-gateway/src/projection/dialogue/block.rs b/crates/persisting-gateway/src/projection/dialogue/block.rs index 8ed40265..89047f2e 100644 --- a/crates/persisting-gateway/src/projection/dialogue/block.rs +++ b/crates/persisting-gateway/src/projection/dialogue/block.rs @@ -75,6 +75,9 @@ pub fn capture_record_to_storyline_turn(rec: &EventRecord) -> Result StorylineTurn { latency_ms: None, ttft_ms: None, extra: None, + env: None, + prompt: None, + finished_at: None, } } diff --git a/crates/persisting-pchronicle-cli/src/exchange.rs b/crates/persisting-pchronicle-cli/src/exchange.rs index fd368613..bb6cd0d4 100644 --- a/crates/persisting-pchronicle-cli/src/exchange.rs +++ b/crates/persisting-pchronicle-cli/src/exchange.rs @@ -52,14 +52,15 @@ pub(super) async fn run_import( .prefix(".pchronicle-import-") .tempdir_in(parent) .with_context(|| format!("create import staging directory in {}", parent.display()))?; - let (imported_sources, unknown_field_warnings) = match output_format { + let (imported_sources, unknown_field_warnings, skipped_warnings) = match output_format { ImportOutputFormat::Preserve => { let mut unknown_field_warnings = persisting_pchronicle::model::UnknownFieldImportWarnings::default(); let mut imported_sources = Vec::new(); + let mut skipped_warnings = Vec::new(); if args.stream { let input = read_bounded(stdin, max_input_bytes, "stdin")?; - imported_sources.push(stage_preserved_import_source( + if let Some(source) = stage_preserved_import_source( args.format, None, None, @@ -67,14 +68,17 @@ pub(super) async fn run_import( &input, staging.path(), &mut unknown_field_warnings, - )?); + &mut skipped_warnings, + )? { + imported_sources.push(source); + } } else { for candidate in &candidates { let label = format!("import source {}", candidate.relative_path.display()); let file = std::fs::File::open(&candidate.path) .with_context(|| format!("open {label}"))?; let input = read_bounded(file, max_input_bytes, &label)?; - imported_sources.push(stage_preserved_import_source( + if let Some(source) = stage_preserved_import_source( args.format, Some(&candidate.path), Some(&candidate.relative_path), @@ -82,10 +86,13 @@ pub(super) async fn run_import( &input, staging.path(), &mut unknown_field_warnings, - )?); + &mut skipped_warnings, + )? { + imported_sources.push(source); + } } } - (imported_sources, unknown_field_warnings) + (imported_sources, unknown_field_warnings, skipped_warnings) } ImportOutputFormat::Storyline => { let store = StorylineLanceStore::open(staging.path()) @@ -97,11 +104,15 @@ pub(super) async fn run_import( StorylineImportIterator::files(args.format, max_input_bytes, &candidates) }; let report = store.replace_storyline_stream(&mut import).await?; + let (imported_sources, unknown_field_warnings, skipped_warnings) = + import.into_result_parts(); + if imported_sources.is_empty() { + return Err(empty_auto_directory_import_error(directory_input)); + } anyhow::ensure!( store.current_table_paths().await?.is_some(), "squashed Storyline Lance Dataset has no committed snapshot" ); - let (imported_sources, unknown_field_warnings) = import.into_result_parts(); let imported_trajectories = imported_sources.iter().try_fold(0usize, |total, source| { total @@ -112,9 +123,12 @@ pub(super) async fn run_import( report.storylines == imported_trajectories, "squashed Storyline import report does not match decoded trajectory count" ); - (imported_sources, unknown_field_warnings) + (imported_sources, unknown_field_warnings, skipped_warnings) } }; + if imported_sources.is_empty() { + return Err(empty_auto_directory_import_error(directory_input)); + } let trajectories = imported_sources.iter().try_fold(0usize, |total, source| { total .checked_add(source.trajectories) @@ -185,6 +199,9 @@ pub(super) async fn run_import( ) .context("write pChronicle import metadata")?; } + for line in skipped_warnings { + writeln!(stderr, "{line}").context("write pChronicle skipped-source warning")?; + } for line in unknown_field_warnings.warning_lines() { writeln!(stderr, "{line}").context("write pChronicle unknown-field warning")?; } @@ -817,6 +834,16 @@ struct DecodedImportSource { storylines: Vec, } +enum DecodeImportOutcome { + Imported(DecodedImportSource), + Skipped { path: PathBuf, reason: String }, +} + +enum ImportFormatResolution { + Format(ExchangeFormat), + Skip(String), +} + enum StorylineImportInputs<'a> { Stdin(Option<&'a mut dyn Read>), Files { @@ -830,11 +857,10 @@ struct StorylineImportIterator<'a> { max_input_bytes: usize, inputs: StorylineImportInputs<'a>, current: std::vec::IntoIter, - current_diagnostic_path: Arc, imported_sources: Vec, unknown_field_warnings: persisting_pchronicle::model::UnknownFieldImportWarnings, - seen_document_ids: HashMap>, - seen_session_ids: HashMap>, + skipped_warnings: Vec, + seen_document_ids: HashSet, failed: bool, } @@ -849,12 +875,11 @@ impl<'a> StorylineImportIterator<'a> { max_input_bytes, inputs: StorylineImportInputs::Stdin(Some(stdin)), current: Vec::new().into_iter(), - current_diagnostic_path: Arc::from(PathBuf::new()), imported_sources: Vec::new(), unknown_field_warnings: persisting_pchronicle::model::UnknownFieldImportWarnings::default(), - seen_document_ids: HashMap::new(), - seen_session_ids: HashMap::new(), + skipped_warnings: Vec::new(), + seen_document_ids: HashSet::new(), failed: false, } } @@ -872,55 +897,61 @@ impl<'a> StorylineImportIterator<'a> { next: 0, }, current: Vec::new().into_iter(), - current_diagnostic_path: Arc::from(PathBuf::new()), imported_sources: Vec::new(), unknown_field_warnings: persisting_pchronicle::model::UnknownFieldImportWarnings::default(), - seen_document_ids: HashMap::new(), - seen_session_ids: HashMap::new(), + skipped_warnings: Vec::new(), + seen_document_ids: HashSet::new(), failed: false, } } fn decode_next_source(&mut self) -> Result> { - match &mut self.inputs { - StorylineImportInputs::Stdin(stdin) => { - let Some(stdin) = stdin.take() else { - return Ok(None); - }; - let input = read_bounded(stdin, self.max_input_bytes, "stdin")?; - decode_import_source( - self.requested_format, - ImportOutputFormat::Storyline, - None, - None, - None, - &input, - &mut self.unknown_field_warnings, - ) - .map(Some) - } - StorylineImportInputs::Files { candidates, next } => { - let Some(candidate) = candidates.get(*next) else { - return Ok(None); - }; - *next = next - .checked_add(1) - .context("import Source index overflow")?; - let label = format!("import source {}", candidate.relative_path.display()); - let file = std::fs::File::open(&candidate.path) - .with_context(|| format!("open {label}"))?; - let input = read_bounded(file, self.max_input_bytes, &label)?; - decode_import_source( - self.requested_format, - ImportOutputFormat::Storyline, - Some(&candidate.path), - Some(&candidate.relative_path), - candidate.output_relative_path.as_deref(), - &input, - &mut self.unknown_field_warnings, - ) - .map(Some) + loop { + let outcome = match &mut self.inputs { + StorylineImportInputs::Stdin(stdin) => { + let Some(stdin) = stdin.take() else { + return Ok(None); + }; + let input = read_bounded(stdin, self.max_input_bytes, "stdin")?; + decode_import_source( + self.requested_format, + ImportOutputFormat::Storyline, + None, + None, + None, + &input, + &mut self.unknown_field_warnings, + )? + } + StorylineImportInputs::Files { candidates, next } => { + let Some(candidate) = candidates.get(*next) else { + return Ok(None); + }; + *next = next + .checked_add(1) + .context("import Source index overflow")?; + let label = format!("import source {}", candidate.relative_path.display()); + let file = std::fs::File::open(&candidate.path) + .with_context(|| format!("open {label}"))?; + let input = read_bounded(file, self.max_input_bytes, &label)?; + decode_import_source( + self.requested_format, + ImportOutputFormat::Storyline, + Some(&candidate.path), + Some(&candidate.relative_path), + candidate.output_relative_path.as_deref(), + &input, + &mut self.unknown_field_warnings, + )? + } + }; + match outcome { + DecodeImportOutcome::Imported(decoded) => return Ok(Some(decoded)), + DecodeImportOutcome::Skipped { path, reason } => { + self.skipped_warnings + .push(skipped_import_warning(&path, &reason)); + } } } } @@ -930,8 +961,13 @@ impl<'a> StorylineImportIterator<'a> { ) -> ( Vec, persisting_pchronicle::model::UnknownFieldImportWarnings, + Vec, ) { - (self.imported_sources, self.unknown_field_warnings) + ( + self.imported_sources, + self.unknown_field_warnings, + self.skipped_warnings, + ) } } @@ -940,23 +976,13 @@ impl Iterator for StorylineImportIterator<'_> { fn next(&mut self) -> Option { loop { - if let Some(storyline) = self.current.next() { - if let Err(error) = record_import_identity( - &mut self.seen_document_ids, - "document_id", - storyline.document_id(), - &self.current_diagnostic_path, - ) - .and_then(|()| { - record_import_identity( - &mut self.seen_session_ids, - "session_id", - &storyline.session_id, - &self.current_diagnostic_path, - ) - }) { - self.failed = true; - return Some(Err(error)); + if let Some(mut storyline) = self.current.next() { + if let Some((original, renamed)) = + uniquify_storyline_document_id(&mut storyline, &mut self.seen_document_ids) + { + self.skipped_warnings.push(format!( + "warning: duplicate document_id '{original}' renamed to '{renamed}'" + )); } return Some(Ok(storyline)); } @@ -965,7 +991,6 @@ impl Iterator for StorylineImportIterator<'_> { } match self.decode_next_source() { Ok(Some(decoded)) => { - self.current_diagnostic_path = Arc::from(decoded.diagnostic_path); self.imported_sources.push(decoded.metadata); self.current = decoded.storylines.into_iter(); } @@ -979,24 +1004,34 @@ impl Iterator for StorylineImportIterator<'_> { } } -fn record_import_identity( - seen: &mut HashMap>, - field: &str, - value: &str, - diagnostic_path: &Arc, -) -> Result<()> { - if let Some(first_path) = seen.get(value) { - return Err(cli_boundary_error( - BoundaryCode::InvalidRequest, - format!( - "import contains duplicate {field} '{value}' in Sources '{}' and '{}'", - first_path.display(), - diagnostic_path.display() - ), - )); +fn uniquify_storyline_document_id( + story: &mut StorylineDocument, + seen: &mut HashSet, +) -> Option<(String, String)> { + let preferred = story.document_id().to_string(); + if seen.insert(preferred.clone()) { + return None; } - seen.insert(value.to_owned(), Arc::clone(diagnostic_path)); - Ok(()) + let mut suffix = 1u64; + let renamed = loop { + let candidate = format!("{preferred}#{suffix}"); + if seen.insert(candidate.clone()) { + break candidate; + } + suffix = suffix + .checked_add(1) + .expect("document_id disambiguation suffix overflow"); + }; + if story + .trajectory_id + .as_deref() + .is_some_and(|id| !id.is_empty()) + { + story.trajectory_id = Some(renamed.clone()); + } else { + story.session_id = renamed.clone(); + } + Some((preferred, renamed)) } #[allow(clippy::too_many_arguments)] @@ -1008,7 +1043,7 @@ fn decode_import_source( logical_source_path: Option<&Path>, input: &[u8], unknown_field_warnings: &mut persisting_pchronicle::model::UnknownFieldImportWarnings, -) -> Result { +) -> Result { let diagnostic_path = decode_relative_path .unwrap_or_else(|| Path::new("stdin")) .to_path_buf(); @@ -1018,13 +1053,23 @@ fn decode_import_source( format!("{} is not UTF-8: {error}", diagnostic_path.display()), ) })?; - let format = resolve_import_format(requested_format, input_path, text).map_err(|error| { - if logical_source_path.is_some() { - scope_import_source_error(error, &diagnostic_path) - } else { - error + let allow_skip = requested_format == ExchangeFormat::Auto && logical_source_path.is_some(); + let format = match resolve_import_format(requested_format, input_path, text, allow_skip) + .map_err(|error| { + if logical_source_path.is_some() { + scope_import_source_error(error, &diagnostic_path) + } else { + error + } + })? { + ImportFormatResolution::Format(format) => format, + ImportFormatResolution::Skip(reason) => { + return Ok(DecodeImportOutcome::Skipped { + path: diagnostic_path, + reason, + }); } - })?; + }; let document_format = exchange_document_format(format) .context("supported import format must map to a physical document format")?; let source_path = logical_source_path @@ -1060,11 +1105,11 @@ fn decode_import_source( trajectories: storylines.len(), input_bytes: input.len(), }; - Ok(DecodedImportSource { + Ok(DecodeImportOutcome::Imported(DecodedImportSource { diagnostic_path, metadata, storylines, - }) + })) } #[allow(clippy::too_many_arguments)] @@ -1076,8 +1121,9 @@ fn stage_preserved_import_source( input: &[u8], staging_root: &Path, unknown_field_warnings: &mut persisting_pchronicle::model::UnknownFieldImportWarnings, -) -> Result { - let decoded = decode_import_source( + skipped_warnings: &mut Vec, +) -> Result> { + let decoded = match decode_import_source( requested_format, ImportOutputFormat::Preserve, input_path, @@ -1085,7 +1131,13 @@ fn stage_preserved_import_source( logical_source_path, input, unknown_field_warnings, - )?; + )? { + DecodeImportOutcome::Imported(decoded) => decoded, + DecodeImportOutcome::Skipped { path, reason } => { + skipped_warnings.push(skipped_import_warning(&path, &reason)); + return Ok(None); + } + }; validate_import_storylines(&decoded.storylines).map_err(|error| { if logical_source_path.is_some() { scope_import_source_error(error, &decoded.diagnostic_path) @@ -1109,7 +1161,7 @@ fn stage_preserved_import_source( .with_context(|| format!("write staged Source {}", decoded.metadata.source_path))?; file.sync_all() .with_context(|| format!("sync staged Source {}", decoded.metadata.source_path))?; - Ok(decoded.metadata) + Ok(Some(decoded.metadata)) } fn read_bounded(mut reader: impl Read, max_bytes: usize, label: &str) -> Result> { @@ -1153,24 +1205,36 @@ fn resolve_import_format( requested: ExchangeFormat, input_path: Option<&Path>, input: &str, -) -> Result { + allow_skip: bool, +) -> Result { let format = match requested { - ExchangeFormat::Auto => match detect_format(input_path, Some(input))?.ok_or_else(|| { - cli_boundary_error( - BoundaryCode::InvalidRequest, - "cannot detect import format; pass --format explicitly", - ) - })? { - DocumentFormat::Atif => ExchangeFormat::Atif, - DocumentFormat::Actf => ExchangeFormat::Actf, - DocumentFormat::OpenaiMsg => ExchangeFormat::OpenaiMessages, - DocumentFormat::Storyline => ExchangeFormat::Storyline, - format => { + ExchangeFormat::Auto => match detect_format(input_path, Some(input))? { + Some(DocumentFormat::Atif) => ExchangeFormat::Atif, + Some(DocumentFormat::Actf) => ExchangeFormat::Actf, + Some(DocumentFormat::OpenaiMsg) => ExchangeFormat::OpenaiMessages, + Some(DocumentFormat::Storyline) => ExchangeFormat::Storyline, + Some(format) if allow_skip => { + return Ok(ImportFormatResolution::Skip(format!( + "detected import format '{format}' is not a queryable JSON format" + ))); + } + Some(format) => { return Err(cli_boundary_error( BoundaryCode::Unsupported, format!("detected import format '{format}' is not a queryable JSON format"), )); } + None if allow_skip && looks_like_json_document(input) => { + return Ok(ImportFormatResolution::Skip( + "cannot detect import format".into(), + )); + } + None => { + return Err(cli_boundary_error( + BoundaryCode::InvalidRequest, + "cannot detect import format; pass --format explicitly", + )); + } }, ExchangeFormat::Atif => ExchangeFormat::Atif, ExchangeFormat::Actf => ExchangeFormat::Actf, @@ -1191,7 +1255,39 @@ fn resolve_import_format( ), )); } - Ok(format) + Ok(ImportFormatResolution::Format(format)) +} + +fn looks_like_json_document(input: &str) -> bool { + let trimmed = input.trim_start(); + if !(trimmed.starts_with('{') || trimmed.starts_with('[')) { + return false; + } + if serde_json::from_str::(trimmed).is_ok() { + return true; + } + trimmed + .lines() + .find(|line| !line.trim().is_empty()) + .is_some_and(|line| serde_json::from_str::(line).is_ok()) +} + +fn skipped_import_warning(path: &Path, reason: &str) -> String { + format!( + "warning: skipped import source {}: {reason}", + path.display() + ) +} + +fn empty_auto_directory_import_error(directory_input: bool) -> anyhow::Error { + cli_boundary_error( + BoundaryCode::InvalidRequest, + if directory_input { + "import directory contains no detectable trajectory files" + } else { + "cannot detect import format; pass --format explicitly" + }, + ) } fn import_source_name(format: ExchangeFormat) -> &'static str { @@ -1230,15 +1326,6 @@ fn import_input_issue_message(issue: &InputIssue, source_path: &Path) -> String } fn validate_import_storylines(storylines: &[StorylineDocument]) -> Result { - let mut seen = HashSet::new(); - for storyline in storylines { - if !seen.insert(storyline.document_id()) { - return Err(cli_boundary_error( - BoundaryCode::InvalidRequest, - "import contains duplicate document_id", - )); - } - } Ok(storylines.len()) } diff --git a/crates/persisting-pchronicle-cli/src/lib.rs b/crates/persisting-pchronicle-cli/src/lib.rs index af790ef7..65c305b7 100644 --- a/crates/persisting-pchronicle-cli/src/lib.rs +++ b/crates/persisting-pchronicle-cli/src/lib.rs @@ -13,7 +13,7 @@ use exchange::{run_export, run_import}; use output::*; use settings::*; -use std::collections::{HashMap, HashSet}; +use std::collections::HashSet; use std::ffi::CString; use std::fmt::Write as _; use std::io::{Error as IoError, Read, Write}; @@ -388,6 +388,7 @@ struct ImportArgs { output: Option, /// Input exchange format. Auto detects each regular file from name and content. + /// Directory imports skip JSON that is not a known trajectory format. #[arg(long, value_enum, default_value_t = ExchangeFormat::Auto)] format: ExchangeFormat, diff --git a/crates/persisting-pchronicle-cli/src/server/acceleration.rs b/crates/persisting-pchronicle-cli/src/server/acceleration.rs index 12c5f673..e283498c 100644 --- a/crates/persisting-pchronicle-cli/src/server/acceleration.rs +++ b/crates/persisting-pchronicle-cli/src/server/acceleration.rs @@ -29,6 +29,7 @@ use serde::Serialize; use serde_json::Value as JsonValue; use tokio::sync::{Mutex, OnceCell}; +use super::explorer; use super::RunSummary; const MAX_INJECTED_SOURCES: usize = 512; @@ -906,23 +907,14 @@ async fn build_run_summaries( let root_session_id = parent_session_id .clone() .or_else(|| run_id.as_ref().filter(|id| *id != &session_id).cloned()); - let path = if file == "." { - match root_session_id.as_deref() { - Some(root) if root != session_id => { - format!("{name}/{root}/subagents/{session_id}") - } - Some(root) => format!("{name}/{root}"), - None => format!("{name}/{session_id}"), - } - } else { - match root_session_id.as_deref() { - Some(root) if root != session_id => { - format!("{name}/{file}/{root}/{session_id}") - } - Some(root) => format!("{name}/{file}/{root}"), - None => format!("{name}/{file}/{session_id}"), - } - }; + let path = explorer::explorer_run_path( + name, + &file, + &document_id, + &session_id, + run_id.as_deref(), + parent_session_id.as_deref(), + ); let status = event_stats .get(&(file.clone(), session_id.clone())) .map_or_else( diff --git a/crates/persisting-pchronicle-cli/src/server/explorer.rs b/crates/persisting-pchronicle-cli/src/server/explorer.rs index 27acfa05..68c998eb 100644 --- a/crates/persisting-pchronicle-cli/src/server/explorer.rs +++ b/crates/persisting-pchronicle-cli/src/server/explorer.rs @@ -117,6 +117,8 @@ pub(crate) struct TurnSummary { pub(crate) timestamp: Option, pub(crate) call_id: Option, pub(crate) preview: String, + pub(crate) char_count: u64, + pub(crate) modalities: Vec, pub(crate) model_name: Option, pub(crate) latency_ms: Option, pub(crate) ttft_ms: Option, @@ -136,6 +138,32 @@ pub(crate) struct TurnDetail { pub(crate) events: Vec, } +pub(crate) fn explorer_run_path( + dataset: &str, + file: &str, + document_id: &str, + session_id: &str, + run_id: Option<&str>, + parent_session_id: Option<&str>, +) -> String { + if file == "." { + return match parent_session_id { + Some(parent) if parent != session_id => { + format!("{dataset}/{parent}/subagents/{document_id}") + } + _ => format!("{dataset}/{document_id}"), + }; + } + let root_session_id = parent_session_id.or_else(|| run_id.filter(|id| *id != session_id)); + match root_session_id { + Some(root) if root != session_id => { + format!("{dataset}/{file}/{root}/{session_id}") + } + Some(root) => format!("{dataset}/{file}/{root}"), + None => format!("{dataset}/{file}/{session_id}"), + } +} + pub(crate) fn run_page(summaries: Vec, query: &ExplorerRunsQuery) -> RunExplorerPage { let needle = query .q @@ -432,11 +460,11 @@ fn turn_summary(item: &TrajectoryTurnView, events: &[EventRecord]) -> TurnSummar } values.extend(linked.iter().map(|event| &event.payload)); let (prompt_tokens, completion_tokens, total_tokens) = token_counts(&values); - let text = match &item.turn.message { - Value::String(value) => value.clone(), - value => serde_json::to_string(value).unwrap_or_default(), - }; - let preview = compact(&text, 220); + let tool_names = display_tool_calls(item) + .into_iter() + .map(|(name, _)| name) + .collect::>(); + let extracted = extract_message_content(&item.turn.message, !tool_names.is_empty()); TurnSummary { id: item.turn.id, source: item.turn.source.clone(), @@ -447,7 +475,9 @@ fn turn_summary(item: &TrajectoryTurnView, events: &[EventRecord]) -> TurnSummar .as_ref() .map(|timestamp| timestamp.canonical_rfc3339()), call_id: item.call_id.clone(), - preview, + preview: compact(&extracted.text, 180), + char_count: extracted.char_count, + modalities: extracted.modalities, model_name: item .turn .model_name @@ -472,10 +502,7 @@ fn turn_summary(item: &TrajectoryTurnView, events: &[EventRecord]) -> TurnSummar prompt_tokens, completion_tokens, total_tokens, - tool_names: display_tool_calls(item) - .into_iter() - .map(|(name, _)| name) - .collect(), + tool_names, event_seqs: item.event_seqs.clone(), has_error: turn_has_error(item, &linked), } @@ -680,6 +707,106 @@ fn searchable_turn(item: &TrajectoryTurnView) -> String { .to_ascii_lowercase() } +#[derive(Debug, PartialEq)] +struct ExtractedMessage { + text: String, + char_count: u64, + modalities: Vec, +} + +fn extract_message_content(message: &Value, has_tools: bool) -> ExtractedMessage { + let mut texts = Vec::new(); + let mut flags = BTreeSet::new(); + match message { + Value::String(value) => { + if !value.is_empty() { + texts.push(value.clone()); + } + } + other => collect_message_parts(other, &mut texts, &mut flags), + } + let text = texts.join(" "); + if !text.is_empty() { + flags.insert("text"); + } + if has_tools || text.contains("") { + flags.insert("tool_call"); + } + let modalities = ["text", "image", "audio", "tool_call"] + .into_iter() + .filter(|name| flags.contains(name)) + .map(str::to_string) + .collect(); + ExtractedMessage { + char_count: text.chars().count() as u64, + text, + modalities, + } +} + +fn collect_message_parts( + value: &Value, + texts: &mut Vec, + flags: &mut BTreeSet<&'static str>, +) { + match value { + Value::Array(items) => { + for item in items { + collect_message_parts(item, texts, flags); + } + } + Value::Object(map) => { + if let Some(Value::String(text)) = map.get("text") { + if !text.is_empty() { + texts.push(text.clone()); + } + } + if let Some(Value::String(content)) = map.get("content") { + if !content.is_empty() { + texts.push(content.clone()); + } + } + if value_present(map.get("image")) + || value_present(map.get("image_url")) + || value_present(map.get("image_bytes")) + { + flags.insert("image"); + } + if value_present(map.get("audio")) || value_present(map.get("input_audio")) { + flags.insert("audio"); + } + if let Some(kind) = map.get("type").and_then(Value::as_str) { + match kind { + "image" | "image_url" => { + flags.insert("image"); + } + "audio" | "input_audio" => { + flags.insert("audio"); + } + _ => {} + } + } + for (key, child) in map { + if key == "text" || (key == "content" && child.is_string()) { + continue; + } + collect_message_parts(child, texts, flags); + } + } + _ => {} + } +} + +fn value_present(value: Option<&Value>) -> bool { + match value { + None | Some(Value::Null) => false, + Some(Value::String(value)) => !value.is_empty(), + Some(Value::Array(items)) => !items.is_empty(), + Some(Value::Object(map)) => !map.is_empty(), + Some(Value::Bool(_) | Value::Number(_)) => true, + } +} + fn compact(value: &str, limit: usize) -> String { let single = value.split_whitespace().collect::>().join(" "); if single.chars().count() <= limit { @@ -772,6 +899,51 @@ fn find_string(value: &Value, key: &str) -> Option { mod tests { use super::*; + #[test] + fn squashed_store_does_not_use_run_id_as_a_folder() { + assert_eq!( + explorer_run_path( + "default", + ".", + "13f9aec9-0e2a-4bdf-baf6-48b58f5715fc", + "13f9aec9-0e2a-4bdf-baf6-48b58f5715fc", + Some("cybergym_0729001"), + None, + ), + "default/13f9aec9-0e2a-4bdf-baf6-48b58f5715fc" + ); + } + + #[test] + fn squashed_store_nests_only_real_parent_sessions() { + assert_eq!( + explorer_run_path( + "default", + ".", + "Energy_001#1", + "Energy_001#1", + Some("Energy_001"), + Some("Energy_001"), + ), + "default/Energy_001/subagents/Energy_001#1" + ); + } + + #[test] + fn preserved_file_sources_keep_the_source_path() { + assert_eq!( + explorer_run_path( + "dataset", + "gateway.json", + "json-session", + "json-session", + Some("json-job"), + None, + ), + "dataset/gateway.json/json-job/json-session" + ); + } + #[test] fn percentiles_report_coverage_without_inventing_missing_samples() { let stats = metric_stats(vec![10.0, 20.0, 30.0, 40.0], 8); @@ -811,4 +983,52 @@ mod tests { let empty = serde_json::json!({"usage":{}}); assert_eq!(token_counts(&[&empty]), (None, None, None)); } + + #[test] + fn multimodal_null_media_fields_do_not_hide_text() { + let message = serde_json::json!([{ + "image_bytes": null, + "image_url": null, + "input_audio": null, + "text": "Please continue on whatever approach you think is suitable" + }]); + let extracted = extract_message_content(&message, false); + assert_eq!( + extracted.text, + "Please continue on whatever approach you think is suitable" + ); + assert_eq!(extracted.char_count, extracted.text.chars().count() as u64); + assert_eq!(extracted.modalities, vec!["text"]); + } + + #[test] + fn string_message_is_plain_text() { + let extracted = extract_message_content(&serde_json::json!("hello world"), false); + assert_eq!(extracted.text, "hello world"); + assert_eq!(extracted.char_count, 11); + assert_eq!(extracted.modalities, vec!["text"]); + } + + #[test] + fn nonempty_image_without_text_is_image_only() { + let extracted = extract_message_content( + &serde_json::json!([{"type":"image_url","image_url":"https://ex/a.png"}]), + false, + ); + assert_eq!(extracted.text, ""); + assert_eq!(extracted.char_count, 0); + assert_eq!(extracted.modalities, vec!["image"]); + } + + #[test] + fn tool_calls_and_markup_mark_tool_modality() { + let from_names = extract_message_content(&serde_json::json!("ok"), true); + assert_eq!(from_names.modalities, vec!["text", "tool_call"]); + let from_markup = extract_message_content( + &serde_json::json!("execute_bash\nls"), + false, + ); + assert!(from_markup.modalities.contains(&"tool_call".to_string())); + assert!(from_markup.modalities.contains(&"text".to_string())); + } } diff --git a/crates/persisting-pchronicle-cli/src/server/mod.rs b/crates/persisting-pchronicle-cli/src/server/mod.rs index 89a00a88..a5b1b678 100644 --- a/crates/persisting-pchronicle-cli/src/server/mod.rs +++ b/crates/persisting-pchronicle-cli/src/server/mod.rs @@ -178,25 +178,31 @@ impl PreparedWarehouse { } } +fn api_routes() -> Router { + Router::new() + .route("/health", get(warehouse_health)) + .route("/runs", get(runs)) + .route("/explorer/runs", get(explorer_runs)) + .route("/explorer/run", get(explorer_run)) + .route("/explorer/turns", get(explorer_turns)) + .route("/explorer/turn", get(explorer_turn)) + .route("/events", get(events)) + .route("/storyline", get(storyline)) + .route("/trajectory-view", get(trajectory_view)) + .route("/export/har", get(export_har)) + .route("/export/otlp", get(export_otlp)) + .route("/revisions", get(revisions)) + .route("/catalog", get(catalog).post(refresh_catalog)) + .route("/query/tables", get(query_tables)) + .route("/query/evidence", post(query_evidence)) +} + fn read_routes() -> Router { Router::new() .route("/", get(index)) .route("/index.html", get(index)) - .route("/api/health", get(warehouse_health)) - .route("/api/runs", get(runs)) - .route("/api/explorer/runs", get(explorer_runs)) - .route("/api/explorer/run", get(explorer_run)) - .route("/api/explorer/turns", get(explorer_turns)) - .route("/api/explorer/turn", get(explorer_turn)) - .route("/api/events", get(events)) - .route("/api/storyline", get(storyline)) - .route("/api/trajectory-view", get(trajectory_view)) - .route("/api/export/har", get(export_har)) - .route("/api/export/otlp", get(export_otlp)) - .route("/api/revisions", get(revisions)) - .route("/api/catalog", get(catalog).post(refresh_catalog)) - .route("/api/query/tables", get(query_tables)) - .route("/api/query/evidence", post(query_evidence)) + .nest("/api", api_routes()) + .nest("/api/v1", api_routes()) .fallback(asset_fallback) } diff --git a/crates/persisting-pchronicle-cli/src/server/tests.rs b/crates/persisting-pchronicle-cli/src/server/tests.rs index 2fed16f8..3cd5106d 100644 --- a/crates/persisting-pchronicle-cli/src/server/tests.rs +++ b/crates/persisting-pchronicle-cli/src/server/tests.rs @@ -205,6 +205,9 @@ fn projected_turn_sequence_wins_over_call_wide_event_group() { latency_ms: None, ttft_ms: None, extra: Some(json!({"call_id": "model-call", "seq": 11})), + env: None, + prompt: None, + finished_at: None, }; let by_call = BTreeMap::from([("model-call".into(), vec![10, 11])]); @@ -235,6 +238,9 @@ fn explorer_analysis_counts_usage_and_normalized_tools_once_per_call() { latency_ms: None, ttft_ms: None, extra: Some(json!({"call_id": "model-call", "seq": 0})), + env: None, + prompt: None, + finished_at: None, }; let agent = StorylineTurn { id: 2, @@ -254,6 +260,8 @@ fn explorer_analysis_counts_usage_and_normalized_tools_once_per_call() { result: None, duration_ms: None, extra: None, + kind: None, + response: None, }]), observation: None, metrics: Some(json!({ @@ -267,6 +275,9 @@ fn explorer_analysis_counts_usage_and_normalized_tools_once_per_call() { latency_ms: Some(1000), ttft_ms: Some(100), extra: Some(json!({"call_id": "model-call", "seq": 1})), + env: None, + prompt: None, + finished_at: None, }; let event = |seq, kind: &str, payload| EventRecord { identity: Default::default(), @@ -749,6 +760,33 @@ async fn prepared_catalog_installs_refreshes_and_retains_the_last_good_runtime( Ok(()) } +#[tokio::test] +async fn warehouse_keeps_api_v1_aliases_for_embedded_web_ui() { + use http_body_util::BodyExt; + use tower::ServiceExt; + + let root = json_dataset_root(); + let app = router(root.to_string_lossy().to_string()); + for uri in ["/api/v1/explorer/runs?limit=10", "/api/v1/query/tables"] { + let response = app + .clone() + .oneshot( + axum::http::Request::builder() + .uri(uri) + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!( + response.status(), + StatusCode::OK, + "{uri} failed: {}", + String::from_utf8_lossy(&response.into_body().collect().await.unwrap().to_bytes()) + ); + } +} + #[tokio::test] async fn explorer_routes_page_runs_and_lazy_load_turn_evidence() { use http_body_util::BodyExt; @@ -866,6 +904,63 @@ async fn explorer_routes_page_runs_and_lazy_load_turn_evidence() { std::fs::remove_dir_all(root).unwrap(); } +#[tokio::test] +async fn explorer_lists_nested_actf_event_log_json_files() { + use http_body_util::BodyExt; + use tower::ServiceExt; + + let root = json_dataset_root(); + let nested = root.join("owner/details"); + std::fs::create_dir_all(&nested).unwrap(); + std::fs::write( + nested.join("_error_lean4-proof_formal method.json"), + serde_json::to_vec(&json!({ + "task_id": "lean4-proof", + "category": "formal method", + "k": 1, + "correct": false, + "solved_at": null, + "attempts_tried": 1, + "attempts": { + "1": { + "correct": false, + "status": "run_error", + "trajectory": [ + {"type":"session","id":"s1","timestamp":"2026-06-17T07:26:27.170Z","cwd":"/root"}, + {"type":"message","id":"m1","timestamp":"2026-06-17T07:26:28Z", + "message":{"role":"user","content":[{"type":"text","text":"hello"}]}} + ] + } + } + })) + .unwrap(), + ) + .unwrap(); + let response = router(root.to_string_lossy().to_string()) + .oneshot( + axum::http::Request::builder() + .uri("/api/explorer/runs?limit=20") + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = response.into_body().collect().await.unwrap().to_bytes(); + assert_eq!( + status, + StatusCode::OK, + "explorer failed: {}", + String::from_utf8_lossy(&body) + ); + let page: Value = serde_json::from_slice(&body).unwrap(); + assert!( + page["snapshot"]["total"].as_u64().unwrap() >= 2, + "expected gateway.json plus nested ACTF, got {page}" + ); + std::fs::remove_dir_all(root).unwrap(); +} + #[tokio::test] async fn explorer_uses_terminal_metadata_for_run_status() { use http_body_util::BodyExt; diff --git a/crates/persisting-pchronicle-cli/src/tests.rs b/crates/persisting-pchronicle-cli/src/tests.rs index 2ea421e2..01079bf3 100644 --- a/crates/persisting-pchronicle-cli/src/tests.rs +++ b/crates/persisting-pchronicle-cli/src/tests.rs @@ -1258,10 +1258,14 @@ async fn import_warns_for_unmapped_and_vendor_residual_keys() -> Result<()> { ] ); assert!( - stderr.contains( - "warning: unknown field source=actf key=/attempts/1/trajectory/steps/*/user_content occurrences=3" - ), - "unmapped ACTF fields must warn: {stderr}" + !stderr.contains("key=/attempts/1/trajectory/steps/*/user_content"), + "mapped user_content must not warn: {stderr}" + ); + assert!( + !stderr.contains("key=/attempts/1/extra") + && !stderr.contains("key=/attempts/1/meta") + && !stderr.contains("key=/attempts/1/max_score"), + "mapped attempt extra/meta/max_score must not warn: {stderr}" ); assert!(!stderr.contains("/assistant_content/content")); assert!( @@ -1619,6 +1623,78 @@ async fn import_recurses_directories_and_preserves_relative_source_paths() -> Re Ok(()) } +#[tokio::test] +async fn directory_import_auto_detects_each_file_and_skips_unknown_json() -> Result<()> { + let temp = tempfile::tempdir()?; + let input = temp.path().join("input"); + fs::create_dir_all(input.join("details"))?; + let atif = input.join("root.json"); + let openai = input.join("nested-training.json"); + let unknown = input.join("details/_error_gravitational-wave-detection_astronomy.json"); + fs::copy(example_source("atif"), &atif)?; + fs::copy(example_source("openai-messages"), &openai)?; + fs::write(&unknown, r#"{"error":"task failed","task_id":"astronomy"}"#)?; + + for output_format in [ImportOutputFormat::Preserve, ImportOutputFormat::Storyline] { + let output = temp + .path() + .join(format!("dataset-{}", output_format.response_name())); + let mut argv = vec![ + "pchronicle".to_owned(), + "import".to_owned(), + "--from".to_owned(), + input.to_string_lossy().into_owned(), + "--output".to_owned(), + output.to_string_lossy().into_owned(), + ]; + if output_format == ImportOutputFormat::Storyline { + argv.extend(["--output-format".to_owned(), "storyline".to_owned()]); + } + let cli = Cli::try_parse_from(argv)?; + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + run(cli, false, &mut stdout, &mut stderr).await?; + + let response: Value = serde_json::from_slice(&stdout)?; + assert_eq!(response["sources"], 2, "{output_format:?}: {response}"); + assert_eq!(response["trajectories"], 3, "{output_format:?}: {response}"); + let warnings = String::from_utf8(stderr)?; + assert!( + warnings.contains("_error_gravitational-wave-detection_astronomy.json"), + "{output_format:?}: {warnings}" + ); + assert!( + warnings.contains("cannot detect import format"), + "{output_format:?}: {warnings}" + ); + if output_format == ImportOutputFormat::Preserve { + assert!(!output.join(unknown.file_name().unwrap()).exists()); + assert!(!output + .join("details/_error_gravitational-wave-detection_astronomy.json") + .exists()); + } + } + + let single = temp.path().join("single-unknown"); + let cli = Cli::try_parse_from([ + "pchronicle", + "import", + "--from", + unknown.to_str().unwrap(), + "--output", + single.to_str().unwrap(), + ])?; + let error = run(cli, false, &mut Vec::new(), &mut Vec::new()) + .await + .unwrap_err(); + assert!( + format!("{error:#}").contains("cannot detect import format"), + "{error:#}" + ); + assert!(!single.exists()); + Ok(()) +} + #[tokio::test] async fn import_storyline_output_writes_one_root_lance_store() -> Result<()> { let temp = tempfile::tempdir()?; @@ -1872,6 +1948,67 @@ async fn directory_storyline_output_squashes_sources_into_one_root_store() -> Re Ok(()) } +#[tokio::test] +async fn directory_storyline_import_renames_duplicate_document_ids() -> Result<()> { + let temp = tempfile::tempdir()?; + let input = temp.path().join("input"); + fs::create_dir_all(input.join("first"))?; + fs::create_dir_all(input.join("second"))?; + fs::write( + input.join("first/Energy_001_Energy.json"), + serde_json::to_vec(&atif_identity_document("Energy_001", "Energy_001"))?, + )?; + fs::write( + input.join("second/Energy_001_Energy.json"), + serde_json::to_vec(&atif_identity_document("Energy_001", "Energy_001"))?, + )?; + let output = temp.path().join("dataset"); + + let cli = Cli::try_parse_from([ + "pchronicle", + "import", + "--from", + input.to_str().unwrap(), + "--output", + output.to_str().unwrap(), + "--output-format", + "storyline", + ])?; + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + run(cli, false, &mut stdout, &mut stderr).await?; + + let response: Value = serde_json::from_slice(&stdout)?; + assert_eq!(response["trajectories"], 2); + let stderr = String::from_utf8(stderr)?; + assert!( + stderr.contains("duplicate document_id 'Energy_001' renamed to 'Energy_001#1'"), + "{stderr}" + ); + + let cli = Cli::try_parse_from([ + "pchronicle", + "query", + output.to_str().unwrap(), + "SELECT document_id FROM dataset.runs ORDER BY document_id", + "--format", + "jsonl", + ])?; + let mut stdout = Vec::new(); + run(cli, false, &mut stdout, &mut Vec::new()).await?; + let rows = stdout + .split(|&byte| byte == b'\n') + .filter(|line| !line.is_empty()) + .map(serde_json::from_slice::) + .collect::>>()?; + let ids = rows + .iter() + .map(|row| row["document_id"].as_str().unwrap_or_default()) + .collect::>(); + assert_eq!(ids, ["Energy_001", "Energy_001#1"]); + Ok(()) +} + #[tokio::test] async fn directory_import_dedupes_unknown_warnings_across_sources() -> Result<()> { let temp = tempfile::tempdir()?; @@ -1909,9 +2046,8 @@ async fn directory_import_dedupes_unknown_warnings_across_sources() -> Result<() "warning: unknown field source=actf key=/attempts/1/trajectory/steps/*/vendor_step occurrences=3" ] ); - assert!(stderr.contains( - "warning: unknown field source=actf key=/attempts/1/trajectory/steps/*/user_content occurrences=3" - )); + assert!(!stderr.contains("key=/attempts/1/trajectory/steps/*/user_content")); + assert!(!stderr.contains("key=/attempts/1/extra") && !stderr.contains("key=/attempts/1/meta")); assert!(!stderr.contains("alpha") && !stderr.contains("beta") && !stderr.contains("gamma")); Ok(()) } @@ -1956,58 +2092,61 @@ async fn directory_import_failure_does_not_publish_partial_output() -> Result<() } #[tokio::test] -async fn storyline_squash_rejects_global_identity_collisions() -> Result<()> { +async fn storyline_squash_renames_duplicate_document_ids() -> Result<()> { let temp = tempfile::tempdir()?; - for (field, value, first, second) in [ - ( - "document_id", - "shared-document", - ("shared-document", "session-first"), - ("shared-document", "session-second"), - ), - ( - "session_id", - "shared-session", - ("document-first", "shared-session"), - ("document-second", "shared-session"), - ), - ] { - let case_root = temp.path().join(field); - let input = case_root.join("input"); - fs::create_dir_all(input.join("nested"))?; - fs::write( - input.join("first.json"), - serde_json::to_vec(&atif_identity_document(first.0, first.1))?, - )?; - fs::write( - input.join("nested/second.json"), - serde_json::to_vec(&atif_identity_document(second.0, second.1))?, - )?; - let output = case_root.join("output"); - let cli = Cli::try_parse_from([ - "pchronicle", - "import", - "--from", - input.to_str().unwrap(), - "--output", - output.to_str().unwrap(), - "--output-format", - "storyline", - ])?; - let error = run(cli, false, &mut Vec::new(), &mut Vec::new()) - .await - .unwrap_err(); - let concise = error.to_string(); - assert!(concise.contains(field), "{concise}"); - assert!(concise.contains("first.json"), "{concise}"); - assert!(concise.contains("nested/second.json"), "{concise}"); - let message = format!("{error:#}"); - assert!(message.contains(field), "{message}"); - assert!(message.contains(value), "{message}"); - assert!(message.contains("first.json"), "{message}"); - assert!(message.contains("nested/second.json"), "{message}"); - assert!(!output.exists()); - } + let input = temp.path().join("input"); + fs::create_dir_all(input.join("nested"))?; + fs::write( + input.join("first.json"), + serde_json::to_vec(&atif_identity_document("shared-document", "session-first"))?, + )?; + fs::write( + input.join("nested/second.json"), + serde_json::to_vec(&atif_identity_document("shared-document", "session-second"))?, + )?; + let output = temp.path().join("output"); + let cli = Cli::try_parse_from([ + "pchronicle", + "import", + "--from", + input.to_str().unwrap(), + "--output", + output.to_str().unwrap(), + "--output-format", + "storyline", + ])?; + let mut stderr = Vec::new(); + run(cli, false, &mut Vec::new(), &mut stderr).await?; + let stderr = String::from_utf8(stderr)?; + assert!( + stderr.contains("duplicate document_id 'shared-document' renamed to 'shared-document#1'"), + "{stderr}" + ); + assert!(output.exists()); + + let session_input = temp.path().join("sessions"); + fs::create_dir_all(&session_input)?; + fs::write( + session_input.join("first.json"), + serde_json::to_vec(&atif_identity_document("document-first", "shared-session"))?, + )?; + fs::write( + session_input.join("second.json"), + serde_json::to_vec(&atif_identity_document("document-second", "shared-session"))?, + )?; + let session_output = temp.path().join("session-output"); + let cli = Cli::try_parse_from([ + "pchronicle", + "import", + "--from", + session_input.to_str().unwrap(), + "--output", + session_output.to_str().unwrap(), + "--output-format", + "storyline", + ])?; + run(cli, false, &mut Vec::new(), &mut Vec::new()).await?; + assert!(session_output.exists()); let duplicate_input = temp.path().join("duplicates.json"); fs::write( @@ -2030,17 +2169,14 @@ async fn storyline_squash_rejects_global_identity_collisions() -> Result<()> { "--output-format", "storyline", ])?; - let error = run(cli, false, &mut Vec::new(), &mut Vec::new()) - .await - .unwrap_err(); - let message = format!("{error:#}"); - assert!(message.contains("document_id"), "{message}"); - assert!(message.contains("same-document"), "{message}"); + let mut stderr = Vec::new(); + run(cli, false, &mut Vec::new(), &mut stderr).await?; + let stderr = String::from_utf8(stderr)?; assert!( - message.contains("Sources 'duplicates.json' and 'duplicates.json'"), - "{message}" + stderr.contains("duplicate document_id 'same-document' renamed to 'same-document#1'"), + "{stderr}" ); - assert!(!duplicate_output.exists()); + assert!(duplicate_output.exists()); Ok(()) } @@ -2265,7 +2401,7 @@ async fn import_rejects_invalid_oversized_and_unsupported_input_without_partial_ } #[tokio::test] -async fn import_is_create_only_and_rejects_duplicate_documents() -> Result<()> { +async fn import_is_create_only_and_keeps_duplicate_documents() -> Result<()> { let temp = tempfile::tempdir()?; let output = temp.path().join("existing"); fs::create_dir(&output)?; @@ -2300,14 +2436,8 @@ async fn import_is_create_only_and_rejects_duplicate_documents() -> Result<()> { "--format", "atif", ])?; - let error = run(cli, false, &mut Vec::new(), &mut Vec::new()) - .await - .unwrap_err(); - assert_eq!( - error.to_string(), - "invalid_request: import contains duplicate document_id" - ); - assert!(!duplicate_output.exists()); + run(cli, false, &mut Vec::new(), &mut Vec::new()).await?; + assert!(duplicate_output.is_dir()); assert!(!fs::read_dir(temp.path())?.any(|entry| { entry .ok() diff --git a/crates/persisting-pchronicle/src/agenticmd/convert.rs b/crates/persisting-pchronicle/src/agenticmd/convert.rs index d44ca398..b30c770e 100644 --- a/crates/persisting-pchronicle/src/agenticmd/convert.rs +++ b/crates/persisting-pchronicle/src/agenticmd/convert.rs @@ -305,6 +305,8 @@ mod tests { result: None, duration_ms: Some(12), extra: Some(json!({"provider":"test"})), + kind: None, + response: None, }]), observation: Some(json!({ "results":[{"source_call_id":"call-1","content":"ok"}] @@ -316,6 +318,9 @@ mod tests { latency_ms: Some(50), ttft_ms: Some(5), extra: Some(json!({"trace_id":"trace-1"})), + env: None, + prompt: None, + finished_at: None, }); let markdown = encode_agenticmd(&story).unwrap(); @@ -410,6 +415,9 @@ hi latency_ms: None, ttft_ms: None, extra: None, + env: None, + prompt: None, + finished_at: None, }); story .unknown_fields diff --git a/crates/persisting-pchronicle/src/agenticmd/fs.rs b/crates/persisting-pchronicle/src/agenticmd/fs.rs index f9311155..6aaba15f 100644 --- a/crates/persisting-pchronicle/src/agenticmd/fs.rs +++ b/crates/persisting-pchronicle/src/agenticmd/fs.rs @@ -787,6 +787,9 @@ mod tests { latency_ms: None, ttft_ms: Some(12), extra: Some(serde_json::json!({"domain": "kept"})), + env: None, + prompt: None, + finished_at: None, }; assert!(!upsert_agenticmd_turn(&path, &story, &turn, "call-7").unwrap()); diff --git a/crates/persisting-pchronicle/src/convert/actf.rs b/crates/persisting-pchronicle/src/convert/actf.rs index fc21b08d..b9be247c 100644 --- a/crates/persisting-pchronicle/src/convert/actf.rs +++ b/crates/persisting-pchronicle/src/convert/actf.rs @@ -11,8 +11,9 @@ use crate::formats::actf::{ ACTF_SCHEMA_VERSION, }; use crate::formats::storyline::{ - StorylineAgent, StorylineDocument, StorylineOrigin, StorylineToolCall, StorylineTurn, - STORYLINE_SCHEMA_VERSION, + StorylineAgent, StorylineDocument, StorylineEnv, StorylineOrigin, StorylinePrompt, + StorylineTask, StorylineTaskLlm, StorylineTaskResult, StorylineToolCall, StorylineToolResponse, + StorylineTurn, STORYLINE_SCHEMA_VERSION, }; use crate::formats::timestamp::StorylineTimestamp; use crate::formats::unknown_fields::{ @@ -22,14 +23,30 @@ use crate::formats::unknown_fields::{ }; use crate::Result; -fn actf_tool_to_storyline(call: &ActfToolCall, duration_ms: Option) -> StorylineToolCall { +fn actf_tool_to_storyline( + call: &ActfToolCall, + duration_ms: Option, + step_id: i64, + call_index: usize, +) -> StorylineToolCall { + let status = call + .extra + .get("status") + .and_then(Value::as_str) + .filter(|status| !status.is_empty()) + .map(str::to_string); + let exit_code = call.extra.get("exit_code").and_then(Value::as_i64); + let response = (status.is_some() || exit_code.is_some()) + .then_some(StorylineToolResponse { status, exit_code }); StorylineToolCall { - tool_call_id: call.id.clone(), + tool_call_id: call.effective_id(step_id, call_index), function_name: actf_tool_name(call), arguments: actf_tool_arguments(call), result: call.extra.get("aggregated_output").cloned(), duration_ms, extra: None, + kind: (!call.kind.is_empty()).then(|| call.kind.clone()), + response, } } @@ -85,20 +102,40 @@ fn attempt_to_storyline( attempt: &ActfAttempt, multiple_attempts: bool, ) -> Result { + if !attempt.trajectory.events.is_empty() && attempt.trajectory.steps.is_empty() { + return event_log_attempt_to_storyline(document, attempt_id, attempt, multiple_attempts); + } + let prompt_pairs: Vec<(String, String)> = attempt + .trajectory + .steps + .iter() + .map(|step| (step.system_prompt.clone(), step.user_content.clone())) + .collect(); + let baseline = prompt_pairs + .iter() + .find(|(system, user)| !system.is_empty() || !user.is_empty()) + .cloned(); + let document_prompt = baseline + .as_ref() + .and_then(|(system, user)| StorylinePrompt::from_pair(system, user)); let mut turns = Vec::with_capacity(attempt.trajectory.steps.len()); - for step in &attempt.trajectory.steps { - let tool_calls = (!step.tools.is_empty()) + for (step, pair) in attempt.trajectory.steps.iter().zip(prompt_pairs) { + let source_tools = step.effective_tools(); + let tool_calls = (!source_tools.is_empty()) .then(|| { - step.tools + source_tools .iter() - .map(|call| { + .enumerate() + .map(|(call_index, call)| { Ok(actf_tool_to_storyline( call, - if step.tools.len() == 1 { + if source_tools.len() == 1 { step.metric.env_action_ms.as_f64().map(|value| value as i64) } else { None }, + step.step_id, + call_index, )) }) .collect::>>() @@ -130,6 +167,9 @@ fn attempt_to_storyline( latency_ms: step.metric.llm_infer_ms.as_f64().map(|value| value as i64), ttft_ms: None, extra: None, + env: None, + prompt: actf_turn_prompt(pair, baseline.as_ref()), + finished_at: Some(StorylineTimestamp::from_rfc3339(&step.finished_at)?), }); } @@ -160,21 +200,467 @@ fn attempt_to_storyline( parent: None, child_session_ids: None, notes: None, - final_metrics: Some(json!({ - "correct": attempt.correct, - "score": attempt.score, - "status": attempt.status, - "task_correct": document.correct, - "analysis_result": attempt.analysis_result, - })), + task: actf_task(document, attempt), + prompt: document_prompt, + started_at: Some(StorylineTimestamp::from_rfc3339( + &attempt.trajectory.started_at, + )?), + finished_at: Some(StorylineTimestamp::from_rfc3339( + &attempt.trajectory.finished_at, + )?), + final_metrics: Some(actf_final_metrics(document, attempt)), continued_trajectory_ref: None, - extra: None, + extra: omit_empty_value(&attempt.extra), + meta: omit_empty_value(&attempt.meta), + unknown_fields: Default::default(), + unknown_key_counts: Default::default(), + turns, + }) +} + +fn event_log_attempt_to_storyline( + document: &ActfDocument, + attempt_id: &str, + attempt: &ActfAttempt, + multiple_attempts: bool, +) -> Result { + let (turns, session_env, model_name) = openclaw_events_to_turns(&attempt.trajectory.events)?; + let session_id = if multiple_attempts { + format!("{}#attempt-{attempt_id}", document.task_id) + } else { + document.task_id.clone() + }; + let mut task = actf_task(document, attempt).unwrap_or_default(); + if let Some(env) = session_env { + task.env = Some(env); + } + event_log_storyline( + document, + attempt, + attempt_id, + session_id, + model_name, + (!task.is_empty()).then_some(task), + turns, + ) +} + +fn event_log_storyline( + document: &ActfDocument, + attempt: &ActfAttempt, + attempt_id: &str, + session_id: String, + model_name: Option, + task: Option, + turns: Vec, +) -> Result { + Ok(StorylineDocument { + schema_version: STORYLINE_SCHEMA_VERSION.into(), + origin: Some(StorylineOrigin { + format: DocumentFormat::Actf.as_str().into(), + schema_version: Some(ACTF_SCHEMA_VERSION.into()), + document_id: None, + }), + run_id: Some(document.task_id.clone()), + trajectory_id: None, + attempt_id: Some(attempt_id.to_string()), + session_id, + agent: StorylineAgent { + id: "actf-agent".into(), + name: Some("ACTF Agent".into()), + version: None, + model_name, + tool_definitions: None, + extra: None, + }, + parent: None, + child_session_ids: None, + notes: None, + task, + prompt: None, + started_at: Some(StorylineTimestamp::from_rfc3339( + &attempt.trajectory.started_at, + )?), + finished_at: Some(StorylineTimestamp::from_rfc3339( + &attempt.trajectory.finished_at, + )?), + final_metrics: Some(actf_final_metrics(document, attempt)), + continued_trajectory_ref: None, + extra: omit_empty_value(&attempt.extra), + meta: omit_empty_value(&attempt.meta), unknown_fields: Default::default(), unknown_key_counts: Default::default(), turns, }) } +fn openclaw_events_to_turns( + events: &[Value], +) -> Result<(Vec, Option, Option)> { + let mut turns = Vec::new(); + let mut session_env = None; + let mut model_name = None; + let mut next_id = 1i64; + for event in events { + match event.get("type").and_then(Value::as_str) { + Some("session") => { + let mut env = StorylineEnv { + id: event + .get("id") + .and_then(Value::as_str) + .filter(|id| !id.is_empty()) + .map(str::to_string), + ..StorylineEnv::default() + }; + if let Some(cwd) = event.get("cwd").and_then(Value::as_str) { + let mut state = serde_json::Map::new(); + state.insert("cwd".into(), Value::String(cwd.to_string())); + env.state = Some(state); + } + if !env.is_empty() { + session_env = Some(env); + } + } + Some("model_change") => { + model_name = event + .get("modelId") + .and_then(Value::as_str) + .filter(|name| !name.is_empty()) + .map(str::to_string); + } + Some("message") => { + if let Some(turn) = openclaw_message_to_turn(event, next_id)? { + next_id = next_id + .checked_add(1) + .context("ACTF event-log turn id overflow")?; + turns.push(turn); + } + } + _ => {} + } + } + attach_openclaw_tool_results(&mut turns); + Ok((turns, session_env, model_name)) +} + +fn openclaw_message_to_turn(event: &Value, id: i64) -> Result> { + let message = event.get("message").and_then(Value::as_object); + let Some(message) = message else { + return Ok(None); + }; + let role = message.get("role").and_then(Value::as_str).unwrap_or(""); + let timestamp = event + .get("timestamp") + .and_then(Value::as_str) + .map(StorylineTimestamp::from_rfc3339) + .transpose()?; + let content = message.get("content").cloned().unwrap_or(Value::Null); + match role { + "user" => Ok(Some(StorylineTurn { + id, + kind: None, + timestamp, + source: "user".into(), + message: Value::String(openclaw_text_parts(&content, "text")), + reasoning_content: None, + reasoning_effort: None, + tool_calls: None, + observation: None, + metrics: None, + model_name: message + .get("model") + .and_then(Value::as_str) + .map(str::to_string), + llm_call_count: None, + is_copied_context: None, + latency_ms: None, + ttft_ms: None, + extra: None, + env: None, + prompt: None, + finished_at: None, + })), + "assistant" => { + let tool_calls = openclaw_tool_calls(&content); + Ok(Some(StorylineTurn { + id, + kind: (!tool_calls.is_empty()).then(|| "autonomous".into()), + timestamp, + source: "agent".into(), + message: Value::String(openclaw_text_parts(&content, "text")), + reasoning_content: omit_empty_string(&openclaw_text_parts(&content, "thinking")), + reasoning_effort: None, + tool_calls: (!tool_calls.is_empty()).then_some(tool_calls), + observation: None, + metrics: message.get("usage").cloned(), + model_name: message + .get("model") + .and_then(Value::as_str) + .map(str::to_string), + llm_call_count: Some(1), + is_copied_context: None, + latency_ms: None, + ttft_ms: None, + extra: None, + env: None, + prompt: None, + finished_at: None, + })) + } + "toolResult" => Ok(Some(StorylineTurn { + id, + kind: None, + timestamp, + source: "agent".into(), + message: Value::String(openclaw_text_parts(&content, "text")), + reasoning_content: None, + reasoning_effort: None, + tool_calls: None, + observation: Some(json!({ + "results": [openclaw_tool_result(message, &content)] + })), + metrics: None, + model_name: None, + llm_call_count: None, + is_copied_context: None, + latency_ms: None, + ttft_ms: None, + extra: Some(json!({ + "openclaw_role": "toolResult", + "toolCallId": message.get("toolCallId"), + "toolName": message.get("toolName"), + })), + env: None, + prompt: None, + finished_at: None, + })), + _ => Ok(None), + } +} + +fn openclaw_text_parts(content: &Value, part_type: &str) -> String { + match content { + Value::String(text) => text.clone(), + Value::Array(parts) => parts + .iter() + .filter(|part| part.get("type").and_then(Value::as_str) == Some(part_type)) + .filter_map(|part| { + part.get(part_type) + .or_else(|| part.get("text")) + .and_then(Value::as_str) + }) + .collect::>() + .join("\n"), + _ => String::new(), + } +} + +fn openclaw_tool_calls(content: &Value) -> Vec { + let Some(parts) = content.as_array() else { + return Vec::new(); + }; + parts + .iter() + .filter(|part| part.get("type").and_then(Value::as_str) == Some("toolCall")) + .map(|part| StorylineToolCall { + tool_call_id: part + .get("id") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(), + function_name: part + .get("name") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(), + arguments: part.get("arguments").cloned().unwrap_or(json!({})), + result: None, + duration_ms: None, + extra: None, + kind: Some("function".into()), + response: None, + }) + .filter(|call| !call.tool_call_id.is_empty() && !call.function_name.is_empty()) + .collect() +} + +fn openclaw_tool_result(message: &Map, content: &Value) -> Value { + let mut result = Map::new(); + result.insert( + "content".into(), + Value::String(openclaw_text_parts(content, "text")), + ); + if let Some(id) = message.get("toolCallId").cloned() { + result.insert("tool_use_id".into(), id.clone()); + result.insert("source_call_id".into(), id); + } + if let Some(name) = message.get("toolName").cloned() { + result.insert("name".into(), name); + } + if let Some(details) = message.get("details").and_then(Value::as_object) { + if let Some(status) = details.get("status").cloned() { + result.insert("status".into(), status); + } + if let Some(exit_code) = details.get("exitCode").cloned() { + result.insert("exit_code".into(), exit_code); + } + if let Some(duration_ms) = details.get("durationMs").cloned() { + result.insert("duration_ms".into(), duration_ms); + } + if let Some(aggregated) = details.get("aggregated").cloned() { + result.insert("aggregated_output".into(), aggregated); + } + } + Value::Object(result) +} + +struct OpenclawToolResult { + id: String, + text: String, + duration_ms: Option, + status: Option, + exit_code: Option, + result: Value, +} + +fn openclaw_pending_result(turn: &StorylineTurn) -> Option { + let extra = turn.extra.as_ref()?; + if extra.get("openclaw_role").and_then(Value::as_str) != Some("toolResult") { + return None; + } + let result = turn + .observation + .as_ref()? + .get("results")? + .as_array()? + .first()? + .clone(); + Some(OpenclawToolResult { + id: extra.get("toolCallId").and_then(Value::as_str)?.to_string(), + text: turn.message.as_str().unwrap_or("").to_string(), + duration_ms: result.get("duration_ms").and_then(Value::as_i64), + status: result + .get("status") + .and_then(Value::as_str) + .map(str::to_string), + exit_code: result.get("exit_code").and_then(Value::as_i64), + result, + }) +} + +fn attach_openclaw_tool_results(turns: &mut Vec) { + let pending = turns + .iter() + .filter_map(openclaw_pending_result) + .collect::>(); + for item in pending { + let Some(turn) = turns.iter_mut().rev().find(|turn| { + turn.tool_calls + .as_ref() + .is_some_and(|calls| calls.iter().any(|call| call.tool_call_id == item.id)) + }) else { + continue; + }; + if let Some(call) = turn + .tool_calls + .as_mut() + .and_then(|calls| calls.iter_mut().find(|call| call.tool_call_id == item.id)) + { + call.result = Some(Value::String(item.text)); + call.duration_ms = item.duration_ms; + if item.status.is_some() || item.exit_code.is_some() { + call.response = Some(StorylineToolResponse { + status: item.status, + exit_code: item.exit_code, + }); + } + } + let results = turn + .observation + .get_or_insert_with(|| json!({"results": []})); + if let Some(results) = results.get_mut("results").and_then(Value::as_array_mut) { + results.push(item.result); + } + } + turns.retain(|turn| openclaw_pending_result(turn).is_none()); +} + +fn actf_turn_prompt( + pair: (String, String), + baseline: Option<&(String, String)>, +) -> Option { + match baseline { + Some(baseline) if pair == *baseline => None, + Some(_) if pair.0.is_empty() && pair.1.is_empty() => { + Some(StorylinePrompt::explicit_clear()) + } + Some(_) | None => StorylinePrompt::from_pair(&pair.0, &pair.1), + } +} + +fn omit_empty_string(value: &str) -> Option { + (!value.is_empty()).then(|| value.to_string()) +} + +fn omit_empty_value(value: &Value) -> Option { + match value { + Value::Null => None, + Value::String(text) if text.is_empty() => None, + Value::Object(object) if object.is_empty() => None, + Value::Array(items) if items.is_empty() => None, + other => Some(other.clone()), + } +} + +fn actf_task(document: &ActfDocument, attempt: &ActfAttempt) -> Option { + let result = StorylineTaskResult { + task_correct: Some(document.correct), + correct: Some(attempt.correct), + final_answer: omit_empty_value(&attempt.final_answer), + ground_truth: omit_empty_value(&attempt.ground_truth), + status: omit_empty_string(&attempt.status), + score: omit_empty_value(&attempt.score), + error: omit_empty_string(&attempt.error), + artifacts: omit_empty_value(&attempt.artifacts), + category: omit_empty_string(&document.category), + attempts_tried: i64::try_from(document.attempts_tried).ok(), + solved_at: attempt_solved_at(&document.solved_at), + retry_count: document.extra.get("retry_count").cloned(), + retry_counts: document.extra.get("retry_counts").cloned(), + max_score: omit_empty_value(&attempt.max_score), + }; + let llm = StorylineTaskLlm { + k: i64::try_from(document.k).ok().filter(|k| *k > 0), + }; + let task = StorylineTask { + env: None, + llm: (!llm.is_empty()).then_some(llm), + result: (!result.is_empty()).then_some(result), + }; + (!task.is_empty()).then_some(task) +} + +fn attempt_solved_at(value: &Value) -> Option { + value + .as_str() + .filter(|text| !text.is_empty()) + .map(str::to_string) +} + +fn actf_final_metrics(document: &ActfDocument, attempt: &ActfAttempt) -> Value { + let mut metrics = Map::from_iter([ + ("correct".into(), json!(attempt.correct)), + ("score".into(), attempt.score.clone()), + ("status".into(), json!(attempt.status)), + ("task_correct".into(), json!(document.correct)), + ("analysis_result".into(), attempt.analysis_result.clone()), + ]); + if let Some(max_score) = omit_empty_value(&attempt.max_score) { + metrics.insert("max_score".into(), max_score); + } + Value::Object(metrics) +} + fn capture_actf_unknowns( document: &ActfDocument, attempt_id: &str, @@ -190,6 +676,16 @@ fn capture_actf_unknowns( for key in ["task_id", "correct", "attempts"] { root.remove(key); } + for key in [ + "category", + "k", + "attempts_tried", + "solved_at", + "retry_count", + "retry_counts", + ] { + root.remove(key); + } insert_actf_map(story, source_id, "", root)?; let attempt_prefix = pointer_join("/attempts", attempt_id); @@ -204,6 +700,13 @@ fn capture_actf_unknowns( "status", "score", "analysis_result", + "final_answer", + "ground_truth", + "error", + "artifacts", + "extra", + "meta", + "max_score", ] { attempt_map.remove(key); } @@ -215,7 +718,13 @@ fn capture_actf_unknowns( let trajectory_map = trajectory_value.as_object_mut().ok_or_else(|| { crate::InputIssue::invalid("serialized ACTF trajectory must be an object") })?; - for key in ["schema_version", "steps"] { + for key in [ + "schema_version", + "steps", + "started_at", + "finished_at", + "events", + ] { trajectory_map.remove(key); } insert_actf_map(story, source_id, &trajectory_prefix, trajectory_map)?; @@ -238,6 +747,9 @@ fn capture_actf_unknowns( "tools", "observation", "started_at", + "finished_at", + "system_prompt", + "user_content", ] { step_map.remove(key); } @@ -291,14 +803,17 @@ fn capture_actf_tool( prefix: &str, call: &ActfToolCall, ) -> crate::InputResult<()> { - story.unknown_fields.insert( - "actf", - source_id, - pointer_join(prefix, "type"), - Value::String(call.kind.clone()), - )?; let mut unknown = call.extra.clone(); - for key in ["name", "input", "command", "aggregated_output"] { + for key in [ + "name", + "input", + "arguments", + "command", + "aggregated_output", + "status", + "exit_code", + "function", + ] { unknown.remove(key); } insert_actf_map(story, source_id, prefix, &unknown) @@ -349,20 +864,74 @@ fn storylines_to_actf_pointer(stories: &[StorylineDocument]) -> Result 0) + .unwrap_or(stories.len() as i64); + let attempts_tried = stories[0] + .task + .as_ref() + .and_then(|task| task.result.as_ref()) + .and_then(|result| result.attempts_tried) + .unwrap_or(stories.len() as i64); + let solved_at = stories[0] + .task + .as_ref() + .and_then(|task| task.result.as_ref()) + .and_then(|result| result.solved_at.clone()) + .map(Value::String) + .unwrap_or(Value::Null); + let mut root = Map::from_iter([ + ("task_id".into(), Value::String(task_id)), + ("category".into(), Value::String(category)), + ("k".into(), Value::Number(k.into())), + ("correct".into(), task_correct), + ( + "attempts_tried".into(), + Value::Number(attempts_tried.into()), + ), + ("solved_at".into(), solved_at), + ("attempts".into(), Value::Object(attempts)), + ]); + if let Some(retry_count) = stories[0] + .task + .as_ref() + .and_then(|task| task.result.as_ref()) + .and_then(|result| result.retry_count.clone()) + { + root.insert("retry_count".into(), retry_count); + } + if let Some(retry_counts) = stories[0] + .task + .as_ref() + .and_then(|task| task.result.as_ref()) + .and_then(|result| result.retry_counts.clone()) + { + root.insert("retry_counts".into(), retry_counts); + } + let mut value = Value::Object(root); let mut source_id = None::; let mut unknown_fields = BTreeMap::::new(); let actf_sources = stories @@ -429,6 +998,7 @@ fn is_actf_source_owned(pointer: &str) -> bool { | "extra" | "analysis_result" | "meta" + | "max_score" | "schema_version" | "started_at" | "finished_at" @@ -444,70 +1014,109 @@ fn synthesize_actf(story: &StorylineDocument) -> Result { } let epoch = "1970-01-01 00:00:00+00:00".to_string(); let started_at = story - .turns - .first() - .and_then(|turn| turn.timestamp.as_ref()) + .started_at + .as_ref() .map(format_actf_timestamp) .transpose()? + .or_else(|| { + story + .turns + .first() + .and_then(|turn| turn.timestamp.as_ref()) + .map(format_actf_timestamp) + .transpose() + .ok() + .flatten() + }) .unwrap_or_else(|| epoch.clone()); let finished_at = story - .turns - .last() - .and_then(|turn| turn.timestamp.as_ref()) + .finished_at + .as_ref() .map(format_actf_timestamp) .transpose()? + .or_else(|| { + story + .turns + .last() + .and_then(|turn| turn.finished_at.as_ref().or(turn.timestamp.as_ref())) + .map(format_actf_timestamp) + .transpose() + .ok() + .flatten() + }) .unwrap_or_else(|| started_at.clone()); let steps = story .turns .iter() - .map(synthesize_step) + .map(|turn| synthesize_step(story, turn)) .collect::>>()?; - let correct = story - .final_metrics - .as_ref() - .and_then(|metrics| metrics.get("correct")) - .and_then(Value::as_bool) + let result = story.task.as_ref().and_then(|task| task.result.as_ref()); + let correct = result + .and_then(|result| result.correct) + .or_else(|| { + story + .final_metrics + .as_ref() + .and_then(|metrics| metrics.get("correct")) + .and_then(Value::as_bool) + }) .unwrap_or(false); - let score = story - .final_metrics - .as_ref() - .and_then(|metrics| metrics.get("score")) - .cloned() + let score = result + .and_then(|result| result.score.clone()) + .or_else(|| { + story + .final_metrics + .as_ref() + .and_then(|metrics| metrics.get("score")) + .cloned() + }) .unwrap_or(Value::Null); - let status = story - .final_metrics - .as_ref() - .and_then(|metrics| metrics.get("status")) - .and_then(Value::as_str) - .unwrap_or("completed") - .to_string(); + let status = result + .and_then(|result| result.status.clone()) + .or_else(|| { + story + .final_metrics + .as_ref() + .and_then(|metrics| metrics.get("status")) + .and_then(Value::as_str) + .map(str::to_string) + }) + .unwrap_or_else(|| "completed".into()); let attempt = ActfAttempt { correct, - final_answer: story - .turns - .last() - .map(|turn| turn.message.clone()) + final_answer: result + .and_then(|result| result.final_answer.clone()) .unwrap_or(Value::Null), - ground_truth: String::new(), + ground_truth: result + .and_then(|result| result.ground_truth.clone()) + .unwrap_or_else(|| Value::String(String::new())), trajectory: ActfTrajectory { schema_version: ACTF_SCHEMA_VERSION.into(), steps, started_at, finished_at: finished_at.clone(), + events: Vec::new(), extra: Map::new(), }, status, score, - error: String::new(), - artifacts: json!({}), - extra: json!({}), + error: result + .and_then(|result| result.error.clone()) + .unwrap_or_default(), + artifacts: result + .and_then(|result| result.artifacts.clone()) + .unwrap_or_else(|| json!({})), + extra: story.extra.clone().unwrap_or_else(|| json!({})), analysis_result: story .final_metrics .as_ref() .and_then(|metrics| metrics.get("analysis_result")) .cloned() .unwrap_or_else(|| json!({})), - meta: json!({}), + meta: story.meta.clone().unwrap_or_else(|| json!({})), + max_score: result + .and_then(|result| result.max_score.clone()) + .unwrap_or(Value::Null), extensions: Map::new(), }; let mut attempts = BTreeMap::new(); @@ -533,16 +1142,23 @@ fn synthesize_actf(story: &StorylineDocument) -> Result { Ok(document) } -fn synthesize_step(turn: &StorylineTurn) -> Result { - let step = serde_json::from_value(storyline_step_value(turn)?)?; +fn synthesize_step(story: &StorylineDocument, turn: &StorylineTurn) -> Result { + let step = serde_json::from_value(storyline_step_value(story, turn)?)?; Ok(step) } fn storyline_tool_to_actf(call: &StorylineToolCall) -> Value { let mut tool = Map::new(); tool.insert("id".into(), Value::String(call.tool_call_id.clone())); - if call.function_name == "command_execution" { - tool.insert("type".into(), Value::String("command_execution".into())); + let kind = call.kind.clone().unwrap_or_else(|| { + if call.function_name == "command_execution" { + "command_execution".into() + } else { + "tool_use".into() + } + }); + tool.insert("type".into(), Value::String(kind.clone())); + if kind == "command_execution" { if let Some(command) = call.arguments.get("command") { tool.insert("command".into(), command.clone()); } @@ -550,10 +1166,17 @@ fn storyline_tool_to_actf(call: &StorylineToolCall) -> Value { tool.insert("aggregated_output".into(), result.clone()); } } else { - tool.insert("type".into(), Value::String("tool_use".into())); tool.insert("name".into(), Value::String(call.function_name.clone())); tool.insert("input".into(), call.arguments.clone()); } + if let Some(response) = &call.response { + if let Some(status) = &response.status { + tool.insert("status".into(), Value::String(status.clone())); + } + if let Some(exit_code) = response.exit_code { + tool.insert("exit_code".into(), json!(exit_code)); + } + } Value::Object(tool) } @@ -565,9 +1188,6 @@ fn storyline_observation_to_actf(result: &Value) -> Value { extra.insert("aggregated_output".into(), content); } } - extra - .entry("type") - .or_insert_with(|| Value::String("tool_result".into())); if let Some(source_call_id) = source_call_id { if extra.contains_key("tool_use_id") { extra.insert("tool_use_id".into(), source_call_id); @@ -577,10 +1197,15 @@ fn storyline_observation_to_actf(result: &Value) -> Value { extra.insert("tool_use_id".into(), source_call_id); } } + if extra.get("type").is_none() + && (extra.contains_key("tool_use_id") || extra.contains_key("id")) + { + extra.insert("type".into(), Value::String("tool_result".into())); + } Value::Object(extra) } -fn storyline_step_value(turn: &StorylineTurn) -> Result { +fn storyline_step_value(story: &StorylineDocument, turn: &StorylineTurn) -> Result { let tools = turn .tool_calls .as_deref() @@ -624,6 +1249,12 @@ fn storyline_step_value(turn: &StorylineTurn) -> Result { .map(format_actf_timestamp) .transpose()? .unwrap_or_else(|| "1970-01-01 00:00:00+00:00".into()); + let finished_at = turn + .finished_at + .as_ref() + .map(format_actf_timestamp) + .transpose()? + .unwrap_or_else(|| timestamp.clone()); let mut assistant = Map::new(); assistant.insert( "content".into(), @@ -641,10 +1272,14 @@ fn storyline_step_value(turn: &StorylineTurn) -> Result { step.insert("metric".into(), Value::Object(metric)); step.insert("tools".into(), Value::Array(tools)); step.insert("observation".into(), Value::Array(observations)); - step.insert("started_at".into(), Value::String(timestamp.clone())); - step.insert("system_prompt".into(), Value::String(String::new())); - step.insert("user_content".into(), Value::String(String::new())); - step.insert("finished_at".into(), Value::String(timestamp.clone())); + step.insert("started_at".into(), Value::String(timestamp)); + let (system_prompt, user_content) = story + .effective_prompt(turn) + .map(StorylinePrompt::pair) + .unwrap_or_default(); + step.insert("system_prompt".into(), Value::String(system_prompt)); + step.insert("user_content".into(), Value::String(user_content)); + step.insert("finished_at".into(), Value::String(finished_at)); Ok(Value::Object(step)) } @@ -653,6 +1288,14 @@ fn actf_tool_name(call: &ActfToolCall) -> String { .get("name") .and_then(Value::as_str) .filter(|name| !name.is_empty()) + .or_else(|| { + call.extra + .get("function") + .and_then(Value::as_object) + .and_then(|function| function.get("name")) + .and_then(Value::as_str) + .filter(|name| !name.is_empty()) + }) .unwrap_or(&call.kind) .to_string() } @@ -661,9 +1304,20 @@ fn actf_tool_arguments(call: &ActfToolCall) -> Value { if let Some(input) = call.extra.get("input") { return input.clone(); } + if let Some(arguments) = call.extra.get("arguments") { + return arguments.clone(); + } if let Some(command) = call.extra.get("command") { return json!({ "command": command }); } + if let Some(arguments) = call + .extra + .get("function") + .and_then(Value::as_object) + .and_then(|function| function.get("arguments")) + { + return arguments.clone(); + } Value::Object(call.extra.clone()) } @@ -722,6 +1376,63 @@ mod tests { "status":"completed","score":null,"error":"","artifacts":{},"extra":{},"analysis_result":{},"meta":{}}} }"#; + #[test] + fn actf_event_log_trajectory_maps_openclaw_messages() { + let document = parse_actf_document( + r#"{ + "task_id":"gravitational-wave-detection","category":"astronomy","k":1, + "correct":false,"attempts_tried":1,"solved_at":null, + "attempts":{"1":{ + "correct":false,"status":"run_error", + "error":"RunError: timeout", + "trajectory":[ + {"type":"session","id":"sess-1","timestamp":"2026-06-17T07:26:27.170Z","cwd":"/root"}, + {"type":"model_change","timestamp":"2026-06-17T07:26:27.225Z","provider":"vllm","modelId":"qwen"}, + {"type":"message","timestamp":"2026-06-17T07:26:28Z", + "message":{"role":"user","content":[{"type":"text","text":"detect waves"}]}}, + {"type":"message","timestamp":"2026-06-17T07:26:29Z", + "message":{"role":"assistant","model":"qwen","content":[ + {"type":"thinking","thinking":"plan"}, + {"type":"text","text":"listing"}, + {"type":"toolCall","id":"c1","name":"exec","arguments":{"command":"ls"}} + ]}}, + {"type":"message","timestamp":"2026-06-17T07:26:30Z", + "message":{"role":"toolResult","toolCallId":"c1","toolName":"exec", + "content":[{"type":"text","text":"ok"}], + "details":{"status":"completed","exitCode":0,"durationMs":12}}} + ] + }} + }"#, + ) + .unwrap(); + let story = actf_to_storyline(&document).unwrap(); + assert_eq!(story.session_id, "gravitational-wave-detection"); + assert_eq!(story.agent.model_name.as_deref(), Some("qwen")); + assert_eq!( + story + .task + .as_ref() + .unwrap() + .env + .as_ref() + .unwrap() + .id + .as_deref(), + Some("sess-1") + ); + assert_eq!(story.turns.len(), 2); + assert_eq!(story.turns[0].source, "user"); + assert_eq!(story.turns[0].message, Value::String("detect waves".into())); + assert_eq!(story.turns[1].source, "agent"); + assert_eq!(story.turns[1].reasoning_content.as_deref(), Some("plan")); + let call = &story.turns[1].tool_calls.as_ref().unwrap()[0]; + assert_eq!(call.tool_call_id, "c1"); + assert_eq!(call.function_name, "exec"); + assert_eq!(call.result.as_ref().unwrap(), "ok"); + assert_eq!(call.duration_ms, Some(12)); + assert_eq!(call.response.as_ref().unwrap().exit_code, Some(0)); + } + #[test] fn actf_storyline_roundtrip_is_lossless() { let document = parse_actf_document(FIXTURE).unwrap(); @@ -759,6 +1470,7 @@ mod tests { "extra": {"harness_metrics": {"passed": 0}}, "analysis_result": {"quality": 7}, "meta": {"suite": "fixture"}, + "max_score": 10, "trajectory": { "schema_version": "ACTF_v1.0", "started_at": "2026-01-01 00:00:00+00:00", @@ -814,6 +1526,25 @@ mod tests { let story = actf_to_storyline(&document).unwrap(); let fields = &story.unknown_fields.sources["actf"].fields; assert_eq!(fields["/vendor_root"], json!({"kept": true})); + assert_eq!(story.extra, Some(json!({"harness_metrics": {"passed": 0}}))); + assert_eq!(story.meta, Some(json!({"suite": "fixture"}))); + assert_eq!( + story + .task + .as_ref() + .unwrap() + .result + .as_ref() + .unwrap() + .max_score, + Some(json!(10)) + ); + assert_eq!( + story.prompt.as_ref().map(StorylinePrompt::pair), + Some(("system".into(), "task".into())) + ); + assert_eq!(story.turns[0].prompt, None); + assert_eq!(story.turns[0].message, json!("done")); for pointer in [ "/category", "/k", @@ -825,32 +1556,26 @@ mod tests { "/attempts/1/ground_truth", "/attempts/1/error", "/attempts/1/artifacts", - "/attempts/1/extra", - "/attempts/1/meta", "/attempts/1/trajectory/started_at", "/attempts/1/trajectory/finished_at", - "/attempts/1/trajectory/steps/0/system_prompt", - "/attempts/1/trajectory/steps/0/user_content", "/attempts/1/trajectory/steps/0/finished_at", "/attempts/1/trajectory/steps/0/tools/0/type", "/attempts/1/trajectory/steps/0/tools/0/exit_code", "/attempts/1/trajectory/steps/0/tools/0/status", - ] { - assert!( - fields.contains_key(pointer), - "missing unknown pointer {pointer}" - ); - } - for pointer in [ "/task_id", "/correct", "/attempts/1/correct", "/attempts/1/status", "/attempts/1/score", + "/attempts/1/extra", + "/attempts/1/meta", + "/attempts/1/max_score", "/attempts/1/analysis_result", "/attempts/1/trajectory/schema_version", "/attempts/1/trajectory/steps/0/step_id", "/attempts/1/trajectory/steps/0/started_at", + "/attempts/1/trajectory/steps/0/system_prompt", + "/attempts/1/trajectory/steps/0/user_content", "/attempts/1/trajectory/steps/0/assistant_content/content", "/attempts/1/trajectory/steps/0/assistant_content/reasoning_content", "/attempts/1/trajectory/steps/0/tools/0/id", @@ -862,7 +1587,6 @@ mod tests { "canonical pointer leaked into unknowns: {pointer}" ); } - assert!(story.extra.is_none()); assert!(story.turns[0].extra.is_none()); assert_eq!( story.final_metrics.as_ref().unwrap()["analysis_result"]["quality"], @@ -870,7 +1594,20 @@ mod tests { ); let call = &story.turns[0].tool_calls.as_ref().unwrap()[0]; assert_eq!(call.result.as_ref().unwrap(), "/app\n"); + assert_eq!(call.kind.as_deref(), Some("command_execution")); + assert_eq!(call.response.as_ref().unwrap().exit_code, Some(0)); + assert_eq!( + call.response.as_ref().unwrap().status.as_deref(), + Some("completed") + ); assert!(call.extra.is_none()); + let task = story.task.as_ref().unwrap(); + assert_eq!(task.llm.as_ref().unwrap().k, Some(1)); + assert_eq!( + task.result.as_ref().unwrap().category.as_deref(), + Some("software-engineering") + ); + assert_eq!(task.result.as_ref().unwrap().retry_count, Some(json!(2))); assert_eq!( story.turns[0].observation.as_ref().unwrap()["results"][0]["content"], "/app\n" @@ -879,6 +1616,120 @@ mod tests { assert_eq!(storyline_to_actf(&story).unwrap(), document); } + #[test] + fn actf_name_arguments_tool_maps_without_type_or_id() { + let mut value: Value = serde_json::from_str(FIXTURE).unwrap(); + let tool = json!({"name": "Glob", "arguments": {"path": "/tmp", "pattern": "**/*"}}); + value["attempts"]["1"]["trajectory"]["steps"][0]["tools"] = json!([tool]); + value["attempts"]["1"]["trajectory"]["steps"][0]["assistant_content"]["tool_calls"] = + json!([tool]); + value["attempts"]["1"]["trajectory"]["steps"][0]["observation"] = + json!([{"role": "tool", "text": "listed"}]); + let document: ActfDocument = serde_json::from_value(value).unwrap(); + let story = actf_to_storyline(&document).unwrap(); + let call = &story.turns[0].tool_calls.as_ref().unwrap()[0]; + assert_eq!(call.function_name, "Glob"); + assert_eq!(call.arguments["pattern"], "**/*"); + assert_eq!(call.tool_call_id, "step-1-tool-0"); + assert!(call.kind.is_none()); + } + + #[test] + fn actf_object_ground_truth_roundtrips() { + let mut value: Value = serde_json::from_str(FIXTURE).unwrap(); + value["attempts"]["1"]["ground_truth"] = json!({"checklist_path": "/tmp/check.json"}); + let document: ActfDocument = serde_json::from_value(value).unwrap(); + let story = actf_to_storyline(&document).unwrap(); + assert_eq!( + story + .task + .as_ref() + .unwrap() + .result + .as_ref() + .unwrap() + .ground_truth, + Some(json!({"checklist_path": "/tmp/check.json"})) + ); + assert_eq!( + storyline_to_actf(&story).unwrap().attempts["1"].ground_truth, + json!({"checklist_path": "/tmp/check.json"}) + ); + } + + #[test] + fn actf_empty_tools_falls_back_to_assistant_function_calls() { + let mut value: Value = serde_json::from_str(FIXTURE).unwrap(); + value["attempts"]["1"]["trajectory"]["steps"][0]["tools"] = json!([]); + value["attempts"]["1"]["trajectory"]["steps"][0]["assistant_content"]["tool_calls"] = json!([{ + "id": "c1", + "type": "function", + "function": { + "name": "bash_command", + "arguments": {"keystrokes": "pwd\n", "duration": 0.1} + } + }]); + let document: ActfDocument = serde_json::from_value(value).unwrap(); + let story = actf_to_storyline(&document).unwrap(); + let call = &story.turns[0].tool_calls.as_ref().unwrap()[0]; + assert_eq!(call.tool_call_id, "c1"); + assert_eq!(call.function_name, "bash_command"); + assert_eq!(call.kind.as_deref(), Some("function")); + assert_eq!(call.arguments["keystrokes"], "pwd\n"); + assert_eq!(call.arguments["duration"], 0.1); + assert!( + !story + .unknown_fields + .sources + .get("actf") + .map(|source| source + .fields + .keys() + .any(|pointer| pointer.contains("/function"))) + .unwrap_or(false), + "OpenAI function wrapper should be consumed" + ); + } + + #[test] + fn actf_content_only_observation_keeps_missing_type() { + let mut value: Value = serde_json::from_str(FIXTURE).unwrap(); + value["attempts"]["1"]["trajectory"]["steps"][0]["observation"] = + json!([{"content": "env output"}]); + let document: ActfDocument = serde_json::from_value(value).unwrap(); + let story = actf_to_storyline(&document).unwrap(); + let result = &story.turns[0].observation.as_ref().unwrap()["results"][0]; + assert_eq!(result["content"], "env output"); + assert!(result.get("type").is_none()); + let recovered = storyline_to_actf(&story).unwrap(); + assert_eq!( + recovered.attempts["1"].trajectory.steps[0].observation[0].kind, + "" + ); + assert_eq!( + recovered.attempts["1"].trajectory.steps[0].observation[0].extra["content"], + "env output" + ); + } + + #[test] + fn actf_null_reasoning_content_omits_reason() { + let mut value: Value = serde_json::from_str(FIXTURE).unwrap(); + value["attempts"]["1"]["trajectory"]["steps"][0]["assistant_content"] + ["reasoning_content"] = Value::Null; + let document: ActfDocument = serde_json::from_value(value).unwrap(); + let story = actf_to_storyline(&document).unwrap(); + assert!(story.turns[0].reasoning_content.is_none()); + assert_eq!( + storyline_to_actf(&story).unwrap().attempts["1"] + .trajectory + .steps[0] + .assistant_content + .reasoning_content, + "" + ); + } + #[test] fn actf_unknown_fields_preserve_values_but_storyline_fields_are_authoritative() { let mut value: Value = serde_json::from_str(FIXTURE).unwrap(); @@ -958,6 +1809,69 @@ mod tests { assert_eq!(storylines_to_actf(&stories).unwrap(), document); } + #[test] + fn actf_prompt_uses_document_baseline_and_turn_overlay() { + let mut document = parse_actf_document(FIXTURE).unwrap(); + let steps = &mut document.attempts.get_mut("1").unwrap().trajectory.steps; + steps[0].system_prompt.clear(); + steps[0].user_content.clear(); + let mut changed = steps[0].clone(); + changed.step_id = 2; + changed.system_prompt = "system".into(); + changed.user_content = "task".into(); + changed.assistant_content.content = "second".into(); + changed.assistant_content.tool_calls.clear(); + changed.tools.clear(); + changed.observation.clear(); + let mut again = changed.clone(); + again.step_id = 3; + again.assistant_content.content = "third".into(); + let mut overlay = changed.clone(); + overlay.step_id = 4; + overlay.user_content = "later".into(); + overlay.assistant_content.content = "fourth".into(); + steps.push(changed); + steps.push(again); + steps.push(overlay); + + let story = actf_to_storyline(&document).unwrap(); + assert_eq!( + story.prompt.as_ref().map(StorylinePrompt::pair), + Some(("system".into(), "task".into())) + ); + assert_eq!( + story.turns[0].prompt, + Some(StorylinePrompt::explicit_clear()) + ); + assert_eq!(story.turns[1].prompt, None); + assert_eq!(story.turns[2].prompt, None); + assert_eq!( + story.turns[3].prompt.as_ref().map(StorylinePrompt::pair), + Some(("system".into(), "later".into())) + ); + assert_eq!(story.turns[3].message, json!("fourth")); + assert!(!story + .unknown_fields + .sources + .get("actf") + .map(|source| source + .fields + .keys() + .any(|key| { key.ends_with("/system_prompt") || key.ends_with("/user_content") })) + .unwrap_or(false)); + + let restored = storyline_to_actf(&story).unwrap(); + let restored_steps = &restored.attempts["1"].trajectory.steps; + assert_eq!(restored_steps[0].system_prompt, ""); + assert_eq!(restored_steps[0].user_content, ""); + assert_eq!(restored_steps[1].system_prompt, "system"); + assert_eq!(restored_steps[1].user_content, "task"); + assert_eq!(restored_steps[2].system_prompt, "system"); + assert_eq!(restored_steps[2].user_content, "task"); + assert_eq!(restored_steps[3].system_prompt, "system"); + assert_eq!(restored_steps[3].user_content, "later"); + } + #[test] fn synthesis_completes_partial_metrics_and_normalizes_observations() { let mut story = StorylineDocument::new("session", "agent"); @@ -976,6 +1890,8 @@ mod tests { result: None, duration_ms: None, extra: None, + kind: None, + response: None, }]), observation: Some(json!({ "results": [{"source_call_id": "call-1", "content": "ok"}] @@ -987,6 +1903,9 @@ mod tests { latency_ms: None, ttft_ms: None, extra: None, + env: None, + prompt: None, + finished_at: None, }); let document = storyline_to_actf(&story).unwrap(); diff --git a/crates/persisting-pchronicle/src/convert/atif.rs b/crates/persisting-pchronicle/src/convert/atif.rs index ef69beeb..c10e72bb 100644 --- a/crates/persisting-pchronicle/src/convert/atif.rs +++ b/crates/persisting-pchronicle/src/convert/atif.rs @@ -196,6 +196,8 @@ fn atif_to_storyline_node( result: c.result.clone(), duration_ms, extra: c.extra.clone(), + kind: None, + response: None, } }) .collect::>() @@ -227,6 +229,9 @@ fn atif_to_storyline_node( latency_ms, ttft_ms, extra: step.extra.clone(), + env: None, + prompt: None, + finished_at: None, }; let derived = turn.effective_kind().to_string(); if !matches!( @@ -265,9 +270,14 @@ fn atif_to_storyline_node( }), child_session_ids: child_ids, notes: traj.notes.clone(), + task: None, + prompt: None, + started_at: None, + finished_at: None, final_metrics: traj.final_metrics.clone(), continued_trajectory_ref: traj.continued_trajectory_ref.clone(), extra: traj.extra.clone(), + meta: None, unknown_fields: Default::default(), unknown_key_counts: Default::default(), turns, diff --git a/crates/persisting-pchronicle/src/convert/events.rs b/crates/persisting-pchronicle/src/convert/events.rs index 4202a582..dc67e722 100644 --- a/crates/persisting-pchronicle/src/convert/events.rs +++ b/crates/persisting-pchronicle/src/convert/events.rs @@ -226,6 +226,9 @@ fn events_to_storyline_unchecked(events: &[EventRecord]) -> Result Result Result Result, } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize)] pub struct ActfTrajectory { pub schema_version: String, pub steps: Vec, pub started_at: String, pub finished_at: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub events: Vec, #[serde(flatten)] pub extra: Map, } +impl ActfTrajectory { + pub fn from_event_log(events: Vec) -> Self { + let started_at = events + .iter() + .find_map(|event| event.get("timestamp").and_then(Value::as_str)) + .unwrap_or("1970-01-01T00:00:00Z") + .to_string(); + let finished_at = events + .iter() + .rev() + .find_map(|event| event.get("timestamp").and_then(Value::as_str)) + .unwrap_or(started_at.as_str()) + .to_string(); + Self { + schema_version: ACTF_SCHEMA_VERSION.into(), + steps: Vec::new(), + started_at, + finished_at, + events, + extra: Map::new(), + } + } +} + +#[derive(Deserialize)] +#[serde(untagged)] +enum ActfTrajectoryWire { + Events(Vec), + Canonical { + schema_version: String, + steps: Vec, + started_at: String, + finished_at: String, + #[serde(default)] + events: Vec, + #[serde(flatten)] + extra: Map, + }, +} + +impl<'de> Deserialize<'de> for ActfTrajectory { + fn deserialize(deserializer: D) -> std::result::Result + where + D: Deserializer<'de>, + { + match ActfTrajectoryWire::deserialize(deserializer)? { + ActfTrajectoryWire::Events(events) => Ok(Self::from_event_log(events)), + ActfTrajectoryWire::Canonical { + schema_version, + steps, + started_at, + finished_at, + events, + extra, + } => Ok(Self { + schema_version, + steps, + started_at, + finished_at, + events, + extra, + }), + } + } +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ActfStep { pub step_id: i64, pub assistant_content: ActfAssistantContent, pub metric: ActfMetric, + #[serde(default, deserialize_with = "null_as_empty_string")] pub system_prompt: String, + #[serde(default, deserialize_with = "null_as_empty_string")] pub user_content: String, + #[serde(default, deserialize_with = "null_as_default")] pub tools: Vec, + #[serde(default, deserialize_with = "null_as_default")] pub observation: Vec, pub started_at: String, pub finished_at: String, @@ -68,10 +151,23 @@ pub struct ActfStep { pub extra: Map, } +impl ActfStep { + pub fn effective_tools(&self) -> &[ActfToolCall] { + if self.tools.is_empty() { + &self.assistant_content.tool_calls + } else { + &self.tools + } + } +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ActfAssistantContent { + #[serde(default, deserialize_with = "null_as_empty_string")] pub content: String, + #[serde(default, deserialize_with = "null_as_empty_string")] pub reasoning_content: String, + #[serde(default, deserialize_with = "null_as_default")] pub tool_calls: Vec, #[serde(flatten)] pub extra: Map, @@ -79,10 +175,15 @@ pub struct ActfAssistantContent { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ActfMetric { + #[serde(default)] pub prompt_tokens_len: Value, + #[serde(default)] pub completion_tokens_len: Value, + #[serde(default)] pub llm_infer_ms: Value, + #[serde(default)] pub env_action_ms: Value, + #[serde(default)] pub stop_reason: Value, #[serde(flatten)] pub extra: Map, @@ -90,21 +191,61 @@ pub struct ActfMetric { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ActfToolCall { - #[serde(rename = "type")] + #[serde( + rename = "type", + default, + deserialize_with = "null_as_empty_string", + skip_serializing_if = "String::is_empty" + )] pub kind: String, + #[serde( + default, + deserialize_with = "null_as_empty_string", + skip_serializing_if = "String::is_empty" + )] pub id: String, #[serde(flatten)] pub extra: Map, } +impl ActfToolCall { + pub fn effective_id(&self, step_id: i64, index: usize) -> String { + if self.id.trim().is_empty() { + format!("step-{step_id}-tool-{index}") + } else { + self.id.clone() + } + } +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ActfObservation { - #[serde(rename = "type")] + #[serde( + rename = "type", + default, + deserialize_with = "null_as_empty_string", + skip_serializing_if = "String::is_empty" + )] pub kind: String, #[serde(flatten)] pub extra: Map, } +fn null_as_empty_string<'de, D>(deserializer: D) -> std::result::Result +where + D: Deserializer<'de>, +{ + Ok(Option::::deserialize(deserializer)?.unwrap_or_default()) +} + +fn null_as_default<'de, T, D>(deserializer: D) -> std::result::Result +where + T: Default + Deserialize<'de>, + D: Deserializer<'de>, +{ + Ok(Option::::deserialize(deserializer)?.unwrap_or_default()) +} + impl ActfDocument { #[cfg(any(test, feature = "lance-store"))] pub fn from_json_str(input: &str) -> InputResult { @@ -155,11 +296,6 @@ impl ActfDocument { if attempt_id.trim().is_empty() { return Err(InputIssue::invalid("ACTF attempt id must not be empty")); } - if attempt.status.trim().is_empty() { - return Err(InputIssue::invalid(format!( - "ACTF attempt '{attempt_id}' status is required" - ))); - } attempt .trajectory .validate() @@ -182,7 +318,7 @@ impl ActfTrajectory { "ACTF trajectory started_at and finished_at are required", )); } - if self.steps.is_empty() { + if self.steps.is_empty() && self.events.is_empty() { return Err(InputIssue::invalid( "ACTF trajectory steps must not be empty", )); @@ -209,7 +345,10 @@ impl ActfTrajectory { step.step_id ))); } - if step.assistant_content.tool_calls != step.tools { + if !step.tools.is_empty() + && !step.assistant_content.tool_calls.is_empty() + && step.assistant_content.tool_calls != step.tools + { return Err(InputIssue::invalid(format!( "ACTF step {} assistant_content.tool_calls must equal tools", step.step_id @@ -229,27 +368,16 @@ impl ActfTrajectory { } let mut step_call_ids = HashSet::new(); - for call in &step.tools { - if call.kind.trim().is_empty() || call.id.trim().is_empty() { - return Err(InputIssue::invalid(format!( - "ACTF step {} tool calls require type and id", - step.step_id - ))); - } - if !step_call_ids.insert(call.id.as_str()) { + for (call_index, call) in step.effective_tools().iter().enumerate() { + let call_id = call.effective_id(step.step_id, call_index); + if !step_call_ids.insert(call_id) { return Err(InputIssue::invalid(format!( "duplicate ACTF tool call id '{}'", - call.id + call.effective_id(step.step_id, call_index) ))); } } for observation in &step.observation { - if observation.kind.trim().is_empty() { - return Err(InputIssue::invalid(format!( - "ACTF step {} observation type is required", - step.step_id - ))); - } let referenced_id = observation .extra .get("tool_use_id") @@ -331,6 +459,36 @@ mod tests { .unwrap() } + #[test] + fn accepts_name_arguments_tool_without_type_or_id() { + let mut value = serde_json::to_value(fixture()).unwrap(); + let tool = json!({"name": "Glob", "arguments": {"pattern": "**/*"}}); + value["attempts"]["1"]["trajectory"]["steps"][0]["tools"] = json!([tool]); + value["attempts"]["1"]["trajectory"]["steps"][0]["assistant_content"]["tool_calls"] = + json!([tool]); + value["attempts"]["1"]["trajectory"]["steps"][0]["observation"] = + json!([{"role":"tool","text":"ok"}]); + let document: ActfDocument = serde_json::from_value(value).unwrap(); + document.validate().unwrap(); + let call = &document.attempts["1"].trajectory.steps[0].tools[0]; + assert_eq!(call.kind, ""); + assert_eq!(call.id, ""); + assert_eq!(call.extra["name"], "Glob"); + assert_eq!(call.effective_id(1, 0), "step-1-tool-0"); + } + + #[test] + fn accepts_object_ground_truth() { + let mut value = serde_json::to_value(fixture()).unwrap(); + value["attempts"]["1"]["ground_truth"] = json!({"checklist_path": "/tmp/check.json"}); + let document: ActfDocument = serde_json::from_value(value).unwrap(); + assert_eq!( + document.attempts["1"].ground_truth, + json!({"checklist_path": "/tmp/check.json"}) + ); + document.validate().unwrap(); + } + #[test] fn parses_and_validates_actf_v1() { let document = fixture(); @@ -339,6 +497,79 @@ mod tests { assert_eq!(ActfDocument::from_json_str(&json).unwrap(), document); } + #[test] + fn accepts_empty_tools_when_assistant_has_tool_calls() { + let mut value = serde_json::to_value(fixture()).unwrap(); + value["attempts"]["1"]["trajectory"]["steps"][0]["tools"] = json!([]); + value["attempts"]["1"]["trajectory"]["steps"][0]["assistant_content"]["tool_calls"] = json!([{ + "id": "call-1", + "type": "function", + "function": {"name": "bash_command", "arguments": {"keystrokes": "pwd\n"}} + }]); + let document: ActfDocument = serde_json::from_value(value).unwrap(); + document.validate().unwrap(); + assert_eq!( + document.attempts["1"].trajectory.steps[0] + .effective_tools() + .len(), + 1 + ); + assert_eq!( + document.attempts["1"].trajectory.steps[0].effective_tools()[0].id, + "call-1" + ); + } + + #[test] + fn accepts_observation_without_type() { + let mut value = serde_json::to_value(fixture()).unwrap(); + value["attempts"]["1"]["trajectory"]["steps"][0]["observation"] = json!([{"content":"ok"}]); + let document: ActfDocument = serde_json::from_value(value).unwrap(); + assert_eq!( + document.attempts["1"].trajectory.steps[0].observation[0].kind, + "" + ); + assert_eq!( + document.attempts["1"].trajectory.steps[0].observation[0].extra["content"], + "ok" + ); + document.validate().unwrap(); + } + + #[test] + fn accepts_openclaw_event_log_as_trajectory() { + let mut value = serde_json::to_value(fixture()).unwrap(); + value["attempts"]["1"]["status"] = json!("run_error"); + value["attempts"]["1"]["trajectory"] = json!([ + {"type":"session","id":"s1","timestamp":"2026-06-17T07:26:27.170Z","cwd":"/root"}, + {"type":"message","timestamp":"2026-06-17T07:26:28Z", + "message":{"role":"user","content":[{"type":"text","text":"hello"}]}} + ]); + let document: ActfDocument = serde_json::from_value(value).unwrap(); + document.validate().unwrap(); + assert!(document.attempts["1"].trajectory.steps.is_empty()); + assert_eq!(document.attempts["1"].trajectory.events.len(), 2); + assert_eq!( + document.attempts["1"].trajectory.started_at, + "2026-06-17T07:26:27.170Z" + ); + } + + #[test] + fn treats_null_reasoning_content_as_empty_string() { + let mut value = serde_json::to_value(fixture()).unwrap(); + value["attempts"]["1"]["trajectory"]["steps"][0]["assistant_content"] + ["reasoning_content"] = Value::Null; + let document: ActfDocument = serde_json::from_value(value).unwrap(); + assert_eq!( + document.attempts["1"].trajectory.steps[0] + .assistant_content + .reasoning_content, + "" + ); + document.validate().unwrap(); + } + #[test] fn rejects_observation_without_matching_tool() { let mut document = fixture(); diff --git a/crates/persisting-pchronicle/src/formats/detect.rs b/crates/persisting-pchronicle/src/formats/detect.rs index 2c68f748..84746e79 100644 --- a/crates/persisting-pchronicle/src/formats/detect.rs +++ b/crates/persisting-pchronicle/src/formats/detect.rs @@ -65,6 +65,19 @@ pub fn detect_format_from_content(input: &str) -> Result> Ok(None) } +fn looks_like_actf_attempt(attempt: &serde_json::Value) -> bool { + match attempt.get("trajectory") { + Some(trajectory) if trajectory.is_array() => trajectory + .as_array() + .is_some_and(|events| events.iter().all(serde_json::Value::is_object)), + Some(trajectory) => trajectory + .get("schema_version") + .and_then(serde_json::Value::as_str) + .is_some_and(|version| version.starts_with("ACTF_")), + None => false, + } +} + fn detect_json_format(v: &serde_json::Value) -> Option { let candidate = v.as_array().and_then(|values| values.first()).unwrap_or(v); if candidate @@ -74,19 +87,12 @@ fn detect_json_format(v: &serde_json::Value) -> Option { { return Some(DocumentFormat::Storyline); } - let is_actf_document = v - .get("attempts") - .and_then(serde_json::Value::as_object) - .is_some_and(|attempts| { - !attempts.is_empty() - && attempts.values().all(|attempt| { - attempt - .get("trajectory") - .and_then(|trajectory| trajectory.get("schema_version")) - .and_then(serde_json::Value::as_str) - .is_some_and(|version| version.starts_with("ACTF_")) - }) - }); + let is_actf_document = v.get("task_id").is_some() + && v.get("attempts") + .and_then(serde_json::Value::as_object) + .is_some_and(|attempts| { + !attempts.is_empty() && attempts.values().all(looks_like_actf_attempt) + }); if is_actf_document { return Some(DocumentFormat::Actf); } @@ -155,4 +161,28 @@ mod tests { Some(DocumentFormat::Storyline) ); } + + #[test] + fn detects_actf_error_dump_with_event_log_trajectory() { + let input = r#"{ + "task_id":"gravitational-wave-detection", + "category":"astronomy", + "k":1, + "correct":false, + "attempts_tried":1, + "attempts":{"1":{ + "correct":false, + "status":"run_error", + "trajectory":[ + {"type":"session","id":"s1","timestamp":"2026-06-17T07:26:27.170Z","cwd":"/root"}, + {"type":"message","id":"m1","timestamp":"2026-06-17T07:26:28Z", + "message":{"role":"user","content":[{"type":"text","text":"hello"}]}} + ] + }} + }"#; + assert_eq!( + detect_format_from_content(input).unwrap(), + Some(DocumentFormat::Actf) + ); + } } diff --git a/crates/persisting-pchronicle/src/formats/openai_corpus.rs b/crates/persisting-pchronicle/src/formats/openai_corpus.rs index 9156e426..0cf9f18b 100644 --- a/crates/persisting-pchronicle/src/formats/openai_corpus.rs +++ b/crates/persisting-pchronicle/src/formats/openai_corpus.rs @@ -13,8 +13,8 @@ use serde_json::{json, Map, Value}; use crate::format::DocumentFormat; use crate::formats::storyline::{ - StorylineAgent, StorylineDocument, StorylineOrigin, StorylineToolCall, StorylineTurn, - STORYLINE_SCHEMA_VERSION, + StorylineAgent, StorylineDocument, StorylineEnv, StorylineOrigin, StorylineTask, + StorylineToolCall, StorylineTurn, STORYLINE_SCHEMA_VERSION, }; use crate::formats::timestamp::StorylineTimestamp; use crate::formats::unknown_fields::{ @@ -380,6 +380,7 @@ fn consume_openai_meta( { meta.remove("source"); } + meta.remove("group_id"); if let Some(original_env_state) = meta.remove("env_state") { if is_known_optional_empty(&original_env_state) { @@ -397,6 +398,16 @@ fn consume_openai_meta( for field in OPENAI_ENV_METRIC_FIELDS { env_state.remove(*field); } + for field in [ + "endpoint", + "event_type", + "redaction_policy", + "request_id", + "upstream_base_url", + "weight_version", + ] { + env_state.remove(field); + } if !env_state.is_empty() { meta.insert("env_state".into(), Value::Object(env_state)); } @@ -546,6 +557,15 @@ fn consume_openai_row( row.remove("session_id"); row.remove("step_id"); row.remove("created_at"); + for key in ["env_name", "dataset_type", "dt", "id"] { + if row + .get(key) + .and_then(Value::as_str) + .is_some_and(|value| !value.is_empty()) + { + row.remove(key); + } + } for field in OPENAI_ROW_METRIC_FIELDS { row.remove(*field); } @@ -755,38 +775,104 @@ fn populate_openai_row_fields( row.insert("created_at".into(), timestamp.source_value().clone()); } - let Some(metrics) = agent.metrics.as_ref().and_then(Value::as_object) else { - return; - }; - for field in OPENAI_ROW_METRIC_FIELDS { - if let Some(value) = metrics.get(*field) { - row.insert((*field).to_string(), value.clone()); + if let Some(metrics) = agent.metrics.as_ref().and_then(Value::as_object) { + for field in OPENAI_ROW_METRIC_FIELDS { + if let Some(value) = metrics.get(*field) { + row.insert((*field).to_string(), value.clone()); + } } - } - let mut env_state = Map::new(); - for field in OPENAI_ENV_METRIC_FIELDS { - if OPENAI_ROW_METRIC_FIELDS.contains(field) && row.contains_key(*field) { - continue; + let mut env_state = Map::new(); + for field in OPENAI_ENV_METRIC_FIELDS { + if OPENAI_ROW_METRIC_FIELDS.contains(field) && row.contains_key(*field) { + continue; + } + if let Some(value) = metrics.get(*field) { + env_state.insert((*field).to_string(), value.clone()); + } + } + if !metrics.contains_key("total_latency_ms") { + if let Some(latency_ms) = agent.latency_ms { + env_state.insert("total_latency_ms".into(), json!(latency_ms)); + } } - if let Some(value) = metrics.get(*field) { - env_state.insert((*field).to_string(), value.clone()); + if !metrics.contains_key("ttft_ms") { + if let Some(ttft_ms) = agent.ttft_ms { + env_state.insert("ttft_ms".into(), json!(ttft_ms)); + } + } + if !env_state.is_empty() { + row.insert( + "meta_json".into(), + json!({"env_state": Value::Object(env_state)}), + ); } } - if !metrics.contains_key("total_latency_ms") { - if let Some(latency_ms) = agent.latency_ms { - env_state.insert("total_latency_ms".into(), json!(latency_ms)); + write_openai_env_fields(row, story, agent); +} + +fn write_openai_env_fields( + row: &mut Map, + story: &StorylineDocument, + agent: &StorylineTurn, +) { + let merged = match ( + story.task.as_ref().and_then(|task| task.env.as_ref()), + agent.env.as_ref(), + ) { + (Some(base), Some(overlay)) => Some(base.merge_overlay(overlay)), + (Some(base), None) => Some(base.clone()), + (None, Some(overlay)) => Some(overlay.clone()), + (None, None) => None, + }; + let Some(env) = merged else { + return; + }; + if let Some(name) = &env.name { + row.insert("env_name".into(), Value::String(name.clone())); + } + if let Some(id) = &env.id { + row.insert("id".into(), Value::String(id.clone())); + } + if let Some(state) = &env.state { + if let Some(dataset_type) = state.get("dataset_type") { + row.insert("dataset_type".into(), dataset_type.clone()); + } + if let Some(dt) = state.get("dt") { + row.insert("dt".into(), dt.clone()); } } - if !metrics.contains_key("ttft_ms") { - if let Some(ttft_ms) = agent.ttft_ms { - env_state.insert("ttft_ms".into(), json!(ttft_ms)); + let mut meta = row + .remove("meta_json") + .and_then(|value| value.as_object().cloned()) + .unwrap_or_default(); + if let Some(group_id) = env.state.as_ref().and_then(|state| state.get("group_id")) { + meta.insert("group_id".into(), group_id.clone()); + } + let mut env_state = meta + .remove("env_state") + .and_then(|value| value.as_object().cloned()) + .unwrap_or_default(); + if let Some(endpoint) = &env.endpoint { + env_state.insert("endpoint".into(), Value::String(endpoint.clone())); + } + if let Some(event_type) = &env.event_type { + env_state.insert("event_type".into(), Value::String(event_type.clone())); + } + if let Some(request_id) = &env.request_id { + env_state.insert("request_id".into(), Value::String(request_id.clone())); + } + if let Some(state) = &env.state { + for key in ["redaction_policy", "upstream_base_url", "weight_version"] { + if let Some(value) = state.get(key) { + env_state.insert(key.to_string(), value.clone()); + } } } if !env_state.is_empty() { - row.insert( - "meta_json".into(), - json!({"env_state": Value::Object(env_state)}), - ); + meta.insert("env_state".into(), Value::Object(env_state)); + } + if !meta.is_empty() { + row.insert("meta_json".into(), Value::Object(meta)); } } @@ -1015,6 +1101,9 @@ fn openai_context_turn(id: i64, message: &Map) -> Option InputResult<(i64, i64)> Ok((user_id, agent_id)) } +fn openai_string(value: Option<&Value>) -> Option { + value + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +fn assign_stable_string( + slot: &mut Option, + incoming: Option, + overlay: &mut Option, +) { + let Some(incoming) = incoming else { + return; + }; + match slot { + None => *slot = Some(incoming), + Some(existing) if existing == &incoming => {} + Some(_) => *overlay = Some(incoming), + } +} + +fn assign_stable_state( + task_state: &mut serde_json::Map, + key: &str, + incoming: Option, + overlay: &mut serde_json::Map, +) { + let Some(incoming) = incoming else { + return; + }; + if incoming.is_null() { + return; + } + match task_state.get(key) { + None => { + task_state.insert(key.to_string(), incoming); + } + Some(existing) if existing == &incoming => {} + Some(_) => { + overlay.insert(key.to_string(), incoming); + } + } +} + +fn openai_turn_env( + row: &Map, + env_state: Option<&Value>, + task_env: &mut StorylineEnv, + task_state: &mut serde_json::Map, +) -> Option { + let env_state = env_state.and_then(Value::as_object); + let mut overlay = StorylineEnv::default(); + let mut overlay_state = serde_json::Map::new(); + assign_stable_string( + &mut task_env.name, + openai_string(row.get("env_name")), + &mut overlay.name, + ); + assign_stable_string( + &mut task_env.endpoint, + openai_string(env_state.and_then(|state| state.get("endpoint"))), + &mut overlay.endpoint, + ); + assign_stable_state( + task_state, + "dataset_type", + row.get("dataset_type").cloned(), + &mut overlay_state, + ); + assign_stable_state(task_state, "dt", row.get("dt").cloned(), &mut overlay_state); + let group_id = parsed_meta(row) + .as_ref() + .and_then(|meta| meta.get("group_id")) + .cloned(); + assign_stable_state(task_state, "group_id", group_id, &mut overlay_state); + for key in ["redaction_policy", "upstream_base_url", "weight_version"] { + assign_stable_state( + task_state, + key, + env_state.and_then(|state| state.get(key)).cloned(), + &mut overlay_state, + ); + } + overlay.id = openai_string(row.get("id")); + overlay.event_type = openai_string(env_state.and_then(|state| state.get("event_type"))); + overlay.request_id = openai_string(env_state.and_then(|state| state.get("request_id"))); + overlay.state = (!overlay_state.is_empty()).then_some(overlay_state); + (!overlay.is_empty()).then_some(overlay) +} + fn rows_to_storyline( session_id: &str, records: &mut [(usize, Value)], @@ -1047,6 +1227,8 @@ fn rows_to_storyline( let mut first_model: Option = None; let mut run_id: Option = None; let mut context_count = 0_i64; + let mut task_env = StorylineEnv::default(); + let mut task_state = serde_json::Map::new(); for (record_index, (ordinal, raw)) in records.iter_mut().enumerate() { let row = raw.as_object_mut().ok_or_else(|| { @@ -1193,6 +1375,9 @@ fn rows_to_storyline( latency_ms: None, ttft_ms: None, extra: None, + env: None, + prompt: None, + finished_at: None, }); } @@ -1217,8 +1402,16 @@ fn rows_to_storyline( latency_ms, ttft_ms, extra: None, + env: None, + prompt: None, + finished_at: None, }); + let turn_env = openai_turn_env(row, env_state.as_ref(), &mut task_env, &mut task_state); + if let Some(turn) = turns.last_mut() { + turn.env = turn_env; + } + let mapped_agent_id = first_agent_id .as_deref() .or(agent_source.as_deref()) @@ -1233,6 +1426,13 @@ fn rows_to_storyline( ); } + task_env.state = (!task_state.is_empty()).then_some(task_state); + let task = StorylineTask { + env: (!task_env.is_empty()).then_some(task_env), + llm: None, + result: None, + }; + let final_metrics = turns.last().and_then(|turn| turn.metrics.clone()); let agent_id = first_agent_id .or(agent_source) @@ -1263,6 +1463,11 @@ fn rows_to_storyline( final_metrics, continued_trajectory_ref: None, extra: None, + meta: None, + task: (!task.is_empty()).then_some(task), + prompt: None, + started_at: None, + finished_at: None, unknown_fields: Default::default(), unknown_key_counts: Default::default(), turns, @@ -1464,6 +1669,8 @@ fn parse_tool_calls(value: Option<&Value>) -> Option> { result: Default::default(), duration_ms: None, extra: None, + kind: None, + response: None, }) }) .collect::>(); @@ -1526,6 +1733,8 @@ fn parse_embedded_tool_call( result: Default::default(), duration_ms: None, extra: None, + kind: None, + response: None, }]) } diff --git a/crates/persisting-pchronicle/src/formats/openai_corpus/tests.rs b/crates/persisting-pchronicle/src/formats/openai_corpus/tests.rs index 93964f74..a83873e1 100644 --- a/crates/persisting-pchronicle/src/formats/openai_corpus/tests.rs +++ b/crates/persisting-pchronicle/src/formats/openai_corpus/tests.rs @@ -153,24 +153,30 @@ fn openai_only_reports_unmapped_source_fields() { let input = mapped_fields_fixture(); let stories = parse_openai_msg_corpus_value(&input, "source.json").unwrap(); - let fields = &stories[0].unknown_fields.sources["openai-msg"].fields; - + let story = &stories[0]; + let env = story.task.as_ref().unwrap().env.as_ref().unwrap(); assert_eq!( - fields.get("/session_steps/0/dataset_type"), + env.state.as_ref().unwrap().get("dataset_type"), Some(&json!("TEST")) ); - assert_eq!(fields.get("/session_steps/0/id"), Some(&json!("event-1"))); - assert_eq!( - fields.get("/session_steps/0/vendor_row"), - Some(&json!({"kept": true})) - ); assert_eq!( - fields.get("/session_steps/0/meta_json/group_id"), + env.state.as_ref().unwrap().get("group_id"), Some(&json!("group-1")) ); + let response_env = story + .turns + .iter() + .rev() + .find(|turn| turn.source == "agent") + .and_then(|turn| turn.env.as_ref()) + .unwrap(); + assert_eq!(response_env.id.as_deref(), Some("event-1")); + assert_eq!(response_env.request_id.as_deref(), Some("request-1")); + let fields = &stories[0].unknown_fields.sources["openai-msg"].fields; + assert_eq!( - fields.get("/session_steps/0/meta_json/env_state/request_id"), - Some(&json!("request-1")) + fields.get("/session_steps/0/vendor_row"), + Some(&json!({"kept": true})) ); for mapped in [ @@ -194,6 +200,10 @@ fn openai_only_reports_unmapped_source_fields() { "/session_steps/0/response/tool_calls", "/session_steps/0/blob_manifest", "/session_steps/0/chosen_response", + "/session_steps/0/dataset_type", + "/session_steps/0/id", + "/session_steps/0/meta_json/group_id", + "/session_steps/0/meta_json/env_state/request_id", ] { assert!( !fields.contains_key(mapped), diff --git a/crates/persisting-pchronicle/src/formats/storyline.rs b/crates/persisting-pchronicle/src/formats/storyline.rs index bc9eb5d9..4bb46b74 100644 --- a/crates/persisting-pchronicle/src/formats/storyline.rs +++ b/crates/persisting-pchronicle/src/formats/storyline.rs @@ -42,11 +42,21 @@ pub struct StorylineDocument { #[serde(default, skip_serializing_if = "Option::is_none")] pub notes: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub task: Option, + #[serde(default, skip_serializing_if = "skip_optional_empty_prompt")] + pub prompt: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub started_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub finished_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub final_metrics: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub continued_trajectory_ref: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub extra: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub meta: Option, #[serde(default, skip_serializing_if = "StorylineUnknownFields::is_empty")] pub unknown_fields: StorylineUnknownFields, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] @@ -142,6 +152,12 @@ pub struct StorylineTurn { pub ttft_ms: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub extra: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub env: Option, + #[serde(default, skip_serializing_if = "skip_turn_prompt")] + pub prompt: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub finished_at: Option, } impl StorylineTurn { @@ -182,6 +198,258 @@ pub struct StorylineToolCall { pub duration_ms: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub extra: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub kind: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub response: Option, +} + +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct StorylineEnv { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub endpoint: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub event_type: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub request_id: Option, + #[serde(default, skip_serializing_if = "skip_empty_map")] + pub state: Option>, +} + +#[derive(Debug, Clone, PartialEq, Default, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct StorylinePrompt { + #[serde(default)] + pub system: Option, + #[serde(default)] + pub user: Option, +} + +impl StorylinePrompt { + pub fn from_pair(system: &str, user: &str) -> Option { + if system.is_empty() && user.is_empty() { + return None; + } + Some(Self { + system: (!system.is_empty()).then(|| system.to_string()), + user: (!user.is_empty()).then(|| user.to_string()), + }) + } + + pub fn explicit_clear() -> Self { + Self { + system: Some(String::new()), + user: Some(String::new()), + } + } + + pub fn is_empty(&self) -> bool { + self.system.as_deref().is_none_or(str::is_empty) + && self.user.as_deref().is_none_or(str::is_empty) + } + + pub fn is_explicit_clear(&self) -> bool { + self.system.as_deref() == Some("") && self.user.as_deref() == Some("") + } + + pub fn has_nonempty_field(&self) -> bool { + self.system + .as_deref() + .is_some_and(|value| !value.is_empty()) + || self.user.as_deref().is_some_and(|value| !value.is_empty()) + } + + pub fn pair(&self) -> (String, String) { + ( + self.system.clone().unwrap_or_default(), + self.user.clone().unwrap_or_default(), + ) + } +} + +impl Serialize for StorylinePrompt { + fn serialize( + &self, + serializer: S, + ) -> std::result::Result { + use serde::ser::SerializeStruct; + if self.is_explicit_clear() { + let mut state = serializer.serialize_struct("StorylinePrompt", 2)?; + state.serialize_field("system", "")?; + state.serialize_field("user", "")?; + return state.end(); + } + let system = self.system.as_deref().filter(|value| !value.is_empty()); + let user = self.user.as_deref().filter(|value| !value.is_empty()); + let mut state = serializer.serialize_struct( + "StorylinePrompt", + usize::from(system.is_some()) + usize::from(user.is_some()), + )?; + if let Some(system) = system { + state.serialize_field("system", system)?; + } + if let Some(user) = user { + state.serialize_field("user", user)?; + } + state.end() + } +} + +fn skip_optional_empty_prompt(prompt: &Option) -> bool { + prompt.as_ref().is_none_or(StorylinePrompt::is_empty) +} + +fn skip_turn_prompt(prompt: &Option) -> bool { + match prompt { + None => true, + Some(prompt) if prompt.is_explicit_clear() => false, + Some(prompt) => prompt.is_empty(), + } +} + +impl StorylineEnv { + pub fn is_empty(&self) -> bool { + self.name.is_none() + && self.endpoint.is_none() + && self.id.is_none() + && self.event_type.is_none() + && self.request_id.is_none() + && self.state.as_ref().is_none_or(serde_json::Map::is_empty) + } + + pub fn merge_overlay(&self, overlay: &Self) -> Self { + let mut state = self.state.clone().unwrap_or_default(); + if let Some(overlay_state) = &overlay.state { + for (key, value) in overlay_state { + state.insert(key.clone(), value.clone()); + } + } + Self { + name: overlay.name.clone().or_else(|| self.name.clone()), + endpoint: overlay.endpoint.clone().or_else(|| self.endpoint.clone()), + id: overlay.id.clone().or_else(|| self.id.clone()), + event_type: overlay + .event_type + .clone() + .or_else(|| self.event_type.clone()), + request_id: overlay + .request_id + .clone() + .or_else(|| self.request_id.clone()), + state: (!state.is_empty()).then_some(state), + } + } +} + +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct StorylineTaskLlm { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub k: Option, +} + +impl StorylineTaskLlm { + pub fn is_empty(&self) -> bool { + self.k.is_none() + } +} + +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct StorylineTaskResult { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub task_correct: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub correct: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub final_answer: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ground_truth: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub status: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub score: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub artifacts: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub category: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub attempts_tried: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub solved_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub retry_count: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub retry_counts: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_score: Option, +} + +impl StorylineTaskResult { + pub fn is_empty(&self) -> bool { + self.task_correct.is_none() + && self.correct.is_none() + && self.final_answer.is_none() + && self.ground_truth.is_none() + && self.status.is_none() + && self.score.is_none() + && self.error.is_none() + && self.artifacts.is_none() + && self.category.is_none() + && self.attempts_tried.is_none() + && self.solved_at.is_none() + && self.retry_count.is_none() + && self.retry_counts.is_none() + && self.max_score.is_none() + } +} + +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct StorylineTask { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub env: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub llm: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub result: Option, +} + +impl StorylineTask { + pub fn is_empty(&self) -> bool { + self.env.as_ref().is_none_or(StorylineEnv::is_empty) + && self.llm.as_ref().is_none_or(StorylineTaskLlm::is_empty) + && self + .result + .as_ref() + .is_none_or(StorylineTaskResult::is_empty) + } +} + +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct StorylineToolResponse { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub status: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub exit_code: Option, +} + +impl StorylineToolResponse { + pub fn is_empty(&self) -> bool { + self.status.is_none() && self.exit_code.is_none() + } +} + +fn skip_empty_map(map: &Option>) -> bool { + map.as_ref().is_none_or(serde_json::Map::is_empty) } impl StorylineDocument { @@ -205,9 +473,14 @@ impl StorylineDocument { parent: None, child_session_ids: None, notes: None, + task: None, + prompt: None, + started_at: None, + finished_at: None, final_metrics: None, continued_trajectory_ref: None, extra: None, + meta: None, unknown_fields: StorylineUnknownFields::default(), unknown_key_counts: UnknownKeyCounts::default(), turns: Vec::new(), @@ -256,6 +529,25 @@ impl StorylineDocument { if self.agent.id.is_empty() { return Err(InputIssue::invalid("storyline.agent.id is required")); } + if let Some(task) = &self.task { + if task.is_empty() { + return Err(InputIssue::invalid( + "storyline.task must contain env, llm, or result", + )); + } + if let Some(k) = task.llm.as_ref().and_then(|llm| llm.k) { + if k <= 0 { + return Err(InputIssue::invalid("storyline.task.llm.k must be positive")); + } + } + } + if let Some(prompt) = &self.prompt { + if !prompt.has_nonempty_field() { + return Err(InputIssue::invalid( + "storyline.prompt must contain a non-empty system or user", + )); + } + } if let Some(origin) = &self.origin { if origin.format.is_empty() { return Err(InputIssue::invalid( @@ -295,6 +587,22 @@ impl StorylineDocument { turn.id ))); } + if let Some(prompt) = &turn.prompt { + if turn.is_copied_context == Some(true) { + return Err(InputIssue::invalid(format!( + "turn id={} copied context must not contain prompt", + turn.id + ))); + } + if prompt.is_explicit_clear() { + // Whole-document replace to empty strings. + } else if !prompt.has_nonempty_field() { + return Err(InputIssue::invalid(format!( + "turn id={} prompt must contain a non-empty system or user, or explicit empty system and user", + turn.id + ))); + } + } if !seen.insert(turn.id) { return Err(InputIssue::invalid(format!( "duplicate turn id {}", @@ -314,6 +622,16 @@ impl StorylineDocument { turn.id ))); } + if call + .response + .as_ref() + .is_some_and(StorylineToolResponse::is_empty) + { + return Err(InputIssue::invalid(format!( + "turn id={} tool_call response must contain status or exit_code", + turn.id + ))); + } if !seen_tool_calls.insert(call.tool_call_id.as_str()) { return Err(InputIssue::invalid(format!( "duplicate tool_call_id {}", @@ -329,6 +647,10 @@ impl StorylineDocument { self.unknown_key_counts = compute_unknown_key_counts(&self.unknown_fields)?; Ok(()) } + + pub fn effective_prompt<'a>(&'a self, turn: &'a StorylineTurn) -> Option<&'a StorylinePrompt> { + turn.prompt.as_ref().or(self.prompt.as_ref()) + } } #[cfg(all(test, feature = "lance-store"))] @@ -339,6 +661,7 @@ pub fn parse_storyline_document(input: &str) -> Result { #[cfg(test)] mod tests { use super::*; + use serde_json::json; fn story_with_source_normalized_counts() -> StorylineDocument { let mut story = StorylineDocument::new("session", "agent"); @@ -597,6 +920,8 @@ mod tests { result: None, duration_ms: None, extra: None, + kind: None, + response: None, }; for id in [1, 2] { story.turns.push(StorylineTurn { @@ -616,6 +941,9 @@ mod tests { latency_ms: None, ttft_ms: None, extra: None, + env: None, + prompt: None, + finished_at: None, }); } @@ -652,4 +980,169 @@ mod tests { serde_json::json!({"answer": 42}) ); } + + #[test] + fn task_env_and_tool_response_roundtrip_on_the_wire() { + let mut story = StorylineDocument::new("session", "agent"); + story.started_at = Some(StorylineTimestamp::from_rfc3339("2026-01-01T00:00:00Z").unwrap()); + story.finished_at = Some(StorylineTimestamp::from_rfc3339("2026-01-01T00:00:02Z").unwrap()); + story.task = Some(StorylineTask { + env: Some(StorylineEnv { + name: Some("prod".into()), + endpoint: Some("https://llm".into()), + state: Some(serde_json::Map::from_iter([( + "weight_version".into(), + json!("v1"), + )])), + ..StorylineEnv::default() + }), + llm: Some(StorylineTaskLlm { k: Some(3) }), + result: Some(StorylineTaskResult { + correct: Some(true), + category: Some("software-engineering".into()), + ..StorylineTaskResult::default() + }), + }); + story.turns.push(StorylineTurn { + id: 1, + kind: None, + timestamp: story.started_at.clone(), + source: "agent".into(), + message: json!("done"), + reasoning_content: None, + reasoning_effort: None, + tool_calls: Some(vec![StorylineToolCall { + tool_call_id: "c1".into(), + function_name: "Bash".into(), + arguments: json!({}), + result: Some(json!("ok")), + duration_ms: None, + extra: None, + kind: Some("tool_use".into()), + response: Some(StorylineToolResponse { + status: Some("completed".into()), + exit_code: Some(0), + }), + }]), + observation: None, + metrics: None, + model_name: None, + llm_call_count: None, + is_copied_context: None, + latency_ms: None, + ttft_ms: None, + extra: None, + env: Some(StorylineEnv { + request_id: Some("req-1".into()), + ..StorylineEnv::default() + }), + prompt: None, + finished_at: story.finished_at.clone(), + }); + + let encoded = serde_json::to_value(&story).unwrap(); + assert_eq!(encoded["task"]["llm"]["k"], 3); + assert_eq!(encoded["task"]["env"]["name"], "prod"); + assert_eq!(encoded["turns"][0]["env"]["request_id"], "req-1"); + assert_eq!(encoded["turns"][0]["tool_calls"][0]["kind"], "tool_use"); + assert_eq!( + encoded["turns"][0]["tool_calls"][0]["response"]["exit_code"], + 0 + ); + let decoded: StorylineDocument = serde_json::from_value(encoded).unwrap(); + decoded.validate().unwrap(); + assert_eq!( + decoded.task.as_ref().unwrap().llm.as_ref().unwrap().k, + Some(3) + ); + } + + #[test] + fn empty_task_is_rejected() { + let mut story = StorylineDocument::new("session", "agent"); + story.task = Some(StorylineTask::default()); + let error = story.validate().unwrap_err(); + assert!(error.to_string().contains("task"), "{error}"); + } + + fn agent_turn(id: i64) -> StorylineTurn { + StorylineTurn { + id, + kind: None, + timestamp: None, + source: "agent".into(), + message: json!("done"), + reasoning_content: None, + reasoning_effort: None, + tool_calls: None, + observation: None, + metrics: None, + model_name: None, + llm_call_count: None, + is_copied_context: None, + latency_ms: None, + ttft_ms: None, + extra: None, + env: None, + prompt: None, + finished_at: None, + } + } + + #[test] + fn prompt_wire_roundtrip_and_explicit_clear() { + let mut story = StorylineDocument::new("session", "agent"); + story.prompt = StorylinePrompt::from_pair("sys", "task"); + let mut changed = agent_turn(1); + changed.prompt = StorylinePrompt::from_pair("sys", "later"); + let mut cleared = agent_turn(2); + cleared.prompt = Some(StorylinePrompt::explicit_clear()); + story.turns.push(changed); + story.turns.push(cleared); + story.validate().unwrap(); + + let encoded = serde_json::to_value(&story).unwrap(); + assert_eq!(encoded["prompt"]["system"], "sys"); + assert_eq!(encoded["prompt"]["user"], "task"); + assert_eq!(encoded["turns"][0]["prompt"]["user"], "later"); + assert_eq!(encoded["turns"][0]["prompt"]["system"], "sys"); + assert_eq!(encoded["turns"][1]["prompt"]["system"], ""); + assert_eq!(encoded["turns"][1]["prompt"]["user"], ""); + assert!(encoded["turns"][0]["msg"].is_string()); + + let decoded: StorylineDocument = serde_json::from_value(encoded).unwrap(); + decoded.validate().unwrap(); + assert_eq!( + decoded.effective_prompt(&decoded.turns[0]).unwrap().pair(), + ("sys".into(), "later".into()) + ); + assert_eq!( + decoded.effective_prompt(&decoded.turns[1]).unwrap().pair(), + (String::new(), String::new()) + ); + } + + #[test] + fn prompt_validation_rejects_empty_and_copied() { + let mut story = StorylineDocument::new("session", "agent"); + story.prompt = Some(StorylinePrompt::default()); + let error = story.validate().unwrap_err(); + assert!(error.to_string().contains("prompt"), "{error}"); + + let mut story = StorylineDocument::new("session", "agent"); + let mut turn = agent_turn(1); + turn.prompt = Some(StorylinePrompt::default()); + story.turns.push(turn); + let error = story.validate().unwrap_err(); + assert!(error.to_string().contains("prompt"), "{error}"); + + let mut story = StorylineDocument::new("session", "agent"); + story.prompt = StorylinePrompt::from_pair("sys", "task"); + let mut turn = agent_turn(1); + turn.is_copied_context = Some(true); + turn.prompt = StorylinePrompt::from_pair("sys", "task"); + story.turns.push(turn); + let error = story.validate().unwrap_err(); + assert!(error.to_string().contains("copied"), "{error}"); + } } diff --git a/crates/persisting-pchronicle/src/model.rs b/crates/persisting-pchronicle/src/model.rs index d8816cb5..a45a4c08 100644 --- a/crates/persisting-pchronicle/src/model.rs +++ b/crates/persisting-pchronicle/src/model.rs @@ -10,7 +10,8 @@ pub use crate::formats::llm::{ LlmToolDefinition, LlmUsage, }; pub use crate::formats::storyline::{ - StoryLink, StorylineAgent, StorylineDocument, StorylineOrigin, StorylineToolCall, + StoryLink, StorylineAgent, StorylineDocument, StorylineEnv, StorylineOrigin, StorylinePrompt, + StorylineTask, StorylineTaskLlm, StorylineTaskResult, StorylineToolCall, StorylineToolResponse, StorylineTurn, STORYLINE_SCHEMA_VERSION, }; pub use crate::formats::timestamp::StorylineTimestamp; diff --git a/crates/persisting-pchronicle/src/store/catalog/discovery.rs b/crates/persisting-pchronicle/src/store/catalog/discovery.rs index 86be9ebc..bb93d788 100644 --- a/crates/persisting-pchronicle/src/store/catalog/discovery.rs +++ b/crates/persisting-pchronicle/src/store/catalog/discovery.rs @@ -150,8 +150,17 @@ pub(super) async fn freeze_candidate( )) } Candidate::LocalFile { - file, root, path, .. + file, + root, + path, + size_bytes, + .. } => { + anyhow::ensure!( + size_bytes <= options.files.max_file_bytes, + "trajectory query file {file} is {size_bytes} bytes, exceeding max_file_bytes {}", + options.files.max_file_bytes + ); // Keep format detection behind LazySource::resolve so an exact // `_file_` predicate can prune unrelated malformed files before // any of their contents are opened. diff --git a/crates/persisting-pchronicle/src/store/catalog/provider.rs b/crates/persisting-pchronicle/src/store/catalog/provider.rs index d88a526b..8f5e9e87 100644 --- a/crates/persisting-pchronicle/src/store/catalog/provider.rs +++ b/crates/persisting-pchronicle/src/store/catalog/provider.rs @@ -87,11 +87,20 @@ impl CatalogTableProvider { .scan(state, Some(&source_projection), filters, limit) .await? } else { - let base_projection = base_projection(projection); + let physical_projection = physical_projection( + projection, + self.schema.as_ref(), + table.provider.schema().as_ref(), + )?; let business_filters = business_filters(filters); let input = table .provider - .scan(state, base_projection.as_ref(), &business_filters, limit) + .scan( + state, + physical_projection.as_ref(), + &business_filters, + limit, + ) .await?; project_catalog_source(input, source.file(), projection, &self.schema)? }; @@ -302,13 +311,37 @@ fn collect_business_conjuncts(expr: &Expr, output: &mut Vec) { } } -fn base_projection(projection: Option<&Vec>) -> Option> { - projection.map(|projection| { - projection - .iter() - .filter_map(|index| index.checked_sub(1)) - .collect() - }) +fn physical_projection( + projection: Option<&Vec>, + catalog_schema: &Schema, + physical_schema: &Schema, +) -> datafusion::common::Result>> { + let Some(projection) = projection else { + return Ok(None); + }; + let mut physical = Vec::with_capacity(projection.len()); + for &index in projection { + if index == 0 { + continue; + } + let name = catalog_schema.field(index).name(); + if let Ok(physical_index) = physical_schema.index_of(name) { + physical.push(physical_index); + } + } + Ok(Some(physical)) +} + +fn null_literal( + data_type: &DataType, +) -> datafusion::common::Result> { + Ok(Arc::new(Literal::new( + ScalarValue::try_from(data_type).map_err(|error| { + DataFusionError::Internal(format!( + "catalog cannot synthesize a null for {data_type}: {error}" + )) + })?, + ))) } fn file_source_projection(projection: Option<&Vec>, catalog_width: usize) -> Vec { @@ -346,8 +379,10 @@ fn project_catalog_source( let field = schema.field(index); let expr: Arc = if index == 0 { Arc::new(Literal::new(ScalarValue::Utf8(Some(file.to_string())))) - } else { + } else if input.schema().index_of(field.name()).is_ok() { physical_col(field.name(), input.schema().as_ref())? + } else { + null_literal(field.data_type())? }; Ok(ProjectionExpr { expr, @@ -539,3 +574,52 @@ fn catalog_schema(base: &SchemaRef) -> SchemaRef { fields.extend(base.fields().iter().cloned()); Arc::new(Schema::new(fields)) } + +#[cfg(test)] +mod tests { + use super::*; + + fn runs_schema_without_meta() -> SchemaRef { + let fields = story_runs_arrow_schema() + .fields() + .iter() + .filter(|field| field.name() != "meta_json") + .cloned() + .collect::>(); + Arc::new(Schema::new(fields)) + } + + #[test] + fn storyline_projection_follows_column_names_when_meta_json_is_absent() { + let catalog = catalog_schema(&story_runs_arrow_schema()); + let physical = runs_schema_without_meta(); + let catalog_index = catalog + .index_of("unknown_fields_json") + .expect("catalog schema exposes unknown_fields_json"); + let physical_index = physical + .index_of("unknown_fields_json") + .expect("older Storyline runs still have unknown_fields_json"); + assert_ne!( + catalog_index.checked_sub(1), + Some(physical_index), + "inserting meta_json must shift later catalog indexes" + ); + + let mapped = + physical_projection(Some(&vec![0, catalog_index]), &catalog, physical.as_ref()) + .expect("older physical schema remains queryable"); + assert_eq!(mapped, Some(vec![physical_index])); + } + + #[test] + fn storyline_projection_skips_columns_missing_from_older_runs() { + let catalog = catalog_schema(&story_runs_arrow_schema()); + let physical = runs_schema_without_meta(); + let meta = catalog + .index_of("meta_json") + .expect("current catalog schema exposes meta_json"); + let mapped = + physical_projection(Some(&vec![meta]), &catalog, physical.as_ref()).expect("missing"); + assert_eq!(mapped, Some(Vec::new())); + } +} diff --git a/crates/persisting-pchronicle/src/store/catalog/tests.rs b/crates/persisting-pchronicle/src/store/catalog/tests.rs index ba400a9f..fe6ca7e9 100644 --- a/crates/persisting-pchronicle/src/store/catalog/tests.rs +++ b/crates/persisting-pchronicle/src/store/catalog/tests.rs @@ -40,6 +40,11 @@ fn storyline(session_id: &str, run_id: &str) -> StorylineDocument { final_metrics: None, continued_trajectory_ref: None, extra: None, + meta: None, + task: None, + prompt: None, + started_at: None, + finished_at: None, unknown_fields: Default::default(), unknown_key_counts: Default::default(), turns: vec![StorylineTurn { @@ -59,6 +64,9 @@ fn storyline(session_id: &str, run_id: &str) -> StorylineDocument { latency_ms: None, ttft_ms: None, extra: None, + env: None, + prompt: None, + finished_at: None, }], } } @@ -211,6 +219,39 @@ async fn ignores_derived_lance_sidecars_during_discovery() -> Result<()> { Ok(()) } +#[tokio::test] +async fn report_mode_skips_oversized_files_when_querying_all_runs() -> Result<()> { + let temp = tempfile::tempdir()?; + write_openai_source(&temp.path().join("good.json"), "event-good")?; + fs::write(temp.path().join("huge.json"), vec![b'x'; 4096])?; + let snapshot = Arc::new( + DatasetCatalogSnapshot::discover( + vec![DatasetMount::default(temp.path().to_string_lossy())?], + Some(DEFAULT_DATASET_NAME.into()), + CatalogSnapshotOptions { + error_policy: CatalogErrorPolicy::Report, + files: crate::store::FileTrajectoryDataSourceOptions { + max_file_bytes: 1024, + ..Default::default() + }, + ..CatalogSnapshotOptions::default() + }, + ) + .await?, + ); + assert_eq!(snapshot.datasets()[0].ready_source_count(), 1); + assert_eq!(snapshot.datasets()[0].error_source_count(), 1); + assert_eq!(snapshot.datasets()[0].sources[0].file, "good.json"); + assert_eq!(snapshot.datasets()[0].sources[1].file, "huge.json"); + + let engine = snapshot.query_engine(Default::default()).await?; + let rows = engine + .query_jsonl("SELECT COUNT(*) AS runs FROM dataset.runs") + .await?; + assert_eq!(rows.trim(), r#"{"runs":1}"#); + Ok(()) +} + #[tokio::test] async fn report_mode_keeps_late_local_format_errors_lazy() -> Result<()> { let temp = tempfile::tempdir()?; @@ -464,6 +505,17 @@ async fn catalog_prunes_storyline_sources_before_opening_lance() -> Result<()> { ) .await?; assert_eq!(rows.trim(), r#"{"run_id":"run-a"}"#); + let explorer = engine + .query_jsonl( + "SELECT r._file_, r.document_id, r.run_id, r.session_id, r.agent_id, \ + r.agent_model_name, r.parent_json, r.final_metrics_json, \ + r.extra_json, r.unknown_fields_json \ + FROM dataset.runs r WHERE r._file_ = 'a'", + ) + .await?; + let explorer: serde_json::Value = serde_json::from_str(explorer.lines().next().unwrap())?; + assert_eq!(explorer["run_id"], "run-a"); + assert_eq!(explorer["session_id"], "session-a"); assert_eq!( snapshot.prepared[0] .sources diff --git a/crates/persisting-pchronicle/src/store/files/actf_stream.rs b/crates/persisting-pchronicle/src/store/files/actf_stream.rs index 9038c8c3..7f635aae 100644 --- a/crates/persisting-pchronicle/src/store/files/actf_stream.rs +++ b/crates/persisting-pchronicle/src/store/files/actf_stream.rs @@ -233,6 +233,9 @@ struct ProjectedActfTrajectorySeed<'a> { scan: &'a FileScanSpec, } +pub(super) const ACTF_TRAJECTORY_NOT_PROJECTABLE: &str = + "ACTF trajectory is an event log; use full decode"; + impl<'de> DeserializeSeed<'de> for ProjectedActfTrajectorySeed<'_> { type Value = ProjectedActfAttempt; @@ -240,7 +243,7 @@ impl<'de> DeserializeSeed<'de> for ProjectedActfTrajectorySeed<'_> { where D: serde::Deserializer<'de>, { - deserializer.deserialize_map(ProjectedActfTrajectoryVisitor { scan: self.scan }) + deserializer.deserialize_any(ProjectedActfTrajectoryVisitor { scan: self.scan }) } } @@ -252,7 +255,15 @@ impl<'de> Visitor<'de> for ProjectedActfTrajectoryVisitor<'_> { type Value = ProjectedActfAttempt; fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str("an ACTF trajectory object") + formatter.write_str("an ACTF trajectory object or event-log array") + } + + fn visit_seq(self, mut sequence: A) -> std::result::Result + where + A: SeqAccess<'de>, + { + while sequence.next_element::()?.is_some() {} + Err(de::Error::custom(ACTF_TRAJECTORY_NOT_PROJECTABLE)) } fn visit_map(self, mut map: A) -> std::result::Result @@ -454,15 +465,17 @@ impl<'de> Visitor<'de> for ProjectedActfAssistantContentVisitor<'_> { match key { ProjectedActfAssistantContentField::Content => { if self.scan.wants("message_json") { - content = map.next_value::()?; + content = map.next_value::>()?.unwrap_or_default(); } else { map.next_value::()?; } } ProjectedActfAssistantContentField::ReasoningContent => { if self.scan.wants("reasoning_content") { - let value = map.next_value::()?; - if !value.is_empty() { + if let Some(value) = map + .next_value::>()? + .filter(|value| !value.is_empty()) + { reasoning_content = Some(value); } } else { diff --git a/crates/persisting-pchronicle/src/store/files/mod.rs b/crates/persisting-pchronicle/src/store/files/mod.rs index 99a8040b..6d7ff6ea 100644 --- a/crates/persisting-pchronicle/src/store/files/mod.rs +++ b/crates/persisting-pchronicle/src/store/files/mod.rs @@ -11,7 +11,7 @@ mod json_stream; mod projected_steps; use actf_reader::parse_actf_storylines_from_reader_with_stats; -use actf_stream::stream_projected_actf_steps; +use actf_stream::{stream_projected_actf_steps, ACTF_TRAJECTORY_NOT_PROJECTABLE}; use atif_reader::parse_atif_storylines_from_reader_with_stats; pub(crate) use atif_reader::AtifReader; use atif_stream::stream_projected_atif_steps; @@ -578,8 +578,11 @@ fn stream_file( && kind == StorylineTableKind::Steps && scan.can_project_steps(&source_schema) { - stream_projected_actf_steps(file, runtime, &schema, batch_size, scan, tx)?; - return Ok(()); + match stream_projected_actf_steps(file, runtime, &schema, batch_size, scan, tx) { + Ok(()) => return Ok(()), + Err(error) if is_actf_event_log_fallback(&error) => {} + Err(error) => return Err(error), + } } let parsed = load_file(file, runtime, format)?; for batch in parsed.batches(kind) { @@ -596,6 +599,12 @@ fn stream_file( Ok(()) } +fn is_actf_event_log_fallback(error: &anyhow::Error) -> bool { + error + .chain() + .any(|cause| cause.to_string().contains(ACTF_TRAJECTORY_NOT_PROJECTABLE)) +} + #[derive(Debug)] struct FileState { file: LocalQueryInputFile, diff --git a/crates/persisting-pchronicle/src/store/files/tests.rs b/crates/persisting-pchronicle/src/store/files/tests.rs index e14f0b8e..418efd50 100644 --- a/crates/persisting-pchronicle/src/store/files/tests.rs +++ b/crates/persisting-pchronicle/src/store/files/tests.rs @@ -243,6 +243,59 @@ async fn full_atif_array_reports_bounded_input_buffer_peak() { assert!(peak < 2 * 64 * 1024, "peak={peak}"); } +#[tokio::test] +async fn queries_actf_event_log_trajectory_as_steps() { + let input = tempfile::NamedTempFile::with_suffix(".json").unwrap(); + std::fs::write( + input.path(), + r#"{ + "task_id":"gravitational-wave-detection", + "category":"astronomy", + "k":1, + "correct":false, + "solved_at":null, + "attempts_tried":1, + "attempts":{"1":{ + "correct":false, + "status":"run_error", + "trajectory":[ + {"type":"session","id":"s1","timestamp":"2026-06-17T07:26:27.170Z","cwd":"/root"}, + {"type":"message","id":"m1","timestamp":"2026-06-17T07:26:28Z", + "message":{"role":"user","content":[{"type":"text","text":"hello"}]}}, + {"type":"message","id":"m2","timestamp":"2026-06-17T07:26:29Z", + "message":{"role":"assistant","content":[{"type":"text","text":"world"}]}} + ] + }} + }"#, + ) + .unwrap(); + let manifest = LocalQueryManifest::for_format(input.path(), DocumentFormat::Actf).unwrap(); + let source = FileTrajectoryDataSource::from_manifest(manifest).unwrap(); + let context = SessionContext::new(); + source.register(&context).unwrap(); + + let runs = context + .sql("SELECT document_id, session_id FROM runs") + .await + .unwrap() + .collect() + .await + .unwrap(); + assert_eq!(runs.iter().map(RecordBatch::num_rows).sum::(), 1); + + let steps = context + .sql("SELECT session_id, source FROM steps ORDER BY step_id") + .await + .unwrap() + .collect() + .await + .unwrap(); + assert!( + steps.iter().map(RecordBatch::num_rows).sum::() >= 1, + "event-log ACTF must project at least one step" + ); +} + #[tokio::test] async fn projected_actf_pushdown_matches_session_id_and_step_id() { let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) diff --git a/crates/persisting-pchronicle/src/store/storyline/content.rs b/crates/persisting-pchronicle/src/store/storyline/content.rs index 3e9257de..943e4ce2 100644 --- a/crates/persisting-pchronicle/src/store/storyline/content.rs +++ b/crates/persisting-pchronicle/src/store/storyline/content.rs @@ -313,6 +313,11 @@ pub(crate) fn content_columns(kind: StorylineTableKind) -> &'static [(&'static s ("final_metrics_json", true), ("continued_trajectory_ref", false), ("extra_json", true), + ("meta_json", true), + ("task_json", true), + ("started_at_json", true), + ("finished_at_json", true), + ("prompt_json", true), ], StorylineTableKind::Steps => &[ ("message_json", true), @@ -320,11 +325,15 @@ pub(crate) fn content_columns(kind: StorylineTableKind) -> &'static [(&'static s ("reasoning_effort_json", true), ("metrics_json", true), ("extra_json", true), + ("env_json", true), + ("finished_at_json", true), + ("prompt_json", true), ], StorylineTableKind::ToolCalls => &[ ("arguments_json", true), ("results_json", true), ("extra_json", true), + ("response_json", true), ], } } @@ -349,7 +358,9 @@ fn externalize_batch( ) -> Result { let mut columns = batch.columns().to_vec(); for (name, is_json) in content_columns(kind) { - let index = batch.schema().index_of(name)?; + let Ok(index) = batch.schema().index_of(name) else { + continue; + }; let values = columns[index] .as_any() .downcast_ref::() diff --git a/crates/persisting-pchronicle/src/store/storyline/model.rs b/crates/persisting-pchronicle/src/store/storyline/model.rs index 6ccd420c..ad318cee 100644 --- a/crates/persisting-pchronicle/src/store/storyline/model.rs +++ b/crates/persisting-pchronicle/src/store/storyline/model.rs @@ -12,7 +12,10 @@ use serde_json::Value; use crate::formats::unknown_fields::{ validate_unknown_fields, StorylineUnknownFields, UnknownFieldLimits, UnknownKeyCounts, }; -use crate::model::StorylineOrigin; +use crate::model::{ + StorylineEnv, StorylineOrigin, StorylinePrompt, StorylineTask, StorylineTimestamp, + StorylineToolResponse, +}; use crate::{Result, StoryLink, StorylineDocument, StorylineToolCall, StorylineTurn}; #[cfg(feature = "lance-store")] @@ -44,9 +47,14 @@ pub struct StoryRunRow { pub parent: Option, pub child_session_ids: Option>, pub notes: Option, + pub task: Option, + pub prompt: Option, + pub started_at: Option, + pub finished_at: Option, pub final_metrics: Option, pub continued_trajectory_ref: Option, pub extra: Option, + pub meta: Option, pub unknown_fields: StorylineUnknownFields, pub unknown_key_counts: UnknownKeyCounts, } @@ -78,6 +86,9 @@ pub struct StoryStepRow { /// Complete authoritative observation. `StoryToolCallRow::results` is derived. pub observation: Option, pub extra: Option, + pub env: Option, + pub prompt: Option, + pub finished_at: Option, } /// One row per tool call. `results` keeps zero or more ATIF observation result @@ -96,6 +107,8 @@ pub struct StoryToolCallRow { pub results: Vec, pub duration_ms: Option, pub extra: Option, + pub kind: Option, + pub response: Option, } #[derive(Debug, Clone, PartialEq)] @@ -154,9 +167,14 @@ pub(crate) fn split_storyline_with_unknown_limits( parent: story.parent.clone(), child_session_ids: story.child_session_ids.clone(), notes: story.notes.clone(), + task: story.task.clone(), + prompt: story.prompt.clone(), + started_at: story.started_at.clone(), + finished_at: story.finished_at.clone(), final_metrics: story.final_metrics.clone(), continued_trajectory_ref: story.continued_trajectory_ref.clone(), extra: story.extra.clone(), + meta: story.meta.clone(), unknown_fields: story.unknown_fields.clone(), unknown_key_counts: story.unknown_key_counts.clone(), }; @@ -189,6 +207,9 @@ pub(crate) fn split_storyline_with_unknown_limits( had_observation: turn.observation.is_some(), observation: turn.observation.clone(), extra: turn.extra.clone(), + env: turn.env.clone(), + prompt: turn.prompt.clone(), + finished_at: turn.finished_at.clone(), }); let mut call_positions = HashMap::new(); @@ -222,6 +243,8 @@ pub(crate) fn split_storyline_with_unknown_limits( results: Vec::new(), duration_ms: call.duration_ms, extra: call.extra.clone(), + kind: call.kind.clone(), + response: call.response.clone(), }); } for result in observation_results(turn.observation.as_ref()).unwrap_or_default() { @@ -378,6 +401,8 @@ pub fn reconstruct_storyline(tables: StorylineTables) -> Result>(); StorylineTurn { @@ -397,6 +422,9 @@ pub fn reconstruct_storyline(tables: StorylineTables) -> Result Result Arc { field("final_metrics_json", DataType::Utf8, true), field("continued_trajectory_ref", DataType::Utf8, true), field("extra_json", DataType::Utf8, true), + field("meta_json", DataType::Utf8, true), field("unknown_fields_json", DataType::Utf8, true), field("unknown_key_counts_json", DataType::Utf8, true), + field("task_json", DataType::Utf8, true), + field("started_at_json", DataType::Utf8, true), + field("finished_at_json", DataType::Utf8, true), + field("prompt_json", DataType::Utf8, true), ])) } @@ -75,6 +80,9 @@ pub fn story_steps_arrow_schema() -> Arc { field("had_observation", DataType::Boolean, false), field("observation_json", DataType::Utf8, true), field("extra_json", DataType::Utf8, true), + field("env_json", DataType::Utf8, true), + field("finished_at_json", DataType::Utf8, true), + field("prompt_json", DataType::Utf8, true), ])) } @@ -92,6 +100,8 @@ pub fn story_tool_calls_arrow_schema() -> Arc { field("results_json", DataType::Utf8, false), field("duration_ms", DataType::Int64, true), field("extra_json", DataType::Utf8, true), + field("kind", DataType::Utf8, true), + field("response_json", DataType::Utf8, true), ])) } @@ -191,6 +201,11 @@ pub fn story_runs_to_batch(rows: &[StoryRunRow]) -> Result { .map(|r| opt_json(&r.extra)) .collect::>>()?, )), + Arc::new(opt_utf8_owned( + rows.iter() + .map(|r| opt_json(&r.meta)) + .collect::>>()?, + )), Arc::new(opt_utf8_owned( rows.iter() .map(|r| { @@ -209,6 +224,26 @@ pub fn story_runs_to_batch(rows: &[StoryRunRow]) -> Result { }) .collect::>>()?, )), + Arc::new(opt_utf8_owned( + rows.iter() + .map(|r| opt_json(&r.task)) + .collect::>>()?, + )), + Arc::new(opt_utf8_owned( + rows.iter() + .map(|r| opt_json(&r.started_at)) + .collect::>>()?, + )), + Arc::new(opt_utf8_owned( + rows.iter() + .map(|r| opt_json(&r.finished_at)) + .collect::>>()?, + )), + Arc::new(opt_utf8_owned( + rows.iter() + .map(|r| opt_json(&r.prompt)) + .collect::>>()?, + )), ], ) .context("build runs Lance batch") @@ -288,6 +323,21 @@ pub fn story_steps_to_batch(rows: &[StoryStepRow]) -> Result { .map(|r| opt_json(&r.extra)) .collect::>()?, )), + Arc::new(opt_utf8_owned( + rows.iter() + .map(|r| opt_json(&r.env)) + .collect::>()?, + )), + Arc::new(opt_utf8_owned( + rows.iter() + .map(|r| opt_json(&r.finished_at)) + .collect::>()?, + )), + Arc::new(opt_utf8_owned( + rows.iter() + .map(|r| opt_json(&r.prompt)) + .collect::>()?, + )), ], ) .context("build steps Lance batch") @@ -331,6 +381,12 @@ pub fn story_tool_calls_to_batch(rows: &[StoryToolCallRow]) -> Result>()?, )), + Arc::new(opt_utf8(rows.iter().map(|r| r.kind.as_deref()))), + Arc::new(opt_utf8_owned( + rows.iter() + .map(|r| opt_json(&r.response)) + .collect::>()?, + )), ], ) .context("build tool_calls Lance batch") @@ -502,6 +558,7 @@ pub fn story_runs_from_batch(batch: &RecordBatch) -> Result> { final_metrics: optional_json_at(batch, "final_metrics_json", row)?, continued_trajectory_ref: string_at(batch, "continued_trajectory_ref", row)?, extra: optional_json_at(batch, "extra_json", row)?, + meta: optional_json_if_present(batch, "meta_json", row)?, unknown_fields: optional_json_if_present(batch, "unknown_fields_json", row)? .unwrap_or_default(), unknown_key_counts: optional_json_if_present( @@ -510,6 +567,10 @@ pub fn story_runs_from_batch(batch: &RecordBatch) -> Result> { row, )? .unwrap_or_default(), + task: optional_json_if_present(batch, "task_json", row)?, + started_at: optional_json_if_present(batch, "started_at_json", row)?, + finished_at: optional_json_if_present(batch, "finished_at_json", row)?, + prompt: optional_json_if_present(batch, "prompt_json", row)?, }) }) .collect() @@ -544,6 +605,9 @@ pub fn story_steps_from_batch(batch: &RecordBatch) -> Result> had_observation: required_bool_at(batch, "had_observation", row)?, observation: optional_json_at(batch, "observation_json", row)?, extra: optional_json_at(batch, "extra_json", row)?, + env: optional_json_if_present(batch, "env_json", row)?, + finished_at: optional_json_if_present(batch, "finished_at_json", row)?, + prompt: optional_json_if_present(batch, "prompt_json", row)?, }) }) .collect() @@ -573,6 +637,8 @@ pub fn story_tool_calls_from_batch(batch: &RecordBatch) -> Result StorylineDocument { final_metrics: None, continued_trajectory_ref: None, extra: None, + meta: None, + task: None, + prompt: None, + started_at: None, + finished_at: None, unknown_fields: Default::default(), unknown_key_counts: Default::default(), turns: vec![ @@ -133,6 +138,9 @@ fn story(session_id: &str) -> StorylineDocument { latency_ms: None, ttft_ms: None, extra: None, + env: None, + prompt: None, + finished_at: None, }, StorylineTurn { id: 2, @@ -149,6 +157,8 @@ fn story(session_id: &str) -> StorylineDocument { result: Default::default(), duration_ms: Some(12), extra: None, + kind: None, + response: None, }]), observation: Some(serde_json::json!({ "results": [{"source_call_id": "call-1", "content": "42"}] @@ -160,6 +170,9 @@ fn story(session_id: &str) -> StorylineDocument { latency_ms: Some(20), ttft_ms: Some(5), extra: None, + env: None, + prompt: None, + finished_at: None, }, ], } diff --git a/crates/persisting-pchronicle/src/tests.rs b/crates/persisting-pchronicle/src/tests.rs index 4d447420..2ba159b2 100644 --- a/crates/persisting-pchronicle/src/tests.rs +++ b/crates/persisting-pchronicle/src/tests.rs @@ -757,6 +757,11 @@ fn storyline_to_events_assigns_call_id_for_paired_turns() { final_metrics: None, continued_trajectory_ref: None, extra: None, + meta: None, + task: None, + prompt: None, + started_at: None, + finished_at: None, unknown_fields: Default::default(), unknown_key_counts: Default::default(), turns: vec![ @@ -777,6 +782,9 @@ fn storyline_to_events_assigns_call_id_for_paired_turns() { latency_ms: None, ttft_ms: None, extra: None, + env: None, + prompt: None, + finished_at: None, }, StorylineTurn { id: 2, @@ -795,6 +803,9 @@ fn storyline_to_events_assigns_call_id_for_paired_turns() { latency_ms: Some(10), ttft_ms: None, extra: None, + env: None, + prompt: None, + finished_at: None, }, ], }; diff --git a/crates/persisting-pchronicle/tests/document_source.rs b/crates/persisting-pchronicle/tests/document_source.rs index f2e9e2a6..a9d19b57 100644 --- a/crates/persisting-pchronicle/tests/document_source.rs +++ b/crates/persisting-pchronicle/tests/document_source.rs @@ -36,6 +36,9 @@ fn turn(id: i64, message: &str) -> StorylineTurn { latency_ms: None, ttft_ms: None, extra: None, + env: None, + prompt: None, + finished_at: None, } } diff --git a/crates/persisting-pvisor/README.md b/crates/persisting-pvisor/README.md index babf3ab7..5a28f5ec 100644 --- a/crates/persisting-pvisor/README.md +++ b/crates/persisting-pvisor/README.md @@ -75,8 +75,12 @@ By default, replay's internal state, WAL, manifest, comparisons, and native working files stay below `/tmp/pvisor-sandbox-replay` and disappear with the sandbox. Replay does not enable pVisor Gateway, pChronicle, model-traffic capture, or a Claude Resume Transport audit. Callers that explicitly select -`--state-dir` or `--output-dir` own the resulting files. Use `--replay-only` -when only prefix reconstruction and tool replay are required. +`--state-dir` or `--output-dir` own the resulting files. The three execution +modes are: `--prepare-only` to parse and construct the prefix without a runtime, +`--replay-only` to execute that prefix without a model request, and the default +replay-and-continue mode. `--max-steps` is a total action budget including the +prefix. Results use `sandbox-playback.result/v3` and report `phase`, `quality`, +`agent_status`, artifact paths, and structured failure details. ## Start with one Agent diff --git a/crates/persisting-pvisor/src/cli/mod.rs b/crates/persisting-pvisor/src/cli/mod.rs index 76ae20ce..dd8d752e 100644 --- a/crates/persisting-pvisor/src/cli/mod.rs +++ b/crates/persisting-pvisor/src/cli/mod.rs @@ -212,6 +212,53 @@ mod tests { assert!(error.to_string().contains("--chronicle-mode")); } + #[test] + fn replay_modes_are_mutually_exclusive_cli_flags() { + for mode in ["--prepare-only", "--replay-only"] { + Cli::try_parse_from([ + "pvisor", + "replay", + "--agent", + "claude-code", + "--trajectory", + "/input/session.jsonl", + "--after-step", + "1", + mode, + ]) + .expect("individual replay mode flag must be accepted"); + } + + let error = Cli::try_parse_from([ + "pvisor", + "replay", + "--agent", + "claude-code", + "--trajectory", + "/input/session.jsonl", + "--after-step", + "1", + "--prepare-only", + "--replay-only", + ]) + .unwrap_err(); + assert!(error.to_string().contains("cannot be used with")); + } + + #[test] + fn replay_help_describes_phase_modes() { + let help = Cli::try_parse_from(["pvisor", "replay", "--help"]) + .unwrap_err() + .to_string(); + + assert!(help.contains("--prepare-only")); + assert!(help.contains("without executing tools or starting an Agent")); + assert!(help.contains("--replay-only")); + assert!(help.contains("stop before the next model request")); + assert!(help.contains("--allow-stale-observations")); + assert!(help.contains("including the replayed prefix and any live continuation")); + } + #[test] fn unknown_first_token_becomes_default_run() { let args = normalize_default_run(vec!["pvisor".into(), "/bin/true".into()]); diff --git a/crates/persisting-pvisor/src/cli/replay.rs b/crates/persisting-pvisor/src/cli/replay.rs index 97a9efbf..099fc501 100644 --- a/crates/persisting-pvisor/src/cli/replay.rs +++ b/crates/persisting-pvisor/src/cli/replay.rs @@ -7,7 +7,7 @@ use clap::Args; use persisting_replay::{ execute, request_from_json, AgentKind, OverlayFsConfig as ReplayOverlayFsConfig, OverlayNetConfig as ReplayOverlayNetConfig, PlaybackRequest, ReplayConfig, ReplayError, - ReplayToml, RunConfig as ReplayRunConfig, RESULT_SCHEMA_VERSION, + ReplayMode, ReplayToml, RunConfig as ReplayRunConfig, RESULT_SCHEMA_VERSION, }; use serde_json::json; @@ -71,13 +71,22 @@ pub struct ReplayArgs { #[arg(long)] session_id: Option, + /// Total Agent action budget including the replayed prefix and any live continuation. #[arg(long)] max_steps: Option, - /// Reconstruct the selected prefix without starting a live Agent. - #[arg(long)] + /// Parse and construct the selected prefix without executing tools or starting an Agent. + #[arg(long, conflicts_with = "replay_only")] + prepare_only: bool, + + /// Execute the selected tool prefix and stop before the next model request. + #[arg(long, conflicts_with = "prepare_only")] replay_only: bool, + /// Permit replay to reuse source observations that cannot be freshly reproduced. + #[arg(long)] + allow_stale_observations: bool, + /// Force live continuation model requests to disable thinking. #[arg(long)] disable_thinking: bool, @@ -151,12 +160,12 @@ pub fn run(args: ReplayArgs) -> i32 { } } match normalize(args).and_then(execute) { - Ok(result) => { + Ok(report) => { println!( "{}", - serde_json::to_string(&result).expect("ReplayResult is serializable") + serde_json::to_string(&report.result).expect("ReplayResult is serializable") ); - 0 + report.exit_code } Err(error) => { print_error(&error); @@ -234,6 +243,8 @@ fn direct_managed_config(args: &ReplayArgs) -> Result { max_steps: args.max_steps, session_id: args.session_id.clone(), replay_only: args.replay_only, + prepare_only: args.prepare_only, + allow_stale_observations: args.allow_stale_observations, disable_thinking: args.disable_thinking, run_id: args.run_id.clone(), workspace: args.workspace.clone(), @@ -433,6 +444,12 @@ fn inner_replay_command( if replay.replay_only { command.push("--replay-only".into()); } + if replay.prepare_only { + command.push("--prepare-only".into()); + } + if replay.allow_stale_observations { + command.push("--allow-stale-observations".into()); + } if replay.disable_thinking { command.push("--disable-thinking".into()); } @@ -487,7 +504,14 @@ fn normalize(args: ReplayArgs) -> Result { trajectory_assets: args.trajectory_assets, session_id: args.session_id, max_steps: args.max_steps, - replay_only: args.replay_only, + mode: if args.prepare_only { + ReplayMode::PrepareOnly + } else if args.replay_only { + ReplayMode::ReplayOnly + } else { + ReplayMode::ReplayAndContinue + }, + allow_stale_observations: args.allow_stale_observations, run_id: args.run_id, disable_thinking: args.disable_thinking, }) @@ -506,7 +530,9 @@ fn reject_direct(args: &ReplayArgs) -> Result<(), ReplayError> { || args.output_dir.is_some() || args.session_id.is_some() || args.max_steps.is_some() + || args.prepare_only || args.replay_only + || args.allow_stale_observations || args.disable_thinking || args.run_id.is_some() || args.safe @@ -529,18 +555,33 @@ fn reject_direct(args: &ReplayArgs) -> Result<(), ReplayError> { } fn print_error(error: &ReplayError) { - println!( - "{}", - json!({ - "schema_version": RESULT_SCHEMA_VERSION, - "status": "failed", - "error": { - "category": error.kind.category(), - "message": error.to_string(), - }, - "retryable": error.kind.retryable(), - }) - ); + println!("{}", failure_json(error)); +} + +fn failure_json(error: &ReplayError) -> serde_json::Value { + let (run_id, state_dir, output_dir) = error + .locations() + .map(|(run_id, state_dir, output_dir)| (json!(run_id), json!(state_dir), json!(output_dir))) + .unwrap_or(( + serde_json::Value::Null, + serde_json::Value::Null, + serde_json::Value::Null, + )); + json!({ + "schema_version": RESULT_SCHEMA_VERSION, + "phase": null, + "quality": null, + "agent_status": "not_started", + "run_id": run_id, + "state_dir": state_dir, + "output_dir": output_dir, + "artifacts": [], + "failure": { + "category": error.kind.category(), + "message": error.to_string(), + }, + "retryable": error.kind.retryable(), + }) } #[cfg(test)] mod tests { @@ -614,4 +655,45 @@ agent_entrypoint = "/usr/bin/claude" config.run.inherit_env = true; assert!(needs_managed_run(&config)); } + + #[test] + fn managed_command_propagates_prepare_and_stale_observation_flags() { + let config: ReplayToml = toml::from_str( + r#" +[replay] +agent = "claude-code" +trajectory = "/input/session.jsonl" +after_step = 1 +prepare_only = true +allow_stale_observations = true +"#, + ) + .unwrap(); + + let command = + inner_replay_command(&config, std::path::Path::new("/usr/bin/pvisor")).unwrap(); + + assert!(command.iter().any(|argument| argument == "--prepare-only")); + assert!(command + .iter() + .any(|argument| argument == "--allow-stale-observations")); + assert!(!command.iter().any(|argument| argument == "--replay-only")); + } + + #[test] + fn failure_json_keeps_run_locations() { + let error = ReplayError::configuration("invalid request").with_locations( + "replay-1", + PathBuf::from("/state/replay-1"), + PathBuf::from("/output/replay-1"), + ); + + let value = failure_json(&error); + + assert_eq!(value["schema_version"], "sandbox-playback.result/v3"); + assert_eq!(value["run_id"], "replay-1"); + assert_eq!(value["state_dir"], "/state/replay-1"); + assert_eq!(value["output_dir"], "/output/replay-1"); + assert_eq!(value["failure"]["category"], "configuration_error"); + } } diff --git a/crates/persisting-replay/assets/mini_swe_agent_runner.py b/crates/persisting-replay/assets/mini_swe_agent_runner.py index c634454f..b53f4400 100644 --- a/crates/persisting-replay/assets/mini_swe_agent_runner.py +++ b/crates/persisting-replay/assets/mini_swe_agent_runner.py @@ -108,11 +108,20 @@ def run(request: dict[str, Any]) -> None: from minisweagent.models import get_model source = _load(Path(request["source"])) + mode = str(request["mode"]) + if mode not in {"replay_only", "replay_and_continue"}: + raise ValueError(f"unsupported replay mode: {mode}") + after_step = int(request["after_step"]) + max_steps = request.get("max_steps") + if max_steps is not None: + max_steps = int(max_steps) + if max_steps < after_step or (mode == "replay_and_continue" and max_steps == after_step): + raise ValueError("max_steps does not leave the steps required by replay mode") info = source["info"] config = copy.deepcopy(info["config"]) messages = source["messages"] - selected = _action_messages(messages)[: int(request["after_step"])] - if len(selected) != int(request["after_step"]): + selected = _action_messages(messages)[:after_step] + if len(selected) != after_step: raise ValueError("native trajectory does not contain the requested replay prefix") model_config = config["model"] @@ -146,8 +155,8 @@ def run(request: dict[str, Any]) -> None: agent_config["mode"] = "yolo" if "confirm_exit" in agent_config: agent_config["confirm_exit"] = False - if request.get("max_steps") is not None: - agent_config["step_limit"] = int(request["max_steps"]) + if max_steps is not None: + agent_config["step_limit"] = max_steps agent_config["cost_limit"] = 0 model = get_model(config=model_config) @@ -184,11 +193,48 @@ def run(request: dict[str, Any]) -> None: source_prefix = messages[: selected[-1][0] + 1] agent.n_calls = sum(_has_model_response(message) for message in source_prefix) + prefix_calls = agent.n_calls agent.cost = sum(float((message.get("extra") or {}).get("cost") or 0) for message in source_prefix) Path(request["observations"]).write_text( json.dumps(fresh, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" ) - _continue(agent, Path(request["continued"])) + reconstructed_path = Path(request["reconstructed"]) + continued_path = Path(request["continued"]) + agent.save(reconstructed_path) + if mode == "replay_only": + phase = "replayed" + agent_status = "not_started" + continued_steps = 0 + trajectory_path = reconstructed_path + else: + _continue(agent, continued_path) + phase = "continued" + continued_steps = max(0, int(agent.n_calls) - int(prefix_calls)) + exit_status = "" + if agent.messages: + exit_status = str((agent.messages[-1].get("extra") or {}).get("exit_status") or "") + agent_status = ( + "max_steps" + if exit_status in {"LimitsExceeded", "StepLimitExceeded"} + else "completed" + ) + trajectory_path = continued_path + Path(request["result"]).write_text( + json.dumps( + { + "phase": phase, + "agent_status": agent_status, + "replayed_steps": len(selected), + "continued_steps": continued_steps, + "trajectory": str(trajectory_path), + "reconstructed": str(reconstructed_path), + }, + ensure_ascii=False, + indent=2, + ) + + "\n", + encoding="utf-8", + ) if __name__ == "__main__": diff --git a/crates/persisting-replay/assets/swe_agent_runner.py b/crates/persisting-replay/assets/swe_agent_runner.py index c8652b35..c87e5275 100644 --- a/crates/persisting-replay/assets/swe_agent_runner.py +++ b/crates/persisting-replay/assets/swe_agent_runner.py @@ -10,7 +10,7 @@ from sweagent.agent.agents import DefaultAgent from sweagent.environment.swe_env import SWEEnv -from sweagent.run.run_single import RunSingle, RunSingleConfig +from sweagent.run.run_single import RunSingleConfig from swerex.deployment.config import get_deployment @@ -19,6 +19,7 @@ def __init__(self, prefix: list[dict[str, Any]], live_model: Any) -> None: self.prefix = prefix self.live_model = live_model self.index = 0 + self.live_calls = 0 @property def stats(self) -> Any: @@ -38,6 +39,7 @@ def query(self, history: list[dict[str, Any]], **kwargs: Any) -> dict[str, Any]: if value.get("thinking_blocks") is not None: result["thinking_blocks"] = value["thinking_blocks"] return result + self.live_calls += 1 return self.live_model.query(history, **kwargs) def __getattr__(self, name: str) -> Any: @@ -66,11 +68,22 @@ def main() -> int: if len(sys.argv) != 2: raise SystemExit("runner expects one JSON request path") request = json.loads(Path(sys.argv[1]).read_text()) + mode = str(request["mode"]) + if mode not in {"replay_only", "replay_and_continue"}: + raise ValueError(f"unsupported replay mode: {mode}") + after_step = int(request["after_step"]) + max_steps = request.get("max_steps") + if max_steps is not None: + max_steps = int(max_steps) + if max_steps < after_step or (mode == "replay_and_continue" and max_steps == after_step): + raise ValueError("max_steps does not leave the steps required by replay mode") source = json.loads(Path(request["trajectory"]).read_text()) config = _config(source.get("replay_config"), request["workspace"]) + if getattr(config.agent, "type", None) != "default": + raise ValueError("SWE-agent retry configurations are unsupported for deterministic replay") assistant = [item for item in source["history"] if item.get("role") == "assistant"] - prefix = assistant[: int(request["after_step"])] - if len(prefix) != int(request["after_step"]): + prefix = assistant[:after_step] + if len(prefix) != after_step: raise ValueError("SWE-agent history has fewer assistant actions than the cutoff") agent = DefaultAgent.from_config(config.agent) @@ -82,14 +95,63 @@ def main() -> int: repo=config.env.repo, post_startup_commands=[], ) - run = RunSingle( - environment, - agent, + output_dir = Path(request["output_dir"]) + agent.setup( + env=environment, problem_statement=config.problem_statement, - output_dir=Path(request["output_dir"]), + output_dir=output_dir, + ) + agent.replay_config = config + total_steps = 0 + done = False + while True: + if mode == "replay_only" and total_steps >= after_step: + break + if max_steps is not None and total_steps >= max_steps: + break + step_output = agent.step() + total_steps += 1 + agent.save_trajectory() + if total_steps == after_step: + Path(request["reconstructed"]).write_text( + json.dumps(agent.get_trajectory_data(), indent=2) + "\n" + ) + if total_steps >= after_step and step_output.done: + done = True + break + if agent.model.index != after_step: + raise RuntimeError( + f"SWE-agent replay consumed {agent.model.index} source actions, expected {after_step}" + ) + if mode == "replay_only" and agent.model.live_calls != 0: + raise RuntimeError("SWE-agent replay-only unexpectedly queried the live model") + data = agent.get_trajectory_data() + agent.save_trajectory() + continued_steps = max(0, total_steps - after_step) + if mode == "replay_only": + phase = "replayed" + agent_status = "not_started" + trajectory_path = Path(request["reconstructed"]) + else: + phase = "continued" + agent_status = "completed" if done else "max_steps" + trajectory_path = Path(request["continued"]) + trajectory_path.write_text(json.dumps(data, indent=2) + "\n") + Path(request["result"]).write_text( + json.dumps( + { + "phase": phase, + "agent_status": agent_status, + "replayed_steps": after_step, + "continued_steps": continued_steps, + "trajectory": str(trajectory_path), + "reconstructed": str(request["reconstructed"]), + "trajectory_steps": len(data["trajectory"]), + }, + indent=2, + ) + + "\n" ) - run.run() - print(json.dumps({"status": "completed", "replayed_actions": len(prefix)})) return 0 diff --git a/crates/persisting-replay/src/adapter.rs b/crates/persisting-replay/src/adapter/claude_code.rs similarity index 50% rename from crates/persisting-replay/src/adapter.rs rename to crates/persisting-replay/src/adapter/claude_code.rs index 6110e43a..221f2ed9 100644 --- a/crates/persisting-replay/src/adapter.rs +++ b/crates/persisting-replay/src/adapter/claude_code.rs @@ -1,286 +1,54 @@ use std::collections::{BTreeMap, BTreeSet}; -use std::ffi::OsString; use std::fs; -use std::io::{Read, Write}; -#[cfg(unix)] -use std::os::unix::process::CommandExt; -use std::path::{Component, Path, PathBuf}; -use std::process::{Command, Output, Stdio}; -use std::thread; +use std::path::{Path, PathBuf}; +use std::process::Command; use std::time::{Duration, Instant}; -use serde::Deserialize; use serde_json::{json, Value}; +use super::{ + agent_command, check_boundary, sanitized_environment, RunContext, MAX_TOOL_OUTPUT_BYTES, +}; use crate::claude_bridge::ClaudeBridgeHandle; use crate::claude_resume::ResumeTransportManifest; use crate::error::{ReplayError, ReplayErrorKind, ResultExt}; use crate::io::{atomic_write, atomic_write_json, canonicalize, read_regular_file, sha256}; use crate::journal::Journal; use crate::model::{ - AgentKind, FreshObservation, PlaybackRequest, ReplayOutcome, ReplayPlan, ToolBatch, ToolCall, + AdapterPlan, FreshObservation, PlaybackRequest, ReplayMode, ReplayOutcome, ReplayPlan, + ToolBatch, ToolCall, }; +use crate::process::{run_process, ProcessSpec}; -const MAX_TOOL_OUTPUT_BYTES: usize = 4 * 1024 * 1024; -const SUPPORTED_CLAUDE_TOOLS: &[&str] = &[ +const FRESH_CLAUDE_TOOLS: &[&str] = &["Bash", "Edit", "Glob", "Grep", "MultiEdit", "Read", "Write"]; +const STALE_CLAUDE_TOOLS: &[&str] = &[ "Agent", - "Bash", - "Edit", - "Find", - "Glob", - "Grep", - "MultiEdit", - "Read", "TaskCreate", "TaskGet", "TaskList", "TaskOutput", "TaskUpdate", "TodoWrite", - "Write", ]; -#[derive(Debug, Clone)] -pub struct LaunchSpec { - pub entrypoint: PathBuf, - pub version: String, - pub source: String, - pub runtime_root: Option, -} - -pub struct RunContext<'a> { - pub request: &'a PlaybackRequest, - pub state_dir: &'a Path, - pub output_dir: &'a Path, - pub launch: Option<&'a LaunchSpec>, - pub session_id: &'a str, - pub nonce: &'a str, -} - -pub fn resolve_launch_spec(request: &PlaybackRequest) -> Result, ReplayError> { - if request.agent_entrypoint.is_some() && request.agent_runtime.is_some() { - return Err(ReplayError::configuration( - "agent entrypoint and agent runtime are mutually exclusive", - )); - } - if request.replay_only && request.agent_entrypoint.is_none() && request.agent_runtime.is_none() - { - return Ok(None); - } - let (entrypoint, source, runtime_root, declared_version) = - if let Some(runtime_root) = &request.agent_runtime { - let root = canonicalize( - runtime_root, - ReplayErrorKind::Configuration, - "agent runtime", - )?; - let manifest_path = root.join("sandbox-playback-agent.json"); - let manifest: RuntimeManifest = - serde_json::from_slice(&read_regular_file(&manifest_path)?).replay_context( - ReplayErrorKind::Configuration, - format!("parse agent runtime manifest {}", manifest_path.display()), - )?; - if manifest.schema_version != "sandbox-playback.agent-runtime/v1" { - return Err(ReplayError::configuration( - "agent runtime schema_version must be sandbox-playback.agent-runtime/v1", - )); - } - if manifest.agent != request.agent.as_str() { - return Err(ReplayError::new( - ReplayErrorKind::UnsupportedAgent, - format!( - "agent runtime declares {:?}, requested {:?}", - manifest.agent, - request.agent.as_str() - ), - )); - } - if manifest.version != request.agent.supported_version() { - return Err(ReplayError::new( - ReplayErrorKind::UnsupportedVersion, - format!( - "agent runtime declares {:?}; profile requires {}", - manifest.version, - request.agent.supported_version() - ), - )); - } - let relative = safe_relative(&manifest.entrypoint)?; - ( - root.join(relative), - "runtime_manifest".to_owned(), - Some(root), - Some(manifest.version), - ) - } else { - let entrypoint = request.agent_entrypoint.clone().ok_or_else(|| { - ReplayError::configuration( - "non-replay-only mode requires --agent-entrypoint or --agent-runtime", - ) - })?; - (entrypoint, "explicit_entrypoint".to_owned(), None, None) - }; - if !entrypoint.is_absolute() { - return Err(ReplayError::configuration( - "agent entrypoint must be an absolute path", - )); - } - let entrypoint = canonicalize( - &entrypoint, - ReplayErrorKind::Configuration, - "agent entrypoint", - )?; - if !entrypoint.is_file() { - return Err(ReplayError::configuration(format!( - "agent entrypoint is not a regular file: {}", - entrypoint.display() - ))); - } - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - if entrypoint - .metadata() - .map(|m| m.permissions().mode() & 0o111 == 0) - .unwrap_or(true) - { - return Err(ReplayError::configuration(format!( - "agent entrypoint is not executable: {}", - entrypoint.display() - ))); - } - } - let version = probe_version(request.agent, &entrypoint)?; - if declared_version - .as_deref() - .is_some_and(|declared| declared != version) - { - return Err(ReplayError::new( - ReplayErrorKind::UnsupportedVersion, - "agent runtime manifest and executable versions differ", - )); - } - Ok(Some(LaunchSpec { - entrypoint, - version, - source, - runtime_root, - })) -} - -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -struct RuntimeManifest { - schema_version: String, - agent: String, - version: String, - entrypoint: PathBuf, - #[serde(default, rename = "paths")] - _paths: BTreeMap, -} - -fn safe_relative(path: &Path) -> Result { - if path.as_os_str().is_empty() - || path.is_absolute() - || path.components().any(|component| { - matches!( - component, - Component::ParentDir | Component::RootDir | Component::Prefix(_) - ) - }) - { - return Err(ReplayError::configuration( - "agent runtime entrypoint must be a non-empty relative path without '..'", - )); - } - Ok(path.to_path_buf()) -} - -fn probe_version(agent: AgentKind, entrypoint: &Path) -> Result { - let expected = agent.supported_version(); - let mut command = Command::new(entrypoint); - match agent { - AgentKind::ClaudeCode | AgentKind::MiniSweAgent => { - command.arg("--version"); - } - AgentKind::Openhands => { - command.args([ - "-c", - "import importlib.metadata;print(importlib.metadata.version('openhands-ai'))", - ]); - } - AgentKind::SweAgent => { - command.args([ - "-c", - "import importlib.metadata;print(importlib.metadata.version('sweagent'))", - ]); - } - } - command.env_remove("PYTHONHOME"); - command.env_remove("PYTHONPATH"); - command.env_remove("VIRTUAL_ENV"); - if agent == AgentKind::MiniSweAgent { - let runtime = mini_python_runtime(entrypoint)?; - configure_mini_python_environment(&mut command, &runtime)?; - } - let output = command.output().replay_context( - ReplayErrorKind::UnsupportedVersion, - format!( - "probe {} version from {}", - agent.as_str(), - entrypoint.display() - ), - )?; - let rendered = String::from_utf8_lossy(if output.stdout.is_empty() { - &output.stderr - } else { - &output.stdout - }); - let detected = probed_version(agent, &rendered, expected); - // mini-swe-agent 2.4.6 prints its version before loading the global config. - // In a freshly provisioned sandbox that later config load can exit non-zero, - // but the unambiguous version banner is still a valid executable probe. - let status_is_acceptable = - output.status.success() || (agent == AgentKind::MiniSweAgent && detected == Some(expected)); - if !status_is_acceptable || detected != Some(expected) { - return Err(ReplayError::new( - ReplayErrorKind::UnsupportedVersion, - format!( - "{} profile requires {}, got {:?} from {}", - agent.as_str(), - expected, - rendered.trim(), - entrypoint.display() - ), - )); - } - Ok(expected.to_owned()) +fn required_str<'a>(value: &'a Value, field: &str, context: &str) -> Result<&'a str, ReplayError> { + value + .get(field) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .ok_or_else(|| ReplayError::trajectory(format!("{context} has no {field}"))) } -fn probed_version<'a>(agent: AgentKind, rendered: &'a str, expected: &'a str) -> Option<&'a str> { - if agent != AgentKind::MiniSweAgent { - return rendered.contains(expected).then_some(expected); - } - - const PREFIX: &str = "This is mini-swe-agent version "; - rendered.lines().find_map(|line| { - let version = line - .trim() - .strip_prefix(PREFIX)? - .split_whitespace() - .next()? - .trim_end_matches('.'); - (version == expected).then_some(version) - }) +pub(super) fn build(request: &PlaybackRequest) -> Result { + build_claude_plan(request).map(AdapterPlan::ClaudeCode) } -pub fn build_plan(request: &PlaybackRequest) -> Result { - match request.agent { - AgentKind::ClaudeCode => build_claude_plan(request), - AgentKind::MiniSweAgent => build_mini_plan(request), - AgentKind::Openhands => build_openhands_plan(request), - AgentKind::SweAgent => build_swe_plan(request), - } +pub(super) fn execute( + plan: &ReplayPlan, + context: &RunContext<'_>, + journal: &mut Journal, +) -> Result { + run_claude(plan, context, journal) } fn claude_boundary_tool_use_ids(plan: &ReplayPlan) -> Vec { @@ -512,19 +280,6 @@ fn claude_render_text(content: &Value) -> String { .join("\n") } -pub fn run( - plan: &ReplayPlan, - context: &RunContext<'_>, - journal: &mut Journal, -) -> Result { - match plan.agent { - AgentKind::ClaudeCode => run_claude(plan, context, journal), - AgentKind::MiniSweAgent => run_mini(plan, context, journal), - AgentKind::Openhands => run_openhands(plan, context, journal), - AgentKind::SweAgent => run_swe(plan, context, journal), - } -} - fn build_claude_plan(request: &PlaybackRequest) -> Result { let raw = read_regular_file(&request.trajectory)?; let text = std::str::from_utf8(&raw).replay_context( @@ -585,22 +340,9 @@ fn build_claude_plan(request: &PlaybackRequest) -> Result bool { && event.get("uuid").and_then(Value::as_str).is_some() } -fn mini_reasoning(message: &Value) -> &str { - message - .get("reasoning_content") - .and_then(Value::as_str) - .or_else(|| { - message - .pointer("/extra/response/choices/0/message/reasoning_content") - .and_then(Value::as_str) - }) - .unwrap_or_default() -} - -fn mini_batch_signature(batch: &ToolBatch, message: &Value) -> Value { - json!({ - "text": batch.assistant_text.as_str(), - "reasoning": mini_reasoning(message), - "tools": batch.tool_calls.iter().map(|call| json!({ - "name": call.name.as_str(), - "arguments": &call.arguments, - })).collect::>(), - }) -} - -fn build_mini_plan(request: &PlaybackRequest) -> Result { - let raw = read_regular_file(&request.trajectory)?; - let value: Value = serde_json::from_slice(&raw).replay_context( - ReplayErrorKind::Trajectory, - "invalid mini-swe-agent trajectory JSON", +fn run_claude( + plan: &ReplayPlan, + context: &RunContext<'_>, + journal: &mut Journal, +) -> Result { + let session_id = plan.native["session_id"] + .as_str() + .ok_or_else(|| ReplayError::trajectory("Claude plan lost its session ID"))?; + if context.request.mode == ReplayMode::PrepareOnly { + let replacements = plan + .calls() + .map(|call| { + ( + call.call_id.clone(), + FreshObservation { + call_id: call.call_id.clone(), + content: call.original_observation.clone(), + is_error: call.original_is_error, + return_code: None, + duration_ms: 0, + truncated: false, + metadata: BTreeMap::new(), + }, + ) + }) + .collect(); + let canonical = rebuild_claude(plan, &replacements)?; + let prepared = context.output_dir.join("native/prepared-prefix.jsonl"); + atomic_write(&prepared, canonical.as_bytes())?; + journal.append( + "session_prepared", + [("sha256".into(), json!(sha256(canonical.as_bytes())))], + )?; + return Ok(ReplayOutcome { + status: "prepared".into(), + reconstructed_path: Some(prepared), + continued_path: None, + observations: Vec::new(), + continued_steps: 0, + metadata: json!({"native_session_id": session_id}), + }); + } + let mut replacements = BTreeMap::new(); + let mut observations = Vec::new(); + let mut comparisons = Vec::new(); + let historical_logs = context.output_dir.join("logs/historical-tools"); + fs::create_dir_all(&historical_logs).replay_context( + ReplayErrorKind::Executor, + "create Claude historical tool log directory", )?; - if value.get("trajectory_format").and_then(Value::as_str) != Some("mini-swe-agent-1.1") { - return Err(ReplayError::trajectory( - "mini-swe-agent trajectory_format must be mini-swe-agent-1.1", - )); + for batch in &plan.batches { + journal.append("batch_started", [("batch".into(), json!(batch.ordinal))])?; + for call in &batch.tool_calls { + journal.append( + "tool_started", + [ + ("batch".into(), json!(batch.ordinal)), + ("call_id".into(), json!(call.call_id)), + ("tool".into(), json!(call.name)), + ], + )?; + let bash_log = historical_logs.join(format!("bash-{}.log", call.ordinal)); + let fresh = execute_claude_tool_with_policy( + call, + &context.request.workspace, + context.request.allow_stale_observations, + Some(&bash_log), + )?; + replacements.insert(call.call_id.clone(), fresh.clone()); + comparisons.push(json!({ + "call_id": call.call_id, + "tool": call.name, + "exact": call.original_observation == fresh.content + && call.original_is_error == fresh.is_error + && !fresh.metadata.contains_key("opaque_source_observation"), + "original_is_error": call.original_is_error, + "replayed_is_error": fresh.is_error, + })); + journal.append( + "tool_finished", + [ + ("batch".into(), json!(batch.ordinal)), + ("call_id".into(), json!(call.call_id)), + ("return_code".into(), json!(fresh.return_code)), + ("is_error".into(), json!(fresh.is_error)), + ("duration_ms".into(), json!(fresh.duration_ms)), + ], + )?; + observations.push(fresh); + } + journal.append("batch_committed", [("batch".into(), json!(batch.ordinal))])?; } - if value - .get("info") - .and_then(|info| info.get("mini_version")) - .and_then(Value::as_str) - != Some("2.4.6") - { - return Err(ReplayError::new( - ReplayErrorKind::UnsupportedVersion, - "mini-swe-agent trajectory requires exact version 2.4.6", - )); + let canonical = rebuild_claude(plan, &replacements)?; + let reconstructed = context.output_dir.join("native/reconstructed-prefix.jsonl"); + atomic_write(&reconstructed, canonical.as_bytes())?; + atomic_write_json( + &context.output_dir.join("observation-comparison.json"), + &comparisons, + )?; + journal.append( + "session_rebuilt", + [("sha256".into(), json!(sha256(canonical.as_bytes())))], + )?; + if context.request.mode == ReplayMode::ReplayOnly { + return Ok(ReplayOutcome { + status: "replayed".into(), + reconstructed_path: Some(reconstructed), + continued_path: None, + observations, + continued_steps: 0, + metadata: json!({"native_session_id": session_id}), + }); } - let messages = value - .get("messages") - .and_then(Value::as_array) - .ok_or_else(|| ReplayError::trajectory("mini-swe-agent messages must be an array"))?; - let mut batches = Vec::new(); - for (message_index, message) in messages.iter().enumerate() { - let native_calls = mini_calls(message, message_index)?; - if native_calls.is_empty() { - continue; - } - let mut observations = Vec::new(); - for candidate in messages.iter().skip(message_index + 1) { - if !mini_calls(candidate, message_index + 1 + observations.len())?.is_empty() { - break; - } - if matches!( - candidate.get("role").and_then(Value::as_str), - Some("tool" | "user") - ) || candidate.get("type").and_then(Value::as_str) == Some("function_call_output") - { - observations.push(candidate); - if observations.len() == native_calls.len() { - break; - } - } - } - if observations.len() != native_calls.len() { - break; + let launch = context + .launch + .ok_or_else(|| ReplayError::continuation("Claude continuation has no launch spec"))?; + if let Some(max_steps) = context.request.max_steps { + if max_steps <= plan.prefix_model_turns { + return Err(ReplayError::continuation( + "max-steps is exhausted by the replay prefix", + )); } - let batch_is_in_prefix = batches.len() < request.after_step; - let calls = native_calls - .into_iter() - .zip(observations) - .enumerate() - .map(|(index, (native, observation))| { - let command = native["arguments"]["command"].as_str().unwrap_or_default(); - if mini_submission_in_prefix(batch_is_in_prefix, command) { - return Err(ReplayError::new( - ReplayErrorKind::UnsupportedVersion, - "mini-swe-agent submission cannot appear inside a replay prefix", - )); - } - let return_code = observation - .get("extra") - .and_then(|extra| extra.get("returncode")) - .and_then(Value::as_i64); - Ok(ToolCall { - ordinal: index + 1, - call_id: native["id"].as_str().unwrap().to_owned(), - name: "bash".into(), - arguments: native["arguments"].clone(), - original_observation: mini_observation(observation), - original_is_error: return_code.is_some_and(|code| code != 0), - native, - }) - }) - .collect::, _>>()?; - batches.push(ToolBatch { - ordinal: batches.len() + 1, - native_locator: format!("messages:{message_index}"), - tool_calls: calls, - assistant_text: message - .get("content") - .and_then(Value::as_str) - .unwrap_or_default() - .to_owned(), - native: json!({"message_index": message_index}), - }); } - check_boundary(request.after_step, batches.len())?; - let original_next_action = if let Some(batch) = batches.get(request.after_step) { - let message_index = batch.native["message_index"].as_u64().ok_or_else(|| { - ReplayError::trajectory("mini-swe-agent next action lost message_index") - })? as usize; - let message = messages.get(message_index).ok_or_else(|| { - ReplayError::trajectory(format!( - "mini-swe-agent next action message index {message_index} is out of bounds" - )) - })?; - Some(mini_batch_signature(batch, message)) - } else { - None - }; - batches.truncate(request.after_step); - let boundary_message_index = batches.last().unwrap().native["message_index"] - .as_u64() - .ok_or_else(|| ReplayError::trajectory("mini-swe-agent batch lost message_index"))? - as usize; - let prefix_model_turns = value["messages"] - .as_array() - .ok_or_else(|| ReplayError::trajectory("mini-swe-agent messages must be an array"))? - .iter() - .take(boundary_message_index + 1) - .filter(|message| { - message - .get("extra") - .and_then(|extra| extra.get("response")) - .is_some_and(Value::is_object) + let remaining_turns = context + .request + .max_steps + .map(|max_steps| max_steps - plan.prefix_model_turns); + let config_dir = context.state_dir.join("claude-config"); + let project_key: String = context + .request + .workspace + .to_string_lossy() + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() { + character + } else { + '-' + } }) - .count(); - Ok(ReplayPlan { - agent: request.agent, - source_path: canonicalize( - &request.trajectory, - ReplayErrorKind::Trajectory, - "trajectory", - )?, - source_sha256: sha256(&raw), - after_step: request.after_step, - prefix_model_turns, - batches, - native: value, - original_next_action, - }) -} - -fn mini_submission_in_prefix(batch_is_in_prefix: bool, command: &str) -> bool { - batch_is_in_prefix - && command - .trim_start() - .starts_with("echo COMPLETE_TASK_AND_SUBMIT_FINAL_OUTPUT") -} - -fn mini_calls(message: &Value, message_index: usize) -> Result, ReplayError> { - if let Some(actions) = message - .get("extra") - .and_then(|extra| extra.get("actions")) - .and_then(Value::as_array) - { - let native_calls = message - .get("tool_calls") - .and_then(Value::as_array) - .cloned() - .unwrap_or_default(); - return actions - .iter() - .enumerate() - .map(|(index, action)| { - let command = action - .get("command") - .and_then(Value::as_str) - .ok_or_else(|| { - ReplayError::trajectory(format!( - "mini-swe-agent message[{message_index}] has an invalid native action" - )) - })?; - let call_id = action - .get("tool_call_id") - .and_then(Value::as_str) - .or_else(|| { - native_calls - .get(index) - .and_then(|call| call.get("id")) - .and_then(Value::as_str) - }) - .map(str::to_owned) - .unwrap_or_else(|| format!("mini-{message_index}-{}", index + 1)); - Ok(json!({ - "id": call_id, - "arguments": {"command": command}, - "native": action, - })) - }) - .collect(); - } - let mut result = Vec::new(); - for (index, call) in message - .get("tool_calls") - .and_then(Value::as_array) - .into_iter() - .flatten() - .enumerate() - { - let function = call - .get("function") - .and_then(Value::as_object) - .ok_or_else(|| { - ReplayError::trajectory(format!( - "mini-swe-agent message[{message_index}] has an invalid tool call" - )) - })?; - if function.get("name").and_then(Value::as_str) != Some("bash") { - return Err(ReplayError::new( - ReplayErrorKind::UnsupportedVersion, - "mini-swe-agent playback supports only native bash actions", - )); - } - let arguments = match function.get("arguments") { - Some(Value::String(raw)) => serde_json::from_str(raw).replay_context( - ReplayErrorKind::Trajectory, - "invalid mini-swe-agent tool arguments", - )?, - Some(value) => value.clone(), - None => json!({}), - }; - if arguments.get("command").and_then(Value::as_str).is_none() { - return Err(ReplayError::trajectory( - "mini-swe-agent bash action has no command", - )); - } - result.push(json!({ - "id": call.get("id").and_then(Value::as_str) - .map(str::to_owned).unwrap_or_else(|| format!("mini-{message_index}-{}", index + 1)), - "arguments": arguments, - "native": call, - })); - } - Ok(result) -} - -fn mini_observation(message: &Value) -> Value { - message - .get("extra") - .and_then(|extra| extra.get("raw_output")) - .cloned() - .or_else(|| message.get("output").cloned()) - .or_else(|| message.get("content").cloned()) - .unwrap_or(Value::String(String::new())) -} - -fn build_openhands_plan(request: &PlaybackRequest) -> Result { - let raw = read_regular_file(&request.trajectory)?; - let events: Vec = serde_json::from_slice(&raw).replay_context( - ReplayErrorKind::Trajectory, - "invalid OpenHands trajectory JSON", + .collect(); + let native_path = config_dir + .join("projects") + .join(project_key) + .join(format!("{session_id}.jsonl")); + atomic_write(&native_path, canonical.as_bytes())?; + let canonical_messages = claude_canonical_messages(&canonical)?; + let manifest = ResumeTransportManifest::create( + session_id, + claude_boundary_tool_use_ids(plan), + canonical_messages, + context.nonce.to_owned(), + ) + .map_err(|error| { + ReplayError::trajectory(format!( + "construct Claude Resume Transport manifest: {error}" + )) + })?; + let bridge = ClaudeBridgeHandle::start( + manifest, + context.session_id, + context.request.disable_thinking, )?; - if events.is_empty() { - return Err(ReplayError::trajectory( - "OpenHands trajectory must be a non-empty event array", - )); + journal.append("continuation_started", std::iter::empty())?; + let mut command = agent_command(&launch.entrypoint, context); + for (name, value) in bridge.child_environment() { + command.env(name, value); } - let mut ids = BTreeSet::new(); - for event in &events { - let id = event_id(event)?; - if !ids.insert(id) { - return Err(ReplayError::trajectory(format!( - "duplicate OpenHands event id {id}" - ))); - } + command + .args(["--verbose", "--output-format=stream-json", "--resume"]) + .arg(session_id); + if let Some(remaining_turns) = remaining_turns { + command.args(["--max-turns", &remaining_turns.to_string()]); } - let observations: BTreeMap = events - .iter() - .filter_map(|event| { - (event.get("observation").is_some() && !event["observation"].is_null()) - .then(|| { - event - .get("cause") - .and_then(Value::as_i64) - .map(|cause| (cause, event)) - }) - .flatten() - }) - .collect(); - let supported = ["run", "read", "edit", "run_ipython", "think"]; - let mut batches = Vec::new(); - for action in &events { - let action_name = action.get("action").and_then(Value::as_str); - if action.get("source").and_then(Value::as_str) != Some("agent") - || matches!(action_name, None | Some("system" | "finish" | "message")) - { - continue; - } - let action_name = action_name.unwrap(); - if !supported.contains(&action_name) { - return Err(ReplayError::new( - ReplayErrorKind::UnsupportedVersion, - format!("unsupported OpenHands action {action_name:?}"), - )); - } - let id = event_id(action)?; - let Some(observation) = observations.get(&id) else { - break; - }; - batches.push(ToolBatch { - ordinal: batches.len() + 1, - native_locator: format!("event:{id}"), - tool_calls: vec![ToolCall { - ordinal: batches.len() + 1, - call_id: id.to_string(), - name: action_name.to_owned(), - arguments: action.get("args").cloned().unwrap_or_else(|| json!({})), - original_observation: json!({ - "observation": observation.get("observation"), - "message": observation.get("message"), - "args": observation.get("args"), - }), - original_is_error: observation.get("observation").and_then(Value::as_str) - == Some("error"), - native: action.clone(), - }], - assistant_text: action - .get("args") - .and_then(|args| args.get("thought")) - .and_then(Value::as_str) - .unwrap_or_default() - .to_owned(), - native: json!({"observation_id": observation.get("id")}), - }); + if !context.request.disallowed_tools.is_empty() { + command.args([ + "--disallowedTools", + &context.request.disallowed_tools.join(","), + ]); } - check_boundary(request.after_step, batches.len())?; - batches.truncate(request.after_step); - let boundary_id = batches.last().unwrap().tool_calls[0] - .call_id - .parse::() - .unwrap(); - let initial_user_event = events - .iter() - .find(|event| { - event.get("source").and_then(Value::as_str) == Some("user") - && event.get("action").and_then(Value::as_str) == Some("message") - && event.get("id").and_then(Value::as_i64).unwrap_or(i64::MAX) <= boundary_id - }) - .cloned() - .ok_or_else(|| { - ReplayError::trajectory("OpenHands replay has no user message through the boundary") - })?; - let original_next_action = events.iter().find_map(|event| { - let id = event.get("id").and_then(Value::as_i64)?; - let action = event.get("action").and_then(Value::as_str)?; - if id <= boundary_id - || event.get("source").and_then(Value::as_str) != Some("agent") - || action == "finish" - { - return None; - } - Some(openhands_action_signature(event)) - }); - Ok(ReplayPlan { - agent: request.agent, - source_path: canonicalize( - &request.trajectory, - ReplayErrorKind::Trajectory, - "trajectory", - )?, - source_sha256: sha256(&raw), - after_step: request.after_step, - prefix_model_turns: request.after_step, - batches, - native: json!({"events": events, "initial_user_event": initial_user_event}), - original_next_action, - }) -} - -fn openhands_action_signature(event: &Value) -> Value { - let response_message = event - .get("tool_call_metadata") - .and_then(|metadata| metadata.get("model_response")) - .and_then(|response| response.get("choices")) - .and_then(Value::as_array) - .and_then(|choices| choices.first()) - .and_then(|choice| choice.get("message")); - let text = response_message - .and_then(|message| message.get("content")) - .and_then(Value::as_str) - .unwrap_or_default(); - let reasoning = response_message - .and_then(|message| message.get("reasoning_content")) - .and_then(Value::as_str) - .or_else(|| { - response_message.is_none().then(|| { - event - .get("args") - .and_then(|args| args.get("thought")) - .and_then(Value::as_str) - .unwrap_or_default() - }) - }) - .unwrap_or_default(); - json!({ - "text": text, - "reasoning": reasoning, - "tools": [{ - "name": event.get("action").and_then(Value::as_str).unwrap_or_default(), - "arguments": openhands_reconstructed_tool_arguments(event), - }], + command.args(["--permission-mode", "bypassPermissions", "--print"]); + command.env("CLAUDE_CONFIG_DIR", &config_dir); + let log = context.output_dir.join("logs/claude-code.jsonl"); + let output = run_process(ProcessSpec { + command, + stdin: Some(context.nonce.as_bytes().to_vec()), + timeout: Duration::from_secs(24 * 60 * 60), + termination_grace: Duration::from_secs(2), + pipe_grace: Duration::from_millis(250), + retained_bytes: MAX_TOOL_OUTPUT_BYTES / 2, + log_path: log.clone(), }) -} - -fn openhands_reconstructed_tool_metadata(event: &Value) -> Result { - let event_id = event_id(event)?; - let action = event - .get("action") - .and_then(Value::as_str) - .ok_or_else(|| ReplayError::trajectory("OpenHands replay action has no action"))?; - let tool_name = match action { - "run" => "execute_bash", - "read" | "edit" => "str_replace_editor", - "run_ipython" => "execute_ipython_cell", - "think" => "think", - _ => { - return Err(ReplayError::new( - ReplayErrorKind::UnsupportedVersion, - format!("unsupported OpenHands action {action:?}"), - )); + .map_err(|error| ReplayError::new(ReplayErrorKind::Continuation, error.message))?; + let process_error = if output.timed_out + || (!output.status.success() + && !expected_claude_max_turn_exit(&output.stdout_tail, remaining_turns)) + { + let mut rendered = String::from_utf8_lossy(&output.stdout_tail).into_owned(); + if !output.stderr_tail.is_empty() { + rendered.push('\n'); + rendered.push_str(&String::from_utf8_lossy(&output.stderr_tail)); } + Some(ReplayError::classify_continuation( + format!( + "Claude continuation exited {}; see {}", + output.status, + log.display() + ), + &rendered, + )) + } else { + None }; - let tool_call_id = format!("sandbox-playback-replay-{event_id}"); - let arguments = openhands_reconstructed_tool_arguments(event); - let serialized_arguments = serde_json::to_string(&arguments).replay_context( - ReplayErrorKind::Internal, - "serialize reconstructed OpenHands tool arguments", - )?; - let thought = event - .get("args") - .and_then(|args| args.get("thought")) - .and_then(Value::as_str) - .filter(|thought| !thought.is_empty()) - .map(str::to_owned); - Ok(json!({ - "function_name": tool_name, - "tool_call_id": tool_call_id.clone(), - "total_calls_in_response": 1, - "model_response": { - "id": format!("sandbox-playback-response-{event_id}"), - "created": 0, - "model": "sandbox-playback/reconstructed", - "object": "chat.completion", - "choices": [{ - "index": 0, - "finish_reason": "tool_calls", - "message": { - "role": "assistant", - "content": thought, - "tool_calls": [{ - "id": tool_call_id, - "type": "function", - "function": { - "name": tool_name, - "arguments": serialized_arguments, - }, - }], - }, - }], - }, - })) -} - -fn openhands_reconstructed_tool_arguments(event: &Value) -> Value { - let action = event - .get("action") - .and_then(Value::as_str) - .unwrap_or_default(); - let source = event.get("args").cloned().unwrap_or_else(|| json!({})); - match action { - "run" => { - let mut arguments = serde_json::Map::from_iter([( - "command".to_owned(), - source.get("command").cloned().unwrap_or_else(|| json!("")), - )]); - if let Some(value) = source.get("is_input") { - arguments.insert( - "is_input".to_owned(), - if let Some(value) = value.as_bool() { - Value::String(value.to_string()) - } else { - value.clone() - }, - ); - } - if let Some(value) = source.get("timeout").filter(|value| !value.is_null()) { - arguments.insert("timeout".to_owned(), value.clone()); - } - Value::Object(arguments) - } - "run_ipython" => json!({ - "code": source.get("code").cloned().unwrap_or_else(|| json!("")), - }), - "read" => { - let mut arguments = serde_json::Map::from_iter([ - ("command".to_owned(), json!("view")), - ( - "path".to_owned(), - source.get("path").cloned().unwrap_or_else(|| json!("")), - ), - ]); - if let Some(value) = source.get("view_range").filter(|value| !value.is_null()) { - arguments.insert("view_range".to_owned(), value.clone()); - } - Value::Object(arguments) - } - "edit" => { - let mut arguments = serde_json::Map::new(); - for key in [ - "command", - "path", - "file_text", - "old_str", - "new_str", - "insert_line", - "view_range", - ] { - if let Some(value) = source.get(key) { - arguments.insert(key.to_owned(), value.clone()); - } - } - arguments - .entry("command".to_owned()) - .or_insert(json!("str_replace")); - Value::Object(arguments) + let bridge_result = bridge.finish(); + if let Some(mut process_error) = process_error { + if let Err(bridge_error) = bridge_result { + process_error.message = format!( + "{}; SandboxReplay bridge shutdown/validation also failed: {}", + process_error.message, bridge_error + ); } - "think" => json!({ - "thought": source.get("thought").cloned().unwrap_or_else(|| json!("")), - }), - _ => source, + return Err(process_error); } + let validated_model_requests = bridge_result?; + let raw_continued = String::from_utf8(read_regular_file(&native_path)?).replay_context( + ReplayErrorKind::Continuation, + "continued Claude session is not UTF-8", + )?; + let (cleaned, continued_steps) = + clean_claude_continuation(plan, context.nonce, &raw_continued)?; + let continued = context.output_dir.join("native/continued-session.jsonl"); + atomic_write(&continued, cleaned.as_bytes())?; + journal.append( + "continuation_finished", + [ + ("return_code".into(), json!(output.status.code())), + ("continued_steps".into(), json!(continued_steps)), + ( + "validated_model_requests".into(), + json!(validated_model_requests), + ), + ], + )?; + Ok(ReplayOutcome { + status: "completed".into(), + reconstructed_path: Some(reconstructed), + continued_path: Some(continued), + observations, + continued_steps, + metadata: json!({ + "native_session_id": session_id, + "validated_model_requests": validated_model_requests, + "model_transport": "sandbox-replay-claude-bridge", + }), + }) } -fn event_id(event: &Value) -> Result { - event - .get("id") - .and_then(Value::as_i64) - .ok_or_else(|| ReplayError::trajectory("OpenHands event has no integer id")) +fn validate_claude_tool_policy( + call: &ToolCall, + allow_stale_observations: bool, +) -> Result<(), ReplayError> { + let unsupported = || { + ReplayError::new( + ReplayErrorKind::UnsupportedVersion, + format!( + "unsupported Claude replay tool call {}({}) inside the selected prefix", + call.name, call.call_id + ), + ) + }; + if call.name == "Find" + || (!FRESH_CLAUDE_TOOLS.contains(&call.name.as_str()) + && !STALE_CLAUDE_TOOLS.contains(&call.name.as_str())) + || (call.name == "Bash" + && call + .arguments + .get("run_in_background") + .and_then(Value::as_bool) + == Some(true)) + || (call.name == "Agent" + && call.arguments.get("subagent_type").and_then(Value::as_str) != Some("Explore")) + { + return Err(unsupported()); + } + let requires_stale = STALE_CLAUDE_TOOLS.contains(&call.name.as_str()) + || (call.original_is_error && claude_arguments_are_invalid(call)); + if requires_stale && !allow_stale_observations { + return Err(ReplayError::trajectory(format!( + "Claude tool call {}({}) can only reuse its source observation; pass --allow-stale-observations to opt into degraded replay", + call.name, call.call_id + ))); + } + Ok(()) } -fn build_swe_plan(request: &PlaybackRequest) -> Result { - let raw = read_regular_file(&request.trajectory)?; - let mut value: Value = serde_json::from_slice(&raw).replay_context( - ReplayErrorKind::Trajectory, - "invalid SWE-agent trajectory JSON", - )?; - for field in ["trajectory", "history", "replay_config"] { - if value.get(field).is_none() { - return Err(ReplayError::trajectory(format!( - "SWE-agent trajectory is missing {field}" - ))); - } +fn execute_claude_tool_with_policy( + call: &ToolCall, + workspace: &Path, + allow_stale_observations: bool, + bash_log: Option<&Path>, +) -> Result { + validate_claude_tool_policy(call, allow_stale_observations)?; + let started = Instant::now(); + if call.original_is_error && claude_arguments_are_invalid(call) { + return replay_original_observation(call, started); } - resolve_swe_problem_asset(&mut value, request.trajectory_assets.as_deref())?; - let trajectory = value["trajectory"] - .as_array() - .ok_or_else(|| ReplayError::trajectory("SWE-agent trajectory must be an array"))?; - let history: Vec<_> = value["history"] - .as_array() - .ok_or_else(|| ReplayError::trajectory("SWE-agent history must be an array"))? - .iter() - .filter(|item| item.get("role").and_then(Value::as_str) == Some("assistant")) - .collect(); - check_boundary(request.after_step, trajectory.len().min(history.len()))?; - let original_next_action = trajectory.get(request.after_step).map(|step| { - json!({ - "text": "", - "reasoning": step.get("thought").and_then(Value::as_str).unwrap_or_default(), - "tools": [{ - "name": "swe_agent_action", - "arguments": {"raw_action": step.get("action").cloned().unwrap_or(Value::Null)}, - }], - }) - }); - let mut batches = Vec::new(); - for index in 0..request.after_step { - let step = &trajectory[index]; - let assistant = history[index]; - let action = step - .get("action") - .and_then(Value::as_str) - .ok_or_else(|| ReplayError::trajectory("SWE-agent step has no action"))?; - if action.trim() == "submit" || action.trim_start().starts_with("submit\n") { - return Err(ReplayError::new( - ReplayErrorKind::UnsupportedVersion, - "SWE-agent submit cannot appear inside a replay prefix", - )); - } - let observation = step - .get("observation") - .and_then(Value::as_str) - .ok_or_else(|| ReplayError::trajectory("SWE-agent step has no observation"))?; - let calls = assistant - .get("tool_calls") - .and_then(Value::as_array) - .cloned() - .unwrap_or_default(); - let call_id = if calls.len() == 1 { - calls[0] - .get("id") - .and_then(Value::as_str) - .map(str::to_owned) - .unwrap_or_else(|| format!("swe-agent-step-{}", index + 1)) - } else { - format!("swe-agent-step-{}", index + 1) - }; - batches.push(ToolBatch { - ordinal: index + 1, - native_locator: format!("trajectory:{index}"), - tool_calls: vec![ToolCall { - ordinal: index + 1, - call_id, - name: "swe_agent_action".into(), - arguments: json!({"raw_action": action}), - original_observation: Value::String(observation.to_owned()), - original_is_error: false, - native: json!({"assistant": assistant}), - }], - assistant_text: step - .get("thought") - .and_then(Value::as_str) - .or_else(|| assistant.get("content").and_then(Value::as_str)) - .unwrap_or_default() - .to_owned(), - native: json!({"state": step.get("state")}), - }); - } - Ok(ReplayPlan { - agent: request.agent, - source_path: canonicalize( - &request.trajectory, - ReplayErrorKind::Trajectory, - "trajectory", - )?, - source_sha256: sha256(&raw), - after_step: request.after_step, - prefix_model_turns: request.after_step, - batches, - native: value, - original_next_action, - }) -} - -fn resolve_swe_problem_asset(value: &mut Value, assets: Option<&Path>) -> Result<(), ReplayError> { - let replay_config = value - .get_mut("replay_config") - .ok_or_else(|| ReplayError::trajectory("SWE-agent replay_config is required"))?; - if replay_config.is_string() { - let encoded = replay_config.as_str().unwrap(); - *replay_config = serde_json::from_str(encoded).replay_context( - ReplayErrorKind::Trajectory, - "invalid encoded SWE-agent replay_config", - )?; - } - let Some(problem) = replay_config.get_mut("problem_statement") else { - return Ok(()); - }; - if !matches!( - problem.get("type").and_then(Value::as_str), - Some("file" | "path") - ) { - return Ok(()); - } - let root = assets.ok_or_else(|| { - ReplayError::trajectory("SWE-agent file problem_statement requires trajectory_assets") - })?; - let relative = problem - .get("path") - .or_else(|| problem.get("file")) - .and_then(Value::as_str) - .ok_or_else(|| ReplayError::trajectory("SWE-agent problem asset path is invalid"))?; - let relative = safe_relative(Path::new(relative))?; - let root = canonicalize(root, ReplayErrorKind::Trajectory, "trajectory assets")?; - let path = canonicalize( - &root.join(relative), - ReplayErrorKind::Trajectory, - "trajectory asset", - )?; - if !path.starts_with(&root) { - return Err(ReplayError::trajectory( - "SWE-agent trajectory asset escapes its root", - )); - } - let text = String::from_utf8(read_regular_file(&path)?).replay_context( - ReplayErrorKind::Trajectory, - "SWE-agent problem asset is not UTF-8", - )?; - let id = problem - .get("id") - .cloned() - .unwrap_or_else(|| json!("replay")); - *problem = json!({"type": "text", "text": text, "id": id}); - Ok(()) -} - -fn check_boundary(after_step: usize, complete: usize) -> Result<(), ReplayError> { - if after_step == 0 || after_step > complete { - return Err(ReplayError::trajectory(format!( - "requested after-step {after_step}, trajectory has {complete} complete batches" - ))); - } - Ok(()) -} - -fn required_str<'a>(value: &'a Value, field: &str, context: &str) -> Result<&'a str, ReplayError> { - value - .get(field) - .and_then(Value::as_str) - .filter(|value| !value.is_empty()) - .ok_or_else(|| ReplayError::trajectory(format!("{context} has no {field}"))) -} - -fn run_claude( - plan: &ReplayPlan, - context: &RunContext<'_>, - journal: &mut Journal, -) -> Result { - let session_id = plan.native["session_id"] - .as_str() - .ok_or_else(|| ReplayError::trajectory("Claude plan lost its session ID"))?; - let mut replacements = BTreeMap::new(); - let mut observations = Vec::new(); - let mut comparisons = Vec::new(); - for batch in &plan.batches { - journal.append("batch_started", [("batch".into(), json!(batch.ordinal))])?; - for call in &batch.tool_calls { - journal.append( - "tool_started", - [ - ("batch".into(), json!(batch.ordinal)), - ("call_id".into(), json!(call.call_id)), - ("tool".into(), json!(call.name)), - ], - )?; - let fresh = execute_claude_tool(call, &context.request.workspace)?; - replacements.insert(call.call_id.clone(), fresh.clone()); - comparisons.push(json!({ - "call_id": call.call_id, - "tool": call.name, - "exact": call.original_observation == fresh.content - && call.original_is_error == fresh.is_error - && !fresh.metadata.contains_key("opaque_source_observation"), - "original_is_error": call.original_is_error, - "replayed_is_error": fresh.is_error, - })); - journal.append( - "tool_finished", - [ - ("batch".into(), json!(batch.ordinal)), - ("call_id".into(), json!(call.call_id)), - ("return_code".into(), json!(fresh.return_code)), - ("is_error".into(), json!(fresh.is_error)), - ("duration_ms".into(), json!(fresh.duration_ms)), - ], - )?; - observations.push(fresh); - } - journal.append("batch_committed", [("batch".into(), json!(batch.ordinal))])?; - } - let canonical = rebuild_claude(plan, &replacements)?; - let reconstructed = context.output_dir.join("native/reconstructed-prefix.jsonl"); - atomic_write(&reconstructed, canonical.as_bytes())?; - atomic_write_json( - &context.output_dir.join("observation-comparison.json"), - &comparisons, - )?; - journal.append( - "session_rebuilt", - [("sha256".into(), json!(sha256(canonical.as_bytes())))], - )?; - if context.request.replay_only { - return Ok(ReplayOutcome { - status: "replayed".into(), - reconstructed_path: Some(reconstructed), - continued_path: None, - observations, - continued_steps: 0, - metadata: json!({"native_session_id": session_id}), - }); - } - let launch = context - .launch - .ok_or_else(|| ReplayError::continuation("Claude continuation has no launch spec"))?; - if let Some(max_steps) = context.request.max_steps { - if max_steps <= plan.prefix_model_turns { - return Err(ReplayError::continuation( - "max-steps is exhausted by the replay prefix", - )); - } - } - let remaining_turns = context - .request - .max_steps - .map(|max_steps| max_steps - plan.prefix_model_turns); - let config_dir = context.state_dir.join("claude-config"); - let project_key: String = context - .request - .workspace - .to_string_lossy() - .chars() - .map(|character| { - if character.is_ascii_alphanumeric() { - character - } else { - '-' - } - }) - .collect(); - let native_path = config_dir - .join("projects") - .join(project_key) - .join(format!("{session_id}.jsonl")); - atomic_write(&native_path, canonical.as_bytes())?; - let canonical_messages = claude_canonical_messages(&canonical)?; - let manifest = ResumeTransportManifest::create( - session_id, - claude_boundary_tool_use_ids(plan), - canonical_messages, - context.nonce.to_owned(), - ) - .map_err(|error| { - ReplayError::trajectory(format!( - "construct Claude Resume Transport manifest: {error}" - )) - })?; - let bridge = ClaudeBridgeHandle::start( - manifest, - context.session_id, - context.request.disable_thinking, - )?; - journal.append("continuation_started", std::iter::empty())?; - let mut command = agent_command(&launch.entrypoint, context); - for (name, value) in bridge.child_environment() { - command.env(name, value); - } - command - .args(["--verbose", "--output-format=stream-json", "--resume"]) - .arg(session_id); - if let Some(remaining_turns) = remaining_turns { - command.args(["--max-turns", &remaining_turns.to_string()]); - } - if !context.request.disallowed_tools.is_empty() { - command.args([ - "--disallowedTools", - &context.request.disallowed_tools.join(","), - ]); - } - command.args(["--permission-mode", "bypassPermissions", "--print"]); - command.env("CLAUDE_CONFIG_DIR", &config_dir); - command - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - let mut child = command.spawn().replay_context( - ReplayErrorKind::Continuation, - format!("start Claude Code {}", launch.entrypoint.display()), - )?; - let nonce_write = match child.stdin.take() { - Some(mut stdin) => stdin - .write_all(context.nonce.as_bytes()) - .replay_context(ReplayErrorKind::Continuation, "write Claude resume nonce"), - None => Err(ReplayError::continuation("Claude stdin unavailable")), - }; - if let Err(error) = nonce_write { - let _ = child.kill(); - let _ = child.wait(); - return Err(error); - } - let output = child - .wait_with_output() - .replay_context(ReplayErrorKind::Continuation, "wait for Claude Code")?; - let log = context.output_dir.join("logs/claude-code.jsonl"); - write_process_log(&log, &output)?; - let process_error = if !output.status.success() - && !expected_claude_max_turn_exit(&output.stdout, remaining_turns) - { - let rendered = render_output(&output); - Some(ReplayError::classify_continuation( - format!( - "Claude continuation exited {}; see {}", - output.status, - log.display() - ), - &rendered, - )) - } else { - None - }; - let bridge_result = bridge.finish(); - if let Some(mut process_error) = process_error { - if let Err(bridge_error) = bridge_result { - process_error.message = format!( - "{}; SandboxReplay bridge shutdown/validation also failed: {}", - process_error.message, bridge_error - ); - } - return Err(process_error); - } - let validated_model_requests = bridge_result?; - let raw_continued = String::from_utf8(read_regular_file(&native_path)?).replay_context( - ReplayErrorKind::Continuation, - "continued Claude session is not UTF-8", - )?; - let (cleaned, continued_steps) = - clean_claude_continuation(plan, context.nonce, &raw_continued)?; - let continued = context.output_dir.join("native/continued-session.jsonl"); - atomic_write(&continued, cleaned.as_bytes())?; - journal.append( - "continuation_finished", - [ - ("return_code".into(), json!(output.status.code())), - ("continued_steps".into(), json!(continued_steps)), - ( - "validated_model_requests".into(), - json!(validated_model_requests), - ), - ], - )?; - Ok(ReplayOutcome { - status: "completed".into(), - reconstructed_path: Some(reconstructed), - continued_path: Some(continued), - observations, - continued_steps, - metadata: json!({ - "native_session_id": session_id, - "validated_model_requests": validated_model_requests, - "model_transport": "sandbox-replay-claude-bridge", - }), - }) -} - -fn execute_claude_tool(call: &ToolCall, workspace: &Path) -> Result { - let started = Instant::now(); - if call.original_is_error && claude_arguments_are_invalid(call) { - return replay_original_observation(call, started, "input_validation_error"); - } - let (content, is_error, return_code) = match call.name.as_str() { - "Agent" => { - if call.arguments.get("subagent_type").and_then(Value::as_str) != Some("Explore") { - return Err(ReplayError::new( - ReplayErrorKind::UnsupportedVersion, - "Claude Agent replay supports only the read-only Explore subagent", - )); - } - return replay_original_observation(call, started, "read_only_explore_agent"); + let (content, is_error, return_code) = match call.name.as_str() { + "Agent" => { + return replay_original_observation(call, started); } "TaskOutput" => { - // TaskOutput only retrieves the result of an already-launched subagent. It does not - // execute a command or mutate the workspace, so preserve the source observation just - // as we do for the read-only Explore Agent call that produced it. - return replay_original_observation(call, started, "read_only_task_output"); + return replay_original_observation(call, started); } "Bash" => { let command = call @@ -1992,9 +1075,24 @@ fn execute_claude_tool(call: &ToolCall, workspace: &Path) -> Result { + let log = bash_log.ok_or_else(|| { + ReplayError::new( + ReplayErrorKind::Internal, + "Claude Bash replay requires a process log path", + ) + })?; + let (content, is_error, return_code, truncated) = + run_bash(command, workspace, timeout, log)?; + return observation_with_truncation( + call, + content, + is_error, + return_code, + started, + truncated, + ); + } + "Read" => { let path = tool_path(&call.arguments, workspace, true)?; let bytes = match fs::read(&path) { Ok(bytes) => bytes, @@ -2130,16 +1228,9 @@ fn execute_claude_tool(call: &ToolCall, workspace: &Path) -> Result ( - "Find is unavailable in the native Claude Code 2.1.220 replay profile; use Glob".into(), - true, - Some(1), - ), - "TaskCreate" | "TaskGet" | "TaskList" | "TaskUpdate" | "TodoWrite" => ( - json!({"replayed": true, "tool": call.name, "input": call.arguments}).to_string(), - false, - Some(0), - ), + "TaskCreate" | "TaskGet" | "TaskList" | "TaskUpdate" | "TodoWrite" => { + return replay_original_observation(call, started); + } other => { return Err(ReplayError::new( ReplayErrorKind::UnsupportedVersion, @@ -2171,10 +1262,17 @@ fn claude_arguments_are_invalid(call: &ToolCall) -> bool { fn replay_original_observation( call: &ToolCall, started: Instant, - reason: &str, ) -> Result { let mut metadata = BTreeMap::new(); - metadata.insert("opaque_source_observation".into(), json!(reason)); + metadata.insert( + "opaque_source_observation".into(), + json!("stale_source_observation"), + ); + metadata.insert( + "degradation_reason".into(), + json!("stale_source_observation"), + ); + metadata.insert("source_call_id".into(), json!(call.call_id)); Ok(FreshObservation { call_id: call.call_id.clone(), content: call.original_observation.clone(), @@ -2192,10 +1290,21 @@ fn observation( is_error: bool, return_code: Option, started: Instant, +) -> Result { + observation_with_truncation(call, content, is_error, return_code, started, false) +} + +fn observation_with_truncation( + call: &ToolCall, + content: String, + is_error: bool, + return_code: Option, + started: Instant, + forced_truncated: bool, ) -> Result { let bytes = content.into_bytes(); - let truncated = bytes.len() > MAX_TOOL_OUTPUT_BYTES; - let content = if truncated { + let truncated = forced_truncated || bytes.len() > MAX_TOOL_OUTPUT_BYTES; + let content = if bytes.len() > MAX_TOOL_OUTPUT_BYTES { format!( "{}\n[output truncated by pvisor replay]", String::from_utf8_lossy(&bytes[..MAX_TOOL_OUTPUT_BYTES]) @@ -2218,69 +1327,35 @@ fn run_bash( command: &str, workspace: &Path, timeout: Duration, -) -> Result<(String, bool, Option), ReplayError> { + log_path: &Path, +) -> Result<(String, bool, Option, bool), ReplayError> { let mut process = Command::new("/bin/bash"); - process - .args(["-c", command]) - .current_dir(workspace) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - #[cfg(unix)] - process.process_group(0); + process.args(["-c", command]).current_dir(workspace); sanitized_environment(&mut process, true); - let mut child = process.spawn().replay_context( - ReplayErrorKind::Executor, - "execute historical Claude Bash call", - )?; - let mut stdout = child.stdout.take().expect("stdout configured as piped"); - let mut stderr = child.stderr.take().expect("stderr configured as piped"); - let stdout_reader = thread::spawn(move || { - let mut bytes = Vec::new(); - stdout.read_to_end(&mut bytes).map(|_| bytes) - }); - let stderr_reader = thread::spawn(move || { - let mut bytes = Vec::new(); - stderr.read_to_end(&mut bytes).map(|_| bytes) - }); - let started = Instant::now(); - let (status, timed_out) = loop { - if let Some(status) = child.try_wait().replay_context( - ReplayErrorKind::Executor, - "wait for historical Claude Bash call", - )? { - break (status, false); - } - if started.elapsed() >= timeout { - #[cfg(unix)] - unsafe { - libc::kill(-(child.id() as i32), libc::SIGKILL); - } - #[cfg(not(unix))] - let _ = child.kill(); - let status = child.wait().replay_context( - ReplayErrorKind::Executor, - "reap timed-out historical Claude Bash call", - )?; - break (status, true); - } - thread::sleep(Duration::from_millis(10)); - }; - let stdout = stdout_reader - .join() - .map_err(|_| ReplayError::new(ReplayErrorKind::Internal, "Bash stdout reader panicked"))? - .replay_context(ReplayErrorKind::Executor, "read historical Bash stdout")?; - let stderr = stderr_reader - .join() - .map_err(|_| ReplayError::new(ReplayErrorKind::Internal, "Bash stderr reader panicked"))? - .replay_context(ReplayErrorKind::Executor, "read historical Bash stderr")?; - let mut content = String::from_utf8_lossy(&stdout).into_owned(); - if !stderr.is_empty() { + let output = run_process(ProcessSpec { + command: process, + stdin: None, + timeout, + termination_grace: Duration::from_millis(250), + pipe_grace: Duration::from_millis(100), + retained_bytes: MAX_TOOL_OUTPUT_BYTES / 2, + log_path: log_path.to_path_buf(), + })?; + let mut content = String::from_utf8_lossy(&output.stdout_tail).into_owned(); + if !output.stderr_tail.is_empty() { if !content.is_empty() && !content.ends_with('\n') { content.push('\n'); } - content.push_str(&String::from_utf8_lossy(&stderr)); + content.push_str(&String::from_utf8_lossy(&output.stderr_tail)); } - if timed_out { + if output.stdout_truncated || output.stderr_truncated { + content + .push_str("\n[output truncated by pvisor replay; full output is in the process log]"); + } + if output.background_cleanup && !output.timed_out { + content.push_str("\n[background descendants were terminated after the command exited]"); + } + if output.timed_out { content = format!( "Command timed out after {} ms\n{}", timeout.as_millis(), @@ -2291,8 +1366,13 @@ fn run_bash( } Ok(( content, - timed_out || !status.success(), - if timed_out { Some(124) } else { status.code() }, + output.timed_out || output.background_cleanup || !output.status.success(), + if output.timed_out { + Some(124) + } else { + output.status.code() + }, + output.stdout_truncated || output.stderr_truncated, )) } @@ -2643,1146 +1723,277 @@ fn clean_claude_continuation( || dequeue.get("type").and_then(Value::as_str) != Some("queue-operation") || dequeue.get("operation").and_then(Value::as_str) != Some("dequeue") || dequeue.get("sessionId").and_then(Value::as_str) != Some(session_id) - { - return Err(ReplayError::continuation( - "Claude native queue resume envelope is malformed", - )); - } - if continue_event.get("type").and_then(Value::as_str) != Some("user") - || continue_event.get("isMeta").and_then(Value::as_bool) != Some(true) - || exact_claude_event_text(continue_event) != Some("Continue from where you left off.") - || no_response_event.get("type").and_then(Value::as_str) != Some("assistant") - || exact_claude_event_text(no_response_event) != Some("No response requested.") - || nonce_event.get("type").and_then(Value::as_str) != Some("user") - || exact_claude_event_text(nonce_event) != Some(nonce) - { - return Err(ReplayError::continuation( - "Claude native resume message envelope is malformed", - )); - } - let continue_uuid = required_event_uuid(continue_event, "continue")?; - let no_response_uuid = required_event_uuid(no_response_event, "no-response")?; - let nonce_uuid = required_event_uuid(nonce_event, "nonce")?; - if continue_event.get("parentUuid").and_then(Value::as_str) != Some(&boundary_uuid) - || no_response_event.get("parentUuid").and_then(Value::as_str) != Some(&continue_uuid) - || nonce_event.get("parentUuid").and_then(Value::as_str) != Some(&no_response_uuid) - { - return Err(ReplayError::continuation( - "Claude resume envelope is not attached directly to the boundary observation", - )); - } - - let mut remove_indexes: BTreeSet = (boundary_index + 1..boundary_index + 6).collect(); - let mut removed_parent_by_uuid = BTreeMap::from([ - (continue_uuid.clone(), boundary_uuid.clone()), - (no_response_uuid.clone(), continue_uuid), - (nonce_uuid.clone(), no_response_uuid), - ]); - let last_prompt_indexes: Vec<_> = events - .iter() - .enumerate() - .filter_map(|(index, event)| { - (event.get("type").and_then(Value::as_str) == Some("last-prompt") - && event.get("lastPrompt").and_then(Value::as_str) == Some(nonce) - && event.get("sessionId").and_then(Value::as_str) == Some(session_id)) - .then_some(index) - }) - .collect(); - if last_prompt_indexes.is_empty() { - return Err(ReplayError::continuation( - "resumed Claude session has no nonce last-prompt metadata", - )); - } - remove_indexes.extend(last_prompt_indexes); - - let mut attachment_parent = nonce_uuid; - let mut previous_attachment_order = -1_i8; - let mut seen_attachment_types = BTreeSet::new(); - loop { - let matching: Vec<_> = events - .iter() - .enumerate() - .filter(|(index, event)| { - !remove_indexes.contains(index) - && event.get("type").and_then(Value::as_str) == Some("attachment") - && event.get("parentUuid").and_then(Value::as_str) - == Some(attachment_parent.as_str()) - }) - .collect(); - if matching.is_empty() { - break; - } - if matching.len() != 1 { - return Err(ReplayError::continuation( - "Claude resume attachment branch is ambiguous", - )); - } - let (index, event) = matching[0]; - let attachment = event.get("attachment").unwrap_or(&Value::Null); - let attachment_type = attachment - .get("type") - .and_then(Value::as_str) - .unwrap_or_default(); - let attachment_order = match attachment_type { - "agent_listing_delta" => 0, - "skill_listing" => 1, - "task_reminder" => 2, - _ => -1, - }; - let event_uuid = required_event_uuid(event, "resume attachment")?; - if !valid_claude_resume_attachment(attachment) - || !seen_attachment_types.insert(attachment_type.to_owned()) - || attachment_order <= previous_attachment_order - { - return Err(ReplayError::continuation( - "unexpected attachment in Claude resume envelope", - )); - } - previous_attachment_order = attachment_order; - remove_indexes.insert(index); - removed_parent_by_uuid.insert(event_uuid.clone(), attachment_parent); - attachment_parent = event_uuid; - } - - let mut cleaned_events = Vec::with_capacity(events.len() - remove_indexes.len()); - let mut first_real_assistant: Option = None; - for (index, event) in events.into_iter().enumerate() { - if remove_indexes.contains(&index) { - continue; - } - let mut updated = event; - if let Some(parent) = updated - .get("parentUuid") - .and_then(Value::as_str) - .map(str::to_owned) - { - let resolved = resolve_claude_parent(parent, &removed_parent_by_uuid)?; - updated["parentUuid"] = Value::String(resolved); - } - if index > boundary_index - && first_real_assistant.is_none() - && updated.get("type").and_then(Value::as_str) == Some("assistant") - && updated.get("isSidechain").and_then(Value::as_bool) != Some(true) - { - first_real_assistant = Some(updated.clone()); - } - cleaned_events.push(updated); - } - let first_real_assistant = first_real_assistant - .ok_or_else(|| ReplayError::continuation("Claude produced no real continuation turn"))?; - if first_real_assistant - .get("parentUuid") - .and_then(Value::as_str) - != Some(boundary_uuid.as_str()) - { - return Err(ReplayError::continuation( - "first real resumed Claude assistant is not a child of the boundary observation", - )); - } - for forbidden in [ - nonce, - "Continue from where you left off.", - "No response requested.", - ] { - if cleaned_events - .iter() - .any(|event| value_contains(event, forbidden)) - { - return Err(ReplayError::continuation( - "Claude resume transport text remains after native-session cleanup", - )); - } - } - let cleaned_boundary_index = cleaned_events - .iter() - .position(|event| event.get("uuid").and_then(Value::as_str) == Some(&boundary_uuid)) - .ok_or_else(|| ReplayError::continuation("cleaned Claude session lost its boundary"))?; - let continued_steps = cleaned_events - .iter() - .skip(cleaned_boundary_index + 1) - .filter(|event| { - main_event(event) - && event.get("type").and_then(Value::as_str) == Some("assistant") - && event - .get("message") - .and_then(|message| message.get("stop_reason")) - .is_some_and(|reason| !reason.is_null()) - }) - .count(); - if continued_steps == 0 { - return Err(ReplayError::continuation( - "cleaned Claude session has no complete continuation turn", - )); - } - let mut output = cleaned_events - .iter() - .map(serde_json::to_string) - .collect::, _>>() - .replay_context( - ReplayErrorKind::Internal, - "serialize cleaned Claude session", - )? - .join("\n"); - output.push('\n'); - Ok((output, continued_steps)) -} - -fn exact_claude_event_text(event: &Value) -> Option<&str> { - let content = event.get("message")?.get("content")?; - if let Some(text) = content.as_str() { - return Some(text); - } - let blocks = content.as_array()?; - if blocks.len() == 1 && blocks[0].get("type").and_then(Value::as_str) == Some("text") { - return blocks[0].get("text").and_then(Value::as_str); - } - None -} - -fn required_event_uuid(event: &Value, context: &str) -> Result { - event - .get("uuid") - .and_then(Value::as_str) - .filter(|uuid| !uuid.is_empty()) - .map(str::to_owned) - .ok_or_else(|| ReplayError::continuation(format!("Claude {context} event lacks a UUID"))) -} - -fn valid_claude_resume_attachment(attachment: &Value) -> bool { - match attachment.get("type").and_then(Value::as_str) { - Some("task_reminder") => { - attachment - .get("content") - .and_then(Value::as_array) - .is_some_and(Vec::is_empty) - && attachment.get("itemCount").and_then(Value::as_u64) == Some(0) - } - Some("agent_listing_delta") => { - let Some(added_lines) = attachment.get("addedLines").and_then(Value::as_array) else { - return false; - }; - let Some(added_types) = attachment.get("addedTypes").and_then(Value::as_array) else { - return false; - }; - attachment.get("isInitial").and_then(Value::as_bool) == Some(true) - && attachment - .get("showConcurrencyNote") - .and_then(Value::as_bool) - .is_some() - && added_lines.iter().all(Value::is_string) - && added_types.iter().all(Value::is_string) - && added_lines.len() == added_types.len() - && attachment - .get("removedTypes") - .and_then(Value::as_array) - .is_some_and(Vec::is_empty) - } - Some("skill_listing") => { - let Some(names) = attachment.get("names").and_then(Value::as_array) else { - return false; - }; - attachment.get("isInitial").and_then(Value::as_bool) == Some(true) - && attachment.get("content").and_then(Value::as_str).is_some() - && names.iter().all(Value::is_string) - && attachment.get("skillCount").and_then(Value::as_u64) == Some(names.len() as u64) - } - _ => false, - } -} - -fn resolve_claude_parent( - mut parent: String, - removed_parent_by_uuid: &BTreeMap, -) -> Result { - let mut seen = BTreeSet::new(); - while let Some(next) = removed_parent_by_uuid.get(&parent) { - if !seen.insert(parent.clone()) { - return Err(ReplayError::continuation( - "cycle in Claude resume transport parent chain", - )); - } - parent = next.clone(); - } - Ok(parent) -} - -fn value_contains(value: &Value, needle: &str) -> bool { - match value { - Value::String(value) => value.contains(needle), - Value::Array(values) => values.iter().any(|value| value_contains(value, needle)), - Value::Object(values) => values.values().any(|value| value_contains(value, needle)), - _ => false, - } -} - -fn run_mini( - plan: &ReplayPlan, - context: &RunContext<'_>, - journal: &mut Journal, -) -> Result { - let boundary = plan.batches.last().unwrap().native["message_index"] - .as_u64() - .unwrap() as usize; - let mut prepared = plan.native.clone(); - prepared["messages"] = - Value::Array(plan.native["messages"].as_array().unwrap()[..=boundary].to_vec()); - let path = context.output_dir.join("native/prepared-prefix.json"); - atomic_write_json(&path, &prepared)?; - journal.append( - "session_rebuilt", - [("prepared_only".into(), json!(context.request.replay_only))], - )?; - if context.request.replay_only { - return Ok(prepared_outcome(path)); - } - run_sdk_bridge(plan, context, journal, AgentKind::MiniSweAgent, &path) -} - -fn run_swe( - plan: &ReplayPlan, - context: &RunContext<'_>, - journal: &mut Journal, -) -> Result { - let mut prepared = plan.native.clone(); - prepared["trajectory"] = - Value::Array(plan.native["trajectory"].as_array().unwrap()[..plan.after_step].to_vec()); - let mut assistant = 0; - let mut history = Vec::new(); - for item in plan.native["history"].as_array().unwrap() { - history.push(item.clone()); - if item.get("role").and_then(Value::as_str) == Some("assistant") { - assistant += 1; - if assistant == plan.after_step { - break; - } - } - } - prepared["history"] = Value::Array(history); - let path = context.output_dir.join("native/prepared-prefix.traj"); - atomic_write_json(&path, &prepared)?; - journal.append( - "session_rebuilt", - [("prepared_only".into(), json!(context.request.replay_only))], - )?; - if context.request.replay_only { - return Ok(prepared_outcome(path)); - } - run_sdk_bridge(plan, context, journal, AgentKind::SweAgent, &path) -} - -fn prepared_outcome(path: PathBuf) -> ReplayOutcome { - ReplayOutcome { - status: "prepared".into(), - reconstructed_path: Some(path), - continued_path: None, - observations: Vec::new(), - continued_steps: 0, - metadata: json!({"replay_only_execution": false}), - } -} - -fn run_sdk_bridge( - plan: &ReplayPlan, - context: &RunContext<'_>, - journal: &mut Journal, - agent: AgentKind, - prepared: &Path, -) -> Result { - let launch = context - .launch - .ok_or_else(|| ReplayError::continuation("SDK continuation has no launch spec"))?; - if context - .request - .max_steps - .is_some_and(|max| max <= plan.prefix_model_turns) - { - return Err(ReplayError::continuation( - "max-steps is exhausted by the replay prefix", - )); - } - let native_dir = context.output_dir.join("native"); - let logs_dir = context.output_dir.join("logs"); - fs::create_dir_all(&native_dir) - .replay_context(ReplayErrorKind::Executor, "create native output directory")?; - fs::create_dir_all(&logs_dir) - .replay_context(ReplayErrorKind::Executor, "create Agent log directory")?; - - let (program, bridge_source, bridge_name, request_value, continued, observations_path) = - match agent { - AgentKind::MiniSweAgent => { - let source = context.state_dir.join("mini-source.json"); - let continued = native_dir.join("continued-trajectory.json"); - let observations = context.state_dir.join("mini-fresh-observations.json"); - atomic_write_json(&source, &plan.native)?; - let runtime = mini_python_runtime(&launch.entrypoint)?; - let program = runtime - .loader - .clone() - .unwrap_or_else(|| runtime.python.clone()); - ( - program, - include_str!("../assets/mini_swe_agent_runner.py"), - "mini-swe-agent-runner.py", - json!({ - "source": source, - "continued": continued, - "observations": observations, - "workspace": context.request.workspace, - "after_step": plan.after_step, - "max_steps": context.request.max_steps, - "session_id": context.session_id, - }), - continued, - Some(observations), - ) - } - AgentKind::SweAgent => { - let source = native_dir.join("continuation-source.traj"); - let run_output = native_dir.join("swe-agent-run"); - let continued = native_dir.join("continued-trajectory.traj"); - atomic_write_json(&source, &plan.native)?; - ( - launch.entrypoint.clone(), - include_str!("../assets/swe_agent_runner.py"), - "swe-agent-runner.py", - json!({ - "trajectory": source, - "trajectory_assets": context.request.trajectory_assets, - "after_step": plan.after_step, - "workspace": context.request.workspace, - "output_dir": run_output, - }), - continued, - None, - ) - } - _ => { - return Err(ReplayError::new( - ReplayErrorKind::Internal, - "SDK bridge selected for a non-SDK agent", - )); - } - }; - - let bridge = context.state_dir.join(bridge_name); - let request_path = context - .state_dir - .join(format!("{}-request.json", agent.as_str())); - atomic_write(&bridge, bridge_source.as_bytes())?; - atomic_write_json(&request_path, &request_value)?; - let mut command = agent_command(&program, context); - if agent == AgentKind::MiniSweAgent { - let runtime = mini_python_runtime(&launch.entrypoint)?; - if runtime.loader.is_some() { - let library_path = mini_python_library_path(&runtime)?.ok_or_else(|| { - ReplayError::continuation("bundled mini-swe-agent Python has no library path") - })?; - let argv0 = runtime - .virtual_env - .as_deref() - .map(|venv| venv.join("bin/python")) - .unwrap_or_else(|| runtime.python.clone()); - command - .arg("--argv0") - .arg(argv0) - .arg("--library-path") - .arg(library_path) - .arg(&runtime.python); - } - configure_mini_python_environment(&mut command, &runtime)?; - command.env("MSWEA_CONFIGURED", "true"); - command.env("MSWEA_COST_TRACKING", "ignore_errors"); - command.env("SWE_EVAL_MINI_RUNTIME", "1"); - } - command.arg(&bridge).arg(&request_path); - command.stdout(Stdio::piped()).stderr(Stdio::piped()); - journal.append("continuation_started", std::iter::empty())?; - let output = command.output().replay_context( - ReplayErrorKind::Continuation, - format!("start {} replay bridge", agent.as_str()), - )?; - let log = logs_dir.join(format!("{}.log", agent.as_str())); - write_process_log(&log, &output)?; - if !output.status.success() { - let rendered = render_output(&output); - return Err(ReplayError::classify_continuation( - format!( - "{} replay/continuation exited {}; see {}", - agent.as_str(), - output.status, - log.display() - ), - &rendered, - )); - } - - let (observations, continued_steps) = if agent == AgentKind::MiniSweAgent { - if !continued.is_file() { - return Err(ReplayError::continuation(format!( - "mini-swe-agent produced no continued trajectory; see {}", - log.display() - ))); - } - let raw_observations: Vec = serde_json::from_slice(&read_regular_file( - observations_path.as_ref().expect("mini observations path"), - )?) - .replay_context( - ReplayErrorKind::Trajectory, - "parse mini-swe-agent fresh observations", - )?; - if raw_observations.len() != plan.calls().count() { - return Err(ReplayError::trajectory( - "mini-swe-agent output lost replayed observations", - )); - } - let observations = plan - .calls() - .zip(raw_observations) - .map(|(call, value)| FreshObservation { - call_id: call.call_id.clone(), - content: value.get("content").cloned().unwrap_or(Value::Null), - is_error: value - .get("is_error") - .and_then(Value::as_bool) - .unwrap_or(false), - return_code: value - .get("return_code") - .and_then(Value::as_i64) - .map(|code| code as i32), - duration_ms: value - .get("duration_ms") - .and_then(Value::as_u64) - .unwrap_or_default() as u128, - truncated: false, - metadata: BTreeMap::new(), - }) - .collect::>(); - let continued_value: Value = serde_json::from_slice(&read_regular_file(&continued)?) - .replay_context( - ReplayErrorKind::Trajectory, - "parse continued mini-swe-agent trajectory", - )?; - let action_count = continued_value["messages"] - .as_array() - .map(|messages| { - messages - .iter() - .filter(|message| { - message - .get("extra") - .and_then(|extra| extra.get("actions")) - .and_then(Value::as_array) - .is_some_and(|actions| !actions.is_empty()) - }) - .count() - }) - .unwrap_or_default(); - (observations, action_count.saturating_sub(plan.after_step)) - } else { - let run_output = request_value["output_dir"] - .as_str() - .map(PathBuf::from) - .ok_or_else(|| { - ReplayError::new(ReplayErrorKind::Internal, "SWE-agent output missing") - })?; - let mut candidates = Vec::new(); - collect_extension(&run_output, "traj", &mut candidates)?; - if candidates.len() != 1 { - return Err(ReplayError::continuation(format!( - "SWE-agent continuation produced {} trajectory files", - candidates.len() - ))); - } - atomic_write(&continued, &read_regular_file(&candidates[0])?)?; - let replayed: Value = serde_json::from_slice(&read_regular_file(&continued)?) - .replay_context( - ReplayErrorKind::Trajectory, - "parse continued SWE-agent trajectory", - )?; - let steps = replayed["trajectory"] - .as_array() - .ok_or_else(|| ReplayError::trajectory("continued SWE-agent trajectory is invalid"))?; - if steps.len() < plan.after_step { - return Err(ReplayError::trajectory( - "SWE-agent output lost replayed steps", - )); - } - let observations = plan - .calls() - .zip(steps.iter()) - .map(|(call, step)| FreshObservation { - call_id: call.call_id.clone(), - content: step.get("observation").cloned().unwrap_or(Value::Null), - is_error: false, - return_code: None, - duration_ms: 0, - truncated: false, - metadata: BTreeMap::new(), - }) - .collect::>(); - let continued_steps = steps[plan.after_step..] - .iter() - .filter(|step| { - step.get("action") - .and_then(Value::as_str) - .is_some_and(|action| !action.trim().is_empty()) - }) - .count(); - (observations, continued_steps) - }; - if continued_steps == 0 { - return Err(ReplayError::continuation(format!( - "{} produced no actionable continuation step; see {}", - agent.as_str(), - log.display() - ))); - } - let comparisons: Vec<_> = plan - .calls() - .zip(&observations) - .map(|(call, fresh)| { - json!({ - "call_id": call.call_id, - "tool": call.name, - "exact": call.original_observation == fresh.content - && call.original_is_error == fresh.is_error, - "original_is_error": call.original_is_error, - "replayed_is_error": fresh.is_error, - }) - }) - .collect(); - atomic_write_json( - &context.output_dir.join("observation-comparison.json"), - &comparisons, - )?; - journal.append( - "continuation_finished", - [ - ("return_code".into(), json!(output.status.code())), - ("continued_steps".into(), json!(continued_steps)), - ], - )?; - Ok(ReplayOutcome { - status: "completed".into(), - reconstructed_path: Some(prepared.to_path_buf()), - continued_path: Some(continued), - observations, - continued_steps, - metadata: json!({"sdk_bridge": bridge_name}), - }) -} - -#[derive(Debug)] -struct MiniPythonRuntime { - python: PathBuf, - loader: Option, - python_home: Option, - virtual_env: Option, - library_paths: Vec, -} - -fn mini_python_runtime(entrypoint: &Path) -> Result { - if let Some(local_root) = entrypoint.parent().and_then(Path::parent) { - let uv_root = local_root.join("share/uv"); - let virtual_env = uv_root.join("tools/mini-swe-agent"); - let python = virtual_env.join("bin/python"); - if python.is_file() { - let python = fs::canonicalize(&python).replay_context( - ReplayErrorKind::Continuation, - format!( - "resolve bundled mini-swe-agent Python from {}", - python.display() - ), - )?; - let python_home = python - .parent() - .and_then(Path::parent) - .ok_or_else(|| ReplayError::continuation("bundled Python has no prefix"))? - .to_path_buf(); - if !python_home.join("lib/python3.12/encodings").is_dir() { - return Err(ReplayError::continuation(format!( - "bundled mini-swe-agent Python has no standard library below {}", - python_home.display() - ))); - } - let loader = uv_root.join("sweeval-system-libs/ld-linux-x86-64.so.2"); - if !loader.is_file() { - return Err(ReplayError::continuation(format!( - "bundled mini-swe-agent Python loader does not exist: {}", - loader.display() - ))); - } - return Ok(MiniPythonRuntime { - python, - loader: Some(loader), - python_home: Some(python_home.clone()), - virtual_env: Some(virtual_env), - library_paths: vec![uv_root.join("sweeval-system-libs"), python_home.join("lib")], - }); - } - } - - let prefix = read_regular_file(entrypoint)?; - if let Some(first) = prefix.split(|byte| *byte == b'\n').next() { - if let Some(shebang) = first.strip_prefix(b"#!") { - let rendered = String::from_utf8_lossy(shebang); - let words: Vec<_> = rendered.split_whitespace().collect(); - if words.first() == Some(&"/usr/bin/env") { - if let Some(program) = words.get(1) { - if program.contains("python") { - return Ok(MiniPythonRuntime { - python: PathBuf::from(program), - loader: None, - python_home: None, - virtual_env: None, - library_paths: Vec::new(), - }); - } - } - } else if let Some(program) = words.first() { - if program.contains("python") { - return Ok(MiniPythonRuntime { - python: PathBuf::from(program), - loader: None, - python_home: None, - virtual_env: None, - library_paths: Vec::new(), - }); - } - } - } + { + return Err(ReplayError::continuation( + "Claude native queue resume envelope is malformed", + )); } - for name in ["python3", "python"] { - let candidate = entrypoint.parent().unwrap_or(Path::new("/")).join(name); - if candidate.is_file() { - return Ok(MiniPythonRuntime { - python: candidate, - loader: None, - python_home: None, - virtual_env: None, - library_paths: Vec::new(), - }); - } + if continue_event.get("type").and_then(Value::as_str) != Some("user") + || continue_event.get("isMeta").and_then(Value::as_bool) != Some(true) + || exact_claude_event_text(continue_event) != Some("Continue from where you left off.") + || no_response_event.get("type").and_then(Value::as_str) != Some("assistant") + || exact_claude_event_text(no_response_event) != Some("No response requested.") + || nonce_event.get("type").and_then(Value::as_str) != Some("user") + || exact_claude_event_text(nonce_event) != Some(nonce) + { + return Err(ReplayError::continuation( + "Claude native resume message envelope is malformed", + )); } - Err(ReplayError::continuation( - "mini-swe-agent entrypoint does not expose its Python interpreter", - )) -} - -fn mini_python_library_path(runtime: &MiniPythonRuntime) -> Result, ReplayError> { - let paths = runtime - .library_paths - .iter() - .filter(|path| path.is_dir()) - .collect::>(); - if paths.is_empty() { - return Ok(None); + let continue_uuid = required_event_uuid(continue_event, "continue")?; + let no_response_uuid = required_event_uuid(no_response_event, "no-response")?; + let nonce_uuid = required_event_uuid(nonce_event, "nonce")?; + if continue_event.get("parentUuid").and_then(Value::as_str) != Some(&boundary_uuid) + || no_response_event.get("parentUuid").and_then(Value::as_str) != Some(&continue_uuid) + || nonce_event.get("parentUuid").and_then(Value::as_str) != Some(&no_response_uuid) + { + return Err(ReplayError::continuation( + "Claude resume envelope is not attached directly to the boundary observation", + )); } - std::env::join_paths(paths).map(Some).map_err(|error| { - ReplayError::configuration(format!( - "cannot construct mini-swe-agent Python library path: {error}" - )) - }) -} -fn configure_mini_python_environment( - command: &mut Command, - runtime: &MiniPythonRuntime, -) -> Result<(), ReplayError> { - if let Some(python_home) = &runtime.python_home { - command.env("PYTHONHOME", python_home); - } - if let Some(virtual_env) = &runtime.virtual_env { - command.env("VIRTUAL_ENV", virtual_env); - command.env( - "PYTHONPATH", - virtual_env.join("lib/python3.12/site-packages"), - ); - let current = std::env::var_os("PATH").unwrap_or_else(|| "/usr/bin:/bin".into()); - let paths = std::iter::once(virtual_env.join("bin")).chain(std::env::split_paths(¤t)); - let path = std::env::join_paths(paths).map_err(|error| { - ReplayError::configuration(format!( - "cannot prepend mini-swe-agent virtual environment to PATH: {error}" - )) - })?; - command.env("PATH", path); - } - if let Some(library_path) = mini_python_library_path(runtime)? { - command.env("LD_LIBRARY_PATH", library_path); + let mut remove_indexes: BTreeSet = (boundary_index + 1..boundary_index + 6).collect(); + let mut removed_parent_by_uuid = BTreeMap::from([ + (continue_uuid.clone(), boundary_uuid.clone()), + (no_response_uuid.clone(), continue_uuid), + (nonce_uuid.clone(), no_response_uuid), + ]); + let last_prompt_indexes: Vec<_> = events + .iter() + .enumerate() + .filter_map(|(index, event)| { + (event.get("type").and_then(Value::as_str) == Some("last-prompt") + && event.get("lastPrompt").and_then(Value::as_str) == Some(nonce) + && event.get("sessionId").and_then(Value::as_str) == Some(session_id)) + .then_some(index) + }) + .collect(); + if last_prompt_indexes.is_empty() { + return Err(ReplayError::continuation( + "resumed Claude session has no nonce last-prompt metadata", + )); } - Ok(()) -} + remove_indexes.extend(last_prompt_indexes); -fn collect_extension( - root: &Path, - extension: &str, - output: &mut Vec, -) -> Result<(), ReplayError> { - if !root.exists() { - return Ok(()); - } - if root.is_file() { - if root.extension().and_then(|value| value.to_str()) == Some(extension) { - output.push(root.to_path_buf()); + let mut attachment_parent = nonce_uuid; + let mut previous_attachment_order = -1_i8; + let mut seen_attachment_types = BTreeSet::new(); + loop { + let matching: Vec<_> = events + .iter() + .enumerate() + .filter(|(index, event)| { + !remove_indexes.contains(index) + && event.get("type").and_then(Value::as_str) == Some("attachment") + && event.get("parentUuid").and_then(Value::as_str) + == Some(attachment_parent.as_str()) + }) + .collect(); + if matching.is_empty() { + break; } - return Ok(()); - } - for entry in fs::read_dir(root).replay_context( - ReplayErrorKind::Continuation, - format!("scan Agent output {}", root.display()), - )? { - let path = entry - .replay_context(ReplayErrorKind::Continuation, "read Agent output entry")? - .path(); - collect_extension(&path, extension, output)?; + if matching.len() != 1 { + return Err(ReplayError::continuation( + "Claude resume attachment branch is ambiguous", + )); + } + let (index, event) = matching[0]; + let attachment = event.get("attachment").unwrap_or(&Value::Null); + let attachment_type = attachment + .get("type") + .and_then(Value::as_str) + .unwrap_or_default(); + let attachment_order = match attachment_type { + "agent_listing_delta" => 0, + "skill_listing" => 1, + "task_reminder" => 2, + _ => -1, + }; + let event_uuid = required_event_uuid(event, "resume attachment")?; + if !valid_claude_resume_attachment(attachment) + || !seen_attachment_types.insert(attachment_type.to_owned()) + || attachment_order <= previous_attachment_order + { + return Err(ReplayError::continuation( + "unexpected attachment in Claude resume envelope", + )); + } + previous_attachment_order = attachment_order; + remove_indexes.insert(index); + removed_parent_by_uuid.insert(event_uuid.clone(), attachment_parent); + attachment_parent = event_uuid; } - Ok(()) -} -fn run_openhands( - plan: &ReplayPlan, - context: &RunContext<'_>, - journal: &mut Journal, -) -> Result { - let events = plan.native["events"].as_array().unwrap(); - let initial = plan.native["initial_user_event"].clone(); - let boundary_id = plan.batches.last().unwrap().tool_calls[0] - .call_id - .parse::() - .unwrap(); - let mut prepared_events = vec![initial.clone()]; - for event in events { - if event_id(event)? > boundary_id { - break; - } - if event == &initial || event.get("action").and_then(Value::as_str) == Some("system") { + let mut cleaned_events = Vec::with_capacity(events.len() - remove_indexes.len()); + let mut first_real_assistant: Option = None; + for (index, event) in events.into_iter().enumerate() { + if remove_indexes.contains(&index) { continue; } - if event.get("action").is_some() && !event["action"].is_null() { - let mut reconstructed = event.clone(); - if reconstructed.get("source").and_then(Value::as_str) == Some("agent") - && matches!( - reconstructed.get("action").and_then(Value::as_str), - Some("run" | "read" | "edit" | "run_ipython" | "think") - ) - && reconstructed - .get("tool_call_metadata") - .is_none_or(Value::is_null) - { - reconstructed["tool_call_metadata"] = - openhands_reconstructed_tool_metadata(&reconstructed)?; - } - prepared_events.push(reconstructed); + let mut updated = event; + if let Some(parent) = updated + .get("parentUuid") + .and_then(Value::as_str) + .map(str::to_owned) + { + let resolved = resolve_claude_parent(parent, &removed_parent_by_uuid)?; + updated["parentUuid"] = Value::String(resolved); } + if index > boundary_index + && first_real_assistant.is_none() + && updated.get("type").and_then(Value::as_str) == Some("assistant") + && updated.get("isSidechain").and_then(Value::as_bool) != Some(true) + { + first_real_assistant = Some(updated.clone()); + } + cleaned_events.push(updated); } - let prepared = context - .output_dir - .join("native/prepared-replay-events.json"); - atomic_write_json(&prepared, &prepared_events)?; - journal.append( - "session_rebuilt", - [("prepared_only".into(), json!(context.request.replay_only))], - )?; - if context.request.replay_only { - return Ok(prepared_outcome(prepared)); - } - let launch = context - .launch - .ok_or_else(|| ReplayError::continuation("OpenHands continuation has no launch spec"))?; - if context - .request - .max_steps - .is_some_and(|max| max <= plan.prefix_model_turns) + let first_real_assistant = first_real_assistant + .ok_or_else(|| ReplayError::continuation("Claude produced no real continuation turn"))?; + if first_real_assistant + .get("parentUuid") + .and_then(Value::as_str) + != Some(boundary_uuid.as_str()) { return Err(ReplayError::continuation( - "max-steps is exhausted by the replay prefix", + "first real resumed Claude assistant is not a child of the boundary observation", )); } - let continued = context.output_dir.join("native/continued-trajectory.json"); - let mut command = agent_command(&launch.entrypoint, context); - command.args(["-m", "openhands.core.main"]); - command.env("REPLAY_TRAJECTORY_PATH", &prepared); - command.env("SAVE_TRAJECTORY_PATH", &continued); - command.env("FILE_STORE", "local"); - command.env( - "FILE_STORE_PATH", - context.state_dir.join("openhands-file-store"), - ); - command.env("RUNTIME", "local"); - command.env("SU_TO_USER", "false"); - command.env("RUN_AS_OPENHANDS", "false"); - command.env("SKIP_DEPENDENCY_CHECK", "1"); - command.env("INIT_PLUGIN_TIMEOUT", "240"); - command.env("AGENT_ENABLE_PROMPT_EXTENSIONS", "false"); - command.env("AGENT_ENABLE_BROWSING", "false"); - command.env("ENABLE_BROWSER", "false"); - command.env("SANDBOX_ENABLE_AUTO_LINT", "true"); - command.env( - "SANDBOX_VOLUMES", - format!("{}:/workspace:rw", context.request.workspace.display()), - ); - prepend_openhands_runtime_tools(&mut command, launch)?; - command.env( - "OPENAI_CUSTOM_HEADERS", - format!("X-LiteLLM-Session-ID: {}", context.session_id), - ); - if let Some(max) = context.request.max_steps { - command.env("MAX_ITERATIONS", (max + 1).to_string()); + for forbidden in [ + nonce, + "Continue from where you left off.", + "No response requested.", + ] { + if cleaned_events + .iter() + .any(|event| value_contains(event, forbidden)) + { + return Err(ReplayError::continuation( + "Claude resume transport text remains after native-session cleanup", + )); + } } - command - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - journal.append("continuation_started", std::iter::empty())?; - let mut child = command.spawn().replay_context( - ReplayErrorKind::Continuation, - "start OpenHands replay/continuation", - )?; - child - .stdin - .take() - .ok_or_else(|| ReplayError::continuation("OpenHands stdin unavailable"))? - .write_all(b"\n") - .replay_context(ReplayErrorKind::Continuation, "write OpenHands stdin")?; - let output = child - .wait_with_output() - .replay_context(ReplayErrorKind::Continuation, "wait for OpenHands")?; - let log = context.output_dir.join("logs/openhands.log"); - write_process_log(&log, &output)?; - let rendered = render_output(&output); - let fatal_marker = openhands_fatal_controller_marker(&rendered); - if !output.status.success() || !continued.is_file() { - let detail = fatal_marker - .map(|marker| format!("; OpenHands controller reported {marker:?}")) - .unwrap_or_default(); - return Err(ReplayError::classify_continuation( - format!( - "OpenHands replay/continuation exited {}{detail}; see {}", - output.status, - log.display() - ), - &rendered, + let cleaned_boundary_index = cleaned_events + .iter() + .position(|event| event.get("uuid").and_then(Value::as_str) == Some(&boundary_uuid)) + .ok_or_else(|| ReplayError::continuation("cleaned Claude session lost its boundary"))?; + let continued_steps = cleaned_events + .iter() + .skip(cleaned_boundary_index + 1) + .filter(|event| { + main_event(event) + && event.get("type").and_then(Value::as_str) == Some("assistant") + && event + .get("message") + .and_then(|message| message.get("stop_reason")) + .is_some_and(|reason| !reason.is_null()) + }) + .count(); + if continued_steps == 0 { + return Err(ReplayError::continuation( + "cleaned Claude session has no complete continuation turn", )); } - let continued_events: Vec = serde_json::from_slice(&read_regular_file(&continued)?) + let mut output = cleaned_events + .iter() + .map(serde_json::to_string) + .collect::, _>>() .replay_context( - ReplayErrorKind::Trajectory, - "parse continued OpenHands trajectory", - )?; - let complete = openhands_complete_batches(&continued_events)?; - if complete.len() < plan.after_step { - return Err(ReplayError::trajectory( - "OpenHands output lost replayed action/observation batches", - )); - } - let replayed = &complete[..plan.after_step]; - let observations = plan - .calls() - .zip(replayed.iter()) - .map(|(call, (_, observation))| FreshObservation { - call_id: call.call_id.clone(), - content: openhands_observation_content(observation), - is_error: observation.get("observation").and_then(Value::as_str) == Some("error"), - return_code: None, - duration_ms: 0, - truncated: false, - metadata: BTreeMap::new(), - }) - .collect::>(); - let comparisons = plan - .calls() - .zip(&observations) - .map(|(call, fresh)| { - json!({ - "call_id": call.call_id, - "tool": call.name, - "exact": call.original_observation == fresh.content - && call.original_is_error == fresh.is_error, - "original_is_error": call.original_is_error, - "replayed_is_error": fresh.is_error, - }) - }) - .collect::>(); - atomic_write_json( - &context.output_dir.join("observation-comparison.json"), - &comparisons, - )?; - let continued_steps = complete.len() - plan.after_step; - journal.append( - "continuation_finished", - [ - ("continued_steps".into(), json!(continued_steps)), - ("agent_error".into(), json!(fatal_marker)), - ], - )?; - Ok(ReplayOutcome { - status: "completed".into(), - reconstructed_path: Some(prepared), - continued_path: Some(continued), - observations, - continued_steps, - metadata: fatal_marker - .map(|marker| { - json!({ - "agent_terminal_status": "error", - "agent_error": marker, - }) - }) - .unwrap_or_else(|| json!({})), - }) + ReplayErrorKind::Internal, + "serialize cleaned Claude session", + )? + .join("\n"); + output.push('\n'); + Ok((output, continued_steps)) } -fn openhands_fatal_controller_marker(output: &str) -> Option<&'static str> { - if output.contains("Agent reached maximum iteration") { - return None; +fn exact_claude_event_text(event: &Value) -> Option<&str> { + let content = event.get("message")?.get("content")?; + if let Some(text) = content.as_str() { + return Some(text); + } + let blocks = content.as_array()?; + if blocks.len() == 1 && blocks[0].get("type").and_then(Value::as_str) == Some("text") { + return blocks[0].get("text").and_then(Value::as_str); } - [ - "AgentState.ERROR", - "Error while running the agent", - "There was an unexpected error while running the agent", - ] - .into_iter() - .find(|marker| output.contains(marker)) + None } -fn openhands_observation_content(observation: &Value) -> Value { - json!({ - "observation": observation.get("observation"), - "message": observation.get("message"), - "args": observation.get("args"), - }) +fn required_event_uuid(event: &Value, context: &str) -> Result { + event + .get("uuid") + .and_then(Value::as_str) + .filter(|uuid| !uuid.is_empty()) + .map(str::to_owned) + .ok_or_else(|| ReplayError::continuation(format!("Claude {context} event lacks a UUID"))) } -fn openhands_complete_batches(events: &[Value]) -> Result, ReplayError> { - let mut observations = BTreeMap::new(); - for event in events { - let Some(cause) = event - .get("observation") - .filter(|value| !value.is_null()) - .and_then(|_| event.get("cause")) - .and_then(Value::as_i64) - else { - continue; - }; - if observations.insert(cause, event).is_some() { - return Err(ReplayError::trajectory(format!( - "multiple OpenHands observations for action {cause}" - ))); +fn valid_claude_resume_attachment(attachment: &Value) -> bool { + match attachment.get("type").and_then(Value::as_str) { + Some("task_reminder") => { + attachment + .get("content") + .and_then(Value::as_array) + .is_some_and(Vec::is_empty) + && attachment.get("itemCount").and_then(Value::as_u64) == Some(0) } - } - - let supported = ["run", "read", "edit", "run_ipython", "think"]; - let mut batches = Vec::new(); - for event in events { - let action = event.get("action").and_then(Value::as_str); - if event.get("source").and_then(Value::as_str) != Some("agent") - || matches!(action, None | Some("system" | "finish" | "message")) - { - continue; + Some("agent_listing_delta") => { + let Some(added_lines) = attachment.get("addedLines").and_then(Value::as_array) else { + return false; + }; + let Some(added_types) = attachment.get("addedTypes").and_then(Value::as_array) else { + return false; + }; + attachment.get("isInitial").and_then(Value::as_bool) == Some(true) + && attachment + .get("showConcurrencyNote") + .and_then(Value::as_bool) + .is_some() + && added_lines.iter().all(Value::is_string) + && added_types.iter().all(Value::is_string) + && added_lines.len() == added_types.len() + && attachment + .get("removedTypes") + .and_then(Value::as_array) + .is_some_and(Vec::is_empty) } - let action = action.unwrap(); - if !supported.contains(&action) { - return Err(ReplayError::new( - ReplayErrorKind::UnsupportedVersion, - format!("unsupported OpenHands action {action:?}"), - )); + Some("skill_listing") => { + let Some(names) = attachment.get("names").and_then(Value::as_array) else { + return false; + }; + attachment.get("isInitial").and_then(Value::as_bool) == Some(true) + && attachment.get("content").and_then(Value::as_str).is_some() + && names.iter().all(Value::is_string) + && attachment.get("skillCount").and_then(Value::as_u64) == Some(names.len() as u64) } - let id = event_id(event)?; - let Some(observation) = observations.get(&id) else { - break; - }; - batches.push((event, *observation)); - } - Ok(batches) -} - -fn agent_command(entrypoint: &Path, context: &RunContext<'_>) -> Command { - let mut command = Command::new(entrypoint); - command.current_dir(&context.request.workspace); - sanitized_environment(&mut command, context.request.agent == AgentKind::ClaudeCode); - if context.request.agent != AgentKind::ClaudeCode { - command.env("X_LITELLM_SESSION_ID", context.session_id); - command.env( - "LITELLM_EXTRA_HEADERS", - json!({"X-LiteLLM-Session-ID": context.session_id}).to_string(), - ); - } - command -} - -fn prepend_openhands_runtime_tools( - command: &mut Command, - launch: &LaunchSpec, -) -> Result<(), ReplayError> { - let inferred_root = launch - .entrypoint - .parent() - .and_then(Path::parent) - .unwrap_or_else(|| Path::new("/")); - let tools = launch - .runtime_root - .as_deref() - .unwrap_or(inferred_root) - .join("tools"); - if !tools.is_dir() { - return Ok(()); + _ => false, } - let current = std::env::var_os("PATH").unwrap_or_else(|| "/usr/bin:/bin".into()); - let paths = std::iter::once(tools.clone()).chain(std::env::split_paths(¤t)); - let path = std::env::join_paths(paths).map_err(|error| { - ReplayError::configuration(format!( - "cannot prepend OpenHands runtime tools {} to PATH: {error}", - tools.display() - )) - })?; - command.env("PATH", path); - Ok(()) } -fn sanitized_environment(command: &mut Command, strip_credentials: bool) { - command.env_clear(); - for (name, value) in std::env::vars_os() { - let rendered = name.to_string_lossy().to_ascii_uppercase(); - if !environment_name_allowed(&rendered, strip_credentials) { - continue; +fn resolve_claude_parent( + mut parent: String, + removed_parent_by_uuid: &BTreeMap, +) -> Result { + let mut seen = BTreeSet::new(); + while let Some(next) = removed_parent_by_uuid.get(&parent) { + if !seen.insert(parent.clone()) { + return Err(ReplayError::continuation( + "cycle in Claude resume transport parent chain", + )); } - command.env(name, value); + parent = next.clone(); } + Ok(parent) } -fn environment_name_allowed(rendered: &str, strip_credentials: bool) -> bool { - let credential = ["API_KEY", "TOKEN", "SECRET", "AUTHORIZATION", "PASSWORD"] - .iter() - .any(|fragment| rendered.contains(fragment)); - let claude_provider_override = strip_credentials - && matches!( - rendered, - "CLAUDE_CODE_USE_BEDROCK" | "CLAUDE_CODE_USE_VERTEX" | "CLAUDE_CODE_USE_FOUNDRY" - ); - !(strip_credentials && credential) - && !claude_provider_override - && !matches!(rendered, "PYTHONHOME" | "PYTHONPATH" | "VIRTUAL_ENV") -} - -fn write_process_log(path: &Path, output: &Output) -> Result<(), ReplayError> { - let mut bytes = output.stdout.clone(); - if !output.stderr.is_empty() { - if !bytes.ends_with(b"\n") { - bytes.push(b'\n'); - } - bytes.extend_from_slice(&output.stderr); +fn value_contains(value: &Value, needle: &str) -> bool { + match value { + Value::String(value) => value.contains(needle), + Value::Array(values) => values.iter().any(|value| value_contains(value, needle)), + Value::Object(values) => values.values().any(|value| value_contains(value, needle)), + _ => false, } - atomic_write(path, &bytes) -} - -fn render_output(output: &Output) -> String { - format!( - "{}\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ) } - fn expected_claude_max_turn_exit(stdout: &[u8], max_turns: Option) -> bool { let Some(max_turns) = max_turns else { return false; @@ -3801,7 +2012,25 @@ fn expected_claude_max_turn_exit(stdout: &[u8], max_turns: Option) -> boo #[cfg(test)] mod tests { - use super::*; + use std::collections::BTreeMap; + use std::fs; + use std::path::PathBuf; + use std::time::{Duration, Instant}; + + use serde_json::{json, Value}; + + use super::{ + claude_boundary_tool_use_ids, claude_canonical_messages, execute_claude_tool_with_policy, + expected_claude_max_turn_exit, rebuild_claude, run_bash, validate_claude_tool_policy, + wildcard_match, + }; + use crate::adapter::{build_plan, run, RunContext}; + use crate::claude_resume::ResumeTransportManifest; + use crate::journal::Journal; + use crate::model::{ + AdapterPlan, AgentKind, FreshObservation, PlaybackRequest, ReplayMode, ToolCall, + }; + fn claude_tool_call(name: &str, arguments: Value) -> ToolCall { ToolCall { ordinal: 1, @@ -3858,12 +2087,13 @@ mod tests { call.original_observation = json!("file_path is missing"); call.original_is_error = true; - let observation = execute_claude_tool(&call, workspace.path()).unwrap(); + let observation = + execute_claude_tool_with_policy(&call, workspace.path(), true, None).unwrap(); assert!(observation.is_error); assert_eq!(observation.content, call.original_observation); assert_eq!( observation.metadata.get("opaque_source_observation"), - Some(&json!("input_validation_error")) + Some(&json!("stale_source_observation")) ); } @@ -3883,12 +2113,13 @@ mod tests { "text": "Async agent launched successfully.", }]); - let observation = execute_claude_tool(&call, workspace.path()).unwrap(); + let observation = + execute_claude_tool_with_policy(&call, workspace.path(), true, None).unwrap(); assert!(!observation.is_error); assert_eq!(observation.content, call.original_observation); assert_eq!( observation.metadata.get("opaque_source_observation"), - Some(&json!("read_only_explore_agent")) + Some(&json!("stale_source_observation")) ); } @@ -3908,13 +2139,138 @@ mod tests { "text": "Explore agent result", }]); - let observation = execute_claude_tool(&call, workspace.path()).unwrap(); + let observation = + execute_claude_tool_with_policy(&call, workspace.path(), true, None).unwrap(); assert!(!observation.is_error); assert_eq!(observation.content, call.original_observation); assert_eq!( observation.metadata.get("opaque_source_observation"), - Some(&json!("read_only_task_output")) + Some(&json!("stale_source_observation")) + ); + } + + #[test] + fn stale_observations_fail_closed_by_default() { + for call in [ + claude_tool_call( + "Agent", + json!({ + "description": "Inspect code", + "prompt": "Find files", + "subagent_type": "Explore", + }), + ), + claude_tool_call("TaskOutput", json!({"task_id": "task-1"})), + claude_tool_call("TaskCreate", json!({"subject": "work"})), + claude_tool_call("TodoWrite", json!({"todos": []})), + ] { + let error = validate_claude_tool_policy(&call, false).unwrap_err(); + assert!(error.to_string().contains("--allow-stale-observations")); + } + + let find = claude_tool_call("Find", json!({"pattern": "*.rs"})); + assert!(validate_claude_tool_policy(&find, true).is_err()); + } + + #[test] + fn stale_observations_are_explicitly_degraded() { + let workspace = tempfile::tempdir().unwrap(); + let mut call = claude_tool_call("TaskOutput", json!({"task_id": "task-1"})); + call.original_observation = json!("source observation"); + + validate_claude_tool_policy(&call, true).unwrap(); + let observation = + execute_claude_tool_with_policy(&call, workspace.path(), true, None).unwrap(); + + assert_eq!(observation.content, call.original_observation); + assert_eq!( + observation.metadata.get("degradation_reason"), + Some(&json!("stale_source_observation")) ); + assert_eq!( + observation.metadata.get("source_call_id"), + Some(&json!(call.call_id)) + ); + } + + #[test] + fn prepare_only_executes_no_historical_tool() { + let temporary = tempfile::tempdir().unwrap(); + let workspace = temporary.path().join("workspace"); + let state = temporary.path().join("state"); + let output = temporary.path().join("output"); + let trajectory = temporary.path().join("trajectory.jsonl"); + fs::create_dir_all(&workspace).unwrap(); + fs::create_dir_all(output.join("native")).unwrap(); + let marker = workspace.join("must-not-exist"); + let events = [ + json!({ + "type": "assistant", "uuid": "assistant-1", "parentUuid": null, + "sessionId": "session", "version": "2.1.220", + "message": {"id": "message-1", "stop_reason": "tool_use", "content": [{ + "type": "tool_use", "id": "tool-1", "name": "Bash", + "input": {"command": format!("touch {}", marker.display())} + }]} + }), + json!({ + "type": "user", "uuid": "result-1", "parentUuid": "assistant-1", + "sourceToolAssistantUUID": "assistant-1", "sessionId": "session", + "version": "2.1.220", "message": {"content": [{ + "type": "tool_result", "tool_use_id": "tool-1", "content": "old" + }]} + }), + json!({ + "type": "assistant", "uuid": "assistant-2", "parentUuid": "result-1", + "sessionId": "session", "version": "2.1.220", + "message": {"id": "message-2", "stop_reason": "end_turn", "content": [{ + "type": "text", "text": "next" + }]} + }), + ]; + fs::write( + &trajectory, + events + .iter() + .map(|event| serde_json::to_string(event).unwrap()) + .collect::>() + .join("\n") + + "\n", + ) + .unwrap(); + let request = PlaybackRequest { + agent: AgentKind::ClaudeCode, + trajectory, + after_step: 1, + workspace, + state_dir: state.clone(), + output_dir: output.clone(), + agent_entrypoint: None, + agent_runtime: None, + disallowed_tools: Vec::new(), + trajectory_assets: None, + session_id: None, + max_steps: None, + mode: ReplayMode::PrepareOnly, + allow_stale_observations: false, + run_id: Some("test".into()), + disable_thinking: false, + }; + let plan = build_plan(&request).unwrap(); + let mut journal = Journal::open(&state).unwrap(); + let context = RunContext { + request: &request, + state_dir: &state, + output_dir: &output, + launch: None, + session_id: "session", + nonce: "nonce", + }; + + let outcome = run(&plan, &context, &mut journal).unwrap(); + + assert_eq!(outcome.status, "prepared"); + assert!(outcome.observations.is_empty()); + assert!(!marker.exists()); } #[test] @@ -3923,9 +2279,11 @@ mod tests { fs::create_dir(workspace.path().join("directory")).unwrap(); for file_path in ["directory", "missing/nested/file.txt"] { - let observation = execute_claude_tool( + let observation = execute_claude_tool_with_policy( &claude_tool_call("Read", json!({"file_path": file_path})), workspace.path(), + false, + None, ) .unwrap(); assert!(observation.is_error); @@ -3943,9 +2301,11 @@ mod tests { let workspace = tempfile::tempdir().unwrap(); let source = workspace.path().join("source.txt"); fs::write(&source, "first\nneedle here\nlast\n").unwrap(); - let observation = execute_claude_tool( + let observation = execute_claude_tool_with_policy( &claude_tool_call("Grep", json!({"search": "needle", "files": source})), workspace.path(), + false, + None, ) .unwrap(); assert!(!observation.is_error); @@ -3959,9 +2319,11 @@ mod tests { fn claude_grep_rejects_an_empty_pattern() { let workspace = tempfile::tempdir().unwrap(); fs::write(workspace.path().join("source.txt"), "content").unwrap(); - let observation = execute_claude_tool( + let observation = execute_claude_tool_with_policy( &claude_tool_call("Grep", json!({"pattern": ""})), workspace.path(), + false, + None, ) .unwrap(); assert!(observation.is_error); @@ -3989,14 +2351,17 @@ mod tests { trajectory_assets: None, session_id: None, max_steps: None, - replay_only: true, + mode: ReplayMode::PrepareOnly, + allow_stale_observations: false, run_id: Some("test".into()), disable_thinking: false, }; if !request.trajectory.exists() { return; } - let plan = build_plan(&request).unwrap(); + let AdapterPlan::ClaudeCode(plan) = build_plan(&request).unwrap() else { + panic!("Claude fixture produced a non-Claude plan"); + }; assert_eq!(plan.batches.len(), 1); assert_eq!(plan.batches[0].tool_calls[0].name, "Bash"); let replacements = BTreeMap::from([( @@ -4095,11 +2460,14 @@ mod tests { trajectory_assets: None, session_id: None, max_steps: None, - replay_only: true, + mode: ReplayMode::PrepareOnly, + allow_stale_observations: false, run_id: Some("test".into()), disable_thinking: false, }; - let plan = build_plan(&request).unwrap(); + let AdapterPlan::ClaudeCode(plan) = build_plan(&request).unwrap() else { + panic!("Claude fixture produced a non-Claude plan"); + }; assert_eq!(plan.batches.len(), 1); assert_eq!(plan.batches[0].tool_calls.len(), 2); assert_eq!( @@ -4136,200 +2504,44 @@ mod tests { assert_eq!(rebuilt.len(), 4); } - #[test] - fn openhands_reconstructs_legacy_native_tool_metadata() { - let event = json!({ - "id": 7, - "source": "agent", - "action": "read", - "args": {"path": "/workspace/file", "view_range": [1, 2], "thought": "inspect"}, - }); - let metadata = openhands_reconstructed_tool_metadata(&event).unwrap(); - assert_eq!(metadata["function_name"], "str_replace_editor"); - assert_eq!(metadata["tool_call_id"], "sandbox-playback-replay-7"); - let arguments = metadata["model_response"]["choices"][0]["message"]["tool_calls"][0] - ["function"]["arguments"] - .as_str() - .unwrap(); - let arguments: Value = serde_json::from_str(arguments).unwrap(); - assert_eq!(arguments["command"], "view"); - assert_eq!(arguments["path"], "/workspace/file"); - } - - #[test] - fn openhands_signature_separates_visible_text_reasoning_and_tool_arguments() { - let event = json!({ - "id": 7, - "source": "agent", - "action": "run", - "args": {"command": "pwd", "thought": "legacy thought"}, - "tool_call_metadata": { - "model_response": { - "choices": [{ - "message": { - "content": "visible preamble", - "reasoning_content": "hidden reasoning" - } - }] - } - } - }); - - let signature = openhands_action_signature(&event); - - assert_eq!(signature["text"], "visible preamble"); - assert_eq!(signature["reasoning"], "hidden reasoning"); - assert_eq!( - signature["tools"][0]["arguments"], - json!({"command": "pwd"}) - ); - } - - #[test] - fn openhands_complete_batches_preserve_fresh_observations() { - let events = vec![ - json!({ - "id": 5, - "source": "agent", - "action": "run", - "args": {"command": "pwd"}, - }), - json!({ - "id": 6, - "source": "environment", - "observation": "run", - "cause": 5, - "message": "ok", - "args": {"command": "pwd", "metadata": {"exit_code": 0}}, - }), - ]; - let batches = openhands_complete_batches(&events).unwrap(); - assert_eq!(batches.len(), 1); - assert_eq!(batches[0].0["id"], 5); - assert_eq!( - openhands_observation_content(batches[0].1), - json!({ - "observation": "run", - "message": "ok", - "args": {"command": "pwd", "metadata": {"exit_code": 0}}, - }) - ); - } - - #[test] - fn openhands_runtime_tools_are_prepended_to_path() { - let runtime = tempfile::tempdir().unwrap(); - let bin = runtime.path().join("bin/openhands-python"); - fs::create_dir_all(bin.parent().unwrap()).unwrap(); - fs::create_dir(runtime.path().join("tools")).unwrap(); - let launch = LaunchSpec { - entrypoint: bin, - version: "0.53.0".into(), - source: "explicit_entrypoint".into(), - runtime_root: None, - }; - let mut command = Command::new(&launch.entrypoint); - prepend_openhands_runtime_tools(&mut command, &launch).unwrap(); - let path = command - .get_envs() - .find_map(|(name, value)| { - (name == "PATH").then(|| value.expect("PATH value").to_os_string()) - }) - .expect("PATH override"); - let first = std::env::split_paths(&path).next().unwrap(); - assert_eq!(first, runtime.path().join("tools")); - } - - #[test] - fn mini_submit_is_rejected_only_inside_the_selected_prefix() { - let command = " echo COMPLETE_TASK_AND_SUBMIT_FINAL_OUTPUT"; - assert!(mini_submission_in_prefix(true, command)); - assert!(!mini_submission_in_prefix(false, command)); - assert!(!mini_submission_in_prefix(true, "echo still-working")); - } - - #[test] - fn mini_version_probe_accepts_exact_banner_before_config_noise() { - let output = "This is mini-swe-agent version 2.4.6.\n\ -Check the v2 migration guide at https://example.invalid\n\ -Loading global config from '/root/.config/mini-swe-agent/.env'"; - assert_eq!( - probed_version(AgentKind::MiniSweAgent, output, "2.4.6"), - Some("2.4.6") - ); - assert_eq!( - probed_version(AgentKind::MiniSweAgent, output, "2.4.5"), - None - ); - } - - #[cfg(unix)] - #[test] - fn mini_python_runtime_finds_the_portable_uv_bundle() { - use std::os::unix::fs::symlink; - - let root = tempfile::tempdir().unwrap(); - let local = root.path().join(".local"); - let entrypoint = local.join("bin/mini-swe-agent"); - let virtual_env = local.join("share/uv/tools/mini-swe-agent"); - let python_home = local.join("share/uv/python/cpython-3.12.11"); - let python = python_home.join("bin/python3.12"); - fs::create_dir_all(entrypoint.parent().unwrap()).unwrap(); - fs::create_dir_all(virtual_env.join("bin")).unwrap(); - fs::create_dir_all(virtual_env.join("lib/python3.12/site-packages")).unwrap(); - fs::create_dir_all(python_home.join("lib/python3.12/encodings")).unwrap(); - fs::create_dir_all(python.parent().unwrap()).unwrap(); - let loader = local.join("share/uv/sweeval-system-libs/ld-linux-x86-64.so.2"); - fs::create_dir_all(loader.parent().unwrap()).unwrap(); - fs::write(&loader, "loader").unwrap(); - fs::write(&entrypoint, "#!/bin/sh\nexit 0\n").unwrap(); - fs::write(&python, "python").unwrap(); - symlink(&python, virtual_env.join("bin/python")).unwrap(); - - let runtime = mini_python_runtime(&entrypoint).unwrap(); - assert_eq!(runtime.python, python); - assert_eq!(runtime.python_home.as_deref(), Some(python_home.as_path())); - assert_eq!(runtime.loader.as_deref(), Some(loader.as_path())); - assert_eq!(runtime.virtual_env.as_deref(), Some(virtual_env.as_path())); - let mut command = Command::new(&runtime.python); - configure_mini_python_environment(&mut command, &runtime).unwrap(); - assert!(command - .get_envs() - .any(|(name, value)| name == "PYTHONHOME" && value == Some(python_home.as_os_str()))); - let path = command - .get_envs() - .find_map(|(name, value)| { - (name == "PATH").then(|| value.expect("PATH value").to_os_string()) - }) - .expect("PATH override"); - assert_eq!( - std::env::split_paths(&path).next().unwrap(), - virtual_env.join("bin") - ); - } - - #[test] - fn openhands_zero_exit_controller_errors_are_detected_for_partial_results() { - assert_eq!( - openhands_fatal_controller_marker("Error while running the agent"), - Some("Error while running the agent") - ); - assert_eq!( - openhands_fatal_controller_marker("Agent reached maximum iteration AgentState.ERROR"), - None - ); - } - #[test] fn bash_timeout_kills_the_historical_process_group() { let workspace = tempfile::tempdir().unwrap(); + let log = workspace.path().join("bash.log"); let started = Instant::now(); - let (content, is_error, return_code) = - run_bash("sleep 5", workspace.path(), Duration::from_millis(50)).unwrap(); + let (content, is_error, return_code, truncated) = + run_bash("sleep 5", workspace.path(), Duration::from_millis(50), &log).unwrap(); assert!(started.elapsed() < Duration::from_secs(2)); assert!(is_error); assert_eq!(return_code, Some(124)); assert!(content.contains("timed out")); + assert!(!truncated); + } + + #[test] + fn bash_reports_truncation_and_background_cleanup() { + let workspace = tempfile::tempdir().unwrap(); + let large_log = workspace.path().join("large.log"); + let (_, is_error, _, truncated) = run_bash( + "yes x | head -c 6291456", + workspace.path(), + Duration::from_secs(2), + &large_log, + ) + .unwrap(); + assert!(!is_error); + assert!(truncated); + + let background_log = workspace.path().join("background.log"); + let (content, is_error, _, _) = run_bash( + "sleep 30 &", + workspace.path(), + Duration::from_secs(2), + &background_log, + ) + .unwrap(); + assert!(is_error); + assert!(content.contains("background descendants were terminated")); } #[test] @@ -4337,16 +2549,4 @@ Loading global config from '/root/.config/mini-swe-agent/.env'"; assert!(wildcard_match("**/*.rs", "src/lib.rs")); assert!(!wildcard_match("*.toml", "src/lib.rs")); } - #[test] - fn direct_agents_keep_model_credentials_but_claude_tools_do_not() { - assert!(environment_name_allowed("OPENAI_API_KEY", false)); - assert!(environment_name_allowed("LLM_API_KEY", false)); - assert!(environment_name_allowed("OPENAI_BASE_URL", false)); - assert!(!environment_name_allowed("OPENAI_API_KEY", true)); - assert!(!environment_name_allowed("ANTHROPIC_AUTH_TOKEN", true)); - assert!(!environment_name_allowed("CLAUDE_CODE_USE_BEDROCK", true)); - assert!(!environment_name_allowed("CLAUDE_CODE_USE_VERTEX", true)); - assert!(!environment_name_allowed("CLAUDE_CODE_USE_FOUNDRY", true)); - assert!(!environment_name_allowed("PYTHONPATH", false)); - } } diff --git a/crates/persisting-replay/src/adapter/mini_swe_agent.rs b/crates/persisting-replay/src/adapter/mini_swe_agent.rs new file mode 100644 index 00000000..51688a0c --- /dev/null +++ b/crates/persisting-replay/src/adapter/mini_swe_agent.rs @@ -0,0 +1,326 @@ +use serde_json::{json, Value}; + +use super::{check_boundary, prepared_outcome, run_sdk_bridge, RunContext}; +use crate::error::{ReplayError, ReplayErrorKind, ResultExt}; +use crate::io::{atomic_write_json, canonicalize, read_regular_file, sha256}; +use crate::journal::Journal; +use crate::model::{ + AdapterPlan, AgentKind, PlaybackRequest, ReplayMode, ReplayOutcome, ReplayPlan, ToolBatch, + ToolCall, +}; + +pub(super) fn build(request: &PlaybackRequest) -> Result { + build_mini_plan(request).map(AdapterPlan::MiniSweAgent) +} + +pub(super) fn execute( + plan: &ReplayPlan, + context: &RunContext<'_>, + journal: &mut Journal, +) -> Result { + run_mini(plan, context, journal) +} + +fn mini_reasoning(message: &Value) -> &str { + message + .get("reasoning_content") + .and_then(Value::as_str) + .or_else(|| { + message + .pointer("/extra/response/choices/0/message/reasoning_content") + .and_then(Value::as_str) + }) + .unwrap_or_default() +} + +fn mini_batch_signature(batch: &ToolBatch, message: &Value) -> Value { + json!({ + "text": batch.assistant_text.as_str(), + "reasoning": mini_reasoning(message), + "tools": batch.tool_calls.iter().map(|call| json!({ + "name": call.name.as_str(), + "arguments": &call.arguments, + })).collect::>(), + }) +} + +fn build_mini_plan(request: &PlaybackRequest) -> Result { + let raw = read_regular_file(&request.trajectory)?; + let value: Value = serde_json::from_slice(&raw).replay_context( + ReplayErrorKind::Trajectory, + "invalid mini-swe-agent trajectory JSON", + )?; + if value.get("trajectory_format").and_then(Value::as_str) != Some("mini-swe-agent-1.1") { + return Err(ReplayError::trajectory( + "mini-swe-agent trajectory_format must be mini-swe-agent-1.1", + )); + } + if value + .get("info") + .and_then(|info| info.get("mini_version")) + .and_then(Value::as_str) + != Some("2.4.6") + { + return Err(ReplayError::new( + ReplayErrorKind::UnsupportedVersion, + "mini-swe-agent trajectory requires exact version 2.4.6", + )); + } + let messages = value + .get("messages") + .and_then(Value::as_array) + .ok_or_else(|| ReplayError::trajectory("mini-swe-agent messages must be an array"))?; + let mut batches = Vec::new(); + for (message_index, message) in messages.iter().enumerate() { + let native_calls = mini_calls(message, message_index)?; + if native_calls.is_empty() { + continue; + } + let mut observations = Vec::new(); + for candidate in messages.iter().skip(message_index + 1) { + if !mini_calls(candidate, message_index + 1 + observations.len())?.is_empty() { + break; + } + if matches!( + candidate.get("role").and_then(Value::as_str), + Some("tool" | "user") + ) || candidate.get("type").and_then(Value::as_str) == Some("function_call_output") + { + observations.push(candidate); + if observations.len() == native_calls.len() { + break; + } + } + } + if observations.len() != native_calls.len() { + break; + } + let batch_is_in_prefix = batches.len() < request.after_step; + let calls = native_calls + .into_iter() + .zip(observations) + .enumerate() + .map(|(index, (native, observation))| { + let command = native["arguments"]["command"].as_str().unwrap_or_default(); + if mini_submission_in_prefix(batch_is_in_prefix, command) { + return Err(ReplayError::new( + ReplayErrorKind::UnsupportedVersion, + "mini-swe-agent submission cannot appear inside a replay prefix", + )); + } + let return_code = observation + .get("extra") + .and_then(|extra| extra.get("returncode")) + .and_then(Value::as_i64); + Ok(ToolCall { + ordinal: index + 1, + call_id: native["id"].as_str().unwrap().to_owned(), + name: "bash".into(), + arguments: native["arguments"].clone(), + original_observation: mini_observation(observation), + original_is_error: return_code.is_some_and(|code| code != 0), + native, + }) + }) + .collect::, _>>()?; + batches.push(ToolBatch { + ordinal: batches.len() + 1, + native_locator: format!("messages:{message_index}"), + tool_calls: calls, + assistant_text: message + .get("content") + .and_then(Value::as_str) + .unwrap_or_default() + .to_owned(), + native: json!({"message_index": message_index}), + }); + } + check_boundary(request.after_step, batches.len())?; + let original_next_action = if let Some(batch) = batches.get(request.after_step) { + let message_index = batch.native["message_index"].as_u64().ok_or_else(|| { + ReplayError::trajectory("mini-swe-agent next action lost message_index") + })? as usize; + let message = messages.get(message_index).ok_or_else(|| { + ReplayError::trajectory(format!( + "mini-swe-agent next action message index {message_index} is out of bounds" + )) + })?; + Some(mini_batch_signature(batch, message)) + } else { + None + }; + batches.truncate(request.after_step); + let boundary_message_index = batches.last().unwrap().native["message_index"] + .as_u64() + .ok_or_else(|| ReplayError::trajectory("mini-swe-agent batch lost message_index"))? + as usize; + let prefix_model_turns = value["messages"] + .as_array() + .ok_or_else(|| ReplayError::trajectory("mini-swe-agent messages must be an array"))? + .iter() + .take(boundary_message_index + 1) + .filter(|message| { + message + .get("extra") + .and_then(|extra| extra.get("response")) + .is_some_and(Value::is_object) + }) + .count(); + Ok(ReplayPlan { + agent: request.agent, + source_path: canonicalize( + &request.trajectory, + ReplayErrorKind::Trajectory, + "trajectory", + )?, + source_sha256: sha256(&raw), + after_step: request.after_step, + prefix_model_turns, + batches, + native: value, + original_next_action, + }) +} + +fn mini_submission_in_prefix(batch_is_in_prefix: bool, command: &str) -> bool { + batch_is_in_prefix + && command + .trim_start() + .starts_with("echo COMPLETE_TASK_AND_SUBMIT_FINAL_OUTPUT") +} + +fn mini_calls(message: &Value, message_index: usize) -> Result, ReplayError> { + if let Some(actions) = message + .get("extra") + .and_then(|extra| extra.get("actions")) + .and_then(Value::as_array) + { + let native_calls = message + .get("tool_calls") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + return actions + .iter() + .enumerate() + .map(|(index, action)| { + let command = action + .get("command") + .and_then(Value::as_str) + .ok_or_else(|| { + ReplayError::trajectory(format!( + "mini-swe-agent message[{message_index}] has an invalid native action" + )) + })?; + let call_id = action + .get("tool_call_id") + .and_then(Value::as_str) + .or_else(|| { + native_calls + .get(index) + .and_then(|call| call.get("id")) + .and_then(Value::as_str) + }) + .map(str::to_owned) + .unwrap_or_else(|| format!("mini-{message_index}-{}", index + 1)); + Ok(json!({ + "id": call_id, + "arguments": {"command": command}, + "native": action, + })) + }) + .collect(); + } + let mut result = Vec::new(); + for (index, call) in message + .get("tool_calls") + .and_then(Value::as_array) + .into_iter() + .flatten() + .enumerate() + { + let function = call + .get("function") + .and_then(Value::as_object) + .ok_or_else(|| { + ReplayError::trajectory(format!( + "mini-swe-agent message[{message_index}] has an invalid tool call" + )) + })?; + if function.get("name").and_then(Value::as_str) != Some("bash") { + return Err(ReplayError::new( + ReplayErrorKind::UnsupportedVersion, + "mini-swe-agent playback supports only native bash actions", + )); + } + let arguments = match function.get("arguments") { + Some(Value::String(raw)) => serde_json::from_str(raw).replay_context( + ReplayErrorKind::Trajectory, + "invalid mini-swe-agent tool arguments", + )?, + Some(value) => value.clone(), + None => json!({}), + }; + if arguments.get("command").and_then(Value::as_str).is_none() { + return Err(ReplayError::trajectory( + "mini-swe-agent bash action has no command", + )); + } + result.push(json!({ + "id": call.get("id").and_then(Value::as_str) + .map(str::to_owned).unwrap_or_else(|| format!("mini-{message_index}-{}", index + 1)), + "arguments": arguments, + "native": call, + })); + } + Ok(result) +} + +fn mini_observation(message: &Value) -> Value { + message + .get("extra") + .and_then(|extra| extra.get("raw_output")) + .cloned() + .or_else(|| message.get("output").cloned()) + .or_else(|| message.get("content").cloned()) + .unwrap_or(Value::String(String::new())) +} + +fn run_mini( + plan: &ReplayPlan, + context: &RunContext<'_>, + journal: &mut Journal, +) -> Result { + let boundary = plan.batches.last().unwrap().native["message_index"] + .as_u64() + .unwrap() as usize; + let mut prepared = plan.native.clone(); + prepared["messages"] = + Value::Array(plan.native["messages"].as_array().unwrap()[..=boundary].to_vec()); + let path = context.output_dir.join("native/prepared-prefix.json"); + atomic_write_json(&path, &prepared)?; + journal.append( + "session_rebuilt", + [( + "prepared_only".into(), + json!(context.request.mode == ReplayMode::PrepareOnly), + )], + )?; + if context.request.mode == ReplayMode::PrepareOnly { + return Ok(prepared_outcome(path)); + } + run_sdk_bridge(plan, context, journal, AgentKind::MiniSweAgent) +} + +#[cfg(test)] +mod tests { + use super::mini_submission_in_prefix; + + #[test] + fn mini_submit_is_rejected_only_inside_the_selected_prefix() { + let command = " echo COMPLETE_TASK_AND_SUBMIT_FINAL_OUTPUT"; + assert!(mini_submission_in_prefix(true, command)); + assert!(!mini_submission_in_prefix(false, command)); + assert!(!mini_submission_in_prefix(true, "echo still-working")); + } +} diff --git a/crates/persisting-replay/src/adapter/mod.rs b/crates/persisting-replay/src/adapter/mod.rs new file mode 100644 index 00000000..d42be76d --- /dev/null +++ b/crates/persisting-replay/src/adapter/mod.rs @@ -0,0 +1,512 @@ +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::Duration; + +mod claude_code; +mod mini_swe_agent; +mod openhands; +mod runtime; +mod swe_agent; + +use serde_json::{json, Value}; + +use crate::error::{ReplayError, ReplayErrorKind, ResultExt}; +use crate::io::{atomic_write, atomic_write_json, read_regular_file}; +use crate::journal::Journal; +use crate::model::{ + AdapterPlan, AgentKind, FreshObservation, PlaybackRequest, ReplayMode, ReplayOutcome, + ReplayPlan, +}; +use crate::process::{run_process, ProcessSpec}; +use runtime::{configure_mini_python_environment, mini_python_library_path, mini_python_runtime}; +pub(crate) use runtime::{resolve_launch_spec, LaunchSpec}; + +const MAX_TOOL_OUTPUT_BYTES: usize = 4 * 1024 * 1024; + +pub struct RunContext<'a> { + pub request: &'a PlaybackRequest, + pub state_dir: &'a Path, + pub output_dir: &'a Path, + pub launch: Option<&'a LaunchSpec>, + pub session_id: &'a str, + pub nonce: &'a str, +} + +pub fn build_plan(request: &PlaybackRequest) -> Result { + match request.agent { + AgentKind::ClaudeCode => claude_code::build(request), + AgentKind::MiniSweAgent => mini_swe_agent::build(request), + AgentKind::Openhands => openhands::build(request), + AgentKind::SweAgent => swe_agent::build(request), + } +} + +pub fn run( + plan: &AdapterPlan, + context: &RunContext<'_>, + journal: &mut Journal, +) -> Result { + match plan { + AdapterPlan::ClaudeCode(plan) => claude_code::execute(plan, context, journal), + AdapterPlan::MiniSweAgent(plan) => mini_swe_agent::execute(plan, context, journal), + AdapterPlan::Openhands(plan) => openhands::execute(plan, context, journal), + AdapterPlan::SweAgent(plan) => swe_agent::execute(plan, context, journal), + } +} + +fn check_boundary(after_step: usize, complete: usize) -> Result<(), ReplayError> { + if after_step == 0 || after_step > complete { + return Err(ReplayError::trajectory(format!( + "requested after-step {after_step}, trajectory has {complete} complete batches" + ))); + } + Ok(()) +} + +fn prepared_outcome(path: PathBuf) -> ReplayOutcome { + ReplayOutcome { + status: "prepared".into(), + reconstructed_path: Some(path), + continued_path: None, + observations: Vec::new(), + continued_steps: 0, + metadata: json!({"replay_only_execution": false}), + } +} + +fn run_sdk_bridge( + plan: &ReplayPlan, + context: &RunContext<'_>, + journal: &mut Journal, + agent: AgentKind, +) -> Result { + let launch = context + .launch + .ok_or_else(|| ReplayError::continuation("SDK continuation has no launch spec"))?; + let native_dir = context.output_dir.join("native"); + let logs_dir = context.output_dir.join("logs"); + fs::create_dir_all(&native_dir) + .replay_context(ReplayErrorKind::Executor, "create native output directory")?; + fs::create_dir_all(&logs_dir) + .replay_context(ReplayErrorKind::Executor, "create Agent log directory")?; + + let runner_result = context + .state_dir + .join(format!("{}-runner-result.json", agent.as_str())); + let mode = match context.request.mode { + ReplayMode::ReplayOnly => "replay_only", + ReplayMode::ReplayAndContinue => "replay_and_continue", + ReplayMode::PrepareOnly => { + return Err(ReplayError::new( + ReplayErrorKind::Internal, + "prepare-only unexpectedly started an SDK runner", + )); + } + }; + let ( + program, + bridge_source, + bridge_name, + request_value, + reconstructed, + continued, + observations_path, + ) = match agent { + AgentKind::MiniSweAgent => { + let source = context.state_dir.join("mini-source.json"); + let reconstructed = native_dir.join("reconstructed-trajectory.json"); + let continued = native_dir.join("continued-trajectory.json"); + let observations = context.state_dir.join("mini-fresh-observations.json"); + atomic_write_json(&source, &plan.native)?; + let runtime = mini_python_runtime(&launch.entrypoint)?; + let program = runtime + .loader + .clone() + .unwrap_or_else(|| runtime.python.clone()); + ( + program, + include_str!("../../assets/mini_swe_agent_runner.py"), + "mini-swe-agent-runner.py", + json!({ + "source": source, + "reconstructed": reconstructed, + "continued": continued, + "observations": observations, + "result": runner_result, + "mode": mode, + "workspace": context.request.workspace, + "after_step": plan.after_step, + "max_steps": context.request.max_steps, + "session_id": context.session_id, + }), + reconstructed, + continued, + Some(observations), + ) + } + AgentKind::SweAgent => { + let source = native_dir.join("continuation-source.traj"); + let run_output = native_dir.join("swe-agent-run"); + let reconstructed = native_dir.join("reconstructed-trajectory.traj"); + let continued = native_dir.join("continued-trajectory.traj"); + atomic_write_json(&source, &plan.native)?; + ( + launch.entrypoint.clone(), + include_str!("../../assets/swe_agent_runner.py"), + "swe-agent-runner.py", + json!({ + "trajectory": source, + "reconstructed": reconstructed, + "continued": continued, + "trajectory_assets": context.request.trajectory_assets, + "after_step": plan.after_step, + "max_steps": context.request.max_steps, + "mode": mode, + "result": runner_result, + "workspace": context.request.workspace, + "output_dir": run_output, + }), + reconstructed, + continued, + None, + ) + } + _ => { + return Err(ReplayError::new( + ReplayErrorKind::Internal, + "SDK bridge selected for a non-SDK agent", + )); + } + }; + + let bridge = context.state_dir.join(bridge_name); + let request_path = context + .state_dir + .join(format!("{}-request.json", agent.as_str())); + atomic_write(&bridge, bridge_source.as_bytes())?; + atomic_write_json(&request_path, &request_value)?; + let mut command = agent_command(&program, context); + if agent == AgentKind::MiniSweAgent { + let runtime = mini_python_runtime(&launch.entrypoint)?; + if runtime.loader.is_some() { + let library_path = mini_python_library_path(&runtime)?.ok_or_else(|| { + ReplayError::continuation("bundled mini-swe-agent Python has no library path") + })?; + let argv0 = runtime + .virtual_env + .as_deref() + .map(|venv| venv.join("bin/python")) + .unwrap_or_else(|| runtime.python.clone()); + command + .arg("--argv0") + .arg(argv0) + .arg("--library-path") + .arg(library_path) + .arg(&runtime.python); + } + configure_mini_python_environment(&mut command, &runtime)?; + command.env("MSWEA_CONFIGURED", "true"); + command.env("MSWEA_COST_TRACKING", "ignore_errors"); + command.env("SWE_EVAL_MINI_RUNTIME", "1"); + } + command.arg(&bridge).arg(&request_path); + journal.append("continuation_started", std::iter::empty())?; + let log = logs_dir.join(format!("{}.log", agent.as_str())); + let output = run_process(ProcessSpec { + command, + stdin: None, + timeout: Duration::from_secs(24 * 60 * 60), + termination_grace: Duration::from_secs(2), + pipe_grace: Duration::from_millis(250), + retained_bytes: MAX_TOOL_OUTPUT_BYTES / 2, + log_path: log.clone(), + }) + .map_err(|error| ReplayError::new(ReplayErrorKind::Continuation, error.message))?; + if !output.status.success() { + let mut rendered = String::from_utf8_lossy(&output.stdout_tail).into_owned(); + if !output.stderr_tail.is_empty() { + rendered.push('\n'); + rendered.push_str(&String::from_utf8_lossy(&output.stderr_tail)); + } + return Err(ReplayError::classify_continuation( + format!( + "{} replay/continuation exited {}; see {}", + agent.as_str(), + output.status, + log.display() + ), + &rendered, + )); + } + + let runner: Value = serde_json::from_slice(&read_regular_file(&runner_result)?) + .replay_context( + ReplayErrorKind::Continuation, + "parse SDK replay runner result", + )?; + let expected_phase = if context.request.mode == ReplayMode::ReplayOnly { + "replayed" + } else { + "continued" + }; + if runner.get("phase").and_then(Value::as_str) != Some(expected_phase) + || runner.get("replayed_steps").and_then(Value::as_u64) != Some(plan.after_step as u64) + { + return Err(ReplayError::continuation(format!( + "{} runner returned an invalid replay boundary", + agent.as_str() + ))); + } + let runner_continued_steps = runner + .get("continued_steps") + .and_then(Value::as_u64) + .ok_or_else(|| ReplayError::continuation("SDK runner omitted continued_steps"))? + as usize; + let runner_agent_status = runner + .get("agent_status") + .and_then(Value::as_str) + .ok_or_else(|| ReplayError::continuation("SDK runner omitted agent_status"))?; + let status_is_valid = match context.request.mode { + ReplayMode::ReplayOnly => { + runner_agent_status == "not_started" && runner_continued_steps == 0 + } + ReplayMode::ReplayAndContinue => { + matches!(runner_agent_status, "completed" | "max_steps") + && context.request.max_steps.is_none_or(|max_steps| { + plan.prefix_model_turns + runner_continued_steps <= max_steps + }) + } + ReplayMode::PrepareOnly => false, + }; + if !status_is_valid { + return Err(ReplayError::continuation(format!( + "{} runner returned an invalid terminal status or step count", + agent.as_str() + ))); + } + let runner_trajectory = runner + .get("trajectory") + .and_then(Value::as_str) + .map(PathBuf::from) + .ok_or_else(|| ReplayError::continuation("SDK runner omitted trajectory"))?; + let expected_trajectory = if context.request.mode == ReplayMode::ReplayOnly { + &reconstructed + } else { + &continued + }; + if runner_trajectory != *expected_trajectory || !runner_trajectory.is_file() { + return Err(ReplayError::continuation(format!( + "{} runner produced an unexpected trajectory path", + agent.as_str() + ))); + } + + let (observations, continued_steps) = if agent == AgentKind::MiniSweAgent { + let raw_observations: Vec = serde_json::from_slice(&read_regular_file( + observations_path.as_ref().expect("mini observations path"), + )?) + .replay_context( + ReplayErrorKind::Trajectory, + "parse mini-swe-agent fresh observations", + )?; + if raw_observations.len() != plan.calls().count() { + return Err(ReplayError::trajectory( + "mini-swe-agent output lost replayed observations", + )); + } + let observations = plan + .calls() + .zip(raw_observations) + .map(|(call, value)| FreshObservation { + call_id: call.call_id.clone(), + content: value.get("content").cloned().unwrap_or(Value::Null), + is_error: value + .get("is_error") + .and_then(Value::as_bool) + .unwrap_or(false), + return_code: value + .get("return_code") + .and_then(Value::as_i64) + .map(|code| code as i32), + duration_ms: value + .get("duration_ms") + .and_then(Value::as_u64) + .unwrap_or_default() as u128, + truncated: false, + metadata: BTreeMap::new(), + }) + .collect::>(); + let continued_value: Value = + serde_json::from_slice(&read_regular_file(&runner_trajectory)?).replay_context( + ReplayErrorKind::Trajectory, + "parse continued mini-swe-agent trajectory", + )?; + let action_count = continued_value["messages"] + .as_array() + .map(|messages| { + messages + .iter() + .filter(|message| { + message + .get("extra") + .and_then(|extra| extra.get("actions")) + .and_then(Value::as_array) + .is_some_and(|actions| !actions.is_empty()) + }) + .count() + }) + .unwrap_or_default(); + let measured = action_count.saturating_sub(plan.after_step); + if measured != runner_continued_steps { + return Err(ReplayError::trajectory( + "mini-swe-agent runner result disagrees with its trajectory", + )); + } + (observations, measured) + } else { + let replayed: Value = serde_json::from_slice(&read_regular_file(&runner_trajectory)?) + .replay_context( + ReplayErrorKind::Trajectory, + "parse SWE-agent replay runner trajectory", + )?; + let steps = replayed["trajectory"] + .as_array() + .ok_or_else(|| ReplayError::trajectory("continued SWE-agent trajectory is invalid"))?; + if steps.len() < plan.after_step { + return Err(ReplayError::trajectory( + "SWE-agent output lost replayed steps", + )); + } + let observations = plan + .calls() + .zip(steps.iter()) + .map(|(call, step)| FreshObservation { + call_id: call.call_id.clone(), + content: step.get("observation").cloned().unwrap_or(Value::Null), + is_error: false, + return_code: None, + duration_ms: 0, + truncated: false, + metadata: BTreeMap::new(), + }) + .collect::>(); + let continued_steps = steps[plan.after_step..] + .iter() + .filter(|step| { + step.get("action") + .and_then(Value::as_str) + .is_some_and(|action| !action.trim().is_empty()) + }) + .count(); + if continued_steps != runner_continued_steps { + return Err(ReplayError::trajectory( + "SWE-agent runner result disagrees with its trajectory", + )); + } + (observations, continued_steps) + }; + if context.request.mode == ReplayMode::ReplayAndContinue && continued_steps == 0 { + return Err(ReplayError::continuation(format!( + "{} produced no actionable continuation step; see {}", + agent.as_str(), + log.display() + ))); + } + let comparisons: Vec<_> = plan + .calls() + .zip(&observations) + .map(|(call, fresh)| { + json!({ + "call_id": call.call_id, + "tool": call.name, + "exact": call.original_observation == fresh.content + && call.original_is_error == fresh.is_error, + "original_is_error": call.original_is_error, + "replayed_is_error": fresh.is_error, + }) + }) + .collect(); + atomic_write_json( + &context.output_dir.join("observation-comparison.json"), + &comparisons, + )?; + journal.append( + "continuation_finished", + [ + ("return_code".into(), json!(output.status.code())), + ("continued_steps".into(), json!(continued_steps)), + ], + )?; + Ok(ReplayOutcome { + status: if context.request.mode == ReplayMode::ReplayOnly { + "replayed".into() + } else { + runner_agent_status.into() + }, + reconstructed_path: Some(reconstructed), + continued_path: (context.request.mode == ReplayMode::ReplayAndContinue) + .then_some(continued), + observations, + continued_steps, + metadata: json!({"sdk_bridge": bridge_name}), + }) +} + +fn agent_command(entrypoint: &Path, context: &RunContext<'_>) -> Command { + let mut command = Command::new(entrypoint); + command.current_dir(&context.request.workspace); + sanitized_environment(&mut command, context.request.agent == AgentKind::ClaudeCode); + if context.request.agent != AgentKind::ClaudeCode { + command.env("X_LITELLM_SESSION_ID", context.session_id); + command.env( + "LITELLM_EXTRA_HEADERS", + json!({"X-LiteLLM-Session-ID": context.session_id}).to_string(), + ); + } + command +} + +fn sanitized_environment(command: &mut Command, strip_credentials: bool) { + command.env_clear(); + for (name, value) in std::env::vars_os() { + let rendered = name.to_string_lossy().to_ascii_uppercase(); + if !environment_name_allowed(&rendered, strip_credentials) { + continue; + } + command.env(name, value); + } +} + +fn environment_name_allowed(rendered: &str, strip_credentials: bool) -> bool { + let credential = ["API_KEY", "TOKEN", "SECRET", "AUTHORIZATION", "PASSWORD"] + .iter() + .any(|fragment| rendered.contains(fragment)); + let claude_provider_override = strip_credentials + && matches!( + rendered, + "CLAUDE_CODE_USE_BEDROCK" | "CLAUDE_CODE_USE_VERTEX" | "CLAUDE_CODE_USE_FOUNDRY" + ); + !(strip_credentials && credential) + && !claude_provider_override + && !matches!(rendered, "PYTHONHOME" | "PYTHONPATH" | "VIRTUAL_ENV") +} + +#[cfg(test)] +mod tests { + use super::environment_name_allowed; + + #[test] + fn direct_agents_keep_model_credentials_but_claude_tools_do_not() { + assert!(environment_name_allowed("OPENAI_API_KEY", false)); + assert!(environment_name_allowed("LLM_API_KEY", false)); + assert!(environment_name_allowed("OPENAI_BASE_URL", false)); + assert!(!environment_name_allowed("OPENAI_API_KEY", true)); + assert!(!environment_name_allowed("ANTHROPIC_AUTH_TOKEN", true)); + assert!(!environment_name_allowed("CLAUDE_CODE_USE_BEDROCK", true)); + assert!(!environment_name_allowed("CLAUDE_CODE_USE_VERTEX", true)); + assert!(!environment_name_allowed("CLAUDE_CODE_USE_FOUNDRY", true)); + assert!(!environment_name_allowed("PYTHONPATH", false)); + } +} diff --git a/crates/persisting-replay/src/adapter/openhands.rs b/crates/persisting-replay/src/adapter/openhands.rs new file mode 100644 index 00000000..387983a5 --- /dev/null +++ b/crates/persisting-replay/src/adapter/openhands.rs @@ -0,0 +1,781 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::Path; +use std::process::Command; +use std::time::Duration; + +use serde_json::{json, Value}; + +use super::{ + agent_command, check_boundary, prepared_outcome, LaunchSpec, RunContext, MAX_TOOL_OUTPUT_BYTES, +}; +use crate::error::{ReplayError, ReplayErrorKind, ResultExt}; +use crate::io::{atomic_write_json, canonicalize, read_regular_file, sha256}; +use crate::journal::Journal; +use crate::model::{ + AdapterPlan, FreshObservation, PlaybackRequest, ReplayMode, ReplayOutcome, ReplayPlan, + ToolBatch, ToolCall, +}; +use crate::process::{run_process, ProcessSpec}; + +pub(super) fn build(request: &PlaybackRequest) -> Result { + build_openhands_plan(request).map(AdapterPlan::Openhands) +} + +pub(super) fn execute( + plan: &ReplayPlan, + context: &RunContext<'_>, + journal: &mut Journal, +) -> Result { + run_openhands(plan, context, journal) +} + +fn build_openhands_plan(request: &PlaybackRequest) -> Result { + let raw = read_regular_file(&request.trajectory)?; + let events: Vec = serde_json::from_slice(&raw).replay_context( + ReplayErrorKind::Trajectory, + "invalid OpenHands trajectory JSON", + )?; + if events.is_empty() { + return Err(ReplayError::trajectory( + "OpenHands trajectory must be a non-empty event array", + )); + } + let mut ids = BTreeSet::new(); + for event in &events { + let id = event_id(event)?; + if !ids.insert(id) { + return Err(ReplayError::trajectory(format!( + "duplicate OpenHands event id {id}" + ))); + } + } + let observations: BTreeMap = events + .iter() + .filter_map(|event| { + (event.get("observation").is_some() && !event["observation"].is_null()) + .then(|| { + event + .get("cause") + .and_then(Value::as_i64) + .map(|cause| (cause, event)) + }) + .flatten() + }) + .collect(); + let supported = ["run", "read", "edit", "run_ipython", "think"]; + let mut batches = Vec::new(); + for action in &events { + let action_name = action.get("action").and_then(Value::as_str); + if action.get("source").and_then(Value::as_str) != Some("agent") + || matches!(action_name, None | Some("system" | "finish" | "message")) + { + continue; + } + let action_name = action_name.unwrap(); + if !supported.contains(&action_name) { + return Err(ReplayError::new( + ReplayErrorKind::UnsupportedVersion, + format!("unsupported OpenHands action {action_name:?}"), + )); + } + let id = event_id(action)?; + let Some(observation) = observations.get(&id) else { + break; + }; + batches.push(ToolBatch { + ordinal: batches.len() + 1, + native_locator: format!("event:{id}"), + tool_calls: vec![ToolCall { + ordinal: batches.len() + 1, + call_id: id.to_string(), + name: action_name.to_owned(), + arguments: action.get("args").cloned().unwrap_or_else(|| json!({})), + original_observation: json!({ + "observation": observation.get("observation"), + "message": observation.get("message"), + "args": observation.get("args"), + }), + original_is_error: observation.get("observation").and_then(Value::as_str) + == Some("error"), + native: action.clone(), + }], + assistant_text: action + .get("args") + .and_then(|args| args.get("thought")) + .and_then(Value::as_str) + .unwrap_or_default() + .to_owned(), + native: json!({"observation_id": observation.get("id")}), + }); + } + check_boundary(request.after_step, batches.len())?; + batches.truncate(request.after_step); + let boundary_id = batches.last().unwrap().tool_calls[0] + .call_id + .parse::() + .unwrap(); + let initial_user_event = events + .iter() + .find(|event| { + event.get("source").and_then(Value::as_str) == Some("user") + && event.get("action").and_then(Value::as_str) == Some("message") + && event.get("id").and_then(Value::as_i64).unwrap_or(i64::MAX) <= boundary_id + }) + .cloned() + .ok_or_else(|| { + ReplayError::trajectory("OpenHands replay has no user message through the boundary") + })?; + let original_next_action = events.iter().find_map(|event| { + let id = event.get("id").and_then(Value::as_i64)?; + let action = event.get("action").and_then(Value::as_str)?; + if id <= boundary_id + || event.get("source").and_then(Value::as_str) != Some("agent") + || action == "finish" + { + return None; + } + Some(openhands_action_signature(event)) + }); + Ok(ReplayPlan { + agent: request.agent, + source_path: canonicalize( + &request.trajectory, + ReplayErrorKind::Trajectory, + "trajectory", + )?, + source_sha256: sha256(&raw), + after_step: request.after_step, + prefix_model_turns: request.after_step, + batches, + native: json!({"events": events, "initial_user_event": initial_user_event}), + original_next_action, + }) +} + +fn openhands_action_signature(event: &Value) -> Value { + let response_message = event + .get("tool_call_metadata") + .and_then(|metadata| metadata.get("model_response")) + .and_then(|response| response.get("choices")) + .and_then(Value::as_array) + .and_then(|choices| choices.first()) + .and_then(|choice| choice.get("message")); + let text = response_message + .and_then(|message| message.get("content")) + .and_then(Value::as_str) + .unwrap_or_default(); + let reasoning = response_message + .and_then(|message| message.get("reasoning_content")) + .and_then(Value::as_str) + .or_else(|| { + response_message.is_none().then(|| { + event + .get("args") + .and_then(|args| args.get("thought")) + .and_then(Value::as_str) + .unwrap_or_default() + }) + }) + .unwrap_or_default(); + json!({ + "text": text, + "reasoning": reasoning, + "tools": [{ + "name": event.get("action").and_then(Value::as_str).unwrap_or_default(), + "arguments": openhands_reconstructed_tool_arguments(event), + }], + }) +} + +fn openhands_reconstructed_tool_metadata(event: &Value) -> Result { + let event_id = event_id(event)?; + let action = event + .get("action") + .and_then(Value::as_str) + .ok_or_else(|| ReplayError::trajectory("OpenHands replay action has no action"))?; + let tool_name = match action { + "run" => "execute_bash", + "read" | "edit" => "str_replace_editor", + "run_ipython" => "execute_ipython_cell", + "think" => "think", + _ => { + return Err(ReplayError::new( + ReplayErrorKind::UnsupportedVersion, + format!("unsupported OpenHands action {action:?}"), + )); + } + }; + let tool_call_id = format!("sandbox-playback-replay-{event_id}"); + let arguments = openhands_reconstructed_tool_arguments(event); + let serialized_arguments = serde_json::to_string(&arguments).replay_context( + ReplayErrorKind::Internal, + "serialize reconstructed OpenHands tool arguments", + )?; + let thought = event + .get("args") + .and_then(|args| args.get("thought")) + .and_then(Value::as_str) + .filter(|thought| !thought.is_empty()) + .map(str::to_owned); + Ok(json!({ + "function_name": tool_name, + "tool_call_id": tool_call_id.clone(), + "total_calls_in_response": 1, + "model_response": { + "id": format!("sandbox-playback-response-{event_id}"), + "created": 0, + "model": "sandbox-playback/reconstructed", + "object": "chat.completion", + "choices": [{ + "index": 0, + "finish_reason": "tool_calls", + "message": { + "role": "assistant", + "content": thought, + "tool_calls": [{ + "id": tool_call_id, + "type": "function", + "function": { + "name": tool_name, + "arguments": serialized_arguments, + }, + }], + }, + }], + }, + })) +} + +fn openhands_reconstructed_tool_arguments(event: &Value) -> Value { + let action = event + .get("action") + .and_then(Value::as_str) + .unwrap_or_default(); + let source = event.get("args").cloned().unwrap_or_else(|| json!({})); + match action { + "run" => { + let mut arguments = serde_json::Map::from_iter([( + "command".to_owned(), + source.get("command").cloned().unwrap_or_else(|| json!("")), + )]); + if let Some(value) = source.get("is_input") { + arguments.insert( + "is_input".to_owned(), + if let Some(value) = value.as_bool() { + Value::String(value.to_string()) + } else { + value.clone() + }, + ); + } + if let Some(value) = source.get("timeout").filter(|value| !value.is_null()) { + arguments.insert("timeout".to_owned(), value.clone()); + } + Value::Object(arguments) + } + "run_ipython" => json!({ + "code": source.get("code").cloned().unwrap_or_else(|| json!("")), + }), + "read" => { + let mut arguments = serde_json::Map::from_iter([ + ("command".to_owned(), json!("view")), + ( + "path".to_owned(), + source.get("path").cloned().unwrap_or_else(|| json!("")), + ), + ]); + if let Some(value) = source.get("view_range").filter(|value| !value.is_null()) { + arguments.insert("view_range".to_owned(), value.clone()); + } + Value::Object(arguments) + } + "edit" => { + let mut arguments = serde_json::Map::new(); + for key in [ + "command", + "path", + "file_text", + "old_str", + "new_str", + "insert_line", + "view_range", + ] { + if let Some(value) = source.get(key) { + arguments.insert(key.to_owned(), value.clone()); + } + } + arguments + .entry("command".to_owned()) + .or_insert(json!("str_replace")); + Value::Object(arguments) + } + "think" => json!({ + "thought": source.get("thought").cloned().unwrap_or_else(|| json!("")), + }), + _ => source, + } +} + +fn event_id(event: &Value) -> Result { + event + .get("id") + .and_then(Value::as_i64) + .ok_or_else(|| ReplayError::trajectory("OpenHands event has no integer id")) +} + +fn run_openhands( + plan: &ReplayPlan, + context: &RunContext<'_>, + journal: &mut Journal, +) -> Result { + let events = plan.native["events"].as_array().unwrap(); + let initial = plan.native["initial_user_event"].clone(); + let boundary_id = plan.batches.last().unwrap().tool_calls[0] + .call_id + .parse::() + .unwrap(); + let mut prepared_events = vec![initial.clone()]; + for event in events { + if event_id(event)? > boundary_id { + break; + } + if event == &initial || event.get("action").and_then(Value::as_str) == Some("system") { + continue; + } + if event.get("action").is_some() && !event["action"].is_null() { + let mut reconstructed = event.clone(); + if reconstructed.get("source").and_then(Value::as_str) == Some("agent") + && matches!( + reconstructed.get("action").and_then(Value::as_str), + Some("run" | "read" | "edit" | "run_ipython" | "think") + ) + && reconstructed + .get("tool_call_metadata") + .is_none_or(Value::is_null) + { + reconstructed["tool_call_metadata"] = + openhands_reconstructed_tool_metadata(&reconstructed)?; + } + prepared_events.push(reconstructed); + } + } + let prepared = context + .output_dir + .join("native/prepared-replay-events.json"); + atomic_write_json(&prepared, &prepared_events)?; + journal.append( + "session_rebuilt", + [( + "prepared_only".into(), + json!(context.request.mode == ReplayMode::PrepareOnly), + )], + )?; + if context.request.mode == ReplayMode::PrepareOnly { + return Ok(prepared_outcome(prepared)); + } + let launch = context + .launch + .ok_or_else(|| ReplayError::continuation("OpenHands replay has no launch spec"))?; + let replayed_trajectory = match context.request.mode { + ReplayMode::ReplayOnly => context + .output_dir + .join("native/reconstructed-trajectory.json"), + ReplayMode::ReplayAndContinue => { + context.output_dir.join("native/continued-trajectory.json") + } + ReplayMode::PrepareOnly => unreachable!("prepare-only returned before OpenHands launch"), + }; + let mut command = agent_command(&launch.entrypoint, context); + command.args(["-m", "openhands.core.main"]); + command.env("REPLAY_TRAJECTORY_PATH", &prepared); + command.env("SAVE_TRAJECTORY_PATH", &replayed_trajectory); + command.env("FILE_STORE", "local"); + command.env( + "FILE_STORE_PATH", + context.state_dir.join("openhands-file-store"), + ); + command.env("RUNTIME", "local"); + command.env("SU_TO_USER", "false"); + command.env("RUN_AS_OPENHANDS", "false"); + command.env("SKIP_DEPENDENCY_CHECK", "1"); + command.env("INIT_PLUGIN_TIMEOUT", "240"); + command.env("AGENT_ENABLE_PROMPT_EXTENSIONS", "false"); + command.env("AGENT_ENABLE_BROWSING", "false"); + command.env("ENABLE_BROWSER", "false"); + command.env("SANDBOX_ENABLE_AUTO_LINT", "true"); + command.env( + "SANDBOX_VOLUMES", + format!("{}:/workspace:rw", context.request.workspace.display()), + ); + prepend_openhands_runtime_tools(&mut command, launch)?; + command.env( + "OPENAI_CUSTOM_HEADERS", + format!("X-LiteLLM-Session-ID: {}", context.session_id), + ); + let iteration_limit = match context.request.mode { + ReplayMode::ReplayOnly => Some(plan.prefix_model_turns), + ReplayMode::ReplayAndContinue => context.request.max_steps, + ReplayMode::PrepareOnly => None, + }; + if let Some(max) = iteration_limit { + command.env("MAX_ITERATIONS", max.to_string()); + } + journal.append("continuation_started", std::iter::empty())?; + let log = context.output_dir.join("logs/openhands.log"); + fs::create_dir_all(log.parent().expect("OpenHands log has a parent")) + .replay_context(ReplayErrorKind::Executor, "create OpenHands log directory")?; + let output = run_process(ProcessSpec { + command, + stdin: Some(b"\n".to_vec()), + timeout: Duration::from_secs(24 * 60 * 60), + termination_grace: Duration::from_secs(2), + pipe_grace: Duration::from_millis(250), + retained_bytes: MAX_TOOL_OUTPUT_BYTES / 2, + log_path: log.clone(), + }) + .map_err(|error| ReplayError::new(ReplayErrorKind::Continuation, error.message))?; + let mut rendered = String::from_utf8_lossy(&output.stdout_tail).into_owned(); + if !output.stderr_tail.is_empty() { + rendered.push('\n'); + rendered.push_str(&String::from_utf8_lossy(&output.stderr_tail)); + } + let fatal_marker = openhands_fatal_controller_marker(&rendered); + if output.timed_out || !output.status.success() || !replayed_trajectory.is_file() { + let detail = fatal_marker + .map(|marker| format!("; OpenHands controller reported {marker:?}")) + .unwrap_or_default(); + return Err(ReplayError::classify_continuation( + format!( + "OpenHands replay/continuation exited {}{detail}; see {}", + output.status, + log.display() + ), + &rendered, + )); + } + if let Some(marker) = fatal_marker { + return Err(ReplayError::classify_continuation( + format!( + "OpenHands controller reported {marker:?} despite exiting successfully; partial trajectory retained at {}; see {}", + replayed_trajectory.display(), + log.display() + ), + &rendered, + )); + } + let continued_events: Vec = + serde_json::from_slice(&read_regular_file(&replayed_trajectory)?).replay_context( + ReplayErrorKind::Trajectory, + "parse replayed OpenHands trajectory", + )?; + let complete = openhands_complete_batches(&continued_events)?; + if complete.len() < plan.after_step { + return Err(ReplayError::trajectory( + "OpenHands output lost replayed action/observation batches", + )); + } + if context.request.mode == ReplayMode::ReplayOnly && complete.len() != plan.after_step { + return Err(ReplayError::continuation(format!( + "OpenHands replay-only crossed the selected boundary: expected {} actions, observed {}", + plan.after_step, + complete.len() + ))); + } + if context + .request + .max_steps + .is_some_and(|max_steps| complete.len() > max_steps) + { + return Err(ReplayError::continuation(format!( + "OpenHands exceeded the total max_steps budget: allowed {}, observed {} actions", + context.request.max_steps.unwrap(), + complete.len() + ))); + } + let replayed = &complete[..plan.after_step]; + let observations = plan + .calls() + .zip(replayed.iter()) + .map(|(call, (_, observation))| FreshObservation { + call_id: call.call_id.clone(), + content: openhands_observation_content(observation), + is_error: observation.get("observation").and_then(Value::as_str) == Some("error"), + return_code: None, + duration_ms: 0, + truncated: false, + metadata: BTreeMap::new(), + }) + .collect::>(); + let comparisons = plan + .calls() + .zip(&observations) + .map(|(call, fresh)| { + json!({ + "call_id": call.call_id, + "tool": call.name, + "exact": call.original_observation == fresh.content + && call.original_is_error == fresh.is_error, + "original_is_error": call.original_is_error, + "replayed_is_error": fresh.is_error, + }) + }) + .collect::>(); + atomic_write_json( + &context.output_dir.join("observation-comparison.json"), + &comparisons, + )?; + let continued_steps = complete.len() - plan.after_step; + journal.append( + "continuation_finished", + [ + ("continued_steps".into(), json!(continued_steps)), + ("agent_error".into(), Value::Null), + ], + )?; + let reached_max_steps = context.request.mode == ReplayMode::ReplayAndContinue + && context + .request + .max_steps + .is_some_and(|max_steps| complete.len() == max_steps) + && rendered.contains("Agent reached maximum iteration"); + Ok(ReplayOutcome { + status: if context.request.mode == ReplayMode::ReplayOnly { + "replayed".into() + } else if reached_max_steps { + "max_steps".into() + } else { + "completed".into() + }, + reconstructed_path: (context.request.mode == ReplayMode::ReplayOnly) + .then_some(replayed_trajectory.clone()), + continued_path: (context.request.mode == ReplayMode::ReplayAndContinue) + .then_some(replayed_trajectory), + observations, + continued_steps, + metadata: json!({}), + }) +} + +fn openhands_fatal_controller_marker(output: &str) -> Option<&'static str> { + if output.contains("Agent reached maximum iteration") { + return None; + } + [ + "AgentState.ERROR", + "Error while running the agent", + "There was an unexpected error while running the agent", + ] + .into_iter() + .find(|marker| output.contains(marker)) +} + +fn openhands_observation_content(observation: &Value) -> Value { + json!({ + "observation": observation.get("observation"), + "message": observation.get("message"), + "args": observation.get("args"), + }) +} + +fn openhands_complete_batches(events: &[Value]) -> Result, ReplayError> { + let mut observations = BTreeMap::new(); + for event in events { + let Some(cause) = event + .get("observation") + .filter(|value| !value.is_null()) + .and_then(|_| event.get("cause")) + .and_then(Value::as_i64) + else { + continue; + }; + if observations.insert(cause, event).is_some() { + return Err(ReplayError::trajectory(format!( + "multiple OpenHands observations for action {cause}" + ))); + } + } + + let supported = ["run", "read", "edit", "run_ipython", "think"]; + let mut batches = Vec::new(); + for event in events { + let action = event.get("action").and_then(Value::as_str); + if event.get("source").and_then(Value::as_str) != Some("agent") + || matches!(action, None | Some("system" | "finish" | "message")) + { + continue; + } + let action = action.unwrap(); + if !supported.contains(&action) { + return Err(ReplayError::new( + ReplayErrorKind::UnsupportedVersion, + format!("unsupported OpenHands action {action:?}"), + )); + } + let id = event_id(event)?; + let Some(observation) = observations.get(&id) else { + break; + }; + batches.push((event, *observation)); + } + Ok(batches) +} + +fn prepend_openhands_runtime_tools( + command: &mut Command, + launch: &LaunchSpec, +) -> Result<(), ReplayError> { + let inferred_root = launch + .entrypoint + .parent() + .and_then(Path::parent) + .unwrap_or_else(|| Path::new("/")); + let tools = launch + .runtime_root + .as_deref() + .unwrap_or(inferred_root) + .join("tools"); + if !tools.is_dir() { + return Ok(()); + } + let current = std::env::var_os("PATH").unwrap_or_else(|| "/usr/bin:/bin".into()); + let paths = std::iter::once(tools.clone()).chain(std::env::split_paths(¤t)); + let path = std::env::join_paths(paths).map_err(|error| { + ReplayError::configuration(format!( + "cannot prepend OpenHands runtime tools {} to PATH: {error}", + tools.display() + )) + })?; + command.env("PATH", path); + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::fs; + use std::process::Command; + + use serde_json::{json, Value}; + + use super::{ + openhands_action_signature, openhands_complete_batches, openhands_fatal_controller_marker, + openhands_observation_content, openhands_reconstructed_tool_metadata, + prepend_openhands_runtime_tools, LaunchSpec, + }; + + #[test] + fn openhands_reconstructs_legacy_native_tool_metadata() { + let event = json!({ + "id": 7, + "source": "agent", + "action": "read", + "args": {"path": "/workspace/file", "view_range": [1, 2], "thought": "inspect"}, + }); + let metadata = openhands_reconstructed_tool_metadata(&event).unwrap(); + assert_eq!(metadata["function_name"], "str_replace_editor"); + assert_eq!(metadata["tool_call_id"], "sandbox-playback-replay-7"); + let arguments = metadata["model_response"]["choices"][0]["message"]["tool_calls"][0] + ["function"]["arguments"] + .as_str() + .unwrap(); + let arguments: Value = serde_json::from_str(arguments).unwrap(); + assert_eq!(arguments["command"], "view"); + assert_eq!(arguments["path"], "/workspace/file"); + } + + #[test] + fn openhands_signature_separates_visible_text_reasoning_and_tool_arguments() { + let event = json!({ + "id": 7, + "source": "agent", + "action": "run", + "args": {"command": "pwd", "thought": "legacy thought"}, + "tool_call_metadata": { + "model_response": { + "choices": [{ + "message": { + "content": "visible preamble", + "reasoning_content": "hidden reasoning" + } + }] + } + } + }); + + let signature = openhands_action_signature(&event); + + assert_eq!(signature["text"], "visible preamble"); + assert_eq!(signature["reasoning"], "hidden reasoning"); + assert_eq!( + signature["tools"][0]["arguments"], + json!({"command": "pwd"}) + ); + } + + #[test] + fn openhands_complete_batches_preserve_fresh_observations() { + let events = vec![ + json!({ + "id": 5, + "source": "agent", + "action": "run", + "args": {"command": "pwd"}, + }), + json!({ + "id": 6, + "source": "environment", + "observation": "run", + "cause": 5, + "message": "ok", + "args": {"command": "pwd", "metadata": {"exit_code": 0}}, + }), + ]; + let batches = openhands_complete_batches(&events).unwrap(); + assert_eq!(batches.len(), 1); + assert_eq!(batches[0].0["id"], 5); + assert_eq!( + openhands_observation_content(batches[0].1), + json!({ + "observation": "run", + "message": "ok", + "args": {"command": "pwd", "metadata": {"exit_code": 0}}, + }) + ); + } + + #[test] + fn openhands_runtime_tools_are_prepended_to_path() { + let runtime = tempfile::tempdir().unwrap(); + let bin = runtime.path().join("bin/openhands-python"); + fs::create_dir_all(bin.parent().unwrap()).unwrap(); + fs::create_dir(runtime.path().join("tools")).unwrap(); + let launch = LaunchSpec { + entrypoint: bin, + version: "0.53.0".into(), + source: "explicit_entrypoint".into(), + runtime_root: None, + }; + let mut command = Command::new(&launch.entrypoint); + prepend_openhands_runtime_tools(&mut command, &launch).unwrap(); + let path = command + .get_envs() + .find_map(|(name, value)| { + (name == "PATH").then(|| value.expect("PATH value").to_os_string()) + }) + .expect("PATH override"); + let first = std::env::split_paths(&path).next().unwrap(); + assert_eq!(first, runtime.path().join("tools")); + } + + #[test] + fn openhands_zero_exit_controller_errors_are_detected_for_partial_results() { + assert_eq!( + openhands_fatal_controller_marker("Error while running the agent"), + Some("Error while running the agent") + ); + assert_eq!( + openhands_fatal_controller_marker("Agent reached maximum iteration AgentState.ERROR"), + None + ); + } +} diff --git a/crates/persisting-replay/src/adapter/runtime.rs b/crates/persisting-replay/src/adapter/runtime.rs new file mode 100644 index 00000000..2f7792a7 --- /dev/null +++ b/crates/persisting-replay/src/adapter/runtime.rs @@ -0,0 +1,532 @@ +use std::collections::BTreeMap; +use std::ffi::OsString; +use std::fs; +use std::path::{Component, Path, PathBuf}; +use std::process::Command; + +use serde::Deserialize; + +use crate::error::{ReplayError, ReplayErrorKind, ResultExt}; +use crate::io::{canonicalize, read_regular_file}; +use crate::model::{AgentKind, PlaybackRequest, ReplayMode}; + +#[derive(Debug, Clone)] +pub(crate) struct LaunchSpec { + pub entrypoint: PathBuf, + pub version: String, + pub source: String, + pub runtime_root: Option, +} + +pub(crate) fn resolve_launch_spec( + request: &PlaybackRequest, +) -> Result, ReplayError> { + if request.agent_entrypoint.is_some() && request.agent_runtime.is_some() { + return Err(ReplayError::configuration( + "agent entrypoint and agent runtime are mutually exclusive", + )); + } + if request.mode == ReplayMode::PrepareOnly { + return Ok(None); + } + let (entrypoint, source, runtime_root, declared_version) = + if let Some(runtime_root) = &request.agent_runtime { + let root = canonicalize( + runtime_root, + ReplayErrorKind::Configuration, + "agent runtime", + )?; + let manifest_path = root.join("sandbox-playback-agent.json"); + let manifest: RuntimeManifest = + serde_json::from_slice(&read_regular_file(&manifest_path)?).replay_context( + ReplayErrorKind::Configuration, + format!("parse agent runtime manifest {}", manifest_path.display()), + )?; + if manifest.schema_version != "sandbox-playback.agent-runtime/v1" { + return Err(ReplayError::configuration( + "agent runtime schema_version must be sandbox-playback.agent-runtime/v1", + )); + } + if manifest.agent != request.agent.as_str() { + return Err(ReplayError::new( + ReplayErrorKind::UnsupportedAgent, + format!( + "agent runtime declares {:?}, requested {:?}", + manifest.agent, + request.agent.as_str() + ), + )); + } + if manifest.version != request.agent.supported_version() { + return Err(ReplayError::new( + ReplayErrorKind::UnsupportedVersion, + format!( + "agent runtime declares {:?}; profile requires {}", + manifest.version, + request.agent.supported_version() + ), + )); + } + let relative = safe_relative(&manifest.entrypoint)?; + ( + root.join(relative), + "runtime_manifest".to_owned(), + Some(root), + Some(manifest.version), + ) + } else { + let entrypoint = request.agent_entrypoint.clone().ok_or_else(|| { + ReplayError::configuration( + "replay and continuation modes require --agent-entrypoint or --agent-runtime", + ) + })?; + (entrypoint, "explicit_entrypoint".to_owned(), None, None) + }; + if !entrypoint.is_absolute() { + return Err(ReplayError::configuration( + "agent entrypoint must be an absolute path", + )); + } + let entrypoint = canonicalize( + &entrypoint, + ReplayErrorKind::Configuration, + "agent entrypoint", + )?; + if !entrypoint.is_file() { + return Err(ReplayError::configuration(format!( + "agent entrypoint is not a regular file: {}", + entrypoint.display() + ))); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if entrypoint + .metadata() + .map(|metadata| metadata.permissions().mode() & 0o111 == 0) + .unwrap_or(true) + { + return Err(ReplayError::configuration(format!( + "agent entrypoint is not executable: {}", + entrypoint.display() + ))); + } + } + let version = probe_version(request.agent, &entrypoint)?; + if declared_version + .as_deref() + .is_some_and(|declared| declared != version) + { + return Err(ReplayError::new( + ReplayErrorKind::UnsupportedVersion, + "agent runtime manifest and executable versions differ", + )); + } + Ok(Some(LaunchSpec { + entrypoint, + version, + source, + runtime_root, + })) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct RuntimeManifest { + schema_version: String, + agent: String, + version: String, + entrypoint: PathBuf, + #[serde(default, rename = "paths")] + _paths: BTreeMap, +} + +pub(super) fn safe_relative(path: &Path) -> Result { + if path.as_os_str().is_empty() + || path.is_absolute() + || path.components().any(|component| { + matches!( + component, + Component::ParentDir | Component::RootDir | Component::Prefix(_) + ) + }) + { + return Err(ReplayError::configuration( + "agent runtime entrypoint must be a non-empty relative path without '..'", + )); + } + Ok(path.to_path_buf()) +} + +fn probe_version(agent: AgentKind, entrypoint: &Path) -> Result { + let expected = agent.supported_version(); + let mut command = Command::new(entrypoint); + match agent { + AgentKind::ClaudeCode | AgentKind::MiniSweAgent => { + command.arg("--version"); + } + AgentKind::Openhands => { + command.args([ + "-c", + "import importlib.metadata;print(importlib.metadata.version('openhands-ai'))", + ]); + } + AgentKind::SweAgent => { + command.args([ + "-c", + "import importlib.metadata;print(importlib.metadata.version('sweagent'))", + ]); + } + } + command.env_remove("PYTHONHOME"); + command.env_remove("PYTHONPATH"); + command.env_remove("VIRTUAL_ENV"); + if agent == AgentKind::MiniSweAgent { + let runtime = mini_python_runtime(entrypoint)?; + configure_mini_python_environment(&mut command, &runtime)?; + } + let output = command.output().replay_context( + ReplayErrorKind::UnsupportedVersion, + format!( + "probe {} version from {}", + agent.as_str(), + entrypoint.display() + ), + )?; + let rendered = String::from_utf8_lossy(if output.stdout.is_empty() { + &output.stderr + } else { + &output.stdout + }); + let detected = parse_version(agent, &rendered); + let status_is_acceptable = + output.status.success() || (agent == AgentKind::MiniSweAgent && detected == Some(expected)); + if !status_is_acceptable || detected != Some(expected) { + return Err(ReplayError::new( + ReplayErrorKind::UnsupportedVersion, + format!( + "{} profile requires {}, got {:?} from {}", + agent.as_str(), + expected, + rendered.trim(), + entrypoint.display() + ), + )); + } + Ok(expected.to_owned()) +} + +fn parse_version(agent: AgentKind, rendered: &str) -> Option<&'static str> { + let expected = agent.supported_version(); + match agent { + AgentKind::ClaudeCode => { + let mut lines = rendered.trim().lines(); + let first = lines.next()?.trim(); + let token = first.split_whitespace().next()?; + (lines.next().is_none() && token == expected).then_some(expected) + } + AgentKind::MiniSweAgent => { + const PREFIX: &str = "This is mini-swe-agent version "; + let mut versions = rendered.lines().filter_map(|line| { + line.trim() + .strip_prefix(PREFIX)? + .split_whitespace() + .next() + .map(|version| version.trim_end_matches('.')) + }); + let version = versions.next()?; + (versions.next().is_none() && version == expected).then_some(expected) + } + AgentKind::Openhands | AgentKind::SweAgent => { + (rendered.trim() == expected).then_some(expected) + } + } +} + +#[derive(Debug)] +pub(super) struct MiniPythonRuntime { + pub python: PathBuf, + pub loader: Option, + pub python_home: Option, + pub virtual_env: Option, + library_paths: Vec, +} + +pub(super) fn mini_python_runtime(entrypoint: &Path) -> Result { + if let Some(local_root) = entrypoint.parent().and_then(Path::parent) { + let uv_root = local_root.join("share/uv"); + let virtual_env = uv_root.join("tools/mini-swe-agent"); + let python = virtual_env.join("bin/python"); + if python.is_file() { + let python = fs::canonicalize(&python).replay_context( + ReplayErrorKind::Continuation, + format!( + "resolve bundled mini-swe-agent Python from {}", + python.display() + ), + )?; + let python_home = python + .parent() + .and_then(Path::parent) + .ok_or_else(|| ReplayError::continuation("bundled Python has no prefix"))? + .to_path_buf(); + if !python_home.join("lib/python3.12/encodings").is_dir() { + return Err(ReplayError::continuation(format!( + "bundled mini-swe-agent Python has no standard library below {}", + python_home.display() + ))); + } + let loader = uv_root.join("sweeval-system-libs/ld-linux-x86-64.so.2"); + if !loader.is_file() { + return Err(ReplayError::continuation(format!( + "bundled mini-swe-agent Python loader does not exist: {}", + loader.display() + ))); + } + return Ok(MiniPythonRuntime { + python, + loader: Some(loader), + python_home: Some(python_home.clone()), + virtual_env: Some(virtual_env), + library_paths: vec![uv_root.join("sweeval-system-libs"), python_home.join("lib")], + }); + } + } + + let prefix = read_regular_file(entrypoint)?; + if let Some(first) = prefix.split(|byte| *byte == b'\n').next() { + if let Some(shebang) = first.strip_prefix(b"#!") { + let rendered = String::from_utf8_lossy(shebang); + let words: Vec<_> = rendered.split_whitespace().collect(); + if words.first() == Some(&"/usr/bin/env") { + if let Some(program) = words.get(1) { + if program.contains("python") { + return Ok(MiniPythonRuntime { + python: PathBuf::from(program), + loader: None, + python_home: None, + virtual_env: None, + library_paths: Vec::new(), + }); + } + } + } else if let Some(program) = words.first() { + if program.contains("python") { + return Ok(MiniPythonRuntime { + python: PathBuf::from(program), + loader: None, + python_home: None, + virtual_env: None, + library_paths: Vec::new(), + }); + } + } + } + } + for name in ["python3", "python"] { + let candidate = entrypoint.parent().unwrap_or(Path::new("/")).join(name); + if candidate.is_file() { + return Ok(MiniPythonRuntime { + python: candidate, + loader: None, + python_home: None, + virtual_env: None, + library_paths: Vec::new(), + }); + } + } + Err(ReplayError::continuation( + "mini-swe-agent entrypoint does not expose its Python interpreter", + )) +} + +pub(super) fn mini_python_library_path( + runtime: &MiniPythonRuntime, +) -> Result, ReplayError> { + let paths = runtime + .library_paths + .iter() + .filter(|path| path.is_dir()) + .collect::>(); + if paths.is_empty() { + return Ok(None); + } + std::env::join_paths(paths).map(Some).map_err(|error| { + ReplayError::configuration(format!( + "cannot construct mini-swe-agent Python library path: {error}" + )) + }) +} + +pub(super) fn configure_mini_python_environment( + command: &mut Command, + runtime: &MiniPythonRuntime, +) -> Result<(), ReplayError> { + if let Some(python_home) = &runtime.python_home { + command.env("PYTHONHOME", python_home); + } + if let Some(virtual_env) = &runtime.virtual_env { + command.env("VIRTUAL_ENV", virtual_env); + command.env( + "PYTHONPATH", + virtual_env.join("lib/python3.12/site-packages"), + ); + let current = std::env::var_os("PATH").unwrap_or_else(|| "/usr/bin:/bin".into()); + let paths = std::iter::once(virtual_env.join("bin")).chain(std::env::split_paths(¤t)); + let path = std::env::join_paths(paths).map_err(|error| { + ReplayError::configuration(format!( + "cannot prepend mini-swe-agent virtual environment to PATH: {error}" + )) + })?; + command.env("PATH", path); + } + if let Some(library_path) = mini_python_library_path(runtime)? { + command.env("LD_LIBRARY_PATH", library_path); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::{fs, process::Command}; + + use super::{ + configure_mini_python_environment, mini_python_runtime, parse_version, resolve_launch_spec, + }; + use crate::model::{AgentKind, PlaybackRequest, ReplayMode}; + + #[test] + fn mini_version_probe_accepts_exact_banner_before_config_noise() { + let output = "This is mini-swe-agent version 2.4.6.\n\ +Check the v2 migration guide at https://example.invalid\n\ +Loading global config from '/root/.config/mini-swe-agent/.env'"; + assert_eq!( + parse_version(AgentKind::MiniSweAgent, output), + Some("2.4.6") + ); + } + + #[cfg(unix)] + #[test] + fn mini_python_runtime_finds_the_portable_uv_bundle() { + use std::os::unix::fs::symlink; + + let root = tempfile::tempdir().unwrap(); + let local = root.path().join(".local"); + let entrypoint = local.join("bin/mini-swe-agent"); + let virtual_env = local.join("share/uv/tools/mini-swe-agent"); + let python_home = local.join("share/uv/python/cpython-3.12.11"); + let python = python_home.join("bin/python3.12"); + fs::create_dir_all(entrypoint.parent().unwrap()).unwrap(); + fs::create_dir_all(virtual_env.join("bin")).unwrap(); + fs::create_dir_all(virtual_env.join("lib/python3.12/site-packages")).unwrap(); + fs::create_dir_all(python_home.join("lib/python3.12/encodings")).unwrap(); + fs::create_dir_all(python.parent().unwrap()).unwrap(); + let loader = local.join("share/uv/sweeval-system-libs/ld-linux-x86-64.so.2"); + fs::create_dir_all(loader.parent().unwrap()).unwrap(); + fs::write(&loader, "loader").unwrap(); + fs::write(&entrypoint, "#!/bin/sh\nexit 0\n").unwrap(); + fs::write(&python, "python").unwrap(); + symlink(&python, virtual_env.join("bin/python")).unwrap(); + + let runtime = mini_python_runtime(&entrypoint).unwrap(); + let canonical_python_home = fs::canonicalize(&python_home).unwrap(); + assert_eq!(runtime.python, fs::canonicalize(&python).unwrap()); + assert_eq!( + runtime.python_home.as_deref(), + Some(canonical_python_home.as_path()) + ); + assert_eq!(runtime.loader.as_deref(), Some(loader.as_path())); + assert_eq!(runtime.virtual_env.as_deref(), Some(virtual_env.as_path())); + let mut command = Command::new(&runtime.python); + configure_mini_python_environment(&mut command, &runtime).unwrap(); + assert!(command.get_envs().any(|(name, value)| { + name == "PYTHONHOME" && value == Some(canonical_python_home.as_os_str()) + })); + let path = command + .get_envs() + .find_map(|(name, value)| { + (name == "PATH").then(|| value.expect("PATH value").to_os_string()) + }) + .expect("PATH override"); + assert_eq!( + std::env::split_paths(&path).next().unwrap(), + virtual_env.join("bin") + ); + } + + #[cfg(unix)] + use std::os::unix::fs::PermissionsExt; + + #[test] + fn version_probes_require_exact_banners() { + assert_eq!( + parse_version(AgentKind::ClaudeCode, "2.1.220 (Claude Code)"), + Some("2.1.220") + ); + assert_eq!( + parse_version(AgentKind::ClaudeCode, "12.1.220 (Claude Code)"), + None + ); + assert_eq!( + parse_version(AgentKind::Openhands, "0.53.0\n"), + Some("0.53.0") + ); + assert_eq!( + parse_version( + AgentKind::Openhands, + "warning about 0.53.0; actual runtime 0.54.0" + ), + None + ); + assert_eq!( + parse_version( + AgentKind::MiniSweAgent, + "This is mini-swe-agent version 2.4.6.\n" + ), + Some("2.4.6") + ); + assert_eq!(parse_version(AgentKind::SweAgent, "1.1.0"), Some("1.1.0")); + assert_eq!(parse_version(AgentKind::SweAgent, "swe-agent 1.1.0"), None); + } + + #[cfg(unix)] + #[test] + fn prepare_only_never_starts_a_supplied_runtime() { + let temporary = tempfile::tempdir().unwrap(); + let marker = temporary.path().join("started"); + let entrypoint = temporary.path().join("claude"); + fs::write( + &entrypoint, + format!( + "#!/bin/sh\ntouch '{}'\nprintf '2.1.220 (Claude Code)\\n'\n", + marker.display() + ), + ) + .unwrap(); + let mut permissions = fs::metadata(&entrypoint).unwrap().permissions(); + permissions.set_mode(0o700); + fs::set_permissions(&entrypoint, permissions).unwrap(); + let request = PlaybackRequest { + agent: AgentKind::ClaudeCode, + trajectory: temporary.path().join("trajectory"), + after_step: 1, + workspace: temporary.path().to_path_buf(), + state_dir: temporary.path().join("state"), + output_dir: temporary.path().join("output"), + agent_entrypoint: Some(entrypoint), + agent_runtime: None, + disallowed_tools: Vec::new(), + trajectory_assets: None, + session_id: None, + max_steps: None, + mode: ReplayMode::PrepareOnly, + allow_stale_observations: false, + run_id: None, + disable_thinking: false, + }; + + assert!(resolve_launch_spec(&request).unwrap().is_none()); + assert!(!marker.exists()); + } +} diff --git a/crates/persisting-replay/src/adapter/swe_agent.rs b/crates/persisting-replay/src/adapter/swe_agent.rs new file mode 100644 index 00000000..94625887 --- /dev/null +++ b/crates/persisting-replay/src/adapter/swe_agent.rs @@ -0,0 +1,215 @@ +use std::path::Path; + +use serde_json::{json, Value}; + +use super::runtime::safe_relative; +use super::{check_boundary, prepared_outcome, run_sdk_bridge, RunContext}; +use crate::error::{ReplayError, ReplayErrorKind, ResultExt}; +use crate::io::{atomic_write_json, canonicalize, read_regular_file, sha256}; +use crate::journal::Journal; +use crate::model::{ + AdapterPlan, AgentKind, PlaybackRequest, ReplayMode, ReplayOutcome, ReplayPlan, ToolBatch, + ToolCall, +}; + +pub(super) fn build(request: &PlaybackRequest) -> Result { + build_swe_plan(request).map(AdapterPlan::SweAgent) +} + +pub(super) fn execute( + plan: &ReplayPlan, + context: &RunContext<'_>, + journal: &mut Journal, +) -> Result { + run_swe(plan, context, journal) +} + +fn build_swe_plan(request: &PlaybackRequest) -> Result { + let raw = read_regular_file(&request.trajectory)?; + let mut value: Value = serde_json::from_slice(&raw).replay_context( + ReplayErrorKind::Trajectory, + "invalid SWE-agent trajectory JSON", + )?; + for field in ["trajectory", "history", "replay_config"] { + if value.get(field).is_none() { + return Err(ReplayError::trajectory(format!( + "SWE-agent trajectory is missing {field}" + ))); + } + } + resolve_swe_problem_asset(&mut value, request.trajectory_assets.as_deref())?; + let trajectory = value["trajectory"] + .as_array() + .ok_or_else(|| ReplayError::trajectory("SWE-agent trajectory must be an array"))?; + let history: Vec<_> = value["history"] + .as_array() + .ok_or_else(|| ReplayError::trajectory("SWE-agent history must be an array"))? + .iter() + .filter(|item| item.get("role").and_then(Value::as_str) == Some("assistant")) + .collect(); + check_boundary(request.after_step, trajectory.len().min(history.len()))?; + let original_next_action = trajectory.get(request.after_step).map(|step| { + json!({ + "text": "", + "reasoning": step.get("thought").and_then(Value::as_str).unwrap_or_default(), + "tools": [{ + "name": "swe_agent_action", + "arguments": {"raw_action": step.get("action").cloned().unwrap_or(Value::Null)}, + }], + }) + }); + let mut batches = Vec::new(); + for index in 0..request.after_step { + let step = &trajectory[index]; + let assistant = history[index]; + let action = step + .get("action") + .and_then(Value::as_str) + .ok_or_else(|| ReplayError::trajectory("SWE-agent step has no action"))?; + if action.trim() == "submit" || action.trim_start().starts_with("submit\n") { + return Err(ReplayError::new( + ReplayErrorKind::UnsupportedVersion, + "SWE-agent submit cannot appear inside a replay prefix", + )); + } + let observation = step + .get("observation") + .and_then(Value::as_str) + .ok_or_else(|| ReplayError::trajectory("SWE-agent step has no observation"))?; + let calls = assistant + .get("tool_calls") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + let call_id = if calls.len() == 1 { + calls[0] + .get("id") + .and_then(Value::as_str) + .map(str::to_owned) + .unwrap_or_else(|| format!("swe-agent-step-{}", index + 1)) + } else { + format!("swe-agent-step-{}", index + 1) + }; + batches.push(ToolBatch { + ordinal: index + 1, + native_locator: format!("trajectory:{index}"), + tool_calls: vec![ToolCall { + ordinal: index + 1, + call_id, + name: "swe_agent_action".into(), + arguments: json!({"raw_action": action}), + original_observation: Value::String(observation.to_owned()), + original_is_error: false, + native: json!({"assistant": assistant}), + }], + assistant_text: step + .get("thought") + .and_then(Value::as_str) + .or_else(|| assistant.get("content").and_then(Value::as_str)) + .unwrap_or_default() + .to_owned(), + native: json!({"state": step.get("state")}), + }); + } + Ok(ReplayPlan { + agent: request.agent, + source_path: canonicalize( + &request.trajectory, + ReplayErrorKind::Trajectory, + "trajectory", + )?, + source_sha256: sha256(&raw), + after_step: request.after_step, + prefix_model_turns: request.after_step, + batches, + native: value, + original_next_action, + }) +} + +fn resolve_swe_problem_asset(value: &mut Value, assets: Option<&Path>) -> Result<(), ReplayError> { + let replay_config = value + .get_mut("replay_config") + .ok_or_else(|| ReplayError::trajectory("SWE-agent replay_config is required"))?; + if replay_config.is_string() { + let encoded = replay_config.as_str().unwrap(); + *replay_config = serde_json::from_str(encoded).replay_context( + ReplayErrorKind::Trajectory, + "invalid encoded SWE-agent replay_config", + )?; + } + let Some(problem) = replay_config.get_mut("problem_statement") else { + return Ok(()); + }; + if !matches!( + problem.get("type").and_then(Value::as_str), + Some("file" | "path") + ) { + return Ok(()); + } + let root = assets.ok_or_else(|| { + ReplayError::trajectory("SWE-agent file problem_statement requires trajectory_assets") + })?; + let relative = problem + .get("path") + .or_else(|| problem.get("file")) + .and_then(Value::as_str) + .ok_or_else(|| ReplayError::trajectory("SWE-agent problem asset path is invalid"))?; + let relative = safe_relative(Path::new(relative))?; + let root = canonicalize(root, ReplayErrorKind::Trajectory, "trajectory assets")?; + let path = canonicalize( + &root.join(relative), + ReplayErrorKind::Trajectory, + "trajectory asset", + )?; + if !path.starts_with(&root) { + return Err(ReplayError::trajectory( + "SWE-agent trajectory asset escapes its root", + )); + } + let text = String::from_utf8(read_regular_file(&path)?).replay_context( + ReplayErrorKind::Trajectory, + "SWE-agent problem asset is not UTF-8", + )?; + let id = problem + .get("id") + .cloned() + .unwrap_or_else(|| json!("replay")); + *problem = json!({"type": "text", "text": text, "id": id}); + Ok(()) +} + +fn run_swe( + plan: &ReplayPlan, + context: &RunContext<'_>, + journal: &mut Journal, +) -> Result { + let mut prepared = plan.native.clone(); + prepared["trajectory"] = + Value::Array(plan.native["trajectory"].as_array().unwrap()[..plan.after_step].to_vec()); + let mut assistant = 0; + let mut history = Vec::new(); + for item in plan.native["history"].as_array().unwrap() { + history.push(item.clone()); + if item.get("role").and_then(Value::as_str) == Some("assistant") { + assistant += 1; + if assistant == plan.after_step { + break; + } + } + } + prepared["history"] = Value::Array(history); + let path = context.output_dir.join("native/prepared-prefix.traj"); + atomic_write_json(&path, &prepared)?; + journal.append( + "session_rebuilt", + [( + "prepared_only".into(), + json!(context.request.mode == ReplayMode::PrepareOnly), + )], + )?; + if context.request.mode == ReplayMode::PrepareOnly { + return Ok(prepared_outcome(path)); + } + run_sdk_bridge(plan, context, journal, AgentKind::SweAgent) +} diff --git a/crates/persisting-replay/src/config.rs b/crates/persisting-replay/src/config.rs index b51d2395..6adadcda 100644 --- a/crates/persisting-replay/src/config.rs +++ b/crates/persisting-replay/src/config.rs @@ -5,7 +5,7 @@ use std::str::FromStr; use serde::Deserialize; use crate::error::{ReplayError, ReplayErrorKind, ResultExt}; -use crate::model::{AgentKind, PlaybackRequest, REQUEST_SCHEMA_VERSION}; +use crate::model::{AgentKind, PlaybackRequest, ReplayMode, REQUEST_SCHEMA_VERSION}; #[derive(Debug, Clone, Deserialize)] #[serde(deny_unknown_fields)] @@ -35,6 +35,10 @@ pub struct ReplayConfig { #[serde(default)] pub replay_only: bool, #[serde(default)] + pub prepare_only: bool, + #[serde(default)] + pub allow_stale_observations: bool, + #[serde(default)] pub disable_thinking: bool, pub run_id: Option, pub workspace: Option, @@ -85,6 +89,7 @@ impl ReplayToml { pub fn into_request(self, cwd: &Path) -> Result { let replay = self.replay; + let mode = replay_mode(replay.prepare_only, replay.replay_only)?; Ok(PlaybackRequest { agent: AgentKind::from_str(&replay.agent) .map_err(|message| ReplayError::new(ReplayErrorKind::UnsupportedAgent, message))?, @@ -103,7 +108,8 @@ impl ReplayToml { trajectory_assets: replay.trajectory_assets, session_id: replay.session_id, max_steps: replay.max_steps, - replay_only: replay.replay_only, + mode, + allow_stale_observations: replay.allow_stale_observations, run_id: replay.run_id, disable_thinking: replay.disable_thinking, }) @@ -126,6 +132,10 @@ struct JsonRequest { #[serde(default)] replay_only: bool, #[serde(default)] + prepare_only: bool, + #[serde(default)] + allow_stale_observations: bool, + #[serde(default)] disable_thinking: bool, run_id: Option, } @@ -155,6 +165,7 @@ pub fn request_from_json(path: &Path) -> Result { "request schema_version must be {REQUEST_SCHEMA_VERSION:?}" ))); } + let mode = replay_mode(request.prepare_only, request.replay_only)?; Ok(PlaybackRequest { agent: AgentKind::from_str(&request.agent.kind) .map_err(|message| ReplayError::new(ReplayErrorKind::UnsupportedAgent, message))?, @@ -169,15 +180,74 @@ pub fn request_from_json(path: &Path) -> Result { trajectory_assets: request.trajectory_assets, session_id: request.session_id, max_steps: request.max_steps, - replay_only: request.replay_only, + mode, + allow_stale_observations: request.allow_stale_observations, run_id: request.run_id, disable_thinking: request.disable_thinking, }) } +fn replay_mode(prepare_only: bool, replay_only: bool) -> Result { + match (prepare_only, replay_only) { + (true, true) => Err(ReplayError::configuration( + "prepare_only and replay_only are mutually exclusive", + )), + (true, false) => Ok(ReplayMode::PrepareOnly), + (false, true) => Ok(ReplayMode::ReplayOnly), + (false, false) => Ok(ReplayMode::ReplayAndContinue), + } +} + #[cfg(test)] mod tests { use super::*; + use crate::model::ReplayMode; + + #[test] + fn toml_maps_prepare_and_replay_modes_and_rejects_both() { + let prepare: ReplayToml = toml::from_str( + r#" +[replay] +agent = "claude-code" +trajectory = "/input/session.jsonl" +after_step = 1 +prepare_only = true +allow_stale_observations = true +"#, + ) + .unwrap(); + let prepare = prepare.into_request(Path::new("/workspace")).unwrap(); + assert_eq!(prepare.mode, ReplayMode::PrepareOnly); + assert!(prepare.allow_stale_observations); + + let replay: ReplayToml = toml::from_str( + r#" +[replay] +agent = "claude-code" +trajectory = "/input/session.jsonl" +after_step = 1 +replay_only = true +"#, + ) + .unwrap(); + assert_eq!( + replay.into_request(Path::new("/workspace")).unwrap().mode, + ReplayMode::ReplayOnly + ); + + let both: ReplayToml = toml::from_str( + r#" +[replay] +agent = "claude-code" +trajectory = "/input/session.jsonl" +after_step = 1 +prepare_only = true +replay_only = true +"#, + ) + .unwrap(); + assert!(both.into_request(Path::new("/workspace")).is_err()); + } #[test] fn minimal_toml_defaults_to_prepared_sandbox() { @@ -288,9 +358,36 @@ mode = "off" assert_eq!(request.max_steps, Some(200)); assert_eq!(request.session_id.as_deref(), Some("task-291-attempt-1")); assert_eq!(request.run_id.as_deref(), Some("sweeval")); + assert_eq!(request.mode, ReplayMode::ReplayAndContinue); assert!(!request.disable_thinking); } + #[test] + fn json_maps_prepare_mode_and_rejects_conflicting_modes() { + let temporary = tempfile::tempdir().unwrap(); + let path = temporary.path().join("request.json"); + let mut value = serde_json::json!({ + "schema_version": "sandbox-playback.request/v1", + "agent": { "type": "claude-code" }, + "trajectory": "/input/session.jsonl", + "after_step": 1, + "workspace": "/workspace", + "state_dir": "/state", + "output_dir": "/output", + "prepare_only": true, + "allow_stale_observations": true + }); + fs::write(&path, serde_json::to_vec(&value).unwrap()).unwrap(); + + let request = request_from_json(&path).unwrap(); + assert_eq!(request.mode, ReplayMode::PrepareOnly); + assert!(request.allow_stale_observations); + + value["replay_only"] = serde_json::Value::Bool(true); + fs::write(&path, serde_json::to_vec(&value).unwrap()).unwrap(); + assert!(request_from_json(&path).is_err()); + } + #[test] fn json_request_can_disable_thinking() { let temporary = tempfile::tempdir().unwrap(); diff --git a/crates/persisting-replay/src/engine.rs b/crates/persisting-replay/src/engine.rs index 21d38c79..a04eff42 100644 --- a/crates/persisting-replay/src/engine.rs +++ b/crates/persisting-replay/src/engine.rs @@ -10,11 +10,26 @@ use crate::error::{ReplayError, ReplayErrorKind, ResultExt}; use crate::io::{atomic_write_json, canonicalize}; use crate::journal::Journal; use crate::model::{ - AgentKind, AgentResult, Artifact, PlaybackRequest, ReplayOutcome, ReplayResult, + AdapterPlan, AgentKind, AgentResult, AgentStatus, Artifact, ExecutionReport, PlaybackRequest, + ReplayFailure, ReplayMode, ReplayOutcome, ReplayPhase, ReplayQuality, ReplayResult, RESULT_SCHEMA_VERSION, }; -pub fn execute(mut request: PlaybackRequest) -> Result { +pub fn execute(request: PlaybackRequest) -> Result { + let run_id = request + .run_id + .clone() + .unwrap_or_else(|| format!("replay-{}", uuid::Uuid::new_v4().simple())); + let state_dir = location_hint(&request.state_dir, &run_id); + let output_dir = location_hint(&request.output_dir, &run_id); + execute_with_run_id(request, run_id.clone()) + .map_err(|error| error.with_default_locations(run_id, state_dir, output_dir)) +} + +fn execute_with_run_id( + mut request: PlaybackRequest, + run_id: String, +) -> Result { validate(&request)?; request.workspace = canonicalize(&request.workspace, ReplayErrorKind::Workspace, "workspace")?; request.trajectory = canonicalize( @@ -22,81 +37,106 @@ pub fn execute(mut request: PlaybackRequest) -> Result Ok(ExecutionReport { + result, + exit_code: 0, + }), + Err(error) => finalize_failure( + &request, + &run_id, + &state_dir, + &output_dir, + &plan, + launch.as_ref(), + &mut journal, + error, + ), + } +} + +fn execute_allocated( + request: &PlaybackRequest, + run_id: &str, + state_dir: &Path, + output_dir: &Path, + plan: &AdapterPlan, + launch: Option<&LaunchSpec>, + journal: &mut Journal, +) -> Result { journal.append( "run_started", [ ("run_id".into(), json!(run_id)), ("agent".into(), json!(request.agent.as_str())), - ("source_sha256".into(), json!(plan.source_sha256)), - ("after_step".into(), json!(plan.after_step)), + ("source_sha256".into(), json!(plan.source_sha256())), + ("after_step".into(), json!(plan.after_step())), ], )?; journal.append( "plan_validated", [ - ("profile".into(), json!(plan.agent.profile())), + ("profile".into(), json!(plan.agent().profile())), ("tool_calls".into(), json!(plan.calls().count())), ], )?; + atomic_write_json(&output_dir.join("manifest.json"), &plan.public_value())?; - let session_id = request.session_id.clone().unwrap_or_else(|| run_id.clone()); + let session_id = request + .session_id + .clone() + .unwrap_or_else(|| run_id.to_owned()); let nonce = format!("__PVISOR_NATIVE_REPLAY_{}__", uuid::Uuid::new_v4().simple()); let context = RunContext { - request: &request, - state_dir: &state_dir, - output_dir: &output_dir, - launch: launch.as_ref(), + request, + state_dir, + output_dir, + launch, session_id: &session_id, nonce: &nonce, }; - let outcome = run(&plan, &context, &mut journal)?; - write_next_action_comparison(&request, &plan, &outcome, &output_dir)?; + let outcome = run(plan, &context, journal)?; + write_next_action_comparison(request, plan, &outcome, output_dir)?; journal.append("run_finished", [("status".into(), json!(outcome.status))])?; - let journal_path = journal.path.clone(); - drop(journal); - fs::copy(&journal_path, output_dir.join("replay-events.jsonl")) + fs::copy(&journal.path, output_dir.join("replay-events.jsonl")) .replay_context(ReplayErrorKind::Executor, "copy replay journal to output")?; let comparison = read_comparison(&output_dir.join("observation-comparison.json")); @@ -113,7 +153,7 @@ pub fn execute(mut request: PlaybackRequest) -> Result Result Result, + journal: &mut Journal, + error: ReplayError, +) -> Result { + let _ = journal.append( + "run_failed", + [ + ("category".into(), json!(error.kind.category())), + ("message".into(), json!(error.message)), + ], + ); + let _ = fs::copy(&journal.path, output_dir.join("replay-events.jsonl")); + let result = failure_result( + request, + launch, + plan, + run_id.to_owned(), + state_dir.to_path_buf(), + output_dir.to_path_buf(), + &error, + ); + atomic_write_json(&output_dir.join("result.json"), &result)?; + Ok(ExecutionReport { + exit_code: error.exit_code(), + result, + }) +} + +fn phase_for_mode(mode: ReplayMode) -> ReplayPhase { + match mode { + ReplayMode::PrepareOnly => ReplayPhase::Prepared, + ReplayMode::ReplayOnly => ReplayPhase::Replayed, + ReplayMode::ReplayAndContinue => ReplayPhase::Continued, + } +} + +fn quality_for_outcome(outcome: &ReplayOutcome) -> ReplayQuality { + if outcome.observations.iter().any(|observation| { + observation + .metadata + .contains_key("opaque_source_observation") + || observation.metadata.contains_key("degradation_reason") + }) { + ReplayQuality::Degraded + } else { + ReplayQuality::Verified + } +} + +fn agent_status_for_outcome(mode: ReplayMode, outcome: &ReplayOutcome) -> AgentStatus { + if mode != ReplayMode::ReplayAndContinue { + AgentStatus::NotStarted + } else if outcome.status == "max_steps" { + AgentStatus::MaxSteps + } else { + AgentStatus::Completed + } +} + +fn failure_result( + request: &PlaybackRequest, + launch: Option<&LaunchSpec>, + plan: &AdapterPlan, + run_id: String, + state_dir: std::path::PathBuf, + output_dir: std::path::PathBuf, + error: &ReplayError, +) -> ReplayResult { + let artifacts = existing_artifacts(request, &output_dir); + let replay_completed = artifacts.iter().any(|artifact| { + matches!( + artifact.role.as_str(), + "reconstructed_native_trajectory" | "continued_native_trajectory" + ) + }); + ReplayResult { + schema_version: RESULT_SCHEMA_VERSION, + phase: if replay_completed { + ReplayPhase::Replayed + } else { + ReplayPhase::Prepared + }, + quality: ReplayQuality::Verified, + agent_status: if request.mode == ReplayMode::ReplayAndContinue && replay_completed { + AgentStatus::Failed + } else { + AgentStatus::NotStarted + }, + run_id, + agent: agent_result(request, launch), + after_step: plan.after_step(), + replayed_tool_calls: 0, + prefix_model_turns: plan.prefix_model_turns(), + continued_steps: 0, + state_dir, + output_dir: output_dir.clone(), + artifacts, + failure: Some(ReplayFailure { + category: error.kind.category().into(), + message: error.to_string(), + }), + retryable: error.kind.retryable(), + metadata: Value::Null, + } +} + fn write_next_action_comparison( request: &PlaybackRequest, - plan: &crate::model::ReplayPlan, + plan: &AdapterPlan, outcome: &ReplayOutcome, output_dir: &Path, ) -> Result<(), ReplayError> { let (Some(original), Some(continued_path)) = - (&plan.original_next_action, &outcome.continued_path) + (plan.original_next_action(), &outcome.continued_path) else { return Ok(()); }; let mut continued_request = request.clone(); continued_request.trajectory = continued_path.clone(); let continued_plan = build_plan(&continued_request)?; - let Some(replayed) = continued_plan.original_next_action else { + let Some(replayed) = continued_plan.original_next_action() else { return Ok(()); }; write_next_action( &output_dir.join("next-action-comparison.json"), original, - &replayed, + replayed, ) } @@ -237,6 +394,30 @@ fn validate(request: &PlaybackRequest) -> Result<(), ReplayError> { Ok(()) } +fn validate_step_budget( + mode: ReplayMode, + max_steps: Option, + prefix_steps: usize, +) -> Result<(), ReplayError> { + let Some(max_steps) = max_steps else { + return Ok(()); + }; + match mode { + ReplayMode::PrepareOnly => Ok(()), + ReplayMode::ReplayOnly if max_steps < prefix_steps => Err(ReplayError::configuration( + format!( + "max_steps {max_steps} is smaller than the selected replay prefix of {prefix_steps} steps" + ), + )), + ReplayMode::ReplayAndContinue if max_steps <= prefix_steps => { + Err(ReplayError::configuration(format!( + "max_steps {max_steps} leaves no live step after the selected replay prefix of {prefix_steps} steps" + ))) + } + _ => Ok(()), + } +} + fn absolute_or_current(path: &Path) -> Result { if path.is_absolute() { Ok(path.to_path_buf()) @@ -247,6 +428,16 @@ fn absolute_or_current(path: &Path) -> Result { } } +fn location_hint(path: &Path, run_id: &str) -> std::path::PathBuf { + if path.is_absolute() { + path.join(run_id) + } else { + std::env::current_dir() + .map(|cwd| cwd.join(path).join(run_id)) + .unwrap_or_else(|_| path.join(run_id)) + } +} + fn read_comparison(path: &Path) -> Vec { fs::read(path) .ok() @@ -263,7 +454,7 @@ fn agent_result(request: &PlaybackRequest, launch: Option<&LaunchSpec>) -> Agent entrypoint: launch.map(|launch| launch.entrypoint.clone()), launch_source: launch .map(|launch| launch.source.clone()) - .unwrap_or_else(|| "replay_only".into()), + .unwrap_or_else(|| "prepare_only".into()), disallowed_tools: request.disallowed_tools.clone(), } } @@ -296,13 +487,23 @@ fn artifacts( AgentKind::Openhands => "openhands/native-json-0.53.0", AgentKind::SweAgent => "swe-agent/native-traj-1.1.0", }; - if let Some(path) = &outcome.reconstructed_path { + let prepared_path = prepared_native_path(request.agent, output_dir); + if prepared_path.is_file() { artifacts.push(artifact( - "reconstructed_native_trajectory", + "prepared_native_prefix", native_format, - path.clone(), + prepared_path.clone(), )); } + if let Some(path) = &outcome.reconstructed_path { + if path != &prepared_path { + artifacts.push(artifact( + "reconstructed_native_trajectory", + native_format, + path.clone(), + )); + } + } if let Some(path) = &outcome.continued_path { artifacts.push(artifact( "continued_native_trajectory", @@ -337,6 +538,120 @@ fn artifacts( artifacts } +fn existing_artifacts(request: &PlaybackRequest, output_dir: &Path) -> Vec { + let mut artifacts = Vec::new(); + for (role, format, path) in [ + ( + "playback_plan", + "sandbox-playback/plan-v1", + output_dir.join("manifest.json"), + ), + ( + "replay_events", + "sandbox-playback/events-v1", + output_dir.join("replay-events.jsonl"), + ), + ( + "replay_summary", + "sandbox-playback/summary-v1", + output_dir.join("replay-summary.json"), + ), + ( + "observation_comparison", + "sandbox-playback/comparison-v1", + output_dir.join("observation-comparison.json"), + ), + ( + "next_action_comparison", + "sandbox-playback/next-action-v2", + output_dir.join("next-action-comparison.json"), + ), + ] { + if path.is_file() { + artifacts.push(artifact(role, format, path)); + } + } + + let native_format = match request.agent { + AgentKind::ClaudeCode => "claude-code/native-jsonl-2.1.220", + AgentKind::MiniSweAgent => "mini-swe-agent/native-json-2.4.6", + AgentKind::Openhands => "openhands/native-json-0.53.0", + AgentKind::SweAgent => "swe-agent/native-traj-1.1.0", + }; + let native_paths: &[(&str, &str)] = match request.agent { + AgentKind::ClaudeCode => &[ + ("prepared_native_prefix", "native/prepared-prefix.jsonl"), + ( + "reconstructed_native_trajectory", + "native/reconstructed-prefix.jsonl", + ), + ( + "continued_native_trajectory", + "native/continued-session.jsonl", + ), + ], + AgentKind::MiniSweAgent => &[ + ("prepared_native_prefix", "native/prepared-prefix.json"), + ( + "reconstructed_native_trajectory", + "native/reconstructed-trajectory.json", + ), + ( + "continued_native_trajectory", + "native/continued-trajectory.json", + ), + ], + AgentKind::Openhands => &[ + ( + "prepared_native_prefix", + "native/prepared-replay-events.json", + ), + ( + "reconstructed_native_trajectory", + "native/reconstructed-trajectory.json", + ), + ( + "continued_native_trajectory", + "native/continued-trajectory.json", + ), + ], + AgentKind::SweAgent => &[ + ("prepared_native_prefix", "native/prepared-prefix.traj"), + ( + "reconstructed_native_trajectory", + "native/reconstructed-trajectory.traj", + ), + ( + "continued_native_trajectory", + "native/continued-trajectory.traj", + ), + ], + }; + for (role, relative) in native_paths { + let path = output_dir.join(relative); + if path.is_file() { + artifacts.push(artifact(role, native_format, path)); + } + } + if output_dir.join("logs").is_dir() { + artifacts.push(artifact( + "agent_logs", + "sandbox-playback/log-directory-v1", + output_dir.join("logs"), + )); + } + artifacts +} + +fn prepared_native_path(agent: AgentKind, output_dir: &Path) -> std::path::PathBuf { + output_dir.join(match agent { + AgentKind::ClaudeCode => "native/prepared-prefix.jsonl", + AgentKind::MiniSweAgent => "native/prepared-prefix.json", + AgentKind::Openhands => "native/prepared-replay-events.json", + AgentKind::SweAgent => "native/prepared-prefix.traj", + }) +} + fn artifact(role: &str, format: &str, path: std::path::PathBuf) -> Artifact { Artifact { role: role.into(), @@ -344,3 +659,104 @@ fn artifact(role: &str, format: &str, path: std::path::PathBuf) -> Artifact { path, } } + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeMap; + + #[test] + fn validation_errors_keep_resolved_run_locations() { + let request = PlaybackRequest { + agent: AgentKind::ClaudeCode, + trajectory: std::path::PathBuf::from("/missing/trajectory.jsonl"), + after_step: 1, + workspace: std::path::PathBuf::from("/missing/workspace"), + state_dir: std::path::PathBuf::from("/state"), + output_dir: std::path::PathBuf::from("/output"), + agent_entrypoint: None, + agent_runtime: None, + disallowed_tools: Vec::new(), + trajectory_assets: None, + session_id: None, + max_steps: None, + mode: ReplayMode::PrepareOnly, + allow_stale_observations: false, + run_id: Some("replay-1".into()), + disable_thinking: false, + }; + + let error = execute(request).unwrap_err(); + let (run_id, state_dir, output_dir) = error.locations().unwrap(); + assert_eq!(run_id, "replay-1"); + assert_eq!(state_dir, Path::new("/state/replay-1")); + assert_eq!(output_dir, Path::new("/output/replay-1")); + } + + #[test] + fn validation_does_not_consume_output_run_id() { + let temporary = tempfile::tempdir().unwrap(); + let workspace = temporary.path().join("workspace"); + fs::create_dir(&workspace).unwrap(); + let trajectory = temporary.path().join("invalid-openhands.json"); + fs::write(&trajectory, "{}").unwrap(); + let output_root = temporary.path().join("output"); + let request = PlaybackRequest { + agent: AgentKind::Openhands, + trajectory, + after_step: 1, + workspace, + state_dir: temporary.path().join("state"), + output_dir: output_root.clone(), + agent_entrypoint: None, + agent_runtime: None, + disallowed_tools: Vec::new(), + trajectory_assets: None, + session_id: None, + max_steps: None, + mode: ReplayMode::PrepareOnly, + allow_stale_observations: false, + run_id: Some("reserved-run".into()), + disable_thinking: false, + }; + + let error = execute(request).unwrap_err(); + + assert_eq!(error.kind, ReplayErrorKind::Trajectory); + assert!(!output_root.join("reserved-run").exists()); + } + + #[test] + fn stale_source_observations_degrade_result_quality() { + let outcome = ReplayOutcome { + status: "replayed".into(), + reconstructed_path: None, + continued_path: None, + observations: vec![crate::model::FreshObservation { + call_id: "call-1".into(), + content: Value::Null, + is_error: false, + return_code: Some(0), + duration_ms: 0, + truncated: false, + metadata: BTreeMap::from([( + "degradation_reason".into(), + json!("stale_source_observation"), + )]), + }], + continued_steps: 0, + metadata: Value::Null, + }; + + assert_eq!(quality_for_outcome(&outcome), ReplayQuality::Degraded); + } + + #[test] + fn total_step_budget_is_checked_before_replay() { + assert!(validate_step_budget(ReplayMode::ReplayOnly, Some(3), 3).is_ok()); + assert!(validate_step_budget(ReplayMode::ReplayOnly, Some(2), 3).is_err()); + assert!(validate_step_budget(ReplayMode::ReplayAndContinue, Some(3), 3).is_err()); + assert!(validate_step_budget(ReplayMode::ReplayAndContinue, Some(4), 3).is_ok()); + assert!(validate_step_budget(ReplayMode::PrepareOnly, Some(1), 3).is_ok()); + } +} diff --git a/crates/persisting-replay/src/error.rs b/crates/persisting-replay/src/error.rs index 8b4a45e9..ce2ed707 100644 --- a/crates/persisting-replay/src/error.rs +++ b/crates/persisting-replay/src/error.rs @@ -1,4 +1,5 @@ use std::fmt; +use std::path::{Path, PathBuf}; /// Stable error categories retained from SandboxReplay's public protocol. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -63,6 +64,14 @@ impl ReplayErrorKind { pub struct ReplayError { pub kind: ReplayErrorKind, pub message: String, + pub locations: Option, +} + +#[derive(Debug, Clone)] +pub struct ReplayLocations { + pub run_id: String, + pub state_dir: PathBuf, + pub output_dir: PathBuf, } impl ReplayError { @@ -70,6 +79,7 @@ impl ReplayError { Self { kind, message: message.into(), + locations: None, } } @@ -123,6 +133,46 @@ impl ReplayError { pub fn exit_code(&self) -> i32 { self.kind.exit_code() } + + pub fn with_locations( + mut self, + run_id: impl Into, + state_dir: PathBuf, + output_dir: PathBuf, + ) -> Self { + self.locations = Some(ReplayLocations { + run_id: run_id.into(), + state_dir, + output_dir, + }); + self + } + + pub fn with_default_locations( + mut self, + run_id: impl Into, + state_dir: PathBuf, + output_dir: PathBuf, + ) -> Self { + if self.locations.is_none() { + self.locations = Some(ReplayLocations { + run_id: run_id.into(), + state_dir, + output_dir, + }); + } + self + } + + pub fn locations(&self) -> Option<(&str, &Path, &Path)> { + self.locations.as_ref().map(|locations| { + ( + locations.run_id.as_str(), + locations.state_dir.as_path(), + locations.output_dir.as_path(), + ) + }) + } } impl fmt::Display for ReplayError { diff --git a/crates/persisting-replay/src/journal.rs b/crates/persisting-replay/src/journal.rs index 6ffdbaf7..3316aff4 100644 --- a/crates/persisting-replay/src/journal.rs +++ b/crates/persisting-replay/src/journal.rs @@ -41,6 +41,14 @@ impl Journal { ) })?; let path = state_dir.join("replay-events.jsonl"); + if let Some(call_id) = Self::find_ambiguous(&path)? { + return Err(ReplayError::new( + ReplayErrorKind::AmbiguousExecution, + format!( + "state contains an uncertain started tool call {call_id:?}; use a new sandbox and run-id to replay from T1" + ), + )); + } let file = OpenOptions::new() .create(true) .append(true) @@ -85,26 +93,23 @@ impl Journal { ReplayErrorKind::AmbiguousExecution, format!("read {}", path.display()), )?; - let mut started = BTreeSet::new(); - let mut finished = BTreeSet::new(); + let mut started_since_terminal = BTreeSet::new(); for line in BufReader::new(file).lines() { let line = line.replay_context(ReplayErrorKind::AmbiguousExecution, "read replay journal")?; let event: Value = serde_json::from_str(&line) .replay_context(ReplayErrorKind::AmbiguousExecution, "parse replay journal")?; - if let Some(call_id) = event.get("call_id").and_then(Value::as_str) { - match event.get("event").and_then(Value::as_str) { - Some("tool_started") => { - started.insert(call_id.to_owned()); + match event.get("event").and_then(Value::as_str) { + Some("tool_started") => { + if let Some(call_id) = event.get("call_id").and_then(Value::as_str) { + started_since_terminal.insert(call_id.to_owned()); } - Some("tool_finished") => { - finished.insert(call_id.to_owned()); - } - _ => {} } + Some("run_finished" | "run_failed") => started_since_terminal.clear(), + _ => {} } } - Ok(started.difference(&finished).next().cloned()) + Ok(started_since_terminal.into_iter().next()) } } @@ -114,3 +119,107 @@ impl Drop for Journal { let _ = FileExt::unlock(&self.lock); } } + +#[cfg(test)] +mod tests { + use super::*; + + fn write_events(path: &Path, events: &[Value]) { + let contents = events + .iter() + .map(Value::to_string) + .collect::>() + .join("\n"); + fs::write(path, format!("{contents}\n")).unwrap(); + } + + #[test] + fn finished_tool_without_terminal_run_is_ambiguous() { + let temporary = tempfile::tempdir().unwrap(); + let path = temporary.path().join("replay-events.jsonl"); + write_events( + &path, + &[ + serde_json::json!({"event": "run_started"}), + serde_json::json!({"event": "tool_started", "call_id": "same-call"}), + serde_json::json!({"event": "tool_finished", "call_id": "same-call"}), + ], + ); + + assert_eq!( + Journal::find_ambiguous(&path).unwrap().as_deref(), + Some("same-call") + ); + } + + #[test] + fn repeated_call_id_after_a_terminal_run_remains_ambiguous() { + let temporary = tempfile::tempdir().unwrap(); + let path = temporary.path().join("replay-events.jsonl"); + write_events( + &path, + &[ + serde_json::json!({"event": "run_started"}), + serde_json::json!({"event": "tool_started", "call_id": "same-call"}), + serde_json::json!({"event": "tool_finished", "call_id": "same-call"}), + serde_json::json!({"event": "run_finished"}), + serde_json::json!({"event": "run_started"}), + serde_json::json!({"event": "tool_started", "call_id": "same-call"}), + ], + ); + + assert_eq!( + Journal::find_ambiguous(&path).unwrap().as_deref(), + Some("same-call") + ); + } + + #[test] + fn interruption_before_any_tool_starts_is_retryable() { + let temporary = tempfile::tempdir().unwrap(); + let path = temporary.path().join("replay-events.jsonl"); + write_events( + &path, + &[ + serde_json::json!({"event": "run_started"}), + serde_json::json!({"event": "plan_validated"}), + ], + ); + + assert_eq!(Journal::find_ambiguous(&path).unwrap(), None); + } + + #[test] + fn failed_run_is_terminal() { + let temporary = tempfile::tempdir().unwrap(); + let path = temporary.path().join("replay-events.jsonl"); + write_events( + &path, + &[ + serde_json::json!({"event": "run_started"}), + serde_json::json!({"event": "tool_started", "call_id": "call-1"}), + serde_json::json!({"event": "run_failed"}), + ], + ); + + assert_eq!(Journal::find_ambiguous(&path).unwrap(), None); + } + + #[test] + fn opening_a_journal_rejects_ambiguous_state_while_holding_the_lock() { + let temporary = tempfile::tempdir().unwrap(); + let path = temporary.path().join("replay-events.jsonl"); + write_events( + &path, + &[ + serde_json::json!({"event": "run_started"}), + serde_json::json!({"event": "tool_started", "call_id": "call-1"}), + ], + ); + + let error = Journal::open(temporary.path()).err().unwrap(); + + assert_eq!(error.kind, ReplayErrorKind::AmbiguousExecution); + assert!(error.message.contains("call-1")); + } +} diff --git a/crates/persisting-replay/src/lib.rs b/crates/persisting-replay/src/lib.rs index 2ca44c10..4c6730a8 100644 --- a/crates/persisting-replay/src/lib.rs +++ b/crates/persisting-replay/src/lib.rs @@ -15,10 +15,14 @@ mod error; mod io; mod journal; mod model; +mod process; pub use config::{ request_from_json, OverlayFsConfig, OverlayNetConfig, ReplayConfig, ReplayToml, RunConfig, }; pub use engine::execute; pub use error::{ReplayError, ReplayErrorKind}; -pub use model::{AgentKind, PlaybackRequest, ReplayResult, RESULT_SCHEMA_VERSION}; +pub use model::{ + AgentKind, AgentStatus, ExecutionReport, PlaybackRequest, ReplayFailure, ReplayMode, + ReplayPhase, ReplayQuality, ReplayResult, RESULT_SCHEMA_VERSION, +}; diff --git a/crates/persisting-replay/src/model.rs b/crates/persisting-replay/src/model.rs index 78a85b17..44476885 100644 --- a/crates/persisting-replay/src/model.rs +++ b/crates/persisting-replay/src/model.rs @@ -7,7 +7,7 @@ use serde_json::Value; pub const PLAN_SCHEMA_VERSION: &str = "sandbox-replay.plan/v1"; pub const REQUEST_SCHEMA_VERSION: &str = "sandbox-playback.request/v1"; -pub const RESULT_SCHEMA_VERSION: &str = "sandbox-playback.result/v2"; +pub const RESULT_SCHEMA_VERSION: &str = "sandbox-playback.result/v3"; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] @@ -63,6 +63,14 @@ impl FromStr for AgentKind { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ReplayMode { + PrepareOnly, + ReplayOnly, + ReplayAndContinue, +} + #[derive(Debug, Clone)] pub struct PlaybackRequest { pub agent: AgentKind, @@ -77,7 +85,8 @@ pub struct PlaybackRequest { pub trajectory_assets: Option, pub session_id: Option, pub max_steps: Option, - pub replay_only: bool, + pub mode: ReplayMode, + pub allow_stale_observations: bool, pub run_id: Option, pub disable_thinking: bool, } @@ -108,15 +117,15 @@ pub struct ToolBatch { } #[derive(Debug, Clone)] -pub struct ReplayPlan { - pub agent: AgentKind, - pub source_path: PathBuf, - pub source_sha256: String, - pub after_step: usize, - pub batches: Vec, - pub prefix_model_turns: usize, - pub native: Value, - pub original_next_action: Option, +pub(crate) struct ReplayPlan { + pub(crate) agent: AgentKind, + pub(crate) source_path: PathBuf, + pub(crate) source_sha256: String, + pub(crate) after_step: usize, + pub(crate) batches: Vec, + pub(crate) prefix_model_turns: usize, + pub(crate) native: Value, + pub(crate) original_next_action: Option, } impl ReplayPlan { @@ -149,6 +158,53 @@ impl ReplayPlan { } } +#[derive(Debug, Clone)] +pub(crate) enum AdapterPlan { + ClaudeCode(ReplayPlan), + MiniSweAgent(ReplayPlan), + Openhands(ReplayPlan), + SweAgent(ReplayPlan), +} + +impl AdapterPlan { + pub(crate) fn agent(&self) -> AgentKind { + self.plan().agent + } + + pub(crate) fn after_step(&self) -> usize { + self.plan().after_step + } + + pub(crate) fn prefix_model_turns(&self) -> usize { + self.plan().prefix_model_turns + } + + pub(crate) fn source_sha256(&self) -> &str { + &self.plan().source_sha256 + } + + pub(crate) fn calls(&self) -> impl Iterator { + self.plan().calls() + } + + pub(crate) fn public_value(&self) -> Value { + self.plan().public_value() + } + + pub(crate) fn original_next_action(&self) -> Option<&Value> { + self.plan().original_next_action.as_ref() + } + + fn plan(&self) -> &ReplayPlan { + match self { + Self::ClaudeCode(plan) + | Self::MiniSweAgent(plan) + | Self::Openhands(plan) + | Self::SweAgent(plan) => plan, + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct FreshObservation { pub call_id: String, @@ -188,18 +244,135 @@ pub struct AgentResult { pub disallowed_tools: Vec, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ReplayPhase { + Prepared, + Replayed, + Continued, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ReplayQuality { + Verified, + Degraded, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum AgentStatus { + Completed, + MaxSteps, + Failed, + NotStarted, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ReplayFailure { + pub category: String, + pub message: String, +} + #[derive(Debug, Clone, Serialize)] pub struct ReplayResult { pub schema_version: &'static str, - pub status: String, + pub phase: ReplayPhase, + pub quality: ReplayQuality, + pub agent_status: AgentStatus, pub run_id: String, pub agent: AgentResult, pub after_step: usize, pub replayed_tool_calls: usize, pub prefix_model_turns: usize, pub continued_steps: usize, + pub state_dir: PathBuf, pub output_dir: PathBuf, pub artifacts: Vec, + pub failure: Option, pub retryable: bool, pub metadata: Value, } + +#[derive(Debug)] +pub struct ExecutionReport { + pub result: ReplayResult, + pub exit_code: i32, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn replay_plan(agent: AgentKind, marker: &str) -> ReplayPlan { + ReplayPlan { + agent, + source_path: PathBuf::from(format!("/{marker}")), + source_sha256: marker.into(), + after_step: 1, + batches: vec![ToolBatch { + ordinal: 1, + native_locator: marker.into(), + tool_calls: Vec::new(), + assistant_text: String::new(), + native: serde_json::json!({"private": marker}), + }], + prefix_model_turns: 1, + native: serde_json::json!({"private": marker}), + original_next_action: None, + } + } + + #[test] + fn adapter_plan_exposes_only_common_dispatch_fields() { + let plans = [ + AdapterPlan::ClaudeCode(replay_plan(AgentKind::ClaudeCode, "claude")), + AdapterPlan::MiniSweAgent(replay_plan(AgentKind::MiniSweAgent, "mini")), + AdapterPlan::Openhands(replay_plan(AgentKind::Openhands, "openhands")), + AdapterPlan::SweAgent(replay_plan(AgentKind::SweAgent, "swe")), + ]; + + for plan in plans { + assert_eq!(plan.after_step(), 1); + assert_eq!(plan.prefix_model_turns(), 1); + assert_eq!(plan.calls().count(), 0); + assert_eq!(plan.public_value()["agent"]["name"], plan.agent().as_str()); + assert!(!plan.source_sha256().is_empty()); + } + } + + #[test] + fn v3_result_serializes_typed_execution_state() { + let result = ReplayResult { + schema_version: RESULT_SCHEMA_VERSION, + phase: ReplayPhase::Replayed, + quality: ReplayQuality::Degraded, + agent_status: AgentStatus::NotStarted, + run_id: "replay-1".into(), + agent: AgentResult { + kind: "claude-code".into(), + version: "2.1.220".into(), + entrypoint: None, + launch_source: "runtime_manifest".into(), + disallowed_tools: Vec::new(), + }, + after_step: 1, + replayed_tool_calls: 1, + prefix_model_turns: 1, + continued_steps: 0, + state_dir: PathBuf::from("/state/replay-1"), + output_dir: PathBuf::from("/output/replay-1"), + artifacts: Vec::new(), + failure: None, + retryable: false, + metadata: Value::Null, + }; + + let value = serde_json::to_value(result).unwrap(); + assert_eq!(value["schema_version"], "sandbox-playback.result/v3"); + assert_eq!(value["phase"], "replayed"); + assert_eq!(value["quality"], "degraded"); + assert_eq!(value["agent_status"], "not_started"); + assert_eq!(value["failure"], Value::Null); + } +} diff --git a/crates/persisting-replay/src/process.rs b/crates/persisting-replay/src/process.rs new file mode 100644 index 00000000..871576c7 --- /dev/null +++ b/crates/persisting-replay/src/process.rs @@ -0,0 +1,416 @@ +use std::fs::{File, OpenOptions}; +use std::io::{self, Read, Write}; +#[cfg(unix)] +use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; +#[cfg(unix)] +use std::os::unix::process::CommandExt; +use std::path::PathBuf; +use std::process::{Command, ExitStatus, Stdio}; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::{Duration, Instant}; + +use crate::error::{ReplayError, ReplayErrorKind, ResultExt}; + +#[allow(dead_code)] +pub(crate) struct ProcessSpec { + pub command: Command, + pub stdin: Option>, + pub timeout: Duration, + pub termination_grace: Duration, + pub pipe_grace: Duration, + pub retained_bytes: usize, + pub log_path: PathBuf, +} + +#[allow(dead_code)] +pub(crate) struct ProcessOutput { + pub status: ExitStatus, + pub stdout_tail: Vec, + pub stderr_tail: Vec, + pub stdout_bytes: u64, + pub stderr_bytes: u64, + pub stdout_truncated: bool, + pub stderr_truncated: bool, + pub timed_out: bool, + pub background_cleanup: bool, +} + +struct StreamCapture { + tail: Vec, + total: u64, + log_error: Option, +} + +#[allow(dead_code)] +pub(crate) fn run_process(mut spec: ProcessSpec) -> Result { + let log = owner_only_log(&spec.log_path)?; + let log = Arc::new(Mutex::new(log)); + spec.command.stdout(Stdio::piped()).stderr(Stdio::piped()); + if spec.stdin.is_some() { + spec.command.stdin(Stdio::piped()); + } + #[cfg(unix)] + unsafe { + spec.command.pre_exec(|| { + if libc::setpgid(0, 0) == 0 { + Ok(()) + } else { + Err(io::Error::last_os_error()) + } + }); + } + let mut child = spec + .command + .spawn() + .replay_context(ReplayErrorKind::Executor, "spawn supervised replay process")?; + let process_group = child.id() as i32; + let stdout = child + .stdout + .take() + .ok_or_else(|| ReplayError::new(ReplayErrorKind::Internal, "stdout pipe missing"))?; + let stderr = child + .stderr + .take() + .ok_or_else(|| ReplayError::new(ReplayErrorKind::Internal, "stderr pipe missing"))?; + let stdout_reader = spawn_reader(stdout, Arc::clone(&log), spec.retained_bytes); + let stderr_reader = spawn_reader(stderr, Arc::clone(&log), spec.retained_bytes); + if let Some(input) = spec.stdin.take() { + let write_result = child + .stdin + .take() + .ok_or_else(|| io::Error::other("stdin pipe missing")) + .and_then(|mut stdin| stdin.write_all(&input)); + if let Err(error) = write_result { + #[cfg(unix)] + let _ = signal_group(process_group, libc::SIGKILL); + #[cfg(not(unix))] + let _ = child.kill(); + let _ = child.wait(); + let _ = stdout_reader.join(); + let _ = stderr_reader.join(); + return Err(ReplayError::new( + ReplayErrorKind::Executor, + format!("write supervised process stdin: {error}"), + )); + } + } + + let started = Instant::now(); + let mut timed_out = false; + let mut background_cleanup = false; + let status = loop { + if let Some(status) = child + .try_wait() + .replay_context(ReplayErrorKind::Executor, "poll supervised replay process")? + { + break status; + } + if started.elapsed() >= spec.timeout { + timed_out = true; + background_cleanup = true; + break terminate_running_group(&mut child, process_group, spec.termination_grace)?; + } + thread::sleep(Duration::from_millis(10)); + }; + + #[cfg(unix)] + if process_group_exists(process_group)? { + background_cleanup = true; + terminate_remaining_group(process_group, spec.termination_grace)?; + } + + let pipe_deadline = Instant::now() + spec.pipe_grace + spec.termination_grace; + while (!stdout_reader.is_finished() || !stderr_reader.is_finished()) + && Instant::now() < pipe_deadline + { + thread::sleep(Duration::from_millis(5)); + } + #[cfg(unix)] + if !stdout_reader.is_finished() || !stderr_reader.is_finished() { + background_cleanup = true; + let _ = signal_group(process_group, libc::SIGKILL); + } + + let stdout = stdout_reader + .join() + .map_err(|_| ReplayError::new(ReplayErrorKind::Internal, "stdout reader panicked"))? + .replay_context(ReplayErrorKind::Executor, "drain supervised stdout")?; + let stderr = stderr_reader + .join() + .map_err(|_| ReplayError::new(ReplayErrorKind::Internal, "stderr reader panicked"))? + .replay_context(ReplayErrorKind::Executor, "drain supervised stderr")?; + if let Some(error) = stdout.log_error.or(stderr.log_error) { + return Err(ReplayError::new( + ReplayErrorKind::Executor, + format!("write supervised process log: {error}"), + )); + } + + Ok(ProcessOutput { + status, + stdout_truncated: stdout.total > stdout.tail.len() as u64, + stderr_truncated: stderr.total > stderr.tail.len() as u64, + stdout_tail: stdout.tail, + stderr_tail: stderr.tail, + stdout_bytes: stdout.total, + stderr_bytes: stderr.total, + timed_out, + background_cleanup, + }) +} + +fn owner_only_log(path: &std::path::Path) -> Result { + let mut options = OpenOptions::new(); + options.create(true).truncate(true).write(true); + #[cfg(unix)] + options.mode(0o600); + let file = options.open(path).replay_context( + ReplayErrorKind::Executor, + format!("create process log {}", path.display()), + )?; + #[cfg(unix)] + file.set_permissions(std::fs::Permissions::from_mode(0o600)) + .replay_context( + ReplayErrorKind::Executor, + format!("restrict process log {}", path.display()), + )?; + Ok(file) +} + +fn spawn_reader( + mut reader: R, + log: Arc>, + retained_bytes: usize, +) -> thread::JoinHandle> +where + R: Read + Send + 'static, +{ + thread::spawn(move || { + let mut tail = Vec::with_capacity(retained_bytes.min(64 * 1024)); + let mut total = 0_u64; + let mut log_error = None; + let mut chunk = [0_u8; 16 * 1024]; + loop { + let count = reader.read(&mut chunk)?; + if count == 0 { + break; + } + total = total.saturating_add(count as u64); + if log_error.is_none() { + let write_result = log + .lock() + .map_err(|_| io::Error::other("process log lock poisoned"))? + .write_all(&chunk[..count]); + if let Err(error) = write_result { + log_error = Some(error); + } + } + retain_tail(&mut tail, &chunk[..count], retained_bytes); + } + Ok(StreamCapture { + tail, + total, + log_error, + }) + }) +} + +fn retain_tail(tail: &mut Vec, chunk: &[u8], limit: usize) { + if limit == 0 { + tail.clear(); + } else if chunk.len() >= limit { + tail.clear(); + tail.extend_from_slice(&chunk[chunk.len() - limit..]); + } else { + let overflow = tail.len().saturating_add(chunk.len()).saturating_sub(limit); + if overflow != 0 { + tail.drain(..overflow); + } + tail.extend_from_slice(chunk); + } +} + +#[cfg(unix)] +fn terminate_running_group( + child: &mut std::process::Child, + process_group: i32, + grace: Duration, +) -> Result { + let _ = signal_group(process_group, libc::SIGTERM)?; + let deadline = Instant::now() + grace; + loop { + if let Some(status) = child + .try_wait() + .replay_context(ReplayErrorKind::Executor, "poll terminated process leader")? + { + if process_group_exists(process_group)? { + let _ = signal_group(process_group, libc::SIGKILL)?; + } + return Ok(status); + } + if Instant::now() >= deadline { + let _ = signal_group(process_group, libc::SIGKILL)?; + return child + .wait() + .replay_context(ReplayErrorKind::Executor, "reap killed process leader"); + } + thread::sleep(Duration::from_millis(5)); + } +} + +#[cfg(not(unix))] +fn terminate_running_group( + child: &mut std::process::Child, + _process_group: i32, + _grace: Duration, +) -> Result { + child + .kill() + .replay_context(ReplayErrorKind::Executor, "kill timed out process")?; + child + .wait() + .replay_context(ReplayErrorKind::Executor, "reap killed process") +} + +#[cfg(unix)] +fn terminate_remaining_group(process_group: i32, grace: Duration) -> Result<(), ReplayError> { + let _ = signal_group(process_group, libc::SIGTERM)?; + let deadline = Instant::now() + grace; + while process_group_exists(process_group)? && Instant::now() < deadline { + thread::sleep(Duration::from_millis(5)); + } + if process_group_exists(process_group)? { + let _ = signal_group(process_group, libc::SIGKILL)?; + } + Ok(()) +} + +#[cfg(unix)] +fn process_group_exists(process_group: i32) -> Result { + match unsafe { libc::kill(-process_group, 0) } { + 0 => Ok(true), + _ => { + let error = io::Error::last_os_error(); + if error.raw_os_error() == Some(libc::ESRCH) { + Ok(false) + } else { + Err(ReplayError::new( + ReplayErrorKind::Executor, + format!("inspect replay process group {process_group}: {error}"), + )) + } + } + } +} + +#[cfg(unix)] +fn signal_group(process_group: i32, signal: i32) -> Result { + match unsafe { libc::kill(-process_group, signal) } { + 0 => Ok(true), + _ => { + let error = io::Error::last_os_error(); + if error.raw_os_error() == Some(libc::ESRCH) { + Ok(false) + } else { + Err(ReplayError::new( + ReplayErrorKind::Executor, + format!("signal replay process group {process_group}: {error}"), + )) + } + } + } +} + +#[cfg(all(test, unix))] +mod tests { + use super::*; + use std::path::Path; + use std::process::Command; + use std::time::{Duration, Instant}; + + fn shell_spec(script: &str, log_path: &Path) -> ProcessSpec { + let mut command = Command::new("/bin/sh"); + command.args(["-c", script]); + ProcessSpec { + command, + stdin: None, + timeout: Duration::from_secs(5), + termination_grace: Duration::from_millis(100), + pipe_grace: Duration::from_millis(100), + retained_bytes: 64 * 1024, + log_path: log_path.to_path_buf(), + } + } + + #[test] + fn writes_configured_stdin_before_waiting() { + let temporary = tempfile::tempdir().unwrap(); + let log_path = temporary.path().join("stdin.log"); + let mut spec = shell_spec("cat", &log_path); + spec.stdin = Some(b"resume nonce".to_vec()); + + let output = run_process(spec).unwrap(); + + assert!(output.status.success()); + assert_eq!(output.stdout_tail, b"resume nonce"); + } + + #[test] + fn drains_large_output_to_log_with_a_bounded_tail() { + let temporary = tempfile::tempdir().unwrap(); + let log_path = temporary.path().join("large.log"); + let output = run_process(shell_spec("yes x | head -c 8388608", &log_path)).unwrap(); + + assert!(output.status.success()); + assert_eq!(output.stdout_bytes, 8 * 1024 * 1024); + assert!(output.stdout_truncated); + assert_eq!(output.stdout_tail.len(), 64 * 1024); + assert_eq!(std::fs::metadata(log_path).unwrap().len(), 8 * 1024 * 1024); + assert_eq!( + std::fs::metadata(temporary.path().join("large.log")) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o600 + ); + } + + #[test] + fn cleans_background_descendants_after_the_leader_exits() { + let temporary = tempfile::tempdir().unwrap(); + let log_path = temporary.path().join("background.log"); + let started = Instant::now(); + let output = run_process(shell_spec("sleep 30 & echo $!", &log_path)).unwrap(); + + assert!(started.elapsed() < Duration::from_secs(3)); + assert!(output.status.success()); + assert!(output.background_cleanup); + let pid: i32 = String::from_utf8(output.stdout_tail) + .unwrap() + .trim() + .parse() + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(1); + while unsafe { libc::kill(pid, 0) } == 0 && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(10)); + } + assert_ne!(unsafe { libc::kill(pid, 0) }, 0); + } + + #[test] + fn times_out_and_reaps_the_foreground_process_group() { + let temporary = tempfile::tempdir().unwrap(); + let log_path = temporary.path().join("timeout.log"); + let mut spec = shell_spec("sleep 30", &log_path); + spec.timeout = Duration::from_millis(100); + let started = Instant::now(); + + let output = run_process(spec).unwrap(); + + assert!(started.elapsed() < Duration::from_secs(3)); + assert!(output.timed_out); + assert!(output.background_cleanup); + } +} diff --git a/crates/persisting-replay/tests/fixtures/fake_agent_runtime.py b/crates/persisting-replay/tests/fixtures/fake_agent_runtime.py new file mode 100644 index 00000000..306fb513 --- /dev/null +++ b/crates/persisting-replay/tests/fixtures/fake_agent_runtime.py @@ -0,0 +1,241 @@ +"""Inject minimal pinned-SDK fakes, then execute one replay runner.""" + +from __future__ import annotations + +import json +import os +import runpy +import sys +import types +from pathlib import Path +from types import SimpleNamespace +from typing import Any + + +def _module(name: str) -> types.ModuleType: + module = types.ModuleType(name) + sys.modules[name] = module + return module + + +def _touch(path: str | None, text: str = "1\n") -> None: + if path: + target = Path(path) + target.parent.mkdir(parents=True, exist_ok=True) + with target.open("a", encoding="utf-8") as stream: + stream.write(text) + + +def _install_mini() -> None: + _module("minisweagent") + agents = _module("minisweagent.agents") + environments = _module("minisweagent.environments") + models = _module("minisweagent.models") + exceptions = _module("minisweagent.exceptions") + + class FormatError(Exception): + pass + + class InterruptAgentFlow(Exception): + pass + + exceptions.FormatError = FormatError + exceptions.InterruptAgentFlow = InterruptAgentFlow + + class FakeEnvironment: + def execute(self, action: dict[str, Any]) -> dict[str, Any]: + _touch(action.get("marker")) + return {"output": "fresh observation", "returncode": 0} + + class FakeModel: + def format_observation_messages( + self, + assistant: dict[str, Any], + outputs: list[dict[str, Any]], + template_vars: dict[str, Any], + ) -> list[dict[str, Any]]: + del assistant, template_vars + return [ + { + "role": "tool", + "content": output["output"], + "extra": {"returncode": output["returncode"]}, + } + for output in outputs + ] + + class FakeAgent: + def __init__(self, model: Any, environment: Any, config: dict[str, Any]) -> None: + self.model = model + self.environment = environment + self.messages: list[dict[str, Any]] = [] + self.n_calls = 0 + self.cost = 0.0 + self.n_consecutive_format_errors = 0 + self.config = SimpleNamespace( + max_consecutive_format_errors=3, + step_limit=config.get("step_limit"), + ) + + def add_messages(self, *messages: dict[str, Any]) -> None: + self.messages.extend(messages) + + def get_template_vars(self) -> dict[str, Any]: + return {} + + def save(self, path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps({"messages": self.messages, "info": {}}, indent=2) + "\n", + encoding="utf-8", + ) + + def step(self) -> None: + if self.config.step_limit is not None and self.n_calls >= self.config.step_limit: + self.add_messages( + { + "role": "exit", + "content": "LimitsExceeded", + "extra": {"exit_status": "LimitsExceeded", "submission": ""}, + } + ) + return + _touch(os.environ.get("FAKE_LIVE_MARKER")) + self.n_calls += 1 + if os.environ.get("FAKE_NEVER_COMPLETE") == "1": + self.add_messages({"role": "assistant", "content": "continue", "extra": {}}) + else: + self.add_messages( + { + "role": "exit", + "content": "Completed", + "extra": {"exit_status": "Completed", "submission": "done"}, + } + ) + + def handle_uncaught_exception(self, exc: Exception) -> None: + raise exc + + agents.get_agent = lambda model, environment, config, default_type: FakeAgent( + model, environment, config + ) + environments.get_environment = lambda config, default_type: FakeEnvironment() + models.get_model = lambda config: FakeModel() + + +def _install_swe() -> None: + for name in [ + "sweagent", + "sweagent.agent", + "sweagent.environment", + "sweagent.run", + "swerex", + "swerex.deployment", + ]: + _module(name) + agents = _module("sweagent.agent.agents") + environment_module = _module("sweagent.environment.swe_env") + run_single = _module("sweagent.run.run_single") + deployment = _module("swerex.deployment.config") + + class FakeProblem: + id = "fake-problem" + + def get_problem_statement(self) -> str: + return "fake problem" + + class RunSingleConfig: + @classmethod + def model_validate(cls, value: dict[str, Any]) -> Any: + agent_value = value.get("agent") or {} + return SimpleNamespace( + agent=SimpleNamespace(type=agent_value.get("type", "default")), + env=SimpleNamespace( + deployment=(value.get("env") or {}).get("deployment"), + repo=(value.get("env") or {}).get("repo"), + ), + problem_statement=FakeProblem(), + model_dump=lambda: value, + ) + + class FakeLiveModel: + stats = SimpleNamespace() + config = SimpleNamespace() + + def query(self, history: list[dict[str, Any]], **kwargs: Any) -> dict[str, Any]: + del history, kwargs + _touch(os.environ.get("FAKE_LIVE_MARKER")) + return {"message": "live action"} + + class DefaultAgent: + @classmethod + def from_config(cls, config: Any) -> "DefaultAgent": + del config + instance = cls() + instance.model = FakeLiveModel() + instance.trajectory: list[dict[str, Any]] = [] + instance.history: list[dict[str, Any]] = [] + instance.info: dict[str, Any] = {} + instance.replay_config = None + instance.traj_path: Path | None = None + instance._env = None + return instance + + def setup(self, env: Any, problem_statement: Any, output_dir: Path) -> None: + self._env = env + output_dir.mkdir(parents=True, exist_ok=True) + self.traj_path = output_dir / f"{problem_statement.id}.traj" + + def step(self) -> Any: + response = self.model.query(self.history) + action = str(response.get("message") or "") + self.history.append({"role": "assistant", "content": action}) + self.trajectory.append( + {"action": action, "observation": f"fresh-{len(self.trajectory) + 1}"} + ) + return SimpleNamespace(done=False) + + def get_trajectory_data(self) -> dict[str, Any]: + return { + "trajectory": self.trajectory, + "history": self.history, + "info": self.info, + "replay_config": None, + "environment": "fake", + } + + def save_trajectory(self) -> None: + assert self.traj_path is not None + self.traj_path.write_text( + json.dumps(self.get_trajectory_data(), indent=2) + "\n", + encoding="utf-8", + ) + + class SWEEnv: + def __init__(self, deployment: Any, repo: Any, post_startup_commands: list[str]) -> None: + del deployment, post_startup_commands + self.repo = SimpleNamespace(repo_name=str(repo)) + self.name = "fake" + + agents.DefaultAgent = DefaultAgent + environment_module.SWEEnv = SWEEnv + run_single.RunSingleConfig = RunSingleConfig + deployment.get_deployment = lambda config: config + + +def main() -> None: + if len(sys.argv) != 4: + raise SystemExit("usage: fake_agent_runtime.py KIND RUNNER REQUEST") + kind, runner, request = sys.argv[1:] + if kind == "mini": + _install_mini() + elif kind == "swe": + _install_swe() + else: + raise ValueError(f"unknown fake kind: {kind}") + sys.argv = [runner, request] + runpy.run_path(runner, run_name="__main__") + + +if __name__ == "__main__": + main() diff --git a/crates/persisting-replay/tests/fixtures/replay-managed-smoke.toml b/crates/persisting-replay/tests/fixtures/replay-managed-smoke.toml index 991cbe5c..c711c801 100644 --- a/crates/persisting-replay/tests/fixtures/replay-managed-smoke.toml +++ b/crates/persisting-replay/tests/fixtures/replay-managed-smoke.toml @@ -5,7 +5,7 @@ after_step = 1 workspace = "." state_dir = "/tmp/pvisor-replay-managed-state" output_dir = "/tmp/pvisor-replay-managed-output" -replay_only = true +prepare_only = true disable_thinking = false [run] diff --git a/crates/persisting-replay/tests/fixtures/replay-smoke.toml b/crates/persisting-replay/tests/fixtures/replay-smoke.toml index 338cd685..f1325bf0 100644 --- a/crates/persisting-replay/tests/fixtures/replay-smoke.toml +++ b/crates/persisting-replay/tests/fixtures/replay-smoke.toml @@ -5,5 +5,5 @@ after_step = 1 workspace = "/tmp/pvisor-replay-toml-workspace" state_dir = "/tmp/pvisor-replay-toml-state" output_dir = "/tmp/pvisor-replay-toml-output" -replay_only = true +prepare_only = true disable_thinking = false diff --git a/crates/persisting-replay/tests/replay_contract.rs b/crates/persisting-replay/tests/replay_contract.rs new file mode 100644 index 00000000..fbda1f3f --- /dev/null +++ b/crates/persisting-replay/tests/replay_contract.rs @@ -0,0 +1,337 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; + +use persisting_replay::{ + execute, AgentKind, AgentStatus, PlaybackRequest, ReplayMode, ReplayPhase, +}; +use serde_json::{json, Value}; + +fn crate_path(relative: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join(relative) +} + +fn run_fake(kind: &str, runner: &str, request: &Path, live_marker: &Path) { + let output = Command::new("python3") + .arg(crate_path("tests/fixtures/fake_agent_runtime.py")) + .arg(kind) + .arg(crate_path(runner)) + .arg(request) + .env("FAKE_LIVE_MARKER", live_marker) + .env("OPENAI_API_KEY", "fake") + .env("OPENAI_BASE_URL", "http://127.0.0.1.invalid") + .env("MODEL_NAME", "fake-model") + .output() + .unwrap(); + assert!( + output.status.success(), + "fake runner failed: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +#[cfg(unix)] +fn write_fake_openhands_entrypoint(path: &Path) { + fs::write( + path, + r#"#!/usr/bin/env python3 +import json +import os +import pathlib +import sys + +if len(sys.argv) > 1 and sys.argv[1] == "-c": + print("0.53.0") + raise SystemExit(0) + +prepared = pathlib.Path(os.environ["REPLAY_TRAJECTORY_PATH"]) +continued = pathlib.Path(os.environ["SAVE_TRAJECTORY_PATH"]) +events = json.loads(prepared.read_text(encoding="utf-8")) +actions = [event for event in events if event.get("source") == "agent" and event.get("action") == "run"] +next_id = max(event["id"] for event in events) + 1 +for action in actions: + if not any(event.get("cause") == action["id"] for event in events): + events.append({ + "id": next_id, + "source": "environment", + "observation": "run", + "cause": action["id"], + "message": "fresh replay observation", + "args": {"command": action.get("args", {}).get("command", ""), "metadata": {"exit_code": 0}}, + }) + next_id += 1 + +limit = int(os.environ["MAX_ITERATIONS"]) +while len(actions) < limit: + pathlib.Path("live-marker").write_text("live\n", encoding="utf-8") + action_id = next_id + next_id += 1 + action = {"id": action_id, "source": "agent", "action": "run", "args": {"command": "echo live"}} + observation = { + "id": next_id, + "source": "environment", + "observation": "run", + "cause": action_id, + "message": "fresh live observation", + "args": {"command": "echo live", "metadata": {"exit_code": 0}}, + } + next_id += 1 + events.extend([action, observation]) + actions.append(action) + +continued.parent.mkdir(parents=True, exist_ok=True) +continued.write_text(json.dumps(events), encoding="utf-8") +if pathlib.Path("fatal-mode").exists(): + print("Error while running the agent", file=sys.stderr) +"#, + ) + .unwrap(); + let mut permissions = fs::metadata(path).unwrap().permissions(); + permissions.set_mode(0o755); + fs::set_permissions(path, permissions).unwrap(); +} + +#[cfg(unix)] +fn openhands_request(root: &Path, mode: ReplayMode, max_steps: usize) -> PlaybackRequest { + let workspace = root.join("workspace"); + fs::create_dir_all(&workspace).unwrap(); + let trajectory = root.join("openhands-trajectory.json"); + fs::write( + &trajectory, + serde_json::to_vec(&json!([ + {"id": 0, "source": "user", "action": "message", "args": {"content": "fix it"}}, + {"id": 1, "source": "agent", "action": "run", "args": {"command": "pwd"}}, + { + "id": 2, + "source": "environment", + "observation": "run", + "cause": 1, + "message": "old observation", + "args": {"command": "pwd", "metadata": {"exit_code": 0}} + } + ])) + .unwrap(), + ) + .unwrap(); + let entrypoint = root.join("fake-openhands"); + write_fake_openhands_entrypoint(&entrypoint); + PlaybackRequest { + agent: AgentKind::Openhands, + trajectory, + after_step: 1, + workspace, + state_dir: root.join("state"), + output_dir: root.join("output"), + agent_entrypoint: Some(entrypoint), + agent_runtime: None, + disallowed_tools: Vec::new(), + trajectory_assets: None, + session_id: None, + max_steps: Some(max_steps), + mode, + allow_stale_observations: false, + run_id: Some("contract".into()), + disable_thinking: false, + } +} + +#[test] +fn mini_replay_only_executes_prefix_without_live_model() { + let temporary = tempfile::tempdir().unwrap(); + let historical_marker = temporary.path().join("historical-marker"); + let live_marker = temporary.path().join("live-marker"); + let source = temporary.path().join("source.json"); + fs::write( + &source, + serde_json::to_vec(&json!({ + "info": { + "config": { + "model": {}, "environment": {}, "agent": {} + } + }, + "messages": [ + {"role": "system", "content": "system", "extra": {}}, + { + "role": "assistant", + "content": "historical action", + "extra": { + "response": {}, + "actions": [{ + "tool_call_id": "call-1", + "marker": historical_marker, + }] + } + }, + {"role": "tool", "content": "old observation", "extra": {}} + ] + })) + .unwrap(), + ) + .unwrap(); + let request_path = temporary.path().join("request.json"); + let result_path = temporary.path().join("result.json"); + let observations = temporary.path().join("observations.json"); + let reconstructed = temporary.path().join("reconstructed.json"); + let continued = temporary.path().join("continued.json"); + fs::write( + &request_path, + serde_json::to_vec(&json!({ + "source": source, + "reconstructed": reconstructed, + "continued": continued, + "observations": observations, + "result": result_path, + "workspace": temporary.path(), + "after_step": 1, + "max_steps": 1, + "session_id": "session", + "mode": "replay_only" + })) + .unwrap(), + ) + .unwrap(); + + run_fake( + "mini", + "assets/mini_swe_agent_runner.py", + &request_path, + &live_marker, + ); + + let result: Value = serde_json::from_slice(&fs::read(result_path).unwrap()).unwrap(); + assert!(historical_marker.is_file()); + assert!(!live_marker.exists()); + assert_eq!(result["phase"], "replayed"); + assert_eq!(result["replayed_steps"], 1); + assert_eq!(result["continued_steps"], 0); + assert!(reconstructed.is_file()); + assert!(!continued.exists()); +} + +#[test] +fn swe_max_steps_caps_total_actions() { + let temporary = tempfile::tempdir().unwrap(); + let live_marker = temporary.path().join("live-marker"); + let source = temporary.path().join("source.traj"); + fs::write( + &source, + serde_json::to_vec(&json!({ + "replay_config": { + "agent": {"type": "default", "model": {}}, + "env": {}, + "problem_statement": {"type": "text", "text": "problem", "id": "fake"} + }, + "history": [ + {"role": "assistant", "content": "historical action"}, + {"role": "user", "content": "old observation"} + ], + "trajectory": [ + {"action": "historical action", "observation": "old observation"} + ] + })) + .unwrap(), + ) + .unwrap(); + let request_path = temporary.path().join("request.json"); + let result_path = temporary.path().join("result.json"); + let reconstructed = temporary.path().join("reconstructed.traj"); + let continued = temporary.path().join("continued.traj"); + fs::write( + &request_path, + serde_json::to_vec(&json!({ + "trajectory": source, + "reconstructed": reconstructed, + "continued": continued, + "result": result_path, + "workspace": temporary.path(), + "output_dir": temporary.path().join("agent-output"), + "after_step": 1, + "max_steps": 3, + "mode": "replay_and_continue" + })) + .unwrap(), + ) + .unwrap(); + + run_fake( + "swe", + "assets/swe_agent_runner.py", + &request_path, + &live_marker, + ); + + let result: Value = serde_json::from_slice(&fs::read(result_path).unwrap()).unwrap(); + assert_eq!(result["phase"], "continued"); + assert_eq!(result["agent_status"], "max_steps"); + assert_eq!(result["replayed_steps"], 1); + assert_eq!(result["continued_steps"], 2); + assert_eq!(fs::read_to_string(live_marker).unwrap().lines().count(), 2); + assert!(reconstructed.is_file()); + assert!(continued.is_file()); +} + +#[cfg(unix)] +#[test] +fn openhands_replay_only_stops_at_boundary() { + let temporary = tempfile::tempdir().unwrap(); + let report = execute(openhands_request( + temporary.path(), + ReplayMode::ReplayOnly, + 1, + )) + .unwrap(); + + assert_eq!(report.exit_code, 0); + assert_eq!(report.result.phase, ReplayPhase::Replayed); + assert_eq!(report.result.agent_status, AgentStatus::NotStarted); + assert_eq!(report.result.replayed_tool_calls, 1); + assert_eq!(report.result.continued_steps, 0); + assert!(!temporary.path().join("workspace/live-marker").exists()); + assert!(report.result.artifacts.iter().any(|artifact| { + artifact.role == "reconstructed_native_trajectory" + && artifact + .path + .ends_with("native/reconstructed-trajectory.json") + })); +} + +#[cfg(unix)] +#[test] +fn openhands_zero_exit_fatal_status_is_a_failed_result_with_trajectory() { + let temporary = tempfile::tempdir().unwrap(); + let workspace = temporary.path().join("workspace"); + fs::create_dir_all(&workspace).unwrap(); + fs::write(workspace.join("fatal-mode"), "1\n").unwrap(); + let report = execute(openhands_request( + temporary.path(), + ReplayMode::ReplayAndContinue, + 2, + )) + .unwrap(); + + assert_ne!(report.exit_code, 0); + assert_eq!(report.result.agent_status, AgentStatus::Failed); + assert!(report + .result + .failure + .as_ref() + .is_some_and(|failure| { failure.message.contains("Error while running the agent") })); + assert_eq!( + report.result.output_dir, + temporary.path().join("output/contract") + ); + assert_eq!( + report.result.state_dir, + temporary.path().join("state/contract") + ); + assert!(report.result.output_dir.join("result.json").is_file()); + let journal = fs::read_to_string(report.result.output_dir.join("replay-events.jsonl")).unwrap(); + let terminal: Value = serde_json::from_str(journal.lines().last().unwrap()).unwrap(); + assert_eq!(terminal["event"], "run_failed"); + assert!(report.result.artifacts.iter().any(|artifact| { + artifact.role == "continued_native_trajectory" && artifact.path.is_file() + })); +} diff --git a/docs/src/pchronicle/design/storyline-lance.md b/docs/src/pchronicle/design/storyline-lance.md index 1c2b4fb3..9285ae68 100644 --- a/docs/src/pchronicle/design/storyline-lance.md +++ b/docs/src/pchronicle/design/storyline-lance.md @@ -87,6 +87,13 @@ projection ownership 见[轨迹存储](trajectory-storage.md),用户查询流 `steps.turn_ordinal` 是 turn 数组顺序的权威列;`step_id` 只作身份,不参与重排。 `had_tool_calls` 让显式空数组与字段缺失保持可区分。 +`runs.task_json`、`runs.started_at_json`、`runs.finished_at_json`、`runs.prompt_json`、 +`runs.extra_json`、`runs.meta_json` 保存文档级 `/task`、文档时间、`/prompt`、`/extra` 与 +`/meta`;`steps.env_json`、`steps.finished_at_json`、 +`steps.prompt_json` 保存 turn env、结束时间与 turn `/prompt`; +`tool_calls.kind`、`tool_calls.response_json` 保存工具事件类型与 `response`。旧表缺列 +时按字段缺失解码。这些对象不拆成独立 SQL 列。 + `steps.timestamp` 是规范化到 UTC 的 `Timestamp(Nanosecond, "UTC")` 查询列; `timestamp_source_json` 保存权威 JSON 标量,因此 RFC3339 字符串和 Unix epoch 秒数值 都能无损恢复。写入端拒绝无效、越界或无法精确表示为纳秒的非空时间。SQL 排序、范围 diff --git a/docs/src/pchronicle/guides/exchange.md b/docs/src/pchronicle/guides/exchange.md index 0018e498..db461992 100644 --- a/docs/src/pchronicle/guides/exchange.md +++ b/docs/src/pchronicle/guides/exchange.md @@ -14,7 +14,9 @@ pchronicle import --from input.json \ The target is create-only. pChronicle refuses an existing target instead of silently appending or replacing it. Regular files can be auto-detected. A directory recursively imports `.json`, `.jsonl`, and `.ndjson` Sources while -preserving their relative paths in the default output: +preserving their relative paths in the default output. When `--format` is +omitted, each file is detected independently; JSON that is not a known +trajectory format is skipped with a warning: ```bash pchronicle import --from ./corpus --output ./imported diff --git a/docs/src/pchronicle/guides/exchange.zh.md b/docs/src/pchronicle/guides/exchange.zh.md index 56ecbc5d..bde71b4c 100644 --- a/docs/src/pchronicle/guides/exchange.zh.md +++ b/docs/src/pchronicle/guides/exchange.zh.md @@ -12,7 +12,7 @@ pchronicle import --from input.json \ 目标是 create-only。已有目标会被拒绝,而不是静默 append 或 replace。普通文件可以自动 识别。目录输入会递归扫描 `.json`、`.jsonl` 与 `.ndjson` Source;默认输出会保留其相对 -路径: +路径。未指定 `--format` 时按文件分别探测类型;无法识别为轨迹格式的 JSON 会跳过并警告: ```bash pchronicle import --from ./corpus --output ./imported diff --git a/docs/src/pchronicle/reference/cli.md b/docs/src/pchronicle/reference/cli.md index 4204bffd..1aebe6e9 100644 --- a/docs/src/pchronicle/reference/cli.md +++ b/docs/src/pchronicle/reference/cli.md @@ -180,9 +180,10 @@ cat input.json | pchronicle import --from - --stream \ ``` Regular files can be auto-detected. A directory input recursively scans -`.json`, `.jsonl`, and `.ndjson` regular files, skips symbolic links encountered -during traversal, and keeps each Source's relative path in the default -`--output-format preserve` output. An explicitly named symbolic link is +`.json`, `.jsonl`, and `.ndjson` regular files, detects each file independently +when `--format` is omitted, skips JSON that is not a known trajectory format, +skips symbolic links encountered during traversal, and keeps each Source's +relative path in the default `--output-format preserve` output. An explicitly named symbolic link is accepted only when its target is a regular file. ATIF `.jsonl`/`.ndjson` Sources decode every non-empty record. diff --git a/docs/src/pvisor/guides/sandbox-replay.md b/docs/src/pvisor/guides/sandbox-replay.md index fb1deecf..44ffcfea 100644 --- a/docs/src/pvisor/guides/sandbox-replay.md +++ b/docs/src/pvisor/guides/sandbox-replay.md @@ -44,6 +44,31 @@ replay_only = false disable_thinking = true ``` +### Execution modes and results + +- The default mode executes the selected prefix and continues with the live Agent. +- `--replay-only` executes the prefix and stops before the next model request. +- `--prepare-only` only validates and constructs the prefix. It executes no tools, + starts no Agent, and does not require an Agent runtime. + +`--max-steps` counts all Agent actions, including the selected prefix. For +example, `--after-step 30 --max-steps 50` leaves at most 20 live actions. A +replay-only budget must cover the prefix; a continuation budget must leave at +least one live action. + +Results use `sandbox-playback.result/v3`. The `phase` is `prepared`, `replayed`, +or `continued`; `quality` is `verified` or `degraded`; and `agent_status` +distinguishes `not_started`, `completed`, `max_steps`, and `failed`. Failures +retain available logs and native trajectories. OpenHands controller fatal states +are failures even when its process exits with status zero. + +Migration: older non-Claude configurations sometimes used `replay_only = true` +to construct a prefix without executing it. Use `prepare_only = true` for that +behavior. In v3, replay-only always executes the selected prefix and therefore +requires an exact-version runtime. Claude observations that cannot be reproduced +fresh fail by default; `--allow-stale-observations` explicitly permits a +`degraded` result. + Runtime isolation is opt-in. Replay only creates an outer managed `pvisor run` when the caller supplies runtime options such as `--safe`, `--executor`, or `--overlayfs-base`, or their TOML equivalents. See the diff --git a/docs/src/pvisor/guides/sandbox-replay.zh.md b/docs/src/pvisor/guides/sandbox-replay.zh.md index 5eabc618..e1f078b3 100644 --- a/docs/src/pvisor/guides/sandbox-replay.zh.md +++ b/docs/src/pvisor/guides/sandbox-replay.zh.md @@ -79,6 +79,21 @@ replay_only = false disable_thinking = true ~~~ +### 4.1 执行模式与结果 + +- 默认模式会执行选中的前缀,然后启动 Agent 继续运行; +- `--replay-only` 会执行前缀,但在下一次模型请求前停止; +- `--prepare-only` 只校验并构造前缀,不执行工具、不启动 Agent,也不要求 Agent runtime。 + +`--max-steps` 是包含回放前缀在内的 Agent 动作总预算。例如 +`--after-step 30 --max-steps 50` 最多留下 20 个续跑动作。仅回放模式的预算必须覆盖前缀;续跑模式还必须至少留下一个实时动作。 + +结果协议为 `sandbox-playback.result/v3`:`phase` 为 `prepared`、`replayed` +或 `continued`;`quality` 为 `verified` 或 `degraded`;`agent_status` 区分 +`not_started`、`completed`、`max_steps` 与 `failed`。失败结果会保留已经生成的日志和原生轨迹。即使 OpenHands 进程返回 0,只要控制器报告 fatal 状态,结果仍为失败。 + +迁移说明:旧版非 Claude 配置有时使用 `replay_only = true` 表示只构造前缀、不执行。现在应改为 `prepare_only = true`;v3 的 replay-only 一定会执行选中的前缀,因此需要精确版本的 runtime。无法重新生成的 Claude observation 默认失败,只有显式指定 `--allow-stale-observations` 才会复用并把质量标记为 `degraded`。 + `disable_thinking` 也可以通过 `--disable-thinking` 指定。只有显式提供 `--safe`、`--executor`、`--overlayfs-base` 等运行参数,或在 TOML 中增加 `[run]`、`[overlayfs]`、`[overlaynet]`,才会在回放外层创建受管的 `pvisor run`。 完整参数见 [`pvisor replay` 命令参考](../reference/cli.md#replay-an-agent-trajectory)。 diff --git a/docs/src/pvisor/reference/cli.md b/docs/src/pvisor/reference/cli.md index 26b91f92..980b38af 100644 --- a/docs/src/pvisor/reference/cli.md +++ b/docs/src/pvisor/reference/cli.md @@ -118,6 +118,18 @@ replay_only = false disable_thinking = true ``` +Replay has three modes. The default replays the prefix and continues; +`--replay-only` executes the prefix and stops before a model request; and +`--prepare-only` constructs the prefix without executing tools or requiring a +runtime. `--max-steps` is the total action budget, including replayed actions. +`--allow-stale-observations` is an explicit Claude-only escape hatch that marks +the v3 result `degraded`. + +The result schema is `sandbox-playback.result/v3`, with typed `phase`, `quality`, +and `agent_status` fields plus state/output locations, artifacts, and an optional +structured failure. Existing non-Claude callers that used `replay_only = true` +only to construct a prefix must migrate to `prepare_only = true`. + `disable_thinking` belongs to `[replay]` and is also exposed as `--disable-thinking`; it is applied by the Claude protocol bridge without turning on Gateway capture. Optional `[run]`, `[overlayfs]`, and `[overlaynet]` sections create an outer @@ -128,8 +140,8 @@ comparisons, and native working files remain under `/tmp/pvisor-sandbox-replay` and disappear with the sandbox. Replay does not enable pVisor Gateway, pChronicle, a model-traffic capture store, or a Claude Resume Transport audit. A caller that explicitly selects `--state-dir` or -`--output-dir` owns those files. Use `--replay-only` to stop after prefix -reconstruction and tool replay. +`--output-dir` owns those files. Use `--replay-only` to execute the prefix and +stop before live inference, or `--prepare-only` to construct it without execution. ## One configuration model diff --git a/docs/src/rfcs/0001-storyline-format.md b/docs/src/rfcs/0001-storyline-format.md index 33f92966..1230b3a0 100644 --- a/docs/src/rfcs/0001-storyline-format.md +++ b/docs/src/rfcs/0001-storyline-format.md @@ -48,6 +48,10 @@ atif ─────┘ | Required `schema_version` | 当前只接受 `storyline/v1`;未知版本 fail closed | | 可选 `origin` | 记录来源格式、来源 schema 与文档身份,不冒充 Storyline 自身版本 | | `unknown_fields` | 仅保存 Storyline 不认识的源格式 key/value,按来源和 JSON Pointer 隔离 | +| 可选 `/task`、`/started_at`、`/finished_at` | 评测/预算、文档级时间;不升 `schema_version` | +| 可选 `turns[].env`、`turns[].finished_at` | 相对 `/task/env` 的运行时 delta 与 turn 结束时间 | +| 可选 `/prompt`、`turns[].prompt` | ACTF `system_prompt` / `user_content`;文档基线 + turn 整段覆盖 | +| 可选 `tool_calls[].kind`、`tool_calls[].response` | 工具事件类型与执行状态;`result` 仍是输出体 | --- @@ -100,7 +104,7 @@ JSON 序列化和解码都使用短名;长名仅用于说明字段概念,不 | `fn` | `function_name` | tool_calls[] | | `args` | `arguments` | tool_calls[] | -保持全名:`agent` / `final_metrics` / `continued_trajectory_ref` / `tool_calls` / `observation` / `metrics` / `extra` / `latency_ms` / `ttft_ms` / `duration_ms`,以及 `id` / `name` / `notes` / `kind` / `turns` / `parent`。 +保持全名:`agent` / `task` / `env` / `llm` / `result` / `response` / `prompt` / `started_at` / `finished_at` / `final_metrics` / `continued_trajectory_ref` / `tool_calls` / `observation` / `metrics` / `extra` / `meta` / `latency_ms` / `ttft_ms` / `duration_ms`,以及 `id` / `name` / `notes` / `kind` / `turns` / `parent`。`tool_calls[].kind` 与 `turns[].kind` 同名不同槽。 ### 根对象 @@ -115,9 +119,14 @@ JSON 序列化和解码都使用短名;长名仅用于说明字段概念,不 | `trajectory` | string | Optional;非空 | | `attempt_id` | string | Optional;源格式 attempt identity | | `notes` | string | Optional | +| `task` | object | Optional;`env` / `llm` / `result`,至少一项非空 | +| `prompt` | object | Optional;`{system, user}` 文档基线;至少一个非空字符串 | +| `started_at` | string \| number | Optional;与 turn `ts` 同一时间编码 | +| `finished_at` | string \| number | Optional;与 turn `ts` 同一时间编码 | | `final_metrics` | any | Optional | | `continued_trajectory_ref` | string | Optional | | `extra` | any | Optional;Storyline 业务扩展 | +| `meta` | any | Optional;文档级元数据 | | `unknown_fields` | object | Optional;源格式 residual,见下 | | `unknown_key_counts` | object | Optional;必须与 `unknown_fields` 一致 | | `children` | string[] | Optional;子 Storyline identity 外链 | @@ -172,9 +181,18 @@ JSON 序列化和解码都使用短名;长名仅用于说明字段概念,不 | `nllm` | integer | Optional | | `copied` | boolean | Optional | | `extra` | any | Optional | -| `kind` | string | Optional;省略时由 `src` 与 `tool_calls` 推导 | +| `kind` | string | Optional;省略时由 `src` 与 `tool_calls` 推导;不读取 tool `kind` | | `latency_ms` | integer | Optional | | `ttft_ms` | integer | Optional | +| `env` | object | Optional;相对 `/task/env` 的浅合并 delta,不相对前一 turn | +| `prompt` | object | Optional;相对文档 `/prompt` 的整段覆盖,不相对前一 turn | +| `finished_at` | string \| number | Optional;turn 结束时间 | + +`task.env` 与 `turns[].env` 形状相同:`name` / `endpoint` / `id` / `event_type` / `request_id` 为可选字符串;`state` 为开放 JSON object。重建某 turn 的有效 env 时,先取 `/task/env` 再浅合并该 turn 的 `env`(`state` 也浅合并)。落盘保持未合并形态。 + +`task.llm` 目前只有可选正整数 `k`。`task.result` 承载评测与预算:`task_correct` / `correct` / `final_answer` / `ground_truth` / `status` / `score` / `max_score` / `error` / `artifacts` / `category` / `attempts_tried` / `solved_at` / `retry_count` / `retry_counts`。空字符串、空 object、`null` 视为缺省。 + +`prompt` 与 `turns[].prompt` 形状相同:可选字符串 `system` / `user`。有效 prompt 取该 turn 的 `/prompt`,缺省则用文档 `/prompt`;turn 一旦出现 `/prompt` 就整段替换,缺省键视为空字符串,不从文档继承。`copied == true` 的 turn 不得写 `prompt`。文档 `/prompt` 不能是空对象。turn 上唯一允许的双空对象是显式 `{"system":"","user":""}`,用来清空文档基线。 ### `tool_calls[]` @@ -186,6 +204,8 @@ JSON 序列化和解码都使用短名;长名仅用于说明字段概念,不 | `result` | any | Optional | | `duration_ms` | integer | Optional | | `extra` | any | Optional | +| `kind` | string | Optional;工具事件类型(≠ turn `kind`);空字符串视为缺省 | +| `response` | object | Optional;`status` 字符串与/或 `exit_code` 整数 | ### `unknown_fields` 与 `unknown_key_counts` @@ -198,8 +218,8 @@ JSON 序列化和解码都使用短名;长名仅用于说明字段概念,不 `P` 是源文档中的完整 RFC 6901 JSON Pointer;`E(P)` 是把整个 `P` 作为 `fields` 对象 key 后执行一次 RFC 6901 token 转义。`unknown_key_counts` 按 source 和规范化 pointer -记录出现次数,必须能由 `unknown_fields` 确定性重算。已知业务扩展写入 `extra`,不得混入 -`unknown_fields`。 +记录出现次数,必须能由 `unknown_fields` 确定性重算。已知业务扩展写入 `extra`,文档级元数据写入 +`meta`,不得混入 `unknown_fields`。 --- @@ -246,3 +266,5 @@ convert(from, to, input) ≡ from_storyline(to, into_storyline(from, input | 2026-07-30 | 收敛为 ATIF-first:去掉 Capture Call/Normal 过度叙事;`continued_trajectory_ref` 对齐 ATIF;`parent.scid` 可选 | | 2026-08-20 | 固定 `storyline/v1`,增加 `origin` 与统一 unknown fields;Lance v2 保留数组顺序、出现语义和原始 observation | | 2026-08-21 | Storyline schema 与外围格式映射分离;映射由各格式 RFC 独立负责 | +| 2026-08-22 | 增加可选 `/task`、文档/turn 时间、turn `env`、tool `kind`/`response`;`schema_version` 仍为 `storyline/v1` | +| 2026-08-22 | 增加可选文档 `/prompt` 与 turn `/prompt`(`{system, user}`);`msg` 仍是助手正文 | diff --git a/docs/src/rfcs/0004-actf-format.md b/docs/src/rfcs/0004-actf-format.md index 6657add5..25291466 100644 --- a/docs/src/rfcs/0004-actf-format.md +++ b/docs/src/rfcs/0004-actf-format.md @@ -15,7 +15,7 @@ ACTF v1.0 是以 benchmark task 为根、以编号 attempt 为分支、以结构化 agent step 为 轨迹单元的 JSON 格式。pChronicle 将 `actf` 作为 Storyline hub 的外围格式;每个 attempt 转换为一条 Storyline,并写入既有的 `runs.lance`、`steps.lance`、 -`tool_calls.lance` 三表。ACTF 不引入第四张表,也不改变三表 Arrow schema。 +`tool_calls.lance` 三表。新字段作为这三张表上的可空 JSON/时间列投影,不引入第四张表。 ## JSON 数据模型 @@ -35,7 +35,7 @@ ActfDocument ActfAttempt ├── correct: bool ├── final_answer: any | null -├── ground_truth: string +├── ground_truth: string | object | any ├── status: string ├── score: any | null ├── error: string @@ -57,26 +57,34 @@ ActfStep ├── observation: ActfObservation[] └── started_at / finished_at: string -ActfToolCall = { type: string, id: string, ...event-specific fields } -ActfObservation = { type: string, id?: string, tool_use_id?: string, +ActfToolCall = { type?: string, id?: string, name?: string, + arguments? | input? | command?, ...event-specific fields } +ActfObservation = { type?: string, id?: string, tool_use_id?: string, ...event-specific fields } ``` token 数、`llm_infer_ms` 和 `env_action_ms` 可以是数值或 `null`,`stop_reason` 也可以 -显式为 `null`;解析器必须接受两种表示,进入 Storyline 后 missing/null 按同一语义默认值 -规范化。ACTF v1.0 已观察到两种工具事件: +显式为 `null`。`assistant_content.content` / `reasoning_content`、`system_prompt` / +`user_content`、attempt `error` / `status` 也可以是字符串或 `null`;`null` 与缺省、空字符串 +按同一语义规范化(空 `reasoning_content` 使 `/reason` 缺省;空 `error` 使 +`/task/result/error` 缺省;空 `status` 仍校验失败)。解析器必须接受两种表示,进入 +Storyline 后 missing/null 按同一语义默认值规范化。ACTF v1.0 已观察到两种工具事件: `tool_use` 使用 `name/input` 与 `tool_use_id/content`,`command_execution` 使用 `command/aggregated_output/exit_code/status`,并通过共同的 `id` 关联。事件专属字段作为 opaque JSON 保留。 ## 校验约束 -- `task_id`、`category`、attempt id、attempt `status` 必须非空,`k > 0`。 +- `task_id`、`category`、attempt id 必须非空,`k > 0`。attempt `status` 可为空。 - `attempts` 必须非空;`attempts_tried` 等于实际 attempt 数且不得大于 `k`。 -- trajectory 的 `schema_version` 必须是 `ACTF_v1.0`,时间字段必须非空。 +- trajectory 的 `schema_version` 必须是 `ACTF_v1.0`,时间字段必须非空。失败 attempt + 允许把 `trajectory` 写成 OpenClaw 事件数组(`session` / `message` / `model_change` + 等),而不是 `{schema_version, steps, ...}` 对象。探测仍认作 ACTF;导入时 + `message.role=user|assistant|toolResult` 收成 Storyline turns,`toolResult` 折回 + 前一条 assistant 的 `tool_calls`。 - step id 必须为正数并严格递增。 -- 同一步内 tool call id 唯一,`assistant_content.tool_calls` 与 `tools` 相等。 -- observation 存在 `tool_use_id` 或 `id` 时,必须引用同一步的 tool call。 +- 同一步内 tool call id 唯一(缺 `id` 时按 `step-{step_id}-tool-{index}` 合成后再比)。`tools` 与 `assistant_content.tool_calls` 都非空时必须相等;一侧为空时以非空一侧为工具来源。`type` / `id` 可选;`{name, arguments}` 与 OpenAI `{id,type,function:{name,arguments}}` 都合法。attempt `status` 可为空。 +- observation 的 `type` 可选;仅有 `content` 的环境输出合法。存在 `tool_use_id` 或 `id` 时,必须引用同一步的 tool call。 根级 `correct`、`solved_at` 与 attempt 结果之间不施加样本之外的推导约束;它们按输入 原值保存。 @@ -87,80 +95,174 @@ opaque JSON 保留。 `{c}`、`{o}` 和 `{t}` 分别表示 attempt key、源 step 下标、tool call 下标、observation result 下标和目标 turn 下标。代入实际 token 后即为普通 JSON Pointer。 +映射分成四类,禁止把不同角色的槽写进同一格: + +| 类别 | 含义 | 每个源 pointer 的目标数 | +| --- | --- | --- | +| 权威字段 | ACTF 值进入一个 Storyline 领域字段;同源导出从该字段还原 | 恰好 1 | +| 派生身份 | 由一个或多个源值计算,不是字段拷贝 | 公式,见下 | +| 便利提升 | 在权威字段之外再复制到 hub 顶栏或别名 | 额外 0 或 1,不替代权威字段 | +| 残差 | Storyline 没有对应领域字段,按原 pointer 进入 `unknown_fields` | 1(残差槽) | + `P` 表示左侧命中的完整源 pointer;`E(P)` 表示把整个 `P` 作为 `fields` 对象 key 后再做 -一次 RFC 6901 token 转义。所有输出都生成 -`/schema_version = "storyline/v1"`、`/origin/format = "actf"`,并从 -`/unknown_fields` 计算 `/unknown_key_counts`;这些值没有源 pointer,故不列入表。 +一次 RFC 6901 token 转义。 -一个 ACTF document 可以包含多个 attempts,因此映射基数为: +基数: ```text -ACTF document 1 ──► N Storyline runs ──► 原有 Lance 三表 -ACTF attempt 1 ──► 1 Storyline run -ACTF step 1 ──► 1 Storyline step -ACTF tool 1 ──► 1 Storyline tool_call +ACTF document 1 ──► N Storyline 文档(共用 /run = task_id) +ACTF attempt 1 ──► 1 Storyline 文档 +ACTF step 1 ──► 1 Storyline turn +ACTF tool 1 ──► 1 Storyline tool_call ``` +`/turns/{t}` 与源 `steps[{s}]` 保持同一数组顺序。`tools` 是 tool-call 的权威来源; +`tools` 非空时是 tool-call 的权威来源;`tools` 为空则回退到 +`assistant_content.tool_calls`。两侧都非空时必须完全相等,不另写一套目标。 +OpenAI `function.name` / `function.arguments` 分别映射到 `/fn` 与 `/args`,`function` +视为已消费。 + +### 权威字段(1:1) + | ACTF JSON Pointer | Storyline JSON Pointer | | --- | --- | -| `/task_id` | `/run`
`/session`
`/unknown_fields/sources/actf/source_document_id` | -| `/correct` | `/final_metrics/task_correct` | -| `/attempts/{a}` | `/attempt_id`
`/session` | -| `/attempts/{a}/correct` | `/final_metrics/correct` | -| `/attempts/{a}/score` | `/final_metrics/score` | -| `/attempts/{a}/status` | `/final_metrics/status` | +| `/task_id` | `/run` | +| `/correct` | `/task/result/task_correct` | +| `/category` | `/task/result/category` | +| `/k` | `/task/llm/k` | +| `/attempts_tried` | `/task/result/attempts_tried` | +| `/solved_at` | `/task/result/solved_at` | +| `/retry_count` | `/task/result/retry_count` | +| `/retry_counts` | `/task/result/retry_counts` | +| `/attempts/{a}` 的 map key `{a}` | `/attempt_id` | +| `/attempts/{a}/correct` | `/task/result/correct` | +| `/attempts/{a}/score` | `/task/result/score` | +| `/attempts/{a}/max_score` | `/task/result/max_score` | +| `/attempts/{a}/status` | `/task/result/status` | +| `/attempts/{a}/extra` | `/extra` | +| `/attempts/{a}/meta` | `/meta` | +| `/attempts/{a}/final_answer` | `/task/result/final_answer` | +| `/attempts/{a}/ground_truth` | `/task/result/ground_truth` | +| `/attempts/{a}/error` | `/task/result/error` | +| `/attempts/{a}/artifacts` | `/task/result/artifacts` | | `/attempts/{a}/analysis_result` | `/final_metrics/analysis_result` | | `/attempts/{a}/trajectory/schema_version` | `/origin/schema_version` | +| `/attempts/{a}/trajectory/started_at` | `/started_at` | +| `/attempts/{a}/trajectory/finished_at` | `/finished_at` | | `/attempts/{a}/trajectory/steps/{s}/step_id` | `/turns/{t}/id` | | `/attempts/{a}/trajectory/steps/{s}/started_at` | `/turns/{t}/ts` | +| `/attempts/{a}/trajectory/steps/{s}/finished_at` | `/turns/{t}/finished_at` | | `/attempts/{a}/trajectory/steps/{s}/assistant_content/content` | `/turns/{t}/msg` | +| `/attempts/{a}/trajectory/steps/{s}/system_prompt` | 文档 `/prompt/system` 或 `/turns/{t}/prompt/system`(见下) | +| `/attempts/{a}/trajectory/steps/{s}/user_content` | 文档 `/prompt/user` 或 `/turns/{t}/prompt/user`(见下) | | `/attempts/{a}/trajectory/steps/{s}/assistant_content/reasoning_content` | `/turns/{t}/reason` | -| `/attempts/{a}/trajectory/steps/{s}/tools`
`/attempts/{a}/trajectory/steps/{s}/assistant_content/tool_calls` | `/turns/{t}/tool_calls`
`/turns/{t}/kind` | -| `/attempts/{a}/trajectory/steps/{s}/tools/{c}/id`
`/attempts/{a}/trajectory/steps/{s}/assistant_content/tool_calls/{c}/id` | `/turns/{t}/tool_calls/{c}/tcid` | -| `/attempts/{a}/trajectory/steps/{s}/tools/{c}/name`
`/attempts/{a}/trajectory/steps/{s}/assistant_content/tool_calls/{c}/name` | `/turns/{t}/tool_calls/{c}/fn`
`/turns/{t}/tool_calls/{c}/args/name` | -| `/attempts/{a}/trajectory/steps/{s}/tools/{c}/type`
`/attempts/{a}/trajectory/steps/{s}/assistant_content/tool_calls/{c}/type` | `/turns/{t}/tool_calls/{c}/fn`
`/unknown_fields/sources/actf/fields/{E(P)}` | -| `/attempts/{a}/trajectory/steps/{s}/tools/{c}/input`
`/attempts/{a}/trajectory/steps/{s}/assistant_content/tool_calls/{c}/input` | `/turns/{t}/tool_calls/{c}/args` | -| `/attempts/{a}/trajectory/steps/{s}/tools/{c}/command`
`/attempts/{a}/trajectory/steps/{s}/assistant_content/tool_calls/{c}/command` | `/turns/{t}/tool_calls/{c}/args/command` | -| `/attempts/{a}/trajectory/steps/{s}/tools/{c}/aggregated_output`
`/attempts/{a}/trajectory/steps/{s}/assistant_content/tool_calls/{c}/aggregated_output` | `/turns/{t}/tool_calls/{c}/result`
`/turns/{t}/tool_calls/{c}/args/aggregated_output` | -| `/attempts/{a}/trajectory/steps/{s}/metric/prompt_tokens_len` | `/turns/{t}/metrics/prompt_tokens_len` | -| `/attempts/{a}/trajectory/steps/{s}/metric/completion_tokens_len` | `/turns/{t}/metrics/completion_tokens_len` | -| `/attempts/{a}/trajectory/steps/{s}/metric/llm_infer_ms` | `/turns/{t}/metrics/llm_infer_ms`
`/turns/{t}/latency_ms` | -| `/attempts/{a}/trajectory/steps/{s}/metric/env_action_ms` | `/turns/{t}/metrics/env_action_ms`
`/turns/{t}/tool_calls/0/duration_ms` | -| `/attempts/{a}/trajectory/steps/{s}/metric/stop_reason` | `/turns/{t}/metrics/stop_reason` | -| `/attempts/{a}/trajectory/steps/{s}/metric/{other-metric}` | `/turns/{t}/metrics/{other-metric}` | -| `/attempts/{a}/trajectory/steps/{s}/observation/{o}` | `/turns/{t}/observation/results/{o}` | -| `/attempts/{a}/trajectory/steps/{s}/observation/{o}/tool_use_id` | `/turns/{t}/observation/results/{o}/tool_use_id`
`/turns/{t}/observation/results/{o}/source_call_id` | -| `/attempts/{a}/trajectory/steps/{s}/observation/{o}/id` | `/turns/{t}/observation/results/{o}/id`
`/turns/{t}/observation/results/{o}/source_call_id` | -| `/attempts/{a}/trajectory/steps/{s}/observation/{o}/content` | `/turns/{t}/observation/results/{o}/content` | -| `/attempts/{a}/trajectory/steps/{s}/observation/{o}/aggregated_output` | `/turns/{t}/observation/results/{o}/aggregated_output`
`/turns/{t}/observation/results/{o}/content` | -| `/attempts/{a}/trajectory/steps/{s}/observation/{o}/{other-field}` | `/turns/{t}/observation/results/{o}/{other-field}` | -| `/category`
`/k`
`/attempts_tried`
`/solved_at`
`/{other-root-key}` | `/unknown_fields/sources/actf/fields/{E(P)}` | -| `/attempts/{a}/final_answer`
`/attempts/{a}/ground_truth`
`/attempts/{a}/error`
`/attempts/{a}/artifacts`
`/attempts/{a}/extra`
`/attempts/{a}/meta`
`/attempts/{a}/{other-attempt-key}` | `/unknown_fields/sources/actf/fields/{E(P)}` | -| `/attempts/{a}/trajectory/started_at`
`/attempts/{a}/trajectory/finished_at`
`/attempts/{a}/trajectory/{other-trajectory-key}` | `/unknown_fields/sources/actf/fields/{E(P)}` | -| `/attempts/{a}/trajectory/steps/{s}/system_prompt`
`/attempts/{a}/trajectory/steps/{s}/user_content`
`/attempts/{a}/trajectory/steps/{s}/finished_at`
`/attempts/{a}/trajectory/steps/{s}/{other-step-key}` | `/unknown_fields/sources/actf/fields/{E(P)}` | -| `/attempts/{a}/trajectory/steps/{s}/assistant_content/{other-assistant-key}` | `/unknown_fields/sources/actf/fields/{E(P)}` | -| `/attempts/{a}/trajectory/steps/{s}/tools/{c}/{other-tool-key}`
`/attempts/{a}/trajectory/steps/{s}/assistant_content/tool_calls/{c}/{other-tool-key}` | `/turns/{t}/tool_calls/{c}/args/{other-tool-key}`
`/unknown_fields/sources/actf/fields/{E(P)}` | - -条件和规范化规则: - -- 一个 `/attempts/{a}` 生成一份 Storyline。单 attempt 时 `/session = task_id`;多 - attempt 时 `/session = "{task_id}#attempt-{a}"`。 -- `/agent/id` 固定为 `actf-agent`,`/agent/name` 固定为 `ACTF Agent`。每个 turn 固定 - `src=agent, nllm=1`;存在非空 tools 时 `kind=autonomous`。 -- `tools` 是规范化 tool-call 来源;`assistant_content.tool_calls` 必须与其完全相等。 - `name` 优先作为 `/fn`,缺失或为空时回退到 `type`。 -- arguments 按 `input → {"command": command} → flattened tool fields object` 选择。 - 因此 `/args/name`、`/args/aggregated_output` 和 `/args/{other-tool-key}` 只在 `input` - 与 `command` 都不存在时产生;无论 `{other-tool-key}` 是否进入 `/args`,它仍以源 pointer - 保存在 `unknown_fields`。`type` 也总是额外保留,以便同源恢复。 -- `env_action_ms` 仅在该 step 恰有一个 tool call 且值为 number 时,才同时写入该 call - 的 `/duration_ms`。所有 metric 字段始终保留在 `/metrics`。 -- observation result 整体进入 `/observation/results`。`tool_use_id` 优先于 `id` 生成 - `source_call_id`;`aggregated_output` 优先于 `content` 生成规范化 `content`,原字段仍 - 保留在 result 内。 -- unknown root 字段附到该 document 产生的每个 Storyline attempt;同源恢复时要求它们 - 一致。恢复使用 `/run`、`/attempt_id` 和 ACTF unknown fields 重组 attempt map;跨格式 - 转换通过 version-1 `_storyline` envelope 携带外来 unknown fields。 +| `/attempts/{a}/trajectory/steps/{s}/tools` | `/turns/{t}/tool_calls` | +| `/attempts/{a}/trajectory/steps/{s}/tools/{c}/id` | `/turns/{t}/tool_calls/{c}/tcid` | +| `/attempts/{a}/trajectory/steps/{s}/tools/{c}/name` | `/turns/{t}/tool_calls/{c}/fn` | +| `/attempts/{a}/trajectory/steps/{s}/tools/{c}/type` | `/turns/{t}/tool_calls/{c}/kind` | +| `/attempts/{a}/trajectory/steps/{s}/tools/{c}/input` | `/turns/{t}/tool_calls/{c}/args` | +| `/attempts/{a}/trajectory/steps/{s}/tools/{c}/command` | `/turns/{t}/tool_calls/{c}/args/command` | +| `/attempts/{a}/trajectory/steps/{s}/tools/{c}/aggregated_output` | `/turns/{t}/tool_calls/{c}/result` | +| `/attempts/{a}/trajectory/steps/{s}/tools/{c}/status` | `/turns/{t}/tool_calls/{c}/response/status` | +| `/attempts/{a}/trajectory/steps/{s}/tools/{c}/exit_code` | `/turns/{t}/tool_calls/{c}/response/exit_code` | +| `/attempts/{a}/trajectory/steps/{s}/metric` | `/turns/{t}/metrics` | +| `/attempts/{a}/trajectory/steps/{s}/observation` | `/turns/{t}/observation/results` | + +`system_prompt` / `user_content` 按 pair 去重。第一个至少一侧非空的 pair 写入文档 +`/prompt`(空字符串键省略)。后续 step:pair 与基线相同则已消费,turn 不写 +`/prompt`;不同则该 turn `/prompt` 写当前完整 pair(整段覆盖,不是浅合并)。基线之前的 +双空 step 写 `{"system":"","user":""}`。导出时 +`system_prompt` / `user_content` 取 `turn.prompt` 否则文档 `/prompt`,缺省为 `""`。 + +`/metric` 与 `/observation` 是整对象/整数组复制。因此 +`prompt_tokens_len`、`completion_tokens_len`、`llm_infer_ms`、`env_action_ms`、 +`stop_reason` 及未知 metric 键都在 `/metrics` 内;observation 元素的 `type`、 +`tool_use_id`、`id`、`content`、`aggregated_output` 及其余键都在对应 +`/observation/results/{o}` 对象内。这些子键不再单独占一行权威映射。 + +空 `tools` 使 `/tool_calls` 缺省(不是空数组)。空 `observation` 使 `/observation` +缺省。空字符串 `reasoning_content` 使 `/reason` 缺省。空 `error`、空 `artifacts`、 +`null` 的 `final_answer` / `solved_at` / `score` 使对应 `/task/result` 键缺省。 + +`name` 为空或缺失时 `/fn` 回退到同 call 的 `type`;`type` 的权威目标只是 `/kind`。 +`args` 按 `input` → `arguments` → `function.arguments` → `{"command": command}` → +剩余 tool 对象(不含 `type`/`id`/`status`/`exit_code`)选择;因此 `/args/name` 等扁平键只在既无 `input` +也无 `arguments` / `command` 时出现,不是 `name` 的权威目标。`name` / `input` / `arguments` / +`function` / `command` / `aggregated_output` / `type` / `status` / `exit_code` 只要在源对象上出现,就视为已消费,即使这次选择没有用到该键也不 +写入残差。缺 `id` 时 Storyline `/tcid` 为 `step-{step_id}-tool-{index}`。`assistant_content.tool_calls` 上的同一组键同样已消费,不另写残差。 + +### 派生身份(不是字段拷贝) + +Storyline 要求每份文档有非空 `/session`。ACTF 没有 session 字段。`/session` 由 +`task_id` 与 attempt key 计算,不得再写进权威映射表: + +```text +/session := + task_id 当 attempts 恰有 1 个 + "{task_id}#attempt-{a}" 当 attempts 多于 1 个 +``` + +残差文档键同样由 `task_id` 赋值,但它不是 Storyline 领域字段,只用来把同一 ACTF +文档拆出的 N 条 Storyline 在导出时合并: + +```text +/unknown_fields/sources/actf/source_document_id := task_id +``` + +`/origin/document_id` 对 ACTF 保持缺省。同源恢复用 `/run` 还原 `task_id`,用 +`/attempt_id` 还原 attempt map key,用上述 `source_document_id` 校验切片来自同一文档。 + +没有源 pointer 的常量: + +| Storyline | 值 | +| --- | --- | +| `/schema_version` | `"storyline/v1"` | +| `/origin/format` | `"actf"` | +| `/agent/id` | `"actf-agent"` | +| `/agent/name` | `"ACTF Agent"` | +| `/turns/{t}/src` | `"agent"` | +| `/turns/{t}/nllm` | `1` | +| `/turns/{t}/kind` | `"autonomous"`(该 step 的 `tools` 非空);否则缺省 | + +### 便利提升 + +权威字段已经保存完整值。下列复制只服务 Storyline hub 顶栏或 ATIF 对齐别名;缺失或 +条件不满足时不写,不得替代权威字段。 + +| 源(已在权威表中) | 额外写入 | 条件 | +| --- | --- | --- | +| `/task/result/task_correct` | `/final_metrics/task_correct` | 有值 | +| `/task/result/correct` | `/final_metrics/correct` | 有值 | +| `/task/result/status` | `/final_metrics/status` | 有值 | +| `/task/result/score` | `/final_metrics/score` | 有值 | +| `/task/result/max_score` | `/final_metrics/max_score` | 有值 | +| `/metrics/llm_infer_ms` | `/turns/{t}/latency_ms` | 值为 number | +| `/metrics/env_action_ms` | `/turns/{t}/tool_calls/0/duration_ms` | 值为 number,且该 step 恰有 1 个 tool call | +| `results/{o}/tool_use_id` | `results/{o}/source_call_id` | 非空;优先于 `id` | +| `results/{o}/id` | `results/{o}/source_call_id` | 非空,且没有 `tool_use_id` | +| `results/{o}/aggregated_output` | `results/{o}/content` | 有 `aggregated_output`;否则才用已有 `content` | + +### 残差 + +未进入权威字段的键按原 pointer 写入 +`/unknown_fields/sources/actf/fields/{E(P)}`。`task_id`、`correct`、`attempts` 容器、 +已映射的 attempt `correct`/`score`/`max_score`/`status`/`analysis_result`/`final_answer`/`ground_truth`/`error`/`artifacts`/`extra`/`meta`/`trajectory.steps`, +根上的 `category`/`k`/`attempts_tried`/`solved_at`/`retry_count`/`retry_counts`, +trajectory 的 `started_at`/`finished_at`,step 的 `finished_at`、`system_prompt`、`user_content`, +以及 tool 的 `id`/`name`/`input`/`command`/`aggregated_output`/`type`/`status`/`exit_code` 不再作为残差保存。 + +| ACTF JSON Pointer | +| --- | +| `/{other-root-key}` | +| `/attempts/{a}/{other-attempt-key}` | +| `/attempts/{a}/trajectory/{other-trajectory-key}` | +| `/attempts/{a}/trajectory/steps/{s}/{other-step-key}` | +| `/attempts/{a}/trajectory/steps/{s}/assistant_content/{other-assistant-key}` | +| `/attempts/{a}/trajectory/steps/{s}/tools/{c}/{other-tool-key}` | +| `/attempts/{a}/trajectory/steps/{s}/assistant_content/tool_calls/{c}/{other-tool-key}` | + +未知 root 残差复制到该 document 产生的每一条 Storyline;同源恢复时这些副本必须一致。 +跨格式转换通过 version-1 `_storyline` envelope 携带外来 unknown fields。 ## 保真边界 @@ -168,4 +270,19 @@ ACTF → Storyline → 三表 Lance → Storyline → ACTF 保证规范化 JSON 未知键及其值(包括 `null`)、嵌套值、数组顺序和 attempt 分组均保留。已知字段的 missing/显式 `null` 会按 Storyline 语义规范化;源文件空白、缩进和对象键顺序不属于保真 边界。没有 ACTF source unknown fields 的普通 Storyline 可以导出为结构合法的单 attempt ACTF, -但这是有定义的合成转换,不宣称还原某个原始 ACTF 文件。 +但这是有定义的合成转换,不宣称还原某个原始 ACTF 文件。`unknown_key_counts` 由 +`unknown_fields` 确定性重算,没有独立源 pointer。 + +## Amendment history + +| Date | Change | +| --- | --- | +| 2026-08-22 | 主映射改为每个源 pointer 恰好一个权威目标。`/session` 与 `source_document_id` 从 `/task_id` 拆出为派生身份;`kind`、`/fn` 回退、`latency_ms` / `duration_ms` / `source_call_id` / 规范化 `content` 改为便利提升。基数用语不再把 Storyline 文档叫成 run。 | +| 2026-08-22 | 评测/预算、文档与 step 结束时间、tool `type`/`status`/`exit_code` 进入 `/task`、`/started_at`、`/finished_at`、`tool_calls[].kind`/`response`;`task_correct`/`correct`/`status`/`score` 提升到 `/final_metrics`。 | +| 2026-08-22 | `system_prompt` / `user_content` 进入文档 `/prompt` 与 turn `/prompt`;不再作为残差。 | +| 2026-08-22 | `assistant_content.content` / `reasoning_content`、`system_prompt` / `user_content`、attempt `error` / `status` 接受 `null`,与空字符串同一规范化。 | +| 2026-08-22 | observation `type` 可选;仅有 `content` 的观察合法。合成导出仅在存在 tool 引用时补 `type=tool_result`。 | +| 2026-08-22 | `tools` 为空时回退 `assistant_content.tool_calls`;接受 OpenAI `function` 形状。 | +| 2026-08-22 | attempt `ground_truth` 接受任意 JSON(含 `{checklist_path}` 对象),权威目标仍是 `/task/result/ground_truth`。 | +| 2026-08-22 | tool `type`/`id` 可选;`{name,arguments}` 映射 `/fn`/`/args`,缺 `id` 合成 `step-{step_id}-tool-{index}`。attempt `status` 可空。 | +| 2026-08-22 | attempt `extra`/`meta` 进入文档 `/extra`/`/meta`;`max_score` 进入 `/task/result/max_score`。 | diff --git a/docs/src/rfcs/0009-openai-messages-format.md b/docs/src/rfcs/0009-openai-messages-format.md index 0937851c..508a324c 100644 --- a/docs/src/rfcs/0009-openai-messages-format.md +++ b/docs/src/rfcs/0009-openai-messages-format.md @@ -63,120 +63,184 @@ assistant output 优先选择包含有效输出的 `/response`;否则从 `mess 本节是 OpenAI Messages 到 Storyline 字段映射的权威定义。顶层数组在映射和 unknown-field 记录中规范化为 `/session_steps/{r}`。指针遵循 RFC 6901;`{r}`、`{m}`、`{c}`、`{o}` 和 `{t}` 分别表示 row、message、tool call、observation result 和目标 turn 下标。 -`{last-user}`、`{selected-assistant}`、`{context-message}` 与 `{tool-message}` 表示按上一节 -选中的 message 下标。 +`{last-user}`、`{output}`、`{context-message}` 与 `{tool-message}` 表示按上一节选中的 +message:`{output}` 是有效 `/response`,否则是 `messages` 里选中的 assistant。 + +映射分成四类,禁止把不同角色的槽写进同一格: + +| 类别 | 含义 | 每个源 pointer 的目标数 | +| --- | --- | --- | +| 权威字段 | OpenAI 值进入一个 Storyline 领域字段;同源导出从该字段还原 | 恰好 1 | +| 派生身份 | 由一个或多个源值计算,不是字段拷贝 | 公式,见下 | +| 便利提升 | 在权威字段之外再复制到 hub 顶栏或别名 | 额外 0 或 1,不替代权威字段 | +| 残差 | Storyline 没有对应领域字段,按原 pointer 进入 `unknown_fields` | 1(残差槽) | `P` 表示左侧命中的完整源 pointer;`E(P)` 表示把整个 `P` 作为 `fields` 对象 key 后再做 -一次 RFC 6901 token 转义。例如 `/session_steps/0/env_name` 保存在 -`/unknown_fields/sources/openai-msg/fields/~1session_steps~10~1env_name`。所有输出都生成 -`/schema_version = "storyline/v1"`、`/origin/format = "openai-msg"`、 -`/origin/document_id = source relative path`,并从 `/unknown_fields` 计算 -`/unknown_key_counts`;这些值没有源 pointer,故不列入表。 +一次 RFC 6901 token 转义。例如 `/session_steps/0/vendor_row` 保存在 +`/unknown_fields/sources/openai-msg/fields/~1session_steps~10~1vendor_row`。 + +基数: + +```text +OpenAI 文件 1 ──► N Storyline 文档(按 session_id 分组) +OpenAI row 1 ──► 0 或 1 条 request turn + 1 条 response turn +第一行 last-user 之前的 + system/user/assistant 1 ──► 1 条 copied context turn +OpenAI tool message 1 ──► 1 条 observation result +OpenAI tool call 1 ──► 1 条 Storyline tool_call +``` + +`meta_json` 与 `meta_json.env_state` 可以是 object,也可以是编码 object 的 JSON string; +表中的子 pointer 表示解码后的逻辑路径。无法解码时整个值进入残差。 + +### 权威字段(1:1) | OpenAI Messages JSON Pointer | Storyline JSON Pointer | | --- | --- | | `/session_steps/{r}/session_id` | `/session` | -| `/session_steps/{r}/step_id` | `/turns/{request-t}/id`
`/turns/{response-t}/id` | -| `/session_steps/{r}/created_at` | `/turns/{request-t}/ts`
`/turns/{response-t}/ts` | -| `/session_steps/{r}/run_id` | `/run` | -| `/session_steps/{r}/run_bucket` | `/run` | -| `/session_steps/{r}/job_id` | `/run` | -| `/session_steps/{r}/env_id` | `/session` | -| `/session_steps/{r}/agent_id` | `/agent/id`
`/agent/name` | -| `/session_steps/{r}/meta_json/source` | `/agent/id`
`/agent/name` | -| `/session_steps/{r}/agent_model` | `/agent/model`
`/turns/{response-t}/model` | -| `/session_steps/{r}/llm_model` | `/agent/model`
`/turns/{response-t}/model` | -| `/session_steps/{r}/meta_json/env_state/session_id` | `/session` | -| `/session_steps/{r}/meta_json/env_state/requested_model` | `/turns/{response-t}/model` | -| `/session_steps/{r}/meta_json/env_state/llm_step_index` | `/turns/{request-t}/id`
`/turns/{response-t}/id` | -| `/session_steps/0/messages/{context-message}/role` | `/turns/{t}/src`
`/turns/{t}/kind`
`/turns/{t}/copied` | +| `/session_steps/{r}/created_at` | `/turns/{response-t}/ts` | | `/session_steps/0/messages/{context-message}/content` | `/turns/{t}/msg` | -| `/session_steps/{r}/messages/{last-user}/role` | `/turns/{request-t}/src`
`/turns/{request-t}/kind` | | `/session_steps/{r}/messages/{last-user}/content` | `/turns/{request-t}/msg` | -| `/session_steps/{r}/messages/{tool-message}/role` | `/turns/{response-t}/observation/results/{o}` | | `/session_steps/{r}/messages/{tool-message}/tool_call_id` | `/turns/{response-t}/observation/results/{o}/source_call_id` | | `/session_steps/{r}/messages/{tool-message}/content` | `/turns/{response-t}/observation/results/{o}/content` | -| `/session_steps/{r}/response/role`
`/session_steps/{r}/messages/{selected-assistant}/role` | `/turns/{response-t}/src`
`/turns/{response-t}/kind` | -| `/session_steps/{r}/response/content`
`/session_steps/{r}/messages/{selected-assistant}/content` | `/turns/{response-t}/msg`
`/turns/{response-t}/tool_calls/{c}` | -| `/session_steps/{r}/response/refusal`
`/session_steps/{r}/messages/{selected-assistant}/refusal` | `/turns/{response-t}/msg` | -| `/session_steps/{r}/response/reasoning_content`
`/session_steps/{r}/messages/{selected-assistant}/reasoning_content` | `/turns/{response-t}/reason` | -| `/session_steps/{r}/response/tool_calls/{c}/id`
`/session_steps/{r}/messages/{selected-assistant}/tool_calls/{c}/id`
`/session_steps/0/messages/{context-message}/tool_calls/{c}/id` | `/turns/{t}/tool_calls/{c}/tcid` | -| `/session_steps/{r}/response/tool_calls/{c}/type`
`/session_steps/{r}/messages/{selected-assistant}/tool_calls/{c}/type`
`/session_steps/0/messages/{context-message}/tool_calls/{c}/type` | `/turns/{t}/tool_calls/{c}`
`/unknown_fields/sources/openai-msg/fields/{E(P)}` | -| `/session_steps/{r}/response/tool_calls/{c}/function/name`
`/session_steps/{r}/messages/{selected-assistant}/tool_calls/{c}/function/name`
`/session_steps/0/messages/{context-message}/tool_calls/{c}/function/name` | `/turns/{t}/tool_calls/{c}/fn` | -| `/session_steps/{r}/response/tool_calls/{c}/function/arguments`
`/session_steps/{r}/messages/{selected-assistant}/tool_calls/{c}/function/arguments`
`/session_steps/0/messages/{context-message}/tool_calls/{c}/function/arguments` | `/turns/{t}/tool_calls/{c}/args` | -| `/session_steps/{r}/reward` | `/turns/{response-t}/metrics/reward`
`/final_metrics/reward` | -| `/session_steps/{r}/step_reward` | `/turns/{response-t}/metrics/step_reward`
`/final_metrics/step_reward` | -| `/session_steps/{r}/is_terminal` | `/turns/{response-t}/metrics/is_terminal`
`/final_metrics/is_terminal` | -| `/session_steps/{r}/is_truncated` | `/turns/{response-t}/metrics/is_truncated`
`/final_metrics/is_truncated` | -| `/session_steps/{r}/is_session_completed` | `/turns/{response-t}/metrics/is_session_completed`
`/final_metrics/is_session_completed` | -| `/session_steps/{r}/is_trainable` | `/turns/{response-t}/metrics/is_trainable`
`/final_metrics/is_trainable` | -| `/session_steps/{r}/meta_json/env_state/prompt_tokens` | `/turns/{response-t}/metrics/prompt_tokens`
`/final_metrics/prompt_tokens` | -| `/session_steps/{r}/meta_json/env_state/completion_tokens` | `/turns/{response-t}/metrics/completion_tokens`
`/final_metrics/completion_tokens` | -| `/session_steps/{r}/meta_json/env_state/total_tokens` | `/turns/{response-t}/metrics/total_tokens`
`/final_metrics/total_tokens` | -| `/session_steps/{r}/meta_json/env_state/request_bytes` | `/turns/{response-t}/metrics/request_bytes`
`/final_metrics/request_bytes` | -| `/session_steps/{r}/meta_json/env_state/response_bytes` | `/turns/{response-t}/metrics/response_bytes`
`/final_metrics/response_bytes` | -| `/session_steps/{r}/meta_json/env_state/output_bytes` | `/turns/{response-t}/metrics/output_bytes`
`/final_metrics/output_bytes` | -| `/session_steps/{r}/meta_json/env_state/output_chunk_count` | `/turns/{response-t}/metrics/output_chunk_count`
`/final_metrics/output_chunk_count` | -| `/session_steps/{r}/meta_json/env_state/finish_reason` | `/turns/{response-t}/metrics/finish_reason`
`/final_metrics/finish_reason` | -| `/session_steps/{r}/meta_json/env_state/status_code` | `/turns/{response-t}/metrics/status_code`
`/final_metrics/status_code` | -| `/session_steps/{r}/meta_json/env_state/retry_count` | `/turns/{response-t}/metrics/retry_count`
`/final_metrics/retry_count` | -| `/session_steps/{r}/meta_json/env_state/upstream_latency_ms` | `/turns/{response-t}/metrics/upstream_latency_ms`
`/final_metrics/upstream_latency_ms` | -| `/session_steps/{r}/meta_json/env_state/gateway_overhead_ms` | `/turns/{response-t}/metrics/gateway_overhead_ms`
`/final_metrics/gateway_overhead_ms` | -| `/session_steps/{r}/meta_json/env_state/total_latency_ms` | `/turns/{response-t}/metrics/total_latency_ms`
`/turns/{response-t}/latency_ms`
`/final_metrics/total_latency_ms` | -| `/session_steps/{r}/meta_json/env_state/ttft_ms` | `/turns/{response-t}/metrics/ttft_ms`
`/turns/{response-t}/ttft_ms`
`/final_metrics/ttft_ms` | -| `/session_steps/{r}/meta_json/env_state/truncate_reason` | `/turns/{response-t}/metrics/truncate_reason`
`/final_metrics/truncate_reason` | -| `/session_steps/{r}/meta_json/env_state/error_type` | `/turns/{response-t}/metrics/error_type`
`/final_metrics/error_type` | -| `/session_steps/{r}/meta_json/env_state/error_text` | `/turns/{response-t}/metrics/error_text`
`/final_metrics/error_text` | -| `/session_steps/{r}/meta_json/env_state/client_cancelled` | `/turns/{response-t}/metrics/client_cancelled`
`/final_metrics/client_cancelled` | -| `/session_steps/{r}/meta_json/env_state/upstream_cancelled` | `/turns/{response-t}/metrics/upstream_cancelled`
`/final_metrics/upstream_cancelled` | -| `/session_steps/{r}/meta_json/env_state/synthetic_stop` | `/turns/{response-t}/metrics/synthetic_stop`
`/final_metrics/synthetic_stop` | -| `/session_steps/{r}/meta_json/env_state/is_truncated` | `/turns/{response-t}/metrics/is_truncated`
`/final_metrics/is_truncated` | -| `/session_steps/{r}/meta_json/env_state/is_session_completed` | `/turns/{response-t}/metrics/is_session_completed`
`/final_metrics/is_session_completed` | -| `/session_steps/{r}/meta_json/env_state/max_steps` | `/turns/{response-t}/metrics/max_steps`
`/final_metrics/max_steps` | -| `/session_steps/{r}/meta_json/env_state/is_stream` | `/turns/{response-t}/metrics/is_stream`
`/final_metrics/is_stream` | -| `/session_steps/{r}/meta_json/env_state/payload_sampled` | `/turns/{response-t}/metrics/payload_sampled`
`/final_metrics/payload_sampled` | -| `/session_steps/{r}/meta_json/env_state/created_at` | `/turns/{response-t}/metrics/created_at`
`/final_metrics/created_at` | -| `/session_steps/{r}/meta_json/env_state/completed_at` | `/turns/{response-t}/metrics/completed_at`
`/final_metrics/completed_at` | -| `/{unmapped-root}`
`/session_steps/{r}/{unmapped-row}`
`/session_steps/{r}/messages/{m}/{unmapped-message}`
`/session_steps/{r}/response/{unmapped-message}`
`/session_steps/{r}/messages/{m}/tool_calls/{c}/{unmapped-call}`
`/session_steps/{r}/response/tool_calls/{c}/{unmapped-call}`
`/session_steps/{r}/messages/{m}/tool_calls/{c}/function/{unmapped-function}`
`/session_steps/{r}/response/tool_calls/{c}/function/{unmapped-function}`
`/session_steps/{r}/meta_json/{unmapped-meta}`
`/session_steps/{r}/meta_json/env_state/{unmapped-env}` | `/unknown_fields/sources/openai-msg/fields/{E(P)}` | - -条件和规范化规则: - -- `{request-t} = context_count + 2 × step_id - 1`, - `{response-t} = context_count + 2 × step_id`。`context_count` 是第一行实际接纳的 context - turns 数,不一定等于原 message 数。 -- context role 映射为 `system → system`、`user → user`、`assistant → agent`;request 固定 - 为 `src=user, kind=llm.request`,response 固定为 `src=agent`,有 tool calls 时 - `kind=autonomous`,否则为 `llm.response`。 -- `/run` 按 `run_id → run_bucket → job_id` 选择 session 内第一个非空值;后续 row 必须 - 一致。`/agent/id` 按 `agent_id → meta_json.source → 首个 model → openai-import` 选择。 - model 在每行按 `agent_model → llm_model` 选择,首个 model 同时写入 `/agent/model`。 -- `env_id`、`env_state.session_id`、`env_state.requested_model` 和 - `env_state.llm_step_index` 只在与规范字段一致时视为冗余别名;不一致值进入 - `unknown_fields`。 -- `meta_json` 和 `meta_json.env_state` 可以是 object,也可以是编码 object 的 JSON string; - 表中的子 pointer 表示解码后的逻辑路径。无法解码时整个值进入 `unknown_fields`。 -- `function.arguments` 若为合法 JSON string,会解析成对应 JSON value;否则保留原 string。 - 没有结构化 `tool_calls` 时,response content 中受支持的 `` 或 - `` 标记还会派生 `/tool_calls`,原 content 仍写入 `/msg`。 -- tool call 的 `type="function"` 是结构判别值,没有独立 Storyline 字段,会被规范化掉; - 其它 `type` 值不影响 id/name/arguments 映射,并额外进入 `unknown_fields`。 -- `/final_metrics/*` 只复制 session 最后一个 response turn 的 metrics。row 顶层 metric 与 - env-state metric 同名时,row 顶层值优先。 -- 已映射 message 的 `name`、`refusal`、`tool_call_id`、`tool_calls` 中的 `null` 或空容器, - 以及 row 的 `blob_manifest`、`chosen_response`、`rejected_response`、 - `ground_truth_answer`、`reference_answer` 中的 `null` 或空容器,按缺失值规范化;非空且 - 未命中表中语义的值进入 `unknown_fields`。 -- 后续 row 重复携带的历史 messages 被视为 context 副本,不重复生成 turns。当前实现会 - 规范化掉这些副本中已识别的 role/content;只有每行最后一个 user、选中的 assistant - output 和 tool-role results 产生新语义。只有选中的 assistant output 保留 - `reasoning_content`;未选中的 assistant 副本中的 string/null `reasoning_content`,以及 - 没有有效 content 时的非空 `refusal`,也按副本规范化,不进入 `unknown_fields`。 -- 除上述明确的结构判别、冗余副本和空值规范化外,不能映射到 Storyline 已知字段的值都 - 进入 `unknown_fields`,同源恢复按原 pointer 写回。外来格式 residual 通过 version-1 - `_storyline` envelope 携带。 +| `/session_steps/{r}/{output}/content` | `/turns/{response-t}/msg` | +| `/session_steps/{r}/{output}/reasoning_content` | `/turns/{response-t}/reason` | +| `/session_steps/{r}/{output}/tool_calls/{c}/id` | `/turns/{response-t}/tool_calls/{c}/tcid` | +| `/session_steps/{r}/{output}/tool_calls/{c}/function/name` | `/turns/{response-t}/tool_calls/{c}/fn` | +| `/session_steps/{r}/{output}/tool_calls/{c}/function/arguments` | `/turns/{response-t}/tool_calls/{c}/args` | +| `/session_steps/0/messages/{context-message}/tool_calls/{c}/id` | `/turns/{t}/tool_calls/{c}/tcid` | +| `/session_steps/0/messages/{context-message}/tool_calls/{c}/function/name` | `/turns/{t}/tool_calls/{c}/fn` | +| `/session_steps/0/messages/{context-message}/tool_calls/{c}/function/arguments` | `/turns/{t}/tool_calls/{c}/args` | +| `/session_steps/{r}/env_name` | `/task/env/name` | +| `/session_steps/{r}/meta_json/env_state/endpoint` | `/task/env/endpoint` | +| `/session_steps/{r}/dataset_type` | `/task/env/state/dataset_type` | +| `/session_steps/{r}/dt` | `/task/env/state/dt` | +| `/session_steps/{r}/meta_json/group_id` | `/task/env/state/group_id` | +| `/session_steps/{r}/meta_json/env_state/redaction_policy` | `/task/env/state/redaction_policy` | +| `/session_steps/{r}/meta_json/env_state/upstream_base_url` | `/task/env/state/upstream_base_url` | +| `/session_steps/{r}/meta_json/env_state/weight_version` | `/task/env/state/weight_version` | +| `/session_steps/{r}/id` | `/turns/{response-t}/env/id` | +| `/session_steps/{r}/meta_json/env_state/event_type` | `/turns/{response-t}/env/event_type` | +| `/session_steps/{r}/meta_json/env_state/request_id` | `/turns/{response-t}/env/request_id` | + +已知 metric 键组成 response turn 上的一个 `/metrics` 对象,不再为每个键单列一行: + +| 源 | 进入 `/turns/{response-t}/metrics` 的键 | +| --- | --- | +| `/session_steps/{r}/reward`、`/session_steps/{r}/step_reward`、`/session_steps/{r}/is_terminal`、`/session_steps/{r}/is_truncated`、`/session_steps/{r}/is_session_completed`、`/session_steps/{r}/is_trainable` | 同名 | +| `/session_steps/{r}/meta_json/env_state/{metric}` | 同名;`{metric}` 为 `prompt_tokens`、`completion_tokens`、`total_tokens`、`request_bytes`、`response_bytes`、`output_bytes`、`output_chunk_count`、`finish_reason`、`status_code`、`retry_count`、`upstream_latency_ms`、`gateway_overhead_ms`、`total_latency_ms`、`ttft_ms`、`truncate_reason`、`error_type`、`error_text`、`client_cancelled`、`upstream_cancelled`、`synthetic_stop`、`is_truncated`、`is_session_completed`、`max_steps`、`is_stream`、`payload_sampled`、`created_at`、`completed_at` | + +row 顶层与 `env_state` 同名时,row 顶层值写入 `/metrics`,`env_state` 里的同名键视为已消费。 +`function.arguments` 若为合法 JSON string,权威 `args` 是解析后的 JSON value,否则保留原 +string。没有结构化 `tool_calls` 时,`{output}/content` 仍只映射到 `/msg`;其中受支持的 +`` / `` 标记另外**派生** `/tool_calls`,不是 `content` 的第二 +个权威目标。`{output}/refusal` 仅在 `content` 没有有效值时回退写入 `/msg`,此时 `refusal` +视为已消费;若与有效 `content` 同时存在,`refusal` 进残差。 + +session-stable 的 `/task/env` 键取该 session **第一个非空值**;后续 row 上相等的值视为已消费 +别名;后续不相等的值写入该 response turn 的 `/env`(含 `state` 浅 delta),导入不失败。 +`id` / `event_type` / `request_id` 只写 response turn `/env`,不提升到 `/task/env`。copied +context turns 不写 `env`。`env_state` 里已进入 `/metrics` 的键(tokens、latency、 +`status_code`、`finish_reason`、`created_at`/`completed_at` 等)不写入 `env`。row +`created_at` 不提升为文档 `/started_at`。 + +### 派生身份(不是字段拷贝) + +```text +{request-t} := context_count + 2 × step_id - 1 当该 row 有 last-user +{response-t} := context_count + 2 × step_id +/run := session 内第一个非空 run_id → run_bucket → job_id +/agent/id := 第一个非空 agent_id → meta_json.source → 首个 model → "openai-import" +/agent/name := /agent/id +/agent/model := session 内第一个非空 agent_model → llm_model +/turns/{response-t}/model := 该 row 的 agent_model → llm_model +``` + +未选中的 `llm_model` 仅当等于已选 model 时才消费,否则进残差。 + +`context_count` 是第一行实际接纳的 context turns 数,不一定等于原 message 数。`step_id` +只用于计算 turn id,不拷贝到两个 `/id`。同一 session 后续 row 的 `/run` 候选必须与已选 +值一致,否则导入失败。 + +没有 JSON 源 pointer 的常量与文件身份: + +| Storyline | 值 | +| --- | --- | +| `/schema_version` | `"storyline/v1"` | +| `/origin/format` | `"openai-msg"` | +| `/origin/document_id` | 源文件相对路径 | +| `/unknown_fields/sources/openai-msg/source_document_id` | 同上;残差文档键,用来把同一文件拆出的 N 条 Storyline 再拼回去 | +| `/origin/schema_version` | 缺省 | +| context `/src` | `system → system`、`user → user`、`assistant → agent` | +| context `/copied` | `true` | +| context `/kind` | 有 tool calls 时 `"autonomous"`,assistant 为 `"llm.response"`,user 为 `"llm.request"`,system 为 `"context"` | +| request `/src`、`/kind` | `"user"`、`"llm.request"` | +| response `/src`、`/nllm` | `"agent"`、`1` | +| response `/kind` | 有 tool calls 时 `"autonomous"`,否则 `"llm.response"` | + +`env_id`、`env_state.session_id` 仅当等于 `/session` 时视为冗余别名并消费; +`env_state.requested_model` 仅当等于该 row 已选 model 时消费;`env_state.llm_step_index` +仅当等于该 row `step_id` 时消费。不一致值进入残差,不改写 `/session`、`/model` 或 turn id。 + +tool-role message 的 `role` 只用于选出 observation,不映射到 result 对象。未选中的 +assistant / 历史 messages 副本不生成新 turn。 + +### 便利提升 + +| 源(已在权威表或派生结果中) | 额外写入 | 条件 | +| --- | --- | --- | +| `/turns/{response-t}/ts` | `/turns/{request-t}/ts` | 该 row 有 request turn | +| `/turns/{response-t}/metrics` | `/final_metrics` | 只复制 session **最后一个** response turn | +| `/metrics/total_latency_ms` | `/turns/{response-t}/latency_ms` | 值为 number | +| `/metrics/ttft_ms` | `/turns/{response-t}/ttft_ms` | 值为 number | + +### 残差 + +未进入权威字段且未被当作冗余别名/结构判别/空值消费的键,按原 pointer 写入 +`/unknown_fields/sources/openai-msg/fields/{E(P)}`。已消费、不再作为残差保存的包括: +`session_id`、`step_id`、`created_at`、已选 run/agent/model 键、row 顶层与 `env_state` +中已进入 `/metrics` 的键、已进入 `/task/env` 或 response `/env` 的 env 键、选中 message 的 `role`/`content`、作为 output 消费的 +`refusal`、string/null 的选中 `reasoning_content`、有效 tool call 的 `id` / +`function.name` / `function.arguments`、以及 `type="function"`。其它 `type` 值进入残差。 + +| OpenAI Messages JSON Pointer | +| --- | +| `/{unmapped-root}` | +| `/session_steps/{r}/{unmapped-row}` | +| `/session_steps/{r}/messages/{m}/{unmapped-message}` | +| `/session_steps/{r}/response/{unmapped-message}` | +| `/session_steps/{r}/messages/{m}/tool_calls/{c}/{unmapped-call}` | +| `/session_steps/{r}/response/tool_calls/{c}/{unmapped-call}` | +| `/session_steps/{r}/messages/{m}/tool_calls/{c}/function/{unmapped-function}` | +| `/session_steps/{r}/response/tool_calls/{c}/function/{unmapped-function}` | +| `/session_steps/{r}/meta_json/{unmapped-meta}` | +| `/session_steps/{r}/meta_json/env_state/{unmapped-env}` | + +后续 row 里重复的历史 messages 视为 context 副本:已识别的 `role`/`content` 被规范化掉, +不重复生成 turns,也不进入残差。只有每行最后一个 user、选中的 assistant output 和 +tool-role results 产生新语义。未选中 assistant 副本中的 string/null `reasoning_content`, +以及没有有效 content 时的非空 `refusal`,按副本规范化。已映射 message 的 `name`、 +`refusal`、`tool_call_id`、`tool_calls` 中的 `null` 或空容器,以及 row 的 +`blob_manifest`、`chosen_response`、`rejected_response`、`ground_truth_answer`、 +`reference_answer` 中的 `null` 或空容器,按缺失值规范化;非空且未命中权威语义的值进入 +残差。 + +同源恢复按原 pointer 写回。导出时若没有 openai-msg residual,合成行把 `/run` 写成 +`job_id`、把 `/agent/id` 写成 `agent_id`、把 model 写成 `agent_model`,不宣称还原 +`run_id` / `run_bucket` / `llm_model` 的原始键名。外来格式 residual 通过 version-1 +`_storyline` envelope 携带。`unknown_key_counts` 由 `unknown_fields` 确定性重算,没有 +独立源 pointer。 ## 保真边界 同源 roundtrip 保留进入 Storyline 语义或 `unknown_fields` 的 JSON value、数组顺序与 -session 分组。已知空值、重复历史 context 和结构判别字段按上节规范化;文件空白、缩进、 -object key 顺序以及顶层数组与 `session_steps` envelope 的原始排版不属于保真边界。 +session 分组。已知空值、重复历史 context、`type="function"` 结构判别,以及 +`refusal` 在无 content 时并入 `/msg`,按上节规范化。文件空白、缩进、object key 顺序 +以及顶层数组与 `session_steps` envelope 的原始排版不属于保真边界。 + +## Amendment history + +| Date | Change | +| --- | --- | +| 2026-08-22 | 按 RFC-0004 的映射类别重写:每个源 pointer 恰好一个权威目标。`step_id`、`/run`、`/agent/id`、turn id 改为派生公式;`created_at` 只权威写入 response `/ts`;metric 收成 `/metrics` 对象并以 `/final_metrics` 为最后一 turn 的提升;`type`/`role`/`env_id` 等不再写成 1 到多字段拷贝。 | +| 2026-08-22 | session-stable env 键进入 `/task/env`;row `id`、`event_type`、`request_id` 进入 response turn `/env`。 | diff --git a/docs/superpowers/plans/2026-08-22-explorer-steps-chats.md b/docs/superpowers/plans/2026-08-22-explorer-steps-chats.md new file mode 100644 index 00000000..5497c53c --- /dev/null +++ b/docs/superpowers/plans/2026-08-22-explorer-steps-chats.md @@ -0,0 +1,28 @@ +# Explorer Steps / Chats Implementation Plan + +> **For agentic workers:** Implement in this session. TDD for grouping; UI wires the same functions. + +**Goal:** Trace 默认 Chats,可切 Steps;分组只在前端。 + +**Architecture:** 纯函数 `group_chats` 把 `TurnSummary[]` 收成 `TraceCard`。`TrajectoryView` 按 `view` 渲染。URL `view=chats|steps`,旧值 `tree` 视为 chats。 + +**Tech Stack:** Dioxus 0.7, Rust unit tests in `pchronicle-web` + +## Global Constraints + +- 不改 Storyline / ATIF / Lance +- 不伪造 user turn +- 详情仍用 `turn.id` + +--- + +## Task 1: 分组函数 + +- [x] `pchronicle-web/src/chat_view.rs` 先写失败测试,再实现 `normalize_trace_view` / `group_chats` +- [x] `cargo test --manifest-path pchronicle-web/Cargo.toml --bin pchronicle-web -- chat_view` + +## Task 2: Trace UI + +- [x] 工具栏 Chats / Steps;默认 chats +- [x] Chats 卡片 + Steps 平铺 turn 行 +- [x] 嵌入 Copilot 引用保持 Steps diff --git a/docs/superpowers/plans/2026-08-22-json-value-renderer.md b/docs/superpowers/plans/2026-08-22-json-value-renderer.md new file mode 100644 index 00000000..52a43e19 --- /dev/null +++ b/docs/superpowers/plans/2026-08-22-json-value-renderer.md @@ -0,0 +1,764 @@ +# JSON Value Renderer Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Turn evidence 里的 JSON 值按形态派发:一层表、嵌套树(默认折叠);字符串里的 JSON 对象/数组先 peel。 + +**Architecture:** `pchronicle-web/src/json_value.rs` 提供 `peel_json` / `classify_json` 和 `JsonValue`。`JsonValue` 先 peel 再派发到 `JsonScalar` / `JsonKvTable` / `JsonRecordTable` / `JsonTree`。表格单元格和树子节点回调 `JsonValue`。`EvidenceBlock` 改成标题 + children 宿主。 + +**Tech Stack:** Dioxus 0.7, `serde_json::Value`, Rust unit tests in `pchronicle-web` bin + +## Global Constraints + +- 只改 `pchronicle-web`;不改 Storyline / 后端 wire / agent pretty JSON +- 不识别 `{fn, args}` 等协议形状 +- 不改查询单元格弹窗 +- Reasoning 始终纯文本,不 peel +- 样式加在现有 `assets/components.css` / `assets/inline-trace.css`,`pc2-` 前缀 +- 单测只覆盖 peel / classify / 列名 / 摘要;不要求 WASM 组件快照 +- 规范:[`docs/superpowers/specs/2026-08-22-json-value-renderer-design.md`](../specs/2026-08-22-json-value-renderer-design.md) + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| Create: `pchronicle-web/src/json_value.rs` | peel、classify、摘要、列名、`JsonValue` 与四个形态组件 | +| Modify: `pchronicle-web/src/main.rs` | `mod json_value` | +| Modify: `pchronicle-web/src/components.rs` | `EvidenceBlock` 宿主化;`InlineTurnDetail` 接入 | +| Modify: `pchronicle-web/assets/components.css` | `.pc2-json-*` 表与树 | +| Modify: `pchronicle-web/assets/inline-trace.css` | evidence 内 JSON 滚动高度;bump `index.html` 的 `?v=` | + +--- + +### Task 1: peel / classify 纯函数 + +**Files:** +- Create: `pchronicle-web/src/json_value.rs` +- Modify: `pchronicle-web/src/main.rs` + +**Interfaces:** +- Consumes: `serde_json::Value` +- Produces: + - `#[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum JsonShape { Scalar, KvTable, RecordTable, Tree }` + - `pub fn peel_json(value: &Value) -> Value` + - `pub fn classify_json(value: &Value) -> JsonShape`(先 `peel_json` 再分类;判断子字段是否标量时不 peel) + - `pub fn is_structured_json(value: &Value) -> bool`(peel 后是 object 或 array) + +- [ ] **Step 1: 声明模块并写失败测试** + +在 `pchronicle-web/src/main.rs` 的 `mod` 列表中、`mod components;` 旁加入: + +```rust +mod json_value; +``` + +创建 `pchronicle-web/src/json_value.rs`,先只放测试引用的空壳,让测试编译失败或断言失败: + +```rust +use serde_json::Value; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum JsonShape { + Scalar, + KvTable, + RecordTable, + Tree, +} + +pub fn peel_json(_value: &Value) -> Value { + Value::Null +} + +pub fn classify_json(_value: &Value) -> JsonShape { + JsonShape::Scalar +} + +pub fn is_structured_json(_value: &Value) -> bool { + false +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn peel_promotes_object_and_array_strings_only() { + assert_eq!(peel_json(&json!({"a": 1})), json!({"a": 1})); + assert_eq!(peel_json(&json!("{\"a\":1}")), json!({"a": 1})); + assert_eq!(peel_json(&json!("[1,2]")), json!([1, 2])); + assert_eq!(peel_json(&json!("not-json")), json!("not-json")); + assert_eq!(peel_json(&json!("{")), json!("{")); + assert_eq!(peel_json(&json!("\"hello\"")), json!("\"hello\"")); + assert_eq!(peel_json(&json!(7)), json!(7)); + } + + #[test] + fn classify_matches_one_level_tables_and_trees() { + assert_eq!(classify_json(&json!("plain")), JsonShape::Scalar); + assert_eq!(classify_json(&json!({})), JsonShape::KvTable); + assert_eq!(classify_json(&json!({"b": true, "a": 1})), JsonShape::KvTable); + assert_eq!( + classify_json(&json!([{"b": 2, "a": 1}, {"a": 3, "c": null}])), + JsonShape::RecordTable + ); + assert_eq!( + classify_json(&json!([{"fn": "read", "args": "{\"path\":\"x\"}"}])), + JsonShape::RecordTable + ); + assert_eq!( + classify_json(&json!("{\"fn\":\"read\",\"args\":\"{\\\"path\\\":\\\"x\\\"}\"}")), + JsonShape::KvTable + ); + assert_eq!( + classify_json(&peel_json(&json!("{\"path\":\"x\"}"))), + JsonShape::KvTable + ); + assert_eq!(classify_json(&json!({"nested": {"x": 1}})), JsonShape::Tree); + assert_eq!(classify_json(&json!([1, 2, 3])), JsonShape::Tree); + assert_eq!(classify_json(&json!([])), JsonShape::Tree); + assert_eq!(classify_json(&json!([{"a": 1}, "tail"])), JsonShape::Tree); + assert_eq!(classify_json(&json!([{"a": {"b": 1}}])), JsonShape::Tree); + } + + #[test] + fn structured_detection_follows_peel() { + assert!(!is_structured_json(&json!("hello"))); + assert!(is_structured_json(&json!({"a": 1}))); + assert!(is_structured_json(&json!("{\"a\":1}"))); + assert!(!is_structured_json(&json!("\"hello\""))); + } +} +``` + +- [ ] **Step 2: 跑测试,确认失败** + +Run: + +```bash +cargo test --manifest-path pchronicle-web/Cargo.toml --bin pchronicle-web -- json_value -- --test-threads=1 +``` + +Expected: FAIL(`peel_promotes_object_and_array_strings_only` 断言 `Null != Object`,或同类) + +- [ ] **Step 3: 实现 peel / classify** + +把 `pchronicle-web/src/json_value.rs` 的三个函数换成: + +```rust +use serde_json::Value; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum JsonShape { + Scalar, + KvTable, + RecordTable, + Tree, +} + +pub fn peel_json(value: &Value) -> Value { + match value { + Value::String(raw) => match serde_json::from_str::(raw) { + Ok(parsed) if parsed.is_object() || parsed.is_array() => parsed, + _ => value.clone(), + }, + other => other.clone(), + } +} + +pub fn classify_json(value: &Value) -> JsonShape { + classify_peeled(&peel_json(value)) +} + +pub fn is_structured_json(value: &Value) -> bool { + let peeled = peel_json(value); + peeled.is_object() || peeled.is_array() +} + +fn is_scalar(value: &Value) -> bool { + matches!( + value, + Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) + ) +} + +fn classify_peeled(value: &Value) -> JsonShape { + match value { + Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => JsonShape::Scalar, + Value::Object(object) => { + if object.values().all(is_scalar) { + JsonShape::KvTable + } else { + JsonShape::Tree + } + } + Value::Array(items) => { + if !items.is_empty() + && items.iter().all(|item| { + item.as_object() + .is_some_and(|object| object.values().all(is_scalar)) + }) + { + JsonShape::RecordTable + } else { + JsonShape::Tree + } + } + } +} +``` + +保留 Step 1 的 `#[cfg(test)]` 模块,不要删。 + +- [ ] **Step 4: 再跑测试,确认通过** + +Run: + +```bash +cargo test --manifest-path pchronicle-web/Cargo.toml --bin pchronicle-web -- json_value -- --test-threads=1 +``` + +Expected: `3 passed` + +- [ ] **Step 5: Commit** + +```bash +git add pchronicle-web/src/json_value.rs pchronicle-web/src/main.rs +git commit -m "$(cat <<'EOF' +Add JSON shape classification for turn evidence. + +EOF +)" +``` + +--- + +### Task 2: 列名并集与树摘要 + +**Files:** +- Modify: `pchronicle-web/src/json_value.rs` + +**Interfaces:** +- Consumes: Task 1 的 `peel_json` +- Produces: + - `pub fn record_columns(rows: &[Value]) -> Vec`(只收集 object key,首次出现顺序) + - `pub fn json_summary(value: &Value) -> String`(先 peel;object → `{n keys}`;array → `[n items]`;string → `string`;number → `number`;bool → `boolean`;null → `null`) + +- [ ] **Step 1: 写失败测试** + +在 `pchronicle-web/src/json_value.rs` 的 `tests` 模块末尾追加: + +```rust + #[test] + fn record_columns_keep_first_seen_union() { + let rows = vec![ + json!({"b": 2, "a": 1}), + json!({"a": 3, "c": null}), + ]; + assert_eq!(record_columns(&rows), vec!["a", "b", "c"]); + } + + #[test] + fn json_summary_peels_and_names_types() { + assert_eq!(json_summary(&json!({"a": 1, "b": 2})), "{2 keys}"); + assert_eq!(json_summary(&json!([1, 2, 3])), "[3 items]"); + assert_eq!(json_summary(&json!([])), "[0 items]"); + assert_eq!(json_summary(&json!("{}")), "{0 keys}"); + assert_eq!(json_summary(&json!("hello")), "string"); + assert_eq!(json_summary(&json!(true)), "boolean"); + assert_eq!(json_summary(&json!(1)), "number"); + assert_eq!(json_summary(&json!(null)), "null"); + } +``` + +- [ ] **Step 2: 跑测试,确认失败** + +Run: + +```bash +cargo test --manifest-path pchronicle-web/Cargo.toml --bin pchronicle-web -- record_columns_keep_first_seen -- --test-threads=1 +``` + +Expected: FAIL(`cannot find function record_columns`) + +- [ ] **Step 3: 实现两个函数** + +在 `classify_peeled` 之后、`#[cfg(test)]` 之前加入: + +```rust +pub fn record_columns(rows: &[Value]) -> Vec { + let mut columns = Vec::new(); + for row in rows { + if let Value::Object(object) = row { + for key in object.keys() { + if !columns.contains(key) { + columns.push(key.clone()); + } + } + } + } + columns +} + +pub fn json_summary(value: &Value) -> String { + match peel_json(value) { + Value::Object(object) => format!("{{{} keys}}", object.len()), + Value::Array(items) => format!("[{} items]", items.len()), + Value::String(_) => "string".into(), + Value::Number(_) => "number".into(), + Value::Bool(_) => "boolean".into(), + Value::Null => "null".into(), + } +} +``` + +`serde_json` 默认 `Map` 是 `BTreeMap`,单行 object 的 key 顺序是字典序,所以 `{"b":2,"a":1}` 的首次顺序是 `a` 然后 `b`。测试按这个写。 + +- [ ] **Step 4: 再跑测试** + +Run: + +```bash +cargo test --manifest-path pchronicle-web/Cargo.toml --bin pchronicle-web -- json_value -- --test-threads=1 +``` + +Expected: `5 passed` + +- [ ] **Step 5: Commit** + +```bash +git add pchronicle-web/src/json_value.rs +git commit -m "$(cat <<'EOF' +Add JSON table columns and tree summaries. + +EOF +)" +``` + +--- + +### Task 3: `JsonValue` 派发组件与样式 + +**Files:** +- Modify: `pchronicle-web/src/json_value.rs` +- Modify: `pchronicle-web/assets/components.css` +- Modify: `pchronicle-web/assets/inline-trace.css` +- Modify: `pchronicle-web/index.html` + +**Interfaces:** +- Consumes: `peel_json`, `classify_json` 的内部 `classify_peeled`(组件内先 `peel_json` 再 `match classify_json` 对 peeled 值渲染)、`record_columns`, `json_summary` +- Produces: `#[component] pub fn JsonValue(value: Value) -> Element` + +- [ ] **Step 1: 实现四个形态 + 门面** + +在 `pchronicle-web/src/json_value.rs` 顶部把 import 换成: + +```rust +use dioxus::prelude::*; +use serde_json::Value; +``` + +在 `json_summary` 之后、`#[cfg(test)]` 之前加入: + +```rust +fn scalar_text(value: &Value) -> String { + match value { + Value::Null => "null".into(), + Value::Bool(value) => value.to_string(), + Value::Number(value) => value.to_string(), + Value::String(value) => value.clone(), + other => other.to_string(), + } +} + +#[component] +pub fn JsonValue(value: Value) -> Element { + let peeled = peel_json(&value); + match classify_json(&value) { + JsonShape::Scalar => rsx! { span { class: "pc2-json-scalar", "{scalar_text(&peeled)}" } }, + JsonShape::KvTable => rsx! { JsonKvTable { value: peeled } }, + JsonShape::RecordTable => rsx! { JsonRecordTable { value: peeled } }, + JsonShape::Tree => rsx! { JsonTree { value: peeled } }, + } +} + +#[component] +fn JsonKvTable(value: Value) -> Element { + let map = match value { + Value::Object(map) => map, + _ => return rsx! { span { class: "pc2-json-scalar", "—" } }, + }; + rsx! { + table { class: "pc2-json-table pc2-json-kv", + thead { tr { th { "key" } th { "value" } } } + tbody { + for (key, child) in map { + tr { key: "{key}", + th { scope: "row", "{key}" } + td { JsonValue { value: child } } + } + } + } + } + } +} + +#[component] +fn JsonRecordTable(value: Value) -> Element { + let rows = match value { + Value::Array(rows) => rows, + _ => return rsx! { span { class: "pc2-json-scalar", "—" } }, + }; + let columns = record_columns(&rows); + rsx! { + div { class: "pc2-json-scroll", + table { class: "pc2-json-table pc2-json-records", + thead { tr { for column in columns.iter() { th { "{column}" } } } } + tbody { + for (row_index, row) in rows.iter().enumerate() { + tr { key: "{row_index}", + for column in columns.iter() { + td { + JsonValue { + value: match row { + Value::Object(object) => { + object.get(column).cloned().unwrap_or(Value::Null) + } + _ => Value::Null, + } + } + } + } + } + } + } + } + } + } +} + +#[component] +fn JsonTree(value: Value) -> Element { + match value { + Value::Object(map) if map.is_empty() => rsx! { + details { class: "pc2-json-node", summary { span { class: "pc2-json-size", "{0 keys}" } } } + }, + Value::Array(items) if items.is_empty() => rsx! { + details { class: "pc2-json-node", summary { span { class: "pc2-json-size", "[0 items]" } } } + }, + Value::Object(map) => rsx! { + div { class: "pc2-json-tree", + for (key, child) in map { + JsonTreeNode { key: "{key}", label: key, value: child } + } + } + }, + Value::Array(items) => rsx! { + div { class: "pc2-json-tree", + for (index, child) in items.into_iter().enumerate() { + JsonTreeNode { key: "{index}", label: format!("[{index}]"), value: child } + } + } + }, + other => rsx! { span { class: "pc2-json-scalar", "{scalar_text(&other)}" } }, + } +} + +#[component] +fn JsonTreeNode(label: String, value: Value) -> Element { + let summary = json_summary(&value); + rsx! { + details { class: "pc2-json-node", + summary { span { class: "pc2-json-key", "{label}" } span { class: "pc2-json-size", "{summary}" } } + JsonValue { value } + } + } +} +``` + +注意:`JsonTree` 里空对象分支的 `"{0 keys}"` 必须写成 `"{0 keys}"` 对应 `json_summary` 的 `{0 keys}`。空对象走 `classify` 是 **KvTable**,`JsonValue` 不会把空对象派到 `JsonTree`。空对象分支可删,只留空数组: + +把 `JsonTree` 换成: + +```rust +#[component] +fn JsonTree(value: Value) -> Element { + match value { + Value::Array(items) if items.is_empty() => rsx! { + details { class: "pc2-json-node", + summary { span { class: "pc2-json-size", "[0 items]" } } + } + }, + Value::Object(map) => rsx! { + div { class: "pc2-json-tree", + for (key, child) in map { + JsonTreeNode { key: "{key}", label: key, value: child } + } + } + }, + Value::Array(items) => rsx! { + div { class: "pc2-json-tree", + for (index, child) in items.into_iter().enumerate() { + JsonTreeNode { key: "{index}", label: format!("[{index}]"), value: child } + } + } + }, + other => rsx! { span { class: "pc2-json-scalar", "{scalar_text(&other)}" } }, + } +} +``` + +`
` 不要写 `open`,默认折叠。 + +- [ ] **Step 2: 加 CSS** + +在 `pchronicle-web/assets/components.css` 文件末尾追加: + +```css +.pc2-json-scroll { + max-height: 360px; + overflow: auto; +} + +.pc2-json-table { + width: 100%; + border-collapse: collapse; + font-size: 11px; +} + +.pc2-json-table th, +.pc2-json-table td { + padding: 5px 8px; + border: 1px solid #eef0f3; + text-align: left; + vertical-align: top; +} + +.pc2-json-table thead th, +.pc2-json-kv th[scope="row"] { + background: #f8fafc; + color: #667085; + font-size: 9px; + font-weight: 700; +} + +.pc2-json-scalar { + white-space: pre-wrap; + word-break: break-word; + color: #344054; +} + +.pc2-json-tree { + display: flex; + flex-direction: column; + gap: 2px; +} + +.pc2-json-node { + border-left: 1px solid #e4e7ec; + padding-left: 8px; +} + +.pc2-json-node > summary { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + list-style: none; +} + +.pc2-json-node > summary::-webkit-details-marker { + display: none; +} + +.pc2-json-key { + color: #101828; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 11px; +} + +.pc2-json-size { + color: #667085; + font-size: 9px; +} + +.pc2-json-node > .pc2-json-table, +.pc2-json-node > .pc2-json-tree, +.pc2-json-node > .pc2-json-scroll, +.pc2-json-node > .pc2-json-scalar { + margin: 6px 0 8px; +} +``` + +在 `pchronicle-web/assets/inline-trace.css` 的 `.pc2-inline-detail .pc2-evidence-block pre` 规则旁追加: + +```css +.pc2-inline-detail .pc2-evidence-block .pc2-json-scroll, +.pc2-inline-detail .pc2-evidence-block .pc2-json-tree { + max-height: 420px; + overflow: auto; +} +``` + +把 `pchronicle-web/index.html` 里: + +```html + + +``` + +改成: + +```html + + +``` + +- [ ] **Step 3: 编译并跑 json_value 测试** + +Run: + +```bash +cargo test --manifest-path pchronicle-web/Cargo.toml --bin pchronicle-web -- json_value -- --test-threads=1 +``` + +Expected: `5 passed`;含 `JsonValue` 的 bin 能编过。 + +- [ ] **Step 4: Commit** + +```bash +git add pchronicle-web/src/json_value.rs pchronicle-web/assets/components.css pchronicle-web/assets/inline-trace.css pchronicle-web/index.html +git commit -m "$(cat <<'EOF' +Render classified JSON as tables and collapsed trees. + +EOF +)" +``` + +--- + +### Task 4: Turn evidence 接入 + +**Files:** +- Modify: `pchronicle-web/src/components.rs` + +**Interfaces:** +- Consumes: `crate::json_value::{is_structured_json, JsonValue}` +- Produces: `EvidenceBlock { title: &'static str, open: bool, children: Element }`;`InlineTurnDetail` 按 spec 表接入 + +- [ ] **Step 1: 改 `EvidenceBlock` 为宿主** + +在 `pchronicle-web/src/components.rs` 顶部 import 区增加: + +```rust +use crate::json_value::{is_structured_json, JsonValue}; +``` + +把现有 + +```rust +fn EvidenceBlock(title: &'static str, value: String) -> Element { + rsx! { details { class: "pc2-evidence-block", open: title == "Message", summary { "{title}" } pre { "{value}" } } } +} +``` + +换成: + +```rust +#[component] +fn EvidenceBlock(title: &'static str, #[props(default = false)] open: bool, children: Element) -> Element { + rsx! { details { class: "pc2-evidence-block", open, summary { "{title}" } {children} } } +} +``` + +- [ ] **Step 2: 改 `InlineTurnDetail`** + +把 `InlineTurnDetail` 整段换成(facts 行保持原样,只换块): + +```rust +#[component] +fn InlineTurnDetail(value: TurnDetail) -> Element { + let message = value.turn.message.clone(); + let message_text = value.turn.text(); + let structured_message = is_structured_json(&message); + let tool_calls = serde_json::to_value(&value.wire_tool_calls).unwrap_or(Value::Array(Vec::new())); + let events = serde_json::to_value(&value.events).unwrap_or(Value::Array(Vec::new())); + rsx! { div { class: "pc2-inline-detail-head", strong { "Full turn evidence" } } + div { class: "pc2-inspector-facts", Fact { label: "Turn", value: format!("#{}", value.summary.id) } Fact { label: "Source", value: value.summary.source.clone() } Fact { label: "Kind", value: value.summary.kind.clone().unwrap_or_else(|| "unavailable".into()) } Fact { label: "Model", value: value.summary.model_name.clone().unwrap_or_else(|| "unavailable".into()) } Fact { label: "Latency", value: value.summary.latency_ms.map(format_ms).unwrap_or_else(|| "unavailable".into()) } Fact { label: "TTFT", value: value.summary.ttft_ms.map(format_ms).unwrap_or_else(|| "unavailable".into()) } Fact { label: "Tokens", value: value.summary.total_tokens.map(|tokens| tokens.to_string()).unwrap_or_else(|| "unavailable".into()) } Fact { label: "Token split", value: format!("{} in · {} out", optional_u64(value.summary.prompt_tokens), optional_u64(value.summary.completion_tokens)) } Fact { label: "Events", value: value.events.len().to_string() } } + if structured_message { + EvidenceBlock { title: "Message", open: true, JsonValue { value: message } } + } else { + EvidenceBlock { title: "Message", open: true, pre { "{message_text}" } } + } + if let Some(reasoning) = &value.turn.reasoning_content { + EvidenceBlock { title: "Reasoning", pre { "{reasoning.clone()}" } } + } + if !value.wire_tool_calls.is_empty() { + EvidenceBlock { title: "Tool calls", JsonValue { value: tool_calls } } + } + if let Some(observation) = value.turn.observation.clone() { + EvidenceBlock { title: "Observation", JsonValue { value: observation } } + } + if !value.events.is_empty() { + EvidenceBlock { title: "Raw linked events", JsonValue { value: events } } + } + if let Some(extra) = value.turn.extra.clone() { + EvidenceBlock { title: "Extra", JsonValue { value: extra } } + } + if let Some(metrics) = value.turn.metrics.clone() { + EvidenceBlock { title: "Metrics", JsonValue { value: metrics } } + } + } +} +``` + +`TurnDetail.turn` 已有 `extra: Option` 和 `metrics: Option`,不要改 `model.rs`。不要改 `agent.rs` 里给模型的 pretty JSON。不要改 `CellValue` / 查询弹窗。 + +- [ ] **Step 3: 编译测试** + +Run: + +```bash +cargo test --manifest-path pchronicle-web/Cargo.toml --bin pchronicle-web -- --test-threads=1 +``` + +Expected: 全部通过(含 `json_value` 与现有 `components` / `chat_view` / `model` 测试)。 + +本地预览(需要时): + +```bash +just chronicle-web-build +``` + +然后用已有 `pchronicle serve` 看一条带 tool calls / observation 的 turn:扁平对象应是表,嵌套应是默认折叠的树,JSON 字符串格子里再派发。 + +- [ ] **Step 4: Commit** + +```bash +git add pchronicle-web/src/components.rs +git commit -m "$(cat <<'EOF' +Host turn evidence JSON through JsonValue. + +EOF +)" +``` + +--- + +## Spec coverage + +| Spec | Task | +|---|---| +| peel 只处理当前节点;子字段不 peel | 1 | +| Scalar / KvTable / RecordTable / Tree 判定 | 1 | +| 空对象 KvTable;空数组 Tree | 1 | +| 非法 JSON / JSON 标量字符串 → Scalar | 1 | +| RecordTable 列并集、首次出现 | 2 | +| 树摘要 `{n keys}` / `[n items]` | 2 | +| `JsonValue` 派发四形态;单元格/子节点回调 | 3 | +| `
` 默认折叠 | 3 | +| `pc2-` 样式、不新开 CSS 管线 | 3 | +| EvidenceBlock 宿主;Message / Reasoning / 各 JSON 块 / Extra / Metrics | 4 | +| 不改查询单元格、不改 agent pretty JSON | 4(明确不碰) | diff --git a/docs/superpowers/plans/2026-08-22-persisting-replay-adapter-module-split.md b/docs/superpowers/plans/2026-08-22-persisting-replay-adapter-module-split.md new file mode 100644 index 00000000..f994fb2a --- /dev/null +++ b/docs/superpowers/plans/2026-08-22-persisting-replay-adapter-module-split.md @@ -0,0 +1,312 @@ +# Persisting Replay Adapter Module Split Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Physically move every Agent-specific parser and executor out of `adapter/mod.rs` into its versioned Agent module without changing replay behavior or public contracts. + +**Architecture:** `adapter/mod.rs` remains the static dispatcher and owns only shared context, helpers, and the existing Mini/SWE SDK bridge. Each Agent module owns its native parsing, prefix preparation, execution, reconstruction helpers, terminal interpretation, and focused tests. + +**Tech Stack:** Rust 2024, serde/serde_json, Cargo test and Clippy. + +**Spec:** `docs/superpowers/specs/2026-08-22-persisting-replay-adapter-module-split-design.md` + +## Global Constraints + +- Preserve `ReplayPlan`, `AdapterPlan`, `PlaybackRequest`, `ReplayOutcome`, and all serialized request/result contracts. +- Preserve every artifact filename and runtime command/environment variable. +- Do not redesign `run_sdk_bridge`; it remains shared by Mini and SWE. +- Use explicit imports in Agent modules; do not retain `use super::*`. +- Do not modify Gateway, pChronicle, Queue, Search, TTAS, or `persisting-dlcapt`. +- Stage only replay adapter files and this plan. + +--- + +### Task 1: Move OpenHands implementation + +**Files:** +- Modify: `crates/persisting-replay/src/adapter/mod.rs` +- Modify: `crates/persisting-replay/src/adapter/openhands.rs` + +**Interfaces:** +- Consumes: `RunContext`, `check_boundary`, `prepared_outcome`, `agent_command`, `MAX_TOOL_OUTPUT_BYTES`, `run_process`, and common IO/error/model types. +- Produces: the existing `openhands::build` and `openhands::execute` signatures with all OpenHands implementation private to `openhands.rs`. + +- [ ] **Step 1: Record the focused characterization baseline** + +Run: + +```bash +cargo test -p persisting-replay openhands_ -- --nocapture +cargo test -p persisting-replay --test replay_contract openhands_ -- --nocapture +``` + +Expected: all selected tests pass before movement. + +- [ ] **Step 2: Move the OpenHands parser and executor** + +Move these functions unchanged into `openhands.rs`, below the existing public-to-parent entrypoints: + +```text +build_openhands_plan +openhands_action_signature +openhands_reconstructed_tool_metadata +openhands_reconstructed_tool_arguments +event_id +run_openhands +openhands_fatal_controller_marker +openhands_observation_content +openhands_complete_batches +prepend_openhands_runtime_tools +``` + +Change the entrypoints to call module-local functions: + +```rust +pub(super) fn build(request: &PlaybackRequest) -> Result { + build_openhands_plan(request).map(AdapterPlan::Openhands) +} + +pub(super) fn execute( + plan: &ReplayPlan, + context: &RunContext<'_>, + journal: &mut Journal, +) -> Result { + run_openhands(plan, context, journal) +} +``` + +Move the five `openhands_*` unit tests from the root test module into a local `#[cfg(test)] mod tests` in `openhands.rs`. + +- [ ] **Step 3: Verify OpenHands after movement** + +Run the two Step 1 commands again, then: + +```bash +cargo check -p persisting-replay +``` + +Expected: all commands exit zero. + +- [ ] **Step 4: Commit the OpenHands split** + +```bash +git add crates/persisting-replay/src/adapter/mod.rs crates/persisting-replay/src/adapter/openhands.rs +git commit -m "refactor(replay): move OpenHands adapter implementation" +``` + +### Task 2: Move Mini implementation + +**Files:** +- Modify: `crates/persisting-replay/src/adapter/mod.rs` +- Modify: `crates/persisting-replay/src/adapter/mini_swe_agent.rs` + +**Interfaces:** +- Consumes: shared `check_boundary`, `prepared_outcome`, and `run_sdk_bridge`. +- Produces: module-local Mini parsing/preparation with unchanged `mini_swe_agent::build` and `mini_swe_agent::execute` entrypoints. + +- [ ] **Step 1: Record the Mini characterization baseline** + +```bash +cargo test -p persisting-replay mini_ -- --nocapture +cargo test -p persisting-replay --test replay_contract mini_ -- --nocapture +``` + +Expected: all selected tests pass. + +- [ ] **Step 2: Move Mini-specific code** + +Move these functions unchanged into `mini_swe_agent.rs`: + +```text +mini_reasoning +mini_batch_signature +build_mini_plan +mini_submission_in_prefix +mini_calls +mini_observation +run_mini +``` + +Keep `run_sdk_bridge` in `adapter/mod.rs`; expose it as `pub(super)` so the +module-local `run_mini` can call: + +```rust +run_sdk_bridge(plan, context, journal, AgentKind::MiniSweAgent) +``` + +Move `mini_submit_is_rejected_only_inside_the_selected_prefix` into the Mini +module test block. Move version-probe and portable-runtime tests to +`adapter/runtime.rs`, because they test runtime resolution rather than native +Mini trajectory behavior. + +- [ ] **Step 3: Verify and commit Mini** + +```bash +cargo test -p persisting-replay mini_ -- --nocapture +cargo test -p persisting-replay --test replay_contract mini_ -- --nocapture +cargo check -p persisting-replay +git add crates/persisting-replay/src/adapter/mod.rs crates/persisting-replay/src/adapter/mini_swe_agent.rs crates/persisting-replay/src/adapter/runtime.rs +git commit -m "refactor(replay): move Mini adapter implementation" +``` + +Expected: tests and check pass before the commit. + +### Task 3: Move SWE implementation + +**Files:** +- Modify: `crates/persisting-replay/src/adapter/mod.rs` +- Modify: `crates/persisting-replay/src/adapter/swe_agent.rs` + +**Interfaces:** +- Consumes: shared `check_boundary`, `prepared_outcome`, and `run_sdk_bridge`. +- Produces: module-local SWE parser, asset resolution, and prefix preparation. + +- [ ] **Step 1: Record the SWE characterization baseline** + +```bash +cargo test -p persisting-replay --test replay_contract swe_ -- --nocapture +``` + +Expected: the SWE total-budget contract test passes. + +- [ ] **Step 2: Move SWE-specific code** + +Move these functions unchanged into `swe_agent.rs`: + +```text +build_swe_plan +resolve_swe_problem_asset +run_swe +``` + +The module-local executor continues to call exactly: + +```rust +run_sdk_bridge(plan, context, journal, AgentKind::SweAgent) +``` + +Do not move or rewrite any Mini/SWE result parsing in `run_sdk_bridge` during +this task. + +- [ ] **Step 3: Verify and commit SWE** + +```bash +cargo test -p persisting-replay --test replay_contract swe_ -- --nocapture +cargo check -p persisting-replay +git add crates/persisting-replay/src/adapter/mod.rs crates/persisting-replay/src/adapter/swe_agent.rs +git commit -m "refactor(replay): move SWE adapter implementation" +``` + +Expected: tests and check pass before the commit. + +### Task 4: Move Claude implementation and tests + +**Files:** +- Modify: `crates/persisting-replay/src/adapter/mod.rs` +- Modify: `crates/persisting-replay/src/adapter/claude_code.rs` + +**Interfaces:** +- Consumes: shared `check_boundary`, `required_str`, `agent_command`, and process/IO/error types. +- Produces: all Claude parsing, tool execution, reconstruction, resume cleanup, and Claude unit tests inside `claude_code.rs`. + +- [ ] **Step 1: Record the Claude characterization baseline** + +```bash +cargo test -p persisting-replay claude_ -- --nocapture +cargo test -p persisting-replay stale_observations_ -- --nocapture +cargo test -p persisting-replay prepare_only_executes_no_historical_tool -- --nocapture +cargo test -p persisting-replay bash_ -- --nocapture +cargo test -p persisting-replay wildcard_ -- --nocapture +``` + +Expected: all selected tests pass. + +- [ ] **Step 2: Move all Claude-specific implementation** + +Move the Claude code ranges beginning with `claude_boundary_tool_use_ids` and +`build_claude_plan`, plus `run_claude` through its native tool/reconstruction +helpers, into `claude_code.rs`. The resulting module must own these groups: + +```text +Claude canonical-message and active-chain parsing +Claude ToolUse/ToolResult batch parsing +Claude tool policy and historical tool execution +Bash/Edit/Read/Glob/Grep replay helpers and wildcard traversal +Claude native session rebuilding and continuation cleanup +Resume Transport attachment, UUID, nonce, and parent-chain validation +Claude max-turn terminal-result validation +``` + +Move every root unit test whose name starts with `claude_`, `stale_`, `bash_`, +or `wildcard_`, plus `prepare_only_executes_no_historical_tool`, into the +Claude module test block. Keep `direct_agents_keep_model_credentials_but_claude_tools_do_not` +in `adapter/mod.rs` because it tests the shared environment policy. + +- [ ] **Step 3: Remove obsolete root imports and verify structural boundaries** + +Remove imports and constants from `adapter/mod.rs` that are now used only by +Claude. Confirm the forbidden functions no longer exist in the root: + +```bash +rg -n 'fn (build_(claude|mini|openhands|swe)_plan|run_(claude|mini|openhands|swe))' crates/persisting-replay/src/adapter/mod.rs +``` + +Expected: no matches. + +- [ ] **Step 4: Verify and commit Claude** + +Run every Step 1 command, then: + +```bash +cargo check -p persisting-replay +cargo fmt --check -p persisting-replay +git add crates/persisting-replay/src/adapter/mod.rs crates/persisting-replay/src/adapter/claude_code.rs +git commit -m "refactor(replay): move Claude adapter implementation" +``` + +Expected: all commands exit zero. + +### Task 5: Full regression and scope verification + +**Files:** +- Verify only; modify adapter files only if a verification failure was introduced by this refactor. + +**Interfaces:** +- Consumes: all four physically split adapters. +- Produces: evidence that the refactor preserved behavior and repository scope. + +- [ ] **Step 1: Run full replay verification** + +```bash +cargo fmt --check -p persisting-replay +cargo test -p persisting-replay +cargo clippy -p persisting-replay --all-targets -- -D warnings +git diff --check +``` + +Expected: every command exits zero; the test output includes all replay unit, +contract, and doc tests with zero failures. + +- [ ] **Step 2: Inspect the final module sizes and boundaries** + +```bash +wc -l crates/persisting-replay/src/adapter/*.rs +rg -n 'fn (build_(claude|mini|openhands|swe)_plan|run_(claude|mini|openhands|swe))' crates/persisting-replay/src/adapter/mod.rs +git status --short +``` + +Expected: all four Agent files contain substantial implementations, the `rg` +command returns no matches, and no Gateway/pChronicle/storyline or `.workbuddy` +path is staged by this work. + +- [ ] **Step 3: Commit any verification-only import cleanup** + +If Step 1 required an adapter import or visibility cleanup, stage only those +adapter files and commit: + +```bash +git add crates/persisting-replay/src/adapter +git commit -m "refactor(replay): finish physical adapter split" +``` + +If no cleanup was required, do not create an empty commit. diff --git a/docs/superpowers/plans/2026-08-22-persisting-replay-reliability.md b/docs/superpowers/plans/2026-08-22-persisting-replay-reliability.md new file mode 100644 index 00000000..9c142576 --- /dev/null +++ b/docs/superpowers/plans/2026-08-22-persisting-replay-reliability.md @@ -0,0 +1,433 @@ +# Persisting Replay Reliability Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking. + +**Goal:** Make prepare, replay, and continuation semantics reliable across all four supported Agents while bounding child processes and publishing an explicit v3 result contract. + +**Architecture:** The common engine owns phase transitions, state/output lifecycle, results, journaling, and process supervision. Static Agent adapters own version-pinned native parsing and execution; behavioral coverage lands before the monolithic adapter is split. + +**Tech Stack:** Rust 2021, serde/serde_json, clap, tokio/axum/reqwest, Unix process groups through libc, and pinned Python runners for mini-swe-agent and SWE-agent. + +**Spec:** docs/superpowers/specs/2026-08-22-persisting-replay-reliability-design.md + +## Global Constraints + +- Keep Claude Code at 2.1.220, mini-swe-agent at 2.4.6, OpenHands at 0.53.0, and SWE-agent at 1.1.0. +- Do not add dynamic adapter loading, Gateway capture, pChronicle storage, or model-traffic capture. +- Do not modify TTAS, Queue, Search, or persisting-dlcapt. +- max_steps means total Agent action/model steps including the replay prefix. +- Write and observe a failing regression test before every production behavior change. +- Preserve unrelated .workbuddy and worktree changes. + +--- + +## File Map + +- crates/persisting-replay/src/model.rs: request modes and v3 result/status types. +- crates/persisting-replay/src/config.rs: strict TOML/JSON mapping. +- crates/persisting-replay/src/process.rs: bounded process-group supervisor. +- crates/persisting-replay/src/adapter/: static dispatch and Agent-specific plans. +- crates/persisting-replay/src/engine.rs: validated phase state machine and result publication. +- crates/persisting-replay/src/journal.rs: conservative ambiguity detection. +- crates/persisting-replay/assets/: native replay runners. +- crates/persisting-replay/tests/replay_contract.rs: fake-runtime integration contract. +- crates/persisting-pvisor/src/cli/: public CLI and error output. +- pVisor README and replay docs: migration documentation. + +--- + +### Task 1: Explicit replay modes and request compatibility + +**Files:** +- Modify: crates/persisting-replay/src/model.rs +- Modify: crates/persisting-replay/src/config.rs +- Modify: crates/persisting-replay/src/lib.rs +- Modify: crates/persisting-pvisor/src/cli/replay.rs +- Modify: crates/persisting-pvisor/src/cli/mod.rs + +**Interfaces:** +- Produces ReplayMode with PrepareOnly, ReplayOnly, and ReplayAndContinue. +- Produces PlaybackRequest.mode and PlaybackRequest.allow_stale_observations. + +- [ ] **Step 1: Write failing mode tests** + +Add config tests using the desired API: + + assert_eq!(prepare.into_request(cwd)?.mode, ReplayMode::PrepareOnly); + assert_eq!(replay.into_request(cwd)?.mode, ReplayMode::ReplayOnly); + assert!(both.into_request(cwd).is_err()); + +Add clap tests accepting each flag, rejecting both, and proving managed replay propagates prepare-only and allow-stale-observations. + +- [ ] **Step 2: Verify RED** + + cargo test -p persisting-replay config::tests + cargo test -p persisting-pvisor cli::replay + +Expected: compile/test failure because the new mode and flags do not exist. + +- [ ] **Step 3: Implement the request contract** + +Add a serde snake_case ReplayMode enum. Replace PlaybackRequest.replay_only. Add strict prepare_only and allow_stale_observations fields to TOML and v1 JSON. Map legacy replay_only=true to corrected ReplayOnly, reject both booleans, and default to live continuation. Add conflicting clap flags and managed-command propagation. + +- [ ] **Step 4: Update request literals and verify GREEN** + +Use mode=PrepareOnly and allow_stale_observations=false in existing prepare-only tests, then rerun Step 2. + +- [ ] **Step 5: Commit** + + git commit -m "feat(replay): separate prepare replay and continuation modes" + +--- + +### Task 2: V3 results and failure locations + +**Files:** +- Modify: crates/persisting-replay/src/model.rs +- Modify: crates/persisting-replay/src/error.rs +- Modify: crates/persisting-replay/src/engine.rs +- Modify: crates/persisting-pvisor/src/cli/replay.rs + +**Interfaces:** +- Produces ReplayPhase, ReplayQuality, AgentStatus, and ReplayFailure. +- Produces ExecutionReport containing ReplayResult and exit_code. + +- [ ] **Step 1: Write failing serialization tests** + +Assert representative JSON contains: + + assert_eq!(value["schema_version"], "sandbox-playback.result/v3"); + assert_eq!(value["phase"], "replayed"); + assert_eq!(value["quality"], "degraded"); + assert_eq!(value["agent_status"], "not_started"); + assert_eq!(value["failure"], Value::Null); + +Add a CLI helper test requiring run_id, state_dir, and output_dir in failure JSON. + +- [ ] **Step 2: Verify RED** + + cargo test -p persisting-replay model::tests + cargo test -p persisting-pvisor cli::replay::tests::failure_json_keeps_run_locations + +- [ ] **Step 3: Implement typed v3 results** + +Add snake-case enums for Prepared/Replayed/Continued, Verified/Degraded, and Completed/MaxSteps/Failed/NotStarted. Replace string statuses, set RESULT_SCHEMA_VERSION to v3, add both roots and optional structured failure, and retain partial artifacts on runtime failure. + +- [ ] **Step 4: Update CLI and verify GREEN** + +Print ExecutionReport.result for success and execution failure, returning its exit code. Pre-execution errors use a structured envelope containing every known location. + +- [ ] **Step 5: Commit** + + git commit -m "feat(replay): publish structured v3 execution results" + +--- + +### Task 3: Exact runtime versions and portable runtime paths + +**Files:** +- Create: crates/persisting-replay/src/adapter/mod.rs +- Create: crates/persisting-replay/src/adapter/runtime.rs +- Modify/delete: crates/persisting-replay/src/adapter.rs + +**Interfaces:** +- Produces resolve_launch_spec(PlaybackRequest). +- Produces parse_version(AgentKind, output) with exact matching. + +- [ ] **Step 1: Write failing exact-version tests** + + assert_eq!(parse_version(ClaudeCode, "2.1.220 (Claude Code)"), Some("2.1.220")); + assert_eq!(parse_version(ClaudeCode, "12.1.220"), None); + assert_eq!(parse_version(Openhands, "0.53.0\n"), Some("0.53.0")); + assert_eq!(parse_version(Openhands, "warning 0.53.0 actual 0.54.0"), None); + +Canonicalize the expected mini Python path before equality assertions. + +- [ ] **Step 2: Verify RED** + + cargo test -p persisting-replay adapter::runtime::tests::version_probes_require_exact_banners + +Expected: the old substring matcher accepts the wrong Claude banner. + +- [ ] **Step 3: Extract and implement runtime parsing** + +Move LaunchSpec, runtime manifest handling, safe relative paths, version probes, and mini Python discovery/configuration into adapter/runtime.rs. Require an exact first Claude version token, exact trimmed metadata output for OpenHands/SWE-agent, and the exact mini banner. + +- [ ] **Step 4: Verify GREEN** + + cargo test -p persisting-replay adapter::runtime + cargo clippy -p persisting-replay --all-targets -- -D warnings + +- [ ] **Step 5: Commit** + + git commit -m "refactor(replay): isolate exact agent runtime resolution" + +--- + +### Task 4: Bounded process-group supervisor + +**Files:** +- Create: crates/persisting-replay/src/process.rs +- Modify: crates/persisting-replay/src/lib.rs + +**Interfaces:** +- Produces run_process(ProcessSpec) returning ProcessOutput. +- ProcessOutput carries status, bounded stdout/stderr tails, byte totals, truncation, timeout, and background cleanup state. + +- [ ] **Step 1: Read test guidance and write regression tests** + +Read superpowers/test-driven-development/writing-good-tests.md. Add Unix tests that produce 8 MiB with a 64 KiB retained cap, start sleep 30 in the background, and time out a foreground process. Assert complete log draining, bounded retained bytes, prompt return, and no surviving process-group member. + +- [ ] **Step 2: Verify RED** + + cargo test -p persisting-replay process::tests -- --nocapture + +- [ ] **Step 3: Implement streaming supervision** + +Use a dedicated Unix process group. Reader threads stream all chunks to an owner-only log and retain only the configured amount. Poll the leader, enforce timeout, terminate the negative PGID with TERM then KILL, reap the leader, and kill a group whose pipes remain open after leader exit. Do not use read_to_end, Command::output, or wait_with_output. + +- [ ] **Step 4: Verify GREEN** + + cargo test -p persisting-replay process::tests -- --nocapture + cargo clippy -p persisting-replay --all-targets -- -D warnings + +- [ ] **Step 5: Commit** + + git commit -m "feat(replay): supervise child process groups with bounded output" + +--- + +### Task 5: Verified Claude replay by default + +**Files:** +- Create: crates/persisting-replay/src/adapter/claude.rs +- Modify: crates/persisting-replay/src/adapter/mod.rs +- Modify: crates/persisting-replay/src/engine.rs +- Modify: crates/persisting-replay/src/process.rs + +**Interfaces:** +- Produces private ClaudePlan and phase-oriented prepare/replay/continue functions. +- Consumes allow_stale_observations and the process supervisor. + +- [ ] **Step 1: Write failing stale-observation tests** + +Use a prefix containing Agent(Explore), TaskOutput, and Task/Todo state calls. Assert default validation fails before execution. With opt-in, assert Degraded quality and per-call degradation=stale_source_observation. Assert Find is rejected at plan time. + +- [ ] **Step 2: Verify RED** + + cargo test -p persisting-replay adapter::claude::tests::stale_observations_fail_closed_by_default + cargo test -p persisting-replay adapter::claude::tests::stale_observations_are_explicitly_degraded + +- [ ] **Step 3: Split Claude code and enforce quality** + +Move Claude parsing, chain reconstruction, tools, and continuation into adapter/claude.rs. Classify tools as fresh, stale-opt-in, or unsupported. Prepare-only keeps the selected source prefix without execution; replay-only executes and rebuilds without starting the bridge; live mode continues from replayed state. + +- [ ] **Step 4: Integrate supervision** + +Replace Bash readers and Claude wait_with_output with run_process. Retain at most 4 MiB for observations, keep full owner-only logs, propagate truncation, and report terminated background descendants as an error observation. + +- [ ] **Step 5: Verify and commit** + + cargo test -p persisting-replay adapter::claude + cargo test -p persisting-replay claude_resume + cargo test -p persisting-replay claude_bridge + git commit -m "fix(replay): fail closed on stale Claude observations" + +--- + +### Task 6: True mini-swe-agent and SWE-agent replay-only execution + +**Files:** +- Create: crates/persisting-replay/src/adapter/mini_swe.rs +- Create: crates/persisting-replay/src/adapter/swe_agent.rs +- Modify: crates/persisting-replay/src/adapter/mod.rs +- Modify: crates/persisting-replay/assets/mini_swe_agent_runner.py +- Modify: crates/persisting-replay/assets/swe_agent_runner.py +- Create: crates/persisting-replay/tests/replay_contract.rs +- Create: crates/persisting-replay/tests/fixtures/fake_agent_runtime.py + +**Interfaces:** +- Runner request gains mode and max_steps. +- Runner result gains phase, agent_status, replayed_steps, and continued_steps. + +- [ ] **Step 1: Write failing fake-runtime tests** + +Prove prepare-only starts no runtime; replay-only executes exactly after_step actions and zero live calls; live mode performs no more than max_steps-after_step live calls; replay-only without runtime fails before a workspace marker is written. + +- [ ] **Step 2: Verify RED** + + cargo test -p persisting-replay --test replay_contract mini_replay_only_executes_prefix_without_live_model + cargo test -p persisting-replay --test replay_contract swe_max_steps_caps_total_actions + +- [ ] **Step 3: Update mini runner** + +After the historical loop and observation write, save and return a structured replay result in replay-only mode. Call continuation only in live mode. Preserve prefix n_calls and set the native step_limit to total max_steps. + +- [ ] **Step 4: Update SWE-agent runner with a bounded loop** + +Pass mode and max_steps. Wrap the pinned DefaultAgent so its run calls setup, then step no more than the total budget while saving after each step. ReplayThenLiveModel supplies the source prefix. Stop before the first live query in replay-only; emit MaxSteps when live mode reaches the cap. Use SWE-agent 1.1.0 APIs setup, step, save_trajectory, get_trajectory_data, and AgentRunResult. Reject retry-agent configs before side effects. + +- [ ] **Step 5: Split adapters and use supervision** + +Move parsers and runner result interpretation into focused modules. Replace Command::output with run_process and parse a structured runner result file. + +- [ ] **Step 6: Verify and commit** + + cargo test -p persisting-replay --test replay_contract mini_ + cargo test -p persisting-replay --test replay_contract swe_ + cargo test -p persisting-replay adapter::mini_swe + cargo test -p persisting-replay adapter::swe_agent + git commit -m "fix(replay): execute SDK prefixes without unwanted continuation" + +--- + +### Task 7: OpenHands replay boundary and fatal status + +**Files:** +- Create: crates/persisting-replay/src/adapter/openhands.rs +- Modify: crates/persisting-replay/src/adapter/mod.rs +- Modify: crates/persisting-replay/src/engine.rs +- Modify: crates/persisting-replay/tests/replay_contract.rs + +**Interfaces:** +- Produces private OpenHandsPlan and typed terminal status. + +- [ ] **Step 1: Write failing integration tests** + +Use a fake entrypoint to prove replay-only executes the prefix and emits no live action. Add a zero-exit log containing Error while running the agent; require Failed status, nonzero report exit code, and a retained partial trajectory artifact. + +- [ ] **Step 2: Verify RED** + + cargo test -p persisting-replay --test replay_contract openhands_replay_only_stops_at_boundary + cargo test -p persisting-replay --test replay_contract openhands_fatal_status_is_not_success + +- [ ] **Step 3: Split OpenHands and stop at the boundary** + +Move parsing/output code into adapter/openhands.rs. In replay-only, run the pinned ReplayManager with the selected-prefix iteration limit and validate exactly after_step complete pairs with no live action. In live mode map and verify the total budget without a silent offset. + +- [ ] **Step 4: Supervise and type terminal states** + +Replace wait_with_output with run_process. Map fatal markers to Failed, exact maximum-iteration termination to MaxSteps, otherwise Completed. Publish a valid partial artifact before returning failure. + +- [ ] **Step 5: Verify and commit** + + cargo test -p persisting-replay adapter::openhands + cargo test -p persisting-replay --test replay_contract openhands_ + git commit -m "fix(replay): separate OpenHands replay from continuation" + +--- + +### Task 8: Conservative journal and output lifecycle + +**Files:** +- Modify: crates/persisting-replay/src/journal.rs +- Modify: crates/persisting-replay/src/engine.rs +- Modify: crates/persisting-replay/tests/replay_contract.rs + +**Interfaces:** +- Produces ordered journal-state inspection that rejects every nonterminal tool-started run. + +- [ ] **Step 1: Write failing lifecycle tests** + +Cover: finished tool without terminal run is ambiguous; prepare-only interruption is retryable; invalid input does not create an explicit output run directory; failed execution publishes result paths and partial artifacts. + +- [ ] **Step 2: Verify RED** + + cargo test -p persisting-replay journal::tests::finished_tool_without_terminal_run_is_ambiguous + cargo test -p persisting-replay engine::tests::validation_does_not_consume_output_run_id + +- [ ] **Step 3: Implement conservative state inspection** + +Treat v3 success/failure terminal events as terminal. If any tool_started exists without one, report ambiguity regardless of tool_finished. Hold the exclusive lock from before state/output allocation through result publication. + +- [ ] **Step 4: Reorder validation and finalization** + +Validate paths, runtime, exact version, and complete plan before output allocation. After allocation, route errors through a finalizer that writes available artifacts, a v3 failed result, and a terminal journal event. + +- [ ] **Step 5: Verify and commit** + + cargo test -p persisting-replay journal::tests + cargo test -p persisting-replay engine::tests + cargo test -p persisting-replay --test replay_contract failure_ + git commit -m "fix(replay): reject ambiguous same-sandbox retries" + +--- + +### Task 9: Typed static adapter dispatch + +**Files:** +- Modify: crates/persisting-replay/src/adapter/mod.rs +- Modify: all four Agent adapter modules +- Modify: crates/persisting-replay/src/model.rs + +**Interfaces:** +- Produces AdapterPlan variants for all four Agents and common phase records. +- Removes common-engine indexing into Agent-specific ReplayPlan.native values. + +- [ ] **Step 1: Write a failing dispatch test** + +Build all four fixture plans and use only agent, after_step, calls, and source_sha256 common accessors. It must fail before AdapterPlan exists. + +- [ ] **Step 2: Introduce typed dispatch** + +Delegate common accessors with explicit enum matches. Keep native JSON private to each plan. Replace old shared build_plan/run with engine-driven prepare/replay/continue dispatch. Leave adapter/mod.rs containing common types, declarations, and static dispatch. + +- [ ] **Step 3: Verify and commit** + + cargo test -p persisting-replay + cargo clippy -p persisting-replay --all-targets -- -D warnings + git commit -m "refactor(replay): split versioned agent adapters" + +--- + +### Task 10: Documentation, migration, and release verification + +**Files:** +- Modify: crates/persisting-pvisor/README.md +- Modify: docs/src/pvisor/guides/sandbox-replay.md +- Modify: docs/src/pvisor/guides/sandbox-replay.zh.md +- Modify: docs/src/pvisor/reference/cli.md +- Modify: replay smoke TOML fixtures + +**Interfaces:** +- Documents three modes, v1 request compatibility, v3 output, total-step budget, stale opt-in, and fatal status. + +- [ ] **Step 1: Write failing help assertions** + +Assert replay help contains prepare-only, corrected replay-only wording, allow-stale-observations, and the total-step definition for max-steps. + +- [ ] **Step 2: Verify RED** + + cargo test -p persisting-pvisor cli::tests::replay_help_describes_phase_modes + +- [ ] **Step 3: Update English/Chinese docs and fixtures** + +State that old non-Claude replay_only callers that only constructed a prefix must use prepare_only; v3 replay-only always executes the selected prefix and requires a runtime. Document v3 fields and change runtime-free smoke fixtures to prepare-only. + +- [ ] **Step 4: Run final verification** + + cargo fmt --check + cargo test -p persisting-replay + cargo test -p persisting-pvisor cli::replay + cargo test -p persisting-pvisor cli::tests::standalone_cli_is_small_and_run_can_be_explicit + cargo clippy -p persisting-replay --all-targets -- -D warnings + cargo clippy -p persisting-pvisor --lib --bin pvisor -- -D warnings + git diff --check + +Expected: every command exits zero. Workspace-wide tests remain outside acceptance because excluded subsystems are out of scope. + +- [ ] **Step 5: Inspect scope and commit** + +Confirm no excluded subsystem or .workbuddy path is staged, then commit documentation and fixtures with: + + git commit -m "docs(replay): document reliable phase and result contracts" + +--- + +## Plan Self-review + +- Every acceptance criterion maps to a task and focused regression test. +- Mode and result contracts land before adapter behavior consumes them. +- Process supervision is verified independently before replacing child paths. +- Behavioral tests precede final modularization, making Task 9 a green refactor. +- No task modifies an AGENTS.md-excluded subsystem. diff --git a/docs/superpowers/plans/2026-08-22-storyline-prompt.md b/docs/superpowers/plans/2026-08-22-storyline-prompt.md new file mode 100644 index 00000000..57d075c9 --- /dev/null +++ b/docs/superpowers/plans/2026-08-22-storyline-prompt.md @@ -0,0 +1,47 @@ +# Storyline `/prompt` Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans. User asked to execute this spec in-session. + +**Goal:** Map ACTF `system_prompt` / `user_content` onto Storyline document `/prompt` plus turn `/prompt` overlays, without changing `/msg`. + +**Architecture:** Optional `{system, user}` object on the document and each turn. First non-empty pair is the document baseline; differing steps write a full-replace overlay. Lance stores the objects as `prompt_json` on `runs` and `steps`. + +**Tech Stack:** Rust, serde, Storyline `storyline/v1`, ACTF convert, three-table Lance. + +## Global Constraints + +- `schema_version` stays `storyline/v1` +- `/turns/{t}/msg` stays `assistant_content.content` +- Prompt is not `/task`, `env`, or `extra` +- OpenAI / ATIF / AgenticMD / Events do not gain first-class prompt fields +- Attempt `extra` / `meta` stay residual +- Do not change TTAS, Queue, Search, or `persisting-dlcapt` +- Do not commit unless the user asks + +--- + +### Task 1: Wire type + validation + +**Files:** `crates/persisting-pchronicle/src/formats/storyline.rs`, `crates/persisting-pchronicle/src/model.rs` + +Add `StorylinePrompt { system, user }`, document and turn optional `prompt`, validation, `effective_prompt`. + +### Task 2: ACTF import/export + +**Files:** `crates/persisting-pchronicle/src/convert/actf.rs` + +Baseline + overlay algorithm; consume residuals; export from effective prompt. + +### Task 3: Lance projection + +**Files:** `crates/persisting-pchronicle/src/store/storyline/{model,rows,content}.rs` + +`runs.prompt_json` and `steps.prompt_json`; missing columns decode as absent. + +### Task 4: Downstream literals + RFCs + +**Files:** Gateway / CLI / other `StorylineTurn` literals; RFC-0001, RFC-0004; `storyline-lance.md` + +### Task 5: Verify + +`cargo test -p persisting-pchronicle --lib` and targeted convert/store tests. diff --git a/docs/superpowers/plans/2026-08-22-storyline-task-env-response.md b/docs/superpowers/plans/2026-08-22-storyline-task-env-response.md new file mode 100644 index 00000000..b9c71f15 --- /dev/null +++ b/docs/superpowers/plans/2026-08-22-storyline-task-env-response.md @@ -0,0 +1,74 @@ +# Storyline task / env / tool response Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Promote ACTF eval/budget/time/tool status and OpenAI env_state into first-class Storyline `task`, document/turn timestamps, turn `env`, and tool `kind`/`response`. + +**Architecture:** Extend `storyline/v1` optional wire structs in `formats/storyline.rs`, map them in ACTF and OpenAI converters with 1:1 JSON Pointer authority, and project the same objects onto the existing three Lance tables as additive nullable columns. + +**Tech Stack:** Rust 2021, serde/serde_json, Arrow/Lance Storyline store. + +**Spec:** docs/superpowers/specs/2026-08-22-storyline-task-env-response-design.md + +## Global Constraints + +- Keep `schema_version` as `storyline/v1`; all new fields optional. +- Each source JSON Pointer has exactly one authoritative Storyline target. +- Do not map `system_prompt` / `user_content` / attempt `extra` / `meta`. +- Do not copy `/metrics` env_state keys into `env`. +- Do not modify TTAS, Queue, Search, or persisting-dlcapt. +- Do not commit unless the user asks. +- New Lance columns must decode as absent on older tables (`*_if_present`). + +## File Map + +- `crates/persisting-pchronicle/src/formats/storyline.rs` — wire structs and validation +- `crates/persisting-pchronicle/src/convert/actf.rs` — ACTF import/export +- `crates/persisting-pchronicle/src/formats/openai_corpus.rs` — OpenAI env mapping +- `crates/persisting-pchronicle/src/store/storyline/{model,rows,content}.rs` — Lance projection +- `docs/src/rfcs/0001-storyline-format.md`, `0004-actf-format.md`, `0009-openai-messages-format.md` + +--- + +### Task 1: Storyline wire types + +**Files:** `crates/persisting-pchronicle/src/formats/storyline.rs` + +- [ ] Add `StorylineTask`, `StorylineEnv`, `StorylineTaskLlm`, `StorylineTaskResult`, `StorylineToolResponse` +- [ ] Add document `task` / `started_at` / `finished_at`, turn `env` / `finished_at`, tool `kind` / `response` +- [ ] Validate empty-task rejection, positive `k`, empty `kind` as missing +- [ ] Tests: JSON roundtrip of the new fields; empty objects omitted + +### Task 2: ACTF mapping + +**Files:** `crates/persisting-pchronicle/src/convert/actf.rs` + +- [ ] Import result/budget/llm/k/timestamps/tool kind+response +- [ ] Lift `task_correct`/`correct`/`status`/`score` into `final_metrics` +- [ ] Stop recording mapped keys as unknown fields +- [ ] Export restores those keys from first-class fields +- [ ] Update `actf_noncanonical_source_fields_are_unknown_without_source_extra` + +### Task 3: OpenAI env mapping + +**Files:** `crates/persisting-pchronicle/src/formats/openai_corpus.rs` and tests + +- [ ] Stable env keys → `/task/env`; step keys → response turn `/env` +- [ ] Equal later values consumed; unequal values become turn env +- [ ] Export writes env back onto rows/`meta_json.env_state` +- [ ] Update `openai_only_reports_unmapped_source_fields` + +### Task 4: Lance three-table projection + +**Files:** `store/storyline/model.rs`, `rows.rs`, `content.rs` + +- [ ] runs: `task_json`, `started_at`, `finished_at` (+ source json) +- [ ] steps: `env_json`, `finished_at` (+ source json) +- [ ] tool_calls: `kind`, `response_json` +- [ ] Roundtrip through `split_storyline` / `reconstruct_storyline` + +### Task 5: RFC docs + +**Files:** `docs/src/rfcs/0001-storyline-format.md`, `0004-actf-format.md`, `0009-openai-messages-format.md` + +- [ ] Wire tables and mapping rows matching the spec diff --git a/docs/superpowers/specs/2026-08-22-explorer-run-paths-design.md b/docs/superpowers/specs/2026-08-22-explorer-run-paths-design.md new file mode 100644 index 00000000..e8a57091 --- /dev/null +++ b/docs/superpowers/specs/2026-08-22-explorer-run-paths-design.md @@ -0,0 +1,29 @@ +# Explorer Run paths:squash 后不再用导入前目录 + +## Status + +Approved as approach A on 2026-08-22. + +## Context + +`--output-format storyline` squash 之后,Catalog 只有一个 `_file_ = "."` 的 Source。 +Run paths 仍把 `run_id`(常为原文件名 / job 名)当成根目录,拼出 +`{dataset}/{run_id}/subagents/{session_id}`。这不是 Storyline 父子关系。 + +## Decision + +当 `_file_ == "."`: + +- 叶子用 `document_id`:`{dataset}/{document_id}` +- 仅当 `parent.psid` / `parent_session_id` 存在且不等于 `session_id` 时: + `{dataset}/{parent}/subagents/{document_id}` +- **不用 `run_id` 做路径段** + +`_file_ != "."`(preserve / 多文件 Catalog)保持现有 `{dataset}/{file}/…` 规则。 +`RunSummary.root_session_id` 仍可回退到 `run_id`,只改展示路径。 + +## Non-goals + +- 改 Catalog source 发现或 `_file_` 列 +- 扁平化真实 parent/child +- 重编嵌入前端 diff --git a/docs/superpowers/specs/2026-08-22-explorer-steps-chats-design.md b/docs/superpowers/specs/2026-08-22-explorer-steps-chats-design.md new file mode 100644 index 00000000..c9fcb78e --- /dev/null +++ b/docs/superpowers/specs/2026-08-22-explorer-steps-chats-design.md @@ -0,0 +1,82 @@ +# Explorer Trace:Steps / Chats 两种前端视图 + +## Status + +Implemented. Approved in conversation on 2026-08-22: two frontend-only layouts; +default open is **Chats**; names are **Steps** and **Chats**. + +## Context + +Harbor ATIF 一个 step 只有一个 `source`(`system` / `user` / `agent`)。 +Storyline turn 对齐这个模型。聊天习惯则把「用户一句 + 随后的助手回复」看成一轮交互。 + +后端不改 wire、不合成 turn、不发明 user 行。两种读法都在 `pchronicle-web` +对已加载的 `TurnSummary[]` 做分组。 + +现有 Trace 按 `call_id` 收成 span,那是工具跨度,不是对话轮。Chats 另做一层, +不和 span 混用同一套 key。 + +URL 已有未使用的 `view`(默认曾是 `tree`)。本功能占用它。 + +## Goals + +1. Trace 工具栏可切换 **Chats** / **Steps**。打开 Run 默认为 Chats。 +2. 分组、计数、卡片结构只在前端完成;点开仍用原始 `turn.id` 拉详情。 +3. Analysis、source 过滤、`turn=` 深链继续针对原始 turn。 +4. ACTF 目前几乎全是 `src=agent` 时,Chats 不强行画用户气泡。 + +## Non-goals + +- 改 Storyline / ATIF / ACTF 映射或 Lance 投影。 +- 用 `/prompt.user` 伪造 `src=user` turn。 +- 引入 exchange / chat id 或后端聚合 API。 +- 改 Analysis 图表的聚合口径。 + +## Decision + +### 名称与默认 + +| 开关 | URL | 含义 | +|---|---|---| +| **Chats** | `view=chats`(默认) | 交互轮:一个 user 开一轮,随后连续 agent 是这轮回复 | +| **Steps** | `view=steps` | ATIF 步:一条 turn 一行,一个 `source` | + +未知或旧值 `tree` 视为 `chats`。刷新保持 `view`。 + +### Chats 分组(数组顺序,稳定) + +对当前列表(已 overlay source / 文本过滤)从左到右扫: + +1. `user` 开启一轮,吃掉后面连续的 `agent`。 +2. 下一个 `user` 或 `system` 结束上一轮。 +3. 单独的 `system`(含 compaction)自成一轮,不并入相邻 chat。 +4. 没有前置 `user` 的 `agent` 各自自成一轮(只有助手侧)。 +5. 连续多个 `user`:各开一轮;没有 agent 的 user 也是一轮。 + +主界面仍是原来的 span 表 + occupancy 时间轴。Chats 把一轮交互收成一行 +(bar 覆盖该轮 `event_seqs`);展开后仍是原来的 turn 行。 + +### Steps + +一条 turn 一行,同样走 span 表和时间轴,不再用 `call_id` 合并。点开证据与现在相同。 + +### 过滤与 URL + +- source / `turn_q` 只在前端过滤。Chats 先分组再按「行里是否含该 source / 文本」 + 决定显隐,匹配行保留全部成员。Steps 仍按单条 turn 滤。不再为过滤重拉 API。 +- 轴标题是 Sequence / occupancy;bar 按 `user` / `agent` / `system` 上色。 +- Chat 行 Overview 是 user preview,没有 user 则 `No user turn`。 +- 助手侧文案统一 Copilot。 +- `turn=` 仍是 turn id。Chats 下若该 id 在某轮内,展开那一轮并选中该 turn。 +- 不新增 query 参数。 + +## Files + +- `pchronicle-web/src/components.rs` — 分组函数 + Chats / Steps 列表 +- `pchronicle-web/src/workspace.rs` — 工具栏开关;`view` 默认 `chats` +- 现有 `assets/*.css` 加 Chats 卡片样式,不新开 CSS 管线 + +## Test + +- 分组单测:user→agent→agent、前导 agent、中段 system、连续 user。 +- 不要求后端 / e2e。 diff --git a/docs/superpowers/specs/2026-08-22-explorer-structure-overview-design.md b/docs/superpowers/specs/2026-08-22-explorer-structure-overview-design.md new file mode 100644 index 00000000..c9bcc2a3 --- /dev/null +++ b/docs/superpowers/specs/2026-08-22-explorer-structure-overview-design.md @@ -0,0 +1,152 @@ +# Explorer Trace:Structure 结构芯片 + Overview 摘要 + +## Status + +Approved in conversation on 2026-08-22: Structure holds type chips and +counts; Overview is the extracted utterance; list API adds `char_count` +and `modalities`, and `preview` becomes extracted text. + +## Context + +Chats / Steps 共用原来的 span 表。Structure 几乎只有 `Chat 5` 和 +`user → agent · seq 4–5`。Overview 把 `turn.message` 原样 +`to_string` 再截断,多模态数组先露出 `image_bytes:null` 一类外壳, +真正的 `text` 被挤掉。模型名、耗时、SYSTEM/CHAT 徽章和 Structure / +Evidence 重复。 + +扫 60 行时,两列要分工:左边回答「这是什么、多大」,右边回答「说了什么」。 + +## Goals + +1. Structure 展示结构化信息:大类型 + 细节形态芯片 + 组成 + 字符数 + seq。 +2. Overview 只展示抽出的对话摘要。 +3. 列表 `preview` 改为抽出正文,过滤 / 搜索 / Copilot 能命中原话。 +4. 形态芯片可靠,不依赖 180 字截断后的 JSON 残片。 + +## Non-goals + +- 改 Storyline / ATIF / ACTF 映射或 Lance 投影。 +- 改 Sequence 轴(会话相对位置 + 按 turn 类型上色)。 +- 改 Evidence 列。 +- 为列表再拉 turn 详情。 +- 用模型生成摘要;摘要就是抽出的正文截断。 +- 在 Overview 保留模型名、耗时、角色大徽章。 + +## Decision + +### 两列分工 + +| 列 | 放什么 | 不放什么 | +|---|---|---| +| Structure | 大类型芯片、形态芯片、组成、字符数、seq、行标题 | 正文、模型、耗时 | +| Overview | 抽出的摘要,单行截断,`title` 为同一段 `preview` | 类型芯片、JSON 外壳 | + +去掉 Structure 左侧色点和 Overview 里的 SYSTEM / USER / CHAT 大徽章。 +大类型只留一枚芯片,紧挨行标题(`Chat 5` / `System` / `#8`)。 + +**Structure 一格顺序** + +1. 行标题 + 大类型芯片 +2. 细节芯片(没有的不画) +3. 一行小字:`1 user + 1 agent · 1 tool · 842 chars · seq 4–5` + +### 大类型 + +| 行 | 芯片 | +|---|---| +| Chats:`TraceCard::Chat`(含无 user 的前导 agent) | `Chat` | +| Chats:`TraceCard::System` | `System` | +| Steps 行 | `User` / `Agent` / `System`(`source`) | +| 根行 | 无大类型芯片;标题仍是 `trajectory · N chats\|steps` | + +展开后的 `CompactTurnRow` 不复制整套 Structure 芯片;角色仍用现有 +`pc2-role`,正文改用抽出的 `preview`。 + +### 细节形态 + +稳定顺序:`text` · `image` · `audio` · `tool_call`。只画实际出现的。 + +Chat 行 / 根行:成员 `modalities` 并集。单条 turn:自己的列表。 + +### 组成与长度 + +- 组成:按 `source` 计数,工具数用 `tool_names.len()` 之和。 + 例:`1 user + 1 agent · 1 tool`;只有 system:`1 system`; + 无 user 的 agent 行:`1 agent · 1 tool`。零工具时省略 `· 0 tool`。 +- 长度:抽出正文的**完整**字符数(截断前),展示为 `842 chars` 或 + `1.2k chars`(≥1000 用一位小数的 `k`)。 +- **Chat 行的 `chars` 只计用户摘要**。没有 user → `0 chars`,Overview + 为 `No user turn`。 +- 根行:各源计数 + 全会话形态并集 + **用户摘要字符合计**。Overview + 不编摘要。 + +### 摘要 + +- Chat 行:第一条 user 的 `preview`。没有 user → `No user turn`。 +- Steps 行:该 turn 的 `preview`。空 → `No text`。 +- 展开的 `CompactTurnRow`:同样用该 turn 的 `preview` / `No text`。 +- 单行截断;`title` 等于 `preview`(服务端已截到约 180 字)。全文在 + turn 详情,不把未截断 message 放进列表。 + +### 列表 wire + +`TurnSummary`(CLI explorer 与 `pchronicle-web` 对齐)在现有字段上: + +- `preview`:**抽出的可读正文**,再 `compact` 到约 180 字。不再 + `serde_json::to_string(message)`。 +- `char_count: u64`:抽出正文截断前的字符数。 +- `modalities: Vec`:`text` / `image` / `audio` / `tool_call`。 + +旧客户端缺字段:前端当 `preview` 空、`char_count = 0`、无形态,不报错。 +不改 Storyline schema。搜索 / 过滤继续用 `preview` 和 `source`。 + +### 从 `turn.message` 抽出 + +服务端在 `turn_summary` 里算,前端只展示和按行聚合。 + +**正文** + +- 字符串 message → 整段。 +- 数组 / 对象:递归走进子节点,收集非空字符串 `text`,以及本身是 + 字符串的 `content`。多段用空格拼接。`null` / 空串不算。 +- 抽不出 → `preview` 空,`char_count = 0`。 + +**形态(有才记)** + +- `text`:抽出正文非空。 +- `image`:`image` / `image_url` / `image_bytes` 有非空值,或 part + `type` 为 `image` / `image_url`。 +- `audio`:`input_audio` / `audio` 有非空值,或 `type` 为 `audio` / + `input_audio`。 +- `tool_call`:`display_tool_calls` 非空,或正文含 ``。 + +空字符串、`null`、缺省 key 都不构成形态。 + +### 文件 + +- `crates/persisting-pchronicle-cli/src/server/explorer.rs` — 抽出、 + `TurnSummary` 新字段、单测 +- `pchronicle-web/src/model.rs` — `TurnSummary` 对齐 +- `pchronicle-web/src/components.rs` — Structure / Overview 渲染与聚合 +- `pchronicle-web/assets/span-timeline.css` — 芯片样式 +- 现有 `chat_view` 分组与过滤规则不动 + +## Test + +服务端(`explorer`): + +- 多模态数组:前部 `null` 媒体字段 + 末尾 `text` → 正文是那句 text, + `char_count` 为完整长度,`modalities` 含 `text`,不含空 image/audio +- 纯字符串 message → 正文即字符串,`text` +- 只有非空 `image_url`、无 text → 空 preview,`0`,`[image]` +- 有 `tool_names` 或 `` → 含 `tool_call` + +前端(`pchronicle-web`): + +- user + agent Chat:形态并集;`chars` 等于 user `char_count`; + Overview 是 user `preview` +- 无 user 的 Chat:Overview `No user turn`,`0 chars`,大类型仍是 + `Chat` +- Steps 空 preview → `No text` + +不要求 e2e。 diff --git a/docs/superpowers/specs/2026-08-22-json-value-renderer-design.md b/docs/superpowers/specs/2026-08-22-json-value-renderer-design.md new file mode 100644 index 00000000..17c4ad7d --- /dev/null +++ b/docs/superpowers/specs/2026-08-22-json-value-renderer-design.md @@ -0,0 +1,123 @@ +# Turn evidence:按类型派发的 JSON 值渲染 + +## Status + +Approved in conversation on 2026-08-22: approach 2 (classifier + small +components behind `JsonValue`); first host is Turn evidence. + +## Context + +Turn evidence 把 Tool calls、Observation、Raw events,以及非字符串 +`message`,一律 `serde_json::to_string_pretty` 丢进 `
`。扁平对象和深层
+树看起来一样,工具 `arguments` 经常还是 JSON 字符串。
+
+查询单元格弹窗也是 `
`,本轮不改。
+
+## Goals
+
+1. 通用 `JsonValue`:按 JSON 形态派发,不按 evidence 块名特判。
+2. 一层扁平对象 → 两列表;一层扁平对象数组 → 多列表。
+3. 更深的对象/数组 → `
` 树,默认全关,打开只露一层。 +4. 当前节点若是能解成对象/数组的字符串,先 peel 再分类。 +5. 接到现有 Turn evidence;`extra` / `metrics` 有值才出块。 + +## Non-goals + +- 查询结果单元格 / 单元格弹窗。 +- 识别 `{fn, args}` 等协议形状。 +- 改 agent 送给模型的 pretty JSON。 +- 改 Reasoning 的纯文本语义(不 peel)。 +- 重编嵌入前端以外的后端 wire。 + +## Decision + +### 模块 + +`pchronicle-web/src/json_value.rs` 只暴露: + +- `peel_json(&Value) -> Value` +- `classify_json(&Value) -> JsonShape` +- `JsonValue` 组件 + +`JsonShape`:`Scalar` | `KvTable` | `RecordTable` | `Tree`。 +四个小组件各管一种形态。表格单元格和树子节点只回调 `JsonValue`。 + +`components.rs` 的 `EvidenceBlock` 改成宿主(`title` + 子内容),不再收 +pretty 字符串。 + +### Peel + +只处理**当前**节点: + +- 字符串 `from_str` 得到对象或数组 → 用解析结果。 +- 失败、得到标量、非字符串 → 原值。 +- 不递归 peel 子字段。因此 `[{fn, args:"{...}"}]` 仍是一层 RecordTable, + `args` 格子里再派发成表或树。 + +`peel` 最多跟节点走,不设跨节点深度预算;循环嵌套 JSON 字符串由下一层 +`JsonValue` 再 peel。 + +### 分类(对 peel 后的当前值;判断子字段是否标量时不 peel) + +标量:`null` / bool / number / string。 + +| 形态 | 条件 | +|---|---| +| Scalar | 标量 | +| KvTable | 对象(含空对象),且每个值都是标量 | +| RecordTable | 非空数组,每项都是对象,且每个对象的每个字段都是标量 | +| Tree | 其余对象或数组(含空数组、标量数组、混杂项、含嵌套的对象) | + +空对象:0 行 KvTable。空数组:折叠树,摘要 `[0 items]`。 + +### 渲染 + +- Scalar:纯文本(null / bool / number 用 `Display`;字符串原文)。 +- KvTable:两列 `key` / `value`;value 格是 `JsonValue`。 +- RecordTable:列名取对象 key 并集,稳定顺序(首次出现);单元格是 `JsonValue`。 +- Tree:每个子项一个 `
`,默认 `open=false`。摘要:`key` 或 `[i]` + + 类型规模(`{3 keys}` / `[12 items]`),不铺整段 JSON。展开后子项再走 + `JsonValue`。 + +### Turn evidence 接入 + +`InlineTurnDetail`: + +| 块 | 规则 | +|---|---| +| Message | peel 后仍是标量 → 现有正文;对象/数组 → `JsonValue` | +| Reasoning | 始终纯文本,不 peel | +| Tool calls | `wire_tool_calls` 序列化成 `Value` 后 `JsonValue` | +| Observation | 有值则 `JsonValue` | +| Raw linked events | 非空则 `JsonValue` | +| Extra / Metrics | `turn.extra` / `turn.metrics` 有值才出块,走 `JsonValue` | + +块外壳仍是 `
`;Message 默认展开,其余 +默认折叠。块内不再用整段 `
` 作为主渲染。
+
+### 样式
+
+沿用 `pc2-` 前缀,加在现有 `assets/components.css` / `assets/inline-trace.css`。
+不新开 CSS 管线。树用原生 `
`。 + +## Files + +- `pchronicle-web/src/json_value.rs` — peel / classify / 四个形态 / `JsonValue` +- `pchronicle-web/src/components.rs` — `EvidenceBlock` 宿主化;`InlineTurnDetail` 接入 +- `pchronicle-web/src/main.rs` — `mod json_value` +- `pchronicle-web/assets/components.css`、`assets/inline-trace.css` — 表与树样式 + +## Test + +单测 `peel_json` / `classify_json`: + +- 扁平对象 → KvTable +- 扁平对象数组 → RecordTable +- 对象数组但 `args` 是 JSON 字符串 → 仍 RecordTable(子字段不 peel) +- 该字符串 peel 后 → KvTable 或 Tree +- 含嵌套对象 → Tree +- 标量数组、空数组 → Tree +- 空对象 → KvTable +- 非法 JSON 字符串、JSON 编码的标量字符串 → Scalar + +不要求 WASM 组件快照。 diff --git a/docs/superpowers/specs/2026-08-22-persisting-replay-adapter-module-split-design.md b/docs/superpowers/specs/2026-08-22-persisting-replay-adapter-module-split-design.md new file mode 100644 index 00000000..4b2354d0 --- /dev/null +++ b/docs/superpowers/specs/2026-08-22-persisting-replay-adapter-module-split-design.md @@ -0,0 +1,99 @@ +# Persisting Replay Adapter Module Split Design + +## Objective + +Replace the current facade-only Agent adapter modules with physical module +boundaries. Each Agent module will own its parser, preparation logic, execution +logic, native reconstruction helpers, and Agent-specific tests. This is a +behavior-preserving refactor. + +## Scope + +In scope: + +- `crates/persisting-replay/src/adapter/mod.rs` +- `crates/persisting-replay/src/adapter/claude_code.rs` +- `crates/persisting-replay/src/adapter/mini_swe_agent.rs` +- `crates/persisting-replay/src/adapter/openhands.rs` +- `crates/persisting-replay/src/adapter/swe_agent.rs` +- `crates/persisting-replay/src/adapter/runtime.rs` only when an import or + visibility adjustment is required by the move + +Out of scope: + +- Changes to `ReplayPlan`, `AdapterPlan`, public request/result schemas, replay + modes, artifact names, or runtime behavior +- Redesign of the Mini/SWE SDK bridge +- Deduplication of comparison, artifact, status, or process configuration code +- Gateway, pChronicle, Queue, Search, TTAS, and `persisting-dlcapt` + +## Module Ownership + +### `adapter/mod.rs` + +The module root owns only: + +- child-module declarations; +- `RunContext`; +- the public `build_plan` and `run` static dispatch functions; +- helpers used by at least two Agent modules; +- the shared Mini/SWE SDK bridge that is explicitly outside this refactor. + +It must not contain `build_claude_plan`, `build_mini_plan`, +`build_openhands_plan`, `build_swe_plan`, `run_claude`, `run_mini`, `run_swe`, +or `run_openhands`. + +### Agent modules + +Each Agent module owns its version-pinned native parsing, boundary extraction, +prefix construction, replay/continuation orchestration, native reconstruction, +terminal-state interpretation, and focused unit tests. + +The only callable module surface from `adapter/mod.rs` remains: + +```rust +pub(super) fn build(request: &PlaybackRequest) -> Result; + +pub(super) fn execute( + plan: &ReplayPlan, + context: &RunContext<'_>, + journal: &mut Journal, +) -> Result; +``` + +Agent-specific helpers remain private to their module. Shared helpers use +`pub(super)` only when a child module must call them. + +## Migration Strategy + +Move one Agent at a time. After each move, compile and run that Agent's focused +tests before moving the next Agent. Start with OpenHands, then Mini, SWE, and +finally Claude; this leaves the largest and most coupled implementation until +the common helper boundary is established. + +Tests move with the implementation they cover. Tests for common environment, +process, or dispatch behavior remain in `adapter/mod.rs` or their existing +common module. + +No logic is rewritten during movement. Necessary edits are limited to module +paths, imports, visibility, and calls through `super`. + +## Acceptance Criteria + +- All four Agent modules contain their actual parser and executor implementations. +- `adapter/mod.rs` contains none of the eight Agent-specific build/run functions + listed above. +- The four dispatch entrypoints retain their current signatures. +- `cargo fmt --check -p persisting-replay` passes. +- `cargo test -p persisting-replay` passes. +- `cargo clippy -p persisting-replay --all-targets -- -D warnings` passes. +- `git diff --check` passes for the refactor. +- No excluded subsystem or unrelated concurrent change is staged. + +## Risks and Controls + +The primary risk is accidental behavioral change while resolving imports or +visibility. Mechanical moves are therefore separated by Agent and verified +incrementally. A second risk is creating a new generic abstraction merely to +make the files compile; the explicit out-of-scope rules prohibit that. Shared +code stays shared unless it already has a clear multi-Agent use. diff --git a/docs/superpowers/specs/2026-08-22-persisting-replay-reliability-design.md b/docs/superpowers/specs/2026-08-22-persisting-replay-reliability-design.md new file mode 100644 index 00000000..3199382a --- /dev/null +++ b/docs/superpowers/specs/2026-08-22-persisting-replay-reliability-design.md @@ -0,0 +1,311 @@ +# Persisting Replay Reliability and Adapter Design + +## Status + +Approved in conversation on 2026-08-22. + +## Context + +`persisting-replay` reconstructs an Agent-native trajectory at a complete tool +batch boundary, re-executes the selected prefix in a fresh workspace, and can +continue the Agent from the resulting observations. The current implementation +supports Claude Code 2.1.220, mini-swe-agent 2.4.6, OpenHands 0.53.0, and +SWE-agent 1.1.0. + +The initial implementation proved the native-resume approach, especially the +Claude Resume Transport cleanup, but exposes several incompatible meanings +through one `replay_only` boolean. It also centralizes parsing, execution, +process management, and result extraction in one large adapter module. Some +public controls are consequently advisory rather than enforced: non-Claude +`replay_only` does not execute tools, SWE-agent ignores `max_steps`, and an +OpenHands controller failure can still produce a successful result. + +This design makes the replay contract explicit and fail-closed while retaining +the existing four version-pinned Agent integrations. + +## Goals + +1. Give prepare, replay, and continuation distinct, consistent public + semantics across every Agent. +2. Ensure advertised limits and terminal statuses are enforced rather than + silently ignored. +3. Bound child-process memory use and reliably clean up process groups. +4. Make stale observations an explicit opt-in degradation. +5. Return enough failure context to locate partial artifacts and ambiguous + state. +6. Split Agent-specific behavior from the common replay lifecycle without + introducing a dynamic plugin system. +7. Add contract-level integration coverage around fake runtimes and committed + trajectory fixtures. + +## Non-goals + +- Supporting additional Agent versions or Agent families. +- Dynamically loading replay adapters. +- Guaranteeing that a replayed next action is identical to the source action. +- Resuming a partially executed, side-effecting prefix in the same sandbox. +- Adding Gateway capture, pChronicle storage, or a model-traffic audit. +- Changing TTAS, Queue, Search, or `persisting-dlcapt`. + +## Public execution modes + +Replace the internal `replay_only: bool` with a `ReplayMode` enum: + +```rust +pub enum ReplayMode { + PrepareOnly, + ReplayOnly, + ReplayAndContinue, +} +``` + +The CLI exposes the modes as follows: + +- `--prepare-only` parses the source trajectory and constructs the selected + native prefix. It executes no historical tool and starts no live Agent. An + Agent runtime is optional. +- `--replay-only` parses the trajectory, executes the complete selected tool + prefix, writes fresh observations into the reconstructed native context, and + stops before the first new model request. It requires an Agent runtime for + every Agent. +- Supplying neither flag performs prepare, replay, and live continuation. It + requires an Agent runtime. +- `--prepare-only` and `--replay-only` conflict. + +The existing v1 JSON request and TOML `replay_only = true` retain their field +shape but adopt the corrected `ReplayOnly` meaning. Callers that relied on the +old non-Claude prepare-only behavior must migrate to `prepare_only = true` or +`--prepare-only`. Deserializers reject both booleans being true. + +## Step budget + +`max_steps` is the total number of Agent action/model steps, including the +selected replay prefix. Every live adapter must either enforce that definition +or reject the request as unsupported before executing tools. + +- Claude passes `max_steps - prefix_model_turns` as `--max-turns`. +- mini-swe-agent initializes its native call counter from the prefix and uses + `max_steps` as the Agent `step_limit`. +- OpenHands maps the total limit to the runtime iteration control and verifies + the resulting action count. Adapter tests lock down any framework-specific + offset. +- SWE-agent receives `max_steps` in its runner request and applies the + remaining live-step budget through the version-pinned Agent configuration. + +`max_steps <= prefix_model_turns` is rejected for live continuation. It is +valid in `ReplayOnly` mode when it equals the selected prefix length. + +## Result protocol + +Output advances from `sandbox-playback.result/v2` to +`sandbox-playback.result/v3`. A successful or failed result contains: + +```text +phase: prepared | replayed | continued +quality: verified | degraded +agent_status: completed | max_steps | failed | not_started +``` + +Definitions: + +- `phase` is the furthest successfully completed replay phase. +- `quality` describes whether every reconstructed observation came from a + supported execution path. It is independent of observation equality. +- `agent_status` describes live continuation only. Prepare and replay-only + results use `not_started`. + +The result also contains `run_id`, `state_dir`, `output_dir`, produced +artifacts, replayed call count, prefix step count, continued step count, and +optional structured failure information. A failed live Agent returns a nonzero +CLI exit code even when a partial native trajectory is available. Partial +artifacts remain listed in the failure result. + +The CLI continues to accept v1 JSON requests. The implementation emits only v3 +results; it does not maintain two output code paths. + +## Degraded observations + +The default invariant is that every observation inserted into the reconstructed +prefix was produced from the fresh workspace by a supported execution path. + +Claude Code tools such as `Agent` and `TaskOutput` currently cannot satisfy +that invariant because their original results are copied from the source +trajectory. Such a call inside the selected prefix fails validation by default. + +`--allow-stale-observations` permits these calls for research and migration +workflows. Each copied observation records a degradation reason and source call +ID. The overall result has `quality: degraded`; it can never be reported as +`verified`. Synthetic Task/Todo acknowledgements are subject to the same rule. +Unsupported tools remain errors rather than synthetic successes. + +## Adapter architecture + +The common engine owns configuration validation, directory allocation, +journaling, phase transitions, artifact publication, process supervision, and +result serialization. Agent modules own native trajectory parsing, typed plan +data, prefix construction, historical execution, continuation launch, and +continued-trajectory interpretation. + +The source layout becomes: + +```text +src/ + adapter/ + mod.rs + claude.rs + mini_swe.rs + openhands.rs + swe_agent.rs + runtime.rs + process.rs + engine.rs + model.rs +``` + +`adapter::mod` defines an internal dispatch enum over four concrete adapters. +This is static dispatch: adding a supported Agent still requires a code change +and a pinned profile. The design deliberately avoids object-safe dynamic +plugins. + +Each adapter exposes the same phase-oriented operations conceptually: + +```rust +trait ReplayAdapter { + type Plan; + + fn build_plan(&self, request: &PlaybackRequest) -> Result; + fn prepare(&self, plan: &Self::Plan, context: &RunContext) -> Result; + fn replay( + &self, + plan: &Self::Plan, + prepared: Prepared, + context: &RunContext, + journal: &mut Journal, + ) -> Result; + fn continue_run( + &self, + plan: &Self::Plan, + replayed: Replayed, + context: &RunContext, + journal: &mut Journal, + ) -> Result; +} +``` + +The concrete implementation may use an internal enum instead of a public Rust +trait where associated plan types make dispatch clearer. The invariant is that +Agent-specific native `serde_json::Value` does not leak into the common engine. +Each module wraps native data in a private plan type and validates it before a +later phase can consume it. + +Runtime manifest parsing and exact version probing live in `adapter/runtime.rs`. +Every Agent has a version-banner parser. A parser must return one exact semantic +version; substring containment is not sufficient. + +## Process supervision + +All spawned historical tools and Agent runners use a common process supervisor. +It provides: + +- a dedicated process group on Unix; +- a wall-clock timeout and cancellation path; +- concurrent stdout and stderr draining; +- streaming logs written to an owner-only file; +- a bounded in-memory tail used for observation content and error + classification; +- an explicit truncation flag and total byte counters; +- process-group termination followed by child reaping on timeout, cancellation, + or unsupported background-process survival. + +The supervisor never collects unbounded output with `read_to_end`, `output`, or +`wait_with_output`. Once the configured observation limit is reached it +continues draining to the log while retaining no additional in-memory bytes. + +Historical Claude Bash does not support persistent background work in this +version. After the shell exits, surviving members of its process group are +terminated and reported as a degraded/error observation according to the +native command outcome. This prevents a background child from holding output +pipes indefinitely or mutating later replay steps asynchronously. + +Agent continuation processes may run their own managed descendants, but the +whole process group is still terminated when the top-level continuation +reaches timeout or cancellation. + +## Journal and directory lifecycle + +Configuration, input trajectory, runtime manifest, exact runtime version, and +the complete plan are validated before allocating the unique output directory. +Validation failure therefore does not consume a caller-selected run ID. + +The state lock is acquired before any replay side effect. Journal events record +phase transitions and tool starts/finishes. Same-sandbox recovery is permitted +only when the previous journal contains no `tool_started` event. If any tool +started and the run lacks a terminal event, the state is ambiguous regardless +of whether a corresponding `tool_finished` was synced: the engine always +restarts from the first tool and cannot prove that repeating completed effects +is safe. + +A failed execution writes a v3 result whenever the state and output locations +are writable. The CLI also includes the generated run ID and paths in its JSON +error envelope if result publication itself fails. + +## Agent-specific terminal behavior + +- Claude Resume Transport keeps the existing nonce, boundary observation hash, + canonical prefix hash, and fail-closed cleanup. The bridge remains local and + authenticated. +- OpenHands controller fatal markers set `agent_status: failed` and a nonzero + exit result. A maximum-iteration terminal state sets `agent_status: + max_steps` and is not confused with an infrastructure failure. +- mini-swe-agent and SWE-agent runners publish structured terminal metadata + rather than forcing Rust to infer status exclusively from free-form logs. + +## Testing strategy + +Development follows test-first cycles. The permanent suite includes: + +1. Mode contract tests proving that prepare-only executes zero tools, + replay-only executes the selected prefix for all four Agents, and live mode + performs continuation. +2. A fake SWE-agent runtime proving `max_steps` reaches the runner and caps live + model calls. +3. Claude tests proving opaque calls fail by default and opt-in runs produce a + degraded result with per-call reasons. +4. OpenHands tests proving fatal controller markers produce a failed Agent + status and nonzero CLI result while retaining partial artifacts. +5. Exact version-parser tests covering prefixes, suffixes, warnings, and wrong + versions that contain the expected digits. +6. Process tests producing output beyond the memory limit and starting a + background child. Tests assert bounded retained bytes, complete log draining, + prompt return, and no surviving process-group member. +7. Journal tests proving that any interrupted side-effecting run is ambiguous + and a prepare-only interruption is safely repeatable. +8. CLI integration tests that execute the committed smoke fixtures with fake + version-pinned runtimes and validate v3 results and artifacts. +9. A portable mini-swe-agent runtime-path test that compares canonical paths on + macOS and Linux. + +Real third-party Agent installations and model calls remain outside ordinary +CI. They are exposed as ignored/profile tests for release qualification. + +## Documentation and migration + +The pVisor README, replay guide, and CLI reference will document the three +modes, the corrected meaning of `replay_only`, the stale-observation opt-in, +the total-step budget, and v3 result fields. The migration note will call out +that old non-Claude `replay_only = true` callers that only wanted prefix +construction must switch to `prepare_only = true`. + +## Acceptance criteria + +- All four Agents implement the same three mode semantics. +- `max_steps` is enforced or rejected before side effects for every Agent. +- A normal run cannot return verified quality after copying a source + observation. +- A fatal Agent terminal state cannot return a successful CLI exit code. +- Child output memory is bounded and background descendants cannot stall the + replay engine. +- Exact version profiles reject substring-only matches. +- Failed runs identify their run and artifact locations. +- Targeted replay and pVisor CLI tests pass on macOS and Linux. +- Clippy passes for `persisting-replay` and the touched pVisor CLI targets. diff --git a/docs/superpowers/specs/2026-08-22-storyline-prompt-design.md b/docs/superpowers/specs/2026-08-22-storyline-prompt-design.md new file mode 100644 index 00000000..110c308b --- /dev/null +++ b/docs/superpowers/specs/2026-08-22-storyline-prompt-design.md @@ -0,0 +1,159 @@ +# Storyline `/prompt`:ACTF `system_prompt` / `user_content` + +## Status + +Implemented. Approach C approved in conversation on 2026-08-22: keep `/turns/{t}/msg` +as the assistant utterance; store the ACTF prompt pair as `{system, user}` JSON +on the document and, when it differs, on the turn. + +This increment supersedes the residual classification of those two ACTF step +keys in [2026-08-22-storyline-task-env-response-design.md](2026-08-22-storyline-task-env-response-design.md). +Attempt `extra` / `meta` stay residual. + +## Context + +ACTF 一步同时带三份文本:`system_prompt`、`user_content`、 +`assistant_content.content`。当前只有助手正文进入 `/turns/{t}/msg`;两份 prompt +落入 `unknown_fields`,导出时被写成空字符串。 + +`/task/env` 是运行时/基础设施,不是这两份模型输入。`msg` 已是助手正文的权威槽, +不能改成 `{system, user}` 或 `{system, user, content}`。 + +实测语料上同一 attempt 的 5454 步往往共享同一对 prompt。权威槽必须能去重, +否则每条 `message_json` 都会再存一份。 + +## Goals + +1. ACTF `system_prompt` 与 `user_content` 各有恰好一个权威目标;导入后不再进入 + `unknown_fields`。 +2. `/turns/{t}/msg` 继续只映射 `assistant_content.content`;字符串 `msg` 保持合法。 +3. 文档基线 + turn 整段覆盖;覆盖相对 `/prompt`,不相对上一 turn。 +4. `schema_version` 仍为 `storyline/v1`。Lance 仍是三表,不新增第四张表。 +5. OpenAI / ATIF / AgenticMD / Events 不新增一等 prompt 字段。 + +## Non-goals + +- 把 `src` 与 `msg` 收成 enum,或改变 `msg` 的话语语义。 +- 把 prompt 写进 `/task`、`/task/env`、`turns[].env`、`extra` 或 `unknown_fields`。 +- 拆出 `src=system` / `src=user` turn,或改变 `step_id` / turn 基数。 +- 把 OpenAI / ATIF 的 system/user 消息提升到 `/prompt`。 +- 提升 attempt `extra` / `meta`。 +- 新增 `storyline/v2`。 +- 改变 TTAS、Queue、Search、`persisting-dlcapt`。 + +## Wire + +`prompt` 保持全名(与 `task` / `env` 相同)。对象 `deny_unknown_fields`。 + +```text +/prompt +├── system string? ACTF system_prompt +└── user string? ACTF user_content +``` + +根对象增加可选 `/prompt`。`turns[]` 增加可选 `/prompt`,形状相同。 + +空对象、只含空字符串的对象,在**文档**上视为缺省,不序列化。 +`system` / `user` 若为空字符串,默认不序列化。 + +`copied == true` 的 context turn 不得写 `prompt`。 + +`effective_kind()` 不读取 `prompt`。 + +## 有效 prompt(整段覆盖,不是浅合并) + +```text +effective(turn) = + turn.prompt 若该 turn 有 /prompt + 否则 document /prompt +``` + +turn 一旦带 `/prompt`,就整段替换文档基线:缺省的 `system` / `user` 视为空字符串, +**不**从文档继承。不合并更早的 turns。查询层按此计算;存储层不物化合并结果。 + +因此,只要某步与文档基线不同,导入必须把**当前完整 pair**写到该 turn +(即使只有一侧变了,未变的一侧也要写上,否则整段覆盖会把它打成空)。 + +两侧都空、且文档基线非空时,turn 必须显式写出 +`{"system":"","user":""}`,以便和「缺省 = 继承文档」区分。这种显式空 pair +是 turn 上唯一允许的「双空」`/prompt`。 + +## ACTF 映射(RFC-0004) + +`pair(step) := (system_prompt, user_content)`,空字符串就是空字符串,不是缺失。 + +| ACTF JSON Pointer | Storyline JSON Pointer | +|---|---| +| `/attempts/{a}/trajectory/steps/{s}/system_prompt` | 见下:文档 `/prompt/system` 或 `/turns/{t}/prompt/system` | +| `/attempts/{a}/trajectory/steps/{s}/user_content` | 见下:文档 `/prompt/user` 或 `/turns/{t}/prompt/user` | +| `/attempts/{a}/trajectory/steps/{s}/assistant_content/content` | `/turns/{t}/msg`(不变) | + +每个源 pointer 仍只有一个权威目标。同一对值不会同时写在文档和该 turn 上。 + +导入算法(单次顺序扫描即可,但空步若出现在基线之前,需要在基线确定后补写): + +1. `baseline` = 第一个至少一侧非空的 `pair`。写入文档 `/prompt`(空字符串键省略)。 + 若全部 step 都是双空,文档 `/prompt` 缺省。 +2. 对每个 step,按 turn 顺序: + - `pair == baseline`(双空对「无基线」也算相等)→ 已消费,turn 不写 `/prompt`。 + - 否则 → 该 turn `/prompt` = 当前完整 pair。双空且文档有基线时写 + `{"system":"","user":""}`。 +3. 基线落在 step `k`、且 `0..k-1` 为双空时:那些 turn 必须带显式空 + `/prompt`,否则还原会错误继承后来的基线。 + +导出: + +```text +system_prompt = effective(turn).system or "" +user_content = effective(turn).user or "" +``` + +ACTF 这两个键保持必填字符串;缺省写成 `""`,不再无条件空写。 + +`system_prompt` / `user_content` 不再列入残差表。attempt `extra` / `meta` 仍是残差。 + +## 其它格式 + +- OpenAI Messages:不写 `/prompt`。角色已经是独立 turn 的 `src` + `msg`。 +- ATIF / AgenticMD / Events:不扩张一等 schema。Storyline-only `/prompt` 走既有 + `_storyline` envelope,不进 ATIF `extra`。 +- 没有 `/prompt` 的普通 Storyline 导出 ACTF 时,两键仍为 `""`(合成转换,与现在一致)。 + +## Lance + +不新增表。JSON 列保存完整对象,不把 `system` / `user` 展平为独立列。 + +| 表 | 新列 | 内容 | +|---|---|---| +| `runs` | `prompt_json` | 文档 `/prompt`,或缺省 | +| `steps` | `prompt_json` | 该 turn 的 `/prompt`,或缺省 | + +旧表缺列按缺失解码。`content.rs` 的 `externalize_batch` 对缺列 skip。 + +## 校验 + +- `/prompt` 若存在:文档侧至少一个键为非空字符串。 +- turn 侧:至少一个非空键,或两个键都在且都是空字符串(显式清空)。 +- 空对象 `{}` 非法(文档与 turn 皆是)。 +- `system` / `user` 必须是字符串;其它 JSON 类型导入失败。 +- 未知键 `deny_unknown_fields`。 + +## 实现落点 + +- `crates/persisting-pchronicle/src/formats/storyline.rs`:`StorylinePrompt`,文档与 turn 字段 +- `crates/persisting-pchronicle/src/convert/actf.rs`:导入算法与导出还原 +- `crates/persisting-pchronicle/src/store/storyline/{model,rows,content}.rs`:`prompt_json` +- Gateway / CLI 测试里的 `StorylineTurn` / `StorylineDocument` 字面量补字段 +- RFC-0001 wire 表;RFC-0004 权威映射与残差表;`docs/src/pchronicle/design/storyline-lance.md` + +## 验收 + +1. 原 ACTF 语料导入 `storyline-lance` 后, + `/attempts/*/trajectory/steps/*/system_prompt` 与 + `/attempts/*/trajectory/steps/*/user_content` 不再出现在 unknown-field warning。 +2. 全程同一 pair:只出现文档 `/prompt`,turns 不写 `/prompt`;`msg` 仍是各步助手正文。 +3. 中途 pair 变化:变化步带完整 turn `/prompt`;未变步不写;导出还原每步原字符串。 +4. 前缀双空、其后出现非空基线:前缀 turn 带显式空 `/prompt`,导出仍是 `""`。 +5. ACTF → Storyline → ACTF 还原这两键,不再依赖 `unknown_fields`。 +6. 既有 ATIF / OpenAI fixture 与无 `/prompt` 的旧 Storyline 仍合法。 +7. 仍为残差:attempt `extra`、`meta`。 diff --git a/docs/superpowers/specs/2026-08-22-storyline-task-env-response-design.md b/docs/superpowers/specs/2026-08-22-storyline-task-env-response-design.md new file mode 100644 index 00000000..472cc9c5 --- /dev/null +++ b/docs/superpowers/specs/2026-08-22-storyline-task-env-response-design.md @@ -0,0 +1,284 @@ +# Storyline `task` / `env` / tool `response` 扩展 + +## Status + +Draft. Approved in conversation on 2026-08-22; amended the same day to put +eval budget on `/task`, `k` on `/task/llm`, and `started_at` / `finished_at` +on the Storyline document (plus turn-level `finished_at`). + +## Context + +ACTF 与 OpenAI Messages 导入 Storyline 时,评测结果、运行时环境和工具执行状态 +大量落入 `unknown_fields`。Storyline 现有 schema 是 ATIF-first:有 `final_metrics` +和任意 JSON 的 `tool_calls[].result`,没有文档级任务对象,也没有步级环境变化。 + +本设计给 `storyline/v1` 增加可选结构,不升 schema 版本: + +1. 根对象 `/task`:文档级环境、LLM 推理参数(先收 `k`)、评测结果与评测预算 +2. 根对象 `/started_at`、`/finished_at`:文档级时间窗 +3. `/turns/{t}/env`:相对 `/task/env` 的环境变化 +4. `/turns/{t}/finished_at`:该 turn 的结束时间(开始时间仍是 `/ts`) +5. `/tool_calls/{c}/kind` 与 `/tool_calls/{c}/response`:工具事件类型与结构化执行结果 + +`agent.model`、`turns[].model`、`effort` 仍是 model / thinking 的权威字段;本设计 +不把它们搬进 `/task/llm`。ACTF 的 `system_prompt` / `user_content` 不是运行时环境, +本设计不吸收;它们改由 +[2026-08-22-storyline-prompt-design.md](2026-08-22-storyline-prompt-design.md) +的 `/prompt` 承接。 + +## Goals + +1. 把两次导入 warning 中的评测结果、评测预算、文档/步时间窗、OpenAI `env_state` + 基础设施键、以及 ACTF 工具 `type` / `status` / `exit_code` 提升为 Storyline + 领域字段。`k` 作为文档级 LLM 推理参数进入 `/task/llm/k`。 +2. 每个源 JSON Pointer 仍只有一个权威目标;`/final_metrics` 上的 + `task_correct` / `correct` / `status` / `score` 改为从 `/task/result` 提升。 +3. 步级环境按「相对文档环境的差异」存储,还原时用 `/task/env` 覆盖该 turn 的 + `env`,不要求折叠更早的 turns。 +4. Lance 仍是 `runs` / `steps` / `tool_calls` 三表,不新增第四张表。 + +## Non-goals + +- 把 ACTF `system_prompt` / `user_content` 提升为一等字段(见后续 `/prompt` 规格)。 +- 把 attempt `extra` / `meta` 提升为一等字段。 +- 把已进入 `/metrics` 的 `env_state` 键(token、latency、`status_code`、 + `finish_reason` 等)再复制进 `env`。 +- 把 `agent.model` / `turns[].model` / `effort` 的权威目标改到 `/task/llm`。 +- 新增 `storyline/v2`,或修改 ATIF / AgenticMD / Events 的一等 schema。 +- 把 `env_state` 展平为独立 SQL 列。 +- 改变 TTAS、Queue、Search、`persisting-dlcapt`。 + +## Wire schema(RFC-0001 增补) + +`schema_version` 仍为 `storyline/v1`。新对象全部 optional;缺省或空对象不序列化。 +拥有字段的对象继续 `deny_unknown_fields`;`env.state` 的值是开放 JSON object。 + +保持全名(不引入短名):`task` / `env` / `llm` / `result` / `response` / +`started_at` / `finished_at`。`tool_calls[].kind` 与 `turns[].kind` 同名不同槽: +前者是工具事件类型,后者是叙事种类。`turns[].effective_kind()` 不得读取 +`tool_calls[].kind`。 + +根对象增加: + +| Wire | Type | Status | +|---|---|---| +| `task` | object | Optional | +| `started_at` | string \| number | Optional;与 turn `ts` 相同的时间表示 | +| `finished_at` | string \| number | Optional;与 turn `ts` 相同的时间表示 | + +`task` 保持全名(与 `agent` 相同)。内部: + +```text +/task +├── env 文档级环境:身份与稳定配置 +│ ├── name string? +│ ├── endpoint string? +│ ├── id string? +│ ├── event_type string? +│ ├── request_id string? +│ └── state object? 其余稳定基础设施键 +├── llm 文档级 LLM 推理参数 +│ └── k integer? ACTF `/k`;不是采样温度 +└── result 评测结果 + 文档级评测预算 + ├── task_correct bool? 源文档根级 correct(可与 attempt 不同) + ├── correct bool? + ├── final_answer any? + ├── ground_truth any? + ├── status string? + ├── score any? + ├── error string? + ├── artifacts any? + ├── category string? + ├── attempts_tried integer? + ├── solved_at string? + ├── retry_count any? + └── retry_counts any? +``` + +`/k` 的权威目标是 `/task/llm/k`,不是 `/task/result`。ACTF 源里它是 attempt 预算, +Storyline 按调用方约定把它放在 LLM 推理参数槽;导出 ACTF 仍写回根 `/k`。 + +`turns[]` 增加: + +| Wire | Type | Status | +|---|---|---| +| `env` | object | Optional;形状与 `/task/env` 相同 | +| `finished_at` | string \| number | Optional;该 turn 结束时间,表示规则与 `ts` 相同 | + +开始时间仍是 `/turns/{t}/ts`,不新增 turn 级 `started_at`。`copied == true` 的 +context turn 不得写 `env`。OpenAI 一行拆成 request + response 时,`env` 只挂在 +response turn。`finished_at` 同样只写在产生该 step 语义的 turn 上(ACTF:该 +step 对应的唯一 turn;OpenAI:response turn)。 + +`tool_calls[]` 增加: + +| Wire | Type | Status | +|---|---|---| +| `kind` | string | Optional;工具事件类型,与 turn 的叙事 `kind` 不是同一个字段 | +| `response` | object | Optional | + +```text +/tool_calls/{c}/response +├── status string? +└── exit_code integer? +``` + +现有 `result` 仍是输出体(ACTF `aggregated_output`)。没有 `status` 且没有 +`exit_code` 时不写 `response`。 + +校验增补: + +- `/task` 若存在,则 `env`、`llm`、`result` 至少有一个含非 null 字段。 +- `/started_at`、`/finished_at`、`turns[].finished_at` 的合法值与 `ts` 相同: + RFC3339 字符串,或可精确表示为纳秒的 Unix epoch 秒数。 +- 若文档同时有 `/started_at` 与 `/finished_at`,不得要求 `finished_at >= started_at` + 以外的推导(源格式可以记录与 turn 窗口不一致的轨迹时间)。 +- `task.llm.k` 若出现,必须是正整数。 +- `tool_calls[].kind` 非空字符串;空字符串视为缺失。 +- `exit_code` 必须是整数(含负值);JSON number 带小数则导入失败。 +- 还原某 turn 的完整环境:以 `/task/env` 为底,对象浅合并该 turn 的 `env` + (turn 侧同名键覆盖;`state` 也是浅合并)。不合并更早的 turns。 + 查询层按此规则计算;存储层不物化合并结果。 + +## 环境键分流 + +文档级 `/task/env` 只保存 session 内作为默认配置的键。步级 `/turns/{t}/env` +只保存本步才有、或相对文档环境发生变化的键。 + +首个为该键提供非空值的 OpenAI row 写入 `/task/env`(见下表「稳定键」)。后续 +row:值相等则视为冗余别名并消费;值不等则写入该 row 的 response turn +`/env`(环境变化)。不得因后续行不一致而失败。 + +只存在于步上的键从不写入 `/task/env`。 + +已进入 `/metrics` 的 `env_state` 键不进入 `env`。OpenAI `env_state.created_at` / +`completed_at` 仍只在 `/metrics`,不提升为文档 `/started_at` / `/finished_at`。 + +## ACTF 映射变更(RFC-0004) + +权威目标搬家或从残差提升: + +| ACTF JSON Pointer | 新权威目标 | +|---|---| +| `/correct` | `/task/result/task_correct` | +| `/k` | `/task/llm/k` | +| `/category` | `/task/result/category` | +| `/attempts_tried` | `/task/result/attempts_tried` | +| `/solved_at` | `/task/result/solved_at` | +| `/retry_count` | `/task/result/retry_count` | +| `/retry_counts` | `/task/result/retry_counts` | +| `/attempts/{a}/correct` | `/task/result/correct` | +| `/attempts/{a}/status` | `/task/result/status` | +| `/attempts/{a}/score` | `/task/result/score` | +| `/attempts/{a}/final_answer` | `/task/result/final_answer` | +| `/attempts/{a}/ground_truth` | `/task/result/ground_truth` | +| `/attempts/{a}/error` | `/task/result/error` | +| `/attempts/{a}/artifacts` | `/task/result/artifacts` | +| `/attempts/{a}/trajectory/started_at` | `/started_at` | +| `/attempts/{a}/trajectory/finished_at` | `/finished_at` | +| `/attempts/{a}/trajectory/steps/{s}/finished_at` | `/turns/{t}/finished_at` | +| `/attempts/{a}/trajectory/steps/{s}/tools/{c}/type` | `/turns/{t}/tool_calls/{c}/kind` | +| `/attempts/{a}/trajectory/steps/{s}/tools/{c}/status` | `/turns/{t}/tool_calls/{c}/response/status` | +| `/attempts/{a}/trajectory/steps/{s}/tools/{c}/exit_code` | `/turns/{t}/tool_calls/{c}/response/exit_code` | + +`steps/{s}/started_at` 仍只映射到 `/turns/{t}/ts`,即使它与文档 `/started_at` +数值相同,也不把文档时间当成 step 时间的第二权威目标。 + +`assistant_content.tool_calls` 必须与 `tools` 相等;其上的 `type` / `status` / +`exit_code` 视为已消费,不另写目标,不进残差。 + +`name` 为空或缺失时 `/fn` 仍可由同 call 的 `kind` 派生。`type` 的唯一权威目标是 +`/kind`,`/fn` 不是它的第二权威目标。 + +便利提升:将 `/task/result` 的 `task_correct` / `correct` / `status` / `score` +再写入 `/final_metrics` 同名键。`analysis_result` 仍只在 `/final_metrics`。 + +ACTF 没有 OpenAI `env_state` 时,`/task/env` 与 `/turns/{t}/env` 缺省。 + +attempt `extra` / `meta` 现进入文档 `/extra` / `/meta`,不再作为残差。 + +`system_prompt` / `user_content` 不再由本规格吸收;见 +[2026-08-22-storyline-prompt-design.md](2026-08-22-storyline-prompt-design.md)。 + +空字符串 `error`、空 `artifacts`、空 `solved_at` 按缺失省略。 + +## OpenAI Messages 映射变更(RFC-0009) + +稳定键(首条非空值 → `/task/env`;后续相等则消费): + +| OpenAI JSON Pointer | Storyline JSON Pointer | +|---|---| +| `/session_steps/{r}/env_name` | `/task/env/name` | +| `/session_steps/{r}/meta_json/env_state/endpoint` | `/task/env/endpoint` | +| `/session_steps/{r}/dataset_type` | `/task/env/state/dataset_type` | +| `/session_steps/{r}/dt` | `/task/env/state/dt` | +| `/session_steps/{r}/meta_json/group_id` | `/task/env/state/group_id` | +| `/session_steps/{r}/meta_json/env_state/redaction_policy` | `/task/env/state/redaction_policy` | +| `/session_steps/{r}/meta_json/env_state/upstream_base_url` | `/task/env/state/upstream_base_url` | +| `/session_steps/{r}/meta_json/env_state/weight_version` | `/task/env/state/weight_version` | + +步级键(只进 response turn): + +| OpenAI JSON Pointer | Storyline JSON Pointer | +|---|---| +| `/session_steps/{r}/id` | `/turns/{response-t}/env/id` | +| `/session_steps/{r}/meta_json/env_state/event_type` | `/turns/{response-t}/env/event_type` | +| `/session_steps/{r}/meta_json/env_state/request_id` | `/turns/{response-t}/env/request_id` | + +OpenAI 没有 ACTF 评测结果、`k`、文档时间窗或工具 `response` 时,对应 Storyline +字段缺省。row `created_at` 仍只权威写入 response `/ts`,不写文档 `/started_at`。 + +## Lance 投影 + +不新增表。JSON 列保存完整对象,不把 `env_state` 展平为独立列。 + +| 表 | 新列 | 内容 | +|---|---|---| +| `runs` | `task` | `/task` 对象,或缺省 | +| `runs` | `started_at` | 文档开始时间,或缺省 | +| `runs` | `finished_at` | 文档结束时间,或缺省 | +| `steps` | `env` | 该 turn 的 `/env`,或缺省 | +| `steps` | `finished_at` | 该 turn 结束时间,或缺省 | +| `tool_calls` | `kind` | 字符串,或缺省 | +| `tool_calls` | `response` | `{status, exit_code}` 对象,或缺省 | + +查询完整环境时由查询层合并 `runs.task.env` 与 `steps.env`;存储层不物化合并结果。 + +## 跨格式 + +- ACTF ↔ Storyline、OpenAI Messages ↔ Storyline:按上表还原。 +- Storyline 一等字段不是 `unknown_fields`。ATIF 本设计不新增一等键;Storyline → + ATIF 时,ATIF 无法表达的 `/task`、`/started_at`、`/finished_at`、`turns[].env`、 + `turns[].finished_at`、`tool_calls[].kind` / `response` 走既有 `_storyline` + envelope,不得塞进 ATIF `extra`(`extra` 仍 1:1 对应 Storyline `extra`)。 +- AgenticMD / Events 同理:不扩张其 schema;经 Storyline 的 roundtrip 必须能恢复 + 这些新字段。 + +## 实现落点(文档,非本阶段改代码) + +- `crates/persisting-pchronicle/src/formats/storyline.rs`:wire 结构 +- `crates/persisting-pchronicle/src/store/storyline/model.rs`:三表行 +- `crates/persisting-pchronicle/src/convert/actf.rs` 与 RFC-0004 +- `crates/persisting-pchronicle/src/formats/openai_corpus.rs` 与 RFC-0009 +- `docs/src/rfcs/0001-storyline-format.md` 增补 wire 表 + +## 验收 + +1. 用产生原 ACTF warning 的语料导入 `storyline-lance` 后,下列 key 不再出现在 + unknown-field warning:`artifacts`、`error`、`final_answer`、`ground_truth`、 + `category`、`k`、`attempts_tried`、`solved_at`、`retry_count`、`retry_counts`、 + `trajectory/started_at`、`trajectory/finished_at`、`steps/*/finished_at`、 + `tools/*/type`、`tools/*/status`、`tools/*/exit_code`,以及 + `assistant_content/tool_calls` 上的同名三键。 +2. 用产生原 OpenAI warning 的 `session_steps.json` 导入后,下列 key 不再 warning: + `dataset_type`、`dt`、`env_name`、`id`、`meta_json/group_id`、 + `env_state/endpoint`、`event_type`、`redaction_policy`、`request_id`、 + `upstream_base_url`、`weight_version`。 +3. ACTF → Storyline → ACTF 还原上表权威字段;`type` 从 `/kind` 还原,`k` 从 + `/task/llm/k` 还原,不再依赖 `unknown_fields`。 +4. OpenAI → Storyline → OpenAI 还原上表环境键;session 内变化的稳定键出现在对应 + step 的源字段上,而不是被首条覆盖。 +5. 既有 ATIF fixture 与 `final_metrics` 提升行为保持:有 `/task/result` 时 + `/final_metrics` 含对应键;无 `/task` 的旧文档仍合法。 +6. 仍为残差(允许继续 warning):attempt `extra`、`meta`。 + `system_prompt` / `user_content` 改由 `/prompt` 规格验收,不再列为本规格残差。 diff --git a/pchronicle-web/Cargo.toml b/pchronicle-web/Cargo.toml index cb1507a6..492a567e 100644 --- a/pchronicle-web/Cargo.toml +++ b/pchronicle-web/Cargo.toml @@ -15,7 +15,7 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" urlencoding = "2" wasm-bindgen = "0.2" -web-sys = { version = "0.3", features = ["History", "KeyboardEvent", "Location", "Storage", "UrlSearchParams", "Window"] } +web-sys = { version = "0.3", features = ["Document", "DomRect", "Element", "History", "HtmlElement", "KeyboardEvent", "Location", "Storage", "UrlSearchParams", "Window"] } [profile.dev] debug = 0 diff --git a/pchronicle-web/assets/components.css b/pchronicle-web/assets/components.css index edfbb008..2c22a28a 100644 --- a/pchronicle-web/assets/components.css +++ b/pchronicle-web/assets/components.css @@ -443,3 +443,138 @@ padding: 16px; } } + +.pc2-json-scroll { + max-height: 360px; + overflow: auto; +} + +.pc2-json-table { + width: 100%; + border-collapse: collapse; + font-size: 11px; +} + +.pc2-json-table th, +.pc2-json-table td { + padding: 5px 8px; + border: 1px solid #eef0f3; + text-align: left; + vertical-align: top; +} + +.pc2-json-table thead th, +.pc2-json-kv th[scope="row"] { + background: #f8fafc; + color: #667085; + font-size: 9px; + font-weight: 700; +} + +.pc2-json-scalar { + white-space: pre-wrap; + word-break: break-word; + color: #344054; +} + +.pc2-json-tree { + display: flex; + flex-direction: column; + gap: 2px; +} + +.pc2-json-node { + border-left: 1px solid #e4e7ec; + padding-left: 8px; +} + +.pc2-json-node > summary { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + list-style: none; +} + +.pc2-json-node > summary::-webkit-details-marker { + display: none; +} + +.pc2-json-key { + color: #101828; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 11px; +} + +.pc2-json-size { + color: #667085; + font-size: 9px; +} + +.pc2-json-node > .pc2-json-table, +.pc2-json-node > .pc2-json-tree, +.pc2-json-node > .pc2-json-scroll, +.pc2-json-node > .pc2-json-scalar { + margin: 6px 0 8px; +} + +.pc2-workspace-notice { + flex: 0 0 auto; + display: flex; + align-items: flex-start; + gap: 12px; + padding: 8px 16px; + border-bottom: 1px solid #fecaca; + background: #fff7f7; + color: #991b1b; + font-size: 11px; +} + +.pc2-workspace-notice-copy { + min-width: 0; + display: flex; + flex: 1; + flex-direction: column; + gap: 2px; +} + +.pc2-workspace-notice-copy strong { + font-size: 12px; +} + +.pc2-workspace-notice-copy > span { + color: #7f1d1d; +} + +.pc2-workspace-notice-details { + margin-top: 4px; +} + +.pc2-workspace-notice-details summary { + cursor: pointer; + color: #9f1239; + font-size: 10px; +} + +.pc2-workspace-notice-details pre { + max-height: 140px; + margin: 6px 0 0; + padding: 8px; + overflow: auto; + border: 1px solid #fecaca; + border-radius: 6px; + background: #fff; + color: #7f1d1d; + font: 10px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; + white-space: pre-wrap; + word-break: break-word; +} + +.pc2-workspace-notice button { + margin-left: auto; + border: 0; + background: transparent; + color: inherit; + font-size: 18px; + cursor: pointer; +} diff --git a/pchronicle-web/assets/inline-trace.css b/pchronicle-web/assets/inline-trace.css index 56ee0067..a1a976a5 100644 --- a/pchronicle-web/assets/inline-trace.css +++ b/pchronicle-web/assets/inline-trace.css @@ -1,3 +1,137 @@ +.pc2-view-toggle { + display: flex; + overflow: hidden; + border: 1px solid #d0d5dd; + border-radius: 6px; + background: #fff; +} + +.pc2-view-toggle button { + height: 30px; + padding: 0 9px; + border: 0; + background: transparent; + color: #667085; + font-size: 10px; + font-weight: 700; + cursor: pointer; +} + +.pc2-view-toggle button + button { + border-left: 1px solid #e4e7ec; +} + +.pc2-view-toggle button.active { + background: #eff6ff; + color: #1d4ed8; +} + +.pc2-view-summary { + display: flex; + align-items: baseline; + gap: 8px; + margin-bottom: 10px; + color: #98a2b3; + font-size: 9px; +} + +.pc2-view-summary strong { + color: #344054; + font-size: 11px; +} + +.pc2-chat-list, +.pc2-step-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.pc2-chat-card { + overflow: hidden; + border: 1px solid #e4e7ec; + border-radius: 9px; + background: #fff; +} + +.pc2-chat-card.selected { + border-color: #93c5fd; + box-shadow: 0 0 0 2px #2563eb12; +} + +.pc2-chat-card-summary { + display: grid; + grid-template-columns: auto auto minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + min-height: 40px; + padding: 8px 10px; + list-style: none; + cursor: pointer; +} + +.pc2-chat-card-summary::-webkit-details-marker { + display: none; +} + +.pc2-chat-card-summary:hover { + background: #f8fafc; +} + +.pc2-chat-kind { + padding: 2px 6px; + border-radius: 4px; + background: #f2f4f7; + color: #475467; + font-size: 8px; + font-weight: 800; + text-transform: uppercase; +} + +.pc2-chat-kind.chat { + background: #eff6ff; + color: #1d4ed8; +} + +.pc2-chat-kind.orphan { + background: #ecfdf5; + color: #047857; +} + +.pc2-chat-kind.system { + background: #fffbeb; + color: #b45309; +} + +.pc2-chat-card-summary strong { + color: #1d2939; + font-size: 11px; + white-space: nowrap; +} + +.pc2-chat-preview { + min-width: 0; + overflow: hidden; + color: #667085; + font-size: 10px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.pc2-chat-card-stats { + display: flex; + gap: 6px; + color: #98a2b3; + font-size: 9px; + white-space: nowrap; +} + +.pc2-chat-card-body { + padding: 8px 10px 10px; + border-top: 1px solid #eceef1; + background: #fbfcfd; +} + .pc2-inline-trace { min-height: 0; flex: 1; @@ -51,6 +185,12 @@ max-height: 420px; } +.pc2-inline-detail .pc2-evidence-block .pc2-json-scroll, +.pc2-inline-detail .pc2-evidence-block .pc2-json-tree { + max-height: 420px; + overflow: auto; +} + @media (max-width: 1050px) { .pc2-inline-detail .pc2-inspector-facts { grid-template-columns: repeat(2, minmax(0, 1fr)); diff --git a/pchronicle-web/assets/span-timeline.css b/pchronicle-web/assets/span-timeline.css index f7aecad6..4661d7c4 100644 --- a/pchronicle-web/assets/span-timeline.css +++ b/pchronicle-web/assets/span-timeline.css @@ -48,19 +48,23 @@ align-items: stretch; } -.span-table-head { +.span-sticky-chrome { position: sticky; top: 0; - z-index: 5; - border-bottom: 1px solid #dfe3e8; + z-index: 6; background: #f8fafcee; - box-shadow: 0 1px 2px #1018280a; + box-shadow: 0 1px 2px #10182814; + backdrop-filter: blur(8px); +} + +.span-table-head { + border-bottom: 1px solid #dfe3e8; + background: transparent; color: #667085; font-size: 9px; font-weight: 700; text-transform: uppercase; letter-spacing: .055em; - backdrop-filter: blur(8px); } .span-table-head > div { @@ -105,12 +109,8 @@ .trace-root-summary { border-bottom: 1px solid #dfe3e8; - background: #f8fafc; - cursor: pointer; -} - -.trace-root-summary:hover { - background: #f2f4f7; + background: transparent; + cursor: default; } .span-children { @@ -147,6 +147,51 @@ gap: 2px; } +.span-structure-title { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} + +.span-structure-title .phase-badge { + margin-left: 0; +} + +.span-structure-chips { + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +.modality-chip { + padding: 1px 5px; + border-radius: 3px; + background: #f2f4f7; + color: #475467; + font: 8px ui-monospace, SFMono-Regular, Menlo, monospace; +} + +.modality-chip.text { + background: #eff6ff; + color: #1d4ed8; +} + +.modality-chip.image { + background: #f5f3ff; + color: #6d28d9; +} + +.modality-chip.audio { + background: #fff7ed; + color: #c2410c; +} + +.modality-chip.tool_call { + background: #ecfdf5; + color: #047857; +} + .span-structure strong { overflow: hidden; color: #27364a; @@ -169,7 +214,9 @@ padding: 6px 10px; } -.span-row-copy > strong { +.span-row-copy > strong, +.overview-line { + display: block; overflow: hidden; color: #475467; font-size: 10px; @@ -228,37 +275,70 @@ height: 7px; flex: 0 0 7px; border-radius: 50%; - background: #3b82f6; + background: #047857; + box-shadow: 0 0 0 3px #d1fae5; +} + +.span-status.user { + background: #1d4ed8; box-shadow: 0 0 0 3px #dbeafe; } -.span-status.tool { - background: #10b981; +.span-status.agent { + background: #047857; box-shadow: 0 0 0 3px #d1fae5; } +.span-status.system { + background: #b45309; + box-shadow: 0 0 0 3px #fde68a; +} + .phase-badge { margin-left: auto; padding: 2px 5px; border-radius: 4px; - background: #eff6ff; - color: #1d4ed8; + background: #ecfdf5; + color: #047857; font-size: 8px; font-weight: 700; text-transform: uppercase; } -.phase-badge.tool { +.phase-badge.user { + background: #eff6ff; + color: #1d4ed8; +} + +.phase-badge.agent { background: #ecfdf5; color: #047857; } +.phase-badge.system { + background: #fffbeb; + color: #b45309; +} + +.phase-badge.chat { + background: #eef2ff; + color: #4338ca; +} + +.span-seq-cell { + min-width: 0; + display: flex; + flex-direction: column; + justify-content: center; + gap: 2px; + padding: 5px 0 4px; +} + .span-track { position: relative; - align-self: center; height: 18px; margin: 0 10px; - overflow: hidden; + overflow: visible; border: 1px solid #e4e7ec; border-radius: 3px; background: #f8fafc; @@ -272,21 +352,85 @@ .span-bar { position: absolute; - inset-block: 4px; - min-width: 3px; - border-radius: 2px; - background: #3b82f6; - box-shadow: 0 0 0 2px #bfdbfe99; + inset-block: 3px; + min-width: 0; + border-radius: 0; + background: #047857; +} + +.span-bar.user { + background: #2563eb; +} + +.span-bar.agent { + background: #059669; +} + +.span-bar.system { + background: #d97706; +} + +.span-table.has-emphasis .span-bar { + opacity: 0.3; +} + +.span-bar.hovered { + opacity: 0.72; + box-shadow: 0 0 0 1px #7c3aed66; +} + +.span-bar.exposed { + opacity: 1; +} + +.span-bar.focused { + opacity: 1; + z-index: 2; + box-shadow: 0 0 0 2px #5b21b6; +} + +.span-expose-band { + position: absolute; + inset-block: 0; + z-index: 1; + background: #5b21b61f; + box-shadow: inset 0 0 0 1px #5b21b64d; + pointer-events: none; +} + +.span-focus-line { + position: absolute; + top: 0; + bottom: 0; + z-index: 3; + width: 2px; + margin-left: -1px; + background: #5b21b6; + pointer-events: none; } -.span-bar.tool { - background: #10b981; - box-shadow: 0 0 0 2px #a7f3d099; +.span-focus-dot { + position: absolute; + top: -3px; + z-index: 4; + width: 7px; + height: 7px; + margin-left: -3.5px; + border: 1.5px solid #fff; + border-radius: 50%; + background: #5b21b6; + box-shadow: 0 0 0 1px #5b21b6; + pointer-events: none; } -.root-bar { - background: #64748b; - box-shadow: 0 0 0 2px #cbd5e1; +.span-seq-caption { + padding: 0 10px; + color: #667085; + font: 8px ui-monospace, SFMono-Regular, Menlo, monospace; +} + +.span-row-copy { + overflow: hidden; } .span-evidence-count { diff --git a/pchronicle-web/index.html b/pchronicle-web/index.html index f10c4c26..ef4cd38b 100644 --- a/pchronicle-web/index.html +++ b/pchronicle-web/index.html @@ -6,13 +6,13 @@ - + - - + +
diff --git a/pchronicle-web/src/chat_view.rs b/pchronicle-web/src/chat_view.rs new file mode 100644 index 00000000..41fae7f6 --- /dev/null +++ b/pchronicle-web/src/chat_view.rs @@ -0,0 +1,214 @@ +//! Frontend-only grouping of Storyline turns into Chats vs Steps. + +use crate::model::TurnSummary; + +#[derive(Clone, Debug, PartialEq)] +pub enum TraceCard { + Chat { + user: Option, + replies: Vec, + }, + System { + turn: TurnSummary, + }, +} + +impl TraceCard { + pub fn contains_turn(&self, id: i64) -> bool { + match self { + Self::Chat { user, replies } => { + user.as_ref().is_some_and(|turn| turn.id == id) + || replies.iter().any(|turn| turn.id == id) + } + Self::System { turn } => turn.id == id, + } + } +} + +pub fn normalize_trace_view(value: &str) -> &'static str { + if value == "steps" { + "steps" + } else { + "chats" + } +} + +pub fn group_chats(turns: &[TurnSummary]) -> Vec { + let mut cards = Vec::new(); + let mut index = 0; + while index < turns.len() { + match turns[index].source.as_str() { + "system" => { + cards.push(TraceCard::System { + turn: turns[index].clone(), + }); + index += 1; + } + "user" => { + let user = turns[index].clone(); + index += 1; + let mut replies = Vec::new(); + while index < turns.len() && turns[index].source == "agent" { + replies.push(turns[index].clone()); + index += 1; + } + cards.push(TraceCard::Chat { + user: Some(user), + replies, + }); + } + _ => { + cards.push(TraceCard::Chat { + user: None, + replies: vec![turns[index].clone()], + }); + index += 1; + } + } + } + cards +} + +pub fn source_class(source: &str) -> &'static str { + match source { + "user" => "user", + "system" => "system", + _ => "agent", + } +} + +pub fn turn_matches_query(turn: &TurnSummary, query: &str) -> bool { + let query = query.trim(); + if query.is_empty() { + return true; + } + let needle = query.to_ascii_lowercase(); + turn.preview.to_ascii_lowercase().contains(&needle) + || turn.source.to_ascii_lowercase().contains(&needle) + || turn.id.to_string() == needle + || turn + .tool_names + .iter() + .any(|name| name.to_ascii_lowercase().contains(&needle)) + || turn + .kind + .as_deref() + .is_some_and(|kind| kind.to_ascii_lowercase().contains(&needle)) +} + +pub fn chat_row_visible(entries: &[TurnSummary], source: &str, query: &str) -> bool { + let source_ok = + source == "all" || source.is_empty() || entries.iter().any(|turn| turn.source == source); + let query_ok = + query.trim().is_empty() || entries.iter().any(|turn| turn_matches_query(turn, query)); + source_ok && query_ok +} + +pub fn step_row_visible(turn: &TurnSummary, source: &str, query: &str) -> bool { + (source == "all" || source.is_empty() || turn.source == source) + && turn_matches_query(turn, query) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn turn(id: i64, source: &str) -> TurnSummary { + TurnSummary { + id, + source: source.into(), + kind: None, + timestamp: None, + call_id: None, + preview: format!("{source}-{id}"), + char_count: 0, + modalities: Vec::new(), + model_name: None, + latency_ms: None, + ttft_ms: None, + prompt_tokens: None, + completion_tokens: None, + total_tokens: None, + tool_names: Vec::new(), + event_seqs: Vec::new(), + has_error: false, + } + } + + fn chat_ids(card: &TraceCard) -> (Option, Vec) { + match card { + TraceCard::Chat { user, replies } => ( + user.as_ref().map(|turn| turn.id), + replies.iter().map(|turn| turn.id).collect(), + ), + TraceCard::System { turn } => panic!("expected chat, got system {}", turn.id), + } + } + + #[test] + fn unknown_and_legacy_tree_views_become_chats() { + assert_eq!(normalize_trace_view("chats"), "chats"); + assert_eq!(normalize_trace_view("steps"), "steps"); + assert_eq!(normalize_trace_view("tree"), "chats"); + assert_eq!(normalize_trace_view(""), "chats"); + } + + #[test] + fn user_opens_a_chat_and_consumes_following_agents() { + let cards = group_chats(&[ + turn(1, "user"), + turn(2, "agent"), + turn(3, "agent"), + turn(4, "user"), + turn(5, "agent"), + ]); + assert_eq!(cards.len(), 2); + assert_eq!(chat_ids(&cards[0]), (Some(1), vec![2, 3])); + assert_eq!(chat_ids(&cards[1]), (Some(4), vec![5])); + assert!(cards[0].contains_turn(3)); + assert!(!cards[0].contains_turn(4)); + } + + #[test] + fn leading_agents_and_mid_system_stay_separate() { + let cards = group_chats(&[ + turn(1, "agent"), + turn(2, "agent"), + turn(3, "user"), + turn(4, "agent"), + turn(5, "system"), + turn(6, "agent"), + ]); + assert_eq!(cards.len(), 5); + assert_eq!(chat_ids(&cards[0]), (None, vec![1])); + assert_eq!(chat_ids(&cards[1]), (None, vec![2])); + assert_eq!(chat_ids(&cards[2]), (Some(3), vec![4])); + match &cards[3] { + TraceCard::System { turn } => assert_eq!(turn.id, 5), + other => panic!("expected system, got {other:?}"), + } + assert_eq!(chat_ids(&cards[4]), (None, vec![6])); + } + + #[test] + fn consecutive_users_each_open_a_chat() { + let cards = group_chats(&[turn(1, "user"), turn(2, "user"), turn(3, "agent")]); + assert_eq!(cards.len(), 2); + assert_eq!(chat_ids(&cards[0]), (Some(1), Vec::::new())); + assert_eq!(chat_ids(&cards[1]), (Some(2), vec![3])); + } + + #[test] + fn source_and_query_filters_keep_the_whole_chat() { + let user = turn(1, "user"); + let mut agent = turn(2, "agent"); + agent.preview = "look up GOOGL".into(); + let entries = [user, agent]; + assert!(chat_row_visible(&entries, "agent", "")); + assert!(chat_row_visible(&entries, "user", "googl")); + assert!(!chat_row_visible(&entries, "system", "")); + assert!(!chat_row_visible(&entries, "all", "missing")); + assert!(step_row_visible(&entries[1], "agent", "googl")); + assert!(!step_row_visible(&entries[0], "agent", "")); + } +} diff --git a/pchronicle-web/src/components.rs b/pchronicle-web/src/components.rs index 159a8dc2..42c2ee61 100644 --- a/pchronicle-web/src/components.rs +++ b/pchronicle-web/src/components.rs @@ -1,7 +1,11 @@ +use std::collections::{HashMap, HashSet}; + use dioxus::prelude::*; use serde::{Deserialize, Serialize}; use serde_json::Value; +use crate::chat_view::{chat_row_visible, group_chats, source_class, step_row_visible, TraceCard}; +use crate::json_value::{is_structured_json, JsonValue}; use crate::model::{QueryEvidence, TurnDetail, TurnSummary}; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -230,62 +234,387 @@ fn CellValue(value: Value, limit: usize) -> Element { struct CompactSpanGroup { key: String, label: String, + overview: String, call_id: Option, entries: Vec, first_seq: u64, last_seq: u64, tool_calls: usize, + kind_chip: &'static str, +} + +#[derive(Clone, Debug, PartialEq)] +struct SeqBar { + turn_id: i64, + source: &'static str, + left: f64, + width: f64, +} + +fn span_from_entries( + key: String, + label: String, + overview: String, + entries: Vec, + fallback_index: usize, + kind_chip: &'static str, +) -> CompactSpanGroup { + let tool_calls = entries.iter().map(|turn| turn.tool_names.len()).sum(); + let call_id = entries + .iter() + .find_map(|turn| turn.call_id.clone().filter(|value| !value.is_empty())); + let seqs = entries + .iter() + .flat_map(|turn| turn.event_seqs.iter().copied()) + .collect::>(); + let (first_seq, last_seq) = if seqs.is_empty() { + let first = fallback_index as u64; + ( + first, + first.saturating_add(entries.len().saturating_sub(1) as u64), + ) + } else { + ( + seqs.iter().copied().min().unwrap_or(0), + seqs.iter().copied().max().unwrap_or(0), + ) + }; + CompactSpanGroup { + key, + label, + overview, + call_id, + entries, + first_seq, + last_seq, + tool_calls, + kind_chip, + } +} + +const MODALITY_ORDER: &[&str] = &["text", "image", "audio", "tool_call"]; + +fn union_modalities(entries: &[TurnSummary]) -> Vec { + MODALITY_ORDER + .iter() + .filter(|name| { + entries + .iter() + .any(|turn| turn.modalities.iter().any(|item| item == *name)) + }) + .map(|name| (*name).to_string()) + .collect() } -fn compact_span_groups(turns: &[TurnSummary]) -> Vec { - let mut groups = Vec::::new(); - for (index, turn) in turns.iter().cloned().enumerate() { - let key = turn - .call_id - .clone() - .filter(|value| !value.is_empty()) - .unwrap_or_else(|| format!("turn-{}", turn.id)); - let first_seq = turn - .event_seqs +fn composition_label(entries: &[TurnSummary]) -> String { + let users = entries.iter().filter(|turn| turn.source == "user").count(); + let agents = entries.iter().filter(|turn| turn.source == "agent").count(); + let systems = entries + .iter() + .filter(|turn| turn.source == "system") + .count(); + let tools = entries + .iter() + .map(|turn| turn.tool_names.len()) + .sum::(); + let mut parts = Vec::new(); + if users > 0 { + parts.push(format!("{users} user")); + } + if agents > 0 { + parts.push(format!("{agents} agent")); + } + if systems > 0 { + parts.push(format!("{systems} system")); + } + let mut label = parts.join(" + "); + if tools > 0 { + label = format!("{label} · {tools} tool"); + } + label +} + +fn row_char_count(entries: &[TurnSummary], user_only: bool) -> u64 { + if user_only { + entries .iter() - .copied() - .min() - .unwrap_or(index as u64); - let last_seq = turn.event_seqs.iter().copied().max().unwrap_or(first_seq); - let tool_calls = turn.tool_names.len(); - let step_number = (turn.id.max(1) + 1) / 2; - let action_name = turn.tool_names.first().cloned(); - if let Some(group) = groups.last_mut().filter(|group| group.key == key) { - group.first_seq = group.first_seq.min(first_seq); - group.last_seq = group.last_seq.max(last_seq); - group.tool_calls += tool_calls; - if let Some(action_name) = action_name { - group.label = format!("Step {step_number} · {action_name}"); - } - group.entries.push(turn); - continue; + .find(|turn| turn.source == "user") + .map(|turn| turn.char_count) + .unwrap_or(0) + } else { + entries.iter().map(|turn| turn.char_count).sum() + } +} + +fn format_char_count(count: u64) -> String { + if count >= 1000 { + format!("{:.1}k chars", count as f64 / 1000.0) + } else { + format!("{count} chars") + } +} + +fn kind_label(kind: &str) -> &'static str { + match kind { + "chat" => "Chat", + "system" => "System", + "user" => "User", + _ => "Agent", + } +} + +fn structure_meta(group: &CompactSpanGroup) -> String { + let user_only = group.kind_chip == "chat"; + format!( + "{} · {}", + composition_label(&group.entries), + format_char_count(row_char_count(&group.entries, user_only)) + ) +} + +fn group_diagnostic(entries: &[TurnSummary]) -> String { + let turns = entries.len(); + let tokens = entries + .iter() + .filter_map(|turn| turn.total_tokens) + .sum::(); + let latency = entries + .iter() + .filter_map(|turn| turn.latency_ms) + .sum::(); + let mut parts = vec![ + if turns == 1 { + "1 turn".into() + } else { + format!("{turns} turns") + }, + composition_label(entries), + ]; + if tokens > 0 { + parts.push(format!("{tokens} tokens")); + } + if latency > 0.0 { + parts.push(format_ms(latency)); + } + parts.join(" · ") +} + +fn turn_expanded_facts(turn: &TurnSummary) -> String { + let mut parts = Vec::new(); + if let Some(model) = &turn.model_name { + parts.push(format!("Model {model}")); + } + if let Some(latency) = turn.latency_ms { + parts.push(format!("Latency {}", format_ms(latency))); + } + if let Some(ttft) = turn.ttft_ms { + parts.push(format!("TTFT {}", format_ms(ttft))); + } + if let Some(tokens) = turn.total_tokens { + parts.push(format!("{tokens} tokens")); + } + if let Some(call_id) = &turn.call_id { + if !call_id.is_empty() { + parts.push(format!("Call {call_id}")); } - let label = action_name.map_or_else( - || format!("Step {step_number}"), - |name| format!("Step {step_number} · {name}"), - ); - groups.push(CompactSpanGroup { - key, - label, - call_id: turn.call_id.clone(), - entries: vec![turn], - first_seq, - last_seq, - tool_calls, - }); } - groups + if let Some(timestamp) = &turn.timestamp { + parts.push(timestamp.clone()); + } + parts.join(" · ") +} + +fn sequence_caption(first_seq: u64, last_seq: u64) -> String { + format!("seq {first_seq}–{last_seq}") +} + +#[derive(Clone, Copy, Debug, PartialEq)] +struct OccupancyRange { + left: f64, + width: f64, +} + +fn occupancy_range(bars: &[SeqBar], ids: &[i64]) -> Option { + let mut min_left = f64::INFINITY; + let mut max_right = f64::NEG_INFINITY; + for bar in bars { + if ids.contains(&bar.turn_id) { + min_left = min_left.min(bar.left); + max_right = max_right.max(bar.left + bar.width); + } + } + if min_left.is_finite() && max_right > min_left { + Some(OccupancyRange { + left: min_left, + width: max_right - min_left, + }) + } else { + None + } +} + +fn focus_line_left(bar: &SeqBar) -> f64 { + bar.left + bar.width / 2.0 +} + +fn turn_collapsed_meta(turn: &TurnSummary) -> String { + let mut parts = Vec::new(); + if let Some(latency) = turn.latency_ms { + parts.push(format_ms(latency)); + } + if !turn.tool_names.is_empty() { + parts.push(format!("{} tool", turn.tool_names.len())); + } + parts.join(" · ") +} + +fn bar_emphasis( + turn_id: i64, + exposed: &[i64], + focused: Option, + hovered: &[i64], +) -> &'static str { + if focused == Some(turn_id) { + "focused" + } else if exposed.contains(&turn_id) { + "exposed" + } else if hovered.contains(&turn_id) { + "hovered" + } else if focused.is_some() || !exposed.is_empty() || !hovered.is_empty() { + "dimmed" + } else { + "" + } +} + +fn chat_overview(entries: &[TurnSummary]) -> String { + entries + .iter() + .find(|turn| turn.source == "user") + .map(|turn| compact_preview(&turn.preview, 180)) + .unwrap_or_else(|| "No user turn".into()) +} + +fn step_overview(turn: &TurnSummary) -> String { + compact_preview(&turn.preview, 180) +} + +fn session_index_map(turns: &[TurnSummary]) -> HashMap { + turns + .iter() + .enumerate() + .map(|(index, turn)| (turn.id, index)) + .collect() +} + +fn session_axis_len(turns: &[TurnSummary]) -> usize { + if turns.iter().any(|turn| !turn.event_seqs.is_empty()) { + turns + .iter() + .flat_map(|turn| turn.event_seqs.iter().copied()) + .max() + .map(|seq| seq as usize + 1) + .unwrap_or(turns.len()) + .max(1) + } else { + turns.len().max(1) + } +} + +fn turn_session_span(turn: &TurnSummary, session_index: usize) -> (usize, usize) { + match ( + turn.event_seqs.iter().copied().min(), + turn.event_seqs.iter().copied().max(), + ) { + (Some(first), Some(last)) => (first as usize, last as usize), + _ => (session_index, session_index), + } +} + +fn seq_bars( + entries: &[TurnSummary], + session_index: &HashMap, + axis_len: usize, +) -> Vec { + let axis_len = axis_len.max(1) as f64; + entries + .iter() + .filter_map(|turn| { + let index = *session_index.get(&turn.id)?; + let (first, last) = turn_session_span(turn, index); + Some(SeqBar { + turn_id: turn.id, + source: source_class(&turn.source), + left: first as f64 / axis_len * 100.0, + width: (last.saturating_sub(first) + 1) as f64 / axis_len * 100.0, + }) + }) + .collect() +} + +fn chat_span_groups(turns: &[TurnSummary]) -> Vec { + group_chats(turns) + .into_iter() + .enumerate() + .map(|(index, card)| { + let fallback = turns + .iter() + .position(|turn| card.contains_turn(turn.id)) + .unwrap_or(index); + match card { + TraceCard::Chat { user, replies } => { + let entries = user.into_iter().chain(replies).collect::>(); + let overview = chat_overview(&entries); + span_from_entries( + format!("chat-{index}"), + format!("Chat {}", index + 1), + overview, + entries, + fallback, + "chat", + ) + } + TraceCard::System { turn } => { + let overview = step_overview(&turn); + span_from_entries( + format!("system-{index}"), + "System".into(), + overview, + vec![turn], + fallback, + "system", + ) + } + } + }) + .collect() +} + +fn step_span_groups(turns: &[TurnSummary]) -> Vec { + turns + .iter() + .cloned() + .enumerate() + .map(|(index, turn)| { + let id = turn.id; + let overview = step_overview(&turn); + let kind_chip = source_class(&turn.source); + span_from_entries( + format!("turn-{id}"), + format!("#{id}"), + overview, + vec![turn], + index, + kind_chip, + ) + }) + .collect() } fn compact_preview(value: &str, limit: usize) -> String { let normalized = value.split_whitespace().collect::>().join(" "); if normalized.is_empty() { - "No response content".into() + "No text".into() } else if normalized.chars().count() <= limit { normalized } else { @@ -306,14 +635,37 @@ pub fn TrajectoryView( detail: Option, loading: bool, #[props(default = false)] embedded: bool, + #[props(default = "chats".to_string())] view: String, + #[props(default = "all".to_string())] source: String, + #[props(default)] query: String, on_turn: EventHandler, ) -> Element { - let groups = compact_span_groups(&turns); - let total_events = turns - .iter() - .flat_map(|turn| turn.event_seqs.iter().copied()) - .max() - .map_or(turns.len().max(1), |seq| seq as usize + 1); + let mut open_key = use_signal(|| None::); + let mut last_focus = use_signal(|| None::); + let mut viewport_ids = use_signal(HashSet::::new); + let mut hovered_ids = use_signal(Vec::::new); + let class = if embedded { + "pc2-trajectory-component embedded" + } else { + "pc2-trajectory-component" + }; + let groups = if view == "steps" { + let visible = turns + .iter() + .filter(|turn| step_row_visible(turn, &source, &query)) + .cloned() + .collect::>(); + step_span_groups(&visible) + } else { + chat_span_groups(&turns) + .into_iter() + .filter(|group| chat_row_visible(&group.entries, &source, &query)) + .collect::>() + }; + let noun = if view == "steps" { "steps" } else { "chats" }; + let session_index = session_index_map(&turns); + let axis_len = session_axis_len(&turns); + let root_bars = seq_bars(&turns, &session_index, axis_len); let total_refs = turns .iter() .map(|turn| turn.event_seqs.len()) @@ -322,19 +674,78 @@ pub fn TrajectoryView( .iter() .map(|turn| turn.tool_names.len()) .sum::(); - let class = if embedded { - "pc2-trajectory-component embedded" - } else { - "pc2-trajectory-component" - }; + let root_modalities = union_modalities(&turns); + let root_meta = format!( + "{} · {}", + composition_label(&turns), + format_char_count(row_char_count(&turns, true)) + ); + if last_focus() != expanded_turn_id { + last_focus.set(expanded_turn_id); + if let Some(id) = expanded_turn_id { + if let Some(group) = groups + .iter() + .find(|group| group.entries.iter().any(|turn| turn.id == id)) + { + open_key.set(Some(group.key.clone())); + } + } + } + let mut exposed_ids = viewport_ids().into_iter().collect::>(); + exposed_ids.sort_unstable(); + let hover_ids = hovered_ids(); + let table_class = + if expanded_turn_id.is_some() || !exposed_ids.is_empty() || !hover_ids.is_empty() { + "span-table has-emphasis" + } else { + "span-table" + }; + let expose_range = occupancy_range(&root_bars, &exposed_ids); + let focus_left = expanded_turn_id.and_then(|id| { + root_bars + .iter() + .find(|bar| bar.turn_id == id) + .map(focus_line_left) + }); + let root_caption = sequence_caption(0, axis_len.saturating_sub(1) as u64); rsx! { div { class, - div { class: "span-summary", span { strong { "{groups.len()} spans" } " · {total_refs} event references" } span { "Sequence window 0 — {total_events.saturating_sub(1)}" } } - div { class: "span-table", role: "tree", aria_label: "Trajectory span hierarchy", - div { class: "span-table-head", div { "Structure" } div { "Overview" } div { class: "span-axis-head", span { "Timeline / occupancy" } div { class: "span-axis-ticks", span { "0" } span { "25%" } span { "50%" } span { "75%" } span { "{total_events.saturating_sub(1)}" } } } div { "Evidence" } } - details { class: "trace-root", open: true, - summary { class: "trace-root-summary", div { class: "span-structure root", span { class: "disclosure" } strong { "trajectory" } span { "{groups.len()} spans" } } div { class: "span-row-copy root-copy", "{total_refs} canonical references across the loaded run" } div { class: "span-track", div { class: "span-bar root-bar", style: "left:0%;width:100%" } } div { class: "span-evidence-count", strong { "{total_refs} ev" } span { "{total_tools} tools" } } } - div { class: "span-children", for group in groups { CompactSpanRow { key: "{group.key}", group, total_events, expanded_turn_id, detail: detail.clone(), loading, embedded, on_turn } } } + div { class: "span-summary", span { strong { "{groups.len()} {noun}" } " · {total_refs} event references" } span { "Sequence window 0 — {axis_len.saturating_sub(1)}" } } + div { class: "{table_class}", role: "tree", aria_label: "Trajectory span hierarchy", + div { class: "span-sticky-chrome", + div { class: "span-table-head", div { "Structure" } div { "Overview" } div { class: "span-axis-head", span { "Sequence / occupancy" } div { class: "span-axis-ticks", span { "0" } span { "25%" } span { "50%" } span { "75%" } span { "{axis_len.saturating_sub(1)}" } } } div { "Evidence" } } + div { class: "trace-root-summary", div { class: "span-structure root", div { div { class: "span-structure-title", strong { "trajectory" } span { "{groups.len()} {noun}" } } div { class: "span-structure-chips", for modality in root_modalities { span { class: "modality-chip {modality}", "{modality}" } } } span { "{root_meta}" } } } div { class: "span-row-copy root-copy" } OccupancyTrack { bars: root_bars.clone(), expose_range, focus_left, caption: root_caption, title: "Session occupancy · {turns.len()} turns", exposed_ids: exposed_ids.clone(), expanded_turn_id, hovered_ids: hover_ids.clone() } div { class: "span-evidence-count", strong { "{total_refs} ev" } span { "{total_tools} tools" } } } } + div { class: "span-children", for group in groups { + CompactSpanRow { + key: "{group.key}", + session_index: session_index.clone(), + axis_len, + expanded_turn_id, + row_open: open_key() == Some(group.key.clone()), + expose_range, + focus_left, + exposed_ids: exposed_ids.clone(), + hovered_ids: hover_ids.clone(), + detail: detail.clone(), + loading, + embedded, + on_turn, + on_open: move |key| open_key.set(key), + on_hover: move |ids| hovered_ids.set(ids), + on_viewport: move |(ids, visible)| { + viewport_ids.with_mut(|set| { + if visible { + set.extend(ids); + } else { + for id in ids { + set.remove(&id); + } + } + }); + }, + group, + } + } } } } } } @@ -342,58 +753,115 @@ pub fn TrajectoryView( #[component] fn CompactSpanRow( group: CompactSpanGroup, - total_events: usize, + session_index: HashMap, + axis_len: usize, expanded_turn_id: Option, + row_open: bool, + expose_range: Option, + focus_left: Option, + exposed_ids: Vec, + hovered_ids: Vec, detail: Option, loading: bool, embedded: bool, on_turn: EventHandler, + on_open: EventHandler>, + on_hover: EventHandler>, + on_viewport: EventHandler<(Vec, bool)>, ) -> Element { let event_refs = group .entries .iter() .map(|turn| turn.event_seqs.len()) .sum::(); - let roles = group - .entries - .iter() - .map(|turn| turn.source.as_str()) - .collect::>() - .join(" → "); - let preview = group - .entries - .iter() - .rev() - .find(|turn| !turn.preview.trim().is_empty()) - .map_or_else( - || "No response content".into(), - |turn| compact_preview(&turn.preview, 120), - ); - let model = group - .entries - .iter() - .rev() - .find_map(|turn| turn.model_name.clone()); - let latency = group - .entries - .iter() - .filter_map(|turn| turn.latency_ms) - .sum::(); - let denominator = total_events.max(1) as f64; - let left = group.first_seq as f64 / denominator * 100.0; - let width = (group.last_seq.saturating_sub(group.first_seq) + 1) as f64 / denominator * 100.0; - let phase = if group.tool_calls > 0 { - "tool" - } else { - "model" - }; + let preview = group.overview.clone(); + let diagnostic = group_diagnostic(&group.entries); + let modalities = union_modalities(&group.entries); + let meta = structure_meta(&group); + let kind = group.kind_chip; + let kind_text = kind_label(kind); + let bars = seq_bars(&group.entries, &session_index, axis_len); + let caption = sequence_caption(group.first_seq, group.last_seq); let has_error = group.entries.iter().any(|turn| turn.has_error); - rsx! { details { class: "span-row", - summary { class: "span-row-summary", div { class: "span-structure", span { class: "disclosure" } span { class: "span-status {phase}" } div { strong { title: "{group.label}", "{group.label}" } span { "{roles} · seq {group.first_seq}–{group.last_seq}" } } if has_error { span { class: "pc2-error-chip", "error" } } else { span { class: "phase-badge {phase}", "{phase}" } } } div { class: "span-row-copy", strong { title: "{preview}", "{preview}" } div { class: "span-copy-meta", if let Some(model) = model { span { "{model}" } } if latency > 0.0 { span { "{format_ms(latency)}" } } if group.tool_calls > 0 { span { "{group.tool_calls} tool calls" } } } } div { class: "span-track", title: "seq {group.first_seq} — {group.last_seq}", div { class: "span-grid-lines" } div { class: "span-bar {phase}", style: "left:{left:.4}%;width:max({width:.4}%,3px)" } } div { class: "span-evidence-count", strong { "{event_refs} ev" } span { "{group.tool_calls} tools" } } } - div { class: "span-detail", div { class: "span-detail-meta", code { "seq {group.first_seq}..{group.last_seq}" } if let Some(call_id) = &group.call_id { code { "call {call_id}" } } } for turn in group.entries { CompactTurnRow { key: "turn-{turn.id}", turn: turn.clone(), expanded: expanded_turn_id == Some(turn.id), detail: detail.clone(), loading, embedded, on_turn } } } + let group_key = group.key.clone(); + let member_ids = group.entries.iter().map(|turn| turn.id).collect::>(); + let hover_ids = member_ids.clone(); + rsx! { details { + class: if row_open { "span-row is-open" } else { "span-row" }, + open: row_open, + onvisible: move |event| { + let intersecting = event.data().is_intersecting().unwrap_or(false); + let visible = intersecting && visible_in_span_scroll(&event.data()).unwrap_or(true); + on_viewport.call((member_ids.clone(), visible)); + }, + onmouseenter: move |_| on_hover.call(hover_ids.clone()), + onmouseleave: move |_| on_hover.call(Vec::new()), + summary { class: "span-row-summary", onclick: move |event| { event.prevent_default(); on_open.call(if row_open { None } else { Some(group_key.clone()) }); }, + div { class: "span-structure", span { class: "disclosure" } div { div { class: "span-structure-title", strong { title: "{group.label}", "{group.label}" } span { class: "phase-badge {kind}", "{kind_text}" } if has_error { span { class: "pc2-error-chip", "error" } } } div { class: "span-structure-chips", for modality in modalities { span { class: "modality-chip {modality}", "{modality}" } } } span { "{meta}" } } } + div { class: "span-row-copy", + if row_open { + strong { class: "overview-line", title: "{diagnostic}", "{diagnostic}" } + } else { + strong { class: "overview-line", title: "{preview}", "{preview}" } + } + } + OccupancyTrack { bars, expose_range, focus_left, caption: caption.clone(), title: "{caption} · {meta}", exposed_ids, expanded_turn_id, hovered_ids } + div { class: "span-evidence-count", strong { "{event_refs} ev" } span { "{group.tool_calls} tools" } } + } + if row_open { + div { class: "span-detail", for turn in group.entries { CompactTurnRow { key: "turn-{turn.id}", turn: turn.clone(), expanded: expanded_turn_id == Some(turn.id), detail: detail.clone(), loading, embedded, on_turn } } } + } } } } +#[component] +fn OccupancyTrack( + bars: Vec, + expose_range: Option, + focus_left: Option, + caption: String, + title: String, + exposed_ids: Vec, + expanded_turn_id: Option, + hovered_ids: Vec, +) -> Element { + rsx! { + div { class: "span-seq-cell", + div { class: "span-track", title, + div { class: "span-grid-lines" } + if let Some(range) = expose_range { + div { + class: "span-expose-band", + style: "left:{range.left:.4}%;width:{range.width:.4}%", + title: "Turns visible in the current list viewport", + } + } + for bar in bars { + div { class: "span-bar {bar.source} {bar_emphasis(bar.turn_id, &exposed_ids, expanded_turn_id, &hovered_ids)}", style: "left:{bar.left:.4}%;width:{bar.width:.4}%" } + } + if let Some(left) = focus_left { + div { class: "span-focus-line", style: "left:{left:.4}%", title: "Expanded turn" } + div { class: "span-focus-dot", style: "left:{left:.4}%" } + } + } + span { class: "span-seq-caption", "{caption}" } + } + } +} + +fn visible_in_span_scroll(data: &VisibleData) -> Option { + let row = data.get_bounding_client_rect().ok()?; + let window = web_sys::window()?; + let root = window + .document()? + .query_selector(".pc2-span-scroll") + .ok()??; + let bounds = root.get_bounding_client_rect(); + let top = row.origin.y; + let bottom = top + row.size.height; + Some(top < bounds.bottom() && bottom > bounds.top()) +} + #[component] fn CompactTurnRow( turn: TurnSummary, @@ -406,26 +874,52 @@ fn CompactTurnRow( let id = turn.id; let kind = turn.kind.clone().unwrap_or_else(|| "turn".into()); let preview = compact_preview(&turn.preview, 180); + let collapsed_meta = turn_collapsed_meta(&turn); + let expanded_facts = turn_expanded_facts(&turn); let tool_count = turn.tool_names.len(); let event_count = turn.event_seqs.len(); if embedded { return rsx! { button { class: "compact-turn pc2-embedded-turn", onclick: move |_| on_turn.call(id), span { class: "compact-turn-chevron" } span { class: "pc2-role {turn.source}", "{turn.source}" } code { "#{id}" } span { class: "compact-kind", "{kind}" } span { class: "compact-preview", title: "{preview}", "{preview}" } span { class: "compact-turn-stats", if tool_count > 0 { span { "{tool_count} tools" } } span { "{event_count} ev" } } } }; } rsx! { details { class: if expanded { "compact-turn selected" } else { "compact-turn" }, open: expanded, - summary { aria_label: "Expand {turn.source} turn {id}", onclick: move |event| { event.prevent_default(); on_turn.call(id); }, span { class: "compact-turn-chevron" } span { class: "pc2-role {turn.source}", "{turn.source}" } code { "#{id}" } span { class: "compact-kind", "{kind}" } span { class: "compact-preview", title: "{preview}", "{preview}" } span { class: "compact-turn-stats", if tool_count > 0 { span { "{tool_count} tools" } } span { "{event_count} ev" } } } + summary { aria_label: "Expand {turn.source} turn {id}", onclick: move |event| { event.prevent_default(); on_turn.call(id); }, span { class: "compact-turn-chevron" } span { class: "pc2-role {turn.source}", "{turn.source}" } code { "#{id}" } if expanded { span { class: "compact-kind", "{expanded_facts}" } } else { span { class: "compact-kind", "{kind}" } span { class: "compact-preview", title: "{preview}", "{preview}" } span { class: "compact-turn-stats", if !collapsed_meta.is_empty() { span { "{collapsed_meta}" } } if tool_count > 0 { span { "{tool_count} tools" } } span { "{event_count} ev" } } } } if expanded { div { class: "compact-turn-body pc2-inline-detail", if loading { div { class: "pc2-inline-loading", span { class: "spinner" } "Loading full turn…" } } else if let Some(value) = detail.filter(|value| value.summary.id == id) { InlineTurnDetail { value } } else { div { class: "pc2-inline-unavailable", "Full evidence is unavailable for this turn." } } } } } } } #[component] fn InlineTurnDetail(value: TurnDetail) -> Element { + let message = value.turn.message.clone(); + let message_text = value.turn.text(); + let structured_message = is_structured_json(&message); + let tool_calls = + serde_json::to_value(&value.wire_tool_calls).unwrap_or(Value::Array(Vec::new())); + let events = serde_json::to_value(&value.events).unwrap_or(Value::Array(Vec::new())); rsx! { div { class: "pc2-inline-detail-head", strong { "Full turn evidence" } } div { class: "pc2-inspector-facts", Fact { label: "Turn", value: format!("#{}", value.summary.id) } Fact { label: "Source", value: value.summary.source.clone() } Fact { label: "Kind", value: value.summary.kind.clone().unwrap_or_else(|| "unavailable".into()) } Fact { label: "Model", value: value.summary.model_name.clone().unwrap_or_else(|| "unavailable".into()) } Fact { label: "Latency", value: value.summary.latency_ms.map(format_ms).unwrap_or_else(|| "unavailable".into()) } Fact { label: "TTFT", value: value.summary.ttft_ms.map(format_ms).unwrap_or_else(|| "unavailable".into()) } Fact { label: "Tokens", value: value.summary.total_tokens.map(|tokens| tokens.to_string()).unwrap_or_else(|| "unavailable".into()) } Fact { label: "Token split", value: format!("{} in · {} out", optional_u64(value.summary.prompt_tokens), optional_u64(value.summary.completion_tokens)) } Fact { label: "Events", value: value.events.len().to_string() } } - EvidenceBlock { title: "Message", value: value.turn.text() } - if let Some(reasoning) = &value.turn.reasoning_content { EvidenceBlock { title: "Reasoning", value: reasoning.clone() } } - if !value.wire_tool_calls.is_empty() { EvidenceBlock { title: "Tool calls", value: serde_json::to_string_pretty(&value.wire_tool_calls).unwrap_or_default() } } - if let Some(observation) = &value.turn.observation { EvidenceBlock { title: "Observation", value: serde_json::to_string_pretty(observation).unwrap_or_default() } } - if !value.events.is_empty() { EvidenceBlock { title: "Raw linked events", value: serde_json::to_string_pretty(&value.events).unwrap_or_default() } } + if structured_message { + EvidenceBlock { title: "Message", open: true, JsonValue { value: message } } + } else { + EvidenceBlock { title: "Message", open: true, pre { "{message_text}" } } + } + if let Some(reasoning) = &value.turn.reasoning_content { + EvidenceBlock { title: "Reasoning", pre { "{reasoning.clone()}" } } + } + if !value.wire_tool_calls.is_empty() { + EvidenceBlock { title: "Tool calls", JsonValue { value: tool_calls } } + } + if let Some(observation) = value.turn.observation.clone() { + EvidenceBlock { title: "Observation", JsonValue { value: observation } } + } + if !value.events.is_empty() { + EvidenceBlock { title: "Raw linked events", JsonValue { value: events } } + } + if let Some(extra) = value.turn.extra.clone() { + EvidenceBlock { title: "Extra", JsonValue { value: extra } } + } + if let Some(metrics) = value.turn.metrics.clone() { + EvidenceBlock { title: "Metrics", JsonValue { value: metrics } } + } } } @@ -435,8 +929,12 @@ fn Fact(label: &'static str, value: String) -> Element { } #[component] -fn EvidenceBlock(title: &'static str, value: String) -> Element { - rsx! { details { class: "pc2-evidence-block", open: title == "Message", summary { "{title}" } pre { "{value}" } } } +fn EvidenceBlock( + title: &'static str, + #[props(default = false)] open: bool, + children: Element, +) -> Element { + rsx! { details { class: "pc2-evidence-block", open, summary { "{title}" } {children} } } } fn format_ms(value: f64) -> String { @@ -446,6 +944,7 @@ fn format_ms(value: f64) -> String { format!("{value:.1}ms") } } + fn optional_u64(value: Option) -> String { value .map(|value| value.to_string()) @@ -481,4 +980,217 @@ mod tests { assert_eq!(value, "abcde…"); assert!(truncated); } + + fn turn(id: i64, source: &str, seqs: &[u64]) -> TurnSummary { + TurnSummary { + id, + source: source.into(), + kind: None, + timestamp: None, + call_id: None, + preview: format!("{source}-{id}"), + char_count: 0, + modalities: Vec::new(), + model_name: None, + latency_ms: None, + ttft_ms: None, + prompt_tokens: None, + completion_tokens: None, + total_tokens: None, + tool_names: Vec::new(), + event_seqs: seqs.to_vec(), + has_error: false, + } + } + + #[test] + fn chats_keep_one_timeline_row_for_a_user_and_following_agents() { + let turns = vec![ + turn(1, "user", &[0]), + turn(2, "agent", &[2, 5]), + turn(3, "agent", &[6]), + ]; + let groups = chat_span_groups(&turns); + assert_eq!(groups.len(), 1); + assert_eq!(groups[0].label, "Chat 1"); + assert_eq!(groups[0].overview, "user-1"); + assert_eq!( + groups[0] + .entries + .iter() + .map(|turn| turn.id) + .collect::>(), + vec![1, 2, 3] + ); + assert_eq!((groups[0].first_seq, groups[0].last_seq), (0, 6)); + let session_index = session_index_map(&turns); + let bars = seq_bars(&groups[0].entries, &session_index, session_axis_len(&turns)); + assert_eq!( + bars.iter().map(|bar| bar.source).collect::>(), + vec!["user", "agent", "agent"] + ); + assert!((bars[0].left - 0.0).abs() < 1e-9); + assert!((bars[0].width - 100.0 / 7.0).abs() < 1e-9); + assert!((bars[1].left - 200.0 / 7.0).abs() < 1e-9); + assert!((bars[1].width - 400.0 / 7.0).abs() < 1e-9); + assert!((bars[2].left - 600.0 / 7.0).abs() < 1e-9); + } + + #[test] + fn chat_structure_uses_user_chars_and_union_modalities() { + let mut user = turn(1, "user", &[0]); + user.preview = "Please continue".into(); + user.char_count = 15; + user.modalities = vec!["text".into()]; + let mut agent = turn(2, "agent", &[1]); + agent.preview = "ls".into(); + agent.char_count = 80; + agent.modalities = vec!["text".into(), "tool_call".into()]; + agent.tool_names = vec!["execute_bash".into()]; + let groups = chat_span_groups(&[user, agent]); + assert_eq!(groups[0].kind_chip, "chat"); + assert_eq!(groups[0].overview, "Please continue"); + assert_eq!( + union_modalities(&groups[0].entries), + vec!["text", "tool_call"] + ); + assert_eq!(row_char_count(&groups[0].entries, true), 15); + assert_eq!( + composition_label(&groups[0].entries), + "1 user + 1 agent · 1 tool" + ); + assert_eq!(format_char_count(15), "15 chars"); + assert_eq!(format_char_count(1200), "1.2k chars"); + } + + #[test] + fn chat_without_user_keeps_chat_kind_and_no_user_overview() { + let mut agent = turn(2, "agent", &[0]); + agent.modalities = vec!["text".into()]; + agent.char_count = 12; + let groups = chat_span_groups(&[agent]); + assert_eq!(groups[0].kind_chip, "chat"); + assert_eq!(groups[0].overview, "No user turn"); + assert_eq!(row_char_count(&groups[0].entries, true), 0); + } + + #[test] + fn steps_empty_preview_reads_as_no_text() { + let mut turn = turn(3, "agent", &[0]); + turn.preview.clear(); + let groups = step_span_groups(&[turn]); + assert_eq!(groups[0].kind_chip, "agent"); + assert_eq!(groups[0].overview, "No text"); + } + + #[test] + fn expanded_chat_overview_is_diagnostics_not_user_text() { + let mut user = turn(1, "user", &[0]); + user.preview = "Please continue".into(); + user.total_tokens = Some(100); + let mut agent = turn(2, "agent", &[1]); + agent.preview = "running ls".into(); + agent.model_name = Some("glm".into()); + agent.latency_ms = Some(2020.0); + agent.total_tokens = Some(400); + agent.tool_names = vec!["execute_bash".into()]; + let diagnostic = group_diagnostic(&[user.clone(), agent.clone()]); + assert!(diagnostic.contains("2 turns")); + assert!(diagnostic.contains("1 user + 1 agent · 1 tool")); + assert!(diagnostic.contains("500 tokens")); + assert!(diagnostic.contains("2.02s")); + assert!(!diagnostic.contains("seq")); + assert!(!diagnostic.contains("Please continue")); + assert!(!diagnostic.contains("running ls")); + let expanded = turn_expanded_facts(&agent); + assert!(expanded.contains("Model glm")); + assert!(expanded.contains("Latency 2.02s")); + assert!(!expanded.contains("running ls")); + assert!(!expanded.contains("seq")); + assert_eq!(sequence_caption(2, 3), "seq 2–3"); + assert!(!structure_meta(&chat_span_groups(&[user, agent])[0]).contains("seq")); + } + + #[test] + fn occupancy_marks_split_exposed_range_from_expanded_turn() { + let turns = vec![ + turn(1, "user", &[0]), + turn(2, "agent", &[1]), + turn(3, "user", &[2]), + turn(4, "agent", &[3]), + ]; + let session_index = session_index_map(&turns); + let bars = seq_bars(&turns, &session_index, session_axis_len(&turns)); + let exposed = occupancy_range(&bars, &[1, 2]).expect("early turns form a range"); + assert!((exposed.left - 0.0).abs() < 1e-9); + assert!((exposed.width - 50.0).abs() < 1e-9); + let focus = focus_line_left(bars.iter().find(|bar| bar.turn_id == 4).unwrap()); + assert!((focus - 87.5).abs() < 1e-9); + assert!(occupancy_range(&bars, &[]).is_none()); + } + + #[test] + fn sequence_emphasis_orders_focus_viewport_then_hover() { + assert_eq!(bar_emphasis(2, &[1, 2], Some(2), &[2]), "focused"); + assert_eq!(bar_emphasis(1, &[1, 2], Some(2), &[1]), "exposed"); + assert_eq!(bar_emphasis(8, &[1, 2], Some(2), &[8]), "hovered"); + assert_eq!(bar_emphasis(9, &[1, 2], Some(2), &[]), "dimmed"); + assert_eq!(bar_emphasis(9, &[], None, &[]), ""); + } + + #[test] + fn sequence_axis_keeps_session_relative_position_and_type_colors() { + let turns = vec![ + turn(1, "system", &[]), + turn(2, "user", &[]), + turn(3, "agent", &[]), + turn(4, "user", &[]), + ]; + let session_index = session_index_map(&turns); + let axis_len = session_axis_len(&turns); + let root = seq_bars(&turns, &session_index, axis_len); + assert_eq!( + root.iter().map(|bar| bar.source).collect::>(), + vec!["system", "user", "agent", "user"] + ); + assert!((root[0].width - 25.0).abs() < 1e-9); + assert!((root[3].left - 75.0).abs() < 1e-9); + + let later_chat = seq_bars(&turns[3..], &session_index, axis_len); + assert_eq!(later_chat[0].source, "user"); + assert!((later_chat[0].left - 75.0).abs() < 1e-9); + assert!((later_chat[0].width - 25.0).abs() < 1e-9); + } + + #[test] + fn source_filter_keeps_chat_members() { + let turns = vec![ + turn(1, "user", &[0]), + turn(2, "agent", &[2]), + turn(3, "system", &[4]), + ]; + let visible = chat_span_groups(&turns) + .into_iter() + .filter(|group| chat_row_visible(&group.entries, "agent", "")) + .collect::>(); + assert_eq!(visible.len(), 1); + assert_eq!( + visible[0] + .entries + .iter() + .map(|turn| turn.id) + .collect::>(), + vec![1, 2] + ); + assert_eq!(visible[0].overview, "user-1"); + } + + #[test] + fn steps_keep_one_timeline_row_per_turn() { + let turns = vec![turn(1, "user", &[0]), turn(2, "agent", &[3])]; + let groups = step_span_groups(&turns); + assert_eq!(groups.len(), 2); + assert_eq!(groups[0].label, "#1"); + assert_eq!(groups[1].first_seq, 3); + } } diff --git a/pchronicle-web/src/json_value.rs b/pchronicle-web/src/json_value.rs new file mode 100644 index 00000000..78cd116a --- /dev/null +++ b/pchronicle-web/src/json_value.rs @@ -0,0 +1,281 @@ +use dioxus::prelude::*; +use serde_json::Value; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum JsonShape { + Scalar, + KvTable, + RecordTable, + Tree, +} + +pub fn peel_json(value: &Value) -> Value { + match value { + Value::String(raw) => match serde_json::from_str::(raw) { + Ok(parsed) if parsed.is_object() || parsed.is_array() => parsed, + _ => value.clone(), + }, + other => other.clone(), + } +} + +pub fn classify_json(value: &Value) -> JsonShape { + classify_peeled(&peel_json(value)) +} + +pub fn is_structured_json(value: &Value) -> bool { + let peeled = peel_json(value); + peeled.is_object() || peeled.is_array() +} + +fn is_scalar(value: &Value) -> bool { + matches!( + value, + Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) + ) +} + +fn classify_peeled(value: &Value) -> JsonShape { + match value { + Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => JsonShape::Scalar, + Value::Object(object) => { + if object.values().all(is_scalar) { + JsonShape::KvTable + } else { + JsonShape::Tree + } + } + Value::Array(items) => { + if !items.is_empty() + && items.iter().all(|item| { + item.as_object() + .is_some_and(|object| object.values().all(is_scalar)) + }) + { + JsonShape::RecordTable + } else { + JsonShape::Tree + } + } + } +} + +pub fn record_columns(rows: &[Value]) -> Vec { + let mut columns = Vec::new(); + for row in rows { + if let Value::Object(object) = row { + for key in object.keys() { + if !columns.contains(key) { + columns.push(key.clone()); + } + } + } + } + columns +} + +pub fn json_summary(value: &Value) -> String { + match peel_json(value) { + Value::Object(object) => format!("{{{} keys}}", object.len()), + Value::Array(items) => format!("[{} items]", items.len()), + Value::String(_) => "string".into(), + Value::Number(_) => "number".into(), + Value::Bool(_) => "boolean".into(), + Value::Null => "null".into(), + } +} + +fn scalar_text(value: &Value) -> String { + match value { + Value::Null => "null".into(), + Value::Bool(value) => value.to_string(), + Value::Number(value) => value.to_string(), + Value::String(value) => value.clone(), + other => other.to_string(), + } +} + +#[component] +pub fn JsonValue(value: Value) -> Element { + let peeled = peel_json(&value); + match classify_json(&value) { + JsonShape::Scalar => { + let text = scalar_text(&peeled); + rsx! { span { class: "pc2-json-scalar", "{text}" } } + } + JsonShape::KvTable => rsx! { JsonKvTable { value: peeled } }, + JsonShape::RecordTable => rsx! { JsonRecordTable { value: peeled } }, + JsonShape::Tree => rsx! { JsonTree { value: peeled } }, + } +} + +#[component] +fn JsonKvTable(value: Value) -> Element { + let map = match value { + Value::Object(map) => map, + _ => return rsx! { span { class: "pc2-json-scalar", "—" } }, + }; + rsx! { + table { class: "pc2-json-table pc2-json-kv", + thead { tr { th { "key" } th { "value" } } } + tbody { + for (key, child) in map { + tr { key: "{key}", + th { scope: "row", "{key}" } + td { JsonValue { value: child } } + } + } + } + } + } +} + +#[component] +fn JsonRecordTable(value: Value) -> Element { + let rows = match value { + Value::Array(rows) => rows, + _ => return rsx! { span { class: "pc2-json-scalar", "—" } }, + }; + let columns = record_columns(&rows); + rsx! { + div { class: "pc2-json-scroll", + table { class: "pc2-json-table pc2-json-records", + thead { tr { for column in columns.iter() { th { "{column}" } } } } + tbody { + for (row_index, row) in rows.iter().enumerate() { + tr { key: "{row_index}", + for column in columns.iter() { + td { + JsonValue { + value: match row { + Value::Object(object) => { + object.get(column).cloned().unwrap_or(Value::Null) + } + _ => Value::Null, + } + } + } + } + } + } + } + } + } + } +} + +#[component] +fn JsonTree(value: Value) -> Element { + match value { + Value::Array(items) if items.is_empty() => rsx! { + details { class: "pc2-json-node", + summary { span { class: "pc2-json-size", "[0 items]" } } + } + }, + Value::Object(map) => rsx! { + div { class: "pc2-json-tree", + for (key, child) in map { + JsonTreeNode { key: "{key}", label: key, value: child } + } + } + }, + Value::Array(items) => rsx! { + div { class: "pc2-json-tree", + for (index, child) in items.into_iter().enumerate() { + JsonTreeNode { key: "{index}", label: format!("[{index}]"), value: child } + } + } + }, + other => { + let text = scalar_text(&other); + rsx! { span { class: "pc2-json-scalar", "{text}" } } + } + } +} + +#[component] +fn JsonTreeNode(label: String, value: Value) -> Element { + let summary = json_summary(&value); + rsx! { + details { class: "pc2-json-node", + summary { span { class: "pc2-json-key", "{label}" } span { class: "pc2-json-size", "{summary}" } } + JsonValue { value } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn peel_promotes_object_and_array_strings_only() { + assert_eq!(peel_json(&json!({"a": 1})), json!({"a": 1})); + assert_eq!(peel_json(&json!("{\"a\":1}")), json!({"a": 1})); + assert_eq!(peel_json(&json!("[1,2]")), json!([1, 2])); + assert_eq!(peel_json(&json!("not-json")), json!("not-json")); + assert_eq!(peel_json(&json!("{")), json!("{")); + assert_eq!(peel_json(&json!("\"hello\"")), json!("\"hello\"")); + assert_eq!(peel_json(&json!(7)), json!(7)); + } + + #[test] + fn classify_matches_one_level_tables_and_trees() { + assert_eq!(classify_json(&json!("plain")), JsonShape::Scalar); + assert_eq!(classify_json(&json!({})), JsonShape::KvTable); + assert_eq!( + classify_json(&json!({"b": true, "a": 1})), + JsonShape::KvTable + ); + assert_eq!( + classify_json(&json!([{"b": 2, "a": 1}, {"a": 3, "c": null}])), + JsonShape::RecordTable + ); + assert_eq!( + classify_json(&json!([{"fn": "read", "args": "{\"path\":\"x\"}"}])), + JsonShape::RecordTable + ); + assert_eq!( + classify_json(&json!( + "{\"fn\":\"read\",\"args\":\"{\\\"path\\\":\\\"x\\\"}\"}" + )), + JsonShape::KvTable + ); + assert_eq!( + classify_json(&peel_json(&json!("{\"path\":\"x\"}"))), + JsonShape::KvTable + ); + assert_eq!(classify_json(&json!({"nested": {"x": 1}})), JsonShape::Tree); + assert_eq!(classify_json(&json!([1, 2, 3])), JsonShape::Tree); + assert_eq!(classify_json(&json!([])), JsonShape::Tree); + assert_eq!(classify_json(&json!([{"a": 1}, "tail"])), JsonShape::Tree); + assert_eq!(classify_json(&json!([{"a": {"b": 1}}])), JsonShape::Tree); + } + + #[test] + fn structured_detection_follows_peel() { + assert!(!is_structured_json(&json!("hello"))); + assert!(is_structured_json(&json!({"a": 1}))); + assert!(is_structured_json(&json!("{\"a\":1}"))); + assert!(!is_structured_json(&json!("\"hello\""))); + } + + #[test] + fn record_columns_keep_first_seen_union() { + let rows = vec![json!({"b": 2, "a": 1}), json!({"a": 3, "c": null})]; + assert_eq!(record_columns(&rows), vec!["a", "b", "c"]); + } + + #[test] + fn json_summary_peels_and_names_types() { + assert_eq!(json_summary(&json!({"a": 1, "b": 2})), "{2 keys}"); + assert_eq!(json_summary(&json!([1, 2, 3])), "[3 items]"); + assert_eq!(json_summary(&json!([])), "[0 items]"); + assert_eq!(json_summary(&json!("{}")), "{0 keys}"); + assert_eq!(json_summary(&json!("hello")), "string"); + assert_eq!(json_summary(&json!(true)), "boolean"); + assert_eq!(json_summary(&json!(1)), "number"); + assert_eq!(json_summary(&json!(null)), "null"); + } +} diff --git a/pchronicle-web/src/main.rs b/pchronicle-web/src/main.rs index bf6775bc..4a9c6fdd 100644 --- a/pchronicle-web/src/main.rs +++ b/pchronicle-web/src/main.rs @@ -2,7 +2,9 @@ mod agent; mod api; +mod chat_view; mod components; +mod json_value; mod model; mod tools; mod workspace; diff --git a/pchronicle-web/src/model.rs b/pchronicle-web/src/model.rs index bcb85cc4..5bf0596d 100644 --- a/pchronicle-web/src/model.rs +++ b/pchronicle-web/src/model.rs @@ -1,8 +1,29 @@ use std::collections::BTreeMap; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; use serde_json::Value; +fn deserialize_optional_timestamp<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + #[derive(Deserialize)] + #[serde(untagged)] + enum TimestampValue { + Text(String), + Integer(i64), + Float(f64), + } + + Ok(match Option::::deserialize(deserializer)? { + None => None, + Some(TimestampValue::Text(value)) if value.is_empty() => None, + Some(TimestampValue::Text(value)) => Some(value), + Some(TimestampValue::Integer(value)) => Some(value.to_string()), + Some(TimestampValue::Float(value)) => Some(value.to_string()), + }) +} + #[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] pub struct RunSummary { #[serde(default = "default_dataset_name")] @@ -118,7 +139,12 @@ fn default_source_file() -> String { pub struct StorylineTurn { pub id: i64, pub kind: Option, - #[serde(rename = "ts", alias = "timestamp")] + #[serde( + rename = "ts", + alias = "timestamp", + default, + deserialize_with = "deserialize_optional_timestamp" + )] pub timestamp: Option, #[serde(rename = "src", alias = "source")] pub source: String, @@ -168,6 +194,7 @@ pub struct EventRecord { pub seq: u64, pub source: String, pub kind: String, + #[serde(default, deserialize_with = "deserialize_optional_timestamp")] pub timestamp: Option, pub event_id: Option, pub call_id: Option, @@ -243,9 +270,14 @@ pub struct TurnSummary { pub id: i64, pub source: String, pub kind: Option, + #[serde(default, deserialize_with = "deserialize_optional_timestamp")] pub timestamp: Option, pub call_id: Option, pub preview: String, + #[serde(default)] + pub char_count: u64, + #[serde(default)] + pub modalities: Vec, pub model_name: Option, pub latency_ms: Option, pub ttft_ms: Option, @@ -340,4 +372,35 @@ mod tests { "dataset=captures&file=capture-comparison%2Fevents.lance&agent_id=capture-comparison&session_id=session-1" ); } + + #[test] + fn turn_detail_accepts_numeric_storyline_timestamps() { + let turn: StorylineTurn = serde_json::from_value(serde_json::json!({ + "id": 85, + "src": "user", + "msg": "hello", + "ts": 1785310111 + })) + .expect("explorer detail preserves numeric Storyline timestamps"); + assert_eq!(turn.timestamp.as_deref(), Some("1785310111")); + + let event: EventRecord = serde_json::from_value(serde_json::json!({ + "seq": 1, + "source": "gateway", + "kind": "llm.request", + "timestamp": 1785310111, + "payload": {} + })) + .expect("linked events may carry numeric timestamps"); + assert_eq!(event.timestamp.as_deref(), Some("1785310111")); + + let rfc3339: StorylineTurn = serde_json::from_value(serde_json::json!({ + "id": 1, + "src": "agent", + "msg": "ok", + "ts": "2026-07-29T00:00:00Z" + })) + .unwrap(); + assert_eq!(rfc3339.timestamp.as_deref(), Some("2026-07-29T00:00:00Z")); + } } diff --git a/pchronicle-web/src/workspace.rs b/pchronicle-web/src/workspace.rs index 4b02bae4..57b860b1 100644 --- a/pchronicle-web/src/workspace.rs +++ b/pchronicle-web/src/workspace.rs @@ -5,6 +5,7 @@ use wasm_bindgen::JsValue; use crate::agent::{self, AgentAnswer, LlmConfig}; use crate::api; +use crate::chat_view::normalize_trace_view; use crate::components::{parse_rich_blocks, DataTable, RichBlock, TrajectoryView}; use crate::model::{ DimensionAggregate, HistogramBucket, QueryCatalog, QueryDatasetSummary, RunAnalysis, @@ -20,6 +21,53 @@ struct ChatMessage { truncated: bool, } +#[derive(Clone, Debug, PartialEq)] +struct WorkspaceNotice { + title: String, + summary: String, + detail: String, + turn_id: Option, +} + +fn workspace_notice(detail: String) -> WorkspaceNotice { + WorkspaceNotice { + title: "Workspace request failed".into(), + summary: detail + .lines() + .next() + .unwrap_or("Request failed") + .to_string(), + detail, + turn_id: None, + } +} + +fn evidence_notice(turn_id: i64, detail: &str) -> WorkspaceNotice { + WorkspaceNotice { + title: "Turn evidence could not be decoded".into(), + summary: format!("Turn #{turn_id} · {}", type_mismatch_summary(detail)), + detail: detail.to_string(), + turn_id: Some(turn_id), + } +} + +fn type_mismatch_summary(detail: &str) -> String { + let received = detail + .split("invalid type: ") + .nth(1) + .and_then(|rest| rest.split([',', ' ']).next()) + .filter(|value| !value.is_empty()); + let expected = detail + .split("expected a ") + .nth(1) + .and_then(|rest| rest.split_whitespace().next()) + .map(|value| value.trim_end_matches(['.', ','])); + match (expected, received) { + (Some(expected), Some(received)) => format!("Expected {expected}, received {received}"), + _ => "The turn payload did not match the explorer schema".into(), + } +} + struct RunFilters { query: String, dataset: String, @@ -73,7 +121,7 @@ pub fn App() -> Element { let mut direction = use_signal(|| url_param("direction").unwrap_or_else(|| "asc".into())); let mut run_path = use_signal(|| url_param("path").unwrap_or_default()); let mut offset = use_signal(|| 0usize); - let mut error = use_signal(|| None::); + let mut error = use_signal(|| None::); let mut selected_run = use_signal(move || initial_run); let mut analysis = use_signal(|| None::); @@ -84,7 +132,9 @@ pub fn App() -> Element { let detail_loading = use_signal(|| false); let turn_loading = use_signal(|| false); let mut detail_mode = use_signal(|| url_param("workspace").unwrap_or_else(|| "trace".into())); - let trace_mode = use_signal(|| url_param("view").unwrap_or_else(|| "tree".into())); + let mut trace_mode = use_signal(|| { + normalize_trace_view(&url_param("view").unwrap_or_else(|| "chats".into())).to_string() + }); let mut source = use_signal(|| url_param("source").unwrap_or_else(|| "all".into())); let mut turn_query = use_signal(|| url_param("turn_q").unwrap_or_default()); @@ -169,7 +219,7 @@ pub fn App() -> Element { } catalog.set(Some(value)); } - Err(message) => error.set(Some(message)), + Err(message) => error.set(Some(workspace_notice(message))), } }); } @@ -196,8 +246,18 @@ pub fn App() -> Element { } main { id: "pc2-main", class: "pc2-main", tabindex: "-1", - if let Some(message) = error() { - div { class: "pc2-global-error", role: "alert", strong { "Evidence unavailable" } span { "{message}" } button { aria_label: "Dismiss", onclick: move |_| error.set(None), "×" } } + if let Some(notice) = error() { + div { class: "pc2-workspace-notice", role: "alert", + div { class: "pc2-workspace-notice-copy", + strong { "{notice.title}" } + span { "{notice.summary}" } + details { class: "pc2-workspace-notice-details", + summary { "Show technical details" } + pre { "{notice.detail}" } + } + } + button { aria_label: "Dismiss", onclick: move |_| error.set(None), "×" } + } } match page().as_str() { "tools" => rsx! { crate::tools::ToolsWorkspace { catalog: catalog(), selected_table } }, @@ -219,22 +279,17 @@ pub fn App() -> Element { loading: detail_loading(), turn_loading: turn_loading(), detail_mode: detail_mode(), + view: trace_mode(), source: source(), query: turn_query(), on_back: move |_| page.set("runs".into()), on_detail_mode: move |value| detail_mode.set(value), - on_source: move |value| { - source.set(value); - if let Some(run) = selected_run() { - reload_turns(run, turn_query(), source(), turns, detail_loading, error); - } + on_view: move |value: String| { + trace_mode.set(normalize_trace_view(&value).to_string()); }, + on_source: move |value| source.set(value), on_query: move |value| turn_query.set(value), - on_apply_filter: move |_| { - if let Some(run) = selected_run() { - reload_turns(run, turn_query(), source(), turns, detail_loading, error); - } - }, + on_apply_filter: move |_| {}, on_turn: move |id| { if expanded_turn_id() == Some(id) { expanded_turn_id.set(None); @@ -285,7 +340,7 @@ pub fn App() -> Element { }; spawn(async move { if let Err(message) = api::refresh_catalog().await { - error.set(Some(message)); + error.set(Some(workspace_notice(message))); return; } if let Ok(value) = api::query_catalog().await { @@ -343,7 +398,7 @@ fn load_runs( filters: RunFilters, mut page: Signal>, mut loading: Signal, - mut error: Signal>, + mut error: Signal>, ) { loading.set(true); spawn(async move { @@ -359,7 +414,7 @@ fn load_runs( .await { Ok(value) => page.set(Some(value)), - Err(message) => error.set(Some(message)), + Err(message) => error.set(Some(workspace_notice(message))), } loading.set(false); }); @@ -370,7 +425,7 @@ fn load_workspace( mut analysis: Signal>, mut turns: Signal>, mut loading: Signal, - mut error: Signal>, + mut error: Signal>, ) { loading.set(true); spawn(async move { @@ -382,45 +437,27 @@ fn load_workspace( turns.set(next_turns.records); } (Err(message), _) | (_, Err(message)) => { - error.set(Some(message)); + error.set(Some(workspace_notice(message))); } } loading.set(false); }); } -fn reload_turns( - run: RunSummary, - q: String, - source: String, - mut turns: Signal>, - mut loading: Signal, - mut error: Signal>, -) { - loading.set(true); - spawn(async move { - match api::turns(&run, &q, &source).await { - Ok(value) => turns.set(value.records), - Err(message) => error.set(Some(message)), - } - loading.set(false); - }); -} - fn load_turn( run: RunSummary, id: i64, active: Signal>, mut selected: Signal>, mut loading: Signal, - mut error: Signal>, + mut error: Signal>, ) { loading.set(true); spawn(async move { match api::turn_detail(&run, id).await { Ok(value) if active() == Some(id) => selected.set(Some(value)), Ok(_) => {} - Err(message) if active() == Some(id) => error.set(Some(message)), + Err(message) if active() == Some(id) => error.set(Some(evidence_notice(id, &message))), Err(_) => {} } if active() == Some(id) { @@ -651,16 +688,23 @@ fn RunDetailWorkspace( loading: bool, turn_loading: bool, detail_mode: String, + view: String, source: String, query: String, on_back: EventHandler, on_detail_mode: EventHandler, + on_view: EventHandler, on_source: EventHandler, on_query: EventHandler, on_apply_filter: EventHandler<()>, on_turn: EventHandler, on_open_copilot: EventHandler, ) -> Element { + let chats_active = view == "chats"; + let steps_active = view == "steps"; + let view_for_list = view.clone(); + let source_for_list = source.clone(); + let query_for_list = query.clone(); rsx! { section { class: "pc2-detail", header { class: "pc2-detail-head", @@ -685,8 +729,12 @@ fn RunDetailWorkspace( } else { section { class: "pc2-trace-surface pc2-inline-trace", div { class: "pc2-trace-toolbar", - div { strong { "Trace hierarchy" } span { "Collapsed rows preserve overview + timeline · expand for full evidence" } } + div { strong { if steps_active { "Steps" } else { "Chats" } } span { "Bars sit at each turn's place in the session · colored by type · sequence is not wall-clock time · expand a row for evidence" } } div { class: "pc2-toolbar-controls", + div { class: "pc2-view-toggle", role: "group", aria_label: "Trace layout", + button { class: if chats_active { "active" } else { "" }, onclick: move |_| on_view.call("chats".to_string()), "Chats" } + button { class: if steps_active { "active" } else { "" }, onclick: move |_| on_view.call("steps".to_string()), "Steps" } + } select { value: "{source}", aria_label: "Filter turns by source", onchange: move |event| on_source.call(event.value()), option { value: "all", "All sources" } option { value: "user", "User" } option { value: "agent", "Agent" } option { value: "system", "System" } } input { value: "{query}", placeholder: "Filter loaded evidence", aria_label: "Filter turns", oninput: move |event| on_query.call(event.value()), onkeydown: move |event| if event.key() == Key::Enter { on_apply_filter.call(()) } } button { class: "pc2-icon", aria_label: "Apply turn filter", onclick: move |_| on_apply_filter.call(()), "⌕" } @@ -695,7 +743,7 @@ fn RunDetailWorkspace( div { class: "pc2-turn-list pc2-span-scroll", if loading { div { class: "pc2-inline-loading", span { class: "spinner" } "Refreshing evidence…" } } if turns.is_empty() { div { class: "pc2-empty", strong { "No visible turns" } span { "No compact turn evidence matches this filter." } } } - else { TrajectoryView { turns, expanded_turn_id, detail: selected, loading: turn_loading, on_turn } } + else { TrajectoryView { turns, expanded_turn_id, detail: selected, loading: turn_loading, view: view_for_list, source: source_for_list, query: query_for_list, on_turn } } } } } @@ -1016,7 +1064,7 @@ fn CopilotPanel( div { class: "pc2-context-card", div { span { "Grounded in" } strong { "{short(&run.session_id, 30)}" } } div { span { "Evidence" } strong { "{analysis.turn_count} turns · {analysis.error_count} explicit errors" } } label { input { r#type: "checkbox", checked: include_full(), disabled: selected.is_none(), onchange: move |event| include_full.set(event.checked()) } "Include selected turn content once (max 64 KiB)" } } div { class: "pc2-skill-chips", for skill in agent::skill_ids() { button { disabled: busy(), onclick: move |_| input.set(format!("/{skill}")), "{skill_label(skill)}" } } } div { class: "pc2-chat", - if messages().is_empty() { div { class: "pc2-chat-welcome", span { "◇" } strong { "Ask from captured evidence" } p { "Copilot can summarize this run, locate explicit failures, rank latency, inspect tool usage, or compare cohorts." } } } + if messages().is_empty() { div { class: "pc2-chat-welcome", span { "◇" } strong { "Ask Copilot" } p { "Copilot can summarize this run, locate explicit failures, rank latency, inspect tool usage, or compare cohorts." } } } for (index, message) in messages().iter().enumerate() { ChatBubble { key: "message-{index}", message: message.clone(), turns: turns.clone(), on_turn } } if busy() { div { class: "pc2-chat-working", span { class: "spinner" } "Selecting one read-only analysis action…" } } } @@ -1051,7 +1099,7 @@ fn CopilotPanel( include_full.set(false); busy.set(false); }); - }, textarea { value: "{input}", rows: "3", placeholder: "Ask about this trajectory…", oninput: move |event| input.set(event.value()), onkeydown: move |event| if event.key() == Key::Enter && !event.modifiers().shift() { event.prevent_default(); }, disabled: busy() } button { class: "button primary", disabled: busy() || input().trim().is_empty(), "Analyze" } } + }, textarea { value: "{input}", rows: "3", placeholder: "Ask Copilot about this trajectory…", oninput: move |event| input.set(event.value()), onkeydown: move |event| if event.key() == Key::Enter && !event.modifiers().shift() { event.prevent_default(); }, disabled: busy() } button { class: "button primary", disabled: busy() || input().trim().is_empty(), "Ask Copilot" } } if settings() { LlmSettings { config: config(), on_close: move |_| settings.set(false), on_save: move |value| { agent::save_config(&value); config.set(value); settings.set(false); } } } } } } @@ -1075,7 +1123,7 @@ fn ChatBubble( rsx! { div { key: "trajectory-{index}", class: "pc2-chat-component", if let Some(title) = trajectory.title { strong { class: "pc2-chat-component-title", "{title}" } } if visible.is_empty() { div { class: "pc2-data-empty", "Referenced turns are outside the loaded evidence window." } } - else { TrajectoryView { turns: visible, expanded_turn_id: None, detail: None, loading: false, embedded: true, on_turn } } + else { TrajectoryView { turns: visible, expanded_turn_id: None, detail: None, loading: false, embedded: true, view: "steps".to_string(), on_turn } } } } }, } @@ -1306,4 +1354,19 @@ mod tests { assert_eq!(percent(10.0, 0.0), 0.0); assert_eq!(percent(150.0, 100.0), 100.0); } + + #[test] + fn evidence_notice_keeps_serde_details_collapsed() { + let notice = evidence_notice( + 85, + "invalid type: integer `1785310111`, expected a string at line 1 column 561", + ); + assert_eq!(notice.title, "Turn evidence could not be decoded"); + assert_eq!( + notice.summary, + "Turn #85 · Expected string, received integer" + ); + assert!(notice.detail.contains("1785310111")); + assert_eq!(notice.turn_id, Some(85)); + } } diff --git a/pyproject.toml b/pyproject.toml index c8edf58f..d9273879 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,6 +43,9 @@ backend-path = ["scripts/packaging"] [tool.cibuildwheel] build = "cp312-*" +# Nightly/PyPI only publish manylinux + macOS wheels; musllinux is unused +# and its before-all still assumes yum. +skip = "*-musllinux_*" build-frontend = "build" [tool.cibuildwheel.linux] diff --git a/vendor/krun-init-blob/init/dhcp.c b/vendor/krun-init-blob/init/dhcp.c index 7cf3458f..d89b5e9d 100644 --- a/vendor/krun-init-blob/init/dhcp.c +++ b/vendor/krun-init-blob/init/dhcp.c @@ -7,14 +7,8 @@ #include "dhcp.h" -#include - #include #include -#include -#include -#include -#include #include #include #include @@ -25,6 +19,20 @@ #include #include +#include +#ifdef __linux__ +/* + * glibc before ~2.24 (manylinux2014 / CentOS 7) cannot include both + * and : they both define IFF_* and struct ifreq. + * libc already provides the ioctl types we need; skip the UAPI header. + */ +#define _LINUX_IF_H +#endif +#include +#include +#include +#include + #define DHCP_BUFFER_SIZE 576 #define DHCP_MSG_OFFER 2 #define DHCP_MSG_ACK 5