- 1
//! `Core` as a [`CapabilityProvider`]: five kinds, one declaration set. - 2
//! - 3
//! Each kind used to have its own lifecycle — a `read_dir` walk per turn for - 4
//! skills and commands, a fingerprint-keyed cache for MCP, a config re-read - 5
//! for hooks, a static list for tools — and none of them could notice a - 6
//! change once a session's contract had frozen. Here they all produce the - 7
//! same [`Declaration`] and travel the same loop, so "added, updated, - 8
//! edited, removed" means one thing for all five. - 9
- 10
use std::sync::Arc; - 11
- 12
use async_trait::async_trait; - 13
use vak_session::types::CapabilityKind; - 14
- 15
use super::domain::{Domain, Serves}; - 16
use super::registry::{CapabilityProvider, CapabilityRegistry, Declaration, Hint}; - 17
use super::snapshot::{CapabilityId, Origin}; - 18
use crate::Core; - 19
- 20
/// Whether a call reaches information from outside the machine and the - 21
/// conversation — the kind an answer should cite — decided from what the - 22
/// capability *declares it serves*, never from its name or its output. - 23
/// - 24
/// * A built-in resolves through its own `Tool::serves` (`tool_serves`). - 25
/// * An MCP call resolves to its server's declared `serves`. A server that - 26
/// declares nothing falls back to the `mcp` broker's own declaration, which - 27
/// claims the web and live data; declaring `serves = ["documents"]` opts a - 28
/// server out. Listing a server's tools is not retrieval, only calling one. - 29
/// * A tool that declares nothing is not retrieval. - 30
/// - 31
/// `mcp_server` is the server a bare MCP tool name resolves to, if `name` is - 32
/// one; `server_serves` returns a server's configured `serves` list. - 33
pub(crate) fn call_retrieves_external( - 34
name: &str, - 35
input: &serde_json::Value, - 36
mcp_server: Option<&str>, - 37
tool_serves: &dyn Fn(&str) -> Vec<String>, - 38
server_serves: &dyn Fn(&str) -> Vec<String>, - 39
) -> bool { - 40
let reaches_outside = |serves: &[String]| { - 41
Domain::parse_list(serves) - 42
.iter() - 43
.any(|domain| matches!(domain, Domain::Web | Domain::LiveData)) - 44
}; - 45
let server = if name == "mcp" { - 46
if input.get("action").and_then(|a| a.as_str()) != Some("call") { - 47
return false; - 48
} - 49
input.get("server").and_then(|s| s.as_str()) - 50
} else { - 51
mcp_server - 52
}; - 53
match server { - 54
Some(server) => { - 55
let declared = server_serves(server); - 56
if declared.is_empty() { - 57
let broker: Vec<String> = vak_mcp::McpTool::SERVES - 58
.iter() - 59
.map(|d| d.to_string()) - 60
.collect(); - 61
reaches_outside(&broker) - 62
} else { - 63
reaches_outside(&declared) - 64
} - 65
} - 66
None => reaches_outside(&tool_serves(name)), - 67
} - 68
} - 69
- 70
/// Whether a built-in tool observes the current state of something — a file, - 71
/// the repository, a command's output, a page, a live value — decided from - 72
/// what it declares it serves. Memory, messaging, orchestration and document - 73
/// production recall or change things; they observe nothing, so a turn that - 74
/// asked for a current value is not answered by them. An MCP call counts - 75
/// through [`call_retrieves_external`]. - 76
pub(crate) fn serves_observation(serves: &[String]) -> bool { - 77
Domain::parse_list(serves).iter().any(|domain| { - 78
matches!( - 79
domain, - 80
Domain::LiveData - 81
| Domain::Web - 82
| Domain::Filesystem - 83
| Domain::CodeExec - 84
| Domain::Vcs - 85
| Domain::Observability - 86
) - 87
}) - 88
} - 89
- 90
impl Core { - 91
/// The registry, created and started on first use. - 92
/// - 93
/// Idempotent: many surfaces call this, and they all get the same - 94
/// registry and the same loop. - 95
pub fn capability_registry(&self) -> Arc<CapabilityRegistry> { - 96
if let Some(registry) = self.inner.capability_registry.get() { - 97
return registry.clone(); - 98
} - 99
let provider: Arc<dyn CapabilityProvider> = Arc::new(self.clone()); - 100
let (registry, hints) = CapabilityRegistry::new(provider); - 101
// Losing the race is fine: the winner's registry is the one everyone - 102
// uses, and the loser's is dropped without ever having been started. - 103
if let Err(losing) = self.inner.capability_registry.set(registry.clone()) { - 104
// Another thread won. Use theirs and drop ours unstarted, rather - 105
// than running two loops against one provider. - 106
drop(losing); - 107
return match self.inner.capability_registry.get() { - 108
Some(winner) => winner.clone(), - 109
// Unreachable in practice (`set` only fails when occupied), - 110
// but a registry is not worth a panic: an unstarted one still - 111
// reconciles on demand. - 112
None => registry, - 113
}; - 114
} - 115
let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false); - 116
if let Ok(mut slot) = self.inner.capability_shutdown.lock() { - 117
*slot = Some(shutdown_tx); - 118
} - 119
// Only spawn when a runtime is present. A synchronous caller (tests, - 120
// one-shot tooling) still gets a working registry — it just - 121
// reconciles on demand rather than on a rhythm. - 122
if tokio::runtime::Handle::try_current().is_ok() { - 123
let loop_registry = registry.clone(); - 124
tokio::spawn(async move { - 125
loop_registry.run(hints, shutdown_rx).await; - 126
}); - 127
registry.hint(Hint::Immediate); - 128
} - 129
registry - 130
} - 131
- 132
/// Reconcile now and return the published set, for callers that cannot - 133
/// wait for the loop: a one-shot CLI turn, or a surface that just - 134
/// changed configuration and wants the result to be visible immediately. - 135
pub async fn reconcile_capabilities(&self) -> Arc<super::snapshot::CapabilitySet> { - 136
let registry = self.capability_registry(); - 137
registry.reconcile().await; - 138
registry.current().await - 139
} - 140
} - 141
- 142
#[async_trait] - 143
impl CapabilityProvider for Core { - 144
fn declare(&self) -> Vec<Declaration> { - 145
let mut out = Vec::new(); - 146
- 147
// --- tools ------------------------------------------------------- - 148
// `tool_declarations()` already applies the runtime toggles and - 149
// channel policy, so a `[tools]` flag flipped through `PUT /config` - 150
// shows up on the next reconcile rather than at the next process - 151
// start. Each tool states its own domains. - 152
for (name, serves) in self.tool_declarations() { - 153
out.push(Declaration { - 154
id: CapabilityId::new(CapabilityKind::Tool, &name), - 155
origin: Origin::Builtin, - 156
summary: String::new(), - 157
serves: Serves::from_labels(serves), - 158
digest: None, - 159
source: None, - 160
configuration: serde_json::Value::Null, - 161
}); - 162
} - 163
if self.channel_tool_allowed("flow") { - 164
out.push(Declaration { - 165
id: CapabilityId::new(CapabilityKind::Tool, "flow"), - 166
origin: Origin::Builtin, - 167
summary: "Managed static-flow dispatcher".into(), - 168
serves: Serves::from_labels(vak_agent::FLOW_SERVES), - 169
digest: None, - 170
source: None, - 171
configuration: serde_json::Value::Null, - 172
}); - 173
} - 174
- 175
// --- skills ------------------------------------------------------ - 176
for skill in self.skills() { - 177
let Ok(digest) = skill.digest() else { - 178
// A skill whose body cannot be read is not silently dropped: - 179
// it simply does not declare, and the parse diagnostic - 180
// surfaces it. Admitting it would advertise instructions the - 181
// loader could not then produce. - 182
continue; - 183
}; - 184
// A skill classifies itself through its own `serves:` - 185
// frontmatter (`crate::skills::validate`). One that declares - 186
// nothing — including every seeded skill that predates this - 187
// field — is undeclared and therefore never sliced away; there - 188
// is no name-keyed table here to fall back to. - 189
let serves = skill - 190
.serves - 191
.as_ref() - 192
.map(|values| Serves::Declared(Domain::parse_list(values))) - 193
.unwrap_or(Serves::Undeclared); - 194
out.push(Declaration { - 195
id: CapabilityId::new(CapabilityKind::Skill, &skill.name), - 196
origin: origin_from_provenance(skill.provenance.as_deref()), - 197
summary: skill.description.clone(), - 198
serves, - 199
digest: Some(digest), - 200
source: Some(skill.path.clone()), - 201
configuration: serde_json::Value::Null, - 202
}); - 203
} - 204
- 205
// --- mcp servers ------------------------------------------------- - 206
// Declared from config alone; what the on-demand pool has observed - 207
// (catalog, last failure) rides along as data. Nothing here starts a - 208
// server — only a model's `mcp` call does. - 209
let mcp = self.effective_mcp(); - 210
let observed = self - 211
.mcp_manager() - 212
.map(|manager| manager.observations()) - 213
.unwrap_or_default(); - 214
for (name, server) in mcp.servers { - 215
let serves = if server.serves.is_empty() { - 216
// Deliberately not guessed from tool names: a keyword table - 217
// would reintroduce exactly the harness-side opinion this - 218
// design deletes. Undeclared is never sliced away, so the - 219
// common case of a server with no `serves` stays reachable. - 220
Serves::Undeclared - 221
} else { - 222
Serves::Declared(Domain::parse_list(&server.serves)) - 223
}; - 224
use sha2::{Digest, Sha256}; - 225
let mut hasher = Sha256::new(); - 226
hasher.update(name.as_bytes()); - 227
hasher.update(server.command.as_bytes()); - 228
for arg in &server.args { - 229
hasher.update(arg.as_bytes()); - 230
} - 231
for (k, v) in &server.env { - 232
hasher.update(k.as_bytes()); - 233
if let Some(resolved) = - 234
crate::interpolate_env_var_with(v, |key| self.mcp_secret(key)) - 235
{ - 236
hasher.update(b"resolved:"); - 237
hasher.update(resolved.as_bytes()); - 238
} else { - 239
hasher.update(b"unresolved:"); - 240
hasher.update(v.as_bytes()); - 241
} - 242
} - 243
let digest = Some(format!("{:x}", hasher.finalize())); - 244
- 245
out.push(Declaration { - 246
id: CapabilityId::new(CapabilityKind::McpServer, &name), - 247
origin: if name.starts_with("plugin.") { - 248
Origin::Plugin { - 249
plugin: name.split('.').nth(1).unwrap_or_default().to_string(), - 250
scope: "workspace".into(), - 251
} - 252
} else { - 253
Origin::Workspace - 254
}, - 255
summary: "MCP server reached through the brokered mcp tool".into(), - 256
serves, - 257
digest, - 258
source: None, - 259
configuration: observed - 260
.get(&name) - 261
.map(mcp_observation_json) - 262
.unwrap_or(serde_json::Value::Null), - 263
}); - 264
} - 265
- 266
// --- hooks ------------------------------------------------------- - 267
for hook in self.effective_hooks().into_iter().filter(|h| h.enabled) { - 268
out.push(Declaration { - 269
id: CapabilityId::new( - 270
CapabilityKind::Hook, - 271
format!("{}/{}", hook.event, hook.command), - 272
), - 273
origin: Origin::Workspace, - 274
summary: hook.matcher.clone().unwrap_or_default(), - 275
serves: Serves::Undeclared, - 276
digest: None, - 277
source: None, - 278
// The hook's own config, read back by `crate::hook_def` — the - 279
// same reader config validation uses. - 280
configuration: serde_json::to_value(&hook).unwrap_or_default(), - 281
}); - 282
} - 283
- 284
// --- commands ---------------------------------------------------- - 285
for command in self.custom_commands() { - 286
out.push(Declaration { - 287
id: CapabilityId::new(CapabilityKind::Command, &command.name), - 288
origin: origin_from_provenance(Some(&command.source)), - 289
summary: command.description.clone(), - 290
serves: Serves::Undeclared, - 291
digest: None, - 292
source: None, - 293
configuration: serde_json::json!({ "template": command.template }), - 294
}); - 295
} - 296
- 297
out - 298
} - 299
- 300
async fn upkeep(&self) { - 301
// Idle eviction: the pool's only background work, and it only ever - 302
// releases. A process that stays up for weeks must not hold a - 303
// subprocess for every server it has ever touched; the next call - 304
// respawns on demand. - 305
if let Some(manager) = self.mcp_manager() { - 306
manager.evict_idle(vak_mcp::IDLE_TTL).await; - 307
} - 308
} - 309
} - 310
- 311
/// A server's observation as declared configuration: its tools (name, - 312
/// description, schema) once used, and the last failure's reason. Only the - 313
/// reason — never attempt counts or times — so repeated failures for one - 314
/// cause do not republish an epoch. - 315
fn mcp_observation_json(observation: &vak_mcp::ServerObservation) -> serde_json::Value { - 316
let mut config = serde_json::Map::new(); - 317
if let Some(tools) = &observation.tools { - 318
config.insert( - 319
"tools".into(), - 320
tools - 321
.iter() - 322
.map(|t| { - 323
serde_json::json!({ - 324
"name": t.name, - 325
"description": t.description, - 326
"inputSchema": t.input_schema, - 327
}) - 328
}) - 329
.collect(), - 330
); - 331
} - 332
if let Some(failure) = &observation.failure { - 333
config.insert("last_failure".into(), failure.clone().into()); - 334
} - 335
if config.is_empty() { - 336
serde_json::Value::Null - 337
} else { - 338
serde_json::Value::Object(config) - 339
} - 340
} - 341
- 342
fn origin_from_provenance(provenance: Option<&str>) -> Origin { - 343
match provenance { - 344
Some(p) if p.starts_with("plugin:") => { - 345
let parts: Vec<&str> = p.split(':').collect(); - 346
Origin::Plugin { - 347
scope: parts.get(1).unwrap_or(&"workspace").to_string(), - 348
plugin: parts.get(2).unwrap_or(&"unknown").to_string(), - 349
} - 350
} - 351
Some("user") => Origin::Shared, - 352
Some("project") => Origin::Workspace, - 353
_ => Origin::Workspace, - 354
} - 355
} - 356
- 357
#[cfg(test)] - 358
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - 359
mod tests { - 360
use super::*; - 361
use std::collections::BTreeSet; - 362
use vak_intent::Act; - 363
- 364
/// The act → domain table lives in the kernel (`vak_intent::engage`); - 365
/// this crate only parses the names. A second copy of the table here - 366
/// drifted once already. - 367
fn required_for(act: Act) -> BTreeSet<Domain> { - 368
let reading = vak_intent::Reading { - 369
act, - 370
confidence: 0.9, - 371
..vak_intent::Reading::general() - 372
}; - 373
let engagement = vak_intent::derive(&reading, &vak_intent::Authority::default(), true); - 374
engagement - 375
.limits - 376
.required_domains - 377
.iter() - 378
.map(|name| Domain::parse(name)) - 379
.collect() - 380
} - 381
- 382
#[test] - 383
fn every_act_that_produces_a_fact_can_reach_a_live_source() { - 384
for act in [Act::Answer, Act::Locate, Act::Analyze, Act::Operate] { - 385
assert!( - 386
required_for(act).contains(&Domain::LiveData), - 387
"{act:?} must be able to reach a live source" - 388
); - 389
} - 390
} - 391
- 392
#[test] - 393
fn a_greeting_stays_narrow() { - 394
let required = required_for(Act::Converse); - 395
assert!(!required.contains(&Domain::CodeExec)); - 396
assert!(!required.contains(&Domain::LiveData)); - 397
} - 398
- 399
#[test] - 400
fn the_mcp_broker_serves_live_data_so_a_search_server_is_reachable() { - 401
// This is the specific link that made "how is the weather" work: - 402
// `mcp` must survive a slice that requires live data. - 403
let required = required_for(Act::Answer); - 404
assert!(Serves::from_labels(vak_mcp::McpTool::SERVES).serves_any(&required)); - 405
} - 406
- 407
#[test] - 408
fn a_tool_that_declares_nothing_is_undeclared_and_never_sliced_away() { - 409
assert!(Serves::from_labels(&[]).is_undeclared()); - 410
let required = BTreeSet::from([Domain::Vcs]); - 411
assert!(Serves::from_labels(&[]).serves_any(&required)); - 412
} - 413
} - 414
- 415
#[cfg(test)] - 416
mod retrieval_tests { - 417
use super::call_retrieves_external; - 418
use serde_json::json; - 419
- 420
fn serves(server: &str) -> Vec<String> { - 421
match server { - 422
"docs-only" => vec!["documents".into()], - 423
"search" => vec!["web".into()], - 424
"local-fs" => vec!["filesystem".into()], - 425
_ => Vec::new(), // declares nothing, like a stock Tavily config - 426
} - 427
} - 428
- 429
/// Built-ins answer from their own `Tool::serves`, as the turn does. - 430
fn tool_serves(name: &str) -> Vec<String> { - 431
let mut tools = vak_tools::default_tools(); - 432
tools.push(std::sync::Arc::new(vak_tools::WebFetchTool)); - 433
tools.push(std::sync::Arc::new(vak_tools::WebBrowseTool)); - 434
tools - 435
.iter() - 436
.find(|tool| tool.name() == name) - 437
.map(|tool| tool.serves().iter().map(|d| d.to_string()).collect()) - 438
.unwrap_or_default() - 439
} - 440
- 441
fn retrieves(name: &str, input: serde_json::Value, server: Option<&str>) -> bool { - 442
call_retrieves_external(name, &input, server, &tool_serves, &serves) - 443
} - 444
- 445
#[test] - 446
fn built_ins_that_reach_the_web_do_and_others_do_not() { - 447
for tool in ["webfetch", "browse"] { - 448
assert!(retrieves(tool, json!({}), None), "{tool}"); - 449
} - 450
// Names the old keyword rule got wrong in either direction. - 451
for tool in [ - 452
"read", - 453
"grep", - 454
"glob", - 455
"bash", - 456
"session_search", - 457
"entity_query", - 458
"emit_research_card", - 459
"some_future_tool", - 460
] { - 461
assert!(!retrieves(tool, json!({}), None), "{tool} is not retrieval"); - 462
} - 463
} - 464
- 465
#[test] - 466
fn an_mcp_call_is_retrieval_unless_its_server_declares_otherwise() { - 467
let call = |server: &str| json!({"action": "call", "server": server, "tool": "anything"}); - 468
assert!( - 469
retrieves("mcp", call("tavily"), None), - 470
"a server declaring nothing inherits the broker's web/live-data claim" - 471
); - 472
assert!(retrieves("mcp", call("search"), None)); - 473
assert!( - 474
!retrieves("mcp", call("docs-only"), None), - 475
"declaring documents opts out" - 476
); - 477
assert!(!retrieves("mcp", call("local-fs"), None)); - 478
} - 479
- 480
#[test] - 481
fn listing_mcp_tools_is_not_retrieval() { - 482
assert!(!retrieves("mcp", json!({"action": "list"}), None)); - 483
} - 484
- 485
#[test] - 486
fn a_bare_mcp_tool_name_resolves_through_its_server() { - 487
assert!(retrieves("tavily_search", json!({}), Some("tavily"))); - 488
assert!(!retrieves("notes_lookup", json!({}), Some("docs-only"))); - 489
} - 490
} - 491
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.