use std::collections::HashMap;
use std::io;
use std::path::PathBuf;
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]
pub fn evidence_routing(
&self,
tool_name: &str,
result: &ToolResult,
_raw_bypass: bool,
) -> (EvidenceRouting, usize, usize) {
let threshold = self.config.threshold_for(tool_name);
let estimated_tokens = estimate_tokens(&result.content);
let routing = EvidenceRouting::from_token_estimate(estimated_tokens, threshold);
(routing, estimated_tokens, threshold)
}
#[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))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EvidenceRouting {
Inline,
Hybrid,
HandleOnly,
}
impl EvidenceRouting {
#[must_use]
pub fn from_token_estimate(estimated_tokens: usize, threshold: usize) -> Self {
if estimated_tokens <= threshold / 4 {
Self::Inline
} else if estimated_tokens <= threshold {
Self::Hybrid
} else {
Self::HandleOnly
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EvidenceArtifact {
pub handle: String,
pub digest: String,
pub size_bytes: u64,
pub content_type: String,
pub tool_name: String,
pub call_id: String,
pub origin_session: String,
pub generation: u32,
pub redacted: bool,
pub encoding: String,
pub retention_state: EvidenceRetentionState,
pub created_at_unix_ms: u64,
pub retain_until_unix_ms: u64,
pub storage_path: PathBuf,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EvidenceRetentionState {
Live,
Expired,
}
pub const EVIDENCE_RETENTION_SECS: u64 = 7 * 24 * 60 * 60;
#[must_use]
pub fn classic_output_routing_enabled() -> bool {
std::env::var("CODEWHALE_CLASSIC_OUTPUT_ROUTING")
.ok()
.is_some_and(|value| matches!(value.trim(), "1" | "true" | "yes" | "on"))
}
#[must_use]
pub fn evidence_metadata_relative_path(handle: &str) -> PathBuf {
PathBuf::from(crate::artifacts::ARTIFACTS_DIR_NAME).join(format!("{handle}.evidence.json"))
}
pub fn publish_evidence_metadata(
session_id: &str,
artifact: &EvidenceArtifact,
) -> io::Result<PathBuf> {
let bytes = serde_json::to_vec_pretty(artifact)
.map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?;
crate::artifacts::write_session_relative_immutable(
session_id,
&evidence_metadata_relative_path(&artifact.handle),
&bytes,
)
}
pub fn read_evidence_metadata(session_id: &str, handle: &str) -> io::Result<EvidenceArtifact> {
let relative = evidence_metadata_relative_path(handle);
let path = crate::artifacts::session_artifact_absolute_path(session_id, &relative)
.ok_or_else(|| io::Error::new(io::ErrorKind::PermissionDenied, "invalid evidence owner"))?;
let raw = std::fs::read(path)?;
serde_json::from_slice(&raw).map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))
}
#[must_use]
pub fn unix_millis_now() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
.try_into()
.unwrap_or(u64::MAX)
}
#[must_use]
pub fn evidence_is_expired(artifact: &EvidenceArtifact, now_ms: u64) -> bool {
artifact.retention_state == EvidenceRetentionState::Expired
|| now_ms > artifact.retain_until_unix_ms
}
#[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 adaptive_evidence_cannot_bypass_context_bound_with_raw_flag() {
let router = LargeOutputRouter::default();
let big = make_result(&"a".repeat(13_000));
let (routing, _, _) = router.evidence_routing("exec_shell", &big, true);
assert_eq!(routing, EvidenceRouting::HandleOnly);
}
#[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"));
}
}