From 65f2632198422876115f8ca0ce656c2a0283cd16 Mon Sep 17 00:00:00 2001 From: AIGameCreator App Date: Sun, 12 Jul 2026 02:32:22 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E5=8D=95Agent=E6=B5=81?= =?UTF-8?q?=E5=BC=8F=E8=81=8A=E5=A4=A9=E4=B8=8E=E5=8E=9F=E7=94=9F=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E8=A7=84=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复 OpenAI SSE 空 choices、异常尾包和读取中断导致完整回复丢失 固定聊天记录区域并增加持续等待状态与内部滚动 接入原生 function tool 计划协议并补齐严格校验和回归测试 更新 AI 游戏创作技术方案与决策记录 --- .../src-tauri/src/agent.rs | 210 ++++- .../src-tauri/src/tests.rs | 459 +++++++++- apps/ai-game-creator-shell/src/App.tsx | 36 +- apps/ai-game-creator-shell/src/styles.css | 22 +- .../tests/appSurface.test.ts | 130 ++- .../shared-memory/decision-log.md | 3 +- ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 3 +- server-rs/crates/api-server/src/llm.rs | 2 + .../src/apimart_gpt5_adapter.rs | 2 + server-rs/crates/platform-llm/src/lib.rs | 852 ++++++++++++++++-- 10 files changed, 1583 insertions(+), 136 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent.rs b/apps/ai-game-creator-shell/src-tauri/src/agent.rs index 5ccb7a532..bcd259b04 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -293,11 +293,34 @@ where prompt, )?; let client = build_game_creator_llm_client_from_llm_config(&llm, &config_path)?; + let mut streamed_reply_text = String::new(); + let mut streamed_finish_reason = None; let response = client - .stream_run(request, |delta| on_delta(delta)) - .await - .map_err(|error| format!("{config_path} 单 Agent 流式聊天调用 LLM 失败:{error}"))?; - let reply_text = strip_llm_thinking_blocks(response.text.as_str()); + .stream_run(request, |delta| { + streamed_reply_text = delta.accumulated_text.clone(); + if let Some(finish_reason) = delta.finish_reason.as_deref() { + streamed_finish_reason = Some(finish_reason.to_string()); + } + on_delta(delta); + }) + .await; + let response_text = match response { + Ok(response) => response.text, + Err(_error) + if !streamed_reply_text.trim().is_empty() + && streamed_finish_reason + .as_deref() + .is_some_and(|finish_reason| !finish_reason.trim().is_empty()) => + { + streamed_reply_text + } + Err(error) => { + return Err(format!( + "{config_path} 单 Agent 流式聊天调用 LLM 失败:{error}" + )); + } + }; + let reply_text = strip_llm_thinking_blocks(response_text.as_str()); if reply_text.is_empty() { return Err(format!("{config_path} 单 Agent 流式聊天未返回内容")); } @@ -2692,6 +2715,7 @@ async fn run_game_creator_agent_background_task_with_context( pub(crate) const AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT: usize = 6; const AGENT_RUNTIME_BACKGROUND_TOOL_ACTION_LIMIT: usize = 3; +pub(crate) const AGENT_RUNTIME_TOOL_PLAN_FUNCTION_NAME: &str = "submit_agent_tool_plan"; const AGENT_RUNTIME_RECENT_TOOL_CALL_LIMIT: usize = 20; const AGENT_RUNTIME_PLAN_STEP_LIMIT: usize = 8; const AGENT_RUNTIME_TOOL_OBSERVATION_MAX_CHARS: usize = 900; @@ -2728,6 +2752,14 @@ pub(crate) struct AgentRuntimeToolPlan { pub(crate) response: String, } +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct ParsedAgentRuntimeToolPlan { + pub(crate) plan: AgentRuntimeToolPlan, + pub(crate) protocol: &'static str, + pub(crate) call_id: Option, + pub(crate) function_name: Option, +} + #[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct AgentRuntimeToolAction { @@ -3382,6 +3414,9 @@ async fn request_game_creator_agent_background_tool_plan_at( )?; let client = build_game_creator_llm_client_from_llm_config(&llm, &config_path)?; for repair_attempt in 0..=AGENT_RUNTIME_TOOL_PLAN_FORMAT_REPAIR_ATTEMPTS { + if game_creator_agent_runtime_cancel_requested_for(root, agent_id, run_id) { + return Err("Agent 后台任务已收到取消请求".to_string()); + } let operation = if repair_attempt == 0 { "后台 Agent 工具计划".to_string() } else { @@ -3395,18 +3430,43 @@ async fn request_game_creator_agent_background_tool_plan_at( &llm, request.clone(), operation.as_str(), + false, ) .await .map_err(|error| format!("{config_path} {operation}调用 LLM 失败:{error}"))?; - match parse_game_creator_agent_tool_plan_response(response.text.as_str()) { - Ok(plan) => return Ok(plan), + match parse_game_creator_agent_tool_plan_llm_response(&response) { + Ok(parsed) => { + append_agent_db_record( + root, + serde_json::json!({ + "recordType": "agent.runtime.tool_plan.protocol", + "agentId": agent_id, + "sessionId": session_id, + "runId": run_id, + "loopIteration": loop_index, + "protocol": parsed.protocol, + "callId": parsed.call_id, + "functionName": parsed.function_name, + "responseId": response.response_id, + }), + )?; + return Ok(parsed.plan); + } Err(error) if repair_attempt < AGENT_RUNTIME_TOOL_PLAN_FORMAT_REPAIR_ATTEMPTS => { if game_creator_agent_runtime_cancel_requested_for(root, agent_id, run_id) { return Err("Agent 后台任务已收到取消请求".to_string()); } let next_attempt = repair_attempt + 1; - let response_preview = sanitize_agent_runtime_text(response.text.as_str(), 2_400); + let response_preview = + game_creator_agent_tool_plan_response_preview(&response, 2_400); let protocol_error = sanitize_agent_runtime_text(&error, 400); + let protocol = if response.tool_calls.is_empty() { + "text_json" + } else { + "native_function" + }; + let call_id = response.tool_calls.first().map(|call| call.id.clone()); + let function_name = response.tool_calls.first().map(|call| call.name.clone()); append_agent_db_record( root, serde_json::json!({ @@ -3419,13 +3479,16 @@ async fn request_game_creator_agent_background_tool_plan_at( "maxAttempts": AGENT_RUNTIME_TOOL_PLAN_FORMAT_REPAIR_ATTEMPTS, "protocolError": protocol_error, "responsePreview": response_preview, + "protocol": protocol, + "callId": call_id, + "functionName": function_name, }), )?; request .messages .push(LlmMessage::assistant(response_preview)); request.messages.push(LlmMessage::user(format!( - "上一条输出不符合工具计划协议:{protocol_error}\n请修复格式,只返回一个完整 JSON object。不要解释,不要 markdown,不要代码围栏,也不要在 JSON 前后添加任何文本。" + "上一条输出不符合工具计划协议:{protocol_error}\n请修复格式。支持 function tool 时重新调用 {AGENT_RUNTIME_TOOL_PLAN_FUNCTION_NAME};只有上游不支持 function tool 时才返回一个完整 JSON object。不要解释,不要 markdown,不要代码围栏,也不要在 JSON 前后添加任何文本。" ))); } Err(error) => { @@ -3463,6 +3526,7 @@ async fn request_game_creator_agent_background_final_reply_at( &llm, request, "后台 Agent 最终回复", + true, ) .await .map_err(|error| format!("{config_path} 后台 Agent 最终回复调用 LLM 失败:{error}"))?; @@ -3494,19 +3558,79 @@ fn build_game_creator_agent_background_tool_plan_request( let tool_policy_json = serde_json::to_string_pretty(&tool_policy) .map_err(|error| format!("序列化 Agent 工具策略失败:{error}"))?; let prompt = format!( - "当前工具策略:\n{tool_policy_json}\n\n运行上下文如下。你正在执行后台 Agent loop 第 {loop_index} 轮。项目记忆、对话、资产和文件内容不会预加载,只能依据已获准工具返回的 observation 使用;未出现在 observation 里的项目事实不得自行假设。请基于目标和已有工具观察修正计划,再决定是否调用最多 {AGENT_RUNTIME_BACKGROUND_TOOL_ACTION_LIMIT} 个白名单工具。只输出 JSON 对象,不要 markdown。\n\n{context}\n\n后台任务:\n{task}\n\n已有工具观察:\n{observations_json}\n\nJSON schema:{{\"thinkingSummary\":\"一句话理解\",\"plan\":[\"步骤\"],\"actions\":[{{\"tool\":\"memory.read|memory.write|conversation.read|asset.list|project.index|project.search|project.verify|project.checkpoint|project.restore|project.diff|file.list|file.read|file.write|file.patch|task.list|task.create|task.update|command.run_limited|preview.start|canvas.asset_generate|blackboard.write|agent.message|agent.delegate|agent.schedule_ready|agent.run_status\",\"reason\":\"为什么需要\",\"input\":{{}}}}],\"response\":\"如果无需继续调用工具,可直接给最终回复\"}}\n\n工具输入约定:memory.read 使用 {{\"scope\":\"session|project|blackboard|agent\"}};memory.write 使用 {{\"scope\":\"agent|project|session|blackboard\",\"title\":\"标题\",\"content\":\"要沉淀的稳定结论\",\"mode\":\"append|overwrite\"}},其中 agent scope 只能写当前 Agent 自己的私有记忆,跨 Agent 共享请用 blackboard.write 或 agent.message;project.search 使用 {{\"query\":\"要查找的字面文本\",\"path\":\"可选项目内相对范围\",\"maxResults\":20,\"caseSensitive\":false}},返回 path:line 和匹配行;project.verify 使用 {{\"script\":\"check|typecheck|test|lint|build\",\"expectedCommand\":\"从 package.json 读取的完整原始脚本\",\"timeoutSeconds\":120}},只执行项目根 package.json 中同名 npm 脚本,expectedCommand 不一致时拒绝执行,确认策略以当前工具策略中 project.verify 的独立权限为准;project.checkpoint input 可为空,用于在写文件或批量修改前创建本地 checkpoint;project.restore 使用 {{\"checkpointId\":\"checkpoint id\"}},用于在确认后把当前项目恢复到指定 checkpoint;project.diff 使用 {{\"checkpointId\":\"checkpoint id\"}},用于读取当前项目相对路径 diff 摘要;file.list 使用 {{\"path\":\"可选项目内相对目录或文件\"}},path 为空时列出项目摘要;file.read 使用 {{\"path\":\"项目内相对路径\",\"startLine\":1,\"maxLines\":120}},按行读取并返回行号;file.write 使用 {{\"path\":\"项目内相对路径\",\"content\":\"完整文件内容\"}};file.patch 使用 {{\"path\":\"项目内相对路径\",\"oldText\":\"必须精确匹配的原文\",\"newText\":\"替换后的文本\",\"expectedReplacements\":1}},匹配数不符时不写入,批量修改前应先调用 project.checkpoint;task.list input 可为空,用于读取 manifest 任务图、状态和 readyTaskIds;task.create 使用 {{\"taskId\":\"可选自定义 taskId\",\"title\":\"任务标题\",\"group\":\"design|art|code|balance|audio|publishing\",\"role\":\"角色名\",\"dependencies\":[\"已有 taskId\"],\"artifacts\":[\"预期产物\"],\"acceptanceCriteria\":[\"验收标准\"],\"status\":\"pending|running|waiting-for-confirmation|completed|failed\"}},用于把 Agent 拆出的新任务追加到 manifest;task.update 使用 {{\"taskId\":\"manifest taskId\",\"status\":\"pending|running|waiting-for-confirmation|completed|failed\"}};command.run_limited 使用 {{\"commandId\":\"game.static_smoke\"}},只支持本地静态自检;preview.start input 可为空,用于启动当前项目的 127.0.0.1 本地 HTTP 预览;canvas.asset_generate 使用 {{\"prompt\":\"要生成的美术素材描述\"}},通过配置的 External Editor API 生成首版素材并登记到 assets;blackboard.write 使用 {{\"title\":\"标题\",\"content\":\"要共享给所有 Agent 的稳定结论\"}};agent.message 使用 {{\"agentId\":\"目标 taskId\",\"content\":\"给目标 Agent 的定向消息\"}};agent.delegate 使用 {{\"agentId\":\"目标 taskId\",\"task\":\"要委派的后台任务\",\"runId\":\"可选 run id\"}},用于把任务投递到另一个 Agent 的独立队列;agent.schedule_ready input 可为空或 {{\"limit\":1}},用于把 manifest 中依赖已完成的 ready task 投递到对应 Agent 后台队列;agent.run_status 使用 {{\"agentId\":\"可选目标 taskId\",\"scope\":\"self|all\"}},用于读取自己或其他 Agent 的 Runtime 状态摘要;如果已有观察足够,请返回空 actions 并填写 response。其他工具 input 可为空。" + "当前工具策略:\n{tool_policy_json}\n\n运行上下文如下。你正在执行后台 Agent loop 第 {loop_index} 轮。项目记忆、对话、资产和文件内容不会预加载,只能依据已获准工具返回的 observation 使用;未出现在 observation 里的项目事实不得自行假设。请基于目标和已有工具观察修正计划,再决定是否调用最多 {AGENT_RUNTIME_BACKGROUND_TOOL_ACTION_LIMIT} 个白名单工具。请按后续结构化工具计划协议提交本轮结果。\n\n{context}\n\n后台任务:\n{task}\n\n已有工具观察:\n{observations_json}\n\nJSON schema:{{\"thinkingSummary\":\"一句话理解\",\"plan\":[\"步骤\"],\"actions\":[{{\"tool\":\"memory.read|memory.write|conversation.read|asset.list|project.index|project.search|project.verify|project.checkpoint|project.restore|project.diff|file.list|file.read|file.write|file.patch|task.list|task.create|task.update|command.run_limited|preview.start|canvas.asset_generate|blackboard.write|agent.message|agent.delegate|agent.schedule_ready|agent.run_status\",\"reason\":\"为什么需要\",\"input\":{{}}}}],\"response\":\"如果无需继续调用工具,可直接给最终回复\"}}\n\n工具输入约定:memory.read 使用 {{\"scope\":\"session|project|blackboard|agent\"}};memory.write 使用 {{\"scope\":\"agent|project|session|blackboard\",\"title\":\"标题\",\"content\":\"要沉淀的稳定结论\",\"mode\":\"append|overwrite\"}},其中 agent scope 只能写当前 Agent 自己的私有记忆,跨 Agent 共享请用 blackboard.write 或 agent.message;project.search 使用 {{\"query\":\"要查找的字面文本\",\"path\":\"可选项目内相对范围\",\"maxResults\":20,\"caseSensitive\":false}},返回 path:line 和匹配行;project.verify 使用 {{\"script\":\"check|typecheck|test|lint|build\",\"expectedCommand\":\"从 package.json 读取的完整原始脚本\",\"timeoutSeconds\":120}},只执行项目根 package.json 中同名 npm 脚本,expectedCommand 不一致时拒绝执行,确认策略以当前工具策略中 project.verify 的独立权限为准;project.checkpoint input 可为空,用于在写文件或批量修改前创建本地 checkpoint;project.restore 使用 {{\"checkpointId\":\"checkpoint id\"}},用于在确认后把当前项目恢复到指定 checkpoint;project.diff 使用 {{\"checkpointId\":\"checkpoint id\"}},用于读取当前项目相对路径 diff 摘要;file.list 使用 {{\"path\":\"可选项目内相对目录或文件\"}},path 为空时列出项目摘要;file.read 使用 {{\"path\":\"项目内相对路径\",\"startLine\":1,\"maxLines\":120}},按行读取并返回行号;file.write 使用 {{\"path\":\"项目内相对路径\",\"content\":\"完整文件内容\"}};file.patch 使用 {{\"path\":\"项目内相对路径\",\"oldText\":\"必须精确匹配的原文\",\"newText\":\"替换后的文本\",\"expectedReplacements\":1}},匹配数不符时不写入,批量修改前应先调用 project.checkpoint;task.list input 可为空,用于读取 manifest 任务图、状态和 readyTaskIds;task.create 使用 {{\"taskId\":\"可选自定义 taskId\",\"title\":\"任务标题\",\"group\":\"design|art|code|balance|audio|publishing\",\"role\":\"角色名\",\"dependencies\":[\"已有 taskId\"],\"artifacts\":[\"预期产物\"],\"acceptanceCriteria\":[\"验收标准\"],\"status\":\"pending|running|waiting-for-confirmation|completed|failed\"}},用于把 Agent 拆出的新任务追加到 manifest;task.update 使用 {{\"taskId\":\"manifest taskId\",\"status\":\"pending|running|waiting-for-confirmation|completed|failed\"}};command.run_limited 使用 {{\"commandId\":\"game.static_smoke\"}},只支持本地静态自检;preview.start input 可为空,用于启动当前项目的 127.0.0.1 本地 HTTP 预览;canvas.asset_generate 使用 {{\"prompt\":\"要生成的美术素材描述\"}},通过配置的 External Editor API 生成首版素材并登记到 assets;blackboard.write 使用 {{\"title\":\"标题\",\"content\":\"要共享给所有 Agent 的稳定结论\"}};agent.message 使用 {{\"agentId\":\"目标 taskId\",\"content\":\"给目标 Agent 的定向消息\"}};agent.delegate 使用 {{\"agentId\":\"目标 taskId\",\"task\":\"要委派的后台任务\",\"runId\":\"可选 run id\"}},用于把任务投递到另一个 Agent 的独立队列;agent.schedule_ready input 可为空或 {{\"limit\":1}},用于把 manifest 中依赖已完成的 ready task 投递到对应 Agent 后台队列;agent.run_status 使用 {{\"agentId\":\"可选目标 taskId\",\"scope\":\"self|all\"}},用于读取自己或其他 Agent 的 Runtime 状态摘要;如果已有观察足够,请返回空 actions 并填写 response。其他工具 input 可为空。" ); - let request = LlmRunRequest::new(vec![ + let api_kind = parse_game_creator_llm_api_kind(&llm.api_kind)?; + let mut request = LlmRunRequest::new(vec![ LlmMessage::system(game_creator_agent_runtime_tool_plan_system_prompt()), LlmMessage::user(prompt), + LlmMessage::user(format!( + "协议要求:支持 function tool 时必须调用 {AGENT_RUNTIME_TOOL_PLAN_FUNCTION_NAME},不要把计划放在普通文本中;只有上游不支持 function tool 时才返回同结构的单个 JSON 对象。" + )), ]) - .with_api_kind(parse_game_creator_llm_api_kind(&llm.api_kind)?) + .with_api_kind(api_kind) .with_max_output_tokens(AGENT_RUNTIME_TOOL_PLAN_MAX_OUTPUT_TOKENS) .with_response_reasoning_effort(platform_llm::LlmResponseReasoningEffort::Low) .with_response_text_verbosity(platform_llm::LlmResponseTextVerbosity::Low); + if api_kind != platform_llm::LlmApiKind::Anthropic { + request = request + .with_function_tools(vec![game_creator_agent_tool_plan_function_tool()]) + .with_tool_choice(platform_llm::LlmToolChoice::Required); + } Ok((llm, config_path, request)) } +fn game_creator_agent_tool_plan_function_tool() -> platform_llm::LlmFunctionTool { + platform_llm::LlmFunctionTool::new( + AGENT_RUNTIME_TOOL_PLAN_FUNCTION_NAME, + "提交本轮 Agent 的任务理解、短计划、白名单工具动作或最终回复。Runtime 只执行 arguments 中经过本地策略校验的动作。", + serde_json::json!({ + "type": "object", + "required": ["thinkingSummary", "plan", "actions", "response"], + "additionalProperties": false, + "properties": { + "thinkingSummary": { + "type": "string", + "minLength": 1, + "description": "一句话概括当前任务理解和决策依据" + }, + "plan": { + "type": "array", + "maxItems": 5, + "items": { "type": "string" } + }, + "actions": { + "type": "array", + "maxItems": AGENT_RUNTIME_BACKGROUND_TOOL_ACTION_LIMIT, + "items": { + "type": "object", + "required": ["tool", "reason", "input"], + "additionalProperties": false, + "properties": { + "tool": { + "type": "string", + "enum": agent_runtime_executable_tools() + }, + "reason": { + "type": ["string", "null"] + }, + "input": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "response": { + "type": "string", + "description": "actions 为空且已有观察足够时给开发者的最终回复,否则为空字符串" + } + } + }), + ) + .with_strict(true) +} + fn build_game_creator_agent_background_final_reply_request( root: &Path, agent_id: &str, @@ -3567,6 +3691,60 @@ pub(crate) fn parse_game_creator_agent_tool_plan_response( let stripped = strip_llm_thinking_blocks(content); let payload = extract_json_payload(stripped.as_str()) .ok_or_else(|| "Agent 工具计划协议错误:未返回完整 JSON 对象".to_string())?; + parse_game_creator_agent_tool_plan_payload(payload) +} + +pub(crate) fn parse_game_creator_agent_tool_plan_llm_response( + response: &platform_llm::LlmRunResponse, +) -> Result { + if response.tool_calls.is_empty() { + return parse_game_creator_agent_tool_plan_response(response.text.as_str()).map(|plan| { + ParsedAgentRuntimeToolPlan { + plan, + protocol: "text_json", + call_id: None, + function_name: None, + } + }); + } + if response.tool_calls.len() != 1 { + return Err(format!( + "Agent 工具计划协议错误:必须恰好调用一次 {AGENT_RUNTIME_TOOL_PLAN_FUNCTION_NAME},实际收到 {} 次 function call", + response.tool_calls.len() + )); + } + let call = &response.tool_calls[0]; + if call.name != AGENT_RUNTIME_TOOL_PLAN_FUNCTION_NAME { + return Err(format!( + "Agent 工具计划协议错误:期望调用 {AGENT_RUNTIME_TOOL_PLAN_FUNCTION_NAME},实际调用 {}", + call.name + )); + } + let plan = parse_game_creator_agent_tool_plan_payload(call.arguments.as_str()) + .map_err(|error| format!("{error};function arguments 解析失败"))?; + Ok(ParsedAgentRuntimeToolPlan { + plan, + protocol: "native_function", + call_id: Some(call.id.clone()), + function_name: Some(call.name.clone()), + }) +} + +fn game_creator_agent_tool_plan_response_preview( + response: &platform_llm::LlmRunResponse, + max_chars: usize, +) -> String { + if response.tool_calls.is_empty() { + return sanitize_agent_runtime_text(response.text.as_str(), max_chars); + } + let serialized = serde_json::to_string(&response.tool_calls) + .unwrap_or_else(|_| "无法序列化 function calls".to_string()); + sanitize_agent_runtime_text(&serialized, max_chars) +} + +fn parse_game_creator_agent_tool_plan_payload( + payload: &str, +) -> Result { let mut plan = serde_json::from_str::(payload) .map_err(|error| format!("解析 Agent 工具计划失败:{error}"))?; if plan.thinking_summary.trim().is_empty() { @@ -9214,7 +9392,7 @@ pub(crate) fn game_creator_role_agent_chat_system_prompt() -> &'static str { } pub(crate) fn game_creator_agent_runtime_tool_plan_system_prompt() -> &'static str { - "你是 Genarrative AI 游戏创作多智能体 Runtime 中的专业 Agent。你必须在白名单工具内规划行动:先给一句 thinkingSummary,再给短计划,再决定是否请求工具。只能请求 memory.read、memory.write、conversation.read、asset.list、project.index、project.search、project.verify、project.checkpoint、project.restore、project.diff、file.list、file.read、file.write、file.patch、task.list、task.create、task.update、command.run_limited、preview.start、canvas.asset_generate、blackboard.write、agent.message、agent.delegate、agent.schedule_ready、agent.run_status。处理代码任务时先用 project.search 定位,再用带行号的 file.read 获取足够上下文;优先使用 file.patch 做精确局部修改,批量修改前创建 project.checkpoint,修改后再次读取验证。需要执行 package.json 中的 check、typecheck、test、lint 或 build 时,先读取 package.json,再把脚本名和读到的完整命令原样提交给 project.verify;不得猜测或改写 expectedCommand。Agent 私有记忆只能由本人写入,跨 Agent 共享稳定结论用 blackboard.write,给单个 Agent 留上下文用 agent.message。不要假装工具已执行;工具结果会由 Runtime 作为 observation 返回。只输出 JSON 对象,不要 markdown,不要泄露密钥。" + "你是 Genarrative AI 游戏创作多智能体 Runtime 中的专业 Agent。你必须在白名单工具内规划行动:先给一句 thinkingSummary,再给短计划,再决定是否请求工具。只能请求 memory.read、memory.write、conversation.read、asset.list、project.index、project.search、project.verify、project.checkpoint、project.restore、project.diff、file.list、file.read、file.write、file.patch、task.list、task.create、task.update、command.run_limited、preview.start、canvas.asset_generate、blackboard.write、agent.message、agent.delegate、agent.schedule_ready、agent.run_status。处理代码任务时先用 project.search 定位,再用带行号的 file.read 获取足够上下文;优先使用 file.patch 做精确局部修改,批量修改前创建 project.checkpoint,修改后再次读取验证。需要执行 package.json 中的 check、typecheck、test、lint 或 build 时,先读取 package.json,再把脚本名和读到的完整命令原样提交给 project.verify;不得猜测或改写 expectedCommand。Agent 私有记忆只能由本人写入,跨 Agent 共享稳定结论用 blackboard.write,给单个 Agent 留上下文用 agent.message。不要假装工具已执行;工具结果会由 Runtime 作为 observation 返回。优先调用 submit_agent_tool_plan function tool 提交结构化计划;只有上游不支持 function tool 时才返回同结构的单个 JSON 对象。不要 markdown,不要泄露密钥。" } pub(crate) fn game_creator_agent_role_definition( @@ -9907,6 +10085,7 @@ async fn request_game_creator_agent_llm_text_retrying_recoverable( llm: &GameCreatorLlmConfig, request: LlmRunRequest, operation: &str, + allow_stream: bool, ) -> Result { const MAX_EMPTY_RETRIES: u32 = 3; const MAX_TRANSIENT_RETRIES: u32 = 2; @@ -9914,7 +10093,12 @@ async fn request_game_creator_agent_llm_text_retrying_recoverable( let mut empty_retries = 0u32; let mut transient_retries = 0u32; loop { - match request_game_creator_llm_text(client, llm, request.clone()).await { + let result = if allow_stream { + request_game_creator_llm_text(client, llm, request.clone()).await + } else { + client.run(request.clone()).await + }; + match result { Ok(response) => return Ok(response), Err(platform_llm::LlmError::EmptyResponse) if empty_retries < MAX_EMPTY_RETRIES => { empty_retries += 1; diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/tests.rs index bc7ad0668..37fbfcea6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests.rs @@ -1239,6 +1239,90 @@ fn spawn_mock_llm_server_responses_with_capture( base_url } +fn spawn_mock_llm_raw_responses_with_capture( + response_bodies: Vec, + request_sender: Option>, +) -> String { + let listener = TcpListener::bind(("127.0.0.1", 0)).expect("mock raw llm bind"); + let base_url = format!("http://{}", listener.local_addr().expect("mock llm addr")); + std::thread::spawn(move || { + for response_body in response_bodies { + let (mut stream, _) = listener.accept().expect("mock raw llm accept"); + let request_text = read_mock_http_request(&mut stream); + if let Some(sender) = request_sender.as_ref() { + let _ = sender.send(request_text); + } + let body = response_body.to_string(); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + stream + .write_all(response.as_bytes()) + .expect("mock raw llm response"); + } + }); + base_url +} + +fn native_agent_tool_plan_chat_response( + call_id: &str, + function_name: &str, + arguments: impl Into, +) -> serde_json::Value { + serde_json::json!({ + "id": format!("chatcmpl-{call_id}"), + "model": "mock-game-model", + "choices": [{ + "message": { + "content": null, + "tool_calls": [{ + "id": call_id, + "type": "function", + "function": { + "name": function_name, + "arguments": arguments.into() + } + }] + }, + "finish_reason": "tool_calls" + }], + "usage": { + "prompt_tokens": 11, + "completion_tokens": 22, + "total_tokens": 33 + } + }) +} + +fn spawn_releasable_mock_llm_raw_response_with_capture( + response_body: serde_json::Value, + request_sender: mpsc::Sender, + release_receiver: mpsc::Receiver<()>, +) -> String { + let listener = TcpListener::bind(("127.0.0.1", 0)).expect("mock releasable raw llm bind"); + let base_url = format!("http://{}", listener.local_addr().expect("mock llm addr")); + std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("mock releasable raw llm accept"); + let request_text = read_mock_http_request(&mut stream); + let _ = request_sender.send(request_text); + release_receiver + .recv_timeout(Duration::from_secs(10)) + .expect("mock raw llm response release"); + let body = response_body.to_string(); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + stream + .write_all(response.as_bytes()) + .expect("mock releasable raw llm response"); + }); + base_url +} + fn spawn_releasable_mock_llm_server_responses_with_capture( response_contents: Vec, request_sender: mpsc::Sender, @@ -1985,6 +2069,54 @@ async fn chat_with_game_creator_role_agent_stream_emits_deltas() { fs::remove_dir_all(root).ok(); } +#[tokio::test] +async fn chat_with_game_creator_role_agent_stream_keeps_completed_reply_after_bad_tail() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); + + let base_url = spawn_mock_llm_stream_server_with_capture( + concat!( + "data: {\"choices\":[{\"delta\":{\"content\":\"完整回复\"}}]}\n\n", + "data: {\"choices\":[{\"finish_reason\":\"stop\"}]}\n\n", + "data: {\"choices\":[}\n\n" + ) + .to_string(), + None, + ); + let _config_guard = write_test_local_config(format!( + r#"{{ + "agentLlm": {{ + "art-director": {{ + "apiKey": "art-key", + "baseUrl": {base_url:?}, + "model": "art-chat-model", + "apiKind": "openai_chat" + }} + }} +}}"# + )); + + let mut deltas = Vec::new(); + let reply = chat_with_game_creator_role_agent_stream_at( + &root, + "art-director", + "给我一个完整回复", + |delta| deltas.push(delta.clone()), + ) + .await + .expect("completed stream reply should survive a malformed tail event"); + + assert_eq!(reply.reply_text, "完整回复"); + assert_eq!( + deltas + .last() + .and_then(|delta| delta.finish_reason.as_deref()), + Some("stop") + ); + + fs::remove_dir_all(root).ok(); +} + #[test] fn agent_runtime_tool_policy_snapshot_reflects_project_policy() { let root = unique_project_path(); @@ -2352,7 +2484,8 @@ async fn background_agent_runtime_task_executes_plan_tool_observation_loop() { assert!(plan_request.contains("当前工具策略")); assert!(plan_request.contains("confirmTools")); assert!(plan_request.contains("task.update")); - assert!(plan_request.contains("只输出 JSON 对象")); + assert!(plan_request.contains("submit_agent_tool_plan")); + assert!(plan_request.contains("\"tool_choice\":\"required\"")); assert!(plan_request.contains("后台分析当前玩法循环")); assert!(!plan_request.contains("核心循环:收集月光食材并躲避暗影")); assert!(!plan_request.contains("黑板:必须先确认核心循环")); @@ -2476,6 +2609,105 @@ async fn background_agent_runtime_task_executes_plan_tool_observation_loop() { fs::remove_dir_all(root).ok(); } +#[tokio::test] +async fn background_agent_runtime_executes_native_function_tool_plan() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); + let (sender, receiver) = mpsc::channel(); + let first_arguments = serde_json::json!({ + "thinkingSummary": "先读取项目索引确认结构", + "plan": ["读取项目索引", "根据观察回复"], + "actions": [{ + "tool": "project.index", + "reason": "确认项目当前文件结构", + "input": {} + }], + "response": "" + }) + .to_string(); + let final_arguments = final_tool_plan_response( + "已通过原生 function tool 完成项目索引读取。NATIVE_FUNCTION_TOOL_OK", + ); + let base_url = spawn_mock_llm_raw_responses_with_capture( + vec![ + native_agent_tool_plan_chat_response( + "call-native-index", + AGENT_RUNTIME_TOOL_PLAN_FUNCTION_NAME, + first_arguments, + ), + native_agent_tool_plan_chat_response( + "call-native-final", + AGENT_RUNTIME_TOOL_PLAN_FUNCTION_NAME, + final_arguments, + ), + ], + Some(sender), + ); + let _config_guard = write_test_local_config(format!( + r#"{{ + "agentLlm": {{ + "design-director": {{ + "apiKey": "design-key", + "baseUrl": {base_url:?}, + "model": "design-runtime-model", + "apiKind": "openai_chat", + "stream": true + }} + }} +}}"# + )); + let run_id = "design-native-function-tool-run"; + + start_game_creator_agent_background_task_at( + &root, + "design-director", + "用原生 function tool 读取项目索引后回复", + run_id, + ) + .expect("start native function tool task"); + + let first_request = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("native plan request"); + assert!(first_request.contains("POST /chat/completions HTTP/1.1")); + assert!(first_request.contains("\"name\":\"submit_agent_tool_plan\"")); + assert!(first_request.contains("\"tool_choice\":\"required\"")); + assert!(first_request.contains("\"strict\":true")); + assert!(first_request.contains("\"stream\":false")); + let followup_request = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("native followup request"); + assert!(followup_request.contains("project.index")); + assert!(followup_request.contains("\"tool_choice\":\"required\"")); + assert!(receiver.recv_timeout(Duration::from_millis(200)).is_err()); + + let runtime = wait_for_agent_runtime_idle(&root, "design-director"); + assert_eq!(runtime.status, "idle"); + assert_eq!(runtime.phase, "completed"); + assert_eq!( + runtime.last_response.as_deref(), + Some("已通过原生 function tool 完成项目索引读取。NATIVE_FUNCTION_TOOL_OK") + ); + assert_eq!(runtime.recent_tool_calls.len(), 1); + assert_eq!(runtime.recent_tool_calls[0].tool, "project.index"); + assert_eq!(runtime.recent_tool_calls[0].status, "ok"); + + let protocol_records = read_agent_db_records_for_test(&root) + .into_iter() + .filter(|record| { + record["recordType"] == "agent.runtime.tool_plan.protocol" && record["runId"] == run_id + }) + .collect::>(); + assert_eq!(protocol_records.len(), 2); + assert!(protocol_records + .iter() + .all(|record| record["protocol"] == "native_function")); + assert_eq!(protocol_records[0]["callId"], "call-native-index"); + assert_eq!(protocol_records[1]["callId"], "call-native-final"); + + fs::remove_dir_all(root).ok(); +} + #[tokio::test] async fn background_agent_runtime_marks_response_plan_step_failed_when_final_reply_fails() { let root = unique_project_path(); @@ -9510,6 +9742,106 @@ fn agent_llm_transient_error_classification_matches_retry_contract() { } } +#[tokio::test] +async fn background_agent_runtime_repairs_malformed_native_function_arguments() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); + let (sender, receiver) = mpsc::channel(); + let repaired_arguments = + final_tool_plan_response("原生 function arguments 已自动修复。NATIVE_REPAIR_OK"); + let base_url = spawn_mock_llm_raw_responses_with_capture( + vec![ + native_agent_tool_plan_chat_response( + "call-native-malformed-1", + AGENT_RUNTIME_TOOL_PLAN_FUNCTION_NAME, + "{", + ), + native_agent_tool_plan_chat_response( + "call-native-malformed-2", + AGENT_RUNTIME_TOOL_PLAN_FUNCTION_NAME, + r#"{"thinkingSummary":"#, + ), + native_agent_tool_plan_chat_response( + "call-native-repaired", + AGENT_RUNTIME_TOOL_PLAN_FUNCTION_NAME, + repaired_arguments, + ), + ], + Some(sender), + ); + let _config_guard = write_test_local_config(format!( + r#"{{ + "agentLlm": {{ + "design-director": {{ + "apiKey": "design-key", + "baseUrl": {base_url:?}, + "model": "design-runtime-model", + "apiKind": "openai_chat" + }} + }} +}}"# + )); + let run_id = "design-native-tool-plan-repair-run"; + + start_game_creator_agent_background_task_at( + &root, + "design-director", + "验证原生 function arguments 格式修复", + run_id, + ) + .expect("start malformed native task"); + + let initial_request = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("initial native tool plan request"); + assert!(initial_request.contains("\"tool_choice\":\"required\"")); + let repair_request = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("first native tool plan repair request"); + assert!(repair_request.contains("call-native-malformed")); + assert!(repair_request.contains("function arguments")); + assert!(repair_request.contains("重新调用 submit_agent_tool_plan")); + let second_repair_request = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("second native tool plan repair request"); + assert!(second_repair_request.contains("call-native-malformed-2")); + assert!(second_repair_request.contains("function arguments")); + assert!(receiver.recv_timeout(Duration::from_millis(200)).is_err()); + + let runtime = wait_for_agent_runtime_idle(&root, "design-director"); + assert_eq!(runtime.status, "idle"); + assert_eq!(runtime.phase, "completed"); + assert_eq!( + runtime.last_response.as_deref(), + Some("原生 function arguments 已自动修复。NATIVE_REPAIR_OK") + ); + + let records = read_agent_db_records_for_test(&root); + let repair_records = records + .iter() + .filter(|record| { + record["recordType"] == "agent.runtime.tool_plan.repair" && record["runId"] == run_id + }) + .collect::>(); + assert_eq!(repair_records.len(), 2); + assert_eq!(repair_records[0]["attempt"], 1); + assert_eq!(repair_records[0]["protocol"], "native_function"); + assert_eq!(repair_records[0]["callId"], "call-native-malformed-1"); + assert_eq!(repair_records[1]["attempt"], 2); + assert_eq!(repair_records[1]["callId"], "call-native-malformed-2"); + assert!(repair_records + .iter() + .all(|record| record["functionName"] == AGENT_RUNTIME_TOOL_PLAN_FUNCTION_NAME)); + assert!(records.iter().any(|record| { + record["recordType"] == "agent.runtime.tool_plan.protocol" + && record["runId"] == run_id + && record["protocol"] == "native_function" + && record["callId"] == "call-native-repaired" + })); + + fs::remove_dir_all(root).ok(); +} + #[tokio::test] async fn background_agent_runtime_repairs_malformed_tool_plan_in_same_run() { let root = unique_project_path(); @@ -9561,7 +9893,8 @@ async fn background_agent_runtime_repairs_malformed_tool_plan_in_same_run() { assert!(repair_request.contains("\"role\":\"assistant\"")); assert!(repair_request.contains("格式损坏")); assert!(repair_request.contains("上一条输出不符合工具计划协议")); - assert!(repair_request.contains("只返回一个完整 JSON object")); + assert!(repair_request.contains("重新调用 submit_agent_tool_plan")); + assert!(repair_request.contains("才返回一个完整 JSON object")); assert!(receiver.recv_timeout(Duration::from_millis(200)).is_err()); let runtime = wait_for_agent_runtime_idle(&root, "design-director"); @@ -11064,8 +11397,12 @@ async fn background_agent_runtime_cancellation_wins_over_inflight_llm_error() { init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); let (request_sender, request_receiver) = mpsc::channel(); let (release_sender, release_receiver) = mpsc::channel(); - let base_url = spawn_releasable_mock_llm_server_responses_with_capture( - vec!["{invalid-tool-plan}".to_string()], + let base_url = spawn_releasable_mock_llm_raw_response_with_capture( + native_agent_tool_plan_chat_response( + "call-cancel-malformed", + AGENT_RUNTIME_TOOL_PLAN_FUNCTION_NAME, + "{", + ), request_sender, release_receiver, ); @@ -11076,7 +11413,7 @@ async fn background_agent_runtime_cancellation_wins_over_inflight_llm_error() { "apiKey": "design-key", "baseUrl": {base_url:?}, "model": "design-runtime-model", - "apiKind": "openai_responses" + "apiKind": "openai_chat" }} }} }}"# @@ -11126,6 +11463,11 @@ async fn background_agent_runtime_cancellation_wins_over_inflight_llm_error() { assert!(!conversation.messages.iter().any(|message| { message.role == "assistant" && message.content.contains("后台任务失败") })); + let records = read_agent_db_records_for_test(&root); + assert!(!records.iter().any(|record| { + record["recordType"] == "agent.runtime.tool_plan.repair" + && record["runId"] == "design-cancel-llm-error" + })); fs::remove_dir_all(root).ok(); } @@ -12834,6 +13176,113 @@ fn strip_llm_thinking_blocks_removes_reasoning_wrappers() { ); } +fn agent_tool_plan_llm_response( + text: impl Into, + tool_calls: Vec, +) -> platform_llm::LlmRunResponse { + platform_llm::LlmRunResponse { + provider: platform_llm::LlmProvider::OpenAiCompatible, + model: "mock-game-model".to_string(), + text: text.into(), + finish_reason: Some("tool_calls".to_string()), + response_id: Some("response-tool-plan-test".to_string()), + usage: None, + tool_calls, + } +} + +#[test] +fn agent_tool_plan_parser_accepts_native_function_arguments() { + let arguments = serde_json::json!({ + "thinkingSummary": "先读取项目索引", + "plan": ["读取索引", "根据观察继续"], + "actions": [{ + "tool": "project.index", + "reason": "确认项目结构", + "input": {} + }], + "response": "" + }) + .to_string(); + let response = agent_tool_plan_llm_response( + "", + vec![platform_llm::LlmToolCall { + id: "call-native-plan".to_string(), + name: AGENT_RUNTIME_TOOL_PLAN_FUNCTION_NAME.to_string(), + arguments, + }], + ); + + let parsed = parse_game_creator_agent_tool_plan_llm_response(&response) + .expect("native function arguments"); + + assert_eq!(parsed.protocol, "native_function"); + assert_eq!(parsed.call_id.as_deref(), Some("call-native-plan")); + assert_eq!( + parsed.function_name.as_deref(), + Some(AGENT_RUNTIME_TOOL_PLAN_FUNCTION_NAME) + ); + assert_eq!(parsed.plan.actions.len(), 1); + assert_eq!(parsed.plan.actions[0].tool, "project.index"); +} + +#[test] +fn agent_tool_plan_parser_rejects_wrong_or_multiple_native_calls() { + let valid_arguments = final_tool_plan_response("不应执行"); + let wrong_function = agent_tool_plan_llm_response( + "", + vec![platform_llm::LlmToolCall { + id: "call-wrong".to_string(), + name: "wrong_function".to_string(), + arguments: valid_arguments.clone(), + }], + ); + let wrong_error = parse_game_creator_agent_tool_plan_llm_response(&wrong_function) + .expect_err("wrong function must be rejected"); + assert!(wrong_error.contains("实际调用 wrong_function")); + + let multiple_calls = agent_tool_plan_llm_response( + "", + vec![ + platform_llm::LlmToolCall { + id: "call-one".to_string(), + name: AGENT_RUNTIME_TOOL_PLAN_FUNCTION_NAME.to_string(), + arguments: valid_arguments.clone(), + }, + platform_llm::LlmToolCall { + id: "call-two".to_string(), + name: AGENT_RUNTIME_TOOL_PLAN_FUNCTION_NAME.to_string(), + arguments: valid_arguments, + }, + ], + ); + let multiple_error = parse_game_creator_agent_tool_plan_llm_response(&multiple_calls) + .expect_err("multiple function calls must be rejected"); + assert!(multiple_error.contains("实际收到 2 次 function call")); +} + +#[test] +fn agent_tool_plan_parser_rejects_malformed_native_arguments_and_falls_back_to_text() { + let malformed = agent_tool_plan_llm_response( + "", + vec![platform_llm::LlmToolCall { + id: "call-malformed".to_string(), + name: AGENT_RUNTIME_TOOL_PLAN_FUNCTION_NAME.to_string(), + arguments: "{".to_string(), + }], + ); + let malformed_error = parse_game_creator_agent_tool_plan_llm_response(&malformed) + .expect_err("malformed function arguments must be rejected"); + assert!(malformed_error.contains("function arguments 解析失败")); + + let text_plan = final_tool_plan_response("TEXT_JSON_FALLBACK_OK"); + let fallback = agent_tool_plan_llm_response(text_plan, Vec::new()); + let parsed = + parse_game_creator_agent_tool_plan_llm_response(&fallback).expect("text json fallback"); + assert_eq!(parsed.protocol, "text_json"); + assert_eq!(parsed.plan.response, "TEXT_JSON_FALLBACK_OK"); +} + #[test] fn agent_tool_plan_parser_uses_first_complete_json_object_before_explanation() { let thinking_summary = r#"先解析字符串里的 {大括号}、引号 "quoted" 和反斜杠 \path"#; diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 4318e1463..74b3236c4 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -4404,19 +4404,25 @@ export function WorkspaceLauncher({ }, ); } catch (error) { - if (pendingStreamDraftText) { + if (pendingStreamDraftText && pendingStreamFinishReason) { + reply = { replyText: pendingStreamDraftText }; + setAgentChatStatus( + `Agent 回复已结束(${pendingStreamFinishReason}),正在保存已接收内容`, + ); + } else if (pendingStreamDraftText) { throw error; + } else { + setAgentChatStatus('流式连接失败,正在切换普通回复模式'); + reply = await invoke( + 'chat_with_game_creator_role_agent', + { + projectPath: projectPathForChat, + agentId: agent.id, + prompt: content, + ...agentChatSessionInvokeArgs(sessionIdForChat), + }, + ); } - setAgentChatStatus('流式连接失败,正在切换普通回复模式'); - reply = await invoke( - 'chat_with_game_creator_role_agent', - { - projectPath: projectPathForChat, - agentId: agent.id, - prompt: content, - ...agentChatSessionInvokeArgs(sessionIdForChat), - }, - ); } } else { reply = await invoke( @@ -5800,7 +5806,9 @@ export function WorkspaceLauncher({
{agentChatMessages.length > 0 ? ( agentChatMessages.map((message, index) => ( @@ -5819,9 +5827,13 @@ export function WorkspaceLauncher({ className="launcher-agent-chat-waiting" role="status" aria-live="polite" + aria-atomic="true" >
) : null} diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index 30353124a..275cf04b3 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -1060,6 +1060,7 @@ textarea { .launcher-agent-chat-layout { display: grid; grid-template-columns: minmax(220px, 0.3fr) minmax(0, 1fr); + align-items: start; gap: 12px; width: 100%; min-width: 0; @@ -1150,6 +1151,8 @@ textarea { .launcher-agent-chat-main { display: grid; grid-template-rows: repeat(6, auto); + align-content: start; + width: 100%; overflow: visible; } @@ -1259,12 +1262,15 @@ textarea { display: grid; align-content: start; gap: 10px; - height: clamp(280px, 44vh, 420px); + height: clamp(260px, 40vh, 380px); + max-height: 380px; min-height: 0; padding: 14px; overflow-y: auto; overscroll-behavior: contain; scrollbar-gutter: stable; + overscroll-behavior: contain; + scrollbar-gutter: stable; } .launcher-agent-chat-messages .message { @@ -1303,17 +1309,31 @@ textarea { } .launcher-agent-chat-waiting strong { + display: block; min-width: 0; font-size: 12px; overflow-wrap: anywhere; } +.launcher-agent-chat-waiting small { + display: block; + margin-top: 2px; + color: #6b7280; + font-size: 11px; +} + @keyframes launcher-agent-chat-spin { to { transform: rotate(360deg); } } +@media (prefers-reduced-motion: reduce) { + .launcher-agent-chat-waiting > span { + animation: none; + } +} + .launcher-agent-chat-composer { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; diff --git a/apps/ai-game-creator-shell/tests/appSurface.test.ts b/apps/ai-game-creator-shell/tests/appSurface.test.ts index d45029f1d..3a45e58e1 100644 --- a/apps/ai-game-creator-shell/tests/appSurface.test.ts +++ b/apps/ai-game-creator-shell/tests/appSurface.test.ts @@ -968,7 +968,7 @@ describe('AI 游戏创作 App 界面边界', () => { ).not.toBeNull(); }); - it('opens the developer agent chat entry and persists selected agent messages', async () => { + it('keeps and persists a completed streamed reply when its tail event fails', async () => { const persistedMessages: Array<{ role: 'user' | 'assistant' | 'tool'; content: string; @@ -980,6 +980,10 @@ describe('AI 游戏创作 App 界面边界', () => { agentId: null, }, ]; + const streamedReply = '流式正文已经完整结束。'; + let streamHandler: + | ((event: { payload: Record }) => void) + | null = null; const invoke = vi.fn( async (command: string, args?: Record) => { if (command === 'read_local_conversation') { @@ -994,12 +998,21 @@ describe('AI 游戏创作 App 界面边界', () => { }; } if (command === 'chat_with_game_creator_role_agent_stream') { + streamHandler?.({ + payload: { + projectPath: args?.projectPath, + agentId: args?.agentId, + runId: args?.runId, + status: 'delta', + deltaText: streamedReply, + accumulatedText: streamedReply, + finishReason: 'stop', + }, + }); throw new Error('LLM SSE 响应缺少 choices[0]'); } if (command === 'chat_with_game_creator_role_agent') { - return { - replyText: mockRoleAgentReply(), - }; + throw new Error('completed stream must not fall back to another request'); } if (command === 'append_local_conversation_message') { const message = args?.message as { @@ -1021,7 +1034,21 @@ describe('AI 游戏创作 App 界面边界', () => { throw new Error(`unexpected invoke ${command}`); }, ); - const listen = vi.fn(async () => () => {}); + const listen = vi.fn( + async ( + eventName: string, + handler: (event: { payload: Record }) => void, + ) => { + if (eventName === 'game-creator-role-agent-chat-stream') { + streamHandler = handler; + } + return () => { + if (streamHandler === handler) { + streamHandler = null; + } + }; + }, + ); window.__TAURI__ = { core: { invoke }, event: { listen } }; renderLauncherAgentChatAt('/?agent-chat'); @@ -1046,7 +1073,7 @@ describe('AI 游戏创作 App 界面边界', () => { expect( await screen.findByText('请单独评估这个角色设定流程'), ).not.toBeNull(); - expect(await screen.findByText(roleAgentMockReply)).not.toBeNull(); + expect(await screen.findByText(streamedReply)).not.toBeNull(); expect(invoke).toHaveBeenCalledWith('append_local_conversation_message', { projectPath: '/tmp/authorized-game', agentId: 'design-director', @@ -1061,15 +1088,17 @@ describe('AI 游戏创作 App 界面边界', () => { agentId: 'design-director', message: { role: 'assistant', - content: roleAgentMockReply, + content: streamedReply, agentId: null, }, }); - expect(invoke).toHaveBeenCalledWith('chat_with_game_creator_role_agent', { - projectPath: '/tmp/authorized-game', - agentId: 'design-director', - prompt: '请单独评估这个角色设定流程', - }); + expect(invoke).not.toHaveBeenCalledWith( + 'chat_with_game_creator_role_agent', + expect.anything(), + ); + expect( + screen.queryByText(/已保存用户消息;Agent 回复失败/), + ).toBeNull(); expect(invoke).toHaveBeenCalledWith( 'chat_with_game_creator_role_agent_stream', expect.objectContaining({ @@ -1084,6 +1113,78 @@ describe('AI 游戏创作 App 界面边界', () => { ); }); + it('falls back to a normal reply when streaming fails before the first delta', async () => { + const persistedMessages: Array<{ + role: 'user' | 'assistant' | 'tool'; + content: string; + agentId: string | null; + }> = []; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'read_local_conversation') { + return { + path: '/tmp/authorized-game/.agent/conversations/agents/design-director.jsonl', + agentId: args?.agentId, + messages: persistedMessages, + }; + } + if (command === 'chat_with_game_creator_role_agent_stream') { + throw new Error('stream disconnected before first delta'); + } + if (command === 'chat_with_game_creator_role_agent') { + return { replyText: '普通回复补位成功。' }; + } + if (command === 'append_local_conversation_message') { + persistedMessages.push( + args?.message as { + role: 'user' | 'assistant' | 'tool'; + content: string; + agentId: string | null; + }, + ); + return { + path: '/tmp/authorized-game/.agent/conversations/agents/design-director.jsonl', + agentId: args?.agentId, + messages: persistedMessages.map((message, index) => ({ + schemaVersion: '1', + ...message, + updatedAt: index + 1, + })), + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { + core: { invoke }, + event: { listen: vi.fn(async () => () => {}) }, + }; + renderLauncherAgentChatAt('/?agent-chat'); + + fireEvent.change(screen.getByLabelText('Agent 聊天项目目录'), { + target: { value: '/tmp/authorized-game' }, + }); + fireEvent.click(screen.getByRole('button', { name: '读取历史' })); + expect(await screen.findByText(/已读取 0 条/)).not.toBeNull(); + + fireEvent.change(screen.getByLabelText('Agent 聊天内容'), { + target: { value: '流式失败时继续回答' }, + }); + fireEvent.click(screen.getByRole('button', { name: '发送' })); + + expect(await screen.findByText('普通回复补位成功。')).not.toBeNull(); + expect(invoke).toHaveBeenCalledWith('chat_with_game_creator_role_agent', { + projectPath: '/tmp/authorized-game', + agentId: 'design-director', + prompt: '流式失败时继续回答', + }); + expect(persistedMessages.at(-1)).toEqual({ + role: 'assistant', + content: '普通回复补位成功。', + agentId: null, + }); + }); + it('keeps the launcher usable and persists a normal reply when runtime listens reject', async () => { const persistedMessages: Array<{ role: 'user' | 'assistant' | 'tool'; @@ -1833,6 +1934,11 @@ describe('AI 游戏创作 App 界面边界', () => { screen.getByLabelText('Agent 聊天记录'), ).findByRole('status'); expect(waitingForFirstDelta.textContent).toContain('已连接 Agent LLM'); + expect(waitingForFirstDelta.textContent).toContain('请求仍在进行中'); + expect(screen.getByLabelText('Agent 聊天记录').getAttribute('role')).toBe( + 'log', + ); + expect(screen.getByLabelText('Agent 聊天记录').tabIndex).toBe(0); await act(async () => { releaseFirstDelta?.(); }); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 9208de6f7..3fd3be880 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -21,7 +21,7 @@ - 背景:开发用单 Agent 聊天已经能真实调用各 Agent 的 LLM 路由并持久化对话,但 Agent 仍主要表现为同步问答,用户无法明确投递一个任务让某个 Agent 独立运行,也无法同时启动多个 Agent 的工作。 - 决策:在现有 `.agent/runtime` 和 `.agent/conversations` 基础上新增单 Agent 后台任务入口。Tauri 命令 `start_game_creator_agent_runtime_task` 立即写入该 Agent 的 runtime state/event/task history,追加用户任务到 `.agent/conversations/agents/.jsonl`,随后在 App 进程内启动 tokio task 执行最小 Agent loop:Agent 按轮输出 `thinkingSummary / plan / actions / response`,Runtime 按白名单和项目权限策略执行工具并记录 `action / observation` 事件,再把已有 observation 放回下一轮 prompt,让 Agent 修正计划、继续行动或用空 actions + response 收束;当前后台任务最多执行 3 轮 loop,仍未收束时再按最后计划和全部观察生成最终回复。完成或失败后把 assistant 回复或错误追加回对话,并写入 `.agent/agent.db` 审计记录。工具箱包含只读工具 `memory.read`、`conversation.read`、`asset.list`、`project.index`、`project.diff`、`file.list`、`file.read`、`agent.run_status`,以及受策略保护的写/运行工具 `memory.write`、`file.write`、`command.run_limited`、`blackboard.write`、`agent.message` 和 `agent.delegate`;`memory.write` 可追加或覆盖本 Agent 私有记忆、项目长期/短期记忆或黑板,`file.write` 只能写项目内相对路径,`command.run_limited` 只接受 `game.static_smoke` 并复用本地静态自检安全边界,`blackboard.write` 追加共享黑板,`agent.message` 写目标 Agent 对话,`agent.delegate` 把任务投递到目标 Agent 的独立后台队列;策略拒绝时不执行工具并把 `blocked` observation 回给 Agent;策略要求确认时不执行工具,而是持久化精确待确认动作并暂停该 Agent 队列,待开发者确认或拒绝后在同一 run 续跑。每个 Agent 的任务历史落在 `.agent/runtime/tasks/.jsonl`,读 runtime 时按 `runId` 去重返回最近任务,任务视角状态使用 `pending / running / completed / failed`,Runtime state 增加 `nextStep`,UI 在 Runtime 面板和主 Agent 状态卡展示当前任务、动作、下一步与最近任务。不同 Agent 使用独立 `.agent/runtime/locks/.lock`,允许并行运行;同一 Agent 已有运行任务时,新任务会先进入该 Agent 的 pending 队列,当前 drain 持锁完成后串行继续下一条 pending。该能力仍不是独立 OS 进程或跨重启离线常驻 worker。 - 2026-07-10 补充:后台 Runtime 每次追加 `.agent/runtime/events/.jsonl` 后会通过 Tauri `game-creator-agent-runtime-update` 事件广播当前 `AgentRuntimeResult`;开发单 Agent 聊天页、项目内 Agent 对话弹窗和主窗口 Agent 状态列表都只把该事件作为实时 UI 通知并复用前端 runtime 归一化合并,事实源仍是 `.agent/runtime/agents`、`events` 和 `tasks` 文件。 -- 2026-07-11 补充:开发单 Agent 聊天页保留整页纵向滚动,聊天消息区固定响应式高度并在内部滚动;Runtime 恢复确认区使用独立布局行,避免与 Runtime 详情或聊天内容重叠。Runtime 面板详情可折叠且折叠时不渲染详情 DOM,但状态标题与任务控制按钮继续保留;等待 LLM 时在消息区显示动态状态,连续流式 delta 合并到动画帧更新并跳过重复 Runtime state。OpenAI Chat SSE 会收集 usage-only 尾包、保留 finish reason 与上游 error message,收到 `[DONE]` 后立即结束;持久事件订阅失败时显示非致命错误,聊天事件监听不可用或首个文本片段前流式失败时降级普通回复并继续落盘。 +- 2026-07-11 补充:开发单 Agent 聊天页保留整页纵向滚动,聊天消息区固定响应式高度并在内部滚动;Runtime 恢复确认区使用独立布局行,避免与 Runtime 详情或聊天内容重叠。Runtime 面板详情可折叠且折叠时不渲染详情 DOM,但状态标题与任务控制按钮继续保留;等待 LLM 时在消息区持续显示动态状态和进行中提示,连续流式 delta 合并到动画帧更新并跳过重复 Runtime state。OpenAI Chat SSE 会跳过空 `choices` 心跳 / 元数据事件,收集 usage-only 尾包、保留 finish reason 与上游 error message,收到 `[DONE]` 后立即结束;正文与 finish reason 已接收后出现尾包异常时保存已完成正文,不把整轮改写成失败。持久事件订阅失败时显示非致命错误,聊天事件监听不可用或首个文本片段前流式失败时降级普通回复并继续落盘。 - 2026-07-11 补充:为缩小单 Agent 与 Codex CLI 在代码任务上的差距,Runtime 工具箱新增 `project.search` 和 `file.patch`,并扩展 `file.read` 的按行分页。`project.search` 在项目内执行有界字面量检索,默认忽略大小写,返回相对路径、行号和匹配行,跳过 `.agent`、敏感配置、依赖和构建目录;权限继承 `file.read`。`file.read` 接受 `startLine / maxLines`,返回带行号的最多 240 行、8,000 字符上下文,允许 Agent 继续分页而不是只看到文件开头约 900 字符。`file.patch` 只做 `oldText -> newText` 精确替换,必须声明预期匹配数,匹配数不符时不写入;它继承 `file.write` 权限,复用项目写锁和 Runtime 动作账本,并追加不含代码正文的 `agent.runtime.file.patch` 审计记录。三者组成“搜索定位 -> 分段读取 -> 局部修改 -> 再次读取验证”的最小代码工作闭环,不开放任意 shell。 - 2026-07-11 补充:单 Agent 代码闭环新增开发专用 `project.verify`,用于在修改后执行项目根 `package.json` 已定义的 `check / typecheck / test / lint / build` 之一;当前只支持 npm,不接受自由命令、参数或工作目录。Agent 必须先读取 `package.json`,再把脚本名、完整原始脚本文本 `expectedCommand` 和 1-300 秒超时一起提交;Runtime 在真正执行前重新解析 JSON 并做精确一致性校验,脚本漂移时拒绝执行。该工具使用独立、默认 `confirm` 的 `project.verify` 权限,不再与 `command.run_limited` / `game.static_smoke` 共用授权;确认指纹覆盖脚本正文和超时。执行时不经过 App 自行拼接的 `bash -c`,而由 npm 执行已确认的项目脚本,并附加 `--ignore-scripts` 阻止 `pre/post` 生命周期旁路;继承环境被清理到 PATH 与必要平台变量,HOME/TMP/npm cache 隔离,stdin 关闭,输出保留有界头尾。Unix 下验证根进程正常结束或超时都会清理同进程组残留后代;项目写锁会按持有 PID 回收崩溃遗留锁,并拒绝 `.agent` 符号链接逃逸。进入进程执行后的成功、非零退出、启动失败和超时会写 `.agent/logs/command.log`、manifest command run 与 `agent.runtime.project.verify` 审计;输入预检拒绝则只进入 Runtime observation / error 事件。输出先过滤敏感内容再进入 observation。只要最新 `project.verify` 未通过,或通过后又发生 `file.write / file.patch / project.restore`,Runtime 就拒绝模型用空 actions 假完成,继续要求修复和重新验证;耗尽 loop 仍未通过时保持失败。该能力会执行用户项目自身脚本,环境隔离不等同于 OS 沙箱,不能把不可信项目脚本视为安全代码;它不是自由 shell 代理,也不进入普通用户命令入口。 - 2026-07-11 补充:开发侧新增 headless 单 Agent Runtime 入口 `npm run ai-game-creator-shell:agent-task -- [--init] `。该入口不实现第二套 Agent,只复用 Tauri App 的持久任务队列、per-agent 锁、LLM 路由、权限策略、工具 action / observation loop、对话和审计文件,并轮询到 `completed / failed / waiting-for-confirmation` 后用稳定键值行退出;`--init` 只在显式传入且 manifest 不存在时初始化项目。遇到待确认动作时 CLI 返回非零并打印 actionId、tool 和脱敏摘要,后续仍由开发窗口完成确认,不提供静默 `--yes` 绕过。 @@ -31,6 +31,7 @@ - 2026-07-11 调整:后台结构化 planning 使用独立的 4,000 输出 token 上限,最终回复使用 2,400;两者显式请求 low reasoning effort 和 low text verbosity。`platform-llm` 会把 reasoning effort 同时映射到 OpenAI Responses 的 `reasoning.effort` 与 Chat Completions 的 `reasoning_effort`,未设置时不新增字段。真实 gpt-5.5 Chat 响应曾连续消耗约 1,000-1,400 completion tokens 却不返回 message content,低推理强度、较大的可见输出余量和 EmptyResponse 重试共同构成恢复策略。 - 2026-07-11 补充:后台单 Agent 的工具计划响应只接受可反序列化为计划 schema 的 JSON object。解析器提取模型输出中的首个完整对象并允许对象后带普通说明;未找到完整 JSON 对象,或提取对象无法反序列化为工具计划时,Runtime 最多追加 2 次自动格式修复请求。每次修复只携带限长、脱敏后的上一次无效输出,并写入 `agent.runtime.tool_plan.repair` 审计。两次修复后仍无有效对象则按工具规划失败处理;工具规划阶段的普通文本不得转换为默认的空 actions + response,也不得据此把任务标记为完成。 - 2026-07-11 调整:工具计划顶层 `thinkingSummary / plan / actions / response` 四个字段必须同时存在,未知顶层字段、空 thinkingSummary 和空 tool 均属于协议错误并进入同一格式修复预算,`{}` 或前置无关 JSON 对象不能再触发空计划收束。空 actions 表示 planning 收束;response 非空时直接采用,response 为空时进入独立的最终回复生成。`agent.runtime.project.verify` 审计同时保存 `runId / actionId / actionFingerprint`,使并行 Agent 的失败与通过记录能够精确归属到发起动作。 +- 2026-07-12 补充:OpenAI Chat / Responses 的后台 Agent 工具 planning 改用唯一 `submit_agent_tool_plan` 原生 function tool,字符串 `tool_choice=required` 和 strict schema;只接受恰好一次同名调用,arguments 继续经过本地计划 schema、工具白名单和权限策略校验,错误函数、多调用或非法 arguments 进入原有两次格式修复预算且不产生副作用。Anthropic 保留文本 JSON 回退;planning 非流式,最终回复仍可流式。每轮成功协议写 `agent.runtime.tool_plan.protocol`,修复审计记录 protocol、callId 和 functionName。 - 2026-07-10 补充:后台 Agent Runtime 的白名单工具继续扩到 `preview.start`,让 Agent 在完成写盘或静态自检后能按策略自行启动当前项目的 `127.0.0.1` 本地 HTTP 预览。该工具复用 `preview.start` 权限策略、项目写锁、共享 `PreviewRegistry`、manifest 预览状态、`.agent/logs/preview.log` 和 run trace 追加逻辑;写入 `.agent/agent.db` 的审计类型为 `agent.runtime.preview.start`。发给 LLM 的 observation 只包含 localhost URL 和端口,不包含用户项目绝对路径。 - 2026-07-10 补充:后台 Agent Runtime 的白名单工具继续扩到 `canvas.asset_generate`,让美术类 Agent 可在 loop 中自行请求生成首版美术素材。该工具读取 AppData / Tauri 配置中的 `editorApi`,复用 `canvas.asset_generate` 权限策略、项目写锁、External Editor API 生成和下载链路、manifest 资产登记以及 `canvas.asset_generate` 本地索引记录;另写 `agent.runtime.canvas.asset_generate` 记录到 `.agent/agent.db`,标明触发的 agent 与本地素材路径。API Key 不进入 prompt observation、manifest、agent.db 或日志;策略要求确认或拒绝时不会调用外部 API。 - 补充:规范 Agent ID 统一使用 manifest taskId,例如 `art-asset-plan` 和 `code-prototype`;历史前端曾使用的 `group-role` 别名只在 Tauri command 层兼容并映射到规范 taskId。主窗口 Agent 状态列表通过 `read_game_creator_agent_runtimes` 批量读取 `.agent/runtime/agents/.json` 和最近任务,把每个 Agent 的 Runtime 状态、当前动作和最近 task 直接显示在状态卡片和 `/agents` 汇总里。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index cb34d2079..e0ec89f54 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -52,7 +52,7 @@ Agent Runtime 负责: - 2026-07-10 补充:`recentEvents` 接入前端归一态和 Runtime 状态面板,事件事实源仍是 `.agent/runtime/events/.jsonl`;面板按时间展示最近 `thinking_summary / plan / action / observation / response / error` 事件,现在能同时看到 Agent 的计划、最近观察、最近事件、最近工具动作和任务队列。 - 2026-07-10 补充:后台 Runtime 每次追加 `.agent/runtime/events/.jsonl` 后会通过 Tauri `game-creator-agent-runtime-update` 事件广播当前 `AgentRuntimeResult`,开发单 Agent 聊天页、项目内 Agent 对话弹窗和主窗口 Agent 状态卡用同一套前端归一化逻辑合并状态;该事件只做实时 UI 通知,`.agent/runtime/agents`、`events` 和 `tasks` 仍是重开项目后的事实源。 - 2026-07-10 补充:后台 Agent loop 的统一语义事件类型为 `thinking_summary / plan / action / observation / response / error`。普通失败和 loop 预算耗尽都会追加 `error` 事件,并继续保留 `turn.failed / turn.budget_exhausted` 生命周期事件兼容既有读取方;开发窗口、项目内 Agent 对话弹窗和主窗口状态卡通过现有最近事件列表直接展示统一错误事件及其安全详情。状态面板默认保持最新 4 条的紧凑视图,当前后端返回的最近事件超过 4 条时可展开查看全部返回记录,确保同一 run 的六类语义事件不会因 UI 硬截断而无法检查。 -- 2026-07-11 补充:开发单 Agent 聊天页继续使用整页纵向滚动,不把 Runtime 锁进固定视口;聊天消息区使用固定响应式高度并在内部滚动,避免历史消息持续撑高聊天面板。可选的 Runtime 恢复确认区始终占据独立布局行,不能与 Runtime 详情或聊天消息重叠。Runtime 状态面板支持折叠详情,折叠时只卸载目标、计划、事件、动作和任务等详情 DOM,仍保留状态标题与取消、重试、确认、拒绝、刷新操作;等待 LLM 时在消息区显示连接 / 等待首包 / 接收中的动态状态。流式聊天的连续 delta 通过 `requestAnimationFrame` 合并为每帧最多一次消息更新,delta 不重复提交未变化的 Runtime state;OpenAI Chat SSE 的 usage-only 事件会回填最终 token usage,finish-only 事件会把结束原因送入状态流,上游 error 保留真实消息,`[DONE]` 立即结束读取;持久事件订阅失败时显示非致命 Runtime 错误,聊天事件监听不可用或流式请求在首个文本片段前失败时自动降级普通回复。 +- 2026-07-11 补充:开发单 Agent 聊天页继续使用整页纵向滚动,不把 Runtime 锁进固定视口;聊天消息区使用固定响应式高度并在内部滚动,避免历史消息持续撑高聊天面板。可选的 Runtime 恢复确认区始终占据独立布局行,不能与 Runtime 详情或聊天消息重叠。Runtime 状态面板支持折叠详情,折叠时只卸载目标、计划、事件、动作和任务等详情 DOM,仍保留状态标题与取消、重试、确认、拒绝、刷新操作;等待 LLM 时在消息区持续显示连接 / 等待首包 / 接收中的动态状态和“请求仍在进行中”提示。流式聊天的连续 delta 通过 `requestAnimationFrame` 合并为每帧最多一次消息更新,delta 不重复提交未变化的 Runtime state;OpenAI Chat SSE 的空 `choices` 心跳 / 元数据事件会跳过,usage-only 尾包会回填最终 token usage,finish-only 事件会把结束原因送入状态流,上游 error 保留真实消息,`[DONE]` 立即结束读取;正文与 finish reason 已接收后即使尾包异常也保存完整正文,不再改判整轮失败。持久事件订阅失败时显示非致命 Runtime 错误,聊天事件监听不可用或流式请求在首个文本片段前失败时自动降级普通回复。 - 2026-07-11 补充:后台单 Agent 新增 Codex 风格的代码导航与局部编辑闭环。`project.search` 接受 `query / path / maxResults / caseSensitive`,在项目边界内做字面量搜索并返回 `path:line`,最多扫描 500 个、单个不超过 512 KiB 的文本文件,跳过 `.agent`、`.git`、`node_modules`、`dist`、`build`、`target`、`.next`、`coverage` 和 `.env*`;该工具映射到 `file.read` 权限。`file.read` 接受 `startLine / maxLines`,返回带行号的指定片段、总行数和下一页提示,单次最多 240 行、8,000 字符。`file.patch` 接受 `path / oldText / newText / expectedReplacements`,只在实际匹配数与预期一致时持锁写入,目标文件和修改后文件最大 2 MiB,成功后写 `agent.runtime.file.patch` 审计;该工具映射到 `file.write` 权限。Agent planning prompt 明确要求批量修改前创建 checkpoint,并可在修改后再次 `file.read` 验证;本轮不开放任意 shell 命令。 - 2026-07-11 补充:代码修改后的真实验证由开发专用 `project.verify` 承接。输入固定为 `script / expectedCommand / timeoutSeconds`,其中 script 只允许项目根 `package.json` 中的 `check / typecheck / test / lint / build`,expectedCommand 必须与执行时重新读取的脚本正文完全一致,timeoutSeconds 为 1-300;当前执行器只支持 npm,其他 packageManager 或锁文件明确失败。工具映射到独立且默认需确认的 `project.verify` 权限,确认动作指纹绑定完整输入,不再因为放行验证而同时放行 `command.run_limited` 静态 smoke。执行器由 npm 运行已确认脚本,使用 `--ignore-scripts`、空 stdin、隔离 HOME/TMP/cache、清理后的环境、独立进程组和有界脱敏输出;Unix 下无论根进程正常结束还是超时都会清理同组残留后代。项目写锁记录 PID 和唯一 nonce,活进程继续持锁,Unix 死进程锁或跨平台超过安全时限的无效锁可回收,且控制路径拒绝符号链接。进入进程执行后的终态写命令日志和 manifest command run,Agent 触发时另写 `agent.runtime.project.verify`;输入预检拒绝只写 Runtime observation / error 事件。失败输出作为 observation 回到下一轮 planning。最新验证失败,或验证通过后又执行 `file.write / file.patch / project.restore` 时,空 actions 不再代表完成,Runtime 会注入 `runtime.verification: blocked` 并继续 replan;loop 耗尽仍未形成新通过结果时保持失败。该能力会执行用户项目脚本,环境隔离不是 OS 沙箱;普通用户 `/smoke` 与 `game.static_smoke` 保持原边界,不暴露该开发工具。 - 2026-07-11 补充:开发验证可用 `npm run ai-game-creator-shell:agent-task -- [--init] ` 无 UI 启动单 Agent 后台任务。CLI 只负责可选初始化、调用现有 Runtime、按 runId 轮询终态并打印 `status / phase / replyText / pendingActionId`,不复制 planning 或工具执行逻辑;默认 10 分钟轮询上限。`waiting-for-confirmation` 会以非零状态退出并要求转到开发窗口确认,CLI 不提供跳过项目权限的自动确认参数。该入口用于真实 provider 的可重复端到端验收,不进入普通用户界面。 @@ -61,6 +61,7 @@ Agent Runtime 负责: - 2026-07-11 调整:后台任务的可执行正文上限统一为 4,000 字符。入队 JSONL、启动后的 `currentTask/currentGoal`、planning prompt、待确认动作 task context、确认续跑和重启恢复都保留同一份正文;对话仍保存用户原始消息。状态事件、列表卡片和 `agent.db` 摘要可继续使用较短安全预览,但不能再反向作为后续 LLM 执行输入。这样长任务末尾的验收标记和输出格式要求不会在队列边界被 180 字符截断。 - 2026-07-11 调整:后台 planning 不再复用普通聊天的 1,800 输出 token 上限,而是使用 4,000;最终回复使用 2,400。两类请求均设置 low reasoning effort / low text verbosity;OpenAI Responses 序列化为 `reasoning.effort=low`,OpenAI Chat Completions 序列化为可选 `reasoning_effort=low`。该设置用于避免推理模型把全部 completion 预算消耗在不可见 reasoning 后留下空 content,并继续叠加最多 3 次 EmptyResponse 重试。 - 2026-07-11 补充:后台单 Agent 的工具 planning 响应必须提供可反序列化为 `thinkingSummary / plan / actions / response` schema 的 JSON object。Runtime 从模型输出中解析首个完整对象,因此对象后的尾随说明可以忽略;只有普通文本、没有完整对象,或对象无法反序列化时都不构成有效工具计划。对于这两类无效输出,Runtime 最多追加 2 次自动格式修复请求,每次只把限长且经过统一敏感信息过滤的上一次输出作为修复上下文,并把修复尝试写入 `.agent/agent.db` 的 `agent.runtime.tool_plan.repair` 审计。修复预算耗尽后进入既有工具规划失败路径,不得把普通文本折算为空 actions + response,也不得因此进入 completed;最终回复阶段仍按其独立的普通文本契约处理。 +- 2026-07-12 补充:OpenAI Chat / Responses 的后台工具 planning 优先注册唯一的 `submit_agent_tool_plan` function tool,并使用字符串形式 `tool_choice=required` 和 strict schema;Runtime 只接受恰好一次同名 function call,并把 arguments 复用现有 `AgentRuntimeToolPlan` 校验与两次格式修复循环。错误函数名、多次调用和非法 arguments 都不得执行工具。Anthropic 保留文本 JSON 回退,planning 强制非流式,最终普通回复继续按 Agent 配置决定是否流式。`platform-llm` 会在本地拒绝无 function tools 的 tool choice 和 Anthropic function tools,并把协议类型写入 `agent.runtime.tool_plan.protocol` 审计。 - 2026-07-11 调整:工具计划四个顶层字段均为必填并拒绝未知顶层字段;thinkingSummary 与 action.tool 必须非空。这样 `{}`、前置无关 JSON 或结构不完整对象会触发格式修复,不会成为假完成信号。空 actions 表示 planning 收束;response 非空时直接采用,response 为空时进入独立最终回复生成。`agent.runtime.project.verify` 记录补充 `runId / actionId / actionFingerprint`,用于在多 Agent 并行验证时把命令终态与具体 Runtime 动作关联。 - 2026-07-10 补充:Agent Runtime state / result 新增 `taskQueue`,从 `.agent/runtime/tasks/.jsonl` 中每个 `runId` 的最新记录汇总 `total / pending / running / completed / failed / latestRunId`;开发窗口 Runtime 面板、主窗口 Agent 状态列表、`agent.run_status` observation 和下一轮 planning prompt 都读取该摘要,用于判断同一 Agent 是否仍有排队任务。该字段是运行观测摘要,不新增调度器、SQLite 或独立 worker。 - 2026-07-10 补充:Runtime 新增 `agent.schedule_ready` 调度入口。开发构建可在权限确认后扫描 manifest ready task,把依赖已完成且仍为 `pending` 的任务标成 `running`,并按 taskId 投递到对应 Agent 的既有后台队列;source 固定为 `agent-ready-task-scheduler`,审计记录写 `agent.runtime.ready_task.scheduled`。该入口只把 manifest ready task 接入现有 per-agent 队列、锁、JSONL、LLM loop、工具策略和事件流,不新增独立 worker,也不会在默认确认策略下静默启动。 diff --git a/server-rs/crates/api-server/src/llm.rs b/server-rs/crates/api-server/src/llm.rs index 252a3baf7..657b590e1 100644 --- a/server-rs/crates/api-server/src/llm.rs +++ b/server-rs/crates/api-server/src/llm.rs @@ -46,6 +46,8 @@ pub async fn proxy_llm_chat_completions( request_timeout_ms: None, response_reasoning_effort: None, response_text_verbosity: None, + function_tools: Vec::new(), + tool_choice: None, }; if payload.stream { diff --git a/server-rs/crates/platform-agent/src/apimart_gpt5_adapter.rs b/server-rs/crates/platform-agent/src/apimart_gpt5_adapter.rs index 0b83dfa91..38cb99016 100644 --- a/server-rs/crates/platform-agent/src/apimart_gpt5_adapter.rs +++ b/server-rs/crates/platform-agent/src/apimart_gpt5_adapter.rs @@ -54,5 +54,7 @@ pub fn build_gpt5_multimodal_request( api_kind: LlmApiKind::OpenAiChat, response_reasoning_effort: None, response_text_verbosity: None, + function_tools: Vec::new(), + tool_choice: None, } } diff --git a/server-rs/crates/platform-llm/src/lib.rs b/server-rs/crates/platform-llm/src/lib.rs index 6759908fd..594709cc8 100644 --- a/server-rs/crates/platform-llm/src/lib.rs +++ b/server-rs/crates/platform-llm/src/lib.rs @@ -75,7 +75,59 @@ pub enum LlmMessageContentPart { InputImage { image_url: String }, } -// 文本补全请求冻结为“消息列表 + 可选模型覆盖 + 可选 max_output_tokens”最小闭环。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct LlmFunctionTool { + pub name: String, + pub description: String, + pub parameters: serde_json::Value, + #[serde(default)] + pub strict: bool, +} + +impl LlmFunctionTool { + pub fn new( + name: impl Into, + description: impl Into, + parameters: serde_json::Value, + ) -> Self { + Self { + name: name.into(), + description: description.into(), + parameters, + strict: false, + } + } + + pub fn with_strict(mut self, strict: bool) -> Self { + self.strict = strict; + self + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LlmToolChoice { + Auto, + Required, +} + +impl LlmToolChoice { + fn as_str(self) -> &'static str { + match self { + Self::Auto => "auto", + Self::Required => "required", + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct LlmToolCall { + pub id: String, + pub name: String, + pub arguments: String, +} + +// 统一请求同时承载消息、输出参数与 OpenAI 原生 function tools。 #[derive(Clone, Debug, PartialEq, Eq)] pub struct LlmRunRequest { pub model: Option, @@ -86,6 +138,8 @@ pub struct LlmRunRequest { pub request_timeout_ms: Option, pub response_reasoning_effort: Option, pub response_text_verbosity: Option, + pub function_tools: Vec, + pub tool_choice: Option, } // 默认走 OpenAI Responses;旧 OpenAI Chat Completions 兼容入口显式选择。 @@ -150,7 +204,7 @@ pub struct LlmTokenUsage { pub total_tokens: u64, } -// 统一文本响应,避免业务层再去解析 choices/message/content。 +// 统一文本与工具调用响应,避免业务层重复解析不同 OpenAI 协议。 #[derive(Clone, Debug, PartialEq, Eq)] pub struct LlmRunResponse { pub provider: LlmProvider, @@ -159,6 +213,7 @@ pub struct LlmRunResponse { pub finish_reason: Option, pub response_id: Option, pub usage: Option, + pub tool_calls: Vec, } // 将上游错误归一到稳定的领域枚举,后续 api-server 可以直接映射成 HTTP error contract。 @@ -218,11 +273,22 @@ struct ChatCompletionsRequestBody { reasoning_effort: Option<&'static str>, #[serde(skip_serializing_if = "Option::is_none")] web_search_options: Option, + #[serde(skip_serializing_if = "Option::is_none")] + tools: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + tool_choice: Option<&'static str>, } #[derive(Serialize)] struct ChatCompletionsWebSearchOptions {} +#[derive(Serialize)] +struct ChatCompletionsFunctionTool { + #[serde(rename = "type")] + tool_type: &'static str, + function: LlmFunctionTool, +} + #[derive(Serialize)] struct ChatCompletionsInputMessage { role: &'static str, @@ -260,7 +326,9 @@ struct ResponsesRequestBody { #[serde(skip_serializing_if = "Option::is_none")] max_output_tokens: Option, #[serde(skip_serializing_if = "Option::is_none")] - tools: Option>, + tools: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + tool_choice: Option<&'static str>, #[serde(skip_serializing_if = "Option::is_none")] reasoning: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -287,6 +355,21 @@ struct ResponsesWebSearchTool { max_keyword: u8, } +#[derive(Serialize)] +#[serde(untagged)] +enum ResponsesTool { + WebSearch(ResponsesWebSearchTool), + Function(ResponsesFunctionTool), +} + +#[derive(Serialize)] +struct ResponsesFunctionTool { + #[serde(rename = "type")] + tool_type: &'static str, + #[serde(flatten)] + function: LlmFunctionTool, +} + #[derive(Serialize)] struct AnthropicMessagesRequestBody { model: String, @@ -356,6 +439,20 @@ struct ChatCompletionsChoice { struct ChatCompletionsMessage { #[serde(default)] content: Option, + #[serde(default)] + tool_calls: Vec, +} + +#[derive(Deserialize)] +struct ChatCompletionsToolCall { + id: String, + function: ChatCompletionsFunctionCall, +} + +#[derive(Deserialize)] +struct ChatCompletionsFunctionCall { + name: String, + arguments: String, } #[derive(Deserialize)] @@ -389,8 +486,19 @@ struct ResponsesResponseEnvelope { #[derive(Deserialize)] struct ResponsesOutputItem { + #[serde(rename = "type")] + #[serde(default)] + item_type: Option, #[serde(default)] content: Vec, + #[serde(default)] + id: Option, + #[serde(default)] + call_id: Option, + #[serde(default)] + name: Option, + #[serde(default)] + arguments: Option, } #[derive(Deserialize)] @@ -454,6 +562,12 @@ struct ParsedStreamEvent { is_terminal: bool, } +#[derive(Debug)] +struct SseEventDrainError { + parsed_events: Vec, + error: LlmError, +} + impl LlmProvider { pub fn as_str(&self) -> &'static str { match self { @@ -640,6 +754,8 @@ impl LlmRunRequest { request_timeout_ms: None, response_reasoning_effort: None, response_text_verbosity: None, + function_tools: Vec::new(), + tool_choice: None, } } @@ -695,6 +811,16 @@ impl LlmRunRequest { self } + pub fn with_function_tools(mut self, function_tools: Vec) -> Self { + self.function_tools = function_tools; + self + } + + pub fn with_tool_choice(mut self, tool_choice: LlmToolChoice) -> Self { + self.tool_choice = Some(tool_choice); + self + } + pub fn with_request_timeout_ms(mut self, request_timeout_ms: u64) -> Self { self.request_timeout_ms = Some(request_timeout_ms); self @@ -749,7 +875,33 @@ impl LlmRunRequest { )); } + if self.tool_choice.is_some() && self.function_tools.is_empty() { + return Err(LlmError::InvalidRequest( + "LLM tool_choice 必须与 function_tools 一起使用".to_string(), + )); + } + + for tool in &self.function_tools { + if tool.name.trim().is_empty() { + return Err(LlmError::InvalidRequest( + "LLM function tool name 不能为空".to_string(), + )); + } + if !tool.parameters.is_object() { + return Err(LlmError::InvalidRequest(format!( + "LLM function tool {} parameters 必须是 JSON object", + tool.name + ))); + } + } + if self.api_kind == LlmApiKind::Anthropic { + if !self.function_tools.is_empty() || self.tool_choice.is_some() { + return Err(LlmError::InvalidRequest( + "Anthropic api_kind 暂不支持 function tools".to_string(), + )); + } + if self.enable_web_search { return Err(LlmError::InvalidRequest( "Anthropic api_kind 暂不支持 web_search".to_string(), @@ -944,18 +1096,30 @@ impl LlmClient { let mut stream_terminated = false; loop { - let next_chunk = response.chunk().await.map_err(|error| { - let llm_error = map_stream_read_error(error, 1); - log_llm_raw_failure( - &self.config, - &request, - true, - 1, - "read_stream_failed", - parser.raw_text().as_str(), - ); - llm_error - })?; + let next_chunk = match response.chunk().await { + Ok(chunk) => chunk, + Err(error) => { + let llm_error = map_stream_read_error(error, 1); + if retain_completed_stream_after_tail_error( + accumulated_text.as_str(), + &finish_reason, + "read_stream_failed", + &llm_error, + ) { + stream_terminated = true; + break; + } + log_llm_raw_failure( + &self.config, + &request, + true, + 1, + "read_stream_failed", + parser.raw_text().as_str(), + ); + return Err(llm_error); + } + }; let Some(chunk) = next_chunk else { break; @@ -963,22 +1127,42 @@ impl LlmClient { undecoded_chunk_bytes.extend_from_slice(chunk.as_ref()); let (chunk_text, remaining_bytes) = - decode_utf8_stream_chunk(undecoded_chunk_bytes.as_slice()).map_err(|error| { - log_llm_raw_failure( - &self.config, - &request, - true, - 1, - "decode_stream_failed", - parser.raw_text().as_str(), - ); - error - })?; + match decode_utf8_stream_chunk(undecoded_chunk_bytes.as_slice()) { + Ok(decoded) => decoded, + Err(error) => { + if retain_completed_stream_after_tail_error( + accumulated_text.as_str(), + &finish_reason, + "decode_stream_failed", + &error, + ) { + stream_terminated = true; + break; + } + log_llm_raw_failure( + &self.config, + &request, + true, + 1, + "decode_stream_failed", + parser.raw_text().as_str(), + ); + return Err(error); + } + }; undecoded_chunk_bytes = remaining_bytes; if chunk_text.is_empty() { continue; } - let stream_events = parser.push_chunk(chunk_text.as_ref()).map_err(|error| { + stream_terminated = consume_stream_parser_result( + parser.push_chunk(chunk_text.as_ref()), + &mut accumulated_text, + &mut finish_reason, + &mut usage, + emit_finish_only_delta, + &mut on_delta, + ) + .map_err(|error| { log_llm_raw_failure( &self.config, &request, @@ -989,34 +1173,48 @@ impl LlmClient { ); error })?; - stream_terminated = consume_stream_events( - stream_events, - &mut accumulated_text, - &mut finish_reason, - &mut usage, - emit_finish_only_delta, - &mut on_delta, - ); if stream_terminated { break; } } if !stream_terminated && !undecoded_chunk_bytes.is_empty() { - let trailing_text = - std_str::from_utf8(undecoded_chunk_bytes.as_slice()).map_err(|error| { - log_llm_raw_failure( - &self.config, - &request, - true, - 1, + let trailing_text = match std_str::from_utf8(undecoded_chunk_bytes.as_slice()) { + Ok(text) => text, + Err(error) => { + let llm_error = + LlmError::Deserialize(format!("解析 LLM 流式 UTF-8 响应失败:{error}")); + if retain_completed_stream_after_tail_error( + accumulated_text.as_str(), + &finish_reason, "decode_stream_failed", - parser.raw_text().as_str(), - ); - LlmError::Deserialize(format!("解析 LLM 流式 UTF-8 响应失败:{error}")) - })?; - if !trailing_text.is_empty() { - let trailing_events = parser.push_chunk(trailing_text).map_err(|error| { + &llm_error, + ) { + stream_terminated = true; + "" + } else { + log_llm_raw_failure( + &self.config, + &request, + true, + 1, + "decode_stream_failed", + parser.raw_text().as_str(), + ); + return Err(llm_error); + } + } + }; + if !stream_terminated && !trailing_text.is_empty() { + stream_terminated = consume_stream_parser_result( + parser.push_chunk(trailing_text), + &mut accumulated_text, + &mut finish_reason, + &mut usage, + emit_finish_only_delta, + &mut on_delta, + ) + .map_err(|error| { log_llm_raw_failure( &self.config, &request, @@ -1027,19 +1225,19 @@ impl LlmClient { ); error })?; - stream_terminated = consume_stream_events( - trailing_events, - &mut accumulated_text, - &mut finish_reason, - &mut usage, - emit_finish_only_delta, - &mut on_delta, - ); } } if !stream_terminated { - let remaining_events = parser.finish().map_err(|error| { + consume_stream_parser_result( + parser.finish(), + &mut accumulated_text, + &mut finish_reason, + &mut usage, + emit_finish_only_delta, + &mut on_delta, + ) + .map_err(|error| { log_llm_raw_failure( &self.config, &request, @@ -1050,14 +1248,6 @@ impl LlmClient { ); error })?; - consume_stream_events( - remaining_events, - &mut accumulated_text, - &mut finish_reason, - &mut usage, - emit_finish_only_delta, - &mut on_delta, - ); } let content = accumulated_text.trim().to_string(); @@ -1080,6 +1270,7 @@ impl LlmClient { finish_reason, response_id, usage, + tool_calls: Vec::new(), }) } @@ -1276,7 +1467,7 @@ impl OpenAiCompatibleSseParser { } } - fn push_chunk(&mut self, chunk: &str) -> Result, LlmError> { + fn push_chunk(&mut self, chunk: &str) -> Result, SseEventDrainError> { self.raw_text.push_str(chunk); if self.terminated { return Ok(Vec::new()); @@ -1291,7 +1482,7 @@ impl OpenAiCompatibleSseParser { self.raw_text.clone() } - fn finish(&mut self) -> Result, LlmError> { + fn finish(&mut self) -> Result, SseEventDrainError> { if self.terminated || self.buffer.trim().is_empty() { return Ok(Vec::new()); } @@ -1300,14 +1491,23 @@ impl OpenAiCompatibleSseParser { self.drain_complete_events() } - fn drain_complete_events(&mut self) -> Result, LlmError> { + fn drain_complete_events(&mut self) -> Result, SseEventDrainError> { let mut events = Vec::new(); while let Some(boundary) = self.buffer.find("\n\n") { let block = self.buffer[..boundary].to_string(); self.buffer = self.buffer[(boundary + 2)..].to_string(); - if let Some(event) = parse_sse_event_block(self.api_kind, block.as_str())? { + let parsed_event = match parse_sse_event_block(self.api_kind, block.as_str()) { + Ok(event) => event, + Err(error) => { + return Err(SseEventDrainError { + parsed_events: events, + error, + }); + } + }; + if let Some(event) = parsed_event { let is_terminal = event.is_terminal; events.push(event); if is_terminal { @@ -1322,6 +1522,74 @@ impl OpenAiCompatibleSseParser { } } +fn consume_stream_parser_result( + result: Result, SseEventDrainError>, + accumulated_text: &mut String, + finish_reason: &mut Option, + usage: &mut Option, + emit_finish_only_delta: bool, + on_delta: &mut F, +) -> Result +where + F: FnMut(&LlmStreamDelta), +{ + let (events, tail_error) = match result { + Ok(events) => (events, None), + Err(error) => (error.parsed_events, Some(error.error)), + }; + let stream_terminated = consume_stream_events( + events, + accumulated_text, + finish_reason, + usage, + emit_finish_only_delta, + on_delta, + ); + + if stream_terminated { + return Ok(true); + } + if let Some(error) = tail_error { + if retain_completed_stream_after_tail_error( + accumulated_text.as_str(), + finish_reason, + "parse_stream_failed", + &error, + ) { + return Ok(true); + } + return Err(error); + } + + Ok(false) +} + +fn retain_completed_stream_after_tail_error( + accumulated_text: &str, + finish_reason: &Option, + stage: &str, + error: &LlmError, +) -> bool { + let is_tolerable_tail_error = matches!( + error.kind(), + LlmErrorKind::Timeout + | LlmErrorKind::Connectivity + | LlmErrorKind::Transport + | LlmErrorKind::Deserialize + ); + let retain_response = + !accumulated_text.trim().is_empty() && finish_reason.is_some() && is_tolerable_tail_error; + + if retain_response { + warn!( + "platform-llm retained completed stream after trailing failure: stage={stage}, error_kind={:?}, error={error}", + error.kind() + ); + } + + retain_response +} + fn consume_stream_events( events: Vec, accumulated_text: &mut String, @@ -1403,6 +1671,18 @@ fn build_request_body(request: &LlmRunRequest, config: &LlmConfig, stream: bool) web_search_options: request .enable_web_search .then_some(ChatCompletionsWebSearchOptions {}), + tools: (!request.function_tools.is_empty()).then(|| { + request + .function_tools + .iter() + .cloned() + .map(|function| ChatCompletionsFunctionTool { + tool_type: "function", + function, + }) + .collect() + }), + tool_choice: request.tool_choice.map(LlmToolChoice::as_str), }), LlmApiKind::OpenAiResponses => LlmRequestBody::Responses(ResponsesRequestBody { model: request.resolved_model(fallback_model).to_string(), @@ -1410,12 +1690,8 @@ fn build_request_body(request: &LlmRunRequest, config: &LlmConfig, stream: bool) input: map_responses_input_messages(request.messages.as_slice()), official_fallback, max_output_tokens: request.max_output_tokens, - tools: request.enable_web_search.then(|| { - vec![ResponsesWebSearchTool { - tool_type: "web_search", - max_keyword: 3, - }] - }), + tools: build_responses_tools(request), + tool_choice: request.tool_choice.map(LlmToolChoice::as_str), reasoning: request .response_reasoning_effort .map(|effort| ResponsesReasoningOptions { @@ -1435,6 +1711,24 @@ fn build_request_body(request: &LlmRunRequest, config: &LlmConfig, stream: bool) } } +fn build_responses_tools(request: &LlmRunRequest) -> Option> { + let mut tools = Vec::new(); + if request.enable_web_search { + tools.push(ResponsesTool::WebSearch(ResponsesWebSearchTool { + tool_type: "web_search", + max_keyword: 3, + })); + } + tools.extend(request.function_tools.iter().cloned().map(|function| { + ResponsesTool::Function(ResponsesFunctionTool { + tool_type: "function", + function, + }) + })); + + (!tools.is_empty()).then_some(tools) +} + fn build_anthropic_messages_request_body( request: &LlmRunRequest, fallback_model: &str, @@ -1689,11 +1983,12 @@ fn parse_chat_completions_response( .first() .ok_or_else(|| LlmError::Deserialize("LLM 响应缺少 choices[0]".to_string()))?; let content = extract_message_text(first_choice) - .ok_or(LlmError::EmptyResponse)? + .unwrap_or_default() .trim() .to_string(); + let tool_calls = extract_chat_tool_calls(first_choice); - if content.is_empty() { + if content.is_empty() && tool_calls.is_empty() { return Err(LlmError::EmptyResponse); } @@ -1704,6 +1999,7 @@ fn parse_chat_completions_response( finish_reason: first_choice.finish_reason.clone(), response_id: parsed.id, usage: parsed.usage, + tool_calls, }) } @@ -1716,11 +2012,12 @@ fn parse_responses_response( LlmError::Deserialize(format!("解析 LLM Responses JSON 响应失败:{error}")) })?; let content = extract_responses_text(&parsed) - .ok_or(LlmError::EmptyResponse)? + .unwrap_or_default() .trim() .to_string(); + let tool_calls = extract_responses_tool_calls(&parsed); - if content.is_empty() { + if content.is_empty() && tool_calls.is_empty() { return Err(LlmError::EmptyResponse); } @@ -1735,6 +2032,7 @@ fn parse_responses_response( completion_tokens: usage.output_tokens, total_tokens: usage.total_tokens, }), + tool_calls, }) } @@ -1766,6 +2064,7 @@ fn parse_anthropic_response( completion_tokens: usage.output_tokens, total_tokens: usage.input_tokens.saturating_add(usage.output_tokens), }), + tool_calls: Vec::new(), }) } @@ -1788,6 +2087,21 @@ fn extract_responses_text(parsed: &ResponsesResponseEnvelope) -> Option }) } +fn extract_responses_tool_calls(parsed: &ResponsesResponseEnvelope) -> Vec { + parsed + .output + .iter() + .filter(|item| item.item_type.as_deref() == Some("function_call")) + .filter_map(|item| { + Some(LlmToolCall { + id: item.call_id.as_ref().or(item.id.as_ref())?.clone(), + name: item.name.as_ref()?.clone(), + arguments: item.arguments.as_ref()?.clone(), + }) + }) + .collect() +} + fn extract_anthropic_text(parsed: &AnthropicResponseEnvelope) -> Option { let text = parsed .content @@ -1815,6 +2129,28 @@ fn extract_message_text(choice: &ChatCompletionsChoice) -> Option { }) } +fn extract_chat_tool_calls(choice: &ChatCompletionsChoice) -> Vec { + choice + .message + .as_ref() + .map(|message| message.tool_calls.as_slice()) + .filter(|tool_calls| !tool_calls.is_empty()) + .or_else(|| { + choice + .delta + .as_ref() + .map(|message| message.tool_calls.as_slice()) + }) + .unwrap_or_default() + .iter() + .map(|tool_call| LlmToolCall { + id: tool_call.id.clone(), + name: tool_call.function.name.clone(), + arguments: tool_call.function.arguments.clone(), + }) + .collect() +} + fn extract_content_text(content: &ChatCompletionsContent) -> Option { match content { ChatCompletionsContent::Text(text) => Some(text.clone()), @@ -1913,9 +2249,9 @@ fn parse_sse_event_block( is_terminal: false, })) } else { - Err(LlmError::Deserialize( - "LLM SSE 响应缺少 choices[0]".to_string(), - )) + // OpenAI-compatible gateways may emit heartbeat or metadata-only + // chunks with an empty choices array before the next text delta. + Ok(None) }; }; @@ -2180,6 +2516,37 @@ mod tests { assert_eq!(request.with_openai_chat().api_kind, LlmApiKind::OpenAiChat); } + #[test] + fn run_request_rejects_tool_choice_without_function_tools() { + let error = LlmRunRequest::single_turn("系统", "用户") + .with_tool_choice(LlmToolChoice::Required) + .validate() + .expect_err("tool choice without tools should fail"); + + assert_eq!( + error, + LlmError::InvalidRequest("LLM tool_choice 必须与 function_tools 一起使用".to_string()) + ); + } + + #[test] + fn run_request_rejects_function_tools_for_anthropic() { + let error = LlmRunRequest::single_turn("系统", "用户") + .with_anthropic() + .with_function_tools(vec![LlmFunctionTool::new( + "submit_plan", + "提交计划", + serde_json::json!({ "type": "object" }), + )]) + .validate() + .expect_err("anthropic function tools should fail locally"); + + assert_eq!( + error, + LlmError::InvalidRequest("Anthropic api_kind 暂不支持 function tools".to_string()) + ); + } + #[tokio::test] async fn run_sends_official_fallback_for_openai_compatible_clients() { let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind"); @@ -2247,6 +2614,26 @@ mod tests { assert!(events_b[1].is_terminal); } + #[test] + fn sse_parser_preserves_events_before_malformed_tail_in_same_chunk() { + let mut parser = OpenAiCompatibleSseParser::new(LlmApiKind::OpenAiChat); + let error = parser + .push_chunk(concat!( + "data: {\"choices\":[{\"delta\":{\"content\":\"你好\"}}]}\n\n", + "data: {\"choices\":[{\"finish_reason\":\"stop\"}]}\n\n", + "data: {\"choices\":[malformed]}\n\n" + )) + .expect_err("malformed tail should retain the earlier parsed events"); + + assert_eq!(error.parsed_events.len(), 2); + assert_eq!(error.parsed_events[0].delta_text.as_deref(), Some("你好")); + assert_eq!( + error.parsed_events[1].finish_reason.as_deref(), + Some("stop") + ); + assert!(matches!(error.error, LlmError::Deserialize(_))); + } + #[test] fn responses_sse_parser_only_emits_output_text_delta() { let mut parser = OpenAiCompatibleSseParser::new(LlmApiKind::OpenAiResponses); @@ -2311,6 +2698,41 @@ mod tests { ); } + #[tokio::test] + async fn run_accepts_chat_tool_calls_without_text_content() { + let server_url = spawn_mock_server(vec![MockResponse { + status_line: "200 OK", + content_type: "application/json; charset=utf-8", + body: r#"{"id":"chat_tool_01","model":"gpt-5","choices":[{"message":{"content":null,"tool_calls":[{"id":"call_project_index","type":"function","function":{"name":"project_index","arguments":"{\"path\":\"/tmp/game\"}"}}]},"finish_reason":"tool_calls"}]}"#.to_string(), + extra_headers: Vec::new(), + }]); + + let client = build_test_client(server_url, 0); + let response = client + .run( + LlmRunRequest::single_turn("系统", "索引项目") + .with_openai_chat() + .with_function_tools(vec![LlmFunctionTool::new( + "project_index", + "索引指定项目目录", + serde_json::json!({ "type": "object" }), + )]), + ) + .await + .expect("tool-call-only chat response should succeed"); + + assert_eq!(response.text, ""); + assert_eq!(response.finish_reason.as_deref(), Some("tool_calls")); + assert_eq!( + response.tool_calls, + vec![LlmToolCall { + id: "call_project_index".to_string(), + name: "project_index".to_string(), + arguments: r#"{"path":"/tmp/game"}"#.to_string(), + }] + ); + } + #[tokio::test] async fn run_retries_after_upstream_500() { let server_url = spawn_mock_server(vec![ @@ -2426,6 +2848,82 @@ mod tests { assert!(request_json.get("official_fallback").is_none()); } + #[tokio::test] + async fn chat_completions_request_sends_native_function_tools_and_choice() { + let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind"); + let address = listener.local_addr().expect("listener should have addr"); + let server_handle = thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("request should connect"); + let request_text = read_request(&mut stream); + write_response( + &mut stream, + MockResponse { + status_line: "200 OK", + content_type: "application/json; charset=utf-8", + body: r#"{"choices":[{"message":{"content":"工具请求已接收"},"finish_reason":"stop"}]}"# + .to_string(), + extra_headers: Vec::new(), + }, + ); + request_text + }); + + let client = build_test_client(format!("http://{address}"), 0); + let response = client + .run( + LlmRunRequest::single_turn("系统", "索引项目") + .with_openai_chat() + .with_function_tools(vec![ + LlmFunctionTool::new( + "project_index", + "索引指定项目目录", + serde_json::json!({ + "type": "object", + "properties": { + "path": { "type": "string" } + }, + "required": ["path"], + "additionalProperties": false + }), + ) + .with_strict(true), + ]) + .with_tool_choice(LlmToolChoice::Required), + ) + .await + .expect("chat function request should succeed"); + + let request_text = server_handle.join().expect("server thread should join"); + let request_body = request_text + .split("\r\n\r\n") + .nth(1) + .expect("request body should exist"); + let request_json: serde_json::Value = + serde_json::from_str(request_body).expect("request body should be json"); + + assert_eq!(response.text, "工具请求已接收"); + assert_eq!(request_json["tool_choice"], "required"); + assert_eq!( + request_json["tools"], + serde_json::json!([{ + "type": "function", + "function": { + "name": "project_index", + "description": "索引指定项目目录", + "parameters": { + "type": "object", + "properties": { + "path": { "type": "string" } + }, + "required": ["path"], + "additionalProperties": false + }, + "strict": true + } + }]) + ); + } + #[tokio::test] async fn chat_completions_multimodal_request_sends_text_and_image_url_parts() { let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind"); @@ -2500,7 +2998,7 @@ mod tests { } #[tokio::test] - async fn run_sends_responses_body_with_web_search_tool() { + async fn run_sends_responses_body_with_web_search_and_function_tools() { let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind"); let address = listener.local_addr().expect("listener should have addr"); let server_handle = thread::spawn(move || { @@ -2525,6 +3023,19 @@ mod tests { .with_model("deepseek-v3-2-251201") .with_openai_responses() .with_web_search(true) + .with_function_tools(vec![ + LlmFunctionTool::new( + "asset_list", + "列出项目素材", + serde_json::json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }), + ) + .with_strict(true), + ]) + .with_tool_choice(LlmToolChoice::Auto) .with_response_reasoning_effort(LlmResponseReasoningEffort::Low) .with_response_text_verbosity(LlmResponseTextVerbosity::Low) .with_max_output_tokens(128), @@ -2559,8 +3070,22 @@ mod tests { assert_eq!(request_json["stream"], serde_json::json!(false)); assert_eq!( request_json["tools"], - serde_json::json!([{ "type": "web_search", "max_keyword": 3 }]) + serde_json::json!([ + { "type": "web_search", "max_keyword": 3 }, + { + "type": "function", + "name": "asset_list", + "description": "列出项目素材", + "parameters": { + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "strict": true + } + ]) ); + assert_eq!(request_json["tool_choice"], "auto"); assert_eq!( request_json["reasoning"], serde_json::json!({ "effort": "low" }) @@ -2576,6 +3101,74 @@ mod tests { ); } + #[tokio::test] + async fn run_accepts_responses_function_call_without_output_text() { + let server_url = spawn_mock_server(vec![MockResponse { + status_line: "200 OK", + content_type: "application/json; charset=utf-8", + body: r#"{"id":"resp_tool_01","model":"gpt-5","output":[{"type":"function_call","id":"fc_asset_list","call_id":"call_asset_list","name":"asset_list","arguments":"{}","status":"completed"}],"status":"completed"}"#.to_string(), + extra_headers: Vec::new(), + }]); + + let client = build_test_client(server_url, 0); + let response = client + .run( + LlmRunRequest::single_turn("系统", "列出素材") + .with_openai_responses() + .with_function_tools(vec![LlmFunctionTool::new( + "asset_list", + "列出项目素材", + serde_json::json!({ "type": "object" }), + )]), + ) + .await + .expect("function-call-only Responses output should succeed"); + + assert_eq!(response.text, ""); + assert_eq!(response.finish_reason.as_deref(), Some("completed")); + assert_eq!( + response.tool_calls, + vec![LlmToolCall { + id: "call_asset_list".to_string(), + name: "asset_list".to_string(), + arguments: "{}".to_string(), + }] + ); + } + + #[tokio::test] + async fn pure_text_chat_and_responses_outputs_remain_backward_compatible() { + let server_url = spawn_mock_server(vec![ + MockResponse { + status_line: "200 OK", + content_type: "application/json; charset=utf-8", + body: r#"{"id":"chat_text","model":"gpt-5","choices":[{"message":{"content":"Chat 纯文本"},"finish_reason":"stop"}]}"#.to_string(), + extra_headers: Vec::new(), + }, + MockResponse { + status_line: "200 OK", + content_type: "application/json; charset=utf-8", + body: r#"{"id":"responses_text","model":"gpt-5","output_text":"Responses 纯文本","status":"completed"}"#.to_string(), + extra_headers: Vec::new(), + }, + ]); + let client = build_test_client(server_url, 0); + + let chat_response = client + .run(LlmRunRequest::single_turn("系统", "用户").with_openai_chat()) + .await + .expect("plain chat response should succeed"); + let responses_response = client + .run(LlmRunRequest::single_turn("系统", "用户").with_openai_responses()) + .await + .expect("plain Responses response should succeed"); + + assert_eq!(chat_response.text, "Chat 纯文本"); + assert!(chat_response.tool_calls.is_empty()); + assert_eq!(responses_response.text, "Responses 纯文本"); + assert!(responses_response.tool_calls.is_empty()); + } + #[tokio::test] async fn responses_multimodal_request_sends_input_text_and_input_image() { let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind"); @@ -2642,6 +3235,7 @@ mod tests { content_type: "text/event-stream; charset=utf-8", body: concat!( "data: {\"choices\":[{\"delta\":{\"content\":\"你\"}}]}\n\n", + "data: {\"choices\":[]}\n\n", "data: {\"choices\":[{\"delta\":{\"content\":\"好\"}}]}\n\n", "data: {\"choices\":[{\"finish_reason\":\"stop\"}]}\n\n", "data: {\"choices\":[],\"usage\":{\"prompt_tokens\":2,\"completion_tokens\":2,\"total_tokens\":4}}\n\n", @@ -2680,6 +3274,85 @@ mod tests { ); } + #[tokio::test] + async fn stream_run_keeps_completed_chat_response_before_malformed_tail() { + let server_url = spawn_mock_server(vec![MockResponse { + status_line: "200 OK", + content_type: "text/event-stream; charset=utf-8", + body: concat!( + "data: {\"choices\":[{\"delta\":{\"content\":\"你好\"}}]}\n\n", + "data: {\"choices\":[{\"finish_reason\":\"stop\"}]}\n\n", + "data: {\"choices\":[],\"usage\":{\"prompt_tokens\":2,\"completion_tokens\":1,\"total_tokens\":3}}\n\n", + "data: {\"choices\":[malformed]}\n\n" + ) + .to_string(), + extra_headers: Vec::new(), + }]); + + let client = build_test_client(server_url, 0); + let response = client + .stream_run( + LlmRunRequest::single_turn("系统", "用户").with_openai_chat(), + |_| {}, + ) + .await + .expect("completed response should survive a malformed SSE tail"); + + assert_eq!(response.text, "你好"); + assert_eq!(response.finish_reason.as_deref(), Some("stop")); + assert_eq!( + response.usage, + Some(LlmTokenUsage { + prompt_tokens: 2, + completion_tokens: 1, + total_tokens: 3, + }) + ); + } + + #[tokio::test] + async fn stream_run_keeps_completed_chat_response_after_body_read_tail_error() { + let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind"); + let address = listener.local_addr().expect("listener should have addr"); + let server_handle = thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("request should connect"); + read_request(&mut stream); + let completed_sse = concat!( + "data: {\"choices\":[{\"delta\":{\"content\":\"你好\"}}]}\n\n", + "data: {\"choices\":[{\"finish_reason\":\"stop\"}]}\n\n" + ); + let raw_response = format!( + concat!( + "HTTP/1.1 200 OK\r\n", + "Content-Type: text/event-stream; charset=utf-8\r\n", + "Transfer-Encoding: chunked\r\n", + "Connection: close\r\n\r\n", + "{:X}\r\n{}\r\n", + "not-a-chunk-size\r\n" + ), + completed_sse.len(), + completed_sse + ); + stream + .write_all(raw_response.as_bytes()) + .expect("malformed chunked response should be written"); + stream.flush().expect("stream response should flush"); + }); + + let client = build_test_client(format!("http://{address}"), 0); + let response = client + .stream_run( + LlmRunRequest::single_turn("系统", "用户").with_openai_chat(), + |_| {}, + ) + .await + .expect("completed response should survive a body-read tail error"); + + assert_eq!(response.text, "你好"); + assert_eq!(response.finish_reason.as_deref(), Some("stop")); + server_handle.join().expect("server thread should join"); + } + #[tokio::test] async fn stream_run_emits_chat_finish_only_delta_without_repeating_text() { let server_url = spawn_mock_server(vec![MockResponse { @@ -2777,14 +3450,11 @@ mod tests { } #[test] - fn chat_sse_rejects_empty_choices_without_usage() { - let error = parse_sse_event_block(LlmApiKind::OpenAiChat, "data: {\"choices\":[]}") - .expect_err("empty choices without usage should remain a protocol error"); + fn chat_sse_ignores_empty_choices_metadata_event() { + let event = parse_sse_event_block(LlmApiKind::OpenAiChat, "data: {\"choices\":[]}") + .expect("empty choices metadata should not fail the stream"); - assert_eq!( - error, - LlmError::Deserialize("LLM SSE 响应缺少 choices[0]".to_string()) - ); + assert!(event.is_none()); } #[tokio::test]