Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 15 additions & 15 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ members = ["crates/*"]
resolver = "2"

[workspace.package]
version = "0.1.106"
version = "0.1.107"
edition = "2024"
rust-version = "1.85"
license = "Apache-2.0"
Expand Down
142 changes: 141 additions & 1 deletion crates/cli-sub-agent/src/pipeline_result_contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,28 @@ pub(crate) fn enforce_result_toml_path_contract(
return;
}

// Disk-based fallback: the agent wrote result.toml to session_dir but the
// path was not found in output/summary (e.g. verbose output truncated the
// path, or ACP message boundaries split it). Accept the file if it exists,
// passes validation, and contains valid TOML.
if session_result_fallback_is_valid(&expected_path) {
let warning = format!(
"contract warning: output/summary path not found; accepted verified session result '{}'",
expected_path.display()
);
warn!(
summary = %result.summary,
fallback = %expected_path.display(),
"Session output path not in output/summary; accepting verified session-dir result.toml fallback"
);
if !result.stderr_output.is_empty() && !result.stderr_output.ends_with('\n') {
result.stderr_output.push('\n');
}
result.stderr_output.push_str(&warning);
result.stderr_output.push('\n');
return;
}

let reason = if path_candidate.is_empty() {
format!(
"contract violation: expected existing absolute result path '{}' or '{}' in output/summary, but output and summary were empty",
Expand Down Expand Up @@ -106,6 +128,7 @@ fn prompt_requires_result_toml_path(prompt: &str) -> bool {
}

fn contract_result_toml_path_candidate(result: &ExecutionResult) -> &str {
// 1. Whole-line match (exact path on its own line).
let output_path_candidate = result
.output
.lines()
Expand All @@ -116,11 +139,24 @@ fn contract_result_toml_path_candidate(result: &ExecutionResult) -> &str {
return candidate;
}

// 2. Summary as path.
let summary_candidate = normalize_contract_path_candidate(&result.summary);
if !summary_candidate.is_empty() {
if !summary_candidate.is_empty() && line_looks_like_result_toml_path(summary_candidate) {
return summary_candidate;
}

// 3. Embedded path extraction: scan output lines for an absolute result.toml
// path that appears as a substring within a longer line.
if let Some(embedded) = extract_embedded_result_toml_path(&result.output) {
return embedded;
}

// 4. Embedded path in summary.
if let Some(embedded) = extract_embedded_result_toml_path(&result.summary) {
return embedded;
}

// 5. Last non-empty output line (legacy fallback).
result
.output
.lines()
Expand All @@ -130,6 +166,91 @@ fn contract_result_toml_path_candidate(result: &ExecutionResult) -> &str {
.unwrap_or("")
}

/// Extract an embedded absolute `result.toml` or `user-result.toml` path from
/// text that may contain the path as a substring within a longer line.
///
/// Scans each line for a `/` character that begins an absolute path ending with
/// `result.toml` or `user-result.toml`, stripping surrounding quotes/backticks.
fn extract_embedded_result_toml_path(text: &str) -> Option<&str> {
for line in text.lines().rev() {
if let Some(path) = find_result_toml_path_in_line(line) {
return Some(path);
}
}
None
}

/// Find an absolute result.toml path embedded anywhere in a single line.
/// Returns the longest matching substring that starts with `/` and ends with
/// `result.toml` or `user-result.toml`.
fn find_result_toml_path_in_line(line: &str) -> Option<&str> {
const SUFFIXES: &[&str] = &["result.toml", "user-result.toml"];

for suffix in SUFFIXES {
// Search from the end to prefer the last occurrence.
let mut search_from = line.len();
while let Some(end_pos) = line[..search_from].rfind(suffix) {
let candidate_end = end_pos + suffix.len();
// Walk backwards from end_pos to find the start `/`.
// The path must start with `/` and contain only path-legal characters.
if let Some(start) = find_absolute_path_start(&line[..end_pos]) {
let raw = &line[start..candidate_end];
let cleaned = raw.trim_matches(|c: char| c == '"' || c == '`' || c == '\'');
let path = Path::new(cleaned);
if path.is_absolute()
&& path.file_name().and_then(|n| n.to_str()).is_some_and(|n| {
n.eq_ignore_ascii_case("result.toml")
|| n.eq_ignore_ascii_case("user-result.toml")
})
{
return Some(cleaned);
}
}
// Continue searching before this occurrence.
search_from = end_pos;
if search_from == 0 {
break;
}
}
}
None
}

/// Find the start index of an absolute path that ends at `before_suffix`.
/// Walks backwards from the end to find a `/` that begins the path,
/// skipping only characters valid in Unix paths.
fn find_absolute_path_start(before_suffix: &str) -> Option<usize> {
// Walk backwards to find the leading `/` of the absolute path.
// Path characters: anything except whitespace and certain delimiters.
let bytes = before_suffix.as_bytes();
let mut i = bytes.len();
while i > 0 {
i -= 1;
let c = bytes[i];
// Stop at whitespace or common non-path delimiters, but allow the
// path to start with `/` preceded by whitespace.
if c == b'/' {
// Check if this is the root `/` (preceded by start-of-string,
// whitespace, quote, or backtick).
if i == 0
|| matches!(
bytes[i - 1],
b' ' | b'\t' | b'"' | b'\'' | b'`' | b'(' | b'[' | b'{'
)
{
return Some(i);
}
// Otherwise it's a path separator within the path, keep going.
continue;
}
if c.is_ascii_whitespace() || c == b'(' || c == b'[' || c == b'{' {
// The path starts after this delimiter.
return None;
}
}
None
}

fn path_matches_expected_contract_result(path_candidate: &str, expected_path: &Path) -> bool {
let path = Path::new(path_candidate);
path == expected_path && expected_contract_file_is_valid(expected_path)
Expand Down Expand Up @@ -181,6 +302,25 @@ fn user_result_fallback_is_valid(path: &Path) -> bool {
)
}

/// Validates session-dir result.toml as a disk-based fallback when the path
/// could not be extracted from output/summary. Applies the same validation as
/// user-result fallback: file must exist, not be a symlink, have nlink==1,
/// and contain valid non-empty TOML.
fn session_result_fallback_is_valid(path: &Path) -> bool {
if !expected_contract_file_is_valid(path) {
return false;
}

let Ok(contents) = std::fs::read_to_string(path) else {
return false;
};

matches!(
toml::from_str::<toml::Value>(&contents),
Ok(toml::Value::Table(table)) if !table.is_empty()
)
}

fn line_looks_like_result_toml_path(line: &str) -> bool {
let candidate = normalize_contract_path_candidate(line);
let path = Path::new(candidate);
Expand Down
3 changes: 3 additions & 0 deletions crates/cli-sub-agent/src/pipeline_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -772,3 +772,6 @@ fn enforce_result_toml_contract_now(

#[path = "pipeline_tests_tail.rs"]
mod tail_tests;

#[path = "pipeline_tests_contract.rs"]
mod contract_tests;
Loading
Loading