实现 Direct replay 有界滑动窗口

DirectProject replay 按上下文预算保留最新连续历史

超预算时仅派生裁剪 prompt,不修改 project.jsonl

补充超大历史与当前请求超限测试
This commit is contained in:
2026-09-03 11:44:29 +08:00
parent 456762f569
commit 8a679d7011
@@ -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__"),
&current_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<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 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::<Vec<_>>();
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, &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
@@ -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 = [