实现Agent持久上下文压缩
新增全局与单 Agent token 预算、持久摘要 sidecar、自动及手动压缩恢复链路 接入 Runner、CLI /compact、开发 UI 状态和共享能力契约 补齐幂等、漂移、生命周期、恢复、隐私及真实三十轮端到端验收 同步 Runtime 技术方案、App 实施计划和长期决策记录
This commit is contained in:
@@ -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
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user