- 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), - 1275
agent_network: Arc::new(std::sync::Mutex::new( - 1276
agent_network::AgentNetworkBroker::default(), - 1277
)), - 1278
task_sandboxes: std::sync::Mutex::new(HashMap::new()), - 1279
theme_override: std::sync::Mutex::new(None), - 1280
voice_override: std::sync::Mutex::new(None), - 1281
theme_runtime_pinned: std::sync::atomic::AtomicBool::new(false), - 1282
memory_search_enabled_override: std::sync::Mutex::new(None), - 1283
memory_write_enabled_override: std::sync::Mutex::new(None), - 1284
memory_reflection_override: std::sync::Mutex::new(None), - 1285
memory_skill_proposals_override: std::sync::Mutex::new(None), - 1286
workers_override: std::sync::Mutex::new(None), - 1287
work_override: std::sync::Mutex::new(None), - 1288
plugins_override: std::sync::Mutex::new(None), - 1289
finops_max_run_usd_override: std::sync::Mutex::new(None), - 1290
finops_max_day_usd_override: std::sync::Mutex::new(None), - 1291
provider_instance: std::sync::Mutex::new(None), - 1292
sessions_home_override: std::sync::Mutex::new(None), - 1293
breaker, - 1294
workers: Arc::new(vak_agent::WorkerRegistry::new()), - 1295
trust_project_config, - 1296
extra_allow: std::sync::Mutex::new(extra_allow), - 1297
user_env_override: std::sync::Mutex::new(None), - 1298
tool_worker_exe: std::sync::Mutex::new( - 1299
std::env::current_exe() - 1300
.unwrap_or_else(|_| PathBuf::from("__vak_tool_worker_unavailable__")), - 1301
), - 1302
models_cache: std::sync::Mutex::new(HashMap::new()), - 1303
model_context_cache: std::sync::Mutex::new(HashMap::new()), - 1304
model_context_refreshing: std::sync::Mutex::new(std::collections::HashSet::new()), - 1305
capacity_cache: std::sync::Mutex::new(HashMap::new()), - 1306
capacity_probes: Arc::new(std::sync::Mutex::new(HashMap::new())), - 1307
capacity_probe_attempted: Arc::new(std::sync::Mutex::new(HashMap::new())), - 1308
mcp_override: std::sync::Mutex::new(None), - 1309
mcp_runtime_pinned: std::sync::atomic::AtomicBool::new(false), - 1310
hooks_override: std::sync::Mutex::new(None), - 1311
hooks_runtime_pinned: std::sync::atomic::AtomicBool::new(false), - 1312
capabilities_override: std::sync::Mutex::new(None), - 1313
channel_policy: std::sync::Mutex::new(None), - 1314
web_fetch_override: std::sync::Mutex::new(None), - 1315
browse_override: std::sync::Mutex::new(None), - 1316
commitment_override: std::sync::Mutex::new(None), - 1317
beliefs: Arc::new(routing::BeliefState::new()), - 1318
spend_gates: std::sync::Mutex::new(HashMap::new()), - 1319
day_budget: Arc::new(std::sync::Mutex::new(finops::DayBudget::new())), - 1320
mcp_cache: std::sync::Mutex::new(None), - 1321
capability_registry: std::sync::OnceLock::new(), - 1322
capability_shutdown: std::sync::Mutex::new(None), - 1323
}), - 1324
}) - 1325
} - 1326
- 1327
pub fn config(&self) -> &vak_config::Config { - 1328
&self.inner.config - 1329
} - 1330
- 1331
pub fn effective_work(&self) -> vak_config::WorkResolved { - 1332
Self::read_override(&self.inner.work_override) - 1333
.unwrap_or_else(|| self.inner.config.work.clone()) - 1334
} - 1335
- 1336
pub fn apply_persisted_work(&self, work: vak_config::WorkResolved) { - 1337
Self::write_override(&self.inner.work_override, Some(work)); - 1338
} - 1339
- 1340
/// Effective live voice runtime settings. Persisted updates are applied - 1341
/// without restarting the daemon; existing sessions keep their limits. - 1342
pub fn effective_voice(&self) -> vak_config::VoiceSettings { - 1343
Self::read_override(&self.inner.voice_override) - 1344
.unwrap_or_else(|| self.inner.config.voice.clone()) - 1345
} - 1346
- 1347
pub fn apply_persisted_voice(&self, voice: vak_config::VoiceSettings) { - 1348
Self::write_override(&self.inner.voice_override, Some(voice)); - 1349
} - 1350
- 1351
pub fn effective_plugins(&self) -> vak_config::PluginResolved { - 1352
Self::read_override(&self.inner.plugins_override).unwrap_or_else(|| { - 1353
let resolved: &vak_config::PluginResolved = &self.inner.config.plugins; - 1354
resolved.clone() - 1355
}) - 1356
} - 1357
- 1358
pub fn apply_persisted_plugins(&self, plugins: vak_config::PluginResolved) { - 1359
Self::write_override(&self.inner.plugins_override, Some(plugins)); - 1360
} - 1361
- 1362
/// Session-scoped routing beliefs (Phase R): domain-weighted doubt - 1363
/// that demotes flaky legs until one success clears them. - 1364
pub fn beliefs(&self) -> &Arc<routing::BeliefState> { - 1365
&self.inner.beliefs - 1366
} - 1367
- 1368
/// Read a session-scoped override, releasing the lock before returning. - 1369
/// - 1370
/// Every `Option`-shaped override on `Inner` is read through here. The - 1371
/// idiom this replaces — `if let Ok(g) = self.inner.slot.lock() && …` - 1372
/// — keeps the guard alive for the whole body, so a `self.` call - 1373
/// inside that body which locks the same slot deadlocks the thread: - 1374
/// `std::sync::Mutex` is not reentrant. `cache_home` did exactly that - 1375
/// against `sessions_home`, wedging any process that set the override. - 1376
/// Cloning out under a minimal scope makes the hazard unreachable. - 1377
fn read_override<T: Clone>(slot: &std::sync::Mutex<Option<T>>) -> Option<T> { - 1378
slot.lock() - 1379
.unwrap_or_else(std::sync::PoisonError::into_inner) - 1380
.clone() - 1381
} - 1382
- 1383
/// Set a session-scoped override. Poisoning is recovered rather than - 1384
/// propagated: an override is a preference, and losing one to an - 1385
/// unrelated panic elsewhere should not take down this call. - 1386
fn write_override<T>(slot: &std::sync::Mutex<Option<T>>, value: Option<T>) { - 1387
*slot - 1388
.lock() - 1389
.unwrap_or_else(std::sync::PoisonError::into_inner) = value; - 1390
} - 1391
- 1392
pub fn set_model(&self, model: String) { - 1393
let route = self.effective_route(); - 1394
self.set_route(route.provider, model); - 1395
} - 1396
- 1397
pub fn effective_model(&self) -> String { - 1398
self.effective_route().model - 1399
} - 1400
- 1401
pub fn model_source(&self) -> String { - 1402
self.effective_route().model_source - 1403
} - 1404
- 1405
pub fn set_provider(&self, provider: String) { - 1406
let route = self.effective_route(); - 1407
self.set_route(provider, route.model); - 1408
} - 1409
- 1410
/// Apply an explicit scoped route override atomically. CLI flags, task - 1411
/// pins, heartbeat pins, and test seams use this path; persisted admin - 1412
/// changes use `apply_persisted_route` instead. - 1413
pub fn set_route(&self, provider: String, model: String) { - 1414
self.replace_route(route_selection( - 1415
provider, - 1416
model, - 1417
"runtime_override", - 1418
"runtime_override", - 1419
true, - 1420
)); - 1421
} - 1422
- 1423
/// Re-read the layered provider/model defaults. Runtime-pinned cores are - 1424
/// deliberately excluded so a global admin edit cannot rewrite a scoped - 1425
/// CLI, task, heartbeat, or worker contract. - 1426
pub fn refresh_persisted_route(&self) -> Result<RouteSelection, CoreError> { - 1427
let current = self.effective_route(); - 1428
if current.runtime_pinned { - 1429
return Ok(current); - 1430
} - 1431
let config = vak_config::load_with_trust(&self.inner.cwd, self.inner.trust_project_config)?; - 1432
let route = route_from_config(&self.inner.cwd, &config, false); - 1433
if route != current { - 1434
self.replace_route(route.clone()); - 1435
} - 1436
Ok(route) - 1437
} - 1438
- 1439
/// Hot-apply a route that has already been committed atomically to the - 1440
/// workspace config by the authenticated administration surface. - 1441
pub fn apply_persisted_route(&self, provider: String, model: String) { - 1442
let provider_source = route_source(&self.inner.cwd, "provider"); - 1443
let model_source = route_source(&self.inner.cwd, "model"); - 1444
self.replace_route(route_selection( - 1445
provider, - 1446
model, - 1447
&provider_source, - 1448
&model_source, - 1449
false, - 1450
)); - 1451
} - 1452
- 1453
pub fn effective_route(&self) -> RouteSelection { - 1454
self.refresh_route_if_stale(); - 1455
self.inner - 1456
.route - 1457
.lock() - 1458
.unwrap_or_else(std::sync::PoisonError::into_inner) - 1459
.clone() - 1460
} - 1461
- 1462
/// Cheap (stat-only) check for whether the config files the cached - 1463
/// route was derived from have changed since — e.g. another process - 1464
/// ran `vak setup` while this `Core` was already resolved and pooled. - 1465
/// Only pays for a full re-parse + re-derivation when the fingerprint - 1466
/// actually moved (docs/design/44-shared-config.md, "Liveness"). - 1467
fn refresh_route_if_stale(&self) { - 1468
let current_fp = vak_config::config_fingerprint(&self.inner.cwd); - 1469
{ - 1470
let mut last_fp = self - 1471
.inner - 1472
.route_fingerprint - 1473
.lock() - 1474
.unwrap_or_else(std::sync::PoisonError::into_inner); - 1475
if *last_fp == current_fp { - 1476
return; - 1477
} - 1478
*last_fp = current_fp; - 1479
} - 1480
let _ = self.refresh_persisted_route(); - 1481
} - 1482
- 1483
fn replace_route(&self, route: RouteSelection) { - 1484
let provider = route.provider.clone(); - 1485
*self - 1486
.inner - 1487
.route - 1488
.lock() - 1489
.unwrap_or_else(std::sync::PoisonError::into_inner) = route; - 1490
if let Ok(mut injected) = self.inner.provider_instance.lock() - 1491
&& injected - 1492
.as_ref() - 1493
.is_some_and(|current| current.name() != provider) - 1494
{ - 1495
*injected = None; - 1496
} - 1497
} - 1498
- 1499
pub fn effective_provider(&self) -> String { - 1500
self.effective_route().provider - 1501
} - 1502
- 1503
pub fn provider_source(&self) -> String { - 1504
self.effective_route().provider_source - 1505
} - 1506
- 1507
/// Whether project-owned privileged configuration was admitted when this - 1508
/// core was created. Presentation files use the same trust boundary. - 1509
pub fn project_config_trusted(&self) -> bool { - 1510
self.inner.trust_project_config - 1511
} - 1512
- 1513
pub fn provider_names(&self) -> Vec<String> { - 1514
self.inner.registry.names() - 1515
} - 1516
- 1517
pub fn set_max_turns(&self, max_turns: usize) { - 1518
self.inner - 1519
.max_turns_runtime_pinned - 1520
.store(true, std::sync::atomic::Ordering::Release); - 1521
if let Ok(mut c) = self.inner.max_turns_override.lock() { - 1522
*c = Some(max_turns); - 1523
} - 1524
} - 1525
- 1526
pub fn apply_persisted_max_turns(&self, max_turns: usize) { - 1527
Self::write_override(&self.inner.max_turns_override, Some(max_turns)); - 1528
self.inner - 1529
.max_turns_runtime_pinned - 1530
.store(false, std::sync::atomic::Ordering::Release); - 1531
} - 1532
- 1533
pub fn set_tool_worker_exe(&self, executable: PathBuf) { - 1534
if let Ok(mut worker) = self.inner.tool_worker_exe.lock() { - 1535
*worker = executable; - 1536
} - 1537
} - 1538
- 1539
/// Carry the pinned, version-matched worker into an isolated child Core. - 1540
pub fn tool_worker_exe(&self) -> PathBuf { - 1541
self.inner - 1542
.tool_worker_exe - 1543
.lock() - 1544
.map(|worker| worker.clone()) - 1545
.unwrap_or_else(|_| PathBuf::from("__vak_tool_worker_unavailable__")) - 1546
} - 1547
- 1548
pub fn agent_tools(&self) -> Vec<Arc<dyn vak_tools::Tool>> { - 1549
let worker = self - 1550
.inner - 1551
.tool_worker_exe - 1552
.lock() - 1553
.ok() - 1554
.map(|worker| worker.clone()) - 1555
.unwrap_or_else(|| PathBuf::from("__vak_tool_worker_unavailable__")); - 1556
let tools = vak_tools::brokered_tools(worker, &self.new_documents); - 1557
self.filter_builtin_tools(tools) - 1558
} - 1559
- 1560
/// Prepare the current effective capability surface for a standalone - 1561
/// flow. The prompt and both tool views are derived together so callers - 1562
/// cannot accidentally advertise one surface while executing another. - 1563
pub async fn prepare_turn(&self) -> PreparedTurn { - 1564
let descriptors = self.admitted_capabilities().await; - 1565
let admitted: std::collections::BTreeSet<String> = descriptors - 1566
.iter() - 1567
.filter(|descriptor| descriptor.kind == CapabilityKind::Tool) - 1568
.map(|descriptor| descriptor.name.clone()) - 1569
.collect(); - 1570
let tools: Vec<_> = self - 1571
.agent_tools() - 1572
.into_iter() - 1573
.filter(|tool| admitted.contains(tool.name())) - 1574
.collect(); - 1575
let read_only_tools: Vec<_> = self - 1576
.agent_read_only_tools() - 1577
.into_iter() - 1578
.filter(|tool| admitted.contains(tool.name())) - 1579
.collect(); - 1580
PreparedTurn::from_parts( - 1581
self.resolve_prompt(&descriptors).text, - 1582
tools, - 1583
read_only_tools, - 1584
) - 1585
} - 1586
- 1587
pub fn agent_read_only_tools(&self) -> Vec<Arc<dyn vak_tools::Tool>> { - 1588
let worker = self - 1589
.inner - 1590
.tool_worker_exe - 1591
.lock() - 1592
.ok() - 1593
.map(|worker| worker.clone()) - 1594
.unwrap_or_else(|| PathBuf::from("__vak_tool_worker_unavailable__")); - 1595
let tools = vak_tools::brokered_read_only_tools(worker); - 1596
self.filter_builtin_tools(tools) - 1597
} - 1598
- 1599
fn filter_builtin_tools( - 1600
&self, - 1601
tools: Vec<Arc<dyn vak_tools::Tool>>, - 1602
) -> Vec<Arc<dyn vak_tools::Tool>> { - 1603
let Some(policy) = self.channel_policy() else { - 1604
return tools; - 1605
};
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.