Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions docs/architecture/cli-product-line-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 来源解释,首次连接、策略限制和凭据缺失分别显示。

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// Distinguishes a resolved empty result from a caller that has not resolved instructions.
pub workspace_instruction_files_context_resolved: bool,
}

impl PromptBuilderContext {
Expand All @@ -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,
}
}

Expand Down Expand Up @@ -118,6 +124,12 @@ impl PromptBuilderContext {
self.inline_markdown_image_display = enabled;
self
}

pub fn with_workspace_instruction_files_context(mut self, context: Option<String>) -> Self {
self.workspace_instruction_files_context = context;
self.workspace_instruction_files_context_resolved = true;
self
}
}

pub async fn build_prompt_context_for_workspace(
Expand Down Expand Up @@ -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) => {}
Expand Down Expand Up @@ -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<document name=\"AGENTS.md\">\nremote rules\n</document>"
.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);
Expand Down
112 changes: 90 additions & 22 deletions src/crates/assembly/core/src/agentic/deep_review/capabilities.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -169,6 +148,35 @@ pub async fn review_capability_catalog(
descriptors
}

async fn implicitly_invocable_skills(context: &ToolUseContext) -> Vec<SkillInfo> {
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)
}
Expand Down Expand Up @@ -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<SkillData> {
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(|| {
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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());
}
}
Loading