73f5c94de4
合并 origin/master 的 UI 编辑器、GDD 审批与前端修复。 保留本分支 LLM Router、Direct 过程卡与私有路径相关实现和决策记录。 解决 decision-log 文档冲突,完整保留双方新增决策条目。
2732 lines
92 KiB
Rust
2732 lines
92 KiB
Rust
#![cfg_attr(all(not(dev), target_os = "windows"), windows_subsystem = "windows")]
|
||
|
||
use std::collections::BTreeMap;
|
||
use std::fs;
|
||
use std::fs::{File, OpenOptions};
|
||
use std::io::{BufRead, BufReader, Read, Seek, SeekFrom, Write};
|
||
use std::net::{TcpListener, TcpStream};
|
||
use std::path::{Path, PathBuf};
|
||
use std::process::Command;
|
||
use std::sync::atomic::AtomicBool;
|
||
use std::sync::{mpsc, Arc, Mutex, OnceLock};
|
||
use std::thread;
|
||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||
|
||
use futures::StreamExt;
|
||
use platform_agent::{
|
||
build_game_creation_seed_task_graph, plan_game_creation_agent_pass,
|
||
route_game_creation_repair_issues,
|
||
};
|
||
use platform_llm::{
|
||
LlmApiKind, LlmClient, LlmConfig, LlmMessage, LlmMessageContentPart, LlmProvider,
|
||
LlmRunRequest, DEFAULT_RETRY_BACKOFF_MS,
|
||
};
|
||
use reqwest::header;
|
||
use serde::{Deserialize, Serialize};
|
||
use sha2::Digest;
|
||
use shared_contracts::game_creation_app::{
|
||
new_game_creation_app_manifest, new_game_creation_app_seed_tasks,
|
||
validate_game_iteration_versions, GameCreationAgentArtifactTrace,
|
||
GameCreationAgentCapabilityDescriptor, GameCreationAgentPassPlanTrace,
|
||
GameCreationAgentRepairRouteTrace, GameCreationAgentRunStep,
|
||
GameCreationAgentRunTaskGraphTrace, GameCreationAgentRunTrace, GameCreationAgentToolCallTrace,
|
||
GameCreationAppAgentGroup, GameCreationAppAssetManifestEntry, GameCreationAppAssetSource,
|
||
GameCreationAppAssetSourceKind, GameCreationAppCommandRunState,
|
||
GameCreationAppCommandRunStatus, GameCreationAppLimitedRunCommandDescriptor,
|
||
GameCreationAppManifest, GameCreationAppPermission, GameCreationAppPreviewState,
|
||
GameCreationAppPreviewStatus, GameCreationAppTaskState, GameCreationAppTaskStatus,
|
||
GameIterationVersion, GameIterationVersionCreatedReason, GameIterationVersionResourceBinding,
|
||
ProjectResourceCanvasLayout, ProjectResourceCanvasLayoutMode, ProjectResourceCanvasPosition,
|
||
UpdateProjectResourceCanvasLayoutResult, UpdateProjectResourceCanvasLayoutStatus,
|
||
GAME_CREATION_AGENT_CAPABILITIES, GAME_CREATION_AGENT_RUN_SCHEMA_VERSION,
|
||
GAME_CREATION_AGENT_TOOL_CALL_MAX, GAME_CREATION_APP_COMMANDS,
|
||
GAME_CREATION_APP_LIMITED_RUN_COMMANDS, GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION,
|
||
};
|
||
use tauri::{Emitter, Manager};
|
||
use tauri_plugin_dialog::DialogExt;
|
||
use tauri_plugin_opener::OpenerExt;
|
||
|
||
const AGC_UPDATE_OSS_HOST: &str = "agc-dev.oss-rg-china-mainland.aliyuncs.com";
|
||
const AGC_UPDATE_MAX_DOWNLOAD_BYTES: u64 = 512 * 1024 * 1024;
|
||
const AGC_UPDATE_DOWNLOAD_PROGRESS_EVENT: &str = "agc-update-download-progress";
|
||
|
||
fn build_agc_update_download_client() -> reqwest::Client {
|
||
reqwest::Client::new()
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct AgcUpdateDownloadProgress {
|
||
downloaded_bytes: u64,
|
||
total_bytes: Option<u64>,
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
fn launch_agc_installer(path: &Path, relaunch_path: &Path) -> Result<(), String> {
|
||
use std::os::windows::process::CommandExt;
|
||
|
||
let executable = path.to_string_lossy().replace('\'', "''");
|
||
let relaunch_executable = relaunch_path.to_string_lossy().replace('\'', "''");
|
||
let script = format!(
|
||
"$ErrorActionPreference = 'Stop'; $installer = Start-Process -Verb RunAs -Wait -PassThru -FilePath '{executable}' -ArgumentList @('/S'); if ($installer.ExitCode -eq 0 -and (Test-Path -LiteralPath '{relaunch_executable}')) {{ Start-Process -FilePath '{relaunch_executable}' }}; exit $installer.ExitCode"
|
||
);
|
||
Command::new("powershell.exe")
|
||
.args([
|
||
"-NoProfile",
|
||
"-NonInteractive",
|
||
"-WindowStyle",
|
||
"Hidden",
|
||
"-Command",
|
||
script.as_str(),
|
||
])
|
||
.creation_flags(0x0800_0000)
|
||
.spawn()
|
||
.map(|_| ())
|
||
.map_err(|error| format!("无法启动更新安装程序:{error}"))
|
||
}
|
||
|
||
#[cfg(not(windows))]
|
||
fn launch_agc_installer(path: &Path, _relaunch_path: &Path) -> Result<(), String> {
|
||
Command::new(path)
|
||
.arg("/S")
|
||
.spawn()
|
||
.map(|_| ())
|
||
.map_err(|error| format!("无法启动更新安装程序:{error}"))
|
||
}
|
||
|
||
#[tauri::command]
|
||
async fn download_agc_update(
|
||
app: tauri::AppHandle,
|
||
download_url: String,
|
||
expected_sha256: Option<String>,
|
||
expected_size: Option<u64>,
|
||
) -> Result<String, String> {
|
||
let parsed =
|
||
url::Url::parse(download_url.trim()).map_err(|_| "更新下载地址无效".to_string())?;
|
||
if parsed.scheme() != "https" || parsed.host_str() != Some(AGC_UPDATE_OSS_HOST) {
|
||
return Err("更新下载地址必须来自受信任的 OSS".to_string());
|
||
}
|
||
let encoded_filename = parsed
|
||
.path_segments()
|
||
.and_then(|segments| segments.last())
|
||
.filter(|value| !value.is_empty())
|
||
.ok_or_else(|| "更新下载地址缺少文件名".to_string())?
|
||
.to_string();
|
||
let filename = percent_encoding::percent_decode_str(&encoded_filename)
|
||
.decode_utf8()
|
||
.map_err(|_| "更新文件名无效".to_string())?
|
||
.into_owned();
|
||
if filename.contains('/') || filename.contains('\\') || filename.contains("..") {
|
||
return Err("更新文件名无效".to_string());
|
||
}
|
||
if filename.is_empty() || filename.len() > 128 {
|
||
return Err("更新文件名无效".to_string());
|
||
}
|
||
let response = build_agc_update_download_client()
|
||
.get(parsed)
|
||
.send()
|
||
.await
|
||
.map_err(|_| "下载更新失败".to_string())?;
|
||
if !response.status().is_success() {
|
||
return Err("下载更新失败".to_string());
|
||
}
|
||
if response
|
||
.content_length()
|
||
.is_some_and(|length| length > AGC_UPDATE_MAX_DOWNLOAD_BYTES)
|
||
{
|
||
return Err("更新文件超过大小限制".to_string());
|
||
}
|
||
let download_dir = app
|
||
.path()
|
||
.temp_dir()
|
||
.map_err(|_| "无法定位临时目录".to_string())?
|
||
.join("genarrative-agc-update");
|
||
fs::create_dir_all(&download_dir).map_err(|_| "无法创建临时目录".to_string())?;
|
||
let target = download_dir.join(&filename);
|
||
let temporary = download_dir.join(format!(
|
||
"{}.{}.download",
|
||
filename,
|
||
uuid::Uuid::new_v4().simple()
|
||
));
|
||
let mut file = File::create(&temporary).map_err(|_| "保存更新文件失败".to_string())?;
|
||
let mut hasher = sha2::Sha256::new();
|
||
let total_bytes = response.content_length();
|
||
let mut downloaded_bytes = 0_u64;
|
||
let _ = app.emit(
|
||
AGC_UPDATE_DOWNLOAD_PROGRESS_EVENT,
|
||
AgcUpdateDownloadProgress {
|
||
downloaded_bytes,
|
||
total_bytes,
|
||
},
|
||
);
|
||
let mut stream = response.bytes_stream();
|
||
while let Some(chunk_result) = stream.next().await {
|
||
let chunk = match chunk_result {
|
||
Ok(chunk) => chunk,
|
||
Err(_) => {
|
||
let _ = fs::remove_file(&temporary);
|
||
return Err("读取更新文件失败".to_string());
|
||
}
|
||
};
|
||
downloaded_bytes = match downloaded_bytes.checked_add(chunk.len() as u64) {
|
||
Some(value) if value <= AGC_UPDATE_MAX_DOWNLOAD_BYTES => value,
|
||
_ => {
|
||
let _ = fs::remove_file(&temporary);
|
||
return Err("更新文件超过大小限制".to_string());
|
||
}
|
||
};
|
||
hasher.update(&chunk);
|
||
if file.write_all(&chunk).is_err() {
|
||
let _ = fs::remove_file(&temporary);
|
||
return Err("保存更新文件失败".to_string());
|
||
}
|
||
let _ = app.emit(
|
||
AGC_UPDATE_DOWNLOAD_PROGRESS_EVENT,
|
||
AgcUpdateDownloadProgress {
|
||
downloaded_bytes,
|
||
total_bytes,
|
||
},
|
||
);
|
||
}
|
||
if file.flush().is_err() {
|
||
let _ = fs::remove_file(&temporary);
|
||
return Err("保存更新文件失败".to_string());
|
||
}
|
||
drop(file);
|
||
if let Some(expected_size) = expected_size {
|
||
if downloaded_bytes != expected_size {
|
||
let _ = fs::remove_file(&temporary);
|
||
return Err("更新文件大小校验失败".to_string());
|
||
}
|
||
}
|
||
if let Some(expected_sha256) = expected_sha256 {
|
||
let expected_sha256 = expected_sha256.trim().to_ascii_lowercase();
|
||
if !expected_sha256.bytes().all(|byte| byte.is_ascii_hexdigit())
|
||
|| expected_sha256.len() != 64
|
||
{
|
||
let _ = fs::remove_file(&temporary);
|
||
return Err("更新文件摘要无效".to_string());
|
||
}
|
||
let actual = format!("{:x}", hasher.finalize());
|
||
if actual != expected_sha256 {
|
||
let _ = fs::remove_file(&temporary);
|
||
return Err("更新文件完整性校验失败".to_string());
|
||
}
|
||
}
|
||
if target.exists() {
|
||
let _ = fs::remove_file(&target);
|
||
}
|
||
if let Err(error) = fs::rename(&temporary, &target) {
|
||
let _ = fs::remove_file(&temporary);
|
||
return Err(format!("提交更新文件失败:{error}"));
|
||
}
|
||
let relaunch_path =
|
||
std::env::current_exe().map_err(|error| format!("无法定位客户端程序:{error}"))?;
|
||
launch_agc_installer(&target, &relaunch_path)?;
|
||
app.exit(0);
|
||
Ok(target.to_string_lossy().into_owned())
|
||
}
|
||
|
||
// 调试落盘模块(保存 LLM 原始输出 / 失败输入,排查截断、空返回等)放在 debug_drafts.rs。
|
||
// 用 #[cfg] 编译期门控:仅开发(debug)且非测试构建编入;生产 release 与 cargo test 下整体剔除。
|
||
mod agent;
|
||
mod agent_native_tools;
|
||
mod assets;
|
||
mod browser;
|
||
mod cli;
|
||
mod client_extensions;
|
||
mod collaboration;
|
||
mod command_exec;
|
||
mod command_output;
|
||
mod command_sandbox;
|
||
mod command_sandbox_trampoline;
|
||
mod commands;
|
||
mod config;
|
||
mod context_compaction;
|
||
mod context_menu;
|
||
#[cfg(all(debug_assertions, not(test)))]
|
||
mod debug;
|
||
mod delegation;
|
||
mod git_inspect;
|
||
mod goal;
|
||
mod http_client;
|
||
mod image_inspect;
|
||
mod isolated_agent;
|
||
mod patchset;
|
||
mod platform_session;
|
||
mod preview;
|
||
mod process_session;
|
||
mod process_session_bridge;
|
||
mod project;
|
||
mod provider_handoff;
|
||
mod provider_retry;
|
||
mod repository_context;
|
||
mod resource_inspect;
|
||
mod resource_preview_scheduler;
|
||
mod runner;
|
||
mod swarm_cli;
|
||
mod tool_plan_handoff;
|
||
mod user_input;
|
||
mod windows;
|
||
|
||
use agent::*;
|
||
use agent_native_tools::*;
|
||
use assets::*;
|
||
use browser::*;
|
||
use cli::*;
|
||
use client_extensions::*;
|
||
use collaboration::*;
|
||
use command_exec::*;
|
||
use command_output::*;
|
||
use command_sandbox::*;
|
||
use commands::*;
|
||
use config::*;
|
||
use context_compaction::*;
|
||
use delegation::*;
|
||
use git_inspect::*;
|
||
use goal::*;
|
||
use image_inspect::*;
|
||
use isolated_agent::*;
|
||
use patchset::*;
|
||
use platform_session::*;
|
||
use preview::*;
|
||
use process_session::*;
|
||
use project::*;
|
||
use repository_context::*;
|
||
use resource_inspect::*;
|
||
use resource_preview_scheduler::*;
|
||
use runner::*;
|
||
use swarm_cli::*;
|
||
use user_input::*;
|
||
use windows::*;
|
||
#[tauri::command]
|
||
async fn suggest_ui_design_semantic(
|
||
project_path: String,
|
||
state: ui_editor::state::State,
|
||
) -> Result<Vec<ui_editor::commands::UIDesignSuggestionTreeNode>, String> {
|
||
ui_editor::commands::suggest_ui_design_semantic_impl(project_path, state).await
|
||
}
|
||
|
||
#[tauri::command]
|
||
async fn recognize_ui(
|
||
project_path: String,
|
||
state: ui_editor::state::State,
|
||
) -> Result<ui_editor::commands::RecognitionDTO, String> {
|
||
ui_editor::commands::recognize_ui_impl(project_path, state).await
|
||
}
|
||
|
||
#[tauri::command]
|
||
async fn merge_ui(state: ui_editor::state::State) -> Result<ui_editor::commands::MergeDTO, String> {
|
||
ui_editor::commands::merge_ui_impl(state).await
|
||
}
|
||
|
||
#[tauri::command]
|
||
async fn bind_components(
|
||
project_path: String,
|
||
state: ui_editor::state::State,
|
||
sprite_ids: Vec<String>,
|
||
) -> Result<ui_editor::commands::BindingDTO, String> {
|
||
ui_editor::commands::bind_components_impl(project_path, state, sprite_ids).await
|
||
}
|
||
|
||
#[tauri::command]
|
||
fn load_ui_design_state(
|
||
input: ui_editor::persistence::LoadUiDesignStateInput,
|
||
) -> Result<ui_editor::persistence::UiDesignStateSnapshot, String> {
|
||
let root = Path::new(input.project_path.trim());
|
||
enforce_project_permission_policy(root, "asset.list")?;
|
||
ui_editor::persistence::load_ui_design_state_at(input)
|
||
}
|
||
|
||
#[tauri::command]
|
||
fn save_ui_design_state(
|
||
input: ui_editor::persistence::SaveUiDesignStateInput,
|
||
) -> Result<ui_editor::persistence::SaveUiDesignStateResult, String> {
|
||
let root = Path::new(input.project_path.trim());
|
||
enforce_project_permission_policy(root, "asset.register")?;
|
||
ui_editor::persistence::save_ui_design_state_at(input)
|
||
}
|
||
|
||
#[tauri::command]
|
||
fn generate_ui_design_code(
|
||
input: ui_editor::persistence::GenerateUiDesignCodeInput,
|
||
) -> Result<ui_editor::persistence::GenerateUiDesignCodeResult, String> {
|
||
let root = Path::new(input.project_path.trim());
|
||
enforce_project_permission_policy(root, "file.write")?;
|
||
ui_editor::persistence::generate_ui_design_code_at(input)
|
||
}
|
||
|
||
#[tauri::command]
|
||
fn ensure_ui_design_resource_for_prototype(
|
||
input: ui_editor::resource_bridge::EnsureUiDesignResourceForPrototypeInput,
|
||
) -> Result<ui_editor::resource_bridge::EnsureUiDesignResourceForPrototypeResult, String> {
|
||
ui_editor::resource_bridge::ensure_ui_design_resource_for_prototype(input)
|
||
}
|
||
|
||
#[derive(Debug, Eq, PartialEq, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct InitLocalProjectResult {
|
||
project_path: String,
|
||
manifest_path: String,
|
||
manifest: GameCreationAppManifest,
|
||
}
|
||
|
||
#[derive(Debug, Eq, PartialEq, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct LocalProjectDirectoryStatus {
|
||
project_path: String,
|
||
exists: bool,
|
||
is_directory: bool,
|
||
is_game_creator_project: bool,
|
||
is_godot_project: bool,
|
||
godot_project_root: Option<String>,
|
||
project_name: Option<String>,
|
||
modified_at: Option<u64>,
|
||
manifest_error: Option<String>,
|
||
recent_run_status: Option<String>,
|
||
recent_run_stop_reason: Option<String>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct LocalPreviewResult {
|
||
url: String,
|
||
port: u16,
|
||
root: String,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct LocalPreviewStatus {
|
||
status: String,
|
||
url: Option<String>,
|
||
port: Option<u16>,
|
||
root: Option<String>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct LocalGameProjectRevisionStatus {
|
||
revision: u64,
|
||
}
|
||
|
||
#[derive(Debug, Eq, PartialEq, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct GenerateLocalGameDraftResult {
|
||
project_path: String,
|
||
game_index_path: String,
|
||
design_path: String,
|
||
short_memory_path: String,
|
||
long_memory_path: String,
|
||
manifest: GameCreationAppManifest,
|
||
}
|
||
|
||
#[derive(Debug, Eq, PartialEq, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct GameCreatorChatAgentReply {
|
||
reply_text: String,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct GameCreatorRoleAgentChatStreamEvent {
|
||
project_path: String,
|
||
agent_id: String,
|
||
run_id: String,
|
||
status: String,
|
||
delta_text: String,
|
||
accumulated_text: String,
|
||
finish_reason: Option<String>,
|
||
session_id: Option<String>,
|
||
runtime_status: Option<String>,
|
||
runtime_phase: Option<String>,
|
||
runtime_summary: Option<String>,
|
||
runtime_state: Option<AgentRuntimeState>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct AgentRuntimeState {
|
||
#[serde(default)]
|
||
schema_version: String,
|
||
#[serde(default)]
|
||
agent_id: String,
|
||
#[serde(default)]
|
||
task_id: String,
|
||
#[serde(default)]
|
||
session_id: String,
|
||
#[serde(default)]
|
||
run_id: String,
|
||
#[serde(default)]
|
||
source: String,
|
||
#[serde(default = "default_agent_runtime_run_profile")]
|
||
run_profile: String,
|
||
#[serde(default)]
|
||
run_profile_binding_fingerprint: String,
|
||
#[serde(default)]
|
||
parent_agent_id: Option<String>,
|
||
#[serde(default)]
|
||
parent_run_id: Option<String>,
|
||
#[serde(default)]
|
||
delegation_id: Option<String>,
|
||
#[serde(default)]
|
||
status: String,
|
||
#[serde(default)]
|
||
phase: String,
|
||
#[serde(default)]
|
||
current_task: String,
|
||
#[serde(default)]
|
||
current_goal: String,
|
||
#[serde(default)]
|
||
goal_id: Option<String>,
|
||
#[serde(default)]
|
||
goal_revision: u64,
|
||
#[serde(default)]
|
||
goal_status: Option<String>,
|
||
#[serde(default)]
|
||
goal_outcome: Option<String>,
|
||
#[serde(default)]
|
||
goal_constraints: Vec<String>,
|
||
#[serde(default)]
|
||
goal_verification: Vec<String>,
|
||
#[serde(default)]
|
||
current_action: String,
|
||
#[serde(default)]
|
||
waiting_on: String,
|
||
#[serde(default)]
|
||
next_step: String,
|
||
#[serde(default)]
|
||
loop_iteration: u32,
|
||
/// Consecutive strict Fast-GDD submit rejections for this exact planning
|
||
/// child run. It is Runtime-owned durable state so a restart cannot turn
|
||
/// an invalid-provider-output loop back into an unbounded retry.
|
||
#[serde(default)]
|
||
plan_submit_gdd_rejection_count: u32,
|
||
/// Consecutive rounds where this run produced no action and no structured
|
||
/// plan step advance. Runtime-owned durable state so a runner restart
|
||
/// cannot launder an explanation-only planning loop back into an unbounded
|
||
/// Provider spend.
|
||
#[serde(default)]
|
||
plan_update_idle_rounds: u32,
|
||
/// Consecutive final replies this run had refused by a completion blocker.
|
||
/// Runtime-owned durable state for the same reason as the two counters
|
||
/// above: a blocker the model cannot satisfy is a livelock, and a runner
|
||
/// restart must not launder it back into unbounded Provider spend.
|
||
#[serde(default)]
|
||
stale_finalization_rounds: u32,
|
||
#[serde(default)]
|
||
max_loop_iterations: u32,
|
||
#[serde(default)]
|
||
tool_action_budget: u32,
|
||
#[serde(default)]
|
||
plan_revision: u64,
|
||
#[serde(default)]
|
||
plan_explanation: String,
|
||
#[serde(default)]
|
||
plan: Vec<String>,
|
||
#[serde(default)]
|
||
plan_steps: Vec<AgentRuntimePlanStep>,
|
||
#[serde(default)]
|
||
active_plan_step_index: Option<u32>,
|
||
#[serde(default)]
|
||
observations: Vec<String>,
|
||
#[serde(default)]
|
||
recent_tool_calls: Vec<AgentRuntimeToolCallRecord>,
|
||
#[serde(default)]
|
||
pending_tool_action: Option<AgentRuntimePendingToolActionSummary>,
|
||
#[serde(default)]
|
||
task_queue: AgentRuntimeTaskQueueSummary,
|
||
#[serde(default)]
|
||
allowed_tools: Vec<String>,
|
||
#[serde(default)]
|
||
tool_policy: AgentRuntimeToolPolicySnapshot,
|
||
#[serde(default)]
|
||
applied_steer_cursor: u64,
|
||
#[serde(default)]
|
||
applied_steer_refs: Vec<AgentRuntimeSteerRef>,
|
||
#[serde(default)]
|
||
queued_steer_count: u32,
|
||
#[serde(default)]
|
||
context_usage: AgentRuntimeContextUsage,
|
||
#[serde(default)]
|
||
last_response: Option<String>,
|
||
#[serde(default)]
|
||
error: Option<String>,
|
||
#[serde(default)]
|
||
started_at: u64,
|
||
#[serde(default)]
|
||
updated_at: u64,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct AgentRuntimeContextUsage {
|
||
#[serde(default)]
|
||
estimated_input_tokens: u64,
|
||
#[serde(default)]
|
||
auto_compact_token_limit: u64,
|
||
#[serde(default)]
|
||
last_prompt_tokens: Option<u64>,
|
||
#[serde(default)]
|
||
last_completion_tokens: Option<u64>,
|
||
#[serde(default)]
|
||
last_total_tokens: Option<u64>,
|
||
#[serde(default)]
|
||
compaction_revision: u64,
|
||
#[serde(default)]
|
||
compaction_count: u64,
|
||
#[serde(default)]
|
||
last_compaction_trigger: Option<String>,
|
||
#[serde(default)]
|
||
last_compacted_at: Option<u64>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct AgentRuntimeSteerRef {
|
||
#[serde(default)]
|
||
steer_id: String,
|
||
#[serde(default)]
|
||
sequence: u64,
|
||
#[serde(default)]
|
||
message_id: String,
|
||
#[serde(default)]
|
||
instruction_sha256: String,
|
||
#[serde(default)]
|
||
content_chars: u32,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct AgentRuntimeToolPolicySnapshot {
|
||
#[serde(default = "default_agent_runtime_run_profile")]
|
||
run_profile: String,
|
||
#[serde(default)]
|
||
run_profile_binding_fingerprint: String,
|
||
#[serde(default)]
|
||
allowed_tools: Vec<String>,
|
||
#[serde(default)]
|
||
auto_tools: Vec<String>,
|
||
#[serde(default)]
|
||
confirm_tools: Vec<String>,
|
||
#[serde(default)]
|
||
denied_tools: Vec<String>,
|
||
#[serde(default)]
|
||
updated_at: u64,
|
||
}
|
||
|
||
impl Default for AgentRuntimeToolPolicySnapshot {
|
||
fn default() -> Self {
|
||
Self {
|
||
run_profile: default_agent_runtime_run_profile(),
|
||
run_profile_binding_fingerprint: String::new(),
|
||
allowed_tools: Vec::new(),
|
||
auto_tools: Vec::new(),
|
||
confirm_tools: Vec::new(),
|
||
denied_tools: Vec::new(),
|
||
updated_at: 0,
|
||
}
|
||
}
|
||
}
|
||
|
||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct AgentRuntimeToolCallRecord {
|
||
#[serde(default)]
|
||
action_id: Option<String>,
|
||
#[serde(default)]
|
||
tool: String,
|
||
#[serde(default)]
|
||
status: String,
|
||
#[serde(default)]
|
||
action_fingerprint: Option<String>,
|
||
#[serde(default)]
|
||
input_summary: Option<String>,
|
||
#[serde(default)]
|
||
reason: Option<String>,
|
||
#[serde(default)]
|
||
summary: String,
|
||
#[serde(default)]
|
||
detail: Option<String>,
|
||
#[serde(default)]
|
||
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 {
|
||
#[serde(default)]
|
||
index: u32,
|
||
#[serde(default)]
|
||
title: String,
|
||
#[serde(default)]
|
||
status: String,
|
||
#[serde(default)]
|
||
detail: Option<String>,
|
||
#[serde(default)]
|
||
updated_at: u64,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct AgentRuntimeTaskQueueSummary {
|
||
#[serde(default)]
|
||
total: u32,
|
||
#[serde(default)]
|
||
pending: u32,
|
||
#[serde(default)]
|
||
running: u32,
|
||
#[serde(default)]
|
||
waiting_for_confirmation: u32,
|
||
#[serde(default)]
|
||
waiting_for_user_input: u32,
|
||
#[serde(default)]
|
||
paused: u32,
|
||
#[serde(default)]
|
||
cancelled: u32,
|
||
#[serde(default)]
|
||
completed: u32,
|
||
#[serde(default)]
|
||
failed: u32,
|
||
#[serde(default)]
|
||
latest_run_id: Option<String>,
|
||
#[serde(default)]
|
||
updated_at: u64,
|
||
}
|
||
|
||
impl Default for AgentRuntimeTaskQueueSummary {
|
||
fn default() -> Self {
|
||
Self {
|
||
total: 0,
|
||
pending: 0,
|
||
running: 0,
|
||
waiting_for_confirmation: 0,
|
||
waiting_for_user_input: 0,
|
||
paused: 0,
|
||
cancelled: 0,
|
||
completed: 0,
|
||
failed: 0,
|
||
latest_run_id: None,
|
||
updated_at: 0,
|
||
}
|
||
}
|
||
}
|
||
|
||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct AgentRuntimeEvent {
|
||
#[serde(default)]
|
||
schema_version: String,
|
||
#[serde(default)]
|
||
agent_id: String,
|
||
#[serde(default)]
|
||
task_id: String,
|
||
#[serde(default)]
|
||
session_id: String,
|
||
#[serde(default)]
|
||
run_id: String,
|
||
#[serde(default)]
|
||
source: String,
|
||
#[serde(default)]
|
||
event_type: String,
|
||
#[serde(default)]
|
||
event_id: String,
|
||
#[serde(default)]
|
||
action_id: Option<String>,
|
||
#[serde(default)]
|
||
status: String,
|
||
#[serde(default)]
|
||
phase: String,
|
||
#[serde(default)]
|
||
summary: String,
|
||
#[serde(default)]
|
||
public_text: Option<String>,
|
||
#[serde(default)]
|
||
detail: Option<String>,
|
||
#[serde(default)]
|
||
updated_at: u64,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||
struct AgentRuntimeResponseStream {
|
||
schema_version: String,
|
||
agent_id: String,
|
||
task_id: String,
|
||
session_id: String,
|
||
run_id: String,
|
||
request_kind: String,
|
||
request_slot: String,
|
||
applied_steer_cursor: u64,
|
||
response_revision: u64,
|
||
sequence: u64,
|
||
status: String,
|
||
accumulated_text: String,
|
||
finish_reason: Option<String>,
|
||
started_at: u64,
|
||
updated_at: u64,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct AgentRuntimeTaskRecord {
|
||
#[serde(default)]
|
||
schema_version: String,
|
||
#[serde(default)]
|
||
agent_id: String,
|
||
#[serde(default)]
|
||
task_id: String,
|
||
#[serde(default)]
|
||
session_id: String,
|
||
#[serde(default)]
|
||
run_id: String,
|
||
#[serde(default)]
|
||
source: String,
|
||
#[serde(default = "default_agent_runtime_run_profile")]
|
||
run_profile: String,
|
||
#[serde(default)]
|
||
run_profile_binding_fingerprint: String,
|
||
#[serde(default)]
|
||
parent_agent_id: Option<String>,
|
||
#[serde(default)]
|
||
parent_run_id: Option<String>,
|
||
#[serde(default)]
|
||
delegation_id: Option<String>,
|
||
#[serde(default)]
|
||
goal_id: Option<String>,
|
||
#[serde(default)]
|
||
goal_revision: u64,
|
||
#[serde(default)]
|
||
goal_status: Option<String>,
|
||
#[serde(default)]
|
||
task: String,
|
||
#[serde(default)]
|
||
status: String,
|
||
#[serde(default)]
|
||
phase: String,
|
||
#[serde(default)]
|
||
current_action: String,
|
||
#[serde(default)]
|
||
terminal_detail: Option<String>,
|
||
#[serde(default)]
|
||
error: Option<String>,
|
||
#[serde(default)]
|
||
updated_at: u64,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||
struct AgentGoalRecord {
|
||
schema_version: String,
|
||
project_id: String,
|
||
goal_id: String,
|
||
agent_id: String,
|
||
session_id: String,
|
||
run_id: String,
|
||
revision: u64,
|
||
status: String,
|
||
outcome: String,
|
||
constraints: Vec<String>,
|
||
verification: Vec<String>,
|
||
#[serde(default)]
|
||
completion_evidence: Vec<String>,
|
||
#[serde(default)]
|
||
response_fingerprint: Option<String>,
|
||
created_at: u64,
|
||
#[serde(default)]
|
||
pause_requested_at: Option<u64>,
|
||
#[serde(default)]
|
||
paused_at: Option<u64>,
|
||
#[serde(default)]
|
||
completed_at: Option<u64>,
|
||
#[serde(default)]
|
||
cleared_at: Option<u64>,
|
||
#[serde(default)]
|
||
error: Option<String>,
|
||
updated_at: u64,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct AgentGoalMutationResult {
|
||
goal: AgentGoalRecord,
|
||
runtime: AgentRuntimeResult,
|
||
provider_interrupted: bool,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct AgentRuntimeResult {
|
||
state: AgentRuntimeState,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
accepted_run_id: Option<String>,
|
||
session_path: String,
|
||
event_path: String,
|
||
task_path: String,
|
||
task_queue: AgentRuntimeTaskQueueSummary,
|
||
recent_events: Vec<AgentRuntimeEvent>,
|
||
recent_tasks: Vec<AgentRuntimeTaskRecord>,
|
||
response_stream: Option<AgentRuntimeResponseStream>,
|
||
user_input_request: Option<AgentRuntimeUserInputRequestView>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct AgentRuntimeSteerResult {
|
||
runtime: AgentRuntimeResult,
|
||
steer_id: String,
|
||
sequence: u64,
|
||
status: String,
|
||
provider_interrupted: bool,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
assistant_reply: Option<String>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
interrupt_decision: Option<bool>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
decision_reason: Option<String>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct GameCreatorAgentRuntimeUpdateEvent {
|
||
project_path: String,
|
||
agent_id: String,
|
||
run_id: String,
|
||
status: String,
|
||
phase: String,
|
||
manifest_invalidated: bool,
|
||
runtime: AgentRuntimeResult,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct GameCreatorManifestInvalidatedEvent {
|
||
project_path: String,
|
||
agent_id: String,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct GameCreatorManifestInvalidationRelayEnvelope {
|
||
token: String,
|
||
event: GameCreatorManifestInvalidatedEvent,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||
struct GameCreatorManifestInvalidationEventSink {
|
||
port: u16,
|
||
token: String,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct GameCreatorAgentProgressEvent {
|
||
project_path: String,
|
||
stage: String,
|
||
message: String,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct GameCreatorDirectTurnUpdateEvent {
|
||
project_path: String,
|
||
turn_id: String,
|
||
sequence: u64,
|
||
status: String,
|
||
activity: Option<String>,
|
||
accumulated_text: Option<String>,
|
||
updated_at: u64,
|
||
}
|
||
|
||
#[derive(Debug, Eq, PartialEq, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct GameCreatorLlmConfigStatus {
|
||
agent_mode: String,
|
||
configured: bool,
|
||
account_credential_state: String,
|
||
official_route_locked: bool,
|
||
reasoning_effort: String,
|
||
stream: bool,
|
||
web_search_enabled: bool,
|
||
context_window_tokens: u64,
|
||
auto_compact_token_limit: u64,
|
||
tool_output_token_limit: u64,
|
||
request_timeout_ms: u64,
|
||
max_retries: u32,
|
||
retry_backoff_ms: u64,
|
||
error: Option<String>,
|
||
agents: Vec<GameCreatorAgentLlmConfigStatus>,
|
||
}
|
||
|
||
#[derive(Debug, Eq, PartialEq, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct GameCreatorAgentLlmConfigStatus {
|
||
agent_mode: String,
|
||
agent_id: String,
|
||
label: String,
|
||
configured: bool,
|
||
account_credential_state: String,
|
||
official_route_locked: bool,
|
||
reasoning_effort: String,
|
||
stream: bool,
|
||
web_search_enabled: bool,
|
||
context_window_tokens: u64,
|
||
auto_compact_token_limit: u64,
|
||
tool_output_token_limit: u64,
|
||
request_timeout_ms: u64,
|
||
max_retries: u32,
|
||
retry_backoff_ms: u64,
|
||
error: Option<String>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct GameCreatorAppConfigFile {
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
schema_version: Option<String>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
agent_mode: Option<String>,
|
||
llm: Option<GameCreatorLlmConfigFile>,
|
||
agent_llm: Option<BTreeMap<String, GameCreatorLlmConfigFile>>,
|
||
editor_api: Option<GameCreatorEditorApiConfigFile>,
|
||
planning: Option<GameCreatorPlanningConfigFile>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct GameCreatorPlanningConfigFile {
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
capability_enabled: Option<bool>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct GameCreatorLlmConfigFile {
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
api_key: Option<String>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
base_url: Option<String>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
model: Option<String>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
api_kind: Option<String>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
reasoning_effort: Option<String>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
stream: Option<bool>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
web_search_enabled: Option<bool>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
context_window_tokens: Option<u64>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
auto_compact_token_limit: Option<u64>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
tool_output_token_limit: Option<u64>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
request_timeout_ms: Option<u64>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
max_retries: Option<u32>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
retry_backoff_ms: Option<u64>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct GameCreatorEditorApiConfigFile {
|
||
base_url: Option<String>,
|
||
api_key: Option<String>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct GameCreatorAppConfig {
|
||
#[serde(default = "default_game_creator_app_config_schema_version")]
|
||
schema_version: String,
|
||
#[serde(default = "default_game_creator_agent_mode")]
|
||
agent_mode: String,
|
||
llm: GameCreatorLlmConfig,
|
||
#[serde(default)]
|
||
agent_llm: BTreeMap<String, GameCreatorLlmConfigFile>,
|
||
editor_api: GameCreatorEditorApiConfig,
|
||
#[serde(default)]
|
||
planning: GameCreatorPlanningConfig,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct GameCreatorPlanningConfig {
|
||
#[serde(default = "default_game_creator_planning_capability_enabled")]
|
||
capability_enabled: bool,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct GameCreatorLlmConfig {
|
||
api_key: String,
|
||
base_url: String,
|
||
model: String,
|
||
api_kind: String,
|
||
reasoning_effort: String,
|
||
stream: bool,
|
||
web_search_enabled: bool,
|
||
#[serde(default = "default_game_creator_llm_context_window_tokens")]
|
||
context_window_tokens: u64,
|
||
#[serde(default = "default_game_creator_llm_auto_compact_token_limit")]
|
||
auto_compact_token_limit: u64,
|
||
#[serde(default = "default_game_creator_llm_tool_output_token_limit")]
|
||
tool_output_token_limit: u64,
|
||
request_timeout_ms: u64,
|
||
max_retries: u32,
|
||
retry_backoff_ms: u64,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct GameCreatorEditorApiConfig {
|
||
base_url: String,
|
||
api_key: String,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct GameCreatorAppConfigView {
|
||
path: String,
|
||
config: GameCreatorAppConfig,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct AgentRuntimeContextCompactionResult {
|
||
agent_id: String,
|
||
session_id: String,
|
||
run_id: Option<String>,
|
||
trigger: String,
|
||
revision: u64,
|
||
estimated_tokens_before: u64,
|
||
estimated_tokens_after: u64,
|
||
prompt_tokens: Option<u64>,
|
||
completion_tokens: Option<u64>,
|
||
total_tokens: Option<u64>,
|
||
covered_agent_messages: u64,
|
||
covered_project_messages: u64,
|
||
covered_observations: u64,
|
||
reused: bool,
|
||
compacted_at: u64,
|
||
}
|
||
|
||
#[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 {
|
||
id: String,
|
||
local_path: String,
|
||
absolute_path: String,
|
||
manifest_path: String,
|
||
}
|
||
|
||
#[derive(Debug, Eq, PartialEq, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct CreateUiDesignResourceResult {
|
||
asset: UploadLocalAssetResult,
|
||
manifest: GameCreationAppManifest,
|
||
committed_project_revision: u64,
|
||
}
|
||
|
||
#[derive(Debug, Eq, PartialEq, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct ImportCanvasExportResult {
|
||
import_root: String,
|
||
metadata_path: String,
|
||
imported_count: usize,
|
||
assets: Vec<UploadLocalAssetResult>,
|
||
}
|
||
|
||
#[derive(Debug, Eq, PartialEq, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct SyncCanvasProjectAssetsResult {
|
||
canvas_project_id: String,
|
||
import_root: String,
|
||
imported_count: usize,
|
||
assets: Vec<UploadLocalAssetResult>,
|
||
}
|
||
|
||
#[derive(Debug, Eq, PartialEq, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct LocalImportResult {
|
||
assets: Vec<ImportedAsset>,
|
||
}
|
||
|
||
type RemoteImportResult = LocalImportResult;
|
||
|
||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct ImportedAsset {
|
||
id: String,
|
||
local_path: String,
|
||
asset_kind: Option<String>,
|
||
}
|
||
|
||
#[derive(Debug, Eq, PartialEq)]
|
||
struct GeneratedPlatformArtAssetSlice {
|
||
name: String,
|
||
width: u32,
|
||
height: u32,
|
||
local_path: String,
|
||
resource_id: Option<String>,
|
||
asset_object_id: Option<String>,
|
||
content_sha256: String,
|
||
pixel_sha256: String,
|
||
}
|
||
|
||
#[derive(Debug, Eq, PartialEq)]
|
||
struct GeneratedPlatformArtAsset {
|
||
asset: UploadLocalAssetResult,
|
||
slices: Vec<GeneratedPlatformArtAssetSlice>,
|
||
resource_id: Option<String>,
|
||
asset_object_id: Option<String>,
|
||
task_id: Option<String>,
|
||
model: Option<String>,
|
||
warning: Option<String>,
|
||
slice_warning: Option<String>,
|
||
}
|
||
|
||
#[derive(Debug, Eq, PartialEq, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct OpenCanvasProjectResult {
|
||
url: String,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct CanvasExportMetadata {
|
||
project_title: String,
|
||
exported_at: String,
|
||
layers: Vec<CanvasExportLayerMetadata>,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct CanvasExportLayerMetadata {
|
||
title: String,
|
||
file: Option<String>,
|
||
visible: CanvasExportVisibleMetadata,
|
||
export_error: Option<String>,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct CanvasExportVisibleMetadata {
|
||
#[serde(rename = "type")]
|
||
layer_type: String,
|
||
model: String,
|
||
task: String,
|
||
object: String,
|
||
}
|
||
|
||
#[derive(Debug, Eq, PartialEq, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct LocalGameMemoryResult {
|
||
scope: String,
|
||
path: String,
|
||
content: String,
|
||
exists: bool,
|
||
}
|
||
|
||
#[derive(Debug, Eq, PartialEq, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct LocalAgentMemoryResult {
|
||
task_id: String,
|
||
path: String,
|
||
content: String,
|
||
exists: bool,
|
||
}
|
||
|
||
#[derive(Debug, Eq, PartialEq, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct LimitedLocalCommandResult {
|
||
command_id: String,
|
||
status: String,
|
||
output: String,
|
||
log_path: String,
|
||
updated_at: u64,
|
||
}
|
||
|
||
#[derive(Debug, Eq, PartialEq, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct LocalProjectFileEntry {
|
||
path: String,
|
||
kind: String,
|
||
size: u64,
|
||
modified_at: u64,
|
||
}
|
||
|
||
#[derive(Debug, Eq, PartialEq, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct ListLocalProjectFilesResult {
|
||
project_path: String,
|
||
files: Vec<LocalProjectFileEntry>,
|
||
}
|
||
|
||
#[derive(Debug, Eq, PartialEq, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct LocalProjectFileResult {
|
||
path: String,
|
||
absolute_path: String,
|
||
content: String,
|
||
}
|
||
|
||
#[derive(Debug, Eq, PartialEq, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct LocalProjectFileMutationResult {
|
||
path: String,
|
||
absolute_path: String,
|
||
deleted: bool,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct LocalConversationMessage {
|
||
role: String,
|
||
content: String,
|
||
agent_id: Option<String>,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct LocalConversationMessageRecord {
|
||
schema_version: String,
|
||
role: String,
|
||
content: String,
|
||
agent_id: Option<String>,
|
||
message_id: Option<String>,
|
||
updated_at: u64,
|
||
}
|
||
|
||
#[derive(Debug, Eq, PartialEq, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct LocalConversationResult {
|
||
path: String,
|
||
agent_id: Option<String>,
|
||
session_id: Option<String>,
|
||
messages: Vec<LocalConversationMessageRecord>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct AgentConversationSessionRecord {
|
||
session_id: String,
|
||
title: String,
|
||
created_at: u64,
|
||
updated_at: u64,
|
||
archived_at: Option<u64>,
|
||
message_count: u64,
|
||
legacy: bool,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
forked_from_session_id: Option<String>,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
forked_message_count: Option<u64>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct AgentConversationSessionListResult {
|
||
path: String,
|
||
agent_id: String,
|
||
active_session_id: String,
|
||
sessions: Vec<AgentConversationSessionRecord>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct ProjectPermissionPolicy {
|
||
denied_commands: Vec<String>,
|
||
confirm_commands: Vec<String>,
|
||
#[serde(default)]
|
||
agent_policies: BTreeMap<String, ProjectAgentPermissionPolicy>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct ProjectAgentPermissionPolicy {
|
||
#[serde(default)]
|
||
denied_commands: Vec<String>,
|
||
#[serde(default)]
|
||
confirm_commands: Vec<String>,
|
||
}
|
||
|
||
#[derive(Debug, Eq, PartialEq, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct ProjectPermissionPolicyView {
|
||
path: String,
|
||
policy: ProjectPermissionPolicy,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct LocalProjectIndexedFile {
|
||
path: String,
|
||
size: u64,
|
||
checksum: String,
|
||
}
|
||
|
||
#[derive(Debug, Eq, PartialEq, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct LocalProjectIndexResult {
|
||
project_path: String,
|
||
index_path: String,
|
||
file_count: usize,
|
||
total_bytes: u64,
|
||
files: Vec<LocalProjectIndexedFile>,
|
||
}
|
||
|
||
#[derive(Debug, Eq, PartialEq, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct LocalProjectCheckpointResult {
|
||
checkpoint_id: String,
|
||
checkpoint_path: String,
|
||
file_count: usize,
|
||
total_bytes: u64,
|
||
}
|
||
|
||
#[derive(Debug, Eq, PartialEq, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct LocalProjectExportPackageResult {
|
||
package_path: String,
|
||
package_relative_path: String,
|
||
file_count: usize,
|
||
total_bytes: u64,
|
||
}
|
||
|
||
#[derive(Debug, Eq, PartialEq, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct LocalProjectExportPackageSummary {
|
||
package_path: String,
|
||
package_relative_path: String,
|
||
total_bytes: u64,
|
||
modified_at: u64,
|
||
}
|
||
|
||
#[derive(Debug, Eq, PartialEq, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct LocalProjectExportPackagesResult {
|
||
project_path: String,
|
||
packages: Vec<LocalProjectExportPackageSummary>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct LocalProjectDiffEntry {
|
||
path: String,
|
||
status: String,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct LocalProjectDiffResult {
|
||
checkpoint_id: String,
|
||
added: Vec<LocalProjectDiffEntry>,
|
||
changed: Vec<LocalProjectDiffEntry>,
|
||
deleted: Vec<LocalProjectDiffEntry>,
|
||
}
|
||
|
||
#[derive(Debug, Eq, PartialEq, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct LocalProjectRestoreResult {
|
||
checkpoint_id: String,
|
||
restored_count: usize,
|
||
deleted_count: usize,
|
||
}
|
||
|
||
const DEFAULT_GAME_INDEX_HTML: &str = r#"<!doctype html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<title>Genarrative Game Draft</title>
|
||
<style>
|
||
body { margin: 0; display: grid; min-height: 100vh; place-items: center; background: #101827; color: #d9e7ff; font: 16px system-ui, sans-serif; }
|
||
main { width: min(720px, calc(100vw - 32px)); }
|
||
</style>
|
||
</head>
|
||
<body><main>还没有生成游戏。回到聊天输入创意并确认生成后,这里会写入可试玩原型。</main></body>
|
||
</html>
|
||
"#;
|
||
|
||
const DEFAULT_EDITOR_BASE_URL: &str = "http://127.0.0.1:3000";
|
||
const GAME_CREATOR_CONFIG_FILE_NAME: &str = "game-creator.config.json";
|
||
const GAME_CREATOR_LOCAL_CONFIG_FILE_NAME: &str = "game-creator.config.local.json";
|
||
const GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER: &str = "codex_app_server";
|
||
const GAME_CREATOR_AGENT_MODE_CODEX_CLI: &str = "codex_cli";
|
||
const GAME_CREATOR_AGENT_MODE_PROVIDER: &str = "provider";
|
||
const GAME_CREATOR_APP_CONFIG_SCHEMA_VERSION: &str = "game-creator-config.v2";
|
||
const DEFAULT_GAME_CREATOR_LLM_BASE_URL: &str = "https://dev.genarrative.world/gpt/v1";
|
||
const DEFAULT_GAME_CREATOR_LLM_MODEL: &str = "gpt-5.6-sol";
|
||
const DEFAULT_GAME_CREATOR_LLM_API_KIND: &str = "openai_responses";
|
||
const DEFAULT_GAME_CREATOR_LLM_REASONING_EFFORT: &str = "max";
|
||
const DEFAULT_GAME_CREATOR_LLM_CONTEXT_WINDOW_TOKENS: u64 = 128_000;
|
||
const DEFAULT_GAME_CREATOR_LLM_AUTO_COMPACT_TOKEN_LIMIT: u64 = 64_000;
|
||
const DEFAULT_GAME_CREATOR_LLM_TOOL_OUTPUT_TOKEN_LIMIT: u64 = 12_000;
|
||
const DEFAULT_GAME_CREATOR_LLM_MAX_RETRIES: u32 = 2;
|
||
|
||
fn default_game_creator_agent_mode() -> String {
|
||
GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER.to_string()
|
||
}
|
||
|
||
fn default_game_creator_app_config_schema_version() -> String {
|
||
GAME_CREATOR_APP_CONFIG_SCHEMA_VERSION.to_string()
|
||
}
|
||
|
||
fn default_game_creator_llm_context_window_tokens() -> u64 {
|
||
DEFAULT_GAME_CREATOR_LLM_CONTEXT_WINDOW_TOKENS
|
||
}
|
||
|
||
fn default_game_creator_llm_auto_compact_token_limit() -> u64 {
|
||
DEFAULT_GAME_CREATOR_LLM_AUTO_COMPACT_TOKEN_LIMIT
|
||
}
|
||
|
||
fn default_game_creator_llm_tool_output_token_limit() -> u64 {
|
||
DEFAULT_GAME_CREATOR_LLM_TOOL_OUTPUT_TOKEN_LIMIT
|
||
}
|
||
|
||
fn default_game_creator_planning_capability_enabled() -> bool {
|
||
true
|
||
}
|
||
|
||
const DEFAULT_CANVAS_SYNC_API_BASE_URL: &str = "https://dev.genarrative.world";
|
||
const DEFAULT_GAME_CREATOR_APP_CONFIG_JSON: &str = include_str!("../../game-creator.config.json");
|
||
const GAME_CREATOR_LLM_MAX_OUTPUT_TOKENS: u32 = 320000;
|
||
const GAME_CREATOR_CHAT_AGENT_MAX_OUTPUT_TOKENS: u32 = 1800;
|
||
const GAME_CREATOR_PLANNER_MAX_OUTPUT_TOKENS: u32 = 900;
|
||
const GAME_CREATOR_ROLE_AGENT_MAX_OUTPUT_TOKENS: u32 = 1200;
|
||
const GAME_CREATOR_REQUIRED_LLM_AGENT_IDS: [&str; 3] = [
|
||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||
"planner",
|
||
"generator",
|
||
];
|
||
const MIN_GAME_CREATOR_LLM_REQUEST_TIMEOUT_MS: u64 = 1_000;
|
||
const GAME_CREATOR_LLM_REQUEST_TIMEOUT_MS: u64 = 180_000;
|
||
const GAME_CREATOR_AGENT_LOOP_MAX_PASSES: u8 = 3;
|
||
const GAME_CREATOR_AGENT_TOOL_CALL_MAX: u16 = GAME_CREATION_AGENT_TOOL_CALL_MAX;
|
||
const GAME_CREATOR_AGENT_RUN_HISTORY_MAX_COUNT: usize = 100;
|
||
const GAME_CREATOR_AGENT_DB_SCHEMA_VERSION: &str = "game-creator-agent-db.v1";
|
||
const PROJECT_BLACKBOARD_MEMORY_PATH: &str = "memory/blackboard.md";
|
||
const PROJECT_PERMISSION_POLICY_PATH: &str = ".agent/policy.json";
|
||
const PROJECT_INDEX_PATH: &str = ".agent/project.index.json";
|
||
const PROJECT_WRITE_LOCK_PATH: &str = ".agent/project.lock";
|
||
const LOCAL_CONVERSATION_SCHEMA_VERSION: &str = "game-creator-conversation.v1";
|
||
const AGENT_CONVERSATION_SESSION_SCHEMA_VERSION: &str = "game-creator-agent-sessions.v1";
|
||
const AGENT_RUNTIME_SCHEMA_VERSION: &str = "game-creator-agent-runtime.v1";
|
||
const AGENT_RUNTIME_RUN_PROFILE_STANDARD: &str = "standard";
|
||
const AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD: &str = "autonomous-game-build";
|
||
const AGENT_RUNTIME_AUTONOMOUS_SOURCE_FIELD_MAX_CHARS: usize = 8_000;
|
||
const AGENT_RUNTIME_AUTONOMOUS_SOURCE_TOTAL_MAX_CHARS: usize = 10_000;
|
||
const AGENT_RUNTIME_RECENT_EVENT_LIMIT: usize = 20;
|
||
const AGENT_RUNTIME_RECENT_TASK_LIMIT: usize = 12;
|
||
const GAME_CREATOR_CONVERSATION_CONTEXT_MAX_MESSAGES: usize = 12;
|
||
|
||
fn default_agent_runtime_run_profile() -> String {
|
||
AGENT_RUNTIME_RUN_PROFILE_STANDARD.to_string()
|
||
}
|
||
const MAX_CANVAS_EXPORT_FILES: usize = 500;
|
||
const MAX_CANVAS_EXPORT_BYTES: u64 = 512 * 1024 * 1024;
|
||
const MAX_PROJECT_EXPORT_PACKAGE_FILES: usize = 1200;
|
||
const MAX_PROJECT_EXPORT_PACKAGE_BYTES: u64 = 512 * 1024 * 1024;
|
||
const GAME_CREATOR_AGENT_ARTIFACT_PATHS: [&str; 15] = [
|
||
".agent/agent.db",
|
||
".agent/spec.md",
|
||
".agent/findings.md",
|
||
".agent/logs/agent.log",
|
||
".agent/logs/command.log",
|
||
".agent/logs/preview.log",
|
||
"memory/session.md",
|
||
"memory/project.md",
|
||
PROJECT_BLACKBOARD_MEMORY_PATH,
|
||
"game/game_design.md",
|
||
"game/balance.json",
|
||
"assets/manifest.art.json",
|
||
"assets/manifest.audio.json",
|
||
"exports/README.md",
|
||
"game/index.html",
|
||
];
|
||
static GAME_CREATOR_RUNTIME_CONFIG_DIR: OnceLock<Mutex<Option<PathBuf>>> = OnceLock::new();
|
||
|
||
impl Default for GameCreatorAppConfig {
|
||
fn default() -> Self {
|
||
let mut llm = GameCreatorLlmConfig::default();
|
||
// DirectProject is the shipped product route, so the application-level
|
||
// default enables the controlled AGC search tool. The bare
|
||
// GameCreatorLlmConfig default remains conservative for legacy callers.
|
||
llm.web_search_enabled = true;
|
||
Self {
|
||
schema_version: default_game_creator_app_config_schema_version(),
|
||
agent_mode: default_game_creator_agent_mode(),
|
||
llm,
|
||
agent_llm: BTreeMap::new(),
|
||
editor_api: GameCreatorEditorApiConfig::default(),
|
||
planning: GameCreatorPlanningConfig::default(),
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Default for GameCreatorPlanningConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
capability_enabled: default_game_creator_planning_capability_enabled(),
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Default for GameCreatorLlmConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
api_key: String::new(),
|
||
base_url: DEFAULT_GAME_CREATOR_LLM_BASE_URL.to_string(),
|
||
model: DEFAULT_GAME_CREATOR_LLM_MODEL.to_string(),
|
||
api_kind: DEFAULT_GAME_CREATOR_LLM_API_KIND.to_string(),
|
||
reasoning_effort: DEFAULT_GAME_CREATOR_LLM_REASONING_EFFORT.to_string(),
|
||
stream: true,
|
||
web_search_enabled: false,
|
||
context_window_tokens: DEFAULT_GAME_CREATOR_LLM_CONTEXT_WINDOW_TOKENS,
|
||
auto_compact_token_limit: DEFAULT_GAME_CREATOR_LLM_AUTO_COMPACT_TOKEN_LIMIT,
|
||
tool_output_token_limit: DEFAULT_GAME_CREATOR_LLM_TOOL_OUTPUT_TOKEN_LIMIT,
|
||
request_timeout_ms: GAME_CREATOR_LLM_REQUEST_TIMEOUT_MS,
|
||
max_retries: DEFAULT_GAME_CREATOR_LLM_MAX_RETRIES,
|
||
retry_backoff_ms: DEFAULT_RETRY_BACKOFF_MS,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Default for GameCreatorEditorApiConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
base_url: DEFAULT_CANVAS_SYNC_API_BASE_URL.to_string(),
|
||
api_key: String::new(),
|
||
}
|
||
}
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug)]
|
||
struct AgentRoleDefinition {
|
||
id: &'static str,
|
||
role: &'static str,
|
||
task_id: &'static str,
|
||
tool_id: &'static str,
|
||
brief_path_name: &'static str,
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug)]
|
||
struct AgentGroupDefinition {
|
||
id: &'static str,
|
||
label: &'static str,
|
||
role: &'static str,
|
||
brief_path_name: &'static str,
|
||
roles: &'static [AgentRoleDefinition],
|
||
}
|
||
|
||
include!(concat!(env!("OUT_DIR"), "/agent_runtime_prompt_bundle.rs"));
|
||
|
||
const GAME_CREATOR_LEGACY_CHAT_AGENT_CONFIG_ID: &str = "chat";
|
||
|
||
struct GameCreatorLlmAgentStatusDefinition {
|
||
agent_id: String,
|
||
label: String,
|
||
}
|
||
|
||
fn game_creator_llm_agent_status_definitions() -> Vec<GameCreatorLlmAgentStatusDefinition> {
|
||
let mut agents = vec![
|
||
GameCreatorLlmAgentStatusDefinition {
|
||
agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(),
|
||
label: format!("{} Agent", PROJECT_SUPERVISOR_AGENT_DEFINITION.label),
|
||
},
|
||
GameCreatorLlmAgentStatusDefinition {
|
||
agent_id: "planner".to_string(),
|
||
label: "Planner".to_string(),
|
||
},
|
||
GameCreatorLlmAgentStatusDefinition {
|
||
agent_id: "orchestrator".to_string(),
|
||
label: "Orchestrator".to_string(),
|
||
},
|
||
GameCreatorLlmAgentStatusDefinition {
|
||
agent_id: "generator".to_string(),
|
||
label: "Generator".to_string(),
|
||
},
|
||
GameCreatorLlmAgentStatusDefinition {
|
||
agent_id: "evaluator".to_string(),
|
||
label: "Evaluator".to_string(),
|
||
},
|
||
];
|
||
for group in GAME_CREATOR_AGENT_GROUP_DEFINITIONS {
|
||
for role in group.roles {
|
||
if agents.iter().any(|agent| agent.agent_id == role.task_id) {
|
||
continue;
|
||
}
|
||
agents.push(GameCreatorLlmAgentStatusDefinition {
|
||
agent_id: role.task_id.to_string(),
|
||
label: format!("{} / {}", group.label, role.role),
|
||
});
|
||
}
|
||
}
|
||
agents
|
||
}
|
||
|
||
#[derive(Clone, Debug)]
|
||
struct AgentPassArtifactPaths {
|
||
draft_json: String,
|
||
design_markdown: String,
|
||
balance_json: String,
|
||
art_manifest_json: String,
|
||
audio_manifest_json: String,
|
||
publish_readme: String,
|
||
game_html: String,
|
||
handoff_markdown: String,
|
||
}
|
||
|
||
#[derive(Clone, Debug)]
|
||
struct AgentRoleBrief {
|
||
group_definition: AgentGroupDefinition,
|
||
role_definition: AgentRoleDefinition,
|
||
markdown: String,
|
||
relative_path: String,
|
||
memory_relative_path: String,
|
||
status: String,
|
||
tool_id: String,
|
||
summary: String,
|
||
}
|
||
|
||
#[derive(Clone, Debug)]
|
||
struct AgentGroupBrief {
|
||
definition: AgentGroupDefinition,
|
||
markdown: String,
|
||
relative_path: String,
|
||
role_briefs: Vec<AgentRoleBrief>,
|
||
}
|
||
|
||
#[derive(Clone, Debug)]
|
||
struct AgentPassAgenda {
|
||
relative_path: String,
|
||
task_graph_relative_path: String,
|
||
active_task_ids: Vec<String>,
|
||
carried_task_ids: Vec<String>,
|
||
dependency_waves: Vec<Vec<String>>,
|
||
repair_focus: Vec<String>,
|
||
repair_routes: Vec<GameCreationAgentRepairRouteTrace>,
|
||
summary: String,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct LlmGameDraft {
|
||
title: String,
|
||
design_markdown: String,
|
||
balance: serde_json::Value,
|
||
art_manifest: serde_json::Value,
|
||
audio_manifest: serde_json::Value,
|
||
publish_readme: String,
|
||
handoffs: Vec<LlmAgentHandoff>,
|
||
game_html: String,
|
||
handoff_summary: String,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
struct LlmAgentHandoff {
|
||
group: String,
|
||
role: String,
|
||
summary: String,
|
||
outputs: Vec<String>,
|
||
next: String,
|
||
}
|
||
|
||
const DIAGNOSTIC_LOG_MAX_BYTES: u64 = 256 * 1024;
|
||
static DIAGNOSTIC_LOG_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||
static STARTUP_PANIC_LOG_PATH: OnceLock<PathBuf> = OnceLock::new();
|
||
static STARTUP_ERROR_DIALOG_SHOWN: AtomicBool = AtomicBool::new(false);
|
||
|
||
fn diagnostic_timestamp() -> u64 {
|
||
SystemTime::now()
|
||
.duration_since(UNIX_EPOCH)
|
||
.unwrap_or_default()
|
||
.as_secs()
|
||
}
|
||
|
||
fn append_bounded_diagnostic_line_with_limit(
|
||
path: &Path,
|
||
line: &str,
|
||
max_bytes: u64,
|
||
) -> std::io::Result<()> {
|
||
let _guard = DIAGNOSTIC_LOG_LOCK
|
||
.get_or_init(|| Mutex::new(()))
|
||
.lock()
|
||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||
if let Some(parent) = path.parent() {
|
||
fs::create_dir_all(parent)?;
|
||
}
|
||
let mut file = open_secure_diagnostic_log(path)?;
|
||
if file.metadata()?.len() >= max_bytes {
|
||
file.seek(SeekFrom::Start(0)).map_err(|error| {
|
||
std::io::Error::new(error.kind(), format!("seek current log: {error}"))
|
||
})?;
|
||
let mut previous_content = Vec::new();
|
||
std::io::Read::by_ref(&mut file)
|
||
.take(max_bytes.saturating_add(1))
|
||
.read_to_end(&mut previous_content)
|
||
.map_err(|error| {
|
||
std::io::Error::new(error.kind(), format!("read current log: {error}"))
|
||
})?;
|
||
let previous_path = path.with_extension("previous.log");
|
||
let mut previous = open_secure_diagnostic_log(&previous_path)?;
|
||
previous.set_len(0).map_err(|error| {
|
||
std::io::Error::new(error.kind(), format!("truncate previous log: {error}"))
|
||
})?;
|
||
previous.write_all(&previous_content).map_err(|error| {
|
||
std::io::Error::new(error.kind(), format!("write previous log: {error}"))
|
||
})?;
|
||
previous.flush().map_err(|error| {
|
||
std::io::Error::new(error.kind(), format!("flush previous log: {error}"))
|
||
})?;
|
||
file.set_len(0).map_err(|error| {
|
||
std::io::Error::new(error.kind(), format!("truncate current log: {error}"))
|
||
})?;
|
||
}
|
||
file.seek(SeekFrom::End(0))
|
||
.map_err(|error| std::io::Error::new(error.kind(), format!("seek log end: {error}")))?;
|
||
writeln!(file, "{} {line}", diagnostic_timestamp())?;
|
||
file.flush()
|
||
}
|
||
|
||
fn open_secure_diagnostic_log(path: &Path) -> std::io::Result<File> {
|
||
match fs::symlink_metadata(path) {
|
||
Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => {
|
||
return Err(std::io::Error::new(
|
||
std::io::ErrorKind::InvalidInput,
|
||
"diagnostic log must be a regular file",
|
||
));
|
||
}
|
||
Ok(_) => {}
|
||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||
Err(error) => return Err(error),
|
||
}
|
||
let mut options = OpenOptions::new();
|
||
options.read(true).write(true).create(true);
|
||
#[cfg(unix)]
|
||
{
|
||
use std::os::unix::fs::OpenOptionsExt;
|
||
options.mode(0o600).custom_flags(libc::O_NOFOLLOW);
|
||
}
|
||
#[cfg(windows)]
|
||
{
|
||
use std::os::windows::fs::OpenOptionsExt;
|
||
const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
|
||
options.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT);
|
||
}
|
||
let file = options.open(path)?;
|
||
let metadata = file.metadata()?;
|
||
if !metadata.is_file() {
|
||
return Err(std::io::Error::new(
|
||
std::io::ErrorKind::InvalidInput,
|
||
"diagnostic log must be a regular file",
|
||
));
|
||
}
|
||
#[cfg(unix)]
|
||
{
|
||
use std::os::unix::fs::MetadataExt;
|
||
if metadata.nlink() != 1 {
|
||
return Err(std::io::Error::new(
|
||
std::io::ErrorKind::InvalidInput,
|
||
"diagnostic log must not be a hardlink",
|
||
));
|
||
}
|
||
}
|
||
#[cfg(windows)]
|
||
crate::runner::validate_windows_regular_file_handle(&file, "diagnostic log")
|
||
.map_err(std::io::Error::other)?;
|
||
Ok(file)
|
||
}
|
||
|
||
pub(crate) fn append_bounded_diagnostic_line(path: &Path, line: &str) -> std::io::Result<()> {
|
||
append_bounded_diagnostic_line_with_limit(path, line, DIAGNOSTIC_LOG_MAX_BYTES)
|
||
}
|
||
|
||
fn redact_windows_absolute_paths(value: &str) -> String {
|
||
let bytes = value.as_bytes();
|
||
let mut output = String::with_capacity(value.len());
|
||
let mut cursor = 0;
|
||
while cursor < bytes.len() {
|
||
let previous_allows_drive_path = cursor == 0 || !bytes[cursor - 1].is_ascii_alphanumeric();
|
||
let is_drive_path = previous_allows_drive_path
|
||
&& cursor + 2 < bytes.len()
|
||
&& bytes[cursor].is_ascii_alphabetic()
|
||
&& bytes[cursor + 1] == b':'
|
||
&& matches!(bytes[cursor + 2], b'\\' | b'/');
|
||
if !is_drive_path {
|
||
let ch = value[cursor..]
|
||
.chars()
|
||
.next()
|
||
.expect("valid character boundary");
|
||
output.push(ch);
|
||
cursor += ch.len_utf8();
|
||
continue;
|
||
}
|
||
output.push_str("<absolute-path>");
|
||
cursor += 3;
|
||
while cursor < bytes.len()
|
||
&& !bytes[cursor].is_ascii_whitespace()
|
||
&& !matches!(bytes[cursor], b'\"' | b'\'' | b',' | b';')
|
||
{
|
||
cursor += 1;
|
||
}
|
||
}
|
||
output
|
||
}
|
||
|
||
fn redact_unix_absolute_paths(value: &str) -> String {
|
||
let chars = value.chars().collect::<Vec<_>>();
|
||
let mut output = String::with_capacity(value.len());
|
||
let mut cursor = 0;
|
||
while cursor < chars.len() {
|
||
let previous_allows_path = cursor == 0
|
||
|| chars[cursor - 1].is_whitespace()
|
||
|| matches!(chars[cursor - 1], '=' | '(' | ':' | ':');
|
||
let is_url_separator = chars.get(cursor + 1) == Some(&'/');
|
||
if chars[cursor] != '/' || !previous_allows_path || is_url_separator {
|
||
output.push(chars[cursor]);
|
||
cursor += 1;
|
||
continue;
|
||
}
|
||
output.push_str("<absolute-path>");
|
||
cursor += 1;
|
||
while cursor < chars.len()
|
||
&& !chars[cursor].is_whitespace()
|
||
&& !matches!(chars[cursor], '"' | '\'' | ',' | ';')
|
||
{
|
||
cursor += 1;
|
||
}
|
||
}
|
||
output
|
||
}
|
||
|
||
pub(crate) fn sanitize_diagnostic_message(value: &str, private_root: Option<&Path>) -> String {
|
||
let mut sanitized = value.replace(['\r', '\n'], " ");
|
||
if let Some(root) = private_root {
|
||
let root = root.to_string_lossy();
|
||
if !root.is_empty() {
|
||
sanitized = sanitized.replace(root.as_ref(), "<appdata>");
|
||
}
|
||
}
|
||
let lowercase = sanitized.to_ascii_lowercase();
|
||
if [
|
||
"authorization",
|
||
"bearer ",
|
||
"api_key",
|
||
"apikey",
|
||
"api key",
|
||
"x-api-key",
|
||
"token=",
|
||
"token:",
|
||
"credential",
|
||
]
|
||
.iter()
|
||
.any(|marker| lowercase.contains(marker))
|
||
{
|
||
return "<sensitive diagnostic details redacted>".to_string();
|
||
}
|
||
sanitized = redact_unix_absolute_paths(&redact_windows_absolute_paths(&sanitized));
|
||
sanitized.chars().take(2_048).collect()
|
||
}
|
||
|
||
fn show_startup_error_dialog(log_path: &Path) {
|
||
eprintln!("Genarrative startup failed; see {}", log_path.display());
|
||
}
|
||
|
||
#[derive(Clone, Debug)]
|
||
struct GameCreatorAgentLoopResult {
|
||
run_id: String,
|
||
draft: LlmGameDraft,
|
||
spec_markdown: String,
|
||
findings_markdown: String,
|
||
passes: u8,
|
||
steps: Vec<GameCreationAgentRunStep>,
|
||
}
|
||
|
||
fn game_creator_gui_run_event_requests_runner_shutdown(event: &tauri::RunEvent) -> bool {
|
||
matches!(event, tauri::RunEvent::Exit)
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||
enum GameCreatorGuiRunnerShutdownOutcome {
|
||
NotRequested,
|
||
Requested,
|
||
Failed(GameCreatorGuiRunnerShutdownFailure),
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||
enum GameCreatorGuiRunnerShutdownFailure {
|
||
EndpointUnavailable,
|
||
RunnerUnresponsive,
|
||
ProcessIdentity,
|
||
PlatformUnsupported,
|
||
LockTimeout,
|
||
Other,
|
||
}
|
||
|
||
impl GameCreatorGuiRunnerShutdownFailure {
|
||
fn code(self) -> &'static str {
|
||
match self {
|
||
Self::EndpointUnavailable => "endpoint_unavailable",
|
||
Self::RunnerUnresponsive => "runner_unresponsive",
|
||
Self::ProcessIdentity => "process_identity",
|
||
Self::PlatformUnsupported => "platform_unsupported",
|
||
Self::LockTimeout => "lock_timeout",
|
||
Self::Other => "other",
|
||
}
|
||
}
|
||
}
|
||
|
||
fn classify_game_creator_gui_runner_shutdown_error(
|
||
error: &str,
|
||
) -> GameCreatorGuiRunnerShutdownFailure {
|
||
if error.contains("进程启动身份")
|
||
|| error.contains("pid 已")
|
||
|| error.contains("pidfd")
|
||
|| error.contains("进程句柄")
|
||
{
|
||
GameCreatorGuiRunnerShutdownFailure::ProcessIdentity
|
||
} else if error.contains("当前平台不支持") || error.contains("macOS 不提供") {
|
||
GameCreatorGuiRunnerShutdownFailure::PlatformUnsupported
|
||
} else if error.contains("实例锁") || error.contains("owner 锁") {
|
||
GameCreatorGuiRunnerShutdownFailure::LockTimeout
|
||
} else if error.contains("endpoint") {
|
||
GameCreatorGuiRunnerShutdownFailure::EndpointUnavailable
|
||
} else if error.contains("响应") || error.contains("连接 Agent Runner") {
|
||
GameCreatorGuiRunnerShutdownFailure::RunnerUnresponsive
|
||
} else {
|
||
GameCreatorGuiRunnerShutdownFailure::Other
|
||
}
|
||
}
|
||
|
||
fn resolve_game_creator_gui_runner_shutdown<F>(
|
||
event: &tauri::RunEvent,
|
||
shutdown: F,
|
||
) -> GameCreatorGuiRunnerShutdownOutcome
|
||
where
|
||
F: FnOnce() -> Result<(), String>,
|
||
{
|
||
if !game_creator_gui_run_event_requests_runner_shutdown(event) {
|
||
return GameCreatorGuiRunnerShutdownOutcome::NotRequested;
|
||
}
|
||
match shutdown() {
|
||
Ok(()) => GameCreatorGuiRunnerShutdownOutcome::Requested,
|
||
Err(error) => GameCreatorGuiRunnerShutdownOutcome::Failed(
|
||
classify_game_creator_gui_runner_shutdown_error(&error),
|
||
),
|
||
}
|
||
}
|
||
|
||
fn handle_game_creator_gui_run_event(event: &tauri::RunEvent) {
|
||
if matches!(event, tauri::RunEvent::Exit) {
|
||
if let Err(error) = agent::shutdown_game_creator_codex_app_servers() {
|
||
eprintln!("agent.direct_codex.gui_exit.shutdown_failed: {error}");
|
||
}
|
||
}
|
||
match resolve_game_creator_gui_runner_shutdown(event, shutdown_external_agent_runner) {
|
||
GameCreatorGuiRunnerShutdownOutcome::NotRequested => {}
|
||
GameCreatorGuiRunnerShutdownOutcome::Requested => {
|
||
eprintln!("agent.runner.gui_exit.shutdown_requested")
|
||
}
|
||
GameCreatorGuiRunnerShutdownOutcome::Failed(failure) => {
|
||
eprintln!("agent.runner.gui_exit.shutdown_failed.{}", failure.code())
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Tauri 的全局异步 runtime 默认由 `TokioRuntime::new()` 建出来,worker 线程吃
|
||
/// tokio 默认栈。Runtime 的 agent turn 调用链深到本仓库另一处专门给自己的后台
|
||
/// 线程配了 AGENT_RUNTIME_BACKGROUND_WORKER_STACK_BYTES;但凡经 async_runtime::spawn
|
||
/// 派发的活(例如静态委派的父 run 唤醒)都落在这些默认栈的 worker 上,同一段代码
|
||
/// 在那里直接 `thread 'tokio-rt-worker' has overflowed its stack` 把整个进程 abort
|
||
/// 掉——现场表现是父 run 认领委派回执那一刻 Agent Runner 无声消失,调用方只看到
|
||
/// 连接超时。这里在任何异步派发之前把全局 runtime 换成同样栈尺寸的实例。
|
||
///
|
||
/// `async_runtime::set` 只接受 handle 且要求底层 Runtime 常驻,所以这里刻意泄漏。
|
||
fn build_agent_runtime_async_runtime() -> Result<tokio::runtime::Runtime, String> {
|
||
tokio::runtime::Builder::new_multi_thread()
|
||
.enable_all()
|
||
.thread_stack_size(crate::agent::AGENT_RUNTIME_BACKGROUND_WORKER_STACK_BYTES)
|
||
.build()
|
||
.map_err(|error| format!("创建全局异步 runtime 失败:{error}"))
|
||
}
|
||
|
||
#[cfg(not(test))]
|
||
fn install_agent_runtime_async_runtime_with_deep_stack() {
|
||
let runtime = match build_agent_runtime_async_runtime() {
|
||
Ok(runtime) => runtime,
|
||
Err(error) => {
|
||
eprintln!("agent.runner.failed: {error}");
|
||
std::process::exit(1);
|
||
}
|
||
};
|
||
tauri::async_runtime::set(runtime.handle().clone());
|
||
// 进程生命周期内必须持有,否则 handle 立即失效。
|
||
Box::leak(Box::new(runtime));
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
fn run_windows_acl_repair_if_requested(args: &[String]) -> Option<i32> {
|
||
let [command, path, target_user_sid_flag, target_user_sid, authorization_flag, nonce, scope_flag, scope_value] =
|
||
args
|
||
else {
|
||
if args.first().map(String::as_str) == Some("--repair-private-acl") {
|
||
eprintln!(
|
||
"用法:--repair-private-acl <路径> --target-user-sid <SID> --authorization <票据> --scope <managed|user-selected>"
|
||
);
|
||
return Some(1);
|
||
}
|
||
return None;
|
||
};
|
||
if command != "--repair-private-acl"
|
||
|| target_user_sid_flag != "--target-user-sid"
|
||
|| authorization_flag != "--authorization"
|
||
|| scope_flag != "--scope"
|
||
{
|
||
eprintln!(
|
||
"用法:--repair-private-acl <路径> --target-user-sid <SID> --authorization <票据> --scope <managed|user-selected>"
|
||
);
|
||
return Some(1);
|
||
}
|
||
let path = std::path::PathBuf::from(path);
|
||
let scope = match config::parse_windows_acl_repair_scope(scope_value) {
|
||
Ok(scope) => scope,
|
||
Err(error) => {
|
||
eprintln!("{error}");
|
||
return Some(1);
|
||
}
|
||
};
|
||
let result =
|
||
config::consume_windows_acl_repair_authorization(&path, target_user_sid, nonce, scope)
|
||
.and_then(|()| {
|
||
config::repair_game_creator_private_acl_for_user_sid(&path, target_user_sid)
|
||
});
|
||
match result {
|
||
Ok(()) => Some(0),
|
||
Err(error) => {
|
||
eprintln!("AGC ACL 提权修复失败:{error}");
|
||
Some(1)
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod async_runtime_stack_tests {
|
||
/// 每帧固定占 16 KiB,用 black_box 挡住优化,让递归深度直接换算成栈用量。
|
||
fn consume_stack(depth: usize) -> u64 {
|
||
let mut frame = [0_u8; 16 * 1024];
|
||
frame[depth % frame.len()] = depth as u8;
|
||
let sum = std::hint::black_box(&frame)
|
||
.iter()
|
||
.map(|byte| u64::from(*byte))
|
||
.sum::<u64>();
|
||
if depth == 0 {
|
||
sum
|
||
} else {
|
||
sum + consume_stack(depth - 1)
|
||
}
|
||
}
|
||
|
||
/// 全局异步 runtime 的 worker 必须和 Runtime 自己的后台线程用同一份栈预算。
|
||
/// 掉了 thread_stack_size 时这条不是断言失败而是整个测试进程被 abort——这正是
|
||
/// 线上的失效形态:Agent Runner 在父 run 认领委派回执时无声消失。
|
||
#[test]
|
||
fn async_runtime_workers_hold_a_call_chain_that_overflows_the_default_stack() {
|
||
const FRAMES: usize = 192; // 192 × 16 KiB = 3 MiB,超出 tokio 默认栈,远低于 16 MiB
|
||
let runtime = super::build_agent_runtime_async_runtime().expect("build async runtime");
|
||
let handled =
|
||
runtime.block_on(async { tokio::spawn(async { consume_stack(FRAMES - 1) }).await });
|
||
assert!(handled.is_ok(), "{handled:?}");
|
||
}
|
||
}
|
||
|
||
#[cfg(not(test))]
|
||
fn main() {
|
||
install_agent_runtime_async_runtime_with_deep_stack();
|
||
let mut args = std::env::args().skip(1).collect::<Vec<_>>();
|
||
if let Some(exit_code) = run_direct_tools_mcp_if_requested(&args) {
|
||
std::process::exit(exit_code);
|
||
}
|
||
#[cfg(windows)]
|
||
if let Some(exit_code) = run_windows_acl_repair_if_requested(&args) {
|
||
std::process::exit(exit_code);
|
||
}
|
||
#[cfg(target_os = "linux")]
|
||
if command_sandbox_trampoline::is_trampoline_mode(&args) {
|
||
match command_sandbox_trampoline::run_trampoline() {
|
||
Ok(exit_code) => std::process::exit(exit_code),
|
||
Err(_) => std::process::exit(125),
|
||
}
|
||
}
|
||
#[cfg(target_os = "linux")]
|
||
if command_sandbox_trampoline::is_process_session_trampoline_mode(&args) {
|
||
match command_sandbox_trampoline::run_process_session_trampoline() {
|
||
Ok(exit_code) => std::process::exit(exit_code),
|
||
Err(_) => std::process::exit(125),
|
||
}
|
||
}
|
||
#[cfg(target_os = "linux")]
|
||
if is_process_session_child_mode(&args) {
|
||
match run_process_session_child(&args) {
|
||
Ok(exit_code) => std::process::exit(exit_code),
|
||
Err(_) => std::process::exit(125),
|
||
}
|
||
}
|
||
let runtime_config_dir = match take_cli_runtime_config_dir(&mut args) {
|
||
Ok(config_dir) => config_dir,
|
||
Err(error) => {
|
||
eprintln!("{error}");
|
||
std::process::exit(1);
|
||
}
|
||
};
|
||
if args.first().map(String::as_str) == Some("--agent-runner") {
|
||
let gui_owner_required = match args.as_slice() {
|
||
[_] => false,
|
||
[_, option] if option == "--gui-owner-required" => true,
|
||
_ => {
|
||
eprintln!(
|
||
"用法:--agent-runner [--gui-owner-required] --config-dir <AppData 绝对路径>"
|
||
);
|
||
std::process::exit(1);
|
||
}
|
||
};
|
||
let Some(config_dir) = runtime_config_dir else {
|
||
eprintln!("Agent Runner 必须显式传入 --config-dir <AppData 绝对路径>");
|
||
std::process::exit(1);
|
||
};
|
||
if let Err(error) = load_platform_session_fixture_from_env(&config_dir) {
|
||
eprintln!("agent.runner.failed: {error}");
|
||
std::process::exit(1);
|
||
}
|
||
set_game_creator_runtime_config_dir(config_dir.clone());
|
||
if let Err(error) = run_external_agent_runner_server(config_dir, gui_owner_required) {
|
||
eprintln!("agent.runner.failed: {error}");
|
||
std::process::exit(1);
|
||
}
|
||
return;
|
||
}
|
||
match parse_cli_command(&args) {
|
||
Ok(Some(mut command)) => {
|
||
let config_dir =
|
||
match prepare_cli_command_paths(&mut command, runtime_config_dir.as_deref()) {
|
||
Ok(config_dir) => config_dir,
|
||
Err(error) => {
|
||
eprintln!("agent.runner.failed: {error}");
|
||
std::process::exit(1);
|
||
}
|
||
};
|
||
if let Some(config_dir) = config_dir {
|
||
if let Err(error) = load_platform_session_fixture_from_env(&config_dir) {
|
||
eprintln!("agent.runner.failed: {error}");
|
||
std::process::exit(1);
|
||
}
|
||
if command.requires_external_agent_runner() {
|
||
let configured = if command.is_read_only_status() {
|
||
configure_external_agent_runner_read_only(&config_dir)
|
||
} else {
|
||
configure_external_agent_runner(&config_dir)
|
||
};
|
||
if let Err(error) = configured {
|
||
eprintln!("agent.runner.failed: {error}");
|
||
std::process::exit(1);
|
||
}
|
||
}
|
||
set_game_creator_runtime_config_dir(config_dir);
|
||
}
|
||
if command.requires_started_external_agent_runner() {
|
||
if let Err(error) = ensure_external_agent_runner_started() {
|
||
eprintln!("agent.runner.failed: {error}");
|
||
std::process::exit(1);
|
||
}
|
||
}
|
||
if let Err(error) = run_cli_command(command) {
|
||
eprintln!("agent.run.failed: {error}");
|
||
std::process::exit(1);
|
||
}
|
||
return;
|
||
}
|
||
Ok(None) => {}
|
||
Err(error) => {
|
||
eprintln!("{error}");
|
||
std::process::exit(1);
|
||
}
|
||
}
|
||
|
||
if let Some(config_dir) = runtime_config_dir {
|
||
set_game_creator_runtime_config_dir(config_dir);
|
||
}
|
||
|
||
let mut tauri_context = tauri::generate_context!();
|
||
let startup_log: Option<PathBuf> = None;
|
||
let setup_log = startup_log.clone();
|
||
let app = tauri::Builder::default()
|
||
.plugin(tauri_plugin_opener::init())
|
||
.plugin(tauri_plugin_dialog::init())
|
||
.plugin(tauri_plugin_http::init())
|
||
.plugin(tauri_plugin_clipboard_manager::init())
|
||
.plugin(context_menu::init())
|
||
.manage(game_creator_preview_registry())
|
||
.manage(ProjectResourcePreviewReadManager::default())
|
||
.setup(move |app| {
|
||
if let Some(path) = setup_log.as_deref() {
|
||
let _ = append_bounded_diagnostic_line(path, "startup.setup.begin");
|
||
let _ = append_bounded_diagnostic_line(path, "startup.appdata.configure.begin");
|
||
}
|
||
configure_game_creator_runtime_config_dir(app.handle()).inspect_err(|error| {
|
||
if let Some(path) = setup_log.as_deref() {
|
||
let details = sanitize_diagnostic_message(&error.to_string(), path.parent());
|
||
let _ = append_bounded_diagnostic_line(
|
||
path,
|
||
&format!("startup.appdata.configure.failed details={details}"),
|
||
);
|
||
show_startup_error_dialog(path);
|
||
}
|
||
})?;
|
||
if let Some(path) = setup_log.as_deref() {
|
||
let _ = append_bounded_diagnostic_line(path, "startup.appdata.configure.complete");
|
||
}
|
||
let config_dir = game_creator_runtime_config_dir().ok_or_else(|| {
|
||
let error = std::io::Error::new(
|
||
std::io::ErrorKind::NotFound,
|
||
"客户端 AppData 配置目录未初始化",
|
||
);
|
||
if let Some(path) = setup_log.as_deref() {
|
||
let _ = append_bounded_diagnostic_line(
|
||
path,
|
||
"startup.appdata.resolve.failed details=config-dir-uninitialized",
|
||
);
|
||
show_startup_error_dialog(path);
|
||
}
|
||
error
|
||
})?;
|
||
load_platform_session_fixture_from_env(&config_dir).map_err(|error| {
|
||
std::io::Error::new(
|
||
std::io::ErrorKind::PermissionDenied,
|
||
format!("加载平台登录态测试 fixture 失败:{error}"),
|
||
)
|
||
})?;
|
||
if let Some(path) = setup_log.as_deref() {
|
||
let _ = append_bounded_diagnostic_line(path, "startup.runner.configure.begin");
|
||
}
|
||
configure_external_agent_runner(&config_dir)
|
||
.inspect_err(|error| {
|
||
if let Some(path) = setup_log.as_deref() {
|
||
let details =
|
||
sanitize_diagnostic_message(error, Some(config_dir.as_path()));
|
||
let _ = append_bounded_diagnostic_line(
|
||
path,
|
||
&format!("startup.runner.configure.failed details={details}"),
|
||
);
|
||
show_startup_error_dialog(path);
|
||
}
|
||
})
|
||
.map_err(|error| {
|
||
std::io::Error::new(
|
||
std::io::ErrorKind::Other,
|
||
format!("配置 Agent Runner 失败:{error}"),
|
||
)
|
||
})?;
|
||
if let Some(path) = setup_log.as_deref() {
|
||
let _ = append_bounded_diagnostic_line(path, "startup.runner.configure.complete");
|
||
}
|
||
let gui_owner_lock = acquire_external_agent_runner_gui_owner_lock(&config_dir)
|
||
.inspect_err(|error| {
|
||
if let Some(path) = setup_log.as_deref() {
|
||
let details =
|
||
sanitize_diagnostic_message(error, Some(config_dir.as_path()));
|
||
let _ = append_bounded_diagnostic_line(
|
||
path,
|
||
&format!("startup.runner.owner-lock.failed details={details}"),
|
||
);
|
||
show_startup_error_dialog(path);
|
||
}
|
||
})
|
||
.map_err(|error| {
|
||
std::io::Error::new(
|
||
std::io::ErrorKind::AlreadyExists,
|
||
format!("获取 GUI owner 锁失败:{error}"),
|
||
)
|
||
})?;
|
||
let gui_owner_epoch = gui_owner_lock.owner_epoch().to_string();
|
||
app.manage(gui_owner_lock);
|
||
if let Some(path) = setup_log.as_deref() {
|
||
let _ = append_bounded_diagnostic_line(path, "startup.runner.start.begin");
|
||
}
|
||
set_game_creator_agent_runtime_update_app_handle(app.handle().clone());
|
||
let manifest_event_sink =
|
||
start_game_creator_manifest_invalidation_event_sink(app.handle().clone())?;
|
||
attach_external_agent_runner_gui_owner(&manifest_event_sink, &gui_owner_epoch)
|
||
.inspect_err(|error| {
|
||
if let Some(path) = setup_log.as_deref() {
|
||
let details =
|
||
sanitize_diagnostic_message(error, Some(config_dir.as_path()));
|
||
let _ = append_bounded_diagnostic_line(
|
||
path,
|
||
&format!("startup.runner.attach-owner.failed details={details}"),
|
||
);
|
||
show_startup_error_dialog(path);
|
||
}
|
||
})
|
||
.map_err(|error| {
|
||
std::io::Error::new(
|
||
std::io::ErrorKind::Other,
|
||
format!("绑定 Agent Runner GUI owner 失败:{error}"),
|
||
)
|
||
})?;
|
||
if let Some(path) = setup_log.as_deref() {
|
||
let _ = append_bounded_diagnostic_line(path, "startup.runner.start.complete");
|
||
}
|
||
if let Some(path) = setup_log.as_deref() {
|
||
let _ = append_bounded_diagnostic_line(path, "startup.setup.complete");
|
||
}
|
||
Ok(())
|
||
})
|
||
.invoke_handler(tauri::generate_handler![
|
||
create_automatic_local_game_project,
|
||
init_local_game_project,
|
||
import_local_godot_project,
|
||
is_local_project_directory_non_empty,
|
||
inspect_local_project_directory,
|
||
pick_local_project_directory,
|
||
pick_local_file,
|
||
pick_client_extension_file,
|
||
pick_client_extension_directory,
|
||
list_client_extensions,
|
||
import_client_extension,
|
||
set_client_extension_enabled,
|
||
rename_client_extension,
|
||
remove_client_extension,
|
||
open_local_project_directory,
|
||
open_local_project_plan_gdd_markdown,
|
||
control_agent_run,
|
||
generate_local_game_draft,
|
||
chat_with_game_creator_agent,
|
||
chat_with_game_creator_role_agent,
|
||
chat_with_game_creator_role_agent_stream,
|
||
chat_with_game_creator_direct_codex,
|
||
start_game_creator_agent_runtime_task,
|
||
start_game_creator_supervisor_runtime_task,
|
||
compact_game_creator_agent_runtime_context,
|
||
read_game_creator_agent_goal,
|
||
start_game_creator_agent_goal,
|
||
edit_game_creator_agent_goal,
|
||
pause_game_creator_agent_goal,
|
||
resume_game_creator_agent_goal,
|
||
clear_game_creator_agent_goal,
|
||
steer_game_creator_agent_runtime_task,
|
||
cancel_game_creator_agent_runtime_task,
|
||
retry_game_creator_agent_runtime_task,
|
||
confirm_retry_game_creator_agent_runtime_task,
|
||
confirm_game_creator_agent_runtime_task,
|
||
reject_game_creator_agent_runtime_task,
|
||
answer_game_creator_agent_runtime_user_input,
|
||
decide_game_creator_plan_gdd,
|
||
hydrate_game_creator_plan_gdd_state,
|
||
read_game_creator_agent_runtime,
|
||
read_game_creator_agent_runtimes,
|
||
resume_game_creator_agent_runtime_tasks,
|
||
confirm_resume_game_creator_agent_runtime_tasks,
|
||
schedule_game_creator_agent_ready_tasks,
|
||
check_game_creator_llm_config,
|
||
read_platform_account_session_generation,
|
||
install_platform_account_session,
|
||
clear_platform_account_session,
|
||
read_game_creator_app_config,
|
||
write_game_creator_app_config,
|
||
upload_local_asset,
|
||
register_local_asset,
|
||
create_ui_design_resource,
|
||
derive_local_project_resource,
|
||
list_pending_local_project_resource_edits,
|
||
resume_local_project_resource_edit,
|
||
request_local_project_resource_edit_service_identity_confirmation,
|
||
confirm_local_project_resource_edit_service_identity,
|
||
archive_failed_local_project_resource_edit,
|
||
normalize_local_project_raster_resource,
|
||
import_canvas_asset,
|
||
import_canvas_export,
|
||
sync_canvas_project_assets,
|
||
import_ui_editor_assets,
|
||
prepare_ui_editor_project_fonts,
|
||
read_ui_editor_font_bytes,
|
||
check_ui_editor_font_glyph_coverage,
|
||
suggest_ui_design_semantic,
|
||
recognize_ui,
|
||
merge_ui,
|
||
bind_components,
|
||
load_ui_design_state,
|
||
save_ui_design_state,
|
||
generate_ui_design_code,
|
||
ensure_ui_design_resource_for_prototype,
|
||
generate_platform_art_asset,
|
||
open_canvas_project,
|
||
get_game_creation_agent_capabilities,
|
||
get_limited_local_commands,
|
||
run_limited_local_command,
|
||
append_local_permission_log,
|
||
list_local_project_files,
|
||
import_local_project_image_assets,
|
||
read_local_project_file,
|
||
read_local_project_image_preview,
|
||
read_local_project_text_preview,
|
||
read_local_project_media_preview,
|
||
cancel_local_project_resource_preview_scope,
|
||
write_local_project_file,
|
||
delete_local_project_file,
|
||
read_local_game_memory,
|
||
read_local_agent_memory,
|
||
write_local_agent_memory,
|
||
write_local_game_memory,
|
||
delete_local_game_memory,
|
||
list_game_creator_agent_sessions,
|
||
create_game_creator_agent_session,
|
||
fork_game_creator_agent_session,
|
||
set_active_game_creator_agent_session,
|
||
archive_game_creator_agent_session,
|
||
read_local_conversation,
|
||
append_local_conversation_message,
|
||
build_local_project_index,
|
||
create_local_project_checkpoint,
|
||
export_local_project_package,
|
||
list_local_project_export_packages,
|
||
diff_local_project_checkpoint,
|
||
restore_local_project_checkpoint,
|
||
read_project_permission_policy,
|
||
write_project_permission_policy,
|
||
open_game_creator_workspace_window,
|
||
open_game_creator_launcher_window,
|
||
open_project_supervisor_chat_window,
|
||
start_local_game_preview,
|
||
activate_local_game_preview,
|
||
stop_local_game_preview,
|
||
stop_local_game_preview_if_matches,
|
||
get_local_game_preview_status,
|
||
read_local_project_resource_canvas_layout,
|
||
read_local_project_resource_graph,
|
||
update_local_project_resource_canvas_layout,
|
||
create_local_project_asset_canvas_draft,
|
||
read_local_project_asset_canvas_draft,
|
||
discover_local_project_asset_canvas_draft,
|
||
update_local_project_asset_canvas_draft,
|
||
acknowledge_local_project_asset_canvas_candidate_layers,
|
||
import_local_project_asset_canvas_images,
|
||
store_local_project_asset_canvas_media,
|
||
stage_local_project_asset_canvas_image,
|
||
finalize_local_project_asset_canvas_generation_failure,
|
||
archive_failed_local_project_asset_canvas_generation,
|
||
generate_local_project_asset_canvas_image,
|
||
recover_local_project_asset_canvas_generations,
|
||
confirm_local_project_asset_canvas_generation_service_identity,
|
||
read_local_project_asset_canvas_media,
|
||
discard_local_project_asset_canvas_draft,
|
||
recover_local_project_asset_canvas_transactions,
|
||
commit_local_project_asset,
|
||
commit_local_project_asset_canvas_candidate,
|
||
get_local_game_project_revision,
|
||
get_local_game_manifest,
|
||
download_agc_update,
|
||
])
|
||
.build(tauri_context);
|
||
let app = match app {
|
||
Ok(app) => {
|
||
if let Some(path) = startup_log.as_deref() {
|
||
let _ = append_bounded_diagnostic_line(path, "startup.build.complete");
|
||
}
|
||
app
|
||
}
|
||
Err(error) => {
|
||
if let Some(path) = startup_log.as_deref() {
|
||
let details = sanitize_diagnostic_message(&error.to_string(), path.parent());
|
||
let _ = append_bounded_diagnostic_line(
|
||
path,
|
||
&format!("startup.build.failed details={details}"),
|
||
);
|
||
show_startup_error_dialog(path);
|
||
}
|
||
eprintln!("failed to build Genarrative AI Game Creator shell: {error}");
|
||
std::process::exit(1);
|
||
}
|
||
};
|
||
app.run(move |_app_handle, event| handle_game_creator_gui_run_event(&event));
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod diagnostic_log_tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn bounded_diagnostic_log_rotates_and_keeps_only_one_previous_file() {
|
||
let directory = tempfile::tempdir().expect("create diagnostics directory");
|
||
let path = directory.path().join("startup.log");
|
||
let first_record = "x".repeat(128);
|
||
append_bounded_diagnostic_line_with_limit(&path, &first_record, 64)
|
||
.expect("write first record");
|
||
append_bounded_diagnostic_line_with_limit(&path, "second-record", 64)
|
||
.expect("rotate diagnostic log");
|
||
|
||
let current = fs::read_to_string(&path).expect("read current diagnostic log");
|
||
let previous = fs::read_to_string(path.with_extension("previous.log"))
|
||
.expect("read previous diagnostic log");
|
||
assert!(current.contains("second-record"));
|
||
assert!(previous.contains(&"x".repeat(32)));
|
||
}
|
||
|
||
#[test]
|
||
fn diagnostic_message_redacts_sensitive_values_and_absolute_paths() {
|
||
assert_eq!(
|
||
sanitize_diagnostic_message("Authorization: Bearer secret", None),
|
||
"<sensitive diagnostic details redacted>"
|
||
);
|
||
assert_eq!(
|
||
sanitize_diagnostic_message(r"failed at C:\private\project\game.json", None),
|
||
"failed at <absolute-path>"
|
||
);
|
||
assert_eq!(
|
||
sanitize_diagnostic_message("failed at /home/example/private/game.json", None),
|
||
"failed at <absolute-path>"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn diagnostic_log_rejects_hardlink_targets_including_rotation_backup() {
|
||
let directory = tempfile::tempdir().expect("create diagnostics directory");
|
||
let outside = directory.path().join("outside.txt");
|
||
fs::write(&outside, "outside-unchanged").expect("write outside target");
|
||
let path = directory.path().join("startup.log");
|
||
fs::hard_link(&outside, &path).expect("create diagnostic hardlink");
|
||
assert!(append_bounded_diagnostic_line(&path, "must-not-write").is_err());
|
||
assert_eq!(
|
||
fs::read_to_string(&outside).expect("read outside target"),
|
||
"outside-unchanged"
|
||
);
|
||
|
||
fs::remove_file(&path).expect("remove diagnostic hardlink");
|
||
fs::write(&path, "rotate-me").expect("write diagnostic file");
|
||
let previous = path.with_extension("previous.log");
|
||
fs::hard_link(&outside, &previous).expect("create previous hardlink");
|
||
assert!(append_bounded_diagnostic_line_with_limit(&path, "blocked", 1).is_err());
|
||
assert_eq!(
|
||
fs::read_to_string(&outside).expect("read outside target after rotation"),
|
||
"outside-unchanged"
|
||
);
|
||
}
|
||
|
||
#[cfg(unix)]
|
||
#[test]
|
||
fn diagnostic_log_rejects_symlink_targets() {
|
||
use std::os::unix::fs::symlink;
|
||
|
||
let directory = tempfile::tempdir().expect("create diagnostics directory");
|
||
let outside = directory.path().join("outside.txt");
|
||
fs::write(&outside, "outside-unchanged").expect("write outside target");
|
||
let path = directory.path().join("startup.log");
|
||
symlink(&outside, &path).expect("create diagnostic symlink");
|
||
assert!(append_bounded_diagnostic_line(&path, "must-not-write").is_err());
|
||
assert_eq!(
|
||
fs::read_to_string(&outside).expect("read outside target"),
|
||
"outside-unchanged"
|
||
);
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
#[test]
|
||
fn diagnostic_log_rejects_windows_symlink_or_reparse_targets_when_supported() {
|
||
use std::os::windows::fs::symlink_file;
|
||
|
||
let directory = tempfile::tempdir().expect("create diagnostics directory");
|
||
let outside = directory.path().join("outside.txt");
|
||
fs::write(&outside, "outside-unchanged").expect("write outside target");
|
||
let path = directory.path().join("startup.log");
|
||
if symlink_file(&outside, &path).is_err() {
|
||
return;
|
||
}
|
||
assert!(append_bounded_diagnostic_line(&path, "must-not-write").is_err());
|
||
assert_eq!(
|
||
fs::read_to_string(&outside).expect("read outside target"),
|
||
"outside-unchanged"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod update_client_tests {
|
||
use super::*;
|
||
use std::io::{Read, Write};
|
||
|
||
#[tokio::test]
|
||
async fn update_download_client_omits_agc_marker() {
|
||
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind update fixture");
|
||
let address = listener.local_addr().expect("update fixture address");
|
||
let server = std::thread::spawn(move || {
|
||
let (mut stream, _) = listener.accept().expect("accept update request");
|
||
stream
|
||
.set_read_timeout(Some(std::time::Duration::from_secs(2)))
|
||
.expect("set update fixture timeout");
|
||
let mut bytes = Vec::new();
|
||
let mut buffer = [0_u8; 1024];
|
||
while !bytes.windows(4).any(|window| window == b"\r\n\r\n") {
|
||
let read = stream.read(&mut buffer).expect("read update request");
|
||
assert!(read > 0, "update request closed before headers");
|
||
bytes.extend_from_slice(&buffer[..read]);
|
||
}
|
||
stream
|
||
.write_all(
|
||
b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
|
||
)
|
||
.expect("write update response");
|
||
String::from_utf8_lossy(&bytes).into_owned()
|
||
});
|
||
|
||
let client = build_agc_update_download_client();
|
||
let response = client
|
||
.get(format!("http://{address}/update.exe"))
|
||
.send()
|
||
.await
|
||
.expect("send update request");
|
||
let request = server.join().expect("join update fixture");
|
||
|
||
assert_eq!(response.status(), reqwest::StatusCode::NO_CONTENT);
|
||
assert!(!request
|
||
.to_ascii_lowercase()
|
||
.contains("x-genarrative-client:"));
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests;
|
||
pub mod ui_editor;
|