mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-07 17:09:13 -10:00
fix(tui): learn the image cap from a refused summary request and from a stream error frame
An adversarial review off0913c3f62(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:f0913c3f62plus 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:
1 parent
f0913c3f62
commit
9d567ebd2a
7 files changed
+110
-22
No files matched your search
+8
-3
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
Reference in new issue
Block a user