Skip to main content

entelix_core/
llm_facing.rs

1//! LLM-facing channel — type-level separation of operator-facing
2//! diagnostics from the value the model actually sees (invariant #16).
3//!
4//! Two surfaces, both narrowly defined:
5//!
6//! - [`LlmRenderable`] — `render_for_llm()` returns the raw model-facing
7//!   value; `for_llm()` wraps it in a sealed [`RenderedForLlm`] carrier
8//!   so emit sites cannot fabricate model-facing content without
9//!   passing through a registered impl. Implementors keep prose brief,
10//!   omit operator-only context (status codes, type-system
11//!   identifiers, source chains), and never echo input payloads —
12//!   those are prompt-injection vectors.
13//! - [`LlmFacingSchema`] — `strip(&Value) -> Value` reduces a JSON
14//!   Schema to the keys vendor APIs actually consume (`type`,
15//!   `properties`, `required`, `items`, `enum`, `description`,
16//!   bounds…). Schemars-generated knobs (`$schema`, `title`,
17//!   `$defs`, `$ref`, format specifiers like `int64`) ride out.
18//!   Saves 30–120 tokens per tool per request × every turn.
19//!
20//! ## Why the sealed carrier
21//!
22//! Errors, future sub-agent results, approval decisions, and
23//! memory-recall summaries all flow through the same funnel toward
24//! the model's context window. Without a sealed carrier any
25//! `String`-typed field can be fabricated by external code — a
26//! reviewer reading an emit site cannot distinguish "this string
27//! went through the LLM-facing rendering" from "this string was
28//! built directly from operator content". Wrapping the value in
29//! `RenderedForLlm<T>` whose constructor is private to this
30//! module makes the boundary structural: the only path from value
31//! to carrier is the trait's default `for_llm` impl, which wraps
32//! the implementer's `render_for_llm` output. A subtype that
33//! tries to override `for_llm` cannot reach `RenderedForLlm::new`,
34//! so the sealing holds across crate boundaries.
35//!
36//! ## Why a separate trait rather than a method on `Error`
37//!
38//! The split lets non-`Error` types (custom tool error wrappers, MCP
39//! server errors lifted into IR, future sub-agent result types) opt
40//! into the same contract without coupling to `entelix_core::Error`.
41//! Default impls on `Error` and `String`/`&str` cover the common
42//! cases; bespoke implementors override `render_for_llm` only.
43//!
44//! ## Enforcement
45//!
46//! `crates/entelix-tools/tests/llm_context_economy.rs` regression-checks
47//! that built-in tool outputs and tool-spec schemas never leak the
48//! forbidden patterns. CI rejects new sites silently re-introducing
49//! operator-channel content into the model's view.
50
51use std::collections::BTreeMap;
52
53use serde_json::{Map, Value};
54
55use crate::error::Error;
56
57/// Sealed carrier for a model-facing value of type `T`. Constructed
58/// only by [`LlmRenderable::for_llm`]'s default impl — the
59/// constructor is `pub(crate)`, so an external crate that
60/// implements [`LlmRenderable<T>`] for its own type can override
61/// `render_for_llm` (the raw producer) but cannot override
62/// `for_llm` (the carrier-producing wrapper) because it has no way
63/// to reach `RenderedForLlm::new`. Emit sites that accept
64/// `RenderedForLlm<T>` therefore receive a value that
65/// structurally must have come through the trait funnel.
66///
67/// `RenderedForLlm` is intentionally minimal — it exposes
68/// [`Self::into_inner`] for consumers that need to forward the
69/// underlying value (the audit-log projection of
70/// `AgentEvent::ToolError` does exactly this when emitting the
71/// model-safe rendering as `GraphEvent::ToolResult` content). The
72/// carrier carries no metadata because the boundary it enforces is
73/// authorship, not provenance.
74#[derive(Clone, Debug, Eq, Hash, PartialEq)]
75pub struct RenderedForLlm<T>(T);
76
77impl<T> RenderedForLlm<T> {
78    /// Sealed constructor — only [`LlmRenderable::for_llm`]'s
79    /// default impl reaches this. `pub(crate)` is the entire seal.
80    pub(crate) const fn new(inner: T) -> Self {
81        Self(inner)
82    }
83
84    /// Borrow the inner model-facing value.
85    #[must_use]
86    pub const fn as_inner(&self) -> &T {
87        &self.0
88    }
89
90    /// Consume the carrier and return the inner value.
91    #[must_use]
92    pub fn into_inner(self) -> T {
93        self.0
94    }
95}
96
97impl<T: AsRef<str>> AsRef<str> for RenderedForLlm<T> {
98    fn as_ref(&self) -> &str {
99        self.0.as_ref()
100    }
101}
102
103impl<T: std::fmt::Display> std::fmt::Display for RenderedForLlm<T> {
104    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105        self.0.fmt(f)
106    }
107}
108
109impl<T> serde::Serialize for RenderedForLlm<T>
110where
111    T: serde::Serialize,
112{
113    fn serialize<S: serde::Serializer>(&self, ser: S) -> std::result::Result<S::Ok, S::Error> {
114        self.0.serialize(ser)
115    }
116}
117
118impl<'de, T> serde::Deserialize<'de> for RenderedForLlm<T>
119where
120    T: serde::Deserialize<'de>,
121{
122    fn deserialize<D: serde::Deserializer<'de>>(de: D) -> std::result::Result<Self, D::Error> {
123        // Audit-log replay paths (re-load `AgentEvent::ToolError`
124        // events from a `SessionLog`) must reconstruct the carrier
125        // around its persisted inner value. The persisted value
126        // already passed `for_llm` on first emit (invariant 18 —
127        // events are the SSoT), so deserialising into the carrier
128        // is the inverse, not a fresh fabrication.
129        T::deserialize(de).map(Self::new)
130    }
131}
132
133/// Render a value (typically an error, sub-agent result, or
134/// memory-recall summary) into the short, actionable form the
135/// model is allowed to see. Implementors define
136/// [`Self::render_for_llm`] (the raw producer); the default
137/// [`Self::for_llm`] wraps the result in a sealed
138/// [`RenderedForLlm`] carrier whose constructor is private to this
139/// crate, so emit sites that accept the carrier receive a value
140/// that structurally went through the trait.
141///
142/// Implementations keep prose brief, omit operator-only context
143/// (status codes, type-system identifiers, source chains), and
144/// never echo input payloads — those are prompt-injection vectors.
145/// The full operator-facing form continues to flow through
146/// `Display` / `Error::source` / event sinks / OTel.
147pub trait LlmRenderable<T> {
148    /// The raw model-facing rendering. Must not include vendor
149    /// status codes, `provider returned …` framing, source chains,
150    /// RFC3339 timestamps, or internal type names — operator
151    /// channels carry those.
152    fn render_for_llm(&self) -> T;
153
154    /// Sealed carrier wrapping [`Self::render_for_llm`]'s output.
155    /// External crates that implement this trait cannot override
156    /// this method without access to `RenderedForLlm::new`, which
157    /// is `pub(crate)` to `entelix-core`. The boundary therefore
158    /// holds across crate boundaries: only `entelix-core`'s default
159    /// impl can produce a `RenderedForLlm<T>`.
160    fn for_llm(&self) -> RenderedForLlm<T> {
161        RenderedForLlm::new(self.render_for_llm())
162    }
163}
164
165// `use_self` would prefer `Self` in place of `String` here, but the
166// trait param `String` and the receiver type `String` are
167// fundamentally the same in this two-parameter `LlmRenderable<T>`
168// shape — substituting `Self` reads worse than the explicit form.
169#[allow(clippy::use_self)]
170impl LlmRenderable<String> for String {
171    /// Identity rendering. The seal still holds — `for_llm()`'s
172    /// default impl (the only path to `RenderedForLlm::new`) routes
173    /// every emit through this trait, even when the operator's hint
174    /// is already a plain string. Validators raising
175    /// `Error::ModelRetry` thus write
176    /// `"corrective text".to_owned().for_llm()` and the type system
177    /// confirms the rendering boundary was crossed.
178    fn render_for_llm(&self) -> String {
179        self.clone()
180    }
181}
182
183impl LlmRenderable<String> for &str {
184    fn render_for_llm(&self) -> String {
185        (*self).to_owned()
186    }
187}
188
189impl LlmRenderable<String> for Error {
190    /// Short, model-actionable rendering. Mapping:
191    ///
192    /// - `InvalidRequest(msg)` → `"invalid input: {msg}"` — the
193    ///   message is already caller-supplied and free of vendor
194    ///   identifiers.
195    /// - `Provider { .. }` → `"upstream model error"` — vendor
196    ///   status is operator-only.
197    /// - `Auth(_)` → `"authentication failed"` — never echo the
198    ///   underlying provider's auth diagnostic.
199    /// - `Config(_)` → `"tool misconfigured"` — operator must fix.
200    /// - `Cancelled` → `"cancelled"`.
201    /// - `DeadlineExceeded` → `"timed out"`.
202    /// - `Interrupted { .. }` → `"awaiting human review"`.
203    /// - `Serde(_)` → `"output could not be serialised"` — the
204    ///   inner serde error names internal types.
205    fn render_for_llm(&self) -> String {
206        match self {
207            Self::InvalidRequest(msg) => format!("invalid input: {msg}"),
208            Self::Provider { .. } => "upstream model error".to_owned(),
209            Self::Auth(_) => "authentication failed".to_owned(),
210            Self::Config(_) => "tool misconfigured".to_owned(),
211            Self::Cancelled => "cancelled".to_owned(),
212            Self::DeadlineExceeded => "timed out".to_owned(),
213            Self::Interrupted { .. } => "awaiting human review".to_owned(),
214            Self::Serde(_) => "output could not be serialised".to_owned(),
215            // Usage-limit breaches are operational signals — the
216            // model does not need budget visibility (and exposing
217            // it would invite the model to plan around limits).
218            Self::UsageLimitExceeded(_) => "request quota reached".to_owned(),
219            // `ModelRetry` carries an already-rendered hint by
220            // construction — surface that text verbatim. The retry
221            // loop catches the variant before LLM emission in normal
222            // flow; this branch covers leaks past the loop boundary.
223            Self::ModelRetry { hint, .. } => hint.as_inner().clone(),
224            // Terminal-routed tool failures should not reach the
225            // model in the first place — the reasoning loop catches
226            // them and returns to the caller. This arm covers leaks
227            // past the loop boundary; delegate to the inner source
228            // so the rendering matches what the underlying error
229            // would have produced (the routing flag is operator-only).
230            Self::ToolErrorTerminal { source, .. } => source.render_for_llm(),
231        }
232    }
233}
234
235/// JSON-Schema sanitiser — strips schemars / draft-meta keys that
236/// vendor APIs ignore but that still cost tokens to ship.
237pub struct LlmFacingSchema;
238
239/// JSON-Schema key classification — drives the schema-aware walk.
240///
241/// Different keys hold different *kinds* of value: some carry literal
242/// data (`type: "string"`, `description: "..."`), some carry a single
243/// nested schema (`items`, `additionalProperties` when an object),
244/// some carry an array of schemas (`anyOf`, `oneOf`, `allOf`), some
245/// carry a `map<user-name, schema>` (`properties`), and some carry
246/// user data that must not be schema-walked (`enum`, `default`,
247/// `const`, `required`). The classifier picks the right walk for
248/// each key so user-named properties survive the strip and user
249/// values are not accidentally pruned to empty objects.
250enum AllowedKey {
251    /// Literal value — `type`, `description`, bounds, `format`, …
252    /// Cloned through (with the `format` noise filter applied).
253    Literal,
254    /// Single nested schema — `items` (single-schema form),
255    /// `additionalProperties` (when an object), `not`.
256    Schema,
257    /// Array of nested schemas — `anyOf`, `oneOf`, `allOf`.
258    /// `items` (array form) also flows through here at runtime.
259    SchemaArray,
260    /// Map of user-named entries to schemas — `properties`. Keys
261    /// are preserved verbatim; values are schema-walked.
262    SchemaMap,
263    /// User data — `enum`, `default`, `const`, `required`. Cloned
264    /// verbatim; never schema-walked.
265    UserData,
266}
267
268fn classify(key: &str) -> Option<AllowedKey> {
269    Some(match key {
270        "type" | "description" | "minimum" | "maximum" | "exclusiveMinimum"
271        | "exclusiveMaximum" | "minLength" | "maxLength" | "minItems" | "maxItems"
272        | "uniqueItems" | "minProperties" | "maxProperties" | "pattern" | "format" => {
273            AllowedKey::Literal
274        }
275        "items" | "additionalProperties" | "not" => AllowedKey::Schema,
276        "anyOf" | "oneOf" | "allOf" => AllowedKey::SchemaArray,
277        "properties" => AllowedKey::SchemaMap,
278        "enum" | "default" | "const" | "required" => AllowedKey::UserData,
279        _ => return None,
280    })
281}
282
283/// `format` values that read as noise to the vendor — the
284/// JSON-Schema-encoded width hint is already implied by
285/// `type: "integer"`/`"number"` and the model gains nothing from
286/// seeing it. Removing them shrinks the wire without losing meaning.
287const NOISY_FORMATS: &[&str] = &[
288    "int8", "int16", "int32", "int64", "uint8", "uint16", "uint32", "uint64", "float", "double",
289];
290
291impl LlmFacingSchema {
292    /// Walk `schema` and return a copy containing only
293    /// vendor-relevant keys. The walk inlines `$ref`/`$defs`
294    /// indirection so the resulting schema is self-contained — no
295    /// dangling references, no draft-meta envelope.
296    ///
297    /// Self-referential `$ref` chains (`Inner` → `Inner`, or
298    /// `A` → `B` → `A`) cannot be expanded into a finite tree.
299    /// On cycle the recursion breaks at the offending node by
300    /// substituting an empty `{}` (accept-any) schema and
301    /// `tracing::warn!`s the cycle's def-name chain so operators
302    /// see the truncation. The accept-any substitute keeps the
303    /// surrounding shape valid for vendor consumption — the model
304    /// loses the inner recursion's structural detail (necessarily,
305    /// since cyclic types have no finite JSON Schema form), but
306    /// the schema as a whole stays well-formed.
307    #[must_use]
308    pub fn strip(schema: &Value) -> Value {
309        let defs = collect_defs(schema);
310        let mut visited: Vec<String> = Vec::new();
311        strip_schema(schema, &defs, &mut visited)
312    }
313}
314
315fn collect_defs(schema: &Value) -> BTreeMap<String, Value> {
316    let mut out = BTreeMap::new();
317    if let Some(obj) = schema.as_object() {
318        // Merge `$defs` (2020-12) and the legacy `definitions` key.
319        for key in ["$defs", "definitions"] {
320            if let Some(Value::Object(defs)) = obj.get(key) {
321                for (name, body) in defs {
322                    out.insert(name.clone(), body.clone());
323                }
324            }
325        }
326    }
327    out
328}
329
330/// Strip one schema node. Resolves `$ref` indirection up front, then
331/// dispatches each surviving key according to its [`AllowedKey`]
332/// classification.
333///
334/// `visited` is the stack of `$defs` / `definitions` names currently
335/// being expanded along this branch of the recursion. A `$ref` whose
336/// target name already sits on the stack is a cycle — without
337/// breaking the recursion the call stack-overflows on any
338/// self-referential type (`Box<Self>` in a Rust struct → schemars
339/// emits a `$ref` back into its own `$defs` body).
340fn strip_schema(node: &Value, defs: &BTreeMap<String, Value>, visited: &mut Vec<String>) -> Value {
341    let Some(obj) = node.as_object() else {
342        // Not an object (likely a boolean schema like
343        // `additionalProperties: false` or an `items: true` shorthand)
344        // — clone through unchanged.
345        return node.clone();
346    };
347
348    // `$ref` short-circuits — replace the whole node with the
349    // stripped definition body. Eliminates `$defs` indirection.
350    // Cyclic chains break to an accept-any `{}` substitute so
351    // self-referential types do not stack-overflow the encoder.
352    if let Some(Value::String(reference)) = obj.get("$ref")
353        && let Some(name) = reference
354            .strip_prefix("#/$defs/")
355            .or_else(|| reference.strip_prefix("#/definitions/"))
356        && let Some(target) = defs.get(name)
357    {
358        if visited.iter().any(|seen| seen == name) {
359            let cycle_chain: Vec<&str> = visited
360                .iter()
361                .map(String::as_str)
362                .chain(std::iter::once(name))
363                .collect();
364            tracing::warn!(
365                cycle = ?cycle_chain,
366                "LlmFacingSchema::strip broke a $ref cycle — emitting accept-any substitute"
367            );
368            return Value::Object(Map::new());
369        }
370        visited.push(name.to_owned());
371        let stripped = strip_schema(target, defs, visited);
372        visited.pop();
373        return stripped;
374    }
375
376    let mut out = Map::new();
377    for (key, value) in obj {
378        let Some(kind) = classify(key) else {
379            continue;
380        };
381        match kind {
382            AllowedKey::Literal => {
383                if key == "format"
384                    && let Some(format) = value.as_str()
385                    && NOISY_FORMATS.contains(&format)
386                {
387                    continue;
388                }
389                out.insert(key.clone(), value.clone());
390            }
391            AllowedKey::Schema => {
392                // `items` may be a single schema or an array of
393                // schemas (tuple-style validation); `additionalProperties`
394                // may be a boolean. Dispatch per shape.
395                let stripped = match value {
396                    Value::Array(arr) => {
397                        Value::Array(arr.iter().map(|v| strip_schema(v, defs, visited)).collect())
398                    }
399                    other => strip_schema(other, defs, visited),
400                };
401                out.insert(key.clone(), stripped);
402            }
403            AllowedKey::SchemaArray => {
404                if let Value::Array(arr) = value {
405                    let stripped: Vec<Value> =
406                        arr.iter().map(|v| strip_schema(v, defs, visited)).collect();
407                    out.insert(key.clone(), Value::Array(stripped));
408                } else {
409                    // Malformed — keep the original; the vendor will
410                    // reject it with a clearer error than we can
411                    // synthesize here.
412                    out.insert(key.clone(), value.clone());
413                }
414            }
415            AllowedKey::SchemaMap => {
416                // User-named keys → preserve verbatim, values → walk.
417                if let Value::Object(map) = value {
418                    let stripped: Map<String, Value> = map
419                        .iter()
420                        .map(|(k, v)| (k.clone(), strip_schema(v, defs, visited)))
421                        .collect();
422                    out.insert(key.clone(), Value::Object(stripped));
423                } else {
424                    out.insert(key.clone(), value.clone());
425                }
426            }
427            AllowedKey::UserData => {
428                out.insert(key.clone(), value.clone());
429            }
430        }
431    }
432    Value::Object(out)
433}
434
435#[cfg(test)]
436#[allow(clippy::unwrap_used, clippy::indexing_slicing)]
437mod tests {
438    use super::*;
439    use serde_json::json;
440
441    #[test]
442    fn render_for_llm_omits_provider_status() {
443        let err = Error::provider_http(503, "vendor down".to_owned());
444        let rendered = err.render_for_llm();
445        assert!(!rendered.contains("503"), "{rendered}");
446        assert!(!rendered.contains("vendor down"), "{rendered}");
447        assert!(!rendered.contains("provider returned"), "{rendered}");
448    }
449
450    #[test]
451    fn render_for_llm_invalid_request_carries_caller_message() {
452        let err = Error::invalid_request("missing 'task' field");
453        assert_eq!(err.render_for_llm(), "invalid input: missing 'task' field");
454    }
455
456    #[test]
457    fn strip_removes_schema_envelope() {
458        let raw = json!({
459            "$schema": "https://json-schema.org/draft/2020-12/schema",
460            "title": "DoubleInput",
461            "type": "object",
462            "properties": {"n": {"type": "integer", "format": "int64"}},
463            "required": ["n"]
464        });
465        let stripped = LlmFacingSchema::strip(&raw);
466        assert!(stripped.get("$schema").is_none());
467        assert!(stripped.get("title").is_none());
468        assert_eq!(stripped["type"], "object");
469        assert_eq!(stripped["properties"]["n"]["type"], "integer");
470        // int64 is the noisy width hint — dropped.
471        assert!(stripped["properties"]["n"].get("format").is_none());
472        assert_eq!(stripped["required"], json!(["n"]));
473    }
474
475    #[test]
476    fn strip_inlines_refs_and_drops_defs_envelope() {
477        let raw = json!({
478            "$schema": "https://json-schema.org/draft/2020-12/schema",
479            "title": "Outer",
480            "type": "object",
481            "properties": {"inner": {"$ref": "#/$defs/Inner"}},
482            "$defs": {
483                "Inner": {
484                    "title": "Inner",
485                    "type": "object",
486                    "properties": {"x": {"type": "string"}},
487                    "required": ["x"]
488                }
489            }
490        });
491        let stripped = LlmFacingSchema::strip(&raw);
492        assert!(stripped.get("$defs").is_none());
493        let inner = &stripped["properties"]["inner"];
494        // $ref resolved → inlined object, title gone.
495        assert_eq!(inner["type"], "object");
496        assert_eq!(inner["properties"]["x"]["type"], "string");
497        assert!(inner.get("title").is_none());
498    }
499
500    #[test]
501    fn strip_keeps_meaningful_format_specifiers() {
502        // `date-time`, `email`, `uri` are real vendor-honored
503        // formats — the noise list only targets width hints.
504        let raw = json!({
505            "type": "string",
506            "format": "date-time"
507        });
508        let stripped = LlmFacingSchema::strip(&raw);
509        assert_eq!(stripped["format"], "date-time");
510    }
511
512    #[test]
513    fn strip_breaks_self_referential_ref_cycle() {
514        // `Tree { children: Vec<Tree> }` — schemars emits a `$ref`
515        // pointing back into `Tree`'s own `$defs` body. Without
516        // cycle detection the inliner stack-overflows on this
517        // shape, which would be a DoS surface for operator-supplied
518        // schemas.
519        let raw = json!({
520            "$schema": "https://json-schema.org/draft/2020-12/schema",
521            "title": "Tree",
522            "type": "object",
523            "properties": {
524                "value": {"type": "string"},
525                "children": {
526                    "type": "array",
527                    "items": {"$ref": "#/$defs/Tree"}
528                }
529            },
530            "$defs": {
531                "Tree": {
532                    "type": "object",
533                    "properties": {
534                        "value": {"type": "string"},
535                        "children": {
536                            "type": "array",
537                            "items": {"$ref": "#/$defs/Tree"}
538                        }
539                    }
540                }
541            }
542        });
543        let stripped = LlmFacingSchema::strip(&raw);
544        // Convention (matches ajv, json-ref-resolver, etc.): the
545        // first expansion of a `$ref` along a recursion branch is
546        // performed; the *second* hit on the same name breaks the
547        // cycle. So `Tree.children.items` expands once into the
548        // Tree body, and the next `Tree.children.items` inside
549        // that expansion is the accept-any substitute.
550        assert_eq!(stripped["type"], "object");
551        assert_eq!(stripped["properties"]["value"]["type"], "string");
552        let one_level_deep = &stripped["properties"]["children"]["items"];
553        assert_eq!(one_level_deep["type"], "object");
554        assert_eq!(one_level_deep["properties"]["value"]["type"], "string");
555        let cycle_break = &one_level_deep["properties"]["children"]["items"];
556        assert_eq!(cycle_break, &json!({}));
557    }
558
559    #[test]
560    fn strip_breaks_mutually_recursive_ref_cycle() {
561        // `A { b: B }` and `B { a: A }` — alternating `$ref`s. The
562        // visited stack must catch the cycle at the second hop back
563        // to A, not just direct self-references.
564        let raw = json!({
565            "type": "object",
566            "properties": {"root": {"$ref": "#/$defs/A"}},
567            "$defs": {
568                "A": {
569                    "type": "object",
570                    "properties": {"b": {"$ref": "#/$defs/B"}}
571                },
572                "B": {
573                    "type": "object",
574                    "properties": {"a": {"$ref": "#/$defs/A"}}
575                }
576            }
577        });
578        let stripped = LlmFacingSchema::strip(&raw);
579        let root = &stripped["properties"]["root"];
580        assert_eq!(root["type"], "object");
581        // root.b expanded to B; B.a hits the cycle → accept-any.
582        let cycle_break = &root["properties"]["b"]["properties"]["a"];
583        assert_eq!(cycle_break, &json!({}));
584    }
585
586    #[test]
587    fn strip_inlines_shared_non_cyclic_ref_at_every_usage() {
588        // `Outer { x: Shared, y: Shared }` — `Shared` referenced
589        // twice but never recursively. Both usages must inline
590        // independently; the visited stack must `pop` after `x` so
591        // `y` can re-enter `Shared`.
592        let raw = json!({
593            "type": "object",
594            "properties": {
595                "x": {"$ref": "#/$defs/Shared"},
596                "y": {"$ref": "#/$defs/Shared"}
597            },
598            "$defs": {
599                "Shared": {"type": "string", "description": "shared scalar"}
600            }
601        });
602        let stripped = LlmFacingSchema::strip(&raw);
603        assert_eq!(stripped["properties"]["x"]["type"], "string");
604        assert_eq!(stripped["properties"]["x"]["description"], "shared scalar");
605        assert_eq!(stripped["properties"]["y"]["type"], "string");
606        assert_eq!(stripped["properties"]["y"]["description"], "shared scalar");
607    }
608}