diff --git a/docs/providers-reference.md b/docs/providers-reference.md index 9fa2cca..bc91382 100644 --- a/docs/providers-reference.md +++ b/docs/providers-reference.md @@ -144,6 +144,11 @@ Optional: - `MINIMAX_OAUTH_REGION=global` or `cn` (defaults by provider alias) - `MINIMAX_OAUTH_CLIENT_ID` to override the default OAuth client id +Channel compatibility note: + +- For MiniMax-backed channel conversations, runtime history is normalized to keep valid `user`/`assistant` turn order. +- Channel-specific delivery guidance (for example Telegram attachment markers) is merged into the leading system prompt instead of being appended as a trailing `system` turn. + ## Qwen Code OAuth Setup (config.toml) Set Qwen Code OAuth mode in config: diff --git a/src/channels/mod.rs b/src/channels/mod.rs index 80a82ae..02633b0 100644 --- a/src/channels/mod.rs +++ b/src/channels/mod.rs @@ -200,6 +200,43 @@ fn channel_delivery_instructions(channel_name: &str) -> Option<&'static str> { } } +fn build_channel_system_prompt(base_prompt: &str, channel_name: &str) -> String { + if let Some(instructions) = channel_delivery_instructions(channel_name) { + if base_prompt.is_empty() { + instructions.to_string() + } else { + format!("{base_prompt}\n\n{instructions}") + } + } else { + base_prompt.to_string() + } +} + +fn normalize_cached_channel_turns(turns: Vec) -> Vec { + let mut normalized = Vec::with_capacity(turns.len()); + let mut expecting_user = true; + + for turn in turns { + match (expecting_user, turn.role.as_str()) { + (true, "user") => { + normalized.push(turn); + expecting_user = false; + } + (false, "assistant") => { + normalized.push(turn); + expecting_user = true; + } + _ => {} + } + } + + if normalized.last().map_or(false, |msg| msg.role == "user") { + normalized.pop(); + } + + normalized +} + fn supports_runtime_model_switch(channel_name: &str) -> bool { matches!(channel_name, "telegram" | "discord") } @@ -318,7 +355,7 @@ fn compact_sender_history(ctx: &ChannelRuntimeContext, sender_key: &str) -> bool let keep_from = turns .len() .saturating_sub(CHANNEL_HISTORY_COMPACT_KEEP_MESSAGES); - let mut compacted = turns[keep_from..].to_vec(); + let mut compacted = normalize_cached_channel_turns(turns[keep_from..].to_vec()); for turn in &mut compacted { if turn.content.chars().count() > CHANNEL_HISTORY_COMPACT_CONTENT_CHARS { @@ -327,6 +364,11 @@ fn compact_sender_history(ctx: &ChannelRuntimeContext, sender_key: &str) -> bool } } + if compacted.is_empty() { + turns.clear(); + return false; + } + *turns = compacted; true } @@ -783,21 +825,19 @@ async fn process_channel_message( ChatMessage::user(&enriched_message), ); - let mut prior_turns = ctx + // Build history from per-sender conversation cache. + let prior_turns_raw = ctx .conversation_histories .lock() .unwrap_or_else(|e| e.into_inner()) .get(&history_key) .cloned() .unwrap_or_default(); + let prior_turns = normalize_cached_channel_turns(prior_turns_raw); - let mut history = vec![ChatMessage::system(ctx.system_prompt.as_str())]; - history.append(&mut prior_turns); - - if let Some(instructions) = channel_delivery_instructions(&msg.channel) { - history.push(ChatMessage::system(instructions)); - } - + let system_prompt = build_channel_system_prompt(ctx.system_prompt.as_str(), &msg.channel); + let mut history = vec![ChatMessage::system(system_prompt)]; + history.extend(prior_turns); let use_streaming = target_channel .as_ref() .is_some_and(|ch| ch.supports_draft_updates()); @@ -3994,6 +4034,87 @@ mod tests { assert!(calls[1][3].1.contains("follow up")); } + #[tokio::test] + async fn process_channel_message_telegram_keeps_system_instruction_at_top_only() { + let channel_impl = Arc::new(TelegramRecordingChannel::default()); + let channel: Arc = channel_impl.clone(); + + let mut channels_by_name = HashMap::new(); + channels_by_name.insert(channel.name().to_string(), channel); + + let provider_impl = Arc::new(HistoryCaptureProvider::default()); + let mut histories = HashMap::new(); + histories.insert( + "telegram_alice".to_string(), + vec![ + ChatMessage::assistant("stale assistant"), + ChatMessage::user("earlier user question"), + ChatMessage::assistant("earlier assistant reply"), + ], + ); + + let runtime_ctx = Arc::new(ChannelRuntimeContext { + channels_by_name: Arc::new(channels_by_name), + provider: provider_impl.clone(), + default_provider: Arc::new("test-provider".to_string()), + memory: Arc::new(NoopMemory), + tools_registry: Arc::new(vec![]), + observer: Arc::new(NoopObserver), + system_prompt: Arc::new("test-system-prompt".to_string()), + model: Arc::new("test-model".to_string()), + temperature: 0.0, + auto_save_memory: false, + max_tool_iterations: 5, + min_relevance_score: 0.0, + conversation_histories: Arc::new(Mutex::new(histories)), + provider_cache: Arc::new(Mutex::new(HashMap::new())), + route_overrides: Arc::new(Mutex::new(HashMap::new())), + api_key: None, + api_url: None, + reliability: Arc::new(crate::config::ReliabilityConfig::default()), + provider_runtime_options: providers::ProviderRuntimeOptions::default(), + workspace_dir: Arc::new(std::env::temp_dir()), + message_timeout_secs: CHANNEL_MESSAGE_TIMEOUT_SECS, + interrupt_on_new_message: false, + multimodal: crate::config::MultimodalConfig::default(), + }); + + process_channel_message( + runtime_ctx.clone(), + traits::ChannelMessage { + id: "tg-msg-1".to_string(), + sender: "alice".to_string(), + reply_target: "chat-telegram".to_string(), + content: "hello".to_string(), + channel: "telegram".to_string(), + timestamp: 1, + thread_ts: None, + }, + CancellationToken::new(), + ) + .await; + + let calls = provider_impl + .calls + .lock() + .unwrap_or_else(|e| e.into_inner()); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].len(), 4); + + let roles = calls[0] + .iter() + .map(|(role, _)| role.as_str()) + .collect::>(); + assert_eq!(roles, vec!["system", "user", "assistant", "user"]); + assert!( + calls[0][0] + .1 + .contains("When responding on Telegram, include media markers"), + "telegram delivery instruction should live in the system prompt" + ); + assert!(!calls[0].iter().skip(1).any(|(role, _)| role == "system")); + } + // ── AIEOS Identity Tests (Issue #168) ───────────────────────── #[test]