use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use crate::tools::spec::ToolResult;
pub const DEFAULT_LARGE_OUTPUT_THRESHOLD_TOKENS: usize = 4_096;
const CHARS_PER_TOKEN_ESTIMATE: usize = 3;
pub const WORKSHOP_LAST_TOOL_RESULT_VAR: &str = "last_tool_result";
#[derive(Debug, Clone, Deserialize, Default)]
pub struct WorkshopConfig {
#[serde(default)]
pub large_output_threshold_tokens: Option<usize>,
#[serde(default)]
pub per_tool_thresholds: Option<HashMap<String, usize>>,
}
impl WorkshopConfig {
#[must_use]
pub fn threshold_for(&self, tool_name: &str) -> usize {
if let Some(per_tool) = self.per_tool_thresholds.as_ref()
&& let Some(&limit) = per_tool.get(tool_name)
{
return limit;
}
self.large_output_threshold_tokens
.unwrap_or(DEFAULT_LARGE_OUTPUT_THRESHOLD_TOKENS)
}
}
#[must_use]
pub fn estimate_tokens(text: &str) -> usize {
let chars = text.chars().count();
chars.div_ceil(CHARS_PER_TOKEN_ESTIMATE)
}
#[derive(Debug, Clone, PartialEq)]
pub enum RouteDecision {
PassThrough,
Synthesise {
estimated_tokens: usize,
threshold: usize,
},
}
#[derive(Debug, Clone, Default)]
pub struct LargeOutputRouter {
config: WorkshopConfig,
}
impl LargeOutputRouter {
#[must_use]
pub fn new(config: WorkshopConfig) -> Self {
Self { config }
}
#[must_use]
pub fn route(&self, tool_name: &str, result: &ToolResult, raw_bypass: bool) -> RouteDecision {
if raw_bypass || !result.success {
return RouteDecision::PassThrough;
}
let threshold = self.config.threshold_for(tool_name);
let estimated_tokens = estimate_tokens(&result.content);
if estimated_tokens > threshold {
RouteDecision::Synthesise {
estimated_tokens,
threshold,
}
} else {
RouteDecision::PassThrough
}
}
#[must_use]
#[allow(dead_code)] pub fn synthesis_prompt(tool_name: &str, raw_output: &str, estimated_tokens: usize) -> String {
format!(
"You are a synthesis assistant. The tool `{tool_name}` produced {estimated_tokens} tokens \
of output that is too large to include directly in the parent context.\n\n\
Summarise the output below into a concise, faithful synthesis of ≤ 800 words. \
Preserve key facts, numbers, file paths, error messages, and any actionable \
information. Do NOT add commentary or interpretation beyond what is in the source.\n\n\
<raw_tool_output>\n{raw_output}\n</raw_tool_output>"
)
}
#[must_use]
pub fn wrap_synthesis(
tool_name: &str,
synthesis: &str,
estimated_tokens: usize,
threshold: usize,
) -> String {
format!(
"[workshop-synthesis: tool={tool_name}, raw_tokens≈{estimated_tokens}, \
threshold={threshold}, raw_stored_in={WORKSHOP_LAST_TOOL_RESULT_VAR}]\n\n{synthesis}"
)
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct WorkshopVariables {
#[serde(default)]
pub last_tool_result: String,
#[serde(default)]
pub last_tool_name: String,
}
impl WorkshopVariables {
pub fn store_raw(&mut self, tool_name: &str, raw: &str) {
self.last_tool_result = raw.to_string();
self.last_tool_name = tool_name.to_string();
}
#[must_use]
#[allow(dead_code)] pub fn take_raw(&mut self) -> Option<(String, String)> {
if self.last_tool_result.is_empty() {
return None;
}
let content = std::mem::take(&mut self.last_tool_result);
let name = std::mem::take(&mut self.last_tool_name);
Some((name, content))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_result(content: &str) -> ToolResult {
ToolResult::success(content.to_string())
}
#[test]
fn pass_through_below_threshold() {
let router = LargeOutputRouter::default();
let small = "x".repeat(100);
let result = make_result(&small);
assert_eq!(
router.route("read_file", &result, false),
RouteDecision::PassThrough
);
}
#[test]
fn synthesise_above_threshold() {
let router = LargeOutputRouter::default();
let big = "a".repeat(13_000);
let result = make_result(&big);
assert!(matches!(
router.route("read_file", &result, false),
RouteDecision::Synthesise { .. }
));
}
#[test]
fn raw_bypass_skips_routing() {
let router = LargeOutputRouter::default();
let big = "a".repeat(13_000);
let result = make_result(&big);
assert_eq!(
router.route("exec_shell", &result, true),
RouteDecision::PassThrough
);
}
#[test]
fn error_results_always_pass_through() {
let router = LargeOutputRouter::default();
let big = "error: ".repeat(2_000);
let result = ToolResult::error(big);
assert_eq!(
router.route("exec_shell", &result, false),
RouteDecision::PassThrough
);
}
#[test]
fn per_tool_threshold_override() {
let mut per_tool = HashMap::new();
per_tool.insert("grep_files".to_string(), 100); let config = WorkshopConfig {
large_output_threshold_tokens: Some(4096),
per_tool_thresholds: Some(per_tool),
};
let router = LargeOutputRouter::new(config);
let medium = "b".repeat(400);
let result = make_result(&medium);
assert!(matches!(
router.route("grep_files", &result, false),
RouteDecision::Synthesise { .. }
));
assert_eq!(
router.route("read_file", &result, false),
RouteDecision::PassThrough
);
}
#[test]
fn estimate_tokens_conservative() {
assert_eq!(estimate_tokens("123456789"), 3);
assert_eq!(estimate_tokens("1234567890"), 4);
assert_eq!(estimate_tokens(""), 0);
}
#[test]
fn workshop_variables_store_and_take() {
let mut vars = WorkshopVariables::default();
assert!(vars.take_raw().is_none());
vars.store_raw("read_file", "raw content here");
let taken = vars.take_raw().expect("should have content");
assert_eq!(taken.0, "read_file");
assert_eq!(taken.1, "raw content here");
assert!(vars.take_raw().is_none());
}
#[test]
fn wrap_synthesis_includes_provenance_header() {
let wrapped = LargeOutputRouter::wrap_synthesis("web_search", "key facts here", 5000, 4096);
assert!(wrapped.contains("workshop-synthesis"));
assert!(wrapped.contains("web_search"));
assert!(wrapped.contains("5000"));
assert!(wrapped.contains("key facts here"));
}
}