use super::*;
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
impl SRBNOrchestrator {
pub fn parse_artifact_bundle_typed(
&self,
content: &str,
node_id: &str,
attempt: u32,
) -> (
Option<perspt_core::types::ArtifactBundle>,
perspt_core::types::ParseResultState,
Option<perspt_core::types::CorrectionAttemptRecord>,
) {
use perspt_core::types::{
ArtifactBundle, ArtifactOperation, CorrectionAttemptRecord, ParseResultState,
RetryClassification,
};
let response_fingerprint = {
let mut hasher = DefaultHasher::new();
content.hash(&mut hasher);
format!("{:016x}", hasher.finish())
};
let response_length = content.len();
let created_at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
let classify_retry = |state: ParseResultState, rejection: Option<&str>| match state {
ParseResultState::StrictJsonOk | ParseResultState::TolerantRecoveryOk => None,
ParseResultState::NoStructuredPayload
| ParseResultState::SchemaInvalid
| ParseResultState::EmptyBundle => Some(RetryClassification::MalformedRetry),
ParseResultState::SemanticallyRejected => {
let reason = rejection.unwrap_or_default().to_ascii_lowercase();
if reason.contains("all artifacts rejected")
|| reason.contains("undeclared")
|| reason.contains("target")
{
Some(RetryClassification::Retarget)
} else if reason.contains("support") {
Some(RetryClassification::SupportFileViolation)
} else {
Some(RetryClassification::Replan)
}
}
};
let build_record = |state: ParseResultState, accepted: bool, rejection: Option<String>| {
let retry_classification = classify_retry(state, rejection.as_deref());
CorrectionAttemptRecord {
attempt,
parse_state: state,
retry_classification,
response_fingerprint: response_fingerprint.clone(),
response_length,
energy_after: None,
accepted,
rejection_reason: rejection,
created_at,
}
};
match perspt_core::normalize::extract_json(content) {
Ok(output) => {
let bundle = match serde_json::from_str::<ArtifactBundle>(&output.json_body) {
Ok(bundle) => {
log::info!(
"Parsed ArtifactBundle via normalization ({})",
output.method
);
bundle
}
Err(e) => {
let record = build_record(
ParseResultState::SchemaInvalid,
false,
Some(format!(
"JSON extracted via {} but bundle schema deserialization failed: {}",
output.method, e
)),
);
return (None, ParseResultState::SchemaInvalid, Some(record));
}
};
if bundle.validate().is_ok() {
let bundle = self.normalize_bundle_paths(bundle);
if bundle.artifacts.is_empty() {
let record = build_record(
ParseResultState::EmptyBundle,
false,
Some("Bundle is empty after path normalization".to_string()),
);
return (None, ParseResultState::EmptyBundle, Some(record));
}
match self.semantic_validate_bundle(&bundle, node_id) {
Ok(filtered) => {
if filtered.artifacts.is_empty() {
let record = build_record(
ParseResultState::SemanticallyRejected,
false,
Some(
"All artifacts rejected by semantic validation".to_string(),
),
);
return (
None,
ParseResultState::SemanticallyRejected,
Some(record),
);
}
let record = build_record(ParseResultState::StrictJsonOk, true, None);
return (Some(filtered), ParseResultState::StrictJsonOk, Some(record));
}
Err(reason) => {
let record = build_record(
ParseResultState::SemanticallyRejected,
false,
Some(reason),
);
return (None, ParseResultState::SemanticallyRejected, Some(record));
}
}
} else {
log::warn!("JSON bundle found but failed schema validation");
let record = build_record(
ParseResultState::SchemaInvalid,
false,
Some("JSON parsed but bundle schema validation failed".to_string()),
);
return (None, ParseResultState::SchemaInvalid, Some(record));
}
}
Err(e) => {
log::debug!("Normalization could not extract ArtifactBundle JSON: {}", e);
}
}
let markers = perspt_core::normalize::extract_file_markers(content);
if !markers.is_empty() {
let artifacts: Vec<ArtifactOperation> = markers
.into_iter()
.filter_map(|m| {
let path = m.path?;
if m.content.is_empty() {
return None;
}
if m.is_diff {
Some(ArtifactOperation::Diff {
path,
patch: m.content,
})
} else {
Some(ArtifactOperation::Write {
path,
content: m.content,
})
}
})
.collect();
if artifacts.is_empty() {
let record = build_record(
ParseResultState::NoStructuredPayload,
false,
Some("File markers found but no named artifacts extracted".to_string()),
);
return (None, ParseResultState::NoStructuredPayload, Some(record));
}
let bundle = ArtifactBundle {
artifacts,
commands: vec![],
};
let bundle = self.normalize_bundle_paths(bundle);
log::info!(
"Tolerant recovery extracted {}-artifact bundle via file markers",
bundle.len()
);
match self.semantic_validate_bundle(&bundle, node_id) {
Ok(filtered) => {
if filtered.artifacts.is_empty() {
let record = build_record(
ParseResultState::SemanticallyRejected,
false,
Some("All artifacts rejected by semantic validation".to_string()),
);
return (None, ParseResultState::SemanticallyRejected, Some(record));
}
let record = build_record(ParseResultState::TolerantRecoveryOk, true, None);
return (
Some(filtered),
ParseResultState::TolerantRecoveryOk,
Some(record),
);
}
Err(reason) => {
let record =
build_record(ParseResultState::SemanticallyRejected, false, Some(reason));
return (None, ParseResultState::SemanticallyRejected, Some(record));
}
}
}
let record = build_record(
ParseResultState::NoStructuredPayload,
false,
Some("No JSON bundle or file markers found in response".to_string()),
);
(None, ParseResultState::NoStructuredPayload, Some(record))
}
fn normalize_bundle_paths(
&self,
mut bundle: perspt_core::types::ArtifactBundle,
) -> perspt_core::types::ArtifactBundle {
bundle.artifacts = bundle
.artifacts
.into_iter()
.filter_map(|op| match op {
perspt_core::types::ArtifactOperation::Write { path, content } => {
match perspt_core::path::normalize_artifact_path(&path) {
Ok(normalized) => Some(perspt_core::types::ArtifactOperation::Write {
path: normalized,
content,
}),
Err(e) => {
log::warn!("Dropping write artifact with bad path '{}': {}", path, e);
None
}
}
}
perspt_core::types::ArtifactOperation::Diff { path, patch } => {
match perspt_core::path::normalize_artifact_path(&path) {
Ok(normalized) => Some(perspt_core::types::ArtifactOperation::Diff {
path: normalized,
patch,
}),
Err(e) => {
log::warn!("Dropping diff artifact with bad path '{}': {}", path, e);
None
}
}
}
perspt_core::types::ArtifactOperation::Delete { path } => {
match perspt_core::path::normalize_artifact_path(&path) {
Ok(normalized) => {
Some(perspt_core::types::ArtifactOperation::Delete { path: normalized })
}
Err(e) => {
log::warn!("Dropping delete artifact with bad path '{}': {}", path, e);
None
}
}
}
perspt_core::types::ArtifactOperation::Move { from, to } => {
let from_norm = perspt_core::path::normalize_artifact_path(&from);
let to_norm = perspt_core::path::normalize_artifact_path(&to);
match (from_norm, to_norm) {
(Ok(f), Ok(t)) => {
Some(perspt_core::types::ArtifactOperation::Move { from: f, to: t })
}
_ => {
log::warn!("Dropping move artifact with bad paths '{}'→'{}'", from, to);
None
}
}
}
})
.collect();
bundle
}
fn semantic_validate_bundle(
&self,
bundle: &perspt_core::types::ArtifactBundle,
node_id: &str,
) -> Result<perspt_core::types::ArtifactBundle, String> {
let allowed_paths = self.allowed_bundle_paths(node_id);
if allowed_paths.is_empty() {
return Ok(bundle.clone());
}
let registry = perspt_core::plugin::PluginRegistry::new();
let plugin_name = self
.node_indices
.get(node_id)
.map(|idx| self.graph[*idx].owner_plugin.as_str())
.unwrap_or("");
let plugin = registry.get(plugin_name);
let legal_support: std::collections::HashSet<String> = plugin
.map(|p| {
p.legal_support_files()
.iter()
.map(|s| s.to_string())
.collect()
})
.unwrap_or_default();
let (kept, dropped): (Vec<_>, Vec<_>) = bundle.artifacts.iter().cloned().partition(|a| {
let normalized = perspt_core::path::normalize_artifact_path(a.path())
.unwrap_or_else(|_| a.path().to_string());
if let Some(plugin) = plugin {
if Self::is_manifest_path(&normalized)
&& plugin.manifest_mutation_policy(&normalized)
== perspt_core::types::ManifestMutationPolicy::Deny
{
log::warn!(
"Rejecting manifest mutation '{}' by plugin policy for '{}'",
normalized,
plugin_name
);
return false;
}
}
if allowed_paths.contains(&normalized) {
return true;
}
let filename = std::path::Path::new(&normalized)
.file_name()
.map(|f| f.to_string_lossy().to_string())
.unwrap_or_default();
if legal_support.contains(&filename) {
log::info!(
"Accepting support file '{}' via plugin legal_support_files",
normalized
);
return true;
}
false
});
if !dropped.is_empty() {
let dropped_paths: Vec<String> = dropped.iter().map(|a| a.path().to_string()).collect();
log::warn!(
"Semantic validation stripped {} artifact(s) from node '{}': {}",
dropped.len(),
node_id,
dropped_paths.join(", ")
);
}
let mut validated_commands = Vec::new();
for cmd in &bundle.commands {
let decision = self
.node_indices
.get(node_id)
.and_then(|idx| {
let plugin_name = &self.graph[*idx].owner_plugin;
let registry = perspt_core::plugin::PluginRegistry::new();
registry
.get(plugin_name)
.map(|p| p.dependency_command_policy(cmd))
})
.unwrap_or(perspt_core::types::CommandPolicyDecision::Allow);
match decision {
perspt_core::types::CommandPolicyDecision::Allow => {
validated_commands.push(cmd.clone());
}
perspt_core::types::CommandPolicyDecision::RequireApproval => {
log::info!("Command '{}' requires approval — including with flag", cmd);
validated_commands.push(cmd.clone());
}
perspt_core::types::CommandPolicyDecision::Deny => {
log::warn!("Command '{}' denied by plugin policy", cmd);
}
}
}
Ok(perspt_core::types::ArtifactBundle {
artifacts: kept,
commands: validated_commands,
})
}
pub async fn apply_bundle_transactionally(
&mut self,
bundle: &perspt_core::types::ArtifactBundle,
node_id: &str,
node_class: perspt_core::types::NodeClass,
) -> Result<()> {
let idx =
self.node_indices.get(node_id).copied().ok_or_else(|| {
anyhow::anyhow!("Unknown node '{}' for bundle application", node_id)
})?;
let node_workdir = self.effective_working_dir(idx);
bundle.validate().map_err(|e| {
eprintln!(
"[SRBN-DIAG] Bundle validation failed for '{}': {}",
node_id, e
);
anyhow::anyhow!(e)
})?;
let filtered = self
.semantic_validate_bundle(bundle, node_id)
.map_err(|reason| {
anyhow::anyhow!(
"Bundle semantic validation failed for '{}': {}",
node_id,
reason
)
})?;
if filtered.artifacts.is_empty() && !bundle.artifacts.is_empty() {
let dropped_paths: Vec<String> = bundle
.artifacts
.iter()
.map(|a| a.path().to_string())
.collect();
eprintln!(
"[SRBN-DIAG] All artifacts stripped for '{}': {:?}",
node_id, dropped_paths
);
log::warn!(
"All artifacts stripped for node '{}' — skipping bundle application. \
Dropped paths: {}",
node_id,
dropped_paths.join(", ")
);
self.emit_log(format!(
"⚠️ All artifacts for '{}' targeted undeclared paths — bundle skipped. \
The actuator's output_files don't match the plan.",
node_id
));
return Err(anyhow::anyhow!(
"All {} artifact(s) targeted undeclared paths for node '{}': [{}]. \
Expected paths: {:?}",
bundle.artifacts.len(),
node_id,
dropped_paths.join(", "),
self.node_indices
.get(node_id)
.map(|idx| self.graph[*idx]
.output_targets
.iter()
.map(|p| p.to_string_lossy().to_string())
.collect::<Vec<_>>())
.unwrap_or_default()
));
}
let bundle = filtered;
if let Err(e) = self
.context
.ownership_manifest
.validate_bundle(&bundle, node_id, node_class)
{
log::warn!("Ownership validation warning for node '{}': {}", node_id, e);
self.emit_log(format!("⚠️ Ownership warning: {}", e));
}
let owner_plugin = self
.node_indices
.get(node_id)
.and_then(|idx| {
let plugin = &self.graph[*idx].owner_plugin;
if plugin.is_empty() {
None
} else {
Some(plugin.clone())
}
})
.unwrap_or_else(|| "unknown".to_string());
let mut files_created: Vec<String> = Vec::new();
let mut files_modified: Vec<String> = Vec::new();
let mut files_deleted: Vec<String> = Vec::new();
for op in &bundle.artifacts {
let mut args = HashMap::new();
let resolved_path = node_workdir.join(op.path());
args.insert(
"path".to_string(),
resolved_path.to_string_lossy().to_string(),
);
let call = match op {
perspt_core::types::ArtifactOperation::Write { content, .. } => {
args.insert("content".to_string(), content.clone());
ToolCall {
name: "write_file".to_string(),
arguments: args,
}
}
perspt_core::types::ArtifactOperation::Diff { patch, .. } => {
args.insert("diff".to_string(), patch.clone());
ToolCall {
name: "apply_diff".to_string(),
arguments: args,
}
}
perspt_core::types::ArtifactOperation::Delete { path } => {
if let Err(e) = perspt_policy::sanitize::validate_artifact_mutation(
path,
&self.context.working_dir,
"Delete",
) {
log::warn!("Delete blocked by policy: {}", e);
self.emit_log(format!("⚠️ Delete blocked: {}", e));
continue;
}
ToolCall {
name: "delete_file".to_string(),
arguments: args,
}
}
perspt_core::types::ArtifactOperation::Move { from, to } => {
if let Err(e) = perspt_policy::sanitize::validate_artifact_mutation(
from,
&self.context.working_dir,
"Move",
) {
log::warn!("Move source blocked by policy: {}", e);
self.emit_log(format!("⚠️ Move blocked: {}", e));
continue;
}
if let Err(e) = perspt_policy::sanitize::validate_artifact_mutation(
to,
&self.context.working_dir,
"Move",
) {
log::warn!("Move destination blocked by policy: {}", e);
self.emit_log(format!("⚠️ Move blocked: {}", e));
continue;
}
let resolved_to = node_workdir.join(to);
args.insert("from".to_string(), args["path"].clone());
args.insert("to".to_string(), resolved_to.to_string_lossy().to_string());
ToolCall {
name: "move_file".to_string(),
arguments: args,
}
}
};
let result = self.tools.execute(&call).await;
if result.success {
let full_path = resolved_path.clone();
if op.is_write() {
files_created.push(op.path().to_string());
} else if op.is_delete() {
files_deleted.push(op.path().to_string());
self.emit_event(perspt_core::AgentEvent::FileDeleted {
node_id: self.graph[idx].node_id.clone(),
path: op.path().to_string(),
});
} else if op.is_move() {
if let perspt_core::types::ArtifactOperation::Move { to, .. } = op {
files_modified.push(format!("{} -> {}", op.path(), to));
self.emit_event(perspt_core::AgentEvent::FileMoved {
node_id: self.graph[idx].node_id.clone(),
from: op.path().to_string(),
to: to.to_string(),
});
}
} else {
files_modified.push(op.path().to_string());
}
if !op.is_delete() {
self.last_written_file = Some(full_path.clone());
self.file_version += 1;
let registry = perspt_core::plugin::PluginRegistry::new();
for (lang, client) in self.lsp_clients.iter_mut() {
let should_notify = match registry.get(lang) {
Some(plugin) => plugin.owns_file(op.path()),
None => true,
};
if should_notify {
if let Ok(content) = std::fs::read_to_string(&full_path) {
let _ = client
.did_change(&full_path, &content, self.file_version)
.await;
}
}
}
}
log::info!("✓ Applied: {}", op.path());
self.emit_log(format!("✅ Applied: {}", op.path()));
} else {
log::warn!("Failed to apply {}: {:?}", op.path(), result.error);
self.emit_log(format!("❌ Failed: {} - {:?}", op.path(), result.error));
self.last_tool_failure = result.error.clone();
return Err(anyhow::anyhow!(
"Bundle application failed at {}: {:?}",
op.path(),
result.error
));
}
}
self.context.ownership_manifest.assign_new_paths(
&bundle,
node_id,
&owner_plugin,
node_class,
);
self.emit_event(perspt_core::AgentEvent::BundleApplied {
node_id: node_id.to_string(),
files_created,
files_modified,
writes_count: bundle.writes_count(),
diffs_count: bundle.diffs_count(),
node_class: node_class.to_string(),
});
self.last_tool_failure = None;
Ok(())
}
fn allowed_bundle_paths(&self, node_id: &str) -> std::collections::HashSet<String> {
self.node_indices
.get(node_id)
.map(|idx| {
self.graph[*idx]
.output_targets
.iter()
.map(|p| {
let raw = p.to_string_lossy();
perspt_core::path::normalize_artifact_path(&raw)
.unwrap_or_else(|_| raw.to_string())
})
.collect()
})
.unwrap_or_default()
}
fn is_manifest_path(path: &str) -> bool {
matches!(
std::path::Path::new(path)
.file_name()
.and_then(|name| name.to_str()),
Some("Cargo.toml" | "package.json" | "pyproject.toml" | "setup.py" | "setup.cfg")
)
}
}
#[cfg(test)]
mod tests {
use perspt_core::types::{ArtifactBundle, ArtifactOperation, ParseResultState};
#[test]
fn test_parse_result_state_is_ok() {
assert!(ParseResultState::StrictJsonOk.is_ok());
assert!(ParseResultState::TolerantRecoveryOk.is_ok());
assert!(!ParseResultState::NoStructuredPayload.is_ok());
assert!(!ParseResultState::SchemaInvalid.is_ok());
assert!(!ParseResultState::SemanticallyRejected.is_ok());
assert!(!ParseResultState::EmptyBundle.is_ok());
}
#[test]
fn test_strict_json_layer_c_valid_bundle() {
let json = r#"{"artifacts":[{"operation":"write","path":"src/main.rs","content":"fn main() {}"}],"commands":[]}"#;
let result = perspt_core::normalize::extract_and_deserialize::<ArtifactBundle>(json);
assert!(result.is_ok());
let (bundle, _method) = result.unwrap();
assert_eq!(bundle.artifacts.len(), 1);
assert!(bundle.validate().is_ok());
}
#[test]
fn test_strict_json_layer_c_invalid_schema() {
let json = r#"{"foo": "bar"}"#;
let result = perspt_core::normalize::extract_and_deserialize::<ArtifactBundle>(json);
assert!(result.is_err());
}
#[test]
fn test_tolerant_recovery_layer_d_file_markers() {
let response = r#"
Here is the implementation:
### File: src/main.rs
```rust
fn main() {
println!("Hello");
}
```
### File: src/lib.rs
```rust
pub fn greet() -> &'static str { "Hello" }
```
"#;
let markers = perspt_core::normalize::extract_file_markers(response);
assert_eq!(markers.len(), 2);
assert_eq!(markers[0].path, Some("src/main.rs".to_string()));
assert_eq!(markers[1].path, Some("src/lib.rs".to_string()));
assert!(!markers[0].is_diff);
}
#[test]
fn test_tolerant_recovery_layer_d_no_named_blocks() {
let response = "Here is some code:\n```rust\nfn foo() {}\n```\n";
let markers = perspt_core::normalize::extract_file_markers(response);
let named = markers.iter().filter(|m| m.path.is_some()).count();
assert_eq!(named, 0);
}
#[test]
fn test_path_normalization_layer_b() {
let normalized = perspt_core::path::normalize_artifact_path("`src/main.rs`").unwrap();
assert_eq!(normalized, "src/main.rs");
let normalized = perspt_core::path::normalize_artifact_path("'src/lib.rs'").unwrap();
assert_eq!(normalized, "src/lib.rs");
let normalized = perspt_core::path::normalize_artifact_path("**src/utils.rs**").unwrap();
assert_eq!(normalized, "src/utils.rs");
}
#[test]
fn test_empty_bundle_detection() {
let bundle = ArtifactBundle {
artifacts: vec![],
commands: vec![],
};
assert!(bundle.artifacts.is_empty());
}
#[test]
fn test_bundle_with_commands() {
let json = r#"{"artifacts":[{"operation":"write","path":"src/main.rs","content":"fn main() {}"}],"commands":["cargo add serde"]}"#;
let result = perspt_core::normalize::extract_and_deserialize::<ArtifactBundle>(json);
assert!(result.is_ok());
let (bundle, _) = result.unwrap();
assert_eq!(bundle.commands.len(), 1);
assert_eq!(bundle.commands[0], "cargo add serde");
}
#[test]
fn test_layer_d_diff_markers() {
let response = r#"
### Diff: src/main.rs
```diff
--- a/src/main.rs
+++ b/src/main.rs
@@ -1 +1 @@
-fn main() {}
+fn main() { println!("hello"); }
```
"#;
let markers = perspt_core::normalize::extract_file_markers(response);
assert!(!markers.is_empty());
let first = &markers[0];
assert_eq!(first.path, Some("src/main.rs".to_string()));
assert!(first.is_diff);
}
#[test]
fn test_no_structured_payload() {
let response = "I'm sorry, I can't help with that. Please try again.";
let json_result =
perspt_core::normalize::extract_and_deserialize::<ArtifactBundle>(response);
assert!(json_result.is_err());
let markers = perspt_core::normalize::extract_file_markers(response);
assert!(markers.is_empty());
}
#[test]
fn test_fenced_json_bundle_extraction() {
let response = r#"Here is the bundle:
```json
{"artifacts":[{"operation":"write","path":"src/main.rs","content":"fn main() {}"}],"commands":[]}
```
"#;
let result = perspt_core::normalize::extract_and_deserialize::<ArtifactBundle>(response);
assert!(result.is_ok());
}
#[test]
fn test_artifact_operation_paths() {
let write = ArtifactOperation::Write {
path: "src/main.rs".to_string(),
content: "fn main() {}".to_string(),
};
assert_eq!(write.path(), "src/main.rs");
let diff = ArtifactOperation::Diff {
path: "src/lib.rs".to_string(),
patch: "...".to_string(),
};
assert_eq!(diff.path(), "src/lib.rs");
}
}