- 1
//! One projection, three audiences. - 2
//! - 3
//! Previously the model and the operator read different sources and could - 4
//! disagree. `doctor` counted hooks and MCP servers from raw config while - 5
//! counting skills from the effective set, so a plugin-contributed server was - 6
//! invisible to the operator and present to the model. `reach` recovered a - 7
//! server name by string-parsing a human-readable label, making a rendering - 8
//! decision load-bearing for a policy decision. And `capability_diagnostics` - 9
//! — the one structure that explained *why* something was dropped — was - 10
//! rendered only into the system prompt, so the model was told and the person - 11
//! who could fix the configuration was not. - 12
//! - 13
//! Everything here derives from one published [`CapabilitySet`]. The model's - 14
//! standing section, the `doctor` report, and the admin console render this - 15
//! same value, so they cannot drift apart. - 16
- 17
use serde::{Deserialize, Serialize}; - 18
- 19
use super::registry::ReconcileStatus; - 20
use super::snapshot::{CapabilityDelta, CapabilitySet}; - 21
- 22
/// One capability, flattened for display. - 23
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] - 24
pub struct CapabilityRow { - 25
pub id: String, - 26
pub kind: String, - 27
pub name: String, - 28
pub origin: String, - 29
pub summary: String, - 30
/// Declared domains; empty means undeclared, which is never sliced away. - 31
pub serves: Vec<String>, - 32
pub usable: bool, - 33
/// "ready" | "checking" | "unavailable: … " | "removed: …" - 34
pub status: String, - 35
/// What the operator can do, when there is something to do. - 36
pub remedy: String, - 37
pub source: Option<String>, - 38
} - 39
- 40
/// The whole subsystem, in one value. - 41
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] - 42
pub struct CapabilityReport { - 43
pub epoch: u64, - 44
pub digest: String, - 45
pub reconcile: ReconcileStatus, - 46
pub capabilities: Vec<CapabilityRow>, - 47
} - 48
- 49
impl CapabilityReport { - 50
pub fn build(set: &CapabilitySet, reconcile: ReconcileStatus) -> Self { - 51
let capabilities = set - 52
.all() - 53
.map(|capability| { - 54
let failure = mcp_failure(capability); - 55
let remedy = failure - 56
.map(|_| mcp_remedy(&capability.id.name)) - 57
.unwrap_or_default(); - 58
let status = match failure { - 59
Some(reason) => format!("ready (last attempt failed: {reason})"), - 60
None => capability.resolution.summary(), - 61
}; - 62
CapabilityRow { - 63
id: capability.id.to_string(), - 64
kind: format!("{:?}", capability.id.kind).to_lowercase(), - 65
name: capability.id.name.clone(), - 66
origin: capability.origin.label(), - 67
summary: capability.summary.clone(), - 68
serves: capability.serves.labels(), - 69
usable: capability.is_usable(), - 70
status, - 71
remedy, - 72
source: capability.source.as_ref().map(|p| p.display().to_string()), - 73
} - 74
}) - 75
.collect(); - 76
CapabilityReport { - 77
epoch: set.epoch, - 78
digest: set.digest.clone(), - 79
reconcile, - 80
capabilities, - 81
} - 82
} - 83
- 84
pub fn usable(&self) -> impl Iterator<Item = &CapabilityRow> { - 85
self.capabilities.iter().filter(|row| row.usable) - 86
} - 87
- 88
pub fn unusable(&self) -> impl Iterator<Item = &CapabilityRow> { - 89
self.capabilities.iter().filter(|row| !row.usable) - 90
} - 91
- 92
/// Counts by kind, over the *effective* set — the number `doctor` should - 93
/// have been showing all along. - 94
pub fn counts(&self) -> Vec<(String, usize, usize)> { - 95
let mut kinds: Vec<String> = self - 96
.capabilities - 97
.iter() - 98
.map(|row| row.kind.clone()) - 99
.collect(); - 100
kinds.sort(); - 101
kinds.dedup(); - 102
kinds - 103
.into_iter() - 104
.map(|kind| { - 105
let total = self - 106
.capabilities - 107
.iter() - 108
.filter(|row| row.kind == kind) - 109
.count(); - 110
let usable = self - 111
.capabilities - 112
.iter() - 113
.filter(|row| row.kind == kind && row.usable) - 114
.count(); - 115
(kind, usable, total) - 116
}) - 117
.collect() - 118
} - 119
- 120
/// One line for `doctor`'s facts block. - 121
pub fn summary_line(&self) -> String { - 122
let parts = self - 123
.counts() - 124
.into_iter() - 125
.map(|(kind, usable, total)| { - 126
if usable == total { - 127
format!("{usable} {kind}") - 128
} else { - 129
format!("{usable}/{total} {kind}") - 130
} - 131
}) - 132
.collect::<Vec<_>>(); - 133
format!("capabilities (epoch {}): {}", self.epoch, parts.join(" · ")) - 134
} - 135
} - 136
- 137
/// The failure the MCP pool last observed for a server, if any. The server - 138
/// stays callable — the next demand retries after the pool's backoff — so - 139
/// this is a status to report, not a reason to withhold it. - 140
pub fn mcp_failure(capability: &super::snapshot::Capability) -> Option<&str> { - 141
if capability.id.kind != vak_session::types::CapabilityKind::McpServer { - 142
return None; - 143
} - 144
capability.configuration.get("last_failure")?.as_str() - 145
} - 146
- 147
/// What an operator can change when a server fails. - 148
pub fn mcp_remedy(server: &str) -> String { - 149
format!("check the `{server}` entry under [mcp.servers] — command, args, and any required env") - 150
} - 151
- 152
/// The model-facing standing section: what is configured but not usable on - 153
/// this turn, and what changed since this session's last turn. - 154
/// - 155
/// The wording matters. A model told only "you have no live-data tool" answers - 156
/// from memory and sounds certain; told "the `search` server is configured - 157
/// and currently unreachable", it says so and names the fix. - 158
pub fn standing_section(set: &CapabilitySet, delta: Option<&CapabilityDelta>) -> String { - 159
let mut out = String::new(); - 160
- 161
if let Some(delta) = delta.filter(|d| !d.is_empty()) { - 162
out.push_str(&format!( - 163
"\nYour capabilities changed since your last turn — {}.\n", - 164
delta.describe() - 165
)); - 166
} - 167
- 168
let unusable: Vec<_> = set - 169
.unusable() - 170
.filter(|c| { - 171
c.id.kind != vak_session::types::CapabilityKind::Hook - 172
&& c.id.kind != vak_session::types::CapabilityKind::Command - 173
}) - 174
.collect(); - 175
if !unusable.is_empty() { - 176
out.push_str( - 177
"\nConfigured but NOT usable on this turn. These are not in your tool schemas \ - 178
and calling them will fail. If the request needs one, say so plainly, name the \ - 179
capability, and give the operator the fix — do not substitute a different tool \ - 180
and do not answer as though you had the data:\n", - 181
); - 182
for capability in unusable { - 183
out.push_str(&format!( - 184
"- {}: {}", - 185
capability.id, - 186
capability.resolution.summary() - 187
)); - 188
out.push('\n'); - 189
} - 190
} - 191
out - 192
} - 193
- 194
#[cfg(test)] - 195
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - 196
mod tests { - 197
use super::*; - 198
use crate::capability::domain::Serves; - 199
use crate::capability::resolution::Resolution; - 200
use crate::capability::snapshot::{Capability, CapabilityId, Origin}; - 201
use vak_session::types::CapabilityKind; - 202
- 203
fn failing_server(name: &str) -> Capability { - 204
Capability { - 205
configuration: serde_json::json!({"last_failure": "connection refused"}), - 206
..ok(name, CapabilityKind::McpServer) - 207
} - 208
} - 209
- 210
fn revoked(name: &str) -> Capability { - 211
Capability { - 212
resolution: Resolution::Retired { - 213
reason: "operator disabled".into(), - 214
}, - 215
..ok(name, CapabilityKind::McpServer) - 216
} - 217
} - 218
- 219
fn ok(name: &str, kind: CapabilityKind) -> Capability { - 220
Capability { - 221
id: CapabilityId::new(kind, name), - 222
origin: Origin::Builtin, - 223
summary: String::new(), - 224
serves: Serves::Undeclared, - 225
digest: None, - 226
source: None, - 227
resolution: Resolution::Available, - 228
configuration: serde_json::Value::Null, - 229
} - 230
} - 231
- 232
/// A server whose last attempt failed stays callable (the pool retries - 233
/// on the next demand), so the operator sees the failure and its fix - 234
/// without the model being told the server is gone. - 235
#[test] - 236
fn the_operator_sees_an_observed_mcp_failure_and_its_fix() { - 237
let set = CapabilitySet::new( - 238
7, - 239
vec![ok("read", CapabilityKind::Tool), failing_server("tavily")], - 240
); - 241
let report = CapabilityReport::build(&set, ReconcileStatus::default()); - 242
let row = report - 243
.capabilities - 244
.iter() - 245
.find(|row| row.name == "tavily") - 246
.unwrap(); - 247
assert!(row.usable); - 248
assert!(row.status.contains("connection refused")); - 249
assert!(row.remedy.contains("[mcp.servers]")); - 250
assert!(standing_section(&set, None).is_empty()); - 251
} - 252
- 253
#[test] - 254
fn counts_come_from_the_effective_set_not_raw_config() { - 255
let set = CapabilitySet::new( - 256
1, - 257
vec![ - 258
ok("read", CapabilityKind::Tool), - 259
ok("pdf", CapabilityKind::Skill), - 260
revoked("tavily"), - 261
], - 262
); - 263
let report = CapabilityReport::build(&set, ReconcileStatus::default()); - 264
let line = report.summary_line(); - 265
assert!( - 266
line.contains("0/1 mcpserver"), - 267
"unusable must be visible: {line}" - 268
); - 269
assert!(line.contains("1 tool")); - 270
assert!(standing_section(&set, None).contains("removed: operator disabled")); - 271
} - 272
- 273
#[test] - 274
fn a_delta_is_announced_once_to_the_model() { - 275
let before = CapabilitySet::new(1, vec![ok("read", CapabilityKind::Tool)]); - 276
let after = CapabilitySet::new( - 277
2, - 278
vec![ - 279
ok("read", CapabilityKind::Tool), - 280
ok("pdf", CapabilityKind::Skill), - 281
], - 282
); - 283
let delta = after.delta_from(&before); - 284
let section = standing_section(&after, Some(&delta)); - 285
assert!(section.contains("changed since your last turn")); - 286
assert!(section.contains("skill:pdf")); - 287
} - 288
- 289
#[test] - 290
fn a_clean_set_says_nothing() { - 291
let set = CapabilitySet::new(1, vec![ok("read", CapabilityKind::Tool)]); - 292
assert!(standing_section(&set, None).is_empty()); - 293
} - 294
} - 295
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.