fix(agent): 压缩后保证请求至少含一条 user 消息

上下文压缩会把本轮唯一的用户输入并入摘要,保留的尾部只剩助手与工具消息,压缩后的请求没有任何 user 消息,OpenAI 兼容网关会以 400 No user query found in messages 拒绝,导致整轮任务失败。

请求构造改为经过 ensure_user_message:缺失 user 消息时补一条「继续当前任务」的 user 消息,摘要仍保留在 system 提示中。
This commit is contained in:
胡飞
2026-09-18 14:05:33 +08:00
parent 33bef328ce
commit 0106e166f7
4 changed files with 100 additions and 5 deletions
@@ -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.
+1 -1
View File
@@ -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};
+92 -2
View File
@@ -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<Message>) -> Vec<Message> {
normalized
}
/// 保证请求中至少存在一条 user 消息。
///
/// OpenAI 兼容网关(以及部分 chat template 严格的模型)会以
/// `No user query found in messages` 拒绝只含 system / assistant / tool 的请求。
/// 上下文压缩会把本轮用户输入并入摘要,压缩后的历史可能只剩助手与工具消息,
/// 因此发请求前做一次兜底:缺失时补一条「继续当前任务」的 user 消息。
/// 摘要本身仍在 system 提示中,上下文不会丢失。
pub fn ensure_user_message(messages: Vec<Message>) -> Vec<Message> {
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<Message>,
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() {
+3 -2
View File
@@ -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() {