1use std::collections::BTreeMap;
18
19use devboy_core::{CostModel, FollowUpLink, SideEffectClass, ToolValueModel, ValueClass};
20
21pub fn default_tool_value_models() -> BTreeMap<String, ToolValueModel> {
25 let mut m = BTreeMap::new();
26
27 m.insert(
32 "Read".into(),
33 ToolValueModel {
34 value_class: ValueClass::Critical,
35 cost_model: CostModel {
36 typical_kb: 2.5,
37 max_kb: Some(43.0),
38 latency_ms_p50: Some(50),
39 ..CostModel::default()
40 },
41 follow_up: vec![FollowUpLink {
42 tool: "Read".into(),
43 probability: 0.45,
44 ..FollowUpLink::default()
45 }],
46 side_effect_class: SideEffectClass::Pure,
48 ..ToolValueModel::default()
49 },
50 );
51
52 m.insert(
58 "Edit".into(),
59 ToolValueModel {
60 value_class: ValueClass::Supporting,
61 cost_model: CostModel {
62 typical_kb: 0.2,
63 max_kb: Some(1.0),
64 latency_ms_p50: Some(20),
65 ..CostModel::default()
66 },
67 follow_up: vec![
68 FollowUpLink {
69 tool: "Bash".into(),
70 probability: 0.27,
71 ..FollowUpLink::default()
72 },
73 FollowUpLink {
74 tool: "Read".into(),
75 probability: 0.14,
76 ..FollowUpLink::default()
77 },
78 ],
79 invalidates: vec!["Read".into(), "Grep".into()],
80 side_effect_class: SideEffectClass::MutatesLocal,
83 ..ToolValueModel::default()
84 },
85 );
86 m.insert(
87 "Write".into(),
88 ToolValueModel {
89 value_class: ValueClass::Supporting,
90 cost_model: CostModel {
91 typical_kb: 0.2,
92 ..CostModel::default()
93 },
94 invalidates: vec!["Read".into(), "Grep".into(), "Glob".into()],
95 side_effect_class: SideEffectClass::MutatesLocal,
96 ..ToolValueModel::default()
97 },
98 );
99 m.insert(
100 "MultiEdit".into(),
101 ToolValueModel {
102 value_class: ValueClass::Supporting,
103 cost_model: CostModel {
104 typical_kb: 0.2,
105 ..CostModel::default()
106 },
107 invalidates: vec!["Read".into(), "Grep".into()],
108 side_effect_class: SideEffectClass::MutatesLocal,
109 ..ToolValueModel::default()
110 },
111 );
112 m.insert(
113 "NotebookEdit".into(),
114 ToolValueModel {
115 value_class: ValueClass::Supporting,
116 cost_model: CostModel {
117 typical_kb: 0.5,
118 ..CostModel::default()
119 },
120 invalidates: vec!["Read".into()],
121 side_effect_class: SideEffectClass::MutatesLocal,
122 ..ToolValueModel::default()
123 },
124 );
125
126 m.insert(
131 "Bash".into(),
132 ToolValueModel {
133 value_class: ValueClass::Critical,
134 cost_model: CostModel {
135 typical_kb: 0.2,
136 max_kb: Some(9.0),
137 latency_ms_p50: Some(200),
138 ..CostModel::default()
139 },
140 side_effect_class: SideEffectClass::Indeterminate,
144 ..ToolValueModel::default()
145 },
146 );
147
148 m.insert(
152 "Grep".into(),
153 ToolValueModel {
154 value_class: ValueClass::Critical,
155 cost_model: CostModel {
156 typical_kb: 0.3,
157 max_kb: Some(10.5),
158 latency_ms_p50: Some(80),
159 ..CostModel::default()
160 },
161 follow_up: vec![
162 FollowUpLink {
163 tool: "Read".into(),
164 probability: 0.35,
165 projection: Some("path".into()),
166 projection_arg: Some("file_path".into()),
167 },
168 FollowUpLink {
171 tool: "Edit".into(),
172 probability: 0.07,
173 projection: Some("path".into()),
174 projection_arg: Some("file_path".into()),
175 },
176 FollowUpLink {
177 tool: "Grep".into(),
178 probability: 0.39,
179 ..FollowUpLink::default()
180 },
181 ],
182 side_effect_class: SideEffectClass::Pure,
185 ..ToolValueModel::default()
186 },
187 );
188
189 m.insert(
193 "Glob".into(),
194 ToolValueModel {
195 value_class: ValueClass::Supporting,
196 cost_model: CostModel {
197 typical_kb: 0.2,
198 max_kb: Some(16.6),
199 latency_ms_p50: Some(60),
200 ..CostModel::default()
201 },
202 follow_up: vec![
203 FollowUpLink {
204 tool: "Read".into(),
205 probability: 0.32,
206 projection: Some("match_path".into()),
207 projection_arg: Some("file_path".into()),
208 },
209 FollowUpLink {
210 tool: "Grep".into(),
211 probability: 0.13,
212 ..FollowUpLink::default()
216 },
217 FollowUpLink {
218 tool: "Glob".into(),
219 probability: 0.41,
220 ..FollowUpLink::default()
221 },
222 ],
223 side_effect_class: SideEffectClass::ReadOnly,
224 ..ToolValueModel::default()
225 },
226 );
227
228 m.insert(
232 "WebSearch".into(),
233 ToolValueModel {
234 value_class: ValueClass::Supporting,
235 cost_model: CostModel {
236 typical_kb: 3.1,
237 max_kb: Some(7.2),
238 latency_ms_p50: Some(900),
239 freshness_ttl_s: Some(3600),
240 ..CostModel::default()
241 },
242 side_effect_class: SideEffectClass::ReadOnly,
245 rate_limit_host: None,
246 follow_up: vec![FollowUpLink {
247 tool: "WebFetch".into(),
248 probability: 0.65,
249 projection: Some("url".into()),
250 projection_arg: Some("url".into()),
251 }],
252 field_groups: {
253 let mut g = BTreeMap::new();
254 g.insert(
255 "must_have".into(),
256 devboy_core::FieldGroup {
257 fields: vec!["title".into(), "url".into()],
258 estimated_value: 1.0,
259 default_include: true,
260 },
261 );
262 g.insert(
263 "nice_to_have".into(),
264 devboy_core::FieldGroup {
265 fields: vec!["snippet".into()],
266 estimated_value: 0.3,
267 default_include: false,
268 },
269 );
270 g
271 },
272 ..ToolValueModel::default()
273 },
274 );
275 m.insert(
276 "WebFetch".into(),
277 ToolValueModel {
278 value_class: ValueClass::Supporting,
279 cost_model: CostModel {
280 typical_kb: 1.2,
281 max_kb: Some(24.0),
282 latency_ms_p50: Some(800),
283 freshness_ttl_s: Some(900),
284 ..CostModel::default()
285 },
286 side_effect_class: SideEffectClass::ReadOnly,
287 rate_limit_host: None,
288 ..ToolValueModel::default()
289 },
290 );
291
292 for name in [
296 "TaskUpdate",
297 "TaskCreate",
298 "TaskGet",
299 "TaskList",
300 "TodoWrite",
301 ] {
302 m.insert(name.into(), ToolValueModel::audit_only());
303 }
304
305 m.insert(
309 "ToolSearch".into(),
310 ToolValueModel {
311 value_class: ValueClass::Supporting,
312 cost_model: CostModel {
313 typical_kb: 0.0,
314 max_kb: Some(0.1),
315 ..CostModel::default()
316 },
317 fail_fast_after_n: Some(2),
318 side_effect_class: SideEffectClass::ReadOnly,
322 ..ToolValueModel::default()
323 },
324 );
325
326 m.insert(
330 "Agent".into(),
331 ToolValueModel {
332 value_class: ValueClass::Supporting,
333 cost_model: CostModel {
334 typical_kb: 6.5,
335 max_kb: Some(23.7),
336 latency_ms_p50: Some(60_000),
337 ..CostModel::default()
338 },
339 side_effect_class: SideEffectClass::Indeterminate,
342 ..ToolValueModel::default()
343 },
344 );
345
346 m
347}
348
349#[cfg(test)]
350mod tests {
351 use super::*;
352
353 #[test]
354 fn defaults_cover_top_tools_from_corpus() {
355 let m = default_tool_value_models();
356 for required in [
357 "Read",
358 "Edit",
359 "Write",
360 "Bash",
361 "Grep",
362 "Glob",
363 "WebSearch",
364 "WebFetch",
365 "TaskUpdate",
366 "TodoWrite",
367 "ToolSearch",
368 "Agent",
369 ] {
370 assert!(m.contains_key(required), "missing default for {required}");
371 }
372 }
373
374 #[test]
375 fn audit_only_tools_are_excluded_from_budget() {
376 let m = default_tool_value_models();
377 for name in ["TaskUpdate", "TaskCreate", "TodoWrite"] {
378 assert!(
379 m[name].excluded_from_budget(),
380 "{name} should be excluded_from_budget"
381 );
382 }
383 }
384
385 #[test]
386 fn read_is_critical_with_typical_kb_anchored_on_corpus() {
387 let m = default_tool_value_models();
388 let read = &m["Read"];
389 assert_eq!(read.value_class, ValueClass::Critical);
390 assert_eq!(read.cost_model.typical_kb, 2.5);
391 }
392
393 #[test]
394 fn grep_followup_includes_read_and_edit_with_path_projection() {
395 let m = default_tool_value_models();
396 let fu = &m["Grep"].follow_up;
397 let read_link = fu.iter().find(|l| l.tool == "Read").unwrap();
398 assert_eq!(read_link.projection.as_deref(), Some("path"));
399 let edit_link = fu.iter().find(|l| l.tool == "Edit").unwrap();
400 assert_eq!(edit_link.projection.as_deref(), Some("path"));
401 }
402
403 #[test]
404 fn web_search_drops_snippets_first_under_budget() {
405 let m = default_tool_value_models();
406 let groups = &m["WebSearch"].field_groups;
407 assert!(groups["must_have"].default_include);
408 assert!(!groups["nice_to_have"].default_include);
409 }
410
411 #[test]
419 fn never_speculatable_safety_invariant() {
420 let m = default_tool_value_models();
421 for tool in [
422 "Bash",
423 "Agent",
424 "Edit",
425 "Write",
426 "MultiEdit",
427 "NotebookEdit",
428 ] {
429 let model = m.get(tool).unwrap_or_else(|| panic!("{tool} missing"));
430 assert!(
431 !model.is_speculatable(),
432 "SAFETY: {tool} (side_effect={:?}) must never be speculatable; \
433 a regression here can lead to double-applied writes / shell \
434 commands re-run",
435 model.side_effect_class
436 );
437 }
438 }
439
440 #[test]
444 fn pure_and_read_only_tools_are_speculatable() {
445 let m = default_tool_value_models();
446 for tool in [
447 "Read",
448 "Grep",
449 "Glob",
450 "WebSearch",
451 "WebFetch",
452 "ToolSearch",
453 ] {
454 let model = m.get(tool).unwrap_or_else(|| panic!("{tool} missing"));
455 assert!(
456 model.is_speculatable(),
457 "{tool} (side_effect={:?}) should remain speculatable",
458 model.side_effect_class
459 );
460 }
461 }
462
463 #[test]
464 fn tool_search_has_fail_fast() {
465 let m = default_tool_value_models();
466 assert_eq!(m["ToolSearch"].fail_fast_after_n, Some(2));
467 }
468
469 #[test]
470 fn mutating_tools_invalidate_read_cache() {
471 let m = default_tool_value_models();
472 for name in ["Edit", "Write", "MultiEdit"] {
473 assert!(
474 m[name].invalidates.iter().any(|t| t == "Read"),
475 "{name} should invalidate Read"
476 );
477 }
478 }
479}