heartbit_core/browser/
distill_tool.rs1use std::sync::Arc;
20
21use serde_json::Value;
22
23use crate::error::Error;
24use crate::execution_context::ExecutionContext;
25use crate::llm::types::ToolDefinition;
26use crate::tool::{Tool, ToolOutput};
27
28use super::distill::{DistillConfig, distill_snapshot};
29
30pub struct DistillingTool {
32 inner: Arc<dyn Tool>,
33 cfg: DistillConfig,
34}
35
36impl DistillingTool {
37 pub fn wrap(inner: Arc<dyn Tool>, cfg: DistillConfig) -> Self {
39 Self { inner, cfg }
40 }
41
42 pub fn wrap_all(tools: Vec<Arc<dyn Tool>>, cfg: DistillConfig) -> Vec<Arc<dyn Tool>> {
44 tools
45 .into_iter()
46 .map(|t| Arc::new(Self::wrap(t, cfg.clone())) as Arc<dyn Tool>)
47 .collect()
48 }
49}
50
51impl Tool for DistillingTool {
52 fn definition(&self) -> ToolDefinition {
53 self.inner.definition()
54 }
55
56 fn execute(
57 &self,
58 ctx: &ExecutionContext,
59 input: Value,
60 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<ToolOutput, Error>> + Send + '_>>
61 {
62 let inner = Arc::clone(&self.inner);
65 let cfg = self.cfg.clone();
66 let ctx = ctx.clone();
67 Box::pin(async move {
68 let out = inner.execute(&ctx, input).await?;
69 if out.content.contains("uid=") {
71 Ok(ToolOutput {
72 content: distill_snapshot(&out.content, &cfg),
73 is_error: out.is_error,
74 })
75 } else {
76 Ok(out)
77 }
78 })
79 }
80}
81
82#[cfg(test)]
83mod tests {
84 use super::*;
85
86 struct FixedTool {
88 name: String,
89 output: ToolOutput,
90 }
91 impl Tool for FixedTool {
92 fn definition(&self) -> ToolDefinition {
93 ToolDefinition {
94 name: self.name.clone(),
95 description: "fixed".into(),
96 input_schema: serde_json::json!({ "type": "object" }),
97 }
98 }
99 fn execute(
100 &self,
101 _ctx: &ExecutionContext,
102 _input: Value,
103 ) -> std::pin::Pin<
104 Box<dyn std::future::Future<Output = Result<ToolOutput, Error>> + Send + '_>,
105 > {
106 let out = self.output.clone();
107 Box::pin(async move { Ok(out) })
108 }
109 }
110
111 fn fixed(name: &str, content: &str, is_error: bool) -> Arc<dyn Tool> {
112 Arc::new(FixedTool {
113 name: name.to_string(),
114 output: ToolOutput {
115 content: content.to_string(),
116 is_error,
117 },
118 })
119 }
120
121 const SNAP: &str = r#"## Latest page snapshot
124uid=1_0 RootWebArea "Hacker News" url="https://news.ycombinator.com/"
125 uid=1_2 link "Hacker News" url="https://news.ycombinator.com/news"
126 uid=1_3 StaticText "Hacker News"
127 uid=1_4 link "new" url="https://news.ycombinator.com/newest"
128 uid=1_5 StaticText "new""#;
129
130 #[tokio::test]
131 async fn distills_snapshot_output_and_shrinks_it() {
132 let tool = DistillingTool::wrap(
133 fixed("take_snapshot", SNAP, false),
134 DistillConfig::default(),
135 );
136 let out = tool
137 .execute(&ExecutionContext::default(), serde_json::json!({}))
138 .await
139 .expect("ok");
140 assert!(
141 out.content.len() < SNAP.len(),
142 "distilled output must be smaller"
143 );
144 assert!(out.content.contains("uid=1_2 link \"Hacker News\""));
146 assert!(out.content.contains("uid=1_4 link \"new\""));
147 assert!(!out.content.contains("uid=1_3 StaticText \"Hacker News\""));
149 assert!(out.content.contains("## Latest page snapshot"));
151 }
152
153 #[tokio::test]
154 async fn preserves_every_interactive_uid() {
155 let tool = DistillingTool::wrap(
156 fixed("take_snapshot", SNAP, false),
157 DistillConfig::default(),
158 );
159 let out = tool
160 .execute(&ExecutionContext::default(), serde_json::json!({}))
161 .await
162 .expect("ok");
163 for uid in ["uid=1_2", "uid=1_4"] {
164 assert!(out.content.contains(uid), "interactive {uid} must survive");
165 }
166 }
167
168 #[tokio::test]
169 async fn non_snapshot_output_passes_through_untouched() {
170 let raw = "reqid=3 GET https://example.com/ [304]\nreqid=4 GET /style.css [200]";
172 let tool = DistillingTool::wrap(
173 fixed("list_network_requests", raw, false),
174 DistillConfig::default(),
175 );
176 let out = tool
177 .execute(&ExecutionContext::default(), serde_json::json!({}))
178 .await
179 .expect("ok");
180 assert_eq!(out.content, raw, "non-snapshot output must be untouched");
181 }
182
183 #[tokio::test]
184 async fn preserves_is_error_flag() {
185 let tool = DistillingTool::wrap(
186 fixed("click", "Error: no element with uid=9_9", true),
187 DistillConfig::default(),
188 );
189 let out = tool
190 .execute(&ExecutionContext::default(), serde_json::json!({}))
191 .await
192 .expect("ok");
193 assert!(
194 out.is_error,
195 "error flag must be preserved through distillation"
196 );
197 }
198
199 #[tokio::test]
200 async fn definition_is_passthrough() {
201 let tool = DistillingTool::wrap(
202 fixed("take_snapshot", SNAP, false),
203 DistillConfig::default(),
204 );
205 assert_eq!(tool.definition().name, "take_snapshot");
206 }
207
208 #[test]
209 fn wrap_all_preserves_count_and_names() {
210 let tools = vec![
211 fixed("take_snapshot", SNAP, false),
212 fixed("click", "ok", false),
213 ];
214 let wrapped = DistillingTool::wrap_all(tools, DistillConfig::default());
215 assert_eq!(wrapped.len(), 2);
216 assert_eq!(wrapped[0].definition().name, "take_snapshot");
217 assert_eq!(wrapped[1].definition().name, "click");
218 }
219}