mirror of
https://wget.la/https://github.com/leookun/cursor-byok
synced 2026-10-06 13:31:03 +08:00
feat(docs): add documentation site and demo features
- Introduced a new documentation site for Cursor BYOK using Next.js and Fumadocs. - Added a product demo page with a corresponding Vite configuration. - Implemented a demo API to simulate LLM calls and responses. - Enhanced the Makefile to include new build and development commands for the documentation. - Updated package.json scripts for building and running the documentation site. - Created various components and layouts for the documentation structure, including blog and user documentation sections. - Added styling for the new components and layouts to ensure a cohesive design. - Included a README and other necessary files for local development and deployment.
This commit is contained in:
@@ -188,7 +188,6 @@ pub fn tool_placeholder(name: &str, call_id: &str) -> Result<pb::ToolCall> {
|
||||
"updatecurrentstep" => {
|
||||
Tool::CommunicateUpdateToolCall(pb::CommunicateUpdateToolCall::default())
|
||||
}
|
||||
"awaitshell" => Tool::AwaitToolCall(pb::AwaitToolCall::default()),
|
||||
"getmcptools" => Tool::GetMcpToolsToolCall(pb::GetMcpToolsToolCall::default()),
|
||||
_ => return Err(Error::Protocol(format!("unsupported tool: {name}"))),
|
||||
};
|
||||
@@ -457,17 +456,6 @@ pub fn render_tool_call(call: &ToolCall, completed: bool) -> Result<pb::ToolCall
|
||||
chars: string("chars"),
|
||||
})
|
||||
}
|
||||
Some(pb::tool_call::Tool::AwaitToolCall(tool)) => {
|
||||
tool.args = Some(pb::AwaitArgs {
|
||||
task_id: string("shell_id"),
|
||||
block_until_ms: call
|
||||
.arguments
|
||||
.get("block_until_ms")
|
||||
.and_then(Value::as_u64)
|
||||
.map(|v| v as u32),
|
||||
regex: optional("pattern"),
|
||||
})
|
||||
}
|
||||
Some(pb::tool_call::Tool::GetMcpToolsToolCall(tool)) => {
|
||||
tool.args = Some(pb::GetMcpToolsArgs {
|
||||
server: optional("server"),
|
||||
|
||||
@@ -158,7 +158,6 @@ fn tool_identifier(name: &str, dynamic_tools: &HashSet<String>) -> String {
|
||||
return name.into();
|
||||
}
|
||||
match name {
|
||||
"AwaitShell" => "AWAIT".into(),
|
||||
"CallMcpTool" | "SembleSearch" | "SembleFindRelated" => "MCP".into(),
|
||||
"CreatePlan" => "CREATE_PLAN_V2".into(),
|
||||
"UpdateCurrentStep" => "COMMUNICATE_UPDATE".into(),
|
||||
|
||||
@@ -478,9 +478,9 @@ pub fn dynamic_mcp(
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
fn normalize_mcp_parameters(tool_name: &str, parameters: Value) -> Result<Value> {
|
||||
fn normalize_mcp_parameters(tool_name: &str, mut parameters: Value) -> Result<Value> {
|
||||
let schema = parameters
|
||||
.as_object()
|
||||
.as_object_mut()
|
||||
.ok_or_else(|| invalid_mcp_parameters(tool_name))?;
|
||||
match schema.get("type") {
|
||||
Some(Value::String(schema_type)) if schema_type == "object" => return Ok(parameters),
|
||||
@@ -505,6 +505,12 @@ fn normalize_mcp_parameters(tool_name: &str, parameters: Value) -> Result<Value>
|
||||
if !object_only_union {
|
||||
return Err(invalid_mcp_parameters(tool_name));
|
||||
}
|
||||
// OpenAI-compatible function schemas (and the corresponding schema
|
||||
// validators used by other providers) require the root schema to declare
|
||||
// an object type. Cursor's app-control MCP sometimes sends an object-only
|
||||
// `anyOf`/`oneOf` schema without that root annotation. Preserve the union
|
||||
// while adding the annotation to the model-facing copy.
|
||||
schema.insert("type".into(), Value::String("object".into()));
|
||||
Ok(parameters)
|
||||
}
|
||||
|
||||
@@ -613,7 +619,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_mcp_preserves_cursor_object_union_schema() {
|
||||
fn dynamic_mcp_normalizes_cursor_object_union_without_mutating_wire_schema() {
|
||||
let original_schema = serde_json::json!({
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"anyOf": [
|
||||
@@ -654,7 +660,8 @@ mod tests {
|
||||
.get("cursor-app-control-move_agent_to_cloned_root")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(definition.parameters, original_schema);
|
||||
assert_eq!(definition.parameters["type"], "object");
|
||||
assert_eq!(definition.parameters["anyOf"], original_schema["anyOf"]);
|
||||
assert_eq!(
|
||||
wire.input_schema_json.as_deref(),
|
||||
Some(original_json.as_str())
|
||||
|
||||
@@ -2,7 +2,5 @@ mod request;
|
||||
mod response;
|
||||
|
||||
pub use request::{abort, mcp_request, mcp_state_request, request};
|
||||
pub(crate) use request::{
|
||||
await_read_request, edit_read_request, json_object_to_prost, mcp_meta_request,
|
||||
};
|
||||
pub(crate) use request::{edit_read_request, json_object_to_prost, mcp_meta_request};
|
||||
pub use response::{client_event, stream_closed, ClientExecEvent};
|
||||
|
||||
@@ -191,32 +191,6 @@ pub(crate) fn edit_read_request(id: u32, call: &ToolCall) -> Result<pb::AgentSer
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn await_read_request(
|
||||
id: u32,
|
||||
call: &ToolCall,
|
||||
context: &ExecContext,
|
||||
) -> Result<pb::AgentServerMessage> {
|
||||
let task_id = call
|
||||
.arguments
|
||||
.get("shell_id")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| Error::Protocol("AwaitShell is missing shell_id".into()))?;
|
||||
Ok(server_message(
|
||||
id,
|
||||
call,
|
||||
pb::exec_server_message::Message::ReadArgs(pb::ReadArgs {
|
||||
path: format!(
|
||||
"{}/{}.txt",
|
||||
context.terminals_folder.trim_end_matches('/'),
|
||||
task_id
|
||||
),
|
||||
tool_call_id: call.call_id.clone(),
|
||||
..Default::default()
|
||||
}),
|
||||
Some(false),
|
||||
))
|
||||
}
|
||||
|
||||
pub(super) fn edit_write_request(
|
||||
id: u32,
|
||||
call: &ToolCall,
|
||||
|
||||
@@ -12,7 +12,7 @@ use crate::{
|
||||
Error, Result,
|
||||
};
|
||||
|
||||
use super::request::{await_read_request, edit_write_request};
|
||||
use super::request::edit_write_request;
|
||||
|
||||
pub enum ClientExecEvent {
|
||||
Delta(Box<pb::AgentServerMessage>),
|
||||
@@ -53,7 +53,6 @@ pub async fn client_event(
|
||||
let entry = take(message.id, pending).await?;
|
||||
return match entry.stage {
|
||||
ExecStage::EditRead => advance_edit(entry, wire_result, pending).await,
|
||||
ExecStage::Await(_) => advance_await(entry, wire_result, pending).await,
|
||||
ExecStage::Direct | ExecStage::DynamicMcp(_) | ExecStage::EditWrite(_) => {
|
||||
completed(entry, wire_result.clone())
|
||||
}
|
||||
@@ -203,81 +202,6 @@ fn is_terminal(message: &pb::exec_client_message::Message) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
async fn advance_await(
|
||||
entry: PendingExec,
|
||||
result: &pb::exec_client_message::Message,
|
||||
registry: &CursorToolRuntime,
|
||||
) -> Result<ClientExecEvent> {
|
||||
let read = match result {
|
||||
pb::exec_client_message::Message::ReadResult(result)
|
||||
| pb::exec_client_message::Message::RedactedReadResult(result) => result,
|
||||
_ => return Err(Error::Protocol("AwaitShell expected ReadResult".into())),
|
||||
};
|
||||
let ExecStage::Await(state) = &entry.stage else {
|
||||
return Err(Error::Protocol(
|
||||
"AwaitShell result reached a non-await execution stage".into(),
|
||||
));
|
||||
};
|
||||
let content = match read.result.as_ref() {
|
||||
Some(pb::read_result::Result::Success(success)) => match success.output.as_ref() {
|
||||
Some(pb::read_success::Output::Content(content)) => content.as_str(),
|
||||
_ => "",
|
||||
},
|
||||
Some(pb::read_result::Result::FileNotFound(_)) => "",
|
||||
Some(pb::read_result::Result::Error(error)) => {
|
||||
return Ok(ClientExecEvent::Completed(Box::new(result::await_error(
|
||||
entry,
|
||||
&error.error,
|
||||
)?)))
|
||||
}
|
||||
_ => "",
|
||||
};
|
||||
let regex_match = state
|
||||
.regex
|
||||
.as_ref()
|
||||
.map(|pattern| regex::Regex::new(pattern))
|
||||
.transpose()
|
||||
.map_err(|error| Error::Protocol(format!("invalid AwaitShell pattern: {error}")))?
|
||||
.and_then(|pattern| {
|
||||
pattern
|
||||
.find(content)
|
||||
.map(|found| found.as_str().to_string())
|
||||
});
|
||||
let exit_code = content.lines().find_map(|line| {
|
||||
line.strip_prefix("exit_code:")
|
||||
.and_then(|value| value.trim().parse::<i32>().ok())
|
||||
});
|
||||
if regex_match.is_some() || exit_code.is_some() || std::time::Instant::now() >= state.deadline {
|
||||
return Ok(ClientExecEvent::Completed(Box::new(result::await_result(
|
||||
entry,
|
||||
content.len() as u64,
|
||||
regex_match,
|
||||
exit_code,
|
||||
)?)));
|
||||
}
|
||||
let state = match entry.stage {
|
||||
ExecStage::Await(state) => state,
|
||||
_ => {
|
||||
return Err(Error::Protocol(
|
||||
"AwaitShell result changed execution stage".into(),
|
||||
))
|
||||
}
|
||||
};
|
||||
let wait = state
|
||||
.deadline
|
||||
.saturating_duration_since(std::time::Instant::now())
|
||||
.min(std::time::Duration::from_secs(1));
|
||||
tokio::time::sleep(wait).await;
|
||||
let call = entry.call.clone();
|
||||
let context = entry.context.clone();
|
||||
let id = registry
|
||||
.reserve_await_again(&call, &context, state, entry.started_at_ms)
|
||||
.await?;
|
||||
Ok(ClientExecEvent::Message(Box::new(await_read_request(
|
||||
id, &call, &context,
|
||||
)?)))
|
||||
}
|
||||
|
||||
async fn advance_edit(
|
||||
entry: PendingExec,
|
||||
result: &pb::exec_client_message::Message,
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
//! AwaitShell's timed and file-backed execution paths.
|
||||
|
||||
use crate::{model::ToolCall, Error, Result};
|
||||
|
||||
use super::ToolStart;
|
||||
use crate::cursor::tools::{
|
||||
codec, result,
|
||||
result::ToolResultSender,
|
||||
runtime::{CursorToolRuntime, ExecContext},
|
||||
};
|
||||
|
||||
pub(super) async fn start(
|
||||
runtime: &CursorToolRuntime,
|
||||
results: &ToolResultSender,
|
||||
call: &ToolCall,
|
||||
context: &ExecContext,
|
||||
) -> Result<ToolStart> {
|
||||
let message = if call
|
||||
.arguments
|
||||
.get("shell_id")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.is_some()
|
||||
{
|
||||
let id = runtime.reserve_await(call, context).await?;
|
||||
Some(codec::await_read_request(id, call, context)?)
|
||||
} else {
|
||||
wait_without_shell_id(results, call)?;
|
||||
None
|
||||
};
|
||||
Ok(ToolStart {
|
||||
messages: message.into_iter().collect(),
|
||||
completion: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn wait_without_shell_id(results: &ToolResultSender, call: &ToolCall) -> Result<()> {
|
||||
let block_ms = call
|
||||
.arguments
|
||||
.get("block_until_ms")
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
.unwrap_or(30_000);
|
||||
if block_ms == 0 || block_ms > 7_140_000 {
|
||||
return Err(Error::Protocol(
|
||||
"AwaitShell without shell_id requires block_until_ms in 1..=7140000".into(),
|
||||
));
|
||||
}
|
||||
let call = call.clone();
|
||||
let results = results.clone();
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(block_ms)).await;
|
||||
results.send(result::await_sleep(&call, block_ms));
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
mod await_shell;
|
||||
mod edit;
|
||||
mod exec;
|
||||
mod interaction;
|
||||
@@ -58,7 +57,6 @@ pub(super) async fn start(
|
||||
"askquestion" | "websearch" | "webfetch" | "switchmode" | "createplan"
|
||||
| "generateimage" => interaction::start(runtime, call).await,
|
||||
"todowrite" | "updatecurrentstep" => local::start(call, message_index),
|
||||
"awaitshell" => await_shell::start(runtime, results, call, context).await,
|
||||
"semblesearch" | "semblefindrelated" => semble::start(results, call, store.cloned()),
|
||||
_ => Err(Error::Protocol(format!("unsupported tool: {}", call.name))),
|
||||
}
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{
|
||||
cursor::proto::agent::v1 as pb,
|
||||
model::{ToolCall, ToolResult},
|
||||
Error, Result,
|
||||
};
|
||||
|
||||
use super::{now_ms, ToolCompletion};
|
||||
use crate::cursor::tools::runtime::{ExecStage, PendingExec};
|
||||
|
||||
pub(crate) fn await_result(
|
||||
pending: PendingExec,
|
||||
output_length: u64,
|
||||
regex_match: Option<String>,
|
||||
exit_code: Option<i32>,
|
||||
) -> Result<ToolCompletion> {
|
||||
let ExecStage::Await(state) = &pending.stage else {
|
||||
return Err(Error::Protocol(
|
||||
"AwaitShell completion reached a non-await execution stage".into(),
|
||||
));
|
||||
};
|
||||
let runtime_ms = now_ms().saturating_sub(pending.started_at_ms);
|
||||
let result = if exit_code.is_some() {
|
||||
pb::await_success::AwaitResult::Complete(pb::AwaitTaskComplete {
|
||||
task_id: state.task_id.clone(),
|
||||
runtime_ms,
|
||||
output_file_path: state.output_file_path.clone(),
|
||||
output_length,
|
||||
regex_requested: state.regex.is_some(),
|
||||
regex_match,
|
||||
exit_code,
|
||||
wake_reason: Some("task_complete".into()),
|
||||
})
|
||||
} else {
|
||||
pb::await_success::AwaitResult::StillRunning(pb::AwaitTaskStillRunning {
|
||||
task_id: state.task_id.clone(),
|
||||
runtime_ms,
|
||||
output_file_path: state.output_file_path.clone(),
|
||||
output_length,
|
||||
regex_requested: state.regex.is_some(),
|
||||
regex_match,
|
||||
wake_reason: Some("timeout_or_pattern".into()),
|
||||
})
|
||||
};
|
||||
let content = serde_json::json!({
|
||||
"task_id": state.task_id,
|
||||
"output_file_path": state.output_file_path,
|
||||
"output_length": output_length,
|
||||
"exit_code": exit_code,
|
||||
})
|
||||
.to_string();
|
||||
completion(
|
||||
&pending,
|
||||
content,
|
||||
false,
|
||||
pb::await_result::Result::Success(pb::AwaitSuccess {
|
||||
await_result: Some(result),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn await_error(pending: PendingExec, error: &str) -> Result<ToolCompletion> {
|
||||
completion(
|
||||
&pending,
|
||||
error.into(),
|
||||
true,
|
||||
pb::await_result::Result::Error(pb::AwaitError {
|
||||
error: error.into(),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn completion(
|
||||
pending: &PendingExec,
|
||||
content: String,
|
||||
is_error: bool,
|
||||
result: pb::await_result::Result,
|
||||
) -> Result<ToolCompletion> {
|
||||
let ExecStage::Await(state) = &pending.stage else {
|
||||
return Err(Error::Protocol(
|
||||
"AwaitShell completion reached a non-await execution stage".into(),
|
||||
));
|
||||
};
|
||||
Ok(ToolCompletion::new(
|
||||
&pending.call,
|
||||
pending.started_at_ms,
|
||||
ToolResult {
|
||||
call_id: pending.call.call_id.clone(),
|
||||
content,
|
||||
is_error,
|
||||
image: None,
|
||||
},
|
||||
pb::tool_call::Tool::AwaitToolCall(pb::AwaitToolCall {
|
||||
args: Some(pb::AwaitArgs {
|
||||
task_id: state.task_id.clone(),
|
||||
block_until_ms: pending
|
||||
.call
|
||||
.arguments
|
||||
.get("block_until_ms")
|
||||
.and_then(Value::as_u64)
|
||||
.map(|value| value as u32),
|
||||
regex: state.regex.clone(),
|
||||
}),
|
||||
result: Some(pb::AwaitResult {
|
||||
result: Some(result),
|
||||
}),
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn await_sleep(call: &ToolCall, runtime_ms: u64) -> ToolCompletion {
|
||||
ToolCompletion::new(
|
||||
call,
|
||||
now_ms().saturating_sub(runtime_ms),
|
||||
ToolResult {
|
||||
call_id: call.call_id.clone(),
|
||||
content: format!("Waited {runtime_ms} ms"),
|
||||
is_error: false,
|
||||
image: None,
|
||||
},
|
||||
pb::tool_call::Tool::AwaitToolCall(pb::AwaitToolCall {
|
||||
args: Some(pb::AwaitArgs {
|
||||
task_id: String::new(),
|
||||
block_until_ms: Some(runtime_ms as u32),
|
||||
regex: None,
|
||||
}),
|
||||
result: Some(pb::AwaitResult {
|
||||
result: Some(pb::await_result::Result::Success(pb::AwaitSuccess {
|
||||
await_result: Some(pb::await_success::AwaitResult::StillRunning(
|
||||
pb::AwaitTaskStillRunning {
|
||||
task_id: String::new(),
|
||||
runtime_ms,
|
||||
output_file_path: String::new(),
|
||||
output_length: 0,
|
||||
regex_requested: false,
|
||||
regex_match: None,
|
||||
wake_reason: Some("sleep_complete".into()),
|
||||
},
|
||||
)),
|
||||
})),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -1,13 +1,54 @@
|
||||
use crate::cursor::proto::agent::v1 as pb;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::{cursor::proto::agent::v1 as pb, model::limit_tool_result_text};
|
||||
|
||||
const KIB: usize = 1024;
|
||||
const READ_CONTENT_LIMIT: usize = 64 * KIB;
|
||||
const READ_BINARY_LIMIT: usize = 32 * KIB;
|
||||
const SHELL_STREAM_LIMIT: usize = 16 * KIB;
|
||||
const SHELL_CONTENT_LIMIT: usize = 32 * KIB;
|
||||
const SHELL_INTERLEAVED_LIMIT: usize = 32 * KIB;
|
||||
const GREP_CONTENT_LIMIT: usize = 32 * KIB;
|
||||
const GREP_MATCH_LIMIT: usize = 2 * KIB;
|
||||
const GREP_MATCHES_PER_FILE: usize = 100;
|
||||
const GREP_TOTAL_MATCHES: usize = 300;
|
||||
const GREP_LIST_LIMIT: usize = 300;
|
||||
const GLOB_FILE_LIMIT: usize = 200;
|
||||
const EDIT_RESULT_LIMIT: usize = 32 * KIB;
|
||||
const PATCH_EDIT_RESULT_LIMIT: usize = 4 * KIB;
|
||||
const MCP_TEXT_LIMIT: usize = 32 * KIB;
|
||||
const MCP_CONTENT_ITEM_LIMIT: usize = 20;
|
||||
const MCP_STRUCTURED_LIMIT: usize = 32 * KIB;
|
||||
const MCP_BINARY_LIMIT: usize = 32 * KIB;
|
||||
const MCP_RESOURCE_LIMIT: usize = 200;
|
||||
const MCP_RESOURCE_DESCRIPTION_LIMIT: usize = KIB;
|
||||
const WEB_FETCH_LIMIT: usize = 32 * KIB;
|
||||
const WEB_SEARCH_LIMIT: usize = 16 * KIB;
|
||||
const WEB_SEARCH_TITLE_LIMIT: usize = 512;
|
||||
const WEB_SEARCH_SNIPPET_LIMIT: usize = 2 * KIB;
|
||||
|
||||
pub(super) fn model_content(tool: &pb::tool_call::Tool, content: &mut String) {
|
||||
if matches!(tool, pb::tool_call::Tool::ShellToolCall(_)) {
|
||||
*content = truncate_edges("Shell", content, SHELL_CONTENT_LIMIT);
|
||||
pub(super) fn tool_completion(
|
||||
tool_name: &str,
|
||||
tool: &mut pb::tool_call::Tool,
|
||||
content: &mut String,
|
||||
) {
|
||||
use pb::tool_call::Tool;
|
||||
|
||||
match tool {
|
||||
Tool::ShellToolCall(tool) => gate_shell(tool),
|
||||
Tool::GrepToolCall(tool) => gate_grep(tool),
|
||||
Tool::GlobToolCall(tool) => gate_glob(tool),
|
||||
Tool::ReadToolCall(tool) => gate_read(tool),
|
||||
Tool::EditToolCall(tool) => gate_edit(tool_name, tool),
|
||||
Tool::McpToolCall(tool) => gate_mcp(tool),
|
||||
Tool::ListMcpResourcesToolCall(tool) => gate_mcp_resources(tool),
|
||||
Tool::ReadMcpResourceToolCall(tool) => gate_mcp_resource(tool),
|
||||
Tool::GetMcpToolsToolCall(tool) => gate_mcp_tools(tool),
|
||||
Tool::WebFetchToolCall(tool) => gate_web_fetch(tool),
|
||||
Tool::WebSearchToolCall(tool) => gate_web_search(tool),
|
||||
Tool::GenerateImageToolCall(tool) => gate_generate_image(tool),
|
||||
_ => {}
|
||||
}
|
||||
*content = limit_tool_result_text(tool_name, content);
|
||||
}
|
||||
|
||||
pub(super) fn exec_message(message: &mut pb::exec_client_message::Message) {
|
||||
@@ -20,6 +61,12 @@ pub(super) fn exec_message(message: &mut pb::exec_client_message::Message) {
|
||||
}
|
||||
}
|
||||
|
||||
fn gate_shell(tool: &mut pb::ShellToolCall) {
|
||||
if let Some(result) = tool.result.as_mut() {
|
||||
gate_shell_result(result);
|
||||
}
|
||||
}
|
||||
|
||||
fn gate_shell_result(result: &mut pb::ShellResult) {
|
||||
use pb::shell_result::Result;
|
||||
match result.result.as_mut() {
|
||||
@@ -27,22 +74,584 @@ fn gate_shell_result(result: &mut pb::ShellResult) {
|
||||
success.stdout = truncate_edges("Shell stdout", &success.stdout, SHELL_STREAM_LIMIT);
|
||||
success.stderr = truncate_edges("Shell stderr", &success.stderr, SHELL_STREAM_LIMIT);
|
||||
if let Some(interleaved) = success.interleaved_output.as_mut() {
|
||||
*interleaved =
|
||||
truncate_edges("Shell interleaved output", interleaved, SHELL_CONTENT_LIMIT);
|
||||
*interleaved = truncate_edges(
|
||||
"Shell interleaved output",
|
||||
interleaved,
|
||||
SHELL_INTERLEAVED_LIMIT,
|
||||
);
|
||||
}
|
||||
}
|
||||
Some(Result::Failure(failure)) => {
|
||||
failure.stdout = truncate_edges("Shell stdout", &failure.stdout, SHELL_STREAM_LIMIT);
|
||||
failure.stderr = truncate_edges("Shell stderr", &failure.stderr, SHELL_STREAM_LIMIT);
|
||||
if let Some(interleaved) = failure.interleaved_output.as_mut() {
|
||||
*interleaved =
|
||||
truncate_edges("Shell interleaved output", interleaved, SHELL_CONTENT_LIMIT);
|
||||
*interleaved = truncate_edges(
|
||||
"Shell interleaved output",
|
||||
interleaved,
|
||||
SHELL_INTERLEAVED_LIMIT,
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn gate_read(tool: &mut pb::ReadToolCall) {
|
||||
let Some(pb::read_tool_result::Result::Success(success)) = tool
|
||||
.result
|
||||
.as_mut()
|
||||
.and_then(|result| result.result.as_mut())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Some(output) = success.output.as_mut() else {
|
||||
return;
|
||||
};
|
||||
match output {
|
||||
pb::read_tool_success::Output::Content(value) => {
|
||||
let next = truncate_text("Read", value, READ_CONTENT_LIMIT);
|
||||
if next != *value {
|
||||
*value = next;
|
||||
success.exceeded_limit = true;
|
||||
}
|
||||
}
|
||||
pb::read_tool_success::Output::Data(value) if value.len() > READ_BINARY_LIMIT => {
|
||||
let notice = truncation_notice("Read binary data", READ_BINARY_LIMIT, 0, value.len());
|
||||
success.output = Some(pb::read_tool_success::Output::Content(notice));
|
||||
success.exceeded_limit = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn gate_glob(tool: &mut pb::GlobToolCall) {
|
||||
let Some(pb::glob_tool_result::Result::Success(success)) = tool
|
||||
.result
|
||||
.as_mut()
|
||||
.and_then(|result| result.result.as_mut())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let original = success.files.len();
|
||||
if original <= GLOB_FILE_LIMIT {
|
||||
if success.total_files <= 0 {
|
||||
success.total_files = original as i32;
|
||||
}
|
||||
return;
|
||||
}
|
||||
success.files.truncate(GLOB_FILE_LIMIT);
|
||||
success.total_files = success.total_files.max(original as i32);
|
||||
success.client_truncated = true;
|
||||
}
|
||||
|
||||
fn gate_grep(tool: &mut pb::GrepToolCall) {
|
||||
let Some(pb::grep_result::Result::Success(success)) = tool
|
||||
.result
|
||||
.as_mut()
|
||||
.and_then(|result| result.result.as_mut())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let mut budget = GrepBudget {
|
||||
content_bytes: GREP_CONTENT_LIMIT,
|
||||
matches: GREP_TOTAL_MATCHES,
|
||||
};
|
||||
let mut workspace_names = success
|
||||
.workspace_results
|
||||
.keys()
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
workspace_names.sort_unstable();
|
||||
for name in workspace_names {
|
||||
if let Some(result) = success.workspace_results.get_mut(&name) {
|
||||
gate_grep_union(result, &mut budget);
|
||||
}
|
||||
}
|
||||
if let Some(result) = success.active_editor_result.as_mut() {
|
||||
gate_grep_union(result, &mut budget);
|
||||
}
|
||||
}
|
||||
|
||||
struct GrepBudget {
|
||||
content_bytes: usize,
|
||||
matches: usize,
|
||||
}
|
||||
|
||||
fn gate_grep_union(result: &mut pb::GrepUnionResult, budget: &mut GrepBudget) {
|
||||
use pb::grep_union_result::Result;
|
||||
match result.result.as_mut() {
|
||||
Some(Result::Content(content)) => gate_grep_content(content, budget),
|
||||
Some(Result::Files(files)) => {
|
||||
let original = files.files.len();
|
||||
if original > GREP_LIST_LIMIT {
|
||||
files.files.truncate(GREP_LIST_LIMIT);
|
||||
files.client_truncated = true;
|
||||
}
|
||||
if files.total_files <= 0 {
|
||||
files.total_files = original as i32;
|
||||
}
|
||||
}
|
||||
Some(Result::Count(counts)) => {
|
||||
let original = counts.counts.len();
|
||||
if original > GREP_LIST_LIMIT {
|
||||
counts.counts.truncate(GREP_LIST_LIMIT);
|
||||
counts.client_truncated = true;
|
||||
}
|
||||
if counts.total_files <= 0 {
|
||||
counts.total_files = original as i32;
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn gate_grep_content(content: &mut pb::GrepContentResult, budget: &mut GrepBudget) {
|
||||
if content
|
||||
.matches
|
||||
.iter()
|
||||
.flat_map(|file| &file.matches)
|
||||
.any(is_grep_notice)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let original_bytes = grep_content_bytes(&content.matches);
|
||||
let original_files = content.matches.len();
|
||||
let mut truncated = false;
|
||||
let mut files = Vec::with_capacity(original_files);
|
||||
|
||||
for file in &content.matches {
|
||||
if budget.matches == 0 || budget.content_bytes == 0 {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
let mut next = pb::GrepFileMatch {
|
||||
file: file.file.clone(),
|
||||
matches: Vec::new(),
|
||||
};
|
||||
for matched in &file.matches {
|
||||
if is_grep_notice(matched) {
|
||||
next.matches.push(matched.clone());
|
||||
continue;
|
||||
}
|
||||
if next.matches.len() >= GREP_MATCHES_PER_FILE
|
||||
|| budget.matches == 0
|
||||
|| budget.content_bytes == 0
|
||||
{
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
let mut next_match = matched.clone();
|
||||
let original = next_match.content.clone();
|
||||
next_match.content = truncate_text("Grep match", &original, GREP_MATCH_LIMIT);
|
||||
if next_match.content != original {
|
||||
next_match.content_truncated = true;
|
||||
truncated = true;
|
||||
}
|
||||
if next_match.content.len() > budget.content_bytes {
|
||||
next_match.content =
|
||||
truncate_text("Grep", &next_match.content, budget.content_bytes);
|
||||
next_match.content_truncated = true;
|
||||
truncated = true;
|
||||
}
|
||||
if next_match.content.trim().is_empty() {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
budget.content_bytes -= next_match.content.len();
|
||||
budget.matches -= 1;
|
||||
next.matches.push(next_match);
|
||||
}
|
||||
if next.matches.len() < file.matches.len() {
|
||||
truncated = true;
|
||||
}
|
||||
if !next.matches.is_empty() {
|
||||
files.push(next);
|
||||
}
|
||||
}
|
||||
if files.len() < original_files {
|
||||
truncated = true;
|
||||
}
|
||||
if truncated {
|
||||
content.client_truncated = true;
|
||||
add_grep_notice(&mut files, original_bytes);
|
||||
}
|
||||
content.matches = files;
|
||||
}
|
||||
|
||||
fn add_grep_notice(files: &mut Vec<pb::GrepFileMatch>, original_bytes: usize) {
|
||||
if files
|
||||
.iter()
|
||||
.flat_map(|file| &file.matches)
|
||||
.any(is_grep_notice)
|
||||
{
|
||||
return;
|
||||
}
|
||||
loop {
|
||||
let used = grep_content_bytes(files);
|
||||
let notice = truncation_notice("Grep", GREP_CONTENT_LIMIT, used, original_bytes);
|
||||
if used.saturating_add(notice.len()) <= GREP_CONTENT_LIMIT {
|
||||
let matched = pb::GrepContentMatch {
|
||||
line_number: 0,
|
||||
content: notice,
|
||||
content_truncated: true,
|
||||
is_context_line: true,
|
||||
};
|
||||
if let Some(file) = files.last_mut() {
|
||||
file.matches.push(matched);
|
||||
} else {
|
||||
files.push(pb::GrepFileMatch {
|
||||
file: "[truncated]".into(),
|
||||
matches: vec![matched],
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
let Some(file) = files.last_mut() else {
|
||||
return;
|
||||
};
|
||||
file.matches.pop();
|
||||
if file.matches.is_empty() {
|
||||
files.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_grep_notice(matched: &pb::GrepContentMatch) -> bool {
|
||||
matched.line_number == 0
|
||||
&& matched.content_truncated
|
||||
&& matched
|
||||
.content
|
||||
.starts_with("[truncated: Grep result exceeded")
|
||||
}
|
||||
|
||||
fn grep_content_bytes(files: &[pb::GrepFileMatch]) -> usize {
|
||||
files
|
||||
.iter()
|
||||
.flat_map(|file| &file.matches)
|
||||
.map(|matched| matched.content.len())
|
||||
.sum()
|
||||
}
|
||||
|
||||
fn gate_edit(tool_name: &str, tool: &mut pb::EditToolCall) {
|
||||
let Some(pb::edit_result::Result::Success(success)) = tool
|
||||
.result
|
||||
.as_mut()
|
||||
.and_then(|result| result.result.as_mut())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let limit = match tool_name.trim() {
|
||||
"PatchEdit" | "PatchEditLines" | "PatchEditSpan" | "StrReplace" => PATCH_EDIT_RESULT_LIMIT,
|
||||
_ => EDIT_RESULT_LIMIT,
|
||||
};
|
||||
if let Some(diff) = success.diff_string.as_mut() {
|
||||
*diff = truncate_text(tool_name, diff, limit);
|
||||
success.before_full_file_content = None;
|
||||
success.after_full_file_content.clear();
|
||||
} else {
|
||||
success.before_full_file_content = None;
|
||||
success.after_full_file_content =
|
||||
truncate_text(tool_name, &success.after_full_file_content, limit);
|
||||
}
|
||||
}
|
||||
|
||||
fn gate_mcp(tool: &mut pb::McpToolCall) {
|
||||
let Some(pb::mcp_tool_result::Result::Success(success)) = tool
|
||||
.result
|
||||
.as_mut()
|
||||
.and_then(|result| result.result.as_mut())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if success.content.iter().any(is_mcp_notice) {
|
||||
return;
|
||||
}
|
||||
let mut notices = Vec::new();
|
||||
if structured_json_len(&success.structured_content) > MCP_STRUCTURED_LIMIT {
|
||||
let original = structured_json_len(&success.structured_content);
|
||||
success.structured_content = truncated_struct(original, MCP_STRUCTURED_LIMIT);
|
||||
notices.push(truncation_notice(
|
||||
"MCP structured_content",
|
||||
MCP_STRUCTURED_LIMIT,
|
||||
0,
|
||||
original,
|
||||
));
|
||||
}
|
||||
let original_items = success.content.len();
|
||||
if original_items > MCP_CONTENT_ITEM_LIMIT {
|
||||
success.content.truncate(MCP_CONTENT_ITEM_LIMIT);
|
||||
notices.push(format!(
|
||||
"[truncated: MCP content items exceeded {MCP_CONTENT_ITEM_LIMIT} items; showing {MCP_CONTENT_ITEM_LIMIT} of {original_items} items]"
|
||||
));
|
||||
}
|
||||
let mut remaining_text = MCP_TEXT_LIMIT;
|
||||
let mut content = Vec::with_capacity(success.content.len() + notices.len());
|
||||
for mut item in std::mem::take(&mut success.content) {
|
||||
match item.content.as_mut() {
|
||||
Some(pb::mcp_tool_result_content_item::Content::Text(text)) => {
|
||||
let original = text.text.clone();
|
||||
let next = truncate_text("MCP content item", &original, MCP_TEXT_LIMIT);
|
||||
if remaining_text == 0 {
|
||||
notices.push(truncation_notice(
|
||||
"MCP text",
|
||||
MCP_TEXT_LIMIT,
|
||||
MCP_TEXT_LIMIT,
|
||||
MCP_TEXT_LIMIT.saturating_add(original.len()),
|
||||
));
|
||||
continue;
|
||||
}
|
||||
text.text = truncate_text("MCP text", &next, remaining_text);
|
||||
remaining_text = remaining_text.saturating_sub(text.text.len());
|
||||
}
|
||||
Some(pb::mcp_tool_result_content_item::Content::Image(image))
|
||||
if image.data.len() > MCP_BINARY_LIMIT =>
|
||||
{
|
||||
let original = image.data.len();
|
||||
image.data.truncate(MCP_BINARY_LIMIT);
|
||||
notices.push(truncation_notice(
|
||||
"MCP image data",
|
||||
MCP_BINARY_LIMIT,
|
||||
image.data.len(),
|
||||
original,
|
||||
));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
content.push(item);
|
||||
}
|
||||
content.extend(notices.into_iter().map(mcp_notice));
|
||||
success.content = content;
|
||||
}
|
||||
|
||||
fn mcp_notice(text: String) -> pb::McpToolResultContentItem {
|
||||
pb::McpToolResultContentItem {
|
||||
content: Some(pb::mcp_tool_result_content_item::Content::Text(
|
||||
pb::McpTextContent {
|
||||
text,
|
||||
output_location: None,
|
||||
},
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_mcp_notice(item: &pb::McpToolResultContentItem) -> bool {
|
||||
matches!(
|
||||
item.content.as_ref(),
|
||||
Some(pb::mcp_tool_result_content_item::Content::Text(text))
|
||||
if text.text.starts_with("[truncated:")
|
||||
)
|
||||
}
|
||||
|
||||
fn structured_json_len(value: &Option<prost_types::Struct>) -> usize {
|
||||
value
|
||||
.as_ref()
|
||||
.and_then(|value| {
|
||||
serde_json::to_vec(&serde_json::Value::Object(
|
||||
value
|
||||
.fields
|
||||
.iter()
|
||||
.map(|(key, value)| (key.clone(), super::prost_json(value)))
|
||||
.collect(),
|
||||
))
|
||||
.ok()
|
||||
})
|
||||
.map_or(0, |value| value.len())
|
||||
}
|
||||
|
||||
fn truncated_struct(original: usize, limit: usize) -> Option<prost_types::Struct> {
|
||||
Some(prost_types::Struct {
|
||||
fields: BTreeMap::from([
|
||||
("_truncated".into(), prost_bool(true)),
|
||||
("original_json_bytes".into(), prost_number(original as f64)),
|
||||
("limit_bytes".into(), prost_number(limit as f64)),
|
||||
]),
|
||||
})
|
||||
}
|
||||
|
||||
fn prost_bool(value: bool) -> prost_types::Value {
|
||||
prost_types::Value {
|
||||
kind: Some(prost_types::value::Kind::BoolValue(value)),
|
||||
}
|
||||
}
|
||||
|
||||
fn prost_number(value: f64) -> prost_types::Value {
|
||||
prost_types::Value {
|
||||
kind: Some(prost_types::value::Kind::NumberValue(value)),
|
||||
}
|
||||
}
|
||||
|
||||
fn gate_mcp_resources(tool: &mut pb::ListMcpResourcesToolCall) {
|
||||
let Some(pb::list_mcp_resources_exec_result::Result::Success(success)) = tool
|
||||
.result
|
||||
.as_mut()
|
||||
.and_then(|result| result.result.as_mut())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if success
|
||||
.resources
|
||||
.iter()
|
||||
.any(|resource| resource.uri == "truncated:list-mcp-resources")
|
||||
{
|
||||
return;
|
||||
}
|
||||
let original = success.resources.len();
|
||||
success.resources.truncate(MCP_RESOURCE_LIMIT);
|
||||
for resource in &mut success.resources {
|
||||
if let Some(description) = resource.description.as_mut() {
|
||||
*description = truncate_text(
|
||||
"MCP resource description",
|
||||
description,
|
||||
MCP_RESOURCE_DESCRIPTION_LIMIT,
|
||||
);
|
||||
}
|
||||
}
|
||||
if success.resources.len() < original {
|
||||
success
|
||||
.resources
|
||||
.push(pb::list_mcp_resources_exec_result::McpResource {
|
||||
uri: "truncated:list-mcp-resources".into(),
|
||||
name: Some("truncated".into()),
|
||||
description: Some(truncation_notice(
|
||||
"ListMcpResources",
|
||||
MCP_TEXT_LIMIT,
|
||||
success.resources.len(),
|
||||
original,
|
||||
)),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn gate_mcp_resource(tool: &mut pb::ReadMcpResourceToolCall) {
|
||||
let Some(pb::read_mcp_resource_exec_result::Result::Success(success)) = tool
|
||||
.result
|
||||
.as_mut()
|
||||
.and_then(|result| result.result.as_mut())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
match success.content.as_mut() {
|
||||
Some(pb::read_mcp_resource_success::Content::Text(text)) => {
|
||||
*text = truncate_text("FetchMcpResource", text, MCP_TEXT_LIMIT);
|
||||
}
|
||||
Some(pb::read_mcp_resource_success::Content::Blob(blob))
|
||||
if blob.len() > MCP_BINARY_LIMIT =>
|
||||
{
|
||||
let notice =
|
||||
truncation_notice("FetchMcpResource blob", MCP_BINARY_LIMIT, 0, blob.len());
|
||||
success.content = Some(pb::read_mcp_resource_success::Content::Text(notice));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn gate_mcp_tools(tool: &mut pb::GetMcpToolsToolCall) {
|
||||
let Some(pb::get_mcp_tools_agent_result::Result::Success(success)) = tool
|
||||
.result
|
||||
.as_mut()
|
||||
.and_then(|result| result.result.as_mut())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
success.content = truncate_text("GetMcpTools", &success.content, MCP_TEXT_LIMIT);
|
||||
}
|
||||
|
||||
fn gate_web_fetch(tool: &mut pb::WebFetchToolCall) {
|
||||
let Some(pb::web_fetch_result::Result::Success(success)) = tool
|
||||
.result
|
||||
.as_mut()
|
||||
.and_then(|result| result.result.as_mut())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
success.markdown = truncate_text("WebFetch", &success.markdown, WEB_FETCH_LIMIT);
|
||||
}
|
||||
|
||||
fn gate_web_search(tool: &mut pb::WebSearchToolCall) {
|
||||
let Some(pb::web_search_result::Result::Success(success)) = tool
|
||||
.result
|
||||
.as_mut()
|
||||
.and_then(|result| result.result.as_mut())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
for reference in &mut success.references {
|
||||
reference.title =
|
||||
truncate_text("WebSearch title", &reference.title, WEB_SEARCH_TITLE_LIMIT);
|
||||
reference.chunk = truncate_text(
|
||||
"WebSearch snippet",
|
||||
&reference.chunk,
|
||||
WEB_SEARCH_SNIPPET_LIMIT,
|
||||
);
|
||||
}
|
||||
let original = web_search_bytes(&success.references);
|
||||
while success.references.len() > 1 && web_search_bytes(&success.references) > WEB_SEARCH_LIMIT {
|
||||
success.references.pop();
|
||||
}
|
||||
if original > WEB_SEARCH_LIMIT {
|
||||
let total = web_search_bytes(&success.references);
|
||||
if let Some(reference) = success.references.last_mut() {
|
||||
let other = total.saturating_sub(reference.chunk.len());
|
||||
let notice = truncation_notice(
|
||||
"WebSearch",
|
||||
WEB_SEARCH_LIMIT,
|
||||
WEB_SEARCH_LIMIT.saturating_sub(other),
|
||||
original,
|
||||
);
|
||||
let available = WEB_SEARCH_LIMIT.saturating_sub(other + notice.len() + 2);
|
||||
reference.chunk = format!(
|
||||
"{}\n\n{notice}",
|
||||
utf8_prefix(&reference.chunk, available).trim_end_matches('\n')
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn web_search_bytes(references: &[pb::WebSearchReference]) -> usize {
|
||||
references
|
||||
.iter()
|
||||
.map(|reference| reference.title.len() + reference.url.len() + reference.chunk.len())
|
||||
.sum()
|
||||
}
|
||||
|
||||
fn gate_generate_image(tool: &mut pb::GenerateImageToolCall) {
|
||||
let Some(pb::generate_image_result::Result::Success(success)) = tool
|
||||
.result
|
||||
.as_mut()
|
||||
.and_then(|result| result.result.as_mut())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if !success.image_data.trim().is_empty()
|
||||
&& !success
|
||||
.image_data
|
||||
.starts_with("[base64 image data omitted from replay; bytes=")
|
||||
{
|
||||
let original = success.image_data.trim().len();
|
||||
success.image_data = format!("[base64 image data omitted from replay; bytes={original}]");
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate_text(tool_name: &str, content: &str, limit: usize) -> String {
|
||||
if content.len() <= limit {
|
||||
return content.to_string();
|
||||
}
|
||||
let original = content.len();
|
||||
let mut shown = limit;
|
||||
loop {
|
||||
let notice = format!(
|
||||
"\n\n[truncated: {tool_name} result exceeded {limit} bytes; showing {shown} of {original} bytes]"
|
||||
);
|
||||
let available = limit.saturating_sub(notice.len());
|
||||
let kept = utf8_prefix(content, available);
|
||||
if kept.len() == shown {
|
||||
return format!("{}{notice}", kept.trim_end_matches('\n'));
|
||||
}
|
||||
shown = kept.len();
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate_edges(tool_name: &str, content: &str, limit: usize) -> String {
|
||||
if content.len() <= limit {
|
||||
return content.to_string();
|
||||
@@ -64,6 +673,12 @@ fn truncate_edges(tool_name: &str, content: &str, limit: usize) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn truncation_notice(tool_name: &str, limit: usize, shown: usize, original: usize) -> String {
|
||||
format!(
|
||||
"[truncated: {tool_name} result exceeded {limit} bytes; showing {shown} of {original} bytes]"
|
||||
)
|
||||
}
|
||||
|
||||
fn utf8_prefix(value: &str, limit: usize) -> &str {
|
||||
let mut end = limit.min(value.len());
|
||||
while end > 0 && !value.is_char_boundary(end) {
|
||||
@@ -84,33 +699,212 @@ fn utf8_suffix(value: &str, limit: usize) -> &str {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn shell_tool() -> pb::tool_call::Tool {
|
||||
pb::tool_call::Tool::ShellToolCall(pb::ShellToolCall::default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_output_keeps_both_ends_within_its_budget() {
|
||||
let mut content = format!("HEAD{}TAIL", " ".repeat(1024 * KIB));
|
||||
let mut tool = pb::tool_call::Tool::ShellToolCall(pb::ShellToolCall::default());
|
||||
|
||||
model_content(&shell_tool(), &mut content);
|
||||
tool_completion("Shell", &mut tool, &mut content);
|
||||
|
||||
assert!(content.len() <= SHELL_CONTENT_LIMIT);
|
||||
assert!(content.len() <= 128 * KIB);
|
||||
assert!(content.starts_with("HEAD"));
|
||||
assert!(content.ends_with("TAIL"));
|
||||
assert!(content.contains("omitted middle"));
|
||||
assert!(content.contains("[truncated: Shell result exceeded"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_shell_output_is_unchanged() {
|
||||
fn grep_limits_matches_per_file_total_bytes_and_adds_notice() {
|
||||
let matches = (0..150)
|
||||
.map(|line_number| pb::GrepContentMatch {
|
||||
line_number,
|
||||
content: "x".repeat(3 * KIB),
|
||||
..Default::default()
|
||||
})
|
||||
.collect();
|
||||
let mut tool = pb::tool_call::Tool::GrepToolCall(pb::GrepToolCall {
|
||||
result: Some(pb::GrepResult {
|
||||
result: Some(pb::grep_result::Result::Success(pb::GrepSuccess {
|
||||
workspace_results: std::collections::HashMap::from([(
|
||||
"workspace".into(),
|
||||
pb::GrepUnionResult {
|
||||
result: Some(pb::grep_union_result::Result::Content(
|
||||
pb::GrepContentResult {
|
||||
matches: vec![pb::GrepFileMatch {
|
||||
file: "large.txt".into(),
|
||||
matches,
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
)),
|
||||
},
|
||||
)]),
|
||||
..Default::default()
|
||||
})),
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
let mut model_content = "x".repeat(128 * KIB);
|
||||
|
||||
tool_completion("Grep", &mut tool, &mut model_content);
|
||||
|
||||
assert!(model_content.len() <= GREP_CONTENT_LIMIT);
|
||||
assert!(model_content.contains("[truncated: Grep result exceeded"));
|
||||
let pb::tool_call::Tool::GrepToolCall(tool) = tool else {
|
||||
unreachable!()
|
||||
};
|
||||
let Some(pb::grep_result::Result::Success(success)) =
|
||||
tool.result.clone().and_then(|result| result.result)
|
||||
else {
|
||||
panic!("expected grep success")
|
||||
};
|
||||
let result = success.workspace_results.get("workspace").unwrap();
|
||||
let Some(pb::grep_union_result::Result::Content(content)) = result.result.as_ref() else {
|
||||
panic!("expected grep content")
|
||||
};
|
||||
assert!(content.client_truncated);
|
||||
assert!(grep_content_bytes(&content.matches) <= GREP_CONTENT_LIMIT);
|
||||
assert!(content.matches[0].matches.len() <= GREP_MATCHES_PER_FILE + 1);
|
||||
assert!(content.matches[0]
|
||||
.matches
|
||||
.last()
|
||||
.unwrap()
|
||||
.content
|
||||
.contains("[truncated: Grep result exceeded"));
|
||||
|
||||
let once = tool.clone();
|
||||
let mut tool_enum = pb::tool_call::Tool::GrepToolCall(tool);
|
||||
let mut second_content = model_content.clone();
|
||||
tool_completion("Grep", &mut tool_enum, &mut second_content);
|
||||
let pb::tool_call::Tool::GrepToolCall(second) = tool_enum else {
|
||||
panic!("expected grep tool")
|
||||
};
|
||||
assert_eq!(second, once);
|
||||
assert_eq!(second_content, model_content);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_content_is_limited_and_marked() {
|
||||
let mut tool = pb::tool_call::Tool::ReadToolCall(pb::ReadToolCall {
|
||||
result: Some(pb::ReadToolResult {
|
||||
result: Some(pb::read_tool_result::Result::Success(pb::ReadToolSuccess {
|
||||
output: Some(pb::read_tool_success::Output::Content(
|
||||
"前".repeat(READ_CONTENT_LIMIT),
|
||||
)),
|
||||
..Default::default()
|
||||
})),
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
let mut content = "前".repeat(READ_CONTENT_LIMIT);
|
||||
|
||||
tool_completion("Read", &mut tool, &mut content);
|
||||
|
||||
assert!(content.len() <= READ_CONTENT_LIMIT);
|
||||
let pb::tool_call::Tool::ReadToolCall(tool) = tool else {
|
||||
unreachable!()
|
||||
};
|
||||
let Some(pb::read_tool_result::Result::Success(success)) =
|
||||
tool.result.and_then(|result| result.result)
|
||||
else {
|
||||
panic!("expected read success")
|
||||
};
|
||||
assert!(success.exceeded_limit);
|
||||
let Some(pb::read_tool_success::Output::Content(output)) = success.output else {
|
||||
panic!("expected text output")
|
||||
};
|
||||
assert!(output.len() <= READ_CONTENT_LIMIT);
|
||||
assert!(output.contains("[truncated: Read result exceeded"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_limits_items_text_and_structured_content() {
|
||||
let mut tool = pb::tool_call::Tool::McpToolCall(pb::McpToolCall {
|
||||
result: Some(pb::McpToolResult {
|
||||
result: Some(pb::mcp_tool_result::Result::Success(pb::McpSuccess {
|
||||
content: (0..25)
|
||||
.map(|_| pb::McpToolResultContentItem {
|
||||
content: Some(pb::mcp_tool_result_content_item::Content::Text(
|
||||
pb::McpTextContent {
|
||||
text: "x".repeat(4 * KIB),
|
||||
..Default::default()
|
||||
},
|
||||
)),
|
||||
})
|
||||
.collect(),
|
||||
structured_content: Some(prost_types::Struct {
|
||||
fields: BTreeMap::from([(
|
||||
"large".into(),
|
||||
prost_types::Value {
|
||||
kind: Some(prost_types::value::Kind::StringValue(
|
||||
"x".repeat(64 * KIB),
|
||||
)),
|
||||
},
|
||||
)]),
|
||||
}),
|
||||
..Default::default()
|
||||
})),
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
let mut content = "x".repeat(64 * KIB);
|
||||
let original = content.clone();
|
||||
|
||||
model_content(
|
||||
&pb::tool_call::Tool::ReadToolCall(pb::ReadToolCall::default()),
|
||||
&mut content,
|
||||
tool_completion("CallMcpTool", &mut tool, &mut content);
|
||||
|
||||
assert!(content.len() <= MCP_TEXT_LIMIT);
|
||||
let pb::tool_call::Tool::McpToolCall(tool) = tool else {
|
||||
unreachable!()
|
||||
};
|
||||
let Some(pb::mcp_tool_result::Result::Success(success)) =
|
||||
tool.result.and_then(|result| result.result)
|
||||
else {
|
||||
panic!("expected mcp success")
|
||||
};
|
||||
assert!(success.content.len() > MCP_CONTENT_ITEM_LIMIT);
|
||||
assert_eq!(
|
||||
success
|
||||
.structured_content
|
||||
.unwrap()
|
||||
.fields
|
||||
.get("_truncated")
|
||||
.unwrap()
|
||||
.kind,
|
||||
Some(prost_types::value::Kind::BoolValue(true))
|
||||
);
|
||||
assert!(success.content.iter().any(|item| matches!(
|
||||
item.content.as_ref(),
|
||||
Some(pb::mcp_tool_result_content_item::Content::Text(text))
|
||||
if text.text.contains("MCP content items exceeded")
|
||||
)));
|
||||
}
|
||||
|
||||
assert_eq!(content, original);
|
||||
#[test]
|
||||
fn edit_keeps_only_a_bounded_diff() {
|
||||
let mut tool = pb::tool_call::Tool::EditToolCall(pb::EditToolCall {
|
||||
result: Some(pb::EditResult {
|
||||
result: Some(pb::edit_result::Result::Success(pb::EditSuccess {
|
||||
diff_string: Some("d".repeat(16 * KIB)),
|
||||
before_full_file_content: Some("b".repeat(64 * KIB)),
|
||||
after_full_file_content: "a".repeat(64 * KIB),
|
||||
..Default::default()
|
||||
})),
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
let mut content = "x".repeat(64 * KIB);
|
||||
|
||||
tool_completion("StrReplace", &mut tool, &mut content);
|
||||
|
||||
assert!(content.len() <= PATCH_EDIT_RESULT_LIMIT);
|
||||
let pb::tool_call::Tool::EditToolCall(tool) = tool else {
|
||||
unreachable!()
|
||||
};
|
||||
let Some(pb::edit_result::Result::Success(success)) =
|
||||
tool.result.and_then(|result| result.result)
|
||||
else {
|
||||
panic!("expected edit success")
|
||||
};
|
||||
assert!(success.diff_string.unwrap().len() <= PATCH_EDIT_RESULT_LIMIT);
|
||||
assert!(success.before_full_file_content.is_none());
|
||||
assert!(success.after_full_file_content.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -139,31 +933,6 @@ mod tests {
|
||||
assert!(success.stderr.len() <= SHELL_STREAM_LIMIT);
|
||||
assert!(success.stderr.starts_with("ERROR_HEAD"));
|
||||
assert!(success.stderr.ends_with("ERROR_TAIL"));
|
||||
assert!(success.interleaved_output.unwrap().len() <= SHELL_CONTENT_LIMIT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_shell_streams_are_limited() {
|
||||
let mut message = pb::exec_client_message::Message::ShellResult(pb::ShellResult {
|
||||
result: Some(pb::shell_result::Result::Failure(pb::ShellFailure {
|
||||
stdout: "x".repeat(64 * KIB),
|
||||
stderr: "y".repeat(64 * KIB),
|
||||
interleaved_output: Some("z".repeat(64 * KIB)),
|
||||
..Default::default()
|
||||
})),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
exec_message(&mut message);
|
||||
|
||||
let pb::exec_client_message::Message::ShellResult(result) = message else {
|
||||
panic!("expected Shell result");
|
||||
};
|
||||
let Some(pb::shell_result::Result::Failure(failure)) = result.result else {
|
||||
panic!("expected Shell failure");
|
||||
};
|
||||
assert!(failure.stdout.len() <= SHELL_STREAM_LIMIT);
|
||||
assert!(failure.stderr.len() <= SHELL_STREAM_LIMIT);
|
||||
assert!(failure.interleaved_output.unwrap().len() <= SHELL_CONTENT_LIMIT);
|
||||
assert!(success.interleaved_output.unwrap().len() <= SHELL_INTERLEAVED_LIMIT);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
mod await_shell;
|
||||
mod exec;
|
||||
mod gate;
|
||||
mod interaction;
|
||||
@@ -19,7 +18,6 @@ use crate::{
|
||||
|
||||
use super::runtime::now_ms;
|
||||
|
||||
pub(crate) use await_shell::{await_error, await_result, await_sleep};
|
||||
pub(crate) use exec::{edit_failure, from_exec};
|
||||
pub(crate) use interaction::{complete_web_fetch, complete_web_search, from_interaction};
|
||||
pub(crate) use local::{local, subagents_disabled, todo_items};
|
||||
@@ -89,9 +87,12 @@ impl ToolCompletion {
|
||||
call: &ToolCall,
|
||||
started_at_ms: u64,
|
||||
mut result: ToolResult,
|
||||
tool: pb::tool_call::Tool,
|
||||
mut tool: pb::tool_call::Tool,
|
||||
) -> Self {
|
||||
gate::model_content(&tool, &mut result.content);
|
||||
// Apply the model-visible size gate once, at the tool completion
|
||||
// boundary. Canonical history and every provider projection then
|
||||
// carry the same bounded result without reprocessing it.
|
||||
gate::tool_completion(&call.name, &mut tool, &mut result.content);
|
||||
Self {
|
||||
result,
|
||||
tool_call: pb::ToolCall {
|
||||
|
||||
@@ -4,7 +4,6 @@ use std::{
|
||||
atomic::{AtomicU32, Ordering},
|
||||
Arc,
|
||||
},
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
use tokio::sync::Mutex;
|
||||
@@ -36,14 +35,6 @@ pub(crate) enum ExecStage {
|
||||
DynamicMcp(pb::McpToolDefinition),
|
||||
EditRead,
|
||||
EditWrite(EditWrite),
|
||||
Await(AwaitState),
|
||||
}
|
||||
|
||||
pub(crate) struct AwaitState {
|
||||
pub deadline: Instant,
|
||||
pub output_file_path: String,
|
||||
pub task_id: String,
|
||||
pub regex: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
@@ -172,60 +163,6 @@ impl CursorToolRuntime {
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn reserve_await(
|
||||
&self,
|
||||
call: &ToolCall,
|
||||
context: &ExecContext,
|
||||
) -> Result<u32> {
|
||||
let task_id = call
|
||||
.arguments
|
||||
.get("shell_id")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.ok_or_else(|| Error::Protocol("AwaitShell is missing shell_id".into()))?;
|
||||
let block_ms = call
|
||||
.arguments
|
||||
.get("block_until_ms")
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
.unwrap_or(30_000);
|
||||
if block_ms > 7_140_000 {
|
||||
return Err(Error::Protocol(
|
||||
"AwaitShell block_until_ms exceeds 7140000".into(),
|
||||
));
|
||||
}
|
||||
let output_file_path = format!(
|
||||
"{}/{}.txt",
|
||||
context.terminals_folder.trim_end_matches('/'),
|
||||
task_id
|
||||
);
|
||||
self.reserve_exec_stage(
|
||||
call,
|
||||
context,
|
||||
ExecStage::Await(AwaitState {
|
||||
deadline: Instant::now() + std::time::Duration::from_millis(block_ms),
|
||||
output_file_path,
|
||||
task_id: task_id.to_string(),
|
||||
regex: call
|
||||
.arguments
|
||||
.get("pattern")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::to_string),
|
||||
}),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn reserve_await_again(
|
||||
&self,
|
||||
call: &ToolCall,
|
||||
context: &ExecContext,
|
||||
state: AwaitState,
|
||||
started_at_ms: u64,
|
||||
) -> Result<u32> {
|
||||
self.reserve_exec_stage(call, context, ExecStage::Await(state), Some(started_at_ms))
|
||||
.await
|
||||
}
|
||||
|
||||
async fn reserve_exec_stage(
|
||||
&self,
|
||||
call: &ToolCall,
|
||||
|
||||
@@ -181,6 +181,11 @@ impl ModelConfig {
|
||||
|
||||
pub fn configure(&self, model: &mut super::ModelSpec) {
|
||||
model.display_name = Some(self.display_name.clone());
|
||||
// A request-selected context is authoritative. Use the saved model
|
||||
// value only when Cursor did not send a context parameter.
|
||||
if model.context_window_tokens.is_none() {
|
||||
model.context_window_tokens = self.context_window_tokens;
|
||||
}
|
||||
if model.reasoning.effort.is_none() {
|
||||
model.reasoning.effort = match self.model_type {
|
||||
ModelType::OpenAi => self.reasoning_effort.clone(),
|
||||
@@ -505,4 +510,77 @@ mod tests {
|
||||
"https://example.com/v1/messages"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configured_context_window_does_not_override_the_client_request() {
|
||||
let input = input();
|
||||
let config = ModelConfig {
|
||||
model_hash: "hash".into(),
|
||||
sort_order: input.sort_order,
|
||||
display_name: input.display_name,
|
||||
model_type: input.model_type,
|
||||
base_url: input.base_url,
|
||||
use_full_url: input.use_full_url,
|
||||
api_key: input.api_key,
|
||||
tooltip_data: input.tooltip_data,
|
||||
model_id: input.model_id,
|
||||
reasoning_effort: input.reasoning_effort,
|
||||
openai_endpoint: input.openai_endpoint,
|
||||
openai_extra_params_enabled: input.openai_extra_params_enabled,
|
||||
openai_extra_params: input.openai_extra_params,
|
||||
custom_headers_enabled: input.custom_headers_enabled,
|
||||
custom_headers: input.custom_headers,
|
||||
anthropic_extra_params_enabled: input.anthropic_extra_params_enabled,
|
||||
anthropic_extra_params: input.anthropic_extra_params,
|
||||
context_window_tokens: Some(350_000),
|
||||
max_completion_tokens: input.max_completion_tokens,
|
||||
anthropic_max_tokens: input.anthropic_max_tokens,
|
||||
anthropic_thinking_effort: input.anthropic_thinking_effort,
|
||||
thinking_budget_tokens: input.thinking_budget_tokens,
|
||||
created_at_ms: 0,
|
||||
updated_at_ms: 0,
|
||||
};
|
||||
let mut requested = super::super::ModelSpec::new("model-a");
|
||||
requested.context_window_tokens = Some(200_000);
|
||||
|
||||
config.configure(&mut requested);
|
||||
|
||||
assert_eq!(requested.context_window_tokens, Some(200_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configured_context_window_fills_missing_client_value() {
|
||||
let input = input();
|
||||
let config = ModelConfig {
|
||||
model_hash: "hash".into(),
|
||||
sort_order: input.sort_order,
|
||||
display_name: input.display_name,
|
||||
model_type: input.model_type,
|
||||
base_url: input.base_url,
|
||||
use_full_url: input.use_full_url,
|
||||
api_key: input.api_key,
|
||||
tooltip_data: input.tooltip_data,
|
||||
model_id: input.model_id,
|
||||
reasoning_effort: input.reasoning_effort,
|
||||
openai_endpoint: input.openai_endpoint,
|
||||
openai_extra_params_enabled: input.openai_extra_params_enabled,
|
||||
openai_extra_params: input.openai_extra_params,
|
||||
custom_headers_enabled: input.custom_headers_enabled,
|
||||
custom_headers: input.custom_headers,
|
||||
anthropic_extra_params_enabled: input.anthropic_extra_params_enabled,
|
||||
anthropic_extra_params: input.anthropic_extra_params,
|
||||
context_window_tokens: Some(350_000),
|
||||
max_completion_tokens: input.max_completion_tokens,
|
||||
anthropic_max_tokens: input.anthropic_max_tokens,
|
||||
anthropic_thinking_effort: input.anthropic_thinking_effort,
|
||||
thinking_budget_tokens: input.thinking_budget_tokens,
|
||||
created_at_ms: 0,
|
||||
updated_at_ms: 0,
|
||||
};
|
||||
let mut requested = super::super::ModelSpec::new("model-a");
|
||||
|
||||
config.configure(&mut requested);
|
||||
|
||||
assert_eq!(requested.context_window_tokens, Some(350_000));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ mod run;
|
||||
mod runtime_tag;
|
||||
mod token_count;
|
||||
mod tool;
|
||||
mod tool_result_replay;
|
||||
mod usage;
|
||||
|
||||
pub use configuration::*;
|
||||
@@ -26,4 +27,5 @@ pub use run::*;
|
||||
pub use runtime_tag::*;
|
||||
pub(crate) use token_count::*;
|
||||
pub use tool::*;
|
||||
pub(crate) use tool_result_replay::limit_tool_result_text;
|
||||
pub use usage::*;
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
use serde_json::Value;
|
||||
|
||||
const KIB: usize = 1024;
|
||||
|
||||
pub(crate) fn limit_tool_result_text(name: &str, content: &str) -> String {
|
||||
let Some(limit) = replay_limit(name) else {
|
||||
return content.to_string();
|
||||
};
|
||||
let content = match name.trim() {
|
||||
"GenerateImage" => compact_generate_image(content),
|
||||
"Shell" => compact_shell(content),
|
||||
"PatchEdit" | "PatchEditLines" | "PatchEditSpan" | "StrReplace" | "Edit" | "Write" => {
|
||||
compact_edit(name, content)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
.unwrap_or_else(|| content.to_string());
|
||||
truncate_replay_text(name, &content, limit)
|
||||
}
|
||||
|
||||
fn replay_limit(name: &str) -> Option<usize> {
|
||||
match name.trim() {
|
||||
"GenerateImage" | "WebSearch" => Some(16 * KIB),
|
||||
"Read" => Some(64 * KIB),
|
||||
"Shell" => Some(128 * KIB),
|
||||
"Grep" | "Glob" => Some(32 * KIB),
|
||||
"PatchEdit" | "PatchEditLines" | "PatchEditSpan" | "StrReplace" => Some(4 * KIB),
|
||||
"Edit" | "EditNotebook" | "Write" | "WebFetch" => Some(32 * KIB),
|
||||
"CallMcpTool" | "FetchMcpResource" | "ListMcpResources" | "GetMcpTools"
|
||||
| "SembleSearch" | "SembleFindRelated" => Some(32 * KIB),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate_replay_text(name: &str, content: &str, limit: usize) -> String {
|
||||
if content.len() <= limit {
|
||||
return content.to_string();
|
||||
}
|
||||
let original = content.len();
|
||||
let mut shown = limit;
|
||||
loop {
|
||||
let notice = format!(
|
||||
"\n\n[truncated: {name} result exceeded {limit} bytes; showing {shown} of {original} bytes]"
|
||||
);
|
||||
let available = limit.saturating_sub(notice.len());
|
||||
let kept = utf8_prefix(content, available);
|
||||
if kept.len() == shown {
|
||||
return format!("{}{notice}", kept.trim_end_matches('\n'));
|
||||
}
|
||||
shown = kept.len();
|
||||
}
|
||||
}
|
||||
|
||||
fn compact_generate_image(content: &str) -> Option<String> {
|
||||
let mut value = serde_json::from_str::<Value>(content.trim()).ok()?;
|
||||
if !replace_image_data(&mut value) {
|
||||
return None;
|
||||
}
|
||||
serde_json::to_string(&value).ok()
|
||||
}
|
||||
|
||||
fn replace_image_data(value: &mut Value) -> bool {
|
||||
match value {
|
||||
Value::Object(object) => {
|
||||
let mut changed = false;
|
||||
for (key, child) in object.iter_mut() {
|
||||
if matches!(key.as_str(), "image_data" | "imageData") {
|
||||
if let Value::String(data) = child {
|
||||
if data.starts_with("[base64 image data omitted from replay; bytes=") {
|
||||
continue;
|
||||
}
|
||||
*child = Value::String(format!(
|
||||
"[base64 image data omitted from replay; bytes={}]",
|
||||
data.trim().len()
|
||||
));
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
changed |= replace_image_data(child);
|
||||
}
|
||||
changed
|
||||
}
|
||||
Value::Array(items) => items.iter_mut().any(replace_image_data),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn compact_shell(content: &str) -> Option<String> {
|
||||
let mut value = serde_json::from_str::<Value>(content.trim()).ok()?;
|
||||
if !compact_shell_fields(&mut value) {
|
||||
return None;
|
||||
}
|
||||
serde_json::to_string(&value).ok()
|
||||
}
|
||||
|
||||
fn compact_shell_fields(value: &mut Value) -> bool {
|
||||
match value {
|
||||
Value::Object(object) => {
|
||||
let mut changed = false;
|
||||
for (key, child) in object.iter_mut() {
|
||||
if let Value::String(text) = child {
|
||||
let limit = match key.as_str() {
|
||||
"stdout" | "stderr" => Some(16 * KIB),
|
||||
"interleaved_output" | "interleavedOutput" => Some(32 * KIB),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(limit) = limit {
|
||||
let next = truncate_middle(&format!("Shell {key}"), text, limit);
|
||||
if next != *text {
|
||||
*text = next;
|
||||
changed = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
changed |= compact_shell_fields(child);
|
||||
}
|
||||
changed
|
||||
}
|
||||
Value::Array(items) => items.iter_mut().any(compact_shell_fields),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn compact_edit(name: &str, content: &str) -> Option<String> {
|
||||
let value = serde_json::from_str::<Value>(content.trim()).ok()?;
|
||||
let success = value.get("success")?.as_object()?;
|
||||
let diff = success
|
||||
.get("diff_string")
|
||||
.or_else(|| success.get("diffString"))
|
||||
.and_then(Value::as_str)
|
||||
.filter(|text| !text.is_empty())
|
||||
.map(|text| truncate_replay_text(name, text, edit_limit(name)));
|
||||
if let Some(diff) = diff {
|
||||
return Some(serde_json::json!({"success": {"diff_string": diff}}).to_string());
|
||||
}
|
||||
let after = success
|
||||
.get("after_full_file_content")
|
||||
.or_else(|| success.get("afterFullFileContent"))
|
||||
.and_then(Value::as_str)
|
||||
.filter(|text| !text.is_empty())
|
||||
.map(|text| truncate_replay_text(name, text, edit_limit(name)));
|
||||
after
|
||||
.map(|after| serde_json::json!({"success": {"after_full_file_content": after}}).to_string())
|
||||
}
|
||||
|
||||
fn edit_limit(name: &str) -> usize {
|
||||
match name.trim() {
|
||||
"PatchEdit" | "PatchEditLines" | "PatchEditSpan" | "StrReplace" => 4 * KIB,
|
||||
_ => 32 * KIB,
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate_middle(name: &str, content: &str, limit: usize) -> String {
|
||||
if content.len() <= limit {
|
||||
return content.to_string();
|
||||
}
|
||||
let original = content.len();
|
||||
let mut shown = limit;
|
||||
loop {
|
||||
let notice = format!(
|
||||
"\n\n[truncated: {name} result exceeded {limit} bytes; omitted middle; showing {shown} of {original} bytes]\n\n"
|
||||
);
|
||||
let available = limit.saturating_sub(notice.len());
|
||||
let head = utf8_prefix(content, available / 2);
|
||||
let tail = utf8_suffix(content, available.saturating_sub(head.len()));
|
||||
let next_shown = head.len() + tail.len();
|
||||
let next_notice = format!(
|
||||
"\n\n[truncated: {name} result exceeded {limit} bytes; omitted middle; showing {next_shown} of {original} bytes]\n\n"
|
||||
);
|
||||
let output = format!("{head}{next_notice}{tail}");
|
||||
if output.len() <= limit || next_notice == notice {
|
||||
return output;
|
||||
}
|
||||
shown = next_shown;
|
||||
}
|
||||
}
|
||||
|
||||
fn utf8_prefix(value: &str, limit: usize) -> &str {
|
||||
let mut end = limit.min(value.len());
|
||||
while end > 0 && !value.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
&value[..end]
|
||||
}
|
||||
|
||||
fn utf8_suffix(value: &str, limit: usize) -> &str {
|
||||
let mut start = value.len().saturating_sub(limit);
|
||||
while start < value.len() && !value.is_char_boundary(start) {
|
||||
start += 1;
|
||||
}
|
||||
&value[start..]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn truncation_preserves_utf8_and_limit() {
|
||||
let content = "前".repeat(32 * KIB);
|
||||
let truncated = truncate_replay_text("Grep", &content, 32 * KIB);
|
||||
|
||||
assert!(truncated.len() <= 32 * KIB);
|
||||
assert!(truncated.is_char_boundary(truncated.len()));
|
||||
assert!(truncated.contains("[truncated: Grep result exceeded"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_replay_compacts_nested_image_data_and_shell_streams() {
|
||||
let image = serde_json::json!({"success": {"image_data": "x".repeat(64 * KIB)}});
|
||||
let image_result = limit_tool_result_text("GenerateImage", &image.to_string());
|
||||
assert!(image_result.contains("base64 image data omitted"));
|
||||
assert!(image_result.len() < 1024);
|
||||
assert_eq!(
|
||||
limit_tool_result_text("GenerateImage", &image_result),
|
||||
image_result
|
||||
);
|
||||
|
||||
let shell = serde_json::json!({"success": {"stdout": "x".repeat(64 * KIB)}});
|
||||
let shell_result = limit_tool_result_text("Shell", &shell.to_string());
|
||||
assert!(shell_result.len() <= 128 * KIB);
|
||||
assert!(shell_result.contains("omitted middle"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user