- 3068
pub fn sessions_home(&self) -> PathBuf { - 3069
let base = Self::read_override(&self.inner.sessions_home_override) - 3070
.unwrap_or_else(|| self.inner.sessions_home.clone()); - 3071
if let Some(agent) = self.agent_identity.as_ref() { - 3072
let home = vak_config::paths::agent_home_at(&base, &agent.id); - 3073
let _ = std::fs::create_dir_all(&home); - 3074
return home; - 3075
} - 3076
base - 3077
} - 3078
- 3079
/// Root shared data home across all agents (gateway allowlist, scheduler tasks, FinOps ledger). - 3080
pub fn shared_data_home(&self) -> PathBuf { - 3081
Self::read_override(&self.inner.sessions_home_override) - 3082
.unwrap_or_else(|| self.inner.sessions_home.clone()) - 3083
} - 3084
- 3085
/// Rebuildable-artifact directory (SQLite FTS index + WAL sidecars). - 3086
/// Canonical layout (doc 32): Library/Caches on macOS, XDG cache on - 3087
/// Linux — deleting it must always be safe. - 3088
pub fn cache_home(&self) -> PathBuf { - 3089
// Overridden homes are self-contained sandboxes, so the cache - 3090
// lives inside them. Resolved from the cloned override rather - 3091
// than by calling `sessions_home()` under the guard, which - 3092
// re-locked the same non-reentrant mutex and hung the thread. - 3093
match Self::read_override(&self.inner.sessions_home_override) { - 3094
Some(home) => home.join("cache"), - 3095
None => vak_config::paths::cache_home(), - 3096
} - 3097
} - 3098
- 3099
pub fn system_prompt(&self) -> String { - 3100
self.system_prompt_for_capabilities(&self.capability_descriptors()) - 3101
} - 3102
- 3103
fn system_prompt_for_capabilities(&self, capabilities: &[CapabilityDescriptor]) -> String { - 3104
self.resolve_prompt(capabilities).text - 3105
} - 3106
- 3107
/// The full composition, with the per-layer descriptors the ledger and - 3108
/// the editing surfaces need (docs/design/45-prompt-layers.md). - 3109
pub fn resolve_prompt(&self, capabilities: &[CapabilityDescriptor]) -> prompts::Resolution { - 3110
self.resolve_prompt_with_stance(capabilities, None) - 3111
} - 3112
- 3113
/// Compose the prompt, optionally informing the runtime of the epistemic cognitive stance. - 3114
pub fn resolve_prompt_with_stance( - 3115
&self, - 3116
capabilities: &[CapabilityDescriptor], - 3117
stance: Option<vak_intent::EpistemicStance>, - 3118
) -> prompts::Resolution { - 3119
self.resolve_prompt_with_stance_parts(capabilities, stance, "") - 3120
.0 - 3121
} - 3122
- 3123
/// The prompt's catalogue of admitted tools a turn may defer: every one - 3124
/// in `admitted` that is not always loaded. Read from the tools' own - 3125
/// declarations, so it matches what `build_tool_surface` can defer. - 3126
fn tool_catalogue_for(&self, admitted: &std::collections::BTreeSet<String>) -> String { - 3127
let tools: Vec<Arc<dyn vak_tools::Tool>> = self - 3128
.scoped_tools(&ToolScope::default()) - 3129
.into_iter() - 3130
.filter(|tool| admitted.contains(tool.name()) && !tool.always_loaded()) - 3131
.collect(); - 3132
let mut entries: Vec<(&str, &str)> = tools - 3133
.iter() - 3134
.map(|tool| (tool.name(), tool.description())) - 3135
.collect(); - 3136
if admitted.contains("task") { - 3137
entries.push(("task", vak_agent::TaskTool::DESCRIPTION)); - 3138
} - 3139
capability::tool_catalogue(entries) - 3140
} - 3141
- 3142
/// Same composition as [`Core::resolve_prompt_with_stance`], additionally - 3143
/// returning the raw temporal and epistemic-stance text that fed - 3144
/// `Resolution::tail` — the per-turn place that assembles - 3145
/// `AgentConfig::tail` needs both pieces under their own tag rather than - 3146
/// the single concatenated blob, and re-deriving them from a second call - 3147
/// would both duplicate the formatting and risk a different clock - 3148
/// instant (docs/design/68-context-engine.md §6). - 3149
fn resolve_prompt_with_stance_parts( - 3150
&self, - 3151
capabilities: &[CapabilityDescriptor], - 3152
stance: Option<vak_intent::EpistemicStance>, - 3153
tool_catalogue: &str, - 3154
) -> (prompts::Resolution, String, String) { - 3155
let server_caps = capabilities - 3156
.iter() - 3157
.filter(|capability| capability.kind == CapabilityKind::McpServer) - 3158
.collect::<Vec<_>>(); - 3159
// No discovery is triggered here, and none is waited for. - 3160
// - 3161
// Admission owns that decision now — `Core::admitted_capabilities` - 3162
// is the single place any surface waits for the registry, so the - 3163
// packet handed to this function is already as resolved as it is - 3164
// going to get. Rendering is pure: same packet in, same prompt out. - 3165
let seed = prompts::seed(APP_VERSION); - 3166
// Advertise only what the composed policy will actually run. A - 3167
// server listed here that dispatch refuses is the exact mismatch - 3168
// this reconciliation exists to remove, so blocked servers move out - 3169
// of the "use these" catalogue and into the standing section, which - 3170
// says why and how to fix it. - 3171
let standings = self.capability_standings(); - 3172
let blocked_servers = reach::blocked_mcp_servers(&standings); - 3173
let server_caps = server_caps - 3174
.into_iter() - 3175
.filter(|capability| !blocked_servers.contains(&capability.name)) - 3176
.collect::<Vec<_>>(); - 3177
let mut standing = reach::prompt_section(&standings); - 3178
let extra_diags: Vec<_> = self - 3179
.capability_diagnostics() - 3180
.into_iter() - 3181
.filter(|d| d.source.is_some()) - 3182
.collect(); - 3183
if !extra_diags.is_empty() { - 3184
if standing.is_empty() { - 3185
standing = String::from( - 3186
"\nConfigured but NOT usable on this turn. These are not in your tool \ - 3187
schemas and calling them will fail. If the request needs one, say so \ - 3188
plainly, name the capability, and give the operator the fix — do not \ - 3189
substitute a different tool and do not answer as though you had the \ - 3190
data:\n", - 3191
); - 3192
} - 3193
for diag in extra_diags { - 3194
standing.push_str(&format!( - 3195
"- {} `{}`: {}.", - 3196
diag.kind, diag.name, diag.reason - 3197
)); - 3198
if !diag.remedy.is_empty() { - 3199
standing.push_str(&format!(" Fix: {}.", diag.remedy)); - 3200
} - 3201
standing.push('\n'); - 3202
} - 3203
} - 3204
let has_bash = capabilities - 3205
.iter() - 3206
.any(|c| c.kind == CapabilityKind::Tool && c.name == "bash"); - 3207
let has_cards = capabilities - 3208
.iter() - 3209
.any(|c| c.kind == CapabilityKind::Tool && presentation_tools::is_card_tool(&c.name)); - 3210
let epistemic_stance = match stance { - 3211
Some(s) => format!( - 3212
"\nEpistemic stance: {}\n- {}", - 3213
s.as_str(), - 3214
s.guideline_prompt() - 3215
), - 3216
None => String::new(), - 3217
}; - 3218
// Each code-owned contract appears only where it is true: cards where - 3219
// card tools are admitted, the sandbox where `bash` is. - 3220
let runtime = prompts::RuntimeSections { - 3221
capability_contract: seed.capability_contract, - 3222
presentation_contract: if has_cards { - 3223
seed.presentation_contract - 3224
} else { - 3225
String::new() - 3226
}, - 3227
sandbox_contract: if has_bash { - 3228
seed.sandbox_contract - 3229
} else { - 3230
String::new() - 3231
}, - 3232
surface: self.surface.prompt_section(), - 3233
skills: skills::prompt_section_from_capabilities(capabilities), - 3234
mcp: mcp_config_section(&server_caps), - 3235
standing, - 3236
epistemic_stance: epistemic_stance.clone(), - 3237
tool_index: tool_catalogue.to_string(), - 3238
temporal: format!( - 3239
"\nTemporal context: current UTC instant {}; local date/time {} (system timezone {}). Treat relative dates as ambiguous unless the user's timezone is known.", - 3240
chrono::Utc::now().to_rfc3339(), - 3241
chrono::Local::now().to_rfc3339(), - 3242
chrono::Local::now().offset() - 3243
), - 3244
}; - 3245
let resolution = prompts::resolve(&self.prompt_layers(seed.content), &runtime); - 3246
let temporal = runtime.temporal; - 3247
(resolution, temporal, epistemic_stance) - 3248
} - 3249
- 3250
/// Whether a session's frozen prompt still matches what this workspace - 3251
/// would resolve today (docs/design/45-prompt-layers.md). - 3252
/// - 3253
/// `None` means "no drift, or no baseline to compare against" — a ledger - 3254
/// written before prompt layers existed carries no descriptors and must - 3255
/// not be reported as having changed. - 3256
pub fn prompt_drift( - 3257
&self, - 3258
contract: &vak_session::FrozenContract, - 3259
) -> Option<prompts::PromptDrift> { - 3260
let current = self.resolve_prompt(&self.capability_descriptors()); - 3261
prompts::drift(&contract.prompt_layers, ¤t.descriptors) - 3262
} - 3263
- 3264
/// Every contributing layer, broadest first. Public so the editing - 3265
/// surfaces can render provenance without re-deriving the chain. - 3266
pub fn prompt_layers(&self, seed: prompts::LayerContent) -> Vec<prompts::LayerInput> { - 3267
let mut layers = vec![prompts::LayerInput::new( - 3268
prompts::PromptLayer::Seed, - 3269
Some("shipped".into()), - 3270
seed, - 3271
)]; - 3272
- 3273
let shared_dir = prompts::layer_dir(&vak_config::paths::default_workspace()); - 3274
let shared = prompts::read_layer(&shared_dir); - 3275
if !shared.is_empty() { - 3276
layers.push(prompts::LayerInput::new( - 3277
prompts::PromptLayer::Shared, - 3278
Some(shared_dir.display().to_string()), - 3279
shared, - 3280
)); - 3281
} - 3282
- 3283
let project_dir = prompts::layer_dir(&self.inner.cwd); - 3284
let mut project = prompts::read_layer(&project_dir); - 3285
// Legacy whole-prompt override. Read as this layer's identity and - 3286
// rules rather than as the entire document, so it can no longer - 3287
// delete the capability contract or the guardrails. - 3288
let legacy = self.inner.cwd.join(".vak/SYSTEM.md"); - 3289
if project.is_empty() - 3290
&& legacy.is_file() - 3291
&& let Ok(text) = std::fs::read_to_string(&legacy) - 3292
&& !text.trim().is_empty() - 3293
{ - 3294
project.identity = Some(text.trim().to_string()); - 3295
} - 3296
// Memory never writes a prompt layer. A note — however it was - 3297
// classified, and whoever wrote it — is recalled through - 3298
// `session_search`, never promoted into guardrails: the model's own - 3299
// `remember` and background consolidation both write notes, so - 3300
// anything else would let an inbound message author a permanent - 3301
// instruction (invariant 28) and grow the cached prefix without bound. - 3302
if !project.is_empty() { - 3303
// The fix for the hole this design opened with: a project layer - 3304
// is untrusted config until the user says otherwise, exactly - 3305
// like `hooks`, `allow`, and `mcp.servers` in - 3306
// `vak_config::load_with_trust`. Its guardrails survive because - 3307
// a guardrail can only ever narrow behaviour. - 3308
if !self.inner.trust_project_config { - 3309
project.demote_untrusted(); - 3310
} - 3311
if !project.is_empty() { - 3312
layers.push(prompts::LayerInput::new( - 3313
prompts::PromptLayer::Workspace, - 3314
Some(project_dir.display().to_string()), - 3315
project, - 3316
)); - 3317
} - 3318
} - 3319
- 3320
for (kind, name, layer) in [ - 3321
( - 3322
"surface", - 3323
self.surface.slug().to_string(), - 3324
prompts::PromptLayer::Surface, - 3325
), - 3326
( - 3327
"agents", - 3328
self.prompt_role.clone().unwrap_or_default(), - 3329
prompts::PromptLayer::Agent, - 3330
), - 3331
] { - 3332
if name.is_empty() { - 3333
continue; - 3334
} - 3335
let mut found_on_disk = false; - 3336
for root in [&vak_config::paths::default_workspace(), &self.inner.cwd] { - 3337
let Some(dir) = prompts::sub_layer_dir(root, kind, &name) else { - 3338
continue; - 3339
}; - 3340
let mut content = prompts::read_layer(&dir); - 3341
if content.is_empty() { - 3342
continue; - 3343
} - 3344
if root == &self.inner.cwd && !self.inner.trust_project_config { - 3345
content.demote_untrusted(); - 3346
if content.is_empty() { - 3347
continue; - 3348
} - 3349
} - 3350
found_on_disk = true; - 3351
layers.push(prompts::LayerInput::new( - 3352
layer, - 3353
Some(dir.display().to_string()), - 3354
content, - 3355
)); - 3356
} - 3357
if !found_on_disk && kind == "agents" { - 3358
let builtin_text = match name.as_str() { - 3359
"analyst" => Some( - 3360
"You are the Data Analyst specialist. Compute figures with your tools rather than estimating them, show the data behind every number, state assumptions and uncertainty, and present results as tables or charts where they read best.", - 3361
), - 3362
"operator" => Some( - 3363
"You are the Operations specialist. Inspect the current state before changing it, act in small reversible steps, confirm each effect before the next, and report exactly what changed and what did not.", - 3364
), - 3365
"researcher" => Some( - 3366
"You are the Research Analyst specialist. Focus on empirical verification, numbered citations [1], [2] linked to sources, counter-evidence, and epistemic uncertainty.", - 3367
), - 3368
"writer" => Some( - 3369
"You are the Communications & Writing specialist. Focus on rhetorical clarity, tone adaptation, structural hierarchy, and compelling audience communication.", - 3370
), - 3371
_ => None, - 3372
}; - 3373
if let Some(text) = builtin_text { - 3374
let content = prompts::LayerContent { - 3375
instructions: Some(text.to_string()), - 3376
..Default::default() - 3377
}; - 3378
layers.push(prompts::LayerInput::new( - 3379
prompts::PromptLayer::Agent, - 3380
Some(format!("builtin-role:{name}")), - 3381
content, - 3382
)); - 3383
} - 3384
} - 3385
} - 3386
- 3387
// Gateway and role tiers handed in by the caller that knows them: - 3388
// operator state, not files on this machine's disk. - 3389
layers.extend(self.prompt_overlays.iter().cloned()); - 3390
if let Some(agent) = &self.agent_identity - 3391
&& agent.id != "vak" - 3392
{ - 3393
let agent_home = self.sessions_home(); - 3394
let agent_prompts_dir = prompts::layer_dir(&agent_home); - 3395
let mut agent_layer = prompts::read_layer(&agent_prompts_dir); - 3396
if agent_layer.identity.is_none() { - 3397
agent_layer.identity = Some(format!( - 3398
"You are {}. {}\nWorking style: {}\nUseful for: {}\nThis identity does not grant tools, permissions, credentials or budget.", - 3399
agent.name, agent.personality, agent.behaviour, agent.responsibilities - 3400
)); - 3401
} - 3402
if agent_layer.instructions.is_none() && !agent.instructions.trim().is_empty() { - 3403
agent_layer.instructions = Some(agent.instructions.clone()); - 3404
} - 3405
layers.push(prompts::LayerInput::new( - 3406
prompts::PromptLayer::Agent, - 3407
Some(format!("agent:{}@{}", agent.id, agent.revision)), - 3408
agent_layer, - 3409
)); - 3410
} - 3411
layers - 3412
} - 3413
- 3414
/// Roles defined for this workspace, shared layer first so a project can - 3415
/// shadow a shared role by name — the same name-keyed shadowing MCP - 3416
/// servers already use. - 3417
pub fn prompt_role_names(&self) -> Vec<String> { - 3418
let mut names: Vec<String> = Vec::new(); - 3419
for root in [&vak_config::paths::default_workspace(), &self.inner.cwd] { - 3420
let dir = prompts::layer_dir(root).join("agents"); - 3421
let Ok(entries) = std::fs::read_dir(&dir) else { - 3422
continue; - 3423
}; - 3424
for entry in entries.flatten() { - 3425
if !entry.path().is_dir() { - 3426
continue; - 3427
} - 3428
let Some(name) = entry.file_name().to_str().map(str::to_string) else { - 3429
continue; - 3430
}; - 3431
if prompts::sub_layer_dir(root, "agents", &name).is_some() - 3432
&& !names.contains(&name) - 3433
&& !prompts::read_layer(&entry.path()).is_empty() - 3434
{ - 3435
names.push(name); - 3436
} - 3437
} - 3438
} - 3439
for builtin in ["analyst", "operator", "researcher", "writer"] { - 3440
if !names.contains(&builtin.to_string()) { - 3441
names.push(builtin.to_string()); - 3442
} - 3443
} - 3444
names.sort(); - 3445
names - 3446
} - 3447
- 3448
/// Fully resolved prompt per role, admitted up front so a child can only - 3449
/// run under a role that existed when this session was admitted. - 3450
pub fn role_prompts( - 3451
&self, - 3452
capabilities: &[CapabilityDescriptor], - 3453
) -> std::collections::BTreeMap<String, String> { - 3454
self.prompt_role_names() - 3455
.into_iter() - 3456
.map(|name| { - 3457
let prompt = self - 3458
.clone() - 3459
.with_prompt_role(Some(name.clone())) - 3460
.system_prompt_for_capabilities(capabilities); - 3461
(name, prompt) - 3462
}) - 3463
.collect() - 3464
} - 3465
- 3466
pub fn skills(&self) -> Vec<skills::Skill> { - 3467
let shared_root = self.shared_capability_root(); - 3468
let plugin_roots = self.enabled_plugin_skill_roots(); - 3469
let mut skills = - 3470
skills::discover_with_plugins(&self.inner.cwd, &shared_root, &plugin_roots); - 3471
if !self.effective_capability_inheritance().inherit_skills { - 3472
let shared_skills = shared_root.join("skills"); - 3473
skills.retain(|skill| !skill.path.starts_with(&shared_skills)); - 3474
} - 3475
let Some(policy) = self.channel_policy() else { - 3476
return skills; - 3477
}; - 3478
skills - 3479
.into_iter() - 3480
.filter(|skill| { - 3481
Self::allowed_by(&policy.skills_allow, &policy.skills_deny, &skill.name) - 3482
}) - 3483
.collect() - 3484
} - 3485
- 3486
pub fn skills_with_shadowed(&self) -> Vec<skills::Skill> { - 3487
let shared_root = self.shared_capability_root(); - 3488
let plugin_roots = self.enabled_plugin_skill_roots(); - 3489
let mut skills = - 3490
skills::discover_all_with_plugins(&self.inner.cwd, &shared_root, &plugin_roots); - 3491
if !self.effective_capability_inheritance().inherit_skills { - 3492
let shared_skills = shared_root.join("skills"); - 3493
skills.retain(|skill| !skill.path.starts_with(&shared_skills)); - 3494
} - 3495
let Some(policy) = self.channel_policy() else { - 3496
return skills; - 3497
}; - 3498
skills - 3499
.into_iter() - 3500
.filter(|skill| { - 3501
Self::allowed_by(&policy.skills_allow, &policy.skills_deny, &skill.name) - 3502
}) - 3503
.collect() - 3504
} - 3505
- 3506
/// Live workers spawned by this Core's runs, for attach/steer UIs. - 3507
pub fn workers(&self) -> Arc<vak_agent::WorkerRegistry> { - 3508
self.inner.workers.clone() - 3509
} - 3510
- 3511
pub fn custom_commands(&self) -> Vec<custom_commands::CustomCommand> { - 3512
let shared_root = self.shared_capability_root(); - 3513
let plugin_roots = self.enabled_plugin_skill_roots(); - 3514
let mut commands = - 3515
custom_commands::discover_with_plugins(&self.inner.cwd, &shared_root, &plugin_roots); - 3516
if !self.effective_capability_inheritance().inherit_commands { - 3517
// `discover_with_plugins` labels the shared root's commands - 3518
// "user"; workspace ones are "project" and plugin ones carry a - 3519
// "plugin:" prefix. - 3520
commands.retain(|command| command.source != "user"); - 3521
} - 3522
commands - 3523
} - 3524
- 3525
/// Every tool this Core offers a turn, constructed once. - 3526
/// - 3527
/// The only list of built-in tools: `tool_names`, the capability - 3528
/// declarations, and the turn itself all derive from it, and each tool - 3529
/// states its own domains and loading (`Tool::serves`, - 3530
/// `Tool::always_loaded`). Three tools are bound to what the turn - 3531
/// admitted and are added by the turn instead — `skill` (the admitted - 3532
/// skills), `mcp` (the admitted servers) and `task` (the admitted tools) - 3533
/// — plus the synthetic `find_tools`. - 3534
fn scoped_tools(&self, scope: &ToolScope) -> Vec<Arc<dyn vak_tools::Tool>> { - 3535
let worker = self - 3536
.inner - 3537
.tool_worker_exe - 3538
.lock() - 3539
.ok() - 3540
.map(|worker| worker.clone()) - 3541
.unwrap_or_else(|| PathBuf::from("__vak_tool_worker_unavailable__")); - 3542
let mut tools = vak_tools::brokered_tools(worker, &self.new_documents); - 3543
tools.push(Arc::new(vak_tools::RecallTool)); - 3544
tools.push(Arc::new(tools_tasks::TasksTool { - 3545
sessions_home: self.shared_data_home(), - 3546
cwd: self.inner.cwd.clone(), - 3547
default_deliver_to: self.default_deliver_to.clone(), - 3548
})); - 3549
if self.effective_commitment() { - 3550
tools.push(Arc::new(tools_commitments::CommitmentsTool { - 3551
sessions_home: self.sessions_home(), - 3552
audience_id: scope.audience_id.clone(), - 3553
})); - 3554
} - 3555
if self.effective_memory_search_enabled() { - 3556
tools.push(Arc::new(session_search::SessionSearchTool { - 3557
sessions_home: self.sessions_home(), - 3558
trash_home: self.shared_data_home(), - 3559
cwd: self.inner.cwd.clone(), - 3560
exclude_session_id: scope.session_id.clone(), - 3561
agent_id: scope.agent_id.clone(), - 3562
audience_id: scope.audience_id.clone(), - 3563
})); - 3564
} - 3565
if self.effective_memory_write_enabled() { - 3566
tools.push(Arc::new(learning::RememberTool { - 3567
sessions_home: self.sessions_home(), - 3568
cwd: self.inner.cwd.clone(), - 3569
session_id: scope.session_id.clone(), - 3570
})); - 3571
tools.push(Arc::new(entities::EntityRecordTool { - 3572
sessions_home: self.sessions_home(), - 3573
cwd: self.inner.cwd.clone(), - 3574
})); - 3575
} - 3576
if self.effective_memory_skill_proposals() { - 3577
tools.push(Arc::new(learning::ProposeSkillTool { - 3578
sessions_home: self.sessions_home(), - 3579
cwd: self.inner.cwd.clone(), - 3580
session_id: scope.session_id.clone(), - 3581
})); - 3582
} - 3583
tools.push(Arc::new(entities::EntityQueryTool { - 3584
sessions_home: self.sessions_home(), - 3585
cwd: self.inner.cwd.clone(), - 3586
})); - 3587
tools.push(Arc::new(data_engine::DataQueryTool)); - 3588
for emit_tool in presentation_tools::EmitCardTool::all() { - 3589
tools.push(Arc::new(emit_tool)); - 3590
} - 3591
if self.effective_web_fetch() { - 3592
tools.push(Arc::new(vak_tools::WebFetchTool)); - 3593
} - 3594
if self.effective_browse() { - 3595
tools.push(Arc::new(vak_tools::WebBrowseTool)); - 3596
} - 3597
// Inter-agent messaging, only while an operator has authorized this - 3598
// workspace on the broker (docs/design/25-docker-sandbox.md). The - 3599
// broker keys workspaces by canonical path, as the server registers - 3600
// them. - 3601
let workspace = self - 3602
.inner - 3603
.cwd - 3604
.canonicalize() - 3605
.unwrap_or_else(|_| self.inner.cwd.clone()) - 3606
.display() - 3607
.to_string(); - 3608
let network = self.agent_network_broker(); - 3609
if network.is_enabled(&workspace) { - 3610
tools.push(Arc::new(agent_network::AgentNetworkTool::new( - 3611
network, workspace, - 3612
))); - 3613
} - 3614
tools.retain(|tool| self.channel_tool_allowed(tool.name())); - 3615
tools - 3616
} - 3617
- 3618
/// `(name, serves)` for every tool a turn could be offered, including the - 3619
/// three the turn binds itself. The capability declarations read this. - 3620
fn tool_declarations(&self) -> Vec<(String, &'static [&'static str])> { - 3621
let mut out: Vec<(String, &'static [&'static str])> = self - 3622
.scoped_tools(&ToolScope::default()) - 3623
.iter() - 3624
.map(|tool| (tool.name().to_string(), tool.serves())) - 3625
.collect(); - 3626
let bound = [ - 3627
(!self.skills().is_empty()).then_some(("skill", skills::SkillTool::SERVES)), - 3628
(!self.effective_mcp().servers.is_empty()).then_some(("mcp", vak_mcp::McpTool::SERVES)), - 3629
self.effective_workers() - 3630
.then_some(("task", vak_agent::TaskTool::SERVES)), - 3631
]; - 3632
for (name, serves) in bound.into_iter().flatten() { - 3633
if self.channel_tool_allowed(name) { - 3634
out.push((name.to_string(), serves)); - 3635
} - 3636
} - 3637
out - 3638
} - 3639
- 3640
pub fn tool_names(&self) -> Vec<String> { - 3641
self.tool_declarations() - 3642
.into_iter() - 3643
.map(|(name, _)| name) - 3644
.collect() - 3645
} - 3646
- 3647
/// The capability packet, derived from the same declarations the - 3648
/// registry uses. - 3649
/// - 3650
/// This used to build descriptors a second time, by hand, and the two - 3651
/// constructions drifted: the same built-in tool came out stamped - 3652
/// `provenance: "vak-core"` here and `"builtin"` through the registry, - 3653
/// so which spelling a session recorded depended on which path admitted - 3654
/// it. That is the parallel-representation defect doc 41 exists to - 3655
/// remove, reintroduced one layer down. - 3656
/// - 3657
/// There is one construction now. `CapabilityProvider::declare` is the - 3658
/// single description of what exists, and both this and the registry - 3659
/// project from it. Note this is the *unresolved* view — anything that - 3660
/// needs probing is described but not yet proven usable — which is why - 3661
/// admission goes through [`Self::admitted_capabilities`] instead and - 3662
/// this remains only the synchronous fallback. - 3663
pub fn capability_descriptors(&self) -> Vec<CapabilityDescriptor> { - 3664
use crate::capability::registry::CapabilityProvider; - 3665
let mut out: Vec<CapabilityDescriptor> = self - 3666
.declare() - 3667
.into_iter() - 3668
.map(|declaration| capability::Capability { - 3669
id: declaration.id, - 3670
origin: declaration.origin, - 3671
summary: declaration.summary, - 3672
serves: declaration.serves, - 3673
digest: declaration.digest, - 3674
source: declaration.source, - 3675
// Unprobed: `Static` describes it without claiming a probe - 3676
// succeeded. Admission is what proves the rest. - 3677
resolution: capability::Resolution::Available, - 3678
configuration: declaration.configuration, - 3679
}) - 3680
.map(|capability| capability.to_descriptor()) - 3681
.collect(); - 3682
- 3683
// Strictly subtractive: `reach` never returns a capability that - 3684
// configuration did not already grant. - 3685
let standings = self.capability_standings(); - 3686
let unreachable_tools = reach::fully_blocked_tools(&standings); - 3687
let unreachable_servers = reach::blocked_mcp_servers(&standings); - 3688
let unreachable_skills = reach::blocked_skills(&standings); - 3689
out.retain(|capability| match capability.kind { - 3690
CapabilityKind::Tool => !unreachable_tools - 3691
.iter() - 3692
.any(|name| name == &capability.name), - 3693
CapabilityKind::McpServer => !unreachable_servers - 3694
.iter() - 3695
.any(|name| name == &capability.name), - 3696
CapabilityKind::Skill => !unreachable_skills - 3697
.iter() - 3698
.any(|name| name == &capability.name), - 3699
_ => true, - 3700
}); - 3701
out.sort_by(|a, b| { - 3702
format!("{:?}:{}", a.kind, a.name).cmp(&format!("{:?}:{}", b.kind, b.name)) - 3703
}); - 3704
out - 3705
} - 3706
- 3707
/// Every capability that was configured/discovered but excluded from - 3708
/// [`capability_descriptors`] — skill parse failures, reach-blocked - 3709
/// tools, channel-policy-filtered capabilities. The observability - 3710
/// counterpart: `capability_descriptors` says what a turn CAN do; - 3711
/// this says what it configured but CANNOT do, and why. - 3712
pub fn capability_diagnostics(&self) -> Vec<CapabilityDiagnostic> { - 3713
let mut out = Vec::new(); - 3714
- 3715
// 1. Skill parse failures. - 3716
let shared_root = self.shared_capability_root(); - 3717
let plugin_roots = self.enabled_plugin_skill_roots(); - 3718
let (_skills, skill_diags) = - 3719
skills::discover_with_diagnostics(&self.inner.cwd, &shared_root, &plugin_roots); - 3720
for diag in skill_diags { - 3721
out.push(CapabilityDiagnostic { - 3722
kind: "skill".into(), - 3723
name: diag - 3724
.path - 3725
.parent() - 3726
.and_then(|p| p.file_name()) - 3727
.map(|n| n.to_string_lossy().into_owned()) - 3728
.unwrap_or_else(|| diag.path.display().to_string()), - 3729
reason: diag.reason, - 3730
source: Some(diag.path.display().to_string()), - 3731
remedy: "fix the SKILL.md frontmatter (name must be lowercase kebab-case, \ - 3732
description must be present and non-empty)" - 3733
.into(), - 3734
// A skill that will not parse is broken, not chosen. - 3735
deliberate: false, - 3736
}); - 3737
} - 3738
- 3739
// 2. Reach-blocked capabilities (MCP servers, network tools). - 3740
let standings = self.capability_standings(); - 3741
for standing in &standings { - 3742
if standing.reach.is_blocked() { - 3743
out.push(CapabilityDiagnostic { - 3744
kind: if standing.tool == "mcp" { - 3745
"mcp-server".into() - 3746
} else if standing.tool == "skill" { - 3747
"skill".into() - 3748
} else { - 3749
"tool".into() - 3750
}, - 3751
name: standing.label.clone(), - 3752
reason: standing.reason.clone(), - 3753
source: None, - 3754
remedy: standing.remedy.clone(), - 3755
// Reach follows from permission mode, approval posture - 3756
// and channel policy — all chosen. The dedicated - 3757
// `capability reach` check already reports these, so - 3758
// this also stops `capability health` double-reporting. - 3759
deliberate: true, - 3760
}); - 3761
} - 3762
} - 3763
- 3764
// 3. Skills filtered by channel policy. - 3765
if let Some(policy) = self.channel_policy() { - 3766
let all_skills = - 3767
skills::discover_with_plugins(&self.inner.cwd, &shared_root, &plugin_roots); - 3768
for skill in &all_skills { - 3769
if !Self::allowed_by(&policy.skills_allow, &policy.skills_deny, &skill.name) { - 3770
out.push(CapabilityDiagnostic { - 3771
kind: "skill".into(), - 3772
name: skill.name.clone(), - 3773
reason: "blocked by channel policy (skills_deny or not in skills_allow)" - 3774
.into(), - 3775
source: Some(skill.path.display().to_string()), - 3776
remedy: "adjust the channel's skills_allow/skills_deny in the \ - 3777
gateway allowlist" - 3778
.into(), - 3779
// The channel allowlist is a policy the operator set. - 3780
deliberate: true, - 3781
}); - 3782
} - 3783
} - 3784
} - 3785
- 3786
// Enabled hooks whose definition cannot be read. A fail-closed one - 3787
// refuses what it guards rather than disappearing (capability::turn), - 3788
// so this is how an operator learns why tools are being refused. - 3789
for hook in self - 3790
.effective_hooks() - 3791
.into_iter() - 3792
.filter(|hook| hook.enabled) - 3793
{ - 3794
if let Err(reason) = hook_def(&hook) { - 3795
out.push(CapabilityDiagnostic { - 3796
kind: "hook".into(), - 3797
name: format!("{}/{}", hook.event, hook.command), - 3798
reason, - 3799
source: None, - 3800
remedy: "fix the hook's event, match rule, or failure_mode".into(), - 3801
deliberate: false, - 3802
}); - 3803
} - 3804
} - 3805
- 3806
// 4. Disabled hooks (present in config but enabled=false). - 3807
for hook in self - 3808
.effective_hooks() - 3809
.into_iter() - 3810
.filter(|hook| !hook.enabled) - 3811
{ - 3812
out.push(CapabilityDiagnostic { - 3813
kind: "hook".into(), - 3814
name: format!("{}/{}", hook.event, hook.command), - 3815
reason: "hook is disabled (enabled = false)".into(), - 3816
source: None, - 3817
remedy: "set enabled = true in .vak/config.toml or the admin console".into(), - 3818
// `enabled = false` is the operator saying so. - 3819
deliberate: true, - 3820
}); - 3821
} - 3822
- 3823
// 5. MCP servers whose last on-demand attempt failed, as the pool - 3824
// observed it. Operator-facing only (`source: None`): the server is - 3825
// still callable — the next demand retries after the pool's backoff — - 3826
// so it must not join the prompt's "NOT usable" list; the model sees - 3827
// the failure on the server's own line in the MCP section instead. - 3828
for capability in self.capability_registry().current_blocking().all() { - 3829
if let Some(reason) = capability::report::mcp_failure(capability) { - 3830
out.push(CapabilityDiagnostic { - 3831
kind: "mcp-server".into(), - 3832
name: capability.id.name.clone(), - 3833
reason: reason.to_string(), - 3834
source: None, - 3835
remedy: capability::report::mcp_remedy(&capability.id.name), - 3836
// A server that will not answer is broken, not chosen. - 3837
deliberate: false, - 3838
}); - 3839
} - 3840
} - 3841
- 3842
out - 3843
} - 3844
- 3845
fn provider_auth(&self) -> Result<ProviderAuth, CoreError> { - 3846
self.provider_auth_for(&self.effective_provider()) - 3847
} - 3848
- 3849
/// Resolve credentials for an arbitrary provider, not just the active - 3850
/// one — model discovery needs to authenticate against whichever - 3851
/// provider the user is inspecting. - 3852
fn provider_auth_for(&self, provider: &str) -> Result<ProviderAuth, CoreError> { - 3853
let provider = provider.to_string(); - 3854
let required_key = |env: &str, provider: &str| { - 3855
let primary = self.provider_secret(env).or_else(|| { - 3856
Self::provider_pool_env_var(provider).and_then(|pool_env| { - 3857
self.provider_secret(pool_env).and_then(|value| { - 3858
value - 3859
.split([',', '\n']) - 3860
.map(str::trim) - 3861
.find(|key| !key.is_empty()) - 3862
.map(str::to_string) - 3863
}) - 3864
}) - 3865
}); - 3866
primary - 3867
.filter(|key| !key.trim().is_empty()) - 3868
.map(|key| key.trim().to_string()) - 3869
.ok_or_else(|| CoreError::MissingAuth { - 3870
env: env.into(), - 3871
provider: provider.into(), - 3872
}) - 3873
}; - 3874
match provider.as_str() { - 3875
"anthropic" => { - 3876
let api_key = required_key("ANTHROPIC_API_KEY", "anthropic")?; - 3877
let base_url = self - 3878
.inner - 3879
.config - 3880
.anthropic_base_url - 3881
.clone() - 3882
.or_else(|| vak_config::get_var("VAK_ANTHROPIC_BASE_URL")); - 3883
Ok(ProviderAuth { - 3884
credential_id: Some(vak_llm::credential_id( - 3885
base_url - 3886
.as_deref() - 3887
.unwrap_or(vak_llm::anthropic::DEFAULT_BASE_URL), - 3888
&api_key, - 3889
)), - 3890
api_key, - 3891
base_url, - 3892
..Default::default() - 3893
}) - 3894
} - 3895
"google" => { - 3896
let api_key = self - 3897
.provider_secret("GEMINI_API_KEY") - 3898
.or_else(|| self.provider_secret("GOOGLE_API_KEY")) - 3899
.or_else(|| { - 3900
self.provider_secret("GEMINI_API_KEYS").and_then(|value| { - 3901
value - 3902
.split([',', '\n']) - 3903
.map(str::trim) - 3904
.find(|key| !key.is_empty()) - 3905
.map(str::to_string) - 3906
}) - 3907
}) - 3908
.filter(|key| !key.trim().is_empty()) - 3909
.map(|key| key.trim().to_string()) - 3910
.ok_or_else(|| CoreError::MissingAuth { - 3911
env: "GEMINI_API_KEY".into(), - 3912
provider: provider.clone(), - 3913
})?; - 3914
Ok(ProviderAuth { - 3915
credential_id: Some(vak_llm::credential_id( - 3916
&vak_config::get_var("VAK_GOOGLE_BASE_URL").unwrap_or_else(|| { - 3917
"https://generativelanguage.googleapis.com/v1beta".into() - 3918
}), - 3919
&api_key, - 3920
)), - 3921
api_key, - 3922
base_url: vak_config::get_var("VAK_GOOGLE_BASE_URL").or_else(|| { - 3923
Some("https://generativelanguage.googleapis.com/v1beta".into()) - 3924
}), - 3925
..Default::default() - 3926
}) - 3927
} - 3928
"openai-responses" => { - 3929
let api_key = required_key("OPENAI_API_KEY", "openai-responses")?; - 3930
Ok(ProviderAuth { - 3931
credential_id: Some(vak_llm::credential_id( - 3932
&vak_config::get_var("VAK_OPENAI_BASE_URL") - 3933
.unwrap_or_else(|| "https://api.openai.com/v1".into()), - 3934
&api_key, - 3935
)), - 3936
api_key, - 3937
base_url: vak_config::get_var("VAK_OPENAI_BASE_URL") - 3938
.or_else(|| Some("https://api.openai.com/v1".into())), - 3939
..Default::default() - 3940
}) - 3941
} - 3942
// get_var (not raw env) so user-level and project secret - 3943
// scopes authenticate these providers exactly like every other one. - 3944
"openai" | "openrouter" | "openrouter-responses" => { - 3945
let (env, default_base, override_env) = if provider == "openai" { - 3946
( - 3947
"OPENAI_API_KEY", - 3948
"https://api.openai.com/v1", - 3949
"VAK_OPENAI_BASE_URL", - 3950
) - 3951
} else { - 3952
( - 3953
"OPENROUTER_API_KEY", - 3954
"https://openrouter.ai/api/v1", - 3955
"VAK_OPENROUTER_BASE_URL", - 3956
) - 3957
}; - 3958
let api_key = required_key(env, &provider)?; - 3959
Ok(ProviderAuth { - 3960
credential_id: Some(vak_llm::credential_id( - 3961
&vak_config::get_var(override_env).unwrap_or_else(|| default_base.into()), - 3962
&api_key, - 3963
)), - 3964
api_key, - 3965
base_url: vak_config::get_var(override_env) - 3966
.or_else(|| Some(default_base.into())), - 3967
..Default::default() - 3968
}) - 3969
} - 3970
"opencode-zen" => { - 3971
let api_key = required_key("OPENCODE_API_KEY", "opencode-zen")?; - 3972
Ok(ProviderAuth { - 3973
credential_id: Some(vak_llm::credential_id( - 3974
&vak_config::get_var("VAK_OPENCODE_ZEN_BASE_URL") - 3975
.unwrap_or_else(|| "https://opencode.ai/zen/v1".into()), - 3976
&api_key, - 3977
)), - 3978
api_key, - 3979
base_url: vak_config::get_var("VAK_OPENCODE_ZEN_BASE_URL") - 3980
.or_else(|| Some("https://opencode.ai/zen/v1".into())), - 3981
..Default::default() - 3982
}) - 3983
} - 3984
"ollama" => { - 3985
// Threaded through generically (registry.rs::ProviderAuth::options) - 3986
// rather than a provider-specific auth variant, per invariant - 3987
// 17 (one configuration contract) and docs/design/68 §8. - 3988
let mut options = std::collections::BTreeMap::new(); - 3989
options.insert( - 3990
"keep_alive".to_string(), - 3991
self.inner.config.ollama.keep_alive.clone(), - 3992
); - 3993
if let Some(num_ctx) = self.inner.config.ollama.num_ctx { - 3994
options.insert("num_ctx".to_string(), num_ctx.to_string()); - 3995
} - 3996
Ok(ProviderAuth { - 3997
api_key: "ollama".into(), - 3998
base_url: vak_config::get_var("VAK_OLLAMA_BASE_URL") - 3999
.or_else(|| Some("http://localhost:11434/v1".into())), - 4000
credential_id: Some(vak_llm::credential_id( - 4001
&vak_config::get_var("VAK_OLLAMA_BASE_URL") - 4002
.unwrap_or_else(|| "http://localhost:11434/v1".into()), - 4003
"ollama", - 4004
)), - 4005
options, - 4006
}) - 4007
} - 4008
"bedrock" => { - 4009
let api_key = required_key("AWS_BEARER_TOKEN_BEDROCK", "bedrock")?; - 4010
let base_url = vak_config::get_var("VAK_BEDROCK_BASE_URL") - 4011
.or_else(|| Some("https://bedrock-mantle.us-east-1.api.aws/v1".into())); - 4012
Ok(ProviderAuth { - 4013
credential_id: Some(vak_llm::credential_id( - 4014
base_url.as_deref().unwrap_or_default(), - 4015
&api_key, - 4016
)), - 4017
api_key, - 4018
base_url, - 4019
..Default::default() - 4020
}) - 4021
} - 4022
other => Err(CoreError::MissingAuth { - 4023
env: format!("(no auth wiring for '{other}' yet)"), - 4024
provider: other.into(), - 4025
}), - 4026
} - 4027
} - 4028
- 4029
pub fn provider_pool_env_var(provider: &str) -> Option<&'static str> { - 4030
match provider { - 4031
"anthropic" => Some("ANTHROPIC_API_KEYS"), - 4032
"google" => Some("GEMINI_API_KEYS"), - 4033
"openai" | "openai-responses" => Some("OPENAI_API_KEYS"), - 4034
"openrouter" | "openrouter-responses" => Some("OPENROUTER_API_KEYS"), - 4035
"opencode-zen" => Some("OPENCODE_API_KEYS"), - 4036
"ollama" => None, - 4037
"bedrock" => Some("AWS_BEARER_TOKEN_BEDROCK"), - 4038
_ => None, - 4039
} - 4040
} - 4041
- 4042
/// Resolve all credentials configured for one provider. The singular - 4043
/// provider variable remains the primary; the plural companion is an - 4044
/// operator-managed secret value separated by commas or newlines. - 4045
/// Returned identities are stable fingerprints, never the credentials. - 4046
fn provider_auth_pool_for(&self, provider: &str) -> Result<Vec<ProviderAuth>, CoreError> { - 4047
let primary = self.provider_auth_for(provider)?; - 4048
let Some(pool_env) = Self::provider_pool_env_var(provider) else { - 4049
return Ok(vec![primary]); - 4050
}; - 4051
let mut keys = vec![primary.api_key.clone()]; - 4052
if let Some(value) = self.provider_secret(pool_env) { - 4053
keys.extend( - 4054
value - 4055
.split([',', '\n']) - 4056
.map(str::trim) - 4057
.filter(|key| !key.is_empty()) - 4058
.map(str::to_string), - 4059
); - 4060
} - 4061
let mut unique_keys = Vec::with_capacity(keys.len()); - 4062
for key in keys { - 4063
if !unique_keys.iter().any(|existing| existing == &key) { - 4064
unique_keys.push(key); - 4065
} - 4066
} - 4067
let base_url = primary.base_url.clone();
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.