fix(core/config): allow tool output size override (fixes #498)

This commit is contained in:
James Peter
2026-01-06 09:29:48 +10:00
parent deef15ac72
commit c3782bafe6
5 changed files with 149 additions and 16 deletions
+4
View File
@@ -715,6 +715,10 @@ In general, Codex knows the context window for the most common OpenAI models, bu
This is analogous to `model_context_window`, but for the maximum number of output tokens for the model.
## tool_output_max_bytes
Maximum number of bytes of tool output (including shell command output and file reads) to include in a model request. Defaults to 32 KiB. Increase this if you need to send larger outputs to the model (note the exec capture cap remains 32 MiB per stream).
## project_doc_max_bytes
Maximum number of bytes to read from an `AGENTS.md` file to include in the instructions sent with the first turn of a session. Defaults to 32 KiB.
+2 -2
View File
@@ -1,7 +1,6 @@
use super::*;
use super::streaming::{
AgentTask,
MAX_TOOL_OUTPUT_BYTES_FOR_MODEL,
TRUNCATION_MARKER,
TimelineReplayContext,
debug_history,
@@ -361,6 +360,7 @@ pub(crate) struct Session {
pub(super) confirm_guard: ConfirmGuardRuntime,
pub(super) project_hooks: ProjectHooks,
pub(super) project_commands: Vec<ProjectCommand>,
pub(super) tool_output_max_bytes: usize,
pub(super) hook_guard: AtomicBool,
pub(super) github: Arc<RwLock<crate::config_types::GithubConfig>>,
pub(super) validation: Arc<RwLock<crate::config_types::ValidationConfig>>,
@@ -915,7 +915,7 @@ impl Session {
}
let (_, was_truncated, prefix_end, suffix_start) =
truncate_middle_bytes(&aggregated, MAX_TOOL_OUTPUT_BYTES_FOR_MODEL);
truncate_middle_bytes(&aggregated, self.tool_output_max_bytes);
if !was_truncated {
return;
}
+106 -14
View File
@@ -645,6 +645,7 @@ pub(super) async fn submission_loop(
confirm_guard: ConfirmGuardRuntime::from_config(&config.confirm_guard),
project_hooks: config.project_hooks.clone(),
project_commands: config.project_commands.clone(),
tool_output_max_bytes: config.tool_output_max_bytes,
hook_guard: AtomicBool::new(false),
github: Arc::new(RwLock::new(config.github.clone())),
validation: Arc::new(RwLock::new(config.validation.clone())),
@@ -4439,10 +4440,11 @@ async fn handle_wait(
st.background_execs.remove(&call_id);
}
let content = format_exec_output_with_limit(
sess,
sess.get_cwd(),
&ctx_inner.sub_id,
&ctx_inner.call_id,
&done,
sess.tool_output_max_bytes,
);
suppress_guard.disarm();
return ResponseInputItem::FunctionCallOutput {
@@ -4599,7 +4601,13 @@ async fn handle_wait(
}
};
if let Some(done) = done {
let content = format_exec_output_with_limit(sess, &ctx_inner.sub_id, &ctx_inner.call_id, &done);
let content = format_exec_output_with_limit(
sess.get_cwd(),
&ctx_inner.sub_id,
&ctx_inner.call_id,
&done,
sess.tool_output_max_bytes,
);
suppress_guard.disarm();
ResponseInputItem::FunctionCallOutput {
call_id: ctx_inner.call_id.clone(),
@@ -7915,6 +7923,7 @@ async fn handle_container_exec_with_params(
let backgrounded_task = backgrounded.clone();
let suppress_event_flag_task = suppress_event_flag.clone();
let display_label_task = display_label.clone();
let tool_output_max_bytes = sess.tool_output_max_bytes;
let task_handle = tokio::spawn(async move {
// Build stdout stream with tail capture. We cannot stamp via `Session` here,
// but deltas will be delivered with neutral ordering which the UI tolerates.
@@ -8016,7 +8025,13 @@ async fn handle_container_exec_with_params(
};
let header = format!("Background shell completed ({header_label}), exit_code={}, duration={:?}.", out.exit_code, out.duration);
let full_body = format_exec_output_str(&out);
let body = truncate_exec_output_for_storage(&sandbox_cwd, &sub_id_for_events, &call_id_for_events, &full_body);
let body = truncate_exec_output_for_storage(
&sandbox_cwd,
&sub_id_for_events,
&call_id_for_events,
&full_body,
tool_output_max_bytes,
);
let dev_text = format!("{}\n\n{}", header, body);
let _ = tx
.send(Submission { id: uuid::Uuid::new_v4().to_string(), op: Op::AddPendingInputDeveloper { text: dev_text } })
@@ -8060,7 +8075,13 @@ async fn handle_container_exec_with_params(
};
if let Some(done) = done_opt {
let is_success = done.exit_code == 0;
let mut content = format_exec_output_with_limit(sess, &sub_id, &call_id, &done);
let mut content = format_exec_output_with_limit(
sess.get_cwd(),
&sub_id,
&call_id,
&done,
sess.tool_output_max_bytes,
);
if let Some(harness) = harness_summary_json.as_ref() {
if !harness.is_empty() {
content.push('\n');
@@ -8128,7 +8149,13 @@ async fn handle_sandbox_error(
.as_ref()
.map(|bytes| format!(" (memory.max={bytes} bytes)"))
.unwrap_or_default();
let tail = format_exec_output_with_limit(sess, &sub_id, &call_id, output.as_ref());
let tail = format_exec_output_with_limit(
sess.get_cwd(),
&sub_id,
&call_id,
output.as_ref(),
sess.tool_output_max_bytes,
);
let content = format!(
"command exceeded memory limit{limit_note}. Try reducing parallelism (e.g. fewer jobs) and retry.\n\n{tail}"
);
@@ -8281,7 +8308,13 @@ async fn handle_sandbox_error(
let ExecToolCallOutput { exit_code, .. } = &retry_output;
let is_success = *exit_code == 0;
let content = format_exec_output_with_limit(sess, &sub_id, &call_id, &retry_output);
let content = format_exec_output_with_limit(
sess.get_cwd(),
&sub_id,
&call_id,
&retry_output,
sess.tool_output_max_bytes,
);
ResponseInputItem::FunctionCallOutput {
call_id: call_id.clone(),
@@ -8301,10 +8334,7 @@ async fn handle_sandbox_error(
}
}
// Limit extremely large tool outputs before sending to the model to avoid
// context overflows. Keep this conservative because multiple tool outputs
// can appear in a single turn. The limit is in bytes (on the UTF‑8 string).
pub(super) const MAX_TOOL_OUTPUT_BYTES_FOR_MODEL: usize = 32 * 1024; // 32 KiB
/// Marker inserted when tool output is truncated.
pub(super) const TRUNCATION_MARKER: &str = "…truncated…\n";
pub(super) fn truncate_middle_bytes(s: &str, max_bytes: usize) -> (String, bool, usize, usize) {
@@ -8386,8 +8416,10 @@ fn truncate_exec_output_for_storage(
sub_id: &str,
call_id: &str,
full: &str,
max_tool_output_bytes: usize,
) -> String {
let (maybe_truncated, was_truncated, _, _) = truncate_middle_bytes(full, MAX_TOOL_OUTPUT_BYTES_FOR_MODEL);
let (maybe_truncated, was_truncated, _, _) =
truncate_middle_bytes(full, max_tool_output_bytes);
if !was_truncated {
return maybe_truncated;
}
@@ -8408,10 +8440,11 @@ fn truncate_exec_output_for_storage(
/// Exec output serialized for the model. If the payload is too large,
/// write the full output to a file and include a truncated preview here.
fn format_exec_output_with_limit(
sess: &Session,
cwd: &Path,
sub_id: &str,
call_id: &str,
exec_output: &ExecToolCallOutput,
max_tool_output_bytes: usize,
) -> String {
let ExecToolCallOutput {
exit_code,
@@ -8431,9 +8464,9 @@ fn format_exec_output_with_limit(
// round to 1 decimal place
let duration_seconds = ((duration.as_secs_f32()) * 10.0).round() / 10.0;
let cwd = sess.get_cwd().to_path_buf();
let full = format_exec_output_str(exec_output);
let final_output = truncate_exec_output_for_storage(&cwd, sub_id, call_id, &full);
let final_output =
truncate_exec_output_for_storage(cwd, sub_id, call_id, &full, max_tool_output_bytes);
let payload = ExecOutput {
output: &final_output,
@@ -11114,3 +11147,62 @@ fn parse_legacy_status_snapshot(item: &ResponseItem) -> Option<EnvironmentContex
}
None
}
#[cfg(test)]
mod tests {
use super::{format_exec_output_with_limit, TRUNCATION_MARKER};
use crate::exec::{ExecToolCallOutput, StreamOutput};
use serde_json::Value;
use std::time::Duration;
use tempfile::TempDir;
fn make_exec_output(output: String) -> ExecToolCallOutput {
ExecToolCallOutput {
exit_code: 0,
stdout: StreamOutput::new(String::new()),
stderr: StreamOutput::new(String::new()),
aggregated_output: StreamOutput::new(output),
duration: Duration::from_secs(1),
timed_out: false,
}
}
#[test]
fn format_exec_output_truncates_with_small_limit() {
let dir = TempDir::new().expect("tempdir");
let output = "line\n".repeat(200);
let exec_output = make_exec_output(output);
let payload =
format_exec_output_with_limit(dir.path(), "sub", "call", &exec_output, 64);
let parsed: Value = serde_json::from_str(&payload).expect("parse payload");
let content = parsed
.get("output")
.and_then(Value::as_str)
.expect("output string");
assert!(content.contains(TRUNCATION_MARKER));
}
#[test]
fn format_exec_output_keeps_output_when_under_limit() {
let dir = TempDir::new().expect("tempdir");
let output = "line\n".repeat(10);
let exec_output = make_exec_output(output.clone());
let payload = format_exec_output_with_limit(
dir.path(),
"sub",
"call",
&exec_output,
output.len() + 32,
);
let parsed: Value = serde_json::from_str(&payload).expect("parse payload");
let content = parsed
.get("output")
.and_then(Value::as_str)
.expect("output string");
assert!(!content.contains(TRUNCATION_MARKER));
assert!(content.contains("line"));
}
}
+33
View File
@@ -109,6 +109,8 @@ pub const GPT_5_CODEX_MEDIUM_MODEL: &str = "gpt-5.2-codex";
/// files are *silently truncated* to this size so we do not take up too much of
/// the context window.
pub(crate) const PROJECT_DOC_MAX_BYTES: usize = 32 * 1024; // 32 KiB
/// Maximum number of bytes of tool output included in a model request.
pub(crate) const DEFAULT_TOOL_OUTPUT_MAX_BYTES: usize = 32 * 1024; // 32 KiB
pub(crate) const CONFIG_TOML_FILE: &str = "config.toml";
@@ -297,6 +299,9 @@ pub struct Config {
/// Maximum number of bytes to include from an AGENTS.md project doc file.
pub project_doc_max_bytes: usize,
/// Maximum number of bytes of tool output to include in a model request.
pub tool_output_max_bytes: usize,
/// Ordered list of fallback filenames to consider when loading project docs.
pub project_doc_fallback_filenames: Vec<String>,
@@ -558,6 +563,9 @@ pub struct ConfigToml {
/// Maximum number of bytes to include from an AGENTS.md project doc file.
pub project_doc_max_bytes: Option<usize>,
/// Maximum number of bytes of tool output to include in a model request.
pub tool_output_max_bytes: Option<usize>,
/// Ordered list of fallback filenames to look for when AGENTS.md is missing.
pub project_doc_fallback_filenames: Option<Vec<String>>,
@@ -1363,6 +1371,9 @@ impl Config {
agents,
model_providers,
project_doc_max_bytes: cfg.project_doc_max_bytes.unwrap_or(PROJECT_DOC_MAX_BYTES),
tool_output_max_bytes: cfg
.tool_output_max_bytes
.unwrap_or(DEFAULT_TOOL_OUTPUT_MAX_BYTES),
project_doc_fallback_filenames: cfg
.project_doc_fallback_filenames
.unwrap_or_default()
@@ -1644,6 +1655,28 @@ persistence = "none"
assert_eq!(parsed_bool.auto_upgrade_enabled, Some(true));
}
#[test]
fn tool_output_max_bytes_defaults_and_overrides() -> std::io::Result<()> {
let code_home = TempDir::new()?;
let default_config = Config::load_from_base_config_with_overrides(
ConfigToml::default(),
ConfigOverrides::default(),
code_home.path().to_path_buf(),
)?;
assert_eq!(default_config.tool_output_max_bytes, DEFAULT_TOOL_OUTPUT_MAX_BYTES);
let cfg = toml::from_str::<ConfigToml>("tool_output_max_bytes = 65536")
.expect("TOML should deserialize");
let overridden = Config::load_from_base_config_with_overrides(
cfg,
ConfigOverrides::default(),
code_home.path().to_path_buf(),
)?;
assert_eq!(overridden.tool_output_max_bytes, 65_536);
Ok(())
}
#[test]
fn load_instructions_reads_from_code_home() -> anyhow::Result<()> {
let code_home = TempDir::new()?;
+4
View File
@@ -856,6 +856,10 @@ In general, Code knows the context window for the most common OpenAI models, but
This is analogous to `model_context_window`, but for the maximum number of output tokens for the model.
## tool_output_max_bytes
Maximum number of bytes of tool output (including shell command output and file reads) to include in a model request. Defaults to 32 KiB. Increase this if you need to send larger outputs to the model (note the exec capture cap remains 32 MiB per stream).
## project_doc_max_bytes
Maximum number of bytes to read from an `AGENTS.md` file to include in the instructions sent with the first turn of a session. Defaults to 32 KiB.