use super::*; use agent_runtime_core::{CapabilityDefinition, CapabilityRegistry}; 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; const AGENT_INTERACTION_PROVIDER_INSTANCE_ID: &str = "agc-interaction"; pub(crate) const AGENT_RUNTIME_STEER_DECISION_TOOL: &str = "runtime_steer_decision"; #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum AgentInteractionToolKind { Execute, Resume, ProjectLocation, } fn agent_interaction_tool_registry() -> Result, String> { let empty_input_schema = || { serde_json::json!({ "type": "object", "properties": {}, "additionalProperties": false }) }; CapabilityRegistry::try_new([ CapabilityDefinition::try_new( AGENT_INTERACTION_EXECUTE_TOOL, AGENT_INTERACTION_EXECUTE_TOOL, "仅当用户明确要求执行需要读取、修改、生成、测试或调用项目工具的工作时,提交用户原始消息给持久 Runtime。否定、假设、解释、咨询或需求仍不明确时不要调用。", empty_input_schema(), AgentInteractionToolKind::Execute, ) .map_err(|error| format!("Agent interaction capability 无效:{error}"))?, CapabilityDefinition::try_new( AGENT_INTERACTION_RESUME_TOOL, AGENT_INTERACTION_RESUME_TOOL, "仅当用户明确要求继续或恢复当前 Session 中未完成的持久 Runtime 时调用。", empty_input_schema(), AgentInteractionToolKind::Resume, ) .map_err(|error| format!("Agent interaction capability 无效:{error}"))?, CapabilityDefinition::try_new( AGENT_INTERACTION_PROJECT_LOCATION_TOOL, AGENT_INTERACTION_PROJECT_LOCATION_TOOL, "当用户询问当前项目目录或项目在哪里时调用;宿主会直接返回真实本地目录,不要猜测路径。", empty_input_schema(), AgentInteractionToolKind::ProjectLocation, ) .map_err(|error| format!("Agent interaction capability 无效:{error}"))?, ]) .map_err(|error| format!("Agent interaction registry 无效:{error}")) } #[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, } #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] struct AgentRuntimeSteerDecisionArguments { reply: String, interrupt_current_provider: bool, reason: String, } fn agent_runtime_steer_decision_function_tool() -> platform_llm::LlmFunctionTool { platform_llm::LlmFunctionTool::new( AGENT_RUNTIME_STEER_DECISION_TOOL, "回复用户,并判断是否必须中断当前正在进行的 LLM Provider 请求。", serde_json::json!({ "type": "object", "required": ["reply", "interruptCurrentProvider", "reason"], "additionalProperties": false, "properties": { "reply": { "type": "string", "minLength": 1, "maxLength": 2000 }, "interruptCurrentProvider": { "type": "boolean" }, "reason": { "type": "string", "minLength": 1, "maxLength": 500 } } }), ) .with_strict(true) } fn build_agent_runtime_steer_decision_request( root: &Path, state: &AgentRuntimeState, instruction: &str, ) -> Result<(GameCreatorLlmConfig, String, LlmRunRequest), String> { let (llm, config_path, context) = build_game_creator_role_agent_context_for_session( root, &state.agent_id, Some(&state.session_id), )?; let api_kind = parse_game_creator_llm_api_kind(&llm.api_kind)?; let runtime_summary = serde_json::json!({ "status": state.status, "phase": state.phase, "currentAction": state.current_action, "waitingOn": state.waiting_on, "nextStep": state.next_step, "plan": state.plan, "appliedSteerCursor": state.applied_steer_cursor, }); let system = format!( "{}\n\n你现在是项目总控的非终态对话判定层。一个制作 Run 正在执行,你必须先自然回答用户,再判断是否需要中断当前正在进行的 LLM Provider 请求。不要因为收到新消息就默认中断:状态询问、解释、鼓励、确认以及不冲突的补充应保持 interruptCurrentProvider=false;只有用户明确停止/改向,或新要求会让当前 LLM 继续生成明显过期方案时才设为 true。宿主工具和已经开始的外部动作不会被此判定强杀,它们会在安全边界完成。你的回复不能宣称整个 Run 已完成,也不能把这次回复当作终态。必须调用且只调用 runtime_steer_decision。", game_creator_project_supervisor_chat_system_prompt() ); let user = format!( "项目上下文如下,只作为背景,不要逐字复述:\n{context}\n\n当前 Runtime 摘要:\n{}\n\n用户在制作过程中发来的消息:\n{}", serde_json::to_string_pretty(&runtime_summary) .map_err(|error| format!("序列化 steer Runtime 摘要失败:{error}"))?, instruction.trim(), ); let request = LlmRunRequest::single_turn(system, user) .with_api_kind(api_kind) .with_max_output_tokens(AGENT_INTERACTION_MAX_OUTPUT_TOKENS) .with_function_tools(vec![agent_runtime_steer_decision_function_tool()]) .with_tool_choice(platform_llm::LlmToolChoice::Required); let request = apply_game_creator_llm_reasoning_effort(request, &llm)?; Ok((llm, config_path, request)) } fn parse_agent_runtime_steer_decision_response( response: &platform_llm::LlmRunResponse, ) -> Result { if response.tool_calls.len() != 1 { return Err("Supervisor steer decision 必须返回一个 runtime_steer_decision".to_string()); } let call = &response.tool_calls[0]; if call.name != AGENT_RUNTIME_STEER_DECISION_TOOL { return Err(format!( "Supervisor steer decision 返回未知工具:{}", call.name )); } let arguments = serde_json::from_str::(&call.arguments) .map_err(|error| format!("runtime_steer_decision 参数无效:{error}"))?; let reply = arguments.reply.trim(); let reason = arguments.reason.trim(); if reply.is_empty() || reason.is_empty() { return Err("runtime_steer_decision reply/reason 不能为空".to_string()); } Ok(AgentRuntimeSteerDecision { reply: reply.to_string(), interrupt_current_provider: arguments.interrupt_current_provider, reason: reason.to_string(), }) } pub(crate) async fn decide_game_creator_agent_runtime_steer_at( root: &Path, state: &AgentRuntimeState, steer_id: &str, sequence: u64, instruction: &str, ) -> Result { if let Some(decision) = read_game_creator_agent_runtime_steer_decision_at( root, &state.agent_id, &state.run_id, steer_id, )? { return Ok(decision); } if state.agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID || state.parent_agent_id.is_some() || state.parent_run_id.is_some() { return Err("只有根 Project Supervisor 支持运行中非终态回复".to_string()); } let (llm, config_path, request) = build_agent_runtime_steer_decision_request(root, state, instruction)?; let decision_snapshot = { let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( root, "runtime.steer_decision.snapshot", )?; let mut snapshot = capture_game_creator_agent_runtime_provider_request_snapshot_at_locked( root, &state.agent_id, &state.session_id, &state.run_id, "steer-decision", &format!("steer-{sequence}"), state.applied_steer_cursor, )?; // The main Runtime Provider request must keep running while this // independent LLM turn decides whether it is stale. A distinct node // identity gives Codex app-server a separate process/thread gate. snapshot.agent_id = format!("{}-steer-decision", state.agent_id); snapshot.task_id = snapshot.agent_id.clone(); snapshot }; let agent_mode = normalize_game_creator_agent_mode(&load_game_creator_app_config()?.agent_mode)?; let response = match agent_mode.as_str() { GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER => { request_game_creator_agent_codex_app_server(&decision_snapshot, &llm, request).await } GAME_CREATOR_AGENT_MODE_CODEX_CLI => request_game_creator_agent_codex_cli(request).await, GAME_CREATOR_AGENT_MODE_PROVIDER => { build_game_creator_agent_runtime_llm_client(&llm, &config_path)? .run(request) .await } _ => unreachable!("agent mode is normalized"), } .map_err(|error| format!("Supervisor steer decision 调用 LLM 失败:{error}"))?; let decision = parse_agent_runtime_steer_decision_response(&response)?; persist_game_creator_agent_runtime_steer_decision_and_reply_at( root, state, steer_id, sequence, decision, ) } 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() -> Result, String> { Ok(agent_interaction_tool_registry()? .iter() .map(|definition| { platform_llm::LlmFunctionTool::new( definition.function_name(), definition.description(), definition.input_schema().clone(), ) .with_strict(true) }) .collect()) } fn agent_interaction_system_prompt(agent_id: &str) -> 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 = "普通问答、身份说明、架构解释、方案讨论和必要澄清直接用自然语言回复。只有确实需要宿主持久能力时才调用一个 function tool,调用工具时不要同时输出回复文本。不要根据单个关键词决定是否执行,要理解整句的否定、假设、范围和上下文。"; format!( "{role_prompt}\n\n你现在位于统一的 Agent interaction loop。{protocol} 高影响请求仍不明确时直接追问,不要擅自启动 Runtime。" ) } fn build_agent_interaction_llm_request( agent_id: &str, prompt: &str, context: &str, llm: &GameCreatorLlmConfig, ) -> Result { let api_kind = parse_game_creator_llm_api_kind(&llm.api_kind)?; let user_prompt = if context.trim().is_empty() { format!("用户这轮输入:\n{prompt}") } else { format!( "项目上下文如下。只把它当作背景,不要逐字复述。\n\n{context}\n\n用户这轮输入:\n{prompt}" ) }; let function_tools = agent_interaction_function_tools()?; let request = LlmRunRequest::new(vec![ LlmMessage::system(agent_interaction_system_prompt(agent_id)), LlmMessage::user(user_prompt), ]) .with_api_kind(api_kind) .with_max_output_tokens(AGENT_INTERACTION_MAX_OUTPUT_TOKENS) .with_function_tools(function_tools) .with_tool_choice(platform_llm::LlmToolChoice::Auto); apply_game_creator_llm_reasoning_effort(request, llm) } fn build_agent_interaction_request_for_session( root: &Path, agent_id: &str, session_id: &str, prompt: &str, ) -> Result<(GameCreatorLlmConfig, String, LlmRunRequest), 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 request = build_agent_interaction_llm_request(agent_id, prompt, &context, &llm)?; Ok((llm, config_path, request)) } pub(crate) async fn decide_game_creator_agent_interaction_turn_for_session_at( root: &Path, agent_id: &str, session_id: &str, prompt: &str, mut on_delta: F, ) -> Result where F: FnMut(&platform_llm::LlmStreamDelta), { let (llm, config_path, request) = build_agent_interaction_request_for_session(root, agent_id, session_id, prompt)?; let api_kind = request.api_kind; let client = build_game_creator_agent_runtime_llm_client(&llm, &config_path)?; let llm_provider = client.config().provider(); let request = platform_llm::provider_request_from_llm_request("agent-interaction", request) .map_err(|error| format!("{config_path} Agent interaction Provider 请求无效:{error}"))?; let (registry, target) = platform_llm::build_platform_llm_provider_registry_for_api_kind( AGENT_INTERACTION_PROVIDER_INSTANCE_ID, client, api_kind, ) .map_err(|error| format!("{config_path} Agent interaction Provider 注册失败:{error}"))?; let response = if llm.stream { let fallback_request = request.clone(); let sink = AgentInteractionProviderStreamSink { on_delta: &mut on_delta, }; match registry.stream(&target, request, Box::new(sink)).await { Ok(response) => response, Err(error) if matches!( error.kind(), agent_runtime_core::ProviderErrorKind::StreamUnavailable | agent_runtime_core::ProviderErrorKind::EmptyResponse | agent_runtime_core::ProviderErrorKind::Deserialize ) => { registry .invoke(&target, 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 { registry .invoke(&target, request) .await .map_err(|error| format!("{config_path} Agent interaction 调用 LLM 失败:{error}"))? }; let response = platform_llm::llm_response_from_provider_response(llm_provider, response) .map_err(|error| format!("{config_path} Agent interaction Provider 响应无效:{error}"))?; parse_agent_interaction_response(&response) } struct AgentInteractionProviderStreamSink<'a, F> { on_delta: &'a mut F, } impl agent_runtime_core::ProviderStreamSink for AgentInteractionProviderStreamSink<'_, F> where F: FnMut(&platform_llm::LlmStreamDelta), { fn emit( &mut self, event: agent_runtime_core::ProviderStreamEvent, ) -> Result<(), agent_runtime_core::ProviderError> { if let agent_runtime_core::ProviderStreamEvent::TextDelta { accumulated_text, delta_text, finish_reason, } = event { (self.on_delta)(&platform_llm::LlmStreamDelta { accumulated_text, delta_text, accumulated_reasoning: String::new(), reasoning_delta: String::new(), finish_reason, }); } Ok(()) } } fn parse_agent_interaction_response( response: &platform_llm::LlmRunResponse, ) -> Result { if response.tool_calls.len() > 1 { return Err("Agent interaction 一轮最多只能选择一个宿主工具".to_string()); } if let Some(call) = response.tool_calls.first() { let registry = agent_interaction_tool_registry()?; let definition = registry .get_by_function_name(&call.name) .ok_or_else(|| format!("Agent interaction 返回未知工具:{}", call.name))?; return match definition.dispatch() { AgentInteractionToolKind::Execute => { let arguments = serde_json::from_str::>( 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::(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::>(&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::LlmRunResponse { platform_llm::LlmRunResponse { provider: LlmProvider::OpenAiCompatible, model: "interaction-test".to_string(), text: text.to_string(), reasoning: String::new(), finish_reason: Some("stop".to_string()), response_id: Some("interaction-response".to_string()), usage: None, tool_calls, responses_output: Vec::new(), } } 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 steer_decision_replies_without_interrupting_status_questions() { let decision = parse_agent_runtime_steer_decision_response(&response( "", vec![tool_call( AGENT_RUNTIME_STEER_DECISION_TOOL, r#"{"reply":"我正在完成玩法实现,当前任务会继续。","interruptCurrentProvider":false,"reason":"这是状态询问,不会使当前方案过期。"}"#, )], )) .expect("parse non-interrupting steer decision"); assert_eq!( decision, AgentRuntimeSteerDecision { reply: "我正在完成玩法实现,当前任务会继续。".to_string(), interrupt_current_provider: false, reason: "这是状态询问,不会使当前方案过期。".to_string(), } ); } #[test] fn steer_decision_can_request_safe_interrupt_for_conflicting_change() { let decision = parse_agent_runtime_steer_decision_response(&response( "", vec![tool_call( AGENT_RUNTIME_STEER_DECISION_TOOL, r#"{"reply":"明白,我会停止旧方向并改成回合制。","interruptCurrentProvider":true,"reason":"用户明确改向,旧 Provider 仍在生成过期方案。"}"#, )], )) .expect("parse interrupting steer decision"); assert!(decision.interrupt_current_provider); assert!(decision.reply.contains("改成回合制")); } #[test] fn interaction_registry_derives_unique_strict_function_tools() { let registry = agent_interaction_tool_registry().expect("interaction registry"); let tools = agent_interaction_function_tools().expect("interaction tools"); assert_eq!(tools.len(), registry.len()); let names = tools .iter() .map(|tool| tool.name.as_str()) .collect::>(); 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_request_crosses_neutral_provider_contract_without_shape_drift() { let mut llm = GameCreatorLlmConfig::default(); llm.api_kind = "openai_responses".to_string(); llm.reasoning_effort = "max".to_string(); let request = build_agent_interaction_llm_request( GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, "制作一个三消经营游戏", "", &llm, ) .expect("build interaction request"); let request = platform_llm::provider_request_from_llm_request("agent-interaction", request) .expect("neutral request"); assert_eq!( request.max_output_tokens(), Some(AGENT_INTERACTION_MAX_OUTPUT_TOKENS) ); assert_eq!(request.tools().len(), 3); assert_eq!( request.reasoning_effort(), Some(agent_runtime_core::ProviderReasoningEffort::Max) ); assert!(request.tools().iter().all(|tool| tool.strict())); assert_eq!( request.tool_choice(), &agent_runtime_core::ProviderToolChoice::Auto ); } #[test] fn interaction_request_propagates_invalid_reasoning_effort() { let mut llm = GameCreatorLlmConfig::default(); llm.reasoning_effort = "maximum".to_string(); let error = build_agent_interaction_llm_request( GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, "制作一个三消经营游戏", "", &llm, ) .expect_err("invalid reasoning effort must fail before Provider request"); assert!(error.contains("reasoning_effort")); } #[test] fn interaction_stream_sink_preserves_accumulation_delta_and_finish_reason() { let mut observed = Vec::new(); let mut callback = |delta: &platform_llm::LlmStreamDelta| observed.push(delta.clone()); let mut sink = AgentInteractionProviderStreamSink { on_delta: &mut callback, }; agent_runtime_core::ProviderStreamSink::emit( &mut sink, agent_runtime_core::ProviderStreamEvent::TextDelta { accumulated_text: "完成".to_string(), delta_text: "成".to_string(), finish_reason: Some("stop".to_string()), }, ) .expect("emit"); assert_eq!(observed.len(), 1); assert_eq!(observed[0].accumulated_text, "完成"); assert_eq!(observed[0].delta_text, "成"); assert_eq!(observed[0].finish_reason.as_deref(), Some("stop")); } #[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("不接受参数")); } }