Add task context to shadow skill selection (#39008)

## Why

Short continuation prompts such as `continue` do not contain enough context for
skill selection on their own.

## What changed

- Add the `task_context_fusion_v1` shadow selector, combining the current request
  with up to two prior substantive requests and recently relevant skills.
- Record explicit skill intent and successful skill invocations for future turns,
  while excluding same-turn observations from predictions.
- Bound retained requests, augmented queries, and skill history, including safe
  truncation at UTF-8 character boundaries.

## Testing

Add unit and extension tests for continuation prompts, explicit intent, turn
isolation, cold thread state, bounded history, and unchanged control selectors.

GitOrigin-RevId: 72eca3f0d64620a0d24e95d5675f126ac982f8c7
This commit is contained in:
jif authored and copyberry committed 2026-08-17 13:29:57 +00:00
1 parent def7ed5572
commit 21cfd369ef
8 files changed
+524 -9

No files matched your search

+2 -1
View File
@@ -395,11 +395,12 @@ where
let shadow_selected_entries =
collect_explicit_skill_mentions(&input.user_input, &shadow_catalog);
Some(self.shadow_selection.run(
&input.user_input,
&input,
&shadow_catalog,
&shadow_selected_entries,
host_snapshot.as_deref(),
Arc::clone(&thread_state.recent_skill_invocations),
Arc::clone(&thread_state.shadow_task_context),
))
} else {
None
@@ -1,5 +1,9 @@
// This shadow-selection experiment is temporary and should be removed after evaluation.
mod task_context;
pub(crate) use task_context::ShadowTaskContext;
use std::collections::HashMap;
use std::collections::HashSet;
use std::collections::VecDeque;
@@ -10,6 +14,7 @@ use std::time::Duration;
use std::time::Instant;
use crate::HostSkillsSnapshot;
use codex_extension_api::TurnInputContext;
use codex_otel::MetricsClient;
use codex_protocol::user_input::UserInput;
@@ -64,14 +69,16 @@ impl ShadowSelectionExperiment {
pub(crate) fn run(
&self,
inputs: &[UserInput],
input: &TurnInputContext,
catalog: &SkillCatalog,
explicitly_selected: &[SkillCatalogEntry],
host_snapshot: Option<&HostSkillsSnapshot>,
recent_skill_invocations: Arc<RecentSkillInvocations>,
task_context: Arc<ShadowTaskContext>,
) -> ShadowSelectionTurnState {
let query = build_shadow_query(inputs);
let query = build_shadow_query(&input.user_input);
let query_script = query_script_tag(&query.text);
let task_snapshot = task_context.begin_turn(&input.turn_id, &query, &input.user_input);
let explicitly_selected_skill_resources = explicitly_selected
.iter()
.map(|entry| normalize_skill_resource(entry.main_prompt.as_str()))
@@ -132,9 +139,19 @@ impl ShadowSelectionExperiment {
lru_selector.clone(),
routing_selector.clone(),
);
let mut ranked_selections = Vec::with_capacity(self.selectors.len() + 5);
let task_selector = LruPlusLexicalCharacterRoutingSkillSelector::new(
LruSkillSelector::new(
task_snapshot
.recent_skills
.iter()
.filter_map(|resource| eligible_skill_ids_by_resource.get(resource).copied())
.collect(),
),
routing_selector.clone(),
);
let mut ranked_selections = Vec::with_capacity(self.selectors.len() + 6);
for selector in self
for (method, selector, query) in self
.selectors
.iter()
.map(std::convert::AsRef::as_ref)
@@ -145,14 +162,21 @@ impl ShadowSelectionExperiment {
&lru_plus_character_selector as &dyn CheapSkillSelector,
&lru_plus_lexical_character_selector as &dyn CheapSkillSelector,
])
.map(|selector| (selector.method(), selector, &query))
.chain([(
"task_context_fusion_v1",
&task_selector as &dyn CheapSkillSelector,
&task_snapshot.query,
)])
{
let query_script = query_script_tag(&query.text);
let start = Instant::now();
let selection =
selector.select(&query.text, &documents, /*limit*/ MAX_SHADOW_RESULTS);
let duration = start.elapsed();
let selected_ids = sanitize_selected_ids(&selection, &eligible_ids);
self.record_metrics(ShadowSelectionObservation {
method: selector.method(),
method,
selection: &selection,
query_truncated_before_selection: query.truncated,
query_script,
@@ -161,14 +185,14 @@ impl ShadowSelectionExperiment {
duration,
});
ranked_selections.push(RankedSelection {
method: selector.method(),
method,
skill_resources: selected_ids
.iter()
.map(|id| normalize_skill_resource(catalog.entries[*id].main_prompt.as_str()))
.collect(),
});
tracing::debug!(
method = selector.method(),
method,
catalog_entries = documents.len(),
selected_entries = selected_ids.len(),
query_terms = selection.query_term_count,
@@ -179,12 +203,29 @@ impl ShadowSelectionExperiment {
);
}
// Explicit intent is a relevance signal even if the subsequent prompt read fails.
// Keep it out of the implicit-only controls and freeze predictions before recording it.
for entry in explicitly_selected.iter().filter(|entry| {
entry.is_model_visible()
&& matches!(
&entry.authority.kind,
SkillSourceKind::Host | SkillSourceKind::Orchestrator
)
}) {
task_context.record(
&input.turn_id,
normalize_skill_resource(entry.main_prompt.as_str()),
);
}
ShadowSelectionTurnState {
ranked_selections,
turn_id: input.turn_id.clone(),
query_script,
eligible_skill_resources,
seen_skill_resources: Mutex::new(HashSet::new()),
recent_skill_invocations,
task_context,
}
}
@@ -204,6 +245,9 @@ impl ShadowSelectionExperiment {
state
.recent_skill_invocations
.record(skill_resource.clone());
state
.task_context
.record(&state.turn_id, skill_resource.clone());
let Some(metrics_client) = self.metrics_client.as_ref() else {
return;
};
@@ -274,10 +318,12 @@ impl ShadowSelectionExperiment {
pub(crate) struct ShadowSelectionTurnState {
ranked_selections: Vec<RankedSelection>,
turn_id: String,
query_script: &'static str,
eligible_skill_resources: HashSet<String>,
seen_skill_resources: Mutex<HashSet<String>>,
recent_skill_invocations: Arc<RecentSkillInvocations>,
task_context: Arc<ShadowTaskContext>,
}
#[derive(Default)]
@@ -435,6 +481,7 @@ fn is_cjk(character: char) -> bool {
)
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct ShadowQuery {
text: String,
truncated: bool,
@@ -479,5 +526,5 @@ fn push_bounded(destination: &mut String, value: &str) -> bool {
}
#[cfg(test)]
#[path = "shadow_selection_experiment_tests.rs"]
#[path = "experiment_tests.rs"]
mod tests;
@@ -0,0 +1,155 @@
use std::collections::VecDeque;
use std::sync::Mutex;
use std::sync::PoisonError;
use codex_protocol::user_input::UserInput;
use codex_utils_string::take_bytes_at_char_boundary;
use super::ShadowQuery;
const MAX_PRIOR_REQUESTS: usize = 2;
const MAX_REQUEST_BYTES: usize = 2 * 1024;
const MAX_QUERY_BYTES: usize = 4 * 1024;
const MAX_RECENT_SKILLS: usize = 50;
/// Shadow-only relevance history. New and reconstructed thread runtimes start cold.
#[derive(Default)]
pub(crate) struct ShadowTaskContext(Mutex<TaskContextState>);
#[derive(Default)]
struct TaskContextState {
prior_requests: VecDeque<ShadowQuery>,
recent_skills: VecDeque<String>,
pending: Option<PendingTurn>,
}
struct PendingTurn {
id: String,
request: Option<ShadowQuery>,
recent_skills: VecDeque<String>,
}
#[derive(Debug, PartialEq, Eq)]
pub(super) struct TaskContextSnapshot {
pub(super) query: ShadowQuery,
pub(super) recent_skills: Vec<String>,
}
impl ShadowTaskContext {
pub(super) fn begin_turn(
&self,
turn_id: &str,
current: &ShadowQuery,
inputs: &[UserInput],
) -> TaskContextSnapshot {
let mut state = self.0.lock().unwrap_or_else(PoisonError::into_inner);
if state.pending.as_ref().is_none_or(|turn| turn.id != turn_id) {
if let Some(previous) = state.pending.take() {
if let Some(request) = previous.request {
state
.prior_requests
.retain(|prior| prior.text != request.text);
state.prior_requests.push_front(request);
state.prior_requests.truncate(MAX_PRIOR_REQUESTS);
}
for resource in previous.recent_skills.into_iter().rev() {
remember_skill(&mut state.recent_skills, resource);
}
}
state.pending = Some(PendingTurn {
id: turn_id.to_string(),
request: None,
recent_skills: VecDeque::new(),
});
}
let retained = take_bytes_at_char_boundary(&current.text, MAX_REQUEST_BYTES);
let text = take_bytes_at_char_boundary(&current.text, MAX_QUERY_BYTES);
let mut query = ShadowQuery {
text: text.to_string(),
truncated: current.truncated || text.len() < current.text.len(),
};
for prior in &state.prior_requests {
if prior.text == retained {
continue;
}
if !query.text.is_empty() && query.text.len() < MAX_QUERY_BYTES {
query.text.push('\n');
}
let part = take_bytes_at_char_boundary(
&prior.text,
MAX_QUERY_BYTES.saturating_sub(query.text.len()),
);
query.text.push_str(part);
query.truncated |= prior.truncated || part.len() < prior.text.len();
if part.len() < prior.text.len() {
break;
}
}
let recent_skills = state.recent_skills.iter().cloned().collect();
if is_substantive(&current.text, inputs)
&& let Some(pending) = state.pending.as_mut()
{
pending.request = Some(ShadowQuery {
text: retained.to_string(),
truncated: current.truncated || retained.len() < current.text.len(),
});
}
TaskContextSnapshot {
query,
recent_skills,
}
}
/// Records relevance evidence for future turns, never the active turn's predictions.
pub(super) fn record(&self, turn_id: &str, resource: String) {
let mut state = self.0.lock().unwrap_or_else(PoisonError::into_inner);
if let Some(pending) = state.pending.as_mut().filter(|turn| turn.id == turn_id) {
remember_skill(&mut pending.recent_skills, resource);
}
}
}
fn remember_skill(skills: &mut VecDeque<String>, resource: String) {
skills.retain(|previous| previous != &resource);
skills.push_front(resource);
skills.truncate(MAX_RECENT_SKILLS);
}
fn is_substantive(text: &str, inputs: &[UserInput]) -> bool {
if inputs.iter().any(|input| {
matches!(input, UserInput::Skill { name, .. } | UserInput::Mention { name, .. } if !name.trim().is_empty())
}) {
return true;
}
let normalized = text
.split(|character: char| !character.is_alphanumeric())
.filter(|part| !part.is_empty())
.map(str::to_lowercase)
.collect::<Vec<_>>()
.join(" ");
!matches!(
normalized.as_str(),
"" | "yes"
| "yep"
| "yeah"
| "ok"
| "okay"
| "sure"
| "go"
| "go ahead"
| "continue"
| "please continue"
| "proceed"
| "do it"
| "do that"
| "try again"
| "retry"
| "thanks"
| "thank you"
)
}
#[cfg(test)]
#[path = "task_context_tests.rs"]
mod tests;
@@ -0,0 +1,112 @@
use pretty_assertions::assert_eq;
use super::*;
fn query(text: &str) -> ShadowQuery {
ShadowQuery {
text: text.to_string(),
truncated: false,
}
}
fn begin(context: &ShadowTaskContext, turn_id: &str, text: &str) -> TaskContextSnapshot {
context.begin_turn(turn_id, &query(text), &[])
}
#[test]
fn prior_requests_survive_continuations_and_keep_short_substantive_input() {
let context = ShadowTaskContext::default();
begin(&context, "1", "Fix lint errors.");
assert_eq!(
query("continue\nFix lint errors."),
begin(&context, "2", "continue").query
);
begin(&context, "3", "OK!!!");
begin(&context, "4", "deploy");
begin(&context, "5", "修复测试");
assert_eq!(
query("continue\n修复测试\ndeploy"),
begin(&context, "6", "continue").query
);
assert_eq!(
query("deploy\n修复测试"),
begin(&context, "7", "deploy").query
);
assert!(is_substantive(
"go",
&[UserInput::Mention {
name: "go".to_string(),
path: "skill://go".to_string(),
}]
));
}
#[test]
fn current_turn_observations_are_pending_and_late_observations_are_ignored() {
let context = ShadowTaskContext::default();
begin(&context, "1", "continue");
context.record("1", "a".to_string());
assert_eq!(
Vec::<String>::new(),
begin(&context, "1", "continue").recent_skills
);
assert_eq!(vec!["a"], begin(&context, "2", "continue").recent_skills);
context.record("1", "late".to_string());
context.record("2", "a".to_string());
context.record("2", "b".to_string());
assert_eq!(vec!["a"], begin(&context, "2", "continue").recent_skills);
assert_eq!(
TaskContextSnapshot {
query: query("continue"),
recent_skills: vec!["b".to_string(), "a".to_string()],
},
begin(&context, "3", "continue")
);
assert_eq!(
TaskContextSnapshot {
query: query("continue"),
recent_skills: Vec::new(),
},
begin(&ShadowTaskContext::default(), "3", "continue")
);
}
#[test]
fn history_and_augmented_query_are_bounded_at_utf8_boundaries() {
let context = ShadowTaskContext::default();
let first = "界".repeat(MAX_REQUEST_BYTES);
let initial = begin(&context, "1", &first);
assert_eq!(
ShadowQuery {
text: take_bytes_at_char_boundary(&first, MAX_QUERY_BYTES).to_string(),
truncated: true,
},
initial.query
);
for index in 0..=MAX_RECENT_SKILLS {
context.record("1", format!("skill-{index}"));
}
begin(&context, "2", &"é".repeat(MAX_REQUEST_BYTES));
context.record("2", "skill-1".to_string());
let expected = format!(
"continue\n{}\n{}",
"é".repeat(MAX_REQUEST_BYTES / 2),
take_bytes_at_char_boundary(&first, MAX_REQUEST_BYTES)
);
assert_eq!(
TaskContextSnapshot {
query: ShadowQuery {
text: take_bytes_at_char_boundary(&expected, MAX_QUERY_BYTES).to_string(),
truncated: true,
},
recent_skills: std::iter::once("skill-1".to_string())
.chain(
(2..=MAX_RECENT_SKILLS)
.rev()
.map(|index| format!("skill-{index}"))
)
.collect(),
},
begin(&context, "3", "continue")
);
}
+3
View File
@@ -23,6 +23,7 @@ use crate::provider::SkillListQuery;
use crate::provider::SkillReadRequest;
use crate::shadow_selection_experiment::RecentSkillInvocations;
use crate::shadow_selection_experiment::ShadowSelectionTurnState;
use crate::shadow_selection_experiment::ShadowTaskContext;
use crate::sources::SkillProviders;
const MAX_CACHED_ORCHESTRATOR_RESOURCES: usize = 100;
@@ -41,6 +42,7 @@ pub(crate) struct SkillsThreadState {
orchestrator_cache: Mutex<Option<Arc<OrchestratorGenerationCache>>>,
shadow_selection_turn: Mutex<Option<ShadowSelectionTurn>>,
pub(crate) recent_skill_invocations: Arc<RecentSkillInvocations>,
pub(crate) shadow_task_context: Arc<ShadowTaskContext>,
}
impl SkillsThreadState {
@@ -53,6 +55,7 @@ impl SkillsThreadState {
orchestrator_cache: Mutex::new(None),
shadow_selection_turn: Mutex::new(None),
recent_skill_invocations: Arc::new(RecentSkillInvocations::default()),
shadow_task_context: Arc::new(ShadowTaskContext::default()),
}
}