From bc59c25b05605160e1870bf12d43af68db57144a Mon Sep 17 00:00:00 2001 From: limityan Date: Mon, 27 Jul 2026 16:11:22 +0800 Subject: [PATCH] feat: preserve workspace instruction and skill intent Resolve workspace instructions through the active local or remote filesystem. Honor source-level implicit invocation policies without changing explicit skill access. --- docs/architecture/cli-product-line-design.md | 11 +- .../prompt_builder/prompt_builder_impl.rs | 63 +++- .../src/agentic/deep_review/capabilities.rs | 112 +++++-- .../src/agentic/execution/execution_engine.rs | 284 ++++++++++++++++-- .../core/src/agentic/skill_agent_snapshot.rs | 9 +- .../tools/implementations/skill_tool.rs | 60 ++++ .../tools/implementations/skills/registry.rs | 96 +++++- .../tools/implementations/skills/resolver.rs | 2 + .../core/src/service/instruction_context.rs | 17 ++ .../execution/agent-runtime/src/skills/mod.rs | 6 +- .../agent-runtime/src/skills/selection.rs | 9 + .../agent-runtime/src/skills/types.rs | 65 ++++ .../agent-runtime/tests/skill_contracts.rs | 103 ++++++- .../src/workspace_instructions.rs | 87 ++++-- .../tests/storage_owner_contracts.rs | 24 ++ .../tests/workspace_instruction_contracts.rs | 31 ++ 16 files changed, 904 insertions(+), 75 deletions(-) create mode 100644 src/crates/services/services-core/tests/workspace_instruction_contracts.rs diff --git a/docs/architecture/cli-product-line-design.md b/docs/architecture/cli-product-line-design.md index e4f14b574d..28ec3dc4c3 100644 --- a/docs/architecture/cli-product-line-design.md +++ b/docs/architecture/cli-product-line-design.md @@ -508,9 +508,16 @@ MCP C0a:发现 -> 安全投影 -> 预览 | 显式 apply -> 原子写入 disabl 规则文件优先复用项目已有文件,不复制出第二份内容。若不同生态规则冲突,导入报告必须展示目标文件、 优先级和冲突段,不能自动拼接。 +当前 Workspace Instructions 只消费真实工作区根:本地和 Remote 共用 `WorkspaceFileSystem` 读取, +`AGENTS.override.md` 文件存在时替代同目录 `AGENTS.md`(空文件也不回退),`CLAUDE.md` 继续作为独立来源按既有顺序追加。 +运行时尚无稳定的嵌套活动目录事实,因此不声明 root-to-cwd 级联;全局规则、Claude rules/import、OpenCode +`instructions` glob/URL、变化监听和冲突报告也不属于当前实现。 + 现有对 `.claude/.codex/.opencode/.agents` Skill 根的直接发现已经保留来源身份和全局/项目使用范围,并在 GUI/TUI -展示来源和默认覆盖状态,模式配置再展示实际采用项;固定根顺序保持为 Skill Registry 的独立回归契约。变化监听与可见性测试仍需 -后续补齐。OpenCode 兼容来源继续直接发现官方目录;Codex/Claude 是否增加新的持续来源另行决定。 +展示来源和默认覆盖状态,模式配置再展示实际采用项;固定根顺序保持为 Skill Registry 的独立回归契约。 +Skill Registry 还保留来源资产声明的隐式调用意图:Claude `SKILL.md` 的 `disable-model-invocation: true` 与 Codex +`agents/openai.yaml` 的 `policy.allow_implicit_invocation: false` 都会让 Skill 不进入模型自动目录,但不影响 `/skills`、 +模式配置和显式加载。BitFun 不继承来源产品的全局启停策略,也未实现 URL/额外根和自动变化监听。 Skill 说明和索引可按 L1 处理,脚本、URL 和外部依赖按 L2 确认;显式导入仍不得复制凭据值。MCP 启用状态按 OpenCode 来源解释,首次连接、策略限制和凭据缺失分别显示。 diff --git a/src/crates/assembly/core/src/agentic/agents/prompt_builder/prompt_builder_impl.rs b/src/crates/assembly/core/src/agentic/agents/prompt_builder/prompt_builder_impl.rs index 4778c9ef59..3b6029eb68 100644 --- a/src/crates/assembly/core/src/agentic/agents/prompt_builder/prompt_builder_impl.rs +++ b/src/crates/assembly/core/src/agentic/agents/prompt_builder/prompt_builder_impl.rs @@ -56,6 +56,10 @@ pub struct PromptBuilderContext { pub remote_file_delivery_channel: bool, /// The active response surface can render Markdown image syntax inline. pub inline_markdown_image_display: bool, + /// Resolved through the active local or remote workspace filesystem provider. + pub workspace_instruction_files_context: Option, + /// Distinguishes a resolved empty result from a caller that has not resolved instructions. + pub workspace_instruction_files_context_resolved: bool, } impl PromptBuilderContext { @@ -76,6 +80,8 @@ impl PromptBuilderContext { runtime_context_needs: RuntimeContextNeeds::default(), remote_file_delivery_channel: false, inline_markdown_image_display: false, + workspace_instruction_files_context: None, + workspace_instruction_files_context_resolved: false, } } @@ -118,6 +124,12 @@ impl PromptBuilderContext { self.inline_markdown_image_display = enabled; self } + + pub fn with_workspace_instruction_files_context(mut self, context: Option) -> Self { + self.workspace_instruction_files_context = context; + self.workspace_instruction_files_context_resolved = true; + self + } } pub async fn build_prompt_context_for_workspace( @@ -325,9 +337,13 @@ impl PromptBuilder { additional_sections.push(self.get_workspace_context()); } - if self.context.remote_execution.is_none() { - let workspace = Path::new(&self.context.workspace_path); - if policy.includes(UserContextSection::WorkspaceInstructions) { + if policy.includes(UserContextSection::WorkspaceInstructions) { + if let Some(prompt) = &self.context.workspace_instruction_files_context { + additional_sections.push(prompt.clone()); + } else if !self.context.workspace_instruction_files_context_resolved + && self.context.remote_execution.is_none() + { + let workspace = Path::new(&self.context.workspace_path); match build_workspace_instruction_files_context(workspace).await { Ok(Some(prompt)) => additional_sections.push(prompt), Ok(None) => {} @@ -857,6 +873,47 @@ mod tests { assert!(!runtime_context.contains("Local BitFun client OS:")); } + #[tokio::test] + async fn remote_user_context_keeps_port_resolved_workspace_instructions() { + let context = PromptBuilderContext::new("/workspace/project", None, None) + .with_remote_prompt_overlay( + RemoteExecutionHints { + connection_display_name: "dev-server".to_string(), + kernel_name: "Linux".to_string(), + hostname: "devbox".to_string(), + }, + None, + ) + .with_workspace_instruction_files_context(Some( + "## Codebase and user instructions\n\n\nremote rules\n" + .to_string(), + )); + + let user_context = PromptBuilder::new(context) + .build_user_context_reminder(&UserContextPolicy::empty().with_workspace_instructions()) + .await + .expect("remote instructions should be rendered"); + + assert!(user_context.contains("remote rules")); + assert!(user_context.contains("AGENTS.md")); + } + + #[tokio::test] + async fn resolved_empty_instruction_context_does_not_fall_back_to_local_disk() { + let temp = tempfile::tempdir().expect("tempdir"); + std::fs::write(temp.path().join("AGENTS.md"), "stale direct-disk rules\n") + .expect("agents file"); + let context = + PromptBuilderContext::new(temp.path().to_string_lossy().to_string(), None, None) + .with_workspace_instruction_files_context(None); + + let user_context = PromptBuilder::new(context) + .build_user_context_reminder(&UserContextPolicy::empty().with_workspace_instructions()) + .await; + + assert!(user_context.is_none()); + } + #[tokio::test] async fn local_terminal_transcript_placeholder_includes_the_agents_path() { let context = PromptBuilderContext::new("workspace/root", None, None); diff --git a/src/crates/assembly/core/src/agentic/deep_review/capabilities.rs b/src/crates/assembly/core/src/agentic/deep_review/capabilities.rs index 3831a7bbb9..ee5444023a 100644 --- a/src/crates/assembly/core/src/agentic/deep_review/capabilities.rs +++ b/src/crates/assembly/core/src/agentic/deep_review/capabilities.rs @@ -62,28 +62,7 @@ pub async fn review_capability_catalog( BUILTIN_GUIDANCE, )]; - let skill_registry = get_skill_registry(); - let skills = if context.is_remote() { - if let Some(fs) = context.ws_fs() { - let root = context - .workspace - .as_ref() - .map(|workspace| workspace.root_path_string()) - .unwrap_or_default(); - skill_registry - .get_resolved_skills_for_remote_workspace(fs, &root, context.agent_type.as_deref()) - .await - } else { - Vec::new() - } - } else { - skill_registry - .get_resolved_skills_for_workspace( - context.workspace_root(), - context.agent_type.as_deref(), - ) - .await - }; + let skills = implicitly_invocable_skills(context).await; let mut skills = skills .into_iter() .filter(|skill| is_compatible_review_skill(&skill.dir_name)) @@ -169,6 +148,35 @@ pub async fn review_capability_catalog( descriptors } +async fn implicitly_invocable_skills(context: &ToolUseContext) -> Vec { + let skill_registry = get_skill_registry(); + if context.is_remote() { + if let Some(fs) = context.ws_fs() { + let root = context + .workspace + .as_ref() + .map(|workspace| workspace.root_path_string()) + .unwrap_or_default(); + skill_registry + .get_implicitly_invocable_skills_for_remote_workspace( + fs, + &root, + context.agent_type.as_deref(), + ) + .await + } else { + Vec::new() + } + } else { + skill_registry + .get_implicitly_invocable_skills_for_workspace( + context.workspace_root(), + context.agent_type.as_deref(), + ) + .await + } +} + pub async fn review_capability_catalog_for_context(context: &ToolUseContext) -> String { render_review_capability_catalog(&review_capability_catalog(context).await) } @@ -271,6 +279,14 @@ fn agent_fingerprint_material(guidance: &str, preferred_model: Option<&str>) -> } async fn load_review_skill(context: &ToolUseContext, skill_key: &str) -> BitFunResult { + let still_implicitly_invocable = implicitly_invocable_skills(context) + .await + .into_iter() + .any(|skill| skill.key == skill_key); + if !still_implicitly_invocable { + return Err(capability_changed_error()); + } + let registry = get_skill_registry(); if context.is_remote() { let fs = context.ws_fs().ok_or_else(|| { @@ -406,6 +422,26 @@ fn xml_escape(value: &str) -> String { #[cfg(test)] mod tests { use super::*; + use crate::agentic::tools::framework::ToolUseContext; + use crate::agentic::WorkspaceBinding; + use std::collections::HashMap; + use std::path::PathBuf; + + fn local_tool_context(root: PathBuf) -> ToolUseContext { + ToolUseContext { + tool_call_id: None, + agent_type: None, + session_id: None, + dialog_turn_id: None, + workspace: Some(WorkspaceBinding::new(None, root)), + loaded_deferred_tool_specs: Vec::new(), + primary_model_facts: tool_runtime::context::PrimaryModelFacts::default(), + custom_data: HashMap::new(), + computer_use_host: None, + runtime_tool_restrictions: Default::default(), + runtime_handles: bitfun_runtime_ports::ToolRuntimeHandles::default(), + } + } #[test] fn compatible_skill_uses_directory_convention_not_metadata_name() { @@ -478,4 +514,36 @@ mod tests { assert!(rendered.len() < 800); assert!(!rendered.contains("full guidance")); } + + #[tokio::test] + async fn resolve_rejects_skill_when_implicit_policy_changed_after_catalog() { + let temp = tempfile::tempdir().expect("temporary workspace"); + let skill_dir = temp + .path() + .join(".codex") + .join("skills") + .join("code-review-policy-change"); + std::fs::create_dir_all(skill_dir.join("agents")).expect("skill directories"); + std::fs::write( + skill_dir.join("SKILL.md"), + "---\nname: Policy review\ndescription: Check policy changes\n---\nReview policy-sensitive behavior.\n", + ) + .expect("skill markdown"); + let context = local_tool_context(temp.path().to_path_buf()); + let descriptor = review_capability_catalog(&context) + .await + .into_iter() + .find(|descriptor| descriptor.key().contains("code-review-policy-change")) + .expect("review skill descriptor"); + + std::fs::write( + skill_dir.join("agents").join("openai.yaml"), + "policy:\n allow_implicit_invocation: false\n", + ) + .expect("updated policy"); + + let result = + resolve_review_capability(&context, descriptor.key(), descriptor.fingerprint()).await; + assert!(result.is_err()); + } } diff --git a/src/crates/assembly/core/src/agentic/execution/execution_engine.rs b/src/crates/assembly/core/src/agentic/execution/execution_engine.rs index 27da5c379e..ab54e9291f 100644 --- a/src/crates/assembly/core/src/agentic/execution/execution_engine.rs +++ b/src/crates/assembly/core/src/agentic/execution/execution_engine.rs @@ -10,6 +10,7 @@ use super::types::{ExecutionContext, ExecutionResult, RoundContext, RoundResult} use crate::agentic::agents::{ build_prompt_context_for_workspace, get_agent_registry, PrependedPromptReminders, PromptBuilder, PromptBuilderContext, RuntimeContextNeeds, ToolListingSections, + UserContextPolicy, UserContextSection, }; use crate::agentic::context_profile::{ContextProfilePolicy, ModelCapabilityProfile}; use crate::agentic::core::{ @@ -42,6 +43,9 @@ use crate::service::config::get_global_config_service; use crate::service::config::types::{ automatic_max_output_tokens, model_runtime_binding_fingerprint, ModelCapability, ModelCategory, }; +use crate::service::instruction_context::{ + build_workspace_instruction_files_context, build_workspace_instruction_files_context_with_fs, +}; use crate::util::errors::{BitFunError, BitFunResult}; use crate::util::token_counter::TokenCounter; use crate::util::types::Message as AIMessage; @@ -993,16 +997,65 @@ impl ExecutionEngine { }) } + async fn build_user_context_for_cache_miss( + workspace: Option<&WorkspaceBinding>, + workspace_services: Option<&crate::agentic::workspace::WorkspaceServices>, + mut prompt_context: PromptBuilderContext, + policy: &UserContextPolicy, + ) -> (Option, bool) { + let mut cacheable = true; + if policy.includes(UserContextSection::WorkspaceInstructions) { + let instruction_context: BitFunResult> = + if let Some(workspace) = workspace { + if let Some(services) = workspace_services { + build_workspace_instruction_files_context_with_fs( + services.fs.as_ref(), + &workspace.root_path_string(), + ) + .await + } else if workspace.is_remote() { + cacheable = false; + Ok(None) + } else { + build_workspace_instruction_files_context(workspace.root_path()).await + } + } else { + Ok(None) + }; + let instruction_context = match instruction_context { + Ok(instruction_context) => instruction_context, + Err(error) => { + cacheable = false; + warn!( + "Failed to build workspace instruction context: path={} error={}", + workspace + .map(WorkspaceBinding::root_path_string) + .unwrap_or_else(|| "".to_string()), + error + ); + None + } + }; + prompt_context = + prompt_context.with_workspace_instruction_files_context(instruction_context); + } + + let user_context = PromptBuilder::new(prompt_context) + .build_user_context_reminder(policy) + .await; + (user_context, cacheable) + } + async fn build_cached_prepended_prompt_reminders( &self, - session_id: &str, + execution_context: &ExecutionContext, current_agent: &dyn crate::agentic::agents::Agent, prompt_context: Option<&PromptBuilderContext>, - _context_vars: &HashMap, ) -> PrependedPromptReminders { let Some(prompt_context) = prompt_context.cloned() else { return PrependedPromptReminders::default(); }; + let session_id = &execution_context.session_id; // Extract remote execution info before prompt_context is moved into PromptBuilder. let remote_connection_for_cache = prompt_context @@ -1010,7 +1063,7 @@ impl ExecutionEngine { .as_ref() .map(|remote| remote.connection_display_name.replace('|', "/")); - let prompt_builder = PromptBuilder::new(prompt_context); + let prompt_builder = PromptBuilder::new(prompt_context.clone()); let baseline_snapshot = if let Some(snapshot) = self .session_manager .skill_agent_baseline_override_snapshot(session_id) @@ -1058,17 +1111,29 @@ impl ExecutionEngine { "User context cache miss: session_id={}, scope_key={}", session_id, user_context_identity.scope_key ); - let built_user_context = prompt_builder - .build_user_context_reminder(¤t_agent.user_context_policy()) - .await; - if let Some(ref user_context) = built_user_context { - self.session_manager - .remember_user_context( - session_id, - user_context_identity.clone(), - user_context.clone(), - ) - .await; + let user_context_policy = current_agent.user_context_policy(); + let (built_user_context, cacheable) = Self::build_user_context_for_cache_miss( + execution_context.workspace.as_ref(), + execution_context.workspace_services.as_ref(), + prompt_context, + &user_context_policy, + ) + .await; + if cacheable { + if let Some(ref user_context) = built_user_context { + self.session_manager + .remember_user_context( + session_id, + user_context_identity.clone(), + user_context.clone(), + ) + .await; + } + } else { + debug!( + "User context was not cached after workspace instruction resolution failed: session_id={}, scope_key={}", + session_id, user_context_identity.scope_key + ); } built_user_context }; @@ -1145,10 +1210,9 @@ impl ExecutionEngine { .await; let prepended_prompt_reminders = self .build_cached_prepended_prompt_reminders( - &input.context.session_id, + input.context, input.current_agent, prompt_context.as_ref(), - &input.context.context, ) .await; let system_prompt = self @@ -4147,16 +4211,105 @@ impl ExecutionEngine { #[cfg(test)] mod tests { use super::{ContextHealthSnapshot, ExecutionEngine, TurnPromptScaffold}; - use crate::agentic::agents::PrependedPromptReminders; + use crate::agentic::agents::{ + PrependedPromptReminders, PromptBuilderContext, UserContextPolicy, + }; use crate::agentic::core::{InternalReminderKind, Message, MessageRole, ToolCall, ToolResult}; use crate::agentic::session::{TokenAnchor, TokenAnchorInput}; use crate::agentic::tools::ToolRuntimeRestrictions; + use crate::agentic::workspace::{local_workspace_services, WorkspaceBinding}; use crate::service::config::types::AIConfig; use crate::service::config::types::AIModelConfig; + use crate::service::remote_ssh::workspace_state::workspace_session_identity; use crate::util::types::ToolDefinition; + use bitfun_runtime_ports::{WorkspaceDirEntry, WorkspaceFileSystem}; use serde_json::json; use sha2::{Digest, Sha256}; use std::collections::HashMap; + use std::path::PathBuf; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + use std::sync::Arc; + + #[derive(Clone)] + struct InstructionWorkspaceFs { + operation_count: Arc, + fail_next_probe: Arc, + } + + impl InstructionWorkspaceFs { + fn recovering() -> Self { + Self { + operation_count: Arc::new(AtomicUsize::new(0)), + fail_next_probe: Arc::new(AtomicBool::new(true)), + } + } + + fn record(&self) { + self.operation_count.fetch_add(1, Ordering::SeqCst); + } + + fn operation_count(&self) -> usize { + self.operation_count.load(Ordering::SeqCst) + } + } + + #[async_trait::async_trait] + impl WorkspaceFileSystem for InstructionWorkspaceFs { + async fn read_file(&self, path: &str) -> anyhow::Result> { + Ok(self.read_file_text(path).await?.into_bytes()) + } + + async fn read_file_text(&self, path: &str) -> anyhow::Result { + self.record(); + Ok(if path.ends_with("AGENTS.md") { + "Recovered workspace instructions.".to_string() + } else { + String::new() + }) + } + + async fn write_file(&self, _path: &str, _contents: &[u8]) -> anyhow::Result<()> { + anyhow::bail!("writes are not supported") + } + + async fn exists(&self, path: &str) -> anyhow::Result { + self.is_file(path).await + } + + async fn is_file(&self, path: &str) -> anyhow::Result { + self.record(); + if path.ends_with("AGENTS.override.md") + && self.fail_next_probe.swap(false, Ordering::SeqCst) + { + anyhow::bail!("temporary workspace connection failure") + } + Ok(path.ends_with("AGENTS.md") && !path.ends_with("AGENTS.override.md")) + } + + async fn is_dir(&self, _path: &str) -> anyhow::Result { + Ok(false) + } + + async fn read_dir(&self, _path: &str) -> anyhow::Result> { + Ok(Vec::new()) + } + } + + fn workspace_with_fs( + fs: Arc, + ) -> ( + WorkspaceBinding, + crate::agentic::workspace::WorkspaceServices, + ) { + let workspace_root = PathBuf::from("/workspace"); + let mut workspace_services = + local_workspace_services(workspace_root.to_string_lossy().to_string()); + workspace_services.fs = fs; + ( + WorkspaceBinding::new(None, workspace_root), + workspace_services, + ) + } fn build_model(id: &str, name: &str, model_name: &str) -> AIModelConfig { AIModelConfig { @@ -4176,6 +4329,103 @@ mod tests { } } + #[tokio::test] + async fn user_context_without_instruction_policy_does_not_read_instruction_files() { + let fs = InstructionWorkspaceFs::recovering(); + let (workspace, workspace_services) = workspace_with_fs(Arc::new(fs.clone())); + let prompt_context = PromptBuilderContext::new( + "/workspace".to_string(), + Some("session".to_string()), + Some("model".to_string()), + ); + let (_, cacheable) = ExecutionEngine::build_user_context_for_cache_miss( + Some(&workspace), + Some(&workspace_services), + prompt_context, + &UserContextPolicy::empty().with_workspace_context(), + ) + .await; + + assert!(cacheable); + assert_eq!(fs.operation_count(), 0); + } + + #[tokio::test] + async fn workspace_instruction_read_failure_is_not_cacheable_and_can_recover() { + let fs = InstructionWorkspaceFs::recovering(); + let (workspace, workspace_services) = workspace_with_fs(Arc::new(fs)); + let prompt_context = PromptBuilderContext::new( + "/workspace".to_string(), + Some("session".to_string()), + Some("model".to_string()), + ); + let policy = UserContextPolicy::empty() + .with_workspace_context() + .with_workspace_instructions(); + + let (degraded_context, degraded_cacheable) = + ExecutionEngine::build_user_context_for_cache_miss( + Some(&workspace), + Some(&workspace_services), + prompt_context.clone(), + &policy, + ) + .await; + assert!(!degraded_cacheable); + assert!(!degraded_context + .as_deref() + .unwrap_or_default() + .contains("Recovered workspace instructions.")); + + let (recovered_context, recovered_cacheable) = + ExecutionEngine::build_user_context_for_cache_miss( + Some(&workspace), + Some(&workspace_services), + prompt_context, + &policy, + ) + .await; + assert!(recovered_cacheable); + assert!(recovered_context + .as_deref() + .unwrap_or_default() + .contains("Recovered workspace instructions.")); + } + + #[tokio::test] + async fn remote_workspace_without_services_is_not_cacheable() { + let identity = workspace_session_identity( + "/remote/workspace", + Some("connection-1"), + Some("remote-host"), + ) + .expect("remote identity"); + let workspace = WorkspaceBinding::new_remote( + None, + PathBuf::from("/remote/workspace"), + "connection-1".to_string(), + "Remote".to_string(), + identity, + ); + let policy = UserContextPolicy::empty() + .with_workspace_context() + .with_workspace_instructions(); + + let (_, cacheable) = ExecutionEngine::build_user_context_for_cache_miss( + Some(&workspace), + None, + PromptBuilderContext::new( + "/remote/workspace".to_string(), + Some("session".to_string()), + Some("model".to_string()), + ), + &policy, + ) + .await; + + assert!(!cacheable); + } + #[test] fn resolve_configured_fast_model_falls_back_to_primary_when_fast_is_stale() { let mut ai_config = AIConfig { diff --git a/src/crates/assembly/core/src/agentic/skill_agent_snapshot.rs b/src/crates/assembly/core/src/agentic/skill_agent_snapshot.rs index ac5882e25f..ab813f329f 100644 --- a/src/crates/assembly/core/src/agentic/skill_agent_snapshot.rs +++ b/src/crates/assembly/core/src/agentic/skill_agent_snapshot.rs @@ -144,7 +144,7 @@ async fn load_skill_entries( Some(workspace) if workspace.is_remote() => { if let Some(services) = workspace_services { registry - .get_resolved_skills_for_remote_workspace( + .get_implicitly_invocable_skills_for_remote_workspace( services.fs.as_ref(), &workspace.root_path_string(), agent_type, @@ -156,12 +156,15 @@ async fn load_skill_entries( } Some(workspace) => { registry - .get_resolved_skills_for_workspace(Some(workspace.root_path()), agent_type) + .get_implicitly_invocable_skills_for_workspace( + Some(workspace.root_path()), + agent_type, + ) .await } None => { registry - .get_resolved_skills_for_workspace(None, agent_type) + .get_implicitly_invocable_skills_for_workspace(None, agent_type) .await } }; diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/skill_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/skill_tool.rs index 94b729887d..6086cd39c3 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/skill_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/skill_tool.rs @@ -321,6 +321,7 @@ mod tests { use crate::service::remote_ssh::workspace_state::workspace_session_identity; use async_trait::async_trait; use serde_json::json; + use std::fs; use std::path::PathBuf; use std::sync::Arc; @@ -561,6 +562,9 @@ Use the remote project skill. "/remote/project/.bitfun/skills/z-last/SKILL.md" => { Ok("---\nname: z-last\ndescription: last\n---\n\nz\n".to_string()) } + "/remote/project/.bitfun/skills/z-last/agents/openai.yaml" => { + Ok("policy:\n allow_implicit_invocation: false\n".to_string()) + } "/remote/project/.bitfun/skills/a-first/SKILL.md" => { Ok("---\nname: A-First\ndescription: first\n---\n\na\n".to_string()) } @@ -586,6 +590,7 @@ Use the remote project skill. Ok(matches!( path, "/remote/project/.bitfun/skills/z-last/SKILL.md" + | "/remote/project/.bitfun/skills/z-last/agents/openai.yaml" | "/remote/project/.bitfun/skills/a-first/SKILL.md" | "/remote/project/.bitfun/skills/dup-one/SKILL.md" | "/remote/project/.bitfun/skills/dup-two/SKILL.md" @@ -658,4 +663,59 @@ Use the remote project skill. Some("dup one") ); } + + #[tokio::test] + async fn remote_codex_policy_hides_only_the_implicit_model_catalog() { + let registry = SkillRegistry::global(); + let resolved = registry + .get_resolved_skills_for_remote_workspace(&OrderingRemoteFs, "/remote/project", None) + .await; + let implicit = registry + .get_implicitly_invocable_skills_for_remote_workspace( + &OrderingRemoteFs, + "/remote/project", + None, + ) + .await; + + assert!(resolved.iter().any(|skill| skill.name == "z-last")); + assert!(!implicit.iter().any(|skill| skill.name == "z-last")); + } + + #[tokio::test] + async fn local_codex_policy_hides_only_the_implicit_model_catalog() { + let temp = tempfile::tempdir().expect("tempdir"); + let skill_dir = temp.path().join(".codex/skills/local-explicit-only"); + fs::create_dir_all(skill_dir.join("agents")).expect("skill directories"); + fs::write( + skill_dir.join("SKILL.md"), + "---\nname: local-explicit-only\ndescription: explicit only\n---\n\nRun explicitly.\n", + ) + .expect("skill markdown"); + fs::write( + skill_dir.join("agents/openai.yaml"), + "policy:\n allow_implicit_invocation: false\n", + ) + .expect("skill policy"); + + let registry = SkillRegistry::global(); + let resolved = registry + .get_resolved_skills_for_workspace(Some(temp.path()), None) + .await; + let implicit = registry + .get_implicitly_invocable_skills_for_workspace(Some(temp.path()), None) + .await; + + assert!(resolved + .iter() + .any(|skill| skill.name == "local-explicit-only")); + assert!(!implicit + .iter() + .any(|skill| skill.name == "local-explicit-only")); + let loaded = registry + .find_and_load_skill_for_workspace("local-explicit-only", Some(temp.path()), None) + .await + .expect("explicit invocation should remain available"); + assert_eq!(loaded.name, "local-explicit-only"); + } } diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/skills/registry.rs b/src/crates/assembly/core/src/agentic/tools/implementations/skills/registry.rs index 570e5eb21b..368230e1e7 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/skills/registry.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/skills/registry.rs @@ -13,7 +13,8 @@ use crate::infrastructure::get_path_manager_arc; use crate::util::errors::{BitFunError, BitFunResult}; use bitfun_agent_runtime::skills::{ annotate_shadowed_skills, build_mode_skill_infos, filter_candidates_for_mode, - normalize_local_skill_dir_name, normalize_remote_skill_dir_name, normalize_skill_keys, + filter_implicitly_invocable_skills, normalize_local_skill_dir_name, + normalize_remote_skill_dir_name, normalize_skill_keys, resolve_default_hidden_builtin_for_explicit_invocation, resolve_user_config_skill_root, resolve_visible_skills, sort_skill_candidates_by_dir, sort_skills, ExplicitSkillInvocationResolution, SkillCandidate, BITFUN_SKILL_SOURCE_ID, @@ -21,7 +22,7 @@ use bitfun_agent_runtime::skills::{ BITFUN_USER_SKILL_SLOT, PROJECT_SKILL_KEY_PREFIX, PROJECT_SKILL_ROOTS, USER_CONFIG_SKILL_ROOTS, USER_HOME_SKILL_ROOTS, USER_SKILL_KEY_PREFIX, }; -use log::{debug, error}; +use log::{debug, error, warn}; use std::collections::HashSet; use std::path::{Path, PathBuf}; use std::sync::OnceLock; @@ -78,6 +79,68 @@ impl SkillRegistry { SKILL_REGISTRY.get_or_init(Self::new) } + async fn apply_local_openai_policy(skill_data: &mut SkillData, skill_dir: &Path) { + let policy_path = skill_dir.join("agents").join("openai.yaml"); + let content = match fs::read_to_string(&policy_path).await { + Ok(content) => content, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return, + Err(error) => { + warn!( + "Failed to read optional skill policy {}: {}", + policy_path.display(), + error + ); + return; + } + }; + + if let Err(error) = skill_data.apply_openai_yaml_policy(&content) { + warn!( + "Ignoring invalid optional skill policy {}: {}", + policy_path.display(), + error + ); + } + } + + async fn apply_remote_openai_policy( + skill_data: &mut SkillData, + fs: &dyn WorkspaceFileSystem, + skill_dir: &str, + ) { + let policy_path = format!("{}/agents/openai.yaml", skill_dir.trim_end_matches('/')); + let is_file = match fs.is_file(&policy_path).await { + Ok(is_file) => is_file, + Err(error) => { + warn!( + "Failed to inspect optional remote skill policy {}: {}", + policy_path, error + ); + return; + } + }; + if !is_file { + return; + } + + let content = match fs.read_file_text(&policy_path).await { + Ok(content) => content, + Err(error) => { + warn!( + "Failed to read optional remote skill policy {}: {}", + policy_path, error + ); + return; + } + }; + if let Err(error) = skill_data.apply_openai_yaml_policy(&content) { + warn!( + "Ignoring invalid optional remote skill policy {}: {}", + policy_path, error + ); + } + } + fn get_possible_paths_for_workspace(workspace_root: Option<&Path>) -> Vec { let mut entries = Vec::new(); let mut priority = 0usize; @@ -233,6 +296,7 @@ impl SkillRegistry { false, ) { Ok(mut skill_data) => { + Self::apply_local_openai_policy(&mut skill_data, &path).await; skill_data.dir_name = dir_name; let key_prefix = match entry.level { SkillLocation::User => USER_SKILL_KEY_PREFIX, @@ -325,6 +389,7 @@ impl SkillRegistry { false, ) { Ok(mut skill_data) => { + Self::apply_remote_openai_policy(&mut skill_data, fs, &item.path).await; skill_data.dir_name = dir_name; skills.push(SkillCandidate::from_data( skill_data, @@ -562,6 +627,29 @@ impl SkillRegistry { sort_skills(resolve_visible_skills(filtered)) } + pub async fn get_implicitly_invocable_skills_for_workspace( + &self, + workspace_root: Option<&Path>, + agent_type: Option<&str>, + ) -> Vec { + filter_implicitly_invocable_skills( + self.get_resolved_skills_for_workspace(workspace_root, agent_type) + .await, + ) + } + + pub async fn get_implicitly_invocable_skills_for_remote_workspace( + &self, + fs: &dyn WorkspaceFileSystem, + remote_root: &str, + agent_type: Option<&str>, + ) -> Vec { + filter_implicitly_invocable_skills( + self.get_resolved_skills_for_remote_workspace(fs, remote_root, agent_type) + .await, + ) + } + pub async fn get_mode_skill_infos_for_workspace( &self, workspace_root: Option<&Path>, @@ -775,7 +863,7 @@ impl SkillRegistry { workspace_root: Option<&Path>, agent_type: Option<&str>, ) -> Vec { - self.get_resolved_skills_for_workspace(workspace_root, agent_type) + self.get_implicitly_invocable_skills_for_workspace(workspace_root, agent_type) .await .into_iter() .map(|skill| skill.to_xml_desc()) @@ -788,7 +876,7 @@ impl SkillRegistry { remote_root: &str, agent_type: Option<&str>, ) -> Vec { - self.get_resolved_skills_for_remote_workspace(fs, remote_root, agent_type) + self.get_implicitly_invocable_skills_for_remote_workspace(fs, remote_root, agent_type) .await .into_iter() .map(|skill| skill.to_xml_desc()) diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/skills/resolver.rs b/src/crates/assembly/core/src/agentic/tools/implementations/skills/resolver.rs index bf1477d175..773c649184 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/skills/resolver.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/skills/resolver.rs @@ -31,6 +31,7 @@ mod tests { group_key: None, is_shadowed: false, shadowed_by_key: None, + allow_implicit_invocation: true, } } @@ -49,6 +50,7 @@ mod tests { group_key: None, is_shadowed: false, shadowed_by_key: None, + allow_implicit_invocation: true, } } diff --git a/src/crates/assembly/core/src/service/instruction_context.rs b/src/crates/assembly/core/src/service/instruction_context.rs index 19caf62f85..d5dcbb9959 100644 --- a/src/crates/assembly/core/src/service/instruction_context.rs +++ b/src/crates/assembly/core/src/service/instruction_context.rs @@ -1,4 +1,5 @@ use crate::util::errors::*; +use bitfun_runtime_ports::WorkspaceFileSystem; use bitfun_services_core::workspace_instructions::WorkspaceInstructionFile; use std::path::Path; @@ -16,6 +17,22 @@ pub(crate) async fn build_workspace_instruction_files_context( )) } +pub(crate) async fn build_workspace_instruction_files_context_with_fs( + fs: &dyn WorkspaceFileSystem, + workspace_root: &str, +) -> BitFunResult> { + let instruction_files = + bitfun_services_core::workspace_instructions::read_workspace_instruction_files_with_fs( + fs, + workspace_root, + ) + .await + .map_err(BitFunError::service)?; + Ok(render_workspace_instruction_files_section( + &instruction_files, + )) +} + fn render_workspace_instruction_files_section( files: &[WorkspaceInstructionFile], ) -> Option { diff --git a/src/crates/execution/agent-runtime/src/skills/mod.rs b/src/crates/execution/agent-runtime/src/skills/mod.rs index 7694e99717..a3a9026dff 100644 --- a/src/crates/execution/agent-runtime/src/skills/mod.rs +++ b/src/crates/execution/agent-runtime/src/skills/mod.rs @@ -28,9 +28,9 @@ pub use roots::{ }; pub use selection::{ annotate_shadowed_skills, build_mode_skill_infos, filter_candidates_for_mode, - normalize_skill_keys, resolve_default_hidden_builtin_for_explicit_invocation, - resolve_visible_skills, sort_skill_candidates_by_dir, sort_skills, - ExplicitSkillInvocationResolution, SkillCandidate, + filter_implicitly_invocable_skills, normalize_skill_keys, + resolve_default_hidden_builtin_for_explicit_invocation, resolve_visible_skills, + sort_skill_candidates_by_dir, sort_skills, ExplicitSkillInvocationResolution, SkillCandidate, }; pub use types::{ render_loaded_skill_for_assistant, ModeSkillInfo, ModeSkillStateReason, SkillData, SkillInfo, diff --git a/src/crates/execution/agent-runtime/src/skills/selection.rs b/src/crates/execution/agent-runtime/src/skills/selection.rs index 2d3a393448..34ec620ef9 100644 --- a/src/crates/execution/agent-runtime/src/skills/selection.rs +++ b/src/crates/execution/agent-runtime/src/skills/selection.rs @@ -45,6 +45,7 @@ impl SkillCandidate { group_key, is_shadowed: false, shadowed_by_key: None, + allow_implicit_invocation: data.allow_implicit_invocation, }, priority, } @@ -141,6 +142,13 @@ pub fn resolve_visible_skills(candidates: Vec) -> Vec .collect() } +pub fn filter_implicitly_invocable_skills(skills: Vec) -> Vec { + skills + .into_iter() + .filter(|skill| skill.allow_implicit_invocation) + .collect() +} + pub fn filter_candidates_for_mode( candidates: Vec, mode_id: &str, @@ -311,6 +319,7 @@ mod tests { group_key: None, is_shadowed: false, shadowed_by_key: None, + allow_implicit_invocation: true, }, priority: 0, } diff --git a/src/crates/execution/agent-runtime/src/skills/types.rs b/src/crates/execution/agent-runtime/src/skills/types.rs index 221104ada2..88fea2d876 100644 --- a/src/crates/execution/agent-runtime/src/skills/types.rs +++ b/src/crates/execution/agent-runtime/src/skills/types.rs @@ -53,6 +53,8 @@ pub struct SkillInfo { pub is_shadowed: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub shadowed_by_key: Option, + #[serde(default = "default_allow_implicit_invocation", skip_serializing)] + pub allow_implicit_invocation: bool, } impl SkillInfo { @@ -98,6 +100,44 @@ pub struct SkillData { pub path: String, pub source_slot: String, pub dir_name: String, + pub allow_implicit_invocation: bool, +} + +fn default_allow_implicit_invocation() -> bool { + true +} + +fn optional_bool(metadata: &Value, field: &'static str) -> Result, SkillParseError> { + let Some(value) = metadata.get(field) else { + return Ok(None); + }; + value + .as_bool() + .map(Some) + .ok_or_else(|| SkillParseError::InvalidFormat(format!("Field '{field}' must be a boolean"))) +} + +fn optional_claude_bool( + metadata: &Value, + field: &'static str, +) -> Result, SkillParseError> { + let Some(value) = metadata.get(field) else { + return Ok(None); + }; + let parsed = match value { + Value::Bool(value) => Some(*value), + Value::Number(value) if value.as_i64() == Some(1) => Some(true), + Value::Number(value) if value.as_i64() == Some(0) => Some(false), + Value::String(value) => match value.to_ascii_lowercase().as_str() { + "true" | "yes" | "on" | "1" => Some(true), + "false" | "no" | "off" | "0" => Some(false), + _ => None, + }, + _ => None, + }; + parsed + .map(Some) + .ok_or_else(|| SkillParseError::InvalidFormat(format!("Field '{field}' must be a boolean"))) } fn parse_front_matter_markdown(content: &str) -> Result<(Value, String), SkillParseError> { @@ -147,6 +187,9 @@ impl SkillData { .map(str::to_string) .ok_or(SkillParseError::MissingField("description"))?; + let allow_implicit_invocation = + !optional_claude_bool(&metadata, "disable-model-invocation")?.unwrap_or(false); + let skill_content = if with_content { body } else { String::new() }; let dir_name = Path::new(&path) .file_name() @@ -163,8 +206,30 @@ impl SkillData { path, source_slot: String::new(), dir_name, + allow_implicit_invocation, }) } + + pub fn apply_openai_yaml_policy(&mut self, content: &str) -> Result<(), SkillParseError> { + let metadata: Value = serde_yaml::from_str(content).map_err(|error| { + SkillParseError::InvalidFormat(format!("Failed to parse agents/openai.yaml: {error}")) + })?; + let Some(policy) = metadata.get("policy") else { + return Ok(()); + }; + if !policy.is_mapping() { + return Err(SkillParseError::InvalidFormat( + "Field 'policy' in agents/openai.yaml must be a mapping".to_string(), + )); + } + let Some(allow_implicit_invocation) = optional_bool(policy, "allow_implicit_invocation")? + else { + return Ok(()); + }; + + self.allow_implicit_invocation &= allow_implicit_invocation; + Ok(()) + } } pub fn render_loaded_skill_for_assistant( diff --git a/src/crates/execution/agent-runtime/tests/skill_contracts.rs b/src/crates/execution/agent-runtime/tests/skill_contracts.rs index 56ab706bcc..b18b8268fa 100644 --- a/src/crates/execution/agent-runtime/tests/skill_contracts.rs +++ b/src/crates/execution/agent-runtime/tests/skill_contracts.rs @@ -3,7 +3,8 @@ use std::path::PathBuf; use bitfun_agent_runtime::skills::{ annotate_shadowed_skills, build_mode_skill_infos, builtin_skill_group_key, - filter_candidates_for_mode, render_loaded_skill_for_assistant, resolve_builtin_default_enabled, + filter_candidates_for_mode, filter_implicitly_invocable_skills, + render_loaded_skill_for_assistant, resolve_builtin_default_enabled, resolve_default_hidden_builtin_for_explicit_invocation, resolve_skill_default_enabled_for_mode, resolve_skill_state_for_mode, resolve_user_config_skill_root, resolve_visible_skills, sort_skills, ExplicitSkillInvocationResolution, ModeSkillStateReason, SkillCandidate, @@ -27,6 +28,7 @@ fn builtin_skill(dir_name: &str) -> SkillInfo { group_key: builtin_skill_group_key(dir_name).map(str::to_string), is_shadowed: false, shadowed_by_key: None, + allow_implicit_invocation: true, } } @@ -45,6 +47,7 @@ fn custom_user_skill(dir_name: &str) -> SkillInfo { group_key: None, is_shadowed: false, shadowed_by_key: None, + allow_implicit_invocation: true, } } @@ -63,6 +66,7 @@ fn project_skill(dir_name: &str) -> SkillInfo { group_key: None, is_shadowed: false, shadowed_by_key: None, + allow_implicit_invocation: true, } } @@ -297,6 +301,99 @@ Use the pdf workflow. assert!(stable_assistant.contains("from stable key 'project::bitfun::pdf'")); } +#[test] +fn claude_manual_skill_is_not_implicitly_invocable() { + let markdown = r#"--- +name: deploy +description: Deploy the current project. +disable-model-invocation: true +--- + +Run the deployment workflow. +"#; + + let data = SkillData::from_markdown( + "/workspace/.claude/skills/deploy".to_string(), + markdown, + SkillLocation::Project, + false, + ) + .expect("valid Claude skill markdown should parse"); + + assert!(!data.allow_implicit_invocation); +} + +#[test] +fn claude_boolean_aliases_preserve_explicit_only_skill_visibility() { + for value in ["yes", "ON", "1"] { + let markdown = format!( + "---\nname: deploy\ndescription: Deploy the current project.\ndisable-model-invocation: {value}\n---\n\nRun the deployment workflow.\n" + ); + let data = SkillData::from_markdown( + "/workspace/.claude/skills/deploy".to_string(), + &markdown, + SkillLocation::Project, + false, + ) + .expect("Claude-compatible boolean aliases should parse"); + assert!(!data.allow_implicit_invocation, "value={value}"); + } +} + +#[test] +fn codex_policy_can_restrict_but_not_relax_skill_invocation() { + let markdown = r#"--- +name: deploy +description: Deploy the current project. +disable-model-invocation: true +--- + +Run the deployment workflow. +"#; + let mut data = SkillData::from_markdown( + "/workspace/.codex/skills/deploy".to_string(), + markdown, + SkillLocation::Project, + false, + ) + .expect("valid skill markdown should parse"); + + data.apply_openai_yaml_policy("policy:\n allow_implicit_invocation: true\n") + .expect("valid Codex policy should parse"); + assert!(!data.allow_implicit_invocation); + + let permissive_markdown = r#"--- +name: review +description: Review the current project. +--- + +Run the review workflow. +"#; + let mut restricted = SkillData::from_markdown( + "/workspace/.codex/skills/review".to_string(), + permissive_markdown, + SkillLocation::Project, + false, + ) + .expect("valid skill markdown should parse"); + restricted + .apply_openai_yaml_policy("policy:\n allow_implicit_invocation: false\n") + .expect("valid Codex policy should parse"); + assert!(!restricted.allow_implicit_invocation); +} + +#[test] +fn implicit_skill_filter_keeps_explicit_only_skill_out_of_model_catalog() { + let visible = project_skill("review"); + let mut explicit_only = project_skill("deploy"); + explicit_only.allow_implicit_invocation = false; + + let filtered = filter_implicitly_invocable_skills(vec![explicit_only, visible]); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].name, "review"); +} + #[test] fn skill_candidate_key_group_and_resolution_are_runtime_owned() { let markdown = r#"--- @@ -570,7 +667,9 @@ fn explicit_invocation_reaches_default_hidden_agent_browser() { ExplicitSkillInvocationResolution::Found(skill) => { assert_eq!(skill.key, "user::bitfun-system::agent-browser"); } - other => panic!("expected hidden agent-browser fallback for mode {mode_id}, got {other:?}"), + other => { + panic!("expected hidden agent-browser fallback for mode {mode_id}, got {other:?}") + } } } } diff --git a/src/crates/services/services-core/src/workspace_instructions.rs b/src/crates/services/services-core/src/workspace_instructions.rs index 5a947f0c7d..44771b32fd 100644 --- a/src/crates/services/services-core/src/workspace_instructions.rs +++ b/src/crates/services/services-core/src/workspace_instructions.rs @@ -1,7 +1,11 @@ use std::path::Path; use tokio::fs; -pub const WORKSPACE_INSTRUCTION_FILE_NAMES: [&str; 2] = ["AGENTS.md", "CLAUDE.md"]; +pub const WORKSPACE_INSTRUCTION_FILE_NAMES: [&str; 3] = + ["AGENTS.override.md", "AGENTS.md", "CLAUDE.md"]; + +const WORKSPACE_INSTRUCTION_FILE_GROUPS: [&[&str]; 2] = + [&["AGENTS.override.md", "AGENTS.md"], &["CLAUDE.md"]]; #[derive(Debug, Clone, PartialEq, Eq)] pub struct WorkspaceInstructionFile { @@ -14,29 +18,74 @@ pub async fn read_workspace_instruction_files( ) -> Result, String> { let mut files = Vec::new(); - for file_name in WORKSPACE_INSTRUCTION_FILE_NAMES { - let path = workspace_root.join(file_name); - if !path.exists() || !path.is_file() { - continue; - } + for candidates in WORKSPACE_INSTRUCTION_FILE_GROUPS { + for file_name in candidates { + let path = workspace_root.join(file_name); + if !path.is_file() { + continue; + } - let content = fs::read_to_string(&path).await.map_err(|e| { - format!( - "Failed to read workspace instruction file {}: {}", - path.display(), - e - ) - })?; + let content = fs::read_to_string(&path).await.map_err(|e| { + format!( + "Failed to read workspace instruction file {}: {}", + path.display(), + e + ) + })?; - if content.trim().is_empty() { - continue; + if !content.trim().is_empty() { + files.push(WorkspaceInstructionFile { + name: (*file_name).to_string(), + content, + }); + } + break; } + } - files.push(WorkspaceInstructionFile { - name: file_name.to_string(), - content, - }); + Ok(files) +} + +#[cfg(feature = "workspace-runtime")] +pub async fn read_workspace_instruction_files_with_fs( + fs: &dyn bitfun_runtime_ports::WorkspaceFileSystem, + workspace_root: &str, +) -> Result, String> { + let mut files = Vec::new(); + + for candidates in WORKSPACE_INSTRUCTION_FILE_GROUPS { + for file_name in candidates { + let path = join_workspace_path(workspace_root, file_name); + let is_file = fs.is_file(&path).await.map_err(|error| { + format!("Failed to inspect workspace instruction file {path}: {error}") + })?; + if !is_file { + continue; + } + + let content = fs.read_file_text(&path).await.map_err(|error| { + format!("Failed to read workspace instruction file {path}: {error}") + })?; + if !content.trim().is_empty() { + files.push(WorkspaceInstructionFile { + name: (*file_name).to_string(), + content, + }); + } + break; + } } Ok(files) } + +#[cfg(feature = "workspace-runtime")] +fn join_workspace_path(workspace_root: &str, file_name: &str) -> String { + let root = workspace_root.trim_end_matches(['/', '\\']); + let separator = if root.contains('\\') && !root.contains('/') { + '\\' + } else { + '/' + }; + format!("{root}{separator}{file_name}") +} diff --git a/src/crates/services/services-core/tests/storage_owner_contracts.rs b/src/crates/services/services-core/tests/storage_owner_contracts.rs index 3fdf2695ad..9fb56041b2 100644 --- a/src/crates/services/services-core/tests/storage_owner_contracts.rs +++ b/src/crates/services/services-core/tests/storage_owner_contracts.rs @@ -189,6 +189,30 @@ async fn workspace_instruction_files_reads_agents_then_claude_and_skips_empty_fi assert_eq!(files[0].name, "CLAUDE.md"); } +#[tokio::test] +async fn workspace_instruction_override_replaces_agents_without_hiding_claude() { + let temp = tempfile::tempdir().expect("tempdir"); + fs::write(temp.path().join("AGENTS.override.md"), "override rules\n").expect("override"); + fs::write(temp.path().join("AGENTS.md"), "base rules\n").expect("agents"); + fs::write(temp.path().join("CLAUDE.md"), "claude rules\n").expect("claude"); + + let files = read_workspace_instruction_files(temp.path()) + .await + .expect("instruction files"); + + assert_eq!(files.len(), 2); + assert_eq!(files[0].name, "AGENTS.override.md"); + assert_eq!(files[0].content, "override rules\n"); + assert_eq!(files[1].name, "CLAUDE.md"); + + fs::write(temp.path().join("AGENTS.override.md"), "").expect("empty override"); + let files = read_workspace_instruction_files(temp.path()) + .await + .expect("instruction files"); + assert_eq!(files.len(), 1); + assert_eq!(files[0].name, "CLAUDE.md"); +} + #[tokio::test] async fn token_usage_service_persists_records_and_filters_subagents_by_default() { let temp = tempfile::tempdir().expect("tempdir"); diff --git a/src/crates/services/services-core/tests/workspace_instruction_contracts.rs b/src/crates/services/services-core/tests/workspace_instruction_contracts.rs new file mode 100644 index 0000000000..3a0d313665 --- /dev/null +++ b/src/crates/services/services-core/tests/workspace_instruction_contracts.rs @@ -0,0 +1,31 @@ +#![cfg(feature = "workspace-runtime")] + +use bitfun_services_core::workspace::LocalWorkspaceFs; +use bitfun_services_core::workspace_instructions::read_workspace_instruction_files_with_fs; +use std::fs; + +#[tokio::test] +async fn port_backed_instructions_honor_agents_override_and_keep_claude_context() { + let temp = tempfile::tempdir().expect("tempdir"); + fs::write(temp.path().join("AGENTS.override.md"), "override rules\n").expect("override"); + fs::write(temp.path().join("AGENTS.md"), "base rules\n").expect("agents"); + fs::write(temp.path().join("CLAUDE.md"), "claude rules\n").expect("claude"); + let root = temp.path().to_string_lossy(); + + let files = read_workspace_instruction_files_with_fs(&LocalWorkspaceFs, &root) + .await + .expect("instruction files"); + + assert_eq!(files.len(), 2); + assert_eq!(files[0].name, "AGENTS.override.md"); + assert_eq!(files[0].content, "override rules\n"); + assert_eq!(files[1].name, "CLAUDE.md"); + assert_eq!(files[1].content, "claude rules\n"); + + fs::write(temp.path().join("AGENTS.override.md"), "").expect("empty override"); + let files = read_workspace_instruction_files_with_fs(&LocalWorkspaceFs, &root) + .await + .expect("empty override selection"); + assert_eq!(files.len(), 1); + assert_eq!(files[0].name, "CLAUDE.md"); +}