From c1326e02192b03d4d4439734029b526988e3b1d3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 15 Aug 2026 07:50:31 +0000 Subject: [PATCH] WIP: feat(tui): configurable model-visible read/tool-result budgets (#5367) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend existing [workshop] / WorkshopConfig with optional read_result_max_bytes and tool_result_max_bytes. Absent keys keep the current conservative constants (read 50 KiB, hidden read_file 16 KiB, tool-result context/wire 12 000 chars or 48 000 on ≥500K windows). Thread Config → Engine / App / DeepSeekClient into compact_tool_result_for_route, compact_tool_result_for_wire, and file.rs READ_MAX_BYTES / MAX_VISIBLE_BYTES / SMALL_FILE_BYTES via unwrap_or. HarnessProfile is unchanged. Requested by @hxfhd in #5367. Agent assistance used for implementation; verification still pending. Co-authored-by: Hunter Bown --- config.example.toml | 6 +++ crates/tui/src/client.rs | 9 ++++ crates/tui/src/client/chat.rs | 44 +++++++++++++--- crates/tui/src/client/prepared.rs | 2 + crates/tui/src/config.rs | 6 ++- crates/tui/src/core/engine/context.rs | 6 ++- crates/tui/src/core/engine/tests.rs | 28 +++++++++++ crates/tui/src/core/engine/turn_loop.rs | 4 ++ crates/tui/src/tools/file.rs | 50 ++++++++++++------ crates/tui/src/tools/large_output_router.rs | 56 +++++++++++++++++++++ crates/tui/src/tui/app.rs | 4 ++ crates/tui/src/tui/app/init.rs | 1 + crates/tui/src/tui/ui.rs | 6 +++ docs/CONFIGURATION.md | 25 +++++++++ 14 files changed, 220 insertions(+), 27 deletions(-) diff --git a/config.example.toml b/config.example.toml index 3ac34b6c18..6ca3389f3a 100644 --- a/config.example.toml +++ b/config.example.toml @@ -1085,6 +1085,12 @@ exponential_base = 2.0 # # [workshop] # large_output_threshold_tokens = 4096 +# Optional per-result ceilings for what the model sees. Omit both keys to +# keep today's conservative defaults (read 50 KiB / hidden read_file 16 KiB; +# tool-result context+wire 12 000 chars, or 48 000 on ≥500K windows). +# These do not disable session compaction. +# read_result_max_bytes = 131072 +# tool_result_max_bytes = 131072 # [workshop.per_tool_thresholds] # Bash = 2048 # shell output synthesised aggressively # Web = 8192 # web results can be large; give them more room diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index bb5a34341b..ad2463c156 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -210,6 +210,9 @@ pub struct DeepSeekClient { test_messages_transport_base_url: Option, pub(super) reasoning_stream_style: Option, pub(super) stream_idle_timeout: Duration, + /// Optional `[workshop].tool_result_max_bytes` char budget for wire + /// compaction. `None` keeps `TOOL_RESULT_SENT_CHAR_BUDGET` (12 000). + tool_result_max_bytes: Option, } const CONNECTION_FAILURE_THRESHOLD: u32 = 2; @@ -467,6 +470,7 @@ impl Clone for DeepSeekClient { test_messages_transport_base_url: self.test_messages_transport_base_url.clone(), reasoning_stream_style: self.reasoning_stream_style.clone(), stream_idle_timeout: self.stream_idle_timeout, + tool_result_max_bytes: self.tool_result_max_bytes, } } } @@ -1178,6 +1182,10 @@ impl DeepSeekClient { test_messages_transport_base_url: None, reasoning_stream_style, stream_idle_timeout, + tool_result_max_bytes: config + .workshop + .as_ref() + .and_then(|workshop| workshop.tool_result_max_bytes), }) } @@ -1823,6 +1831,7 @@ impl DeepSeekClient { chat_shape_provider, &self.base_url, stream, + self.tool_result_max_bytes, )?; let url = chat_completions_url( self.chat_transport_base_url(), diff --git a/crates/tui/src/client/chat.rs b/crates/tui/src/client/chat.rs index 139cc65cad..ef66b71565 100644 --- a/crates/tui/src/client/chat.rs +++ b/crates/tui/src/client/chat.rs @@ -982,9 +982,11 @@ pub(crate) fn build_chat_wire_body( provider: ApiProvider, base_url: &str, stream: bool, + tool_result_max_bytes: Option, ) -> Result { - let messages = - build_chat_messages_for_request_and_provider_and_route(request, provider, base_url); + let messages = PromptBuilder::for_request(request) + .with_tool_result_max_bytes(tool_result_max_bytes) + .build_for_provider_and_route(provider, base_url); let model = { let wire = wire_model_for_provider_route(provider, base_url, &request.model); crate::models::effective_muse_wire_id(&wire).to_string() @@ -1497,6 +1499,7 @@ pub(super) fn build_chat_messages( model, should_replay_reasoning_content(model, None), false, + None, ) } @@ -1541,6 +1544,7 @@ struct PromptBuilder<'a> { tools: Option<&'a [Tool]>, model: &'a str, reasoning_effort: Option<&'a str>, + tool_result_max_bytes: Option, } impl<'a> PromptBuilder<'a> { @@ -1551,9 +1555,15 @@ impl<'a> PromptBuilder<'a> { tools: request.tools.as_deref(), model: &request.model, reasoning_effort: request.reasoning_effort.as_deref(), + tool_result_max_bytes: None, } } + fn with_tool_result_max_bytes(mut self, tool_result_max_bytes: Option) -> Self { + self.tool_result_max_bytes = tool_result_max_bytes; + self + } + #[cfg(test)] fn build(self) -> Vec { build_chat_messages_with_reasoning( @@ -1562,6 +1572,7 @@ impl<'a> PromptBuilder<'a> { self.model, should_replay_reasoning_content(self.model, self.reasoning_effort), false, + self.tool_result_max_bytes, ) } @@ -1577,6 +1588,7 @@ impl<'a> PromptBuilder<'a> { self.reasoning_effort, ), false, + self.tool_result_max_bytes, ); dump_system_prompt_if_requested(&messages); if provider == ApiProvider::Arcee { @@ -1601,6 +1613,7 @@ impl<'a> PromptBuilder<'a> { self.model, should_replay_reasoning_content(self.model, self.reasoning_effort), true, + self.tool_result_max_bytes, ); inspect_wire_request(self.tools, &messages) } @@ -2250,15 +2263,16 @@ fn compact_tool_result_for_wire( content: &str, message_label: &str, seen_tool_results: &mut HashMap, + tool_result_max_bytes: Option, ) -> WireToolResult { let original_chars = content.chars().count(); let sha = sha256_hex(content.as_bytes()); + let sent_budget = tool_result_max_bytes.unwrap_or(TOOL_RESULT_SENT_CHAR_BUDGET); // Only medium, non-mutation results can point back to a full earlier // message in this one request. Oversized results are already excerpts, so // a back-reference would falsely imply the exact bytes remain available. - let dedup_eligible = (TOOL_RESULT_DEDUP_MIN_CHARS..=TOOL_RESULT_SENT_CHAR_BUDGET) - .contains(&original_chars) + let dedup_eligible = (TOOL_RESULT_DEDUP_MIN_CHARS..=sent_budget).contains(&original_chars) && !is_mutation_tool(tool_name); if dedup_eligible && let Some(previous) = seen_tool_results.get(&sha) { @@ -2288,7 +2302,7 @@ fn compact_tool_result_for_wire( ); } - if original_chars <= TOOL_RESULT_SENT_CHAR_BUDGET { + if original_chars <= sent_budget { return WireToolResult { content: content.to_string(), original_chars, @@ -2400,6 +2414,7 @@ fn build_chat_messages_with_reasoning( _model: &str, include_reasoning: bool, include_tool_budget_metadata: bool, + tool_result_max_bytes: Option, ) -> Vec { let mut out = Vec::new(); let mut pending_tool_calls: HashMap = HashMap::new(); @@ -2620,6 +2635,7 @@ fn build_chat_messages_with_reasoning( &content, &message_label, &mut seen_tool_results, + tool_result_max_bytes, ); let mut tool_msg = json!({ "role": "tool", @@ -5869,6 +5885,7 @@ mod image_block_wire_tests { ApiProvider::Openai, "https://api.openai.com/v1", false, + None, ) .expect("wire body"); @@ -5915,6 +5932,7 @@ mod image_block_wire_tests { ApiProvider::Openai, "https://api.openai.com/v1", false, + None, ) .expect("wire body"); @@ -5963,6 +5981,7 @@ mod image_block_wire_tests { ApiProvider::Openai, "https://api.openai.com/v1", false, + None, ) .expect("wire body"); let messages = body.body["messages"].as_array().expect("messages"); @@ -6332,6 +6351,7 @@ mod mistral_reasoning_tests { ApiProvider::Mistral, crate::config::DEFAULT_MISTRAL_BASE_URL, true, + None, ) .expect("Mistral stream wire body"); let assistant = &wire.body["messages"][0]; @@ -6481,6 +6501,7 @@ mod google_thought_signature_tests { ApiProvider::Google, DEFAULT_GOOGLE_BASE_URL, false, + None, ) .err() .expect("missing signature must fail closed before transport"); @@ -6501,6 +6522,7 @@ mod google_thought_signature_tests { ApiProvider::Google, DEFAULT_GOOGLE_BASE_URL, false, + None, ) .expect("flash-lite replay must not require a signature"); } @@ -6536,6 +6558,7 @@ mod google_thought_signature_tests { ApiProvider::Google, "https://gateway.example.com/v1", false, + None, ) .expect("non-official Google base URL must not require signatures"); let messages = build_chat_messages_for_request_and_provider_and_route( @@ -6560,6 +6583,7 @@ mod google_thought_signature_tests { ApiProvider::Google, DEFAULT_GOOGLE_BASE_URL, false, + None, ) .expect("valid google body"); assert_eq!( @@ -6571,8 +6595,14 @@ mod google_thought_signature_tests { let mut low = google_request_with_signed_tool(Some("SIG")); low.reasoning_effort = Some("low".to_string()); - let body = build_chat_wire_body(&low, ApiProvider::Google, DEFAULT_GOOGLE_BASE_URL, false) - .expect("valid google body"); + let body = build_chat_wire_body( + &low, + ApiProvider::Google, + DEFAULT_GOOGLE_BASE_URL, + false, + None, + ) + .expect("valid google body"); assert_eq!( body.body .pointer("/google/thinking_config/thinking_level") diff --git a/crates/tui/src/client/prepared.rs b/crates/tui/src/client/prepared.rs index 23adf70ab9..ad64cbf6c9 100644 --- a/crates/tui/src/client/prepared.rs +++ b/crates/tui/src/client/prepared.rs @@ -1314,6 +1314,7 @@ mod dialect_seam_tests { client.api_provider(), client.base_url(), true, + None, ) .expect("reference body builds"); @@ -1361,6 +1362,7 @@ mod dialect_seam_tests { client.api_provider(), client.base_url(), true, + None, ) .expect("reference body builds"); assert_eq!( diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index ea9f7e9910..b724f6c7c6 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -2741,8 +2741,10 @@ pub struct Config { #[serde(default)] pub runtime_api: Option, - /// Workshop / large-tool-output routing (#548). When absent, the global - /// default threshold of 4 096 tokens applies and routing is active. + /// Workshop / large-tool-output routing (#548) and optional per-result + /// model-visible budgets (#5367). When absent, routing uses the built-in + /// threshold and read/tool-result size limits stay at their conservative + /// constants. #[serde(default)] pub workshop: Option, diff --git a/crates/tui/src/core/engine/context.rs b/crates/tui/src/core/engine/context.rs index bcfdb5abf1..971657764c 100644 --- a/crates/tui/src/core/engine/context.rs +++ b/crates/tui/src/core/engine/context.rs @@ -417,7 +417,7 @@ pub(crate) fn compact_tool_result_for_context( tool_name: &str, output: &ToolResult, ) -> String { - compact_tool_result_for_route(ApiProvider::Deepseek, model, None, tool_name, output) + compact_tool_result_for_route(ApiProvider::Deepseek, model, None, tool_name, output, None) } pub(crate) fn compact_tool_result_for_route( @@ -426,6 +426,7 @@ pub(crate) fn compact_tool_result_for_route( route_limits: Option, tool_name: &str, output: &ToolResult, + tool_result_max_bytes: Option, ) -> String { let raw = output.content.trim(); if raw.is_empty() { @@ -466,7 +467,8 @@ pub(crate) fn compact_tool_result_for_route( let context_window = crate::route_budget::route_context_window_tokens(provider, model, route_limits); - let limits = tool_result_context_limits_for_window(context_window); + let mut limits = tool_result_context_limits_for_window(context_window); + limits.hard_limit_chars = tool_result_max_bytes.unwrap_or(limits.hard_limit_chars); let raw_chars = raw.chars().count(); let should_compact = raw_chars > limits.hard_limit_chars || (tool_result_is_noisy(tool_name) && raw_chars > limits.noisy_soft_limit_chars); diff --git a/crates/tui/src/core/engine/tests.rs b/crates/tui/src/core/engine/tests.rs index 1800328b10..2d556439a0 100644 --- a/crates/tui/src/core/engine/tests.rs +++ b/crates/tui/src/core/engine/tests.rs @@ -7705,6 +7705,7 @@ fn registry_catalog_bypasses_generic_tool_result_compaction() { None, "registry_sync", &output, + None, ); assert_eq!(context, raw); @@ -13306,6 +13307,32 @@ fn v4_keeps_large_file_reads_but_compacts_noisy_shell_output() { assert!(legacy_context.len() < v4_context.len()); } +#[test] +fn configured_tool_result_budget_keeps_large_file_read_on_small_context_model() { + let content = "0123456789abcdef\n".repeat(4_000); + let output = ToolResult::success(content.clone()); + + let default_context = compact_tool_result_for_route( + ApiProvider::Deepseek, + "deepseek-v3.2-128k", + None, + "read_file", + &output, + None, + ); + assert!(default_context.contains("output compacted to protect context")); + + let configured_context = compact_tool_result_for_route( + ApiProvider::Deepseek, + "deepseek-v3.2-128k", + None, + "read_file", + &output, + Some(131_072), + ); + assert_eq!(configured_context, content.trim()); +} + #[test] fn evidence_bounded_preview_is_not_recompacted() { // The adaptive evidence envelope already produced an honest bounded @@ -13344,6 +13371,7 @@ fn codex_tool_retention_uses_oauth_route_window_not_asmall_contract_model_window Some(limits), "read_file", &output, + None, ); assert!(context.contains("output compacted to protect context")); diff --git a/crates/tui/src/core/engine/turn_loop.rs b/crates/tui/src/core/engine/turn_loop.rs index dceee4d67e..794dc47dfd 100644 --- a/crates/tui/src/core/engine/turn_loop.rs +++ b/crates/tui/src/core/engine/turn_loop.rs @@ -3714,6 +3714,10 @@ impl Engine { self.active_route_limits, &outcome.name, &output, + self.config + .workshop + .as_ref() + .and_then(|workshop| workshop.tool_result_max_bytes), ); let tool_was_executed = output .metadata diff --git a/crates/tui/src/tools/file.rs b/crates/tui/src/tools/file.rs index d40a56c6c9..422f3a25f6 100644 --- a/crates/tui/src/tools/file.rs +++ b/crates/tui/src/tools/file.rs @@ -458,6 +458,14 @@ fn is_codewhale_credential_path(path: &Path) -> bool { const READ_MAX_LINES: usize = 2_000; const READ_MAX_BYTES: usize = 50 * 1024; +fn configured_read_result_max_bytes(context: &ToolContext, default: usize) -> usize { + context + .large_output_router + .as_ref() + .map(|router| router.workshop_config().read_result_max_bytes_or(default)) + .unwrap_or(default) +} + type FileMutationMutex = AsyncMutex<()>; /// File primitives can also be invoked outside the native engine's global @@ -587,6 +595,10 @@ struct ContractReadWindow { /// line and UTF-8 byte budgets. A terminal newline is content but does not add /// a phantom line to the truncation counter. fn contract_read_window(content: &str) -> ContractReadWindow { + contract_read_window_with_limit(content, READ_MAX_BYTES) +} + +fn contract_read_window_with_limit(content: &str, max_bytes: usize) -> ContractReadWindow { let mut lines = if content.is_empty() { Vec::new() } else { @@ -595,10 +607,7 @@ fn contract_read_window(content: &str) -> ContractReadWindow { if content.ends_with('\n') { let _ = lines.pop(); } - if lines - .first() - .is_some_and(|line| line.len() > READ_MAX_BYTES) - { + if lines.first().is_some_and(|line| line.len() > max_bytes) { return ContractReadWindow { content: String::new(), shown_lines: 0, @@ -608,7 +617,7 @@ fn contract_read_window(content: &str) -> ContractReadWindow { }; } - if lines.len() <= READ_MAX_LINES && content.len() <= READ_MAX_BYTES { + if lines.len() <= READ_MAX_LINES && content.len() <= max_bytes { return ContractReadWindow { content: content.to_string(), shown_lines: lines.len(), @@ -623,7 +632,7 @@ fn contract_read_window(content: &str) -> ContractReadWindow { let mut truncated_by_bytes = false; for line in lines.iter().take(READ_MAX_LINES) { let next = line.len() + usize::from(!kept.is_empty()); - if bytes.saturating_add(next) > READ_MAX_BYTES { + if bytes.saturating_add(next) > max_bytes { truncated_by_bytes = true; break; } @@ -695,14 +704,15 @@ impl ReadFileTool { None => available, }; let selected_content = selected.join("\n"); - let window = contract_read_window(&selected_content); + let max_bytes = configured_read_result_max_bytes(context, READ_MAX_BYTES); + let window = contract_read_window_with_limit(&selected_content, max_bytes); let first_display = start + 1; let mut output = if window.first_line_too_large { let size = selected.first().map_or(0, |line| line.len()); format!( - "[Line {first_display} is {}, exceeds {} limit. Use bash: sed -n '{first_display}p' {path_str} | head -c {READ_MAX_BYTES}]", + "[Line {first_display} is {}, exceeds {} limit. Use bash: sed -n '{first_display}p' {path_str} | head -c {max_bytes}]", contract_format_size(size), - contract_format_size(READ_MAX_BYTES) + contract_format_size(max_bytes) ) } else { window.content @@ -714,8 +724,9 @@ impl ReadFileTool { let next_offset = last_display + 1; if window.truncated_by_bytes { output.push_str(&format!( - "\n\n[Showing lines {first_display}-{last_display} of {} (50KB limit). Use offset={next_offset} to continue.]", - all_lines.len() + "\n\n[Showing lines {first_display}-{last_display} of {} ({} limit). Use offset={next_offset} to continue.]", + all_lines.len(), + contract_format_size(max_bytes) )); } else { output.push_str(&format!( @@ -828,6 +839,8 @@ impl ToolSpec for ReadFileTool { ToolError::execution_failed(format!("Failed to read {}: {}", file_path.display(), e)) })?; let file_bytes = file.metadata().map(|meta| meta.len()).unwrap_or(u64::MAX); + let max_visible_bytes = configured_read_result_max_bytes(context, MAX_VISIBLE_BYTES); + let small_file_bytes = configured_read_result_max_bytes(context, SMALL_FILE_BYTES); let explicit_range = input .get("start_line") @@ -837,7 +850,7 @@ impl ToolSpec for ReadFileTool { // Small-file fast path. Only applies when the caller didn't pass an // explicit range — otherwise an explicit `start_line = 5` on a // tiny file would silently ignore the request. - if !explicit_range && file_bytes <= SMALL_FILE_BYTES as u64 { + if !explicit_range && file_bytes <= small_file_bytes as u64 { drop(file); let contents = fs::read_to_string(&file_path).map_err(|e| { ToolError::execution_failed(format!( @@ -877,6 +890,7 @@ impl ToolSpec for ReadFileTool { 1, DEFAULT_READ_LINES, Some(hash.as_str()), + max_visible_bytes, )); } @@ -967,6 +981,7 @@ impl ToolSpec for ReadFileTool { start_line, max_lines, hash.as_deref(), + max_visible_bytes, )) } } @@ -1080,6 +1095,7 @@ fn render_line_window( start_line: usize, max_lines: usize, content_hash: Option<&str>, + max_visible_bytes: usize, ) -> ToolResult { let zero_based_start = start_line - 1; let zero_based_end = std::cmp::min(zero_based_start + max_lines, total_lines); @@ -1096,9 +1112,9 @@ fn render_line_window( // short head (budget/5) plus the matching tail so the model sees both // ends of a long range. The full file already lives at `path_str` — the // recovery note names that absolute/workspace path for a re-read. - let truncated_by_bytes = numbered.len() > MAX_VISIBLE_BYTES; + let truncated_by_bytes = numbered.len() > max_visible_bytes; let shown_content = if truncated_by_bytes { - let (head, tail) = head_tail_for_budget(&numbered, MAX_VISIBLE_BYTES); + let (head, tail) = head_tail_for_budget(&numbered, max_visible_bytes); format!("{head}{BYTE_TRUNCATION_SEPARATOR}{tail}") } else { numbered @@ -1132,12 +1148,14 @@ fn render_line_window( // combination can ever reveal the elided middle, so the note must // not pretend otherwise — name the escape hatch that works. output.push_str(&format!( - "\n[TRUNCATED] Line {shown_first} alone exceeds 50KB; showing its head + tail. No line window can reveal the middle of one line — use a searched shell slice when needed.\n" + "\n[TRUNCATED] Line {shown_first} alone exceeds {}; showing its head + tail. No line window can reveal the middle of one line — use a searched shell slice when needed.\n", + contract_format_size(max_visible_bytes) )); } else { let narrower = (shown_last - shown_first).div_ceil(2).max(1); output.push_str(&format!( - "\n[TRUNCATED] The selected range exceeded 50KB; showing head + tail of lines {shown_first}-{shown_last}. Re-read narrower windows to see the middle, e.g. offset={shown_first} limit={narrower}, then advance offset.\n" + "\n[TRUNCATED] The selected range exceeded {}; showing head + tail of lines {shown_first}-{shown_last}. Re-read narrower windows to see the middle, e.g. offset={shown_first} limit={narrower}, then advance offset.\n", + contract_format_size(max_visible_bytes) )); } } diff --git a/crates/tui/src/tools/large_output_router.rs b/crates/tui/src/tools/large_output_router.rs index 7f610209f5..bd913af2f5 100644 --- a/crates/tui/src/tools/large_output_router.rs +++ b/crates/tui/src/tools/large_output_router.rs @@ -47,6 +47,25 @@ pub struct WorkshopConfig { /// `large_output_threshold_tokens`. #[serde(default)] pub per_tool_thresholds: Option>, + + /// Optional model-visible byte budget for `read` / hidden `read_file`. + /// + /// When unset, each call site keeps its own conservative constant + /// (`READ_MAX_BYTES` 50 KiB, `MAX_VISIBLE_BYTES` / `SMALL_FILE_BYTES` + /// 16 KiB). Setting this key applies the same budget at every one of + /// those sites via `.unwrap_or(CURRENT_CONSTANT)`. + #[serde(default)] + pub read_result_max_bytes: Option, + + /// Optional character budget for a single tool result on the context + /// path and the Chat Completions wire. + /// + /// When unset, the context hard limit stays 12 000 characters (48 000 + /// on routes whose window is ≥ 500K) and the wire sent budget stays + /// 12 000. Setting this key overrides those hard ceilings; it does not + /// disable session compaction or change the 4K+4K head/tail excerpt. + #[serde(default)] + pub tool_result_max_bytes: Option, } impl WorkshopConfig { @@ -61,6 +80,18 @@ impl WorkshopConfig { self.large_output_threshold_tokens .unwrap_or(DEFAULT_LARGE_OUTPUT_THRESHOLD_TOKENS) } + + /// Model-visible read budget, or `default` when the key is absent. + #[must_use] + pub fn read_result_max_bytes_or(&self, default: usize) -> usize { + self.read_result_max_bytes.unwrap_or(default) + } + + /// Tool-result character budget, or `default` when the key is absent. + #[must_use] + pub fn tool_result_max_bytes_or(&self, default: usize) -> usize { + self.tool_result_max_bytes.unwrap_or(default) + } } // ── Token estimation ────────────────────────────────────────────────────────── @@ -110,6 +141,12 @@ impl LargeOutputRouter { Self { config } } + /// The `[workshop]` table this router was built from. + #[must_use] + pub fn workshop_config(&self) -> &WorkshopConfig { + &self.config + } + /// Decide whether classic routing would synthesize `result`. /// /// This is used only by the rollback implementation. @@ -384,6 +421,7 @@ mod tests { let config = WorkshopConfig { large_output_threshold_tokens: Some(4096), per_tool_thresholds: Some(per_tool), + ..Default::default() }; let router = LargeOutputRouter::new(config); // 100 tokens * 3 = 300 chars → trigger with 400 chars @@ -432,4 +470,22 @@ mod tests { assert!(wrapped.contains("5000")); assert!(wrapped.contains("key facts here")); } + + #[test] + fn workshop_result_budgets_default_absent_and_parse_when_set() { + let absent: WorkshopConfig = toml::from_str("large_output_threshold_tokens = 4096\n") + .expect("parse workshop table without result budgets"); + assert_eq!(absent.read_result_max_bytes, None); + assert_eq!(absent.tool_result_max_bytes, None); + assert_eq!(absent.read_result_max_bytes_or(50 * 1024), 50 * 1024); + assert_eq!(absent.tool_result_max_bytes_or(12_000), 12_000); + + let present: WorkshopConfig = + toml::from_str("read_result_max_bytes = 131072\ntool_result_max_bytes = 131072\n") + .expect("parse workshop result budgets"); + assert_eq!(present.read_result_max_bytes, Some(131_072)); + assert_eq!(present.tool_result_max_bytes, Some(131_072)); + assert_eq!(present.read_result_max_bytes_or(50 * 1024), 131_072); + assert_eq!(present.tool_result_max_bytes_or(12_000), 131_072); + } } diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index c43a67e1ea..edea496a70 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -1632,6 +1632,10 @@ pub struct App { /// `/config mini_window.keep_*`. The renderer reads this instead of the /// parsed Config so runtime changes apply without a restart. pub(crate) mini_window: crate::config::MiniWindowConfig, + /// Optional `[workshop]` table from config.toml. Used to honor + /// `tool_result_max_bytes` when the TUI path compacts a tool result + /// for the next model turn. + pub(crate) workshop: Option, /// Ordered list of footer items the user wants visible. Sourced from /// `tui.status_items` in `~/.deepseek/config.toml` at startup; mutated /// live by `/statusline`. The renderer iterates this slice; no item is diff --git a/crates/tui/src/tui/app/init.rs b/crates/tui/src/tui/app/init.rs index b369576c90..40cc2d9263 100644 --- a/crates/tui/src/tui/app/init.rs +++ b/crates/tui/src/tui/app/init.rs @@ -862,6 +862,7 @@ impl App { trust_mode: yolo_compat || initial_mode == AppMode::Yolo || configured_trust_mode, translation_enabled: false, mini_window: config.mini_window.clone().unwrap_or_default(), + workshop: config.workshop.clone(), status_items: config .tui .as_ref() diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index e63e1340ac..59c3de8f17 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -1843,6 +1843,9 @@ async fn tool_result_content_for_api_message( app.active_route_limits, name, output, + app.workshop + .as_ref() + .and_then(|workshop| workshop.tool_result_max_bytes), ); } @@ -1869,6 +1872,9 @@ async fn tool_result_content_for_api_message( app.active_route_limits, name, output, + app.workshop + .as_ref() + .and_then(|workshop| workshop.tool_result_max_bytes), ) } diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 66c7fb1b89..3f5eb2c70b 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -772,6 +772,31 @@ compaction settings and [Token Quantities and Drivers](#token-quantities-and-drivers) for what each displayed token number actually measures. +### Model-visible read / tool-result budgets + +Self-hosted long-context routes still use conservative per-result ceilings so +a single `read` or tool result cannot flood a 128K window. Those ceilings are +independent of the session window: raising `context_window` does not enlarge +them. Optional `[workshop]` keys raise the individual-result budgets without +turning session compaction off (#5367). Omit them to keep today's defaults. + +```toml +[workshop] +# Model-visible `read` / hidden `read_file` byte budget. +# Unset: `read` stays at 50 KiB; `read_file` stays at 16 KiB +# (including the small-file fast path). +# read_result_max_bytes = 131072 + +# Character budget for one tool result on the context path and the Chat +# Completions wire. Unset: 12 000 characters (48 000 on routes whose +# window is ≥ 500K). Setting this overrides both hard ceilings. +# Does not disable session compaction or change the 4K+4K excerpt. +# tool_result_max_bytes = 131072 +``` + +Do not put these on `[[harness_profiles]]`. Harness profiles are a preview +schema and are not consumed at runtime. + ## Profiles You can define multiple profiles in the same file: