补齐Agent结构化计划进度

新增 Agent Runtime 结构化计划步骤与当前步骤索引。

在工具、观察、回复和失败生命周期中更新计划步骤状态。

在开发面板、主窗口状态卡和聊天摘要展示当前计划步骤。

补充后台 loop 成功与最终回复失败的计划步骤测试。

同步 AI 游戏创作 App 实施计划和项目决策记录。
This commit is contained in:
AIGameCreator App
2026-07-10 04:07:06 +08:00
parent 20c760bf45
commit b98f34a727
7 changed files with 542 additions and 11 deletions
@@ -492,7 +492,7 @@ async fn run_game_creator_agent_background_task(
);
}
if !plan.plan.is_empty() {
runtime.plan = plan.plan.clone();
update_agent_runtime_plan_steps(&mut runtime, plan.plan.clone());
runtime.updated_at = unix_timestamp();
let _ = write_game_creator_agent_runtime_state(&root, &runtime);
let _ = append_game_creator_agent_runtime_event(
@@ -508,16 +508,28 @@ async fn run_game_creator_agent_background_task(
if plan.actions.is_empty() {
if !plan.response.trim().is_empty() {
activate_agent_runtime_response_plan_step(
&mut runtime,
"Agent 已直接给出最终回复。",
);
runtime.updated_at = unix_timestamp();
let _ = write_game_creator_agent_runtime_state(&root, &runtime);
final_reply = Some(plan.response.clone());
}
break;
}
for action in plan
for (action_index, action) in plan
.actions
.iter()
.take(AGENT_RUNTIME_BACKGROUND_TOOL_ACTION_LIMIT)
.enumerate()
{
activate_agent_runtime_plan_step(
&mut runtime,
action_index,
action.reason.as_deref().unwrap_or(action.tool.as_str()),
);
runtime = match advance_game_creator_agent_runtime_turn_at(
&root,
runtime,
@@ -547,6 +559,15 @@ async fn run_game_creator_agent_background_task(
let observation_summary = observation.summary();
runtime.observations.push(observation_summary.clone());
append_agent_runtime_tool_call_record(&mut runtime, action, &observation);
complete_agent_runtime_active_plan_step(
&mut runtime,
if observation.status == "ok" {
"completed"
} else {
"failed"
},
&observation_summary,
);
runtime.waiting_on = "Agent 根据工具观察修正计划".to_string();
runtime.next_step = "把工具观察交给 Agent 修正计划".to_string();
runtime.updated_at = unix_timestamp();
@@ -579,6 +600,10 @@ async fn run_game_creator_agent_background_task(
let final_reply = if let Some(reply) = final_reply {
reply
} else {
activate_agent_runtime_response_plan_step(
&mut runtime,
"Agent 已完成工具观察,正在整理最终回复。",
);
runtime = match advance_game_creator_agent_runtime_turn_at(
&root,
runtime,
@@ -702,6 +727,7 @@ async fn run_game_creator_agent_background_task(
const AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT: usize = 3;
const AGENT_RUNTIME_BACKGROUND_TOOL_ACTION_LIMIT: usize = 3;
const AGENT_RUNTIME_RECENT_TOOL_CALL_LIMIT: usize = 20;
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;
pub(crate) const AGENT_RUNTIME_LOCK_STALE_AFTER_SECONDS: u64 = 300;
@@ -775,6 +801,158 @@ fn append_agent_runtime_tool_call_record(
}
}
fn update_agent_runtime_plan_steps(runtime: &mut AgentRuntimeState, plan: Vec<String>) {
runtime.plan = plan
.into_iter()
.filter(|item| !item.trim().is_empty())
.take(AGENT_RUNTIME_PLAN_STEP_LIMIT)
.map(|item| sanitize_agent_runtime_text(&item, 180))
.collect();
runtime.plan_steps = runtime
.plan
.iter()
.enumerate()
.map(|(index, title)| AgentRuntimePlanStep {
index: index as u32,
title: title.clone(),
status: if index == 0 { "active" } else { "pending" }.to_string(),
detail: None,
updated_at: unix_timestamp(),
})
.collect();
runtime.active_plan_step_index = if runtime.plan_steps.is_empty() {
None
} else {
Some(0)
};
}
fn activate_agent_runtime_plan_step(
runtime: &mut AgentRuntimeState,
step_index: usize,
detail: &str,
) {
if runtime.plan_steps.is_empty() {
return;
}
let target_index = step_index.min(runtime.plan_steps.len().saturating_sub(1));
let now = unix_timestamp();
for step in runtime.plan_steps.iter_mut() {
if step.index as usize == target_index {
step.status = "active".to_string();
step.detail = Some(sanitize_agent_runtime_text(detail, 180));
step.updated_at = now;
} else if step.status == "active" {
step.status = "pending".to_string();
step.updated_at = now;
}
}
runtime.active_plan_step_index = Some(target_index as u32);
}
fn complete_agent_runtime_active_plan_step(
runtime: &mut AgentRuntimeState,
status: &str,
detail: &str,
) {
let Some(active_index) = runtime.active_plan_step_index else {
return;
};
let status = if status == "failed" {
"failed"
} else {
"completed"
};
let now = unix_timestamp();
for step in runtime.plan_steps.iter_mut() {
if step.index == active_index {
step.status = status.to_string();
step.detail = Some(sanitize_agent_runtime_text(detail, 220));
step.updated_at = now;
break;
}
}
runtime.active_plan_step_index = None;
}
fn activate_agent_runtime_response_plan_step(runtime: &mut AgentRuntimeState, detail: &str) {
let target_index = runtime
.plan_steps
.iter()
.find(|step| step.status == "pending" || step.status == "active")
.map(|step| step.index as usize);
if let Some(target_index) = target_index {
activate_agent_runtime_plan_step(runtime, target_index, detail);
return;
}
if runtime.plan_steps.len() >= AGENT_RUNTIME_PLAN_STEP_LIMIT {
return;
}
let index = runtime.plan_steps.len() as u32;
let title = "生成最终回复".to_string();
runtime.plan.push(title.clone());
runtime.plan_steps.push(AgentRuntimePlanStep {
index,
title,
status: "active".to_string(),
detail: Some(sanitize_agent_runtime_text(detail, 180)),
updated_at: unix_timestamp(),
});
runtime.active_plan_step_index = Some(index);
}
fn fail_agent_runtime_remaining_plan_steps(runtime: &mut AgentRuntimeState, detail: &str) {
if runtime.active_plan_step_index.is_some() {
complete_agent_runtime_active_plan_step(runtime, "failed", detail);
return;
}
let now = unix_timestamp();
let detail = sanitize_agent_runtime_text(detail, 220);
let mut marked_failed = false;
for step in runtime.plan_steps.iter_mut() {
if step.status == "pending" || step.status == "active" {
step.status = "failed".to_string();
step.detail = Some(detail.clone());
step.updated_at = now;
marked_failed = true;
}
}
if !marked_failed {
if let Some(step) = runtime
.plan_steps
.iter_mut()
.rev()
.find(|step| step.status != "failed")
{
step.status = "failed".to_string();
step.detail = Some(detail);
step.updated_at = now;
}
}
runtime.active_plan_step_index = None;
}
fn complete_agent_runtime_remaining_plan_steps(runtime: &mut AgentRuntimeState, detail: &str) {
let now = unix_timestamp();
for step in runtime.plan_steps.iter_mut() {
if step.status != "failed" {
step.status = "completed".to_string();
if step
.detail
.as_deref()
.map_or(true, |value| value.trim().is_empty())
{
step.detail = Some(sanitize_agent_runtime_text(detail, 180));
}
step.updated_at = now;
}
}
runtime.active_plan_step_index = None;
}
async fn request_game_creator_agent_background_tool_plan_at(
root: &Path,
agent_id: &str,
@@ -2142,6 +2320,7 @@ fn format_agent_runtime_status_observation(result: &AgentRuntimeResult) -> Strin
.collect::<Vec<_>>()
.join(" / ")
};
let plan_step = format_agent_runtime_active_plan_step_observation(state);
let recent_task = result
.recent_tasks
.last()
@@ -2160,7 +2339,7 @@ fn format_agent_runtime_status_observation(result: &AgentRuntimeResult) -> Strin
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| "-".to_string());
format!(
"agentId: {}\nstatus: {}\nphase: {}\nrunId: {}\n循环轮次: {}/{}\n每轮工具预算: {}\n当前目标: {}\n当前任务: {}\n当前动作: {}\n等待: {}\n下一步: {}\n计划: {}\n任务队列: {}\n最近任务: {}\n最近工具: {}\n错误: {}",
"agentId: {}\nstatus: {}\nphase: {}\nrunId: {}\n循环轮次: {}/{}\n每轮工具预算: {}\n当前目标: {}\n当前任务: {}\n当前动作: {}\n等待: {}\n下一步: {}\n计划: {}\n当前计划步骤: {}\n任务队列: {}\n最近任务: {}\n最近工具: {}\n错误: {}",
state.agent_id,
state.status,
state.phase,
@@ -2174,6 +2353,7 @@ fn format_agent_runtime_status_observation(result: &AgentRuntimeResult) -> Strin
waiting_on,
next_step,
plan,
plan_step,
task_queue,
recent_task,
recent_tool,
@@ -2190,6 +2370,27 @@ fn agent_runtime_status_text(value: &str, max_chars: usize) -> String {
}
}
fn format_agent_runtime_active_plan_step_observation(state: &AgentRuntimeState) -> String {
let step = state
.active_plan_step_index
.and_then(|active_index| {
state
.plan_steps
.iter()
.find(|step| step.index == active_index)
})
.or_else(|| state.plan_steps.iter().find(|step| step.status == "active"));
step.map(|step| {
format!(
"#{} [{}] {}",
step.index + 1,
step.status,
sanitize_agent_runtime_text(&step.title, 120)
)
})
.unwrap_or_else(|| "-".to_string())
}
fn format_agent_runtime_task_queue_observation(queue: &AgentRuntimeTaskQueueSummary) -> String {
format!(
"total={} pending={} running={} completed={} failed={} latest={}",
@@ -2335,7 +2536,7 @@ pub(crate) fn start_game_creator_agent_runtime_task_at(
state.current_action = current_action.trim().to_string();
state.waiting_on = agent_runtime_waiting_on_for_phase(&state.phase).to_string();
state.next_step = "等待 Agent 输出计划或回复".to_string();
state.plan = plan;
update_agent_runtime_plan_steps(&mut state, plan);
state.observations = vec!["已创建本轮 Agent Runtime run。".to_string()];
if let Some(previous_state) = previous_state {
state.recent_tool_calls = previous_state.recent_tool_calls;
@@ -2415,6 +2616,7 @@ pub(crate) fn finish_game_creator_agent_runtime_turn_at(
state.next_step = "等待下一轮输入".to_string();
state.last_response = Some(sanitize_agent_runtime_text(response, 500));
state.error = None;
complete_agent_runtime_remaining_plan_steps(&mut state, "本轮 Agent 已生成最终回复。");
state
.observations
.push("Agent 已完成回复,assistant 消息等待或已经由前端落盘。".to_string());
@@ -2458,6 +2660,7 @@ pub(crate) fn fail_game_creator_agent_runtime_turn_at(
state.waiting_on = "开发者处理失败".to_string();
state.next_step = "等待开发者处理失败".to_string();
state.error = Some(sanitize_agent_runtime_text(error, 500));
fail_agent_runtime_remaining_plan_steps(&mut state, error);
let _ = refresh_game_creator_agent_runtime_tool_policy(root, &mut state);
state.updated_at = unix_timestamp();
append_game_creator_agent_runtime_task(root, &state)?;
@@ -2539,6 +2742,8 @@ fn default_game_creator_agent_runtime_state(agent_id: &str, run_id: &str) -> Age
"按角色职责推理".to_string(),
"回复并记录 runtime 事件".to_string(),
],
plan_steps: Vec::new(),
active_plan_step_index: None,
observations: Vec::new(),
recent_tool_calls: Vec::new(),
task_queue: AgentRuntimeTaskQueueSummary::default(),
@@ -2623,6 +2828,41 @@ fn normalize_game_creator_agent_runtime_state(state: &mut AgentRuntimeState, age
"回复并记录 runtime 事件".to_string(),
];
}
if state.plan_steps.is_empty() && !state.plan.is_empty() {
state.plan_steps = state
.plan
.iter()
.filter(|item| !item.trim().is_empty())
.take(AGENT_RUNTIME_PLAN_STEP_LIMIT)
.enumerate()
.map(|(index, title)| AgentRuntimePlanStep {
index: index as u32,
title: sanitize_agent_runtime_text(title, 180),
status: if state.phase == "completed" {
"completed"
} else if index == 0 && state.phase != "idle" {
"active"
} else {
"pending"
}
.to_string(),
detail: None,
updated_at: state.updated_at,
})
.collect();
}
if state.plan_steps.len() > AGENT_RUNTIME_PLAN_STEP_LIMIT {
state.plan_steps.truncate(AGENT_RUNTIME_PLAN_STEP_LIMIT);
}
if let Some(active_index) = state.active_plan_step_index {
if !state
.plan_steps
.iter()
.any(|step| step.index == active_index)
{
state.active_plan_step_index = None;
}
}
if state.allowed_tools.is_empty() {
state.allowed_tools = default_game_creator_agent_runtime_allowed_tools();
} else {
@@ -3351,6 +3591,28 @@ fn render_agent_runtime_prompt_context(root: &Path, agent_id: &str) -> Result<St
));
}
}
if !state.plan_steps.is_empty() {
lines.push("计划进度:".to_string());
for step in state.plan_steps.iter().take(AGENT_RUNTIME_PLAN_STEP_LIMIT) {
let mut line = format!(
"- #{} [{}] {}",
step.index + 1,
redact_agent_runtime_project_paths(root, &step.status, 80),
redact_agent_runtime_project_paths(root, &step.title, 180)
);
if let Some(detail) = step
.detail
.as_deref()
.filter(|value| !value.trim().is_empty())
{
line.push_str(&format!(
";{}",
redact_agent_runtime_project_paths(root, detail, 180)
));
}
lines.push(line);
}
}
let recent_observations = state
.observations
@@ -170,6 +170,10 @@ struct AgentRuntimeState {
#[serde(default)]
plan: Vec<String>,
#[serde(default)]
plan_steps: Vec<AgentRuntimePlanStep>,
#[serde(default)]
active_plan_step_index: Option<u32>,
#[serde(default)]
observations: Vec<String>,
#[serde(default)]
recent_tool_calls: Vec<AgentRuntimeToolCallRecord>,
@@ -231,6 +235,21 @@ struct AgentRuntimeToolCallRecord {
updated_at: u64,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct AgentRuntimePlanStep {
#[serde(default)]
index: u32,
#[serde(default)]
title: String,
#[serde(default)]
status: String,
#[serde(default)]
detail: Option<String>,
#[serde(default)]
updated_at: u64,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct AgentRuntimeTaskQueueSummary {
@@ -1135,6 +1135,10 @@ async fn role_agent_legacy_alias_maps_to_canonical_task_runtime_and_route() {
assert_eq!(runtime.session_id, "agent-session-art-asset-plan");
assert_eq!(runtime.current_goal, "排队规划美术资产");
assert_eq!(runtime.waiting_on, "Agent 输出计划或回复");
assert_eq!(runtime.active_plan_step_index, Some(0));
assert_eq!(runtime.plan_steps.len(), 1);
assert_eq!(runtime.plan_steps[0].title, "确认旧别名会落到规范 taskId");
assert_eq!(runtime.plan_steps[0].status, "active");
let runtime_wire: Value = serde_json::from_str(
&fs::read_to_string(root.join(".agent/runtime/agents/art-asset-plan.json"))
.expect("runtime wire json"),
@@ -1145,6 +1149,12 @@ async fn role_agent_legacy_alias_maps_to_canonical_task_runtime_and_route() {
assert_eq!(runtime_wire["loopIteration"], 0);
assert_eq!(runtime_wire["maxLoopIterations"], 3);
assert_eq!(runtime_wire["toolActionBudget"], 3);
assert_eq!(runtime_wire["activePlanStepIndex"], 0);
assert_eq!(
runtime_wire["planSteps"][0]["title"],
"确认旧别名会落到规范 taskId"
);
assert_eq!(runtime_wire["planSteps"][0]["status"], "active");
assert_eq!(runtime_wire["taskQueue"]["total"], 1);
assert_eq!(runtime_wire["taskQueue"]["running"], 1);
assert_eq!(
@@ -1159,6 +1169,7 @@ async fn role_agent_legacy_alias_maps_to_canonical_task_runtime_and_route() {
assert_eq!(alias_read.state.loop_iteration, 0);
assert_eq!(alias_read.state.max_loop_iterations, 3);
assert_eq!(alias_read.state.tool_action_budget, 3);
assert_eq!(alias_read.state.active_plan_step_index, Some(0));
assert_eq!(alias_read.task_queue.total, 1);
assert_eq!(alias_read.task_queue.running, 1);
assert!(alias_read
@@ -1654,6 +1665,27 @@ async fn background_agent_runtime_task_executes_plan_tool_observation_loop() {
"回复开发者".to_string(),
]
);
assert_eq!(runtime.active_plan_step_index, None);
assert_eq!(runtime.plan_steps.len(), 3);
assert!(runtime
.plan_steps
.iter()
.all(|step| step.status == "completed"));
assert_eq!(runtime.plan_steps[0].title, "读取项目笔记");
assert!(runtime.plan_steps[0]
.detail
.as_deref()
.is_some_and(|detail| detail.contains("file.read:ok")));
assert_eq!(runtime.plan_steps[1].title, "结合黑板判断下一步");
assert!(runtime.plan_steps[1]
.detail
.as_deref()
.is_some_and(|detail| detail.contains("memory.read:ok")));
assert_eq!(runtime.plan_steps[2].title, "回复开发者");
assert!(runtime.plan_steps[2]
.detail
.as_deref()
.is_some_and(|detail| detail.contains("最终回复")));
assert!(runtime
.observations
.iter()
@@ -1719,6 +1751,66 @@ async fn background_agent_runtime_task_executes_plan_tool_observation_loop() {
fs::remove_dir_all(root).ok();
}
#[tokio::test]
async fn background_agent_runtime_marks_response_plan_step_failed_when_final_reply_fails() {
let root = unique_project_path();
init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init");
let plan_json = serde_json::json!({
"thinkingSummary": "已有上下文足够,准备回复开发者",
"plan": ["回复开发者"],
"actions": [],
"response": ""
})
.to_string();
let base_url = spawn_mock_llm_server_responses(vec![plan_json]);
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",
"后台分析最终回复失败路径",
"design-response-fail-run",
)
.expect("start background task");
let mut runtime = read_game_creator_agent_runtime_at(&root, "design-director")
.expect("read runtime")
.state;
for _ in 0..50 {
if runtime.status == "failed" {
break;
}
std::thread::sleep(Duration::from_millis(20));
runtime = read_game_creator_agent_runtime_at(&root, "design-director")
.expect("read runtime")
.state;
}
assert_eq!(runtime.status, "failed");
assert_eq!(runtime.phase, "failed");
assert_eq!(runtime.active_plan_step_index, None);
assert_eq!(runtime.plan_steps.len(), 1);
assert_eq!(runtime.plan_steps[0].title, "回复开发者");
assert_eq!(runtime.plan_steps[0].status, "failed");
assert!(runtime.plan_steps[0]
.detail
.as_deref()
.is_some_and(|detail| detail.contains("后台 Agent 最终回复调用 LLM 失败")));
fs::remove_dir_all(root).ok();
}
#[tokio::test]
async fn background_agent_runtime_can_replan_after_observation() {
let root = unique_project_path();
@@ -1978,6 +2070,8 @@ async fn background_agent_runtime_plan_request_includes_same_agent_continuity_co
assert!(second_design_request.contains("当前状态:status=running"));
assert!(second_design_request.contains("runId=design-continuity-second"));
assert!(second_design_request.contains("循环轮次:1/3;每轮工具预算:3"));
assert!(second_design_request.contains("计划进度:"));
assert!(second_design_request.contains("#1 [active] 记录开发者投递的后台任务"));
assert!(second_design_request.contains(
"任务队列:total=2 pending=0 running=1 completed=1 failed=0 latest=design-continuity-second"
));
@@ -4194,6 +4288,7 @@ async fn background_agent_runtime_can_read_other_agent_status() {
assert!(final_request.contains("phase: action"));
assert!(final_request.contains("循环轮次: 0/3"));
assert!(final_request.contains("每轮工具预算: 3"));
assert!(final_request.contains("当前计划步骤: #1 [active] 确认角色设定"));
assert!(final_request.contains("当前目标: 生成角色规范图"));
assert!(final_request.contains("当前任务: 生成角色规范图"));
assert!(final_request.contains("当前动作: 正在生成角色规范图"));
@@ -5296,7 +5391,7 @@ async fn agent_loop_writes_spec_findings_and_retries_generator() {
.iter()
.any(|task_id| task_id == "code-prototype")));
let steps = trace["steps"].as_array().unwrap();
assert_eq!(steps.len(), 66);
assert!(steps.len() >= 66);
assert_eq!(steps[0]["agent"], "Planner");
assert_eq!(steps[0]["phase"], "planning");
assert_eq!(steps[0]["taskId"], "design-director");
@@ -5493,7 +5588,7 @@ async fn agent_loop_writes_spec_findings_and_retries_generator() {
assert_eq!(run_history["runId"], trace["runId"]);
assert_eq!(run_history["status"], "passed");
assert_eq!(run_history["stopReason"], "evaluator-passed");
assert_eq!(run_history["steps"].as_array().unwrap().len(), 66);
assert_eq!(run_history["steps"].as_array().unwrap().len(), steps.len());
let manifest: Value =
serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap())
.expect("manifest json");
+99 -1
View File
@@ -245,6 +245,8 @@ interface AgentRuntimeState {
maxLoopIterations?: number;
toolActionBudget?: number;
plan: string[];
planSteps?: AgentRuntimePlanStep[];
activePlanStepIndex?: number | null;
observations: string[];
recentToolCalls?: AgentRuntimeToolCallRecord[];
taskQueue?: AgentRuntimeTaskQueueSummary;
@@ -274,6 +276,14 @@ interface AgentRuntimeToolCallRecord {
updatedAt: number;
}
interface AgentRuntimePlanStep {
index: number;
title: string;
status: string;
detail: string | null;
updatedAt: number;
}
interface AgentRuntimeTaskQueueSummary {
total: number;
pending: number;
@@ -530,6 +540,48 @@ function createAgentChatRunId(prefix: string) {
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
function agentRuntimePlanStepsFromPlan(plan: string[]): AgentRuntimePlanStep[] {
return plan
.filter((item) => item.trim().length > 0)
.slice(0, 8)
.map((title, index) => ({
index,
title,
status: index === 0 ? 'active' : 'pending',
detail: null,
updatedAt: 0,
}));
}
function normalizeAgentRuntimePlanSteps(
state: AgentRuntimeState,
previous?: AgentRuntimeState | null,
) {
if (state.planSteps && state.planSteps.length > 0) {
return state.planSteps;
}
if (previous?.planSteps && previous.planSteps.length > 0) {
return previous.planSteps;
}
return agentRuntimePlanStepsFromPlan(state.plan ?? []);
}
function normalizeAgentRuntimeActivePlanStepIndex(
state: AgentRuntimeState,
previous?: AgentRuntimeState | null,
) {
if (state.activePlanStepIndex !== undefined) {
return state.activePlanStepIndex;
}
if (previous?.activePlanStepIndex !== undefined) {
return previous.activePlanStepIndex;
}
const activeStep = (state.planSteps ?? []).find(
(step) => step.status === 'active',
);
return activeStep?.index ?? null;
}
function normalizeAgentRuntimeState(
state: AgentRuntimeState,
previous?: AgentRuntimeState | null,
@@ -543,6 +595,11 @@ function normalizeAgentRuntimeState(
loopIteration: state.loopIteration ?? previous?.loopIteration ?? 0,
maxLoopIterations: state.maxLoopIterations ?? previous?.maxLoopIterations ?? 3,
toolActionBudget: state.toolActionBudget ?? previous?.toolActionBudget ?? 3,
planSteps: normalizeAgentRuntimePlanSteps(state, previous),
activePlanStepIndex: normalizeAgentRuntimeActivePlanStepIndex(
state,
previous,
),
recentToolCalls: state.recentToolCalls ?? previous?.recentToolCalls ?? [],
toolPolicy: state.toolPolicy ?? previous?.toolPolicy ?? {
allowedTools: state.allowedTools ?? [],
@@ -667,6 +724,23 @@ function formatAgentRuntimeLoopProgress(
}`;
}
function formatAgentRuntimePlanStep(step: AgentRuntimePlanStep) {
return `#${step.index + 1} ${step.status} · ${step.title}${
step.detail ? ` · ${step.detail}` : ''
}`;
}
function agentRuntimeActivePlanStep(
runtime: Pick<AgentRuntimeState, 'planSteps' | 'activePlanStepIndex'>,
) {
const steps = runtime.planSteps ?? [];
return (
steps.find((step) => step.index === runtime.activePlanStepIndex) ??
steps.find((step) => step.status === 'active') ??
null
);
}
function AgentRuntimeStatusPanel({
runtime,
error,
@@ -692,6 +766,7 @@ function AgentRuntimeStatusPanel({
const recentEvents = (runtime.recentEvents ?? []).slice(-4).reverse();
const recentToolCalls = (runtime.recentToolCalls ?? []).slice(-3).reverse();
const recentTasks = (runtime.recentTasks ?? []).slice(-3).reverse();
const planSteps = (runtime.planSteps ?? []).slice(0, 5);
const toolPolicy = runtime.toolPolicy;
const taskQueueSummary = formatAgentRuntimeTaskQueue(runtime.taskQueue);
const loopProgress = formatAgentRuntimeLoopProgress(runtime);
@@ -724,7 +799,18 @@ function AgentRuntimeStatusPanel({
: ''}
</small>
) : null}
{planItems.length > 0 ? (
{planSteps.length > 0 ? (
<div aria-label="Agent 计划进度">
<strong>计划进度</strong>
{planSteps.map((step) => (
<small
key={`${runtime.sessionId}-plan-step-${step.index}-${step.updatedAt}`}
>
{formatAgentRuntimePlanStep(step)}
</small>
))}
</div>
) : planItems.length > 0 ? (
<ol>
{planItems.map((item, index) => (
<li key={`${runtime.sessionId}-plan-${index}`}>{item}</li>
@@ -902,6 +988,7 @@ interface AgentStatusCard {
runtimeLoopIteration: number | null;
runtimeMaxLoopIterations: number | null;
runtimeToolActionBudget: number | null;
runtimeActivePlanStep: string | null;
runtimeTaskQueue: AgentRuntimeTaskQueueSummary | null;
runtimeRecentTasks: AgentRuntimeTaskRecord[];
}
@@ -10195,6 +10282,7 @@ export function deriveAgentStatusCards(
latestByGroupRole.get(agentGroupRoleKey(task.group, task.role) ?? '');
const cardId = agentConversationId(task);
const runtime = runtimeByAgentId[cardId] ?? runtimeByAgentId[task.id] ?? null;
const activePlanStep = runtime ? agentRuntimeActivePlanStep(runtime) : null;
return {
id: cardId,
taskId: task.id,
@@ -10226,6 +10314,9 @@ export function deriveAgentStatusCards(
runtimeLoopIteration: runtime?.loopIteration ?? null,
runtimeMaxLoopIterations: runtime?.maxLoopIterations ?? null,
runtimeToolActionBudget: runtime?.toolActionBudget ?? null,
runtimeActivePlanStep: activePlanStep
? formatAgentRuntimePlanStep(activePlanStep)
: null,
runtimeTaskQueue: runtime?.taskQueue ?? null,
runtimeRecentTasks: runtime?.recentTasks ?? [],
};
@@ -10262,6 +10353,7 @@ function sameAgentStatusCard(left: AgentStatusCard, right: AgentStatusCard) {
left.runtimeLoopIteration === right.runtimeLoopIteration &&
left.runtimeMaxLoopIterations === right.runtimeMaxLoopIterations &&
left.runtimeToolActionBudget === right.runtimeToolActionBudget &&
left.runtimeActivePlanStep === right.runtimeActivePlanStep &&
sameAgentRuntimeTaskQueue(left.runtimeTaskQueue, right.runtimeTaskQueue) &&
left.hasRecentEvidence === right.hasRecentEvidence &&
left.taskGraphState === right.taskGraphState &&
@@ -11138,6 +11230,9 @@ function summarizeAgentStatusCardsForChat(
`- ${parts.join(' · ')}`,
` ${agent.summary}`,
agent.runtimeGoal ? ` 当前目标:${agent.runtimeGoal}` : null,
agent.runtimeActivePlanStep
? ` 当前计划步骤:${agent.runtimeActivePlanStep}`
: null,
runtimeTaskQueue ? ` ${runtimeTaskQueue}` : null,
latestRuntimeTask
? ` 最近任务:${formatAgentRecentRuntimeTask(latestRuntimeTask)}`
@@ -20394,6 +20489,9 @@ export function App() {
{agent.runtimeTask ? (
<small>{`当前任务:${agent.runtimeTask}`}</small>
) : null}
{agent.runtimeActivePlanStep ? (
<small>{`当前计划步骤:${agent.runtimeActivePlanStep}`}</small>
) : null}
{agentRuntimeTaskQueue ? (
<small>{agentRuntimeTaskQueue}</small>
) : null}
@@ -519,6 +519,16 @@ describe('AI 游戏创作 App 界面边界', () => {
maxLoopIterations: 3,
toolActionBudget: 3,
plan: ['读取项目上下文'],
planSteps: [
{
index: 0,
title: '读取项目上下文',
status: 'active',
detail: '拆解素材规格',
updatedAt: 1235,
},
],
activePlanStepIndex: 0,
observations: ['已创建本轮 Agent Runtime run。'],
allowedTools: ['conversation.read'],
lastResponse: null,
@@ -561,6 +571,7 @@ describe('AI 游戏创作 App 界面边界', () => {
runtimeLoopIteration: 2,
runtimeMaxLoopIterations: 3,
runtimeToolActionBudget: 3,
runtimeActivePlanStep: '#1 active · 读取项目上下文 · 拆解素材规格',
runtimeTaskQueue,
runtimeRecentTasks: [runtimeTask],
});
@@ -1391,6 +1402,30 @@ describe('AI 游戏创作 App 界面边界', () => {
maxLoopIterations: 3,
toolActionBudget: 3,
plan: ['读取项目笔记', '结合观察修正建议', '回复开发者'],
planSteps: [
{
index: 0,
title: '读取项目笔记',
status: 'completed',
detail: 'file.read:ok · 已读取 game/notes.txt',
updatedAt: 4005,
},
{
index: 1,
title: '结合观察修正建议',
status: 'active',
detail: '正在根据观察修正计划',
updatedAt: 4006,
},
{
index: 2,
title: '回复开发者',
status: 'failed',
detail: '最终回复仍缺少角色规范确认',
updatedAt: 4007,
},
],
activePlanStepIndex: 1,
observations: ['思考摘要:需要先看项目笔记', 'file.read:ok · 已读取 game/notes.txt'],
recentToolCalls: [
{
@@ -1580,9 +1615,16 @@ describe('AI 游戏创作 App 界面边界', () => {
/任务队列:pending 0 · running 1 · completed 0 · failed 0 · total 1 · latest launcher-agent-task-/,
),
).not.toBeNull();
expect(screen.getByText('读取项目笔记')).not.toBeNull();
expect(screen.getByText('结合观察修正建议')).not.toBeNull();
expect(screen.getByText('回复开发者')).not.toBeNull();
expect(screen.getByText('计划进度')).not.toBeNull();
expect(
screen.getByText('#1 completed · 读取项目笔记 · file.read:ok · 已读取 game/notes.txt'),
).not.toBeNull();
expect(
screen.getByText('#2 active · 结合观察修正建议 · 正在根据观察修正计划'),
).not.toBeNull();
expect(
screen.getByText('#3 failed · 回复开发者 · 最终回复仍缺少角色规范确认'),
).not.toBeNull();
expect(screen.getByText('思考摘要:需要先看项目笔记')).not.toBeNull();
expect(screen.getByText('file.read:ok · 已读取 game/notes.txt')).not.toBeNull();
expect(screen.getByText('最近动作')).not.toBeNull();
@@ -13365,6 +13407,16 @@ describe('AI 游戏创作 App 界面边界', () => {
maxLoopIterations: 3,
toolActionBudget: 3,
plan: ['读取项目上下文'],
planSteps: [
{
index: 0,
title: '读取项目上下文',
status: 'active',
detail: '正在整理目标和约束',
updatedAt: 11,
},
],
activePlanStepIndex: 0,
observations: ['已创建本轮 Agent Runtime run。'],
taskQueue: {
total: 2,
@@ -13437,6 +13489,9 @@ describe('AI 游戏创作 App 界面边界', () => {
'当前目标:补齐第一关节奏目标',
);
expect(designCard.textContent).toContain('当前任务:拆解关卡节奏');
expect(designCard.textContent).toContain(
'当前计划步骤:#1 active · 读取项目上下文 · 正在整理目标和约束',
);
expect(designCard.textContent).toContain(
'任务队列:pending 1 · running 1 · completed 0 · failed 0 · total 2 · latest runtime-design-director-1',
);
@@ -4062,6 +4062,7 @@
- 2026-07-10 调整:Agent Runtime state 新增 `recentToolCalls`,后台 loop 每次执行白名单工具后记录最近 20 条结构化动作,包含 tool、status、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 调整: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 调整:Agent Runtime state / result 新增 `taskQueue` 观测摘要,从 `.agent/runtime/tasks/<agentId>.jsonl` 中每个 `runId` 的最新记录汇总 `total / pending / running / completed / failed / latestRunId`。开发窗口 Runtime 面板、主窗口 Agent 状态列表、`agent.run_status` observation 和下一轮 planning prompt 都使用该字段判断同一 Agent 是否仍有排队任务;它不是新的调度器、SQLite 或跨重启独立 worker。
- 2026-07-10 调整:Agent Runtime V1 后台工具箱新增只读 `task.list`。Agent 可自行读取 `.agent/manifest.json` 的 seed task 状态、依赖、产物交接和按依赖计算的 `readyTaskIds`,用于判断下一步任务;该工具必须受 `task.list` 项目权限策略保护,策略要求确认或拒绝时不得把任务图细节放进 observation。
@@ -37,6 +37,7 @@ Agent Runtime 负责:
- 2026-07-10 补充:Agent Runtime state 新增 `recentToolCalls`,每次后台工具执行后记录最近 20 条结构化工具动作,包含 tool、status、reason、summary、detail 和 updatedAt;开发窗口、项目内 Agent 对话弹窗和主窗口 Agent 状态列表可直接展示“最近动作”,不再只能从 observation 字符串里猜测 action / observation 对应关系。字段只保存过滤后的摘要和观察细节,不保存原始 API Key 或任意未过滤输入。
- 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 补充: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/<agentId>.jsonl`;面板按时间展示最近 `thinking_summary / plan / action / observation / response / error` 事件,现在能同时看到 Agent 的计划、最近观察、最近事件、最近工具动作和任务队列。
- 2026-07-10 补充:Agent Runtime state / result 新增 `taskQueue`,从 `.agent/runtime/tasks/<agentId>.jsonl` 中每个 `runId` 的最新记录汇总 `total / pending / running / completed / failed / latestRunId`;开发窗口 Runtime 面板、主窗口 Agent 状态列表、`agent.run_status` observation 和下一轮 planning prompt 都读取该摘要,用于判断同一 Agent 是否仍有排队任务。该字段是运行观测摘要,不新增调度器、SQLite 或独立 worker。
- 2026-07-10 补充:单 Agent 聊天和后台 planning prompt 会读取同一个 Agent 的 Runtime 连续上下文,把本 Agent 最近 status / phase / runId / 当前任务 / 下一步、最近回复、计划、观察、最近 3 条工具动作、最近事件、最近 3 条任务记录和工具策略摘要带入下一轮推理;上下文按规范 taskId 隔离,不读取其他 Agent 的 runtime 文件,并在进入 prompt 前过滤密钥和本机绝对路径。新后台 run 启动时会继承本 Agent 上次 `recentToolCalls` 和 `lastResponse`,让多轮任务不丢失结构化行动证据。
@@ -298,7 +299,7 @@ game-project/
- loop 每次运行会写 `.agent/run.latest.json` 和 `.agent/runs/<runId>.json`,记录 `Planner` / `Orchestrator` agenda / 16 个组内角色 brief 或 carry-over / 6 个 `GroupCoordinator` 汇总 / `Generator` / 6 个专业组交接 / `Evaluator` 质量评审 / `ArtifactWriter` / `Playtest` step、每步 `toolCalls`、输入文件、输出文件、状态、轮次、maxPasses、toolCallCount、maxToolCalls、stopReason、nextStep 和错误摘要;Planner、角色 agent 和 Generator 的 `inputPaths` 必须包含对应记忆文件、`.agent/conversations/project.jsonl`、`.agent/conversations/agents/`、`.agent/manifest.json` 和 agenda 等上下文来源,其中角色 agent 必须包含自己的 `memory/agents/<group>/<role>.md` 和 `memory/blackboard.md`;conversation 输入只取最近少量 project / agent 对话摘要,不读取全量历史;每个 step 必须带 phase、taskId、group 和 role,`.agent/run.latest.json.taskGraph` 必须记录 goal、readyTaskIds、activeTaskIds、carriedTaskIds、repairFocus、repairRoutes 和当前任务状态;`.agent/run.latest.json.passPlans` 必须按轮记录 mode、summary、activeTaskIds、carriedTaskIds、dependencyWaves、repairFocus 和 repairRoutes,作为 `/trace` 与开发窗口判断编排 loop 是否真实发生的直接证据;`run.latest.json` 是当前指针,`.agent/runs/` 保留历史 run trace;开发窗口读取 latest 展示编排过程,并复用 `file.list/read` 按文件修改时间先载入最近 20 个历史 run,滚动时再按批次读取剩余历史,普通用户窗口不展示。
- `.agent/run.latest.json` 的 `artifacts` 使用结构化记录,包含相对路径、字节数和 `fnv1a64:` checksum;除最终本地产物外,也会收集 `.agent/passes/pass-N/` 快照,便于确认返工前后的产物差异。
- 通过 Evaluator 和 `game.static_smoke` 后,Agent loop 会把本次 runId、状态、轮次、下一步、active / carry-over 任务和最终本地产物摘要追加到 `memory/session.md` 与 `memory/project.md`,把重要跨 agent 决策 / 依赖 / 风险摘要追加到 `memory/blackboard.md`,并把各角色本轮成功产出的角色摘要追加到 `memory/agents/<group>/<role>.md`;下一次 Planner、组内角色和 Generator 会通过记忆输入自然读取上一轮稳定原型状态,而不只依赖开发窗口 trace。
- 单 agent 对话入口读取对应 agent conversation;用户提交后先追加用户消息,再调用 `chat_with_game_creator_role_agent` / `chat_with_game_creator_role_agent_stream` 让对应 `agentLlm.<agentId>` 结合项目上下文、Agent 私有记忆和本 Agent 历史对话生成回复,随后把回复写入对应 `.agent/conversations/agents/<agentId>.jsonl`。这里的 `<agentId>` 以任务 `taskId` 为规范值,Tauri 只兼容旧 `group-role` 别名并映射到 taskId。每轮对话会同步写 `.agent/runtime/agents/<agentId>.json` 和 `.agent/runtime/events/<agentId>.jsonl`,字段包含 `agentId`、`taskId`、`sessionId`、`runId`、`source`、`status`、`phase`、`currentTask`、`currentGoal`、`currentAction`、`waitingOn`、`nextStep`、`loopIteration`、`maxLoopIterations`、`toolActionBudget`、`plan`、`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/<group>/<role>.md`,写入前复用 `memory.write` 项目策略和本地项目锁;最近对话可作为本次生成 prompt 上下文读取,但只有经过显式总结、用户显式手动沉淀或生成 loop 成功沉淀的稳定结论,才追加到 `memory/blackboard.md` 或 `memory/agents/<group>/<role>.md`。
- 单 agent 对话入口读取对应 agent conversation;用户提交后先追加用户消息,再调用 `chat_with_game_creator_role_agent` / `chat_with_game_creator_role_agent_stream` 让对应 `agentLlm.<agentId>` 结合项目上下文、Agent 私有记忆和本 Agent 历史对话生成回复,随后把回复写入对应 `.agent/conversations/agents/<agentId>.jsonl`。这里的 `<agentId>` 以任务 `taskId` 为规范值,Tauri 只兼容旧 `group-role` 别名并映射到 taskId。每轮对话会同步写 `.agent/runtime/agents/<agentId>.json` 和 `.agent/runtime/events/<agentId>.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/<group>/<role>.md`,写入前复用 `memory.write` 项目策略和本地项目锁;最近对话可作为本次生成 prompt 上下文读取,但只有经过显式总结、用户显式手动沉淀或生成 loop 成功沉淀的稳定结论,才追加到 `memory/blackboard.md` 或 `memory/agents/<group>/<role>.md`。
- 生成 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 命令` 移除确认项。