mirror of
https://github.com/openai/codex.git
synced 2026-05-09 05:42:32 +00:00
Compare commits
4 Commits
dev/cc/new
...
dev/mzeng/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
deae88d371 | ||
|
|
64cd78e0e7 | ||
|
|
82e5ae9611 | ||
|
|
47f5026d98 |
1
codex-rs/Cargo.lock
generated
1
codex-rs/Cargo.lock
generated
@@ -2070,6 +2070,7 @@ dependencies = [
|
||||
"codex-protocol",
|
||||
"codex-skills",
|
||||
"codex-utils-absolute-path",
|
||||
"codex-utils-output-truncation",
|
||||
"codex-utils-plugins",
|
||||
"dirs",
|
||||
"dunce",
|
||||
|
||||
@@ -937,6 +937,8 @@ Event notifications are the server-initiated event stream for thread lifecycles,
|
||||
|
||||
Thread realtime uses a separate thread-scoped notification surface. `thread/realtime/*` notifications are ephemeral transport events, not `ThreadItem`s, and are not returned by `thread/read`, `thread/resume`, or `thread/fork`.
|
||||
|
||||
Recoverable startup warnings use the existing `configWarning` notification: `{ summary, details?, path?, range? }`. App-server may emit it during initialization and immediately after a thread starts for session setup warnings such as skill metadata being trimmed to fit its budget.
|
||||
|
||||
### Notification opt-out
|
||||
|
||||
Clients can suppress specific notifications per connection by sending exact method names in `initialize.params.capabilities.optOutNotificationMethods`.
|
||||
|
||||
@@ -46,6 +46,7 @@ use codex_app_server_protocol::CommandExecParams;
|
||||
use codex_app_server_protocol::CommandExecResizeParams;
|
||||
use codex_app_server_protocol::CommandExecTerminateParams;
|
||||
use codex_app_server_protocol::CommandExecWriteParams;
|
||||
use codex_app_server_protocol::ConfigWarningNotification;
|
||||
use codex_app_server_protocol::ConversationGitInfo;
|
||||
use codex_app_server_protocol::ConversationSummary;
|
||||
use codex_app_server_protocol::DynamicToolSpec as ApiDynamicToolSpec;
|
||||
@@ -2557,7 +2558,7 @@ impl CodexMessageProcessor {
|
||||
thread_id,
|
||||
thread,
|
||||
session_configured,
|
||||
..
|
||||
thread_start_warnings,
|
||||
} = new_conv;
|
||||
if let Err(error) = Self::set_app_server_client_info(
|
||||
thread.as_ref(),
|
||||
@@ -2650,6 +2651,7 @@ impl CodexMessageProcessor {
|
||||
);
|
||||
}
|
||||
|
||||
let response_connection_id = request_id.connection_id;
|
||||
listener_task_context
|
||||
.outgoing
|
||||
.send_response(request_id, response)
|
||||
@@ -2668,6 +2670,13 @@ impl CodexMessageProcessor {
|
||||
otel.name = "app_server.thread_start.notify_started",
|
||||
))
|
||||
.await;
|
||||
|
||||
send_thread_start_warnings_to_connection(
|
||||
&listener_task_context.outgoing,
|
||||
response_connection_id,
|
||||
thread_start_warnings,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Err(err) => {
|
||||
let error = JSONRPCErrorError {
|
||||
@@ -4110,6 +4119,7 @@ impl CodexMessageProcessor {
|
||||
thread_id,
|
||||
thread: codex_thread,
|
||||
session_configured,
|
||||
..
|
||||
}) => {
|
||||
let SessionConfiguredEvent { rollout_path, .. } = session_configured;
|
||||
let Some(rollout_path) = rollout_path else {
|
||||
@@ -4678,7 +4688,7 @@ impl CodexMessageProcessor {
|
||||
thread_id,
|
||||
thread: forked_thread,
|
||||
session_configured,
|
||||
..
|
||||
thread_start_warnings,
|
||||
} = match self
|
||||
.thread_manager
|
||||
.fork_thread(
|
||||
@@ -4873,6 +4883,12 @@ impl CodexMessageProcessor {
|
||||
self.outgoing
|
||||
.send_server_notification(ServerNotification::ThreadStarted(notif))
|
||||
.await;
|
||||
send_thread_start_warnings_to_connection(
|
||||
&self.outgoing,
|
||||
connection_id,
|
||||
thread_start_warnings,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn get_thread_summary(
|
||||
@@ -7422,7 +7438,7 @@ impl CodexMessageProcessor {
|
||||
thread_id,
|
||||
thread: review_thread,
|
||||
session_configured,
|
||||
..
|
||||
thread_start_warnings,
|
||||
} = self
|
||||
.thread_manager
|
||||
.fork_thread(
|
||||
@@ -7470,6 +7486,12 @@ impl CodexMessageProcessor {
|
||||
self.outgoing
|
||||
.send_server_notification(ServerNotification::ThreadStarted(notif))
|
||||
.await;
|
||||
send_thread_start_warnings_to_connection(
|
||||
&self.outgoing,
|
||||
request_id.connection_id,
|
||||
thread_start_warnings,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
@@ -8360,6 +8382,31 @@ impl CodexMessageProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_thread_start_warnings_to_connection(
|
||||
outgoing: &Arc<OutgoingMessageSender>,
|
||||
connection_id: ConnectionId,
|
||||
thread_start_warnings: Vec<String>,
|
||||
) {
|
||||
let warning_connection_ids = [connection_id];
|
||||
for warning in thread_start_warnings {
|
||||
outgoing
|
||||
.send_server_notification_to_connections(
|
||||
&warning_connection_ids,
|
||||
ServerNotification::ConfigWarning(ConfigWarningNotification {
|
||||
summary: warning,
|
||||
details: None,
|
||||
path: None,
|
||||
range: None,
|
||||
}),
|
||||
)
|
||||
.instrument(tracing::info_span!(
|
||||
"app_server.thread_start.notify_config_warning",
|
||||
otel.name = "app_server.thread_start.notify_config_warning",
|
||||
))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_thread_list_cwd_filter(
|
||||
cwd: Option<String>,
|
||||
) -> Result<Option<PathBuf>, JSONRPCErrorError> {
|
||||
|
||||
@@ -188,6 +188,72 @@ async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_fork_emits_skill_metadata_budget_warning() -> Result<()> {
|
||||
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
||||
let codex_home = TempDir::new()?;
|
||||
create_config_toml(codex_home.path(), &server.uri())?;
|
||||
insert_model_context_window(codex_home.path(), 100)?;
|
||||
for i in 0..120 {
|
||||
write_skill(codex_home.path(), &format!("demo-{i:03}"))?;
|
||||
}
|
||||
|
||||
let conversation_id = create_fake_rollout(
|
||||
codex_home.path(),
|
||||
"2025-01-05T12-00-00",
|
||||
"2025-01-05T12:00:00Z",
|
||||
"Saved user message",
|
||||
Some("mock_provider"),
|
||||
/*git_info*/ None,
|
||||
)?;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let fork_id = mcp
|
||||
.send_thread_fork_request(ThreadForkParams {
|
||||
thread_id: conversation_id,
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let fork_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(fork_id)),
|
||||
)
|
||||
.await??;
|
||||
let ThreadForkResponse { thread, .. } = to_response::<ThreadForkResponse>(fork_resp)?;
|
||||
|
||||
let started = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("thread/started"),
|
||||
)
|
||||
.await??;
|
||||
let started: ThreadStartedNotification =
|
||||
serde_json::from_value(started.params.expect("params must be present"))?;
|
||||
assert_eq!(started.thread.id, thread.id);
|
||||
|
||||
let warning = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("configWarning"),
|
||||
)
|
||||
.await??;
|
||||
let parsed: ServerNotification = warning.try_into()?;
|
||||
let ServerNotification::ConfigWarning(notification) = parsed else {
|
||||
panic!("expected configWarning notification");
|
||||
};
|
||||
|
||||
assert!(
|
||||
notification
|
||||
.summary
|
||||
.contains("Skills list trimmed to fit the metadata budget"),
|
||||
"unexpected warning: {}",
|
||||
notification.summary
|
||||
);
|
||||
assert!(notification.summary.contains("2 tokens"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_fork_emits_restored_token_usage_before_next_turn() -> Result<()> {
|
||||
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
||||
@@ -614,6 +680,33 @@ stream_max_retries = 0
|
||||
)
|
||||
}
|
||||
|
||||
fn insert_model_context_window(
|
||||
codex_home: &Path,
|
||||
model_context_window: i64,
|
||||
) -> std::io::Result<()> {
|
||||
let config_toml = codex_home.join("config.toml");
|
||||
let mut config = std::fs::read_to_string(&config_toml)?;
|
||||
let provider_table = "\n[model_providers.mock_provider]";
|
||||
let Some(provider_table_index) = config.find(provider_table) else {
|
||||
return Err(std::io::Error::other(
|
||||
"mock provider table missing from test config",
|
||||
));
|
||||
};
|
||||
config.insert_str(
|
||||
provider_table_index,
|
||||
&format!("\nmodel_context_window = {model_context_window}\n"),
|
||||
);
|
||||
std::fs::write(config_toml, config)
|
||||
}
|
||||
|
||||
fn write_skill(codex_home: &Path, name: &str) -> std::io::Result<()> {
|
||||
let skill_dir = codex_home.join("skills").join(name);
|
||||
std::fs::create_dir_all(&skill_dir)?;
|
||||
let description = "desc ".repeat(200);
|
||||
let content = format!("---\nname: {name}\ndescription: {description}\n---\n\n# Body\n");
|
||||
std::fs::write(skill_dir.join("SKILL.md"), content)
|
||||
}
|
||||
|
||||
fn create_config_toml_with_chatgpt_base_url(
|
||||
codex_home: &Path,
|
||||
server_uri: &str,
|
||||
|
||||
@@ -24,6 +24,7 @@ codex-otel = { workspace = true }
|
||||
codex-protocol = { workspace = true }
|
||||
codex-skills = { workspace = true }
|
||||
codex-utils-absolute-path = { workspace = true }
|
||||
codex-utils-output-truncation = { workspace = true }
|
||||
codex-utils-plugins = { workspace = true }
|
||||
dirs = { workspace = true }
|
||||
dunce = { workspace = true }
|
||||
|
||||
@@ -22,4 +22,9 @@ pub use model::SkillLoadOutcome;
|
||||
pub use model::SkillMetadata;
|
||||
pub use model::SkillPolicy;
|
||||
pub use model::filter_skill_load_outcome_for_product;
|
||||
pub use render::RenderedSkillsSection;
|
||||
pub use render::SkillMetadataBudget;
|
||||
pub use render::SkillRenderReport;
|
||||
pub use render::default_skill_metadata_budget;
|
||||
pub use render::render_skills_section;
|
||||
pub use render::render_skills_section_with_budget;
|
||||
|
||||
@@ -1,22 +1,140 @@
|
||||
use crate::model::SkillMetadata;
|
||||
use codex_protocol::protocol::SKILLS_INSTRUCTIONS_CLOSE_TAG;
|
||||
use codex_protocol::protocol::SKILLS_INSTRUCTIONS_OPEN_TAG;
|
||||
use codex_protocol::protocol::SkillScope;
|
||||
use codex_utils_output_truncation::approx_token_count;
|
||||
|
||||
pub const DEFAULT_SKILL_METADATA_CHAR_BUDGET: usize = 8_000;
|
||||
pub const SKILL_METADATA_CONTEXT_WINDOW_PERCENT: usize = 2;
|
||||
const DEFAULT_OMITTED_SKILL_SAMPLE_LIMIT: usize = 5;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SkillMetadataBudget {
|
||||
Tokens(usize),
|
||||
Characters(usize),
|
||||
}
|
||||
|
||||
impl SkillMetadataBudget {
|
||||
fn limit(self) -> usize {
|
||||
match self {
|
||||
Self::Tokens(limit) | Self::Characters(limit) => limit,
|
||||
}
|
||||
}
|
||||
|
||||
fn cost(self, text: &str) -> usize {
|
||||
match self {
|
||||
Self::Tokens(_) => approx_token_count(text),
|
||||
Self::Characters(_) => text.chars().count(),
|
||||
}
|
||||
}
|
||||
|
||||
fn describe(self) -> String {
|
||||
match self {
|
||||
Self::Tokens(limit) => format!("{limit} tokens"),
|
||||
Self::Characters(limit) => format!("{limit} characters"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct OmittedSkillSummary {
|
||||
pub name: String,
|
||||
pub scope: SkillScope,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SkillRenderReport {
|
||||
pub budget: SkillMetadataBudget,
|
||||
pub total_count: usize,
|
||||
pub included_count: usize,
|
||||
pub omitted_count: usize,
|
||||
pub omitted_samples: Vec<OmittedSkillSummary>,
|
||||
}
|
||||
|
||||
impl SkillRenderReport {
|
||||
pub fn warning_message(&self) -> Option<String> {
|
||||
if self.omitted_count == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let omitted = if self.omitted_samples.is_empty() {
|
||||
"some skills".to_string()
|
||||
} else {
|
||||
let mut names = self
|
||||
.omitted_samples
|
||||
.iter()
|
||||
.map(|skill| format!("{} ({})", skill.name, scope_label(skill.scope)))
|
||||
.collect::<Vec<_>>();
|
||||
let hidden_count = self.omitted_count.saturating_sub(names.len());
|
||||
if hidden_count > 0 {
|
||||
names.push(format!("{hidden_count} more"));
|
||||
}
|
||||
names.join(", ")
|
||||
};
|
||||
|
||||
Some(format!(
|
||||
"Skills list trimmed to fit the metadata budget: showing {included} of {total} enabled skills ({budget}). Omitted skills include {omitted}. Explicitly mention a skill by name or path if needed, or disable unused skills to reduce the list.",
|
||||
included = self.included_count,
|
||||
total = self.total_count,
|
||||
budget = self.budget.describe(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RenderedSkillsSection {
|
||||
pub text: String,
|
||||
pub report: SkillRenderReport,
|
||||
}
|
||||
|
||||
pub fn default_skill_metadata_budget(context_window: Option<i64>) -> SkillMetadataBudget {
|
||||
context_window
|
||||
.and_then(|window| usize::try_from(window).ok())
|
||||
.filter(|window| *window > 0)
|
||||
.map(|window| {
|
||||
SkillMetadataBudget::Tokens(
|
||||
window
|
||||
.saturating_mul(SKILL_METADATA_CONTEXT_WINDOW_PERCENT)
|
||||
.saturating_div(100)
|
||||
.max(1),
|
||||
)
|
||||
})
|
||||
.unwrap_or(SkillMetadataBudget::Characters(
|
||||
DEFAULT_SKILL_METADATA_CHAR_BUDGET,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn render_skills_section(skills: &[SkillMetadata]) -> Option<String> {
|
||||
render_skills_section_inner(skills, None).map(|rendered| rendered.text)
|
||||
}
|
||||
|
||||
pub fn render_skills_section_with_budget(
|
||||
skills: &[SkillMetadata],
|
||||
budget: SkillMetadataBudget,
|
||||
) -> Option<RenderedSkillsSection> {
|
||||
render_skills_section_inner(skills, Some(budget))
|
||||
}
|
||||
|
||||
fn render_skills_section_inner(
|
||||
skills: &[SkillMetadata],
|
||||
budget: Option<SkillMetadataBudget>,
|
||||
) -> Option<RenderedSkillsSection> {
|
||||
if skills.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let (skill_lines, report) = render_skill_lines(skills, budget);
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
lines.push("## Skills".to_string());
|
||||
lines.push("A skill is a set of local instructions to follow that is stored in a `SKILL.md` file. Below is the list of skills that can be used. Each entry includes a name, description, and file path so you can open the source for full instructions when using a specific skill.".to_string());
|
||||
lines.push("### Available skills".to_string());
|
||||
|
||||
for skill in skills {
|
||||
let path_str = skill.path_to_skills_md.to_string_lossy().replace('\\', "/");
|
||||
let name = skill.name.as_str();
|
||||
let description = skill.description.as_str();
|
||||
lines.push(format!("- {name}: {description} (file: {path_str})"));
|
||||
if skill_lines.is_empty() {
|
||||
lines.push("No skill metadata entries fit within the configured budget.".to_string());
|
||||
} else {
|
||||
lines.extend(skill_lines);
|
||||
}
|
||||
if let Some(message) = report.warning_message() {
|
||||
lines.push(message);
|
||||
}
|
||||
|
||||
lines.push("### How to use skills".to_string());
|
||||
@@ -42,7 +160,202 @@ pub fn render_skills_section(skills: &[SkillMetadata]) -> Option<String> {
|
||||
);
|
||||
|
||||
let body = lines.join("\n");
|
||||
Some(format!(
|
||||
"{SKILLS_INSTRUCTIONS_OPEN_TAG}\n{body}\n{SKILLS_INSTRUCTIONS_CLOSE_TAG}"
|
||||
))
|
||||
Some(RenderedSkillsSection {
|
||||
text: format!("{SKILLS_INSTRUCTIONS_OPEN_TAG}\n{body}\n{SKILLS_INSTRUCTIONS_CLOSE_TAG}"),
|
||||
report,
|
||||
})
|
||||
}
|
||||
|
||||
fn render_skill_lines(
|
||||
skills: &[SkillMetadata],
|
||||
budget: Option<SkillMetadataBudget>,
|
||||
) -> (Vec<String>, SkillRenderReport) {
|
||||
let ordered_skills = ordered_skills_for_budget(skills, budget.is_some());
|
||||
let Some(budget) = budget else {
|
||||
return (
|
||||
ordered_skills
|
||||
.iter()
|
||||
.map(|skill| render_skill_line(skill))
|
||||
.collect(),
|
||||
SkillRenderReport {
|
||||
budget: SkillMetadataBudget::Characters(usize::MAX),
|
||||
total_count: skills.len(),
|
||||
included_count: skills.len(),
|
||||
omitted_count: 0,
|
||||
omitted_samples: Vec::new(),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
let mut included = Vec::new();
|
||||
let mut omitted_samples = Vec::new();
|
||||
let mut used = 0usize;
|
||||
let mut omitted_count = 0usize;
|
||||
|
||||
for skill in ordered_skills {
|
||||
let line = render_skill_line(skill);
|
||||
let line_cost = budget.cost(&format!("{line}\n"));
|
||||
if used.saturating_add(line_cost) <= budget.limit() {
|
||||
used = used.saturating_add(line_cost);
|
||||
included.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
omitted_count = omitted_count.saturating_add(1);
|
||||
if omitted_samples.len() < DEFAULT_OMITTED_SKILL_SAMPLE_LIMIT {
|
||||
omitted_samples.push(OmittedSkillSummary {
|
||||
name: skill.name.clone(),
|
||||
scope: skill.scope,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let report = SkillRenderReport {
|
||||
budget,
|
||||
total_count: skills.len(),
|
||||
included_count: included.len(),
|
||||
omitted_count,
|
||||
omitted_samples,
|
||||
};
|
||||
|
||||
(included, report)
|
||||
}
|
||||
|
||||
fn ordered_skills_for_budget(
|
||||
skills: &[SkillMetadata],
|
||||
prioritize_for_budget: bool,
|
||||
) -> Vec<&SkillMetadata> {
|
||||
let mut ordered = skills.iter().collect::<Vec<_>>();
|
||||
if prioritize_for_budget {
|
||||
ordered.sort_by(|a, b| {
|
||||
prompt_scope_rank(a.scope)
|
||||
.cmp(&prompt_scope_rank(b.scope))
|
||||
.then_with(|| a.name.cmp(&b.name))
|
||||
.then_with(|| a.path_to_skills_md.cmp(&b.path_to_skills_md))
|
||||
});
|
||||
}
|
||||
ordered
|
||||
}
|
||||
|
||||
fn prompt_scope_rank(scope: SkillScope) -> u8 {
|
||||
match scope {
|
||||
SkillScope::System => 0,
|
||||
SkillScope::Admin => 1,
|
||||
SkillScope::Repo => 2,
|
||||
SkillScope::User => 3,
|
||||
}
|
||||
}
|
||||
|
||||
fn render_skill_line(skill: &SkillMetadata) -> String {
|
||||
let path_str = skill.path_to_skills_md.to_string_lossy().replace('\\', "/");
|
||||
let name = skill.name.as_str();
|
||||
let description = skill.description.as_str();
|
||||
format!("- {name}: {description} (file: {path_str})")
|
||||
}
|
||||
|
||||
fn scope_label(scope: SkillScope) -> &'static str {
|
||||
match scope {
|
||||
SkillScope::Admin => "admin",
|
||||
SkillScope::Repo => "repo",
|
||||
SkillScope::User => "user",
|
||||
SkillScope::System => "system",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use codex_utils_absolute_path::test_support::PathBufExt;
|
||||
use codex_utils_absolute_path::test_support::test_path_buf;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
fn make_skill(name: &str, scope: SkillScope) -> SkillMetadata {
|
||||
SkillMetadata {
|
||||
name: name.to_string(),
|
||||
description: "desc".to_string(),
|
||||
short_description: None,
|
||||
interface: None,
|
||||
dependencies: None,
|
||||
policy: None,
|
||||
path_to_skills_md: test_path_buf(&format!("/tmp/{name}/SKILL.md")).abs(),
|
||||
scope,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_budget_uses_two_percent_of_full_context_window() {
|
||||
assert_eq!(
|
||||
default_skill_metadata_budget(Some(200_000)),
|
||||
SkillMetadataBudget::Tokens(4_000)
|
||||
);
|
||||
assert_eq!(
|
||||
default_skill_metadata_budget(Some(99)),
|
||||
SkillMetadataBudget::Tokens(1)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_budget_falls_back_to_characters_without_context_window() {
|
||||
assert_eq!(
|
||||
default_skill_metadata_budget(None),
|
||||
SkillMetadataBudget::Characters(DEFAULT_SKILL_METADATA_CHAR_BUDGET)
|
||||
);
|
||||
assert_eq!(
|
||||
default_skill_metadata_budget(Some(-1)),
|
||||
SkillMetadataBudget::Characters(DEFAULT_SKILL_METADATA_CHAR_BUDGET)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn budgeted_rendering_preserves_prompt_priority() {
|
||||
let system = make_skill("system-skill", SkillScope::System);
|
||||
let user = make_skill("user-skill", SkillScope::User);
|
||||
let repo = make_skill("repo-skill", SkillScope::Repo);
|
||||
let admin = make_skill("admin-skill", SkillScope::Admin);
|
||||
let system_cost = SkillMetadataBudget::Characters(usize::MAX)
|
||||
.cost(&format!("{}\n", render_skill_line(&system)));
|
||||
let admin_cost = SkillMetadataBudget::Characters(usize::MAX)
|
||||
.cost(&format!("{}\n", render_skill_line(&admin)));
|
||||
let budget = SkillMetadataBudget::Characters(system_cost + admin_cost);
|
||||
|
||||
let rendered = render_skills_section_with_budget(&[system, user, repo, admin], budget)
|
||||
.expect("skills should render");
|
||||
|
||||
assert_eq!(rendered.report.included_count, 2);
|
||||
assert_eq!(rendered.report.omitted_count, 2);
|
||||
assert!(rendered.text.contains("- system-skill:"));
|
||||
assert!(rendered.text.contains("- admin-skill:"));
|
||||
assert!(!rendered.text.contains("- repo-skill:"));
|
||||
assert!(!rendered.text.contains("- user-skill:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn budgeted_rendering_keeps_scanning_after_oversized_entry() {
|
||||
let mut oversized = make_skill("oversized-system-skill", SkillScope::System);
|
||||
oversized.description = "desc ".repeat(100);
|
||||
let repo = make_skill("repo-skill", SkillScope::Repo);
|
||||
let repo_cost = SkillMetadataBudget::Characters(usize::MAX)
|
||||
.cost(&format!("{}\n", render_skill_line(&repo)));
|
||||
let budget = SkillMetadataBudget::Characters(repo_cost);
|
||||
|
||||
let rendered =
|
||||
render_skills_section_with_budget(&[oversized, repo], budget).expect("skills render");
|
||||
|
||||
assert_eq!(rendered.report.included_count, 1);
|
||||
assert_eq!(rendered.report.omitted_count, 1);
|
||||
assert!(!rendered.text.contains("- oversized-system-skill:"));
|
||||
assert!(rendered.text.contains("- repo-skill:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unbudgeted_rendering_preserves_input_order() {
|
||||
let user = make_skill("user-skill", SkillScope::User);
|
||||
let admin = make_skill("admin-skill", SkillScope::Admin);
|
||||
|
||||
let rendered = render_skills_section(&[user, admin]).expect("skills should render");
|
||||
|
||||
let user_index = rendered.find("- user-skill:").expect("user skill");
|
||||
let admin_index = rendered.find("- admin-skill:").expect("admin skill");
|
||||
assert!(user_index < admin_index);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,12 +20,13 @@ use crate::commit_attribution::commit_message_trailer_instruction;
|
||||
use crate::compact;
|
||||
use crate::config::ManagedFeatures;
|
||||
use crate::connectors;
|
||||
use crate::default_skill_metadata_budget;
|
||||
use crate::exec_policy::ExecPolicyManager;
|
||||
use crate::installation_id::resolve_installation_id;
|
||||
use crate::parse_turn_item;
|
||||
use crate::path_utils::normalize_for_native_workdir;
|
||||
use crate::realtime_conversation::RealtimeConversationManager;
|
||||
use crate::render_skills_section;
|
||||
use crate::render_skills_section_with_budget;
|
||||
use crate::rollout::find_thread_name_by_id;
|
||||
use crate::session_prefix::format_subagent_notification_message;
|
||||
use crate::skills_load_input_from_config;
|
||||
@@ -361,12 +362,12 @@ pub struct Codex {
|
||||
|
||||
pub(crate) type SessionLoopTermination = Shared<BoxFuture<'static, ()>>;
|
||||
|
||||
/// Wrapper returned by [`Codex::spawn`] containing the spawned [`Codex`],
|
||||
/// the submission id for the initial `ConfigureSession` request and the
|
||||
/// unique session id.
|
||||
/// Wrapper returned by [`Codex::spawn`] containing the spawned [`Codex`], the
|
||||
/// unique session id, and any warnings known at thread start.
|
||||
pub struct CodexSpawnOk {
|
||||
pub codex: Codex,
|
||||
pub thread_id: ThreadId,
|
||||
pub thread_start_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
pub(crate) struct CodexSpawnArgs {
|
||||
@@ -544,6 +545,14 @@ impl Codex {
|
||||
let model_info = models_manager
|
||||
.get_model_info(model.as_str(), &config.to_models_manager_config())
|
||||
.await;
|
||||
let implicit_skills = loaded_skills.allowed_skills_for_implicit_invocation();
|
||||
let thread_start_warnings = render_skills_section_with_budget(
|
||||
&implicit_skills,
|
||||
default_skill_metadata_budget(model_info.context_window),
|
||||
)
|
||||
.and_then(|rendered| rendered.report.warning_message())
|
||||
.into_iter()
|
||||
.collect();
|
||||
let base_instructions = config
|
||||
.base_instructions
|
||||
.clone()
|
||||
@@ -661,7 +670,11 @@ impl Codex {
|
||||
session_loop_termination: session_loop_termination_from_handle(session_loop_handle),
|
||||
};
|
||||
|
||||
Ok(CodexSpawnOk { codex, thread_id })
|
||||
Ok(CodexSpawnOk {
|
||||
codex,
|
||||
thread_id,
|
||||
thread_start_warnings,
|
||||
})
|
||||
}
|
||||
|
||||
/// Submit the `op` wrapped in a `Submission` with a unique ID.
|
||||
@@ -3970,8 +3983,11 @@ impl Session {
|
||||
.turn_skills
|
||||
.outcome
|
||||
.allowed_skills_for_implicit_invocation();
|
||||
if let Some(skills_section) = render_skills_section(&implicit_skills) {
|
||||
developer_sections.push(skills_section);
|
||||
if let Some(rendered_skills) = render_skills_section_with_budget(
|
||||
&implicit_skills,
|
||||
default_skill_metadata_budget(turn_context.model_info.context_window),
|
||||
) {
|
||||
developer_sections.push(rendered_skills.text);
|
||||
}
|
||||
let loaded_plugins = self
|
||||
.services
|
||||
|
||||
@@ -85,6 +85,7 @@ use codex_protocol::protocol::RealtimeVoice;
|
||||
use codex_protocol::protocol::RealtimeVoicesList;
|
||||
use codex_protocol::protocol::ResumedHistory;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
use codex_protocol::protocol::SkillScope;
|
||||
use codex_protocol::protocol::Submission;
|
||||
use codex_protocol::protocol::ThreadRolledBackEvent;
|
||||
use codex_protocol::protocol::TokenCountEvent;
|
||||
@@ -4579,6 +4580,52 @@ async fn build_initial_context_omits_default_image_save_location_without_image_h
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn build_initial_context_trims_skill_metadata_from_context_window_budget() {
|
||||
let (session, mut turn_context) = make_session_and_context().await;
|
||||
let mut outcome = SkillLoadOutcome::default();
|
||||
outcome.skills = vec![
|
||||
SkillMetadata {
|
||||
name: "admin-skill".to_string(),
|
||||
description: "desc".to_string(),
|
||||
short_description: None,
|
||||
interface: None,
|
||||
dependencies: None,
|
||||
policy: None,
|
||||
path_to_skills_md: PathBuf::from("/tmp/admin-skill/SKILL.md").abs(),
|
||||
scope: SkillScope::Admin,
|
||||
},
|
||||
SkillMetadata {
|
||||
name: "repo-skill".to_string(),
|
||||
description: "desc".to_string(),
|
||||
short_description: None,
|
||||
interface: None,
|
||||
dependencies: None,
|
||||
policy: None,
|
||||
path_to_skills_md: PathBuf::from("/tmp/repo-skill/SKILL.md").abs(),
|
||||
scope: SkillScope::Repo,
|
||||
},
|
||||
];
|
||||
turn_context.model_info.context_window = Some(100);
|
||||
turn_context.turn_skills = TurnSkillsContext::new(Arc::new(outcome));
|
||||
|
||||
let initial_context = session.build_initial_context(&turn_context).await;
|
||||
let developer_texts = developer_input_texts(&initial_context);
|
||||
|
||||
assert!(
|
||||
developer_texts
|
||||
.iter()
|
||||
.any(|text| text.contains("Skills list trimmed to fit the metadata budget")),
|
||||
"expected skill budget warning in initial context, got {developer_texts:?}"
|
||||
);
|
||||
assert!(
|
||||
developer_texts
|
||||
.iter()
|
||||
.all(|text| !text.contains("- admin-skill:") && !text.contains("- repo-skill:")),
|
||||
"expected no skill metadata entries to fit the tiny budget, got {developer_texts:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handle_output_item_done_records_image_save_history_message() {
|
||||
let (session, turn_context) = make_session_and_context().await;
|
||||
|
||||
@@ -99,10 +99,11 @@ pub(crate) use skills::build_skill_injections;
|
||||
pub(crate) use skills::build_skill_name_counts;
|
||||
pub(crate) use skills::collect_env_var_dependencies;
|
||||
pub(crate) use skills::collect_explicit_skill_mentions;
|
||||
pub(crate) use skills::default_skill_metadata_budget;
|
||||
pub(crate) use skills::injection;
|
||||
pub(crate) use skills::manager;
|
||||
pub(crate) use skills::maybe_emit_implicit_skill_invocation;
|
||||
pub(crate) use skills::render_skills_section;
|
||||
pub(crate) use skills::render_skills_section_with_budget;
|
||||
pub(crate) use skills::resolve_skill_dependencies_for_turn;
|
||||
pub(crate) use skills::skills_load_input_from_config;
|
||||
mod skills_watcher;
|
||||
|
||||
@@ -21,11 +21,13 @@ pub use codex_core_skills::SkillError;
|
||||
pub use codex_core_skills::SkillLoadOutcome;
|
||||
pub use codex_core_skills::SkillMetadata;
|
||||
pub use codex_core_skills::SkillPolicy;
|
||||
pub use codex_core_skills::SkillRenderReport;
|
||||
pub use codex_core_skills::SkillsLoadInput;
|
||||
pub use codex_core_skills::SkillsManager;
|
||||
pub use codex_core_skills::build_skill_name_counts;
|
||||
pub use codex_core_skills::collect_env_var_dependencies;
|
||||
pub use codex_core_skills::config_rules;
|
||||
pub use codex_core_skills::default_skill_metadata_budget;
|
||||
pub use codex_core_skills::detect_implicit_skill_invocation_for_command;
|
||||
pub use codex_core_skills::filter_skill_load_outcome_for_product;
|
||||
pub use codex_core_skills::injection;
|
||||
@@ -38,6 +40,7 @@ pub use codex_core_skills::model;
|
||||
pub use codex_core_skills::remote;
|
||||
pub use codex_core_skills::render;
|
||||
pub use codex_core_skills::render_skills_section;
|
||||
pub use codex_core_skills::render_skills_section_with_budget;
|
||||
pub use codex_core_skills::system;
|
||||
|
||||
pub(crate) fn skills_load_input_from_config(
|
||||
|
||||
@@ -137,6 +137,7 @@ pub struct NewThread {
|
||||
pub thread_id: ThreadId,
|
||||
pub thread: Arc<CodexThread>,
|
||||
pub session_configured: SessionConfiguredEvent,
|
||||
pub thread_start_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
// TODO(ccunningham): Add an explicit non-interrupting live-turn snapshot once
|
||||
@@ -926,7 +927,9 @@ impl ThreadManagerState {
|
||||
Some(_) | None => crate::file_watcher::WatchRegistration::default(),
|
||||
};
|
||||
let CodexSpawnOk {
|
||||
codex, thread_id, ..
|
||||
codex,
|
||||
thread_id,
|
||||
thread_start_warnings,
|
||||
} = Codex::spawn(CodexSpawnArgs {
|
||||
config,
|
||||
auth_manager,
|
||||
@@ -949,7 +952,7 @@ impl ThreadManagerState {
|
||||
analytics_events_client: self.analytics_events_client.clone(),
|
||||
})
|
||||
.await?;
|
||||
self.finalize_thread_spawn(codex, thread_id, watch_registration)
|
||||
self.finalize_thread_spawn(codex, thread_id, watch_registration, thread_start_warnings)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -958,6 +961,7 @@ impl ThreadManagerState {
|
||||
codex: Codex,
|
||||
thread_id: ThreadId,
|
||||
watch_registration: crate::file_watcher::WatchRegistration,
|
||||
thread_start_warnings: Vec<String>,
|
||||
) -> CodexResult<NewThread> {
|
||||
let event = codex.next_event().await?;
|
||||
let session_configured = match event {
|
||||
@@ -982,6 +986,7 @@ impl ThreadManagerState {
|
||||
thread_id,
|
||||
thread,
|
||||
session_configured,
|
||||
thread_start_warnings,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use super::*;
|
||||
use crate::codex::make_session_and_context;
|
||||
use crate::config::ConfigBuilder;
|
||||
use crate::config::ConfigOverrides;
|
||||
use crate::config::test_config;
|
||||
use crate::rollout::RolloutRecorder;
|
||||
use crate::tasks::interrupted_turn_history_marker;
|
||||
@@ -43,6 +45,14 @@ fn assistant_msg(text: &str) -> ResponseItem {
|
||||
}
|
||||
}
|
||||
|
||||
fn write_skill(codex_home: &std::path::Path, name: &str) {
|
||||
let skill_dir = codex_home.join("skills").join(name);
|
||||
std::fs::create_dir_all(&skill_dir).expect("create skill dir");
|
||||
let description = "desc ".repeat(200);
|
||||
let content = format!("---\nname: {name}\ndescription: {description}\n---\n\n# Body\n");
|
||||
std::fs::write(skill_dir.join("SKILL.md"), content).expect("write skill");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncates_before_requested_user_message() {
|
||||
let items = [
|
||||
@@ -234,6 +244,46 @@ async fn ignores_session_prefix_messages_when_truncating() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn start_thread_reports_skill_metadata_budget_warning() {
|
||||
let temp_dir = tempdir().expect("tempdir");
|
||||
let codex_home = temp_dir.path().join("codex-home");
|
||||
let cwd = codex_home.clone();
|
||||
std::fs::create_dir_all(&codex_home).expect("create codex home");
|
||||
for i in 0..120 {
|
||||
write_skill(&codex_home, &format!("demo-{i:03}"));
|
||||
}
|
||||
let mut config = ConfigBuilder::without_managed_config_for_tests()
|
||||
.codex_home(codex_home.clone())
|
||||
.harness_overrides(ConfigOverrides {
|
||||
cwd: Some(cwd),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.await
|
||||
.expect("load test config");
|
||||
config.model_context_window = Some(100);
|
||||
|
||||
let manager = ThreadManager::with_models_provider_and_home_for_tests(
|
||||
CodexAuth::from_api_key("dummy"),
|
||||
config.model_provider.clone(),
|
||||
config.codex_home.to_path_buf(),
|
||||
Arc::new(codex_exec_server::EnvironmentManager::new(
|
||||
/*exec_server_url*/ None,
|
||||
)),
|
||||
);
|
||||
|
||||
let new_thread = manager.start_thread(config).await.expect("start thread");
|
||||
|
||||
assert_eq!(new_thread.thread_start_warnings.len(), 1);
|
||||
let warning = &new_thread.thread_start_warnings[0];
|
||||
assert!(warning.contains("Skills list trimmed to fit the metadata budget"));
|
||||
assert!(warning.contains("enabled skills"));
|
||||
let _ = manager
|
||||
.shutdown_all_threads_bounded(Duration::from_secs(10))
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shutdown_all_threads_bounded_submits_shutdown_to_every_thread() {
|
||||
let temp_dir = tempdir().expect("tempdir");
|
||||
|
||||
@@ -68,6 +68,7 @@ pub async fn run_codex_tool_session(
|
||||
thread_id,
|
||||
thread,
|
||||
session_configured,
|
||||
..
|
||||
} = match thread_manager.start_thread(config).await {
|
||||
Ok(res) => res,
|
||||
Err(e) => {
|
||||
|
||||
Reference in New Issue
Block a user