Skip to content

Commit c0782c5

Browse files
fix(pipeline): robust contract violation parsing for verbose agent output (#384)
Add disk-based result.toml fallback and embedded path extraction to handle cases where employee tools produce verbose multi-line output that prevents clean path extraction from stdout. Two-pronged fix: - session_result_fallback_is_valid() validates session-dir result.toml on disk when output parsing fails (symlink + nlink + TOML checks) - extract_embedded_result_toml_path() scans output lines for absolute paths ending in result.toml even when surrounded by other text Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 1475710 commit c0782c5

7 files changed

Lines changed: 343 additions & 54 deletions

File tree

Cargo.lock

Lines changed: 15 additions & 15 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ members = ["crates/*"]
33
resolver = "2"
44

55
[workspace.package]
6-
version = "0.1.106"
6+
version = "0.1.107"
77
edition = "2024"
88
rust-version = "1.85"
99
license = "Apache-2.0"

crates/cli-sub-agent/src/pipeline_result_contract.rs

Lines changed: 141 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,28 @@ pub(crate) fn enforce_result_toml_path_contract(
7070
return;
7171
}
7272

73+
// Disk-based fallback: the agent wrote result.toml to session_dir but the
74+
// path was not found in output/summary (e.g. verbose output truncated the
75+
// path, or ACP message boundaries split it). Accept the file if it exists,
76+
// passes validation, and contains valid TOML.
77+
if session_result_fallback_is_valid(&expected_path) {
78+
let warning = format!(
79+
"contract warning: output/summary path not found; accepted verified session result '{}'",
80+
expected_path.display()
81+
);
82+
warn!(
83+
summary = %result.summary,
84+
fallback = %expected_path.display(),
85+
"Session output path not in output/summary; accepting verified session-dir result.toml fallback"
86+
);
87+
if !result.stderr_output.is_empty() && !result.stderr_output.ends_with('\n') {
88+
result.stderr_output.push('\n');
89+
}
90+
result.stderr_output.push_str(&warning);
91+
result.stderr_output.push('\n');
92+
return;
93+
}
94+
7395
let reason = if path_candidate.is_empty() {
7496
format!(
7597
"contract violation: expected existing absolute result path '{}' or '{}' in output/summary, but output and summary were empty",
@@ -106,6 +128,7 @@ fn prompt_requires_result_toml_path(prompt: &str) -> bool {
106128
}
107129

108130
fn contract_result_toml_path_candidate(result: &ExecutionResult) -> &str {
131+
// 1. Whole-line match (exact path on its own line).
109132
let output_path_candidate = result
110133
.output
111134
.lines()
@@ -116,11 +139,24 @@ fn contract_result_toml_path_candidate(result: &ExecutionResult) -> &str {
116139
return candidate;
117140
}
118141

142+
// 2. Summary as path.
119143
let summary_candidate = normalize_contract_path_candidate(&result.summary);
120-
if !summary_candidate.is_empty() {
144+
if !summary_candidate.is_empty() && line_looks_like_result_toml_path(summary_candidate) {
121145
return summary_candidate;
122146
}
123147

148+
// 3. Embedded path extraction: scan output lines for an absolute result.toml
149+
// path that appears as a substring within a longer line.
150+
if let Some(embedded) = extract_embedded_result_toml_path(&result.output) {
151+
return embedded;
152+
}
153+
154+
// 4. Embedded path in summary.
155+
if let Some(embedded) = extract_embedded_result_toml_path(&result.summary) {
156+
return embedded;
157+
}
158+
159+
// 5. Last non-empty output line (legacy fallback).
124160
result
125161
.output
126162
.lines()
@@ -130,6 +166,91 @@ fn contract_result_toml_path_candidate(result: &ExecutionResult) -> &str {
130166
.unwrap_or("")
131167
}
132168

169+
/// Extract an embedded absolute `result.toml` or `user-result.toml` path from
170+
/// text that may contain the path as a substring within a longer line.
171+
///
172+
/// Scans each line for a `/` character that begins an absolute path ending with
173+
/// `result.toml` or `user-result.toml`, stripping surrounding quotes/backticks.
174+
fn extract_embedded_result_toml_path(text: &str) -> Option<&str> {
175+
for line in text.lines().rev() {
176+
if let Some(path) = find_result_toml_path_in_line(line) {
177+
return Some(path);
178+
}
179+
}
180+
None
181+
}
182+
183+
/// Find an absolute result.toml path embedded anywhere in a single line.
184+
/// Returns the longest matching substring that starts with `/` and ends with
185+
/// `result.toml` or `user-result.toml`.
186+
fn find_result_toml_path_in_line(line: &str) -> Option<&str> {
187+
const SUFFIXES: &[&str] = &["result.toml", "user-result.toml"];
188+
189+
for suffix in SUFFIXES {
190+
// Search from the end to prefer the last occurrence.
191+
let mut search_from = line.len();
192+
while let Some(end_pos) = line[..search_from].rfind(suffix) {
193+
let candidate_end = end_pos + suffix.len();
194+
// Walk backwards from end_pos to find the start `/`.
195+
// The path must start with `/` and contain only path-legal characters.
196+
if let Some(start) = find_absolute_path_start(&line[..end_pos]) {
197+
let raw = &line[start..candidate_end];
198+
let cleaned = raw.trim_matches(|c: char| c == '"' || c == '`' || c == '\'');
199+
let path = Path::new(cleaned);
200+
if path.is_absolute()
201+
&& path.file_name().and_then(|n| n.to_str()).is_some_and(|n| {
202+
n.eq_ignore_ascii_case("result.toml")
203+
|| n.eq_ignore_ascii_case("user-result.toml")
204+
})
205+
{
206+
return Some(cleaned);
207+
}
208+
}
209+
// Continue searching before this occurrence.
210+
search_from = end_pos;
211+
if search_from == 0 {
212+
break;
213+
}
214+
}
215+
}
216+
None
217+
}
218+
219+
/// Find the start index of an absolute path that ends at `before_suffix`.
220+
/// Walks backwards from the end to find a `/` that begins the path,
221+
/// skipping only characters valid in Unix paths.
222+
fn find_absolute_path_start(before_suffix: &str) -> Option<usize> {
223+
// Walk backwards to find the leading `/` of the absolute path.
224+
// Path characters: anything except whitespace and certain delimiters.
225+
let bytes = before_suffix.as_bytes();
226+
let mut i = bytes.len();
227+
while i > 0 {
228+
i -= 1;
229+
let c = bytes[i];
230+
// Stop at whitespace or common non-path delimiters, but allow the
231+
// path to start with `/` preceded by whitespace.
232+
if c == b'/' {
233+
// Check if this is the root `/` (preceded by start-of-string,
234+
// whitespace, quote, or backtick).
235+
if i == 0
236+
|| matches!(
237+
bytes[i - 1],
238+
b' ' | b'\t' | b'"' | b'\'' | b'`' | b'(' | b'[' | b'{'
239+
)
240+
{
241+
return Some(i);
242+
}
243+
// Otherwise it's a path separator within the path, keep going.
244+
continue;
245+
}
246+
if c.is_ascii_whitespace() || c == b'(' || c == b'[' || c == b'{' {
247+
// The path starts after this delimiter.
248+
return None;
249+
}
250+
}
251+
None
252+
}
253+
133254
fn path_matches_expected_contract_result(path_candidate: &str, expected_path: &Path) -> bool {
134255
let path = Path::new(path_candidate);
135256
path == expected_path && expected_contract_file_is_valid(expected_path)
@@ -181,6 +302,25 @@ fn user_result_fallback_is_valid(path: &Path) -> bool {
181302
)
182303
}
183304

305+
/// Validates session-dir result.toml as a disk-based fallback when the path
306+
/// could not be extracted from output/summary. Applies the same validation as
307+
/// user-result fallback: file must exist, not be a symlink, have nlink==1,
308+
/// and contain valid non-empty TOML.
309+
fn session_result_fallback_is_valid(path: &Path) -> bool {
310+
if !expected_contract_file_is_valid(path) {
311+
return false;
312+
}
313+
314+
let Ok(contents) = std::fs::read_to_string(path) else {
315+
return false;
316+
};
317+
318+
matches!(
319+
toml::from_str::<toml::Value>(&contents),
320+
Ok(toml::Value::Table(table)) if !table.is_empty()
321+
)
322+
}
323+
184324
fn line_looks_like_result_toml_path(line: &str) -> bool {
185325
let candidate = normalize_contract_path_candidate(line);
186326
let path = Path::new(candidate);

crates/cli-sub-agent/src/pipeline_tests.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -772,3 +772,6 @@ fn enforce_result_toml_contract_now(
772772

773773
#[path = "pipeline_tests_tail.rs"]
774774
mod tail_tests;
775+
776+
#[path = "pipeline_tests_contract.rs"]
777+
mod contract_tests;

0 commit comments

Comments
 (0)