Merge pull request '清理 Rust 编译 warning(含 AGC 壳)并加门禁(#649)' (#650) from chore/rust-compile-warnings into master
Project CI / AI game creator shell Rust crates (push) Successful in 7m33s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 8m42s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 9m18s
Project CI / Backend tests (push) Successful in 9m43s
Project CI / Frontend tests (push) Successful in 3m19s
Project CI / Repository checks (push) Successful in 5m54s
Project CI / Native shell tests (push) Successful in 7m47s
Project CI / AI game creator shell web tests (push) Has been cancelled

Reviewed-on: http://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/650
This commit was merged in pull request #650.
This commit is contained in:
2026-10-07 22:52:22 +08:00
87 changed files with 1769 additions and 18251 deletions
+6
View File
@@ -152,6 +152,9 @@ jobs:
- name: Run AI game creator shell Rust shard 4/4
run: npm run check:native-shells:agc-rust-shard-4
- name: Run AGC shell Rust warning check (Linux)
run: npm run check:rust-warnings -- --profile=agc-linux
- name: Report isolated Rust compilation cache
if: always()
run: bash scripts/ci-rust-cache.sh report
@@ -266,6 +269,9 @@ jobs:
- name: Run AI game creator shell shared crate gates
run: npm run check:native-shells:agc-rust-crates
- name: Run server-rs Rust warning check
run: npm run check:rust-warnings -- --profile=server-rs
- name: Report isolated Rust compilation cache
if: always()
run: bash scripts/ci-rust-cache.sh report
+7
View File
@@ -1,4 +1,11 @@
# Git 在链接工作树里执行 Hook 时会注入 GIT_DIR 等仓库定位变量,优先级高于 cwd;
# 钩子链(npm → check:repository-ci → 测试夹具)会继承它们并写到真实仓库,故在入口统一清除。
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_COMMON_DIR GIT_PREFIX GIT_CONFIG_PARAMETERS GIT_CEILING_DIRECTORIES
# Rust 告警检查对所有分支一视同仁,但按内容触发:脚本自己看本次推送的 refs 有没有碰 *.rs/Cargo.toml/Cargo.lock。
# refs 落成临时文件传给脚本,这样最后一行原有的钩子命令保持逐字不变、且仍拿到完整的 stdin。
push_refs="$(mktemp)"
cat > "${push_refs}"
trap 'rm -f "${push_refs}"' EXIT
node scripts/check-rust-warnings.mjs --only-if-changed --refs-file="${push_refs}"
exec < "${push_refs}"
npm run check:pre-push-master -- "$@"
@@ -337,7 +337,7 @@ pub fn compile_manifest(manifest_path: &Path) -> Result<CompiledPromptBundle, St
)?;
}
let mut provider_fragment_sections = BTreeSet::new();
for (name, _, section_id) in provider_fragment_entries(&manifest.provider_fragments) {
for (name, section_id) in provider_fragment_entries(&manifest.provider_fragments) {
validate_section_reference(section_id, &sections, name)?;
if !provider_fragment_sections.insert(section_id) {
return Err(format!("Provider fragment section 重复引用:{section_id}"));
@@ -370,7 +370,7 @@ pub fn compile_manifest(manifest_path: &Path) -> Result<CompiledPromptBundle, St
used_sections.extend(
provider_fragment_entries(&manifest.provider_fragments)
.into_iter()
.map(|(_, _, section_id)| section_id.clone()),
.map(|(_, section_id)| section_id.clone()),
);
for section_id in sections.keys() {
if !used_sections.contains(section_id) {
@@ -379,7 +379,7 @@ pub fn compile_manifest(manifest_path: &Path) -> Result<CompiledPromptBundle, St
));
}
}
let mut rust_source = render_rust(&manifest, &sections);
let mut rust_source = render_rust(&manifest);
rust_source.push_str(&render_text_macro(&texts));
let specialist_nodes = manifest
.agent_catalog
@@ -443,7 +443,7 @@ fn validate_section_ownership(manifest: &PromptBundleManifest) -> Result<(), Str
register("roleOverlays", section);
}
}
for (_, _, section) in provider_fragment_entries(&manifest.provider_fragments) {
for (_, section) in provider_fragment_entries(&manifest.provider_fragments) {
register("providerFragments", section);
}
@@ -463,48 +463,40 @@ fn validate_section_ownership(manifest: &PromptBundleManifest) -> Result<(), Str
Ok(())
}
fn provider_fragment_entries(
fragments: &ProviderFragments,
) -> [(&'static str, &'static str, &String); 8] {
/// 返回 `(manifest 路径键, section 键)`:常量名标签随退役 Runtime 的 `RUNTIME_PROMPT_*` 生成逻辑一并停发,
/// 只剩校验与所有权归属两处用途,因此不再携带常量名字符串。
fn provider_fragment_entries(fragments: &ProviderFragments) -> [(&'static str, &String); 8] {
[
(
"providerFragments.isolatedToolContract",
"RUNTIME_PROMPT_PROVIDER_ISOLATED_TOOL_CONTRACT_SECTION",
&fragments.isolated_tool_contract,
),
(
"providerFragments.autonomousRunProfile",
"RUNTIME_PROMPT_PROVIDER_AUTONOMOUS_RUN_PROFILE_SECTION",
&fragments.autonomous_run_profile,
),
(
"providerFragments.autonomousSupervisorManifest",
"RUNTIME_PROMPT_PROVIDER_AUTONOMOUS_SUPERVISOR_MANIFEST_SECTION",
&fragments.autonomous_supervisor_manifest,
),
(
"providerFragments.initialCollaborationRepair",
"RUNTIME_PROMPT_PROVIDER_INITIAL_COLLABORATION_REPAIR_SECTION",
&fragments.initial_collaboration_repair,
),
(
"providerFragments.autonomousInitialCollaborationRepair",
"RUNTIME_PROMPT_PROVIDER_AUTONOMOUS_INITIAL_COLLABORATION_REPAIR_SECTION",
&fragments.autonomous_initial_collaboration_repair,
),
(
"providerFragments.supervisorDeliveryConvergenceRepair",
"RUNTIME_PROMPT_PROVIDER_SUPERVISOR_DELIVERY_CONVERGENCE_REPAIR_SECTION",
&fragments.supervisor_delivery_convergence_repair,
),
(
"providerFragments.manifestDagWaitRepair",
"RUNTIME_PROMPT_PROVIDER_MANIFEST_DAG_WAIT_REPAIR_SECTION",
&fragments.manifest_dag_wait_repair,
),
(
"providerFragments.delegatedPlaytestRepair",
"RUNTIME_PROMPT_PROVIDER_DELEGATED_PLAYTEST_REPAIR_SECTION",
&fragments.delegated_playtest_repair,
),
]
@@ -931,101 +923,16 @@ fn validate_nonempty(value: &str, field: &str) -> Result<(), String> {
}
}
fn render_rust(manifest: &PromptBundleManifest, sections: &BTreeMap<String, String>) -> String {
/// 只生成运行期仍被引用的内容:Agent 目录(supervisor/分组/角色)与 `prompt_text!` 目录由调用方
/// 分别拼接。已退役 Runtime 的 section 视图(`runtime_prompt_bundle_section`、各
/// `RUNTIME_PROMPT_*` 常量)不再产出——它们没有任何消费者,继续生成只会变成 dead_code。
fn render_rust(manifest: &PromptBundleManifest) -> String {
let mut output =
String::from("// @generated by build.rs from prompts/runtime/manifest.json\n\n");
output.push_str(&format!(
"#[cfg(test)]\npub(crate) const RUNTIME_PROMPT_BUNDLE_ID: &str = {};\n",
rust_literal(&manifest.id)
));
output.push_str(&format!(
"#[cfg(test)]\npub(crate) const RUNTIME_PROMPT_BUNDLE_VERSION: &str = {};\n",
rust_literal(&manifest.version)
));
output.push_str("pub(crate) fn runtime_prompt_bundle_section(id: &str) -> Option<&'static str> {\n match id {\n");
for (id, content) in sections {
output.push_str(&format!(
" {} => Some({}),\n",
rust_literal(id),
rust_literal(content)
));
}
output.push_str(" _ => None,\n }\n}\n");
output.push_str(&render_string_slice_const(
"RUNTIME_PROMPT_RUNTIME_COMPOSITION",
&manifest.compositions.runtime,
));
output.push_str(&render_string_slice_const(
"RUNTIME_PROMPT_SUPERVISOR_COMPOSITION",
&manifest.compositions.supervisor,
));
output.push_str(&format!(
"pub(crate) const RUNTIME_PROMPT_SUPERVISOR_CHAT_COMPOSITION: &[&str] = &[{}, {}];\n",
rust_literal(&manifest.compositions.supervisor_chat.identity),
rust_literal(&manifest.compositions.supervisor_chat.final_reply)
));
output.push_str(&format!(
"pub(crate) const RUNTIME_PROMPT_PLATFORM_DEFAULT_SECTION: &str = {};\n",
rust_literal(&manifest.variants.platform.default)
));
output.push_str(&format!(
"pub(crate) const RUNTIME_PROMPT_PLATFORM_LINUX_SECTION: &str = {};\n",
rust_literal(&manifest.variants.platform.linux)
));
output.push_str(&format!(
"pub(crate) const RUNTIME_PROMPT_VISUAL_EDITOR_SECTION: &str = {};\n",
rust_literal(&manifest.variants.visual_contract.editor_configured)
));
output.push_str(&format!(
"pub(crate) const RUNTIME_PROMPT_VISUAL_NO_EDITOR_SECTION: &str = {};\n",
rust_literal(&manifest.variants.visual_contract.editor_unavailable)
));
output.push_str("pub(crate) const RUNTIME_PROMPT_ROLE_OVERLAYS: &[(&str, &[&str])] = &[\n");
for overlay in &manifest.role_overlays {
output.push_str(&format!(
" ({}, &{}),\n",
rust_literal(&overlay.agent_id),
render_string_slice(&overlay.sections)
));
}
output.push_str("];\n\n");
output.push_str(&render_provider_fragment_constants(
&manifest.provider_fragments,
));
output.push_str(&render_agent_catalog(&manifest.agent_catalog));
output
}
fn render_provider_fragment_constants(fragments: &ProviderFragments) -> String {
provider_fragment_entries(fragments)
.into_iter()
.map(|(_, name, section_id)| {
format!(
"pub(crate) const {name}: &str = {};\n",
rust_literal(section_id)
)
})
.collect()
}
fn render_string_slice_const(name: &str, values: &[String]) -> String {
format!(
"pub(crate) const {name}: &[&str] = &{};\n",
render_string_slice(values)
)
}
fn render_string_slice(values: &[String]) -> String {
format!(
"[{}]",
values
.iter()
.map(|value| rust_literal(value))
.collect::<Vec<_>>()
.join(", ")
)
}
fn render_agent_catalog(catalog: &AgentCatalog) -> String {
let supervisor_role = &catalog.supervisor.roles[0];
let mut output = format!(
@@ -1072,11 +979,9 @@ fn render_role_array(name: &str, roles: &[AgentRole]) -> String {
);
for role in roles {
output.push_str(&format!(
" AgentRoleDefinition {{ id: {}, role: {}, task_id: {}, tool_id: {}, brief_path_name: {} }},\n",
rust_literal(&role.id),
" AgentRoleDefinition {{ role: {}, task_id: {}, brief_path_name: {} }},\n",
rust_literal(&role.role),
rust_literal(&role.task_id),
rust_literal(&role.tool_id),
rust_literal(&role.brief_path_name)
));
}
@@ -1086,11 +991,9 @@ fn render_role_array(name: &str, roles: &[AgentRole]) -> String {
fn render_group_value(group: &AgentGroup, roles: &str) -> String {
format!(
"AgentGroupDefinition {{ id: {}, label: {}, role: {}, brief_path_name: {}, roles: {} }}",
"AgentGroupDefinition {{ id: {}, label: {}, roles: {} }}",
rust_literal(&group.id),
rust_literal(&group.label),
rust_literal(&group.role),
rust_literal(&group.brief_path_name),
roles
)
}
@@ -1,13 +1,12 @@
use super::*;
use sha2::{Digest, Sha256};
use std::collections::BTreeSet;
use std::io::{Seek, SeekFrom};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
mod art_manifest;
mod claude_code_cli;
mod codex_app_server;
mod codex_cli;
mod codex_provider;
// 既有调用点仍按旧名引用这两棵子树(`crate::agent::codex_cli::…` 等);保留同义别名,
// 使收敛只改变挂载点,不改变对外路径。
pub(crate) use codex_provider::{app_server as codex_app_server, cli as codex_cli};
mod codex_provider_proxy;
mod design_runtime;
pub(crate) mod design_tools;
@@ -34,20 +33,22 @@ mod direct_validation;
pub(crate) use direct_validation::browser_tool_result;
mod generation;
pub(crate) mod json_sidecar;
mod provider_request_snapshot;
pub(crate) mod redaction;
mod runtime_error;
mod runtime_models;
mod skill_pack;
mod task_lock;
mod thread_manager;
pub mod tool;
pub(crate) use art_manifest::*;
use claude_code_cli::*;
use art_manifest::*;
/// 首页直连回合只在生产入口接线,测试态不编译。
#[cfg(not(test))]
pub(crate) use claude_code_cli::direct_game_creator_claude_code_home_chat;
pub(crate) use claude_code_cli::{
cancel_direct_claude_code_turn_at, claude_code_failure_to_turn_error,
direct_game_creator_claude_code_chat_at, direct_game_creator_claude_code_home_chat,
game_creator_claude_code_cli_route_error, game_creator_claude_code_cli_version_identity,
direct_game_creator_claude_code_chat_at, game_creator_claude_code_cli_route_error,
game_creator_claude_code_cli_version_identity,
};
pub(crate) use codex_app_server::direct_game_creator_codex_chat_at;
pub(crate) use codex_app_server::turn_error::*;
@@ -76,12 +77,11 @@ pub(crate) use direct_tools_mcp::*;
pub(crate) use direct_validation::DirectValidationConfig;
pub(crate) use generation::*;
pub(crate) use json_sidecar::*;
pub(in crate::agent) use provider_request_snapshot::*;
pub(crate) use redaction::*;
pub(crate) use runtime_error::*;
pub(in crate::agent) use runtime_models::*;
pub(crate) use skill_pack::*;
pub(crate) use task_lock::*;
pub(crate) use thread_manager::dispatch::*;
pub(crate) use thread_manager::queue::*;
pub(crate) use thread_manager::wire::*;
pub(crate) use thread_manager::*;
@@ -30,6 +30,7 @@ const CLAUDE_DIRECT_MAX_TURNS: u32 = 16;
/// Codex 侧对同一套工具桥已设 `tool_timeout_sec=6600`(110 分钟),这里按同一口径对齐。
const CLAUDE_CODE_MCP_TOOL_TIMEOUT_MS: u64 = 110 * 60 * 1_000;
const CLAUDE_CODE_OUTPUT_MAX_BYTES: usize = 8 * 1024 * 1024;
#[cfg(not(test))]
const CLAUDE_CODE_PROMPT_MAX_BYTES: usize = 4 * 1024 * 1024;
const CLAUDE_CODE_MODEL_MAX_CHARS: usize = 200;
/// 超时收口时给 sidecar stderr 的排空窗口。
@@ -976,6 +977,7 @@ fn configure_claude_code_process(command: &mut tokio::process::Command) {
super::codex_cli::configure_game_creator_codex_cli_process(command);
}
#[cfg(not(test))]
fn render_claude_code_prompt(request: &LlmRunRequest) -> Result<String, String> {
let messages = serde_json::to_string_pretty(&request.messages)
.map_err(|error| format!("序列化 cc Agent 消息失败:{error}"))?;
@@ -998,6 +1000,7 @@ fn render_claude_code_prompt(request: &LlmRunRequest) -> Result<String, String>
Ok(prompt)
}
#[cfg(not(test))]
fn claude_code_schema(request: &LlmRunRequest) -> Option<serde_json::Value> {
if request.function_tools.is_empty() {
return None;
@@ -1273,6 +1276,7 @@ fn parse_claude_code_result(
})
}
#[cfg(not(test))]
pub(in crate::agent) async fn request_game_creator_agent_claude_code_cli(
request: LlmRunRequest,
) -> Result<platform_llm::LlmRunResponse, platform_llm::LlmError> {
@@ -1323,6 +1327,7 @@ pub(in crate::agent) async fn request_game_creator_agent_claude_code_cli(
)
}
#[cfg(not(test))]
pub(crate) async fn direct_game_creator_claude_code_home_chat(
llm: &GameCreatorLlmConfig,
system_prompt: String,
@@ -877,7 +877,7 @@ impl CodexAppServerWorkspaceMode {
}
}
pub(super) fn resolve_direct_codex_project_authority(
pub(in crate::agent) fn resolve_direct_codex_project_authority(
project_root: &std::path::Path,
) -> Result<(std::path::PathBuf, std::path::PathBuf), String> {
if !project_root.is_absolute() {
@@ -304,8 +304,11 @@ mod tests {
use std::process::Stdio;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt};
// 夹具用 `--exact` 复跑本测试二进制,这个字符串必须与真实模块路径逐字一致;本模块随
// codex provider 收敛挂到 `agent::codex_provider::app_server::` 下,故同步更新(否则子进程跑 0 个用例、
// 父进程等 marker 超时 → `Elapsed(())`)。
const FIXTURE: &str =
"agent::codex_app_server::process_tree::tests::owned_tree_process_fixture";
"agent::codex_provider::app_server::process_tree::tests::owned_tree_process_fixture";
#[cfg(target_os = "linux")]
#[test]
@@ -0,0 +1,29 @@
//! Codex provider 相关逻辑的收敛父模块:凭据、模型目录、OAuth 交接,以及 app-server 与 CLI
//! 两条请求路径。
//!
//! 这两棵子树里的实现目前**休眠**:AGC 交付口径恒走平台会话 / DirectProject 路径,原生
//! app-server 与内置 CLI 的请求入口在 Windows 生产构建里没有调用方(只在用例里被引用)。
//! 上游 master 仍在活跃编辑这些文件,因此本分支**不删除**它们,改为由本模块统一承载,并在此
//! 显式允许 dead code。
//!
//! `#![allow(dead_code)]` 只作用于本模块及其子模块;其它 crate、其它目录与 CI 门禁不受影响。
//! 代价是**本子树内后续新增的 dead code 不会再被任何机器检查发现**。这个例外的理由、失效边界
//! 与退出条件写在 dev 运维文档「Rust 编译告警门禁」一节,改这里时请一并维护。
//!
//! 两个子模块用 `#[path]` 就地挂载,不搬动文件:这样与上游活跃编辑区的冲突面只剩本文件与
//! `agent.rs` 的两行,子树内部的相对路径(`super::…`)由下面的转发与别名保持不变。
#![allow(dead_code)]
use super::*;
/// Codex app-server:连接、进程池、模型目录、OAuth 交接与 Direct 回合编排。
#[path = "../codex_app_server/mod.rs"]
pub(crate) mod app_server;
/// 内置 Codex CLI:随包定位与校验、CLI 请求路径。
#[path = "../codex_cli.rs"]
pub(crate) mod cli;
// 子树内部既有 `super::codex_cli::…`(相对路径)引用;挂载后 `super` 变成本模块,这里保留同义
// 别名,免去改动活跃编辑中的文件。
pub(crate) use cli as codex_cli;
@@ -6,11 +6,16 @@ use crate::{
GameCreatorAgentProgressEvent, GameCreatorManifestInvalidatedEvent,
GameCreatorManifestInvalidationEventSink, GameCreatorManifestInvalidationRelayEnvelope,
};
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
#[cfg(not(test))]
use std::io::Read;
use std::io::Write;
#[cfg(not(test))]
use std::net::TcpListener;
use std::net::TcpStream;
use std::path::Path;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
#[cfg(not(test))]
use std::thread;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tauri::Emitter;
@@ -226,16 +231,6 @@ pub(crate) fn start_game_creator_manifest_invalidation_event_sink(
Ok(GameCreatorManifestInvalidationEventSink { port, token })
}
#[cfg(test)]
pub(crate) fn configure_game_creator_manifest_invalidation_event_sink(
port: u16,
token: &str,
) -> Result<(), String> {
let sink = validate_game_creator_manifest_invalidation_event_sink(port, token)?;
register_game_creator_manifest_invalidation_event_sink(sink);
Ok(())
}
pub(crate) fn validate_game_creator_manifest_invalidation_event_sink(
port: u16,
token: &str,
@@ -281,10 +276,6 @@ pub(crate) struct GameCreatorManifestInvalidationEventSinkTestGuard {
#[cfg(test)]
impl GameCreatorManifestInvalidationEventSinkTestGuard {
pub(crate) fn configure(&self, port: u16, token: &str) -> Result<(), String> {
configure_game_creator_manifest_invalidation_event_sink(port, token)
}
pub(crate) fn configured_sink(&self) -> Option<GameCreatorManifestInvalidationEventSink> {
lock_game_creator_manifest_invalidation_event_sinks()
.first()
@@ -4772,7 +4772,6 @@ async fn run_direct_game_creator_turn_inner(
}
}
};
drop(observer);
reply_result
} else {
let mut feedback_prompt = prompt.to_string();
@@ -3353,7 +3353,7 @@ async fn bridge_environment_check(
root: &Path,
arguments: &Value,
) -> Result<Value, EnvironmentCheckError> {
let arguments = ToolArgumentsRejection::check(arguments, &[])?;
ToolArgumentsRejection::check(arguments, &[])?;
enforce_project_permission_policy_rejection(root, "game.run_local")?;
let result = crate::environment_check::check_environment(root).await;
if result["status"] != "ready" {
@@ -1925,6 +1925,7 @@ async fn handle_external_mcp_http_request(
Ok(Json(response).into_response())
}
#[cfg(not(test))]
pub(crate) async fn start_external_mcp_loopback(
root: &Path,
controlled_web_search: bool,
@@ -2001,6 +2002,7 @@ async fn start_external_mcp_loopback_with_mode(
Ok((url, token))
}
#[cfg(not(test))]
pub(crate) fn stop_external_mcp_loopback() {
if let Some(registry) = EXTERNAL_MCP_SERVER.get() {
match registry.lock() {
@@ -1,22 +1,20 @@
use super::*;
mod canvas_generation;
mod draft_validation;
mod draft_writer;
mod external_generation_state;
mod loop_orchestration;
mod pass_artifacts;
mod game_html_validation;
mod llm_request;
mod prompt_context;
mod trace;
/// 只服务生产入口(`commands::desktop`)的生成入口,测试态不编译。
#[cfg(not(test))]
pub(crate) use canvas_generation::generate_platform_art_asset_at;
#[cfg(test)]
pub(crate) use canvas_generation::submit_external_generation_request;
pub(in crate::agent) use canvas_generation::{
admit_platform_art_generation_at, commit_prepared_platform_art_asset_at,
commit_prepared_platform_art_asset_strict_slices_at, generate_admitted_platform_art_asset_at,
generate_art_package_asset_with_runtime_options_at,
platform_art_generation_error_result_unknown, recover_persisted_visual_generation_options,
register_existing_platform_art_slices_at, request_platform_art_asset_with_runtime_options_at,
admit_platform_art_generation_at, generate_admitted_platform_art_asset_at,
generate_art_package_asset_with_runtime_options_at, register_existing_platform_art_slices_at,
restore_platform_art_asset_bytes_at,
retained_platform_art_generation_runtime_spritesheet_identity_at,
retained_platform_art_generation_runtime_state_matches_direct_stage_at,
@@ -28,7 +26,16 @@ pub(crate) use canvas_generation::{
prepare_external_canvas_generation_context, resolve_canvas_resource_download_with_access,
ExternalCanvasGenerationContext,
};
pub(in crate::agent) use draft_validation::validate_closed_game_script_blocks;
pub(crate) use canvas_generation::{
generate_platform_art_asset_with_options_at, normalize_platform_art_asset_generation_kind,
normalize_platform_art_reference_asset_ids, normalize_platform_art_target_category,
platform_art_runtime_references_match_request_contract, validate_platform_art_icon_prompt,
PlatformArtAssetGenerationOptions, PLATFORM_ART_ASSET_GENERATION_KINDS,
};
#[cfg(test)]
pub(crate) use external_generation_state::platform_art_generation_legacy_external_configuration_fingerprint;
#[cfg(test)]
pub(in crate::agent) use external_generation_state::PLATFORM_ART_GENERATION_RUNTIME_SCHEMA_VERSION;
pub(crate) use external_generation_state::{
classify_platform_art_generation_service_identity,
platform_art_generation_external_service_fingerprint,
@@ -37,78 +44,19 @@ pub(crate) use external_generation_state::{
};
pub(in crate::agent) use external_generation_state::{
game_creator_agent_runtime_external_generation_exists,
platform_art_generation_runtime_context_from_pending,
platform_art_generation_runtime_recovery_at, remove_platform_art_generation_runtime_state_at,
PlatformArtGenerationRuntimeContext, PlatformArtGenerationRuntimeRecovery,
PLATFORM_ART_GENERATION_RUNTIME_SCHEMA_VERSION,
remove_platform_art_generation_runtime_state_at, PlatformArtGenerationRuntimeContext,
};
#[cfg(test)]
pub(crate) use external_generation_state::{
platform_art_generation_legacy_external_configuration_fingerprint,
write_platform_art_generation_runtime_accepted_for_test,
write_platform_art_generation_runtime_prepared_for_test,
pub(crate) use game_html_validation::{
contains_any, fnv1a64, strip_llm_thinking_blocks, validate_game_html_smoke,
};
pub(in crate::agent) use loop_orchestration::build_game_creator_agent_runtime_llm_client;
pub(in crate::agent) use trace::game_creation_agent_group_id;
#[cfg(test)]
pub(crate) use canvas_generation::request_platform_art_asset_with_options_for_test;
#[allow(unused_imports)]
pub(crate) use canvas_generation::{
build_platform_art_asset_prompt, editor_api_key_is_configured, generate_platform_art_asset_at,
generate_platform_art_asset_with_options_at, maybe_generate_platform_art_asset_step,
needs_platform_art_asset_generation, normalize_platform_art_asset_generation_kind,
normalize_platform_art_reference_asset_ids, normalize_platform_art_target_category,
platform_art_asset_art_spec, platform_art_asset_output_extension_matches,
platform_art_runtime_references_match_request_contract, prepare_platform_art_asset_output_path,
project_canvas_asset_media_types, role_has_canvas_assets, validate_platform_art_icon_prompt,
PlatformArtAssetGenerationOptions, PLATFORM_ART_ASSET_GENERATION_KINDS,
pub(crate) use llm_request::{
game_creator_agent_llm_error_is_mud_points_insufficient,
game_creator_agent_llm_error_public_summary, request_game_creator_llm_text,
};
#[allow(unused_imports)]
pub(crate) use draft_validation::{
contains_any, evaluate_game_draft, extract_json_payload, fnv1a64,
parse_llm_game_draft_response, render_evaluator_findings, render_planner_spec,
strip_llm_thinking_blocks, validate_canvas_rendering_html, validate_game_html_smoke,
validate_llm_agent_handoffs, validate_llm_game_draft, validate_non_placeholder_game_html,
validate_playable_game_html, validate_safe_game_html_runtime,
};
pub(crate) use draft_writer::{ensure_legacy_json_generator_project, write_local_game_draft_at};
#[allow(unused_imports)]
pub(crate) use loop_orchestration::{
emit_agent_progress, game_creator_agent_llm_error_is_mud_points_insufficient,
game_creator_agent_llm_error_public_summary, game_creator_mud_points_insufficient_message,
game_creator_runtime_error_is_mud_points_insufficient, request_game_creator_llm_text,
AgentProgressEmitter, GAME_CREATOR_MUD_POINTS_INSUFFICIENT_ERROR_KIND,
};
#[allow(unused_imports)]
pub(crate) use pass_artifacts::{
agent_role_memory_relative_path, agent_role_memory_relative_path_for_task,
append_agent_success_memories, append_group_brief_steps, read_previous_agent_role_brief,
render_agent_pass_agenda_markdown, render_agent_pass_task_graph_json,
render_carryover_role_brief, write_agent_group_brief, write_agent_pass_agenda,
write_agent_pass_artifacts, write_agent_pass_file, write_agent_role_brief,
};
#[allow(unused_imports)]
pub(crate) use prompt_context::{
append_prompt_context, asset_source_kind_label, game_creator_generator_user_prompt,
game_creator_planner_system_prompt, game_creator_planner_user_prompt,
game_creator_system_prompt, read_optional_text, redact_secret_tokens,
render_local_asset_prompt_context, render_local_conversation_prompt_context,
render_local_conversation_prompt_context_for_session, sanitize_error_context,
sanitize_prompt_context, truncate_prompt_context, truncate_prompt_context_preserving_tail,
redact_secret_tokens, sanitize_error_context, sanitize_prompt_context, truncate_prompt_context,
};
#[allow(unused_imports)]
pub(crate) use trace::{
agent_run_lifecycle_status, agent_run_stop_reason, agent_trace_step, agent_trace_step_owned,
append_agent_run_activity, append_agent_run_jsonl, append_agent_run_output,
append_agent_run_trace_step, append_local_artifact_write_step, append_preview_log,
append_preview_start_trace_step, append_preview_stop_trace_step,
append_static_smoke_manual_trace_step, append_static_smoke_step,
build_agent_run_task_graph_trace, collect_agent_pass_plan_traces,
game_creation_agent_group_from_id, infer_agent_trace_phase, parse_agent_task_id_list,
read_agent_agenda_snapshot, ready_task_ids_for_tasks, record_replaced_preview_stop,
role_brief_completes_task, set_task_status_if_current, should_replace_task_status, status_rank,
task_has_status, task_status_from_agent_step, with_task_context,
write_agent_run_context_bundle, write_agent_run_trace, AgentAgendaSnapshot,
AgentPassTaskGraphSnapshot,
append_preview_log, append_preview_start_trace_step, append_preview_stop_trace_step,
append_static_smoke_manual_trace_step, record_replaced_preview_stop,
};
@@ -11,6 +11,7 @@ use super::external_generation_state::{
retain_platform_art_generation_runtime_accepted_result, PlatformArtGenerationRuntimeState,
};
use super::*;
use crate::agent::redaction::redact_absolute_path_tokens;
use crate::platform_session::{
acquire_platform_session_identity_lease, validate_platform_session_identity,
PlatformSessionIdentity,
@@ -34,21 +35,6 @@ enum PlatformArtGenerationPurpose {
GameArtPackage,
}
/// 设计阶段的功能页面输出路径是否合法:必须落在 `assets/ui-pages/` 下、`.png` 结尾,
/// 文件名只含安全字符且不超过 80 字符。
pub(in crate::agent) fn design_foundation_ui_page_output_path_is_valid(path: &str) -> bool {
let Some(page_id) = path
.strip_prefix("assets/ui-pages/")
.and_then(|value| value.strip_suffix(".png"))
else {
return false;
};
!page_id.is_empty()
&& page_id.len() <= 80
&& page_id
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
}
const PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX: &str = "platform-generation-local-reconciliation:";
const PLATFORM_ART_SPRITESHEET_TOTAL_DOWNLOAD_BYTES: usize = 32 * 1024 * 1024;
const PLATFORM_ART_SPRITESHEET_SINGLE_DOWNLOAD_BYTES: usize = 20 * 1024 * 1024;
@@ -317,112 +303,6 @@ fn remove_external_canvas_binding_partial(
sync_agent_runtime_sidecar_parent(&path, "External Editor project binding partial")
}
pub(crate) fn project_canvas_asset_media_types(root: &Path) -> Vec<String> {
read_manifest_for_project(root)
.map(|manifest| {
manifest
.assets
.iter()
.filter(|asset| asset.source.kind == GameCreationAppAssetSourceKind::Canvas)
.map(|asset| asset.media_type.clone())
.collect()
})
.unwrap_or_default()
}
pub(crate) async fn maybe_generate_platform_art_asset_step(
root: &Path,
prompt: &str,
briefs: &[AgentGroupBrief],
pass: u8,
progress: Option<&AgentProgressEmitter<'_>>,
) -> Option<GameCreationAgentRunStep> {
if !needs_platform_art_asset_generation(root, briefs) || !editor_api_key_is_configured() {
return None;
}
emit_agent_progress(
progress,
"editor.image_generate",
"美术组正在通过平台 External Editor API 生成首版素材",
);
let result = generate_platform_art_asset_at(root, prompt, briefs).await;
let (status, output_paths, summary) = match result {
Ok(generated) => (
"completed",
vec![generated.asset.local_path.clone()],
format!(
"已通过平台 External Editor API 生成首版美术素材:{}",
generated.asset.local_path
),
),
Err(error) => (
"failed",
Vec::new(),
format!("平台 External Editor API 生成首版美术素材失败:{error}"),
),
};
Some(with_task_context(
agent_trace_step_owned(
pass,
"美术组 / PlatformImageGenerate",
status,
vec![
".agent/spec.md".to_string(),
".agent/manifest.json".to_string(),
],
output_paths,
&summary,
"agent.tool.platform.editor_image_generate",
),
"art",
"Asset",
Some("art-asset-plan"),
"asset-generation",
))
}
pub(crate) fn needs_platform_art_asset_generation(root: &Path, briefs: &[AgentGroupBrief]) -> bool {
let canvas_asset_media_types = project_canvas_asset_media_types(root);
briefs.iter().any(|brief| {
brief.role_briefs.iter().any(|role_brief| {
role_brief.status == "completed"
&& role_brief.group_definition.id == "art"
&& role_brief.role_definition.id == "asset"
&& !role_has_canvas_assets(role_brief, &canvas_asset_media_types)
})
})
}
pub(crate) fn editor_api_key_is_configured() -> bool {
if external_editor_api_credentials_override_is_active() {
return true;
}
if platform_editor_uses_account_routes() {
return platform_session_is_available();
}
load_game_creator_app_config()
.ok()
.and_then(|config| trim_config_string(&config.editor_api.api_key))
.is_some()
}
pub(crate) fn role_has_canvas_assets(role_brief: &AgentRoleBrief, media_types: &[String]) -> bool {
match (
role_brief.group_definition.id,
role_brief.role_definition.id,
) {
("art", "asset") => media_types.iter().any(|media_type| {
let media_type = media_type.as_str();
media_type.starts_with("image/")
|| media_type == "application/vnd.genarrative.image-sequence"
}),
("audio", "sfx") => media_types
.iter()
.any(|media_type| media_type.starts_with("audio/")),
_ => false,
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct PlatformArtAssetGenerationOptions {
pub(crate) output_path: Option<String>,
@@ -607,89 +487,6 @@ pub(crate) fn normalize_platform_art_reference_asset_ids(
Ok(normalized)
}
pub(in crate::agent) fn recover_persisted_visual_generation_options(
root: &Path,
pending: &AgentRuntimePendingToolAction,
prompt: &str,
requested: &PlatformArtAssetGenerationOptions,
) -> Result<Option<PlatformArtAssetGenerationOptions>, String> {
let context = platform_art_generation_runtime_context_from_pending(pending);
let Some(state) = read_platform_art_generation_runtime_state(root, &context)? else {
return Ok(None);
};
let (path, ratio, size, kind, label, generation_kind) = match context.agent_id.as_str() {
"art-director" => (
"assets/art-spec.png",
"1:1",
"1K",
GameCreationAppAssetKind::IconSpec,
"游戏统一视觉规范图",
"spec",
),
"design-foundation" => (
"assets/ui-prototype.png",
"16:9",
"2K",
GameCreationAppAssetKind::UiDesign,
"游戏横屏界面原型图",
"ui-design",
),
"art-asset-plan" => (
"assets/art-spritesheet.png",
"1:1",
"1K",
GameCreationAppAssetKind::IconSpritesheet,
"游戏首版核心美术素材",
"icon-spritesheet",
),
_ => return Ok(None),
};
let mut options = requested.clone();
let label = if context.agent_id == "design-foundation"
&& options
.output_path
.as_deref()
.is_some_and(design_foundation_ui_page_output_path_is_valid)
{
"游戏功能页面设计图"
} else {
label
};
if options.output_path.is_none() {
options.output_path = Some(path.to_string());
}
if options.aspect_ratio.is_empty() {
options.aspect_ratio = ratio.to_string();
}
if options.image_size.is_empty() {
options.image_size = size.to_string();
}
if options.asset_kind == GameCreationAppAssetKind::Unknown {
options.asset_kind = kind;
}
if options.asset_label.is_empty() {
options.asset_label = label.to_string();
}
let snapshot = platform_art_generation_runtime_request_snapshot(&state)?;
// 只恢复经过身份校验的已有请求;显式改参和新请求仍走当前合同。
if snapshot.generation_kind == generation_kind
&& snapshot.generation_prompt == build_platform_art_asset_prompt(prompt, &options)
&& options.asset_kind == kind
&& options.aspect_ratio == ratio
&& options.image_size == size
&& (options.output_path.as_deref() == Some(path)
|| (context.agent_id == "design-foundation"
&& options
.output_path
.as_deref()
.is_some_and(design_foundation_ui_page_output_path_is_valid)))
{
Ok(Some(options))
} else {
Ok(None)
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct ExistingPlatformArtAssetFingerprint {
local_path: String,
@@ -757,14 +554,6 @@ fn prepare_platform_art_asset_output_path_for_mode(
Ok(Some((normalized, absolute, existing)))
}
pub(crate) fn prepare_platform_art_asset_output_path(
root: &Path,
output_path: Option<&str>,
) -> Result<Option<(String, PathBuf)>, String> {
prepare_platform_art_asset_output_path_for_mode(root, output_path, false)
.map(|prepared| prepared.map(|(local_path, absolute_path, _)| (local_path, absolute_path)))
}
pub(crate) fn platform_art_asset_output_extension_matches(
output_path: &str,
generated_extension: &str,
@@ -940,6 +729,7 @@ pub(crate) fn platform_art_generation_error_needs_reconciliation(error: &str) ->
|| error.starts_with(PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX)
}
#[cfg(test)]
pub(in crate::agent) fn platform_art_generation_error_result_unknown(error: &str) -> bool {
error.starts_with(EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX)
}
@@ -1735,6 +1525,7 @@ pub(crate) fn external_canvas_placeholder(aspect_ratio: &str) -> serde_json::Val
})
}
#[cfg(not(test))]
pub(crate) async fn generate_platform_art_asset_at(
root: &Path,
prompt: &str,
@@ -3264,16 +3055,6 @@ async fn generate_platform_art_asset_with_runtime_options_and_retention_at(
Ok(generated)
}
#[cfg(test)]
pub(in crate::agent) async fn request_platform_art_asset_with_options_at(
root: &Path,
prompt: &str,
briefs: &[AgentGroupBrief],
options: &PlatformArtAssetGenerationOptions,
) -> Result<PreparedPlatformArtAssetGeneration, String> {
request_platform_art_asset_with_runtime_options_at(root, prompt, briefs, options, None).await
}
pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at(
root: &Path,
prompt: &str,
@@ -3969,17 +3750,6 @@ async fn request_platform_art_asset_for_purpose_at(
})
}
#[cfg(test)]
pub(crate) async fn request_platform_art_asset_with_options_for_test(
root: &Path,
prompt: &str,
options: &PlatformArtAssetGenerationOptions,
) -> Result<(), String> {
request_platform_art_asset_with_options_at(root, prompt, &[], options)
.await
.map(|_| ())
}
pub(in crate::agent) fn commit_prepared_platform_art_asset_at(
root: &Path,
prepared: PreparedPlatformArtAssetGeneration,
@@ -1,155 +0,0 @@
use super::*;
pub(crate) fn write_local_game_draft_at(
root: &Path,
prompt: &str,
draft: &LlmGameDraft,
) -> Result<GenerateLocalGameDraftResult, String> {
let prompt = prompt.trim();
if prompt.is_empty() {
return Err("创作想法不能为空".to_string());
}
ensure_legacy_json_generator_project(root)?;
validate_llm_game_draft(prompt, draft)?;
let checkpoint = create_local_project_checkpoint_at(root)?;
let timestamp = unix_timestamp();
let title = draft.title.trim();
let handoff_summary = draft.handoff_summary.trim();
let design_path = root.join("game/game_design.md");
let balance_path = root.join("game/balance.json");
let art_manifest_path = root.join("assets/manifest.art.json");
let audio_manifest_path = root.join("assets/manifest.audio.json");
let agent_log_path = root.join(".agent/logs/agent.log");
let short_memory_path = root.join("memory/session.md");
let long_memory_path = root.join("memory/project.md");
let game_index_path = root.join("game/index.html");
append_markdown_entry(
&short_memory_path,
"# 短期记忆\n\n",
&format!("- {timestamp}: {prompt}\n"),
"写入短期记忆失败",
)?;
append_markdown_entry(
&long_memory_path,
"# 项目长期记忆\n\n## 当前约束\n\n- Web 小游戏原型\n- 本地 HTTP 预览\n\n## 创作目标记录\n\n",
&format!("- {timestamp}: {prompt}\n"),
"写入长期记忆失败",
)?;
fs::write(
&design_path,
format!(
"# 游戏设计草案\n\n## 原始想法\n\n{prompt}\n\n## Agent 协作交接\n\n{handoff_summary}\n\n## LLM 生成草案\n\n{}\n",
draft.design_markdown.trim()
),
)
.map_err(|error| format!("写入游戏设计失败:{}: {error}", design_path.display()))?;
fs::write(
&balance_path,
serde_json::to_string_pretty(&draft.balance)
.map_err(|error| format!("生成数值配置失败:{error}"))?,
)
.map_err(|error| format!("写入数值配置失败:{}: {error}", balance_path.display()))?;
fs::write(
&art_manifest_path,
serde_json::to_string_pretty(&draft.art_manifest)
.map_err(|error| format!("生成美术清单失败:{error}"))?,
)
.map_err(|error| format!("写入美术清单失败:{}: {error}", art_manifest_path.display()))?;
fs::write(
&audio_manifest_path,
serde_json::to_string_pretty(&draft.audio_manifest)
.map_err(|error| format!("生成音乐音效清单失败:{error}"))?,
)
.map_err(|error| {
format!(
"写入音乐音效清单失败:{}: {error}",
audio_manifest_path.display()
)
})?;
fs::write(&game_index_path, draft.game_html.trim())
.map_err(|error| format!("写入游戏入口失败:{}: {error}", game_index_path.display()))?;
fs::OpenOptions::new()
.create(true)
.append(true)
.open(&agent_log_path)
.and_then(|mut file| {
file.write_all(
format!("{timestamp} game.generate_draft llm\n{handoff_summary}\n").as_bytes(),
)
})
.map_err(|error| format!("写入 Agent 日志失败:{}: {error}", agent_log_path.display()))?;
append_agent_db_record(
root,
serde_json::json!({
"recordType": "game.generate_draft",
"goal": prompt,
"title": title,
"paths": [
"memory/session.md",
"memory/project.md",
"game/game_design.md",
"game/balance.json",
"assets/manifest.art.json",
"assets/manifest.audio.json",
"game/index.html",
],
}),
)?;
let manifest = record_draft_task_progress(root, prompt, timestamp, &agent_log_path)?;
let diff = diff_local_project_checkpoint_at(root, &checkpoint.checkpoint_id)?;
append_agent_db_record(
root,
serde_json::json!({
"recordType": "project.diff",
"checkpointId": checkpoint.checkpoint_id,
"added": diff.added.len(),
"changed": diff.changed.len(),
"deleted": diff.deleted.len(),
}),
)?;
Ok(GenerateLocalGameDraftResult {
project_path: root.to_string_lossy().into_owned(),
game_index_path: game_index_path.to_string_lossy().into_owned(),
design_path: design_path.to_string_lossy().into_owned(),
short_memory_path: short_memory_path.to_string_lossy().into_owned(),
long_memory_path: long_memory_path.to_string_lossy().into_owned(),
manifest,
})
}
pub(crate) fn ensure_legacy_json_generator_project(root: &Path) -> Result<(), String> {
if root.join("game/package.json").exists()
|| root.join("package.json").exists()
|| !root.join("game/index.html").is_file()
|| !root.join(".agent/manifest.json").is_file()
{
return Err("JSON Generator 仅支持已有的单文件 HTML 项目;新建游戏与 npm / Phaser 4 项目请使用 DirectProject 完成依赖安装、构建和试玩".to_string());
}
Ok(())
}
#[cfg(test)]
mod legacy_generator_tests {
use super::*;
#[test]
fn legacy_generator_rejects_npm_before_writing() {
let root = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap();
fs::create_dir_all(root.path().join("game")).unwrap();
fs::create_dir_all(root.path().join(".agent")).unwrap();
fs::write(root.path().join("game/index.html"), "legacy source").unwrap();
fs::write(root.path().join(".agent/manifest.json"), "{}").unwrap();
assert!(ensure_legacy_json_generator_project(root.path()).is_ok());
fs::write(root.path().join("game/package.json"), "{}").unwrap();
assert!(ensure_legacy_json_generator_project(root.path())
.unwrap_err()
.contains("DirectProject"));
assert_eq!(
fs::read_to_string(root.path().join("game/index.html")).unwrap(),
"legacy source"
);
assert!(!root.path().join(".agent/spec.md").exists());
}
}
@@ -192,14 +192,6 @@ pub(super) struct PlatformArtGenerationRuntimeRequestSnapshot {
pub(super) reference_resource_ids: Vec<String>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(in crate::agent) enum PlatformArtGenerationRuntimeRecovery {
Missing,
ResumePrepared,
ResumeAccepted,
ResumeLegacyCompleted,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum PlatformArtGenerationServiceIdentityMatch {
Unbound,
@@ -247,20 +239,6 @@ pub(in crate::agent) fn game_creator_agent_runtime_external_generation_exists(
|| entry_exists_fail_closed(&agent_runtime_json_sidecar_backup_path(&path))
}
pub(in crate::agent) fn platform_art_generation_runtime_context_from_pending(
pending: &AgentRuntimePendingToolAction,
) -> PlatformArtGenerationRuntimeContext {
PlatformArtGenerationRuntimeContext {
agent_id: pending.agent_id.clone(),
task_id: pending.task_id.clone(),
session_id: pending.session_id.clone(),
run_id: pending.run_id.clone(),
source: pending.source.clone(),
action_id: pending.action_id.clone(),
action_fingerprint: pending.action_fingerprint.clone(),
}
}
fn request_body_json_and_sha256(
request_body: &serde_json::Value,
) -> Result<(String, String), String> {
@@ -1055,31 +1033,6 @@ pub(super) fn platform_art_generation_runtime_status(
&state.status
}
pub(in crate::agent) fn platform_art_generation_runtime_recovery_at(
root: &Path,
pending: &AgentRuntimePendingToolAction,
) -> Result<PlatformArtGenerationRuntimeRecovery, String> {
if pending.action.tool != "canvas.asset_generate" {
return Ok(PlatformArtGenerationRuntimeRecovery::Missing);
}
let context = platform_art_generation_runtime_context_from_pending(pending);
let Some(state) = read_platform_art_generation_runtime_state(root, &context)? else {
return Ok(PlatformArtGenerationRuntimeRecovery::Missing);
};
Ok(match state.status.as_str() {
PLATFORM_ART_GENERATION_STATUS_PREPARED => {
PlatformArtGenerationRuntimeRecovery::ResumePrepared
}
PLATFORM_ART_GENERATION_STATUS_ACCEPTED => {
PlatformArtGenerationRuntimeRecovery::ResumeAccepted
}
PLATFORM_ART_GENERATION_STATUS_LEGACY_COMPLETED => {
PlatformArtGenerationRuntimeRecovery::ResumeLegacyCompleted
}
_ => unreachable!("validated External Editor generation state status"),
})
}
pub(in crate::agent) fn remove_platform_art_generation_runtime_state_at(
root: &Path,
agent_id: &str,
@@ -1240,93 +1193,10 @@ pub(super) fn platform_art_generation_runtime_action_fingerprint_for_test(
&state.action_fingerprint
}
#[cfg(test)]
pub(crate) fn write_platform_art_generation_runtime_accepted_for_test(
root: &Path,
pending: &AgentRuntimePendingToolAction,
) -> Result<(), String> {
let context = platform_art_generation_runtime_context_from_pending(pending);
let (api_base_url, api_key, platform_session) = resolve_canvas_sync_api_credentials(None, None)
.unwrap_or_else(|_| {
(
"http://127.0.0.1:1".to_string(),
"durable-test-key".to_string(),
None,
)
});
let access =
ExternalEditorBindingAccess::new(&api_base_url, &api_key, platform_session.as_ref())?;
let (state, created) = prepare_platform_art_generation_runtime_state(
root,
&context,
"/api/external/v1/editor/images/generations",
"durable-test-canvas",
"durable test generation",
&serde_json::json!({
"prompt": "durable test generation",
"kind": "spec",
"projectId": "test-canvas-project",
"assetFolderId": "test-asset-folder",
"referenceImageSrcs": []
}),
&access,
)?;
if !created {
return Err("External Editor 测试账本已存在".to_string());
}
mark_platform_art_generation_runtime_accepted(root, state, "test-operation-id", 1_500)?;
Ok(())
}
#[cfg(test)]
pub(crate) fn write_platform_art_generation_runtime_prepared_for_test(
root: &Path,
pending: &AgentRuntimePendingToolAction,
) -> Result<(), String> {
let context = platform_art_generation_runtime_context_from_pending(pending);
let (api_base_url, api_key, platform_session) = resolve_canvas_sync_api_credentials(None, None)
.unwrap_or_else(|_| {
(
"http://127.0.0.1:1".to_string(),
"durable-test-key".to_string(),
None,
)
});
let access =
ExternalEditorBindingAccess::new(&api_base_url, &api_key, platform_session.as_ref())?;
let (_, created) = prepare_platform_art_generation_runtime_state(
root,
&context,
"/api/external/v1/editor/images/generations",
"durable-test-canvas",
"durable test generation",
&serde_json::json!({
"prompt": "durable test generation",
"kind": "spec",
"projectId": "test-canvas-project",
"assetFolderId": "test-asset-folder",
"referenceImageSrcs": []
}),
&access,
)?;
if !created {
return Err("External Editor 测试账本已存在".to_string());
}
Ok(())
}
#[cfg(test)]
mod external_generation_state_tests {
use super::*;
fn developer_access<'a>(
api_base_url: &'a str,
api_key: &'a str,
) -> ExternalEditorBindingAccess<'a> {
ExternalEditorBindingAccess::for_developer(api_base_url, api_key)
.expect("create developer binding access")
}
fn direct_art_spec_runtime_context(project_id: &str) -> PlatformArtGenerationRuntimeContext {
PlatformArtGenerationRuntimeContext {
agent_id: "direct-codex-art".to_string(),
@@ -1,82 +1,11 @@
use super::*;
//! 游戏 HTML / JS 校验原语:静态冒烟自检与生成链共用。
//!
//! 草案(draft)与多 pass 校验链已随 Agent Runtime 退役删除;本模块只保留校验。
use oxc_allocator::Allocator as JavascriptAllocator;
use oxc_ast::ast::{
RegExpLiteral as JavascriptRegExpLiteral, StringLiteral as JavascriptStringLiteral,
TemplateElement as JavascriptTemplateElement,
};
use oxc_ast_visit::Visit as VisitJavascript;
use oxc_parser::Parser as JavascriptParser;
use oxc_semantic::SemanticBuilder as JavascriptSemanticBuilder;
use oxc_span::{GetSpan as JavascriptGetSpan, SourceType as JavascriptSourceType};
pub(crate) fn evaluate_game_draft(prompt: &str, draft: &LlmGameDraft) -> Vec<String> {
let mut issues = Vec::new();
if let Err(error) = validate_llm_game_draft(prompt, draft) {
issues.push(error);
}
let html = draft.game_html.to_ascii_lowercase();
if !["keydown", "keyup", "pointer", "mousedown", "touch", "click"]
.iter()
.any(|needle| html.contains(needle))
{
issues.push("gameHtml 缺少明确的键盘、鼠标或触摸输入监听".to_string());
}
issues
}
pub(crate) fn render_planner_spec(prompt: &str, spec: &str) -> String {
format!(
"# Planner Spec\n\n## 用户需求\n\n{}\n\n## 规格\n\n{}\n",
prompt.trim(),
spec.trim()
)
}
pub(crate) fn render_evaluator_findings(pass: u8, issues: &[impl AsRef<str>]) -> String {
let issue_values = issues
.iter()
.map(|issue| issue.as_ref().trim())
.filter(|issue| !issue.is_empty())
.collect::<Vec<_>>();
let actionable_issues = issue_values
.iter()
.filter(|issue| !issue.contains("暂无上一轮问题"))
.map(|issue| (*issue).to_string())
.collect::<Vec<_>>();
let mut output = format!(
"# Evaluator Findings\n\n- pass: {pass}\n- status: {}\n\n",
if issue_values.is_empty() {
"passed"
} else {
"needs-revision"
}
);
if issue_values.is_empty() {
output
.push_str("## 结果\n\n- 本地静态验收通过:HTML 自包含、包含 canvas、主循环和输入。\n");
} else {
output.push_str("## 问题\n\n");
for issue in &issue_values {
output.push_str("- ");
output.push_str(issue);
output.push('\n');
}
}
output.push_str("\n## Repair Routes\n\n```json\n");
if actionable_issues.is_empty() {
output.push_str("[]");
} else {
let routes = build_game_creation_seed_task_graph("AI 游戏创作")
.map(|graph| route_game_creation_repair_issues(&graph, &actionable_issues))
.unwrap_or_default();
match serde_json::to_string_pretty(&routes) {
Ok(payload) => output.push_str(&payload),
Err(_) => output.push_str("[]"),
}
}
output.push_str("\n```\n");
output
}
use oxc_span::SourceType as JavascriptSourceType;
pub(crate) fn fnv1a64(bytes: &[u8]) -> u64 {
let mut hash = 0xcbf29ce484222325_u64;
@@ -87,14 +16,6 @@ pub(crate) fn fnv1a64(bytes: &[u8]) -> u64 {
hash
}
pub(crate) fn parse_llm_game_draft_response(content: &str) -> Result<LlmGameDraft, String> {
let content = strip_llm_thinking_blocks(content);
let payload = extract_json_payload(content.as_str())
.ok_or_else(|| "LLM 返回不是 JSON 对象".to_string())?;
serde_json::from_str::<LlmGameDraft>(payload)
.map_err(|error| format!("解析 LLM 游戏草案失败:{error}"))
}
pub(crate) fn strip_llm_thinking_blocks(content: &str) -> String {
let mut output = String::new();
let mut rest = content;
@@ -113,91 +34,6 @@ pub(crate) fn strip_llm_thinking_blocks(content: &str) -> String {
output.trim().to_string()
}
pub(crate) fn extract_json_payload(content: &str) -> Option<&str> {
let trimmed = content.trim();
let without_fence = trimmed
.strip_prefix("```json")
.or_else(|| trimmed.strip_prefix("```"))
.and_then(|value| value.strip_suffix("```"))
.map(str::trim)
.unwrap_or(trimmed);
let start = without_fence.find('{')?;
let mut depth = 0usize;
let mut inside_string = false;
let mut escaped = false;
for (offset, character) in without_fence[start..].char_indices() {
if inside_string {
if escaped {
escaped = false;
} else if character == '\\' {
escaped = true;
} else if character == '"' {
inside_string = false;
}
continue;
}
match character {
'"' => inside_string = true,
'{' => depth += 1,
'}' => {
depth = depth.checked_sub(1)?;
if depth == 0 {
let end = start + offset + character.len_utf8();
return Some(&without_fence[start..end]);
}
}
_ => {}
}
}
None
}
pub(crate) fn validate_llm_game_draft(prompt: &str, draft: &LlmGameDraft) -> Result<(), String> {
if draft.title.trim().is_empty() {
return Err("LLM 草案缺少标题".to_string());
}
if draft.design_markdown.trim().is_empty() {
return Err("LLM 草案缺少设计说明".to_string());
}
if !draft.balance.is_object() {
return Err("LLM 草案 balance 必须是 JSON object".to_string());
}
if !draft.art_manifest.is_object() {
return Err("LLM 草案 artManifest 必须是 JSON object".to_string());
}
if !draft.audio_manifest.is_object() {
return Err("LLM 草案 audioManifest 必须是 JSON object".to_string());
}
if draft.publish_readme.trim().is_empty() {
return Err("LLM 草案缺少发布说明".to_string());
}
if draft.handoff_summary.trim().is_empty() {
return Err("LLM 草案缺少多智能体交接摘要".to_string());
}
validate_llm_agent_handoffs(draft)?;
let html = draft.game_html.trim();
let lower_html = html.to_ascii_lowercase();
if !lower_html.contains("<html") && !lower_html.contains("<!doctype html") {
return Err("LLM 草案 gameHtml 必须是完整 HTML".to_string());
}
if !html.contains("requestAnimationFrame") {
return Err("LLM 草案 gameHtml 必须包含游戏主循环".to_string());
}
if !lower_html.contains("<canvas") {
return Err("LLM 草案 gameHtml 必须包含可渲染画布".to_string());
}
validate_canvas_rendering_html(html, "LLM 草案 gameHtml")?;
validate_safe_game_html_runtime(html, "LLM 草案 gameHtml")?;
if (prompt.contains('<') || prompt.contains('>')) && html.contains(prompt) {
return Err("LLM 草案 gameHtml 包含未转义的用户输入".to_string());
}
validate_playable_game_html(html, "LLM 草案 gameHtml")?;
validate_non_placeholder_game_html(html, "LLM 草案 gameHtml")?;
Ok(())
}
pub(crate) fn validate_playable_game_html(html: &str, label: &str) -> Result<(), String> {
let lower_html = html.to_ascii_lowercase();
if !contains_any(
@@ -381,28 +217,6 @@ pub(crate) fn contains_any(haystack: &str, needles: &[&str]) -> bool {
needles.iter().any(|needle| haystack.contains(needle))
}
pub(crate) fn validate_llm_agent_handoffs(draft: &LlmGameDraft) -> Result<(), String> {
const REQUIRED_GROUPS: [&str; 6] = ["design", "balance", "art", "audio", "code", "publishing"];
for required_group in REQUIRED_GROUPS {
let handoff = draft
.handoffs
.iter()
.find(|handoff| handoff.group.trim() == required_group)
.ok_or_else(|| format!("LLM 草案 handoffs 缺少 {required_group} 专业组"))?;
if handoff.role.trim().is_empty()
|| handoff.summary.trim().is_empty()
|| handoff.next.trim().is_empty()
|| handoff
.outputs
.iter()
.all(|output| output.trim().is_empty())
{
return Err(format!("LLM 草案 handoffs.{required_group} 交接内容不完整"));
}
}
Ok(())
}
fn is_html_ascii_whitespace(byte: u8) -> bool {
matches!(byte, b'\t' | b'\n' | b'\x0c' | b'\r' | b' ')
}
@@ -540,82 +354,6 @@ fn validate_executable_inline_javascript_syntax(html: &str) -> Result<(), String
Ok(())
}
fn html_tag_like_start(content: &str, tag_start: usize) -> bool {
content
.as_bytes()
.get(tag_start + 1)
.is_some_and(|byte| byte.is_ascii_alphabetic() || matches!(byte, b'/' | b'!' | b'?'))
}
#[derive(Default)]
struct JavascriptLiteralSpanCollector {
strings: Vec<std::ops::Range<usize>>,
regular_expressions: Vec<std::ops::Range<usize>>,
template_elements: Vec<std::ops::Range<usize>>,
}
fn javascript_span_range(span: oxc_span::Span) -> std::ops::Range<usize> {
span.start as usize..span.end as usize
}
impl<'a> VisitJavascript<'a> for JavascriptLiteralSpanCollector {
fn visit_string_literal(&mut self, literal: &JavascriptStringLiteral<'a>) {
self.strings.push(javascript_span_range(literal.span));
}
fn visit_reg_exp_literal(&mut self, literal: &JavascriptRegExpLiteral<'a>) {
self.regular_expressions
.push(javascript_span_range(literal.span));
}
fn visit_template_element(&mut self, element: &JavascriptTemplateElement<'a>) {
self.template_elements
.push(javascript_span_range(element.span));
}
}
fn javascript_without_string_literals_or_comments(content: &str) -> String {
let allocator = JavascriptAllocator::default();
let parsed = JavascriptParser::new(
&allocator,
content,
JavascriptSourceType::default().with_unambiguous(true),
)
.parse();
if parsed.panicked || !parsed.diagnostics.is_empty() {
return " ".repeat(content.len());
}
let mut collector = JavascriptLiteralSpanCollector::default();
collector.visit_program(&parsed.program);
let mut output = content.as_bytes().to_vec();
for range in collector.template_elements {
output[range].fill(b' ');
}
for range in collector.strings {
if range.end.saturating_sub(range.start) > 2 {
output[range.start + 1..range.end - 1].fill(b' ');
}
}
for range in collector.regular_expressions {
let raw = &content[range.clone()];
if let Some(close) = raw[1..].rfind('/') {
let close = range.start + 1 + close;
output[range.start + 1..close].fill(b' ');
if close > range.start + 1 {
output[range.start + 1] = b'a';
}
}
}
for comment in &parsed.program.comments {
for byte in &mut output[javascript_span_range(comment.span())] {
if !matches!(*byte, b'\r' | b'\n') {
*byte = b' ';
}
}
}
String::from_utf8(output).expect("masking parsed JavaScript literals preserves UTF-8")
}
fn html_script_type_is_executable(script_tag: &str) -> bool {
if let Some(script_type) = html_attribute_value(script_tag, "type") {
let script_type = script_type.trim();
@@ -1,46 +1,12 @@
//! LLM 文本请求与其额度 / 错误面:`request_game_creator_llm_text`,以及泥点(mud points)不足的
//! 判定、消息构造与公开摘要。
//!
//! 多 pass 循环编排与进度发射面(`AgentProgressEmitter` / `emit_agent_progress`)已随 Agent
//! Runtime 退役删除,本模块只剩请求与错误面。
use super::*;
use std::path::Path;
pub(crate) struct AgentProgressEmitter<'a> {
app: &'a tauri::AppHandle,
project_path: String,
}
impl<'a> AgentProgressEmitter<'a> {
#[cfg(not(test))]
pub(crate) fn new(app: &'a tauri::AppHandle, project_path: &str) -> Self {
Self {
app,
project_path: project_path.to_string(),
}
}
fn emit(&self, stage: &str, message: &str) {
if let Err(error) = self.app.emit(
"game-creator-agent-progress",
GameCreatorAgentProgressEvent {
project_path: self.project_path.clone(),
stage: stage.to_string(),
message: message.to_string(),
},
) {
app_log!(
"agent.generation.progress.emit_failed stage={} detail={error}",
stage
);
}
}
}
pub(crate) fn emit_agent_progress(
progress: Option<&AgentProgressEmitter<'_>>,
stage: &str,
message: &str,
) {
if let Some(progress) = progress {
progress.emit(stage, message);
}
}
pub(crate) async fn request_game_creator_llm_text(
client: &LlmClient,
llm: &GameCreatorLlmConfig,
@@ -53,15 +19,6 @@ pub(crate) async fn request_game_creator_llm_text(
}
}
pub(in crate::agent) fn build_game_creator_agent_runtime_llm_client(
llm: &GameCreatorLlmConfig,
config_path: &str,
) -> Result<LlmClient, String> {
let mut single_attempt = llm.clone();
single_attempt.max_retries = 0;
build_game_creator_llm_client_without_redirects_from_llm_config(&single_attempt, config_path)
}
pub(crate) const GAME_CREATOR_MUD_POINTS_INSUFFICIENT_ERROR_KIND: &str =
"kind=mud-points-insufficient";
@@ -70,10 +27,6 @@ pub(crate) fn game_creator_mud_points_insufficient_message(message: &str) -> boo
message == "泥点余额不足" || message.starts_with("可消费泥点不足:")
}
pub(crate) fn game_creator_runtime_error_is_mud_points_insufficient(error: &str) -> bool {
error.contains("泥点余额不足") || error.contains("可消费泥点不足:")
}
pub(crate) fn game_creator_agent_llm_error_is_mud_points_insufficient(
error: &platform_llm::LlmError,
) -> bool {
@@ -1,499 +0,0 @@
use super::*;
pub(crate) fn write_agent_pass_agenda(
root: &Path,
pass: u8,
findings_markdown: &str,
) -> Result<AgentPassAgenda, String> {
let graph = build_game_creation_seed_task_graph("AI 游戏创作")
.map_err(|error| format!("构建 Agent 编排任务图失败:{error}"))?;
let pass_plan = plan_game_creation_agent_pass(&graph, pass, findings_markdown)
.map_err(|error| format!("规划 Agent 编排任务图失败:{error}"))?;
let repair_routes = pass_plan
.repair_routes
.iter()
.map(|route| GameCreationAgentRepairRouteTrace {
issue: route.issue.clone(),
task_ids: route.task_ids.clone(),
reason: route.reason.clone(),
})
.collect::<Vec<_>>();
let relative_path = format!(".agent/passes/pass-{pass}/agenda.md");
let markdown = render_agent_pass_agenda_markdown(
pass,
&pass_plan.mode,
&pass_plan.active_task_ids,
&pass_plan.carried_task_ids,
&pass_plan.dependency_waves,
&pass_plan.repair_focus,
&repair_routes,
);
write_agent_pass_file(root, &relative_path, &markdown)?;
let task_graph_relative_path = format!(".agent/passes/pass-{pass}/task-graph.json");
write_agent_pass_file(
root,
&task_graph_relative_path,
&render_agent_pass_task_graph_json(
pass,
&pass_plan.mode,
&pass_plan.summary,
&pass_plan.active_task_ids,
&pass_plan.carried_task_ids,
&pass_plan.dependency_waves,
&pass_plan.repair_focus,
&repair_routes,
)?,
)?;
Ok(AgentPassAgenda {
relative_path,
task_graph_relative_path,
active_task_ids: pass_plan.active_task_ids,
carried_task_ids: pass_plan.carried_task_ids,
dependency_waves: pass_plan.dependency_waves,
repair_focus: pass_plan.repair_focus,
repair_routes,
summary: pass_plan.summary,
})
}
pub(crate) fn render_agent_pass_agenda_markdown(
pass: u8,
mode: &str,
active_task_ids: &[String],
carried_task_ids: &[String],
dependency_waves: &[Vec<String>],
issues: &[String],
repair_routes: &[GameCreationAgentRepairRouteTrace],
) -> String {
let mut output = format!(
"# Orchestrator Agenda\n\n- pass: {pass}\n- mode: {}\n- activeTasks: {}\n- carriedTasks: {}\n\n",
mode,
active_task_ids.join(", "),
if carried_task_ids.is_empty() {
"none".to_string()
} else {
carried_task_ids.join(", ")
}
);
output.push_str("## Repair Focus\n\n");
if issues.is_empty() {
output.push_str("- 首轮生成,所有组内角色参与。\n");
} else {
for issue in issues {
output.push_str("- ");
output.push_str(issue);
output.push('\n');
}
}
output.push_str("\n## Dependency Waves\n\n");
for (index, wave) in dependency_waves.iter().enumerate() {
output.push_str(&format!(
"- wave {}: {}\n",
index + 1,
if wave.is_empty() {
"none".to_string()
} else {
wave.join(", ")
}
));
}
output.push_str("\n## Repair Routes\n\n");
if repair_routes.is_empty() {
output.push_str("- none\n");
} else {
for route in repair_routes {
output.push_str(&format!(
"- issue: {}\n taskIds: {}\n reason: {}\n",
route.issue,
route.task_ids.join(", "),
route.reason
));
}
}
output.push_str(prompt_text!("goalContext.agenda_rules"));
output
}
pub(crate) fn render_agent_pass_task_graph_json(
pass: u8,
mode: &str,
summary: &str,
active_task_ids: &[String],
carried_task_ids: &[String],
dependency_waves: &[Vec<String>],
issues: &[String],
repair_routes: &[GameCreationAgentRepairRouteTrace],
) -> Result<String, String> {
serde_json::to_string_pretty(&serde_json::json!({
"schemaVersion": "game-creator-agent-pass-task-graph.v1",
"pass": pass,
"mode": mode,
"summary": summary,
"activeTaskIds": active_task_ids,
"carriedTaskIds": carried_task_ids,
"repairFocus": issues,
"repairRoutes": repair_routes,
"dependencyWaves": dependency_waves,
}))
.map(|payload| format!("{payload}\n"))
.map_err(|error| format!("生成 Agent pass task graph 失败:{error}"))
}
pub(crate) fn read_previous_agent_role_brief(
root: &Path,
pass: u8,
group_definition: AgentGroupDefinition,
role_definition: AgentRoleDefinition,
) -> Result<Option<(String, String)>, String> {
if pass <= 1 {
return Ok(None);
}
let relative_path = format!(
".agent/passes/pass-{}/groups/{}/{}",
pass - 1,
group_definition.id,
role_definition.brief_path_name
);
match fs::read_to_string(root.join(&relative_path)) {
Ok(content) => Ok(Some((relative_path, content))),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(format!(
"读取上一轮角色 brief 失败:{relative_path}: {error}"
)),
}
}
pub(crate) fn render_carryover_role_brief(
group_definition: AgentGroupDefinition,
role_definition: AgentRoleDefinition,
source_path: &str,
source_markdown: &str,
) -> String {
format!(
"本角色判断:沿用上一轮 {} / {} 输出。\n交付物:{}\n下游约束:本轮未命中该任务,Generator 只在必要时读取此约束。\n验收风险:如果 Evaluator 后续命中该组,下一轮必须重新激活。\n\n## 上一轮 brief\n\n{}",
group_definition.label,
role_definition.role,
source_path,
source_markdown.trim()
)
}
pub(crate) fn write_agent_pass_artifacts(
root: &Path,
pass: u8,
draft: &LlmGameDraft,
) -> Result<AgentPassArtifactPaths, String> {
let relative_dir = format!(".agent/passes/pass-{pass}");
let pass_dir = root.join(&relative_dir);
fs::create_dir_all(&pass_dir)
.map_err(|error| format!("创建 Agent pass 目录失败:{}: {error}", pass_dir.display()))?;
let paths = AgentPassArtifactPaths {
draft_json: format!("{relative_dir}/draft.json"),
design_markdown: format!("{relative_dir}/design.md"),
balance_json: format!("{relative_dir}/balance.json"),
art_manifest_json: format!("{relative_dir}/manifest.art.json"),
audio_manifest_json: format!("{relative_dir}/manifest.audio.json"),
publish_readme: format!("{relative_dir}/README.md"),
game_html: format!("{relative_dir}/game.html"),
handoff_markdown: format!("{relative_dir}/handoff.md"),
};
write_agent_pass_file(
root,
&paths.draft_json,
&format!(
"{}\n",
serde_json::to_string_pretty(draft)
.map_err(|error| format!("序列化 Agent pass draft 失败:{error}"))?
),
)?;
write_agent_pass_file(
root,
&paths.design_markdown,
&format!("# 策划组 / Gameplay\n\n{}\n", draft.design_markdown.trim()),
)?;
write_agent_pass_file(
root,
&paths.balance_json,
&format!(
"{}\n",
serde_json::to_string_pretty(&draft.balance)
.map_err(|error| format!("序列化 Agent pass 数值失败:{error}"))?
),
)?;
write_agent_pass_file(
root,
&paths.art_manifest_json,
&format!(
"{}\n",
serde_json::to_string_pretty(&draft.art_manifest)
.map_err(|error| format!("序列化 Agent pass 美术清单失败:{error}"))?
),
)?;
write_agent_pass_file(
root,
&paths.audio_manifest_json,
&format!(
"{}\n",
serde_json::to_string_pretty(&draft.audio_manifest)
.map_err(|error| format!("序列化 Agent pass 音乐清单失败:{error}"))?
),
)?;
write_agent_pass_file(
root,
&paths.publish_readme,
&format!("# 运营组 / Publish\n\n{}\n", draft.publish_readme.trim()),
)?;
write_agent_pass_file(root, &paths.game_html, &draft.game_html)?;
write_agent_pass_file(
root,
&paths.handoff_markdown,
&render_agent_pass_handoff(pass, draft),
)?;
Ok(paths)
}
pub(crate) fn write_agent_pass_file(
root: &Path,
relative_path: &str,
content: &str,
) -> Result<(), String> {
let path = root.join(relative_path);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|error| {
format!(
"创建 Agent pass 文件目录失败:{}: {error}",
parent.display()
)
})?;
}
fs::write(&path, content)
.map_err(|error| format!("写入 Agent pass 文件失败:{}: {error}", path.display()))
}
pub(crate) fn write_agent_group_brief(
root: &Path,
pass: u8,
definition: AgentGroupDefinition,
markdown: &str,
) -> Result<String, String> {
let relative_path = format!(
".agent/passes/pass-{pass}/groups/{}",
definition.brief_path_name
);
write_agent_pass_file(
root,
&relative_path,
&format!(
"# {} / {}\n\n{}\n",
definition.label,
definition.role,
markdown.trim()
),
)?;
Ok(relative_path)
}
pub(crate) fn write_agent_role_brief(
root: &Path,
pass: u8,
group_definition: AgentGroupDefinition,
role_definition: AgentRoleDefinition,
markdown: &str,
) -> Result<String, String> {
let relative_path = format!(
".agent/passes/pass-{pass}/groups/{}/{}",
group_definition.id, role_definition.brief_path_name
);
write_agent_pass_file(
root,
&relative_path,
&format!(
"# {} / {}\n\n- task: {}\n\n{}\n",
group_definition.label,
role_definition.role,
role_definition.task_id,
markdown.trim()
),
)?;
Ok(relative_path)
}
pub(crate) fn agent_role_memory_relative_path(
group_definition: AgentGroupDefinition,
role_definition: AgentRoleDefinition,
) -> String {
format!(
"memory/agents/{}/{}",
group_definition.id, role_definition.brief_path_name
)
}
pub(crate) fn agent_role_memory_relative_path_for_task(task_id: &str) -> Result<String, String> {
if task_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID {
return Ok(GAME_CREATOR_PROJECT_SUPERVISOR_MEMORY_PATH.to_string());
}
for group in GAME_CREATOR_AGENT_GROUP_DEFINITIONS {
for role in group.roles {
if role.task_id == task_id {
return Ok(agent_role_memory_relative_path(group, *role));
}
}
}
Err(format!("未知 Agent 任务:{task_id}"))
}
pub(crate) fn append_agent_success_memories(
root: &Path,
pass: u8,
draft: &LlmGameDraft,
briefs: &[AgentGroupBrief],
) -> Result<(), String> {
let timestamp = unix_timestamp();
let title = draft.title.trim();
let mut blackboard_entry = format!(
"\n## pass {pass} - {title}\n\n- 时间:{timestamp}\n- 稳定原型:game/index.html\n- 设计:game/game_design.md\n- 数值:game/balance.json\n- 美术:assets/manifest.art.json\n- 音乐音效:assets/manifest.audio.json\n\n## 角色共享摘要\n\n"
);
for brief in briefs {
for role_brief in &brief.role_briefs {
blackboard_entry.push_str(&format!(
"- {} / {}:{};status={};brief={}\n",
role_brief.group_definition.label,
role_brief.role_definition.role,
role_brief.summary,
role_brief.status,
role_brief.relative_path
));
let private_entry = format!(
"\n## pass {pass} - {title}\n\n- 时间:{timestamp}\n- task:{}\n- status:{}\n- brief:{}\n- 摘要:{}\n",
role_brief.role_definition.task_id,
role_brief.status,
role_brief.relative_path,
role_brief.summary
);
append_markdown_entry(
&root.join(&role_brief.memory_relative_path),
&format!(
"# Agent 私有记忆 - {} / {}\n\n",
role_brief.group_definition.label, role_brief.role_definition.role
),
&private_entry,
"写入 Agent 私有记忆失败",
)?;
}
}
append_markdown_entry(
&root.join(PROJECT_BLACKBOARD_MEMORY_PATH),
"# 项目黑板\n\n",
&blackboard_entry,
"写入项目黑板失败",
)
}
pub(crate) fn append_group_brief_steps(
pass: u8,
agenda_relative_path: &str,
briefs: &[AgentGroupBrief],
steps: &mut Vec<GameCreationAgentRunStep>,
) {
for brief in briefs {
for role_brief in &brief.role_briefs {
let input_paths = vec![
"memory/session.md".to_string(),
"memory/project.md".to_string(),
PROJECT_BLACKBOARD_MEMORY_PATH.to_string(),
".agent/conversations/project.jsonl".to_string(),
".agent/conversations/agents/".to_string(),
role_brief.memory_relative_path.clone(),
".agent/manifest.json".to_string(),
".agent/spec.md".to_string(),
".agent/findings.md".to_string(),
agenda_relative_path.to_string(),
];
let mut output_paths = vec![role_brief.relative_path.clone()];
if role_brief.status == "completed" || role_brief.status == "carried-over" {
output_paths.push(role_brief.memory_relative_path.clone());
output_paths.push(PROJECT_BLACKBOARD_MEMORY_PATH.to_string());
}
let step = with_task_context(
agent_trace_step_owned(
pass,
&format!(
"{} / {}",
role_brief.group_definition.label, role_brief.role_definition.role
),
&role_brief.status,
input_paths.clone(),
output_paths,
&role_brief.summary,
&role_brief.tool_id,
),
role_brief.group_definition.id,
role_brief.role_definition.role,
Some(role_brief.role_definition.task_id),
"role-brief",
);
steps.push(step);
}
let role_paths = brief
.role_briefs
.iter()
.map(|role_brief| role_brief.relative_path.clone())
.collect::<Vec<_>>();
steps.push(with_task_context(
agent_trace_step_owned(
pass,
&format!("{} / GroupCoordinator", brief.definition.label),
"completed",
role_paths,
vec![brief.relative_path.clone()],
"汇总组内角色 brief,交给 Generator",
&format!("agent.group.aggregate.{}", brief.definition.id),
),
brief.definition.id,
"GroupCoordinator",
None,
"group-aggregate",
));
}
}
pub(crate) fn handoff_group_label(group: &str) -> &'static str {
match group.trim() {
"design" => "策划组",
"balance" => "数值组",
"art" => "美术组",
"audio" => "音乐组",
"code" => "程序组",
"publishing" => "运营组",
_ => "专业组",
}
}
pub(crate) fn render_agent_pass_handoff(pass: u8, draft: &LlmGameDraft) -> String {
let mut output = format!("# Agent Handoff\n\n- pass: {pass}\n\n");
for handoff in &draft.handoffs {
output.push_str(&format!(
"## {} / {}\n\n{}\n\n- outputs: {}\n- next: {}\n\n",
handoff_group_label(&handoff.group),
handoff.role.trim(),
handoff.summary.trim(),
handoff
.outputs
.iter()
.map(|output| output.trim())
.filter(|output| !output.is_empty())
.collect::<Vec<_>>()
.join(", "),
handoff.next.trim()
));
}
output.push_str("## 总结\n\n");
output.push_str(draft.handoff_summary.trim());
output.push('\n');
output
}
@@ -1,415 +1,3 @@
use super::*;
/// 旧 Agent Runtime 写入的公开状态消息 id 前缀;历史工程里可能残留这类消息,
/// 组装对话上下文时仍然跳过它们。
const AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX: &str = "runtime-public-status-";
pub(crate) fn game_creator_system_prompt() -> &'static str {
prompt_text!("generation.generator_system")
}
pub(crate) fn game_creator_planner_system_prompt() -> &'static str {
prompt_text!("generation.planner_system")
}
pub(crate) fn game_creator_planner_user_prompt(
prompt: &str,
short_memory: &str,
long_memory: &str,
project_blackboard: &str,
) -> String {
format!(
prompt_text!("generation.planner_user"),
prompt.trim(),
truncate_prompt_context(short_memory),
truncate_prompt_context(long_memory),
truncate_prompt_context(project_blackboard)
)
}
pub(crate) fn game_creator_generator_user_prompt(
prompt: &str,
short_memory: &str,
long_memory: &str,
project_blackboard: &str,
spec_markdown: &str,
findings_markdown: &str,
group_briefs_markdown: &str,
agenda_markdown: &str,
) -> String {
format!(
prompt_text!("generation.generator_user"),
prompt.trim(),
truncate_prompt_context(short_memory),
truncate_prompt_context(long_memory),
truncate_prompt_context(project_blackboard),
truncate_prompt_context(spec_markdown),
truncate_prompt_context(agenda_markdown),
truncate_prompt_context(group_briefs_markdown),
truncate_prompt_context(findings_markdown)
)
}
pub(crate) fn append_prompt_context(base: &str, extra: &str) -> String {
match (base.trim().is_empty(), extra.trim().is_empty()) {
(_, true) => base.to_string(),
(true, false) => extra.trim().to_string(),
(false, false) => format!("{}\n\n{}", base.trim_end(), extra.trim()),
}
}
pub(crate) fn render_local_asset_prompt_context(root: &Path) -> Result<String, String> {
let manifest = read_manifest_for_project(root)?;
let registered_paths = manifest
.assets
.iter()
.map(|asset| asset.local_path.as_str())
.collect::<std::collections::BTreeSet<_>>();
let mut output = String::new();
if !manifest.assets.is_empty() {
output.push_str(prompt_text!("generation.local_assets_heading"));
for asset in manifest.assets.iter().take(24) {
output.push_str("- ");
output.push_str(&asset.id);
output.push_str(": ");
output.push_str(asset.kind.as_str());
output.push_str(" / ");
output.push_str(&asset.media_type);
output.push_str(" / ");
output.push_str(&asset.local_path);
output.push_str(" / source=");
output.push_str(asset_source_kind_label(&asset.source.kind));
if let Some(canvas_project_id) = asset.source.canvas_project_id.as_deref() {
output.push_str(" / canvasProjectId=");
output.push_str(canvas_project_id);
}
if let Some(resource_id) = asset.source.resource_id.as_deref() {
output.push_str(" / resourceId=");
output.push_str(resource_id);
}
if let Some(asset_object_id) = asset.source.asset_object_id.as_deref() {
output.push_str(" / assetObjectId=");
output.push_str(asset_object_id);
}
if let Some(task_id) = asset.source.task_id.as_deref() {
output.push_str(" / taskId=");
output.push_str(task_id);
}
if let Some(model) = asset.source.model.as_deref() {
output.push_str(" / model=");
output.push_str(model);
}
output.push('\n');
}
if manifest.assets.len() > 24 {
output.push_str(&format!(
prompt_text!("generation.additional_assets"),
manifest.assets.len() - 24
));
}
}
// A project may contain useful media copied in by a user or another tool
// before it has been registered in `.agent/manifest.json`. Surface a
// small, metadata-only candidate list so the Agent can discover it, while
// keeping the manifest as the sole source of formal asset identity and
// provenance. Never expose control directories or sensitive config paths.
let mut unregistered = list_local_project_files_at(root)?
.files
.into_iter()
.filter(|file| file.kind == "file")
.filter(|file| !registered_paths.contains(file.path.as_str()))
.filter(|file| !prompt_context_hidden_project_path(&file.path))
.filter(|file| reject_sensitive_project_file_read(&file.path).is_ok())
.filter_map(|file| {
let media_type = prompt_context_media_type(&file.path)?;
Some((file.path, file.size, media_type))
})
.collect::<Vec<_>>();
unregistered.sort_by(|left, right| left.0.cmp(&right.0));
if !unregistered.is_empty() {
if !output.is_empty() {
output.push('\n');
}
output.push_str(prompt_text!("generation.unregistered_media"));
for (path, size, media_type) in unregistered.iter().take(48) {
let asset_importable = !media_type.is_empty();
output.push_str(&format!(
"- {path} / {media_type} / {size} bytes / registered=false / assetImportable={asset_importable}\n"
));
}
if unregistered.len() > 48 {
output.push_str(&format!(
prompt_text!("generation.additional_unregistered_media"),
unregistered.len() - 48
));
}
}
Ok(output)
}
fn prompt_context_hidden_project_path(path: &str) -> bool {
// Keep prompt discovery aligned with the repository snapshot/read safety
// boundary. This covers control trees as well as credentials, secret
// directories and sensitive file suffixes; a media extension alone must
// never make one of those paths visible to the model.
should_skip_project_snapshot_path(path)
|| path
.split('/')
.any(|component| component.eq_ignore_ascii_case(".codex"))
}
fn prompt_context_media_type(path: &str) -> Option<&'static str> {
let extension = Path::new(path)
.extension()
.and_then(|value| value.to_str())
.unwrap_or_default()
.to_ascii_lowercase();
match extension.as_str() {
"png" => Some("image/png"),
"jpg" | "jpeg" => Some("image/jpeg"),
"webp" => Some("image/webp"),
"gif" => Some("image/gif"),
"avif" => Some("image/avif"),
"bmp" => Some("image/bmp"),
"svg" => Some("image/svg+xml"),
"ttf" => Some("font/ttf"),
"otf" => Some("font/otf"),
"woff" => Some("font/woff"),
"woff2" => Some("font/woff2"),
"mp3" => Some("audio/mpeg"),
"wav" => Some("audio/wav"),
"ogg" => Some("audio/ogg"),
"flac" => Some("audio/flac"),
"m4a" => Some("audio/mp4"),
"aac" => Some("audio/aac"),
"opus" => Some("audio/opus"),
"mp4" => Some("video/mp4"),
"webm" => Some("video/webm"),
"mov" => Some("video/quicktime"),
"json" => Some("application/json"),
"yaml" | "yml" => Some("application/yaml"),
"xml" => Some("application/xml"),
"html" | "htm" => Some("text/html"),
"md" | "markdown" | "mdx" | "txt" | "toml" | "csv" | "ini" | "conf" => Some("text/plain"),
"css" | "scss" | "less" => Some("text/css"),
"js" | "mjs" | "cjs" | "ts" | "tsx" | "gd" | "rs" | "py" | "go" | "java" | "kt" | "kts"
| "c" | "cc" | "cpp" | "h" | "hpp" | "cs" | "swift" | "php" | "rb" | "lua" | "sh"
| "bash" | "zsh" | "sql" | "graphql" | "gql" | "vue" | "svelte" => Some("text/plain"),
/*
* Cocos Creator 资源。这里只是给未登记文件清单标注媒体类型,取值必须与
* `agent/direct_tool_bridge.rs::bridge_project_file_class` 和
* `commands.rs::agent_local_project_file_type` 一致:少写一个扩展名,
* Agent 的提示词就会把「其实可以登记」的资源说成只能发现。
*/
"glb" => Some("model/gltf-binary"),
"gltf" => Some("model/gltf+json"),
"fbx" | "dbbin" | "bin" | "skel" | "texture" | "cubemap" | "rt" | "psd" | "znt" => {
Some("application/octet-stream")
}
"scene" | "fire" | "prefab" | "anim" | "animation" | "animgraph" | "animgraphvari"
| "animask" | "mtl" | "material" | "pmtl" | "terrain" | "labelatlas" | "pac" | "mesh"
| "skeleton" => Some("application/json"),
"tmx" | "plist" => Some("application/xml"),
"effect" | "chunk" | "fnt" | "atlas" => Some("text/plain"),
"tga" => Some("image/x-tga"),
"tif" | "tiff" => Some("image/tiff"),
"hdr" => Some("image/vnd.radiance"),
"exr" => Some("image/x-exr"),
"pcm" => Some("audio/pcm"),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::prompt_context_media_type;
#[test]
fn prompt_context_media_type_covers_all_importable_resource_categories() {
for (path, expected) in [
("assets/icon.avif", "image/avif"),
("assets/font.woff2", "font/woff2"),
("assets/theme.opus", "audio/opus"),
("assets/intro.mov", "video/quicktime"),
("assets/data.json", "application/json"),
("game/index.html", "text/html"),
("game/main.rs", "text/plain"),
// 引擎资源同样要在未登记清单里被标出可登记:漏一个扩展名,
// Agent 就会把「其实可以登记」的 Cocos 资源说成只能发现。
("assets/model/hero.glb", "model/gltf-binary"),
("assets/model/hero.fbx", "application/octet-stream"),
("assets/anim/walk.anim", "application/json"),
("assets/scene/main.scene", "application/json"),
("assets/shader/glow.effect", "text/plain"),
("assets/atlas/hero.plist", "application/xml"),
("assets/tex/grass.tga", "image/x-tga"),
("assets/audio/voice.pcm", "audio/pcm"),
] {
assert_eq!(prompt_context_media_type(path), Some(expected), "{path}");
}
// `.bin` 现在是引擎 BufferAsset 的载体,不再是「未识别」;真正未知的扩展名才返回 None。
assert_eq!(prompt_context_media_type("assets/unknown.dat"), None);
}
}
pub(crate) fn render_local_conversation_prompt_context(
root: &Path,
agent_id: Option<&str>,
) -> Result<String, String> {
render_local_conversation_prompt_context_for_session(root, agent_id, None)
}
pub(crate) fn render_local_conversation_prompt_context_for_session(
root: &Path,
agent_id: Option<&str>,
session_id: Option<&str>,
) -> Result<String, String> {
#[derive(Debug)]
struct ConversationPromptEntry {
updated_at: u64,
agent_label: String,
role: String,
content: String,
}
fn push_conversation_entries(
entries: &mut Vec<ConversationPromptEntry>,
conversation: LocalConversationResult,
agent_label: &str,
) {
for message in conversation.messages {
if message.message_id.as_deref().is_some_and(|message_id| {
message_id
.trim()
.starts_with(AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX)
}) {
continue;
}
let content = sanitize_prompt_context(&message.content)
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
if content.is_empty() {
continue;
}
entries.push(ConversationPromptEntry {
updated_at: message.updated_at,
agent_label: agent_label.to_string(),
role: message.role,
content,
});
}
}
validate_project_root(root)?;
let mut entries = Vec::new();
push_conversation_entries(
&mut entries,
read_local_conversation_for_session_at(root, None, None)?,
"project",
);
if let Some(agent_id) = agent_id.map(str::trim).filter(|value| !value.is_empty()) {
if agent_id == "*" {
if session_id
.map(str::trim)
.is_some_and(|value| !value.is_empty())
{
return Err("读取全部 Agent 对话时不能指定单个 sessionId".to_string());
}
let agents_dir = root.join(".agent/conversations/agents");
match fs::read_dir(&agents_dir) {
Ok(read_dir) => {
let mut agent_ids = std::collections::BTreeSet::new();
for entry in read_dir {
let entry = entry.map_err(|error| {
format!("读取 Agent 对话目录失败:{}: {error}", agents_dir.display())
})?;
let path = entry.path();
if path.extension().and_then(|value| value.to_str()) == Some("jsonl") {
if let Some(agent_id) =
path.file_stem().and_then(|value| value.to_str())
{
agent_ids.insert(agent_id.to_string());
}
} else if path.is_dir() {
if let Some(agent_id) =
path.file_name().and_then(|value| value.to_str())
{
agent_ids.insert(agent_id.to_string());
}
}
}
for agent_id in agent_ids {
push_conversation_entries(
&mut entries,
read_local_conversation_for_session_at(root, Some(&agent_id), None)?,
&agent_id,
);
}
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => {
return Err(format!(
"读取 Agent 对话目录失败:{}: {error}",
agents_dir.display()
));
}
}
} else {
push_conversation_entries(
&mut entries,
read_local_conversation_for_session_at(root, Some(agent_id), session_id)?,
agent_id,
);
}
} else if session_id
.map(str::trim)
.is_some_and(|value| !value.is_empty())
{
return Err("项目主对话不接受 sessionId".to_string());
}
if entries.is_empty() {
return Ok(String::new());
}
entries.sort_by(|left, right| {
right
.updated_at
.cmp(&left.updated_at)
.then_with(|| left.agent_label.cmp(&right.agent_label))
.then_with(|| left.role.cmp(&right.role))
});
entries.truncate(GAME_CREATOR_CONVERSATION_CONTEXT_MAX_MESSAGES);
entries.sort_by(|left, right| {
left.updated_at
.cmp(&right.updated_at)
.then_with(|| left.agent_label.cmp(&right.agent_label))
.then_with(|| left.role.cmp(&right.role))
});
let mut output = prompt_text!("generation.conversation_heading").to_string();
for entry in entries {
output.push_str(&format!(
"- [{} / {}] {}\n",
entry.agent_label, entry.role, entry.content
));
}
Ok(output)
}
pub(crate) fn asset_source_kind_label(kind: &GameCreationAppAssetSourceKind) -> &'static str {
match kind {
GameCreationAppAssetSourceKind::Uploaded => "uploaded",
GameCreationAppAssetSourceKind::Generated => "generated",
GameCreationAppAssetSourceKind::Canvas => "canvas",
}
}
pub(crate) fn truncate_prompt_context(value: &str) -> String {
const MAX_CHARS: usize = 2400;
let sanitized = sanitize_prompt_context(value);
@@ -421,21 +9,6 @@ pub(crate) fn truncate_prompt_context(value: &str) -> String {
output
}
pub(crate) fn truncate_prompt_context_preserving_tail(value: &str) -> String {
const MAX_CHARS: usize = 2400;
let sanitized = sanitize_prompt_context(value);
let trimmed = sanitized.trim();
if trimmed.chars().count() <= MAX_CHARS {
return trimmed.to_string();
}
let mut characters = trimmed.chars().rev().take(MAX_CHARS).collect::<Vec<_>>();
characters.reverse();
format!(
"...<truncated>\n{}",
characters.into_iter().collect::<String>()
)
}
fn contains_sensitive_assignment(line: &str) -> bool {
let lower = line.to_ascii_lowercase();
[
@@ -1113,14 +686,6 @@ pub(crate) fn redact_secret_tokens(line: &str) -> String {
output
}
pub(crate) fn read_optional_text(path: &Path) -> Result<String, String> {
match fs::read_to_string(path) {
Ok(content) => Ok(content),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(String::new()),
Err(error) => Err(format!("读取上下文失败:{}: {error}", path.display())),
}
}
fn agent_runtime_secret_token_end_with_minimum(
content: &str,
index: usize,
@@ -110,81 +110,6 @@ pub(crate) fn game_creation_agent_group_from_id(
}
}
pub(in crate::agent) fn game_creation_agent_group_id(
group: &GameCreationAppAgentGroup,
) -> &'static str {
match group {
GameCreationAppAgentGroup::Design => "design",
GameCreationAppAgentGroup::Balance => "balance",
GameCreationAppAgentGroup::Art => "art",
GameCreationAppAgentGroup::Audio => "audio",
GameCreationAppAgentGroup::Code => "code",
GameCreationAppAgentGroup::Publishing => "publishing",
}
}
pub(crate) fn append_static_smoke_step(
root: &Path,
prompt: &str,
loop_result: &mut GameCreatorAgentLoopResult,
) -> Result<(), String> {
match run_limited_local_command_at(root, "game.static_smoke") {
Ok(smoke) => {
loop_result.steps.push(with_task_context(
agent_trace_step(
loop_result.passes,
"Playtest",
"completed",
&["game/index.html"],
&[".agent/logs/command.log", ".agent/manifest.json"],
&smoke.output,
"game.static_smoke",
),
"code",
"Preview",
Some("preview-readiness"),
"playtest",
));
write_agent_run_trace(
root,
&loop_result.run_id,
prompt,
"passed",
loop_result.passes,
&loop_result.steps,
None,
)
}
Err(error) => {
loop_result.steps.push(with_task_context(
agent_trace_step(
loop_result.passes,
"Playtest",
"failed",
&["game/index.html"],
&[".agent/logs/command.log"],
&error,
"game.static_smoke",
),
"code",
"Preview",
Some("preview-readiness"),
"playtest",
));
write_agent_run_trace(
root,
&loop_result.run_id,
prompt,
"failed",
loop_result.passes,
&loop_result.steps,
Some(&error),
)?;
Err(format!("生成后自检失败:{error}"))
}
}
}
pub(crate) fn append_static_smoke_manual_trace_step(
root: &Path,
result: &LimitedLocalCommandResult,
@@ -212,45 +137,6 @@ pub(crate) fn append_static_smoke_manual_trace_step(
)
}
pub(crate) fn append_local_artifact_write_step(
root: &Path,
prompt: &str,
loop_result: &mut GameCreatorAgentLoopResult,
) -> Result<(), String> {
loop_result.steps.push(agent_trace_step_owned(
loop_result.passes,
"ArtifactWriter",
"completed",
vec![
format!(".agent/passes/pass-{}/draft.json", loop_result.passes),
format!(".agent/passes/pass-{}/handoff.md", loop_result.passes),
],
vec![
"memory/session.md".to_string(),
"memory/project.md".to_string(),
PROJECT_BLACKBOARD_MEMORY_PATH.to_string(),
"memory/agents/".to_string(),
"game/game_design.md".to_string(),
"game/balance.json".to_string(),
"assets/manifest.art.json".to_string(),
"assets/manifest.audio.json".to_string(),
"game/index.html".to_string(),
".agent/manifest.json".to_string(),
],
"把通过 Evaluator 的草案写入本地项目产物",
"file.write.local_artifacts",
));
write_agent_run_trace(
root,
&loop_result.run_id,
prompt,
"artifacts-written",
loop_result.passes,
&loop_result.steps,
None,
)
}
pub(crate) fn append_preview_start_trace_step(
root: &Path,
preview: &LocalPreviewResult,
@@ -669,46 +555,6 @@ pub(crate) fn ready_task_ids_for_tasks(tasks: &[GameCreationAppTaskState]) -> Ve
.collect()
}
pub(crate) fn write_agent_run_trace(
root: &Path,
run_id: &str,
prompt: &str,
status: &str,
passes: u8,
steps: &[GameCreationAgentRunStep],
error: Option<&str>,
) -> Result<(), String> {
let tool_call_count = count_agent_tool_calls(steps)?;
let trace = GameCreationAgentRunTrace {
schema_version: GAME_CREATION_AGENT_RUN_SCHEMA_VERSION.to_string(),
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,
max_tool_calls: GAME_CREATOR_AGENT_TOOL_CALL_MAX,
stop_reason: agent_run_stop_reason(status, error).to_string(),
goal: prompt.trim().to_string(),
coordination: "filesystem".to_string(),
steps: steps.to_vec(),
artifacts: collect_agent_run_artifacts(root)?,
task_graph: build_agent_run_task_graph_trace(root, prompt, passes, steps)?,
pass_plans: collect_agent_pass_plan_traces(root, passes)?,
next_step: match status {
"passed" => "preview-playtest",
"artifacts-written" => "game.static_smoke",
"failed" => "inspect-error",
_ => "generator-revision",
}
.to_string(),
error: error.map(str::to_string),
updated_at: unix_timestamp(),
};
write_agent_run_trace_payload(root, &trace)
}
pub(crate) fn agent_run_stop_reason(status: &str, error: Option<&str>) -> &'static str {
match status {
"running" => "loop-running",
@@ -725,113 +571,6 @@ pub(crate) fn agent_run_stop_reason(status: &str, error: Option<&str>) -> &'stat
}
}
pub(crate) 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",
}
}
pub(crate) 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,
}),
)
}
pub(crate) 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,
}),
)
}
pub(crate) 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 line =
serde_json::to_string(value).map_err(|error| format!("序列化 Agent 事件失败:{error}"))?;
append_jsonl_line(&path, &line, "Agent 事件")
}
pub(crate) 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",
"blackboard": PROJECT_BLACKBOARD_MEMORY_PATH,
"agents": "memory/agents/"
},
"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()
)
})
}
pub(crate) 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())
@@ -0,0 +1,21 @@
//! Provider 请求身份快照:退役的 Agent Runtime 计划 / 观察 DTO 已删除,本模块只保留这一个结构。
/// 一次 Provider 请求的身份快照:退役的 Agent Runtime 计划/观察 DTO 已删除,
/// 只保留仍被 Codex app-server 会话与 provider 请求路径使用的这个结构。
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct AgentRuntimeProviderRequestSnapshot {
pub(in crate::agent) project_id: String,
pub(in crate::agent) agent_id: String,
pub(in crate::agent) task_id: String,
pub(in crate::agent) session_id: String,
pub(in crate::agent) run_id: String,
pub(in crate::agent) source: String,
pub(in crate::agent) goal_id: Option<String>,
pub(in crate::agent) goal_revision: u64,
pub(in crate::agent) goal_snapshot_fingerprint: String,
pub(in crate::agent) applied_steer_cursor: u64,
pub(in crate::agent) request_kind: String,
pub(in crate::agent) request_slot: String,
pub(in crate::agent) web_search_enabled: bool,
pub(in crate::agent) allow_idle_context_compaction: bool,
}
@@ -25,17 +25,6 @@ pub(crate) fn truncate_agent_runtime_text(value: &str, max_chars: usize) -> Stri
output
}
pub(crate) fn truncate_agent_runtime_text_preserving_tail(value: &str, max_chars: usize) -> String {
let value = value.trim();
let count = value.chars().count();
if count <= max_chars {
return value.to_string();
}
let mut characters = value.chars().rev().take(max_chars).collect::<Vec<_>>();
characters.reverse();
format!("…{}", characters.into_iter().collect::<String>())
}
pub(crate) fn sanitize_agent_runtime_text(value: &str, max_chars: usize) -> String {
truncate_agent_runtime_text(sanitize_prompt_context(value).trim(), max_chars)
}
@@ -133,32 +122,6 @@ pub(crate) fn redact_agent_runtime_project_paths_raw(root: &Path, value: &str) -
redacted
}
pub(crate) fn redact_agent_runtime_project_paths_preserving_tail(
root: &Path,
value: &str,
max_chars: usize,
) -> String {
let mut redacted = value.to_string();
let root_display = root.to_string_lossy();
if !root_display.is_empty() {
redacted = redacted.replace(root_display.as_ref(), "$PROJECT_ROOT");
#[cfg(windows)]
if let Some(non_verbatim_root) = root_display.strip_prefix(r"\\?\") {
redacted = redacted.replace(non_verbatim_root, "$PROJECT_ROOT");
}
}
if let Ok(canonical_root) = root.canonicalize() {
let canonical_display = canonical_root.to_string_lossy();
if !canonical_display.is_empty() && canonical_display != root_display {
redacted = redacted.replace(canonical_display.as_ref(), "$PROJECT_ROOT");
}
}
truncate_agent_runtime_text_preserving_tail(
sanitize_prompt_context(&redacted).trim(),
max_chars,
)
}
/// 把一个身份值压成可安全拼进项目内路径的分量:只保留 ASCII 字母数字和 `-`、`_`、`.`,
/// 其余折叠成 `-`,超长截断;全空时退回 `fallback`。
pub(crate) fn agent_runtime_confirmation_path_component(value: &str, fallback: &str) -> String {
@@ -194,3 +157,145 @@ pub(crate) fn game_creator_agent_runtime_context_project_id(root: &Path) -> Resu
}
Ok(redact_agent_runtime_project_paths(root, &project_id, 240))
}
pub(crate) fn redact_absolute_path_tokens(value: &str) -> String {
let bytes = value.as_bytes();
let mut output = String::with_capacity(value.len());
let mut index = 0;
while index < bytes.len() {
if starts_absolute_path(bytes, index) || starts_file_uri(bytes, index) {
output.push_str("<absolute-path>");
index = consume_path_token(bytes, index);
continue;
}
let character = value[index..].chars().next().unwrap_or_default();
if character.is_control() && !matches!(character, '\n' | '\r' | '\t') {
output.push(' ');
} else {
output.push(character);
}
index += character.len_utf8();
}
output
}
fn starts_absolute_path(bytes: &[u8], index: usize) -> bool {
let boundary = index == 0 || is_path_boundary(bytes[index - 1]);
if !boundary {
return false;
}
if bytes[index] == b'/' {
return bytes.get(index + 1) != Some(&b'/') || starts_forward_slash_unc_path(bytes, index);
}
if bytes[index] == b'\\' && bytes.get(index + 1) == Some(&b'\\') {
return true;
}
bytes[index].is_ascii_alphabetic()
&& bytes.get(index + 1) == Some(&b':')
&& bytes
.get(index + 2)
.is_some_and(|separator| matches!(separator, b'/' | b'\\'))
}
fn starts_forward_slash_unc_path(bytes: &[u8], index: usize) -> bool {
let server_start = index + 2;
let token_end = consume_path_token(bytes, server_start);
let Some(server_end) = bytes[server_start..token_end]
.iter()
.position(|byte| *byte == b'/')
.map(|offset| server_start + offset)
else {
return false;
};
let share_start = server_end + 1;
server_end > server_start && share_start < token_end && bytes.get(share_start) != Some(&b'/')
}
fn starts_file_uri(bytes: &[u8], index: usize) -> bool {
const FILE_URI_PREFIX: &[u8] = b"file:";
let boundary = index == 0 || is_path_boundary(bytes[index - 1]);
if !boundary {
return false;
}
let Some(prefix_end) = index.checked_add(FILE_URI_PREFIX.len()) else {
return false;
};
if !bytes
.get(index..prefix_end)
.is_some_and(|prefix| prefix.eq_ignore_ascii_case(FILE_URI_PREFIX))
{
return false;
}
let token_end = consume_path_token(bytes, prefix_end);
bytes.get(prefix_end) == Some(&b'/')
&& token_end > prefix_end
&& bytes.get(prefix_end..token_end) != Some(b"//")
}
fn is_path_boundary(byte: u8) -> bool {
byte.is_ascii_whitespace()
|| matches!(
byte,
b'"' | b'\'' | b'`' | b'=' | b'(' | b'[' | b'{' | b'<' | b',' | b';'
)
}
fn consume_path_token(bytes: &[u8], mut index: usize) -> usize {
while index < bytes.len()
&& !bytes[index].is_ascii_whitespace()
&& !matches!(
bytes[index],
b'"' | b'\'' | b'`' | b')' | b']' | b'}' | b'>' | b',' | b';'
)
{
index += 1;
}
index
}
#[cfg(test)]
mod tests {
use super::redact_absolute_path_tokens;
#[test]
fn redacts_file_uri_tokens_without_redacting_relative_text() {
assert_eq!(
redact_absolute_path_tokens(
"open file:///home/alice/project, FILE:///C:/Users/Alice/project; \
file://server/share/project file:///home/alice/My%20Project \
file:///%68ome/alice%2Fproject"
),
"open <absolute-path>, <absolute-path>; <absolute-path> <absolute-path> \
<absolute-path>"
);
assert_eq!(
redact_absolute_path_tokens(
"profile homeward docs/file.txt file:notes.txt file:// file:///"
),
"profile homeward docs/file.txt file:notes.txt file:// <absolute-path>"
);
}
#[test]
fn redacts_cross_platform_absolute_paths_without_redacting_urls() {
assert_eq!(
redact_absolute_path_tokens(concat!(
r#"open //server/share/private, \\server\share\private; "#,
"file:/home/user/private file:/C:/Users/Alice/private ",
r#"C:/Users/Alice/private C:\Users\Alice\private"#
)),
"open <absolute-path>, <absolute-path>; \
<absolute-path> <absolute-path> \
<absolute-path> <absolute-path>"
);
assert_eq!(
redact_absolute_path_tokens(
"keep docs/private file:notes.txt // not-a-path /// docs \
https://example.test/private http://localhost:3000/private"
),
"keep docs/private file:notes.txt // not-a-path /// docs \
https://example.test/private http://localhost:3000/private"
);
}
}
@@ -1,142 +0,0 @@
use super::*;
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct AgentRuntimeToolAction {
#[serde(default)]
pub(crate) tool: String,
#[serde(default)]
pub(crate) reason: Option<String>,
#[serde(default)]
pub(crate) input: serde_json::Value,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct AgentRuntimeToolObservation {
pub(crate) tool: String,
pub(crate) status: String,
pub(crate) summary: String,
pub(crate) detail: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub(crate) struct AgentRuntimeProjectRevision {
pub(crate) schema_version: String,
pub(crate) project_id: String,
pub(crate) revision: u64,
pub(crate) updated_at: u64,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct AgentRuntimeProviderRequestSnapshot {
pub(in crate::agent) project_id: String,
pub(in crate::agent) agent_id: String,
pub(in crate::agent) task_id: String,
pub(in crate::agent) session_id: String,
pub(in crate::agent) run_id: String,
pub(in crate::agent) source: String,
pub(in crate::agent) goal_id: Option<String>,
pub(in crate::agent) goal_revision: u64,
pub(in crate::agent) goal_snapshot_fingerprint: String,
pub(in crate::agent) applied_steer_cursor: u64,
pub(in crate::agent) request_kind: String,
pub(in crate::agent) request_slot: String,
pub(in crate::agent) web_search_enabled: bool,
pub(in crate::agent) allow_idle_context_compaction: bool,
}
impl AgentRuntimeProviderRequestSnapshot {
pub(in crate::agent) fn with_request_slot(&self, request_slot: impl Into<String>) -> Self {
let mut snapshot = self.clone();
snapshot.request_slot = request_slot.into();
snapshot
}
pub(in crate::agent) fn with_web_search_enabled(&self, web_search_enabled: bool) -> Self {
let mut snapshot = self.clone();
snapshot.web_search_enabled = web_search_enabled;
snapshot
}
pub(super) fn with_idle_context_compaction(&self, allow: bool) -> Self {
let mut snapshot = self.clone();
snapshot.allow_idle_context_compaction = allow;
snapshot
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub(crate) struct AgentRuntimeVerificationGate {
pub(crate) schema_version: String,
pub(crate) project_id: String,
pub(crate) agent_id: String,
pub(crate) run_id: String,
pub(crate) requires_verification: bool,
pub(crate) mutation_revision: Option<u64>,
pub(crate) verified_revision: Option<u64>,
pub(crate) last_mutation_tool: Option<String>,
pub(crate) last_verification_tool: Option<String>,
pub(crate) last_verification_status: Option<String>,
/// `last_verification_*` is deliberately the most recent verification so
/// preview failures can drive the repair loop. Keep the static smoke
/// result separately: a successful `preview.validate` must not erase the
/// proof that the exact same revision passed `game.static_smoke`.
#[serde(default)]
pub(crate) static_smoke_verified_revision: Option<u64>,
/// Bind the durable static-smoke credential to the exact entry bytes.
/// A project revision alone is insufficient because files can be changed
/// outside the Runtime mutation path without advancing that revision.
#[serde(default)]
pub(crate) static_smoke_verified_game_index_sha256: Option<String>,
#[serde(default)]
pub(crate) failed_playtest_revision: Option<u64>,
pub(crate) updated_at: u64,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct AgentRuntimePendingToolAction {
pub(crate) schema_version: String,
pub(crate) fingerprint_version: String,
pub(crate) agent_id: String,
pub(crate) task_id: String,
pub(crate) session_id: String,
pub(crate) run_id: String,
pub(crate) source: String,
#[serde(default = "default_agent_runtime_run_profile")]
pub(crate) run_profile: String,
#[serde(default)]
pub(crate) run_profile_binding_fingerprint: String,
pub(crate) task: String,
#[serde(default)]
pub(crate) goal_id: Option<String>,
#[serde(default)]
pub(crate) goal_revision: u64,
#[serde(default)]
pub(crate) goal_snapshot_fingerprint: String,
pub(crate) loop_iteration: u32,
pub(crate) action_index: u32,
pub(crate) occurrence_nonce: u64,
pub(crate) thinking_summary: String,
pub(crate) plan: Vec<String>,
pub(crate) fallback_response: String,
pub(crate) observations: Vec<AgentRuntimeToolObservation>,
pub(crate) project_revision_before: AgentRuntimeProjectRevision,
pub(crate) verification_gate_before: AgentRuntimeVerificationGate,
pub(crate) planned_repository_context_fingerprint: String,
#[serde(default)]
pub(crate) planned_steer_cursor: u64,
pub(crate) action: AgentRuntimeToolAction,
pub(crate) action_id: String,
pub(crate) action_fingerprint: String,
pub(crate) input_summary: Option<String>,
#[serde(default)]
pub(crate) execution_mode: String,
pub(crate) status: String,
pub(crate) observation: Option<AgentRuntimeToolObservation>,
pub(crate) created_at: u64,
pub(crate) updated_at: u64,
}
@@ -10,6 +10,7 @@ use crate::project::{normalize_relative_path, unix_timestamp, validate_project_r
use std::fs::{self, File};
use std::io::{Seek, SeekFrom, Write};
use std::path::Path;
#[cfg(unix)]
use std::sync::{Mutex, OnceLock};
use std::time::Duration;
@@ -333,12 +334,3 @@ pub(crate) fn try_open_game_creator_agent_runtime_task_lock_file(
root.join(relative_path).display()
))
}
#[cfg(test)]
pub(crate) fn game_creator_agent_runtime_task_lock_is_available(
root: &Path,
agent_id: &str,
) -> Result<bool, String> {
let relative_path = format!(".agent/runtime/locks/{agent_id}.lock");
Ok(try_open_game_creator_agent_runtime_task_lock_file(root, &relative_path)?.is_some())
}

Some files were not shown because too many files have changed in this diff Show More