完善智能体交互与画布美术生成
为 Supervisor 与部门 Director 接入统一 Interaction Loop 和持久 Runtime 调度 在配置画布 API Key 时强制委派美术 Agent 并复用规范素材 收紧画布生成角色权限、图片内容校验和项目锁并发边界 完善专业 Agent revision 验证、任务恢复与 Provider 兼容处理 补充自主协作、真实画布生成、运行时收束测试及技术文档
This commit is contained in:
@@ -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.png,assetKind=art-spritesheet、assetLabel=游戏首版核心美术素材。随后用 asset.list 核对 manifest 已登记该 image/* 资产。图片生成未配置、待确认或失败时不得提交最终回复,也不得把计划写完当成 completed。"
|
||||
"{prompt}\n\n你负责首版美术素材实际生成。资产清单和美术计划只是中间结果;最终必须调用 canvas.asset_generate 生成核心素材图并登记到 assets/art-spritesheet.png,assetKind=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()),
|
||||
|
||||
+1
-1
@@ -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.html;quality-review 的 task 必须显式声明只读、不得修改项目,且 expectedArtifacts 必须为 []。两者都使用 agent.delegate,repairOfDelegationId=null、runId=null;如当前 policy 还要求 isolated,再在同批补齐 agent.spawn_isolated。不得更新计划、读取、搜索、查询状态、修改项目或返回最终回复。不要解释,不要 markdown,不要代码围栏。"
|
||||
"上一条输出不符合工具计划协议:{protocol_error}\n本次修复的原生工具目录只保留首批协作工具。必须在同一响应一次性建立完整首批合同:code-prototype 必须是非只读实现任务且 expectedArtifacts 包含 game/index.html;quality-review 的 task 必须显式声明只读、不得修改项目,且 expectedArtifacts 必须为 [];如当前 policy 的 requiredStaticAgentIds 包含 art-asset-plan,还必须加入非只读美术生成委派且 expectedArtifacts 包含 assets/art-spritesheet.png。所有静态委派都使用 agent.delegate,repairOfDelegationId=null、runId=null;如当前 policy 还要求 isolated,再在同批补齐 agent.spawn_isolated。不得更新计划、读取、搜索、查询状态、修改项目或返回最终回复。不要解释,不要 markdown,不要代码围栏。"
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
|
||||
+21
@@ -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, ¤t_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, ¤t_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!(
|
||||
|
||||
+322
-1
@@ -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
Reference in New Issue
Block a user