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