Skip to content

Commit f47f5b8

Browse files
committed
fix: concatenate security block into last message instead of appending separate user message
Avoids consecutive same-role messages which violates the Anthropic Messages API protocol. The security block is now appended to the last User or Tool message content rather than pushed as a separate synthetic User message. Simplifies Claude CLI provider helpers since they no longer need to detect and extract the security block separately.
1 parent dd217d1 commit f47f5b8

2 files changed

Lines changed: 34 additions & 37 deletions

File tree

crates/core/src/agent/mod.rs

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -420,12 +420,16 @@ impl Agent {
420420
}
421421

422422
/// Build the message array for an LLM API call, with the security
423-
/// block injected as a trailing user message on every call.
423+
/// block concatenated into the last user/tool message on every call.
424424
///
425425
/// This ensures the security suffix always occupies the recency position
426426
/// (last content before generation), regardless of conversation length.
427427
/// The security block is synthetic — it is not persisted in session
428428
/// history and not included in compaction/summarization.
429+
///
430+
/// We concatenate into the last message rather than appending a separate
431+
/// user message to avoid consecutive same-role messages, which violates
432+
/// the Anthropic Messages API protocol.
429433
fn messages_for_api_call(&self) -> Vec<Message> {
430434
let mut messages = self.session.messages_for_llm();
431435

@@ -438,15 +442,29 @@ impl Agent {
438442

439443
let security_block = crate::security::build_ending_security_block(policy, include_suffix);
440444

441-
// Only append if the block has content
442445
if !security_block.is_empty() {
443-
messages.push(Message {
444-
role: Role::User,
445-
content: security_block,
446-
tool_calls: None,
447-
tool_call_id: None,
448-
images: Vec::new(),
449-
});
446+
// Concatenate into the last User or Tool message to avoid
447+
// consecutive same-role messages (Anthropic API requirement).
448+
let appended = if let Some(last) = messages.last_mut() {
449+
matches!(last.role, Role::User | Role::Tool)
450+
} else {
451+
false
452+
};
453+
454+
if appended {
455+
let last = messages.last_mut().unwrap();
456+
last.content.push_str("\n\n");
457+
last.content.push_str(&security_block);
458+
} else {
459+
// Fallback: no messages or last message is Assistant/System
460+
messages.push(Message {
461+
role: Role::User,
462+
content: security_block,
463+
tool_calls: None,
464+
tool_call_id: None,
465+
images: Vec::new(),
466+
});
467+
}
450468
}
451469

452470
messages

crates/core/src/agent/providers.rs

Lines changed: 7 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1894,47 +1894,26 @@ fn normalize_claude_model(model: &str) -> String {
18941894
.to_string()
18951895
}
18961896

1897-
#[cfg(feature = "claude-cli")]
1898-
/// Check if a message is the synthetic security block appended by `messages_for_api_call`.
1899-
fn is_security_block(msg: &Message) -> bool {
1900-
msg.role == Role::User
1901-
&& msg
1902-
.content
1903-
.contains(crate::security::HARDCODED_SECURITY_SUFFIX)
1904-
}
1905-
19061897
#[cfg(feature = "claude-cli")]
19071898
fn build_prompt_from_messages(messages: &[Message]) -> String {
1908-
// Get the last *real* user message as the prompt, skipping the security block
1899+
// Get the last user message as the prompt.
1900+
// The security block is now concatenated into it by messages_for_api_call().
19091901
messages
19101902
.iter()
19111903
.rev()
1912-
.find(|m| m.role == Role::User && !is_security_block(m))
1904+
.find(|m| m.role == Role::User)
19131905
.map(|m| m.content.clone())
19141906
.unwrap_or_default()
19151907
}
19161908

19171909
#[cfg(feature = "claude-cli")]
19181910
fn extract_system_prompt(messages: &[Message]) -> Option<String> {
1919-
let system = messages
1911+
// The security block is now concatenated into the last user/tool message
1912+
// by messages_for_api_call(), so no need to fold it here.
1913+
messages
19201914
.iter()
19211915
.find(|m| m.role == Role::System)
1922-
.map(|m| m.content.clone());
1923-
1924-
// For Claude CLI, fold the security block into the system prompt
1925-
// since the CLI only accepts a single prompt + system prompt
1926-
let security = messages
1927-
.iter()
1928-
.rev()
1929-
.find(|m| is_security_block(m))
1930-
.map(|m| m.content.clone());
1931-
1932-
match (system, security) {
1933-
(Some(sys), Some(sec)) => Some(format!("{}\n\n{}", sys, sec)),
1934-
(Some(sys), None) => Some(sys),
1935-
(None, Some(sec)) => Some(sec),
1936-
(None, None) => None,
1937-
}
1916+
.map(|m| m.content.clone())
19381917
}
19391918

19401919
#[cfg(feature = "claude-cli")]

0 commit comments

Comments
 (0)