From caf021e55638857a4f0373cd694001a4fd3f916d Mon Sep 17 00:00:00 2001 From: Linghong Date: Mon, 24 Aug 2026 04:30:50 +0000 Subject: [PATCH] =?UTF-8?q?tool-plan=20=E5=AE=A1=E8=AE=A1=E8=AE=B0?= =?UTF-8?q?=E4=B8=8B=E4=B8=8A=E6=B8=B8=E7=BB=88=E6=80=81=E6=A0=87=E8=AE=B0?= =?UTF-8?q?=EF=BC=8C=E9=A2=84=E7=AE=97=E5=88=87=E6=96=AD=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E6=97=A0=E5=A3=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tool-plan 这条链路此前把 finish_reason 整个丢掉了:provider_tool_plan.rs 全文没有一处引用它。platform-llm 只在**存在工具调用**且上游明确给出未完成 终态时才拒收(reject_incomplete_tool_calls),其余情形按「可用的降级结果」 放行;而这一层既不看也不记,审计里没有任何东西能把「上游说这一轮没写完」 和「模型自己写歪了」分开。 信封退化的排查就卡在这个盲点上:我一度据此判定上游根本不发 status,实际 response-streams 里的 final-reply 记录明明写着 "completed"——只是 tool-plan 那一发的标记从来没落过盘,而策划子 Agent 的信封恰恰产在 tool-plan 那一发 (stream=false 时 plan.response 直接充当 final reply,不再发 final-reply 请求)。 只记录,不改判。是否因未完成终态拒收仍旧由 platform-llm 决定,做游戏与 做素材逐字保持既有行为。 字段按固定字符集夹紧后落库:兼容网关会在这里发自定义值甚至整段文案,审计 不是转发通道,越界字符丢弃、长度夹到 32、夹空记 null。agent_db 侧同步进 PROTOCOL_FIELDS 白名单并复核夹紧结果——那张白名单是精确长度匹配,写入侧 加字段而不同步读取侧会直接把审计写失败。 tool_plan 过滤器 93/93。 Co-Authored-By: Claude Opus 5 --- .../runtime_actions/provider_tool_plan.rs | 67 +++++++++++++++++++ .../src-tauri/src/project/agent_db.rs | 26 +++++++ .../src/project/agent_db/security_tests.rs | 1 + 3 files changed, 94 insertions(+) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs index 1aee4b4ef..3db3cb2e1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs @@ -140,6 +140,32 @@ fn root_goal_contract_repair_instruction(protocol_error: &str, plan_root: bool) } } +/// 把上游的终态标记夹紧成可落审计的短标记。 +/// +/// tool-plan 这条链路此前把 finish_reason 整个丢掉了:真撞上 max_output_tokens 时, +/// platform-llm 只会在**存在工具调用**且上游明确给出未完成终态时拒收,其余情形一律 +/// 当作可用的降级结果放行,而这一层既不看也不记,审计里没有任何东西能把「上游说这 +/// 一轮没写完」和「模型自己写歪了」分开。信封退化的现场排查就卡在这里。 +/// +/// 只记录,不改判:是否因未完成终态拒收仍旧由 platform-llm 决定,做游戏与做素材的 +/// 行为逐字不变。 +fn agent_runtime_tool_plan_audit_finish_reason(finish_reason: Option<&str>) -> Option { + // 兼容网关常发自定义值甚至整段文案,所以按固定字符集丢弃而不是原样透传。 + let reason = finish_reason? + .trim() + .to_ascii_lowercase() + .chars() + .filter(|character| { + character.is_ascii_lowercase() + || character.is_ascii_digit() + || *character == '_' + || *character == '-' + }) + .take(32) + .collect::(); + (!reason.is_empty()).then_some(reason) +} + pub(in crate::agent) fn append_game_creator_agent_tool_plan_audit_idempotent( root: &Path, record: serde_json::Value, @@ -873,6 +899,9 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at "responseFingerprint": response_fingerprint, "providerRequestIdSha256": provider_request_id_sha256, "protocol": protocol, + "finishReason": agent_runtime_tool_plan_audit_finish_reason( + response.finish_reason.as_deref(), + ), "functionCallCount": call_ids.len(), "callIdSha256s": call_id_sha256s, "functionNames": function_names, @@ -1380,6 +1409,44 @@ pub(crate) async fn request_game_creator_agent_background_tool_plan_for_test( } } +#[cfg(test)] +mod tool_plan_audit_finish_reason_tests { + use super::*; + + /// 正常终态原样落库,缺失记 null——这两个值就是「上游到底说没说这一轮写完了」 + /// 的全部答案,此前审计里一个都没有。 + #[test] + fn ordinary_finish_reasons_are_recorded_and_absence_stays_null() { + assert_eq!( + agent_runtime_tool_plan_audit_finish_reason(Some("completed")).as_deref(), + Some("completed") + ); + assert_eq!( + agent_runtime_tool_plan_audit_finish_reason(Some(" INCOMPLETE ")).as_deref(), + Some("incomplete") + ); + assert_eq!(agent_runtime_tool_plan_audit_finish_reason(None), None); + assert_eq!(agent_runtime_tool_plan_audit_finish_reason(Some(" ")), None); + } + + /// 兼容网关会在这个字段里发自定义值甚至整段文案。审计不是转发通道:越界字符 + /// 一律丢弃,长度夹到 32,夹空了记 null,绝不原样透传。 + #[test] + fn gateway_freeform_reasons_are_clamped_rather_than_relayed() { + assert_eq!( + agent_runtime_tool_plan_audit_finish_reason(Some("上游异常:截断了")), + None + ); + assert_eq!( + agent_runtime_tool_plan_audit_finish_reason(Some("stop\n\"};DROP")).as_deref(), + Some("stopdrop") + ); + let clamped = agent_runtime_tool_plan_audit_finish_reason(Some(&"a".repeat(200))) + .expect("a long ascii reason is still recorded"); + assert_eq!(clamped.chars().count(), 32); + } +} + #[cfg(test)] mod supervisor_collaboration_repair_tests { use super::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs b/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs index 83cdedd19..39868c2f5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs @@ -2138,6 +2138,7 @@ pub(crate) fn append_agent_db_tool_plan_audit_idempotent( "responseFingerprint", "providerRequestIdSha256", "protocol", + "finishReason", "functionCallCount", "callIdSha256s", "functionNames", @@ -2264,6 +2265,31 @@ pub(crate) fn append_agent_db_tool_plan_audit_idempotent( return Err(format!("Agent DB tool-plan 幂等审计字段无效:{field}")); } } + // Provider 终态标记是上游自由文本(兼容网关常发自定义值),写入侧已夹紧成 + // 固定字符集的短标记;这里只复核夹紧结果,不接受原样透传的自由文本。 + match record.get("finishReason") { + Some(serde_json::Value::Null) => {} + Some(serde_json::Value::String(value)) => { + if value.is_empty() + || value.chars().count() > 32 + || !value + .chars() + .all(|character| { + character.is_ascii_lowercase() + || character.is_ascii_digit() + || character == '_' + || character == '-' + }) + { + return Err( + "Agent DB tool-plan 幂等审计字段无效:finishReason".to_string() + ); + } + } + _ => { + return Err("Agent DB tool-plan 幂等审计字段无效:finishReason".to_string()); + } + } let autonomous_source_payload_validated = record .get("autonomousSourcePayloadValidated") .and_then(serde_json::Value::as_bool) diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/agent_db/security_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/agent_db/security_tests.rs index cd6314dc6..c7aa53808 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/agent_db/security_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/agent_db/security_tests.rs @@ -52,6 +52,7 @@ fn tool_plan_protocol_audit_record( "responseFingerprint": "1".repeat(64), "providerRequestIdSha256": "2".repeat(64), "protocol": "native_runtime_tools", + "finishReason": "completed", "functionCallCount": 0, "callIdSha256s": [], "functionNames": [],