实现 Direct replay 有界滑动窗口
DirectProject replay 按上下文预算保留最新连续历史 超预算时仅派生裁剪 prompt,不修改 project.jsonl 补充超大历史与当前请求超限测试
This commit is contained in:
@@ -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<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, ¤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::<Vec<_>>();
|
||||
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::<Vec<_>>();
|
||||
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<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 = [
|
||||
|
||||
Reference in New Issue
Block a user