- 1
//! Provider-shape adapters: the one place external tool output is allowed to - 2
//! be reinterpreted for rendering. - 3
//! - 4
//! `structured_outputs_from_text` (see `skills.rs`) covers tools we can ask - 5
//! to speak our contract. Most tools are not ours to ask — a ticketing - 6
//! system, an inventory API, a third-party MCP server ships whatever shape - 7
//! it ships, and nothing here can make the rest of the world adopt - 8
//! `semantic_type` / `payload`. Rewriting a tool's actual output to fit our - 9
//! envelope would also reach past our own boundary: that output is the same - 10
//! value the ledger records, the same value a future turn's model sees, the - 11
//! same value any other consumer reads — none of which asked for our - 12
//! rendering concerns. - 13
//! - 14
//! So the boundary sits at render composition, not at the tool: an adapter - 15
//! reads the *stored, untouched* result and, only for shapes it explicitly - 16
//! recognizes, produces a `StructuredOutput` for that one rendering pass. It - 17
//! never writes back to the ledger, never changes what the tool returned, - 18
//! and — like every other structured candidate — never skips - 19
//! `SkillRegistry::validate` before anything is trusted enough to render. - 20
//! - 21
//! An adapter is deliberately narrow: it matches one provider's actual, - 22
//! known response shape and returns `None` for anything else. There is no - 23
//! adapter here that pattern-matches on field names in general ("has a - 24
//! `value` key, must be a metric") — that would be exactly the guessing this - 25
//! module exists to avoid. Recognizing a *specific, known* schema precisely - 26
//! is not guessing; inferring meaning from incidental field names is. - 27
//! - 28
//! Adding support for a new provider means writing one adapter and - 29
//! registering it — never touching another adapter, never adding a branch - 30
//! to the render pipeline itself, and never touching the tool that produced - 31
//! the data. The core ships none: a specific vendor's JSON shape is an - 32
//! integration's own concern, declared by its own manifest, not baked into - 33
//! this crate. - 34
- 35
use serde_json::Value; - 36
- 37
use crate::skills::StructuredOutput; - 38
- 39
/// Recognizes one external provider's specific, known response shape and - 40
/// projects it into a `StructuredOutput` candidate — still subject to - 41
/// `SkillRegistry::validate` before anything renders from it. - 42
pub trait ResultAdapter: Send + Sync { - 43
/// Stable identifier for diagnostics (never shown as if it were a - 44
/// tool name the pipeline "knows" — it names the adapter, not a tool). - 45
fn id(&self) -> &'static str; - 46
- 47
/// Return `Some` only when `raw` matches this adapter's specific known - 48
/// shape. Any other shape — including one that merely looks similar — - 49
/// must return `None` rather than guess. - 50
fn adapt(&self, raw: &Value) -> Option<StructuredOutput>; - 51
} - 52
- 53
/// An ordered, purely additive set of adapters. Order only matters as a - 54
/// tie-break when two adapters both recognize the same input, which a - 55
/// well-scoped adapter should make rare. - 56
#[derive(Default)] - 57
pub struct AdapterRegistry { - 58
adapters: Vec<Box<dyn ResultAdapter>>, - 59
} - 60
- 61
impl AdapterRegistry { - 62
pub fn register(&mut self, adapter: Box<dyn ResultAdapter>) { - 63
self.adapters.push(adapter); - 64
} - 65
- 66
/// Tries every registered adapter against `raw`, returning the first - 67
/// match. Registering a new adapter cannot change what an existing one - 68
/// matches — each is asked independently and in isolation. - 69
pub fn try_adapt(&self, raw: &Value) -> Option<StructuredOutput> { - 70
self.adapters.iter().find_map(|adapter| adapter.adapt(raw)) - 71
} - 72
- 73
#[cfg(test)] - 74
fn ids(&self) -> Vec<&'static str> { - 75
self.adapters.iter().map(|adapter| adapter.id()).collect() - 76
} - 77
} - 78
- 79
/// A tool result's full path to becoming a render candidate: try the - 80
/// self-declared contract first (`structured_outputs_from_text` — the tool - 81
/// already speaks our envelope, fenced or bare), and only if that finds - 82
/// nothing, try parsing the result as JSON and asking the adapter registry - 83
/// whether it recognizes the shape. Either way, nothing is returned that - 84
/// hasn't also passed `SkillRegistry::validate` for `surface` — an adapter - 85
/// gets no more trust than a tool declaring its own type would. - 86
pub fn structured_outputs_from_tool_result( - 87
text: &str, - 88
surface: &str, - 89
adapters: &AdapterRegistry, - 90
) -> Vec<StructuredOutput> { - 91
structured_outputs_from_tool_result_with( - 92
text, - 93
surface, - 94
adapters, - 95
&crate::skills::built_in_skill_registry(), - 96
) - 97
} - 98
- 99
pub fn structured_outputs_from_tool_result_with( - 100
text: &str, - 101
surface: &str, - 102
adapters: &AdapterRegistry, - 103
skills: &crate::skills::SkillRegistry, - 104
) -> Vec<StructuredOutput> { - 105
let declared = crate::skills::structured_outputs_from_text(text); - 106
if !declared.is_empty() { - 107
return declared; - 108
} - 109
let Ok(raw) = serde_json::from_str::<Value>(text) else { - 110
return Vec::new(); - 111
}; - 112
let Some(candidate) = adapters.try_adapt(&raw) else { - 113
return Vec::new(); - 114
}; - 115
if skills.validate(&candidate, surface, &[]).is_ok() { - 116
vec![candidate] - 117
} else { - 118
Vec::new() - 119
} - 120
} - 121
- 122
/// The core ships no built-in adapters: a specific vendor's JSON shape is an - 123
/// integration's own concern. An integration that needs one registers it - 124
/// through its own manifest; this registry stays the extension point. - 125
pub fn built_in_adapters() -> AdapterRegistry { - 126
AdapterRegistry::default() - 127
} - 128
- 129
#[cfg(test)] - 130
mod tests { - 131
#![allow(clippy::expect_used)] - 132
use super::*; - 133
use serde_json::json; - 134
- 135
/// A stand-in for a real third-party shape, kept in the test module and - 136
/// named accordingly so it can never be mistaken for a shipped adapter. - 137
struct FixtureOpenMeteoLikeAdapter; - 138
- 139
impl ResultAdapter for FixtureOpenMeteoLikeAdapter { - 140
fn id(&self) -> &'static str { - 141
"fixture.open_meteo_like" - 142
} - 143
- 144
fn adapt(&self, raw: &Value) -> Option<StructuredOutput> { - 145
let current = raw.get("current_weather")?; - 146
let temperature = current.get("temperature")?.as_f64()?; - 147
Some(StructuredOutput { - 148
semantic_type: "metric".into(), - 149
schema_version: crate::PRESENTATION_SCHEMA_VERSION, - 150
skill_id: "core".into(), - 151
skill_version: "1.0.0".into(), - 152
payload: json!({ - 153
"label": "Temperature", - 154
"value": temperature, - 155
"unit": "C", - 156
}), - 157
}) - 158
} - 159
} - 160
- 161
#[test] - 162
fn an_adapter_only_matches_its_own_known_shape() { - 163
let mut registry = AdapterRegistry::default(); - 164
registry.register(Box::new(FixtureOpenMeteoLikeAdapter)); - 165
- 166
let matched = registry - 167
.try_adapt(&json!({"current_weather": {"temperature": 21.5, "windspeed": 4.0}})) - 168
.expect("recognized shape should adapt"); - 169
assert_eq!(matched.semantic_type, "metric"); - 170
assert_eq!(matched.payload["value"], 21.5); - 171
- 172
// A shape that merely has some overlapping keys, or none at all, - 173
// must not be coerced into rendering as something it isn't. - 174
assert!(registry.try_adapt(&json!({"temp": 21.5})).is_none()); - 175
assert!(registry.try_adapt(&json!({"unrelated": true})).is_none()); - 176
} - 177
- 178
#[test] - 179
fn adapter_output_still_goes_through_normal_validation() { - 180
// The adapter itself can be wrong or stale; nothing here should let - 181
// its output skip the same schema check every other candidate goes - 182
// through before it is trusted enough to render. - 183
let mut registry = AdapterRegistry::default(); - 184
registry.register(Box::new(FixtureOpenMeteoLikeAdapter)); - 185
let candidate = registry - 186
.try_adapt(&json!({"current_weather": {"temperature": 21.5}})) - 187
.expect("recognized shape should adapt"); - 188
let registry_check = crate::skills::built_in_skill_registry(); - 189
assert!(registry_check.validate(&candidate, "desktop", &[]).is_ok()); - 190
} - 191
- 192
#[test] - 193
fn built_in_adapters_register_no_vendor_shapes() { - 194
// The core ships no provider-specific adapter: a vendor's JSON shape - 195
// is an integration's own concern, declared by its own manifest. - 196
let empty: Vec<&str> = Vec::new(); - 197
assert_eq!(built_in_adapters().ids(), empty); - 198
} - 199
- 200
#[test] - 201
fn a_self_declared_result_never_needs_an_adapter() { - 202
let mut adapters = AdapterRegistry::default(); - 203
adapters.register(Box::new(FixtureOpenMeteoLikeAdapter)); - 204
let outputs = structured_outputs_from_tool_result( - 205
r#"{"semantic_type":"metric","payload":{"label":"Temperature","value":25,"unit":"C"}}"#, - 206
"desktop", - 207
&adapters, - 208
); - 209
assert_eq!(outputs.len(), 1); - 210
assert_eq!(outputs[0].semantic_type, "metric"); - 211
} - 212
- 213
#[test] - 214
fn an_unrecognized_provider_shape_renders_as_nothing_rather_than_a_guess() { - 215
let adapters = AdapterRegistry::default(); - 216
let outputs = structured_outputs_from_tool_result( - 217
r#"{"current_weather": {"temperature": 21.5}}"#, - 218
"desktop", - 219
&adapters, - 220
); - 221
assert!(outputs.is_empty()); - 222
} - 223
- 224
#[test] - 225
fn a_recognized_provider_shape_adapts_and_validates() { - 226
let mut adapters = AdapterRegistry::default(); - 227
adapters.register(Box::new(FixtureOpenMeteoLikeAdapter)); - 228
let outputs = structured_outputs_from_tool_result( - 229
r#"{"current_weather": {"temperature": 21.5, "windspeed": 4.0}}"#, - 230
"desktop", - 231
&adapters, - 232
); - 233
assert_eq!(outputs.len(), 1); - 234
assert_eq!(outputs[0].semantic_type, "metric"); - 235
assert_eq!(outputs[0].payload["value"], 21.5); - 236
} - 237
} - 238
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.