From 8a679d7011527d3681be69a5dab1d62eaefc1dd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 3 Sep 2026 11:44:29 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=20Direct=20replay=20?= =?UTF-8?q?=E6=9C=89=E7=95=8C=E6=BB=91=E5=8A=A8=E7=AA=97=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DirectProject replay 按上下文预算保留最新连续历史 超预算时仅派生裁剪 prompt,不修改 project.jsonl 补充超大历史与当前请求超限测试 --- .../src-tauri/src/agent/codex_app_server.rs | 227 +++++++++++++++++- 1 file changed, 218 insertions(+), 9 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs index 9b8dcb4f3..99e7ef3fe 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs @@ -2125,16 +2125,18 @@ impl CodexAppServerConnection { let thread_id = thread_lease.thread_id.clone(); let mut request = request; if thread_created && self.inner.workspace_mode.uses_direct_conversation() { - // DirectProject intentionally replays the complete append-only - // project history. AGC owns that durable fact source; keeping - // the replay lossless is the product contract even though a - // future context-budget policy may need to change this choice. + // DirectProject owns the append-only project history in AGC. The + // replay builder derives a bounded prompt without mutating that + // durable fact source, so a new ephemeral thread can recover the + // newest contiguous context within the model budget. let current_prompt = direct_codex_current_user_prompt(&request).to_string(); let history_root = direct_history_root.unwrap_or(&self.inner.workspace_path); let history_prompt = build_direct_codex_history_prompt( history_root, direct_client_turn_id.unwrap_or("__none__"), ¤t_prompt, + &request, + llm, ) .map_err(platform_llm::LlmError::InvalidRequest)?; if let Some(message) = request @@ -3217,24 +3219,153 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( .map_err(|error| error.to_string()) } +/// Builds the prompt used when a new DirectProject thread needs replay. +/// +/// The JSONL history remains immutable; only the derived prompt is bounded. +/// When the full request exceeds the replay target, the oldest contiguous +/// records are omitted and a plain `system:` marker is prepended. pub(crate) fn build_direct_codex_history_prompt( root: &std::path::Path, client_turn_id: &str, current_prompt: &str, + base_request: &LlmRunRequest, + llm: &GameCreatorLlmConfig, ) -> Result { let conversation = read_local_conversation_for_session_at(root, None, None)?; let current_message_id = format!("direct-codex:{client_turn_id}:user"); // The deliberately simple role-prefix format is part of the Direct // replay contract. Do not introduce an envelope or implicit escaping // here without updating the persisted-history compatibility decision. - let mut lines = conversation + let lines = conversation .messages .iter() .filter(|message| message.message_id.as_deref() != Some(current_message_id.as_str())) .map(|message| format!("{}: {}", message.role, message.content)) .collect::>(); - lines.push(format!("user: {}", current_prompt.trim())); - Ok(lines.join("\n")) + let current_line = format!("user: {}", current_prompt.trim()); + let full_prompt = format_direct_codex_replay_prompt(&lines, ¤t_line, None); + let target_budget = direct_codex_replay_target_budget(llm, base_request)?; + if direct_codex_replay_prompt_fits(base_request, llm, &full_prompt, target_budget)? { + return Ok(full_prompt); + } + + // The persisted project conversation is immutable. We only derive a + // bounded prompt for this replay, keeping the newest contiguous records. + let omission_marker = DIRECT_CODEX_REPLAY_OMISSION_MARKER; + let mut selected_reversed = Vec::new(); + for line in lines.iter().rev() { + let mut candidate_reversed = selected_reversed.clone(); + candidate_reversed.push(line.as_str()); + let candidate_lines = candidate_reversed.iter().rev().copied().collect::>(); + let candidate_prompt = format_direct_codex_replay_prompt( + &candidate_lines, + ¤t_line, + Some(omission_marker), + ); + if direct_codex_replay_prompt_fits(base_request, llm, &candidate_prompt, target_budget)? { + selected_reversed.push(line.as_str()); + } else { + break; + } + } + + let selected_lines = selected_reversed.iter().rev().copied().collect::>(); + let marked_prompt = + format_direct_codex_replay_prompt(&selected_lines, ¤t_line, Some(omission_marker)); + if direct_codex_replay_prompt_fits(base_request, llm, &marked_prompt, target_budget)? { + return Ok(marked_prompt); + } + + // If the marker itself would push the request over the target, preserve + // the current user request and omit only the marker. + let current_only_prompt = + format_direct_codex_replay_prompt(&[] as &[&str], ¤t_line, None); + if direct_codex_replay_prompt_fits(base_request, llm, ¤t_only_prompt, target_budget)? { + return Ok(current_only_prompt); + } + direct_codex_replay_validate_context_budget(base_request, llm, ¤t_only_prompt) +} + +const DIRECT_CODEX_REPLAY_OMISSION_MARKER: &str = + "system: Earlier conversation history was omitted due to context budget."; + +fn format_direct_codex_replay_prompt( + history_lines: &[impl AsRef], + current_line: &str, + omission_marker: Option<&str>, +) -> String { + let mut lines = Vec::with_capacity(history_lines.len() + 2); + if let Some(marker) = omission_marker { + lines.push(marker.to_string()); + } + lines.extend(history_lines.iter().map(|line| line.as_ref().to_string())); + lines.push(current_line.to_string()); + lines.join("\n") +} + +fn direct_codex_replay_target_budget( + llm: &GameCreatorLlmConfig, + request: &LlmRunRequest, +) -> Result { + const SAFETY_MARGIN_TOKENS: u64 = 4_096; + let max_output_tokens = u64::from(request.max_output_tokens.unwrap_or(0)); + let hard_budget = llm + .context_window_tokens + .checked_sub(max_output_tokens) + .and_then(|value| value.checked_sub(SAFETY_MARGIN_TOKENS)) + .ok_or_else(|| "Direct replay 没有可用的输入上下文预算".to_string())?; + Ok(llm.auto_compact_token_limit.min(hard_budget)) +} + +fn direct_codex_replay_estimate( + base_request: &LlmRunRequest, + prompt: &str, +) -> Result { + let mut request = base_request.clone(); + let user = request + .messages + .iter_mut() + .rev() + .find(|message| message.role == LlmMessageRole::User) + .ok_or_else(|| "Direct replay 请求缺少 user message".to_string())?; + user.content = prompt.to_string(); + Ok(request) +} + +fn direct_codex_replay_prompt_fits( + base_request: &LlmRunRequest, + llm: &GameCreatorLlmConfig, + prompt: &str, + target_budget: u64, +) -> Result { + let request = direct_codex_replay_estimate(base_request, prompt)?; + let estimated = estimate_game_creator_llm_request_tokens(&request)?; + if estimated > target_budget { + return Ok(false); + } + validate_game_creator_llm_request_context_budget( + llm, + &request, + estimated, + "Direct replay 请求", + )?; + Ok(true) +} + +fn direct_codex_replay_validate_context_budget( + base_request: &LlmRunRequest, + llm: &GameCreatorLlmConfig, + prompt: &str, +) -> Result { + let request = direct_codex_replay_estimate(base_request, prompt)?; + let estimated = estimate_game_creator_llm_request_tokens(&request)?; + validate_game_creator_llm_request_context_budget( + llm, + &request, + estimated, + "Direct replay 请求", + )?; + Ok(prompt.to_string()) } /// Direct home-page chat never binds Codex to a user project. It gets a @@ -3349,14 +3480,92 @@ mod tests { ) .expect("append history"); } - let prompt = build_direct_codex_history_prompt(root.path(), "new-turn", "new request") - .expect("build prompt"); + let request = LlmRunRequest::single_turn("system", "new request") + .with_model("fixture-model") + .with_max_output_tokens(16_000); + let prompt = build_direct_codex_history_prompt( + root.path(), + "new-turn", + "new request", + &request, + &test_llm(), + ) + .expect("build prompt"); assert_eq!( prompt, "user: hello\nassistant: partial\nunexpected interrupt happened here\ntool: file-read result\nuser: new request" ); } + #[test] + fn direct_history_prompt_slides_old_prefix_when_budget_is_exceeded() { + let root = tempfile::tempdir().expect("temp dir"); + init_local_game_project_at(root.path(), "window-project", "window").expect("init"); + for (role, content, message_id) in [ + ("user", "old ".repeat(1_000), "old-user"), + ("assistant", "middle ".repeat(100), "middle-assistant"), + ("tool", "newest ".repeat(100), "newest-tool"), + ] { + append_local_conversation_message_for_session_idempotent_at( + root.path(), + None, + None, + LocalConversationMessage { + role: role.to_string(), + content, + agent_id: None, + }, + message_id, + ) + .expect("append history"); + } + let mut llm = test_llm(); + llm.auto_compact_token_limit = 800; + let request = LlmRunRequest::single_turn("system", "new request") + .with_model("fixture-model") + .with_max_output_tokens(16_000); + let before = + std::fs::read_to_string(root.path().join(".agent/conversations/project.jsonl")) + .expect("read history"); + let prompt = build_direct_codex_history_prompt( + root.path(), + "new-turn", + "new request", + &request, + &llm, + ) + .expect("build prompt"); + let after = std::fs::read_to_string(root.path().join(".agent/conversations/project.jsonl")) + .expect("read history"); + + assert!(prompt.starts_with(DIRECT_CODEX_REPLAY_OMISSION_MARKER)); + assert!(prompt.contains("tool: newest")); + assert!(!prompt.contains("user: old")); + assert!(prompt.ends_with("user: new request")); + assert_eq!(before, after); + } + + #[test] + fn direct_history_prompt_rejects_current_request_that_exceeds_context() { + let root = tempfile::tempdir().expect("temp dir"); + init_local_game_project_at(root.path(), "oversized-project", "oversized").expect("init"); + let mut llm = test_llm(); + llm.context_window_tokens = 5_000; + llm.auto_compact_token_limit = 1_000; + let request = LlmRunRequest::single_turn("system", "new request") + .with_model("fixture-model") + .with_max_output_tokens(100); + let error = build_direct_codex_history_prompt( + root.path(), + "new-turn", + &"request ".repeat(10_000), + &request, + &llm, + ) + .expect_err("oversized request should fail"); + assert!(error.contains("Direct replay 请求")); + } + #[test] fn direct_item_activities_are_closed_safe_categories() { let allowed = [