完善智能体交互与画布美术生成

为 Supervisor 与部门 Director 接入统一 Interaction Loop 和持久 Runtime 调度
在配置画布 API Key 时强制委派美术 Agent 并复用规范素材
收紧画布生成角色权限、图片内容校验和项目锁并发边界
完善专业 Agent revision 验证、任务恢复与 Provider 兼容处理
补充自主协作、真实画布生成、运行时收束测试及技术文档
This commit is contained in:
AIGameCreator App
2026-07-24 21:44:07 +08:00
parent ddbca15e30
commit 3237ad140a
33 changed files with 2357 additions and 236 deletions
@@ -10,6 +10,7 @@ use std::io::{Seek, SeekFrom};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
mod generation;
mod interaction;
mod prompt;
mod runtime_actions;
mod runtime_driver;
@@ -17,6 +18,7 @@ mod runtime_protocol;
mod runtime_state;
mod runtime_tools;
pub(crate) use generation::*;
pub(crate) use interaction::*;
pub(crate) use prompt::*;
pub(crate) use runtime_actions::*;
pub(crate) use runtime_driver::*;
@@ -12,11 +12,15 @@ mod run_lifecycle;
mod tests;
mod trace;
pub(in crate::agent) use canvas_generation::generate_platform_art_asset_with_options_at;
pub(in crate::agent) use canvas_generation::{
commit_prepared_platform_art_asset_at, request_platform_art_asset_with_options_at,
};
pub(in crate::agent) use draft_validation::validate_closed_game_script_blocks;
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,
@@ -227,7 +227,7 @@ async fn prepare_external_canvas_generation_context(
api_base_url: &str,
api_key: &str,
) -> Result<ExternalCanvasGenerationContext, String> {
let manifest = read_manifest_for_project(root)?;
let manifest = read_existing_manifest_for_project(root)?;
let canvas_name = manifest.name.trim().chars().take(80).collect::<String>();
let canvas_name = if canvas_name.is_empty() {
"未命名游戏原型".to_string()
@@ -344,16 +344,43 @@ pub(crate) async fn generate_platform_art_asset_at(
.await
}
pub(in crate::agent) struct PreparedPlatformArtAssetGeneration {
requested_output_path: Option<String>,
download: CanvasResourceDownload,
canvas_context: ExternalCanvasGenerationContext,
resource_id: Option<String>,
task_id: Option<String>,
asset_object_id: Option<String>,
canvas_project_id: Option<String>,
generated_prompt: Option<String>,
model: Option<String>,
provider: Option<String>,
extension: String,
}
pub(in crate::agent) async fn generate_platform_art_asset_with_options_at(
root: &Path,
prompt: &str,
briefs: &[AgentGroupBrief],
options: &PlatformArtAssetGenerationOptions,
) -> Result<GeneratedPlatformArtAsset, String> {
let prepared =
request_platform_art_asset_with_options_at(root, prompt, briefs, options).await?;
let _lock = acquire_project_write_lock(root, "canvas.asset_generate")?;
advance_agent_runtime_project_revision_locked(root)?;
commit_prepared_platform_art_asset_at(root, prepared, options)
}
pub(in crate::agent) async fn request_platform_art_asset_with_options_at(
root: &Path,
prompt: &str,
briefs: &[AgentGroupBrief],
options: &PlatformArtAssetGenerationOptions,
) -> Result<PreparedPlatformArtAssetGeneration, String> {
enforce_project_permission_policy(root, "canvas.asset_generate")?;
init_local_game_project_at(root, "local-project-draft", "未命名游戏原型")?;
let requested_output =
prepare_platform_art_asset_output_path(root, options.output_path.as_deref())?;
let requested_output_path =
prepare_platform_art_asset_output_path(root, options.output_path.as_deref())?
.map(|(local_path, _)| local_path);
let api_base_url = resolve_canvas_sync_api_base_url(None)?;
let api_key = resolve_canvas_sync_api_key(None)?;
let client = reqwest::Client::new();
@@ -412,6 +439,9 @@ pub(in crate::agent) async fn generate_platform_art_asset_with_options_at(
let asset_object_id = json_string_field(generated, "assetObjectId")
.or_else(|| json_string_field(resource, "assetObjectId"))
.or_else(|| json_string_field(asset, "assetObjectId"));
let canvas_project_id = json_string_field(resource, "projectId")
.or_else(|| json_string_field(generated, "projectId"))
.or_else(|| Some(canvas_context.project_id.clone()));
let generated_prompt = json_string_field(generated, "actualPrompt")
.or_else(|| json_string_field(generated, "prompt"))
.or_else(|| json_string_field(resource, "actualPrompt"))
@@ -422,13 +452,60 @@ pub(in crate::agent) async fn generate_platform_art_asset_with_options_at(
.or_else(|| json_string_field(resource, "provider"));
let source_hint = json_string_field(generated, "objectKey")
.or_else(|| json_string_field(generated, "imageSrc"));
let extension = infer_file_extension(source_hint.as_deref(), &download.media_type);
let extension = infer_file_extension(source_hint.as_deref(), &download.media_type).to_string();
Ok(PreparedPlatformArtAssetGeneration {
requested_output_path,
download,
canvas_context,
resource_id,
task_id,
asset_object_id,
canvas_project_id,
generated_prompt,
model,
provider,
extension,
})
}
#[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,
options: &PlatformArtAssetGenerationOptions,
) -> Result<GeneratedPlatformArtAsset, String> {
let PreparedPlatformArtAssetGeneration {
requested_output_path,
download,
canvas_context,
resource_id,
task_id,
asset_object_id,
canvas_project_id,
generated_prompt,
model,
provider,
extension,
} = prepared;
let file_stem = resource_id
.as_deref()
.or(task_id.as_deref())
.unwrap_or("platform-art");
let (local_path, mut absolute_path) = match requested_output {
Some((local_path, absolute_path)) => {
let (local_path, mut absolute_path) = match requested_output_path {
Some(requested_output_path) => {
let (local_path, absolute_path) =
prepare_platform_art_asset_output_path(root, Some(&requested_output_path))?
.ok_or_else(|| "图片生成 outputPath 不能为空".to_string())?;
if !platform_art_asset_output_extension_matches(&local_path, &extension) {
return Err(format!(
"图片生成结果格式为 {extension},与 outputPath 扩展名不一致"
@@ -468,9 +545,6 @@ pub(in crate::agent) async fn generate_platform_art_asset_with_options_at(
format!("写入平台生成素材失败:{}: {error}", absolute_path.display())
})?;
drop(output);
let canvas_project_id = json_string_field(resource, "projectId")
.or_else(|| json_string_field(generated, "projectId"))
.or_else(|| Some(canvas_context.project_id.clone()));
let registered = match register_local_asset_entry(
root,
&local_path,
@@ -0,0 +1,391 @@
use super::*;
const AGENT_INTERACTION_EXECUTE_TOOL: &str = "runtime_execute";
const AGENT_INTERACTION_RESUME_TOOL: &str = "runtime_resume";
const AGENT_INTERACTION_PROJECT_LOCATION_TOOL: &str = "project_location";
const AGENT_INTERACTION_MAX_OUTPUT_TOKENS: u32 = 1_200;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum AgentInteractionToolKind {
Execute,
Resume,
ProjectLocation,
}
#[derive(Clone, Copy)]
struct AgentInteractionToolDefinition {
kind: AgentInteractionToolKind,
name: &'static str,
description: &'static str,
}
const AGENT_INTERACTION_TOOL_DEFINITIONS: &[AgentInteractionToolDefinition] = &[
AgentInteractionToolDefinition {
kind: AgentInteractionToolKind::Execute,
name: AGENT_INTERACTION_EXECUTE_TOOL,
description: "仅当用户明确要求执行需要读取、修改、生成、测试或调用项目工具的工作时,提交用户原始消息给持久 Runtime。否定、假设、解释、咨询或需求仍不明确时不要调用。",
},
AgentInteractionToolDefinition {
kind: AgentInteractionToolKind::Resume,
name: AGENT_INTERACTION_RESUME_TOOL,
description: "仅当用户明确要求继续或恢复当前 Session 中未完成的持久 Runtime 时调用。",
},
AgentInteractionToolDefinition {
kind: AgentInteractionToolKind::ProjectLocation,
name: AGENT_INTERACTION_PROJECT_LOCATION_TOOL,
description: "当用户询问当前项目目录或项目在哪里时调用;宿主会直接返回真实本地目录,不要猜测路径。",
},
];
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) enum AgentInteractionAction {
Reply(String),
Execute,
Resume,
ProjectLocation,
}
impl AgentInteractionAction {
pub(crate) fn label(&self) -> &'static str {
match self {
Self::Reply(_) => "reply",
Self::Execute => "execute",
Self::Resume => "resume",
Self::ProjectLocation => "project_location",
}
}
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields, tag = "action", rename_all = "snake_case")]
enum AgentInteractionTextEnvelope {
Reply { reply: String },
Execute,
Resume,
ProjectLocation,
}
pub(crate) fn game_creator_agent_uses_interaction_kernel(agent_id: &str) -> bool {
if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID {
return true;
}
game_creator_agent_role_definition(agent_id).is_some_and(|(_group, role)| role.id == "director")
}
fn agent_interaction_function_tools() -> Vec<platform_llm::LlmFunctionTool> {
AGENT_INTERACTION_TOOL_DEFINITIONS
.iter()
.map(|definition| {
let parameters = match definition.kind {
AgentInteractionToolKind::Execute
| AgentInteractionToolKind::Resume
| AgentInteractionToolKind::ProjectLocation => {
serde_json::json!({
"type": "object",
"properties": {},
"additionalProperties": false
})
}
};
platform_llm::LlmFunctionTool::new(definition.name, definition.description, parameters)
.with_strict(true)
})
.collect()
}
fn agent_interaction_system_prompt(agent_id: &str, native_tools: bool) -> String {
let role_prompt = if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID {
game_creator_project_supervisor_chat_system_prompt()
} else {
game_creator_role_agent_chat_system_prompt()
};
let protocol = if native_tools {
"普通问答、身份说明、架构解释、方案讨论和必要澄清直接用自然语言回复。只有确实需要宿主持久能力时才调用一个 function tool,调用工具时不要同时输出回复文本。不要根据单个关键词决定是否执行,要理解整句的否定、假设、范围和上下文。"
} else {
"你必须只输出一个 JSON 对象,不要代码块或额外文字。允许的结构为:{\"action\":\"reply\",\"reply\":\"自然语言回复\"}、{\"action\":\"execute\"}、{\"action\":\"resume\"}、{\"action\":\"project_location\"}。不要根据单个关键词决定 action,要理解整句的否定、假设、范围和上下文。"
};
format!(
"{role_prompt}\n\n你现在位于统一的 Agent interaction loop。{protocol} 高影响请求仍不明确时直接追问,不要擅自启动 Runtime。"
)
}
fn build_agent_interaction_request_for_session(
root: &Path,
agent_id: &str,
session_id: &str,
prompt: &str,
) -> Result<(GameCreatorLlmConfig, String, LlmRunRequest, bool), String> {
let prompt = prompt.trim();
if prompt.is_empty() {
return Err("交互内容不能为空".to_string());
}
if !game_creator_agent_uses_interaction_kernel(agent_id) {
return Err(format!("Agent 不启用交互决策层:{agent_id}"));
}
let (llm, config_path, context) =
build_game_creator_role_agent_context_for_session(root, agent_id, Some(session_id))?;
let api_kind = parse_game_creator_llm_api_kind(&llm.api_kind)?;
let native_tools = api_kind != LlmApiKind::Anthropic;
let user_prompt = if context.trim().is_empty() {
format!("用户这轮输入:\n{prompt}")
} else {
format!(
"项目上下文如下。只把它当作背景,不要逐字复述。\n\n{context}\n\n用户这轮输入:\n{prompt}"
)
};
let mut request = LlmRunRequest::new(vec![
LlmMessage::system(agent_interaction_system_prompt(agent_id, native_tools)),
LlmMessage::user(user_prompt),
])
.with_api_kind(api_kind)
.with_max_output_tokens(AGENT_INTERACTION_MAX_OUTPUT_TOKENS);
if native_tools {
request = request
.with_function_tools(agent_interaction_function_tools())
.with_tool_choice(platform_llm::LlmToolChoice::Auto);
}
Ok((llm, config_path, request, native_tools))
}
pub(crate) async fn decide_game_creator_agent_interaction_turn_for_session_at<F>(
root: &Path,
agent_id: &str,
session_id: &str,
prompt: &str,
mut on_delta: F,
) -> Result<AgentInteractionAction, String>
where
F: FnMut(&platform_llm::LlmStreamDelta),
{
let (llm, config_path, request, native_tools) =
build_agent_interaction_request_for_session(root, agent_id, session_id, prompt)?;
let client = build_game_creator_agent_runtime_llm_client(&llm, &config_path)?;
let response = if native_tools && llm.stream {
let fallback_request = request.clone();
match client.stream_run(request, |delta| on_delta(delta)).await {
Ok(response) => response,
Err(error)
if matches!(
error.kind(),
platform_llm::LlmErrorKind::StreamUnavailable
| platform_llm::LlmErrorKind::EmptyResponse
| platform_llm::LlmErrorKind::Deserialize
) =>
{
client.run(fallback_request).await.map_err(|fallback_error| {
format!(
"{config_path} Agent interaction 流式协议不可用且普通请求回退失败:流式错误:{error};普通请求错误:{fallback_error}"
)
})?
}
Err(error) => {
return Err(format!(
"{config_path} Agent interaction 调用 LLM 失败:{error}"
));
}
}
} else {
client
.run(request)
.await
.map_err(|error| format!("{config_path} Agent interaction 调用 LLM 失败:{error}"))?
};
parse_agent_interaction_response(&response)
}
fn parse_agent_interaction_response(
response: &platform_llm::LlmRunResponse,
) -> Result<AgentInteractionAction, String> {
if response.tool_calls.len() > 1 {
return Err("Agent interaction 一轮最多只能选择一个宿主工具".to_string());
}
if let Some(call) = response.tool_calls.first() {
let definition = AGENT_INTERACTION_TOOL_DEFINITIONS
.iter()
.find(|definition| definition.name == call.name)
.ok_or_else(|| format!("Agent interaction 返回未知工具:{}", call.name))?;
return match definition.kind {
AgentInteractionToolKind::Execute => {
let arguments = serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(
call.arguments.as_str(),
)
.map_err(|error| format!("runtime_execute 参数无效:{error}"))?;
if !arguments.is_empty() {
return Err("runtime_execute 不接受参数".to_string());
}
Ok(AgentInteractionAction::Execute)
}
AgentInteractionToolKind::Resume => {
validate_empty_interaction_tool_arguments(call)?;
Ok(AgentInteractionAction::Resume)
}
AgentInteractionToolKind::ProjectLocation => {
validate_empty_interaction_tool_arguments(call)?;
Ok(AgentInteractionAction::ProjectLocation)
}
};
}
let text = strip_llm_thinking_blocks(response.text.as_str());
if let Some(payload) = extract_json_payload(text.as_str()) {
if let Ok(envelope) = serde_json::from_str::<AgentInteractionTextEnvelope>(payload) {
return match envelope {
AgentInteractionTextEnvelope::Reply { reply } => {
let reply = reply.trim();
if reply.is_empty() {
Err("Agent interaction.reply 不能为空".to_string())
} else {
Ok(AgentInteractionAction::Reply(reply.to_string()))
}
}
AgentInteractionTextEnvelope::Execute => Ok(AgentInteractionAction::Execute),
AgentInteractionTextEnvelope::Resume => Ok(AgentInteractionAction::Resume),
AgentInteractionTextEnvelope::ProjectLocation => {
Ok(AgentInteractionAction::ProjectLocation)
}
};
}
}
if text.is_empty() {
return Err("Agent interaction 未返回回复或工具调用".to_string());
}
Ok(AgentInteractionAction::Reply(text))
}
fn validate_empty_interaction_tool_arguments(
call: &platform_llm::LlmToolCall,
) -> Result<(), String> {
let arguments =
serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(&call.arguments)
.map_err(|error| format!("{} 参数无效:{error}", call.name))?;
if !arguments.is_empty() {
return Err(format!("{} 不接受参数", call.name));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn response(
text: &str,
tool_calls: Vec<platform_llm::LlmToolCall>,
) -> platform_llm::LlmRunResponse {
platform_llm::LlmRunResponse {
provider: LlmProvider::OpenAiCompatible,
model: "interaction-test".to_string(),
text: text.to_string(),
finish_reason: Some("stop".to_string()),
response_id: Some("interaction-response".to_string()),
usage: None,
tool_calls,
}
}
fn tool_call(name: &str, arguments: &str) -> platform_llm::LlmToolCall {
platform_llm::LlmToolCall {
id: "call-interaction".to_string(),
name: name.to_string(),
arguments: arguments.to_string(),
}
}
#[test]
fn interaction_kernel_is_limited_to_supervisor_and_department_directors() {
for agent_id in [
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
"design-director",
"balance-director",
"art-director",
"audio-director",
"code-director",
"publish-strategy",
] {
assert!(
game_creator_agent_uses_interaction_kernel(agent_id),
"{agent_id}"
);
}
for agent_id in ["design-foundation", "art-asset-plan", "code-prototype"] {
assert!(
!game_creator_agent_uses_interaction_kernel(agent_id),
"{agent_id}"
);
}
}
#[test]
fn interaction_response_uses_natural_text_as_direct_reply() {
assert_eq!(
parse_agent_interaction_response(&response("我是项目总控。", Vec::new())).unwrap(),
AgentInteractionAction::Reply("我是项目总控。".to_string())
);
}
#[test]
fn interaction_response_dispatches_registered_execute_tool() {
assert_eq!(
parse_agent_interaction_response(&response(
"",
vec![tool_call(AGENT_INTERACTION_EXECUTE_TOOL, "{}",)],
))
.unwrap(),
AgentInteractionAction::Execute
);
}
#[test]
fn interaction_response_supports_text_protocol_for_non_native_provider() {
assert_eq!(
parse_agent_interaction_response(&response(
r#"{"action":"project_location"}"#,
Vec::new(),
))
.unwrap(),
AgentInteractionAction::ProjectLocation
);
}
#[test]
fn interaction_response_rejects_multiple_host_actions() {
let error = parse_agent_interaction_response(&response(
"",
vec![
tool_call(AGENT_INTERACTION_RESUME_TOOL, "{}"),
tool_call(AGENT_INTERACTION_PROJECT_LOCATION_TOOL, "{}"),
],
))
.expect_err("multiple actions must fail closed");
assert!(error.contains("最多只能选择一个"));
}
#[test]
fn interaction_registry_derives_unique_strict_function_tools() {
let tools = agent_interaction_function_tools();
assert_eq!(tools.len(), AGENT_INTERACTION_TOOL_DEFINITIONS.len());
let names = tools
.iter()
.map(|tool| tool.name.as_str())
.collect::<std::collections::BTreeSet<_>>();
assert_eq!(names.len(), tools.len());
assert!(tools.iter().all(|tool| tool.strict));
assert!(tools.iter().all(|tool| {
tool.parameters["additionalProperties"] == serde_json::json!(false)
&& tool.parameters["properties"] == serde_json::json!({})
}));
}
#[test]
fn interaction_response_rejects_arguments_for_host_selected_action() {
let error = parse_agent_interaction_response(&response(
"",
vec![tool_call(
AGENT_INTERACTION_PROJECT_LOCATION_TOOL,
r#"{"path":"/tmp"}"#,
)],
))
.expect_err("host facts must not accept model-selected arguments");
assert!(error.contains("不接受参数"));
}
}
@@ -489,7 +489,7 @@ pub(crate) fn game_creator_agent_runtime_tool_plan_system_prompt_for_agent(
}
if agent_id == "art-asset-plan" {
return format!(
"{prompt}\n\n你负责首版美术素材实际生成。资产清单和美术计划只是中间结果;最终必须调用 canvas.asset_generate 生成核心素材图并登记到 assets/art-spritesheet.pngassetKind=art-spritesheet、assetLabel=游戏首版核心美术素材。随后用 asset.list 核对 manifest 已登记该 image/* 资产。图片生成未配置、待确认或失败时不得提交最终回复,也不得把计划写完当成 completed。"
"{prompt}\n\n你负责首版美术素材实际生成。资产清单和美术计划只是中间结果;最终必须调用 canvas.asset_generate 生成核心素材图并登记到 assets/art-spritesheet.pngassetKind=art-spritesheet、assetLabel=游戏首版核心美术素材。成功执行 canvas.asset_generate 后,该动作本身就是当前 revision 的验证;随后只需调用 asset.list 回读并核对 manifest 已登记该 image/* 资产,然后回复。不得运行 game.static_smoke 或 preview.validate,也不得编辑 game/index.html。图片生成未配置、待确认或失败时不得提交最终回复,也不得把计划写完当成 completed。"
);
}
if agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID {
@@ -257,6 +257,7 @@ pub(in crate::agent) fn validate_agent_runtime_autonomous_initial_collaboration_
) -> Result<(), String> {
let mut code_prototype = None;
let mut quality_review = None;
let mut art_asset_plan = None;
for action in &plan.actions {
let Some(input) = autonomous_initial_delegate_input(action)? else {
continue;
@@ -277,6 +278,7 @@ pub(in crate::agent) fn validate_agent_runtime_autonomous_initial_collaboration_
let slot = match target_agent_id {
"code-prototype" => &mut code_prototype,
AGENT_RUNTIME_QUALITY_REVIEW_AGENT_ID => &mut quality_review,
"art-asset-plan" => &mut art_asset_plan,
_ => continue,
};
if slot.replace(input).is_some() {
@@ -339,6 +341,31 @@ pub(in crate::agent) fn validate_agent_runtime_autonomous_initial_collaboration_
"首批 quality-review 的 expectedArtifacts 必须为 []",
));
}
if let Some(art_asset_plan) = art_asset_plan {
let art_task = autonomous_initial_delegate_task(art_asset_plan, "art-asset-plan")?;
let art_criteria = agent_runtime_tool_input_string_list(
&serde_json::Value::Object(art_asset_plan.clone()),
&["acceptanceCriteria", "acceptance_criteria", "criteria"],
);
if std::iter::once(art_task.as_str())
.chain(art_criteria.iter().map(String::as_str))
.any(agent_runtime_task_explicitly_requires_read_only_delivery)
{
return Err(autonomous_initial_collaboration_contract_error(
"首批 art-asset-plan 必须是非只读美术生成任务",
));
}
let art_artifacts =
autonomous_initial_delegate_expected_artifacts(art_asset_plan, "art-asset-plan")?;
if !art_artifacts
.iter()
.any(|path| path == "assets/art-spritesheet.png")
{
return Err(autonomous_initial_collaboration_contract_error(
"首批 art-asset-plan 的 expectedArtifacts 必须包含 assets/art-spritesheet.png",
));
}
}
Ok(())
}
@@ -1243,18 +1243,35 @@ pub(in crate::agent) fn evaluate_project_verification_completion_at_locked(
if !gate.requires_verification {
return Ok(None);
}
let required_verified_revision = if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID {
revision.revision
} else {
gate.mutation_revision.unwrap_or(revision.revision)
};
let verification_covers_required_revision =
if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID {
gate.verified_revision == Some(required_verified_revision)
} else {
gate.verified_revision
.is_some_and(|verified_revision| verified_revision >= required_verified_revision)
};
if gate.last_verification_status.as_deref() != Some(AGENT_RUNTIME_VERIFICATION_STATUS_PASSED)
|| gate.verified_revision != Some(revision.revision)
|| !verification_covers_required_revision
{
let verified_revision = gate
.verified_revision
.map(|value| value.to_string())
.unwrap_or_else(|| "none".to_string());
return Ok(Some(agent_runtime_verification_blocker(
"项目在当前 revision 上尚未通过验证,不能把任务标记为完成",
if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID {
"项目在当前 revision 上尚未通过验证,不能把任务标记为完成"
} else {
"当前专业 run 的最后一次修改尚未通过验证,不能把任务标记为完成"
},
format!(
"currentRevision={}, mutationRevision={}, verifiedRevision={};请重新执行并通过 project.verify、可验证 command.exec 或 game.static_smoke。",
"currentRevision={}, requiredVerifiedRevision={}, mutationRevision={}, verifiedRevision={};请重新执行并通过 project.verify、可验证 command.exec 或 game.static_smoke。",
revision.revision,
required_verified_revision,
gate.mutation_revision
.map(|value| value.to_string())
.unwrap_or_else(|| "none".to_string()),
@@ -714,7 +714,7 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at
== AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD
{
format!(
"上一条输出不符合工具计划协议:{protocol_error}\n本次修复的原生工具目录只保留首批协作工具。必须在同一响应一次性建立完整首批合同:code-prototype 必须是非只读实现任务且 expectedArtifacts 包含 game/index.htmlquality-review 的 task 必须显式声明只读、不得修改项目,且 expectedArtifacts 必须为 []。两者都使用 agent.delegaterepairOfDelegationId=null、runId=null;如当前 policy 还要求 isolated,再在同批补齐 agent.spawn_isolated。不得更新计划、读取、搜索、查询状态、修改项目或返回最终回复。不要解释,不要 markdown,不要代码围栏。"
"上一条输出不符合工具计划协议:{protocol_error}\n本次修复的原生工具目录只保留首批协作工具。必须在同一响应一次性建立完整首批合同:code-prototype 必须是非只读实现任务且 expectedArtifacts 包含 game/index.htmlquality-review 的 task 必须显式声明只读、不得修改项目,且 expectedArtifacts 必须为 [];如当前 policy 的 requiredStaticAgentIds 包含 art-asset-plan,还必须加入非只读美术生成委派且 expectedArtifacts 包含 assets/art-spritesheet.png。所有静态委派都使用 agent.delegaterepairOfDelegationId=null、runId=null;如当前 policy 还要求 isolated,再在同批补齐 agent.spawn_isolated。不得更新计划、读取、搜索、查询状态、修改项目或返回最终回复。不要解释,不要 markdown,不要代码围栏。"
)
} else {
format!(
@@ -46,6 +46,10 @@ pub(crate) fn agent_runtime_executable_tools() -> Vec<&'static str> {
]
}
fn autonomous_game_build_agent_can_generate_canvas_asset(agent_id: &str) -> bool {
matches!(agent_id.trim(), "design-foundation" | "art-asset-plan")
}
pub(in crate::agent) fn agent_runtime_tool_policy_snapshot_at(
root: &Path,
agent_id: &str,
@@ -132,6 +136,23 @@ pub(crate) fn agent_runtime_tool_policy_snapshot_for_run_at(
.denied_tools
.push(GAME_CREATOR_USER_INPUT_REQUEST_TOOL.to_string());
}
if !autonomous_game_build_agent_can_generate_canvas_asset(agent_id) {
snapshot
.auto_tools
.retain(|tool| tool != "canvas.asset_generate");
snapshot
.confirm_tools
.retain(|tool| tool != "canvas.asset_generate");
if !snapshot
.denied_tools
.iter()
.any(|tool| tool == "canvas.asset_generate")
{
snapshot
.denied_tools
.push("canvas.asset_generate".to_string());
}
}
for tool in agent_runtime_executable_tools() {
let Some(command_id) = game_creator_agent_runtime_tool_command_id(tool) else {
continue;
@@ -113,6 +113,7 @@ pub(super) const AGENT_RUNTIME_AUTONOMOUS_GAME_BUILD_AUTO_COMMAND_IDS: &[&str] =
"project.verify",
"command.run_limited",
"preview.validate",
"canvas.asset_generate",
"agent.delegate",
"agent.spawn_isolated",
"agent.run_status",
@@ -140,6 +140,7 @@ pub(in crate::agent) fn validate_agent_runtime_verification_gate(
| "project.restore"
| "command.exec"
| "command.start"
| "canvas.asset_generate"
)
}) {
return Err("Agent Runtime verification gate 的修改工具无效".to_string());
@@ -147,7 +148,11 @@ pub(in crate::agent) fn validate_agent_runtime_verification_gate(
if gate.last_verification_tool.as_deref().is_some_and(|tool| {
!matches!(
tool,
"project.verify" | "game.static_smoke" | "command.exec" | "preview.validate"
"project.verify"
| "game.static_smoke"
| "command.exec"
| "preview.validate"
| "canvas.asset_generate"
)
}) {
return Err("Agent Runtime verification gate 的验证工具无效".to_string());
@@ -3280,26 +3280,65 @@ pub(crate) fn read_all_game_creator_agent_runtime_tasks(
let mut records = Vec::new();
match File::open(path) {
Ok(file) => {
for line in BufReader::new(file).lines() {
let line = line.map_err(|error| {
let mut reader = BufReader::new(file);
let mut line_number = 0usize;
loop {
let mut line = Vec::new();
let bytes_read = reader.read_until(b'\n', &mut line).map_err(|error| {
format!("读取 Agent Runtime 任务失败:{}: {error}", path.display())
})?;
let line = line.trim();
if bytes_read == 0 {
break;
}
line_number += 1;
let terminated = line.last() == Some(&b'\n');
if terminated {
line.pop();
if line.last() == Some(&b'\r') {
line.pop();
}
}
let line = match std::str::from_utf8(&line) {
Ok(line) => line.trim(),
Err(error) if !terminated && error.error_len().is_none() => break,
Err(error) => {
return Err(format!(
"读取 Agent Runtime 任务失败:{}: 第 {line_number} 行不是有效 UTF-8{error}",
path.display()
));
}
};
if line.is_empty() {
continue;
}
match serde_json::from_str::<AgentRuntimeTaskRecord>(line) {
Ok(mut record) => {
normalize_game_creator_agent_runtime_task(&mut record);
validate_game_creator_agent_runtime_task_goal_binding(&record).map_err(
|error| {
format!("读取 Agent Runtime 任务失败:{}: {error}", path.display())
},
)?;
records.push(record);
let mut record = match serde_json::from_str::<AgentRuntimeTaskRecord>(line) {
Ok(record) => record,
Err(error) if !terminated && error.is_eof() => break,
Err(error) => {
return Err(format!(
"读取 Agent Runtime 任务失败:{}: {line_number} 行 JSON 无效:{error}",
path.display()
));
}
Err(_) => continue,
}
};
normalize_game_creator_agent_runtime_task(&mut record);
validate_game_creator_agent_runtime_task_status_phase(&record).map_err(
|error| {
format!(
"读取 Agent Runtime 任务失败:{}: 第 {line_number} 行:{error}",
path.display()
)
},
)?;
validate_game_creator_agent_runtime_task_goal_binding(&record).map_err(
|error| {
format!(
"读取 Agent Runtime 任务失败:{}: 第 {line_number} 行:{error}",
path.display()
)
},
)?;
records.push(record);
}
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
@@ -3313,6 +3352,69 @@ pub(crate) fn read_all_game_creator_agent_runtime_tasks(
Ok(records)
}
fn validate_game_creator_agent_runtime_task_status_phase(
record: &AgentRuntimeTaskRecord,
) -> Result<(), String> {
if !matches!(
record.status.as_str(),
"idle"
| "pending"
| "running"
| "waiting-for-confirmation"
| "waiting-for-user-input"
| "paused"
| "pausing"
| "cancelling"
| "cancelled"
| "completed"
| "failed"
| "needs-reconciliation"
) {
return Err(format!(
"Agent Runtime task status 无效:runId={} status={}",
record.run_id, record.status
));
}
if !matches!(
record.phase.as_str(),
"idle"
| "queued"
| "planning"
| "llm"
| "action"
| "provider-action-batch"
| "observation"
| "response"
| "finalizing"
| "waiting-for-confirmation"
| "waiting-for-user-input"
| "waiting-for-provider-retry"
| "waiting-for-process-session"
| "waiting-for-isolated-join"
| "waiting-for-delegate-receipts"
| "waiting-for-visual-asset"
| "brief"
| "paused"
| "pausing"
| "cancelling"
| "cancelled"
| "completed"
| "failed"
| "budget-exhausted"
| "needs-reconciliation"
| "completion-contract-failed"
| "conversation-write-failed"
| "parent-terminal"
| "parent-link-missing"
) {
return Err(format!(
"Agent Runtime task phase 无效:runId={} phase={}",
record.run_id, record.phase
));
}
Ok(())
}
pub(super) fn latest_game_creator_agent_runtime_tasks(
records: Vec<AgentRuntimeTaskRecord>,
) -> Vec<AgentRuntimeTaskRecord> {
@@ -531,7 +531,33 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio
detail: None,
};
}
let _lock = match acquire_project_write_lock(root, "canvas.asset_generate") {
if let Some(blocker) =
supervisor_orchestrator_mutation_block_at(root, agent_id, run_id, "canvas.asset_generate")
{
return blocker;
}
let prepared = match request_platform_art_asset_with_options_at(
root,
prompt.trim(),
&[],
&options,
)
.await
{
Ok(prepared) => prepared,
Err(error) => {
return AgentRuntimeToolObservation {
tool: "canvas.asset_generate".to_string(),
status: "failed".to_string(),
summary: redact_agent_runtime_project_paths(root, &error, 240),
detail: None,
};
}
};
let _lock = match acquire_game_creator_agent_runtime_project_write_lock_with_wait(
root,
"canvas.asset_generate",
) {
Ok(lock) => lock,
Err(error) => {
return AgentRuntimeToolObservation {
@@ -547,20 +573,47 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio
{
return blocker;
}
if let Err(error) = advance_agent_runtime_project_revision_locked(root) {
return agent_runtime_revision_advance_failure_observation(
root,
"canvas.asset_generate",
&error,
);
}
match generate_platform_art_asset_with_options_at(root, prompt.trim(), &[], &options).await {
let mutation_revision = match prepare_agent_runtime_project_mutation_locked(
root,
agent_id,
run_id,
"canvas.asset_generate",
) {
Ok(revision) => revision,
Err(error) => {
return agent_runtime_revision_advance_failure_observation(
root,
"canvas.asset_generate",
&error,
);
}
};
match commit_prepared_platform_art_asset_at(root, prepared, &options) {
Ok(generated) => {
let verification = begin_agent_runtime_project_verification_locked(
root,
agent_id,
run_id,
"canvas.asset_generate",
)
.and_then(|(revision, gate)| {
finish_agent_runtime_project_verification_locked(root, &revision, gate, true)
});
if let Err(error) = verification {
return AgentRuntimeToolObservation {
tool: "canvas.asset_generate".to_string(),
status: "failed".to_string(),
summary: "美术素材已生成,但无法提交当前 revision 的验证凭证".to_string(),
detail: Some(redact_agent_runtime_project_paths(root, &error, 500)),
};
}
let _ = append_agent_db_record(
root,
serde_json::json!({
"recordType": "agent.runtime.canvas.asset_generate",
"agentId": agent_id,
"runId": run_id,
"verifiedRevision": mutation_revision,
"assetId": generated.asset.id.clone(),
"localPath": generated.asset.local_path.clone(),
"resourceId": generated.resource_id.clone(),
@@ -574,7 +627,7 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio
status: "ok".to_string(),
summary: format!("已生成美术素材:{}", generated.asset.local_path),
detail: Some(format!(
"assetId={}, localPath={}, resourceId={}, assetObjectId={}, taskId={}, model={}",
"assetId={}, localPath={}, resourceId={}, assetObjectId={}, taskId={}, model={}, verifiedRevision={mutation_revision}",
generated.asset.id,
generated.asset.local_path,
generated.resource_id.as_deref().unwrap_or(""),
@@ -1,5 +1,9 @@
use super::*;
fn autonomous_game_build_agent_can_execute_canvas_asset_generate(agent_id: &str) -> bool {
matches!(agent_id.trim(), "design-foundation" | "art-asset-plan")
}
pub(in crate::agent) fn refresh_game_creator_agent_runtime_tool_policy(
root: &Path,
state: &mut AgentRuntimeState,
@@ -26,6 +30,9 @@ pub(crate) fn game_creator_agent_runtime_tool_policy_rule_for_run(
command_id: &str,
) -> Option<AgentRuntimeToolPolicyBlock> {
let blocked = game_creator_agent_runtime_tool_policy_rule(root, agent_id, command_id);
if matches!(blocked, Some(AgentRuntimeToolPolicyBlock::Denied(_))) {
return blocked;
}
let (run_profile, _) = match agent_runtime_run_profile_identity_at(
root,
agent_id,
@@ -36,6 +43,14 @@ pub(crate) fn game_creator_agent_runtime_tool_policy_rule_for_run(
Ok(identity) => identity,
Err(error) => return Some(AgentRuntimeToolPolicyBlock::Denied(error)),
};
if run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD
&& command_id == "canvas.asset_generate"
&& !autonomous_game_build_agent_can_execute_canvas_asset_generate(agent_id)
{
return Some(AgentRuntimeToolPolicyBlock::Denied(format!(
"自主构建模式只允许 design-foundation 或 art-asset-plan 执行:{command_id}"
)));
}
match blocked {
Some(AgentRuntimeToolPolicyBlock::RequiresConfirmation(_))
if run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD =>
@@ -292,3 +307,156 @@ pub(in crate::agent) fn validate_agent_runtime_pending_action_after_lock(
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
fn bind_delegated_autonomous_run(
root: &Path,
parent_run_id: &str,
agent_id: &str,
) -> AgentRuntimeRunProfileBinding {
bind_game_creator_agent_runtime_run_profile_at(
root,
agent_id,
&format!("policy-{agent_id}-run"),
"agent-delegate",
None,
Some(&AgentRuntimeTaskLink {
parent_agent_id: Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string()),
parent_run_id: Some(parent_run_id.to_string()),
delegation_id: Some(format!("policy-{agent_id}-delegation")),
}),
)
.expect("bind delegated autonomous run")
}
#[test]
fn autonomous_canvas_execution_gate_allows_only_visual_agents_and_preserves_explicit_denies() {
let temporary = tempfile::tempdir().expect("create canvas host gate root");
let root = temporary.path().join("project");
init_local_game_project_at(&root, "project-canvas-host-gate", "画布宿主门禁项目")
.expect("project init");
let supervisor_run_id = "policy-supervisor-run";
let supervisor_binding = bind_game_creator_agent_runtime_run_profile_at(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
supervisor_run_id,
AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE,
Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD),
None,
)
.expect("bind supervisor autonomous run");
let supervisor_block = game_creator_agent_runtime_tool_policy_rule_for_run(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
supervisor_run_id,
Some(&supervisor_binding.profile),
Some(&supervisor_binding.binding_fingerprint),
"canvas.asset_generate",
);
assert!(matches!(
supervisor_block,
Some(AgentRuntimeToolPolicyBlock::Denied(reason))
if reason.contains("只允许 design-foundation 或 art-asset-plan")
));
let mut bindings = BTreeMap::new();
for agent_id in [
"code-prototype",
"quality-review",
"design-foundation",
"art-asset-plan",
] {
bindings.insert(
agent_id,
bind_delegated_autonomous_run(&root, supervisor_run_id, agent_id),
);
}
for agent_id in ["code-prototype", "quality-review"] {
let binding = bindings.get(agent_id).expect("non-visual binding");
let blocked = game_creator_agent_runtime_tool_policy_rule_for_run(
&root,
agent_id,
&format!("policy-{agent_id}-run"),
Some(&binding.profile),
Some(&binding.binding_fingerprint),
"canvas.asset_generate",
);
assert!(matches!(
blocked,
Some(AgentRuntimeToolPolicyBlock::Denied(reason))
if reason.contains("只允许 design-foundation 或 art-asset-plan")
));
}
for agent_id in ["design-foundation", "art-asset-plan"] {
let binding = bindings.get(agent_id).expect("visual binding");
assert!(game_creator_agent_runtime_tool_policy_rule_for_run(
&root,
agent_id,
&format!("policy-{agent_id}-run"),
Some(&binding.profile),
Some(&binding.binding_fingerprint),
"canvas.asset_generate",
)
.is_none());
}
write_project_permission_policy_at(
&root,
ProjectPermissionPolicy {
denied_commands: vec!["canvas.asset_generate".to_string()],
confirm_commands: ProjectPermissionPolicy::default().confirm_commands,
agent_policies: BTreeMap::new(),
},
)
.expect("write project deny");
let art_binding = bindings.get("art-asset-plan").expect("art binding");
assert!(matches!(
game_creator_agent_runtime_tool_policy_rule_for_run(
&root,
"art-asset-plan",
"policy-art-asset-plan-run",
Some(&art_binding.profile),
Some(&art_binding.binding_fingerprint),
"canvas.asset_generate",
),
Some(AgentRuntimeToolPolicyBlock::Denied(reason))
if reason.contains("项目权限策略拒绝执行")
));
let mut agent_policies = BTreeMap::new();
agent_policies.insert(
"design-foundation".to_string(),
ProjectAgentPermissionPolicy {
denied_commands: vec!["canvas.asset_generate".to_string()],
confirm_commands: Vec::new(),
},
);
write_project_permission_policy_at(
&root,
ProjectPermissionPolicy {
denied_commands: Vec::new(),
confirm_commands: ProjectPermissionPolicy::default().confirm_commands,
agent_policies,
},
)
.expect("write agent deny");
let design_binding = bindings.get("design-foundation").expect("design binding");
assert!(matches!(
game_creator_agent_runtime_tool_policy_rule_for_run(
&root,
"design-foundation",
"policy-design-foundation-run",
Some(&design_binding.profile),
Some(&design_binding.binding_fingerprint),
"canvas.asset_generate",
),
Some(AgentRuntimeToolPolicyBlock::Denied(reason))
if reason.contains("Agent 权限策略拒绝执行")
));
}
}
@@ -346,29 +346,120 @@ pub(crate) struct CanvasResourceDownload {
pub(crate) media_type: String,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum CanvasImageFormat {
Png,
Jpeg,
Webp,
Gif,
}
impl CanvasImageFormat {
fn from_media_type(media_type: &str) -> Option<Self> {
match media_type {
"image/png" => Some(Self::Png),
"image/jpeg" | "image/jpg" => Some(Self::Jpeg),
"image/webp" => Some(Self::Webp),
"image/gif" => Some(Self::Gif),
_ => None,
}
}
fn from_source_hint(source_hint: Option<&str>) -> Option<Self> {
let source = source_hint?
.split(['?', '#'])
.next()
.unwrap_or_default()
.to_ascii_lowercase();
match Path::new(&source)
.extension()
.and_then(|extension| extension.to_str())
{
Some("png") => Some(Self::Png),
Some("jpg" | "jpeg") => Some(Self::Jpeg),
Some("webp") => Some(Self::Webp),
Some("gif") => Some(Self::Gif),
_ => None,
}
}
fn media_type(self) -> &'static str {
match self {
Self::Png => "image/png",
Self::Jpeg => "image/jpeg",
Self::Webp => "image/webp",
Self::Gif => "image/gif",
}
}
fn matches_magic(self, bytes: &[u8]) -> bool {
match self {
Self::Png => bytes.starts_with(b"\x89PNG\r\n\x1a\n"),
Self::Jpeg => bytes.starts_with(&[0xff, 0xd8, 0xff]),
Self::Webp => bytes.len() >= 12 && &bytes[..4] == b"RIFF" && &bytes[8..12] == b"WEBP",
Self::Gif => bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a"),
}
}
}
fn validate_canvas_downloaded_asset_content(
source_hint: Option<&str>,
image_source: bool,
media_type: &str,
bytes: &[u8],
) -> Result<(), String> {
let normalized_media_type = media_type
.split(';')
.next()
.unwrap_or(media_type)
.trim()
.to_ascii_lowercase();
let declared_format = CanvasImageFormat::from_media_type(&normalized_media_type);
let hinted_format = CanvasImageFormat::from_source_hint(source_hint);
if declared_format
.zip(hinted_format)
.is_some_and(|(declared, hinted)| declared != hinted)
{
return Err("画板图片响应格式与资源路径不一致".to_string());
}
if let Some(format) = declared_format.or(hinted_format) {
if !format.matches_magic(bytes) {
return Err(format!("画板图片内容与 {} 格式不匹配", format.media_type()));
}
} else if normalized_media_type.starts_with("image/") || image_source {
return Err("画板图片响应缺少可校验的受支持格式".to_string());
}
Ok(())
}
pub(crate) async fn resolve_canvas_resource_download(
client: &reqwest::Client,
api_base_url: &str,
api_key: &str,
resource: &serde_json::Value,
) -> Result<Option<CanvasResourceDownload>, String> {
let signed_url = if let Some(object_key) = json_string_field(resource, "objectKey") {
let object_key = json_string_field(resource, "objectKey");
let image_src = json_string_field(resource, "imageSrc");
let source_hint = object_key.as_deref().or(image_src.as_deref());
let signed_url = if let Some(object_key) = object_key.as_deref() {
let read_url = format!(
"{}/api/external/v1/assets/read-url?objectKey={}",
api_base_url,
percent_encode_query_component(&object_key)
percent_encode_query_component(object_key)
);
Some(resolve_external_asset_signed_url(client, api_key, read_url).await?)
} else if let Some(image_src) = json_string_field(resource, "imageSrc") {
} else if let Some(image_src) = image_src.as_deref() {
if image_src.starts_with('/') {
let read_url = format!(
"{}/api/external/v1/assets/read-url?legacyPublicPath={}",
api_base_url,
percent_encode_query_component(&image_src)
percent_encode_query_component(image_src)
);
Some(resolve_external_asset_signed_url(client, api_key, read_url).await?)
} else if image_src.starts_with("http://") || image_src.starts_with("https://") {
Some(image_src)
Some(image_src.to_string())
} else {
None
}
@@ -408,6 +499,12 @@ pub(crate) async fn resolve_canvas_resource_download(
if bytes.len() > 20 * 1024 * 1024 {
return Err("画板资产超过 20 MiB,已拒绝同步".to_string());
}
validate_canvas_downloaded_asset_content(
source_hint,
image_src.is_some(),
&media_type,
&bytes,
)?;
if bytes.is_empty() {
return Ok(None);
}
@@ -776,3 +873,89 @@ pub(crate) fn register_local_asset_entry(
manifest_path: manifest_path.to_string_lossy().into_owned(),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn canvas_download_accepts_supported_image_magic() {
let cases: [(&str, &str, &[u8]); 4] = [
(
"image/png; charset=binary",
"assets/hero.png",
b"\x89PNG\r\n\x1a\nbody",
),
("IMAGE/JPEG", "assets/hero.jpeg", &[0xff, 0xd8, 0xff, 0xe0]),
(
"image/webp",
"assets/hero.webp?version=1",
b"RIFF\x04\x00\x00\x00WEBP",
),
("image/gif", "assets/hero.gif#frame", b"GIF89abody"),
];
for (media_type, source_hint, bytes) in cases {
validate_canvas_downloaded_asset_content(Some(source_hint), false, media_type, bytes)
.unwrap_or_else(|error| panic!("{media_type} should pass: {error}"));
}
}
#[test]
fn canvas_download_rejects_error_bodies_and_wrong_image_magic() {
for (media_type, source_hint) in [
("image/png", "assets/hero.png"),
("image/jpeg", "assets/hero.jpg"),
("image/webp", "assets/hero.webp"),
("image/gif", "assets/hero.gif"),
] {
let error = validate_canvas_downloaded_asset_content(
Some(source_hint),
false,
media_type,
br#"{"error":"generation failed"}"#,
)
.expect_err("200 error body must fail image validation");
assert!(error.contains("格式不匹配"));
}
}
#[test]
fn canvas_download_uses_image_path_when_response_mime_is_not_image() {
let error = validate_canvas_downloaded_asset_content(
Some("assets/hero.png"),
false,
"application/json",
br#"{"error":"expired signed url"}"#,
)
.expect_err("image path must still require valid image bytes");
assert!(error.contains("image/png"));
}
#[test]
fn canvas_download_rejects_unverifiable_image_source_but_leaves_media_unchanged() {
let error = validate_canvas_downloaded_asset_content(
Some("https://example.test/generated"),
true,
"text/html",
b"upstream error",
)
.expect_err("imageSrc response without a supported format must fail closed");
assert!(error.contains("缺少可校验"));
validate_canvas_downloaded_asset_content(
Some("assets/theme.mp3"),
false,
"audio/mpeg",
b"audio-validation-remains-unchanged",
)
.expect("audio downloads are outside image magic validation");
validate_canvas_downloaded_asset_content(
Some("assets/intro.mp4"),
false,
"video/mp4",
b"video-validation-remains-unchanged",
)
.expect("video downloads are outside image magic validation");
}
}
@@ -22,7 +22,7 @@ const SUPERVISOR_COLLABORATION_POLICY_BINDING_LEGACY_CURRENT: &str =
const SUPERVISOR_COLLABORATION_MAX_STATIC_DELEGATES: usize = 3;
const SUPERVISOR_COLLABORATION_MAX_ISOLATED_CHILDREN: usize = 3;
const SUPERVISOR_COLLABORATION_MAX_ISOLATED_GROUPS_BEFORE_CLAIM: usize = 16;
const AUTONOMOUS_GAME_BUILD_REQUIRED_STATIC_AGENT_IDS: [&str; 2] =
const AUTONOMOUS_GAME_BUILD_BASE_REQUIRED_STATIC_AGENT_IDS: [&str; 2] =
["code-prototype", "quality-review"];
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
@@ -67,18 +67,65 @@ impl Default for SupervisorCollaborationPolicy {
}
}
fn autonomous_game_build_supervisor_collaboration_policy() -> SupervisorCollaborationPolicy {
fn autonomous_game_build_has_canonical_art_asset(root: &Path) -> bool {
const ART_PATH: &str = "assets/art-spritesheet.png";
read_existing_manifest_for_project(root)
.ok()
.is_some_and(|manifest| {
manifest.assets.iter().any(|asset| {
asset.local_path == ART_PATH
&& asset.kind == "art-spritesheet"
&& asset.media_type.starts_with("image/")
&& asset.source.kind == GameCreationAppAssetSourceKind::Canvas
&& resolve_local_project_path(root, ART_PATH)
.ok()
.and_then(|path| fs::read(path).ok())
.is_some_and(|bytes| {
bytes.len() > 8 && bytes.starts_with(b"\x89PNG\r\n\x1a\n")
})
})
})
}
fn autonomous_game_build_supervisor_collaboration_policy(
root: &Path,
) -> SupervisorCollaborationPolicy {
let mut required_static_agent_ids = AUTONOMOUS_GAME_BUILD_BASE_REQUIRED_STATIC_AGENT_IDS
.into_iter()
.map(str::to_string)
.collect::<Vec<_>>();
if editor_api_key_is_configured() && !autonomous_game_build_has_canonical_art_asset(root) {
required_static_agent_ids.push("art-asset-plan".to_string());
}
SupervisorCollaborationPolicy {
required_initial_wave: SupervisorInitialCollaborationWave::Static,
min_static_delegates: AUTONOMOUS_GAME_BUILD_REQUIRED_STATIC_AGENT_IDS.len(),
required_static_agent_ids: AUTONOMOUS_GAME_BUILD_REQUIRED_STATIC_AGENT_IDS
.into_iter()
.map(str::to_string)
.collect(),
min_static_delegates: required_static_agent_ids.len(),
required_static_agent_ids,
..SupervisorCollaborationPolicy::default()
}
}
fn apply_autonomous_game_build_required_static_agents(
root: &Path,
mut policy: SupervisorCollaborationPolicy,
) -> Result<SupervisorCollaborationPolicy, String> {
if editor_api_key_is_configured()
&& !autonomous_game_build_has_canonical_art_asset(root)
&& !policy
.required_static_agent_ids
.iter()
.any(|existing| existing == "art-asset-plan")
{
policy
.required_static_agent_ids
.push("art-asset-plan".to_string());
}
policy.min_static_delegates = policy
.min_static_delegates
.max(policy.required_static_agent_ids.len());
normalize_supervisor_collaboration_policy(policy)
}
#[derive(Clone, Debug)]
struct SupervisorCollaborationUnboundPolicy {
policy: SupervisorCollaborationPolicy,
@@ -92,11 +139,19 @@ fn read_supervisor_collaboration_unbound_policy_for_run_at(
parent_agent_id: &str,
parent_run_id: &str,
) -> Result<SupervisorCollaborationUnboundPolicy, String> {
let (run_profile, _) =
agent_runtime_run_profile_identity_at(root, parent_agent_id, parent_run_id, None, None)?;
let autonomous_supervisor = parent_agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID
&& run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD;
let policy_path = root.join(SUPERVISOR_COLLABORATION_POLICY_RELATIVE_PATH);
match fs::symlink_metadata(&policy_path) {
Ok(_) => {
let mut policy = read_supervisor_collaboration_policy_at(root)?;
if autonomous_supervisor {
policy = apply_autonomous_game_build_required_static_agents(root, policy)?;
}
return Ok(SupervisorCollaborationUnboundPolicy {
policy: read_supervisor_collaboration_policy_at(root)?,
policy,
source: "project-policy-unbound",
project_policy_status: "current",
project_policy_present: true,
@@ -109,14 +164,10 @@ fn read_supervisor_collaboration_unbound_policy_for_run_at(
));
}
}
let (run_profile, _) =
agent_runtime_run_profile_identity_at(root, parent_agent_id, parent_run_id, None, None)?;
if parent_agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID
&& run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD
{
if autonomous_supervisor {
return Ok(SupervisorCollaborationUnboundPolicy {
policy: normalize_supervisor_collaboration_policy(
autonomous_game_build_supervisor_collaboration_policy(),
autonomous_game_build_supervisor_collaboration_policy(root),
)?,
source: "autonomous-run-default",
project_policy_status: "absent",
@@ -995,8 +995,6 @@ pub(crate) async fn generate_platform_art_asset(
) -> Result<UploadLocalAssetResult, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "canvas.asset_generate")?;
let _lock = acquire_project_write_lock(root, "canvas.asset_generate")?;
advance_agent_runtime_project_revision_locked(root)?;
let generated = generate_platform_art_asset_at(root, prompt.trim(), &[]).await?;
Ok(generated.asset)
}
@@ -122,6 +122,16 @@ pub(crate) fn read_manifest_for_project(root: &Path) -> Result<GameCreationAppMa
Ok(manifest)
}
pub(crate) fn read_existing_manifest_for_project(
root: &Path,
) -> Result<GameCreationAppManifest, String> {
let (manifest_path, manifest) = read_or_create_manifest(root)?;
if !manifest_storage_exists(&manifest_path)? {
return Err("本地项目尚未初始化".to_string());
}
Ok(manifest)
}
pub(crate) fn ensure_manifest_has_seed_tasks(
root: &Path,
goal: Option<&str>,
@@ -11,6 +11,7 @@ mod input;
mod observer;
mod report;
mod terminal_classification;
mod turn_dispatch;
mod turn_wait;
use commands::*;
@@ -20,6 +21,7 @@ use input::*;
use observer::*;
use report::*;
use terminal_classification::*;
use turn_dispatch::*;
use turn_wait::*;
#[cfg(test)]
@@ -10,6 +10,7 @@ pub(super) enum SwarmChatInput {
Mcp,
Goal(SwarmGoalCommand),
InvalidGoal(String),
Resume,
Quit,
Message(String),
}
@@ -77,14 +78,12 @@ pub(super) fn run_game_creator_swarm_chat_with_input<W: Write>(
return Err("parentAgentId 不能为空".to_string());
}
let new_run_launch = resolve_swarm_new_run_launch(parent_agent_id, run_profile)?;
let project_path = root.display().to_string();
enforce_project_permission_policy(root, "conversation.read")?;
enforce_project_permission_policy(root, "conversation.write")?;
enforce_project_permission_policy(root, "agent.run_status")?;
enforce_project_permission_policy(root, "agent.compact")?;
enforce_project_permission_policy(root, "agent.resume")?;
let _ = read_local_conversation_for_session_at(root, Some(parent_agent_id), None)?;
let resumed = resume_game_creator_agent_background_tasks_at(root)?;
let existing_runtimes = read_game_creator_agent_runtimes_at(root)?;
writeln!(output, "Agent Swarm Chat")
@@ -93,10 +92,6 @@ pub(super) fn run_game_creator_swarm_chat_with_input<W: Write>(
.and_then(|_| writeln!(output, "输入 /help 查看命令。"))
.map_err(|error| format!("写入终端失败:{error}"))?;
print_conversation_history(root, parent_agent_id, output)?;
if !resumed.is_empty() {
writeln!(output, "[恢复扫描] 已检查 {} 个 Runtime。", resumed.len())
.map_err(|error| format!("写入终端失败:{error}"))?;
}
let active_conversation =
read_local_conversation_for_session_at(root, Some(parent_agent_id), None)?;
@@ -112,42 +107,11 @@ pub(super) fn run_game_creator_swarm_chat_with_input<W: Write>(
)
.is_some_and(runtime_is_busy);
if matching_parent_is_busy {
writeln!(output, "[恢复] 检测到未收束 Runtime,继续观察现有任务。")
.map_err(|error| format!("写入终端失败:{error}"))?;
let before = active_conversation;
let session_id = before
.session_id
.as_deref()
.ok_or_else(|| "父 Agent 当前 Session 缺失".to_string())?;
let parent_run_id =
swarm_parent_runtime(parent_agent_id, session_id, run_profile, &existing_runtimes)
.map(|runtime| runtime.state.run_id.as_str())
.unwrap_or_default();
let mut conversation_baseline =
new_swarm_turn_conversation_baseline(before.messages.len(), parent_run_id);
capture_recovered_swarm_assistant_at(
root,
parent_agent_id,
session_id,
&mut conversation_baseline,
)?;
let mut observer = SwarmRuntimeObserver::default();
let outcome = wait_for_swarm_turn(
root,
parent_agent_id,
session_id,
run_profile,
conversation_baseline,
input,
writeln!(
output,
&mut observer,
SWARM_CHAT_POLL_INTERVAL,
SWARM_CHAT_SETTLE_WINDOW,
)?;
if outcome == SwarmTurnOutcome::Quit {
return print_swarm_chat_exit(output);
}
print_turn_outcome(outcome, output)?;
"[恢复扫描] 检测到未收束 Runtime;输入 /resume 继续观察,新消息会进入该 run 的 steer 队列。"
)
.map_err(|error| format!("写入终端失败:{error}"))?;
}
loop {
@@ -201,6 +165,26 @@ pub(super) fn run_game_creator_swarm_chat_with_input<W: Write>(
print_turn_outcome(outcome, output)?;
}
SwarmChatInput::InvalidGoal(error) => print_swarm_goal_error(output, &error)?,
SwarmChatInput::Resume => {
let before =
read_local_conversation_for_session_at(root, Some(parent_agent_id), None)?;
let session_id = before
.session_id
.as_deref()
.ok_or_else(|| "父 Agent 当前 Session 缺失".to_string())?;
if handle_swarm_resume_turn(
root,
parent_agent_id,
session_id,
run_profile,
before.messages.len(),
input,
output,
)? == SwarmChatFlow::Exit
{
return Ok(());
}
}
SwarmChatInput::Quit => {
writeln!(
output,
@@ -214,129 +198,21 @@ pub(super) fn run_game_creator_swarm_chat_with_input<W: Write>(
read_local_conversation_for_session_at(root, Some(parent_agent_id), None)?;
let session_id = before
.session_id
.clone()
.as_deref()
.ok_or_else(|| "父 Agent 当前 Session 缺失".to_string())?;
let mut observer = SwarmRuntimeObserver::seed(root)?;
if let Some(goal) =
read_game_creator_agent_goal_at(root, parent_agent_id, &session_id)?
{
match goal.status.as_str() {
AGENT_GOAL_STATUS_ACTIVE => {
let steer_id = format!("swarm-goal-steer-{}", unix_millis());
let result = steer_game_creator_agent_runtime_task(
project_path.clone(),
parent_agent_id.to_string(),
session_id.clone(),
goal.run_id.clone(),
steer_id.clone(),
message,
Some(run_profile.to_string()),
)?;
writeln!(
output,
"[Goal 已追加] run={} steer={} providerInterrupted={}",
goal.run_id, steer_id, result.provider_interrupted
)
.map_err(|error| format!("写入终端失败:{error}"))?;
let conversation_baseline = new_swarm_turn_conversation_baseline(
before.messages.len(),
&goal.run_id,
);
let outcome = wait_for_swarm_turn(
root,
parent_agent_id,
&session_id,
run_profile,
conversation_baseline,
input,
output,
&mut observer,
SWARM_CHAT_POLL_INTERVAL,
SWARM_CHAT_SETTLE_WINDOW,
)?;
if outcome == SwarmTurnOutcome::Quit {
return print_swarm_chat_exit(output);
}
print_turn_outcome(outcome, output)?;
continue;
}
AGENT_GOAL_STATUS_PAUSE_REQUESTED | AGENT_GOAL_STATUS_PAUSED => {
print_swarm_goal_error(
output,
"当前 Goal 已暂停;请先输入 /goal resume。",
)?;
continue;
}
AGENT_GOAL_STATUS_CLEARING => {
print_swarm_goal_error(output, "当前 Goal 正在清理,暂不接受新消息。")?;
continue;
}
AGENT_GOAL_STATUS_NEEDS_RECONCILIATION => {
print_swarm_goal_error(
output,
"当前 Goal 需要人工 reconciliation,暂不接受新消息。",
)?;
continue;
}
AGENT_GOAL_STATUS_COMPLETED | AGENT_GOAL_STATUS_CLEARED => {}
status => {
print_swarm_goal_error(
output,
&format!("当前 Goal 状态未知,已阻止发送:{status}"),
)?;
continue;
}
}
}
let requested_run_id = format!("swarm-{parent_agent_id}-{}", unix_millis());
let started = match new_run_launch {
SwarmNewRunLaunch::ProjectSupervisor {
source,
run_profile,
} => start_game_creator_supervisor_background_task_for_session_at(
root,
Some(&session_id),
&message,
&requested_run_id,
source,
run_profile,
)?,
SwarmNewRunLaunch::ExplicitParentDebug => {
start_game_creator_agent_runtime_task(
project_path.clone(),
parent_agent_id.to_string(),
Some(session_id.clone()),
message,
requested_run_id.clone(),
)?
}
};
writeln!(
output,
"[已投递] agent={} session={} run={}",
parent_agent_id, started.state.session_id, requested_run_id
)
.map_err(|error| format!("写入终端失败:{error}"))?;
let conversation_baseline = new_swarm_turn_conversation_baseline(
before.messages.len(),
&started.state.run_id,
);
let outcome = wait_for_swarm_turn(
if handle_swarm_user_turn(
root,
parent_agent_id,
&session_id,
session_id,
run_profile,
conversation_baseline,
new_run_launch,
&message,
input,
output,
&mut observer,
SWARM_CHAT_POLL_INTERVAL,
SWARM_CHAT_SETTLE_WINDOW,
)?;
if outcome == SwarmTurnOutcome::Quit {
return print_swarm_chat_exit(output);
)? == SwarmChatFlow::Exit
{
return Ok(());
}
print_turn_outcome(outcome, output)?;
}
}
}
@@ -433,6 +309,7 @@ pub(super) fn parse_swarm_chat_input(input: &str) -> Option<SwarmChatInput> {
"/history" => SwarmChatInput::History,
"/compact" => SwarmChatInput::Compact,
"/mcp" => SwarmChatInput::Mcp,
"/resume" => SwarmChatInput::Resume,
"/quit" | "/exit" => SwarmChatInput::Quit,
value => SwarmChatInput::Message(value.to_string()),
})
@@ -481,6 +358,7 @@ pub(super) fn print_swarm_chat_help<W: Write>(output: &mut W) -> Result<(), Stri
.and_then(|_| writeln!(output, "/history 查看父 Agent 当前 Session 历史"))
.and_then(|_| writeln!(output, "/compact 压缩父 Agent 当前空闲 Session 历史"))
.and_then(|_| writeln!(output, "/mcp 查看 Runner MCP server 与工具目录"))
.and_then(|_| writeln!(output, "/resume 继续观察当前 Session 的未收束 Runtime"))
.and_then(|_| writeln!(output, "/goal <目标> 启动当前 Session 的持久 Goal"))
.and_then(|_| writeln!(output, "/goal 查看当前 Goal"))
.and_then(|_| writeln!(output, "/goal status 查看当前 Goal"))
@@ -213,6 +213,32 @@ fn cross_profile_steer_is_rejected_before_persistent_side_effects() {
fs::remove_dir_all(root).ok();
}
#[test]
fn natural_language_is_not_preclassified_by_cli_keywords() {
for message in [
"你是谁",
"项目在哪里",
"实现登录页并运行测试",
"不要运行任何命令,只解释架构",
"你能做什么,并顺便修复这个问题",
"继续刚才的任务",
] {
assert_eq!(
parse_swarm_chat_input(message),
Some(SwarmChatInput::Message(message.to_string())),
"{message} must enter the unified Agent interaction loop"
);
}
}
#[test]
fn resume_is_an_explicit_control_command() {
assert_eq!(
parse_swarm_chat_input("/resume"),
Some(SwarmChatInput::Resume)
);
}
#[test]
fn parent_runtime_matching_is_scoped_to_requested_profile() {
let mut standard = runtime("running", "planning", 0);
@@ -0,0 +1,421 @@
use super::*;
pub(super) fn handle_swarm_user_turn<W: Write>(
root: &Path,
parent_agent_id: &str,
session_id: &str,
run_profile: &str,
new_run_launch: SwarmNewRunLaunch<'_>,
message: &str,
input: &Receiver<SwarmInputEvent>,
output: &mut W,
) -> Result<SwarmChatFlow, String> {
let before =
read_local_conversation_for_session_at(root, Some(parent_agent_id), Some(session_id))?;
if let Some(goal) = read_game_creator_agent_goal_at(root, parent_agent_id, session_id)? {
match goal.status.as_str() {
AGENT_GOAL_STATUS_ACTIVE => {
return steer_and_wait_for_swarm_turn(
root,
parent_agent_id,
session_id,
run_profile,
&goal.run_id,
message,
before.messages.len(),
input,
output,
"Goal 已追加",
);
}
AGENT_GOAL_STATUS_PAUSE_REQUESTED | AGENT_GOAL_STATUS_PAUSED => {
print_swarm_goal_error(output, "当前 Goal 已暂停;请先输入 /goal resume。")?;
return Ok(SwarmChatFlow::Continue);
}
AGENT_GOAL_STATUS_CLEARING => {
print_swarm_goal_error(output, "当前 Goal 正在清理,暂不接受新消息。")?;
return Ok(SwarmChatFlow::Continue);
}
AGENT_GOAL_STATUS_NEEDS_RECONCILIATION => {
print_swarm_goal_error(
output,
"当前 Goal 需要人工 reconciliation,暂不接受新消息。",
)?;
return Ok(SwarmChatFlow::Continue);
}
AGENT_GOAL_STATUS_COMPLETED | AGENT_GOAL_STATUS_CLEARED => {}
status => {
print_swarm_goal_error(
output,
&format!("当前 Goal 状态未知,已阻止发送:{status}"),
)?;
return Ok(SwarmChatFlow::Continue);
}
}
}
let runtimes = read_game_creator_agent_runtimes_at(root)?;
if let Some(runtime) = swarm_parent_runtime(parent_agent_id, session_id, run_profile, &runtimes)
.filter(|runtime| runtime_is_busy(runtime))
{
return steer_and_wait_for_swarm_turn(
root,
parent_agent_id,
session_id,
run_profile,
&runtime.state.run_id,
message,
before.messages.len(),
input,
output,
"运行中输入已排队",
);
}
let action = if game_creator_agent_uses_interaction_kernel(parent_agent_id) {
let Some(runtime_lock) =
try_acquire_game_creator_agent_runtime_task_lock(root, parent_agent_id)?
else {
let current_runtimes = read_game_creator_agent_runtimes_at(root)?;
if let Some(runtime) =
swarm_parent_runtime(parent_agent_id, session_id, run_profile, &current_runtimes)
.filter(|runtime| runtime_is_busy(runtime))
{
return steer_and_wait_for_swarm_turn(
root,
parent_agent_id,
session_id,
run_profile,
&runtime.state.run_id,
message,
before.messages.len(),
input,
output,
"运行中输入已排队",
);
}
return Err(format!(
"Agent 交互锁已被占用但没有可识别的活动 Runtime:{parent_agent_id}"
));
};
let action_result =
decide_interaction_action(root, parent_agent_id, session_id, message, output);
let persistence_result = match &action_result {
Ok(AgentInteractionAction::Reply(reply)) => {
persist_swarm_reply(root, parent_agent_id, session_id, message, reply)
}
Ok(AgentInteractionAction::ProjectLocation) => {
let reply = format!("当前 CLI 会话绑定的项目目录是:{}", root.display());
writeln!(output, "陶泥儿> {reply}")
.map_err(|error| format!("写入终端失败:{error}"))
.and_then(|_| {
persist_swarm_reply(root, parent_agent_id, session_id, message, &reply)
})
}
_ => Ok(()),
};
spawn_next_game_creator_agent_background_task_drain_with_lock(
root,
parent_agent_id,
runtime_lock,
);
persistence_result?;
action_result?
} else {
AgentInteractionAction::Execute
};
writeln!(output, "[意图] {}", action.label())
.map_err(|error| format!("写入终端失败:{error}"))?;
match action {
AgentInteractionAction::Reply(reply) => {
debug_assert!(!reply.trim().is_empty());
Ok(SwarmChatFlow::Continue)
}
AgentInteractionAction::ProjectLocation => Ok(SwarmChatFlow::Continue),
AgentInteractionAction::Execute => start_and_wait_for_swarm_turn(
root,
parent_agent_id,
session_id,
run_profile,
new_run_launch,
message,
before.messages.len(),
input,
output,
),
AgentInteractionAction::Resume => handle_swarm_resume_turn(
root,
parent_agent_id,
session_id,
run_profile,
before.messages.len(),
input,
output,
),
}
}
fn decide_interaction_action<W: Write>(
root: &Path,
parent_agent_id: &str,
session_id: &str,
message: &str,
output: &mut W,
) -> Result<AgentInteractionAction, String> {
writeln!(output, "[决策] Agent 正在判断直接回复或调用持久能力。")
.map_err(|error| format!("写入终端失败:{error}"))?;
let mut printed_chars = 0usize;
let mut reply_started = false;
let mut protocol_buffered = false;
let action =
tauri::async_runtime::block_on(decide_game_creator_agent_interaction_turn_for_session_at(
root,
parent_agent_id,
session_id,
message,
|delta| {
if protocol_buffered {
return;
}
let trimmed = delta.accumulated_text.trim_start();
if !reply_started && (trimmed.starts_with('{') || trimmed.starts_with("```")) {
protocol_buffered = true;
return;
}
if delta.accumulated_text.len() <= printed_chars {
return;
}
if !reply_started {
let _ = write!(output, "陶泥儿> ");
reply_started = true;
}
let chunk = &delta.accumulated_text[printed_chars..];
let _ = write!(output, "{chunk}");
let _ = output.flush();
printed_chars = delta.accumulated_text.len();
},
))?;
if let AgentInteractionAction::Reply(reply) = &action {
if reply.len() > printed_chars {
if !reply_started {
write!(output, "陶泥儿> ").map_err(|error| format!("写入终端失败:{error}"))?;
}
write!(output, "{}", &reply[printed_chars..])
.map_err(|error| format!("写入终端失败:{error}"))?;
}
writeln!(output).map_err(|error| format!("写入终端失败:{error}"))?;
} else if reply_started {
writeln!(output).map_err(|error| format!("写入终端失败:{error}"))?;
}
Ok(action)
}
fn persist_swarm_reply(
root: &Path,
parent_agent_id: &str,
session_id: &str,
message: &str,
reply: &str,
) -> Result<(), String> {
with_agent_conversation_session_lane_at(
root,
parent_agent_id,
"Swarm interaction 回复落盘",
|| {
append_local_conversation_message_for_session_at(
root,
Some(parent_agent_id),
Some(session_id),
LocalConversationMessage {
role: "user".to_string(),
content: message.to_string(),
agent_id: Some(parent_agent_id.to_string()),
},
)?;
append_local_conversation_message_for_session_at(
root,
Some(parent_agent_id),
Some(session_id),
LocalConversationMessage {
role: "assistant".to_string(),
content: reply.to_string(),
agent_id: Some(parent_agent_id.to_string()),
},
)?;
Ok(())
},
)
}
fn steer_and_wait_for_swarm_turn<W: Write>(
root: &Path,
parent_agent_id: &str,
session_id: &str,
run_profile: &str,
run_id: &str,
message: &str,
previous_message_count: usize,
input: &Receiver<SwarmInputEvent>,
output: &mut W,
label: &str,
) -> Result<SwarmChatFlow, String> {
require_external_agent_runner_for_cli_runtime_write(root)?;
let steer_id = format!("swarm-steer-{}", unix_millis());
let result = steer_game_creator_agent_runtime_task(
root.display().to_string(),
parent_agent_id.to_string(),
session_id.to_string(),
run_id.to_string(),
steer_id.clone(),
message.to_string(),
Some(run_profile.to_string()),
)?;
writeln!(
output,
"[{label}] run={} steer={} providerInterrupted={}",
run_id, steer_id, result.provider_interrupted
)
.map_err(|error| format!("写入终端失败:{error}"))?;
let conversation_baseline =
new_swarm_turn_conversation_baseline(previous_message_count, run_id);
wait_and_print_swarm_turn(
root,
parent_agent_id,
session_id,
run_profile,
conversation_baseline,
input,
output,
)
}
fn start_and_wait_for_swarm_turn<W: Write>(
root: &Path,
parent_agent_id: &str,
session_id: &str,
run_profile: &str,
new_run_launch: SwarmNewRunLaunch<'_>,
task: &str,
previous_message_count: usize,
input: &Receiver<SwarmInputEvent>,
output: &mut W,
) -> Result<SwarmChatFlow, String> {
require_external_agent_runner_for_cli_runtime_write(root)?;
let requested_run_id = format!("swarm-{parent_agent_id}-{}", unix_millis());
let started = match new_run_launch {
SwarmNewRunLaunch::ProjectSupervisor {
source,
run_profile,
} => start_game_creator_supervisor_background_task_for_session_at(
root,
Some(session_id),
task,
&requested_run_id,
source,
run_profile,
)?,
SwarmNewRunLaunch::ExplicitParentDebug => start_game_creator_agent_runtime_task(
root.display().to_string(),
parent_agent_id.to_string(),
Some(session_id.to_string()),
task.to_string(),
requested_run_id.clone(),
)?,
};
writeln!(
output,
"[已投递] agent={} session={} run={}",
parent_agent_id, started.state.session_id, requested_run_id
)
.map_err(|error| format!("写入终端失败:{error}"))?;
let conversation_baseline =
new_swarm_turn_conversation_baseline(previous_message_count, &started.state.run_id);
wait_and_print_swarm_turn(
root,
parent_agent_id,
session_id,
run_profile,
conversation_baseline,
input,
output,
)
}
pub(super) fn handle_swarm_resume_turn<W: Write>(
root: &Path,
parent_agent_id: &str,
session_id: &str,
run_profile: &str,
previous_message_count: usize,
input: &Receiver<SwarmInputEvent>,
output: &mut W,
) -> Result<SwarmChatFlow, String> {
require_external_agent_runner_for_cli_runtime_write(root)?;
let resumed = resume_game_creator_agent_background_tasks_at(root)?;
if !resumed.is_empty() {
writeln!(output, "[恢复扫描] 已检查 {} 个 Runtime。", resumed.len())
.map_err(|error| format!("写入终端失败:{error}"))?;
}
let current_runtimes = read_game_creator_agent_runtimes_at(root)?;
let Some(parent) =
swarm_parent_runtime(parent_agent_id, session_id, run_profile, &current_runtimes)
.filter(|runtime| runtime_is_busy(runtime))
else {
writeln!(output, "[恢复] 当前 Session 没有可恢复的运行任务。")
.map_err(|error| format!("写入终端失败:{error}"))?;
return Ok(SwarmChatFlow::Continue);
};
writeln!(
output,
"[恢复] 继续观察 run={} status={} phase={}",
parent.state.run_id, parent.state.status, parent.state.phase
)
.map_err(|error| format!("写入终端失败:{error}"))?;
let mut conversation_baseline =
new_swarm_turn_conversation_baseline(previous_message_count, &parent.state.run_id);
capture_recovered_swarm_assistant_at(
root,
parent_agent_id,
session_id,
&mut conversation_baseline,
)?;
wait_and_print_swarm_turn(
root,
parent_agent_id,
session_id,
run_profile,
conversation_baseline,
input,
output,
)
}
fn wait_and_print_swarm_turn<W: Write>(
root: &Path,
parent_agent_id: &str,
session_id: &str,
run_profile: &str,
conversation_baseline: SwarmTurnConversationBaseline,
input: &Receiver<SwarmInputEvent>,
output: &mut W,
) -> Result<SwarmChatFlow, String> {
let mut observer = SwarmRuntimeObserver::seed(root)?;
let outcome = wait_for_swarm_turn(
root,
parent_agent_id,
session_id,
run_profile,
conversation_baseline,
input,
output,
&mut observer,
SWARM_CHAT_POLL_INTERVAL,
SWARM_CHAT_SETTLE_WINDOW,
)?;
if outcome == SwarmTurnOutcome::Quit {
print_swarm_chat_exit(output)?;
return Ok(SwarmChatFlow::Exit);
}
print_turn_outcome(outcome, output)?;
Ok(SwarmChatFlow::Continue)
}
@@ -279,6 +279,10 @@ pub(super) fn wait_for_swarm_turn<W: Write>(
recovery_scan_required = true;
}
SwarmChatInput::InvalidGoal(error) => print_swarm_goal_error(output, &error)?,
SwarmChatInput::Resume => {
writeln!(output, "[恢复] 当前已经在观察这个 Runtime。")
.map_err(|error| format!("写入终端失败:{error}"))?;
}
SwarmChatInput::Message(message) => {
if let Some(parent) = runtimes.iter().find(|runtime| {
runtime.state.agent_id == parent_agent_id
@@ -67,7 +67,11 @@ fn visual_specialist_prompts_require_real_registered_image_deliveries() {
"canvas.asset_generate",
"assets/art-spritesheet.png",
"assetKind=art-spritesheet",
"该动作本身就是当前 revision 的验证",
"随后只需调用 asset.list",
"asset.list",
"不得运行 game.static_smoke 或 preview.validate",
"不得编辑 game/index.html",
"不得把计划写完当成 completed",
] {
assert!(
@@ -645,6 +645,102 @@ fn valid_autonomous_initial_responsibility_actions_for_test() -> Vec<AgentRuntim
)
}
fn autonomous_art_responsibility_action_for_test(
expected_artifacts: &[&str],
) -> AgentRuntimeToolAction {
AgentRuntimeToolAction {
tool: "agent.delegate".to_string(),
reason: Some("委派美术 Agent 生成首版精灵图".to_string()),
input: serde_json::json!({
"agentId": "art-asset-plan",
"task": "生成首版游戏美术资源并写入项目资产目录。",
"acceptanceCriteria": [
"使用画布生成接口产出可直接用于游戏的精灵图",
"生成结果必须登记为项目本地资产"
],
"expectedArtifacts": expected_artifacts,
"repairOfDelegationId": null,
"runId": null
}),
}
}
#[tokio::test]
async fn supervisor_autonomous_initial_art_responsibility_requires_canonical_spritesheet_artifact()
{
let root = unique_project_path();
let config_dir = unique_project_path();
fs::create_dir_all(&config_dir).expect("create isolated runtime config dir");
fs::write(
config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME),
serde_json::json!({
"editorApi": {
"baseUrl": "https://editor.example.test",
"apiKey": "editor-runtime-key"
}
})
.to_string(),
)
.expect("write runtime config with editor API key");
let _config_guard = use_test_runtime_config_dir(config_dir.clone());
init_local_game_project_at(
&root,
"project-supervisor-autonomous-art-contract",
"自主构建首批美术交付合同测试",
)
.expect("project init");
let run_id = "supervisor-autonomous-art-contract-run";
bind_game_creator_agent_runtime_run_profile_at(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
run_id,
AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE,
Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD),
None,
)
.expect("bind autonomous Supervisor profile");
let runtime = start_game_creator_agent_runtime_task_at(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
"建立程序、质量与美术首批职责。",
run_id,
AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE,
"准备首批美术交付合同",
Vec::new(),
)
.expect("start autonomous Supervisor runtime");
let mut actions = valid_autonomous_initial_responsibility_actions_for_test();
actions.push(autonomous_art_responsibility_action_for_test(&[
"assets/art-preview.png",
]));
let plan = supervisor_collaboration_plan_for_test(actions);
let revision = read_game_creator_agent_runtime_project_revision(&root)
.expect("read art contract project revision");
let preparation = prepare_game_creator_agent_runtime_provider_action_batch(
&root,
&runtime,
&runtime.current_task,
&plan,
&[],
&revision,
&"e".repeat(64),
)
.await
.expect("preflight art responsibility without canonical spritesheet");
let AgentRuntimeProviderActionBatchPreparation::Blocked(observation) = preparation else {
panic!("art responsibility without canonical spritesheet must be blocked");
};
assert_eq!(observation.tool, "runtime.collaboration_policy");
assert_eq!(observation.status, "blocked");
let detail = observation.detail.expect("art responsibility block detail");
assert!(detail.contains("art-asset-plan"));
assert!(detail.contains("assets/art-spritesheet.png"));
fs::remove_dir_all(root).ok();
fs::remove_dir_all(config_dir).ok();
}
fn native_supervisor_responsibility_plan_response_for_test(
call_prefix: &str,
actions: &[AgentRuntimeToolAction],
@@ -705,7 +801,7 @@ fn captured_supervisor_function_names_for_test(request: &str) -> BTreeSet<String
}
#[tokio::test]
async fn supervisor_autonomous_initial_responsibilities_reject_four_invalid_plans_before_accepting_contract(
async fn supervisor_autonomous_initial_responsibilities_reject_invalid_plans_before_accepting_contract(
) {
let root = unique_project_path();
init_local_game_project_at(
@@ -1519,6 +1615,20 @@ async fn supervisor_collaboration_read_only_first_window_repairs_with_collaborat
#[test]
fn supervisor_autonomous_game_build_without_project_policy_requires_builder_and_reviewer() {
let root = unique_project_path();
let config_dir = unique_project_path();
fs::create_dir_all(&config_dir).expect("create isolated runtime config dir");
fs::write(
config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME),
serde_json::json!({
"editorApi": {
"baseUrl": "http://127.0.0.1:8082",
"apiKey": ""
}
})
.to_string(),
)
.expect("write runtime config without editor API key");
let _config_guard = use_test_runtime_config_dir(config_dir.clone());
init_local_game_project_at(
&root,
"project-supervisor-autonomous-default-collaboration",
@@ -1558,4 +1668,215 @@ fn supervisor_autonomous_game_build_without_project_policy_requires_builder_and_
.exists());
fs::remove_dir_all(root).ok();
fs::remove_dir_all(config_dir).ok();
}
#[test]
fn supervisor_autonomous_game_build_with_editor_api_key_and_missing_art_asset_requires_art_delegate(
) {
let root = unique_project_path();
let config_dir = unique_project_path();
fs::create_dir_all(&config_dir).expect("create isolated runtime config dir");
fs::write(
config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME),
serde_json::json!({
"editorApi": {
"baseUrl": "https://editor.example.test",
"apiKey": "editor-runtime-key"
}
})
.to_string(),
)
.expect("write runtime config with editor API key");
let _config_guard = use_test_runtime_config_dir(config_dir.clone());
init_local_game_project_at(
&root,
"project-supervisor-autonomous-art-collaboration",
"自主构建美术协作策略测试",
)
.expect("project init");
let run_id = "supervisor-autonomous-art-collaboration-run";
bind_game_creator_agent_runtime_run_profile_at(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
run_id,
AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE,
Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD),
None,
)
.expect("bind autonomous supervisor profile");
let resolution = resolve_supervisor_collaboration_policy_for_run_at(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
run_id,
)
.expect("resolve autonomous collaboration policy with editor API key");
assert_eq!(resolution.source, "autonomous-run-default");
assert_eq!(resolution.project_policy_status, "absent");
assert_eq!(
resolution.policy.required_initial_wave,
SupervisorInitialCollaborationWave::Static
);
assert_eq!(resolution.policy.min_static_delegates, 3);
assert_eq!(
resolution
.policy
.required_static_agent_ids
.iter()
.map(String::as_str)
.collect::<BTreeSet<_>>(),
BTreeSet::from(["code-prototype", "quality-review", "art-asset-plan"])
);
assert!(!root
.join(SUPERVISOR_COLLABORATION_POLICY_RELATIVE_PATH)
.exists());
fs::remove_dir_all(root).ok();
fs::remove_dir_all(config_dir).ok();
}
#[test]
fn supervisor_autonomous_game_build_augments_existing_project_policy_with_required_art_delegate() {
let root = unique_project_path();
let config_dir = unique_project_path();
fs::create_dir_all(&config_dir).expect("create isolated runtime config dir");
fs::write(
config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME),
serde_json::json!({
"editorApi": {
"baseUrl": "https://editor.example.test",
"apiKey": "editor-runtime-key"
}
})
.to_string(),
)
.expect("write runtime config with editor API key");
let _config_guard = use_test_runtime_config_dir(config_dir.clone());
init_local_game_project_at(
&root,
"project-supervisor-autonomous-existing-policy-art",
"自主构建已有策略美术协作测试",
)
.expect("project init");
write_supervisor_collaboration_policy_at(&root, SupervisorCollaborationPolicy::default())
.expect("write default project collaboration policy");
let run_id = "supervisor-autonomous-existing-policy-art-run";
bind_game_creator_agent_runtime_run_profile_at(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
run_id,
AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE,
Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD),
None,
)
.expect("bind autonomous supervisor profile");
let resolution = resolve_supervisor_collaboration_policy_for_run_at(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
run_id,
)
.expect("resolve augmented project collaboration policy");
assert_eq!(resolution.source, "project-policy-unbound");
assert_eq!(resolution.project_policy_status, "current");
assert_eq!(resolution.policy.min_static_delegates, 1);
assert_eq!(
resolution
.policy
.required_static_agent_ids
.iter()
.map(String::as_str)
.collect::<BTreeSet<_>>(),
BTreeSet::from(["art-asset-plan"])
);
fs::remove_dir_all(root).ok();
fs::remove_dir_all(config_dir).ok();
}
#[test]
fn supervisor_autonomous_game_build_with_editor_api_key_and_existing_art_asset_skips_art_delegate()
{
let root = unique_project_path();
let config_dir = unique_project_path();
fs::create_dir_all(&config_dir).expect("create isolated runtime config dir");
fs::write(
config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME),
serde_json::json!({
"editorApi": {
"baseUrl": "https://editor.example.test",
"apiKey": "editor-runtime-key"
}
})
.to_string(),
)
.expect("write runtime config with editor API key");
let _config_guard = use_test_runtime_config_dir(config_dir.clone());
init_local_game_project_at(
&root,
"project-supervisor-autonomous-existing-art-collaboration",
"自主构建已有美术资源协作策略测试",
)
.expect("project init");
register_canvas_visual_asset_fixture(&root, "assets/art-spritesheet.png", "art-spritesheet");
let run_id = "supervisor-autonomous-existing-art-collaboration-run";
bind_game_creator_agent_runtime_run_profile_at(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
run_id,
AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE,
Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD),
None,
)
.expect("bind autonomous supervisor profile");
let resolution = resolve_supervisor_collaboration_policy_for_run_at(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
run_id,
)
.expect("resolve autonomous collaboration policy with existing art asset");
assert_eq!(resolution.source, "autonomous-run-default");
assert_eq!(resolution.project_policy_status, "absent");
assert_eq!(
resolution.policy.required_initial_wave,
SupervisorInitialCollaborationWave::Static
);
assert_eq!(resolution.policy.min_static_delegates, 2);
assert_eq!(
resolution.policy.required_static_agent_ids,
vec!["code-prototype".to_string(), "quality-review".to_string()]
);
assert!(!root
.join(SUPERVISOR_COLLABORATION_POLICY_RELATIVE_PATH)
.exists());
fs::write(root.join("assets/art-spritesheet.png"), b"not-a-png")
.expect("corrupt canonical art fixture");
let corrupt_run_id = "supervisor-autonomous-corrupt-art-collaboration-run";
bind_game_creator_agent_runtime_run_profile_at(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
corrupt_run_id,
AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE,
Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD),
None,
)
.expect("bind autonomous supervisor profile for corrupt art");
let corrupt_resolution = resolve_supervisor_collaboration_policy_for_run_at(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
corrupt_run_id,
)
.expect("resolve autonomous collaboration policy with corrupt art asset");
assert_eq!(corrupt_resolution.policy.min_static_delegates, 3);
assert!(corrupt_resolution
.policy
.required_static_agent_ids
.iter()
.any(|agent_id| agent_id == "art-asset-plan"));
fs::remove_dir_all(root).ok();
fs::remove_dir_all(config_dir).ok();
}

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