Skip to main content

vtcode_core/tools/
builder.rs

1//! Unified builder for tool responses
2//!
3//! Provides a consistent way to construct tool execution results with
4//! support for dual-channel output, structured metadata, and standardized error reporting.
5
6use hashbrown::HashMap;
7use serde_json::{Value, json};
8use std::path::PathBuf;
9
10use crate::tools::result::{ToolMetadataBuilder, ToolResult};
11
12/// Builder for standardized tool responses
13pub struct ToolResponseBuilder {
14    tool_name: String,
15    success: bool,
16    message: Option<String>,
17    content: Option<String>,
18    stdout: Option<String>,
19    modified_files: Vec<String>,
20    has_more: bool,
21    llm_content: Option<String>,
22    ui_content: Option<String>,
23    error: Option<String>,
24    metadata: ToolMetadataBuilder,
25    custom_fields: HashMap<String, Value>,
26}
27
28impl ToolResponseBuilder {
29    /// Create a new builder for the given tool
30    pub fn new(tool_name: impl Into<String>) -> Self {
31        Self {
32            tool_name: tool_name.into(),
33            success: true,
34            message: None,
35            content: None,
36            stdout: None,
37            modified_files: Vec::new(),
38            has_more: false,
39            llm_content: None,
40            ui_content: None,
41            error: None,
42            metadata: ToolMetadataBuilder::new(),
43            custom_fields: HashMap::new(),
44        }
45    }
46
47    /// Mark the execution as successful
48    pub fn success(mut self) -> Self {
49        self.success = true;
50        self
51    }
52
53    /// Mark the execution as failed with an error message
54    pub fn failure(mut self, error: impl Into<String>) -> Self {
55        self.success = false;
56        self.error = Some(error.into());
57        self
58    }
59
60    /// Set a user-friendly status message
61    pub fn message(mut self, message: impl Into<String>) -> Self {
62        self.message = Some(message.into());
63        self
64    }
65
66    /// Set the main content (used for both LLM and UI if not overridden)
67    pub fn content(mut self, content: impl Into<String>) -> Self {
68        self.content = Some(content.into());
69        self
70    }
71
72    /// Set standard output from a process
73    pub fn stdout(mut self, stdout: impl Into<String>) -> Self {
74        self.stdout = Some(stdout.into());
75        self
76    }
77
78    /// Add a modified file to the list
79    pub fn modified_file(mut self, path: impl Into<String>) -> Self {
80        self.modified_files.push(path.into());
81        self
82    }
83
84    /// Add multiple modified files
85    pub fn modified_files(mut self, paths: Vec<String>) -> Self {
86        self.modified_files.extend(paths);
87        self
88    }
89
90    /// Set whether there are more results available
91    pub fn has_more(mut self, has_more: bool) -> Self {
92        self.has_more = has_more;
93        self
94    }
95
96    /// Set explicit dual-channel content
97    pub fn dual_content(mut self, llm: impl Into<String>, ui: impl Into<String>) -> Self {
98        self.llm_content = Some(llm.into());
99        self.ui_content = Some(ui.into());
100        self
101    }
102
103    /// Add a file reference to the metadata (for UI linking)
104    pub fn file(mut self, path: impl Into<PathBuf>) -> Self {
105        self.metadata = self.metadata.file(path.into());
106        self
107    }
108
109    /// Add multiple file references
110    pub fn files(mut self, paths: Vec<PathBuf>) -> Self {
111        self.metadata = self.metadata.files(paths);
112        self
113    }
114
115    /// Add structured data to the metadata
116    pub fn data(mut self, key: impl Into<String>, value: Value) -> Self {
117        self.metadata = self.metadata.data(key, value);
118        self
119    }
120
121    /// Add a custom top-level field to the final JSON response
122    pub fn field(mut self, key: impl Into<String>, value: Value) -> Self {
123        self.custom_fields.insert(key.into(), value);
124        self
125    }
126
127    /// Build the legacy JSON Value response
128    pub fn build_json(self) -> Value {
129        let mut res = json!({
130            "success": self.success,
131            "status": if self.success { "success" } else { "error" },
132        });
133
134        let Some(obj) = res.as_object_mut() else {
135            return res;
136        };
137
138        if let Some(msg) = self.message {
139            obj.insert("message".to_string(), json!(msg));
140        }
141
142        if let Some(err) = self.error {
143            obj.insert("error".to_string(), json!(err));
144        }
145
146        let content_value = self.content;
147        if let Some(c) = content_value.as_ref() {
148            obj.insert("content".to_string(), json!(c));
149        }
150
151        if let Some(s) = self.stdout {
152            let duplicates_content = content_value.as_deref() == Some(s.as_str());
153            if !duplicates_content {
154                obj.insert("stdout".to_string(), json!(s));
155            }
156        }
157
158        if !self.modified_files.is_empty() {
159            obj.insert("modified_files".to_string(), json!(self.modified_files));
160        }
161
162        if self.has_more {
163            obj.insert("has_more".to_string(), json!(true));
164        }
165
166        // Build and merge metadata. Keys already surfaced as top-level custom
167        // fields stay top-level only: duplicating them inside `metadata.data`
168        // re-serializes the same values on every tool result (read_file paid
169        // that tax for `content_kind`/`encoding` on every call). Only explicit
170        // `.field()` twins dedup — a `.data()` entry sharing a name with a
171        // builder-structural key (success/status/message/...) must survive.
172        let mut meta = self.metadata.build();
173        meta.data.retain(|key, _| !self.custom_fields.contains_key(key));
174        if !meta.data.is_empty() || !meta.files.is_empty() || !meta.lines.is_empty() {
175            obj.insert("metadata".to_string(), json!(meta));
176        }
177
178        // Add custom top-level fields
179        for (k, v) in self.custom_fields {
180            obj.insert(k, v);
181        }
182
183        res
184    }
185
186    /// Build the modern dual-channel ToolResult
187    pub fn build_result(self) -> ToolResult {
188        if !self.success {
189            return ToolResult::error(self.tool_name, self.error.unwrap_or_else(|| "Unknown error".to_string()));
190        }
191
192        let llm = self.llm_content.or_else(|| self.content.clone()).unwrap_or_default();
193        let ui = self.ui_content.or_else(|| self.content.clone()).unwrap_or_default();
194
195        let mut res = ToolResult::new(self.tool_name, llm, ui);
196        res.metadata = self.metadata.build();
197
198        // Add custom fields to metadata data map
199        for (k, v) in self.custom_fields {
200            res.metadata.data.insert(k, v);
201        }
202
203        res
204    }
205}
206
207#[cfg(test)]
208mod tests {
209    use super::ToolResponseBuilder;
210    use serde_json::json;
211
212    #[test]
213    fn build_json_omits_stdout_when_same_as_content() {
214        let value = ToolResponseBuilder::new("test").content("same").stdout("same").build_json();
215
216        assert_eq!(value.get("content").and_then(|v| v.as_str()), Some("same"));
217        assert!(value.get("stdout").is_none());
218    }
219
220    #[test]
221    fn build_json_keeps_metadata_only_keys_and_deduplicates_field_keys() {
222        let value = ToolResponseBuilder::new("read_file")
223            .field("content_kind", json!("text"))
224            .field("encoding", json!("utf8"))
225            .data("content_kind", json!("text"))
226            .data("encoding", json!("utf8"))
227            .data("size_bytes", json!(1024))
228            .build_json();
229
230        // Duplicated keys stay top-level only; metadata-only keys survive.
231        assert_eq!(value["content_kind"], json!("text"));
232        assert_eq!(value["encoding"], json!("utf8"));
233        assert_eq!(value["metadata"]["data"]["size_bytes"], json!(1024));
234        assert!(value["metadata"]["data"].get("content_kind").is_none());
235        assert!(value["metadata"]["data"].get("encoding").is_none());
236    }
237
238    #[test]
239    fn build_json_omits_metadata_object_when_all_data_keys_are_duplicates() {
240        let value = ToolResponseBuilder::new("read_file")
241            .field("content_kind", json!("text"))
242            .data("content_kind", json!("text"))
243            .build_json();
244
245        assert_eq!(value["content_kind"], json!("text"));
246        assert!(value.get("metadata").is_none());
247    }
248
249    #[test]
250    fn build_json_keeps_data_keys_colliding_with_structural_names() {
251        // A `.data()` entry must not vanish because it shares a name with a
252        // builder-structural key; only explicit `.field()` twins dedup.
253        let value = ToolResponseBuilder::new("read_file")
254            .message("done")
255            .field("kind", json!("top"))
256            .data("message", json!("from data"))
257            .data("kind", json!("from data"))
258            .build_json();
259
260        assert_eq!(value["message"], json!("done"));
261        assert_eq!(value["kind"], json!("top"));
262        assert_eq!(value["metadata"]["data"]["message"], json!("from data"));
263        assert!(value["metadata"]["data"].get("kind").is_none());
264    }
265}