- 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(); - 4068
Ok(unique_keys - 4069
.into_iter() - 4070
.map(|api_key| ProviderAuth { - 4071
credential_id: Some(vak_llm::credential_id( - 4072
base_url.as_deref().unwrap_or_default(), - 4073
&api_key, - 4074
)), - 4075
api_key, - 4076
base_url: base_url.clone(), - 4077
..Default::default() - 4078
}) - 4079
.collect()) - 4080
} - 4081
- 4082
fn provider_auth_for_leg( - 4083
&self, - 4084
provider: &str, - 4085
credential_id: Option<&str>, - 4086
) -> Result<ProviderAuth, CoreError> { - 4087
let pool = self.provider_auth_pool_for(provider)?; - 4088
if let Some(id) = credential_id { - 4089
return pool - 4090
.into_iter() - 4091
.find(|auth| auth.credential_id.as_deref() == Some(id)) - 4092
.ok_or_else(|| CoreError::MissingAuth { - 4093
env: format!("credential pool for {provider}"), - 4094
provider: provider.to_string(), - 4095
}); - 4096
} - 4097
pool.into_iter() - 4098
.next() - 4099
.ok_or_else(|| CoreError::MissingAuth { - 4100
env: format!("credential pool for {provider}"), - 4101
provider: provider.to_string(), - 4102
}) - 4103
} - 4104
- 4105
/// Return only non-secret identities in the configured provider pool. - 4106
/// This is safe for picker/admin surfaces and lets operators verify that - 4107
/// a plural pool variable was actually discovered. - 4108
pub fn provider_credential_ids(&self, provider: &str) -> Vec<String> { - 4109
self.provider_auth_pool_for(provider) - 4110
.unwrap_or_default() - 4111
.into_iter() - 4112
.filter_map(|auth| auth.credential_id) - 4113
.collect() - 4114
} - 4115
- 4116
pub fn provider(&self) -> Result<Arc<dyn Provider>, CoreError> { - 4117
if let Ok(p) = self.inner.provider_instance.lock() - 4118
&& let Some(provider) = p.as_ref() - 4119
{ - 4120
return Ok(provider.clone()); - 4121
} - 4122
let auth = self.provider_auth()?; - 4123
Ok(self.inner.registry.get(&self.effective_provider(), &auth)?) - 4124
} - 4125
- 4126
/// The env var that authenticates `provider`, or None for keyless - 4127
/// providers (ollama). Unknown providers yield None as well — callers - 4128
/// distinguish via `provider_known`. - 4129
pub fn provider_env_var(provider: &str) -> Option<&'static str> { - 4130
match provider { - 4131
"anthropic" => Some("ANTHROPIC_API_KEY"), - 4132
"google" => Some("GEMINI_API_KEY"), - 4133
"openai" | "openai-responses" => Some("OPENAI_API_KEY"), - 4134
"openrouter" | "openrouter-responses" => Some("OPENROUTER_API_KEY"), - 4135
"opencode-zen" => Some("OPENCODE_API_KEY"), - 4136
"bedrock" => Some("AWS_BEARER_TOKEN_BEDROCK"), - 4137
_ => None, - 4138
} - 4139
} - 4140
- 4141
/// The name a person knows `provider` by, for every everyday screen; - 4142
/// the id stays for configuration and technical views. This table is - 4143
/// also the set of known providers (`provider_known`), so a provider - 4144
/// cannot be added without a name. - 4145
pub fn provider_label(provider: &str) -> Option<&'static str> { - 4146
match provider { - 4147
"anthropic" => Some("Anthropic"), - 4148
"google" => Some("Google Gemini"), - 4149
"openai" => Some("OpenAI"), - 4150
"openai-responses" => Some("OpenAI (Responses API)"), - 4151
"openrouter" => Some("OpenRouter"), - 4152
"openrouter-responses" => Some("OpenRouter (Responses API)"), - 4153
"opencode-zen" => Some("OpenCode Zen"), - 4154
"bedrock" => Some("Amazon Bedrock"), - 4155
"ollama" => Some("Ollama"), - 4156
_ => None, - 4157
} - 4158
} - 4159
- 4160
pub fn provider_known(provider: &str) -> bool { - 4161
Self::provider_label(provider).is_some() - 4162
} - 4163
- 4164
/// True when a run on `provider` would find credentials right now. - 4165
pub fn provider_configured(&self, provider: &str) -> bool { - 4166
if self - 4167
.inner - 4168
.provider_instance - 4169
.lock() - 4170
.unwrap_or_else(std::sync::PoisonError::into_inner) - 4171
.is_some() - 4172
{ - 4173
return true; - 4174
} - 4175
self.provider_auth_for_leg(provider, None).is_ok() - 4176
} - 4177
- 4178
/// Secret provenance for administrative displays. Values are deliberately - 4179
/// reduced to booleans; credentials and their fingerprints never leave - 4180
/// the process through this API. - 4181
pub fn provider_key_sources(&self, provider: &str) -> (bool, bool, bool) { - 4182
let Some(env) = Self::provider_env_var(provider) else { - 4183
return (false, false, false); - 4184
}; - 4185
let project = vak_config::read_env_file_var(&self.inner.cwd.join(".env"), env).is_some(); - 4186
let agent = self.agent_identity.is_some() - 4187
&& vak_config::read_env_file_var(&self.sessions_home().join(".env"), env).is_some(); - 4188
let user = agent || vak_config::read_env_file_var(&self.user_env_file(), env).is_some(); - 4189
let process = std::env::var(env)
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.