Skip to content

Commit 9456b1d

Browse files
authored
fix(hooks): a fold does not make the signal partial (#780)
* fix(hooks): the partial banner is the distiller's claim, so read the distiller [Partial signal] says the pipeline recognised some of the output and not all of it. That is a statement about the distiller, and it was being read off a route computed from final_out, which the ledger has already folded. So a payload the distiller never touched could land in Soft on the strength of a fold and collect a banner over an answer nothing was lost from. Reproduced: 16 of 40 lines seen earlier, the fold announced with a handle, 24 lines verbatim, and [Partial signal] underneath. route is left alone. It describes the delivered reply and that is right for the accounting that reads it. Only the banner moves to the ratio it is about. * docs(changelog): record the partial-signal fix
1 parent cdb4b35 commit 9456b1d

2 files changed

Lines changed: 80 additions & 1 deletion

File tree

‎changelog.d/775b.fixed.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
- **A folded reply was labelled a partial signal (#775)**: `[Partial signal]` says the
2+
pipeline recognised some of the output and not all of it, which is a claim about the
3+
distiller, and it was being read off a route computed after the ledger folds. So a
4+
payload the distiller never touched could reach that route on the strength of a fold and
5+
carry the banner over an answer nothing was lost from: 16 of 40 lines folded with a
6+
handle, 24 verbatim, and the reply calling itself partial. The banner now reads the
7+
distiller's own ratio. The pipe door was checked and never had this: its ledger runs
8+
after the banner, so its ratio was already the distiller's.

‎src/hooks/post_tool.rs‎

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -945,7 +945,26 @@ pub fn process_payload(
945945
// partial is a false claim about a complete answer (#335). `Soft` is decided
946946
// on the byte ratio alone, and folding a repeated prefix shrinks bytes
947947
// without losing a line.
948-
if route == Route::Soft && distilled_lines <= content.lines().count() {
948+
//
949+
// Weighed on the distiller's own ratio, not the delivered one (#775). `route`
950+
// above is computed from `final_out`, which the ledger has already folded, so
951+
// a payload the distiller never touched can land in `Soft` on the strength of
952+
// a fold and collect a banner announcing partial recognition of an output
953+
// nothing was lost from. Reproduced with 16 of 40 lines already seen: the
954+
// reply carried `16 lines already shown` with a handle, 24 lines verbatim,
955+
// and `[Partial signal]` under an answer that was complete.
956+
//
957+
// The banner is the distiller's claim about its own reading, so it is the
958+
// distiller's ratio that decides whether it is made.
959+
let distiller_ratio = 1.0 - (distilled_len as f32 / content.len().max(1) as f32);
960+
let distiller_route = if distiller_ratio >= keep_threshold {
961+
Route::Keep
962+
} else if distiller_ratio >= soft_threshold {
963+
Route::Soft
964+
} else {
965+
Route::Passthrough
966+
};
967+
if distiller_route == Route::Soft && distilled_lines <= content.lines().count() {
949968
final_out.push_str("\n[Partial signal]\n");
950969
}
951970

@@ -1607,6 +1626,58 @@ mod tests {
16071626
assert_eq!(retrieval, None, "a retrieval must reach the agent verbatim");
16081627
}
16091628

1629+
/// #775, second half. `[Partial signal]` says the pipeline recognised some of
1630+
/// the output and not all of it, which is a claim about the distiller. The
1631+
/// route it was read from is computed after the ledger folds, so a payload
1632+
/// the distiller never touched could land in `Soft` on the strength of a fold
1633+
/// and collect the banner over an answer nothing was lost from.
1634+
///
1635+
/// Sixteen of forty lines seen earlier: the fold is announced with a handle,
1636+
/// the other twenty four are verbatim, and the reply used to end with a
1637+
/// banner calling that partial.
1638+
#[test]
1639+
fn a_fold_does_not_make_the_signal_partial() {
1640+
let dir = tempfile::tempdir().expect("tempdir");
1641+
let store = Arc::new(Store::open_path(&dir.path().join("omni.db")).expect("store"));
1642+
let lines: Vec<String> = (0..40)
1643+
.map(|i| {
1644+
format!(
1645+
"2026-09-04T10:{i:02}:00Z INFO worker {i} finished batch {} in {}ms",
1646+
i * 7,
1647+
11 + i
1648+
)
1649+
})
1650+
.collect();
1651+
let seen = format!("{}\n", lines[..16].join("\n"));
1652+
let whole = format!("{}\n", lines.join("\n"));
1653+
let payload = |cmd: &str, body: &str| {
1654+
serde_json::json!({
1655+
"session_id": "s-775b",
1656+
"tool_name": "Bash",
1657+
"tool_input": {"command": cmd},
1658+
"tool_response": {"stdout": body, "stderr": ""}
1659+
})
1660+
.to_string()
1661+
};
1662+
1663+
let _ = process_payload(
1664+
&payload("tail -16 app.log", &seen),
1665+
Some(store.clone()),
1666+
None,
1667+
);
1668+
let out = process_payload(&payload("cat app.log", &whole), Some(store.clone()), None)
1669+
.expect("a partial repeat is foldable");
1670+
1671+
assert!(
1672+
out.contains("already shown"),
1673+
"the fixture has to fold part of the reply or it proves nothing: {out}"
1674+
);
1675+
assert!(
1676+
!out.contains("[Partial signal]"),
1677+
"a complete answer was labelled partial because the ledger folded it: {out}"
1678+
);
1679+
}
1680+
16101681
/// #775. Two accountings of one payload landed in one blob and their sum
16111682
/// exceeded the input. The distiller cut a single byte from
16121683
/// `git log --oneline -40`, the ledger then folded the reply to a few lines,

0 commit comments

Comments
 (0)