mirror of
https://github.com/openai/codex.git
synced 2026-11-13 06:45:07 +00:00
Honor response budgets when listing skills (#40413)
## What changed - Build `skills.list` pages against the current tool-call response-byte budget. - Skip entries that cannot fit, preserve pagination across retained entries, and report oversized metadata without repeating the warning for the same budget. - Keep existing cursors compatible while encoding the response budget in new cursors. ## Testing - Cover direct and Code Mode listing, budget changes between pages, provider and omission warnings, oversized entries, legacy cursors, and budgets too small to return discovery warnings. GitOrigin-RevId: 4b34cd2714e4849fe9155ce3f0021b218763c395
This commit is contained in:
1 parent
cd25b81c65
commit
a25e986323
3 files changed
+389
-54
No files matched your search
@@ -20,13 +20,13 @@ use super::SkillToolAuthority;
|
||||
use super::SkillToolAuthoritySelector;
|
||||
use super::SkillToolContext;
|
||||
use super::is_bounded_handle;
|
||||
use super::pagination_cursor;
|
||||
use super::parse_args;
|
||||
use super::parse_pagination_cursor;
|
||||
use super::serialized_len;
|
||||
use super::skill_function_tool;
|
||||
use super::skill_json_output;
|
||||
use super::skill_tool_name;
|
||||
use super::value_fingerprint;
|
||||
|
||||
const TOOL_NAME: &str = "list";
|
||||
const MAX_SKILLS_PER_PAGE: usize = 20;
|
||||
@@ -78,49 +78,114 @@ impl ToolExecutor<ToolCall> for ListTool {
|
||||
fn handle(&self, call: ToolCall) -> ToolExecutorFuture<'_> {
|
||||
Box::pin(async move {
|
||||
let args: ListArgs = parse_args(&call)?;
|
||||
let response_byte_budget = call.response_byte_budget(MAX_SKILL_RESPONSE_BYTES);
|
||||
let catalog = self.context.catalog(&call.turn_id, args.authority).await;
|
||||
let mut omitted_oversized_entry = false;
|
||||
let skills = catalog
|
||||
let canonical_skills = catalog
|
||||
.entries
|
||||
.into_iter()
|
||||
.filter(|entry| {
|
||||
entry.is_model_visible() && args.authority.matches(&entry.authority)
|
||||
})
|
||||
.filter_map(|entry| {
|
||||
let listed = listed_skill(entry).filter(single_entry_response_is_bounded);
|
||||
let listed = listed_skill(entry);
|
||||
omitted_oversized_entry |= listed.is_none();
|
||||
listed
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let start = parse_pagination_cursor(args.cursor.as_deref(), &skills, "skills.list")?;
|
||||
if start > skills.len() {
|
||||
// Older cursors contain only the catalog fingerprint and offset. New cursors
|
||||
// also record the budget whose omissions have already been reported.
|
||||
let (cursor, previous_byte_budget) = match args.cursor.as_deref() {
|
||||
Some(cursor) => match cursor.rsplit_once(':') {
|
||||
Some((canonical_cursor, byte_budget)) => {
|
||||
if canonical_cursor.contains(':') {
|
||||
let byte_budget = byte_budget.parse::<usize>().map_err(|_| {
|
||||
FunctionCallError::RespondToModel(
|
||||
"skills.list cursor is invalid".to_string(),
|
||||
)
|
||||
})?;
|
||||
(Some(canonical_cursor), Some(byte_budget))
|
||||
} else {
|
||||
(Some(cursor), None)
|
||||
}
|
||||
}
|
||||
None => (Some(cursor), None),
|
||||
},
|
||||
None => (None, None),
|
||||
};
|
||||
let start = parse_pagination_cursor(cursor, &canonical_skills, "skills.list")?;
|
||||
if start > canonical_skills.len() {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"skills.list cursor is invalid".to_string(),
|
||||
));
|
||||
}
|
||||
let mut warnings = if start == 0 {
|
||||
let mut warnings = catalog.warnings;
|
||||
if omitted_oversized_entry {
|
||||
warnings.push(OVERSIZED_ENTRY_WARNING.to_string());
|
||||
let cursor_fingerprint = value_fingerprint(&canonical_skills);
|
||||
let cursor_at =
|
||||
|offset| format!("{cursor_fingerprint:016x}:{offset}:{response_byte_budget}");
|
||||
let mut skills = Vec::with_capacity(canonical_skills.len().saturating_sub(start));
|
||||
// The final retained skill needs no cursor, even if later entries are oversized.
|
||||
for (index, skill) in canonical_skills.iter().enumerate().skip(start).rev() {
|
||||
let next_cursor = (!skills.is_empty()).then(|| cursor_at(index.saturating_add(1)));
|
||||
if single_entry_response_is_bounded(skill, response_byte_budget, next_cursor) {
|
||||
skills.push((index, skill));
|
||||
} else {
|
||||
omitted_oversized_entry = true;
|
||||
}
|
||||
bounded_warnings(&warnings)
|
||||
}
|
||||
skills.reverse();
|
||||
let mut provider_warnings = if args.cursor.is_none() {
|
||||
bounded_warnings(&catalog.warnings)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let mut end = (start + MAX_SKILLS_PER_PAGE).min(skills.len());
|
||||
let omission_warning = (omitted_oversized_entry
|
||||
&& previous_byte_budget != Some(response_byte_budget))
|
||||
.then_some(OVERSIZED_ENTRY_WARNING);
|
||||
let mut end = MAX_SKILLS_PER_PAGE.min(skills.len());
|
||||
loop {
|
||||
let response = ListResponse {
|
||||
skills: skills[start..end].to_vec(),
|
||||
warnings: warnings.clone(),
|
||||
next_cursor: (end < skills.len()).then(|| pagination_cursor(&skills, end)),
|
||||
let mut response = ListResponse {
|
||||
skills: skills[..end]
|
||||
.iter()
|
||||
.map(|(_, skill)| (*skill).clone())
|
||||
.collect(),
|
||||
warnings: provider_warnings
|
||||
.iter()
|
||||
.cloned()
|
||||
.chain(omission_warning.map(str::to_string))
|
||||
.collect(),
|
||||
next_cursor: (end < skills.len())
|
||||
.then(|| cursor_at(skills[end.saturating_sub(1)].0.saturating_add(1))),
|
||||
};
|
||||
if serialized_len(&response)? <= MAX_SKILL_RESPONSE_BYTES {
|
||||
if serialized_len(&response)? <= response_byte_budget {
|
||||
return skill_json_output(&response, args.authority);
|
||||
}
|
||||
if end.saturating_sub(start) > 1 {
|
||||
end -= 1;
|
||||
} else if !warnings.is_empty() {
|
||||
warnings.clear();
|
||||
if end > 1 {
|
||||
end = end.saturating_sub(1);
|
||||
} else if provider_warnings.len() > 1 {
|
||||
provider_warnings.pop();
|
||||
} else if !response.warnings.is_empty() {
|
||||
response.skills.clear();
|
||||
// Recording this budget acknowledges the omission warning, even when
|
||||
// the next call resumes at the same canonical skill offset.
|
||||
response.next_cursor = Some(cursor_at(start));
|
||||
if serialized_len(&response)? <= response_byte_budget {
|
||||
return skill_json_output(&response, args.authority);
|
||||
}
|
||||
if omission_warning.is_some() && !provider_warnings.is_empty() {
|
||||
response.warnings = provider_warnings;
|
||||
// Report discovery first without acknowledging an unshown omission.
|
||||
// Reusing this offset lets the next call report it before advancing.
|
||||
response.next_cursor = Some(format!("{cursor_fingerprint:016x}:{start}"));
|
||||
if serialized_len(&response)? <= response_byte_budget {
|
||||
return skill_json_output(&response, args.authority);
|
||||
}
|
||||
}
|
||||
// FunctionCallError cannot carry external-context metadata, so do not
|
||||
// expose provider warnings through this path.
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"skills.list response budget leaves no room for discovery warnings"
|
||||
.to_string(),
|
||||
));
|
||||
} else {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"skill metadata is too large to list".to_string(),
|
||||
@@ -131,13 +196,17 @@ impl ToolExecutor<ToolCall> for ListTool {
|
||||
}
|
||||
}
|
||||
|
||||
fn single_entry_response_is_bounded(skill: &ListedSkill) -> bool {
|
||||
fn single_entry_response_is_bounded(
|
||||
skill: &ListedSkill,
|
||||
response_byte_budget: usize,
|
||||
next_cursor: Option<String>,
|
||||
) -> bool {
|
||||
serialized_len(&ListResponse {
|
||||
skills: vec![skill.clone()],
|
||||
warnings: Vec::new(),
|
||||
next_cursor: Some(pagination_cursor(skill, usize::MAX)),
|
||||
next_cursor,
|
||||
})
|
||||
.is_ok_and(|size| size <= MAX_SKILL_RESPONSE_BYTES)
|
||||
.is_ok_and(|size| size <= response_byte_budget)
|
||||
}
|
||||
|
||||
fn listed_skill(entry: SkillCatalogEntry) -> Option<ListedSkill> {
|
||||
|
||||
Reference in new issue
Block a user