From bd896794fdc2fb01ea5fb0d764df56e7cf18ee0e Mon Sep 17 00:00:00 2001 From: Richard Zink Date: Tue, 10 Nov 2026 23:12:49 +0100 Subject: [PATCH] Answer budget within the context window; enforced reviews of completed todos Context window - Requests asked for the model's full max_output, but providers count input + output against the window: long chats with large-output models (DeepSeek V4.1 Flash, 384k) failed with "input too long" before automatic compaction (85 %) kicked in. max_tokens is now what the window leaves (estimated input = last usage + messages since), the chat is compacted when less than 32k would remain for an answer, and a context-overflow error compacts and retries once. - ModelConfig.max_input (catalogue limit.input, editable in the provider settings) lowers the compaction threshold for models with an input limit. Enforced reviews (modes with refine.rounds > 0, refine.after_todos default on) - Every todo marked completed is reviewed against the work since the last review; with findings it is reopened and the feedback goes to the agent (max. rounds per todo). Passed reviews leave a visible note. - Ending the turn with open todos sends the agent back to work while it makes progress (max. 10 reminders in a row without progress); such modes may take up to 2,000 steps per turn. Reaching the step limit is reported. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 23 ++ core/src/config.rs | 3 + server/src/budget.rs | 1 + server/src/config.rs | 3 + server/src/prompts.rs | 2 +- server/src/runner.rs | 274 +++++++++++++++++-- web/src/components/ChatView.tsx | 39 ++- web/src/components/Settings.tsx | 6 + web/src/components/settings/AgentsEditor.tsx | 8 +- web/src/i18n/de.ts | 6 + web/src/i18n/en.ts | 6 + web/src/styles.css | 29 ++ web/src/types.ts | 3 +- web/src/whatsnew.ts | 10 + 14 files changed, 386 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d32bbe9..770df63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,29 @@ All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/); until 1.0, minor versions may contain breaking changes. +## [Unreleased] + +### Added + +- **Enforced reviews:** when a review is configured for a mode, every todo the agent marks + completed is reviewed; with findings the todo is reopened and the agent has to fix them and + complete it again (up to the configured rounds per todo). Ending the turn with open todos sends + the agent back to work as long as it makes progress – for unattended runs, e.g. overnight + (such modes may take up to 2,000 steps per turn). The chat shows passed reviews and these + reminders; *Settings → Modes* can switch it off per mode (then only one review at the end of a + turn, as before). +- **Max input per model:** providers that limit the input below the context window (e.g. 272k of + 400k) are taken into account; fetched from the model catalogue, editable in the provider settings. + +### Fixed + +- **Context overflow instead of compaction** (e.g. DeepSeek V4.1 Flash with 384k max output): + requests asked for the model's full max output, and providers count input + output against the + window, so long chats failed before automatic compaction kicked in. The output budget is now what + the window leaves, the chat is compacted when no reasonable room for an answer is left, and a + "context too long" error compacts and retries once instead of stopping. A turn that hits the + step limit now says so. + ## [v202611-00017] – 2026-11-10 ### Added diff --git a/core/src/config.rs b/core/src/config.rs index 1aabf1c..6ffbc86 100644 --- a/core/src/config.rs +++ b/core/src/config.rs @@ -64,6 +64,9 @@ pub struct ModelConfig { pub context: Option, #[serde(default)] pub max_output: Option, + /// Input limit when the provider sets one below the context window (e.g. 272k of 400k). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_input: Option, #[serde(default)] pub reasoning: bool, #[serde(default)] diff --git a/server/src/budget.rs b/server/src/budget.rs index 2f66101..6f26b80 100644 --- a/server/src/budget.rs +++ b/server/src/budget.rs @@ -409,6 +409,7 @@ fn opencode_model(m: &Value) -> Value { "id": m["id"], "context": m["limit"]["context"], "max_output": m["limit"]["output"], + "max_input": m["limit"]["input"], "vision": images, "reasoning": m["capabilities"]["reasoning"]["supported"].as_bool().unwrap_or(false), "input_price": input["price"], diff --git a/server/src/config.rs b/server/src/config.rs index 0c6cbd1..525a223 100644 --- a/server/src/config.rs +++ b/server/src/config.rs @@ -413,6 +413,9 @@ pub struct RefineConfig { /// Review criteria; empty = the mode's defaults. #[serde(default, skip_serializing_if = "Option::is_none")] pub criteria: Option, + /// Also review every todo the agent marks completed (reopened when findings come back). + #[serde(default = "yes")] + pub after_todos: bool, } #[derive(Debug, Clone, Deserialize, Serialize)] diff --git a/server/src/prompts.rs b/server/src/prompts.rs index 0776b95..ce3559b 100644 --- a/server/src/prompts.rs +++ b/server/src/prompts.rs @@ -121,7 +121,7 @@ pub fn agents(app: &App, root: &Path, cwd: &Path) -> Vec { model: meta.get("model").filter(|m| m.contains('/')).cloned(), source: "file".into(), max_parallel: meta.get("max_parallel").and_then(|v| v.parse().ok()), - refine: meta.get("refine_rounds").and_then(|v| v.parse().ok()).map(|rounds| crate::config::RefineConfig { rounds, model: meta.get("refine_model").cloned(), criteria: None }), + refine: meta.get("refine_rounds").and_then(|v| v.parse().ok()).map(|rounds| crate::config::RefineConfig { rounds, model: meta.get("refine_model").cloned(), criteria: None, after_todos: meta.get("refine_after_todos").is_none_or(|v| v != "false") }), path: Some(p.display().to_string()), }); } diff --git a/server/src/runner.rs b/server/src/runner.rs index d502a37..bdedda0 100644 --- a/server/src/runner.rs +++ b/server/src/runner.rs @@ -18,6 +18,8 @@ use tokio::sync::mpsc; use tokio_util::sync::CancellationToken; const MAX_STEPS: usize = 250; +/// Modes with enforced reviews work through their todo list (e.g. overnight). +const MAX_STEPS_ENFORCED: usize = 2_000; const MAX_SUBAGENT_DEPTH: usize = 3; /// What happened to a prompt. @@ -260,7 +262,15 @@ pub async fn run_session(app: Arc, session_id: &str, cancel: CancellationTo let mut stop_hooks = 0; let mut refine_done = 0u32; let turn_start = now_ms(); - for _ in 0..MAX_STEPS { + // Compacted for the current step (no second compaction before a request went through). + let mut compacted_now = false; + // Mandatory reviews of completed todos: rounds per todo, and where the last one ended. + let mut todo_reviews: std::collections::HashMap = std::collections::HashMap::new(); + let mut review_since = turn_start; + // Reminders about open todos at the end of a turn (see MAX_IDLE_CONTINUES). + let mut last_reminder: Option = None; + let mut idle_continues = 0u32; + for step_no in 0..=MAX_STEPS_ENFORCED { if cancel.is_cancelled() { break; } @@ -275,40 +285,65 @@ pub async fn run_session(app: Arc, session_id: &str, cancel: CancellationTo agent.prompt = prompts::craft_prompt(clean_room); } let model_spec = agent.model.clone().unwrap_or_else(|| session.model.clone()); + let step_limit = if enforced_review(&agent) > 0 { MAX_STEPS_ENFORCED } else { MAX_STEPS }; + if step_no >= step_limit { + app.emit(Event::Error { session_id: session_id.into(), message: format!("Stopped after {step_limit} steps (limit of a turn) – send a message to continue.") }); + break; + } let (provider, mut model) = app.registry().resolve(&model_spec)?; if let Some(effort) = app.with_runtime(session_id, |r| r.effort.clone()) { model.effort = Some(effort); } - // Auto-compaction when the last request used most of the context window. - if let (Some(u), Some(ctx_len)) = (&session.last_usage, model.context) { + let history = active_history(app.store.messages_for_model(session_id)?); + // Providers count input + max_tokens against the context window: the answer needs + // room too. Estimated input = last request (input + its answer) + what came since. + let est_input = estimate_input(session.last_usage.as_ref(), &history); + let want_output = model.max_output.unwrap_or(16_000); + if let Some(ctx_len) = model.context { let threshold = app.config().server.auto_compact_threshold; - if threshold > 0.0 && u.context_tokens() as f64 > ctx_len as f64 * threshold { - compact(app.clone(), session_id, cancel.clone(), Some((u.context_tokens(), ctx_len))).await?; + // Input limit: the context window, or the provider's lower input limit. + let input_limit = model.max_input.map_or(ctx_len, |m| m.min(ctx_len)); + let room = ctx_len.saturating_sub(est_input); + // Most of the input limit used, or no room left for a reasonable answer. + if threshold > 0.0 && (est_input as f64 > input_limit as f64 * threshold || room < want_output.min(MIN_ANSWER_ROOM)) && !compacted_now { + compacted_now = true; + compact(app.clone(), session_id, cancel.clone(), Some((est_input, input_limit))).await?; continue; } } + let max_tokens = answer_budget(want_output, model.context, est_input); let owner = session.owner_id.as_deref().and_then(|id| app.store.user(id).ok().flatten()); let tools = available_tools(&app, &agent, owner.as_ref()); - let history = active_history(app.store.messages_for_model(session_id)?); let req = ChatRequest { system: prompts::with_chat_instructions(prompts::system_prompt(&app, &agent, &root, &cwd, &model_spec, &session.permission_mode, owner.as_ref()), &session.instructions), messages: history, tools: tools.iter().map(|t| t.def()).collect(), - max_tokens: model.max_output.unwrap_or(16_000), + max_tokens, reasoning: true, session_id: Some(session_id.to_string()), model: model.clone(), }; let step = match stream_step(&app, &session, &agent.name, &model_spec, provider, req.clone(), &cancel).await { - Ok(step) => step, + Ok(step) => { + compacted_now = false; + step + } + // The estimate was off and the provider says the input is too long: compact + // once and try again instead of stopping. + Err(e) if is_context_overflow(&e) && !compacted_now && !cancel.is_cancelled() => { + compacted_now = true; + app.emit(Event::Error { session_id: session_id.into(), message: format!("context window full ({}) – compacting the conversation and continuing", short_error(&e)) }); + compact(app.clone(), session_id, cancel.clone(), Some((est_input, model.context.unwrap_or(0)))).await?; + continue; + } // Host down or budget used up: switch to the model's fallback for this step. Err(e) if model.fallback.is_some() && !cancel.is_cancelled() && needs_fallback(&e) => { let fb = model.fallback.clone().unwrap_or_default(); let (fb_provider, fb_model) = app.registry().resolve(&fb)?; app.emit(Event::Error { session_id: session_id.into(), message: format!("{model_spec} is not available ({}) – using the fallback model {fb}", short_error(&e)) }); - let req = ChatRequest { model: fb_model.clone(), max_tokens: fb_model.max_output.unwrap_or(16_000), ..req }; + let req = ChatRequest { model: fb_model.clone(), max_tokens: answer_budget(fb_model.max_output.unwrap_or(16_000), fb_model.context, est_input), ..req }; stream_step(&app, &session, &agent.name, &fb, fb_provider, req, &cancel).await? } Err(e) => return Err(e), @@ -353,12 +388,37 @@ pub async fn run_session(app: Arc, session_id: &str, cancel: CancellationTo } } let normal_end = matches!(step.stop_reason.as_str(), "stop" | "end_turn" | ""); + // Enforced reviews also mean: work until the todo list is done. Ending the turn with + // open todos sends the agent back (as long as it makes progress). + if calls.is_empty() && depth == 0 && normal_end && !cancel.is_cancelled() && enforced_review(&agent) > 0 { + let todos_now = app.session(session_id)?.todos; + let open = open_todos(&todos_now); + if !open.is_empty() { + let progressed = last_reminder.as_ref() != Some(&todos_now); + idle_continues = if progressed { 0 } else { idle_continues + 1 }; + if idle_continues < MAX_IDLE_CONTINUES { + last_reminder = Some(todos_now); + save_user_parts( + &app, + session_id, + vec![Part::Text { + text: format!( + "\n{}\n\nThese todos are still open. Continue working until all of them are completed (each completed todo is reviewed). Only if you are truly blocked and need the user, say clearly what you need and stop.", + open.len(), + open.iter().map(|t| format!("- {t}")).collect::>().join("\n") + ), + }], + )?; + continue; + } + } + } if calls.is_empty() && depth == 0 && normal_end && !cancel.is_cancelled() { // Refinement loop: a reviewer checks the turn; findings send the agent back to work. let rounds = agent.refine.as_ref().map(|r| r.rounds).unwrap_or(0); if refine_done < rounds { refine_done += 1; - match review_turn(&app, &session, &agent, &model_spec, turn_start, refine_done, rounds, &cancel).await { + match review_turn(&app, &session, &agent, &model_spec, turn_start, None, refine_done, rounds, &cancel).await { Ok(Some(feedback)) => { save_user_parts(&app, session_id, vec![Part::Text { text: feedback }])?; continue; @@ -412,6 +472,40 @@ pub async fn run_session(app: Arc, session_id: &str, cancel: CancellationTo results.push(execute_call(&app, &session, &base, by_name(name), id, name, input.clone(), depth).await); } } + // Mode with a review: every todo marked completed is reviewed; with findings it is + // reopened and the agent has to fix them and complete it again (max. `rounds` per todo). + // The verdict goes into the same message as the tool results. + let rounds = enforced_review(&agent); + if rounds > 0 && depth == 0 && !cancel.is_cancelled() && calls.iter().any(|(_, n, _)| n == "todo_write") { + let todos_now = app.session(session_id)?.todos; + let due: Vec = newly_completed(&session.todos, &todos_now) + .into_iter() + .filter(|t| todo_reviews.get(t).copied().unwrap_or(0) < rounds) + .collect(); + if !due.is_empty() { + for t in &due { + *todo_reviews.entry(t.clone()).or_default() += 1; + } + let round = due.iter().filter_map(|t| todo_reviews.get(t)).copied().max().unwrap_or(1); + let fresh = app.session(session_id)?; + let reviewed = review_turn(&app, &fresh, &agent, &model_spec, review_since, Some(&due), round, rounds, &cancel).await; + review_since = now_ms(); + match reviewed { + Ok(Some(feedback)) => { + app.set_todos(session_id, reopen_todos(&todos_now, &due))?; + results.push(Part::Text { text: feedback }); + } + Ok(None) => { + let open = open_todos(&todos_now); + let next = if open.is_empty() { "All todos are done – check the whole request once more, then finish." } else { "Continue with the next open todo." }; + results.push(Part::Text { + text: format!("\n{}\n\nReview passed. {next}", review_model(&agent, &model_spec), due.iter().map(|t| format!("- {t}")).collect::>().join("\n")), + }); + } + Err(e) => tracing::warn!("todo review of {session_id} failed: {e:#}"), + } + } + } // Deliver messages the user queued while we were working (with attachments). results.extend(take_queue(&app, session_id)); save_user_parts(&app, session_id, results)?; @@ -419,6 +513,55 @@ pub async fn run_session(app: Arc, session_id: &str, cancel: CancellationTo Ok(final_text) } +/// Review rounds when the mode enforces reviews of completed todos (0 = not enforced). +fn enforced_review(agent: &prompts::Agent) -> u32 { + agent.refine.as_ref().filter(|r| r.after_todos).map(|r| r.rounds).unwrap_or(0) +} + +/// The model that reviews for this mode. +fn review_model(agent: &prompts::Agent, model_spec: &str) -> String { + agent.refine.as_ref().and_then(|r| r.model.clone()).filter(|m| !m.is_empty()).unwrap_or_else(|| model_spec.to_string()) +} + +/// Contents of the todos that are not completed yet. +fn open_todos(todos: &Value) -> Vec { + todos.as_array().into_iter().flatten().filter(|t| t["status"] != "completed").filter_map(|t| t["content"].as_str()).map(String::from).collect() +} + +/// Times in a row the agent may end its turn with open todos without making progress. +const MAX_IDLE_CONTINUES: u32 = 10; + +/// Contents of the todos that are completed in `after` but were not in `before`. +fn newly_completed(before: &Value, after: &Value) -> Vec { + let done_before: std::collections::HashSet<&str> = before + .as_array() + .into_iter() + .flatten() + .filter(|t| t["status"] == "completed") + .filter_map(|t| t["content"].as_str()) + .collect(); + after + .as_array() + .into_iter() + .flatten() + .filter(|t| t["status"] == "completed") + .filter_map(|t| t["content"].as_str()) + .filter(|c| !done_before.contains(c)) + .map(String::from) + .collect() +} + +/// The todo list with the given items open again (in progress). +fn reopen_todos(todos: &Value, items: &[String]) -> Value { + let mut out = todos.clone(); + for t in out.as_array_mut().into_iter().flatten() { + if t["content"].as_str().is_some_and(|c| items.iter().any(|i| i == c)) { + t["status"] = Value::from("in_progress"); + } + } + out +} + fn tool_result(call_id: &str, name: &str, content: String, is_error: bool, meta: Option) -> Part { Part::ToolResult { call_id: call_id.into(), name: name.into(), content, images: vec![], is_error, meta } } @@ -779,6 +922,47 @@ fn estimate_prompt_tokens(req: &ChatRequest) -> u64 { /// Errors after which the fallback model takes over: the host is unreachable /// (connection, timeout, gateway errors) or the provider's budget is used up. +/// Room an answer gets at least before the conversation is compacted. +const MIN_ANSWER_ROOM: u64 = 32_000; + +/// Estimated input tokens of the next request: the last request (its input and answer) +/// plus the messages that came after that answer (tool results, user messages), at about +/// 3 characters per token; without usage (fresh or just compacted) the whole history. +fn estimate_input(last: Option<&crate::types::Usage>, history: &[Message]) -> u64 { + let chars_of = |m: &Message| -> u64 { + m.parts + .iter() + .map(|p| match p { + Part::Text { text } | Part::Reasoning { text, .. } | Part::Summary { text, .. } => text.len() as u64, + Part::ToolCall { input, .. } => input.to_string().len() as u64, + Part::ToolResult { content, images, .. } => content.len() as u64 + images.len() as u64 * 4_500, + Part::Image { .. } => 4_500, + }) + .sum() + }; + match last { + Some(u) => { + let after = history.iter().rposition(|m| m.role == Role::Assistant).map(|i| &history[i + 1..]).unwrap_or(&[]); + u.context_tokens() + after.iter().map(chars_of).sum::() / 3 + } + None => history.iter().map(chars_of).sum::() / 3 + 8_000, + } +} + +/// max_tokens for a request: the model's maximum, but no more than the window leaves +/// (with a margin for the estimate), and never below a small floor. +fn answer_budget(max_output: u64, context: Option, est_input: u64) -> u64 { + let Some(ctx) = context else { return max_output }; + let room = ctx.saturating_sub(est_input + est_input / 50 + 2_000); + max_output.min(room).max(4_096.min(max_output)) +} + +/// The provider rejected the request because input + output exceed the context window. +fn is_context_overflow(e: &anyhow::Error) -> bool { + let s = format!("{e:#}").to_lowercase(); + ["input too long", "context length", "context_length", "maximum context", "context window", "prompt is too long", "too many tokens", "reduce the length"].iter().any(|k| s.contains(k)) +} + fn needs_fallback(e: &anyhow::Error) -> bool { let s = format!("{e:#}").to_lowercase(); ["connect", "timed out", "timeout", "http 502", "http 503", "http 504", "dns", "budget"].iter().any(|k| s.contains(k)) @@ -1133,7 +1317,7 @@ fn advance_phase(app: &App, session_id: &str, phase: &str, only_from: Option<&st /// Review the last turn against the user's request. Returns feedback for the /// agent, or None when the reviewer approves. #[allow(clippy::too_many_arguments)] -async fn review_turn(app: &Arc, session: &Session, agent: &prompts::Agent, model_spec: &str, turn_start: i64, round: u32, rounds: u32, cancel: &CancellationToken) -> Result> { +async fn review_turn(app: &Arc, session: &Session, agent: &prompts::Agent, model_spec: &str, since: i64, todos: Option<&[String]>, round: u32, rounds: u32, cancel: &CancellationToken) -> Result> { let refine = agent.refine.clone().unwrap_or_default(); let spec = refine.model.clone().filter(|m| !m.is_empty()).unwrap_or_else(|| model_spec.to_string()); let (provider, mut model) = app.registry().resolve(&spec)?; @@ -1147,7 +1331,12 @@ async fn review_turn(app: &Arc, session: &Session, agent: &prompts::Agent, .rposition(|m| matches!(m.role, Role::User) && m.parts.iter().any(|p| matches!(p, Part::Text { text } if !text.starts_with(">().join("\n")).unwrap_or_default(); - let mut work = transcript_of(&history[(req_idx + 1).min(history.len())..]); + // A todo review looks at the work since the last review, the end-of-turn review at the whole turn. + let from = match todos { + Some(_) => history.iter().position(|m| m.created_at >= since).unwrap_or(history.len()).max(req_idx + 1), + None => req_idx + 1, + }; + let mut work = transcript_of(&history[from.min(history.len())..]); if work.len() > 30_000 { let mut start = work.len() - 30_000; while !work.is_char_boundary(start) { @@ -1158,7 +1347,7 @@ async fn review_turn(app: &Arc, session: &Session, agent: &prompts::Agent, // Actual file changes of this turn (checkpoint vs. current content). let mut changes = String::new(); let mut seen = std::collections::HashSet::new(); - for (path, before) in app.store.checkpoints_since(&session.id, turn_start)? { + for (path, before) in app.store.checkpoints_since(&session.id, since)? { if !seen.insert(path.clone()) || changes.len() > 30_000 { continue; } @@ -1172,10 +1361,17 @@ async fn review_turn(app: &Arc, session: &Session, agent: &prompts::Agent, let system = "You review the work of an AI coding agent for the user. Be strict about what matters for the user's request and ignore style nitpicks. \ Reply in exactly this format: the first line is APPROVED or CHANGES_NEEDED. For CHANGES_NEEDED, follow with at most 8 concrete, actionable findings as a bullet list \ (with file:line where possible). If the important work is done and correct, reply APPROVED."; + let focus = match todos { + Some(items) => format!( + "\n{}\n\nThe agent just marked these todo items as completed. Review only whether they are really done and correct; other parts of the request may still be in progress.\n\n", + items.iter().map(|t| format!("- {t}")).collect::>().join("\n") + ), + None => String::new(), + }; let body = format!( - "\n{request}\n\n\n\n{criteria}\n\n\n\n{work}\n\n\n\n{}\n", + "\n{request}\n\n\n{focus}\n{criteria}\n\n\n\n{work}\n\n\n\n{}\n", agent.name, - if changes.is_empty() { "(no files changed in this turn)" } else { changes.as_str() } + if changes.is_empty() { "(no files changed since the last review)" } else { changes.as_str() } ); let req = ChatRequest { system: system.into(), messages: vec![Message::new(Role::User, vec![Part::Text { text: body }])], tools: vec![], max_tokens: 4000, reasoning: false, model: model.clone(), session_id: Some(session.id.clone()) }; let (text, usage) = complete_text(app, &spec, provider, req, cancel.clone()).await?; @@ -1188,10 +1384,17 @@ async fn review_turn(app: &Arc, session: &Session, agent: &prompts::Agent, } let findings: String = lines.collect::>().join("\n"); let findings = if findings.trim().is_empty() { text.clone() } else { findings }; - Ok(Some(format!( - "\n{}\n\nAddress these review findings (or explain briefly why one does not apply), verify, then finish.", - findings.trim() - ))) + Ok(Some(match todos { + Some(items) => format!( + "\n{}\n\nThe review of the todo(s) you just completed found problems, so they are open again. Fix these findings (or explain briefly why one does not apply), verify, then mark the todo(s) completed again – that triggers the next review.", + items.len(), + findings.trim() + ), + None => format!( + "\n{}\n\nAddress these review findings (or explain briefly why one does not apply), verify, then finish.", + findings.trim() + ), + })) } .await; app.emit(Event::Waiting { session_id: session.id.clone(), reason: None }); @@ -1314,6 +1517,39 @@ async fn compact_inner(app: Arc, session_id: &str, cancel: CancellationToke mod tests { use super::*; + #[test] + fn todo_completion_and_reopening() { + let before = serde_json::json!([{"content": "A", "status": "in_progress"}, {"content": "B", "status": "completed"}, {"content": "C", "status": "pending"}]); + let after = serde_json::json!([{"content": "A", "status": "completed"}, {"content": "B", "status": "completed"}, {"content": "C", "status": "pending"}]); + assert_eq!(newly_completed(&before, &after), vec!["A".to_string()]); + assert_eq!(newly_completed(&Value::Null, &after), vec!["A".to_string(), "B".to_string()]); + assert_eq!(open_todos(&after), vec!["C".to_string()]); + let reopened = reopen_todos(&after, &["A".to_string()]); + assert_eq!(reopened[0]["status"], "in_progress"); + assert_eq!(reopened[1]["status"], "completed"); + } + + #[test] + fn answer_room_and_overflow() { + // The case from production: DeepSeek V4.1 Flash, 1M window, 384K output, 720K input. + let (ctx, out) = (1_048_576u64, 393_216u64); + let b = answer_budget(out, Some(ctx), 720_000); + assert!(720_000 + b <= ctx && b > 300_000, "{b}"); + assert_eq!(answer_budget(out, Some(ctx), 100_000), out); + assert_eq!(answer_budget(16_000, None, 5_000_000), 16_000); + assert_eq!(answer_budget(out, Some(ctx), 1_040_000), 4_096); + assert!(is_context_overflow(&anyhow!("HTTP 400: [invalid_request_error] Input too long: model limit 1048576 tokens, requested 1108739 tokens"))); + assert!(is_context_overflow(&anyhow!("This model's maximum context length is 128000 tokens"))); + assert!(!is_context_overflow(&anyhow!("HTTP 429 rate limited"))); + // Estimate: last request + what came after its answer (about 3 chars per token). + let u = crate::types::Usage { input_tokens: 1_000, cache_read_tokens: 9_000, output_tokens: 500, ..Default::default() }; + let mut call = Message::new(Role::Assistant, vec![call("c1")]); + call.id = "a".into(); + let result = Message::new(Role::User, vec![Part::ToolResult { call_id: "c1".into(), name: "bash".into(), content: "x".repeat(3_000), images: vec![], is_error: false, meta: None }]); + assert_eq!(estimate_input(Some(&u), &[call.clone(), result.clone()]), 10_500 + 1_000); + assert!(estimate_input(None, &[call, result]) >= 1_000 + 8_000); + } + #[test] fn detects_commands_that_move_files() { assert!(moves_files("mkdir -p art/old && mv art/*.png art/old/")); diff --git a/web/src/components/ChatView.tsx b/web/src/components/ChatView.tsx index ba82a48..3ec11dd 100644 --- a/web/src/components/ChatView.tsx +++ b/web/src/components/ChatView.tsx @@ -115,7 +115,36 @@ function BackgroundResult({ text }: { text: string }) { ); } -const REFINE_RE = /^\n?([\s\S]*?)\n?<\/refine-feedback>/; +const REFINE_RE = /^\n?([\s\S]*?)\n?<\/refine-feedback>/; +/** Enforced reviews: a completed todo passed, or a reminder about open todos. */ +const NOTE_RE = /^<(refine-approved|todo-reminder)(?: reviewer="([^"]*)")?(?: open="\d+")?>\n?([\s\S]*?)\n?<\/(?:refine-approved|todo-reminder)>/; +/** Messages from the harness shown as cards instead of user bubbles. */ +const isHarnessNote = (text: string) => BG_RE.test(text) || REFINE_RE.test(text) || NOTE_RE.test(text); + +/** A todo passed its review, or the agent was sent back to its open todos. */ +function HarnessNote({ text }: { text: string }) { + const t = useT(); + const m = text.match(NOTE_RE); + if (!m) return null; + const [, kind, reviewer, body] = m; + const items = body.split("\n").map((l) => l.replace(/^- /, "").trim()).filter(Boolean); + const approved = kind === "refine-approved"; + return ( +
+ + + {t(approved ? "chat.reviewPassed" : "chat.todoReminder", { n: items.length })} {items.join(" · ")} + + {reviewer && {reviewer}} +
+ ); +} + +function HarnessMessage({ text }: { text: string }) { + if (REFINE_RE.test(text)) return ; + if (NOTE_RE.test(text)) return ; + return ; +} /** Findings of the review loop, sent back to the agent. */ function RefineFeedback({ text }: { text: string }) { @@ -469,14 +498,14 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string ); } const allTexts = m.parts.filter((p) => p.type === "text") as { text: string }[]; - const background = allTexts.filter((p) => BG_RE.test(p.text) || REFINE_RE.test(p.text)); - const texts = allTexts.filter((p) => !BG_RE.test(p.text) && !REFINE_RE.test(p.text)); + const background = allTexts.filter((p) => isHarnessNote(p.text)); + const texts = allTexts.filter((p) => !isHarnessNote(p.text)); const imgs = m.parts.filter((p) => p.type === "image") as { image: ImageData }[]; if (texts.length === 0 && imgs.length === 0) { return background.length ? (
{background.map((p, i) => ( - REFINE_RE.test(p.text) ? : + ))}
) : null; @@ -484,7 +513,7 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string return (
{background.map((p, i) => ( - REFINE_RE.test(p.text) ? : + ))}
{!busy && !readOnly && ( diff --git a/web/src/components/Settings.tsx b/web/src/components/Settings.tsx index 879387a..4b63eba 100644 --- a/web/src/components/Settings.tsx +++ b/web/src/components/Settings.tsx @@ -376,6 +376,7 @@ interface ModelRow { name?: string; context?: number; max_output?: number; + max_input?: number; reasoning?: boolean; vision?: boolean; effort?: string; @@ -454,6 +455,7 @@ function ProviderEditor({ name, p, reload, status }: { name: string; p: Json; re price_multipliers: f.price_multipliers ?? m.price_multipliers, context: m.context ?? f.context, max_output: m.max_output ?? f.max_output, + max_input: m.max_input ?? f.max_input ?? undefined, vision: m.vision || !!f.vision, reasoning: m.reasoning || !!f.reasoning, }; @@ -616,6 +618,7 @@ function ProviderEditor({ name, p, reload, status }: { name: string; p: Json; re ID {t("settings.context")} {t("settings.maxOutput")} + {t("settings.maxInput")} {t("settings.effort")} @@ -640,6 +643,9 @@ function ProviderEditor({ name, p, reload, status }: { name: string; p: Json; re setModel(i, { max_output: num(e.target.value) })} /> + + setModel(i, { max_input: num(e.target.value) })} /> + setModel(i, { reasoning: e.target.checked })} /> diff --git a/web/src/components/settings/AgentsEditor.tsx b/web/src/components/settings/AgentsEditor.tsx index 2ae8556..bdd1fc1 100644 --- a/web/src/components/settings/AgentsEditor.tsx +++ b/web/src/components/settings/AgentsEditor.tsx @@ -61,6 +61,7 @@ function AgentRow({ a, raw, tools, onSaved }: { a: AgentInfo; raw: any; tools: T refine_rounds: String(raw?.refine?.rounds ?? a.refine?.rounds ?? 0), refine_model: raw?.refine?.model ?? "", refine_criteria: raw?.refine?.criteria ?? "", + refine_after_todos: (raw?.refine?.after_todos ?? a.refine?.after_todos ?? true) as boolean, }); const builtin = a.source === "builtin" || (a.source === "config" && !raw?.prompt && ["build", "plan", "craft", "chat", "general", "explore"].includes(a.name)); @@ -74,7 +75,7 @@ function AgentRow({ a, raw, tools, onSaved }: { a: AgentInfo; raw: any; tools: T const rounds = Math.max(0, Math.min(10, Number(form.refine_rounds) || 0)); patches.push({ path: [...base, "refine"], - value: rounds || form.refine_model || form.refine_criteria ? { rounds, model: form.refine_model || null, criteria: form.refine_criteria || null } : null, + value: rounds || form.refine_model || form.refine_criteria ? { rounds, model: form.refine_model || null, criteria: form.refine_criteria || null, after_todos: form.refine_after_todos } : null, }); } if (!builtin || form.description) patches.push({ path: [...base, "description"], value: form.description || null }); @@ -158,6 +159,11 @@ function AgentRow({ a, raw, tools, onSaved }: { a: AgentInfo; raw: any; tools: T