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:
Tamir Duberstein authored and copyberry committed 2026-08-24 14:54:31 +00:00
1 parent cd25b81c65
commit a25e986323
3 files changed
+389 -54

No files matched your search

+92 -23
View File
@@ -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> {