use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ToolCall {
pub id: String,
#[serde(rename = "type")]
pub typ: String,
pub function: FunctionCall,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FunctionCall {
pub name: String,
pub arguments: String,
}
#[must_use]
pub fn repair_tool_call_arguments_json(arguments: &str) -> Option<String> {
let t = arguments.trim();
if t.is_empty() {
return None;
}
if let Ok(v) = serde_json::from_str::<serde_json::Value>(t) {
return Some(v.to_string());
}
let escaped = escape_raw_controls_inside_json_string_regions(t);
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&escaped) {
return Some(v.to_string());
}
if let Some(s) = try_repair_truncated_tool_arguments_json(&escaped) {
return Some(s);
}
try_repair_truncated_tool_arguments_json(t)
}
#[must_use]
pub fn prepare_tool_call_arguments_for_local_execution(arguments: &str) -> String {
if let Some(s) = repair_tool_call_arguments_json(arguments) {
return s;
}
let t = arguments.trim();
if t.is_empty() {
return "{}".to_string();
}
t.to_string()
}
#[must_use]
pub fn sanitize_tool_call_arguments_for_openai_compat(arguments: &str) -> String {
repair_tool_call_arguments_json(arguments).unwrap_or_else(|| "{}".to_string())
}
fn escape_raw_controls_inside_json_string_regions(t: &str) -> String {
let mut out = String::with_capacity(t.len().saturating_add(16));
let mut in_string = false;
let mut escape = false;
for ch in t.chars() {
if escape {
out.push(ch);
escape = false;
continue;
}
if in_string {
match ch {
'\\' => {
out.push('\\');
escape = true;
}
'"' => {
out.push('"');
in_string = false;
}
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if (c as u32) < 0x20 => {
use std::fmt::Write;
let _ = write!(&mut out, "\\u{:04x}", c as u32);
}
c => out.push(c),
}
continue;
}
if ch == '"' {
in_string = true;
}
out.push(ch);
}
out
}
fn try_repair_truncated_tool_arguments_json(t: &str) -> Option<String> {
if !t.starts_with('{') {
return None;
}
let mut out = String::with_capacity(t.len().saturating_add(8));
let mut brace_depth = 0i32;
let mut in_string = false;
let mut escape = false;
for ch in t.chars() {
out.push(ch);
if escape {
escape = false;
continue;
}
if in_string {
match ch {
'\\' => escape = true,
'"' => in_string = false,
_ => {}
}
continue;
}
match ch {
'"' => in_string = true,
'{' => brace_depth += 1,
'}' => brace_depth = brace_depth.saturating_sub(1),
_ => {}
}
}
if !in_string || escape {
return None;
}
out.push('"');
while brace_depth > 0 {
out.push('}');
brace_depth -= 1;
}
serde_json::from_str::<serde_json::Value>(&out)
.ok()
.map(|v| v.to_string())
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum MessageContent {
Text(String),
Parts(Vec<serde_json::Value>),
}
impl From<String> for MessageContent {
fn from(s: String) -> Self {
MessageContent::Text(s)
}
}
impl From<&str> for MessageContent {
fn from(s: &str) -> Self {
MessageContent::Text(s.to_string())
}
}
pub fn message_content_get_or_insert_empty_text(
content: &mut Option<MessageContent>,
) -> &mut String {
match content {
Some(MessageContent::Text(s)) => s,
_ => {
*content = Some(MessageContent::Text(String::new()));
match content {
Some(MessageContent::Text(s)) => s,
_ => unreachable!("just assigned Text"),
}
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Message {
pub role: String,
pub content: Option<MessageContent>,
#[serde(default, skip_serializing_if = "Option::is_none", alias = "reasoning")]
pub reasoning_content: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_details: Option<Vec<serde_json::Value>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<ToolCall>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_call_id: Option<String>,
}
#[inline]
pub fn is_chat_ui_separator(m: &Message) -> bool {
m.role == "system" && m.name.as_deref() == Some("crabmate_ui_sep")
}
#[inline]
pub fn is_chat_timeline_marker(m: &Message) -> bool {
m.role == "system" && m.name.as_deref() == Some("crabmate_timeline")
}
#[inline]
pub fn is_message_excluded_from_llm_context_except_memory(m: &Message) -> bool {
is_chat_ui_separator(m) || is_chat_timeline_marker(m)
}
pub fn filter_messages_for_web_client_snapshot(messages: &[Message]) -> Vec<Message> {
messages
.iter()
.filter(|m| is_message_visible_in_chat_transcript(m))
.cloned()
.collect()
}
#[inline]
fn is_system_role_hidden_from_web_transcript(m: &Message) -> bool {
m.role == "system" && !is_chat_timeline_marker(m)
}
#[inline]
pub fn is_message_visible_in_chat_transcript(m: &Message) -> bool {
!crate::cm_types::server_injected_user::is_server_injected_user_message(m)
&& !is_system_role_hidden_from_web_transcript(m)
}
pub const CRABMATE_LONG_TERM_MEMORY_NAME: &str = "crabmate_long_term_memory";
pub const CRABMATE_WORKSPACE_CHANGELIST_NAME: &str = "crabmate_workspace_changelist";
pub const CRABMATE_FIRST_TURN_WORKSPACE_CONTEXT_NAME: &str =
"crabmate_first_turn_workspace_context";
pub const CRABMATE_PLANNER_TOOL_CALL_REJECT_NAME: &str = "crabmate_planner_tool_call_reject";
pub const CRABMATE_PLAN_REWRITE_NAME: &str = "crabmate_plan_rewrite";
pub const STAGED_PLANNER_TOOL_CALL_REJECT_CONTENT_PREFIX: &str =
"### 规划轮约束提醒(code=PLANNER_TOOL_CALL_REJECTED)";
pub const CRABMATE_EXECUTION_CONSTRAINT_HINT_NAME: &str = "crabmate_execution_constraint_hint";
#[inline]
pub fn is_execution_constraint_ephemeral_system(m: &Message) -> bool {
m.role == "system" && m.name.as_deref() == Some(CRABMATE_EXECUTION_CONSTRAINT_HINT_NAME)
}
#[inline]
pub fn is_long_term_memory_injection(m: &Message) -> bool {
m.role == "user" && m.name.as_deref() == Some(CRABMATE_LONG_TERM_MEMORY_NAME)
}
#[inline]
pub fn is_workspace_changelist_injection(m: &Message) -> bool {
m.role == "user" && m.name.as_deref() == Some(CRABMATE_WORKSPACE_CHANGELIST_NAME)
}
#[inline]
pub fn is_first_turn_workspace_context_injection(m: &Message) -> bool {
m.role == "user" && m.name.as_deref() == Some(CRABMATE_FIRST_TURN_WORKSPACE_CONTEXT_NAME)
}
#[inline]
pub fn user_message_counts_for_branch_truncation(m: &Message) -> bool {
m.role == "user" && !crate::cm_types::server_injected_user::is_server_injected_user_message(m)
}
#[inline]
pub fn message_content_as_str(content: &Option<MessageContent>) -> Option<&str> {
match content {
Some(MessageContent::Text(s)) => Some(s.as_str()),
Some(MessageContent::Parts(_)) | None => None,
}
}
#[must_use]
pub fn message_content_plain_for_chat_display(content: &Option<MessageContent>) -> String {
match content {
None => String::new(),
Some(MessageContent::Text(s)) => s.clone(),
Some(MessageContent::Parts(parts)) => parts
.iter()
.filter_map(|p| p.get("text").and_then(|t| t.as_str()))
.map(str::trim)
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("\n"),
}
}
pub fn message_content_byte_len_for_estimate(content: &Option<MessageContent>) -> usize {
match content {
None => 0,
Some(MessageContent::Text(s)) => s.len(),
Some(MessageContent::Parts(parts)) => parts
.iter()
.filter_map(|p| p.get("text").and_then(|t| t.as_str()))
.map(|t| t.len())
.sum(),
}
}
pub fn message_content_into_text_lossy(content: Option<MessageContent>) -> String {
match content {
None => String::new(),
Some(MessageContent::Text(s)) => s,
Some(MessageContent::Parts(_)) => String::new(),
}
}
pub fn message_content_is_effectively_empty(m: &Message) -> bool {
match &m.content {
None => true,
Some(MessageContent::Text(s)) => s.trim().is_empty(),
Some(MessageContent::Parts(a)) => a.is_empty(),
}
}
pub fn merge_system_text_prefix_into_user_content(msg: &mut Message, prefix: &str) {
let prefix = prefix.trim();
if prefix.is_empty() {
return;
}
match std::mem::take(&mut msg.content) {
None => {
msg.content = Some(MessageContent::Text(prefix.to_string()));
}
Some(MessageContent::Text(s)) => {
let u = s.trim();
msg.content = Some(MessageContent::Text(if u.is_empty() {
prefix.to_string()
} else {
format!("{prefix}\n\n{u}")
}));
}
Some(MessageContent::Parts(mut parts)) => {
if let Some(serde_json::Value::Object(obj)) = parts.first_mut()
&& obj.get("type").and_then(|t| t.as_str()) == Some("text")
&& let Some(serde_json::Value::String(t)) = obj.get_mut("text")
{
let u = t.trim();
*t = if u.is_empty() {
prefix.to_string()
} else {
format!("{prefix}\n\n{u}")
};
msg.content = Some(MessageContent::Parts(parts));
return;
}
let mut new_parts = Vec::with_capacity(parts.len() + 1);
new_parts.push(serde_json::json!({"type": "text", "text": prefix}));
new_parts.extend(parts);
msg.content = Some(MessageContent::Parts(new_parts));
}
}
}
pub fn message_user_with_images(text: &str, image_urls: &[String]) -> Message {
let mut parts = Vec::new();
let t = text.trim();
if !t.is_empty() {
parts.push(serde_json::json!({"type": "text", "text": t}));
}
for url in image_urls {
let u = url.trim();
if u.is_empty() {
continue;
}
parts.push(serde_json::json!({
"type": "image_url",
"image_url": {"url": u}
}));
}
let content = if parts.is_empty() {
None
} else {
Some(MessageContent::Parts(parts))
};
Message {
role: "user".to_string(),
content,
reasoning_content: None,
reasoning_details: None,
tool_calls: None,
name: None,
tool_call_id: None,
}
}
impl Message {
pub fn chat_ui_separator(short: bool) -> Self {
Self {
role: "system".to_string(),
content: Some(MessageContent::Text(
if short { "short" } else { "long" }.to_string(),
)),
reasoning_content: None,
reasoning_details: None,
tool_calls: None,
name: Some("crabmate_ui_sep".to_string()),
tool_call_id: None,
}
}
pub fn system_only(content: impl Into<String>) -> Self {
Self {
role: "system".to_string(),
content: Some(MessageContent::Text(content.into())),
reasoning_content: None,
reasoning_details: None,
tool_calls: None,
name: None,
tool_call_id: None,
}
}
pub fn system_execution_constraint_hint(content: impl Into<String>) -> Self {
Self {
role: "system".to_string(),
content: Some(MessageContent::Text(content.into())),
reasoning_content: None,
reasoning_details: None,
tool_calls: None,
name: Some(CRABMATE_EXECUTION_CONSTRAINT_HINT_NAME.to_string()),
tool_call_id: None,
}
}
pub fn user_only(content: impl Into<String>) -> Self {
Self {
role: "user".to_string(),
content: Some(MessageContent::Text(content.into())),
reasoning_content: None,
reasoning_details: None,
tool_calls: None,
name: None,
tool_call_id: None,
}
}
pub fn user_first_turn_workspace_context(content: impl Into<String>) -> Self {
Self {
role: "user".to_string(),
content: Some(MessageContent::Text(content.into())),
reasoning_content: None,
reasoning_details: None,
tool_calls: None,
name: Some(CRABMATE_FIRST_TURN_WORKSPACE_CONTEXT_NAME.to_string()),
tool_call_id: None,
}
}
pub fn user_planner_tool_call_reject_injection(content: impl Into<String>) -> Self {
Self::user_server_injection(CRABMATE_PLANNER_TOOL_CALL_REJECT_NAME, content)
}
pub fn user_server_injection(name: &'static str, content: impl Into<String>) -> Self {
Self {
role: "user".to_string(),
content: Some(MessageContent::Text(content.into())),
reasoning_content: None,
reasoning_details: None,
tool_calls: None,
name: Some(name.to_string()),
tool_call_id: None,
}
}
pub fn user_plan_rewrite_injection(content: impl Into<String>) -> Self {
Self::user_server_injection(CRABMATE_PLAN_REWRITE_NAME, content)
}
pub fn assistant_only(content: impl Into<String>) -> Self {
Self {
role: "assistant".to_string(),
content: Some(MessageContent::Text(content.into())),
reasoning_content: None,
reasoning_details: None,
tool_calls: None,
name: None,
tool_call_id: None,
}
}
}
#[inline]
pub fn message_clone_stripping_reasoning_for_api(
m: &Message,
preserve_reasoning_on_assistant_tool_calls: bool,
preserve_deepseek_thinking_reasoning_roundtrip: bool,
) -> Message {
let is_asst = is_assistant_role(m.role.as_str());
let tc = assistant_has_non_empty_tool_calls(m);
let keep_kimi = preserve_reasoning_on_assistant_tool_calls && is_asst && tc;
let keep_deepseek = preserve_deepseek_thinking_reasoning_roundtrip && is_asst && tc;
let keep = keep_kimi || keep_deepseek;
if keep {
let mut x = m.clone();
merge_reasoning_details_into_reasoning_content(&mut x);
x.reasoning_details = None;
if x.reasoning_content.is_none() {
x.reasoning_content = Some(String::new());
}
return x;
}
if m.reasoning_content.is_none() && m.reasoning_details.is_none() {
m.clone()
} else {
Message {
reasoning_content: None,
reasoning_details: None,
..m.clone()
}
}
}
pub fn merge_reasoning_details_into_reasoning_content(msg: &mut Message) {
let Some(details) = msg.reasoning_details.take() else {
return;
};
let mut from_details = String::new();
for d in details {
let Some(obj) = d.as_object() else {
continue;
};
if let Some(serde_json::Value::String(t)) = obj.get("text") {
from_details.push_str(t);
}
}
if from_details.is_empty() {
return;
}
let replace = match msg.reasoning_content.as_deref() {
None | Some("") => true,
Some(rc) => from_details.starts_with(rc) && from_details.len() >= rc.len(),
};
if replace {
msg.reasoning_content = Some(from_details);
}
}
pub fn messages_for_api_stripping_reasoning_skip_ui_separators(
messages: &[Message],
preserve_reasoning_on_assistant_tool_calls: bool,
preserve_deepseek_thinking_reasoning_roundtrip: bool,
) -> Vec<Message> {
messages
.iter()
.filter(|m| {
!is_message_excluded_from_llm_context_except_memory(m)
&& !is_long_term_memory_injection(m)
&& !is_workspace_changelist_injection(m)
})
.map(|m| {
let mut out = message_clone_stripping_reasoning_for_api(
m,
preserve_reasoning_on_assistant_tool_calls,
preserve_deepseek_thinking_reasoning_roundtrip,
);
strip_user_skill_slash_in_message_for_api(&mut out);
out
})
.collect()
}
fn strip_user_skill_slash_in_message_for_api(m: &mut Message) {
if !m.role.trim().eq_ignore_ascii_case("user") {
return;
}
if crate::cm_types::server_injected_user::is_server_injected_user_message(m) {
return;
}
match m.content.as_mut() {
Some(MessageContent::Text(s)) => {
let stripped = crate::cm_types::strip_explicit_skill_slash_prefix_for_model(s);
if stripped != *s {
*s = stripped;
}
}
Some(MessageContent::Parts(parts)) => {
for part in parts.iter_mut() {
let Some(obj) = part.as_object_mut() else {
continue;
};
let is_text = obj
.get("type")
.and_then(|v| v.as_str())
.is_some_and(|t| t == "text");
if !is_text {
continue;
}
let Some(serde_json::Value::String(text)) = obj.get_mut("text") else {
continue;
};
let stripped = crate::cm_types::strip_explicit_skill_slash_prefix_for_model(text);
if stripped != *text {
*text = stripped;
}
break;
}
}
None => {}
}
}
#[inline]
fn assistant_has_non_empty_tool_calls(m: &Message) -> bool {
m.tool_calls.as_ref().is_some_and(|c| !c.is_empty())
}
#[inline]
fn is_assistant_role(role: &str) -> bool {
role.trim().eq_ignore_ascii_case("assistant")
}
fn merge_adjacent_assistant_text(into: &mut Message, from: &Message) {
let a = message_content_as_str(&into.content)
.map(str::trim)
.unwrap_or("");
let b = message_content_as_str(&from.content)
.map(str::trim)
.unwrap_or("");
into.content = match (a.is_empty(), b.is_empty()) {
(true, true) => None,
(false, true) => into.content.clone(),
(true, false) => from.content.clone(),
(false, false) => {
if b.starts_with(a) {
from.content.clone()
} else if a.starts_with(b) {
into.content.clone()
} else {
Some(MessageContent::Text(format!("{a}\n\n{b}")))
}
}
};
}
fn squash_consecutive_assistant_pair(into: &mut Message, from: Message) {
let from_has_tc = assistant_has_non_empty_tool_calls(&from);
let into_has_tc = assistant_has_non_empty_tool_calls(into);
let from_empty = message_content_is_effectively_empty(&from);
if into_has_tc && !from_has_tc {
if from_empty {
into.tool_calls = None;
return;
}
into.tool_calls = None;
merge_adjacent_assistant_text(into, &from);
return;
}
if into_has_tc && from_has_tc {
merge_adjacent_assistant_text(into, &from);
let mut a = into.tool_calls.take().unwrap_or_default();
a.extend(from.tool_calls.unwrap_or_default());
into.tool_calls = if a.is_empty() { None } else { Some(a) };
return;
}
if !into_has_tc && from_has_tc {
merge_adjacent_assistant_text(into, &from);
into.tool_calls = from.tool_calls;
return;
}
merge_adjacent_assistant_text(into, &from);
}
fn merge_all_consecutive_assistant_messages_in_vec(mut out: Vec<Message>) -> Vec<Message> {
loop {
let mut merged_any = false;
let mut i = 0usize;
while i + 1 < out.len() {
if !(is_assistant_role(&out[i].role) && is_assistant_role(&out[i + 1].role)) {
i += 1;
continue;
}
out[i].role = "assistant".to_string();
let next = out.remove(i + 1);
squash_consecutive_assistant_pair(&mut out[i], next);
merged_any = true;
i = i.saturating_sub(1);
}
if !merged_any {
break;
}
}
out
}
pub fn merge_consecutive_assistants_in_place(messages: &mut Vec<Message>) {
*messages = merge_all_consecutive_assistant_messages_in_vec(std::mem::take(messages));
}
pub fn normalize_messages_for_openai_compatible_request(msgs: Vec<Message>) -> Vec<Message> {
let mut out = merge_all_consecutive_assistant_messages_in_vec(msgs);
remove_all_assistants_lacking_openai_content_or_tool_calls(&mut out);
if let Some(last) = out.last_mut()
&& is_assistant_role(&last.role)
&& assistant_has_non_empty_tool_calls(last)
{
last.tool_calls = None;
}
remove_all_assistants_lacking_openai_content_or_tool_calls(&mut out);
out
}
#[inline]
fn role_is_system_for_vendor(role: &str) -> bool {
role.trim().eq_ignore_ascii_case("system")
}
pub fn fold_system_messages_into_following_user(msgs: Vec<Message>) -> Vec<Message> {
let mut out: Vec<Message> = Vec::with_capacity(msgs.len());
let mut pending: Vec<String> = Vec::new();
let push_merged_user = |pending: &mut Vec<String>, out: &mut Vec<Message>, mut msg: Message| {
if pending.is_empty() {
out.push(msg);
return;
}
let prefix = pending.join("\n\n");
pending.clear();
merge_system_text_prefix_into_user_content(&mut msg, &prefix);
out.push(msg);
};
for m in msgs {
if role_is_system_for_vendor(&m.role) {
if let Some(c) = message_content_as_str(&m.content)
.map(str::trim)
.filter(|s| !s.is_empty())
{
pending.push(c.to_string());
}
continue;
}
let is_user = m.role.trim().eq_ignore_ascii_case("user");
if is_user {
push_merged_user(&mut pending, &mut out, m);
} else {
if !pending.is_empty() {
let prefix = pending.join("\n\n");
pending.clear();
out.push(Message::user_only(prefix));
}
out.push(m);
}
}
if !pending.is_empty() {
out.push(Message::user_only(pending.join("\n\n")));
}
out
}
#[inline]
fn assistant_lacks_openai_content_and_tool_calls(m: &Message) -> bool {
if !is_assistant_role(m.role.as_str()) {
return false;
}
if assistant_has_non_empty_tool_calls(m) {
return false;
}
message_content_as_str(&m.content)
.map(|s| s.trim().is_empty())
.unwrap_or_else(|| message_content_is_effectively_empty(m))
}
fn remove_all_assistants_lacking_openai_content_or_tool_calls(out: &mut Vec<Message>) {
out.retain(|m| !assistant_lacks_openai_content_and_tool_calls(m));
}
pub fn messages_chat_seed(system_prompt: &str, user_text: &str) -> Vec<Message> {
vec![
Message::system_only(system_prompt.to_string()),
Message::user_only(user_text.to_string()),
]
}