Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions crates/tui/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,9 @@ pub struct DeepSeekClient {
test_messages_transport_base_url: Option<String>,
pub(super) reasoning_stream_style: Option<String>,
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<usize>,
}

const CONNECTION_FAILURE_THRESHOLD: u32 = 2;
Expand Down Expand Up @@ -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,
}
}
}
Expand Down Expand Up @@ -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),
})
}

Expand Down Expand Up @@ -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(),
Expand Down
44 changes: 37 additions & 7 deletions crates/tui/src/client/chat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -982,9 +982,11 @@ pub(crate) fn build_chat_wire_body(
provider: ApiProvider,
base_url: &str,
stream: bool,
tool_result_max_bytes: Option<usize>,
) -> Result<ChatWireBody> {
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()
Expand Down Expand Up @@ -1497,6 +1499,7 @@ pub(super) fn build_chat_messages(
model,
should_replay_reasoning_content(model, None),
false,
None,
)
}

Expand Down Expand Up @@ -1541,6 +1544,7 @@ struct PromptBuilder<'a> {
tools: Option<&'a [Tool]>,
model: &'a str,
reasoning_effort: Option<&'a str>,
tool_result_max_bytes: Option<usize>,
}

impl<'a> PromptBuilder<'a> {
Expand All @@ -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<usize>) -> Self {
self.tool_result_max_bytes = tool_result_max_bytes;
self
}

#[cfg(test)]
fn build(self) -> Vec<Value> {
build_chat_messages_with_reasoning(
Expand All @@ -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,
)
}

Expand All @@ -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 {
Expand All @@ -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)
}
Expand Down Expand Up @@ -2250,15 +2263,16 @@ fn compact_tool_result_for_wire(
content: &str,
message_label: &str,
seen_tool_results: &mut HashMap<String, SeenToolResult>,
tool_result_max_bytes: Option<usize>,
) -> 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) {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<usize>,
) -> Vec<Value> {
let mut out = Vec::new();
let mut pending_tool_calls: HashMap<String, PendingToolCallInfo> = HashMap::new();
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -5869,6 +5885,7 @@ mod image_block_wire_tests {
ApiProvider::Openai,
"https://api.openai.com/v1",
false,
None,
)
.expect("wire body");

Expand Down Expand Up @@ -5915,6 +5932,7 @@ mod image_block_wire_tests {
ApiProvider::Openai,
"https://api.openai.com/v1",
false,
None,
)
.expect("wire body");

Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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");
Expand All @@ -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");
}
Expand Down Expand Up @@ -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(
Expand All @@ -6560,6 +6583,7 @@ mod google_thought_signature_tests {
ApiProvider::Google,
DEFAULT_GOOGLE_BASE_URL,
false,
None,
)
.expect("valid google body");
assert_eq!(
Expand All @@ -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")
Expand Down
2 changes: 2 additions & 0 deletions crates/tui/src/client/prepared.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1314,6 +1314,7 @@ mod dialect_seam_tests {
client.api_provider(),
client.base_url(),
true,
None,
)
.expect("reference body builds");

Expand Down Expand Up @@ -1361,6 +1362,7 @@ mod dialect_seam_tests {
client.api_provider(),
client.base_url(),
true,
None,
)
.expect("reference body builds");
assert_eq!(
Expand Down
6 changes: 4 additions & 2 deletions crates/tui/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2741,8 +2741,10 @@ pub struct Config {
#[serde(default)]
pub runtime_api: Option<RuntimeApiConfig>,

/// 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<crate::tools::large_output_router::WorkshopConfig>,

Expand Down
6 changes: 4 additions & 2 deletions crates/tui/src/core/engine/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -426,6 +426,7 @@ pub(crate) fn compact_tool_result_for_route(
route_limits: Option<RouteLimits>,
tool_name: &str,
output: &ToolResult,
tool_result_max_bytes: Option<usize>,
) -> String {
let raw = output.content.trim();
if raw.is_empty() {
Expand Down Expand Up @@ -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);
Expand Down
28 changes: 28 additions & 0 deletions crates/tui/src/core/engine/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7705,6 +7705,7 @@ fn registry_catalog_bypasses_generic_tool_result_compaction() {
None,
"registry_sync",
&output,
None,
);

assert_eq!(context, raw);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"));
Expand Down
4 changes: 4 additions & 0 deletions crates/tui/src/core/engine/turn_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading