From 0106e166f7fffd84fa4cdb94cb6ee75e33b6ddbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=83=A1=E9=A3=9E?= <1835698775@qq.com> Date: Fri, 18 Sep 2026 14:05:33 +0800 Subject: [PATCH] =?UTF-8?q?fix(agent):=20=E5=8E=8B=E7=BC=A9=E5=90=8E?= =?UTF-8?q?=E4=BF=9D=E8=AF=81=E8=AF=B7=E6=B1=82=E8=87=B3=E5=B0=91=E5=90=AB?= =?UTF-8?q?=E4=B8=80=E6=9D=A1=20user=20=E6=B6=88=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上下文压缩会把本轮唯一的用户输入并入摘要,保留的尾部只剩助手与工具消息,压缩后的请求没有任何 user 消息,OpenAI 兼容网关会以 400 No user query found in messages 拒绝,导致整轮任务失败。 请求构造改为经过 ensure_user_message:缺失 user 消息时补一条「继续当前任务」的 user 消息,摘要仍保留在 system 提示中。 --- .../agent_runtime/locales/agent_runtime.yml | 4 + crates/agent_runtime/src/planner/mod.rs | 2 +- crates/agent_runtime/src/planner/prompt.rs | 94 ++++++++++++++++++- crates/agent_runtime/src/tasks/agent.rs | 5 +- 4 files changed, 100 insertions(+), 5 deletions(-) diff --git a/crates/agent_runtime/locales/agent_runtime.yml b/crates/agent_runtime/locales/agent_runtime.yml index 7a154a43d..356361894 100644 --- a/crates/agent_runtime/locales/agent_runtime.yml +++ b/crates/agent_runtime/locales/agent_runtime.yml @@ -25,6 +25,10 @@ AgentRuntime: en: Compressing context... zh-CN: 正在压缩上下文... zh-HK: 正在壓縮上下文... + continue_after_compaction: + en: Continue the current task from the compressed context above. + zh-CN: 请根据上方压缩后的上下文继续完成当前任务。 + zh-HK: 請根據上方壓縮後的上下文繼續完成當前任務。 compaction_system_prompt: en: |- You are performing Codex-style context compaction. diff --git a/crates/agent_runtime/src/planner/mod.rs b/crates/agent_runtime/src/planner/mod.rs index c24c7a630..d1fb4b1dc 100644 --- a/crates/agent_runtime/src/planner/mod.rs +++ b/crates/agent_runtime/src/planner/mod.rs @@ -14,4 +14,4 @@ pub use plan::{Plan, PlanSource, PlanStatus}; pub use step::{PlanStep, StepStatus}; // 供 tasks 模块复用:把历史转换为模型消息。 -pub(crate) use prompt::{history_to_messages, normalize_system_messages}; +pub(crate) use prompt::{ensure_user_message, history_to_messages, normalize_system_messages}; diff --git a/crates/agent_runtime/src/planner/prompt.rs b/crates/agent_runtime/src/planner/prompt.rs index d28270e48..43d5b3e1b 100644 --- a/crates/agent_runtime/src/planner/prompt.rs +++ b/crates/agent_runtime/src/planner/prompt.rs @@ -5,6 +5,7 @@ use crate::history::{HistoryItem, RuntimeHistory}; use crate::tools::{ToolCall, ToolObservation}; use llm_connector::types::{Message, MessageBlock, Role}; +use rust_i18n::t; /// 历史图片回灌模型时允许占用的 base64 总量。 /// @@ -207,6 +208,24 @@ pub fn normalize_system_messages(messages: Vec) -> Vec { normalized } +/// 保证请求中至少存在一条 user 消息。 +/// +/// OpenAI 兼容网关(以及部分 chat template 严格的模型)会以 +/// `No user query found in messages` 拒绝只含 system / assistant / tool 的请求。 +/// 上下文压缩会把本轮用户输入并入摘要,压缩后的历史可能只剩助手与工具消息, +/// 因此发请求前做一次兜底:缺失时补一条「继续当前任务」的 user 消息。 +/// 摘要本身仍在 system 提示中,上下文不会丢失。 +pub fn ensure_user_message(messages: Vec) -> Vec { + if messages.iter().any(|message| message.role == Role::User) { + return messages; + } + let mut messages = messages; + messages.push(Message::user( + t!("AgentRuntime.continue_after_compaction").to_string(), + )); + messages +} + fn push_assistant_tool_call_message( messages: &mut Vec, call: &ToolCall, @@ -299,10 +318,81 @@ fn user_message_with_images(text: &str, images: &[crate::runtime::InputImage]) - #[cfg(test)] mod tests { use super::*; - use crate::history::RuntimeHistory; + use crate::history::{HistoryItem, RuntimeHistory}; use crate::ids::ToolCallId; use crate::runtime::InputImage; - use crate::tools::{ToolCall, ToolName, ToolObservation}; + use crate::tools::{ObservationData, ToolCall, ToolName, ToolObservation}; + + #[test] + fn compacted_history_still_sends_a_user_message() { + let mut history = RuntimeHistory::new(); + history.record_user("长任务目标:修复部署脚本"); + for index in 0..20 { + history.record_assistant("继续处理"); + let call = tool_call(&format!("call_{index}"), "echo"); + let call_id = call.call_id.clone(); + history.record_tool_call(call); + history.record_observation(ToolObservation::success( + call_id, + ToolName::new("echo"), + "echo: ok", + ObservationData::Text("ok".into()), + )); + } + + // 本轮唯一的用户输入落在压缩前缀里,保留的尾部只剩助手与工具消息。 + let prefix = history + .compaction_prefix(32) + .expect("history should be compactable"); + assert!( + prefix + .iter() + .any(|item| matches!(item, HistoryItem::User { .. })), + "用户输入应落在被压缩的前缀里" + ); + assert!(history.compact_old_items("之前的上下文摘要", 32)); + + let mut messages = vec![Message::system("主系统提示")]; + messages.extend(history_to_messages(&history)); + let messages = ensure_user_message(normalize_system_messages(messages)); + + assert!( + messages.iter().any(|message| message.role == Role::User), + "压缩后仍必须发出至少一条 user 消息,否则 OpenAI 兼容网关会报 No user query found in messages" + ); + } + + #[test] + fn agent_loop_applies_user_message_guarantee() { + let source = include_str!("../tasks/agent.rs"); + assert!( + source.contains("ensure_user_message(normalize_system_messages(messages))"), + "请求构造必须经过 ensure_user_message,否则压缩后可能发出没有 user 消息的请求" + ); + } + + fn tool_call(id: &str, name: &str) -> ToolCall { + ToolCall { + call_id: ToolCallId::from_string(id), + tool_name: ToolName::new(name), + arguments: serde_json::json!({}), + resource_id: None, + } + } + + #[test] + fn ensure_user_message_keeps_existing_user_message() { + let messages = normalize_system_messages(vec![ + Message::system("主系统提示"), + Message::user("你好"), + ]); + + let messages = ensure_user_message(messages); + + assert_eq!(2, messages.len()); + assert_eq!(Role::User, messages[1].role); + assert_eq!("你好", messages[1].content_as_text()); + } #[test] fn plain_user_history_becomes_text_message() { diff --git a/crates/agent_runtime/src/tasks/agent.rs b/crates/agent_runtime/src/tasks/agent.rs index 3ba5f3315..d4896b47b 100644 --- a/crates/agent_runtime/src/tasks/agent.rs +++ b/crates/agent_runtime/src/tasks/agent.rs @@ -14,7 +14,7 @@ use crate::error::RuntimeError; use crate::ids::{ToolCallId, TurnId}; use crate::model::{ModelRequest, ModelResponse, ModelStreamEvent}; -use crate::planner::{history_to_messages, normalize_system_messages}; +use crate::planner::{ensure_user_message, history_to_messages, normalize_system_messages}; use crate::resource::ResourceContext; use crate::risk::RiskLevel; use crate::runtime::{ @@ -193,7 +193,8 @@ async fn run_agent_loop(ctx: AgentLoopContext, cancellation: CancellationToken) ctx.session.current_plan().as_ref(), ))]; messages.extend(history_to_messages(&ctx.session.history_snapshot())); - let messages = normalize_system_messages(messages); + // 压缩可能把本轮用户输入并入摘要,兜底保证请求里仍有 user 消息。 + let messages = ensure_user_message(normalize_system_messages(messages)); let mut request = ModelRequest::new(messages); if !tools.is_empty() {