mirror of
https://github.com/feigeCode/navop.git
synced 2026-09-21 16:01:21 +00:00
fix(agent): 压缩后保证请求至少含一条 user 消息
上下文压缩会把本轮唯一的用户输入并入摘要,保留的尾部只剩助手与工具消息,压缩后的请求没有任何 user 消息,OpenAI 兼容网关会以 400 No user query found in messages 拒绝,导致整轮任务失败。 请求构造改为经过 ensure_user_message:缺失 user 消息时补一条「继续当前任务」的 user 消息,摘要仍保留在 system 提示中。
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user