- 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, - 1023
pub model: String, - 1024
pub provider_source: String, - 1025
pub model_source: String, - 1026
pub revision: String, - 1027
#[serde(skip)] - 1028
runtime_pinned: bool, - 1029
} - 1030
- 1031
/// Provider identity owns credentials and model discovery; the adapter name - 1032
/// owns a concrete wire dialect. Keeping the conversion here prevents a - 1033
/// route selected at admission from being silently sent through whichever - 1034
/// adapter happened to share the provider's credential. - 1035
/// Extracts the host from a `scheme://[user:pass@]host[:port][/path]` URL - 1036
/// without a `url` crate dependency — enough to answer "is this loopback", - 1037
/// not a general URL parser. - 1038
fn url_host(url: &str) -> Option<&str> { - 1039
let after_scheme = url.split("://").nth(1).unwrap_or(url); - 1040
let host_port = after_scheme.split('/').next().unwrap_or(after_scheme); - 1041
let host_port = host_port.rsplit('@').next().unwrap_or(host_port); - 1042
if let Some(stripped) = host_port.strip_prefix('[') { - 1043
// IPv6 literal, e.g. `[::1]:11434`. - 1044
stripped.split(']').next() - 1045
} else { - 1046
host_port.split(':').next() - 1047
} - 1048
} - 1049
- 1050
/// Whether `host` names this machine (docs/design/68-context-engine.md §1 - 1051
/// local-vs-hosted probing). - 1052
fn is_loopback_host(host: &str) -> bool { - 1053
host.eq_ignore_ascii_case("localhost") || host == "::1" || host.starts_with("127.") - 1054
} - 1055
- 1056
fn adapter_name_for_leg(leg: &vak_llm::RouteLeg) -> String { - 1057
match (&*leg.provider, leg.dialect) { - 1058
("openai", vak_llm::EndpointDialect::Responses) => "openai-responses".into(), - 1059
("openrouter", vak_llm::EndpointDialect::Responses) => "openrouter-responses".into(), - 1060
_ => leg.provider.clone(), - 1061
} - 1062
} - 1063
- 1064
fn route_selection( - 1065
provider: String, - 1066
model: String, - 1067
provider_source: &str, - 1068
model_source: &str, - 1069
runtime_pinned: bool, - 1070
) -> RouteSelection { - 1071
let revision = route_revision(&provider, &model, provider_source, model_source); - 1072
RouteSelection { - 1073
provider, - 1074
model, - 1075
provider_source: provider_source.to_string(), - 1076
model_source: model_source.to_string(), - 1077
revision, - 1078
runtime_pinned, - 1079
} - 1080
} - 1081
- 1082
fn has_credential(env_name: &str, cwd: &std::path::Path) -> bool { - 1083
if std::env::var(env_name).is_ok_and(|v| !v.trim().is_empty()) { - 1084
return true; - 1085
} - 1086
if vak_config::read_env_file_var(&cwd.join(".env"), env_name) - 1087
.is_some_and(|v| !v.trim().is_empty()) - 1088
{ - 1089
return true; - 1090
} - 1091
if let Some(user_env) = vak_config::user_env_path() - 1092
&& vak_config::read_env_file_var(&user_env, env_name).is_some_and(|v| !v.trim().is_empty()) - 1093
{ - 1094
return true; - 1095
} - 1096
false - 1097
} - 1098
- 1099
fn route_from_config( - 1100
cwd: &std::path::Path, - 1101
config: &vak_config::Config, - 1102
pinned: bool, - 1103
) -> RouteSelection { - 1104
let p_src = route_source(cwd, "provider"); - 1105
let m_src = route_source(cwd, "model"); - 1106
- 1107
let (provider, model) = if p_src == "built_in_default" { - 1108
// If the user didn't explicitly pin a provider, detect configured credentials - 1109
// instead of blindly demanding an Anthropic key. - 1110
if has_credential("ANTHROPIC_API_KEY", cwd) { - 1111
(config.provider.clone(), config.model.clone()) - 1112
} else if has_credential("GEMINI_API_KEY", cwd) || has_credential("GOOGLE_API_KEY", cwd) { - 1113
let m = if m_src == "built_in_default" { - 1114
"gemini-2.5-flash".to_string() - 1115
} else { - 1116
config.model.clone() - 1117
}; - 1118
("google".to_string(), m) - 1119
} else if has_credential("OPENAI_API_KEY", cwd) { - 1120
let m = if m_src == "built_in_default" { - 1121
"gpt-4o".to_string() - 1122
} else { - 1123
config.model.clone() - 1124
}; - 1125
("openai".to_string(), m) - 1126
} else if has_credential("AWS_BEARER_TOKEN_BEDROCK", cwd) { - 1127
let m = if m_src == "built_in_default" { - 1128
"us.anthropic.claude-3-7-sonnet-20250219-v1:0".to_string() - 1129
} else { - 1130
config.model.clone() - 1131
}; - 1132
("bedrock".to_string(), m) - 1133
} else { - 1134
(config.provider.clone(), config.model.clone()) - 1135
} - 1136
} else { - 1137
(config.provider.clone(), config.model.clone()) - 1138
}; - 1139
- 1140
route_selection(provider, model, &p_src, &m_src, pinned) - 1141
} - 1142
- 1143
fn route_revision( - 1144
provider: &str, - 1145
model: &str, - 1146
provider_source: &str, - 1147
model_source: &str, - 1148
) -> String { - 1149
let mut hash = 0xcbf29ce484222325_u64; - 1150
for byte in [provider, model, provider_source, model_source] - 1151
.join("\0") - 1152
.bytes() - 1153
{ - 1154
hash ^= u64::from(byte); - 1155
hash = hash.wrapping_mul(0x100000001b3); - 1156
} - 1157
format!("r{hash:016x}") - 1158
} - 1159
- 1160
fn route_source(cwd: &std::path::Path, key: &str) -> String { - 1161
let env_set = match key { - 1162
"provider" => std::env::var("VAK_PROVIDER").is_ok(), - 1163
"model" => std::env::var("VAK_MODEL").is_ok(), - 1164
_ => false, - 1165
}; - 1166
if env_set { - 1167
"environment" - 1168
} else if project_profile_has_key(cwd, key) { - 1169
"project_profile" - 1170
} else if vak_config::project_path(cwd).is_file() && project_config_has_key(cwd, key) { - 1171
"project_config" - 1172
} else if global_profile_has_key(key) { - 1173
"global_profile" - 1174
} else if vak_config::global_path().is_some_and(|path| path.is_file()) - 1175
&& global_config_has_key(key) - 1176
{ - 1177
"global_config" - 1178
} else { - 1179
"built_in_default" - 1180
} - 1181
.to_string() - 1182
} - 1183
- 1184
/// Scan plugin stores for packages whose skill descriptions reference - 1185
/// retired tool names. Emits an `eprintln!` warning for each finding so - 1186
/// operators see it in logs. Non-destructive: actual removal is handled by - 1187
/// `seed::cleanup_retired_plugins` during `vak setup seed` / `vak self update`. - 1188
/// - 1189
/// Checks both the workspace-local `.vak` and the shared home store. - 1190
fn warn_retired_plugins(cwd: &Path, sessions_home: &Path, _config: &vak_config::Config) { - 1191
let roots: Vec<PathBuf> = vec![cwd.join(".vak"), sessions_home.to_path_buf()]; - 1192
for root in roots { - 1193
if let Ok(store) = vak_plugin::PluginStore::new(&root).retired_plugins() { - 1194
for (plugin_name, retired) in &store { - 1195
eprintln!( - 1196
"WARNING: plugin '{}' references retired tool(s): {}. \ - 1197
Run `vak setup seed` to remove it automatically, or \ - 1198
manually run `vak plugins remove {}`.", - 1199
plugin_name, - 1200
retired.join(", "), - 1201
plugin_name - 1202
); - 1203
} - 1204
} - 1205
} - 1206
} - 1207
- 1208
impl Core { - 1209
pub fn new(cwd: PathBuf) -> Result<Self, CoreError> { - 1210
Self::new_with_trust(cwd, true) - 1211
} - 1212
- 1213
/// `trust_project_config == false` demotes privileged project-layer - 1214
/// keys (permission_mode, allow, hooks, base URLs, mcp servers) so a - 1215
/// cloned repository cannot grant itself full access, auto-approvals, - 1216
/// or hook/base-URL redirection on first run. - 1217
pub fn new_with_trust(cwd: PathBuf, trust_project_config: bool) -> Result<Self, CoreError> { - 1218
let config = vak_config::load_with_trust(&cwd, trust_project_config)?; - 1219
let route = route_from_config(&cwd, &config, false); - 1220
let route_fingerprint = vak_config::config_fingerprint(&cwd); - 1221
// Canonical layout (doc 32): one resolver for the whole workspace. - 1222
// The cwd fallback covers exotic environments with no HOME. - 1223
let sessions_home = vak_config::paths::data_home(); - 1224
let sessions_home = if std::env::var_os("VAK_HOME").is_none() - 1225
&& std::env::var_os("HOME").is_none() - 1226
&& std::env::var_os("USERPROFILE").is_none() - 1227
{ - 1228
cwd.join(".vak") - 1229
} else { - 1230
sessions_home - 1231
}; - 1232
// Warn about plugins whose skill descriptions reference retired - 1233
// tool names. These plugins can cause model hallucinations - 1234
// (e.g. `python_eval` → `unknown_capability` → fabricated output). - 1235
// Removal happens at setup time via `seed::cleanup_retired_plugins`; - 1236
// this is a loud non-destructive check so operators see it. - 1237
warn_retired_plugins(&cwd, &sessions_home, &config); - 1238
let breaker = Arc::new(vak_agent::CircuitBreaker::new( - 1239
vak_agent::CircuitBreakerConfig { - 1240
threshold: config.circuit_breaker_threshold, - 1241
cooldown: std::time::Duration::from_secs(config.circuit_breaker_cooldown_secs), - 1242
}, - 1243
)); - 1244
let extra_allow = if trust_project_config { - 1245
load_permissions_local(&cwd) - 1246
} else { - 1247
Vec::new() - 1248
}; - 1249
Ok(Core { - 1250
task_copy_boundary: false, - 1251
new_documents: Arc::default(), - 1252
default_deliver_to: None, - 1253
surface: Surface::Unknown, - 1254
prompt_role: None, - 1255
agent_identity: Some(vak_agent_identity()), - 1256
conversation_context: None, - 1257
prompt_overlays: Arc::new(Vec::new()), - 1258
approver_answerable: true, - 1259
inner: Arc::new(CoreInner { - 1260
config, - 1261
cwd, - 1262
sessions_home, - 1263
registry: default_registry(), - 1264
route: std::sync::Mutex::new(route), - 1265
route_fingerprint: std::sync::Mutex::new(route_fingerprint), - 1266
max_turns_override: std::sync::Mutex::new(None), - 1267
max_turns_runtime_pinned: std::sync::atomic::AtomicBool::new(false), - 1268
evidence_max_age_override: std::sync::Mutex::new(None), - 1269
mode_override: std::sync::Mutex::new(None), - 1270
permission_lease: std::sync::Mutex::new(CancellationToken::new()), - 1271
mode_runtime_pinned: std::sync::atomic::AtomicBool::new(false), - 1272
approval_mode_override: std::sync::Mutex::new(None), - 1273
rules_override: std::sync::Mutex::new(None), - 1274
sandbox_backend_override: std::sync::Mutex::new(None),
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.