补齐游戏创作智能体本地Run控制

新增 agent run 状态、停止、重试和恢复命令
把 run 生命周期写入 trace、activity、output 和 context bundle
补齐聊天入口、测试覆盖和实施计划文档
This commit is contained in:
AIGameCreator App
2026-06-26 21:55:31 +08:00
parent 5de4916956
commit afd699a060
8 changed files with 622 additions and 3 deletions
@@ -88,6 +88,19 @@ struct GameCreatorLlmConfigStatus {
error: Option<String>,
}
#[derive(Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct AgentRunControlResult {
run_id: String,
status: String,
lifecycle_status: String,
next_step: String,
message: String,
activity_path: String,
output_path: String,
context_bundle_path: String,
}
#[derive(Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct UploadLocalAssetResult {
@@ -754,6 +767,19 @@ fn get_local_game_manifest(project_path: String) -> Result<GameCreationAppManife
read_manifest_for_project(Path::new(project_path.trim()))
}
#[tauri::command]
fn control_agent_run(
project_path: String,
action: String,
detail: Option<String>,
) -> Result<AgentRunControlResult, String> {
update_agent_run_lifecycle(
Path::new(project_path.trim()),
action.trim(),
detail.as_deref().map(str::trim).filter(|value| !value.is_empty()),
)
}
#[tauri::command]
async fn generate_local_game_draft(
app: tauri::AppHandle,
@@ -3504,6 +3530,7 @@ fn write_agent_run_trace(
run_id: run_id.to_string(),
command_id: "game.generate_draft".to_string(),
status: status.to_string(),
lifecycle_status: Some(agent_run_lifecycle_status(status).to_string()),
passes,
max_passes: GAME_CREATOR_AGENT_LOOP_MAX_PASSES,
tool_call_count,
@@ -3544,6 +3571,216 @@ fn agent_run_stop_reason(status: &str, error: Option<&str>) -> &'static str {
}
}
fn agent_run_lifecycle_status(status: &str) -> &'static str {
match status {
"running" | "needs-revision" | "artifacts-written" | "preview-running" => "running",
"waiting" | "preview-stopped" => "waiting",
"pending" => "pending",
"killed" => "killed",
"failed" => "failed",
"passed" => "done",
_ => "scheduled",
}
}
fn append_agent_run_activity(
root: &Path,
run_id: &str,
event: &str,
message: &str,
) -> Result<(), String> {
append_agent_run_jsonl(
root,
".agent/activity.jsonl",
&serde_json::json!({
"timestamp": unix_timestamp(),
"runId": run_id,
"event": event,
"message": message,
}),
)
}
fn append_agent_run_output(
root: &Path,
run_id: &str,
event: &str,
message: &str,
) -> Result<(), String> {
append_agent_run_jsonl(
root,
".agent/output.jsonl",
&serde_json::json!({
"timestamp": unix_timestamp(),
"runId": run_id,
"event": event,
"content": message,
}),
)
}
fn append_agent_run_jsonl(
root: &Path,
relative_path: &str,
value: &serde_json::Value,
) -> Result<(), String> {
let path = root.join(relative_path);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("创建 Agent 事件目录失败:{}: {error}", parent.display()))?;
}
let mut line = serde_json::to_string(value)
.map_err(|error| format!("序列化 Agent 事件失败:{error}"))?;
line.push('\n');
fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.and_then(|mut file| file.write_all(line.as_bytes()))
.map_err(|error| format!("写入 Agent 事件失败:{}: {error}", path.display()))
}
fn write_agent_run_context_bundle(root: &Path, trace: &GameCreationAgentRunTrace) -> Result<(), String> {
let bundle_path = root.join(".agent/context.bundle.json");
if let Some(parent) = bundle_path.parent() {
fs::create_dir_all(parent).map_err(|error| {
format!(
"创建 Agent context bundle 目录失败:{}: {error}",
parent.display()
)
})?;
}
let manifest = read_manifest_for_project(root).ok();
let payload = serde_json::json!({
"schemaVersion": "game-creator-context-bundle.v1",
"runId": trace.run_id,
"commandId": trace.command_id,
"goal": trace.goal,
"lifecycleStatus": trace.lifecycle_status.as_deref().unwrap_or_else(|| agent_run_lifecycle_status(&trace.status)),
"status": trace.status,
"nextStep": trace.next_step,
"memory": {
"short": "memory/session.md",
"long": "memory/project.md"
},
"manifest": manifest,
"trace": ".agent/run.latest.json",
"activity": ".agent/activity.jsonl",
"output": ".agent/output.jsonl",
"updatedAt": unix_timestamp()
});
let content = serde_json::to_string_pretty(&payload)
.map_err(|error| format!("生成 Agent context bundle 失败:{error}"))?;
fs::write(&bundle_path, content).map_err(|error| {
format!(
"写入 Agent context bundle 失败:{}: {error}",
bundle_path.display()
)
})
}
fn update_agent_run_lifecycle(
root: &Path,
action: &str,
detail: Option<&str>,
) -> Result<AgentRunControlResult, String> {
let trace_path = root.join(".agent/run.latest.json");
let content = fs::read_to_string(&trace_path).map_err(|error| {
format!(
"读取 Agent run trace 失败:{}: {error}",
trace_path.display()
)
})?;
let mut trace =
serde_json::from_str::<GameCreationAgentRunTrace>(&content).map_err(|error| {
format!(
"解析 Agent run trace 失败:{}: {error}",
trace_path.display()
)
})?;
let (status, lifecycle_status, next_step, event, message) = match action {
"status" => {
let lifecycle = trace
.lifecycle_status
.clone()
.unwrap_or_else(|| agent_run_lifecycle_status(&trace.status).to_string());
(
trace.status.clone(),
lifecycle.clone(),
trace.next_step.clone(),
"agent.run_status",
format!("run {} 当前状态:{} / {}", trace.run_id, trace.status, lifecycle),
)
}
"kill" => (
"killed".to_string(),
"killed".to_string(),
"resume-or-retry".to_string(),
"agent.kill",
format!("run {} 已标记为 killed", trace.run_id),
),
"retry" => (
"pending".to_string(),
"pending".to_string(),
"runner-claim".to_string(),
"agent.retry",
format!("run {} 已重试,等待下一次 claim", trace.run_id),
),
"resume" => (
"pending".to_string(),
"pending".to_string(),
"runner-claim".to_string(),
"agent.resume",
format!(
"run {} 已恢复:{}",
trace.run_id,
detail.unwrap_or("等待下一次 claim")
),
),
_ => return Err("未知 Agent run 控制动作".to_string()),
};
if action != "status" {
trace.status = status;
trace.lifecycle_status = Some(lifecycle_status);
trace.next_step = next_step;
trace.stop_reason = match action {
"kill" => "killed",
"retry" => "retry-requested",
"resume" => "human-resume",
_ => trace.stop_reason.as_str(),
}
.to_string();
trace.error = if action == "kill" {
Some("用户请求停止当前 run".to_string())
} else {
None
};
trace.updated_at = unix_timestamp();
write_agent_run_trace_payload(root, &trace)?;
}
append_agent_run_activity(root, &trace.run_id, event, &message)?;
append_agent_run_output(root, &trace.run_id, event, &message)?;
write_agent_run_context_bundle(root, &trace)?;
Ok(AgentRunControlResult {
run_id: trace.run_id,
status: trace.status,
lifecycle_status: trace
.lifecycle_status
.unwrap_or_else(|| agent_run_lifecycle_status("scheduled").to_string()),
next_step: trace.next_step,
message,
activity_path: root.join(".agent/activity.jsonl").to_string_lossy().to_string(),
output_path: root.join(".agent/output.jsonl").to_string_lossy().to_string(),
context_bundle_path: root
.join(".agent/context.bundle.json")
.to_string_lossy()
.to_string(),
})
}
fn count_agent_tool_calls(steps: &[GameCreationAgentRunStep]) -> Result<u16, String> {
let count = steps.iter().try_fold(0u16, |current, step| {
let step_count = u16::try_from(step.tool_calls.len())
@@ -5960,6 +6197,7 @@ fn main() {
})
.invoke_handler(tauri::generate_handler![
init_local_game_project,
control_agent_run,
generate_local_game_draft,
check_game_creator_llm_config,
upload_local_asset,
@@ -8041,6 +8279,61 @@ GENARRATIVE_GAME_CREATOR_LLM_STREAM=true
fs::remove_dir_all(root).ok();
}
#[test]
fn agent_run_control_updates_lifecycle_and_jsonl() {
let root = unique_project_path();
init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init");
write_agent_run_trace(
&root,
"run-control-1",
"做一个可控 run",
"running",
1,
&[agent_trace_step(
1,
"Planner",
"completed",
&[".agent/manifest.json"],
&[".agent/spec.md"],
"已拆解目标",
"llm.chat.planner",
)],
None,
)
.expect("run trace");
let killed =
update_agent_run_lifecycle(&root, "kill", None).expect("kill should update trace");
assert_eq!(killed.status, "killed");
assert_eq!(killed.lifecycle_status, "killed");
assert_eq!(killed.next_step, "resume-or-retry");
let trace: Value =
serde_json::from_str(&fs::read_to_string(root.join(".agent/run.latest.json")).unwrap())
.expect("run trace json");
assert_eq!(trace["status"], "killed");
assert_eq!(trace["lifecycleStatus"], "killed");
assert_eq!(trace["stopReason"], "killed");
let retried =
update_agent_run_lifecycle(&root, "retry", None).expect("retry should mark pending");
assert_eq!(retried.status, "pending");
assert_eq!(retried.lifecycle_status, "pending");
assert_eq!(retried.next_step, "runner-claim");
let status =
update_agent_run_lifecycle(&root, "status", None).expect("status should read trace");
assert_eq!(status.status, "pending");
assert_eq!(status.lifecycle_status, "pending");
let activity = fs::read_to_string(root.join(".agent/activity.jsonl")).expect("activity");
assert!(activity.contains("agent.kill"));
assert!(activity.contains("agent.retry"));
assert!(activity.contains("agent.run_status"));
let output = fs::read_to_string(root.join(".agent/output.jsonl")).expect("output");
assert!(output.contains("agent.kill"));
assert!(root.join(".agent/context.bundle.json").exists());
fs::remove_dir_all(root).ok();
}
#[test]
fn limited_local_command_rejects_placeholder_game_smoke() {
let root = unique_project_path();
+159 -1
View File
@@ -139,6 +139,17 @@ interface AgentProgressEvent {
message: string;
}
interface AgentRunControlResult {
runId: string;
status: string;
lifecycleStatus: string;
nextStep: string;
message: string;
activityPath: string;
outputPath: string;
contextBundlePath: string;
}
export type PendingCommand =
| {
id: 'game.generate_draft';
@@ -165,6 +176,10 @@ export type PendingCommand =
| {
id: 'preview.open';
}
| {
id: 'agent.kill' | 'agent.retry' | 'agent.resume';
detail?: string;
}
| {
id: 'memory.write';
scope: MemoryScope;
@@ -257,6 +272,10 @@ const chatCommandHelp = [
'/status:查看项目状态',
'/tasks:查看任务拆分',
'/trace 或 /loop:查看最近一次 Agent loop trace',
'/agent-status:查看最近 run 生命周期',
'/agent-kill:标记最近 run 为 killed',
'/agent-retry:把最近 run 标记为 pending 等待重试',
'/agent-resume [说明]:把最近 run 标记为 pending 等待继续',
'/files:列出本地项目文件',
'/assets:列出本地项目资产',
'/read 路径:读取本地项目内文本文件',
@@ -681,6 +700,9 @@ export function isAbsoluteProjectPath(value: string) {
export function needsInitializedChatProject(commandId: PendingCommand['id']) {
return [
'asset.upload',
'agent.kill',
'agent.retry',
'agent.resume',
'command.run_limited',
'game.generate_draft',
'game.run_local',
@@ -909,6 +931,13 @@ function pendingCommandTitle(command: PendingCommand) {
if (command.id === 'project.create') {
return command.id;
}
if (
command.id === 'agent.kill' ||
command.id === 'agent.retry' ||
command.id === 'agent.resume'
) {
return command.id;
}
if (command.id === 'memory.write' || command.id === 'memory.delete') {
return `${command.id} · ${memoryScopeLabel(command.scope)}`;
}
@@ -952,6 +981,15 @@ export function pendingCommandDetail(
if (command.id === 'preview.open') {
return '打开当前本地预览';
}
if (command.id === 'agent.kill') {
return `标记 ${projectPath}/.agent/run.latest.json 为 killed,并写入 activity/output`;
}
if (command.id === 'agent.retry') {
return `标记 ${projectPath}/.agent/run.latest.json 为 pending,等待 runner claim`;
}
if (command.id === 'agent.resume') {
return `附加用户说明并标记 ${projectPath}/.agent/run.latest.json 为 pending`;
}
if (command.id === 'memory.write') {
return `${
command.mode === 'replace' ? '覆盖保存到' : '追加到'
@@ -1241,6 +1279,50 @@ export function App() {
return;
}
if (prompt === '/agent-status') {
void executeAgentRunControl('status', undefined, true);
return;
}
if (prompt === '/agent-kill') {
if (!requireChatProjectForUserAction()) {
return;
}
queuePendingCommand({ id: 'agent.kill' });
setMessages((current) => [
...current,
{ role: 'assistant', text: '准备标记最近 run 为 killed。' },
]);
return;
}
if (prompt === '/agent-retry') {
if (!requireChatProjectForUserAction()) {
return;
}
queuePendingCommand({ id: 'agent.retry' });
setMessages((current) => [
...current,
{ role: 'assistant', text: '准备把最近 run 标记为 pending。' },
]);
return;
}
if (prompt === '/agent-resume' || prompt.startsWith('/agent-resume ')) {
if (!requireChatProjectForUserAction()) {
return;
}
queuePendingCommand({
id: 'agent.resume',
detail: prompt.slice('/agent-resume'.length).trim(),
});
setMessages((current) => [
...current,
{ role: 'assistant', text: '准备恢复最近 run。' },
]);
return;
}
if (prompt === '/smoke') {
if (!requireChatProjectForUserAction()) {
return;
@@ -1707,6 +1789,16 @@ export function App() {
void executePreviewStart(true);
} else if (command.id === 'preview.open') {
void executePreviewOpen(true);
} else if (
command.id === 'agent.kill' ||
command.id === 'agent.retry' ||
command.id === 'agent.resume'
) {
void executeAgentRunControl(
command.id.slice('agent.'.length),
command.detail,
true,
);
} else if (command.id === 'memory.write') {
void executeMemoryWrite(
command.scope,
@@ -1735,7 +1827,7 @@ export function App() {
command.canvasProjectId,
true,
);
} else {
} else if (command.id === 'game.generate_draft') {
void executeGameDraft(command.prompt);
}
}
@@ -2088,6 +2180,72 @@ export function App() {
}
}
async function executeAgentRunControl(
action: string,
detail: string | undefined,
announceToChat: boolean,
) {
const invoke = resolveTauriInvoke();
if (!invoke) {
setAgentRunStatus('需要在 Tauri App 内运行');
if (announceToChat) {
setMessages((current) => [
...current,
{ role: 'assistant', text: '需要在 Tauri App 内运行。' },
]);
}
return;
}
const nextProjectPath = requireChatProjectForUserAction();
if (!nextProjectPath) {
return;
}
try {
const result = await invoke<AgentRunControlResult>('control_agent_run', {
projectPath: nextProjectPath,
action,
detail,
});
setAgentRunStatus(
`${result.status} · ${result.lifecycleStatus} · ${result.nextStep}`,
);
setCommandLog((current) => [
...current,
`agent.${action}`,
'file.write .agent/activity.jsonl',
'file.write .agent/output.jsonl',
'file.write .agent/context.bundle.json',
]);
await refreshAgentRunTrace(nextProjectPath);
if (announceToChat) {
setMessages((current) => [
...current,
{
role: 'assistant',
text: [
result.message,
`状态:${result.status} / ${result.lifecycleStatus}`,
`下一步:${result.nextStep}`,
`事件:${result.activityPath}`,
`输出:${result.outputPath}`,
`上下文包:${result.contextBundlePath}`,
].join('\n'),
},
]);
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setAgentRunStatus(message);
if (announceToChat) {
setMessages((current) => [
...current,
{ role: 'assistant', text: message },
]);
}
}
}
async function executeAgentAuditChat() {
const invoke = resolveTauriInvoke();
if (!invoke) {
@@ -93,6 +93,8 @@ describe('AI 游戏创作 App 界面边界', () => {
expect(
screen.getByText(/\/audit:审计当前项目的 Agent 能力证据/),
).not.toBeNull();
expect(screen.getByText(/\/agent-status:查看最近 run 生命周期/)).not.toBeNull();
expect(screen.getByText(/\/agent-kill:标记最近 run 为 killed/)).not.toBeNull();
expect(screen.queryByLabelText('开发环境')).toBeNull();
});
@@ -2142,6 +2144,116 @@ describe('AI 游戏创作 App 界面边界', () => {
});
});
it('controls agent run lifecycle from chat through the authorized local project path', async () => {
const manifest = createGameCreationAppManifest(
'local-project-draft',
'未命名游戏原型',
);
const trace: GameCreationAgentRunTrace = {
schemaVersion: GAME_CREATION_AGENT_RUN_SCHEMA_VERSION,
runId: 'run-control-chat',
commandId: 'game.generate_draft',
status: 'pending',
lifecycleStatus: 'pending',
passes: 1,
maxPasses: 3,
toolCallCount: 1,
maxToolCalls: 128,
stopReason: 'retry-requested',
goal: '做一个反弹弹幕厨房游戏',
coordination: 'filesystem',
steps: [],
artifacts: [],
taskGraph: {
goal: '做一个反弹弹幕厨房游戏',
readyTaskIds: [],
activeTaskIds: [],
carriedTaskIds: [],
repairFocus: [],
repairRoutes: [],
tasks: createGameCreationAppSeedTasks(),
},
passPlans: [],
nextStep: 'runner-claim',
error: null,
updatedAt: 1,
};
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 === 'control_agent_run') {
return {
runId: 'run-control-chat',
status: args?.action === 'kill' ? 'killed' : 'pending',
lifecycleStatus: args?.action === 'kill' ? 'killed' : 'pending',
nextStep:
args?.action === 'kill' ? 'resume-or-retry' : 'runner-claim',
message:
args?.action === 'kill'
? 'run run-control-chat 已标记为 killed'
: 'run run-control-chat 当前状态:pending / pending',
activityPath: '/tmp/authorized-game/.agent/activity.jsonl',
outputPath: '/tmp/authorized-game/.agent/output.jsonl',
contextBundlePath: '/tmp/authorized-game/.agent/context.bundle.json',
};
}
if (command === 'read_local_project_file') {
return {
path: '.agent/run.latest.json',
absolutePath: `${String(args?.projectPath ?? '')}/.agent/run.latest.json`,
content: JSON.stringify(trace),
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
renderAppAt('/');
submitChat('/project /tmp/authorized-game');
fireEvent.click(screen.getByRole('button', { name: '确认' }));
expect(
await screen.findByText('已设置本地项目:/tmp/authorized-game'),
).not.toBeNull();
submitChat('/agent-status');
expect(
await screen.findByText(/run run-control-chat 当前状态:pending \/ pending/),
).not.toBeNull();
expect(invoke).toHaveBeenCalledWith('control_agent_run', {
projectPath: '/tmp/authorized-game',
action: 'status',
detail: undefined,
});
submitChat('/agent-kill');
expect(screen.getByText('agent.kill')).not.toBeNull();
expect(
screen.getByText(
'标记 /tmp/authorized-game/.agent/run.latest.json 为 killed,并写入 activity/output',
),
).not.toBeNull();
fireEvent.click(screen.getByRole('button', { name: '确认' }));
expect(
await screen.findByText(/run run-control-chat 已标记为 killed/),
).not.toBeNull();
expect(invoke).toHaveBeenCalledWith('control_agent_run', {
projectPath: '/tmp/authorized-game',
action: 'kill',
detail: undefined,
});
});
it('manages long memory from chat through the authorized local project path', async () => {
const manifest = createGameCreationAppManifest(
'local-project-draft',
@@ -102,6 +102,9 @@ describe('AI 游戏创作聊天记忆命令', () => {
it('requires a project before chat commands write or run local artifacts', () => {
expect(needsInitializedChatProject('game.generate_draft')).toBe(true);
expect(needsInitializedChatProject('asset.upload')).toBe(true);
expect(needsInitializedChatProject('agent.kill')).toBe(true);
expect(needsInitializedChatProject('agent.retry')).toBe(true);
expect(needsInitializedChatProject('agent.resume')).toBe(true);
expect(needsInitializedChatProject('command.run_limited')).toBe(true);
expect(needsInitializedChatProject('preview.open')).toBe(true);
expect(needsInitializedChatProject('preview.start')).toBe(true);
@@ -113,6 +116,21 @@ describe('AI 游戏创作聊天记忆命令', () => {
expect(needsInitializedChatProject('canvas.project_open')).toBe(false);
});
it('describes agent run lifecycle controls before confirmation', () => {
expect(pendingCommandDetail({ id: 'agent.kill' }, '/tmp/game')).toBe(
'标记 /tmp/game/.agent/run.latest.json 为 killed,并写入 activity/output',
);
expect(pendingCommandDetail({ id: 'agent.retry' }, '/tmp/game')).toBe(
'标记 /tmp/game/.agent/run.latest.json 为 pending,等待 runner claim',
);
expect(
pendingCommandDetail(
{ id: 'agent.resume', detail: '继续修复输入监听' },
'/tmp/game',
),
).toBe('附加用户说明并标记 /tmp/game/.agent/run.latest.json 为 pending');
});
it('shows the authorized project path in pending write command details', () => {
expect(
resolvePendingCommandProjectPath(
@@ -3789,6 +3789,7 @@
- 2026-06-25 调整:正式用户 App 不承载游戏预览画面,release CSP 不允许 `frame-src http://127.0.0.1:*`;只有开发窗口 / dev CSP 可以嵌入本地预览 iframe。`/preview``/run` 和生成完成后的用户侧路径只启动 `127.0.0.1` HTTP preview 并通过 `open_local_game_preview` 交给系统外部浏览器。
- 2026-06-25 调整:`project.create` 成功后的 durable 权限证据必须在聊天 `/project` 和开发窗口初始化两条入口统一写入 `.agent/logs/command.log`,避免同一能力因为入口不同导致 `/audit` 或开发排障证据不一致。
- 2026-06-25 调整:`.agent/run.latest.json``.agent/runs/<runId>.json` 必须记录 loop 的 `maxPasses``stopReason`,开发窗口直接展示该状态,避免只从 summary 文案推断 loop 是否跑满、通过、返工、写入产物或进入预览。本地 HTTP 预览的 `/` 映射到 `game/index.html`,路径解析必须 canonicalize 项目根目录和目标文件,只允许访问项目内 `game/``assets/`,拒绝 `memory/``.agent/``exports/``..`、反斜杠和符号链接越界;常见图片、音频、视频和 Web 资源必须返回对应 MIME。这样上传和画板回流资产能被生成游戏引用,但记忆、trace 和导出包不会被预览服务暴露。
- 2026-06-26 调整:AI 游戏创作 App 借鉴 Harbour 的控制平面思想,但不搬 Harbour 后台。最近 run 在 `.agent/run.latest.json` 增加可选 `lifecycleStatus`,并通过 `/agent-status``/agent-kill``/agent-retry``/agent-resume [说明]` 控制本地生命周期,写入 `.agent/activity.jsonl``.agent/output.jsonl``.agent/context.bundle.json`。v1 的 kill/retry/resume 只更新本地状态和上下文包,不伪装成能中断已发出的上游 LLM 请求;后续引入独立 runner 后再把 `pending` 接入 claim。
- 2026-06-25 调整:本地 HTTP 预览静态 `HEAD` 必须返回与 `GET` 相同的真实 `Content-Length`,但不返回 body;浏览器、图片、音频和视频探测不能拿到 `Content-Length: 0` 的假响应。
- 2026-06-25 调整:普通用户通过聊天输入 `/run` 触发待确认 `game.run_local`,确认后只能复用白名单 `game.static_smoke` 自检当前 `game/index.html`,通过后启动 `127.0.0.1` 本地 HTTP 预览。独立执行 `game.static_smoke` 时如果已有 `.agent/run.latest.json`,必须追加 `Playtest / game.static_smoke` trace step,避免“运行了代码但编排 trace 不可见”。
- 2026-06-25 调整:普通用户通过聊天输入 `/trace` 触发只读 `agent.trace_read`,读取 `.agent/run.latest.json` 并在聊天里摘要 loop 轮次、stopReason、nextStep、active / carry-over 任务、repairRoutes、agent 建议命令和最近 step。trace 面板仍只在开发窗口展示,普通用户窗口不新增面板。
@@ -11,6 +11,7 @@
- 本地能力:使用 Tauri Rust command;正式用户 App 只启动 `127.0.0.1` 本地 HTTP preview 并交给外部浏览器,不在正式用户窗口内承载游戏预览画面。
- Agent Runtime:扩展 `server-rs/crates/platform-agent`,不引入 LangChain、AutoGen、Microsoft Agent Framework 或 OpenAI Agents SDK sidecar 作为核心。
- 设计参考:借鉴 OpenAI Agents SDK 的 Agent、Tools、Handoffs、Guardrails、Tracing 抽象,但运行时由 Genarrative 自己掌控。
- Run 控制参考:借鉴 Harbour 的控制平面思想,只吸收 `run lifecycle`、activity/output stream、context bundle、kill/retry/resume 等本地运行治理能力;不引入 Harbour 的多租户后台、调度 UI、通用 shell workflow 或远程 runner 作为 v1 依赖。
## Runtime 边界
@@ -47,6 +48,9 @@ game-project/
agent.db
manifest.json
run.latest.json
activity.jsonl
output.jsonl
context.bundle.json
runs/
logs/
```
@@ -115,6 +119,7 @@ game-project/
- 通过 Evaluator 和 `game.static_smoke` 后,Agent loop 会把本次 runId、状态、轮次、下一步、active / carry-over 任务和最终本地产物摘要追加到 `memory/session.md``memory/project.md`;下一次 Planner、组内角色和 Generator 会通过记忆输入自然读取上一轮稳定原型状态,而不只依赖开发窗口 trace。
- `.agent/agent.db` 当前作为最小本地索引文件使用 JSONL:初始化写入 `project.init`,每次 `game.generate_draft` 追加目标、标题和本地产物路径,上传 / 登记 / 画板导入资产时追加 `asset.register``asset.update`v1 不引入 SQLite 依赖。
- `.agent/run.latest.json` 的 schema 固定为共享契约 `GAME_CREATION_AGENT_RUN_SCHEMA_VERSION = game-creator-agent-run.v1`;TS 与 Rust 都从共享契约读取 run trace 类型,避免开发窗口和 Tauri 写入结构漂移。
- `.agent/run.latest.json` 增加可选 `lifecycleStatus`,把一次生成 run 映射到本地最小生命周期:`scheduled / running / waiting / pending / done / failed / killed`。聊天命令 `/agent-status` 读取最近 run`/agent-kill` 标记为 `killed``/agent-retry``/agent-resume [说明]` 标记为 `pending`,并写入 `.agent/activity.jsonl``.agent/output.jsonl``.agent/context.bundle.json`。v1 只做本地状态控制,不承诺真正中断已在上游执行中的 LLM 请求;后续引入独立 runner 后再把 `pending` 接入 claim。
- `game.generate_draft` 写入最终产物后会复用白名单受限命令 `game.static_smoke` 做一次生成后自检,至少检查 `game/index.html` 包含 canvas、canvas 渲染上下文、绘制调用、主循环、非空输入监听、明确目标、失败或胜利状态和重开路径,且不使用远程资源、`eval``new Function``localStorage``fetch``WebSocket``ServiceWorker`,也不得包含固定星核传送门模板词、纯按钮计分模板或 `TODO` / `待实现` / `这里省略` 等未完成实现;画板资源占位引用允许出现在 asset id 或说明中,并把该工具调用写入 `.agent/run.latest.json``.agent/logs/command.log`;自检失败则本次命令失败,不继续启动预览。
- `ArtifactWriter` step 使用 `file.write.local_artifacts` 工具调用记录最终写入的 `memory/``game/``assets/``exports/``.agent/manifest.json` 路径;写入完成后 `nextStep` 指向 `game.static_smoke`
- `preview.start` / `preview.stop` 会追加 `.agent/logs/preview.log`,并在 `.agent/run.latest.json` 已存在时追加 `Preview` step 和 `preview.*` toolCall,记录本地 HTTP 预览 URL 与停止事件;单全局本地预览被新项目替换时,会 best-effort 把旧项目 manifest、preview log 和 trace 记录为 stopped,避免旧项目残留 running;本地 HTTP server 的 `/` 映射到 `game/index.html`,只允许读取 canonical 后仍位于项目真实 `game/` 或真实 `assets/` 下的文件,拒绝 `memory/``.agent/``exports/``..`、一级 `game` / `assets` 符号链接目录和内部符号链接越界,并为常见图片、音频、视频和 Web 资源返回对应 MIME;静态 `HEAD` 返回真实 `Content-Length` 但不返回 body,确保浏览器和媒体资源探测可用;上传和画板回流资产可被生成游戏引用但不会暴露记忆或 trace;没有 run trace 的手动预览启动不阻断。
@@ -18,6 +18,10 @@ export const GAME_CREATION_APP_COMMANDS = [
{ id: 'project.status', permission: 'auto' },
{ id: 'task.list', permission: 'auto' },
{ id: 'agent.trace_read', permission: 'auto' },
{ id: 'agent.run_status', permission: 'auto' },
{ id: 'agent.kill', permission: 'confirm' },
{ id: 'agent.retry', permission: 'confirm' },
{ id: 'agent.resume', permission: 'confirm' },
{ id: 'agent.capabilities', permission: 'auto' },
{ id: 'agent.audit', permission: 'auto' },
{ id: 'llm.config_check', permission: 'auto' },
@@ -88,6 +92,7 @@ export const GAME_CREATION_AGENT_CAPABILITIES = [
title: '组内角色协作',
},
{ id: 'quality-review', area: 'agent-runtime', title: '质量评审' },
{ id: 'run-lifecycle', area: 'agent-runtime', title: 'Run 生命周期控制' },
{
id: 'repair-loop-carryover',
area: 'agent-runtime',
@@ -433,6 +438,7 @@ export interface GameCreationAgentRunTrace {
runId: string;
commandId: string;
status: string;
lifecycleStatus?: string | null;
passes: number;
maxPasses: number;
toolCallCount: number;
@@ -21,12 +21,16 @@ pub struct GameCreationAppCommandDescriptor {
pub permission: GameCreationAppPermission,
}
pub const GAME_CREATION_APP_COMMANDS: [GameCreationAppCommandDescriptor; 29] = [
pub const GAME_CREATION_APP_COMMANDS: [GameCreationAppCommandDescriptor; 33] = [
command("help.show", GameCreationAppPermission::Auto),
command("project.create", GameCreationAppPermission::Confirm),
command("project.status", GameCreationAppPermission::Auto),
command("task.list", GameCreationAppPermission::Auto),
command("agent.trace_read", GameCreationAppPermission::Auto),
command("agent.run_status", GameCreationAppPermission::Auto),
command("agent.kill", GameCreationAppPermission::Confirm),
command("agent.retry", GameCreationAppPermission::Confirm),
command("agent.resume", GameCreationAppPermission::Confirm),
command("agent.capabilities", GameCreationAppPermission::Auto),
command("agent.audit", GameCreationAppPermission::Auto),
command("llm.config_check", GameCreationAppPermission::Auto),
@@ -68,7 +72,7 @@ pub struct GameCreationAgentCapabilityDescriptor {
pub title: &'static str,
}
pub const GAME_CREATION_AGENT_CAPABILITIES: [GameCreationAgentCapabilityDescriptor; 22] = [
pub const GAME_CREATION_AGENT_CAPABILITIES: [GameCreationAgentCapabilityDescriptor; 23] = [
capability("chat", "user", "聊天入口"),
capability("file-upload", "user", "上传文件"),
capability("built-in-commands", "agent-runtime", "内置命令调用"),
@@ -90,6 +94,7 @@ pub const GAME_CREATION_AGENT_CAPABILITIES: [GameCreationAgentCapabilityDescript
capability("multi-agent-collaboration", "agent-runtime", "多智能体协作"),
capability("role-level-collaboration", "agent-runtime", "组内角色协作"),
capability("quality-review", "agent-runtime", "质量评审"),
capability("run-lifecycle", "agent-runtime", "Run 生命周期控制"),
capability(
"repair-loop-carryover",
"agent-runtime",
@@ -508,6 +513,8 @@ pub struct GameCreationAgentRunTrace {
pub run_id: String,
pub command_id: String,
pub status: String,
#[serde(default)]
pub lifecycle_status: Option<String>,
pub passes: u8,
#[serde(default = "default_game_creation_agent_run_max_passes")]
pub max_passes: u8,
@@ -636,6 +643,23 @@ mod tests {
.expect("command should exist");
assert_eq!(trace_read.permission, GameCreationAppPermission::Auto);
let run_status = GAME_CREATION_APP_COMMANDS
.iter()
.find(|command| command.id == "agent.run_status")
.expect("command should exist");
assert_eq!(run_status.permission, GameCreationAppPermission::Auto);
for command_id in ["agent.kill", "agent.retry", "agent.resume"] {
let lifecycle_command = GAME_CREATION_APP_COMMANDS
.iter()
.find(|command| command.id == command_id)
.expect("command should exist");
assert_eq!(
lifecycle_command.permission,
GameCreationAppPermission::Confirm
);
}
let agent_capabilities = GAME_CREATION_APP_COMMANDS
.iter()
.find(|command| command.id == "agent.capabilities")
@@ -733,6 +757,7 @@ mod tests {
"tool-call-budget",
"multi-agent-collaboration",
"role-level-collaboration",
"run-lifecycle",
"repair-loop-carryover",
"short-term-memory",
"long-term-memory",
@@ -761,6 +786,7 @@ mod tests {
run_id: "run-1".to_string(),
command_id: "game.generate_draft".to_string(),
status: "preview-running".to_string(),
lifecycle_status: Some("running".to_string()),
passes: 2,
max_passes: GAME_CREATION_AGENT_RUN_MAX_PASSES,
tool_call_count: 1,