use std::sync::Arc;
use serde_json::Value;
use crate::error::Error;
use crate::execution_context::ExecutionContext;
use crate::llm::types::ToolDefinition;
use crate::tool::{Tool, ToolOutput};
use super::distill::{DistillConfig, distill_snapshot};
pub struct DistillingTool {
inner: Arc<dyn Tool>,
cfg: DistillConfig,
}
impl DistillingTool {
pub fn wrap(inner: Arc<dyn Tool>, cfg: DistillConfig) -> Self {
Self { inner, cfg }
}
pub fn wrap_all(tools: Vec<Arc<dyn Tool>>, cfg: DistillConfig) -> Vec<Arc<dyn Tool>> {
tools
.into_iter()
.map(|t| Arc::new(Self::wrap(t, cfg.clone())) as Arc<dyn Tool>)
.collect()
}
}
impl Tool for DistillingTool {
fn definition(&self) -> ToolDefinition {
self.inner.definition()
}
fn execute(
&self,
ctx: &ExecutionContext,
input: Value,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<ToolOutput, Error>> + Send + '_>>
{
let inner = Arc::clone(&self.inner);
let cfg = self.cfg.clone();
let ctx = ctx.clone();
Box::pin(async move {
let out = inner.execute(&ctx, input).await?;
if out.content.contains("uid=") {
Ok(ToolOutput {
content: distill_snapshot(&out.content, &cfg),
is_error: out.is_error,
})
} else {
Ok(out)
}
})
}
}
#[cfg(test)]
mod tests {
use super::*;
struct FixedTool {
name: String,
output: ToolOutput,
}
impl Tool for FixedTool {
fn definition(&self) -> ToolDefinition {
ToolDefinition {
name: self.name.clone(),
description: "fixed".into(),
input_schema: serde_json::json!({ "type": "object" }),
}
}
fn execute(
&self,
_ctx: &ExecutionContext,
_input: Value,
) -> std::pin::Pin<
Box<dyn std::future::Future<Output = Result<ToolOutput, Error>> + Send + '_>,
> {
let out = self.output.clone();
Box::pin(async move { Ok(out) })
}
}
fn fixed(name: &str, content: &str, is_error: bool) -> Arc<dyn Tool> {
Arc::new(FixedTool {
name: name.to_string(),
output: ToolOutput {
content: content.to_string(),
is_error,
},
})
}
const SNAP: &str = r#"## Latest page snapshot
uid=1_0 RootWebArea "Hacker News" url="https://news.ycombinator.com/"
uid=1_2 link "Hacker News" url="https://news.ycombinator.com/news"
uid=1_3 StaticText "Hacker News"
uid=1_4 link "new" url="https://news.ycombinator.com/newest"
uid=1_5 StaticText "new""#;
#[tokio::test]
async fn distills_snapshot_output_and_shrinks_it() {
let tool = DistillingTool::wrap(
fixed("take_snapshot", SNAP, false),
DistillConfig::default(),
);
let out = tool
.execute(&ExecutionContext::default(), serde_json::json!({}))
.await
.expect("ok");
assert!(
out.content.len() < SNAP.len(),
"distilled output must be smaller"
);
assert!(out.content.contains("uid=1_2 link \"Hacker News\""));
assert!(out.content.contains("uid=1_4 link \"new\""));
assert!(!out.content.contains("uid=1_3 StaticText \"Hacker News\""));
assert!(out.content.contains("## Latest page snapshot"));
}
#[tokio::test]
async fn preserves_every_interactive_uid() {
let tool = DistillingTool::wrap(
fixed("take_snapshot", SNAP, false),
DistillConfig::default(),
);
let out = tool
.execute(&ExecutionContext::default(), serde_json::json!({}))
.await
.expect("ok");
for uid in ["uid=1_2", "uid=1_4"] {
assert!(out.content.contains(uid), "interactive {uid} must survive");
}
}
#[tokio::test]
async fn non_snapshot_output_passes_through_untouched() {
let raw = "reqid=3 GET https://example.com/ [304]\nreqid=4 GET /style.css [200]";
let tool = DistillingTool::wrap(
fixed("list_network_requests", raw, false),
DistillConfig::default(),
);
let out = tool
.execute(&ExecutionContext::default(), serde_json::json!({}))
.await
.expect("ok");
assert_eq!(out.content, raw, "non-snapshot output must be untouched");
}
#[tokio::test]
async fn preserves_is_error_flag() {
let tool = DistillingTool::wrap(
fixed("click", "Error: no element with uid=9_9", true),
DistillConfig::default(),
);
let out = tool
.execute(&ExecutionContext::default(), serde_json::json!({}))
.await
.expect("ok");
assert!(
out.is_error,
"error flag must be preserved through distillation"
);
}
#[tokio::test]
async fn definition_is_passthrough() {
let tool = DistillingTool::wrap(
fixed("take_snapshot", SNAP, false),
DistillConfig::default(),
);
assert_eq!(tool.definition().name, "take_snapshot");
}
#[test]
fn wrap_all_preserves_count_and_names() {
let tools = vec![
fixed("take_snapshot", SNAP, false),
fixed("click", "ok", false),
];
let wrapped = DistillingTool::wrap_all(tools, DistillConfig::default());
assert_eq!(wrapped.len(), 2);
assert_eq!(wrapped[0].definition().name, "take_snapshot");
assert_eq!(wrapped[1].definition().name, "click");
}
}