diff --git a/changelog.d/180.added.md b/changelog.d/180.added.md new file mode 100644 index 000000000..d0b47fdc4 --- /dev/null +++ b/changelog.d/180.added.md @@ -0,0 +1 @@ +Added native Fusion dual-lane routing (`--fusion` / `always_fusion` / `[agent.fusion]`): a frontier lead agent delegates mechanical work to a cost-aware sidekick via `fusion_delegate`, with mid-session model switches at compaction boundaries and per-lane cost stats on done events. diff --git a/n00n-agent/src/agent/compaction.rs b/n00n-agent/src/agent/compaction.rs index 77e9ec343..38d563ab5 100644 --- a/n00n-agent/src/agent/compaction.rs +++ b/n00n-agent/src/agent/compaction.rs @@ -79,7 +79,7 @@ pub(super) async fn compact_history( session_id: Option<&n00n_storage::id::SessionRef>, cwd: &std::path::Path, transcript_path: Option<&std::path::Path>, -) -> Result { +) -> Result<(TokenUsage, String), AgentError> { run_precompact_hooks(trigger, session_id, cwd, transcript_path).await?; let compact_start = std::time::Instant::now(); @@ -135,13 +135,8 @@ pub(super) async fn compact_history( run_postcompact_hooks(trigger, session_id, cwd, transcript_path, &summary).await; - Ok(finish_compact( - response, - history, - event_tx, - compact_start, - model, - )) + let usage = finish_compact(response, history, event_tx, compact_start, model); + Ok((usage, summary)) } fn finish_compact( @@ -185,7 +180,7 @@ pub async fn compact( ) -> Result<(), AgentError> { let cancel = CancelToken::none(); let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("/")); - let usage = compact_history( + let (usage, _summary) = compact_history( provider, model, history, @@ -203,6 +198,7 @@ pub async fn compact( usage, num_turns: 1, stop_reason: None, + fusion: None, })?; Ok(()) diff --git a/n00n-agent/src/agent/run.rs b/n00n-agent/src/agent/run.rs index eaebd2518..9e05eaea5 100644 --- a/n00n-agent/src/agent/run.rs +++ b/n00n-agent/src/agent/run.rs @@ -5,8 +5,8 @@ use tracing::{error, info, warn}; use n00n_providers::provider::Provider; use n00n_providers::{ - ContentBlock, HistoryReplayReason, Message, Model, OpenAiOptions, RequestOptions, Role, - StopReason, StreamResponse, System, TokenUsage, + ContentBlock, HistoryReplayReason, Message, Model, ModelTier, OpenAiOptions, RequestOptions, + Role, StopReason, StreamResponse, System, TokenUsage, }; use super::compaction::{self, CONTINUE_AFTER_COMPACT}; @@ -24,8 +24,10 @@ use crate::tools::{ }; use crate::{ AgentConfig, AgentError, AgentEvent, AgentInput, AgentMode, EventSender, ExtractedCommand, - InterruptPoint, InterruptSource, ToolDoneEvent, TurnCompleteEvent, + FusionLane, FusionRoute, FusionState, InterruptPoint, InterruptSource, ToolDoneEvent, + TurnCompleteEvent, }; +use n00n_config::providers::Tier; use n00n_config::{ToolKey, ToolOutputLines}; use n00n_storage::id::SessionRef; @@ -50,6 +52,15 @@ const CACHE_BREAKPOINTS_MEDIUM: usize = 3; const CACHE_BREAKPOINTS_SHORT: usize = 2; const CACHE_BREAKPOINTS_MIN: usize = 1; +fn tier_to_model_tier(tier: Tier) -> ModelTier { + match tier { + Tier::Weak => ModelTier::Weak, + Tier::Medium => ModelTier::Medium, + Tier::Strong => ModelTier::Strong, + Tier::Compaction => ModelTier::Compaction, + } +} + fn filter_tools_for_mode(tools: &mut Value, mode: &AgentMode) { if !mode.is_readonly() { return; @@ -196,12 +207,31 @@ pub struct Agent<'h> { tool_filter: ToolFilter, active_tools: ActiveTools, supports_tool_examples: bool, + lead_model: Option>, + lead_provider: Option>, + fusion_state: Option, } impl<'h> Agent<'h> { #[must_use] pub fn new(params: AgentParams, run: AgentRunParams<'h>) -> Self { let supports_tool_examples = params.model.supports_tool_examples(); + let fusion_enabled = params.config.fusion.enabled; + let lead_model = if fusion_enabled { + Some(Arc::new(params.model.clone())) + } else { + None + }; + let lead_provider = if fusion_enabled { + Some(Arc::clone(¶ms.provider)) + } else { + None + }; + let fusion_state = if fusion_enabled { + Some(FusionState::new_lead()) + } else { + None + }; let mut agent = Self { provider: params.provider, model: Arc::new(params.model), @@ -245,7 +275,15 @@ impl<'h> Agent<'h> { tool_filter: run.tool_filter, active_tools: ActiveTools::default(), supports_tool_examples, + lead_model, + lead_provider, + fusion_state, }; + if fusion_enabled { + agent + .system + .push_dynamic(crate::fusion::fusion_lead_system_append()); + } agent.warm_active_tools(); agent } @@ -514,6 +552,11 @@ impl<'h> Agent<'h> { if has_tools { let history_len_before = self.history.len(); let tool_results = self.process_tool_calls(response).await?; + if self.config.fusion.enabled + && let Some(state) = self.fusion_state.as_mut() + { + state.observe_tool_results(&tool_results); + } self.apply_skill_policy_from_results(&tool_results); if self.apply_tool_search_results(&tool_results) { self.rebuild_tools(); @@ -633,6 +676,11 @@ impl<'h> Agent<'h> { } fn record_usage(&mut self, usage: TokenUsage, cost: f64) { + if self.config.fusion.enabled + && let Some(state) = self.fusion_state.as_mut() + { + state.record_lane_usage(state.lane, usage, cost); + } self.total_usage += usage; self.total_cost += cost; } @@ -658,6 +706,11 @@ impl<'h> Agent<'h> { usage: self.total_usage, num_turns: self.num_turns, stop_reason, + fusion: self + .fusion_state + .as_ref() + .filter(|_| self.config.fusion.enabled) + .map(FusionState::usage_stats), }) } @@ -805,6 +858,116 @@ impl<'h> Agent<'h> { Ok(true) } + fn apply_fusion_route(&mut self, route: FusionRoute) { + let Some(lead) = self.lead_model.as_ref() else { + return; + }; + match route { + FusionRoute::Stay(_) => {} + FusionRoute::EscalateToLead | FusionRoute::Switch(FusionLane::Lead) => { + if let Some(provider) = self.lead_provider.as_ref() { + self.provider = Arc::clone(provider); + self.model = Arc::clone(lead); + } else { + let spec = lead.spec(); + let mut model = match Model::from_spec(&spec) { + Ok(model) => model, + Err(e) => { + warn!( + error = %e, + spec = %spec, + "fusion: failed to resolve lead model spec, keeping current provider/model" + ); + return; + } + }; + let provider = match n00n_providers::provider::from_model_with_openai_options( + &mut model, + self.timeouts, + self.openai_options, + ) { + Ok(provider) => provider, + Err(e) => { + warn!( + error = %e, + spec = %spec, + "fusion: failed to build provider for lead model, keeping current provider/model" + ); + return; + } + }; + self.provider = Arc::from(provider); + self.model = Arc::new(model); + } + if let Some(state) = self.fusion_state.as_mut() { + state.lane = FusionLane::Lead; + } + self.apply_fusion_lane_context(FusionLane::Lead); + info!(model = %self.model.id, "fusion: escalated to lead lane"); + } + FusionRoute::Switch(FusionLane::Sidekick) => { + let tier = tier_to_model_tier(self.config.fusion.sidekick_tier); + let spec = match n00n_providers::model_registry::model_registry().read() { + Ok(registry) => registry.spec_for_tier_any(tier), + Err(e) => { + warn!( + error = ?e, + ?tier, + "fusion: model registry lock poisoned, cannot resolve sidekick" + ); + return; + } + }; + let Some(spec) = spec else { + warn!(?tier, "fusion: no model for sidekick tier"); + return; + }; + let mut model = match Model::from_spec(&spec) { + Ok(model) => model, + Err(e) => { + warn!(error = %e, spec = %spec, "fusion: invalid sidekick model spec"); + return; + } + }; + let provider = match n00n_providers::provider::from_model_with_openai_options( + &mut model, + self.timeouts, + self.openai_options, + ) { + Ok(provider) => provider, + Err(e) => { + warn!( + error = %e, + spec = %spec, + "fusion: failed to build provider for sidekick model, keeping current provider/model" + ); + return; + } + }; + self.provider = Arc::from(provider); + self.model = Arc::new(model); + if let Some(state) = self.fusion_state.as_mut() { + state.record_delegation(); + } + self.apply_fusion_lane_context(FusionLane::Sidekick); + info!(model = %self.model.id, "fusion: switched to sidekick lane"); + } + } + } + + fn apply_fusion_lane_context(&mut self, lane: FusionLane) { + let append = match lane { + FusionLane::Lead => crate::fusion::fusion_lead_system_append(), + FusionLane::Sidekick => crate::fusion::fusion_sidekick_system_append(), + }; + if !self.system.replace_last_dynamic(append) { + self.system.push_dynamic(append); + } + self.supports_tool_examples = self.model.supports_tool_examples(); + self.tool_filter = ToolFilter::from_config(&self.config, &self.model, &[]); + self.rebuild_tools(); + } + async fn do_compact(&mut self) -> Result<(), AgentError> { if !self.commit_pre_dispatch() { return Err(AgentError::Cancelled); @@ -816,7 +979,7 @@ impl<'h> Agent<'h> { self.openai_options, ); let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("/")); - let usage = compaction::compact_history( + let (usage, summary) = compaction::compact_history( &*compact_provider, &compact_model, self.history, @@ -828,8 +991,20 @@ impl<'h> Agent<'h> { None, ) .await?; + // Charge compaction to the pre-route lane before any Fusion switch. let cost = usage.cost(&compact_model.pricing, false); self.record_usage(usage, cost); + if self.config.fusion.enabled { + let route = self.fusion_state.as_mut().map(|state| { + let recent_errors = state.recent_tool_errors(); + let route = crate::fusion::route_after_compact(state, &summary, recent_errors); + state.clear_recent_tool_errors(); + route + }); + if let Some(route) = route { + self.apply_fusion_route(route); + } + } self.rollback_len = Some(self.history.len()); self.event_tx.send(AgentEvent::CompactionDone)?; self.history diff --git a/n00n-agent/src/fusion/mod.rs b/n00n-agent/src/fusion/mod.rs new file mode 100644 index 000000000..a0eebaf9d --- /dev/null +++ b/n00n-agent/src/fusion/mod.rs @@ -0,0 +1,377 @@ +//! Devin Fusion-style dual-lane routing: a frontier lead agent delegates mechanical +//! work to a cheaper sidekick while both keep separate cached contexts. Model switches +//! happen at compaction boundaries when a cache miss is already unavoidable. + +use n00n_providers::TokenUsage; +use serde::Serialize; + +const FUSION_DELEGATE_TOOL: &str = "fusion_delegate"; +const RECENT_ERROR_ESCALATE_THRESHOLD: u32 = 2; +const SIDEKICK_FAILURE_ESCALATE_THRESHOLD: u32 = 2; +const MAX_DELEGATIONS_BEFORE_LEAD_LOCK: u32 = 8; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum FusionLane { + #[default] + Lead, + Sidekick, +} + +impl FusionLane { + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Lead => "lead", + Self::Sidekick => "sidekick", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DelegationKind { + /// Ambiguity, design, review, or serial debugging — keep on lead. + LeadOnly, + /// Mechanical exploration, edits, tests, lint — delegate to sidekick. + Delegate, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FusionRoute { + Stay(FusionLane), + Switch(FusionLane), + EscalateToLead, +} + +#[derive(Debug, Clone, Default, Serialize)] +pub struct FusionUsageStats { + pub lead_cost: f64, + pub sidekick_cost: f64, + pub lead_usage: TokenUsage, + pub sidekick_usage: TokenUsage, + pub delegation_count: u32, + pub compact_count: u32, + pub final_lane: FusionLane, +} + +#[derive(Debug, Clone, Default)] +pub struct FusionState { + pub lane: FusionLane, + pub delegation_count: u32, + pub sidekick_failures: u32, + pub compact_count: u32, + pub recent_tool_errors: u32, + pub lead_usage: TokenUsage, + pub sidekick_usage: TokenUsage, + pub lead_cost: f64, + pub sidekick_cost: f64, +} + +impl FusionState { + #[must_use] + pub fn new_lead() -> Self { + Self { + lane: FusionLane::Lead, + delegation_count: 0, + sidekick_failures: 0, + compact_count: 0, + recent_tool_errors: 0, + lead_usage: TokenUsage::default(), + sidekick_usage: TokenUsage::default(), + lead_cost: 0.0, + sidekick_cost: 0.0, + } + } + + pub fn record_lane_usage(&mut self, lane: FusionLane, usage: TokenUsage, cost: f64) { + match lane { + FusionLane::Lead => { + self.lead_usage += usage; + self.lead_cost += cost; + } + FusionLane::Sidekick => { + self.sidekick_usage += usage; + self.sidekick_cost += cost; + } + } + } + + #[must_use] + pub fn usage_stats(&self) -> FusionUsageStats { + FusionUsageStats { + lead_cost: self.lead_cost, + sidekick_cost: self.sidekick_cost, + lead_usage: self.lead_usage, + sidekick_usage: self.sidekick_usage, + delegation_count: self.delegation_count, + compact_count: self.compact_count, + final_lane: self.lane, + } + } + + pub fn observe_tool_results(&mut self, results: &[crate::ToolDoneEvent]) { + for done in results { + if done.tool.as_ref() == FUSION_DELEGATE_TOOL { + if done.is_error { + self.recent_tool_errors = self.recent_tool_errors.saturating_add(1); + self.record_sidekick_failure(); + } else { + // fusion_delegate runs as an isolated subagent; main lane stays Lead. + self.delegation_count = self.delegation_count.saturating_add(1); + if let Some(tel) = done.output.telemetry() { + let cost = tel.cost.unwrap_or_else(|| 0.0); + let usage = match tel.usage.as_ref() { + Some(usage) => tool_usage_to_token_usage(usage), + None => TokenUsage::default(), + }; + self.record_lane_usage(FusionLane::Sidekick, usage, cost); + } + } + continue; + } + if done.is_error { + self.recent_tool_errors = self.recent_tool_errors.saturating_add(1); + } + } + } + + pub fn clear_recent_tool_errors(&mut self) { + self.recent_tool_errors = 0; + } + + #[must_use] + pub fn recent_tool_errors(&self) -> u32 { + self.recent_tool_errors + } + + #[must_use] + pub fn should_escalate_for_tool_errors(&self) -> bool { + self.recent_tool_errors >= RECENT_ERROR_ESCALATE_THRESHOLD + } + + pub fn record_delegation(&mut self) { + self.delegation_count = self.delegation_count.saturating_add(1); + self.lane = FusionLane::Sidekick; + } + + pub fn record_sidekick_failure(&mut self) { + self.sidekick_failures = self.sidekick_failures.saturating_add(1); + } + + pub fn record_compact(&mut self) { + self.compact_count = self.compact_count.saturating_add(1); + } +} + +/// Lexical classifier aligned with Cognition's delegation guidance: lead owns plan, +/// ambiguity, and final review; sidekick handles exploration, broad edits, tests, lint. +#[must_use] +pub fn classify_delegation(prompt: &str) -> DelegationKind { + const LEAD: &[&str] = &[ + "ambiguous", + "unclear", + "trade-off", + "tradeoff", + "architect", + "design", + "plan", + "review", + "approve", + "decide", + "judgment", + "security", + "vulnerab", + "root cause", + "serial debug", + "debug chain", + "commit", + "merge", + "definition of done", + "constraints", + "edge case", + "interpret", + ]; + + const DELEGATE: &[&str] = &[ + "explore", + "search", + "grep", + "find", + "list", + "read file", + "implement", + "write test", + "add test", + "fix lint", + "format", + "rename", + "boilerplate", + "run test", + "cargo test", + "cargo clippy", + "update doc", + "mechanical", + "apply patch", + ]; + + let p = prompt.to_ascii_lowercase(); + if p.is_empty() { + return DelegationKind::LeadOnly; + } + + let lead_hits = LEAD.iter().filter(|sig| p.contains(**sig)).count(); + let delegate_hits = DELEGATE.iter().filter(|sig| p.contains(**sig)).count(); + + if lead_hits > 0 && delegate_hits == 0 { + return DelegationKind::LeadOnly; + } + if delegate_hits > 0 && lead_hits == 0 { + return DelegationKind::Delegate; + } + if delegate_hits > lead_hits { + return DelegationKind::Delegate; + } + DelegationKind::LeadOnly +} + +/// Route after compaction using summary text and recent sidekick failure pressure. +#[must_use] +pub fn route_after_compact( + state: &mut FusionState, + compact_summary: &str, + recent_tool_errors: u32, +) -> FusionRoute { + state.record_compact(); + + if state.sidekick_failures >= SIDEKICK_FAILURE_ESCALATE_THRESHOLD + || recent_tool_errors >= RECENT_ERROR_ESCALATE_THRESHOLD + { + return FusionRoute::EscalateToLead; + } + + let summary_kind = classify_delegation(compact_summary); + + match (state.lane, summary_kind) { + (FusionLane::Lead, DelegationKind::Delegate) + if state.delegation_count < MAX_DELEGATIONS_BEFORE_LEAD_LOCK => + { + FusionRoute::Switch(FusionLane::Sidekick) + } + (FusionLane::Sidekick, DelegationKind::LeadOnly) => FusionRoute::Switch(FusionLane::Lead), + (lane, _) => FusionRoute::Stay(lane), + } +} + +fn tool_usage_to_token_usage(usage: &crate::ToolUsage) -> TokenUsage { + TokenUsage { + input: u64_to_u32_saturating(usage.fresh_input_tokens), + output: u64_to_u32_saturating(usage.output_tokens), + cache_creation: u64_to_u32_saturating(usage.cache_write_tokens), + cache_read: u64_to_u32_saturating(usage.cache_read_tokens), + } +} + +fn u64_to_u32_saturating(value: u64) -> u32 { + u32::try_from(value).unwrap_or_else(|_| u32::MAX) +} + +/// System prompt appendix for lead lane when Fusion is enabled. +#[must_use] +pub fn fusion_lead_system_append() -> &'static str { + "Fusion mode is on. You are the lead agent: plan, resolve ambiguity, and do final review. \ + Delegate mechanical work early via fusion_delegate with a spec-quality brief (goal, constraints, \ + definition of done) — do not dictate full file contents. Monitor sidekick results and escalate \ + when judgment is needed." +} + +/// System prompt appendix for sidekick lane. +#[must_use] +pub fn fusion_sidekick_system_append() -> &'static str { + "Fusion sidekick lane: execute the delegated brief efficiently. Prefer index/codegraph/arbor \ + before broad reads. Return concise file:line evidence, test results, and a short summary — \ + not full file dumps." +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::*; + use test_case::test_case; + + #[test_case("grep the repo for TODO and list matches", DelegationKind::Delegate ; "mechanical grep")] + #[test_case("design the auth architecture and trade-offs", DelegationKind::LeadOnly ; "architecture")] + #[test_case("run cargo test and fix lint issues", DelegationKind::Delegate ; "tests lint")] + #[test_case("review the PR and decide if we should merge", DelegationKind::LeadOnly ; "review merge")] + #[test_case("explore the codebase for handler registration", DelegationKind::Delegate ; "explore")] + fn classify_delegation_cases(prompt: &str, expected: DelegationKind) { + assert_eq!(classify_delegation(prompt), expected); + } + + #[test] + fn route_escalates_after_sidekick_failures() { + let mut state = FusionState::new_lead(); + state.lane = FusionLane::Sidekick; + state.sidekick_failures = 2; + let route = route_after_compact(&mut state, "grep for foo", 0); + assert_eq!(route, FusionRoute::EscalateToLead); + } + + #[test] + fn route_switches_lead_to_sidekick_on_mechanical_summary() { + let mut state = FusionState::new_lead(); + let route = route_after_compact(&mut state, "implement tests and fix lint", 0); + assert_eq!(route, FusionRoute::Switch(FusionLane::Sidekick)); + } + + #[test] + fn observe_tool_results_counts_errors_and_sidekick_failures() { + let mut state = FusionState::new_lead(); + state.observe_tool_results(&[ + crate::ToolDoneEvent::error("1".into(), "fail"), + crate::ToolDoneEvent { + id: "2".into(), + tool: Arc::from(FUSION_DELEGATE_TOOL), + output: crate::ToolOutput::Plain("sidekick fail".into()), + is_error: true, + annotation: None, + written_path: None, + }, + ]); + assert_eq!(state.recent_tool_errors(), 2); + assert_eq!(state.sidekick_failures, 1); + assert!(state.should_escalate_for_tool_errors()); + } + + #[test] + fn observe_successful_fusion_delegate_updates_sidekick_stats() { + let mut state = FusionState::new_lead(); + let telemetry = crate::ToolTelemetry::try_new( + Some(0.12), + Some(crate::ToolUsage::try_new(10, 2, 1, 13, 5).expect("conserving tool usage")), + ) + .expect("valid telemetry") + .expect("some telemetry"); + state.observe_tool_results(&[crate::ToolDoneEvent { + id: "1".into(), + tool: Arc::from(FUSION_DELEGATE_TOOL), + output: crate::ToolOutput::Plain("ok".into()).with_telemetry(Some(telemetry)), + is_error: false, + annotation: None, + written_path: None, + }]); + assert_eq!(state.delegation_count, 1); + assert_eq!(state.lane, FusionLane::Lead); + assert!((state.sidekick_cost - 0.12).abs() < f64::EPSILON); + assert_eq!(state.sidekick_usage.input, 10); + assert_eq!(state.sidekick_usage.output, 5); + assert_eq!(state.sidekick_usage.cache_read, 2); + assert_eq!(state.sidekick_usage.cache_creation, 1); + } + + #[test] + fn lane_as_str_matches_storage_labels() { + assert_eq!(FusionLane::Lead.as_str(), "lead"); + assert_eq!(FusionLane::Sidekick.as_str(), "sidekick"); + } +} diff --git a/n00n-agent/src/lib.rs b/n00n-agent/src/lib.rs index 4749f0153..9b929ecfe 100644 --- a/n00n-agent/src/lib.rs +++ b/n00n-agent/src/lib.rs @@ -4,6 +4,7 @@ pub mod agent; pub mod cancel; pub mod child_guard; pub use child_guard::ChildGuard; +pub mod fusion; pub mod headless; pub mod mcp; pub use mcp::config::{McpConfigError, McpConfigErrors, McpServerInfo, McpServerStatus}; @@ -17,7 +18,8 @@ pub use agent::{ SharedTranscript, find_subdirectory_instructions, is_instruction_file, }; pub use cancel::{CancelMap, CancelToken, CancelTrigger, PreDispatchGate}; -pub use n00n_config::{AgentConfig, PermissionsConfig, ToolOutputLines}; +pub use fusion::{FusionLane, FusionRoute, FusionState, FusionUsageStats}; +pub use n00n_config::{AgentConfig, FusionConfig, PermissionsConfig, ToolOutputLines}; pub mod command; pub mod diff; pub mod permissions; diff --git a/n00n-agent/src/types.rs b/n00n-agent/src/types.rs index f36b6a6bc..205e6cc95 100644 --- a/n00n-agent/src/types.rs +++ b/n00n-agent/src/types.rs @@ -879,6 +879,8 @@ pub enum AgentEvent { usage: TokenUsage, num_turns: u32, stop_reason: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + fusion: Option, }, AutoCompacting, CompactionDone, diff --git a/n00n-config/src/lib.rs b/n00n-config/src/lib.rs index 727440fc5..a1f251c64 100644 --- a/n00n-config/src/lib.rs +++ b/n00n-config/src/lib.rs @@ -65,6 +65,7 @@ pub const DEFAULT_BUILTINS: &[&str] = &[ "codegraph", "edit", "explore", + "fusion", "glob", "grep", "index", @@ -139,6 +140,13 @@ pub const TOP_LEVEL_FIELDS: &[ConfigField] = &[ min: None, description: "Start every session with workflow mode (task callable inside code_execution)", }, + ConfigField { + name: "always_fusion", + ty: "bool", + default: ConfigValue::Bool(false), + min: None, + description: "Start every session with Fusion dual-lane routing (lead + sidekick)", + }, ConfigField { name: "always_thinking", ty: "bool | string", @@ -226,6 +234,7 @@ pub struct RawConfig { pub always_yolo: Option, pub always_fast: Option, pub always_workflow: Option, + pub always_fusion: Option, pub always_thinking: Option, #[serde(default)] pub ui: UiFileConfig, @@ -243,6 +252,7 @@ impl RawConfig { always_yolo, always_fast, always_workflow, + always_fusion, always_thinking ); self.ui.merge(overlay.ui); @@ -277,6 +287,7 @@ impl RawConfig { always_yolo: self.always_yolo.is_some_and(|v| v), always_fast: self.always_fast.is_some_and(|v| v), always_workflow: self.always_workflow.is_some_and(|v| v), + always_fusion: self.always_fusion.is_some_and(|v| v), always_thinking: self .always_thinking .map(AlwaysThinking::resolve) @@ -477,6 +488,14 @@ pub struct AgentFileConfig { pub compaction_buffer: Option, pub mcp_tool_desc_max_chars: Option, pub dynamic_tools: Option, + pub fusion: Option, +} + +#[derive(Deserialize, Default, Debug, Clone)] +#[serde(default, deny_unknown_fields)] +pub struct FusionFileConfig { + pub enabled: Option, + pub sidekick_tier: Option, } #[derive(Deserialize, Default, Debug, Clone)] @@ -509,6 +528,18 @@ impl AgentFileConfig { (None, Some(over)) => self.dynamic_tools = Some(over), _ => {} } + match (self.fusion.as_mut(), overlay.fusion.clone()) { + (Some(base), Some(over)) => { + if over.enabled.is_some() { + base.enabled = over.enabled; + } + if over.sidekick_tier.is_some() { + base.sidekick_tier = over.sidekick_tier; + } + } + (None, Some(over)) => self.fusion = Some(over), + _ => {} + } } } @@ -835,6 +866,7 @@ pub struct Config { pub always_yolo: bool, pub always_fast: bool, pub always_workflow: bool, + pub always_fusion: bool, pub always_thinking: Option, pub ui: UiConfig, pub agent: AgentConfig, @@ -1076,6 +1108,24 @@ pub struct AgentConfig { #[config(skip, default = "DynamicToolConfig::default()")] pub dynamic_tools: DynamicToolConfig, + + #[config(skip, default = "FusionConfig::default()")] + pub fusion: FusionConfig, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FusionConfig { + pub enabled: bool, + pub sidekick_tier: crate::providers::Tier, +} + +impl Default for FusionConfig { + fn default() -> Self { + Self { + enabled: false, + sidekick_tier: crate::providers::Tier::Weak, + } + } } #[derive(Debug, Clone, Serialize, Deserialize, ConfigSection)] @@ -1116,6 +1166,17 @@ impl AgentConfig { DynamicToolConfig::default() }; + let fusion = if let Some(ff) = file.fusion { + FusionConfig { + enabled: ff.enabled.map_or(false, |enabled| enabled), + sidekick_tier: ff + .sidekick_tier + .map_or(crate::providers::Tier::Weak, |tier| tier), + } + } else { + FusionConfig::default() + }; + Self { no_rtk, max_output_bytes: file @@ -1137,6 +1198,7 @@ impl AgentConfig { allowed_tools: Vec::new(), disabled_tools, dynamic_tools, + fusion, } } } @@ -2345,6 +2407,7 @@ mod tests { always_yolo: false, always_fast: false, always_workflow: false, + always_fusion: false, always_thinking: None, ui: UiConfig::default(), agent: AgentConfig::default(), diff --git a/n00n-docgen/src/gen_config.rs b/n00n-docgen/src/gen_config.rs index d1ea5beb8..b5f4d704e 100644 --- a/n00n-docgen/src/gen_config.rs +++ b/n00n-docgen/src/gen_config.rs @@ -221,6 +221,14 @@ All fields are optional. Typos in field names cause an error right away. write_theme_section(&mut out); write_tool_output_section(&mut out); write_section(&mut out, "[agent]", AgentConfig::FIELDS); + + out.push_str("### `agent.fusion`\n\n"); + out.push_str("| Field | Type | Default | Description |\n"); + out.push_str("|-------|------|---------|-------------|\n"); + out.push_str("| `enabled` | bool | `false` | Enable Fusion dual-lane routing (lead + sidekick) for this session |\n"); + out.push_str("| `sidekick_tier` | string | - | Model tier for the sidekick lane (e.g. \"haiku\", \"sonnet\", \"opus\") |\n"); + out.push('\n'); + write_section(&mut out, "[provider]", ProviderConfig::FIELDS); write_section(&mut out, "[storage]", StorageConfig::FIELDS); diff --git a/n00n-docgen/src/gen_tools.rs b/n00n-docgen/src/gen_tools.rs index ad170c265..016e03873 100644 --- a/n00n-docgen/src/gen_tools.rs +++ b/n00n-docgen/src/gen_tools.rs @@ -50,6 +50,7 @@ const SECTIONS: &[(&str, &[&str])] = &[ "skill", "tool_search", "load_namespace", + "fusion_delegate", ], ), ("Web", &["webfetch", "websearch"]), @@ -419,7 +420,8 @@ mod tests { assert!(cleaned.starts_with(remaining_prefix), "cleaned: {cleaned}"); } - const MAX_TOOL_DEFINITION_BYTES: usize = 38_000; + // Room for fusion_delegate + skill-system tools; keep definitions lean. + const MAX_TOOL_DEFINITION_BYTES: usize = 38_500; #[test] fn tool_definitions_fit_byte_budget() { diff --git a/n00n-lua/src/api/agent.rs b/n00n-lua/src/api/agent.rs index beaa87d3a..b63d670f9 100644 --- a/n00n-lua/src/api/agent.rs +++ b/n00n-lua/src/api/agent.rs @@ -1385,6 +1385,7 @@ async fn prompt( usage: TokenUsage::default(), num_turns: 0, stop_reason: None, + fusion: None, }) { s.failed = true; diff --git a/n00n-lua/src/loader.rs b/n00n-lua/src/loader.rs index 4c58657df..6a9e2b6ea 100644 --- a/n00n-lua/src/loader.rs +++ b/n00n-lua/src/loader.rs @@ -103,6 +103,10 @@ static BUNDLED_PLUGINS: &[BundledPlugin] = &[ name: "explore", dir: include_dir!("$CARGO_MANIFEST_DIR/../plugins/explore"), }, + BundledPlugin { + name: "fusion", + dir: include_dir!("$CARGO_MANIFEST_DIR/../plugins/fusion"), + }, BundledPlugin { name: "task", dir: include_dir!("$CARGO_MANIFEST_DIR/../plugins/task"), diff --git a/n00n-providers/src/providers/devin.rs b/n00n-providers/src/providers/devin.rs index af4a66175..73f288afb 100644 --- a/n00n-providers/src/providers/devin.rs +++ b/n00n-providers/src/providers/devin.rs @@ -54,6 +54,12 @@ const DEFAULT_COMMAND: &str = "devin"; const REQUEST_PERMISSION_METHOD: &str = "session/request_permission"; const API_KEY_AUTH_METHOD: &str = "api-key"; const META_API_KEY: &str = "api_key"; +const META_SUPPORTS_IMAGES: &str = "cognition.ai/supportsImages"; +const META_CONTEXT_WINDOW: &str = "cognition.ai/contextWindow"; +const META_MAX_OUTPUT_TOKENS: &str = "cognition.ai/maxOutputTokens"; +const META_PRICING: &str = "cognition.ai/pricing"; +const META_FREE: &str = "cognition.ai/free"; +const META_PROMO: &str = "cognition.ai/promo"; pub(crate) const fn models() -> &'static [ModelEntry] { &[ @@ -1134,6 +1140,71 @@ fn fallback_models() -> Vec { .collect() } +fn models_from_config_options(opts: &[SessionConfigOption]) -> Vec { + let mut models = Vec::new(); + for opt in opts { + if opt.category != Some(SessionConfigOptionCategory::Model) { + continue; + } + let SessionConfigKind::Select(select) = &opt.kind else { + continue; + }; + let options: Vec<_> = match &select.options { + SessionConfigSelectOptions::Ungrouped(opts) => opts.clone(), + SessionConfigSelectOptions::Grouped(groups) => { + groups.iter().flat_map(|g| g.options.clone()).collect() + } + _ => Vec::new(), + }; + for option in options { + let value_str = option.value.to_string(); + if value_str.trim().is_empty() { + warn!("devin: skipping empty model id from session config options"); + continue; + } + let mut info = crate::model::ModelInfo::id_only(value_str.clone()); + info.name = Some(option.name.clone()).filter(|n| !n.trim().is_empty()); + info.supports_vision = option + .meta + .as_ref() + .and_then(|m| m.get(META_SUPPORTS_IMAGES)) + .and_then(Value::as_bool); + if let Some(meta) = &option.meta { + info.context_window = meta + .get(META_CONTEXT_WINDOW) + .and_then(Value::as_u64) + .map(clamped_u32) + .or_else(|| infer_context_window(&value_str)); + info.max_output_tokens = meta + .get(META_MAX_OUTPUT_TOKENS) + .and_then(Value::as_u64) + .map(clamped_u32) + .or_else(|| infer_max_output_tokens(&value_str)); + info.pricing = meta + .get(META_PRICING) + .and_then(parse_pricing) + .or_else(|| infer_pricing(&value_str)); + info.is_free = meta + .get(META_FREE) + .and_then(Value::as_bool) + .or_else(|| infer_is_free(&value_str)); + info.is_promo = meta + .get(META_PROMO) + .and_then(Value::as_bool) + .or_else(|| infer_is_promo(&value_str)); + } else { + info.context_window = infer_context_window(&value_str); + info.max_output_tokens = infer_max_output_tokens(&value_str); + info.pricing = infer_pricing(&value_str); + info.is_free = infer_is_free(&value_str); + info.is_promo = infer_is_promo(&value_str); + } + models.push(info); + } + } + models +} + #[derive(Debug, Clone)] struct DevinModelMeta { id: &'static str, @@ -1802,6 +1873,16 @@ impl Provider for Devin { Box::pin(async move { let inner = self.get_inner().await?; + { + let guard = inner.config_options.lock().await; + if !guard.is_empty() { + let models = models_from_config_options(&guard); + if !models.is_empty() { + return Ok(models); + } + } + } + let cwd = match std::env::current_dir() { Ok(p) => p, Err(_) => PathBuf::from("."), @@ -1814,74 +1895,18 @@ impl Provider for Devin { message: format!("session/new failed: {e}"), })?; - let models = response - .config_options - .map_or_else(fallback_models, |opts| { - let mut models = Vec::new(); - for opt in opts { - if opt.category != Some(SessionConfigOptionCategory::Model) { - continue; - } - let SessionConfigKind::Select(select) = opt.kind else { - continue; - }; - let options: Vec<_> = match select.options { - SessionConfigSelectOptions::Ungrouped(opts) => opts, - SessionConfigSelectOptions::Grouped(groups) => { - groups.into_iter().flat_map(|g| g.options).collect() - } - _ => Vec::new(), - }; - for option in options { - let value_str = option.value.to_string(); - let mut info = crate::model::ModelInfo::id_only(value_str.clone()); - info.name = Some(option.name.clone()).filter(|n| !n.trim().is_empty()); - info.supports_vision = option - .meta - .as_ref() - .and_then(|m| m.get("cognition.ai/supportsImages")) - .and_then(Value::as_bool); - if let Some(meta) = &option.meta { - info.context_window = meta - .get("cognition.ai/contextWindow") - .and_then(Value::as_u64) - .map(clamped_u32) - .or_else(|| infer_context_window(&value_str)); - info.max_output_tokens = meta - .get("cognition.ai/maxOutputTokens") - .and_then(Value::as_u64) - .map(clamped_u32) - .or_else(|| infer_max_output_tokens(&value_str)); - info.pricing = meta - .get("cognition.ai/pricing") - .and_then(parse_pricing) - .or_else(|| infer_pricing(&value_str)); - info.is_free = meta - .get("cognition.ai/free") - .and_then(Value::as_bool) - .or_else(|| infer_is_free(&value_str)); - info.is_promo = meta - .get("cognition.ai/promo") - .and_then(Value::as_bool) - .or_else(|| infer_is_promo(&value_str)); - } else { - info.context_window = infer_context_window(&value_str); - info.max_output_tokens = infer_max_output_tokens(&value_str); - info.pricing = infer_pricing(&value_str); - info.is_free = infer_is_free(&value_str); - info.is_promo = infer_is_promo(&value_str); - } - models.push(info); - } - } - if models.is_empty() { - fallback_models() - } else { - models - } - }); - - Ok(models) + if let Some(opts) = response.config_options { + let mut config_options = inner.config_options.lock().await; + config_options.clone_from(&opts); + let models = models_from_config_options(&opts); + if models.is_empty() { + Ok(fallback_models()) + } else { + Ok(models) + } + } else { + Ok(fallback_models()) + } }) } diff --git a/n00n-providers/src/types.rs b/n00n-providers/src/types.rs index 25b536446..b9953d389 100644 --- a/n00n-providers/src/types.rs +++ b/n00n-providers/src/types.rs @@ -217,6 +217,23 @@ impl System { self.push(SystemBlock::new(text, CacheControl::Dynamic)); } + /// Replace the text of the most recently pushed dynamic block, if any. + /// + /// Returns `true` when a dynamic block was updated. + pub fn replace_last_dynamic(&mut self, text: impl Into) -> bool { + if let Some(block) = self + .blocks + .iter_mut() + .rev() + .find(|block| block.cache == CacheControl::Dynamic) + { + block.text = text.into(); + true + } else { + false + } + } + /// Mark the static prefix as cacheable when no dynamic content has been /// added. This is a no-op after a dynamic block because caching a later /// static block would also cache the dynamic prefix. diff --git a/n00n-storage/src/sessions.rs b/n00n-storage/src/sessions.rs index 90d7dfdf8..edd41342a 100644 --- a/n00n-storage/src/sessions.rs +++ b/n00n-storage/src/sessions.rs @@ -192,11 +192,32 @@ pub struct SessionMeta { pub workflow: bool, #[serde(default, skip_serializing_if = "HashMap::is_empty")] pub usage_by_model: HashMap, + /// Fusion dual-lane cost breakdown when `--fusion` / `always_fusion` was on. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fusion: Option, /// Monotonic snapshot ordering used by write-behind persistence. #[serde(default)] pub revision: u64, } +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct StoredFusionUsage { + #[serde(default)] + pub lead_cost: f64, + #[serde(default)] + pub sidekick_cost: f64, + #[serde(default)] + pub lead_usage: StoredTokenUsage, + #[serde(default)] + pub sidekick_usage: StoredTokenUsage, + #[serde(default)] + pub delegation_count: u32, + #[serde(default)] + pub compact_count: u32, + #[serde(default)] + pub final_lane: String, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct StoredOpenAiResponseChain { pub response_id: String, @@ -630,6 +651,7 @@ fn classify_and_display(title: &str, first_message: Option<&str>) -> (String, St #[derive(Serialize, Deserialize)] #[serde(tag = "t")] +#[allow(clippy::large_enum_variant)] enum LogRecord { #[serde(rename = "header")] Header { diff --git a/n00n-token-profile/baselines/cold_start.json b/n00n-token-profile/baselines/cold_start.json index 20cdc185b..f4cd75067 100644 --- a/n00n-token-profile/baselines/cold_start.json +++ b/n00n-token-profile/baselines/cold_start.json @@ -4,20 +4,20 @@ "surfaces": { "cache_prefix": { "name": "cache_prefix", - "bytes": 16181, - "tokens": 3977 + "bytes": 17017, + "tokens": 4186 }, "main_tools_payload": { "name": "main_tools_payload", - "tool_count": 28, - "bytes": 22804, - "tokens": 5701 + "tool_count": 29, + "bytes": 23934, + "tokens": 5983 }, "main_tools_schemas": { "name": "main_tools_schemas", - "tool_count": 28, - "bytes": 13662, - "tokens": 3415 + "tool_count": 29, + "bytes": 14498, + "tokens": 3624 }, "system_prompt": { "name": "system_prompt", diff --git a/n00n-ui/src/agent/agent_loop.rs b/n00n-ui/src/agent/agent_loop.rs index fd51e3b93..03a8b38f5 100644 --- a/n00n-ui/src/agent/agent_loop.rs +++ b/n00n-ui/src/agent/agent_loop.rs @@ -431,6 +431,7 @@ impl AgentLoop { usage: TokenUsage::default(), num_turns: 0, stop_reason: None, + fusion: None, }); } e => { diff --git a/n00n-ui/src/app/mod.rs b/n00n-ui/src/app/mod.rs index 791492aea..ff2bea11f 100644 --- a/n00n-ui/src/app/mod.rs +++ b/n00n-ui/src/app/mod.rs @@ -63,6 +63,7 @@ use n00n_providers::{Effort, Message, Model, System, ThinkingConfig}; use n00n_storage::StateDir; use n00n_storage::input_history::InputHistory; use n00n_storage::model::persist_model; +use n00n_storage::sessions::StoredTokenUsage; use crate::storage_writer::StorageWriter; use ratatui::layout::Position; @@ -1607,6 +1608,40 @@ impl App { self.chats[chat_idx].set_pending_turn_usage(formatted); } + if chat_idx == 0 { + match &envelope.event { + AgentEvent::Done { + fusion: Some(stats), + .. + } => { + self.state.session.meta.fusion = + Some(n00n_storage::sessions::StoredFusionUsage { + lead_cost: stats.lead_cost, + sidekick_cost: stats.sidekick_cost, + lead_usage: StoredTokenUsage { + input: stats.lead_usage.input, + output: stats.lead_usage.output, + cache_creation: stats.lead_usage.cache_creation, + cache_read: stats.lead_usage.cache_read, + }, + sidekick_usage: StoredTokenUsage { + input: stats.sidekick_usage.input, + output: stats.sidekick_usage.output, + cache_creation: stats.sidekick_usage.cache_creation, + cache_read: stats.sidekick_usage.cache_read, + }, + delegation_count: stats.delegation_count, + compact_count: stats.compact_count, + final_lane: stats.final_lane.as_str().into(), + }); + } + AgentEvent::Done { fusion: None, .. } => { + self.state.session.meta.fusion = None; + } + _ => {} + } + } + let result = self.chats[chat_idx].handle_event(envelope.event, plan_path); if let ChatEventResult::QueueItemConsumed { diff --git a/n00n-ui/src/app/tests.rs b/n00n-ui/src/app/tests.rs index b42b7f8aa..b71d478e3 100644 --- a/n00n-ui/src/app/tests.rs +++ b/n00n-ui/src/app/tests.rs @@ -529,7 +529,7 @@ fn ctrl_c_quits_when_input_empty() { assert!(actions.is_empty()); } -#[test_case(AgentEvent::Done { usage: TokenUsage::default(), num_turns: 1, stop_reason: None }, ExitRequest::Success ; "done_exits_success")] +#[test_case(AgentEvent::Done { usage: TokenUsage::default(), num_turns: 1, stop_reason: None, fusion: None }, ExitRequest::Success ; "done_exits_success")] #[test_case(AgentEvent::Error { message: "boom".into() }, ExitRequest::Error ; "error_exits_error")] fn exit_on_done_flag_triggers_exit(event: AgentEvent, expected: ExitRequest) { let mut app = test_app(); @@ -1893,6 +1893,7 @@ fn streaming_status_keeps_app_animating() { usage: TokenUsage::default(), num_turns: 1, stop_reason: None, + fusion: None, })); assert!(!app.is_animating()); @@ -1910,6 +1911,7 @@ fn edge_scroll_makes_app_animating() { usage: TokenUsage::default(), num_turns: 1, stop_reason: None, + fusion: None, })); assert!(!app.is_animating()); let zone = Rect::new(0, 2, 80, 20); @@ -2023,6 +2025,7 @@ fn pending_copy_not_animating() { usage: TokenUsage::default(), num_turns: 1, stop_reason: None, + fusion: None, })); make_pending_copy(&mut app); assert!(!app.is_animating()); @@ -2221,6 +2224,7 @@ fn stale_done_does_not_drain_queue() { usage: TokenUsage::default(), num_turns: 1, stop_reason: None, + fusion: None, }, 1, )); @@ -3442,7 +3446,7 @@ fn streaming_app_with_history() -> App { } #[test_case( - AgentEvent::Done { usage: TokenUsage::default(), num_turns: 1, stop_reason: None } ; "stale_done_saves_session" + AgentEvent::Done { usage: TokenUsage::default(), num_turns: 1, stop_reason: None, fusion: None } ; "stale_done_saves_session" )] #[test_case( AgentEvent::Error { message: "timeout".into() } ; "stale_error_saves_session" @@ -3492,6 +3496,7 @@ fn done_event() -> Msg { usage: TokenUsage::default(), num_turns: 1, stop_reason: None, + fusion: None, }) } diff --git a/plugins/fusion/init.lua b/plugins/fusion/init.lua new file mode 100644 index 000000000..808263784 --- /dev/null +++ b/plugins/fusion/init.lua @@ -0,0 +1,128 @@ +-- Fusion sidekick delegation (Cognition Devin Fusion pattern). +-- Lead agent delegates via spec-quality briefs; sidekick runs in an isolated +-- cached context on a cost-aware tier (auto_tier on by default). + +local subagent = require("n00n.subagent") + +local description = + [[Delegate to a Fusion sidekick. Pass goal, constraints, and definition_of_done — not file dumps.]] + +local schema = { + type = "object", + required = { "description", "goal", "definition_of_done" }, + additionalProperties = false, + properties = { + description = { + type = "string", + description = "Short label (3-5 words).", + }, + goal = { + type = "string", + description = "What to accomplish.", + }, + constraints = { + type = "string", + description = "Scope and patterns.", + }, + definition_of_done = { + type = "string", + description = "Success checks (tests, artifacts).", + }, + escalation_triggers = { + type = "string", + description = "When to escalate to the lead.", + }, + subagent_type = { + type = "string", + description = "research (read-only) or general (edit). Default: general.", + }, + model_tier = { + type = "string", + description = "weak/medium/strong override.", + }, + model = { + type = "string", + description = "Exact model override.", + }, + auto_tier = { + type = "boolean", + description = "Tier from brief (default: true).", + }, + }, +} + +local opts = n00n.api.register_options({ + auto_tier = { default = true, desc = "Route sidekick tier from the brief." }, + default_subagent_type = { default = "general", desc = "Default subagent_type when omitted." }, +}) + +local function build_prompt(input) + local parts = { + "# Fusion sidekick brief\n", + "## Goal\n", + input.goal, + "\n", + } + if input.constraints and input.constraints ~= "" then + parts[#parts + 1] = "## Constraints\n" + parts[#parts + 1] = input.constraints + parts[#parts + 1] = "\n" + end + parts[#parts + 1] = "## Definition of done\n" + parts[#parts + 1] = input.definition_of_done + parts[#parts + 1] = "\n" + if input.escalation_triggers and input.escalation_triggers ~= "" then + parts[#parts + 1] = "## Escalate to lead when\n" + parts[#parts + 1] = input.escalation_triggers + parts[#parts + 1] = "\n" + end + parts[#parts + 1] = + "\nExecute efficiently. Prefer index/codegraph/arbor before broad reads. Return concise file:line evidence, test/lint results, and a short summary — not full file dumps." + return table.concat(parts) +end + +local function handler(input, ctx) + local subagent_type = input.subagent_type or opts.default_subagent_type + if subagent_type ~= "research" and subagent_type ~= "general" then + return { llm_output = "unknown subagent_type: " .. tostring(subagent_type), is_error = true } + end + + local auto_tier = input.auto_tier + if auto_tier == nil then + auto_tier = opts.auto_tier + end + + local prompt = build_prompt(input) + local result, err, cost, usage, model_spec = subagent.launch(ctx, { + description = input.description, + prompt = prompt, + subagent_type = subagent_type, + model_spec = input.model, + model_tier = input.model_tier, + auto_tier = auto_tier, + audience = "general_sub", + }) + + if err then + return { llm_output = err, is_error = true, cost = cost, usage = usage, model = model_spec } + end + + local footer = "" + if cost and model_spec then + footer = string.format("\n\n[sidekick cost: $%.4f · %s]", cost, model_spec) + end + + return { + llm_output = tostring(result) .. footer, + cost = cost, + usage = usage, + model = model_spec, + } +end + +n00n.api.register_tool({ + name = "fusion_delegate", + description = description, + schema = schema, + handler = handler, +}) diff --git a/site/docs/content/configuration/_index.md b/site/docs/content/configuration/_index.md index 5c894d607..a2312f920 100644 --- a/site/docs/content/configuration/_index.md +++ b/site/docs/content/configuration/_index.md @@ -58,6 +58,7 @@ All fields are optional. Typos in field names cause an error right away. | `always_yolo` | bool | `false` | Start every session with YOLO mode (skip permission prompts, deny rules still apply) | | `always_fast` | bool | `false` | Start every session with Anthropic fast mode (Opus only; ignored otherwise) | | `always_workflow` | bool | `false` | Start every session with workflow mode (task callable inside code_execution) | +| `always_fusion` | bool | `false` | Start every session with Fusion dual-lane routing (lead + sidekick) | | `always_thinking` | bool \| string | `false` | Start every session with extended thinking (true/"adaptive", "off", an effort level ("minimal" to "max"), or a token budget) | ### `ui` @@ -109,6 +110,13 @@ How many lines of output to show per tool in the UI. All values are `usize` with | `compaction_buffer` | u32 \| string | `20%` | - | Context reserved for compaction: token count or percent of the context window (e.g. "20%") | | `mcp_tool_desc_max_chars` | usize | `200` | 10 | Max MCP tool description length (characters) | +### `agent.fusion` + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `enabled` | bool | `false` | Enable Fusion dual-lane routing (lead + sidekick) for this session | +| `sidekick_tier` | string | - | Model tier for the sidekick lane (e.g. "haiku", "sonnet", "opus") | + ### `provider` | Field | Type | Default | Min | Description | @@ -177,6 +185,13 @@ n00n.setup({ | `insert_lines` | boolean | `true` | - | Provide the line-based `insert_lines` tool. | | `multiedit` | boolean | `true` | - | Provide the `multiedit` tool. | +### `plugins.fusion` + +| Field | Type | Default | Min | Description | +|-------|------|---------|-----|-------------| +| `auto_tier` | boolean | `true` | - | Route sidekick tier from the brief. | +| `default_subagent_type` | string | `"general"` | - | Default subagent_type when omitted. | + ### `plugins.glob` | Field | Type | Default | Min | Description | diff --git a/site/docs/content/tools/_index.md b/site/docs/content/tools/_index.md index dd0709b43..a5ea98d07 100644 --- a/site/docs/content/tools/_index.md +++ b/site/docs/content/tools/_index.md @@ -7,7 +7,7 @@ group = "Reference" # Tools -n00n ships with 31 built-in tools. This is the full reference. +n00n ships with 32 built-in tools. This is the full reference. ## File Operations @@ -355,6 +355,22 @@ Load all tools from a namespace. Returns the list of tools that were loaded. |-----------|------|----------|-------------| | `namespace` | string | yes | Namespace to load | +### `fusion_delegate` *(lua plugin)* + +Delegate to a Fusion sidekick. Pass goal, constraints, and definition_of_done — not file dumps. + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `description` | string | yes | | Short label (3-5 words). | +| `model_tier` | string | no | | weak/medium/strong override. | +| `model` | string | no | | Exact model override. | +| `goal` | string | yes | | What to accomplish. | +| `constraints` | string | no | | Scope and patterns. | +| `definition_of_done` | string | yes | | Success checks (tests, artifacts). | +| `auto_tier` | boolean | no | true | Tier from brief. | +| `escalation_triggers` | string | no | | When to escalate to the lead. | +| `subagent_type` | string | no | | research (read-only) or general (edit). Default: general. | + ## Web ### `webfetch` *(lua plugin)* diff --git a/specs/008-fusion-mode/plan.md b/specs/008-fusion-mode/plan.md new file mode 100644 index 000000000..b20f5033c --- /dev/null +++ b/specs/008-fusion-mode/plan.md @@ -0,0 +1,46 @@ +# Fusion Mode — Implementation Plan + +Tracked in Linear: **[N00N-69](https://linear.app/n00n/issue/N00N-69/native-fusion-dual-lane-routing-devin-fusion-parity)** (epic) + +## Research basis (Cognition Devin Fusion, June–July 2026) + +1. **Sidekick pattern**: two parallel fully-capable agents (frontier lead + cheap sidekick), each with own persistent cached context. +2. **Dynamic mid-session routing**: lightweight classifiers escalate/de-escalate; model switches at compaction (cache miss already happening). +3. **Lead tuning**: minimal actions; delegate early with spec-quality briefs; own plan, ambiguity, final review, commit. +4. **When delegation fails**: short tasks, serial debugging chains. + +## n00n-native improvements + +| Cognition | n00n twist | +|-----------|------------| +| Fixed sidekick model | `ModelTier` routing + `auto_tier` on delegate | +| Proprietary classifiers | Lexical `classify_delegation` + `route_tier` in Lua | +| Devin-only harness | Works with any provider via `task`/`fusion_delegate` infra | +| Hidden routing | `--fusion` CLI + `[agent.fusion]` config + per-session toggle | +| No policy hooks | Skill policy + compaction hooks integration | +| Opaque cost | Lead vs sidekick usage in agent stats (follow-up) | + +## Deliverables + +- [x] `n00n-agent/src/fusion/` routing core + tests +- [x] `FusionConfig` in `n00n-config` +- [x] `plugins/fusion/init.lua` — `fusion_delegate` tool +- [x] Compaction-boundary model switch in `run.rs` +- [x] `--fusion` / `always_fusion` CLI + config +- [x] Devin `list_models` uses cached ACP config_options +- [ ] Docs (`just gen-docs`) — [N00N-75](https://linear.app/n00n/issue/N00N-75/fusion-mode-docs-gen-docs) + +## Linear issues + +| ID | Title | Status | +|----|-------|--------| +| [N00N-69](https://linear.app/n00n/issue/N00N-69) | Epic: Native Fusion dual-lane routing | In Progress | +| [N00N-70](https://linear.app/n00n/issue/N00N-70) | Fusion routing core | Done | +| [N00N-71](https://linear.app/n00n/issue/N00N-71) | Config + CLI toggle | Done | +| [N00N-72](https://linear.app/n00n/issue/N00N-72) | fusion_delegate plugin | Done | +| [N00N-73](https://linear.app/n00n/issue/N00N-73) | Compaction routing hook | Done | +| [N00N-74](https://linear.app/n00n/issue/N00N-74) | Devin cached model list | Done | +| [N00N-75](https://linear.app/n00n/issue/N00N-75) | Fusion docs | Done | +| [N00N-76](https://linear.app/n00n/issue/N00N-76) | Lead vs sidekick cost stats | In Progress | +| [N00N-77](https://linear.app/n00n/issue/N00N-77) | Tool-error escalation | Done | +| [N00N-78](https://linear.app/n00n/issue/N00N-78) | Open draft PR | In Review — [PR #180](https://github.com/w0wl0lxd/n00n/pull/180) | diff --git a/src/cli.rs b/src/cli.rs index ad4bec15c..d72ff816b 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -193,6 +193,10 @@ pub struct Cli { #[arg(long, hide = true)] pub thinking_display: Option, + /// Enable Fusion dual-lane routing (frontier lead + cost-aware sidekick) + #[arg(long, global = true)] + pub fusion: bool, + /// Initial prompt (reads stdin if piped) #[arg(value_name = "PROMPT")] pub initial_prompt: Option, @@ -584,6 +588,25 @@ mod tests { )); } + #[test] + fn agent_run_accepts_fusion_flag_after_subcommand() { + let cli = Cli::parse_from([ + "n00n", + "agent", + "run", + "--prompt", + "delegate mechanical work", + "--fusion", + ]); + assert!(cli.fusion); + assert!(matches!( + cli.command, + Some(Command::Agent { + action: AgentCommand::Run { .. } + }) + )); + } + #[test] fn agent_message_parse() { let cli = Cli::parse_from(["n00n", "agent", "message", "agent-id", "hello"]); diff --git a/src/cmd/agent.rs b/src/cmd/agent.rs index 0eb3b08fd..4e082ea85 100644 --- a/src/cmd/agent.rs +++ b/src/cmd/agent.rs @@ -347,6 +347,7 @@ pub struct AgentRunOptions<'a> { pub task_description: Option<&'a str>, pub yolo: bool, pub no_jit: bool, + pub fusion: bool, } struct PreparedEnv { @@ -364,7 +365,12 @@ struct PreparedEnv { prompt_slots: ResolvedSlots, } -fn prepare_agent_env(model_arg: Option<&str>, yolo: bool, no_jit: bool) -> Result { +fn prepare_agent_env( + model_arg: Option<&str>, + yolo: bool, + no_jit: bool, + fusion: bool, +) -> Result { let storage = StateDir::resolve().context("resolve data directory")?; n00n_providers::model_registry::load_from_storage(&storage); @@ -389,6 +395,9 @@ fn prepare_agent_env(model_arg: Option<&str>, yolo: bool, no_jit: bool) -> Resul if yolo || config.always_yolo { config.permissions.yolo = true; } + if fusion || config.always_fusion { + config.agent.fusion.enabled = true; + } config.validate()?; plugin_host @@ -443,7 +452,7 @@ async fn write_line(writer: &mut W, line: &str) -> Res } pub fn run(opts: &AgentRunOptions<'_>, json: bool) -> Result<()> { - let env = prepare_agent_env(opts.model, opts.yolo, opts.no_jit)?; + let env = prepare_agent_env(opts.model, opts.yolo, opts.no_jit, opts.fusion)?; let message = build_message( opts.mode, opts.prompt, @@ -694,7 +703,7 @@ fn server_unix(opts: &AgentRunOptions<'_>, agent_id: Option) -> Result<( _ => return Err(eyre!("fork failed")), } - let env = prepare_agent_env(opts.model, opts.yolo, opts.no_jit)?; + let env = prepare_agent_env(opts.model, opts.yolo, opts.no_jit, opts.fusion)?; let message = build_message( opts.mode, opts.prompt, diff --git a/src/cmd/agent_stub.rs b/src/cmd/agent_stub.rs index 2eb594b8d..1a9d03f10 100644 --- a/src/cmd/agent_stub.rs +++ b/src/cmd/agent_stub.rs @@ -20,6 +20,7 @@ pub struct AgentRunOptions<'a> { pub task_description: Option<&'a str>, pub yolo: bool, pub no_jit: bool, + pub fusion: bool, } fn unsupported() -> Result<()> { diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs index f8bdc76d5..04286a50b 100644 --- a/src/cmd/mod.rs +++ b/src/cmd/mod.rs @@ -94,6 +94,7 @@ pub fn dispatch(cli: Cli) -> Result<()> { task_description: task_description.as_deref(), yolo: cli.permission_flags.yolo, no_jit: cli.plugin_flags.no_jit, + fusion: cli.fusion, }; if background { agent::server(&run_opts, id)?; diff --git a/src/cmd/tui.rs b/src/cmd/tui.rs index a9602add8..144f91c06 100644 --- a/src/cmd/tui.rs +++ b/src/cmd/tui.rs @@ -108,6 +108,9 @@ fn load_config(plugin_host: &PluginHost, cli: &Cli, cwd: &Path) -> Result { usage: &'a mut TokenUsage, stop_reason: &'a mut Option, session_id: &'a SessionRef, + fusion: &'a mut Option, } struct ResultSummary { @@ -170,6 +173,7 @@ struct ResultSummary { session_id: SessionRef, usage: TokenUsage, total_cost_usd: f64, + fusion: Option, } fn load_inputs( @@ -285,6 +289,7 @@ pub fn run(model: &Model, args: PrintArgs<'_>) -> Result<()> { let mut num_turns: u32 = 0; let mut usage = TokenUsage::default(); let mut stop_reason: Option = None; + let mut fusion: Option = None; let mut state = PrintState { verbose_out: &mut verbose_out, @@ -295,6 +300,7 @@ pub fn run(model: &Model, args: PrintArgs<'_>) -> Result<()> { usage: &mut usage, stop_reason: &mut stop_reason, session_id: &session_id, + fusion: &mut fusion, }; while let Ok(envelope) = smol::block_on(event_rx.recv_async()) { @@ -320,7 +326,10 @@ pub fn run(model: &Model, args: PrintArgs<'_>) -> Result<()> { } let duration_ms = start.elapsed().as_millis(); - let total_cost_usd = usage.cost(&model.pricing, fast); + let total_cost_usd = fusion.as_ref().map_or_else( + || usage.cost(&model.pricing, fast), + |stats| stats.lead_cost + stats.sidekick_cost, + ); output_result( format, std::mem::take(&mut verbose_out), @@ -333,6 +342,7 @@ pub fn run(model: &Model, args: PrintArgs<'_>) -> Result<()> { session_id, usage, total_cost_usd, + fusion, }, ) } @@ -422,10 +432,12 @@ fn handle_print_event( usage: u, num_turns: turns, stop_reason: sr, + fusion, } => { *state.num_turns = *turns; *state.usage = *u; *state.stop_reason = *sr; + state.fusion.clone_from(fusion); return Ok(true); } AgentEvent::Error { message } => { @@ -451,11 +463,34 @@ fn output_result( session_id, usage, total_cost_usd, + fusion, } = summary; match format { OutputFormat::Text => { print!("{result_text}"); + if let Some(stats) = fusion { + eprintln!( + "fusion: lead=${:.4} sidekick=${:.4} total=${:.4} \ + lead_tokens={{in:{} out:{} cache_r:{} cache_w:{}}} \ + sidekick_tokens={{in:{} out:{} cache_r:{} cache_w:{}}} \ + final_lane={} delegations={} compacts={}", + stats.lead_cost, + stats.sidekick_cost, + stats.lead_cost + stats.sidekick_cost, + stats.lead_usage.input, + stats.lead_usage.output, + stats.lead_usage.cache_read, + stats.lead_usage.cache_creation, + stats.sidekick_usage.input, + stats.sidekick_usage.output, + stats.sidekick_usage.cache_read, + stats.sidekick_usage.cache_creation, + stats.final_lane.as_str(), + stats.delegation_count, + stats.compact_count, + ); + } } OutputFormat::Json | OutputFormat::StreamJson => { let result = PrintResult { @@ -478,6 +513,29 @@ fn output_result( } _ => println!("{}", serde_json::to_string(&result)?), } + // Keep Claude Code wire format intact; emit Fusion on stderr. + if let Some(stats) = fusion { + eprintln!( + "fusion: lead=${:.4} sidekick=${:.4} total=${:.4} \ + lead_tokens={{in:{} out:{} cache_r:{} cache_w:{}}} \ + sidekick_tokens={{in:{} out:{} cache_r:{} cache_w:{}}} \ + final_lane={} delegations={} compacts={}", + stats.lead_cost, + stats.sidekick_cost, + stats.lead_cost + stats.sidekick_cost, + stats.lead_usage.input, + stats.lead_usage.output, + stats.lead_usage.cache_read, + stats.lead_usage.cache_creation, + stats.sidekick_usage.input, + stats.sidekick_usage.output, + stats.sidekick_usage.cache_read, + stats.sidekick_usage.cache_creation, + stats.final_lane.as_str(), + stats.delegation_count, + stats.compact_count, + ); + } } } diff --git a/src/sdk_mode.rs b/src/sdk_mode.rs index 3b23e887a..b0a4a019f 100644 --- a/src/sdk_mode.rs +++ b/src/sdk_mode.rs @@ -1004,6 +1004,7 @@ impl EventPump { result: String, num_turns: u32, usage: TokenUsage, + fusion: Option<&n00n_agent::FusionUsageStats>, ) -> Result<()> { let (duration_ms, total_cost_usd) = { let shared = self @@ -1012,7 +1013,7 @@ impl EventPump { .map_err(|e| eyre!("mutex poisoned: {e}"))?; ( shared.turn_start.elapsed().as_millis(), - usage.cost(&shared.model.pricing, self.fast), + result_cost_usd(&usage, &shared.model, self.fast, fusion), ) }; self.writer.emit(WireInner::Result(ResultPayload { @@ -1074,8 +1075,9 @@ impl EventPump { AgentEvent::Done { usage, num_turns, - stop_reason: _, - } => self.handle_done(usage, *num_turns), + fusion, + .. + } => self.handle_done(usage, *num_turns, fusion.as_ref()), AgentEvent::Error { message } => self.handle_error(message), } } @@ -1204,13 +1206,18 @@ impl EventPump { })) } - fn handle_done(&mut self, usage: &TokenUsage, num_turns: u32) -> Result<()> { + fn handle_done( + &mut self, + usage: &TokenUsage, + num_turns: u32, + fusion: Option<&n00n_agent::FusionUsageStats>, + ) -> Result<()> { let result = mem::take(&mut self.result_text); - self.emit_turn_result(false, result, num_turns, *usage) + self.emit_turn_result(false, result, num_turns, *usage, fusion) } fn handle_error(&mut self, message: &str) -> Result<()> { - self.emit_turn_result(true, message.to_string(), 0, TokenUsage::default()) + self.emit_turn_result(true, message.to_string(), 0, TokenUsage::default(), None) } } @@ -1236,6 +1243,18 @@ fn map_tool_names_in_content(content: &Value) -> Value { } } +fn result_cost_usd( + usage: &TokenUsage, + model: &Model, + fast: bool, + fusion: Option<&n00n_agent::FusionUsageStats>, +) -> f64 { + fusion.map_or_else( + || usage.cost(&model.pricing, fast), + |stats| stats.lead_cost + stats.sidekick_cost, + ) +} + #[cfg(test)] mod tests { use super::*; @@ -1599,4 +1618,42 @@ mod tests { assert_eq!(mapped[1]["name"], "Read"); assert_eq!(mapped[2]["name"], "unknown_native"); } + + #[test] + fn mixed_lane_fusion_cost_uses_per_lane_totals() { + let model = Model::from_spec("anthropic/claude-sonnet-4-20250514").unwrap(); + let usage = TokenUsage { + input: 1_000, + output: 200, + cache_creation: 0, + cache_read: 0, + }; + let stats = n00n_agent::FusionUsageStats { + lead_cost: 0.10, + sidekick_cost: 0.02, + lead_usage: TokenUsage { + input: 800, + output: 100, + ..TokenUsage::default() + }, + sidekick_usage: TokenUsage { + input: 200, + output: 100, + ..TokenUsage::default() + }, + delegation_count: 1, + compact_count: 0, + final_lane: n00n_agent::FusionLane::Sidekick, + }; + let fusion_cost = result_cost_usd(&usage, &model, false, Some(&stats)); + let lead_only_reprice = usage.cost(&model.pricing, false); + assert!((fusion_cost - 0.12).abs() < f64::EPSILON); + assert!( + (fusion_cost - lead_only_reprice).abs() > f64::EPSILON, + "mixed-lane cost must not collapse to lead-model reprice of total usage" + ); + assert!( + (result_cost_usd(&usage, &model, false, None) - lead_only_reprice).abs() < f64::EPSILON + ); + } }