mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +00:00
fix(core/config): allow tool output size override (fixes #498)
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user