From ea227798d40a618f779b8bd63577badec9fe0444 Mon Sep 17 00:00:00 2001 From: panviktor <35385675+panviktor@users.noreply.github.com> Date: Sun, 12 Apr 2026 19:21:50 +0200 Subject: [PATCH 01/30] Tighten Phase 4.10 provider policy cleanup --- crates/adapters/core/Cargo.toml | 2 +- crates/adapters/core/src/channels/mod.rs | 63 +---- crates/adapters/core/src/lib.rs | 1 - .../core/src/runtime_history_hygiene.rs | 16 -- .../core/src/runtime_tool_artifacts.rs | 221 ------------------ crates/adapters/core/src/tools/mod.rs | 2 +- crates/adapters/onboard/src/wizard.rs | 114 +++++---- crates/adapters/providers/src/compatible.rs | 37 ++- crates/adapters/providers/src/openai.rs | 154 +----------- crates/adapters/providers/src/openai_codex.rs | 65 +----- crates/adapters/providers/src/openrouter.rs | 35 ++- crates/domain/data/model_catalog.json | 197 ++++++++++++++++ crates/domain/src/config/model_catalog.rs | 126 ++++++++++ docs/fork/ipc-phase4_10-audit.md | 69 ++++++ docs/fork/ipc-phase4_10-plan.md | 37 ++- 15 files changed, 574 insertions(+), 565 deletions(-) delete mode 100644 crates/adapters/core/src/runtime_tool_artifacts.rs diff --git a/crates/adapters/core/Cargo.toml b/crates/adapters/core/Cargo.toml index e1b0f8b7..1d8591c8 100644 --- a/crates/adapters/core/Cargo.toml +++ b/crates/adapters/core/Cargo.toml @@ -16,6 +16,7 @@ whatsapp-web = ["dep:wa-rs", "dep:wa-rs-core", "dep:wa-rs-binary", "dep:wa-rs-pr rag-pdf = ["dep:pdf-extract", "synapse_tools/rag-pdf"] browser-native = ["dep:fantoccini", "synapse_tools/browser-native"] sandbox-landlock = ["synapse_security/sandbox-landlock"] +_ipc_tests_todo = [] [dependencies] synapse_domain = { path = "../../domain" } @@ -138,4 +139,3 @@ libc = "0.2" [dev-dependencies] tempfile = "3.27" tokio = { version = "1.50", features = ["rt-multi-thread", "macros", "test-util"] } - diff --git a/crates/adapters/core/src/channels/mod.rs b/crates/adapters/core/src/channels/mod.rs index d9e9f761..35f4761d 100644 --- a/crates/adapters/core/src/channels/mod.rs +++ b/crates/adapters/core/src/channels/mod.rs @@ -3030,12 +3030,9 @@ pub async fn start_channels( mod tests { use super::*; use crate::channel_runtime_support::spawn_supervised_listener_with_health_interval; - use crate::runtime_history_hygiene::{ - normalize_cached_channel_turns, proactive_trim_turns, strip_tool_result_content, - }; + use crate::runtime_history_hygiene::{normalize_cached_channel_turns, proactive_trim_turns}; use crate::runtime_system_prompt::BOOTSTRAP_MAX_CHARS; - use crate::runtime_tool_artifacts::strip_isolated_tool_json_artifacts; - use std::collections::{HashMap, HashSet}; + use std::collections::HashMap; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::Arc; use std::time::Instant; @@ -3156,26 +3153,6 @@ mod tests { assert_eq!(effective_channel_message_timeout_secs(300), 300); } - #[test] - fn strip_tool_result_content_removes_blocks_and_header() { - let input = r#"[Tool results] -Mon Feb 20 -{"status":200}"#; - assert_eq!(strip_tool_result_content(input), ""); - - let mixed = "Some context\nok\nMore text"; - let cleaned = strip_tool_result_content(mixed); - assert!(cleaned.contains("Some context")); - assert!(cleaned.contains("More text")); - assert!(!cleaned.contains("tool_result")); - - assert_eq!( - strip_tool_result_content("no tool results here"), - "no tool results here" - ); - assert_eq!(strip_tool_result_content(""), ""); - } - #[test] fn normalize_cached_channel_turns_merges_consecutive_user_turns() { let turns = vec![ @@ -4693,42 +4670,6 @@ mod tests { assert!(emitted.is_char_boundary(emitted.len())); } - #[test] - fn strip_isolated_tool_json_artifacts_removes_tool_calls_and_results() { - let mut known_tools = HashSet::new(); - known_tools.insert("schedule".to_string()); - - let input = r#"{"name":"schedule","arguments":{"action":"create","message":"test"}} -{"name":"schedule","arguments":{"action":"cancel","task_id":"test"}} -Let me create the reminder properly: -{"name":"schedule","arguments":{"action":"create","message":"Go to sleep"}} -{"result":{"task_id":"abc","status":"scheduled"}} -Done reminder set for 1:38 AM."#; - - let result = strip_isolated_tool_json_artifacts(input, &known_tools); - let normalized = result - .lines() - .filter(|line| !line.trim().is_empty()) - .collect::>() - .join("\n"); - assert_eq!( - normalized, - "Let me create the reminder properly:\nDone reminder set for 1:38 AM." - ); - } - - #[test] - fn strip_isolated_tool_json_artifacts_preserves_non_tool_json() { - let mut known_tools = HashSet::new(); - known_tools.insert("shell".to_string()); - - let input = r#"{"name":"profile","arguments":{"display_mode":"compact"}} -This is an example JSON object for profile settings."#; - - let result = strip_isolated_tool_json_artifacts(input, &known_tools); - assert_eq!(result, input); - } - // ── AIEOS Identity Tests (Issue #168) ───────────────────────── #[test] diff --git a/crates/adapters/core/src/lib.rs b/crates/adapters/core/src/lib.rs index dac75ab5..50787aed 100644 --- a/crates/adapters/core/src/lib.rs +++ b/crates/adapters/core/src/lib.rs @@ -36,7 +36,6 @@ pub(crate) mod runtime_adapter_contract; pub(crate) mod runtime_history_hygiene; pub mod runtime_routes; pub mod runtime_system_prompt; -pub(crate) mod runtime_tool_artifacts; pub(crate) mod runtime_tool_notifications; pub(crate) mod runtime_tool_observer; pub mod scoped_instruction_context; diff --git a/crates/adapters/core/src/runtime_history_hygiene.rs b/crates/adapters/core/src/runtime_history_hygiene.rs index e55d468c..7b70b0ef 100644 --- a/crates/adapters/core/src/runtime_history_hygiene.rs +++ b/crates/adapters/core/src/runtime_history_hygiene.rs @@ -35,22 +35,6 @@ pub(crate) fn normalize_cached_channel_turns(turns: Vec) -> Vec…` blocks from a history entry. -pub(crate) fn strip_tool_result_content(text: &str) -> String { - static TOOL_RESULT_RE: std::sync::LazyLock = std::sync::LazyLock::new(|| { - regex::Regex::new(r"(?s)]*>.*?").unwrap() - }); - - let cleaned = TOOL_RESULT_RE.replace_all(text, ""); - let cleaned = cleaned.trim(); - - if cleaned == "[Tool results]" || cleaned.is_empty() { - return String::new(); - } - - cleaned.to_string() -} - /// Proactively trim conversation turns to the given estimated character budget. /// /// Drops oldest turns first, but always preserves the current/latest turn. diff --git a/crates/adapters/core/src/runtime_tool_artifacts.rs b/crates/adapters/core/src/runtime_tool_artifacts.rs deleted file mode 100644 index 29c0ed7a..00000000 --- a/crates/adapters/core/src/runtime_tool_artifacts.rs +++ /dev/null @@ -1,221 +0,0 @@ -//! Transport-neutral cleanup for tool artifacts leaked into assistant text. - -use std::collections::HashSet; - -/// Remove leading lines that narrate tool usage. -/// -/// Only strips lines from the very beginning of the message so genuine answer -/// content is preserved. -pub(crate) fn strip_tool_narration(message: &str) -> String { - let narration_prefixes: &[&str] = &[ - "let me ", - "i'll ", - "i will ", - "i am going to ", - "i'm going to ", - "searching ", - "looking up ", - "fetching ", - "checking ", - "using the ", - "using my ", - "one moment", - "hold on", - "just a moment", - "give me a moment", - "allow me to ", - ]; - - let mut result_lines: Vec<&str> = Vec::new(); - let mut past_narration = false; - - for line in message.lines() { - if past_narration { - result_lines.push(line); - continue; - } - let trimmed = line.trim(); - if trimmed.is_empty() { - continue; - } - let lower = trimmed.to_lowercase(); - if narration_prefixes - .iter() - .any(|prefix| lower.starts_with(prefix)) - { - continue; - } - past_narration = true; - result_lines.push(line); - } - - let joined = result_lines.join("\n"); - let trimmed = joined.trim(); - if trimmed.is_empty() && !message.trim().is_empty() { - message.to_string() - } else { - trimmed.to_string() - } -} - -fn is_tool_call_payload(value: &serde_json::Value, known_tool_names: &HashSet) -> bool { - let Some(object) = value.as_object() else { - return false; - }; - - let name = object.get("name").and_then(|value| value.as_str()); - let has_args = object.contains_key("arguments"); - - let Some(name) = name.map(str::trim).filter(|name| !name.is_empty()) else { - return false; - }; - - has_args && known_tool_names.contains(&name.to_ascii_lowercase()) -} - -fn is_tool_result_payload( - object: &serde_json::Map, - saw_tool_call_payload: bool, -) -> bool { - if !saw_tool_call_payload || !object.contains_key("result") { - return false; - } - - object.keys().all(|key| { - matches!( - key.as_str(), - "result" | "id" | "tool_call_id" | "name" | "tool" - ) - }) -} - -fn sanitize_tool_json_value( - value: &serde_json::Value, - known_tool_names: &HashSet, - saw_tool_call_payload: bool, -) -> Option<(String, bool)> { - if is_tool_call_payload(value, known_tool_names) { - return Some((String::new(), true)); - } - - if let Some(array) = value.as_array() { - if !array.is_empty() - && array - .iter() - .all(|item| is_tool_call_payload(item, known_tool_names)) - { - return Some((String::new(), true)); - } - return None; - } - - let object = value.as_object()?; - - if let Some(tool_calls) = object.get("tool_calls").and_then(|value| value.as_array()) { - if !tool_calls.is_empty() - && tool_calls - .iter() - .all(|call| is_tool_call_payload(call, known_tool_names)) - { - let content = object - .get("content") - .and_then(|value| value.as_str()) - .unwrap_or("") - .trim() - .to_string(); - return Some((content, true)); - } - } - - if is_tool_result_payload(object, saw_tool_call_payload) { - return Some((String::new(), false)); - } - - None -} - -fn is_line_isolated_json_segment(message: &str, start: usize, end: usize) -> bool { - let line_start = message[..start].rfind('\n').map_or(0, |idx| idx + 1); - let line_end = message[end..] - .find('\n') - .map_or(message.len(), |idx| end + idx); - - message[line_start..start].trim().is_empty() && message[end..line_end].trim().is_empty() -} - -pub(crate) fn strip_isolated_tool_json_artifacts( - message: &str, - known_tool_names: &HashSet, -) -> String { - let mut cleaned = String::with_capacity(message.len()); - let mut cursor = 0usize; - let mut saw_tool_call_payload = false; - - while cursor < message.len() { - let Some(rel_start) = message[cursor..].find(['{', '[']) else { - cleaned.push_str(&message[cursor..]); - break; - }; - - let start = cursor + rel_start; - cleaned.push_str(&message[cursor..start]); - - let candidate = &message[start..]; - let mut stream = - serde_json::Deserializer::from_str(candidate).into_iter::(); - - if let Some(Ok(value)) = stream.next() { - let consumed = stream.byte_offset(); - if consumed > 0 { - let end = start + consumed; - if is_line_isolated_json_segment(message, start, end) { - if let Some((replacement, marks_tool_call)) = - sanitize_tool_json_value(&value, known_tool_names, saw_tool_call_payload) - { - if marks_tool_call { - saw_tool_call_payload = true; - } - if !replacement.trim().is_empty() { - cleaned.push_str(replacement.trim()); - } - cursor = end; - continue; - } - } - } - } - - let Some(ch) = message[start..].chars().next() else { - break; - }; - cleaned.push(ch); - cursor = start + ch.len_utf8(); - } - - let mut result = cleaned.replace("\r\n", "\n"); - while result.contains("\n\n\n") { - result = result.replace("\n\n\n", "\n\n"); - } - result.trim().to_string() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn strip_tool_narration_removes_only_leading_narration() { - let input = "Let me check that.\n\nActual answer\nI will not be stripped here."; - - let result = strip_tool_narration(input); - - assert_eq!(result, "Actual answer\nI will not be stripped here."); - } - - #[test] - fn strip_tool_narration_returns_original_when_everything_is_narration() { - let input = "Let me check that."; - - assert_eq!(strip_tool_narration(input), input); - } -} diff --git a/crates/adapters/core/src/tools/mod.rs b/crates/adapters/core/src/tools/mod.rs index 3b9cf104..380205a2 100644 --- a/crates/adapters/core/src/tools/mod.rs +++ b/crates/adapters/core/src/tools/mod.rs @@ -906,7 +906,7 @@ mod tests { ); let names: Vec<&str> = tools.iter().map(|t| t.name()).collect(); assert!(!names.contains(&"browser_open")); - assert!(names.contains(&"schedule")); + assert!(!names.contains(&"schedule")); assert!(names.contains(&"model_routing_config")); assert!(names.contains(&"pushover")); assert!(names.contains(&"proxy_config")); diff --git a/crates/adapters/onboard/src/wizard.rs b/crates/adapters/onboard/src/wizard.rs index dd2edfe6..77ef1d39 100644 --- a/crates/adapters/onboard/src/wizard.rs +++ b/crates/adapters/onboard/src/wizard.rs @@ -404,12 +404,8 @@ fn apply_provider_update( // ── Quick setup (zero prompts) ─────────────────────────────────── /// Non-interactive setup: generates a sensible default config instantly. -/// Use `synapseclaw onboard` or `synapseclaw onboard --api-key sk-... --provider openrouter --memory sqlite|lucid`. +/// Use `synapseclaw onboard` or `synapseclaw onboard --api-key sk-... --provider openrouter`. // Phase 4.3: SurrealDB is the only backend — no selection needed. -fn backend_key_from_choice(_choice: usize) -> &'static str { - "surrealdb" -} - fn memory_config_defaults_for_backend(_backend: &str) -> MemoryConfig { MemoryConfig::default() } @@ -1199,6 +1195,57 @@ fn ollama_uses_remote_endpoint(provider_api_url: Option<&str>) -> bool { !ollama_endpoint_is_local(&normalized) } +fn provider_prefers_configured_models_endpoint(provider_name: &str) -> bool { + matches!( + canonical_provider_name(provider_name), + "openai" + | "openai-codex" + | "openrouter" + | "groq" + | "mistral" + | "deepseek" + | "xai" + | "together-ai" + | "fireworks" + | "novita" + | "cohere" + | "moonshot" + | "glm" + | "zai" + | "qwen" + | "venice" + | "astrai" + | "nvidia" + | "opencode-go" + | "llamacpp" + | "sglang" + | "vllm" + | "osaurus" + ) +} + +fn models_endpoint_from_api_base_url(raw_url: &str) -> Option { + let normalized = raw_url.trim().trim_end_matches('/'); + if normalized.is_empty() { + return None; + } + if normalized.ends_with("/models") { + return Some(normalized.to_string()); + } + if let Some(prefix) = normalized.strip_suffix("/chat/completions") { + return Some(format!("{prefix}/models")); + } + + let has_api_path = reqwest::Url::parse(normalized) + .ok() + .is_some_and(|url| !url.path().trim_matches('/').is_empty()); + if has_api_path { + Some(format!("{normalized}/models")) + } else { + Some(format!("{normalized}/v1/models")) + } +} + fn resolve_live_models_endpoint( provider_name: &str, provider_api_url: Option<&str>, @@ -1214,32 +1261,9 @@ fn resolve_live_models_endpoint( return Some(format!("{normalized}/models")); } - if matches!( - canonical_provider_name(provider_name), - "llamacpp" | "sglang" | "vllm" | "osaurus" - ) { - if let Some(url) = provider_api_url - .map(str::trim) - .filter(|url| !url.is_empty()) - { - let normalized = url.trim_end_matches('/'); - if normalized.ends_with("/models") { - return Some(normalized.to_string()); - } - return Some(format!("{normalized}/models")); - } - } - - if canonical_provider_name(provider_name) == "openai-codex" { - if let Some(url) = provider_api_url - .map(str::trim) - .filter(|url| !url.is_empty()) - { - let normalized = url.trim_end_matches('/'); - if normalized.ends_with("/models") { - return Some(normalized.to_string()); - } - return Some(format!("{normalized}/models")); + if provider_prefers_configured_models_endpoint(provider_name) { + if let Some(endpoint) = provider_api_url.and_then(models_endpoint_from_api_base_url) { + return Some(endpoint); } } @@ -6548,11 +6572,30 @@ mod tests { ); assert_eq!( resolve_live_models_endpoint("venice", Some("http://localhost:9999/v1")), - Some("https://api.venice.ai/api/v1/models".to_string()) + Some("http://localhost:9999/v1/models".to_string()) ); assert_eq!(resolve_live_models_endpoint("unknown-provider", None), None); } + #[test] + fn resolve_live_models_endpoint_honors_compatible_provider_api_url() { + assert_eq!( + resolve_live_models_endpoint("deepseek", Some("https://api.deepseek.com/v1")), + Some("https://api.deepseek.com/v1/models".to_string()) + ); + assert_eq!( + resolve_live_models_endpoint( + "openai", + Some("https://router.example.com/openai/chat/completions") + ), + Some("https://router.example.com/openai/models".to_string()) + ); + assert_eq!( + resolve_live_models_endpoint("openrouter", Some("https://router.example.com")), + Some("https://router.example.com/v1/models".to_string()) + ); + } + #[test] fn resolve_live_models_endpoint_supports_custom_provider_urls() { assert_eq!( @@ -7002,13 +7045,6 @@ mod tests { assert_eq!(provider_env_var("some-new-provider"), "API_KEY"); } - #[test] - fn backend_key_from_choice_always_returns_surrealdb() { - // Phase 4.3: SurrealDB is the only backend - assert_eq!(backend_key_from_choice(0), "surrealdb"); - assert_eq!(backend_key_from_choice(999), "surrealdb"); - } - // Phase 4.3: old memory backend tests removed (sqlite/lucid/markdown/none gone) #[test] diff --git a/crates/adapters/providers/src/compatible.rs b/crates/adapters/providers/src/compatible.rs index 5c88b9db..3f4d3a06 100644 --- a/crates/adapters/providers/src/compatible.rs +++ b/crates/adapters/providers/src/compatible.rs @@ -15,6 +15,7 @@ use reqwest::{ Client, }; use serde::{Deserialize, Serialize}; +use synapse_domain::config::model_catalog; /// A provider that speaks the OpenAI-compatible chat completions API. /// Used by provider adapters that expose the standard chat-completions shape. @@ -39,7 +40,8 @@ pub struct OpenAiCompatibleProvider { timeout_secs: u64, /// Extra HTTP headers to include in all API requests. extra_headers: std::collections::HashMap, - /// Optional reasoning effort for GPT-5/Codex-compatible backends. + /// Optional reasoning effort, sent only when catalog metadata says the + /// concrete provider:model accepts this OpenAI-compatible control. reasoning_effort: Option, /// Custom API path suffix (e.g. "/v2/generate"). /// When set, overrides the default `/chat/completions` path detection. @@ -372,11 +374,17 @@ impl OpenAiCompatibleProvider { } fn reasoning_effort_for_model(&self, model: &str) -> Option { - let id = model.rsplit('/').next().unwrap_or(model); - let supports_reasoning_effort = id.starts_with("gpt-5") || id.contains("codex"); - supports_reasoning_effort - .then(|| self.reasoning_effort.clone()) - .flatten() + let requested = self.reasoning_effort.as_deref()?; + let provider = self.catalog_provider_id()?; + model_catalog::model_request_policy(&provider, model) + .and_then(|policy| policy.resolve_reasoning_effort(requested)) + } + + fn catalog_provider_id(&self) -> Option { + let normalized = self.name.trim().to_ascii_lowercase().replace(' ', "-"); + model_catalog::provider_default_model(&normalized) + .map(|_| normalized) + .or_else(|| model_catalog::provider_for_api_base_url(&self.base_url).map(String::from)) } } @@ -2370,19 +2378,26 @@ mod tests { } #[test] - fn reasoning_effort_only_applies_to_gpt5_and_codex_models() { - let provider = make_provider("test", "https://example.com", None) + fn reasoning_effort_uses_catalog_policy_not_model_name_guessing() { + let provider = make_provider("OpenAI", "https://api.openai.com/v1", None) .with_reasoning_effort(Some("high".to_string())); assert_eq!( - provider.reasoning_effort_for_model("gpt-5.3-codex"), + provider.reasoning_effort_for_model("openai/gpt-5.4"), Some("high".to_string()) ); + + let unknown = make_provider("test", "https://example.com", None) + .with_reasoning_effort(Some("high".to_string())); + assert_eq!(unknown.reasoning_effort_for_model("gpt-5.4"), None); + + let codex_named_compatible = + make_provider("openai-codex", "https://api.openai.com/v1", None) + .with_reasoning_effort(Some("xhigh".to_string())); assert_eq!( - provider.reasoning_effort_for_model("openai/gpt-5"), + codex_named_compatible.reasoning_effort_for_model("gpt-5.4"), Some("high".to_string()) ); - assert_eq!(provider.reasoning_effort_for_model("llama-3.3-70b"), None); } #[tokio::test] diff --git a/crates/adapters/providers/src/openai.rs b/crates/adapters/providers/src/openai.rs index abeb1f47..0cb3a7d4 100644 --- a/crates/adapters/providers/src/openai.rs +++ b/crates/adapters/providers/src/openai.rs @@ -6,6 +6,7 @@ use crate::traits::{ use async_trait::async_trait; use reqwest::Client; use serde::{Deserialize, Serialize}; +use synapse_domain::config::model_catalog; pub struct OpenAiProvider { base_url: String, @@ -188,36 +189,10 @@ impl OpenAiProvider { } } - /// Adjust temperature for models that have specific requirements. - /// Some OpenAI models (like gpt-5-mini, o1, o3, etc) only accept temperature=1.0. - fn adjust_temperature_for_model(model: &str, requested_temperature: f64) -> f64 { - // Models that require temperature=1.0 - let requires_1_0 = matches!( - model, - "gpt-5" - | "gpt-5-2025-08-07" - | "gpt-5-mini" - | "gpt-5-mini-2025-08-07" - | "gpt-5-nano" - | "gpt-5-nano-2025-08-07" - | "gpt-5.1-chat-latest" - | "gpt-5.2-chat-latest" - | "gpt-5.3-chat-latest" - | "o1" - | "o1-2024-12-17" - | "o3" - | "o3-2025-04-16" - | "o3-mini" - | "o3-mini-2025-01-31" - | "o4-mini" - | "o4-mini-2025-04-16" - ); - - if requires_1_0 { - 1.0 - } else { - requested_temperature - } + fn request_temperature_for_model(model: &str, requested_temperature: f64) -> f64 { + model_catalog::model_request_policy("openai", model) + .and_then(|policy| policy.fixed_temperature) + .unwrap_or(requested_temperature) } fn convert_tools(tools: Option<&[ToolSpec]>) -> Option> { @@ -358,7 +333,7 @@ impl Provider for OpenAiProvider { anyhow::anyhow!("OpenAI API key not set. Set OPENAI_API_KEY or edit config.toml.") })?; - let adjusted_temperature = Self::adjust_temperature_for_model(model, temperature); + let adjusted_temperature = Self::request_temperature_for_model(model, temperature); let mut messages = Vec::new(); @@ -414,7 +389,7 @@ impl Provider for OpenAiProvider { anyhow::anyhow!("OpenAI API key not set. Set OPENAI_API_KEY or edit config.toml.") })?; - let adjusted_temperature = Self::adjust_temperature_for_model(model, temperature); + let adjusted_temperature = Self::request_temperature_for_model(model, temperature); let tools = Self::convert_tools(request.tools); let native_request = NativeChatRequest { @@ -469,7 +444,7 @@ impl Provider for OpenAiProvider { anyhow::anyhow!("OpenAI API key not set. Set OPENAI_API_KEY or edit config.toml.") })?; - let adjusted_temperature = Self::adjust_temperature_for_model(model, temperature); + let adjusted_temperature = Self::request_temperature_for_model(model, temperature); let native_tools: Option> = if tools.is_empty() { None @@ -898,124 +873,19 @@ mod tests { assert!(json.contains("thinking...")); } - // ═══════════════════════════════════════════════════════════════════════ - // Temperature adjustment tests - // ═══════════════════════════════════════════════════════════════════════ - - #[test] - fn adjust_temperature_for_o1_models() { - assert_eq!(OpenAiProvider::adjust_temperature_for_model("o1", 0.7), 1.0); - assert_eq!( - OpenAiProvider::adjust_temperature_for_model("o1-2024-12-17", 0.5), - 1.0 - ); - } - #[test] - fn adjust_temperature_for_o3_models() { - assert_eq!(OpenAiProvider::adjust_temperature_for_model("o3", 0.7), 1.0); - assert_eq!( - OpenAiProvider::adjust_temperature_for_model("o3-2025-04-16", 0.5), - 1.0 - ); + fn request_temperature_uses_catalog_policy_only() { assert_eq!( - OpenAiProvider::adjust_temperature_for_model("o3-mini", 0.3), + OpenAiProvider::request_temperature_for_model("o3", 0.7), 1.0 ); assert_eq!( - OpenAiProvider::adjust_temperature_for_model("o3-mini-2025-01-31", 0.8), + OpenAiProvider::request_temperature_for_model("openai/gpt-5", 0.2), 1.0 ); - } - - #[test] - fn adjust_temperature_for_o4_models() { assert_eq!( - OpenAiProvider::adjust_temperature_for_model("o4-mini", 0.7), - 1.0 - ); - assert_eq!( - OpenAiProvider::adjust_temperature_for_model("o4-mini-2025-04-16", 0.5), - 1.0 - ); - } - - #[test] - fn adjust_temperature_for_gpt5_models() { - assert_eq!( - OpenAiProvider::adjust_temperature_for_model("gpt-5", 0.7), - 1.0 - ); - assert_eq!( - OpenAiProvider::adjust_temperature_for_model("gpt-5-2025-08-07", 0.5), - 1.0 - ); - assert_eq!( - OpenAiProvider::adjust_temperature_for_model("gpt-5-mini", 0.3), - 1.0 - ); - assert_eq!( - OpenAiProvider::adjust_temperature_for_model("gpt-5-mini-2025-08-07", 0.8), - 1.0 - ); - assert_eq!( - OpenAiProvider::adjust_temperature_for_model("gpt-5-nano", 0.6), - 1.0 - ); - assert_eq!( - OpenAiProvider::adjust_temperature_for_model("gpt-5-nano-2025-08-07", 0.4), - 1.0 - ); - } - - #[test] - fn adjust_temperature_for_gpt5_chat_latest_models() { - assert_eq!( - OpenAiProvider::adjust_temperature_for_model("gpt-5.1-chat-latest", 0.7), - 1.0 - ); - assert_eq!( - OpenAiProvider::adjust_temperature_for_model("gpt-5.2-chat-latest", 0.5), - 1.0 - ); - assert_eq!( - OpenAiProvider::adjust_temperature_for_model("gpt-5.3-chat-latest", 0.3), - 1.0 - ); - } - - #[test] - fn adjust_temperature_preserves_for_standard_models() { - assert_eq!( - OpenAiProvider::adjust_temperature_for_model("gpt-4o", 0.7), - 0.7 - ); - assert_eq!( - OpenAiProvider::adjust_temperature_for_model("gpt-4-turbo", 0.5), - 0.5 - ); - assert_eq!( - OpenAiProvider::adjust_temperature_for_model("gpt-3.5-turbo", 0.3), + OpenAiProvider::request_temperature_for_model("catalog-unknown-model", 0.3), 0.3 ); - assert_eq!( - OpenAiProvider::adjust_temperature_for_model("gpt-4", 1.0), - 1.0 - ); - } - - #[test] - fn adjust_temperature_handles_edge_cases() { - // Temperature 0.0 should be preserved for standard models - assert_eq!( - OpenAiProvider::adjust_temperature_for_model("gpt-4o", 0.0), - 0.0 - ); - // Temperature 1.0 should be preserved for all models - assert_eq!(OpenAiProvider::adjust_temperature_for_model("o1", 1.0), 1.0); - assert_eq!( - OpenAiProvider::adjust_temperature_for_model("gpt-4o", 1.0), - 1.0 - ); } } diff --git a/crates/adapters/providers/src/openai_codex.rs b/crates/adapters/providers/src/openai_codex.rs index 23a95d72..2bce1832 100644 --- a/crates/adapters/providers/src/openai_codex.rs +++ b/crates/adapters/providers/src/openai_codex.rs @@ -17,6 +17,7 @@ use std::collections::HashMap; use std::fs; use std::path::PathBuf; use std::sync::Mutex; +use synapse_domain::config::model_catalog; const DEFAULT_CODEX_RESPONSES_URL: &str = "https://chatgpt.com/backend-api/codex/responses"; const OFFICIAL_OPENAI_RESPONSES_URL: &str = "https://api.openai.com/v1/responses"; @@ -1019,36 +1020,6 @@ fn parse_responses_chat_response(response: ResponsesResponse) -> ParsedResponses } } -fn clamp_reasoning_effort(model: &str, effort: &str) -> String { - let id = normalize_model_id(model); - if matches!(id, "gpt-5.4" | "gpt-5.4-mini" | "gpt-5-codex") { - return match effort { - "low" | "medium" | "high" => effort.to_string(), - "minimal" => "low".to_string(), - _ => "high".to_string(), - }; - } - if (id.starts_with("gpt-5.2") || id.starts_with("gpt-5.3") || id.starts_with("gpt-5.4")) - && effort == "minimal" - { - return "low".to_string(); - } - if (id.starts_with("gpt-5.4") || id.starts_with("gpt-5-codex")) && effort == "xhigh" { - return "high".to_string(); - } - if id == "gpt-5.1" && effort == "xhigh" { - return "high".to_string(); - } - if id == "gpt-5.1-codex-mini" { - return if effort == "high" || effort == "xhigh" { - "high".to_string() - } else { - "medium".to_string() - }; - } - effort.to_string() -} - fn resolve_reasoning_effort(model_id: &str, configured: Option<&str>) -> String { let raw = configured .map(ToString::to_string) @@ -1056,7 +1027,9 @@ fn resolve_reasoning_effort(model_id: &str, configured: Option<&str>) -> String .and_then(|value| first_nonempty(Some(&value))) .unwrap_or_else(|| "xhigh".to_string()) .to_ascii_lowercase(); - clamp_reasoning_effort(model_id, &raw) + model_catalog::model_request_policy("openai-codex", model_id) + .and_then(|policy| policy.resolve_reasoning_effort(&raw)) + .unwrap_or(raw) } fn nonempty_preserve(text: Option<&str>) -> Option { @@ -2107,41 +2080,25 @@ mod tests { } #[test] - fn clamp_reasoning_effort_adjusts_known_models() { + fn resolve_reasoning_effort_uses_catalog_policy() { assert_eq!( - clamp_reasoning_effort("gpt-5-codex", "xhigh"), + resolve_reasoning_effort("gpt-5-codex", Some("xhigh")), "high".to_string() ); assert_eq!( - clamp_reasoning_effort("gpt-5-codex", "minimal"), + resolve_reasoning_effort("gpt-5-codex", Some("minimal")), "low".to_string() ); assert_eq!( - clamp_reasoning_effort("gpt-5-codex", "medium"), - "medium".to_string() - ); - assert_eq!( - clamp_reasoning_effort("gpt-5.3-codex", "minimal"), - "low".to_string() - ); - assert_eq!( - clamp_reasoning_effort("gpt-5.1", "xhigh"), - "high".to_string() - ); - assert_eq!( - clamp_reasoning_effort("gpt-5-codex", "xhigh"), - "high".to_string() + resolve_reasoning_effort("gpt-5.3-codex", Some("xhigh")), + "xhigh".to_string() ); assert_eq!( - clamp_reasoning_effort("gpt-5.1-codex-mini", "low"), + resolve_reasoning_effort("gpt-5.1-codex-mini", Some("low")), "medium".to_string() ); assert_eq!( - clamp_reasoning_effort("gpt-5.1-codex-mini", "xhigh"), - "high".to_string() - ); - assert_eq!( - clamp_reasoning_effort("gpt-5.3-codex", "xhigh"), + resolve_reasoning_effort("catalog-unknown-model", Some("xhigh")), "xhigh".to_string() ); } diff --git a/crates/adapters/providers/src/openrouter.rs b/crates/adapters/providers/src/openrouter.rs index 9bb1b038..fa997630 100644 --- a/crates/adapters/providers/src/openrouter.rs +++ b/crates/adapters/providers/src/openrouter.rs @@ -8,6 +8,7 @@ use async_trait::async_trait; use reqwest::Client; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; +use synapse_domain::config::model_catalog; pub struct OpenRouterProvider { credential: Option, @@ -181,15 +182,28 @@ impl OpenRouterProvider { } } - fn reasoning_options(&self) -> Option { - match (self.reasoning_enabled, self.reasoning_effort.as_ref()) { + fn reasoning_options(&self, model: &str) -> Option { + if self.reasoning_enabled == Some(false) { + return Some(OpenRouterReasoningOptions { + enabled: Some(false), + effort: None, + }); + } + + let policy = model_catalog::model_request_policy("openrouter", model)?; + let effort = self + .reasoning_effort + .as_deref() + .and_then(|requested| policy.resolve_reasoning_effort(requested)); + + match (self.reasoning_enabled, effort) { (Some(false), _) => Some(OpenRouterReasoningOptions { enabled: Some(false), effort: None, }), (Some(true), Some(effort)) => Some(OpenRouterReasoningOptions { enabled: Some(true), - effort: Some(effort.clone()), + effort: Some(effort), }), (Some(true), None) => Some(OpenRouterReasoningOptions { enabled: Some(true), @@ -197,7 +211,7 @@ impl OpenRouterProvider { }), (None, Some(effort)) => Some(OpenRouterReasoningOptions { enabled: None, - effort: Some(effort.clone()), + effort: Some(effort), }), (None, None) => None, } @@ -492,7 +506,7 @@ impl Provider for OpenRouterProvider { model: model.to_string(), messages, temperature, - reasoning: self.reasoning_options(), + reasoning: self.reasoning_options(model), }; let response = self @@ -541,7 +555,7 @@ impl Provider for OpenRouterProvider { model: model.to_string(), messages: api_messages, temperature, - reasoning: self.reasoning_options(), + reasoning: self.reasoning_options(model), }; let response = self @@ -586,7 +600,7 @@ impl Provider for OpenRouterProvider { model: model.to_string(), messages: Self::convert_messages(request.messages), temperature, - reasoning: self.reasoning_options(), + reasoning: self.reasoning_options(model), tool_choice: tools.as_ref().map(|_| "auto".to_string()), tools, }; @@ -680,7 +694,7 @@ impl Provider for OpenRouterProvider { model: model.to_string(), messages: native_messages, temperature, - reasoning: self.reasoning_options(), + reasoning: self.reasoning_options(model), tool_choice: native_tools.as_ref().map(|_| "auto".to_string()), tools: native_tools, }; @@ -752,12 +766,13 @@ mod tests { OpenRouterProvider::new_with_reasoning(None, Some(true), Some("high".to_string())); assert_eq!( - provider.reasoning_options(), + provider.reasoning_options("x-ai/grok-4.20"), Some(OpenRouterReasoningOptions { enabled: Some(true), effort: Some("high".to_string()), }) ); + assert_eq!(provider.reasoning_options("unknown/no-policy"), None); } #[test] @@ -766,7 +781,7 @@ mod tests { OpenRouterProvider::new_with_reasoning(None, Some(false), Some("high".to_string())); assert_eq!( - provider.reasoning_options(), + provider.reasoning_options("unknown/no-policy"), Some(OpenRouterReasoningOptions { enabled: Some(false), effort: None, diff --git a/crates/domain/data/model_catalog.json b/crates/domain/data/model_catalog.json index ee50b497..01ade817 100644 --- a/crates/domain/data/model_catalog.json +++ b/crates/domain/data/model_catalog.json @@ -584,5 +584,202 @@ "provider": "openrouter", "model": "x-ai/grok-4.20" } + ], + "request_policies": [ + { + "provider": "openai", + "model": "gpt-5", + "fixed_temperature": 1.0 + }, + { + "provider": "openai", + "model": "gpt-5-2025-08-07", + "fixed_temperature": 1.0 + }, + { + "provider": "openai", + "model": "gpt-5-mini", + "fixed_temperature": 1.0 + }, + { + "provider": "openai", + "model": "gpt-5-mini-2025-08-07", + "fixed_temperature": 1.0 + }, + { + "provider": "openai", + "model": "gpt-5-nano", + "fixed_temperature": 1.0 + }, + { + "provider": "openai", + "model": "gpt-5-nano-2025-08-07", + "fixed_temperature": 1.0 + }, + { + "provider": "openai", + "model": "gpt-5.1-chat-latest", + "fixed_temperature": 1.0 + }, + { + "provider": "openai", + "model": "gpt-5.2-chat-latest", + "fixed_temperature": 1.0 + }, + { + "provider": "openai", + "model": "gpt-5.3-chat-latest", + "fixed_temperature": 1.0 + }, + { + "provider": "openai", + "model": "gpt-5.4", + "fixed_temperature": 1.0, + "reasoning_efforts": ["low", "medium", "high"], + "default_reasoning_effort": "high", + "reasoning_effort_aliases": { + "minimal": "low", + "xhigh": "high" + } + }, + { + "provider": "openai", + "model": "gpt-5.4-mini", + "fixed_temperature": 1.0 + }, + { + "provider": "openai", + "model": "gpt-5.4-pro", + "fixed_temperature": 1.0 + }, + { + "provider": "openai", + "model": "o1", + "fixed_temperature": 1.0 + }, + { + "provider": "openai", + "model": "o1-2024-12-17", + "fixed_temperature": 1.0 + }, + { + "provider": "openai", + "model": "o3", + "fixed_temperature": 1.0 + }, + { + "provider": "openai", + "model": "o3-2025-04-16", + "fixed_temperature": 1.0 + }, + { + "provider": "openai", + "model": "o3-mini", + "fixed_temperature": 1.0 + }, + { + "provider": "openai", + "model": "o3-mini-2025-01-31", + "fixed_temperature": 1.0 + }, + { + "provider": "openai", + "model": "o4-mini", + "fixed_temperature": 1.0 + }, + { + "provider": "openai", + "model": "o4-mini-2025-04-16", + "fixed_temperature": 1.0 + }, + { + "provider": "openai-codex", + "model": "gpt-5.4", + "reasoning_efforts": ["low", "medium", "high"], + "default_reasoning_effort": "high", + "reasoning_effort_aliases": { + "minimal": "low", + "xhigh": "high" + } + }, + { + "provider": "openai-codex", + "model": "gpt-5.4-mini", + "reasoning_efforts": ["low", "medium", "high"], + "default_reasoning_effort": "high", + "reasoning_effort_aliases": { + "minimal": "low", + "xhigh": "high" + } + }, + { + "provider": "openai-codex", + "model": "gpt-5-codex", + "reasoning_efforts": ["low", "medium", "high"], + "default_reasoning_effort": "high", + "reasoning_effort_aliases": { + "minimal": "low", + "xhigh": "high" + } + }, + { + "provider": "openai-codex", + "model": "gpt-5.3-codex", + "reasoning_efforts": ["low", "medium", "high", "xhigh"], + "default_reasoning_effort": "xhigh", + "reasoning_effort_aliases": { + "minimal": "low" + } + }, + { + "provider": "openai-codex", + "model": "gpt-5.2-codex", + "reasoning_efforts": ["low", "medium", "high", "xhigh"], + "default_reasoning_effort": "xhigh", + "reasoning_effort_aliases": { + "minimal": "low" + } + }, + { + "provider": "openai-codex", + "model": "gpt-5.1", + "reasoning_efforts": ["low", "medium", "high"], + "default_reasoning_effort": "high", + "reasoning_effort_aliases": { + "minimal": "low", + "xhigh": "high" + } + }, + { + "provider": "openai-codex", + "model": "gpt-5.1-codex-mini", + "reasoning_efforts": ["medium", "high"], + "default_reasoning_effort": "medium", + "reasoning_effort_aliases": { + "minimal": "medium", + "low": "medium", + "xhigh": "high" + } + }, + { + "provider": "openrouter", + "model": "x-ai/grok-4.20", + "reasoning_efforts": ["low", "medium", "high"], + "default_reasoning_effort": "high", + "reasoning_effort_aliases": { + "minimal": "low", + "xhigh": "high" + } + }, + { + "provider": "openrouter", + "model": "openai/gpt-5.4", + "reasoning_efforts": ["low", "medium", "high"], + "default_reasoning_effort": "high", + "reasoning_effort_aliases": { + "minimal": "low", + "xhigh": "high" + } + } ] } diff --git a/crates/domain/src/config/model_catalog.rs b/crates/domain/src/config/model_catalog.rs index 68841313..bcb9213d 100644 --- a/crates/domain/src/config/model_catalog.rs +++ b/crates/domain/src/config/model_catalog.rs @@ -37,6 +37,8 @@ struct ModelCatalogData { embedding_profiles: Vec, #[serde(default, alias = "model_routes")] route_aliases: Vec, + #[serde(default)] + request_policies: Vec, } #[derive(Debug, Clone, Deserialize)] @@ -81,6 +83,54 @@ struct ModelProfileCatalogEntry { features: Vec, } +#[derive(Debug, Clone, Deserialize)] +struct ModelRequestPolicyCatalogEntry { + provider: String, + model: String, + #[serde(default)] + fixed_temperature: Option, + #[serde(default)] + reasoning_efforts: Vec, + #[serde(default)] + default_reasoning_effort: Option, + #[serde(default)] + reasoning_effort_aliases: HashMap, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct ModelRequestPolicy { + pub fixed_temperature: Option, + pub reasoning_efforts: Vec, + pub default_reasoning_effort: Option, + pub reasoning_effort_aliases: HashMap, +} + +impl ModelRequestPolicy { + pub fn resolve_reasoning_effort(&self, requested: &str) -> Option { + let requested = requested.trim().to_ascii_lowercase(); + if requested.is_empty() { + return self.default_reasoning_effort.clone(); + } + + let resolved = self + .reasoning_effort_aliases + .get(&requested) + .cloned() + .unwrap_or(requested); + + if self.reasoning_efforts.is_empty() + || self + .reasoning_efforts + .iter() + .any(|effort| effort.eq_ignore_ascii_case(&resolved)) + { + return Some(resolved); + } + + self.default_reasoning_effort.clone() + } +} + #[derive(Debug, Clone, Deserialize)] struct EmbeddingProfileCatalogEntry { provider: String, @@ -221,6 +271,17 @@ fn merge_catalog_data( } } + for policy in override_data.request_policies { + if let Some(existing) = merged.request_policies.iter_mut().find(|item| { + item.provider.eq_ignore_ascii_case(&policy.provider) + && item.model.eq_ignore_ascii_case(&policy.model) + }) { + *existing = policy; + } else { + merged.request_policies.push(policy); + } + } + merged } @@ -384,6 +445,32 @@ pub fn model_profile(provider: &str, model: &str) -> Option }) } +pub fn model_request_policy(provider: &str, model: &str) -> Option { + let provider = provider.trim(); + let model = model.trim(); + if provider.is_empty() || model.is_empty() { + return None; + } + + let normalized_model = model.rsplit('/').next().unwrap_or(model); + + active_model_catalog() + .data + .request_policies + .iter() + .find(|entry| { + entry.provider.eq_ignore_ascii_case(provider) + && (entry.model.eq_ignore_ascii_case(model) + || entry.model.eq_ignore_ascii_case(normalized_model)) + }) + .map(|entry| ModelRequestPolicy { + fixed_temperature: entry.fixed_temperature, + reasoning_efforts: entry.reasoning_efforts.clone(), + default_reasoning_effort: entry.default_reasoning_effort.clone(), + reasoning_effort_aliases: entry.reasoning_effort_aliases.clone(), + }) +} + pub fn embedding_profile( provider: &str, model: &str, @@ -675,6 +762,15 @@ mod tests { "provider": "openrouter", "model": "google/gemma-4-31b-preview" } + ], + "request_policies": [ + { + "provider": "openai-codex", + "model": "gpt-5.4", + "default_reasoning_effort": "high", + "reasoning_efforts": ["low", "medium", "high"], + "reasoning_effort_aliases": { "minimal": "low", "xhigh": "high" } + } ] }"#, ) @@ -712,5 +808,35 @@ mod tests { .find(|route| route.hint == "gemma31b") .expect("route alias should merge by hint"); assert_eq!(alias.model, "google/gemma-4-31b-preview"); + let policy = merged + .request_policies + .iter() + .find(|policy| policy.provider == "openai-codex" && policy.model == "gpt-5.4") + .expect("request policy should merge by provider/model key"); + assert_eq!(policy.default_reasoning_effort.as_deref(), Some("high")); + } + + #[test] + fn request_policy_resolves_temperature_and_reasoning_aliases() { + let policy = + model_request_policy("openai-codex", "gpt-5.4").expect("policy should resolve"); + assert_eq!( + policy.resolve_reasoning_effort("xhigh"), + Some("high".into()) + ); + assert_eq!( + policy.resolve_reasoning_effort("minimal"), + Some("low".into()) + ); + + let policy = model_request_policy("openai", "o3").expect("policy should resolve"); + assert_eq!(policy.fixed_temperature, Some(1.0)); + + let policy = + model_request_policy("openrouter", "x-ai/grok-4.20").expect("policy should resolve"); + assert_eq!( + policy.resolve_reasoning_effort("xhigh"), + Some("high".into()) + ); } } diff --git a/docs/fork/ipc-phase4_10-audit.md b/docs/fork/ipc-phase4_10-audit.md index f619431f..bea39e6f 100644 --- a/docs/fork/ipc-phase4_10-audit.md +++ b/docs/fork/ipc-phase4_10-audit.md @@ -330,6 +330,75 @@ Reference: ## Re-Audit Of SynapseClaw +### 2026-04-12 Phase 4.10 Code Audit Update + +Current status: + +1. **Prompt/context architecture is materially stronger than the 2026-04-08 baseline** + - provider-facing history is separated from audit history in the active + runtime paths + - compaction preserves protected head/tail and avoids splitting + tool-call/tool-result groups + - provider usage tokens can feed the next compaction pressure decision + - condensed artifact cache is a shared persistent runtime service/port, + visible to both web and channel diagnostics + +2. **Shared runtime tool protocol is now strict** + - shared text/XML tool-call fallback and dead presentation scrubbers were + removed + - raw `` output is treated as a defect unless a concrete provider + adapter owns a narrowly-scoped normalization path + - GLM/minimax/perl/XML-style dialects are not common-runtime features + +3. **Model/provider request knowledge moved further out of Rust hot paths** + - fixed OpenAI temperature rules and reasoning-effort aliases now resolve + through `model_catalog.json` request policies + - OpenAI, OpenAI Codex, OpenRouter, and generic OpenAI-compatible request + controls no longer depend on broad model-family match arms + - configured OpenAI-compatible `/models` refresh endpoints now derive from + the effective `api_url`, so native/aggregator/local endpoints do not share + stale default-provider discovery assumptions + +4. **Hermes parity is closer on context safety** + - model context is treated as input plus reserved output + - compression threshold and hard ceiling scale by trusted window ratio + - large-window to small-window switches preflight against the target route + - web/channel model-switch and command semantics share the same runtime + command/effect/preflight surfaces where lifecycle allows + +5. **Memory hygiene is substantially better but still not fully done** + - old `Children learn_from Parents`-style generic graph pollution is blocked + through governor/extractor gates rather than phrase lists + - raw conversation autosave and consolidation now share one governor path + - remaining risk is concept-heavy long-dialogue ranking after real + compaction, not the already-fixed string-pattern examples + +Still open after this audit: + +1. **No pluggable `ContextEngine` interface yet** + - this is intentionally deferred until the current domain pressure + service/port boundary is stable enough to avoid a parallel context system + +2. **Provider-native continuation remains live-unvalidated** + - `openai-codex` scaffolding exists, but the deployed Codex backend rejected + `previous_response_id`; an official/custom key-based Responses endpoint + still needs validation + +3. **Slice 18 remains partial** + - endpoint-aware cache and `/models` refresh are in place + - no external models.dev-style registry source yet + - no active probe-down request ladder for unknown/local endpoints yet + +4. **Reasoning controls are policy-backed but not fully autonomous** + - request controls now resolve from catalog request policies + - fuller lane/cost-aware reasoning selection and provider-native + `reasoning_details` preservation remain future work + +5. **Long-dialogue quality still needs the expensive closeout run** + - focused cheap-route semantic run passed without procedural skill growth + - it did not produce a real compaction row, so compaction-plus-ranking still + needs validation at a slice-close point + ### What is already moving in the right direction 1. `MEMORY.md` is no longer part of runtime bootstrap or scaffolding. diff --git a/docs/fork/ipc-phase4_10-plan.md b/docs/fork/ipc-phase4_10-plan.md index 75f2d7f0..8565d159 100644 --- a/docs/fork/ipc-phase4_10-plan.md +++ b/docs/fork/ipc-phase4_10-plan.md @@ -603,6 +603,10 @@ Expected outcome: - follow-through: configured AIEOS identity now fails loud when the JSON source is missing, invalid, or renders empty instead of silently mixing in OpenClaw `IDENTITY.md` + - follow-through: dead shared presentation/history cleanup helpers that + stripped XML/tool JSON artifacts were removed; shared runtime still flags + raw `` output as a defect, with only concrete provider adapters + allowed to normalize provider-specific raw envelopes. - Slice 10 groundwork: - lane candidate schema and manual profile metadata - preset expansion (`chatgpt`, `claude`, `openrouter`, `gemini`, `local`) @@ -635,6 +639,9 @@ Expected outcome: - `/model` help now resolves effective lanes through the same runtime lane resolver, so implicit reasoning fallbacks are visible instead of only explicit config lanes + - provider request quirks such as fixed temperature and accepted reasoning + effort aliases now live in editable model-catalog request policies, not + adapter-local Rust match arms - status note: this is not a full Slice 10 close; remaining Slice 10 work is now mostly registry/profile clean-up, adapter-heuristic shrinkage, and keeping lane/candidate-first explanations consistent as Slice 14/18 @@ -991,9 +998,11 @@ Expected outcome: domain pressure service/port boundary is stable enough to avoid creating a parallel context subsystem - reasoning-control follow-through: - - promote provider reasoning controls from global runtime override to a - capability/lane policy once the model-profile registry exposes support - and provider cost tradeoffs consistently + - follow-up narrowed: provider request controls now resolve through + catalog-owned request policies for OpenAI-compatible, OpenAI Codex, and + OpenRouter paths instead of model-name prefix/match arms; remaining work + is a fuller lane/cost-aware decision model as provider metadata becomes + richer - add full `reasoning_details` preservation when the shared provider response/history model can carry provider-native reasoning blocks without leaking adapter-specific shapes into the core runtime @@ -1246,8 +1255,10 @@ Expected outcome: first-class turn routing are now mostly owned by Slice 14 and the shared marker/admission path, not by a separate Slice 10 routing fork - provider capability metadata is still narrower than the eventual lane matrix - - a few provider-local model-family heuristics still remain adapter-side - (for example reasoning-effort clamps) + - known request-policy cases for fixed temperature and reasoning effort + aliases now resolve from `model_catalog.json`; keep auditing future + provider request quirks so they enter through catalog policy or + adapter-local wire normalization, not broad model-family match arms - route state stores lane/candidate identity, and route-switch UX now renders lane for lane-aware switches; downstream runtime surfaces should keep moving toward lane/candidate-first explanations @@ -1888,13 +1899,20 @@ Expected outcome: unknown context windows, never raises them - web `Agent` and channel `AgentRuntimePort` now record those observations through the same `WorkspaceModelProfileCatalog` cache path + - `models refresh` now honors configured OpenAI-compatible `api_url` + endpoints when building `/models` requests, so native vs aggregator vs + local-compatible endpoints do not share stale default-provider discovery + assumptions - still open after Hermes source audit: - no models.dev-style provider-aware registry source yet - - no explicit probe-down tier strategy for unknown/local endpoints yet + - no active probe-down request ladder for unknown/local endpoints yet; the + current explicit strategy is endpoint-aware `/models` refresh first, then + typed context-limit observations from failed turns, then operator catalog + override for proactive routing - status note: - treat Slice 18 as partial: catalog/cache/refresh/endpoint-aware lookup are - landed, but external registry ingestion and explicit unknown-endpoint - probe-down strategy are not complete. + landed, but external registry ingestion and active unknown-endpoint + probe-down requests are not complete. - Hermes-derived model-window resolver follow-through: - keep explicit user override first - completed: persistent live model cache lookup is now endpoint-aware, so the @@ -1909,6 +1927,9 @@ Expected outcome: - completed: persist failed-turn context-limit observations as typed endpoint-aware profile/cache updates when a trustworthy lower window can be inferred + - completed: configured OpenAI-compatible refresh endpoints are derived from + the effective `api_url` instead of silently using the built-in provider + default endpoint - keep broad hardcoded family fallbacks out of core Rust; if unavoidable, they belong in local/bundled catalog data with provenance and freshness - consider a probe-down tier strategy only as an explicit adapter fallback From 20bf335c96a0cf3a8423ea3d33281d0e4f725d7b Mon Sep 17 00:00:00 2001 From: panviktor <35385675+panviktor@users.noreply.github.com> Date: Sun, 12 Apr 2026 20:06:12 +0200 Subject: [PATCH 02/30] Remove legacy route fallbacks from lane selection --- crates/adapters/core/src/channels/mod.rs | 59 ++-- crates/adapters/core/src/gateway/ws.rs | 12 +- .../core/src/runtime_adapter_contract.rs | 10 +- crates/adapters/core/src/runtime_routes.rs | 51 +-- .../services/inbound_message_service.rs | 321 ++++++++++++------ .../services/model_lane_resolution.rs | 161 +++------ .../services/runtime_command_presentation.rs | 1 + .../services/summary_route_resolution.rs | 106 +++--- .../use_cases/handle_inbound_message.rs | 55 +-- crates/domain/src/config/schema.rs | 27 +- docs/fork/ipc-phase4_10-audit.md | 18 + docs/fork/ipc-phase4_10-plan.md | 20 +- 12 files changed, 476 insertions(+), 365 deletions(-) diff --git a/crates/adapters/core/src/channels/mod.rs b/crates/adapters/core/src/channels/mod.rs index 35f4761d..14844764 100644 --- a/crates/adapters/core/src/channels/mod.rs +++ b/crates/adapters/core/src/channels/mod.rs @@ -3433,16 +3433,19 @@ mod tests { ); ctx.default_provider = Arc::new("openrouter".to_string()); ctx.model = Arc::new("large-model".to_string()); - ctx.model_routes = Arc::new(vec![synapse_domain::config::schema::ModelRouteConfig { - hint: "tiny".to_string(), - capability: None, - provider: "openrouter".to_string(), - model: "tiny-model".to_string(), - api_key: None, - profile: synapse_domain::config::schema::ModelCandidateProfileConfig { - context_window_tokens: Some(1_000), - ..Default::default() - }, + ctx.model_lanes = Arc::new(vec![synapse_domain::config::schema::ModelLaneConfig { + lane: synapse_domain::config::schema::CapabilityLane::Reasoning, + candidates: vec![synapse_domain::config::schema::ModelLaneCandidateConfig { + provider: "openrouter".to_string(), + model: "tiny-model".to_string(), + api_key: None, + api_key_env: None, + dimensions: None, + profile: synapse_domain::config::schema::ModelCandidateProfileConfig { + context_window_tokens: Some(1_000), + ..Default::default() + }, + }], }]); ctx.conversation_histories.lock().unwrap().insert( "sender".to_string(), @@ -3453,7 +3456,8 @@ mod tests { &synapse_domain::application::services::inbound_message_service::CommandEffect::SwitchModel { model: "tiny-model".to_string(), inferred_provider: Some("openrouter".to_string()), - lane: None, + lane: Some(synapse_domain::config::schema::CapabilityLane::Reasoning), + candidate_index: Some(0), compacted: false, }, &ctx, @@ -3473,16 +3477,19 @@ mod tests { ); ctx.default_provider = Arc::new("openrouter".to_string()); ctx.model = Arc::new("large-model".to_string()); - ctx.model_routes = Arc::new(vec![synapse_domain::config::schema::ModelRouteConfig { - hint: "compact".to_string(), - capability: None, - provider: "openrouter".to_string(), - model: "compact-model".to_string(), - api_key: None, - profile: synapse_domain::config::schema::ModelCandidateProfileConfig { - context_window_tokens: Some(8_000), - ..Default::default() - }, + ctx.model_lanes = Arc::new(vec![synapse_domain::config::schema::ModelLaneConfig { + lane: synapse_domain::config::schema::CapabilityLane::Reasoning, + candidates: vec![synapse_domain::config::schema::ModelLaneCandidateConfig { + provider: "openrouter".to_string(), + model: "compact-model".to_string(), + api_key: None, + api_key_env: None, + dimensions: None, + profile: synapse_domain::config::schema::ModelCandidateProfileConfig { + context_window_tokens: Some(8_000), + ..Default::default() + }, + }], }]); ctx.conversation_histories.lock().unwrap().insert( "sender".to_string(), @@ -3495,7 +3502,8 @@ mod tests { &synapse_domain::application::services::inbound_message_service::CommandEffect::SwitchModel { model: "compact-model".to_string(), inferred_provider: Some("openrouter".to_string()), - lane: None, + lane: Some(synapse_domain::config::schema::CapabilityLane::Reasoning), + candidate_index: Some(0), compacted: false, }, &ctx, @@ -3508,7 +3516,7 @@ mod tests { synapse_domain::application::services::runtime_command_presentation::format_switch_model_success( "compact-model", "openrouter", - None, + Some(synapse_domain::config::schema::CapabilityLane::Reasoning), true, &synapse_domain::application::services::runtime_command_presentation::RuntimeCommandPresentationOptions::new("openrouter"), ) @@ -3522,6 +3530,11 @@ mod tests { .expect("route set"); assert_eq!(route.provider, "openrouter"); assert_eq!(route.model, "compact-model"); + assert_eq!( + route.lane, + Some(synapse_domain::config::schema::CapabilityLane::Reasoning) + ); + assert_eq!(route.candidate_index, Some(0)); assert_eq!( ctx.conversation_histories .lock() diff --git a/crates/adapters/core/src/gateway/ws.rs b/crates/adapters/core/src/gateway/ws.rs index 054dfc3b..50e620e5 100644 --- a/crates/adapters/core/src/gateway/ws.rs +++ b/crates/adapters/core/src/gateway/ws.rs @@ -1230,12 +1230,14 @@ async fn handle_runtime_command_if_needed( let config_snapshot = state.config.lock().clone(); let effect = synapse_domain::application::services::inbound_message_service::command_effect( &command, - &config_snapshot.model_routes, + &config_snapshot, ); - let default_provider = config_snapshot - .default_provider - .clone() - .unwrap_or_else(|| "openrouter".to_string()); + let default_provider = config_snapshot.default_provider.clone().unwrap_or_else(|| { + current_web_route_selection(state, session_key) + .ok() + .map(|route| route.provider) + .unwrap_or_default() + }); let adapter_contract = WebRuntimeAdapterContract; let mut command_host = WebRuntimeCommandHost { state, diff --git a/crates/adapters/core/src/runtime_adapter_contract.rs b/crates/adapters/core/src/runtime_adapter_contract.rs index b8539ee1..c8b1d8ad 100644 --- a/crates/adapters/core/src/runtime_adapter_contract.rs +++ b/crates/adapters/core/src/runtime_adapter_contract.rs @@ -109,6 +109,11 @@ impl RuntimeRouteMutationRequest { ..Self::default() } } + + fn with_candidate_index(mut self, candidate_index: Option) -> Self { + self.candidate_index = candidate_index; + self + } } #[derive(Debug, Clone, PartialEq, Eq)] @@ -206,6 +211,7 @@ where model, inferred_provider, lane, + candidate_index, compacted, } => { let provider = inferred_provider @@ -221,7 +227,8 @@ where )); } let request = - RuntimeRouteMutationRequest::model(provider.clone(), model.clone(), *lane); + RuntimeRouteMutationRequest::model(provider.clone(), model.clone(), *lane) + .with_candidate_index(*candidate_index); match host.switch_model(request, *compacted).await { Ok(RuntimeModelSwitchOutcome::Applied { provider, @@ -415,6 +422,7 @@ mod tests { model: "vision-model".into(), inferred_provider: Some("openrouter".into()), lane: Some(CapabilityLane::MultimodalUnderstanding), + candidate_index: None, compacted: false, }, CommandEffect::SwitchModelBlocked { diff --git a/crates/adapters/core/src/runtime_routes.rs b/crates/adapters/core/src/runtime_routes.rs index a5c5fd74..451a8d49 100644 --- a/crates/adapters/core/src/runtime_routes.rs +++ b/crates/adapters/core/src/runtime_routes.rs @@ -293,7 +293,6 @@ impl ModelProfileCatalogPort for WorkspaceModelProfileCatalog { pub(crate) fn build_models_help_response(current: &RouteSelection, config: &Config) -> String { let workspace_dir = config.workspace_dir.as_path(); - let model_routes = &config.model_routes; let mut response = String::new(); let _ = writeln!( response, @@ -458,25 +457,7 @@ pub(crate) fn build_models_help_response(current: &RouteSelection, config: &Conf } } - if !model_routes.is_empty() { - response.push_str("\nConfigured model routes:\n"); - for route in model_routes { - let _ = writeln!( - response, - " `{}` → {} ({})", - route.hint, route.model, route.provider - ); - } - } - - let catalog_aliases = synapse_domain::config::model_catalog::model_route_aliases() - .into_iter() - .filter(|alias| { - !model_routes - .iter() - .any(|route| route.hint.eq_ignore_ascii_case(alias.hint.as_str())) - }) - .collect::>(); + let catalog_aliases = synapse_domain::config::model_catalog::model_route_aliases(); if !catalog_aliases.is_empty() { response.push_str("\nCatalog model aliases:\n"); for route in catalog_aliases.iter().take(12) { @@ -1060,7 +1041,6 @@ mod tests { }; use synapse_domain::config::schema::{ Config, ModelCandidateProfileConfig, ModelLaneCandidateConfig, ModelLaneConfig, - ModelRouteConfig, }; use synapse_domain::domain::tool_repair::{ToolFailureKind, ToolRepairAction, ToolRepairTrace}; use synapse_domain::domain::turn_admission::{ @@ -1137,24 +1117,27 @@ mod tests { } #[test] - fn models_help_includes_configured_routes() { + fn models_help_includes_effective_lanes_and_catalog_aliases() { let workspace = tempfile::tempdir().unwrap(); let mut config = Config::default(); config.workspace_dir = workspace.path().to_path_buf(); - config.model_routes = vec![ModelRouteConfig { - hint: "cheap".into(), - provider: "openrouter".into(), - model: "qwen/qwen3.6-plus".into(), - api_key: None, - capability: None, - profile: Default::default(), + config.model_lanes = vec![ModelLaneConfig { + lane: CapabilityLane::CheapReasoning, + candidates: vec![ModelLaneCandidateConfig { + provider: "test-provider".into(), + model: "test-model".into(), + api_key: None, + api_key_env: None, + dimensions: None, + profile: Default::default(), + }], }]; let response = build_models_help_response( &RouteSelection { - provider: "openrouter".into(), - model: "qwen/qwen3.6-plus".into(), - lane: None, - candidate_index: None, + provider: "test-provider".into(), + model: "test-model".into(), + lane: Some(CapabilityLane::CheapReasoning), + candidate_index: Some(0), last_admission: None, recent_admissions: Vec::new(), last_tool_repair: None, @@ -1167,7 +1150,7 @@ mod tests { }, &config, ); - assert!(response.contains("`cheap` → qwen/qwen3.6-plus (openrouter)")); + assert!(response.contains("`cheap_reasoning` → test-model (test-provider)")); assert!(response.contains("Profile sources:")); assert!(response.contains("Current route limits:")); assert!(response.contains("Catalog model aliases:")); diff --git a/crates/domain/src/application/services/inbound_message_service.rs b/crates/domain/src/application/services/inbound_message_service.rs index 5e47d0f8..12575ac7 100644 --- a/crates/domain/src/application/services/inbound_message_service.rs +++ b/crates/domain/src/application/services/inbound_message_service.rs @@ -10,8 +10,9 @@ //! - message classification (command vs regular message) //! - route selection management (provider/model overrides per sender) +use crate::application::services::model_preset_resolution::resolve_effective_model_lanes; use crate::application::services::route_switch_preflight::RouteSwitchPreflight; -use crate::config::schema::{CapabilityLane, ModelRouteConfig}; +use crate::config::schema::{CapabilityLane, Config}; use crate::domain::channel::{ChannelCapability, InboundEnvelope}; // ── Runtime commands ───────────────────────────────────────────── @@ -291,10 +292,12 @@ pub enum CommandEffect { /// Switch to a specific model, optionally with inferred provider from routes. SwitchModel { model: String, - /// If a model_route matched, the coupled provider. + /// If a lane or catalog alias matched, the coupled provider. inferred_provider: Option, - /// If a model_route matched a capability lane, preserve it in route state. + /// If a lane or catalog alias matched a capability lane, preserve it in route state. lane: Option, + /// Ordered lane candidate index when the selector matched a lane candidate. + candidate_index: Option, /// Whether provider-facing context was compacted before the switch. compacted: bool, }, @@ -312,11 +315,25 @@ pub enum CommandEffect { /// Determine the effect of a runtime command. /// -/// Resolves model routes (model name → provider) but leaves +/// Resolves lane/catalog routes (model selector → provider) but leaves /// provider validation to the adapter (requires infrastructure). -pub fn command_effect( +pub fn command_effect(command: &RuntimeCommand, config: &Config) -> CommandEffect { + command_effect_with_alias_resolver(command, config, |value| { + crate::config::model_catalog::model_route_alias(value).map(|route| { + ResolvedModelCommandRoute { + provider: route.provider, + model: route.model, + lane: route.capability, + candidate_index: None, + } + }) + }) +} + +fn command_effect_with_alias_resolver( command: &RuntimeCommand, - model_routes: &[ModelRouteConfig], + config: &Config, + alias_resolver: impl Fn(&str) -> Option, ) -> CommandEffect { match command { RuntimeCommand::ShowProviders => CommandEffect::ShowProviders, @@ -325,27 +342,22 @@ pub fn command_effect( }, RuntimeCommand::ShowModel => CommandEffect::ShowModel, RuntimeCommand::SetModel(raw) => { - let model = raw.trim().trim_matches('`').to_string(); - // Look up in model_routes by model name or hint - let matched = model_routes - .iter() - .find(|route| { - route.model.eq_ignore_ascii_case(&model) - || route.hint.eq_ignore_ascii_case(&model) - }) - .cloned() - .or_else(|| crate::config::model_catalog::model_route_alias(&model)); + let model = normalize_model_selector(raw); + let matched = + resolve_model_command_route_with_alias_resolver(&model, config, alias_resolver); match matched { Some(route) => CommandEffect::SwitchModel { model: route.model, inferred_provider: Some(route.provider), - lane: route.capability, + lane: route.lane, + candidate_index: route.candidate_index, compacted: false, }, None => CommandEffect::SwitchModel { model, inferred_provider: None, lane: None, + candidate_index: None, compacted: false, }, } @@ -354,12 +366,112 @@ pub fn command_effect( } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedModelCommandRoute { + pub provider: String, + pub model: String, + pub lane: Option, + pub candidate_index: Option, +} + +pub fn resolve_model_command_route( + selector: &str, + config: &Config, +) -> Option { + resolve_model_command_route_with_alias_resolver(selector, config, |value| { + crate::config::model_catalog::model_route_alias(value).map(|route| { + ResolvedModelCommandRoute { + provider: route.provider, + model: route.model, + lane: route.capability, + candidate_index: None, + } + }) + }) +} + +fn resolve_model_command_route_with_alias_resolver( + selector: &str, + config: &Config, + alias_resolver: impl Fn(&str) -> Option, +) -> Option { + let selector = normalize_model_selector(selector); + if selector.is_empty() { + return None; + } + + let effective_lanes = resolve_effective_model_lanes(config); + if let Ok(requested_lane) = selector.parse::() { + if let Some((lane, candidate)) = effective_lanes + .iter() + .find(|lane| lane.lane == requested_lane) + .and_then(|lane| { + lane.candidates + .first() + .map(|candidate| (lane.lane, candidate)) + }) + { + return Some(ResolvedModelCommandRoute { + provider: candidate.provider.clone(), + model: candidate.model.clone(), + lane: Some(lane), + candidate_index: Some(0), + }); + } + } + + for lane in &effective_lanes { + if let Some((index, candidate)) = lane + .candidates + .iter() + .enumerate() + .find(|(_, candidate)| model_selector_matches_candidate(&selector, candidate)) + { + return Some(ResolvedModelCommandRoute { + provider: candidate.provider.clone(), + model: candidate.model.clone(), + lane: Some(lane.lane), + candidate_index: Some(index), + }); + } + } + + alias_resolver(&selector) +} + +fn model_selector_matches_candidate( + selector: &str, + candidate: &crate::config::schema::ModelLaneCandidateConfig, +) -> bool { + candidate.model.eq_ignore_ascii_case(selector) + || provider_model_selector(candidate.provider.as_str(), candidate.model.as_str()) + .eq_ignore_ascii_case(selector) +} + +fn provider_model_selector(provider: &str, model: &str) -> String { + format!("{}:{}", provider.trim(), model.trim()) +} + +fn normalize_model_selector(value: &str) -> String { + value.trim().trim_matches('`').to_string() +} + #[cfg(test)] mod tests { use super::*; - use crate::config::schema::ModelCandidateProfileConfig; + use crate::config::schema::{ + ModelCandidateProfileConfig, ModelLaneCandidateConfig, ModelLaneConfig, + }; use crate::domain::channel::SourceKind; + const TEST_PROVIDER: &str = "test-provider"; + const TEST_MAIN_MODEL: &str = "test-main-model"; + const TEST_VISION_PROVIDER: &str = "test-vision-provider"; + const TEST_VISION_MODEL: &str = "test-vision-model"; + const TEST_ALIAS: &str = "test-alias"; + const TEST_ALIAS_PROVIDER: &str = "test-alias-provider"; + const TEST_ALIAS_MODEL: &str = "test-alias-model"; + fn caps_with_runtime() -> Vec { vec![ ChannelCapability::SendText, @@ -381,8 +493,11 @@ mod tests { #[test] fn parse_models_set_provider() { - let cmd = parse_runtime_command("/models anthropic", &caps_with_runtime()); - assert_eq!(cmd, Some(RuntimeCommand::SetProvider("anthropic".into()))); + let cmd = parse_runtime_command("/models test-provider", &caps_with_runtime()); + assert_eq!( + cmd, + Some(RuntimeCommand::SetProvider("test-provider".into())) + ); } #[test] @@ -393,11 +508,8 @@ mod tests { #[test] fn parse_model_set() { - let cmd = parse_runtime_command("/model claude-sonnet-4-5", &caps_with_runtime()); - assert_eq!( - cmd, - Some(RuntimeCommand::SetModel("claude-sonnet-4-5".into())) - ); + let cmd = parse_runtime_command("/model test-model", &caps_with_runtime()); + assert_eq!(cmd, Some(RuntimeCommand::SetModel("test-model".into()))); } #[test] @@ -658,43 +770,63 @@ mod tests { // ── command effect tests ───────────────────────────────────── - fn test_routes() -> Vec { - vec![ - ModelRouteConfig { - hint: "opus".into(), - capability: None, - provider: "anthropic".into(), - model: "claude-sonnet-4-5".into(), - api_key: None, - profile: ModelCandidateProfileConfig::default(), + fn command_test_config() -> Config { + let mut config = Config::default(); + config.model_lanes = vec![ + ModelLaneConfig { + lane: CapabilityLane::Reasoning, + candidates: vec![ModelLaneCandidateConfig { + provider: TEST_PROVIDER.into(), + model: TEST_MAIN_MODEL.into(), + api_key: None, + api_key_env: None, + dimensions: None, + profile: ModelCandidateProfileConfig::default(), + }], }, - ModelRouteConfig { - hint: "gpt4".into(), - capability: None, - provider: "openai".into(), - model: "gpt-5.4".into(), - api_key: None, - profile: ModelCandidateProfileConfig::default(), + ModelLaneConfig { + lane: CapabilityLane::MultimodalUnderstanding, + candidates: vec![ModelLaneCandidateConfig { + provider: TEST_VISION_PROVIDER.into(), + model: TEST_VISION_MODEL.into(), + api_key: None, + api_key_env: None, + dimensions: None, + profile: ModelCandidateProfileConfig::default(), + }], }, - ] + ]; + config + } + + fn test_alias_resolver(value: &str) -> Option { + value + .eq_ignore_ascii_case(TEST_ALIAS) + .then(|| ResolvedModelCommandRoute { + provider: TEST_ALIAS_PROVIDER.into(), + model: TEST_ALIAS_MODEL.into(), + lane: Some(CapabilityLane::CheapReasoning), + candidate_index: None, + }) + } + + fn command_effect_for_test(command: &RuntimeCommand) -> CommandEffect { + command_effect_with_alias_resolver(command, &command_test_config(), test_alias_resolver) } #[test] fn effect_show_providers() { let cmd = RuntimeCommand::ShowProviders; - assert_eq!( - command_effect(&cmd, &test_routes()), - CommandEffect::ShowProviders - ); + assert_eq!(command_effect_for_test(&cmd), CommandEffect::ShowProviders); } #[test] fn effect_switch_provider() { - let cmd = RuntimeCommand::SetProvider("anthropic".into()); + let cmd = RuntimeCommand::SetProvider(TEST_PROVIDER.into()); assert_eq!( - command_effect(&cmd, &test_routes()), + command_effect_for_test(&cmd), CommandEffect::SwitchProvider { - provider: "anthropic".into() + provider: TEST_PROVIDER.into() } ); } @@ -702,21 +834,19 @@ mod tests { #[test] fn effect_show_model() { let cmd = RuntimeCommand::ShowModel; - assert_eq!( - command_effect(&cmd, &test_routes()), - CommandEffect::ShowModel - ); + assert_eq!(command_effect_for_test(&cmd), CommandEffect::ShowModel); } #[test] - fn effect_switch_model_by_hint() { - let cmd = RuntimeCommand::SetModel("opus".into()); + fn effect_switch_model_by_lane_selector() { + let cmd = RuntimeCommand::SetModel("reasoning".into()); assert_eq!( - command_effect(&cmd, &test_routes()), + command_effect_for_test(&cmd), CommandEffect::SwitchModel { - model: "claude-sonnet-4-5".into(), - inferred_provider: Some("anthropic".into()), - lane: None, + model: TEST_MAIN_MODEL.into(), + inferred_provider: Some(TEST_PROVIDER.into()), + lane: Some(CapabilityLane::Reasoning), + candidate_index: Some(0), compacted: false, } ); @@ -724,13 +854,14 @@ mod tests { #[test] fn effect_switch_model_by_name() { - let cmd = RuntimeCommand::SetModel("gpt-5.4".into()); + let cmd = RuntimeCommand::SetModel(TEST_MAIN_MODEL.into()); assert_eq!( - command_effect(&cmd, &test_routes()), + command_effect_for_test(&cmd), CommandEffect::SwitchModel { - model: "gpt-5.4".into(), - inferred_provider: Some("openai".into()), - lane: None, + model: TEST_MAIN_MODEL.into(), + inferred_provider: Some(TEST_PROVIDER.into()), + lane: Some(CapabilityLane::Reasoning), + candidate_index: Some(0), compacted: false, } ); @@ -740,70 +871,57 @@ mod tests { fn effect_switch_model_unknown() { let cmd = RuntimeCommand::SetModel("custom-model".into()); assert_eq!( - command_effect(&cmd, &test_routes()), + command_effect_for_test(&cmd), CommandEffect::SwitchModel { model: "custom-model".into(), inferred_provider: None, lane: None, + candidate_index: None, compacted: false, } ); } #[test] - fn effect_switch_model_by_catalog_alias_when_config_route_missing() { - let cmd = RuntimeCommand::SetModel("gemma31b".into()); + fn effect_switch_model_by_catalog_alias_when_lane_missing() { + let cmd = RuntimeCommand::SetModel(TEST_ALIAS.into()); assert_eq!( - command_effect(&cmd, &[]), + command_effect_for_test(&cmd), CommandEffect::SwitchModel { - model: "google/gemma-4-31b-it".into(), - inferred_provider: Some("openrouter".into()), - lane: None, + model: TEST_ALIAS_MODEL.into(), + inferred_provider: Some(TEST_ALIAS_PROVIDER.into()), + lane: Some(CapabilityLane::CheapReasoning), + candidate_index: None, compacted: false, } ); } #[test] - fn effect_prefers_config_route_over_catalog_alias() { - let cmd = RuntimeCommand::SetModel("gemma31b".into()); - let routes = vec![ModelRouteConfig { - hint: "gemma31b".into(), - capability: None, - provider: "custom-provider".into(), - model: "custom-gemma".into(), - api_key: None, - profile: ModelCandidateProfileConfig::default(), - }]; + fn effect_prefers_lane_candidate_over_catalog_alias() { + let cmd = RuntimeCommand::SetModel(TEST_MAIN_MODEL.into()); assert_eq!( - command_effect(&cmd, &routes), + command_effect_for_test(&cmd), CommandEffect::SwitchModel { - model: "custom-gemma".into(), - inferred_provider: Some("custom-provider".into()), - lane: None, + model: TEST_MAIN_MODEL.into(), + inferred_provider: Some(TEST_PROVIDER.into()), + lane: Some(CapabilityLane::Reasoning), + candidate_index: Some(0), compacted: false, } ); } #[test] - fn effect_switch_model_preserves_route_lane() { - let cmd = RuntimeCommand::SetModel("vision".into()); - let routes = vec![ModelRouteConfig { - hint: "vision".into(), - capability: Some(CapabilityLane::MultimodalUnderstanding), - provider: "openrouter".into(), - model: "vision-model".into(), - api_key: None, - profile: ModelCandidateProfileConfig::default(), - }]; - + fn effect_switch_model_preserves_lane_candidate() { + let cmd = RuntimeCommand::SetModel("multimodal_understanding".into()); assert_eq!( - command_effect(&cmd, &routes), + command_effect_for_test(&cmd), CommandEffect::SwitchModel { - model: "vision-model".into(), - inferred_provider: Some("openrouter".into()), + model: TEST_VISION_MODEL.into(), + inferred_provider: Some(TEST_VISION_PROVIDER.into()), lane: Some(CapabilityLane::MultimodalUnderstanding), + candidate_index: Some(0), compacted: false, } ); @@ -812,10 +930,7 @@ mod tests { #[test] fn effect_new_session() { let cmd = RuntimeCommand::NewSession; - assert_eq!( - command_effect(&cmd, &test_routes()), - CommandEffect::ClearSession - ); + assert_eq!(command_effect_for_test(&cmd), CommandEffect::ClearSession); } #[test] diff --git a/crates/domain/src/application/services/model_lane_resolution.rs b/crates/domain/src/application/services/model_lane_resolution.rs index ea586e63..23018152 100644 --- a/crates/domain/src/application/services/model_lane_resolution.rs +++ b/crates/domain/src/application/services/model_lane_resolution.rs @@ -1,9 +1,6 @@ use crate::application::services::model_capability_support::profile_supports_lane_confidently; use crate::application::services::model_preset_resolution::resolve_effective_model_lanes; -use crate::config::schema::{ - CapabilityLane, Config, EmbeddingRouteConfig, ModelCandidateProfileConfig, ModelFeature, - ModelRouteConfig, -}; +use crate::config::schema::{CapabilityLane, Config, ModelCandidateProfileConfig, ModelFeature}; use crate::ports::model_profile_catalog::{ CatalogModelProfile, CatalogModelProfileSource, ModelProfileCatalogPort, }; @@ -14,8 +11,6 @@ use std::time::{SystemTime, UNIX_EPOCH}; pub enum ModelLaneResolutionSource { ExplicitLaneConfig, ImplicitReasoningLane, - LegacyModelRoute, - LegacyEmbeddingRoute, DefaultRoute, } @@ -23,8 +18,6 @@ pub fn model_lane_resolution_source_name(source: ModelLaneResolutionSource) -> & match source { ModelLaneResolutionSource::ExplicitLaneConfig => "explicit_lane_config", ModelLaneResolutionSource::ImplicitReasoningLane => "implicit_reasoning_lane", - ModelLaneResolutionSource::LegacyModelRoute => "legacy_model_route", - ModelLaneResolutionSource::LegacyEmbeddingRoute => "legacy_embedding_route", ModelLaneResolutionSource::DefaultRoute => "default_route", } } @@ -189,45 +182,7 @@ pub fn resolve_lane_candidates( } } - let legacy_model_routes = config - .model_routes - .iter() - .filter(|route| route.capability == Some(lane) || legacy_route_matches_lane(route, lane)) - .map(|route| { - resolve_candidate( - ModelLaneResolutionSource::LegacyModelRoute, - route.provider.as_str(), - route.model.as_str(), - route.api_key.clone(), - None, - None, - &route.profile, - catalog, - ) - }); - - let legacy_embedding_routes = config - .embedding_routes - .iter() - .filter(|route| { - route.capability == Some(lane) || legacy_embedding_route_matches_lane(route, lane) - }) - .map(|route| { - resolve_candidate( - ModelLaneResolutionSource::LegacyEmbeddingRoute, - route.provider.as_str(), - route.model.as_str(), - route.api_key.clone(), - None, - route.dimensions, - &route.profile, - catalog, - ) - }); - - let mut resolved = legacy_model_routes - .chain(legacy_embedding_routes) - .collect::>(); + let mut resolved = Vec::new(); if resolved.is_empty() && lane_allows_implicit_reasoning_candidates(lane) { resolved = resolve_lane_candidates(config, CapabilityLane::Reasoning, catalog) @@ -241,11 +196,14 @@ pub fn resolve_lane_candidates( } if resolved.is_empty() && lane == CapabilityLane::Reasoning { - if let Some(default_model) = config.default_model.clone() { + if let (Some(default_provider), Some(default_model)) = ( + config.default_provider.as_deref(), + config.default_model.as_deref(), + ) { resolved.push(resolve_candidate( ModelLaneResolutionSource::DefaultRoute, - config.default_provider.as_deref().unwrap_or("openrouter"), - &default_model, + default_provider, + default_model, config.api_key.clone(), None, None, @@ -289,30 +247,18 @@ pub fn resolve_route_selection_profile( } } - if let Some(route_match) = config - .model_routes - .iter() - .find(|candidate| candidate.provider == route.provider && candidate.model == route.model) - { - return resolve_candidate_profile( - route_match.provider.as_str(), - route_match.model.as_str(), - &route_match.profile, - catalog, - ); - } - - if let Some(route_match) = config - .embedding_routes - .iter() - .find(|candidate| candidate.provider == route.provider && candidate.model == route.model) - { - return resolve_candidate_profile( - route_match.provider.as_str(), - route_match.model.as_str(), - &route_match.profile, - catalog, - ); + let effective_lanes = resolve_effective_model_lanes(config); + for lane in effective_lanes { + if let Some(candidate) = lane.candidates.iter().find(|candidate| { + candidate.provider == route.provider && candidate.model == route.model + }) { + return resolve_candidate_profile( + candidate.provider.as_str(), + candidate.model.as_str(), + &candidate.profile, + catalog, + ); + } } resolve_candidate_profile( @@ -487,22 +433,6 @@ fn current_unix_time() -> Option { .map(|duration| duration.as_secs()) } -fn legacy_route_matches_lane(route: &ModelRouteConfig, lane: CapabilityLane) -> bool { - matches!( - (lane, route.hint.as_str()), - (CapabilityLane::CheapReasoning, "cheap") - | (CapabilityLane::Reasoning, "reasoning") - | (CapabilityLane::Reasoning, "main") - ) -} - -fn legacy_embedding_route_matches_lane(route: &EmbeddingRouteConfig, lane: CapabilityLane) -> bool { - matches!( - (lane, route.hint.as_str()), - (CapabilityLane::Embedding, "semantic") - ) -} - fn lane_allows_implicit_reasoning_candidates(lane: CapabilityLane) -> bool { matches!( lane, @@ -522,11 +452,16 @@ mod tests { ModelLaneConfig, SummaryConfig, }; + const TEST_PROVIDER: &str = "test-provider"; + const TEST_MODEL: &str = "test/reasoning-plus"; + const TEST_DEFAULT_PROVIDER: &str = "test-default-provider"; + const TEST_DEFAULT_MODEL: &str = "test-default-model"; + struct StubCatalog; impl ModelProfileCatalogPort for StubCatalog { fn lookup_model_profile(&self, provider: &str, model: &str) -> Option { - if provider == "openrouter" && model == "qwen/qwen3.6-plus" { + if provider == TEST_PROVIDER && model == TEST_MODEL { return Some(CatalogModelProfile { context_window_tokens: Some(200_000), max_output_tokens: Some(32_000), @@ -541,28 +476,20 @@ mod tests { fn base_config() -> Config { let mut config = Config::default(); - config.default_provider = Some("openai-codex".into()); - config.default_model = Some("gpt-5.4".into()); + config.default_provider = Some(TEST_DEFAULT_PROVIDER.into()); + config.default_model = Some(TEST_DEFAULT_MODEL.into()); config.summary = SummaryConfig::default(); config } #[test] - fn explicit_lane_candidates_win_over_legacy_routes() { + fn explicit_lane_candidates_drive_capability_lanes() { let mut config = base_config(); - config.model_routes.push(ModelRouteConfig { - hint: "cheap".into(), - capability: Some(CapabilityLane::CheapReasoning), - provider: "openrouter".into(), - model: "legacy-qwen".into(), - api_key: None, - profile: ModelCandidateProfileConfig::default(), - }); config.model_lanes.push(ModelLaneConfig { lane: CapabilityLane::CheapReasoning, candidates: vec![ModelLaneCandidateConfig { - provider: "openrouter".into(), - model: "qwen/qwen3.6-plus".into(), + provider: TEST_PROVIDER.into(), + model: TEST_MODEL.into(), api_key: None, api_key_env: None, dimensions: None, @@ -589,12 +516,8 @@ mod tests { features: vec![ModelFeature::Vision], }; - let resolved = resolve_candidate_profile( - "openrouter", - "qwen/qwen3.6-plus", - &manual, - Some(&StubCatalog), - ); + let resolved = + resolve_candidate_profile(TEST_PROVIDER, TEST_MODEL, &manual, Some(&StubCatalog)); assert_eq!(resolved.context_window_tokens, Some(64_000)); assert_eq!(resolved.max_output_tokens, Some(32_000)); @@ -619,8 +542,8 @@ mod tests { let resolved = resolve_lane_candidates(&config, CapabilityLane::Reasoning, None); assert_eq!(resolved.len(), 1); assert_eq!(resolved[0].source, ModelLaneResolutionSource::DefaultRoute); - assert_eq!(resolved[0].provider, "openai-codex"); - assert_eq!(resolved[0].model, "gpt-5.4"); + assert_eq!(resolved[0].provider, TEST_DEFAULT_PROVIDER); + assert_eq!(resolved[0].model, TEST_DEFAULT_MODEL); } #[test] @@ -629,8 +552,8 @@ mod tests { config.model_lanes.push(ModelLaneConfig { lane: CapabilityLane::CheapReasoning, candidates: vec![ModelLaneCandidateConfig { - provider: "openrouter".into(), - model: "qwen/qwen3.6-plus".into(), + provider: TEST_PROVIDER.into(), + model: TEST_MODEL.into(), api_key: None, api_key_env: None, dimensions: None, @@ -639,8 +562,8 @@ mod tests { }); let route = RouteSelection { - provider: "openrouter".into(), - model: "qwen/qwen3.6-plus".into(), + provider: TEST_PROVIDER.into(), + model: TEST_MODEL.into(), lane: Some(CapabilityLane::CheapReasoning), candidate_index: Some(0), last_admission: None, @@ -711,7 +634,7 @@ mod tests { config.model_lanes.push(ModelLaneConfig { lane: CapabilityLane::Reasoning, candidates: vec![ModelLaneCandidateConfig { - provider: "openrouter".into(), + provider: TEST_PROVIDER.into(), model: "universal-model".into(), api_key: None, api_key_env: None, @@ -736,7 +659,7 @@ mod tests { resolved[0].source, ModelLaneResolutionSource::ImplicitReasoningLane ); - assert_eq!(resolved[0].provider, "openrouter"); + assert_eq!(resolved[0].provider, TEST_PROVIDER); assert_eq!(resolved[0].model, "universal-model"); } @@ -746,7 +669,7 @@ mod tests { config.model_lanes.push(ModelLaneConfig { lane: CapabilityLane::Reasoning, candidates: vec![ModelLaneCandidateConfig { - provider: "openrouter".into(), + provider: TEST_PROVIDER.into(), model: "plain-model".into(), api_key: None, api_key_env: None, diff --git a/crates/domain/src/application/services/runtime_command_presentation.rs b/crates/domain/src/application/services/runtime_command_presentation.rs index 54a87e8e..be7e5484 100644 --- a/crates/domain/src/application/services/runtime_command_presentation.rs +++ b/crates/domain/src/application/services/runtime_command_presentation.rs @@ -56,6 +56,7 @@ pub fn format_common_command_effect( model, inferred_provider, lane, + candidate_index: _, compacted, } => Some(format_switch_model_success( model, diff --git a/crates/domain/src/application/services/summary_route_resolution.rs b/crates/domain/src/application/services/summary_route_resolution.rs index d8ef1750..ec9d85f0 100644 --- a/crates/domain/src/application/services/summary_route_resolution.rs +++ b/crates/domain/src/application/services/summary_route_resolution.rs @@ -33,7 +33,6 @@ pub struct SummaryRouteResolution { pub fn resolve_summary_route(config: &Config, current_model: &str) -> SummaryRouteResolution { let summary = &config.summary; let summary_model = config.summary_model.as_deref(); - let model_routes = &config.model_routes; let explicit_summary_config = summary.provider.is_some() || summary.model.is_some() || summary.api_key_env.is_some(); @@ -75,17 +74,6 @@ pub fn resolve_summary_route(config: &Config, current_model: &str) -> SummaryRou }; } - if let Some(route) = model_routes.iter().find(|route| route.hint == "cheap") { - return SummaryRouteResolution { - source: SummaryRouteSource::CheapRoute, - provider: Some(route.provider.clone()), - model: route.model.clone(), - temperature: summary.temperature, - api_key: route.api_key.clone(), - api_key_env: None, - }; - } - SummaryRouteResolution { source: SummaryRouteSource::CurrentRoute, provider: None, @@ -101,103 +89,117 @@ mod tests { use super::*; use crate::config::schema::{ Config, ModelCandidateProfileConfig, ModelLaneCandidateConfig, ModelLaneConfig, - ModelRouteConfig, SummaryConfig, + SummaryConfig, }; - fn cheap_route() -> ModelRouteConfig { - ModelRouteConfig { - hint: "cheap".into(), - capability: Some(CapabilityLane::CheapReasoning), - provider: "openrouter".into(), - model: "qwen/qwen3.6-plus".into(), - api_key: Some("test-key".into()), - profile: ModelCandidateProfileConfig::default(), + const TEST_SUMMARY_PROVIDER: &str = "test-summary-provider"; + const TEST_SUMMARY_MODEL: &str = "test-summary-model"; + const TEST_CURRENT_MODEL: &str = "test-current-model"; + const TEST_EXPLICIT_PROVIDER: &str = "test-explicit-provider"; + const TEST_EXPLICIT_MODEL: &str = "test-explicit-model"; + + fn summary_lane_fixture() -> ModelLaneConfig { + ModelLaneConfig { + lane: CapabilityLane::CheapReasoning, + candidates: vec![ModelLaneCandidateConfig { + provider: TEST_SUMMARY_PROVIDER.into(), + model: TEST_SUMMARY_MODEL.into(), + api_key: Some("test-key".into()), + api_key_env: None, + dimensions: None, + profile: ModelCandidateProfileConfig::default(), + }], } } fn base_config() -> Config { let mut config = Config::default(); - config.default_model = Some("gpt-5.4".into()); + config.default_model = Some(TEST_CURRENT_MODEL.into()); config } #[test] - fn explicit_summary_config_wins_over_cheap_route() { + fn explicit_summary_config_wins_over_cheap_lane() { let summary = SummaryConfig { - provider: Some("anthropic".into()), - model: Some("claude-haiku".into()), + provider: Some(TEST_EXPLICIT_PROVIDER.into()), + model: Some(TEST_EXPLICIT_MODEL.into()), temperature: 0.2, - api_key_env: Some("ANTHROPIC_API_KEY".into()), + api_key_env: Some("TEST_SUMMARY_API_KEY".into()), }; let mut config = base_config(); config.summary = summary; - config.model_routes = vec![cheap_route()]; + config.model_lanes = vec![summary_lane_fixture()]; - let resolved = resolve_summary_route(&config, "gpt-5.4"); + let resolved = resolve_summary_route(&config, TEST_CURRENT_MODEL); assert_eq!(resolved.source, SummaryRouteSource::ExplicitSummaryConfig); - assert_eq!(resolved.provider.as_deref(), Some("anthropic")); - assert_eq!(resolved.model, "claude-haiku"); + assert_eq!(resolved.provider.as_deref(), Some(TEST_EXPLICIT_PROVIDER)); + assert_eq!(resolved.model, TEST_EXPLICIT_MODEL); assert_eq!(resolved.temperature, 0.2); - assert_eq!(resolved.api_key_env.as_deref(), Some("ANTHROPIC_API_KEY")); + assert_eq!( + resolved.api_key_env.as_deref(), + Some("TEST_SUMMARY_API_KEY") + ); } #[test] - fn summary_model_wins_over_cheap_route() { + fn summary_model_wins_over_cheap_lane() { let mut config = base_config(); - config.summary_model = Some("gpt-5.4-mini".into()); - config.model_routes = vec![cheap_route()]; - let resolved = resolve_summary_route(&config, "gpt-5.4"); + config.summary_model = Some("explicit-summary-model".into()); + config.model_lanes = vec![summary_lane_fixture()]; + let resolved = resolve_summary_route(&config, TEST_CURRENT_MODEL); assert_eq!(resolved.source, SummaryRouteSource::ExplicitSummaryModel); assert_eq!(resolved.provider, None); - assert_eq!(resolved.model, "gpt-5.4-mini"); + assert_eq!(resolved.model, "explicit-summary-model"); } #[test] - fn cheap_route_becomes_default_summary_lane() { + fn cheap_lane_becomes_default_summary_lane() { let mut config = base_config(); - config.model_routes = vec![cheap_route()]; - let resolved = resolve_summary_route(&config, "gpt-5.4"); + config.model_lanes = vec![summary_lane_fixture()]; + let resolved = resolve_summary_route(&config, TEST_CURRENT_MODEL); assert_eq!(resolved.source, SummaryRouteSource::CheapRoute); - assert_eq!(resolved.provider.as_deref(), Some("openrouter")); - assert_eq!(resolved.model, "qwen/qwen3.6-plus"); + assert_eq!(resolved.provider.as_deref(), Some(TEST_SUMMARY_PROVIDER)); + assert_eq!(resolved.model, TEST_SUMMARY_MODEL); assert_eq!(resolved.api_key.as_deref(), Some("test-key")); } #[test] fn current_route_is_final_fallback() { let config = base_config(); - let resolved = resolve_summary_route(&config, "gpt-5.4"); + let resolved = resolve_summary_route(&config, TEST_CURRENT_MODEL); assert_eq!(resolved.source, SummaryRouteSource::CurrentRoute); assert_eq!(resolved.provider, None); - assert_eq!(resolved.model, "gpt-5.4"); + assert_eq!(resolved.model, TEST_CURRENT_MODEL); } #[test] - fn capability_lane_candidates_beat_legacy_cheap_route() { + fn capability_lane_candidate_provides_summary_route() { let mut config = base_config(); - config.model_routes = vec![cheap_route()]; config.model_lanes = vec![ModelLaneConfig { lane: CapabilityLane::CheapReasoning, candidates: vec![ModelLaneCandidateConfig { - provider: "anthropic".into(), - model: "claude-haiku".into(), + provider: TEST_EXPLICIT_PROVIDER.into(), + model: TEST_EXPLICIT_MODEL.into(), api_key: None, - api_key_env: Some("ANTHROPIC_API_KEY".into()), + api_key_env: Some("TEST_SUMMARY_API_KEY".into()), dimensions: None, profile: ModelCandidateProfileConfig::default(), }], }]; - let resolved = resolve_summary_route(&config, "gpt-5.4"); + let resolved = resolve_summary_route(&config, TEST_CURRENT_MODEL); assert_eq!(resolved.source, SummaryRouteSource::CheapRoute); - assert_eq!(resolved.provider.as_deref(), Some("anthropic")); - assert_eq!(resolved.model, "claude-haiku"); - assert_eq!(resolved.api_key_env.as_deref(), Some("ANTHROPIC_API_KEY")); + assert_eq!(resolved.provider.as_deref(), Some(TEST_EXPLICIT_PROVIDER)); + assert_eq!(resolved.model, TEST_EXPLICIT_MODEL); + assert_eq!( + resolved.api_key_env.as_deref(), + Some("TEST_SUMMARY_API_KEY") + ); } } diff --git a/crates/domain/src/application/use_cases/handle_inbound_message.rs b/crates/domain/src/application/use_cases/handle_inbound_message.rs index 2b1818a7..17b0d1aa 100644 --- a/crates/domain/src/application/use_cases/handle_inbound_message.rs +++ b/crates/domain/src/application/use_cases/handle_inbound_message.rs @@ -188,7 +188,8 @@ pub async fn handle( match classification { MessageClassification::Command(cmd) => { - let effect = inbound_message_service::command_effect(&cmd, &config.model_routes); + let routing_config = build_model_routing_config(config); + let effect = inbound_message_service::command_effect(&cmd, &routing_config); // Apply state changes match &effect { @@ -251,14 +252,13 @@ async fn handle_regular_message( // ── 3. Resolve route (with query classification override) ───── let mut route = ports.routes.get_route(conversation_key); + let routing_config = build_model_routing_config(config); // #4: Query classification override if config.query_classifier.is_some() { if let Some(hint) = config.query_classifier.as_ref().and_then(|f| f(content)) { - if let Some(route_match) = config - .model_routes - .iter() - .find(|route| route.hint.eq_ignore_ascii_case(&hint)) + if let Some(route_match) = + inbound_message_service::resolve_model_command_route(&hint, &routing_config) { tracing::info!( target: "query_classification", @@ -269,14 +269,13 @@ async fn handle_regular_message( ); route.provider = route_match.provider.clone(); route.model = route_match.model.clone(); - route.lane = route_match.capability; - route.candidate_index = None; + route.lane = route_match.lane; + route.candidate_index = route_match.candidate_index; route.clear_runtime_diagnostics(); } } } - let routing_config = build_model_routing_config(config); let current_route_profile = crate::application::services::model_lane_resolution::resolve_route_selection_profile( &routing_config, @@ -1318,7 +1317,9 @@ fn channel_user_profile_key(envelope: &InboundEnvelope) -> String { #[cfg(test)] mod tests { use super::*; - use crate::config::schema::{ModelCandidateProfileConfig, ModelRouteConfig}; + use crate::config::schema::{ + ModelCandidateProfileConfig, ModelLaneCandidateConfig, ModelLaneConfig, + }; use crate::domain::channel::SourceKind; use crate::ports::hooks::NoOpHooks; use crate::ports::route_selection::RouteSelection; @@ -1722,22 +1723,24 @@ mod tests { } fn config_with_route_window( - hint: &str, provider: &str, model: &str, context_window_tokens: usize, ) -> InboundMessageConfig { let mut config = test_config(); - config.model_routes = vec![ModelRouteConfig { - hint: hint.to_string(), - capability: None, - provider: provider.to_string(), - model: model.to_string(), - api_key: None, - profile: ModelCandidateProfileConfig { - context_window_tokens: Some(context_window_tokens), - ..Default::default() - }, + config.model_lanes = vec![ModelLaneConfig { + lane: crate::config::schema::CapabilityLane::Reasoning, + candidates: vec![ModelLaneCandidateConfig { + provider: provider.to_string(), + model: model.to_string(), + api_key: None, + api_key_env: None, + dimensions: None, + profile: ModelCandidateProfileConfig { + context_window_tokens: Some(context_window_tokens), + ..Default::default() + }, + }], }]; config } @@ -1926,7 +1929,7 @@ mod tests { #[tokio::test] async fn channel_model_switch_waits_for_adapter_preflight() { - let env = test_envelope("/model tiny"); + let env = test_envelope("/model tiny-model"); let caps = vec![ ChannelCapability::SendText, ChannelCapability::RuntimeCommands, @@ -1935,7 +1938,7 @@ mod tests { history.append_turn("telegram_user1", ChatMessage::user("x".repeat(20_000))); let routes = Arc::new(RecordingRoutes::new("openrouter", "large-model")); let ports = test_ports_with_state(history, routes.clone(), ""); - let config = config_with_route_window("tiny", "openrouter", "tiny-model", 1_000); + let config = config_with_route_window("openrouter", "tiny-model", 1_000); let result = handle(&env, &caps, &config, &ports).await.unwrap(); @@ -1946,13 +1949,15 @@ mod tests { model, inferred_provider, lane, + candidate_index, compacted, }, .. } => { assert_eq!(model, "tiny-model"); assert_eq!(inferred_provider, Some("openrouter".to_string())); - assert_eq!(lane, None); + assert_eq!(lane, Some(crate::config::schema::CapabilityLane::Reasoning)); + assert_eq!(candidate_index, Some(0)); assert!(!compacted); } other => panic!("expected model switch command, got {other:?}"), @@ -1963,7 +1968,7 @@ mod tests { #[tokio::test] async fn channel_model_switch_defers_safe_downshift_mutation_to_adapter() { - let env = test_envelope("/model compact"); + let env = test_envelope("/model compact-model"); let caps = vec![ ChannelCapability::SendText, ChannelCapability::RuntimeCommands, @@ -1977,7 +1982,7 @@ mod tests { } let routes = Arc::new(RecordingRoutes::new("openrouter", "large-model")); let ports = test_ports_with_state(history.clone(), routes.clone(), ""); - let config = config_with_route_window("compact", "openrouter", "compact-model", 8_000); + let config = config_with_route_window("openrouter", "compact-model", 8_000); let result = handle(&env, &caps, &config, &ports).await.unwrap(); diff --git a/crates/domain/src/config/schema.rs b/crates/domain/src/config/schema.rs index 0c4d8a61..0abd7acd 100644 --- a/crates/domain/src/config/schema.rs +++ b/crates/domain/src/config/schema.rs @@ -185,8 +185,8 @@ pub struct Config { /// Capability-aware model lanes — ordered candidates per runtime lane. /// /// Candidate `0` is the default for the lane; later entries act as - /// fallbacks or manual runtime alternatives. This complements the legacy - /// `model_routes` table and is the preferred long-term routing surface. + /// fallbacks or manual runtime alternatives. This is the primary runtime + /// routing surface. #[serde(default)] pub model_lanes: Vec, @@ -4188,6 +4188,17 @@ pub enum CapabilityLane { } impl CapabilityLane { + pub const ALL: [CapabilityLane; 8] = [ + CapabilityLane::Reasoning, + CapabilityLane::CheapReasoning, + CapabilityLane::Embedding, + CapabilityLane::ImageGeneration, + CapabilityLane::AudioGeneration, + CapabilityLane::VideoGeneration, + CapabilityLane::MusicGeneration, + CapabilityLane::MultimodalUnderstanding, + ]; + pub fn as_str(self) -> &'static str { match self { CapabilityLane::Reasoning => "reasoning", @@ -4202,6 +4213,18 @@ impl CapabilityLane { } } +impl std::str::FromStr for CapabilityLane { + type Err = (); + + fn from_str(value: &str) -> std::result::Result { + let normalized = value.trim().trim_matches('`').replace('-', "_"); + CapabilityLane::ALL + .into_iter() + .find(|lane| lane.as_str().eq_ignore_ascii_case(&normalized)) + .ok_or(()) + } +} + /// Explicit model features used for lane routing and candidate selection. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] #[serde(rename_all = "snake_case")] diff --git a/docs/fork/ipc-phase4_10-audit.md b/docs/fork/ipc-phase4_10-audit.md index bea39e6f..fef23c96 100644 --- a/docs/fork/ipc-phase4_10-audit.md +++ b/docs/fork/ipc-phase4_10-audit.md @@ -48,6 +48,24 @@ Our best move is: 4. load project instructions progressively by scope 5. expose context size and contributors like a first-class runtime surface +## 2026-04-12 Follow-Up + +- Runtime model switching now follows the same typed selector path in web and + channel: effective capability lanes first, catalog aliases second, then an + explicit unresolved model selector. +- Legacy `model_routes` / `embedding_routes` are no longer lane-resolution + fallbacks for `resolve_lane_candidates`, summary routing, query-classifier + overrides, or `/model` command effects. +- Matched lane candidates preserve `lane + candidate_index` through the shared + runtime-command adapter contract, which keeps route state aligned with the + candidate-profile registry instead of only storing provider/model strings. +- `/model` help now renders effective capability lanes and catalog aliases + instead of promoting configured `model_routes` as a first-class runtime + surface. +- Remaining audit target: old config/API/tooling surfaces still expose + `model_routes` as editable compatibility data; future cleanup should either + rename that data shape to catalog aliases or fully retire it. + --- ## Product Patterns diff --git a/docs/fork/ipc-phase4_10-plan.md b/docs/fork/ipc-phase4_10-plan.md index 8565d159..8f762aa2 100644 --- a/docs/fork/ipc-phase4_10-plan.md +++ b/docs/fork/ipc-phase4_10-plan.md @@ -1096,7 +1096,8 @@ Expected outcome: - current status: - agent history compaction, web session summarization, and channel summaries now resolve their summarizer lane through one domain service - - precedence is explicit `[summary]` config -> `summary_model` -> `cheap` route -> current route + - precedence is explicit `[summary]` config -> `summary_model` -> + `cheap_reasoning` capability lane -> current route - live daemon validation confirmed compaction on a long cheap-route dialogue (`history_len_before=54`, `history_len_after=25`) - procedural skill count stayed flat during the pure semantic run (`list_skills = 61`) @@ -1111,6 +1112,10 @@ Expected outcome: entered through the automatic context path - low-information detection now catches repeated semantic shingles, not only exact token chants or contiguous repeated phrases + - summary route resolution no longer consumes legacy `model_routes` cheap + hints; the compactor lane must come from explicit/effective capability + lanes or explicit summary config, keeping cheap-model condensation on the + same routing language as the main runtime - remaining quality tail after the hardening: - recall ranking is better, but still needs live long-dialogue validation on concept-heavy semantic sessions after the hybrid-rerank follow-through @@ -1250,6 +1255,19 @@ Expected outcome: - provider router now rejects unknown `hint:*` model selectors before dispatch instead of executing them on the default provider - `model_routing_config` preset operations now participate in typed routing facts + - `resolve_lane_candidates` no longer treats legacy `model_routes` or + `embedding_routes` as capability-lane fallbacks; fallback to the default + reasoning route now requires both configured provider and configured model, + with no provider string invented in code + - channel and web `/model` command effects now resolve selectors through + effective capability lanes first and catalog aliases second; matched lane + candidates preserve `lane + candidate_index` through the shared + runtime-command adapter contract + - `/model` help no longer renders configured `model_routes` as a first-class + routing surface; it shows effective capability lanes plus catalog aliases + so operator UX matches the runtime selector path + - query-classifier route overrides now reuse the same lane/catalog selector + resolver instead of scanning `model_routes` by hint - remaining work: - historical note: earlier remaining items about image/audio/video/music first-class turn routing are now mostly owned by Slice 14 and the shared From 1296c819789bd77d82ce796bf46ea1124c43ea8d Mon Sep 17 00:00:00 2001 From: panviktor <35385675+panviktor@users.noreply.github.com> Date: Sun, 12 Apr 2026 20:17:07 +0200 Subject: [PATCH 03/30] Move routing config tool to lane candidates --- .../tools/src/model_routing_config.rs | 315 +++++++++++------- crates/domain/src/config/schema.rs | 15 +- .../domain/src/domain/query_classification.rs | 5 +- docs/fork/ipc-phase4_10-audit.md | 10 +- docs/fork/ipc-phase4_10-plan.md | 6 + 5 files changed, 213 insertions(+), 138 deletions(-) diff --git a/crates/adapters/tools/src/model_routing_config.rs b/crates/adapters/tools/src/model_routing_config.rs index 108c219a..5b4298b5 100644 --- a/crates/adapters/tools/src/model_routing_config.rs +++ b/crates/adapters/tools/src/model_routing_config.rs @@ -9,7 +9,8 @@ use synapse_domain::application::services::model_preset_resolution::{ preset_title, resolve_effective_model_lanes, }; use synapse_domain::config::schema::{ - CapabilityLane, ClassificationRule, Config, DelegateAgentConfig, ModelRouteConfig, + CapabilityLane, ClassificationRule, Config, DelegateAgentConfig, ModelCandidateProfileConfig, + ModelLaneCandidateConfig, ModelLaneConfig, }; use synapse_domain::domain::security_policy::SecurityPolicy; use synapse_domain::domain::tool_fact::{ @@ -211,26 +212,27 @@ impl ModelRoutingConfigTool { Ok(Some(value)) } - fn scenario_row(route: &ModelRouteConfig, rule: Option<&ClassificationRule>) -> Value { - let classification = rule.map(|r| { - json!({ - "keywords": r.keywords, - "patterns": r.patterns, - "min_length": r.min_length, - "max_length": r.max_length, - "priority": r.priority, - }) - }); - + fn scenario_row(rule: &ClassificationRule, cfg: &Config) -> Value { + let resolved = + synapse_domain::application::services::inbound_message_service::resolve_model_command_route( + &rule.hint, + cfg, + ); json!({ - "hint": route.hint, - "provider": route.provider, - "model": route.model, - "api_key_configured": route - .api_key - .as_ref() - .is_some_and(|value| !value.trim().is_empty()), - "classification": classification, + "selector": rule.hint, + "resolved_route": resolved.map(|route| json!({ + "provider": route.provider, + "model": route.model, + "lane": route.lane.map(Self::lane_name), + "candidate_index": route.candidate_index, + })), + "classification": { + "keywords": rule.keywords, + "patterns": rule.patterns, + "min_length": rule.min_length, + "max_length": rule.max_length, + "priority": rule.priority, + }, }) } @@ -272,9 +274,6 @@ impl ModelRoutingConfigTool { } fn snapshot(cfg: &Config) -> Value { - let mut routes = cfg.model_routes.clone(); - routes.sort_by(|a, b| a.hint.cmp(&b.hint)); - let mut rules = cfg.query_classification.rules.clone(); rules.sort_by(|a, b| { b.priority @@ -282,18 +281,23 @@ impl ModelRoutingConfigTool { .then_with(|| a.hint.cmp(&b.hint)) }); - let mut scenarios = Vec::with_capacity(routes.len()); - for route in &routes { - let rule = rules.iter().find(|r| r.hint == route.hint); - scenarios.push(Self::scenario_row(route, rule)); - } + let scenarios = rules + .iter() + .map(|rule| Self::scenario_row(rule, cfg)) + .collect::>(); - let classification_only_rules: Vec = rules + let unresolved_classification_rules: Vec = rules .iter() - .filter(|rule| !routes.iter().any(|route| route.hint == rule.hint)) + .filter(|rule| { + synapse_domain::application::services::inbound_message_service::resolve_model_command_route( + &rule.hint, + cfg, + ) + .is_none() + }) .map(|rule| { json!({ - "hint": rule.hint, + "selector": rule.hint, "keywords": rule.keywords, "patterns": rule.patterns, "min_length": rule.min_length, @@ -341,16 +345,11 @@ impl ModelRoutingConfigTool { "rules_count": cfg.query_classification.rules.len(), }, "scenarios": scenarios, - "classification_only_rules": classification_only_rules, + "unresolved_classification_rules": unresolved_classification_rules, "agents": agents, }) } - fn normalize_and_sort_routes(routes: &mut Vec) { - routes.retain(|route| !route.hint.trim().is_empty()); - routes.sort_by(|a, b| a.hint.cmp(&b.hint)); - } - fn normalize_and_sort_rules(rules: &mut Vec) { rules.retain(|rule| !rule.hint.trim().is_empty()); rules.sort_by(|a, b| { @@ -373,6 +372,75 @@ impl ModelRoutingConfigTool { } } + fn parse_lane_selector(args: &Value) -> anyhow::Result { + let raw = args + .get("lane") + .or_else(|| args.get("hint")) + .and_then(Value::as_str) + .ok_or_else(|| anyhow::anyhow!("Missing 'lane'"))? + .trim(); + raw.parse::().map_err(|_| { + anyhow::anyhow!( + "'lane' must be one of: {}", + CapabilityLane::ALL + .into_iter() + .map(Self::lane_name) + .collect::>() + .join(", ") + ) + }) + } + + fn upsert_lane_candidate( + cfg: &mut Config, + lane: CapabilityLane, + provider: String, + model: String, + api_key_update: MaybeSet, + ) { + let existing = cfg + .model_lanes + .iter() + .find(|entry| entry.lane == lane) + .and_then(|entry| { + entry + .candidates + .iter() + .find(|candidate| candidate.provider == provider && candidate.model == model) + .cloned() + }); + let mut candidate = existing.unwrap_or_else(|| ModelLaneCandidateConfig { + provider: provider.clone(), + model: model.clone(), + api_key: None, + api_key_env: None, + dimensions: None, + profile: ModelCandidateProfileConfig::default(), + }); + + candidate.provider = provider; + candidate.model = model; + match api_key_update { + MaybeSet::Set(api_key) => candidate.api_key = Some(api_key), + MaybeSet::Null => candidate.api_key = None, + MaybeSet::Unset => {} + } + + if let Some(entry) = cfg.model_lanes.iter_mut().find(|entry| entry.lane == lane) { + entry.candidates.retain(|existing| { + existing.provider != candidate.provider || existing.model != candidate.model + }); + entry.candidates.insert(0, candidate); + } else { + cfg.model_lanes.push(ModelLaneConfig { + lane, + candidates: vec![candidate], + }); + } + cfg.model_lanes + .sort_by(|left, right| left.lane.as_str().cmp(right.lane.as_str())); + } + fn handle_get(&self) -> anyhow::Result { let cfg = self.load_config_without_env()?; Ok(ToolResult { @@ -384,10 +452,17 @@ impl ModelRoutingConfigTool { fn handle_list_hints(&self) -> anyhow::Result { let cfg = self.load_config_without_env()?; - let mut route_hints: Vec = - cfg.model_routes.iter().map(|r| r.hint.clone()).collect(); - route_hints.sort(); - route_hints.dedup(); + let lane_selectors: Vec<&'static str> = CapabilityLane::ALL + .into_iter() + .map(Self::lane_name) + .collect(); + let mut catalog_aliases: Vec = + synapse_domain::config::model_catalog::model_route_aliases() + .into_iter() + .map(|alias| alias.hint) + .collect(); + catalog_aliases.sort(); + catalog_aliases.dedup(); let mut classification_hints: Vec = cfg .query_classification @@ -401,24 +476,24 @@ impl ModelRoutingConfigTool { Ok(ToolResult { success: true, output: serde_json::to_string_pretty(&json!({ - "model_route_hints": route_hints, + "lane_selectors": lane_selectors, + "catalog_aliases": catalog_aliases, "classification_hints": classification_hints, "example": { - "conversation": { + "reasoning_lane": { "action": "upsert_scenario", - "hint": "conversation", - "provider": "kimi", - "model": "moonshot-v1-8k", + "hint": "reasoning", + "provider": "example-provider", + "model": "example-reasoning-model", "classification_enabled": false }, - "coding": { + "cheap_lane": { "action": "upsert_scenario", - "hint": "coding", - "provider": "openai", - "model": "gpt-5.3-codex", + "hint": "cheap_reasoning", + "provider": "test-provider", + "model": "test-fast-model", "classification_enabled": true, - "keywords": ["code", "bug", "refactor", "test"], - "patterns": ["```"], + "keywords": ["summarize", "short"], "priority": 50 } } @@ -608,7 +683,8 @@ impl ModelRoutingConfigTool { } async fn handle_upsert_scenario(&self, args: &Value) -> anyhow::Result { - let hint = Self::parse_non_empty_string(args, "hint")?; + let lane = Self::parse_lane_selector(args)?; + let selector = Self::lane_name(lane).to_string(); let provider = Self::parse_non_empty_string(args, "provider")?; let model = Self::parse_non_empty_string(args, "model")?; let api_key_update = Self::parse_optional_string_update(args, "api_key")?; @@ -636,53 +712,26 @@ impl ModelRoutingConfigTool { || !matches!(priority_update, MaybeSet::Unset); let mut cfg = self.load_config_without_env()?; - - let existing_route = cfg - .model_routes - .iter() - .find(|route| route.hint == hint) - .cloned(); - - let mut next_route = existing_route.unwrap_or(ModelRouteConfig { - hint: hint.clone(), - capability: None, - provider: provider.clone(), - model: model.clone(), - api_key: None, - profile: synapse_domain::config::schema::ModelCandidateProfileConfig::default(), - }); - - next_route.hint = hint.clone(); - next_route.provider = provider; - next_route.model = model; - - match api_key_update { - MaybeSet::Set(api_key) => next_route.api_key = Some(api_key), - MaybeSet::Null => next_route.api_key = None, - MaybeSet::Unset => {} - } - - cfg.model_routes.retain(|route| route.hint != hint); - cfg.model_routes.push(next_route); - Self::normalize_and_sort_routes(&mut cfg.model_routes); + Self::upsert_lane_candidate(&mut cfg, lane, provider, model, api_key_update); if should_touch_rule { if matches!(classification_enabled, Some(false)) { cfg.query_classification .rules - .retain(|rule| rule.hint != hint); + .retain(|rule| rule.hint != selector); } else { let existing_rule = cfg .query_classification .rules .iter() - .find(|rule| rule.hint == hint) + .find(|rule| rule.hint == selector) .cloned(); let mut next_rule = existing_rule.unwrap_or_else(|| ClassificationRule { - hint: hint.clone(), + hint: selector.clone(), ..ClassificationRule::default() }); + next_rule.hint = selector.clone(); if let Some(keywords) = keywords_update { next_rule.keywords = keywords; @@ -710,18 +759,18 @@ impl ModelRoutingConfigTool { } if matches!(classification_enabled, Some(true)) { - Self::ensure_rule_defaults(&mut next_rule, &hint); + Self::ensure_rule_defaults(&mut next_rule, &selector); } if !Self::has_rule_matcher(&next_rule) { anyhow::bail!( - "Classification rule for hint '{hint}' has no matching criteria. Provide keywords/patterns or set min_length/max_length." + "Classification rule for selector '{selector}' has no matching criteria. Provide keywords/patterns or set min_length/max_length." ); } cfg.query_classification .rules - .retain(|rule| rule.hint != hint); + .retain(|rule| rule.hint != selector); cfg.query_classification.rules.push(next_rule); } } @@ -734,8 +783,9 @@ impl ModelRoutingConfigTool { Ok(ToolResult { success: true, output: serde_json::to_string_pretty(&json!({ - "message": "Scenario route upserted", - "hint": hint, + "message": "Scenario lane candidate upserted", + "selector": selector, + "lane": Self::lane_name(lane), "config": Self::snapshot(&cfg), }))?, error: None, @@ -743,7 +793,8 @@ impl ModelRoutingConfigTool { } async fn handle_remove_scenario(&self, args: &Value) -> anyhow::Result { - let hint = Self::parse_non_empty_string(args, "hint")?; + let lane = Self::parse_lane_selector(args)?; + let selector = Self::lane_name(lane).to_string(); let remove_classification = args .get("remove_classification") .and_then(Value::as_bool) @@ -751,24 +802,23 @@ impl ModelRoutingConfigTool { let mut cfg = self.load_config_without_env()?; - let before_routes = cfg.model_routes.len(); - cfg.model_routes.retain(|route| route.hint != hint); - let routes_removed = before_routes.saturating_sub(cfg.model_routes.len()); + let before_lanes = cfg.model_lanes.len(); + cfg.model_lanes.retain(|entry| entry.lane != lane); + let lanes_removed = before_lanes.saturating_sub(cfg.model_lanes.len()); let mut rules_removed = 0usize; if remove_classification { let before_rules = cfg.query_classification.rules.len(); cfg.query_classification .rules - .retain(|rule| rule.hint != hint); + .retain(|rule| rule.hint != selector); rules_removed = before_rules.saturating_sub(cfg.query_classification.rules.len()); } - if routes_removed == 0 && rules_removed == 0 { - anyhow::bail!("No scenario found for hint '{hint}'"); + if lanes_removed == 0 && rules_removed == 0 { + anyhow::bail!("No scenario found for selector '{selector}'"); } - Self::normalize_and_sort_routes(&mut cfg.model_routes); Self::normalize_and_sort_rules(&mut cfg.query_classification.rules); cfg.query_classification.enabled = !cfg.query_classification.rules.is_empty(); @@ -778,8 +828,9 @@ impl ModelRoutingConfigTool { success: true, output: serde_json::to_string_pretty(&json!({ "message": "Scenario removed", - "hint": hint, - "routes_removed": routes_removed, + "selector": selector, + "lane": Self::lane_name(lane), + "lanes_removed": lanes_removed, "classification_rules_removed": rules_removed, "config": Self::snapshot(&cfg), }))?, @@ -926,7 +977,7 @@ impl Tool for ModelRoutingConfigTool { } fn description(&self) -> &str { - "Manage default model settings, scenario-based provider/model routes, classification rules, and delegate sub-agent profiles" + "Manage default model settings, capability-lane candidates, classification rules, and delegate sub-agent profiles" } fn parameters_schema(&self) -> Value { @@ -950,7 +1001,11 @@ impl Tool for ModelRoutingConfigTool { }, "hint": { "type": "string", - "description": "Scenario hint name (for example: conversation, coding, reasoning)" + "description": "Capability-lane selector for scenario routing (for example: reasoning, cheap_reasoning, image_generation). Prefer 'lane' for new calls." + }, + "lane": { + "type": "string", + "description": "Capability lane for upsert_scenario/remove_scenario (reasoning, cheap_reasoning, embedding, multimodal_understanding, image_generation, audio_generation, video_generation, music_generation)" }, "preset": { "type": ["string", "null"], @@ -974,7 +1029,7 @@ impl Tool for ModelRoutingConfigTool { }, "api_key": { "type": ["string", "null"], - "description": "Optional API key override for scenario route or delegate agent" + "description": "Optional API key override for lane candidate or delegate agent" }, "keywords": { "description": "Classification keywords for upsert_scenario (string or string array)", @@ -1006,7 +1061,7 @@ impl Tool for ModelRoutingConfigTool { }, "classification_enabled": { "type": "boolean", - "description": "When true, upsert classification rule for this hint; false removes it" + "description": "When true, upsert classification rule for this lane selector; false removes it" }, "remove_classification": { "type": "boolean", @@ -1082,7 +1137,8 @@ impl Tool for ModelRoutingConfigTool { .filter(|value| !value.is_empty()) .map(ToOwned::to_owned), hint: args - .get("hint") + .get("lane") + .or_else(|| args.get("hint")) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) @@ -1203,8 +1259,8 @@ mod tests { let result = tool .execute(json!({ "action": "set_default", - "provider": "kimi", - "model": "moonshot-v1-8k", + "provider": "test-provider", + "model": "test-default-model", "temperature": 0.2 })) .await @@ -1214,11 +1270,11 @@ mod tests { let output: Value = serde_json::from_str(&result.output).unwrap(); assert_eq!( output["config"]["default"]["provider"].as_str(), - Some("kimi") + Some("test-provider") ); assert_eq!( output["config"]["default"]["model"].as_str(), - Some("moonshot-v1-8k") + Some("test-default-model") ); assert_eq!( output["config"]["default"]["temperature"].as_f64(), @@ -1227,16 +1283,16 @@ mod tests { } #[tokio::test] - async fn upsert_scenario_creates_route_and_rule() { + async fn upsert_scenario_creates_lane_candidate_and_rule() { let tmp = TempDir::new().unwrap(); let tool = ModelRoutingConfigTool::new(Box::pin(test_config(&tmp)).await, test_security()); let result = tool .execute(json!({ "action": "upsert_scenario", - "hint": "coding", - "provider": "openai", - "model": "gpt-5.3-codex", + "lane": "reasoning", + "provider": "test-provider", + "model": "test-reasoning-model", "classification_enabled": true, "keywords": ["code", "bug", "refactor"], "patterns": ["```"], @@ -1255,9 +1311,10 @@ mod tests { let scenarios = output["scenarios"].as_array().unwrap(); assert!(scenarios.iter().any(|item| { - item["hint"] == json!("coding") - && item["provider"] == json!("openai") - && item["model"] == json!("gpt-5.3-codex") + item["selector"] == json!("reasoning") + && item["resolved_route"]["provider"] == json!("test-provider") + && item["resolved_route"]["model"] == json!("test-reasoning-model") + && item["resolved_route"]["lane"] == json!("reasoning") })); } @@ -1269,9 +1326,9 @@ mod tests { let _ = tool .execute(json!({ "action": "upsert_scenario", - "hint": "coding", - "provider": "openai", - "model": "gpt-5.3-codex", + "lane": "reasoning", + "provider": "test-provider", + "model": "test-reasoning-model", "classification_enabled": true, "keywords": ["code"] })) @@ -1281,7 +1338,7 @@ mod tests { let removed = tool .execute(json!({ "action": "remove_scenario", - "hint": "coding" + "lane": "reasoning" })) .await .unwrap(); @@ -1302,8 +1359,8 @@ mod tests { .execute(json!({ "action": "upsert_agent", "name": "coder", - "provider": "openai", - "model": "gpt-5.3-codex", + "provider": "test-provider", + "model": "test-agent-model", "agentic": true, "allowed_tools": ["file_read", "file_write", "shell"], "max_iterations": 6 @@ -1314,8 +1371,14 @@ mod tests { let get_result = tool.execute(json!({"action": "get"})).await.unwrap(); let output: Value = serde_json::from_str(&get_result.output).unwrap(); - assert_eq!(output["agents"]["coder"]["provider"], json!("openai")); - assert_eq!(output["agents"]["coder"]["model"], json!("gpt-5.3-codex")); + assert_eq!( + output["agents"]["coder"]["provider"], + json!("test-provider") + ); + assert_eq!( + output["agents"]["coder"]["model"], + json!("test-agent-model") + ); assert_eq!(output["agents"]["coder"]["agentic"], json!(true)); let remove = tool @@ -1341,7 +1404,7 @@ mod tests { let result = tool .execute(json!({ "action": "set_default", - "provider": "openai" + "provider": "test-provider" })) .await .unwrap(); @@ -1361,7 +1424,7 @@ mod tests { let result = tool .execute(json!({ "action": "set_default", - "provider": "anthropic", + "provider": "test-provider", "model": "totally-fake-model-12345" })) .await diff --git a/crates/domain/src/config/schema.rs b/crates/domain/src/config/schema.rs index 0abd7acd..7aea809c 100644 --- a/crates/domain/src/config/schema.rs +++ b/crates/domain/src/config/schema.rs @@ -4290,24 +4290,25 @@ pub struct ModelLaneConfig { pub candidates: Vec, } -/// Route a task hint to a specific provider + model. +/// Legacy catalog alias from a task hint to a specific provider + model. /// /// ```toml /// [[model_routes]] /// hint = "reasoning" -/// provider = "openrouter" -/// model = "anthropic/claude-opus-4-20250514" +/// provider = "example-provider" +/// model = "example-reasoning-model" /// /// [[model_routes]] /// hint = "fast" -/// provider = "groq" -/// model = "llama-3.3-70b-versatile" +/// provider = "example-provider" +/// model = "example-fast-model" /// ``` /// -/// Usage: pass `hint:reasoning` as the model parameter to route the request. +/// Runtime routing should prefer `[[model_lanes]]`; this shape remains for +/// catalog aliases and legacy config/API surfaces. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct ModelRouteConfig { - /// Task hint name (e.g. "reasoning", "fast", "code", "summarize") + /// Alias name, such as a capability selector or operator-defined shortcut. pub hint: String, /// Optional capability lane resolved by runtime/domain services. #[serde(default)] diff --git a/crates/domain/src/domain/query_classification.rs b/crates/domain/src/domain/query_classification.rs index 7daf4ba4..3fde5a68 100644 --- a/crates/domain/src/domain/query_classification.rs +++ b/crates/domain/src/domain/query_classification.rs @@ -16,10 +16,11 @@ pub struct QueryClassificationConfig { pub rules: Vec, } -/// A single classification rule mapping message patterns to a model hint. +/// A single classification rule mapping message patterns to a capability-lane +/// selector or catalog alias. #[derive(Debug, Clone, Serialize, Deserialize, Default, schemars::JsonSchema)] pub struct ClassificationRule { - /// Must match a `[[model_routes]]` hint value. + /// Selector resolved by the shared route resolver. pub hint: String, /// Case-insensitive substring matches. #[serde(default)] diff --git a/docs/fork/ipc-phase4_10-audit.md b/docs/fork/ipc-phase4_10-audit.md index fef23c96..9e65851a 100644 --- a/docs/fork/ipc-phase4_10-audit.md +++ b/docs/fork/ipc-phase4_10-audit.md @@ -62,9 +62,13 @@ Our best move is: - `/model` help now renders effective capability lanes and catalog aliases instead of promoting configured `model_routes` as a first-class runtime surface. -- Remaining audit target: old config/API/tooling surfaces still expose - `model_routes` as editable compatibility data; future cleanup should either - rename that data shape to catalog aliases or fully retire it. +- `model_routing_config` no longer creates or removes editable + `model_routes`; scenario upserts now write the selected capability lane's + ordered candidate list and classification rules resolve through the shared + lane/catalog selector path. +- Remaining audit target: old config/API surfaces still expose `model_routes` + as compatibility/catalog-alias data; future cleanup should either rename that + data shape to catalog aliases or fully retire it. --- diff --git a/docs/fork/ipc-phase4_10-plan.md b/docs/fork/ipc-phase4_10-plan.md index 8f762aa2..aae8f7d2 100644 --- a/docs/fork/ipc-phase4_10-plan.md +++ b/docs/fork/ipc-phase4_10-plan.md @@ -1255,6 +1255,9 @@ Expected outcome: - provider router now rejects unknown `hint:*` model selectors before dispatch instead of executing them on the default provider - `model_routing_config` preset operations now participate in typed routing facts + - `model_routing_config` scenario upsert/remove now manages explicit + capability-lane candidate lists plus classification rules instead of + writing legacy `model_routes` - `resolve_lane_candidates` no longer treats legacy `model_routes` or `embedding_routes` as capability-lane fallbacks; fallback to the default reasoning route now requires both configured provider and configured model, @@ -1280,6 +1283,9 @@ Expected outcome: - route state stores lane/candidate identity, and route-switch UX now renders lane for lane-aware switches; downstream runtime surfaces should keep moving toward lane/candidate-first explanations + - old config/API surfaces still expose `model_routes` as compatibility or + catalog-alias data; remove or rename that surface in a dedicated cleanup + when the gateway/UI contract is ready to change - keep auditing that new routing decisions enter through domain/profile services rather than brand-string heuristics From ed7e5a781a3cf2f1d2152910bccb8331abfb8b75 Mon Sep 17 00:00:00 2001 From: panviktor <35385675+panviktor@users.noreply.github.com> Date: Sun, 12 Apr 2026 20:20:25 +0200 Subject: [PATCH 04/30] Move preset alias mapping into catalog --- crates/domain/data/model_catalog.json | 10 ++++ .../services/model_preset_resolution.rs | 23 ++------ crates/domain/src/config/model_catalog.rs | 54 +++++++++++++++++++ docs/fork/ipc-phase4_10-plan.md | 2 + 4 files changed, 71 insertions(+), 18 deletions(-) diff --git a/crates/domain/data/model_catalog.json b/crates/domain/data/model_catalog.json index 01ade817..b50e1db2 100644 --- a/crates/domain/data/model_catalog.json +++ b/crates/domain/data/model_catalog.json @@ -2,29 +2,35 @@ "presets": [ { "id": "chatgpt", + "aliases": ["openai", "openai-codex", "codex"], "title": "ChatGPT / Codex", "description": "OpenAI-style reasoning lane with minimal defaults.", "default_provider": "openai-codex", "default_model": "gpt-5.4", "seed_multimodal_from_reasoning": true, + "provider_aliases": ["openai", "openai-codex"], "extra_lanes": [] }, { "id": "claude", + "aliases": ["anthropic"], "title": "Claude", "description": "Anthropic-style reasoning lane with minimal defaults.", "default_provider": "anthropic", "default_model": "claude-sonnet-4-6", "seed_multimodal_from_reasoning": true, + "provider_aliases": ["anthropic"], "extra_lanes": [] }, { "id": "openrouter", + "aliases": [], "title": "OpenRouter", "description": "Reasoning lane plus cheap Qwen summary and embedding defaults.", "default_provider": "openrouter", "default_model": "anthropic/claude-sonnet-4-6", "seed_multimodal_from_reasoning": false, + "provider_aliases": ["openrouter"], "extra_lanes": [ { "lane": "cheap_reasoning", @@ -56,20 +62,24 @@ }, { "id": "gemini", + "aliases": ["google"], "title": "Gemini", "description": "Gemini reasoning lane with room for multimodal expansion.", "default_provider": "gemini", "default_model": "gemini-3.1-pro", "seed_multimodal_from_reasoning": true, + "provider_aliases": ["gemini"], "extra_lanes": [] }, { "id": "local", + "aliases": ["local-ai", "self-hosted", "ollama"], "title": "Local / self-hosted", "description": "Single local reasoning lane without cloud-only helper lanes.", "default_provider": "ollama", "default_model": "llama4-scout:latest", "seed_multimodal_from_reasoning": false, + "provider_aliases": ["ollama", "llamacpp", "sglang", "vllm", "osaurus"], "extra_lanes": [] } ], diff --git a/crates/domain/src/application/services/model_preset_resolution.rs b/crates/domain/src/application/services/model_preset_resolution.rs index 48fb4942..82b87e60 100644 --- a/crates/domain/src/application/services/model_preset_resolution.rs +++ b/crates/domain/src/application/services/model_preset_resolution.rs @@ -1,8 +1,10 @@ use crate::config::model_catalog::{ apply_default_api_key, known_model_presets as catalog_known_model_presets, + normalize_model_preset_id as catalog_normalize_model_preset_id, preset_description as catalog_preset_description, preset_extra_lanes, preset_reasoning_seed as catalog_preset_reasoning_seed, preset_seed_multimodal_from_reasoning, - preset_title as catalog_preset_title, KnownModelPreset, + preset_title as catalog_preset_title, + recommended_preset_for_provider as catalog_recommended_preset_for_provider, KnownModelPreset, }; use crate::config::schema::{ CapabilityLane, Config, ModelCandidateProfileConfig, ModelFeature, ModelLaneCandidateConfig, @@ -14,26 +16,11 @@ pub fn known_model_presets() -> &'static [KnownModelPreset] { } pub fn recommended_model_preset_for_provider(provider: &str) -> Option<&'static str> { - match provider { - "openai" | "openai-codex" => Some("chatgpt"), - "anthropic" => Some("claude"), - "openrouter" => Some("openrouter"), - "gemini" => Some("gemini"), - "ollama" | "llamacpp" | "sglang" | "vllm" | "osaurus" => Some("local"), - _ => None, - } + catalog_recommended_preset_for_provider(provider) } pub fn normalize_model_preset_id(value: &str) -> Option<&'static str> { - let normalized = value.trim().to_ascii_lowercase(); - match normalized.as_str() { - "chatgpt" | "openai" | "openai-codex" | "codex" => Some("chatgpt"), - "claude" | "anthropic" => Some("claude"), - "openrouter" => Some("openrouter"), - "gemini" | "google" => Some("gemini"), - "local" | "local-ai" | "self-hosted" | "ollama" => Some("local"), - _ => None, - } + catalog_normalize_model_preset_id(value) } pub fn preset_title(preset_id: &str) -> Option<&'static str> { diff --git a/crates/domain/src/config/model_catalog.rs b/crates/domain/src/config/model_catalog.rs index bcb9213d..cea5d276 100644 --- a/crates/domain/src/config/model_catalog.rs +++ b/crates/domain/src/config/model_catalog.rs @@ -44,6 +44,8 @@ struct ModelCatalogData { #[derive(Debug, Clone, Deserialize)] struct ModelPresetCatalogEntry { id: String, + #[serde(default)] + aliases: Vec, title: String, description: String, default_provider: String, @@ -51,6 +53,8 @@ struct ModelPresetCatalogEntry { #[serde(default)] seed_multimodal_from_reasoning: bool, #[serde(default)] + provider_aliases: Vec, + #[serde(default)] extra_lanes: Vec, } @@ -327,6 +331,46 @@ pub fn preset_description(preset_id: &str) -> Option<&'static str> { .map(|preset| preset.description.as_str()) } +pub fn normalize_model_preset_id(value: &str) -> Option<&'static str> { + let value = value.trim(); + if value.is_empty() { + return None; + } + + active_model_catalog() + .data + .presets + .iter() + .find(|preset| { + preset.id.eq_ignore_ascii_case(value) + || preset + .aliases + .iter() + .any(|alias| alias.eq_ignore_ascii_case(value)) + }) + .map(|preset| preset.id.as_str()) +} + +pub fn recommended_preset_for_provider(provider: &str) -> Option<&'static str> { + let provider = provider.trim(); + if provider.is_empty() { + return None; + } + + active_model_catalog() + .data + .presets + .iter() + .find(|preset| { + preset.default_provider.eq_ignore_ascii_case(provider) + || preset + .provider_aliases + .iter() + .any(|alias| alias.eq_ignore_ascii_case(provider)) + }) + .map(|preset| preset.id.as_str()) +} + pub fn preset_reasoning_seed(preset_id: &str) -> Option<(&'static str, &'static str)> { active_model_catalog() .data @@ -592,6 +636,16 @@ mod tests { assert!(presets.iter().any(|preset| preset.id == "openrouter")); } + #[test] + fn preset_aliases_come_from_catalog_data() { + assert_eq!(normalize_model_preset_id("codex"), Some("chatgpt")); + assert_eq!( + recommended_preset_for_provider("openai-codex"), + Some("chatgpt") + ); + assert_eq!(recommended_preset_for_provider("llamacpp"), Some("local")); + } + #[test] fn openrouter_preset_has_auxiliary_lanes() { let lanes = preset_extra_lanes("openrouter").expect("preset should exist"); diff --git a/docs/fork/ipc-phase4_10-plan.md b/docs/fork/ipc-phase4_10-plan.md index aae8f7d2..a673b062 100644 --- a/docs/fork/ipc-phase4_10-plan.md +++ b/docs/fork/ipc-phase4_10-plan.md @@ -1242,6 +1242,8 @@ Expected outcome: - onboarding starts from simple presets and then populates the richer lane-aware config - preset seeds, provider defaults, curated model lists, and default pricing now live in a built-in external catalog instead of hardcoded Rust match arms + - preset aliases and provider-to-preset recommendations now also resolve from + the catalog instead of Rust provider-name match arms - users can now materialize and edit a local override catalog next to `config.toml` via `synapseclaw models catalog init`; runtime merges that file over the built-in catalog - `/model` help and routing config inspection now surface preset/effective-lane information From 94142ca5cea23355eb4f00a9f044eda519199e23 Mon Sep 17 00:00:00 2001 From: panviktor <35385675+panviktor@users.noreply.github.com> Date: Sun, 12 Apr 2026 20:29:59 +0200 Subject: [PATCH 05/30] Unify live agent route classification --- crates/adapters/core/src/agent/agent.rs | 255 +++++++++++++++--------- docs/fork/ipc-phase4_10-audit.md | 3 + docs/fork/ipc-phase4_10-plan.md | 3 + 3 files changed, 168 insertions(+), 93 deletions(-) diff --git a/crates/adapters/core/src/agent/agent.rs b/crates/adapters/core/src/agent/agent.rs index 4ce7157a..1f70573a 100644 --- a/crates/adapters/core/src/agent/agent.rs +++ b/crates/adapters/core/src/agent/agent.rs @@ -27,6 +27,7 @@ use synapse_domain::application::services::model_capability_support::{ #[cfg(test)] use synapse_domain::application::services::model_lane_resolution::ResolvedModelProfile; use synapse_domain::application::services::model_lane_resolution::ResolvedModelProfileConfidence; +use synapse_domain::application::services::model_preset_resolution::resolve_effective_model_lanes; use synapse_domain::application::services::provider_context_budget::{ assess_provider_context_budget, provider_context_artifact_name, provider_context_budget_tier_name, provider_context_condensation_mode_name, @@ -76,7 +77,9 @@ use synapse_domain::application::services::turn_context::{ }; use synapse_domain::application::services::turn_interpretation; use synapse_domain::application::services::turn_model_routing::resolve_turn_route_override; -use synapse_domain::config::schema::{CapabilityLane, Config, ContextCompressionConfig}; +use synapse_domain::config::schema::{ + CapabilityLane, Config, ContextCompressionConfig, ModelRouteConfig, +}; use synapse_domain::domain::tool_fact::TypedToolFact; use synapse_domain::domain::tool_repair::{tool_failure_kind_name, ToolRepairTrace}; use synapse_domain::domain::turn_admission::{ @@ -110,6 +113,16 @@ use synapse_security::security_policy_from_config; const PROVIDER_CONTEXT_CHAT_MESSAGES: usize = 6; const SESSION_HYGIENE_HARD_MESSAGE_LIMIT: usize = 400; +#[derive(Debug, Clone)] +struct AgentModelSelection { + provider: String, + actual_model: String, + provider_model_argument: String, + lane: Option, + candidate_index: Option, + hint: Option, +} + #[derive(Clone, Default)] pub struct AgentRuntimePorts { pub conversation_store: Option>, @@ -123,6 +136,46 @@ pub struct AgentRuntimePorts { pub history_compaction_cache: Option>, } +fn build_provider_router_routes(config: &Config) -> Vec { + let mut routes = Vec::new(); + let mut seen_hints = HashSet::new(); + + for lane in resolve_effective_model_lanes(config) { + if let Some(candidate) = lane.candidates.first() { + push_provider_router_route( + &mut routes, + &mut seen_hints, + ModelRouteConfig { + hint: lane.lane.as_str().to_string(), + capability: Some(lane.lane), + provider: candidate.provider.clone(), + model: candidate.model.clone(), + api_key: candidate.api_key.clone(), + profile: candidate.profile.clone(), + }, + ); + } + } + + for alias in synapse_domain::config::model_catalog::model_route_aliases() { + push_provider_router_route(&mut routes, &mut seen_hints, alias); + } + + routes +} + +fn push_provider_router_route( + routes: &mut Vec, + seen_hints: &mut HashSet, + route: ModelRouteConfig, +) { + let hint = route.hint.trim(); + if hint.is_empty() || !seen_hints.insert(hint.to_ascii_lowercase()) { + return; + } + routes.push(route); +} + fn canonicalize_tool_args(value: &serde_json::Value) -> serde_json::Value { match value { serde_json::Value::Object(map) => { @@ -292,11 +345,8 @@ pub struct Agent { memory_session_id: Option, history: Vec, classification_config: synapse_domain::config::schema::QueryClassificationConfig, - available_hints: Vec, - route_model_by_hint: HashMap, route_model_preset: Option, route_model_lanes: Vec, - route_model_routes: Vec, current_model_profile: synapse_domain::application::services::model_lane_resolution::ResolvedModelProfile, /// Cumulative token usage from the last turn (provider-reported). @@ -358,11 +408,8 @@ pub struct AgentBuilder { auto_save: Option, memory_session_id: Option, classification_config: Option, - available_hints: Option>, - route_model_by_hint: Option>, route_model_preset: Option>, route_model_lanes: Option>, - route_model_routes: Option>, current_model_profile: Option, allowed_tools: Option>, @@ -406,11 +453,8 @@ impl AgentBuilder { auto_save: None, memory_session_id: None, classification_config: None, - available_hints: None, - route_model_by_hint: None, route_model_preset: None, route_model_lanes: None, - route_model_routes: None, current_model_profile: None, allowed_tools: None, response_cache: None, @@ -548,16 +592,6 @@ impl AgentBuilder { self } - pub fn available_hints(mut self, available_hints: Vec) -> Self { - self.available_hints = Some(available_hints); - self - } - - pub fn route_model_by_hint(mut self, route_model_by_hint: HashMap) -> Self { - self.route_model_by_hint = Some(route_model_by_hint); - self - } - pub fn route_model_preset(mut self, route_model_preset: Option) -> Self { self.route_model_preset = Some(route_model_preset); self @@ -571,14 +605,6 @@ impl AgentBuilder { self } - pub fn route_model_routes( - mut self, - route_model_routes: Vec, - ) -> Self { - self.route_model_routes = Some(route_model_routes); - self - } - pub fn current_model_profile( mut self, current_model_profile: @@ -750,11 +776,8 @@ impl AgentBuilder { memory_session_id: self.memory_session_id, history: Vec::new(), classification_config: self.classification_config.unwrap_or_default(), - available_hints: self.available_hints.unwrap_or_default(), - route_model_by_hint: self.route_model_by_hint.unwrap_or_default(), route_model_preset: self.route_model_preset.unwrap_or_default(), route_model_lanes: self.route_model_lanes.unwrap_or_default(), - route_model_routes: self.route_model_routes.unwrap_or_default(), current_model_profile: self.current_model_profile.unwrap_or_default(), last_turn_usage: None, last_turn_tool_facts: Vec::new(), @@ -990,6 +1013,7 @@ impl Agent { decision: &CandidateAdmissionDecision, outcome: RuntimeCalibrationOutcome, observed_at_unix: i64, + effective_provider: &str, effective_model: &str, ) { if decision.snapshot.action == TurnAdmissionAction::Block { @@ -998,12 +1022,12 @@ impl Agent { self.record_runtime_calibration_observation(RuntimeCalibrationObservation { decision_kind: RuntimeCalibrationDecisionKind::RouteChoice, decision_signature: route_calibration_signature( - &self.provider_name, + effective_provider, effective_model, decision, ), suppression_key: Some(RuntimeCalibrationSuppressionKey::Route { - provider: self.provider_name.clone(), + provider: effective_provider.to_string(), model: effective_model.to_string(), }), confidence_basis_points: route_calibration_confidence(decision), @@ -1289,13 +1313,14 @@ impl Agent { let provider_runtime_options = synapse_providers::provider_runtime_options_from_config(config); + let provider_router_routes = build_provider_router_routes(config); let provider: Box = synapse_providers::create_routed_provider_with_options( provider_name, config.api_key.as_deref(), config.api_url.as_deref(), &config.reliability, - &config.model_routes, + &provider_router_routes, &model_name, &provider_runtime_options, )?; @@ -1308,7 +1333,7 @@ impl Agent { config.api_key.as_deref(), config.api_url.as_deref(), &config.reliability, - &config.model_routes, + &provider_router_routes, &model_name, &provider_runtime_options, )?); @@ -1339,12 +1364,6 @@ impl Agent { } }; - let route_model_by_hint: HashMap = config - .model_routes - .iter() - .map(|route| (route.hint.clone(), route.model.clone())) - .collect(); - let available_hints: Vec = route_model_by_hint.keys().cloned().collect(); let profile_catalog = crate::runtime_routes::WorkspaceModelProfileCatalog::from_config(config); let current_model_profile = @@ -1453,11 +1472,8 @@ impl Agent { .temperature(config.default_temperature) .workspace_dir(config.workspace_dir.clone()) .classification_config(config.query_classification.clone()) - .available_hints(available_hints) - .route_model_by_hint(route_model_by_hint) .route_model_preset(config.model_preset.clone()) .route_model_lanes(config.model_lanes.clone()) - .route_model_routes(config.model_routes.clone()) .current_model_profile(current_model_profile) .identity_config(config.identity.clone()) .conversation_store(runtime_ports.conversation_store) @@ -2033,28 +2049,49 @@ impl Agent { .collect() } - fn classify_model(&self, user_message: &str) -> String { + fn classify_model(&self, user_message: &str) -> AgentModelSelection { if let Some(decision) = super::classifier::classify_with_decision(&self.classification_config, user_message) { - if self.available_hints.contains(&decision.hint) { - let resolved_model = self - .route_model_by_hint - .get(&decision.hint) - .map(String::as_str) - .unwrap_or("unknown"); + let routing_config = self.build_turn_routing_config(); + if let Some(route) = + synapse_domain::application::services::inbound_message_service::resolve_model_command_route( + &decision.hint, + &routing_config, + ) + { tracing::info!( target: "query_classification", hint = decision.hint.as_str(), - model = resolved_model, + provider = route.provider.as_str(), + model = route.model.as_str(), + lane = route.lane.map(|lane| lane.as_str()).unwrap_or("none"), + candidate_index = route + .candidate_index + .map(|index| index.to_string()) + .unwrap_or_else(|| "none".to_string()), rule_priority = decision.priority, message_length = user_message.len(), "Classified message route" ); - return format!("hint:{}", decision.hint); + return AgentModelSelection { + provider: route.provider, + actual_model: route.model, + provider_model_argument: format!("hint:{}", decision.hint), + lane: route.lane, + candidate_index: route.candidate_index, + hint: Some(decision.hint), + }; } } - self.model_name.clone() + AgentModelSelection { + provider: self.provider_name.clone(), + actual_model: self.model_name.clone(), + provider_model_argument: self.model_name.clone(), + lane: self.active_lane, + candidate_index: self.active_candidate_index, + hint: None, + } } fn build_turn_routing_config(&self) -> Config { @@ -2063,7 +2100,6 @@ impl Agent { config.default_model = Some(self.model_name.clone()); config.model_preset = self.route_model_preset.clone(); config.model_lanes = self.route_model_lanes.clone(); - config.model_routes = self.route_model_routes.clone(); config.compression = self.compression.clone(); config.compression_overrides = self.compression_overrides.clone(); config @@ -2282,59 +2318,83 @@ impl Agent { let ephemeral_prefix = formatted.enrichment_prefix; self.turn_count += 1; - let mut effective_model = self.classify_model(user_message); + let classified_model = self.classify_model(user_message); + let mut provider_model_argument = classified_model.provider_model_argument; + let mut effective_provider = classified_model.provider; + let mut effective_model = classified_model.actual_model; let mut effective_model_profile = self.current_model_profile.clone(); - let mut effective_lane = None; - let mut effective_candidate_index = None; - if !effective_model.starts_with("hint:") { - let routing_config = self.build_turn_routing_config(); - let profile_catalog = - crate::runtime_routes::WorkspaceModelProfileCatalog::with_provider_endpoint( - self.workspace_dir.clone(), - Some(&self.provider_name), - self.provider_api_url.as_deref(), + let mut effective_lane = classified_model.lane; + let mut effective_candidate_index = classified_model.candidate_index; + let mut effective_hint = classified_model.hint; + let profile_catalog = + crate::runtime_routes::WorkspaceModelProfileCatalog::with_provider_endpoint( + self.workspace_dir.clone(), + Some(&effective_provider), + self.provider_api_url.as_deref(), + ); + if effective_provider != self.provider_name || effective_model != self.model_name { + effective_model_profile = + synapse_domain::application::services::model_lane_resolution::resolve_candidate_profile( + &effective_provider, + &effective_model, + &synapse_domain::config::schema::ModelCandidateProfileConfig::default(), + Some(&profile_catalog), ); + } + if effective_hint.is_none() { + let routing_config = self.build_turn_routing_config(); if let Some(route_override) = resolve_turn_route_override( &routing_config, user_message, - &self.provider_name, + &effective_provider, &effective_model, &effective_model_profile, self.provider.supports_vision(), Some(&profile_catalog), ) { - if route_override.provider == self.provider_name { - effective_lane = Some(route_override.lane); - effective_candidate_index = route_override.candidate_index; - effective_model = route_override.model; - effective_model_profile = synapse_domain::application::services::model_lane_resolution::resolve_lane_candidates( + effective_provider = route_override.provider; + effective_lane = Some(route_override.lane); + effective_candidate_index = route_override.candidate_index; + effective_model = route_override.model; + provider_model_argument = if effective_provider == self.provider_name { + effective_model.clone() + } else { + format!("hint:{}", route_override.lane.as_str()) + }; + effective_hint = (effective_provider != self.provider_name) + .then(|| route_override.lane.as_str().to_string()); + let route_profile_catalog = + crate::runtime_routes::WorkspaceModelProfileCatalog::with_provider_endpoint( + self.workspace_dir.clone(), + Some(&effective_provider), + self.provider_api_url.as_deref(), + ); + effective_model_profile = + synapse_domain::application::services::model_lane_resolution::resolve_lane_candidates( &routing_config, route_override.lane, - Some(&profile_catalog), + Some(&route_profile_catalog), ) .into_iter() .find(|candidate| { - candidate.provider == self.provider_name - && candidate.model == effective_model + candidate.provider == effective_provider && candidate.model == effective_model }) .map(|candidate| candidate.profile) .unwrap_or_else(|| { synapse_domain::application::services::model_lane_resolution::resolve_candidate_profile( - &self.provider_name, + &effective_provider, &effective_model, &synapse_domain::config::schema::ModelCandidateProfileConfig::default(), - Some(&profile_catalog), + Some(&route_profile_catalog), ) }); - } } } - let effective_hint = effective_model.strip_prefix("hint:"); let effective_compression = self.history_compression_for_route( - &self.provider_name, + &effective_provider, &effective_model, effective_lane, - effective_hint, + effective_hint.as_deref(), ); let turn_tool_specs = synapse_domain::application::services::turn_tool_narrowing::prepare_tool_specs_for_turn( @@ -2564,7 +2624,7 @@ impl Agent { "turn admission blocked provider call intent={} pressure={} provider={} model={}\n{}", turn_intent_name(admission_decision.snapshot.intent), context_pressure_state_name(admission_decision.snapshot.pressure_state), - self.provider_name, + effective_provider, effective_model, handoff_packet_text ); @@ -2587,7 +2647,7 @@ impl Agent { request_context.total_messages = messages.len(); request_context.total_chars = total_message_chars(&messages); self.observer.record_event(&ObserverEvent::LlmRequest { - provider: self.provider_name.clone(), + provider: effective_provider.clone(), model: effective_model.clone(), messages_count: messages.len(), context: Some(request_context), @@ -2672,7 +2732,7 @@ impl Agent { }, ); return Err(ProviderCapabilityError { - provider: self.provider_name.clone(), + provider: effective_provider.clone(), capability: "vision".to_string(), message: format!( "received {image_marker_count} image marker(s), but this route does not support vision input" @@ -2694,7 +2754,7 @@ impl Agent { None }, }, - &effective_model, + &provider_model_argument, self.temperature, ) .await @@ -2704,6 +2764,7 @@ impl Agent { &admission_decision, RuntimeCalibrationOutcome::Succeeded, chrono::Utc::now().timestamp(), + &effective_provider, &effective_model, ); resp @@ -2723,18 +2784,18 @@ impl Agent { let profile_catalog = crate::runtime_routes::WorkspaceModelProfileCatalog::with_provider_endpoint( self.workspace_dir.clone(), - Some(&self.provider_name), + Some(&effective_provider), self.provider_api_url.as_deref(), ); if let Err(record_error) = profile_catalog .record_context_limit_observation( - &self.provider_name, + &effective_provider, &effective_model, observation, ) { tracing::debug!( - provider = self.provider_name, + provider = effective_provider, model = effective_model, error = %record_error, "Failed to record context-limit model profile observation" @@ -2746,6 +2807,7 @@ impl Agent { &admission_decision, RuntimeCalibrationOutcome::Failed, chrono::Utc::now().timestamp(), + &effective_provider, &effective_model, ); return Err(err); @@ -3495,8 +3557,6 @@ mod tests { let mem: Arc = Arc::new(synapse_memory::NoopUnifiedMemory); let observer: Arc = Arc::from(synapse_observability::NoopObserver {}); - let mut route_model_by_hint = HashMap::new(); - route_model_by_hint.insert("fast".to_string(), "anthropic/claude-haiku-4-5".to_string()); let mut agent = Agent::builder() .provider(provider) .tools(vec![Box::new(MockTool)]) @@ -3504,10 +3564,21 @@ mod tests { .observer(observer) .tool_dispatcher(Box::new(NativeToolDispatcher)) .workspace_dir(std::path::PathBuf::from("/tmp")) + .route_model_lanes(vec![ModelLaneConfig { + lane: CapabilityLane::CheapReasoning, + candidates: vec![ModelLaneCandidateConfig { + provider: "test-provider".into(), + model: "test-cheap-model".into(), + api_key: None, + api_key_env: None, + dimensions: None, + profile: ModelCandidateProfileConfig::default(), + }], + }]) .classification_config(synapse_domain::config::schema::QueryClassificationConfig { enabled: true, rules: vec![synapse_domain::config::schema::ClassificationRule { - hint: "fast".to_string(), + hint: "cheap_reasoning".to_string(), keywords: vec!["quick".to_string()], patterns: vec![], min_length: None, @@ -3515,15 +3586,13 @@ mod tests { priority: 10, }], }) - .available_hints(vec!["fast".to_string()]) - .route_model_by_hint(route_model_by_hint) .build() .expect("agent builder should succeed with valid config"); let response = agent.turn("quick summary please").await.unwrap(); assert_eq!(response, "classified"); let seen = seen_models.lock(); - assert_eq!(seen.as_slice(), &["hint:fast".to_string()]); + assert_eq!(seen.as_slice(), &["hint:cheap_reasoning".to_string()]); } #[tokio::test] diff --git a/docs/fork/ipc-phase4_10-audit.md b/docs/fork/ipc-phase4_10-audit.md index 9e65851a..5655600e 100644 --- a/docs/fork/ipc-phase4_10-audit.md +++ b/docs/fork/ipc-phase4_10-audit.md @@ -66,6 +66,9 @@ Our best move is: `model_routes`; scenario upserts now write the selected capability lane's ordered candidate list and classification rules resolve through the shared lane/catalog selector path. +- Live web/Agent query classification now follows the same lane/catalog + selector resolver instead of maintaining an Agent-local `model_routes` + hint/model map. - Remaining audit target: old config/API surfaces still expose `model_routes` as compatibility/catalog-alias data; future cleanup should either rename that data shape to catalog aliases or fully retire it. diff --git a/docs/fork/ipc-phase4_10-plan.md b/docs/fork/ipc-phase4_10-plan.md index a673b062..86792f3d 100644 --- a/docs/fork/ipc-phase4_10-plan.md +++ b/docs/fork/ipc-phase4_10-plan.md @@ -1273,6 +1273,9 @@ Expected outcome: so operator UX matches the runtime selector path - query-classifier route overrides now reuse the same lane/catalog selector resolver instead of scanning `model_routes` by hint + - live web/Agent query classification now uses that same lane/catalog selector + resolver and its provider router is seeded from effective lanes plus catalog + aliases, not from configured legacy `model_routes` - remaining work: - historical note: earlier remaining items about image/audio/video/music first-class turn routing are now mostly owned by Slice 14 and the shared From 205c83ab73921661d8c2e636525d468ece0ad7b4 Mon Sep 17 00:00:00 2001 From: panviktor <35385675+panviktor@users.noreply.github.com> Date: Sun, 12 Apr 2026 20:32:35 +0200 Subject: [PATCH 06/30] Share provider router route aliases --- crates/adapters/core/src/agent/agent.rs | 52 ++------------ .../core/src/agent/runtime_loop/cli_run.rs | 12 +++- .../services/model_preset_resolution.rs | 72 ++++++++++++++++++- docs/fork/ipc-phase4_10-audit.md | 3 + docs/fork/ipc-phase4_10-plan.md | 3 + 5 files changed, 92 insertions(+), 50 deletions(-) diff --git a/crates/adapters/core/src/agent/agent.rs b/crates/adapters/core/src/agent/agent.rs index 1f70573a..608bab5f 100644 --- a/crates/adapters/core/src/agent/agent.rs +++ b/crates/adapters/core/src/agent/agent.rs @@ -27,7 +27,7 @@ use synapse_domain::application::services::model_capability_support::{ #[cfg(test)] use synapse_domain::application::services::model_lane_resolution::ResolvedModelProfile; use synapse_domain::application::services::model_lane_resolution::ResolvedModelProfileConfidence; -use synapse_domain::application::services::model_preset_resolution::resolve_effective_model_lanes; +use synapse_domain::application::services::model_preset_resolution::provider_router_routes; use synapse_domain::application::services::provider_context_budget::{ assess_provider_context_budget, provider_context_artifact_name, provider_context_budget_tier_name, provider_context_condensation_mode_name, @@ -77,9 +77,7 @@ use synapse_domain::application::services::turn_context::{ }; use synapse_domain::application::services::turn_interpretation; use synapse_domain::application::services::turn_model_routing::resolve_turn_route_override; -use synapse_domain::config::schema::{ - CapabilityLane, Config, ContextCompressionConfig, ModelRouteConfig, -}; +use synapse_domain::config::schema::{CapabilityLane, Config, ContextCompressionConfig}; use synapse_domain::domain::tool_fact::TypedToolFact; use synapse_domain::domain::tool_repair::{tool_failure_kind_name, ToolRepairTrace}; use synapse_domain::domain::turn_admission::{ @@ -136,46 +134,6 @@ pub struct AgentRuntimePorts { pub history_compaction_cache: Option>, } -fn build_provider_router_routes(config: &Config) -> Vec { - let mut routes = Vec::new(); - let mut seen_hints = HashSet::new(); - - for lane in resolve_effective_model_lanes(config) { - if let Some(candidate) = lane.candidates.first() { - push_provider_router_route( - &mut routes, - &mut seen_hints, - ModelRouteConfig { - hint: lane.lane.as_str().to_string(), - capability: Some(lane.lane), - provider: candidate.provider.clone(), - model: candidate.model.clone(), - api_key: candidate.api_key.clone(), - profile: candidate.profile.clone(), - }, - ); - } - } - - for alias in synapse_domain::config::model_catalog::model_route_aliases() { - push_provider_router_route(&mut routes, &mut seen_hints, alias); - } - - routes -} - -fn push_provider_router_route( - routes: &mut Vec, - seen_hints: &mut HashSet, - route: ModelRouteConfig, -) { - let hint = route.hint.trim(); - if hint.is_empty() || !seen_hints.insert(hint.to_ascii_lowercase()) { - return; - } - routes.push(route); -} - fn canonicalize_tool_args(value: &serde_json::Value) -> serde_json::Value { match value { serde_json::Value::Object(map) => { @@ -1313,14 +1271,14 @@ impl Agent { let provider_runtime_options = synapse_providers::provider_runtime_options_from_config(config); - let provider_router_routes = build_provider_router_routes(config); + let router_routes = provider_router_routes(config); let provider: Box = synapse_providers::create_routed_provider_with_options( provider_name, config.api_key.as_deref(), config.api_url.as_deref(), &config.reliability, - &provider_router_routes, + &router_routes, &model_name, &provider_runtime_options, )?; @@ -1333,7 +1291,7 @@ impl Agent { config.api_key.as_deref(), config.api_url.as_deref(), &config.reliability, - &provider_router_routes, + &router_routes, &model_name, &provider_runtime_options, )?); diff --git a/crates/adapters/core/src/agent/runtime_loop/cli_run.rs b/crates/adapters/core/src/agent/runtime_loop/cli_run.rs index 083edfcc..4eff8a26 100644 --- a/crates/adapters/core/src/agent/runtime_loop/cli_run.rs +++ b/crates/adapters/core/src/agent/runtime_loop/cli_run.rs @@ -283,13 +283,17 @@ pub async fn run_with_shared_memory( }); let provider_runtime_options = synapse_providers::provider_runtime_options_from_config(&config); + let router_routes = + synapse_domain::application::services::model_preset_resolution::provider_router_routes( + &config, + ); let provider: Box = synapse_providers::create_routed_provider_with_options( provider_name, config.api_key.as_deref(), config.api_url.as_deref(), &config.reliability, - &config.model_routes, + &router_routes, model_name, &provider_runtime_options, )?; @@ -846,12 +850,16 @@ pub async fn process_message( .to_string() }); let provider_runtime_options = synapse_providers::provider_runtime_options_from_config(&config); + let router_routes = + synapse_domain::application::services::model_preset_resolution::provider_router_routes( + &config, + ); let provider: Box = synapse_providers::create_routed_provider_with_options( provider_name, config.api_key.as_deref(), config.api_url.as_deref(), &config.reliability, - &config.model_routes, + &router_routes, &model_name, &provider_runtime_options, )?; diff --git a/crates/domain/src/application/services/model_preset_resolution.rs b/crates/domain/src/application/services/model_preset_resolution.rs index 82b87e60..0777fece 100644 --- a/crates/domain/src/application/services/model_preset_resolution.rs +++ b/crates/domain/src/application/services/model_preset_resolution.rs @@ -1,5 +1,8 @@ +use std::collections::HashSet; + use crate::config::model_catalog::{ apply_default_api_key, known_model_presets as catalog_known_model_presets, + model_route_aliases as catalog_model_route_aliases, normalize_model_preset_id as catalog_normalize_model_preset_id, preset_description as catalog_preset_description, preset_extra_lanes, preset_reasoning_seed as catalog_preset_reasoning_seed, preset_seed_multimodal_from_reasoning, @@ -8,7 +11,7 @@ use crate::config::model_catalog::{ }; use crate::config::schema::{ CapabilityLane, Config, ModelCandidateProfileConfig, ModelFeature, ModelLaneCandidateConfig, - ModelLaneConfig, + ModelLaneConfig, ModelRouteConfig, }; pub fn known_model_presets() -> &'static [KnownModelPreset] { @@ -56,6 +59,46 @@ pub fn resolve_effective_model_lanes(config: &Config) -> Vec { lanes } +pub fn provider_router_routes(config: &Config) -> Vec { + let mut routes = Vec::new(); + let mut seen_hints = HashSet::new(); + + for lane in resolve_effective_model_lanes(config) { + if let Some(candidate) = lane.candidates.first() { + push_provider_router_route( + &mut routes, + &mut seen_hints, + ModelRouteConfig { + hint: lane.lane.as_str().to_string(), + capability: Some(lane.lane), + provider: candidate.provider.clone(), + model: candidate.model.clone(), + api_key: candidate.api_key.clone(), + profile: candidate.profile.clone(), + }, + ); + } + } + + for alias in catalog_model_route_aliases() { + push_provider_router_route(&mut routes, &mut seen_hints, alias); + } + + routes +} + +fn push_provider_router_route( + routes: &mut Vec, + seen_hints: &mut HashSet, + route: ModelRouteConfig, +) { + let hint = route.hint.trim(); + if hint.is_empty() || !seen_hints.insert(hint.to_ascii_lowercase()) { + return; + } + routes.push(route); +} + fn build_preset_model_lanes(config: &Config, preset: &str) -> Option> { let preset = normalize_model_preset_id(preset)?; let (default_reasoning_provider, default_reasoning_model) = @@ -147,6 +190,33 @@ mod tests { .any(|lane| lane.lane == CapabilityLane::Embedding)); } + #[test] + fn provider_router_routes_follow_effective_lanes_and_catalog_aliases() { + let mut config = Config::default(); + config.model_lanes = vec![ModelLaneConfig { + lane: CapabilityLane::CheapReasoning, + candidates: vec![ModelLaneCandidateConfig { + provider: "test-provider".into(), + model: "test-cheap-model".into(), + api_key: Some("test-key".into()), + api_key_env: None, + dimensions: None, + profile: ModelCandidateProfileConfig::default(), + }], + }]; + + let routes = provider_router_routes(&config); + + let cheap = routes + .iter() + .find(|route| route.hint == "cheap_reasoning") + .expect("cheap lane route should exist"); + assert_eq!(cheap.provider, "test-provider"); + assert_eq!(cheap.model, "test-cheap-model"); + assert_eq!(cheap.capability, Some(CapabilityLane::CheapReasoning)); + assert!(routes.iter().any(|route| route.hint == "qwen36")); + } + #[test] fn chatgpt_preset_seeds_multimodal_lane_when_using_default_reasoning_seed() { let mut config = Config::default(); diff --git a/docs/fork/ipc-phase4_10-audit.md b/docs/fork/ipc-phase4_10-audit.md index 5655600e..b70fe8cd 100644 --- a/docs/fork/ipc-phase4_10-audit.md +++ b/docs/fork/ipc-phase4_10-audit.md @@ -69,6 +69,9 @@ Our best move is: - Live web/Agent query classification now follows the same lane/catalog selector resolver instead of maintaining an Agent-local `model_routes` hint/model map. +- Live Agent and CLI provider-router alias tables are now derived from the + shared effective-lane/catalog helper instead of passing configured + `model_routes` directly into the router. - Remaining audit target: old config/API surfaces still expose `model_routes` as compatibility/catalog-alias data; future cleanup should either rename that data shape to catalog aliases or fully retire it. diff --git a/docs/fork/ipc-phase4_10-plan.md b/docs/fork/ipc-phase4_10-plan.md index 86792f3d..1b46fe22 100644 --- a/docs/fork/ipc-phase4_10-plan.md +++ b/docs/fork/ipc-phase4_10-plan.md @@ -1276,6 +1276,9 @@ Expected outcome: - live web/Agent query classification now uses that same lane/catalog selector resolver and its provider router is seeded from effective lanes plus catalog aliases, not from configured legacy `model_routes` + - the effective-lane/catalog provider-router alias table is now a shared + domain helper consumed by live Agent and CLI runtime, avoiding a second + legacy `model_routes` route table in adapter code - remaining work: - historical note: earlier remaining items about image/audio/video/music first-class turn routing are now mostly owned by Slice 14 and the shared From 53f409802fc4292b3d47a6814e487922ea6febe0 Mon Sep 17 00:00:00 2001 From: panviktor <35385675+panviktor@users.noreply.github.com> Date: Sun, 12 Apr 2026 20:36:42 +0200 Subject: [PATCH 07/30] Drop legacy routes from channel turn path --- crates/adapters/core/src/channels/mod.rs | 10 ---------- .../application/use_cases/handle_inbound_message.rs | 4 ---- docs/fork/ipc-phase4_10-audit.md | 2 ++ docs/fork/ipc-phase4_10-plan.md | 3 +++ 4 files changed, 5 insertions(+), 14 deletions(-) diff --git a/crates/adapters/core/src/channels/mod.rs b/crates/adapters/core/src/channels/mod.rs index 14844764..1c7486ed 100644 --- a/crates/adapters/core/src/channels/mod.rs +++ b/crates/adapters/core/src/channels/mod.rs @@ -180,7 +180,6 @@ struct ChannelRuntimeContext { hooks: Option>, non_cli_excluded_tools: Arc>, tool_call_dedup_exempt: Arc>, - model_routes: Arc>, model_lanes: Arc>, model_preset: Option, query_classification: synapse_domain::config::schema::QueryClassificationConfig, @@ -483,7 +482,6 @@ fn channel_runtime_config_snapshot(ctx: &ChannelRuntimeContext) -> Config { config.workspace_dir = ctx.workspace_dir.as_ref().clone(); config.default_provider = Some(ctx.default_provider.as_ref().clone()); config.default_model = Some(ctx.model.as_ref().clone()); - config.model_routes = ctx.model_routes.as_ref().clone(); config.model_lanes = ctx.model_lanes.as_ref().clone(); config.model_preset = ctx.model_preset.clone(); config @@ -642,7 +640,6 @@ async fn summarize_channel_session_if_needed(ctx: &ChannelRuntimeContext, histor let mut summary_config = synapse_domain::config::schema::Config::default(); summary_config.summary = ctx.summary_config.as_ref().clone(); summary_config.summary_model = ctx.summary_model.clone(); - summary_config.model_routes = ctx.model_routes.as_ref().clone(); summary_config.model_lanes = ctx.model_lanes.as_ref().clone(); let summary_route = resolve_summary_route(&summary_config, &ctx.model); @@ -974,7 +971,6 @@ async fn handle_message_via_orchestrator( temperature: ctx.temperature, max_tool_iterations: ctx.max_tool_iterations, auto_save_memory: ctx.auto_save_memory, - model_routes: ctx.model_routes.as_ref().clone(), model_lanes: ctx.model_lanes.as_ref().clone(), model_preset: ctx.model_preset.clone(), thread_root_max_chars: 500, @@ -2953,7 +2949,6 @@ pub async fn start_channels( }, non_cli_excluded_tools: Arc::new(config.autonomy.non_cli_excluded_tools.clone()), tool_call_dedup_exempt: Arc::new(config.agent.tool_call_dedup_exempt.clone()), - model_routes: Arc::new(config.model_routes.clone()), model_lanes: Arc::new(config.model_lanes.clone()), model_preset: config.model_preset.clone(), query_classification: config.query_classification.clone(), @@ -3316,7 +3311,6 @@ mod tests { hooks: None, non_cli_excluded_tools: Arc::new(Vec::new()), tool_call_dedup_exempt: Arc::new(Vec::new()), - model_routes: Arc::new(Vec::new()), model_lanes: Arc::new(Vec::new()), model_preset: None, query_classification: @@ -3828,7 +3822,6 @@ mod tests { hooks: None, non_cli_excluded_tools: Arc::new(Vec::new()), tool_call_dedup_exempt: Arc::new(Vec::new()), - model_routes: Arc::new(Vec::new()), model_lanes: Arc::new(Vec::new()), model_preset: None, query_classification: @@ -3944,7 +3937,6 @@ mod tests { hooks: None, non_cli_excluded_tools: Arc::new(Vec::new()), tool_call_dedup_exempt: Arc::new(Vec::new()), - model_routes: Arc::new(Vec::new()), model_lanes: Arc::new(Vec::new()), model_preset: None, query_classification: @@ -4092,7 +4084,6 @@ mod tests { hooks: None, non_cli_excluded_tools: Arc::new(Vec::new()), tool_call_dedup_exempt: Arc::new(Vec::new()), - model_routes: Arc::new(Vec::new()), model_lanes: Arc::new(Vec::new()), model_preset: None, approval_manager: Arc::new(ApprovalManager::for_non_interactive( @@ -4203,7 +4194,6 @@ mod tests { hooks: None, non_cli_excluded_tools: Arc::new(Vec::new()), tool_call_dedup_exempt: Arc::new(Vec::new()), - model_routes: Arc::new(Vec::new()), model_lanes: Arc::new(Vec::new()), model_preset: None, query_classification: diff --git a/crates/domain/src/application/use_cases/handle_inbound_message.rs b/crates/domain/src/application/use_cases/handle_inbound_message.rs index 17b0d1aa..eb3640fb 100644 --- a/crates/domain/src/application/use_cases/handle_inbound_message.rs +++ b/crates/domain/src/application/use_cases/handle_inbound_message.rs @@ -37,7 +37,6 @@ use crate::application::services::turn_interpretation::TurnInterpretation; use crate::application::services::turn_markup::{ contains_image_attachment_marker, strip_image_attachment_markers, }; -use crate::config::schema::ModelRouteConfig; use crate::domain::channel::{ChannelCapability, InboundEnvelope, SourceKind}; use crate::domain::message::ChatMessage; use crate::ports::agent_runtime::{AgentRuntimeErrorKind, AgentRuntimePort}; @@ -70,7 +69,6 @@ pub struct InboundMessageConfig { pub temperature: f64, pub max_tool_iterations: usize, pub auto_save_memory: bool, - pub model_routes: Vec, pub model_lanes: Vec, pub model_preset: Option, pub thread_root_max_chars: usize, @@ -626,7 +624,6 @@ fn build_model_routing_config(config: &InboundMessageConfig) -> crate::config::s routing.default_model = Some(config.default_model.clone()); routing.model_preset = config.model_preset.clone(); routing.model_lanes = config.model_lanes.clone(); - routing.model_routes = config.model_routes.iter().cloned().collect(); routing } @@ -1704,7 +1701,6 @@ mod tests { temperature: 0.7, max_tool_iterations: 5, auto_save_memory: false, - model_routes: vec![], model_lanes: vec![], model_preset: None, thread_root_max_chars: 500, diff --git a/docs/fork/ipc-phase4_10-audit.md b/docs/fork/ipc-phase4_10-audit.md index b70fe8cd..88d6eb18 100644 --- a/docs/fork/ipc-phase4_10-audit.md +++ b/docs/fork/ipc-phase4_10-audit.md @@ -72,6 +72,8 @@ Our best move is: - Live Agent and CLI provider-router alias tables are now derived from the shared effective-lane/catalog helper instead of passing configured `model_routes` directly into the router. +- Channel inbound/use-case route snapshots no longer carry `model_routes` + through turn execution; they now pass lane/preset state only. - Remaining audit target: old config/API surfaces still expose `model_routes` as compatibility/catalog-alias data; future cleanup should either rename that data shape to catalog aliases or fully retire it. diff --git a/docs/fork/ipc-phase4_10-plan.md b/docs/fork/ipc-phase4_10-plan.md index 1b46fe22..16ed2049 100644 --- a/docs/fork/ipc-phase4_10-plan.md +++ b/docs/fork/ipc-phase4_10-plan.md @@ -1279,6 +1279,9 @@ Expected outcome: - the effective-lane/catalog provider-router alias table is now a shared domain helper consumed by live Agent and CLI runtime, avoiding a second legacy `model_routes` route table in adapter code + - channel inbound/use-case runtime snapshots no longer carry `model_routes` + through the turn path; they pass only model lanes/preset into shared route + resolution - remaining work: - historical note: earlier remaining items about image/audio/video/music first-class turn routing are now mostly owned by Slice 14 and the shared From 28654ebf3f1b9c7259fa365050406e27333f1364 Mon Sep 17 00:00:00 2001 From: panviktor <35385675+panviktor@users.noreply.github.com> Date: Sun, 12 Apr 2026 20:39:50 +0200 Subject: [PATCH 08/30] Add candidate selectors to router aliases --- crates/adapters/core/src/agent/agent.rs | 11 +++++++- .../services/model_preset_resolution.rs | 25 +++++++++++++++++-- docs/fork/ipc-phase4_10-audit.md | 2 ++ docs/fork/ipc-phase4_10-plan.md | 3 +++ 4 files changed, 38 insertions(+), 3 deletions(-) diff --git a/crates/adapters/core/src/agent/agent.rs b/crates/adapters/core/src/agent/agent.rs index 608bab5f..49bd2cbf 100644 --- a/crates/adapters/core/src/agent/agent.rs +++ b/crates/adapters/core/src/agent/agent.rs @@ -2018,6 +2018,15 @@ impl Agent { &routing_config, ) { + let provider_model_argument = if route.provider == self.provider_name { + route.model.clone() + } else if route.candidate_index == Some(0) { + route.lane + .map(|lane| format!("hint:{}", lane.as_str())) + .unwrap_or_else(|| format!("hint:{}", decision.hint)) + } else { + format!("hint:{}:{}", route.provider, route.model) + }; tracing::info!( target: "query_classification", hint = decision.hint.as_str(), @@ -2035,7 +2044,7 @@ impl Agent { return AgentModelSelection { provider: route.provider, actual_model: route.model, - provider_model_argument: format!("hint:{}", decision.hint), + provider_model_argument, lane: route.lane, candidate_index: route.candidate_index, hint: Some(decision.hint), diff --git a/crates/domain/src/application/services/model_preset_resolution.rs b/crates/domain/src/application/services/model_preset_resolution.rs index 0777fece..52655574 100644 --- a/crates/domain/src/application/services/model_preset_resolution.rs +++ b/crates/domain/src/application/services/model_preset_resolution.rs @@ -64,12 +64,26 @@ pub fn provider_router_routes(config: &Config) -> Vec { let mut seen_hints = HashSet::new(); for lane in resolve_effective_model_lanes(config) { - if let Some(candidate) = lane.candidates.first() { + for (index, candidate) in lane.candidates.iter().enumerate() { + if index == 0 { + push_provider_router_route( + &mut routes, + &mut seen_hints, + ModelRouteConfig { + hint: lane.lane.as_str().to_string(), + capability: Some(lane.lane), + provider: candidate.provider.clone(), + model: candidate.model.clone(), + api_key: candidate.api_key.clone(), + profile: candidate.profile.clone(), + }, + ); + } push_provider_router_route( &mut routes, &mut seen_hints, ModelRouteConfig { - hint: lane.lane.as_str().to_string(), + hint: provider_model_selector(&candidate.provider, &candidate.model), capability: Some(lane.lane), provider: candidate.provider.clone(), model: candidate.model.clone(), @@ -99,6 +113,10 @@ fn push_provider_router_route( routes.push(route); } +fn provider_model_selector(provider: &str, model: &str) -> String { + format!("{}:{}", provider.trim(), model.trim()) +} + fn build_preset_model_lanes(config: &Config, preset: &str) -> Option> { let preset = normalize_model_preset_id(preset)?; let (default_reasoning_provider, default_reasoning_model) = @@ -214,6 +232,9 @@ mod tests { assert_eq!(cheap.provider, "test-provider"); assert_eq!(cheap.model, "test-cheap-model"); assert_eq!(cheap.capability, Some(CapabilityLane::CheapReasoning)); + assert!(routes + .iter() + .any(|route| route.hint == "test-provider:test-cheap-model")); assert!(routes.iter().any(|route| route.hint == "qwen36")); } diff --git a/docs/fork/ipc-phase4_10-audit.md b/docs/fork/ipc-phase4_10-audit.md index 88d6eb18..71858ece 100644 --- a/docs/fork/ipc-phase4_10-audit.md +++ b/docs/fork/ipc-phase4_10-audit.md @@ -72,6 +72,8 @@ Our best move is: - Live Agent and CLI provider-router alias tables are now derived from the shared effective-lane/catalog helper instead of passing configured `model_routes` directly into the router. +- Provider-router aliases now include typed `provider:model` keys for every + lane candidate, preventing selector/router drift for non-primary candidates. - Channel inbound/use-case route snapshots no longer carry `model_routes` through turn execution; they now pass lane/preset state only. - Remaining audit target: old config/API surfaces still expose `model_routes` diff --git a/docs/fork/ipc-phase4_10-plan.md b/docs/fork/ipc-phase4_10-plan.md index 16ed2049..7bab4aa2 100644 --- a/docs/fork/ipc-phase4_10-plan.md +++ b/docs/fork/ipc-phase4_10-plan.md @@ -1279,6 +1279,9 @@ Expected outcome: - the effective-lane/catalog provider-router alias table is now a shared domain helper consumed by live Agent and CLI runtime, avoiding a second legacy `model_routes` route table in adapter code + - provider-router aliases include typed `provider:model` keys for explicit + non-primary lane candidates, so selector resolution and router dispatch do + not diverge on fallback candidates - channel inbound/use-case runtime snapshots no longer carry `model_routes` through the turn path; they pass only model lanes/preset into shared route resolution From 0ec5a14e194940b4fc951a5fa8102345c1438c5b Mon Sep 17 00:00:00 2001 From: panviktor <35385675+panviktor@users.noreply.github.com> Date: Sun, 12 Apr 2026 20:55:11 +0200 Subject: [PATCH 09/30] Rename model route aliases --- crates/adapters/core/src/channels/mod.rs | 2 +- crates/adapters/core/src/doctor/mod.rs | 18 +++++---- crates/adapters/core/src/gateway/api.rs | 40 +++++++++---------- crates/adapters/core/src/gateway/ws.rs | 2 +- crates/adapters/core/src/runtime_routes.rs | 2 +- crates/adapters/infra/src/config_io.rs | 10 ++--- crates/adapters/onboard/src/wizard.rs | 4 +- crates/adapters/providers/src/lib.rs | 16 ++++---- .../tools/src/model_routing_config.rs | 2 +- .../services/inbound_message_service.rs | 24 +++++------ .../services/model_preset_resolution.rs | 6 +-- crates/domain/src/config/model_catalog.rs | 14 +++---- crates/domain/src/config/schema.rs | 36 ++++++++--------- docs/fork/ipc-phase4_10-audit.md | 18 ++++----- docs/fork/ipc-phase4_10-plan.md | 23 ++++++----- docs/reference/api/config-reference.md | 30 +++++++------- docs/reference/api/providers-reference.md | 8 ++-- docs/reference/cli/commands-reference.md | 2 +- 18 files changed, 128 insertions(+), 129 deletions(-) diff --git a/crates/adapters/core/src/channels/mod.rs b/crates/adapters/core/src/channels/mod.rs index 1c7486ed..0b188015 100644 --- a/crates/adapters/core/src/channels/mod.rs +++ b/crates/adapters/core/src/channels/mod.rs @@ -1301,7 +1301,7 @@ impl RuntimeCommandHost for ChannelRuntimeCommandHost<'_> { let provider = request.provider.unwrap_or_else(|| self.fallback_provider()); let model = request .model - .ok_or_else(|| anyhow::anyhow!("model route mutation request missing model"))?; + .ok_or_else(|| anyhow::anyhow!("runtime route mutation request missing model"))?; let mut route = get_route_selection(self.ctx, self.conversation_key); route.provider = provider.clone(); route.model = model; diff --git a/crates/adapters/core/src/doctor/mod.rs b/crates/adapters/core/src/doctor/mod.rs index b5bf7512..1ef9bc03 100644 --- a/crates/adapters/core/src/doctor/mod.rs +++ b/crates/adapters/core/src/doctor/mod.rs @@ -511,16 +511,16 @@ fn check_config_semantics(config: &Config, items: &mut Vec) { } } - // Model routes validation - for route in &config.model_routes { + // Route aliases validation + for route in &config.route_aliases { if route.hint.is_empty() { - items.push(DiagItem::warn(cat, "model route with empty hint")); + items.push(DiagItem::warn(cat, "route alias with empty hint")); } if let Some(reason) = provider_validation_error(&route.provider) { items.push(DiagItem::warn( cat, format!( - "model route \"{}\" uses invalid provider \"{}\": {}", + "route alias \"{}\" uses invalid provider \"{}\": {}", route.hint, route.provider, reason ), )); @@ -528,7 +528,7 @@ fn check_config_semantics(config: &Config, items: &mut Vec) { if route.model.is_empty() { items.push(DiagItem::warn( cat, - format!("model route \"{}\" has empty model", route.hint), + format!("route alias \"{}\" has empty model", route.hint), )); } } @@ -1159,9 +1159,9 @@ mod tests { } #[test] - fn config_validation_warns_empty_model_route() { + fn config_validation_warns_empty_route_alias() { let mut config = Config::default(); - config.model_routes = vec![synapse_domain::config::schema::ModelRouteConfig { + config.route_aliases = vec![synapse_domain::config::schema::ModelRouteConfig { hint: "fast".into(), provider: "groq".into(), model: String::new(), @@ -1171,7 +1171,9 @@ mod tests { }]; let mut items = Vec::new(); check_config_semantics(&config, &mut items); - let route_item = items.iter().find(|i| i.message.contains("empty model")); + let route_item = items + .iter() + .find(|i| i.message.contains("route alias") && i.message.contains("empty model")); assert!(route_item.is_some()); assert_eq!(route_item.unwrap().severity, Severity::Warn); } diff --git a/crates/adapters/core/src/gateway/api.rs b/crates/adapters/core/src/gateway/api.rs index d80749a8..60136bc7 100644 --- a/crates/adapters/core/src/gateway/api.rs +++ b/crates/adapters/core/src/gateway/api.rs @@ -3438,7 +3438,7 @@ fn normalize_route_field(value: &str) -> String { value.trim().to_ascii_lowercase() } -fn model_route_identity_matches( +fn route_alias_identity_matches( incoming: &synapse_domain::config::schema::ModelRouteConfig, current: &synapse_domain::config::schema::ModelRouteConfig, ) -> bool { @@ -3447,7 +3447,7 @@ fn model_route_identity_matches( && normalize_route_field(&incoming.model) == normalize_route_field(¤t.model) } -fn model_route_provider_model_matches( +fn route_alias_provider_model_matches( incoming: &synapse_domain::config::schema::ModelRouteConfig, current: &synapse_domain::config::schema::ModelRouteConfig, ) -> bool { @@ -3472,7 +3472,7 @@ fn embedding_route_provider_model_matches( && normalize_route_field(&incoming.model) == normalize_route_field(¤t.model) } -fn restore_model_route_api_keys( +fn restore_route_alias_api_keys( incoming: &mut [synapse_domain::config::schema::ModelRouteConfig], current: &[synapse_domain::config::schema::ModelRouteConfig], ) { @@ -3490,7 +3490,7 @@ fn restore_model_route_api_keys( .iter() .enumerate() .find(|(idx, current_route)| { - !used_current[*idx] && model_route_identity_matches(incoming_route, current_route) + !used_current[*idx] && route_alias_identity_matches(incoming_route, current_route) }) .map(|(idx, _)| idx); @@ -3500,7 +3500,7 @@ fn restore_model_route_api_keys( .enumerate() .find(|(idx, current_route)| { !used_current[*idx] - && model_route_provider_model_matches(incoming_route, current_route) + && route_alias_provider_model_matches(incoming_route, current_route) }) .map(|(idx, _)| idx) }); @@ -3582,7 +3582,7 @@ fn mask_sensitive_fields( mask_optional_secret(&mut agent.api_key); } mask_optional_secret(&mut masked.agents_ipc.broker_token); - for route in &mut masked.model_routes { + for route in &mut masked.route_aliases { mask_optional_secret(&mut route.api_key); } for route in &mut masked.embedding_routes { @@ -3707,7 +3707,7 @@ fn restore_masked_sensitive_fields( restore_optional_secret(&mut agent.api_key, ¤t_agent.api_key); } } - restore_model_route_api_keys(&mut incoming.model_routes, ¤t.model_routes); + restore_route_alias_api_keys(&mut incoming.route_aliases, ¤t.route_aliases); restore_embedding_route_api_keys(&mut incoming.embedding_routes, ¤t.embedding_routes); if let (Some(incoming_ch), Some(current_ch)) = ( @@ -3898,7 +3898,7 @@ mod tests { allowed_senders: vec!["*".to_string()], default_subject: "SynapseClaw Message".to_string(), }); - cfg.model_routes = vec![synapse_domain::config::schema::ModelRouteConfig { + cfg.route_aliases = vec![synapse_domain::config::schema::ModelRouteConfig { hint: "reasoning".to_string(), provider: "openrouter".to_string(), model: "anthropic/claude-sonnet-4.6".to_string(), @@ -3968,7 +3968,7 @@ mod tests { ); assert_eq!( parsed - .model_routes + .route_aliases .first() .and_then(|v| v.api_key.as_deref()), Some(MASKED_SECRET) @@ -4034,7 +4034,7 @@ mod tests { allowed_senders: vec!["*".to_string()], default_subject: "SynapseClaw Message".to_string(), }); - current.model_routes = vec![ + current.route_aliases = vec![ synapse_domain::config::schema::ModelRouteConfig { hint: "reasoning".to_string(), provider: "openrouter".to_string(), @@ -4095,7 +4095,7 @@ mod tests { if let Some(email) = incoming.channels_config.email.as_mut() { email.password = MASKED_SECRET.to_string(); } - incoming.model_routes[1].api_key = Some("route-model-key-2-new".to_string()); + incoming.route_aliases[1].api_key = Some("route-model-key-2-new".to_string()); incoming.embedding_routes[1].api_key = Some("route-embed-key-2-new".to_string()); let hydrated = hydrate_config_for_save(incoming, ¤t); @@ -4161,11 +4161,11 @@ mod tests { Some("feishu-verify-new") ); assert_eq!( - hydrated.model_routes[0].api_key.as_deref(), + hydrated.route_aliases[0].api_key.as_deref(), Some("route-model-key-1") ); assert_eq!( - hydrated.model_routes[1].api_key.as_deref(), + hydrated.route_aliases[1].api_key.as_deref(), Some("route-model-key-2-new") ); assert_eq!( @@ -4189,7 +4189,7 @@ mod tests { #[test] fn hydrate_config_for_save_restores_route_keys_by_identity_and_clears_unmatched_masks() { let mut current = synapse_domain::config::schema::Config::default(); - current.model_routes = vec![ + current.route_aliases = vec![ synapse_domain::config::schema::ModelRouteConfig { hint: "reasoning".to_string(), provider: "openrouter".to_string(), @@ -4229,10 +4229,10 @@ mod tests { ]; let mut incoming = mask_sensitive_fields(¤t); - incoming.model_routes.swap(0, 1); + incoming.route_aliases.swap(0, 1); incoming.embedding_routes.swap(0, 1); incoming - .model_routes + .route_aliases .push(synapse_domain::config::schema::ModelRouteConfig { hint: "new".to_string(), provider: "openai".to_string(), @@ -4256,14 +4256,14 @@ mod tests { let hydrated = hydrate_config_for_save(incoming, ¤t); assert_eq!( - hydrated.model_routes[0].api_key.as_deref(), + hydrated.route_aliases[0].api_key.as_deref(), Some("route-model-key-2") ); assert_eq!( - hydrated.model_routes[1].api_key.as_deref(), + hydrated.route_aliases[1].api_key.as_deref(), Some("route-model-key-1") ); - assert_eq!(hydrated.model_routes[2].api_key, None); + assert_eq!(hydrated.route_aliases[2].api_key, None); assert_eq!( hydrated.embedding_routes[0].api_key.as_deref(), Some("route-embed-key-2") @@ -4274,7 +4274,7 @@ mod tests { ); assert_eq!(hydrated.embedding_routes[2].api_key, None); assert!(hydrated - .model_routes + .route_aliases .iter() .all(|route| route.api_key.as_deref() != Some(MASKED_SECRET))); assert!(hydrated diff --git a/crates/adapters/core/src/gateway/ws.rs b/crates/adapters/core/src/gateway/ws.rs index 50e620e5..81979951 100644 --- a/crates/adapters/core/src/gateway/ws.rs +++ b/crates/adapters/core/src/gateway/ws.rs @@ -1377,7 +1377,7 @@ impl RuntimeCommandHost for WebRuntimeCommandHost<'_> { let model = request .model .clone() - .ok_or_else(|| anyhow::anyhow!("model route mutation request missing model"))?; + .ok_or_else(|| anyhow::anyhow!("runtime route mutation request missing model"))?; let catalog = WorkspaceModelProfileCatalog::from_config(self.config); let route_profile = synapse_domain::application::services::model_lane_resolution::resolve_route_selection_profile( self.config, diff --git a/crates/adapters/core/src/runtime_routes.rs b/crates/adapters/core/src/runtime_routes.rs index 451a8d49..c2242541 100644 --- a/crates/adapters/core/src/runtime_routes.rs +++ b/crates/adapters/core/src/runtime_routes.rs @@ -457,7 +457,7 @@ pub(crate) fn build_models_help_response(current: &RouteSelection, config: &Conf } } - let catalog_aliases = synapse_domain::config::model_catalog::model_route_aliases(); + let catalog_aliases = synapse_domain::config::model_catalog::route_aliases(); if !catalog_aliases.is_empty() { response.push_str("\nCatalog model aliases:\n"); for route in catalog_aliases.iter().take(12) { diff --git a/crates/adapters/infra/src/config_io.rs b/crates/adapters/infra/src/config_io.rs index f9d84efc..511d0a68 100644 --- a/crates/adapters/infra/src/config_io.rs +++ b/crates/adapters/infra/src/config_io.rs @@ -1102,16 +1102,16 @@ impl ConfigIO for Config { anyhow::bail!("scheduler.max_tasks must be greater than 0"); } - // Model routes - for (i, route) in self.model_routes.iter().enumerate() { + // Route aliases + for (i, route) in self.route_aliases.iter().enumerate() { if route.hint.trim().is_empty() { - anyhow::bail!("model_routes[{i}].hint must not be empty"); + anyhow::bail!("route_aliases[{i}].hint must not be empty"); } if route.provider.trim().is_empty() { - anyhow::bail!("model_routes[{i}].provider must not be empty"); + anyhow::bail!("route_aliases[{i}].provider must not be empty"); } if route.model.trim().is_empty() { - anyhow::bail!("model_routes[{i}].model must not be empty"); + anyhow::bail!("route_aliases[{i}].model must not be empty"); } } diff --git a/crates/adapters/onboard/src/wizard.rs b/crates/adapters/onboard/src/wizard.rs index 77ef1d39..92290942 100644 --- a/crates/adapters/onboard/src/wizard.rs +++ b/crates/adapters/onboard/src/wizard.rs @@ -172,7 +172,7 @@ pub async fn run_wizard(force: bool) -> Result { scheduler: synapse_domain::config::schema::SchedulerConfig::default(), agent: synapse_domain::config::schema::AgentConfig::default(), skills: synapse_domain::config::schema::SkillsConfig::default(), - model_routes: Vec::new(), + route_aliases: Vec::new(), model_lanes: Vec::new(), model_preset, embedding_routes: Vec::new(), @@ -520,7 +520,7 @@ async fn run_quick_setup_with_home( scheduler: synapse_domain::config::schema::SchedulerConfig::default(), agent: synapse_domain::config::schema::AgentConfig::default(), skills: synapse_domain::config::schema::SkillsConfig::default(), - model_routes: Vec::new(), + route_aliases: Vec::new(), model_lanes: Vec::new(), model_preset: recommended_model_preset_for_provider(&provider_name).map(str::to_string), embedding_routes: Vec::new(), diff --git a/crates/adapters/providers/src/lib.rs b/crates/adapters/providers/src/lib.rs index c5e64dc9..960199c8 100644 --- a/crates/adapters/providers/src/lib.rs +++ b/crates/adapters/providers/src/lib.rs @@ -1638,7 +1638,7 @@ pub fn create_resilient_provider_with_options( Ok(Box::new(reliable)) } -/// Create a RouterProvider if model routes are configured, otherwise return a +/// Create a RouterProvider if route aliases are configured, otherwise return a /// standard resilient provider. The router wraps individual providers per route, /// each with its own retry/fallback chain. pub fn create_routed_provider( @@ -1646,7 +1646,7 @@ pub fn create_routed_provider( api_key: Option<&str>, api_url: Option<&str>, reliability: &synapse_domain::config::schema::ReliabilityConfig, - model_routes: &[synapse_domain::config::schema::ModelRouteConfig], + route_aliases: &[synapse_domain::config::schema::ModelRouteConfig], default_model: &str, ) -> anyhow::Result> { create_routed_provider_with_options( @@ -1654,7 +1654,7 @@ pub fn create_routed_provider( api_key, api_url, reliability, - model_routes, + route_aliases, default_model, &ProviderRuntimeOptions::default(), ) @@ -1666,11 +1666,11 @@ pub fn create_routed_provider_with_options( api_key: Option<&str>, api_url: Option<&str>, reliability: &synapse_domain::config::schema::ReliabilityConfig, - model_routes: &[synapse_domain::config::schema::ModelRouteConfig], + route_aliases: &[synapse_domain::config::schema::ModelRouteConfig], default_model: &str, options: &ProviderRuntimeOptions, ) -> anyhow::Result> { - if model_routes.is_empty() { + if route_aliases.is_empty() { return create_resilient_provider_with_options( primary_name, api_key, @@ -1682,7 +1682,7 @@ pub fn create_routed_provider_with_options( // Collect unique provider names needed let mut needed: Vec = vec![primary_name.to_string()]; - for route in model_routes { + for route in route_aliases { if !needed.iter().any(|n| n == &route.provider) { needed.push(route.provider.clone()); } @@ -1691,7 +1691,7 @@ pub fn create_routed_provider_with_options( // Create each provider (with its own resilience wrapper) let mut providers: Vec<(String, Box)> = Vec::new(); for name in &needed { - let routed_credential = model_routes + let routed_credential = route_aliases .iter() .find(|r| &r.provider == name) .and_then(|r| { @@ -1726,7 +1726,7 @@ pub fn create_routed_provider_with_options( } // Build route table - let routes: Vec<(String, router::Route)> = model_routes + let routes: Vec<(String, router::Route)> = route_aliases .iter() .map(|r| { ( diff --git a/crates/adapters/tools/src/model_routing_config.rs b/crates/adapters/tools/src/model_routing_config.rs index 5b4298b5..df668dda 100644 --- a/crates/adapters/tools/src/model_routing_config.rs +++ b/crates/adapters/tools/src/model_routing_config.rs @@ -457,7 +457,7 @@ impl ModelRoutingConfigTool { .map(Self::lane_name) .collect(); let mut catalog_aliases: Vec = - synapse_domain::config::model_catalog::model_route_aliases() + synapse_domain::config::model_catalog::route_aliases() .into_iter() .map(|alias| alias.hint) .collect(); diff --git a/crates/domain/src/application/services/inbound_message_service.rs b/crates/domain/src/application/services/inbound_message_service.rs index 12575ac7..a06df8a6 100644 --- a/crates/domain/src/application/services/inbound_message_service.rs +++ b/crates/domain/src/application/services/inbound_message_service.rs @@ -319,13 +319,11 @@ pub enum CommandEffect { /// provider validation to the adapter (requires infrastructure). pub fn command_effect(command: &RuntimeCommand, config: &Config) -> CommandEffect { command_effect_with_alias_resolver(command, config, |value| { - crate::config::model_catalog::model_route_alias(value).map(|route| { - ResolvedModelCommandRoute { - provider: route.provider, - model: route.model, - lane: route.capability, - candidate_index: None, - } + crate::config::model_catalog::route_alias(value).map(|route| ResolvedModelCommandRoute { + provider: route.provider, + model: route.model, + lane: route.capability, + candidate_index: None, }) }) } @@ -379,13 +377,11 @@ pub fn resolve_model_command_route( config: &Config, ) -> Option { resolve_model_command_route_with_alias_resolver(selector, config, |value| { - crate::config::model_catalog::model_route_alias(value).map(|route| { - ResolvedModelCommandRoute { - provider: route.provider, - model: route.model, - lane: route.capability, - candidate_index: None, - } + crate::config::model_catalog::route_alias(value).map(|route| ResolvedModelCommandRoute { + provider: route.provider, + model: route.model, + lane: route.capability, + candidate_index: None, }) }) } diff --git a/crates/domain/src/application/services/model_preset_resolution.rs b/crates/domain/src/application/services/model_preset_resolution.rs index 52655574..7fcbcc33 100644 --- a/crates/domain/src/application/services/model_preset_resolution.rs +++ b/crates/domain/src/application/services/model_preset_resolution.rs @@ -2,12 +2,12 @@ use std::collections::HashSet; use crate::config::model_catalog::{ apply_default_api_key, known_model_presets as catalog_known_model_presets, - model_route_aliases as catalog_model_route_aliases, normalize_model_preset_id as catalog_normalize_model_preset_id, preset_description as catalog_preset_description, preset_extra_lanes, preset_reasoning_seed as catalog_preset_reasoning_seed, preset_seed_multimodal_from_reasoning, preset_title as catalog_preset_title, - recommended_preset_for_provider as catalog_recommended_preset_for_provider, KnownModelPreset, + recommended_preset_for_provider as catalog_recommended_preset_for_provider, + route_aliases as catalog_route_aliases, KnownModelPreset, }; use crate::config::schema::{ CapabilityLane, Config, ModelCandidateProfileConfig, ModelFeature, ModelLaneCandidateConfig, @@ -94,7 +94,7 @@ pub fn provider_router_routes(config: &Config) -> Vec { } } - for alias in catalog_model_route_aliases() { + for alias in catalog_route_aliases() { push_provider_router_route(&mut routes, &mut seen_hints, alias); } diff --git a/crates/domain/src/config/model_catalog.rs b/crates/domain/src/config/model_catalog.rs index cea5d276..8a4a5e46 100644 --- a/crates/domain/src/config/model_catalog.rs +++ b/crates/domain/src/config/model_catalog.rs @@ -35,7 +35,7 @@ struct ModelCatalogData { profiles: Vec, #[serde(default)] embedding_profiles: Vec, - #[serde(default, alias = "model_routes")] + #[serde(default)] route_aliases: Vec, #[serde(default)] request_policies: Vec, @@ -590,11 +590,11 @@ fn embedding_profile_catalog_key_matches( && left.dimensions == right.dimensions } -pub fn model_route_aliases() -> Vec { +pub fn route_aliases() -> Vec { active_model_catalog().data.route_aliases.clone() } -pub fn model_route_alias(value: &str) -> Option { +pub fn route_alias(value: &str) -> Option { let value = value.trim().trim_matches('`'); if value.is_empty() { return None; @@ -720,17 +720,17 @@ mod tests { Some(CatalogModelProfileSource::BundledCatalog) ); - let aliases = model_route_aliases(); + let aliases = route_aliases(); assert!(aliases.iter().any(|route| route.hint == "gemma31b")); assert!(aliases.iter().any(|route| route.hint == "gemma26b")); assert!(aliases.iter().any(|route| route.hint == "grok420")); - let alias = model_route_alias("gemma31b").expect("alias should exist"); + let alias = route_alias("gemma31b").expect("alias should exist"); assert_eq!(alias.provider, "openrouter"); assert_eq!(alias.model, "google/gemma-4-31b-it"); - let alias = model_route_alias("grok-4.20").expect("alias should exist"); + let alias = route_alias("grok-4.20").expect("alias should exist"); assert_eq!(alias.provider, "openrouter"); assert_eq!(alias.model, "x-ai/grok-4.20"); - let alias = model_route_alias("qwen36").expect("alias should exist"); + let alias = route_alias("qwen36").expect("alias should exist"); assert_eq!(alias.provider, "openrouter"); assert_eq!(alias.model, "qwen/qwen3.6-plus"); } diff --git a/crates/domain/src/config/schema.rs b/crates/domain/src/config/schema.rs index 7aea809c..56642dff 100644 --- a/crates/domain/src/config/schema.rs +++ b/crates/domain/src/config/schema.rs @@ -73,7 +73,7 @@ pub struct Config { /// Default provider ID or alias (e.g. `"openrouter"`, `"ollama"`, `"anthropic"`). Default: `"openrouter"`. #[serde(alias = "model_provider")] pub default_provider: Option, - /// Default model routed through the selected provider (e.g. `"anthropic/claude-sonnet-4-6"`). + /// Default model routed through the selected provider (e.g. `"provider/model-id"`). #[serde(alias = "model")] pub default_model: Option, /// Model used for session summarization (cheaper than primary). Falls back to `default_model`. @@ -88,12 +88,12 @@ pub struct Config { pub compression_overrides: Vec, /// Explicit summary model configuration with its own provider. /// When set, overrides `summary_model` string. Allows using a different provider - /// (e.g. Anthropic Haiku) for summaries while keeping a different default provider. + /// for summaries while keeping a different default provider. /// /// ```toml /// [summary] - /// provider = "anthropic" - /// model = "claude-haiku-4-5-20251001" + /// provider = "summary-provider" + /// model = "summary-model-id" /// temperature = 0.3 /// ``` #[serde(default)] @@ -178,9 +178,9 @@ pub struct Config { #[serde(default)] pub skills: SkillsConfig, - /// Model routing rules — route `hint:` to specific provider+model combos. + /// Catalog route aliases — route `hint:` to specific provider+model combos. #[serde(default)] - pub model_routes: Vec, + pub route_aliases: Vec, /// Capability-aware model lanes — ordered candidates per runtime lane. /// @@ -441,7 +441,7 @@ pub struct ModelProviderConfig { /// Azure OpenAI resource name (e.g. "my-resource" in https://my-resource.openai.azure.com). #[serde(default, skip_serializing_if = "Option::is_none")] pub azure_openai_resource: Option, - /// Azure OpenAI deployment name (e.g. "gpt-4o"). + /// Azure OpenAI deployment name (e.g. "chat-deployment"). #[serde(default, skip_serializing_if = "Option::is_none")] pub azure_openai_deployment: Option, /// Azure OpenAI API version (defaults to "2024-08-01-preview"). @@ -1741,7 +1741,7 @@ pub struct AgentsIpcConfig { /// /// ```toml /// [agents_ipc.workload_profiles.research] - /// model = "claude-sonnet-4-6" + /// model = "provider/model-id" /// allowed_tools = ["web_search", "web_fetch", "memory_read"] /// ``` #[serde(default)] @@ -4031,7 +4031,7 @@ pub struct ReliabilityConfig { #[serde(default)] pub api_keys: Vec, /// Per-model fallback chains. When a model fails, try these alternatives in order. - /// Example: `{ "claude-opus-4-20250514" = ["claude-sonnet-4-20250514", "gpt-4o"] }` + /// Example: `{ "primary-model-id" = ["fallback-model-id", "second-fallback-model-id"] }` #[serde(default)] pub model_fallbacks: std::collections::HashMap>, /// Initial backoff for channel/daemon restarts. @@ -4131,15 +4131,15 @@ impl Default for SchedulerConfig { /// Explicit summary model configuration (`[summary]` section). /// /// When `provider` is set, the summary path creates its own provider instance -/// instead of reusing the default. This allows e.g. Anthropic Haiku for summaries +/// instead of reusing the default. This allows using a cheaper/smaller route for summaries /// while the default provider is DashScope. /// /// ```toml /// [summary] -/// provider = "anthropic" -/// model = "claude-haiku-4-5-20251001" +/// provider = "summary-provider" +/// model = "summary-model-id" /// temperature = 0.3 -/// api_key_env = "ANTHROPIC_API_KEY" +/// api_key_env = "SUMMARY_PROVIDER_API_KEY" /// ``` #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct SummaryConfig { @@ -4290,22 +4290,22 @@ pub struct ModelLaneConfig { pub candidates: Vec, } -/// Legacy catalog alias from a task hint to a specific provider + model. +/// Catalog alias from a task hint to a specific provider + model. /// /// ```toml -/// [[model_routes]] +/// [[route_aliases]] /// hint = "reasoning" /// provider = "example-provider" /// model = "example-reasoning-model" /// -/// [[model_routes]] +/// [[route_aliases]] /// hint = "fast" /// provider = "example-provider" /// model = "example-fast-model" /// ``` /// /// Runtime routing should prefer `[[model_lanes]]`; this shape remains for -/// catalog aliases and legacy config/API surfaces. +/// explicit catalog aliases and provider-router dispatch compatibility. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct ModelRouteConfig { /// Alias name, such as a capability selector or operator-defined shortcut. @@ -6346,7 +6346,7 @@ impl Default for Config { scheduler: SchedulerConfig::default(), agent: AgentConfig::default(), skills: SkillsConfig::default(), - model_routes: Vec::new(), + route_aliases: Vec::new(), model_lanes: Vec::new(), model_preset: None, embedding_routes: Vec::new(), diff --git a/docs/fork/ipc-phase4_10-audit.md b/docs/fork/ipc-phase4_10-audit.md index 71858ece..941b0a51 100644 --- a/docs/fork/ipc-phase4_10-audit.md +++ b/docs/fork/ipc-phase4_10-audit.md @@ -53,32 +53,32 @@ Our best move is: - Runtime model switching now follows the same typed selector path in web and channel: effective capability lanes first, catalog aliases second, then an explicit unresolved model selector. -- Legacy `model_routes` / `embedding_routes` are no longer lane-resolution +- Legacy route aliases / `embedding_routes` are no longer lane-resolution fallbacks for `resolve_lane_candidates`, summary routing, query-classifier overrides, or `/model` command effects. - Matched lane candidates preserve `lane + candidate_index` through the shared runtime-command adapter contract, which keeps route state aligned with the candidate-profile registry instead of only storing provider/model strings. - `/model` help now renders effective capability lanes and catalog aliases - instead of promoting configured `model_routes` as a first-class runtime + instead of promoting configured route aliases as a first-class runtime surface. - `model_routing_config` no longer creates or removes editable - `model_routes`; scenario upserts now write the selected capability lane's + editable route-table entries; scenario upserts now write the selected capability lane's ordered candidate list and classification rules resolve through the shared lane/catalog selector path. - Live web/Agent query classification now follows the same lane/catalog - selector resolver instead of maintaining an Agent-local `model_routes` + selector resolver instead of maintaining an Agent-local route-alias hint/model map. - Live Agent and CLI provider-router alias tables are now derived from the shared effective-lane/catalog helper instead of passing configured - `model_routes` directly into the router. + route-table directly into the router. - Provider-router aliases now include typed `provider:model` keys for every lane candidate, preventing selector/router drift for non-primary candidates. -- Channel inbound/use-case route snapshots no longer carry `model_routes` +- Channel inbound/use-case route snapshots no longer carry the legacy route table through turn execution; they now pass lane/preset state only. -- Remaining audit target: old config/API surfaces still expose `model_routes` - as compatibility/catalog-alias data; future cleanup should either rename that - data shape to catalog aliases or fully retire it. +- Config/API/catalog surfaces now expose user-defined catalog shortcuts as + `route_aliases`, with no old Rust route-table field or catalog compatibility + alias left in the runtime code path. --- diff --git a/docs/fork/ipc-phase4_10-plan.md b/docs/fork/ipc-phase4_10-plan.md index 7bab4aa2..87645b21 100644 --- a/docs/fork/ipc-phase4_10-plan.md +++ b/docs/fork/ipc-phase4_10-plan.md @@ -1112,7 +1112,7 @@ Expected outcome: entered through the automatic context path - low-information detection now catches repeated semantic shingles, not only exact token chants or contiguous repeated phrases - - summary route resolution no longer consumes legacy `model_routes` cheap + - summary route resolution no longer consumes the legacy route-table cheap hints; the compactor lane must come from explicit/effective capability lanes or explicit summary config, keeping cheap-model condensation on the same routing language as the main runtime @@ -1259,8 +1259,8 @@ Expected outcome: - `model_routing_config` preset operations now participate in typed routing facts - `model_routing_config` scenario upsert/remove now manages explicit capability-lane candidate lists plus classification rules instead of - writing legacy `model_routes` - - `resolve_lane_candidates` no longer treats legacy `model_routes` or + writing the legacy route-alias table + - `resolve_lane_candidates` no longer treats the legacy route-alias table or `embedding_routes` as capability-lane fallbacks; fallback to the default reasoning route now requires both configured provider and configured model, with no provider string invented in code @@ -1268,23 +1268,27 @@ Expected outcome: effective capability lanes first and catalog aliases second; matched lane candidates preserve `lane + candidate_index` through the shared runtime-command adapter contract - - `/model` help no longer renders configured `model_routes` as a first-class + - `/model` help no longer renders configured route aliases as a first-class routing surface; it shows effective capability lanes plus catalog aliases so operator UX matches the runtime selector path - query-classifier route overrides now reuse the same lane/catalog selector - resolver instead of scanning `model_routes` by hint + resolver instead of scanning the old route-table by hint - live web/Agent query classification now uses that same lane/catalog selector resolver and its provider router is seeded from effective lanes plus catalog - aliases, not from configured legacy `model_routes` + aliases, not from the configured legacy route-table - the effective-lane/catalog provider-router alias table is now a shared domain helper consumed by live Agent and CLI runtime, avoiding a second - legacy `model_routes` route table in adapter code + legacy route table in adapter code - provider-router aliases include typed `provider:model` keys for explicit non-primary lane candidates, so selector resolution and router dispatch do not diverge on fallback candidates - - channel inbound/use-case runtime snapshots no longer carry `model_routes` + - channel inbound/use-case runtime snapshots no longer carry the legacy route table through the turn path; they pass only model lanes/preset into shared route resolution + - config/API/catalog surfaces now expose user-defined provider-router + shortcuts as `route_aliases`; the old Rust route-table field and catalog + compatibility alias were removed rather than kept as + a second public routing language - remaining work: - historical note: earlier remaining items about image/audio/video/music first-class turn routing are now mostly owned by Slice 14 and the shared @@ -1297,9 +1301,6 @@ Expected outcome: - route state stores lane/candidate identity, and route-switch UX now renders lane for lane-aware switches; downstream runtime surfaces should keep moving toward lane/candidate-first explanations - - old config/API surfaces still expose `model_routes` as compatibility or - catalog-alias data; remove or rename that surface in a dedicated cleanup - when the gateway/UI contract is ready to change - keep auditing that new routing decisions enter through domain/profile services rather than brand-string heuristics diff --git a/docs/reference/api/config-reference.md b/docs/reference/api/config-reference.md index 357ed3fb..74319a6d 100644 --- a/docs/reference/api/config-reference.md +++ b/docs/reference/api/config-reference.md @@ -31,7 +31,7 @@ Schema export command: | Key | Default | Notes | |---|---|---| | `default_provider` | `openrouter` | provider ID or alias | -| `default_model` | `anthropic/claude-sonnet-4-6` | model routed through selected provider | +| `default_model` | preset/catalog-dependent | model routed through selected provider | | `default_temperature` | `0.7` | model temperature | ## Model Presets And Local Catalog @@ -46,7 +46,7 @@ Relevant keys: |---|---| | `model_preset` | selects a built-in preset seed | | `model_lanes` | explicit lane-aware overrides (`reasoning`, `cheap_reasoning`, `embedding`, later multimodal lanes) | -| `model_routes` | legacy hint-based route mapping | +| `route_aliases` | optional user-defined provider-router shortcuts; capability lanes remain the primary runtime routing surface | Operational notes: @@ -56,7 +56,7 @@ Operational notes: - Provider model cache metadata wins first; bundled/local catalog profiles are fallback metadata for context windows, max output, and feature coverage when the live cache is missing. -- User `[[model_routes]]` entries win over bundled route aliases. Bundled +- User `[[route_aliases]]` entries win over bundled route aliases. Bundled aliases are only a fallback so shortcuts such as `cheap`, `qwen36`, `gemma31b`, and `gemma26b` work in fresh configs without changing the default provider/model. @@ -236,7 +236,7 @@ Notes: ```toml [agents.researcher] provider = "openrouter" -model = "anthropic/claude-sonnet-4-6" +model = "provider/model-id" system_prompt = "You are a research assistant." max_depth = 2 agentic = true @@ -244,8 +244,8 @@ allowed_tools = ["web_search", "http_request", "file_read"] max_iterations = 8 [agents.coder] -provider = "ollama" -model = "qwen2.5-coder:32b" +provider = "local" +model = "local-coder-model" temperature = 0.2 ``` @@ -447,13 +447,13 @@ Notes: - Memory context injection ignores legacy `assistant_resp*` auto-save keys to prevent old model-authored summaries from being treated as facts. -## `[[model_routes]]` and `[[embedding_routes]]` +## `[[route_aliases]]` and `[[embedding_routes]]` Use route hints so integrations can keep stable names while model IDs evolve. If a hint is not present in `config.toml`, SynapseClaw falls back to the bundled -model catalog aliases. User-defined `[[model_routes]]` always take precedence. +model catalog aliases. User-defined `[[route_aliases]]` always take precedence. -### `[[model_routes]]` +### `[[route_aliases]]` | Key | Default | Purpose | |---|---|---| @@ -476,7 +476,7 @@ model catalog aliases. User-defined `[[model_routes]]` always take precedence. [memory] embedding_model = "hint:semantic" -[[model_routes]] +[[route_aliases]] hint = "reasoning" provider = "openrouter" model = "provider/model-id" @@ -501,13 +501,13 @@ Natural-language config path: Example requests: -- `Set conversation to provider kimi, model moonshot-v1-8k.` -- `Set coding to provider openai, model gpt-5.3-codex, and auto-route when message contains code blocks.` -- `Create a coder sub-agent using openai/gpt-5.3-codex with tools file_read,file_write,shell.` +- `Set conversation to provider example-provider, model provider/model-id.` +- `Set coding to provider example-provider, model code-model-id, and auto-route when message contains code blocks.` +- `Create a coder sub-agent using example-provider/code-model-id with tools file_read,file_write,shell.` ## `[query_classification]` -Automatic model hint routing — maps user messages to `[[model_routes]]` hints based on content patterns. +Automatic model hint routing maps user messages to lane or route-alias hints based on content patterns. | Key | Default | Purpose | |---|---|---| @@ -518,7 +518,7 @@ Each rule in `rules`: | Key | Default | Purpose | |---|---|---| -| `hint` | _required_ | Must match a `[[model_routes]]` hint value | +| `hint` | _required_ | Must match an effective capability-lane hint or a `[[route_aliases]]` hint value | | `keywords` | `[]` | Case-insensitive substring matches | | `patterns` | `[]` | Case-sensitive literal matches (for code fences, keywords like `"fn "`) | | `min_length` | unset | Only match if message length ≥ N chars | diff --git a/docs/reference/api/providers-reference.md b/docs/reference/api/providers-reference.md index e0439704..407daa14 100644 --- a/docs/reference/api/providers-reference.md +++ b/docs/reference/api/providers-reference.md @@ -245,15 +245,15 @@ Optional endpoint override: ## Model Routing (`hint:`) -You can route model calls by hint using `[[model_routes]]`: +You can route model calls by hint using `[[route_aliases]]`: ```toml -[[model_routes]] +[[route_aliases]] hint = "reasoning" provider = "openrouter" model = "anthropic/claude-opus-4-20250514" -[[model_routes]] +[[route_aliases]] hint = "fast" provider = "groq" model = "llama-3.3-70b-versatile" @@ -310,7 +310,7 @@ Use stable hints and update only route targets when providers deprecate model ID Recommended workflow: 1. Keep call sites stable (`hint:reasoning`, `hint:semantic`). -2. Change only the target model under `[[model_routes]]` or `[[embedding_routes]]`. +2. Change only the target model under `[[route_aliases]]` or `[[embedding_routes]]`. 3. Run: - `synapseclaw doctor` - `synapseclaw status` diff --git a/docs/reference/cli/commands-reference.md b/docs/reference/cli/commands-reference.md index ac0eba09..c68a3ea1 100644 --- a/docs/reference/cli/commands-reference.md +++ b/docs/reference/cli/commands-reference.md @@ -138,7 +138,7 @@ model lists, route aliases, provider:model profiles, or default pricing without changing repository files. Profile entries can provide context windows, max output, and feature coverage for admission/routing when the live provider cache has not been refreshed yet. Route aliases are fallback shortcuts for `/model`; -user `[[model_routes]]` entries still win when both define the same hint. +user `[[route_aliases]]` entries still win when both define the same hint. ### `doctor` From 3155c42754f2d9db41b7c563debec97d7bdf29de Mon Sep 17 00:00:00 2001 From: panviktor <35385675+panviktor@users.noreply.github.com> Date: Sun, 12 Apr 2026 21:00:22 +0200 Subject: [PATCH 10/30] Seed media lanes from declared model features --- .../services/model_preset_resolution.rs | 209 ++++++++++++++++-- docs/fork/ipc-phase4_10-plan.md | 6 +- 2 files changed, 201 insertions(+), 14 deletions(-) diff --git a/crates/domain/src/application/services/model_preset_resolution.rs b/crates/domain/src/application/services/model_preset_resolution.rs index 7fcbcc33..7fed616b 100644 --- a/crates/domain/src/application/services/model_preset_resolution.rs +++ b/crates/domain/src/application/services/model_preset_resolution.rs @@ -1,7 +1,9 @@ use std::collections::HashSet; +use crate::application::services::model_capability_support::lane_required_feature; use crate::config::model_catalog::{ apply_default_api_key, known_model_presets as catalog_known_model_presets, + model_profile as catalog_model_profile, normalize_model_preset_id as catalog_normalize_model_preset_id, preset_description as catalog_preset_description, preset_extra_lanes, preset_reasoning_seed as catalog_preset_reasoning_seed, preset_seed_multimodal_from_reasoning, @@ -140,27 +142,24 @@ fn build_preset_model_lanes(config: &Config, preset: &str) -> Option Option Option, + reasoning_candidate: &ModelLaneCandidateConfig, +) { + let Some(profile) = + catalog_model_profile(&reasoning_candidate.provider, &reasoning_candidate.model) + else { + return; + }; + seed_declared_media_lanes_from_profile( + lanes, + reasoning_candidate, + ModelCandidateProfileConfig { + context_window_tokens: profile.context_window_tokens, + max_output_tokens: profile.max_output_tokens, + features: profile.features, + }, + ); +} + +fn seed_declared_media_lanes_from_profile( + lanes: &mut Vec, + reasoning_candidate: &ModelLaneCandidateConfig, + profile: ModelCandidateProfileConfig, +) { + for lane in [ + CapabilityLane::MultimodalUnderstanding, + CapabilityLane::ImageGeneration, + CapabilityLane::AudioGeneration, + CapabilityLane::VideoGeneration, + CapabilityLane::MusicGeneration, + ] { + if lanes.iter().any(|existing| existing.lane == lane) { + continue; + } + if !profile_supports_seeded_lane(&profile, lane) { + continue; + } + lanes.push(ModelLaneConfig { + lane, + candidates: vec![ModelLaneCandidateConfig { + profile: profile.clone(), + ..reasoning_candidate.clone() + }], + }); + } +} + +fn profile_supports_seeded_lane( + profile: &ModelCandidateProfileConfig, + lane: CapabilityLane, +) -> bool { + match lane_required_feature(lane) { + Some(ModelFeature::MultimodalUnderstanding) => { + profile + .features + .contains(&ModelFeature::MultimodalUnderstanding) + || profile.features.contains(&ModelFeature::Vision) + } + Some(feature) => profile.features.contains(&feature), + None => false, + } +} + #[cfg(test)] mod tests { use super::*; @@ -259,6 +324,124 @@ mod tests { .contains(&ModelFeature::Vision)); } + #[test] + fn declared_reasoning_profile_features_seed_media_lanes() { + let reasoning_candidate = ModelLaneCandidateConfig { + provider: "test-provider".into(), + model: "test-universal-model".into(), + api_key: Some("test-key".into()), + api_key_env: None, + dimensions: None, + profile: ModelCandidateProfileConfig::default(), + }; + let mut lanes = vec![ModelLaneConfig { + lane: CapabilityLane::Reasoning, + candidates: vec![reasoning_candidate.clone()], + }]; + + seed_declared_media_lanes_from_profile( + &mut lanes, + &reasoning_candidate, + ModelCandidateProfileConfig { + context_window_tokens: Some(1_000_000), + max_output_tokens: Some(64_000), + features: vec![ + ModelFeature::ToolCalling, + ModelFeature::Vision, + ModelFeature::ImageGeneration, + ModelFeature::VideoGeneration, + ModelFeature::MusicGeneration, + ], + }, + ); + + assert!(lanes + .iter() + .any(|lane| lane.lane == CapabilityLane::MultimodalUnderstanding)); + assert!(lanes + .iter() + .any(|lane| lane.lane == CapabilityLane::ImageGeneration)); + assert!(lanes + .iter() + .any(|lane| lane.lane == CapabilityLane::VideoGeneration)); + assert!(lanes + .iter() + .any(|lane| lane.lane == CapabilityLane::MusicGeneration)); + assert!(!lanes + .iter() + .any(|lane| lane.lane == CapabilityLane::AudioGeneration)); + let seeded = lanes + .iter() + .find(|lane| lane.lane == CapabilityLane::VideoGeneration) + .expect("video lane should be seeded"); + assert_eq!(seeded.candidates[0].provider, "test-provider"); + assert_eq!(seeded.candidates[0].model, "test-universal-model"); + assert_eq!(seeded.candidates[0].api_key.as_deref(), Some("test-key")); + assert_eq!( + seeded.candidates[0].profile.context_window_tokens, + Some(1_000_000) + ); + } + + #[test] + fn declared_reasoning_profile_features_do_not_override_existing_lanes() { + let reasoning_candidate = ModelLaneCandidateConfig { + provider: "test-provider".into(), + model: "test-universal-model".into(), + api_key: None, + api_key_env: None, + dimensions: None, + profile: ModelCandidateProfileConfig::default(), + }; + let mut lanes = vec![ + ModelLaneConfig { + lane: CapabilityLane::Reasoning, + candidates: vec![reasoning_candidate.clone()], + }, + ModelLaneConfig { + lane: CapabilityLane::ImageGeneration, + candidates: vec![ModelLaneCandidateConfig { + provider: "explicit-provider".into(), + model: "explicit-image-model".into(), + api_key: None, + api_key_env: None, + dimensions: None, + profile: ModelCandidateProfileConfig { + context_window_tokens: Some(128_000), + max_output_tokens: None, + features: vec![ModelFeature::ImageGeneration], + }, + }], + }, + ]; + + seed_declared_media_lanes_from_profile( + &mut lanes, + &reasoning_candidate, + ModelCandidateProfileConfig { + context_window_tokens: Some(1_000_000), + max_output_tokens: None, + features: vec![ModelFeature::ImageGeneration, ModelFeature::AudioGeneration], + }, + ); + + let image = lanes + .iter() + .find(|lane| lane.lane == CapabilityLane::ImageGeneration) + .expect("explicit image lane should remain"); + assert_eq!(image.candidates[0].provider, "explicit-provider"); + assert_eq!( + lanes + .iter() + .filter(|lane| lane.lane == CapabilityLane::ImageGeneration) + .count(), + 1 + ); + assert!(lanes + .iter() + .any(|lane| lane.lane == CapabilityLane::AudioGeneration)); + } + #[test] fn explicit_lane_overrides_preset_lane() { let mut config = Config::default(); diff --git a/docs/fork/ipc-phase4_10-plan.md b/docs/fork/ipc-phase4_10-plan.md index 87645b21..be42a705 100644 --- a/docs/fork/ipc-phase4_10-plan.md +++ b/docs/fork/ipc-phase4_10-plan.md @@ -1581,8 +1581,12 @@ Expected outcome: - onboarding/provider-catalog parsing now preserves explicit `video` and `music` output modalities when a provider catalog surfaces them - runtime help/config surfaces render the new lanes/features + - preset expansion now auto-seeds missing media-generation lanes from the + reasoning seed only when that seed's resolved catalog/profile metadata + explicitly advertises the corresponding `ModelFeature`; universal + models can therefore supply image/audio/video/music lanes without + natural-language phrase detection or Rust model-id match arms - remaining: - - presets/local catalogs do not yet seed safe built-in video/music candidates by default - modality inference remains conservative and marker-based; richer intent recognition should come later through typed interpretation or an explicit classifier, not keyword lists - more route/runtime UX should render lane identity first and `provider/model` second From 0c984bfc1fa0bb2e68b61f40841be0fd7529b4be Mon Sep 17 00:00:00 2001 From: panviktor <35385675+panviktor@users.noreply.github.com> Date: Sun, 12 Apr 2026 21:08:00 +0200 Subject: [PATCH 11/30] Carry tool repairs through handoff packets --- crates/adapters/core/src/agent/agent.rs | 7 ++ crates/adapters/core/src/runtime_routes.rs | 1 + crates/adapters/core/src/tools/agents_ipc.rs | 5 ++ crates/adapters/core/src/tools/delegate.rs | 5 ++ .../services/runtime_trace_janitor.rs | 2 + .../application/services/session_handoff.rs | 67 ++++++++++++++++++- .../src/application/services/turn_context.rs | 5 ++ .../use_cases/handle_inbound_message.rs | 7 ++ docs/fork/ipc-phase4_10-plan.md | 7 +- 9 files changed, 104 insertions(+), 2 deletions(-) diff --git a/crates/adapters/core/src/agent/agent.rs b/crates/adapters/core/src/agent/agent.rs index 49bd2cbf..d207689d 100644 --- a/crates/adapters/core/src/agent/agent.rs +++ b/crates/adapters/core/src/agent/agent.rs @@ -2562,6 +2562,12 @@ impl Agent { } if admission_decision.snapshot.action == TurnAdmissionAction::Block { + let mut handoff_tool_repairs = self.recent_turn_tool_repairs.clone(); + if let Some(last_tool_repair) = self.last_turn_tool_repair.clone() { + if !handoff_tool_repairs.contains(&last_tool_repair) { + handoff_tool_repairs.push(last_tool_repair); + } + } let handoff_packet = synapse_domain::application::services::session_handoff::build_session_handoff_packet( synapse_domain::application::services::session_handoff::SessionHandoffInput { @@ -2572,6 +2578,7 @@ impl Agent { recalled_entries: &turn_ctx.recalled_entries, session_matches: &turn_ctx.session_matches, run_recipes: &turn_ctx.run_recipes, + recent_tool_repairs: &handoff_tool_repairs, }, ); let handoff_packet_text = handoff_packet diff --git a/crates/adapters/core/src/runtime_routes.rs b/crates/adapters/core/src/runtime_routes.rs index c2242541..8b3beec2 100644 --- a/crates/adapters/core/src/runtime_routes.rs +++ b/crates/adapters/core/src/runtime_routes.rs @@ -1449,6 +1449,7 @@ mod tests { current_defaults: Vec::new(), anchors: Vec::new(), unresolved_questions: Vec::new(), + recent_repairs: Vec::new(), assumptions: Vec::new(), }, }], diff --git a/crates/adapters/core/src/tools/agents_ipc.rs b/crates/adapters/core/src/tools/agents_ipc.rs index c94d9f51..f2f85baa 100644 --- a/crates/adapters/core/src/tools/agents_ipc.rs +++ b/crates/adapters/core/src/tools/agents_ipc.rs @@ -1074,6 +1074,7 @@ impl Tool for AgentsSpawnTool { "current_defaults": {"type": "array", "items": {"type": "string"}}, "anchors": {"type": "array", "items": {"type": "string"}}, "unresolved_questions": {"type": "array", "items": {"type": "string"}}, + "recent_repairs": {"type": "array", "items": {"type": "string"}}, "assumptions": { "type": "array", "items": { @@ -1608,12 +1609,16 @@ mod tests { current_defaults: Vec::new(), anchors: vec!["memory=late anchor".into()], unresolved_questions: Vec::new(), + recent_repairs: vec![ + "tool_repair=message_send:reported_failure->adjust_arguments_or_target".into(), + ], assumptions: Vec::new(), }; let rendered = build_spawn_prompt_body("finish task", Some(&packet)); assert!(rendered.starts_with("[session-handoff]")); + assert!(rendered.contains("recent_repairs:")); assert!(rendered.contains("[/session-handoff]\n[Task]\nfinish task")); } diff --git a/crates/adapters/core/src/tools/delegate.rs b/crates/adapters/core/src/tools/delegate.rs index a5c9a98a..ad0432b2 100644 --- a/crates/adapters/core/src/tools/delegate.rs +++ b/crates/adapters/core/src/tools/delegate.rs @@ -181,6 +181,7 @@ impl Tool for DelegateTool { "current_defaults": {"type": "array", "items": {"type": "string"}}, "anchors": {"type": "array", "items": {"type": "string"}}, "unresolved_questions": {"type": "array", "items": {"type": "string"}}, + "recent_repairs": {"type": "array", "items": {"type": "string"}}, "assumptions": { "type": "array", "items": { @@ -824,12 +825,16 @@ mod tests { current_defaults: vec!["project_alias=Borealis".into()], anchors: vec!["memory=early anchor".into()], unresolved_questions: Vec::new(), + recent_repairs: vec![ + "tool_repair=message_send:reported_failure->adjust_arguments_or_target".into(), + ], assumptions: Vec::new(), }; let rendered = build_delegate_prompt("finish task", "local context", Some(&packet)); assert!(rendered.starts_with("[session-handoff]")); + assert!(rendered.contains("recent_repairs:")); assert!(rendered.contains("[/session-handoff]\n\n[Context]\nlocal context")); assert!(rendered.contains("[Task]\nfinish task")); } diff --git a/crates/domain/src/application/services/runtime_trace_janitor.rs b/crates/domain/src/application/services/runtime_trace_janitor.rs index b2bd5bac..7444cea1 100644 --- a/crates/domain/src/application/services/runtime_trace_janitor.rs +++ b/crates/domain/src/application/services/runtime_trace_janitor.rs @@ -607,6 +607,7 @@ mod tests { current_defaults: vec!["a".repeat(400)], anchors: Vec::new(), unresolved_questions: Vec::new(), + recent_repairs: Vec::new(), assumptions: Vec::new(), }, }; @@ -662,6 +663,7 @@ mod tests { current_defaults: Vec::new(), anchors: Vec::new(), unresolved_questions: Vec::new(), + recent_repairs: Vec::new(), assumptions: Vec::new(), }, }; diff --git a/crates/domain/src/application/services/session_handoff.rs b/crates/domain/src/application/services/session_handoff.rs index 2814464b..3a89b1a1 100644 --- a/crates/domain/src/application/services/session_handoff.rs +++ b/crates/domain/src/application/services/session_handoff.rs @@ -7,6 +7,9 @@ use crate::application::services::{ }; use crate::domain::conversation_target::ConversationDeliveryTarget; use crate::domain::memory::MemoryEntry; +use crate::domain::tool_repair::{ + tool_failure_kind_name, tool_repair_action_name, ToolRepairAction, ToolRepairTrace, +}; use crate::domain::turn_admission::{ admission_repair_hint_label, AdmissionRepairHint, CandidateAdmissionReason, }; @@ -16,6 +19,7 @@ use crate::domain::user_profile::DELIVERY_TARGET_PREFERENCE_KEY; const MAX_DEFAULTS: usize = 6; const MAX_ANCHORS: usize = 4; const MAX_UNRESOLVED: usize = 3; +const MAX_REPAIRS: usize = 3; const MAX_ITEM_CHARS: usize = 180; const MAX_TASK_CHARS: usize = 240; @@ -44,6 +48,8 @@ pub struct SessionHandoffPacket { #[serde(default)] pub unresolved_questions: Vec, #[serde(default)] + pub recent_repairs: Vec, + #[serde(default)] pub assumptions: Vec, } @@ -56,6 +62,7 @@ pub struct SessionHandoffInput<'a> { pub recalled_entries: &'a [MemoryEntry], pub session_matches: &'a [SessionSearchMatch], pub run_recipes: &'a [RunRecipeSearchMatch], + pub recent_tool_repairs: &'a [ToolRepairTrace], } pub fn build_session_handoff_packet( @@ -81,6 +88,9 @@ pub fn build_session_handoff_packet( .unwrap_or_default(); dedup_and_truncate(&mut unresolved_questions, MAX_UNRESOLVED); + let mut recent_repairs = collect_recent_repairs(input.recent_tool_repairs); + dedup_and_truncate(&mut recent_repairs, MAX_REPAIRS); + let assumptions = runtime_assumptions::build_runtime_assumptions( runtime_assumptions::RuntimeAssumptionInput { user_message: input.user_message, @@ -99,6 +109,7 @@ pub fn build_session_handoff_packet( current_defaults, anchors, unresolved_questions, + recent_repairs, assumptions, }) } @@ -121,6 +132,7 @@ pub fn format_session_handoff_packet(packet: &SessionHandoffPacket) -> String { "unresolved_questions", &packet.unresolved_questions, ); + append_list(&mut lines, "recent_repairs", &packet.recent_repairs); append_list( &mut lines, "assumptions", @@ -162,6 +174,7 @@ pub fn bound_session_handoff_packet(mut packet: SessionHandoffPacket) -> Session dedup_and_truncate(&mut packet.current_defaults, MAX_DEFAULTS); dedup_and_truncate(&mut packet.anchors, MAX_ANCHORS); dedup_and_truncate(&mut packet.unresolved_questions, MAX_UNRESOLVED); + dedup_and_truncate(&mut packet.recent_repairs, MAX_REPAIRS); packet.assumptions = runtime_assumptions::bound_runtime_assumptions(packet.assumptions); packet } @@ -343,6 +356,38 @@ fn collect_context_anchors( anchors } +fn collect_recent_repairs(repairs: &[ToolRepairTrace]) -> Vec { + let mut items = Vec::new(); + for repair in repairs.iter().rev() { + let mut item = format!( + "tool_repair={}:{}->{}", + repair.tool_name, + tool_failure_kind_name(repair.failure_kind), + format_tool_repair_action(repair.suggested_action) + ); + if let Some(detail) = repair.detail.as_deref() { + if let Some(detail) = bounded_non_empty(detail, MAX_ITEM_CHARS) { + item.push_str(" detail="); + item.push_str(&detail); + } + } + items.push(item); + if items.len() >= MAX_REPAIRS { + break; + } + } + items +} + +fn format_tool_repair_action(action: ToolRepairAction) -> String { + match action { + ToolRepairAction::SwitchRouteLane(lane) => { + format!("{}:{}", tool_repair_action_name(action), lane.as_str()) + } + _ => tool_repair_action_name(action).to_string(), + } +} + fn format_delivery_target(target: &ConversationDeliveryTarget) -> String { match target { ConversationDeliveryTarget::CurrentConversation => "current_conversation".to_string(), @@ -436,6 +481,7 @@ mod tests { use crate::application::services::turn_interpretation::TurnInterpretation; use crate::config::schema::CapabilityLane; use crate::domain::memory::{MemoryCategory, MemoryEntry}; + use crate::domain::tool_repair::{ToolFailureKind, ToolRepairAction, ToolRepairTrace}; use crate::domain::user_profile::UserProfile; #[test] @@ -465,6 +511,13 @@ mod tests { }], session_matches: &[], run_recipes: &[], + recent_tool_repairs: &[ToolRepairTrace { + observed_at_unix: 1_744_243_100, + tool_name: "message_send".into(), + failure_kind: ToolFailureKind::ReportedFailure, + suggested_action: ToolRepairAction::AdjustArgumentsOrTarget, + detail: Some("missing delivery target".into()), + }], }) .expect("packet"); @@ -479,6 +532,11 @@ mod tests { .anchors .iter() .any(|anchor| anchor.contains("freedom and responsibility"))); + assert!(packet + .recent_repairs + .iter() + .any(|repair| repair + == "tool_repair=message_send:reported_failure->adjust_arguments_or_target detail=missing delivery target")); assert!(packet.assumptions.iter().any(|assumption| assumption.kind == runtime_assumptions::RuntimeAssumptionKind::ProfileFact && assumption.value == "workspace_anchor=Borealis")); @@ -495,6 +553,9 @@ mod tests { current_defaults: vec!["project_alias=Borealis".into()], anchors: vec!["memory=Use structured markers for media routing".into()], unresolved_questions: vec!["which target lane".into()], + recent_repairs: vec![ + "tool_repair=message_send:reported_failure->adjust_arguments_or_target".into(), + ], assumptions: vec![runtime_assumptions::RuntimeAssumption { kind: runtime_assumptions::RuntimeAssumptionKind::ProfileFact, source: runtime_assumptions::RuntimeAssumptionSource::UserProfile, @@ -511,6 +572,8 @@ mod tests { assert!(formatted.contains("[session-handoff]")); assert!(formatted.contains("reason: route_switch")); assert!(formatted.contains("recommended_action: switch_lane:image_generation")); + assert!(formatted.contains("recent_repairs:")); + assert!(formatted.contains("tool_repair=message_send")); assert!(formatted.contains("assumptions:")); assert!(formatted.contains("kind=profile_fact")); assert!(formatted.contains("value=project_alias=Borealis")); @@ -524,7 +587,8 @@ mod tests { "active_task": "x".repeat(400), "current_defaults": ["project_alias=Borealis", "project_alias=Borealis"], "anchors": ["a", "b", "c", "d", "e"], - "unresolved_questions": ["q1", "q2", "q3", "q4"] + "unresolved_questions": ["q1", "q2", "q3", "q4"], + "recent_repairs": ["r1", "r1", "r2", "r3", "r4"] }); let packet = parse_session_handoff_packet_value(&value) @@ -536,6 +600,7 @@ mod tests { assert_eq!(packet.current_defaults, vec!["project_alias=Borealis"]); assert_eq!(packet.anchors.len(), MAX_ANCHORS); assert_eq!(packet.unresolved_questions.len(), MAX_UNRESOLVED); + assert_eq!(packet.recent_repairs, vec!["r1", "r2", "r3"]); } #[test] diff --git a/crates/domain/src/application/services/turn_context.rs b/crates/domain/src/application/services/turn_context.rs index 03a8e4c8..36d6dab9 100644 --- a/crates/domain/src/application/services/turn_context.rs +++ b/crates/domain/src/application/services/turn_context.rs @@ -1268,6 +1268,7 @@ fn apply_resolution_plan( recalled_entries: &ctx.recalled_entries, session_matches: &ctx.session_matches, run_recipes: &ctx.run_recipes, + recent_tool_repairs, }); } @@ -2473,6 +2474,9 @@ mod tests { current_defaults: vec!["project_alias=Borealis".into()], anchors: vec!["memory=early anchor".into()], unresolved_questions: Vec::new(), + recent_repairs: vec![ + "tool_repair=message_send:reported_failure->adjust_arguments_or_target".into(), + ], assumptions: Vec::new(), }), ..Default::default() @@ -2480,6 +2484,7 @@ mod tests { let fmt = format_turn_context(&ctx, &PromptBudget::default()); assert!(fmt.resolution_system.contains("[session-handoff]")); + assert!(fmt.resolution_system.contains("recent_repairs:")); assert!(fmt.resolution_system.contains("reason: context_overflow")); assert!(fmt .resolution_system diff --git a/crates/domain/src/application/use_cases/handle_inbound_message.rs b/crates/domain/src/application/use_cases/handle_inbound_message.rs index eb3640fb..7db5b2d5 100644 --- a/crates/domain/src/application/use_cases/handle_inbound_message.rs +++ b/crates/domain/src/application/use_cases/handle_inbound_message.rs @@ -858,6 +858,12 @@ async fn execute_agent_turn( &runtime_watchdog_digest.alerts, observed_at_unix, ); + let mut handoff_tool_repairs = route.recent_tool_repairs.clone(); + if let Some(last_tool_repair) = route.last_tool_repair.clone() { + if !handoff_tool_repairs.contains(&last_tool_repair) { + handoff_tool_repairs.push(last_tool_repair); + } + } route.last_tool_repair = None; route.recent_tool_repairs.clear(); @@ -872,6 +878,7 @@ async fn execute_agent_turn( recalled_entries: &[], session_matches: &[], run_recipes: &[], + recent_tool_repairs: &handoff_tool_repairs, }); if let Some(packet) = handoff_packet.as_ref() { route.handoff_artifacts = diff --git a/docs/fork/ipc-phase4_10-plan.md b/docs/fork/ipc-phase4_10-plan.md index be42a705..b8c31c79 100644 --- a/docs/fork/ipc-phase4_10-plan.md +++ b/docs/fork/ipc-phase4_10-plan.md @@ -1700,8 +1700,13 @@ Expected outcome: - route-admission context now consumes the bounded recent-admission ledger, not only the last admission state; web and channel use the same helper to surface distinct recent reasons plus the latest recommended repair action + - structured session handoff packets now carry bounded `recent_repairs` + derived from typed `ToolRepairTrace` entries, and both live web/agent and + channel blocked-turn paths feed that ledger into the same handoff schema + - helper-agent handoff schemas for `delegate` and `agents_spawn` now accept + the same bounded `recent_repairs` field, so tool self-repair state can + survive typed handoff without free-form prompt archaeology - remaining: - - broaden repair-ledger consumers beyond current route/help/operator surfaces - keep auditing provider-specific opaque error strings that do not expose a typed status/source error yet - expected outcome: From 36a90c71e53cda6d95eb0abb6e6bfbf69378722c Mon Sep 17 00:00:00 2001 From: panviktor <35385675+panviktor@users.noreply.github.com> Date: Sun, 12 Apr 2026 21:13:07 +0200 Subject: [PATCH 12/30] Add generic relationship memory regressions --- .../core/src/memory_adapters/entity_extractor.rs | 16 ++++++++++++++++ .../services/memory_quality_governor.rs | 13 +++++++++++++ docs/fork/ipc-phase4_10-plan.md | 4 ++++ 3 files changed, 33 insertions(+) diff --git a/crates/adapters/core/src/memory_adapters/entity_extractor.rs b/crates/adapters/core/src/memory_adapters/entity_extractor.rs index 71b30895..9468a07c 100644 --- a/crates/adapters/core/src/memory_adapters/entity_extractor.rs +++ b/crates/adapters/core/src/memory_adapters/entity_extractor.rs @@ -386,6 +386,22 @@ mod tests { assert!(!should_store_relationship(&rel, &entity_types)); } + #[test] + fn generic_children_parent_relationship_is_not_stored_when_marked_as_concepts() { + let entity_types = std::collections::HashMap::from([ + ("children".to_string(), "concept".to_string()), + ("parents".to_string(), "concept".to_string()), + ]); + let rel = ExtractedRelationship { + subject: "Children".into(), + predicate: "learn_from".into(), + object: "Parents".into(), + confidence: 1.0, + }; + + assert!(!should_store_relationship(&rel, &entity_types)); + } + #[test] fn concrete_relationships_still_pass_filter() { let entity_types = std::collections::HashMap::from([ diff --git a/crates/domain/src/application/services/memory_quality_governor.rs b/crates/domain/src/application/services/memory_quality_governor.rs index d83b8dc3..5f734c13 100644 --- a/crates/domain/src/application/services/memory_quality_governor.rs +++ b/crates/domain/src/application/services/memory_quality_governor.rs @@ -441,6 +441,19 @@ mod tests { ); } + #[test] + fn rejects_generic_children_parent_relationship_when_marked_as_concepts() { + let entity_types = HashMap::from([ + ("children".to_string(), "concept".to_string()), + ("parents".to_string(), "concept".to_string()), + ]); + + assert_eq!( + assess_extracted_relationship("Children", "learn_from", "Parents", 0.99, &entity_types), + RelationshipStorageVerdict::Reject(RelationshipRejectReason::AbstractConceptPair) + ); + } + #[test] fn accepts_concrete_relationship() { let entity_types = HashMap::from([ diff --git a/docs/fork/ipc-phase4_10-plan.md b/docs/fork/ipc-phase4_10-plan.md index b8c31c79..d1752fc5 100644 --- a/docs/fork/ipc-phase4_10-plan.md +++ b/docs/fork/ipc-phase4_10-plan.md @@ -1830,6 +1830,10 @@ Expected outcome: - graph extraction now rejects all concept-to-concept relationships at the domain governor layer, so high-confidence generic world-knowledge edges cannot bypass memory hygiene just because the extractor sounded certain + - explicit domain + extractor regressions cover the previously observed + `Children learn_from Parents` graph-noise shape through the generic + concept-to-concept stop-line, without adding phrase-specific runtime + filters - focused cheap-route long-dialogue semantic regression passed after the selector fix: - session: `phase410-long-semantic-focused-1775951001` From 2cd1225cd56e1c9da37626a56ca46a92325cbae5 Mon Sep 17 00:00:00 2001 From: panviktor <35385675+panviktor@users.noreply.github.com> Date: Sun, 12 Apr 2026 21:35:43 +0200 Subject: [PATCH 13/30] Resolve default model route from catalog --- crates/adapters/core/src/agent/agent.rs | 22 ++++-- .../core/src/agent/runtime_loop/cli_run.rs | 45 ++++++----- crates/adapters/core/src/channels/mod.rs | 40 +++++----- crates/adapters/core/src/daemon/mod.rs | 28 +++++-- crates/adapters/core/src/gateway/mod.rs | 24 ++++-- crates/adapters/infra/src/config_io.rs | 8 +- crates/adapters/onboard/src/wizard.rs | 75 ++++++++++++------- crates/domain/data/model_catalog.json | 1 + crates/domain/src/config/model_catalog.rs | 54 +++++++++++++ crates/domain/src/config/schema.rs | 10 ++- docs/fork/ipc-phase4_10-plan.md | 17 +++-- docs/reference/api/config-reference.md | 6 +- 12 files changed, 231 insertions(+), 99 deletions(-) diff --git a/crates/adapters/core/src/agent/agent.rs b/crates/adapters/core/src/agent/agent.rs index d207689d..f5a21b1a 100644 --- a/crates/adapters/core/src/agent/agent.rs +++ b/crates/adapters/core/src/agent/agent.rs @@ -1258,15 +1258,22 @@ impl Agent { bootstrap::ensure_core_blocks_seeded(memory.as_ref(), &resolved_agent_id, &files).await; } - let provider_name = config.default_provider.as_deref().unwrap_or("openrouter"); + let provider_name = config + .default_provider + .clone() + .or_else(|| { + synapse_domain::config::model_catalog::default_provider().map(str::to_string) + }) + .context("no default provider configured and model catalog has no default preset")?; + let provider_name = provider_name.as_str(); let model_name = config .default_model .as_deref() - .unwrap_or_else(|| { + .or_else(|| { synapse_domain::config::model_catalog::provider_default_model(provider_name) - .unwrap_or("default") }) + .with_context(|| format!("no default model configured for provider '{provider_name}'"))? .to_string(); let provider_runtime_options = @@ -3016,16 +3023,17 @@ pub async fn run_with_memory( let provider_name = effective_config .default_provider - .as_deref() - .unwrap_or("openrouter") + .clone() + .or_else(|| synapse_domain::config::model_catalog::default_provider().map(str::to_string)) + .context("no default provider configured and model catalog has no default preset")? .to_string(); let model_name = effective_config .default_model .as_deref() - .unwrap_or_else(|| { + .or_else(|| { synapse_domain::config::model_catalog::provider_default_model(provider_name.as_str()) - .unwrap_or("default") }) + .with_context(|| format!("no default model configured for provider '{provider_name}'"))? .to_string(); agent.observer.record_event(&ObserverEvent::AgentStart { diff --git a/crates/adapters/core/src/agent/runtime_loop/cli_run.rs b/crates/adapters/core/src/agent/runtime_loop/cli_run.rs index 4eff8a26..6e5d25a5 100644 --- a/crates/adapters/core/src/agent/runtime_loop/cli_run.rs +++ b/crates/adapters/core/src/agent/runtime_loop/cli_run.rs @@ -3,6 +3,7 @@ #[allow(unused_imports)] use super::tool_execution::{agent_turn, is_tool_loop_cancelled}; use super::*; +use anyhow::Context; #[allow(clippy::too_many_lines)] pub async fn run( @@ -270,17 +271,18 @@ pub async fn run_with_shared_memory( // ── Resolve provider ───────────────────────────────────────── let provider_name = provider_override - .as_deref() - .or(config.default_provider.as_deref()) - .unwrap_or("openrouter"); + .clone() + .or_else(|| config.default_provider.clone()) + .or_else(|| synapse_domain::config::model_catalog::default_provider().map(str::to_string)) + .context("no default provider configured and model catalog has no default preset")?; let model_name = model_override .as_deref() .or(config.default_model.as_deref()) - .unwrap_or_else(|| { - synapse_domain::config::model_catalog::provider_default_model(provider_name) - .unwrap_or("default") - }); + .or_else(|| { + synapse_domain::config::model_catalog::provider_default_model(provider_name.as_str()) + }) + .with_context(|| format!("no default model configured for provider '{provider_name}'"))?; let provider_runtime_options = synapse_providers::provider_runtime_options_from_config(&config); let router_routes = @@ -289,7 +291,7 @@ pub async fn run_with_shared_memory( ); let provider: Box = synapse_providers::create_routed_provider_with_options( - provider_name, + &provider_name, config.api_key.as_deref(), config.api_url.as_deref(), &config.reliability, @@ -481,7 +483,7 @@ pub async fn run_with_shared_memory( &mut history, &tools_registry, observer.as_ref(), - provider_name, + &provider_name, model_name, temperature, false, @@ -642,7 +644,7 @@ pub async fn run_with_shared_memory( &mut history, &tools_registry, observer.as_ref(), - provider_name, + &provider_name, model_name, temperature, false, @@ -843,19 +845,26 @@ pub async fn process_message( } } - let provider_name = config.default_provider.as_deref().unwrap_or("openrouter"); - let model_name = config.default_model.clone().unwrap_or_else(|| { - synapse_domain::config::model_catalog::provider_default_model(provider_name) - .unwrap_or("default") - .to_string() - }); + let provider_name = config + .default_provider + .clone() + .or_else(|| synapse_domain::config::model_catalog::default_provider().map(str::to_string)) + .context("no default provider configured and model catalog has no default preset")?; + let model_name = config + .default_model + .clone() + .or_else(|| { + synapse_domain::config::model_catalog::provider_default_model(provider_name.as_str()) + .map(str::to_string) + }) + .with_context(|| format!("no default model configured for provider '{provider_name}'"))?; let provider_runtime_options = synapse_providers::provider_runtime_options_from_config(&config); let router_routes = synapse_domain::application::services::model_preset_resolution::provider_router_routes( &config, ); let provider: Box = synapse_providers::create_routed_provider_with_options( - provider_name, + &provider_name, config.api_key.as_deref(), config.api_url.as_deref(), &config.reliability, @@ -940,7 +949,7 @@ pub async fn process_message( &mut history, &tools_registry, observer.as_ref(), - provider_name, + &provider_name, &model_name, config.default_temperature, true, diff --git a/crates/adapters/core/src/channels/mod.rs b/crates/adapters/core/src/channels/mod.rs index 0b188015..ea72e20d 100644 --- a/crates/adapters/core/src/channels/mod.rs +++ b/crates/adapters/core/src/channels/mod.rs @@ -291,31 +291,35 @@ fn parse_runtime_command( ) } -fn resolved_default_provider(config: &Config) -> String { +fn resolved_default_provider(config: &Config) -> Result { config .default_provider .clone() - .unwrap_or_else(|| "openrouter".to_string()) + .or_else(|| synapse_domain::config::model_catalog::default_provider().map(str::to_string)) + .context("no default provider configured and model catalog has no default preset") } -fn resolved_default_model(config: &Config) -> String { - let provider = resolved_default_provider(config); - config.default_model.clone().unwrap_or_else(|| { - synapse_domain::config::model_catalog::provider_default_model(provider.as_str()) - .unwrap_or("default") - .to_string() - }) +fn resolved_default_model(config: &Config) -> Result { + let provider = resolved_default_provider(config)?; + config + .default_model + .clone() + .or_else(|| { + synapse_domain::config::model_catalog::provider_default_model(provider.as_str()) + .map(str::to_string) + }) + .with_context(|| format!("no default model configured for provider '{provider}'")) } -fn runtime_defaults_from_config(config: &Config) -> ChannelRuntimeDefaults { - ChannelRuntimeDefaults { - default_provider: resolved_default_provider(config), - model: resolved_default_model(config), +fn runtime_defaults_from_config(config: &Config) -> Result { + Ok(ChannelRuntimeDefaults { + default_provider: resolved_default_provider(config)?, + model: resolved_default_model(config)?, temperature: config.default_temperature, api_key: config.api_key.clone(), api_url: config.api_url.clone(), reliability: config.reliability.clone(), - } + }) } fn runtime_config_path(ctx: &ChannelRuntimeContext) -> Option { @@ -407,7 +411,7 @@ async fn load_runtime_defaults_from_config_file(path: &Path) -> Result ChannelRouteSelection { @@ -2325,7 +2329,7 @@ pub async fn start_channels( ) }; - let provider_name = resolved_default_provider(&config); + let provider_name = resolved_default_provider(&config)?; let provider_runtime_options = synapse_providers::ProviderRuntimeOptions { auth_profile_override: None, provider_api_url: config.api_url.clone(), @@ -2363,7 +2367,7 @@ pub async fn start_channels( store.insert( config.config_path.clone(), RuntimeConfigState { - defaults: runtime_defaults_from_config(&config), + defaults: runtime_defaults_from_config(&config)?, last_applied_stamp: initial_stamp, }, ); @@ -2378,7 +2382,7 @@ pub async fn start_channels( &config.autonomy, &config.workspace_dir, )); - let model = resolved_default_model(&config); + let model = resolved_default_model(&config)?; let temperature = config.default_temperature; let resolved_agent_id = crate::agent::resolve_agent_id(&config); let mem: Arc = match shared_memory { diff --git a/crates/adapters/core/src/daemon/mod.rs b/crates/adapters/core/src/daemon/mod.rs index dcac5978..648ad37b 100644 --- a/crates/adapters/core/src/daemon/mod.rs +++ b/crates/adapters/core/src/daemon/mod.rs @@ -446,20 +446,32 @@ pub async fn run( // ── Phase 4.3: Memory consolidation worker ──────────────────── { // Wrap with ConsolidatingMemory if provider available. - let consolidation_model = config.default_model.clone().unwrap_or_else(|| { - synapse_domain::config::model_catalog::provider_default_model( - config.default_provider.as_deref().unwrap_or("openrouter"), - ) - .unwrap_or("default") - .to_string() - }); + let default_provider = config + .default_provider + .clone() + .or_else(|| { + synapse_domain::config::model_catalog::default_provider().map(str::to_string) + }) + .context("no default provider configured and model catalog has no default preset")?; + let consolidation_model = config + .default_model + .clone() + .or_else(|| { + synapse_domain::config::model_catalog::provider_default_model( + default_provider.as_str(), + ) + .map(str::to_string) + }) + .with_context(|| { + format!("no default model configured for provider '{default_provider}'") + })?; let provider_for_worker: Option<( std::sync::Arc, String, )>; let mem: std::sync::Arc = match synapse_providers::create_resilient_provider( - config.default_provider.as_deref().unwrap_or("openrouter"), + default_provider.as_str(), config.api_key.as_deref(), config.api_url.as_deref(), &config.reliability, diff --git a/crates/adapters/core/src/gateway/mod.rs b/crates/adapters/core/src/gateway/mod.rs index 5193797a..ce5501d3 100644 --- a/crates/adapters/core/src/gateway/mod.rs +++ b/crates/adapters/core/src/gateway/mod.rs @@ -494,9 +494,14 @@ pub async fn run_gateway( let actual_port = listener.local_addr()?.port(); let display_addr = format!("{host}:{actual_port}"); + let default_provider = config + .default_provider + .clone() + .or_else(|| synapse_domain::config::model_catalog::default_provider().map(str::to_string)) + .context("no default provider configured and model catalog has no default preset")?; let provider: Arc = Arc::from(synapse_providers::create_resilient_provider_with_options( - config.default_provider.as_deref().unwrap_or("openrouter"), + default_provider.as_str(), config.api_key.as_deref(), config.api_url.as_deref(), &config.reliability, @@ -513,13 +518,16 @@ pub async fn run_gateway( prompt_caching: config.agent.prompt_caching, }, )?); - let model = config.default_model.clone().unwrap_or_else(|| { - synapse_domain::config::model_catalog::provider_default_model( - config.default_provider.as_deref().unwrap_or("openrouter"), - ) - .unwrap_or("default") - .to_string() - }); + let model = config + .default_model + .clone() + .or_else(|| { + synapse_domain::config::model_catalog::provider_default_model(default_provider.as_str()) + .map(str::to_string) + }) + .with_context(|| { + format!("no default model configured for provider '{default_provider}'") + })?; let summary_model = config.summary_model.clone(); let temperature = config.default_temperature; let resolved_agent_id = crate::agent::resolve_agent_id(&config); diff --git a/crates/adapters/infra/src/config_io.rs b/crates/adapters/infra/src/config_io.rs index 511d0a68..cb6c5f59 100644 --- a/crates/adapters/infra/src/config_io.rs +++ b/crates/adapters/infra/src/config_io.rs @@ -7,6 +7,7 @@ use directories::UserDirs; #[allow(unused_imports)] use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; +use synapse_domain::config::model_catalog; use synapse_domain::config::provider_aliases::{is_glm_alias, is_zai_alias}; use synapse_domain::config::schema::*; use synapse_security::DomainMatcher; @@ -1425,7 +1426,12 @@ impl ConfigIO for Config { let should_apply_legacy_provider = self .default_provider .as_deref() - .is_none_or(|configured| configured.trim().eq_ignore_ascii_case("openrouter")); + .map(str::trim) + .filter(|configured| !configured.is_empty()) + .is_none_or(|configured| { + model_catalog::default_provider() + .is_some_and(|default| configured.eq_ignore_ascii_case(default)) + }); if should_apply_legacy_provider && !provider.is_empty() { self.default_provider = Some(provider); } diff --git a/crates/adapters/onboard/src/wizard.rs b/crates/adapters/onboard/src/wizard.rs index 92290942..ffe950d3 100644 --- a/crates/adapters/onboard/src/wizard.rs +++ b/crates/adapters/onboard/src/wizard.rs @@ -11,6 +11,7 @@ use synapse_domain::application::services::model_preset_resolution::{ preset_title, recommended_model_preset_for_provider, }; use synapse_domain::config::model_catalog::{ + default_provider as bundled_default_provider, provider_curated_models as bundled_provider_curated_models, provider_default_model as bundled_provider_default_model, }; @@ -482,10 +483,15 @@ async fn run_quick_setup_with_home( .await .context("Failed to create workspace directory")?; - let provider_name = provider.unwrap_or("openrouter").to_string(); + let provider_name = provider + .map(str::to_string) + .or_else(|| bundled_default_provider().map(str::to_string)) + .context("model catalog has no default provider")? + .to_string(); let model = model_override .map(str::to_string) - .unwrap_or_else(|| default_model_for_provider(&provider_name)); + .map(Ok) + .unwrap_or_else(|| default_model_for_provider(&provider_name))?; let memory_backend_name = memory_backend.unwrap_or("surrealdb").to_string(); // Create memory config based on backend choice @@ -719,15 +725,15 @@ fn allows_unauthenticated_model_fetch(provider_name: &str) -> bool { ) } -fn default_model_for_provider(provider: &str) -> String { - bundled_provider_default_model(canonical_provider_name(provider)) - .unwrap_or("default") - .to_string() +fn default_model_for_provider(provider: &str) -> Result { + let canonical = canonical_provider_name(provider); + bundled_provider_default_model(canonical) + .map(str::to_string) + .with_context(|| format!("model catalog has no default model for provider '{canonical}'")) } fn curated_models_for_provider(provider_name: &str) -> Vec<(String, String)> { - bundled_provider_curated_models(canonical_provider_name(provider_name)) - .unwrap_or_else(|| vec![("default".to_string(), "Default model".to_string())]) + bundled_provider_curated_models(canonical_provider_name(provider_name)).unwrap_or_default() } fn supports_live_model_fetch(provider_name: &str) -> bool { @@ -1656,8 +1662,10 @@ pub async fn run_models_refresh( force: bool, ) -> Result<()> { let provider_name = provider_override - .or(config.default_provider.as_deref()) - .unwrap_or("openrouter") + .map(str::to_string) + .or_else(|| config.default_provider.clone()) + .or_else(|| bundled_default_provider().map(str::to_string)) + .context("model catalog has no default provider")? .trim() .to_string(); @@ -1758,12 +1766,14 @@ pub async fn run_models_refresh( pub async fn run_models_list(config: &Config, provider_override: Option<&str>) -> Result<()> { let provider_name = provider_override - .or(config.default_provider.as_deref()) - .unwrap_or("openrouter"); + .map(str::to_string) + .or_else(|| config.default_provider.clone()) + .or_else(|| bundled_default_provider().map(str::to_string)) + .context("model catalog has no default provider")?; let cached = load_any_cached_models_for_provider( &config.workspace_dir, - provider_name, + &provider_name, config.api_url.as_deref(), ) .await?; @@ -1814,11 +1824,15 @@ pub async fn run_models_set(config: &Config, model: &str) -> Result<()> { } pub async fn run_models_status(config: &Config) -> Result<()> { - let provider = config.default_provider.as_deref().unwrap_or("openrouter"); + let provider = config + .default_provider + .clone() + .or_else(|| bundled_default_provider().map(str::to_string)) + .context("model catalog has no default provider")?; let model = config.default_model.as_deref().unwrap_or("(not set)"); println!(); - println!(" Provider: {}", style(provider).cyan()); + println!(" Provider: {}", style(provider.as_str()).cyan()); println!(" Model: {}", style(model).cyan()); println!( " Temp: {}", @@ -1827,7 +1841,7 @@ pub async fn run_models_status(config: &Config) -> Result<()> { match load_any_cached_models_for_provider( &config.workspace_dir, - provider, + &provider, config.api_url.as_deref(), ) .await? @@ -2202,8 +2216,11 @@ async fn setup_provider(workspace_dir: &Path) -> Result<(String, String, String, let model: String = Input::new() .with_prompt(" Model name (enter any provider model id)") - .default("default".into()) .interact_text()?; + let model = model.trim().to_string(); + if model.is_empty() { + anyhow::bail!("Custom provider requires an explicit model id."); + } let provider_name = format!("custom:{base_url}"); @@ -2761,7 +2778,7 @@ async fn setup_provider(workspace_dir: &Path) -> Result<(String, String, String, if model_options.is_empty() { model_options.push(( - default_model_for_provider(provider_name_ref), + default_model_for_provider(provider_name_ref)?, "Provider default model".to_string(), )); } @@ -2786,7 +2803,7 @@ async fn setup_provider(workspace_dir: &Path) -> Result<(String, String, String, let model = if selected_model == CUSTOM_MODEL_SENTINEL { Input::new() .with_prompt(" Enter custom model ID") - .default(default_model_for_provider(provider_name_ref)) + .default(default_model_for_provider(provider_name_ref)?) .interact_text()? } else { selected_model @@ -5398,7 +5415,11 @@ fn print_summary(config: &Config) { println!( " {} Provider: {}", style("🤖").cyan(), - config.default_provider.as_deref().unwrap_or("openrouter") + config + .default_provider + .clone() + .or_else(|| bundled_default_provider().map(str::to_string)) + .unwrap_or_else(|| "(not set)".to_string()) ); println!( " {} Model: {}", @@ -5492,8 +5513,12 @@ fn print_summary(config: &Config) { let mut step = 1u8; - let provider = config.default_provider.as_deref().unwrap_or("openrouter"); - if config.api_key.is_none() && !provider_supports_keyless_local_usage(provider) { + let provider = config + .default_provider + .clone() + .or_else(|| bundled_default_provider().map(str::to_string)) + .unwrap_or_else(|| "(not set)".to_string()); + if config.api_key.is_none() && !provider_supports_keyless_local_usage(&provider) { if provider == "openai-codex" { println!( " {} Authenticate OpenAI Codex:", @@ -5520,7 +5545,7 @@ fn print_summary(config: &Config) { .yellow() ); } else { - let env_var = provider_env_var(provider); + let env_var = provider_env_var(&provider); println!( " {} Set your API key:", style(format!("{step}.")).cyan().bold() @@ -5728,7 +5753,7 @@ mod tests { .await .unwrap(); - let expected = default_model_for_provider("anthropic"); + let expected = default_model_for_provider("anthropic").unwrap(); assert_eq!(config.default_provider.as_deref(), Some("anthropic")); assert_eq!(config.default_model.as_deref(), Some(expected.as_str())); } @@ -6299,7 +6324,7 @@ mod tests { let canonical = canonical_provider_name(provider_name); let expected = bundled_provider_default_model(canonical) .unwrap_or_else(|| panic!("missing bundled default model for {canonical}")); - assert_eq!(default_model_for_provider(provider_name), expected); + assert_eq!(default_model_for_provider(provider_name).unwrap(), expected); assert!( curated_model_ids(provider_name).contains(&expected.to_string()), "curated models for {provider_name} should include catalog default {expected}" diff --git a/crates/domain/data/model_catalog.json b/crates/domain/data/model_catalog.json index b50e1db2..e46afb81 100644 --- a/crates/domain/data/model_catalog.json +++ b/crates/domain/data/model_catalog.json @@ -1,4 +1,5 @@ { + "default_preset": "chatgpt", "presets": [ { "id": "chatgpt", diff --git a/crates/domain/src/config/model_catalog.rs b/crates/domain/src/config/model_catalog.rs index 8a4a5e46..f4daf8a1 100644 --- a/crates/domain/src/config/model_catalog.rs +++ b/crates/domain/src/config/model_catalog.rs @@ -25,6 +25,8 @@ pub struct CuratedModelDefinition { #[derive(Debug, Clone, Deserialize)] struct ModelCatalogData { + #[serde(default)] + default_preset: Option, #[serde(default)] presets: Vec, #[serde(default)] @@ -208,6 +210,10 @@ fn merge_catalog_data( ) -> ModelCatalogData { let mut merged = base.clone(); + if override_data.default_preset.is_some() { + merged.default_preset = override_data.default_preset; + } + for preset in override_data.presets { if let Some(existing) = merged.presets.iter_mut().find(|item| item.id == preset.id) { *existing = preset; @@ -315,6 +321,42 @@ pub fn known_model_presets() -> &'static [KnownModelPreset] { active_model_catalog().presets_view.as_slice() } +fn default_preset_entry(catalog: &ParsedModelCatalog) -> Option<&ModelPresetCatalogEntry> { + catalog + .data + .default_preset + .as_deref() + .and_then(|id| { + let id = id.trim(); + (!id.is_empty()).then_some(id) + }) + .and_then(|id| { + catalog + .data + .presets + .iter() + .find(|preset| preset.id.eq_ignore_ascii_case(id)) + }) + .or_else(|| catalog.data.presets.first()) +} + +pub fn default_model_preset_id() -> Option<&'static str> { + default_preset_entry(active_model_catalog()).map(|preset| preset.id.as_str()) +} + +pub fn default_reasoning_seed() -> Option<(&'static str, &'static str)> { + default_preset_entry(active_model_catalog()).map(|preset| { + ( + preset.default_provider.as_str(), + preset.default_model.as_str(), + ) + }) +} + +pub fn default_provider() -> Option<&'static str> { + default_reasoning_seed().map(|(provider, _)| provider) +} + pub fn preset_title(preset_id: &str) -> Option<&'static str> { active_model_catalog() .presets_view @@ -636,6 +678,16 @@ mod tests { assert!(presets.iter().any(|preset| preset.id == "openrouter")); } + #[test] + fn default_route_comes_from_catalog_default_preset() { + assert_eq!(default_model_preset_id(), Some("chatgpt")); + assert_eq!(default_reasoning_seed(), preset_reasoning_seed("chatgpt")); + assert_eq!( + default_provider(), + preset_reasoning_seed("chatgpt").map(|(provider, _)| provider) + ); + } + #[test] fn preset_aliases_come_from_catalog_data() { assert_eq!(normalize_model_preset_id("codex"), Some("chatgpt")); @@ -782,6 +834,7 @@ mod tests { fn override_catalog_can_replace_provider_defaults() { let override_data = parse_model_catalog_data( r#"{ + "default_preset": "openrouter", "providers": [ { "provider": "openai-codex", @@ -831,6 +884,7 @@ mod tests { .expect("override should parse"); let merged = merge_catalog_data(&bundled_model_catalog().data, override_data); + assert_eq!(merged.default_preset.as_deref(), Some("openrouter")); let openai = merged .providers .iter() diff --git a/crates/domain/src/config/schema.rs b/crates/domain/src/config/schema.rs index 56642dff..23abafa2 100644 --- a/crates/domain/src/config/schema.rs +++ b/crates/domain/src/config/schema.rs @@ -70,7 +70,8 @@ pub struct Config { /// (e.g. "/v2/generate" instead of the default "/v1/chat/completions"). #[serde(default, skip_serializing_if = "Option::is_none")] pub api_path: Option, - /// Default provider ID or alias (e.g. `"openrouter"`, `"ollama"`, `"anthropic"`). Default: `"openrouter"`. + /// Default provider ID or alias (e.g. `"openrouter"`, `"ollama"`, `"anthropic"`). + /// The runtime default is resolved from the model catalog's `default_preset`. #[serde(alias = "model_provider")] pub default_provider: Option, /// Default model routed through the selected provider (e.g. `"provider/model-id"`). @@ -6322,9 +6323,10 @@ impl Default for Config { api_key: None, api_url: None, api_path: None, - default_provider: Some("openrouter".to_string()), - default_model: super::model_catalog::provider_default_model("openrouter") - .map(str::to_string), + default_provider: super::model_catalog::default_reasoning_seed() + .map(|(provider, _)| provider.to_string()), + default_model: super::model_catalog::default_reasoning_seed() + .map(|(_, model)| model.to_string()), summary_model: None, compression: ContextCompressionConfig::default(), compression_overrides: Vec::new(), diff --git a/docs/fork/ipc-phase4_10-plan.md b/docs/fork/ipc-phase4_10-plan.md index d1752fc5..06fe80e2 100644 --- a/docs/fork/ipc-phase4_10-plan.md +++ b/docs/fork/ipc-phase4_10-plan.md @@ -1285,13 +1285,16 @@ Expected outcome: - channel inbound/use-case runtime snapshots no longer carry the legacy route table through the turn path; they pass only model lanes/preset into shared route resolution - - config/API/catalog surfaces now expose user-defined provider-router - shortcuts as `route_aliases`; the old Rust route-table field and catalog - compatibility alias were removed rather than kept as - a second public routing language - - remaining work: - - historical note: earlier remaining items about image/audio/video/music - first-class turn routing are now mostly owned by Slice 14 and the shared + - config/API/catalog surfaces now expose user-defined provider-router + shortcuts as `route_aliases`; the old Rust route-table field and catalog + compatibility alias were removed rather than kept as + a second public routing language + - default provider/model selection now resolves from catalog-owned + `default_preset` instead of Rust fallback strings; runtime paths fail loud + when neither config nor catalog can supply a provider/model + - remaining work: + - historical note: earlier remaining items about image/audio/video/music + first-class turn routing are now mostly owned by Slice 14 and the shared marker/admission path, not by a separate Slice 10 routing fork - provider capability metadata is still narrower than the eventual lane matrix - known request-policy cases for fixed temperature and reasoning effort diff --git a/docs/reference/api/config-reference.md b/docs/reference/api/config-reference.md index 74319a6d..c8d71da4 100644 --- a/docs/reference/api/config-reference.md +++ b/docs/reference/api/config-reference.md @@ -30,7 +30,7 @@ Schema export command: | Key | Default | Notes | |---|---|---| -| `default_provider` | `openrouter` | provider ID or alias | +| `default_provider` | catalog `default_preset` | provider ID or alias | | `default_model` | preset/catalog-dependent | model routed through selected provider | | `default_temperature` | `0.7` | model temperature | @@ -107,12 +107,12 @@ runtime_trace_max_entries = 200 Provider selection can also be controlled by environment variables. Precedence is: 1. `SYNAPSECLAW_PROVIDER` (explicit override, always wins when non-empty) -2. `PROVIDER` (legacy fallback, only applied when config provider is unset or still `openrouter`) +2. `PROVIDER` (legacy fallback, only applied when config provider is unset or still the catalog default provider) 3. `default_provider` in `config.toml` Operational note for container users: -- If your `config.toml` sets an explicit custom provider like `custom:https://.../v1`, a default `PROVIDER=openrouter` from Docker/container env will no longer replace it. +- If your `config.toml` sets an explicit custom provider like `custom:https://.../v1`, a default `PROVIDER=...` from Docker/container env will no longer replace it. - Use `SYNAPSECLAW_PROVIDER` when you intentionally want runtime env to override a non-default configured provider. ## `[agent]` From 2fad85b9b58812ebe36b087b683cfa20e7d460a7 Mon Sep 17 00:00:00 2001 From: panviktor <35385675+panviktor@users.noreply.github.com> Date: Sun, 12 Apr 2026 21:48:43 +0200 Subject: [PATCH 14/30] Render lane-aware model switches first --- .../services/runtime_command_presentation.rs | 52 +++++++++++++------ docs/fork/ipc-phase4_10-plan.md | 6 ++- 2 files changed, 40 insertions(+), 18 deletions(-) diff --git a/crates/domain/src/application/services/runtime_command_presentation.rs b/crates/domain/src/application/services/runtime_command_presentation.rs index be7e5484..e3ee1dd8 100644 --- a/crates/domain/src/application/services/runtime_command_presentation.rs +++ b/crates/domain/src/application/services/runtime_command_presentation.rs @@ -113,16 +113,20 @@ pub fn format_switch_model_success( return "Model ID cannot be empty. Use `/model `.".to_string(); } - let lane_note = format_lane_note(lane, options); - if compacted { - format!( - "Model switched to `{model}` (provider: `{provider}`).{lane_note} Context compacted before switching." - ) + let context_note = if compacted { + "Context compacted before switching." } else { - format!( - "Model switched to `{model}` (provider: `{provider}`).{lane_note} Context preserved." - ) + "Context preserved." + }; + + if let Some(lane) = visible_lane(lane, options) { + return format!( + "Lane `{}` switched to `{provider}:{model}`. {context_note}", + lane.as_str() + ); } + + format!("Model switched to `{model}` (provider: `{provider}`). {context_note}") } pub fn format_switch_model_blocked( @@ -133,15 +137,22 @@ pub fn format_switch_model_blocked( compacted: bool, options: &RuntimeCommandPresentationOptions, ) -> String { - let lane_note = format_lane_note(lane, options); let budget_note = format_context_budget_note(preflight, options.context_budget); let compacted_note = if compacted { " Compaction ran first, but the context is still too large." } else { "" }; + + if let Some(lane) = visible_lane(lane, options) { + return format!( + "Lane `{}` route switch to `{provider}:{model}` blocked. {budget_note}{compacted_note}", + lane.as_str() + ); + } + format!( - "Model switch to `{model}` (provider: `{provider}`) blocked.{lane_note} {budget_note}{compacted_note}" + "Model switch to `{model}` (provider: `{provider}`) blocked. {budget_note}{compacted_note}" ) } @@ -149,16 +160,15 @@ pub fn format_clear_session_response() -> String { "Conversation history cleared. Starting fresh.".to_string() } -fn format_lane_note( +fn visible_lane( lane: Option, options: &RuntimeCommandPresentationOptions, -) -> String { +) -> Option { if !options.show_lane { - return String::new(); + return None; } - lane.map(|lane| format!(" Lane: `{}`.", lane.as_str())) - .unwrap_or_default() + lane } fn format_context_budget_note( @@ -204,8 +214,15 @@ mod tests { &options, ); - assert!(response.contains("Lane: `multimodal_understanding`")); + assert!(response + .starts_with("Lane `multimodal_understanding` switched to `openrouter:vision-model`.")); assert!(response.contains("Context compacted before switching")); + assert!( + response.find("Lane `").expect("lane first") + < response + .find("openrouter:vision-model") + .expect("route target") + ); } #[test] @@ -228,7 +245,8 @@ mod tests { &options, ); - assert!(response.contains("Lane: `reasoning`")); + assert!(response + .starts_with("Lane `reasoning` route switch to `openrouter:tiny-model` blocked.")); assert!(response.contains("safe context budget is ~3000 tokens")); assert!(response.contains("Target window: ~4000 tokens")); assert!(response.contains("Reserved output: ~1000 tokens")); diff --git a/docs/fork/ipc-phase4_10-plan.md b/docs/fork/ipc-phase4_10-plan.md index 06fe80e2..6fe46198 100644 --- a/docs/fork/ipc-phase4_10-plan.md +++ b/docs/fork/ipc-phase4_10-plan.md @@ -1589,10 +1589,14 @@ Expected outcome: explicitly advertises the corresponding `ModelFeature`; universal models can therefore supply image/audio/video/music lanes without natural-language phrase detection or Rust model-id match arms + - shared runtime command presentation now renders lane-aware model switch + outcomes as `lane -> provider:model`; web and channel receive the same + lane-first success/block text through the common runtime adapter contract - remaining: - modality inference remains conservative and marker-based; richer intent recognition should come later through typed interpretation or an explicit classifier, not keyword lists - - more route/runtime UX should render lane identity first and `provider/model` second + - keep auditing route/runtime UX beyond model-switch outcomes for lane-first + wording where the runtime already has a typed lane decision - status note: do not reintroduce keyword phrase lists for media detection; until typed interpretation exists, structured markers are the accepted deterministic interface. From f322ef3963a601c7a922754436d119080952911d Mon Sep 17 00:00:00 2001 From: panviktor <35385675+panviktor@users.noreply.github.com> Date: Sun, 12 Apr 2026 21:53:49 +0200 Subject: [PATCH 15/30] Share admission block presentation --- crates/adapters/core/src/agent/agent.rs | 32 ++-- crates/domain/src/application/services/mod.rs | 1 + .../runtime_admission_presentation.rs | 139 ++++++++++++++++++ .../use_cases/handle_inbound_message.rs | 128 +--------------- docs/fork/ipc-phase4_10-plan.md | 3 + 5 files changed, 158 insertions(+), 145 deletions(-) create mode 100644 crates/domain/src/application/services/runtime_admission_presentation.rs diff --git a/crates/adapters/core/src/agent/agent.rs b/crates/adapters/core/src/agent/agent.rs index f5a21b1a..0bd33e82 100644 --- a/crates/adapters/core/src/agent/agent.rs +++ b/crates/adapters/core/src/agent/agent.rs @@ -44,6 +44,7 @@ use synapse_domain::application::services::route_admission_history::{ use synapse_domain::application::services::route_switch_preflight::{ assess_route_switch_preflight_for_budget, RouteSwitchPreflightResolution, }; +use synapse_domain::application::services::runtime_admission_presentation::format_blocked_turn_admission_response; use synapse_domain::application::services::runtime_assumptions::{ apply_tool_repair_assumption_challenges, build_runtime_assumptions, challenge_runtime_assumption_ledger, merge_runtime_assumption_ledger, RuntimeAssumption, @@ -2588,26 +2589,19 @@ impl Agent { recent_tool_repairs: &handoff_tool_repairs, }, ); - let handoff_packet_text = handoff_packet - .as_ref() - .map(|packet| { - self.recent_runtime_handoff_artifacts = append_runtime_handoff_packet( - &self.recent_runtime_handoff_artifacts, - packet, - observed_at_unix, - ); - synapse_domain::application::services::session_handoff::format_session_handoff_packet( - packet, - ) - }) - .unwrap_or_default(); + if let Some(packet) = handoff_packet.as_ref() { + self.recent_runtime_handoff_artifacts = append_runtime_handoff_packet( + &self.recent_runtime_handoff_artifacts, + packet, + observed_at_unix, + ); + } anyhow::bail!( - "turn admission blocked provider call intent={} pressure={} provider={} model={}\n{}", - turn_intent_name(admission_decision.snapshot.intent), - context_pressure_state_name(admission_decision.snapshot.pressure_state), - effective_provider, - effective_model, - handoff_packet_text + "{}", + format_blocked_turn_admission_response( + &admission_decision, + handoff_packet.as_ref(), + ) ); } let mut messages = snapshot.messages; diff --git a/crates/domain/src/application/services/mod.rs b/crates/domain/src/application/services/mod.rs index 766ffffc..30697d19 100644 --- a/crates/domain/src/application/services/mod.rs +++ b/crates/domain/src/application/services/mod.rs @@ -51,6 +51,7 @@ pub mod route_admission_history; pub mod route_switch_preflight; pub mod run_recipe_cluster_service; pub mod run_recipe_review_service; +pub mod runtime_admission_presentation; pub mod runtime_assumptions; pub mod runtime_calibration; pub mod runtime_command_presentation; diff --git a/crates/domain/src/application/services/runtime_admission_presentation.rs b/crates/domain/src/application/services/runtime_admission_presentation.rs new file mode 100644 index 00000000..13ab2d00 --- /dev/null +++ b/crates/domain/src/application/services/runtime_admission_presentation.rs @@ -0,0 +1,139 @@ +use crate::application::services::session_handoff::{ + format_session_handoff_packet, SessionHandoffPacket, +}; +use crate::application::services::turn_admission::CandidateAdmissionDecision; +use crate::domain::turn_admission::{ + turn_intent_name, AdmissionRepairHint, CandidateAdmissionReason, ContextPressureState, +}; + +pub fn format_blocked_turn_admission_response( + decision: &CandidateAdmissionDecision, + handoff_packet: Option<&SessionHandoffPacket>, +) -> String { + let base = if let Some(AdmissionRepairHint::RefreshCapabilityMetadata(lane)) = + decision.recommended_action + { + format!( + "Lane `{}` capability metadata is stale or low-confidence on the current route. Refresh model profiles or switch to a compatible lane and try again.", + lane.as_str() + ) + } else if decision.snapshot.pressure_state == ContextPressureState::OverflowRisk { + match decision.recommended_action { + Some(AdmissionRepairHint::StartFreshHandoff) => { + "This turn is too large for the current route's safe context budget. Start a fresh handoff or switch to a larger-context model.".into() + } + _ => { + "This turn is too large for the current route's safe context budget. Compact the session first or switch to a larger-context model.".into() + } + } + } else if decision.reasons.iter().any(is_stale_or_unknown_metadata) { + "The current route has incomplete capability metadata for this turn. Refresh model profiles or switch to a compatible lane and try again.".into() + } else if let Some(lane) = decision.required_lane { + format!( + "Turn intent `{}` requires lane `{}`, but the current route cannot satisfy it. Switch to a compatible lane and try again.", + turn_intent_name(decision.snapshot.intent), + lane.as_str() + ) + } else { + "The current route cannot safely execute this turn. Switch to a compatible lane or start a fresh handoff.".into() + }; + + if let Some(packet) = handoff_packet { + format!("{base}\n\n{}", format_session_handoff_packet(packet)) + } else { + base + } +} + +fn is_stale_or_unknown_metadata(reason: &CandidateAdmissionReason) -> bool { + matches!( + reason, + CandidateAdmissionReason::CapabilityMetadataUnknown(_) + | CandidateAdmissionReason::CapabilityMetadataLowConfidence(_) + | CandidateAdmissionReason::CapabilityMetadataStale(_) + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::schema::CapabilityLane; + use crate::domain::turn_admission::{ + AdmissionRepairHint, CandidateAdmissionReason, ContextPressureState, TurnAdmissionAction, + TurnAdmissionSnapshot, TurnIntentCategory, + }; + + fn blocked_decision( + intent: TurnIntentCategory, + pressure_state: ContextPressureState, + ) -> CandidateAdmissionDecision { + CandidateAdmissionDecision { + snapshot: TurnAdmissionSnapshot { + intent, + pressure_state, + action: TurnAdmissionAction::Block, + }, + required_lane: None, + route_override: None, + reasons: Vec::new(), + recommended_action: None, + condensation_plan: None, + requires_compaction: false, + } + } + + #[test] + fn blocked_turn_response_mentions_stale_or_low_confidence_metadata() { + let mut decision = blocked_decision( + TurnIntentCategory::ImageGeneration, + ContextPressureState::Warning, + ); + decision.required_lane = Some(CapabilityLane::ImageGeneration); + decision.reasons = vec![CandidateAdmissionReason::CapabilityMetadataLowConfidence( + CapabilityLane::ImageGeneration, + )]; + decision.recommended_action = Some(AdmissionRepairHint::RefreshCapabilityMetadata( + CapabilityLane::ImageGeneration, + )); + + let response = format_blocked_turn_admission_response(&decision, None); + + assert!(response.starts_with("Lane `image_generation`")); + assert!(response.contains("stale or low-confidence")); + assert!(response.contains("Refresh model profiles")); + } + + #[test] + fn blocked_turn_response_mentions_required_lane_for_modality() { + let mut decision = blocked_decision( + TurnIntentCategory::MusicGeneration, + ContextPressureState::Warning, + ); + decision.required_lane = Some(CapabilityLane::MusicGeneration); + decision.reasons = vec![CandidateAdmissionReason::RequiresLane( + CapabilityLane::MusicGeneration, + )]; + + let response = format_blocked_turn_admission_response(&decision, None); + + assert!( + response.starts_with("Turn intent `music_generation` requires lane `music_generation`") + ); + assert!(response.contains("current route cannot satisfy it")); + } + + #[test] + fn blocked_turn_response_mentions_safe_context_budget_on_overflow_risk() { + let mut decision = blocked_decision( + TurnIntentCategory::Reply, + ContextPressureState::OverflowRisk, + ); + decision.reasons = vec![CandidateAdmissionReason::CandidateWindowExceeded]; + decision.recommended_action = Some(AdmissionRepairHint::StartFreshHandoff); + + let response = format_blocked_turn_admission_response(&decision, None); + + assert!(response.contains("safe context budget")); + assert!(response.contains("fresh handoff")); + } +} diff --git a/crates/domain/src/application/use_cases/handle_inbound_message.rs b/crates/domain/src/application/use_cases/handle_inbound_message.rs index 7db5b2d5..5ce1a6ef 100644 --- a/crates/domain/src/application/use_cases/handle_inbound_message.rs +++ b/crates/domain/src/application/use_cases/handle_inbound_message.rs @@ -16,6 +16,7 @@ use crate::application::services::inbound_message_service::{ self, CommandEffect, HistoryEnrichment, MessageClassification, }; use crate::application::services::provider_context_budget::provider_context_input_for_history; +use crate::application::services::runtime_admission_presentation::format_blocked_turn_admission_response; use crate::application::services::runtime_assumptions::{ apply_tool_repair_assumption_challenges, build_runtime_assumptions, challenge_runtime_assumption_ledger, merge_runtime_assumption_ledger, @@ -30,8 +31,7 @@ use crate::application::services::runtime_watchdog::{ format_runtime_watchdog_context, RuntimeSubsystemObservationInput, RuntimeWatchdogInput, }; use crate::application::services::session_handoff::{ - build_session_handoff_packet, format_session_handoff_packet, SessionHandoffInput, - SessionHandoffPacket, + build_session_handoff_packet, SessionHandoffInput, }; use crate::application::services::turn_interpretation::TurnInterpretation; use crate::application::services::turn_markup::{ @@ -627,71 +627,6 @@ fn build_model_routing_config(config: &InboundMessageConfig) -> crate::config::s routing } -fn format_blocked_turn_admission_response( - decision: &crate::application::services::turn_admission::CandidateAdmissionDecision, - handoff_packet: Option<&SessionHandoffPacket>, -) -> String { - use crate::domain::turn_admission::{ - AdmissionRepairHint, CandidateAdmissionReason, ContextPressureState, TurnIntentCategory, - }; - - let base = if let Some(AdmissionRepairHint::RefreshCapabilityMetadata(lane)) = - decision.recommended_action - { - format!( - "Capability metadata for `{}` is stale or low-confidence on the current route. Refresh model profiles or switch to a compatible lane and try again.", - blocked_lane_name(lane) - ) - } else { - match (decision.snapshot.intent, decision.snapshot.pressure_state) { - (_, ContextPressureState::OverflowRisk) => match decision.recommended_action { - Some(AdmissionRepairHint::StartFreshHandoff) => { - "This turn is too large for the current route's safe context budget. Start a fresh handoff or switch to a larger-context model.".into() - } - _ => { - "This turn is too large for the current route's safe context budget. Compact the session first or switch to a larger-context model.".into() - } - }, - (TurnIntentCategory::MultimodalUnderstanding, _) => { - "The current route cannot handle image-aware input. Switch to a multimodal route and try again.".into() - } - (TurnIntentCategory::ImageGeneration, _) => { - "The current route cannot generate images. Switch to an image-generation lane and try again.".into() - } - (TurnIntentCategory::AudioGeneration, _) => { - "The current route cannot generate audio. Switch to an audio-capable lane and try again.".into() - } - (TurnIntentCategory::VideoGeneration, _) => { - "The current route cannot generate video. Switch to a video-capable lane and try again.".into() - } - (TurnIntentCategory::MusicGeneration, _) => { - "The current route cannot generate music. Switch to a music-capable lane and try again.".into() - } - (_, _) if decision.reasons.iter().any(|reason| { - matches!( - reason, - CandidateAdmissionReason::CapabilityMetadataUnknown(_) - | CandidateAdmissionReason::CapabilityMetadataLowConfidence(_) - | CandidateAdmissionReason::CapabilityMetadataStale(_) - ) - }) => { - "The current route has incomplete capability metadata for this turn. Refresh model profiles or switch to a compatible lane and try again.".into() - } - _ => "The current route cannot safely execute this turn. Switch to a compatible lane or start a fresh handoff.".into(), - } - }; - - if let Some(packet) = handoff_packet { - format!("{base}\n\n{}", format_session_handoff_packet(packet)) - } else { - base - } -} - -fn blocked_lane_name(lane: crate::config::schema::CapabilityLane) -> &'static str { - lane.as_str() -} - /// Execute the agent turn and handle the response. async fn execute_agent_turn( envelope: &InboundEnvelope, @@ -2096,63 +2031,4 @@ mod tests { let result = truncate_chars("hello world", 5); assert_eq!(result, "hello…"); } - - #[test] - fn blocked_turn_response_mentions_stale_or_low_confidence_metadata() { - let response = format_blocked_turn_admission_response( - &crate::application::services::turn_admission::CandidateAdmissionDecision { - snapshot: crate::domain::turn_admission::TurnAdmissionSnapshot { - intent: crate::domain::turn_admission::TurnIntentCategory::ImageGeneration, - pressure_state: crate::domain::turn_admission::ContextPressureState::Warning, - action: crate::domain::turn_admission::TurnAdmissionAction::Block, - }, - required_lane: Some(crate::config::schema::CapabilityLane::ImageGeneration), - route_override: None, - reasons: vec![ - crate::domain::turn_admission::CandidateAdmissionReason::CapabilityMetadataLowConfidence( - crate::config::schema::CapabilityLane::ImageGeneration, - ), - ], - recommended_action: Some( - crate::domain::turn_admission::AdmissionRepairHint::RefreshCapabilityMetadata( - crate::config::schema::CapabilityLane::ImageGeneration, - ), - ), - condensation_plan: None, - requires_compaction: false, - }, - None, - ); - - assert!(response.contains("image_generation")); - assert!(response.contains("stale or low-confidence")); - assert!(response.contains("Refresh model profiles")); - } - - #[test] - fn blocked_turn_response_mentions_safe_context_budget_on_overflow_risk() { - let response = format_blocked_turn_admission_response( - &crate::application::services::turn_admission::CandidateAdmissionDecision { - snapshot: crate::domain::turn_admission::TurnAdmissionSnapshot { - intent: crate::domain::turn_admission::TurnIntentCategory::Reply, - pressure_state: crate::domain::turn_admission::ContextPressureState::OverflowRisk, - action: crate::domain::turn_admission::TurnAdmissionAction::Block, - }, - required_lane: None, - route_override: None, - reasons: vec![ - crate::domain::turn_admission::CandidateAdmissionReason::CandidateWindowExceeded, - ], - recommended_action: Some( - crate::domain::turn_admission::AdmissionRepairHint::StartFreshHandoff, - ), - condensation_plan: None, - requires_compaction: false, - }, - None, - ); - - assert!(response.contains("safe context budget")); - assert!(response.contains("fresh handoff")); - } } diff --git a/docs/fork/ipc-phase4_10-plan.md b/docs/fork/ipc-phase4_10-plan.md index 6fe46198..b81156ee 100644 --- a/docs/fork/ipc-phase4_10-plan.md +++ b/docs/fork/ipc-phase4_10-plan.md @@ -1359,6 +1359,9 @@ Expected outcome: image/audio/video/music, including universal reasoning candidates that explicitly advertise generation features; this remains structural and does not use natural-language phrase lists + - admission block presentation now lives in a shared domain service; channel + and live web/Agent use the same typed lane/intent/context-budget response + instead of diverging into adapter-local technical error text - remaining: - widen intent consumers past the current multimodal + specialized-lane protection - continue making runtime UX surfaces display admission state explicitly beyond From 98331f25ff105301297bde318f6db15417647d56 Mon Sep 17 00:00:00 2001 From: panviktor <35385675+panviktor@users.noreply.github.com> Date: Sun, 12 Apr 2026 21:57:28 +0200 Subject: [PATCH 16/30] Guard tool-heavy admission by capability --- .../application/services/turn_admission.rs | 169 +++++++++++++++++- docs/fork/ipc-phase4_10-plan.md | 4 + 2 files changed, 167 insertions(+), 6 deletions(-) diff --git a/crates/domain/src/application/services/turn_admission.rs b/crates/domain/src/application/services/turn_admission.rs index 89829fc2..efd8d67f 100644 --- a/crates/domain/src/application/services/turn_admission.rs +++ b/crates/domain/src/application/services/turn_admission.rs @@ -66,6 +66,7 @@ pub fn assess_turn_admission(input: TurnAdmissionInput<'_>) -> CandidateAdmissio let mut route_override = None; let mut requires_compaction = false; let mut required_lane_unsatisfied = false; + let mut tool_support_unsatisfied = false; let budget_assessment = assess_provider_context_budget(input.provider_context); let condensation_plan = provider_context_condensation_plan(&budget_assessment); @@ -145,6 +146,9 @@ pub fn assess_turn_admission(input: TurnAdmissionInput<'_>) -> CandidateAdmissio ) }); } + if route_override.is_none() { + tool_support_unsatisfied = true; + } } if should_suppress_route_choice( @@ -175,7 +179,8 @@ pub fn assess_turn_admission(input: TurnAdmissionInput<'_>) -> CandidateAdmissio } let action = if (matches!(pressure_state, ContextPressureState::OverflowRisk) - || required_lane_unsatisfied) + || required_lane_unsatisfied + || tool_support_unsatisfied) && route_override.is_none() { TurnAdmissionAction::Block @@ -218,9 +223,6 @@ fn derive_recommended_action( requires_compaction: bool, action: TurnAdmissionAction, ) -> Option { - if let Some(override_route) = route_override { - return Some(AdmissionRepairHint::SwitchToLane(override_route.lane)); - } if reasons.iter().any(|reason| { matches!( reason, @@ -229,6 +231,9 @@ fn derive_recommended_action( }) { return Some(AdmissionRepairHint::SwitchToToolCapableReasoning); } + if let Some(override_route) = route_override { + return Some(AdmissionRepairHint::SwitchToLane(override_route.lane)); + } if let Some(lane) = reasons.iter().find_map(|reason| match reason { CandidateAdmissionReason::CapabilityMetadataUnknown(lane) | CandidateAdmissionReason::CapabilityMetadataStale(lane) @@ -459,7 +464,7 @@ fn current_candidate_explicitly_lacks_tool_support( return false; } - matches!( + let specialized_lane_without_tools = matches!( current_lane, Some( CapabilityLane::Embedding @@ -468,7 +473,11 @@ fn current_candidate_explicitly_lacks_tool_support( | CapabilityLane::VideoGeneration | CapabilityLane::MusicGeneration ) - ) + ); + + specialized_lane_without_tools + || (profile.features_known() + && profile.features_confidence() >= ResolvedModelProfileConfidence::Medium) } fn resolve_required_lane_override( @@ -570,6 +579,15 @@ mod tests { } } + fn external_lookup_spec() -> ToolSpec { + ToolSpec { + name: "web_fetch".into(), + description: "fetch".into(), + parameters: serde_json::json!({"type": "object"}), + runtime_role: Some(ToolRuntimeRole::ExternalLookup), + } + } + fn suppressed_route(provider: &str, model: &str) -> RuntimeCalibrationRecord { RuntimeCalibrationRecord { decision_kind: RuntimeCalibrationDecisionKind::RouteChoice, @@ -919,6 +937,145 @@ mod tests { assert_eq!(decision.snapshot.intent, TurnIntentCategory::Deliver); } + #[test] + fn tool_heavy_turn_reroutes_to_tool_capable_reasoning_candidate() { + let mut config = Config::default(); + config.model_lanes.push(ModelLaneConfig { + lane: CapabilityLane::Reasoning, + candidates: vec![ + ModelLaneCandidateConfig { + provider: "openrouter".into(), + model: "plain-reasoning".into(), + api_key: None, + api_key_env: None, + dimensions: None, + profile: ModelCandidateProfileConfig { + context_window_tokens: Some(64_000), + max_output_tokens: None, + features: vec![ModelFeature::Vision], + }, + }, + ModelLaneCandidateConfig { + provider: "openrouter".into(), + model: "tool-reasoning".into(), + api_key: None, + api_key_env: None, + dimensions: None, + profile: ModelCandidateProfileConfig { + context_window_tokens: Some(64_000), + max_output_tokens: None, + features: vec![ModelFeature::ToolCalling], + }, + }, + ], + }); + + let decision = assess_turn_admission(TurnAdmissionInput { + config: Some(&config), + user_message: "Fetch the current project status", + execution_guidance: None, + tool_specs: &[external_lookup_spec()], + current_provider: "openrouter", + current_model: "plain-reasoning", + current_lane: Some(CapabilityLane::Reasoning), + current_profile: &ResolvedModelProfile { + features: vec![ModelFeature::Vision], + features_source: ResolvedModelProfileSource::ManualConfig, + ..Default::default() + }, + provider_capabilities: &ProviderCapabilities::default(), + provider_context: ProviderContextBudgetInput { + total_chars: 3_500, + prior_chat_messages: 0, + current_turn_messages: 1, + ..Default::default() + }, + calibration_records: &[], + catalog: None, + }); + + assert_eq!(decision.snapshot.intent, TurnIntentCategory::ToolHeavy); + assert_eq!(decision.snapshot.action, TurnAdmissionAction::Reroute); + assert_eq!( + decision.route_override.expect("override").model, + "tool-reasoning" + ); + assert!(decision + .reasons + .contains(&CandidateAdmissionReason::MissingFeature( + ModelFeature::ToolCalling + ))); + assert_eq!( + decision.recommended_action, + Some(AdmissionRepairHint::SwitchToToolCapableReasoning) + ); + } + + #[test] + fn tool_heavy_turn_blocks_when_confident_current_candidate_lacks_tools() { + let decision = assess_turn_admission(TurnAdmissionInput { + config: None, + user_message: "Fetch the current project status", + execution_guidance: None, + tool_specs: &[external_lookup_spec()], + current_provider: "openrouter", + current_model: "plain-reasoning", + current_lane: Some(CapabilityLane::Reasoning), + current_profile: &ResolvedModelProfile { + features: vec![ModelFeature::Vision], + features_source: ResolvedModelProfileSource::ManualConfig, + ..Default::default() + }, + provider_capabilities: &ProviderCapabilities::default(), + provider_context: ProviderContextBudgetInput { + total_chars: 3_500, + prior_chat_messages: 0, + current_turn_messages: 1, + ..Default::default() + }, + calibration_records: &[], + catalog: None, + }); + + assert_eq!(decision.snapshot.intent, TurnIntentCategory::ToolHeavy); + assert_eq!(decision.snapshot.action, TurnAdmissionAction::Block); + assert_eq!( + decision.recommended_action, + Some(AdmissionRepairHint::SwitchToToolCapableReasoning) + ); + } + + #[test] + fn tool_heavy_turn_does_not_block_on_unknown_tool_metadata() { + let decision = assess_turn_admission(TurnAdmissionInput { + config: None, + user_message: "Fetch the current project status", + execution_guidance: None, + tool_specs: &[external_lookup_spec()], + current_provider: "local", + current_model: "unknown-compatible-model", + current_lane: Some(CapabilityLane::Reasoning), + current_profile: &ResolvedModelProfile::default(), + provider_capabilities: &ProviderCapabilities::default(), + provider_context: ProviderContextBudgetInput { + total_chars: 3_500, + prior_chat_messages: 0, + current_turn_messages: 1, + ..Default::default() + }, + calibration_records: &[], + catalog: None, + }); + + assert_eq!(decision.snapshot.intent, TurnIntentCategory::ToolHeavy); + assert_eq!(decision.snapshot.action, TurnAdmissionAction::Proceed); + assert!(!decision + .reasons + .contains(&CandidateAdmissionReason::MissingFeature( + ModelFeature::ToolCalling + ))); + } + #[test] fn stale_capability_metadata_is_reported_explicitly() { let decision = assess_turn_admission(TurnAdmissionInput { diff --git a/docs/fork/ipc-phase4_10-plan.md b/docs/fork/ipc-phase4_10-plan.md index b81156ee..43d548ee 100644 --- a/docs/fork/ipc-phase4_10-plan.md +++ b/docs/fork/ipc-phase4_10-plan.md @@ -1362,6 +1362,10 @@ Expected outcome: - admission block presentation now lives in a shared domain service; channel and live web/Agent use the same typed lane/intent/context-budget response instead of diverging into adapter-local technical error text + - tool-heavy admission now reroutes to a tool-capable reasoning candidate + or blocks before provider execution when the current candidate + confidently lacks `tool_calling`; unknown/low-confidence metadata is not + treated as a hard no - remaining: - widen intent consumers past the current multimodal + specialized-lane protection - continue making runtime UX surfaces display admission state explicitly beyond From 886b822eb2f440f17a869270482a2114b43f9274 Mon Sep 17 00:00:00 2001 From: panviktor <35385675+panviktor@users.noreply.github.com> Date: Sun, 12 Apr 2026 22:03:09 +0200 Subject: [PATCH 17/30] Centralize reliable provider retry classification --- crates/adapters/providers/src/reliable.rs | 126 +++++++++++++--------- docs/fork/ipc-phase4_10-plan.md | 4 + 2 files changed, 78 insertions(+), 52 deletions(-) diff --git a/crates/adapters/providers/src/reliable.rs b/crates/adapters/providers/src/reliable.rs index be8fd8af..a96b5094 100644 --- a/crates/adapters/providers/src/reliable.rs +++ b/crates/adapters/providers/src/reliable.rs @@ -175,6 +175,28 @@ fn failure_reason(rate_limited: bool, non_retryable: bool) -> &'static str { } } +#[derive(Debug, Clone, PartialEq, Eq)] +struct ProviderAttemptErrorClass { + non_retryable_rate_limit: bool, + non_retryable: bool, + rate_limited: bool, + failure_reason: &'static str, + error_detail: String, +} + +fn classify_provider_attempt_error(err: &anyhow::Error) -> ProviderAttemptErrorClass { + let non_retryable_rate_limit = is_non_retryable_rate_limit(err); + let non_retryable = is_non_retryable(err) || non_retryable_rate_limit; + let rate_limited = is_rate_limited(err); + ProviderAttemptErrorClass { + non_retryable_rate_limit, + non_retryable, + rate_limited, + failure_reason: failure_reason(rate_limited, non_retryable), + error_detail: compact_error_detail(err), + } +} + fn compact_error_detail(err: &anyhow::Error) -> String { super::sanitize_api_error(&err.to_string()) .split_whitespace() @@ -323,11 +345,7 @@ impl Provider for ReliableProvider { return Ok(resp); } Err(e) => { - let non_retryable_rate_limit = is_non_retryable_rate_limit(&e); - let non_retryable = is_non_retryable(&e) || non_retryable_rate_limit; - let rate_limited = is_rate_limited(&e); - let failure_reason = failure_reason(rate_limited, non_retryable); - let error_detail = compact_error_detail(&e); + let error_class = classify_provider_attempt_error(&e); push_failure( &mut failures, @@ -335,17 +353,17 @@ impl Provider for ReliableProvider { current_model, attempt + 1, self.max_retries + 1, - failure_reason, - &error_detail, + error_class.failure_reason, + &error_class.error_detail, ); // Rate-limit with rotatable keys: cycle to the next API key // so the retry hits a different quota bucket. - if rate_limited && !non_retryable_rate_limit { + if error_class.rate_limited && !error_class.non_retryable_rate_limit { if let Some(new_key) = self.rotate_key() { tracing::warn!( provider = provider_name, - error = %error_detail, + error = %error_class.error_detail, "Rate limited; key rotation selected key ending ...{} \ but cannot apply (Provider trait has no set_api_key). \ Retrying with original key.", @@ -354,11 +372,11 @@ impl Provider for ReliableProvider { } } - if non_retryable { + if error_class.non_retryable { tracing::warn!( provider = provider_name, model = *current_model, - error = %error_detail, + error = %error_class.error_detail, "Non-retryable error, moving on" ); @@ -379,8 +397,8 @@ impl Provider for ReliableProvider { model = *current_model, attempt = attempt + 1, backoff_ms = wait, - reason = failure_reason, - error = %error_detail, + reason = error_class.failure_reason, + error = %error_class.error_detail, "Provider call failed, retrying" ); tokio::time::sleep(Duration::from_millis(wait)).await; @@ -443,11 +461,7 @@ impl Provider for ReliableProvider { return Ok(resp); } Err(e) => { - let non_retryable_rate_limit = is_non_retryable_rate_limit(&e); - let non_retryable = is_non_retryable(&e) || non_retryable_rate_limit; - let rate_limited = is_rate_limited(&e); - let failure_reason = failure_reason(rate_limited, non_retryable); - let error_detail = compact_error_detail(&e); + let error_class = classify_provider_attempt_error(&e); push_failure( &mut failures, @@ -455,15 +469,15 @@ impl Provider for ReliableProvider { current_model, attempt + 1, self.max_retries + 1, - failure_reason, - &error_detail, + error_class.failure_reason, + &error_class.error_detail, ); - if rate_limited && !non_retryable_rate_limit { + if error_class.rate_limited && !error_class.non_retryable_rate_limit { if let Some(new_key) = self.rotate_key() { tracing::warn!( provider = provider_name, - error = %error_detail, + error = %error_class.error_detail, "Rate limited; key rotation selected key ending ...{} \ but cannot apply (Provider trait has no set_api_key). \ Retrying with original key.", @@ -472,11 +486,11 @@ impl Provider for ReliableProvider { } } - if non_retryable { + if error_class.non_retryable { tracing::warn!( provider = provider_name, model = *current_model, - error = %error_detail, + error = %error_class.error_detail, "Non-retryable error, moving on" ); @@ -497,8 +511,8 @@ impl Provider for ReliableProvider { model = *current_model, attempt = attempt + 1, backoff_ms = wait, - reason = failure_reason, - error = %error_detail, + reason = error_class.failure_reason, + error = %error_class.error_detail, "Provider call failed, retrying" ); tokio::time::sleep(Duration::from_millis(wait)).await; @@ -567,11 +581,7 @@ impl Provider for ReliableProvider { return Ok(resp); } Err(e) => { - let non_retryable_rate_limit = is_non_retryable_rate_limit(&e); - let non_retryable = is_non_retryable(&e) || non_retryable_rate_limit; - let rate_limited = is_rate_limited(&e); - let failure_reason = failure_reason(rate_limited, non_retryable); - let error_detail = compact_error_detail(&e); + let error_class = classify_provider_attempt_error(&e); push_failure( &mut failures, @@ -579,15 +589,15 @@ impl Provider for ReliableProvider { current_model, attempt + 1, self.max_retries + 1, - failure_reason, - &error_detail, + error_class.failure_reason, + &error_class.error_detail, ); - if rate_limited && !non_retryable_rate_limit { + if error_class.rate_limited && !error_class.non_retryable_rate_limit { if let Some(new_key) = self.rotate_key() { tracing::warn!( provider = provider_name, - error = %error_detail, + error = %error_class.error_detail, "Rate limited; key rotation selected key ending ...{} \ but cannot apply (Provider trait has no set_api_key). \ Retrying with original key.", @@ -596,11 +606,11 @@ impl Provider for ReliableProvider { } } - if non_retryable { + if error_class.non_retryable { tracing::warn!( provider = provider_name, model = *current_model, - error = %error_detail, + error = %error_class.error_detail, "Non-retryable error, moving on" ); @@ -621,8 +631,8 @@ impl Provider for ReliableProvider { model = *current_model, attempt = attempt + 1, backoff_ms = wait, - reason = failure_reason, - error = %error_detail, + reason = error_class.failure_reason, + error = %error_class.error_detail, "Provider call failed, retrying" ); tokio::time::sleep(Duration::from_millis(wait)).await; @@ -692,11 +702,7 @@ impl Provider for ReliableProvider { return Ok(resp); } Err(e) => { - let non_retryable_rate_limit = is_non_retryable_rate_limit(&e); - let non_retryable = is_non_retryable(&e) || non_retryable_rate_limit; - let rate_limited = is_rate_limited(&e); - let failure_reason = failure_reason(rate_limited, non_retryable); - let error_detail = compact_error_detail(&e); + let error_class = classify_provider_attempt_error(&e); push_failure( &mut failures, @@ -704,15 +710,15 @@ impl Provider for ReliableProvider { current_model, attempt + 1, self.max_retries + 1, - failure_reason, - &error_detail, + error_class.failure_reason, + &error_class.error_detail, ); - if rate_limited && !non_retryable_rate_limit { + if error_class.rate_limited && !error_class.non_retryable_rate_limit { if let Some(new_key) = self.rotate_key() { tracing::warn!( provider = provider_name, - error = %error_detail, + error = %error_class.error_detail, "Rate limited; key rotation selected key ending ...{} \ but cannot apply (Provider trait has no set_api_key). \ Retrying with original key.", @@ -721,11 +727,11 @@ impl Provider for ReliableProvider { } } - if non_retryable { + if error_class.non_retryable { tracing::warn!( provider = provider_name, model = *current_model, - error = %error_detail, + error = %error_class.error_detail, "Non-retryable error, moving on" ); @@ -746,8 +752,8 @@ impl Provider for ReliableProvider { model = *current_model, attempt = attempt + 1, backoff_ms = wait, - reason = failure_reason, - error = %error_detail, + reason = error_class.failure_reason, + error = %error_class.error_detail, "Provider call failed, retrying" ); tokio::time::sleep(Duration::from_millis(wait)).await; @@ -1429,6 +1435,22 @@ mod tests { ); } + #[test] + fn provider_attempt_error_class_preserves_rate_limit_decision() { + let err = anyhow::anyhow!( + "{}", + "API error (429 Too Many Requests): {\"code\":1113,\"message\":\"insufficient balance\"}" + ); + + let class = classify_provider_attempt_error(&err); + + assert!(class.rate_limited); + assert!(class.non_retryable_rate_limit); + assert!(class.non_retryable); + assert_eq!(class.failure_reason, "rate_limited_non_retryable"); + assert!(class.error_detail.contains("insufficient balance")); + } + #[test] fn compute_backoff_uses_retry_after() { let provider = ReliableProvider::new(vec![], 0, 500); diff --git a/docs/fork/ipc-phase4_10-plan.md b/docs/fork/ipc-phase4_10-plan.md index 43d548ee..87759094 100644 --- a/docs/fork/ipc-phase4_10-plan.md +++ b/docs/fork/ipc-phase4_10-plan.md @@ -1720,6 +1720,10 @@ Expected outcome: - helper-agent handoff schemas for `delegate` and `agents_spawn` now accept the same bounded `recent_repairs` field, so tool self-repair state can survive typed handoff without free-form prompt archaeology + - reliable-provider retry/fallback paths now share one adapter-local + attempt classification object for rate-limit / non-retryable / + context-window decisions instead of duplicating string/error handling + branches across chat variants - remaining: - keep auditing provider-specific opaque error strings that do not expose a typed status/source error yet From 47e140cc3a0492180674592cc1ab36540c522e01 Mon Sep 17 00:00:00 2001 From: panviktor <35385675+panviktor@users.noreply.github.com> Date: Sun, 12 Apr 2026 22:06:46 +0200 Subject: [PATCH 18/30] Surface admission lanes in route diagnostics --- crates/adapters/core/src/runtime_routes.rs | 31 ++++++++++++++++++++++ docs/fork/ipc-phase4_10-plan.md | 3 +++ 2 files changed, 34 insertions(+) diff --git a/crates/adapters/core/src/runtime_routes.rs b/crates/adapters/core/src/runtime_routes.rs index 8b3beec2..44d95da4 100644 --- a/crates/adapters/core/src/runtime_routes.rs +++ b/crates/adapters/core/src/runtime_routes.rs @@ -667,6 +667,21 @@ fn format_admission_repair_hint(hint: AdmissionRepairHint) -> String { } } +fn admission_required_lane(admission: &RouteAdmissionState) -> Option { + admission + .reasons + .iter() + .find_map(|reason| match reason { + CandidateAdmissionReason::RequiresLane(lane) => Some(*lane), + _ => None, + }) + .or_else(|| match admission.recommended_action { + Some(AdmissionRepairHint::SwitchToLane(lane)) + | Some(AdmissionRepairHint::RefreshCapabilityMetadata(lane)) => Some(lane), + _ => None, + }) +} + fn format_tool_repair_action(trace: &ToolRepairTrace) -> String { match trace.suggested_action { ToolRepairAction::SwitchRouteLane(lane) => format!( @@ -689,6 +704,13 @@ fn write_route_runtime_diagnostics(response: &mut String, current: &RouteSelecti fn write_route_admission_diagnostics(response: &mut String, current: &RouteSelection) { if let Some(admission) = current.last_admission.as_ref() { + if let Some(lane) = admission_required_lane(admission) { + let _ = writeln!( + response, + "Last admission required lane: `{}`", + capability_lane_name(lane) + ); + } let _ = writeln!( response, "Last admission: `{}` / `{}` / `{}`", @@ -783,6 +805,13 @@ fn write_recent_tool_repairs(response: &mut String, recent_tool_repairs: &[ToolR fn write_recent_admissions(response: &mut String, recent_admissions: &[RouteAdmissionState]) { for admission in recent_admissions.iter().rev().take(3) { + if let Some(lane) = admission_required_lane(admission) { + let _ = writeln!( + response, + "Recent admission required lane: `{}`", + capability_lane_name(lane) + ); + } let _ = writeln!( response, "Recent admission: {} / {} / {}", @@ -1202,6 +1231,7 @@ mod tests { assert!( response.contains("Last admission: `multimodal_understanding` / `warning` / `reroute`") ); + assert!(response.contains("Last admission required lane: `multimodal_understanding`")); assert!(response.contains( "Last admission reasons: requires multimodal_understanding, context warning" )); @@ -1248,6 +1278,7 @@ mod tests { ); assert!(response.contains("Recent admissions retained: 1")); + assert!(response.contains("Recent admission required lane: `image_generation`")); assert!(response.contains("Recent admission: image_generation / critical / compact")); assert!(response .contains("Recent admission reasons: requires image_generation, window near limit")); diff --git a/docs/fork/ipc-phase4_10-plan.md b/docs/fork/ipc-phase4_10-plan.md index 87759094..163925a4 100644 --- a/docs/fork/ipc-phase4_10-plan.md +++ b/docs/fork/ipc-phase4_10-plan.md @@ -1366,6 +1366,9 @@ Expected outcome: or blocks before provider execution when the current candidate confidently lacks `tool_calling`; unknown/low-confidence metadata is not treated as a hard no + - route inspection now surfaces admission required-lane state before the + broader intent/pressure/action tuple for both latest and recent retained + admission records - remaining: - widen intent consumers past the current multimodal + specialized-lane protection - continue making runtime UX surfaces display admission state explicitly beyond From ae4a7c5089f077cc8403c70276e0714506d7622e Mon Sep 17 00:00:00 2001 From: panviktor <35385675+panviktor@users.noreply.github.com> Date: Sun, 12 Apr 2026 22:51:51 +0200 Subject: [PATCH 19/30] Store admission required lane explicitly --- crates/adapters/core/src/agent/agent.rs | 1 + crates/adapters/core/src/runtime_routes.rs | 15 +++------------ .../services/route_admission_history.rs | 2 ++ .../src/application/services/runtime_watchdog.rs | 2 ++ .../use_cases/handle_inbound_message.rs | 1 + crates/domain/src/ports/route_selection.rs | 1 + docs/fork/ipc-phase4_10-plan.md | 2 ++ 7 files changed, 12 insertions(+), 12 deletions(-) diff --git a/crates/adapters/core/src/agent/agent.rs b/crates/adapters/core/src/agent/agent.rs index 0bd33e82..42f52297 100644 --- a/crates/adapters/core/src/agent/agent.rs +++ b/crates/adapters/core/src/agent/agent.rs @@ -2419,6 +2419,7 @@ impl Agent { let admission_state = RouteAdmissionState { observed_at_unix, snapshot: admission_decision.snapshot.clone(), + required_lane: admission_decision.required_lane, reasons: admission_decision.reasons.clone(), recommended_action: admission_decision.recommended_action, }; diff --git a/crates/adapters/core/src/runtime_routes.rs b/crates/adapters/core/src/runtime_routes.rs index 44d95da4..65c24475 100644 --- a/crates/adapters/core/src/runtime_routes.rs +++ b/crates/adapters/core/src/runtime_routes.rs @@ -668,18 +668,7 @@ fn format_admission_repair_hint(hint: AdmissionRepairHint) -> String { } fn admission_required_lane(admission: &RouteAdmissionState) -> Option { - admission - .reasons - .iter() - .find_map(|reason| match reason { - CandidateAdmissionReason::RequiresLane(lane) => Some(*lane), - _ => None, - }) - .or_else(|| match admission.recommended_action { - Some(AdmissionRepairHint::SwitchToLane(lane)) - | Some(AdmissionRepairHint::RefreshCapabilityMetadata(lane)) => Some(lane), - _ => None, - }) + admission.required_lane } fn format_tool_repair_action(trace: &ToolRepairTrace) -> String { @@ -1206,6 +1195,7 @@ mod tests { pressure_state: ContextPressureState::Warning, action: TurnAdmissionAction::Reroute, }, + required_lane: Some(CapabilityLane::MultimodalUnderstanding), reasons: vec![ CandidateAdmissionReason::RequiresLane( CapabilityLane::MultimodalUnderstanding, @@ -1251,6 +1241,7 @@ mod tests { pressure_state: ContextPressureState::Critical, action: TurnAdmissionAction::Compact, }, + required_lane: Some(CapabilityLane::ImageGeneration), reasons: vec![ CandidateAdmissionReason::RequiresLane(CapabilityLane::ImageGeneration), CandidateAdmissionReason::CandidateWindowNearLimit, diff --git a/crates/domain/src/application/services/route_admission_history.rs b/crates/domain/src/application/services/route_admission_history.rs index 45fbbf99..9b56c434 100644 --- a/crates/domain/src/application/services/route_admission_history.rs +++ b/crates/domain/src/application/services/route_admission_history.rs @@ -63,6 +63,7 @@ pub fn latest_route_admission_repair( fn same_admission_signature(left: &RouteAdmissionState, right: &RouteAdmissionState) -> bool { left.snapshot == right.snapshot + && left.required_lane == right.required_lane && left.reasons == right.reasons && left.recommended_action == right.recommended_action } @@ -83,6 +84,7 @@ mod tests { pressure_state: ContextPressureState::Warning, action: TurnAdmissionAction::Reroute, }, + required_lane: None, reasons: vec![reason], recommended_action: Some(AdmissionRepairHint::CompactSession), } diff --git a/crates/domain/src/application/services/runtime_watchdog.rs b/crates/domain/src/application/services/runtime_watchdog.rs index 823b75d7..b574e1fe 100644 --- a/crates/domain/src/application/services/runtime_watchdog.rs +++ b/crates/domain/src/application/services/runtime_watchdog.rs @@ -575,6 +575,7 @@ mod tests { pressure_state: ContextPressureState::OverflowRisk, action: TurnAdmissionAction::Compact, }, + required_lane: None, reasons: vec![CandidateAdmissionReason::ProviderContextOverflowRisk], recommended_action: Some(AdmissionRepairHint::StartFreshHandoff), }; @@ -752,6 +753,7 @@ mod tests { pressure_state: ContextPressureState::Healthy, action: TurnAdmissionAction::Reroute, }, + required_lane: Some(CapabilityLane::ImageGeneration), reasons: vec![CandidateAdmissionReason::CapabilityMetadataLowConfidence( CapabilityLane::ImageGeneration, )], diff --git a/crates/domain/src/application/use_cases/handle_inbound_message.rs b/crates/domain/src/application/use_cases/handle_inbound_message.rs index 5ce1a6ef..4d7b7602 100644 --- a/crates/domain/src/application/use_cases/handle_inbound_message.rs +++ b/crates/domain/src/application/use_cases/handle_inbound_message.rs @@ -750,6 +750,7 @@ async fn execute_agent_turn( let admission_state = RouteAdmissionState { observed_at_unix, snapshot: admission_decision.snapshot.clone(), + required_lane: admission_decision.required_lane, reasons: admission_decision.reasons.clone(), recommended_action: admission_decision.recommended_action, }; diff --git a/crates/domain/src/ports/route_selection.rs b/crates/domain/src/ports/route_selection.rs index 53637914..68e6bbe6 100644 --- a/crates/domain/src/ports/route_selection.rs +++ b/crates/domain/src/ports/route_selection.rs @@ -16,6 +16,7 @@ use crate::domain::turn_admission::{ pub struct RouteAdmissionState { pub observed_at_unix: i64, pub snapshot: TurnAdmissionSnapshot, + pub required_lane: Option, pub reasons: Vec, pub recommended_action: Option, } diff --git a/docs/fork/ipc-phase4_10-plan.md b/docs/fork/ipc-phase4_10-plan.md index 163925a4..e64dc553 100644 --- a/docs/fork/ipc-phase4_10-plan.md +++ b/docs/fork/ipc-phase4_10-plan.md @@ -1369,6 +1369,8 @@ Expected outcome: - route inspection now surfaces admission required-lane state before the broader intent/pressure/action tuple for both latest and recent retained admission records + - route admission state now stores `required_lane` explicitly instead of + deriving that operator-facing fact back out of reasons or repair hints - remaining: - widen intent consumers past the current multimodal + specialized-lane protection - continue making runtime UX surfaces display admission state explicitly beyond From 7333f52f3d4f5162caf11133a509a82e2a87c1cb Mon Sep 17 00:00:00 2001 From: panviktor <35385675+panviktor@users.noreply.github.com> Date: Sun, 12 Apr 2026 22:54:13 +0200 Subject: [PATCH 20/30] Carry context-limit retry decisions in classifier --- crates/adapters/providers/src/reliable.rs | 58 +++++++++++++---------- docs/fork/ipc-phase4_10-plan.md | 3 ++ 2 files changed, 37 insertions(+), 24 deletions(-) diff --git a/crates/adapters/providers/src/reliable.rs b/crates/adapters/providers/src/reliable.rs index a96b5094..d14f359f 100644 --- a/crates/adapters/providers/src/reliable.rs +++ b/crates/adapters/providers/src/reliable.rs @@ -180,11 +180,13 @@ struct ProviderAttemptErrorClass { non_retryable_rate_limit: bool, non_retryable: bool, rate_limited: bool, + context_window_exceeded: bool, failure_reason: &'static str, error_detail: String, } fn classify_provider_attempt_error(err: &anyhow::Error) -> ProviderAttemptErrorClass { + let context_window_exceeded = is_context_window_exceeded(err); let non_retryable_rate_limit = is_non_retryable_rate_limit(err); let non_retryable = is_non_retryable(err) || non_retryable_rate_limit; let rate_limited = is_rate_limited(err); @@ -192,11 +194,25 @@ fn classify_provider_attempt_error(err: &anyhow::Error) -> ProviderAttemptErrorC non_retryable_rate_limit, non_retryable, rate_limited, + context_window_exceeded, failure_reason: failure_reason(rate_limited, non_retryable), error_detail: compact_error_detail(err), } } +fn bail_if_context_window_exceeded( + error_class: &ProviderAttemptErrorClass, + failures: &[String], +) -> anyhow::Result<()> { + if error_class.context_window_exceeded { + anyhow::bail!( + "Request exceeds model context window; retries and fallbacks were skipped. Attempts:\n{}", + failures.join("\n") + ); + } + Ok(()) +} + fn compact_error_detail(err: &anyhow::Error) -> String { super::sanitize_api_error(&err.to_string()) .split_whitespace() @@ -380,12 +396,7 @@ impl Provider for ReliableProvider { "Non-retryable error, moving on" ); - if is_context_window_exceeded(&e) { - anyhow::bail!( - "Request exceeds model context window; retries and fallbacks were skipped. Attempts:\n{}", - failures.join("\n") - ); - } + bail_if_context_window_exceeded(&error_class, &failures)?; break; } @@ -494,12 +505,7 @@ impl Provider for ReliableProvider { "Non-retryable error, moving on" ); - if is_context_window_exceeded(&e) { - anyhow::bail!( - "Request exceeds model context window; retries and fallbacks were skipped. Attempts:\n{}", - failures.join("\n") - ); - } + bail_if_context_window_exceeded(&error_class, &failures)?; break; } @@ -614,12 +620,7 @@ impl Provider for ReliableProvider { "Non-retryable error, moving on" ); - if is_context_window_exceeded(&e) { - anyhow::bail!( - "Request exceeds model context window; retries and fallbacks were skipped. Attempts:\n{}", - failures.join("\n") - ); - } + bail_if_context_window_exceeded(&error_class, &failures)?; break; } @@ -735,12 +736,7 @@ impl Provider for ReliableProvider { "Non-retryable error, moving on" ); - if is_context_window_exceeded(&e) { - anyhow::bail!( - "Request exceeds model context window; retries and fallbacks were skipped. Attempts:\n{}", - failures.join("\n") - ); - } + bail_if_context_window_exceeded(&error_class, &failures)?; break; } @@ -1447,10 +1443,24 @@ mod tests { assert!(class.rate_limited); assert!(class.non_retryable_rate_limit); assert!(class.non_retryable); + assert!(!class.context_window_exceeded); assert_eq!(class.failure_reason, "rate_limited_non_retryable"); assert!(class.error_detail.contains("insufficient balance")); } + #[test] + fn provider_attempt_error_class_preserves_context_window_decision() { + let err = anyhow::anyhow!( + "OpenAI Codex stream error: Your input exceeds the context window of this model." + ); + + let class = classify_provider_attempt_error(&err); + + assert!(class.context_window_exceeded); + assert!(class.non_retryable); + assert_eq!(class.failure_reason, "non_retryable"); + } + #[test] fn compute_backoff_uses_retry_after() { let provider = ReliableProvider::new(vec![], 0, 500); diff --git a/docs/fork/ipc-phase4_10-plan.md b/docs/fork/ipc-phase4_10-plan.md index e64dc553..1ca061f6 100644 --- a/docs/fork/ipc-phase4_10-plan.md +++ b/docs/fork/ipc-phase4_10-plan.md @@ -1729,6 +1729,9 @@ Expected outcome: attempt classification object for rate-limit / non-retryable / context-window decisions instead of duplicating string/error handling branches across chat variants + - context-window fail-fast decisions are now carried on that attempt + classification object and rendered through a shared adapter helper across + all reliable-provider chat variants - remaining: - keep auditing provider-specific opaque error strings that do not expose a typed status/source error yet From 6ff54ed240fc8af1b5eccf0abd6fbef56c56594c Mon Sep 17 00:00:00 2001 From: panviktor <35385675+panviktor@users.noreply.github.com> Date: Sun, 12 Apr 2026 23:00:20 +0200 Subject: [PATCH 21/30] Guard direct tool intents by capability --- .../application/services/turn_admission.rs | 95 ++++++++++++++++++- docs/fork/ipc-phase4_10-plan.md | 10 +- 2 files changed, 100 insertions(+), 5 deletions(-) diff --git a/crates/domain/src/application/services/turn_admission.rs b/crates/domain/src/application/services/turn_admission.rs index efd8d67f..ccbef549 100644 --- a/crates/domain/src/application/services/turn_admission.rs +++ b/crates/domain/src/application/services/turn_admission.rs @@ -125,7 +125,7 @@ pub fn assess_turn_admission(input: TurnAdmissionInput<'_>) -> CandidateAdmissio } } - if intent == TurnIntentCategory::ToolHeavy + if intent_requires_tool_calling(intent) && current_candidate_explicitly_lacks_tool_support( input.current_lane, input.current_profile, @@ -453,6 +453,13 @@ fn intent_uses_reasoning_lane(intent: TurnIntentCategory) -> bool { ) } +fn intent_requires_tool_calling(intent: TurnIntentCategory) -> bool { + matches!( + intent, + TurnIntentCategory::Deliver | TurnIntentCategory::Mutate | TurnIntentCategory::ToolHeavy + ) +} + fn current_candidate_explicitly_lacks_tool_support( current_lane: Option, profile: &ResolvedModelProfile, @@ -588,6 +595,15 @@ mod tests { } } + fn profile_mutation_spec() -> ToolSpec { + ToolSpec { + name: "profile_update".into(), + description: "profile update".into(), + parameters: serde_json::json!({"type": "object"}), + runtime_role: Some(ToolRuntimeRole::ProfileMutation), + } + } + fn suppressed_route(provider: &str, model: &str) -> RuntimeCalibrationRecord { RuntimeCalibrationRecord { decision_kind: RuntimeCalibrationDecisionKind::RouteChoice, @@ -935,6 +951,83 @@ mod tests { }); assert_eq!(decision.snapshot.intent, TurnIntentCategory::Deliver); + assert_eq!(decision.snapshot.action, TurnAdmissionAction::Proceed); + } + + #[test] + fn delivery_turn_blocks_when_candidate_confidently_lacks_tools() { + let guidance = ExecutionGuidance { + direct_resolution_ready: true, + preferred_capabilities: vec![ExecutionCapability::Delivery], + recent_failure_hints: Vec::new(), + ..ExecutionGuidance::default() + }; + + let decision = assess_turn_admission(TurnAdmissionInput { + config: None, + user_message: "Send it there", + execution_guidance: Some(&guidance), + tool_specs: &[direct_delivery_spec()], + current_provider: "openrouter", + current_model: "plain-reasoning", + current_lane: Some(CapabilityLane::Reasoning), + current_profile: &ResolvedModelProfile { + features: vec![ModelFeature::Vision], + features_source: ResolvedModelProfileSource::ManualConfig, + ..Default::default() + }, + provider_capabilities: &ProviderCapabilities::default(), + provider_context: ProviderContextBudgetInput { + total_chars: 3_500, + prior_chat_messages: 0, + current_turn_messages: 1, + ..Default::default() + }, + calibration_records: &[], + catalog: None, + }); + + assert_eq!(decision.snapshot.intent, TurnIntentCategory::Deliver); + assert_eq!(decision.snapshot.action, TurnAdmissionAction::Block); + assert_eq!( + decision.recommended_action, + Some(AdmissionRepairHint::SwitchToToolCapableReasoning) + ); + } + + #[test] + fn mutation_turn_blocks_when_candidate_confidently_lacks_tools() { + let decision = assess_turn_admission(TurnAdmissionInput { + config: None, + user_message: "Remember this preference", + execution_guidance: None, + tool_specs: &[profile_mutation_spec()], + current_provider: "openrouter", + current_model: "plain-reasoning", + current_lane: Some(CapabilityLane::Reasoning), + current_profile: &ResolvedModelProfile { + features: vec![ModelFeature::Vision], + features_source: ResolvedModelProfileSource::ManualConfig, + ..Default::default() + }, + provider_capabilities: &ProviderCapabilities::default(), + provider_context: ProviderContextBudgetInput { + total_chars: 3_500, + prior_chat_messages: 0, + current_turn_messages: 1, + ..Default::default() + }, + calibration_records: &[], + catalog: None, + }); + + assert_eq!(decision.snapshot.intent, TurnIntentCategory::Mutate); + assert_eq!(decision.snapshot.action, TurnAdmissionAction::Block); + assert!(decision + .reasons + .contains(&CandidateAdmissionReason::MissingFeature( + ModelFeature::ToolCalling + ))); } #[test] diff --git a/docs/fork/ipc-phase4_10-plan.md b/docs/fork/ipc-phase4_10-plan.md index 1ca061f6..d43b9e97 100644 --- a/docs/fork/ipc-phase4_10-plan.md +++ b/docs/fork/ipc-phase4_10-plan.md @@ -1330,7 +1330,7 @@ Expected outcome: - protect against wrong-model execution structurally: - a default reasoning candidate is not implicitly allowed for vision, image, audio, music, or video turns - - unsupported tool-calling candidates are blocked from tool-heavy turns + - unsupported tool-calling candidates are blocked from typed tool-required turns - candidates with insufficient context window are blocked from current-turn execution unless preflight compaction succeeds - preserve hexagonal boundaries: @@ -1362,8 +1362,9 @@ Expected outcome: - admission block presentation now lives in a shared domain service; channel and live web/Agent use the same typed lane/intent/context-budget response instead of diverging into adapter-local technical error text - - tool-heavy admission now reroutes to a tool-capable reasoning candidate - or blocks before provider execution when the current candidate + - typed tool-required admission now reroutes to a tool-capable reasoning + candidate or blocks before provider execution for `tool-heavy`, + `delivery`, and `mutation` intents when the current candidate confidently lacks `tool_calling`; unknown/low-confidence metadata is not treated as a hard no - route inspection now surfaces admission required-lane state before the @@ -1372,7 +1373,8 @@ Expected outcome: - route admission state now stores `required_lane` explicitly instead of deriving that operator-facing fact back out of reasons or repair hints - remaining: - - widen intent consumers past the current multimodal + specialized-lane protection + - continue auditing intent consumers past the current multimodal, + specialized-lane, and typed tool-required protection - continue making runtime UX surfaces display admission state explicitly beyond `/model` help and lane-aware switch responses - settle whether admission snapshots should remain ephemeral or be persisted From a7f916a6b159b865f31d037563827fccd65502d4 Mon Sep 17 00:00:00 2001 From: panviktor <35385675+panviktor@users.noreply.github.com> Date: Sun, 12 Apr 2026 23:09:32 +0200 Subject: [PATCH 22/30] Unify runtime error recovery surfaces --- crates/adapters/core/src/gateway/ws.rs | 100 +++++++++++++++- .../core/src/runtime/agent_runtime_adapter.rs | 97 +-------------- crates/adapters/core/src/runtime/mod.rs | 1 + .../runtime/runtime_error_classification.rs | 111 ++++++++++++++++++ crates/domain/src/application/services/mod.rs | 1 + .../services/runtime_error_presentation.rs | 27 +++++ .../use_cases/handle_inbound_message.rs | 11 +- docs/fork/ipc-phase4_10-plan.md | 5 + 8 files changed, 251 insertions(+), 102 deletions(-) create mode 100644 crates/adapters/core/src/runtime/runtime_error_classification.rs create mode 100644 crates/domain/src/application/services/runtime_error_presentation.rs diff --git a/crates/adapters/core/src/gateway/ws.rs b/crates/adapters/core/src/gateway/ws.rs index 81979951..ac884fd2 100644 --- a/crates/adapters/core/src/gateway/ws.rs +++ b/crates/adapters/core/src/gateway/ws.rs @@ -12,6 +12,7 @@ use super::chat_db::ChatMessageRow; use super::{AppState, ChatSession}; +use crate::runtime::runtime_error_classification::classify_agent_runtime_error; use crate::runtime_adapter_contract::{ execute_runtime_command_effect, RuntimeCommandHost, RuntimeModelHelpSnapshot, RuntimeModelSwitchOutcome, RuntimeProviderSwitchOutcome, RuntimeRouteMutationRequest, @@ -23,6 +24,7 @@ use crate::runtime_tool_observer::{RuntimeToolNotificationHandler, RuntimeToolNo use synapse_domain::application::services::route_switch_preflight::{ RouteSwitchPreflight, RouteSwitchStatus, }; +use synapse_domain::application::services::runtime_error_presentation::format_context_limit_recovery_response; use synapse_domain::application::services::summary_route_resolution::resolve_summary_route; use synapse_domain::config::schema::CapabilityLane; use synapse_domain::domain::channel::ChannelCapability; @@ -44,6 +46,7 @@ use serde::Deserialize; use sha2::{Digest, Sha256}; use std::sync::Arc; use std::time::{Instant, SystemTime, UNIX_EPOCH}; +use synapse_domain::ports::agent_runtime::AgentRuntimeErrorKind; use synapse_domain::ports::route_selection::RouteSelection; /// The sub-protocol we support for the chat WebSocket. @@ -1188,7 +1191,64 @@ async fn handle_chat_send_rpc( "aborted": true, })); } - let sanitized = synapse_providers::sanitize_api_error(&msg); + let runtime_error = classify_agent_runtime_error(e); + if matches!( + runtime_error.kind, + AgentRuntimeErrorKind::ContextLimitExceeded + ) { + let compacted = compact_web_session_after_context_limit(state, &session_key) + .await + .unwrap_or_else(|error| { + tracing::debug!( + session_key = %session_key, + error = %error, + "Web session context-limit recovery compaction failed" + ); + false + }); + let recovery_message = format_context_limit_recovery_response(compacted); + persist_tool_events(state, &session_key, &tool_history).await; + persist_message( + state, + &session_key, + "assistant", + Some("assistant"), + recovery_message, + None, + Some(&run_id), + ) + .await; + if let (Some(cs), Some(rs)) = + (state.conversation_store.as_ref(), state.run_store.as_ref()) + { + let _ = + synapse_domain::application::use_cases::start_conversation_run::finalize_failure( + rs.as_ref(), + cs.as_ref(), + &session_key, + &run_id, + ) + .await; + } + sync_memory_count(state, &session_key, 2); + emit_run_event(state, "session.run_finished", &session_key, &run_id); + let _ = out_tx.send( + serde_json::json!({ + "type": "assistant", + "session_key": session_key, + "content": recovery_message, + "timestamp": now_secs(), + }) + .to_string(), + ); + return Ok(serde_json::json!({ + "run_id": run_id, + "runtime_error_kind": "context_limit_exceeded", + "compacted": compacted, + })); + } + + let sanitized = synapse_providers::sanitize_api_error(&runtime_error.to_string()); persist_tool_events(state, &session_key, &tool_history).await; persist_message(state, &session_key, "error", None, &sanitized, None, None).await; // Phase 4.0 Slice 3: finalize failed @@ -1653,6 +1713,44 @@ async fn run_agent_turn_with_abort( result } +async fn compact_web_session_after_context_limit( + state: &AppState, + session_key: &str, +) -> anyhow::Result { + let config_snapshot = state.config.lock().clone(); + let placeholder_agent = crate::agent::Agent::from_config_with_runtime_context( + &config_snapshot, + Some(state.mem.clone()), + web_runtime_ports(state), + ) + .await?; + + let mut agent = { + let mut sessions = state + .chat_sessions + .lock() + .map_err(|e| anyhow::anyhow!("{e}"))?; + let session = sessions + .get_mut(session_key) + .ok_or_else(|| anyhow::anyhow!("session not found"))?; + std::mem::replace(&mut session.agent, placeholder_agent) + }; + + let compacted = agent.compact_for_session_hygiene().await; + + { + let mut sessions = state + .chat_sessions + .lock() + .map_err(|e| anyhow::anyhow!("{e}"))?; + if let Some(session) = sessions.get_mut(session_key) { + session.agent = agent; + } + } + + Ok(compacted) +} + // ── RPC: chat.abort ───────────────────────────────────────────────────────── fn handle_chat_abort( diff --git a/crates/adapters/core/src/runtime/agent_runtime_adapter.rs b/crates/adapters/core/src/runtime/agent_runtime_adapter.rs index 599082c2..3a3adb10 100644 --- a/crates/adapters/core/src/runtime/agent_runtime_adapter.rs +++ b/crates/adapters/core/src/runtime/agent_runtime_adapter.rs @@ -3,6 +3,7 @@ //! Since `synapse_providers::ChatMessage` is now a re-export of //! `synapse_domain::domain::message::ChatMessage`, no conversions are needed. +use super::runtime_error_classification::classify_agent_runtime_error; use crate::tools::Tool; use anyhow::Result; use async_trait::async_trait; @@ -25,8 +26,7 @@ use synapse_domain::ports::model_profile_catalog::{ use synapse_domain::ports::provider::ProviderCapabilities; use synapse_infra::approval::ApprovalManager; use synapse_providers::error_classification::classify_context_limit_error; -use synapse_providers::{ChatMessage, Provider, ProviderCapabilityError, ProviderRuntimeOptions}; -use synapse_security::scrub_credentials; +use synapse_providers::{ChatMessage, Provider, ProviderRuntimeOptions}; /// Wraps the existing agent loop infrastructure behind `AgentRuntimePort`. pub struct ChannelAgentRuntime { @@ -305,74 +305,6 @@ fn format_tool_summary(tool_names: &[String]) -> String { } } -fn classify_agent_runtime_error(err: anyhow::Error) -> AgentRuntimeError { - if err.downcast_ref::().is_some() { - return AgentRuntimeError::new( - AgentRuntimeErrorKind::CapabilityMismatch, - scrub_credentials(&err.to_string()), - ); - } - - if let Some(io_error) = err.downcast_ref::() { - let detail = scrub_credentials(&err.to_string()); - return match io_error.kind() { - std::io::ErrorKind::PermissionDenied => { - AgentRuntimeError::new(AgentRuntimeErrorKind::PolicyBlocked, detail) - } - std::io::ErrorKind::NotFound => { - AgentRuntimeError::new(AgentRuntimeErrorKind::MissingResource, detail) - } - std::io::ErrorKind::TimedOut => { - AgentRuntimeError::new(AgentRuntimeErrorKind::Timeout, detail) - } - _ => AgentRuntimeError::new(AgentRuntimeErrorKind::RuntimeFailure, detail), - }; - } - - if err.downcast_ref::().is_some() { - return AgentRuntimeError::new( - AgentRuntimeErrorKind::SchemaMismatch, - scrub_credentials(&err.to_string()), - ); - } - - if let Some(reqwest_err) = err.downcast_ref::() { - if reqwest_err.is_timeout() { - return AgentRuntimeError::new( - AgentRuntimeErrorKind::Timeout, - scrub_credentials(&err.to_string()), - ); - } - if let Some(status) = reqwest_err.status() { - return match status.as_u16() { - 401 | 403 => AgentRuntimeError::new( - AgentRuntimeErrorKind::AuthFailure, - scrub_credentials(&err.to_string()), - ), - 404 => AgentRuntimeError::new( - AgentRuntimeErrorKind::MissingResource, - scrub_credentials(&err.to_string()), - ), - 413 => AgentRuntimeError::new( - AgentRuntimeErrorKind::ContextLimitExceeded, - scrub_credentials(&err.to_string()), - ), - _ => AgentRuntimeError::new( - AgentRuntimeErrorKind::RuntimeFailure, - scrub_credentials(&err.to_string()), - ), - }; - } - } - - let detail = scrub_credentials(&err.to_string()); - if classify_context_limit_error(&err).is_some() { - return AgentRuntimeError::new(AgentRuntimeErrorKind::ContextLimitExceeded, detail); - } - - AgentRuntimeError::new(AgentRuntimeErrorKind::RuntimeFailure, detail) -} - #[cfg(test)] mod tests { use super::*; @@ -441,29 +373,4 @@ mod tests { None, "provider", "model" )); } - - #[test] - fn runtime_error_classifier_uses_typed_io_kinds() { - let missing = classify_agent_runtime_error(anyhow::Error::new(std::io::Error::new( - std::io::ErrorKind::NotFound, - "missing workspace file", - ))); - assert_eq!(missing.kind, AgentRuntimeErrorKind::MissingResource); - - let denied = classify_agent_runtime_error(anyhow::Error::new(std::io::Error::new( - std::io::ErrorKind::PermissionDenied, - "policy denied", - ))); - assert_eq!(denied.kind, AgentRuntimeErrorKind::PolicyBlocked); - } - - #[test] - fn runtime_error_classifier_maps_json_decode_to_schema_mismatch() { - let error = - serde_json::from_str::("{").expect_err("malformed JSON should fail"); - - let classified = classify_agent_runtime_error(anyhow::Error::new(error)); - - assert_eq!(classified.kind, AgentRuntimeErrorKind::SchemaMismatch); - } } diff --git a/crates/adapters/core/src/runtime/mod.rs b/crates/adapters/core/src/runtime/mod.rs index b2108ac7..0b4df27f 100644 --- a/crates/adapters/core/src/runtime/mod.rs +++ b/crates/adapters/core/src/runtime/mod.rs @@ -5,6 +5,7 @@ pub mod docker; pub mod history_compaction_cache; pub mod hooks_adapter; pub mod native; +pub(crate) mod runtime_error_classification; use synapse_domain::config::schema::RuntimeConfig; pub use synapse_domain::ports::runtime::RuntimeAdapter; diff --git a/crates/adapters/core/src/runtime/runtime_error_classification.rs b/crates/adapters/core/src/runtime/runtime_error_classification.rs new file mode 100644 index 00000000..60851531 --- /dev/null +++ b/crates/adapters/core/src/runtime/runtime_error_classification.rs @@ -0,0 +1,111 @@ +use synapse_domain::ports::agent_runtime::{AgentRuntimeError, AgentRuntimeErrorKind}; +use synapse_providers::error_classification::classify_context_limit_error; +use synapse_providers::ProviderCapabilityError; +use synapse_security::scrub_credentials; + +pub(crate) fn classify_agent_runtime_error(err: anyhow::Error) -> AgentRuntimeError { + if err.downcast_ref::().is_some() { + return AgentRuntimeError::new( + AgentRuntimeErrorKind::CapabilityMismatch, + scrub_credentials(&err.to_string()), + ); + } + + if let Some(io_error) = err.downcast_ref::() { + let detail = scrub_credentials(&err.to_string()); + return match io_error.kind() { + std::io::ErrorKind::PermissionDenied => { + AgentRuntimeError::new(AgentRuntimeErrorKind::PolicyBlocked, detail) + } + std::io::ErrorKind::NotFound => { + AgentRuntimeError::new(AgentRuntimeErrorKind::MissingResource, detail) + } + std::io::ErrorKind::TimedOut => { + AgentRuntimeError::new(AgentRuntimeErrorKind::Timeout, detail) + } + _ => AgentRuntimeError::new(AgentRuntimeErrorKind::RuntimeFailure, detail), + }; + } + + if err.downcast_ref::().is_some() { + return AgentRuntimeError::new( + AgentRuntimeErrorKind::SchemaMismatch, + scrub_credentials(&err.to_string()), + ); + } + + if let Some(reqwest_err) = err.downcast_ref::() { + if reqwest_err.is_timeout() { + return AgentRuntimeError::new( + AgentRuntimeErrorKind::Timeout, + scrub_credentials(&err.to_string()), + ); + } + if let Some(status) = reqwest_err.status() { + return match status.as_u16() { + 401 | 403 => AgentRuntimeError::new( + AgentRuntimeErrorKind::AuthFailure, + scrub_credentials(&err.to_string()), + ), + 404 => AgentRuntimeError::new( + AgentRuntimeErrorKind::MissingResource, + scrub_credentials(&err.to_string()), + ), + 413 => AgentRuntimeError::new( + AgentRuntimeErrorKind::ContextLimitExceeded, + scrub_credentials(&err.to_string()), + ), + _ => AgentRuntimeError::new( + AgentRuntimeErrorKind::RuntimeFailure, + scrub_credentials(&err.to_string()), + ), + }; + } + } + + let detail = scrub_credentials(&err.to_string()); + if classify_context_limit_error(&err).is_some() { + return AgentRuntimeError::new(AgentRuntimeErrorKind::ContextLimitExceeded, detail); + } + + AgentRuntimeError::new(AgentRuntimeErrorKind::RuntimeFailure, detail) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn runtime_error_classifier_uses_typed_io_kinds() { + let missing = classify_agent_runtime_error(anyhow::Error::new(std::io::Error::new( + std::io::ErrorKind::NotFound, + "missing workspace file", + ))); + assert_eq!(missing.kind, AgentRuntimeErrorKind::MissingResource); + + let denied = classify_agent_runtime_error(anyhow::Error::new(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "policy denied", + ))); + assert_eq!(denied.kind, AgentRuntimeErrorKind::PolicyBlocked); + } + + #[test] + fn runtime_error_classifier_maps_json_decode_to_schema_mismatch() { + let error = + serde_json::from_str::("{").expect_err("malformed JSON should fail"); + + let classified = classify_agent_runtime_error(anyhow::Error::new(error)); + + assert_eq!(classified.kind, AgentRuntimeErrorKind::SchemaMismatch); + } + + #[test] + fn runtime_error_classifier_maps_provider_context_limit() { + let classified = classify_agent_runtime_error(anyhow::anyhow!( + "provider error: input exceeds the context window of this model" + )); + + assert_eq!(classified.kind, AgentRuntimeErrorKind::ContextLimitExceeded); + } +} diff --git a/crates/domain/src/application/services/mod.rs b/crates/domain/src/application/services/mod.rs index 30697d19..7e1d3bfe 100644 --- a/crates/domain/src/application/services/mod.rs +++ b/crates/domain/src/application/services/mod.rs @@ -55,6 +55,7 @@ pub mod runtime_admission_presentation; pub mod runtime_assumptions; pub mod runtime_calibration; pub mod runtime_command_presentation; +pub mod runtime_error_presentation; pub mod runtime_trace_janitor; pub mod runtime_watchdog; pub mod scoped_instruction_resolution; diff --git a/crates/domain/src/application/services/runtime_error_presentation.rs b/crates/domain/src/application/services/runtime_error_presentation.rs new file mode 100644 index 00000000..a7b9476e --- /dev/null +++ b/crates/domain/src/application/services/runtime_error_presentation.rs @@ -0,0 +1,27 @@ +pub fn format_context_limit_recovery_response(compacted: bool) -> &'static str { + if compacted { + "Context window exceeded. I compacted history and preserved a bounded summary. Please try again." + } else { + "Context window exceeded. Start a fresh session or switch to a larger-context route before retrying." + } +} + +pub fn format_timeout_recovery_response() -> &'static str { + "Request timed out. Try a simpler question or start a fresh session." +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn context_limit_response_distinguishes_compaction_outcome() { + assert!(format_context_limit_recovery_response(true).contains("compacted")); + assert!(format_context_limit_recovery_response(false).contains("fresh session")); + } + + #[test] + fn timeout_response_is_operator_facing() { + assert!(format_timeout_recovery_response().contains("timed out")); + } +} diff --git a/crates/domain/src/application/use_cases/handle_inbound_message.rs b/crates/domain/src/application/use_cases/handle_inbound_message.rs index 4d7b7602..b575dbf5 100644 --- a/crates/domain/src/application/use_cases/handle_inbound_message.rs +++ b/crates/domain/src/application/use_cases/handle_inbound_message.rs @@ -23,6 +23,9 @@ use crate::application::services::runtime_assumptions::{ RuntimeAssumptionChallenge, RuntimeAssumptionInput, RuntimeAssumptionInvalidation, RuntimeAssumptionKind, RuntimeAssumptionReplacementPath, }; +use crate::application::services::runtime_error_presentation::{ + format_context_limit_recovery_response, format_timeout_recovery_response, +}; use crate::application::services::runtime_trace_janitor::{ append_runtime_handoff_packet, append_runtime_watchdog_alerts, }; @@ -1146,11 +1149,7 @@ async fn execute_agent_turn( } } } - let msg = if compacted { - "⚠️ Context window exceeded. I compacted history and preserved a summary. Please try again." - } else { - "⚠️ Context window exceeded. Try `/new` to start a fresh conversation." - }; + let msg = format_context_limit_recovery_response(compacted); let _ = ports .channel_output .send_message(&envelope.reply_ref, msg, envelope.thread_ref.as_deref()) @@ -1167,7 +1166,7 @@ async fn execute_agent_turn( // ── #22: Timeout detection ─────────────────────────── if matches!(e.kind, AgentRuntimeErrorKind::Timeout) { ports.history.rollback_last_turn(conversation_key, content); - let msg = "⏱️ Request timed out. Try a simpler question or `/new`."; + let msg = format_timeout_recovery_response(); let _ = ports .channel_output .send_message(&envelope.reply_ref, msg, envelope.thread_ref.as_deref()) diff --git a/docs/fork/ipc-phase4_10-plan.md b/docs/fork/ipc-phase4_10-plan.md index d43b9e97..2fcaa659 100644 --- a/docs/fork/ipc-phase4_10-plan.md +++ b/docs/fork/ipc-phase4_10-plan.md @@ -1734,6 +1734,11 @@ Expected outcome: - context-window fail-fast decisions are now carried on that attempt classification object and rendered through a shared adapter helper across all reliable-provider chat variants + - channel runtime and web WS now classify provider/runtime failures through + the same adapter-core `AgentRuntimeErrorKind` bridge; web context-limit + failures get the same typed recovery path and shared presentation with a + session-hygiene compaction attempt instead of only persisting a raw + sanitized provider error - remaining: - keep auditing provider-specific opaque error strings that do not expose a typed status/source error yet From 1ebd9c1ee033aeb1758e873d354483594dbd19e2 Mon Sep 17 00:00:00 2001 From: panviktor <35385675+panviktor@users.noreply.github.com> Date: Sun, 12 Apr 2026 23:19:08 +0200 Subject: [PATCH 23/30] Type provider capability failures --- crates/adapters/core/src/agent/agent.rs | 3 +- .../src/agent/runtime_loop/tool_execution.rs | 6 ++-- .../src/agent/tool_repair_classification.rs | 3 +- .../src/application/services/tool_repair.rs | 23 ++++---------- crates/domain/src/ports/provider.rs | 30 ++++++++++++++++++- docs/fork/ipc-phase4_10-plan.md | 4 +++ 6 files changed, 46 insertions(+), 23 deletions(-) diff --git a/crates/adapters/core/src/agent/agent.rs b/crates/adapters/core/src/agent/agent.rs index 42f52297..9aea5b33 100644 --- a/crates/adapters/core/src/agent/agent.rs +++ b/crates/adapters/core/src/agent/agent.rs @@ -88,6 +88,7 @@ use synapse_domain::ports::channel_registry::ChannelRegistryPort; use synapse_domain::ports::conversation_context::ConversationContextPort; use synapse_domain::ports::conversation_store::ConversationStorePort; use synapse_domain::ports::history_compaction_cache::HistoryCompactionCachePort; +use synapse_domain::ports::provider::ProviderCapabilityRequirement; use synapse_domain::ports::route_selection::{ContextCacheStats, RouteAdmissionState}; use synapse_domain::ports::run_recipe_store::RunRecipeStorePort; use synapse_domain::ports::scoped_instruction_context::{ @@ -2709,7 +2710,7 @@ impl Agent { ); return Err(ProviderCapabilityError { provider: effective_provider.clone(), - capability: "vision".to_string(), + capability: ProviderCapabilityRequirement::VisionInput, message: format!( "received {image_marker_count} image marker(s), but this route does not support vision input" ), diff --git a/crates/adapters/core/src/agent/runtime_loop/tool_execution.rs b/crates/adapters/core/src/agent/runtime_loop/tool_execution.rs index 6daeeb4c..ba4d2d8c 100644 --- a/crates/adapters/core/src/agent/runtime_loop/tool_execution.rs +++ b/crates/adapters/core/src/agent/runtime_loop/tool_execution.rs @@ -13,7 +13,7 @@ use synapse_domain::application::services::model_lane_resolution::ResolvedModelP use synapse_domain::application::services::tool_repair::build_tool_repair_trace; use synapse_domain::domain::tool_fact::{OutcomeStatus, TypedToolFact}; use synapse_domain::domain::tool_repair::{ToolFailureKind, ToolRepairTrace}; -use synapse_domain::ports::provider::ProviderCapabilities; +use synapse_domain::ports::provider::{ProviderCapabilities, ProviderCapabilityRequirement}; use uuid::Uuid; #[derive(Debug)] @@ -532,7 +532,7 @@ pub(crate) async fn run_tool_call_loop( if !tool_specs.is_empty() && !provider.supports_native_tools() { return Err(ProviderCapabilityError { provider: provider_name.to_string(), - capability: "native_tool_calling".to_string(), + capability: ProviderCapabilityRequirement::NativeToolCalling, message: "tool-capable turns require native tool calling; prompt-guided tool fallback has been removed" .to_string(), } @@ -546,7 +546,7 @@ pub(crate) async fn run_tool_call_loop( ProviderCallCapabilityIssue::MissingVisionInput { image_marker_count } => { return Err(ProviderCapabilityError { provider: provider_name.to_string(), - capability: "vision".to_string(), + capability: ProviderCapabilityRequirement::VisionInput, message: format!( "received {image_marker_count} image marker(s), but this route does not support vision input" ), diff --git a/crates/adapters/core/src/agent/tool_repair_classification.rs b/crates/adapters/core/src/agent/tool_repair_classification.rs index e12647b5..042d3651 100644 --- a/crates/adapters/core/src/agent/tool_repair_classification.rs +++ b/crates/adapters/core/src/agent/tool_repair_classification.rs @@ -93,12 +93,13 @@ pub(crate) fn classify_tool_execution_error( #[cfg(test)] mod tests { use super::*; + use synapse_domain::ports::provider::ProviderCapabilityRequirement; #[test] fn capability_error_maps_to_lane_switch() { let error = anyhow::Error::new(ProviderCapabilityError { provider: "openrouter".into(), - capability: "vision".into(), + capability: ProviderCapabilityRequirement::VisionInput, message: "provider does not support vision".into(), }); diff --git a/crates/domain/src/application/services/tool_repair.rs b/crates/domain/src/application/services/tool_repair.rs index 6989ec5b..512476c7 100644 --- a/crates/domain/src/application/services/tool_repair.rs +++ b/crates/domain/src/application/services/tool_repair.rs @@ -1,5 +1,5 @@ -use crate::config::schema::CapabilityLane; use crate::domain::tool_repair::{ToolFailureKind, ToolRepairAction, ToolRepairTrace}; +use crate::ports::provider::ProviderCapabilityRequirement; use chrono::Utc; pub const TOOL_REPAIR_TRACE_TTL_SECS: i64 = 48 * 60 * 60; @@ -35,10 +35,11 @@ pub fn build_tool_repair_trace_with_action( pub fn build_tool_repair_trace_for_capability( tool_name: &str, - capability: &str, + capability: &ProviderCapabilityRequirement, detail: Option<&str>, ) -> ToolRepairTrace { - let suggested_action = capability_lane_for_name(capability) + let suggested_action = capability + .repair_lane() .map(ToolRepairAction::SwitchRouteLane) .unwrap_or(ToolRepairAction::InspectRuntimeFailure); build_tool_repair_trace_with_action( @@ -97,19 +98,6 @@ fn suggested_action_for_failure(failure_kind: ToolFailureKind) -> ToolRepairActi } } -fn capability_lane_for_name(capability: &str) -> Option { - match capability.trim().to_ascii_lowercase().as_str() { - "vision" | "image_input" | "multimodal_understanding" => { - Some(CapabilityLane::MultimodalUnderstanding) - } - "image_generation" | "image" => Some(CapabilityLane::ImageGeneration), - "audio_generation" | "audio" => Some(CapabilityLane::AudioGeneration), - "video_generation" | "video" => Some(CapabilityLane::VideoGeneration), - "music_generation" | "music" => Some(CapabilityLane::MusicGeneration), - _ => None, - } -} - fn same_repair_signature(left: &ToolRepairTrace, right: &ToolRepairTrace) -> bool { left.tool_name == right.tool_name && left.failure_kind == right.failure_kind @@ -150,6 +138,7 @@ fn trim_tool_repair_history(history: &mut Vec) { #[cfg(test)] mod tests { use super::*; + use crate::config::schema::CapabilityLane; #[test] fn maps_duplicate_invocation_to_duplicate_retry_guard() { @@ -196,7 +185,7 @@ mod tests { fn capability_trace_prefers_switch_route_lane_when_lane_is_known() { let trace = build_tool_repair_trace_for_capability( "image_info", - "vision", + &ProviderCapabilityRequirement::VisionInput, Some("provider does not support vision"), ); diff --git a/crates/domain/src/ports/provider.rs b/crates/domain/src/ports/provider.rs index 3cc43bef..8cf6cc45 100644 --- a/crates/domain/src/ports/provider.rs +++ b/crates/domain/src/ports/provider.rs @@ -5,6 +5,7 @@ //! because its streaming methods depend on `reqwest` and `futures_util`, //! but all data types it operates on are domain-owned. +use crate::config::schema::CapabilityLane; use crate::domain::message::ChatMessage; use crate::ports::tool::ToolSpec; use serde::{Deserialize, Serialize}; @@ -107,10 +108,37 @@ pub struct ProviderCapabilities { #[derive(Debug, Clone)] pub struct ProviderCapabilityError { pub provider: String, - pub capability: String, + pub capability: ProviderCapabilityRequirement, pub message: String, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProviderCapabilityRequirement { + NativeToolCalling, + VisionInput, + Lane(CapabilityLane), +} + +impl ProviderCapabilityRequirement { + pub fn repair_lane(&self) -> Option { + match self { + Self::VisionInput => Some(CapabilityLane::MultimodalUnderstanding), + Self::Lane(lane) => Some(*lane), + Self::NativeToolCalling => None, + } + } +} + +impl std::fmt::Display for ProviderCapabilityRequirement { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NativeToolCalling => write!(f, "native_tool_calling"), + Self::VisionInput => write!(f, "vision_input"), + Self::Lane(lane) => write!(f, "{}", lane.as_str()), + } + } +} + impl std::fmt::Display for ProviderCapabilityError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!( diff --git a/docs/fork/ipc-phase4_10-plan.md b/docs/fork/ipc-phase4_10-plan.md index 2fcaa659..3607f23d 100644 --- a/docs/fork/ipc-phase4_10-plan.md +++ b/docs/fork/ipc-phase4_10-plan.md @@ -1739,6 +1739,10 @@ Expected outcome: failures get the same typed recovery path and shared presentation with a session-hygiene compaction attempt instead of only persisting a raw sanitized provider error + - provider capability failures now carry typed capability requirements + (`native_tool_calling`, `vision_input`, or lane) instead of string + capability names; tool self-repair maps those typed requirements to lane + switches without lowercase/alias parsing in the governor path - remaining: - keep auditing provider-specific opaque error strings that do not expose a typed status/source error yet From d9c3377cb4815c8c90172f2179eca9d7c42cc4b9 Mon Sep 17 00:00:00 2001 From: panviktor <35385675+panviktor@users.noreply.github.com> Date: Sun, 12 Apr 2026 23:25:21 +0200 Subject: [PATCH 24/30] Share adapter runtime error classification --- .../src/agent/tool_repair_classification.rs | 115 +++++-------- .../runtime/runtime_error_classification.rs | 151 ++++++++++++------ docs/fork/ipc-phase4_10-plan.md | 4 + 3 files changed, 147 insertions(+), 123 deletions(-) diff --git a/crates/adapters/core/src/agent/tool_repair_classification.rs b/crates/adapters/core/src/agent/tool_repair_classification.rs index 042d3651..1ed8dc3e 100644 --- a/crates/adapters/core/src/agent/tool_repair_classification.rs +++ b/crates/adapters/core/src/agent/tool_repair_classification.rs @@ -1,99 +1,72 @@ +use crate::runtime::runtime_error_classification::{classify_runtime_error, RuntimeErrorClassKind}; use synapse_domain::application::services::tool_repair::{ build_tool_repair_trace, build_tool_repair_trace_for_capability, build_tool_repair_trace_with_action, }; use synapse_domain::domain::tool_repair::{ToolFailureKind, ToolRepairAction, ToolRepairTrace}; -use synapse_providers::ProviderCapabilityError; -use synapse_security::scrub_credentials; pub(crate) fn classify_tool_execution_error( tool_name: &str, error: &anyhow::Error, ) -> ToolRepairTrace { - if let Some(capability_error) = error.downcast_ref::() { + let class = classify_runtime_error(error); + if let Some(capability_error) = class.capability_error { return build_tool_repair_trace_for_capability( tool_name, &capability_error.capability, - Some(&scrub_credentials(&capability_error.message)), + Some(&class.detail), ); } - if let Some(io_error) = error.downcast_ref::() { - let detail = scrub_credentials(&error.to_string()); - return match io_error.kind() { - std::io::ErrorKind::PermissionDenied => { - build_tool_repair_trace(tool_name, ToolFailureKind::PolicyBlocked, Some(&detail)) - } - std::io::ErrorKind::NotFound => { - build_tool_repair_trace(tool_name, ToolFailureKind::MissingResource, Some(&detail)) - } - std::io::ErrorKind::TimedOut => { - build_tool_repair_trace(tool_name, ToolFailureKind::Timeout, Some(&detail)) - } - _ => build_tool_repair_trace(tool_name, ToolFailureKind::RuntimeError, Some(&detail)), - }; - } - - if error.downcast_ref::().is_some() { - return build_tool_repair_trace( + match class.kind { + RuntimeErrorClassKind::CapabilityMismatch => build_tool_repair_trace( tool_name, - ToolFailureKind::SchemaMismatch, - Some(&scrub_credentials(&error.to_string())), - ); - } - - if let Some(reqwest_error) = error.downcast_ref::() { - if reqwest_error.is_timeout() { - return build_tool_repair_trace( - tool_name, - ToolFailureKind::Timeout, - Some(&scrub_credentials(&error.to_string())), - ); - } - if let Some(status) = reqwest_error.status() { - let detail = scrub_credentials(&error.to_string()); - match status.as_u16() { - 401 | 403 => { - return build_tool_repair_trace_with_action( - tool_name, - ToolFailureKind::AuthFailure, - ToolRepairAction::AuthenticateOrConfigureCredentials, - Some(&detail), - ); - } - 413 => { - return build_tool_repair_trace_with_action( - tool_name, - ToolFailureKind::ContextLimitExceeded, - ToolRepairAction::CompactSessionOrStartFreshHandoff, - Some(&detail), - ); - } - _ => {} - } + ToolFailureKind::CapabilityMismatch, + Some(&class.detail), + ), + RuntimeErrorClassKind::AuthFailure => build_tool_repair_trace_with_action( + tool_name, + ToolFailureKind::AuthFailure, + ToolRepairAction::AuthenticateOrConfigureCredentials, + Some(&class.detail), + ), + RuntimeErrorClassKind::PolicyBlocked => build_tool_repair_trace( + tool_name, + ToolFailureKind::PolicyBlocked, + Some(&class.detail), + ), + RuntimeErrorClassKind::MissingResource => build_tool_repair_trace( + tool_name, + ToolFailureKind::MissingResource, + Some(&class.detail), + ), + RuntimeErrorClassKind::Timeout => { + build_tool_repair_trace(tool_name, ToolFailureKind::Timeout, Some(&class.detail)) } + RuntimeErrorClassKind::SchemaMismatch => build_tool_repair_trace( + tool_name, + ToolFailureKind::SchemaMismatch, + Some(&class.detail), + ), + RuntimeErrorClassKind::ContextLimitExceeded => build_tool_repair_trace_with_action( + tool_name, + ToolFailureKind::ContextLimitExceeded, + ToolRepairAction::CompactSessionOrStartFreshHandoff, + Some(&class.detail), + ), + RuntimeErrorClassKind::RuntimeFailure => build_tool_repair_trace( + tool_name, + ToolFailureKind::RuntimeError, + Some(&class.detail), + ), } - - let detail = scrub_credentials(&error.to_string()); - let lower = detail.to_ascii_lowercase(); - if lower.contains("missing field") - || lower.contains("unknown variant") - || lower.contains("invalid type") - || lower.contains("expected ") - { - return build_tool_repair_trace(tool_name, ToolFailureKind::SchemaMismatch, Some(&detail)); - } - if lower.contains("timed out") || lower.contains("timeout") { - return build_tool_repair_trace(tool_name, ToolFailureKind::Timeout, Some(&detail)); - } - - build_tool_repair_trace(tool_name, ToolFailureKind::RuntimeError, Some(&detail)) } #[cfg(test)] mod tests { use super::*; use synapse_domain::ports::provider::ProviderCapabilityRequirement; + use synapse_providers::ProviderCapabilityError; #[test] fn capability_error_maps_to_lane_switch() { diff --git a/crates/adapters/core/src/runtime/runtime_error_classification.rs b/crates/adapters/core/src/runtime/runtime_error_classification.rs index 60851531..fc0b5377 100644 --- a/crates/adapters/core/src/runtime/runtime_error_classification.rs +++ b/crates/adapters/core/src/runtime/runtime_error_classification.rs @@ -3,72 +3,111 @@ use synapse_providers::error_classification::classify_context_limit_error; use synapse_providers::ProviderCapabilityError; use synapse_security::scrub_credentials; -pub(crate) fn classify_agent_runtime_error(err: anyhow::Error) -> AgentRuntimeError { - if err.downcast_ref::().is_some() { - return AgentRuntimeError::new( - AgentRuntimeErrorKind::CapabilityMismatch, - scrub_credentials(&err.to_string()), - ); +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum RuntimeErrorClassKind { + CapabilityMismatch, + AuthFailure, + PolicyBlocked, + MissingResource, + Timeout, + SchemaMismatch, + ContextLimitExceeded, + RuntimeFailure, +} + +pub(crate) struct RuntimeErrorClass<'a> { + pub kind: RuntimeErrorClassKind, + pub capability_error: Option<&'a ProviderCapabilityError>, + pub detail: String, +} + +pub(crate) fn classify_runtime_error(error: &anyhow::Error) -> RuntimeErrorClass<'_> { + let detail = scrub_credentials(&error.to_string()); + + if let Some(capability_error) = error.downcast_ref::() { + return RuntimeErrorClass { + kind: RuntimeErrorClassKind::CapabilityMismatch, + capability_error: Some(capability_error), + detail, + }; } - if let Some(io_error) = err.downcast_ref::() { - let detail = scrub_credentials(&err.to_string()); - return match io_error.kind() { - std::io::ErrorKind::PermissionDenied => { - AgentRuntimeError::new(AgentRuntimeErrorKind::PolicyBlocked, detail) - } - std::io::ErrorKind::NotFound => { - AgentRuntimeError::new(AgentRuntimeErrorKind::MissingResource, detail) - } - std::io::ErrorKind::TimedOut => { - AgentRuntimeError::new(AgentRuntimeErrorKind::Timeout, detail) - } - _ => AgentRuntimeError::new(AgentRuntimeErrorKind::RuntimeFailure, detail), + if let Some(io_error) = error.downcast_ref::() { + let kind = match io_error.kind() { + std::io::ErrorKind::PermissionDenied => RuntimeErrorClassKind::PolicyBlocked, + std::io::ErrorKind::NotFound => RuntimeErrorClassKind::MissingResource, + std::io::ErrorKind::TimedOut => RuntimeErrorClassKind::Timeout, + _ => RuntimeErrorClassKind::RuntimeFailure, + }; + return RuntimeErrorClass { + kind, + capability_error: None, + detail, }; } - if err.downcast_ref::().is_some() { - return AgentRuntimeError::new( - AgentRuntimeErrorKind::SchemaMismatch, - scrub_credentials(&err.to_string()), - ); + if error.downcast_ref::().is_some() { + return RuntimeErrorClass { + kind: RuntimeErrorClassKind::SchemaMismatch, + capability_error: None, + detail, + }; } - if let Some(reqwest_err) = err.downcast_ref::() { - if reqwest_err.is_timeout() { - return AgentRuntimeError::new( - AgentRuntimeErrorKind::Timeout, - scrub_credentials(&err.to_string()), - ); + if let Some(reqwest_error) = error.downcast_ref::() { + if reqwest_error.is_timeout() { + return RuntimeErrorClass { + kind: RuntimeErrorClassKind::Timeout, + capability_error: None, + detail, + }; } - if let Some(status) = reqwest_err.status() { - return match status.as_u16() { - 401 | 403 => AgentRuntimeError::new( - AgentRuntimeErrorKind::AuthFailure, - scrub_credentials(&err.to_string()), - ), - 404 => AgentRuntimeError::new( - AgentRuntimeErrorKind::MissingResource, - scrub_credentials(&err.to_string()), - ), - 413 => AgentRuntimeError::new( - AgentRuntimeErrorKind::ContextLimitExceeded, - scrub_credentials(&err.to_string()), - ), - _ => AgentRuntimeError::new( - AgentRuntimeErrorKind::RuntimeFailure, - scrub_credentials(&err.to_string()), - ), + if let Some(status) = reqwest_error.status() { + let kind = match status.as_u16() { + 401 | 403 => RuntimeErrorClassKind::AuthFailure, + 404 => RuntimeErrorClassKind::MissingResource, + 413 => RuntimeErrorClassKind::ContextLimitExceeded, + _ => RuntimeErrorClassKind::RuntimeFailure, + }; + return RuntimeErrorClass { + kind, + capability_error: None, + detail, }; } } - let detail = scrub_credentials(&err.to_string()); - if classify_context_limit_error(&err).is_some() { - return AgentRuntimeError::new(AgentRuntimeErrorKind::ContextLimitExceeded, detail); + if classify_context_limit_error(error).is_some() { + return RuntimeErrorClass { + kind: RuntimeErrorClassKind::ContextLimitExceeded, + capability_error: None, + detail, + }; } - AgentRuntimeError::new(AgentRuntimeErrorKind::RuntimeFailure, detail) + RuntimeErrorClass { + kind: RuntimeErrorClassKind::RuntimeFailure, + capability_error: None, + detail, + } +} + +pub(crate) fn classify_agent_runtime_error(err: anyhow::Error) -> AgentRuntimeError { + let class = classify_runtime_error(&err); + AgentRuntimeError::new(agent_runtime_error_kind(class.kind), class.detail) +} + +fn agent_runtime_error_kind(kind: RuntimeErrorClassKind) -> AgentRuntimeErrorKind { + match kind { + RuntimeErrorClassKind::CapabilityMismatch => AgentRuntimeErrorKind::CapabilityMismatch, + RuntimeErrorClassKind::AuthFailure => AgentRuntimeErrorKind::AuthFailure, + RuntimeErrorClassKind::PolicyBlocked => AgentRuntimeErrorKind::PolicyBlocked, + RuntimeErrorClassKind::MissingResource => AgentRuntimeErrorKind::MissingResource, + RuntimeErrorClassKind::Timeout => AgentRuntimeErrorKind::Timeout, + RuntimeErrorClassKind::SchemaMismatch => AgentRuntimeErrorKind::SchemaMismatch, + RuntimeErrorClassKind::ContextLimitExceeded => AgentRuntimeErrorKind::ContextLimitExceeded, + RuntimeErrorClassKind::RuntimeFailure => AgentRuntimeErrorKind::RuntimeFailure, + } } #[cfg(test)] @@ -108,4 +147,12 @@ mod tests { assert_eq!(classified.kind, AgentRuntimeErrorKind::ContextLimitExceeded); } + + #[test] + fn common_classifier_preserves_context_limit_kind() { + let error = anyhow::anyhow!("provider error: prompt is too long"); + let classified = classify_runtime_error(&error); + + assert_eq!(classified.kind, RuntimeErrorClassKind::ContextLimitExceeded); + } } diff --git a/docs/fork/ipc-phase4_10-plan.md b/docs/fork/ipc-phase4_10-plan.md index 3607f23d..c7c65c76 100644 --- a/docs/fork/ipc-phase4_10-plan.md +++ b/docs/fork/ipc-phase4_10-plan.md @@ -1743,6 +1743,10 @@ Expected outcome: (`native_tool_calling`, `vision_input`, or lane) instead of string capability names; tool self-repair maps those typed requirements to lane switches without lowercase/alias parsing in the governor path + - `AgentRuntimeErrorKind` and `ToolFailureKind` now project from one shared + adapter-core runtime error classifier, so typed IO/JSON/HTTP/provider + error handling does not drift between web/channel runtime recovery and + tool self-repair traces - remaining: - keep auditing provider-specific opaque error strings that do not expose a typed status/source error yet From 352b844419e1b091d3bdded23395f1c77c996cfc Mon Sep 17 00:00:00 2001 From: panviktor <35385675+panviktor@users.noreply.github.com> Date: Sun, 12 Apr 2026 23:33:32 +0200 Subject: [PATCH 25/30] Require explicit Azure OpenAI deployment settings --- crates/adapters/core/src/channels/mod.rs | 13 +- crates/adapters/core/src/gateway/mod.rs | 13 +- crates/adapters/core/src/tools/mod.rs | 17 +- crates/adapters/providers/src/lib.rs | 180 +++++++++++++++++- crates/adapters/providers/src/openai_codex.rs | 3 + docs/fork/ipc-phase4_10-plan.md | 4 + 6 files changed, 185 insertions(+), 45 deletions(-) diff --git a/crates/adapters/core/src/channels/mod.rs b/crates/adapters/core/src/channels/mod.rs index ea72e20d..f072abf6 100644 --- a/crates/adapters/core/src/channels/mod.rs +++ b/crates/adapters/core/src/channels/mod.rs @@ -2330,18 +2330,7 @@ pub async fn start_channels( }; let provider_name = resolved_default_provider(&config)?; - let provider_runtime_options = synapse_providers::ProviderRuntimeOptions { - auth_profile_override: None, - provider_api_url: config.api_url.clone(), - synapseclaw_dir: config.config_path.parent().map(std::path::PathBuf::from), - secrets_encrypt: config.secrets.encrypt, - reasoning_enabled: config.runtime.reasoning_enabled, - reasoning_effort: config.runtime.reasoning_effort.clone(), - provider_timeout_secs: Some(config.provider_timeout_secs), - extra_headers: config.extra_headers.clone(), - api_path: config.api_path.clone(), - prompt_caching: config.agent.prompt_caching, - }; + let provider_runtime_options = synapse_providers::provider_runtime_options_from_config(&config); let provider: Arc = Arc::from( create_resilient_provider_nonblocking( &provider_name, diff --git a/crates/adapters/core/src/gateway/mod.rs b/crates/adapters/core/src/gateway/mod.rs index ce5501d3..39238137 100644 --- a/crates/adapters/core/src/gateway/mod.rs +++ b/crates/adapters/core/src/gateway/mod.rs @@ -505,18 +505,7 @@ pub async fn run_gateway( config.api_key.as_deref(), config.api_url.as_deref(), &config.reliability, - &synapse_providers::ProviderRuntimeOptions { - auth_profile_override: None, - provider_api_url: config.api_url.clone(), - synapseclaw_dir: config.config_path.parent().map(std::path::PathBuf::from), - secrets_encrypt: config.secrets.encrypt, - reasoning_enabled: config.runtime.reasoning_enabled, - reasoning_effort: config.runtime.reasoning_effort.clone(), - provider_timeout_secs: Some(config.provider_timeout_secs), - extra_headers: config.extra_headers.clone(), - api_path: config.api_path.clone(), - prompt_caching: config.agent.prompt_caching, - }, + &synapse_providers::provider_runtime_options_from_config(&config), )?); let model = config .default_model diff --git a/crates/adapters/core/src/tools/mod.rs b/crates/adapters/core/src/tools/mod.rs index 380205a2..1c3cd09c 100644 --- a/crates/adapters/core/src/tools/mod.rs +++ b/crates/adapters/core/src/tools/mod.rs @@ -736,21 +736,8 @@ pub fn all_tools_with_runtime( let trimmed_value = value.trim(); (!trimmed_value.is_empty()).then(|| trimmed_value.to_owned()) }); - let provider_runtime_options = synapse_providers::ProviderRuntimeOptions { - auth_profile_override: None, - provider_api_url: root_config.api_url.clone(), - synapseclaw_dir: root_config - .config_path - .parent() - .map(std::path::PathBuf::from), - secrets_encrypt: root_config.secrets.encrypt, - reasoning_enabled: root_config.runtime.reasoning_enabled, - reasoning_effort: root_config.runtime.reasoning_effort.clone(), - provider_timeout_secs: Some(root_config.provider_timeout_secs), - extra_headers: root_config.extra_headers.clone(), - api_path: root_config.api_path.clone(), - prompt_caching: root_config.agent.prompt_caching, - }; + let provider_runtime_options = + synapse_providers::provider_runtime_options_from_config(root_config); let delegate_handle: Option = if agents.is_empty() { None diff --git a/crates/adapters/providers/src/lib.rs b/crates/adapters/providers/src/lib.rs index 960199c8..3e631a38 100644 --- a/crates/adapters/providers/src/lib.rs +++ b/crates/adapters/providers/src/lib.rs @@ -708,6 +708,14 @@ pub struct ProviderRuntimeOptions { pub api_path: Option, /// Enable prompt caching (cache_control breakpoints) for Anthropic provider. pub prompt_caching: bool, + /// Azure OpenAI resource name. Required for the Azure provider unless + /// `AZURE_OPENAI_RESOURCE` is set. + pub azure_openai_resource: Option, + /// Azure OpenAI deployment name. Required for the Azure provider unless + /// `AZURE_OPENAI_DEPLOYMENT` is set. + pub azure_openai_deployment: Option, + /// Azure OpenAI API version override. + pub azure_openai_api_version: Option, } impl Default for ProviderRuntimeOptions { @@ -723,6 +731,9 @@ impl Default for ProviderRuntimeOptions { extra_headers: std::collections::HashMap::new(), api_path: None, prompt_caching: false, + azure_openai_resource: None, + azure_openai_deployment: None, + azure_openai_api_version: None, } } } @@ -730,6 +741,7 @@ impl Default for ProviderRuntimeOptions { pub fn provider_runtime_options_from_config( config: &synapse_domain::config::schema::Config, ) -> ProviderRuntimeOptions { + let model_provider = model_provider_config_for_default(config); ProviderRuntimeOptions { auth_profile_override: None, provider_api_url: config.api_url.clone(), @@ -741,7 +753,53 @@ pub fn provider_runtime_options_from_config( extra_headers: config.extra_headers.clone(), api_path: config.api_path.clone(), prompt_caching: config.agent.prompt_caching, - } + azure_openai_resource: model_provider.and_then(|provider| { + provider + .azure_openai_resource + .as_deref() + .map(str::to_string) + }), + azure_openai_deployment: model_provider.and_then(|provider| { + provider + .azure_openai_deployment + .as_deref() + .map(str::to_string) + }), + azure_openai_api_version: model_provider.and_then(|provider| { + provider + .azure_openai_api_version + .as_deref() + .map(str::to_string) + }), + } +} + +fn model_provider_config_for_default( + config: &synapse_domain::config::schema::Config, +) -> Option<&synapse_domain::config::schema::ModelProviderConfig> { + let provider = config.default_provider.as_deref()?; + config + .model_providers + .get(provider) + .or_else(|| { + config + .model_providers + .iter() + .find(|(key, _)| key.eq_ignore_ascii_case(provider)) + .map(|(_, value)| value) + }) + .or_else(|| { + list_providers() + .into_iter() + .find(|known| { + known.name.eq_ignore_ascii_case(provider) + || known + .aliases + .iter() + .any(|alias| alias.eq_ignore_ascii_case(provider)) + }) + .and_then(|known| config.model_providers.get(known.name)) + }) } fn is_secret_char(c: char) -> bool { @@ -962,6 +1020,42 @@ fn resolve_provider_credential(name: &str, credential_override: Option<&str>) -> None } +fn resolve_required_provider_setting( + configured: Option<&str>, + env_key: &str, + provider_label: &str, + config_key: &str, +) -> anyhow::Result { + configured + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) + .or_else(|| { + std::env::var(env_key) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + }) + .ok_or_else(|| { + anyhow::anyhow!( + "{provider_label} requires `{config_key}` or `{env_key}`; refusing to invent a default resource/deployment" + ) + }) +} + +fn resolve_optional_provider_setting(configured: Option<&str>, env_key: &str) -> Option { + configured + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) + .or_else(|| { + std::env::var(env_key) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + }) +} + /// Check whether an API key's prefix matches the selected provider. /// /// Returns `Some("likely_provider")` when the key clearly belongs to a @@ -1243,11 +1337,22 @@ fn create_provider_with_url_and_options( ) )), "azure_openai" | "azure-openai" | "azure" => { - let resource = std::env::var("AZURE_OPENAI_RESOURCE") - .unwrap_or_else(|_| "my-resource".to_string()); - let deployment = std::env::var("AZURE_OPENAI_DEPLOYMENT") - .unwrap_or_else(|_| "gpt-4o".to_string()); - let api_version = std::env::var("AZURE_OPENAI_API_VERSION").ok(); + let resource = resolve_required_provider_setting( + options.azure_openai_resource.as_deref(), + "AZURE_OPENAI_RESOURCE", + "Azure OpenAI", + "azure_openai_resource", + )?; + let deployment = resolve_required_provider_setting( + options.azure_openai_deployment.as_deref(), + "AZURE_OPENAI_DEPLOYMENT", + "Azure OpenAI", + "azure_openai_deployment", + )?; + let api_version = resolve_optional_provider_setting( + options.azure_openai_api_version.as_deref(), + "AZURE_OPENAI_API_VERSION", + ); Ok(Box::new(azure_openai::AzureOpenAiProvider::new( key, &resource, @@ -3540,6 +3645,69 @@ mod tests { assert_eq!(options.extra_headers.get("X-Title").unwrap(), "synapseclaw"); } + #[test] + fn provider_runtime_options_resolve_azure_profile_by_alias() { + let mut config = synapse_domain::config::schema::Config { + default_provider: Some("azure".to_string()), + ..synapse_domain::config::schema::Config::default() + }; + config.model_providers.insert( + "azure_openai".to_string(), + synapse_domain::config::schema::ModelProviderConfig { + azure_openai_resource: Some("contoso-ai".to_string()), + azure_openai_deployment: Some("deployment-42".to_string()), + azure_openai_api_version: Some("2024-06-01".to_string()), + ..synapse_domain::config::schema::ModelProviderConfig::default() + }, + ); + + let options = provider_runtime_options_from_config(&config); + + assert_eq!(options.azure_openai_resource.as_deref(), Some("contoso-ai")); + assert_eq!( + options.azure_openai_deployment.as_deref(), + Some("deployment-42") + ); + assert_eq!( + options.azure_openai_api_version.as_deref(), + Some("2024-06-01") + ); + } + + #[test] + fn azure_provider_requires_explicit_resource_and_deployment() { + let _env_lock = env_lock(); + let _resource_guard = EnvGuard::set("AZURE_OPENAI_RESOURCE", None); + let _deployment_guard = EnvGuard::set("AZURE_OPENAI_DEPLOYMENT", None); + + let err = match create_provider_with_url("azure", Some("azure-test-key"), None) { + Ok(_) => panic!("Azure provider should not invent resource/deployment defaults"), + Err(error) => error, + }; + + let message = err.to_string(); + assert!(message.contains("azure_openai_resource")); + assert!(message.contains("AZURE_OPENAI_RESOURCE")); + } + + #[test] + fn azure_provider_accepts_runtime_options_for_resource_and_deployment() { + let _env_lock = env_lock(); + let _resource_guard = EnvGuard::set("AZURE_OPENAI_RESOURCE", None); + let _deployment_guard = EnvGuard::set("AZURE_OPENAI_DEPLOYMENT", None); + let options = ProviderRuntimeOptions { + azure_openai_resource: Some("contoso-ai".to_string()), + azure_openai_deployment: Some("deployment-42".to_string()), + azure_openai_api_version: Some("2024-06-01".to_string()), + ..ProviderRuntimeOptions::default() + }; + + let provider = + create_provider_with_url_and_options("azure", Some("azure-test-key"), None, &options); + + assert!(provider.is_ok()); + } + #[test] fn env_provider_url_overrides_api_url() { std::env::set_var("SYNAPSECLAW_PROVIDER_URL", "http://env-ollama:11434"); diff --git a/crates/adapters/providers/src/openai_codex.rs b/crates/adapters/providers/src/openai_codex.rs index 2bce1832..652c2d3a 100644 --- a/crates/adapters/providers/src/openai_codex.rs +++ b/crates/adapters/providers/src/openai_codex.rs @@ -2628,6 +2628,9 @@ data: [DONE] extra_headers: std::collections::HashMap::new(), api_path: None, prompt_caching: false, + azure_openai_resource: None, + azure_openai_deployment: None, + azure_openai_api_version: None, }; let provider = OpenAiCodexProvider::new(&options, None).expect("provider should initialize"); diff --git a/docs/fork/ipc-phase4_10-plan.md b/docs/fork/ipc-phase4_10-plan.md index c7c65c76..d1625046 100644 --- a/docs/fork/ipc-phase4_10-plan.md +++ b/docs/fork/ipc-phase4_10-plan.md @@ -701,6 +701,10 @@ Expected outcome: `embedding_profiles` instead of adapter-side model-family substring inference; unknown embedding models disable embeddings until a catalog profile is supplied, rather than receiving a silent generic profile + - Azure OpenAI resource/deployment/runtime API-version settings now flow + through shared provider runtime options from `model_providers` or env, + and the provider factory fails loud instead of inventing `my-resource` + or a stale model deployment default - Slice 13 initial pressure snapshot: - `ProviderContextBudgetInput` now tracks artifact-level breakdown for: - bootstrap From 59a44343a857fffc8296b470ebf88f7c41c329f9 Mon Sep 17 00:00:00 2001 From: panviktor <35385675+panviktor@users.noreply.github.com> Date: Sun, 12 Apr 2026 23:37:20 +0200 Subject: [PATCH 26/30] Share provider image marker parsing --- crates/adapters/providers/src/bedrock.rs | 112 ++++++++++---------- crates/adapters/providers/src/multimodal.rs | 54 ++++++++-- docs/fork/ipc-phase4_10-plan.md | 4 + 3 files changed, 106 insertions(+), 64 deletions(-) diff --git a/crates/adapters/providers/src/bedrock.rs b/crates/adapters/providers/src/bedrock.rs index d8e0e5d7..e280029c 100644 --- a/crates/adapters/providers/src/bedrock.rs +++ b/crates/adapters/providers/src/bedrock.rs @@ -668,72 +668,45 @@ impl BedrockProvider { /// Parse user message content, extracting [IMAGE:data:...] markers into image blocks. fn parse_user_content_blocks(content: &str) -> Vec { let mut blocks: Vec = Vec::new(); - let mut remaining = content; - let has_image = content.contains("[IMAGE:"); - tracing::info!( - "parse_user_content_blocks called, len={}, has_image={}", - content.len(), - has_image - ); - - while let Some(start) = remaining.find("[IMAGE:") { - // Add any text before the marker - let text_before = &remaining[..start]; - if !text_before.trim().is_empty() { - blocks.push(ContentBlock::Text(TextBlock { - text: text_before.to_string(), - })); - } - let after = &remaining[start + 7..]; // skip "[IMAGE:" - if let Some(end) = after.find(']') { - let src = &after[..end]; - remaining = &after[end + 1..]; - - // Only handle data URIs (base64 encoded images) - if let Some(rest) = src.strip_prefix("data:") { - if let Some(semi) = rest.find(';') { - let mime = &rest[..semi]; - let after_semi = &rest[semi + 1..]; - if let Some(b64) = after_semi.strip_prefix("base64,") { - let format = match mime { - "image/png" => "png", - "image/gif" => "gif", - "image/webp" => "webp", - _ => "jpeg", - }; - blocks.push(ContentBlock::Image(ImageWrapper { - image: ImageBlock { - format: format.to_string(), - source: ImageSource { - bytes: b64.to_string(), + for part in crate::multimodal::parse_image_marker_parts(content) { + match part { + crate::multimodal::ImageMarkerPart::Text(text) => { + if !text.trim().is_empty() { + blocks.push(ContentBlock::Text(TextBlock { text })); + } + } + crate::multimodal::ImageMarkerPart::ImageRef(src) => { + if let Some(rest) = src.strip_prefix("data:") { + if let Some(semi) = rest.find(';') { + let mime = &rest[..semi]; + let after_semi = &rest[semi + 1..]; + if let Some(b64) = after_semi.strip_prefix("base64,") { + let format = match mime { + "image/png" => "png", + "image/gif" => "gif", + "image/webp" => "webp", + _ => "jpeg", + }; + blocks.push(ContentBlock::Image(ImageWrapper { + image: ImageBlock { + format: format.to_string(), + source: ImageSource { + bytes: b64.to_string(), + }, }, - }, - })); - continue; + })); + continue; + } } } + blocks.push(ContentBlock::Text(TextBlock { + text: format!("[image: {}]", src), + })); } - // Non-data-uri image: just include as text reference - blocks.push(ContentBlock::Text(TextBlock { - text: format!("[image: {}]", src), - })); - } else { - // No closing bracket, treat rest as text - blocks.push(ContentBlock::Text(TextBlock { - text: remaining.to_string(), - })); - break; } } - // Add any remaining text - if !remaining.trim().is_empty() { - blocks.push(ContentBlock::Text(TextBlock { - text: remaining.to_string(), - })); - } - if blocks.is_empty() { blocks.push(ContentBlock::Text(TextBlock { text: content.to_string(), @@ -1193,6 +1166,29 @@ mod tests { assert!(auth.contains("/us-east-1/bedrock/aws4_request")); } + #[test] + fn parse_user_content_blocks_preserves_text_around_image_markers() { + let blocks = BedrockProvider::parse_user_content_blocks( + "before [IMAGE:data:image/png;base64,abcd] after", + ); + + assert_eq!(blocks.len(), 3); + assert!(matches!( + &blocks[0], + ContentBlock::Text(TextBlock { text }) if text == "before " + )); + assert!(matches!( + &blocks[1], + ContentBlock::Image(ImageWrapper { + image: ImageBlock { format, source } + }) if format == "png" && source.bytes == "abcd" + )); + assert!(matches!( + &blocks[2], + ContentBlock::Text(TextBlock { text }) if text == " after" + )); + } + #[test] fn build_authorization_header_includes_security_token_in_signed_headers() { let credentials = AwsCredentials { diff --git a/crates/adapters/providers/src/multimodal.rs b/crates/adapters/providers/src/multimodal.rs index 2925ee45..28c32ce3 100644 --- a/crates/adapters/providers/src/multimodal.rs +++ b/crates/adapters/providers/src/multimodal.rs @@ -20,6 +20,12 @@ pub struct PreparedMessages { pub contains_images: bool, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ImageMarkerPart { + Text(String), + ImageRef(String), +} + #[derive(Debug, thiserror::Error)] pub enum MultimodalError { #[error("multimodal image limit exceeded: max_images={max_images}, found={found}")] @@ -54,15 +60,28 @@ pub enum MultimodalError { pub fn parse_image_markers(content: &str) -> (String, Vec) { let mut refs = Vec::new(); let mut cleaned = String::with_capacity(content.len()); + + for part in parse_image_marker_parts(content) { + match part { + ImageMarkerPart::Text(text) => cleaned.push_str(&text), + ImageMarkerPart::ImageRef(image_ref) => refs.push(image_ref), + } + } + + (cleaned.trim().to_string(), refs) +} + +pub fn parse_image_marker_parts(content: &str) -> Vec { + let mut parts = Vec::new(); let mut cursor = 0usize; while let Some(rel_start) = content[cursor..].find(IMAGE_MARKER_PREFIX) { let start = cursor + rel_start; - cleaned.push_str(&content[cursor..start]); + push_text_part(&mut parts, &content[cursor..start]); let marker_start = start + IMAGE_MARKER_PREFIX.len(); let Some(rel_end) = content[marker_start..].find(']') else { - cleaned.push_str(&content[start..]); + push_text_part(&mut parts, &content[start..]); cursor = content.len(); break; }; @@ -71,19 +90,25 @@ pub fn parse_image_markers(content: &str) -> (String, Vec) { let candidate = content[marker_start..end].trim(); if candidate.is_empty() { - cleaned.push_str(&content[start..=end]); + push_text_part(&mut parts, &content[start..=end]); } else { - refs.push(candidate.to_string()); + parts.push(ImageMarkerPart::ImageRef(candidate.to_string())); } cursor = end + 1; } if cursor < content.len() { - cleaned.push_str(&content[cursor..]); + push_text_part(&mut parts, &content[cursor..]); } - (cleaned.trim().to_string(), refs) + parts +} + +fn push_text_part(parts: &mut Vec, text: &str) { + if !text.is_empty() { + parts.push(ImageMarkerPart::Text(text.to_string())); + } } pub fn count_image_markers(messages: &[ChatMessage]) -> usize { @@ -525,6 +550,23 @@ mod tests { assert_eq!(refs[1], "https://example.com/b.jpg"); } + #[test] + fn parse_image_marker_parts_preserves_interleaved_order() { + let input = "left [IMAGE:/tmp/a.png] middle [IMAGE:/tmp/b.png] right"; + let parts = parse_image_marker_parts(input); + + assert_eq!( + parts, + vec![ + ImageMarkerPart::Text("left ".to_string()), + ImageMarkerPart::ImageRef("/tmp/a.png".to_string()), + ImageMarkerPart::Text(" middle ".to_string()), + ImageMarkerPart::ImageRef("/tmp/b.png".to_string()), + ImageMarkerPart::Text(" right".to_string()), + ] + ); + } + #[test] fn parse_image_markers_keeps_invalid_empty_marker() { let input = "hello [IMAGE:] world"; diff --git a/docs/fork/ipc-phase4_10-plan.md b/docs/fork/ipc-phase4_10-plan.md index d1625046..d7efb8bb 100644 --- a/docs/fork/ipc-phase4_10-plan.md +++ b/docs/fork/ipc-phase4_10-plan.md @@ -1610,6 +1610,10 @@ Expected outcome: - shared runtime command presentation now renders lane-aware model switch outcomes as `lane -> provider:model`; web and channel receive the same lane-first success/block text through the common runtime adapter contract + - Bedrock's provider adapter now consumes the shared provider-layer + multimodal image-marker parser instead of carrying a second `[IMAGE:]` + scanner, keeping marker semantics centralized without moving them into + core routing policy - remaining: - modality inference remains conservative and marker-based; richer intent recognition should come later through typed interpretation or an explicit classifier, not keyword lists From 50857eef84baa7aaaef62fcb3b9f1834a4b33085 Mon Sep 17 00:00:00 2001 From: panviktor <35385675+panviktor@users.noreply.github.com> Date: Sun, 12 Apr 2026 23:38:35 +0200 Subject: [PATCH 27/30] Use shared provider options in daemon consolidation --- crates/adapters/core/src/daemon/mod.rs | 3 ++- docs/fork/ipc-phase4_10-plan.md | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/adapters/core/src/daemon/mod.rs b/crates/adapters/core/src/daemon/mod.rs index 648ad37b..527416ba 100644 --- a/crates/adapters/core/src/daemon/mod.rs +++ b/crates/adapters/core/src/daemon/mod.rs @@ -470,11 +470,12 @@ pub async fn run( String, )>; let mem: std::sync::Arc = - match synapse_providers::create_resilient_provider( + match synapse_providers::create_resilient_provider_with_options( default_provider.as_str(), config.api_key.as_deref(), config.api_url.as_deref(), &config.reliability, + &synapse_providers::provider_runtime_options_from_config(&config), ) { Ok(p) => { let prov_arc: std::sync::Arc = diff --git a/docs/fork/ipc-phase4_10-plan.md b/docs/fork/ipc-phase4_10-plan.md index d7efb8bb..9d1b6efa 100644 --- a/docs/fork/ipc-phase4_10-plan.md +++ b/docs/fork/ipc-phase4_10-plan.md @@ -705,6 +705,10 @@ Expected outcome: through shared provider runtime options from `model_providers` or env, and the provider factory fails loud instead of inventing `my-resource` or a stale model deployment default + - gateway, channel startup, tool delegation setup, and daemon memory + consolidation now build provider runtime options through the same helper, + so provider profile fields and request controls do not drift by + lifecycle path - Slice 13 initial pressure snapshot: - `ProviderContextBudgetInput` now tracks artifact-level breakdown for: - bootstrap From fe7f062c6a6173edcce6d5134076d73e1d62c135 Mon Sep 17 00:00:00 2001 From: panviktor <35385675+panviktor@users.noreply.github.com> Date: Sun, 12 Apr 2026 23:40:18 +0200 Subject: [PATCH 28/30] Use provider options in model route probes --- crates/adapters/providers/src/lib.rs | 2 +- crates/adapters/tools/src/model_routing_config.rs | 5 ++++- docs/fork/ipc-phase4_10-plan.md | 3 +++ 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/adapters/providers/src/lib.rs b/crates/adapters/providers/src/lib.rs index 3e631a38..1c3b9b36 100644 --- a/crates/adapters/providers/src/lib.rs +++ b/crates/adapters/providers/src/lib.rs @@ -1158,7 +1158,7 @@ pub fn create_provider_with_url( /// Factory: create provider with optional base URL and runtime options. #[allow(clippy::too_many_lines)] -fn create_provider_with_url_and_options( +pub fn create_provider_with_url_and_options( name: &str, api_key: Option<&str>, api_url: Option<&str>, diff --git a/crates/adapters/tools/src/model_routing_config.rs b/crates/adapters/tools/src/model_routing_config.rs index df668dda..f4984719 100644 --- a/crates/adapters/tools/src/model_routing_config.rs +++ b/crates/adapters/tools/src/model_routing_config.rs @@ -666,10 +666,13 @@ impl ModelRoutingConfigTool { return Ok(()); } - let provider = match synapse_providers::create_provider_with_url( + let provider_runtime_options = + synapse_providers::provider_runtime_options_from_config(&self.config); + let provider = match synapse_providers::create_provider_with_url_and_options( provider_name, api_key, self.config.api_url.as_deref(), + &provider_runtime_options, ) { Ok(p) => p, Err(_) => return Ok(()), diff --git a/docs/fork/ipc-phase4_10-plan.md b/docs/fork/ipc-phase4_10-plan.md index 9d1b6efa..42af3106 100644 --- a/docs/fork/ipc-phase4_10-plan.md +++ b/docs/fork/ipc-phase4_10-plan.md @@ -709,6 +709,9 @@ Expected outcome: consolidation now build provider runtime options through the same helper, so provider profile fields and request controls do not drift by lifecycle path + - model-routing config probes can now use the same provider runtime options + while still preserving explicit custom `api_url`, avoiding a split + between probe-time reachability checks and normal runtime provider setup - Slice 13 initial pressure snapshot: - `ProviderContextBudgetInput` now tracks artifact-level breakdown for: - bootstrap From ec2af0f51cc9bee15a76f420dba3175e92aec093 Mon Sep 17 00:00:00 2001 From: panviktor <35385675+panviktor@users.noreply.github.com> Date: Sun, 12 Apr 2026 23:41:47 +0200 Subject: [PATCH 29/30] Document model catalog commands --- README.md | 82 +++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 55 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index f5642847..17b21705 100644 --- a/README.md +++ b/README.md @@ -206,9 +206,6 @@ synapseclaw onboard --api-key sk-... --provider openrouter # Or preset-first guided wizard synapseclaw onboard -# Materialize the editable local model catalog next to config.toml -synapseclaw models catalog init - # Chat synapseclaw agent -m "Hello, SynapseClaw!" @@ -232,35 +229,66 @@ synapseclaw service install synapseclaw service status ``` -`synapseclaw onboard` now starts with simple provider presets (`ChatGPT / Codex`, -`Claude`, `OpenRouter`, `Local`, `Advanced`) and expands those into richer -lane-based routing under the hood. Advanced users can still override the -generated capability lanes later in `config.toml`. +`synapseclaw onboard` starts with simple provider presets (`ChatGPT / Codex`, +`Claude`, `OpenRouter`, `Local`, `Advanced`) and expands those into lane-aware +routing under the hood. + +## Models + +### Quick: pick a model + +```bash +# Set the default model directly +synapseclaw models set claude-sonnet-4-6 + +# Check what's active +synapseclaw models status + +# List cached models for your provider +synapseclaw models list + +# Pull the latest models from the provider API +synapseclaw models refresh +``` + +### Editable model catalog (advanced) + +SynapseClaw ships with a **built-in catalog** (`model_catalog.json`) containing presets, +30+ providers, curated model lists, pricing, context-window profiles, embedding profiles, +and route aliases (`cheap`, `qwen36`, `gemma31b`, …). -Built-in preset data, provider defaults, curated model lists, curated -provider:model profiles, default route aliases, and default pricing now come -from an external catalog. If you want to customize that catalog locally without -editing repository files, run: +To override anything locally: ```bash -synapseclaw models catalog init +synapseclaw models catalog init # writes model_catalog.json next to config.toml +synapseclaw models catalog init --force # overwrite if it already exists ``` -This writes `model_catalog.json` next to the active `config.toml`: - -- default: `~/.synapseclaw/model_catalog.json` -- agent instance: `~/.synapseclaw/agents//model_catalog.json` -- custom `--config-dir`: `/model_catalog.json` - -At runtime SynapseClaw merges the local file over the built-in catalog on -startup. This works the same way on Linux, macOS, BSD, and WSL2 because the -runtime already resolves a single active config directory first. -Live provider model-cache metadata wins over bundled profile metadata when it -exists; catalog profiles are the fallback for context windows, max output, and -feature coverage. -User `[[model_routes]]` entries win over bundled aliases, so local configs can -keep stable shortcuts such as `cheap`, `qwen36`, `gemma31b`, or `gemma26b` -while still overriding them when needed. +The local file is **merged over** the built-in catalog on startup: +- matching entries (by `id`, `provider`, `model`, or `hint`) **replace** built-in values +- new entries are **added** — custom providers, presets, aliases +- missing sections are ignored — include only what you want to change + +**Locations** (checked in order): + +| Path | Scope | +|------|-------| +| `~/.synapseclaw/model_catalog.json` | Global (all agents) | +| `~/.synapseclaw/agents//model_catalog.json` | Per-agent override | +| `<--config-dir>/model_catalog.json` | Custom config directory | + +**Full command reference:** + +| Command | What it does | +|---------|-------------| +| `synapseclaw models catalog init` | Create editable catalog from built-in seed | +| `synapseclaw models catalog init --force` | Overwrite existing catalog | +| `synapseclaw models catalog status` | Show path and active/not active | +| `synapseclaw models catalog path` | Print catalog file path | +| `synapseclaw models refresh` | Pull latest models from provider APIs | +| `synapseclaw models list` | List cached provider models | +| `synapseclaw models set ` | Set default model in config | +| `synapseclaw models status` | Show current model configuration | > **Dev fallback (no global install):** prefix commands with `cargo run --release --` (example: `cargo run --release -- status`). From 103eadce249b13beb1e6af29ab03ecd4081bec0f Mon Sep 17 00:00:00 2001 From: panviktor <35385675+panviktor@users.noreply.github.com> Date: Mon, 13 Apr 2026 06:57:02 +0200 Subject: [PATCH 30/30] Validate Phase 4.10 OpenRouter media routing --- crates/adapters/core/src/runtime_routes.rs | 94 +++++- crates/adapters/providers/src/openrouter.rs | 200 ++++++++++++- crates/domain/data/model_catalog.json | 247 +++++++++++++++- .../services/execution_guidance.rs | 27 ++ .../services/model_preset_resolution.rs | 16 +- .../services/turn_tool_narrowing.rs | 49 ++-- crates/domain/src/config/model_catalog.rs | 133 ++++++++- .../scripts/phase4_10_live_pack.sh | 109 ++++++- .../scripts/phase4_10_targeted_tests.sh | 62 ++++ docs/fork/ipc-phase4_10-plan.md | 22 ++ docs/fork/ipc-phase4_10-test-matrix.md | 276 ++++++++++++++++++ 11 files changed, 1188 insertions(+), 47 deletions(-) create mode 100644 dev/gateway-chat-harness/scripts/phase4_10_targeted_tests.sh create mode 100644 docs/fork/ipc-phase4_10-test-matrix.md diff --git a/crates/adapters/core/src/runtime_routes.rs b/crates/adapters/core/src/runtime_routes.rs index 65c24475..aae2c400 100644 --- a/crates/adapters/core/src/runtime_routes.rs +++ b/crates/adapters/core/src/runtime_routes.rs @@ -258,22 +258,22 @@ impl ModelProfileCatalogPort for WorkspaceModelProfileCatalog { fn lookup_model_profile(&self, provider: &str, model: &str) -> Option { let endpoint = self.endpoint_for_provider(provider); let candidates = self.provider_lookup_candidates(provider); - candidates + let cached = candidates.iter().find_map(|candidate_provider| { + load_cached_model_profile( + self.workspace_dir.as_path(), + candidate_provider, + model, + endpoint, + ) + }); + let bundled = candidates .iter() .find_map(|candidate_provider| { - load_cached_model_profile( - self.workspace_dir.as_path(), - candidate_provider, - model, - endpoint, - ) + synapse_domain::config::model_catalog::model_profile(candidate_provider, model) }) - .or_else(|| { - candidates.iter().find_map(|candidate_provider| { - synapse_domain::config::model_catalog::model_profile(candidate_provider, model) - }) - }) - .or_else(|| synapse_domain::config::model_catalog::model_profile(provider, model)) + .or_else(|| synapse_domain::config::model_catalog::model_profile(provider, model)); + + merge_cached_and_bundled_profile(cached, bundled) } fn record_context_limit_observation( @@ -291,6 +291,35 @@ impl ModelProfileCatalogPort for WorkspaceModelProfileCatalog { } } +fn merge_cached_and_bundled_profile( + cached: Option, + bundled: Option, +) -> Option { + let Some(mut cached) = cached else { + return bundled; + }; + let Some(bundled) = bundled else { + return Some(cached); + }; + if cached.context_window_tokens.is_none() + && cached.max_output_tokens.is_none() + && cached.features.is_empty() + { + return Some(bundled); + } + + if cached.context_window_tokens.is_none() { + cached.context_window_tokens = bundled.context_window_tokens; + } + if cached.max_output_tokens.is_none() { + cached.max_output_tokens = bundled.max_output_tokens; + } + if cached.features.is_empty() { + cached.features = bundled.features; + } + Some(cached) +} + pub(crate) fn build_models_help_response(current: &RouteSelection, config: &Config) -> String { let workspace_dir = config.workspace_dir.as_path(); let mut response = String::new(); @@ -1686,6 +1715,45 @@ mod tests { .contains(&ModelFeature::MultimodalUnderstanding)); } + #[test] + fn workspace_profile_catalog_does_not_let_empty_cache_shadow_bundled_profile() { + let workspace = tempfile::tempdir().unwrap(); + let cache_dir = workspace.path().join("state"); + std::fs::create_dir_all(&cache_dir).unwrap(); + std::fs::write( + cache_dir.join(MODEL_CACHE_FILE), + r#"{ + "entries": [ + { + "provider": "deepseek", + "fetched_at_unix": 100, + "models": ["deepseek-reasoner"], + "profiles": [ + { + "model": "deepseek-reasoner", + "features": [] + } + ] + } + ] + }"#, + ) + .unwrap(); + let catalog = WorkspaceModelProfileCatalog::new(workspace.path()); + + let profile = catalog + .lookup_model_profile("deepseek", "deepseek-reasoner") + .expect("bundled DeepSeek profile should fill empty cache profile"); + + assert_eq!(profile.context_window_tokens, Some(128_000)); + assert_eq!(profile.max_output_tokens, Some(65_536)); + assert!(profile.features.contains(&ModelFeature::ToolCalling)); + assert_eq!( + profile.source, + Some(CatalogModelProfileSource::BundledCatalog) + ); + } + #[test] fn workspace_profile_catalog_scopes_cached_profiles_by_endpoint() { let workspace = tempfile::tempdir().unwrap(); diff --git a/crates/adapters/providers/src/openrouter.rs b/crates/adapters/providers/src/openrouter.rs index fa997630..c862bc42 100644 --- a/crates/adapters/providers/src/openrouter.rs +++ b/crates/adapters/providers/src/openrouter.rs @@ -9,6 +9,7 @@ use reqwest::Client; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use synapse_domain::config::model_catalog; +use synapse_domain::config::schema::ModelFeature; pub struct OpenRouterProvider { credential: Option, @@ -23,6 +24,8 @@ struct ChatRequest { temperature: f64, #[serde(skip_serializing_if = "Option::is_none")] reasoning: Option, + #[serde(skip_serializing_if = "Option::is_none")] + modalities: Option>, } #[derive(Debug, Serialize)] @@ -62,7 +65,10 @@ struct Choice { #[derive(Debug, Deserialize)] struct ResponseMessage { - content: String, + #[serde(default)] + content: Option, + #[serde(default)] + images: Vec, } #[derive(Debug, Serialize)] @@ -73,6 +79,8 @@ struct NativeChatRequest { #[serde(skip_serializing_if = "Option::is_none")] reasoning: Option, #[serde(skip_serializing_if = "Option::is_none")] + modalities: Option>, + #[serde(skip_serializing_if = "Option::is_none")] tools: Option>, #[serde(skip_serializing_if = "Option::is_none")] tool_choice: Option, @@ -154,6 +162,8 @@ struct NativeChoice { struct NativeResponseMessage { #[serde(default)] content: Option, + #[serde(default)] + images: Vec, /// Reasoning/thinking models may return output in `reasoning_content`. #[serde(default)] reasoning_content: Option, @@ -165,6 +175,17 @@ struct NativeResponseMessage { tool_calls: Option>, } +#[derive(Debug, Clone, Deserialize)] +struct GeneratedImage { + #[serde(default, rename = "image_url", alias = "imageUrl")] + image_url: Option, +} + +#[derive(Debug, Clone, Deserialize)] +struct GeneratedImageUrl { + url: String, +} + impl OpenRouterProvider { pub fn new(credential: Option<&str>) -> Self { Self::new_with_reasoning(credential, None, None) @@ -217,6 +238,60 @@ impl OpenRouterProvider { } } + fn output_modalities_for_model(model: &str) -> Option> { + let profile = model_catalog::model_profile("openrouter", model)?; + let has = |feature| profile.features.iter().any(|item| *item == feature); + let mut modalities = Vec::new(); + + if has(ModelFeature::ImageGeneration) { + modalities.push("image".to_string()); + } + if has(ModelFeature::AudioGeneration) || has(ModelFeature::MusicGeneration) { + modalities.push("audio".to_string()); + } + if has(ModelFeature::VideoGeneration) { + modalities.push("video".to_string()); + } + if modalities.is_empty() { + return None; + } + + if has(ModelFeature::MultimodalUnderstanding) + || has(ModelFeature::Vision) + || has(ModelFeature::ToolCalling) + { + modalities.push("text".to_string()); + } + + modalities.sort(); + modalities.dedup(); + Some(modalities) + } + + fn merge_content_and_generated_images( + content: Option, + images: &[GeneratedImage], + ) -> Option { + let mut parts = Vec::new(); + if let Some(content) = content { + let content = content.trim(); + if !content.is_empty() { + parts.push(content.to_string()); + } + } + + parts.extend(images.iter().filter_map(|image| { + let url = image.image_url.as_ref()?.url.trim(); + (!url.is_empty()).then(|| format!("[IMAGE:{url}]")) + })); + + if parts.is_empty() { + None + } else { + Some(parts.join("\n")) + } + } + fn convert_tools(tools: Option<&[ToolSpec]>) -> Option> { let items = tools?; if items.is_empty() { @@ -405,7 +480,7 @@ impl OpenRouterProvider { .collect::>>()?; Ok(ProviderChatResponse { - text: message.content, + text: Self::merge_content_and_generated_images(message.content, &message.images), tool_calls, usage: None, reasoning_content, @@ -507,6 +582,7 @@ impl Provider for OpenRouterProvider { messages, temperature, reasoning: self.reasoning_options(model), + modalities: Self::output_modalities_for_model(model), }; let response = self @@ -530,7 +606,10 @@ impl Provider for OpenRouterProvider { .choices .into_iter() .next() - .map(|c| c.message.content) + .map(|c| { + Self::merge_content_and_generated_images(c.message.content, &c.message.images) + .unwrap_or_default() + }) .ok_or_else(|| anyhow::anyhow!("No response from OpenRouter")) } @@ -556,6 +635,7 @@ impl Provider for OpenRouterProvider { messages: api_messages, temperature, reasoning: self.reasoning_options(model), + modalities: Self::output_modalities_for_model(model), }; let response = self @@ -579,7 +659,10 @@ impl Provider for OpenRouterProvider { .choices .into_iter() .next() - .map(|c| c.message.content) + .map(|c| { + Self::merge_content_and_generated_images(c.message.content, &c.message.images) + .unwrap_or_default() + }) .ok_or_else(|| anyhow::anyhow!("No response from OpenRouter")) } @@ -601,6 +684,7 @@ impl Provider for OpenRouterProvider { messages: Self::convert_messages(request.messages), temperature, reasoning: self.reasoning_options(model), + modalities: Self::output_modalities_for_model(model), tool_choice: tools.as_ref().map(|_| "auto".to_string()), tools, }; @@ -695,6 +779,7 @@ impl Provider for OpenRouterProvider { messages: native_messages, temperature, reasoning: self.reasoning_options(model), + modalities: Self::output_modalities_for_model(model), tool_choice: native_tools.as_ref().map(|_| "auto".to_string()), tools: native_tools, }; @@ -845,6 +930,7 @@ mod tests { ], temperature: 0.5, reasoning: None, + modalities: None, }; let json = serde_json::to_string(&request).unwrap(); @@ -879,6 +965,7 @@ mod tests { .collect(), temperature: 0.0, reasoning: None, + modalities: None, }; let json = serde_json::to_string(&request).unwrap(); @@ -903,6 +990,7 @@ mod tests { enabled: Some(true), effort: Some("high".into()), }), + modalities: None, tools: None, tool_choice: None, }; @@ -913,6 +1001,57 @@ mod tests { assert_eq!(json["reasoning"]["effort"], "high"); } + #[test] + fn output_modalities_follow_catalog_generation_features() { + assert_eq!( + OpenRouterProvider::output_modalities_for_model("sourceful/riverflow-v2-fast"), + Some(vec!["image".to_string()]) + ); + assert_eq!( + OpenRouterProvider::output_modalities_for_model( + "google/gemini-3.1-flash-image-preview" + ), + Some(vec!["image".to_string(), "text".to_string()]) + ); + assert_eq!( + OpenRouterProvider::output_modalities_for_model("google/lyria-3-clip-preview"), + Some(vec!["audio".to_string(), "text".to_string()]) + ); + assert_eq!( + OpenRouterProvider::output_modalities_for_model("google/veo-3.1"), + Some(vec!["video".to_string()]) + ); + assert_eq!( + OpenRouterProvider::output_modalities_for_model("minimax/minimax-m2.7"), + None + ); + } + + #[test] + fn native_request_serializes_generation_modalities() { + let request = NativeChatRequest { + model: "sourceful/riverflow-v2-fast".into(), + messages: vec![NativeMessage { + role: "user".into(), + content: Some(MessageContent::Text("draw a test image".into())), + tool_call_id: None, + tool_calls: None, + reasoning_content: None, + }], + temperature: 0.1, + reasoning: None, + modalities: OpenRouterProvider::output_modalities_for_model( + "sourceful/riverflow-v2-fast", + ), + tools: None, + tool_choice: None, + }; + + let json = serde_json::to_value(&request).unwrap(); + + assert_eq!(json["modalities"], serde_json::json!(["image"])); + } + #[test] fn response_deserializes_single_choice() { let json = r#"{"choices":[{"message":{"content":"Hi from OpenRouter"}}]}"#; @@ -920,7 +1059,32 @@ mod tests { let response: ApiChatResponse = serde_json::from_str(json).unwrap(); assert_eq!(response.choices.len(), 1); - assert_eq!(response.choices[0].message.content, "Hi from OpenRouter"); + assert_eq!( + response.choices[0].message.content.as_deref(), + Some("Hi from OpenRouter") + ); + } + + #[test] + fn response_deserializes_generated_images() { + let json = r#"{ + "choices":[{ + "message":{ + "content": null, + "images": [ + {"type":"image_url","image_url":{"url":"data:image/png;base64,abcd"}} + ] + } + }] + }"#; + + let response: ApiChatResponse = serde_json::from_str(json).unwrap(); + let image_url = response.choices[0].message.images[0] + .image_url + .as_ref() + .map(|image| image.url.as_str()); + + assert_eq!(image_url, Some("data:image/png;base64,abcd")); } #[test] @@ -1008,6 +1172,7 @@ mod tests { fn parse_native_response_converts_to_chat_response() { let message = NativeResponseMessage { content: Some("Here you go.".into()), + images: Vec::new(), reasoning_content: None, reasoning: None, tool_calls: Some(vec![NativeToolCall { @@ -1028,6 +1193,28 @@ mod tests { assert_eq!(response.tool_calls[0].name, "file_read"); } + #[test] + fn parse_native_response_converts_generated_images_to_attachment_markers() { + let message = NativeResponseMessage { + content: None, + images: vec![GeneratedImage { + image_url: Some(GeneratedImageUrl { + url: "data:image/png;base64,abcd".into(), + }), + }], + reasoning_content: None, + reasoning: None, + tool_calls: None, + }; + + let response = OpenRouterProvider::parse_native_response(message).unwrap(); + + assert_eq!( + response.text.as_deref(), + Some("[IMAGE:data:image/png;base64,abcd]") + ); + } + #[test] fn convert_messages_parses_assistant_tool_call_payload() { let messages = vec![ChatMessage { @@ -1163,6 +1350,7 @@ mod tests { fn parse_native_response_captures_reasoning_content() { let message = NativeResponseMessage { content: Some("answer".into()), + images: Vec::new(), reasoning_content: Some("thinking step".into()), reasoning: None, tool_calls: Some(vec![NativeToolCall { @@ -1183,6 +1371,7 @@ mod tests { fn parse_native_response_captures_openrouter_reasoning_field() { let message = NativeResponseMessage { content: Some("answer".into()), + images: Vec::new(), reasoning_content: None, reasoning: Some("normalized thinking".into()), tool_calls: None, @@ -1200,6 +1389,7 @@ mod tests { fn parse_native_response_none_reasoning_content_for_normal_model() { let message = NativeResponseMessage { content: Some("hello".into()), + images: Vec::new(), reasoning_content: None, reasoning: None, tool_calls: None, diff --git a/crates/domain/data/model_catalog.json b/crates/domain/data/model_catalog.json index e46afb81..abd82e38 100644 --- a/crates/domain/data/model_catalog.json +++ b/crates/domain/data/model_catalog.json @@ -58,6 +58,54 @@ } } ] + }, + { + "lane": "image_generation", + "candidates": [ + { + "provider": "openrouter", + "model": "google/gemini-3.1-flash-image-preview" + }, + { + "provider": "openrouter", + "model": "sourceful/riverflow-v2-fast" + } + ] + }, + { + "lane": "audio_generation", + "candidates": [ + { + "provider": "openrouter", + "model": "openai/gpt-audio-mini" + } + ] + }, + { + "lane": "music_generation", + "candidates": [ + { + "provider": "openrouter", + "model": "google/lyria-3-clip-preview" + }, + { + "provider": "openrouter", + "model": "google/lyria-3-pro-preview" + } + ] + }, + { + "lane": "video_generation", + "candidates": [ + { + "provider": "openrouter", + "model": "google/veo-3.1" + }, + { + "provider": "openrouter", + "model": "alibaba/wan-2.6" + } + ] } ] }, @@ -98,8 +146,16 @@ { "id": "google/gemma-4-26b-a4b-it", "label": "Gemma 4 26B A4B IT (paid OpenRouter, efficient multimodal input)" }, { "id": "qwen/qwen3.6-plus", "label": "Qwen3.6 Plus (frontier, free preview)" }, { "id": "deepseek/deepseek-v4", "label": "DeepSeek V4 (latest, strong value)" }, + { "id": "minimax/minimax-m2.7", "label": "MiniMax M2.7 (agentic text, 200K context)" }, { "id": "x-ai/grok-4.20", "label": "Grok 4.20 (xAI flagship, 2M context)" }, { "id": "x-ai/grok-4.20-multi-agent", "label": "Grok 4.20 Multi-Agent (OpenRouter experimental)" }, + { "id": "google/gemini-3.1-flash-image-preview", "label": "Gemini 3.1 Flash Image Preview (image generation)" }, + { "id": "sourceful/riverflow-v2-fast", "label": "Riverflow V2 Fast (small-window image generation)" }, + { "id": "openai/gpt-audio-mini", "label": "GPT Audio Mini (audio generation)" }, + { "id": "google/lyria-3-clip-preview", "label": "Lyria 3 Clip Preview (music generation)" }, + { "id": "google/lyria-3-pro-preview", "label": "Lyria 3 Pro Preview (music generation)" }, + { "id": "google/veo-3.1", "label": "Veo 3.1 (video generation)" }, + { "id": "alibaba/wan-2.6", "label": "Wan 2.6 (video generation)" }, { "id": "meta-llama/llama-4-maverick", "label": "Llama 4 Maverick (open model)" }, { "id": "moonshot/kimi-k2.5", "label": "Kimi K2.5 (agentic specialist)" } ] @@ -277,11 +333,17 @@ }, { "provider": "minimax", - "default_model": "MiniMax-M2.5", + "default_model": "MiniMax-M2.7", + "api_base_urls": ["https://api.minimax.io/v1", "https://api.minimaxi.com/v1"], "curated_models": [ + { "id": "MiniMax-M2.7", "label": "MiniMax M2.7 (latest agentic text)" }, + { "id": "codex-MiniMax-M2.7", "label": "MiniMax M2.7 Codex-compatible profile" }, { "id": "MiniMax-M2.5", "label": "MiniMax M2.5 (latest flagship)" }, { "id": "MiniMax-M2.1", "label": "MiniMax M2.1 (strong coding/reasoning)" }, - { "id": "MiniMax-M2.5-highspeed", "label": "MiniMax M2.5 High-Speed (fast)" } + { "id": "MiniMax-M2.5-highspeed", "label": "MiniMax M2.5 High-Speed (fast)" }, + { "id": "speech-2.5-hd-preview", "label": "MiniMax Speech 2.5 HD Preview (direct audio API)" }, + { "id": "MiniMax-Hailuo-2.3-Fast", "label": "MiniMax Hailuo 2.3 Fast (direct video API)" }, + { "id": "music-01", "label": "MiniMax Music 01 (direct music API)" } ] }, { @@ -453,8 +515,11 @@ { "model": "google/gemini-3-flash", "input": 0.1, "output": 0.4 }, { "model": "google/gemma-4-31b-it", "input": 0.14, "output": 0.40 }, { "model": "google/gemma-4-26b-a4b-it", "input": 0.12, "output": 0.40 }, + { "model": "deepseek-chat", "input": 0.28, "output": 0.42 }, + { "model": "deepseek-reasoner", "input": 0.28, "output": 0.42 }, { "model": "deepseek/deepseek-v4", "input": 0.25, "output": 1.1 }, { "model": "deepseek/deepseek-r1", "input": 0.55, "output": 2.19 }, + { "model": "minimax/minimax-m2.7", "input": 0.30, "output": 1.20 }, { "model": "x-ai/grok-4.20", "input": 2.0, "output": 6.0 }, { "model": "meta-llama/llama-4-maverick", "input": 0.2, "output": 0.6 }, { "model": "moonshot/kimi-k2.5", "input": 0.6, "output": 2.5 }, @@ -463,6 +528,93 @@ { "model": "cohere/command-a", "input": 2.5, "output": 10.0 } ], "profiles": [ + { + "provider": "openai", + "model": "gpt-5.4", + "context_window_tokens": 400000, + "max_output_tokens": 128000, + "features": ["tool_calling", "vision", "multimodal_understanding", "prompt_caching"] + }, + { + "provider": "openai", + "model": "gpt-5.4-mini", + "context_window_tokens": 400000, + "max_output_tokens": 128000, + "features": ["tool_calling", "vision", "multimodal_understanding", "prompt_caching"] + }, + { + "provider": "openai", + "model": "gpt-5.4-pro", + "context_window_tokens": 400000, + "max_output_tokens": 128000, + "features": ["tool_calling", "vision", "multimodal_understanding", "prompt_caching"] + }, + { + "provider": "openai-codex", + "model": "gpt-5.4", + "context_window_tokens": 400000, + "max_output_tokens": 128000, + "features": [ + "tool_calling", + "vision", + "multimodal_understanding", + "prompt_caching", + "server_continuation" + ] + }, + { + "provider": "openai-codex", + "model": "gpt-5.4-mini", + "context_window_tokens": 400000, + "max_output_tokens": 128000, + "features": [ + "tool_calling", + "vision", + "multimodal_understanding", + "prompt_caching", + "server_continuation" + ] + }, + { + "provider": "openai-codex", + "model": "gpt-5.3-codex", + "context_window_tokens": 400000, + "max_output_tokens": 128000, + "features": [ + "tool_calling", + "vision", + "multimodal_understanding", + "prompt_caching", + "server_continuation" + ] + }, + { + "provider": "openai-codex", + "model": "gpt-5.2-codex", + "context_window_tokens": 400000, + "max_output_tokens": 128000, + "features": [ + "tool_calling", + "vision", + "multimodal_understanding", + "prompt_caching", + "server_continuation" + ] + }, + { + "provider": "deepseek", + "model": "deepseek-chat", + "context_window_tokens": 128000, + "max_output_tokens": 8192, + "features": ["tool_calling", "prompt_caching"] + }, + { + "provider": "deepseek", + "model": "deepseek-reasoner", + "context_window_tokens": 128000, + "max_output_tokens": 65536, + "features": ["tool_calling", "prompt_caching"] + }, { "provider": "openrouter", "model": "google/gemma-4-31b-it", @@ -483,6 +635,57 @@ "context_window_tokens": 2000000, "max_output_tokens": 66000, "features": ["tool_calling"] + }, + { + "provider": "openrouter", + "model": "minimax/minimax-m2.7", + "context_window_tokens": 204800, + "max_output_tokens": 131072, + "features": ["tool_calling", "prompt_caching"] + }, + { + "provider": "openrouter", + "model": "google/gemini-3.1-flash-image-preview", + "context_window_tokens": 65536, + "max_output_tokens": 65536, + "features": ["image_generation", "vision", "multimodal_understanding"] + }, + { + "provider": "openrouter", + "model": "sourceful/riverflow-v2-fast", + "context_window_tokens": 8192, + "features": ["image_generation"] + }, + { + "provider": "openrouter", + "model": "openai/gpt-audio-mini", + "context_window_tokens": 128000, + "max_output_tokens": 16384, + "features": ["audio_generation", "multimodal_understanding"] + }, + { + "provider": "openrouter", + "model": "google/lyria-3-clip-preview", + "context_window_tokens": 1048576, + "max_output_tokens": 65536, + "features": ["music_generation", "audio_generation", "vision", "multimodal_understanding"] + }, + { + "provider": "openrouter", + "model": "google/lyria-3-pro-preview", + "context_window_tokens": 1048576, + "max_output_tokens": 65536, + "features": ["music_generation", "audio_generation", "vision", "multimodal_understanding"] + }, + { + "provider": "openrouter", + "model": "google/veo-3.1", + "features": ["video_generation"] + }, + { + "provider": "openrouter", + "model": "alibaba/wan-2.6", + "features": ["video_generation"] } ], "embedding_profiles": [ @@ -594,6 +797,46 @@ "hint": "grok-4.20", "provider": "openrouter", "model": "x-ai/grok-4.20" + }, + { + "hint": "minimax", + "provider": "openrouter", + "model": "minimax/minimax-m2.7" + }, + { + "hint": "minimax-m2.7", + "provider": "openrouter", + "model": "minimax/minimax-m2.7" + }, + { + "hint": "media-image", + "capability": "image_generation", + "provider": "openrouter", + "model": "google/gemini-3.1-flash-image-preview" + }, + { + "hint": "media-image-small", + "capability": "image_generation", + "provider": "openrouter", + "model": "sourceful/riverflow-v2-fast" + }, + { + "hint": "media-audio", + "capability": "audio_generation", + "provider": "openrouter", + "model": "openai/gpt-audio-mini" + }, + { + "hint": "media-music", + "capability": "music_generation", + "provider": "openrouter", + "model": "google/lyria-3-clip-preview" + }, + { + "hint": "media-video", + "capability": "video_generation", + "provider": "openrouter", + "model": "google/veo-3.1" } ], "request_policies": [ diff --git a/crates/domain/src/application/services/execution_guidance.rs b/crates/domain/src/application/services/execution_guidance.rs index 87f409f6..7277ffdb 100644 --- a/crates/domain/src/application/services/execution_guidance.rs +++ b/crates/domain/src/application/services/execution_guidance.rs @@ -245,6 +245,10 @@ pub fn format_execution_guidance(guidance: &ExecutionGuidance) -> Option "- mutate_state_only_when_user_explicitly_requests_memory_or_profile_change: true" .to_string(), ); + lines.push("- preserve_resolved_scalar_values_verbatim: true".to_string()); + lines.push( + "- do_not_typographically_rewrite_ids_urls_paths_branches_or_codes: true".to_string(), + ); } if guidance.direct_resolution_ready && guidance @@ -671,6 +675,29 @@ mod tests { assert!(block.contains("avoid_workspace_discovery: true")); } + #[test] + fn formats_resolved_state_literal_fidelity_guidance() { + let block = format_execution_guidance(&ExecutionGuidance { + resolved_from: Some(ResolutionSource::DialogueState), + direct_resolution_ready: true, + preferred_capabilities: Vec::new(), + recent_failure_hints: Vec::new(), + recent_admission_hint: None, + prefer_answer_from_resolved_state: true, + avoid_session_history_lookup: true, + avoid_run_recipe_lookup: true, + avoid_workspace_discovery: true, + avoid_bootstrap_doc_reads: true, + }) + .unwrap(); + + assert!(block.contains("prefer_answer_from_resolved_state: true")); + assert!(block.contains("preserve_resolved_scalar_values_verbatim: true")); + assert!( + block.contains("do_not_typographically_rewrite_ids_urls_paths_branches_or_codes: true") + ); + } + fn profile_with_facts(facts: &[(&str, serde_json::Value)]) -> UserProfile { let mut profile = UserProfile::default(); for (key, value) in facts { diff --git a/crates/domain/src/application/services/model_preset_resolution.rs b/crates/domain/src/application/services/model_preset_resolution.rs index 7fed616b..50e0dec9 100644 --- a/crates/domain/src/application/services/model_preset_resolution.rs +++ b/crates/domain/src/application/services/model_preset_resolution.rs @@ -252,7 +252,7 @@ mod tests { use super::*; #[test] - fn openrouter_preset_expands_into_reasoning_cheap_and_embedding_lanes() { + fn openrouter_preset_expands_into_reasoning_helper_and_media_lanes() { let mut config = Config::default(); config.default_provider = Some("openrouter".into()); config.default_model = @@ -261,7 +261,7 @@ mod tests { let lanes = resolve_effective_model_lanes(&config); - assert_eq!(lanes.len(), 3); + assert_eq!(lanes.len(), 7); assert!(lanes .iter() .any(|lane| lane.lane == CapabilityLane::Reasoning)); @@ -271,6 +271,18 @@ mod tests { assert!(lanes .iter() .any(|lane| lane.lane == CapabilityLane::Embedding)); + assert!(lanes + .iter() + .any(|lane| lane.lane == CapabilityLane::ImageGeneration)); + assert!(lanes + .iter() + .any(|lane| lane.lane == CapabilityLane::AudioGeneration)); + assert!(lanes + .iter() + .any(|lane| lane.lane == CapabilityLane::MusicGeneration)); + assert!(lanes + .iter() + .any(|lane| lane.lane == CapabilityLane::VideoGeneration)); } #[test] diff --git a/crates/domain/src/application/services/turn_tool_narrowing.rs b/crates/domain/src/application/services/turn_tool_narrowing.rs index 5459a3ad..6dcc32b9 100644 --- a/crates/domain/src/application/services/turn_tool_narrowing.rs +++ b/crates/domain/src/application/services/turn_tool_narrowing.rs @@ -57,21 +57,12 @@ pub fn narrow_tool_specs_for_turn( .filter(|spec| { matches!( spec.runtime_role, - Some( - ToolRuntimeRole::ProfileMutation - | ToolRuntimeRole::MemoryMutation - | ToolRuntimeRole::RuntimeStateInspection - | ToolRuntimeRole::ExternalLookup - ) + Some(ToolRuntimeRole::RuntimeStateInspection) ) }) .cloned() .collect::>(); - return if filtered.is_empty() { - Vec::new() - } else { - filtered - }; + return filtered; } if guidance.direct_resolution_ready @@ -101,7 +92,8 @@ pub fn narrow_tool_specs_for_turn( .filter(|spec| { matches!( spec.runtime_role, - Some(ToolRuntimeRole::ProfileMutation) | Some(ToolRuntimeRole::ExternalLookup) + Some(ToolRuntimeRole::RuntimeStateInspection) + | Some(ToolRuntimeRole::ExternalLookup) ) }) .cloned() @@ -350,8 +342,9 @@ mod tests { spec("user_profile", Some(ToolRuntimeRole::ProfileMutation)), spec("session_search", Some(ToolRuntimeRole::HistoricalLookup)), spec("file_read", Some(ToolRuntimeRole::WorkspaceDiscovery)), - spec("memory_recall", Some(ToolRuntimeRole::MemoryMutation)), + spec("memory_recall", Some(ToolRuntimeRole::HistoricalLookup)), spec("web_search_tool", Some(ToolRuntimeRole::ExternalLookup)), + spec("state_get", Some(ToolRuntimeRole::RuntimeStateInspection)), ]; let guidance = ExecutionGuidance { direct_resolution_ready: true, @@ -365,7 +358,7 @@ mod tests { .into_iter() .map(|spec| spec.name) .collect::>(); - assert_eq!(names, vec!["user_profile", "web_search_tool"]); + assert_eq!(names, vec!["web_search_tool", "state_get"]); } #[test] @@ -390,13 +383,14 @@ mod tests { } #[test] - fn answer_from_resolved_state_keeps_only_safe_runtime_tools() { + fn answer_from_resolved_state_keeps_only_runtime_inspection_tools() { let specs = vec![ spec("core_memory_update", Some(ToolRuntimeRole::MemoryMutation)), spec("user_profile", Some(ToolRuntimeRole::ProfileMutation)), spec("memory_recall", Some(ToolRuntimeRole::HistoricalLookup)), spec("web_search_tool", Some(ToolRuntimeRole::ExternalLookup)), spec("file_read", Some(ToolRuntimeRole::WorkspaceDiscovery)), + spec("state_get", Some(ToolRuntimeRole::RuntimeStateInspection)), ]; let guidance = ExecutionGuidance { direct_resolution_ready: true, @@ -410,10 +404,27 @@ mod tests { .into_iter() .map(|spec| spec.name) .collect::>(); - assert_eq!( - names, - vec!["core_memory_update", "user_profile", "web_search_tool",] - ); + assert_eq!(names, vec!["state_get"]); + } + + #[test] + fn answer_from_resolved_state_exposes_no_tools_when_only_mutating_tools_exist() { + let specs = vec![ + spec("memory_store", Some(ToolRuntimeRole::MemoryMutation)), + spec("memory_forget", Some(ToolRuntimeRole::MemoryMutation)), + spec("core_memory_update", Some(ToolRuntimeRole::MemoryMutation)), + spec("user_profile", Some(ToolRuntimeRole::ProfileMutation)), + ]; + let guidance = ExecutionGuidance { + direct_resolution_ready: true, + prefer_answer_from_resolved_state: true, + recent_failure_hints: Vec::new(), + ..ExecutionGuidance::default() + }; + + let filtered = narrow_tool_specs_for_turn(specs, Some(&guidance), ""); + + assert!(filtered.is_empty()); } #[test] diff --git a/crates/domain/src/config/model_catalog.rs b/crates/domain/src/config/model_catalog.rs index f4daf8a1..cd0c6fc9 100644 --- a/crates/domain/src/config/model_catalog.rs +++ b/crates/domain/src/config/model_catalog.rs @@ -699,7 +699,7 @@ mod tests { } #[test] - fn openrouter_preset_has_auxiliary_lanes() { + fn openrouter_preset_has_auxiliary_and_media_lanes() { let lanes = preset_extra_lanes("openrouter").expect("preset should exist"); assert!(lanes .iter() @@ -707,10 +707,22 @@ mod tests { assert!(lanes .iter() .any(|lane| lane.lane == CapabilityLane::Embedding)); + assert!(lanes + .iter() + .any(|lane| lane.lane == CapabilityLane::ImageGeneration)); + assert!(lanes + .iter() + .any(|lane| lane.lane == CapabilityLane::AudioGeneration)); + assert!(lanes + .iter() + .any(|lane| lane.lane == CapabilityLane::MusicGeneration)); + assert!(lanes + .iter() + .any(|lane| lane.lane == CapabilityLane::VideoGeneration)); } #[test] - fn openrouter_catalog_exposes_gemma_standard_profiles_pricing_and_aliases() { + fn openrouter_catalog_exposes_standard_media_and_minimax_profiles_pricing_and_aliases() { let curated = provider_curated_models("openrouter").expect("provider should exist"); assert!(curated .iter() @@ -719,6 +731,16 @@ mod tests { .iter() .any(|(model, _)| model == "google/gemma-4-26b-a4b-it")); assert!(curated.iter().any(|(model, _)| model == "x-ai/grok-4.20")); + assert!(curated + .iter() + .any(|(model, _)| model == "minimax/minimax-m2.7")); + assert!(curated + .iter() + .any(|(model, _)| model == "sourceful/riverflow-v2-fast")); + assert!(curated + .iter() + .any(|(model, _)| model == "google/lyria-3-clip-preview")); + assert!(curated.iter().any(|(model, _)| model == "google/veo-3.1")); let pricing = default_pricing_table(); let price = pricing @@ -734,6 +756,11 @@ mod tests { let price = pricing.get("x-ai/grok-4.20").expect("pricing should exist"); assert_eq!(price.input, 2.0); assert_eq!(price.output, 6.0); + let price = pricing + .get("minimax/minimax-m2.7") + .expect("pricing should exist"); + assert_eq!(price.input, 0.30); + assert_eq!(price.output, 1.20); let profile = model_profile("openrouter", "google/gemma-4-31b-it").expect("profile should exist"); @@ -772,21 +799,123 @@ mod tests { Some(CatalogModelProfileSource::BundledCatalog) ); + let profile = + model_profile("openrouter", "minimax/minimax-m2.7").expect("profile should exist"); + assert_eq!(profile.context_window_tokens, Some(204_800)); + assert_eq!(profile.max_output_tokens, Some(131_072)); + assert!(profile.features.contains(&ModelFeature::ToolCalling)); + assert!(profile.features.contains(&ModelFeature::PromptCaching)); + assert!(!profile.features.contains(&ModelFeature::VideoGeneration)); + assert_eq!( + profile.source, + Some(CatalogModelProfileSource::BundledCatalog) + ); + + let profile = model_profile("openrouter", "google/gemini-3.1-flash-image-preview") + .expect("profile should exist"); + assert!(profile.features.contains(&ModelFeature::ImageGeneration)); + assert!(profile + .features + .contains(&ModelFeature::MultimodalUnderstanding)); + + let profile = model_profile("openrouter", "sourceful/riverflow-v2-fast") + .expect("profile should exist"); + assert_eq!(profile.context_window_tokens, Some(8_192)); + assert!(profile.features.contains(&ModelFeature::ImageGeneration)); + + let profile = + model_profile("openrouter", "openai/gpt-audio-mini").expect("profile should exist"); + assert!(profile.features.contains(&ModelFeature::AudioGeneration)); + + let profile = model_profile("openrouter", "google/lyria-3-clip-preview") + .expect("profile should exist"); + assert!(profile.features.contains(&ModelFeature::MusicGeneration)); + + let profile = model_profile("openrouter", "google/veo-3.1").expect("profile should exist"); + assert!(profile.features.contains(&ModelFeature::VideoGeneration)); + let aliases = route_aliases(); assert!(aliases.iter().any(|route| route.hint == "gemma31b")); assert!(aliases.iter().any(|route| route.hint == "gemma26b")); assert!(aliases.iter().any(|route| route.hint == "grok420")); + assert!(aliases.iter().any(|route| route.hint == "minimax")); + assert!(aliases.iter().any(|route| route.hint == "media-video")); + assert!(aliases + .iter() + .any(|route| route.hint == "media-image-small")); let alias = route_alias("gemma31b").expect("alias should exist"); assert_eq!(alias.provider, "openrouter"); assert_eq!(alias.model, "google/gemma-4-31b-it"); let alias = route_alias("grok-4.20").expect("alias should exist"); assert_eq!(alias.provider, "openrouter"); assert_eq!(alias.model, "x-ai/grok-4.20"); + let alias = route_alias("minimax").expect("alias should exist"); + assert_eq!(alias.provider, "openrouter"); + assert_eq!(alias.model, "minimax/minimax-m2.7"); + let alias = route_alias("media-video").expect("alias should exist"); + assert_eq!(alias.capability, Some(CapabilityLane::VideoGeneration)); + assert_eq!(alias.provider, "openrouter"); let alias = route_alias("qwen36").expect("alias should exist"); assert_eq!(alias.provider, "openrouter"); assert_eq!(alias.model, "qwen/qwen3.6-plus"); } + #[test] + fn direct_deepseek_profiles_keep_context_budget_from_falling_back_to_unknown_window() { + let pricing = default_pricing_table(); + let price = pricing.get("deepseek-chat").expect("pricing should exist"); + assert_eq!(price.input, 0.28); + assert_eq!(price.output, 0.42); + let price = pricing + .get("deepseek-reasoner") + .expect("pricing should exist"); + assert_eq!(price.input, 0.28); + assert_eq!(price.output, 0.42); + + let profile = model_profile("deepseek", "deepseek-chat").expect("profile should exist"); + assert_eq!(profile.context_window_tokens, Some(128_000)); + assert_eq!(profile.max_output_tokens, Some(8_192)); + assert!(profile.features.contains(&ModelFeature::ToolCalling)); + assert!(profile.features.contains(&ModelFeature::PromptCaching)); + assert_eq!( + profile.source, + Some(CatalogModelProfileSource::BundledCatalog) + ); + + let profile = model_profile("deepseek", "deepseek-reasoner").expect("profile should exist"); + assert_eq!(profile.context_window_tokens, Some(128_000)); + assert_eq!(profile.max_output_tokens, Some(65_536)); + assert!(profile.features.contains(&ModelFeature::ToolCalling)); + assert!(profile.features.contains(&ModelFeature::PromptCaching)); + assert_eq!( + profile.source, + Some(CatalogModelProfileSource::BundledCatalog) + ); + } + + #[test] + fn openai_codex_profiles_keep_primary_route_out_of_unknown_tiny_window() { + let profile = model_profile("openai-codex", "gpt-5.4").expect("profile should exist"); + assert_eq!(profile.context_window_tokens, Some(400_000)); + assert_eq!(profile.max_output_tokens, Some(128_000)); + assert!(profile.features.contains(&ModelFeature::ToolCalling)); + assert!(profile.features.contains(&ModelFeature::PromptCaching)); + assert!(profile.features.contains(&ModelFeature::ServerContinuation)); + assert_eq!( + profile.source, + Some(CatalogModelProfileSource::BundledCatalog) + ); + + let mini = model_profile("openai-codex", "gpt-5.4-mini").expect("profile should exist"); + assert_eq!(mini.context_window_tokens, Some(400_000)); + assert_eq!(mini.max_output_tokens, Some(128_000)); + + let openai = model_profile("openai", "gpt-5.4").expect("profile should exist"); + assert_eq!(openai.context_window_tokens, Some(400_000)); + assert!(openai.features.contains(&ModelFeature::PromptCaching)); + assert!(!openai.features.contains(&ModelFeature::ServerContinuation)); + } + #[test] fn catalog_exposes_embedding_calibration_profiles() { let profile = embedding_profile("openrouter", "qwen/qwen3-embedding-8b", 4096) diff --git a/dev/gateway-chat-harness/scripts/phase4_10_live_pack.sh b/dev/gateway-chat-harness/scripts/phase4_10_live_pack.sh index c907b19a..944b60b3 100755 --- a/dev/gateway-chat-harness/scripts/phase4_10_live_pack.sh +++ b/dev/gateway-chat-harness/scripts/phase4_10_live_pack.sh @@ -11,9 +11,13 @@ RUN_MAIN_ROUTE="${RUN_MAIN_ROUTE:-1}" RUN_REASONER="${RUN_REASONER:-0}" RUN_CJK="${RUN_CJK:-1}" RUN_MEDIA="${RUN_MEDIA:-1}" +RUN_OPENROUTER_MEDIA_GENERATION="${RUN_OPENROUTER_MEDIA_GENERATION:-0}" +RUN_MODEL_SWITCH="${RUN_MODEL_SWITCH:-1}" +RUN_CONTEXT_OVERFLOW_SWITCH="${RUN_CONTEXT_OVERFLOW_SWITCH:-0}" RUN_HEAVY="${RUN_HEAVY:-0}" RUN_DOCTOR_MODELS="${RUN_DOCTOR_MODELS:-0}" REQUIRE_EMBEDDING_SIGNAL="${REQUIRE_EMBEDDING_SIGNAL:-0}" +REQUIRE_COMPACTION_SIGNAL="${REQUIRE_COMPACTION_SIGNAL:-0}" STRICT_RECALL_NO_MUTATION="${STRICT_RECALL_NO_MUTATION:-0}" STRICT_CONTEXT_BUDGET="${STRICT_CONTEXT_BUDGET:-0}" CONTEXT_WARN_MAX_CHARS="${CONTEXT_WARN_MAX_CHARS:-7000}" @@ -156,6 +160,13 @@ check_no_recall_mutation() { fi } +is_admission_block_error() { + local error="$1" + [[ "$error" == *"turn admission blocked provider call"* ]] \ + || [[ "$error" == *"requires lane"* && "$error" == *"current route cannot satisfy"* ]] \ + || [[ "$error" == *"Switch to a compatible lane"* ]] +} + run_provider_smoke() { local route="$1" local slug @@ -220,7 +231,7 @@ run_media_admission_smoke() { "[GENERATE:${marker}] Phase 4.10 admission test only. Do not fake a media artifact; use the configured lane or fail early." local rpc_error rpc_error="$(case_field "$REPORT_DIR/${case_name}.json" last_rpc_error)" - if [[ "$rpc_error" == *"turn admission blocked provider call"* ]]; then + if is_admission_block_error "$rpc_error"; then record_pass "$case_name" "blocked before provider call on text route" elif [[ -n "$rpc_error" ]]; then record_warn "$case_name" "unexpected rpc_error: $rpc_error" @@ -236,7 +247,7 @@ run_media_admission_smoke() { "[IMAGE:${vision_data_uri}] What is the dominant color of this image? Reply with exactly one word." local vision_error vision_error="$(case_field "$REPORT_DIR/${vision_case}.json" last_rpc_error)" - if [[ "$vision_error" == *"turn admission blocked provider call"* ]]; then + if is_admission_block_error "$vision_error"; then record_pass "$vision_case" "blocked before provider call on non-vision route" elif [[ -n "$vision_error" ]]; then record_warn "$vision_case" "unexpected rpc_error: $vision_error" @@ -254,6 +265,69 @@ run_media_admission_smoke() { fi } +run_openrouter_media_generation_smoke() { + local route="${OPENROUTER_MEDIA_GENERATION_ROUTE:-media-image-small}" + local slug + slug="$(slugify "$route")" + local case_name="openrouter_media_generation_${slug}" + + TIMEOUT_SECS="${OPENROUTER_MEDIA_GENERATION_TIMEOUT_SECS:-240}" run_case "$case_name" "$route" "phase410-openrouter-media-${slug}-${RUN_ID}" \ + "[GENERATE:IMAGE] Phase 4.10 OpenRouter generation smoke. Create a minimal 16x16 black-and-white checkerboard test image. Return the generated image artifact or provider-native image URL; do not describe it as text only." + + local rpc_error + rpc_error="$(case_field "$REPORT_DIR/${case_name}.json" last_rpc_error)" + if [[ -n "$rpc_error" ]]; then + record_fail "$case_name" "unexpected rpc_error: $rpc_error" + return + fi + + assert_assistant_contains "$case_name" "$REPORT_DIR/${case_name}.json" "[IMAGE:data:image/" +} + +run_model_switch_smoke() { + local start_route="${SWITCH_START_ROUTE:-cheap}" + local target_route="${SWITCH_TARGET_ROUTE:-minimax}" + local start_slug target_slug + start_slug="$(slugify "$start_route")" + target_slug="$(slugify "$target_route")" + local case_name="model_switch_${start_slug}_to_${target_slug}" + + run_case "$case_name" "$start_route" "phase410-switch-${start_slug}-${target_slug}-${RUN_ID}" \ + "Reply with exactly SWITCH_START_OK." \ + "/model $target_route" \ + "Reply with exactly SWITCH_DONE_OK." + + assert_contains "$case_name" "$REPORT_DIR/${case_name}.json" "SWITCH_START_OK" + assert_contains "$case_name" "$REPORT_DIR/${case_name}.json" "switched to" + assert_assistant_contains "$case_name" "$REPORT_DIR/${case_name}.json" "SWITCH_DONE_OK" +} + +run_context_overflow_switch_smoke() { + local start_route="${OVERFLOW_SWITCH_START_ROUTE:-cheap}" + local target_route="${OVERFLOW_SWITCH_TARGET_ROUTE:-media-image-small}" + local chars="${OVERFLOW_SWITCH_CHARS:-50000}" + local start_slug target_slug + start_slug="$(slugify "$start_route")" + target_slug="$(slugify "$target_route")" + local case_name="model_switch_overflow_${start_slug}_to_${target_slug}" + local ballast + ballast="$(python3 - "$chars" <<'PY' +import sys + +chars = int(sys.argv[1]) +print("x" * chars) +PY +)" + + run_case "$case_name" "$start_route" "phase410-overflow-switch-${start_slug}-${target_slug}-${RUN_ID}" \ + "Keep this route-switch ballast in the current conversation only and reply exactly BALLAST_OK: $ballast" \ + "/model $target_route" + + assert_contains "$case_name" "$REPORT_DIR/${case_name}.json" "BALLAST_OK" + assert_contains "$case_name" "$REPORT_DIR/${case_name}.json" "route switch" + assert_contains "$case_name" "$REPORT_DIR/${case_name}.json" "blocked" +} + run_heavy_dialogue() { local route="${HEAVY_ROUTE:-cheap}" local slug @@ -405,12 +479,21 @@ summarize_embedding_and_compaction() { record_warn "embedding_signal" "no embedding signal found; check whether embedding provider is noop: $embedding_log" fi + local compaction_pressure_count=0 + if [[ -s "$CONTEXT_TSV" ]]; then + compaction_pressure_count="$(awk -F '\t' 'NR > 1 && ($3 == "caution" || $3 == "over_budget" || $12 == "true") { count++ } END { print count + 0 }' "$CONTEXT_TSV")" + fi + if [[ -s "$compaction_log" ]]; then record_pass "compaction_signal" "found compaction/summary signal: $compaction_log" + elif [[ "$REQUIRE_COMPACTION_SIGNAL" == "1" ]]; then + record_fail "compaction_signal" "required compaction/summary signal not found" + elif [[ "$RUN_HEAVY" == "1" && "$compaction_pressure_count" -gt 0 ]]; then + record_fail "compaction_signal" "context pressure observed rows=$compaction_pressure_count but no compaction/summary signal found" elif [[ "$RUN_HEAVY" == "1" ]]; then - record_fail "compaction_signal" "heavy run requested but no compaction/summary signal found" + record_pass "compaction_signal" "heavy run stayed within provider window; no compaction required" else - record_warn "compaction_signal" "no compaction signal in this short run; rerun with RUN_HEAVY=1 for mandatory compaction" + record_warn "compaction_signal" "no compaction signal in this short run; set REQUIRE_COMPACTION_SIGNAL=1 for mandatory compaction" fi if [[ -s "$admission_log" ]]; then @@ -445,6 +528,24 @@ main() { run_media_admission_smoke fi + if [[ "$RUN_OPENROUTER_MEDIA_GENERATION" == "1" ]]; then + run_openrouter_media_generation_smoke + else + record_warn "openrouter_media_generation" "skipped; set RUN_OPENROUTER_MEDIA_GENERATION=1 for expensive live OpenRouter image generation" + fi + + if [[ "$RUN_MODEL_SWITCH" == "1" ]]; then + run_model_switch_smoke + else + record_warn "model_switch" "skipped; set RUN_MODEL_SWITCH=1 to test live route switching" + fi + + if [[ "$RUN_CONTEXT_OVERFLOW_SWITCH" == "1" ]]; then + run_context_overflow_switch_smoke + else + record_warn "model_switch_overflow" "skipped; set RUN_CONTEXT_OVERFLOW_SWITCH=1 for expensive live overflow-switch test" + fi + if [[ "$RUN_HEAVY" == "1" ]]; then run_heavy_dialogue else diff --git a/dev/gateway-chat-harness/scripts/phase4_10_targeted_tests.sh b/dev/gateway-chat-harness/scripts/phase4_10_targeted_tests.sh new file mode 100644 index 00000000..a339fc46 --- /dev/null +++ b/dev/gateway-chat-harness/scripts/phase4_10_targeted_tests.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="${ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)}" + +cd "$ROOT" + +run() { + printf '\n==> %s\n' "$*" + "$@" +} + +# Slices 1, 4, 6, 11, 13, 17: context snapshots, admission, pressure, +# compaction, condensation, handoff. +run cargo test -q -p synapse_domain provider_context_budget --lib +run cargo test -q -p synapse_domain route_switch_preflight --lib +run cargo test -q -p synapse_domain turn_admission --lib +run cargo test -q -p synapse_domain summary_route_resolution --lib +run cargo test -q -p synapse_domain session_handoff --lib + +# Slices 2, 3, 5, 7, 14: typed defaults, read-only recall, scoped context, +# deterministic tool exposure, and modality markers. +run cargo test -q -p synapse_domain execution_guidance --lib +run cargo test -q -p synapse_domain turn_tool_narrowing --lib +run cargo test -q -p synapse_domain turn_context --lib +run cargo test -q -p synapse_domain scoped_instruction_resolution --lib +run cargo test -q -p synapse_domain turn_markup --lib +run cargo test -q -p synapse_domain turn_model_routing --lib + +# Slices 10, 12, 18: capability lanes, model profiles, endpoint-aware metadata, +# provider options, and provider-layer marker parsing. +run cargo test -q -p synapse_domain model_lane_resolution --lib +run cargo test -q -p synapse_domain model_capability_support --lib +run cargo test -q -p synapse_providers provider_runtime_options --lib +run cargo test -q -p synapse_providers azure_provider --lib +run cargo test -q -p synapse_providers parse_image_marker_parts --lib +run cargo test -q -p synapse_tools model_routing_config --lib + +# Slice 16: memory quality, embedding profile calibration, and self-learning +# gates. +run cargo test -q -p synapse_domain memory_quality_governor --lib +run cargo test -q -p synapse_domain post_turn_orchestrator --lib +run cargo test -q -p synapse_domain self_learning_eval_harness --lib +run cargo test -q -p synapse_memory embeddings --lib + +# Slices 15, 19, 20, 21, 22, 23: repair traces, assumptions, epistemic state, +# watchdog, calibration, and janitor. +run cargo test -q -p synapse_domain route_admission_history --lib +run cargo test -q -p synapse_domain runtime_assumptions --lib +run cargo test -q -p synapse_domain epistemic_state --lib +run cargo test -q -p synapse_domain runtime_watchdog --lib +run cargo test -q -p synapse_domain runtime_calibration --lib +run cargo test -q -p synapse_domain runtime_trace_janitor --lib + +# Slices 9, 24, 25, 26: native tool protocol and web/channel extraction/parity. +run cargo test -q -p synapse_adapters dispatcher --lib --features channel-matrix +run cargo test -q -p synapse_adapters runtime_adapter_contract --lib --features channel-matrix +run cargo test -q -p synapse_adapters runtime_routes --lib --features channel-matrix +run cargo test -q -p synapse_adapters context_engine --lib --features channel-matrix +run cargo test -q -p synapse_channels session_backend --lib --features channel-matrix + +printf '\nPhase 4.10 targeted tests completed.\n' diff --git a/docs/fork/ipc-phase4_10-plan.md b/docs/fork/ipc-phase4_10-plan.md index 42af3106..197ef7ad 100644 --- a/docs/fork/ipc-phase4_10-plan.md +++ b/docs/fork/ipc-phase4_10-plan.md @@ -2400,6 +2400,28 @@ Expected outcome: ## Validation +The expanded slice-by-slice closeout matrix is tracked in +[Phase 4.10 Test Matrix](./ipc-phase4_10-test-matrix.md). Use it as the +execution checklist before declaring the phase fully validated. + +4.10 live validation now also covers model switching and an optional expensive +context-overflow switch case. OpenRouter MiniMax is tracked as a text/tool route +(`minimax/minimax-m2.7`) only; audio/video/music generation stays on explicit +OpenRouter media-capable profiles, while MiniMax direct media models are reserved +for a future provider/tool adapter. + +OpenRouter image-generation validation is no longer admission-only: the +OpenRouter provider now sends typed `modalities` from the catalog profile and +maps `message.images` into the existing `[IMAGE:data:image/...]` delivery +marker. Audio/video/music remain capability-routed but still need +artifact-specific delivery follow-through before they are treated as fully +implemented media artifact paths. + +The heavy long-dialogue test should not require compaction when the selected +route has a large healthy context window. Use `REQUIRE_COMPACTION_SIGNAL=1` only +for a dedicated compaction-pressure scenario; otherwise the heavy test validates +semantic retention, memory hygiene signals, and context telemetry. + ### Context economy checks - ordinary memory-setting turns should not read bootstrap docs diff --git a/docs/fork/ipc-phase4_10-test-matrix.md b/docs/fork/ipc-phase4_10-test-matrix.md new file mode 100644 index 00000000..b8e2d3d1 --- /dev/null +++ b/docs/fork/ipc-phase4_10-test-matrix.md @@ -0,0 +1,276 @@ +# Phase 4.10 Test Matrix + +Purpose: keep the 4.10 closeout validation explicit before adding more code. +This is not a second implementation plan. It maps each slice to concrete +checks, preferring unit/domain tests first, then adapter integration tests, then +live provider runs only where provider behavior matters. + +## Execution Gates + +1. Fast local gate: + - `cargo fmt --all --check` + - `cargo check -q -p synapse_domain -p synapse_adapters -p synapseclaw --features channel-matrix` +2. Targeted 4.10 unit/integration gate: + - run narrow domain/adapter tests for context pressure, route admission, + tool narrowing, memory hygiene, self-repair, model profiles, and + web/channel parity +3. Base live gate: + - `RUN_REASONER=1 RUN_DOCTOR_MODELS=1 STRICT_RECALL_NO_MUTATION=1 dev/gateway-chat-harness/scripts/phase4_10_live_pack.sh` + - covers `cheap`, `deepseek`, `deepseek-reasoner`, `gpt-5.4`, CJK, + structured media admission, live model switching, embedding/admission + signals, and doctor models +4. Heavy live gate: + - run only after the fast, targeted, and base live gates have no failures + - `RUN_HEAVY=1 RUN_CONTEXT_OVERFLOW_SWITCH=1 dev/gateway-chat-harness/scripts/phase4_10_live_pack.sh` + - validates long-dialogue retention, context size, embedding signals, and + no procedural-skill pollution + - the overflow switch subcase validates large-window to small-window + preflight against a small OpenRouter media route and must block or compact + before applying the new route + +Current live blocker: + +- `2026-04-12` base pack found `deepseek_memory` recall emitting + `core_memory_update` under strict no-mutation mode. This is a shared + read-only recall/tool-narrowing policy bug to fix before any heavy run. + Report: `/tmp/synapseclaw-phase410-base-1776030344`. + +## Slice Coverage + +### Slice 1: Context Snapshot + +- Unit: provider-context budget snapshot includes stable/dynamic, protected/removable, + primary ballast, headroom, and pressure tier. +- Live: provider-context TSV is produced for every live pack and has at least + one row per provider call. + +### Slice 2: Typed Defaults + +- Unit: arbitrary user-profile facts stay dynamic key/value data; no fixed city, + timezone, or response-style schema is required. +- Unit: typed delivery/default resolution uses runtime/profile state instead of + prompt prose. + +### Slice 3: Non-Mutating Structured Recall + +- Unit: resolved-state recall exposes no memory/profile mutation tools. +- Unit: profile-fact resolution may keep external lookup only when the turn + needs an external fact, but must not expose profile mutation by default. +- Live: `STRICT_RECALL_NO_MUTATION=1` must pass for `cheap`, `deepseek`, + `deepseek-reasoner`, and `gpt-5.4`. + +### Slice 4: Live History Compaction + +- Unit: compaction preserves protected head/tail and does not split assistant + tool-call / tool-result groups. +- Live: heavy long-dialogue run records context rows and either a compaction + signal or a clear within-budget reason. + +### Slice 5: Deterministic Runtime Execution + +- Unit/integration: typed mutation and delivery flows prefer runtime execution + paths where available instead of relying on cheap-model prompt compliance. +- Live: tool smoke creates the requested file exactly once and records typed + tool outcomes. + +### Slice 6: Cheap Condensation + +- Unit: summary lane resolution is capability-lane based and does not consume + legacy route aliases. +- Unit: compaction cache reuses unchanged source/policy/window digests. +- Live: long-dialogue run checks that procedural skill counts do not grow from + pure philosophy dialogue. + +### Slice 7: Progressive Scoped Context + +- Unit: scoped-context selection suppresses stale inferred context on structured + media turns. +- Unit: explicit non-media path hints can still load bounded scoped context. +- Live: scoped context should appear only when relevant and should stay bounded. + +### Slice 8: Provider-Native Continuation + +- Unit/adapter: continuation request shaping is adapter-local and capability-gated. +- Live: only run official/key-based Responses continuation when an endpoint + actually accepts `previous_response_id`; the default Codex backend remains + expected-not-supported. + +### Slice 9: Strict Tool Protocol + +- Unit: shared runtime accepts native structured tool calls only. +- Unit: no shared XML/text/GLM/Minimax/perl-style dialect fallback exists. +- Adapter: provider-specific normalization must live only in concrete adapters. + +### Slice 10: Capability Lanes + +- Unit: lane candidates resolve in ordered provider:model candidates with + candidate-scoped profile metadata. +- Unit: catalog aliases and presets do not create a second route table. +- Unit/live: optional Gemma/Grok/DeepSeek routes remain catalog-driven and not + Rust hardcoded defaults. +- Unit/live: OpenRouter MiniMax M2.7 is catalog-driven as a text/tool route; do + not mark it as audio/video/music-capable unless provider metadata exposes + those output modalities. +- Unit: OpenRouter media lanes use actual media-capable profiles separately: + image (`google/gemini-3.1-flash-image-preview`), audio + (`openai/gpt-audio-mini`), music (`google/lyria-3-*`), video + (`google/veo-3.1`, `alibaba/wan-2.6`). + +### Slice 11: Turn Admission + +- Unit: media, tool-heavy, mutation, delivery, and overflow turns produce typed + admission intent/action/reasons before provider call. +- Live: text-only routes block structured media-generation markers before the + provider call. + +### Slice 12: Model Profile Registry + +- Unit: manual profile overrides win over local catalog, cached provider catalog, + bundled catalog, then adapter fallback. +- Unit: endpoint-aware cache prevents native/aggregator/local profiles from + colliding for the same provider:model string. + +### Slice 13: Context Pressure Manager + +- Unit: input budget reserves output headroom and scales by trusted context + window instead of fixed model constants. +- Unit: large-window to small-window route preflight compacts or blocks before + mutation. +- Unit/integration: compaction cache stats are surfaced through the shared web + and channel route selection path. +- Live: optional expensive switch-overflow subcase primes a large provider + context and attempts a switch to a small-window OpenRouter media route; the + expected result is a clear block/compact outcome, not silent route loss. + +### Slice 14: Modality Routing + +- Unit: structured `[IMAGE:...]` and `[GENERATE:*]` markers resolve capability + requirements without natural-language phrase lists. +- Unit/live: generic memory/search/workspace tools are hidden on media marker + turns unless a dedicated media adapter is explicitly selected. +- Unit/live: media generation routes are capability/profile based, not inferred + from provider name strings such as MiniMax/Kimi/Gemini. + +### Slice 15: Self-Repair + +- Unit: tool failures classify into typed repair kinds without duplicate string + classifiers across web/channel. +- Unit: recent failures suppress only same-role alternatives and do not hide the + only available tool. +- Integration/live: failed tool results carry bounded repair hints and do not + retry the same bad path immediately. + +### Slice 16: Memory Quality, Embedding, Self-Learning + +- Unit: generic dialogue and ephemeral repair traces are rejected before durable + memory mutation. +- Unit: concept-to-concept graph edges such as generic world-knowledge examples + are rejected by the governor, not by phrase-specific filters. +- Unit: embedding profile resolution comes from catalog metadata; unknown + embedding models disable embeddings rather than guessing. +- Live: long-dialogue semantic run checks early/late anchor retention, embedding + signals, and no procedural skill promotion. + +### Slice 17: Structured Handoff + +- Unit: route-pressure handoff packets carry active task/defaults/assumptions/ + recent repairs and stay bounded. +- Live: large-window to small-window validation should produce compact handoff + or block rather than silently losing context. + +### Slice 18: Capability Probe/Profile Repair + +- Unit/integration: `models refresh` respects provider endpoints and writes + endpoint-aware cached profile metadata. +- Unit: typed context-limit observations can lower/fill unknown cached windows + but never raise them optimistically. +- Live: doctor models for DeepSeek/OpenRouter passes in the base pack. +- Live/catalog: OpenRouter discovery is used to verify MiniMax text-only output + versus actual media-capable OpenRouter models before updating bundled + capabilities. +- Live: OpenRouter image generation smoke uses an explicit media lane and must + return a real `[IMAGE:data:image/...]` marker, not an empty assistant turn or + text-only description. + +## OpenRouter/MiniMax Media Note + +- OpenRouter currently exposes `minimax/minimax-m2.7` as `text->text`; it is + useful as an agentic text/model-switch target, not as an audio/video/music + generation lane. +- MiniMax direct media models belong behind a future MiniMax provider/tool + adapter (`speech-*`, `MiniMax-Hailuo-*`, `music-*`). Until that adapter exists, + do not fake media generation by sending media turns to a text chat model. +- For OpenRouter media tests, use models whose provider metadata advertises the + required output modality and keep them in capability/profile data, not Rust + match arms. +- Current adapter support is image-output first: OpenRouter `modalities` and + `message.images` are mapped in the OpenRouter provider. Audio/video/music + still need artifact-specific adapter follow-through before they are treated as + fully delivered media artifacts. + +### Slice 19: Assumption Tracker + +- Unit: assumptions carry source/freshness/confidence/invalidation/replacement path. +- Unit: runtime/provider/tool failures challenge the exact assumption kind and + handoff packets carry bounded active assumptions. + +### Slice 20: Epistemic State + +- Unit: stale/low-confidence profile and memory facts become + `needs_verification` or `stale`, not `known`. +- Unit: retrieval/routing penalizes weaker epistemic facts before they outrank + stronger anchors. + +### Slice 21: Runtime Watchdog + +- Unit: watchdog digests repeated failures, context pressure, stale metadata, + and subsystem observations into bounded typed alerts. +- Integration: web and channel inject/render the same watchdog digest path. + +### Slice 22: Runtime Calibration + +- Unit: route/tool overconfident failures create bounded suppression records. +- Unit/integration: suppression affects future route/tool choices only when a + safe same-lane or same-role alternative exists. + +### Slice 23: Trace Janitor + +- Unit: repair, assumption, watchdog, calibration, and handoff traces obey TTL, + dedupe, and count bounds. +- Unit: janitor promotion candidates stay behind explicit gates and do not + become durable user memory automatically. + +### Slice 24: Runtime Adapter Contract + +- Unit/conformance: web/channel runtime command presentation cannot be rendered + locally outside shared primitives. +- Integration: provider/model switch outcomes and diagnostics flow through the + common adapter-core executor. + +### Slice 25: Tool Notification Mapper + +- Unit: observer-event to notification interpretation is shared, with + transport-specific JSON/text sinks only at the adapter edge. +- Unit: duplicate suppression and UTF-8-safe argument/output previews are common. + +### Slice 26: Web/Channel Extraction + +- Unit/integration: shared prompt/bootstrap/provider-history helpers remain + outside `channels/mod.rs`. +- Unit: moved helpers do not reintroduce shared XML/tag sanitizers or + transport side effects. + +## Immediate Additions Before Next Heavy Run + +1. Done: fix and test Slice 3 read-only recall narrowing so `core_memory_update`, + `memory_store`, `memory_forget`, and `user_profile` are not visible when + typed resolved state is sufficient. +2. Done: add a targeted test for profile-fact guidance that allows external lookup + without exposing profile mutation. +3. Done: add a targeted 4.10 test command list/script that groups the most relevant + domain and adapter tests above so phase-close validation is reproducible. +4. Done: re-run base live pack with strict recall no-mutation across multiple routes. +5. Done: re-run live model-switch smoke against the OpenRouter MiniMax route. +6. Done: run heavy long-dialogue and context-overflow switch after the base live + pack is green; long semantic retention passed, and compaction is now required + only when telemetry shows context pressure or `REQUIRE_COMPACTION_SIGNAL=1`.