支持Agent待确认动作可靠恢复

持久化精确待确认工具动作并绑定run、输入指纹与发生序号

使用OS级Agent锁串行确认、拒绝、取消、恢复与队列续跑

增加取消中状态、开发确认拒绝控件和私有Runtime文件边界

补齐并发恢复、重启核对、取消失败与前端交互回归测试

同步Agent Runtime技术方案与项目决策记录
This commit is contained in:
AIGameCreator App
2026-07-10 12:24:54 +08:00
parent a5c85c912b
commit c88c94b98c
9 changed files with 3044 additions and 372 deletions
File diff suppressed because it is too large Load Diff
@@ -431,7 +431,7 @@ pub(crate) fn confirm_game_creator_agent_runtime_task(
project_path: String,
agent_id: String,
run_id: String,
next_run_id: String,
action_id: String,
note: String,
) -> Result<AgentRuntimeResult, String> {
let root = Path::new(project_path.trim());
@@ -444,7 +444,30 @@ pub(crate) fn confirm_game_creator_agent_runtime_task(
root,
agent_id.trim(),
run_id.trim(),
next_run_id.trim(),
action_id.trim(),
note.trim(),
)
}
#[tauri::command]
pub(crate) fn reject_game_creator_agent_runtime_task(
project_path: String,
agent_id: String,
run_id: String,
action_id: String,
note: String,
) -> Result<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")?;
// 该命令只由开发者显式点击“拒绝并继续”触发。
enforce_project_permission_policy(root, "agent.resume")?;
reject_game_creator_agent_runtime_task_at(
root,
agent_id.trim(),
run_id.trim(),
action_id.trim(),
note.trim(),
)
}
@@ -178,6 +178,8 @@ struct AgentRuntimeState {
#[serde(default)]
recent_tool_calls: Vec<AgentRuntimeToolCallRecord>,
#[serde(default)]
pending_tool_action: Option<AgentRuntimePendingToolActionSummary>,
#[serde(default)]
task_queue: AgentRuntimeTaskQueueSummary,
#[serde(default)]
allowed_tools: Vec<String>,
@@ -239,6 +241,23 @@ struct AgentRuntimeToolCallRecord {
updated_at: u64,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct AgentRuntimePendingToolActionSummary {
#[serde(default)]
action_id: String,
#[serde(default)]
action_fingerprint: String,
#[serde(default)]
tool: String,
#[serde(default)]
input_summary: Option<String>,
#[serde(default)]
reason: Option<String>,
#[serde(default)]
requested_at: u64,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct AgentRuntimePlanStep {
@@ -1207,6 +1226,7 @@ fn main() {
cancel_game_creator_agent_runtime_task,
retry_game_creator_agent_runtime_task,
confirm_game_creator_agent_runtime_task,
reject_game_creator_agent_runtime_task,
read_game_creator_agent_runtime,
read_game_creator_agent_runtimes,
resume_game_creator_agent_runtime_tasks,
@@ -674,6 +674,9 @@ pub(crate) fn list_local_project_files_at(
let path = entry.path();
let relative_path = relative_project_path(root, &path)?;
if is_agent_runtime_private_control_path(&relative_path) {
continue;
}
let metadata = entry.metadata().map_err(|error| {
format!("读取文件元数据失败:{}: {error}", entry.path().display())
})?;
@@ -715,6 +718,7 @@ pub(crate) fn read_local_project_file_at(
relative_path: &str,
) -> Result<LocalProjectFileResult, String> {
let normalized_path = normalize_relative_path(relative_path)?;
reject_agent_runtime_private_control_path(&normalized_path)?;
reject_sensitive_project_file_read(&normalized_path)?;
let path = resolve_local_project_path(root, &normalized_path)?;
let metadata = fs::metadata(&path)
@@ -732,6 +736,19 @@ pub(crate) fn read_local_project_file_at(
})
}
fn is_agent_runtime_private_control_path(normalized_path: &str) -> bool {
let mut parts = normalized_path.split('/');
matches!(parts.next(), Some(part) if part.eq_ignore_ascii_case(".agent"))
&& matches!(parts.next(), Some(part) if part.eq_ignore_ascii_case("runtime"))
}
fn reject_agent_runtime_private_control_path(normalized_path: &str) -> Result<(), String> {
if is_agent_runtime_private_control_path(normalized_path) {
return Err("Agent Runtime 私有控制面不可通过通用文件工具访问".to_string());
}
Ok(())
}
pub(crate) fn reject_sensitive_project_file_read(normalized_path: &str) -> Result<(), String> {
for part in normalized_path.split('/') {
let lower = part.to_ascii_lowercase();
@@ -756,7 +773,9 @@ pub(crate) fn write_local_project_file_at(
relative_path: &str,
content: &str,
) -> Result<LocalProjectFileMutationResult, String> {
let path = resolve_local_project_path(root, relative_path)?;
let normalized_path = normalize_relative_path(relative_path)?;
reject_agent_runtime_private_control_path(&normalized_path)?;
let path = resolve_local_project_path(root, &normalized_path)?;
if path.exists() && !path.is_file() {
return Err("只能写入文件".to_string());
}
@@ -768,7 +787,7 @@ pub(crate) fn write_local_project_file_at(
.map_err(|error| format!("写入项目文件失败:{}: {error}", path.display()))?;
Ok(LocalProjectFileMutationResult {
path: normalize_relative_path(relative_path)?,
path: normalized_path,
absolute_path: path.to_string_lossy().into_owned(),
deleted: false,
})
@@ -778,10 +797,12 @@ pub(crate) fn delete_local_project_file_at(
root: &Path,
relative_path: &str,
) -> Result<LocalProjectFileMutationResult, String> {
let path = resolve_local_project_path(root, relative_path)?;
let normalized_path = normalize_relative_path(relative_path)?;
reject_agent_runtime_private_control_path(&normalized_path)?;
let path = resolve_local_project_path(root, &normalized_path)?;
if !path.exists() {
return Ok(LocalProjectFileMutationResult {
path: normalize_relative_path(relative_path)?,
path: normalized_path,
absolute_path: path.to_string_lossy().into_owned(),
deleted: false,
});
@@ -793,7 +814,7 @@ pub(crate) fn delete_local_project_file_at(
.map_err(|error| format!("删除项目文件失败:{}: {error}", path.display()))?;
Ok(LocalProjectFileMutationResult {
path: normalize_relative_path(relative_path)?,
path: normalized_path,
absolute_path: path.to_string_lossy().into_owned(),
deleted: true,
})
File diff suppressed because it is too large Load Diff
+273 -14
View File
@@ -249,6 +249,7 @@ interface AgentRuntimeState {
activePlanStepIndex?: number | null;
observations: string[];
recentToolCalls?: AgentRuntimeToolCallRecord[];
pendingToolAction?: AgentRuntimePendingToolActionSummary | null;
taskQueue?: AgentRuntimeTaskQueueSummary;
allowedTools: string[];
toolPolicy?: AgentRuntimeToolPolicySnapshot;
@@ -278,6 +279,15 @@ interface AgentRuntimeToolCallRecord {
updatedAt: number;
}
interface AgentRuntimePendingToolActionSummary {
actionId: string;
actionFingerprint: string;
tool: string;
inputSummary: string | null;
reason: string | null;
requestedAt: number;
}
interface AgentRuntimePlanStep {
index: number;
title: string;
@@ -662,6 +672,8 @@ function agentRuntimeWaitingOnFromPhase(phase: string) {
return '工具观察结果';
case 'waiting-for-confirmation':
return '开发者确认 Agent 工具动作';
case 'cancelling':
return '当前 LLM 或工具调用返回';
case 'response':
return 'Agent 整理最终回复';
case 'completed':
@@ -684,6 +696,8 @@ function agentRuntimeNextStepFromPhase(phase: string) {
return '等待工具观察结果';
case 'waiting-for-confirmation':
return '等待开发者确认工具动作';
case 'cancelling':
return '取消完成后可重试该任务或提交新任务';
case 'response':
return '等待 Agent 整理最终回复';
case 'completed':
@@ -785,13 +799,17 @@ function AgentRuntimeStatusPanel({
onCancelRuntimeTask,
onRetryRuntimeTask,
onConfirmRuntimeTask,
onRejectRuntimeTask,
onRefreshRuntime,
}: {
runtime: AgentRuntimeState | null;
error?: string | null;
controlBusy?: boolean;
onCancelRuntimeTask?: (runId: string) => void;
onRetryRuntimeTask?: (runId: string) => void;
onConfirmRuntimeTask?: (runId: string) => void;
onConfirmRuntimeTask?: (runId: string, actionId: string) => void;
onRejectRuntimeTask?: (runId: string, actionId: string) => void;
onRefreshRuntime?: () => void;
}) {
if (!runtime && error) {
return (
@@ -800,6 +818,11 @@ function AgentRuntimeStatusPanel({
<strong>Runtime </strong>
</header>
<p>{error}</p>
{onRefreshRuntime ? (
<button type="button" disabled={controlBusy} onClick={onRefreshRuntime}>
</button>
) : null}
</section>
);
}
@@ -818,41 +841,70 @@ function AgentRuntimeStatusPanel({
const nextStep = runtime.nextStep ?? agentRuntimeNextStepFromPhase(runtime.phase);
const currentGoal = runtime.currentGoal ?? runtime.currentTask;
const waitingOn = runtime.waitingOn ?? agentRuntimeWaitingOnFromPhase(runtime.phase);
const pendingToolAction = runtime.pendingToolAction ?? null;
const canCancel =
Boolean(runtime.runId) &&
agentRuntimeCanCancel(runtime.status) &&
(agentRuntimeCanCancel(runtime.status) ||
(runtime.phase === 'needs-reconciliation' && Boolean(pendingToolAction))) &&
Boolean(onCancelRuntimeTask);
const canRetry =
Boolean(runtime.runId) &&
agentRuntimeCanRetry(runtime.status) &&
!pendingToolAction &&
Boolean(onRetryRuntimeTask);
const canConfirm =
Boolean(runtime.runId) &&
Boolean(pendingToolAction?.actionId) &&
agentRuntimeCanConfirm(runtime.status) &&
Boolean(onConfirmRuntimeTask);
const canReject =
Boolean(runtime.runId) &&
Boolean(pendingToolAction?.actionId) &&
agentRuntimeCanConfirm(runtime.status) &&
Boolean(onRejectRuntimeTask);
return (
<section className="agent-runtime-status" aria-label="Agent Runtime 状态">
<header>
<strong>{`${runtime.status} / ${runtime.phase}`}</strong>
<small>{runtime.sessionId}</small>
</header>
{onCancelRuntimeTask || onRetryRuntimeTask || onConfirmRuntimeTask ? (
{onCancelRuntimeTask ||
onRetryRuntimeTask ||
onConfirmRuntimeTask ||
onRejectRuntimeTask ||
onRefreshRuntime ? (
<div className="agent-runtime-actions" aria-label="Agent Runtime 操作">
<button
type="button"
disabled={controlBusy || !canConfirm}
onClick={() =>
runtime.runId && onConfirmRuntimeTask?.(runtime.runId)
runtime.runId &&
pendingToolAction?.actionId &&
onConfirmRuntimeTask?.(
runtime.runId,
pendingToolAction.actionId,
)
}
>
</button>
<button
type="button"
disabled={controlBusy || !canReject}
onClick={() =>
runtime.runId &&
pendingToolAction?.actionId &&
onRejectRuntimeTask?.(runtime.runId, pendingToolAction.actionId)
}
>
</button>
<button
type="button"
disabled={controlBusy || !canCancel}
onClick={() => runtime.runId && onCancelRuntimeTask?.(runtime.runId)}
>
</button>
<button
type="button"
@@ -861,6 +913,13 @@ function AgentRuntimeStatusPanel({
>
</button>
<button
type="button"
disabled={controlBusy || !onRefreshRuntime}
onClick={() => onRefreshRuntime?.()}
>
</button>
</div>
) : null}
<small>{`task: ${runtime.taskId} · ${runtime.source}`}</small>
@@ -870,6 +929,21 @@ function AgentRuntimeStatusPanel({
<small>{runtime.currentAction}</small>
{waitingOn ? <small>{`等待:${waitingOn}`}</small> : null}
{nextStep ? <small>{`下一步:${nextStep}`}</small> : null}
{pendingToolAction ? (
<small>
{`${
runtime.phase === 'needs-reconciliation'
? '待核对动作'
: '待确认动作'
}${pendingToolAction.tool}${
pendingToolAction.inputSummary
? ` · ${pendingToolAction.inputSummary}`
: ''
}`}
</small>
) : runtime.status === 'waiting-for-confirmation' ? (
<small></small>
) : null}
{loopProgress ? <small>{loopProgress}</small> : null}
{taskQueueSummary ? <small>{taskQueueSummary}</small> : null}
{toolPolicy ? (
@@ -3937,10 +4011,19 @@ export function WorkspaceLauncher({
}
}
async function handleAgentChatConfirmRuntimeTask(runId: string) {
async function handleAgentChatConfirmRuntimeTask(
runId: string,
actionId: string,
) {
const projectPathForChat = validateAgentChatProjectPath();
const agent = selectedLauncherAgentChatAgent();
if (!projectPathForChat || !agent || !runId || agentChatBackgroundBusy) {
if (
!projectPathForChat ||
!agent ||
!runId ||
!actionId ||
agentChatBackgroundBusy
) {
return;
}
const llmWarning = getCurrentAgentChatLlmWarning(agent);
@@ -3964,7 +4047,7 @@ export function WorkspaceLauncher({
projectPath: projectPathForChat,
agentId: agent.id,
runId,
nextRunId: createAgentChatRunId('launcher-agent-confirm'),
actionId,
note: '开发者已确认待执行工具动作',
},
);
@@ -3997,6 +4080,75 @@ export function WorkspaceLauncher({
}
}
async function handleAgentChatRejectRuntimeTask(
runId: string,
actionId: string,
) {
const projectPathForChat = validateAgentChatProjectPath();
const agent = selectedLauncherAgentChatAgent();
if (
!projectPathForChat ||
!agent ||
!runId ||
!actionId ||
agentChatBackgroundBusy
) {
return;
}
const llmWarning = getCurrentAgentChatLlmWarning(agent);
if (llmWarning) {
setAgentChatStatus(llmWarning);
return;
}
const invoke = resolveTauriInvoke();
if (!invoke) {
setAgentChatStatus('需要在 Tauri App 内运行');
return;
}
const saveVersion = agentChatLoadVersionRef.current + 1;
agentChatLoadVersionRef.current = saveVersion;
setAgentChatBackgroundBusy(true);
setAgentChatStatus('正在拒绝工具动作并继续 Agent 后台任务');
try {
const runtime = await invoke<AgentRuntimeResult>(
'reject_game_creator_agent_runtime_task',
{
projectPath: projectPathForChat,
agentId: agent.id,
runId,
actionId,
note: '开发者拒绝待执行工具动作',
},
);
if (agentChatLoadVersionRef.current !== saveVersion) {
return;
}
setAgentChatRuntime(agentRuntimeStateFromResult(runtime));
setAgentChatRuntimeError('');
const conversation = await invoke<LocalConversationResult>(
'read_local_conversation',
{
projectPath: projectPathForChat,
agentId: agent.id,
},
);
if (agentChatLoadVersionRef.current !== saveVersion) {
return;
}
setAgentChatMessages(conversation.messages);
setAgentChatStatus(agentRuntimeStartStatus(runtime));
} catch (error) {
if (agentChatLoadVersionRef.current !== saveVersion) {
return;
}
setAgentChatStatus(error instanceof Error ? error.message : String(error));
} finally {
if (agentChatLoadVersionRef.current === saveVersion) {
setAgentChatBackgroundBusy(false);
}
}
}
const projectRows = recentWorkspaces.map((workspace) => {
const directoryStatus = recentWorkspaceStatuses[workspace];
const isPendingStatus = directoryStatus === undefined;
@@ -4671,9 +4823,13 @@ export function WorkspaceLauncher({
onRetryRuntimeTask={(runId) =>
void handleAgentChatRetryRuntimeTask(runId)
}
onConfirmRuntimeTask={(runId) =>
void handleAgentChatConfirmRuntimeTask(runId)
onConfirmRuntimeTask={(runId, actionId) =>
void handleAgentChatConfirmRuntimeTask(runId, actionId)
}
onRejectRuntimeTask={(runId, actionId) =>
void handleAgentChatRejectRuntimeTask(runId, actionId)
}
onRefreshRuntime={() => void loadAgentChatConversation()}
/>
</section>
<div className="launcher-agent-chat-messages" aria-label="Agent 聊天记录">
@@ -13951,8 +14107,14 @@ export function App() {
async function confirmSelectedAgentRuntimeTask(
agent: AgentStatusCard,
runId: string,
actionId: string,
) {
if (!agent || !runId || agentConversationBackgroundBusyRef.current) {
if (
!agent ||
!runId ||
!actionId ||
agentConversationBackgroundBusyRef.current
) {
return;
}
const invoke = resolveTauriInvoke();
@@ -13981,7 +14143,7 @@ export function App() {
projectPath: nextProjectPath,
agentId: agent.id,
runId,
nextRunId: createAgentChatRunId('agent-background-confirm'),
actionId,
note: '开发者已确认待执行工具动作',
},
);
@@ -14021,6 +14183,85 @@ export function App() {
}
}
async function rejectSelectedAgentRuntimeTask(
agent: AgentStatusCard,
runId: string,
actionId: string,
) {
if (
!agent ||
!runId ||
!actionId ||
agentConversationBackgroundBusyRef.current
) {
return;
}
const invoke = resolveTauriInvoke();
const nextProjectPath = resolveChatProjectPath(localProject);
if (!invoke) {
setAgentConversationStatus('需要在 Tauri App 内运行');
return;
}
if (!nextProjectPath) {
setAgentConversationStatus('请先初始化本地项目');
return;
}
const llmWarning = formatAgentLlmConfigWarning(llmConfigStatus, agent);
if (llmWarning) {
setAgentConversationStatus(llmWarning);
return;
}
const saveVersion = agentConversationLoadVersionRef.current;
agentConversationBackgroundBusyRef.current = true;
setAgentConversationBackgroundBusy(true);
setAgentConversationStatus('正在拒绝工具动作并继续 Agent 后台任务');
try {
const runtime = await invoke<AgentRuntimeResult>(
'reject_game_creator_agent_runtime_task',
{
projectPath: nextProjectPath,
agentId: agent.id,
runId,
actionId,
note: '开发者拒绝待执行工具动作',
},
);
if (agentConversationLoadVersionRef.current !== saveVersion) {
return;
}
const nextRuntime = agentRuntimeStateFromResult(runtime);
setAgentConversationRuntime(nextRuntime);
rememberAgentRuntimeState(nextRuntime);
setAgentConversationRuntimeError('');
const conversation = await invoke<LocalConversationResult>(
'read_local_conversation',
{
projectPath: nextProjectPath,
agentId: agent.id,
},
);
if (agentConversationLoadVersionRef.current !== saveVersion) {
return;
}
setAgentConversationMessages(conversation.messages);
setAgentConversationStatus(agentRuntimeStartStatus(runtime));
setCommandLog((current) => [
...current,
'agent.runtime.tool_confirmation.rejected',
]);
} catch (error) {
if (agentConversationLoadVersionRef.current !== saveVersion) {
return;
}
setAgentConversationStatus(
error instanceof Error ? error.message : String(error),
);
} finally {
agentConversationBackgroundBusyRef.current = false;
setAgentConversationBackgroundBusy(false);
}
}
async function saveSelectedAgentPrivateMemory(
agent: AgentStatusCard,
content: string,
@@ -21489,9 +21730,27 @@ export function App() {
? void retrySelectedAgentRuntimeTask(selectedAgent, runId)
: undefined
}
onConfirmRuntimeTask={(runId) =>
onConfirmRuntimeTask={(runId, actionId) =>
selectedAgent
? void confirmSelectedAgentRuntimeTask(selectedAgent, runId)
? void confirmSelectedAgentRuntimeTask(
selectedAgent,
runId,
actionId,
)
: undefined
}
onRejectRuntimeTask={(runId, actionId) =>
selectedAgent
? void rejectSelectedAgentRuntimeTask(
selectedAgent,
runId,
actionId,
)
: undefined
}
onRefreshRuntime={() =>
selectedAgent
? void openAgentConversation(selectedAgent, true, true)
: undefined
}
/>
@@ -1670,7 +1670,7 @@ describe('AI 游戏创作 App 界面边界', () => {
const runtimeActions = screen.getByLabelText('Agent Runtime 操作');
expect(
(within(runtimeActions).getByRole('button', {
name: '取消',
name: '取消任务',
}) as HTMLButtonElement).disabled,
).toBe(false);
expect(
@@ -1735,6 +1735,52 @@ describe('AI 游戏创作 App 界面边界', () => {
},
]);
const cancellingRuntimeState = {
...runningRuntimeState,
status: 'cancelling',
phase: 'cancelling',
currentAction: '正在取消 Agent 后台任务',
waitingOn: '当前 LLM 或工具调用返回',
nextStep: '取消完成后可重试该任务或提交新任务',
};
await act(async () => {
runtimeUpdateHandler?.({
payload: {
projectPath: '/tmp/authorized-game',
agentId: 'design-director',
runId: 'launcher-agent-task-test',
status: 'cancelling',
phase: 'cancelling',
runtime: {
state: cancellingRuntimeState,
sessionPath:
'/tmp/authorized-game/.agent/runtime/agents/design-director.json',
eventPath:
'/tmp/authorized-game/.agent/runtime/events/design-director.jsonl',
taskPath:
'/tmp/authorized-game/.agent/runtime/tasks/design-director.jsonl',
taskQueue: cancellingRuntimeState.taskQueue,
recentEvents: runningRuntimeEvents,
recentTasks: [runningRuntimeTask],
},
},
});
});
expect(await screen.findByText('cancelling / cancelling')).not.toBeNull();
expect(screen.getByText('等待:当前 LLM 或工具调用返回')).not.toBeNull();
const cancellingActions = screen.getByLabelText('Agent Runtime 操作');
expect(
(within(cancellingActions).getByRole('button', {
name: '取消任务',
}) as HTMLButtonElement).disabled,
).toBe(true);
expect(
(within(cancellingActions).getByRole('button', {
name: '重试',
}) as HTMLButtonElement).disabled,
).toBe(true);
await act(async () => {
runtimeUpdateHandler?.({
payload: {
@@ -1784,7 +1830,7 @@ describe('AI 游戏创作 App 界面边界', () => {
).not.toBeNull();
});
it('confirms the exact pending tool action from the developer agent window', async () => {
it('confirms or rejects the exact pending tool action from the developer agent window', async () => {
const waitingRuntimeState = {
schemaVersion: 'game-creator-agent-runtime.v1',
agentId: 'design-director',
@@ -1816,6 +1862,14 @@ describe('AI 游戏创作 App 界面边界', () => {
updatedAt: 5000,
},
],
pendingToolAction: {
actionId: `action-${'b'.repeat(24)}`,
actionFingerprint: 'b'.repeat(64),
tool: 'file.read',
inputSummary: 'path=game/notes.txt',
reason: '读取角色规范依据',
requestedAt: 5000,
},
taskQueue: {
total: 1,
pending: 0,
@@ -1895,7 +1949,7 @@ describe('AI 游戏创作 App 界面边界', () => {
return runtimeResult;
}
if (command === 'confirm_game_creator_agent_runtime_task') {
const runId = String(args?.nextRunId ?? 'launcher-agent-confirmed');
const runId = waitingRuntimeState.runId;
const taskQueue = {
...waitingRuntimeState.taskQueue,
running: 1,
@@ -1908,9 +1962,10 @@ describe('AI 游戏创作 App 界面边界', () => {
...waitingRuntimeState,
runId,
status: 'running',
phase: 'planning',
currentAction: '生成 Agent 工具计划(第 1 轮)',
waitingOn: 'Agent 输出计划或回复',
phase: 'action',
currentAction: '执行已确认工具 file.read',
waitingOn: '已确认工具执行结果',
pendingToolAction: null,
taskQueue,
},
taskQueue,
@@ -1919,7 +1974,38 @@ describe('AI 游戏创作 App 界面边界', () => {
...waitingTask,
runId,
status: 'running',
phase: 'planning',
phase: 'action',
},
],
};
}
if (command === 'reject_game_creator_agent_runtime_task') {
const runId = waitingRuntimeState.runId;
const taskQueue = {
...waitingRuntimeState.taskQueue,
running: 1,
waitingForConfirmation: 0,
latestRunId: runId,
};
return {
...runtimeResult,
state: {
...waitingRuntimeState,
runId,
status: 'running',
phase: 'observation',
currentAction: '开发者拒绝工具 file.read',
waitingOn: 'Agent 根据拒绝结果修正计划',
pendingToolAction: null,
taskQueue,
},
taskQueue,
recentTasks: [
{
...waitingTask,
runId,
status: 'running',
phase: 'observation',
},
],
};
@@ -1945,12 +2031,38 @@ describe('AI 游戏创作 App 界面边界', () => {
'file.read · waiting-for-confirmation · 项目权限策略要求用户确认:file.read · 目标:path=game/notes.txt · 读取角色规范依据',
),
).not.toBeNull();
expect(
screen.getByText('待确认动作:file.read · path=game/notes.txt'),
).not.toBeNull();
const runtimeActions = screen.getByLabelText('Agent Runtime 操作');
const confirmButton = within(runtimeActions).getByRole('button', {
name: '确认继续',
}) as HTMLButtonElement;
expect(confirmButton.disabled).toBe(false);
fireEvent.click(confirmButton);
expect(
(within(runtimeActions).getByRole('button', {
name: '拒绝并继续',
}) as HTMLButtonElement).disabled,
).toBe(false);
const runtimeReadsBeforeRefresh = invoke.mock.calls.filter(
([command]) => command === 'read_game_creator_agent_runtime',
).length;
fireEvent.click(
within(runtimeActions).getByRole('button', { name: '刷新状态' }),
);
await waitFor(() => {
expect(
invoke.mock.calls.filter(
([command]) => command === 'read_game_creator_agent_runtime',
).length,
).toBeGreaterThan(runtimeReadsBeforeRefresh);
});
const refreshedRuntimeActions = screen.getByLabelText('Agent Runtime 操作');
const refreshedConfirmButton = within(refreshedRuntimeActions).getByRole(
'button',
{ name: '确认继续' },
);
fireEvent.click(refreshedConfirmButton);
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith(
@@ -1959,11 +2071,45 @@ describe('AI 游戏创作 App 界面边界', () => {
projectPath: '/tmp/authorized-game',
agentId: 'design-director',
runId: 'launcher-agent-waiting',
actionId: `action-${'b'.repeat(24)}`,
note: '开发者已确认待执行工具动作',
}),
);
});
expect(await screen.findByText('running / planning')).not.toBeNull();
expect(await screen.findByText('running / action')).not.toBeNull();
cleanup();
invoke.mockClear();
window.__TAURI__ = { core: { invoke } };
renderLauncherAgentChatAt('/?agent-chat');
fireEvent.change(screen.getByLabelText('Agent 聊天项目目录'), {
target: { value: '/tmp/authorized-game' },
});
fireEvent.click(screen.getByRole('button', { name: '读取历史' }));
expect(
await screen.findByText(
'waiting-for-confirmation / waiting-for-confirmation',
),
).not.toBeNull();
const rejectRuntimeActions = screen.getByLabelText('Agent Runtime 操作');
fireEvent.click(
within(rejectRuntimeActions).getByRole('button', {
name: '拒绝并继续',
}),
);
await waitFor(() => {
expect(invoke).toHaveBeenCalledWith(
'reject_game_creator_agent_runtime_task',
expect.objectContaining({
projectPath: '/tmp/authorized-game',
agentId: 'design-director',
runId: 'launcher-agent-waiting',
actionId: `action-${'b'.repeat(24)}`,
note: '开发者拒绝待执行工具动作',
}),
);
});
expect(await screen.findByText('running / observation')).not.toBeNull();
});
it('shows queued developer agent background tasks when the agent is already running', async () => {
@@ -19,7 +19,7 @@
## 2026-07-09 AI 游戏创作 App Runtime V1 增加单 Agent 后台任务
- 背景:开发用单 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/<agentId>.jsonl`,随后在 App 进程内启动 tokio task 执行最小 Agent loopAgent 按轮输出 `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 的独立后台队列;策略要求确认或拒绝时不执行写入、运行或委派,只把策略结果作为 observation 回给 Agent。每个 Agent 的任务历史落在 `.agent/runtime/tasks/<agentId>.jsonl`,读 runtime 时按 `runId` 去重返回最近任务,任务视角状态使用 `pending / running / completed / failed`Runtime state 增加 `nextStep`UI 在 Runtime 面板和主 Agent 状态卡展示当前任务、动作、下一步与最近任务。不同 Agent 使用独立 `.agent/runtime/locks/<agentId>.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/<agentId>.jsonl`,随后在 App 进程内启动 tokio task 执行最小 Agent loopAgent 按轮输出 `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/<agentId>.jsonl`,读 runtime 时按 `runId` 去重返回最近任务,任务视角状态使用 `pending / running / completed / failed`Runtime state 增加 `nextStep`UI 在 Runtime 面板和主 Agent 状态卡展示当前任务、动作、下一步与最近任务。不同 Agent 使用独立 `.agent/runtime/locks/<agentId>.lock`,允许并行运行;同一 Agent 已有运行任务时,新任务会先进入该 Agent 的 pending 队列,当前 drain 持锁完成后串行继续下一条 pending。该能力仍不是独立 OS 进程或跨重启离线常驻 worker。
- 2026-07-10 补充:后台 Runtime 每次追加 `.agent/runtime/events/<agentId>.jsonl` 后会通过 Tauri `game-creator-agent-runtime-update` 事件广播当前 `AgentRuntimeResult`;开发单 Agent 聊天页、项目内 Agent 对话弹窗和主窗口 Agent 状态列表都只把该事件作为实时 UI 通知并复用前端 runtime 归一化合并,事实源仍是 `.agent/runtime/agents``events``tasks` 文件。
- 2026-07-10 补充:后台 Agent Runtime 的白名单工具继续扩到 `preview.start`,让 Agent 在完成写盘或静态自检后能按策略自行启动当前项目的 `127.0.0.1` 本地 HTTP 预览。该工具复用 `preview.start` 权限策略、项目写锁、共享 `PreviewRegistry`、manifest 预览状态、`.agent/logs/preview.log` 和 run trace 追加逻辑;写入 `.agent/agent.db` 的审计类型为 `agent.runtime.preview.start`。发给 LLM 的 observation 只包含 localhost URL 和端口,不包含用户项目绝对路径。
- 2026-07-10 补充:后台 Agent Runtime 的白名单工具继续扩到 `canvas.asset_generate`,让美术类 Agent 可在 loop 中自行请求生成首版美术素材。该工具读取 AppData / Tauri 配置中的 `editorApi`,复用 `canvas.asset_generate` 权限策略、项目写锁、External Editor API 生成和下载链路、manifest 资产登记以及 `canvas.asset_generate` 本地索引记录;另写 `agent.runtime.canvas.asset_generate` 记录到 `.agent/agent.db`,标明触发的 agent 与本地素材路径。API Key 不进入 prompt observation、manifest、agent.db 或日志;策略要求确认或拒绝时不会调用外部 API。
@@ -4066,11 +4066,12 @@
- 2026-07-10 调整:Agent Runtime state 新增 `toolPolicy`,从项目权限策略派生工具级 `allowedTools``autoTools``confirmTools``deniedTools`。后台 planning prompt 必须带入该快照,让 Agent 在规划阶段知道工具策略;执行阶段仍由 Runtime 白名单和项目权限 gate 决定。`blackboard.write` 继承 `memory.write` 策略,`agent.message` 继承 `conversation.write` 策略,`agent.delegate` 使用独立 `agent.delegate` 策略。
- 2026-07-10 调整:`.agent/policy.json` 支持 `agentPolicies`,用规范 Agent id 保存单个 Agent 的 `deniedCommands / confirmCommands`。Runtime 计算有效工具策略时把项目级策略和 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 工具命中确认策略时不再当作 `blocked` observation 继续收尾,而是把当前 Runtime 写成 `status/phase = waiting-for-confirmation``waitingOn` 固定为等待开发者确认工具动作,`recentToolCalls`、事件流、任务记录和 `taskQueue.waitingForConfirmation` 都保留该事实;同一 Agent 的后台 drain 暂停,不继续消费后续 pending 任务。命中拒绝策略仍使用 `blocked` observation 交回 Agent 修正计划。
- 2026-07-10 调整:Agent Runtime 后台任务支持按 Agent / runId 取消和重试。取消通过 `.agent/runtime/cancel/<agentId>/<runId>.json` 写入本地取消请求,并向任务 JSONL、事件流和 `agent.db` 追加 `cancelled` 审计;pending 任务被取消后不会被 drain 消费,running 任务会在当前 LLM 或工具调用返回后的检查点停止,不再继续执行工具或保存最终 assistant 回复。重试只能基于已有非 running / pending / waiting-for-confirmation 任务创建新的 run,并继续走 `agent.resume` 自动权限和同一 Agent 队列锁。
- 2026-07-10 调整:Agent Runtime 后台任务支持按 Agent / runId 取消和重试。取消通过 `.agent/runtime/cancel/<agentId>/<runId>.json` 写入本地取消请求;pending 任务被取消后不会被 drain 消费,running 任务在原 worker 仍持锁时只投影为 `cancelling`,必须等当前 LLM 或工具调用返回后的检查点真正停下,才由持锁 worker 向任务 JSONL、事件流和 `agent.db` 追加 `cancelled` 审计,不再继续执行工具或保存最终 assistant 回复。`cancelling` 期间禁止重试;重试只能基于已有非 running / pending / waiting-for-confirmation / cancelling 任务创建新的 run,并继续走 `agent.resume` 自动权限和同一 Agent 队列锁。
- 2026-07-10 调整:Agent Runtime 后台任务的 `runId` 是同一 Agent 任务历史的身份,不允许复用覆盖。`start_game_creator_agent_runtime_task``agent.delegate` 和 retry 进入后台队列前会读取该 Agent 全量 task JSONL 历史;若调用方传入的规范化 runId 已存在,Runtime 自动追加 `-dup-<timestamp>-<attempt>` 生成实际 runId。任务队列、delegate observation 和 `agent.db` 审计都必须使用实际 runId,避免 `latest_game_creator_agent_runtime_tasks` 按 runId 去重时折叠掉不同任务。
- 2026-07-10 调整:Agent Runtime 的 `memory.write scope=agent` 只能写当前 Agent 自己的私有记忆。若 action 指定其他 `agentId / targetAgentId`Runtime 返回 `blocked` observation,不写目标 Agent 私有记忆、不写 `agent.runtime.memory.write` 审计;跨 Agent 共享稳定结论必须走 `blackboard.write`,给单个 Agent 留上下文必须走 `agent.message`
- 2026-07-10 调整:Agent Runtime 和本地对话使用 append-only JSONL 作为事实源时,进程内必须按目标文件路径串行追加整行。`.agent/agent.db``.agent/conversations/**/*.jsonl``.agent/runtime/events/*.jsonl``.agent/runtime/tasks/*.jsonl``.agent/activity.jsonl``.agent/output.jsonl` 统一走共享追加 helper,避免多个后台 Agent 并行完成时 JSON record 与换行交错。
- 2026-07-10 调整:Agent Runtime 待确认工具动作支持确认后继续。开发者确认 `waiting-for-confirmation` run 时,Runtime 为新 run 写入一次性 `.agent/runtime/confirmations/<agentId>/<runId>/<commandId>.json` 票据并重新入队;票据同时保存工具名与输入 JSON 的 SHA-256 `actionFingerprint`,权限 gate 在 deny 之后、confirm 阶段只放行对应 Agent/run/command/fingerprint 一次,模型若把同一工具改成其他路径、checkpoint 或目标 Agent,旧票据立即失效并重新进入待确认。`recentToolCalls` 和确认审计只额外保存安全 `inputSummary`,例如相对路径、checkpoint id、目标 Agent 或内容字符数,不保存待写正文、消息正文、素材 prompt 或 API Key。开发窗口必须把 `onConfirmRuntimeTask` 接入真实“确认继续”按钮;该显式确认命令允许 `agent.resume` 处于 confirm,只继续服从 deny,自动重启恢复仍要求 `agent.resume` 为 auto。原 waiting run 追加 `completed/confirmed` 任务记录,避免队列长期显示等待确认;审计记录写 `agent.runtime.tool_confirmation.approved`
- 2026-07-10 调整:Agent Runtime 待确认工具动作改用 durable `AgentRuntimePendingToolAction`。Runtime 将精确 `action` 输入、当前 task/run、loop 轮次、action 序号、计划、已有 observations 与后续 loop 所需上下文先做敏感内容和项目绝对路径校验,再通过临时文件替换原子写入 `.agent/runtime/pending-actions/<agentId>/<runId>.json`;公共 runtime state 的 `pendingToolAction` 只暴露 `actionId / actionFingerprint / tool / inputSummary / reason / requestedAt` 安全摘要,完整输入不进入公共状态。`actionFingerprint` 绑定工具名、完整输入 JSON 与实际执行使用的 task context`actionId` 还绑定 run、loop、action 序号和 occurrence nonce,使同一 run 内输入相同的两次动作仍是两个不同发生。确认和拒绝都必须匹配 `runId + actionId`,Runtime 会重算指纹并与私有落盘动作及公共摘要交叉校验,不一致时失败关闭。确认通过后在同一 run 直接执行持久化的原 action,把真实 observation 接回后续 Agent loop,不创建新 run,也不让模型重复生成待确认动作;拒绝不执行工具,写入 `blocked` observation 后在同一 run 继续规划。待确认账本按 `pending-confirmation / approved / executing / observed-approved / observed-rejected` 迁移:重启时 `approved` 可恢复精确动作,已持久化 observation 可直接续 loop`executing` 表示外部副作用结果未知,Runtime 必须进入 `failed / needs-reconciliation` 并禁止自动重放,开发者核对项目状态后只能先取消原任务。waiting run、完整待确认动作和安全摘要均已落盘,App 重启不会越过该 run 去启动后续任务;等待期间同 Agent 新任务只保持 `pending`,确认、拒绝或取消结束后再由同一 drain 串行排空。`.agent/runtime/` 是 Runtime 私有控制面,通用 `file.list / file.read / file.write / file.delete` 不得列出、读取、修改或删除;checkpoint/index/diff/restore 继续整体排除该目录。每 Agent 锁包含唯一 token,旧持有者析构时只删除自己的锁;Linux 上其他仍存活进程的锁不会因超过固定时长被抢占。确认、拒绝及工具 observation 分别写入 `agent.runtime.tool_confirmation.approved``agent.runtime.tool_confirmation.rejected``agent.runtime.tool_observation` 审计;pending 和 confirmation 文件只在 observation/终态可靠落盘后清理,失败清理会显式报错
- 2026-07-10 调整:per-agent 互斥锁最终改用 OS 级文件锁,而不是依赖 JSON token、PID、超时和 `remove + create_new` 竞争所有权。Unix 使用非阻塞独占 `flock`,Windows 使用禁止共享的文件句柄;锁文件只保留诊断元数据并长期存在,进程退出会由 OS 释放所有权。确认、拒绝和取消必须先取得同一系统锁,再重新读取 runtime、latest task 和 durable pending action 后迁移状态;恢复入口也必须先拿锁,再读取 durable pending action 或 recoverable task,禁止用锁外旧快照覆盖并发结果。waiting 取消只短暂等待原 worker 释放系统锁,running 取消拿不到锁时只保留 tombstone,并由原 worker 在 LLM / 工具成功或失败返回后的检查点收束,不得根据 Runtime status 抢锁。这条最终实现取代上一条中的 token 删除和 Linux PID 存活判断描述。
- 2026-07-10 调整:Agent Runtime 工具箱新增 `task.create`,用于让 Agent 把目标拆成新的 manifest 任务,而不只能更新 seed task。该工具默认 `confirm` 权限,写入前要求 taskId 唯一、依赖指向已有任务、列表长度受限,并写 `agent.runtime.task.create` 审计;策略要求确认或拒绝时不修改 `.agent/manifest.json`
- 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 文本;写入前继续过滤敏感上下文,不保存原始密钥、待写正文或任意未过滤输入。
File diff suppressed because one or more lines are too long