Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/context_compaction.rs
T
kdletters 97d8a6c39a
Project CI / AI game creator shell Rust shard 4/4 (push) Failing after 7m34s
Project CI / AI game creator shell Rust shard 2/4 (push) Failing after 7m42s
Project CI / AI game creator shell Rust shard 3/4 (push) Failing after 7m43s
Project CI / AI game creator shell Rust shard 1/4 (push) Failing after 8m1s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m58s
Project CI / AI game creator shell Rust crates (push) Successful in 3m9s
Project CI / Backend tests (push) Successful in 12m4s
Project CI / Frontend tests (push) Successful in 11m37s
Project CI / Native shell tests (push) Successful in 15m37s
Project CI / Repository checks (push) Successful in 13m51s
Project CI / AI game creator shell web tests (push) Successful in 6m52s
清理并统一外置智能体提示词
将智能体系统指令、工具说明和恢复模板集中到外置提示词目录
精简重复否定、历史对照与无关实现说明,保留必要执行约束
扩展提示词构建校验和源码边界检查并接入现有本地与持续集成门禁
同步内置技能、协作规范、技术文档与对应验证断言
2026-09-20 15:32:48 +08:00

1360 lines
51 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use super::*;
use sha2::{Digest, Sha256};
pub(crate) const AGENT_RUNTIME_CONTEXT_COMPACTION_SCHEMA_VERSION: &str =
"game-creator-runtime-context-compaction.v1";
pub(crate) const AGENT_RUNTIME_CONTEXT_COMPACTION_MAX_BYTES: usize = 256 * 1024;
pub(crate) const AGENT_RUNTIME_CONTEXT_COMPACTION_SUMMARY_MAX_CHARS: usize = 12_000;
pub(crate) const AGENT_RUNTIME_CONTEXT_COMPACTION_AGENT_TAIL: usize = 4;
pub(crate) const AGENT_RUNTIME_CONTEXT_COMPACTION_PROJECT_TAIL: usize = 2;
pub(crate) const AGENT_RUNTIME_CONTEXT_COMPACTION_OBSERVATION_TAIL: usize = 4;
const AGENT_RUNTIME_CONTEXT_COMPACTION_MAX_SOURCE_CHARS: usize = 360_000;
const AGENT_RUNTIME_CONTEXT_COMPACTION_PINNED_CONSTRAINT_MAX_CHARS: usize = 4_000;
const AGENT_RUNTIME_CONTEXT_COMPACTION_PINNED_CONSTRAINT_MAX_COUNT: usize = 24;
const AGENT_RUNTIME_CONTEXT_COMPACTION_PINNED_CONSTRAINT_ITEM_MAX_CHARS: usize = 600;
const AGENT_RUNTIME_CONTEXT_COMPACTION_REQUEST_MAX_OUTPUT_TOKENS: u32 = 2_400;
const AGENT_RUNTIME_CONTEXT_TOKEN_ESTIMATE_BYTES_PER_TOKEN: u64 = 2;
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub(crate) struct AgentRuntimeContextCompaction {
pub(crate) schema_version: String,
pub(crate) project_id: String,
pub(crate) agent_id: String,
pub(crate) session_id: String,
pub(crate) run_id: Option<String>,
pub(crate) trigger: String,
pub(crate) previous_summary_fingerprint: Option<String>,
pub(crate) covered_agent_messages: u64,
pub(crate) agent_messages_prefix_sha256: String,
pub(crate) covered_project_messages: u64,
pub(crate) project_messages_prefix_sha256: String,
pub(crate) covered_observations: u64,
pub(crate) observations_prefix_sha256: String,
pub(crate) source_fingerprint: String,
pub(crate) summary: String,
pub(crate) summary_fingerprint: String,
pub(crate) estimated_tokens_before: u64,
pub(crate) estimated_tokens_after: u64,
pub(crate) prompt_tokens: Option<u64>,
pub(crate) completion_tokens: Option<u64>,
pub(crate) total_tokens: Option<u64>,
pub(crate) revision: u64,
pub(crate) compacted_at: u64,
}
#[derive(Clone, Debug)]
pub(crate) struct AgentRuntimeContextCompactionSource {
pub(crate) project_id: String,
pub(crate) agent_id: String,
pub(crate) session_id: String,
pub(crate) run_id: String,
pub(crate) trigger: String,
pub(crate) previous: Option<AgentRuntimeContextCompaction>,
pub(crate) covered_agent_messages: u64,
pub(crate) agent_messages_prefix_sha256: String,
pub(crate) covered_project_messages: u64,
pub(crate) project_messages_prefix_sha256: String,
pub(crate) covered_observations: u64,
pub(crate) observations_prefix_sha256: String,
pub(crate) source_fingerprint: String,
pub(crate) source_prompt: String,
pub(crate) source_prompt_tokens: u64,
pub(crate) pinned_constraints: Vec<String>,
pub(crate) has_new_source: bool,
}
#[derive(Clone, Debug)]
pub(crate) struct AgentRuntimePromptHistory {
pub(crate) context: String,
pub(crate) observations: Vec<AgentRuntimeToolObservation>,
}
fn context_compaction_identity_component(value: &str) -> String {
format!("{:x}", Sha256::digest(value.as_bytes()))
.chars()
.take(32)
.collect()
}
pub(crate) fn game_creator_agent_runtime_context_compaction_relative_path(
agent_id: &str,
session_id: &str,
) -> String {
format!(
".agent/runtime/context-compactions/{}/{}.json",
context_compaction_identity_component(agent_id),
context_compaction_identity_component(session_id)
)
}
pub(crate) fn game_creator_agent_runtime_context_compaction_path(
root: &Path,
agent_id: &str,
session_id: &str,
) -> PathBuf {
root.join(game_creator_agent_runtime_context_compaction_relative_path(
agent_id, session_id,
))
}
fn sha256_json<T: Serialize + ?Sized>(value: &T) -> Result<String, String> {
let bytes = serde_json::to_vec(value)
.map_err(|error| format!("序列化上下文压缩指纹输入失败:{error}"))?;
Ok(format!("{:x}", Sha256::digest(bytes)))
}
fn valid_sha256(value: &str) -> bool {
value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit())
}
fn prefix_sha256<T: Serialize>(values: &[T], count: u64) -> Result<String, String> {
let count = usize::try_from(count).map_err(|_| "上下文压缩覆盖计数溢出".to_string())?;
if count > values.len() {
return Err("上下文压缩覆盖计数超过当前事实源".to_string());
}
sha256_json(&values[..count])
}
fn context_compaction_source_fingerprint(
project_id: &str,
agent_id: &str,
session_id: &str,
covered_agent_messages: u64,
agent_messages_prefix_sha256: &str,
covered_project_messages: u64,
project_messages_prefix_sha256: &str,
observation_run_id: Option<&str>,
covered_observations: u64,
observations_prefix_sha256: &str,
) -> Result<String, String> {
sha256_json(&serde_json::json!({
"schemaVersion": AGENT_RUNTIME_CONTEXT_COMPACTION_SCHEMA_VERSION,
"projectId": project_id,
"agentId": agent_id,
"sessionId": session_id,
"coveredAgentMessages": covered_agent_messages,
"agentMessagesPrefixSha256": agent_messages_prefix_sha256,
"coveredProjectMessages": covered_project_messages,
"projectMessagesPrefixSha256": project_messages_prefix_sha256,
"observationRunId": observation_run_id,
"coveredObservations": covered_observations,
"observationsPrefixSha256": observations_prefix_sha256,
}))
}
fn estimate_serialized_bytes_as_tokens(bytes: usize) -> u64 {
let bytes = u64::try_from(bytes).unwrap_or(u64::MAX);
bytes.saturating_add(AGENT_RUNTIME_CONTEXT_TOKEN_ESTIMATE_BYTES_PER_TOKEN - 1)
/ AGENT_RUNTIME_CONTEXT_TOKEN_ESTIMATE_BYTES_PER_TOKEN
}
pub(crate) fn estimate_game_creator_llm_request_tokens(
request: &LlmRunRequest,
) -> Result<u64, String> {
let payload = serde_json::json!({
"model": request.model,
"messages": request.messages,
"maxOutputTokens": request.max_output_tokens,
"enableWebSearch": request.enable_web_search,
"apiKind": request.api_kind,
"functionTools": request.function_tools,
"toolChoice": request.tool_choice,
});
let bytes = serde_json::to_vec(&payload)
.map_err(|error| format!("序列化 LLM token 估算输入失败:{error}"))?;
Ok(estimate_serialized_bytes_as_tokens(bytes.len()).saturating_add(128))
}
pub(crate) fn validate_game_creator_llm_request_context_budget(
llm: &GameCreatorLlmConfig,
request: &LlmRunRequest,
estimated_input_tokens: u64,
operation: &str,
) -> Result<(), String> {
const SAFETY_MARGIN_TOKENS: u64 = 4_096;
let max_output_tokens = u64::from(request.max_output_tokens.unwrap_or(0));
let required = estimated_input_tokens
.checked_add(max_output_tokens)
.and_then(|value| value.checked_add(SAFETY_MARGIN_TOKENS))
.ok_or_else(|| format!("{operation} 上下文预算计算溢出"))?;
if required >= llm.context_window_tokens {
return Err(format!(
"{operation} 预计需要 {estimated_input_tokens} 输入 tokens + {max_output_tokens} 输出 tokens + {SAFETY_MARGIN_TOKENS} 安全余量,超过 contextWindowTokens={};请压缩历史、调低输出或新建 Session",
llm.context_window_tokens
));
}
Ok(())
}
fn truncate_to_estimated_tokens(value: &str, token_limit: u64) -> String {
if token_limit == 0 {
return String::new();
}
let max_bytes = token_limit
.saturating_mul(AGENT_RUNTIME_CONTEXT_TOKEN_ESTIMATE_BYTES_PER_TOKEN)
.min(usize::MAX as u64) as usize;
if value.len() <= max_bytes {
return value.to_string();
}
let suffix = "\n...[tool output truncated by token budget]";
if max_bytes <= suffix.len() {
let mut boundary = max_bytes.min(value.len());
while boundary > 0 && !value.is_char_boundary(boundary) {
boundary -= 1;
}
return value[..boundary].to_string();
}
let retained_bytes = max_bytes.saturating_sub(suffix.len());
let mut boundary = retained_bytes.min(value.len());
while boundary > 0 && !value.is_char_boundary(boundary) {
boundary -= 1;
}
format!("{}{}", &value[..boundary], suffix)
}
fn bound_observation_for_prompt(
root: &Path,
observation: &AgentRuntimeToolObservation,
token_limit: u64,
) -> AgentRuntimeToolObservation {
let mut bounded = sanitize_agent_runtime_context_observation(root, observation);
let summary_budget = token_limit.min(1_024).max(1);
bounded.summary = truncate_to_estimated_tokens(&bounded.summary, summary_budget);
let detail_budget = token_limit.saturating_sub(summary_budget).max(1);
bounded.detail = bounded
.detail
.as_deref()
.map(|detail| truncate_to_estimated_tokens(detail, detail_budget))
.filter(|detail| !detail.trim().is_empty());
while estimate_serialized_bytes_as_tokens(
bounded.summary.len() + bounded.detail.as_deref().map(str::len).unwrap_or_default(),
) > token_limit
{
if let Some(detail) = bounded.detail.as_deref() {
let chars = detail.chars().count();
if chars > 32 {
bounded.detail = Some(detail.chars().take(chars / 2).collect());
continue;
}
bounded.detail = None;
continue;
}
let chars = bounded.summary.chars().count();
if chars <= 8 {
bounded.summary.clear();
continue;
}
bounded.summary = bounded.summary.chars().take(chars / 2).collect();
}
bounded
}
pub(crate) fn sanitize_game_creator_agent_runtime_context_observations_for_storage(
root: &Path,
observations: &[AgentRuntimeToolObservation],
) -> Vec<AgentRuntimeToolObservation> {
observations
.iter()
.map(|observation| sanitize_agent_runtime_context_observation(root, observation))
.collect()
}
fn validate_context_compaction_identity(
root: &Path,
sidecar: &AgentRuntimeContextCompaction,
agent_id: &str,
session_id: &str,
) -> Result<(), String> {
if sidecar.schema_version != AGENT_RUNTIME_CONTEXT_COMPACTION_SCHEMA_VERSION {
return Err(format!(
"不支持的 Agent Runtime context compaction 版本:{}",
sidecar.schema_version
));
}
if sidecar.project_id != game_creator_agent_runtime_context_project_id(root)?
|| sidecar.agent_id != agent_id
|| sidecar.session_id != session_id
{
return Err("Agent Runtime context compaction 身份不匹配".to_string());
}
if sidecar.revision == 0
|| !matches!(sidecar.trigger.as_str(), "auto" | "manual")
|| !valid_sha256(&sidecar.agent_messages_prefix_sha256)
|| !valid_sha256(&sidecar.project_messages_prefix_sha256)
|| !valid_sha256(&sidecar.observations_prefix_sha256)
|| !valid_sha256(&sidecar.source_fingerprint)
|| !valid_sha256(&sidecar.summary_fingerprint)
|| sidecar
.previous_summary_fingerprint
.as_deref()
.is_some_and(|value| !valid_sha256(value))
{
return Err("Agent Runtime context compaction 元数据无效".to_string());
}
if sidecar.summary.trim().is_empty()
|| sidecar.summary.chars().count() > AGENT_RUNTIME_CONTEXT_COMPACTION_SUMMARY_MAX_CHARS
|| sha256_json(&sidecar.summary)? != sidecar.summary_fingerprint
{
return Err("Agent Runtime context compaction summary 指纹或大小无效".to_string());
}
Ok(())
}
pub(crate) fn read_game_creator_agent_runtime_context_compaction(
root: &Path,
agent_id: &str,
session_id: &str,
) -> Result<Option<AgentRuntimeContextCompaction>, String> {
let relative_path =
game_creator_agent_runtime_context_compaction_relative_path(agent_id, session_id);
let sidecar = read_agent_runtime_json_sidecar_with_max_bytes(
root,
&relative_path,
"Agent Runtime context compaction",
AGENT_RUNTIME_CONTEXT_COMPACTION_MAX_BYTES,
)?;
if let Some(sidecar) = sidecar.as_ref() {
validate_context_compaction_identity(root, sidecar, agent_id, session_id)?;
}
Ok(sidecar)
}
pub(crate) fn write_game_creator_agent_runtime_context_compaction(
root: &Path,
sidecar: &AgentRuntimeContextCompaction,
) -> Result<(), String> {
validate_context_compaction_identity(root, sidecar, &sidecar.agent_id, &sidecar.session_id)?;
let relative_path = game_creator_agent_runtime_context_compaction_relative_path(
&sidecar.agent_id,
&sidecar.session_id,
);
write_agent_runtime_json_sidecar_with_max_bytes(
root,
&relative_path,
"Agent Runtime context compaction",
sidecar,
AGENT_RUNTIME_CONTEXT_COMPACTION_MAX_BYTES,
)
}
fn validate_compaction_prefixes(
root: &Path,
sidecar: &AgentRuntimeContextCompaction,
agent_messages: &[LocalConversationMessageRecord],
project_messages: &[LocalConversationMessageRecord],
run_id: &str,
observations: &[AgentRuntimeToolObservation],
) -> Result<(), String> {
let observations =
sanitize_game_creator_agent_runtime_context_observations_for_storage(root, observations);
if prefix_sha256(agent_messages, sidecar.covered_agent_messages)?
!= sidecar.agent_messages_prefix_sha256
|| prefix_sha256(project_messages, sidecar.covered_project_messages)?
!= sidecar.project_messages_prefix_sha256
{
return Err("Agent Runtime context compaction 对话前缀发生漂移".to_string());
}
if sidecar.run_id.as_deref() == Some(run_id)
&& prefix_sha256(&observations, sidecar.covered_observations)?
!= sidecar.observations_prefix_sha256
{
return Err("Agent Runtime context compaction observation 前缀发生漂移".to_string());
}
Ok(())
}
fn normalize_conversation_content(content: &str) -> String {
sanitize_prompt_context(content)
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}
fn render_conversation_messages(
label: &str,
messages: &[LocalConversationMessageRecord],
) -> String {
messages
.iter()
.filter_map(|message| {
if message.message_id.as_deref().is_some_and(|message_id| {
message_id
.trim()
.starts_with(AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX)
}) {
return None;
}
let content = normalize_conversation_content(&message.content);
(!content.is_empty()).then(|| format!("- [{label} / {}] {content}", message.role))
})
.collect::<Vec<_>>()
.join("\n")
}
fn observations_tail_start(observations: &[AgentRuntimeToolObservation]) -> usize {
let ordinary_tail = observations
.len()
.saturating_sub(AGENT_RUNTIME_CONTEXT_COMPACTION_OBSERVATION_TAIL);
observations
.iter()
.rposition(|observation| {
observation.tool == "agent.action_history" && observation.status == "ok"
})
.map_or(ordinary_tail, |index| ordinary_tail.min(index))
}
fn prompt_history_sources(
root: &Path,
agent_id: &str,
session_id: &str,
run_id: &str,
observations: &[AgentRuntimeToolObservation],
) -> Result<
(
Vec<LocalConversationMessageRecord>,
Vec<LocalConversationMessageRecord>,
Option<AgentRuntimeContextCompaction>,
),
String,
> {
let agent_messages =
read_local_conversation_for_session_at(root, Some(agent_id), Some(session_id))?.messages;
let project_messages = if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID {
read_local_conversation_for_session_at(root, None, None)?.messages
} else {
Vec::new()
};
let sidecar = read_game_creator_agent_runtime_context_compaction(root, agent_id, session_id)?;
if let Some(sidecar) = sidecar.as_ref() {
validate_compaction_prefixes(
root,
sidecar,
&agent_messages,
&project_messages,
run_id,
observations,
)?;
}
Ok((agent_messages, project_messages, sidecar))
}
pub(crate) fn read_validated_game_creator_agent_runtime_context_compaction(
root: &Path,
agent_id: &str,
session_id: &str,
run_id: &str,
observations: &[AgentRuntimeToolObservation],
) -> Result<Option<AgentRuntimeContextCompaction>, String> {
prompt_history_sources(root, agent_id, session_id, run_id, observations)
.map(|(_, _, sidecar)| sidecar)
}
pub(crate) fn prepare_game_creator_agent_runtime_prompt_history(
root: &Path,
agent_id: &str,
session_id: &str,
run_id: &str,
observations: &[AgentRuntimeToolObservation],
tool_output_token_limit: u64,
) -> Result<AgentRuntimePromptHistory, String> {
let (agent_messages, project_messages, sidecar) =
prompt_history_sources(root, agent_id, session_id, run_id, observations)?;
let agent_start = sidecar
.as_ref()
.map(|value| value.covered_agent_messages)
.unwrap_or(0);
let project_start = sidecar
.as_ref()
.map(|value| value.covered_project_messages)
.unwrap_or(0);
let observation_start = sidecar
.as_ref()
.filter(|value| value.run_id.as_deref() == Some(run_id))
.map(|value| value.covered_observations)
.unwrap_or(0);
let agent_start = usize::try_from(agent_start).map_err(|_| "Agent 对话覆盖计数溢出")?;
let project_start = usize::try_from(project_start).map_err(|_| "项目对话覆盖计数溢出")?;
let observation_start = usize::try_from(observation_start).map_err(|_| "观察覆盖计数溢出")?;
let mut sections = Vec::new();
if let Some(sidecar) = sidecar.as_ref() {
sections.push(format!(
prompt_text!("interaction.history_summary_context"),
sidecar.summary
));
}
let agent_tail = render_conversation_messages("agent", &agent_messages[agent_start..]);
if !agent_tail.is_empty() {
sections.push(format!(
prompt_text!("interaction.agent_tail_context"),
agent_tail = agent_tail,
));
}
let project_tail = render_conversation_messages("project", &project_messages[project_start..]);
if !project_tail.is_empty() {
sections.push(format!(
prompt_text!("interaction.project_tail_context"),
project_tail = project_tail,
));
}
let observations = observations[observation_start..]
.iter()
.map(|observation| bound_observation_for_prompt(root, observation, tool_output_token_limit))
.collect();
Ok(AgentRuntimePromptHistory {
context: sections.join("\n\n"),
observations,
})
}
fn context_compaction_constraint_segment(value: &str) -> bool {
[
"必须", "不得", "不要", "只能", "原样", "约束", "保留", "禁止",
]
.iter()
.any(|marker| value.contains(marker))
}
fn collect_context_compaction_pinned_constraints(
root: &Path,
sources: &[(&str, &[LocalConversationMessageRecord])],
) -> Vec<String> {
let mut constraints = Vec::new();
let mut seen = std::collections::BTreeSet::new();
let mut retained_chars = 0usize;
for (scope, messages) in sources {
for message in *messages {
if message.role.trim() != "user" {
continue;
}
for raw_segment in message
.content
.split_inclusive(|character| matches!(character, '。' | '' | '' | '' | '\n'))
{
let raw_segment = raw_segment.trim();
if raw_segment.is_empty() || !context_compaction_constraint_segment(raw_segment) {
continue;
}
let segment = redact_agent_runtime_project_paths(
root,
raw_segment,
AGENT_RUNTIME_CONTEXT_COMPACTION_PINNED_CONSTRAINT_ITEM_MAX_CHARS,
);
let segment = redact_absolute_path_tokens(&segment);
let segment = redact_secret_tokens(&segment);
let segment = sanitize_prompt_context(&segment);
let segment = segment.trim();
if segment.is_empty() {
continue;
}
let entry = format!("{scope}: {segment}");
let entry_chars = entry.chars().count();
if constraints.len() >= AGENT_RUNTIME_CONTEXT_COMPACTION_PINNED_CONSTRAINT_MAX_COUNT
|| retained_chars.saturating_add(entry_chars)
> AGENT_RUNTIME_CONTEXT_COMPACTION_PINNED_CONSTRAINT_MAX_CHARS
{
return constraints;
}
if seen.insert(entry.clone()) {
retained_chars = retained_chars.saturating_add(entry_chars);
constraints.push(entry);
}
}
}
}
constraints
}
pub(crate) fn build_game_creator_agent_runtime_context_compaction_source(
root: &Path,
agent_id: &str,
session_id: &str,
run_id: &str,
observations: &[AgentRuntimeToolObservation],
trigger: &str,
) -> Result<AgentRuntimeContextCompactionSource, String> {
if !matches!(trigger, "auto" | "manual") {
return Err("上下文压缩 trigger 必须是 auto 或 manual".to_string());
}
let (agent_messages, project_messages, previous) =
prompt_history_sources(root, agent_id, session_id, run_id, observations)?;
let safe_observations =
sanitize_game_creator_agent_runtime_context_observations_for_storage(root, observations);
let covered_agent_messages = u64::try_from(
agent_messages
.len()
.saturating_sub(AGENT_RUNTIME_CONTEXT_COMPACTION_AGENT_TAIL),
)
.unwrap_or(u64::MAX);
let covered_project_messages = u64::try_from(
project_messages
.len()
.saturating_sub(AGENT_RUNTIME_CONTEXT_COMPACTION_PROJECT_TAIL),
)
.unwrap_or(u64::MAX);
let covered_observations =
u64::try_from(observations_tail_start(&safe_observations)).unwrap_or(u64::MAX);
let agent_messages_prefix_sha256 = prefix_sha256(&agent_messages, covered_agent_messages)?;
let project_messages_prefix_sha256 =
prefix_sha256(&project_messages, covered_project_messages)?;
let observations_prefix_sha256 = prefix_sha256(&safe_observations, covered_observations)?;
let project_id = game_creator_agent_runtime_context_project_id(root)?;
let source_fingerprint = context_compaction_source_fingerprint(
&project_id,
agent_id,
session_id,
covered_agent_messages,
&agent_messages_prefix_sha256,
covered_project_messages,
&project_messages_prefix_sha256,
(covered_observations > 0).then_some(run_id),
covered_observations,
&observations_prefix_sha256,
)?;
let previous_agent_count = previous
.as_ref()
.map(|value| value.covered_agent_messages)
.unwrap_or(0)
.min(covered_agent_messages);
let previous_project_count = previous
.as_ref()
.map(|value| value.covered_project_messages)
.unwrap_or(0)
.min(covered_project_messages);
let previous_observation_count = previous
.as_ref()
.filter(|value| value.run_id.as_deref() == Some(run_id))
.map(|value| value.covered_observations)
.unwrap_or(0)
.min(covered_observations);
let has_new_source = covered_agent_messages > previous_agent_count
|| covered_project_messages > previous_project_count
|| covered_observations > previous_observation_count;
let previous_agent_count = usize::try_from(previous_agent_count).unwrap_or(usize::MAX);
let covered_agent_count = usize::try_from(covered_agent_messages).unwrap_or(usize::MAX);
let previous_project_count = usize::try_from(previous_project_count).unwrap_or(usize::MAX);
let covered_project_count = usize::try_from(covered_project_messages).unwrap_or(usize::MAX);
let previous_observation_count =
usize::try_from(previous_observation_count).unwrap_or(usize::MAX);
let covered_observation_count = usize::try_from(covered_observations).unwrap_or(usize::MAX);
let pinned_constraints = collect_context_compaction_pinned_constraints(
root,
&[
("agent", &agent_messages[..covered_agent_count]),
("project", &project_messages[..covered_project_count]),
],
);
let agent_delta = render_conversation_messages(
"agent",
&agent_messages[previous_agent_count..covered_agent_count],
);
let project_delta = render_conversation_messages(
"project",
&project_messages[previous_project_count..covered_project_count],
);
let observation_delta = serde_json::to_string_pretty(
&safe_observations[previous_observation_count..covered_observation_count],
)
.map_err(|error| format!("序列化上下文压缩 observation 增量失败:{error}"))?;
let previous_summary = previous
.as_ref()
.map(|value| value.summary.as_str())
.unwrap_or(prompt_text!("interaction.empty_context"));
let source_prompt = format!(
prompt_text!("interaction.compaction_source"),
if agent_delta.is_empty() {
prompt_text!("interaction.empty_context")
} else {
&agent_delta
},
if project_delta.is_empty() {
prompt_text!("interaction.empty_context")
} else {
&project_delta
},
if observation_delta == "[]" {
prompt_text!("interaction.empty_context")
} else {
&observation_delta
},
previous_summary = previous_summary,
);
if source_prompt.chars().count() > AGENT_RUNTIME_CONTEXT_COMPACTION_MAX_SOURCE_CHARS {
return Err(format!(
"上下文压缩源超过 {} 字符上限,请新建 Session",
AGENT_RUNTIME_CONTEXT_COMPACTION_MAX_SOURCE_CHARS
));
}
let source_prompt_tokens = estimate_serialized_bytes_as_tokens(source_prompt.as_bytes().len());
Ok(AgentRuntimeContextCompactionSource {
project_id,
agent_id: agent_id.to_string(),
session_id: session_id.to_string(),
run_id: run_id.to_string(),
trigger: trigger.to_string(),
previous,
covered_agent_messages,
agent_messages_prefix_sha256,
covered_project_messages,
project_messages_prefix_sha256,
covered_observations,
observations_prefix_sha256,
source_fingerprint,
source_prompt,
source_prompt_tokens,
pinned_constraints,
has_new_source,
})
}
pub(crate) fn build_game_creator_agent_runtime_context_compaction_request(
source: &AgentRuntimeContextCompactionSource,
llm: &GameCreatorLlmConfig,
) -> Result<LlmRunRequest, String> {
let request = LlmRunRequest::new(vec![
LlmMessage::system(prompt_text!("interaction.compaction_system")),
LlmMessage::user(format!(
prompt_text!("interaction.compaction_user"),
AGENT_RUNTIME_CONTEXT_COMPACTION_SUMMARY_MAX_CHARS, source.source_prompt
)),
])
.with_api_kind(parse_game_creator_llm_api_kind(&llm.api_kind)?)
.with_max_output_tokens(AGENT_RUNTIME_CONTEXT_COMPACTION_REQUEST_MAX_OUTPUT_TOKENS)
.with_response_text_verbosity(platform_llm::LlmResponseTextVerbosity::Low);
apply_game_creator_llm_reasoning_effort(request, llm)
}
fn merge_context_compaction_summary_with_pinned_constraints(
summary: &str,
pinned_constraints: &[String],
) -> String {
if pinned_constraints.is_empty() {
return summary.to_string();
}
let pinned = format!(
prompt_text!("interaction.pinned_constraints"),
pinned_constraints
.iter()
.enumerate()
.map(|(index, constraint)| format!("{}. {constraint}", index + 1))
.collect::<Vec<_>>()
.join("\n")
);
let pinned_chars = pinned.chars().count();
let summary_budget = AGENT_RUNTIME_CONTEXT_COMPACTION_SUMMARY_MAX_CHARS
.saturating_sub(pinned_chars.saturating_add(2));
let summary = summary.chars().take(summary_budget).collect::<String>();
if summary.trim().is_empty() {
pinned
} else {
format!("{}\n\n{pinned}", summary.trim())
}
}
pub(crate) fn finalize_game_creator_agent_runtime_context_compaction(
root: &Path,
source: &AgentRuntimeContextCompactionSource,
response: &platform_llm::LlmRunResponse,
estimated_tokens_before: u64,
) -> Result<AgentRuntimeContextCompaction, String> {
let summary = strip_llm_thinking_blocks(response.text.as_str());
let summary = redact_agent_runtime_project_paths(
root,
&summary,
AGENT_RUNTIME_CONTEXT_COMPACTION_SUMMARY_MAX_CHARS,
);
let summary = redact_absolute_path_tokens(&summary);
let summary = redact_secret_tokens(&summary);
let summary = sanitize_prompt_context(&summary);
let summary = summary.trim();
if summary.is_empty() {
return Err("上下文压缩 Provider 返回空摘要".to_string());
}
let summary = merge_context_compaction_summary_with_pinned_constraints(
summary,
&source.pinned_constraints,
);
let summary = summary.trim();
if summary.chars().count() > AGENT_RUNTIME_CONTEXT_COMPACTION_SUMMARY_MAX_CHARS {
return Err(format!(
"上下文压缩摘要超过 {} 字符上限",
AGENT_RUNTIME_CONTEXT_COMPACTION_SUMMARY_MAX_CHARS
));
}
let summary_fingerprint = sha256_json(summary)?;
let previous_summary_tokens = source
.previous
.as_ref()
.map(|value| estimate_serialized_bytes_as_tokens(value.summary.as_bytes().len()))
.unwrap_or(0);
let summary_tokens = estimate_serialized_bytes_as_tokens(summary.as_bytes().len());
let estimated_tokens_after = estimated_tokens_before
.saturating_sub(source.source_prompt_tokens)
.saturating_sub(previous_summary_tokens)
.saturating_add(summary_tokens)
.saturating_add(128);
let usage = response.usage.as_ref();
Ok(AgentRuntimeContextCompaction {
schema_version: AGENT_RUNTIME_CONTEXT_COMPACTION_SCHEMA_VERSION.to_string(),
project_id: source.project_id.clone(),
agent_id: source.agent_id.clone(),
session_id: source.session_id.clone(),
run_id: (!source.run_id.trim().is_empty()).then(|| source.run_id.clone()),
trigger: source.trigger.clone(),
previous_summary_fingerprint: source
.previous
.as_ref()
.map(|value| value.summary_fingerprint.clone()),
covered_agent_messages: source.covered_agent_messages,
agent_messages_prefix_sha256: source.agent_messages_prefix_sha256.clone(),
covered_project_messages: source.covered_project_messages,
project_messages_prefix_sha256: source.project_messages_prefix_sha256.clone(),
covered_observations: source.covered_observations,
observations_prefix_sha256: source.observations_prefix_sha256.clone(),
source_fingerprint: source.source_fingerprint.clone(),
summary: summary.to_string(),
summary_fingerprint,
estimated_tokens_before,
estimated_tokens_after,
prompt_tokens: usage.map(|value| value.prompt_tokens),
completion_tokens: usage.map(|value| value.completion_tokens),
total_tokens: usage.map(|value| value.total_tokens),
revision: source
.previous
.as_ref()
.map(|value| value.revision.saturating_add(1))
.unwrap_or(1),
compacted_at: unix_timestamp(),
})
}
pub(crate) fn context_compaction_result(
sidecar: &AgentRuntimeContextCompaction,
reused: bool,
) -> AgentRuntimeContextCompactionResult {
AgentRuntimeContextCompactionResult {
agent_id: sidecar.agent_id.clone(),
session_id: sidecar.session_id.clone(),
run_id: sidecar.run_id.clone(),
trigger: sidecar.trigger.clone(),
revision: sidecar.revision,
estimated_tokens_before: sidecar.estimated_tokens_before,
estimated_tokens_after: sidecar.estimated_tokens_after,
prompt_tokens: sidecar.prompt_tokens,
completion_tokens: sidecar.completion_tokens,
total_tokens: sidecar.total_tokens,
covered_agent_messages: sidecar.covered_agent_messages,
covered_project_messages: sidecar.covered_project_messages,
covered_observations: sidecar.covered_observations,
reused,
compacted_at: sidecar.compacted_at,
}
}
#[cfg(test)]
mod tests {
use super::*;
struct ContextCompactionTestProject(PathBuf);
impl Drop for ContextCompactionTestProject {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn context_compaction_test_project(label: &str) -> ContextCompactionTestProject {
let root = std::env::temp_dir().join(format!(
"genarrative-context-compaction-{label}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system clock after unix epoch")
.as_nanos()
));
init_local_game_project_at(&root, "context-compaction-project", label)
.expect("initialize context compaction test project");
ContextCompactionTestProject(root)
}
fn append_context_compaction_agent_messages(
root: &Path,
agent_id: &str,
session_id: &str,
start: usize,
count: usize,
) {
for index in start..start + count {
append_local_conversation_message_for_session_at(
root,
Some(agent_id),
Some(session_id),
LocalConversationMessage {
role: if index % 2 == 0 { "user" } else { "assistant" }.to_string(),
content: format!("CONVERSATION_MARKER_{index}"),
agent_id: (index % 2 == 1).then(|| agent_id.to_string()),
},
)
.expect("append context compaction conversation message");
}
}
fn context_compaction_observations(
start: usize,
count: usize,
) -> Vec<AgentRuntimeToolObservation> {
(start..start + count)
.map(|index| AgentRuntimeToolObservation {
tool: "file.read".to_string(),
status: "ok".to_string(),
summary: format!("OBSERVATION_MARKER_{index}"),
detail: Some(format!("safe observation detail {index}")),
})
.collect()
}
fn context_compaction_response(summary: impl Into<String>) -> platform_llm::LlmRunResponse {
platform_llm::LlmRunResponse {
provider: platform_llm::LlmProvider::OpenAiCompatible,
model: "context-compaction-test".to_string(),
text: summary.into(),
reasoning: String::new(),
finish_reason: Some("stop".to_string()),
response_id: Some("context-compaction-response".to_string()),
usage: Some(platform_llm::LlmTokenUsage {
prompt_tokens: 321,
completion_tokens: 45,
total_tokens: 366,
}),
tool_calls: Vec::new(),
responses_output: Vec::new(),
}
}
fn write_context_compaction_fixture(
root: &Path,
agent_id: &str,
session_id: &str,
run_id: &str,
observations: &[AgentRuntimeToolObservation],
summary: &str,
estimated_tokens_before: u64,
) -> AgentRuntimeContextCompaction {
let source = build_game_creator_agent_runtime_context_compaction_source(
root,
agent_id,
session_id,
run_id,
observations,
"auto",
)
.expect("build context compaction source");
assert!(source.has_new_source);
let sidecar = finalize_game_creator_agent_runtime_context_compaction(
root,
&source,
&context_compaction_response(summary),
estimated_tokens_before,
)
.expect("finalize context compaction");
write_game_creator_agent_runtime_context_compaction(root, &sidecar)
.expect("write context compaction sidecar");
sidecar
}
#[test]
fn token_estimate_includes_function_schema() {
let base = LlmRunRequest::single_turn("system", "user");
let with_tool = base
.clone()
.with_function_tools(vec![platform_llm::LlmFunctionTool::new(
"test_tool",
"a deliberately long tool description",
serde_json::json!({
"type": "object",
"properties": { "query": { "type": "string" } }
}),
)]);
assert!(
estimate_game_creator_llm_request_tokens(&with_tool).unwrap()
> estimate_game_creator_llm_request_tokens(&base).unwrap()
);
}
#[test]
fn tool_output_is_bounded_by_token_limit() {
let root = PathBuf::from("/tmp/context-compaction-token-bound");
let observation = AgentRuntimeToolObservation {
tool: "file.read".to_string(),
status: "ok".to_string(),
summary: "s".repeat(8_000),
detail: Some("d".repeat(20_000)),
};
let bounded = bound_observation_for_prompt(&root, &observation, 256);
let tokens = estimate_serialized_bytes_as_tokens(
bounded.summary.len() + bounded.detail.as_deref().map(str::len).unwrap_or_default(),
);
assert!(tokens <= 256);
let tiny = bound_observation_for_prompt(&root, &observation, 1);
let tiny_tokens = estimate_serialized_bytes_as_tokens(
tiny.summary.len() + tiny.detail.as_deref().map(str::len).unwrap_or_default(),
);
assert!(tiny_tokens <= 1);
assert_eq!(tiny.tool, "file.read");
assert_eq!(tiny.status, "ok");
}
#[test]
fn compaction_keeps_recent_tails_and_advances_only_for_new_source() {
let project = context_compaction_test_project("tail-and-revision");
let root = &project.0;
let agent_id = "design-director";
let session_id = "agent-session-design-director";
let run_id = "context-compaction-run";
append_context_compaction_agent_messages(root, agent_id, session_id, 0, 8);
let mut observations = context_compaction_observations(0, 8);
let first = write_context_compaction_fixture(
root,
agent_id,
session_id,
run_id,
&observations,
"第一版安全摘要",
8_000,
);
assert_eq!(first.revision, 1);
assert_eq!(first.covered_agent_messages, 4);
assert_eq!(first.covered_observations, 4);
let history = prepare_game_creator_agent_runtime_prompt_history(
root,
agent_id,
session_id,
run_id,
&observations,
1_000,
)
.expect("prepare compacted prompt history");
assert!(history.context.contains("第一版安全摘要"));
assert!(!history.context.contains("CONVERSATION_MARKER_0"));
assert!(history.context.contains("CONVERSATION_MARKER_4"));
assert!(history.context.contains("CONVERSATION_MARKER_7"));
assert_eq!(history.observations.len(), 4);
assert_eq!(history.observations[0].summary, "OBSERVATION_MARKER_4");
let unchanged = build_game_creator_agent_runtime_context_compaction_source(
root,
agent_id,
session_id,
run_id,
&observations,
"auto",
)
.expect("rebuild unchanged source");
assert!(!unchanged.has_new_source);
assert_eq!(unchanged.source_fingerprint, first.source_fingerprint);
assert_eq!(
unchanged.previous.as_ref().map(|value| value.revision),
Some(1)
);
append_context_compaction_agent_messages(root, agent_id, session_id, 8, 2);
observations.extend(context_compaction_observations(8, 2));
let appended = build_game_creator_agent_runtime_context_compaction_source(
root,
agent_id,
session_id,
run_id,
&observations,
"auto",
)
.expect("build appended source");
assert!(appended.has_new_source);
let second = finalize_game_creator_agent_runtime_context_compaction(
root,
&appended,
&context_compaction_response("第二版安全摘要"),
8_400,
)
.expect("finalize appended compaction");
assert_eq!(second.revision, 2);
assert_eq!(
second.previous_summary_fingerprint,
Some(first.summary_fingerprint)
);
assert_eq!(second.covered_agent_messages, 6);
assert_eq!(second.covered_observations, 6);
}
#[test]
fn compaction_rejects_conversation_and_observation_prefix_drift() {
let project = context_compaction_test_project("prefix-drift");
let root = &project.0;
let agent_id = "design-director";
let session_id = "agent-session-design-director";
let run_id = "context-prefix-run";
append_context_compaction_agent_messages(root, agent_id, session_id, 0, 8);
let observations = context_compaction_observations(0, 8);
write_context_compaction_fixture(
root,
agent_id,
session_id,
run_id,
&observations,
"前缀漂移测试摘要",
8_000,
);
let mut tampered_observations = observations.clone();
tampered_observations[0].summary = "TAMPERED_OBSERVATION".to_string();
let observation_error = prepare_game_creator_agent_runtime_prompt_history(
root,
agent_id,
session_id,
run_id,
&tampered_observations,
1_000,
)
.expect_err("tampered observation prefix must fail");
assert!(observation_error.contains("observation 前缀发生漂移"));
let (conversation_path, _, _) =
conversation_file_path_for_session(root, Some(agent_id), Some(session_id))
.expect("resolve conversation path");
let content = fs::read_to_string(&conversation_path).expect("read conversation fixture");
let mut records = content
.lines()
.map(|line| {
serde_json::from_str::<serde_json::Value>(line).expect("parse conversation record")
})
.collect::<Vec<_>>();
records[0]["content"] = serde_json::Value::String("TAMPERED_CONVERSATION".to_string());
let tampered = records
.into_iter()
.map(|record| serde_json::to_string(&record).expect("serialize conversation record"))
.collect::<Vec<_>>()
.join("\n")
+ "\n";
fs::write(&conversation_path, tampered).expect("tamper conversation fixture");
let conversation_error = prepare_game_creator_agent_runtime_prompt_history(
root,
agent_id,
session_id,
run_id,
&observations,
1_000,
)
.expect_err("tampered conversation prefix must fail");
assert!(conversation_error.contains("对话前缀发生漂移"));
}
#[test]
fn compaction_rejects_sidecar_identity_conflict() {
let project = context_compaction_test_project("identity-conflict");
let root = &project.0;
let agent_id = "design-director";
let session_id = "agent-session-design-director";
let run_id = "context-identity-run";
append_context_compaction_agent_messages(root, agent_id, session_id, 0, 8);
let observations = context_compaction_observations(0, 8);
write_context_compaction_fixture(
root,
agent_id,
session_id,
run_id,
&observations,
"身份测试摘要",
8_000,
);
let path = game_creator_agent_runtime_context_compaction_path(root, agent_id, session_id);
let mut sidecar = serde_json::from_str::<serde_json::Value>(
&fs::read_to_string(&path).expect("read sidecar fixture"),
)
.expect("parse sidecar fixture");
sidecar["agentId"] = serde_json::Value::String("code-prototype".to_string());
fs::write(
&path,
serde_json::to_vec(&sidecar).expect("serialize tampered sidecar"),
)
.expect("tamper sidecar identity");
let error = read_game_creator_agent_runtime_context_compaction(root, agent_id, session_id)
.expect_err("identity conflict must fail");
assert!(error.contains("身份不匹配"));
}
#[test]
fn compaction_summary_redacts_secrets_and_absolute_paths() {
let project = context_compaction_test_project("summary-redaction");
let root = &project.0;
let agent_id = "design-director";
let session_id = "agent-session-design-director";
let run_id = "context-summary-run";
append_context_compaction_agent_messages(root, agent_id, session_id, 0, 8);
let observations = context_compaction_observations(0, 8);
let source = build_game_creator_agent_runtime_context_compaction_source(
root,
agent_id,
session_id,
run_id,
&observations,
"auto",
)
.expect("build summary source");
let private_path = "/home/test/private/context.txt";
let secret = ["s", "k-context-compaction-private-value"].concat();
let sidecar = finalize_game_creator_agent_runtime_context_compaction(
root,
&source,
&context_compaction_response(format!(
"项目位于 {}\n旁路文件是 {private_path}\napi_key={secret}",
root.display()
)),
8_000,
)
.expect("finalize redacted summary");
assert!(!sidecar.summary.contains(root.to_string_lossy().as_ref()));
assert!(!sidecar.summary.contains(private_path));
assert!(!sidecar.summary.contains(&secret));
assert!(sidecar.summary.contains("$PROJECT_ROOT"));
assert!(sidecar.summary.contains("<absolute-path>"));
assert!(sidecar.summary.contains("[redacted sensitive context]"));
}
#[test]
fn compaction_pins_explicit_user_constraints_when_provider_omits_them() {
let project = context_compaction_test_project("pinned-constraint");
let root = &project.0;
let agent_id = "design-director";
let session_id = "agent-session-design-director";
let run_id = "context-pinned-run";
let canary = "CONTEXT_CONSTRAINT_CANARY_1234";
for index in 0..8 {
append_local_conversation_message_for_session_at(
root,
Some(agent_id),
Some(session_id),
LocalConversationMessage {
role: if index % 2 == 0 { "user" } else { "assistant" }.to_string(),
content: if index == 0 {
format!(
"必须记住项目约束代号 {canary},未来明确询问时原样回复;不要提前复述。"
)
} else {
format!("普通历史消息 {index}")
},
agent_id: (index % 2 == 1).then(|| agent_id.to_string()),
},
)
.expect("append pinned constraint conversation");
}
let source = build_game_creator_agent_runtime_context_compaction_source(
root,
agent_id,
session_id,
run_id,
&[],
"manual",
)
.expect("build pinned constraint source");
assert!(source
.pinned_constraints
.iter()
.any(|constraint| constraint.contains(canary)));
let request = build_game_creator_agent_runtime_context_compaction_request(
&source,
&GameCreatorLlmConfig::default(),
)
.expect("build pinned constraint request");
assert!(request.messages[0].content.contains("必须逐字保留"));
let sidecar = finalize_game_creator_agent_runtime_context_compaction(
root,
&source,
&context_compaction_response("Provider 只保留了普通历史概览。"),
8_000,
)
.expect("finalize pinned constraint summary");
assert!(sidecar.summary.contains("用户显式约束"));
assert!(sidecar.summary.contains(canary));
assert!(sidecar.summary.contains("不要提前复述"));
}
#[test]
fn supervisor_compaction_keeps_legacy_history_once_and_isolates_agents() {
let project = context_compaction_test_project("supervisor-legacy-isolation");
let root = &project.0;
let agent_id = GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID;
let session_id = "agent-session-project-supervisor";
let run_id = "supervisor-context-run";
append_context_compaction_agent_messages(root, agent_id, session_id, 0, 8);
for index in 0..4 {
append_local_conversation_message_for_session_at(
root,
None,
None,
LocalConversationMessage {
role: if index % 2 == 0 { "user" } else { "assistant" }.to_string(),
content: format!("LEGACY_PROJECT_MARKER_{index}"),
agent_id: None,
},
)
.expect("append legacy project conversation");
}
let observations = context_compaction_observations(0, 8);
let sidecar = write_context_compaction_fixture(
root,
agent_id,
session_id,
run_id,
&observations,
"总控历史摘要",
9_000,
);
assert_eq!(sidecar.covered_agent_messages, 4);
assert_eq!(sidecar.covered_project_messages, 2);
let history = prepare_game_creator_agent_runtime_prompt_history(
root,
agent_id,
session_id,
run_id,
&observations,
1_000,
)
.expect("prepare supervisor prompt history");
assert!(!history.context.contains("LEGACY_PROJECT_MARKER_0"));
assert!(!history.context.contains("LEGACY_PROJECT_MARKER_1"));
assert_eq!(
history.context.matches("LEGACY_PROJECT_MARKER_2").count(),
1
);
assert_eq!(
history.context.matches("LEGACY_PROJECT_MARKER_3").count(),
1
);
assert_eq!(history.context.matches("CONVERSATION_MARKER_4").count(), 1);
assert_eq!(history.context.matches("CONVERSATION_MARKER_7").count(), 1);
let other_agent = "code-prototype";
let other_session = "agent-session-code-prototype";
assert_ne!(
game_creator_agent_runtime_context_compaction_relative_path(agent_id, session_id),
game_creator_agent_runtime_context_compaction_relative_path(other_agent, other_session,)
);
assert!(read_game_creator_agent_runtime_context_compaction(
root,
other_agent,
other_session,
)
.expect("read isolated Agent sidecar")
.is_none());
}
}