补齐游戏创作智能体本地Run控制
新增 agent run 状态、停止、重试和恢复命令 把 run 生命周期写入 trace、activity、output 和 context bundle 补齐聊天入口、测试覆盖和实施计划文档
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user