Compare commits

...

1 Commits

Author SHA1 Message Date
Gabriel Cohen
fc1bf14360 Improve app awareness and skill deconflict 2026-01-30 11:22:32 -08:00
3 changed files with 259 additions and 15 deletions

View File

@@ -300,7 +300,19 @@ impl Codex {
}
let enabled_skills = loaded_skills.enabled_skills();
let user_instructions = get_user_instructions(&config, Some(&enabled_skills)).await;
let apps = if config.features.enabled(Feature::Apps) {
match connectors::list_accessible_connectors_from_mcp_tools(&config).await {
Ok(connectors) => connectors,
Err(err) => {
warn!("failed to load apps for prompt: {err:#}");
Vec::new()
}
}
} else {
Vec::new()
};
let user_instructions =
get_user_instructions(&config, Some(&enabled_skills), Some(&apps)).await;
let exec_policy = ExecPolicyManager::load(&config.features, &config.config_layer_stack)
.await
@@ -4517,6 +4529,8 @@ mod tests {
use codex_app_server_protocol::AuthMode;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::SkillScope;
use codex_protocol::user_input::UserInput;
use std::path::Path;
use std::time::Duration;
use tokio::time::sleep;
@@ -4530,6 +4544,11 @@ mod tests {
use std::sync::Arc;
use std::time::Duration as StdDuration;
use crate::mentions::build_connector_slug_counts;
use crate::mentions::build_skill_name_counts;
use crate::skills::SkillMetadata;
use crate::skills::collect_explicit_skill_mentions;
struct InstructionsTestCase {
slug: &'static str,
expects_apply_patch_instructions: bool,
@@ -4559,6 +4578,25 @@ mod tests {
}
}
fn make_skill(name: &str, path: &str) -> SkillMetadata {
SkillMetadata {
name: name.to_string(),
description: "test skill".to_string(),
short_description: None,
interface: None,
dependencies: None,
path: PathBuf::from(path),
scope: SkillScope::User,
}
}
fn user_input(text: &str) -> UserInput {
UserInput::Text {
text: text.to_string(),
text_elements: Vec::new(),
}
}
#[tokio::test]
async fn get_base_instructions_no_user_content() {
let prompt_with_apply_patch_instructions =
@@ -4662,6 +4700,130 @@ mod tests {
assert_eq!(selected, Vec::new());
}
#[test]
fn prompt_behavior_harness_routes_mentions_with_ambiguity() {
struct Case {
name: &'static str,
prompt: &'static str,
skills: Vec<SkillMetadata>,
connectors: Vec<AppInfo>,
explicit_app_paths: Vec<String>,
expected_skill_names: Vec<&'static str>,
expected_app_ids: Vec<&'static str>,
}
let cases = vec![
Case {
name: "unique_app_slug",
prompt: "use $todoist to list tasks",
skills: Vec::new(),
connectors: vec![make_connector("app_todo", "Todoist")],
explicit_app_paths: Vec::new(),
expected_skill_names: Vec::new(),
expected_app_ids: vec!["app_todo"],
},
Case {
name: "duplicate_app_slug",
prompt: "use $foo-bar",
skills: Vec::new(),
connectors: vec![
make_connector("one", "Foo Bar"),
make_connector("two", "Foo-Bar"),
],
explicit_app_paths: Vec::new(),
expected_skill_names: Vec::new(),
expected_app_ids: Vec::new(),
},
Case {
name: "skill_conflict_blocks_app_and_skill",
prompt: "use $calendar",
skills: vec![make_skill("calendar", "/tmp/skills/calendar/SKILL.md")],
connectors: vec![make_connector("app_cal", "Calendar")],
explicit_app_paths: Vec::new(),
expected_skill_names: Vec::new(),
expected_app_ids: Vec::new(),
},
Case {
name: "explicit_app_path_wins",
prompt: "use [$calendar](app://app_cal)",
skills: vec![make_skill("calendar", "/tmp/skills/calendar/SKILL.md")],
connectors: vec![make_connector("app_cal", "Calendar")],
explicit_app_paths: Vec::new(),
expected_skill_names: Vec::new(),
expected_app_ids: vec!["app_cal"],
},
Case {
name: "explicit_skill_path_wins",
prompt: "use [$calendar](skill:///tmp/skills/calendar/SKILL.md)",
skills: vec![make_skill("calendar", "/tmp/skills/calendar/SKILL.md")],
connectors: vec![make_connector("app_cal", "Calendar")],
explicit_app_paths: Vec::new(),
expected_skill_names: vec!["calendar"],
expected_app_ids: Vec::new(),
},
Case {
name: "mcp_path_does_not_select_skill_or_app",
prompt: "use [$foo](mcp://server/foo)",
skills: vec![make_skill("foo", "/tmp/skills/foo/SKILL.md")],
connectors: vec![make_connector("app_foo", "Foo")],
explicit_app_paths: Vec::new(),
expected_skill_names: Vec::new(),
expected_app_ids: Vec::new(),
},
Case {
name: "plain_skill_mention_selects_skill",
prompt: "run $linting now",
skills: vec![make_skill("linting", "/tmp/skills/linting/SKILL.md")],
connectors: Vec::new(),
explicit_app_paths: Vec::new(),
expected_skill_names: vec!["linting"],
expected_app_ids: Vec::new(),
},
];
for case in cases {
let disabled_paths = HashSet::new();
let inputs = vec![user_input(case.prompt)];
let (skill_name_counts, skill_name_counts_lower) =
build_skill_name_counts(&case.skills, &disabled_paths);
let connector_slug_counts = build_connector_slug_counts(&case.connectors);
let selected_skills = collect_explicit_skill_mentions(
&inputs,
&case.skills,
&disabled_paths,
&skill_name_counts,
&connector_slug_counts,
);
let selected_skill_names = selected_skills
.iter()
.map(|skill| skill.name.as_str())
.collect::<Vec<_>>();
let response_items = vec![user_message(case.prompt)];
let selected_connectors = filter_connectors_for_input(
case.connectors,
&response_items,
&case.explicit_app_paths,
&skill_name_counts_lower,
);
let selected_app_ids = selected_connectors
.iter()
.map(|connector| connector.id.as_str())
.collect::<Vec<_>>();
assert_eq!(
selected_skill_names, case.expected_skill_names,
"case {} selected unexpected skills",
case.name
);
assert_eq!(
selected_app_ids, case.expected_app_ids,
"case {} selected unexpected apps",
case.name
);
}
}
#[tokio::test]
async fn reconstruct_history_matches_live_compactions() {
let (session, turn_context) = make_session_and_context().await;

View File

@@ -85,6 +85,42 @@ pub fn connector_mention_slug(connector: &AppInfo) -> String {
connector_name_slug(&connector_display_label(connector))
}
pub fn render_apps_section(connectors: &[AppInfo]) -> String {
let mut lines: Vec<String> = Vec::new();
lines.push("## Apps".to_string());
lines.push(
"Apps (connectors) are MCP tools. Users may refer to apps, connectors, or MCPs interchangeably; treat those requests as app mentions when they match an available app, otherwise ask for clarification."
.to_string(),
);
lines.push("### How to mention apps".to_string());
lines.push(
"- Use `$<app-slug>` to select a unique app by slug (derived from the display name)."
.to_string(),
);
lines.push(
"- Use `[$<name>](app://<app-id>)` when the name is ambiguous or collides with a skill."
.to_string(),
);
lines.push(
"- If the user asks to use an app/connector/MCP but did not mention it, ask them to add a `$` mention or pick from `/apps`."
.to_string(),
);
lines.push("### Available apps".to_string());
if connectors.is_empty() {
lines.push("- None detected. Ask the user to install an app or run /apps.".to_string());
} else {
for connector in connectors {
let slug = connector_mention_slug(connector);
let name = connector.name.as_str();
let id = connector.id.as_str();
lines.push(format!("- {slug}: {name} (id: {id})"));
}
}
lines.join("\n")
}
pub(crate) fn accessible_connectors_from_mcp_tools(
mcp_tools: &HashMap<String, crate::mcp_connection_manager::ToolInfo>,
) -> Vec<AppInfo> {

View File

@@ -14,6 +14,8 @@
//! 3. We do **not** walk past the Git root.
use crate::config::Config;
use crate::connectors::AppInfo;
use crate::connectors::render_apps_section;
use crate::features::Feature;
use crate::skills::SkillMetadata;
use crate::skills::render_skills_section;
@@ -39,6 +41,7 @@ const PROJECT_DOC_SEPARATOR: &str = "\n\n--- project-doc ---\n\n";
pub(crate) async fn get_user_instructions(
config: &Config,
skills: Option<&[SkillMetadata]>,
apps: Option<&[AppInfo]>,
) -> Option<String> {
let project_docs = read_project_docs(config).await;
@@ -69,6 +72,13 @@ pub(crate) async fn get_user_instructions(
output.push_str(&skills_section);
}
if config.features.enabled(Feature::Apps) {
if !output.is_empty() {
output.push_str("\n\n");
}
output.push_str(&render_apps_section(apps.unwrap_or(&[])));
}
if config.features.enabled(Feature::ChildAgentsMd) {
if !output.is_empty() {
output.push_str("\n\n");
@@ -280,7 +290,7 @@ mod tests {
async fn no_doc_file_returns_none() {
let tmp = tempfile::tempdir().expect("tempdir");
let res = get_user_instructions(&make_config(&tmp, 4096, None).await, None).await;
let res = get_user_instructions(&make_config(&tmp, 4096, None).await, None, None).await;
assert!(
res.is_none(),
"Expected None when AGENTS.md is absent and no system instructions provided"
@@ -294,7 +304,7 @@ mod tests {
let tmp = tempfile::tempdir().expect("tempdir");
fs::write(tmp.path().join("AGENTS.md"), "hello world").unwrap();
let res = get_user_instructions(&make_config(&tmp, 4096, None).await, None)
let res = get_user_instructions(&make_config(&tmp, 4096, None).await, None, None)
.await
.expect("doc expected");
@@ -313,7 +323,7 @@ mod tests {
let huge = "A".repeat(LIMIT * 2); // 2 KiB
fs::write(tmp.path().join("AGENTS.md"), &huge).unwrap();
let res = get_user_instructions(&make_config(&tmp, LIMIT, None).await, None)
let res = get_user_instructions(&make_config(&tmp, LIMIT, None).await, None, None)
.await
.expect("doc expected");
@@ -345,7 +355,7 @@ mod tests {
let mut cfg = make_config(&repo, 4096, None).await;
cfg.cwd = nested;
let res = get_user_instructions(&cfg, None)
let res = get_user_instructions(&cfg, None, None)
.await
.expect("doc expected");
assert_eq!(res, "root level doc");
@@ -357,7 +367,7 @@ mod tests {
let tmp = tempfile::tempdir().expect("tempdir");
fs::write(tmp.path().join("AGENTS.md"), "something").unwrap();
let res = get_user_instructions(&make_config(&tmp, 0, None).await, None).await;
let res = get_user_instructions(&make_config(&tmp, 0, None).await, None, None).await;
assert!(
res.is_none(),
"With limit 0 the function should return None"
@@ -373,9 +383,13 @@ mod tests {
const INSTRUCTIONS: &str = "base instructions";
let res = get_user_instructions(&make_config(&tmp, 4096, Some(INSTRUCTIONS)).await, None)
.await
.expect("should produce a combined instruction string");
let res = get_user_instructions(
&make_config(&tmp, 4096, Some(INSTRUCTIONS)).await,
None,
None,
)
.await
.expect("should produce a combined instruction string");
let expected = format!("{INSTRUCTIONS}{PROJECT_DOC_SEPARATOR}{}", "proj doc");
@@ -390,8 +404,12 @@ mod tests {
const INSTRUCTIONS: &str = "some instructions";
let res =
get_user_instructions(&make_config(&tmp, 4096, Some(INSTRUCTIONS)).await, None).await;
let res = get_user_instructions(
&make_config(&tmp, 4096, Some(INSTRUCTIONS)).await,
None,
None,
)
.await;
assert_eq!(res, Some(INSTRUCTIONS.to_string()));
}
@@ -420,7 +438,7 @@ mod tests {
let mut cfg = make_config(&repo, 4096, None).await;
cfg.cwd = nested;
let res = get_user_instructions(&cfg, None)
let res = get_user_instructions(&cfg, None, None)
.await
.expect("doc expected");
assert_eq!(res, "root doc\n\ncrate doc");
@@ -435,7 +453,7 @@ mod tests {
let cfg = make_config(&tmp, 4096, None).await;
let res = get_user_instructions(&cfg, None)
let res = get_user_instructions(&cfg, None, None)
.await
.expect("local doc expected");
@@ -457,7 +475,7 @@ mod tests {
let cfg = make_config_with_fallback(&tmp, 4096, None, &["EXAMPLE.md"]).await;
let res = get_user_instructions(&cfg, None)
let res = get_user_instructions(&cfg, None, None)
.await
.expect("fallback doc expected");
@@ -473,7 +491,7 @@ mod tests {
let cfg = make_config_with_fallback(&tmp, 4096, None, &["EXAMPLE.md", ".example.md"]).await;
let res = get_user_instructions(&cfg, None)
let res = get_user_instructions(&cfg, None, None)
.await
.expect("AGENTS.md should win");
@@ -506,6 +524,7 @@ mod tests {
let res = get_user_instructions(
&cfg,
skills.errors.is_empty().then_some(skills.skills.as_slice()),
None,
)
.await
.expect("instructions expected");
@@ -523,6 +542,32 @@ mod tests {
assert_eq!(res, expected);
}
#[tokio::test]
async fn apps_are_appended_to_project_doc() {
let tmp = tempfile::tempdir().expect("tempdir");
fs::write(tmp.path().join("AGENTS.md"), "base doc").unwrap();
let mut cfg = make_config(&tmp, 4096, None).await;
cfg.features.enable(Feature::Apps);
let apps = vec![AppInfo {
id: "app_123".to_string(),
name: "Calendar".to_string(),
description: None,
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
install_url: None,
is_accessible: true,
}];
let res = get_user_instructions(&cfg, None, Some(&apps))
.await
.expect("instructions expected");
let expected = format!("base doc\n\n{}", render_apps_section(&apps));
assert_eq!(res, expected);
}
#[tokio::test]
async fn skills_render_without_project_doc() {
let tmp = tempfile::tempdir().expect("tempdir");
@@ -533,6 +578,7 @@ mod tests {
let res = get_user_instructions(
&cfg,
skills.errors.is_empty().then_some(skills.skills.as_slice()),
None,
)
.await
.expect("instructions expected");