完善游戏创作 Agent Runtime 执行闭环

收紧后台规划与回复上下文,项目事实只经权限工具进入 observation
统一前台聊天与后台任务的 Agent 锁并保留多 Agent 并行
补齐独立开发聊天和主工作区的重启恢复确认及跨项目隔离
修正工具预算耗尽、超额动作提示和完成后队列 drain 语义
补充 Runtime、恢复、并发、流式与项目切换回归测试和技术决策文档
This commit is contained in:
AIGameCreator App
2026-07-10 22:00:31 +08:00
parent 7842d5bab3
commit f9e61beb54
8 changed files with 1534 additions and 211 deletions
File diff suppressed because one or more lines are too long
@@ -248,8 +248,9 @@ pub(crate) async fn chat_with_game_creator_role_agent(
let agent_id = normalize_game_creator_runtime_agent_id(agent_id.trim())?;
enforce_project_permission_policy(root, "conversation.read")?;
enforce_project_permission_policy(root, "conversation.write")?;
let _lock = acquire_project_write_lock(root, "conversation.write")?;
chat_with_game_creator_role_agent_runtime_for_session_at(
let runtime_lock = try_acquire_game_creator_agent_runtime_task_lock(root, &agent_id)?
.ok_or_else(|| format!("Agent 正在执行其他前台或后台任务:{agent_id}"))?;
let result = chat_with_game_creator_role_agent_runtime_for_session_at(
root,
&agent_id,
session_id.as_deref(),
@@ -257,7 +258,12 @@ pub(crate) async fn chat_with_game_creator_role_agent(
"",
)
.await
.map(|(reply, _runtime)| reply)
.map(|(reply, _runtime)| reply);
spawn_next_game_creator_agent_background_task_drain_with_lock(root, &agent_id, runtime_lock);
match result {
Ok(reply) => Ok(reply),
Err(error) => Err(error),
}
}
#[tauri::command]
@@ -277,121 +283,133 @@ pub(crate) async fn chat_with_game_creator_role_agent_stream(
resolve_agent_conversation_session_id_at(root, &agent_id, session_id.as_deref(), true)?;
enforce_project_permission_policy(root, "conversation.read")?;
enforce_project_permission_policy(root, "conversation.write")?;
let _lock = acquire_project_write_lock(root, "conversation.write")?;
let runtime_lock = try_acquire_game_creator_agent_runtime_task_lock(root, &agent_id)?
.ok_or_else(|| format!("Agent 正在执行其他前台或后台任务:{agent_id}"))?;
let emit_app = app.clone();
let event_project_path = project_path.clone();
let event_agent_id = agent_id.clone();
let event_run_id = run_id.clone();
let mut runtime_state = start_game_creator_agent_runtime_turn_for_session_at(
root,
agent_id.as_str(),
Some(&session_id),
prompt.trim(),
&run_id,
)?;
runtime_state = advance_game_creator_agent_runtime_turn_at(
root,
runtime_state,
"llm",
"请求 Agent LLM",
"已读取项目上下文,正在让 Agent 独立推理。",
)?;
let mut streaming_runtime_state = runtime_state.clone();
streaming_runtime_state.current_action = "正在接收 Agent 回复".to_string();
let _ = app.emit(
"game-creator-role-agent-chat-stream",
GameCreatorRoleAgentChatStreamEvent {
project_path: event_project_path.clone(),
agent_id: event_agent_id.clone(),
run_id: event_run_id.clone(),
status: "started".to_string(),
delta_text: String::new(),
accumulated_text: String::new(),
finish_reason: None,
session_id: Some(runtime_state.session_id.clone()),
runtime_status: Some(runtime_state.status.clone()),
runtime_phase: Some(runtime_state.phase.clone()),
runtime_summary: Some(runtime_state.current_action.clone()),
runtime_state: Some(runtime_state.clone()),
},
);
let result = chat_with_game_creator_role_agent_stream_for_session_at(
root,
agent_id.as_str(),
Some(&session_id),
prompt.trim(),
|delta| {
let _ = emit_app.emit(
"game-creator-role-agent-chat-stream",
GameCreatorRoleAgentChatStreamEvent {
project_path: event_project_path.clone(),
agent_id: event_agent_id.clone(),
run_id: event_run_id.clone(),
status: "delta".to_string(),
delta_text: delta.delta_text.clone(),
accumulated_text: delta.accumulated_text.clone(),
finish_reason: delta.finish_reason.clone(),
session_id: Some(streaming_runtime_state.session_id.clone()),
runtime_status: Some("running".to_string()),
runtime_phase: Some("llm".to_string()),
runtime_summary: Some("正在接收 Agent 回复".to_string()),
runtime_state: Some(streaming_runtime_state.clone()),
},
);
},
)
let command_result = async {
let mut runtime_state = start_game_creator_agent_runtime_turn_for_session_at(
root,
agent_id.as_str(),
Some(&session_id),
prompt.trim(),
&run_id,
)?;
runtime_state = advance_game_creator_agent_runtime_turn_at(
root,
runtime_state,
"llm",
"请求 Agent LLM",
"已读取项目上下文,正在让 Agent 独立推理。",
)?;
let mut streaming_runtime_state = runtime_state.clone();
streaming_runtime_state.current_action = "正在接收 Agent 回复".to_string();
let _ = app.emit(
"game-creator-role-agent-chat-stream",
GameCreatorRoleAgentChatStreamEvent {
project_path: event_project_path.clone(),
agent_id: event_agent_id.clone(),
run_id: event_run_id.clone(),
status: "started".to_string(),
delta_text: String::new(),
accumulated_text: String::new(),
finish_reason: None,
session_id: Some(runtime_state.session_id.clone()),
runtime_status: Some(runtime_state.status.clone()),
runtime_phase: Some(runtime_state.phase.clone()),
runtime_summary: Some(runtime_state.current_action.clone()),
runtime_state: Some(runtime_state.clone()),
},
);
let result = chat_with_game_creator_role_agent_stream_for_session_at(
root,
agent_id.as_str(),
Some(&session_id),
prompt.trim(),
|delta| {
let _ = emit_app.emit(
"game-creator-role-agent-chat-stream",
GameCreatorRoleAgentChatStreamEvent {
project_path: event_project_path.clone(),
agent_id: event_agent_id.clone(),
run_id: event_run_id.clone(),
status: "delta".to_string(),
delta_text: delta.delta_text.clone(),
accumulated_text: delta.accumulated_text.clone(),
finish_reason: delta.finish_reason.clone(),
session_id: Some(streaming_runtime_state.session_id.clone()),
runtime_status: Some("running".to_string()),
runtime_phase: Some("llm".to_string()),
runtime_summary: Some("正在接收 Agent 回复".to_string()),
runtime_state: Some(streaming_runtime_state.clone()),
},
);
},
)
.await;
match result {
Ok(reply) => {
let completed_runtime = finish_game_creator_agent_runtime_turn_at(
root,
runtime_state,
&reply.reply_text,
)?;
let _ = app.emit(
"game-creator-role-agent-chat-stream",
GameCreatorRoleAgentChatStreamEvent {
project_path: project_path.clone(),
agent_id: agent_id.clone(),
run_id: run_id.clone(),
status: "completed".to_string(),
delta_text: String::new(),
accumulated_text: reply.reply_text.clone(),
finish_reason: None,
session_id: Some(completed_runtime.session_id.clone()),
runtime_status: Some(completed_runtime.status.clone()),
runtime_phase: Some(completed_runtime.phase.clone()),
runtime_summary: Some(completed_runtime.current_action.clone()),
runtime_state: Some(completed_runtime),
},
);
Ok(reply)
}
Err(error) => {
let failed_runtime =
fail_game_creator_agent_runtime_turn_at(root, runtime_state, &error).ok();
let _ = app.emit(
"game-creator-role-agent-chat-stream",
GameCreatorRoleAgentChatStreamEvent {
project_path: project_path.clone(),
agent_id: agent_id.clone(),
run_id: run_id.clone(),
status: "failed".to_string(),
delta_text: String::new(),
accumulated_text: String::new(),
finish_reason: None,
session_id: failed_runtime
.as_ref()
.map(|runtime| runtime.session_id.clone()),
runtime_status: failed_runtime
.as_ref()
.map(|runtime| runtime.status.clone()),
runtime_phase: failed_runtime.as_ref().map(|runtime| runtime.phase.clone()),
runtime_summary: failed_runtime
.as_ref()
.map(|runtime| runtime.current_action.clone()),
runtime_state: failed_runtime,
},
);
Err(error)
}
}
}
.await;
match result {
Ok(reply) => {
let completed_runtime =
finish_game_creator_agent_runtime_turn_at(root, runtime_state, &reply.reply_text)?;
let _ = app.emit(
"game-creator-role-agent-chat-stream",
GameCreatorRoleAgentChatStreamEvent {
project_path,
agent_id,
run_id,
status: "completed".to_string(),
delta_text: String::new(),
accumulated_text: reply.reply_text.clone(),
finish_reason: None,
session_id: Some(completed_runtime.session_id.clone()),
runtime_status: Some(completed_runtime.status.clone()),
runtime_phase: Some(completed_runtime.phase.clone()),
runtime_summary: Some(completed_runtime.current_action.clone()),
runtime_state: Some(completed_runtime),
},
);
Ok(reply)
}
Err(error) => {
let failed_runtime =
fail_game_creator_agent_runtime_turn_at(root, runtime_state, &error).ok();
let _ = app.emit(
"game-creator-role-agent-chat-stream",
GameCreatorRoleAgentChatStreamEvent {
project_path,
agent_id,
run_id,
status: "failed".to_string(),
delta_text: String::new(),
accumulated_text: String::new(),
finish_reason: None,
session_id: failed_runtime
.as_ref()
.map(|runtime| runtime.session_id.clone()),
runtime_status: failed_runtime
.as_ref()
.map(|runtime| runtime.status.clone()),
runtime_phase: failed_runtime.as_ref().map(|runtime| runtime.phase.clone()),
runtime_summary: failed_runtime
.as_ref()
.map(|runtime| runtime.current_action.clone()),
runtime_state: failed_runtime,
},
);
Err(error)
}
spawn_next_game_creator_agent_background_task_drain_with_lock(root, &agent_id, runtime_lock);
match command_result {
Ok(reply) => Ok(reply),
Err(error) => Err(error),
}
}
@@ -527,6 +545,19 @@ pub(crate) fn resume_game_creator_agent_runtime_tasks(
resume_game_creator_agent_background_tasks_at(root)
}
#[tauri::command]
pub(crate) fn confirm_resume_game_creator_agent_runtime_tasks(
project_path: String,
) -> Result<Vec<AgentRuntimeResult>, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "conversation.read")?;
enforce_project_permission_policy(root, "conversation.write")?;
enforce_project_permission_policy(root, "agent.run_status")?;
// 该命令只由开发者在 agent.resume 确认卡中明确批准后调用。
enforce_project_permission_policy(root, "agent.resume")?;
resume_game_creator_agent_background_tasks_at(root)
}
#[tauri::command]
pub(crate) fn schedule_game_creator_agent_ready_tasks(
project_path: String,
@@ -1253,6 +1253,7 @@ fn main() {
read_game_creator_agent_runtime,
read_game_creator_agent_runtimes,
resume_game_creator_agent_runtime_tasks,
confirm_resume_game_creator_agent_runtime_tasks,
schedule_game_creator_agent_ready_tasks,
check_game_creator_llm_config,
read_game_creator_app_config,
File diff suppressed because it is too large Load Diff
+256 -10
View File
@@ -1272,6 +1272,7 @@ export type PendingCommand =
interface PendingUiConfirmation {
commandId: GameCreationAppCommandDescriptor['id'];
detail: string;
projectPath: string | null;
}
type TauriInvoke = <T>(
@@ -1297,6 +1298,20 @@ function isMissingAgentSessionCommandError(error: unknown) {
);
}
function isMissingAgentRuntimeResumeCommandError(error: unknown) {
const message = error instanceof Error ? error.message : String(error);
const normalized = message.toLowerCase();
return (
normalized.includes('resume_game_creator_agent_runtime_tasks') &&
(normalized.includes('not found') ||
normalized.includes('unknown command') ||
normalized.includes('unexpected invoke') ||
normalized.includes('unexpected command') ||
normalized.includes('不存在') ||
normalized.includes('未找到'))
);
}
function createDefaultChatMessages(): ChatMessage[] {
return [
{
@@ -2888,7 +2903,10 @@ export function WorkspaceLauncher({
const [agentChatActiveRuntime, setAgentChatActiveRuntime] =
useState<AgentRuntimeState | null>(null);
const [agentChatRuntimeError, setAgentChatRuntimeError] = useState('');
const [agentChatResumeConfirmation, setAgentChatResumeConfirmation] =
useState<{ projectPath: string; detail: string } | null>(null);
const agentChatLoadVersionRef = useRef(0);
const agentChatRuntimeResumeProjectPathRef = useRef<string | null>(null);
const agentChatProjectPathRef = useRef(agentChatProjectPath);
agentChatProjectPathRef.current = agentChatProjectPath;
const agentChatSelectedAgentIdRef = useRef(agentChatSelectedAgentId);
@@ -3650,6 +3668,91 @@ export function WorkspaceLauncher({
}
}
function cancelAgentChatRuntimeResume() {
setAgentChatResumeConfirmation(null);
setAgentChatStatus('已取消恢复 Agent Runtime 任务');
}
async function confirmAgentChatRuntimeResume() {
const pending = agentChatResumeConfirmation;
const invoke = resolveTauriInvoke();
if (!pending || !invoke) {
return;
}
setAgentChatResumeConfirmation(null);
setAgentChatStatus('正在恢复 Agent Runtime 任务');
try {
await invoke<AgentRuntimeResult[]>(
'confirm_resume_game_creator_agent_runtime_tasks',
{ projectPath: pending.projectPath },
);
if (agentChatProjectPathRef.current.trim() !== pending.projectPath) {
return;
}
agentChatRuntimeResumeProjectPathRef.current = pending.projectPath;
await loadAgentChatConversation(
agentChatSelectedAgentIdRef.current,
pending.projectPath,
agentChatSelectedSessionIdRef.current,
);
setAgentChatStatus('已确认恢复 Agent Runtime 任务');
} catch (error) {
if (agentChatProjectPathRef.current.trim() !== pending.projectPath) {
return;
}
agentChatRuntimeResumeProjectPathRef.current = null;
setAgentChatStatus(
`Agent Runtime 恢复失败:${
error instanceof Error ? error.message : String(error)
}`,
);
}
}
async function resumeAgentChatRuntimeTasksIfNeeded(
invoke: TauriInvoke,
projectPathForChat: string,
) {
if (
agentChatRuntimeResumeProjectPathRef.current === projectPathForChat
) {
return '';
}
try {
await invoke<AgentRuntimeResult[]>(
'resume_game_creator_agent_runtime_tasks',
{ projectPath: projectPathForChat },
);
agentChatRuntimeResumeProjectPathRef.current = projectPathForChat;
return '';
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (isMissingAgentRuntimeResumeCommandError(error)) {
agentChatRuntimeResumeProjectPathRef.current = projectPathForChat;
return '';
}
if (message.includes('项目权限策略要求用户确认:agent.resume')) {
agentChatRuntimeResumeProjectPathRef.current = projectPathForChat;
setAgentChatResumeConfirmation({
projectPath: projectPathForChat,
detail: `恢复 ${projectPathForChat} 中未完成的 Agent Runtime 任务`,
});
return ';等待确认恢复 Agent Runtime 任务';
}
if (message.includes('项目权限策略拒绝执行:agent.resume')) {
agentChatRuntimeResumeProjectPathRef.current = projectPathForChat;
return ';项目策略禁止恢复 Agent Runtime 任务';
}
agentChatRuntimeResumeProjectPathRef.current = null;
return `Agent Runtime 恢复检查失败:${message}`;
}
}
useEscapeToClose(
cancelAgentChatRuntimeResume,
agentChatResumeConfirmation !== null,
);
async function handleAgentChatPickProjectDirectory() {
if (agentChatBusy || agentChatBackgroundBusy) {
return;
@@ -3670,6 +3773,8 @@ export function WorkspaceLauncher({
}
setAgentChatStatus('已选择项目目录');
resetAgentChatSessionView();
agentChatRuntimeResumeProjectPathRef.current = null;
setAgentChatResumeConfirmation(null);
setAgentChatProjectPath(selectedPath);
void loadAgentChatConversation(agentChatSelectedAgentId, selectedPath);
} catch (error) {
@@ -3698,6 +3803,13 @@ export function WorkspaceLauncher({
setAgentChatBusy(true);
setAgentChatStatus('正在读取');
try {
const runtimeResumeStatus = await resumeAgentChatRuntimeTasksIfNeeded(
invoke,
projectPathForChat,
);
if (agentChatLoadVersionRef.current !== loadVersion) {
return;
}
let sessionList = knownSessions;
let sessionListError = '';
if (sessionList === undefined) {
@@ -3821,7 +3933,9 @@ export function WorkspaceLauncher({
);
}
}
setAgentChatStatus(`已读取 ${result.messages.length} 条:${result.path}`);
setAgentChatStatus(
`已读取 ${result.messages.length} 条:${result.path}${runtimeResumeStatus}`,
);
} catch (error) {
if (agentChatLoadVersionRef.current !== loadVersion) {
return;
@@ -5244,8 +5358,11 @@ export function WorkspaceLauncher({
value={agentChatProjectPath}
onChange={(event) => {
agentChatLoadVersionRef.current += 1;
agentChatRuntimeResumeProjectPathRef.current = null;
setAgentChatResumeConfirmation(null);
setAgentChatProjectPath(event.currentTarget.value);
resetAgentChatSessionView();
setAgentChatStatus('请选择并读取会话');
}}
/>
</label>
@@ -5438,6 +5555,26 @@ export function WorkspaceLauncher({
}
/>
</section>
{agentChatResumeConfirmation ? (
<div className="pending-command" role="status">
<span>
agent.resume
<small>{agentChatResumeConfirmation.detail}</small>
</span>
<button
type="button"
onClick={cancelAgentChatRuntimeResume}
>
</button>
<button
type="button"
onClick={() => void confirmAgentChatRuntimeResume()}
>
</button>
</div>
) : null}
<div className="launcher-agent-chat-messages" aria-label="Agent 聊天记录">
{agentChatMessages.length > 0 ? (
agentChatMessages.map((message, index) => (
@@ -13051,6 +13188,16 @@ export function App() {
pendingNonEmptyProjectCreate !== null,
);
useEffect(() => {
if (
pendingUiConfirmation?.projectPath &&
pendingUiConfirmation.projectPath !==
resolveCurrentUiConfirmationProjectPath()
) {
cancelUiCommandConfirmation();
}
}, [localProject?.projectPath, projectPath]);
useEffect(() => {
if (!initialProjectPath || initialProjectOpenedRef.current) {
return;
@@ -13319,6 +13466,22 @@ export function App() {
return projectPath;
}
function resolveCurrentUiConfirmationProjectPath() {
const openedProjectPath = localProjectPathRef.current?.trim();
if (openedProjectPath) {
return openedProjectPath;
}
const draftProjectPath = projectPath.trim();
if (
!draftProjectPath ||
!isAbsoluteProjectPath(draftProjectPath) ||
projectPathHasControlCharacter(draftProjectPath)
) {
return null;
}
return draftProjectPath;
}
function queuePendingCommand(command: PendingCommand) {
setPendingCommand(command);
setCommandLog((current) => [
@@ -13347,7 +13510,7 @@ export function App() {
}
if (permission === 'confirm') {
pendingUiConfirmationActionRef.current = onConfirm;
setPendingUiConfirmation({ commandId, detail });
setPendingUiConfirmation({ commandId, detail, projectPath: null });
setCommandLog((current) => [
...current,
`permission.pending ${commandId}`,
@@ -13369,7 +13532,7 @@ export function App() {
onConfirm: () => void,
) {
pendingUiConfirmationActionRef.current = onConfirm;
setPendingUiConfirmation({ commandId, detail });
setPendingUiConfirmation({ commandId, detail, projectPath });
setCommandLog((current) => [...current, `permission.pending ${commandId}`]);
appendLocalPermissionLog(projectPath, 'permission.pending', commandId);
}
@@ -13474,10 +13637,20 @@ export function App() {
if (!pending) {
return;
}
if (
pending.projectPath &&
pending.projectPath !== resolveCurrentUiConfirmationProjectPath()
) {
cancelUiCommandConfirmation();
return;
}
const permissionProjectPath =
pending.projectPath ??
resolveUiPermissionLogProjectPath(pending.commandId);
if (
await denyPendingCommandIfNeeded(
pending.commandId,
resolveUiPermissionLogProjectPath(pending.commandId),
permissionProjectPath,
)
) {
pendingUiConfirmationActionRef.current = null;
@@ -13492,7 +13665,7 @@ export function App() {
`permission.confirm ${pending.commandId}`,
]);
appendLocalPermissionLog(
resolveUiPermissionLogProjectPath(pending.commandId),
permissionProjectPath,
'permission.confirm',
pending.commandId,
);
@@ -13626,6 +13799,12 @@ export function App() {
: '已取消读取 Agent run 状态',
);
}
if (
pending.commandId === 'agent.resume' &&
pending.detail.includes('未完成的 Agent Runtime 任务')
) {
setAgentRunStatus('已取消恢复 Agent Runtime 任务');
}
if (
pending.commandId === 'project.index' ||
pending.commandId === 'project.diff' ||
@@ -13669,7 +13848,8 @@ export function App() {
`permission.cancel ${pending.commandId}`,
]);
appendLocalPermissionLog(
resolveUiPermissionLogProjectPath(pending.commandId),
pending.projectPath ??
resolveUiPermissionLogProjectPath(pending.commandId),
'permission.cancel',
pending.commandId,
);
@@ -13962,6 +14142,13 @@ export function App() {
const openedProject = { ...result, projectPath: openedProjectPath };
const conversationMessages = createDefaultChatMessages();
if (
pendingUiConfirmation?.projectPath &&
pendingUiConfirmation.projectPath !== openedProject.projectPath
) {
cancelUiCommandConfirmation();
}
localProjectPathRef.current = openedProject.projectPath;
setProjectPath(openedProject.projectPath);
setLocalProject(openedProject);
setManifest(openedProject.manifest);
@@ -21168,23 +21355,80 @@ export function App() {
}
try {
if (agentRuntimeResumeProjectPathRef.current !== nextProjectPath) {
agentRuntimeResumeProjectPathRef.current = nextProjectPath;
try {
const resumedRuntimes = await invoke<AgentRuntimeResult[]>(
'resume_game_creator_agent_runtime_tasks',
{ projectPath: nextProjectPath },
);
if (localProjectPathRef.current !== nextProjectPath) {
return;
}
agentRuntimeResumeProjectPathRef.current = nextProjectPath;
for (const runtimeResult of resumedRuntimes) {
rememberAgentRuntimeState(agentRuntimeStateFromResult(runtimeResult));
}
} catch {
// Runtime recovery is best-effort; reading current status below remains authoritative.
} catch (error) {
if (localProjectPathRef.current !== nextProjectPath) {
return;
}
const message = error instanceof Error ? error.message : String(error);
if (isMissingAgentRuntimeResumeCommandError(error)) {
agentRuntimeResumeProjectPathRef.current = nextProjectPath;
} else if (message.includes('项目权限策略要求用户确认:agent.resume')) {
agentRuntimeResumeProjectPathRef.current = nextProjectPath;
requestProjectPolicyConfirmation(
'agent.resume',
nextProjectPath,
`恢复 ${nextProjectPath} 中未完成的 Agent Runtime 任务`,
() => {
void invoke<AgentRuntimeResult[]>(
'confirm_resume_game_creator_agent_runtime_tasks',
{ projectPath: nextProjectPath },
)
.then((resumedRuntimes) => {
if (localProjectPathRef.current !== nextProjectPath) {
return;
}
agentRuntimeResumeProjectPathRef.current = nextProjectPath;
for (const runtimeResult of resumedRuntimes) {
rememberAgentRuntimeState(
agentRuntimeStateFromResult(runtimeResult),
);
}
setAgentRunStatus('已确认恢复 Agent Runtime 任务');
})
.catch((resumeError) => {
if (localProjectPathRef.current !== nextProjectPath) {
return;
}
agentRuntimeResumeProjectPathRef.current = null;
setAgentRunStatus(
`Agent Runtime 恢复失败:${
resumeError instanceof Error
? resumeError.message
: String(resumeError)
}`,
);
});
},
);
setAgentRunStatus('等待确认恢复 Agent Runtime 任务');
} else if (message.includes('项目权限策略拒绝执行:agent.resume')) {
agentRuntimeResumeProjectPathRef.current = nextProjectPath;
markProjectPolicyDenied('agent.resume', message);
} else {
agentRuntimeResumeProjectPathRef.current = null;
setAgentRunStatus(`Agent Runtime 恢复失败:${message}`);
}
}
}
const runtimes = await invoke<AgentRuntimeResult[]>(
'read_game_creator_agent_runtimes',
{ projectPath: nextProjectPath },
);
if (localProjectPathRef.current !== nextProjectPath) {
return;
}
const nextRuntimeById: Record<string, AgentRuntimeState | undefined> =
{};
for (const runtimeResult of runtimes) {
@@ -21194,7 +21438,9 @@ export function App() {
}
setAgentRuntimeById(nextRuntimeById);
} catch {
setAgentRuntimeById({});
if (localProjectPathRef.current === nextProjectPath) {
setAgentRuntimeById({});
}
}
}
@@ -1063,6 +1063,124 @@ describe('AI 游戏创作 App 界面边界', () => {
});
});
it('asks before recovering interrupted runtime tasks in the developer Agent chat', async () => {
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'resume_game_creator_agent_runtime_tasks') {
throw new Error('项目权限策略要求用户确认:agent.resume');
}
if (command === 'confirm_resume_game_creator_agent_runtime_tasks') {
return [];
}
if (command === 'read_local_conversation') {
return {
path: '/tmp/authorized-game/.agent/conversations/agents/design-director.jsonl',
agentId: args?.agentId,
messages: [],
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
renderLauncherAgentChatAt('/?agent-chat');
fireEvent.change(screen.getByLabelText('Agent 聊天项目目录'), {
target: { value: '/tmp/authorized-game' },
});
fireEvent.click(screen.getByRole('button', { name: '读取历史' }));
const detail = await screen.findByText(
'恢复 /tmp/authorized-game 中未完成的 Agent Runtime 任务',
);
const confirmation = detail.closest('.pending-command');
expect(confirmation).not.toBeNull();
expect(screen.getByText('agent.resume')).not.toBeNull();
expect(
invoke.mock.calls.filter(
([command]) =>
command === 'confirm_resume_game_creator_agent_runtime_tasks',
),
).toHaveLength(0);
fireEvent.click(
within(confirmation as HTMLElement).getByRole('button', {
name: '确认',
}),
);
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith(
'confirm_resume_game_creator_agent_runtime_tasks',
{ projectPath: '/tmp/authorized-game' },
);
expect(
screen.queryByText(
'恢复 /tmp/authorized-game 中未完成的 Agent Runtime 任务',
),
).toBeNull();
expect(screen.getByText('已确认恢复 Agent Runtime 任务')).not.toBeNull();
});
});
it('does not reload an old developer Agent project after recovery returns late', async () => {
let resolveConfirmedResume: ((value: unknown[]) => void) | null = null;
const confirmedResume = new Promise<unknown[]>((resolve) => {
resolveConfirmedResume = resolve;
});
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'resume_game_creator_agent_runtime_tasks') {
throw new Error('项目权限策略要求用户确认:agent.resume');
}
if (command === 'confirm_resume_game_creator_agent_runtime_tasks') {
return confirmedResume;
}
if (command === 'read_local_conversation') {
return {
path: `${String(args?.projectPath ?? '')}/.agent/conversations/agents/design-director.jsonl`,
agentId: args?.agentId,
messages: [],
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
renderLauncherAgentChatAt('/?agent-chat');
fireEvent.change(screen.getByLabelText('Agent 聊天项目目录'), {
target: { value: '/tmp/project-a' },
});
fireEvent.click(screen.getByRole('button', { name: '读取历史' }));
const detail = await screen.findByText(
'恢复 /tmp/project-a 中未完成的 Agent Runtime 任务',
);
fireEvent.click(
within(detail.closest('.pending-command') as HTMLElement).getByRole(
'button',
{ name: '确认' },
),
);
fireEvent.change(screen.getByLabelText('Agent 聊天项目目录'), {
target: { value: '/tmp/project-b' },
});
await act(async () => {
resolveConfirmedResume?.([]);
await confirmedResume;
});
expect(screen.getAllByText('请选择并读取会话').length).toBeGreaterThan(0);
expect(
invoke.mock.calls.filter(
([command, args]) =>
command === 'read_local_conversation' &&
args?.projectPath === '/tmp/project-a',
),
).toHaveLength(1);
});
it('creates, switches, archives, and isolates developer Agent sessions', async () => {
type SessionRecord = {
sessionId: string;
@@ -14029,6 +14147,285 @@ describe('AI 游戏创作 App 界面边界', () => {
expect(screen.queryByText('旧 Agent 历史消息')).toBeNull();
});
it('asks for explicit confirmation before recovering runtime tasks under the default policy', async () => {
const manifest = createGameCreationAppManifest(
'local-project-draft',
'未命名游戏原型',
);
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'append_local_permission_log') {
return {};
}
if (command === 'init_local_game_project') {
const projectPath = String(args?.projectPath ?? '');
return {
projectPath,
manifestPath: `${projectPath}/.agent/manifest.json`,
manifest,
};
}
if (command === 'resume_game_creator_agent_runtime_tasks') {
throw new Error('项目权限策略要求用户确认:agent.resume');
}
if (command === 'confirm_resume_game_creator_agent_runtime_tasks') {
return [];
}
if (command === 'read_game_creator_agent_runtimes') {
return [];
}
if (command === 'read_project_permission_policy') {
return emptyProjectPolicy();
}
if (command === 'read_local_project_file') {
throw new Error(
'读取文件元数据失败:/tmp/authorized-game/.agent/run.latest.json: No such file or directory (os error 2)',
);
}
if (command === 'list_local_project_files') {
return { projectPath: String(args?.projectPath ?? ''), files: [] };
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game');
const detail = await screen.findByText(
'恢复 /tmp/authorized-game 中未完成的 Agent Runtime 任务',
);
expect(screen.getByText(/run:/).textContent).toContain(
'等待确认恢复 Agent Runtime 任务',
);
const confirmation = detail.closest('.pending-command');
expect(confirmation).not.toBeNull();
expect(
invoke.mock.calls.filter(
([command]) => command === 'confirm_resume_game_creator_agent_runtime_tasks',
),
).toHaveLength(0);
fireEvent.click(
within(confirmation as HTMLElement).getByRole('button', { name: '确认' }),
);
await waitFor(() => {
expect(
invoke.mock.calls.filter(
([command]) => command === 'confirm_resume_game_creator_agent_runtime_tasks',
),
).toHaveLength(1);
expect(screen.queryByText(
'恢复 /tmp/authorized-game 中未完成的 Agent Runtime 任务',
)).toBeNull();
expect(screen.getByText(/run:/).textContent).toContain(
'已确认恢复 Agent Runtime 任务',
);
});
});
it('cancels a stale runtime recovery confirmation when the project changes', async () => {
const manifest = createGameCreationAppManifest(
'local-project-draft',
'未命名游戏原型',
);
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'append_local_permission_log') {
return {};
}
if (command === 'init_local_game_project') {
const projectPath = String(args?.projectPath ?? '');
return {
projectPath,
manifestPath: `${projectPath}/.agent/manifest.json`,
manifest,
};
}
if (command === 'resume_game_creator_agent_runtime_tasks') {
throw new Error('项目权限策略要求用户确认:agent.resume');
}
if (command === 'confirm_resume_game_creator_agent_runtime_tasks') {
return [];
}
if (command === 'read_game_creator_agent_runtimes') {
return [];
}
if (command === 'read_project_permission_policy') {
return emptyProjectPolicy();
}
if (command === 'read_local_conversation') {
return {
path: `${String(args?.projectPath ?? '')}/.agent/conversations/project.jsonl`,
agentId: null,
messages: [],
};
}
if (command === 'read_local_project_file') {
throw new Error('run trace 不存在');
}
if (command === 'list_local_project_files') {
return { projectPath: String(args?.projectPath ?? ''), files: [] };
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
renderAppAt('/?dev');
fireEvent.change(screen.getByLabelText('本地项目目录'), {
target: { value: '/tmp/project-a' },
});
fireEvent.click(screen.getByRole('button', { name: '初始化' }));
const createA = screen
.getByText('创建 /tmp/project-a')
.closest('.pending-command');
fireEvent.click(
within(createA as HTMLElement).getByRole('button', { name: '确认' }),
);
expect(
await screen.findByText(
'恢复 /tmp/project-a 中未完成的 Agent Runtime 任务',
),
).not.toBeNull();
fireEvent.change(screen.getByLabelText('本地项目目录'), {
target: { value: '/tmp/project-b' },
});
fireEvent.click(screen.getByRole('button', { name: '初始化' }));
const createB = screen
.getByText('创建 /tmp/project-b')
.closest('.pending-command');
fireEvent.click(
within(createB as HTMLElement).getByRole('button', { name: '确认' }),
);
expect(
await screen.findByText(
'恢复 /tmp/project-b 中未完成的 Agent Runtime 任务',
),
).not.toBeNull();
expect(
screen.queryByText(
'恢复 /tmp/project-a 中未完成的 Agent Runtime 任务',
),
).toBeNull();
expect(
invoke.mock.calls.filter(
([command]) =>
command === 'confirm_resume_game_creator_agent_runtime_tasks',
),
).toHaveLength(0);
expect(invoke).toHaveBeenCalledWith(
'append_local_permission_log',
expect.objectContaining({
projectPath: '/tmp/project-a',
event: 'permission.cancel',
commandId: 'agent.resume',
}),
);
});
it('ignores delayed runtime recovery results from a previously opened project', async () => {
const manifest = createGameCreationAppManifest(
'local-project-draft',
'未命名游戏原型',
);
let resolveProjectAResume: ((value: unknown[]) => void) | null = null;
const projectAResume = new Promise<unknown[]>((resolve) => {
resolveProjectAResume = resolve;
});
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
const targetProjectPath = String(args?.projectPath ?? '');
if (command === 'append_local_permission_log') {
return {};
}
if (command === 'init_local_game_project') {
return {
projectPath: targetProjectPath,
manifestPath: `${targetProjectPath}/.agent/manifest.json`,
manifest,
};
}
if (command === 'resume_game_creator_agent_runtime_tasks') {
return targetProjectPath === '/tmp/project-a'
? projectAResume
: [];
}
if (command === 'read_game_creator_agent_runtimes') {
return [];
}
if (command === 'read_project_permission_policy') {
return emptyProjectPolicy();
}
if (command === 'read_local_conversation') {
return {
path: `${targetProjectPath}/.agent/conversations/project.jsonl`,
agentId: null,
messages: [],
};
}
if (command === 'read_local_project_file') {
throw new Error('run trace 不存在');
}
if (command === 'list_local_project_files') {
return { projectPath: targetProjectPath, files: [] };
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
renderAppAt('/?dev');
fireEvent.change(screen.getByLabelText('本地项目目录'), {
target: { value: '/tmp/project-a' },
});
fireEvent.click(screen.getByRole('button', { name: '初始化' }));
const createA = screen
.getByText('创建 /tmp/project-a')
.closest('.pending-command');
fireEvent.click(
within(createA as HTMLElement).getByRole('button', { name: '确认' }),
);
expect(await screen.findByText('已打开:/tmp/project-a')).not.toBeNull();
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith(
'resume_game_creator_agent_runtime_tasks',
{ projectPath: '/tmp/project-a' },
);
});
fireEvent.change(screen.getByLabelText('本地项目目录'), {
target: { value: '/tmp/project-b' },
});
fireEvent.click(screen.getByRole('button', { name: '初始化' }));
const createB = screen
.getByText('创建 /tmp/project-b')
.closest('.pending-command');
fireEvent.click(
within(createB as HTMLElement).getByRole('button', { name: '确认' }),
);
expect(await screen.findByText('已打开:/tmp/project-b')).not.toBeNull();
await act(async () => {
resolveProjectAResume?.([]);
await projectAResume;
});
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith('read_game_creator_agent_runtimes', {
projectPath: '/tmp/project-b',
});
});
expect(invoke).not.toHaveBeenCalledWith(
'read_game_creator_agent_runtimes',
{ projectPath: '/tmp/project-a' },
);
expect(screen.getByText('已打开:/tmp/project-b')).not.toBeNull();
});
it('shows runtime status and recent tasks in the main agent status list', async () => {
const manifest = createGameCreationAppManifest(
'local-project-draft',
@@ -4129,3 +4129,11 @@
- 决策补充:画布 Agent 侧边栏的“规范图 / 视觉规范图 / 风格规范图 / 素材规范展板”是 Agent 规划 prompt 和 function-calling 工具选择约束,不是侧边栏 UI 说明文案。此类请求默认走 `generate_image`,prompt 必须要求规范展板包含统一视角、线条粗细、色卡、材质、阴影、圆角、状态层级、尺寸标注等视觉规范元素;角色规范图若是规范展板也走 `generate_image`,只有实际角色立绘才走 `generate_character`,多个图标素材 / 图集才走 `generate_icon_spritesheet`
- 影响范围:`server-rs/crates/platform-agent``server-rs/crates/api-server/src/config.rs``src/services/llmClient.ts``.env.example``deploy/env/api-server.env.example``scripts/test-ve-llm.mjs`
- 验证方式:`npm run test -- src/services/llmClient.test.ts``cargo test -p api-server --manifest-path server-rs/Cargo.toml from_env_reads_non_public_models_and_urls app_state_builds_creative_agent_gpt5_client_from_vector_engine_settings llm_chat_completions editor_agent_llm_request_uses_vector_engine_chat_model``cargo test -p platform-agent --manifest-path server-rs/Cargo.toml``npm run check:encoding``git diff --check`
## 2026-07-10 AI 游戏创作 Agent Runtime 执行边界
- 决策:后台 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 默认工具列表必须直接从可执行白名单派生。
- 验证:Rust 覆盖首轮上下文不泄露、工具后 observation 可见、确认前后内容边界、前后台同 Agent 串行、前台结束后队列 drain、恢复确认 gate、预算耗尽失败和默认工具白名单一致性;前端分别覆盖主工作区和独立开发 Agent 聊天窗口的默认恢复确认条与显式恢复 command。
@@ -67,6 +67,10 @@ Agent Runtime 负责:
- 2026-07-10 补充:后台任务工具箱已加入 `agent.run_status`。Agent 可在 loop 中读取自己、目标 Agent 或一组 Agent 的 Runtime 状态摘要,判断同伴是否正在运行、最近任务和最近工具动作;Runtime 复用 `agent.run_status` 项目权限策略,策略要求确认或拒绝时不读取状态,observation 不返回 `.agent/runtime/*` 文件绝对路径。
- 2026-07-10 补充:后台任务工具箱已加入 `agent.delegate`。Agent 可在 loop 中把明确任务投递到另一个 Agent 的独立后台队列,复用目标 Agent 原有锁和 pending drain 语义;同一目标 Agent 串行,不同目标 Agent 可并行。该工具受 `agent.delegate` 策略保护,策略要求确认或拒绝时不会写目标对话、不会启动目标后台任务,也不会写 `agent.runtime.agent.delegate` 审计记录。
- 2026-07-10 补充:Runtime 增加 `resume_game_creator_agent_runtime_tasks` 恢复入口。客户端读取项目 Runtime 时会对每个项目路径最多自动尝试一次恢复;恢复命令必须通过 `agent.resume` 自动权限,默认需要确认或被拒绝时不会静默启动。恢复扫描 `.agent/runtime/tasks/<agentId>.jsonl` 中上一进程遗留的 `running` 或仍为 `pending` 的任务,同一 Agent 同时存在二者时先重接遗留 `running`,再由既有 drain 串行继续 `pending`;恢复动作写 `agent.runtime.background_task.recovered` 审计记录。该能力只是把本地 JSONL 队列重接到当前 App 进程,不是独立常驻 worker,也不承诺恢复已经发出的上游 LLM 请求。
- 2026-07-10 补充:后台 planning 与预算内 final reply 使用专用最小上下文,只预置 Agent 身份、sessionId、runId、执行模式和工具策略;Agent 私有记忆、项目记忆、黑板、对话、资产、项目索引与文件正文只能经对应工具通过权限 gate 后作为 observation 进入下一轮。普通前台聊天仍可使用角色上下文。长黑板、记忆和对话按尾部截断,确保最新结论与最新定向消息优先保留。
- 2026-07-10 补充:同一 Agent 的前台直接聊天、流式聊天和后台任务统一使用 `.agent/runtime/locks/<agentId>.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 观测与执行边界漂移。
- 任务图能力:每轮 Orchestrator agenda、ready / active task 选择、Evaluator 结构化返工路由、返工轮 carry-over。
- 记忆能力:短期记忆 `memory/session.md`、长期记忆 `memory/project.md`、项目级黑板 `memory/blackboard.md` 和角色私有记忆 `memory/agents/<group>/<role>.md`;黑板用于共享重要跨 agent 记忆,角色私有记忆只给对应角色 brief 读取和追加。最近 project / agent conversation 会作为短期 prompt 上下文读取,不替代正式 memory 文件。
- 对话能力:结构化对话记录统一落在 `.agent/conversations/` 的 append-only JSONL;普通聊天写 `.agent/conversations/project.jsonl`,进入单个 agent 后只写对应 `.agent/conversations/agents/<agentId>.jsonl`,不把原始对话混进项目黑板或角色私有记忆。