devboy_core/tool_value_model.rs
1//! Tool value model — Paper 3 §"how to build tools right".
2//!
3//! [`ToolValueModel`] is a machine-readable description that every
4//! provider attaches to each tool it ships. The Paper 3 enrichment
5//! planner reads these models to decide which tools to call, in what
6//! order, and which fields of each response are worth keeping under a
7//! given turn budget.
8//!
9//! Design echoes Paper 2's `[profiles.data]` axis: the model is plain
10//! `serde`-compatible data, ships with sensible per-provider defaults,
11//! and can be overridden through a `[tools.<name>]` block in
12//! `pipeline_config.toml` without recompiling. The schema lives here
13//! (in `devboy-core`) so provider crates can populate it without taking
14//! a dependency on the executor or the pipeline.
15//!
16//! See `docs/research/paper3_corpus_findings.md` for the empirical
17//! basis of the default values, and Paper 3 (issue tracker P-3) for
18//! the planner that consumes them.
19
20use std::collections::BTreeMap;
21
22use serde::{Deserialize, Serialize};
23
24/// How important the tool's output is to the agent's task.
25///
26/// The planner uses this as the *first-pass* filter when budget is
27/// tight: `Critical` tools are kept whatever the cost; `AuditOnly`
28/// tools never enter the budget calculation; `Supporting` and
29/// `Optional` are dropped in that order.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
31#[serde(rename_all = "snake_case")]
32pub enum ValueClass {
33 /// File contents, search results — must always be included.
34 #[default]
35 Critical,
36 /// Useful context that improves answers but is not load-bearing.
37 Supporting,
38 /// Nice-to-have. First to be dropped under tight budget.
39 Optional,
40 /// Metadata about the agent's own plan (TaskUpdate, TodoWrite,
41 /// telemetry pings). Kept in trace for analysis but never spent
42 /// against the per-turn budget.
43 AuditOnly,
44}
45
46/// Side-effect classification — controls whether a tool is safe to
47/// run *speculatively* (i.e. before the LLM asks for it).
48///
49/// Speculative pre-fetch is the killer feature of Paper 3, but it is
50/// only safe when re-issuing the call has **no observable consequence
51/// beyond what the LLM was going to do anyway**. Anything that mutates
52/// state (local files, remote APIs, user-visible objects) must never
53/// be speculated — otherwise we double-execute writes.
54///
55/// The default is the most conservative reading: `Indeterminate`. New
56/// tools are non-speculatable until a provider explicitly opts in.
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
58#[serde(rename_all = "snake_case")]
59#[non_exhaustive]
60pub enum SideEffectClass {
61 /// Deterministic + idempotent: same input → same output, no
62 /// state. Safe to speculate freely. Examples: `Read` of an
63 /// unchanged file, hash computations, pure functions over args.
64 Pure,
65 /// No external mutation, but the result *can* change between
66 /// calls (TTL applies). Safe to speculate when `freshness_ttl_s`
67 /// has not expired. Examples: `get_issues`, `WebFetch`, `Glob`,
68 /// `Grep`, list-style endpoints. The bulk of the planner's wins.
69 ReadOnly,
70 /// Mutates host-local state (files, in-memory caches). Never
71 /// speculate — re-running would duplicate the edit. Examples:
72 /// `Edit`, `Write`, `MultiEdit`, `NotebookEdit`.
73 MutatesLocal,
74 /// Mutates remote state (creates issues, sends messages, runs
75 /// pipelines, `git push`). Never speculate — the consequence is
76 /// visible to other actors. Examples: `create_issue`,
77 /// `create_merge_request`, `add_issue_comment`, `Bash` for
78 /// destructive commands.
79 MutatesExternal,
80 /// Outcome cannot be classified statically (most prominently
81 /// `Bash` — its effect depends on the command string). Default
82 /// for any tool that has not been annotated. Treated as
83 /// non-speculatable; the planner only emits a hint to the LLM.
84 #[default]
85 Indeterminate,
86}
87
88impl SideEffectClass {
89 /// `true` iff the planner is allowed to issue this tool ahead of
90 /// the LLM asking for it. Currently `Pure` and `ReadOnly` only;
91 /// the other variants are bypassed even if `enrichment.enabled`
92 /// is on.
93 pub fn is_speculatable(&self) -> bool {
94 matches!(self, Self::Pure | Self::ReadOnly)
95 }
96}
97
98/// One named subset of fields from a tool's response. Providers carve
99/// the full result into groups so the planner can drop low-value
100/// fields without dropping the call entirely.
101///
102/// Conventionally a tool ships at least:
103///
104/// - `must_have` — fields required for the response to be useful;
105/// - `nice_to_have` — informative but droppable under budget;
106/// - `debug` — low-value diagnostics, dropped first.
107#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct FieldGroup {
109 /// JSON pointer-style field paths (e.g. `"id"`, `"author.email"`).
110 /// Empty list means "all remaining fields" — used by the
111 /// `nice_to_have` group as a wildcard.
112 #[serde(default)]
113 pub fields: Vec<String>,
114
115 /// Expected value contribution of this group, on a 0.0–1.0 scale,
116 /// relative to the tool's total `must_have` value. The planner
117 /// multiplies this by the tool's `value_class` to get the absolute
118 /// value-per-token used in the knapsack.
119 #[serde(default = "default_estimated_value")]
120 pub estimated_value: f32,
121
122 /// Whether the planner should include this group by default.
123 /// `false` means "opt-in" — only included when the user intent
124 /// explicitly mentions one of the fields.
125 #[serde(default = "default_include_true")]
126 pub default_include: bool,
127}
128
129fn default_estimated_value() -> f32 {
130 0.5
131}
132fn default_include_true() -> bool {
133 true
134}
135
136impl Default for FieldGroup {
137 fn default() -> Self {
138 Self {
139 fields: Vec::new(),
140 estimated_value: default_estimated_value(),
141 default_include: default_include_true(),
142 }
143 }
144}
145
146/// What the call costs in tokens, latency, dollars, and how long the
147/// result stays valid in cache.
148///
149/// The numbers are the planner's *prior*; the actual telemetry from
150/// `PipelineEvent` updates them via `tune analyze` (Paper 2 idiom).
151#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
152pub struct CostModel {
153 /// Median response size in kilobytes — informs the knapsack
154 /// `cost` term. Anchored on the corpus mining in
155 /// `docs/research/paper3_corpus_findings.md`.
156 #[serde(default = "default_typical_kb")]
157 pub typical_kb: f32,
158
159 /// p99 response size — the planner uses this for the *worst-case*
160 /// budget reservation when it cannot afford to overshoot.
161 #[serde(default, skip_serializing_if = "Option::is_none")]
162 pub max_kb: Option<f32>,
163
164 /// Median end-to-end latency. `None` = unknown, treated as 0
165 /// in the planner's latency-aware mode.
166 #[serde(default, skip_serializing_if = "Option::is_none")]
167 pub latency_ms_p50: Option<u32>,
168
169 /// Per-call dollar cost for paid APIs (Anthropic, OpenAI, …).
170 /// `None` = free.
171 #[serde(default, skip_serializing_if = "Option::is_none")]
172 pub dollars: Option<f32>,
173
174 /// How long a cached response stays valid before the planner must
175 /// refetch. Used by L0 dedup: a polling endpoint with
176 /// `freshness_ttl_s = 15` returns the cached body for 15 s and
177 /// then collapses to a near-ref hint.
178 #[serde(default, skip_serializing_if = "Option::is_none")]
179 pub freshness_ttl_s: Option<u32>,
180}
181
182fn default_typical_kb() -> f32 {
183 1.0
184}
185
186impl Default for CostModel {
187 fn default() -> Self {
188 Self {
189 typical_kb: default_typical_kb(),
190 max_kb: None,
191 latency_ms_p50: None,
192 dollars: None,
193 freshness_ttl_s: None,
194 }
195 }
196}
197
198/// Edge in the empirically observed follow-up graph. After tool A
199/// fires, the planner consults A's `follow_up` list to decide which
200/// tools to *speculatively* prefetch.
201///
202/// `Default` returns an empty link (`tool = ""`,
203/// `probability = default_followup_probability`). The implementation
204/// is mostly there so call sites can use struct-update syntax
205/// (`FollowUpLink { tool: …, probability: …, ..Default::default() }`)
206/// — the empty `tool` would never resolve in `enumerate_candidates`.
207#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
208pub struct FollowUpLink {
209 /// Name of the tool that typically fires next.
210 pub tool: String,
211
212 /// Probability of this follow-up firing, mined from corpus.
213 /// Range 0.0–1.0; 0.5+ is a reasonable prefetch threshold.
214 #[serde(default = "default_followup_probability")]
215 pub probability: f32,
216
217 /// Optional argument projection — name of the field from the
218 /// previous response to read. For example,
219 /// `Glob.follow_up = [{tool: "Read", projection: "match_path",
220 /// projection_arg: "file_path"}]` tells the planner to take each
221 /// glob result's `match_path` and feed it as the `file_path`
222 /// argument to `Read`.
223 #[serde(default, skip_serializing_if = "Option::is_none")]
224 pub projection: Option<String>,
225
226 /// Optional argument *name* on the follow-up tool that the
227 /// extracted `projection` value should populate. When `None`, the
228 /// provider's `ToolEnricher::project_args` is asked to build the
229 /// arguments instead — that's the right path for built-in tools
230 /// where mapping is hard-coded. Custom MCP tools that the user
231 /// annotates by hand in `pipeline_config.toml` should set both
232 /// `projection` and `projection_arg` so the planner can build the
233 /// follow-up args without provider code.
234 #[serde(default, skip_serializing_if = "Option::is_none")]
235 pub projection_arg: Option<String>,
236}
237
238fn default_followup_probability() -> f32 {
239 0.5
240}
241
242impl Default for FollowUpLink {
243 /// Empty link with the default probability — wouldn't resolve in
244 /// the planner. Provided so callers can use struct-update syntax:
245 /// `FollowUpLink { tool: …, probability: …, ..Default::default() }`
246 /// without spelling every optional field. `f32` has no useful
247 /// `Default::default()` here (would be `0.0`), so we hand-write
248 /// the impl rather than `derive`.
249 fn default() -> Self {
250 Self {
251 tool: String::new(),
252 probability: default_followup_probability(),
253 projection: None,
254 projection_arg: None,
255 }
256 }
257}
258
259/// Provider-shipped, user-overridable description of how a tool fits
260/// into the enrichment knapsack.
261///
262/// **Naming contract.** Keys in `AdaptiveConfig.tools` and in the
263/// `[tools.<name>]` TOML section are *runtime tool names* — exactly
264/// what the LLM sends in `tool_use.name` (`Read`, `Bash`,
265/// `mcp__gitlab__get_issue`, …). Do **not** anonymize them. The
266/// `mcp__p<hash6>__verb` form only appears in the public corpus
267/// aggregates under `docs/research/data/paper3_*.csv`; resolution
268/// (`AdaptiveConfig::effective_tool_value_model`), cross-tool
269/// invalidation (`invalidates = […]`) and the dedup cache all match
270/// on the live runtime name, so an anonymized key would never resolve.
271#[derive(Debug, Clone, Default, Serialize, Deserialize)]
272pub struct ToolValueModel {
273 /// First-pass importance class.
274 #[serde(default)]
275 pub value_class: ValueClass,
276
277 /// Named subsets of the response — `must_have`, `nice_to_have`,
278 /// `debug`, and any provider-specific groups.
279 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
280 pub field_groups: BTreeMap<String, FieldGroup>,
281
282 /// Token / latency / freshness model.
283 #[serde(default)]
284 pub cost_model: CostModel,
285
286 /// Empirically observed next tools — drives speculative prefetch.
287 #[serde(default, skip_serializing_if = "Vec::is_empty")]
288 pub follow_up: Vec<FollowUpLink>,
289
290 /// Tools whose cached responses become stale when *this* tool runs.
291 /// Mirrors the existing file-mutation hook in DedupCache: e.g.
292 /// `update_issue.invalidates = ["get_issue", "get_issues"]`.
293 #[serde(default, skip_serializing_if = "Vec::is_empty")]
294 pub invalidates: Vec<String>,
295
296 /// After how many consecutive empty (or no-change) calls the
297 /// planner should stop re-issuing this tool. `None` = never bail.
298 /// Set to `Some(2)` for `ToolSearch` per the corpus finding that
299 /// 50%+ of repeated `ToolSearch` calls return zero results.
300 #[serde(default, skip_serializing_if = "Option::is_none")]
301 pub fail_fast_after_n: Option<u32>,
302
303 /// Side-effect classification — gates speculative pre-fetch.
304 /// Default `Indeterminate` keeps unannotated tools off the
305 /// speculation path; only `Pure` / `ReadOnly` are eligible.
306 #[serde(default, skip_serializing_if = "is_default_side_effect")]
307 pub side_effect_class: SideEffectClass,
308
309 /// Optional default host for rate-limit grouping
310 /// (e.g. `"github.com"`, `"api.openai.com"`,
311 /// `"gitlab.example.com"`). The host's speculative dispatcher
312 /// caps in-flight prefetches per rate_limit_host; `None` means
313 /// no rate budget tracked for this tool.
314 ///
315 /// **Static vs runtime.** This is the *static* default. For tools
316 /// whose target host depends on runtime arguments (e.g. `WebFetch`
317 /// where the URL is per-call), the provider's
318 /// [`crate::ToolEnricher::rate_limit_host`] override returns the
319 /// runtime value; the static field is only consulted as a
320 /// fallback.
321 #[serde(default, skip_serializing_if = "Option::is_none")]
322 pub rate_limit_host: Option<String>,
323
324 /// Per-tool speculation override. When `Some(false)`, the planner
325 /// is forbidden from speculating this tool even if
326 /// `side_effect_class.is_speculatable()`. Set automatically by
327 /// `tune analyze`'s R7 rule when the observed `prefetch_hit_rate`
328 /// for this tool falls below the floor — i.e. the planner was
329 /// guessing wrong too often. `None` = honour `side_effect_class`.
330 #[serde(default, skip_serializing_if = "Option::is_none")]
331 pub speculate: Option<bool>,
332}
333
334fn is_default_side_effect(s: &SideEffectClass) -> bool {
335 matches!(s, SideEffectClass::Indeterminate)
336}
337
338impl ToolValueModel {
339 /// Ergonomic constructor for the most common case: a critical
340 /// tool with a known typical size and one likely follow-up.
341 pub fn critical_with_size(typical_kb: f32) -> Self {
342 Self {
343 value_class: ValueClass::Critical,
344 cost_model: CostModel {
345 typical_kb,
346 ..CostModel::default()
347 },
348 ..Self::default()
349 }
350 }
351
352 /// Ergonomic constructor for an `audit_only` tool (TaskUpdate,
353 /// TodoWrite). Such tools never enter the knapsack budget.
354 pub fn audit_only() -> Self {
355 Self {
356 value_class: ValueClass::AuditOnly,
357 ..Self::default()
358 }
359 }
360
361 /// True iff this tool's responses should be excluded from the
362 /// per-turn budget. Used by the planner's first-pass filter.
363 pub fn excluded_from_budget(&self) -> bool {
364 matches!(self.value_class, ValueClass::AuditOnly)
365 }
366
367 /// True iff the planner is allowed to issue this tool ahead of
368 /// the LLM's next message. Combines `side_effect_class` with the
369 /// per-tool `speculate` override — `Some(false)` always wins, so
370 /// `tune analyze`'s auto-disable rule cannot be bypassed by a
371 /// stale `Pure` annotation.
372 pub fn is_speculatable(&self) -> bool {
373 if matches!(self.speculate, Some(false)) {
374 return false;
375 }
376 if matches!(self.speculate, Some(true)) {
377 return true;
378 }
379 self.side_effect_class.is_speculatable()
380 }
381}
382
383#[cfg(test)]
384mod tests {
385 use super::*;
386
387 #[test]
388 fn default_is_critical_with_one_kb() {
389 let m = ToolValueModel::default();
390 assert_eq!(m.value_class, ValueClass::Critical);
391 assert_eq!(m.cost_model.typical_kb, 1.0);
392 assert!(m.field_groups.is_empty());
393 assert!(m.follow_up.is_empty());
394 assert!(m.invalidates.is_empty());
395 assert!(!m.excluded_from_budget());
396 }
397
398 #[test]
399 fn audit_only_is_excluded_from_budget() {
400 assert!(ToolValueModel::audit_only().excluded_from_budget());
401 assert!(!ToolValueModel::critical_with_size(2.5).excluded_from_budget());
402 }
403
404 #[test]
405 fn critical_with_size_sets_typical_kb() {
406 let m = ToolValueModel::critical_with_size(2.5);
407 assert_eq!(m.value_class, ValueClass::Critical);
408 assert_eq!(m.cost_model.typical_kb, 2.5);
409 }
410
411 #[test]
412 fn round_trip_via_toml_default() {
413 // A blank table must deserialise into the default.
414 let m: ToolValueModel = toml::from_str("").unwrap();
415 assert_eq!(m.value_class, ValueClass::default());
416 assert_eq!(m.cost_model, CostModel::default());
417 }
418
419 #[test]
420 fn round_trip_via_toml_full() {
421 let m = ToolValueModel {
422 value_class: ValueClass::Supporting,
423 field_groups: {
424 let mut g = BTreeMap::new();
425 g.insert(
426 "must_have".to_string(),
427 FieldGroup {
428 fields: vec!["title".into(), "url".into()],
429 estimated_value: 1.0,
430 default_include: true,
431 },
432 );
433 g.insert(
434 "nice_to_have".to_string(),
435 FieldGroup {
436 fields: vec!["snippet".into()],
437 estimated_value: 0.3,
438 default_include: false,
439 },
440 );
441 g
442 },
443 cost_model: CostModel {
444 typical_kb: 3.1,
445 max_kb: Some(8.0),
446 latency_ms_p50: Some(900),
447 dollars: None,
448 freshness_ttl_s: Some(3600),
449 },
450 follow_up: vec![FollowUpLink {
451 tool: "WebFetch".into(),
452 probability: 0.65,
453 projection: Some("url".into()),
454 projection_arg: Some("url".into()),
455 }],
456 invalidates: vec![],
457 fail_fast_after_n: Some(2),
458 side_effect_class: SideEffectClass::ReadOnly,
459 rate_limit_host: Some("example.com".into()),
460 speculate: None,
461 };
462 let s = toml::to_string_pretty(&m).unwrap();
463 let back: ToolValueModel = toml::from_str(&s).unwrap();
464 assert_eq!(back.value_class, ValueClass::Supporting);
465 assert_eq!(back.field_groups.len(), 2);
466 assert_eq!(
467 back.field_groups.get("must_have").unwrap().fields,
468 vec!["title".to_string(), "url".to_string()]
469 );
470 assert_eq!(back.cost_model.typical_kb, 3.1);
471 assert_eq!(back.cost_model.max_kb, Some(8.0));
472 assert_eq!(back.follow_up[0].tool, "WebFetch");
473 assert_eq!(back.follow_up[0].projection.as_deref(), Some("url"));
474 assert_eq!(back.follow_up[0].projection_arg.as_deref(), Some("url"));
475 assert_eq!(back.fail_fast_after_n, Some(2));
476 assert_eq!(back.side_effect_class, SideEffectClass::ReadOnly);
477 assert_eq!(back.rate_limit_host.as_deref(), Some("example.com"));
478 assert!(back.is_speculatable());
479 }
480
481 // ─── Side-effect classification ──────────────────────────────────
482
483 #[test]
484 fn default_side_effect_class_is_indeterminate_and_blocks_speculation() {
485 let m = ToolValueModel::default();
486 assert_eq!(m.side_effect_class, SideEffectClass::Indeterminate);
487 assert!(
488 !m.is_speculatable(),
489 "Indeterminate must never be speculated"
490 );
491 }
492
493 #[test]
494 fn pure_and_read_only_are_speculatable() {
495 let pure = ToolValueModel {
496 side_effect_class: SideEffectClass::Pure,
497 ..Default::default()
498 };
499 let ro = ToolValueModel {
500 side_effect_class: SideEffectClass::ReadOnly,
501 ..Default::default()
502 };
503 assert!(pure.is_speculatable());
504 assert!(ro.is_speculatable());
505 }
506
507 #[test]
508 fn mutating_classes_block_speculation() {
509 for class in [
510 SideEffectClass::MutatesLocal,
511 SideEffectClass::MutatesExternal,
512 ] {
513 let m = ToolValueModel {
514 side_effect_class: class,
515 ..Default::default()
516 };
517 assert!(
518 !m.is_speculatable(),
519 "{class:?} must never be speculated — would duplicate writes"
520 );
521 }
522 }
523
524 #[test]
525 fn speculate_override_wins_over_side_effect_class() {
526 // `tune analyze`'s R7 disables a Pure tool whose hit rate
527 // dropped: must trump the static class.
528 let pure_but_disabled = ToolValueModel {
529 side_effect_class: SideEffectClass::Pure,
530 speculate: Some(false),
531 ..Default::default()
532 };
533 assert!(!pure_but_disabled.is_speculatable());
534
535 // Manual override forcing speculation on an Indeterminate tool
536 // (e.g. user knows their custom MCP shell wrapper is safe).
537 let forced_on = ToolValueModel {
538 side_effect_class: SideEffectClass::Indeterminate,
539 speculate: Some(true),
540 ..Default::default()
541 };
542 assert!(forced_on.is_speculatable());
543 }
544
545 #[test]
546 fn side_effect_class_serialises_snake_case() {
547 // Indeterminate is the default and is intentionally
548 // skip_serializing_if'd — covered by `default_indeterminate_skipped_on_serialise`.
549 for (class, expected) in [
550 (SideEffectClass::Pure, "pure"),
551 (SideEffectClass::ReadOnly, "read_only"),
552 (SideEffectClass::MutatesLocal, "mutates_local"),
553 (SideEffectClass::MutatesExternal, "mutates_external"),
554 ] {
555 let m = ToolValueModel {
556 side_effect_class: class,
557 ..Default::default()
558 };
559 let s = toml::to_string_pretty(&m).unwrap();
560 assert!(
561 s.contains(&format!("side_effect_class = \"{expected}\"")),
562 "expected `{expected}`, got: {s}"
563 );
564 // Round-trip must preserve the class.
565 let back: ToolValueModel = toml::from_str(&s).unwrap();
566 assert_eq!(back.side_effect_class, class);
567 }
568 }
569
570 #[test]
571 fn default_indeterminate_skipped_on_serialise() {
572 let m = ToolValueModel::default();
573 let s = toml::to_string_pretty(&m).unwrap();
574 assert!(
575 !s.contains("side_effect_class"),
576 "Indeterminate is the default and must be skip_serializing_if'd, got: {s}"
577 );
578 assert!(!s.contains("rate_limit_host"));
579 assert!(!s.contains("speculate"));
580 }
581
582 #[test]
583 fn followup_link_projection_arg_round_trips() {
584 let l = FollowUpLink {
585 tool: "Read".into(),
586 probability: 0.8,
587 projection: Some("path".into()),
588 projection_arg: Some("file_path".into()),
589 };
590 let s = toml::to_string_pretty(&l).unwrap();
591 let back: FollowUpLink = toml::from_str(&s).unwrap();
592 assert_eq!(back.projection_arg.as_deref(), Some("file_path"));
593 }
594
595 #[test]
596 fn empty_optional_fields_are_skipped_on_serialise() {
597 let m = ToolValueModel::default();
598 let s = toml::to_string_pretty(&m).unwrap();
599 // No `field_groups`, `follow_up`, `invalidates`, `fail_fast_after_n` —
600 // they were `Default` and should be skip_serializing_if'd.
601 assert!(!s.contains("field_groups"));
602 assert!(!s.contains("follow_up"));
603 assert!(!s.contains("invalidates"));
604 assert!(!s.contains("fail_fast_after_n"));
605 assert!(!s.contains("max_kb"));
606 }
607
608 #[test]
609 fn value_class_serialises_snake_case() {
610 let m = ToolValueModel {
611 value_class: ValueClass::AuditOnly,
612 ..Default::default()
613 };
614 let s = toml::to_string_pretty(&m).unwrap();
615 assert!(s.contains("audit_only"), "expected snake_case, got: {s}");
616 }
617
618 #[test]
619 fn field_group_default_estimated_value_is_half() {
620 let g = FieldGroup::default();
621 assert!((g.estimated_value - 0.5).abs() < 1e-6);
622 assert!(g.default_include);
623 }
624
625 #[test]
626 fn followup_link_round_trips_without_projection() {
627 let l = FollowUpLink {
628 tool: "Bash".into(),
629 probability: 0.8,
630 ..FollowUpLink::default()
631 };
632 let s = toml::to_string_pretty(&l).unwrap();
633 assert!(
634 !s.contains("projection"),
635 "None should be skipped, got: {s}"
636 );
637 let back: FollowUpLink = toml::from_str(&s).unwrap();
638 assert_eq!(back.tool, "Bash");
639 assert!((back.probability - 0.8).abs() < 1e-6);
640 assert!(back.projection.is_none());
641 assert!(back.projection_arg.is_none());
642 }
643}