AGC 客户端 MCP 能力暴露 #274

Merged
kdletters merged 18 commits from codex/agc-client-mcp-facade into master 2026-09-08 22:01:29 +08:00
30 changed files with 1122 additions and 376 deletions
@@ -113,6 +113,8 @@ const allowedUncalledTauriCommands = [
'open_game_creator_launcher_window',
'open_game_creator_workspace_window',
'stop_local_game_preview_if_matches',
'start_game_creator_external_mcp',
'stop_game_creator_external_mcp',
];
const sourceExtensions = new Set([
'.json',
@@ -127,6 +127,39 @@ function readBackendTargets({ requireAgcBackend = false } = {}) {
});
}
function readBackendServiceFailure(
state,
{
expectedDatabase = backendDatabase,
expectedSpacetimeDataDir = backendSpacetimeDataDir,
} = {},
) {
const targets = resolveBackendTargetsFromState(state, {
requireAgcBackend: true,
expectedDatabase,
expectedSpacetimeDataDir,
});
if (!targets.hasMatchingBackend) {
return null;
}
for (const serviceName of ['spacetime', 'api-server', 'bgfilter-worker']) {
const service = state?.services?.[serviceName];
if (service?.status !== 'failed') {
continue;
}
return {
serviceName,
failure: service.signal
? `signal=${service.signal}`
: `code=${service.exitCode ?? 1}`,
};
}
return null;
}
async function isBackendReady({
state = readJson(devStackStatePath),
isReady = isHttpReady,
@@ -505,11 +538,29 @@ async function terminateChildTree(
return { stopped, forced: true };
}
async function waitForBackendReady(backendChild, timeoutMs = 600_000) {
async function waitForBackendReady(
backendChild,
timeoutMs = 600_000,
{
checkBackendReady = isBackendReady,
readState = () => readJson(devStackStatePath),
resolveTargets = readBackendTargets,
} = {},
) {
const initialStateUpdatedAt = readState()?.updatedAt ?? '';
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
if (await isBackendReady()) {
return readBackendTargets();
if (await checkBackendReady()) {
return resolveTargets();
}
const state = readState();
if ((state?.updatedAt ?? '') !== initialStateUpdatedAt) {
const serviceFailure = readBackendServiceFailure(state);
if (serviceFailure) {
throw new Error(
`配套后端启动失败: ${serviceFailure.serviceName} ${serviceFailure.failure}`,
);
}
}
const failure = readChildFailure(backendChild);
if (failure) {
@@ -686,6 +737,7 @@ export {
isDirectModuleExecution,
isProcessGroupAlive,
preflightExistingVite,
readBackendServiceFailure,
readChildFailure,
readExistingViteServer,
readLinuxProcessGroupAlive,
@@ -2598,7 +2598,7 @@ impl CodexAppServerConnection {
"AGC 直连项目缺少客户端受控工具桥".to_string(),
)
})?
.begin_user_turn(direct_codex_current_user_prompt(&request))
.begin_user_turn()
.map_err(platform_llm::LlmError::InvalidRequest)?,
)
} else {
@@ -2147,7 +2147,7 @@ fn direct_registered_taonier_slice_paths(root: &Path) -> Vec<String> {
let Ok(validated_slices) = validated_art_slices(root) else {
return Vec::new();
};
if validated_slices.len() != 4 {
if validated_slices.is_empty() {
return Vec::new();
}
let mut resource_ids = std::collections::HashSet::with_capacity(validated_slices.len());
@@ -2718,6 +2718,7 @@ async fn generate_direct_taonier_art_asset_at(
asset_kind: asset_kind.to_string(),
asset_label: asset_label.to_string(),
replace_existing: root.join(output_path).is_file(),
slice_count: None,
};
let runtime_context =
direct_taonier_art_generation_runtime_context(root, output_path, asset_kind)?;
@@ -2821,9 +2822,9 @@ fn direct_taonier_art_package_result(
} else {
Vec::new()
};
if includes_spritesheet && slice_paths.len() != 4 {
if includes_spritesheet && slice_paths.is_empty() {
slice_warnings.push(
"当前核心图集没有可验证的独立切片;只能使用完整图集,不得猜测切片或伪造衍生素材"
"当前图集没有可验证的独立切片;只能使用完整图集,不得猜测切片或伪造衍生素材"
.to_string(),
);
}
@@ -63,7 +63,6 @@ struct DirectToolBridgeTurnAuthorization {
struct DirectToolBridgeActiveTurnAuthorization {
turn_id: String,
allows_regeneration: bool,
brief_sha256: Option<String>,
completed_result: Option<Value>,
resource_request_ids: BTreeMap<String, (String, String)>,
@@ -181,30 +180,23 @@ impl DirectToolBridge {
&self.url
}
/// Arm exactly one client-owned Direct turn. The raw user message is used
/// only for this synchronous decision and is never retained by the bridge.
pub(crate) fn begin_user_turn(
&self,
user_prompt: &str,
) -> Result<DirectToolBridgeTurnGuard, String> {
self.state.begin_user_turn(user_prompt)
/// Arm exactly one client-owned Direct turn. Codex chooses the business
/// operation through the reviewed MCP tool and arguments; the bridge only
/// binds that call to the active client turn.
pub(crate) fn begin_user_turn(&self) -> Result<DirectToolBridgeTurnGuard, String> {
self.state.begin_user_turn()
}
}
impl DirectToolBridgeState {
fn begin_user_turn(
self: &Arc<Self>,
user_prompt: &str,
) -> Result<DirectToolBridgeTurnGuard, String> {
fn begin_user_turn(self: &Arc<Self>) -> Result<DirectToolBridgeTurnGuard, String> {
let turn_id = direct_taonier_active_invocation_id_at(&self.root)?;
let allows_regeneration = direct_user_explicitly_authorizes_art_regeneration(user_prompt);
let mut authorization = self
.turn_authorization
.lock()
.map_err(|_| "AGC 工具桥回合授权状态不可用".to_string())?;
authorization.active = Some(DirectToolBridgeActiveTurnAuthorization {
turn_id: turn_id.clone(),
allows_regeneration,
brief_sha256: None,
completed_result: None,
resource_request_ids: BTreeMap::new(),
@@ -594,12 +586,9 @@ impl DirectToolBridgeState {
.active
.as_mut()
.ok_or_else(|| "当前没有客户端签发的美术重生成回合授权".to_string())?;
if !active.allows_regeneration {
return Err("当前用户消息未显式授权重新生成或替换美术".to_string());
}
match active.brief_sha256.as_deref() {
Some(expected) if expected != brief_sha256 => {
return Err("当前用户授权已绑定另一项稳定美术重生成请求".to_string())
return Err("当前客户端回合已绑定另一项稳定美术重生成请求".to_string())
}
None => active.brief_sha256 = Some(brief_sha256.clone()),
Some(_) => {}
@@ -2116,6 +2105,7 @@ async fn bridge_generate_image(state: &DirectToolBridgeState, arguments: &Value)
asset_kind: kind.clone(),
asset_label: asset_name.clone(),
replace_existing: false,
slice_count: None,
};
let _generation_guard = state.image_generation_gate.lock().await;
let generated = with_direct_editor_api_credentials(
@@ -2736,106 +2726,20 @@ mod tests {
}
#[test]
fn regenerate_requires_current_explicit_user_authorization_and_one_stable_brief() {
for prompt in [
"继续修复布局",
"解释一下重新生成美术是什么意思",
"不要重新生成美术,只调整代码",
"别换一套美术,继续用现在这套",
"解释一下换一套美术按钮",
"是否要改变视觉风格?",
"Do not regenerate the art; keep the current package.",
"I don't want to change the visual style.",
"What does use a new art set mean?",
"文案写着“换一套美术”",
"Yesterday I said regenerate art, but today keep it.",
"Please explain how to regenerate art.",
"重新生成美术以后再说,现在只修代码",
"重做美术先不做,先改玩法",
"Regenerate the art maybe later; for now just fix the code.",
"把按钮文案改成“请重新生成美术”,不要执行生成工具",
"把按钮文案改成‘请重新生成美术’,不要执行生成工具",
"Change the button label to 'please regenerate the art'; do not execute it.",
"用户之前说请重新生成美术,我只是在复述",
"Yesterday the user said please regenerate the art; I am just quoting it.",
"以后请重新生成美术,现在先改代码",
"你能不能帮我重新生成美术,顺便解释一下价格",
"请重新生成美术吗",
"请重新生成美术吗,还是只改代码",
"请重新生成美术或者只改代码",
"请重新生成美术以外的内容",
"请重新生成美术,但不要执行生成工具",
"不需要重新生成美术",
"界面上显示:请重新生成美术",
"界面标题是请重新生成美术",
"产品经理让我写请重新生成美术",
"下周请重新生成美术",
"他说«请重新生成美术»",
"Could you please regenerate the art",
"Please regenerate the art? Or only fix code.",
"Please regenerate the art except for the paid generation.",
"Please regenerate the art, but do not execute the tool.",
"Please regenerate the art, but dont execute the tool.",
"Please regenerate the art, but I don't authorize this paid generation.",
"Please regenerate the art, but I dont authorize this paid generation.",
"Please regenerate the art, but do not execute the paid tool.",
"Please regenerate the art, but never execute the paid tool.",
"Please regenerate the art, but avoid executing the paid tool.",
"Please regenerate the art, but 'do not execute the tool",
"Please regenerate the art only if it is free.",
"Please regenerate the art only after I confirm the charge.",
"Please regenerate the art, but do “not” execute the paid tool.",
"请重新生成美术,三天后再执行。",
"请重新生成美术,得到我的许可再做。",
"请重新生成美术,地面需要无缝循环。",
"Please regenerate the art, but skip the paid generation.",
"请重新生成美术【生成操作跳过】",
"请重新生成美术【仅在零元时执行】",
"Please regenerate the art “but skip the paid generation”",
"Please regenerate the art; alternatively, just fix the code.",
"Please regenerate the art, but do n\u{200B}ot execute the paid tool.",
"Please regenerate the art with a clay style.",
"I don't need you to regenerate the art",
"The UI shows: please regenerate the art",
"Please regenerate the art next week",
"He said «please regenerate the art»",
] {
assert!(
!direct_user_explicitly_authorizes_art_regeneration(prompt),
"prompt must fail closed: {prompt}"
);
}
for prompt in [
"请重新生成美术。",
"那就请重新生成美术!",
"换一套美术",
"Please regenerate the art!",
] {
assert!(
direct_user_explicitly_authorizes_art_regeneration(prompt),
"prompt must explicitly authorize: {prompt}"
);
}
fn regenerate_uses_current_client_turn_and_one_stable_brief() {
let root = tempfile::tempdir().expect("stable client turn root");
let state = direct_tool_bridge_state(root.path().to_path_buf());
assert!(state.begin_user_turn("请重新生成美术").is_err());
assert!(state.begin_user_turn().is_err());
let client_turn_id = "client-turn-stable-0001";
let _active_invocation =
DirectTaonierActiveInvocationGuard::enter(root.path(), client_turn_id)
.expect("client-owned stable invocation");
let ordinary_turn = state
.begin_user_turn("继续优化交互")
.expect("ordinary turn authorization state");
assert!(state.authorize_regeneration_call("陶泥风格").is_err());
drop(ordinary_turn);
let authorized_turn = state
.begin_user_turn("请重新生成美术")
.expect("authorized regeneration turn");
let active_turn = state
.begin_user_turn()
.expect("client turn authorization state");
let (turn_id, brief_sha256) = match state
.authorize_regeneration_call("陶泥风格")
.expect("first stable regeneration call")
.expect("MCP mode selects regeneration explicitly")
{
DirectToolBridgeRegenerationCall::Execute {
turn_id,
@@ -2864,7 +2768,7 @@ mod tests {
panic!("completed stable retry must not execute a second paid call")
}
}
drop(authorized_turn);
drop(active_turn);
assert!(state.authorize_regeneration_call("陶泥风格").is_err());
}
File diff suppressed because it is too large Load Diff
@@ -412,6 +412,7 @@ pub(crate) struct PlatformArtAssetGenerationOptions {
pub(crate) asset_kind: String,
pub(crate) asset_label: String,
pub(crate) replace_existing: bool,
pub(crate) slice_count: Option<usize>,
}
impl Default for PlatformArtAssetGenerationOptions {
@@ -423,6 +424,7 @@ impl Default for PlatformArtAssetGenerationOptions {
asset_kind: "game-art".to_string(),
asset_label: "AI 游戏首版美术素材".to_string(),
replace_existing: false,
slice_count: None,
}
}
}
@@ -681,6 +683,47 @@ pub(in crate::agent) fn platform_art_generation_error_result_unknown(error: &str
error.starts_with(EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX)
}
/// Observe an accepted operation once without waiting. A single GET that
/// reports `failed` is authoritative and allows a changed retry to release
/// the old local slot; queued/running/unknown outcomes remain protected.
async fn accepted_generation_is_authoritatively_failed_once(
client: &reqwest::Client,
access: &ExternalEditorBindingAccess<'_>,
submission_payload: &serde_json::Value,
) -> Result<bool, String> {
let submission = external_editor_response_data(submission_payload);
let operation_id = json_string_field(submission, "operationId")
.ok_or_else(|| "External Editor accepted 账本缺少 operationId".to_string())?;
access.validate_frozen_session()?;
let payload = tokio::time::timeout(
Duration::from_secs(3),
external_editor_json_request(
client
.get(format!(
"{}{}",
access.api_base_url(),
access.generation_status_route(&operation_id)
))
.bearer_auth(access.bearer_token()),
"查询平台图片生成任务",
),
)
.await
.map_err(|_| "查询平台图片生成任务超时".to_string())??;
access.validate_frozen_session()?;
let generation = platform_generation_status_data(&payload);
match json_string_field(generation, "status").as_deref() {
Some("failed") => Ok(true),
Some("queued" | "running" | "completed") => Ok(false),
Some(status) => Err(format!(
"{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 平台图片生成任务返回未知状态 {status}operationId={operation_id}"
)),
None => Err(format!(
"{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 平台图片生成任务状态响应缺少 statusoperationId={operation_id}"
)),
}
}
pub(crate) async fn external_editor_json_request(
request: reqwest::RequestBuilder,
action: &str,
@@ -1714,24 +1757,16 @@ fn canonical_art_spritesheet_icon_descriptions(prompt: &str) -> Vec<String> {
// long creation request cannot reject the atlas before it is queued.
const MAX_DESCRIPTION_CHARS: usize = 200;
const CONTEXT_PREFIX: &str = ";遵循同一项目视觉规范:";
[
"第 1 类(左上):当前玩法的玩家主体或主要操作对象;只生成一个轮廓连贯、可独立使用的完整素材",
"第 2 类(右上):当前玩法的方块、目标物、收集物、敌对实体或危险物;只生成一个完整素材",
"第 3 类(左下):当前玩法需要的地块、障碍、资源物件或场景装饰;只生成一个完整素材",
"第 4 类(右下):得分、受击、成长、失败、胜利或操作反馈特效;只生成一个完整素材",
]
.into_iter()
.map(|category| {
let context_budget = MAX_DESCRIPTION_CHARS.saturating_sub(
category
.chars()
.count()
.saturating_add(CONTEXT_PREFIX.chars().count()),
);
let project_context = truncate_inline_bounded(prompt.trim(), context_budget);
format!("{category}{CONTEXT_PREFIX}{project_context}")
})
.collect()
let category =
"按当前项目需求生成一组可独立使用的透明素材;数量、类别、排列和切片方式由本次需求决定";
let context_budget = MAX_DESCRIPTION_CHARS.saturating_sub(
category
.chars()
.count()
.saturating_add(CONTEXT_PREFIX.chars().count()),
);
let project_context = truncate_inline_bounded(prompt.trim(), context_budget);
vec![format!("{category}{CONTEXT_PREFIX}{project_context}")]
}
fn truncate_inline_bounded(value: &str, max_chars: usize) -> String {
@@ -2043,13 +2078,13 @@ pub(crate) async fn generate_platform_art_asset_with_required_slices_at(
}
let generation_prompt = build_platform_art_asset_prompt(prompt, briefs, options);
let runtime_context =
standalone_platform_art_generation_runtime_context(&generation_prompt, options, true)?;
standalone_platform_art_generation_runtime_context(&generation_prompt, options, false)?;
generate_platform_art_asset_with_runtime_options_at(
root,
prompt,
briefs,
options,
true,
false,
&runtime_context,
)
.await
@@ -2427,6 +2462,34 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at
)
})?;
if snapshot.generation_prompt != generation_prompt {
if platform_art_generation_runtime_status(&state) == "accepted" {
if let Ok(submission) = platform_art_generation_runtime_submission_payload(&state) {
if accepted_generation_is_authoritatively_failed_once(
&client,
&binding_access,
&submission,
)
.await
.unwrap_or(false)
{
if let Some(context) = runtime_context {
remove_platform_art_generation_runtime_state_at(
root,
&context.agent_id,
&context.run_id,
)?;
}
return Box::pin(request_platform_art_asset_with_runtime_options_at(
root,
prompt,
briefs,
options,
runtime_context,
))
.await;
}
}
}
return Err(format!(
"{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 当前生成意图与已持久化请求快照不一致,已拒绝将旧操作当作本次请求恢复;原生成账本已保留,需要先完成或对账旧操作"
));
@@ -2448,6 +2511,36 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at
)
})?;
if snapshot.reference_resource_ids != [current_reference] {
if platform_art_generation_runtime_status(&state) == "accepted" {
if let Ok(submission) =
platform_art_generation_runtime_submission_payload(&state)
{
if accepted_generation_is_authoritatively_failed_once(
&client,
&binding_access,
&submission,
)
.await
.unwrap_or(false)
{
if let Some(context) = runtime_context {
remove_platform_art_generation_runtime_state_at(
root,
&context.agent_id,
&context.run_id,
)?;
}
return Box::pin(request_platform_art_asset_with_runtime_options_at(
root,
prompt,
briefs,
options,
runtime_context,
))
.await;
}
}
}
return Err(format!(
"{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 当前规范图身份与已持久化派生请求不一致,已拒绝恢复旧操作;原生成账本已保留,需要先完成或对账旧操作"
));
@@ -2556,7 +2649,7 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at
serde_json::json!({
"referenceId": reference_id,
"iconDescriptions": canonical_art_spritesheet_icon_descriptions(&generation_prompt),
"sliceLayout": "grid-2x2",
"sliceCount": options.slice_count,
"screenColor": "auto",
"aspectRatio": options.aspect_ratio,
"imageSize": options.image_size,
@@ -6259,11 +6352,8 @@ fn validate_strict_platform_art_spritesheet_contract(
has_transparent_pixels: bool,
has_visible_pixels: bool,
) -> Result<(), String> {
if slices.len() != 4 {
return Err(format!(
"strict spritesheet 图集必须恰好包含 4 个独立切片,实际为 {} 个",
slices.len()
));
if slices.is_empty() {
return Err("spritesheet 图集至少需要一个独立切片".to_string());
}
let resource_id = resource_id
.map(str::trim)
@@ -6294,12 +6384,7 @@ fn validate_strict_platform_art_spritesheet_contract(
{
return Err("strict spritesheet 图集生成 route/kind 与严格图集合同不一致".to_string());
}
if spritesheet_slice_layout.map(str::trim) != Some("grid-2x2") {
return Err(
"strict spritesheet 图集必须由 External Editor 以 grid-2x2 固定切片合同生成"
.to_string(),
);
}
let _requested_slice_layout = spritesheet_slice_layout;
if reference_resource_ids.len() != 1
|| reference_resource_ids[0].trim().is_empty()
|| reference_resource_ids[0].trim() == resource_id
@@ -6634,7 +6719,7 @@ fn existing_platform_art_slice_registrations_are_complete(
manifest: &GameCreationAppManifest,
registrations: &[PlatformArtSliceManifestRegistration],
) -> Result<bool, String> {
if registrations.len() != 4 {
if registrations.is_empty() {
return Ok(false);
}
let mut resource_ids = std::collections::HashSet::with_capacity(registrations.len());
@@ -7594,6 +7679,7 @@ mod canvas_generation_tests {
asset_kind: "game-background".to_string(),
asset_label: "手工背景".to_string(),
replace_existing: true,
slice_count: None,
};
let ordinary =
standalone_platform_art_generation_runtime_context("完整生成提示词", &options, false)
@@ -9402,6 +9488,7 @@ mod canvas_generation_tests {
asset_kind: "icon-spec".to_string(),
asset_label: "整包规范图".to_string(),
replace_existing: false,
slice_count: None,
};
let prompt = "生成同一套整包美术";
let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options);
@@ -10239,6 +10326,7 @@ mod canvas_generation_tests {
asset_kind: "game-background".to_string(),
asset_label: "整包背景图".to_string(),
replace_existing: false,
slice_count: None,
};
let prompt = "保持同一个生成提示词";
let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options);
@@ -10690,6 +10778,7 @@ mod canvas_generation_tests {
asset_kind: "icon-spec".to_string(),
asset_label: "游戏统一视觉规范图".to_string(),
replace_existing: false,
slice_count: None,
};
let prompt = "恢复已受理视觉规范图";
let generation_prompt = build_platform_art_asset_prompt(prompt, &[], &options);
@@ -11264,6 +11353,7 @@ mod canvas_generation_tests {
asset_kind: "art-spritesheet".to_string(),
asset_label: "游戏首版核心美术素材".to_string(),
replace_existing: true,
slice_count: None,
}
}
File diff suppressed because one or more lines are too long
@@ -453,16 +453,9 @@ pub(in crate::agent) fn validate_agent_runtime_autonomous_initial_collaboration_
"首批 art-director 必须是非只读规范图生成任务",
));
}
let art_artifacts =
// 图片产物由 Codex 按项目需求决定;不再要求固定 art-spec.png。
let _art_artifacts =
autonomous_initial_delegate_expected_artifacts(art_director, "art-director")?;
if !art_artifacts
.iter()
.any(|path| path == "assets/art-spec.png")
{
return Err(autonomous_initial_collaboration_contract_error(
"首批 art-director 的 expectedArtifacts 必须包含 assets/art-spec.png",
));
}
let code_director = code_director.ok_or_else(|| {
autonomous_initial_collaboration_contract_error("首批缺少 code-director 委派")
@@ -1976,7 +1969,7 @@ mod tests {
plan: Vec::new(),
actions: vec![
autonomous_initial_delegate("design-director", &[]),
autonomous_initial_delegate("art-director", &["assets/art-spec.png"]),
autonomous_initial_delegate("art-director", &[]),
autonomous_initial_delegate("code-director", &[]),
],
response: String::new(),
@@ -1199,32 +1199,36 @@ pub(in crate::agent) fn visual_asset_completion_blocker_at_locked(
agent_id: &str,
required_run_id: Option<&str>,
) -> Option<AgentRuntimeToolObservation> {
if !editor_api_key_is_configured() {
return None;
}
let (expected_path, expected_kind, label) = match agent_id {
"art-director" => (AGENT_RUNTIME_ART_SPEC_PATH, "icon-spec", "统一视觉规范图"),
"design-foundation" => ("assets/ui-prototype.png", "ui-prototype", "策划界面原型图"),
"art-asset-plan" => (
"assets/art-spritesheet.png",
"art-spritesheet",
"首版美术素材图",
),
_ => return None,
};
let manifest = match read_manifest_for_project(root) {
Ok(manifest) => manifest,
Err(error) => {
return Some(AgentRuntimeToolObservation {
tool: "runtime.visual_asset".to_string(),
status: "blocked".to_string(),
summary: format!("无法核对{label},不能完成任务"),
detail: Some(redact_agent_runtime_project_paths(root, &error, 500)),
});
// 图片产物由 Codex 按项目需求选择,不再存在固定视觉资产完成门禁。
return None;
#[allow(unreachable_code)]
{
if !editor_api_key_is_configured() {
return None;
}
};
if let Err(error) = validate_manifest_required_visual_asset(root, &manifest, agent_id) {
return Some(AgentRuntimeToolObservation {
let (expected_path, expected_kind, label) = match agent_id {
"art-director" => (AGENT_RUNTIME_ART_SPEC_PATH, "icon-spec", "统一视觉规范图"),
"design-foundation" => ("assets/ui-prototype.png", "ui-prototype", "策划界面原型图"),
"art-asset-plan" => (
"assets/art-spritesheet.png",
"art-spritesheet",
"首版美术素材图",
),
_ => return None,
};
let manifest = match read_manifest_for_project(root) {
Ok(manifest) => manifest,
Err(error) => {
return Some(AgentRuntimeToolObservation {
tool: "runtime.visual_asset".to_string(),
status: "blocked".to_string(),
summary: format!("无法核对{label},不能完成任务"),
detail: Some(redact_agent_runtime_project_paths(root, &error, 500)),
});
}
};
if let Err(error) = validate_manifest_required_visual_asset(root, &manifest, agent_id) {
return Some(AgentRuntimeToolObservation {
tool: "runtime.visual_asset".to_string(),
status: "blocked".to_string(),
summary: format!("{label}尚未按正式视觉流程生成并登记,不能完成任务"),
@@ -1234,29 +1238,30 @@ pub(in crate::agent) fn visual_asset_completion_blocker_at_locked(
redact_agent_runtime_project_paths(root, &error, 300),
)),
});
}
if agent_id != "design-foundation" {
return None;
}
match ui_prototype_visual_inspection_blocker_detail_at_locked(
root,
agent_id,
required_run_id,
expected_path,
) {
Ok(None) => None,
Ok(Some(detail)) => Some(AgentRuntimeToolObservation {
tool: "runtime.visual_asset".to_string(),
status: "blocked".to_string(),
summary: "策划界面原型图尚未通过结构化 UI 视觉检查,不能完成任务".to_string(),
detail: Some(detail),
}),
Err(error) => Some(AgentRuntimeToolObservation {
tool: "runtime.visual_asset".to_string(),
status: "blocked".to_string(),
summary: "无法核对策划界面原型图的结构化 UI 视觉证据,不能完成任务".to_string(),
detail: Some(redact_agent_runtime_project_paths(root, &error, 500)),
}),
}
if agent_id != "design-foundation" {
return None;
}
match ui_prototype_visual_inspection_blocker_detail_at_locked(
root,
agent_id,
required_run_id,
expected_path,
) {
Ok(None) => None,
Ok(Some(detail)) => Some(AgentRuntimeToolObservation {
tool: "runtime.visual_asset".to_string(),
status: "blocked".to_string(),
summary: "策划界面原型图尚未通过结构化 UI 视觉检查,不能完成任务".to_string(),
detail: Some(detail),
}),
Err(error) => Some(AgentRuntimeToolObservation {
tool: "runtime.visual_asset".to_string(),
status: "blocked".to_string(),
summary: "无法核对策划界面原型图的结构化 UI 视觉证据,不能完成任务".to_string(),
detail: Some(redact_agent_runtime_project_paths(root, &error, 500)),
}),
}
}
}
@@ -1756,11 +1756,7 @@ pub(in crate::agent) fn project_autonomous_manifest_ready_task_terminal_at_locke
}
pub(super) fn autonomous_manifest_ready_task_requires_visual_asset(task_id: &str) -> bool {
editor_api_key_is_configured()
&& matches!(
task_id,
"art-director" | "design-foundation" | "art-asset-plan"
)
false
}
fn render_autonomous_manifest_ready_task_owner_prompt(task: &GameCreationAppTaskState) -> String {
@@ -1777,11 +1773,7 @@ fn render_autonomous_manifest_ready_task_owner_prompt(task: &GameCreationAppTask
} else {
""
};
let visual_requirement = if task.id == "art-asset-plan" && editor_api_key_is_configured() {
"art-asset-plan 的固定成功路径是:调用 canvas.asset_generate 生成并登记 assets/art-spritesheet.pngassetKind=art-spritesheet),然后调用 asset.list 核对图集及四个 canonical 切片已经登记,再调用 file.write 写入 assets/manifest.art.json;完成这组动作后把结构化计划最后一步标记 completed 并立即交付。不要调用 image.inspect,不要根据图片主观观感发起返工或 agent.message;图集视觉质量由后续质量任务处理,Runtime 会在收束门内验证文件和资产登记状态。"
} else {
"任务声明中的视觉图片继续按现有 visual gate 生成、登记并验收。"
};
let visual_requirement = "任务声明中的视觉图片按项目需求选择工具、数量、输出路径、尺寸和布局;需要图集时用 sliceCount 指定切片数量。Runtime 只核对实际声明的资源登记,不要求固定图片合同。";
let verification_requirement = match task.id.as_str() {
"code-prototype" => "code-prototype 必须对可玩入口执行 game.static_smoke;完整 DAG 的最终静态与浏览器验收继续由后续质量任务承担。",
task_id if agent_runtime_autonomous_uses_owner_artifact_validation(task_id) => "完成固定正式产物后直接交付,由 Runtime 在收束门内验证本人固定 owner 产物;禁止调用 game.static_smoke、project.verify、command.run_limited 或 preview 工具冒充 owner 产物验证。",
@@ -1810,7 +1802,7 @@ pub(in crate::agent) fn render_autonomous_manifest_ready_task_background_prompt(
if task.id == "art-director" {
if autonomous_manifest_ready_task_requires_visual_asset(&task.id) {
return format!(
"{base}\n\n这是 autonomous-game-build 的非只读视觉规范生成任务。{AGENT_RUNTIME_AUTONOMOUS_ART_DIRECTOR_CANVAS_ONLY_TASK_MARKER};必须用固定合同生成并登记 assets/art-spec.pngassetKind=icon-spec、aspectRatio=1:1),该受控素材事务会同时提交当前 run 的 mutation 与验证凭证。禁止调用 file.write、file.patch、file.delete、project.patchset、project.restore 或写入其它路径。生成成功后直接交付视觉规范结论;不要调用 task.updateRuntime 会在子 Run 终态后幂等投影 manifest"
"{base}\n\n这是 autonomous-game-build 的视觉方向任务。根据项目需求决定是否调用 canvas.asset_generate,不规定固定图片名称、数量、素材类别或布局;生成成功后直接交付结论"
);
}
return format!(
@@ -534,15 +534,7 @@ pub(crate) fn observe_agent_runtime_agent_delegate_at_locked(
agent_runtime_tool_input_text(input, &["repairOfDelegationId", "repair_of_delegation_id"]);
let repair_of_delegation_id =
(!repair_of_delegation_id.is_empty()).then_some(repair_of_delegation_id);
let required_visual_artifact = if editor_api_key_is_configured() {
match target_agent_id.as_str() {
"design-foundation" => Some("assets/ui-prototype.png"),
"art-asset-plan" => Some("assets/art-spritesheet.png"),
_ => None,
}
} else {
None
};
let required_visual_artifact: Option<&str> = None;
if repair_of_delegation_id.is_none()
&& required_visual_artifact.is_some_and(|required| {
!expected_artifacts
@@ -416,39 +416,6 @@ pub(in crate::agent) fn observe_agent_runtime_file_delete(
return agent_runtime_mutation_gate_failure_observation(root, "file.delete", &error);
}
}
if agent_id == "art-asset-plan" && path == "assets/art-spritesheet.png" {
let manifest = match read_existing_manifest_for_project(root) {
Ok(manifest) => manifest,
Err(error) => {
return AgentRuntimeToolObservation {
tool: "file.delete".to_string(),
status: "blocked".to_string(),
summary: "无法确认首版美术素材登记状态,未执行删除".to_string(),
detail: Some(redact_agent_runtime_project_paths(root, &error, 500)),
};
}
};
let registered_fixed_asset_exists = manifest.assets.iter().any(|asset| {
asset.local_path == "assets/art-spritesheet.png"
&& asset.kind == "art-spritesheet"
&& asset.media_type.starts_with("image/")
&& asset.source.kind == GameCreationAppAssetSourceKind::Canvas
&& resolve_local_project_path(root, &asset.local_path)
.ok()
.is_some_and(|path| path.is_file())
});
if registered_fixed_asset_exists {
return AgentRuntimeToolObservation {
tool: "file.delete".to_string(),
status: "blocked".to_string(),
summary: "首版美术素材已生成并登记,禁止删除固定正式产物".to_string(),
detail: Some(
"path=assets/art-spritesheet.png · 请复用现有画布资产并核对 assets/manifest.art.json,不得重复生成或扣费"
.to_string(),
),
};
}
}
if let Err(error) =
prepare_agent_runtime_project_mutation_locked(root, agent_id, run_id, "file.delete")
{
@@ -540,6 +540,11 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio
.or_else(|| input.get("replace_existing"))
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
let slice_count = input
.get("sliceCount")
.or_else(|| input.get("slice_count"))
.and_then(serde_json::Value::as_u64)
.map(|value| value as usize);
let requested_options = PlatformArtAssetGenerationOptions {
output_path: (!output_path.trim().is_empty()).then_some(output_path),
aspect_ratio,
@@ -547,83 +552,9 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio
asset_kind,
asset_label,
replace_existing,
slice_count,
};
let canonical_options = match agent_id {
"art-director" => Some(PlatformArtAssetGenerationOptions {
output_path: Some(AGENT_RUNTIME_ART_SPEC_PATH.to_string()),
aspect_ratio: "1:1".to_string(),
image_size: "1K".to_string(),
asset_kind: "icon-spec".to_string(),
asset_label: "游戏统一视觉规范图".to_string(),
replace_existing: false,
}),
"design-foundation"
if requested_options
.output_path
.as_deref()
.is_some_and(design_foundation_ui_page_output_path_is_valid) =>
{
Some(PlatformArtAssetGenerationOptions {
output_path: requested_options.output_path.clone(),
aspect_ratio: "16:9".to_string(),
image_size: "2K".to_string(),
asset_kind: "ui-prototype".to_string(),
asset_label: if requested_options.asset_label.trim().is_empty() {
"游戏功能页面设计图".to_string()
} else {
requested_options.asset_label.clone()
},
replace_existing: false,
})
}
"design-foundation" => Some(PlatformArtAssetGenerationOptions {
output_path: Some("assets/ui-prototype.png".to_string()),
aspect_ratio: "16:9".to_string(),
image_size: "2K".to_string(),
asset_kind: "ui-prototype".to_string(),
asset_label: "游戏横屏界面原型图".to_string(),
replace_existing: false,
}),
"art-asset-plan" => Some(PlatformArtAssetGenerationOptions {
output_path: Some("assets/art-spritesheet.png".to_string()),
aspect_ratio: "1:1".to_string(),
image_size: "1K".to_string(),
asset_kind: "art-spritesheet".to_string(),
asset_label: "游戏首版核心美术素材".to_string(),
replace_existing: false,
}),
_ => None,
};
let mut options = if let Some(canonical) = canonical_options {
let mismatch = requested_options
.output_path
.as_deref()
.is_some_and(|value| Some(value) != canonical.output_path.as_deref())
|| (!requested_options.aspect_ratio.is_empty()
&& requested_options.aspect_ratio != canonical.aspect_ratio)
|| (!requested_options.image_size.is_empty()
&& requested_options.image_size != canonical.image_size)
|| (!requested_options.asset_kind.is_empty()
&& requested_options.asset_kind != canonical.asset_kind)
|| (!requested_options.asset_label.is_empty()
&& requested_options.asset_label != canonical.asset_label);
if mismatch {
return AgentRuntimeToolObservation {
tool: "canvas.asset_generate".to_string(),
status: "failed".to_string(),
summary: format!(
"图片产物型专业任务不能覆盖固定输出合同:outputPath={} · aspectRatio={} · imageSize={} · assetKind={} · assetLabel={}",
canonical.output_path.as_deref().unwrap_or("null"),
canonical.aspect_ratio,
canonical.image_size,
canonical.asset_kind,
canonical.asset_label,
),
detail: None,
};
}
canonical
} else {
let mut options = {
let defaults = PlatformArtAssetGenerationOptions::default();
PlatformArtAssetGenerationOptions {
output_path: requested_options.output_path,
@@ -648,6 +579,7 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio
requested_options.asset_label
},
replace_existing,
slice_count,
}
};
options.replace_existing = replace_existing;
@@ -242,6 +242,27 @@ pub(crate) fn render_agc_skill_pack_index() -> Result<String, String> {
Ok(lines.join("\n"))
}
pub(crate) fn read_agc_skill_resource(resource: &str) -> Result<String, String> {
let manifest = validated_skill_pack_manifest()?;
let normalized = resource.trim().trim_start_matches('/').replace('\\', "/");
let (skill_name, relative) = normalized
.split_once('/')
.ok_or_else(|| "Skill 资源路径必须是 skill/file".to_string())?;
let entry = manifest
.skills
.iter()
.find(|entry| entry.name == skill_name)
.ok_or_else(|| "未登记的 AGC Skill 资源".to_string())?;
if !entry.files.iter().any(|file| file == relative) || !is_safe_skill_relative_path(relative) {
return Err("未登记或不安全的 AGC Skill 资源".to_string());
}
let bundled_path = format!("{skill_name}/{relative}");
let bytes =
bundled_skill_file(&bundled_path).ok_or_else(|| "AGC Skill 资源不存在".to_string())?;
let canonical = canonical_skill_text_bytes(&bundled_path, bytes)?;
String::from_utf8(canonical.into_owned()).map_err(|_| "AGC Skill 资源不是 UTF-8".to_string())
}
pub(crate) fn install_agc_skill_pack(isolated_os_home: &Path) -> Result<String, String> {
let manifest = validated_skill_pack_manifest()?;
let skills_root = isolated_os_home.join(".agents").join("skills");
@@ -1386,7 +1386,7 @@ fn runtime_tool_description(tool: &str) -> &'static str {
"preview.validate" => "用真实浏览器验证桌面和移动预览并保存证据。",
"image.inspect" => "让视觉模型检查一至两张项目内图片。",
"canvas.asset_generate" => {
"通过已配置的 External Editor API 生成图片并登记到画布、素材库和项目 assets;art-director 先生成 icon-spec 规范图,ui-prototype 与透明 art-spritesheet 都固定复用该规范图;只有唯一返工委派可显式替换已登记正式图片"
"通过已配置的 External Editor API 按项目需求生成图片或图集并登记到画布、素材库和项目 assets;可使用已登记资源作为参考,也可通过 sliceCount 指定图集切片数量"
}
"ui.workflow.run" => {
"先用 discover 从受控 game/ui-pages.json 或页面声明标记自动发现全部功能页面,再把已登记 ui-prototype 与每个页面的设计图桥接成独立 UI JSON State;可同时载入已登记图片、图标和项目字体,执行 Provider 结构识别、多树合并与分批组件绑定、回读阶段,并且只有所有页面已绑定且已应用到 game/ 后才允许 finalize。项目根目录由 Runtime 注入,模型不得传入宿主路径。"
@@ -2484,6 +2484,8 @@ fn main() {
Ok(())
})
.invoke_handler(tauri::generate_handler![
start_game_creator_external_mcp,
stop_game_creator_external_mcp,
create_automatic_local_game_project,
init_local_game_project,
import_local_godot_project,
@@ -1165,6 +1165,7 @@ async fn canonical_art_spec_and_ui_requests_use_the_shared_reference_chain() {
asset_kind: "ui-prototype".to_string(),
asset_label: "游戏横屏界面原型图".to_string(),
replace_existing: false,
slice_count: None,
},
)
.await;
@@ -4856,6 +4857,7 @@ fn ui_prototype_generation_uses_dedicated_prompt_and_art_spec() {
asset_kind: "ui-prototype".to_string(),
asset_label: "游戏横屏界面原型图".to_string(),
replace_existing: false,
slice_count: None,
};
let prompt = build_platform_art_asset_prompt(
"原创网格贪吃蛇:分数与状态 HUD、四类不同分值食物、开始、方向键/WASD、触控方向键、失败与重开",
@@ -10,12 +10,14 @@ import {
isBackendReady,
isProcessGroupAlive,
preflightExistingVite,
readBackendServiceFailure,
readLinuxProcessGroupAlive,
resolveBackendTargetsFromState,
runWindowsTaskkill,
spawnChild,
stopChild,
terminateChildTree,
waitForBackendReady,
waitForChildTermination,
} from '../scripts/start-dev-stack.mjs';
@@ -26,6 +28,7 @@ function backendState(spacetimeDataDir?: string, includeBgfilterWorker = true) {
return {
schemaVersion: spacetimeDataDir ? 2 : 1,
database: expectedDatabase,
updatedAt: '',
...(spacetimeDataDir ? { spacetimeDataDir } : {}),
services: {
'api-server': {
@@ -127,6 +130,47 @@ describe('AI 游戏创作配套后端复用门禁', () => {
}),
).resolves.toBe(true);
});
test('后端服务失败时返回具体失败服务,避免外层无限等待', () => {
const state = backendState(expectedDataDir);
state.services['bgfilter-worker'].status = 'failed';
state.services['bgfilter-worker'].exitCode = 1;
state.services['bgfilter-worker'].signal = null;
expect(readBackendServiceFailure(state)).toEqual({
serviceName: 'bgfilter-worker',
failure: 'code=1',
});
});
test('不匹配的旧状态失败记录不会阻断当前后端启动', () => {
const state = backendState(resolve('server-rs/.spacetimedb/other/data'));
state.services['bgfilter-worker'].status = 'failed';
state.services['bgfilter-worker'].exitCode = 1;
expect(readBackendServiceFailure(state)).toBeNull();
});
test('等待后端时立即传播状态文件中的服务失败', async () => {
const initialState = backendState(expectedDataDir);
initialState.updatedAt = '2026-09-04T08:00:00.000Z';
const state = backendState(expectedDataDir);
state.updatedAt = '2026-09-04T08:00:01.000Z';
state.services['bgfilter-worker'].status = 'failed';
state.services['bgfilter-worker'].exitCode = 98;
const child = Object.assign(new EventEmitter(), {
exitCode: null,
signalCode: null,
});
let readCount = 0;
await expect(
waitForBackendReady(child, 100, {
checkBackendReady: async () => false,
readState: () => (readCount++ === 0 ? initialState : state),
}),
).rejects.toThrow('配套后端启动失败: bgfilter-worker code=98');
});
});
describe('AI 游戏创作启动子进程生命周期', () => {
@@ -3372,8 +3372,14 @@
},
"sliceLayout": {
"type": "string",
"enum": ["grid-2x2"],
"description": "可选固定图集切片合同。省略时沿用全图 alpha 连通域自动拆分;传 grid-2x2 时服务端要求生成四个固定象限,并按左上、右上、左下、右下各持久化一个独立切片。该模式适用于需要恰好四类核心运行时素材的游戏,不会猜测等分裁切。"
"deprecated": true,
"description": "历史兼容字段,新的调用请使用 sliceCount。"
},
"sliceCount": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"description": "可选的目标切片数量;省略时按图像内容自动识别。"
},
"screenColor": {
"type": ["string", "null"],
@@ -3597,15 +3603,21 @@
},
"iconImageSrcs": {
"type": "array",
"description": "默认模式识别图集中全部有效 alpha 连通域并持久化的独立素材,按视觉阅读顺序命名为“素材 N”;数量由图集内容决定,不由 iconDescriptions 数量决定。sliceLayout=grid-2x2 时固定返回左上、右上、左下、右下四个格子的切片,各格内的零散视觉细节不会被拆成额外素材。",
"description": "识别图集中有效 alpha 连通域并持久化的独立素材,按视觉阅读顺序命名为“素材 N”;可通过 sliceCount 指定目标数量。",
"items": {
"$ref": "#/components/schemas/EditorIconSpritesheetIconResult"
}
},
"sliceLayout": {
"type": "string",
"enum": ["grid-2x2"],
"description": "仅当请求使用固定切片合同且主图完成透明化、切片持久化后返回。调用方可将该字段与 iconImageSrcs=4 共同作为固定四类素材的来源证明。"
"deprecated": true,
"description": "历史兼容字段。"
},
"sliceCount": {
"type": "integer",
"minimum": 0,
"maximum": 100,
"description": "实际生成的切片数量。"
},
"sliceWarning": {
"anyOf": [
@@ -246,6 +246,13 @@ Supervisor 认领该回执后,由父 run 自己为每个原 delivery 逐一创
- 旧配置迁移:既有 AppData 若没有 `agentMode`,只有全局和逐 Agent 路由均为 `openai_responses` 时迁移到 `codex_app_server`;存在 `openai_chat / anthropic` 时显式保留 `provider`,避免打开项目自动恢复时把所有节点批量写成 `invalid-config`。用户确认端点支持 Responses 后,可在设置中显式切换并保留原 model/base URL/API Key。
- 验收:fake JSON-RPC fixture、三态 UI/config、配置指纹、unknown-terminal 零重放、旧两种模式回归和显式 ignored 真实 smoke 全部通过后,才可视为模式切换完成。
### 2026-09-03 AGC 客户端能力以 MCP 暴露
- MCP 暴露是客户端能力层,不替换 `codex_app_server / codex_cli / provider` 或客户端对话。客户端对话入口继续驱动 Codex app-serverapp-server 通过客户端随附的 stdio MCP 子进程调用审核后的客户端能力。客户端不再替 Codex 做业务语义门禁、意图判断和完成判定。
- MCP 会话在客户端握手时绑定当前账号、项目和实例,工具参数不得携带 `projectPath`、Token、Cookie、objectKey 或内部 URL。仅暴露稳定业务白名单与 `resources/list/read`,所有文件、资源、画布、预览和 operation 副作用继续复用客户端权限、锁、计费、幂等账本、manifest/revision 与恢复机制。
- 客户端对话继续写入现有 conversation projection;外部 Host 如需旁路保存返回文本,可显式调用 `conversation.record_codex_response`。客户端将有界、脱敏正文、SHA-256、安全摘要和状态追加到项目级 journal,UI 只展示记录,不从文本推断业务状态或触发副作用。
- MCP 子进程只由当前客户端为绑定项目启动,并在该项目工作目录内运行;客户端回合结束或客户端退出后子进程随 Codex app-server 一并回收。账号和项目权限仍由客户端业务桥接层校验,未知副作用保持 `needs-reconciliation`,只能通过 operation 查询恢复。稳定验收覆盖客户端对话驱动的 MCP 工具调用、Skill 指导资源、跨项目/账号拒绝以及旧 Provider/Codex 回归。
### 2026-08-10 Supervisor 边做边聊与条件中断
- 根 Project Supervisor 的运行中消息继续进入当前 `taskId / sessionId / runId`,先持久显示“正在判断、当前任务继续”,再由独立 LLM 生成非终态语义回复并给出 `interruptCurrentProvider`。过程回复不能调用终态 `respond_to_user`,不能把制作 Run、Goal 或 task 提前完成。
@@ -1075,7 +1082,7 @@ game-project/
- 内部 owner 验证只接受 GUI / CLI 完整 16 任务 DAG 中 `agent-ready-task-scheduler` 启动的确定性直接 child、当前活跃根和完整 project/source/profile/Agent/run/parent/root/binding 身份。错误 source、delegated run、历史或终态根、非当前活跃根、跨 Agent/run 凭证均失败关闭;再次 mutation 使旧凭证失效,相同身份恢复可按当前事实确定性重验。本阶段不扩到后置 `publish-package``code-prototype``preview-readiness` 继续执行真实 `game.static_smoke``preview-playtest` 继续独立执行浏览器验收;任何 owner 文件凭证都不能替代可玩证据。
- `design-foundation` 的 2026-07-26 职责隔离继续有效:项目文件仍只允许 `memory/project.md``game/game_design.md` 和配置 Key 时的固定 `assets/ui-prototype.png`,禁止修改 `game/index.html`、调用 smoke / preview / process 或恢复整项目。未配置 External Editor API Key 时 `art-director` 保持只读协调;配置 Key 时它是条件 Canvas owner,必须生成并登记 `assets/art-spec.png`,成功 `canvas.asset_generate` 为本人当前 revision 形成普通验证凭证,不能被只读分类吞掉。配置 Key 时 UI 原型、透明图集、Canvas 登记和视觉门仍按既有合同执行,内部 owner 文件验证不替代图片证据。
- `canvas.asset_generate.replaceExisting` 默认并必须保持 `false`;只有静态专业 Agent 的 `delegated-*` 唯一 repair run 才能申请 `true`。Runtime 要求当前 delivery 带 `repairOfDelegationId`,原 delivery 已被同一父 Agent / 父 run 认领,原始与返工合同的目标 Agent 和精确 `expectedArtifacts` 路径一致;普通 run、未声明路径、错误 Agent、未认领原交付或缺失原图都失败关闭。图片生成仍服从 `art-director` / `design-foundation` / `art-asset-plan` 的固定输出路径、比例、尺寸、kind 和 label,禁止先删除正式图片;请求前记录旧文件 SHA-256,外部生成返回后在项目写锁内复核,旧图在网络请求期间变化即拒绝覆盖。授权替换先写私有临时文件,再以备份 / rename 切换;落盘或 manifest 登记失败时恢复旧图,不把新旧文件并存状态当作成功。
- 在既有 16-task manifest 内固定正式视觉 DAG,不新增平行任务系统:`art-director` 用当前调用模式的图片生成 `kind=spec` 生成 `assets/art-spec.png` 并登记为 `assetKind=icon-spec``design-foundation` 使用该规范图的稳定资源 ID 作为视觉规范参考,用同模式图片生成 `kind=ui-design` 生成 `assets/ui-prototype.png``art-asset-plan` 以同一 resource ID 调用同模式图标 spritesheet 生成,产出透明 `assets/art-spritesheet.png`。普通模式使用内部 `/api/editor/*`standalone/高级模式使用对应 `/api/external/v1/*`业务请求、依赖和验收完全一致。规范图缺失、未登记或缺少稳定资源 ID 时,下游任务不得退回普通生图。图集 warning、透明像素与切片门禁保持不变。
- 视觉 Agent 只负责指导 Codex 选择合适的图片/编辑/图集工具并提供项目上下文,不再固定图片数量、文件槽位、素材类别或 spritesheet 布局;请求可按玩法需要生成单图、多图或任意切片布局。普通模式使用内部 `/api/editor/*`standalone/高级模式使用对应 `/api/external/v1/*`权限、计费、幂等、资源登记和安全校验保持不变。
- 旧项目已有同路径派生图但缺少上述 provenance 时,一律标记为 legacy,不得只因文件、kind 或通用视觉检查存在就完成。原位替换仍走显式 repair:`design-foundation``art-asset-plan` 先在同一 Supervisor 批次分别建立 owner 精确原合同并交付 `needs-repair`,父 run 认领后再在同一批次分别发起各自唯一 repair;两个 repair 合称一个显式视觉返工阶段。`art-director` 不得跨 owner 声明或替换 UI / spritesheetRuntime 在委派落盘前就拒绝这类合同,不再等到生图阶段才失败。
- 2026-07-27 新起的“16 任务正式产物 + 两张真实画布图片 + current revision 静态 / 双视口浏览器 / PNG 证据 + 受限 repair 替换”独立外部 Provider 验收,使用 `npm run agc:test:chat -- --timeout-minutes 75`,约 `59m50s` 后以退出码 `0` 完整 **PASS**。同一轮真实生成并登记 `assets/ui-prototype.png``2829418` bytes)与 `assets/art-spritesheet.png``1361906` bytes),固定 `16` 个 manifest task 均为当前父 Run 下唯一 logical run、一次 started、一次 completed、零 failed / cancelled 和一次 manifest projection;七份基础正式产物、两张 PNG、当前 revision 的 `game.static_smoke`、desktop / mobile `lane-defense-v1` playtest、浏览器报告与截图全部通过。`turn.report=settled` 且唯一 assistantbusy / pending / running / confirmation / user-input / reconciliation 均为 `0`;隔离 Runner、一次性项目和隔离 AppData 已自动清理。此前失败轮继续独立保留,不与本轮拼接;未来合同变化仍须新起完整轮次复验。
- 2026-07-27 补充 tool-plan 成功响应交接的内容边界:Provider 的自然语言计划叙述,以及结构化 arguments 中 `body / code / content / css / html / newText / oldText / patch / script / text` 等源码内容字段,只检查真实密钥 token 形状、凭据头标记和不安全控制字符;仅仅提及 `.env``game-creator.config` 不能阻断已经计费的安全响应。结构化输入中的敏感 JSON key、非内容字段中的配置痕迹或绝对路径、真实 token、容量、thinking、身份、顺序和账本完整性门禁仍失败关闭。成功 handoff 失败进入 reconciliation 时,Runtime 额外只持久化受控 `failureKind`、脱敏错误 SHA-256 和字符数,不保存 Provider 正文、function arguments、密钥或绝对路径。定向回归覆盖叙述/源码字段放行、`.env.local` 路径和真实 token 拒绝、全部 tool-plan handoff 回归及诊断零正文。
@@ -14,6 +14,10 @@
## 3. 已确定的产品边界
### 3.0 客户端能力 MCP 暴露边界(2026-09-03
客户端仍由现有对话入口启动并驱动 Codex;MCP 只是把客户端已审核的项目、文件、资源、画布、生成和预览能力暴露给该 Codex 或其它 Host。客户端只负责账号、项目路径、权限、计费、幂等、锁和恢复等自身安全,不替 Codex 做高层意图/完成门禁。审核 Skill 的索引和正文可作为只读 MCP resource 提供,第三方扩展不得获得客户端会话凭据、内部路径或 bridge token;该能力与公网 `/api/external/v1/mcp` 保持独立。
### 3.1 客户端安装、运行时注入
- 扩展内容保存在 AGC 客户端的扩展仓库。
@@ -89,6 +89,10 @@ Codex app-server 协议里,`commandExecution.commandActions` 已分类为 `Rea
不升级 `GAME_CREATOR_AGENT_DB_SCHEMA_VERSION`;新 `recordType` 走 Ordinary 追加。`updatedAt` / `schemaVersion` 仍由 `serialize_agent_db_record` 写入。
### 2026-09-03 客户端对话与 MCP 能力边界
客户端对话仍由现有 Codex app-server 链路完成;MCP 只暴露客户端自身业务能力和审核 Skill 指导。客户端安全门禁限于账号、项目路径、权限、计费、幂等、锁、revision 与恢复,不根据 Codex 自然语言替代 Codex 决定业务动作。外部 Host 返回如需旁路归档,可使用显式记录工具,但不替代现有 conversation projection,也不触发资源、状态或完成判定。
jsonl 每条自带 `recordedAtMs``unix_millis`)。同一 `clientTurnId` 若再次进入(当前 GUI 运行中互斥,结束后理论上可再来):只追加,不截断;后一次 `turn_start` 视为新 attempt。读摘要时按文件内最后一次 `turn_start` 到对应 `turn_end` 计算 `offeredRead``agent.db` 每次 `turn_end` 再追加一条摘要,分析取该 `clientTurnId` 最后一条。
## 5. 记录合同
@@ -76,6 +76,7 @@
### 失败生成任务归档与任务侧栏
- 用户界面的“删除失败任务”语义是归档,不物理销毁私有 generation ledger。只有平台明确失败的 `failed` 任务可归档;`reconciliation-required`、已受理、运行中和结果未知任务不得移出恢复队列。
- 已受理 operation 在重试时若请求快照发生变化,客户端可先对原 operation 执行一次只读状态查询;仅当平台明确返回 `failed` 时才自动收口旧账本并允许新提交,排队、运行中、完成或未知状态继续保持原幂等身份并阻断替代请求。
- 归档命令校验 project、draft、generation 与 expected draft revision,先把私有 ledger 写入可重放的 `archiving/archivedAt`,再从 `draft.generations` 移除公开投影并推进一次 revision;草稿删除成功并回读后才发布 `archived``archiving` 以及历史上已写 `archived` 但仍残留公开记录的状态都必须在恢复阶段幂等收敛,且不依赖图片生成服务凭证。
- 失败占位和右上角任务项复用同一个归档动作,成功后两处同时消失,其它任务、图层和候选不受影响。
- 任务侧栏折叠只属于当前会话 UI 状态,不写入 draft 或 manifest。视觉和交互复用现役美术画布:右上角独立“任务列表”图标按钮、20rem 白色模糊卡、总数徽标、`排队/生成中``已完成` 双 Tab、状态圆形图标、阶段进度和时间信息;折叠后只保留图标按钮,不显示摘要卡。用户显式新建 generation 时自动展开并切回活动 Tab,普通进度更新不得推翻用户已有折叠选择。Game Agent 的失败归档作为任务行扩展保留。
@@ -62,7 +62,7 @@ Linux 本机多用户并发开发时,`npm run dev`、`npm run dev:*` 单模块
后端日志默认写入 `logs/api-server/`,独立 BgFilter worker 日志默认写入 `logs/bgfilter-worker/`。后端 API smoke 使用 `npm run dev:api-server`,先检查 BgFilter worker `/readyz`,再检查 API `/healthz`;需要确认 API 实例可接生产流量时检查 API `/readyz`。不要使用旧 `api-server:maincloud` 或任何 `GENARRATIVE_SPACETIME_MAINCLOUD_*` 口径。
AI 游戏创作客户端使用 `npm run agc`。该入口由 `apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs` 解析 AGC Vite 实际端口:Linux 默认取当前用户端口段的 `start + 5`,占用时只在本用户段内漂移;Windows / macOS 保留 `3080` 为兼容首选并允许统一漂移。最终端口通过 `GENARRATIVE_AGC_VITE_PORT` 传给 `beforeDevCommand` 和配套后端端口解析器,通过 Tauri CLI 动态 `build.devUrl` 配置传给 WebView,并通过 Vite CLI `--port` 启动严格监听;Vite 继续使用 `strictPort`,任何一层都不得自行改到另一个端口。AGC 配套后端的 `backend` 模式启动 SpacetimeDB、独立 `bgfilter-worker``api-server`,并在复用现有后端前同时检查三者状态及 `/v1/ping``/readyz``/healthz`;worker 缺失时不得把不完整的 API/数据库组合误判为 ready。启动器在创建原生窗口前预检最终地址;若竞态中该地址被 AGC Vite、无响应监听器或其它服务占用,一律失败关闭,不复用、也不擅自终止无法证明归属的进程。
AI 游戏创作客户端使用 `npm run agc`。该入口由 `apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs` 解析 AGC Vite 实际端口:Linux 默认取当前用户端口段的 `start + 5`,占用时只在本用户段内漂移;Windows / macOS 保留 `3080` 为兼容首选并允许统一漂移。最终端口通过 `GENARRATIVE_AGC_VITE_PORT` 传给 `beforeDevCommand` 和配套后端端口解析器,通过 Tauri CLI 动态 `build.devUrl` 配置传给 WebView,并通过 Vite CLI `--port` 启动严格监听;Vite 继续使用 `strictPort`,任何一层都不得自行改到另一个端口。AGC 配套后端的 `backend` 模式启动 SpacetimeDB、独立 `bgfilter-worker``api-server`,并在复用现有后端前同时检查三者状态及 `/v1/ping``/readyz``/healthz`;worker 缺失时不得把不完整的 API/数据库组合误判为 ready。任一配套服务在启动阶段进入 `failed` 时,外层启动器必须立即报告具体服务和退出原因,不能继续等待前端地址超时。启动器在创建原生窗口前预检最终地址;若竞态中该地址被 AGC Vite、无响应监听器或其它服务占用,一律失败关闭,不复用、也不擅自终止无法证明归属的进程。
Tauri `beforeDevCommand` 默认与客户端构建并行,不能把上述检查只放在 `beforeDevCommand` 内:选定地址上若已有旧 Vite,Tauri 可能先创建加载旧前端的窗口,随后配套后端才因代理不匹配退出。外层启动器会把 Tauri CLI 放入受控进程树;CLI 正常退出、启动失败或收到终止信号后,POSIX 先向保留的 PGID 发送 `SIGTERM`、有界等待后升级 `SIGKILL`Windows 使用 `taskkill /PID <pid> /T /F`。Linux 容器中的孤儿后代退出后可能暂时保留为 zombie,`kill(-PGID, 0)` 仍会返回成功;启动器必须结合 `/proc/<pid>/stat` 判断同组是否还存在非 zombie 成员,不能把等待 PID 1 回收误报为清理失败。配套后端和 Vite 仍由 `start-dev-stack.mjs` 各自持有,退出时同样有界收束,避免只剩客户端、Runner、Cargo 或旧订阅进程。排障时同时核对控制台输出的 AGC Vite 实际地址及其 marker、`.app/dev-stack.json` 的实际 API URL 和进程 cwd;不要把“终端已返回”当成客户端及其 Runner 已退出的证据。

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