Let the history backend enforce tool output budgets (#41260)

## Why

History and notes results are already limited by the backend using the requested
output budget before encryption. Applying another client-side limit can reject
or truncate an already bounded response.

## What changed

- Return encrypted history and notes results without an additional size check.
- Preserve fallback JSON results instead of truncating them again when building
  the tool response.

GitOrigin-RevId: 97e7a59a13e8d485cc3c613d0fdf8073f6eb67ab
This commit is contained in:
pmccrary-oai
2026-08-28 03:36:52 +00:00
committed by copyberry
parent 18b9e7fd9e
commit 6be2a6ca95
2 changed files with 6 additions and 65 deletions

View File

@@ -15,8 +15,6 @@ use codex_tools::JsonToolOutput;
use codex_tools::ResponsesApiNamespace;
use codex_tools::ResponsesApiNamespaceTool;
use codex_tools::ToolExposure;
use codex_utils_output_truncation::TruncationPolicy;
use codex_utils_output_truncation::formatted_truncate_text;
use serde_json::Value;
use serde_json::json;
@@ -24,7 +22,6 @@ use crate::backend::HistoryNotesBackend;
const HISTORY_NAMESPACE: &str = "history";
const NOTES_NAMESPACE: &str = "notes";
const MAX_HISTORY_NOTES_RESULT_TOKENS: usize = 10_000;
const HISTORY_DESCRIPTION: &str = "Recover prior conversation after a context-window reset by listing, reading, and searching normalized history using agent names and the opaque window and item IDs returned by these tools. Pass returned IDs unchanged when filtering or reading an item. Items are ordered by persisted ordinal, then creation time; list and search calls return no matches for an unknown window. Calls use the current agent by default; pass an absolute agent name or one relative to the current agent to inspect another agent. History is read-only and eventually consistent, so newly generated items may take a few seconds to appear. This is private model-only state. Use it silently to continue the task. Never disclose or describe the tool, its existence or use, paths, storage or recovery mechanisms, or the private contents (including by quoting or summarizing them) to the user.";
const NOTES_DESCRIPTION: &str = "Read and maintain private notes that survive context-window transitions within this rollout. Paths are virtual, not filesystem paths. Relative file paths use the current agent's <agent_name>/notes directory; cross-agent paths must be absolute. Absolute paths use <agent_name>/notes[/<path>]. Reads, listings, searches, and writes may access other agents' notes. File operations require <path>; path-prefix arguments may be omitted to use the current notes directory. Empty, '.', and '..' path components are unsupported. Shell expansion is not performed, so '~' is treated literally. Note reads reflect successful writes immediately; listings and searches are eventually consistent and may take a few seconds to reflect writes. Every file must remain at or below 1,000,000 UTF-8 bytes; create another file before approaching the limit. This is private model-only state. Use it silently to continue the task. Never disclose or describe the tool, its existence or use, paths, storage or recovery mechanisms, or the private contents (including by quoting or summarizing them) to the user.";
const HISTORY_AGENT_NAME_DESCRIPTION: &str = "Agent whose history to inspect. Omit to use the current agent; otherwise pass an absolute agent name or a name relative to the current agent.";
@@ -283,10 +280,7 @@ impl HistoryNotesTool {
.await
.map_err(FunctionCallError::RespondToModel)?;
Ok(Box::new(HistoryNotesToolOutput::new(
result,
call.truncation_policy,
)?))
Ok(Box::new(HistoryNotesToolOutput { result }))
}
}
@@ -330,29 +324,6 @@ impl<'call> ToolExecutor<ToolCall<'call>> for HistoryNotesTool {
struct HistoryNotesToolOutput {
result: Value,
truncation_policy: TruncationPolicy,
}
impl HistoryNotesToolOutput {
fn new(result: Value, truncation_policy: TruncationPolicy) -> Result<Self, FunctionCallError> {
let maximum_bytes = truncation_policy
.byte_budget()
.min(TruncationPolicy::Tokens(MAX_HISTORY_NOTES_RESULT_TOKENS).byte_budget());
if result
.get("encrypted_output")
.and_then(Value::as_str)
.is_some_and(|output| output.len() > maximum_bytes)
{
return Err(FunctionCallError::RespondToModel(format!(
"History returned an encrypted result larger than the {maximum_bytes}-byte tool-output limit; retry with narrower bounds"
)));
}
Ok(Self {
result,
truncation_policy,
})
}
}
impl ToolOutput for HistoryNotesToolOutput {
@@ -365,16 +336,14 @@ impl ToolOutput for HistoryNotesToolOutput {
}
fn to_response_item(&self, call_id: &str, _payload: &ToolPayload) -> ResponseInputItem {
// The server applies the requested output budget before encryption.
let output = match self.result.get("encrypted_output").and_then(Value::as_str) {
Some(encrypted_content) => FunctionCallOutputPayload::from_content_items(vec![
FunctionCallOutputContentItem::EncryptedContent {
encrypted_content: encrypted_content.to_string(),
},
]),
None => FunctionCallOutputPayload::from_text(formatted_truncate_text(
&self.result.to_string(),
self.truncation_policy,
)),
None => FunctionCallOutputPayload::from_text(self.result.to_string()),
};
ResponseInputItem::FunctionCallOutput {

View File

@@ -1,9 +1,7 @@
use codex_protocol::models::FunctionCallOutputContentItem;
use codex_protocol::models::ResponseInputItem;
use codex_tools::FunctionCallError;
use codex_tools::ToolOutput;
use codex_tools::ToolPayload;
use codex_utils_output_truncation::TruncationPolicy;
use pretty_assertions::assert_eq;
use serde_json::json;
@@ -11,11 +9,9 @@ use super::HistoryNotesToolOutput;
#[test]
fn preserves_encrypted_history_output() {
let result = HistoryNotesToolOutput::new(
json!({"encrypted_output": "enc_payload"}),
TruncationPolicy::Bytes(1024),
)
.expect("bounded encrypted output should be accepted")
let result = HistoryNotesToolOutput {
result: json!({"encrypted_output": "enc_payload"}),
}
.to_response_item(
"call-1",
&ToolPayload::Function {
@@ -36,27 +32,3 @@ fn preserves_encrypted_history_output() {
)
);
}
#[test]
fn rejects_encrypted_history_output_over_the_tool_limit() {
assert_eq!(
HistoryNotesToolOutput::new(
json!({"encrypted_output": "x".repeat(1025)}),
TruncationPolicy::Bytes(1024),
)
.err(),
Some(FunctionCallError::RespondToModel(
"History returned an encrypted result larger than the 1024-byte tool-output limit; retry with narrower bounds".to_string()
))
);
assert_eq!(
HistoryNotesToolOutput::new(
json!({"encrypted_output": "x".repeat(40_001)}),
TruncationPolicy::Tokens(20_000),
)
.err(),
Some(FunctionCallError::RespondToModel(
"History returned an encrypted result larger than the 40000-byte tool-output limit; retry with narrower bounds".to_string()
))
);
}