- 23
pub mod memory; - 24
pub mod misread; - 25
pub mod onboarding; - 26
pub mod presentation_tools; - 27
/// The three permission rule lists, in `vak_config::Config`'s own order: - 28
/// `(allow, ask, deny)`. - 29
pub type PermissionRuleLists = (Vec<String>, Vec<String>, Vec<String>); - 30
- 31
pub mod prompts; - 32
pub mod reach; - 33
pub mod reflection; - 34
pub mod routing; - 35
pub mod sandbox_docker; - 36
pub mod security_events; - 37
pub mod seed; - 38
pub mod session_search; - 39
pub mod skills; - 40
pub mod state; - 41
pub mod trash; - 42
- 43
pub mod gateway_token; - 44
pub mod tasks; - 45
pub mod tools_commitments; - 46
pub mod tools_tasks; - 47
pub mod transcript_md; - 48
pub mod trust; - 49
pub mod workspaces; - 50
pub mod worktree; - 51
- 52
/// Universal task-environment contract. Backend lifecycle and candidate - 53
/// promotion live in `vak-sandbox`; Core only admits and selects it. - 54
pub use vak_sandbox; - 55
- 56
use std::collections::HashMap; - 57
use std::path::{Path, PathBuf}; - 58
use std::sync::Arc; - 59
- 60
use tokio_util::sync::CancellationToken; - 61
use vak_agent::{Agent, AgentConfig, AgentEvent, TurnOutcome, WorkMode}; - 62
use vak_llm::Provider; - 63
use vak_llm::registry::{ProviderAuth, ProviderRegistry, default_registry}; - 64
- 65
type ModelContextCache = std::sync::Mutex< - 66
HashMap<(String, String, String), (std::time::Instant, Option<vak_llm::models::ModelContext>)>, - 67
>; - 68
/// In-memory cache for measured capacity profiles (docs/design/68-context-engine.md - 69
/// §1), keyed by `(provider, model, quantisation)`. Per-process only — - 70
/// durable history lives in the ledger via `SessionLog::latest_capacity_profile`. - 71
type CapacityCache = std::sync::Mutex< - 72
HashMap<vak_context::capacity::ProfileKey, vak_context::capacity::CapacityProfile>, - 73
>; - 74
- 75
/// Provider metadata gathered before a capacity probe runs, bundled so - 76
/// `run_capacity_probe` stays under clippy's argument-count lint. - 77
struct ProbeMetadata { - 78
declared_window: u64, - 79
output_reserve: u64, - 80
metadata_digest: String, - 81
probed_at: std::time::SystemTime, - 82
} - 83
- 84
type TaskSandboxMap = - 85
std::sync::Mutex<HashMap<String, (String, Arc<dyn vak_tools::sandbox::Sandbox>)>>; - 86
type ModelCache = std::sync::Mutex<HashMap<(String, String), (std::time::Instant, Vec<String>)>>; - 87
use vak_session::SessionLog; - 88
use vak_session::types::{CapabilityDescriptor, CapabilityKind, FrozenContract, SessionHeader}; - 89
use vak_tools::sandbox::SandboxMode; - 90
- 91
struct CoreFlowDispatcher { - 92
core: Core, - 93
tools: Vec<Arc<dyn vak_tools::Tool>>, - 94
system_prompt: String, - 95
} - 96
- 97
#[async_trait::async_trait] - 98
impl vak_agent::FlowDispatcher for CoreFlowDispatcher { - 99
async fn dispatch( - 100
&self, - 101
args: &serde_json::Value, - 102
session: Arc<tokio::sync::Mutex<SessionLog>>, - 103
ctx: &vak_tools::ToolContext, - 104
approver: Option<Arc<dyn vak_agent::Approver>>, - 105
) -> vak_tools::ToolOutput { - 106
let Some(name) = args.get("flow").and_then(|value| value.as_str()) else { - 107
return vak_tools::ToolOutput::error("flow requires flow"); - 108
}; - 109
let Some(contract_id) = args.get("contract_id").and_then(|value| value.as_str()) else { - 110
return vak_tools::ToolOutput::error("flow requires contract_id"); - 111
}; - 112
let Some(work_item_id) = args.get("work_item_id").and_then(|value| value.as_str()) else { - 113
return vak_tools::ToolOutput::error("flow requires work_item_id"); - 114
}; - 115
if !valid_managed_flow_name(name) { - 116
return vak_tools::ToolOutput::error("invalid managed flow name"); - 117
} - 118
let flow_path = self - 119
.core - 120
.cwd() - 121
.join(".vak/flows") - 122
.join(format!("{name}.toml")); - 123
let definition_toml = match std::fs::read_to_string(&flow_path) { - 124
Ok(body) => body, - 125
Err(_) => { - 126
return vak_tools::ToolOutput::error(format!("managed flow '{name}' not found")); - 127
} - 128
}; - 129
let flow = match vak_flow::parse_flow(&definition_toml) { - 130
Ok(flow) if flow.name == name => flow, - 131
Ok(_) => { - 132
return vak_tools::ToolOutput::error( - 133
"managed flow name does not match its definition", - 134
); - 135
} - 136
Err(error) => { - 137
return vak_tools::ToolOutput::error(format!("invalid managed flow: {error}")); - 138
} - 139
}; - 140
let (parent_session_id, attempt) = { - 141
let log = session.lock().await; - 142
let Some(header) = log.header() else { - 143
return vak_tools::ToolOutput::error("managed flow session has no header"); - 144
}; - 145
let Ok(Some(work)) = log.work_projection() else { - 146
return vak_tools::ToolOutput::error("managed flow has no active contract"); - 147
}; - 148
let Some(item) = work.items.get(work_item_id) else { - 149
return vak_tools::ToolOutput::error("managed flow work item does not exist"); - 150
}; - 151
if work.contract.contract_id != contract_id - 152
|| !matches!( - 153
item.status, - 154
vak_session::types::WorkItemStatus::Ready - 155
| vak_session::types::WorkItemStatus::Running - 156
) - 157
{ - 158
return vak_tools::ToolOutput::error("managed flow work item is not runnable"); - 159
} - 160
(header.session_id.clone(), item.attempt.saturating_add(1)) - 161
}; - 162
let state_path = self - 163
.core - 164
.sessions_home() - 165
.join("flow-runs/managed") - 166
.join(format!( - 167
"{}-{}-{attempt}.json", - 168
managed_run_component(contract_id), - 169
managed_run_component(work_item_id), - 170
)); - 171
let mut state = vak_flow::FlowState { - 172
run_id: format!("{contract_id}-{work_item_id}-{attempt}"), - 173
flow_name: name.into(), - 174
definition_toml: definition_toml.clone(), - 175
started_at: chrono::Utc::now(), - 176
outcome: None, - 177
nodes: Default::default(), - 178
}; - 179
let permission = match self - 180
.core - 181
.build_permission_engine(&self.core.extra_allow_snapshot()) - 182
{ - 183
Ok(engine) => Arc::new(engine), - 184
Err(error) => { - 185
return vak_tools::ToolOutput::error(format!( - 186
"managed flow permission setup failed: {error}" - 187
)); - 188
} - 189
}; - 190
let mut outcome = vak_intent::OutcomeSpec::from_reading( - 191
flow.description.clone(), - 192
&vak_intent::Reading::default(), - 193
0, - 194
); - 195
outcome.max_turns = Some(self.core.effective_max_turns()); - 196
let inherited_prompt_layers = session - 197
.lock() - 198
.await - 199
.header() - 200
.map(|header| header.contract.prompt_layers.clone()) - 201
.unwrap_or_default(); - 202
let deps = vak_flow::ExecutorDeps { - 203
provider: match self.core.provider() { - 204
Ok(provider) => provider, - 205
Err(error) => return vak_tools::ToolOutput::error(error.to_string()), - 206
}, - 207
system_prompt: self.system_prompt.clone(), - 208
prompt_layers: inherited_prompt_layers, - 209
model: self.core.effective_model(), - 210
tools: self.tools.clone(), - 211
read_only_tools: self - 212
.tools - 213
.iter() - 214
.filter(|tool| matches!(tool.name(), "read" | "glob" | "grep")) - 215
.cloned() - 216
.collect(), - 217
max_turns: self.core.effective_max_turns(), - 218
outcome: Some(outcome), - 219
max_retries: 0, - 220
retry_base_backoff_ms: 100, - 221
request_timeout: Some(std::time::Duration::from_secs(600)), - 222
circuit_breaker: None, - 223
run_retry_attempts: 0, - 224
run_retry_base_backoff_ms: 1000, - 225
dispatch_ceiling: 1, - 226
spend_gate: None, - 227
permission: Some(permission), - 228
mode: match self.core.effective_permission_mode() { - 229
vak_config::PermissionMode::ReadOnly => vak_permission::Mode::ReadOnly, - 230
vak_config::PermissionMode::WorkspaceWrite => vak_permission::Mode::WorkspaceWrite, - 231
vak_config::PermissionMode::FullAccess => vak_permission::Mode::FullAccess, - 232
}, - 233
approval_mode: match self.core.effective_approval_mode() { - 234
vak_config::ApprovalMode::Ask => vak_agent::ApprovalMode::Ask, - 235
vak_config::ApprovalMode::ApproveSafe => vak_agent::ApprovalMode::ApproveSafe, - 236
vak_config::ApprovalMode::AutoApprove => vak_agent::ApprovalMode::AutoApprove, - 237
}, - 238
approver, - 239
sandbox: self.core.agent_sandbox(), - 240
cwd: self.core.cwd().clone(), - 241
sessions_home: self.core.sessions_home().clone(), - 242
parent_session_id, - 243
state_path, - 244
agent_identity: self.core.agent_identity().cloned(), - 245
conversation_context: self.core.conversation_context().cloned(), - 246
work: Some(vak_flow::FlowWorkContext { - 247
session, - 248
contract_id: contract_id.into(), - 249
work_item_id: work_item_id.into(), - 250
}), - 251
}; - 252
let (events, _receiver) = tokio::sync::mpsc::channel(32); - 253
match vak_flow::Executor::new(deps) - 254
.run(&flow, &mut state, ctx.cancel.child_token(), events) - 255
.await - 256
{ - 257
vak_flow::FlowOutcome::Completed { outputs } => vak_tools::ToolOutput::ok( - 258
serde_json::to_string(&outputs).unwrap_or_else(|_| "managed flow completed".into()), - 259
), - 260
vak_flow::FlowOutcome::Failed { node, reason, .. } => { - 261
vak_tools::ToolOutput::error(format!("managed flow failed at {node}: {reason}")) - 262
} - 263
vak_flow::FlowOutcome::Aborted => { - 264
vak_tools::ToolOutput::error("managed flow cancelled") - 265
} - 266
} - 267
} - 268
} - 269
- 270
fn valid_managed_flow_name(name: &str) -> bool { - 271
!name.is_empty() - 272
&& name.len() <= 128 - 273
&& name - 274
.bytes() - 275
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) - 276
} - 277
- 278
fn managed_run_component(value: &str) -> String { - 279
value - 280
.bytes() - 281
.map(|byte| { - 282
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_') { - 283
char::from(byte) - 284
} else { - 285
'_' - 286
} - 287
}) - 288
.collect() - 289
} - 290
- 291
pub const APP_VERSION: &str = env!("CARGO_PKG_VERSION"); - 292
pub const DEFAULT_SYSTEM_PROMPT: &str = include_str!("system-prompt.md"); - 293
/// The file tools of a task copy. `doc_read` and `office_apply` are how an - 294
/// Office file is read and changed at all (`read`, `write` and `edit` refuse - 295
/// a package); both run in the worker, confined to the copy, and - 296
/// `office_apply` writes only a draft under the copy's `.vak/scratch/`. - 297
const TASK_COPY_TOOLS: &[&str] = &[ - 298
"read", - 299
"glob", - 300
"grep", - 301
"ls", - 302
"write", - 303
"edit", - 304
"bash", - 305
"doc_read", - 306
"office_apply", - 307
]; - 308
- 309
/// Re-exported so consumers (and tests) can name config types via vak_core. - 310
pub use vak_config; - 311
- 312
/// Outcome of revoking a provider key. - 313
#[derive(Debug, Clone)] - 314
pub struct RemovedKey { - 315
pub env_var: String, - 316
/// True when the variable is still set in the real process environment, - 317
/// so the provider stays authenticated despite the stored key going away. - 318
pub shadowed_by_env: bool, - 319
} - 320
- 321
#[derive(Debug, thiserror::Error)] - 322
pub enum CoreError { - 323
#[error("provider auth missing: set {env} for provider '{provider}'")] - 324
MissingAuth { env: String, provider: String }, - 325
#[error("config error: {0}")] - 326
Config(#[from] vak_config::ConfigError), - 327
#[error("session error: {0}")] - 328
Session(#[from] vak_session::SessionError), - 329
#[error("provider error: {0}")] - 330
Llm(#[from] vak_llm::LlmError), - 331
#[error("permission rule error: {0}")] - 332
Rule(#[from] vak_permission::RuleError), - 333
#[error("blocked by hook: {0}")] - 334
HookBlocked(String), - 335
#[error("invalid configuration: {0}")] - 336
InvalidConfig(String), - 337
#[error("internal: permission engine missing")] - 338
MissingEngine, - 339
#[error( - 340
"this request needs a model that can serve {modalities}, and no leg on the route (primary: {model}) is declared able to; set [route] modality_hints or choose a capable model" - 341
)] - 342
UnsupportedModality { modalities: String, model: String }, - 343
} - 344
- 345
/// Stats reported by a successful manual compaction. - 346
#[derive(Debug, Clone, Copy)] - 347
pub struct CompactReport { - 348
pub before_tokens: u64, - 349
pub after_tokens: u64, - 350
pub summarized_messages: usize, - 351
} - 352
- 353
/// Result envelope for manual compaction: the caller keeps ownership of the - 354
/// session either way; `report` is `Some` exactly when `error` is `None`. - 355
#[derive(Debug, Clone)] - 356
pub struct CompactOutcome { - 357
pub report: Option<CompactReport>, - 358
pub error: Option<String>, - 359
} - 360
- 361
impl CompactOutcome { - 362
fn failed(error: String) -> Self { - 363
CompactOutcome { - 364
report: None, - 365
error: Some(error), - 366
} - 367
} - 368
} - 369
- 370
/// A chat transport vak can bridge. Carries its own label so no surface - 371
/// has to maintain a parallel id-to-name map that can drift. - 372
/// - 373
/// Distinct from [`Surface`], which names *which client* is driving a turn - 374
/// (CLI, desktop, a chat) — this names one of the chat transports a bot - 375
/// can live on. - 376
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] - 377
pub struct ChatSurface { - 378
pub id: &'static str, - 379
pub label: &'static str, - 380
} - 381
- 382
/// A capability that was found during discovery but excluded from the - 383
/// admitted set. Returned by [`Core::capability_diagnostics`] so inspection - 384
/// surfaces (`doctor`, admin console, desktop) can explain what was silently - 385
/// dropped and why. - 386
#[derive(Debug, Clone, PartialEq)] - 387
pub struct CapabilityDiagnostic { - 388
/// What kind of capability this was. - 389
pub kind: String, - 390
/// Human-readable name/label. - 391
pub name: String, - 392
/// Why it was excluded. - 393
pub reason: String, - 394
/// Where it came from (path, config layer, plugin name). - 395
pub source: Option<String>, - 396
/// What the operator can do to fix it. - 397
pub remedy: String, - 398
/// Whether this state was *chosen* rather than broken. - 399
/// - 400
/// A hook with `enabled = false`, a skill excluded by channel policy, and - 401
/// a capability `reach` blocks are all "configured but not usable", and - 402
/// none of them is a fault — the operator asked for exactly that. A - 403
/// server that will not connect or a skill that will not parse is a - 404
/// different thing. Without the split, `doctor` shows a failed check for - 405
/// a deliberate configuration choice, and a check that cries wolf is one - 406
/// people learn to scroll past — which is the precise failure this - 407
/// diagnostic exists to prevent. - 408
pub deliberate: bool, - 409
} - 410
- 411
impl Core { - 412
/// Today's estimated spend (local midnight window), USD 0.0 when the - 413
/// ledger is absent or unpriced rows dominate — absent is zero here - 414
/// because the ledger itself is the source being displayed. - 415
pub fn spend_day_usd(&self) -> f64 { - 416
vak_core_ledger(self).day_total_usd(chrono::Utc::now()) - 417
} - 418
- 419
/// Estimated spend over the trailing `days`, USD. - 420
pub fn spend_trailing_usd(&self, days: u64) -> f64 { - 421
vak_core_ledger(self).total_usd_since( - 422
chrono::Utc::now() - chrono::Duration::hours(days.saturating_mul(24) as i64), - 423
) - 424
} - 425
} - 426
- 427
fn vak_core_ledger(core: &Core) -> finops::FinOpsLedger { - 428
finops::FinOpsLedger::new(&core.shared_data_home()) - 429
} - 430
- 431
struct CoreInner { - 432
config: vak_config::Config, - 433
cwd: PathBuf, - 434
sessions_home: PathBuf, - 435
registry: ProviderRegistry, - 436
route: std::sync::Mutex<RouteSelection>, - 437
/// Fingerprint of the config files `route` was last derived from - 438
/// (docs/design/44-shared-config.md, "Liveness"). Checked on every - 439
/// `effective_route()` call so a write from another process (e.g. - 440
/// `vak setup`) is picked up without waiting for pool eviction/restart. - 441
route_fingerprint: std::sync::Mutex<u64>, - 442
max_turns_override: std::sync::Mutex<Option<usize>>, - 443
max_turns_runtime_pinned: std::sync::atomic::AtomicBool, - 444
evidence_max_age_override: std::sync::Mutex<Option<i64>>, - 445
mode_override: std::sync::Mutex<Option<vak_config::PermissionMode>>, - 446
mode_runtime_pinned: std::sync::atomic::AtomicBool, - 447
permission_lease: std::sync::Mutex<CancellationToken>, - 448
approval_mode_override: std::sync::Mutex<Option<vak_config::ApprovalMode>>, - 449
/// Live replacement for the config's `allow`/`ask`/`deny` lists. - 450
/// - 451
/// The rest of `Config` is immutable inside the `Arc`, which is why - 452
/// every settable preference has an override beside it. Rules had none - 453
/// — so editing them was a restart-only operation, and `PUT - 454
/// /config/permissions` would have written a file the running process - 455
/// kept ignoring. Ordering inside the tuple is (allow, ask, deny), - 456
/// matching `vak_config::Config`. - 457
rules_override: std::sync::Mutex<Option<PermissionRuleLists>>, - 458
theme_override: std::sync::Mutex<Option<String>>, - 459
voice_override: std::sync::Mutex<Option<vak_config::VoiceSettings>>, - 460
theme_runtime_pinned: std::sync::atomic::AtomicBool, - 461
/// Live overrides for `[memory]` toggles (docs/design/23-memory.md). - 462
/// No CLI flag pins these today, so unlike route/theme/max_turns there - 463
/// is no `*_runtime_pinned` counterpart — `refresh_persisted_preferences` - 464
/// always takes the latest persisted value. - 465
memory_search_enabled_override: std::sync::Mutex<Option<bool>>, - 466
memory_write_enabled_override: std::sync::Mutex<Option<bool>>, - 467
memory_reflection_override: std::sync::Mutex<Option<bool>>, - 468
memory_skill_proposals_override: std::sync::Mutex<Option<bool>>, - 469
/// Same no-pin, always-take-latest shape as the memory overrides above. - 470
workers_override: std::sync::Mutex<Option<bool>>, - 471
work_override: std::sync::Mutex<Option<vak_config::WorkResolved>>, - 472
/// Same no-pin, always-take-latest shape. `None` follows the cached - 473
/// `inner.config.plugins`; `refresh_persisted_preferences` re-derives - 474
/// it from disk after every persist, so a capability or egress change - 475
/// lands on the next turn (docs/design/41-capability-registry.md). - 476
plugins_override: std::sync::Mutex<Option<vak_config::PluginResolved>>, - 477
/// Live overrides for `[finops]` budget caps (docs/design/15-reliability.md). - 478
/// `None` = follow the persisted value; `Some(None)` = explicitly - 479
/// cleared (no cap); `Some(Some(v))` = pinned to `v`. Distinct from - 480
/// the other overrides here because "no cap" is a real, settable - 481
/// value, not merely "unset" — a plain `Mutex<Option<f64>>` couldn't - 482
/// tell "never overridden" from "overridden to no cap" apart. - 483
finops_max_run_usd_override: std::sync::Mutex<Option<Option<f64>>>, - 484
finops_max_day_usd_override: std::sync::Mutex<Option<Option<f64>>>, - 485
sandbox_backend_override: std::sync::Mutex<Option<String>>, - 486
agent_network: Arc<std::sync::Mutex<agent_network::AgentNetworkBroker>>, - 487
task_sandboxes: TaskSandboxMap, - 488
provider_instance: std::sync::Mutex<Option<Arc<dyn Provider>>>, - 489
sessions_home_override: std::sync::Mutex<Option<PathBuf>>, - 490
breaker: Arc<vak_agent::CircuitBreaker>, - 491
workers: Arc<vak_agent::WorkerRegistry>, - 492
trust_project_config: bool, - 493
extra_allow: std::sync::Mutex<Vec<String>>, - 494
user_env_override: std::sync::Mutex<Option<PathBuf>>, - 495
tool_worker_exe: std::sync::Mutex<PathBuf>, - 496
/// provider -> (fetched_at, model ids). Discovery is a network call; - 497
/// pickers re-read it constantly, so results are memoised briefly. - 498
models_cache: ModelCache, - 499
/// Provider-reported per-model context limits. Unknown metadata is - 500
/// cached briefly too, so an unavailable metadata endpoint cannot stall - 501
/// every turn. - 502
model_context_cache: ModelContextCache, - 503
/// Keys with a background metadata refresh in flight, so a stale hit - 504
/// spawns at most one refresh task per key rather than one per caller. - 505
model_context_refreshing: std::sync::Mutex<std::collections::HashSet<(String, String, String)>>, - 506
/// Measured capacity profiles, one per bound `(provider, model, - 507
/// quantisation)` this process has seen (docs/design/68 §1). - 508
capacity_cache: CapacityCache, - 509
/// One background horizon-ladder probe slot per profile key - 510
/// (docs/design/68 §1): the token cancels an in-flight probe when a - 511
/// real turn starts for the same model, and presence is the - 512
/// single-flight guard. Probes run only after a turn completes, never - 513
/// on a turn's own critical path. - 514
capacity_probes: - 515
Arc<std::sync::Mutex<HashMap<vak_context::capacity::ProfileKey, CancellationToken>>>, - 516
/// Wall-clock time the most recent background probe attempt for a key - 517
/// started, so a key that keeps getting cancelled by real turns is not - 518
/// respawned more than once per [`Core::CAPACITY_PROBE_MIN_INTERVAL`]. - 519
capacity_probe_attempted: - 520
Arc<std::sync::Mutex<HashMap<vak_context::capacity::ProfileKey, std::time::Instant>>>, - 521
/// Runtime MCP table override (desktop/TUI management surface). - 522
mcp_override: std::sync::Mutex<Option<vak_config::McpConfig>>, - 523
mcp_runtime_pinned: std::sync::atomic::AtomicBool, - 524
/// One `McpManager` per distinct server set, reused across turns so - 525
/// spawned server processes (e.g. `npx tavily-mcp`) and their live - 526
/// connections survive a whole session instead of respawning every - 527
/// turn. Keyed by a fingerprint of the resolved server set so a - 528
/// runtime `set_mcp_servers` call or a plugin enable/disable — both of - 529
/// which change what `effective_mcp()` returns — transparently swaps - 530
/// in a fresh manager instead of serving a stale one. Nothing is spawned - 531
/// until a model's `mcp` call needs it — see `mcp_manager()`. - 532
mcp_cache: std::sync::Mutex<Option<McpCache>>, - 533
/// The capability registry and its reconcile loop - 534
/// (docs/design/41-capability-registry.md). Created on first use and - 535
/// shared for the life of the process: it publishes immutable versioned - 536
/// snapshots that turns bind to, which is what lets a session that has - 537
/// been alive for weeks pick up a skill added today without a restart - 538
/// and without being rotated. - 539
capability_registry: std::sync::OnceLock<Arc<capability::CapabilityRegistry>>, - 540
/// Signals the reconcile loop to stop. Held so a dropped Core does not - 541
/// leave the loop running against a dead provider. - 542
capability_shutdown: std::sync::Mutex<Option<tokio::sync::watch::Sender<bool>>>, - 543
/// Runtime hook override (desktop/TUI management surface). - 544
hooks_override: std::sync::Mutex<Option<Vec<vak_config::HookConfig>>>, - 545
hooks_runtime_pinned: std::sync::atomic::AtomicBool, - 546
capabilities_override: std::sync::Mutex<Option<vak_config::CapabilityInheritanceResolved>>, - 547
/// Restrictive overlay applied only to a gateway channel Core. - 548
channel_policy: std::sync::Mutex<Option<vak_config::ChannelPolicy>>, - 549
/// Live overrides for `[tools]` toggles. Same no-pin, always-take-latest - 550
/// shape as the memory overrides — `refresh_persisted_preferences` writes - 551
/// them on every re-read so a live `PUT /config` takes effect on the - 552
/// next turn without a restart. - 553
web_fetch_override: std::sync::Mutex<Option<bool>>, - 554
browse_override: std::sync::Mutex<Option<bool>>, - 555
/// Live override for `[commitment]` enabled toggle. - 556
commitment_override: std::sync::Mutex<Option<bool>>, - 557
/// Session-scoped domain-weighted doubt per (provider, model) leg - 558
/// (Phase R). Fed from work receipts at run end; read at ladder - 559
/// admission. - 560
beliefs: Arc<routing::BeliefState>, - 561
/// Per-session FinOps spend gates (docs/design/15-reliability.md), keyed by - 562
/// session id. Built once per session and reused for every turn: a - 563
/// fresh gate per turn used to zero out `max_run_usd`'s accounting on - 564
/// every message, so a multi-turn conversation could blow past the - 565
/// run cap by an arbitrary multiple. Sessions are evicted explicitly - 566
/// (see `Core::forget_spend_gate`) rather than left to grow forever. - 567
spend_gates: std::sync::Mutex<HashMap<String, Arc<finops::CoreSpendGate>>>, - 568
/// Shared cross-session/cross-turn day-cap admission state (see - 569
/// [`finops::CoreSpendGate`]'s `DayBudget` doc) — one tracker per - 570
/// `Core`, handed to every spend gate it builds so concurrent - 571
/// dispatches from different sessions can't jointly race past the - 572
/// day cap before any of them settles. - 573
day_budget: Arc<std::sync::Mutex<finops::DayBudget>>, - 574
} - 575
- 576
/// Learned permission rules live outside the main config so they can be - 577
/// written at runtime without touching (possibly committed) project config. - 578
/// Where a capability (skill, command, plugin, hook, MCP server) was found. - 579
#[derive(Debug, Clone, Copy, PartialEq, Eq)] - 580
pub enum CapabilityScope { - 581
/// The workspace being operated on: `<cwd>/.vak`. - 582
Workspace, - 583
/// The user's shared workspace: `default_workspace()/.vak`. - 584
Shared, - 585
} - 586
- 587
impl CapabilityScope { - 588
pub fn label(self) -> &'static str { - 589
match self { - 590
CapabilityScope::Workspace => "workspace", - 591
CapabilityScope::Shared => "shared", - 592
} - 593
} - 594
} - 595
- 596
/// One capability root and the scope it speaks for. - 597
#[derive(Debug, Clone)] - 598
pub struct CapabilityRoot { - 599
pub path: std::path::PathBuf, - 600
pub scope: CapabilityScope, - 601
} - 602
- 603
pub const PERMISSIONS_LOCAL_FILE: &str = ".vak/permissions.local.toml"; - 604
- 605
/// The built-in Agent is an explicit identity. New sessions must never rely - 606
/// on a missing `SessionHeader.agent` to mean Vak; absence is retained only - 607
/// while old ledgers are being inspected by the baseline guard. - 608
pub fn vak_agent_identity() -> vak_session::types::AgentIdentity { - 609
vak_session::types::AgentIdentity { - 610
id: "vak".into(), - 611
revision: 1, - 612
name: "Vakyartha".into(), - 613
character: "vak".into(), - 614
personality: String::new(), - 615
animation: "subtle".into(), - 616
voice: "default".into(), - 617
behaviour: String::new(), - 618
responsibilities: String::new(), - 619
instructions: String::new(), - 620
} - 621
} - 622
- 623
#[derive(serde::Deserialize, Default)] - 624
struct PermissionsLocal { - 625
#[serde(default)] - 626
allow: Vec<String>, - 627
} - 628
- 629
#[derive(Clone)] - 630
pub struct Core { - 631
inner: Arc<CoreInner>, - 632
/// A child Core rooted in a retained, separate task copy. Never stamp - 633
/// this on the owner's ordinary conversation Core. - 634
task_copy_boundary: bool, - 635
/// Office files a revision's task copy holds that are new to the - 636
/// workspace it was made from, so their Word edits are written clean - 637
/// (docs/design/72, R7). Set only by the server; empty otherwise. - 638
new_documents: Arc<Vec<String>>, - 639
/// `<surface>:<chat>` for the conversation this turn is running - 640
/// inside, when known (set by the gateway per inbound message; unset - 641
/// for the CLI and desktop app, which have no chat to reply into). - 642
/// Read once, at tool-build time, as [`tasks::TasksTool`]'s default - 643
/// `deliver_to` — so a task created by a prompt in that chat ("remind - 644
/// me every Monday at 9am") reports back into the same chat without - 645
/// the model having to know or guess its own channel address. - 646
default_deliver_to: Option<String>, - 647
/// Which product surface this turn is running on, when known. Carried - 648
/// here rather than in `CoreInner` for the same reason - 649
/// `default_deliver_to` is: the gateway clones a `Core` per inbound - 650
/// message and stamps the channel on it, which must not disturb the - 651
/// shared workspace state behind the `Arc`. - 652
surface: Surface, - 653
/// Named agent role for this turn, selecting a `prompts/agents/<name>` - 654
/// sub-layer. Set for workers spawned with an explicit role. - 655
prompt_role: Option<String>, - 656
agent_identity: Option<vak_session::types::AgentIdentity>, - 657
conversation_context: Option<vak_session::types::ConversationContext>, - 658
/// Prompt layers the caller supplies rather than the filesystem: the - 659
/// gateway's bot and chat tiers. `Arc` because `Core` is cloned per - 660
/// turn and this is almost always empty. - 661
prompt_overlays: Arc<Vec<prompts::LayerInput>>, - 662
/// Whether an approval gate raised on this surface reaches someone who - 663
/// can answer it — the `Approver::answerable()` of the approver this - 664
/// surface installs, known here *before* a run starts. - 665
/// - 666
/// It lives beside `surface` rather than being read off the per-run - 667
/// approver because the thing that needs it is the system prompt, and - 668
/// the prompt is composed and frozen at session creation. A capability - 669
/// that gates on an approval nobody will answer is not part of this - 670
/// turn's callable interface, and the prompt has to be able to say so - 671
/// without waiting for a run to exist. - 672
/// - 673
/// Defaults to `true`: a surface that does not say otherwise is - 674
/// attended. Unattended surfaces (the gateway without forward mode, - 675
/// the heartbeat) set it false, matching the `AutoDeny` they install. - 676
approver_answerable: bool, - 677
} - 678
- 679
/// A step-limit continuation may finish work saved in its earlier bounded - 680
/// turn. Carry only a proven write from the *same intent thread*: the latest - 681
/// run must have stopped at the cap, its write tool must have succeeded, and - 682
/// the current workspace file must still equal the logged input bytes. The - 683
/// Agent stop gate additionally requires a fresh inspection this turn. - 684
fn continued_saved_file( - 685
session: &SessionLog, - 686
intent: &vak_intent::Intent, - 687
workspace: &Path, - 688
) -> bool { - 689
let threads = intent - 690
.strands - 691
.iter() - 692
.filter_map(|strand| match &strand.lineage { - 693
vak_intent::Lineage::Continues { thread_id } => Some(thread_id.as_str()), - 694
_ => None, - 695
}) - 696
.collect::<std::collections::HashSet<_>>(); - 697
if threads.is_empty() { - 698
return false; - 699
} - 700
let chain = session.chain_to_root(); - 701
let capped = chain.iter().rev().find_map(|entry| match &entry.payload { - 702
vak_session::EntryPayload::Activity(activity) if activity.label == "Run finished" => { - 703
Some(activity.detail.as_deref() == Some("max_turns")) - 704
} - 705
_ => None, - 706
}); - 707
if capped != Some(true) { - 708
return false; - 709
} - 710
let Ok(root) = workspace.canonicalize() else { - 711
return false; - 712
}; - 713
let mut same_thread = false; - 714
let mut writes = std::collections::HashMap::<String, (PathBuf, String)>::new(); - 715
for entry in chain { - 716
match &entry.payload { - 717
vak_session::EntryPayload::Intent(record) => { - 718
same_thread = record - 719
.strands - 720
.iter() - 721
.any(|strand| threads.contains(strand.thread_id.as_str())); - 722
writes.clear(); - 723
} - 724
vak_session::EntryPayload::Message(record) if same_thread => { - 725
for block in &record.message.content { - 726
match block { - 727
vak_llm::ContentBlock::ToolUse { id, name, input } - 728
if vak_tools::canonical_tool_name(name) == "write" => - 729
{ - 730
if let (Some(path), Some(content)) = ( - 731
input.get("path").and_then(serde_json::Value::as_str), - 732
input.get("content").and_then(serde_json::Value::as_str), - 733
) { - 734
writes.insert(id.clone(), (PathBuf::from(path), content.into())); - 735
} - 736
} - 737
vak_llm::ContentBlock::ToolResult { - 738
tool_use_id, - 739
is_error: false, - 740
.. - 741
} => { - 742
if let Some((path, content)) = writes.remove(tool_use_id) { - 743
let path = if path.is_absolute() { - 744
path - 745
} else { - 746
root.join(path) - 747
}; - 748
if let Ok(path) = path.canonicalize() - 749
&& let Ok(relative) = path.strip_prefix(&root) - 750
&& !relative.starts_with(".vak") - 751
&& std::fs::metadata(&path).is_ok_and(|meta| { - 752
meta.is_file() && meta.len() == content.len() as u64 - 753
}) - 754
&& std::fs::read(&path) - 755
.is_ok_and(|bytes| bytes == content.as_bytes()) - 756
{ - 757
return true; - 758
} - 759
} - 760
} - 761
_ => {} - 762
} - 763
} - 764
} - 765
_ => {} - 766
} - 767
} - 768
false - 769
} - 770
- 771
#[cfg(test)] - 772
#[allow(clippy::unwrap_used)] - 773
mod continuation_receipt_tests { - 774
use super::*; - 775
use vak_intent::{Lineage, Strand, StrandRelation}; - 776
use vak_session::types::{ - 777
ActivityKind, ActivityRecord, ActivityStatus, IntentRecord, MessageRecord, - 778
}; - 779
- 780
#[tokio::test] - 781
async fn only_capped_same_thread_unchanged_saved_file_can_carry_forward() { - 782
let dir = tempfile::tempdir().unwrap(); - 783
let core = Core::new_with_trust(dir.path().to_path_buf(), true).unwrap(); - 784
core.set_sessions_home(dir.path().join("sessions")); - 785
let mut session = core.start_session().await.unwrap(); - 786
let file = dir.path().join("report.csv"); - 787
std::fs::write(&file, "value\n60\n").unwrap(); - 788
let mut initial = vak_intent::Intent::general(1); - 789
let mut strand = Strand { - 790
strand_id: "s0.0".into(), - 791
thread_id: "s0.0".into(), - 792
text: "Create report.csv".into(), - 793
reading: vak_intent::Reading::general(), - 794
relation: StrandRelation::Independent, - 795
lineage: Lineage::New, - 796
engagement: vak_intent::Engagement::general(), - 797
}; - 798
initial.strands.push(strand.clone()); - 799
session - 800
.append_intent(IntentRecord { - 801
reading: initial.reading.clone(), - 802
strands: initial.strands.clone(), - 803
engagement: initial.engagement.clone(), - 804
provenance: initial.provenance.clone(), - 805
outcome: None, - 806
model_visible: None, - 807
commitment_id: None, - 808
strand_commitments: Default::default(), - 809
}) - 810
.unwrap(); - 811
session - 812
.append_message(MessageRecord { - 813
message: vak_llm::Message::assistant(vec![vak_llm::ContentBlock::ToolUse { - 814
id: "write-1".into(), - 815
name: "write".into(), - 816
input: serde_json::json!({"path": "report.csv", "content": "value\n60\n"}), - 817
}]), - 818
meta: None, - 819
}) - 820
.unwrap(); - 821
session - 822
.append_message(MessageRecord { - 823
message: vak_llm::Message { - 824
role: vak_llm::Role::Assistant, - 825
content: vec![vak_llm::ContentBlock::tool_result("write-1", "saved")], - 826
}, - 827
meta: None, - 828
}) - 829
.unwrap(); - 830
session - 831
.append_activity(ActivityRecord { - 832
activity_id: "run-1".into(), - 833
turn: Some(0), - 834
kind: ActivityKind::Run, - 835
status: ActivityStatus::Partial, - 836
label: "Run finished".into(), - 837
detail: Some("max_turns".into()), - 838
data: Default::default(), - 839
}) - 840
.unwrap(); - 841
strand.strand_id = "s1.0".into(); - 842
strand.lineage = Lineage::Continues { - 843
thread_id: "s0.0".into(), - 844
}; - 845
let mut continuation = vak_intent::Intent::general(1); - 846
continuation.strands.push(strand.clone()); - 847
assert!(continued_saved_file(&session, &continuation, dir.path())); - 848
std::fs::write(&file, "value\n99\n").unwrap(); - 849
assert!(!continued_saved_file(&session, &continuation, dir.path())); - 850
std::fs::write(&file, "value\n60\n").unwrap(); - 851
continuation.strands[0].lineage = Lineage::Continues { - 852
thread_id: "other".into(), - 853
}; - 854
assert!(!continued_saved_file(&session, &continuation, dir.path())); - 855
continuation.strands[0] = strand; - 856
session - 857
.append_activity(ActivityRecord { - 858
activity_id: "run-2".into(), - 859
turn: Some(1), - 860
kind: ActivityKind::Run, - 861
status: ActivityStatus::Succeeded, - 862
label: "Run finished".into(), - 863
detail: Some("completed".into()), - 864
data: Default::default(), - 865
}) - 866
.unwrap(); - 867
assert!(!continued_saved_file(&session, &continuation, dir.path())); - 868
} - 869
} - 870
- 871
/// The complete executable surface handed to a standalone flow or agent. - 872
/// Callers must construct their executor from this value instead of reading - 873
/// prompt text and tool factories independently. - 874
#[derive(Clone)] - 875
pub struct PreparedTurn { - 876
pub system_prompt: String, - 877
pub tools: Vec<Arc<dyn vak_tools::Tool>>, - 878
pub read_only_tools: Vec<Arc<dyn vak_tools::Tool>>, - 879
} - 880
- 881
impl PreparedTurn { - 882
pub fn from_parts( - 883
system_prompt: impl Into<String>, - 884
tools: Vec<Arc<dyn vak_tools::Tool>>, - 885
read_only_tools: Vec<Arc<dyn vak_tools::Tool>>, - 886
) -> Self { - 887
Self { - 888
system_prompt: system_prompt.into(), - 889
tools, - 890
read_only_tools, - 891
} - 892
} - 893
} - 894
- 895
/// Which product surface a turn is running on. - 896
/// - 897
/// One core drives the CLI, the desktop app, the HTTP server, and the chat - 898
/// gateways, and every one of them is served the *same* system prompt text. - 899
/// With nothing to say otherwise the model had no way to know where its reply - 900
/// would be read, so it answered every surface as though it were a terminal — - 901
/// a chat user was addressed as if they were sitting at a shell - 902
/// (docs/design/07-prompt.md, v0.2.1). - 903
/// - 904
/// `Unknown` is the default on purpose: a surface that has not said which one - 905
/// it is gets told to assume nothing, which is the old behaviour, rather than - 906
/// being silently labelled as one it isn't. - 907
#[derive(Debug, Clone, PartialEq, Eq, Default)] - 908
pub enum Surface { - 909
/// Nothing has named the surface for this turn. - 910
#[default] - 911
Unknown, - 912
/// `vak` in a terminal. - 913
Cli, - 914
/// An interactive modern rich terminal client (`vak term`). - 915
Terminal, - 916
/// The Tauri desktop app. - 917
Desktop, - 918
/// An HTTP/SSE API client driving the server directly. - 919
Server, - 920
/// The workspace client running in a browser - 921
/// (docs/design/48-web-client.md). Distinct from `Server` — that is a - 922
/// program calling the API, this is a person looking at a screen, and - 923
/// the difference decides delivery shape, approval routing, and what a - 924
/// ledger entry means when someone asks who did this. - 925
Web, - 926
/// A chat gateway, named by its transport (`telegram`, `discord`, ...). - 927
Chat { channel: String }, - 928
/// An unattended run (heartbeat, scheduled task) with no live reader. - 929
Background, - 930
/// A child agent. Its reply is consumed by the parent agent, not by a - 931
/// person, so it must not inherit the parent's human-facing guidance — - 932
/// a research child spawned from a phone chat is not itself on a phone. - 933
Worker, - 934
} - 935
- 936
impl Surface { - 937
/// Stable identifier, used to name a `prompts/surface/<slug>` layer and - 938
/// to report the surface on inspection surfaces. - 939
pub fn slug(&self) -> &str { - 940
match self { - 941
Surface::Unknown => "", - 942
Surface::Cli => "cli", - 943
Surface::Terminal => "terminal", - 944
Surface::Desktop => "desktop", - 945
Surface::Server => "server", - 946
Surface::Web => "web", - 947
Surface::Chat { channel } => channel, - 948
Surface::Background => "background", - 949
Surface::Worker => "worker", - 950
} - 951
} - 952
- 953
/// The block appended to the system prompt. Every arm states where the - 954
/// reply is read and what that costs the model, because that is the part - 955
/// that changes how it should answer — a phone-sized chat bubble and a - 956
/// terminal beside a diff viewer want different replies. - 957
/// Whether files the agent writes are shown to the reader automatically - 958
/// (the desktop and web clients' preview pane). - 959
pub fn previews_files(&self) -> bool { - 960
matches!(self, Surface::Desktop | Surface::Web) - 961
} - 962
- 963
fn prompt_section(&self) -> String { - 964
let body = match self { - 965
Surface::Unknown => "unknown. Nothing has told you where this reply \ - 966
will be read, so assume nothing about it; write plain text that reads \ - 967
correctly anywhere." - 968
.to_string(), - 969
Surface::Cli => "terminal CLI. Your reply is printed in a terminal \ - 970
the user is watching. Plain text and fenced code blocks render; images do not." - 971
.to_string(), - 972
Surface::Terminal => "modern terminal client (vak term). Your reply \ - 973
is rendered in an interactive terminal TUI with rich typography, syntax-highlighted \ - 974
diffs, collapsible tool execution cards, and live progress indicators. Plain text \ - 975
and fenced code blocks render with full fidelity; images render via inline terminal graphics." - 976
.to_string(), - 977
Surface::Desktop => "desktop app. Your reply is rendered as markdown \ - 978
in a chat panel, beside a diff viewer, an editor, and a terminal the user can \ - 979
already see for themselves." - 980
.to_string(), - 981
Surface::Server => "HTTP API. Your reply is consumed by a client \ - 982
program over HTTP/SSE, which may render it any way it likes, or not at all." - 983
.to_string(), - 984
Surface::Web => "web client. Your reply is rendered as markdown in \ - 985
a browser, possibly on a phone and possibly far from the machine you are \ - 986
working on. The diff, editor, and terminal panes may not be open or may not \ - 987
exist, so do not assume the reader can already see what you changed — say it." - 988
.to_string(), - 989
Surface::Chat { channel } => format!( - 990
"chat gateway ({channel}). Your reply is read as a message in a \ - 991
chat client, often on a phone. Keep it short, skip terminal formatting, and do \ - 992
not assume the user can see your working directory, your scrollback, or any \ - 993
file you are talking about." - 994
), - 995
Surface::Background => "background run. Nobody is reading this live \ - 996
and there is no one to ask a follow-up question. Finish what you can decide \ - 997
on your own, and leave the outcome where the next reader will find it. When a \ - 998
step needs someone's confirmation, do not take it: stop there and leave the \ - 999
question in your result." - 1000
.to_string(), - 1001
Surface::Worker => "worker. Your reply is read by the agent \ - 1002
that spawned you, not by a person. Answer it completely and in full — state \ - 1003
what you found, what you changed, and what you could not resolve — rather \ - 1004
than briefly, since it cannot ask you a follow-up question." - 1005
.to_string(), - 1006
}; - 1007
let preview = if self.previews_files() { - 1008
" Files you write in the workspace or `.vak/scratch/` appear in the \ - 1009
user's preview automatically, so do not start an HTTP server just to preview \ - 1010
a static file." - 1011
} else { - 1012
"" - 1013
}; - 1014
format!("\nSurface: {body}{preview}\n") - 1015
} - 1016
} - 1017
- 1018
/// One indivisible provider/model selection. A route is always read and - 1019
/// written as a pair so session admission cannot observe a torn update. - 1020
#[derive(Debug, Clone, serde::Serialize, PartialEq, Eq)] - 1021
pub struct RouteSelection { - 1022
pub provider: String,
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.