返回 CodeWhale
tool_inspection.rs
根目录 / crates / tui / src / tool_inspection.rs
1 //! Truthful, bounded inspection of a prepared model-client request's tool field.
2 //!
3 //! Capture happens at the request-construction seam and retains only a bounded
4 //! projection. It never claims that the prepared request was delivered.
5 //!
6 //! Two kinds of fact live here, and they are kept apart on purpose:
7 //!
8 //! * **Wire facts** come from the prepared request itself — names, schemas,
9 //! descriptions, per-tool transport flags, byte accounting, and the
10 //! active-tool-catalog digest. The digest is not defined here; it is
11 //! [`crate::core::engine::preview::active_tool_catalog_sha256`], the same
12 //! function the request manifest publishes, so `/tools` and `/request` cannot
13 //! report two different hashes of one catalog.
14 //! * **Surface facts** come from a [`ToolSurfaceContext`] the engine resolves
15 //! once per turn: flattened registry facts, the MCP pool's resolved server
16 //! attributions, the engine-injected catalog names, and the provider receipt
17 //! taken from the *resolved model client*. When that context is present,
18 //! provenance, MCP server identity, capabilities, approval requirement, and
19 //! model visibility become available and true. When it is absent they stay
20 //! explicitly unknown — the context is optional, never faked.
21 //!
22 //! What stays unknowable stays unknown regardless: nothing here observes the
23 //! provider adapter's wire payload, so it is always reported as unavailable.
24
25 mod portable_projection;
26 pub(crate) use portable_projection::tool_snapshot as project_snapshot;
27
28 use std::collections::BTreeMap;
29 use std::io::{self, Write};
30
31 use serde::Serialize;
32 use serde_json::Value;
33
34 use codewhale_models::Tool;
35
36 const MAX_RENDERED_TOOLS: usize = 32;
37 const MAX_NAME_CHARS: usize = 256;
38 const MAX_DESCRIPTION_CHARS: usize = 512;
39 const MAX_SCHEMA_BYTES: usize = 2_048;
40 const MAX_AUXILIARY_CHARS: usize = 512;
41 const MAX_ALLOWED_CALLERS: usize = 16;
42 const MAX_ALLOWED_CALLER_CHARS: usize = 128;
43 const MAX_PAYLOAD_MEASUREMENT_BYTES: usize = 1_048_576;
44
45 #[derive(Debug, Clone, PartialEq, Serialize)]
46 pub struct BoundedString {
47 pub value: String,
48 pub truncated: bool,
49 }
50
51 /// Observed engine exit boundary. This never classifies the assistant's prose.
52 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
53 #[serde(rename_all = "snake_case")]
54 pub enum TurnStopReason {
55 ProviderNoToolCall,
56 ProviderToolCallMissing,
57 StepBudgetExhausted,
58 NoProgress,
59 Interrupted,
60 Failed,
61 }
62
63 /// Terminal facts attached to the existing request inspector, without adding
64 /// conversation input or a notice to an ordinary successful response.
65 #[derive(Debug, Clone, Default, PartialEq, Serialize)]
66 pub struct TurnStopDiagnostics {
67 pub status: Option<crate::core::events::TurnOutcomeStatus>,
68 /// None means the precise runtime exit boundary was not observed.
69 pub reason: Option<TurnStopReason>,
70 /// None means the caller did not install a model-step ceiling.
71 pub effective_max_steps: Option<u32>,
72 pub step_budget_source: &'static str,
73 /// Existing zero-based scheduler step; transport retries do not advance it.
74 pub model_step_index: u32,
75 /// Parent streaming ModelClient calls, including stream retries. Excludes
76 /// HTTP retries inside the client, compaction and child calls; not invoices.
77 pub model_requests_started: u32,
78 /// HTTP retries actually entered inside the captured parent request.
79 /// This is dispatch evidence, not provider usage or a bill.
80 pub transport_retries: u32,
81 pub transparent_stream_retries: u32,
82 pub stream_resumes: u32,
83 pub reasoning_only_reprompts: u32,
84 /// Re-requests after a clean terminal stop that carried no text, no
85 /// reasoning and no tool call (#6310): an exact-prefix retry, then a
86 /// nudged one, before the turn fails visibly.
87 pub empty_stop_retries: u32,
88 pub soft_landing_sent: bool,
89 pub final_report_requested: bool,
90 pub permission_strategy_switches: u32,
91 /// Denied provider-response batches since the latest useful progress.
92 pub permission_denial_rounds_without_progress: u32,
93 pub last_provider_finish_reason: Option<BoundedString>,
94 /// Structured calls decoded from the stream, before legacy text-call parsing.
95 pub last_response_tool_calls: Option<usize>,
96 /// None means suppression was not counted at this exit boundary.
97 pub last_response_tool_calls_suppressed: Option<usize>,
98 /// Last parent response's reported input tokens, not cumulative billing.
99 pub last_reported_input_tokens: Option<u32>,
100 pub route_context_window_tokens: Option<u64>,
101 /// Engine-prepared output allowance. A transport may omit the field;
102 /// this is budget evidence, not a provider-published capability ceiling.
103 pub last_prepared_output_limit_tokens: Option<u32>,
104 pub automatic_compaction_attempts: u32,
105 pub emergency_compaction_attempts: u32,
106 }
107
108 impl TurnStopDiagnostics {
109 pub(crate) fn observe_provider_response(
110 &mut self,
111 finish_reason: Option<&str>,
112 tool_calls: usize,
113 ) {
114 self.last_provider_finish_reason =
115 finish_reason.map(|reason| bounded_chars(reason, MAX_NAME_CHARS));
116 self.last_response_tool_calls = Some(tool_calls);
117 self.last_response_tool_calls_suppressed = None;
118 }
119 }
120
121 #[derive(Debug, Clone, PartialEq, Serialize)]
122 #[serde(tag = "status", rename_all = "snake_case")]
123 pub enum Evidence<T> {
124 Known { value: T },
125 Unknown { reason: String },
126 }
127
128 #[derive(Debug, Clone, PartialEq, Serialize)]
129 pub struct BoundedList {
130 pub count: usize,
131 pub rendered: Vec<BoundedString>,
132 pub omitted: usize,
133 }
134
135 #[derive(Debug, Clone, PartialEq, Serialize)]
136 pub struct CountOnly {
137 pub count: usize,
138 pub values: &'static str,
139 }
140
141 /// One registry tool flattened to the exact facts this projection may report.
142 ///
143 /// The engine fills this from `ToolSpec`, so this module never holds a tool
144 /// object and therefore cannot execute one.
145 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
146 pub struct RegistryFacts {
147 pub name: String,
148 pub description: String,
149 pub model_visible: bool,
150 pub capabilities: Vec<String>,
151 pub approval: String,
152 /// `true` when the tool came from the plugin surface rather than the
153 /// built-in registry builder.
154 pub plugin: bool,
155 }
156
157 /// Where a tool in the prepared request came from.
158 ///
159 /// `Unknown` is a real answer, not a fallback guess: it means the surface
160 /// context resolved no origin for that name.
161 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
162 #[serde(rename_all = "snake_case")]
163 pub enum ToolProvenance {
164 /// Registered by the built-in registry builder.
165 Builtin,
166 /// Loaded from the plugin/tools surface or `config.toml` overrides.
167 Plugin,
168 /// Contributed by the MCP pool, as attributed by the pool itself.
169 Mcp,
170 /// Injected into the request catalog by the engine rather than registered
171 /// (`tool_search` and its legacy spellings, `code_execution`,
172 /// `js_execution`).
173 Synthetic,
174 /// Present in the request with no resolved origin.
175 Unknown,
176 }
177
178 /// A tool's state relative to the request that was prepared for this step.
179 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
180 #[serde(rename_all = "snake_case")]
181 pub enum ToolVisibility {
182 /// In this step's request with its schema included.
183 Active,
184 /// In this step's request, marked deferred (schema loads on demand).
185 Deferred,
186 /// In this step's request, with no transport flag to say which.
187 InRequest,
188 }
189
190 impl ToolVisibility {
191 /// Whether this state means the tool's bytes are carried by the prepared
192 /// request. The only honest source of this answer is the request itself.
193 #[must_use]
194 #[cfg(test)]
195 pub const fn in_request(self) -> bool {
196 matches!(self, Self::Active | Self::Deferred | Self::InRequest)
197 }
198 }
199
200 /// Provider/route availability, derived from the resolved model client taken at
201 /// the request seam — never from the existence of a tool registry.
202 #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
203 #[serde(tag = "status", rename_all = "snake_case")]
204 pub enum ProviderAvailability {
205 /// No client receipt was taken, because no surface context was captured.
206 #[default]
207 Unknown,
208 /// A model client was resolved and this request was built for it.
209 Available { provider: String, model: String },
210 /// The seam was reached with no resolved model client. The reason is a safe
211 /// label, never a URL or credential.
212 Unavailable { reason: String },
213 }
214
215 impl ProviderAvailability {
216 #[must_use]
217 pub const fn is_available(&self) -> bool {
218 matches!(self, Self::Available { .. })
219 }
220
221 #[must_use]
222 #[cfg(test)]
223 pub const fn label(&self) -> &'static str {
224 match self {
225 Self::Unknown => "unknown",
226 Self::Available { .. } => "available",
227 Self::Unavailable { .. } => "unavailable",
228 }
229 }
230 }
231
232 /// Everything outside the prepared request that this projection is allowed to
233 /// report, resolved once per turn by the engine.
234 ///
235 /// Plain data only: no registry, no client, no credentials. Resolving it once
236 /// per turn is what keeps the per-step seam from re-locking the MCP pool.
237 #[derive(Debug, Clone, Default)]
238 pub struct ToolSurfaceContext {
239 /// The real registry, flattened. Sorted by name by the producer.
240 pub registry: Vec<RegistryFacts>,
241 /// Model tool name -> MCP server name, only for names the real pool
242 /// resolved. Absent names stay unknown rather than being split apart.
243 pub mcp_servers: BTreeMap<String, String>,
244 /// Request-catalog names the engine injects rather than registering.
245 pub synthetic_names: Vec<String>,
246 /// Receipt from the resolved model client for this turn.
247 pub provider: ProviderAvailability,
248 }
249
250 impl ToolSurfaceContext {
251 fn provenance(&self, name: &str) -> ToolProvenance {
252 if let Some(facts) = self.registry.iter().find(|facts| facts.name == name) {
253 if facts.plugin {
254 return ToolProvenance::Plugin;
255 }
256 if self.mcp_servers.contains_key(name) {
257 return ToolProvenance::Mcp;
258 }
259 return ToolProvenance::Builtin;
260 }
261 if self.mcp_servers.contains_key(name) {
262 return ToolProvenance::Mcp;
263 }
264 if self.synthetic_names.iter().any(|entry| entry == name) {
265 return ToolProvenance::Synthetic;
266 }
267 ToolProvenance::Unknown
268 }
269 }
270
271 #[derive(Debug, Clone, PartialEq, Serialize)]
272 pub struct ToolProjection {
273 pub ordinal: usize,
274 pub name: BoundedString,
275 pub tool_type: Evidence<BoundedString>,
276 pub description: BoundedString,
277 pub input_schema_json: BoundedString,
278 pub allowed_callers: Evidence<BoundedList>,
279 pub defer_loading: Evidence<bool>,
280 pub input_examples: Evidence<CountOnly>,
281 pub strict: Evidence<bool>,
282 pub cache_control_type: Evidence<BoundedString>,
283 /// Where this tool came from. Known only when a surface context was
284 /// captured; `ToolProvenance::Unknown` inside `Known` means the context was
285 /// captured and still resolved no origin.
286 pub provenance: Evidence<ToolProvenance>,
287 /// Owning MCP server, only when the real pool attributed this exact model
288 /// tool name.
289 pub mcp_server: Evidence<BoundedString>,
290 /// Declared capabilities from the registry, sorted. Known-and-empty means
291 /// "declares none"; unknown means "not in the registry".
292 pub capabilities: Evidence<BoundedList>,
293 /// Declared approval requirement from the registry.
294 pub approval: Evidence<BoundedString>,
295 /// Registry model visibility. A tool can be registered but hidden.
296 pub model_visible: Evidence<bool>,
297 /// State relative to this prepared request. Always known: the request is
298 /// the evidence.
299 pub visibility: ToolVisibility,
300 }
301
302 /// Bounded evidence from a prepared model-client request.
303 #[derive(Debug, Clone, PartialEq, Serialize)]
304 pub struct ToolInspectionSnapshot {
305 pub schema_version: u32,
306 pub capture_source: &'static str,
307 pub delivery_status: &'static str,
308 pub turn_id: BoundedString,
309 pub step: u32,
310 #[serde(skip_serializing_if = "Option::is_none")]
311 pub terminal: Option<TurnStopDiagnostics>,
312 pub tools_field_present: bool,
313 pub tool_count: usize,
314 pub rendered_tool_count: usize,
315 pub omitted_tool_count: usize,
316 pub payload_json_bytes: Option<usize>,
317 pub payload_measurement_status: String,
318 /// The active-tool-catalog digest, computed by the *same* function the
319 /// request manifest uses for `active_tool_catalog_sha256`. Absent only when
320 /// the request carried no tools field at all. Covers tool name,
321 /// description, and canonical input schema — not transport-only fields —
322 /// exactly as the manifest does.
323 pub active_tool_catalog_sha256: Option<String>,
324 /// Facts nothing on this path can observe for this request. Shrinks when a
325 /// surface context supplies registry- and client-derived truth; never
326 /// empties, because the provider adapter's wire payload is never visible
327 /// here.
328 pub unavailable_for_this_request: Vec<&'static str>,
329 /// Provider receipt from the resolved model client, or `Unknown` when no
330 /// surface context was captured.
331 pub provider: ProviderAvailability,
332 /// Whether registry-derived facts were captured at all. Absent stays
333 /// distinct from an empty registry.
334 pub registry_facts_present: bool,
335 /// Size of the flattened registry, when it was captured.
336 pub registry_tool_count: Evidence<usize>,
337 /// Registered, model-visible tools this request does *not* carry. Bounded,
338 /// with an explicit omission count.
339 pub registry_only_tools: Evidence<BoundedList>,
340 pub tools: Vec<ToolProjection>,
341 }
342
343 impl ToolInspectionSnapshot {
344 /// Wire facts only. Provenance, attribution, capabilities, approval, and
345 /// provider identity stay explicitly unknown.
346 #[must_use]
347 #[cfg(test)]
348 pub fn from_prepared_request(turn_id: &str, step: u32, tools: Option<&[Tool]>) -> Self {
349 Self::from_prepared_request_with_surface(turn_id, step, tools, None)
350 }
351
352 /// Wire facts joined against the turn's resolved surface context.
353 ///
354 /// The context is what turns "unavailable" into truth: it is derived from
355 /// the real registry, the real MCP pool's own attribution, the engine's own
356 /// synthetic-name list, and the resolved model client. Passing `None`
357 /// reproduces the wire-only projection exactly.
358 #[must_use]
359 pub fn from_prepared_request_with_surface(
360 turn_id: &str,
361 step: u32,
362 tools: Option<&[Tool]>,
363 surface: Option<&ToolSurfaceContext>,
364 ) -> Self {
365 let tool_count = tools.map_or(0, <[Tool]>::len);
366 let projected = tools
367 .unwrap_or_default()
368 .iter()
369 .take(MAX_RENDERED_TOOLS)
370 .enumerate()
371 .map(|(index, tool)| project_tool(index, tool, surface))
372 .collect::<Vec<_>>();
373 let (payload_json_bytes, payload_measurement_status) = measure_payload(tools);
374
375 let request_names = tools
376 .unwrap_or_default()
377 .iter()
378 .map(|tool| tool.name.as_str())
379 .collect::<std::collections::BTreeSet<_>>();
380 let registry_only_tools = surface.map_or_else(
381 || unknown("registry facts not captured for this request"),
382 |surface| {
383 let names = surface
384 .registry
385 .iter()
386 .filter(|facts| {
387 facts.model_visible && !request_names.contains(facts.name.as_str())
388 })
389 .map(|facts| facts.name.as_str())
390 .collect::<Vec<_>>();
391 let rendered = names
392 .iter()
393 .take(MAX_RENDERED_TOOLS)
394 .map(|name| bounded_chars(name, MAX_NAME_CHARS))
395 .collect::<Vec<_>>();
396 Evidence::Known {
397 value: BoundedList {
398 count: names.len(),
399 omitted: names.len().saturating_sub(rendered.len()),
400 rendered,
401 },
402 }
403 },
404 );
405
406 let mut unavailable_for_this_request = vec!["provider_wire_payload"];
407 if surface.is_none() {
408 unavailable_for_this_request.extend([
409 "provider",
410 "model",
411 "approval",
412 "provenance",
413 "capabilities",
414 ]);
415 } else if !surface.is_some_and(|surface| surface.provider.is_available()) {
416 unavailable_for_this_request.extend(["provider", "model"]);
417 }
418
419 Self {
420 schema_version: 1,
421 capture_source: "prepared model-client request",
422 delivery_status: "unknown (capture does not prove provider delivery)",
423 turn_id: bounded_chars(turn_id, MAX_AUXILIARY_CHARS),
424 step,
425 terminal: None,
426 tools_field_present: tools.is_some(),
427 tool_count,
428 rendered_tool_count: projected.len(),
429 omitted_tool_count: tool_count.saturating_sub(projected.len()),
430 payload_json_bytes,
431 payload_measurement_status,
432 active_tool_catalog_sha256: tools
433 .map(crate::core::engine::preview::active_tool_catalog_sha256),
434 unavailable_for_this_request,
435 provider: surface.map_or(ProviderAvailability::Unknown, |surface| {
436 surface.provider.clone()
437 }),
438 registry_facts_present: surface.is_some(),
439 registry_tool_count: surface.map_or_else(
440 || unknown("registry facts not captured for this request"),
441 |surface| Evidence::Known {
442 value: surface.registry.len(),
443 },
444 ),
445 registry_only_tools,
446 tools: projected,
447 }
448 }
449
450 #[must_use]
451 #[cfg(test)]
452 pub fn render_text(&self) -> String {
453 crate::diagnostics_reports::render_tool_snapshot_text(&project_snapshot(self))
454 }
455
456 #[cfg(test)]
457 pub fn render_json(&self) -> Result<String, serde_json::Error> {
458 crate::diagnostics_reports::render_tool_snapshot_json(&project_snapshot(self))
459 }
460 }
461
462 fn project_tool(index: usize, tool: &Tool, surface: Option<&ToolSurfaceContext>) -> ToolProjection {
463 let facts = surface.and_then(|surface| {
464 surface
465 .registry
466 .iter()
467 .find(|facts| facts.name == tool.name)
468 });
469 let no_surface = "surface context not captured for this request";
470 let not_registered = "tool is not in the registry";
471 ToolProjection {
472 ordinal: index + 1,
473 name: bounded_chars(&tool.name, MAX_NAME_CHARS),
474 tool_type: optional_bounded(tool.tool_type.as_deref()),
475 description: bounded_chars(&tool.description, MAX_DESCRIPTION_CHARS),
476 input_schema_json: bounded_json(&tool.input_schema, MAX_SCHEMA_BYTES),
477 allowed_callers: tool.allowed_callers.as_ref().map_or_else(
478 || unknown("request field absent"),
479 |values| {
480 let rendered = values
481 .iter()
482 .take(MAX_ALLOWED_CALLERS)
483 .map(|value| bounded_chars(value, MAX_ALLOWED_CALLER_CHARS))
484 .collect::<Vec<_>>();
485 Evidence::Known {
486 value: BoundedList {
487 count: values.len(),
488 omitted: values.len().saturating_sub(rendered.len()),
489 rendered,
490 },
491 }
492 },
493 ),
494 defer_loading: optional_copy(tool.defer_loading.as_ref()),
495 input_examples: tool.input_examples.as_ref().map_or_else(
496 || unknown("request field absent"),
497 |values| Evidence::Known {
498 value: CountOnly {
499 count: values.len(),
500 values: "values omitted from bounded projection",
501 },
502 },
503 ),
504 strict: optional_copy(tool.strict.as_ref()),
505 cache_control_type: optional_bounded(
506 tool.cache_control
507 .as_ref()
508 .map(|value| value.cache_type.as_str()),
509 ),
510 provenance: surface.map_or_else(
511 || unknown(no_surface),
512 |surface| Evidence::Known {
513 value: surface.provenance(&tool.name),
514 },
515 ),
516 mcp_server: surface.map_or_else(
517 || unknown(no_surface),
518 |surface| {
519 surface.mcp_servers.get(&tool.name).map_or_else(
520 || unknown("the MCP pool did not attribute this tool name"),
521 |server| Evidence::Known {
522 value: bounded_chars(server, MAX_AUXILIARY_CHARS),
523 },
524 )
525 },
526 ),
527 capabilities: match (surface, facts) {
528 (None, _) => unknown(no_surface),
529 (Some(_), None) => unknown(not_registered),
530 (Some(_), Some(facts)) => {
531 let rendered = facts
532 .capabilities
533 .iter()
534 .take(MAX_ALLOWED_CALLERS)
535 .map(|value| bounded_chars(value, MAX_ALLOWED_CALLER_CHARS))
536 .collect::<Vec<_>>();
537 Evidence::Known {
538 value: BoundedList {
539 count: facts.capabilities.len(),
540 omitted: facts.capabilities.len().saturating_sub(rendered.len()),
541 rendered,
542 },
543 }
544 }
545 },
546 approval: match (surface, facts) {
547 (None, _) => unknown(no_surface),
548 (Some(_), None) => unknown(not_registered),
549 (Some(_), Some(facts)) => Evidence::Known {
550 value: bounded_chars(&facts.approval, MAX_AUXILIARY_CHARS),
551 },
552 },
553 model_visible: match (surface, facts) {
554 (None, _) => unknown(no_surface),
555 (Some(_), None) => unknown(not_registered),
556 (Some(_), Some(facts)) => Evidence::Known {
557 value: facts.model_visible,
558 },
559 },
560 // Every projected tool is carried by this prepared request; the
561 // transport flag only says whether its schema rides along now.
562 visibility: match tool.defer_loading {
563 Some(true) => ToolVisibility::Deferred,
564 Some(false) => ToolVisibility::Active,
565 None => ToolVisibility::InRequest,
566 },
567 }
568 }
569
570 /// Byte accounting only. The digest is deliberately *not* computed here: it is
571 /// the request path's [`crate::core::engine::preview::active_tool_catalog_sha256`],
572 /// so this projection never defines a second catalog hash.
573 fn measure_payload(tools: Option<&[Tool]>) -> (Option<usize>, String) {
574 let Some(tools) = tools else {
575 return (None, "unavailable (tools field absent)".to_string());
576 };
577 let mut writer = BoundedWriter::new(MAX_PAYLOAD_MEASUREMENT_BYTES);
578 match serde_json::to_writer(&mut writer, tools) {
579 Ok(()) => (
580 Some(writer.bytes.len()),
581 "exact (within 1048576-byte measurement bound)".to_string(),
582 ),
583 Err(_) if writer.exceeded => (
584 None,
585 "unavailable (payload exceeds 1048576-byte measurement bound)".to_string(),
586 ),
587 Err(_) => (None, "unavailable (serialization failed)".to_string()),
588 }
589 }
590
591 fn bounded_json(value: &Value, limit: usize) -> BoundedString {
592 let mut writer = BoundedWriter::new(limit);
593 let result = serde_json::to_writer(&mut writer, value);
594 BoundedString {
595 value: String::from_utf8_lossy(&writer.bytes).into_owned(),
596 truncated: result.is_err() && writer.exceeded,
597 }
598 }
599
600 struct BoundedWriter {
601 bytes: Vec<u8>,
602 limit: usize,
603 exceeded: bool,
604 }
605
606 impl BoundedWriter {
607 fn new(limit: usize) -> Self {
608 Self {
609 bytes: Vec::with_capacity(limit.min(8_192)),
610 limit,
611 exceeded: false,
612 }
613 }
614 }
615
616 impl Write for BoundedWriter {
617 fn write(&mut self, buffer: &[u8]) -> io::Result<usize> {
618 let remaining = self.limit.saturating_sub(self.bytes.len());
619 let accepted = buffer.len().min(remaining);
620 self.bytes.extend_from_slice(&buffer[..accepted]);
621 if accepted < buffer.len() {
622 self.exceeded = true;
623 return Err(io::Error::other("inspection bound exceeded"));
624 }
625 Ok(accepted)
626 }
627
628 fn flush(&mut self) -> io::Result<()> {
629 Ok(())
630 }
631 }
632
633 fn bounded_chars(value: &str, limit: usize) -> BoundedString {
634 let mut chars = value.chars();
635 let value = chars.by_ref().take(limit).collect::<String>();
636 BoundedString {
637 value,
638 truncated: chars.next().is_some(),
639 }
640 }
641
642 fn optional_bounded(value: Option<&str>) -> Evidence<BoundedString> {
643 value.map_or_else(
644 || unknown("request field absent"),
645 |value| Evidence::Known {
646 value: bounded_chars(value, MAX_AUXILIARY_CHARS),
647 },
648 )
649 }
650
651 fn optional_copy<T: Copy>(value: Option<&T>) -> Evidence<T> {
652 value.map_or_else(
653 || unknown("request field absent"),
654 |value| Evidence::Known { value: *value },
655 )
656 }
657
658 fn unknown<T>(reason: &str) -> Evidence<T> {
659 Evidence::Unknown {
660 reason: reason.to_string(),
661 }
662 }
663
664 #[cfg(test)]
665 mod tests {
666 use super::*;
667 use serde_json::json;
668
669 fn tool(name: &str) -> Tool {
670 Tool {
671 tool_type: Some("function".to_string()),
672 name: name.to_string(),
673 description: "Read a file".to_string(),
674 input_schema: json!({"type": "object"}),
675 allowed_callers: None,
676 defer_loading: Some(false),
677 input_examples: None,
678 strict: Some(true),
679 cache_control: None,
680 }
681 }
682
683 #[test]
684 fn absent_field_stays_distinct_from_present_empty_array() {
685 let absent = ToolInspectionSnapshot::from_prepared_request("turn", 1, None);
686 let empty = ToolInspectionSnapshot::from_prepared_request("turn", 1, Some(&[]));
687 assert!(!absent.tools_field_present);
688 assert_eq!(absent.payload_json_bytes, None);
689 assert!(absent.active_tool_catalog_sha256.is_none());
690 assert!(empty.tools_field_present);
691 assert_eq!(empty.payload_json_bytes, Some(2));
692 assert!(empty.active_tool_catalog_sha256.is_some());
693 }
694
695 #[test]
696 fn catalog_digest_is_the_request_manifest_digest_not_a_second_definition() {
697 let tools = vec![tool("read_file"), tool("write_file")];
698 let snapshot = ToolInspectionSnapshot::from_prepared_request("turn", 1, Some(&tools));
699
700 // Same prepared request, same accounting object: the value the request
701 // manifest publishes as `active_tool_catalog_sha256`.
702 assert_eq!(
703 snapshot.active_tool_catalog_sha256.as_deref(),
704 Some(crate::core::engine::preview::active_tool_catalog_sha256(&tools).as_str()),
705 );
706
707 // And it is a catalog digest, not an incidental byte hash: reordering
708 // the same tools changes it.
709 let reordered = vec![tools[1].clone(), tools[0].clone()];
710 let reordered = ToolInspectionSnapshot::from_prepared_request("turn", 1, Some(&reordered));
711 assert_ne!(
712 snapshot.active_tool_catalog_sha256,
713 reordered.active_tool_catalog_sha256
714 );
715 }
716
717 #[test]
718 fn projection_preserves_known_false_and_marks_unknown() {
719 let snapshot =
720 ToolInspectionSnapshot::from_prepared_request("turn", 3, Some(&[tool("read_file")]));
721 let text = snapshot.render_text();
722 assert!(text.contains("deferred loading: false"), "{text}");
723 assert!(text.contains("strict: true"), "{text}");
724 assert!(text.contains("allowed callers: unknown (request field absent)"));
725 assert!(text.contains("Delivery: unknown"));
726 assert!(text.contains("Provider-wire tool payload: unavailable"));
727 }
728
729 fn facts(name: &str, plugin: bool, model_visible: bool) -> RegistryFacts {
730 RegistryFacts {
731 name: name.to_string(),
732 description: format!("{name} registry description"),
733 model_visible,
734 capabilities: vec!["ReadOnly".to_string()],
735 approval: "Auto".to_string(),
736 plugin,
737 }
738 }
739
740 fn surface() -> ToolSurfaceContext {
741 ToolSurfaceContext {
742 registry: vec![
743 facts("read_file", false, true),
744 facts("plugin_tool", true, true),
745 facts("hidden_alias", false, false),
746 facts("not_sent", false, true),
747 ],
748 mcp_servers: BTreeMap::from([(
749 "mcp_my_server_read_file".to_string(),
750 "my_server".to_string(),
751 )]),
752 synthetic_names: vec!["tool_search".to_string()],
753 provider: ProviderAvailability::Available {
754 provider: "Deepseek".to_string(),
755 model: "deepseek-chat".to_string(),
756 },
757 }
758 }
759
760 #[test]
761 fn surface_context_turns_provenance_and_attribution_into_truth() {
762 let tools = vec![
763 tool("read_file"),
764 tool("plugin_tool"),
765 tool("mcp_my_server_read_file"),
766 tool("tool_search"),
767 tool("stranger"),
768 ];
769 let surface = surface();
770 let snapshot = ToolInspectionSnapshot::from_prepared_request_with_surface(
771 "turn",
772 1,
773 Some(&tools),
774 Some(&surface),
775 );
776
777 let provenance = |name: &str| {
778 snapshot
779 .tools
780 .iter()
781 .find(|entry| entry.name.value == name)
782 .map(|entry| entry.provenance.clone())
783 .expect("projected tool")
784 };
785 for (name, expected) in [
786 ("read_file", ToolProvenance::Builtin),
787 ("plugin_tool", ToolProvenance::Plugin),
788 ("mcp_my_server_read_file", ToolProvenance::Mcp),
789 ("tool_search", ToolProvenance::Synthetic),
790 // Captured context, no resolved origin: unknown is the answer.
791 ("stranger", ToolProvenance::Unknown),
792 ] {
793 assert_eq!(
794 provenance(name),
795 Evidence::Known { value: expected },
796 "provenance for {name}"
797 );
798 }
799
800 let mcp = snapshot
801 .tools
802 .iter()
803 .find(|entry| entry.name.value == "mcp_my_server_read_file")
804 .expect("mcp tool");
805 // Attribution comes from the pool, not from splitting on `_`.
806 assert_eq!(
807 mcp.mcp_server,
808 Evidence::Known {
809 value: BoundedString {
810 value: "my_server".to_string(),
811 truncated: false,
812 }
813 }
814 );
815
816 let read_file = snapshot
817 .tools
818 .iter()
819 .find(|entry| entry.name.value == "read_file")
820 .expect("read_file");
821 assert!(matches!(read_file.capabilities, Evidence::Known { .. }));
822 assert_eq!(
823 read_file.approval,
824 Evidence::Known {
825 value: BoundedString {
826 value: "Auto".to_string(),
827 truncated: false,
828 }
829 }
830 );
831 assert_eq!(read_file.model_visible, Evidence::Known { value: true });
832
833 // Unregistered tools stay unknown rather than being reported as "none".
834 let stranger = snapshot
835 .tools
836 .iter()
837 .find(|entry| entry.name.value == "stranger")
838 .expect("stranger");
839 assert!(matches!(stranger.capabilities, Evidence::Unknown { .. }));
840 assert!(matches!(stranger.approval, Evidence::Unknown { .. }));
841
842 // The unavailable set shrinks to what nothing here can observe.
843 assert_eq!(
844 snapshot.unavailable_for_this_request,
845 vec!["provider_wire_payload"]
846 );
847 assert!(snapshot.provider.is_available());
848 assert!(snapshot.registry_facts_present);
849 assert_eq!(snapshot.registry_tool_count, Evidence::Known { value: 4 });
850
851 let text = snapshot.render_text();
852 assert!(text.contains("provenance: synthetic"), "{text}");
853 assert!(text.contains("MCP server: \"my_server\""), "{text}");
854 assert!(text.contains("Provider: \"Deepseek\""), "{text}");
855 assert!(
856 text.contains("Provider-wire tool payload: unavailable"),
857 "{text}"
858 );
859 }
860
861 #[test]
862 fn registry_only_tools_are_counted_without_expanding_the_projection() {
863 let tools = vec![tool("read_file")];
864 let surface = surface();
865 let snapshot = ToolInspectionSnapshot::from_prepared_request_with_surface(
866 "turn",
867 1,
868 Some(&tools),
869 Some(&surface),
870 );
871
872 // Only the request's tools are projected; the rest are counted.
873 assert_eq!(snapshot.tools.len(), 1);
874 let Evidence::Known { value } = &snapshot.registry_only_tools else {
875 panic!("registry-only tools must be known when facts were captured");
876 };
877 // `hidden_alias` is not model-visible, so it is not a missing tool.
878 assert_eq!(value.count, 2);
879 let rendered = value
880 .rendered
881 .iter()
882 .map(|entry| entry.value.as_str())
883 .collect::<Vec<_>>();
884 // Order follows the registry facts as supplied; the producer sorts.
885 assert_eq!(rendered, vec!["plugin_tool", "not_sent"]);
886
887 // Everything projected is in the request; the request is the evidence.
888 assert!(snapshot.tools.iter().all(|entry| {
889 entry.visibility.in_request() && entry.visibility == ToolVisibility::Active
890 }));
891 }
892
893 #[test]
894 fn absent_surface_keeps_every_registry_derived_field_unknown() {
895 let tools = vec![tool("read_file")];
896 let snapshot = ToolInspectionSnapshot::from_prepared_request("turn", 1, Some(&tools));
897
898 assert_eq!(snapshot.provider, ProviderAvailability::Unknown);
899 assert!(!snapshot.registry_facts_present);
900 assert!(matches!(
901 snapshot.registry_tool_count,
902 Evidence::Unknown { .. }
903 ));
904 assert!(matches!(
905 snapshot.registry_only_tools,
906 Evidence::Unknown { .. }
907 ));
908 assert_eq!(
909 snapshot.unavailable_for_this_request,
910 vec![
911 "provider_wire_payload",
912 "provider",
913 "model",
914 "approval",
915 "provenance",
916 "capabilities",
917 ]
918 );
919 let entry = &snapshot.tools[0];
920 for evidence in [
921 matches!(entry.provenance, Evidence::Unknown { .. }),
922 matches!(entry.mcp_server, Evidence::Unknown { .. }),
923 matches!(entry.capabilities, Evidence::Unknown { .. }),
924 matches!(entry.approval, Evidence::Unknown { .. }),
925 matches!(entry.model_visible, Evidence::Unknown { .. }),
926 ] {
927 assert!(evidence);
928 }
929 // Wire facts are still exact without a surface context.
930 assert!(entry.visibility.in_request());
931 assert!(snapshot.active_tool_catalog_sha256.is_some());
932 }
933
934 #[test]
935 fn provider_receipt_records_an_unresolved_client_without_borrowing_registry_truth() {
936 let tools = vec![tool("read_file")];
937 let surface = ToolSurfaceContext {
938 registry: vec![facts("read_file", false, true)],
939 provider: ProviderAvailability::Unavailable {
940 reason: "no model client resolved for this turn".to_string(),
941 },
942 ..ToolSurfaceContext::default()
943 };
944 let snapshot = ToolInspectionSnapshot::from_prepared_request_with_surface(
945 "turn",
946 1,
947 Some(&tools),
948 Some(&surface),
949 );
950
951 // A full registry does not make a provider available.
952 assert!(!snapshot.provider.is_available());
953 assert_eq!(snapshot.provider.label(), "unavailable");
954 assert!(snapshot.unavailable_for_this_request.contains(&"provider"));
955 // Registry-derived truth is unaffected by the missing client.
956 assert!(
957 !snapshot
958 .unavailable_for_this_request
959 .contains(&"provenance")
960 );
961 assert_eq!(
962 snapshot.tools[0].provenance,
963 Evidence::Known {
964 value: ToolProvenance::Builtin
965 }
966 );
967 }
968
969 #[test]
970 fn capture_and_rendering_are_bounded_with_explicit_receipts() {
971 let mut tools = (0..40)
972 .map(|index| {
973 let mut value = tool(&format!("tool_{index}"));
974 value.description = "x".repeat(MAX_DESCRIPTION_CHARS + 10);
975 value.input_schema = json!({"large": "y".repeat(MAX_SCHEMA_BYTES * 600)});
976 value
977 })
978 .collect::<Vec<_>>();
979 tools[0].allowed_callers = Some(
980 (0..20)
981 .map(|caller| format!("caller-{caller}-{}", "z".repeat(200)))
982 .collect(),
983 );
984 let snapshot = ToolInspectionSnapshot::from_prepared_request(
985 &"t".repeat(MAX_AUXILIARY_CHARS + 1),
986 1,
987 Some(&tools),
988 );
989 assert_eq!(snapshot.rendered_tool_count, MAX_RENDERED_TOOLS);
990 assert_eq!(snapshot.omitted_tool_count, 8);
991 assert!(snapshot.turn_id.truncated);
992 assert!(snapshot.tools[0].description.truncated);
993 assert!(snapshot.tools[0].input_schema_json.truncated);
994 assert_eq!(snapshot.payload_json_bytes, None);
995 assert!(snapshot.payload_measurement_status.contains("exceeds"));
996 // The catalog digest is fixed-width, so it survives the byte bound.
997 assert!(snapshot.active_tool_catalog_sha256.is_some());
998 let json = snapshot.render_json().expect("bounded JSON");
999 // 160 KiB: the per-tool evidence fields (provenance, MCP server,
1000 // capabilities, approval, visibility) each carry an explicit reason
1001 // string when unresolved, which is the point — the cap moved, the
1002 // bound did not disappear.
1003 assert!(
1004 json.len() < 163_840,
1005 "projection grew to {} bytes",
1006 json.len()
1007 );
1008 }
1009 }
1010
1010 lines RUST