fix(tui): learn the image cap from a refused summary request and from a stream error frame

An adversarial review of f0913c3f62 (Refs CW-7235) returned fix-first.
Its stub-provider probes showed two paths where a count refusal was never
learned, so the headline state ("permanently unsendable") was still
reachable. This commit fixes those two and the two low findings.

1. Emergency-compaction summary request (high). recover_context_overflow
   sends the summary request with every image. The provider refused it,
   the turn ended in preparation.rs before model_step ran, and every later
   turn rebuilt the same refused request. learn_summary_image_cap recounts
   the images the summary request carried (session messages after the same
   strip and cap the request applies) and calls learn_image_cap. The
   context recovery then returns true, so the preflight loop retries
   compaction under the learned cap. MAX_CONTEXT_RECOVERY_ATTEMPTS bounds
   this, and each learned cap is strictly smaller. The automatic and manual
   /compact failure branches also learn the cap for the next attempt.
2. Chat Completions SSE error frame (medium). A `data: {"error":...}`
   frame inside a 200 reaches the StreamEvent::Error arm, which never
   consulted image_count_rejection. That arm now learns the cap exactly
   as the process_stream Err arm does: terminal for this request, an
   image_count_exceeded envelope, and no resume.
3. Parser (low).
   - The clause is bounded on both sides, at '\n', '}', '"' and ';' after
     the phrase, and also at ';' before it.
   - A number followed by a unit (mb, kb, gb, px, pixel, token, byte, %) or
     by '>' is not a limit.
   - When no marker matches, ": N" and " is N" are read within the phrase's
     own sentence.
   - "a maximum of N images" and "images can be provided" are recognized.
   - "image limit" is deliberately not added: it would turn image-size
     refusals into count refusals.
4. docs/AGENT_RUNTIME.md and its zh_hans twin list the real exec
   stream-json types: agent_spawned, service_released, status and
   request_image_window were missing.

CHANGELOG [Unreleased]: the summary-request learning, and one more known
limit: the context-budget estimate still counts every stored image,
including those sent as notes. The stub probe below shows that a 22k
window still trips context recovery for that reason, after the refused
summary was learned and resent with 3 images. That estimate predates this
work and is not a count-refusal path.

Verification (source: f0913c3f62 plus this diff)
- The reviewer's probes, rerun against target/debug/codewhale built from
  this tree:
  - preflight_probe2.py (seeded 16-image session, 4 grown turns,
    context_window 22000, stub refuses more than 3 images). resume2:
    request 1 was the summary, 400 with 16 images; request 2 was the
    summary, 200 with 3 images and 1 note. Before: 400 with 16 images on
    every resume, and no second request.
  - midprobe.py (refusal as an SSE error frame): 1 request, then stderr
    "request_images: model probe-model refused 6 image(s) in one request
    (stated limit None); learned cap 3". Before: no request_images line.
- The implementer's probes (probe-impl/runner.py) are unchanged:
  - over_limit: 6 then 3 images.
  - zero_limit: 2 then 0 images.
  - unstated_exhausted: 16, 8, 4, 2, then image_count_exceeded.
  - No consecutive identical request bodies.
- Parser harness (the reviewer's 29 wordings plus 2 decoys), old against
  new. 0 regressions. Newly read: "Max images per request: 5" as 5,
  "Only up to 4 images ..." as 4, "... per request is 8" as 8, and "A
  maximum of 5 images can be provided ..." as 5. "max 20MB per request;
  too many images" now reads no limit (it was 20). The decoys "request
  id: 123" and a trailing URL ": 99" are not read. Every negative (context
  length, content policy, 5 MB size, pixel dimensions, IPM rate limit)
  still returns None.
- scripts/dev-test.sh tui image_attach llm_client error_taxonomy
  compaction preview turn_loop model_step context runtime_threads
  protocol_parity subagent exec_agent commands::tests: 2280 tests run:
  2280 passed, 13071 skipped. scripts/dev-test.sh tui debug_change, after
  the last CHANGELOG edit: 39 passed.
- scripts/dev-cargo.sh clippy --workspace --all-targets --all-features
  --locked -- -D warnings -A clippy::uninlined_format_args
  -A clippy::too_many_arguments -A clippy::unnecessary_map_or: exit 0, no
  warnings. cargo fmt --all -- --check: clean.
- npm test: root 101/101, runtime-sdk 19/19, extension-host 420 (413
  pass, 7 skipped), web 7/7, vitest 772/772. npm run check:web: exit 0.
  npm --prefix web run prebuild: no generated change.
- sync-changelog.sh --check: up to date. check-versions.sh
  --require-dated-release: OK.

Not verified: a real provider; the Anthropic and Responses wires; a child
engine hitting an error frame; the manual /compact learning (code path
only).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NBKbhx19BRbZtvWK8rVKjz
This commit is contained in:
CodeWhale BotandClaude Opus 5.5 committed 2026-10-16 13:59:42 -07:00
1 parent f0913c3f62
commit 9d567ebd2a
7 files changed
+110 -22

No files matched your search

+8 -3
View File
@@ -28,15 +28,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
(runtime threads store it as a status item with that code, `exec
--output-format stream-json` prints it and the TUI shows it), and
`/preview-request` and the compaction summary request apply the same
window. A sub-agent no longer retries a count rejection as a transient
window. A summary request refused for its image count teaches the limit
too, and the context recovery that sent it retries under that limit. A
sub-agent no longer retries a count rejection as a transient
failure. Known limits: nothing changes until a provider has refused once,
and no documented per-model limits are applied; the learned limit is kept
in memory per engine and per sub-agent, so a restart learns it again with
one refused request; the oldest images go first, user attachments
included; a refusal that arrives mid-stream ends that request and only the
next send uses the limit; a gateway that reports the refusal as a 5xx is
retried unchanged by the transport first; and a request-body size (HTTP
413) refusal on an ordinary turn still has no recovery (Refs CW-7235).
retried unchanged by the transport first; a request-body size (HTTP
413) refusal on an ordinary turn still has no recovery; and the context
budget estimate still counts every stored image, including those the
window sends as notes, so a small context window can still trip context
recovery (Refs CW-7235).
- Switching provider is now refused while a turn is running, as model,
thinking and permission changes already were. The refusal covers
`/provider <id>`, the provider picker, a custom provider, setup and login
+8 -3
View File
@@ -28,15 +28,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
(runtime threads store it as a status item with that code, `exec
--output-format stream-json` prints it and the TUI shows it), and
`/preview-request` and the compaction summary request apply the same
window. A sub-agent no longer retries a count rejection as a transient
window. A summary request refused for its image count teaches the limit
too, and the context recovery that sent it retries under that limit. A
sub-agent no longer retries a count rejection as a transient
failure. Known limits: nothing changes until a provider has refused once,
and no documented per-model limits are applied; the learned limit is kept
in memory per engine and per sub-agent, so a restart learns it again with
one refused request; the oldest images go first, user attachments
included; a refusal that arrives mid-stream ends that request and only the
next send uses the limit; a gateway that reports the refusal as a 5xx is
retried unchanged by the transport first; and a request-body size (HTTP
413) refusal on an ordinary turn still has no recovery (Refs CW-7235).
retried unchanged by the transport first; a request-body size (HTTP
413) refusal on an ordinary turn still has no recovery; and the context
budget estimate still counts every stored image, including those the
window sends as notes, so a small context window can still trip context
recovery (Refs CW-7235).
- Switching provider is now refused while a turn is running, as model,
thinking and permission changes already were. The refusal covers
`/provider <id>`, the provider picker, a custom provider, setup and login
+31
View File
@@ -219,6 +219,26 @@ impl Engine {
))
}
fn learn_summary_image_cap(
&mut self,
prepared: &PreparedCompactionEnvelope,
err: &anyhow::Error,
) -> Option<usize> {
let rejection =
crate::llm_client::image_count_rejection(&format!("{err:#}").to_lowercase())?;
let mut messages = self.session.messages.to_vec();
crate::image_attach::strip_images_when_unsupported(
&mut messages,
prepared.config.image_input,
&prepared.config.model,
);
if let Some(cap) = prepared.image_cap {
crate::image_attach::cap_request_images(&mut messages, cap);
}
let sent = crate::image_attach::count_request_images(&messages);
self.learn_image_cap(&prepared.config.model, sent, rejection)
}
pub(super) fn prepare_compaction_envelope(
&self,
mut config: CompactionConfig,
@@ -471,6 +491,7 @@ impl Engine {
}
}
Err(err) => {
self.learn_summary_image_cap(&prepared, &err);
let message = crate::compaction::report_compaction_failure(
"Making room failed",
&id,
@@ -743,6 +764,7 @@ impl Engine {
}
Err(err) => {
*auto_compaction_suppressed = true;
self.learn_summary_image_cap(&prepared, &err);
// Log error but continue with original messages (never corrupt)
let message = crate::compaction::report_compaction_failure(
"Making room failed",
@@ -859,6 +881,15 @@ impl Engine {
let result = match compaction_result {
Ok(result) => result,
Err(err) => {
if let Some(cap) = self.learn_summary_image_cap(&prepared, &err) {
let message = format!(
"Making room: {} refused the summary request's images; retrying with at most {cap} per request. Original conversation was preserved.",
prepared.config.model
);
self.emit_compaction_failed(id.clone(), true, message).await;
self.finish_compaction(&id);
return true;
}
let message = if is_provider_rejection(&err) {
// The turn's error line carries the provider's answer;
// this receipt only closes the recovery attempt.
+20
View File
@@ -5053,6 +5053,26 @@ impl Engine {
let unreadable = crate::error_taxonomy::unreadable_error_notice(raw_message);
let message = unreadable.as_deref().unwrap_or(raw_message);
crate::logging::warn(format!("Provider stream error event: {message}"));
if self.rlm_host.is_none()
&& let Some(rejection) =
crate::llm_client::image_count_rejection(&message.to_lowercase())
{
let sent =
crate::image_attach::count_request_images(&stream_request.messages);
let model = self.session.model.clone();
if self.learn_image_cap(&model, sent, rejection).is_some() {
terminal_stream_error = true;
stream_error.get_or_insert(message.to_string());
let _ = self
.send_stream_event(Event::error(
crate::error_taxonomy::ErrorEnvelope::image_count_exceeded(
message.to_string(),
),
))
.await;
break;
}
}
// #6795: a gateway can report a transient upstream failure
// as an error frame inside a 200. With nothing actionable
// streamed that is a no-content stream death like a
+39 -13
View File
@@ -807,6 +807,7 @@ pub(crate) fn image_count_rejection(lower: &str) -> Option<ImageCountRejection>
"images per message",
"image(s) may be provided",
"images may be provided",
"images can be provided",
]
.iter()
.find_map(|phrase| lower.find(*phrase));
@@ -815,16 +816,41 @@ pub(crate) fn image_count_rejection(lower: &str) -> Option<ImageCountRejection>
.iter()
.any(|word| lower.contains(*word))
});
let at_most_images = lower.match_indices("at most ").find_map(|(index, marker)| {
let rest = &lower[index + marker.len()..];
let digits = rest.len() - rest.trim_start_matches(|c: char| c.is_ascii_digit()).len();
(digits > 0 && rest[digits..].trim_start().starts_with("image")).then_some(index)
});
let start = phrase.or(number_of_images).or(at_most_images)?;
let bounded_images = ["at most ", "maximum of ", "max of ", "up to "]
.iter()
.find_map(|marker| {
lower.match_indices(*marker).find_map(|(index, marker)| {
let rest = &lower[index + marker.len()..];
let digits =
rest.len() - rest.trim_start_matches(|c: char| c.is_ascii_digit()).len();
(digits > 0 && rest[digits..].trim_start().starts_with("image")).then_some(index)
})
});
let start = phrase.or(number_of_images).or(bounded_images)?;
let clause_start = lower[..start]
.rfind(['.', '\n', '{', '"'])
.rfind(['.', '\n', '{', '"', ';'])
.map_or(0, |index| index + 1);
let clause = &lower[clause_start..];
let clause_end = lower[start..]
.find(['\n', '}', '"', ';'])
.map_or(lower.len(), |index| start + index);
let clause = &lower[clause_start..clause_end];
let sentence = &lower[clause_start
..lower[start..clause_end]
.find('.')
.map_or(clause_end, |index| start + index)];
let read = |text: &str, marker: &str| {
text.match_indices(marker).find_map(|(index, marker)| {
let rest = text[index + marker.len()..].trim_start();
let digits = rest.len() - rest.trim_start_matches(|c: char| c.is_ascii_digit()).len();
let unit = ["mb", "kb", "gb", "px", "pixel", "token", "byte", "%", ">"]
.iter()
.any(|unit| rest[digits..].trim_start().starts_with(unit));
if digits == 0 || unit {
return None;
}
rest[..digits].parse::<usize>().ok()
})
};
let limit = [
"at most ",
"maximum of ",
@@ -842,11 +868,11 @@ pub(crate) fn image_count_rejection(lower: &str) -> Option<ImageCountRejection>
"maximum ",
]
.iter()
.find_map(|marker| {
let rest = &clause[clause.find(*marker)? + marker.len()..];
let rest = rest.trim_start();
let digits = rest.len() - rest.trim_start_matches(|c: char| c.is_ascii_digit()).len();
rest[..digits].parse::<usize>().ok()
.find_map(|marker| read(clause, marker))
.or_else(|| {
[": ", " is "]
.iter()
.find_map(|marker| read(sentence, marker))
});
Some(ImageCountRejection { limit })
}
+3 -2
View File
@@ -145,8 +145,9 @@ The Runtime execution ledger persists the worker's own event stream rather than
a separate, simulated taxonomy. Compatibility APIs and types still expose this
through the `Fleet...` prefix. `codewhale exec --output-format stream-json`
emits
`{"type": "content" | "tool_use" | "tool_result" | "sandbox_denied" |
"workflow_event" | "session_capture" | "turn_usage" | "metadata" | "done" |
`{"type": "content" | "tool_use" | "tool_result" | "agent_spawned" |
"sandbox_denied" | "workflow_event" | "session_capture" | "service_released" |
"turn_usage" | "metadata" | "status" | "request_image_window" | "done" |
"error"}` lines, which map onto the Runtime ledger's compatibility type
`FleetWorkerEventPayload` (`RunningTool`, `WorkflowEvent`, `Running`,
`Completed`, `Failed`, …). `workflow_event` carries the typed
+1 -1
View File
@@ -78,7 +78,7 @@ Workflow IR 另有一个默认的结构校验上限:最多五层嵌套节点
## 事件词汇
Runtime 执行账本持久化的是 worker 自己的事件流,而不是另一套模拟出来的分类。兼容性 API 和类型仍以 `Fleet...` 前缀暴露它。`codewhale exec --output-format stream-json` 会输出 `{"type": "content" | "tool_use" | "tool_result" | "sandbox_denied" | "workflow_event" | "session_capture" | "turn_usage" | "metadata" | "done" | "error"}` 形式的行,它们映射到 Runtime 账本的兼容类型 `FleetWorkerEventPayload`(`RunningTool`、`WorkflowEvent`、`Running`、`Completed`、`Failed` 等)。`workflow_event` 在 Workflow 运行期间携带带类型的 run/phase/task/gate 回执,并作为带类型的 `WorkflowEvent` 保留在 Runtime 执行账本中;终态的 `done` 或 `error` 仍由外层 Runtime worker 负责。一套词汇,两个表面。
Runtime 执行账本持久化的是 worker 自己的事件流,而不是另一套模拟出来的分类。兼容性 API 和类型仍以 `Fleet...` 前缀暴露它。`codewhale exec --output-format stream-json` 会输出 `{"type": "content" | "tool_use" | "tool_result" | "agent_spawned" | "sandbox_denied" | "workflow_event" | "session_capture" | "service_released" | "turn_usage" | "metadata" | "status" | "request_image_window" | "done" | "error"}` 形式的行,它们映射到 Runtime 账本的兼容类型 `FleetWorkerEventPayload`(`RunningTool`、`WorkflowEvent`、`Running`、`Completed`、`Failed` 等)。`workflow_event` 在 Workflow 运行期间携带带类型的 run/phase/task/gate 回执,并作为带类型的 `WorkflowEvent` 保留在 Runtime 执行账本中;终态的 `done` 或 `error` 仍由外层 Runtime worker 负责。一套词汇,两个表面。
`session_capture` 在 exec 运行把自己的对话记录保存为会话时发出一次,并且只在一个位置携带可恢复的 id: