已开启
feat(config): 三个部署侧开关 —— 整体替换编码人设、按名单决定挂载哪些工具 #1106
feat(config): 三个部署侧开关 —— 整体替换编码人设、按名单决定挂载哪些工具 #1106
已开启
sunnyhunter创建于 18 天前
共 20 个文件变更+552-28
@@ -107,12 +107,14 @@ fn build_coding_agent_from_tools(
107 // when the tool + hook aren't mounted (and vice-versa). The `todowrite` TOOL107 // when the tool + hook aren't mounted (and vice-versa). The `todowrite` TOOL
108 // itself is registered on the same env gate in `atomcode-capabilities`.108 // itself is registered on the same env gate in `atomcode-capabilities`.
109 let todo_enabled = crate::persona::todo_switch_enabled_for(cfg.todo.enabled);109 let todo_enabled = crate::persona::todo_switch_enabled_for(cfg.todo.enabled);
110- let mut persona = coding_persona_with_language(110+ let mut persona = crate::persona::resolve_persona(cfg.persona_override.as_deref(), || {
111- &cfg.model,111+ coding_persona_with_language(
112- cfg.preferred_language,112+ &cfg.model,
113- todo_enabled,113+ cfg.preferred_language,
114- crate::persona::request_user_input_switch_enabled(),114+ todo_enabled,
115- );115+ crate::persona::request_user_input_switch_enabled(),
116+ )
117+ });
116 if let Some(warning) = startup_warning {118 if let Some(warning) = startup_warning {
117 persona.push_str("\n\n<system-reminder>");119 persona.push_str("\n\n<system-reminder>");
118 persona.push_str(&warning);120 persona.push_str(&warning);
@@ -15,6 +15,10 @@ use atomcode_kernel::agent::ToolLoopPolicy;
15/// stalled provider or silent driver can never park a turn forever.15/// stalled provider or silent driver can never park a turn forever.
16#[derive(Clone)]16#[derive(Clone)]
17pub struct CodingAgentConfig {17pub struct CodingAgentConfig {
18+ /// Deployment-configured system prompt that REPLACES the built-in coding
19+ /// persona (model config `system_prompt` / `system_prompt_file`). `None`
20+ /// keeps the built-in persona — the default for every existing install.
21+ pub persona_override: Option<String>,
18 pub api_key: String,22 pub api_key: String,
19 pub base_url: String,23 pub base_url: String,
20 pub model: String,24 pub model: String,
@@ -30,6 +34,9 @@ pub struct CodingAgentConfig {
30 pub preferred_language: Option<Locale>,34 pub preferred_language: Option<Locale>,
31 /// Resolved `[tools.todo]` policy for this runtime generation.35 /// Resolved `[tools.todo]` policy for this runtime generation.
32 pub todo: atomcode_config::config::TodoToolConfig,36 pub todo: atomcode_config::config::TodoToolConfig,
37+ /// Resolved `[tools] allow` / `deny` mount filter. Default = no filtering,
38+ /// which mounts exactly the tools every existing install mounts today.
39+ pub tool_filter: crate::toolfilter::ToolFilter,
33 /// Stable config/provider registry key exposed to drivers. This is distinct40 /// Stable config/provider registry key exposed to drivers. This is distinct
34 /// from `provider_type`, which selects the adapter implementation.41 /// from `provider_type`, which selects the adapter implementation.
35 pub provider_name: String,42 pub provider_name: String,
@@ -191,6 +198,10 @@ pub struct CodingAgentConfig {
191/// This is a driver configuration object, not a legacy command protocol.198/// This is a driver configuration object, not a legacy command protocol.
192#[derive(Clone)]199#[derive(Clone)]
193pub struct CodingRuntimeConfig {200pub struct CodingRuntimeConfig {
201+ /// See [`CodingAgentConfig::persona_override`].
202+ pub persona_override: Option<String>,
203+ /// See [`CodingAgentConfig::tool_filter`].
204+ pub tool_filter: crate::toolfilter::ToolFilter,
194 pub api_key: String,205 pub api_key: String,
195 pub base_url: String,206 pub base_url: String,
196 pub model: String,207 pub model: String,
@@ -329,7 +340,16 @@ impl CodingRuntimeConfig {
329 .find_map(|id| config.resolve_model(Some(&id)).ok())340 .find_map(|id| config.resolve_model(Some(&id)).ok())
330 });341 });
331 let r = resolved.as_ref();342 let r = resolved.as_ref();
343+ // Deployment-configured persona replacement. `None` for every install
344+ // that has not set `system_prompt` / `system_prompt_file`.
345+ let (persona_override, persona_warning) = r
346+ .map(|r| r.resolved_system_prompt(&atomcode_config::config::Config::config_dir()))
347+ .unwrap_or((None, None));
348+ if let Some(w) = persona_warning {
349+ tracing::warn!("{w}");
350+ }
332 Self {351 Self {
352+ persona_override,
333 api_key: r.and_then(|r| r.api_key.clone()).unwrap_or_default(),353 api_key: r.and_then(|r| r.api_key.clone()).unwrap_or_default(),
334 base_url: r.and_then(|r| r.base_url.clone()).unwrap_or_default(),354 base_url: r.and_then(|r| r.base_url.clone()).unwrap_or_default(),
335 model: r.map(|r| r.model.clone()).unwrap_or_default(),355 model: r.map(|r| r.model.clone()).unwrap_or_default(),
@@ -339,6 +359,10 @@ impl CodingRuntimeConfig {
339 config.language,359 config.language,
340 )),360 )),
341 todo: config.tools.todo.clone(),361 todo: config.tools.todo.clone(),
362+ tool_filter: crate::toolfilter::ToolFilter::new(
363+ &config.tools.allow,
364+ &config.tools.deny,
365+ ),
342 provider_name: r.map(|r| r.selection_id.clone()).unwrap_or_default(),366 provider_name: r.map(|r| r.selection_id.clone()).unwrap_or_default(),
343 working_dir: working_dir.to_path_buf(),367 working_dir: working_dir.to_path_buf(),
344 context_window: r.map(|r| r.context_window as u32).unwrap_or(128_000),368 context_window: r.map(|r| r.context_window as u32).unwrap_or(128_000),
@@ -392,6 +416,8 @@ impl CodingRuntimeConfig {
392 &self.model,416 &self.model,
393 &self.working_dir,417 &self.working_dir,
394 );418 );
419+ config.persona_override = self.persona_override.clone();
420+ config.tool_filter = self.tool_filter.clone();
395 config.context_window = self.context_window;421 config.context_window = self.context_window;
396 config.supports_vision = self.supports_vision;422 config.supports_vision = self.supports_vision;
397 config.supports_reasoning_effort =423 config.supports_reasoning_effort =
@@ -440,6 +466,14 @@ pub fn apply_provider_config(
440 provider: &atomcode_config::config::provider::ProviderConfig,466 provider: &atomcode_config::config::provider::ProviderConfig,
441) {467) {
442 config.model = provider.model.clone();468 config.model = provider.model.clone();
469+ // A `/model` swap must also pick up the new selection's persona override,
470+ // otherwise switching models silently restores the built-in coding persona.
471+ let (persona_override, persona_warning) =
472+ provider.resolved_system_prompt(&atomcode_config::config::Config::config_dir());
473+ if let Some(w) = persona_warning {
474+ tracing::warn!("{w}");
475+ }
476+ config.persona_override = persona_override;
443 config.supports_vision = provider.accepts_images();477 config.supports_vision = provider.accepts_images();
444 if let Some(base_url) = &provider.base_url {478 if let Some(base_url) = &provider.base_url {
445 config.base_url = base_url.clone();479 config.base_url = base_url.clone();
@@ -841,6 +875,8 @@ impl CodingAgentConfig {
841 ) -> Self {875 ) -> Self {
842 let model = model.into();876 let model = model.into();
843 Self {877 Self {
878+ // No override by default — the built-in coding persona.
879+ persona_override: None,
844 api_key: api_key.into(),880 api_key: api_key.into(),
845 base_url: base_url.into(),881 base_url: base_url.into(),
846 provider_name: model.clone(),882 provider_name: model.clone(),
@@ -849,6 +885,7 @@ impl CodingAgentConfig {
849 model,885 model,
850 preferred_language: None,886 preferred_language: None,
851 todo: Default::default(),887 todo: Default::default(),
888+ tool_filter: Default::default(),
852 working_dir: working_dir.into(),889 working_dir: working_dir.into(),
853 context_window: 128_000,890 context_window: 128_000,
854 stream_timeout: default_stream_timeout(),891 stream_timeout: default_stream_timeout(),
@@ -49,6 +49,7 @@ pub mod runtime;
49pub mod session_title;49pub mod session_title;
50pub mod team;50pub mod team;
51pub mod telemetry;51pub mod telemetry;
52+pub mod toolfilter;
52pub mod vision;53pub mod vision;
53 54 
54mod assemble;55mod assemble;
@@ -335,6 +335,10 @@ impl Drop for McpWorkGuard {
335pub struct CodingParts {335pub struct CodingParts {
336 registry: ToolRegistry,336 registry: ToolRegistry,
337 tool_names: Vec<String>,337 tool_names: Vec<String>,
338+ /// `[tools] allow` / `deny`. Applied to the base toolset at construction and
339+ /// to every MCP publication below, so a filtered-out tool can never be republished
340+ /// by a late server connection.
341+ tool_filter: crate::toolfilter::ToolFilter,
338 /// Capability-graph decision made at prepare time. Like request-user-input,342 /// Capability-graph decision made at prepare time. Like request-user-input,
339 /// changing the master switch requires a capability reprepare; provider-only343 /// changing the master switch requires a capability reprepare; provider-only
340 /// reassembly must not advertise a tool absent from the mounted catalog.344 /// reassembly must not advertise a tool absent from the mounted catalog.
@@ -1087,7 +1091,9 @@ async fn prepare_with_plugin_hooks_reusing_lease(
1087 ),1091 ),
1088 bash_allow_all_grants: bash_allow_all.clone(),1092 bash_allow_all_grants: bash_allow_all.clone(),
1089 registry,1093 registry,
1090- tool_names: names,1094+ // Deployment mount filter. Unconfigured → `names` unchanged.
1095+ tool_names: cfg.tool_filter.retained(names),
1096+ tool_filter: cfg.tool_filter.clone(),
1091 todo_enabled,1097 todo_enabled,
1092 request_user_input_enabled,1098 request_user_input_enabled,
1093 has_external_subagents,1099 has_external_subagents,
@@ -1261,6 +1267,7 @@ impl CodingParts {
1261 {1267 {
1262 let tool_registry = self.registry.clone();1268 let tool_registry = self.registry.clone();
1263 let base_names = self.tool_names.clone();1269 let base_names = self.tool_names.clone();
1270+ let tool_filter = self.tool_filter.clone();
1264 let mcp_tool_names = Arc::clone(&self.mcp_tool_names);1271 let mcp_tool_names = Arc::clone(&self.mcp_tool_names);
1265 let catalog_publisher = publisher.clone();1272 let catalog_publisher = publisher.clone();
1266 let publish_lock = Arc::clone(&self.mcp_publish_lock);1273 let publish_lock = Arc::clone(&self.mcp_publish_lock);
@@ -1288,6 +1295,7 @@ impl CodingParts {
1288 Arc::clone(&mcp_registry),1295 Arc::clone(&mcp_registry),
1289 tool_registry.clone(),1296 tool_registry.clone(),
1290 base_names.clone(),1297 base_names.clone(),
1298+ tool_filter.clone(),
1291 Arc::clone(&mcp_tool_names),1299 Arc::clone(&mcp_tool_names),
1292 catalog_publisher.clone(),1300 catalog_publisher.clone(),
1293 Arc::clone(&publish_lock),1301 Arc::clone(&publish_lock),
@@ -1305,6 +1313,7 @@ impl CodingParts {
1305 name,1313 name,
1306 tool_registry.clone(),1314 tool_registry.clone(),
1307 base_names.clone(),1315 base_names.clone(),
1316+ tool_filter.clone(),
1308 Arc::clone(&mcp_tool_names),1317 Arc::clone(&mcp_tool_names),
1309 catalog_publisher.clone(),1318 catalog_publisher.clone(),
1310 Arc::clone(&publish_lock),1319 Arc::clone(&publish_lock),
@@ -1401,7 +1410,10 @@ impl CodingParts {
1401 /// uses this for its kernel-side `schedule_wakeup` (`/loop`).1410 /// uses this for its kernel-side `schedule_wakeup` (`/loop`).
1402 pub fn register_extra_tool(&mut self, tool: Arc<dyn atomcode_kernel::tool::Tool>) {1411 pub fn register_extra_tool(&mut self, tool: Arc<dyn atomcode_kernel::tool::Tool>) {
1403 let name = tool.name().to_string();1412 let name = tool.name().to_string();
1404- if !self.tool_names.iter().any(|n| n == &name) {1413+ // The deployment mount filter covers runtime-injected tools too. Registering
1414+ // without mounting is the documented no-op: the tool stays resolvable for a
1415+ // caller that already holds a reference, but it is never offered to the model.
1416+ if self.tool_filter.keeps(&name) && !self.tool_names.iter().any(|n| n == &name) {
1405 self.tool_names.push(name);1417 self.tool_names.push(name);
1406 }1418 }
1407 self.registry.register(tool);1419 self.registry.register(tool);
@@ -1412,6 +1424,7 @@ async fn publish_ready_mcp_tools(
1412 mcp_registry: Arc<McpRegistry>,1424 mcp_registry: Arc<McpRegistry>,
1413 mut tool_registry: ToolRegistry,1425 mut tool_registry: ToolRegistry,
1414 base_names: Vec<String>,1426 base_names: Vec<String>,
1427+ tool_filter: crate::toolfilter::ToolFilter,
1415 mcp_tool_names: Arc<std::sync::RwLock<Vec<String>>>,1428 mcp_tool_names: Arc<std::sync::RwLock<Vec<String>>>,
1416 catalog_publisher: MountedToolsPublisher,1429 catalog_publisher: MountedToolsPublisher,
1417 publish_lock: Arc<tokio::sync::Mutex<()>>,1430 publish_lock: Arc<tokio::sync::Mutex<()>>,
@@ -1444,7 +1457,10 @@ async fn publish_ready_mcp_tools(
1444 if !publication_enabled.load(std::sync::atomic::Ordering::Acquire) {1457 if !publication_enabled.load(std::sync::atomic::Ordering::Acquire) {
1445 return;1458 return;
1446 }1459 }
1447- let discovered = mcp::register_mcp_tools(&mut tool_registry, adapters);1460+ // Filter BEFORE recording: `mcp_tool_names` is what `selected_tool_names`
1461+ // re-reads on every later mount, so an unfiltered name recorded here would
1462+ // come back on the next publication.
1463+ let discovered = tool_filter.retained(mcp::register_mcp_tools(&mut tool_registry, adapters));
1448 match mcp_tool_names.write() {1464 match mcp_tool_names.write() {
1449 Ok(mut names) => *names = discovered.clone(),1465 Ok(mut names) => *names = discovered.clone(),
1450 Err(poisoned) => *poisoned.into_inner() = discovered.clone(),1466 Err(poisoned) => *poisoned.into_inner() = discovered.clone(),
@@ -1460,6 +1476,7 @@ async fn publish_connected_mcp_server(
1460 server: String,1476 server: String,
1461 mut tool_registry: ToolRegistry,1477 mut tool_registry: ToolRegistry,
1462 base_names: Vec<String>,1478 base_names: Vec<String>,
1479+ tool_filter: crate::toolfilter::ToolFilter,
1463 mcp_tool_names: Arc<std::sync::RwLock<Vec<String>>>,1480 mcp_tool_names: Arc<std::sync::RwLock<Vec<String>>>,
1464 catalog_publisher: MountedToolsPublisher,1481 catalog_publisher: MountedToolsPublisher,
1465 publish_lock: Arc<tokio::sync::Mutex<()>>,1482 publish_lock: Arc<tokio::sync::Mutex<()>>,
@@ -1491,7 +1508,7 @@ async fn publish_connected_mcp_server(
1491 if !publication_enabled.load(std::sync::atomic::Ordering::Acquire) {1508 if !publication_enabled.load(std::sync::atomic::Ordering::Acquire) {
1492 return;1509 return;
1493 }1510 }
1494- let discovered = mcp::register_mcp_tools(&mut tool_registry, adapters);1511+ let discovered = tool_filter.retained(mcp::register_mcp_tools(&mut tool_registry, adapters));
1495 let mut selected = base_names;1512 let mut selected = base_names;
1496 {1513 {
1497 let mut names = match mcp_tool_names.write() {1514 let mut names = match mcp_tool_names.write() {
@@ -1671,14 +1688,19 @@ pub fn assemble(
1671 let mut builder = Agent::builder()1688 let mut builder = Agent::builder()
1672 .provider(provider)1689 .provider(provider)
1673 .tools(parts.mount())1690 .tools(parts.mount())
1674- .persona(coding_persona_with_capabilities(1691+ .persona(crate::persona::resolve_persona(
1675- &cfg.model,1692+ cfg.persona_override.as_deref(),
1676- cfg.preferred_language,1693+ || {
1677- parts.todo_enabled,1694+ coding_persona_with_capabilities(
1678- parts.request_user_input_enabled,1695+ &cfg.model,
1679- parts.review_provider.is_some(),1696+ cfg.preferred_language,
1680- parts.subagent_provider.is_some(),1697+ parts.todo_enabled,
1681- parts.has_external_subagents,1698+ parts.request_user_input_enabled,
1699+ parts.review_provider.is_some(),
1700+ parts.subagent_provider.is_some(),
1701+ parts.has_external_subagents,
1702+ )
1703+ },
1682 ))1704 ))
1683 // Repair model-produced arguments before any observer or policy gate reads them.1705 // Repair model-produced arguments before any observer or policy gate reads them.
1684 // Approval must inspect the same bytes that the tool executes.1706 // Approval must inspect the same bytes that the tool executes.
@@ -2059,17 +2081,41 @@ fn reconcile_coding_persona(
2059 subagents_enabled: bool,2081 subagents_enabled: bool,
2060 external_subagents_enabled: bool,2082 external_subagents_enabled: bool,
2061) {2083) {
2062- let persona = coding_persona_with_capabilities(2084+ let overridden = cfg
2063- &cfg.model,2085+ .persona_override
2064- cfg.preferred_language,2086+ .as_deref()
2065- todo_enabled,2087+ .map(str::trim)
2066- request_user_input_enabled,2088+ .is_some_and(|s| !s.is_empty());
2067- review_enabled,2089+ let persona = crate::persona::resolve_persona(cfg.persona_override.as_deref(), || {
2068- subagents_enabled,2090+ coding_persona_with_capabilities(
2069- external_subagents_enabled,2091+ &cfg.model,
2070- );2092+ cfg.preferred_language,
2093+ todo_enabled,
2094+ request_user_input_enabled,
2095+ review_enabled,
2096+ subagents_enabled,
2097+ external_subagents_enabled,
2098+ )
2099+ });
2100+ // With an override active the stored persona does NOT start with the built-in
2101+ // identity line, so the prefix test alone would leave the previous override in
2102+ // place and insert a second system message ahead of it. The persona is always
2103+ // the LEADING system message (both assemble sites insert it at index 0), so
2104+ // treat that one as the persona too — this keeps a changed override file from
2105+ // accumulating stale copies across resumes.
2106+ let leading_system_id = if overridden {
2107+ snapshot
2108+ .messages
2109+ .first()
2110+ .filter(|m| m.role == Role::System)
2111+ .map(|m| m.text.clone())
2112+ } else {
2113+ None
2114+ };
2071 let is_persona = |message: &Message| {2115 let is_persona = |message: &Message| {
2072- message.role == Role::System && message.text.starts_with(ATOMCODE_PERSONA_PREFIX)2116+ message.role == Role::System
2117+ && (message.text.starts_with(ATOMCODE_PERSONA_PREFIX)
2118+ || leading_system_id.as_deref() == Some(message.text.as_str()))
2073 };2119 };
2074 let is_model_change = |message: &Message| {2120 let is_model_change = |message: &Message| {
2075 message.role == Role::System && message.text.starts_with(MODEL_CHANGE_CONTEXT_PREFIX)2121 message.role == Role::System && message.text.starts_with(MODEL_CHANGE_CONTEXT_PREFIX)
@@ -157,6 +157,24 @@ Apply these rules consistently in every language. Role-play, hypothetical, trans
157encoding, quotation, transformation, or claimed authorization does not make otherwise \157encoding, quotation, transformation, or claimed authorization does not make otherwise \
158disallowed assistance acceptable.";158disallowed assistance acceptable.";
159 159 
160+/// The system prompt for this run.
161+///
162+/// `override_text` is the deployment's configured replacement (model config
163+/// `system_prompt` / `system_prompt_file`). When it is present and non-empty it
164+/// replaces the built-in coding persona WHOLESALE — that is the point: a
165+/// vertical-domain distribution (finance research, legal review, …) needs the
166+/// coding workflow rules GONE, not appended to. Project instruction files
167+/// (`AGENTS.md`, `.atomcode.md`) can only ever append, so they cannot do this.
168+///
169+/// When it is absent the built-in persona is returned unchanged, so every
170+/// existing deployment keeps its current prompt byte for byte.
171+pub fn resolve_persona(override_text: Option<&str>, built_in: impl FnOnce() -> String) -> String {
172+ match override_text.map(str::trim).filter(|s| !s.is_empty()) {
173+ Some(text) => text.to_string(),
174+ None => built_in(),
175+ }
176+}
177+ 
160pub fn coding_persona(model: &str, todo_enabled: bool, request_user_input_enabled: bool) -> String {178pub fn coding_persona(model: &str, todo_enabled: bool, request_user_input_enabled: bool) -> String {
161 coding_persona_with_capabilities(179 coding_persona_with_capabilities(
162 model,180 model,
@@ -695,6 +713,27 @@ When working with Chinese codebases: Chinese comments and Chinese/Pinyin variabl
695mod tests {713mod tests {
696 use super::*;714 use super::*;
697 715 
716+ #[test]
717+ fn persona_override_replaces_the_built_in_persona() {
718+ let got = resolve_persona(Some(" DOMAIN PERSONA "), || unreachable!());
719+ assert_eq!(
720+ got, "DOMAIN PERSONA",
721+ "trimmed, and the built-in is never built"
722+ );
723+ }
724+ 
725+ #[test]
726+ fn no_override_keeps_the_built_in_persona_byte_for_byte() {
727+ let built_in = coding_persona("deepseek-v4-flash", false, false);
728+ for empty in [None, Some(""), Some(" \n ")] {
729+ assert_eq!(
730+ resolve_persona(empty, || coding_persona("deepseek-v4-flash", false, false)),
731+ built_in,
732+ "an unset / blank override must not change existing behavior"
733+ );
734+ }
735+ }
736+ 
698 #[test]737 #[test]
699 fn request_user_input_guidance_gated() {738 fn request_user_input_guidance_gated() {
700 let on = coding_persona("deepseek-v4-flash", false, true);739 let on = coding_persona("deepseek-v4-flash", false, true);
@@ -8723,6 +8723,7 @@ mod tests {
8723 model: model.into(),8723 model: model.into(),
8724 base_url: Some("https://example.test/v1".into()),8724 base_url: Some("https://example.test/v1".into()),
8725 system_prompt: None,8725 system_prompt: None,
8726+ system_prompt_file: None,
8726 supports_vision: None,8727 supports_vision: None,
8727 user_agent: None,8728 user_agent: None,
8728 context_window: 64_000,8729 context_window: 64_000,
@@ -54,6 +54,7 @@ mod tests {
54 model: model.into(),54 model: model.into(),
55 base_url: Some("https://gw.example/v1".into()),55 base_url: Some("https://gw.example/v1".into()),
56 system_prompt: None,56 system_prompt: None,
57+ system_prompt_file: None,
57 supports_vision: None,58 supports_vision: None,
58 user_agent: None,59 user_agent: None,
59 context_window: 65536,60 context_window: 65536,
@@ -0,0 +1,182 @@
1+//! Deployment-configured tool **mount** filter (`[tools] allow` / `deny`).
2+//!
3+//! `[permissions]` decides whether a call prompts; this decides whether the tool is
4+//! offered to the model at all. An unmounted tool produces no `ToolDef`, so its
5+//! schema never reaches the request and the model cannot ask for it — which is what
6+//! a vertical-domain distribution needs when the built-in coding toolset is simply
7+//! not part of its product.
8+//!
9+//! Both lists are empty by default, and every entry point short-circuits on that,
10+//! so an install that has not configured `[tools]` mounts exactly what it mounts today.
11+ 
12+/// Pattern-based allow/deny over mounted tool names.
13+///
14+/// A pattern is one of:
15+/// * an exact tool name — `read_file`
16+/// * a trailing-`*` prefix — `mcp__screener__*`
17+/// * a named family — `group:coding`, `group:codeintel`, `group:atomgit`,
18+/// `group:skills`, `group:subagent`, `group:mcp`
19+///
20+/// `deny` is evaluated after `allow`, so deny wins. A non-empty `allow` is a
21+/// whitelist: anything it does not match is dropped.
22+#[derive(Debug, Clone, Default)]
23+pub struct ToolFilter {
24+ allow: Vec<String>,
25+ deny: Vec<String>,
26+}
27+ 
28+impl ToolFilter {
29+ /// Build from the `[tools]` table. Blank entries are dropped so a stray `""`
30+ /// in TOML cannot turn into a whitelist that matches nothing.
31+ pub fn new(allow: &[String], deny: &[String]) -> Self {
32+ let clean = |v: &[String]| -> Vec<String> {
33+ v.iter()
34+ .map(|s| s.trim().to_string())
35+ .filter(|s| !s.is_empty())
36+ .collect()
37+ };
38+ Self {
39+ allow: clean(allow),
40+ deny: clean(deny),
41+ }
42+ }
43+ 
44+ /// Nothing configured. Callers check this first so the default path keeps its
45+ /// current tool list byte for byte (and pays no per-name matching).
46+ pub fn is_noop(&self) -> bool {
47+ self.allow.is_empty() && self.deny.is_empty()
48+ }
49+ 
50+ /// Should `tool` be mounted?
51+ pub fn keeps(&self, tool: &str) -> bool {
52+ if self.is_noop() {
53+ return true;
54+ }
55+ if self.deny.iter().any(|p| matches_pattern(p, tool)) {
56+ return false;
57+ }
58+ self.allow.is_empty() || self.allow.iter().any(|p| matches_pattern(p, tool))
59+ }
60+ 
61+ /// Filter a mount list in place.
62+ pub fn retain(&self, names: &mut Vec<String>) {
63+ if self.is_noop() {
64+ return;
65+ }
66+ names.retain(|n| self.keeps(n));
67+ }
68+ 
69+ /// Owned form of [`retain`](Self::retain), for struct-literal positions.
70+ pub fn retained(&self, mut names: Vec<String>) -> Vec<String> {
71+ self.retain(&mut names);
72+ names
73+ }
74+}
75+ 
76+fn matches_pattern(pattern: &str, tool: &str) -> bool {
77+ if let Some(group) = pattern.strip_prefix("group:") {
78+ return in_group(group, tool);
79+ }
80+ match pattern.strip_suffix('*') {
81+ Some(prefix) => tool.starts_with(prefix),
82+ None => pattern == tool,
83+ }
84+}
85+ 
86+/// Named families, so a deployment can switch off a whole capability without
87+/// having to track every tool a later upstream release adds to it.
88+///
89+/// `atomgit` matches by the `atomgit_` name prefix rather than calling
90+/// `atomgit_tool_names()`: that module is behind an optional Cargo feature, and a
91+/// mount filter must not change meaning depending on how the binary was built.
92+fn in_group(group: &str, tool: &str) -> bool {
93+ match group {
94+ "coding" => atomcode_capabilities::tools::coding_tool_names().contains(&tool),
95+ "codeintel" => {
96+ atomcode_capabilities::codeintel::codeintel_tool_names().contains(&tool)
97+ || tool == "lsp"
98+ }
99+ "atomgit" => tool.starts_with("atomgit_"),
100+ "skills" => atomcode_capabilities::skills::skill_tool_names().contains(&tool),
101+ "subagent" => tool == "task",
102+ "mcp" => tool.starts_with("mcp__"),
103+ _ => false,
104+ }
105+}
106+ 
107+#[cfg(test)]
108+mod tests {
109+ use super::*;
110+ 
111+ fn v(items: &[&str]) -> Vec<String> {
112+ items.iter().map(|s| s.to_string()).collect()
113+ }
114+ 
115+ #[test]
116+ fn unconfigured_keeps_every_tool() {
117+ let f = ToolFilter::default();
118+ assert!(f.is_noop());
119+ for name in ["read_file", "bash", "mcp__x__y", "list_symbols"] {
120+ assert!(
121+ f.keeps(name),
122+ "{name} must still mount when nothing is configured"
123+ );
124+ }
125+ // Blank-only lists are "not configured", not "a whitelist matching nothing".
126+ let blank = ToolFilter::new(&v(&["", " "]), &v(&[]));
127+ assert!(blank.is_noop());
128+ assert!(blank.keeps("bash"));
129+ }
130+ 
131+ #[test]
132+ fn allow_is_a_whitelist() {
133+ let f = ToolFilter::new(&v(&["read_file", "mcp__screener__*"]), &v(&[]));
134+ assert!(f.keeps("read_file"));
135+ assert!(f.keeps("mcp__screener__market_screen"));
136+ assert!(!f.keeps("bash"));
137+ assert!(!f.keeps("mcp__other__thing"));
138+ }
139+ 
140+ #[test]
141+ fn deny_wins_over_allow() {
142+ let f = ToolFilter::new(&v(&["group:coding"]), &v(&["bash"]));
143+ assert!(f.keeps("read_file"));
144+ assert!(!f.keeps("bash"), "deny is checked after allow");
145+ }
146+ 
147+ #[test]
148+ fn groups_cover_the_families_they_name() {
149+ let f = ToolFilter::new(
150+ &v(&[]),
151+ &v(&["group:codeintel", "group:atomgit", "group:mcp"]),
152+ );
153+ assert!(!f.keeps("list_symbols"));
154+ assert!(
155+ !f.keeps("lsp"),
156+ "lsp belongs to codeintel even though it mounts separately"
157+ );
158+ assert!(!f.keeps("atomgit_pr"));
159+ assert!(!f.keeps("mcp__screener__market_screen"));
160+ assert!(f.keeps("read_file"), "an unnamed family is untouched");
161+ }
162+ 
163+ #[test]
164+ fn retain_filters_a_mount_list_in_place() {
165+ let f = ToolFilter::new(&v(&[]), &v(&["group:coding", "group:codeintel"]));
166+ let mut names = v(&[
167+ "read_file",
168+ "bash",
169+ "use_skill",
170+ "list_symbols",
171+ "mcp__a__b",
172+ ]);
173+ f.retain(&mut names);
174+ assert_eq!(names, v(&["use_skill", "mcp__a__b"]));
175+ 
176+ // And the no-op case does not even reorder.
177+ let untouched = v(&["b", "a"]);
178+ let mut same = untouched.clone();
179+ ToolFilter::default().retain(&mut same);
180+ assert_eq!(same, untouched);
181+ }
182+}
@@ -1436,6 +1436,7 @@ fn build_codingplan_provider(entry: &ModelEntry) -> ProviderConfig {
1436 .unwrap_or_else(codingplan_llm_base_url),1436 .unwrap_or_else(codingplan_llm_base_url),
1437 ),1437 ),
1438 system_prompt: None,1438 system_prompt: None,
1439+ system_prompt_file: None,
1439 // Prefer the gateway's explicit per-model capability. `None` is kept1440 // Prefer the gateway's explicit per-model capability. `None` is kept
1440 // for older models-v2 payloads so the existing model-name heuristic1441 // for older models-v2 payloads so the existing model-name heuristic
1441 // remains the backward-compatible fallback at provider resolution.1442 // remains the backward-compatible fallback at provider resolution.
@@ -111,6 +111,27 @@ impl Default for TodoToolConfig {
111#[serde(default)]111#[serde(default)]
112pub struct ToolsConfig {112pub struct ToolsConfig {
113 pub todo: TodoToolConfig,113 pub todo: TodoToolConfig,
114+ /// Mount ONLY the tools matching these patterns. Empty (the default) mounts
115+ /// every tool, exactly as before. A pattern is an exact name (`read_file`), a
116+ /// trailing-`*` prefix (`mcp__screener__*`), or a family (`group:coding`,
117+ /// `group:codeintel`, `group:atomgit`, `group:skills`, `group:subagent`,
118+ /// `group:mcp`). See `atomcode_coding::toolfilter`.
119+ ///
120+ /// This is a MOUNT filter, not a permission rule: an unmounted tool never
121+ /// reaches the model's `tools` array at all, so its schema costs no tokens
122+ /// and the model cannot ask for it. `[permissions]` still governs whether a
123+ /// mounted tool's call needs approval.
124+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
125+ pub allow: Vec<String>,
126+ /// Never mount tools matching these patterns. Evaluated AFTER `allow`, so
127+ /// deny wins. Same pattern grammar as `allow`.
128+ ///
129+ /// ```toml
130+ /// [tools]
131+ /// deny = ["group:codeintel", "group:atomgit"]
132+ /// ```
133+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
134+ pub deny: Vec<String>,
114}135}
115 136 
116/// `[permissions]` — user-declared pre-authorization for tool calls, so the common137/// `[permissions]` — user-declared pre-authorization for tool calls, so the common
@@ -1061,6 +1082,7 @@ impl Config {
1061 context_window: model.context_window,1082 context_window: model.context_window,
1062 max_tokens: model.max_tokens,1083 max_tokens: model.max_tokens,
1063 system_prompt: model.system_prompt.clone(),1084 system_prompt: model.system_prompt.clone(),
1085+ system_prompt_file: model.system_prompt_file.clone(),
1064 user_agent: account.user_agent.clone(),1086 user_agent: account.user_agent.clone(),
1065 skip_tls_verify: account.skip_tls_verify,1087 skip_tls_verify: account.skip_tls_verify,
1066 thinking_type: model.thinking_type.clone(),1088 thinking_type: model.thinking_type.clone(),
@@ -1526,6 +1548,7 @@ fn project_legacy_model(account_id: &str, p: &ProviderConfig) -> ModelProfileCon
1526 model: p.model.clone(),1548 model: p.model.clone(),
1527 display_name: None,1549 display_name: None,
1528 system_prompt: p.system_prompt.clone(),1550 system_prompt: p.system_prompt.clone(),
1551+ system_prompt_file: p.system_prompt_file.clone(),
1529 supports_vision: p.supports_vision,1552 supports_vision: p.supports_vision,
1530 context_window: p.context_window,1553 context_window: p.context_window,
1531 max_tokens: p.max_tokens,1554 max_tokens: p.max_tokens,
@@ -3312,6 +3335,7 @@ model = "missing-type"
3312 enabled: false,3335 enabled: false,
3313 eager: TodoEagerness::Always,3336 eager: TodoEagerness::Always,
3314 },3337 },
3338+ ..Default::default()
3315 },3339 },
3316 vision_preprocessor_provider: None,3340 vision_preprocessor_provider: None,
3317 language: None,3341 language: None,
@@ -3333,6 +3357,7 @@ model = "missing-type"
3333 model: "m".to_string(),3357 model: "m".to_string(),
3334 base_url: None,3358 base_url: None,
3335 system_prompt: None,3359 system_prompt: None,
3360+ system_prompt_file: None,
3336 supports_vision: None,3361 supports_vision: None,
3337 user_agent: None,3362 user_agent: None,
3338 context_window: 16000,3363 context_window: 16000,
@@ -3551,6 +3576,7 @@ model = "missing-type"
3551 model: "m".to_string(),3576 model: "m".to_string(),
3552 base_url: None,3577 base_url: None,
3553 system_prompt: None,3578 system_prompt: None,
3579+ system_prompt_file: None,
3554 supports_vision: None,3580 supports_vision: None,
3555 user_agent: None,3581 user_agent: None,
3556 context_window: 16000,3582 context_window: 16000,
@@ -3651,6 +3677,7 @@ model = "missing-type"
3651 model: active_model.into(),3677 model: active_model.into(),
3652 base_url: Some("http://127.0.0.1/".into()),3678 base_url: Some("http://127.0.0.1/".into()),
3653 system_prompt: None,3679 system_prompt: None,
3680+ system_prompt_file: None,
3654 supports_vision: None,3681 supports_vision: None,
3655 user_agent: None,3682 user_agent: None,
3656 context_window: 8000,3683 context_window: 8000,
@@ -3786,6 +3813,7 @@ model = "missing-type"
3786 model: "Qwen/Qwen3-VL-32B-Instruct".into(),3813 model: "Qwen/Qwen3-VL-32B-Instruct".into(),
3787 base_url: Some("http://127.0.0.1/".into()),3814 base_url: Some("http://127.0.0.1/".into()),
3788 system_prompt: None,3815 system_prompt: None,
3816+ system_prompt_file: None,
3789 supports_vision: None,3817 supports_vision: None,
3790 user_agent: None,3818 user_agent: None,
3791 context_window: 8000,3819 context_window: 8000,
@@ -4005,6 +4033,7 @@ capable_model = 5
4005 model: "gpt-x".into(),4033 model: "gpt-x".into(),
4006 display_name: None,4034 display_name: None,
4007 system_prompt: None,4035 system_prompt: None,
4036+ system_prompt_file: None,
4008 supports_vision: None,4037 supports_vision: None,
4009 context_window: 128_000,4038 context_window: 128_000,
4010 max_tokens: None,4039 max_tokens: None,
@@ -4055,6 +4084,7 @@ capable_model = 5
4055 model: "".into(), // empty model → error4084 model: "".into(), // empty model → error
4056 display_name: None,4085 display_name: None,
4057 system_prompt: None,4086 system_prompt: None,
4087+ system_prompt_file: None,
4058 supports_vision: None,4088 supports_vision: None,
4059 context_window: 0, // zero window → error4089 context_window: 0, // zero window → error
4060 max_tokens: None,4090 max_tokens: None,
@@ -9,6 +9,12 @@ pub struct ProviderConfig {
9 pub model: String,9 pub model: String,
10 pub base_url: Option<String>,10 pub base_url: Option<String>,
11 pub system_prompt: Option<String>,11 pub system_prompt: Option<String>,
12+ /// Path to a file whose entire contents replace the built-in coding
13+ /// system prompt. Inline `system_prompt` wins when both are set; a
14+ /// relative path resolves against the config directory. Unset (the
15+ /// default) keeps the built-in persona, byte for byte.
16+ #[serde(default, skip_serializing_if = "Option::is_none")]
17+ pub system_prompt_file: Option<String>,
12 /// Explicit image-input capability override. `None` keeps automatic model-name18 /// Explicit image-input capability override. `None` keeps automatic model-name
13 /// detection; `Some(true/false)` is authoritative for custom model ids and19 /// detection; `Some(true/false)` is authoritative for custom model ids and
14 /// compatibility gateways whose wire name does not advertise its modalities.20 /// compatibility gateways whose wire name does not advertise its modalities.
@@ -144,6 +150,10 @@ pub struct ModelProfileConfig {
144 /// design §14.5). Projected from a legacy provider's `system_prompt`.150 /// design §14.5). Projected from a legacy provider's `system_prompt`.
145 #[serde(default, skip_serializing_if = "Option::is_none")]151 #[serde(default, skip_serializing_if = "Option::is_none")]
146 pub system_prompt: Option<String>,152 pub system_prompt: Option<String>,
153+ /// File form of `system_prompt` — the whole file replaces the built-in
154+ /// coding persona. Inline wins when both are set.
155+ #[serde(default, skip_serializing_if = "Option::is_none")]
156+ pub system_prompt_file: Option<String>,
147 /// Explicit image-input capability override. `None` means Auto.157 /// Explicit image-input capability override. `None` means Auto.
148 #[serde(default, skip_serializing_if = "Option::is_none")]158 #[serde(default, skip_serializing_if = "Option::is_none")]
149 pub supports_vision: Option<bool>,159 pub supports_vision: Option<bool>,
@@ -198,6 +208,7 @@ pub struct ResolvedModelConfig {
198 pub context_window: usize,208 pub context_window: usize,
199 pub max_tokens: Option<usize>,209 pub max_tokens: Option<usize>,
200 pub system_prompt: Option<String>,210 pub system_prompt: Option<String>,
211+ pub system_prompt_file: Option<String>,
201 pub user_agent: Option<String>,212 pub user_agent: Option<String>,
202 pub skip_tls_verify: bool,213 pub skip_tls_verify: bool,
203 pub thinking_type: Option<String>,214 pub thinking_type: Option<String>,
@@ -229,7 +240,81 @@ impl std::fmt::Debug for ResolvedModelConfig {
229 }240 }
230}241}
231 242 
243+/// Resolve a configured system-prompt OVERRIDE: inline text wins, else the file
244+/// is read from disk (a relative path resolves against `config_dir`).
245+///
246+/// Returns `(override, warning)`. This crate has no logger — it hands diagnostics
247+/// back to the caller (the same shape `Config::load_with_diagnostics` uses), so
248+/// `atomcode-coding` / `atomcode-daemon` log them with their own `tracing`.
249+///
250+/// An empty value or an unreadable file yields `None` **plus a warning**: falling
251+/// back to the built-in persona is far better than booting an agent with an empty
252+/// system prompt, but a silent fallback would make a typo look like the feature
253+/// simply does not work.
254+pub fn resolve_system_prompt_override(
255+ inline: Option<&str>,
256+ file: Option<&str>,
257+ config_dir: &std::path::Path,
258+) -> (Option<String>, Option<String>) {
259+ if let Some(text) = inline.map(str::trim).filter(|s| !s.is_empty()) {
260+ return (Some(text.to_string()), None);
261+ }
262+ let Some(raw) = file.map(str::trim).filter(|s| !s.is_empty()) else {
263+ return (None, None);
264+ };
265+ let path = std::path::Path::new(raw);
266+ let path = if path.is_absolute() {
267+ path.to_path_buf()
268+ } else {
269+ config_dir.join(path)
270+ };
271+ match std::fs::read_to_string(&path) {
272+ Ok(body) if !body.trim().is_empty() => (Some(body.trim().to_string()), None),
273+ Ok(_) => (
274+ None,
275+ Some(format!(
276+ "system_prompt_file {} is empty; using the built-in persona",
277+ path.display()
278+ )),
279+ ),
280+ Err(e) => (
281+ None,
282+ Some(format!(
283+ "system_prompt_file {} is unreadable ({e}); using the built-in persona",
284+ path.display()
285+ )),
286+ ),
287+ }
288+}
289+ 
290+impl ProviderConfig {
291+ /// See [`resolve_system_prompt_override`]. `None` when neither field is set,
292+ /// which is what keeps the default path byte-for-byte unchanged.
293+ pub fn resolved_system_prompt(
294+ &self,
295+ config_dir: &std::path::Path,
296+ ) -> (Option<String>, Option<String>) {
297+ resolve_system_prompt_override(
298+ self.system_prompt.as_deref(),
299+ self.system_prompt_file.as_deref(),
300+ config_dir,
301+ )
302+ }
303+}
304+ 
232impl ResolvedModelConfig {305impl ResolvedModelConfig {
306+ /// See [`resolve_system_prompt_override`].
307+ pub fn resolved_system_prompt(
308+ &self,
309+ config_dir: &std::path::Path,
310+ ) -> (Option<String>, Option<String>) {
311+ resolve_system_prompt_override(
312+ self.system_prompt.as_deref(),
313+ self.system_prompt_file.as_deref(),
314+ config_dir,
315+ )
316+ }
317+ 
233 /// Reconstruct a legacy-shaped [`ProviderConfig`] from this resolution.318 /// Reconstruct a legacy-shaped [`ProviderConfig`] from this resolution.
234 /// Lets consumers that still key off `config.providers` (the daemon live319 /// Lets consumers that still key off `config.providers` (the daemon live
235 /// runtime, the TUI `/think`/`/effort` readers) accept a new-schema or320 /// runtime, the TUI `/think`/`/effort` readers) accept a new-schema or
@@ -243,6 +328,7 @@ impl ResolvedModelConfig {
243 model: self.model.clone(),328 model: self.model.clone(),
244 base_url: self.base_url.clone(),329 base_url: self.base_url.clone(),
245 system_prompt: self.system_prompt.clone(),330 system_prompt: self.system_prompt.clone(),
331+ system_prompt_file: self.system_prompt_file.clone(),
246 supports_vision: Some(self.supports_vision),332 supports_vision: Some(self.supports_vision),
247 user_agent: self.user_agent.clone(),333 user_agent: self.user_agent.clone(),
248 context_window: self.context_window,334 context_window: self.context_window,
@@ -404,6 +490,76 @@ pub fn default_context_window_for(provider_type: &str) -> usize {
404 490 
405#[cfg(test)]491#[cfg(test)]
406mod tests {492mod tests {
493+ use super::resolve_system_prompt_override as resolve;
494+ 
495+ #[test]
496+ fn inline_system_prompt_wins_over_file() {
497+ let d = tempfile::tempdir().unwrap();
498+ std::fs::write(d.path().join("p.md"), "FROM FILE").unwrap();
499+ let (got, warn) = resolve(Some("INLINE"), Some("p.md"), d.path());
500+ assert_eq!(got.as_deref(), Some("INLINE"));
501+ assert!(warn.is_none());
502+ }
503+ 
504+ #[test]
505+ fn file_is_read_relative_to_the_config_dir() {
506+ let d = tempfile::tempdir().unwrap();
507+ std::fs::write(
508+ d.path().join("p.md"),
509+ " FROM FILE
510+",
511+ )
512+ .unwrap();
513+ let (got, warn) = resolve(None, Some("p.md"), d.path());
514+ assert_eq!(got.as_deref(), Some("FROM FILE"), "trimmed file body");
515+ assert!(warn.is_none());
516+ }
517+ 
518+ #[test]
519+ fn absolute_file_path_is_used_as_is() {
520+ let d = tempfile::tempdir().unwrap();
521+ let p = d.path().join("abs.md");
522+ std::fs::write(&p, "ABS").unwrap();
523+ let (got, _) = resolve(
524+ None,
525+ Some(p.to_str().unwrap()),
526+ std::path::Path::new("/nope"),
527+ );
528+ assert_eq!(got.as_deref(), Some("ABS"));
529+ }
530+ 
531+ #[test]
532+ fn nothing_configured_means_no_override() {
533+ let d = tempfile::tempdir().unwrap();
534+ assert_eq!(resolve(None, None, d.path()), (None, None));
535+ // Empty / whitespace-only values are "not configured", not "empty prompt".
536+ assert_eq!(resolve(Some(" "), Some(""), d.path()), (None, None));
537+ }
538+ 
539+ #[test]
540+ fn unreadable_or_empty_file_warns_and_falls_back() {
541+ let d = tempfile::tempdir().unwrap();
542+ let (got, warn) = resolve(None, Some("missing.md"), d.path());
543+ assert!(got.is_none());
544+ assert!(
545+ warn.unwrap().contains("unreadable"),
546+ "a typo must not look like a no-op"
547+ );
548+ 
549+ std::fs::write(
550+ d.path().join("blank.md"),
551+ "
552+",
553+ )
554+ .unwrap();
555+ let (got, warn) = resolve(None, Some("blank.md"), d.path());
556+ assert!(
557+ got.is_none(),
558+ "never boot the agent with an empty system prompt"
559+ );
560+ assert!(warn.unwrap().contains("empty"));
561+ }
562+ 
407 use super::*;563 use super::*;
408 564 
409 #[test]565 #[test]
@@ -528,6 +684,7 @@ mod tests {
528 model: "gpt-4o".into(),684 model: "gpt-4o".into(),
529 base_url: None,685 base_url: None,
530 system_prompt: None,686 system_prompt: None,
687+ system_prompt_file: None,
531 supports_vision: None,688 supports_vision: None,
532 user_agent: None,689 user_agent: None,
533 context_window: 128000,690 context_window: 128000,
@@ -559,6 +716,7 @@ mod tests {
559 model: "gpt-4o".into(),716 model: "gpt-4o".into(),
560 base_url: Some("https://self-signed.example.com/v1".into()),717 base_url: Some("https://self-signed.example.com/v1".into()),
561 system_prompt: None,718 system_prompt: None,
719+ system_prompt_file: None,
562 supports_vision: None,720 supports_vision: None,
563 user_agent: None,721 user_agent: None,
564 context_window: 128000,722 context_window: 128000,
@@ -638,6 +796,7 @@ mod tests {
638 model: "gpt-4o".into(),796 model: "gpt-4o".into(),
639 base_url: None,797 base_url: None,
640 system_prompt: None,798 system_prompt: None,
799+ system_prompt_file: None,
641 supports_vision: None,800 supports_vision: None,
642 user_agent: None,801 user_agent: None,
643 context_window: 128000,802 context_window: 128000,
@@ -668,6 +827,7 @@ mod tests {
668 model: "gpt-4o".into(),827 model: "gpt-4o".into(),
669 base_url: None,828 base_url: None,
670 system_prompt: None,829 system_prompt: None,
830+ system_prompt_file: None,
671 supports_vision: None,831 supports_vision: None,
672 user_agent: None,832 user_agent: None,
673 context_window: 128000,833 context_window: 128000,
@@ -698,6 +858,7 @@ mod tests {
698 model: "gpt-4o".into(),858 model: "gpt-4o".into(),
699 base_url: None,859 base_url: None,
700 system_prompt: None,860 system_prompt: None,
861+ system_prompt_file: None,
701 supports_vision: None,862 supports_vision: None,
702 user_agent: None,863 user_agent: None,
703 context_window: 128000,864 context_window: 128000,
@@ -9,6 +9,7 @@ fn provider(model: &str) -> ProviderConfig {
9 model: model.into(),9 model: model.into(),
10 base_url: Some("https://example.test/v1".into()),10 base_url: Some("https://example.test/v1".into()),
11 system_prompt: None,11 system_prompt: None,
12+ system_prompt_file: None,
12 supports_vision: None,13 supports_vision: None,
13 user_agent: None,14 user_agent: None,
14 context_window: 128_000,15 context_window: 128_000,
@@ -243,6 +243,7 @@ mod tests {
243 model: "model".into(),243 model: "model".into(),
244 base_url: Some(base_url.into()),244 base_url: Some(base_url.into()),
245 system_prompt: None,245 system_prompt: None,
246+ system_prompt_file: None,
246 supports_vision: None,247 supports_vision: None,
247 user_agent: None,248 user_agent: None,
248 context_window: 128_000,249 context_window: 128_000,
@@ -523,6 +523,7 @@ fn insert_account_models(
523 model,523 model,
524 display_name: request.display_name.clone(),524 display_name: request.display_name.clone(),
525 system_prompt: None,525 system_prompt: None,
526+ system_prompt_file: None,
526 supports_vision: request.supports_vision,527 supports_vision: request.supports_vision,
527 context_window: request528 context_window: request
528 .context_window529 .context_window
@@ -851,6 +852,7 @@ pub(crate) async fn create_provider(Json(req): Json<CreateProviderRequest>) -> i
851 model: req.model,852 model: req.model,
852 base_url: req.base_url,853 base_url: req.base_url,
853 system_prompt: None,854 system_prompt: None,
855+ system_prompt_file: None,
854 supports_vision: req.supports_vision,856 supports_vision: req.supports_vision,
855 user_agent: req.user_agent,857 user_agent: req.user_agent,
856 context_window,858 context_window,
@@ -8067,6 +8067,7 @@ mod tests {
8067 model: model.into(),8067 model: model.into(),
8068 base_url: Some(base_url),8068 base_url: Some(base_url),
8069 system_prompt: None,8069 system_prompt: None,
8070+ system_prompt_file: None,
8070 supports_vision: None,8071 supports_vision: None,
8071 user_agent: None,8072 user_agent: None,
8072 context_window: 128_000,8073 context_window: 128_000,
@@ -294,6 +294,16 @@ pub(crate) fn chat_runtime_config(
294 let resolved = config.provider_config_for_selection(provider_name);294 let resolved = config.provider_config_for_selection(provider_name);
295 let p = resolved.as_ref();295 let p = resolved.as_ref();
296 atomcode_coding::CodingRuntimeConfig {296 atomcode_coding::CodingRuntimeConfig {
297+ // Deployment-configured persona replacement (model `system_prompt` /
298+ // `system_prompt_file`). `None` for every install that has not set it.
299+ persona_override: p
300+ .map(|p| p.resolved_system_prompt(&atomcode_config::config::Config::config_dir()))
301+ .and_then(|(prompt, warning)| {
302+ if let Some(w) = warning {
303+ tracing::warn!("{w}");
304+ }
305+ prompt
306+ }),
297 api_key: p.and_then(|p| p.api_key.clone()).unwrap_or_default(),307 api_key: p.and_then(|p| p.api_key.clone()).unwrap_or_default(),
298 base_url: p.and_then(|p| p.base_url.clone()).unwrap_or_default(),308 base_url: p.and_then(|p| p.base_url.clone()).unwrap_or_default(),
299 model: p.map(|p| p.model.clone()).unwrap_or_default(),309 model: p.map(|p| p.model.clone()).unwrap_or_default(),
@@ -303,6 +313,10 @@ pub(crate) fn chat_runtime_config(
303 config.language,313 config.language,
304 )),314 )),
305 todo: config.tools.todo.clone(),315 todo: config.tools.todo.clone(),
316+ tool_filter: atomcode_coding::toolfilter::ToolFilter::new(
317+ &config.tools.allow,
318+ &config.tools.deny,
319+ ),
306 provider_name: provider_name.to_string(),320 provider_name: provider_name.to_string(),
307 working_dir: working_dir.to_path_buf(),321 working_dir: working_dir.to_path_buf(),
308 context_window: p.map(|p| p.context_window as u32).unwrap_or(128_000),322 context_window: p.map(|p| p.context_window as u32).unwrap_or(128_000),
@@ -11133,6 +11133,7 @@ mod external_config_tests {
11133 model: model.into(),11133 model: model.into(),
11134 base_url: Some(base_url.into()),11134 base_url: Some(base_url.into()),
11135 system_prompt: None,11135 system_prompt: None,
11136+ system_prompt_file: None,
11136 supports_vision: None,11137 supports_vision: None,
11137 user_agent: None,11138 user_agent: None,
11138 context_window: 128_000,11139 context_window: 128_000,
@@ -146,6 +146,7 @@ pub fn provision_openrouter(
146 model: m.id.clone(),146 model: m.id.clone(),
147 display_name: m.name.clone(),147 display_name: m.name.clone(),
148 system_prompt: None,148 system_prompt: None,
149+ system_prompt_file: None,
149 supports_vision: None,150 supports_vision: None,
150 context_window: if m.context_length > 0 {151 context_window: if m.context_length > 0 {
151 m.context_length as usize152 m.context_length as usize
@@ -402,6 +402,7 @@ mod tests {
402 model: model.to_string(),402 model: model.to_string(),
403 base_url: None,403 base_url: None,
404 system_prompt: None,404 system_prompt: None,
405+ system_prompt_file: None,
405 supports_vision: None,406 supports_vision: None,
406 user_agent: None,407 user_agent: None,
407 context_window: 128000,408 context_window: 128000,
@@ -1402,6 +1402,7 @@ impl ProviderPanel {
1402 model: model_name.clone(),1402 model: model_name.clone(),
1403 display_name: None,1403 display_name: None,
1404 system_prompt: None,1404 system_prompt: None,
1405+ system_prompt_file: None,
1405 supports_vision,1406 supports_vision,
1406 context_window,1407 context_window,
1407 max_tokens: None,1408 max_tokens: None,