diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent.rs b/apps/ai-game-creator-shell/src-tauri/src/agent.rs index 2e77526e6..7f8ca7279 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -13,9 +13,15 @@ const AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_REJECTED: &str = "observed-re pub(crate) const AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO: &str = "auto"; pub(crate) const AGENT_RUNTIME_ACTION_EXECUTION_MODE_CONFIRMATION: &str = "confirmation"; pub(crate) const AGENT_RUNTIME_ACTION_FINGERPRINT_VERSION: &str = "sha256-serde-json-v1"; +pub(crate) const AGENT_RUNTIME_CONTEXT_BUNDLE_SCHEMA_VERSION: &str = + "game-creator-runtime-context-bundle.v1"; +pub(crate) const AGENT_RUNTIME_CONTEXT_OBSERVATION_LIMIT: usize = 12; +pub(crate) const AGENT_RUNTIME_CONTEXT_BUNDLE_MAX_BYTES: usize = 64 * 1024; +const AGENT_RUNTIME_CONTEXT_WINDOW_FINGERPRINT_LIMIT: usize = + AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT * (AGENT_RUNTIME_BACKGROUND_TOOL_ACTION_LIMIT + 1); const AGENT_RUNTIME_DELEGATE_RECEIPT_SOURCE: &str = "agent-delegate-receipt"; const AGENT_RUNTIME_DELEGATE_RECEIPT_TASK_MAX_CHARS: usize = 900; -const AGENT_RUNTIME_TASK_MAX_CHARS: usize = 4_000; +pub(crate) const AGENT_RUNTIME_TASK_MAX_CHARS: usize = 4_000; #[derive(Clone, Debug, Default, Eq, PartialEq)] struct AgentRuntimeTaskLink { @@ -293,6 +299,7 @@ where prompt, )?; let client = build_game_creator_llm_client_from_llm_config(&llm, &config_path)?; + let fallback_request = request.clone(); let mut streamed_reply_text = String::new(); let mut streamed_finish_reason = None; let response = client @@ -314,6 +321,21 @@ where { streamed_reply_text } + Err(error) + if streamed_reply_text.trim().is_empty() + && matches!( + error.kind(), + platform_llm::LlmErrorKind::StreamUnavailable + | platform_llm::LlmErrorKind::EmptyResponse + | platform_llm::LlmErrorKind::Deserialize + ) => + { + client.run(fallback_request).await.map_err(|fallback_error| { + format!( + "{config_path} 单 Agent 流式协议不可用且普通回复回退失败:流式错误:{error};普通回复错误:{fallback_error}" + ) + })?.text + } Err(error) => { return Err(format!( "{config_path} 单 Agent 流式聊天调用 LLM 失败:{error}" @@ -408,10 +430,13 @@ fn read_game_creator_agent_runtime_with_session_filter_at( state.error = Some(sanitize_agent_runtime_text(&error, 500)); } } - } else if state.status == "waiting-for-confirmation" { + } else { state.pending_tool_action = None; - state.error = - Some("待确认动作执行记录缺失;旧版本任务只能取消或重试,不能直接批准".to_string()); + if state.status == "waiting-for-confirmation" { + state.error = Some( + "待确认动作执行记录缺失;旧版本任务只能取消或重试,不能直接批准".to_string(), + ); + } } } if state_matches_session @@ -1524,6 +1549,15 @@ async fn continue_game_creator_agent_pending_tool_action( drain_next_game_creator_agent_background_tasks(root, agent_id).await; return; } + if let Err(error) = validate_agent_runtime_pending_context(&root, &runtime, &pending) { + let _ = mark_game_creator_agent_runtime_needs_reconciliation_at( + &root, + &mut runtime, + &pending, + &error, + ); + return; + } let action = pending.action.clone(); let approved = pending.approved(); let auto_execution = pending.is_auto(); @@ -1787,16 +1821,79 @@ async fn continue_game_creator_agent_pending_tool_action( ); let mut observations = pending.observations.clone(); observations.push(observation); + let mut continuation = match read_game_creator_agent_runtime_context_bundle(&root, &runtime) { + Ok(Some(bundle)) => continuation_from_game_creator_agent_runtime_context_bundle(bundle), + Ok(None) => { + let mut continuation = AgentRuntimeContinuationContext::default(); + continuation.window_completed_loops = usize::try_from(pending.loop_iteration) + .unwrap_or(usize::MAX) + .saturating_sub(1) + % AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT; + continuation + } + Err(error) => { + let session_id = runtime.session_id.clone(); + let _ = fail_game_creator_agent_background_context_at( + &root, + &agent_id, + &session_id, + runtime, + &format!("恢复 Agent Runtime context bundle 失败:{error}"), + ); + drain_next_game_creator_agent_background_tasks(root, agent_id).await; + return; + } + }; + let plan = pending.tool_plan(); + let next_loop_index = usize::try_from(pending.loop_iteration).unwrap_or(usize::MAX); + let mut context_tracker = AgentRuntimeContextWindowTracker::from_continuation(&continuation); + if let Some(observation) = observations.last() { + context_tracker.record(observation); + } + let checkpoint = match checkpoint_game_creator_agent_runtime_context( + &root, + &mut runtime, + &pending.task, + &plan, + &mut observations, + next_loop_index, + &mut context_tracker, + ) { + Ok(checkpoint) => checkpoint, + Err(error) => { + let session_id = runtime.session_id.clone(); + let _ = fail_game_creator_agent_background_context_at( + &root, + &agent_id, + &session_id, + runtime, + &format!("持久化 Agent Runtime context bundle 失败:{error}"), + ); + drain_next_game_creator_agent_background_tasks(root, agent_id).await; + return; + } + }; + continuation.plan = plan; + continuation.observations = observations; + continuation.next_loop_index = next_loop_index; + continuation.context_stalled = checkpoint == AgentRuntimeContextCheckpoint::Stalled; + context_tracker.apply_to_continuation(&mut continuation); + if let Err(error) = clear_game_creator_agent_runtime_observed_action_ledger(&root, &mut runtime) + { + let _ = mark_game_creator_agent_runtime_needs_reconciliation_at( + &root, + &mut runtime, + &pending, + &error, + ); + return; + } let outcome = run_game_creator_agent_background_task_with_context( root.clone(), agent_id.clone(), pending.task.clone(), runtime, - pending.tool_plan(), - observations, - usize::try_from(pending.loop_iteration) - .unwrap_or(AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT) - .min(AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT), + continuation, ) .await; if outcome == AgentBackgroundTaskOutcome::Finished { @@ -1983,22 +2080,73 @@ pub(crate) fn spawn_next_game_creator_agent_background_task_drain_with_lock( }); } +fn fail_game_creator_agent_background_context_at( + root: &Path, + agent_id: &str, + session_id: &str, + runtime: AgentRuntimeState, + error: &str, +) -> AgentBackgroundTaskOutcome { + let failed_runtime = fail_game_creator_agent_runtime_turn_at(root, runtime, error); + let _ = append_local_conversation_message_for_session_at( + root, + Some(agent_id), + Some(session_id), + LocalConversationMessage { + role: "assistant".to_string(), + content: format!("后台任务失败:{error}"), + agent_id: None, + }, + ); + if let Ok(runtime) = failed_runtime { + let _ = append_agent_db_record( + root, + serde_json::json!({ + "recordType": "agent.runtime.context.failed", + "agentId": runtime.agent_id, + "taskId": runtime.task_id, + "sessionId": runtime.session_id, + "runId": runtime.run_id, + "source": runtime.source, + "error": runtime.error, + }), + ); + } + AgentBackgroundTaskOutcome::Finished +} + async fn run_game_creator_agent_background_task( root: PathBuf, agent_id: String, task: String, state: AgentRuntimeState, ) -> AgentBackgroundTaskOutcome { - run_game_creator_agent_background_task_with_context( - root, - agent_id, - task, - state, - AgentRuntimeToolPlan::default(), - Vec::new(), - 0, - ) - .await + if let Err(error) = validate_agent_runtime_context_task_parameter(&root, &state, &task) { + let session_id = state.session_id.clone(); + return fail_game_creator_agent_background_context_at( + &root, + &agent_id, + &session_id, + state, + &error, + ); + } + let continuation = match read_game_creator_agent_runtime_context_bundle(&root, &state) { + Ok(Some(bundle)) => continuation_from_game_creator_agent_runtime_context_bundle(bundle), + Ok(None) => AgentRuntimeContinuationContext::default(), + Err(error) => { + let session_id = state.session_id.clone(); + return fail_game_creator_agent_background_context_at( + &root, + &agent_id, + &session_id, + state, + &format!("恢复 Agent Runtime context bundle 失败:{error}"), + ); + } + }; + run_game_creator_agent_background_task_with_context(root, agent_id, task, state, continuation) + .await } async fn run_game_creator_agent_background_task_with_context( @@ -2006,24 +2154,50 @@ async fn run_game_creator_agent_background_task_with_context( agent_id: String, task: String, state: AgentRuntimeState, - initial_plan: AgentRuntimeToolPlan, - initial_observations: Vec, - start_loop_index: usize, + continuation: AgentRuntimeContinuationContext, ) -> AgentBackgroundTaskOutcome { let session_id = state.session_id.clone(); let mut runtime = state; - let mut plan = initial_plan; - let mut observations = initial_observations; + let mut plan = continuation.plan.clone(); + let mut observations = continuation.observations.clone(); + let start_loop_index = continuation.next_loop_index; + let mut context_tracker = AgentRuntimeContextWindowTracker::from_continuation(&continuation); let mut final_reply = None; let mut converged = false; + let mut context_stalled = continuation.context_stalled; if stop_game_creator_agent_runtime_if_cancel_requested(&root, &mut runtime) { return AgentBackgroundTaskOutcome::Finished; } - for loop_index in start_loop_index..AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT { + if let Err(error) = persist_game_creator_agent_runtime_context( + &root, + &runtime, + &task, + &plan, + &observations, + start_loop_index, + &context_tracker, + ) { + return fail_game_creator_agent_background_context_at( + &root, + &agent_id, + &session_id, + runtime, + &error, + ); + } + + 'agent_loop: for loop_index in start_loop_index.. { + if context_stalled { + break 'agent_loop; + } runtime.loop_iteration = (loop_index + 1) as u32; - runtime.max_loop_iterations = AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT as u32; + runtime.max_loop_iterations = u32::try_from( + (loop_index / AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT + 1) + * AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT, + ) + .unwrap_or(u32::MAX); runtime.tool_action_budget = AGENT_RUNTIME_BACKGROUND_TOOL_ACTION_LIMIT as u32; let fallback = runtime.clone(); runtime = match advance_game_creator_agent_runtime_turn_at( @@ -2126,6 +2300,23 @@ async fn run_game_creator_agent_background_task_with_context( Some(&runtime.plan.join(" / ")), ); } + if let Err(error) = persist_game_creator_agent_runtime_context( + &root, + &runtime, + &task, + &plan, + &observations, + loop_index, + &context_tracker, + ) { + return fail_game_creator_agent_background_context_at( + &root, + &agent_id, + &session_id, + runtime, + &error, + ); + } if plan.actions.is_empty() { if let Some(blocker) = project_verification_completion_blocker(&observations) { @@ -2145,8 +2336,32 @@ async fn run_game_creator_agent_background_task_with_context( &blocker_summary, blocker.detail.as_deref(), ); + context_tracker.record(&blocker); observations.push(blocker); - continue; + match checkpoint_game_creator_agent_runtime_context( + &root, + &mut runtime, + &task, + &plan, + &mut observations, + loop_index + 1, + &mut context_tracker, + ) { + Ok(AgentRuntimeContextCheckpoint::Stalled) => { + context_stalled = true; + break 'agent_loop; + } + Ok(_) => continue, + Err(error) => { + return fail_game_creator_agent_background_context_at( + &root, + &agent_id, + &session_id, + runtime, + &error, + ); + } + } } converged = true; if !plan.response.trim().is_empty() { @@ -2158,6 +2373,25 @@ async fn run_game_creator_agent_background_task_with_context( let _ = write_game_creator_agent_runtime_state(&root, &runtime); final_reply = Some(plan.response.clone()); } + observations = compact_agent_runtime_context_observations(&root, &observations); + let _ = context_tracker.complete_loop(loop_index + 1); + if let Err(error) = persist_game_creator_agent_runtime_context( + &root, + &runtime, + &task, + &plan, + &observations, + loop_index + 1, + &context_tracker, + ) { + return fail_game_creator_agent_background_context_at( + &root, + &agent_id, + &session_id, + runtime, + &error, + ); + } break; } @@ -2185,6 +2419,7 @@ async fn run_game_creator_agent_background_task_with_context( &budget_summary, budget_observation.detail.as_deref(), ); + context_tracker.record(&budget_observation); observations.push(budget_observation); } @@ -2516,11 +2751,48 @@ async fn run_game_creator_agent_background_task_with_context( } return AgentBackgroundTaskOutcome::WaitingForConfirmation; } + context_tracker.record(&observation); observations.push(observation); if stop_game_creator_agent_runtime_if_cancel_requested(&root, &mut runtime) { return AgentBackgroundTaskOutcome::Finished; } } + + let checkpoint = match checkpoint_game_creator_agent_runtime_context( + &root, + &mut runtime, + &task, + &plan, + &mut observations, + loop_index + 1, + &mut context_tracker, + ) { + Ok(checkpoint) => checkpoint, + Err(error) => { + return fail_game_creator_agent_background_context_at( + &root, + &agent_id, + &session_id, + runtime, + &error, + ); + } + }; + if let Err(error) = + clear_game_creator_agent_runtime_observed_action_ledger(&root, &mut runtime) + { + return fail_game_creator_agent_background_context_at( + &root, + &agent_id, + &session_id, + runtime, + &format!("清理已完成 Agent 工具动作账本失败:{error}"), + ); + } + if checkpoint == AgentRuntimeContextCheckpoint::Stalled { + context_stalled = true; + break 'agent_loop; + } } if stop_game_creator_agent_runtime_if_cancel_requested(&root, &mut runtime) { @@ -2533,11 +2805,17 @@ async fn run_game_creator_agent_background_task_with_context( .rev() .find(|observation| observation.status != "ok") .map(AgentRuntimeToolObservation::summary); - let error = match last_non_ok_observation { - Some(observation) => format!( + let error = match (context_stalled, last_non_ok_observation) { + (true, Some(observation)) => format!( + "loop-budget-exhausted: 最近 {AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT} 轮没有产生新的独立观察;最近异常 observation:{observation}" + ), + (true, None) => format!( + "loop-budget-exhausted: 最近 {AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT} 轮没有产生新的独立观察" + ), + (false, Some(observation)) => format!( "loop-budget-exhausted: 已执行 {AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT} 轮仍有未收束动作;最近异常 observation:{observation}" ), - None => format!( + (false, None) => format!( "loop-budget-exhausted: 已执行 {AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT} 轮仍有未收束动作" ), }; @@ -2749,7 +3027,7 @@ pub(crate) const AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT: usize = 6; const AGENT_RUNTIME_BACKGROUND_TOOL_ACTION_LIMIT: usize = 3; pub(crate) const AGENT_RUNTIME_TOOL_PLAN_FUNCTION_NAME: &str = "submit_agent_tool_plan"; const AGENT_RUNTIME_RECENT_TOOL_CALL_LIMIT: usize = 20; -const AGENT_RUNTIME_PLAN_STEP_LIMIT: usize = 8; +pub(crate) const AGENT_RUNTIME_PLAN_STEP_LIMIT: usize = 8; const AGENT_RUNTIME_TOOL_OBSERVATION_MAX_CHARS: usize = 900; const AGENT_RUNTIME_TOOL_WRITE_MAX_CHARS: usize = 12_000; const AGENT_RUNTIME_TOOL_PLAN_MAX_OUTPUT_TOKENS: u32 = 4_000; @@ -2812,6 +3090,788 @@ pub(crate) struct AgentRuntimeToolObservation { pub(crate) detail: Option, } +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct AgentRuntimeContextBundle { + pub(crate) schema_version: String, + #[serde(default)] + pub(crate) project_id: String, + pub(crate) agent_id: String, + pub(crate) task_id: String, + pub(crate) session_id: String, + pub(crate) run_id: String, + pub(crate) source: String, + pub(crate) task: String, + pub(crate) next_loop_index: u32, + pub(crate) context_window: u32, + pub(crate) thinking_summary: String, + pub(crate) plan: Vec, + pub(crate) fallback_response: String, + pub(crate) observations: Vec, + #[serde(default)] + pub(crate) window_completed_loops: u32, + #[serde(default)] + pub(crate) window_observation_fingerprints: Vec, + pub(crate) last_window_fingerprint: Option, + pub(crate) updated_at: u64, +} + +#[derive(Debug, Default)] +pub(crate) struct AgentRuntimeContextWindowTracker { + completed_loops: usize, + observation_signatures: std::collections::BTreeSet, + last_window_fingerprint: Option, +} + +#[derive(Clone, Debug, Default)] +pub(crate) struct AgentRuntimeContinuationContext { + plan: AgentRuntimeToolPlan, + observations: Vec, + next_loop_index: usize, + window_completed_loops: usize, + window_observation_fingerprints: Vec, + last_window_fingerprint: Option, + context_stalled: bool, +} + +impl AgentRuntimeContextWindowTracker { + pub(crate) fn from_continuation(continuation: &AgentRuntimeContinuationContext) -> Self { + Self { + completed_loops: continuation.window_completed_loops, + observation_signatures: continuation + .window_observation_fingerprints + .iter() + .take(AGENT_RUNTIME_CONTEXT_WINDOW_FINGERPRINT_LIMIT) + .cloned() + .collect(), + last_window_fingerprint: continuation.last_window_fingerprint.clone(), + } + } + + fn apply_to_continuation(&self, continuation: &mut AgentRuntimeContinuationContext) { + continuation.window_completed_loops = self.completed_loops; + continuation.window_observation_fingerprints = self + .observation_signatures + .iter() + .take(AGENT_RUNTIME_CONTEXT_WINDOW_FINGERPRINT_LIMIT) + .cloned() + .collect(); + continuation.last_window_fingerprint = self.last_window_fingerprint.clone(); + } + + pub(crate) fn record(&mut self, observation: &AgentRuntimeToolObservation) { + if observation.tool == "runtime.context" { + return; + } + self.observation_signatures + .insert(agent_runtime_context_observation_fingerprint(observation)); + } + + pub(crate) fn complete_loop( + &mut self, + next_loop_index: usize, + ) -> AgentRuntimeContextCheckpoint { + self.completed_loops = self.completed_loops.saturating_add(1); + if next_loop_index == 0 || next_loop_index % AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT != 0 { + return AgentRuntimeContextCheckpoint::Continue; + } + + let fingerprint = agent_runtime_context_window_fingerprint(&self.observation_signatures); + let stalled = self.completed_loops >= AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT + && (self.observation_signatures.len() <= 1 + || fingerprint + .as_ref() + .zip(self.last_window_fingerprint.as_ref()) + .is_some_and(|(current, previous)| current == previous)); + self.completed_loops = 0; + self.observation_signatures.clear(); + if stalled { + return AgentRuntimeContextCheckpoint::Stalled; + } + self.last_window_fingerprint = fingerprint; + AgentRuntimeContextCheckpoint::Compacted + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum AgentRuntimeContextCheckpoint { + Continue, + Compacted, + Stalled, +} + +fn agent_runtime_context_observation_fingerprint( + observation: &AgentRuntimeToolObservation, +) -> String { + let detail = agent_runtime_context_observation_fingerprint_detail(observation); + let payload = serde_json::to_vec(&serde_json::json!({ + "tool": observation.tool, + "status": observation.status, + "summary": observation.summary, + "detail": detail, + })) + .unwrap_or_default(); + format!("{:x}", Sha256::digest(payload)) +} + +fn agent_runtime_context_observation_fingerprint_detail( + observation: &AgentRuntimeToolObservation, +) -> Option { + let detail = observation.detail.as_deref()?; + let normalized = match observation.tool.as_str() { + "project.index" => detail + .lines() + .filter(|line| { + let path = line + .trim_start() + .strip_prefix("- ") + .unwrap_or_default() + .split(" · ") + .next() + .unwrap_or_default(); + path != ".agent" && !path.starts_with(".agent/") + }) + .collect::>() + .join("\n"), + "agent.run_status" => detail + .lines() + .filter(|line| { + [ + "agentId: ", + "status: ", + "phase: ", + "runId: ", + "任务队列: ", + "最近任务: ", + "错误: ", + ] + .iter() + .any(|prefix| line.starts_with(prefix)) + }) + .collect::>() + .join("\n"), + _ => detail.to_string(), + }; + (!normalized.trim().is_empty()).then_some(normalized) +} + +fn agent_runtime_context_window_fingerprint( + signatures: &std::collections::BTreeSet, +) -> Option { + if signatures.is_empty() { + return None; + } + let payload = signatures.iter().cloned().collect::>().join("\n"); + Some(format!("{:x}", Sha256::digest(payload.as_bytes()))) +} + +fn sanitize_agent_runtime_context_observation( + root: &Path, + observation: &AgentRuntimeToolObservation, +) -> AgentRuntimeToolObservation { + let mut sanitized = AgentRuntimeToolObservation { + tool: redact_agent_runtime_project_paths(root, &observation.tool, 80), + status: redact_agent_runtime_project_paths(root, &observation.status, 40), + summary: redact_agent_runtime_project_paths(root, &observation.summary, 320), + detail: observation + .detail + .as_deref() + .map(|detail| redact_agent_runtime_project_paths(root, detail, 1_600)) + .filter(|detail| !detail.trim().is_empty()), + }; + let serialized = serde_json::to_string(&sanitized).unwrap_or_default(); + if validate_agent_runtime_pending_serialized_content(root, &serialized).is_err() { + sanitized.summary = "观察包含敏感内容,持久化上下文已省略正文".to_string(); + sanitized.detail = None; + } + sanitized +} + +fn compact_agent_runtime_context_observations( + root: &Path, + observations: &[AgentRuntimeToolObservation], +) -> Vec { + let sanitized = observations + .iter() + .map(|observation| sanitize_agent_runtime_context_observation(root, observation)) + .collect::>(); + if sanitized.len() <= AGENT_RUNTIME_CONTEXT_OBSERVATION_LIMIT { + return sanitized; + } + + let retained_limit = AGENT_RUNTIME_CONTEXT_OBSERVATION_LIMIT.saturating_sub(1); + let mut retained_indexes = std::collections::BTreeSet::new(); + for index in (0..sanitized.len()).rev().take(retained_limit.min(8)) { + retained_indexes.insert(index); + } + let latest_verification_index = sanitized + .iter() + .rposition(|observation| observation.tool == "project.verify"); + if let Some(index) = latest_verification_index { + retained_indexes.insert(index); + if let Some(mutation_index) = sanitized + .iter() + .enumerate() + .skip(index + 1) + .find(|(_, observation)| { + observation.status == "ok" + && matches!( + observation.tool.as_str(), + "file.write" | "file.patch" | "project.restore" + ) + }) + .map(|(index, _)| index) + { + retained_indexes.insert(mutation_index); + } + } + if let Some(index) = sanitized + .iter() + .rposition(|observation| observation.status != "ok") + { + retained_indexes.insert(index); + } + for index in (0..sanitized.len()).rev() { + if retained_indexes.len() >= retained_limit { + break; + } + retained_indexes.insert(index); + } + while retained_indexes.len() > retained_limit { + let removable = retained_indexes + .iter() + .copied() + .find(|index| { + Some(*index) != latest_verification_index + && sanitized[*index].status == "ok" + && !matches!( + sanitized[*index].tool.as_str(), + "file.write" | "file.patch" | "project.restore" + ) + }) + .or_else(|| retained_indexes.iter().next().copied()); + if let Some(index) = removable { + retained_indexes.remove(&index); + } else { + break; + } + } + + let dropped_indexes = (0..sanitized.len()) + .filter(|index| !retained_indexes.contains(index)) + .collect::>(); + let dropped_detail = dropped_indexes + .iter() + .take(16) + .map(|index| { + let observation = &sanitized[*index]; + format!( + "- {} / {}:{}", + observation.tool, + observation.status, + sanitize_agent_runtime_text(&observation.summary, 180) + ) + }) + .collect::>() + .join("\n"); + let mut compacted = vec![AgentRuntimeToolObservation { + tool: "runtime.context".to_string(), + status: "ok".to_string(), + summary: format!( + "已压缩 {} 条较早观察,保留 {} 条关键观察", + dropped_indexes.len(), + retained_indexes.len() + ), + detail: (!dropped_detail.is_empty()) + .then(|| redact_agent_runtime_project_paths(root, &dropped_detail, 2_400)), + }]; + compacted.extend( + retained_indexes + .into_iter() + .map(|index| sanitized[index].clone()), + ); + compacted +} + +pub(crate) fn game_creator_agent_runtime_context_bundle_path( + root: &Path, + agent_id: &str, + run_id: &str, +) -> PathBuf { + root.join(game_creator_agent_runtime_context_bundle_relative_path( + agent_id, run_id, + )) +} + +fn game_creator_agent_runtime_context_bundle_relative_path(agent_id: &str, run_id: &str) -> String { + format!( + ".agent/runtime/context-bundles/{}/{}.json", + agent_runtime_confirmation_path_component(agent_id, "agent"), + agent_runtime_confirmation_path_component(run_id, "run") + ) +} + +fn game_creator_agent_runtime_context_project_id(root: &Path) -> Result { + let manifest_path = resolve_local_project_path(root, ".agent/manifest.json")?; + let project_id = read_manifest(&manifest_path)?.project_id; + if project_id.trim().is_empty() { + return Err("Agent Runtime context bundle 缺少项目 ID".to_string()); + } + Ok(redact_agent_runtime_project_paths(root, &project_id, 240)) +} + +fn sanitize_game_creator_agent_runtime_context_bundle( + root: &Path, + bundle: &AgentRuntimeContextBundle, +) -> AgentRuntimeContextBundle { + AgentRuntimeContextBundle { + schema_version: bundle.schema_version.clone(), + project_id: redact_agent_runtime_project_paths(root, &bundle.project_id, 240), + agent_id: redact_agent_runtime_project_paths(root, &bundle.agent_id, 160), + task_id: redact_agent_runtime_project_paths(root, &bundle.task_id, 160), + session_id: redact_agent_runtime_project_paths(root, &bundle.session_id, 160), + run_id: redact_agent_runtime_project_paths(root, &bundle.run_id, 160), + source: redact_agent_runtime_project_paths(root, &bundle.source, 120), + task: redact_agent_runtime_project_paths(root, &bundle.task, AGENT_RUNTIME_TASK_MAX_CHARS), + next_loop_index: bundle.next_loop_index, + context_window: bundle.context_window, + thinking_summary: redact_agent_runtime_project_paths(root, &bundle.thinking_summary, 240), + plan: bundle + .plan + .iter() + .take(AGENT_RUNTIME_PLAN_STEP_LIMIT) + .map(|item| redact_agent_runtime_project_paths(root, item, 180)) + .collect(), + fallback_response: redact_agent_runtime_project_paths( + root, + &bundle.fallback_response, + 1_200, + ), + observations: compact_agent_runtime_context_observations(root, &bundle.observations), + window_completed_loops: bundle.window_completed_loops, + window_observation_fingerprints: bundle + .window_observation_fingerprints + .iter() + .take(AGENT_RUNTIME_CONTEXT_WINDOW_FINGERPRINT_LIMIT) + .map(|value| redact_agent_runtime_project_paths(root, value, 64)) + .collect(), + last_window_fingerprint: bundle + .last_window_fingerprint + .as_deref() + .map(|value| redact_agent_runtime_project_paths(root, value, 160)) + .filter(|value| !value.trim().is_empty()), + updated_at: bundle.updated_at, + } +} + +fn validate_agent_runtime_context_task_parameter( + root: &Path, + runtime: &AgentRuntimeState, + task: &str, +) -> Result<(), String> { + let expected = redact_agent_runtime_project_paths( + root, + &runtime.current_task, + AGENT_RUNTIME_TASK_MAX_CHARS, + ); + let actual = redact_agent_runtime_project_paths(root, task, AGENT_RUNTIME_TASK_MAX_CHARS); + if actual != expected { + return Err("Agent Runtime 任务参数与当前状态不匹配".to_string()); + } + Ok(()) +} + +fn validate_agent_runtime_pending_context( + root: &Path, + runtime: &AgentRuntimeState, + pending: &AgentRuntimePendingToolAction, +) -> Result<(), String> { + if pending.agent_id != runtime.agent_id + || pending.task_id != runtime.task_id + || pending.session_id != runtime.session_id + || pending.run_id != runtime.run_id + || pending.source != runtime.source + { + return Err("Agent Runtime 待确认动作身份与当前状态不匹配".to_string()); + } + validate_agent_runtime_context_task_parameter(root, runtime, &pending.task)?; + if pending.loop_iteration != runtime.loop_iteration { + return Err(format!( + "Agent Runtime 待确认动作轮次与当前状态不匹配:pending={} state={}", + pending.loop_iteration, runtime.loop_iteration + )); + } + Ok(()) +} + +pub(crate) fn build_game_creator_agent_runtime_context_bundle( + root: &Path, + runtime: &AgentRuntimeState, + task: &str, + plan: &AgentRuntimeToolPlan, + observations: &[AgentRuntimeToolObservation], + next_loop_index: usize, + context_tracker: &AgentRuntimeContextWindowTracker, +) -> Result { + Ok(AgentRuntimeContextBundle { + schema_version: AGENT_RUNTIME_CONTEXT_BUNDLE_SCHEMA_VERSION.to_string(), + project_id: game_creator_agent_runtime_context_project_id(root)?, + agent_id: runtime.agent_id.clone(), + task_id: runtime.task_id.clone(), + session_id: runtime.session_id.clone(), + run_id: runtime.run_id.clone(), + source: runtime.source.clone(), + task: redact_agent_runtime_project_paths(root, task, AGENT_RUNTIME_TASK_MAX_CHARS), + next_loop_index: u32::try_from(next_loop_index).unwrap_or(u32::MAX), + context_window: u32::try_from(next_loop_index / AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT + 1) + .unwrap_or(u32::MAX), + thinking_summary: redact_agent_runtime_project_paths(root, &plan.thinking_summary, 240), + plan: plan + .plan + .iter() + .take(AGENT_RUNTIME_PLAN_STEP_LIMIT) + .map(|item| redact_agent_runtime_project_paths(root, item, 180)) + .collect(), + fallback_response: redact_agent_runtime_project_paths(root, &plan.response, 1_200), + observations: compact_agent_runtime_context_observations(root, observations), + window_completed_loops: u32::try_from(context_tracker.completed_loops).unwrap_or(u32::MAX), + window_observation_fingerprints: context_tracker + .observation_signatures + .iter() + .take(AGENT_RUNTIME_CONTEXT_WINDOW_FINGERPRINT_LIMIT) + .cloned() + .collect(), + last_window_fingerprint: context_tracker.last_window_fingerprint.clone(), + updated_at: unix_timestamp(), + }) +} + +pub(crate) fn write_game_creator_agent_runtime_context_bundle( + root: &Path, + bundle: &AgentRuntimeContextBundle, +) -> Result<(), String> { + let bundle = sanitize_game_creator_agent_runtime_context_bundle(root, bundle); + if bundle.schema_version != AGENT_RUNTIME_CONTEXT_BUNDLE_SCHEMA_VERSION { + return Err(format!( + "不支持的 Agent Runtime context bundle 版本:{}", + bundle.schema_version + )); + } + let relative_path = + game_creator_agent_runtime_context_bundle_relative_path(&bundle.agent_id, &bundle.run_id); + let mut path = resolve_local_project_path(root, &relative_path)?; + let mut content = serde_json::to_string_pretty(&bundle) + .map_err(|error| format!("序列化 Agent Runtime context bundle 失败:{error}"))?; + content.push('\n'); + validate_agent_runtime_pending_serialized_content(root, &content) + .map_err(|error| format!("Agent Runtime context bundle 不安全:{error}"))?; + if content.len() > AGENT_RUNTIME_CONTEXT_BUNDLE_MAX_BYTES { + return Err(format!( + "Agent Runtime context bundle 超过 {} 字节上限", + AGENT_RUNTIME_CONTEXT_BUNDLE_MAX_BYTES + )); + } + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|error| { + format!( + "创建 Agent Runtime context bundle 目录失败:{}: {error}", + parent.display() + ) + })?; + } + path = resolve_local_project_path(root, &relative_path)?; + let temp_path = path.with_file_name(format!( + ".{}.tmp.{}.{}", + path.file_name() + .and_then(|value| value.to_str()) + .unwrap_or("context-bundle.json"), + std::process::id(), + unix_timestamp_nanos() + )); + fs::write(&temp_path, content.as_bytes()).map_err(|error| { + format!( + "写入 Agent Runtime context bundle 临时文件失败:{}: {error}", + temp_path.display() + ) + })?; + match fs::rename(&temp_path, &path) { + Ok(()) => Ok(()), + Err(_) if path.exists() => { + fs::remove_file(&path).map_err(|error| { + let _ = fs::remove_file(&temp_path); + format!( + "替换 Agent Runtime context bundle 前删除旧文件失败:{}: {error}", + path.display() + ) + })?; + fs::rename(&temp_path, &path).map_err(|error| { + let _ = fs::remove_file(&temp_path); + format!( + "替换 Agent Runtime context bundle 失败:{} -> {}: {error}", + temp_path.display(), + path.display() + ) + }) + } + Err(error) => { + let _ = fs::remove_file(&temp_path); + Err(format!( + "替换 Agent Runtime context bundle 失败:{} -> {}: {error}", + temp_path.display(), + path.display() + )) + } + } +} + +fn read_game_creator_agent_runtime_context_bundle_content(path: &Path) -> Result { + let mut options = fs::OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NOFOLLOW); + } + let file = options.open(path).map_err(|error| { + format!( + "读取 Agent Runtime context bundle 失败:{}: {error}", + path.display() + ) + })?; + let metadata = file.metadata().map_err(|error| { + format!( + "读取 Agent Runtime context bundle 元数据失败:{}: {error}", + path.display() + ) + })?; + if !metadata.is_file() { + return Err("Agent Runtime context bundle 必须是普通文件".to_string()); + } + let mut bytes = Vec::with_capacity( + usize::try_from(metadata.len()) + .unwrap_or(AGENT_RUNTIME_CONTEXT_BUNDLE_MAX_BYTES) + .min(AGENT_RUNTIME_CONTEXT_BUNDLE_MAX_BYTES), + ); + file.take((AGENT_RUNTIME_CONTEXT_BUNDLE_MAX_BYTES + 1) as u64) + .read_to_end(&mut bytes) + .map_err(|error| { + format!( + "读取 Agent Runtime context bundle 失败:{}: {error}", + path.display() + ) + })?; + if bytes.len() > AGENT_RUNTIME_CONTEXT_BUNDLE_MAX_BYTES { + return Err(format!( + "Agent Runtime context bundle 超过 {} 字节上限", + AGENT_RUNTIME_CONTEXT_BUNDLE_MAX_BYTES + )); + } + String::from_utf8(bytes).map_err(|error| { + format!( + "Agent Runtime context bundle 不是 UTF-8:{}: {error}", + path.display() + ) + }) +} + +pub(crate) fn read_game_creator_agent_runtime_context_bundle( + root: &Path, + runtime: &AgentRuntimeState, +) -> Result, String> { + let relative_path = + game_creator_agent_runtime_context_bundle_relative_path(&runtime.agent_id, &runtime.run_id); + let path = resolve_local_project_path(root, &relative_path)?; + let metadata = match fs::symlink_metadata(&path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err(format!( + "读取 Agent Runtime context bundle 失败:{}: {error}", + path.display() + )); + } + }; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err("Agent Runtime context bundle 必须是普通文件".to_string()); + } + let content = read_game_creator_agent_runtime_context_bundle_content(&path)?; + let bundle = serde_json::from_str::(&content).map_err(|error| { + format!( + "解析 Agent Runtime context bundle 失败:{}: {error}", + path.display() + ) + })?; + if bundle.schema_version != AGENT_RUNTIME_CONTEXT_BUNDLE_SCHEMA_VERSION { + return Err(format!( + "不支持的 Agent Runtime context bundle 版本:{}", + bundle.schema_version + )); + } + validate_agent_runtime_pending_serialized_content(root, &content) + .map_err(|error| format!("Agent Runtime context bundle 不安全:{error}"))?; + if bundle.project_id != game_creator_agent_runtime_context_project_id(root)? + || bundle.agent_id != redact_agent_runtime_project_paths(root, &runtime.agent_id, 160) + || bundle.task_id != redact_agent_runtime_project_paths(root, &runtime.task_id, 160) + || bundle.session_id != redact_agent_runtime_project_paths(root, &runtime.session_id, 160) + || bundle.run_id != redact_agent_runtime_project_paths(root, &runtime.run_id, 160) + || bundle.source != redact_agent_runtime_project_paths(root, &runtime.source, 120) + || bundle.task + != redact_agent_runtime_project_paths( + root, + &runtime.current_task, + AGENT_RUNTIME_TASK_MAX_CHARS, + ) + { + return Err("Agent Runtime context bundle 身份与当前任务不匹配".to_string()); + } + let loop_iteration_matches = bundle.next_loop_index == runtime.loop_iteration + || runtime.loop_iteration.checked_sub(1) == Some(bundle.next_loop_index); + if !loop_iteration_matches { + return Err(format!( + "Agent Runtime context bundle 轮次与当前状态不匹配:nextLoopIndex={} state.loopIteration={}", + bundle.next_loop_index, runtime.loop_iteration + )); + } + let expected_context_window = bundle.next_loop_index + / u32::try_from(AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT).unwrap_or(1) + + 1; + if bundle.context_window != expected_context_window { + return Err(format!( + "Agent Runtime context bundle 上下文窗口无效:contextWindow={} expected={expected_context_window}", + bundle.context_window + )); + } + let expected_completed_loops = + bundle.next_loop_index % u32::try_from(AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT).unwrap_or(1); + if bundle.window_completed_loops != expected_completed_loops { + return Err(format!( + "Agent Runtime context bundle 窗口轮次无效:windowCompletedLoops={} expected={expected_completed_loops}", + bundle.window_completed_loops + )); + } + if bundle.window_observation_fingerprints.len() > AGENT_RUNTIME_CONTEXT_WINDOW_FINGERPRINT_LIMIT + || bundle + .window_observation_fingerprints + .iter() + .any(|value| value.len() != 64 || !value.bytes().all(|byte| byte.is_ascii_hexdigit())) + { + return Err("Agent Runtime context bundle 窗口观察指纹无效".to_string()); + } + if bundle + .last_window_fingerprint + .as_deref() + .is_some_and(|value| { + value.len() != 64 || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) + }) + { + return Err("Agent Runtime context bundle 上一窗口指纹无效".to_string()); + } + if bundle.plan.len() > AGENT_RUNTIME_PLAN_STEP_LIMIT { + return Err("Agent Runtime context bundle 计划步骤超过上限".to_string()); + } + if bundle.observations.len() > AGENT_RUNTIME_CONTEXT_OBSERVATION_LIMIT { + return Err("Agent Runtime context bundle 观察数量超过上限".to_string()); + } + Ok(Some(bundle)) +} + +pub(crate) fn continuation_from_game_creator_agent_runtime_context_bundle( + bundle: AgentRuntimeContextBundle, +) -> AgentRuntimeContinuationContext { + AgentRuntimeContinuationContext { + plan: AgentRuntimeToolPlan { + thinking_summary: bundle.thinking_summary, + plan: bundle.plan, + actions: Vec::new(), + response: bundle.fallback_response, + }, + observations: bundle.observations, + next_loop_index: usize::try_from(bundle.next_loop_index).unwrap_or(usize::MAX), + window_completed_loops: usize::try_from(bundle.window_completed_loops) + .unwrap_or(usize::MAX), + window_observation_fingerprints: bundle.window_observation_fingerprints, + last_window_fingerprint: bundle.last_window_fingerprint, + context_stalled: false, + } +} + +fn checkpoint_game_creator_agent_runtime_context( + root: &Path, + runtime: &mut AgentRuntimeState, + task: &str, + plan: &AgentRuntimeToolPlan, + observations: &mut Vec, + next_loop_index: usize, + tracker: &mut AgentRuntimeContextWindowTracker, +) -> Result { + let checkpoint = tracker.complete_loop(next_loop_index); + *observations = compact_agent_runtime_context_observations(root, observations); + persist_game_creator_agent_runtime_context( + root, + runtime, + task, + plan, + observations, + next_loop_index, + tracker, + )?; + if checkpoint == AgentRuntimeContextCheckpoint::Compacted { + let summary = format!( + "已完成第 {} 个上下文窗口并压缩观察,Agent 将在同一 run 继续。", + next_loop_index / AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT + ); + runtime.observations.push(summary.clone()); + runtime.updated_at = unix_timestamp(); + write_game_creator_agent_runtime_state(root, runtime)?; + append_game_creator_agent_runtime_event( + root, + runtime, + "context.compacted", + runtime.status.as_str(), + runtime.phase.as_str(), + &summary, + Some(&format!( + "nextLoopIndex={next_loop_index} · observations={}", + observations.len() + )), + )?; + let _ = append_agent_db_record( + root, + serde_json::json!({ + "recordType": "agent.runtime.context.compacted", + "agentId": runtime.agent_id, + "taskId": runtime.task_id, + "sessionId": runtime.session_id, + "runId": runtime.run_id, + "nextLoopIndex": next_loop_index, + "observationCount": observations.len(), + }), + ); + } + Ok(checkpoint) +} + +pub(crate) fn persist_game_creator_agent_runtime_context( + root: &Path, + runtime: &AgentRuntimeState, + task: &str, + plan: &AgentRuntimeToolPlan, + observations: &[AgentRuntimeToolObservation], + next_loop_index: usize, + context_tracker: &AgentRuntimeContextWindowTracker, +) -> Result<(), String> { + let bundle = build_game_creator_agent_runtime_context_bundle( + root, + runtime, + task, + plan, + observations, + next_loop_index, + context_tracker, + )?; + write_game_creator_agent_runtime_context_bundle(root, &bundle) +} + #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct AgentRuntimePendingToolAction { @@ -3592,6 +4652,9 @@ fn build_game_creator_agent_background_tool_plan_request( let prompt = format!( "当前工具策略:\n{tool_policy_json}\n\n运行上下文如下。你正在执行后台 Agent loop 第 {loop_index} 轮。项目记忆、对话、资产和文件内容不会预加载,只能依据已获准工具返回的 observation 使用;未出现在 observation 里的项目事实不得自行假设。请基于目标和已有工具观察修正计划,再决定是否调用最多 {AGENT_RUNTIME_BACKGROUND_TOOL_ACTION_LIMIT} 个白名单工具。请按后续结构化工具计划协议提交本轮结果。\n\n{context}\n\n后台任务:\n{task}\n\n已有工具观察:\n{observations_json}\n\nJSON schema:{{\"thinkingSummary\":\"一句话理解\",\"plan\":[\"步骤\"],\"actions\":[{{\"tool\":\"memory.read|memory.write|conversation.read|asset.list|project.index|project.search|project.verify|project.checkpoint|project.restore|project.diff|file.list|file.read|file.write|file.patch|task.list|task.create|task.update|command.run_limited|preview.start|canvas.asset_generate|blackboard.write|agent.message|agent.delegate|agent.schedule_ready|agent.run_status\",\"reason\":\"为什么需要\",\"input\":{{}}}}],\"response\":\"如果无需继续调用工具,可直接给最终回复\"}}\n\n工具输入约定:memory.read 使用 {{\"scope\":\"session|project|blackboard|agent\"}};memory.write 使用 {{\"scope\":\"agent|project|session|blackboard\",\"title\":\"标题\",\"content\":\"要沉淀的稳定结论\",\"mode\":\"append|overwrite\"}},其中 agent scope 只能写当前 Agent 自己的私有记忆,跨 Agent 共享请用 blackboard.write 或 agent.message;project.search 使用 {{\"query\":\"要查找的字面文本\",\"path\":\"可选项目内相对范围\",\"maxResults\":20,\"caseSensitive\":false}},返回 path:line 和匹配行;project.verify 使用 {{\"script\":\"check|typecheck|test|lint|build\",\"expectedCommand\":\"从 package.json 读取的完整原始脚本\",\"timeoutSeconds\":120}},只执行项目根 package.json 中同名 npm 脚本,expectedCommand 不一致时拒绝执行,确认策略以当前工具策略中 project.verify 的独立权限为准;project.checkpoint input 可为空,用于在写文件或批量修改前创建本地 checkpoint;project.restore 使用 {{\"checkpointId\":\"checkpoint id\"}},用于在确认后把当前项目恢复到指定 checkpoint;project.diff 使用 {{\"checkpointId\":\"checkpoint id\"}},用于读取当前项目相对路径 diff 摘要;file.list 使用 {{\"path\":\"可选项目内相对目录或文件\"}},path 为空时列出项目摘要;file.read 使用 {{\"path\":\"项目内相对路径\",\"startLine\":1,\"maxLines\":120}},按行读取并返回行号;file.write 使用 {{\"path\":\"项目内相对路径\",\"content\":\"完整文件内容\"}};file.patch 使用 {{\"path\":\"项目内相对路径\",\"oldText\":\"必须精确匹配的原文\",\"newText\":\"替换后的文本\",\"expectedReplacements\":1}},匹配数不符时不写入,批量修改前应先调用 project.checkpoint;task.list input 可为空,用于读取 manifest 任务图、状态和 readyTaskIds;task.create 使用 {{\"taskId\":\"可选自定义 taskId\",\"title\":\"任务标题\",\"group\":\"design|art|code|balance|audio|publishing\",\"role\":\"角色名\",\"dependencies\":[\"已有 taskId\"],\"artifacts\":[\"预期产物\"],\"acceptanceCriteria\":[\"验收标准\"],\"status\":\"pending|running|waiting-for-confirmation|completed|failed\"}},用于把 Agent 拆出的新任务追加到 manifest;task.update 使用 {{\"taskId\":\"manifest taskId\",\"status\":\"pending|running|waiting-for-confirmation|completed|failed\"}};command.run_limited 使用 {{\"commandId\":\"game.static_smoke\"}},只支持本地静态自检;preview.start input 可为空,用于启动当前项目的 127.0.0.1 本地 HTTP 预览;canvas.asset_generate 使用 {{\"prompt\":\"要生成的美术素材描述\"}},通过配置的 External Editor API 生成首版素材并登记到 assets;blackboard.write 使用 {{\"title\":\"标题\",\"content\":\"要共享给所有 Agent 的稳定结论\"}};agent.message 使用 {{\"agentId\":\"目标 taskId\",\"content\":\"给目标 Agent 的定向消息\"}};agent.delegate 使用 {{\"agentId\":\"目标 taskId\",\"task\":\"要委派的后台任务\",\"runId\":\"可选 run id\"}},用于把任务投递到另一个 Agent 的独立队列;agent.schedule_ready input 可为空或 {{\"limit\":1}},用于把 manifest 中依赖已完成的 ready task 投递到对应 Agent 后台队列;agent.run_status 使用 {{\"agentId\":\"可选目标 taskId\",\"scope\":\"self|all\"}},用于读取自己或其他 Agent 的 Runtime 状态摘要;如果已有观察足够,请返回空 actions 并填写 response。其他工具 input 可为空。" ); + let prompt = format!( + "{prompt}\n\n补充协议:project.verify 的 script 除 check、typecheck、test、lint、build 外,还可使用 check:、test:(例如 test:unit)、lint:、typecheck:、build:、verify:、validate: 形式的命名脚本;冒号后的每个非空段必须以字母或数字开头且只能包含字母、数字、连字符、下划线或点,并且 script 与 expectedCommand 都必须原样来自项目根 package.json。每 {AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT} 轮只是一个上下文压缩窗口,不是 run 的终止上限;只要 observation 出现新的独立进展,就在同一 run 继续下一窗口,只有窗口没有新进展时才按停滞处理。" + ); let api_kind = parse_game_creator_llm_api_kind(&llm.api_kind)?; let mut request = LlmRunRequest::new(vec![ LlmMessage::system(game_creator_agent_runtime_tool_plan_system_prompt()), @@ -3628,7 +4691,7 @@ fn game_creator_agent_tool_plan_function_tool() -> platform_llm::LlmFunctionTool }, "plan": { "type": "array", - "maxItems": 5, + "maxItems": AGENT_RUNTIME_PLAN_STEP_LIMIT, "items": { "type": "string" } }, "actions": { @@ -3795,7 +4858,7 @@ fn parse_game_creator_agent_tool_plan_payload( .into_iter() .map(|item| truncate_agent_runtime_text(&item, 160)) .filter(|item| !item.trim().is_empty()) - .take(5) + .take(AGENT_RUNTIME_PLAN_STEP_LIMIT) .collect(); plan.response = truncate_agent_runtime_text(&plan.response, 1_200); Ok(plan) @@ -4016,11 +5079,10 @@ fn validate_agent_runtime_pending_serialized_content( "api key", "token=", "bearer ", - "tnr_sk_", ] .into_iter() .position(|marker| lower.contains(marker)) - .or_else(|| agent_runtime_contains_secret_key_prefix(content, "sk-").then_some(10)); + .or_else(|| (redact_secret_tokens(content) != content).then_some(9)); if let Some(rule) = sensitive_rule { return Err(format!( "待确认工具输入命中敏感规则 #{rule},Runtime 已拒绝持久化" @@ -4040,20 +5102,79 @@ fn validate_agent_runtime_pending_serialized_content( } pub(crate) fn agent_runtime_contains_secret_key_prefix(content: &str, prefix: &str) -> bool { - content.match_indices(prefix).any(|(index, _)| { - let starts_at_boundary = content[..index] - .chars() - .next_back() - .map(|character| !character.is_ascii_alphanumeric() && character != '_') - .unwrap_or(true); - let secret_length = content[index + prefix.len()..] - .chars() - .take_while(|character| { - character.is_ascii_alphanumeric() || matches!(character, '-' | '_') - }) - .count(); - starts_at_boundary && secret_length >= 8 - }) + content + .match_indices(prefix) + .any(|(index, _)| agent_runtime_secret_token_end(content, index, prefix).is_some()) +} + +fn agent_runtime_secret_token_end(content: &str, index: usize, prefix: &str) -> Option { + agent_runtime_secret_token_end_with_minimum(content, index, prefix, 8) +} + +fn agent_runtime_secret_token_end_with_minimum( + content: &str, + index: usize, + prefix: &str, + minimum_body_length: usize, +) -> Option { + if prefix.is_empty() { + return None; + } + let starts_at_boundary = content[..index] + .chars() + .next_back() + .map(|character| !character.is_ascii_alphanumeric() && character != '_') + .unwrap_or(true); + if !starts_at_boundary { + return None; + } + let body_start = index.checked_add(prefix.len())?; + let mut body_length = 0usize; + let mut token_end = body_start; + for (offset, character) in content[body_start..].char_indices() { + if !character.is_ascii_alphanumeric() && !matches!(character, '-' | '_') { + break; + } + body_length = body_length.saturating_add(1); + token_end = body_start + offset + character.len_utf8(); + } + (body_length >= minimum_body_length).then_some(token_end) +} + +fn agent_runtime_jwt_token_end(content: &str, index: usize) -> Option { + let starts_at_boundary = content[..index] + .chars() + .next_back() + .map(|character| !character.is_ascii_alphanumeric() && character != '_') + .unwrap_or(true); + if !starts_at_boundary { + return None; + } + let bytes = content.as_bytes(); + let mut cursor = index; + for segment_index in 0..3 { + let segment_start = cursor; + while cursor < bytes.len() + && (bytes[cursor].is_ascii_alphanumeric() || matches!(bytes[cursor], b'-' | b'_')) + { + cursor += 1; + } + let minimum_segment_length = if segment_index == 2 { 8 } else { 3 }; + if cursor.saturating_sub(segment_start) < minimum_segment_length { + return None; + } + if segment_index < 2 { + if bytes.get(cursor) != Some(&b'.') { + return None; + } + cursor += 1; + } + } + let ends_at_boundary = bytes + .get(cursor) + .map(|byte| !byte.is_ascii_alphanumeric() && !matches!(*byte, b'-' | b'_' | b'.')) + .unwrap_or(true); + (ends_at_boundary && cursor.saturating_sub(index) >= 32).then_some(cursor) } pub(crate) fn write_game_creator_agent_runtime_pending_tool_action( @@ -4232,6 +5353,42 @@ fn remove_game_creator_agent_runtime_confirmations( } } +fn clear_game_creator_agent_runtime_observed_action_ledger( + root: &Path, + runtime: &mut AgentRuntimeState, +) -> Result<(), String> { + let path = game_creator_agent_runtime_pending_tool_action_path( + root, + &runtime.agent_id, + &runtime.run_id, + ); + if !path.exists() { + runtime.pending_tool_action = None; + return Ok(()); + } + let pending = read_game_creator_agent_runtime_pending_tool_action( + root, + &runtime.agent_id, + &runtime.run_id, + )?; + if !matches!( + pending.status.as_str(), + AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_APPROVED + | AGENT_RUNTIME_PENDING_ACTION_STATUS_OBSERVED_REJECTED + ) { + return Ok(()); + } + remove_game_creator_agent_runtime_confirmations(root, &runtime.agent_id, &runtime.run_id)?; + remove_game_creator_agent_runtime_pending_tool_action( + root, + &runtime.agent_id, + &runtime.run_id, + )?; + runtime.pending_tool_action = None; + runtime.updated_at = unix_timestamp(); + write_game_creator_agent_runtime_state(root, runtime) +} + pub(crate) fn write_game_creator_agent_runtime_tool_confirmation( root: &Path, agent_id: &str, @@ -7319,6 +8476,17 @@ pub(crate) fn start_game_creator_agent_runtime_task_for_session_at( update_agent_runtime_plan_steps(&mut state, plan); state.observations = vec!["已创建本轮 Agent Runtime run。".to_string()]; if let Some(previous_state) = previous_state { + let same_runtime_run = previous_state.agent_id == state.agent_id + && previous_state.task_id == state.task_id + && previous_state.session_id == state.session_id + && previous_state.run_id == state.run_id + && previous_state.source == state.source + && previous_state.current_task == state.current_task; + if same_runtime_run { + state.loop_iteration = previous_state.loop_iteration; + state.max_loop_iterations = previous_state.max_loop_iterations; + state.tool_action_budget = previous_state.tool_action_budget; + } state.recent_tool_calls = previous_state.recent_tool_calls; state.last_response = previous_state.last_response; } @@ -9424,7 +10592,7 @@ pub(crate) fn game_creator_role_agent_chat_system_prompt() -> &'static str { } pub(crate) fn game_creator_agent_runtime_tool_plan_system_prompt() -> &'static str { - "你是 Genarrative AI 游戏创作多智能体 Runtime 中的专业 Agent。你必须在白名单工具内规划行动:先给一句 thinkingSummary,再给短计划,再决定是否请求工具。只能请求 memory.read、memory.write、conversation.read、asset.list、project.index、project.search、project.verify、project.checkpoint、project.restore、project.diff、file.list、file.read、file.write、file.patch、task.list、task.create、task.update、command.run_limited、preview.start、canvas.asset_generate、blackboard.write、agent.message、agent.delegate、agent.schedule_ready、agent.run_status。处理代码任务时先用 project.search 定位,再用带行号的 file.read 获取足够上下文;优先使用 file.patch 做精确局部修改,批量修改前创建 project.checkpoint,修改后再次读取验证。需要执行 package.json 中的 check、typecheck、test、lint 或 build 时,先读取 package.json,再把脚本名和读到的完整命令原样提交给 project.verify;不得猜测或改写 expectedCommand。Agent 私有记忆只能由本人写入,跨 Agent 共享稳定结论用 blackboard.write,给单个 Agent 留上下文用 agent.message。不要假装工具已执行;工具结果会由 Runtime 作为 observation 返回。优先调用 submit_agent_tool_plan function tool 提交结构化计划;只有上游不支持 function tool 时才返回同结构的单个 JSON 对象。不要 markdown,不要泄露密钥。" + "你是 Genarrative AI 游戏创作多智能体 Runtime 中的专业 Agent。你必须在白名单工具内规划行动:先给一句 thinkingSummary,再给短计划,再决定是否请求工具。只能请求 memory.read、memory.write、conversation.read、asset.list、project.index、project.search、project.verify、project.checkpoint、project.restore、project.diff、file.list、file.read、file.write、file.patch、task.list、task.create、task.update、command.run_limited、preview.start、canvas.asset_generate、blackboard.write、agent.message、agent.delegate、agent.schedule_ready、agent.run_status。处理代码任务时先用 project.search 定位,再用带行号的 file.read 获取足够上下文;优先使用 file.patch 做精确局部修改,批量修改前创建 project.checkpoint,修改后再次读取验证。需要执行 package.json 中的验证脚本时,先读取 package.json,再把真实脚本名和读到的完整命令原样提交给 project.verify;script 可以是 check、typecheck、test、lint、build,或使用 check:、test:(例如 test:unit)、lint:、typecheck:、build:、verify:、validate: 形式的命名脚本,其中冒号后的每个非空段必须以字母或数字开头且只能包含字母、数字、连字符、下划线或点;不得猜测或改写 expectedCommand。每 6 轮只是一个上下文压缩窗口,不是 run 的终止上限;只要 observation 出现新的独立进展,就在同一 run 继续下一窗口,只有窗口没有新进展时才按停滞处理。Agent 私有记忆只能由本人写入,跨 Agent 共享稳定结论用 blackboard.write,给单个 Agent 留上下文用 agent.message。不要假装工具已执行;工具结果会由 Runtime 作为 observation 返回。优先调用 submit_agent_tool_plan function tool 提交结构化计划;只有上游不支持 function tool 时才返回同结构的单个 JSON 对象。不要 markdown,不要泄露密钥。" } pub(crate) fn game_creator_agent_role_definition( @@ -13614,22 +14782,52 @@ pub(crate) fn sanitize_prompt_context(value: &str) -> String { } pub(crate) fn redact_secret_tokens(line: &str) -> String { - if !line - .split_whitespace() - .any(|part| part.starts_with("sk-") || part.starts_with("tnr_sk_")) - { + let mut spans = [ + ("tnr_sk_", 8usize), + ("sk-", 8), + ("ghp_", 20), + ("gho_", 20), + ("ghu_", 20), + ("ghs_", 20), + ("ghr_", 20), + ("npm_", 20), + ("AKIA", 16), + ("ASIA", 16), + ("AIza", 20), + ("sk_live_", 16), + ("rk_live_", 16), + ("xoxb-", 16), + ("xoxp-", 16), + ("xoxa-", 16), + ("xoxr-", 16), + ] + .into_iter() + .flat_map(|(prefix, minimum_body_length)| { + line.match_indices(prefix).filter_map(move |(index, _)| { + agent_runtime_secret_token_end_with_minimum(line, index, prefix, minimum_body_length) + .map(|token_end| (index, token_end)) + }) + }) + .collect::>(); + spans.extend(line.match_indices("eyJ").filter_map(|(index, _)| { + agent_runtime_jwt_token_end(line, index).map(|token_end| (index, token_end)) + })); + if spans.is_empty() { return line.to_string(); } - line.split_whitespace() - .map(|part| { - if part.starts_with("sk-") || part.starts_with("tnr_sk_") { - "[redacted-secret]" - } else { - part - } - }) - .collect::>() - .join(" ") + spans.sort_unstable_by_key(|(start, end)| (*start, *end)); + let mut output = String::with_capacity(line.len()); + let mut cursor = 0usize; + for (start, end) in spans { + if start < cursor { + continue; + } + output.push_str(&line[cursor..start]); + output.push_str("[redacted-secret]"); + cursor = end; + } + output.push_str(&line[cursor..]); + output } pub(crate) fn read_optional_text(path: &Path) -> Result { diff --git a/apps/ai-game-creator-shell/src-tauri/src/project.rs b/apps/ai-game-creator-shell/src-tauri/src/project.rs index 1f8e47448..60c7adb29 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project.rs @@ -1304,6 +1304,16 @@ pub(crate) const PROJECT_VERIFICATION_OUTPUT_MAX_BYTES: usize = 24 * 1024; const PROJECT_VERIFICATION_PACKAGE_MAX_BYTES: u64 = 512 * 1024; const PROJECT_VERIFICATION_MIN_TIMEOUT_SECONDS: u64 = 1; const PROJECT_VERIFICATION_MAX_TIMEOUT_SECONDS: u64 = 300; +const PROJECT_VERIFICATION_SCRIPT_MAX_CHARS: usize = 160; +const PROJECT_VERIFICATION_NAMED_SCRIPT_PREFIXES: [&str; 7] = [ + "check:", + "test:", + "lint:", + "typecheck:", + "build:", + "verify:", + "validate:", +]; #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) struct ProjectVerificationSpec { @@ -1393,8 +1403,68 @@ pub(crate) fn project_verification_npm_program() -> &'static str { } } +fn project_verification_script_shell() -> Result { + #[cfg(unix)] + let path = PathBuf::from("/bin/sh"); + #[cfg(windows)] + let path = std::env::var_os("ComSpec") + .map(PathBuf::from) + .filter(|path| path.is_absolute()) + .ok_or_else(|| "project.verify 无法解析系统 ComSpec".to_string())?; + #[cfg(not(any(unix, windows)))] + let path: PathBuf = + return Err("project.verify 当前平台没有受支持的固定 script shell".to_string()); + + let metadata = fs::metadata(&path).map_err(|error| { + format!( + "project.verify 无法读取固定 script shell {}: {error}", + path.display() + ) + })?; + if !metadata.is_file() { + return Err(format!( + "project.verify 固定 script shell 不是普通文件:{}", + path.display() + )); + } + Ok(path) +} + fn project_verification_script_allowed(script: &str) -> bool { matches!(script, "check" | "typecheck" | "test" | "lint" | "build") + || PROJECT_VERIFICATION_NAMED_SCRIPT_PREFIXES + .iter() + .any(|prefix| { + script + .strip_prefix(prefix) + .is_some_and(project_verification_named_script_suffix_allowed) + }) +} + +fn project_verification_named_script_suffix_allowed(suffix: &str) -> bool { + suffix.split(':').all(|segment| { + let mut characters = segment.chars(); + characters + .next() + .is_some_and(|character| character.is_ascii_alphanumeric()) + && characters.all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.') + }) + }) +} + +fn ensure_project_verification_has_no_project_npmrc(root: &Path) -> Result<(), String> { + let path = root.join(".npmrc"); + match fs::symlink_metadata(&path) { + Ok(_) => Err( + "project.verify 不允许项目级 .npmrc 改写 npm 执行语义;请移除后重新确认".to_string(), + ), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(format!( + "project.verify 检查项目级 .npmrc 失败:{}: {error}", + path.display() + )), + } } fn project_verification_package_manager_at( @@ -1437,9 +1507,18 @@ pub(crate) fn resolve_project_verification_spec_at( timeout_seconds: u64, ) -> Result { validate_project_root(root)?; + ensure_project_verification_has_no_project_npmrc(root)?; let script = script.trim(); + if script.chars().count() > PROJECT_VERIFICATION_SCRIPT_MAX_CHARS { + return Err(format!( + "project.verify script 总长度不能超过 {PROJECT_VERIFICATION_SCRIPT_MAX_CHARS} 个字符" + )); + } if !project_verification_script_allowed(script) { - return Err("project.verify 只允许 check、typecheck、test、lint、build 脚本".to_string()); + return Err( + "project.verify 只允许验证类脚本:check、typecheck、test、lint、build,或以 check:、test:、lint:、typecheck:、build:、verify:、validate: 开头的安全非空命名脚本" + .to_string(), + ); } if expected_command.trim().is_empty() { return Err("project.verify 缺少 expectedCommand".to_string()); @@ -1539,35 +1618,7 @@ pub(crate) fn sanitize_project_verification_output(value: &str) -> String { .filter(|character| !character.is_control() || matches!(character, '\n' | '\t')) .collect::(); let sanitized = sanitize_prompt_context(&printable); - let mut output = String::with_capacity(sanitized.len()); - let bytes = sanitized.as_bytes(); - let mut index = 0; - while index < bytes.len() { - let prefix_len = if bytes[index..].starts_with(b"tnr_sk_") { - Some(7) - } else if bytes[index..].starts_with(b"sk-") { - Some(3) - } else { - None - }; - let Some(prefix_len) = prefix_len else { - let character = sanitized[index..] - .chars() - .next() - .expect("index stays on a char boundary"); - output.push(character); - index += character.len_utf8(); - continue; - }; - let mut end = index + prefix_len; - while end < bytes.len() - && (bytes[end].is_ascii_alphanumeric() || matches!(bytes[end], b'-' | b'_' | b'.')) - { - end += 1; - } - output.push_str("[redacted-secret]"); - index = end; - } + let output = redact_secret_tokens(&sanitized); truncate_project_verification_output(&output) } @@ -1659,6 +1710,7 @@ async fn run_project_verification_process( root: &Path, spec: &ProjectVerificationSpec, ) -> Result { + ensure_project_verification_has_no_project_npmrc(root)?; let isolated_home = resolve_local_project_path(root, ".agent/runtime/verify-home")?; let isolated_tmp = resolve_local_project_path(root, ".agent/runtime/verify-tmp")?; let isolated_cache = resolve_local_project_path(root, ".agent/runtime/npm-cache")?; @@ -1666,6 +1718,7 @@ async fn run_project_verification_process( .and_then(|()| fs::create_dir_all(&isolated_tmp)) .and_then(|()| fs::create_dir_all(&isolated_cache)) .map_err(|error| format!("创建 project.verify 隔离目录失败:{error}"))?; + let script_shell = project_verification_script_shell()?; let mut command = tokio::process::Command::new(&spec.program); command @@ -1683,6 +1736,7 @@ async fn run_project_verification_process( .env("npm_config_audit", "false") .env("npm_config_fund", "false") .env("npm_config_ignore_scripts", "true") + .env("npm_config_script_shell", &script_shell) .env("npm_config_update_notifier", "false") .env("npm_config_cache", &isolated_cache) .env( diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/tests.rs index 1e93a34ed..f79ffe9cf 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests.rs @@ -1402,6 +1402,70 @@ fn spawn_mock_llm_stream_server_with_capture( base_url } +fn spawn_mock_llm_stream_fallback_server( + first_status_line: &'static str, + first_content_type: &'static str, + first_body: String, + fallback_body: String, +) -> ( + String, + mpsc::Sender<()>, + std::thread::JoinHandle>, +) { + let listener = TcpListener::bind(("127.0.0.1", 0)).expect("mock fallback llm bind"); + let base_url = format!( + "http://{}", + listener.local_addr().expect("mock fallback llm addr") + ); + let (stop_sender, stop_receiver) = mpsc::channel(); + let handle = std::thread::spawn(move || { + let mut requests = Vec::new(); + let (mut first_stream, _) = listener.accept().expect("mock fallback first accept"); + requests.push(read_mock_http_request(&mut first_stream)); + let first_response = format!( + "HTTP/1.1 {first_status_line}\r\nContent-Type: {first_content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + first_body.len(), + first_body + ); + first_stream + .write_all(first_response.as_bytes()) + .expect("mock fallback first response"); + + listener + .set_nonblocking(true) + .expect("mock fallback listener nonblocking"); + let deadline = std::time::Instant::now() + Duration::from_secs(5); + while std::time::Instant::now() < deadline { + if stop_receiver.try_recv().is_ok() { + break; + } + match listener.accept() { + Ok((mut fallback_stream, _)) => { + fallback_stream + .set_nonblocking(false) + .expect("mock fallback stream blocking"); + requests.push(read_mock_http_request(&mut fallback_stream)); + let fallback_response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + fallback_body.len(), + fallback_body + ); + fallback_stream + .write_all(fallback_response.as_bytes()) + .expect("mock fallback response"); + break; + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(5)); + } + Err(error) => panic!("mock fallback accept failed: {error}"), + } + } + requests + }); + (base_url, stop_sender, handle) +} + fn spawn_barrier_mock_llm_server( response_content: String, barrier: Arc<(StdMutex, Condvar)>, @@ -2078,6 +2142,7 @@ async fn chat_with_game_creator_role_agent_stream_keeps_completed_reply_after_ba concat!( "data: {\"choices\":[{\"delta\":{\"content\":\"完整回复\"}}]}\n\n", "data: {\"choices\":[{\"finish_reason\":\"stop\"}]}\n\n", + "data: {\"choices\":[],\"usage\":{\"prompt_tokens\":12,\"completion_tokens\":4,\"total_tokens\":16}}\n\n", "data: {\"choices\":[}\n\n" ) .to_string(), @@ -2117,6 +2182,118 @@ async fn chat_with_game_creator_role_agent_stream_keeps_completed_reply_after_ba fs::remove_dir_all(root).ok(); } +#[tokio::test] +async fn chat_with_game_creator_role_agent_stream_falls_back_once_before_first_delta_on_deserialize( +) { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "流式回退项目").expect("project init"); + let fallback_body = serde_json::json!({ + "id": "chatcmpl-role-fallback", + "model": "art-chat-model", + "choices": [{ + "message": { "content": "非流式回退成功。" }, + "finish_reason": "stop" + }] + }) + .to_string(); + let (base_url, stop_sender, server_handle) = spawn_mock_llm_stream_fallback_server( + "200 OK", + "text/event-stream; charset=utf-8", + "data: {\"choices\":null}\n\n".to_string(), + fallback_body, + ); + let _config_guard = write_test_local_config(format!( + r#"{{ + "agentLlm": {{ + "art-director": {{ + "apiKey": "art-key", + "baseUrl": {base_url:?}, + "model": "art-chat-model", + "apiKind": "openai_chat", + "maxRetries": 0 + }} + }} +}}"# + )); + + let mut deltas = Vec::new(); + let result = chat_with_game_creator_role_agent_stream_at( + &root, + "art-director", + "验证首包协议错误回退", + |delta| deltas.push(delta.clone()), + ) + .await; + let _ = stop_sender.send(()); + let requests = server_handle.join().expect("mock fallback server join"); + + let reply = result.expect("deserialize before first delta should use plain fallback"); + assert_eq!(reply.reply_text, "非流式回退成功。"); + assert!(deltas.is_empty()); + assert_eq!( + requests.len(), + 2, + "stream fallback must issue exactly one retry" + ); + assert!(requests[0].contains("POST /chat/completions HTTP/1.1")); + assert!(requests[0].contains("\"stream\":true")); + assert!(requests[1].contains("POST /chat/completions HTTP/1.1")); + assert!(requests[1].contains("\"stream\":false")); + + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn chat_with_game_creator_role_agent_stream_does_not_fallback_on_upstream_403() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "流式拒绝项目").expect("project init"); + let fallback_body = serde_json::json!({ + "choices": [{ + "message": { "content": "不应发出的非流式请求" }, + "finish_reason": "stop" + }] + }) + .to_string(); + let (base_url, stop_sender, server_handle) = spawn_mock_llm_stream_fallback_server( + "403 Forbidden", + "application/json", + serde_json::json!({ "error": { "message": "模型访问被拒绝" } }).to_string(), + fallback_body, + ); + let _config_guard = write_test_local_config(format!( + r#"{{ + "agentLlm": {{ + "art-director": {{ + "apiKey": "art-key", + "baseUrl": {base_url:?}, + "model": "art-chat-model", + "apiKind": "openai_chat", + "maxRetries": 0 + }} + }} +}}"# + )); + + let result = chat_with_game_creator_role_agent_stream_at( + &root, + "art-director", + "验证 403 不回退", + |_| {}, + ) + .await; + let _ = stop_sender.send(()); + let requests = server_handle.join().expect("mock fallback server join"); + + let error = result.expect_err("upstream 403 must remain an error"); + assert!(error.contains("LLM 上游返回 403")); + assert!(error.contains("模型访问被拒绝")); + assert_eq!(requests.len(), 1, "upstream 403 must not issue fallback"); + assert!(requests[0].contains("POST /chat/completions HTTP/1.1")); + assert!(requests[0].contains("\"stream\":true")); + + fs::remove_dir_all(root).ok(); +} + #[test] fn agent_runtime_tool_policy_snapshot_reflects_project_policy() { let root = unique_project_path(); @@ -3016,6 +3193,691 @@ async fn background_agent_runtime_marks_unconverged_loop_budget_exhausted() { fs::remove_dir_all(root).ok(); } +#[tokio::test] +async fn background_agent_runtime_compacts_context_across_multiple_windows() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "长任务上下文项目").expect("project init"); + fs::write( + root.join("progress.txt"), + (1..=39) + .map(|index| format!("CONTEXT_MARKER_{index}")) + .collect::>() + .join("\n"), + ) + .expect("write progress fixture"); + + let (sender, receiver) = mpsc::channel(); + let mut responses = (0..13) + .map(|loop_index| { + let actions = (1..=3) + .map(|offset| { + let marker = loop_index * 3 + offset; + serde_json::json!({ + "tool": "project.search", + "reason": format!("读取第 {marker} 个独立进展证据"), + "input": { + "query": format!("CONTEXT_MARKER_{marker}"), + "path": "progress.txt", + "maxResults": 1, + "caseSensitive": true + } + }) + }) + .collect::>(); + serde_json::json!({ + "thinkingSummary": format!("第 {} 轮继续收集不同证据", loop_index + 1), + "plan": ["读取下一组证据", "保留压缩后的关键上下文"], + "actions": actions, + "response": "" + }) + .to_string() + }) + .collect::>(); + responses.push( + serde_json::json!({ + "thinkingSummary": "证据已经足够,长任务可以收束", + "plan": [], + "actions": [], + "response": "已连续跨过两个六轮上下文窗口并完成任务。" + }) + .to_string(), + ); + let base_url = spawn_mock_llm_server_responses_with_capture(responses, Some(sender)); + let _config_guard = write_test_local_config(format!( + r#"{{ + "agentLlm": {{ + "design-director": {{ + "apiKey": "design-key", + "baseUrl": {base_url:?}, + "model": "design-runtime-model", + "apiKind": "openai_responses" + }} + }} +}}"# + )); + + start_game_creator_agent_background_task_at( + &root, + "design-director", + "持续收集 39 个证据后再完成", + "design-context-continuation-run", + ) + .expect("start long background task"); + + let mut requests = Vec::new(); + for iteration in 1..=14 { + let request = receiver + .recv_timeout(Duration::from_secs(4)) + .expect("planning request"); + assert!(request.contains(&format!("第 {iteration} 轮"))); + requests.push(request); + } + assert!(receiver.recv_timeout(Duration::from_millis(200)).is_err()); + assert!(requests[6].contains("runtime.context")); + assert!(requests[6].contains("CONTEXT_MARKER_18")); + assert!(requests[12].contains("runtime.context")); + assert!(requests[12].contains("CONTEXT_MARKER_36")); + + let runtime = wait_for_agent_runtime_idle(&root, "design-director"); + assert_eq!(runtime.status, "idle"); + assert_eq!(runtime.phase, "completed"); + assert_eq!(runtime.loop_iteration, 14); + assert_eq!(runtime.max_loop_iterations, 18); + assert_eq!( + runtime.last_response.as_deref(), + Some("已连续跨过两个六轮上下文窗口并完成任务。") + ); + + let bundle_path = game_creator_agent_runtime_context_bundle_path( + &root, + "design-director", + "design-context-continuation-run", + ); + let bundle: Value = serde_json::from_str( + &fs::read_to_string(&bundle_path).expect("read runtime context bundle"), + ) + .expect("parse runtime context bundle"); + assert_eq!( + bundle["schemaVersion"], + AGENT_RUNTIME_CONTEXT_BUNDLE_SCHEMA_VERSION + ); + assert_eq!(bundle["agentId"], "design-director"); + assert_eq!(bundle["sessionId"], "agent-session-design-director"); + assert_eq!(bundle["runId"], "design-context-continuation-run"); + assert_eq!(bundle["nextLoopIndex"], 14); + assert!(bundle["contextWindow"] + .as_u64() + .is_some_and(|value| value >= 3)); + assert!(bundle["observations"] + .as_array() + .is_some_and(|items| items.len() <= AGENT_RUNTIME_CONTEXT_OBSERVATION_LIMIT)); + assert!(bundle["observations"] + .as_array() + .is_some_and(|items| items.iter().any(|item| item["tool"] == "runtime.context"))); + assert!( + fs::metadata(&bundle_path) + .expect("context bundle metadata") + .len() + <= AGENT_RUNTIME_CONTEXT_BUNDLE_MAX_BYTES as u64 + ); + + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn background_agent_runtime_recovers_bound_context_for_the_same_session_and_run() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "上下文恢复项目").expect("project init"); + let (sender, receiver) = mpsc::channel(); + let base_url = spawn_mock_llm_server_responses_with_capture( + vec![serde_json::json!({ + "thinkingSummary": "已读取恢复后的上下文", + "plan": [], + "actions": [], + "response": "已从持久化上下文继续。" + }) + .to_string()], + Some(sender), + ); + let _config_guard = write_test_local_config(format!( + r#"{{ + "agentLlm": {{ + "design-director": {{ + "apiKey": "design-key", + "baseUrl": {base_url:?}, + "model": "design-runtime-model", + "apiKind": "openai_responses" + }} + }} +}}"# + )); + let task = "恢复同一 run 的长任务上下文"; + let mut state = start_game_creator_agent_runtime_task_at( + &root, + "design-director", + task, + "design-context-recovery-run", + "agent-background-task", + "恢复测试", + vec!["读取持久化上下文".to_string(), "继续同一 run".to_string()], + ) + .expect("start recoverable runtime state"); + state.loop_iteration = AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT as u32; + state.max_loop_iterations = AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT as u32; + write_game_creator_agent_runtime_state(&root, &state) + .expect("persist recovered runtime loop state"); + let bundle_path = game_creator_agent_runtime_context_bundle_path( + &root, + "design-director", + "design-context-recovery-run", + ); + fs::create_dir_all(bundle_path.parent().expect("bundle parent")) + .expect("create context bundle dir"); + fs::write( + &bundle_path, + serde_json::to_string_pretty(&serde_json::json!({ + "schemaVersion": AGENT_RUNTIME_CONTEXT_BUNDLE_SCHEMA_VERSION, + "projectId": "project-1", + "agentId": "design-director", + "taskId": "design-director", + "sessionId": state.session_id, + "runId": "design-context-recovery-run", + "source": "agent-background-task", + "task": task, + "nextLoopIndex": AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT, + "contextWindow": 2, + "thinkingSummary": "前六轮已经定位关键约束", + "plan": ["使用恢复证据收束"], + "fallbackResponse": "", + "observations": [{ + "tool": "project.search", + "status": "ok", + "summary": "已定位恢复标记", + "detail": "RECOVERED_CONTEXT_MARKER" + }], + "lastWindowFingerprint": "a".repeat(64), + "updatedAt": unix_timestamp() + })) + .expect("serialize recoverable bundle"), + ) + .expect("write recoverable context bundle"); + + let resumed = resume_game_creator_agent_background_tasks_at(&root) + .expect("resume background task from durable context"); + assert_eq!(resumed.len(), 1); + let request = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("recovered planning request"); + assert!(request.contains("第 7 轮")); + assert!(request.contains("RECOVERED_CONTEXT_MARKER")); + let runtime = wait_for_agent_runtime_idle(&root, "design-director"); + assert_eq!(runtime.loop_iteration, 7); + assert_eq!(runtime.max_loop_iterations, 12); + assert_eq!( + runtime.last_response.as_deref(), + Some("已从持久化上下文继续。") + ); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn background_agent_runtime_rejects_cross_session_context_bundle_on_resume() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "上下文隔离项目").expect("project init"); + let task = "验证 context bundle Session 隔离"; + start_game_creator_agent_runtime_task_at( + &root, + "design-director", + task, + "design-context-isolation-run", + "agent-background-task", + "恢复测试", + vec!["读取持久化上下文".to_string()], + ) + .expect("start recoverable runtime state"); + let bundle_path = game_creator_agent_runtime_context_bundle_path( + &root, + "design-director", + "design-context-isolation-run", + ); + fs::create_dir_all(bundle_path.parent().expect("bundle parent")) + .expect("create context bundle dir"); + fs::write( + &bundle_path, + serde_json::to_string_pretty(&serde_json::json!({ + "schemaVersion": AGENT_RUNTIME_CONTEXT_BUNDLE_SCHEMA_VERSION, + "projectId": "project-1", + "agentId": "design-director", + "taskId": "design-director", + "sessionId": "another-session", + "runId": "design-context-isolation-run", + "source": "agent-background-task", + "task": task, + "nextLoopIndex": 3, + "contextWindow": 1, + "thinkingSummary": "错误 Session 的上下文", + "plan": [], + "fallbackResponse": "", + "observations": [], + "lastWindowFingerprint": null, + "updatedAt": unix_timestamp() + })) + .expect("serialize mismatched bundle"), + ) + .expect("write mismatched context bundle"); + + resume_game_creator_agent_background_tasks_at(&root) + .expect("resume command should fail the task rather than bypass context identity"); + let mut runtime = read_game_creator_agent_runtime_at(&root, "design-director") + .expect("read isolated runtime") + .state; + for _ in 0..100 { + if runtime.status == "failed" { + break; + } + std::thread::sleep(Duration::from_millis(20)); + runtime = read_game_creator_agent_runtime_at(&root, "design-director") + .expect("read isolated runtime") + .state; + } + assert_eq!(runtime.status, "failed"); + assert!(runtime.error.as_deref().is_some_and(|error| { + error.contains("context bundle") && error.contains("身份与当前任务不匹配") + })); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn agent_runtime_context_bundle_rejects_task_source_and_loop_identity_mismatches() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "上下文身份项目").expect("project init"); + let task = "验证 context bundle 完整身份绑定"; + let state = start_game_creator_agent_runtime_task_at( + &root, + "design-director", + task, + "design-context-identity-run", + "agent-background-task", + "身份绑定测试", + vec!["验证身份字段".to_string()], + ) + .expect("start identity runtime state"); + let context_tracker = AgentRuntimeContextWindowTracker::default(); + let valid_bundle = build_game_creator_agent_runtime_context_bundle( + &root, + &state, + task, + &AgentRuntimeToolPlan::default(), + &[], + 0, + &context_tracker, + ) + .expect("build valid identity context bundle"); + + let mut mismatched_task = valid_bundle.clone(); + mismatched_task.task_id = "code-prototype".to_string(); + write_game_creator_agent_runtime_context_bundle(&root, &mismatched_task) + .expect("write task-mismatched bundle"); + let error = read_game_creator_agent_runtime_context_bundle(&root, &state) + .expect_err("taskId mismatch must fail closed"); + assert!(error.contains("身份与当前任务不匹配")); + + let mut mismatched_source = valid_bundle.clone(); + mismatched_source.source = "agent-ready-task-scheduler".to_string(); + write_game_creator_agent_runtime_context_bundle(&root, &mismatched_source) + .expect("write source-mismatched bundle"); + let error = read_game_creator_agent_runtime_context_bundle(&root, &state) + .expect_err("source mismatch must fail closed"); + assert!(error.contains("身份与当前任务不匹配")); + + write_game_creator_agent_runtime_context_bundle(&root, &valid_bundle) + .expect("write project-bound bundle"); + let manifest_path = root.join(".agent/manifest.json"); + let mut manifest: Value = + serde_json::from_str(&fs::read_to_string(&manifest_path).expect("read identity manifest")) + .expect("parse identity manifest"); + manifest["projectId"] = Value::String("project-2".to_string()); + fs::write( + &manifest_path, + serde_json::to_string_pretty(&manifest).expect("serialize changed identity manifest"), + ) + .expect("write changed identity manifest"); + let error = read_game_creator_agent_runtime_context_bundle(&root, &state) + .expect_err("projectId mismatch must fail closed"); + assert!(error.contains("身份与当前任务不匹配")); + manifest["projectId"] = Value::String("project-1".to_string()); + fs::write( + &manifest_path, + serde_json::to_string_pretty(&manifest).expect("serialize restored identity manifest"), + ) + .expect("restore identity manifest"); + + let mut mismatched_loop = valid_bundle; + mismatched_loop.next_loop_index = 2; + mismatched_loop.context_window = 1; + write_game_creator_agent_runtime_context_bundle(&root, &mismatched_loop) + .expect("write loop-mismatched bundle"); + let error = read_game_creator_agent_runtime_context_bundle(&root, &state) + .expect_err("loop mismatch must fail closed"); + assert!(error.contains("轮次与当前状态不匹配")); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn agent_runtime_context_window_restores_progress_and_counts_dynamic_detail_changes() { + let dynamic_observation = |detail: &str| AgentRuntimeToolObservation { + tool: "agent.run_status".to_string(), + status: "ok".to_string(), + summary: "已读取 Agent 状态:code-prototype".to_string(), + detail: Some(detail.to_string()), + }; + let mut dynamic_tracker = AgentRuntimeContextWindowTracker::default(); + for next_loop_index in 1..AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT { + dynamic_tracker.record(&dynamic_observation("status: running")); + assert_eq!( + dynamic_tracker.complete_loop(next_loop_index), + AgentRuntimeContextCheckpoint::Continue + ); + } + dynamic_tracker.record(&dynamic_observation("status: completed")); + assert_eq!( + dynamic_tracker.complete_loop(AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT), + AgentRuntimeContextCheckpoint::Compacted, + "detail-only runtime progress must count as a new observation" + ); + + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "上下文窗口恢复项目").expect("project init"); + let mut state = start_game_creator_agent_runtime_task_at( + &root, + "design-director", + "验证窗口中段恢复", + "design-context-window-resume-run", + "agent-background-task", + "窗口恢复测试", + vec!["恢复窗口 tracker".to_string()], + ) + .expect("start window runtime state"); + let mut tracker = AgentRuntimeContextWindowTracker::default(); + for next_loop_index in 1..=3 { + tracker.record(&AgentRuntimeToolObservation { + tool: "project.search".to_string(), + status: "ok".to_string(), + summary: "重复窗口标记".to_string(), + detail: None, + }); + assert_eq!( + tracker.complete_loop(next_loop_index), + AgentRuntimeContextCheckpoint::Continue + ); + } + let bundle = build_game_creator_agent_runtime_context_bundle( + &root, + &state, + &state.current_task, + &AgentRuntimeToolPlan::default(), + &[], + 3, + &tracker, + ) + .expect("build mid-window context bundle"); + assert_eq!(bundle.window_completed_loops, 3); + assert_eq!(bundle.window_observation_fingerprints.len(), 1); + write_game_creator_agent_runtime_context_bundle(&root, &bundle) + .expect("write mid-window context bundle"); + state.loop_iteration = 3; + let loaded = read_game_creator_agent_runtime_context_bundle(&root, &state) + .expect("read mid-window context bundle") + .expect("mid-window bundle exists"); + let continuation = continuation_from_game_creator_agent_runtime_context_bundle(loaded); + let mut restored = AgentRuntimeContextWindowTracker::from_continuation(&continuation); + for next_loop_index in 4..AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT { + restored.record(&AgentRuntimeToolObservation { + tool: "project.search".to_string(), + status: "ok".to_string(), + summary: "重复窗口标记".to_string(), + detail: None, + }); + assert_eq!( + restored.complete_loop(next_loop_index), + AgentRuntimeContextCheckpoint::Continue + ); + } + restored.record(&AgentRuntimeToolObservation { + tool: "project.search".to_string(), + status: "ok".to_string(), + summary: "重复窗口标记".to_string(), + detail: None, + }); + assert_eq!( + restored.complete_loop(AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT), + AgentRuntimeContextCheckpoint::Stalled, + "restored tracker must retain the original six-loop stall budget" + ); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn agent_runtime_context_bundle_redacts_paths_and_secrets_and_limits_plan() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "上下文安全项目").expect("project init"); + let state = start_game_creator_agent_runtime_task_at( + &root, + "design-director", + "验证上下文安全持久化", + "design-context-safety-run", + "agent-background-task", + "安全持久化测试", + vec!["生成安全 context bundle".to_string()], + ) + .expect("start runtime state"); + let root_display = root.to_string_lossy(); + let secret = ["s", "k-1234567890abcdef"].concat(); + let plan = AgentRuntimeToolPlan { + thinking_summary: format!("检查 {root_display},凭据为 {secret}"), + plan: (0..AGENT_RUNTIME_PLAN_STEP_LIMIT + 3) + .map(|index| format!("步骤 {index} 读取 {root_display}")) + .collect(), + actions: Vec::new(), + response: format!(r#"结果位于 {root_display},{{"credential":"{secret}"}}"#), + }; + let observations = vec![AgentRuntimeToolObservation { + tool: "project.search".to_string(), + status: "ok".to_string(), + summary: format!("在 {root_display} 找到结果"), + detail: Some(format!(r#"{{"credential":"{secret}"}}"#)), + }]; + let context_tracker = AgentRuntimeContextWindowTracker::default(); + + persist_game_creator_agent_runtime_context( + &root, + &state, + &format!("分析 {root_display}"), + &plan, + &observations, + 0, + &context_tracker, + ) + .expect("persist sanitized context bundle"); + + let bundle_path = game_creator_agent_runtime_context_bundle_path( + &root, + "design-director", + "design-context-safety-run", + ); + let content = fs::read_to_string(&bundle_path).expect("read sanitized context bundle"); + let bundle: AgentRuntimeContextBundle = + serde_json::from_str(&content).expect("parse sanitized context bundle"); + assert!(!content.contains(root_display.as_ref())); + assert!(!content.contains(&secret)); + assert!(content.contains("$PROJECT_ROOT")); + assert!(content.contains("[redacted-secret]")); + assert_eq!(bundle.plan.len(), AGENT_RUNTIME_PLAN_STEP_LIMIT); + assert!( + fs::metadata(&bundle_path) + .expect("context bundle metadata") + .len() + <= AGENT_RUNTIME_CONTEXT_BUNDLE_MAX_BYTES as u64 + ); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn agent_runtime_context_bundle_size_limit_includes_trailing_newline() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "上下文大小项目").expect("project init"); + let state = start_game_creator_agent_runtime_task_at( + &root, + "design-director", + "验证上下文大小边界", + "design-context-size-run", + "agent-background-task", + "大小边界测试", + vec!["生成边界 context bundle".to_string()], + ) + .expect("start runtime state"); + let plan = AgentRuntimeToolPlan { + thinking_summary: "上下文大小边界".to_string(), + plan: (0..AGENT_RUNTIME_PLAN_STEP_LIMIT) + .map(|_| "p".repeat(180)) + .collect(), + actions: Vec::new(), + response: String::new(), + }; + let context_tracker = AgentRuntimeContextWindowTracker::default(); + let mut boundary_bundle = None; + for detail_chars in (0..=1_600).rev() { + let observations = (0..AGENT_RUNTIME_CONTEXT_OBSERVATION_LIMIT) + .map(|_| AgentRuntimeToolObservation { + tool: "project.search".to_string(), + status: "ok".to_string(), + summary: "s".repeat(320), + detail: Some("界".repeat(detail_chars)), + }) + .collect::>(); + let bundle = build_game_creator_agent_runtime_context_bundle( + &root, + &state, + &"t".repeat(AGENT_RUNTIME_TASK_MAX_CHARS), + &plan, + &observations, + 0, + &context_tracker, + ) + .expect("build candidate context bundle"); + let serialized_len = serde_json::to_string_pretty(&bundle) + .expect("serialize candidate context bundle") + .len(); + if serialized_len <= AGENT_RUNTIME_CONTEXT_BUNDLE_MAX_BYTES { + boundary_bundle = Some((bundle, serialized_len)); + break; + } + } + let (mut bundle, serialized_len) = + boundary_bundle.expect("find context bundle just below byte limit"); + let remaining = AGENT_RUNTIME_CONTEXT_BUNDLE_MAX_BYTES - serialized_len; + assert!(remaining <= 1_200); + bundle.fallback_response = "x".repeat(remaining); + assert_eq!( + serde_json::to_string_pretty(&bundle) + .expect("serialize boundary context bundle") + .len(), + AGENT_RUNTIME_CONTEXT_BUNDLE_MAX_BYTES + ); + + let error = write_game_creator_agent_runtime_context_bundle(&root, &bundle) + .expect_err("trailing newline must count toward context bundle size limit"); + assert!(error.contains("超过") && error.contains("字节上限")); + + fs::remove_dir_all(root).ok(); +} + +#[cfg(unix)] +#[test] +fn agent_runtime_context_bundle_rejects_symlinked_parent_directory() { + use std::os::unix::fs::symlink; + + let root = unique_project_path(); + let outside = unique_project_path(); + init_local_game_project_at(&root, "project-1", "上下文符号链接项目").expect("project init"); + fs::create_dir_all(&outside).expect("create outside context directory"); + let state = start_game_creator_agent_runtime_task_at( + &root, + "design-director", + "验证上下文符号链接边界", + "design-context-symlink-run", + "agent-background-task", + "符号链接边界测试", + vec!["拒绝符号链接父目录".to_string()], + ) + .expect("start symlink runtime state"); + let tracker = AgentRuntimeContextWindowTracker::default(); + let bundle = build_game_creator_agent_runtime_context_bundle( + &root, + &state, + &state.current_task, + &AgentRuntimeToolPlan::default(), + &[], + 0, + &tracker, + ) + .expect("build symlink context bundle"); + let context_root = root.join(".agent/runtime/context-bundles"); + fs::create_dir_all(&context_root).expect("create context root"); + symlink(&outside, context_root.join("design-director")) + .expect("symlink context agent directory"); + + let error = write_game_creator_agent_runtime_context_bundle(&root, &bundle) + .expect_err("symlinked context parent must fail closed"); + assert!(error.contains("符号链接")); + assert!(fs::read_dir(&outside) + .expect("read outside context directory") + .next() + .is_none()); + + fs::remove_dir_all(root).ok(); + fs::remove_dir_all(outside).ok(); +} + +#[test] +fn agent_runtime_context_bundle_read_is_bounded_by_actual_file_bytes() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "上下文读取上限项目").expect("project init"); + let state = start_game_creator_agent_runtime_task_at( + &root, + "design-director", + "验证上下文读取上限", + "design-context-read-limit-run", + "agent-background-task", + "读取上限测试", + vec!["有界读取 context bundle".to_string()], + ) + .expect("start bounded-read runtime state"); + let bundle_path = game_creator_agent_runtime_context_bundle_path( + &root, + "design-director", + "design-context-read-limit-run", + ); + fs::create_dir_all(bundle_path.parent().expect("bundle parent")) + .expect("create bounded-read bundle parent"); + fs::write( + &bundle_path, + vec![b'x'; AGENT_RUNTIME_CONTEXT_BUNDLE_MAX_BYTES + 1], + ) + .expect("write oversized context bundle"); + + let error = read_game_creator_agent_runtime_context_bundle(&root, &state) + .expect_err("oversized context bundle must fail before parsing"); + assert!(error.contains("超过") && error.contains("字节上限")); + + fs::remove_dir_all(root).ok(); +} + #[tokio::test] async fn background_agent_runtime_reports_and_truncates_excess_tool_actions() { let root = unique_project_path(); @@ -7623,8 +8485,46 @@ fn pending_tool_action_identity_binds_task_context_and_occurrence() { assert_ne!(first_action_id, repeated_action_id); } +#[test] +fn secret_token_redaction_handles_json_boundaries_without_redacting_normal_words() { + let secrets = [ + ["s", "k-1234567890abcdef"].concat(), + format!("ghp_{}", "a".repeat(36)), + format!("npm_{}", "b".repeat(36)), + format!("AKIA{}", "C".repeat(16)), + format!( + "eyJ{}.{}.{}", + "a".repeat(20), + "b".repeat(20), + "c".repeat(24) + ), + ]; + let json = format!( + r#"{{"credentials":{},"label":"task-sketch","package":"npm_package_name"}}"#, + serde_json::to_string(&secrets).expect("serialize secret fixtures") + ); + let redacted = redact_secret_tokens(&json); + + for secret in &secrets { + assert!(!redacted.contains(secret)); + } + assert_eq!(redacted.matches("[redacted-secret]").count(), secrets.len()); + assert!(redacted.contains("task-sketch")); + assert!(redacted.contains("npm_package_name")); +} + +#[test] +fn agent_runtime_tool_plan_prompt_explains_named_verification_scripts_and_context_windows() { + let prompt = game_creator_agent_runtime_tool_plan_system_prompt(); + + assert!(prompt.contains("test:unit")); + assert!(prompt.contains("命名脚本")); + assert!(prompt.contains("上下文压缩窗口")); + assert!(prompt.contains("同一 run")); +} + #[tokio::test] -async fn background_agent_runtime_keeps_auto_ledger_while_followup_plan_is_in_flight() { +async fn background_agent_runtime_moves_auto_observation_to_context_before_followup_plan() { let root = unique_project_path(); init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); write_project_permission_policy_at( @@ -7643,7 +8543,7 @@ async fn background_agent_runtime_keeps_auto_ledger_while_followup_plan_is_in_fl "plan": ["写入私有记忆", "根据观察回复"], "actions": [{ "tool": "memory.write", - "reason": "验证后续规划期间仍保留动作账本", + "reason": "验证后续规划前持久化动作观察", "input": { "scope": "agent", "title": "规划中账本标记", @@ -7696,22 +8596,26 @@ async fn background_agent_runtime_keeps_auto_ledger_while_followup_plan_is_in_fl let pending_path = root.join( ".agent/runtime/pending-actions/design-director/design-auto-ledger-inflight-run.json", ); - let pending: AgentRuntimePendingToolAction = serde_json::from_str( - &fs::read_to_string(&pending_path).expect("auto ledger while followup plan is blocked"), + assert!( + !pending_path.exists(), + "observed action ledger must be removed before the next planning request" + ); + let bundle_path = game_creator_agent_runtime_context_bundle_path( + &root, + "design-director", + "design-auto-ledger-inflight-run", + ); + let bundle: AgentRuntimeContextBundle = serde_json::from_str( + &fs::read_to_string(&bundle_path).expect("durable context while followup plan is blocked"), ) - .expect("parse auto ledger"); - assert_eq!( - pending.execution_mode, - AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO - ); - assert_eq!(pending.status, "observed-approved"); - assert_eq!( - pending - .observation - .as_ref() - .map(|observation| observation.status.as_str()), - Some("ok") - ); + .expect("parse durable followup context"); + assert_eq!(bundle.next_loop_index, 1); + assert_eq!(bundle.window_completed_loops, 1); + assert!(bundle.observations.iter().any(|observation| { + observation.tool == "memory.write" + && observation.status == "ok" + && observation.summary.contains("已写入 Agent 记忆") + })); let memory = read_local_agent_memory_at(&root, "design-director").expect("agent memory"); assert_eq!( memory @@ -15948,6 +16852,13 @@ fn project_verification_resolves_npm_script_and_rejects_command_drift() { "packageManager": "npm@10.0.0", "scripts": { "check": check_command, + "check:ci": "node scripts/check-ci.mjs", + "test:unit": "node --test", + "lint:strict": "eslint . --max-warnings 0", + "typecheck:app": "tsc --noEmit", + "build:preview": "vite build --mode preview", + "verify:contracts": "node scripts/verify-contracts.mjs", + "validate:schema": "node scripts/validate-schema.mjs", "deploy": "node scripts/deploy.mjs" } })) @@ -15966,13 +16877,58 @@ fn project_verification_resolves_npm_script_and_rejects_command_drift() { ); assert_eq!(spec.timeout_seconds, 30); + let named_test = resolve_project_verification_spec_at(&root, "test:unit", "node --test", 30) + .expect("resolve named unit test script"); + assert_eq!( + named_test.arguments, + vec!["run", "--silent", "--ignore-scripts", "test:unit"] + ); + for (script, command) in [ + ("check:ci", "node scripts/check-ci.mjs"), + ("test:unit", "node --test"), + ("lint:strict", "eslint . --max-warnings 0"), + ("typecheck:app", "tsc --noEmit"), + ("build:preview", "vite build --mode preview"), + ("verify:contracts", "node scripts/verify-contracts.mjs"), + ("validate:schema", "node scripts/validate-schema.mjs"), + ] { + resolve_project_verification_spec_at(&root, script, command, 30) + .unwrap_or_else(|error| panic!("resolve named verification script {script}: {error}")); + } + let drift = resolve_project_verification_spec_at(&root, "check", "node stale.mjs", 30) .expect_err("changed package script should fail closed"); assert!(drift.contains("package.json 中的 check 脚本已变化")); let unsupported = resolve_project_verification_spec_at(&root, "deploy", "node scripts/deploy.mjs", 30) .expect_err("deploy is outside verification allowlist"); - assert!(unsupported.contains("只允许 check、typecheck、test、lint、build")); + assert!(unsupported.contains("只允许验证类脚本")); + let empty_suffix = resolve_project_verification_spec_at(&root, "test:", "node --test", 30) + .expect_err("named script suffix must not be empty"); + assert!(empty_suffix.contains("只允许验证类脚本")); + let deploy_family = resolve_project_verification_spec_at( + &root, + "deploy:production", + "node scripts/deploy.mjs", + 30, + ) + .expect_err("deploy family stays outside verification allowlist"); + assert!(deploy_family.contains("只允许验证类脚本")); + for unsafe_script in [ + "test::unit", + "test:unit fast", + "test:../unit", + "pretest:unit", + "posttest:unit", + ] { + let error = resolve_project_verification_spec_at(&root, unsafe_script, "node --test", 30) + .expect_err("unsafe or lifecycle-like script name must fail closed"); + assert!(error.contains("只允许验证类脚本")); + } + let overlong_script = format!("test:{}", "a".repeat(161)); + let error = resolve_project_verification_spec_at(&root, &overlong_script, "node --test", 30) + .expect_err("overlong named script must fail closed"); + assert!(error.contains("总长度不能超过")); fs::remove_dir_all(root).ok(); } @@ -15980,8 +16936,9 @@ fn project_verification_resolves_npm_script_and_rejects_command_drift() { #[test] fn project_verification_output_redacts_secrets_and_keeps_failure_tail() { let secret = concat!("sk-", "1234567890abcdefghijkl"); + let github_secret = format!("ghp_{}", "a".repeat(36)); let output = format!( - "head\n{}\nprovider-error({secret});\nVERIFY_FAILURE_TAIL", + "head\n{}\nprovider-error({secret});\ngithub={github_secret}\nVERIFY_FAILURE_TAIL", "x".repeat(PROJECT_VERIFICATION_OUTPUT_MAX_BYTES * 2), ); let sanitized = sanitize_project_verification_output(&output); @@ -15990,9 +16947,57 @@ fn project_verification_output_redacts_secrets_and_keeps_failure_tail() { assert!(sanitized.contains(" npmrc-script-shell-ran.txt\nexit 0\n", + ) + .expect("write malicious script shell"); + let mut permissions = fs::metadata(&evil_shell) + .expect("malicious script shell metadata") + .permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&evil_shell, permissions).expect("make malicious script shell executable"); + fs::write( + root.join(".npmrc"), + format!("script-shell={}\n", evil_shell.display()), + ) + .expect("write project npmrc"); + let check_command = r#"node -e "require('fs').writeFileSync('expected-script-ran.txt','ok')""#; + fs::write( + root.join("package.json"), + serde_json::to_string_pretty(&serde_json::json!({ + "name": "verify-script-shell-fixture", + "private": true, + "packageManager": "npm@10.0.0", + "scripts": { "check": check_command } + })) + .expect("serialize npmrc package json"), + ) + .expect("write npmrc package json"); + + let error = run_project_verification_at(&root, "check", check_command, 15) + .await + .expect_err("project npmrc must fail closed before verification"); + + assert!(error.contains("不允许项目级 .npmrc")); + assert!(!root.join("expected-script-ran.txt").exists()); + assert!(!root.join("npmrc-script-shell-ran.txt").exists()); + + fs::remove_dir_all(root).ok(); +} + #[cfg(unix)] #[tokio::test] async fn project_verification_cleans_up_residual_process_group() { @@ -16038,6 +17043,11 @@ async fn project_verification_runs_without_prepost_and_records_failure_and_timeo let check_command = r#"node -e "process.stdout.write('VERIFY_PROCESS_OK')""#; let precheck_command = r#"node -e "require('fs').writeFileSync('precheck-ran.txt','unexpected')""#; + let named_test_command = r#"node -e "process.stdout.write('VERIFY_NAMED_TEST_OK')""#; + let prenamed_test_command = + r#"node -e "require('fs').writeFileSync('prenamed-test-ran.txt','unexpected')""#; + let postnamed_test_command = + r#"node -e "require('fs').writeFileSync('postnamed-test-ran.txt','unexpected')""#; let test_command = r#"node -e "console.error('VERIFY_EXIT_7');process.exit(7)""#; let lint_command = r#"node -e "const {spawn}=require('child_process');spawn(process.execPath,['-e','setTimeout(()=>{},5000)'],{stdio:'inherit'});setTimeout(()=>{},5000)""#; fs::write( @@ -16048,6 +17058,9 @@ async fn project_verification_runs_without_prepost_and_records_failure_and_timeo "scripts": { "precheck": precheck_command, "check": check_command, + "pretest:unit": prenamed_test_command, + "test:unit": named_test_command, + "posttest:unit": postnamed_test_command, "test": test_command, "lint": lint_command } @@ -16065,6 +17078,14 @@ async fn project_verification_runs_without_prepost_and_records_failure_and_timeo assert!(completed.output.contains("VERIFY_PROCESS_OK")); assert!(!root.join("precheck-ran.txt").exists()); + let named = run_project_verification_at(&root, "test:unit", named_test_command, 15) + .await + .expect("run named unit test script"); + assert_eq!(named.status, "completed"); + assert!(named.output.contains("VERIFY_NAMED_TEST_OK")); + assert!(!root.join("prenamed-test-ran.txt").exists()); + assert!(!root.join("postnamed-test-ran.txt").exists()); + let failed = run_project_verification_at(&root, "test", test_command, 15) .await .expect("record failed test script"); @@ -16082,6 +17103,7 @@ async fn project_verification_runs_without_prepost_and_records_failure_and_timeo let log = fs::read_to_string(root.join(".agent/logs/command.log")).expect("command log"); assert!(log.contains("project.verify check completed")); + assert!(log.contains("project.verify test:unit completed")); assert!(log.contains("project.verify test failed")); assert!(log.contains("project.verify lint failed")); let manifest: Value = diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 9d7549a86..969f9f28a 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -93,6 +93,7 @@ const AGENT_RUN_HISTORY_INITIAL_VISIBLE_COUNT = 20; const AGENT_RUN_HISTORY_VISIBLE_STEP = 20; const CONVERSATION_INITIAL_VISIBLE_COUNT = 20; const CONVERSATION_VISIBLE_STEP = 20; +const AGENT_CHAT_SCROLL_BOTTOM_THRESHOLD = 48; const ACCESS_TOKEN_STORAGE_KEY = 'genarrative.auth.access-token.v1'; const DEFAULT_CLIENT_AUTH_API_BASE_URL = 'http://127.0.0.1:8082'; const launcherNotifications: Array<{ @@ -3037,6 +3038,7 @@ export function WorkspaceLauncher({ const [agentChatStatus, setAgentChatStatus] = useState('请选择项目和 Agent'); const [agentChatBusy, setAgentChatBusy] = useState(false); const agentChatMessagesRef = useRef(null); + const agentChatShouldFollowLatestRef = useRef(true); const [agentChatBackgroundBusy, setAgentChatBackgroundBusy] = useState(false); const [agentChatPendingRuntimeRun, setAgentChatPendingRuntimeRunState] = useState(null); @@ -3152,7 +3154,7 @@ export function WorkspaceLauncher({ useLayoutEffect(() => { const messageList = agentChatMessagesRef.current; - if (messageList) { + if (messageList && agentChatShouldFollowLatestRef.current) { messageList.scrollTop = messageList.scrollHeight; } }, [ @@ -3162,6 +3164,14 @@ export function WorkspaceLauncher({ agentChatStatus, ]); + function handleAgentChatMessagesScroll(event: UIEvent) { + const messageList = event.currentTarget; + const distanceFromBottom = + messageList.scrollHeight - messageList.scrollTop - messageList.clientHeight; + agentChatShouldFollowLatestRef.current = + distanceFromBottom <= AGENT_CHAT_SCROLL_BOTTOM_THRESHOLD; + } + useEffect(() => { const invoke = resolveTauriInvoke(); if (!invoke || recentWorkspaces.length === 0) { @@ -3887,6 +3897,7 @@ export function WorkspaceLauncher({ } function resetAgentChatSessionView() { + agentChatShouldFollowLatestRef.current = true; setAgentChatReplyPhase('idle'); setAgentChatPendingRuntimeRun(null); setAgentChatSessions([]); @@ -4085,6 +4096,7 @@ export function WorkspaceLauncher({ if (!projectPathForChat || !agent) { return; } + agentChatShouldFollowLatestRef.current = true; const pendingRun = agentChatPendingRuntimeRunRef.current; if ( pendingRun && @@ -4450,6 +4462,7 @@ export function WorkspaceLauncher({ } const saveVersion = agentChatLoadVersionRef.current + 1; agentChatLoadVersionRef.current = saveVersion; + agentChatShouldFollowLatestRef.current = true; setAgentChatBusy(true); setAgentChatReplyPhase('saving-user'); setAgentChatInput(''); @@ -4613,19 +4626,8 @@ export function WorkspaceLauncher({ setAgentChatStatus( `Agent 回复已结束(${pendingStreamFinishReason}),正在保存已接收内容`, ); - } else if (pendingStreamDraftText) { - throw error; } else { - setAgentChatStatus('流式连接失败,正在切换普通回复模式'); - reply = await invoke( - 'chat_with_game_creator_role_agent', - { - projectPath: projectPathForChat, - agentId: agent.id, - prompt: content, - ...agentChatSessionInvokeArgs(sessionIdForChat), - }, - ); + throw error; } } } else { @@ -4794,6 +4796,7 @@ export function WorkspaceLauncher({ } const saveVersion = agentChatLoadVersionRef.current + 1; agentChatLoadVersionRef.current = saveVersion; + agentChatShouldFollowLatestRef.current = true; const requestedRunId = createAgentChatRunId('launcher-agent-task'); let pendingRunId = requestedRunId; setAgentChatBackgroundBusy(true); @@ -6056,6 +6059,7 @@ export function WorkspaceLauncher({ role="log" aria-label="Agent 聊天记录" aria-busy={currentAgentChatWaiting} + onScroll={handleAgentChatMessagesScroll} tabIndex={0} > {agentChatMessages.length > 0 ? ( diff --git a/apps/ai-game-creator-shell/tests/appSurface.test.ts b/apps/ai-game-creator-shell/tests/appSurface.test.ts index caecf4f76..28785652c 100644 --- a/apps/ai-game-creator-shell/tests/appSurface.test.ts +++ b/apps/ai-game-creator-shell/tests/appSurface.test.ts @@ -1122,12 +1122,14 @@ describe('AI 游戏创作 App 界面边界', () => { ); }); - it('falls back to a normal reply when streaming fails before the first delta', async () => { + it('persists and shows an upstream stream error without issuing a normal retry', async () => { const persistedMessages: Array<{ role: 'user' | 'assistant' | 'tool'; content: string; agentId: string | null; }> = []; + const upstreamError = '上游余额不足'; + const persistedError = `已保存用户消息;Agent 回复失败:${upstreamError}`; const invoke = vi.fn( async (command: string, args?: Record) => { if (command === 'read_local_conversation') { @@ -1138,10 +1140,10 @@ describe('AI 游戏创作 App 界面边界', () => { }; } if (command === 'chat_with_game_creator_role_agent_stream') { - throw new Error('stream disconnected before first delta'); + throw new Error(upstreamError); } if (command === 'chat_with_game_creator_role_agent') { - return { replyText: '普通回复补位成功。' }; + throw new Error('upstream errors must not issue a normal retry'); } if (command === 'append_local_conversation_message') { persistedMessages.push( @@ -1177,20 +1179,31 @@ describe('AI 游戏创作 App 界面边界', () => { expect(await screen.findByText(/已读取 0 条/)).not.toBeNull(); fireEvent.change(screen.getByLabelText('Agent 聊天内容'), { - target: { value: '流式失败时继续回答' }, + target: { value: '不要重复请求上游' }, }); selectDeveloperAgentChatMode('chat'); fireEvent.click(screen.getByRole('button', { name: '发送' })); - expect(await screen.findByText('普通回复补位成功。')).not.toBeNull(); - expect(invoke).toHaveBeenCalledWith('chat_with_game_creator_role_agent', { - projectPath: '/tmp/authorized-game', - agentId: 'design-director', - prompt: '流式失败时继续回答', - }); + expect( + await within(screen.getByLabelText('Agent 聊天记录')).findByText( + persistedError, + ), + ).not.toBeNull(); + expect(invoke).not.toHaveBeenCalledWith( + 'chat_with_game_creator_role_agent', + expect.anything(), + ); + expect(invoke).toHaveBeenCalledWith( + 'chat_with_game_creator_role_agent_stream', + expect.objectContaining({ + projectPath: '/tmp/authorized-game', + agentId: 'design-director', + prompt: '不要重复请求上游', + }), + ); expect(persistedMessages.at(-1)).toEqual({ role: 'assistant', - content: '普通回复补位成功。', + content: persistedError, agentId: null, }); }); @@ -2019,6 +2032,201 @@ describe('AI 游戏创作 App 界面边界', () => { ]); }); + it('follows new agent chat messages near the bottom without stealing an intentional scroll position', async () => { + const persistedMessages: Array<{ + role: 'user' | 'assistant'; + content: string; + agentId: string | null; + }> = [ + { + role: 'assistant', + content: '历史消息', + agentId: null, + }, + ]; + let streamHandler: + | ((event: { payload: Record }) => void) + | null = null; + let streamRunId = ''; + let releaseStream: (() => void) | null = null; + const listen = vi.fn( + async ( + eventName: string, + handler: (event: { payload: Record }) => void, + ) => { + if (eventName === 'game-creator-role-agent-chat-stream') { + streamHandler = handler; + return () => { + if (streamHandler === handler) { + streamHandler = null; + } + }; + } + return () => {}; + }, + ); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'read_local_conversation') { + return { + path: '/tmp/authorized-game/.agent/conversations/agents/design-director.jsonl', + agentId: args?.agentId, + messages: persistedMessages.map((message, index) => ({ + schemaVersion: '1', + ...message, + updatedAt: index + 1, + })), + }; + } + if (command === 'chat_with_game_creator_role_agent_stream') { + streamRunId = String(args?.runId ?? ''); + streamHandler?.({ + payload: { + projectPath: args?.projectPath, + agentId: args?.agentId, + runId: streamRunId, + status: 'started', + deltaText: '', + accumulatedText: '', + finishReason: null, + runtimeSummary: '请求 Agent LLM', + }, + }); + await new Promise((resolve) => { + releaseStream = resolve; + }); + streamHandler?.({ + payload: { + projectPath: args?.projectPath, + agentId: args?.agentId, + runId: streamRunId, + status: 'completed', + deltaText: '', + accumulatedText: '第一段第二段', + finishReason: 'stop', + runtimeSummary: '等待下一轮输入', + }, + }); + return { replyText: '第一段第二段' }; + } + if (command === 'append_local_conversation_message') { + persistedMessages.push( + args?.message as { + role: 'user' | 'assistant'; + content: string; + agentId: string | null; + }, + ); + return { + path: '/tmp/authorized-game/.agent/conversations/agents/design-director.jsonl', + agentId: args?.agentId, + messages: persistedMessages.map((message, index) => ({ + schemaVersion: '1', + ...message, + updatedAt: index + 1, + })), + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke }, event: { listen } }; + renderLauncherAgentChatAt('/?agent-chat'); + + fireEvent.change(screen.getByLabelText('Agent 聊天项目目录'), { + target: { value: '/tmp/authorized-game' }, + }); + fireEvent.click(screen.getByRole('button', { name: '读取历史' })); + expect(await screen.findByText('历史消息')).not.toBeNull(); + + const messageList = screen.getByLabelText( + 'Agent 聊天记录', + ) as HTMLDivElement; + let messageScrollHeight = 600; + Object.defineProperty(messageList, 'scrollHeight', { + configurable: true, + get: () => messageScrollHeight, + }); + Object.defineProperty(messageList, 'clientHeight', { + configurable: true, + get: () => 100, + }); + + fireEvent.change(screen.getByLabelText('Agent 聊天内容'), { + target: { value: '测试消息列表滚动' }, + }); + selectDeveloperAgentChatMode('chat'); + fireEvent.click(screen.getByRole('button', { name: '发送' })); + await waitFor(() => expect(releaseStream).not.toBeNull()); + + messageList.scrollTop = 460; + fireEvent.scroll(messageList); + messageScrollHeight = 720; + await act(async () => { + streamHandler?.({ + payload: { + projectPath: '/tmp/authorized-game', + agentId: 'design-director', + runId: streamRunId, + status: 'delta', + deltaText: '第一段', + accumulatedText: '第一段', + finishReason: null, + }, + }); + }); + expect(await screen.findByText('第一段')).not.toBeNull(); + await waitFor(() => expect(messageList.scrollTop).toBe(720)); + + messageList.scrollTop = 120; + fireEvent.scroll(messageList); + messageScrollHeight = 780; + await act(async () => { + streamHandler?.({ + payload: { + projectPath: '/tmp/authorized-game', + agentId: 'design-director', + runId: streamRunId, + status: 'started', + deltaText: '', + accumulatedText: '第一段', + finishReason: null, + runtimeSummary: '上游正在重试', + }, + }); + }); + expect( + await within(messageList).findByText( + '已连接 Agent LLM,上游正在重试', + ), + ).not.toBeNull(); + expect(messageList.scrollTop).toBe(120); + + messageScrollHeight = 840; + await act(async () => { + streamHandler?.({ + payload: { + projectPath: '/tmp/authorized-game', + agentId: 'design-director', + runId: streamRunId, + status: 'delta', + deltaText: '第二段', + accumulatedText: '第一段第二段', + finishReason: null, + }, + }); + }); + expect(await screen.findByText('第一段第二段')).not.toBeNull(); + expect(messageList.scrollTop).toBe(120); + + messageScrollHeight = 900; + await act(async () => { + releaseStream?.(); + }); + expect(await screen.findByText(/已保存 3 条/)).not.toBeNull(); + expect(messageList.scrollTop).toBe(120); + }); + it('runs developer agent work by default and syncs the terminal reply', async () => { const persistedMessages: Array<{ role: 'user' | 'assistant'; diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 8154a104b..24e5ba22e 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -18,15 +18,17 @@ ## 2026-07-09 AI 游戏创作 App Runtime V1 增加单 Agent 后台任务 +- 2026-07-12 安全边界:`project.verify` 的 script 最多 160 个字符,固定使用系统 script shell,并在解析和执行前拒绝项目级 `.npmrc`。Runtime context bundle 必须绑定 `projectId / agentId / taskId / sessionId / runId / source / task`,结尾换行计入 64 KiB 上限;恢复时还要校验 `nextLoopIndex`、context window、当前窗口已完成轮数、观察指纹、计划和 observation 数量。bundle 写入必须拒绝父目录符号链接,读取必须基于同一文件句柄限制到 64 KiB,并清洗项目路径及常见平台凭据;已观察动作只有在 observation 写入 context checkpoint 后才能删除 ledger,下一轮 planning 和跨重启恢复不得再被旧 ledger 抢占。 - 背景:开发用单 Agent 聊天已经能真实调用各 Agent 的 LLM 路由并持久化对话,但 Agent 仍主要表现为同步问答,用户无法明确投递一个任务让某个 Agent 独立运行,也无法同时启动多个 Agent 的工作。 -- 决策:在现有 `.agent/runtime` 和 `.agent/conversations` 基础上新增单 Agent 后台任务入口。Tauri 命令 `start_game_creator_agent_runtime_task` 立即写入该 Agent 的 runtime state/event/task history,追加用户任务到 `.agent/conversations/agents/.jsonl`,随后在 App 进程内启动 tokio task 执行最小 Agent loop:Agent 按轮输出 `thinkingSummary / plan / actions / response`,Runtime 按白名单和项目权限策略执行工具并记录 `action / observation` 事件,再把已有 observation 放回下一轮 prompt,让 Agent 修正计划、继续行动或用空 actions + response 收束;当前后台任务最多执行 3 轮 loop,仍未收束时再按最后计划和全部观察生成最终回复。完成或失败后把 assistant 回复或错误追加回对话,并写入 `.agent/agent.db` 审计记录。工具箱包含只读工具 `memory.read`、`conversation.read`、`asset.list`、`project.index`、`project.diff`、`file.list`、`file.read`、`agent.run_status`,以及受策略保护的写/运行工具 `memory.write`、`file.write`、`command.run_limited`、`blackboard.write`、`agent.message` 和 `agent.delegate`;`memory.write` 可追加或覆盖本 Agent 私有记忆、项目长期/短期记忆或黑板,`file.write` 只能写项目内相对路径,`command.run_limited` 只接受 `game.static_smoke` 并复用本地静态自检安全边界,`blackboard.write` 追加共享黑板,`agent.message` 写目标 Agent 对话,`agent.delegate` 把任务投递到目标 Agent 的独立后台队列;策略拒绝时不执行工具并把 `blocked` observation 回给 Agent;策略要求确认时不执行工具,而是持久化精确待确认动作并暂停该 Agent 队列,待开发者确认或拒绝后在同一 run 续跑。每个 Agent 的任务历史落在 `.agent/runtime/tasks/.jsonl`,读 runtime 时按 `runId` 去重返回最近任务,任务视角状态使用 `pending / running / completed / failed`,Runtime state 增加 `nextStep`,UI 在 Runtime 面板和主 Agent 状态卡展示当前任务、动作、下一步与最近任务。不同 Agent 使用独立 `.agent/runtime/locks/.lock`,允许并行运行;同一 Agent 已有运行任务时,新任务会先进入该 Agent 的 pending 队列,当前 drain 持锁完成后串行继续下一条 pending。该能力仍不是独立 OS 进程或跨重启离线常驻 worker。 +- 决策:在现有 `.agent/runtime` 和 `.agent/conversations` 基础上新增单 Agent 后台任务入口。Tauri 命令 `start_game_creator_agent_runtime_task` 立即写入该 Agent 的 runtime state/event/task history,追加用户任务到 `.agent/conversations/agents/.jsonl`,随后在 App 进程内启动 tokio task 执行最小 Agent loop:Agent 按轮输出 `thinkingSummary / plan / actions / response`,Runtime 按白名单和项目权限策略执行工具并记录 `action / observation` 事件,再把已有 observation 放回下一轮 prompt,让 Agent 修正计划、继续行动或用空 actions + response 收束;单 Agent Runtime 每 6 轮形成一个上下文压缩窗口,窗口有新的独立 observation 时压缩上下文并在同一 run 继续,最近 6 轮没有独立进展或相邻窗口重复时以 `failed / budget-exhausted` 和 `loop-budget-exhausted` 终止,不生成总结伪装完成。完成或失败后把 assistant 回复或错误追加回对话,并写入 `.agent/agent.db` 审计记录。工具箱包含只读工具 `memory.read`、`conversation.read`、`asset.list`、`project.index`、`project.diff`、`file.list`、`file.read`、`agent.run_status`,以及受策略保护的写/运行工具 `memory.write`、`file.write`、`command.run_limited`、`blackboard.write`、`agent.message` 和 `agent.delegate`;`memory.write` 可追加或覆盖本 Agent 私有记忆、项目长期/短期记忆或黑板,`file.write` 只能写项目内相对路径,`command.run_limited` 只接受 `game.static_smoke` 并复用本地静态自检安全边界,`blackboard.write` 追加共享黑板,`agent.message` 写目标 Agent 对话,`agent.delegate` 把任务投递到目标 Agent 的独立后台队列;策略拒绝时不执行工具并把 `blocked` observation 回给 Agent;策略要求确认时不执行工具,而是持久化精确待确认动作并暂停该 Agent 队列,待开发者确认或拒绝后在同一 run 续跑。每个 Agent 的任务历史落在 `.agent/runtime/tasks/.jsonl`,读 runtime 时按 `runId` 去重返回最近任务,任务视角状态使用 `pending / running / completed / failed`,Runtime state 增加 `nextStep`,UI 在 Runtime 面板和主 Agent 状态卡展示当前任务、动作、下一步与最近任务。不同 Agent 使用独立 `.agent/runtime/locks/.lock`,允许并行运行;同一 Agent 已有运行任务时,新任务会先进入该 Agent 的 pending 队列,当前 drain 持锁完成后串行继续下一条 pending。该能力仍不是独立 OS 进程或跨重启离线常驻 worker。 - 2026-07-10 补充:后台 Runtime 每次追加 `.agent/runtime/events/.jsonl` 后会通过 Tauri `game-creator-agent-runtime-update` 事件广播当前 `AgentRuntimeResult`;开发单 Agent 聊天页、项目内 Agent 对话弹窗和主窗口 Agent 状态列表都只把该事件作为实时 UI 通知并复用前端 runtime 归一化合并,事实源仍是 `.agent/runtime/agents`、`events` 和 `tasks` 文件。 - 2026-07-11 补充:开发单 Agent 聊天页保留整页纵向滚动,聊天消息区固定响应式高度并在内部滚动;Runtime 恢复确认区使用独立布局行,避免与 Runtime 详情或聊天内容重叠。Runtime 面板详情可折叠且折叠时不渲染详情 DOM,但状态标题与任务控制按钮继续保留;等待 LLM 时在消息区持续显示动态状态和进行中提示,连续流式 delta 合并到动画帧更新并跳过重复 Runtime state。OpenAI Chat SSE 会跳过空 `choices` 心跳 / 元数据事件,收集 usage-only 尾包、保留 finish reason 与上游 error message,收到 `[DONE]` 后立即结束;正文与 finish reason 已接收后出现尾包异常时保存已完成正文,不把整轮改写成失败。持久事件订阅失败时显示非致命错误,聊天事件监听不可用或首个文本片段前流式失败时降级普通回复并继续落盘。 - 2026-07-11 补充:为缩小单 Agent 与 Codex CLI 在代码任务上的差距,Runtime 工具箱新增 `project.search` 和 `file.patch`,并扩展 `file.read` 的按行分页。`project.search` 在项目内执行有界字面量检索,默认忽略大小写,返回相对路径、行号和匹配行,跳过 `.agent`、敏感配置、依赖和构建目录;权限继承 `file.read`。`file.read` 接受 `startLine / maxLines`,返回带行号的最多 240 行、8,000 字符上下文,允许 Agent 继续分页而不是只看到文件开头约 900 字符。`file.patch` 只做 `oldText -> newText` 精确替换,必须声明预期匹配数,匹配数不符时不写入;它继承 `file.write` 权限,复用项目写锁和 Runtime 动作账本,并追加不含代码正文的 `agent.runtime.file.patch` 审计记录。三者组成“搜索定位 -> 分段读取 -> 局部修改 -> 再次读取验证”的最小代码工作闭环,不开放任意 shell。 -- 2026-07-11 补充:单 Agent 代码闭环新增开发专用 `project.verify`,用于在修改后执行项目根 `package.json` 已定义的 `check / typecheck / test / lint / build` 之一;当前只支持 npm,不接受自由命令、参数或工作目录。Agent 必须先读取 `package.json`,再把脚本名、完整原始脚本文本 `expectedCommand` 和 1-300 秒超时一起提交;Runtime 在真正执行前重新解析 JSON 并做精确一致性校验,脚本漂移时拒绝执行。该工具使用独立、默认 `confirm` 的 `project.verify` 权限,不再与 `command.run_limited` / `game.static_smoke` 共用授权;确认指纹覆盖脚本正文和超时。执行时不经过 App 自行拼接的 `bash -c`,而由 npm 执行已确认的项目脚本,并附加 `--ignore-scripts` 阻止 `pre/post` 生命周期旁路;继承环境被清理到 PATH 与必要平台变量,HOME/TMP/npm cache 隔离,stdin 关闭,输出保留有界头尾。Unix 下验证根进程正常结束或超时都会清理同进程组残留后代;项目写锁会按持有 PID 回收崩溃遗留锁,并拒绝 `.agent` 符号链接逃逸。进入进程执行后的成功、非零退出、启动失败和超时会写 `.agent/logs/command.log`、manifest command run 与 `agent.runtime.project.verify` 审计;输入预检拒绝则只进入 Runtime observation / error 事件。输出先过滤敏感内容再进入 observation。只要最新 `project.verify` 未通过,或通过后又发生 `file.write / file.patch / project.restore`,Runtime 就拒绝模型用空 actions 假完成,继续要求修复和重新验证;耗尽 loop 仍未通过时保持失败。该能力会执行用户项目自身脚本,环境隔离不等同于 OS 沙箱,不能把不可信项目脚本视为安全代码;它不是自由 shell 代理,也不进入普通用户命令入口。 +- 2026-07-11 补充,2026-07-12 更新:单 Agent 代码闭环新增开发专用 `project.verify`,用于在修改后执行项目根 `package.json` 已定义的固定脚本 `check / typecheck / test / lint / build`,或以 `check: / test: / lint: / typecheck: / build: / verify: / validate:` 开头、后缀由安全非空段组成的命名脚本;当前只支持 npm,不接受自由命令、参数或工作目录。Agent 必须先读取普通文件 `package.json`,再把真实存在的脚本名、完整原始脚本文本 `expectedCommand` 和 1-300 秒超时一起提交;Runtime 在真正执行前重新解析 JSON,要求脚本仍存在且正文精确一致,脚本漂移时拒绝执行。非 npm `packageManager` 或 pnpm / yarn / bun 锁文件必须失败关闭;`pre* / post*` 生命周期脚本名不在允许范围,npm 执行再附加 `--ignore-scripts`,阻止所选脚本关联的 pre/post lifecycle。该工具使用独立、默认 `confirm` 的 `project.verify` 权限,不再与 `command.run_limited` / `game.static_smoke` 共用授权;确认指纹覆盖脚本正文和超时。执行时不经过 App 自行拼接的 `bash -c`;继承环境被清理到 PATH 与必要平台变量,HOME/TMP/npm cache 隔离,stdin 关闭,输出保留有界头尾。Unix 下验证根进程正常结束或超时都会清理同进程组残留后代;项目写锁会按持有 PID 回收崩溃遗留锁,并拒绝 `.agent` 符号链接逃逸。进入进程执行后的成功、非零退出、启动失败和超时会写 `.agent/logs/command.log`、manifest command run 与 `agent.runtime.project.verify` 审计;输入预检拒绝则只进入 Runtime observation / error 事件。输出先过滤敏感内容再进入 observation。只要最新 `project.verify` 未通过,或通过后又发生 `file.write / file.patch / project.restore`,Runtime 就拒绝模型用空 actions 假完成,继续要求修复和重新验证;多窗口重复无进展而以 `loop-budget-exhausted` 终止时,仍未形成新通过结果则保持失败。该能力会执行用户项目自身脚本,环境隔离不等同于 OS 沙箱,不能把不可信项目脚本视为安全代码;它不是自由 shell 代理,也不进入普通用户命令入口。 - 2026-07-11 补充:开发侧新增 headless 单 Agent Runtime 入口 `npm run ai-game-creator-shell:agent-task -- [--init] `。该入口不实现第二套 Agent,只复用 Tauri App 的持久任务队列、per-agent 锁、LLM 路由、权限策略、工具 action / observation loop、对话和审计文件,并轮询到 `completed / failed / waiting-for-confirmation` 后用稳定键值行退出;`--init` 只在显式传入且 manifest 不存在时初始化项目。遇到待确认动作时 CLI 返回非零并打印 actionId、tool 和脱敏摘要,后续仍由开发窗口完成确认,不提供静默 `--yes` 绕过。 - 2026-07-11 补充:后台 Agent 的工具规划和最终回复请求对 `LlmError::EmptyResponse` 最多自动重试 3 次(含首次共 4 次请求),与既有 Generator 对上游 HTTP 成功但空 content 的恢复策略一致;对 `Timeout / Connectivity / Transport` 及上游 `408 / 429 / 5xx` 最多额外自动重试 2 次并做线性退避,配置、请求、流能力、反序列化错误及其他 `4xx` 仍立即失败。重试发生在工具计划被解析和执行前,或最终回复尚未落盘时,不会重复执行已经落盘的工具动作。 -- 2026-07-11 调整:后台单 Agent 的 planning loop 上限从 3 轮提升到 6 轮,每轮工具动作上限仍为 3;真实代码任务已证明“搜索定位、分段读取、等待写入确认、写后复读”可能在第 3 轮才进入待确认状态,原上限会让确认后的同 run 没有继续验证余量。`maxLoopIterations` 随新上限写入 Runtime,跨重启待确认动作按已完成轮次继续使用剩余轮次;6 轮后 actions 仍未收束时继续进入 `failed / budget-exhausted`,不会伪装完成。该调整只作用于后台单 Agent Runtime,不改变游戏草案 Generator/Evaluator 的 3 轮上限;本文件和旧实施摘要中“后台 3 轮后整理最终回复”的历史描述由本条取代。 +- 2026-07-11 调整,2026-07-12 更新:后台单 Agent 的 planning loop 每 6 轮形成一个上下文压缩窗口,每轮工具动作上限仍为 3;6 轮不再是整个 run 的固定上限。`loopIteration` 在同一 run 内连续递增,`maxLoopIterations` 指向当前窗口结束轮次,跨重启待确认动作按 context bundle 的 `nextLoopIndex` 继续。每个窗口结束时压缩已有 observation;窗口产生新的独立观察时继续同一 run,最近 6 轮没有独立进展或相邻窗口指纹重复时才进入 `failed / budget-exhausted`,并记录 `loop-budget-exhausted`,不会伪装完成。该调整只作用于后台单 Agent Runtime,不改变游戏草案 Generator/Evaluator 的 3 轮上限;旧实施摘要中“后台 3 轮后整理最终回复”或“整个 run 最多 6 轮”的描述由本条取代。 +- 2026-07-12 补充:后台 Agent 每个 run 的可恢复 planning 上下文使用 `.agent/runtime/context-bundles//.json`。Runtime 通过临时文件替换原子写入,绑定 Agent、Task、Session、Run 和任务正文,保存 `nextLoopIndex`、当前窗口、计划、fallback response、压缩后的 observation 与上一窗口指纹;单文件最多 64 KiB、最多 12 条 observation。写入前统一截断并过滤敏感内容和项目绝对路径,安全校验失败时拒绝落盘;读取时要求普通文件,并校验 schema、Agent、Session、Run、任务正文和 observation 数量,身份不一致时拒绝续跑。该路径属于 Runtime 私有控制面,与根级 `.agent/context.bundle.json` 的旧 run-control 辅助文件不是同一契约,通用文件工具不得暴露。 - 2026-07-11 调整:开发者投递的后台任务从队列记录、Runtime `currentTask/currentGoal` 到待确认动作私有账本统一保留最多 4,000 字符,不再在入队时截成 180 字符。180 字符只用于 UI、事件和审计预览;LLM planning、失败重试、确认续跑和重启恢复必须使用完整任务字段,避免位于长需求末尾的验收条件、禁止项或输出格式在真正执行前丢失。 - 2026-07-11 调整:后台结构化 planning 使用独立的 4,000 输出 token 上限,最终回复使用 2,400;两者显式请求 low reasoning effort 和 low text verbosity。`platform-llm` 会把 reasoning effort 同时映射到 OpenAI Responses 的 `reasoning.effort` 与 Chat Completions 的 `reasoning_effort`,未设置时不新增字段。真实 gpt-5.5 Chat 响应曾连续消耗约 1,000-1,400 completion tokens 却不返回 message content,低推理强度、较大的可见输出余量和 EmptyResponse 重试共同构成恢复策略。 - 2026-07-11 补充:后台单 Agent 的工具计划响应只接受可反序列化为计划 schema 的 JSON object。解析器提取模型输出中的首个完整对象并允许对象后带普通说明;未找到完整 JSON 对象,或提取对象无法反序列化为工具计划时,Runtime 最多追加 2 次自动格式修复请求。每次修复只携带限长、脱敏后的上一次无效输出,并写入 `agent.runtime.tool_plan.repair` 审计。两次修复后仍无有效对象则按工具规划失败处理;工具规划阶段的普通文本不得转换为默认的空 actions + response,也不得据此把任务标记为完成。 @@ -4089,7 +4091,7 @@ - 2026-07-10 调整:Agent Runtime 新增 `agent.schedule_ready` 调度入口,默认 `confirm` 权限。命令会扫描 `.agent/manifest.json` 中依赖已完成且仍为 `pending` 的 ready task,先把任务标成 `running`,再用 taskId 作为 Agent id 投递到既有后台队列,source 记为 `agent-ready-task-scheduler`,并写 `agent.runtime.ready_task.scheduled` 审计;后续执行仍走原 per-agent 锁、任务 JSONL、LLM loop、工具策略和事件流,不新增独立 worker。默认确认策略下该命令不会静默调度。 - 2026-07-10 调整:Agent Runtime state 新增 `recentToolCalls`,后台 loop 每次执行白名单工具后记录最近 20 条结构化动作,包含 tool、status、actionFingerprint、inputSummary、reason、summary、detail 和 updatedAt。状态面板展示最近动作与安全目标摘要时使用该字段,不解析 observation 文本;写入前继续过滤敏感上下文,不保存原始密钥、待写正文或任意未过滤输入。 - 2026-07-10 调整:Agent Runtime state 新增 `currentGoal` 和 `waitingOn`。`currentGoal` 固定表达本轮任务目标,`waitingOn` 表达当前等待 LLM、工具观察、开发者输入或失败处理;后台任务生命周期、`agent.run_status` observation、下一轮 planning prompt、开发单 Agent 对话页、项目内 Agent 对话弹窗和主窗口 Agent 状态列表都必须展示同一份目标 / 等待状态。 -- 2026-07-10 调整:Agent Runtime state 新增 `loopIteration / maxLoopIterations / toolActionBudget`。后台 Agent loop 每轮规划前刷新当前轮次、最大轮次和每轮工具动作预算;开发窗口 Runtime 面板、主窗口 Agent 状态列表、`agent.run_status` observation 和下一轮 planning prompt 都展示该进度,字段只做运行观测,不改变 loop 上限或权限 gate。 +- 2026-07-10 调整,2026-07-12 更新:Agent Runtime state 新增 `loopIteration / maxLoopIterations / toolActionBudget`。后台 Agent loop 每轮规划前刷新当前轮次、当前 6 轮上下文压缩窗口的结束轮次和每轮工具动作预算;`maxLoopIterations` 随窗口推进显示 6、12 等结束轮次。开发窗口 Runtime 面板、主窗口 Agent 状态列表、`agent.run_status` observation 和下一轮 planning prompt 都展示该进度;字段只做运行观测,不构成单个 run 的固定轮数上限,也不改变权限 gate。 - 2026-07-10 调整:Agent Runtime state 新增 `planSteps / activePlanStepIndex`。Runtime 从 Agent 输出的 `plan` 派生结构化计划步骤,并在 action / observation / response / error 生命周期中更新 `pending / active / completed / failed` 和 detail;开发窗口 Runtime 面板、主窗口 Agent 状态列表、`agent.run_status` observation 和下一轮 planning prompt 都展示步骤进度,不再只依赖不可定位的 plan 字符串。 - 2026-07-10 调整:开发单 Agent 对话页和项目内 Agent 对话弹窗的 Runtime 面板接入 `recentEvents`,展示最近 `thinking_summary / plan / action / observation / response / error` 事件,避免只从当前状态、observation 字符串或最近工具动作里倒推 Agent loop。 - 2026-07-10 调整:后台 Runtime 每次追加 `.agent/runtime/events/.jsonl` 后会发送 `game-creator-agent-runtime-update` Tauri 事件,payload 带当前 `AgentRuntimeResult`;开发单 Agent 聊天页、项目内 Agent 对话弹窗和主窗口 Agent 状态列表实时合并该结果,但事件不替代 `.agent/runtime/agents`、`events` 和 `tasks` 的落盘事实源。 @@ -4145,11 +4147,12 @@ ## 2026-07-10 AI 游戏创作 Agent Runtime 执行边界 - 决策:开发单 Agent 对话默认使用可执行 Runtime,输入区通过 `执行 / 聊天` 分段控件显式区分;`执行` 调用 `start_game_creator_agent_runtime_task` 并保留工具策略、确认、取消、排队和状态事件,`聊天` 才使用无工具流式回复,不再保留并列的“后台运行”按钮。消息区使用固定响应式网格行和内部滚动,并在 Runtime 非终态期间显示当前等待对象。Runtime 完成前必须先把 assistant 回复写入发起 Session,再写 completed 终态和广播;落盘失败只能进入 failed。前端收到匹配当前项目、Agent、Session 和 runId 的终态后自动重读对话,切换 Session 会清除当前等待投影,旧 run 事件不得覆盖新 Session。 -- 2026-07-12 修正:Runtime 状态为空时也要保留其网格行位,消息区和输入区显式固定到第 5、6 行,禁止空 Runtime 容器通过 `display:none` 让长消息落入 `auto` 行并撑高页面;等待 LLM 期间消息区同步使用 `aria-busy` 暴露忙碌状态。 +- 2026-07-12 修正:Runtime 状态为空时也要保留其网格行位,消息区和输入区显式固定到第 5、6 行,禁止空 Runtime 容器通过 `display:none` 让长消息落入 `auto` 行并撑高页面;等待 LLM 期间消息区同步使用 `aria-busy` 暴露忙碌状态。消息区只在用户仍接近底部时自动跟随最新片段,用户向上查看历史后暂停跟随,切换会话、重新读取或主动发送时再恢复。 +- 2026-07-12 修正:OpenAI-compatible 流式响应的空 `choices` usage / metadata 包不得再报缺少 `choices[0]`。首个 delta 前只有 `StreamUnavailable / EmptyResponse / Deserialize` 协议兼容错误允许由 Rust 单 Agent 流式入口回退一次非流式请求;上游状态、鉴权、额度、超时、连接和请求错误直接保留原错误,前端不得再次发起普通 LLM 请求。已收到正文和完成原因后继续保留完整流式回复,不能被尾部坏包覆盖。 - 决策:后台 Agent 首轮不得预加载任何需要工具权限控制的项目内容。planning 与 final reply 只拿身份、session/run 元数据、任务、工具策略和已获准 observation;记忆、黑板、对话、资产和文件内容必须通过对应工具进入。最新黑板、记忆和对话采用尾部保留截断。 - 决策:同一 Agent 的前台聊天与后台队列共享 per-Agent OS 执行锁,前台 LLM 等待期间不持有项目写锁;同 Agent 后台投递保持 pending,前台结束后把当前锁直接移交给 drain,drain 异常不得反写已经完成的聊天结果,不同 Agent 继续并行。 - 决策:重启恢复继续遵守 `agent.resume` 默认确认策略。自动 command 只允许 auto;默认 confirm 由主工作区或独立开发 Agent 聊天窗口的 UI 明确确认后调用独立 command,确认绑定发起项目,切换项目取消旧确认且旧项目异步结果不得污染新项目状态;独立 command 只忽略 confirm、不允许绕过 deny,临时失败必须允许重试。 -- 决策:后台 Agent loop 只有空 actions 才算收束;三轮预算耗尽仍有动作时写 `failed / budget-exhausted` 和 `loop-budget-exhausted`,不再生成总结后记成 completed。解析阶段保留 action 总数,超过单轮预算时写 `runtime.tool_budget` 并只执行前三个;Runtime 默认工具列表必须直接从可执行白名单派生。 +- 决策:后台 Agent loop 只有空 actions 且不存在 `project.verify` blocker 才算收束;每 6 轮只形成上下文压缩窗口,不是整个 run 的固定预算。窗口产生新的独立 observation 时压缩上下文并继续同一 run;最近 6 轮没有独立进展或相邻窗口重复时写 `failed / budget-exhausted` 和 `loop-budget-exhausted`,不再生成总结后记成 completed。解析阶段保留 action 总数,超过单轮预算时写 `runtime.tool_budget` 并只执行前三个;Runtime 默认工具列表必须直接从可执行白名单派生。 - 决策:`agent.delegate` 子任务必须 durable 保存 `parentAgentId / parentRunId / delegationId`,其中 `delegationId` 从已持久化工具动作的 `actionId` 派生,不能使用执行时随机值;终态任务记录必须保存经过统一凭据清洗和安全截断的 `terminalDetail`,不能依赖可能被后续 run 覆盖的 Agent 全局 state。子任务进入 `completed / failed / cancelled / budget-exhausted` 任一终态后,Runtime 必须在 delegation 级 OS 文件锁内按固定 receipt runId 幂等生成且至多生成一次 `agent.delegate.result` 回执;不同委派并发写同一目标 Agent 时,runId 分配与 pending 追加还必须在目标 Agent 任务账本 OS 锁内原子完成。失败、排队或活跃取消、预算耗尽与成功同等需要回执,`needs-reconciliation` 只有最终取消后才回执。父 Agent 通过既有队列接收 `source=agent-delegate-receipt` 的续跑任务,回执 prompt 禁止重复同一委派,并携带完整的已清洗 `terminalDetail`,不能只保留 UI 摘要;排队期间不提前写入父会话,真正执行时才幂等落盘,用户消息或回执消息落盘失败时不得进入 LLM。回执任务必须保留父 run 关联,真正开始或恢复前再次核验父 run,关联缺失或父 run 不存在时失败关闭;父 run 已取消或普通失败时只保留 suppressed receipt 审计,不自动复活。父 Session 存在未结束委派时禁止切换或归档,极端竞态下回执回落到父 Agent 当前可写 Session。续跑继续遵守同 Agent FIFO、per-Agent OS 锁、权限确认、取消、恢复和 `needs-reconciliation` 屏障,不允许直接重入、插队或重复投递;恢复必须先恢复 pending action / reconciliation 屏障,再补齐“子终态已落盘、回执未入队”的崩溃窗口。 - 决策:Agent loop 的语义事件类型固定为 `thinking_summary / plan / action / observation / response / error`。普通失败和预算耗尽必须先追加统一 `error` 事件,同时保留 `turn.failed / turn.budget_exhausted` 生命周期事件供旧读取方兼容;状态、phase 和清洗后的错误详情必须在两类事件中一致。Runtime 状态面板默认展示最新 4 条事件,但在当前后端最近事件窗口大于 4 条时必须允许展开全部返回记录,不能让 `plan`、早期 observation 或 thinking summary 永久不可见。 - 验证:Rust 覆盖首轮上下文不泄露、工具后 observation 可见、六类语义事件、确认前后内容边界、前后台同 Agent 串行、前台结束后队列 drain、恢复确认 gate、预算耗尽失败、默认工具白名单一致性,以及 delegate 成功 / 失败 / 取消 / 预算耗尽终态回执、`delegationId` 幂等去重和父 Agent receipt 续跑仍受 FIFO / 锁 / 确认 / 恢复门禁;前端覆盖统一事件展示、主工作区和独立开发 Agent 聊天窗口的默认恢复确认条与显式恢复 command。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 1537fab7d..afd18010c 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -18,6 +18,8 @@ Agent Runtime 负责: +- 2026-07-12 安全边界补充:`project.verify` 的 script 最多 160 个字符,固定使用系统 script shell,并在解析和执行前拒绝项目级 `.npmrc` 改写 npm 语义。Runtime context bundle 绑定 `projectId / agentId / taskId / sessionId / runId / source / task`,结尾换行计入 64 KiB 上限;恢复时同时校验 `nextLoopIndex`、context window、当前窗口已完成轮数、观察指纹、计划和 observation 数量。bundle 通过项目内无符号链接路径原子写入,并从同一文件句柄最多读取 64 KiB;项目路径和常见 `sk- / GitHub / npm / AWS / JWT` 凭据统一脱敏。工具 observation 进入 context checkpoint 后才删除 `observed-*` 动作账本,避免重启时旧 ledger 抢占有效 bundle。 + - 总任务拆分和任务图状态流转。 - 6 个专业组调度:策划组、美术组、程序组、数值组、音乐组、运营组。 - 工具注册、权限 gate、工具调用预算和执行日志。 @@ -32,7 +34,7 @@ Agent Runtime 负责: - 开发窗口能力:debug 构建额外打开 `developer` 窗口,走 `index.html?agent-chat`;开发者可选择 Agent、授权本地项目路径,并通过 `read_local_conversation` / `append_local_conversation_message` 读写 `.agent/conversations/agents/.jsonl`,通过 `agentLlm.` 调用该 Agent 的独立 LLM 路由做真实对话,用于单独调试某个 Agent 的长期对话上下文。这里的 `` 以 manifest taskId 为规范值,旧 `group-role` 别名只作为兼容输入映射到 taskId。 - 命令能力:内置命令调用、权限 gate、执行日志;v1 只允许白名单受限命令,不执行任意 shell。 - 编排能力:任务拆分、任务图依赖、专业组调度、多智能体协作;Runtime V1 会为单 Agent 对话和生成 loop 中的角色 brief 写入独立 runtime state / event,先解决“每个 Agent 正在做什么、跑到哪一步、最近一次 task/run 是什么”的可观测性。 -- 后台任务能力:开发窗口单 Agent 聊天和项目内 Agent 对话弹窗可把当前输入投递为单 Agent 后台任务,Tauri 命令 `start_game_creator_agent_runtime_task` 会立即写入该 Agent 的 `.agent/runtime/agents/.json`、`.agent/runtime/events/.jsonl`、`.agent/runtime/tasks/.jsonl` 和 `.agent/conversations/agents/.jsonl`,随后在 App 进程内启动 tokio task 执行最小 Agent loop:每轮让该 Agent 输出 `thinkingSummary / plan / actions / response`,Runtime 按白名单和项目权限策略执行工具动作,写入 `thinking_summary / plan / action / observation / response / error` 事件,再把 observation 放入下一轮 prompt 让 Agent 修正计划、继续行动或用空 actions + response 收束;后台任务最多执行 3 轮 loop,仍未收束时再按最后计划和全部观察生成最终回复并追加回对话。工具箱包含只读工具 `memory.read`、`conversation.read`、`asset.list`、`project.index`、`project.diff`、`file.list`、`file.read`、`task.list`、`agent.run_status`,以及受策略保护的写/运行工具 `memory.write`、`project.checkpoint`、`project.restore`、`file.write`、`task.create`、`task.update`、`command.run_limited`、`blackboard.write`、`agent.message` 和 `agent.delegate`;`memory.write` 可追加或覆盖本 Agent 私有记忆、项目长期/短期记忆或黑板,`project.checkpoint` 可在写入或批量修改前创建本地 checkpoint,`project.restore` 可在确认后把项目恢复到指定 checkpoint,`file.write` 只能写项目内相对路径并记录审计,`task.create` 只能追加经过校验的新 manifest 任务,`task.update` 只能更新已有 manifest 任务状态,`command.run_limited` 只接受 `game.static_smoke` 并复用本地静态自检安全边界,`blackboard.write` 追加 `memory/blackboard.md`,`agent.message` 给目标 `.agent/conversations/agents/.jsonl` 写入 tool 留言,`agent.delegate` 把任务投递到目标 Agent 的独立后台队列,策略拒绝时不执行工具并返回 `blocked` observation;策略要求确认时不执行工具,而是持久化精确待确认动作并暂停该 Agent 队列,待开发者确认或拒绝后在同一 run 续跑。`read_game_creator_agent_runtime` 会按 `runId` 去重返回最近任务和最近事件,`read_game_creator_agent_runtimes` 批量读取所有规范 taskId 的 runtime;开发窗口和项目内 Agent 对话弹窗的 Runtime 状态面板展示最近事件、最近任务、当前目标、当前任务、当前动作、等待对象、下一步和运行阶段,主窗口 Agent 状态列表展示当前目标、任务、动作、等待对象和运行阶段摘要。不同 Agent 使用各自 runtime 锁,可以并行运行;同一 Agent 已有运行任务时,新任务会先写成 `pending / queued`,由当前后台 drain 在完成后串行继续执行。该能力仍属于 Runtime V1 的进程内任务,不是独立 OS 进程或跨重启离线常驻 worker。 +- 后台任务能力:开发窗口单 Agent 聊天和项目内 Agent 对话弹窗可把当前输入投递为单 Agent 后台任务,Tauri 命令 `start_game_creator_agent_runtime_task` 会立即写入该 Agent 的 `.agent/runtime/agents/.json`、`.agent/runtime/events/.jsonl`、`.agent/runtime/tasks/.jsonl` 和 `.agent/conversations/agents/.jsonl`,随后在 App 进程内启动 tokio task 执行最小 Agent loop:每轮让该 Agent 输出 `thinkingSummary / plan / actions / response`,Runtime 按白名单和项目权限策略执行工具动作,写入 `thinking_summary / plan / action / observation / response / error` 事件,再把 observation 放入下一轮 prompt 让 Agent 修正计划、继续行动或用空 actions + response 收束;单 Agent Runtime 每 6 轮形成一个上下文压缩窗口,而不是把 6 轮作为整个 run 的固定上限。窗口产生新的独立 observation 时压缩上下文并在同一 run 继续;最近 6 轮没有独立进展或相邻窗口重复时以 `failed / budget-exhausted` 和 `loop-budget-exhausted` 终止,不再生成总结伪装完成。工具箱包含只读工具 `memory.read`、`conversation.read`、`asset.list`、`project.index`、`project.diff`、`file.list`、`file.read`、`task.list`、`agent.run_status`,以及受策略保护的写/运行工具 `memory.write`、`project.checkpoint`、`project.restore`、`file.write`、`task.create`、`task.update`、`command.run_limited`、`blackboard.write`、`agent.message` 和 `agent.delegate`;`memory.write` 可追加或覆盖本 Agent 私有记忆、项目长期/短期记忆或黑板,`project.checkpoint` 可在写入或批量修改前创建本地 checkpoint,`project.restore` 可在确认后把项目恢复到指定 checkpoint,`file.write` 只能写项目内相对路径并记录审计,`task.create` 只能追加经过校验的新 manifest 任务,`task.update` 只能更新已有 manifest 任务状态,`command.run_limited` 只接受 `game.static_smoke` 并复用本地静态自检安全边界,`blackboard.write` 追加 `memory/blackboard.md`,`agent.message` 给目标 `.agent/conversations/agents/.jsonl` 写入 tool 留言,`agent.delegate` 把任务投递到目标 Agent 的独立后台队列,策略拒绝时不执行工具并返回 `blocked` observation;策略要求确认时不执行工具,而是持久化精确待确认动作并暂停该 Agent 队列,待开发者确认或拒绝后在同一 run 续跑。`read_game_creator_agent_runtime` 会按 `runId` 去重返回最近任务和最近事件,`read_game_creator_agent_runtimes` 批量读取所有规范 taskId 的 runtime;开发窗口和项目内 Agent 对话弹窗的 Runtime 状态面板展示最近事件、最近任务、当前目标、当前任务、当前动作、等待对象、下一步和运行阶段,主窗口 Agent 状态列表展示当前目标、任务、动作、等待对象和运行阶段摘要。不同 Agent 使用各自 runtime 锁,可以并行运行;同一 Agent 已有运行任务时,新任务会先写成 `pending / queued`,由当前后台 drain 在完成后串行继续执行。该能力仍属于 Runtime V1 的进程内任务,不是独立 OS 进程或跨重启离线常驻 worker。 - 2026-07-10 补充:Agent Runtime state 新增 `toolPolicy`,按当前项目 `.agent/policy.json` 派生工具级 `allowedTools / autoTools / confirmTools / deniedTools` 快照;后台 planning prompt 会带入该快照,让 Agent 在规划时知道哪些工具会自动执行、需要确认或被拒绝。`blackboard.write` 继承 `memory.write` 策略,`agent.message` 继承 `conversation.write` 策略,`agent.delegate` 使用独立 `agent.delegate` 策略;实际执行仍以 Runtime 的白名单和项目权限 gate 为准。 - 2026-07-10 补充:`.agent/policy.json` 新增 `agentPolicies`,可按规范 Agent id 分别配置 `deniedCommands / confirmCommands`。有效策略为“项目级策略 + Agent 级策略”的保守叠加:项目级拒绝 / 确认仍对所有 Agent 生效,Agent 级策略只能进一步限制该 Agent,不能放宽项目级策略,拒绝优先于确认。主聊天新增 `/agent-policy-deny Agent 命令`、`/agent-policy-allow Agent 命令`、`/agent-policy-confirm Agent 命令` 和 `/agent-policy-auto Agent 命令`,写入前仍走 `project.policy_write` 确认卡。 - 2026-07-10 补充:后台 Agent 工具命中 `confirmCommands` 时不再继续整理最终回复,而是把本轮 Runtime 停在 `status/phase = waiting-for-confirmation`,`waitingOn` 指向“开发者确认 Agent 工具动作”,`recentToolCalls`、事件流、任务记录和 `taskQueue.waitingForConfirmation` 都保留该待确认事实;同一 Agent 的 drain 会暂停,不继续消费后续 pending 任务。命中拒绝策略仍作为 `blocked` observation 交回 Agent 继续修正计划。 @@ -47,7 +49,7 @@ Agent Runtime 负责: - 2026-07-10 补充:后台任务工具箱已加入 `task.create`。Agent 可在 loop 中把拆解出的后续工作追加为 manifest 任务;Runtime 复用 `task.create` 策略和项目写锁,写入前校验 taskId 唯一、依赖指向已有任务、任务分组合法、标题 / 角色非空以及列表长度,并写入 `agent.runtime.task.create` 审计记录。策略要求确认或拒绝时不会修改 manifest。 - 2026-07-10 补充:Agent Runtime state 新增 `recentToolCalls`,每次后台工具执行后记录最近 20 条结构化工具动作,包含 tool、status、actionFingerprint、inputSummary、reason、summary、detail 和 updatedAt;开发窗口、项目内 Agent 对话弹窗和主窗口 Agent 状态列表可直接展示“最近动作”和安全目标摘要,不再只能从 observation 字符串里猜测 action / observation 对应关系。`inputSummary` 只保留相对路径、checkpoint id、目标 Agent、内容字符数等确认所需信息,不保存原始 API Key、待写正文、消息正文、素材 prompt 或任意未过滤输入。 - 2026-07-10 补充:Agent Runtime state 新增 `currentGoal` 和 `waitingOn`,把本轮目标与当前等待对象从 `currentTask / currentAction / nextStep` 中显式拆出来;后台任务启动、工具 observation、完成和失败都会刷新该状态,开发窗口、项目内 Agent 对话弹窗、主窗口 Agent 状态列表、`agent.run_status` observation 和下一轮 planning prompt 都展示同一份目标 / 等待信息,避免开发者只能从动作文本里猜 Agent 卡在 LLM、工具、同伴还是人工输入。 -- 2026-07-10 补充:Agent Runtime state 新增 `loopIteration / maxLoopIterations / toolActionBudget`,结构化记录后台 Agent loop 当前轮次、最大轮次和每轮工具动作预算;开发窗口 Runtime 面板、主窗口 Agent 状态列表、`agent.run_status` observation 和下一轮 planning prompt 都展示该进度,帮助判断 Agent 是刚开始规划、正在 replan,还是接近本轮 loop 上限。该字段只做运行观测,不改变后台 loop 的执行上限或工具权限。 +- 2026-07-10 补充,2026-07-12 更新:Agent Runtime state 新增 `loopIteration / maxLoopIterations / toolActionBudget`,结构化记录后台 Agent loop 当前轮次、当前 6 轮上下文压缩窗口的结束轮次和每轮工具动作预算;开发窗口 Runtime 面板、主窗口 Agent 状态列表、`agent.run_status` observation 和下一轮 planning prompt 都展示该进度,帮助判断 Agent 是刚开始规划、正在 replan,还是接近当前窗口边界。`maxLoopIterations` 会随窗口推进显示 6、12 等结束轮次,只做运行观测,不构成单个 run 的固定轮数上限,也不改变工具权限。 - 2026-07-10 补充:Agent Runtime state 新增 `planSteps / activePlanStepIndex`,从 Agent 输出的 `plan` 派生结构化计划步骤,并在工具 action / observation / response / error 生命周期中更新 `pending / active / completed / failed` 和 detail;开发窗口 Runtime 面板、主窗口 Agent 状态列表、`agent.run_status` observation 和下一轮 planning prompt 都展示当前计划步骤与步骤进度,避免只能展示一串不可定位的 plan 文本。 - 2026-07-10 补充:`recentEvents` 接入前端归一态和 Runtime 状态面板,事件事实源仍是 `.agent/runtime/events/.jsonl`;面板按时间展示最近 `thinking_summary / plan / action / observation / response / error` 事件,现在能同时看到 Agent 的计划、最近观察、最近事件、最近工具动作和任务队列。 - 2026-07-10 补充:后台 Runtime 每次追加 `.agent/runtime/events/.jsonl` 后会通过 Tauri `game-creator-agent-runtime-update` 事件广播当前 `AgentRuntimeResult`,开发单 Agent 聊天页、项目内 Agent 对话弹窗和主窗口 Agent 状态卡用同一套前端归一化逻辑合并状态;该事件只做实时 UI 通知,`.agent/runtime/agents`、`events` 和 `tasks` 仍是重开项目后的事实源。 @@ -55,10 +57,11 @@ Agent Runtime 负责: - 2026-07-11 补充:开发单 Agent 聊天页继续使用整页纵向滚动,不把 Runtime 锁进固定视口;聊天消息区使用固定响应式高度并在内部滚动,避免历史消息持续撑高聊天面板。可选的 Runtime 恢复确认区始终占据独立布局行,不能与 Runtime 详情或聊天消息重叠。Runtime 状态面板支持折叠详情,折叠时只卸载目标、计划、事件、动作和任务等详情 DOM,仍保留状态标题与取消、重试、确认、拒绝、刷新操作;等待 LLM 时在消息区持续显示连接 / 等待首包 / 接收中的动态状态和“请求仍在进行中”提示。流式聊天的连续 delta 通过 `requestAnimationFrame` 合并为每帧最多一次消息更新,delta 不重复提交未变化的 Runtime state;OpenAI Chat SSE 的空 `choices` 心跳 / 元数据事件会跳过,usage-only 尾包会回填最终 token usage,finish-only 事件会把结束原因送入状态流,上游 error 保留真实消息,`[DONE]` 立即结束读取;正文与 finish reason 已接收后即使尾包异常也保存完整正文,不再改判整轮失败。持久事件订阅失败时显示非致命 Runtime 错误,聊天事件监听不可用或流式请求在首个文本片段前失败时自动降级普通回复。 - 2026-07-12 调整:开发单 Agent 对话框新增 `执行 / 聊天` 分段模式,默认 `执行`。默认发送直接调用 `start_game_creator_agent_runtime_task`,复用工具规划、权限确认、取消、队列和 Runtime 实时状态;`聊天` 作为显式模式继续走不执行工具的流式回复。消息区在 Runtime 启动、排队、等待 LLM、执行工具、等待确认和同步终态回复期间持续显示当前状态,不再要求开发者从页头文案猜测请求是否仍在运行;原独立“后台运行”按钮移除。Runtime 必须先把最终 assistant 回复可靠写入当前 Agent Session,再写 completed 终态并广播事件;对话写入失败时本轮进入 failed,不得产生 completed 记录。前端只对当前项目、Agent、Session 和 runId 匹配的终态事件自动重读对话,直到看到新 assistant 消息或重试结束,切换 Session 后旧 run 不得污染当前聊天记录。 - 2026-07-11 补充:后台单 Agent 新增 Codex 风格的代码导航与局部编辑闭环。`project.search` 接受 `query / path / maxResults / caseSensitive`,在项目边界内做字面量搜索并返回 `path:line`,最多扫描 500 个、单个不超过 512 KiB 的文本文件,跳过 `.agent`、`.git`、`node_modules`、`dist`、`build`、`target`、`.next`、`coverage` 和 `.env*`;该工具映射到 `file.read` 权限。`file.read` 接受 `startLine / maxLines`,返回带行号的指定片段、总行数和下一页提示,单次最多 240 行、8,000 字符。`file.patch` 接受 `path / oldText / newText / expectedReplacements`,只在实际匹配数与预期一致时持锁写入,目标文件和修改后文件最大 2 MiB,成功后写 `agent.runtime.file.patch` 审计;该工具映射到 `file.write` 权限。Agent planning prompt 明确要求批量修改前创建 checkpoint,并可在修改后再次 `file.read` 验证;本轮不开放任意 shell 命令。 -- 2026-07-11 补充:代码修改后的真实验证由开发专用 `project.verify` 承接。输入固定为 `script / expectedCommand / timeoutSeconds`,其中 script 只允许项目根 `package.json` 中的 `check / typecheck / test / lint / build`,expectedCommand 必须与执行时重新读取的脚本正文完全一致,timeoutSeconds 为 1-300;当前执行器只支持 npm,其他 packageManager 或锁文件明确失败。工具映射到独立且默认需确认的 `project.verify` 权限,确认动作指纹绑定完整输入,不再因为放行验证而同时放行 `command.run_limited` 静态 smoke。执行器由 npm 运行已确认脚本,使用 `--ignore-scripts`、空 stdin、隔离 HOME/TMP/cache、清理后的环境、独立进程组和有界脱敏输出;Unix 下无论根进程正常结束还是超时都会清理同组残留后代。项目写锁记录 PID 和唯一 nonce,活进程继续持锁,Unix 死进程锁或跨平台超过安全时限的无效锁可回收,且控制路径拒绝符号链接。进入进程执行后的终态写命令日志和 manifest command run,Agent 触发时另写 `agent.runtime.project.verify`;输入预检拒绝只写 Runtime observation / error 事件。失败输出作为 observation 回到下一轮 planning。最新验证失败,或验证通过后又执行 `file.write / file.patch / project.restore` 时,空 actions 不再代表完成,Runtime 会注入 `runtime.verification: blocked` 并继续 replan;loop 耗尽仍未形成新通过结果时保持失败。该能力会执行用户项目脚本,环境隔离不是 OS 沙箱;普通用户 `/smoke` 与 `game.static_smoke` 保持原边界,不暴露该开发工具。 +- 2026-07-11 补充,2026-07-12 更新:代码修改后的真实验证由开发专用 `project.verify` 承接。输入固定为 `script / expectedCommand / timeoutSeconds`;`script` 允许项目根 `package.json` 中的固定脚本 `check / typecheck / test / lint / build`,以及以 `check: / test: / lint: / typecheck: / build: / verify: / validate:` 开头、后缀由安全非空段组成的命名脚本。脚本必须真实存在于项目根普通文件 `package.json` 的 `scripts` 中,`expectedCommand` 必须与执行时重新读取的脚本正文完全一致,`timeoutSeconds` 为 1-300;当前执行器只支持 npm,非 npm `packageManager` 或 pnpm / yarn / bun 锁文件明确失败,不接受自由命令、参数或工作目录。`pre* / post*` 生命周期脚本名不在允许范围,执行器再通过 npm `--ignore-scripts` 禁止所选脚本关联的 pre/post lifecycle。工具映射到独立且默认需确认的 `project.verify` 权限,确认动作指纹绑定完整输入,不再因为放行验证而同时放行 `command.run_limited` 静态 smoke。执行器由 npm 运行已确认脚本,使用空 stdin、隔离 HOME/TMP/cache、清理后的环境、独立进程组和有界脱敏输出;Unix 下无论根进程正常结束还是超时都会清理同组残留后代。项目写锁记录 PID 和唯一 nonce,活进程继续持锁,Unix 死进程锁或跨平台超过安全时限的无效锁可回收,且控制路径拒绝符号链接。进入进程执行后的终态写命令日志和 manifest command run,Agent 触发时另写 `agent.runtime.project.verify`;输入预检拒绝只写 Runtime observation / error 事件。失败输出作为 observation 回到下一轮 planning。最新验证失败,或验证通过后又执行 `file.write / file.patch / project.restore` 时,空 actions 不再代表完成,Runtime 会注入 `runtime.verification: blocked` 并继续 replan;多窗口重复无进展而以 `loop-budget-exhausted` 终止时,仍未形成新通过结果则保持失败。该能力会执行用户项目脚本,环境隔离不是 OS 沙箱;普通用户 `/smoke` 与 `game.static_smoke` 保持原边界,不暴露该开发工具。 - 2026-07-11 补充:开发验证可用 `npm run ai-game-creator-shell:agent-task -- [--init] ` 无 UI 启动单 Agent 后台任务。CLI 只负责可选初始化、调用现有 Runtime、按 runId 轮询终态并打印 `status / phase / replyText / pendingActionId`,不复制 planning 或工具执行逻辑;默认 10 分钟轮询上限。`waiting-for-confirmation` 会以非零状态退出并要求转到开发窗口确认,CLI 不提供跳过项目权限的自动确认参数。该入口用于真实 provider 的可重复端到端验收,不进入普通用户界面。 - 2026-07-11 补充:后台工具规划与最终回复的 LLM 请求新增可恢复错误重试:`LlmError::EmptyResponse` 原样自动重试最多 3 次;`Timeout / Connectivity / Transport` 及上游 `408 / 429 / 5xx` 最多额外重试 2 次并按 `500ms / 1000ms` 退避。配置、请求、流能力、反序列化错误及其他 `4xx` 不重试。重试发生在工具计划被解析和执行前,或最终回复尚未落盘时,因此不会重复执行已经落盘的工具副作用;重试耗尽后仍写入原有 `error / turn.failed` 事件并把失败消息追加到当前 Agent 会话。 -- 2026-07-11 调整:后台单 Agent planning loop 上限提升为 6 轮、每轮最多 3 个工具动作,`loopIteration / maxLoopIterations / toolActionBudget` 继续向 UI 和 `agent.run_status` 暴露真实进度。待确认动作在第 N 轮暂停时,确认或重启恢复后从下一轮继续,最多使用剩余的 `6-N` 轮完成修改后复读和自检;6 轮仍返回非空 actions 时终态保持 `failed / budget-exhausted`。这只调整后台单 Agent Runtime;游戏草案 Generator/Evaluator 仍保持独立的 3 轮修复预算。旧摘要中“后台最多 3 轮、耗尽后生成最终回复”的描述不再有效,以本条和 budget-exhausted 决策为准。 +- 2026-07-11 调整,2026-07-12 更新:后台单 Agent planning loop 每 6 轮形成一个上下文压缩窗口,每轮最多 3 个工具动作;6 轮是窗口大小,不是单个 run 的固定上限。`loopIteration` 在同一 run 内连续递增,`maxLoopIterations` 指向当前窗口的结束轮次;待确认或重启恢复后按 context bundle 的 `nextLoopIndex` 在同一 run 继续。每个窗口结束时压缩已有 observation;窗口产生新的独立观察时继续下一窗口,最近 6 轮没有独立进展或相邻窗口指纹重复时才写入 `failed / budget-exhausted` 和 `loop-budget-exhausted`,不生成总结伪装完成。这只调整后台单 Agent Runtime;游戏草案 Generator/Evaluator 仍保持独立的 3 轮修复预算。旧摘要中“后台最多 3 轮”或“整个 run 最多 6 轮”的描述不再有效。 +- 2026-07-12 补充:后台 Agent 每个 run 的可恢复 planning 上下文通过临时文件替换原子写入 `.agent/runtime/context-bundles//.json`,绑定 Agent、Task、Session、Run 和任务正文,保存 `nextLoopIndex`、当前窗口、计划、fallback response、压缩后的 observation 与上一窗口指纹。单文件最多 64 KiB、最多 12 条 observation;写入前统一截断并过滤敏感内容和项目绝对路径,安全校验失败时拒绝落盘。读取时要求普通文件并校验 schema、Agent、Session、Run、任务正文和 observation 数量,身份不一致时拒绝续跑。该文件属于 Runtime 私有控制面,不等同于根级 `.agent/context.bundle.json`,不得由通用文件工具暴露。 - 2026-07-11 调整:后台任务的可执行正文上限统一为 4,000 字符。入队 JSONL、启动后的 `currentTask/currentGoal`、planning prompt、待确认动作 task context、确认续跑和重启恢复都保留同一份正文;对话仍保存用户原始消息。状态事件、列表卡片和 `agent.db` 摘要可继续使用较短安全预览,但不能再反向作为后续 LLM 执行输入。这样长任务末尾的验收标记和输出格式要求不会在队列边界被 180 字符截断。 - 2026-07-11 调整:后台 planning 不再复用普通聊天的 1,800 输出 token 上限,而是使用 4,000;最终回复使用 2,400。两类请求均设置 low reasoning effort / low text verbosity;OpenAI Responses 序列化为 `reasoning.effort=low`,OpenAI Chat Completions 序列化为可选 `reasoning_effort=low`。该设置用于避免推理模型把全部 completion 预算消耗在不可见 reasoning 后留下空 content,并继续叠加最多 3 次 EmptyResponse 重试。 - 2026-07-11 补充:后台单 Agent 的工具 planning 响应必须提供可反序列化为 `thinkingSummary / plan / actions / response` schema 的 JSON object。Runtime 从模型输出中解析首个完整对象,因此对象后的尾随说明可以忽略;只有普通文本、没有完整对象,或对象无法反序列化时都不构成有效工具计划。对于这两类无效输出,Runtime 最多追加 2 次自动格式修复请求,每次只把限长且经过统一敏感信息过滤的上一次输出作为修复上下文,并把修复尝试写入 `.agent/agent.db` 的 `agent.runtime.tool_plan.repair` 审计。修复预算耗尽后进入既有工具规划失败路径,不得把普通文本折算为空 actions + response,也不得因此进入 completed;最终回复阶段仍按其独立的普通文本契约处理。 @@ -84,7 +87,7 @@ Agent Runtime 负责: - 2026-07-10 补充:后台 planning 与预算内 final reply 使用专用最小上下文,只预置 Agent 身份、sessionId、runId、执行模式和工具策略;Agent 私有记忆、项目记忆、黑板、对话、资产、项目索引与文件正文只能经对应工具通过权限 gate 后作为 observation 进入下一轮。普通前台聊天仍可使用角色上下文。长黑板、记忆和对话按尾部截断,确保最新结论与最新定向消息优先保留。 - 2026-07-10 补充:同一 Agent 的前台直接聊天、流式聊天和后台任务统一使用 `.agent/runtime/locks/.lock` OS 文件锁。前台聊天不再在整个 LLM 请求期间占用项目级写锁;同 Agent 后台任务在前台运行时只入队,前台成功或失败后把当前 Agent 锁直接移交给 drain,不重新抢锁,也不允许 drain 启动异常把已经完成的聊天结果改判为失败。不同 Agent 继续并行,真实项目写工具只在副作用执行期间短暂申请项目写锁。 - 2026-07-10 补充:默认 `agent.resume=confirm` 时,客户端自动恢复命令只做 auto gate 并返回待确认错误;主工作区和独立开发 Agent 聊天窗口在首次读取项目 Runtime 时都必须显示 `agent.resume` 确认条,确认对象绑定发起时的项目路径,切换项目会取消旧确认,异步返回后也不得把旧项目 Runtime 合并到新项目 UI。开发者确认后调用独立 `confirm_resume_game_creator_agent_runtime_tasks`,该命令仍执行 deny-only 权限检查后才接回 durable queue。临时调用失败不锁死项目路径,允许后续刷新重试;明确 deny 或取消都不恢复任务。 -- 2026-07-10 补充:后台 Agent 只有返回空 `actions` 才视为本轮 loop 已收束。三轮后仍请求工具时终态为 `status=failed / phase=budget-exhausted`,error 使用 `loop-budget-exhausted` 机器可读前缀,不再调用 final reply 后写 completed 审计;解析阶段保留过滤后的 action 总数,每轮超过 3 个 action 时写入 `runtime.tool_budget` observation 并只执行前三个,要求下一轮重新排序。Runtime 默认 `allowedTools` 直接由实际可执行工具白名单派生,避免 UI 观测与执行边界漂移。 +- 2026-07-10 补充,2026-07-12 更新:后台 Agent 只有返回空 `actions` 且不存在 `project.verify` blocker 才视为 loop 已收束。每 6 轮只是上下文压缩窗口;窗口产生新的独立 observation 时压缩上下文并继续同一 run,最近 6 轮没有独立进展或相邻窗口重复时终态才写为 `status=failed / phase=budget-exhausted`,error 使用 `loop-budget-exhausted` 机器可读前缀,不再调用 final reply 后写 completed 审计。解析阶段保留过滤后的 action 总数,每轮超过 3 个 action 时写入 `runtime.tool_budget` observation 并只执行前三个,要求下一轮重新排序。Runtime 默认 `allowedTools` 直接由实际可执行工具白名单派生,避免 UI 观测与执行边界漂移。 - 任务图能力:每轮 Orchestrator agenda、ready / active task 选择、Evaluator 结构化返工路由、返工轮 carry-over。 - 记忆能力:短期记忆 `memory/session.md`、长期记忆 `memory/project.md`、项目级黑板 `memory/blackboard.md` 和角色私有记忆 `memory/agents//.md`;黑板用于共享重要跨 agent 记忆,角色私有记忆只给对应角色 brief 读取和追加。最近 project / agent conversation 会作为短期 prompt 上下文读取,不替代正式 memory 文件。 - 对话能力:结构化对话记录统一落在 `.agent/conversations/` 的 append-only JSONL;普通聊天写 `.agent/conversations/project.jsonl`,进入单个 agent 后只写对应 `.agent/conversations/agents/.jsonl`,不把原始对话混进项目黑板或角色私有记忆。 @@ -126,6 +129,9 @@ game-project/ .jsonl tasks/ .jsonl + context-bundles/ + / + .json locks/ .lock activity.jsonl @@ -306,7 +312,7 @@ game-project/ - Tauri Rust 入口保持薄壳:`src-tauri/src/main.rs` 只保留共享类型 / 常量、模块声明、CLI preflight、`tauri::Builder`、运行时配置初始化和 `invoke_handler` 清单;命令行入口放在 `cli.rs`,Tauri command 包装放在 `commands.rs`,运行时配置与 LLM 配置检查放在 `config.rs`,Agent loop 与生成编排放在 `agent.rs`,上传 / 画板 / 平台美术生成接入放在 `assets.rs`,本地项目文件、记忆、对话、权限、checkpoint、manifest 和通用路径工具放在 `project.rs`,本地 HTTP 预览与 preview 命令放在 `preview.rs`,旧窗口兼容命令放在 `windows.rs`,Rust 单测放在 `tests.rs`。后续继续拆分时保持 Tauri command 名、JSON 字段、`.agent/*` 路径和错误语义不变。 - 本地项目初始化会创建 `game/`、`assets/`、`memory/`、`memory/agents/`、`exports/`、`.agent/logs/`,写入 `.agent/manifest.json`,生成 append-only JSONL 本地项目索引 `.agent/agent.db`,并生成默认 `game/index.html`。 - v1 conversation 记录使用 append-only JSONL,每行带 `schemaVersion`、`role`、`content`、`agentId` 和 `updatedAt`,作为聊天历史和单 agent 对话历史的事实源;目录在首次写入时创建。 -- 开发窗口和项目内 Agent 对话弹窗的“后台运行”只启动或排队单 Agent 后台任务,不阻塞等待回复;用户可刷新同一 Agent 对话或 runtime 状态查看进度和结果,也可对当前 run 执行取消 / 重试,待确认 run 还可执行“确认继续”或“拒绝并继续”。后台任务会向 `.agent/runtime/tasks/.jsonl` 追加任务视角记录,任务状态使用 `pending / running / waiting-for-confirmation / cancelled / completed / failed`,读取时按 `runId` 去重返回最近任务;`runId` 在同一 Agent 内是单个 run 的身份,后台入队会自动把重复 runId 改写为唯一实际 runId,防止不同任务互相覆盖;runtime state 自身仍可在完成后显示 `idle / completed`,二者语义分开。同一 Agent 的 pending 任务由持有 `.agent/runtime/locks/.lock` 的后台 drain 串行执行,避免同一 Agent 并发抢上下文;不同 Agent 仍可并行;若某个工具动作命中确认策略,该 Agent 会停在 `waiting-for-confirmation` 并暂停继续消费队列,等待后续确认或策略调整;Runtime 会把完整 `AgentRuntimePendingToolAction` 经过敏感内容和项目绝对路径校验后原子写入 `.agent/runtime/pending-actions//.json`,公共 `pendingToolAction` 只公开安全摘要;确认或拒绝必须匹配 `runId + actionId` 并通过工具名与完整输入 JSON 的 SHA-256 校验。确认在同一 run 直接执行原 action 并把 observation 接回后续 loop;拒绝不执行工具,而是写入 `blocked` observation 后在同一 run 继续规划。待确认状态可跨 App 重启读取并回收上一进程锁;等待期间同 Agent 新任务保持 pending,确认/拒绝续跑结束后由同一 drain 串行排空;若用户取消 pending 任务,drain 不再消费该 run,若取消 running 任务,则在当前 LLM 或工具调用返回后的检查点停止,不继续执行工具或保存最终 assistant 回复。客户端重开项目时会对当前项目路径自动尝试一次 Runtime 恢复;恢复命令必须通过 `agent.resume` 自动权限,默认确认策略下不会静默启动;同一 Agent 同时存在上一进程遗留 `running` 和 `pending` 时,先重接 `running`,再由 drain 继续 `pending`。开发构建和后台 Agent 工具箱都可通过 `agent.schedule_ready` 权限确认入口把 manifest ready task 投递进同一后台队列,命令会先把 ready task 标成 `running`,再用 taskId 作为 Agent id 入队,source 为 `agent-ready-task-scheduler`;该入口不新增独立 worker。后台任务的核心 loop 最多 6 轮:每轮把已有 observation 带回 LLM 让 Agent 重新规划;只有合法工具计划返回空 actions,且不存在未通过或项目修改后未重跑的 `project.verify` 时才收束;response 为空时进入独立最终回复生成,6 轮仍未收束则以 `budget-exhausted` 失败。后台任务完成后会把 assistant 回复追加到对应 `.agent/conversations/agents/.jsonl`,并向 `.agent/agent.db` 写入 `agent.runtime.background_task.queued` / `agent.runtime.background_task` / `agent.runtime.background_task.recovered` / `agent.runtime.ready_task.scheduled` / `agent.runtime.tool_observation` / `agent.runtime.tool_confirmation_required` / `agent.runtime.tool_confirmation.approved` / `agent.runtime.tool_confirmation.rejected` / `agent.runtime.memory.write` / `agent.runtime.project.verify` / `agent.runtime.file.write` / `agent.runtime.file.patch` / `agent.runtime.tool_plan.repair` / `agent.runtime.task.create` / `agent.runtime.task.update` / `agent.runtime.command.run_limited` / `agent.runtime.blackboard.write` / `agent.runtime.agent.message` / `agent.runtime.agent.delegate` / `agent.runtime.background_task.cancelled` / `agent.runtime.background_task.retry` / `agent.runtime.background_task.completed` / `agent.runtime.background_task.failed` 审计记录。当前工具箱开放只读工具 `memory.read`、`conversation.read`、`asset.list`、`project.index`、`project.search`、`project.diff`、`file.list`、`file.read`、`task.list`、`agent.run_status`,以及受策略保护的写/运行工具 `memory.write`、`project.checkpoint`、`project.restore`、`project.verify`、`file.write`、`file.patch`、`task.create`、`task.update`、`command.run_limited`、`preview.start`、`canvas.asset_generate`、`blackboard.write`、`agent.message`、`agent.delegate` 和 `agent.schedule_ready`;`memory.write scope=agent` 只允许写当前 Agent 自己的私有记忆,跨 Agent 共享必须改用 `blackboard.write` 或 `agent.message`;`project.checkpoint` 只创建本地 checkpoint,不返回本机绝对路径;`project.restore` 只按 checkpoint id 恢复当前项目,不返回本机绝对路径,默认确认策略下不会静默回滚;`task.create` 只追加新 manifest 任务,`task.update` 只更新已有任务状态;`agent.schedule_ready` 只调度 manifest ready task,不创建平行 runtime;若项目策略拒绝,对应工具不会执行,Runtime 会把策略结果作为 observation 回给 Agent 修正计划;若项目策略要求确认,Runtime 会持久化精确待确认动作并保留 waiting 状态,不执行该工具;只有确认入口通过 `runId + actionId + SHA-256` 校验后才直接执行原 action,拒绝入口则生成 `blocked` observation。`toolPolicy` 保存当前工具级权限快照,供 planning prompt 和状态面板展示;`recentToolCalls` 保存最近 20 条结构化工具动作及安全 `inputSummary`,供状态面板展示最近动作和确认目标;append-only JSONL 写入按目标文件路径在当前进程内串行追加完整行,覆盖 `.agent/agent.db`、对话、Runtime events/tasks、activity 和 output,减少多个 Agent 同时完成时的行交错风险。 +- 开发窗口和项目内 Agent 对话弹窗的“后台运行”只启动或排队单 Agent 后台任务,不阻塞等待回复;用户可刷新同一 Agent 对话或 runtime 状态查看进度和结果,也可对当前 run 执行取消 / 重试,待确认 run 还可执行“确认继续”或“拒绝并继续”。后台任务会向 `.agent/runtime/tasks/.jsonl` 追加任务视角记录,任务状态使用 `pending / running / waiting-for-confirmation / cancelled / completed / failed`,读取时按 `runId` 去重返回最近任务;`runId` 在同一 Agent 内是单个 run 的身份,后台入队会自动把重复 runId 改写为唯一实际 runId,防止不同任务互相覆盖;runtime state 自身仍可在完成后显示 `idle / completed`,二者语义分开。同一 Agent 的 pending 任务由持有 `.agent/runtime/locks/.lock` 的后台 drain 串行执行,避免同一 Agent 并发抢上下文;不同 Agent 仍可并行;若某个工具动作命中确认策略,该 Agent 会停在 `waiting-for-confirmation` 并暂停继续消费队列,等待后续确认或策略调整;Runtime 会把完整 `AgentRuntimePendingToolAction` 经过敏感内容和项目绝对路径校验后原子写入 `.agent/runtime/pending-actions//.json`,公共 `pendingToolAction` 只公开安全摘要;确认或拒绝必须匹配 `runId + actionId` 并通过工具名与完整输入 JSON 的 SHA-256 校验。确认在同一 run 直接执行原 action 并把 observation 接回后续 loop;拒绝不执行工具,而是写入 `blocked` observation 后在同一 run 继续规划。待确认状态可跨 App 重启读取并回收上一进程锁;等待期间同 Agent 新任务保持 pending,确认/拒绝续跑结束后由同一 drain 串行排空;若用户取消 pending 任务,drain 不再消费该 run,若取消 running 任务,则在当前 LLM 或工具调用返回后的检查点停止,不继续执行工具或保存最终 assistant 回复。客户端重开项目时会对当前项目路径自动尝试一次 Runtime 恢复;恢复命令必须通过 `agent.resume` 自动权限,默认确认策略下不会静默启动;同一 Agent 同时存在上一进程遗留 `running` 和 `pending` 时,先重接 `running`,再由 drain 继续 `pending`。开发构建和后台 Agent 工具箱都可通过 `agent.schedule_ready` 权限确认入口把 manifest ready task 投递进同一后台队列,命令会先把 ready task 标成 `running`,再用 taskId 作为 Agent id 入队,source 为 `agent-ready-task-scheduler`;该入口不新增独立 worker。后台任务的核心 loop 每 6 轮形成一个上下文压缩窗口:每轮把已有 observation 带回 LLM 让 Agent 重新规划;只有合法工具计划返回空 actions,且不存在未通过或项目修改后未重跑的 `project.verify` 时才收束,response 为空时进入独立最终回复生成。窗口边界会压缩 observation;有新的独立观察时在同一 run 继续下一窗口,最近窗口重复无进展时才以 `budget-exhausted / loop-budget-exhausted` 失败。后台任务完成后会把 assistant 回复追加到对应 `.agent/conversations/agents/.jsonl`,并向 `.agent/agent.db` 写入 `agent.runtime.background_task.queued` / `agent.runtime.background_task` / `agent.runtime.background_task.recovered` / `agent.runtime.ready_task.scheduled` / `agent.runtime.tool_observation` / `agent.runtime.tool_confirmation_required` / `agent.runtime.tool_confirmation.approved` / `agent.runtime.tool_confirmation.rejected` / `agent.runtime.memory.write` / `agent.runtime.project.verify` / `agent.runtime.file.write` / `agent.runtime.file.patch` / `agent.runtime.tool_plan.repair` / `agent.runtime.task.create` / `agent.runtime.task.update` / `agent.runtime.command.run_limited` / `agent.runtime.blackboard.write` / `agent.runtime.agent.message` / `agent.runtime.agent.delegate` / `agent.runtime.background_task.cancelled` / `agent.runtime.background_task.retry` / `agent.runtime.background_task.completed` / `agent.runtime.background_task.failed` 审计记录。当前工具箱开放只读工具 `memory.read`、`conversation.read`、`asset.list`、`project.index`、`project.search`、`project.diff`、`file.list`、`file.read`、`task.list`、`agent.run_status`,以及受策略保护的写/运行工具 `memory.write`、`project.checkpoint`、`project.restore`、`project.verify`、`file.write`、`file.patch`、`task.create`、`task.update`、`command.run_limited`、`preview.start`、`canvas.asset_generate`、`blackboard.write`、`agent.message`、`agent.delegate` 和 `agent.schedule_ready`;`memory.write scope=agent` 只允许写当前 Agent 自己的私有记忆,跨 Agent 共享必须改用 `blackboard.write` 或 `agent.message`;`project.checkpoint` 只创建本地 checkpoint,不返回本机绝对路径;`project.restore` 只按 checkpoint id 恢复当前项目,不返回本机绝对路径,默认确认策略下不会静默回滚;`task.create` 只追加新 manifest 任务,`task.update` 只更新已有任务状态;`agent.schedule_ready` 只调度 manifest ready task,不创建平行 runtime;若项目策略拒绝,对应工具不会执行,Runtime 会把策略结果作为 observation 回给 Agent 修正计划;若项目策略要求确认,Runtime 会持久化精确待确认动作并保留 waiting 状态,不执行该工具;只有确认入口通过 `runId + actionId + SHA-256` 校验后才直接执行原 action,拒绝入口则生成 `blocked` observation。`toolPolicy` 保存当前工具级权限快照,供 planning prompt 和状态面板展示;`recentToolCalls` 保存最近 20 条结构化工具动作及安全 `inputSummary`,供状态面板展示最近动作和确认目标;append-only JSONL 写入按目标文件路径在当前进程内串行追加完整行,覆盖 `.agent/agent.db`、对话、Runtime events/tasks、activity 和 output,减少多个 Agent 同时完成时的行交错风险。 - 2026-07-10 补充:当前工具箱还开放 `preview.start`,审计记录类型为 `agent.runtime.preview.start`;该工具不会打开任意 URL,只启动当前授权项目的 `127.0.0.1` 本地预览,并和 Tauri 用户命令共用同一个 `PreviewRegistry`。 - 2026-07-10 补充:当前工具箱还开放 `canvas.asset_generate`,审计记录类型为 `agent.runtime.canvas.asset_generate`;该工具只通过配置好的 External Editor API 生成并回流素材,不暴露任意上传 / 任意网络请求能力。 - 2026-07-10 补充:当前工具箱还开放 `task.list`;该工具只读取 manifest 任务图、状态、依赖、产物和 `readyTaskIds`,并受 `task.list` 项目权限策略保护。 @@ -341,7 +347,7 @@ game-project/ - 通过 Evaluator 和 `game.static_smoke` 后,Agent loop 会把本次 runId、状态、轮次、下一步、active / carry-over 任务和最终本地产物摘要追加到 `memory/session.md` 与 `memory/project.md`,把重要跨 agent 决策 / 依赖 / 风险摘要追加到 `memory/blackboard.md`,并把各角色本轮成功产出的角色摘要追加到 `memory/agents//.md`;下一次 Planner、组内角色和 Generator 会通过记忆输入自然读取上一轮稳定原型状态,而不只依赖开发窗口 trace。 - 单 agent 对话入口读取对应 agent conversation;用户提交后先追加用户消息,再调用 `chat_with_game_creator_role_agent` / `chat_with_game_creator_role_agent_stream` 让对应 `agentLlm.` 结合项目上下文、Agent 私有记忆和本 Agent 历史对话生成回复,随后把回复写入对应 `.agent/conversations/agents/.jsonl`。这里的 `` 以任务 `taskId` 为规范值,Tauri 只兼容旧 `group-role` 别名并映射到 taskId。每轮对话会同步写 `.agent/runtime/agents/.json` 和 `.agent/runtime/events/.jsonl`,字段包含 `agentId`、`taskId`、`sessionId`、`runId`、`source`、`status`、`phase`、`currentTask`、`currentGoal`、`currentAction`、`waitingOn`、`nextStep`、`loopIteration`、`maxLoopIterations`、`toolActionBudget`、`plan`、`planSteps`、`activePlanStepIndex`、`observations`、`recentToolCalls`、`toolPolicy`、`allowedTools`、`lastResponse` 和 `error`;流式事件会把最新 `runtimeState` 回传给界面。Runtime state 写入使用临时文件替换,event JSONL 读取会跳过坏行;`currentTask`、`currentGoal`、event detail、`lastResponse` 和 `agent.db` 摘要复用敏感上下文过滤,不保存明显 API Key / Bearer / Cookie 片段。单 agent 面板可把当前输入手动追加到对应 `memory/agents//.md`,写入前复用 `memory.write` 项目策略和本地项目锁;最近对话可作为本次生成 prompt 上下文读取,但只有经过显式总结、用户显式手动沉淀或生成 loop 成功沉淀的稳定结论,才追加到 `memory/blackboard.md` 或 `memory/agents//.md`。 - 2026-07-10 补充:每个 Agent 支持多个持久化 Session。旧 `agent-session-` 永久映射原 `.agent/conversations/agents/.jsonl`,不迁移、不复制、不删除;新 Session 写入 `.agent/conversations/agents//sessions/.jsonl`,目录索引和 active Session 写入 `.agent/runtime/sessions/.json`。开发单 Agent 聊天页提供创建、切换、归档和已归档只读查看;归档只更新元数据,不截断对话。直接聊天、流式回调和后台任务在启动时捕获 `agentId + sessionId`,Runtime task / event 继续使用每 Agent append-only JSONL,但列表、任务队列、连续上下文和最近对话按 Session 过滤;Runtime 内的 `conversation.read` 和 self `agent.run_status` 通过 runId 继续使用该 Session,恢复或处理待确认动作前校验 task、runtime state 和 pending action 的 Session 一致性。同一 Agent 仍共享一把 OS 锁并严格串行,不允许借 Session 绕过 pending、确认或 `needs-reconciliation` 屏障;不同 Agent 仍可并行。 -- 2026-07-12 补充:开发单 Agent 聊天页的 Runtime 容器即使为空也必须保留网格行位,消息区和输入区固定落在第 5、6 行;长历史只增加消息区 `scrollHeight`,不得改变主面板高度。保存用户消息、连接 LLM、等待首个片段和流式接收期间,消息区持续显示等待状态并标记 `aria-busy=true`。 +- 2026-07-12 补充:开发单 Agent 聊天页的 Runtime 容器即使为空也必须保留网格行位,消息区和输入区固定落在第 5、6 行;长历史只增加消息区 `scrollHeight`,不得改变主面板高度。消息区仅在滚动位置接近底部时自动跟随最新回复,用户主动向上查看历史后,状态变化和流式片段不得强行拉回底部;切换会话、重新读取历史或主动发送新任务时恢复跟随。保存用户消息、连接 LLM、等待首个片段和流式接收期间,消息区持续显示等待状态并标记 `aria-busy=true`。OpenAI-compatible 流式响应中的空 `choices` usage / metadata 尾包必须正常消费;首个片段前只有 `StreamUnavailable / EmptyResponse / Deserialize` 协议兼容错误可由 Rust 层回退一次非流式请求,鉴权、额度、上游状态、超时和连接错误不得由前端再次请求。 - 生成 loop 中的角色 brief 也写同一套 Agent Runtime state / event:active 角色用 `source=generate-draft` 和当前 `runId` 标记正在读取上下文、调用角色专属 LLM 或本地编排、生成 brief、完成或失败;carry-over 角色同样写入开始 / 完成事件,但不会伪装成重新调用 LLM。主窗口 Agent 状态列表、开发单 Agent 聊天页和项目内单 Agent 对话弹窗只读展示当前 Agent 的 runtime 状态、最近 task/run、阶段、当前目标、动作、等待对象、下一步、计划、观测和最近工具动作;这只是 V1 可观测性,不代表已经有独立后台常驻进程或可中断任意上游 LLM 请求。 - `.agent/agent.db` 当前作为最小本地项目索引文件使用 JSONL:初始化写入 `project.init`,每次 `game.generate_draft` 追加目标、标题、本地产物路径、checkpoint 和 diff 摘要,上传 / 登记 / 画板导入资产时追加 `asset.register` 或 `asset.update`;v1 不引入 SQLite 依赖。 - `game.generate_draft`、资产登记 / 导入、记忆写入、预览状态写入、checkpoint / restore、agent 生命周期控制、画板资源回流 / 生成和 policy 写入会先按 `.agent/policy.json` 判断本次命令是否被项目策略拒绝,再拿项目级 `.agent/project.lock` 串行化;锁只保护同一本地项目,v1 不做后台锁管理。`confirmCommands` 可把索引、状态读取、资产登记、checkpoint、预览、agent 生命周期、画板资源回流 / 生成、memory 读写删除和 conversation 读写等命令转成项目策略确认,命中时用户确认后才执行;用户可用 `/policy-confirm 命令` 加入确认列表,用 `/policy-auto 命令` 移除确认项。