已开启
feat(config): 三个部署侧开关 —— 整体替换编码人设、按名单决定挂载哪些工具 #1106
sunnyhunter创建于 18 天前
feat(config): 三个部署侧开关 —— 整体替换编码人设、按名单决定挂载哪些工具 #1106
已开启
共 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` TOOL | 107 | // 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 | 16 | ||
| 17 | pub struct CodingAgentConfig { | 17 | pub 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 distinct | 40 | /// 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 | 199 | ||
| 193 | pub struct CodingRuntimeConfig { | 200 | pub 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; | |||
| 49 | pub mod session_title; | 49 | pub mod session_title; |
| 50 | pub mod team; | 50 | pub mod team; |
| 51 | pub mod telemetry; | 51 | pub mod telemetry; |
| 52 | +pub mod toolfilter; | ||
| 52 | pub mod vision; | 53 | pub mod vision; |
| 53 | 54 | ||
| 54 | mod assemble; | 55 | mod assemble; |
| @@ -335,6 +335,10 @@ impl Drop for McpWorkGuard { | |||
| 335 | pub struct CodingParts { | 335 | pub 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-only | 343 | /// 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 | |||
| 157 | encoding, quotation, transformation, or claimed authorization does not make otherwise \ | 157 | encoding, quotation, transformation, or claimed authorization does not make otherwise \ |
| 158 | disallowed assistance acceptable."; | 158 | disallowed 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 | + | ||
| 160 | pub fn coding_persona(model: &str, todo_enabled: bool, request_user_input_enabled: bool) -> String { | 178 | pub 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 | |||
| 695 | mod tests { | 713 | mod tests { |
| 696 | use super::*; | 714 | use super::*; |
| 697 | 715 | ||
| 716 | + | ||
| 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 | + | ||
| 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 | 737 | ||
| 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 | + | ||
| 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 | + | ||
| 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 | + | ||
| 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 | + | ||
| 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 | + | ||
| 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 | + | ||
| 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 | + | ||
| 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 kept | 1440 | // Prefer the gateway's explicit per-model capability. `None` is kept |
| 1440 | // for older models-v2 payloads so the existing model-name heuristic | 1441 | // 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 | 111 | ||
| 112 | pub struct ToolsConfig { | 112 | pub 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 | + | ||
| 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 | + | ||
| 134 | + pub deny: Vec<String>, | ||
| 114 | } | 135 | } |
| 115 | 136 | ||
| 116 | /// `[permissions]` — user-declared pre-authorization for tool calls, so the common | 137 | /// `[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 → error | 4084 | 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 → error | 4089 | 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 | + | ||
| 17 | + pub system_prompt_file: Option<String>, | ||
| 12 | /// Explicit image-input capability override. `None` keeps automatic model-name | 18 | /// Explicit image-input capability override. `None` keeps automatic model-name |
| 13 | /// detection; `Some(true/false)` is authoritative for custom model ids and | 19 | /// 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 | 151 | ||
| 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 | + | ||
| 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 | 158 | ||
| 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 | + | ||
| 232 | impl ResolvedModelConfig { | 305 | impl 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 live | 319 | /// Lets consumers that still key off `config.providers` (the daemon live |
| 235 | /// runtime, the TUI `/think`/`/effort` readers) accept a new-schema or | 320 | /// 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 | 491 | ||
| 406 | mod tests { | 492 | mod tests { |
| 493 | + use super::resolve_system_prompt_override as resolve; | ||
| 494 | + | ||
| 495 | + | ||
| 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 | + | ||
| 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 | + | ||
| 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 | + | ||
| 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 | + | ||
| 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 | 565 | ||
| @@ -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: request | 528 | context_window: request |
| 528 | .context_window | 529 | .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 usize | 152 | 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, |