移除 DirectProject 文本窗口回放

删除旧的文本拼接、预算裁剪和遗漏标记逻辑

DirectProject 仅通过原始 item 注入恢复上下文

保留非 DirectProject 路径的既有会话实现
This commit is contained in:
2026-09-04 17:31:48 +08:00
parent ee6c1b7f99
commit b116a5d75c
2 changed files with 9 additions and 152 deletions
@@ -3272,155 +3272,6 @@ 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<String, String> {
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 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::<Vec<_>>();
let current_line = format!("user: {}", current_prompt.trim());
let full_prompt = format_direct_codex_replay_prompt(&lines, &current_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::<Vec<_>>();
let candidate_prompt = format_direct_codex_replay_prompt(
&candidate_lines,
&current_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::<Vec<_>>();
let marked_prompt =
format_direct_codex_replay_prompt(&selected_lines, &current_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], &current_line, None);
if direct_codex_replay_prompt_fits(base_request, llm, &current_only_prompt, target_budget)? {
return Ok(current_only_prompt);
}
direct_codex_replay_validate_context_budget(base_request, llm, &current_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<str>],
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<u64, String> {
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<LlmRunRequest, String> {
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<bool, String> {
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<String, String> {
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
/// fresh isolated read-only workspace and a stable in-process thread so a
/// normal conversation can continue without creating a project, assets, a
@@ -3506,6 +3357,7 @@ pub(in crate::agent) fn shutdown_game_creator_codex_app_servers_impl() -> Result
mod tests {
use super::*;
/* legacy text replay tests removed with the prompt replay implementation
#[test]
fn direct_history_prompt_replays_all_project_messages_in_order() {
let root = tempfile::tempdir().expect("temp dir");
@@ -3618,6 +3470,7 @@ mod tests {
.expect_err("oversized request should fail");
assert!(error.contains("Direct replay 请求"));
}
*/
#[test]
fn direct_item_activities_are_closed_safe_categories() {
@@ -70,13 +70,17 @@ pub(crate) fn append_direct_project_history_item_at(
enforce_project_permission_policy(root, "conversation.write")?;
let _project_lock = crate::project::acquire_project_write_lock(root, "conversation.write")?;
let path = history_path(root);
prepare_game_creator_private_path_for_read(&path, false, "DirectProject 历史")?;
let lock = project_append_lock_for(&path)?;
let _append_guard = lock.lock("DirectProject 历史追加写")?;
if let Some(item_id) = item.get("id").and_then(Value::as_str) {
if read_direct_project_history_items_at(root)?.iter().any(|existing| {
if let Some(existing) = read_direct_project_history_items_at(root)?.into_iter().find(|existing| {
existing.get("id").and_then(Value::as_str) == Some(item_id)
}) {
return Ok(());
if &existing == item {
return Ok(());
}
return Err(format!("DirectProject 历史 item id 冲突:{item_id}"));
}
}
let line = record(item)?;
@@ -119,7 +123,7 @@ pub(crate) fn read_direct_project_history_items_at(
root: &Path,
) -> Result<Vec<Value>, String> {
let path = history_path(root);
if !path.exists() {
if !prepare_game_creator_private_path_for_read(&path, false, "DirectProject 历史")? {
return Ok(Vec::new());
}
let file = File::open(&path)