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