mirror of
https://wget.la/https://github.com/leookun/cursor-byok
synced 2026-10-07 23:03:43 +08:00
Fix compaction projection and MCP image truncation
This commit is contained in:
@@ -403,18 +403,11 @@ fn gate_mcp(tool: &mut pb::McpToolCall) {
|
|||||||
text.text = truncate_text("MCP text", &next, remaining_text);
|
text.text = truncate_text("MCP text", &next, remaining_text);
|
||||||
remaining_text = remaining_text.saturating_sub(text.text.len());
|
remaining_text = remaining_text.saturating_sub(text.text.len());
|
||||||
}
|
}
|
||||||
Some(pb::mcp_tool_result_content_item::Content::Image(image))
|
// MCP images are sent to the client as inline binary data. Truncating
|
||||||
if image.data.len() > MCP_BINARY_LIMIT =>
|
// an encoded image at an arbitrary byte boundary corrupts the image
|
||||||
{
|
// and makes the client's image/screenshot fallback fail. The model
|
||||||
let original = image.data.len();
|
// receives only the textual MCP summary below, which is bounded by
|
||||||
image.data.truncate(MCP_BINARY_LIMIT);
|
// MCP_TEXT_LIMIT, so the image does not need this text-result gate.
|
||||||
notices.push(truncation_notice(
|
|
||||||
"MCP image data",
|
|
||||||
MCP_BINARY_LIMIT,
|
|
||||||
image.data.len(),
|
|
||||||
original,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
content.push(item);
|
content.push(item);
|
||||||
@@ -876,6 +869,49 @@ mod tests {
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mcp_images_are_not_truncated_at_an_invalid_binary_boundary() {
|
||||||
|
let image_data = (0..(MCP_BINARY_LIMIT + 1))
|
||||||
|
.map(|value| (value % 251) as u8)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let original_image_data = image_data.clone();
|
||||||
|
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: vec![pb::McpToolResultContentItem {
|
||||||
|
content: Some(pb::mcp_tool_result_content_item::Content::Image(
|
||||||
|
pb::McpImageContent {
|
||||||
|
data: image_data,
|
||||||
|
mime_type: "image/png".into(),
|
||||||
|
},
|
||||||
|
)),
|
||||||
|
}],
|
||||||
|
..Default::default()
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
let mut content = "MCP image".into();
|
||||||
|
|
||||||
|
tool_completion("CallMcpTool", &mut tool, &mut content);
|
||||||
|
|
||||||
|
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")
|
||||||
|
};
|
||||||
|
let Some(pb::mcp_tool_result_content_item::Content::Image(image)) =
|
||||||
|
success.content[0].content.as_ref()
|
||||||
|
else {
|
||||||
|
panic!("expected mcp image")
|
||||||
|
};
|
||||||
|
assert_eq!(image.data, original_image_data);
|
||||||
|
assert!(!success.content.iter().any(is_mcp_notice));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn edit_keeps_only_a_bounded_diff() {
|
fn edit_keeps_only_a_bounded_diff() {
|
||||||
let mut tool = pb::tool_call::Tool::EditToolCall(pb::EditToolCall {
|
let mut tool = pb::tool_call::Tool::EditToolCall(pb::EditToolCall {
|
||||||
|
|||||||
+106
-15
@@ -194,7 +194,12 @@ impl RunEngine {
|
|||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
if !auto_compacted && should_auto_compact(prepared, &messages, context_anchor) {
|
let history = match crate::model::project_messages(&messages) {
|
||||||
|
Ok(history) => history,
|
||||||
|
Err(error) => return (RunOutcome::Failed(error.into()), usage),
|
||||||
|
};
|
||||||
|
if !auto_compacted && should_auto_compact(prepared, &messages, &history, context_anchor)
|
||||||
|
{
|
||||||
auto_compacted = true;
|
auto_compacted = true;
|
||||||
match self
|
match self
|
||||||
.auto_compact(prepared, revision, &messages, client, cancellation)
|
.auto_compact(prepared, revision, &messages, client, cancellation)
|
||||||
@@ -219,10 +224,7 @@ impl RunEngine {
|
|||||||
revision_id = revision.0,
|
revision_id = revision.0,
|
||||||
"starting model call"
|
"starting model call"
|
||||||
);
|
);
|
||||||
let mut history = match crate::model::project_messages(&messages) {
|
let mut history = history;
|
||||||
Ok(history) => history,
|
|
||||||
Err(error) => return (RunOutcome::Failed(error.into()), usage),
|
|
||||||
};
|
|
||||||
if let Err(error) = hydrate_tool_images(&self.store, &mut history).await {
|
if let Err(error) = hydrate_tool_images(&self.store, &mut history).await {
|
||||||
return (RunOutcome::Failed(error.into()), usage);
|
return (RunOutcome::Failed(error.into()), usage);
|
||||||
}
|
}
|
||||||
@@ -735,6 +737,7 @@ impl ContextUsageAnchor {
|
|||||||
fn should_auto_compact(
|
fn should_auto_compact(
|
||||||
prepared: &PreparedRun,
|
prepared: &PreparedRun,
|
||||||
messages: &[CanonicalMessage],
|
messages: &[CanonicalMessage],
|
||||||
|
projected_messages: &[crate::model::ProjectedMessage],
|
||||||
anchor: Option<ContextUsageAnchor>,
|
anchor: Option<ContextUsageAnchor>,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
if prepared.action != RunAction::Start {
|
if prepared.action != RunAction::Start {
|
||||||
@@ -750,13 +753,13 @@ fn should_auto_compact(
|
|||||||
}
|
}
|
||||||
let estimated_input = anchor
|
let estimated_input = anchor
|
||||||
.filter(|anchor| {
|
.filter(|anchor| {
|
||||||
anchor.message_count <= messages.len()
|
anchor.message_count <= projected_messages.len()
|
||||||
&& anchor.tool_count == prepared.prompt.tools.len()
|
&& anchor.tool_count == prepared.prompt.tools.len()
|
||||||
})
|
})
|
||||||
.map(|anchor| {
|
.map(|anchor| {
|
||||||
anchor
|
anchor.input_tokens.saturating_add(estimate_message_tokens(
|
||||||
.input_tokens
|
&projected_messages[anchor.message_count..],
|
||||||
.saturating_add(estimate_message_tokens(&messages[anchor.message_count..]))
|
))
|
||||||
})
|
})
|
||||||
.unwrap_or_else(|| estimate_context_tokens(&prepared.prompt, messages));
|
.unwrap_or_else(|| estimate_context_tokens(&prepared.prompt, messages));
|
||||||
estimated_input > context_window.saturating_sub(COMPACTION_RESERVE_TOKENS)
|
estimated_input > context_window.saturating_sub(COMPACTION_RESERVE_TOKENS)
|
||||||
@@ -770,7 +773,7 @@ fn estimate_context_tokens(
|
|||||||
estimate_serialized_tokens(&serialized)
|
estimate_serialized_tokens(&serialized)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn estimate_message_tokens(messages: &[CanonicalMessage]) -> u64 {
|
fn estimate_message_tokens<T: serde::Serialize>(messages: &[T]) -> u64 {
|
||||||
let serialized = serde_json::to_string(messages).unwrap_or_default();
|
let serialized = serde_json::to_string(messages).unwrap_or_default();
|
||||||
estimate_serialized_tokens(&serialized)
|
estimate_serialized_tokens(&serialized)
|
||||||
}
|
}
|
||||||
@@ -942,9 +945,10 @@ mod tests {
|
|||||||
};
|
};
|
||||||
use crate::{
|
use crate::{
|
||||||
model::{
|
model::{
|
||||||
CanonicalMessage, ContentPart, ConversationId, ModelSpec, Origin, PreparedRun,
|
CanonicalMessage, ContentPart, ConversationId, MessageContent, ModelSpec, Origin,
|
||||||
ProjectedContent, ProjectedMessage, PromptSpec, RevisionId, Role, RunAction, RunId,
|
PreparedRun, ProjectedContent, ProjectedMessage, PromptSpec, RevisionId, Role,
|
||||||
RunKind, ToolImageReference, ToolResultContent,
|
RunAction, RunId, RunKind, ToolCallContent, ToolImageReference, ToolResultContent,
|
||||||
|
ToolRoundId,
|
||||||
},
|
},
|
||||||
store::Store,
|
store::Store,
|
||||||
};
|
};
|
||||||
@@ -1011,7 +1015,13 @@ mod tests {
|
|||||||
estimate_context_tokens(&prepared.prompt, &messages),
|
estimate_context_tokens(&prepared.prompt, &messages),
|
||||||
190_813
|
190_813
|
||||||
);
|
);
|
||||||
assert!(!should_auto_compact(&prepared, &messages, Some(anchor)));
|
let projected = crate::model::project_messages(&messages).unwrap();
|
||||||
|
assert!(!should_auto_compact(
|
||||||
|
&prepared,
|
||||||
|
&messages,
|
||||||
|
&projected,
|
||||||
|
Some(anchor)
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1048,7 +1058,88 @@ mod tests {
|
|||||||
tool_count: 0,
|
tool_count: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
assert!(should_auto_compact(&prepared, &messages, Some(anchor)));
|
let projected = crate::model::project_messages(&messages).unwrap();
|
||||||
|
assert!(should_auto_compact(
|
||||||
|
&prepared,
|
||||||
|
&messages,
|
||||||
|
&projected,
|
||||||
|
Some(anchor)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn projected_anchor_does_not_recount_canonical_tool_round_fragments() {
|
||||||
|
let assistant = |message_id: &str, call_id: &str, text: String, index| CanonicalMessage {
|
||||||
|
message_id: message_id.into(),
|
||||||
|
role: Role::Assistant,
|
||||||
|
origin: Origin::Assistant,
|
||||||
|
content: MessageContent::Assistant {
|
||||||
|
text,
|
||||||
|
thinking: String::new(),
|
||||||
|
tool_round_id: Some(ToolRoundId::new("round")),
|
||||||
|
replay_state: None,
|
||||||
|
tool_calls: vec![ToolCallContent {
|
||||||
|
index,
|
||||||
|
call_id: call_id.into(),
|
||||||
|
name: "Shell".into(),
|
||||||
|
arguments: serde_json::json!({}),
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
runtime_event_id: None,
|
||||||
|
};
|
||||||
|
let result = |message_id: &str, call_id: &str| CanonicalMessage {
|
||||||
|
message_id: message_id.into(),
|
||||||
|
role: Role::Tool,
|
||||||
|
origin: Origin::Tool,
|
||||||
|
content: MessageContent::ToolResult(ToolResultContent {
|
||||||
|
call_id: call_id.into(),
|
||||||
|
name: "Shell".into(),
|
||||||
|
content: "ok".into(),
|
||||||
|
is_error: false,
|
||||||
|
image: None,
|
||||||
|
provider_parts: Vec::new(),
|
||||||
|
}),
|
||||||
|
runtime_event_id: None,
|
||||||
|
};
|
||||||
|
let messages = vec![
|
||||||
|
assistant("assistant-1", "call-1", "first".into(), 0),
|
||||||
|
result("result-1", "call-1"),
|
||||||
|
assistant("assistant-2", "call-2", "x".repeat(100_000), 1),
|
||||||
|
result("result-2", "call-2"),
|
||||||
|
];
|
||||||
|
let projected = crate::model::project_messages(&messages).unwrap();
|
||||||
|
assert_eq!(messages.len(), 4);
|
||||||
|
assert_eq!(projected.len(), 3);
|
||||||
|
|
||||||
|
let prepared = PreparedRun {
|
||||||
|
run_id: RunId::new("run"),
|
||||||
|
cursor_request_id: None,
|
||||||
|
conversation_id: ConversationId::new("conversation"),
|
||||||
|
kind: RunKind::Root,
|
||||||
|
model: ModelSpec {
|
||||||
|
context_window_tokens: Some(20_000),
|
||||||
|
..ModelSpec::new("model")
|
||||||
|
},
|
||||||
|
prompt: PromptSpec {
|
||||||
|
instructions: "system".into(),
|
||||||
|
tools: Vec::new(),
|
||||||
|
},
|
||||||
|
initial_messages: Vec::new(),
|
||||||
|
action: RunAction::Start,
|
||||||
|
base_revision_id: RevisionId(1),
|
||||||
|
};
|
||||||
|
let anchor = ContextUsageAnchor {
|
||||||
|
input_tokens: 1_000,
|
||||||
|
message_count: 1,
|
||||||
|
tool_count: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(!should_auto_compact(
|
||||||
|
&prepared,
|
||||||
|
&messages,
|
||||||
|
&projected,
|
||||||
|
Some(anchor)
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
Reference in New Issue
Block a user