mirror of
https://wget.la/https://github.com/leookun/cursor-byok
synced 2026-10-03 18:23:51 +08:00
Merge branch 'main' of github.com:leookun/cursor-byok
This commit is contained in:
Generated
+1
-1
@@ -1172,7 +1172,7 @@ checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
|
||||
|
||||
[[package]]
|
||||
name = "cursor-byok-desktop"
|
||||
version = "0.1.6"
|
||||
version = "0.1.7"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"cursor-server",
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "cursor-byok-desktop",
|
||||
"version": "0.1.6",
|
||||
"version": "0.1.7",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "cursor-byok-desktop",
|
||||
"version": "0.1.6",
|
||||
"version": "0.1.7",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.8.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cursor-byok-desktop",
|
||||
"version": "0.1.6",
|
||||
"version": "0.1.7",
|
||||
"description": "Cursor BYOK desktop management application",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cursor-byok-desktop"
|
||||
version = "0.1.6"
|
||||
version = "0.1.7"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Cursor BYOK",
|
||||
"version": "0.1.6",
|
||||
"version": "0.1.7",
|
||||
"identifier": "dev.cursorbyok.desktop",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
@@ -6,6 +6,7 @@ import cursorIconUrl from "../shared/assets/icons/cursor.svg";
|
||||
import { api } from "../shared/api";
|
||||
import { AdMenu } from "./ads/AdMenu";
|
||||
import { FloatingAd } from "./ads/FloatingAd";
|
||||
import { loadCachedAds, saveCachedAds } from "./ads/cache";
|
||||
import { AdActionType, type AdAction, type AdSlot } from "./ads/types";
|
||||
import { PageLayout } from "./layout/PageLayout";
|
||||
import { Card } from "../shared/ui/Card";
|
||||
@@ -50,7 +51,7 @@ export function AppLayout() {
|
||||
const location = useLocation();
|
||||
const [leftActionTarget, setLeftActionTarget] = useState<HTMLDivElement | null>(null);
|
||||
const [rightActionTarget, setRightActionTarget] = useState<HTMLDivElement | null>(null);
|
||||
const [ads, setAds] = useState<AdSlot[]>([]);
|
||||
const [ads, setAds] = useState<AdSlot[]>(() => loadCachedAds());
|
||||
const [activeAd, setActiveAd] = useState<AdSlot | null>(null);
|
||||
const [dismissCandidate, setDismissCandidate] = useState<AdSlot | null>(null);
|
||||
const [dismissReason, setDismissReason] = useState("");
|
||||
@@ -98,6 +99,8 @@ export function AppLayout() {
|
||||
let disposed = false;
|
||||
let pending = false;
|
||||
let lastRequestedAt = 0;
|
||||
setAds(loadCachedAds());
|
||||
setActiveAd(null);
|
||||
const refreshAds = () => {
|
||||
const now = Date.now();
|
||||
if (pending || now - lastRequestedAt < 10_000) return;
|
||||
@@ -105,6 +108,7 @@ export function AppLayout() {
|
||||
lastRequestedAt = now;
|
||||
void api.ads(dismissedAdIdsRef.current, locale)
|
||||
.then((runtime) => {
|
||||
saveCachedAds(runtime.slots);
|
||||
if (disposed) return;
|
||||
setAds(runtime.slots);
|
||||
setActiveAd((current) => current
|
||||
@@ -112,9 +116,7 @@ export function AppLayout() {
|
||||
: null);
|
||||
})
|
||||
.catch(() => {
|
||||
if (disposed) return;
|
||||
setAds([]);
|
||||
setActiveAd(null);
|
||||
// Keep the current ads and local cache available when refreshing fails.
|
||||
})
|
||||
.finally(() => { pending = false; });
|
||||
};
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { AdSlot } from "./types";
|
||||
|
||||
const adCacheStorageKey = "cursor-byok:ad-runtime";
|
||||
|
||||
export function loadCachedAds(): AdSlot[] {
|
||||
try {
|
||||
const value: unknown = JSON.parse(localStorage.getItem(adCacheStorageKey) ?? "null");
|
||||
if (!value || typeof value !== "object" || !Array.isArray((value as { slots?: unknown }).slots)) return [];
|
||||
return (value as { slots: AdSlot[] }).slots;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function saveCachedAds(slots: AdSlot[]): void {
|
||||
try {
|
||||
localStorage.setItem(adCacheStorageKey, JSON.stringify({ slots }));
|
||||
} catch {
|
||||
// The current session still uses the fetched ads when storage is unavailable.
|
||||
}
|
||||
}
|
||||
@@ -517,11 +517,9 @@ fn gate_mcp_resources(tool: &mut pb::ListMcpResourcesToolCall) {
|
||||
.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,
|
||||
description: Some(format!(
|
||||
"[truncated: ListMcpResources result exceeded {MCP_RESOURCE_LIMIT} resources; showing {} of {original} resources]",
|
||||
success.resources.len()
|
||||
)),
|
||||
..Default::default()
|
||||
});
|
||||
@@ -861,4 +859,39 @@ mod tests {
|
||||
let mut content = String::new();
|
||||
tool_completion("Grep", &mut tool, &mut content);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_mcp_resources_reports_the_cap_it_actually_applied() {
|
||||
let resources = (0..MCP_RESOURCE_LIMIT + 50)
|
||||
.map(|index| pb::list_mcp_resources_exec_result::McpResource {
|
||||
uri: format!("mcp://resource/{index}"),
|
||||
..Default::default()
|
||||
})
|
||||
.collect();
|
||||
let mut tool = pb::ListMcpResourcesToolCall {
|
||||
args: None,
|
||||
result: Some(pb::ListMcpResourcesExecResult {
|
||||
result: Some(pb::list_mcp_resources_exec_result::Result::Success(
|
||||
pb::ListMcpResourcesSuccess { resources },
|
||||
)),
|
||||
}),
|
||||
};
|
||||
|
||||
gate_mcp_resources(&mut tool);
|
||||
|
||||
let pb::list_mcp_resources_exec_result::Result::Success(success) =
|
||||
tool.result.unwrap().result.unwrap()
|
||||
else {
|
||||
panic!("expected a successful result");
|
||||
};
|
||||
let notice = success.resources.last().unwrap();
|
||||
assert_eq!(notice.uri, "truncated:list-mcp-resources");
|
||||
assert_eq!(
|
||||
notice.description.as_deref(),
|
||||
Some(
|
||||
"[truncated: ListMcpResources result exceeded 200 resources; \
|
||||
showing 200 of 250 resources]"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,19 +153,7 @@ impl CursorHarness {
|
||||
}
|
||||
|
||||
pub async fn set_enabled(&self, enabled: bool) -> Result<CursorHarnessStatus> {
|
||||
let settings_applied = {
|
||||
let proxy = self.inner.proxy.lock().await;
|
||||
proxy
|
||||
.url()
|
||||
.as_deref()
|
||||
.map(settings::settings_match)
|
||||
.transpose()?
|
||||
.unwrap_or(false)
|
||||
};
|
||||
if enabled {
|
||||
if !settings_applied {
|
||||
process::terminate_cursor().await?;
|
||||
}
|
||||
self.inner.store.set_cursor_takeover_enabled(true).await?;
|
||||
self.enable().await?;
|
||||
} else {
|
||||
@@ -193,6 +181,15 @@ impl CursorHarness {
|
||||
.read()
|
||||
.ok_or_else(|| Error::Config("desktop management server is not ready".into()))?;
|
||||
let mut proxy = self.inner.proxy.lock().await;
|
||||
let settings_applied = proxy
|
||||
.url()
|
||||
.as_deref()
|
||||
.map(settings::settings_match)
|
||||
.transpose()?
|
||||
.unwrap_or(false);
|
||||
if !settings_applied {
|
||||
process::terminate_cursor().await?;
|
||||
}
|
||||
if proxy.running() {
|
||||
if let Some(url) = proxy.url() {
|
||||
apply_cursor_configuration(&url).await?;
|
||||
|
||||
+258
-17
@@ -5,22 +5,118 @@ use std::collections::HashSet;
|
||||
use crate::{
|
||||
model::{
|
||||
estimate_context_tokens, estimate_projected_messages_tokens, CanonicalMessage, PreparedRun,
|
||||
ProjectedMessage,
|
||||
ProjectedContent, ProjectedMessage, Role,
|
||||
},
|
||||
store::ContextUsageAnchor,
|
||||
};
|
||||
|
||||
const FALLBACK_CHARS: usize = 12_000;
|
||||
|
||||
pub(super) const RESERVE_TOKENS: u64 = 10_000;
|
||||
/// Fraction of the context window kept free, as a divisor: 10 = 10%.
|
||||
///
|
||||
/// The estimate runs behind the provider: the anchor is what the provider
|
||||
/// charged for the *previous* call, and the next request re-sends request
|
||||
/// context and carries provider-side overhead the message-tail estimate does
|
||||
/// not model. A real conversation measured 948K estimated against 1,017,628
|
||||
/// actual, a 7% shortfall that landed it over a 1M window while the check said
|
||||
/// there was room. A proportional reserve absorbs that drift and scales with
|
||||
/// the model: a 200K window keeps 20K free and a 1M window keeps 100K.
|
||||
const CONTEXT_RESERVE_DIVISOR: u64 = 10;
|
||||
pub(super) const OUTPUT_TOKENS: u64 = 4_096;
|
||||
pub(super) const INSTRUCTIONS: &str = "Summarize the conversation for the next model turn. Preserve goals, constraints, decisions, files, commands, errors, results, and unfinished work. Do not call tools. Return only the concise durable summary.";
|
||||
|
||||
/// Usable prompt budget: the window minus the proportional reserve.
|
||||
pub(super) fn context_budget(context_window: u64) -> u64 {
|
||||
context_window.saturating_sub(context_window / CONTEXT_RESERVE_DIVISOR)
|
||||
}
|
||||
|
||||
pub(super) fn input_budget(prepared: &PreparedRun) -> Option<u64> {
|
||||
prepared
|
||||
.model
|
||||
.context_window_tokens
|
||||
.map(|window| window.saturating_sub(RESERVE_TOKENS))
|
||||
prepared.model.context_window_tokens.map(context_budget)
|
||||
}
|
||||
|
||||
/// Whether a provider failure means the prompt did not fit.
|
||||
///
|
||||
/// Providers report this as a plain 400 with prose, so there is nothing
|
||||
/// structured to match on. Anthropic says "prompt is too long"; OpenAI-style
|
||||
/// gateways use `context_length_exceeded` or "maximum context length".
|
||||
pub(super) fn is_context_overflow(message: &str) -> bool {
|
||||
let lowered = message.to_ascii_lowercase();
|
||||
lowered.contains("prompt is too long")
|
||||
|| lowered.contains("context window exceeded")
|
||||
|| lowered.contains("model_context_window_exceeded")
|
||||
|| lowered.contains("context_length_exceeded")
|
||||
|| (lowered.contains("maximum context length") && lowered.contains("token"))
|
||||
}
|
||||
|
||||
/// Builds the history for a compaction call.
|
||||
///
|
||||
/// Two properties matter, and replaying the raw history guarantees neither:
|
||||
///
|
||||
/// 1. The history must end with a user message. Otherwise providers read the
|
||||
/// request as an assistant prefill and refuse it outright: Anthropic answers
|
||||
/// "This model does not support assistant message prefill. The conversation
|
||||
/// must end with a user message."
|
||||
/// 2. The history must fit the context window. Compaction runs precisely
|
||||
/// because the conversation is too large, so replaying all of it asks the
|
||||
/// summarizer to accept a prompt that is already over the limit and the
|
||||
/// call fails with "prompt is too long".
|
||||
///
|
||||
/// Either failure falls back to the truncated summary, which is usually still
|
||||
/// too large, so the conversation stays over its window and cannot recover.
|
||||
///
|
||||
/// Trimming keeps the most recent turns and only ever cuts at a user-message
|
||||
/// boundary, so an assistant tool call is never separated from its results.
|
||||
pub(super) fn compaction_history(
|
||||
history: Vec<ProjectedMessage>,
|
||||
context_window: Option<u64>,
|
||||
) -> Vec<ProjectedMessage> {
|
||||
super::history::user_terminated(
|
||||
trim_to_context(history, context_window),
|
||||
"compaction:instruction",
|
||||
INSTRUCTIONS,
|
||||
)
|
||||
}
|
||||
|
||||
fn trim_to_context(
|
||||
mut history: Vec<ProjectedMessage>,
|
||||
context_window: Option<u64>,
|
||||
) -> Vec<ProjectedMessage> {
|
||||
let Some(budget) = context_window
|
||||
.filter(|window| *window > 0)
|
||||
.map(context_budget)
|
||||
.map(|budget| budget.saturating_sub(OUTPUT_TOKENS))
|
||||
.filter(|budget| *budget > 0)
|
||||
else {
|
||||
return history;
|
||||
};
|
||||
if estimate_projected_messages_tokens(&history) <= budget {
|
||||
return history;
|
||||
}
|
||||
// Walk back from the newest turn, keeping whole user-delimited turns.
|
||||
let mut kept = 0;
|
||||
let mut newest_turn = None;
|
||||
for (index, message) in history.iter().enumerate().rev() {
|
||||
if !is_turn_boundary(message) {
|
||||
continue;
|
||||
}
|
||||
newest_turn.get_or_insert(index);
|
||||
if estimate_projected_messages_tokens(&history[index..]) > budget {
|
||||
break;
|
||||
}
|
||||
kept = history.len() - index;
|
||||
}
|
||||
if kept == 0 {
|
||||
// Not even the newest turn fits. Keep it anyway rather than sending an
|
||||
// empty history: an empty summarize call returns a summary of nothing
|
||||
// that would then replace the whole conversation.
|
||||
let start = newest_turn.unwrap_or(0);
|
||||
return history.split_off(start);
|
||||
}
|
||||
history.split_off(history.len() - kept)
|
||||
}
|
||||
|
||||
fn is_turn_boundary(message: &ProjectedMessage) -> bool {
|
||||
message.role == Role::User && matches!(message.content, ProjectedContent::Parts(_))
|
||||
}
|
||||
|
||||
pub(super) fn estimated_tokens(
|
||||
@@ -115,8 +211,8 @@ pub(super) fn fallback_summary(messages: &[CanonicalMessage]) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::model::{
|
||||
project_messages, CheckpointId, ConversationId, ModelSpec, Origin, PromptSpec, Role,
|
||||
RunAction, RunId, RunKind,
|
||||
project_messages, CheckpointId, ContentPart, ConversationId, ModelSpec, Origin, PromptSpec,
|
||||
Role, RunAction, RunId, RunKind,
|
||||
};
|
||||
|
||||
fn prepared(context_window_tokens: u64) -> PreparedRun {
|
||||
@@ -139,7 +235,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_compaction_uses_fixed_reserve_for_every_action() {
|
||||
fn automatic_compaction_uses_proportional_reserve_for_every_action() {
|
||||
let messages = vec![CanonicalMessage::text(
|
||||
"user",
|
||||
Role::User,
|
||||
@@ -148,10 +244,14 @@ mod tests {
|
||||
)];
|
||||
let projected = project_messages(&messages).unwrap();
|
||||
let estimated = estimate_context_tokens(&prepared(1).prompt, &projected);
|
||||
let mut prepared = prepared(estimated + RESERVE_TOKENS);
|
||||
// Smallest multiple-of-ten window whose 90% budget covers the estimate.
|
||||
let window = estimated.div_ceil(9) * 10;
|
||||
let mut prepared = prepared(window);
|
||||
assert!(context_budget(window) >= estimated);
|
||||
assert!(context_budget(window - 10) < estimated);
|
||||
|
||||
assert!(!should_compact(&prepared, &projected, None));
|
||||
prepared.model.context_window_tokens = Some(estimated + RESERVE_TOKENS - 1);
|
||||
prepared.model.context_window_tokens = Some(window - 10);
|
||||
assert!(should_compact(&prepared, &projected, None));
|
||||
|
||||
prepared.action = RunAction::Resume {
|
||||
@@ -160,6 +260,146 @@ mod tests {
|
||||
assert!(should_compact(&prepared, &projected, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_reserve_leaves_room_for_the_estimate_to_run_behind() {
|
||||
// Reproduces a conversation that wedged itself against a 1M window.
|
||||
// The anchor said 947,797 tokens, so a fixed 10K reserve found room
|
||||
// and let the request through. Anthropic counted 1,017,628 and
|
||||
// refused it, and every retry repeated the same arithmetic.
|
||||
let messages = vec![CanonicalMessage::text(
|
||||
"user",
|
||||
Role::User,
|
||||
Origin::Runtime,
|
||||
"hello",
|
||||
)];
|
||||
let projected = project_messages(&messages).unwrap();
|
||||
let anchor = ContextUsageAnchor {
|
||||
context_input_tokens: 947_797,
|
||||
message_count: 1,
|
||||
};
|
||||
assert!(should_compact(
|
||||
&prepared(1_000_000),
|
||||
&projected,
|
||||
Some(anchor)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_reserve_scales_with_the_window() {
|
||||
assert_eq!(context_budget(200_000), 180_000);
|
||||
assert_eq!(context_budget(1_000_000), 900_000);
|
||||
assert_eq!(context_budget(0), 0);
|
||||
assert_eq!(context_budget(1), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_refusals_that_mean_the_prompt_did_not_fit_are_recognized() {
|
||||
assert!(is_context_overflow(
|
||||
"provider error: Anthropic 400 Bad Request: {\"type\":\"error\",\"error\":\
|
||||
{\"type\":\"invalid_request_error\",\"message\":\"prompt is too long: \
|
||||
1017628 tokens > 1000000 maximum\"}}"
|
||||
));
|
||||
assert!(is_context_overflow("model_context_window_exceeded"));
|
||||
assert!(is_context_overflow("context_length_exceeded"));
|
||||
assert!(is_context_overflow(
|
||||
"This model's maximum context length is 128000 tokens"
|
||||
));
|
||||
|
||||
// Unrelated failures must not trigger a compaction, which would
|
||||
// destroy history to fix something compaction cannot fix.
|
||||
assert!(!is_context_overflow("401 Unauthorized: invalid api key"));
|
||||
assert!(!is_context_overflow("429 Too Many Requests"));
|
||||
assert!(!is_context_overflow(
|
||||
"This model does not support assistant message prefill"
|
||||
));
|
||||
}
|
||||
|
||||
fn user(id: &str, text: &str) -> ProjectedMessage {
|
||||
ProjectedMessage {
|
||||
message_id: id.into(),
|
||||
role: Role::User,
|
||||
content: ProjectedContent::Parts(vec![ContentPart::Text { text: text.into() }]),
|
||||
}
|
||||
}
|
||||
|
||||
fn assistant(id: &str, text: &str) -> ProjectedMessage {
|
||||
ProjectedMessage {
|
||||
message_id: id.into(),
|
||||
role: Role::Assistant,
|
||||
content: ProjectedContent::Assistant {
|
||||
text: text.into(),
|
||||
thinking: String::new(),
|
||||
replay_state: None,
|
||||
calls: Vec::new(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compaction_history_always_ends_with_a_user_message() {
|
||||
// Providers reject an assistant-terminated history as a prefill, which
|
||||
// made every automatic compaction fall back to the truncated summary.
|
||||
let history = vec![user("u1", "question"), assistant("a1", "answer")];
|
||||
let prepared = compaction_history(history, Some(200_000));
|
||||
assert_eq!(prepared.last().unwrap().role, Role::User);
|
||||
assert_eq!(
|
||||
prepared.last().unwrap().message_id,
|
||||
"compaction:instruction"
|
||||
);
|
||||
|
||||
// An already user-terminated history is left alone.
|
||||
let history = vec![assistant("a1", "answer"), user("u2", "next")];
|
||||
let prepared = compaction_history(history.clone(), Some(200_000));
|
||||
assert_eq!(prepared, history);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compaction_history_is_trimmed_to_fit_the_context_window() {
|
||||
// Compaction runs because the conversation is too large, so the
|
||||
// summarize call must not replay a prompt that is over the window.
|
||||
let big = "x".repeat(400_000);
|
||||
let history = vec![
|
||||
user("u1", &big),
|
||||
assistant("a1", &big),
|
||||
user("u2", &big),
|
||||
assistant("a2", "recent answer"),
|
||||
];
|
||||
let window = 200_000;
|
||||
let prepared = compaction_history(history, Some(window));
|
||||
|
||||
let budget = context_budget(window) - OUTPUT_TOKENS;
|
||||
assert!(estimate_projected_messages_tokens(&prepared) <= budget);
|
||||
assert_eq!(prepared.last().unwrap().role, Role::User);
|
||||
// The newest turn survives the trim and is never split.
|
||||
assert!(prepared.iter().any(|message| message.message_id == "u2"));
|
||||
assert!(prepared.iter().any(|message| message.message_id == "a2"));
|
||||
assert!(!prepared.iter().any(|message| message.message_id == "u1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compaction_history_keeps_the_newest_turn_even_when_it_is_over_budget() {
|
||||
// A history whose newest turn alone exceeds the budget is still sent
|
||||
// rather than trimmed to nothing: a summary of nothing would replace
|
||||
// the whole conversation.
|
||||
let history = vec![
|
||||
user("u1", "old"),
|
||||
assistant("a1", "old answer"),
|
||||
user("u2", &"x".repeat(400_000)),
|
||||
assistant("a2", "answer"),
|
||||
];
|
||||
let prepared = compaction_history(history, Some(50_000));
|
||||
assert_eq!(prepared[0].message_id, "u2");
|
||||
assert_eq!(prepared.last().unwrap().role, Role::User);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compaction_history_without_a_context_window_is_untouched_apart_from_termination() {
|
||||
let history = vec![user("u1", "question"), assistant("a1", "answer")];
|
||||
let prepared = compaction_history(history.clone(), None);
|
||||
assert_eq!(prepared[..2], history[..]);
|
||||
assert_eq!(prepared.last().unwrap().role, Role::User);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_usage_anchor_only_estimates_messages_added_after_last_request() {
|
||||
let messages = vec![
|
||||
@@ -253,15 +493,16 @@ mod tests {
|
||||
)];
|
||||
let projected = project_messages(&messages).unwrap();
|
||||
let estimated = estimate_context_tokens(&prepared(1).prompt, &projected);
|
||||
let window = estimated.div_ceil(9) * 10;
|
||||
assert!(context_budget(window) >= estimated);
|
||||
assert!(context_budget(window - 10) < estimated);
|
||||
|
||||
assert_eq!(
|
||||
validate_compacted(&prepared(estimated + RESERVE_TOKENS), &projected),
|
||||
validate_compacted(&prepared(window), &projected),
|
||||
Ok(estimated)
|
||||
);
|
||||
assert!(
|
||||
validate_compacted(&prepared(estimated + RESERVE_TOKENS - 1), &projected)
|
||||
.unwrap_err()
|
||||
.contains("context overflow after compaction")
|
||||
);
|
||||
assert!(validate_compacted(&prepared(window - 10), &projected)
|
||||
.unwrap_err()
|
||||
.contains("context overflow after compaction"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,12 @@ pub struct RunEngine {
|
||||
provider: Arc<dyn Provider>,
|
||||
}
|
||||
|
||||
/// Provider-visible tail appended when the committed history ends with the
|
||||
/// assistant, which happens when Cursor resumes a turn that had already
|
||||
/// finished (for example after a cancelled follow-up).
|
||||
const CONTINUE_MESSAGE_ID: &str = "runtime:continue";
|
||||
const CONTINUE_INSTRUCTION: &str = "Continue from where you left off.";
|
||||
|
||||
impl RunEngine {
|
||||
pub fn new(store: Store, provider: Arc<dyn Provider>) -> Self {
|
||||
Self { store, provider }
|
||||
@@ -171,6 +177,10 @@ impl RunEngine {
|
||||
};
|
||||
}
|
||||
|
||||
// A provider refusal for an over-limit prompt triggers one compaction
|
||||
// per run. A second refusal after compacting means the current input
|
||||
// itself does not fit, and compacting again would only destroy history.
|
||||
let mut overflow_compacted = false;
|
||||
'model: loop {
|
||||
if cancellation.is_cancelled() {
|
||||
return (RunOutcome::Cancelled, usage);
|
||||
@@ -226,6 +236,18 @@ impl RunEngine {
|
||||
if let Err(error) = hydrate_tool_images(&self.store, &mut history).await {
|
||||
return (RunOutcome::Failed(error.into()), usage);
|
||||
}
|
||||
// The usage anchor counts persisted messages only; a transient
|
||||
// tail is provider-visible but never committed.
|
||||
let anchored_messages = history.len();
|
||||
let history = if prepared.action == RunAction::Compact {
|
||||
super::history::user_terminated(
|
||||
history,
|
||||
"compaction:instruction",
|
||||
super::compaction::INSTRUCTIONS,
|
||||
)
|
||||
} else {
|
||||
super::history::user_terminated(history, CONTINUE_MESSAGE_ID, CONTINUE_INSTRUCTION)
|
||||
};
|
||||
let request = crate::model::ModelRequest {
|
||||
prompt: prepared.prompt.clone(),
|
||||
model: prepared.model.clone(),
|
||||
@@ -296,7 +318,7 @@ impl RunEngine {
|
||||
update_context_usage_anchor(
|
||||
&mut context_usage_anchor,
|
||||
cycle_usage,
|
||||
request.history.len(),
|
||||
anchored_messages,
|
||||
);
|
||||
accumulate_usage(&mut usage, cycle_usage);
|
||||
}
|
||||
@@ -306,7 +328,7 @@ impl RunEngine {
|
||||
update_context_usage_anchor(
|
||||
&mut context_usage_anchor,
|
||||
cycle_usage,
|
||||
request.history.len(),
|
||||
anchored_messages,
|
||||
);
|
||||
accumulate_usage(&mut usage, cycle_usage);
|
||||
}
|
||||
@@ -353,7 +375,7 @@ impl RunEngine {
|
||||
update_context_usage_anchor(
|
||||
&mut context_usage_anchor,
|
||||
cycle_usage,
|
||||
request.history.len(),
|
||||
anchored_messages,
|
||||
);
|
||||
accumulate_usage(&mut usage, cycle_usage);
|
||||
}
|
||||
@@ -361,6 +383,58 @@ impl RunEngine {
|
||||
let _ = emit(client, RunEvent::CycleInterrupted).await;
|
||||
return (RunOutcome::Cancelled, usage);
|
||||
}
|
||||
// The estimate that cleared the compaction check can
|
||||
// still land over the real limit: it trails the
|
||||
// provider's own count by whatever the request adds
|
||||
// after the anchor was taken. The provider is the
|
||||
// authority, so treat its refusal as the trigger the
|
||||
// estimate missed. Without this a conversation that
|
||||
// crosses the line is wedged: every retry rebuilds the
|
||||
// same prompt and gets the same refusal.
|
||||
if !overflow_compacted
|
||||
&& prepared.action != RunAction::Compact
|
||||
&& matches!(&cycle_failure.failure, RunFailure::Provider(message)
|
||||
if super::compaction::is_context_overflow(message))
|
||||
{
|
||||
tracing::warn!(
|
||||
provider_call_index,
|
||||
checkpoint_id = checkpoint.0,
|
||||
"provider rejected the prompt as over-limit; compacting and retrying"
|
||||
);
|
||||
overflow_compacted = true;
|
||||
checkpoint = match super::messages::append_batches(
|
||||
&self.store,
|
||||
prepared,
|
||||
client,
|
||||
cancellation,
|
||||
checkpoint,
|
||||
std::mem::take(&mut pending_insertions),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok((checkpoint, _)) => checkpoint,
|
||||
Err(outcome) => return (outcome, usage),
|
||||
};
|
||||
let messages =
|
||||
match self.store.load_checkpoint_messages(checkpoint).await {
|
||||
Ok(messages) => messages,
|
||||
Err(error) => return (RunOutcome::Failed(error.into()), usage),
|
||||
};
|
||||
match self
|
||||
.auto_compact(prepared, checkpoint, &messages, client, cancellation)
|
||||
.await
|
||||
{
|
||||
Ok((next_checkpoint, compaction_usage)) => {
|
||||
checkpoint = next_checkpoint;
|
||||
context_usage_anchor = None;
|
||||
if let Some(compaction_usage) = compaction_usage {
|
||||
accumulate_usage(&mut usage, compaction_usage);
|
||||
}
|
||||
continue 'model;
|
||||
}
|
||||
Err(outcome) => return (outcome, usage),
|
||||
}
|
||||
}
|
||||
if !should_retry(&cycle_failure, retries) {
|
||||
return (RunOutcome::Failed(cycle_failure.failure), usage);
|
||||
}
|
||||
@@ -459,7 +533,7 @@ impl RunEngine {
|
||||
update_context_usage_anchor(
|
||||
&mut context_usage_anchor,
|
||||
cycle_usage,
|
||||
request.history.len(),
|
||||
anchored_messages,
|
||||
);
|
||||
accumulate_usage(&mut usage, cycle_usage);
|
||||
}
|
||||
@@ -721,6 +795,9 @@ impl RunEngine {
|
||||
.await
|
||||
.map_err(|error| RunOutcome::Failed(error.into()))?;
|
||||
let history = crate::model::project_messages(&compactable)
|
||||
.map(|history| {
|
||||
super::compaction::compaction_history(history, prepared.model.context_window_tokens)
|
||||
})
|
||||
.map_err(|error| RunOutcome::Failed(error.into()))?;
|
||||
let mut model = prepared.model.clone();
|
||||
model.max_output_tokens = Some(super::compaction::OUTPUT_TOKENS);
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
//! Guarantees provider history ends with a user message before dispatch.
|
||||
|
||||
use crate::model::{ContentPart, ProjectedContent, ProjectedMessage, Role};
|
||||
|
||||
/// Appends a transient user message when the history ends with the assistant.
|
||||
///
|
||||
/// Providers read an assistant-terminated history as a prefill request, and
|
||||
/// Anthropic refuses it outright: "This model does not support assistant
|
||||
/// message prefill. The conversation must end with a user message." The
|
||||
/// appended message is provider-visible only; it is never persisted, so the
|
||||
/// committed checkpoint stays an exact prefix of the next turn.
|
||||
pub(super) fn user_terminated(
|
||||
mut history: Vec<ProjectedMessage>,
|
||||
message_id: &str,
|
||||
text: &str,
|
||||
) -> Vec<ProjectedMessage> {
|
||||
if history
|
||||
.last()
|
||||
.is_none_or(|message| message.role != Role::Assistant)
|
||||
{
|
||||
return history;
|
||||
}
|
||||
history.push(ProjectedMessage {
|
||||
message_id: message_id.into(),
|
||||
role: Role::User,
|
||||
content: ProjectedContent::Parts(vec![ContentPart::Text { text: text.into() }]),
|
||||
});
|
||||
history
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn message(id: &str, role: Role) -> ProjectedMessage {
|
||||
ProjectedMessage {
|
||||
message_id: id.into(),
|
||||
role,
|
||||
content: ProjectedContent::Parts(vec![ContentPart::Text { text: id.into() }]),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assistant_terminated_history_gains_a_user_tail() {
|
||||
let history = vec![message("u1", Role::User), message("a1", Role::Assistant)];
|
||||
let terminated = user_terminated(history.clone(), "tail", "continue");
|
||||
assert_eq!(terminated[..2], history[..]);
|
||||
assert_eq!(terminated.last().unwrap().role, Role::User);
|
||||
assert_eq!(terminated.last().unwrap().message_id, "tail");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_and_tool_terminated_histories_are_untouched() {
|
||||
let user = vec![message("a1", Role::Assistant), message("u2", Role::User)];
|
||||
assert_eq!(user_terminated(user.clone(), "tail", "continue"), user);
|
||||
let tool = vec![message("a1", Role::Assistant), message("t1", Role::Tool)];
|
||||
assert_eq!(user_terminated(tool.clone(), "tail", "continue"), tool);
|
||||
assert!(user_terminated(Vec::new(), "tail", "continue").is_empty());
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ mod compaction;
|
||||
mod engine;
|
||||
mod event;
|
||||
mod handle;
|
||||
mod history;
|
||||
mod messages;
|
||||
mod model_cycle;
|
||||
mod model_retry;
|
||||
|
||||
@@ -373,7 +373,12 @@ fn failure(
|
||||
partial_reasoning: String,
|
||||
usage: Option<Usage>,
|
||||
) -> Box<ModelCycleFailure> {
|
||||
let retryable = matches!(failure, RunFailure::Protocol(_) | RunFailure::Provider(_));
|
||||
let retryable = match &failure {
|
||||
RunFailure::Protocol(_) => true,
|
||||
RunFailure::Provider(message) if is_rejected_request(message) => false,
|
||||
RunFailure::Provider(_) => true,
|
||||
RunFailure::Store(_) | RunFailure::Client(_) => false,
|
||||
};
|
||||
Box::new(ModelCycleFailure {
|
||||
failure,
|
||||
partial_text,
|
||||
@@ -383,6 +388,21 @@ fn failure(
|
||||
})
|
||||
}
|
||||
|
||||
/// A rejected request (wrong key, unknown model, malformed or oversized body)
|
||||
/// fails identically every time, so retrying it only delays the error the user
|
||||
/// needs to see. Provider failures are formatted as `<label> <status>: <body>`,
|
||||
/// so only the head before the body is inspected. 408 and 425 are timing
|
||||
/// failures and stay retryable.
|
||||
fn is_rejected_request(message: &str) -> bool {
|
||||
let head = message.split_once(": ").map_or(message, |(head, _)| head);
|
||||
head.split_whitespace().any(|token| {
|
||||
token.len() == 3
|
||||
&& token.starts_with('4')
|
||||
&& token.bytes().all(|byte| byte.is_ascii_digit())
|
||||
&& !matches!(token, "408" | "425" | "429")
|
||||
})
|
||||
}
|
||||
|
||||
fn terminal_failure(
|
||||
failure: RunFailure,
|
||||
partial_text: String,
|
||||
@@ -480,4 +500,55 @@ mod tests {
|
||||
assert_eq!(result.usage, Some(usage));
|
||||
assert!(event_rx.try_recv().is_err(), "usage must be forwarded once");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_provider_errors_are_retryable() {
|
||||
let result = failure(
|
||||
RunFailure::Provider("Anthropic 502 Bad Gateway".into()),
|
||||
String::new(),
|
||||
String::new(),
|
||||
None,
|
||||
);
|
||||
assert!(result.retryable);
|
||||
let timeout = failure(
|
||||
RunFailure::Provider("Anthropic 408 Request Timeout".into()),
|
||||
String::new(),
|
||||
String::new(),
|
||||
None,
|
||||
);
|
||||
assert!(timeout.retryable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejected_requests_are_not_retryable() {
|
||||
// Verbatim from a wedged conversation: eight retries of the same
|
||||
// over-limit prompt only delayed the error by forty seconds.
|
||||
let too_long = failure(
|
||||
RunFailure::Provider(
|
||||
"Anthropic 400 Bad Request: {\"type\":\"error\",\"error\":{\"type\":\
|
||||
\"invalid_request_error\",\"message\":\"prompt is too long: 1002148 \
|
||||
tokens > 1000000 maximum\"}}"
|
||||
.into(),
|
||||
),
|
||||
String::new(),
|
||||
String::new(),
|
||||
None,
|
||||
);
|
||||
assert!(!too_long.retryable);
|
||||
let unauthorized = failure(
|
||||
RunFailure::Provider("OpenAI Chat 401 Unauthorized: invalid api key".into()),
|
||||
String::new(),
|
||||
String::new(),
|
||||
None,
|
||||
);
|
||||
assert!(!unauthorized.retryable);
|
||||
// A status-looking number inside the body is not a status code.
|
||||
let body_number = failure(
|
||||
RunFailure::Provider("Anthropic 502 Bad Gateway: upstream returned 400".into()),
|
||||
String::new(),
|
||||
String::new(),
|
||||
None,
|
||||
);
|
||||
assert!(body_number.retryable);
|
||||
}
|
||||
}
|
||||
|
||||
+191
-1
@@ -174,7 +174,11 @@ async fn summarize_replaces_model_history_and_preserves_cursor_history() {
|
||||
.prompt
|
||||
.instructions
|
||||
.contains("compacting conversation history"));
|
||||
assert_eq!(requests[1].history.len(), 2);
|
||||
assert_eq!(requests[1].history.len(), 3);
|
||||
assert_eq!(
|
||||
requests[1].history[2].message_id, "compaction:instruction",
|
||||
"an assistant-terminated history gets the summarize instruction as its user tail"
|
||||
);
|
||||
assert_eq!(requests[2].history.len(), 2);
|
||||
let ProjectedContent::Parts(summary_parts) = &requests[2].history[0].content else {
|
||||
panic!("first post-compaction message must be the summary")
|
||||
@@ -470,6 +474,192 @@ async fn irreducibly_oversized_current_input_fails_before_provider_dispatch() {
|
||||
assert_eq!(output.summary_completed, 0);
|
||||
}
|
||||
|
||||
fn windowed_model(model_id: &str, context_window_tokens: Option<u64>) -> ModelConfigInput {
|
||||
ModelConfigInput {
|
||||
sort_order: 0,
|
||||
display_name: model_id.into(),
|
||||
group_name: None,
|
||||
model_type: ModelType::OpenAi,
|
||||
base_url: "https://example.com/v1/chat/completions".into(),
|
||||
use_full_url: true,
|
||||
api_key: "test-key".into(),
|
||||
tooltip_data: model_id.into(),
|
||||
model_id: model_id.into(),
|
||||
reasoning_effort: None,
|
||||
openai_endpoint: OPENAI_CHAT_ENDPOINT.into(),
|
||||
openai_extra_params_enabled: false,
|
||||
openai_extra_params: serde_json::json!({}),
|
||||
custom_headers_enabled: false,
|
||||
custom_headers: serde_json::json!({}),
|
||||
anthropic_extra_params_enabled: false,
|
||||
anthropic_extra_params: serde_json::json!({}),
|
||||
context_window_tokens,
|
||||
max_completion_tokens: None,
|
||||
anthropic_max_tokens: None,
|
||||
anthropic_thinking_effort: None,
|
||||
thinking_budget_tokens: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn provider_overflow_refusal_compacts_and_retries_once() {
|
||||
// The estimate cleared the compaction check, but the provider counted
|
||||
// more and refused. The refusal is the trigger the estimate missed.
|
||||
let (_directory, store) = fixtures::temp_store().await;
|
||||
let model = store
|
||||
.create_model(&windowed_model("refusal-model", Some(1_000_000)))
|
||||
.await
|
||||
.unwrap();
|
||||
let provider = fake_provider::FakeProvider::default();
|
||||
provider.push(text_response("first answer", 4_000, 12));
|
||||
provider.push_error(cursor_server::Error::Provider(
|
||||
"Anthropic 400 Bad Request: {\"type\":\"error\",\"error\":{\"type\":\
|
||||
\"invalid_request_error\",\"message\":\"prompt is too long: 1002148 tokens > \
|
||||
1000000 maximum\"}}"
|
||||
.into(),
|
||||
));
|
||||
provider.push(text_response("durable summary", 3_000, 20));
|
||||
provider.push(text_response("answer after compaction", 500, 20));
|
||||
let assets = PromptAssets::load(
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("prompt/cursor")
|
||||
.as_path(),
|
||||
)
|
||||
.unwrap();
|
||||
let registry = TransportRegistry::new(
|
||||
store,
|
||||
Arc::new(provider.clone()),
|
||||
PromptCompiler::new(assets),
|
||||
);
|
||||
|
||||
let first = run(
|
||||
®istry,
|
||||
"refusal-first",
|
||||
user_request(
|
||||
"refusal-conversation",
|
||||
"refusal-user-1",
|
||||
"start",
|
||||
&model.model_hash,
|
||||
None,
|
||||
),
|
||||
)
|
||||
.await;
|
||||
let second = run(
|
||||
®istry,
|
||||
"refusal-second",
|
||||
user_request(
|
||||
"refusal-conversation",
|
||||
"refusal-user-2",
|
||||
"continue",
|
||||
&model.model_hash,
|
||||
first.checkpoints.last().cloned(),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(second.summary_started, 1);
|
||||
assert_eq!(second.summary_completed, 1);
|
||||
assert_eq!(second.turn_ended, 1);
|
||||
let requests = provider.requests();
|
||||
assert_eq!(
|
||||
requests.len(),
|
||||
4,
|
||||
"refused call, summary call, retried call"
|
||||
);
|
||||
assert!(requests[2].prompt.tools.is_empty());
|
||||
assert_eq!(
|
||||
requests[2].history.last().unwrap().role,
|
||||
Role::User,
|
||||
"the summarizer history must end with a user message"
|
||||
);
|
||||
assert!(!requests[3].prompt.tools.is_empty());
|
||||
assert!(requests[3]
|
||||
.history
|
||||
.iter()
|
||||
.any(|message| match &message.content {
|
||||
ProjectedContent::Parts(parts) =>
|
||||
matches!(parts.as_slice(), [ContentPart::Text { text }]
|
||||
if text.contains("durable summary")),
|
||||
_ => false,
|
||||
}));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn assistant_terminated_history_is_sent_with_a_user_tail() {
|
||||
// Cursor can resume a conversation whose committed history already ends
|
||||
// with the assistant. Anthropic refuses that as a prefill, so the run
|
||||
// appends a provider-visible continuation without persisting it.
|
||||
let (_directory, store) = fixtures::temp_store().await;
|
||||
let model = store
|
||||
.create_model(&windowed_model("tail-model", None))
|
||||
.await
|
||||
.unwrap();
|
||||
let provider = fake_provider::FakeProvider::default();
|
||||
provider.push(text_response("first answer", 400, 12));
|
||||
provider.push(text_response("resumed answer", 450, 12));
|
||||
let assets = PromptAssets::load(
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("prompt/cursor")
|
||||
.as_path(),
|
||||
)
|
||||
.unwrap();
|
||||
let registry = TransportRegistry::new(
|
||||
store.clone(),
|
||||
Arc::new(provider.clone()),
|
||||
PromptCompiler::new(assets),
|
||||
);
|
||||
|
||||
let first = run(
|
||||
®istry,
|
||||
"tail-first",
|
||||
user_request(
|
||||
"tail-conversation",
|
||||
"tail-user-1",
|
||||
"start",
|
||||
&model.model_hash,
|
||||
None,
|
||||
),
|
||||
)
|
||||
.await;
|
||||
let resumed = run(
|
||||
®istry,
|
||||
"tail-resume",
|
||||
request(
|
||||
"tail-conversation",
|
||||
&model.model_hash,
|
||||
first.checkpoints.last().cloned(),
|
||||
pb::conversation_action::Action::ResumeAction(pb::ResumeAction::default()),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(resumed.turn_ended, 1);
|
||||
|
||||
let requests = provider.requests();
|
||||
assert_eq!(requests.len(), 2);
|
||||
let tail = requests[1].history.last().unwrap();
|
||||
assert_eq!(tail.role, Role::User);
|
||||
assert_eq!(tail.message_id, "runtime:continue");
|
||||
assert_eq!(
|
||||
requests[1].history[..requests[1].history.len() - 1]
|
||||
.iter()
|
||||
.map(|message| message.message_id.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.len(),
|
||||
requests[0].history.len() + 1,
|
||||
"committed history plus the first answer, then the transient tail"
|
||||
);
|
||||
let stored = store
|
||||
.load_current_messages(&ConversationId::new("tail-conversation"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
stored
|
||||
.iter()
|
||||
.all(|message| message.message_id != "runtime:continue"),
|
||||
"the continuation tail is provider-visible only and never persisted"
|
||||
);
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Output {
|
||||
checkpoints: Vec<pb::ConversationStateStructure>,
|
||||
|
||||
Reference in New Issue
Block a user