mirror of
https://github.com/openai/codex.git
synced 2026-11-13 14:46:14 +00:00
Evaluate character matching over skill routing metadata (#35663)
## What changed - Add a character n-gram shadow selector that combines skill descriptions with host interface metadata and tool dependency names and descriptions. - Bound routing fields, dependency records, and candidate counts before ranking. - Exclude explicitly selected skills from shadow candidates and only record invocations for skills eligible for the experiment. ## Testing - Add unit coverage for host interface metadata, connector dependencies, baseline description matches, and long routing metadata. GitOrigin-RevId: 36ef0cb4a68601f2d9d41c3344c88f7927c32bf4
This commit is contained in:
1 parent
8495963ac6
commit
b672b3c409
5 files changed
+325
-6
No files matched your search
@@ -1,10 +1,12 @@
|
|||||||
mod character_ngram;
|
mod character_ngram;
|
||||||
|
mod character_routing_card;
|
||||||
mod fielded_bm25;
|
mod fielded_bm25;
|
||||||
mod multi_query_lexical;
|
mod multi_query_lexical;
|
||||||
mod routing_card_lexical;
|
mod routing_card_lexical;
|
||||||
mod rrf_lexical_char;
|
mod rrf_lexical_char;
|
||||||
mod weighted_lexical;
|
mod weighted_lexical;
|
||||||
pub(crate) use character_ngram::CharacterNgramSkillSelector;
|
pub(crate) use character_ngram::CharacterNgramSkillSelector;
|
||||||
|
pub(crate) use character_routing_card::CharacterRoutingCardSkillSelector;
|
||||||
use codex_skills::SkillDependencies;
|
use codex_skills::SkillDependencies;
|
||||||
pub(crate) use fielded_bm25::FieldedBm25SkillSelector;
|
pub(crate) use fielded_bm25::FieldedBm25SkillSelector;
|
||||||
pub(crate) use multi_query_lexical::MultiQueryLexicalSkillSelector;
|
pub(crate) use multi_query_lexical::MultiQueryLexicalSkillSelector;
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use codex_core_skills::HostSkillsSnapshot;
|
||||||
|
|
||||||
|
use crate::catalog::SkillCatalog;
|
||||||
|
use crate::catalog::SkillSourceKind;
|
||||||
|
|
||||||
|
use super::CharacterNgramSkillSelector;
|
||||||
|
use super::CheapSkillSelection;
|
||||||
|
use super::CheapSkillSelector;
|
||||||
|
use super::SkillSelectionDocument;
|
||||||
|
|
||||||
|
const MAX_ROUTING_FIELD_BYTES: usize = 4 * 1024;
|
||||||
|
const MAX_DEPENDENCY_RECORDS: usize = 32;
|
||||||
|
const MAX_CANDIDATES: usize = 1_000;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
pub(crate) struct CharacterRoutingCardSkillSelector {
|
||||||
|
routing_fields: HashMap<usize, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CharacterRoutingCardSkillSelector {
|
||||||
|
pub(crate) fn new(catalog: &SkillCatalog, host_snapshot: Option<&HostSkillsSnapshot>) -> Self {
|
||||||
|
let host_skill_ids = catalog
|
||||||
|
.entries
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(_, entry)| {
|
||||||
|
entry.authority.kind == SkillSourceKind::Host && entry.is_model_visible()
|
||||||
|
})
|
||||||
|
.take(MAX_CANDIDATES)
|
||||||
|
.map(|(id, entry)| (entry.main_prompt.as_str().replace('\\', "/"), id))
|
||||||
|
.collect::<HashMap<_, _>>();
|
||||||
|
let routing_fields = host_snapshot
|
||||||
|
.into_iter()
|
||||||
|
.flat_map(|snapshot| snapshot.outcome().skills.iter())
|
||||||
|
.filter_map(|skill| {
|
||||||
|
let id = host_skill_ids
|
||||||
|
.get(&skill.path_to_skills_md.to_string_lossy().replace('\\', "/"))?;
|
||||||
|
let interface = skill.interface.as_ref()?;
|
||||||
|
let mut field = String::new();
|
||||||
|
for value in [
|
||||||
|
interface.display_name.as_deref(),
|
||||||
|
interface.short_description.as_deref(),
|
||||||
|
interface.default_prompt.as_deref(),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
{
|
||||||
|
push_bounded(&mut field, value);
|
||||||
|
}
|
||||||
|
(!field.is_empty()).then_some((*id, field))
|
||||||
|
})
|
||||||
|
.take(MAX_CANDIDATES)
|
||||||
|
.collect();
|
||||||
|
Self { routing_fields }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CheapSkillSelector for CharacterRoutingCardSkillSelector {
|
||||||
|
fn method(&self) -> &'static str {
|
||||||
|
"character_routing_card_v1"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select(
|
||||||
|
&self,
|
||||||
|
query: &str,
|
||||||
|
documents: &[SkillSelectionDocument<'_>],
|
||||||
|
limit: usize,
|
||||||
|
) -> CheapSkillSelection {
|
||||||
|
if limit == 0 {
|
||||||
|
return CharacterNgramSkillSelector.select(query, documents, /*limit*/ 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let routing_fields = documents
|
||||||
|
.iter()
|
||||||
|
.take(MAX_CANDIDATES)
|
||||||
|
.map(|document| {
|
||||||
|
let mut field = String::new();
|
||||||
|
if let Some(description) = document.short_description {
|
||||||
|
push_bounded(&mut field, description);
|
||||||
|
}
|
||||||
|
if let Some(routing_field) = self.routing_fields.get(&document.id) {
|
||||||
|
push_bounded(&mut field, routing_field);
|
||||||
|
}
|
||||||
|
if let Some(dependencies) = document.dependencies {
|
||||||
|
for dependency in dependencies.tools.iter().take(MAX_DEPENDENCY_RECORDS) {
|
||||||
|
push_bounded(&mut field, dependency.value.as_str());
|
||||||
|
if let Some(description) = dependency.description.as_deref() {
|
||||||
|
push_bounded(&mut field, description);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
field
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let routing_documents = documents
|
||||||
|
.iter()
|
||||||
|
.zip(&routing_fields)
|
||||||
|
.map(|(document, field)| SkillSelectionDocument {
|
||||||
|
short_description: (!field.is_empty()).then_some(field.as_str()),
|
||||||
|
..*document
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let selection = CharacterNgramSkillSelector.select(query, &routing_documents, limit);
|
||||||
|
CheapSkillSelection {
|
||||||
|
candidate_set_truncated: documents.len() > MAX_CANDIDATES
|
||||||
|
|| selection.candidate_set_truncated,
|
||||||
|
..selection
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_bounded(destination: &mut String, value: &str) {
|
||||||
|
if value.is_empty() || destination.len() == MAX_ROUTING_FIELD_BYTES {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if !destination.is_empty() {
|
||||||
|
destination.push(' ');
|
||||||
|
}
|
||||||
|
let remaining = MAX_ROUTING_FIELD_BYTES.saturating_sub(destination.len());
|
||||||
|
let mut end = value.len().min(remaining);
|
||||||
|
while !value.is_char_boundary(end) {
|
||||||
|
end -= 1;
|
||||||
|
}
|
||||||
|
destination.push_str(&value[..end]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "character_routing_card_tests.rs"]
|
||||||
|
mod tests;
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
use codex_core_skills::SkillLoadOutcome;
|
||||||
|
use codex_protocol::protocol::SkillScope;
|
||||||
|
use codex_skills::SkillDependencies;
|
||||||
|
use codex_skills::SkillInterface;
|
||||||
|
use codex_skills::SkillMetadata;
|
||||||
|
use codex_skills::SkillToolDependency;
|
||||||
|
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||||
|
use pretty_assertions::assert_eq;
|
||||||
|
|
||||||
|
use crate::catalog::SkillAuthority;
|
||||||
|
use crate::catalog::SkillCatalogEntry;
|
||||||
|
use crate::catalog::SkillPackageId;
|
||||||
|
use crate::catalog::SkillResourceId;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn host_interface_metadata_makes_unlisted_workflow_discoverable() {
|
||||||
|
let path = AbsolutePathBuf::try_from(std::env::temp_dir().join("routing-card/SKILL.md"))
|
||||||
|
.expect("temporary directory should be absolute");
|
||||||
|
let resource = path.to_string_lossy().to_string();
|
||||||
|
let mut outcome = SkillLoadOutcome::default();
|
||||||
|
outcome.skills.push(SkillMetadata {
|
||||||
|
name: "content-tools".to_string(),
|
||||||
|
description: "Prepare visual content.".to_string(),
|
||||||
|
short_description: None,
|
||||||
|
interface: Some(SkillInterface {
|
||||||
|
display_name: Some("Visual Assets".to_string()),
|
||||||
|
short_description: Some("Create illustrated assets.".to_string()),
|
||||||
|
icon_small: None,
|
||||||
|
icon_large: None,
|
||||||
|
brand_color: None,
|
||||||
|
default_prompt: Some("Create an animated pet spritesheet.".to_string()),
|
||||||
|
}),
|
||||||
|
dependencies: None,
|
||||||
|
policy: None,
|
||||||
|
path_to_skills_md: path,
|
||||||
|
scope: SkillScope::User,
|
||||||
|
plugin_id: None,
|
||||||
|
remote_plugin_id: None,
|
||||||
|
});
|
||||||
|
let snapshot = HostSkillsSnapshot::new(std::sync::Arc::new(outcome));
|
||||||
|
let catalog = SkillCatalog {
|
||||||
|
entries: vec![SkillCatalogEntry::new(
|
||||||
|
SkillPackageId(resource.clone()),
|
||||||
|
SkillAuthority::new(SkillSourceKind::Host, "host"),
|
||||||
|
"content-tools",
|
||||||
|
"Prepare visual content.",
|
||||||
|
SkillResourceId::new(resource),
|
||||||
|
)],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let selector = CharacterRoutingCardSkillSelector::new(&catalog, Some(&snapshot));
|
||||||
|
let documents = [document(
|
||||||
|
/*id*/ 0,
|
||||||
|
"content-tools",
|
||||||
|
"Prepare visual content.",
|
||||||
|
)];
|
||||||
|
|
||||||
|
let selection = selector.select(
|
||||||
|
"make an animated pet spritesheet",
|
||||||
|
&documents,
|
||||||
|
/*limit*/ 20,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(vec![0], selection.candidate_ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn connector_dependencies_make_generic_skills_discoverable() {
|
||||||
|
let dependencies = SkillDependencies {
|
||||||
|
tools: vec![SkillToolDependency {
|
||||||
|
r#type: "app".to_string(),
|
||||||
|
value: "slack".to_string(),
|
||||||
|
description: Some("Post messages to team channels.".to_string()),
|
||||||
|
transport: None,
|
||||||
|
command: None,
|
||||||
|
url: None,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
let documents = [
|
||||||
|
SkillSelectionDocument {
|
||||||
|
dependencies: Some(&dependencies),
|
||||||
|
..document(
|
||||||
|
/*id*/ 3,
|
||||||
|
"team-communication",
|
||||||
|
"Share updates with the team.",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
document(/*id*/ 4, "documents", "Write project updates."),
|
||||||
|
];
|
||||||
|
let selector = CharacterRoutingCardSkillSelector::new(
|
||||||
|
&SkillCatalog::default(),
|
||||||
|
/*host_snapshot*/ None,
|
||||||
|
);
|
||||||
|
|
||||||
|
let selection = selector.select("post this update in Slack", &documents, /*limit*/ 20);
|
||||||
|
|
||||||
|
assert_eq!(Some(3), selection.candidate_ids.first().copied());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strong_baseline_match_is_preserved_alongside_routing_match() {
|
||||||
|
let documents = [
|
||||||
|
document(/*id*/ 1, "presentation-tools", "Build meeting decks."),
|
||||||
|
document(/*id*/ 2, "content-tools", "Prepare visual content."),
|
||||||
|
];
|
||||||
|
let selector = CharacterRoutingCardSkillSelector {
|
||||||
|
routing_fields: HashMap::from([(
|
||||||
|
2,
|
||||||
|
"Create a presentation with PowerPoint slides".to_string(),
|
||||||
|
)]),
|
||||||
|
};
|
||||||
|
|
||||||
|
let selection = selector.select(
|
||||||
|
"create a PowerPoint presentation",
|
||||||
|
&documents,
|
||||||
|
/*limit*/ 2,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(vec![2, 1], selection.candidate_ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn long_routing_metadata_preserves_original_short_description_matches() {
|
||||||
|
let metadata = (0_u64..60)
|
||||||
|
.map(|index| format!("{:016x}", index.wrapping_mul(/*rhs*/ 0x9e37_79b9_7f4a_7c15)))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ");
|
||||||
|
let short_description = "zebra xylophone quasar";
|
||||||
|
let documents = [SkillSelectionDocument {
|
||||||
|
short_description: Some(short_description),
|
||||||
|
..document(/*id*/ 0, "content-tools", "Prepare visual content.")
|
||||||
|
}];
|
||||||
|
let selector = CharacterRoutingCardSkillSelector {
|
||||||
|
routing_fields: HashMap::from([(/*id*/ 0, metadata)]),
|
||||||
|
};
|
||||||
|
|
||||||
|
let selection = selector.select(short_description, &documents, /*limit*/ 20);
|
||||||
|
|
||||||
|
assert_eq!(vec![0], selection.candidate_ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn document<'a>(id: usize, name: &'a str, description: &'a str) -> SkillSelectionDocument<'a> {
|
||||||
|
SkillSelectionDocument {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
short_description: None,
|
||||||
|
description,
|
||||||
|
dependencies: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -417,10 +417,14 @@ where
|
|||||||
if host_catalog_in_world_state && host_snapshot.is_some() {
|
if host_catalog_in_world_state && host_snapshot.is_some() {
|
||||||
shadow_catalog.extend(self.providers.list_host_for_turn(host_query).await);
|
shadow_catalog.extend(self.providers.list_host_for_turn(host_query).await);
|
||||||
}
|
}
|
||||||
Some(
|
let shadow_selected_entries =
|
||||||
self.shadow_selection
|
collect_explicit_skill_mentions(&input.user_input, &shadow_catalog);
|
||||||
.run(&input.user_input, &shadow_catalog),
|
Some(self.shadow_selection.run(
|
||||||
)
|
&input.user_input,
|
||||||
|
&shadow_catalog,
|
||||||
|
&shadow_selected_entries,
|
||||||
|
host_snapshot.as_deref(),
|
||||||
|
))
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,12 +6,15 @@ use std::sync::PoisonError;
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use codex_core_skills::HostSkillsSnapshot;
|
||||||
use codex_otel::MetricsClient;
|
use codex_otel::MetricsClient;
|
||||||
use codex_protocol::user_input::UserInput;
|
use codex_protocol::user_input::UserInput;
|
||||||
|
|
||||||
use crate::catalog::SkillCatalog;
|
use crate::catalog::SkillCatalog;
|
||||||
|
use crate::catalog::SkillCatalogEntry;
|
||||||
use crate::catalog::SkillSourceKind;
|
use crate::catalog::SkillSourceKind;
|
||||||
use crate::dynamic_skill_selector::CharacterNgramSkillSelector;
|
use crate::dynamic_skill_selector::CharacterNgramSkillSelector;
|
||||||
|
use crate::dynamic_skill_selector::CharacterRoutingCardSkillSelector;
|
||||||
use crate::dynamic_skill_selector::CheapSkillSelection;
|
use crate::dynamic_skill_selector::CheapSkillSelection;
|
||||||
use crate::dynamic_skill_selector::CheapSkillSelector;
|
use crate::dynamic_skill_selector::CheapSkillSelector;
|
||||||
use crate::dynamic_skill_selector::FieldedBm25SkillSelector;
|
use crate::dynamic_skill_selector::FieldedBm25SkillSelector;
|
||||||
@@ -56,9 +59,15 @@ impl ShadowSelectionExperiment {
|
|||||||
&self,
|
&self,
|
||||||
inputs: &[UserInput],
|
inputs: &[UserInput],
|
||||||
catalog: &SkillCatalog,
|
catalog: &SkillCatalog,
|
||||||
|
explicitly_selected: &[SkillCatalogEntry],
|
||||||
|
host_snapshot: Option<&HostSkillsSnapshot>,
|
||||||
) -> ShadowSelectionTurnState {
|
) -> ShadowSelectionTurnState {
|
||||||
let query = build_shadow_query(inputs);
|
let query = build_shadow_query(inputs);
|
||||||
let query_script = query_script_tag(&query.text);
|
let query_script = query_script_tag(&query.text);
|
||||||
|
let explicitly_selected_skill_resources = explicitly_selected
|
||||||
|
.iter()
|
||||||
|
.map(|entry| normalize_skill_resource(entry.main_prompt.as_str()))
|
||||||
|
.collect::<HashSet<_>>();
|
||||||
let documents = catalog
|
let documents = catalog
|
||||||
.entries
|
.entries
|
||||||
.iter()
|
.iter()
|
||||||
@@ -71,6 +80,8 @@ impl ShadowSelectionExperiment {
|
|||||||
&entry.authority.kind,
|
&entry.authority.kind,
|
||||||
SkillSourceKind::Host | SkillSourceKind::Orchestrator
|
SkillSourceKind::Host | SkillSourceKind::Orchestrator
|
||||||
)
|
)
|
||||||
|
&& !explicitly_selected_skill_resources
|
||||||
|
.contains(&normalize_skill_resource(entry.main_prompt.as_str()))
|
||||||
})
|
})
|
||||||
.map(|(id, entry)| SkillSelectionDocument {
|
.map(|(id, entry)| SkillSelectionDocument {
|
||||||
id,
|
id,
|
||||||
@@ -84,9 +95,23 @@ impl ShadowSelectionExperiment {
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|document| document.id)
|
.map(|document| document.id)
|
||||||
.collect::<HashSet<_>>();
|
.collect::<HashSet<_>>();
|
||||||
let mut ranked_selections = Vec::with_capacity(self.selectors.len());
|
let eligible_skill_resources = documents
|
||||||
|
.iter()
|
||||||
|
.map(|document| {
|
||||||
|
normalize_skill_resource(catalog.entries[document.id].main_prompt.as_str())
|
||||||
|
})
|
||||||
|
.collect::<HashSet<_>>();
|
||||||
|
let routing_selector = CharacterRoutingCardSkillSelector::new(catalog, host_snapshot);
|
||||||
|
let mut ranked_selections = Vec::with_capacity(self.selectors.len() + 1);
|
||||||
|
|
||||||
for selector in &self.selectors {
|
for selector in self
|
||||||
|
.selectors
|
||||||
|
.iter()
|
||||||
|
.map(std::convert::AsRef::as_ref)
|
||||||
|
.chain(std::iter::once(
|
||||||
|
&routing_selector as &dyn CheapSkillSelector,
|
||||||
|
))
|
||||||
|
{
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
let selection =
|
let selection =
|
||||||
selector.select(&query.text, &documents, /*limit*/ MAX_SHADOW_RESULTS);
|
selector.select(&query.text, &documents, /*limit*/ MAX_SHADOW_RESULTS);
|
||||||
@@ -123,12 +148,16 @@ impl ShadowSelectionExperiment {
|
|||||||
ShadowSelectionTurnState {
|
ShadowSelectionTurnState {
|
||||||
ranked_selections,
|
ranked_selections,
|
||||||
query_script,
|
query_script,
|
||||||
|
eligible_skill_resources,
|
||||||
seen_skill_resources: Mutex::new(HashSet::new()),
|
seen_skill_resources: Mutex::new(HashSet::new()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn record_invocation(&self, state: &ShadowSelectionTurnState, skill_resource: &str) {
|
pub(crate) fn record_invocation(&self, state: &ShadowSelectionTurnState, skill_resource: &str) {
|
||||||
let skill_resource = normalize_skill_resource(skill_resource);
|
let skill_resource = normalize_skill_resource(skill_resource);
|
||||||
|
if !state.eligible_skill_resources.contains(&skill_resource) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if !state
|
if !state
|
||||||
.seen_skill_resources
|
.seen_skill_resources
|
||||||
.lock()
|
.lock()
|
||||||
@@ -208,6 +237,7 @@ impl ShadowSelectionExperiment {
|
|||||||
pub(crate) struct ShadowSelectionTurnState {
|
pub(crate) struct ShadowSelectionTurnState {
|
||||||
ranked_selections: Vec<RankedSelection>,
|
ranked_selections: Vec<RankedSelection>,
|
||||||
query_script: &'static str,
|
query_script: &'static str,
|
||||||
|
eligible_skill_resources: HashSet<String>,
|
||||||
seen_skill_resources: Mutex<HashSet<String>>,
|
seen_skill_resources: Mutex<HashSet<String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user