- 2606
if let Some(run) = max_run_usd { - 2607
Self::write_override(&self.inner.finops_max_run_usd_override, Some(run)); - 2608
} - 2609
if let Some(day) = max_day_usd { - 2610
Self::write_override(&self.inner.finops_max_day_usd_override, Some(day)); - 2611
} - 2612
} - 2613
- 2614
/// Refresh every non-security persisted preference. Permission mode is - 2615
/// returned to the server control plane so it can revoke in-flight - 2616
/// capabilities before applying a changed value. - 2617
pub fn refresh_persisted_preferences(&self) -> Result<vak_config::PermissionMode, CoreError> { - 2618
let config = vak_config::load_with_trust(&self.inner.cwd, self.inner.trust_project_config)?; - 2619
let current_route = self.effective_route(); - 2620
if !current_route.runtime_pinned { - 2621
let route = route_from_config(&self.inner.cwd, &config, false); - 2622
if route != current_route { - 2623
self.replace_route(route); - 2624
} - 2625
} - 2626
if !self - 2627
.inner - 2628
.max_turns_runtime_pinned - 2629
.load(std::sync::atomic::Ordering::Acquire) - 2630
{ - 2631
self.apply_persisted_max_turns(config.max_turns); - 2632
} - 2633
Self::write_override( - 2634
&self.inner.evidence_max_age_override, - 2635
Some(config.intent.evidence_max_age_secs), - 2636
); - 2637
if !self - 2638
.inner - 2639
.theme_runtime_pinned - 2640
.load(std::sync::atomic::Ordering::Acquire) - 2641
{ - 2642
self.apply_persisted_theme(config.ui.theme.clone()); - 2643
} - 2644
if !self - 2645
.inner - 2646
.mcp_runtime_pinned - 2647
.load(std::sync::atomic::Ordering::Acquire) - 2648
{ - 2649
self.apply_persisted_mcp_servers(config.mcp.clone()); - 2650
} - 2651
if !self - 2652
.inner - 2653
.hooks_runtime_pinned - 2654
.load(std::sync::atomic::Ordering::Acquire) - 2655
{ - 2656
self.apply_persisted_hooks(config.hooks.clone()); - 2657
} - 2658
self.apply_persisted_capability_inheritance(config.capabilities.clone()); - 2659
self.apply_persisted_memory( - 2660
config.memory.search_enabled, - 2661
config.memory.write_enabled, - 2662
config.memory.reflection, - 2663
config.memory.skill_proposals, - 2664
); - 2665
self.apply_persisted_workers(config.workers); - 2666
self.apply_persisted_finops_caps( - 2667
Some(config.finops.max_run_usd), - 2668
Some(config.finops.max_day_usd), - 2669
); - 2670
self.apply_persisted_work(config.work.clone()); - 2671
self.apply_persisted_tools(config.tools.web_fetch, config.tools.browse); - 2672
self.apply_persisted_commitment(config.commitment.enabled); - 2673
self.apply_persisted_approval_mode(config.approval_mode); - 2674
self.apply_persisted_plugins(config.plugins.clone()); - 2675
Ok(config.permission_mode) - 2676
} - 2677
- 2678
pub fn effective_evidence_max_age_secs(&self) -> i64 { - 2679
Self::read_override(&self.inner.evidence_max_age_override) - 2680
.unwrap_or(self.inner.config.intent.evidence_max_age_secs) - 2681
} - 2682
- 2683
/// Derive a scoped allow rule from a call that was just approved, and - 2684
/// persist it — the "always allow this" half of an approval. - 2685
/// - 2686
/// Round-tripped before it is written: the derived spec must parse AND - 2687
/// must match the very call it came from. A rule that does not cover its - 2688
/// own triggering call would silently grant something else, and a rule - 2689
/// nobody can trace back to a decision is worse than no rule. - 2690
/// - 2691
/// Returns the spec that was stored, so a surface can show the operator - 2692
/// exactly what they just granted rather than "remembered". - 2693
pub fn learn_from_call( - 2694
&self, - 2695
tool: &str, - 2696
args: &serde_json::Value, - 2697
) -> Result<String, CoreError> { - 2698
let Some(spec) = scoped_allow_rule(tool, args) else { - 2699
return Err(CoreError::Config(vak_config::ConfigError::Read { - 2700
path: std::path::PathBuf::from(PERMISSIONS_LOCAL_FILE), - 2701
source: std::io::Error::other(format!( - 2702
"'{tool}' cannot be narrowed to a safe rule from this call; \ - 2703
approve it each time instead" - 2704
)), - 2705
})); - 2706
}; - 2707
let rule = vak_permission::Rule::parse(&spec).map_err(CoreError::Rule)?; - 2708
if !rule.matches(tool, args) { - 2709
return Err(CoreError::Config(vak_config::ConfigError::Read { - 2710
path: std::path::PathBuf::from(PERMISSIONS_LOCAL_FILE), - 2711
source: std::io::Error::other(format!( - 2712
"derived rule '{spec}' does not match the call it came from" - 2713
)), - 2714
})); - 2715
} - 2716
self.learn_allow_rule(&spec)?; - 2717
Ok(spec) - 2718
} - 2719
- 2720
/// Persists a learned allow rule to `.vak/permissions.local.toml` - 2721
/// (and this process's in-memory engine inputs). Trusted workspaces only: - 2722
/// an untrusted session must not be able to write grant files. Rules are - 2723
/// severity-aggregated by the engine, so a learned Allow can never - 2724
/// shadow an explicit Deny from any config layer. - 2725
pub fn learn_allow_rule(&self, spec: &str) -> Result<(), CoreError> { - 2726
vak_permission::Rule::parse(spec).map_err(CoreError::Rule)?; - 2727
if !self.inner.trust_project_config { - 2728
return Err(CoreError::Config(vak_config::ConfigError::Read { - 2729
path: std::path::PathBuf::from(PERMISSIONS_LOCAL_FILE), - 2730
source: std::io::Error::other( - 2731
"untrusted workspace: refusing to persist permission rules", - 2732
), - 2733
})); - 2734
} - 2735
let path = self.inner.cwd.join(PERMISSIONS_LOCAL_FILE); - 2736
vak_config::file_update::update_file(&path, |current| { - 2737
let mut document = match current { - 2738
Some(text) => toml::from_str::<toml::Table>(text).map_err(|source| { - 2739
CoreError::Config(vak_config::ConfigError::Parse { - 2740
path: path.clone(), - 2741
source, - 2742
}) - 2743
})?, - 2744
None => toml::Table::new(), - 2745
}; - 2746
let allow = document - 2747
.entry("allow") - 2748
.or_insert_with(|| toml::Value::Array(Vec::new())); - 2749
let Some(allow) = allow.as_array_mut() else { - 2750
return Err(CoreError::Config(vak_config::ConfigError::Read { - 2751
path: path.clone(), - 2752
source: std::io::Error::other("`allow` is not an array"), - 2753
})); - 2754
}; - 2755
if !allow.iter().any(|rule| rule.as_str() == Some(spec)) { - 2756
allow.push(toml::Value::String(spec.to_string())); - 2757
} - 2758
let rules: Vec<String> = allow - 2759
.iter() - 2760
.filter_map(|rule| rule.as_str().map(ToOwned::to_owned)) - 2761
.collect(); - 2762
let body = toml::to_string_pretty(&document).map_err(|error| { - 2763
CoreError::Session(vak_session::SessionError::Io(std::io::Error::other( - 2764
error.to_string(), - 2765
))) - 2766
})?; - 2767
// Published while the file lock is held, so the engine inputs - 2768
// never fall behind a rule another approval just wrote. - 2769
if let Ok(mut extra) = self.inner.extra_allow.lock() { - 2770
*extra = rules; - 2771
} - 2772
Ok(( - 2773
Some(format!( - 2774
"# Learned 'always allow' rules — written when you press [p] on an approval.\n{body}" - 2775
)), - 2776
(), - 2777
)) - 2778
}) - 2779
.map_err(|error| match error { - 2780
vak_config::file_update::UpdateError::Io { source, .. } => { - 2781
CoreError::Session(vak_session::SessionError::Io(source)) - 2782
} - 2783
vak_config::file_update::UpdateError::Edit(error) => error, - 2784
}) - 2785
} - 2786
- 2787
pub fn extra_allow_snapshot(&self) -> Vec<String> { - 2788
self.inner - 2789
.extra_allow - 2790
.lock() - 2791
.ok() - 2792
.map(|e| e.clone()) - 2793
.unwrap_or_default() - 2794
} - 2795
- 2796
pub fn effective_theme(&self) -> String { - 2797
Self::read_override(&self.inner.theme_override) - 2798
.unwrap_or_else(|| self.inner.config.ui.theme.clone()) - 2799
} - 2800
- 2801
pub fn effective_permission_mode(&self) -> vak_config::PermissionMode { - 2802
Self::read_override(&self.inner.mode_override).unwrap_or(self.inner.config.permission_mode) - 2803
} - 2804
- 2805
pub fn cwd(&self) -> &PathBuf { - 2806
&self.inner.cwd - 2807
} - 2808
- 2809
/// Bind this turn to the chat it's replying into, as `<surface>:<chat>` - 2810
/// (the same shape `deliver_to` already uses everywhere). Cheap: an - 2811
/// `Arc` bump plus one `String`, so callers can clone-and-set per - 2812
/// inbound message without touching the shared workspace state the - 2813
/// `Arc<CoreInner>` carries. - 2814
pub fn with_default_deliver_to(mut self, target: Option<String>) -> Self { - 2815
self.default_deliver_to = target; - 2816
self - 2817
} - 2818
- 2819
/// Name the surface this turn runs on, so the system prompt can say where - 2820
/// the reply will be read. Cheap in the same way - 2821
/// [`Core::with_default_deliver_to`] is — an `Arc` bump and one small - 2822
/// value — so the gateway can clone-and-stamp per inbound message. - 2823
pub fn with_surface(mut self, surface: Surface) -> Self { - 2824
self.surface = surface; - 2825
self - 2826
} - 2827
- 2828
/// Use only for a child Core whose cwd is a freshly prepared task copy. - 2829
/// It caps the turn at workspace-write and removes host temp write grants - 2830
/// from the native worker sandbox. It never changes shared Core state. - 2831
pub fn with_task_copy_boundary(mut self) -> Self { - 2832
self.task_copy_boundary = true; - 2833
self - 2834
} - 2835
- 2836
/// Marks Office files in this task copy as new to the workspace it was - 2837
/// made from (see the `new_documents` field). - 2838
pub fn with_new_documents(mut self, paths: Vec<String>) -> Self { - 2839
self.new_documents = Arc::new(paths); - 2840
self - 2841
} - 2842
- 2843
pub fn surface(&self) -> &Surface { - 2844
&self.surface - 2845
} - 2846
- 2847
/// Stamp this turn's answerability from the approver that will actually - 2848
/// serve it. Cheap in the same way [`Core::with_surface`] is, so a host - 2849
/// can clone-and-set per inbound message. - 2850
/// - 2851
/// Takes the approver rather than a bare `bool` deliberately. The two - 2852
/// used to be independent — a host set the flag by hand and installed an - 2853
/// approver separately, with a comment asking them to agree — and the - 2854
/// scheduler proved what that costs: it installed an approver nobody was - 2855
/// subscribed to while leaving the flag at its `true` default, so every - 2856
/// unattended routine was told a gated capability was usable and then - 2857
/// blocked on a gate no one would ever answer. Deriving the flag from - 2858
/// the object makes that disagreement unrepresentable. - 2859
pub fn with_approver(mut self, approver: &dyn vak_agent::Approver) -> Self { - 2860
self.approver_answerable = approver.answerable(); - 2861
self - 2862
} - 2863
- 2864
/// The same stamp for a host that has not constructed its approver yet - 2865
/// but already knows which one it will build — the gateway, whose - 2866
/// `GatewayApprover` needs a session id that does not exist until the - 2867
/// turn starts, and which must freeze the prompt before then. - 2868
/// - 2869
/// Prefer [`Core::with_approver`]. Anything set here is reconciled - 2870
/// against the real approver when the run starts - 2871
/// ([`Core::reconcile_answerability`]), so a wrong value is corrected - 2872
/// and recorded rather than silently believed. - 2873
pub fn with_approver_answerable(mut self, answerable: bool) -> Self { - 2874
self.approver_answerable = answerable; - 2875
self - 2876
} - 2877
- 2878
pub fn approver_answerable(&self) -> bool { - 2879
self.approver_answerable - 2880
} - 2881
- 2882
/// Last line of defence for the stamp above: compare what this turn was - 2883
/// told about its approver against the approver it actually got, and - 2884
/// take the approver's word. - 2885
/// - 2886
/// The prompt is already frozen by the time a run starts, so a - 2887
/// disagreement cannot be un-said to the model — but it can be recorded, - 2888
/// and it can be corrected for everything computed at dispatch (the - 2889
/// registry filter and the audit standings). An operator reading - 2890
/// `answerability_mismatch` in the security log is reading a real defect - 2891
/// in a hosting surface, not a configuration problem. - 2892
fn reconcile_answerability(&mut self, approver: Option<&Arc<dyn vak_agent::Approver>>) { - 2893
let actual = approver.map(|a| a.answerable()).unwrap_or(false); - 2894
if actual == self.approver_answerable { - 2895
return; - 2896
} - 2897
security_events::record( - 2898
&self.sessions_home(), - 2899
security_events::EventKind::ConfigChange, - 2900
"answerability_mismatch", - 2901
&format!( - 2902
"surface={} stamped={} installed_approver={}; using the approver", - 2903
self.surface.slug(), - 2904
self.approver_answerable, - 2905
actual - 2906
), - 2907
None, - 2908
); - 2909
self.approver_answerable = actual; - 2910
} - 2911
- 2912
/// This turn's capability standings: what the composed policy actually - 2913
/// permits, as opposed to what configuration declares. One computation, - 2914
/// read by the prompt, the tool registry, `doctor`, and the audit log, - 2915
/// so those four can never disagree about whether a capability works. - 2916
pub fn capability_standings(&self) -> Vec<reach::Standing> { - 2917
let Ok(engine) = self.build_permission_engine(&self.channel_permission_rules()) else { - 2918
return Vec::new(); - 2919
}; - 2920
let mode = match self.effective_permission_mode() { - 2921
vak_config::PermissionMode::ReadOnly => vak_permission::Mode::ReadOnly, - 2922
vak_config::PermissionMode::WorkspaceWrite => vak_permission::Mode::WorkspaceWrite, - 2923
vak_config::PermissionMode::FullAccess => vak_permission::Mode::FullAccess, - 2924
}; - 2925
let approval_mode = match self.effective_approval_mode() { - 2926
vak_config::ApprovalMode::Ask => vak_agent::ApprovalMode::Ask, - 2927
vak_config::ApprovalMode::ApproveSafe => vak_agent::ApprovalMode::ApproveSafe, - 2928
vak_config::ApprovalMode::AutoApprove => vak_agent::ApprovalMode::AutoApprove, - 2929
}; - 2930
let mut servers: Vec<String> = self.effective_mcp().servers.into_keys().collect(); - 2931
servers.sort(); - 2932
let registered = self.tool_names(); - 2933
let network: Vec<String> = NETWORK_TOOLS - 2934
.iter() - 2935
.filter(|tool| registered.iter().any(|name| name == *tool)) - 2936
.map(|tool| (*tool).to_string()) - 2937
.collect(); - 2938
let skills: Vec<String> = self.skills().into_iter().map(|s| s.name).collect(); - 2939
reach::standings(&reach::Probe { - 2940
engine: &engine, - 2941
mode, - 2942
approval_mode, - 2943
sandboxed: self.build_sandbox().is_some(), - 2944
cwd: &self.inner.cwd, - 2945
approver_answerable: self.approver_answerable, - 2946
mcp_servers: &servers, - 2947
network_tools: &network, - 2948
skills: &skills, - 2949
}) - 2950
} - 2951
- 2952
/// Select a named `prompts/agents/<name>` layer for this turn. - 2953
pub fn with_prompt_role(mut self, role: Option<String>) -> Self { - 2954
self.prompt_role = role.filter(|r| !r.trim().is_empty()); - 2955
self - 2956
} - 2957
- 2958
pub fn prompt_role(&self) -> Option<&str> { - 2959
self.prompt_role.as_deref() - 2960
} - 2961
- 2962
pub fn with_agent_identity(mut self, agent: Option<vak_session::types::AgentIdentity>) -> Self { - 2963
// `None` means the built-in Agent at every new admission. Keeping a - 2964
// concrete identity here prevents background, gateway, and resumed - 2965
// sessions from silently reverting to the old missing-header state. - 2966
self.agent_identity = Some(agent.unwrap_or_else(vak_agent_identity)); - 2967
self - 2968
} - 2969
- 2970
/// Bind a Core clone to one authorized conversation before admission. - 2971
/// This is deliberately clone-local: pooled workspace state must never - 2972
/// acquire one chat's audience or delivery destination. - 2973
pub fn with_conversation_context( - 2974
mut self, - 2975
context: Option<vak_session::types::ConversationContext>, - 2976
) -> Self { - 2977
self.conversation_context = context; - 2978
self - 2979
} - 2980
- 2981
pub fn conversation_context(&self) -> Option<&vak_session::types::ConversationContext> { - 2982
self.conversation_context.as_ref() - 2983
} - 2984
- 2985
pub fn agent_identity(&self) -> Option<&vak_session::types::AgentIdentity> { - 2986
self.agent_identity.as_ref() - 2987
} - 2988
- 2989
/// Attach caller-owned prompt layers (the gateway's bot and chat tiers). - 2990
/// Restrictive by construction: `resolve` folds guardrails in and lets a - 2991
/// narrower identity win, and neither can reach the code-owned blocks. - 2992
pub fn with_prompt_overlays(mut self, overlays: Vec<prompts::LayerInput>) -> Self { - 2993
self.prompt_overlays = Arc::new(overlays); - 2994
self - 2995
} - 2996
- 2997
/// Reopens an existing session ledger for resumed runs. - 2998
pub async fn open_session(&self, session_id: &str) -> Result<SessionLog, CoreError> { - 2999
self.refuse_trashed(session_id)?; - 3000
let path = vak_session::SessionPath::new_session_file( - 3001
&self.sessions_home(), - 3002
&self.inner.cwd, - 3003
session_id, - 3004
); - 3005
Ok(SessionLog::open(path)?) - 3006
} - 3007
- 3008
/// Opens an existing session ledger in read-only mode without acquiring an exclusive write lock. - 3009
pub async fn open_session_read_only(&self, session_id: &str) -> Result<SessionLog, CoreError> { - 3010
self.refuse_trashed(session_id)?; - 3011
let path = vak_session::SessionPath::new_session_file( - 3012
&self.sessions_home(), - 3013
&self.inner.cwd, - 3014
session_id, - 3015
); - 3016
Ok(SessionLog::open_read_only(path)?) - 3017
} - 3018
- 3019
/// A trashed session is hidden everywhere, so nothing reopens it: a - 3020
/// resume, a channel binding or an Agent conversation starts afresh. - 3021
fn refuse_trashed(&self, session_id: &str) -> Result<(), CoreError> { - 3022
if trash::is_trashed(&self.shared_data_home(), session_id) { - 3023
return Err(CoreError::Session(vak_session::SessionError::Io( - 3024
std::io::Error::new( - 3025
std::io::ErrorKind::NotFound, - 3026
format!("session is in the trash: {session_id}"), - 3027
), - 3028
))); - 3029
} - 3030
Ok(()) - 3031
} - 3032
- 3033
/// SDK seam: relocate session storage (tests, embedded runtimes). - 3034
pub fn set_sessions_home(&self, path: PathBuf) { - 3035
Self::write_override(&self.inner.sessions_home_override, Some(path)); - 3036
} - 3037
- 3038
/// Redirects the user-level secret store (tests, portable installs). - 3039
pub fn set_user_env_path(&self, path: PathBuf) { - 3040
Self::write_override(&self.inner.user_env_override, Some(path)); - 3041
} - 3042
- 3043
fn user_env_file(&self) -> PathBuf { - 3044
Self::read_override(&self.inner.user_env_override) - 3045
.or_else(vak_config::user_env_path) - 3046
.unwrap_or_else(|| self.shared_data_home().join(".env")) - 3047
} - 3048
- 3049
/// The directly-injected provider, if any (tests, embedded runtimes), - 3050
/// so a freshly-constructed `Core` for another workspace/agent can be - 3051
/// handed the same injected provider rather than falling through to - 3052
/// real network resolution. - 3053
pub fn provider_instance_override(&self) -> Option<Arc<dyn Provider>> { - 3054
self.inner - 3055
.provider_instance - 3056
.lock() - 3057
.ok() - 3058
.and_then(|p| p.clone()) - 3059
} - 3060
- 3061
/// SDK seam: inject a provider directly (tests, embedded runtimes). - 3062
pub fn set_provider_instance(&self, provider: Arc<dyn Provider>) { - 3063
if let Ok(mut p) = self.inner.provider_instance.lock() { - 3064
*p = Some(provider); - 3065
} - 3066
} - 3067
- 3068
pub fn sessions_home(&self) -> PathBuf { - 3069
let base = Self::read_override(&self.inner.sessions_home_override) - 3070
.unwrap_or_else(|| self.inner.sessions_home.clone()); - 3071
if let Some(agent) = self.agent_identity.as_ref() { - 3072
let home = vak_config::paths::agent_home_at(&base, &agent.id); - 3073
let _ = std::fs::create_dir_all(&home); - 3074
return home; - 3075
} - 3076
base - 3077
} - 3078
- 3079
/// Root shared data home across all agents (gateway allowlist, scheduler tasks, FinOps ledger). - 3080
pub fn shared_data_home(&self) -> PathBuf { - 3081
Self::read_override(&self.inner.sessions_home_override) - 3082
.unwrap_or_else(|| self.inner.sessions_home.clone()) - 3083
} - 3084
- 3085
/// Rebuildable-artifact directory (SQLite FTS index + WAL sidecars). - 3086
/// Canonical layout (doc 32): Library/Caches on macOS, XDG cache on - 3087
/// Linux — deleting it must always be safe. - 3088
pub fn cache_home(&self) -> PathBuf { - 3089
// Overridden homes are self-contained sandboxes, so the cache - 3090
// lives inside them. Resolved from the cloned override rather - 3091
// than by calling `sessions_home()` under the guard, which - 3092
// re-locked the same non-reentrant mutex and hung the thread. - 3093
match Self::read_override(&self.inner.sessions_home_override) { - 3094
Some(home) => home.join("cache"), - 3095
None => vak_config::paths::cache_home(), - 3096
} - 3097
} - 3098
- 3099
pub fn system_prompt(&self) -> String { - 3100
self.system_prompt_for_capabilities(&self.capability_descriptors()) - 3101
} - 3102
- 3103
fn system_prompt_for_capabilities(&self, capabilities: &[CapabilityDescriptor]) -> String { - 3104
self.resolve_prompt(capabilities).text - 3105
} - 3106
- 3107
/// The full composition, with the per-layer descriptors the ledger and - 3108
/// the editing surfaces need (docs/design/45-prompt-layers.md). - 3109
pub fn resolve_prompt(&self, capabilities: &[CapabilityDescriptor]) -> prompts::Resolution { - 3110
self.resolve_prompt_with_stance(capabilities, None) - 3111
} - 3112
- 3113
/// Compose the prompt, optionally informing the runtime of the epistemic cognitive stance. - 3114
pub fn resolve_prompt_with_stance( - 3115
&self, - 3116
capabilities: &[CapabilityDescriptor], - 3117
stance: Option<vak_intent::EpistemicStance>, - 3118
) -> prompts::Resolution { - 3119
self.resolve_prompt_with_stance_parts(capabilities, stance, "") - 3120
.0 - 3121
} - 3122
- 3123
/// The prompt's catalogue of admitted tools a turn may defer: every one - 3124
/// in `admitted` that is not always loaded. Read from the tools' own - 3125
/// declarations, so it matches what `build_tool_surface` can defer. - 3126
fn tool_catalogue_for(&self, admitted: &std::collections::BTreeSet<String>) -> String { - 3127
let tools: Vec<Arc<dyn vak_tools::Tool>> = self - 3128
.scoped_tools(&ToolScope::default()) - 3129
.into_iter() - 3130
.filter(|tool| admitted.contains(tool.name()) && !tool.always_loaded()) - 3131
.collect(); - 3132
let mut entries: Vec<(&str, &str)> = tools - 3133
.iter() - 3134
.map(|tool| (tool.name(), tool.description())) - 3135
.collect(); - 3136
if admitted.contains("task") { - 3137
entries.push(("task", vak_agent::TaskTool::DESCRIPTION)); - 3138
} - 3139
capability::tool_catalogue(entries) - 3140
} - 3141
- 3142
/// Same composition as [`Core::resolve_prompt_with_stance`], additionally - 3143
/// returning the raw temporal and epistemic-stance text that fed - 3144
/// `Resolution::tail` — the per-turn place that assembles - 3145
/// `AgentConfig::tail` needs both pieces under their own tag rather than - 3146
/// the single concatenated blob, and re-deriving them from a second call - 3147
/// would both duplicate the formatting and risk a different clock - 3148
/// instant (docs/design/68-context-engine.md §6). - 3149
fn resolve_prompt_with_stance_parts( - 3150
&self, - 3151
capabilities: &[CapabilityDescriptor], - 3152
stance: Option<vak_intent::EpistemicStance>, - 3153
tool_catalogue: &str, - 3154
) -> (prompts::Resolution, String, String) { - 3155
let server_caps = capabilities - 3156
.iter() - 3157
.filter(|capability| capability.kind == CapabilityKind::McpServer) - 3158
.collect::<Vec<_>>(); - 3159
// No discovery is triggered here, and none is waited for. - 3160
// - 3161
// Admission owns that decision now — `Core::admitted_capabilities` - 3162
// is the single place any surface waits for the registry, so the - 3163
// packet handed to this function is already as resolved as it is - 3164
// going to get. Rendering is pure: same packet in, same prompt out. - 3165
let seed = prompts::seed(APP_VERSION); - 3166
// Advertise only what the composed policy will actually run. A - 3167
// server listed here that dispatch refuses is the exact mismatch - 3168
// this reconciliation exists to remove, so blocked servers move out - 3169
// of the "use these" catalogue and into the standing section, which - 3170
// says why and how to fix it. - 3171
let standings = self.capability_standings(); - 3172
let blocked_servers = reach::blocked_mcp_servers(&standings); - 3173
let server_caps = server_caps - 3174
.into_iter() - 3175
.filter(|capability| !blocked_servers.contains(&capability.name)) - 3176
.collect::<Vec<_>>(); - 3177
let mut standing = reach::prompt_section(&standings); - 3178
let extra_diags: Vec<_> = self - 3179
.capability_diagnostics() - 3180
.into_iter() - 3181
.filter(|d| d.source.is_some()) - 3182
.collect(); - 3183
if !extra_diags.is_empty() { - 3184
if standing.is_empty() { - 3185
standing = String::from( - 3186
"\nConfigured but NOT usable on this turn. These are not in your tool \ - 3187
schemas and calling them will fail. If the request needs one, say so \ - 3188
plainly, name the capability, and give the operator the fix — do not \ - 3189
substitute a different tool and do not answer as though you had the \ - 3190
data:\n", - 3191
); - 3192
} - 3193
for diag in extra_diags { - 3194
standing.push_str(&format!( - 3195
"- {} `{}`: {}.", - 3196
diag.kind, diag.name, diag.reason - 3197
)); - 3198
if !diag.remedy.is_empty() { - 3199
standing.push_str(&format!(" Fix: {}.", diag.remedy)); - 3200
} - 3201
standing.push('\n'); - 3202
} - 3203
} - 3204
let has_bash = capabilities - 3205
.iter() - 3206
.any(|c| c.kind == CapabilityKind::Tool && c.name == "bash"); - 3207
let has_cards = capabilities - 3208
.iter() - 3209
.any(|c| c.kind == CapabilityKind::Tool && presentation_tools::is_card_tool(&c.name)); - 3210
let epistemic_stance = match stance { - 3211
Some(s) => format!( - 3212
"\nEpistemic stance: {}\n- {}", - 3213
s.as_str(), - 3214
s.guideline_prompt() - 3215
), - 3216
None => String::new(), - 3217
}; - 3218
// Each code-owned contract appears only where it is true: cards where - 3219
// card tools are admitted, the sandbox where `bash` is. - 3220
let runtime = prompts::RuntimeSections { - 3221
capability_contract: seed.capability_contract, - 3222
presentation_contract: if has_cards { - 3223
seed.presentation_contract - 3224
} else { - 3225
String::new() - 3226
}, - 3227
sandbox_contract: if has_bash { - 3228
seed.sandbox_contract - 3229
} else { - 3230
String::new() - 3231
}, - 3232
surface: self.surface.prompt_section(), - 3233
skills: skills::prompt_section_from_capabilities(capabilities), - 3234
mcp: mcp_config_section(&server_caps), - 3235
standing, - 3236
epistemic_stance: epistemic_stance.clone(), - 3237
tool_index: tool_catalogue.to_string(), - 3238
temporal: format!( - 3239
"\nTemporal context: current UTC instant {}; local date/time {} (system timezone {}). Treat relative dates as ambiguous unless the user's timezone is known.", - 3240
chrono::Utc::now().to_rfc3339(), - 3241
chrono::Local::now().to_rfc3339(), - 3242
chrono::Local::now().offset() - 3243
), - 3244
}; - 3245
let resolution = prompts::resolve(&self.prompt_layers(seed.content), &runtime); - 3246
let temporal = runtime.temporal; - 3247
(resolution, temporal, epistemic_stance) - 3248
} - 3249
- 3250
/// Whether a session's frozen prompt still matches what this workspace - 3251
/// would resolve today (docs/design/45-prompt-layers.md). - 3252
/// - 3253
/// `None` means "no drift, or no baseline to compare against" — a ledger - 3254
/// written before prompt layers existed carries no descriptors and must - 3255
/// not be reported as having changed. - 3256
pub fn prompt_drift( - 3257
&self, - 3258
contract: &vak_session::FrozenContract, - 3259
) -> Option<prompts::PromptDrift> { - 3260
let current = self.resolve_prompt(&self.capability_descriptors()); - 3261
prompts::drift(&contract.prompt_layers, ¤t.descriptors) - 3262
} - 3263
- 3264
/// Every contributing layer, broadest first. Public so the editing - 3265
/// surfaces can render provenance without re-deriving the chain. - 3266
pub fn prompt_layers(&self, seed: prompts::LayerContent) -> Vec<prompts::LayerInput> { - 3267
let mut layers = vec![prompts::LayerInput::new( - 3268
prompts::PromptLayer::Seed, - 3269
Some("shipped".into()), - 3270
seed, - 3271
)]; - 3272
- 3273
let shared_dir = prompts::layer_dir(&vak_config::paths::default_workspace()); - 3274
let shared = prompts::read_layer(&shared_dir); - 3275
if !shared.is_empty() { - 3276
layers.push(prompts::LayerInput::new( - 3277
prompts::PromptLayer::Shared, - 3278
Some(shared_dir.display().to_string()), - 3279
shared, - 3280
)); - 3281
} - 3282
- 3283
let project_dir = prompts::layer_dir(&self.inner.cwd); - 3284
let mut project = prompts::read_layer(&project_dir); - 3285
// Legacy whole-prompt override. Read as this layer's identity and - 3286
// rules rather than as the entire document, so it can no longer - 3287
// delete the capability contract or the guardrails. - 3288
let legacy = self.inner.cwd.join(".vak/SYSTEM.md"); - 3289
if project.is_empty() - 3290
&& legacy.is_file() - 3291
&& let Ok(text) = std::fs::read_to_string(&legacy) - 3292
&& !text.trim().is_empty() - 3293
{ - 3294
project.identity = Some(text.trim().to_string()); - 3295
} - 3296
// Memory never writes a prompt layer. A note — however it was - 3297
// classified, and whoever wrote it — is recalled through - 3298
// `session_search`, never promoted into guardrails: the model's own - 3299
// `remember` and background consolidation both write notes, so - 3300
// anything else would let an inbound message author a permanent - 3301
// instruction (invariant 28) and grow the cached prefix without bound. - 3302
if !project.is_empty() { - 3303
// The fix for the hole this design opened with: a project layer - 3304
// is untrusted config until the user says otherwise, exactly - 3305
// like `hooks`, `allow`, and `mcp.servers` in - 3306
// `vak_config::load_with_trust`. Its guardrails survive because - 3307
// a guardrail can only ever narrow behaviour. - 3308
if !self.inner.trust_project_config { - 3309
project.demote_untrusted(); - 3310
} - 3311
if !project.is_empty() { - 3312
layers.push(prompts::LayerInput::new( - 3313
prompts::PromptLayer::Workspace, - 3314
Some(project_dir.display().to_string()), - 3315
project, - 3316
)); - 3317
} - 3318
} - 3319
- 3320
for (kind, name, layer) in [ - 3321
( - 3322
"surface", - 3323
self.surface.slug().to_string(), - 3324
prompts::PromptLayer::Surface, - 3325
), - 3326
( - 3327
"agents", - 3328
self.prompt_role.clone().unwrap_or_default(), - 3329
prompts::PromptLayer::Agent, - 3330
), - 3331
] { - 3332
if name.is_empty() { - 3333
continue; - 3334
} - 3335
let mut found_on_disk = false; - 3336
for root in [&vak_config::paths::default_workspace(), &self.inner.cwd] { - 3337
let Some(dir) = prompts::sub_layer_dir(root, kind, &name) else { - 3338
continue; - 3339
}; - 3340
let mut content = prompts::read_layer(&dir); - 3341
if content.is_empty() { - 3342
continue; - 3343
} - 3344
if root == &self.inner.cwd && !self.inner.trust_project_config { - 3345
content.demote_untrusted(); - 3346
if content.is_empty() { - 3347
continue; - 3348
} - 3349
} - 3350
found_on_disk = true; - 3351
layers.push(prompts::LayerInput::new( - 3352
layer, - 3353
Some(dir.display().to_string()), - 3354
content, - 3355
)); - 3356
} - 3357
if !found_on_disk && kind == "agents" { - 3358
let builtin_text = match name.as_str() { - 3359
"analyst" => Some( - 3360
"You are the Data Analyst specialist. Compute figures with your tools rather than estimating them, show the data behind every number, state assumptions and uncertainty, and present results as tables or charts where they read best.", - 3361
), - 3362
"operator" => Some( - 3363
"You are the Operations specialist. Inspect the current state before changing it, act in small reversible steps, confirm each effect before the next, and report exactly what changed and what did not.", - 3364
), - 3365
"researcher" => Some( - 3366
"You are the Research Analyst specialist. Focus on empirical verification, numbered citations [1], [2] linked to sources, counter-evidence, and epistemic uncertainty.", - 3367
), - 3368
"writer" => Some( - 3369
"You are the Communications & Writing specialist. Focus on rhetorical clarity, tone adaptation, structural hierarchy, and compelling audience communication.", - 3370
), - 3371
_ => None, - 3372
}; - 3373
if let Some(text) = builtin_text { - 3374
let content = prompts::LayerContent { - 3375
instructions: Some(text.to_string()), - 3376
..Default::default() - 3377
}; - 3378
layers.push(prompts::LayerInput::new( - 3379
prompts::PromptLayer::Agent, - 3380
Some(format!("builtin-role:{name}")), - 3381
content, - 3382
)); - 3383
} - 3384
} - 3385
} - 3386
- 3387
// Gateway and role tiers handed in by the caller that knows them: - 3388
// operator state, not files on this machine's disk. - 3389
layers.extend(self.prompt_overlays.iter().cloned()); - 3390
if let Some(agent) = &self.agent_identity - 3391
&& agent.id != "vak" - 3392
{ - 3393
let agent_home = self.sessions_home(); - 3394
let agent_prompts_dir = prompts::layer_dir(&agent_home); - 3395
let mut agent_layer = prompts::read_layer(&agent_prompts_dir); - 3396
if agent_layer.identity.is_none() { - 3397
agent_layer.identity = Some(format!( - 3398
"You are {}. {}\nWorking style: {}\nUseful for: {}\nThis identity does not grant tools, permissions, credentials or budget.", - 3399
agent.name, agent.personality, agent.behaviour, agent.responsibilities - 3400
)); - 3401
} - 3402
if agent_layer.instructions.is_none() && !agent.instructions.trim().is_empty() { - 3403
agent_layer.instructions = Some(agent.instructions.clone()); - 3404
} - 3405
layers.push(prompts::LayerInput::new( - 3406
prompts::PromptLayer::Agent, - 3407
Some(format!("agent:{}@{}", agent.id, agent.revision)), - 3408
agent_layer, - 3409
)); - 3410
} - 3411
layers - 3412
} - 3413
- 3414
/// Roles defined for this workspace, shared layer first so a project can - 3415
/// shadow a shared role by name — the same name-keyed shadowing MCP - 3416
/// servers already use. - 3417
pub fn prompt_role_names(&self) -> Vec<String> { - 3418
let mut names: Vec<String> = Vec::new(); - 3419
for root in [&vak_config::paths::default_workspace(), &self.inner.cwd] { - 3420
let dir = prompts::layer_dir(root).join("agents"); - 3421
let Ok(entries) = std::fs::read_dir(&dir) else { - 3422
continue; - 3423
}; - 3424
for entry in entries.flatten() { - 3425
if !entry.path().is_dir() { - 3426
continue; - 3427
} - 3428
let Some(name) = entry.file_name().to_str().map(str::to_string) else { - 3429
continue; - 3430
}; - 3431
if prompts::sub_layer_dir(root, "agents", &name).is_some() - 3432
&& !names.contains(&name) - 3433
&& !prompts::read_layer(&entry.path()).is_empty() - 3434
{ - 3435
names.push(name); - 3436
} - 3437
} - 3438
} - 3439
for builtin in ["analyst", "operator", "researcher", "writer"] { - 3440
if !names.contains(&builtin.to_string()) { - 3441
names.push(builtin.to_string()); - 3442
} - 3443
} - 3444
names.sort(); - 3445
names - 3446
} - 3447
- 3448
/// Fully resolved prompt per role, admitted up front so a child can only - 3449
/// run under a role that existed when this session was admitted. - 3450
pub fn role_prompts( - 3451
&self, - 3452
capabilities: &[CapabilityDescriptor], - 3453
) -> std::collections::BTreeMap<String, String> { - 3454
self.prompt_role_names() - 3455
.into_iter() - 3456
.map(|name| { - 3457
let prompt = self - 3458
.clone() - 3459
.with_prompt_role(Some(name.clone())) - 3460
.system_prompt_for_capabilities(capabilities); - 3461
(name, prompt) - 3462
}) - 3463
.collect() - 3464
} - 3465
- 3466
pub fn skills(&self) -> Vec<skills::Skill> { - 3467
let shared_root = self.shared_capability_root(); - 3468
let plugin_roots = self.enabled_plugin_skill_roots(); - 3469
let mut skills = - 3470
skills::discover_with_plugins(&self.inner.cwd, &shared_root, &plugin_roots); - 3471
if !self.effective_capability_inheritance().inherit_skills { - 3472
let shared_skills = shared_root.join("skills"); - 3473
skills.retain(|skill| !skill.path.starts_with(&shared_skills)); - 3474
} - 3475
let Some(policy) = self.channel_policy() else { - 3476
return skills; - 3477
}; - 3478
skills - 3479
.into_iter() - 3480
.filter(|skill| { - 3481
Self::allowed_by(&policy.skills_allow, &policy.skills_deny, &skill.name) - 3482
}) - 3483
.collect() - 3484
} - 3485
- 3486
pub fn skills_with_shadowed(&self) -> Vec<skills::Skill> { - 3487
let shared_root = self.shared_capability_root(); - 3488
let plugin_roots = self.enabled_plugin_skill_roots(); - 3489
let mut skills = - 3490
skills::discover_all_with_plugins(&self.inner.cwd, &shared_root, &plugin_roots); - 3491
if !self.effective_capability_inheritance().inherit_skills { - 3492
let shared_skills = shared_root.join("skills"); - 3493
skills.retain(|skill| !skill.path.starts_with(&shared_skills)); - 3494
} - 3495
let Some(policy) = self.channel_policy() else { - 3496
return skills; - 3497
}; - 3498
skills - 3499
.into_iter() - 3500
.filter(|skill| { - 3501
Self::allowed_by(&policy.skills_allow, &policy.skills_deny, &skill.name) - 3502
}) - 3503
.collect() - 3504
} - 3505
- 3506
/// Live workers spawned by this Core's runs, for attach/steer UIs. - 3507
pub fn workers(&self) -> Arc<vak_agent::WorkerRegistry> { - 3508
self.inner.workers.clone() - 3509
} - 3510
- 3511
pub fn custom_commands(&self) -> Vec<custom_commands::CustomCommand> { - 3512
let shared_root = self.shared_capability_root(); - 3513
let plugin_roots = self.enabled_plugin_skill_roots(); - 3514
let mut commands = - 3515
custom_commands::discover_with_plugins(&self.inner.cwd, &shared_root, &plugin_roots); - 3516
if !self.effective_capability_inheritance().inherit_commands { - 3517
// `discover_with_plugins` labels the shared root's commands - 3518
// "user"; workspace ones are "project" and plugin ones carry a - 3519
// "plugin:" prefix. - 3520
commands.retain(|command| command.source != "user"); - 3521
} - 3522
commands - 3523
} - 3524
- 3525
/// Every tool this Core offers a turn, constructed once. - 3526
/// - 3527
/// The only list of built-in tools: `tool_names`, the capability - 3528
/// declarations, and the turn itself all derive from it, and each tool - 3529
/// states its own domains and loading (`Tool::serves`, - 3530
/// `Tool::always_loaded`). Three tools are bound to what the turn - 3531
/// admitted and are added by the turn instead — `skill` (the admitted - 3532
/// skills), `mcp` (the admitted servers) and `task` (the admitted tools) - 3533
/// — plus the synthetic `find_tools`. - 3534
fn scoped_tools(&self, scope: &ToolScope) -> Vec<Arc<dyn vak_tools::Tool>> { - 3535
let worker = self - 3536
.inner - 3537
.tool_worker_exe - 3538
.lock() - 3539
.ok() - 3540
.map(|worker| worker.clone()) - 3541
.unwrap_or_else(|| PathBuf::from("__vak_tool_worker_unavailable__")); - 3542
let mut tools = vak_tools::brokered_tools(worker, &self.new_documents); - 3543
tools.push(Arc::new(vak_tools::RecallTool)); - 3544
tools.push(Arc::new(tools_tasks::TasksTool { - 3545
sessions_home: self.shared_data_home(), - 3546
cwd: self.inner.cwd.clone(), - 3547
default_deliver_to: self.default_deliver_to.clone(), - 3548
})); - 3549
if self.effective_commitment() { - 3550
tools.push(Arc::new(tools_commitments::CommitmentsTool { - 3551
sessions_home: self.sessions_home(), - 3552
audience_id: scope.audience_id.clone(), - 3553
})); - 3554
} - 3555
if self.effective_memory_search_enabled() { - 3556
tools.push(Arc::new(session_search::SessionSearchTool { - 3557
sessions_home: self.sessions_home(), - 3558
trash_home: self.shared_data_home(), - 3559
cwd: self.inner.cwd.clone(), - 3560
exclude_session_id: scope.session_id.clone(), - 3561
agent_id: scope.agent_id.clone(), - 3562
audience_id: scope.audience_id.clone(), - 3563
})); - 3564
} - 3565
if self.effective_memory_write_enabled() { - 3566
tools.push(Arc::new(learning::RememberTool { - 3567
sessions_home: self.sessions_home(), - 3568
cwd: self.inner.cwd.clone(), - 3569
session_id: scope.session_id.clone(), - 3570
})); - 3571
tools.push(Arc::new(entities::EntityRecordTool { - 3572
sessions_home: self.sessions_home(), - 3573
cwd: self.inner.cwd.clone(), - 3574
})); - 3575
} - 3576
if self.effective_memory_skill_proposals() { - 3577
tools.push(Arc::new(learning::ProposeSkillTool { - 3578
sessions_home: self.sessions_home(), - 3579
cwd: self.inner.cwd.clone(), - 3580
session_id: scope.session_id.clone(), - 3581
})); - 3582
} - 3583
tools.push(Arc::new(entities::EntityQueryTool { - 3584
sessions_home: self.sessions_home(), - 3585
cwd: self.inner.cwd.clone(), - 3586
})); - 3587
tools.push(Arc::new(data_engine::DataQueryTool)); - 3588
for emit_tool in presentation_tools::EmitCardTool::all() { - 3589
tools.push(Arc::new(emit_tool)); - 3590
} - 3591
if self.effective_web_fetch() { - 3592
tools.push(Arc::new(vak_tools::WebFetchTool)); - 3593
} - 3594
if self.effective_browse() { - 3595
tools.push(Arc::new(vak_tools::WebBrowseTool)); - 3596
} - 3597
// Inter-agent messaging, only while an operator has authorized this - 3598
// workspace on the broker (docs/design/25-docker-sandbox.md). The - 3599
// broker keys workspaces by canonical path, as the server registers - 3600
// them. - 3601
let workspace = self - 3602
.inner - 3603
.cwd - 3604
.canonicalize() - 3605
.unwrap_or_else(|_| self.inner.cwd.clone())
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.