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
61 changes: 60 additions & 1 deletion context-manager/src/core/estimate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,27 @@ impl Estimator for HeuristicEstimator {
}

fn message(&self, message: &AgentMessage) -> u64 {
let chars = serde_json::to_string(message).map(|s| s.len()).unwrap_or(0);
// Provider wire adapters send a function result's rendered `content`
// only — `details` never crosses the wire except inside the denied
// envelope (see provider-*/src/wire `format_function_result_content`).
// A fat details payload (file reads duplicate the whole file there)
// must not consume budget, or the effective window halves.
let chars = match message {
AgentMessage::FunctionResult { details, .. }
if !details.is_null()
&& details.get("status").and_then(serde_json::Value::as_str)
!= Some("denied") =>
{
let mut wire_view = message.clone();
if let AgentMessage::FunctionResult { details, .. } = &mut wire_view {
*details = serde_json::Value::Null;
}
serde_json::to_string(&wire_view)
.map(|s| s.len())
.unwrap_or(0)
}
_ => serde_json::to_string(message).map(|s| s.len()).unwrap_or(0),
};
(chars / 4) as u64
}

Expand Down Expand Up @@ -120,6 +140,45 @@ mod tests {
assert_eq!(HeuristicEstimator.text(""), 0);
}

#[test]
fn function_result_details_are_not_counted() {
// Live incident (2026-07-21, session scan-x7k2-c11-l2): 22 file reads
// carried the full file in BOTH content and details; the double bill
// inflated an ~88k wire request to 148_602 estimated > 148_000 usable
// and killed the turn terminally. Details never cross the provider
// wire, so they must not weigh in.
let est = HeuristicEstimator;
let fat = msg(json!({
"role": "function_result", "function_call_id": "c", "function_id": "f",
"content": [{ "type": "text", "text": "body" }],
"details": { "content": "x".repeat(4000) }, "timestamp": 3
}));
let null_details = msg(json!({
"role": "function_result", "function_call_id": "c", "function_id": "f",
"content": [{ "type": "text", "text": "body" }],
"details": null, "timestamp": 3
}));
assert_eq!(est.message(&fat), est.message(&null_details));
}

#[test]
fn denied_details_envelope_is_counted() {
// The denied envelope IS serialized into the wire body
// ([PERMISSION_DENIED] + JSON), so it keeps weighing in.
let est = HeuristicEstimator;
let denied = msg(json!({
"role": "function_result", "function_call_id": "c", "function_id": "f",
"content": [], "details": { "status": "denied", "reason": "x".repeat(400) },
"timestamp": 3
}));
let plain = msg(json!({
"role": "function_result", "function_call_id": "c", "function_id": "f",
"content": [], "details": { "status": "ok", "reason": "x".repeat(400) },
"timestamp": 3
}));
assert!(est.message(&denied) > est.message(&plain));
}

#[test]
fn by_role_partitions_every_role() {
let messages = vec![
Expand Down
11 changes: 11 additions & 0 deletions context-manager/src/functions/assemble.rs
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,17 @@ async fn try_compact(
let outcome = async {
let budget = preserve_recent_budget(usable_budget, None);
let selection = select(working, sizes, budget, tail_turns);
// Never compact the ENTIRE working set during assembly: an empty
// verbatim tail assembles into an empty messages array, which
// providers hard-reject ("messages: at least one message is
// required") and the harness turns into a terminal context_overflow.
// select() legally returns a whole-head selection for tail_turns == 0
// or a view with no user turn (e.g. a harness candidate window opened
// at a prior compaction's assistant-boundary tail_start); skip
// compaction and let emergency reduction do the shrinking.
if selection.head_len >= working.len() {
return None;
}
let head = &working[..selection.head_len];
if head.is_empty() {
return None;
Expand Down
20 changes: 20 additions & 0 deletions context-manager/tests/features/assemble.feature
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,26 @@ Feature: context::assemble — the model-ready context pipeline
And the response messages are not empty
And the response messages start at request message 2

# Prevents: a candidate window with NO user turn (the harness opens
# windows at a prior compaction's tail_start, which may be an assistant
# boundary) being summarised into an EMPTY model context. select()
# returns a whole-head selection for a user-less view; compaction must
# skip it and leave the shrinking to emergency reduction. Observed live
# 2026-07-21: turn failed "context::assemble returned an empty
# model-facing context".
Scenario: a user-less window is never compacted to empty
Given inline model "small" with context window 5000 and max output 500
And the summariser returns "## Goal\n- never used"
And an assistant function call "c1" to "coder::read-file"
And a function result for call "c1" from "coder::read-file" of ~6000 tokens
When I assemble the history with model "small"
Then the call succeeds
And the response field "applied.compacted" is false
And the response messages are not empty
And the response field "token_count" does not exceed 4000
And the summariser was invoked 0 times
And call/result pairing is intact in the response messages

# Prevents: both passes fighting instead of stacking — prune frees
# the tool output, compaction then folds the rest of the head.
Scenario: prune and compaction stack when one is not enough
Expand Down
9 changes: 7 additions & 2 deletions harness/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,12 @@ fn default_max_validation_retries() -> u32 {
2
}
fn default_max_transient_resumes() -> u32 {
1
// Provider overloads (Anthropic 529s) cluster in bursts, and only
// mid-stream errors burn this budget (pre-stream errors are retried
// with backoff inside llm-router). A budget of 1 killed a turn that
// was one generation step from done when a second overload landed
// (observed live 2026-07-21, session dcmcp-scan-p6w4-c-aq).
3
}
fn default_idem_ttl_secs() -> u64 {
86_400
Expand Down Expand Up @@ -335,7 +340,7 @@ mod tests {
assert_eq!(cfg.default_max_turns, 500);
assert_eq!(cfg.max_depth, 3);
assert_eq!(cfg.max_children, 8);
assert_eq!(cfg.max_transient_resumes, 1);
assert_eq!(cfg.max_transient_resumes, 3);
assert_eq!(cfg.sweep_expression, "0 0 0 * * *");
}

Expand Down
25 changes: 18 additions & 7 deletions harness/src/deferred.rs
Original file line number Diff line number Diff line change
Expand Up @@ -337,13 +337,20 @@ async fn find_call_arguments(
/// Resolve a parked parent call from a finishing child (harness.md §
/// Sub-agents). `completed` delivers the child's result; `failed`/`cancelled`
/// deliver an `is_error`.
///
/// Returns whether the parent turn actually consumed the resolution. A
/// fire-and-forget spawn settles its call `Done` at spawn time and the parent
/// turn has usually completed by the time the child finishes, so both
/// `resolve` gates return `not_resolved` — the caller must then pick another
/// channel if the outcome matters (see the child-failure notification in
/// `finalize_failed`).
pub async fn resolve_parent(
deps: &Deps,
parent: &crate::types::turn::ParentLink,
status: &str,
result: Option<&Value>,
reason: Option<&str>,
) {
) -> bool {
let (content, details, is_error) = if status == "completed" {
let text = result.map(render_text).unwrap_or_default();
(
Expand All @@ -369,12 +376,16 @@ pub async fn resolve_parent(
is_error: Some(is_error),
details: Some(details),
};
if let Err(e) = resolve(deps, req).await {
tracing::warn!(
parent_session = %parent.session_id,
error = %e,
"resolving parent call from child completion failed"
);
match resolve(deps, req).await {
Ok(resp) => resp.resolved,
Err(e) => {
tracing::warn!(
parent_session = %parent.session_id,
error = %e,
"resolving parent call from child completion failed"
);
false
}
}
}

Expand Down
3 changes: 2 additions & 1 deletion harness/src/subagent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,8 @@ pub fn spawned_result(child: &ChildIds) -> ResultData {
});
let text = format!(
"{ids}\nfire-and-forget: this turn will NOT receive the child's result; consume it via \
the triggers/state you registered."
the triggers/state you registered. If the child FAILS terminally, a [child-failure] \
message is delivered to this session automatically — no failure listener needed."
);
ResultData {
content: vec![ContentBlock::text(text)],
Expand Down
128 changes: 121 additions & 7 deletions harness/src/turn_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -464,13 +464,13 @@ pub async fn run_step(
)
.await;
}
// One-shot recovery: fold the measured overshoot (plus margin for
// hook variance on the retry — hooks re-run against the smaller
// context) into the reservation and re-assemble. The compaction
// bookkeeping entry id is per (turn, step), so a re-assembled
// compaction dedupes instead of double-writing.
// One-shot recovery: fold the measured post-assembly additions (plus
// margin for hook variance on the retry — hooks re-run against the
// smaller context) into the reservation and re-assemble. The
// compaction bookkeeping entry id is per (turn, step), so a
// re-assembled compaction dedupes instead of double-writing.
reassembled = true;
let deficit = final_request_tokens - assembled.usable;
let deficit = reassembly_deficit(final_request_tokens, assembled.token_count);
extra_overhead_tokens = extra_overhead_tokens
.saturating_add(deficit)
.saturating_add(REASSEMBLY_HEADROOM_MARGIN_TOKENS);
Expand Down Expand Up @@ -1323,14 +1323,25 @@ async fn finalize_failed(
)
.await;
if let Some(parent) = record.parent.clone() {
crate::deferred::resolve_parent(
let delivered = crate::deferred::resolve_parent(
deps,
&parent,
"failed",
record.result.as_ref(),
Some(reason),
)
.await;
// Fire-and-forget spawns settle their call `Done` at spawn time and
// the parent turn has usually completed by now, so the resolve above
// no-ops — and a dead child can never write the state keys or
// completion markers its spawner is watching. Wake the parent SESSION
// with the failure instead: spawned-child death must be loud by
// default, not structurally silent (observed live 2026-07-21: two
// spawned scanners died instantly and their coordinator waited
// forever on state triggers that could never fire).
if !delivered {
notify_parent_of_child_failure(deps, &parent.session_id, record, reason, failure).await;
}
}
Ok(TurnStepResult {
session_id: record.session_id.clone(),
Expand All @@ -1340,6 +1351,61 @@ async fn finalize_failed(
})
}

/// Best-effort child-failure wake-up: send a user-visible failure notice to
/// the spawner's session, starting (or steering) a turn there so the model
/// can respawn, reroute, or report. Idempotent per child turn.
async fn notify_parent_of_child_failure(
deps: &Deps,
parent_session_id: &str,
record: &TurnRecord,
reason: &str,
failure: FailureInfo,
) {
let notice = child_failure_notice(
&record.session_id,
&record.turn_id,
failure.code,
failure.retryable,
reason,
);
let req = crate::functions::send::SendRequest {
session_id: Some(parent_session_id.to_string()),
message: crate::functions::send::MessageInput::Text(notice),
model: None,
provider: None,
idempotency_key: Some(format!("child_failure_{}", record.turn_id)),
session: None,
options: None,
};
if let Err(e) = crate::functions::send::handle(deps, req).await {
tracing::warn!(
parent_session = %parent_session_id,
child_session = %record.session_id,
child_turn = %record.turn_id,
error = %e,
"child-failure notification to the parent session failed"
);
}
}

/// The wake-up text the spawner's model sees when a spawned child dies
/// terminally without a parked parent call to deliver into.
fn child_failure_notice(
child_session_id: &str,
child_turn_id: &str,
code: &str,
retryable: bool,
reason: &str,
) -> String {
format!(
"[child-failure] Spawned child session '{child_session_id}' (turn {child_turn_id}) \
FAILED terminally [{code}] and will deliver no result: {reason} (retryable: \
{retryable}). Any state keys or completion markers that child was expected to write \
will never arrive — stop waiting on them. Recover now: respawn the work, reassign \
it, or report the failure."
)
}

fn llm_failure_info(error_kind: Option<ErrorKind>) -> FailureInfo {
match error_kind {
Some(ErrorKind::AuthExpired) => FailureInfo {
Expand Down Expand Up @@ -1984,6 +2050,17 @@ fn final_request_unchanged(
!hook_appended && patched == 0 && gen_system_prompt == assembled_system_prompt
}

/// The reservation fold for the one-shot re-assembly: everything the final
/// request grew beyond what assembly believed it built (`token_count`), NOT
/// merely the overshoot past `usable`. Post-assembly additions can dwarf both
/// the up-front allowance and the ceiling overshoot (fp::inject-guidance
/// appends ~2.5k tokens vs the 256-token allowance); folding only the
/// overshoot can leave assembly under its own ceiling, so it rebuilds the
/// identical request and the turn dies terminally on the same count.
fn reassembly_deficit(final_request_tokens: u64, believed_token_count: u64) -> u64 {
final_request_tokens.saturating_sub(believed_token_count)
}

fn patch_orphaned_calls(messages: &mut Vec<Value>) -> usize {
let mut resolved: std::collections::HashSet<String> = std::collections::HashSet::new();
for m in messages.iter() {
Expand Down Expand Up @@ -2194,6 +2271,43 @@ mod tests {
assert!(!super::final_request_unchanged(false, 0, &None, &prompt));
}

#[test]
fn child_failure_notice_names_the_child_and_the_consequence() {
// Live incident (2026-07-21, console-42ae032b): two fire-and-forget
// spawned scanners failed instantly; their coordinator had only
// success-path state triggers and waited forever. The wake-up notice
// must name the child, the failure code, and the key consequence —
// the child's completion markers will never arrive.
let notice = super::child_failure_notice(
"dcmcp-scan-a2k9",
"t_6d37d87e",
"llm.permanent",
false,
"model not found: gpt-4o-mini",
);
assert!(notice.contains("dcmcp-scan-a2k9"));
assert!(notice.contains("t_6d37d87e"));
assert!(notice.contains("[llm.permanent]"));
assert!(notice.contains("retryable: false"));
assert!(notice.contains("will never arrive"));
assert!(notice.contains("model not found: gpt-4o-mini"));
}

#[test]
fn reassembly_deficit_covers_post_assembly_additions_not_just_the_overshoot() {
// Live incident (2026-07-21, session scan-x7k2-c11-l2): assembly
// measured ~146_050 under a 148_000 ceiling, the guidance hook
// appended ~2_552 tokens, final count 148_602. The old
// ceiling-relative fold (148_602 - 148_000 = 602) left re-assembly
// a no-op — assembly stayed under its own ceiling, rebuilt the
// identical request, and the turn died terminally on the same
// count. The believed-relative fold reserves the full addition.
assert_eq!(super::reassembly_deficit(148_602, 146_050), 2_552);
assert!(super::reassembly_deficit(148_602, 146_050) > 148_602 - 148_000);
// Saturates if the final request came out smaller than believed.
assert_eq!(super::reassembly_deficit(100, 200), 0);
}

#[test]
fn context_overflow_classification_requires_the_stable_context_code() {
assert!(super::is_context_overflow_error(
Expand Down
7 changes: 5 additions & 2 deletions harness/src/types/turn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,10 @@ fn default_max_validation_retries() -> u32 {
}

fn default_max_transient_resumes() -> u32 {
1
// Keep in lockstep with `config::default_max_transient_resumes` —
// overload bursts cluster; a budget of 1 dies on the second
// mid-stream 529 in a turn.
3
}

impl TurnOptions {
Expand Down Expand Up @@ -413,7 +416,7 @@ mod tests {
assert!(!r.abort);
assert!(r.calls.is_empty());
assert_eq!(r.options.max_validation_retries, 2);
assert_eq!(r.options.max_transient_resumes, 1);
assert_eq!(r.options.max_transient_resumes, 3);
assert_eq!(r.transient_resumes, 0);
assert_eq!(r.options.output, OutputContract::Text);
}
Expand Down
Loading
Loading