实现Agent持久上下文压缩

新增全局与单 Agent token 预算、持久摘要 sidecar、自动及手动压缩恢复链路
接入 Runner、CLI /compact、开发 UI 状态和共享能力契约
补齐幂等、漂移、生命周期、恢复、隐私及真实三十轮端到端验收
同步 Runtime 技术方案、App 实施计划和长期决策记录
This commit is contained in:
AIGameCreator App
2026-07-15 22:08:17 +08:00
parent 26decf24da
commit 543a5469f8
20 changed files with 5063 additions and 559 deletions
@@ -7,6 +7,9 @@
"reasoningEffort": "high",
"stream": false,
"webSearchEnabled": false,
"contextWindowTokens": 128000,
"autoCompactTokenLimit": 64000,
"toolOutputTokenLimit": 12000,
"requestTimeoutMs": 180000,
"maxRetries": 0,
"retryBackoffMs": 500
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
@@ -30,6 +30,11 @@ pub(crate) enum CliCommand {
project_path: PathBuf,
agent_id: String,
},
AgentContextCompact {
project_path: PathBuf,
agent_id: String,
session_id: Option<String>,
},
AgentGoalStatus {
project_path: PathBuf,
agent_id: String,
@@ -109,6 +114,7 @@ impl CliCommand {
Self::AgentTask { .. }
| Self::SwarmChat { .. }
| Self::AgentEnqueue { .. }
| Self::AgentContextCompact { .. }
| Self::AgentConfirm { .. }
| Self::AgentSteer { .. }
| Self::AgentGoalStart { .. }
@@ -151,6 +157,7 @@ impl CliCommand {
} => Some((project_path, *initialize)),
Self::AgentChat { project_path, .. }
| Self::AgentRuntimeStatus { project_path, .. }
| Self::AgentContextCompact { project_path, .. }
| Self::AgentGoalStatus { project_path, .. }
| Self::AgentGoalEdit { project_path, .. }
| Self::AgentGoalPause { project_path, .. }
@@ -261,6 +268,15 @@ pub(crate) fn game_creator_llm_status_lines(status: &GameCreatorLlmConfigStatus)
format!("llm.reasoningEffort={}", status.reasoning_effort),
format!("llm.stream={}", status.stream),
format!("llm.webSearchEnabled={}", status.web_search_enabled),
format!("llm.contextWindowTokens={}", status.context_window_tokens),
format!(
"llm.autoCompactTokenLimit={}",
status.auto_compact_token_limit
),
format!(
"llm.toolOutputTokenLimit={}",
status.tool_output_token_limit
),
];
for agent in &status.agents {
lines.push(format!(
@@ -297,6 +313,18 @@ pub(crate) fn game_creator_llm_status_lines(status: &GameCreatorLlmConfigStatus)
"llm.agent.{}.webSearchEnabled={}",
agent.agent_id, agent.web_search_enabled
));
lines.push(format!(
"llm.agent.{}.contextWindowTokens={}",
agent.agent_id, agent.context_window_tokens
));
lines.push(format!(
"llm.agent.{}.autoCompactTokenLimit={}",
agent.agent_id, agent.auto_compact_token_limit
));
lines.push(format!(
"llm.agent.{}.toolOutputTokenLimit={}",
agent.agent_id, agent.tool_output_token_limit
));
if let Some(error) = agent.error.as_deref() {
lines.push(format!("llm.agent.{}.error={error}", agent.agent_id));
}
@@ -383,6 +411,20 @@ pub(crate) fn parse_cli_command(args: &[String]) -> Result<Option<CliCommand>, S
agent_id: args[2].trim().to_string(),
}));
}
if args.first().map(String::as_str) == Some("--agent-context-compact") {
const USAGE: &str =
"用法:--agent-context-compact <本地项目绝对路径> <agentId> [sessionId]";
if !(args.len() == 3 || args.len() == 4)
|| args[1..].iter().any(|value| value.trim().is_empty())
{
return Err(USAGE.to_string());
}
return Ok(Some(CliCommand::AgentContextCompact {
project_path: PathBuf::from(&args[1]),
agent_id: args[2].trim().to_string(),
session_id: args.get(3).map(|value| value.trim().to_string()),
}));
}
if args.first().map(String::as_str) == Some("--agent-goal-status") {
const USAGE: &str = "用法:--agent-goal-status <本地项目绝对路径> <agentId> <sessionId>";
if args.len() != 4 || args[1..].iter().any(|value| value.trim().is_empty()) {
@@ -831,6 +873,25 @@ pub(crate) fn run_cli_command(command: CliCommand) -> Result<(), String> {
);
Ok(())
}
CliCommand::AgentContextCompact {
project_path,
agent_id,
session_id,
} => {
let project_path = canonicalize_cli_path(&project_path, "本地项目路径", false)?;
require_external_agent_runner_for_cli_runtime_write(&project_path)?;
let result = compact_external_agent_runner_context(
&project_path,
&agent_id,
session_id.as_deref(),
)?;
println!(
"contextCompactionJson={}",
serde_json::to_string(&result)
.map_err(|error| format!("序列化上下文压缩结果失败:{error}"))?
);
Ok(())
}
CliCommand::AgentGoalStatus {
project_path,
agent_id,
@@ -1488,6 +1549,42 @@ mod tests {
assert!(error.contains("--config-dir"));
}
#[test]
fn parses_agent_context_compaction_with_optional_session() {
assert_eq!(
parse_cli_command(&[
"--agent-context-compact".to_string(),
"/tmp/game-project".to_string(),
"code-prototype".to_string(),
"agent-session-code-prototype".to_string(),
])
.expect("parse context compaction"),
Some(CliCommand::AgentContextCompact {
project_path: PathBuf::from("/tmp/game-project"),
agent_id: "code-prototype".to_string(),
session_id: Some("agent-session-code-prototype".to_string()),
})
);
assert_eq!(
parse_cli_command(&[
"--agent-context-compact".to_string(),
"/tmp/game-project".to_string(),
"code-prototype".to_string(),
])
.expect("parse active-session compaction"),
Some(CliCommand::AgentContextCompact {
project_path: PathBuf::from("/tmp/game-project"),
agent_id: "code-prototype".to_string(),
session_id: None,
})
);
assert!(parse_cli_command(&[
"--agent-context-compact".to_string(),
"/tmp/game-project".to_string(),
])
.is_err());
}
#[test]
fn swarm_chat_defaults_to_project_supervisor() {
let project_path = std::env::current_dir().expect("current directory");
@@ -435,6 +435,23 @@ pub(crate) fn start_game_creator_agent_runtime_task(
)
}
#[tauri::command]
pub(crate) async fn compact_game_creator_agent_runtime_context(
project_path: String,
agent_id: String,
session_id: Option<String>,
) -> Result<AgentRuntimeContextCompactionResult, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "conversation.read")?;
enforce_project_permission_policy(root, "agent.compact")?;
if external_agent_runner_enabled() && !external_agent_runner_is_server_process() {
compact_external_agent_runner_context(root, agent_id.trim(), session_id.as_deref())
} else {
compact_game_creator_agent_runtime_session_at(root, agent_id.trim(), session_id.as_deref())
.await
}
}
#[tauri::command]
pub(crate) fn read_game_creator_agent_goal(
project_path: String,
@@ -134,6 +134,9 @@ pub(crate) fn check_game_creator_llm_config_from_config() -> GameCreatorLlmConfi
reasoning_effort: DEFAULT_GAME_CREATOR_LLM_REASONING_EFFORT.to_string(),
stream: false,
web_search_enabled: false,
context_window_tokens: DEFAULT_GAME_CREATOR_LLM_CONTEXT_WINDOW_TOKENS,
auto_compact_token_limit: DEFAULT_GAME_CREATOR_LLM_AUTO_COMPACT_TOKEN_LIMIT,
tool_output_token_limit: DEFAULT_GAME_CREATOR_LLM_TOOL_OUTPUT_TOKEN_LIMIT,
error: Some(error),
agents: Vec::new(),
}
@@ -242,6 +245,9 @@ pub(crate) fn check_game_creator_llm_config_values(
reasoning_effort: config.reasoning_effort.clone(),
stream: config.stream,
web_search_enabled: config.web_search_enabled,
context_window_tokens: config.context_window_tokens,
auto_compact_token_limit: config.auto_compact_token_limit,
tool_output_token_limit: config.tool_output_token_limit,
error,
agents: Vec::new(),
}
@@ -278,6 +284,9 @@ pub(crate) fn check_game_creator_agent_llm_config_values(
reasoning_effort: config.reasoning_effort.clone(),
stream: config.stream,
web_search_enabled: config.web_search_enabled,
context_window_tokens: config.context_window_tokens,
auto_compact_token_limit: config.auto_compact_token_limit,
tool_output_token_limit: config.tool_output_token_limit,
error: status.error,
}
}
@@ -296,6 +305,44 @@ pub(crate) fn validate_game_creator_llm_timing_config(
}
parse_game_creator_llm_reasoning_effort(&config.reasoning_effort)
.map_err(|error| format!("配置项 {config_path}.reasoningEffort 无效:{error}"))?;
validate_game_creator_llm_context_config(config, config_path)?;
Ok(())
}
pub(crate) fn validate_game_creator_llm_context_config(
config: &GameCreatorLlmConfig,
config_path: &str,
) -> Result<(), String> {
const CONTEXT_SAFETY_MARGIN_TOKENS: u64 = 4_096;
if config.context_window_tokens == 0 {
return Err(format!(
"配置项 {config_path}.contextWindowTokens 必须大于 0"
));
}
if config.auto_compact_token_limit == 0 {
return Err(format!(
"配置项 {config_path}.autoCompactTokenLimit 必须大于 0"
));
}
if config.tool_output_token_limit == 0 {
return Err(format!(
"配置项 {config_path}.toolOutputTokenLimit 必须大于 0"
));
}
if config
.auto_compact_token_limit
.saturating_add(CONTEXT_SAFETY_MARGIN_TOKENS)
>= config.context_window_tokens
{
return Err(format!(
"配置项 {config_path}.autoCompactTokenLimit 必须至少为 contextWindowTokens 预留 {CONTEXT_SAFETY_MARGIN_TOKENS} tokens"
));
}
if config.tool_output_token_limit > config.auto_compact_token_limit {
return Err(format!(
"配置项 {config_path}.toolOutputTokenLimit 不能大于 autoCompactTokenLimit"
));
}
Ok(())
}
@@ -1040,6 +1087,15 @@ pub(crate) fn merge_game_creator_llm_config(
if let Some(value) = patch.web_search_enabled {
config.web_search_enabled = value;
}
if let Some(value) = patch.context_window_tokens {
config.context_window_tokens = value;
}
if let Some(value) = patch.auto_compact_token_limit {
config.auto_compact_token_limit = value;
}
if let Some(value) = patch.tool_output_token_limit {
config.tool_output_token_limit = value;
}
if let Some(value) = patch.request_timeout_ms {
config.request_timeout_ms = value;
}
@@ -1076,6 +1132,15 @@ pub(crate) fn merge_game_creator_llm_patch(
if let Some(value) = patch.web_search_enabled {
config.web_search_enabled = Some(value);
}
if let Some(value) = patch.context_window_tokens {
config.context_window_tokens = Some(value);
}
if let Some(value) = patch.auto_compact_token_limit {
config.auto_compact_token_limit = Some(value);
}
if let Some(value) = patch.tool_output_token_limit {
config.tool_output_token_limit = Some(value);
}
if let Some(value) = patch.request_timeout_ms {
config.request_timeout_ms = Some(value);
}
@@ -1158,6 +1223,7 @@ pub(crate) fn normalize_game_creator_app_config(
for agent_id in config.agent_llm.keys() {
let llm = resolve_game_creator_llm_config_for_agent(&config, agent_id);
validate_game_creator_llm_web_search_config(&llm, &format!("agentLlm.{agent_id}"))?;
validate_game_creator_llm_timing_config(&llm, &format!("agentLlm.{agent_id}"))?;
}
config.editor_api.base_url = trim_config_string(&config.editor_api.base_url)
.ok_or_else(|| "配置项 editorApi.baseUrl 不能为空".to_string())?;
@@ -1199,6 +1265,15 @@ pub(crate) fn normalize_game_creator_llm_patch_config(
"配置项 agentLlm.{agent_id}.retryBackoffMs 必须大于 0"
));
}
for (field, value) in [
("contextWindowTokens", patch.context_window_tokens),
("autoCompactTokenLimit", patch.auto_compact_token_limit),
("toolOutputTokenLimit", patch.tool_output_token_limit),
] {
if value.is_some_and(|value| value == 0) {
return Err(format!("配置项 agentLlm.{agent_id}.{field} 必须大于 0"));
}
}
Ok(patch)
}
@@ -1210,6 +1285,9 @@ pub(crate) fn is_empty_game_creator_llm_patch(patch: &GameCreatorLlmConfigFile)
&& patch.reasoning_effort.is_none()
&& patch.stream.is_none()
&& patch.web_search_enabled.is_none()
&& patch.context_window_tokens.is_none()
&& patch.auto_compact_token_limit.is_none()
&& patch.tool_output_token_limit.is_none()
&& patch.request_timeout_ms.is_none()
&& patch.max_retries.is_none()
&& patch.retry_backoff_ms.is_none()
File diff suppressed because it is too large Load Diff
@@ -50,6 +50,7 @@ mod command_sandbox;
mod command_sandbox_trampoline;
mod commands;
mod config;
mod context_compaction;
#[cfg(all(debug_assertions, not(test)))]
mod debug;
mod delegation;
@@ -76,6 +77,7 @@ use command_output::*;
use command_sandbox::*;
use commands::*;
use config::*;
use context_compaction::*;
use delegation::*;
use git_inspect::*;
use goal::*;
@@ -111,7 +113,7 @@ struct LocalProjectDirectoryStatus {
recent_run_stop_reason: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct LocalPreviewResult {
url: String,
@@ -244,6 +246,8 @@ struct AgentRuntimeState {
#[serde(default)]
queued_steer_count: u32,
#[serde(default)]
context_usage: AgentRuntimeContextUsage,
#[serde(default)]
last_response: Option<String>,
#[serde(default)]
error: Option<String>,
@@ -251,6 +255,29 @@ struct AgentRuntimeState {
updated_at: u64,
}
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct AgentRuntimeContextUsage {
#[serde(default)]
estimated_input_tokens: u64,
#[serde(default)]
auto_compact_token_limit: u64,
#[serde(default)]
last_prompt_tokens: Option<u64>,
#[serde(default)]
last_completion_tokens: Option<u64>,
#[serde(default)]
last_total_tokens: Option<u64>,
#[serde(default)]
compaction_revision: u64,
#[serde(default)]
compaction_count: u64,
#[serde(default)]
last_compaction_trigger: Option<String>,
#[serde(default)]
last_compacted_at: Option<u64>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct AgentRuntimeSteerRef {
@@ -577,6 +604,9 @@ struct GameCreatorLlmConfigStatus {
reasoning_effort: String,
stream: bool,
web_search_enabled: bool,
context_window_tokens: u64,
auto_compact_token_limit: u64,
tool_output_token_limit: u64,
error: Option<String>,
agents: Vec<GameCreatorAgentLlmConfigStatus>,
}
@@ -594,6 +624,9 @@ struct GameCreatorAgentLlmConfigStatus {
reasoning_effort: String,
stream: bool,
web_search_enabled: bool,
context_window_tokens: u64,
auto_compact_token_limit: u64,
tool_output_token_limit: u64,
error: Option<String>,
}
@@ -623,6 +656,12 @@ struct GameCreatorLlmConfigFile {
#[serde(skip_serializing_if = "Option::is_none")]
web_search_enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
context_window_tokens: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
auto_compact_token_limit: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
tool_output_token_limit: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
request_timeout_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
max_retries: Option<u32>,
@@ -656,6 +695,12 @@ struct GameCreatorLlmConfig {
reasoning_effort: String,
stream: bool,
web_search_enabled: bool,
#[serde(default = "default_game_creator_llm_context_window_tokens")]
context_window_tokens: u64,
#[serde(default = "default_game_creator_llm_auto_compact_token_limit")]
auto_compact_token_limit: u64,
#[serde(default = "default_game_creator_llm_tool_output_token_limit")]
tool_output_token_limit: u64,
request_timeout_ms: u64,
max_retries: u32,
retry_backoff_ms: u64,
@@ -675,6 +720,26 @@ struct GameCreatorAppConfigView {
config: GameCreatorAppConfig,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct AgentRuntimeContextCompactionResult {
agent_id: String,
session_id: String,
run_id: Option<String>,
trigger: String,
revision: u64,
estimated_tokens_before: u64,
estimated_tokens_after: u64,
prompt_tokens: Option<u64>,
completion_tokens: Option<u64>,
total_tokens: Option<u64>,
covered_agent_messages: u64,
covered_project_messages: u64,
covered_observations: u64,
reused: bool,
compacted_at: u64,
}
#[derive(Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct AgentRunControlResult {
@@ -992,13 +1057,32 @@ const DEFAULT_GAME_CREATOR_LLM_BASE_URL: &str = "https://api.openai.com/v1";
const DEFAULT_GAME_CREATOR_LLM_MODEL: &str = "gpt-4.1";
const DEFAULT_GAME_CREATOR_LLM_API_KIND: &str = "openai_responses";
const DEFAULT_GAME_CREATOR_LLM_REASONING_EFFORT: &str = "high";
const DEFAULT_GAME_CREATOR_LLM_CONTEXT_WINDOW_TOKENS: u64 = 128_000;
const DEFAULT_GAME_CREATOR_LLM_AUTO_COMPACT_TOKEN_LIMIT: u64 = 64_000;
const DEFAULT_GAME_CREATOR_LLM_TOOL_OUTPUT_TOKEN_LIMIT: u64 = 12_000;
fn default_game_creator_llm_context_window_tokens() -> u64 {
DEFAULT_GAME_CREATOR_LLM_CONTEXT_WINDOW_TOKENS
}
fn default_game_creator_llm_auto_compact_token_limit() -> u64 {
DEFAULT_GAME_CREATOR_LLM_AUTO_COMPACT_TOKEN_LIMIT
}
fn default_game_creator_llm_tool_output_token_limit() -> u64 {
DEFAULT_GAME_CREATOR_LLM_TOOL_OUTPUT_TOKEN_LIMIT
}
const DEFAULT_CANVAS_SYNC_API_BASE_URL: &str = "http://127.0.0.1:8082";
const DEFAULT_GAME_CREATOR_APP_CONFIG_JSON: &str = include_str!("../../game-creator.config.json");
const GAME_CREATOR_LLM_MAX_OUTPUT_TOKENS: u32 = 320000;
const GAME_CREATOR_CHAT_AGENT_MAX_OUTPUT_TOKENS: u32 = 1800;
const GAME_CREATOR_PLANNER_MAX_OUTPUT_TOKENS: u32 = 900;
const GAME_CREATOR_ROLE_AGENT_MAX_OUTPUT_TOKENS: u32 = 1200;
const GAME_CREATOR_REQUIRED_LLM_AGENT_IDS: [&str; 2] = ["planner", "generator"];
const GAME_CREATOR_REQUIRED_LLM_AGENT_IDS: [&str; 3] = [
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
"planner",
"generator",
];
const MIN_GAME_CREATOR_LLM_REQUEST_TIMEOUT_MS: u64 = 1_000;
const GAME_CREATOR_LLM_REQUEST_TIMEOUT_MS: u64 = 180_000;
const GAME_CREATOR_AGENT_LOOP_MAX_PASSES: u8 = 3;
@@ -1058,6 +1142,9 @@ impl Default for GameCreatorLlmConfig {
reasoning_effort: DEFAULT_GAME_CREATOR_LLM_REASONING_EFFORT.to_string(),
stream: false,
web_search_enabled: false,
context_window_tokens: DEFAULT_GAME_CREATOR_LLM_CONTEXT_WINDOW_TOKENS,
auto_compact_token_limit: DEFAULT_GAME_CREATOR_LLM_AUTO_COMPACT_TOKEN_LIMIT,
tool_output_token_limit: DEFAULT_GAME_CREATOR_LLM_TOOL_OUTPUT_TOKEN_LIMIT,
request_timeout_ms: GAME_CREATOR_LLM_REQUEST_TIMEOUT_MS,
max_retries: 0,
retry_backoff_ms: DEFAULT_RETRY_BACKOFF_MS,
@@ -1536,6 +1623,7 @@ fn main() {
chat_with_game_creator_role_agent,
chat_with_game_creator_role_agent_stream,
start_game_creator_agent_runtime_task,
compact_game_creator_agent_runtime_context,
read_game_creator_agent_goal,
start_game_creator_agent_goal,
edit_game_creator_agent_goal,
@@ -1295,7 +1295,7 @@ fn validate_agent_db_lifecycle_record_semantics(
record
.get("requestKind")
.and_then(serde_json::Value::as_str),
Some("tool-plan" | "final-reply")
Some("tool-plan" | "final-reply" | "context-compaction")
) {
return Err("Agent DB Provider lifecycle requestKind 无效".to_string());
}
@@ -8977,6 +8977,36 @@ mod agent_db_security_tests {
vec!["started", "completed"]
);
}
let compaction_request_id = provider_request_id('9');
for status in ["started", "completed"] {
let mut record = provider_lifecycle_record_with_schema(
&compaction_request_id,
status,
AGENT_DB_PROVIDER_REQUEST_LIFECYCLE_SCHEMA_V2,
Some(false),
);
record["requestKind"] = serde_json::Value::String("context-compaction".to_string());
append_agent_db_lifecycle_record_idempotent(
&root,
"requestId",
&compaction_request_id,
"status",
status,
record,
)
.unwrap_or_else(|error| panic!("append context compaction {status}: {error}"));
}
assert_eq!(
read_agent_db_lifecycle_transitions_at(
&root,
AGENT_DB_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE,
"requestId",
&compaction_request_id,
)
.expect("read context compaction Provider lifecycle"),
vec!["started", "completed"]
);
fs::remove_dir_all(root).ok();
}
@@ -12,6 +12,8 @@ use std::sync::{Arc, Mutex, MutexGuard, OnceLock};
use std::thread;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use crate::AgentRuntimeContextCompactionResult;
pub(crate) const EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION: u32 = 3;
const EXTERNAL_AGENT_RUNNER_ENDPOINT_FILE_NAME: &str = "agent-runner.endpoint.json";
@@ -29,6 +31,7 @@ const EXTERNAL_AGENT_RUNNER_MAX_CACHED_REQUESTS: usize = 512;
const EXTERNAL_AGENT_RUNNER_RETRYABLE_WAKE_ERROR_CODE: &str = "runtime-wake-retryable";
const EXTERNAL_AGENT_RUNNER_CONNECT_TIMEOUT: Duration = Duration::from_secs(2);
const EXTERNAL_AGENT_RUNNER_IO_TIMEOUT: Duration = Duration::from_secs(10);
const EXTERNAL_AGENT_RUNNER_CONTEXT_COMPACTION_IO_TIMEOUT: Duration = Duration::from_secs(6 * 60);
const EXTERNAL_AGENT_RUNNER_START_TIMEOUT: Duration = Duration::from_secs(6);
const EXTERNAL_AGENT_RUNNER_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(2);
const EXTERNAL_AGENT_RUNNER_LOOP_INTERVAL: Duration = Duration::from_millis(25);
@@ -158,6 +161,8 @@ struct ExternalAgentRunnerRequestParams {
#[serde(default, alias = "agentId", skip_serializing_if = "Option::is_none")]
agent: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
session_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
run_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
action_id: Option<String>,
@@ -1996,6 +2001,24 @@ fn external_agent_runner_request_agent(
Ok(agent.to_string())
}
fn external_agent_runner_request_session_id(
request: &ExternalAgentRunnerRequest,
) -> Result<Option<String>, String> {
let Some(session_id) = request
.params
.session_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return Ok(None);
};
if session_id.len() > 256 {
return Err("Runtime 请求 sessionId 过长".to_string());
}
Ok(Some(session_id.to_string()))
}
fn external_agent_runner_request_run_id(
request: &ExternalAgentRunnerRequest,
) -> Result<String, String> {
@@ -2294,6 +2317,7 @@ fn dispatch_external_agent_runner_runtime_request(
| "runtime.steer"
| "runtime.pause"
| "runtime.cancel"
| "runtime.compact"
) && state.draining.load(Ordering::Acquire)
{
return ExternalAgentRunnerResponse::failure(
@@ -2310,7 +2334,8 @@ fn dispatch_external_agent_runner_runtime_request(
| "runtime.continue_action"
| "runtime.steer"
| "runtime.pause"
| "runtime.cancel" => {
| "runtime.cancel"
| "runtime.compact" => {
let root = match external_agent_runner_request_root(request) {
Ok(root) => root,
Err(error) => {
@@ -2338,6 +2363,19 @@ fn dispatch_external_agent_runner_runtime_request(
"runtime.resume" => crate::resume_game_creator_agent_background_tasks_at(&root)
.map(|_| json!({ "accepted": true }))
.map_err(|error| error.to_string()),
"runtime.compact" => (|| {
let agent = external_agent_runner_request_agent(request)?;
let session_id = external_agent_runner_request_session_id(request)?;
let result = tauri::async_runtime::block_on(
crate::compact_game_creator_agent_runtime_session_at(
&root,
&agent,
session_id.as_deref(),
),
)?;
serde_json::to_value(result)
.map_err(|error| format!("序列化上下文压缩结果失败:{error}"))
})(),
"runtime.continue_action" => (|| {
let agent = external_agent_runner_request_agent(request)?;
let run_id = external_agent_runner_request_run_id(request)?;
@@ -2587,6 +2625,7 @@ fn handle_external_agent_runner_request(
| "runtime.steer"
| "runtime.pause"
| "runtime.cancel"
| "runtime.compact"
| "runner.shutdown_if_idle"
| "shutdown_if_idle" => dispatch_external_agent_runner_runtime_request(&request, state),
_ => ExternalAgentRunnerResponse::failure(
@@ -3121,8 +3160,9 @@ fn send_external_agent_runner_request_with_protocol_and_id(
let address = SocketAddrV4::new(Ipv4Addr::LOCALHOST, endpoint.port).into();
let mut stream = TcpStream::connect_timeout(&address, EXTERNAL_AGENT_RUNNER_CONNECT_TIMEOUT)
.map_err(|error| format!("连接 Agent Runner 失败:{error}"))?;
let io_timeout = external_agent_runner_client_read_timeout(method);
stream
.set_read_timeout(Some(EXTERNAL_AGENT_RUNNER_IO_TIMEOUT))
.set_read_timeout(Some(io_timeout))
.and_then(|_| stream.set_write_timeout(Some(EXTERNAL_AGENT_RUNNER_IO_TIMEOUT)))
.map_err(|error| format!("配置 Agent Runner 客户端超时失败:{error}"))?;
write_external_agent_runner_frame(&mut stream, &payload)
@@ -3153,6 +3193,14 @@ fn send_external_agent_runner_request_with_protocol_and_id(
))
}
fn external_agent_runner_client_read_timeout(method: &str) -> Duration {
if method == "runtime.compact" {
EXTERNAL_AGENT_RUNNER_CONTEXT_COMPACTION_IO_TIMEOUT
} else {
EXTERNAL_AGENT_RUNNER_IO_TIMEOUT
}
}
fn send_external_agent_runner_request_with_id(
endpoint: &ExternalAgentRunnerEndpoint,
request_id: String,
@@ -3384,6 +3432,7 @@ fn send_external_agent_runner_runtime_request_with_stable_identity(
let params = ExternalAgentRunnerRequestParams {
root: Some(root.to_string()),
agent: agent.map(str::to_string),
session_id: None,
run_id: run_id.map(str::to_string),
action_id: action_id.map(str::to_string),
steer_id: steer_id.map(str::to_string),
@@ -3403,6 +3452,43 @@ fn send_external_agent_runner_runtime_request_with_stable_identity(
}
}
pub(crate) fn compact_external_agent_runner_context(
root: &Path,
agent: &str,
session_id: Option<&str>,
) -> Result<AgentRuntimeContextCompactionResult, String> {
let agent = agent.trim();
if agent.is_empty() {
return Err("手动压缩 Agent 上下文必须提供 agent".to_string());
}
let root = canonicalize_external_agent_runner_project_root(root)?;
let root_text = root
.to_str()
.ok_or_else(|| "通知 Agent Runner 的项目 root 必须是 UTF-8 路径".to_string())?;
let config_dir = external_agent_runner_config_dir()
.ok_or_else(|| "外部 Agent Runner 尚未配置".to_string())?;
crate::validate_game_creator_runtime_config_dir_outside_project(&config_dir, &root)?;
let endpoint = {
let _configure = lock_unpoisoned(external_agent_runner_configure_lock());
ensure_external_agent_runner(&config_dir)?
};
let result = send_external_agent_runner_request(
&endpoint,
"runtime.compact",
ExternalAgentRunnerRequestParams {
root: Some(root_text.to_string()),
agent: Some(agent.to_string()),
session_id: session_id
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string),
..ExternalAgentRunnerRequestParams::default()
},
)?;
serde_json::from_value(result)
.map_err(|error| format!("解析 Agent Runner 上下文压缩结果失败:{error}"))
}
pub(crate) fn wake_external_agent_runner_pending(root: &Path) -> Result<(), String> {
send_external_agent_runner_runtime_request(root, "runtime.wake_pending", None, None, None, None)
.map(|_| ())
@@ -3656,6 +3742,22 @@ mod tests {
);
}
#[test]
fn context_compaction_client_uses_long_response_timeout_without_widening_other_methods() {
assert_eq!(
external_agent_runner_client_read_timeout("runtime.compact"),
EXTERNAL_AGENT_RUNNER_CONTEXT_COMPACTION_IO_TIMEOUT
);
assert!(
external_agent_runner_client_read_timeout("runtime.compact")
> EXTERNAL_AGENT_RUNNER_IO_TIMEOUT
);
assert_eq!(
external_agent_runner_client_read_timeout("runtime.start"),
EXTERNAL_AGENT_RUNNER_IO_TIMEOUT
);
}
fn test_endpoint(token: &str, boot_id: &str, port: u16) -> ExternalAgentRunnerEndpoint {
ExternalAgentRunnerEndpoint {
protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
@@ -4155,7 +4257,7 @@ mod tests {
}
#[test]
fn draining_rejects_runtime_steer() {
fn draining_rejects_runtime_steer_and_compact() {
let directory = unique_test_directory();
let token = "steer-draining-token-steer-draining-token";
let state = ExternalAgentRunnerServerState::new(
@@ -4185,6 +4287,30 @@ mod tests {
response.error.as_ref().map(|error| error.code.as_str()),
Some("runner-draining")
);
let compact_response = handle_external_agent_runner_request(
ExternalAgentRunnerRequest {
protocol_version: EXTERNAL_AGENT_RUNNER_PROTOCOL_VERSION,
request_id: "draining-compact-1".to_string(),
token: token.to_string(),
method: "runtime.compact".to_string(),
params: ExternalAgentRunnerRequestParams {
root: Some(directory.0.to_string_lossy().into_owned()),
agent: Some("code-prototype".to_string()),
session_id: Some("agent-session-code-prototype".to_string()),
..ExternalAgentRunnerRequestParams::default()
},
},
&state,
);
assert!(!compact_response.ok);
assert_eq!(
compact_response
.error
.as_ref()
.map(|error| error.code.as_str()),
Some("runner-draining")
);
}
#[test]
@@ -15,6 +15,7 @@ enum SwarmChatInput {
Agents,
Status,
History,
Compact,
Goal(SwarmGoalCommand),
InvalidGoal(String),
Quit,
@@ -157,6 +158,7 @@ fn run_game_creator_swarm_chat_with_input<W: Write>(
enforce_project_permission_policy(root, "conversation.read")?;
enforce_project_permission_policy(root, "conversation.write")?;
enforce_project_permission_policy(root, "agent.run_status")?;
enforce_project_permission_policy(root, "agent.compact")?;
enforce_project_permission_policy(root, "agent.resume")?;
let _ = read_local_conversation_for_session_at(root, Some(parent_agent_id), None)?;
let resumed = resume_game_creator_agent_background_tasks_at(root)?;
@@ -217,6 +219,9 @@ fn run_game_creator_swarm_chat_with_input<W: Write>(
SwarmChatInput::Agents => print_swarm_agents(root, output)?,
SwarmChatInput::Status => print_swarm_status(root, output)?,
SwarmChatInput::History => print_conversation_history(root, parent_agent_id, output)?,
SwarmChatInput::Compact => {
handle_swarm_context_compaction(root, parent_agent_id, output)?
}
SwarmChatInput::Goal(command) => {
let mut observer = SwarmRuntimeObserver::seed(root)?;
let Some(observation) =
@@ -394,6 +399,10 @@ fn prompt_swarm_decision<W: Write>(
SwarmChatInput::InvalidGoal(error) => {
print_swarm_goal_error(output, &error)?;
}
SwarmChatInput::Compact => {
handle_swarm_context_compaction(root, parent_agent_id, output)?;
return Ok(SwarmPromptDecision::Deferred);
}
_ => {}
}
}
@@ -436,6 +445,7 @@ fn parse_swarm_chat_input(input: &str) -> Option<SwarmChatInput> {
"/agents" => SwarmChatInput::Agents,
"/status" => SwarmChatInput::Status,
"/history" => SwarmChatInput::History,
"/compact" => SwarmChatInput::Compact,
"/quit" | "/exit" => SwarmChatInput::Quit,
value => SwarmChatInput::Message(value.to_string()),
})
@@ -482,6 +492,7 @@ fn print_swarm_chat_help<W: Write>(output: &mut W) -> Result<(), String> {
writeln!(output, "/agents 查看静态 Agent 与动态 child")
.and_then(|_| writeln!(output, "/status 查看全部 Runtime 状态"))
.and_then(|_| writeln!(output, "/history 查看父 Agent 当前 Session 历史"))
.and_then(|_| writeln!(output, "/compact 压缩父 Agent 当前空闲 Session 历史"))
.and_then(|_| writeln!(output, "/goal <目标> 启动当前 Session 的持久 Goal"))
.and_then(|_| writeln!(output, "/goal 查看当前 Goal"))
.and_then(|_| writeln!(output, "/goal status 查看当前 Goal"))
@@ -494,6 +505,33 @@ fn print_swarm_chat_help<W: Write>(output: &mut W) -> Result<(), String> {
.map_err(|error| format!("写入终端失败:{error}"))
}
fn handle_swarm_context_compaction<W: Write>(
root: &Path,
parent_agent_id: &str,
output: &mut W,
) -> Result<(), String> {
let conversation = read_local_conversation_for_session_at(root, Some(parent_agent_id), None)?;
match compact_external_agent_runner_context(
root,
parent_agent_id,
conversation.session_id.as_deref(),
) {
Ok(result) => writeln!(
output,
"[上下文压缩] revision={} reused={} estimated={}->{} covered={}/{}/{}",
result.revision,
result.reused,
result.estimated_tokens_before,
result.estimated_tokens_after,
result.covered_agent_messages,
result.covered_project_messages,
result.covered_observations,
),
Err(error) => writeln!(output, "[上下文压缩失败] {error}"),
}
.map_err(|error| format!("写入终端失败:{error}"))
}
fn handle_swarm_goal_command<W: Write>(
root: &Path,
parent_agent_id: &str,
@@ -859,6 +897,9 @@ fn wait_for_swarm_turn<W: Write>(
SwarmChatInput::History => {
print_conversation_history(root, parent_agent_id, output)?
}
SwarmChatInput::Compact => {
handle_swarm_context_compaction(root, parent_agent_id, output)?
}
SwarmChatInput::Goal(command) => {
let _ = handle_swarm_goal_command(root, parent_agent_id, command, output)?;
stable_since = None;
@@ -1504,6 +1545,34 @@ fn print_runtime_state<W: Write>(
)
.map_err(|error| format!("写入终端失败:{error}"))?;
}
writeln!(
output,
"[上下文] estimated={}/{} actual={}/{}/{} compaction={} last={}",
state.context_usage.estimated_input_tokens,
state.context_usage.auto_compact_token_limit,
state
.context_usage
.last_prompt_tokens
.map(|value| value.to_string())
.unwrap_or_else(|| "-".to_string()),
state
.context_usage
.last_completion_tokens
.map(|value| value.to_string())
.unwrap_or_else(|| "-".to_string()),
state
.context_usage
.last_total_tokens
.map(|value| value.to_string())
.unwrap_or_else(|| "-".to_string()),
state.context_usage.compaction_revision,
state
.context_usage
.last_compacted_at
.map(|value| value.to_string())
.unwrap_or_else(|| "-".to_string()),
)
.map_err(|error| format!("写入终端失败:{error}"))?;
for step in state.plan_steps.iter().take(SWARM_CHAT_PLAN_STEP_LIMIT) {
writeln!(
@@ -1597,6 +1666,16 @@ fn runtime_state_signature(
);
signature.push(':');
signature.push_str(&state.plan_explanation);
signature.push(':');
signature.push_str(&format!(
"{}:{}:{:?}:{:?}:{}:{:?}",
state.context_usage.estimated_input_tokens,
state.context_usage.auto_compact_token_limit,
state.context_usage.last_prompt_tokens,
state.context_usage.last_completion_tokens,
state.context_usage.compaction_revision,
state.context_usage.last_compacted_at,
));
signature
}
@@ -1684,6 +1763,14 @@ mod tests {
Some(SwarmChatInput::Agents)
);
assert_eq!(parse_swarm_chat_input("/exit"), Some(SwarmChatInput::Quit));
assert_eq!(
parse_swarm_chat_input("/status"),
Some(SwarmChatInput::Status)
);
assert_eq!(
parse_swarm_chat_input("/compact"),
Some(SwarmChatInput::Compact)
);
assert_eq!(
parse_swarm_chat_input("让策划和程序并行检查玩法"),
Some(SwarmChatInput::Message(
@@ -1751,6 +1838,8 @@ mod tests {
let output = String::from_utf8(output).expect("help output is utf-8");
for command in [
"/status",
"/compact",
"/goal <目标>",
"/goal status",
"/goal edit <目标>",
File diff suppressed because it is too large Load Diff
+277
View File
@@ -306,6 +306,7 @@ interface AgentRuntimeState {
toolPolicy?: AgentRuntimeToolPolicySnapshot;
appliedSteerCursor?: number;
queuedSteerCount?: number;
contextUsage?: AgentRuntimeContextUsage;
lastResponse: string | null;
error: string | null;
updatedAt: number;
@@ -313,6 +314,36 @@ interface AgentRuntimeState {
recentTasks?: AgentRuntimeTaskRecord[];
}
interface AgentRuntimeContextUsage {
estimatedInputTokens: number;
autoCompactTokenLimit: number;
lastPromptTokens: number | null;
lastCompletionTokens: number | null;
lastTotalTokens: number | null;
compactionRevision: number;
compactionCount: number;
lastCompactionTrigger: string | null;
lastCompactedAt: number | null;
}
interface AgentRuntimeContextCompactionResult {
agentId: string;
sessionId: string;
runId: string | null;
trigger: string;
revision: number;
estimatedTokensBefore: number;
estimatedTokensAfter: number;
promptTokens: number | null;
completionTokens: number | null;
totalTokens: number | null;
coveredAgentMessages: number;
coveredProjectMessages: number;
coveredObservations: number;
reused: boolean;
compactedAt: number;
}
interface AgentRuntimeToolPolicySnapshot {
allowedTools: string[];
autoTools: string[];
@@ -500,6 +531,9 @@ interface GameCreatorLlmConfigStatus {
reasoningEffort: GameCreatorLlmReasoningEffort;
stream: boolean;
webSearchEnabled: boolean;
contextWindowTokens?: number;
autoCompactTokenLimit?: number;
toolOutputTokenLimit?: number;
error: string | null;
agents?: GameCreatorAgentLlmConfigStatus[];
}
@@ -515,6 +549,9 @@ interface GameCreatorAgentLlmConfigStatus {
reasoningEffort: GameCreatorLlmReasoningEffort;
stream: boolean;
webSearchEnabled: boolean;
contextWindowTokens?: number;
autoCompactTokenLimit?: number;
toolOutputTokenLimit?: number;
error: string | null;
}
@@ -535,6 +572,9 @@ interface GameCreatorLlmConfig {
reasoningEffort: GameCreatorLlmReasoningEffort;
stream: boolean;
webSearchEnabled: boolean;
contextWindowTokens: number;
autoCompactTokenLimit: number;
toolOutputTokenLimit: number;
requestTimeoutMs: number;
maxRetries: number;
retryBackoffMs: number;
@@ -944,6 +984,18 @@ function normalizeAgentRuntimeState(
maxLoopIterations:
state.maxLoopIterations ?? previous?.maxLoopIterations ?? 3,
toolActionBudget: state.toolActionBudget ?? previous?.toolActionBudget ?? 3,
contextUsage: state.contextUsage ??
previous?.contextUsage ?? {
estimatedInputTokens: 0,
autoCompactTokenLimit: 0,
lastPromptTokens: null,
lastCompletionTokens: null,
lastTotalTokens: null,
compactionRevision: 0,
compactionCount: 0,
lastCompactionTrigger: null,
lastCompactedAt: null,
},
plan: planState.plan ?? planFallbackState?.plan ?? [],
planRevision: normalizeAgentRuntimePlanRevision(
planState.planRevision,
@@ -1616,6 +1668,7 @@ function AgentRuntimeStatusPanel({
onRetryRuntimeTask,
onConfirmRuntimeTask,
onRejectRuntimeTask,
onCompactContext,
onRefreshRuntime,
}: {
runtime: AgentRuntimeState | null;
@@ -1625,6 +1678,7 @@ function AgentRuntimeStatusPanel({
onRetryRuntimeTask?: (runId: string) => void;
onConfirmRuntimeTask?: (runId: string, actionId: string) => void;
onRejectRuntimeTask?: (runId: string, actionId: string) => void;
onCompactContext?: () => void;
onRefreshRuntime?: () => void;
}) {
const [showAllRecentEvents, setShowAllRecentEvents] = useState(false);
@@ -1719,6 +1773,14 @@ function AgentRuntimeStatusPanel({
Boolean(pendingToolAction?.actionId) &&
agentRuntimeCanConfirm(runtime.status) &&
Boolean(onRejectRuntimeTask);
const canCompact =
runtime.status === 'idle' &&
['idle', 'completed'].includes(runtime.phase) &&
!pendingToolAction &&
(runtime.taskQueue?.pending ?? 0) === 0 &&
(runtime.taskQueue?.running ?? 0) === 0 &&
(runtime.taskQueue?.waitingForConfirmation ?? 0) === 0 &&
Boolean(onCompactContext);
return (
<section className="agent-runtime-status" aria-label="Agent Runtime 状态">
<header>
@@ -1745,8 +1807,16 @@ function AgentRuntimeStatusPanel({
onRetryRuntimeTask ||
onConfirmRuntimeTask ||
onRejectRuntimeTask ||
onCompactContext ||
onRefreshRuntime ? (
<div className="agent-runtime-actions" aria-label="Agent Runtime 操作">
<button
type="button"
disabled={controlBusy || !canCompact}
onClick={() => onCompactContext?.()}
>
压缩上下文
</button>
<button
type="button"
disabled={controlBusy || !canConfirm}
@@ -1820,6 +1890,11 @@ function AgentRuntimeStatusPanel({
) : null}
{loopProgress ? <small>{loopProgress}</small> : null}
{taskQueueSummary ? <small>{taskQueueSummary}</small> : null}
{runtime.contextUsage ? (
<small>
{`上下文:预计 ${runtime.contextUsage.estimatedInputTokens}/${runtime.contextUsage.autoCompactTokenLimit || '-'} tokens · 最近实际 ${runtime.contextUsage.lastPromptTokens ?? '-'}/${runtime.contextUsage.lastCompletionTokens ?? '-'} · 压缩 revision ${runtime.contextUsage.compactionRevision}`}
</small>
) : null}
{toolPolicy ? (
<small>
{`工具策略:auto ${toolPolicy.autoTools.length} · confirm ${toolPolicy.confirmTools.length} · deny ${toolPolicy.deniedTools.length}`}
@@ -2508,6 +2583,9 @@ const defaultRuntimeConfigDraft: GameCreatorAppConfig = {
reasoningEffort: 'high',
stream: false,
webSearchEnabled: false,
contextWindowTokens: 128000,
autoCompactTokenLimit: 64000,
toolOutputTokenLimit: 12000,
requestTimeoutMs: 180000,
maxRetries: 0,
retryBackoffMs: 500,
@@ -2648,6 +2726,24 @@ function normalizeRuntimeAgentLlmConfig(
if (typeof config.webSearchEnabled === 'boolean') {
normalized.webSearchEnabled = config.webSearchEnabled;
}
if (typeof config.contextWindowTokens === 'number') {
normalized.contextWindowTokens = clampRuntimeConfigNumber(
config.contextWindowTokens,
1,
);
}
if (typeof config.autoCompactTokenLimit === 'number') {
normalized.autoCompactTokenLimit = clampRuntimeConfigNumber(
config.autoCompactTokenLimit,
1,
);
}
if (typeof config.toolOutputTokenLimit === 'number') {
normalized.toolOutputTokenLimit = clampRuntimeConfigNumber(
config.toolOutputTokenLimit,
1,
);
}
if (typeof config.requestTimeoutMs === 'number') {
normalized.requestTimeoutMs = clampRuntimeConfigNumber(
config.requestTimeoutMs,
@@ -2698,6 +2794,24 @@ function normalizeRuntimeConfigDraft(
typeof config.llm.webSearchEnabled === 'boolean'
? config.llm.webSearchEnabled
: defaultRuntimeConfigDraft.llm.webSearchEnabled,
contextWindowTokens: clampRuntimeConfigNumber(
typeof config.llm.contextWindowTokens === 'number'
? config.llm.contextWindowTokens
: defaultRuntimeConfigDraft.llm.contextWindowTokens,
1,
),
autoCompactTokenLimit: clampRuntimeConfigNumber(
typeof config.llm.autoCompactTokenLimit === 'number'
? config.llm.autoCompactTokenLimit
: defaultRuntimeConfigDraft.llm.autoCompactTokenLimit,
1,
),
toolOutputTokenLimit: clampRuntimeConfigNumber(
typeof config.llm.toolOutputTokenLimit === 'number'
? config.llm.toolOutputTokenLimit
: defaultRuntimeConfigDraft.llm.toolOutputTokenLimit,
1,
),
requestTimeoutMs: clampRuntimeConfigNumber(
config.llm.requestTimeoutMs,
1000,
@@ -3714,6 +3828,51 @@ function RuntimeConfigDialog({
/>
LLM 联网检索
</label>
<label>
LLM 上下文窗口 tokens
<input
aria-label="LLM 上下文窗口 tokens"
type="number"
min="1"
value={runtimeConfigDraft.llm.contextWindowTokens}
onChange={(event) =>
updateRuntimeLlmConfig(
'contextWindowTokens',
Math.max(1, Number(event.currentTarget.value) || 1),
)
}
/>
</label>
<label>
LLM 自动压缩阈值 tokens
<input
aria-label="LLM 自动压缩阈值 tokens"
type="number"
min="1"
value={runtimeConfigDraft.llm.autoCompactTokenLimit}
onChange={(event) =>
updateRuntimeLlmConfig(
'autoCompactTokenLimit',
Math.max(1, Number(event.currentTarget.value) || 1),
)
}
/>
</label>
<label>
LLM 工具输出上限 tokens
<input
aria-label="LLM 工具输出上限 tokens"
type="number"
min="1"
value={runtimeConfigDraft.llm.toolOutputTokenLimit}
onChange={(event) =>
updateRuntimeLlmConfig(
'toolOutputTokenLimit',
Math.max(1, Number(event.currentTarget.value) || 1),
)
}
/>
</label>
<label>
LLM 超时 ms
<input
@@ -3926,6 +4085,63 @@ function RuntimeConfigDialog({
<option value="false">关闭</option>
</select>
</label>
<label>
{agent.label} 上下文窗口 tokens
<input
aria-label={`${agent.label} 上下文窗口 tokens`}
type="number"
min="1"
placeholder="继承"
value={agentLlm.contextWindowTokens ?? ''}
onChange={(event) =>
updateRuntimeAgentLlmConfig(
agent.id,
'contextWindowTokens',
event.currentTarget.value
? Math.max(1, Number(event.currentTarget.value) || 1)
: undefined,
)
}
/>
</label>
<label>
{agent.label} 自动压缩阈值 tokens
<input
aria-label={`${agent.label} 自动压缩阈值 tokens`}
type="number"
min="1"
placeholder="继承"
value={agentLlm.autoCompactTokenLimit ?? ''}
onChange={(event) =>
updateRuntimeAgentLlmConfig(
agent.id,
'autoCompactTokenLimit',
event.currentTarget.value
? Math.max(1, Number(event.currentTarget.value) || 1)
: undefined,
)
}
/>
</label>
<label>
{agent.label} 工具输出上限 tokens
<input
aria-label={`${agent.label} 工具输出上限 tokens`}
type="number"
min="1"
placeholder="继承"
value={agentLlm.toolOutputTokenLimit ?? ''}
onChange={(event) =>
updateRuntimeAgentLlmConfig(
agent.id,
'toolOutputTokenLimit',
event.currentTarget.value
? Math.max(1, Number(event.currentTarget.value) || 1)
: undefined,
)
}
/>
</label>
</Fragment>
);
})}
@@ -6568,6 +6784,62 @@ export function WorkspaceLauncher({
}
}
async function handleAgentChatCompactContext() {
const projectPathForChat = validateAgentChatProjectPath();
const agent = selectedLauncherAgentChatAgent();
const selectedSession = selectedLauncherAgentChatSession();
if (
!projectPathForChat ||
!agent ||
!selectedSession ||
selectedSession.archivedAt
) {
setAgentChatStatus('请选择可写的 Agent Session');
return;
}
const invoke = resolveTauriInvoke();
if (!invoke) {
setAgentChatStatus('上下文压缩需要在 Tauri App 内运行');
return;
}
setAgentChatBackgroundBusy(true);
setAgentChatStatus('正在压缩当前 Agent Session 的旧上下文');
try {
const result = await invoke<AgentRuntimeContextCompactionResult>(
'compact_game_creator_agent_runtime_context',
{
projectPath: projectPathForChat,
agentId: agent.id,
sessionId: selectedSession.sessionId,
},
);
const runtime = await invoke<AgentRuntimeResult>(
'read_game_creator_agent_runtime',
{
projectPath: projectPathForChat,
agentId: agent.id,
sessionId: selectedSession.sessionId,
},
);
setAgentChatRuntime((current) =>
agentRuntimeStateFromResult(runtime, current),
);
setAgentChatStatus(
result.reused
? `上下文已是最新压缩 revision ${result.revision}`
: `上下文压缩完成:revision ${result.revision},预计 ${result.estimatedTokensBefore} -> ${result.estimatedTokensAfter} tokens`,
);
} catch (error) {
setAgentChatStatus(
`上下文压缩失败:${
error instanceof Error ? error.message : String(error)
}`,
);
} finally {
setAgentChatBackgroundBusy(false);
}
}
async function handleAgentChatCancelRuntimeTask(runId: string) {
const projectPathForChat = validateAgentChatProjectPath();
const agent = selectedLauncherAgentChatAgent();
@@ -7817,6 +8089,11 @@ export function WorkspaceLauncher({
actionId,
)
}
onCompactContext={
currentAgentChatSessionArchived
? undefined
: () => void handleAgentChatCompactContext()
}
onRefreshRuntime={() =>
void loadAgentChatConversation(
agentChatSelectedAgentId,
@@ -4888,6 +4888,199 @@ describe('AI 游戏创作 App 界面边界', () => {
expect(await screen.findByText('running / observation')).not.toBeNull();
});
it('shows context usage and manually compacts an idle Agent session', async () => {
const sessionId = 'agent-session-design-director';
let compacted = false;
const runtimeResult = () => ({
state: {
schemaVersion: 'game-creator-agent-runtime.v1',
agentId: 'design-director',
taskId: 'design-director',
sessionId,
runId: 'context-completed-run',
source: 'agent-background-task',
status: 'idle',
phase: 'completed',
currentTask: '已完成角色规范梳理',
currentAction: '等待输入',
waitingOn: '开发者输入',
nextStep: '等待输入',
loopIteration: 2,
maxLoopIterations: 6,
toolActionBudget: 3,
plan: [],
observations: [],
recentToolCalls: [],
pendingToolAction: null,
taskQueue: {
total: 1,
pending: 0,
running: 0,
waitingForConfirmation: 0,
cancelled: 0,
completed: 1,
failed: 0,
latestRunId: 'context-completed-run',
updatedAt: 5000,
},
contextUsage: compacted
? {
estimatedInputTokens: 12000,
autoCompactTokenLimit: 48000,
lastPromptTokens: 321,
lastCompletionTokens: 45,
lastTotalTokens: 366,
compactionRevision: 3,
compactionCount: 3,
lastCompactionTrigger: 'manual',
lastCompactedAt: 6000,
}
: {
estimatedInputTokens: 42000,
autoCompactTokenLimit: 48000,
lastPromptTokens: 41000,
lastCompletionTokens: 900,
lastTotalTokens: 41900,
compactionRevision: 2,
compactionCount: 2,
lastCompactionTrigger: 'auto',
lastCompactedAt: 5000,
},
allowedTools: ['file.read'],
lastResponse: '角色规范已整理完成。',
error: null,
updatedAt: compacted ? 6000 : 5000,
},
sessionPath:
'/tmp/authorized-game/.agent/runtime/agents/design-director.json',
eventPath:
'/tmp/authorized-game/.agent/runtime/events/design-director.jsonl',
taskPath:
'/tmp/authorized-game/.agent/runtime/tasks/design-director.jsonl',
taskQueue: {
total: 1,
pending: 0,
running: 0,
waitingForConfirmation: 0,
cancelled: 0,
completed: 1,
failed: 0,
latestRunId: 'context-completed-run',
updatedAt: 5000,
},
recentEvents: [],
recentTasks: [],
});
const invoke = vi.fn(async (command: string) => {
if (command === 'check_game_creator_llm_config') {
return {
configured: true,
apiKeyPresent: true,
baseUrl: 'https://llm.example.test/v1',
model: 'gpt-5.5',
apiKind: 'openai_chat',
reasoningEffort: 'high',
stream: true,
webSearchEnabled: false,
contextWindowTokens: 96000,
autoCompactTokenLimit: 48000,
toolOutputTokenLimit: 8000,
error: null,
agents: [],
};
}
if (command === 'list_game_creator_agent_sessions') {
return {
path: '/tmp/authorized-game/.agent/runtime/sessions/design-director.json',
agentId: 'design-director',
activeSessionId: sessionId,
sessions: [
{
sessionId,
title: '角色规范',
createdAt: 1,
updatedAt: 2,
archivedAt: null,
messageCount: 8,
legacy: false,
},
],
};
}
if (command === 'read_local_conversation') {
return {
path: `/tmp/authorized-game/.agent/conversations/agents/design-director/sessions/${sessionId}.jsonl`,
agentId: 'design-director',
sessionId,
messages: [],
};
}
if (command === 'read_game_creator_agent_runtime') {
return runtimeResult();
}
if (command === 'compact_game_creator_agent_runtime_context') {
compacted = true;
return {
agentId: 'design-director',
sessionId,
runId: 'context-completed-run',
trigger: 'manual',
revision: 3,
estimatedTokensBefore: 42000,
estimatedTokensAfter: 12000,
promptTokens: 321,
completionTokens: 45,
totalTokens: 366,
coveredAgentMessages: 4,
coveredProjectMessages: 0,
coveredObservations: 0,
reused: false,
compactedAt: 6000,
};
}
throw new Error(`unexpected invoke ${command}`);
});
window.__TAURI__ = { core: { invoke } };
renderLauncherAgentChatAt('/?agent-chat');
fireEvent.change(screen.getByLabelText('Agent 聊天项目目录'), {
target: { value: '/tmp/authorized-game' },
});
fireEvent.click(screen.getByRole('button', { name: '读取历史' }));
expect(
await screen.findByText(
'上下文:预计 42000/48000 tokens · 最近实际 41000/900 · 压缩 revision 2',
),
).not.toBeNull();
const compactButton = within(
screen.getByLabelText('Agent Runtime 操作'),
).getByRole('button', { name: '压缩上下文' }) as HTMLButtonElement;
expect(compactButton.disabled).toBe(false);
fireEvent.click(compactButton);
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith(
'compact_game_creator_agent_runtime_context',
{
projectPath: '/tmp/authorized-game',
agentId: 'design-director',
sessionId,
},
);
});
expect(
await screen.findByText(
'上下文压缩完成:revision 3,预计 42000 -> 12000 tokens',
),
).not.toBeNull();
expect(
await screen.findByText(
'上下文:预计 12000/48000 tokens · 最近实际 321/45 · 压缩 revision 3',
),
).not.toBeNull();
});
it('shows queued developer agent background tasks when the agent is already running', async () => {
const runningRuntimeState = {
schemaVersion: 'game-creator-agent-runtime.v1',
@@ -5646,6 +5839,18 @@ describe('AI 游戏创作 App 界面边界', () => {
'value',
'openai_responses',
);
expect(screen.getByLabelText('LLM 上下文窗口 tokens')).toHaveProperty(
'value',
'128000',
);
expect(screen.getByLabelText('LLM 自动压缩阈值 tokens')).toHaveProperty(
'value',
'64000',
);
expect(screen.getByLabelText('LLM 工具输出上限 tokens')).toHaveProperty(
'value',
'12000',
);
fireEvent.change(screen.getByLabelText('LLM 模型'), {
target: { value: 'gpt-launcher-updated' },
});
@@ -18435,6 +18640,22 @@ describe('AI 游戏创作 App 界面边界', () => {
expect(supervisorWebSearch).toHaveProperty('value', 'true');
fireEvent.change(supervisorWebSearch, { target: { value: 'false' } });
expect(supervisorWebSearch).toHaveProperty('value', 'false');
expect(screen.getByLabelText('LLM 上下文窗口 tokens')).toHaveProperty(
'value',
'128000',
);
expect(screen.getByLabelText('LLM 自动压缩阈值 tokens')).toHaveProperty(
'value',
'64000',
);
expect(screen.getByLabelText('LLM 工具输出上限 tokens')).toHaveProperty(
'value',
'12000',
);
expect(screen.getByLabelText('Planner 上下文窗口 tokens')).toHaveProperty(
'value',
'',
);
fireEvent.change(screen.getByLabelText('LLM API Key'), {
target: { value: 'unit-new-secret-value' },
@@ -18450,6 +18671,15 @@ describe('AI 游戏创作 App 界面边界', () => {
});
fireEvent.click(screen.getByLabelText('LLM 流式请求'));
fireEvent.click(screen.getByLabelText('LLM 联网检索'));
fireEvent.change(screen.getByLabelText('LLM 上下文窗口 tokens'), {
target: { value: '160000' },
});
fireEvent.change(screen.getByLabelText('LLM 自动压缩阈值 tokens'), {
target: { value: '80000' },
});
fireEvent.change(screen.getByLabelText('LLM 工具输出上限 tokens'), {
target: { value: '10000' },
});
fireEvent.change(screen.getByLabelText('LLM 超时 ms'), {
target: { value: '90000' },
});
@@ -18471,6 +18701,15 @@ describe('AI 游戏创作 App 界面边界', () => {
fireEvent.change(screen.getByLabelText('Generator LLM 流式请求'), {
target: { value: 'true' },
});
fireEvent.change(screen.getByLabelText('Planner 上下文窗口 tokens'), {
target: { value: '96000' },
});
fireEvent.change(screen.getByLabelText('Planner 自动压缩阈值 tokens'), {
target: { value: '48000' },
});
fireEvent.change(screen.getByLabelText('Planner 工具输出上限 tokens'), {
target: { value: '8000' },
});
fireEvent.change(
screen.getByLabelText('规划美术资产 (art/Asset) LLM Provider'),
{
@@ -18497,6 +18736,9 @@ describe('AI 游戏创作 App 界面边界', () => {
reasoningEffort: 'medium',
stream: true,
webSearchEnabled: true,
contextWindowTokens: 160000,
autoCompactTokenLimit: 80000,
toolOutputTokenLimit: 10000,
requestTimeoutMs: 90000,
maxRetries: 3,
retryBackoffMs: 800,
@@ -18510,6 +18752,9 @@ describe('AI 游戏创作 App 界面边界', () => {
reasoningEffort: 'default',
stream: false,
webSearchEnabled: false,
contextWindowTokens: 96000,
autoCompactTokenLimit: 48000,
toolOutputTokenLimit: 8000,
},
'project-supervisor': {
webSearchEnabled: false,
@@ -18598,6 +18843,18 @@ describe('AI 游戏创作 App 界面边界', () => {
'value',
'',
);
expect(screen.getByLabelText('LLM 上下文窗口 tokens')).toHaveProperty(
'value',
'128000',
);
expect(screen.getByLabelText('LLM 自动压缩阈值 tokens')).toHaveProperty(
'value',
'64000',
);
expect(screen.getByLabelText('LLM 工具输出上限 tokens')).toHaveProperty(
'value',
'12000',
);
expect(screen.getByLabelText('画板 API Base URL')).toHaveProperty(
'value',
'http://127.0.0.1:8082',
@@ -18615,6 +18872,9 @@ describe('AI 游戏创作 App 界面边界', () => {
reasoningEffort: 'high',
stream: false,
webSearchEnabled: false,
contextWindowTokens: 128000,
autoCompactTokenLimit: 64000,
toolOutputTokenLimit: 12000,
requestTimeoutMs: 180000,
maxRetries: 0,
retryBackoffMs: 500,
@@ -4616,6 +4616,19 @@
- 审计:后台 Provider lifecycle 升级 v2 并只新增 `webSearchEnabled`;v1 缺省 false 只读兼容,requestId 不变。状态/UI/CLI 展示解析后布尔值;公共 Agent DB 不保存 query、URL、结果或网页正文。真实 Provider 必须用隔离 AppData 验证,不支持时记录明确失败。
- 真实结论:2026-07-15 当前正式 `openai_chat / gpt-5.5` 路由三轮 `web-search` suite 均 FAIL。请求 lifecycle 显示搜索开启且上游完成,但模型明确报告没有 Provider 原生搜索能力,动态 GitHub release baseline 未命中;复验产生 3 个 planning request identity,也没有搜索结果证据。因此不得把“网关接受 `web_search_options`”当作能力可用,当前路由继续关闭该配置。最终验收使用正式 AppData 同级的 `0600` 私有配置副本,源配置 inode/nlink/timestamps/hash 前后完全一致;正式 AppData/Runner 零写入、零 endpoint 漂移,所有凭据/路径/诱饵泄漏计数为 0,隔离现场已完整清理。
## 2026-07-15 AI 游戏创作 Agent Runtime V1.21 token-aware 持久上下文压缩
- 顺序:MCP 动态工具目录与输出会进一步放大上下文,因此先补 Codex 风格 token-aware compaction,再进入 MCP。当前固定 12 条 conversation/observation 截断不再作为“已具备压缩”的完成证据。
- 配置:`llm` 增加 `contextWindowTokens=128000 / autoCompactTokenLimit=64000 / toolOutputTokenLimit=12000`,`agentLlm` 可逐 Agent 覆盖。预算估算必须包含 function schema;Provider usage 单独标记为真实值,不能与估算混用。
- 边界:只压缩旧 Agent/legacy conversation 和当前 run 的旧 observation,保留最近精确 tail;Goal、任务、结构化计划、steer、pending action、project/repository revision、verification、process/join/delegate、receipt 和 finalization 身份保持规范事实,不进入摘要改写。
- 持久化:私有 `game-creator-runtime-context-compaction.v1` sidecar 绑定 Agent/Session、source prefix 指纹、可选 run、summary 指纹、预算与 usage;同源幂等,追加后 revision 单调,前缀漂移失败关闭。context bundle 只绑定压缩元数据,不复制 summary 正文。
- 请求安全:compaction 使用独立 Provider lifecycle、稳定 request slot、零工具和零 web search。未知 started 或 completed 后 sidecar 未提交均按 orphan barrier 进入 reconciliation,禁止自动重发;sidecar 已提交后恢复直接复用。
- 入口:自动压缩只发生在 background planning 安全边界;开发 Agent UI 与 `agc:chat` / `agc:swarm` 提供 `/compact`,但 in-flight Provider、执行中工具、pending confirmation 或未收束 Runtime 时拒绝手动压缩。正式用户 Supervisor 页面不增加压缩控件。
- 验收:除配置、幂等、篡改、恢复和公共零正文回归外,真实套件必须完成至少 30 轮、两次压缩和一次 Runner 强杀,证明请求低于阈值、原身份不变、工具零重放、唯一 assistant 与早期约束可召回;此前不得宣称整体 PASS。
- 语义修正:历史“每 6 轮形成上下文压缩窗口”的表述由本条取代;6 轮只形成进度 checkpoint 并执行停滞检测,不改写 observation。真正摘要只由 token 阈值或显式 `/compact` 触发。
- 实现收口:显式用户约束由确定性保留层逐字钉住并继续做凭据/绝对路径脱敏;`runtime.compact` 单独使用 6 分钟 IPC 响应窗口,其他 Runner 方法仍为 10 秒;普通后台任务公共审计只保存 `taskChars + taskSha256`;终态旧 bundle 只有在完整身份、Goal、revision、verification、observation、sidecar、steer 校验通过后才可刷新 legacy plan 投影。
- 真实验收:2026-07-15 正式 `openai_chat / gpt-5.5` 路由的隔离 `context-compaction` suite PASS。30/30 轮、两次 compaction revision、一次 pidfd Runner 强杀恢复、早期约束召回和 29134/64000 最大估算输入均满足;30 个 tool-plan 与 2 个 compaction lifecycle 唯一闭合,fallback replay、重复 message/audit、工具重放和公共正文/summary/API Key/诱饵/项目路径/正式配置路径泄漏均为 0。首轮第 22 轮 Provider transport 终态按规则 FAIL 且零重放,新 disposable 项目完整重跑取得 PASS,全部一次性现场已按 sentinel 清理。
## 2026-07-13 普通微信支付 V3 退款使用统一观察事务闭环
- 背景:普通微信支付 V3 的退款申请响应、退款结果回调、主动查单和商户平台手工退款发现可能重复、乱序或只出现其中一种;原充值订单只有单一终态,无法表达多次部分退款、权益回收欠款和会员人工处理。
@@ -48,7 +48,7 @@ AppData runner endpoint (protocol + port + private token)
### 契约
新增 `RepositoryStartupContext`,每个 run 在首次 planning 前确定性装配,恢复时按相同规则重建。正文不混入普通 observation,也不被 6 轮压缩窗口删除;context bundle 只保存 schema、来源清单和 fingerprint。
新增 `RepositoryStartupContext`,每个 run 在首次 planning 前确定性装配,恢复时按相同规则重建。正文不混入普通 observation,不受每 6 轮一次的进度 checkpoint / 停滞检测影响,也不交给 V1.21 summary 改写;context bundle 只保存 schema、来源清单和 fingerprint。
启动上下文最多包含:
@@ -345,16 +345,16 @@ npm run ai-game-creator-shell:agent-runtime:real-e2e -- --config-dir <AppData> -
最终形成 94 条 task、156 条 event、140 条 Agent DB 记录和 11 次合法工具计划协议;12 次成功工具执行中 `git.inspect=2`、`project.patchset=1`、`agent.spawn_isolated=1`,project revision 仍为 3。Runner 强制终止后恢复同一 run/session,项目验证、桌面/移动浏览器验证、3 个隔离实例和唯一 join 均通过;副作用重放、重复 action/message/receipt、半完成文件、已加载密钥和 4 类项目诱饵泄露均为 0,临时项目按 sentinel 自动清理。
## V1.5 长任务关键动作账本
## V1.5 长任务关键动作账本(历史方案)
真实 Git E2E 首轮回归暴露了长任务跨 context window 的遗忘问题:早期 `agent.spawn_isolated` 成功 observation 被后续读取与验证挤出窗口后,真实模型再次请求 spawn,验收以 `side-effect-action-replay-detected` 正确失败。Runtime 因此增加压缩期 `runtime.milestones` 安全摘要:
真实 Git E2E 首轮回归曾暴露长任务跨 context window 的遗忘问题:早期 `agent.spawn_isolated` 成功 observation 被后续读取与验证挤出窗口后,真实模型再次请求 spawn,验收以 `side-effect-action-replay-detected` 正确失败。V1.5 当时增加固定 6 轮窗口与 `runtime.milestones` 安全摘要;V1.21 已删除这套伪压缩实现,以下仅保留历史决策背景:
- 账本汇总最近已成功完成的 `agent.spawn_isolated`、`agent.delegate`、`canvas.asset_generate`、`preview.validate`、`project.patchset`、`project.restore` 和 `task.create`,明确提示除非任务要求重试,否则不得重复高成本或有副作用动作。
- `runtime.milestones` 只携带经过路径与凭据清洗的工具名、结果摘要和有界安全 detail;它不是新事实源。精确 actionId、输入、执行状态与恢复语义仍只以 pending-action、task/event、Agent DB 和 sidecar 为准。
- 账本在下一次压缩时合并旧账本和新里程碑,旧 `runtime.context` / `runtime.milestones` 不占普通最近观察槽位;单测覆盖连续两个以上窗口仍保留 spawn、patchset 和 checkpointId。
- 历史账本曾在下一固定窗口合并旧账本和新里程碑,并用 `runtime.context` / `runtime.milestones` 代替部分普通 observation。
- checkpoint 内容 diff 与 Git 工作树 diff 使用两个独立保护槽。后续 `git.inspect` 不得再挤掉已完成 `project.diff` 的 checkpointId/hunk,反之亦然;两类大 detail 仍共同受 128 KiB context bundle 总上限约束。
修复后的真实 `gpt-5.5` 回归在 10 轮内完成并收束,唯一 spawn / patchset / join 均保持 1 次,两次 Git 审阅和两类 diff 同时留在最终 context bundle,重复副作用为 0。
当时修复后的真实 `gpt-5.5` 回归在 10 轮内完成并收束,唯一 spawn / patchset / join 均保持 1 次,两次 Git 审阅和两类 diff 同时留在最终 context bundle,重复副作用为 0。当前 Runtime 每 6 轮只做进度 checkpoint 与停滞检测,完整 observation 继续保留在私有 bundle;真正摘要只由 V1.21 token 阈值或显式 `/compact` 触发,副作用去重继续以规范 action receipt 与各类 durable barrier 为准。
## V1.6 持久动作回执与模型回查
@@ -862,6 +862,45 @@ V1.20 对标 Codex CLI 的可选 Web Search,但只声明当前 `platform-llm`
2026-07-15 对当前正式配置的 `openai_chat / gpt-5.5` 路由执行了三轮真实 `web-search` suite,结果均为 **FAIL**,不能标记该网关已支持原生联网检索。脚本先从 GitHub Releases API 动态读取 `nodejs/node` 最新 stable release,再只给隔离 AppData 中的 `code-prototype` 写入无密钥 `webSearchEnabled=true` overlay。上游接受请求并为 `tool-plan` 写出 v2 `started -> completed` lifecycle,但模型明确判断“当前可用工具不包含 Provider 原生联网搜索能力”,转而尝试本地 `conversation.read / agent.action_history`,最终没有返回动态 baseline;复验观察到 3 个搜索开启的 planning request identity,进一步证明 `web_search_options` 被接受不等于搜索实际生效。三轮均为唯一 assistant、零 API Key/诱饵/项目路径/正式配置路径泄漏,正式配置 CLI 调用为 0,源 Runner endpoint 未变化;最终复验把凭据配置放在正式 AppData 同级的 `0600` 私有副本中,源配置 `dev/inode/nlink/size/mode/mtime/ctime/SHA-256` 前后完全一致,不再用 hardlink 改变源 inode 元数据。隔离 Runner 由 Linux pidfd 精确停止,隔离 AppData 和 disposable 项目均按 sentinel 清理。当前路由应保持搜索关闭,待网关明确支持后重跑;suite 允许 Runtime 直接提交 planning response,只有实际发生 `final-reply` 时才要求其 `webSearchEnabled=false`。
## V1.21 单 Agent token-aware 持久上下文压缩
V1.21 对标 Codex CLI 的 `model_context_window`、`model_auto_compact_token_limit`、`tool_output_token_limit` 和 `/compact`。它替换“固定保留最近 12 条就算压缩”的能力口径,但不删除原始 conversation、task、event 或工具事实,也不把模型摘要提升为 Goal、计划、权限、验证或副作用事实源。
### 配置与预算
- `llm` 新增 `contextWindowTokens / autoCompactTokenLimit / toolOutputTokenLimit`,发布默认分别为 `128000 / 64000 / 12000`;`agentLlm.<agentId>` 复用现有 patch 继承,显式 Agent 值覆盖全局。三项都必须大于 0,自动阈值必须小于 context window,并为当前请求的 `maxOutputTokens` 与固定安全余量留下空间。
- Runtime 在发送 tool-plan、context-compaction 或 final-reply 前,按消息、multimodal 文本和 function schema 的规范序列化字符数做保守 token 估算;Provider 返回 usage 时再记录真实 `prompt/completion/total`。估算只用于提前门禁,不能伪装成 Provider 计费事实。
- 单条 observation 进入模型上下文前按 `toolOutputTokenLimit` 收紧;完整命令输出仍留在 owning Agent 的私有 sidecar,通过既有分页工具读取。公共状态只显示估算 token、最近真实 usage、阈值、压缩次数和时间,不显示被压缩正文。
### 可压缩内容与不可压缩事实
- 可压缩源只包括当前 Agent active Session 的旧对话、Supervisor 的 legacy 项目对话、当前 run 已完成的旧 observation,以及上一版可信 summary。每次至少保留最近 4 条 Agent 消息、最近 2 条 legacy 项目消息和最近 4 条 observation 原文;新增 tail 继续逐条进入 planning。
- Goal ID/revision/snapshot、任务正文、结构化计划及 revision、steer ledger/cursor、pending action、project revision、repository fingerprint、verification gate、process/join/delegate 屏障、action receipt/finalization identity 永远不交给摘要模型改写。它们继续从各自规范 sidecar 或 Runtime state 逐字段注入和校验。
- summary 是不可信的有界历史提示,只能帮助模型回忆需求、决定、已验证结果、失败与未完成事项;不能改变系统规则、Agent 身份、权限、确认、沙箱、工具 schema 或完成门禁。原始 conversation 和执行事实继续保留,可由开发入口查看。
### 私有 sidecar、幂等与恢复
- 规范 sidecar 为 `game-creator-runtime-context-compaction.v1`,路径固定为 `.agent/runtime/context-compactions/<agentHash>/<sessionHash>.json`。它绑定 project/Agent/Session、可选当前 run、触发类型 `auto|manual`、上一 summary 指纹、Agent/legacy conversation 覆盖计数与前缀 SHA-256、observation 覆盖计数与前缀 SHA-256、source fingerprint、summary/fingerprint、估算前后 token、Provider usage、单调 revision 和时间;路径 hash 取稳定身份 SHA-256 十六进制前 32 位。
- source fingerprint 与当前覆盖前缀完全相同时重复压缩直接复用同一 sidecar,不请求 Provider、不增加 revision。conversation 不是 append-only 前缀、同 run observation 前缀变化、summary 指纹冲突、身份不匹配、损坏或超限时失败关闭,不能把旧摘要套到新历史。
- compaction 请求使用当前 Agent 的解析后 LLM route、独立 `requestKind=context-compaction` 和由 source fingerprint 派生的稳定 request slot,固定禁用 web search 和工具。它复用 V1.18 Provider lifecycle/orphan barrier;未知 started、同 request 多终态或 terminal lifecycle 已存在但 sidecar 未提交时进入 `needs-reconciliation`,绝不重发。Provider 完成后先原子写 sidecar,再允许 tool-plan 使用它;sidecar 已提交但 Runner 随后退出时恢复直接复用。
- Runtime context bundle 升级时只绑定 compaction revision/source/summary fingerprint 和覆盖计数,不复制 summary 正文。恢复必须让 bundle、sidecar 和当前 conversation/observation 前缀一致;旧 bundle 可在原有身份、Goal、计划和 verification 校验通过后补入缺省“未压缩”绑定,后续 checkpoint 写新版本。
### 自动与手动入口
- 每次 background tool-plan 构建后先计算输入估算。超过解析后的 `autoCompactTokenLimit` 时,在同 Agent/Session/run 的安全 planning 边界压缩可压缩 prefix,重建请求并再次估算;重建后仍超阈值或没有新的可压缩 prefix 时失败关闭并给出配置/新 Session 建议,不能继续发送已知超限请求。
- `agc:chat` / `agc:swarm` 新增 `/compact`,开发 Agent 窗口提供同一动作和状态。手动压缩只允许当前 Agent active Session 没有 in-flight Provider、执行中工具、待确认动作或未收束 Runtime 时进行;有活动 run 时由自动安全边界处理,不能从 UI 直接打断副作用。正式用户 Project Supervisor 页面不增加压缩按钮。
- `/status` 和开发面板展示 `estimatedInputTokens / autoCompactTokenLimit / lastPromptTokens / lastCompletionTokens / compactionRevision / lastCompactedAt`。手动和自动都写相同的哈希/计数审计,公共 event、Agent DB、receipt、activity/output 和报告不得出现 summary、原 conversation/observation、任务、路径或凭据正文。
### 验收口径
- 确定性测试覆盖配置默认值与 per-Agent 继承、预算非法组合、token 估算包含 function schema、工具输出限额、自动阈值、手动 `/compact`、最近 tail 保留、同源幂等、追加后 revision 单调、conversation/observation 前缀篡改失败关闭、sidecar/bundle 身份冲突、summary 上限和公共审计零正文。
- Provider 生命周期测试覆盖 started 前退出可安全重试、started 未终态进入 reconciliation、completed 后 sidecar 缺失零重放、sidecar 已提交后恢复复用,以及 Goal、计划、steer、pending、verification 和副作用身份压缩前后逐字段相同。
- 真实 Provider 使用隔离 AppData 和 disposable 项目完成至少 30 轮多轮任务,跨越至少两次压缩和一次 Runner 强杀;证明 planning 始终低于阈值、原 Agent/Session/run 身份稳定、已完成工具零重放、最终 assistant 唯一、summary 能引用早期用户约束,且全部公共持久面 API Key、原始对话/observation、项目绝对路径和 summary 正文泄漏为 0。未完成该长链路前只能记录确定性通过,不能宣称 V1.21 整体 PASS。
2026-07-15 使用正式 AppData 的 `openai_chat / gpt-5.5` 路由和隔离 Runner 执行 `context-compaction` suite,V1.21 整体 **PASS**。同一 Agent active Session 完成 30/30 轮、60 条 conversation message 和 30 个唯一 assistant audit;两次真实 Provider compaction 形成 revision 1/2,30 个 tool-plan 与 2 个 compaction request 共 32 组 lifecycle,全部唯一 `started -> completed`,fallback replay 为 0。Runner 通过 Linux pidfd `SIGKILL` 后 boot 变化、Session 身份保持稳定,早期用户显式约束可从最终回复召回;最大估算输入 29134,低于 64000 自动阈值。公共 task/event/Agent DB/conversation/report 中原始正文、summary、API Key、诱饵、项目绝对路径和正式配置路径泄漏均为 0,重复 message/audit、工具执行与 finalization journal 均为 0;隔离 AppData 和 disposable 项目按 sentinel 清理。首轮复验在第 22 轮收到 Provider `transport` 终态并按规则 FAIL,未自动重放;新 disposable 项目完整重跑后取得上述 PASS。
真实长链收口时同步修正四项实现边界:显式用户约束由确定性保留层逐字钉住并继续做密钥/绝对路径脱敏;`runtime.compact` 单独使用 6 分钟 IPC 响应窗口,其他 Runner 方法仍保持 10 秒;普通后台任务公共审计只保存 `taskChars + taskSha256`,任务正文仅留在私有 task ledger/conversation;每 6 轮只做进度 checkpoint 与停滞检测,真正摘要只由 token 阈值或显式 `/compact` 触发。终态旧 context bundle 仅允许在完整 schema、身份、Goal、revision、verification、observation、sidecar 和 steer 校验通过后刷新 legacy plan 投影差异。
## 验收命令
- `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml structured_plan_ -- --nocapture`
@@ -880,6 +919,7 @@ V1.20 对标 Codex CLI 的可选 Web Search,但只声明当前 `platform-llm`
- `npm run ai-game-creator-shell:agent-runtime:real-e2e -- --config-dir <AppData> --suite goal-runtime`
- `npm run ai-game-creator-shell:agent-runtime:real-e2e -- --config-dir <AppData> --suite response-stream`
- `npm run ai-game-creator-shell:agent-runtime:real-e2e -- --config-dir <AppData> --suite web-search`
- `npm run ai-game-creator-shell:agent-runtime:real-e2e -- --config-dir <AppData> --suite context-compaction`
- `npm run ai-game-creator-shell:agent-runtime:real-e2e -- --config-dir <AppData> --suite full`
- `npm run check:encoding`
- `git diff --check`
File diff suppressed because one or more lines are too long
@@ -19,7 +19,7 @@ describe('AI 游戏创作 App 共享契约', () => {
it('keeps command permissions explicit', () => {
const commandIds = GAME_CREATION_APP_COMMANDS.map((command) => command.id);
expect(GAME_CREATION_APP_COMMANDS).toHaveLength(61);
expect(GAME_CREATION_APP_COMMANDS).toHaveLength(62);
expect(commandIds).toContain('project.git_inspect');
expect(commandIds).toContain('project.git_commit');
expect(commandIds).toContain('project.patchset');
@@ -245,7 +245,7 @@ describe('AI 游戏创作 App 共享契约', () => {
(capability) => capability.id,
);
expect(GAME_CREATION_AGENT_CAPABILITIES).toHaveLength(38);
expect(GAME_CREATION_AGENT_CAPABILITIES).toHaveLength(39);
expect(capabilityIds).toEqual(
expect.arrayContaining([
'chat',
@@ -33,6 +33,7 @@ export const GAME_CREATION_APP_COMMANDS = [
{ id: 'task.update', permission: 'confirm' },
{ id: 'agent.trace_read', permission: 'auto' },
{ id: 'agent.run_status', permission: 'auto' },
{ id: 'agent.compact', permission: 'auto' },
{ id: 'agent.kill', permission: 'confirm' },
{ id: 'agent.retry', permission: 'confirm' },
{ id: 'agent.resume', permission: 'confirm' },
@@ -94,6 +95,11 @@ export const GAME_CREATION_AGENT_CAPABILITIES = [
area: 'agent-runtime',
title: 'Provider 原生联网检索',
},
{
id: 'context-compaction',
area: 'agent-runtime',
title: '持久上下文压缩',
},
{ id: 'task-decomposition', area: 'agent-runtime', title: '任务拆分' },
{ id: 'orchestration', area: 'agent-runtime', title: '任务编排' },
{
@@ -21,7 +21,7 @@ pub struct GameCreationAppCommandDescriptor {
pub permission: GameCreationAppPermission,
}
pub const GAME_CREATION_APP_COMMANDS: [GameCreationAppCommandDescriptor; 61] = [
pub const GAME_CREATION_APP_COMMANDS: [GameCreationAppCommandDescriptor; 62] = [
command("help.show", GameCreationAppPermission::Auto),
command("project.create", GameCreationAppPermission::Confirm),
command("project.status", GameCreationAppPermission::Auto),
@@ -42,6 +42,7 @@ pub const GAME_CREATION_APP_COMMANDS: [GameCreationAppCommandDescriptor; 61] = [
command("task.update", GameCreationAppPermission::Confirm),
command("agent.trace_read", GameCreationAppPermission::Auto),
command("agent.run_status", GameCreationAppPermission::Auto),
command("agent.compact", GameCreationAppPermission::Auto),
command("agent.kill", GameCreationAppPermission::Confirm),
command("agent.retry", GameCreationAppPermission::Confirm),
command("agent.resume", GameCreationAppPermission::Confirm),
@@ -102,13 +103,9 @@ pub struct GameCreationAgentCapabilityDescriptor {
pub platforms: Option<&'static [&'static str]>,
}
pub const GAME_CREATION_AGENT_CAPABILITIES: [GameCreationAgentCapabilityDescriptor; 38] = [
pub const GAME_CREATION_AGENT_CAPABILITIES: [GameCreationAgentCapabilityDescriptor; 39] = [
capability("chat", "user", "聊天入口"),
capability(
"project-supervisor",
"agent-runtime",
"项目总控 Agent",
),
capability("project-supervisor", "agent-runtime", "项目总控 Agent"),
capability("file-upload", "user", "上传文件"),
capability("built-in-commands", "agent-runtime", "内置命令调用"),
capability("llm-draft-generation", "agent-runtime", "LLM 草案生成"),
@@ -117,6 +114,7 @@ pub const GAME_CREATION_AGENT_CAPABILITIES: [GameCreationAgentCapabilityDescript
"agent-runtime",
"Provider 原生联网检索",
),
capability("context-compaction", "agent-runtime", "持久上下文压缩"),
capability("task-decomposition", "agent-runtime", "任务拆分"),
capability("orchestration", "agent-runtime", "任务编排"),
capability(
@@ -690,7 +688,7 @@ mod tests {
#[test]
fn command_contract_keeps_expected_permissions() {
assert_eq!(GAME_CREATION_APP_COMMANDS.len(), 61);
assert_eq!(GAME_CREATION_APP_COMMANDS.len(), 62);
let command_ids = GAME_CREATION_APP_COMMANDS
.iter()
@@ -1033,7 +1031,7 @@ mod tests {
#[test]
fn capabilities_cover_standard_agent_runtime_needs() {
assert_eq!(GAME_CREATION_AGENT_CAPABILITIES.len(), 38);
assert_eq!(GAME_CREATION_AGENT_CAPABILITIES.len(), 39);
let ids = GAME_CREATION_AGENT_CAPABILITIES
.iter()